diff --git a/docs/architecture.md b/docs/architecture.md index 1107406a22..1e8cec5a0c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -959,6 +959,13 @@ Key server capabilities: - companion endpoints: `/api/dev-server/detect`, `/config`, `/status`, `/start`, `/stop`, `/restart`, `/preview-url` - **Badge WebSocket**: `/api/ws` (`server.ts`, `websocket.ts`) - Scope-keyed channels (`badge:{scopeKey}:{taskId}`) prevent cross-project collisions + +#### End-to-end project-scoping invariant + +- Every task-data route resolves its request project once through `getProjectContext`/`getScopedStore` (or the canonical resolver used by real-time providers) and uses that resolved store for the whole handler. A supplied `projectId` never falls back to the injected/default store; unscoped launches retain the resolver's single-project fallback. +- `/api/events` and `/api/ws` bind their listeners to that same request-scoped store. Their project-scoped channel keys and listeners prevent events from one project's task IDs from reaching another project, including when IDs overlap. +- Project-scoped client hooks capture the active project context/version before starting fetches, SSE subscriptions, WebSocket callbacks, or reconnect work. Before applying an async result or event they reject work whose captured context is stale, emit the shared `dropped-stale-event` dashboard trace where applicable, and leave the newly active project's state untouched. +- The canonical client implementation is `useProjectContextGuard`, as used alongside the established `useTasks` and badge-WebSocket guards. New project-scoped hooks must reuse this capture-at-start and stale-drop pattern rather than independently comparing mutable current-project state. - **Terminal WebSocket**: `/api/terminal/ws` (`server.ts`, `terminal-service.ts`) - Project-scoped terminal session validation + safe unscoped fallback diff --git a/packages/dashboard/app/hooks/__tests__/useAgents.test.ts b/packages/dashboard/app/hooks/__tests__/useAgents.test.ts index 512121ffa3..163d993bde 100644 --- a/packages/dashboard/app/hooks/__tests__/useAgents.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useAgents.test.ts @@ -190,6 +190,30 @@ describe("useAgents", () => { expect(console.error).toHaveBeenCalledWith("Failed to load agent stats:", expect.any(Error)); }); + it("hydrates and clears agent state per project on a project switch", async () => { + const agentsA = [createAgent({ id: "agent-a", state: "active" })]; + const agentsB = [createAgent({ id: "agent-b", state: "active" })]; + const statsB = { ...defaultStats, activeCount: 7 }; + window.localStorage.setItem(`${SWR_CACHE_KEYS.AGENTS}:project-b`, JSON.stringify(agentsB)); + window.localStorage.setItem(`${SWR_CACHE_KEYS.AGENT_STATS}:project-b`, JSON.stringify(statsB)); + mockFetchAgents.mockImplementation((_filter, projectId) => Promise.resolve(projectId === "project-b" ? agentsB : agentsA)); + mockFetchAgentStats.mockImplementation((projectId) => Promise.resolve(projectId === "project-b" ? statsB : defaultStats)); + + const { result, rerender } = renderHook( + ({ projectId }: { projectId: string }) => useAgents(projectId), + { initialProps: { projectId: "project-a" } }, + ); + await waitFor(() => expect(result.current.agents).toEqual(agentsA)); + + rerender({ projectId: "project-b" }); + + await waitFor(() => { + expect(result.current.agents).toEqual(agentsB); + expect(result.current.stats).toEqual(statsB); + }); + expect(result.current.agents).not.toContainEqual(expect.objectContaining({ id: "agent-a" })); + }); + it("creates SSE subscription with correct URL without projectId", async () => { renderHook(() => useAgents()); diff --git a/packages/dashboard/app/hooks/__tests__/useArtifacts.test.ts b/packages/dashboard/app/hooks/__tests__/useArtifacts.test.ts index b0dfff5885..008200c53b 100644 --- a/packages/dashboard/app/hooks/__tests__/useArtifacts.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useArtifacts.test.ts @@ -5,6 +5,7 @@ import { fetchArtifacts } from "../../api"; import { subscribeSse } from "../../sse-bus"; import { useArtifacts } from "../useArtifacts"; import { message } from "./sseTestHelpers"; +import { clearTraces, getTraces } from "../../utils/dashboardTraceBuffer"; const { handlers, unsubscribeMock } = vi.hoisted(() => ({ handlers: {} as Record void>, @@ -71,6 +72,7 @@ describe("useArtifacts", () => { beforeEach(() => { vi.useFakeTimers({ shouldAdvanceTime: true }); window.localStorage.clear(); + clearTraces(); for (const key of Object.keys(handlers)) delete handlers[key]; unsubscribeMock.mockClear(); mockFetchArtifacts.mockResolvedValue(mockArtifacts); @@ -304,6 +306,51 @@ describe("useArtifacts", () => { await waitFor(() => expect(mockFetchArtifacts).toHaveBeenCalledTimes(1)); }); + it("drops a late artifact response after the active project switches", async () => { + const { result, rerender } = renderHook( + ({ projectId }) => useArtifacts({ projectId }), + { initialProps: { projectId: "project-a" } }, + ); + await loadInitialArtifacts(); + await waitFor(() => expect(result.current.artifacts).toEqual(mockArtifacts)); + + let resolveOldRequest!: (value: ArtifactWithTask[]) => void; + mockFetchArtifacts.mockImplementationOnce(() => new Promise((resolve) => { + resolveOldRequest = resolve; + })); + void result.current.refresh(); + rerender({ projectId: "project-b" }); + + await act(async () => { + resolveOldRequest([{ ...mockArtifacts[0], id: "project-a-artifact" }]); + }); + + expect(result.current.artifacts).not.toContainEqual(expect.objectContaining({ id: "project-a-artifact" })); + expect(getTraces()).toContainEqual(expect.objectContaining({ source: "useArtifacts", event: "dropped-stale-event" })); + }); + + it("drops a late artifact rejection after the active project switches", async () => { + const { result, rerender } = renderHook( + ({ projectId }) => useArtifacts({ projectId }), + { initialProps: { projectId: "project-a" } }, + ); + await loadInitialArtifacts(); + + let rejectOldRequest!: (reason: Error) => void; + mockFetchArtifacts.mockImplementationOnce(() => new Promise((_, reject) => { + rejectOldRequest = reject; + })); + void result.current.refresh(); + rerender({ projectId: "project-b" }); + + await act(async () => { + rejectOldRequest(new Error("project-a failure")); + }); + + expect(result.current.error).toBeNull(); + expect(getTraces()).toContainEqual(expect.objectContaining({ source: "useArtifacts", event: "dropped-stale-event" })); + }); + it("subscribes to project-scoped artifact registration events and unsubscribes on unmount", async () => { const { unmount } = renderHook(() => useArtifacts({ projectId: "project-13" })); diff --git a/packages/dashboard/app/hooks/__tests__/useDocuments.test.ts b/packages/dashboard/app/hooks/__tests__/useDocuments.test.ts index 1a5b95e8eb..250fa300ea 100644 --- a/packages/dashboard/app/hooks/__tests__/useDocuments.test.ts +++ b/packages/dashboard/app/hooks/__tests__/useDocuments.test.ts @@ -224,6 +224,40 @@ describe("useDocuments", () => { }); }); + it("clears project A workspace files immediately when switching to project B", async () => { + let resolveProjectBFileRequest: ((response: Response) => void) | undefined; + globalThis.fetch = vi.fn().mockImplementation((input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url; + if (url.includes("projectId=project-b")) { + if (url.includes("/files/markdown-list")) { + return new Promise((resolve) => { + resolveProjectBFileRequest = resolve; + }); + } + return new Promise(() => {}); + } + return mockFetchResponse(true, url.includes("/files/markdown-list") + ? { files: mockProjectFiles } + : mockDocuments); + }); + + const { result, rerender } = renderHook( + ({ projectId }) => useDocuments({ projectId }), + { initialProps: { projectId: "project-a" } }, + ); + await act(async () => { + await vi.runAllTimersAsync(); + }); + await waitFor(() => expect(result.current.projectFiles).toEqual(mockProjectFiles)); + + rerender({ projectId: "project-b" }); + + await waitFor(() => expect(result.current.projectFiles).toEqual([])); + await act(async () => { + resolveProjectBFileRequest?.(await mockFetchResponse(true, { files: [] })); + }); + }); + it("cancels in-flight request on unmount", async () => { const abortMock = vi.fn(); const originalAbortController = globalThis.AbortController; diff --git a/packages/dashboard/app/hooks/useAgents.ts b/packages/dashboard/app/hooks/useAgents.ts index de8a378c2b..7e28f5d3f1 100644 --- a/packages/dashboard/app/hooks/useAgents.ts +++ b/packages/dashboard/app/hooks/useAgents.ts @@ -4,6 +4,7 @@ import { fetchAgents, fetchAgentStats } from "../api"; import { isEphemeralAgent } from "@fusion/core"; import { subscribeSse } from "../sse-bus"; import { readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, writeCache } from "../utils/swrCache"; +import { useProjectContextGuard } from "./useProjectContextGuard"; interface UseAgentsOptions { filterState?: AgentState | "all"; @@ -23,12 +24,14 @@ interface AgentFilter { const SSE_REFRESH_DEBOUNCE_MS = 250; export function useAgents(projectId?: string, options?: UseAgentsOptions) { + const agentsCacheKey = projectId ? `${SWR_CACHE_KEYS.AGENTS}:${projectId}` : SWR_CACHE_KEYS.AGENTS; + const statsCacheKey = projectId ? `${SWR_CACHE_KEYS.AGENT_STATS}:${projectId}` : SWR_CACHE_KEYS.AGENT_STATS; const [agents, setAgents] = useState(() => { - const cached = readCache(SWR_CACHE_KEYS.AGENTS, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS }); + const cached = readCache(agentsCacheKey, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS }); return Array.isArray(cached) ? cached : []; }); const [stats, setStats] = useState(() => { - const cached = readCache(SWR_CACHE_KEYS.AGENT_STATS, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS }); + const cached = readCache(statsCacheKey, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS }); return cached ?? null; }); const [isLoading, setIsLoading] = useState(false); @@ -52,8 +55,31 @@ export function useAgents(projectId?: string, options?: UseAgentsOptions) { // next SSE event. const agentsGenRef = useRef(0); const statsGenRef = useRef(0); + /* + FNXC:ProjectScoping 2026-07-15-20:10: + Agent snapshots and their SSE-triggered refreshes are scoped to the project + captured at dispatch, so a stale prior-project response cannot replace B. + */ + const { capture } = useProjectContextGuard(projectId, "useAgents"); + + useEffect(() => { + /* + FNXC:ProjectScoping 2026-07-16-00:00: + Agent cache hydration is keyed by project. On a project switch, clear A's + snapshot immediately or hydrate only B's cache while B's scoped request runs. + */ + const cachedAgents = readCache(agentsCacheKey, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS }); + const cachedStats = readCache(statsCacheKey, { maxAgeMs: SWR_DEFAULT_MAX_AGE_MS }); + const nextAgents = Array.isArray(cachedAgents) ? cachedAgents : []; + const nextStats = cachedStats ?? null; + setAgents(nextAgents); + setStats(nextStats); + hasCachedHydrationRef.current = nextAgents.length > 0 || nextStats !== null; + setIsLoading(false); + }, [agentsCacheKey, statsCacheKey]); const loadAgents = useCallback(async (filter?: AgentFilter, opts?: { forceFresh?: boolean }) => { + const context = capture(); const gen = ++agentsGenRef.current; if (!hasCachedHydrationRef.current) { setIsLoading(true); @@ -72,36 +98,37 @@ export function useAgents(projectId?: string, options?: UseAgentsOptions) { : await fetchAgents(mergedFilter, projectId); // A newer call superseded us — drop this response so we don't clobber // fresher state with stale data. - if (gen !== agentsGenRef.current) return; + if (gen !== agentsGenRef.current || context.isStale()) return; // Defensive dedupe: a race between the initial fetch and an SSE refresh // (or a backend that returned the same agent twice) would otherwise put // duplicate ids into every list rendered from this hook, flooding React // with duplicate-key warnings until the dashboard runs out of heap. const unique = Array.from(new Map(data.map((a) => [a.id, a])).values()); setAgents(unique); - writeCache(SWR_CACHE_KEYS.AGENTS, unique, { maxBytes: 500_000 }); + writeCache(agentsCacheKey, unique, { maxBytes: 500_000 }); hasCachedHydrationRef.current = hasCachedHydrationRef.current || unique.length > 0; } catch (err) { console.error("Failed to load agents:", err); } finally { if (gen === agentsGenRef.current) setIsLoading(false); } - }, [projectId, options?.filterState, options?.showSystemAgents]); + }, [agentsCacheKey, capture, projectId, options?.filterState, options?.showSystemAgents]); const loadStats = useCallback(async (opts?: { forceFresh?: boolean }) => { + const context = capture(); const gen = ++statsGenRef.current; try { const data = opts?.forceFresh ? await fetchAgentStats(projectId, { forceFresh: true }) : await fetchAgentStats(projectId); - if (gen !== statsGenRef.current) return; + if (gen !== statsGenRef.current || context.isStale()) return; setStats(data); - writeCache(SWR_CACHE_KEYS.AGENT_STATS, data, { maxBytes: 500_000 }); + writeCache(statsCacheKey, data, { maxBytes: 500_000 }); hasCachedHydrationRef.current = true; } catch (err) { console.error("Failed to load agent stats:", err); } - }, [projectId]); + }, [capture, projectId, statsCacheKey]); useEffect(() => { void loadAgents(); @@ -123,6 +150,7 @@ export function useAgents(projectId?: string, options?: UseAgentsOptions) { // fetch+debounce window, we issue at most 2 fetches (the initial debounced // fetch and one trailing catch-up). useEffect(() => { + const context = capture(); const query = projectId ? `?projectId=${encodeURIComponent(projectId)}` : ""; let debounceTimer: ReturnType | null = null; @@ -161,6 +189,7 @@ export function useAgents(projectId?: string, options?: UseAgentsOptions) { }; const refresh = (): void => { + if (context.isStale()) return; if (fetchInProgress) { // Mark a trailing refresh; don't schedule a new timer that would race // the in-flight fetch and (via forceFresh) discard its response. @@ -187,7 +216,7 @@ export function useAgents(projectId?: string, options?: UseAgentsOptions) { if (debounceTimer) clearTimeout(debounceTimer); unsubscribe(); }; - }, [projectId, loadAgents, loadStats]); + }, [capture, projectId, loadAgents, loadStats]); // refreshAgents is the canonical post-mutation refetch entrypoint. It // defaults to forceFresh so consumers don't have to remember — anyone diff --git a/packages/dashboard/app/hooks/useArtifacts.ts b/packages/dashboard/app/hooks/useArtifacts.ts index 1e7c1f6d8c..e1498d9bcc 100644 --- a/packages/dashboard/app/hooks/useArtifacts.ts +++ b/packages/dashboard/app/hooks/useArtifacts.ts @@ -3,6 +3,7 @@ import type { ArtifactType, ArtifactWithTask } from "@fusion/core"; import { fetchArtifacts } from "../api"; import { subscribeSse } from "../sse-bus"; import { readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, writeCache } from "../utils/swrCache"; +import { useProjectContextGuard } from "./useProjectContextGuard"; export interface UseArtifactsResult { /** List of artifacts across agents and tasks */ @@ -46,8 +47,15 @@ export function useArtifacts(options?: { const debounceRef = useRef | null>(null); const refreshRef = useRef<() => Promise>(async () => {}); const sseRefreshDebounceRef = useRef | null>(null); + /* + FNXC:ProjectScoping 2026-07-15-20:10: + Artifact fetches and SSE callbacks capture their project before work begins; + the shared guard drops delayed project-A results after the view switches to B. + */ + const { capture } = useProjectContextGuard(projectId, "useArtifacts"); const refresh = useCallback(async () => { + const context = capture(); if (abortRef.current) { abortRef.current.abort(); } @@ -69,7 +77,7 @@ export function useArtifacts(options?: { q: searchQuery, }, projectId); - if (requestController.signal.aborted) { + if (requestController.signal.aborted || context.isStale()) { return; } @@ -80,16 +88,21 @@ export function useArtifacts(options?: { } initialLoadCompleteRef.current = true; } catch (err) { - if (requestController.signal.aborted) { + if (requestController.signal.aborted || context.isStale()) { return; } setError(err instanceof Error ? err.message : String(err)); } finally { - if (!requestController.signal.aborted && isInitial) { + /* + FNXC:ProjectScoping 2026-07-16-00:00: + A stale request must not clear the new project's loading indicator after + a project switch, including when the old request rejects. + */ + if (!requestController.signal.aborted && !context.isStale() && isInitial) { setLoading(false); } } - }, [authorId, cacheKey, projectId, searchQuery, taskId, type]); + }, [authorId, cacheKey, capture, projectId, searchQuery, taskId, type]); useEffect(() => { refreshRef.current = refresh; @@ -125,6 +138,7 @@ export function useArtifacts(options?: { }, [refresh]); useEffect(() => { + const context = capture(); /* * FNXC:ArtifactRegistry 2026-06-27-00:00: * Already-open task and project artifact lists must live-refresh from TaskStore's authoritative artifact:registered SSE event and also accept the best-effort agent/chat message notifications as an additional signal. Task-scoped hooks filter by artifact/task metadata while project-scoped hooks rely on the projectId refetch and optional payload projectId guard, preserving SWR cached rendering, search filters, and scoped fetch behavior without showing a loading flash. @@ -145,7 +159,7 @@ export function useArtifacts(options?: { }; const artifactId = source === "artifact" ? payload.id : payload.metadata?.artifactId; const artifactTaskId = source === "artifact" ? payload.taskId : payload.metadata?.taskId; - if (!artifactId) return; + if (!artifactId || context.isStale()) return; if (projectId && payload.projectId && payload.projectId !== projectId) return; if (taskId && artifactTaskId !== taskId) return; @@ -186,7 +200,7 @@ export function useArtifacts(options?: { sseRefreshDebounceRef.current = null; } }; - }, [projectId, taskId]); + }, [capture, projectId, taskId]); useEffect(() => { void refresh(); diff --git a/packages/dashboard/app/hooks/useDocuments.ts b/packages/dashboard/app/hooks/useDocuments.ts index f2d9b30536..ec9e5f79f9 100644 --- a/packages/dashboard/app/hooks/useDocuments.ts +++ b/packages/dashboard/app/hooks/useDocuments.ts @@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import type { TaskDocumentWithTask } from "@fusion/core"; import { fetchAllDocuments, fetchProjectMarkdownFiles, type MarkdownFileEntry } from "../api"; import { readCache, SWR_CACHE_KEYS, SWR_DEFAULT_MAX_AGE_MS, writeCache } from "../utils/swrCache"; +import { useProjectContextGuard } from "./useProjectContextGuard"; export interface UseDocumentsResult { /** List of all documents across tasks */ @@ -49,12 +50,19 @@ export function useDocuments(options?: { const initialLoadCompleteRef = useRef(documents.length > 0); // Debounce timer for search const debounceRef = useRef | null>(null); + /* + FNXC:ProjectScoping 2026-07-15-20:10: + Documents and workspace-file responses must match the project captured when + the request started; the shared guard rejects a late old-project payload. + */ + const { capture } = useProjectContextGuard(projectId, "useDocuments"); /** * Fetch documents from the server. * Background updates (refresh, search) do NOT set loading=true. */ const refresh = useCallback(async () => { + const context = capture(); // Cancel any in-flight requests if (abortRef.current) { abortRef.current.abort(); @@ -84,7 +92,7 @@ export function useDocuments(options?: { projectFileFetchPromise, ]); - if (requestController.signal.aborted) { + if (requestController.signal.aborted || context.isStale()) { return; } @@ -119,9 +127,15 @@ export function useDocuments(options?: { if (isInitial) { setLoading(false); } - }, [cacheKey, includeProjectFiles, projectId, searchQuery]); + }, [cacheKey, capture, includeProjectFiles, projectId, searchQuery]); useEffect(() => { + /* + FNXC:ProjectScoping 2026-07-16-00:00: + Workspace markdown files are project-scoped alongside task documents. Clear + them during a switch so project A paths cannot render while B loads. + */ + setProjectFiles([]); if (!cacheKey) { initialLoadCompleteRef.current = false; setDocuments([]); diff --git a/packages/dashboard/app/hooks/useProjectContextGuard.ts b/packages/dashboard/app/hooks/useProjectContextGuard.ts new file mode 100644 index 0000000000..343c994d71 --- /dev/null +++ b/packages/dashboard/app/hooks/useProjectContextGuard.ts @@ -0,0 +1,38 @@ +import { useCallback, useRef } from "react"; +import { pushTrace } from "../utils/dashboardTraceBuffer"; + +/** + * FNXC:ProjectScoping 2026-07-15-20:10: + * Project-scoped hooks capture this monotonically increasing context version + * before an async request or SSE callback is registered. A changed active + * project invalidates that work, preventing a late prior-project result from + * being applied to the current view. + */ +export function useProjectContextGuard(projectId: string | undefined, source: string) { + const versionRef = useRef(0); + const previousProjectIdRef = useRef(projectId); + const projectIdRef = useRef(projectId); + projectIdRef.current = projectId; + + if (previousProjectIdRef.current !== projectId) { + previousProjectIdRef.current = projectId; + versionRef.current += 1; + } + + const capture = useCallback(() => { + const projectIdAtStart = projectIdRef.current; + const contextVersionAtStart = versionRef.current; + return { + projectIdAtStart, + isStale: () => { + const stale = versionRef.current !== contextVersionAtStart || projectIdRef.current !== projectIdAtStart; + if (stale) { + pushTrace(source, "dropped-stale-event", { projectId: projectIdAtStart }); + } + return stale; + }, + }; + }, [source]); + + return { capture }; +} diff --git a/packages/dashboard/src/__tests__/routes-context-project-identity.test.ts b/packages/dashboard/src/__tests__/routes-context-project-identity.test.ts index 9f7a1a5136..6132725d87 100644 --- a/packages/dashboard/src/__tests__/routes-context-project-identity.test.ts +++ b/packages/dashboard/src/__tests__/routes-context-project-identity.test.ts @@ -154,6 +154,18 @@ describe("routes/context central project identity seam", () => { expect(projectStoreResolver.getOrCreateProjectStore).not.toHaveBeenCalled(); }); + /* + * FNXC:ProjectScoping 2026-07-15-20:20: + * An explicit launch project id on SSE/WebSocket must reuse the injected + * launch store, not create a second EventEmitter-bearing scoped store. + */ + it("explicit launch id → injected store, no duplicate realtime binding", async () => { + const options = { engine: makeEngine("launch-proj", tag("engine-store")) } as unknown as ServerOptions; + const store = await resolveScopedStore("launch-proj", rawLaunchStore, undefined, "launch-proj", options); + expect(store).toBe(rawLaunchStore); + expect(projectStoreResolver.getOrCreateProjectStore).not.toHaveBeenCalled(); + }); + it("explicit id ≠ launch, getEngine undefined → getOrCreateProjectStore", async () => { const options = { engine: makeEngine("launch-proj", tag("engine-store")) } as unknown as ServerOptions; const store = await resolveScopedStore("other-proj", rawLaunchStore, undefined, "launch-proj", options); diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index d1aea39b6d..af55acb604 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -25,7 +25,6 @@ import { ApiError, sendErrorResponse } from "./api-error.js"; import { countRunningAgentsInRegisteredProjectStores, countRunningAgentsInStore, - getOrCreateProjectStore, evictAllProjectStores, setOnProjectFirstCreated, } from "./project-store-resolver.js"; @@ -1188,21 +1187,26 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT // Prefer the engine's store when available — this ensures SSE listeners // attach to the same EventEmitter instance that the engine writes to, // rather than a separate store created by getOrCreateProjectStore. - let scopedStore: TaskStore; + const scopedStore: TaskStore = await resolveProjectScopedStore(projectId); let agentStore: AgentStore | undefined; let messageStore: MessageStore | undefined; let automationStore: AutomationStore | undefined; let scopedChatStore = chatStore; + /* + FNXC:ProjectScoping 2026-07-15-20:10: + A project-scoped SSE stream must attach to the same canonical TaskStore as + request handlers and the launch engine. Reimplementing the resolver here + created a second store for the launch project when its engine was not + immediately available, which separated its EventEmitter from mutations. + */ if (engineManager) { const engine = engineManager.getEngine(projectId); - scopedStore = engine?.getTaskStore() ?? await getOrCreateProjectStore(projectId); scopedChatStore = getOrCreateScopedChatStore(scopedStore, engine?.getChatStore?.()); - // Use the engine's stores if available + // Use the engine's auxiliary stores when available. agentStore = engine?.getAgentStore(); messageStore = engine?.getMessageStore(); automationStore = engine?.getAutomationStore(); } else { - scopedStore = await getOrCreateProjectStore(projectId); scopedChatStore = getOrCreateScopedChatStore(scopedStore); } // Fallback: create AgentStore if engine doesn't have one @@ -2553,7 +2557,12 @@ export function setupBadgeWebSocket( return scopedStore; } - // Create scoped store + /* + FNXC:ProjectScoping 2026-07-15-20:10: + Badge WebSocket listeners use the canonical scoped resolver, including for + the launch project, so an explicit `projectId` cannot bind a duplicate + TaskStore/EventEmitter and leak or miss cross-project badge updates. + */ scopedStore = await resolveScopedStore(projectId, store, options?.engineManager, options?.engine?.getProjectId?.(), options); scopedStores.set(projectId, scopedStore); return scopedStore;