CLAUDE.md@src/grove/core/notifications · git:20260615.085726f · 2026-06-15 · sha256 38ff8621d2facf31

CLAUDE.md@src/grove/core/notifications git:20260615.085726fA

Immutable. This exact content is served forever at /api/v1/blob/38ff8621d2facf31.

# grove.core.notifications — push on agent-state edges (#70)

> ↑ [grove.core](../CLAUDE.md) · [root](../../../../CLAUDE.md)

Turns the existing activity stream into push notifications. One nameable job:
*deliver a notification when an agent crosses into a state that wants the human*.
The broker decides; channels deliver. Off by default (`cfg.notifications`).

Three atomic types, one concern each — no free functions, no module-level
factories. `Notification` (the event, owns its own shape), `NotificationChannel`
(the ABC contract), `NotificationBroker` (the edge engine + the one factory).

- **Reuse the activity bus; add NO status computation (the KISS rule).** The
  broker subscribes via `bind(ActivityService.subscribe)` exactly like `_SseHub`
  and reads each session's already-blended `AgentActivityState`. A notification
  is the *rising edge* into a notifiable state — never a new status engine. `bind`
  takes the `subscribe` callable, not the service, so the broker depends only on
  the bus shape and tests drive it with a bare stub.
- **`NotificationBroker.from_config(cfg.notifications)` is the single factory** —
  returns the broker or `None` (disabled / no channel). It is the one place
  config coerces to runtime: channel objects built from `_CHANNEL_TYPES` (the
  `(config-attr, channel-class)` registry — adding email/Slack/Teams is one tuple
  entry plus a config field plus the channel class, nothing else), and the `on`
  strings coerced to the state enum. There are deliberately no standalone
  `build_channels` / `resolve_notify_states` helpers — that logic belongs to the
  broker that owns it, not to root-level functions.
- **`evaluate` is pure; `dispatch` is the I/O edge — the split is the whole
  design.** `evaluate(delta)` folds one delta against per-session last-state +
  per-workspace debounce under a lock and returns `Notification`s, fully testable
  with a fake clock and hand-built deltas (which use the real engine IR, so a
  contract drift fails to compile). `dispatch` fans out, each channel in the
  best-effort guard.
- **The bus callback must never block or break the poll.** `_on_delta` runs
  `evaluate` synchronously on the emitting (executor) thread, then `submit`s
  `dispatch` to a single-worker `ThreadPoolExecutor` — channel HTTP never touches
  the activity path; a dead/slow sink degrades one delivery and is logged, never
  re-raised. (A `ThreadPoolExecutor`, not a hand-rolled queue+thread+sentinel:
  `shutdown(wait=True)` drains on close for free — less code, same semantics.)
- **Two storm guards, both load-bearing.** First observation of a session seeds
  its state *without firing* (else every already-finished workspace buzzes on
  daemon restart). After a fire the workspace is debounced for a window (else a
  `waiting`→`working`→`waiting` tool round-trip rings twice). Edge + debounce =
  one buzz per genuine attention episode.
- **`Notification` owns its own shape — construction, phrasing, rendering.**
  `from_activity` builds it from the activity row (the only place that knows
  `repo_name`/`deep_link` derivation); `REASONS` maps state→English AND *is* the
  notifiable set (a state absent from it can never fire — the broker intersects
  against `frozenset(Notification.REASONS)`, so adding a notifiable state is a
  one-line edit); `title()`/`body()` render once so channels stay dumb formatters.
- **`NotificationChannel` is an ABC, not a Protocol — the contract for every
  future sink.** One abstract action, `deliver(notification)`; `close()` a
  concrete overridable no-op (stateless sinks inherit it; HTTP ones override —
  `# noqa: B027` marks the empty body deliberate). The rule for divergent
  capabilities (attachments, threading): add the method here with a base body
  that `raise NotImplementedError`, sinks override what they support — keep "what
  a channel can do" in one readable contract, never capability flags at call
  sites. Today `deliver` + a rich `Notification` is the honest complete surface;
  resist speculative methods (YAGNI) until a real second action appears.
- **Channels mirror `MewboClient`'s boundary exactly.** Config holds the env-var
  *name* of each token (never the secret — committed config stays publishable);
  `httpx.MockTransport` is the test seam; every httpx failure narrows to a typed
  `NotificationError` subclass. Gotify deep-links via the
  `client::notification.click` extra; the webhook speaks ntfy's JSON-publish
  shape (`topic`/`click`/`tags`) so it doubles as a generic sink.
- **Config can't import the agents enum (cycle: agents → registry → adapters →
  config).** `NotifyTransition` is a `Literal` mirroring the `AgentActivityState`
  values; `from_config` is the single edge that coerces it back to the enum.
- **Web Push (VAPID/PWA) is the next channel (#75)** — it needs the webapp
  service-worker + subscription surface first, then drops in as one more
  `_CHANNEL_TYPES` entry with no broker change.