Skip to content

refactor(threads): consolidate thread-management hooks; eliminate refetch storm#3372

Open
tlgimenes wants to merge 6 commits into
mainfrom
tlgimenes/thread-management-consolidation
Open

refactor(threads): consolidate thread-management hooks; eliminate refetch storm#3372
tlgimenes wants to merge 6 commits into
mainfrom
tlgimenes/thread-management-consolidation

Conversation

@tlgimenes
Copy link
Copy Markdown
Contributor

@tlgimenes tlgimenes commented May 15, 2026

Summary

Eliminates the COLLECTION_THREADS_LIST refetch 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 all KEYS.tasksPrefix(locator) caches via setQueriesData. 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 useTasks subscription on every SSE event:

  • useTasksAutoRefresh (deleted; replaced by the bridge)
  • useStreamManager.onTaskStatus + onFinish's invalidateThreadList calls (stripped; messages and outputs invalidation kept)
  • The auto-refresh hook call in tasks-panel/index.tsx (removed; bridge handles it)

The existing useTaskManager.useDecopilotEvents.onTaskStatus still runs (untouched in this PR), but the bridge runs first and inserts the row, so its if (!found) invalidateQueries branch never fires.

Files

apps/mesh/src/web/components/chat/task/thread-events.tsx     +87
apps/mesh/src/web/components/chat/task/index.ts              +1
apps/mesh/src/web/layouts/org-shell-layout/index.tsx         +2
apps/mesh/src/web/components/chat/hooks/use-stream-manager.ts +4 -22
apps/mesh/src/web/layouts/tasks-panel/index.tsx                -2
apps/mesh/src/web/hooks/use-tasks-auto-refresh.ts                -23

Root cause (the bug this fixes)

Four useSuspenseQuery instances subscribed to the thread list with different where filters as part of the query key. Three independent SSE listeners (useTasksAutoRefresh, useStreamManager, useTaskManager) all called invalidateQueries({queryKey: tasksPrefix}) on every decopilot.thread.status event. 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_LIST requests during the round-trip.

Out of scope (follow-up PR)

A larger consolidation — the new useThreads / useThreadActions / filterThreads module that collapses the 4 useTasks subscriptions 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

  • CI green
  • Manual: open a chat URL, send a message, confirm the tasks-panel updates without any COLLECTION_THREADS_LIST POST in the Network panel
  • Manual: cross-tab — start a chat in another tab, verify it appears in the sidebar (bridge inserts synthetic row from SSE)
  • Manual: archive a task — verify the row updates (no refetch needed)

🤖 Generated with Claude Code

@github-actions
Copy link
Copy Markdown
Contributor

🧪 Benchmark

Should we run the Virtual MCP strategy benchmark for this PR?

React with 👍 to run the benchmark.

Reaction Action
👍 Run quick benchmark (10 & 128 tools)

Benchmark will run on the next push after you react.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 15, 2026

Release Options

Suggested: Patch (2.330.2) — based on refactor: prefix

React with an emoji to override the release type:

Reaction Type Next Version
👍 Prerelease 2.330.2-alpha.1
🎉 Patch 2.330.2
❤️ Minor 2.331.0
🚀 Major 3.0.0

Current version: 2.330.1

Note: If multiple reactions exist, the smallest bump wins. If no reactions, the suggested bump is used (default: patch).

Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

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.

Comment thread apps/mesh/src/web/components/chat/task/thread-actions.ts
Comment thread apps/mesh/src/web/components/chat/task/thread-actions.ts
Comment thread apps/mesh/src/web/components/chat/task/use-task-manager.ts Outdated
Comment thread apps/mesh/src/web/hooks/use-ensure-task.ts
branch: "branch" in patch ? (patch.branch ?? null) : current.branch,
};
const items = [...data.items];
items[idx] = next;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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>

Comment thread apps/mesh/src/web/components/chat/task/thread-events.tsx
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

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

Comment thread apps/mesh/src/web/layouts/tasks-panel/index.tsx
@tlgimenes tlgimenes force-pushed the tlgimenes/thread-management-consolidation branch 3 times, most recently from ba68661 to b9e1f94 Compare May 15, 2026 20:31
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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>
Suggested change
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.

@tlgimenes tlgimenes force-pushed the tlgimenes/thread-management-consolidation branch from 243b1a2 to c6b62a8 Compare May 16, 2026 15:49
tlgimenes and others added 5 commits May 16, 2026 12:52
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>
@tlgimenes tlgimenes force-pushed the tlgimenes/thread-management-consolidation branch from c6b62a8 to dd548ef Compare May 16, 2026 15:53
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>
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