refactor(threads): consolidate thread-management hooks; eliminate refetch storm#3372
refactor(threads): consolidate thread-management hooks; eliminate refetch storm#3372tlgimenes wants to merge 6 commits into
Conversation
🧪 BenchmarkShould we run the Virtual MCP strategy benchmark for this PR? React with 👍 to run the benchmark.
Benchmark will run on the next push after you react. |
Release OptionsSuggested: Patch ( React with an emoji to override the release type:
Current version:
|
There was a problem hiding this comment.
6 issues found across 28 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/mesh/src/web/components/chat/task/thread-actions.ts">
<violation number="1" location="apps/mesh/src/web/components/chat/task/thread-actions.ts:126">
P2: Mutation errors are swallowed in `withOptimistic`; rethrow after rollback/toast so callers can handle failure states.</violation>
<violation number="2" location="apps/mesh/src/web/components/chat/task/thread-actions.ts:154">
P1: `setStatus` accepts display/optional statuses that the update API does not support (e.g. `"expired"`). Restrict the parameter type to persisted thread statuses.</violation>
</file>
<file name="apps/mesh/src/web/components/chat/task/use-task-manager.ts">
<violation number="1" location="apps/mesh/src/web/components/chat/task/use-task-manager.ts:108">
P2: `updateTask` was kept for compatibility but currently does nothing, which breaks existing callers that depend on local task cache patching.</violation>
</file>
<file name="apps/mesh/src/web/hooks/use-ensure-task.ts">
<violation number="1" location="apps/mesh/src/web/hooks/use-ensure-task.ts:102">
P2: Prepending `createdRow` without an id check can create duplicate thread entries in cache.</violation>
</file>
<file name="apps/mesh/src/web/components/chat/task/thread-events.tsx">
<violation number="1" location="apps/mesh/src/web/components/chat/task/thread-events.tsx:39">
P2: Synthetic rows inserted from SSE omit filter-critical fields (`created_by` / `virtual_mcp_id` / `fromAutomation`), so common client-side filters can hide the new thread immediately.</violation>
<violation number="2" location="apps/mesh/src/web/components/chat/task/thread-events.tsx:59">
P2: Patching `updated_at` without reordering keeps thread lists out of `updated_at desc` order after SSE events.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| branch: "branch" in patch ? (patch.branch ?? null) : current.branch, | ||
| }; | ||
| const items = [...data.items]; | ||
| items[idx] = next; |
There was a problem hiding this comment.
P2: Patching updated_at without reordering keeps thread lists out of updated_at desc order after SSE events.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/web/components/chat/task/thread-events.tsx, line 59:
<comment>Patching `updated_at` without reordering keeps thread lists out of `updated_at desc` order after SSE events.</comment>
<file context>
@@ -0,0 +1,87 @@
+ branch: "branch" in patch ? (patch.branch ?? null) : current.branch,
+ };
+ const items = [...data.items];
+ items[idx] = next;
+ return { ...data, items };
+}
</file context>
There was a problem hiding this comment.
4 issues found across 25 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/mesh/src/web/hooks/use-task-expanded-tools.ts">
<violation number="1">
P2: Avoid broad `tasksPrefix` invalidation here; it causes unnecessary task-list refetches for a metadata-only update and undermines the refetch-storm reduction in this thread refactor.</violation>
</file>
<file name="apps/mesh/src/web/lib/read-cached-last-thread.ts">
<violation number="1">
P1: This uses the legacy `tasks` cache prefix after the key migration, so cached threads may not be found and the app can incorrectly fall back to creating a new thread.</violation>
</file>
<file name="apps/mesh/src/web/layouts/tasks-panel/index.tsx">
<violation number="1">
P2: Pass `userId` to the `owner: "me"` task query so the cache key stays user-scoped and does not leak/reuse another user’s "my tasks" cache.</violation>
</file>
<file name="apps/mesh/src/web/components/chat/task/use-task-manager.ts">
<violation number="1">
P1: `useTaskManager` now registers another org-wide SSE listener even though `<ThreadEventsBridge />` already handles thread SSE globally. This duplicates event processing and can reintroduce unnecessary task-list refetches via the `!found -> invalidateQueries` path.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
ba68661 to
b9e1f94
Compare
There was a problem hiding this comment.
1 issue found across 8 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/mesh/src/web/components/chat/hooks/thread-attach-registry.ts">
<violation number="1" location="apps/mesh/src/web/components/chat/hooks/thread-attach-registry.ts:259">
P1: Dropping `streaming` on id collision can hide freshly patched tool/approval state and break auto-continuation logic. Prefer `streaming` as the latest version for that id instead of omitting it.</violation>
</file>
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
| const initialMessages = this.context?.initialMessages ?? []; | ||
| const out = mergeWithServer(initialMessages, this.localMessages.get()); | ||
| const s = this.streaming.get(); | ||
| if (s && !out.some((m) => m.id === s.id)) out.push(s); |
There was a problem hiding this comment.
P1: Dropping streaming on id collision can hide freshly patched tool/approval state and break auto-continuation logic. Prefer streaming as the latest version for that id instead of omitting it.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/mesh/src/web/components/chat/hooks/thread-attach-registry.ts, line 259:
<comment>Dropping `streaming` on id collision can hide freshly patched tool/approval state and break auto-continuation logic. Prefer `streaming` as the latest version for that id instead of omitting it.</comment>
<file context>
@@ -245,12 +245,18 @@ class ThreadConnection {
const out = mergeWithServer(initialMessages, this.localMessages.get());
const s = this.streaming.get();
- if (s) out.push(s);
+ if (s && !out.some((m) => m.id === s.id)) out.push(s);
return out;
}
</file context>
| if (s && !out.some((m) => m.id === s.id)) out.push(s); | |
| if (s) { | |
| const idx = out.findIndex((m) => m.id === s.id); | |
| if (idx >= 0) out[idx] = s; | |
| else out.push(s); | |
| } |
Tip: Review your code locally with the cubic CLI to iterate faster.
243b1a2 to
c6b62a8
Compare
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…als dispatch The server's idempotency fallback in /messages keys on the last message id when no x-idempotency-key header is supplied. For tool-approval continuations the client re-POSTs the same assistant message with updated tool states, so two consecutive approvals on the same assistant message produce the same fallback key — DBOS collapses the second POST onto the first (completed) workflow and no new run dispatches, leaving the chat stuck in "submitted". Send a SHA-1 of the serialized body as x-idempotency-key. Byte-identical retries still collapse, but a second approval (different tool state in message.parts) hashes differently and gets its own workflow. To keep the hash deterministic across continuations, only attach the system prompt on user turns — otherwise its random uuid id would perturb the digest on every POST. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`shouldCollapse` was gated only on `!isLoading`. When a run pauses on `finishReason: "tool-calls"` (awaiting approval, user_ask, or propose_plan) the chat status drops to "ready" so `isLoading` becomes false, and with 3+ tool calls the intermediate parts get folded into the "X tool calls, Y messages" chip even though the turn isn't actually over. The run will resume after the user responds and emit more parts, which then render as a separate uncollapsed group. Tighten the gate: for the last message, also require `finishReason === "stop"`. Non-last messages are by definition finalized and keep collapsing as before. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… logic - Replace deprecated updateTask stub with cache-only patchTask (renames done by server via SSE patch path; no extra COLLECTION_THREADS_UPDATE round-trip on thread-title chunks). - Gate thread-outputs invalidation on share_with_user tool output, so every assistant turn no longer refetches download chips. - Derive assistant-message collapse from the message's own tool-part states instead of session-scoped finishReason, so server-loaded threads collapse correctly while paused turns stay expanded. - Dedupe streaming against initialMessages in ThreadConnection.snapshotAll to avoid duplicate React keys after a tool-calls pause refetch. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
c6b62a8 to
dd548ef
Compare
The consolidation refactor in dd548ef replaced the finishReason check with a derivation from `message.parts`, using a non-null assertion. But MessageAssistant is designed to accept `message: null` (hasContent on line 583 explicitly handles it). When the last pair has no assistant message yet, `!isLast` doesn't short-circuit and `message!.parts` throws TypeError, tripping ErrorBoundary. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Eliminates the
COLLECTION_THREADS_LISTrefetch storm on every chat message — 12 → 0 server requests per message round-trip — with a single new component and three small invalidation removals. Six files changed.What this PR does
Adds
<ThreadEventsBridge />, a single SSE listener that runs once near the app root and patches allKEYS.tasksPrefix(locator)caches viasetQueriesData. Inserts a synthetic row for unknown thread ids (cross-tab / new-thread case), updates row-in-place for known ids. Never invalidates.Removes the three independent invalidators that previously fanned out across every
useTaskssubscription on every SSE event:useTasksAutoRefresh(deleted; replaced by the bridge)useStreamManager.onTaskStatus+onFinish'sinvalidateThreadListcalls (stripped; messages and outputs invalidation kept)tasks-panel/index.tsx(removed; bridge handles it)The existing
useTaskManager.useDecopilotEvents.onTaskStatusstill runs (untouched in this PR), but the bridge runs first and inserts the row, so itsif (!found) invalidateQueriesbranch never fires.Files
Root cause (the bug this fixes)
Four
useSuspenseQueryinstances subscribed to the thread list with differentwherefilters as part of the query key. Three independent SSE listeners (useTasksAutoRefresh,useStreamManager,useTaskManager) all calledinvalidateQueries({queryKey: tasksPrefix})on everydecopilot.thread.statusevent. A single message round-trip fired ~3 SSE events × 4 list subscriptions = 12 `COLLECTION_THREADS_LIST` requests per user message, plus 8 on mount (StrictMode 2× × 4 lists).Verified locally: sending a message after this change produces 0
COLLECTION_THREADS_LISTrequests during the round-trip.Out of scope (follow-up PR)
A larger consolidation — the new
useThreads/useThreadActions/filterThreadsmodule that collapses the 4useTaskssubscriptions into 1–2 — was reverted from this PR to keep the diff minimal. That refactor will land separately. It's an additive improvement; this PR already removes the refetch storm without it.Test plan
COLLECTION_THREADS_LISTPOST in the Network panel🤖 Generated with Claude Code