Skip to content

refactor(web): unify turn termination through a single coordinator (LUM-2019)#32469

Merged
vex-assistant-bot[bot] merged 2 commits into
mainfrom
ashlee/lum-2019-turn-coordinator
May 28, 2026
Merged

refactor(web): unify turn termination through a single coordinator (LUM-2019)#32469
vex-assistant-bot[bot] merged 2 commits into
mainfrom
ashlee/lum-2019-turn-coordinator

Conversation

@ashleeradka
Copy link
Copy Markdown
Contributor

Summary

Resolves LUM-2019. Eliminates the 5+ site "forget the second call" coordination smell that LUM-1952 surfaced — and an audit during this PR found it was actually 12 sites, not 5.

The problem

A turn's terminal state is split across two stores:

  • turn-store.phase — the active turn's lifecycle (one per tab)
  • conversation-store.processingConversationIds — sidebar's view of which conversations are processing (multi-conversation, includes background convos from Slack/Telegram/etc.)

Every terminal-event path had to call both stores independently. LUM-1952 happened because the silent-stall rescue path called only the turn-store. Every future terminal-event path was a re-introduction risk.

The fix

Single endTurn({ conversationId, reason }) action at src/stores/turn-coordinator.ts that updates both stores atomically. Every terminal-event path now calls one function.

endTurn({ conversationId: "conv-1", reason: "complete" });

Reasons map 1:1 onto turn-store's terminal actions: "complete", "cancelled", "error", "session_error", "rescued" (the last carries a rescuedTurnId to scope onPollReconciled to a specific turn).

What changed

Coordinator

  • New: src/stores/turn-coordinator.tsendTurn() action with full docstring explaining the split-store rationale
  • New: src/stores/turn-coordinator.test.ts — 9 focused unit tests covering each reason, multi-key isolation, mismatched-rescue-turn guard, and the null-conversationId fallback

Call sites converted (11 of 12)

  • handleAssistantActivityState(idle), handleMessageComplete, handleGenerationCancelled — SSE terminal handlers
  • handleStreamError, handleConversationErrorEvent — error handlers
  • use-event-stream.ts session-error path
  • use-send-message.ts post-failure, send-finally settle, handleStopGenerating
  • use-interaction-actions.ts secret-prompt cancel, contact-prompt cancel
  • use-message-reconciliation.ts silent-stall rescue

Left inline with comment

  • use-send-message.ts catch-all (multi-key cleanup when retargeted sends touched multiple conversations) — endTurn is a one-conversation abstraction by design; refactoring this site would require a separate multi-key shape

Structural

  • Moved: domains/messaging/{turn-store,turn-selectors,turn-store.test}.tssrc/stores/
  • Deleted: domains/messaging/ (was only a misnamed singleton folder containing turn-store + selectors — no message-store, no message-handlers, nothing else)
  • Codemod: 18 files updated from @/domains/messaging/@/stores/
  • StreamHandlerContext: drops clearProcessingKey (now unused), gains endTurn

Docs

  • STATE_MANAGEMENT.md — updated turn-store location, added a new section on terminal cleanup through endTurn
  • CONVENTIONS.md — example tree shows turn-store + turn-coordinator under stores/

Cross-domain allowlist

45 files / 61 imports → 33 files / 43 imports (12 fewer files, 18 fewer imports). The "messaging" target is gone entirely from the allowlist.

Behavioural note

The conversions of sites #2 (use-send-message post-failure), #6 (secret cancel), and #7 (contact cancel) add a previously-missing removeProcessingConversationId alongside the turn-store transition. These were exactly the same shape of bug as LUM-1952 — terminal turn-store action with no processing-key cleanup — they just hadn't been reported yet. Strict improvement, no regression risk.

Test plan

  • bunx tsc --noEmit clean
  • bun run lint clean
  • bun run audit:cross-domain regenerates allowlist with 18 fewer imports
  • New turn-coordinator.test.ts (9 tests) covers each reason + edge cases
  • All touched suites pass: stream-handlers, message-reconciliation, event-stream variants, conversation-store, turn-store, turn-coordinator

Linear: LUM-2019


Generated by Claude Code

…UM-2019)

Twelve production call sites were each independently responsible for two
store transitions: one terminal turn-store action + one
`removeProcessingConversationId` cleanup on the conversation-store. The
canonical bug was "forget the second call" — exactly what caused the
loading-state regression that needed a separate hot fix.

Replace with a single `endTurn({ conversationId, reason })` action:

- SSE handlers: `handleAssistantActivityState(idle)`,
  `handleMessageComplete`, `handleGenerationCancelled`
- Error handlers: `handleStreamError`, `handleConversationErrorEvent`
- Session-error path in `useEventStream`
- Send-message error path, send-finally settle, `handleStopGenerating`
- Secret-prompt cancel, contact-prompt cancel
- Silent-stall rescue in `useMessageReconciliation`

Multi-key cleanup in the send-message catch-all (where retargeted sends
may have left processing markers on multiple conversation ids) stays
inline — `endTurn` is a one-conversation abstraction by design.

Coordinator lives at `src/stores/turn-coordinator.ts`. Required moving
turn-store to a top-level home: `domains/messaging/` was only a
misnamed singleton folder containing turn-store + selectors, so it's
been deleted entirely and the three files are now at `src/stores/`.

Cross-domain allowlist shrunk by 12 files / 18 imports (45/61 → 33/43).
StreamHandlerContext drops `clearProcessingKey` (now unused) and gains
`endTurn`.

Docs:
- STATE_MANAGEMENT.md — updated turn-store location, added a new section
  on terminal-cleanup going through `endTurn`.
- CONVENTIONS.md — example tree updated to show turn-store and
  turn-coordinator under stores/.

No behavioural change beyond making previously-unpaired terminal
transitions (sites #2, #6, #7 in the audit) defensively clear the
processing key alongside the turn-store action — a strict improvement.
@linear
Copy link
Copy Markdown

linear Bot commented May 28, 2026

LUM-2019

@ashleeradka ashleeradka marked this pull request as ready for review May 28, 2026 20:43
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0cf33defc2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread apps/web/src/stores/turn-coordinator.ts
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

View 3 additional findings in Devin Review.

Open in Devin Review

Comment thread apps/web/src/stores/turn-coordinator.ts
Comment thread apps/web/src/domains/chat/utils/stream-handlers/error-handlers.ts
… review)

The first cut of endTurn cleared `processingConversationIds` unconditionally
for every reason. That broke for the `rescued` path: when a stale
`.finally()` from a previous send's poll-reconcile fires after the user
has already started a new turn in the same conversation,
`onPollReconciled(staleTurnId)` correctly no-ops on the turn-store side
— but the unconditional processing-key clear would still fire and hide
the new in-flight turn's Stop button + sidebar dot.

Fix: mirror `onPollReconciled`'s preconditions explicitly in the
coordinator. If the rescue is stale (turn already idle, or
`rescuedTurnId` doesn't match `activeTurnId`), short-circuit both
stores together. The four definitive terminal reasons (complete /
cancelled / error / session_error) keep the existing unconditional
behavior — they represent terminal events whose meaning is "this turn
is over."

Two new regression tests cover the rescue-skip scenarios.

Also: `handleConversationErrorEvent` now prefers `event.conversationId`
over `streamContextRef.current?.conversationId`, matching the same
fallback chain used by `handleMessageComplete` and
`handleGenerationCancelled`. Race-resilient when a stream teardown
clears the ref before the error event fires.
@ashleeradka
Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Copy link
Copy Markdown
Contributor

@vex-assistant-bot vex-assistant-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE — reviewed at 643f83ee

Value: Eliminates a structural coordination bug that had 12 active instances. endTurn() is a single call where before there were two independent store mutations — and the "forget the second call" failure mode is now architecturally prevented, not just patched.

Full analysis

The coordinator — turn-coordinator.ts

The core logic is sound. endTurn() reads the turn store first, applies the correct terminal action, then conditionally clears the processing key. The rescued pre-check is the critical design decision:

if (args.reason === "rescued") {
  const isStaleRescue =
    !isSending(turn) ||
    (args.rescuedTurnId != null && turn.activeTurnId !== args.rescuedTurnId);
  if (isStaleRescue) return;
  turn.onPollReconciled(args.rescuedTurnId ?? undefined);
}

This is correct. onPollReconciled already self-guards on turnId mismatch and already-idle state — but without the pre-check, the processing-key clear that follows it would fire regardless. The Codex/Devin finding on 0cf33def (both bots flagged the same bug: stale .finally() clearing an in-flight new turn's processing key) was legitimate, and it's fixed at HEAD 643f83ee by this exact pre-check.

Verified in tests: The "rescued with mismatched turnId leaves processing key untouched" test and "rescued when already-idle is a full no-op" test both cover this exact scenario. ✅

Devin's handleConversationErrorEvent finding — addressed at HEAD ✅

Devin flagged that the original refactor used ctx.streamContextRef.current?.conversationId instead of event.conversationId for the error handler. Fixed at HEAD:

ctx.endTurn({
  conversationId: event.conversationId ?? ctx.streamContextRef.current?.conversationId,
  reason: "error",
});

The event.conversationId ?? ctx.streamContextRef.current?.conversationId fallback chain is the same pattern used by handleMessageComplete and handleGenerationCancelled. Consistent. ✅

11 of 12 call sites converted ✅

Every SSE terminal handler, error handler, session-error path, interaction-cancel path, reconciliation rescue, stop-generation — all go through endTurn. The one intentionally left inline (use-send-message catch-all for multi-key cleanup on retargeted sends) has a clear comment explaining why: endTurn is a one-conversation abstraction by design, and the catch-all needs to clean up both the original active key and the resolved key. The comment is accurate and the remaining useTurnStore.getState().onStreamError() + removeProcessingConversationId loop is the only correct shape here.

Bug-fix bonus — 3 previously-missing processing-key clears ✅

Sites #2 (use-send-message post-failure), #6 (secret cancel), and #7 (contact cancel) were calling only the turn-store action with no removeProcessingConversationId. These were latent LUM-1952 clones that hadn't been reported yet. endTurn fixed them as a side-effect of the refactor. The PR body's description of this as "strict improvement, no regression risk" is accurate.

rescued reason — .finally() in use-send-message

The .finally() block at line ~485 is the right shape:

.finally(() => {
  if (!isCurrentSendScope(effectiveConversationId)) return;
  endTurn({ conversationId: effectiveConversationId, reason: "rescued", rescuedTurnId: turnId });
});

The isCurrentSendScope guard is the outer scope check; endTurn's rescued pre-check is the inner turn-id guard. Defense in depth is correct here — stale sends from navigated-away conversations are caught by the outer guard, stale turns replaced by a new send in the same conversation are caught by the inner guard.

Store move (domains/messaging/stores/) ✅

turn-store.ts was a singleton with no per-conversation state — it had no business in domains/messaging/ alongside domain-scoped hooks. The move to stores/ is correct per CONVENTIONS.md (stores = module-level singletons). 18-file import codemod, cross-domain allowlist drops 18 imports (12 fewer files touching the old path). Structural improvement.

StreamHandlerContextclearProcessingKeyendTurn

The interface change correctly drops the clearProcessingKey: (convKey: string) => void field and adds endTurn: (args: EndTurnArgs) => void. The docstring on the new field explains the atomic-two-store contract. use-stream-event-handler.ts wires endTurn directly from the import — no risk of the context object diverging from the module implementation.

Tests ✅

9 unit tests in turn-coordinator.test.ts cover: each terminal reason, the mismatched-rescue guard (active turn not touched), the already-idle rescue guard (processing key not cleared), multi-key isolation (only the supplied conversationId is removed), and null conversationId (turn transitions, processing-key clear skipped). The test for the Codex/Devin-reported bug is explicit and named clearly. Good.

Minor non-blocking: stray blank lines

error-handlers.ts line 9 and message-handlers.ts line 18 each have a blank line added after the import block that produces a double-blank-line. Not functional, linter will catch if configured strictly. No action needed.

Merge gate

  • ✅ Vex APPROVED (this review)
  • ⏳ Devin + Codex reviewed stale 0cf33def — triggering fresh HEAD reviews below
  • bunx tsc --noEmit clean, bun run lint clean, cross-domain allowlist regenerated
  • ✅ All touched test suites passing per PR body

@vex-assistant-bot
Copy link
Copy Markdown
Contributor

@devin review this PR

@vex-assistant-bot
Copy link
Copy Markdown
Contributor

@codex review

@vex-assistant-bot vex-assistant-bot Bot merged commit 2ebdea5 into main May 28, 2026
7 checks passed
@vex-assistant-bot vex-assistant-bot Bot deleted the ashlee/lum-2019-turn-coordinator branch May 28, 2026 21:11
@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

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.

2 participants