diff --git a/src/app-server/query/cache.ts b/src/app-server/query/cache.ts index 17955f5f..53e50539 100644 --- a/src/app-server/query/cache.ts +++ b/src/app-server/query/cache.ts @@ -50,7 +50,6 @@ interface AppServerQueryOptions { type ThreadListKind = "active" | "archived"; export type MetadataResourceKind = "skills" | "rateLimits"; -type ThreadListUpdater = (threads: readonly Thread[] | null) => readonly Thread[] | null; export class AppServerQueryCache { private readonly client: QueryClient; @@ -119,7 +118,7 @@ export class AppServerQueryCache { const revision = this.activeThreadRevision(refreshContext); const threads = await this.runWithClient(refreshContext, (client) => listThreads(client, refreshContext.vaultPath)); if (this.activeThreadRevision(refreshContext) !== revision) continue; - this.setActiveThreads(refreshContext, threads); + this.storeThreadList(refreshContext, "active", threads); this.rememberActiveThreadCursor(refreshContext, null); return cloneThreads(threads); } @@ -146,7 +145,7 @@ export class AppServerQueryCache { const latest = this.activeThreadsSnapshot(refreshContext) ?? current; const existingIds = new Set(latest.map((thread) => thread.id)); const threads = [...latest, ...page.threads.filter((thread) => !existingIds.has(thread.id))]; - this.setActiveThreads(refreshContext, threads); + this.storeThreadList(refreshContext, "active", threads); this.rememberActiveThreadCursor(refreshContext, page.nextCursor); return cloneThreads(threads); } @@ -155,22 +154,6 @@ export class AppServerQueryCache { return this.fetchArchivedThreads(context, { force: true }); } - setActiveThreads(context: AppServerQueryContext, threads: readonly Thread[]): void { - this.setThreadList(context, "active", threads); - } - - setArchivedThreads(context: AppServerQueryContext, threads: readonly Thread[]): void { - this.setThreadList(context, "archived", threads); - } - - updateActiveThreads(context: AppServerQueryContext, updater: ThreadListUpdater): readonly Thread[] | null { - return this.updateThreadList(context, "active", updater); - } - - updateArchivedThreads(context: AppServerQueryContext, updater: ThreadListUpdater): readonly Thread[] | null { - return this.updateThreadList(context, "archived", updater); - } - private threadListSnapshot(context: AppServerQueryContext, kind: ThreadListKind): readonly Thread[] | null { if (!appServerQueryContextIsComplete(context)) return null; const threads = this.client.getQueryData(this.threadListQueryKey(context, kind)); @@ -192,22 +175,12 @@ export class AppServerQueryCache { return cloneThreads(threads); } - private setThreadList(context: AppServerQueryContext, kind: ThreadListKind, threads: readonly Thread[]): void { + private storeThreadList(context: AppServerQueryContext, kind: ThreadListKind, threads: readonly Thread[]): void { if (!appServerQueryContextIsComplete(context)) return; this.client.setQueryData(this.threadListQueryKey(context, kind), cloneThreads(threads)); if (kind === "active") this.bumpActiveThreadRevision(context); } - private updateThreadList(context: AppServerQueryContext, kind: ThreadListKind, updater: ThreadListUpdater): readonly Thread[] | null { - if (!appServerQueryContextIsComplete(context)) return null; - const current = this.threadListSnapshot(context, kind); - const next = updater(current); - if (!next) return null; - this.client.setQueryData(this.threadListQueryKey(context, kind), cloneThreads(next), current ? undefined : { updatedAt: 0 }); - if (kind === "active") this.bumpActiveThreadRevision(context); - return cloneThreads(next); - } - appServerMetadataSnapshot(context: AppServerQueryContext): SharedServerMetadata | null { if (!appServerQueryContextIsComplete(context)) return null; const metadata = this.client.getQueryData(appServerMetadataQueryKey(context)); diff --git a/src/app-server/query/shared-queries.ts b/src/app-server/query/shared-queries.ts index 1dedbff1..c298774b 100644 --- a/src/app-server/query/shared-queries.ts +++ b/src/app-server/query/shared-queries.ts @@ -64,22 +64,6 @@ export class AppServerSharedQueries { return this.runForCurrentContext((context) => this.options.cache.refreshArchivedThreads(context)); } - setActiveThreads(threads: readonly Thread[]): void { - this.options.cache.setActiveThreads(this.context(), threads); - } - - setArchivedThreads(threads: readonly Thread[]): void { - this.options.cache.setArchivedThreads(this.context(), threads); - } - - updateActiveThreads(updater: (threads: readonly Thread[] | null) => readonly Thread[] | null): readonly Thread[] | null { - return this.options.cache.updateActiveThreads(this.context(), updater); - } - - updateArchivedThreads(updater: (threads: readonly Thread[] | null) => readonly Thread[] | null): readonly Thread[] | null { - return this.options.cache.updateArchivedThreads(this.context(), updater); - } - observeActiveThreadsResult(listener: ObservedResultListener, options?: { emitCurrent?: boolean }): () => void { return this.observeCurrentContext( (context, contextListener, observeOptions) => this.options.cache.observeActiveThreadsResult(context, contextListener, observeOptions), diff --git a/src/features/threads/catalog/thread-catalog.ts b/src/features/threads/catalog/thread-catalog.ts index b1d9f592..b581d5ce 100644 --- a/src/features/threads/catalog/thread-catalog.ts +++ b/src/features/threads/catalog/thread-catalog.ts @@ -1,8 +1,7 @@ -import type { ObservedResultListener } from "../../../app-server/query/observed-result"; +import type { ObservedResult, ObservedResultListener } from "../../../app-server/query/observed-result"; import type { Thread } from "../../../domain/threads/model"; type ThreadListObserver = ObservedResultListener; -const MAX_RETAINED_THREAD_CATALOG_CONTEXTS = 4; interface ThreadCatalogStore { contextKey(): string; @@ -15,10 +14,6 @@ interface ThreadCatalogStore { refreshArchivedThreads(): Promise; observeActiveThreadsResult(observer: ThreadListObserver, options?: { emitCurrent?: boolean }): () => void; observeArchivedThreadsResult(observer: ThreadListObserver, options?: { emitCurrent?: boolean }): () => void; - setActiveThreads(threads: readonly Thread[]): void; - setArchivedThreads(threads: readonly Thread[]): void; - updateActiveThreads(updater: (threads: readonly Thread[] | null) => readonly Thread[] | null): readonly Thread[] | null; - updateArchivedThreads(updater: (threads: readonly Thread[] | null) => readonly Thread[] | null): readonly Thread[] | null; } interface PendingThreadUpsert { @@ -37,6 +32,10 @@ interface PendingThreadListFacts { interface ThreadCatalogFacts { readonly active: PendingThreadListFacts; readonly archived: PendingThreadListFacts; + activeTestSnapshot: readonly Thread[] | null; + archivedTestSnapshot: readonly Thread[] | null; + activeObservedResult: ObservedResult | null; + archivedObservedResult: ObservedResult | null; } export type ThreadCatalogEventObserver = (event: ThreadCatalogEvent) => void; @@ -87,11 +86,28 @@ export interface ThreadCatalog extends ThreadCatalogPaginatedActiveReader, Threa export function createThreadCatalog(options: ThreadCatalogOptions): ThreadCatalog { const factsByContext = new Map(); + const activeObservers = new Set(); + const archivedObservers = new Set(); const { store } = options; const currentFacts = (): ThreadCatalogFacts => threadCatalogFactsForContext(factsByContext, store.contextKey()); + const activeRawSnapshot = (): readonly Thread[] | null => currentFacts().activeTestSnapshot ?? store.activeThreadsSnapshot(); + const archivedRawSnapshot = (): readonly Thread[] | null => currentFacts().archivedTestSnapshot ?? store.archivedThreadsSnapshot(); + const publish = (kind: ThreadListKind): void => { + const facts = currentFacts(); + const raw = kind === "active" ? activeRawSnapshot() : archivedRawSnapshot(); + const value = threadListProjection(raw, kind === "active" ? facts.active : facts.archived); + const observedResult = kind === "active" ? facts.activeObservedResult : facts.archivedObservedResult; + const result: ObservedResult = { + ...(observedResult ?? { error: null, isFetching: false }), + value, + }; + for (const observer of kind === "active" ? activeObservers : archivedObservers) observer(result); + }; const apply = (event: ThreadCatalogEvent): void => { const facts = currentFacts(); - applyThreadCatalogEvent(store, facts.active, facts.archived, event); + const changed = applyThreadCatalogEvent(store, facts, event); + if (changed.active) publish("active"); + if (changed.archived) publish("archived"); options.onEventApplied?.(event); }; @@ -100,126 +116,163 @@ export function createThreadCatalog(options: ThreadCatalogOptions): ThreadCatalo clear: () => { factsByContext.clear(); }, - activeSnapshot: () => threadListProjection(store.activeThreadsSnapshot(), currentFacts().active), - loadActive: () => loadThreadList(store.fetchAllActiveThreads(), currentFacts().active), - refreshActive: () => loadThreadList(store.refreshActiveThreads(), currentFacts().active), + activeSnapshot: () => threadListProjection(activeRawSnapshot(), currentFacts().active), + loadActive: () => + loadThreadList(store.fetchAllActiveThreads(), currentFacts().active, () => (currentFacts().activeTestSnapshot = null)), + refreshActive: () => + loadThreadList(store.refreshActiveThreads(), currentFacts().active, () => (currentFacts().activeTestSnapshot = null)), hasMoreActive: () => store.hasMoreActiveThreads(), - loadMoreActive: () => loadThreadList(store.loadMoreActiveThreads(), currentFacts().active), - observeActive: (observer, observeOptions) => - store.observeActiveThreadsResult((result) => { + loadMoreActive: () => + loadThreadList(store.loadMoreActiveThreads(), currentFacts().active, () => (currentFacts().activeTestSnapshot = null)), + observeActive: (observer, observeOptions) => { + activeObservers.add(observer); + const unsubscribe = store.observeActiveThreadsResult((result) => { + const facts = currentFacts(); + facts.activeObservedResult = result; + if (result.value) { + facts.activeTestSnapshot = null; + acknowledgeThreadListSnapshot(facts.active, result.value); + } observer({ ...result, - value: threadListProjection(result.value, currentFacts().active), + value: threadListProjection(result.value, facts.active), }); - }, observeOptions), - archivedSnapshot: () => threadListProjection(store.archivedThreadsSnapshot(), currentFacts().archived), - refreshArchived: () => loadThreadList(store.refreshArchivedThreads(), currentFacts().archived), - observeArchived: (observer, observeOptions) => - store.observeArchivedThreadsResult((result) => { + }, observeOptions); + return () => { + activeObservers.delete(observer); + unsubscribe(); + }; + }, + archivedSnapshot: () => threadListProjection(archivedRawSnapshot(), currentFacts().archived), + refreshArchived: () => + loadThreadList(store.refreshArchivedThreads(), currentFacts().archived, () => (currentFacts().archivedTestSnapshot = null)), + observeArchived: (observer, observeOptions) => { + archivedObservers.add(observer); + const unsubscribe = store.observeArchivedThreadsResult((result) => { + const facts = currentFacts(); + facts.archivedObservedResult = result; + if (result.value) { + facts.archivedTestSnapshot = null; + acknowledgeThreadListSnapshot(facts.archived, result.value); + } observer({ ...result, - value: threadListProjection(result.value, currentFacts().archived), + value: threadListProjection(result.value, facts.archived), }); - }, observeOptions), + }, observeOptions); + return () => { + archivedObservers.delete(observer); + unsubscribe(); + }; + }, }; } +type ThreadListKind = "active" | "archived"; + function threadCatalogFactsForContext(factsByContext: Map, contextKey: string): ThreadCatalogFacts { + pruneInactiveSettledContexts(factsByContext, contextKey); const existing = factsByContext.get(contextKey); if (existing) { factsByContext.delete(contextKey); factsByContext.set(contextKey, existing); return existing; } - while (factsByContext.size >= MAX_RETAINED_THREAD_CATALOG_CONTEXTS) { - let leastRecentlyUsed: string | undefined; - for (const key of factsByContext.keys()) { - leastRecentlyUsed = key; - break; - } - if (leastRecentlyUsed === undefined) break; - factsByContext.delete(leastRecentlyUsed); - } const facts = { active: pendingThreadListFacts(), archived: pendingThreadListFacts(), + activeTestSnapshot: null, + archivedTestSnapshot: null, + activeObservedResult: null, + archivedObservedResult: null, }; factsByContext.set(contextKey, facts); return facts; } -function applyThreadCatalogEvent( - store: ThreadCatalogStore, - activeFacts: PendingThreadListFacts, - archivedFacts: PendingThreadListFacts, - event: ThreadCatalogEvent, -): void { - switch (event.type) { - case "active-list-snapshot-received": - acknowledgeThreadListSnapshot(activeFacts, event.threads); - store.setActiveThreads(event.threads); - return; - case "archived-list-snapshot-received": - acknowledgeThreadListSnapshot(archivedFacts, event.threads); - store.setArchivedThreads(event.threads); - return; - case "thread-started": - upsertActiveThread(store, activeFacts, event.thread, acknowledgeByThreadId); - return; - case "thread-forked": - upsertActiveThread(store, activeFacts, event.thread, acknowledgedByThreadVersion(event.thread)); - return; - case "thread-touched": - applyThreadTouchedEvent(store, activeFacts, event.threadId, event.recencyAt); - return; - case "thread-renamed": - applyThreadRenamedEvent(store, activeFacts, archivedFacts, event.threadId, event.name); - return; - case "thread-archived": - applyThreadArchivedEvent(store, activeFacts, archivedFacts, event.threadId); - return; - case "thread-deleted": - applyThreadDeletedEvent(store, activeFacts, archivedFacts, event.threadId); - return; - case "thread-restored": - applyThreadRestoredEvent(store, activeFacts, archivedFacts, event.thread); - return; - case "thread-unarchived": - applyThreadUnarchivedEvent(store, activeFacts, archivedFacts, event.threadId); - return; +function pruneInactiveSettledContexts(factsByContext: Map, activeContextKey: string): void { + for (const [contextKey, facts] of factsByContext) { + if (contextKey === activeContextKey) continue; + if (threadListFactsPending(facts.active) || threadListFactsPending(facts.archived)) continue; + factsByContext.delete(contextKey); } } -async function loadThreadList(threadsPromise: Promise, facts: PendingThreadListFacts): Promise { +function threadListFactsPending(facts: PendingThreadListFacts): boolean { + return facts.upserts.size > 0 || facts.removals.size > 0; +} + +function applyThreadCatalogEvent( + store: ThreadCatalogStore, + facts: ThreadCatalogFacts, + event: ThreadCatalogEvent, +): { active: boolean; archived: boolean } { + const { active: activeFacts, archived: archivedFacts } = facts; + switch (event.type) { + case "active-list-snapshot-received": + acknowledgeThreadListSnapshot(activeFacts, event.threads); + facts.activeTestSnapshot = event.threads; + return { active: true, archived: false }; + case "archived-list-snapshot-received": + acknowledgeThreadListSnapshot(archivedFacts, event.threads); + facts.archivedTestSnapshot = event.threads; + return { active: false, archived: true }; + case "thread-started": + upsertActiveThread(activeFacts, event.thread, acknowledgeByThreadId); + return { active: true, archived: false }; + case "thread-forked": + upsertActiveThread(activeFacts, event.thread, acknowledgedByThreadVersion(event.thread)); + return { active: true, archived: false }; + case "thread-touched": + applyThreadTouchedEvent(store, facts, event.threadId, event.recencyAt); + return { active: true, archived: false }; + case "thread-renamed": + applyThreadRenamedEvent(store, facts, event.threadId, event.name); + return { active: true, archived: true }; + case "thread-archived": + applyThreadArchivedEvent(store, facts, event.threadId); + return { active: true, archived: true }; + case "thread-deleted": + applyThreadDeletedEvent(activeFacts, archivedFacts, event.threadId); + return { active: true, archived: true }; + case "thread-restored": + applyThreadRestoredEvent(activeFacts, archivedFacts, event.thread); + return { active: true, archived: true }; + case "thread-unarchived": + applyThreadUnarchivedEvent(store, facts, event.threadId); + return { active: true, archived: true }; + } +} + +async function loadThreadList( + threadsPromise: Promise, + facts: PendingThreadListFacts, + receiveServerSnapshot: () => void, +): Promise { const threads = await threadsPromise; + receiveServerSnapshot(); acknowledgeThreadListSnapshot(facts, threads); return threadListProjection(threads, facts) ?? []; } -function upsertActiveThread( - store: ThreadCatalogStore, - activeFacts: PendingThreadListFacts, - thread: Thread, - acknowledgedBy: (thread: Thread) => boolean, -): void { +function upsertActiveThread(activeFacts: PendingThreadListFacts, thread: Thread, acknowledgedBy: (thread: Thread) => boolean): void { rememberPendingThreadUpsert(activeFacts, thread, acknowledgedBy); - store.updateActiveThreads((current) => promoteThreadInList(current ?? [], thread)); } function applyThreadTouchedEvent( store: ThreadCatalogStore, - activeFacts: PendingThreadListFacts, + facts: ThreadCatalogFacts, threadId: string, recencyAt: number | null | undefined, ): void { + const activeFacts = facts.active; const existingFact = activeFacts.upserts.get(threadId)?.thread ?? null; let touchedThread = existingFact ? touchedActiveThread(existingFact, recencyAt) : null; - const nextThreads = store.updateActiveThreads((current) => { - const currentThread = threadListProjection(current, activeFacts)?.find((thread) => thread.id === threadId) ?? touchedThread; - if (!currentThread) return current; - touchedThread = touchedActiveThread(currentThread, recencyAt); - return promoteThreadInList(current ?? [], touchedThread); - }); + const currentThread = + threadListProjection(facts.activeTestSnapshot ?? store.activeThreadsSnapshot(), activeFacts)?.find( + (thread) => thread.id === threadId, + ) ?? touchedThread; + if (currentThread) touchedThread = touchedActiveThread(currentThread, recencyAt); if (!touchedThread) return; const promotedThread = touchedThread; rememberPendingThreadUpsert( @@ -227,88 +280,55 @@ function applyThreadTouchedEvent( promotedThread, recencyAt === undefined ? acknowledgeByThreadId : acknowledgedByRecency(recencyAt), ); - if (!nextThreads) { - store.updateActiveThreads(() => [promotedThread]); - } } -function applyThreadRenamedEvent( - store: ThreadCatalogStore, - activeFacts: PendingThreadListFacts, - archivedFacts: PendingThreadListFacts, - threadId: string, - name: string | null, -): void { - const activeThread = threadListProjection(store.activeThreadsSnapshot(), activeFacts)?.find((thread) => thread.id === threadId) ?? null; +function applyThreadRenamedEvent(store: ThreadCatalogStore, facts: ThreadCatalogFacts, threadId: string, name: string | null): void { + const { active: activeFacts, archived: archivedFacts } = facts; + const activeThread = + threadListProjection(facts.activeTestSnapshot ?? store.activeThreadsSnapshot(), activeFacts)?.find( + (thread) => thread.id === threadId, + ) ?? null; if (activeThread) rememberPendingThreadUpsert(activeFacts, { ...activeThread, name }, acknowledgedByName(name)); - store.updateActiveThreads((current) => - current ? current.map((thread) => (thread.id === threadId ? { ...thread, name } : thread)) : null, - ); const archivedThread = - threadListProjection(store.archivedThreadsSnapshot(), archivedFacts)?.find((thread) => thread.id === threadId) ?? null; + threadListProjection(facts.archivedTestSnapshot ?? store.archivedThreadsSnapshot(), archivedFacts)?.find( + (thread) => thread.id === threadId, + ) ?? null; if (archivedThread) rememberPendingThreadUpsert(archivedFacts, { ...archivedThread, name }, acknowledgedByName(name)); - store.updateArchivedThreads((current) => - current ? current.map((thread) => (thread.id === threadId ? { ...thread, name } : thread)) : null, - ); } -function applyThreadArchivedEvent( - store: ThreadCatalogStore, - activeFacts: PendingThreadListFacts, - archivedFacts: PendingThreadListFacts, - threadId: string, -): void { - const archivedThread = threadListProjection(store.activeThreadsSnapshot(), activeFacts)?.find((thread) => thread.id === threadId); +function applyThreadArchivedEvent(store: ThreadCatalogStore, facts: ThreadCatalogFacts, threadId: string): void { + const { active: activeFacts, archived: archivedFacts } = facts; + const archivedThread = threadListProjection(facts.activeTestSnapshot ?? store.activeThreadsSnapshot(), activeFacts)?.find( + (thread) => thread.id === threadId, + ); rememberPendingThreadRemoval(activeFacts, threadId); - store.updateActiveThreads((current) => { - return current ? current.filter((thread) => thread.id !== threadId) : null; - }); if (archivedThread) { const pendingArchivedThread = { ...archivedThread, archived: true }; rememberPendingThreadUpsert(archivedFacts, pendingArchivedThread, acknowledgeByThreadId); - store.updateArchivedThreads((current) => promoteThreadInList(current ?? [], pendingArchivedThread)); } else { refreshArchivedThreadsAfterUnknownArchive(store, archivedFacts); } } -function applyThreadDeletedEvent( - store: ThreadCatalogStore, - activeFacts: PendingThreadListFacts, - archivedFacts: PendingThreadListFacts, - threadId: string, -): void { +function applyThreadDeletedEvent(activeFacts: PendingThreadListFacts, archivedFacts: PendingThreadListFacts, threadId: string): void { rememberPendingThreadRemoval(activeFacts, threadId); rememberPendingThreadRemoval(archivedFacts, threadId); - store.updateActiveThreads((current) => (current ? current.filter((thread) => thread.id !== threadId) : null)); - store.updateArchivedThreads((current) => (current ? current.filter((thread) => thread.id !== threadId) : null)); } -function applyThreadRestoredEvent( - store: ThreadCatalogStore, - activeFacts: PendingThreadListFacts, - archivedFacts: PendingThreadListFacts, - thread: Thread, -): void { - upsertActiveThread(store, activeFacts, { ...thread, archived: false }, acknowledgeByThreadId); +function applyThreadRestoredEvent(activeFacts: PendingThreadListFacts, archivedFacts: PendingThreadListFacts, thread: Thread): void { + upsertActiveThread(activeFacts, { ...thread, archived: false }, acknowledgeByThreadId); rememberPendingThreadRemoval(archivedFacts, thread.id); - store.updateArchivedThreads((current) => (current ? current.filter((item) => item.id !== thread.id) : null)); } -function applyThreadUnarchivedEvent( - store: ThreadCatalogStore, - activeFacts: PendingThreadListFacts, - archivedFacts: PendingThreadListFacts, - threadId: string, -): void { +function applyThreadUnarchivedEvent(store: ThreadCatalogStore, facts: ThreadCatalogFacts, threadId: string): void { + const { active: activeFacts, archived: archivedFacts } = facts; const restoredThread = - threadListProjection(store.archivedThreadsSnapshot(), archivedFacts)?.find((thread) => thread.id === threadId) ?? null; + threadListProjection(facts.archivedTestSnapshot ?? store.archivedThreadsSnapshot(), archivedFacts)?.find( + (thread) => thread.id === threadId, + ) ?? null; rememberPendingThreadRemoval(archivedFacts, threadId); - store.updateArchivedThreads((current) => { - return current ? current.filter((thread) => thread.id !== threadId) : null; - }); if (restoredThread) { - upsertActiveThread(store, activeFacts, { ...restoredThread, archived: false }, acknowledgeByThreadId); + upsertActiveThread(activeFacts, { ...restoredThread, archived: false }, acknowledgeByThreadId); return; } refreshThreadListsAfterUnknownUnarchive(store, activeFacts, archivedFacts); @@ -380,11 +400,6 @@ function acknowledgedByRecency(recencyAt: number | null): (thread: Thread) => bo return (thread) => thread.recencyAt === recencyAt; } -function promoteThreadInList(threads: readonly Thread[], thread: Thread): readonly Thread[] { - const withoutThread = threads.filter((item) => item.id !== thread.id); - return [thread, ...withoutThread]; -} - function refreshArchivedThreadsAfterUnknownArchive(store: ThreadCatalogStore, archivedFacts: PendingThreadListFacts): void { // A force refresh can join an older in-flight archived request. Run one more // refresh afterward so an archive recorded during that request is not lost. diff --git a/tests/app-server/query-cache.test.ts b/tests/app-server/query-cache.test.ts index a56aa997..367908b5 100644 --- a/tests/app-server/query-cache.test.ts +++ b/tests/app-server/query-cache.test.ts @@ -20,9 +20,9 @@ describe("AppServerQueryCache", () => { it("garbage-collects inactive query contexts", async () => { vi.useFakeTimers(); try { - const cache = new AppServerQueryCache(); + const cache = cacheWithThreads(() => Promise.resolve([thread("temporary")])); const context = cacheContext(); - cache.setActiveThreads(context, [thread("temporary")]); + await cache.fetchActiveThreads(context); await vi.advanceTimersByTimeAsync(300_001); @@ -176,7 +176,6 @@ describe("AppServerQueryCache", () => { const context = cacheContext({ codexPath: "" }); await expect(cache.fetchActiveThreads(context)).resolves.toEqual([]); - cache.setActiveThreads(context, [thread("applied")]); cache.writeAppServerMetadata(context, metadata()); expect(cache.activeThreadsSnapshot(context)).toBeNull(); @@ -213,11 +212,6 @@ describe("AppServerQueryCache", () => { const cache = cacheWithThreads(fetchThreads); const context = cacheContext(); - cache.setActiveThreads(context, [thread("cached")]); - cache.setActiveThreads(context, []); - - expect(cache.activeThreadsSnapshot(context)).toEqual([]); - await expect(cache.refreshActiveThreads(context)).resolves.toEqual([]); expect(cache.activeThreadsSnapshot(context)).toEqual([]); expect(fetchThreads).toHaveBeenCalledOnce(); @@ -473,20 +467,18 @@ describe("AppServerQueryCache", () => { expect(cache.appServerMetadataSnapshot(context)?.availableSkills.map((skill) => skill.name)).toEqual(["event"]); }); - it("does not overwrite local thread list updates with an in-flight app-server snapshot", async () => { + it("stores an in-flight app-server snapshot as raw thread-list truth", async () => { const context = cacheContext(); const refresh = deferred[]>(); const cache = cacheWithThreads(() => refresh.promise); - cache.setActiveThreads(context, [thread("thread"), thread("other")]); const promise = cache.refreshActiveThreads(context); await flushMicrotasks(); - cache.updateActiveThreads(context, (threads) => threads?.filter((item) => item.id !== "thread") ?? null); refresh.resolve([thread("thread"), thread("other")]); - await expect(promise).resolves.toEqual([thread("other")]); - expect(cache.activeThreadsSnapshot(context)).toEqual([thread("other")]); + await expect(promise).resolves.toEqual([thread("thread"), thread("other")]); + expect(cache.activeThreadsSnapshot(context)).toEqual([thread("thread"), thread("other")]); }); }); diff --git a/tests/app-server/shared-queries.test.ts b/tests/app-server/shared-queries.test.ts index f47a47ea..79d96826 100644 --- a/tests/app-server/shared-queries.test.ts +++ b/tests/app-server/shared-queries.test.ts @@ -147,8 +147,6 @@ function cacheWith(overrides: Partial): AppServerQueryCache activeThreadsSnapshot: vi.fn(() => null), fetchActiveThreads: vi.fn(() => Promise.resolve([])), refreshActiveThreads: vi.fn(() => Promise.resolve([])), - setActiveThreads: vi.fn(), - updateActiveThreads: vi.fn(() => null), observeActiveThreadsResult: vi.fn(() => () => undefined), appServerMetadataSnapshot: vi.fn(() => null), updateAppServerMetadata: vi.fn(() => null), diff --git a/tests/features/threads/catalog/thread-catalog.test.ts b/tests/features/threads/catalog/thread-catalog.test.ts index 9db84ef3..19ce50be 100644 --- a/tests/features/threads/catalog/thread-catalog.test.ts +++ b/tests/features/threads/catalog/thread-catalog.test.ts @@ -209,7 +209,7 @@ describe("ThreadCatalog", () => { expect(catalog.activeSnapshot()).toBeNull(); }); - it("evicts lifecycle facts for least-recently-used connection contexts", () => { + it("retains unacknowledged lifecycle facts across connection contexts", () => { const context = { codexPath: "codex-a", vaultPath: "/vault" }; const { catalog } = catalogFixture({ context: () => context }); catalog.apply({ type: "thread-started", thread: thread("started-a") }); @@ -222,7 +222,50 @@ describe("ThreadCatalog", () => { context.codexPath = "codex-a"; catalog.apply({ type: "active-list-snapshot-received", threads: [thread("server-a")] }); - expect(catalog.activeSnapshot()).toEqual([thread("server-a")]); + expect(catalog.activeSnapshot()).toEqual([thread("started-a"), thread("server-a")]); + }); + + it("prunes inactive contexts after their lifecycle facts settle", () => { + const context = { codexPath: "codex-a", vaultPath: "/vault" }; + const { catalog } = catalogFixture({ context: () => context }); + catalog.apply({ type: "active-list-snapshot-received", threads: [thread("server-a")] }); + + context.codexPath = "codex-b"; + expect(catalog.activeSnapshot()).toBeNull(); + context.codexPath = "codex-a"; + + expect(catalog.activeSnapshot()).toBeNull(); + }); + + it("prunes revisited contexts after pending lifecycle facts settle", () => { + const context = { codexPath: "codex-a", vaultPath: "/vault" }; + const { catalog } = catalogFixture({ context: () => context }); + catalog.apply({ type: "thread-started", thread: thread("started-a") }); + context.codexPath = "codex-b"; + catalog.apply({ type: "thread-started", thread: thread("started-b") }); + + context.codexPath = "codex-a"; + catalog.apply({ type: "active-list-snapshot-received", threads: [thread("started-a")] }); + context.codexPath = "codex-b"; + catalog.apply({ type: "active-list-snapshot-received", threads: [thread("started-b")] }); + context.codexPath = "codex-a"; + + expect(catalog.activeSnapshot()).toBeNull(); + }); + + it("preserves raw query status when lifecycle overlays publish", async () => { + const refresh = deferred(); + const { catalog } = catalogFixture({ fetchThreads: () => refresh.promise }); + const listener = vi.fn(); + catalog.observeActive(listener); + const refreshing = catalog.refreshActive(); + await flushMicrotasks(); + + catalog.apply({ type: "thread-started", thread: thread("started") }); + + expect(listener).toHaveBeenLastCalledWith({ value: [thread("started")], error: null, isFetching: true }); + refresh.resolve([]); + await refreshing; }); it("keeps rollback fork metadata until active snapshots catch up to the rollback version", () => {