Skip to content
Autonomy
AGH RuntimeAutonomy

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.

FieldMeaning
consumer_idStable delivery consumer identity (e.g. bridge_task_subscription:<subscription_id>).
stream_nameCursor stream — task_events for the bridge terminal notifier.
subject_idTask/channel/thread/bridge scope. Bridge terminal cursors use the task id.
last_sequenceHighest confirmed delivered sequence. 0 means no delivery yet.
last_delivery_idLast confirmed delivery id, used for idempotent replay checks.
last_delivered_atLast confirmed delivery timestamp.
last_errorBounded last delivery error summary; populated only on failure paths.
updated_atLast store update timestamp.

Independent consumers must use distinct consumer_id values so one bridge or thread subscription cannot block another. The store enforces:

  • Advance is monotonic. Updates with last_sequence lower than or equal to the stored value return ErrNonMonotonicCursor unless last_sequence and delivery_id exactly match the row's previously confirmed values (idempotent replay).
  • Reset is 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.

StageWhat happens
SubscribeAn operator or agent creates a subscription scoped to one task and one bridge target. The cursor row does not exist yet.
First deliveryThe bridge terminal notifier replays task_events past last_sequence = 0, decides on an event, and only writes the cursor on success.
Steady stateThe cursor advances after each confirmed delivery. Diagnostics expose last_sequence, last_delivery_id, last_delivered_at.
FailureA fail-closed mismatch records bounded last_error and does not advance. The notifier emits notification.terminal_state_mismatch.
DeleteSubscription delete removes the active target row. The cursor row remains so diagnostics still resolve and same-id resubscribes resume.
ResubscribeRecreating 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:

DecisionCursor effectDeliveryWhen it applies
deliverAdvances 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.
deferNo advance for the deferred event.None.A run-level terminal event for a review-required or rejected run while review/continuation is active.
mismatchNo 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:

  1. Inspect the cursor diagnostics (last_error, last_sequence, subscription_id).
  2. 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.
  3. The next replay decision either reaches deliver or 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 json

Generated reference for each verb:

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.

MethodPathOperation IDPurpose
POST/api/tasks/{id}/notifications/bridgescreateTaskBridgeNotificationSubscriptionCreate a bridge terminal-notification subscription for the task. Idempotent on subscription_id.
GET/api/tasks/{id}/notifications/bridgeslistTaskBridgeNotificationSubscriptionsList subscriptions plus inline cursor diagnostics for the task.
GET/api/tasks/{id}/notifications/bridges/{subscription_id}getTaskBridgeNotificationSubscriptionRead one subscription with the same cursor diagnostic projection.
DELETE/api/tasks/{id}/notifications/bridges/{subscription_id}deleteTaskBridgeNotificationSubscriptionRemove 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 state pill and seq 0 instead of inventing delivery metrics.
  • Steady state. last_sequence, last_delivery_id, and last_delivered_at come straight from generated OpenAPI types — there is no client-side derivation.
  • Failure state. When last_error is 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_id resumes 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.

  • 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_approved enters 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.

On this page