Refactor web orchestration sync to incremental events and isolated recovery#1560
Refactor web orchestration sync to incremental events and isolated recovery#1560juliusmarminge wants to merge 19 commits intomainfrom
Conversation
- Apply orchestration events incrementally instead of resyncing full snapshots - Reduce store subscription churn with cached thread snapshots and selectors - Update store tests for incremental event handling
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Filter identity check always fails, causing unnecessary updates
- Replaced the always-false
filter() === originalidentity checks with length comparisons to correctly detect when no element was actually removed.
- Replaced the always-false
Or push these changes by commenting:
@cursor push a1f6b8993a
Preview (a1f6b8993a)
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts
--- a/apps/web/src/store.ts
+++ b/apps/web/src/store.ts
@@ -568,7 +568,9 @@
case "project.deleted": {
const projects = state.projects.filter((project) => project.id !== event.payload.projectId);
- return projects === state.projects ? state : { ...state, projects, threadsHydrated: true };
+ return projects.length === state.projects.length
+ ? state
+ : { ...state, projects, threadsHydrated: true };
}
case "thread.created": {
@@ -604,7 +606,9 @@
case "thread.deleted": {
const threads = state.threads.filter((thread) => thread.id !== event.payload.threadId);
- return threads === state.threads ? state : { ...state, threads, threadsHydrated: true };
+ return threads.length === state.threads.length
+ ? state
+ : { ...state, threads, threadsHydrated: true };
}
case "thread.archived": {Co-authored-by: codex <codex@users.noreply.github.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Turn-diff-completed can regress latestTurn to older turn
- Added a guard so the turn-diff-completed handler only updates latestTurn when the event's turnId matches the current latestTurn or latestTurn is null, preventing delayed checkpoint events for older turns from regressing latestTurn.
Or push these changes by commenting:
@cursor push 0927687131
Preview (0927687131)
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts
--- a/apps/web/src/store.ts
+++ b/apps/web/src/store.ts
@@ -876,24 +876,22 @@
(right.checkpointTurnCount ?? Number.MAX_SAFE_INTEGER),
)
.slice(-MAX_THREAD_CHECKPOINTS);
+ const latestTurn =
+ thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId
+ ? buildLatestTurn({
+ previous: thread.latestTurn,
+ turnId: event.payload.turnId,
+ state: checkpointStatusToLatestTurnState(event.payload.status),
+ requestedAt: thread.latestTurn?.requestedAt ?? event.payload.completedAt,
+ startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt,
+ completedAt: event.payload.completedAt,
+ assistantMessageId: event.payload.assistantMessageId,
+ })
+ : thread.latestTurn;
return {
...thread,
turnDiffSummaries,
- latestTurn: buildLatestTurn({
- previous: thread.latestTurn,
- turnId: event.payload.turnId,
- state: checkpointStatusToLatestTurnState(event.payload.status),
- requestedAt:
- thread.latestTurn?.turnId === event.payload.turnId
- ? thread.latestTurn.requestedAt
- : event.payload.completedAt,
- startedAt:
- thread.latestTurn?.turnId === event.payload.turnId
- ? (thread.latestTurn.startedAt ?? event.payload.completedAt)
- : event.payload.completedAt,
- completedAt: event.payload.completedAt,
- assistantMessageId: event.payload.assistantMessageId,
- }),
+ latestTurn,
updatedAt: event.occurredAt,
};
});- Keep store identity unchanged for missing project/thread deletes - Avoid regressing latestTurn when an older turn diff completes late
There was a problem hiding this comment.
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: Module-level Map cache never cleaned up, unlike WeakMap
- Added a useEffect that prunes entries from threadPlanCatalogCache for thread IDs no longer present in the store's thread list.
- ✅ Fixed: Removed snapshot sync causes race with navigation
- Restored the getSnapshot + syncServerReadModel calls before navigation and in the error cleanup path, ensuring the new thread exists in the store before ChatView renders.
Or push these changes by commenting:
@cursor push 342dacc8c1
Preview (342dacc8c1)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -494,6 +494,14 @@
const threadPlanCatalog = useStore(
useShallow((store) => store.threads.map(toThreadPlanCatalogEntry)),
);
+ useEffect(() => {
+ const activeIds = new Set(threadPlanCatalog.map((t) => t.id));
+ for (const id of threadPlanCatalogCache.keys()) {
+ if (!activeIds.has(id)) {
+ threadPlanCatalogCache.delete(id);
+ }
+ }
+ }, [threadPlanCatalog]);
const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null);
const localDraftThread = useMemo(
() =>
@@ -3158,7 +3166,9 @@
createdAt,
});
})
- .then(() => {
+ .then(() => api.orchestration.getSnapshot())
+ .then((snapshot) => {
+ useStore.getState().syncServerReadModel(snapshot);
// Signal that the plan sidebar should open on the new thread.
planSidebarOpenOnNextThreadRef.current = true;
return navigate({
@@ -3174,6 +3184,12 @@
threadId: nextThreadId,
})
.catch(() => undefined);
+ await api.orchestration
+ .getSnapshot()
+ .then((snapshot) => {
+ useStore.getState().syncServerReadModel(snapshot);
+ })
+ .catch(() => undefined);
toastManager.add({
type: "error",
title: "Could not start implementation thread",- Derive batch effects for draft and terminal-state cleanup - Remove terminal state entries on thread delete - Add tests for lifecycle effect handling
- Split orchestration effects for promoted vs deleted threads - Add single-thread draft cleanup helper and update route handling - Cover promotion cleanup behavior with store and effect tests
- Rename store hydration flag to bootstrapComplete - Only clear missing-thread redirects after snapshot sync
- Split project and thread UI state from server data - Preserve sidebar ordering and unread tracking - Co-authored-by: codex <codex@users.noreply.github.com>
- Read threads and projects from store state on demand - Expose a default project id from the new-thread hook - Skip extra work in global chat shortcut handling
- Simulate thread.created pushes in browser tests - Verify promoted drafts clear via live batch effects
- Replace transient send phase state with local dispatch snapshots - Clear the busy state only after the server reflects the turn/session update - Cover the acknowledgment rules with logic tests
- Move snapshot/replay sequencing state into a shared coordinator - Add tests for deferred replay, gap recovery, and replay fallback
- Replace unbounded Map cache with LRU limits - Estimate per-thread plan entry size before caching
- Add thread-start detection helper and wait logic - Cover immediate, subscription-driven, and timeout cases
There was a problem hiding this comment.
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: Duplicated project ordering logic across two files
- Extracted a shared
orderByPriorityutility inlib/utils.tsand replaced both independent implementations inuseHandleNewThread.tsandSidebar.tsxwith calls to it.
- Extracted a shared
- ✅ Fixed: Selector factories cause unnecessary per-render function allocations
- Added a simple Map-based cache to both
selectProjectByIdandselectThreadByIdso the same argument always returns the identical selector reference, enabling Zustand's fast-path identity check.
- Added a simple Map-based cache to both
Or push these changes by commenting:
@cursor push c83a7972fc
Preview (c83a7972fc)
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -54,7 +54,13 @@
import { isElectron } from "../env";
import { APP_STAGE_LABEL, APP_VERSION } from "../branding";
import { isTerminalFocused } from "../lib/terminalFocus";
-import { isLinuxPlatform, isMacPlatform, newCommandId, newProjectId } from "../lib/utils";
+import {
+ isLinuxPlatform,
+ isMacPlatform,
+ newCommandId,
+ newProjectId,
+ orderByPriority,
+} from "../lib/utils";
import { useStore } from "../store";
import { useUiStateStore } from "../uiStateStore";
import {
@@ -500,18 +506,10 @@
const platform = navigator.platform;
const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop;
const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately;
- const orderedProjects = useMemo(() => {
- if (projectOrder.length === 0) {
- return projects;
- }
- const projectsById = new Map(projects.map((project) => [project.id, project] as const));
- const ordered = projectOrder.flatMap((projectId) => {
- const project = projectsById.get(projectId);
- return project ? [project] : [];
- });
- const remaining = projects.filter((project) => !projectOrder.includes(project.id));
- return [...ordered, ...remaining];
- }, [projectOrder, projects]);
+ const orderedProjects = useMemo(
+ () => orderByPriority(projects, projectOrder, (p) => p.id),
+ [projectOrder, projects],
+ );
const sidebarProjects = useMemo<SidebarProjectSnapshot[]>(
() =>
orderedProjects.map((project) => ({
diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts
--- a/apps/web/src/hooks/useHandleNewThread.ts
+++ b/apps/web/src/hooks/useHandleNewThread.ts
@@ -7,7 +7,7 @@
type DraftThreadState,
useComposerDraftStore,
} from "../composerDraftStore";
-import { newThreadId } from "../lib/utils";
+import { newThreadId, orderByPriority } from "../lib/utils";
import { selectThreadById, useStore } from "../store";
import { useUiStateStore } from "../uiStateStore";
@@ -23,15 +23,10 @@
const activeDraftThread = useComposerDraftStore((store) =>
routeThreadId ? (store.draftThreadsByThreadId[routeThreadId] ?? null) : null,
);
- const orderedProjects = useMemo(() => {
- if (projectOrder.length === 0) {
- return projectIds;
- }
- const projectIdsSet = new Set(projectIds);
- const ordered = projectOrder.filter((projectId) => projectIdsSet.has(projectId));
- const remaining = projectIds.filter((projectId) => !projectOrder.includes(projectId));
- return [...ordered, ...remaining];
- }, [projectIds, projectOrder]);
+ const orderedProjects = useMemo(
+ () => orderByPriority(projectIds, projectOrder, (id) => id),
+ [projectIds, projectOrder],
+ );
const handleNewThread = useCallback(
(
diff --git a/apps/web/src/lib/utils.ts b/apps/web/src/lib/utils.ts
--- a/apps/web/src/lib/utils.ts
+++ b/apps/web/src/lib/utils.ts
@@ -34,3 +34,23 @@
export const newThreadId = (): ThreadId => ThreadId.makeUnsafe(randomUUID());
export const newMessageId = (): MessageId => MessageId.makeUnsafe(randomUUID());
+
+/**
+ * Reorder `items` so that those whose key appears in `orderedKeys` come first
+ * (in `orderedKeys` order), followed by the remaining items in their original order.
+ */
+export function orderByPriority<T>(
+ items: readonly T[],
+ orderedKeys: readonly string[],
+ getKey: (item: T) => string,
+): T[] {
+ if (orderedKeys.length === 0) return items.slice();
+ const itemsByKey = new Map(items.map((item) => [getKey(item), item] as const));
+ const ordered = orderedKeys.flatMap((key) => {
+ const item = itemsByKey.get(key);
+ return item ? [item] : [];
+ });
+ const orderedKeySet = new Set(orderedKeys);
+ const remaining = items.filter((item) => !orderedKeySet.has(getKey(item)));
+ return [...ordered, ...remaining];
+}
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts
--- a/apps/web/src/store.ts
+++ b/apps/web/src/store.ts
@@ -852,15 +852,39 @@
return events.reduce((nextState, event) => applyOrchestrationEvent(nextState, event), state);
}
-export const selectProjectById =
- (projectId: Project["id"] | null | undefined) =>
- (state: AppState): Project | undefined =>
- projectId ? state.projects.find((project) => project.id === projectId) : undefined;
+const _projectSelectorCache = new Map<
+ string | null | undefined,
+ (state: AppState) => Project | undefined
+>();
+export function selectProjectById(
+ projectId: Project["id"] | null | undefined,
+): (state: AppState) => Project | undefined {
+ const key = projectId ?? null;
+ let selector = _projectSelectorCache.get(key);
+ if (!selector) {
+ selector = (state: AppState) =>
+ projectId ? state.projects.find((project) => project.id === projectId) : undefined;
+ _projectSelectorCache.set(key, selector);
+ }
+ return selector;
+}
-export const selectThreadById =
- (threadId: ThreadId | null | undefined) =>
- (state: AppState): Thread | undefined =>
- threadId ? state.threads.find((thread) => thread.id === threadId) : undefined;
+const _threadSelectorCache = new Map<
+ string | null | undefined,
+ (state: AppState) => Thread | undefined
+>();
+export function selectThreadById(
+ threadId: ThreadId | null | undefined,
+): (state: AppState) => Thread | undefined {
+ const key = threadId ?? null;
+ let selector = _threadSelectorCache.get(key);
+ if (!selector) {
+ selector = (state: AppState) =>
+ threadId ? state.threads.find((thread) => thread.id === threadId) : undefined;
+ _threadSelectorCache.set(key, selector);
+ }
+ return selector;
+}
export function setError(state: AppState, threadId: ThreadId, error: string | null): AppState {
const threads = updateThread(state.threads, threadId, (t) => {- Share preferred-id ordering across sidebar and new-thread flows - Add resilient thread startup wait logic and selector hooks - Cover the new ordering helper and race condition in tests
- Skip duplicate preferred IDs when ordering sidebar items - Add regression test for repeated preferred IDs
- Replace inferred local types with explicit shared contract exports - Keep router, chat, and sidebar tests aligned with contract shapes
- Reorder ChatView helper declarations for readability - No functional change
There was a problem hiding this comment.
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: Empty replay response causes infinite recovery loop
- Added a no-progress check: if latestSequence doesn't advance after a replay batch, the recovery loop now falls back to snapshot recovery instead of looping infinitely.
- ✅ Fixed: Thread plan catalog recomputes on every store change
- Replaced the useShallow+map selector with a direct store.threads subscription and useMemo, so the O(n) mapping only runs when the threads array reference changes rather than on every store update.
Or push these changes by commenting:
@cursor push dde2fc9315
Preview (dde2fc9315)
diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx
--- a/apps/web/src/components/ChatView.tsx
+++ b/apps/web/src/components/ChatView.tsx
@@ -28,7 +28,6 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useDebouncedValue } from "@tanstack/react-pacer";
import { useNavigate, useSearch } from "@tanstack/react-router";
-import { useShallow } from "zustand/react/shallow";
import { gitBranchesQueryOptions, gitCreateWorktreeMutationOptions } from "~/lib/gitReactQuery";
import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery";
import { serverConfigQueryOptions, serverQueryKeys } from "~/lib/serverReactQuery";
@@ -591,9 +590,8 @@
);
const fallbackDraftProject = useProjectById(draftThread?.projectId);
- const threadPlanCatalog = useStore(
- useShallow((store) => store.threads.map(toThreadPlanCatalogEntry)),
- );
+ const threads = useStore((store) => store.threads);
+ const threadPlanCatalog = useMemo(() => threads.map(toThreadPlanCatalogEntry), [threads]);
const localDraftError = serverThread ? null : (localDraftErrorsByThreadId[threadId] ?? null);
const localDraftThread = useMemo(
() =>
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
@@ -250,8 +250,10 @@
return;
}
+ const sequenceBefore = recovery.getState().latestSequence;
+
try {
- const events = await api.orchestration.replayEvents(recovery.getState().latestSequence);
+ const events = await api.orchestration.replayEvents(sequenceBefore);
if (!disposed) {
applyEventBatch(events);
}
@@ -261,7 +263,17 @@
return;
}
- if (!disposed && recovery.completeReplayRecovery()) {
+ if (disposed) {
+ return;
+ }
+
+ if (recovery.getState().latestSequence === sequenceBefore) {
+ recovery.failReplayRecovery();
+ void fallbackToSnapshotRecovery();
+ return;
+ }
+
+ if (recovery.completeReplayRecovery()) {
void recoverFromSequenceGap();
}
};- Scope thread plan catalog selection to active/source threads - Avoid immediate replay retries when recovery makes no progress
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Missing latestTurn regression guard in message-sent handler
- Added the same
thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnIdguard used bythread.turn-diff-completedto thethread.message-senthandler, preventing late-arriving older-turn messages from regressinglatestTurn.
- Added the same
Or push these changes by commenting:
@cursor push 9a9ecd8f0d
Preview (9a9ecd8f0d)
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts
--- a/apps/web/src/store.ts
+++ b/apps/web/src/store.ts
@@ -608,7 +608,9 @@
: [...thread.messages, message];
const cappedMessages = messages.slice(-MAX_THREAD_MESSAGES);
const latestTurn: Thread["latestTurn"] =
- event.payload.role === "assistant" && event.payload.turnId !== null
+ event.payload.role === "assistant" &&
+ event.payload.turnId !== null &&
+ (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId)
? buildLatestTurn({
previous: thread.latestTurn,
turnId: event.payload.turnId,Add the same defensive guard that thread.turn-diff-completed uses to prevent a late-arriving assistant message from an older turn from overwriting latestTurn, which could regress the current turn's progress. Applied via @cursor push command
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Incremental event path drops
sourceProposedPlanfromlatestTurn- Added a
pendingSourceProposedPlanfield toThread, storedsourceProposedPlanfrom theturn-start-requestedevent, and passed it as a fallback tobuildLatestTurnso new turns on the incremental path preserve the plan reference.
- Added a
Or push these changes by commenting:
@cursor push 921270aa86
Preview (921270aa86)
diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts
--- a/apps/web/src/store.ts
+++ b/apps/web/src/store.ts
@@ -158,6 +158,7 @@
archivedAt: thread.archivedAt,
updatedAt: thread.updatedAt,
latestTurn: thread.latestTurn,
+ pendingSourceProposedPlan: thread.latestTurn?.sourceProposedPlan,
branch: thread.branch,
worktreePath: thread.worktreePath,
turnDiffSummaries: thread.checkpoints.map(mapTurnDiffSummary),
@@ -214,7 +215,12 @@
startedAt: string | null;
completedAt: string | null;
assistantMessageId: NonNullable<Thread["latestTurn"]>["assistantMessageId"];
+ sourceProposedPlan?: Thread["pendingSourceProposedPlan"];
}): NonNullable<Thread["latestTurn"]> {
+ const resolvedPlan =
+ params.previous?.turnId === params.turnId
+ ? params.previous.sourceProposedPlan
+ : params.sourceProposedPlan;
return {
turnId: params.turnId,
state: params.state,
@@ -222,9 +228,7 @@
startedAt: params.startedAt,
completedAt: params.completedAt,
assistantMessageId: params.assistantMessageId,
- ...(params.previous?.turnId === params.turnId && params.previous.sourceProposedPlan
- ? { sourceProposedPlan: params.previous.sourceProposedPlan }
- : {}),
+ ...(resolvedPlan ? { sourceProposedPlan: resolvedPlan } : {}),
};
}
@@ -534,6 +538,7 @@
: {}),
runtimeMode: event.payload.runtimeMode,
interactionMode: event.payload.interactionMode,
+ pendingSourceProposedPlan: event.payload.sourceProposedPlan,
updatedAt: event.occurredAt,
}));
return threads === state.threads ? state : { ...state, threads };
@@ -629,6 +634,7 @@
thread.latestTurn?.turnId === event.payload.turnId
? (thread.latestTurn.startedAt ?? event.payload.createdAt)
: event.payload.createdAt,
+ sourceProposedPlan: thread.pendingSourceProposedPlan,
completedAt: event.payload.streaming
? thread.latestTurn?.turnId === event.payload.turnId
? (thread.latestTurn.completedAt ?? null)
@@ -671,6 +677,7 @@
thread.latestTurn?.turnId === event.payload.session.activeTurnId
? thread.latestTurn.assistantMessageId
: null,
+ sourceProposedPlan: thread.pendingSourceProposedPlan,
})
: thread.latestTurn,
updatedAt: event.occurredAt,
@@ -755,6 +762,7 @@
startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt,
completedAt: event.payload.completedAt,
assistantMessageId: event.payload.assistantMessageId,
+ sourceProposedPlan: thread.pendingSourceProposedPlan,
})
: thread.latestTurn;
return {
diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts
--- a/apps/web/src/types.ts
+++ b/apps/web/src/types.ts
@@ -104,6 +104,7 @@
archivedAt: string | null;
updatedAt?: string | undefined;
latestTurn: OrchestrationLatestTurn | null;
+ pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"];
branch: string | null;
worktreePath: string | null;
turnDiffSummaries: TurnDiffSummary[];| interactionMode: event.payload.interactionMode, | ||
| updatedAt: event.occurredAt, | ||
| })); | ||
| return threads === state.threads ? state : { ...state, threads }; |
There was a problem hiding this comment.
Incremental event path drops sourceProposedPlan from latestTurn
High Severity
The thread.turn-start-requested handler updates modelSelection, runtimeMode, and interactionMode but never stores event.payload.sourceProposedPlan onto latestTurn. The contracts schema (ThreadTurnStartRequestedPayload) includes an optional sourceProposedPlan field, and findSidebarProposedPlan plus useThreadPlanCatalog in ChatView depend on latestTurn.sourceProposedPlan to look up the originating plan and display the plan sidebar for implementation threads. The snapshot sync path preserves this field (via mapThread copying thread.latestTurn directly), but the incremental event path silently drops it, breaking plan sidebar navigation for any turn started after bootstrap.



Summary
getSnapshot()calls.Testing
bun fmtbun lintbun typecheckbun run test --filter=@t3tools/webcd apps/web && bun run test:browser -- src/components/ChatView.browser.tsxNote
High Risk
Touches core state management and websocket event processing, including recovery logic and persisted UI state, so regressions could affect routing, thread/project lists, and live chat updates. Added complexity around sequencing/replay increases the chance of subtle edge-case bugs.
Overview
Orchestration syncing is refactored from “snapshot refresh on events” to incremental domain-event application with explicit recovery.
EventRouternow applies batches viaapplyOrchestrationEvents, detects sequence gaps, replays missing events, and falls back to snapshot bootstrap/recovery using a newcreateOrchestrationRecoveryCoordinator.UI-only metadata is moved out of the main app store into a new persisted
useUiStateStore. Project expansion/order and thread last-visited/unread state are no longer stored/persisted instoretypes; components (Sidebar,ChatView, route guards) are updated to read/write UI state from the new store and use narrower selectors (storeSelectors).Chat/Sidebar behaviors are adjusted to be event-driven and less snapshot-dependent. Draft-thread promotion/cleanup is driven by
thread.createdeffects (clearPromotedDraftThread(s)+deriveOrchestrationBatchEffects), thread creation navigation waits for observed server start (waitForStartedServerThread), send-busy state is tracked viaLocalDispatchSnapshot, and terminal state cleanup can remove per-thread state on deletes.Tests are expanded/updated to cover recovery coordination, batch side effects, incremental reducers, UI state persistence rules, and browser tests now simulate live
domainEventpushes rather than manual snapshot syncs.Written by Cursor Bugbot for commit ea6a5ae. This will update automatically on new commits. Configure here.
Note
Refactor web orchestration sync to apply domain events incrementally with isolated recovery
applyOrchestrationEvent/applyOrchestrationEventsreducer in store.ts, mapping orchestration events directly to app state.deriveOrchestrationBatchEffectsin orchestrationEventEffects.ts to derive targeted per-batch side effects (draft cleanup, terminal state removal, provider invalidation) instead of triggering them broadly.ChatViewto usewaitForStartedServerThreadandLocalDispatchSnapshot-based acknowledgment instead of forcing snapshot syncs after thread creation.threadsHydratedtobootstrapCompleteacross state, routes, and tests.uiStateStore; existing persisted keys are read as legacy and may be cleared on first load.Macroscope summarized ea6a5ae.