Skip to content

Fix lost-update race in workbench settings participant mutations - #507

Merged
TheGreatAxios merged 1 commit into
mainfrom
cl-7194-workbench-settings-concurrency
Aug 30, 2026
Merged

TheGreatAxios merged 1 commit into
mainfrom
cl-7194-workbench-settings-concurrency

Conversation

@TheGreatAxios

Copy link
Copy Markdown
Contributor

Summary

packages/chat/src/store.ts's updateWorkbenchSettings issued a blind UPDATE ... SET settings = $1 — no version guard, no JSONB merge, whole-blob replace. Every participant add/remove path (joinHumanParticipant, removeWorkbenchParticipant, launchAndJoinAgent in workbench-service.ts, joinRunParticipant in run-participant.ts) was check-then-act off a settings snapshot read earlier in the same request. Two concurrent invites to the same workbench each rebuilt the full settings object from their own stale snapshot, and the second write silently discarded the first's participant — no error anywhere.

Fixes CL-7194.

Concurrency mechanism

Adds mutateWorkbenchParticipants to ChatStore: wraps the read and write in a db.transaction with a SELECT ... FOR UPDATE row lock, folds a caller-supplied pure mutate closure over the current chat/participants list, and writes back only that JSONB key via jsonb_set — never touching other settings keys.

This is the same mechanism PR #490 (CL-7232) uses for the identical lost-update shape in packages/access-policy's upsertPolicy. That PR's review explicitly rejected an optimistic wall-clock version-stamp check for the same reason it doesn't work here: two transactions starting in the same tick can both pass it. Reusing the transactional row lock keeps the two fixes consistent rather than inventing a second mechanism for the same bug shape.

All four participant add/remove paths now call mutateWorkbenchParticipants with a pure fold instead of reading and rewriting a full settings snapshot, dropping the now-unnecessary existingSettings field from their inputs. This forced mechanical call-site updates (just dropping the field being passed, no behavioral changes) in routes.ts, workflow-participant-routes.ts, and apps/hub/src/slack-tag-mount.ts (a narrowed Pick<ChatStore> type there needed widening to include the new method). updateWorkbenchSettings itself is unchanged and still serves callers that legitimately replace non-list settings keys.

No migration needed — workbench_settings and its (tenantId, workbenchId) lookup already exist.

Tests

  • settings-participants.drizzle.test.ts: two real concurrent transactions against a scratch Postgres database, for both a two-invite race and a removal racing an addition. Verified by hand to fail against a naive non-transactional read-then-write implementation and pass against the locked one.
  • store.test.ts: unit tests for the in-memory store's targeted-merge behavior (add, remove, missing-workbench error).
  • workbench-service.test.ts / routes.test.ts: end-to-end tests proving joinHumanParticipant, removeWorkbenchParticipant, and the invite route were correctly rewired off caller-rebuilt stale snapshots. These don't exercise real concurrency — the in-memory store's implementation has no await between its read and write, so Promise.all'd calls against it can't interleave — the Postgres-backed test is the actual race proof; named and commented accordingly.

Known gap (ticketed, not fixed here)

routes.ts's workbench settings PATCH handler (owned by a parallel in-flight lane, CL-7192/PR #498) still whole-blob-writes chat/participants on every settings PATCH — not gated on the patch touching that key — so it can still silently revert a concurrent invite/removal. Filed as CL-7251 with the corrected scope. Closing it needs a more general targeted-merge primitive and touches a file this change doesn't own.

Review process note

Greybeard reviewed the approach before implementation (confirmed the missing-row and JSONB-null edge cases were already handled correctly, and flagged the redundant existence pre-check in joinRunParticipant, since removed). Critique reviewed the committed diff and found a real build-breaking regression (apps/hub/src/slack-tag-mount.ts's narrowed Pick<ChatStore> type was never widened — now fixed), a tests-first/implementation commit split that didn't compile standalone (squashed into one commit), and two test descriptions that overstated what an in-memory-store race test actually proves (reworded). CL-7251's own description was also corrected for a scope inaccuracy Critique caught.

DO NOT MERGE.

store.ts's updateWorkbenchSettings issues a blind
UPDATE ... SET settings = $1 with no version guard and no merge — a
whole-blob replace. Every participant add/remove path was check-then-act
off a snapshot read earlier in the same request (joinHumanParticipant,
removeWorkbenchParticipant, launchAndJoinAgent, joinRunParticipant): two
concurrent invites each rebuilt the full settings object from their own
stale snapshot, and the second write silently discarded the first's
participant.

Adds mutateWorkbenchParticipants, a targeted counterpart that wraps the
read and write in a transaction with a SELECT ... FOR UPDATE row lock and
writes back only the chat/participants JSONB key via jsonb_set — the same
locking mechanism PR #490 (CL-7232) uses for the identical lost-update
shape in access-policy's upsertPolicy. An optimistic wall-clock version
stamp was rejected there for the reason that applies here too: two
transactions starting in the same tick can both pass it.

All four participant add/remove paths now call mutateWorkbenchParticipants
with a pure fold over the current list instead of reading and rewriting a
full settings snapshot, which drops the now-unnecessary existingSettings
field from their inputs (mechanical call-site updates in routes.ts,
workflow-participant-routes.ts, and apps/hub's slack-tag-mount.ts).
updateWorkbenchSettings itself is unchanged and still serves callers that
legitimately replace non-list settings keys.

Regression coverage: two real concurrent transactions against a scratch
Postgres database (settings-participants.drizzle.test.ts), verified by
hand to fail against a naive non-transactional read-then-write and pass
against the locked implementation; unit tests for the in-memory store's
targeted-merge behavior; and end-to-end tests proving joinHumanParticipant
and the invite route were correctly rewired off caller-rebuilt stale
snapshots (these last two don't exercise real concurrency — the in-memory
store's implementation has no await between its read and write, so
Promise.all'd calls against it can't interleave — the real race proof is
the Postgres-backed test).

A known, ticketed gap (CL-7251) is left open: the settings PATCH route in
routes.ts (owned by a parallel in-flight lane) still whole-blob-writes
chat/participants on every settings PATCH, so it can still revert a
concurrent invite. Closing it needs a more general targeted-merge
primitive and touches a file this change doesn't own.
@TheGreatAxios
TheGreatAxios merged commit 10e9e77 into main Aug 30, 2026
5 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