refactor(session-query): simplify provider synchronization

This commit is contained in:
Hypatia May
2026-07-11 11:48:32 +08:00
parent f4b0dba380
commit 8dd85bd185
6 changed files with 106 additions and 138 deletions

View File

@@ -23,11 +23,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:39`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:39`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`stdio-agent`](../packages/ui/stdio-agent) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`emit`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`parallel`) | [`session-persistence`](../packages/session-persistence/session-persistence) |
| `session/persisted` | `parallel` | [`packages/session-persistence/session-persistence/src/index.ts:50`](../packages/session-persistence/session-persistence/src/index.ts) | [`session-persistence`](../packages/session-persistence/session-persistence) (`parallel`) | [`session-query`](../packages/session-query/session-query) | | `session/persisted` | `parallel` | [`packages/session-persistence/session-persistence/src/index.ts:50`](../packages/session-persistence/session-persistence/src/index.ts) | [`session-persistence`](../packages/session-persistence/session-persistence) (`parallel`) | [`session-query`](../packages/session-query/session-query) |
| `session/removed` | `parallel` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | [`session-query`](../packages/session-query/session-query) | | `session/removed` | `parallel` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`parallel`) | - |
| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:98`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) |
| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) |
| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | - | [`tool-subagent`](../packages/subagent/tool-subagent) |

View File

@@ -36,10 +36,7 @@ export interface PersistenceView {
export class SessionCorpus { export class SessionCorpus {
private _persistence: PersistenceBinding | undefined private _persistence: PersistenceBinding | undefined
constructor( constructor(private readonly _ctx: Context) {
private readonly _ctx: Context,
private readonly _onPersistenceChange: (active: boolean) => void,
) {
_ctx.effect(() => { _ctx.effect(() => {
const fiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => { const fiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => {
this._attachPersistence(childCtx, childCtx.sessionPersistence) this._attachPersistence(childCtx, childCtx.sessionPersistence)
@@ -145,7 +142,6 @@ export class SessionCorpus {
refreshing: undefined, refreshing: undefined,
} }
this._persistence = binding this._persistence = binding
this._onPersistenceChange(true)
void this._refreshPersistence(binding) void this._refreshPersistence(binding)
ctx.on('session/persisted', (header) => { ctx.on('session/persisted', (header) => {
/* v8 ignore next -- a stale notification can race optional-service disposal */ /* v8 ignore next -- a stale notification can race optional-service disposal */
@@ -154,7 +150,6 @@ export class SessionCorpus {
const observation = { generation: ++binding.observationGeneration, header: snapshot } const observation = { generation: ++binding.observationGeneration, header: snapshot }
binding.headers.set(header.id, snapshot) binding.headers.set(header.id, snapshot)
binding.observations.set(header.id, observation) binding.observations.set(header.id, observation)
this._onPersistenceChange(true)
}) })
ctx.effect(() => () => { this._detachPersistence(binding) }, 'sessionQuery.persistenceBinding') ctx.effect(() => () => { this._detachPersistence(binding) }, 'sessionQuery.persistenceBinding')
} }
@@ -163,7 +158,6 @@ export class SessionCorpus {
/* v8 ignore next -- duplicate optional-service disposal is a Cordis teardown edge */ /* v8 ignore next -- duplicate optional-service disposal is a Cordis teardown edge */
if (this._persistence?.token !== binding.token) return if (this._persistence?.token !== binding.token) return
this._persistence = undefined this._persistence = undefined
this._onPersistenceChange(false)
} }
private _refreshPersistence(binding: PersistenceBinding): Promise<void> { private _refreshPersistence(binding: PersistenceBinding): Promise<void> {
@@ -184,7 +178,6 @@ export class SessionCorpus {
} }
binding.headers = nextHeaders binding.headers = nextHeaders
binding.error = undefined binding.error = undefined
this._onPersistenceChange(true)
}).catch((error: unknown) => { }).catch((error: unknown) => {
/* v8 ignore next -- a failed list can race optional-service disposal */ /* v8 ignore next -- a failed list can race optional-service disposal */
if (this._persistence?.token !== binding.token) return if (this._persistence?.token !== binding.token) return

View File

@@ -37,7 +37,7 @@ export class SessionTextExtractors {
private readonly _eventExtractors = new Map<SessionEventType, StoredEventExtractor>() private readonly _eventExtractors = new Map<SessionEventType, StoredEventExtractor>()
private readonly _contentExtractors = new Map<ContentBlockType, StoredContentExtractor>() private readonly _contentExtractors = new Map<ContentBlockType, StoredContentExtractor>()
constructor(private readonly _onChange: () => void) { constructor() {
this._installCoreExtractors() this._installCoreExtractors()
} }
@@ -63,10 +63,8 @@ export class SessionTextExtractors {
} }
const dispose = ctx.effect(function* (this: SessionTextExtractors) { const dispose = ctx.effect(function* (this: SessionTextExtractors) {
this._eventExtractors.set(type, stored) this._eventExtractors.set(type, stored)
this._onChange()
yield () => { yield () => {
this._eventExtractors.delete(type) this._eventExtractors.delete(type)
this._onChange()
} }
}.bind(this), `sessionQuery.eventExtractor(${type})`) }.bind(this), `sessionQuery.eventExtractor(${type})`)
return () => void dispose() return () => void dispose()
@@ -94,10 +92,8 @@ export class SessionTextExtractors {
} }
const dispose = ctx.effect(function* (this: SessionTextExtractors) { const dispose = ctx.effect(function* (this: SessionTextExtractors) {
this._contentExtractors.set(type, stored) this._contentExtractors.set(type, stored)
this._onChange()
yield () => { yield () => {
this._contentExtractors.delete(type) this._contentExtractors.delete(type)
this._onChange()
} }
}.bind(this), `sessionQuery.contentExtractor(${type})`) }.bind(this), `sessionQuery.contentExtractor(${type})`)
return () => void dispose() return () => void dispose()

View File

@@ -78,13 +78,13 @@ export class SessionQueryService extends Service {
if (defaultLimit > maxLimit) { if (defaultLimit > maxLimit) {
throw new SessionQueryError('session-query: defaultLimit must be <= maxLimit', 'SESSION_QUERY_INVALID_CONFIG') throw new SessionQueryError('session-query: defaultLimit must be <= maxLimit', 'SESSION_QUERY_INVALID_CONFIG')
} }
this._extractors = new SessionTextExtractors(() => { this._providers.invalidateAll() }) this._extractors = new SessionTextExtractors()
this._providers = new SessionProviderCoordinator(ctx, { this._providers = new SessionProviderCoordinator({
...config.searchProvider !== undefined ? { searchProvider: config.searchProvider } : {}, ...config.searchProvider !== undefined ? { searchProvider: config.searchProvider } : {},
defaultLimit, defaultLimit,
maxLimit, maxLimit,
}, () => this._corpus, this._extractors) }, () => this._corpus, this._extractors)
this._corpus = new SessionCorpus(ctx, (active) => { this._providers.persistenceChanged(active) }) this._corpus = new SessionCorpus(ctx)
} }
/** /**

View File

@@ -26,13 +26,6 @@ interface ProviderState {
active: boolean active: boolean
chain: Promise<void> chain: Promise<void>
liveIds: Set<SessionId> liveIds: Set<SessionId>
fullSync: FullSync | undefined
liveSync: Map<SessionId, Promise<void>>
}
interface FullSync {
liveKey: string
promise: Promise<void>
} }
/** Coordinates one selected provider against live and persisted corpus layers. */ /** Coordinates one selected provider against live and persisted corpus layers. */
@@ -43,7 +36,6 @@ export class SessionProviderCoordinator {
private readonly _providers = new Map<string, ProviderState>() private readonly _providers = new Map<string, ProviderState>()
constructor( constructor(
private readonly _ctx: Context,
config: Required<Pick<Config, 'defaultLimit' | 'maxLimit'>> & Pick<Config, 'searchProvider'>, config: Required<Pick<Config, 'defaultLimit' | 'maxLimit'>> & Pick<Config, 'searchProvider'>,
private readonly _corpus: () => SessionCorpus, private readonly _corpus: () => SessionCorpus,
private readonly _extractors: SessionTextExtractors, private readonly _extractors: SessionTextExtractors,
@@ -51,9 +43,6 @@ export class SessionProviderCoordinator {
this._configuredProviderId = config.searchProvider this._configuredProviderId = config.searchProvider
this._defaultLimit = config.defaultLimit this._defaultLimit = config.defaultLimit
this._maxLimit = config.maxLimit this._maxLimit = config.maxLimit
_ctx.on('session/created', (session) => { this.invalidateLive(session.id) })
_ctx.on('session/event', (session) => { this.invalidateLive(session.id) })
_ctx.on('session/removed', (header) => { this.invalidateLive(header.id) })
} }
/** /**
@@ -71,14 +60,9 @@ export class SessionProviderCoordinator {
active: true, active: true,
chain: Promise.resolve(), chain: Promise.resolve(),
liveIds: new Set(), liveIds: new Set(),
fullSync: undefined,
liveSync: new Map(),
} }
const dispose = ctx.effect(function* (this: SessionProviderCoordinator) { const dispose = ctx.effect(function* (this: SessionProviderCoordinator) {
this._providers.set(provider.id, state) this._providers.set(provider.id, state)
void this._enqueue(state, () => provider.setPersistedActive(false)).catch((error: unknown) => {
this._ctx.logger.warn(`session-query provider "${provider.id}" failed initial deactivation: ${String(error)}`)
})
yield () => { yield () => {
state.active = false state.active = false
this._providers.delete(provider.id) this._providers.delete(provider.id)
@@ -99,9 +83,12 @@ export class SessionProviderCoordinator {
): Promise<SessionSearchPage<SessionSearchHit>> { ): Promise<SessionSearchPage<SessionSearchHit>> {
const state = this._resolveProvider() const state = this._resolveProvider()
const normalized = this._normalizeSessionSearch(request) const normalized = this._normalizeSessionSearch(request)
await waitFor(this._syncAll(state), exec?.signal) const work = this._runFullSearch(state, async () => {
const result = await waitFor(state.provider.searchSessions(normalized, exec), exec?.signal) if (exec?.signal?.aborted) throw aborted()
return this._validateSearchPage(state, result, normalized.limit) const result = await state.provider.searchSessions(normalized, exec)
return this._validateSearchPage(state, result, normalized.limit)
})
return waitFor(work, exec?.signal)
} }
/** /**
@@ -116,87 +103,41 @@ export class SessionProviderCoordinator {
): Promise<SessionSearchPage<SessionEventSearchHit>> { ): Promise<SessionSearchPage<SessionEventSearchHit>> {
const state = this._resolveProvider() const state = this._resolveProvider()
const normalized = this._normalizeEventSearch(request) const normalized = this._normalizeEventSearch(request)
const query = async (): Promise<SessionSearchPage<SessionEventSearchHit>> => {
if (exec?.signal?.aborted) throw aborted()
const result = await state.provider.searchEvents(normalized, exec)
return this._validateSearchPage(state, result, normalized.limit)
}
const live = this._corpus().getLive(request.sessionId) const live = this._corpus().getLive(request.sessionId)
let work: Promise<SessionSearchPage<SessionEventSearchHit>>
if (live !== undefined) { if (live !== undefined) {
await waitFor(this._syncLive(state, live), exec?.signal) work = this._runLiveSearch(state, live, query)
} else { } else {
const persistence = await this._corpus().persistenceView() const persistence = await this._corpus().persistenceView()
if (persistence === undefined || !persistence.headers.some(header => header.id === request.sessionId)) { if (persistence === undefined || !persistence.headers.some(header => header.id === request.sessionId)) {
throw new SessionQueryError(`session "${request.sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND') throw new SessionQueryError(`session "${request.sessionId}" not found`, 'SESSION_QUERY_SESSION_NOT_FOUND')
} }
await waitFor(this._syncAll(state), exec?.signal) work = this._runFullSearch(state, query)
} }
const result = await waitFor(state.provider.searchEvents(normalized, exec), exec?.signal) return waitFor(work, exec?.signal)
return this._validateSearchPage(state, result, normalized.limit)
} }
/** private _runFullSearch<T>(state: ProviderState, query: () => Promise<T>): Promise<T> {
* Invalidate provider synchronization after one live source change. const liveSessions = this._corpus().listLive()
* @param sessionId - changed live session. return this._serialize(state, async () => {
*/ await this._synchronize(state, async () => {
invalidateLive(sessionId: SessionId): void { /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */
for (const state of this._providers.values()) { if (!state.active) return
state.fullSync = undefined const persistence = await this._corpus().persistenceView()
state.liveSync.delete(sessionId) if (persistence === undefined) {
} await state.provider.setPersistedActive(false)
} } else {
await this._syncPersisted(state, persistence)
/** Invalidate all source/extractor-derived provider snapshots. */ }
invalidateAll(): void { await this._replaceLiveCorpus(state, liveSessions)
for (const state of this._providers.values()) {
state.fullSync = undefined
state.liveSync.clear()
}
}
/**
* React to persistence mount, inventory change, or unmount.
* @param active - whether canonical persistence remains mounted.
*/
persistenceChanged(active: boolean): void {
for (const state of this._providers.values()) state.fullSync = undefined
if (active) return
for (const state of this._providers.values()) {
void this._enqueue(state, () => state.provider.setPersistedActive(false)).catch((error: unknown) => {
this._ctx.logger.warn(`session-query provider "${state.provider.id}" failed persistence deactivation: ${String(error)}`)
}) })
} return query()
}
private _syncAll(state: ProviderState): Promise<void> {
// Capture the direct source before awaiting: only searches that observed
// the same live corpus may share an in-flight full synchronization.
let liveSessions: Session[]
let liveKey: string
try {
liveSessions = this._corpus().listLive()
liveKey = JSON.stringify(liveSessions.map(session => this._snapshotLive(session)).map(snapshot => [
snapshot.session.header.id,
snapshot.fingerprint,
snapshot.session.persisted,
]))
} catch (error: unknown) {
return Promise.reject(this._synchronizationError(state, error))
}
if (state.fullSync?.liveKey === liveKey) return state.fullSync.promise
const promise = this._enqueue(state, async () => {
/* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */
if (!state.active) return
const persistence = await this._corpus().persistenceView()
if (persistence === undefined) {
await state.provider.setPersistedActive(false)
} else {
await this._syncPersisted(state, persistence)
}
await this._replaceLiveCorpus(state, liveSessions)
}) })
const fullSync = { liveKey, promise }
state.fullSync = fullSync
void promise.finally(() => {
/* v8 ignore next -- a newer invalidation may already own the sync slot */
if (state.fullSync === fullSync) state.fullSync = undefined
}).catch(() => undefined)
return promise
} }
private async _syncPersisted(state: ProviderState, persistence: PersistenceView): Promise<void> { private async _syncPersisted(state: ProviderState, persistence: PersistenceView): Promise<void> {
@@ -222,39 +163,42 @@ export class SessionProviderCoordinator {
state.liveIds = liveIds state.liveIds = liveIds
} }
private _syncLive(state: ProviderState, session: Session): Promise<void> { private _runLiveSearch<T>(state: ProviderState, session: Session, query: () => Promise<T>): Promise<T> {
const existing = state.liveSync.get(session.id)
if (existing !== undefined) return existing
let snapshot: ReturnType<SessionTextExtractors['buildSnapshot']> let snapshot: ReturnType<SessionTextExtractors['buildSnapshot']>
try { try {
snapshot = this._snapshotLive(session) snapshot = this._snapshotLive(session)
} catch (error: unknown) { } catch (error: unknown) {
return Promise.reject(this._synchronizationError(state, error)) return Promise.reject(this._synchronizationError(state, error))
} }
const promise = this._enqueue(state, async () => { return this._serialize(state, async () => {
/* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */ await this._synchronize(state, async () => {
if (!state.active) return /* v8 ignore next -- a provider can be disposed while queued behind an in-flight update */
await state.provider.replaceLive(snapshot) if (!state.active) return
state.liveIds.add(session.id) await state.provider.replaceLive(snapshot)
state.liveIds.add(session.id)
})
return query()
}) })
state.liveSync.set(session.id, promise)
void promise.finally(() => {
/* v8 ignore next -- a newer invalidation may already own the target slot */
if (state.liveSync.get(session.id) === promise) state.liveSync.delete(session.id)
}).catch(() => undefined)
return promise
} }
private _snapshotLive(session: Session): ReturnType<SessionTextExtractors['buildSnapshot']> { private _snapshotLive(session: Session): ReturnType<SessionTextExtractors['buildSnapshot']> {
return this._extractors.buildSnapshot(this._corpus().snapshotLive(session)) return this._extractors.buildSnapshot(this._corpus().snapshotLive(session))
} }
private _enqueue(state: ProviderState, operation: () => Promise<void>): Promise<void> { /** Serialize reconciliation and its provider query as one stable transaction. */
private _serialize<T>(state: ProviderState, operation: () => Promise<T>): Promise<T> {
const next = state.chain.then(operation, operation) const next = state.chain.then(operation, operation)
state.chain = next.then(() => undefined, () => undefined) state.chain = next.then(() => undefined, () => undefined)
return next.catch((error: unknown) => { return next
}
/** Translate only derived-index update failures, never provider query failures. */
private async _synchronize(state: ProviderState, operation: () => Promise<void>): Promise<void> {
try {
await operation()
} catch (error: unknown) {
throw this._synchronizationError(state, error) throw this._synchronizationError(state, error)
}) }
} }
private _synchronizationError(state: ProviderState, error: unknown): SessionQueryError { private _synchronizationError(state: ProviderState, error: unknown): SessionQueryError {

View File

@@ -103,7 +103,6 @@ class FakeProvider implements SessionSearchProvider {
eventRequests: SessionEventSearchSpec[] = [] eventRequests: SessionEventSearchSpec[] = []
failNextLive = false failNextLive = false
failNextPersisted = false failNextPersisted = false
failNextActive = false
sessionPage: SessionSearchPage<SessionSearchHit> sessionPage: SessionSearchPage<SessionSearchHit>
eventPage: SessionSearchPage<SessionEventSearchHit> eventPage: SessionSearchPage<SessionEventSearchHit>
@@ -125,10 +124,6 @@ class FakeProvider implements SessionSearchProvider {
} }
setPersistedActive(active: boolean): Promise<void> { setPersistedActive(active: boolean): Promise<void> {
if (this.failNextActive) {
this.failNextActive = false
return Promise.reject(new Error('activation failed'))
}
this.activeHistory.push(active) this.activeHistory.push(active)
return Promise.resolve() return Promise.resolve()
} }
@@ -409,7 +404,7 @@ describe('provider selection and synchronization', () => {
await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_UNAVAILABLE')) await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_UNAVAILABLE'))
}) })
it('coalesces concurrent synchronization and supports cancellation while provider search is pending', async () => { it('serializes concurrent synchronization and supports cancellation while provider search is pending', async () => {
const ctx = await liveContext() const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('coalesce')) const session = ctx.sessions.create(SessionId('coalesce'))
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
@@ -418,34 +413,40 @@ describe('provider selection and synchronization', () => {
let releaseLive!: () => void let releaseLive!: () => void
const liveBarrier = new Promise<void>((resolve) => { releaseLive = resolve }) const liveBarrier = new Promise<void>((resolve) => { releaseLive = resolve })
const liveStarted = deferred()
let replacements = 0 let replacements = 0
provider.replaceLive = async (snapshot) => { provider.replaceLive = async (snapshot) => {
replacements += 1 replacements += 1
liveStarted.resolve()
await liveBarrier await liveBarrier
provider.live.set(snapshot.session.header.id, structuredClone(snapshot)) provider.live.set(snapshot.session.header.id, structuredClone(snapshot))
} }
const first = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) const first = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })
const second = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }) const second = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })
await Promise.resolve() await liveStarted.promise
expect(replacements).toBe(1)
releaseLive() releaseLive()
await Promise.all([first, second]) await Promise.all([first, second])
expect(replacements).toBe(1) expect(replacements).toBe(2)
session.append('user/message', { content: [{ type: 'text', text: 'y' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) session.append('user/message', { content: [{ type: 'text', text: 'y' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
let releaseCorpus!: () => void let releaseCorpus!: () => void
const corpusBarrier = new Promise<void>((resolve) => { releaseCorpus = resolve }) const corpusBarrier = new Promise<void>((resolve) => { releaseCorpus = resolve })
const corpusStarted = deferred()
let corpusReplacements = 0 let corpusReplacements = 0
provider.replaceLive = async (snapshot) => { provider.replaceLive = async (snapshot) => {
corpusReplacements += 1 corpusReplacements += 1
corpusStarted.resolve()
await corpusBarrier await corpusBarrier
provider.live.set(snapshot.session.header.id, structuredClone(snapshot)) provider.live.set(snapshot.session.header.id, structuredClone(snapshot))
} }
const crossFirst = ctx.sessionQuery.searchSessions({ query: 'x' }) const crossFirst = ctx.sessionQuery.searchSessions({ query: 'x' })
const crossSecond = ctx.sessionQuery.searchSessions({ query: 'x' }) const crossSecond = ctx.sessionQuery.searchSessions({ query: 'x' })
await Promise.resolve() await corpusStarted.promise
expect(corpusReplacements).toBe(1)
releaseCorpus() releaseCorpus()
await Promise.all([crossFirst, crossSecond]) await Promise.all([crossFirst, crossSecond])
expect(corpusReplacements).toBe(1) expect(corpusReplacements).toBe(2)
let releaseSearch!: () => void let releaseSearch!: () => void
const searchBarrier = new Promise<void>((resolve) => { releaseSearch = resolve }) const searchBarrier = new Promise<void>((resolve) => { releaseSearch = resolve })
@@ -467,6 +468,42 @@ describe('provider selection and synchronization', () => {
.rejects.toThrow('search failed') .rejects.toThrow('search failed')
}) })
it('holds a provider query stable until later reconciliation can begin', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('stable-query'))
session.append('user/message', { content: [{ type: 'text', text: 'stable' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const provider = new FakeProvider()
const queryStarted = deferred()
const releaseQuery = deferred()
provider.searchEvents = async () => {
queryStarted.resolve()
await releaseQuery.promise
return { providerId: provider.id, items: [] }
}
const reconciliationStarted = deferred()
let reconciling = false
provider.setPersistedActive = (active) => {
if (!active) {
reconciling = true
reconciliationStarted.resolve()
}
return Promise.resolve()
}
ctx.sessionQuery.registerSearchProvider(provider)
const eventSearch = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'stable' })
await queryStarted.promise
const fullSearch = ctx.sessionQuery.searchSessions({ query: 'stable' })
await new Promise<void>((resolve) => { setImmediate(resolve) })
expect(reconciling).toBe(false)
releaseQuery.resolve()
await eventSearch
await reconciliationStarted.promise
expect(reconciling).toBe(true)
await fullSearch
})
it('reconciles a live removal observed while an older full sync is in flight', async () => { it('reconciles a live removal observed while an older full sync is in flight', async () => {
const ctx = await liveContext() const ctx = await liveContext()
const session = ctx.sessions.prepare(SessionId('removed-during-sync')) const session = ctx.sessions.prepare(SessionId('removed-during-sync'))
@@ -563,7 +600,6 @@ describe('provider selection and synchronization', () => {
live.append('user/message', { content: [{ type: 'text', text: 'override' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) live.append('user/message', { content: [{ type: 'text', text: 'override' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const persistenceFiber = await ctx.plugin(TestPersistence) const persistenceFiber = await ctx.plugin(TestPersistence)
const provider = new FakeProvider() const provider = new FakeProvider()
provider.failNextActive = true
provider.persisted.set(SessionId('stale'), { session: { header: header('stale'), live: false, persisted: true }, fingerprint: 'stale', documents: [] }) provider.persisted.set(SessionId('stale'), { session: { header: header('stale'), live: false, persisted: true }, fingerprint: 'stale', documents: [] })
ctx.sessionQuery.registerSearchProvider(provider) ctx.sessionQuery.registerSearchProvider(provider)
@@ -583,7 +619,6 @@ describe('provider selection and synchronization', () => {
await ctx.sessionQuery.searchSessions({ query: 'x' }) await ctx.sessionQuery.searchSessions({ query: 'x' })
expect(provider.persisted.get(announced.id)?.documents[0]?.text).toBe('announced') expect(provider.persisted.get(announced.id)?.documents[0]?.text).toBe('announced')
provider.failNextActive = true
await persistenceFiber.dispose() await persistenceFiber.dispose()
await ctx.sessionQuery.searchSessions({ query: 'x' }) await ctx.sessionQuery.searchSessions({ query: 'x' })
expect(provider.activeHistory.at(-1)).toBe(false) expect(provider.activeHistory.at(-1)).toBe(false)
@@ -674,7 +709,7 @@ describe('provider selection and synchronization', () => {
expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('retry') expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('retry')
}) })
it('types synchronous extractor failures during full-search key construction', async () => { it('types extractor failures during queued full synchronization', async () => {
const ctx = await liveContext() const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('throwing-extractor')) const session = ctx.sessions.create(SessionId('throwing-extractor'))
session.append('test/note', { note: 'unreachable' }) session.append('test/note', { note: 'unreachable' })
@@ -714,7 +749,7 @@ describe('provider selection and synchronization', () => {
const onUnhandled = (reason: unknown) => { unhandled.push(reason) } const onUnhandled = (reason: unknown) => { unhandled.push(reason) }
process.on('unhandledRejection', onUnhandled) process.on('unhandledRejection', onUnhandled)
try { try {
await expect(ctx.sessionQuery.searchSessions({ query: 'x' }, { signal: controller.signal })) await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }, { signal: controller.signal }))
.rejects.toThrow(expectCode('SESSION_QUERY_ABORTED')) .rejects.toThrow(expectCode('SESSION_QUERY_ABORTED'))
await new Promise<void>((resolve) => { setImmediate(resolve) }) await new Promise<void>((resolve) => { setImmediate(resolve) })
expect(unhandled).toEqual([]) expect(unhandled).toEqual([])