fix(session-search): stabilize paging and host enablement

This commit is contained in:
Jinwoo-H
2026-09-13 22:55:27 -04:00
parent db397e5f16
commit 705a35a5a2
6 changed files with 81 additions and 35 deletions
@@ -100,6 +100,17 @@ function updateRootRefreshTimer(refresh: RootRefresh, enabled: boolean): void {
async function pushSessionSearchPolicy(refresh: RootRefresh, policyChanged = true): Promise<void> {
try {
const current = sessionSearchServiceInit()
if (!current || refresh.disposed) {
return
}
if (!current.settings.enabled) {
updateRootRefreshTimer(refresh, false)
if (policyChanged) {
updateSessionSearchInService(current)
}
return
}
await refreshSessionSearchScanRoots()
if (refresh.disposed) {
return
@@ -143,18 +143,18 @@ it('starts root refresh on enable and cancels it on disable', async () => {
getSettings: () => settings
})
await vi.advanceTimersByTimeAsync(300_000)
expect(localAiVaultScanRoots).toHaveBeenCalledTimes(1)
expect(localAiVaultScanRoots).toHaveBeenCalledTimes(0)
const before = settings
settings = { aiVaultSearch: { enabled: true, historyDays: null } }
applySessionSearchSettingsChange(before, settings)
await vi.advanceTimersByTimeAsync(300_000)
expect(localAiVaultScanRoots).toHaveBeenCalledTimes(3)
expect(localAiVaultScanRoots).toHaveBeenCalledTimes(2)
const enabled = settings
settings = before
applySessionSearchSettingsChange(enabled, settings)
await vi.advanceTimersByTimeAsync(300_000)
expect(localAiVaultScanRoots).toHaveBeenCalledTimes(4)
expect(localAiVaultScanRoots).toHaveBeenCalledTimes(2)
expect(updateSessionSearchInService).toHaveBeenLastCalledWith(
expect.objectContaining({ settings: before.aiVaultSearch })
)
@@ -205,8 +205,8 @@ it('registers an in-process service for a host with no scanner child', async ()
// host's boot path reaches one, which no unit of either module can show.
it.each([
[
'desktop main',
'src/main/ipc/register-core-handlers/register-core-handlers.ts',
'desktop and headless serve',
'src/main/startup/main-process-runtime-service.ts',
'installChildSessionSearchService'
],
['orcad', 'src/main/orcad/orcad-session-search.ts', 'installInProcessSessionSearchService'],
@@ -220,3 +220,28 @@ it.each([
expect(source).toContain(installer)
expect(source).toMatch(new RegExp(`${installer}\\(\\{`))
})
it('disables immediately while an enabled root discovery is pending', async () => {
const { installChildSessionSearchService, applySessionSearchSettingsChange } =
await import('./session-search-enablement')
let settings = { aiVaultSearch: { enabled: true, historyDays: null } }
const pending = Promise.withResolvers<typeof harness.roots>()
localAiVaultScanRoots.mockReturnValueOnce(pending.promise)
installed = installChildSessionSearchService({
dataRoot: harness.root,
getSettings: () => settings
})
const before = settings
settings = { aiVaultSearch: { enabled: false, historyDays: null } }
applySessionSearchSettingsChange(before, settings)
expect(updateSessionSearchInService).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({ settings: settings.aiVaultSearch })
)
expect(localAiVaultScanRoots).toHaveBeenCalledTimes(1)
pending.resolve(harness.roots)
await Promise.resolve()
await Promise.resolve()
expect(updateSessionSearchInService).toHaveBeenLastCalledWith(
expect.objectContaining({ settings: settings.aiVaultSearch })
)
})
@@ -1,7 +1,7 @@
import { existsSync } from 'node:fs'
import { utimes } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
import { resetSessionParseCacheForTests } from '../ai-vault/session-scanner-parse-cache'
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
import { SessionSearchInstance } from './session-search-instance'
@@ -27,6 +27,7 @@ beforeEach(async () => {
})
afterEach(async () => {
vi.restoreAllMocks()
instance?.close()
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
@@ -165,3 +166,30 @@ it('removes the database on clear and rebuilds only while consent stands', async
expect(existsSync(harness.databasePath)).toBe(false)
expect(errors).toEqual([])
})
it('keeps pagination stable when the clock crosses retention before a purge', async () => {
for (const id of [RECENT_SESSION_ID, ANCIENT_SESSION_ID]) {
await writeClaudeTranscript(transcriptPath(id), [`distinctive conversation ${id}`], id)
}
const subject = newInstance()
subject.apply({ enabled: true, historyDays: 30 })
await subject.settled()
const first = await subject.search({ query: 'distinctive', limit: 1 })
if (first.kind !== 'results') {
throw new Error('expected results')
}
expect(first.page.cursor).toBeTruthy()
vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 31 * 86_400_000)
const second = await subject.search({
query: 'distinctive',
limit: 1,
cursor: first.page.cursor!
})
if (second.kind !== 'results') {
throw new Error('expected results')
}
expect(second.generation).toBe(first.generation)
expect(second.hits).toHaveLength(1)
expect(second.hits[0].sessionId).not.toBe(first.hits[0].sessionId)
expect(errors).toEqual([])
})
@@ -6,7 +6,7 @@ import type {
} from '../../shared/ai-vault-search-types'
import { unavailableSessionSearchStatus } from '../../shared/ai-vault-search-client'
import type { AiVaultSearchSettings } from '../../shared/ai-vault-search-settings'
import { SessionSearchEngine, type SessionSearchEngineOptions } from './session-search-engine'
import { SessionSearchEngine } from './session-search-engine'
import { SessionSearchIndexer } from './session-search-indexer'
import { sessionSearchHistoryCutoffMs } from './session-search-retention-policy'
import { openSessionSearchDatabase, removeSessionSearchDatabase } from './session-search-schema'
@@ -26,9 +26,7 @@ type LiveIndex = {
engine: SessionSearchEngine
/** The engine's own handle; the indexer's store keeps a second, private one. */
db: SyncDatabase
engineOptions: SessionSearchEngineOptions
service: SessionSearchService
historyDays: number | null
}
/**
@@ -82,7 +80,6 @@ export class SessionSearchInstance {
if (!live) {
return { kind: 'unavailable', reason: this.settings.enabled ? 'not-ready' : 'disabled' }
}
this.refreshRetentionCutoff(live)
return live.service.search(request)
}
@@ -125,7 +122,8 @@ export class SessionSearchInstance {
: { reconcileIntervalMs: this.options.reconcileIntervalMs })
})
db = openSessionSearchDatabase(this.options.databasePath)
const engineOptions: SessionSearchEngineOptions = {
// Later expiry comes from the indexer purge, which also invalidates page cursors.
const engineOptions = {
retentionCutoffMs: sessionSearchHistoryCutoffMs(historyDays, Date.now())
}
const engine = new SessionSearchEngine(db, engineOptions)
@@ -133,8 +131,6 @@ export class SessionSearchInstance {
indexer,
engine,
db,
engineOptions,
historyDays,
service: createSessionSearchService({ engine, indexer })
}
void indexer.start().catch(this.onError)
@@ -148,18 +144,6 @@ export class SessionSearchInstance {
}
}
/**
* The window moves with the clock. The store re-reads it every pass; the
* engine holds its options object, so this is where a long-lived instance's
* search stops answering with rows the next purge will delete.
*/
private refreshRetentionCutoff(live: LiveIndex): void {
live.engineOptions.retentionCutoffMs = sessionSearchHistoryCutoffMs(
live.historyDays,
Date.now()
)
}
private closeLive(): void {
const live = this.live
this.live = null
@@ -26,8 +26,6 @@ import { registerRuntimeEnvironmentHandlers } from '../runtime-environments'
import { registerEphemeralVmHandlers } from '../ephemeral-vm'
import { registerAiVaultHandlers } from '../ai-vault'
import { registerAiVaultSearchHandlers } from '../ai-vault-search'
import { installChildSessionSearchService } from '../../ai-vault-search/session-search-enablement'
import { getCanonicalUserDataPath } from '../../persistence/loading-store/user-data-path'
import { registerNativeChatHandlers } from '../native-chat'
import { registerNotificationHandlers } from '../notifications'
import { registerNotebookHandlers } from '../notebook'
@@ -222,14 +220,6 @@ export function registerCoreHandlers(
callRuntimeSearch: (environmentId, method, params) =>
callRuntimeSessionSearch(app.getPath('userData'), environmentId, method, params)
})
// Why beside the handlers and not in preflight: the handlers are what answer a
// search, and this is what gives them something to answer from. Same canonical
// path the parse cache takes, so both halves of the scanner's state agree.
const sessionSearch = installChildSessionSearchService({
dataRoot: getCanonicalUserDataPath(),
getSettings: () => store.getSettings()
})
app.once('will-quit', () => sessionSearch?.dispose())
registerAiVaultHandlers({
ensureStructuredSessionOwnership: () => runtime.ensureStructuredAgentSessionHost(),
getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths,
@@ -1,3 +1,5 @@
import { installChildSessionSearchService } from '../ai-vault-search/session-search-enablement'
import { getCanonicalUserDataPath } from '../persistence/loading-store/user-data-path'
import { app } from 'electron'
import { OrcaRuntimeService } from '../runtime/orca-runtime'
import { getLocalPtyProvider, getSshPtyProvider, clearProviderPtyState } from '../ipc/pty'
@@ -131,6 +133,12 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService {
orchestrationEnvironmentTransport,
skillTransactionRecovery: state.skillTransactionRecovery
})
// Both desktop and headless serve own a host-local search service.
const sessionSearch = installChildSessionSearchService({
dataRoot: getCanonicalUserDataPath(),
getSettings: () => store.getSettings()
})
app.once('will-quit', () => sessionSearch?.dispose())
state.runtime = runtime
agentHookServer.subscribeEnrichedStatus((enriched) =>
recordObservedAgentStatusPaneIdentity(observedPaneIdentities, enriched.paneKey, runtime)