Skip to content

Fix the question-response handler: double dispatch, non-atomic write, inverted ordering - #498

Merged
TheGreatAxios merged 4 commits into
mainfrom
cl-7192-question-response-fix
Aug 30, 2026
Merged

TheGreatAxios merged 4 commits into
mainfrom
cl-7192-question-response-fix

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

Summary

CL-7192: three defects in the question-response handler (POST /workbenches/:id/messages/:id/blocks/:id/responses), the route that records a user's answer to a question card.

  • Double dispatch — every successful upsert unconditionally sent the answer into the workbench and dispatched a turn, so a changed answer or a double-click that beat the UI's disable ran a second turn. Gated the send behind a new claimBlockResponseNotification guard (a token-scoped, guarded UPDATE ... WHERE notified_at IS NULL): only the submission that wins the claim ever sends and dispatches. Once a question is claimed, a later changed answer still upserts the new payload but can never re-claim/re-notify — permanently, not just within a race window.
  • Non-atomic write — the upsert committed before the send; a thrown send left the row reading "answered" with the agent never notified. A failed send now releases the claim and returns an explicit notify_failed 500 telling the caller their answer was saved and to retry, rather than a silent row/timeline mismatch. True cross-system atomicity (one DB row, one external send) isn't achievable without an outbox; this makes the failure recoverable and disclosed instead.
  • Inverted ordering — the turn was dispatched before the machine-readable block.response event was posted, so the agent could read the timeline before its own correlation event existed. The event (built from the upsert's own returned row, not the request's local payload) is now posted before the question branch ever gets a chance to dispatch.

Design notes

  • claimBlockResponseNotification/releaseBlockResponseNotification are token-scoped (notification_claim_token, migration 0026) the same way turn-claims.ts is for CL-7129 — but this is defensive insurance for a future release call site, not a fix for a reachable bug today: this store has no TTL/reaper, so nothing can reassign a claim out from under its own holder yet. write-claims.ts's own release is unconditional for the same reason.
  • Deliberately did not consolidate with write-claims.ts or turn-claims.ts in this PR, to keep the diff scoped and avoid collision with other lanes touching this package. If ever generalized, write-claims.ts's shape (no TTL) is the closer match.
  • Added packages/chat/test/block-responses.drizzle.test.ts (DB-gated, mirrors write-claims.drizzle.test.ts) proving the guarded UPDATE is race-safe against a real Postgres connection pool, not just single-threaded in-memory JS.

Known follow-up (not in this PR)

Filed CL-7245: the chat-ui question-block component gives no retry affordance once a question renders "answered" after a notify_failed response — the release-on-failure claim design depends on a client retry the current UI can't trigger. Out of scope here (packages/chat only).

Test plan

  • cd packages/chat && bun run typecheck && bun test — 732 pass, 43 skip (DB-gated, no local DATABASE_URL), 0 fail
  • bunx prettier --check on all touched files
  • Each commit individually typechecks and passes tests (verified via per-commit checkout)
  • Reviewed by Greybeard (approach) and Critique (implementation) before push
  • CI (pending)

DO NOT MERGE — for review only.

CL-7192: the question-response handler needs a way to guarantee at
most one send-and-dispatch per question, even under a changed answer
or a double-click that beats the UI's disable. Adds a `notifiedAt` /
`notificationClaimToken` pair to `block_responses` (migration 0026):
`claimBlockResponseNotification` flips `notifiedAt` from null to now
in one guarded UPDATE and hands back a fresh token; a second caller
for the same key loses. `releaseBlockResponseNotification` only clears
the claim when the token it is given still matches — the same
token-scoping `turn-claims.ts` uses for CL-7129, applied here as
defensive insurance for a future release call site rather than a fix
for any reachable double-release today (this store has no TTL or
reaper, so nothing can reassign a claim out from under its own
holder).

Once a question has been claimed, the upsert never resets the claim
on a changed answer: re-answering updates the stored payload but never
re-notifies, permanently.

`block-responses.test.ts` covers the in-memory store's claim/release
semantics; `block-responses.drizzle.test.ts` (DB-gated, mirroring
`write-claims.drizzle.test.ts`) proves the guarded UPDATE the drizzle
store depends on is actually race-safe against a real Postgres
connection pool, not just correct-looking single-threaded JS.
…erted ordering

Three defects in the question-response handler on the *answer* path
(`POST /workbenches/:id/messages/:id/blocks/:id/responses`):

- Double dispatch: every successful upsert unconditionally sent the
  answer into the workbench and dispatched a turn, so a changed answer
  or a double-click that beat the UI's disable ran a second turn.
  Gated the send behind `claimBlockResponseNotification`: only the
  submission that wins the claim ever sends and dispatches.
- Non-atomic write: the upsert committed before the send, so a thrown
  send left the row reading "answered" with the agent never notified.
  A failed send now releases the claim and returns an explicit
  `notify_failed` 500 telling the caller their answer was saved and to
  retry, rather than a silent row/timeline mismatch.
- Inverted ordering: the turn was dispatched before the machine-readable
  `block.response` event was posted, so the agent could read the
  timeline before its own correlation event existed there. The event is
  now posted (from the upsert's own returned row, not the request's
  local payload) before the question branch ever gets a chance to
  dispatch.

Tests cover: a changed answer never double-dispatching, a double-click
(concurrent identical submissions) never double-dispatching, and a
failed notify releasing the claim so a retried submission still
reaches the agent.
Documents the notification-claim guard on the question-response
handler alongside the existing reactions/pins section it sits next to.
@TheGreatAxios
TheGreatAxios force-pushed the cl-7192-question-response-fix branch from 6a72e6f to e657336 Compare August 30, 2026 22:52
@TheGreatAxios
TheGreatAxios merged commit a4b2e54 into main Aug 30, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant