Messaging Block Semantics¶
When the messaging Stop hook blocks an agent from ending its turn, and how to clear it
Details¶
The messaging hooks check for pending messages addressed to a session. Only the
Stop hook turns a non-empty result into a decision:block that prevents the
agent from ending its turn; SessionStart and UserPromptSubmit only inject context,
and PostToolUse surfaces messages mid-turn without blocking.
Disposition decides whether a message can block¶
Every message carries a sender-declared disposition (a column on the messages
table) — the first gate on block-worthiness:
actionable— an action item the agent must see/handle. May block the Stop hook (subject to the read/priority rules below). This is the default.acknowledgement— an auto-ack of something already handled in-band (a consent decision echoed back for the sidecar to record, a fire-and-forget FYI). Never blocks: it is excluded from the Stop summary outright, even unread and high-priority. It still appears in the non-blocking SessionStart / UserPromptSubmit context summaries.
Senders declare disposition when they emit — the notification system marks a consent
echo acknowledgement but a genuine request/question answer actionable; the retry
sweep marks retry_success acknowledgement and retry_exhausted actionable.
When a caller omits it — including the Obsidian plugin's out-of-band consent_grant
POST, which cannot set it — create_message infers it via _classify_disposition
(consent subjects/tags, a notification_response whose body title starts "Consent:",
and terminal statuses → acknowledgement; everything else → actionable). Legacy
rows are backfilled by the same rule on migration, and a missing/NULL disposition is
treated as actionable so nothing silently stops blocking.
How an actionable message blocks (the read/priority axis)¶
Among actionable messages, blocking is governed by read state and priority:
- Unread by the recipient session → blocks once. The summary auto-marks it read as it renders, so the next Stop releases it ("surface once, then release").
- High/urgent priority and still
pending→ keeps blocking even after it has been read, until it is resolved. Governed byBLOCK_UNTIL_RESOLVED_PRIORITIESinwork_buddy/messaging/models.py({"high", "urgent"}; set it empty to make every actionable priority surface-once).
A read, normal/low-priority actionable message does not block — it surfaced once and is done. Disposition and priority are orthogonal: disposition decides whether a message can block at all; priority decides how hard an actionable one blocks.
Born-resolved is still a non-block path¶
A message in any non-pending status never blocks: it is excluded at the query level
and pruned on the normal TTL. Fire-and-forget notifications use this — the retry sweep
emits retry_success as status="resolved", and the notification system emits
acknowledgement consent echoes as status="resolved" too — so neither enters the
pending/block path at all.
Disposition also governs retention¶
The age-based artifact sweep keeps a pending row from being reaped only while it is
actionable. acknowledgement rows are not pinned: they reap on the normal 30-day TTL
even while pending, so auto-acks of in-band-handled work cannot accumulate in the inbox
forever. actionable pending messages (genuine action items) are retained until they
are resolved. See _messages_retention in work_buddy/messaging/models.py. This is the
same disposition field that gates blocking, reused as the retention signal.
Clearing a message that is blocking¶
The Stop block text surfaces the verb inline. Two equivalent ways:
bash /tmp/wb/resolve --id <message-id>— the generated helper (PATCHes the message toresolved).update_message_status(msg_id, "resolved")— the capability.
read / reply do not clear the block: they record a read or create a new
message, neither of which changes messages.status. Reading a normal message is
enough to release it on the next Stop (the block is unread-gated), but a
high/urgent message must be resolved.