Notification Cursors
Bridge terminal task notifications, durable cursor diagnostics, replay semantics, and SSE reconnect seeding.
- Audience
- Operators running durable agent work
- Focus
- Autonomy guidance shaped for scanability, day-two clarity, and operator context.
internal/notifications is a narrow durable primitive. It records confirmed delivery progress for
notification consumers and never owns task ownership, event fan-out, hook dispatch, review
authority, queue semantics, or task SSE replay state. The first concrete consumer is bridge-
delivered terminal task notifications owned by internal/bridges. This page documents the cursor
contract, the bridge subscription lifecycle, the operator-facing diagnostics, and the
latest_event_seq seed used by the web stream-resume surface.
Cursor identity and store
The cursor table (notification_cursors) is keyed by (consumer_id, stream_name, subject_id).
subject_id = "" represents an unscoped stream; SQLite never sees a nullable composite key.
last_sequence is monotonic; the secondary index is
(stream_name, last_sequence DESC) WHERE last_sequence > 0 for stream-resume reads.
| Field | Meaning |
|---|---|
consumer_id | Stable delivery consumer identity (e.g. bridge_task_subscription:<subscription_id>). |
stream_name | Cursor stream — task_events for the bridge terminal notifier. |
subject_id | Task/channel/thread/bridge scope. Bridge terminal cursors use the task id. |
last_sequence | Highest confirmed delivered sequence. 0 means no delivery yet. |
last_delivery_id | Last confirmed delivery id, used for idempotent replay checks. |
last_delivered_at | Last confirmed delivery timestamp. |
last_error | Bounded last delivery error summary; populated only on failure paths. |
updated_at | Last store update timestamp. |
Independent consumers must use distinct consumer_id values so one bridge or thread subscription
cannot block another. The store enforces:
Advanceis monotonic. Updates withlast_sequencelower than or equal to the stored value returnErrNonMonotonicCursorunlesslast_sequenceanddelivery_idexactly match the row's previously confirmed values (idempotent replay).Resetis the only service/store path that lowers a cursor; it requires an explicit recovery reason. No task notification CLI/API reset verb is exposed today.- Callers advance only after delivery is confirmed. When delivery recording and cursor advancement are both SQLite writes, they execute inside the same global DB transaction.
External delivery remains at-least-once: a process crash after external delivery but before
Advance may duplicate delivery, but it never skips events.
Bridge task subscription lifecycle
bridge_task_subscriptions defines the delivery target. The cursor row defines the delivery
progress for that target. The two concerns are deliberately separate, and the bridge subscription
does not store cursor state.
| Stage | What happens |
|---|---|
| Subscribe | An operator or agent creates a subscription scoped to one task and one bridge target. The cursor row does not exist yet. |
| First delivery | The bridge terminal notifier replays task_events past last_sequence = 0, decides on an event, and only writes the cursor on success. |
| Steady state | The cursor advances after each confirmed delivery. Diagnostics expose last_sequence, last_delivery_id, last_delivered_at. |
| Failure | A fail-closed mismatch records bounded last_error and does not advance. The notifier emits notification.terminal_state_mismatch. |
| Delete | Subscription delete removes the active target row. The cursor row remains so diagnostics still resolve and same-id resubscribes resume. |
| Resubscribe | Recreating the same subscription_id re-points the target row at the existing cursor and resumes from the persisted last_sequence. |
Every subscription read model exposes the cursor read-model fields (consumer_id, stream_name,
subject_id, last_sequence, last_delivery_id, last_delivered_at, last_error,
updated_at). When no cursor row exists yet the API returns a deterministic zero-sequence
identity (last_sequence = 0, no delivery id, no timestamps) so the operator can distinguish "no
delivery yet" from "delivery stalled".
Terminal notifier states
The bridge terminal notifier replays durable task_events by event_seq. Replay authority is the
durable event sequence, not channel/thread state and not the prompt-side DeliveryBroker. For each
candidate event the notifier resolves one of three decisions:
| Decision | Cursor effect | Delivery | When it applies |
|---|---|---|---|
deliver | Advances after delivery success is confirmed. | Sends the final notification through bridges/deliver. | The replayed event is the accepted-final terminal event for the current task/review state. |
defer | No advance for the deferred event. | None. | A run-level terminal event for a review-required or rejected run while review/continuation is active. |
mismatch | No advance. Records bounded last_error. | Fails closed. Emits notification.terminal_state_mismatch. | The replayed event claims to be the accepted-final terminal result, but task/review state disagrees. |
Candidate terminal events: task.run_completed, task.run_failed, task.run_canceled,
task.run_review_approved, and task.canceled. For review-gated work the accepted-final event is
task.run_review_approved; earlier run-level terminal events deliver only when the policy accepts
their outcome and no review/continuation is still active.
The notifier may continue scanning later events in the same replay batch and advance to a later
accepted-final event after that final delivery succeeds. It never advances past a mismatch and
never deletes a cursor on its own.
Recovery from a mismatch is explicit:
- Inspect the cursor diagnostics (
last_error,last_sequence,subscription_id). - Either repair task/review state through the task-service path so replay and current state agree, or perform an internal cursor reset with an operator-supplied recovery reason. Reset is the only service/store path that lowers a cursor; there is no public task notification reset command.
- The next replay decision either reaches
deliveror records a fresh diagnostic, never silently inventing terminal state.
The notifier wake-up can come from a hook or task.EventObserver, but wake-up is only a nudge.
Authority remains durable task_events.event_seq.
Bridge notification envelope
The bridge delivery message includes the durable event_seq and a deterministic delivery id so
duplicate delivery is detectable downstream:
{
"delivery_id": "notif:<subscription_id>:<event_seq>",
"event_type": "final",
"final": true,
"seq": 123
}seq is the numeric task_events.event_seq from the replayed accepted-final event. The metadata
envelope preserves the original task terminal event type (task.run_completed,
task.run_failed, task.run_review_approved, etc.) so downstream consumers can correlate without
re-parsing the wrapper event_type.
Manage subscriptions from the CLI
The agh task notification command group operates over UDS and shares its surface with the
matching HTTP endpoints. Every subcommand supports -o json|jsonl|toon for agent consumption.
# Subscribe a bridge target to one task. --subscription-id is idempotent.
agh task notification subscribe <task-id> \
--bridge <bridge-instance-id> \
--scope workspace --workspace <workspace-id> \
--thread <thread-id> \
--mode reply \
--subscription-id <subscription-id> \
-o json
# List subscriptions for the task. Cursor diagnostics appear inline.
agh task notification list <task-id> -o json
# Inspect one subscription, including cursor diagnostics.
agh task notification show <task-id> <subscription-id> -o json
# Delete the active subscription. Cursor diagnostics remain inspectable for replay.
agh task notification delete <task-id> <subscription-id> -o jsonGenerated reference for each verb:
- agh task notification
- agh task notification subscribe
- agh task notification list
- agh task notification show
- agh task notification delete
The CLI does not expose a cursor-reset verb. Cursor reset is deliberately kept out of the task notification command group, so normal operator recovery starts by repairing task/review state and letting replay run again.
Manage subscriptions through HTTP and UDS
Both transports mount the same shared core handlers. Operation IDs come from openapi/agh.json;
do not paraphrase the shapes.
| Method | Path | Operation ID | Purpose |
|---|---|---|---|
POST | /api/tasks/{id}/notifications/bridges | createTaskBridgeNotificationSubscription | Create a bridge terminal-notification subscription for the task. Idempotent on subscription_id. |
GET | /api/tasks/{id}/notifications/bridges | listTaskBridgeNotificationSubscriptions | List subscriptions plus inline cursor diagnostics for the task. |
GET | /api/tasks/{id}/notifications/bridges/{subscription_id} | getTaskBridgeNotificationSubscription | Read one subscription with the same cursor diagnostic projection. |
DELETE | /api/tasks/{id}/notifications/bridges/{subscription_id} | deleteTaskBridgeNotificationSubscription | Remove the active subscription target. Cursor row remains for diagnostics and same-id resume. |
Cursor diagnostics ship inside the subscription payload's cursor object — there is no separate
cursor read endpoint. Generated TypeScript types for web consumers live in
web/src/generated/agh-openapi.d.ts. The legacy
bridge-notification-subscriptions route shape is no longer accepted; clients should use the
canonical /api/tasks/{id}/notifications/bridges paths above.
Inspect from the operator web UI
The Bridge Notifications card on the task Orchestration tab renders the cursor diagnostics surfaced by the API. Run detail currently renders the run-level Reviews card, not bridge notification subscriptions.
- Zero state. When no cursor row exists yet, the card shows a
zero statepill andseq 0instead of inventing delivery metrics. - Steady state.
last_sequence,last_delivery_id, andlast_delivered_atcome straight from generated OpenAPI types — there is no client-side derivation. - Failure state. When
last_erroris set, the card surfaces the bounded error and links to the subscription's identifying fields so operators can correlate with task events. - Delete and recreate. Deleting a subscription removes the active target row; the card hides
the row but explains that recreating the same
subscription_idresumes from the preserved cursor.
The same tab's Stream Resume card seeds reconnects from latest_event_seq, which is documented
below.
SSE resume seeding
Every event-bearing task list/detail/dashboard payload includes latest_event_seq — the maximum
durable task_events.event_seq for the task, or 0 when the task has no events. Web and CLI
clients open EventSource (/api/tasks/{id}/stream) with ?after_sequence=<latest_event_seq> from
the rendered read payload, which prevents the read-then-stream race for review and notification
events.
On reconnect, Last-Event-ID takes precedence over ?after_sequence, including the literal value
0; malformed Last-Event-ID headers fail parsing rather than silently falling back to the query
parameter. The first open uses ?after_sequence; reconnects use Last-Event-ID.
latest_event_seq is a read projection. It is not a mutable column, and it does not advance the
notification cursor. Its only job is letting clients resume the task SSE stream without missing
review/notification events that landed between the read and the stream subscribe.
Authority boundary
Notification cursors are delivery-progress state. They never:
- assign tasks, claim runs, or write
tasks.current_run_id; - complete, fail, or rewrite a task or run;
- replace SSE replay cursors or task hooks;
- treat channel/thread state as authority for delivery decisions;
- define bridge delivery targets — that is the bridge subscription's job.
Bridge terminal notifications never deliver before the accepted-final terminal event. Review-gated
runs wait for task.run_review_approved; ungated runs wait for task.run_completed,
task.run_failed, or task.run_canceled according to the policy.
Related pages
- Task Runs and Leases explains the terminal task events that bridge subscriptions deliver.
- Review Gate explains why bridge delivery defers on review-
gated runs and where
task.run_review_approvedenters the stream. - config.toml lists
[task.orchestration]and[task.orchestration.review]bounds that shape task context and review evidence. - agh task notification is the generated CLI reference for the notification commands.