Skip to content

Move archived threads off the main read path#1663

Open
juliusmarminge wants to merge 2 commits intomainfrom
codex/backend-perf-archive-read-path
Open

Move archived threads off the main read path#1663
juliusmarminge wants to merge 2 commits intomainfrom
codex/backend-perf-archive-read-path

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented Apr 1, 2026

Summary

  • add UI-facing orchestration read APIs for an active-only snapshot and archived thread summaries
  • switch the main web bootstrap and recovery path to the active-only snapshot
  • move the archived threads settings page to its own targeted query instead of relying on the full global snapshot

Validation

  • bun fmt
  • bun lint
  • cd apps/server && bun run test src/orchestration/Layers/ProjectionSnapshotQuery.test.ts src/server.test.ts src/serverRuntimeStartup.test.ts src/checkpointing/Layers/CheckpointDiffQuery.test.ts src/orchestration/Layers/OrchestrationEngine.test.ts
  • bun typecheck (still fails in pre-existing web files: apps/web/src/rpc/atomRegistry.tsx, apps/web/src/rpc/client.ts, apps/web/src/rpc/serverState.ts)

Notes

  • cd apps/web && bun run test src/wsNativeApi.test.ts src/components/ChatView.browser.tsx src/components/KeybindingsToast.browser.tsx is currently blocked by the same existing @effect/atom-react resolution issue from apps/web/src/rpc/serverState.ts.
  • internal ProjectionSnapshotQuery.getSnapshot() semantics are unchanged for orchestration engine bootstrap; the active-only snapshot is a separate UI read path.

Note

Medium Risk
Introduces new WebSocket/Native API surface and changes the web bootstrap/recovery path to use an active-only snapshot, so mismatches between snapshot filtering and event-driven state could cause missing threads or stale UI until recovery.

Overview
Adds a new UI-facing orchestration read path: ProjectionSnapshotQuery.getActiveSnapshot() hydrates a snapshot that excludes archived/deleted threads and their child rows, alongside listArchivedThreads() for lightweight archive page data.

Plumbs these through contracts/RPC (orchestration.getActiveSnapshot, orchestration.listArchivedThreads + new error types), server ws.ts, and the web Native API/client; web bootstrap and snapshot-recovery now use getActiveSnapshot and invalidate the archived-threads query on archive/unarchive/delete events (with a recovery fallback when thread.unarchived references an unknown thread).

Refactors the archived threads settings panel to fetch via a dedicated React Query (archivedThreadsQueryOptions) with loading/error states and server-derived grouping, and updates tests/mocks accordingly (including excluding archived threads in getFirstActiveThreadIdByProjectId).

Written by Cursor Bugbot for commit 4b155db. This will update automatically on new commits. Configure here.

Note

Move archived threads off the main read path by adding getActiveSnapshot and listArchivedThreads RPCs

  • Replaces the getSnapshot RPC with getActiveSnapshot, which returns a read model containing only non-archived, non-deleted threads and projects, keeping archived data off the hot path.
  • Adds a new listArchivedThreads RPC that returns OrchestrationArchivedThreadSummary records with project metadata, ordered by archivedAt descending.
  • The ArchivedThreadsPanel settings UI now fetches archived threads via React Query (archivedThreadsQueryOptions) instead of reading from the store, and shows explicit loading/error states.
  • EventRouter now calls getActiveSnapshot for snapshot recovery and invalidates the archivedThreads query on thread archive/unarchive/delete events; it also triggers snapshot recovery when an thread.unarchived event references an unknown thread.
  • Behavioral Change: getSnapshot is removed from all clients and server handlers; callers must use getActiveSnapshot or listArchivedThreads instead.

Macroscope summarized 4b155db.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 1, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 0b8bdbc4-0947-4853-a762-8b38dab3a40b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/backend-perf-archive-read-path

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels Apr 1, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Snapshot sync bypasses recovery coordinator sequence tracking
    • Removed refreshActiveSnapshot from unarchiveThread so the store is no longer updated outside the recovery coordinator; the EventRouter already handles thread.unarchived via fallbackToSnapshotRecovery which properly advances latestSequence.
  • ✅ Fixed: Loading state unreachable due to placeholderData always provided
    • Changed placeholderData from (previous) => previous ?? EMPTY_ARCHIVED_THREADS to (previous) => previous so that on first load there is no placeholder data, allowing isPending to be true and the loading UI to render.

Create PR

Or push these changes by commenting:

@cursor push 95dd2b671c
Preview (95dd2b671c)
diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts
--- a/apps/web/src/hooks/useThreadActions.ts
+++ b/apps/web/src/hooks/useThreadActions.ts
@@ -18,7 +18,6 @@
 
 export function useThreadActions() {
   const appSettings = useSettings();
-  const syncServerReadModel = useStore((store) => store.syncServerReadModel);
   const clearComposerDraftForThread = useComposerDraftStore((store) => store.clearDraftThread);
   const clearProjectDraftThreadById = useComposerDraftStore(
     (store) => store.clearProjectDraftThreadById,
@@ -33,13 +32,6 @@
   const queryClient = useQueryClient();
   const removeWorktreeMutation = useMutation(gitRemoveWorktreeMutationOptions({ queryClient }));
 
-  const refreshActiveSnapshot = useCallback(async () => {
-    const api = readNativeApi();
-    if (!api) return;
-    const snapshot = await api.orchestration.getActiveSnapshot();
-    syncServerReadModel(snapshot);
-  }, [syncServerReadModel]);
-
   const archiveThread = useCallback(
     async (threadId: ThreadId) => {
       const api = readNativeApi();
@@ -73,10 +65,9 @@
         commandId: newCommandId(),
         threadId,
       });
-      await refreshActiveSnapshot();
       void queryClient.invalidateQueries({ queryKey: orchestrationQueryKeys.archivedThreads() });
     },
-    [queryClient, refreshActiveSnapshot],
+    [queryClient],
   );
 
   const deleteThread = useCallback(

diff --git a/apps/web/src/lib/orchestrationReactQuery.ts b/apps/web/src/lib/orchestrationReactQuery.ts
--- a/apps/web/src/lib/orchestrationReactQuery.ts
+++ b/apps/web/src/lib/orchestrationReactQuery.ts
@@ -1,4 +1,3 @@
-import type { OrchestrationListArchivedThreadsResult } from "@t3tools/contracts";
 import { queryOptions } from "@tanstack/react-query";
 import { ensureNativeApi } from "~/nativeApi";
 
@@ -8,7 +7,6 @@
 };
 
 const DEFAULT_ARCHIVED_THREADS_STALE_TIME = 15_000;
-const EMPTY_ARCHIVED_THREADS: OrchestrationListArchivedThreadsResult = [];
 
 export function archivedThreadsQueryOptions() {
   return queryOptions({
@@ -18,6 +16,6 @@
       return api.orchestration.listArchivedThreads();
     },
     staleTime: DEFAULT_ARCHIVED_THREADS_STALE_TIME,
-    placeholderData: (previous) => previous ?? EMPTY_ARCHIVED_THREADS,
+    placeholderData: (previous) => previous,
   });
 }

You can send follow-ups to this agent here.

@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp bot commented Apr 1, 2026

Approvability

Verdict: Needs human review

This PR changes the primary data read path by separating archived threads into a dedicated endpoint, affecting what data is returned to the UI by default. While well-tested and the refactoring is clean, the architectural change to data flow and new recovery logic for unarchiving warrants human review.

You can customize Macroscope's approvability policy. Learn more.

@juliusmarminge juliusmarminge force-pushed the codex/backend-perf-archive-read-path branch from bbe5e14 to 097c3a3 Compare April 1, 2026 21:03
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Autofix Details

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Skipped events permanently lost due to premature sequence advancement
    • Split markEventBatchApplied into filterNewEvents (no side effects) and markEventBatchApplied (advances sequence), and deferred the sequence advancement in applyEventBatch until after the snapshot-recovery check passes, so events that trigger early return no longer inflate latestSequence.
  • ✅ Fixed: Archived threads query not invalidated on snapshot-recovery path
    • Added invalidateQueries for orchestrationQueryKeys.archivedThreads() in runSnapshotRecovery after syncing the snapshot, ensuring the ArchivedThreadsPanel reflects updated state immediately after snapshot recovery.

Create PR

Or push these changes by commenting:

@cursor push 51aaa5d579
Preview (51aaa5d579)
diff --git a/apps/web/src/orchestrationRecovery.ts b/apps/web/src/orchestrationRecovery.ts
--- a/apps/web/src/orchestrationRecovery.ts
+++ b/apps/web/src/orchestrationRecovery.ts
@@ -62,10 +62,14 @@
       return "apply";
     },
 
-    markEventBatchApplied<T extends SequencedEvent>(events: ReadonlyArray<T>): ReadonlyArray<T> {
-      const nextEvents = events
+    filterNewEvents<T extends SequencedEvent>(events: ReadonlyArray<T>): ReadonlyArray<T> {
+      return events
         .filter((event) => event.sequence > state.latestSequence)
         .toSorted((left, right) => left.sequence - right.sequence);
+    },
+
+    markEventBatchApplied<T extends SequencedEvent>(events: ReadonlyArray<T>): ReadonlyArray<T> {
+      const nextEvents = this.filterNewEvents(events);
       if (nextEvents.length === 0) {
         return [];
       }

diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx
--- a/apps/web/src/routes/__root.tsx
+++ b/apps/web/src/routes/__root.tsx
@@ -313,7 +313,7 @@
     const applyEventBatch = (
       events: ReadonlyArray<OrchestrationEvent>,
     ): "applied" | "snapshot-recovery-needed" => {
-      const nextEvents = recovery.markEventBatchApplied(events);
+      const nextEvents = recovery.filterNewEvents(events);
       if (nextEvents.length === 0) {
         return "applied";
       }
@@ -327,6 +327,8 @@
         return "snapshot-recovery-needed";
       }
 
+      recovery.markEventBatchApplied(nextEvents);
+
       const batchEffects = deriveOrchestrationBatchEffects(nextEvents);
       const needsProjectUiSync = nextEvents.some(
         (event) =>
@@ -417,6 +419,9 @@
         if (!disposed) {
           syncServerReadModel(snapshot);
           reconcileSnapshotDerivedState();
+          void queryClient.invalidateQueries({
+            queryKey: orchestrationQueryKeys.archivedThreads(),
+          });
           if (recovery.completeSnapshotRecovery(snapshot.snapshotSequence)) {
             void recoverFromSequenceGap();
           }

You can send follow-ups to this agent here.

@juliusmarminge juliusmarminge force-pushed the codex/backend-perf-archive-read-path branch from 097c3a3 to 07f4e5f Compare April 1, 2026 21:41
@juliusmarminge juliusmarminge force-pushed the codex/backend-perf-bootstrap branch 2 times, most recently from 362933c to 8bb2822 Compare April 1, 2026 21:45
@juliusmarminge juliusmarminge force-pushed the codex/backend-perf-archive-read-path branch from 07f4e5f to 0472757 Compare April 1, 2026 21:45
@juliusmarminge juliusmarminge force-pushed the codex/backend-perf-bootstrap branch from 8bb2822 to 379bef6 Compare April 1, 2026 21:49
@juliusmarminge juliusmarminge force-pushed the codex/backend-perf-archive-read-path branch from 0472757 to c21e9a8 Compare April 1, 2026 21:49
Base automatically changed from codex/backend-perf-bootstrap to main April 1, 2026 22:07
Co-authored-by: codex <codex@users.noreply.github.com>
@juliusmarminge juliusmarminge force-pushed the codex/backend-perf-archive-read-path branch from c21e9a8 to 3b01bbe Compare April 1, 2026 22:08
Copy link
Copy Markdown
Contributor

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Loading state unreachable due to placeholderData
    • Removed the ?? EMPTY_ARCHIVED_THREADS fallback from placeholderData so it returns undefined on first load, allowing TanStack Query v5 to correctly start in pending status and render the loading UI.

Create PR

Or push these changes by commenting:

@cursor push 5407e0b61e
Preview (5407e0b61e)
diff --git a/apps/web/src/lib/orchestrationReactQuery.ts b/apps/web/src/lib/orchestrationReactQuery.ts
--- a/apps/web/src/lib/orchestrationReactQuery.ts
+++ b/apps/web/src/lib/orchestrationReactQuery.ts
@@ -1,4 +1,3 @@
-import type { OrchestrationListArchivedThreadsResult } from "@t3tools/contracts";
 import { queryOptions } from "@tanstack/react-query";
 import { ensureNativeApi } from "~/nativeApi";
 
@@ -8,8 +7,6 @@
 };
 
 const DEFAULT_ARCHIVED_THREADS_STALE_TIME = 15_000;
-const EMPTY_ARCHIVED_THREADS: OrchestrationListArchivedThreadsResult = [];
-
 export function archivedThreadsQueryOptions() {
   return queryOptions({
     queryKey: orchestrationQueryKeys.archivedThreads(),
@@ -18,6 +15,6 @@
       return api.orchestration.listArchivedThreads();
     },
     staleTime: DEFAULT_ARCHIVED_THREADS_STALE_TIME,
-    placeholderData: (previous) => previous ?? EMPTY_ARCHIVED_THREADS,
+    placeholderData: (previous) => previous,
   });
 }

You can send follow-ups to this agent here.

return api.orchestration.listArchivedThreads();
},
staleTime: DEFAULT_ARCHIVED_THREADS_STALE_TIME,
placeholderData: (previous) => previous ?? EMPTY_ARCHIVED_THREADS,
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.

Loading state unreachable due to placeholderData

Medium Severity

The placeholderData in archivedThreadsQueryOptions always provides EMPTY_ARCHIVED_THREADS (an empty array) on first render. In TanStack Query v5, when placeholderData is set, the query status becomes "success" immediately, so isPending is always false. This makes the "Loading archived threads" UI in ArchivedThreadsPanel dead code — it can never render. Instead, users briefly see "No archived threads" before real data arrives, which is misleading when archived threads exist.

Additional Locations (1)
Fix in Cursor Fix in Web

Co-authored-by: codex <codex@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant