Fix the question-response handler: double dispatch, non-atomic write, inverted ordering - #498
Merged
Merged
Conversation
TheGreatAxios
force-pushed
the
cl-7192-question-response-fix
branch
2 times, most recently
from
August 30, 2026 21:45
d91f37d to
6a72e6f
Compare
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
force-pushed
the
cl-7192-question-response-fix
branch
from
August 30, 2026 22:52
6a72e6f to
e657336
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.claimBlockResponseNotificationguard (a token-scoped, guardedUPDATE ... 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.notify_failed500 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.block.responseevent 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/releaseBlockResponseNotificationare token-scoped (notification_claim_token, migration 0026) the same wayturn-claims.tsis 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 ownreleaseis unconditional for the same reason.write-claims.tsorturn-claims.tsin 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.packages/chat/test/block-responses.drizzle.test.ts(DB-gated, mirrorswrite-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-uiquestion-block component gives no retry affordance once a question renders "answered" after anotify_failedresponse — the release-on-failure claim design depends on a client retry the current UI can't trigger. Out of scope here (packages/chatonly).Test plan
cd packages/chat && bun run typecheck && bun test— 732 pass, 43 skip (DB-gated, no localDATABASE_URL), 0 failbunx prettier --checkon all touched filesDO NOT MERGE — for review only.