STATE-WP-0093: per-recipient broadcast receipts and standing notices (T01-T06).
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 53s

Founder approved T01 on 2026-09-22 (D2, D3, D6 as recommended).
- T02: message_receipts table; kind/expires_at/supersedes_id on
  agent_messages; migration d7e8f9a0b1c2 archives existing broadcasts,
  leaves direct messages untouched, reversible.
- T03: reader-aware mark-read (unattributed broadcast mark-read is a
  metered, deprecated no-op), delivery receipts on the scoped unread inbox,
  POST /messages/{id}/ack, news/standing kinds, expiry, supersede, broadcast
  archive no longer stamps read_at, reply writes the replier's receipt.
- T04 (state-hub part): Codex MCP reader param and acknowledge_notice;
  hub-core part handed off (message 69fc387c).
- T05: GET /messages/notices, standing_notices in /state/summary,
  dashboard standing-notices panel.
- T06: 17 new tests; full suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 63291@bnt-lap001
Assistant-Session: 8bd77868-ca68-4f49-bb1e-d539ecc0d703
This commit is contained in:
tegwick 2026-09-22 00:26:33 +02:00
parent 740068fcf1
commit ef541f58cf
18 changed files with 1256 additions and 52 deletions

View file

@ -0,0 +1,212 @@
"""Per-recipient broadcast receipts and standing notices (STATE-WP-0093).
Broadcast visibility is per reader: a ``message_receipts`` row per
(message, agent) records delivery, read and acknowledgement. Direct messages
never get receipts and keep the global ``AgentMessage.read_at``.
- ``news`` broadcasts stay in a reader's unread inbox until that reader has
any receipt (delivery at orientation is enough).
- ``standing`` broadcasts stay until the reader acknowledges, the notice
expires, or it is superseded (which archives it).
"""
from __future__ import annotations
import uuid
from collections.abc import Iterable, Sequence
from datetime import datetime, timedelta, timezone
from sqlalchemy import and_, exists, func, not_, or_, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from api.models.agent_message import BROADCAST, AgentMessage, MessageReceipt
from api.models.managed_repo import ManagedRepo
from api.schemas.agent_message import NoticeStatus, StandingNoticeDigest
NEWS_DEFAULT_TTL = timedelta(days=30)
def utcnow() -> datetime:
return datetime.now(timezone.utc)
def is_broadcast(message: AgentMessage) -> bool:
return message.to_agent == BROADCAST
def not_expired(now: datetime):
return or_(AgentMessage.expires_at.is_(None), AgentMessage.expires_at > now)
def broadcast_unread_clause(reader_values: Sequence[str], now: datetime):
"""SQL clause: broadcast is still unread for a reader (any of its slugs)."""
has_any_receipt = exists().where(
and_(
MessageReceipt.message_id == AgentMessage.id,
MessageReceipt.agent.in_(reader_values),
)
)
has_ack = exists().where(
and_(
MessageReceipt.message_id == AgentMessage.id,
MessageReceipt.agent.in_(reader_values),
MessageReceipt.acknowledged_at.is_not(None),
)
)
return and_(
AgentMessage.to_agent == BROADCAST,
not_expired(now),
or_(
and_(AgentMessage.kind == "standing", not_(has_ack)),
and_(AgentMessage.kind != "standing", not_(has_any_receipt)),
),
)
async def upsert_receipt(
session: AsyncSession,
message_ids: Iterable[uuid.UUID],
agent: str,
*,
delivered: bool = False,
read: bool = False,
acknowledged: bool = False,
at: datetime | None = None,
) -> None:
"""Idempotently stamp receipt fields; the first timestamp wins."""
ids = list(message_ids)
if not ids or not agent or agent == BROADCAST:
return
at = at or utcnow()
rows = [
{
"message_id": mid,
"agent": agent,
"delivered_at": at if delivered else None,
"read_at": at if (read or acknowledged) else None,
"acknowledged_at": at if acknowledged else None,
}
for mid in ids
]
stmt = pg_insert(MessageReceipt).values(rows)
table = MessageReceipt.__table__
stmt = stmt.on_conflict_do_update(
index_elements=[table.c.message_id, table.c.agent],
set_={
col: func.coalesce(table.c[col], stmt.excluded[col])
for col in ("delivered_at", "read_at", "acknowledged_at")
},
)
await session.execute(stmt)
async def receipts_for(
session: AsyncSession,
message_ids: Iterable[uuid.UUID],
reader_values: Sequence[str],
) -> dict[uuid.UUID, dict[str, datetime | None]]:
"""Merged receipt state per message across a reader's historical slugs."""
ids = list(message_ids)
if not ids:
return {}
result = await session.execute(
select(MessageReceipt).where(
MessageReceipt.message_id.in_(ids),
MessageReceipt.agent.in_(reader_values),
)
)
merged: dict[uuid.UUID, dict[str, datetime | None]] = {}
for receipt in result.scalars().all():
state = merged.setdefault(
receipt.message_id,
{"delivered_at": None, "read_at": None, "acknowledged_at": None},
)
for field in state:
value = getattr(receipt, field)
if value is not None and (state[field] is None or value < state[field]):
state[field] = value
return merged
async def _active_repo_slugs(session: AsyncSession) -> list[str]:
result = await session.execute(
select(ManagedRepo.slug).where(ManagedRepo.status == "active").order_by(ManagedRepo.slug)
)
return list(result.scalars().all())
async def live_standing_notices(
session: AsyncSession, now: datetime | None = None
) -> list[AgentMessage]:
now = now or utcnow()
result = await session.execute(
select(AgentMessage)
.where(
AgentMessage.to_agent == BROADCAST,
AgentMessage.kind == "standing",
AgentMessage.archived_at.is_(None),
not_expired(now),
)
.order_by(AgentMessage.created_at.desc())
)
return list(result.scalars().all())
async def notice_statuses(session: AsyncSession) -> list[NoticeStatus]:
notices = await live_standing_notices(session)
if not notices:
return []
repos = await _active_repo_slugs(session)
result = await session.execute(
select(MessageReceipt).where(
MessageReceipt.message_id.in_([n.id for n in notices])
)
)
by_notice: dict[uuid.UUID, list[MessageReceipt]] = {}
for receipt in result.scalars().all():
by_notice.setdefault(receipt.message_id, []).append(receipt)
statuses: list[NoticeStatus] = []
for notice in notices:
receipts = by_notice.get(notice.id, [])
acked = sorted({r.agent for r in receipts if r.acknowledged_at is not None})
seen = {r.agent for r in receipts}
delivered_only = sorted(seen - set(acked))
unreached = [slug for slug in repos if slug not in seen]
statuses.append(
NoticeStatus(
id=notice.id,
from_agent=notice.from_agent,
subject=notice.subject,
body=notice.body,
created_at=notice.created_at,
expires_at=notice.expires_at,
supersedes_id=notice.supersedes_id,
acknowledged=acked,
delivered_only=delivered_only,
unreached=unreached,
acked_count=len(acked),
delivered_only_count=len(delivered_only),
unreached_count=len(unreached),
)
)
return statuses
async def standing_notice_digests(session: AsyncSession) -> list[StandingNoticeDigest]:
statuses = await notice_statuses(session)
if not statuses:
return []
repos = set(await _active_repo_slugs(session))
digests = []
for status in statuses:
acked_repos = len(repos.intersection(status.acknowledged))
digests.append(
StandingNoticeDigest(
id=status.id,
subject=status.subject,
expires_at=status.expires_at,
acked=acked_repos,
pending=len(repos) - acked_repos,
)
)
return digests