docs(release): prepare 0.27.0 — curated changelog, rag/map sweep, concat rebuild script (#662)

This commit is contained in:
Renzo F
2026-07-23 11:33:02 +02:00
committed by GitHub
parent d8841279a8
commit dd4c3c3ed0
26 changed files with 501 additions and 81 deletions
+14 -3
View File
@@ -13,6 +13,7 @@ This slice implements RoboCo's formal-notification backbone: NotificationService
| roboco/services/telegram_inbound.py | V2: `TelegramInboundEngine` — getUpdates poll cycle (offset persisted as `telegram_last_update_id` in system_settings), chat-id AND sender-id authorization, `BOT_COMMANDS` registry driving both `/help` and a once-per-process `setMyCommands` sync, `/status`/`/queue`/`/task`/`/agents`/`/usage`/`/blocked`/`/secretary`/`/newtask`/`/end` command router, `apv|rej:<kind>:<id8>` callback codec, force_reply reject/approve-notes state machine (in-memory `_PENDING_REPLIES`, TTL), per-kind dispatch to the SAME service methods the CEO-gated HTTP routes call (task/release/xpost/video/roadmap), `via=telegram` audit rows | 1295 |
| roboco/services/tg_cockpit.py | V4: `TgCockpitService` — DB-only, one-round-trip aggregate for the Mini App home screen (`today()`) and the bot's `/agents` command (`fleet()`); no live GitHub calls, no orchestrator singleton | 217 |
| roboco/services/telegram_bridge.py | V4: bridges `/secretary`/`/newtask` Telegram chat into the SAME in-process live-chat runtimes the panel drives — a per-chat consumer task drains `PrompterLiveRegistry.stream`, forwards `turn_end`/`draft`/`batch`/`error` events as Telegram messages, and routes a `draft` event's Send-to-Board confirm through `PrompterService.confirm_live_draft` + registry `park` | 291 |
| roboco/services/notification_text.py | `task_display` (title-first, `#id8` fallback) / `agent_display` (identity-map slug first, DB lookup fallback) — feeds every notification producer so subjects/bodies read `Task <title>` / `<agent-slug>` instead of a raw UUID; tool-call examples (`unblock('<uuid>')`) keep the raw id on purpose, agents need it | 65 |
## Data Flow
Two create-and-deliver paths exist. (A) NotificationService._create_notification (notification.py) opens its OWN get_db_context, resolves sender + recipients to UUIDs via _resolve_agent_uuid, runs the Redis re-fire guard (all_recipients_recently_notified), then DB purpose-dedup (ack-required types only, same sender+type+task+overlapping recipients not yet acked), builds NotificationTable with requires_ack from ACK_REQUIRED_BY_TYPE, flushes, calls NotificationDeliveryService.deliver (which defers NOTIFICATION_SENT bus events to after_commit), and finally commits — the commit triggers the deferred bus drain. (B) NotificationDeliveryService._persist_and_deliver (notification_delivery.py) is used by the task-handoff helpers (notify_pm_of_block, escalate_and_notify, etc.): it runs inside the CALLER's open transaction, applies only the Redis re-fire guard (no DB purpose-dedup), adds+flushes+delivers, and leaves the commit to the caller (api/routes/tasks.py). Sweeper loops in the orchestrator call sweep_expired_notifications periodically. Real-time push: deliver defers per-recipient NOTIFICATION_SENT events; the after_commit listener schedules _drain_pending_publishes which publishes to the StreamEventBus; websocket_bridge forwards to /ws/notifications/{id} sockets. ACKs flow acknowledge → acked_by/read_by mutation + NOTIFICATION_ACKED event.
@@ -74,7 +75,7 @@ notification
├── EscalationError / EscalationOutcome / BlockerDetails
├── deliver (delivered_at in-tx, defer per-recipient events)
├── get_notification / _notification_is_fully_acked / _log_expired_notification
├── sweep_expired_notifications (log stale unacked)
├── sweep_expired_notifications / _maybe_reescalate / _claim_reescalation_slot / _re_escalate_unacked (exponential backoff, CAS claim before delivery)
├── get_pending_for_agent / get_unacknowledged_for_agent / get_notification_count
├── acknowledge / mark_read / bulk_acknowledge / get_ack_status / get_delivery_summary
├── Task-handoff / audit-bridge notifications
@@ -110,15 +111,19 @@ telegram_inbound.py (TelegramInboundEngine, V2)
| Name | File | Trigger |
|---|---|---|
| NotificationService.send_*_notification | roboco/services/notification.py | TaskService / orchestrator lifecycle transitions (blocker, qa-ready, docs, a2a, board-review) |
| NotificationService.send_ack_notification | roboco/services/notification.py | gateway `notify` content verb (PM/Board only) |
| NotificationService.send_ack_notification | roboco/services/notification.py | gateway `notify` content verb (PM/Board only); optional `db_session` param lets a caller whose OWN open transaction just created the referenced task (e.g. the release engine's just-originated proposal) join that transaction, so the `related_task_id` FK doesn't reject an insert made through a fresh session against an as-yet-uncommitted task |
| NotificationDeliveryService.notify_pm_of_block / escalate_and_notify / notify_ceo_of_escalation / notify_auditor_of_rework | roboco/services/notification_delivery.py | api/routes/tasks.py i_am_blocked / escalate / ceo-approval routes; TaskService._alert_auditor_of_rework at QA-fail / rework chokepoints |
| NotificationDeliveryService.acknowledge / list_for_agent / get_for_recipient_and_mark_read | roboco/services/notification_delivery.py | api/routes/notifications.py ACK + list endpoints |
| sweep_expired_notifications | roboco/services/notification_delivery.py | orchestrator periodic loop (orchestrator.py:5780) |
| `reescalation_decision` | roboco/foundation/policy/communications.py:123 | Pure due/wait/capped decision for one notification (`now`, `expires_at`, `count`, `last_reescalated_at`, `ReescalationPolicy`) — called by `_maybe_reescalate` before any delivery is attempted |
| `NotificationDeliveryService._claim_reescalation_slot` | roboco/services/notification_delivery.py:443 | Compare-and-set `UPDATE ... WHERE reescalation_count = :n` claimed BEFORE delivery — two sweep ticks racing the same row can never both deliver; the slot is consumed (count bumped) even on a subsequent delivery failure, so a permanently-broken chain still counts toward the cap instead of looping forever |
| sweep_expired_notifications | roboco/services/notification_delivery.py:367 | orchestrator periodic loop (orchestrator.py:5780); re-escalates a still-unacked row only when `reescalation_decision` says it's due, not every tick |
| TelegramInboundEngine.run_cycle | roboco/services/telegram_inbound.py | orchestrator `_telegram_poll_loop` (default off, `telegram_enabled` AND `telegram_inbound_enabled`) |
## Config Flags
- settings.redis_url — Redis URL used by notification_dedup for the SET-NX re-fire guard (derived from ROBOCO_REDIS_HOST/_PORT)
- `ROBOCO_NOTIFICATION_ACK_TTL_HOURS` (default `48`, `ge=0`) — hours until an ack-required notification's `expires_at` is stamped at creation (`_create_notification`, config.py:270); consumed by `NotificationDeliveryService.sweep_expired_notifications`'s re-escalation. `0` disables stamping entirely (`expires_at` stays `NULL`, legacy behavior — never expires). Only ack-required notifications (per `ACK_REQUIRED_BY_TYPE`) get a deadline; informational ones never do regardless of this setting.
- `ROBOCO_NOTIFICATION_REESCALATION_BASE_SECONDS` (default `3600`, `ge=1`) — base interval for the re-escalation backoff: the first re-escalation fires at expiry, each one after that doubles the wait from this base (1h, 2h, 4h, 8h, ...) capped at 24h between attempts.
- `ROBOCO_NOTIFICATION_MAX_REESCALATIONS` (default `5`, `ge=1`) — hard cap on re-escalations per notification; past this many a still-unacked row is logged once as permanently-unacked and never re-escalated again.
- `telegram_enabled` (default off) — V1 master switch; `_notify_telegram` no-ops without it AND stored credentials.
- `telegram_inbound_enabled` (default off, sub-switch on top of `telegram_enabled`) — V2: arms `TelegramInboundEngine.run_cycle` (the poll loop) and makes escalation DMs carry an actionable keyboard; with it off the bot only sends, never listens, and any inline button on an old message is inert.
- `telegram_poll_interval_seconds` (5.0) / `telegram_poll_timeout_seconds` (25, Bot API long-poll `timeout`) / `telegram_max_updates_per_cycle` (50) / `telegram_pending_reply_ttl_seconds` (300) — V2 poll-loop tuning.
@@ -159,6 +164,12 @@ telegram_inbound.py (TelegramInboundEngine, V2)
> `cd978d11`+fixes (2026-07-18, wave-13): Telegram sends are HTML-styled — `_esc` (text nodes) / `_esc_attr` (href attributes) escaping discipline, balance-aware `_truncate`, `parse_mode`/`disable_link_preview` on the client; new `notify_ceo_of_queue_item` pushes a styled keyboard DM at each held-draft origination (release/x/video engines + `propose_roadmap`), sharing `telegram_inbound.render_queue_item_text`.
> `3b9fd0e0`+`11915f36` (PR #551, Telegram V2): `3b9fd0e0` adds `telegram_inbound.py` (new file, `TelegramInboundEngine`), extends `telegram_client.py` with `get_updates`/`answer_callback_query`/`edit_message_reply_markup`/`edit_message_text`, adds `actionable=True` to `_notify_telegram` (escalation only) so the DM carries an Approve/Reject/Open keyboard, and wires the orchestrator's `_telegram_poll_loop`. `11915f36` closes a live-reproduced approve-after-reject hole reachable via a stale Telegram button (or the pre-existing HTTP routes for X/video): `ReleaseProposalService.approve()` now refuses CANCELLED (`already_rejected`) and COMPLETED (`already_published`) proposals via a new `_approve_precheck`, `.reject()` refuses COMPLETED by raising a new `TaskAlreadyCompletedError`, and `XPostService`/`VideoPostService.approve()` each add a CANCELLED pre-lock-and-under-lock guard returning `already_rejected`. Also adds `_authorized_sender` (chat-id auth is defense-in-depth'd with a sender-id check) and widens `_resolve_task`'s search limit 10→50 so a genuine id-prefix hit can't be pushed out by newer title/description matches.
> `baa87d58`+`c7605b0d` (2026-07-19, PR #576 + #582, Telegram Mini App V4): new `tg_cockpit.py` — `TgCockpitService.today()` (tg_cockpit.py:59) assembles `needs_you`/`fleet`/`spend`/`velocity`/`ship` in one DB-only round trip backing `GET /api/telegram/today` (`api/routes/telegram.py:89`, `require_ceo_role` + 30/60s rate limit); `TgCockpitService.fleet()` (tg_cockpit.py:110) is shared verbatim by the bot's new `/agents` command. New `telegram_bridge.py` — `BridgeSession` (per-chat, in-memory) lifecycle via `start_secretary`/`start_intake`, a sole-consumer `_consume` task draining `PrompterLiveRegistry.stream`, `_forward_event` turning `turn_end`/`draft`/`batch`/`error` stream events into Telegram messages, and `mark_parked`/`discard_draft` routing a draft's Send-to-Board confirm through `PrompterService.confirm_live_draft(route="board")` + registry `park`; `sweep_idle()` reuses `settings.interactive_idle_reap_seconds` and skips parked sessions. `telegram_inbound.py` gains `BOT_COMMANDS` (a single registry driving both `/help`'s `_HELP_TEXT` and a once-per-process `client.set_my_commands` sync via `TelegramInboundEngine._ensure_commands_menu`, called from `run_cycle()`) plus `/agents` (`_render_agents`, calls `TgCockpitService.fleet()`), `/usage` (`_render_usage`, `UsageService.get_today_summary`), `/blocked` (`_render_blocked`, capped `awaiting_ceo_approval`+`blocked` lists with deep-linked rows), and `/secretary`/`/newtask`/`/end` (dispatch straight into `telegram_bridge.py`). `telegram_client.py` gains `set_my_commands` (`LiveTelegramClient`, best-effort `httpx.HTTPError`-suppressed) + a `NullTelegramClient` no-op. No orchestrator wiring changed — `_telegram_poll_loop`/`_run_telegram_poll_cycle` are byte-for-byte unchanged; the bridge's idle sweep and the commands sync both run *inside* the existing `run_cycle()` tick. New response schemas in `api/schemas/telegram.py`: `TodayTaskItem`/`TodayNeedsYou`/`TodayFleetAgent`/`TodayFleet`/`TodaySpend` (gains `series`/`delta_pct` in the `c7605b0d` follow-up)/`TodayVelocity` (new in `c7605b0d`)/`TodayShip`/`TelegramTodayResponse`.
> `1d5a8e84` (#652, "exponential backoff + CAS claim for expired-unacked re-escalation"): `sweep_expired_notifications` used to re-escalate EVERY expired unacked row on EVERY ~60s tick forever — the live incident was 3 fresh blocker escalations + Telegram DMs per minute from a static stale pile. Migration 079 adds `reescalation_count`/`last_reescalated_at`/`reescalation_delivered_count`; the due/wait/capped decision is the pure `reescalation_decision` (`docs/map/foundation-policy-misc.md`); `_claim_reescalation_slot`'s compare-and-set claims the attempt BEFORE delivery is even attempted (NOT backstopped by the loop-prone-type Redis dedup — `BLOCKER_ESCALATION`, the type every re-escalation fires as, is deliberately excluded from that dedup set) so two racing sweep ticks can never both deliver. Legacy rows (`count=0`) keep the original first-fire-at-expiry semantics.
>
> `d362858f` (#603, "ack notifications can join the caller's transaction"): `send_ack_notification` gains an optional `db_session` param forwarded to `_create_notification` — the release engine's bell notification for a just-originated proposal was inserting through a FRESH session while the proposal task still sat uncommitted in the engine's own transaction, so the `related_task_id` FK rejected the row and the ping was silently lost (the DB-free Telegram DM still went out, masking the loss). The release engine now passes its own session; the other five `send_ack_notification` callers pass no `task_id` or reference already-committed tasks, so they're unaffected.
>
> `c8f55be9` (#616, "task titles and agent slugs replace raw UUIDs"): new `notification_text.py` (`task_display`/`agent_display`) feeds every notification producer — all 13 `NotificationService` methods, the delivery-service task-handoff bodies, the substitute-PM ad-hoc insert, and the orchestrator/choreographer callers (threading the task row's title one call deeper) — so subjects/bodies read `Task <title>` (falling back to `#<id8>` when no title) and `<agent-slug>` instead of raw UUIDs. Also fixed a literal `'cell_pm'` role string that was being sent as an agent slug in the merge-conflict notification body. Tool-call examples in remediation hints (`unblock('<uuid>')`) keep the raw id on purpose — agents still need it to call the tool.
>
> `56b6693e` ("security-hygiene-sweep"): root-causes a previously dead-on-arrival sweep — `NotificationDeliveryService.sweep_expired_notifications` already ran a real `expires_at < now()` query, but `NotificationService._create_notification` never WROTE `expires_at`, so the query always matched zero rows and every ack-required notification was effectively immortal. `_create_notification` now computes `requires_ack` up front (same derivation as before) and, when ack-required AND `settings.notification_ack_ttl_hours > 0`, stamps `expires_at = now() + timedelta(hours=notification_ack_ttl_hours)` (default 48h) on the `NotificationTable` row; `0` leaves `expires_at` `NULL` (never expires). Informational notifications never get a deadline regardless of the setting.
## Regression Risks