Thread sync protocol — channel turns land in the open app thread
This story lands the PUSH side of the channel→app thread contract
(RFD-AGT-013 TICKET-MAP v2 T2m-a, scenario CC-32: "WhatsApp shows its
own slice; the app shows the whole conversation"). A channel turn is
already persisted into the shared (agent, matter, counsel) memory —
Story-2839's session resolver routes it and the clerk worker's
Bug-2211 writer (_persist_converse_turns) appends it to
agents.chat_turn. What was missing: today's app hydration
is once-per-session, so a turn that landed while the app thread sat
open never appeared. This story makes the broker emit a WebSocket
refresh signal so the open app thread re-reads the canonical store.
Zero new migrations; one new v1 frame type.
The protocol
| Decision | Contract |
|---|---|
| One delivery rail | The signal rides ADR-110's only WS rail:
_FramePusher → ws_pusher Lambda →
postToConnection. No second push path, no domain
event, no polling contract. |
| Signal, not content | {"v": 1, "type": "thread.updated", "run_id": …,
"payload": {chat_session_id, matter_id, agent_key}} —
routing keys only, never turn bodies. The app re-reads
GET /app/agent-sessions/{id}/turns (the idempotent
canonical read) when the frame names its open thread. |
| Channel-agnostic | The emit condition is "the run has NO launch connection" (off-socket origin) — never a provider check. No whatsapp/meta/adapter field appears in the envelope, so every present and future channel adapter rides the same contract. |
| Fail-safe | thread.updated joins
_FramePusher._MUST_DELIVER: with no launch
connection the pusher re-resolves the (firm, user) newest live
agents.ws_connection row (the Story-2896
socket-attach lookup). No open socket → silent no-op; the turn
is durable and the app reads it on next open (ADR-110 §4.1). |
| No double signal | App-socket launches are skipped: their own
turn.completed stream already settles the thread on
that socket — a refresh would be a redundant re-read. |
Where it hooks (src/clerk_worker/runner.py)
The converse settle path persists the thread rows BEFORE the browser
settles (the Bug-2211 posture: counsel never sees an answer that
failed to persist). _emit_thread_refresh(command, pusher)
runs immediately after that persistence succeeds and before
turn.completed is pushed, so the signal always points at
durable truth. A channel-launched converse run rides exactly this code
path, so the hook covers channel turns without touching any channel
adapter code — the seam the RFD's "refresh contract when channel turns
land" assumes.
How to verify (CC-32 push half)
# The thread-sync unit proofs: channel turn → refresh frame with correct # session/matter/agent routing on the re-resolved live socket; no-socket # no-op; app launches not double-signalled; sessionless runs emit nothing uv run pytest tests/unit/clerk_worker/test_runner_thread_sync.py # Neighbors that must stay green: converse-turn persistence, the whole # clerk_worker suite uv run pytest tests/unit/clerk_worker
Red-first evidence
Three planted negatives were observed red against the shipped
implementation: (1) dropping thread.updated from
_MUST_DELIVER fails
test_channel_turn_emits_thread_refresh_with_session_matter_routing
(no live-connection re-resolution for an off-socket run); (2) deleting
the _emit_thread_refresh call at the converse settle site
fails the same test (no frame emitted); (3) dropping the off-socket
guard so the emit fires unconditionally fails
test_app_socket_launch_is_not_double_signalled.
Scope boundaries (TICKET-MAP v2)
- The app-side half — handling
thread.updatedand re-reading the thread, replacing once-per-session hydration — is T2m-b (pramaan-app). This story defines the frame contract the app consumes. - The channel-ingress consumer that launches channel converse runs lands with its own ticket; the emit condition (off-socket origin) is already correct for it, adapter-free.
- The
remember:directive ack path also persists turns but is app-socket-originated, so it is correctly skipped by the off-socket guard. - No migrations, no route/DTO changes — the OpenAPI snapshots are untouched; the canonical store read already exists (Story-2223's turns endpoint).