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

@ -1,7 +1,78 @@
from hub_core.schemas.agent_message import MessageCreate, MessageRead, MessageReply
"""Agent message schemas.
The base shapes come from hub-core. STATE-WP-0093 extends them locally with
broadcast kinds, expiry, supersede and per-reader receipt fields; direct
messages keep the hub-core semantics (the new fields stay at their defaults).
"""
import uuid
from datetime import datetime
from hub_core.schemas.agent_message import MessageCreate as _HubMessageCreate
from hub_core.schemas.agent_message import MessageRead as _HubMessageRead
from hub_core.schemas.agent_message import MessageReply
from pydantic import BaseModel
class MessageCreate(_HubMessageCreate):
# Only meaningful for broadcasts: news | standing. A broadcast without a
# kind defaults to ``news``; direct messages must leave it unset or
# ``message``.
kind: str | None = None
expires_at: datetime | None = None
supersedes_id: uuid.UUID | None = None
class MessageRead(_HubMessageRead):
kind: str = "message"
expires_at: datetime | None = None
supersedes_id: uuid.UUID | None = None
# Per-reader receipt state; populated for broadcasts when the request is
# scoped to a reader (``to_agent`` on list, ``reader`` on mark-read/ack).
# For such responses ``read_at`` is the reader's own read time.
reader: str | None = None
delivered_at: datetime | None = None
acknowledged_at: datetime | None = None
class MessageMarkRead(BaseModel):
reader: str | None = None
ack: bool = False
class MessageAck(BaseModel):
agent: str
class NoticeStatus(BaseModel):
id: uuid.UUID
from_agent: str
subject: str
body: str
created_at: datetime
expires_at: datetime | None = None
supersedes_id: uuid.UUID | None = None
acknowledged: list[str] = []
delivered_only: list[str] = []
unreached: list[str] = []
acked_count: int = 0
delivered_only_count: int = 0
unreached_count: int = 0
class StandingNoticeDigest(BaseModel):
id: uuid.UUID
subject: str
expires_at: datetime | None = None
acked: int = 0
pending: int = 0
__all__ = [
"MessageAck",
"MessageCreate",
"MessageMarkRead",
"MessageRead",
"MessageReply",
"NoticeStatus",
"StandingNoticeDigest",
]

View file

@ -10,6 +10,7 @@ from api.schemas.progress_event import ProgressEventRead
from api.schemas.task import TaskRead
from api.schemas.topic import TopicWithWorkstreams
from api.schemas.suggestion import RankedSuggestionDigest
from api.schemas.agent_message import StandingNoticeDigest
from api.schemas.workstream import WorkstreamWithDeps
from api.schemas.ops_run import OpsRunProjection
@ -94,6 +95,7 @@ class StateSummary(BaseModel):
open_capability_requests: int = 0
ranked_suggestions: list[RankedSuggestionDigest] = []
ops_runs: OpsRunProjection | None = None
standing_notices: list[StandingNoticeDigest] = []
class DashboardWorkplanRow(BaseModel):