Ver Fonte

fix(session-query): address review round 1

Hypatia May há 3 meses atrás
pai
commit
18028cad4f

+ 1 - 0
docs/core-data-structures/core.md

@@ -18,6 +18,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t
 | [llm-streaming.md](llm-streaming.md) | the `StreamChunk` wire protocol + adapter contract, `BlockAssembler`, the `LlmAdapter` seam |
 | [session.md](session.md) | the full `SessionEventMap` variant catalog, `TurnTrigger`/`TurnEndReason`, `deriveMessages()`, the turn-enclosure invariant |
 | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` |
+| [session-query.md](session-query.md) | the retrieval seam: logical session/event records, filters, traces, search pages, extractors, and provider synchronization types |
 | [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/pre-execute`/`tools/post-execute` pipeline |
 | [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy |
 | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s |

+ 14 - 0
docs/core-data-structures/persistence.md

@@ -73,6 +73,20 @@ interface CreateSessionOptions {
 
 Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resuming a *persisted* session into a live agent is `ctx.agents.resume({ resumeSessionId })`.
 
+## `SessionPersistedChange` — committed-log notification range
+
+The observe-only `session/persisted` event carries the canonical header and the committed range. A repair can report `toSeq < fromSeq` when it only removes a torn fragment.
+
+Source: [`packages/session-persistence/session-persistence/src/index.ts`](../../packages/session-persistence/session-persistence/src/index.ts)
+
+```ts type-equiv
+export interface SessionPersistedChange {
+  kind: 'append' | 'repair'
+  fromSeq: number
+  toSeq: number
+}
+```
+
 ## The backends
 
 Both implement the same abstract `SessionPersistence` (create/append/load/list over `SessionEvent`) and pass `runPersistenceContract`, proving the seam is genuinely backend-agnostic:

+ 221 - 0
docs/core-data-structures/session-query.md

@@ -0,0 +1,221 @@
+# Session Query
+
+The provider-neutral retrieval seam over live and optionally persisted sessions. The [package contract](../../packages/session-query/session-query) owns resolution, lifecycle, synchronization, and error behavior; this page catalogs the public data exchanged by callers, extractors, and search providers.
+
+Source: [`packages/session-query/session-query/src/types.ts`](../../packages/session-query/session-query/src/types.ts)
+
+## Logical records and filters
+
+`SessionRecord` exposes source availability independently from its live-preferred header. `SessionEventRecord` classifies every raw event against the folded surface.
+
+```ts type-equiv
+export type SessionEventSurface = 'current' | 'shadowed' | 'log-only'
+```
+
+```ts type-equiv
+export interface SessionRecord {
+  header: SessionHeader
+  live: boolean
+  persisted: boolean
+}
+```
+
+```ts type-equiv
+export interface SessionEventRecord {
+  sessionId: SessionId
+  seq: number
+  type: SessionEventType
+  time: number
+  surface: SessionEventSurface
+}
+```
+
+Filters are serializable discriminated specs. Each spec is one transform in a chain; the literal types below are shared by in-memory filtering and provider pre-ranking requests.
+
+```ts type-equiv
+export interface SessionQueryRange {
+  from?: number
+  to?: number
+}
+```
+
+```ts type-equiv
+export type SessionResultFilter =
+  | { kind: 'id'; values: readonly SessionId[] }
+  | { kind: 'cwd'; values: readonly (string | null)[] }
+  | { kind: 'created-at'; range: SessionQueryRange }
+  | { kind: 'parent'; values: readonly (SessionId | null)[] }
+  | { kind: 'availability'; values: readonly ('live' | 'persisted')[] }
+```
+
+```ts type-equiv
+export type SessionEventResultFilter =
+  | { kind: 'seq'; range: SessionQueryRange }
+  | { kind: 'time'; range: SessionQueryRange }
+  | { kind: 'type'; values: readonly SessionEventType[] }
+  | { kind: 'surface'; values: readonly SessionEventSurface[] }
+```
+
+## Search requests and pages
+
+Both scopes use the same opaque-cursor page envelope. Session hits carry exactly one best event; event hits add only a plain-text snippet to the lightweight record.
+
+```ts type-equiv
+export interface SessionQueryExecContext {
+  readonly signal?: AbortSignal
+}
+```
+
+```ts type-equiv
+export type SessionSearchProviderStatus =
+  | { readonly available: true }
+  | { readonly available: false; readonly reason: 'misconfigured' | 'unavailable' }
+```
+
+```ts type-equiv
+export interface SessionSearchPageRequest {
+  limit?: number
+  cursor?: string
+}
+```
+
+```ts type-equiv
+export interface SessionSearchRequest extends SessionSearchPageRequest {
+  query: string
+  sessionFilters?: readonly SessionResultFilter[]
+  eventFilters?: readonly SessionEventResultFilter[]
+}
+```
+
+```ts type-equiv
+export interface SessionEventSearchRequest extends SessionSearchPageRequest {
+  sessionId: SessionId
+  query: string
+  filters?: readonly SessionEventResultFilter[]
+}
+```
+
+```ts type-equiv
+export interface SessionEventSearchHit extends SessionEventRecord {
+  snippet: string
+}
+```
+
+```ts type-equiv
+export interface SessionSearchHit extends SessionRecord {
+  bestMatch: SessionEventSearchHit
+}
+```
+
+```ts type-equiv
+export interface SessionSearchPage<T> {
+  providerId: string
+  items: readonly T[]
+  nextCursor?: string
+}
+```
+
+## Event reads and traces
+
+An event read returns the full target plus a bounded raw-log window. Trace records retain lightweight seq links so callers choose which related event bodies to read.
+
+```ts type-equiv
+export interface SessionEventReadRequest {
+  sessionId: SessionId
+  seq: number
+  before?: number
+  after?: number
+}
+```
+
+```ts type-equiv
+export interface SessionEventWindow {
+  session: SessionRecord
+  target: SessionEvent
+  events: SessionEvent[]
+  startSeq: number
+  endSeq: number
+}
+```
+
+```ts type-equiv
+export interface SessionLineageNode {
+  session: SessionRecord
+  children: SessionLineageNode[]
+}
+```
+
+```ts type-equiv
+export interface SessionLineageTrace {
+  target: SessionRecord
+  parents: SessionRecord[]
+  root?: SessionRecord
+  unresolvedParentId?: SessionId
+  children: SessionLineageNode[]
+}
+```
+
+```ts type-equiv
+export interface SessionEventTrace {
+  target: SessionEventRecord
+  shadowedBy?: number
+  replacementChain: number[]
+  shadows: number[]
+  references: number[]
+  referencedBy: number[]
+}
+```
+
+## Extraction and provider synchronization
+
+Custom extractors are keyed by declaration-merged event or content discriminants and carry stable cache-invalidation versions. Providers receive complete event documents grouped into independently replaceable persisted and live snapshots.
+
+```ts type-equiv
+export interface SessionEventTextExtractor<K extends SessionEventType = SessionEventType> {
+  version: string
+  extract(event: SessionEvent<K>): readonly string[]
+}
+```
+
+```ts type-equiv
+export interface SessionContentTextExtractor<K extends ContentBlockType = ContentBlockType> {
+  version: string
+  extract(block: ContentBlockMap[K]): readonly string[]
+}
+```
+
+```ts type-equiv
+export interface SessionIndexDocument extends SessionEventRecord {
+  text: string
+}
+```
+
+```ts type-equiv
+export interface SessionIndexSnapshot {
+  session: SessionRecord
+  fingerprint: string
+  documents: readonly SessionIndexDocument[]
+}
+```
+
+```ts type-equiv
+export interface SessionPersistedIndexEntry {
+  sessionId: SessionId
+  fingerprint: string
+}
+```
+
+```ts type-equiv
+export interface SessionSearchProvider {
+  readonly id: string
+  status(): SessionSearchProviderStatus
+  persistedInventory(): Promise<readonly SessionPersistedIndexEntry[]>
+  setPersistedActive(active: boolean): Promise<void>
+  replacePersisted(snapshot: SessionIndexSnapshot): Promise<void>
+  removePersisted(sessionId: SessionId): Promise<void>
+  replaceLive(snapshot: SessionIndexSnapshot): Promise<void>
+  removeLive(sessionId: SessionId): Promise<void>
+  searchSessions(request: SessionSearchRequest, exec?: SessionQueryExecContext): Promise<SessionSearchPage<SessionSearchHit>>
+  searchEvents(request: SessionEventSearchRequest, exec?: SessionQueryExecContext): Promise<SessionSearchPage<SessionEventSearchHit>>
+}
+```

+ 20 - 0
docs/core-data-structures/session.md

@@ -196,6 +196,26 @@ export interface SurfaceNode {
 }
 ```
 
+### `SurfaceFoldReplacement` and `SurfaceFoldResult` — a complete surface replay
+
+`foldSurface(events)` returns detached current nodes together with the actual node seqs shadowed by each declared replacement range. `SurfaceManager` uses the same transition functions for its incremental cache.
+
+```ts type-equiv
+export interface SurfaceFoldReplacement {
+  seq: number
+  start: number
+  end: number
+  shadowedSeqs: number[]
+}
+```
+
+```ts type-equiv
+export interface SurfaceFoldResult {
+  nodes: SurfaceNode[]
+  replacements: SurfaceFoldReplacement[]
+}
+```
+
 ## Derived history: `deriveMessages()` and `deriveEventMessage()`
 
 `Session.deriveMessages()` projects the event log into the `Message[]` the model sees — cached (each surface node projected once, when first seen; a surface rewrite rebuilds) and frozen (a fresh array per call over shared, deep-frozen messages, so mutating logged history through a projection is unrepresentable). `deriveEventMessage(event)` is the per-node pure function the fold applies — public so external reconstructors and the dev invariant project a log prefix with exactly the same rules and cannot disagree with the cache. The projection rules:

+ 5 - 1
packages/core/session/tests/session.spec.ts

@@ -374,9 +374,12 @@ describe('SessionStore', () => {
     expect(observations).toHaveLength(1)
   })
 
-  it('contains rejected session/removed listeners during teardown', async () => {
+  it('contains failing session/removed listeners without starving later observers', async () => {
     const ctx = new Context()
     await ctx.plugin(SessionStore)
+    const observed: SessionId[] = []
+    ctx.on('session/removed', () => { throw new Error('synchronous observer failed') })
+    ctx.on('session/removed', header => void observed.push(header.id))
     ctx.on('session/removed', () => Promise.reject(new Error('observer failed')))
     const session = ctx.sessions.prepare(SessionId('contained'))
     const detach = ctx.sessions.enter(session)
@@ -385,6 +388,7 @@ describe('SessionStore', () => {
     await Promise.resolve()
     await Promise.resolve()
     expect(ctx.sessions.get(session.id)).toBeUndefined()
+    expect(observed).toEqual([session.id])
   })
 
   it('rolls back the session (and onAppend) when a session/created listener throws (P1-1)', async () => {

+ 2 - 1
packages/session-persistence/session-persistence/tests/coordinator-contract.ts

@@ -128,11 +128,12 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
       const fix = await makeFixture()
       const { ctx, fiber } = await freshCtx(fix)
       const observed: Array<{ headerId: SessionId; change: SessionPersistedChange }> = []
+      ctx.on('session/persisted', () => { throw new Error('synchronous derived read model failed') })
       ctx.on('session/persisted', (header, change) => {
         observed.push({ headerId: header.id, change: structuredClone(change) })
         header.createdAt = -1
-        return Promise.reject(new Error('derived read model failed'))
       })
+      ctx.on('session/persisted', () => Promise.reject(new Error('asynchronous derived read model failed')))
       try {
         const m = meta('notifications', WORK)
         await ctx.sessionPersistence.create(m)

+ 1 - 1
packages/session-query/session-query/README.md

@@ -22,7 +22,7 @@ Session filters cover id, exact cwd, inclusive creation time, parent id/root, an
 
 ## Full-text providers
 
-`registerSearchProvider(provider)` is effect-scoped and ids are unique. Without `searchProvider`, exactly one locally available provider must be registered; explicit selection fails loudly when the named provider is missing or unavailable. Search pages default to 20 hits and reject limits above 100. Provider scores never cross the public API: event hits carry a plain snippet, while each session hit carries exactly one best matching event.
+`registerSearchProvider(provider)` is effect-scoped and ids are unique. Without `searchProvider`, exactly one locally available provider must be registered; explicit selection fails loudly when the named provider is missing or unavailable. Search pages default to 20 hits and reject limits above 100; a provider returning more hits than the normalized request limit fails with a typed provider error rather than silently dropping cursor-addressable results. Provider scores never cross the public API: event hits carry a plain snippet, while each session hit carries exactly one best matching event.
 
 The service feeds providers two independent layers: a durable persisted base (`persistedInventory`, `replacePersisted`, `removePersisted`, `setPersistedActive`) and an ephemeral live override (`replaceLive`, `removeLive`). A search waits for the relevant source state observed before its call: the whole corpus for session search, only the target for a live event search. Failed derived updates do not fail session writes; affected searches receive `SESSION_QUERY_INDEX_FAILED`, and a later search retries the dirty state. `AbortSignal` lets a caller stop waiting and is also passed to provider search.
 

+ 26 - 2
packages/session-query/session-query/src/corpus.ts

@@ -12,10 +12,18 @@ interface PersistenceBinding {
   token: symbol
   service: SessionPersistence
   headers: Map<SessionId, SessionHeader>
+  /** Notifications retained until a list that began after them completes. */
+  observations: Map<SessionId, PersistedObservation>
+  observationGeneration: number
   error?: unknown
   refreshing: Promise<void> | undefined
 }
 
+interface PersistedObservation {
+  generation: number
+  header: SessionHeader
+}
+
 /** Active persistence view used by provider reconciliation. */
 export interface PersistenceView {
   /** Canonical headers in deterministic creation order. */
@@ -132,6 +140,8 @@ export class SessionCorpus {
       token: Symbol('session-query-persistence'),
       service,
       headers: new Map(),
+      observations: new Map(),
+      observationGeneration: 0,
       refreshing: undefined,
     }
     this._persistence = binding
@@ -140,7 +150,10 @@ export class SessionCorpus {
     ctx.on('session/persisted', (header) => {
       /* v8 ignore next -- a stale notification can race optional-service disposal */
       if (this._persistence?.token !== binding.token) return
-      binding.headers.set(header.id, structuredClone(header))
+      const snapshot = structuredClone(header)
+      const observation = { generation: ++binding.observationGeneration, header: snapshot }
+      binding.headers.set(header.id, snapshot)
+      binding.observations.set(header.id, observation)
       this._onPersistenceChange(true)
     })
     ctx.effect(() => () => { this._detachPersistence(binding) }, 'sessionQuery.persistenceBinding')
@@ -155,10 +168,21 @@ export class SessionCorpus {
 
   private _refreshPersistence(binding: PersistenceBinding): Promise<void> {
     if (binding.refreshing !== undefined) return binding.refreshing
+    const startGeneration = binding.observationGeneration
     const refresh = binding.service.list().then((headers) => {
       /* v8 ignore next -- a list completion can race optional-service disposal */
       if (this._persistence?.token !== binding.token) return
-      binding.headers = new Map(headers.map(header => [header.id, structuredClone(header)]))
+      const nextHeaders = new Map(headers.map(header => [header.id, structuredClone(header)]))
+      for (const [id, observation] of binding.observations) {
+        // A notification newer than this list's snapshot is the authoritative
+        // read-your-writes layer; older ones must already be present in list().
+        if (observation.generation > startGeneration) {
+          nextHeaders.set(id, structuredClone(observation.header))
+        } else {
+          binding.observations.delete(id)
+        }
+      }
+      binding.headers = nextHeaders
       binding.error = undefined
       this._onPersistenceChange(true)
     }).catch((error: unknown) => {

+ 4 - 1
packages/session-query/session-query/src/provider.ts

@@ -292,7 +292,10 @@ export class SessionProviderCoordinator {
     if (page.providerId !== state.provider.id) {
       throw new SessionQueryError(`session-query provider "${state.provider.id}" returned providerId "${page.providerId}"`, 'SESSION_QUERY_PROVIDER_ERROR')
     }
-    return page.items.length <= limit ? page : { ...page, items: page.items.slice(0, limit) }
+    if (page.items.length > limit) {
+      throw new SessionQueryError(`session-query provider "${state.provider.id}" returned ${page.items.length} items for limit ${limit}`, 'SESSION_QUERY_PROVIDER_ERROR')
+    }
+    return page
   }
 }
 

+ 1 - 1
packages/session-query/session-query/src/types.ts

@@ -118,7 +118,7 @@ export interface SessionSearchHit extends SessionRecord {
 export interface SessionSearchPage<T> {
   /** Stable id of the provider that produced this page. */
   providerId: string
-  /** Ranked hits in deterministic provider order. */
+  /** Ranked hits in deterministic provider order, no longer than the requested limit. */
   items: readonly T[]
   /** Opaque next-page cursor, absent when the result is exhausted. */
   nextCursor?: string

+ 52 - 10
packages/session-query/session-query/tests/session-query.spec.ts

@@ -52,11 +52,15 @@ class TestPersistence extends SessionPersistence {
   static entries = new Map<SessionIdType, { meta: SessionHeader; events: SessionEvent[] }>()
   static listFailure: unknown
   static loadFailure: unknown
+  static listBarrier: Promise<void> | undefined
+  static onList: (() => void) | undefined
 
   static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void {
     this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)]))
     this.listFailure = undefined
     this.loadFailure = undefined
+    this.listBarrier = undefined
+    this.onList = undefined
   }
 
   create(meta: SessionHeader): Promise<void> {
@@ -80,7 +84,9 @@ class TestPersistence extends SessionPersistence {
 
   list(): Promise<SessionHeader[]> {
     if (TestPersistence.listFailure !== undefined) return Promise.reject(asError(TestPersistence.listFailure))
-    return Promise.resolve([...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta)))
+    const snapshot = [...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta))
+    TestPersistence.onList?.()
+    return (TestPersistence.listBarrier ?? Promise.resolve()).then(() => snapshot)
   }
 }
 
@@ -182,6 +188,12 @@ function asError(value: unknown): Error {
   return value instanceof Error ? value : new Error(String(value))
 }
 
+function deferred(): { promise: Promise<void>; resolve: () => void } {
+  let resolve!: () => void
+  const promise = new Promise<void>((done) => { resolve = done })
+  return { promise, resolve }
+}
+
 describe('pure result filters', () => {
   it('chains session filters as AND while values within one filter are OR', () => {
     const root: SessionRecord = { header: header('root', 1, { cwd: '/a' }), live: true, persisted: false }
@@ -379,8 +391,8 @@ describe('provider selection and synchronization', () => {
       { ...record, bestMatch }, { ...record, bestMatch }, { ...record, bestMatch },
     ], nextCursor: 'next' }
 
-    const page = await ctx.sessionQuery.searchSessions({ query: ' hello ', sessionFilters: [{ kind: 'availability', values: ['live'] }] })
-    expect(page.items).toHaveLength(2)
+    await expect(ctx.sessionQuery.searchSessions({ query: ' hello ', sessionFilters: [{ kind: 'availability', values: ['live'] }] }))
+      .rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_ERROR'))
     expect(provider.sessionRequests[0]).toMatchObject({ query: 'hello', limit: 2 })
     expect(provider.live.get(session.id)?.documents[0]?.text).toBe('hello')
     await expect(ctx.sessionQuery.searchSessions({ query: ' ' })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_QUERY'))
@@ -534,6 +546,30 @@ describe('provider selection and synchronization', () => {
     expect(provider.persisted.has(persisted.id)).toBe(true)
   })
 
+  it('preserves persisted observations that race an older inventory listing', async () => {
+    TestPersistence.reset()
+    const listStarted = deferred()
+    const releaseList = deferred()
+    TestPersistence.onList = listStarted.resolve
+    TestPersistence.listBarrier = releaseList.promise
+    const ctx = await liveContext()
+    const provider = new FakeProvider()
+    ctx.sessionQuery.registerSearchProvider(provider)
+    await ctx.plugin(TestPersistence)
+    await listStarted.promise
+
+    const announced = header('racing-announcement', 3)
+    TestPersistence.entries.set(announced.id, { meta: announced, events: eventLog('after durable notification') })
+    await ctx.parallel('session/persisted', announced, { kind: 'append', fromSeq: 0, toSeq: 0 })
+    const search = ctx.sessionQuery.searchSessions({ query: 'notification' })
+    releaseList.resolve()
+
+    await expect(search).resolves.toMatchObject({ providerId: provider.id })
+    expect(provider.persisted.get(announced.id)?.documents[0]?.text).toBe('after durable notification')
+    TestPersistence.listBarrier = undefined
+    TestPersistence.onList = undefined
+  })
+
   it('synchronizes only a live target for event search and retries dirty failures', async () => {
     const ctx = await liveContext()
     const session = ctx.sessions.create(SessionId('target'))
@@ -646,11 +682,9 @@ describe('semantic text extractors', () => {
     session.append('user/message', { content: [{ type: 'test/text', value: 'block note' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
     const provider = new FakeProvider()
     ctx.sessionQuery.registerSearchProvider(provider)
-    let disposeEvent!: () => void
-    let disposeContent!: () => void
     const extractorFiber = await ctx.plugin(Object.assign((inner: Context) => {
-      disposeEvent = inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v1', extract: event => [event.data.note] })
-      disposeContent = inner.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v1', extract: block => [block.value] })
+      inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v1', extract: event => [event.data.note] })
+      inner.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v1', extract: block => [block.value] })
     }, { inject: ['sessionQuery'] }))
 
     await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })
@@ -663,13 +697,21 @@ describe('semantic text extractors', () => {
     expect(() => ctx.sessionQuery.registerContentTextExtractor('test/text', { version: ' ', extract: () => [] }))
       .toThrow(expectCode('SESSION_QUERY_INVALID_EXTRACTOR'))
 
-    disposeEvent()
-    disposeContent()
+    await extractorFiber.dispose()
     await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })
     const second = provider.live.get(session.id)
     expect(second?.documents).toEqual([])
     expect(second?.fingerprint).not.toBe(first?.fingerprint)
-    await extractorFiber.dispose()
+
+    const replacementFiber = await ctx.plugin(Object.assign((inner: Context) => {
+      inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v2', extract: event => [`replacement ${event.data.note}`] })
+      inner.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v2', extract: block => [`replacement ${block.value}`] })
+    }, { inject: ['sessionQuery'] }))
+    await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })
+    const third = provider.live.get(session.id)
+    expect(third?.documents.map(document => document.text)).toEqual(['replacement event note', 'replacement block note'])
+    expect(third?.fingerprint).not.toBe(second?.fingerprint)
+    await replacementFiber.dispose()
   })
 })
 

+ 29 - 0
scripts/type-equiv.manifest.json

@@ -31,9 +31,38 @@
     { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceOp", "source": "packages/core/session/src/types.ts" },
     { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceIntent", "source": "packages/core/session/src/types.ts" },
     { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceNode", "source": "packages/core/session/src/surface.ts" },
+    { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldReplacement", "source": "packages/core/session/src/surface.ts" },
+    { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldResult", "source": "packages/core/session/src/surface.ts" },
 
     { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionHeader", "source": "packages/core/session/src/types.ts" },
     { "doc": "docs/core-data-structures/persistence.md", "symbol": "CreateSessionOptions", "source": "packages/core/session/src/types.ts" },
+    { "doc": "docs/core-data-structures/persistence.md", "symbol": "SessionPersistedChange", "source": "packages/session-persistence/session-persistence/src/index.ts" },
+
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSurface", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionRecord", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventRecord", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryRange", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionResultFilter", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventResultFilter", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionQueryExecContext", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchProviderStatus", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchPageRequest", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchRequest", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchRequest", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventSearchHit", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchHit", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchPage", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventReadRequest", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventWindow", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionLineageNode", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionLineageTrace", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventTrace", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionEventTextExtractor", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionContentTextExtractor", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionIndexDocument", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionIndexSnapshot", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionPersistedIndexEntry", "source": "packages/session-query/session-query/src/types.ts" },
+    { "doc": "docs/core-data-structures/session-query.md", "symbol": "SessionSearchProvider", "source": "packages/session-query/session-query/src/types.ts" },
 
     { "doc": "docs/core-data-structures/tools.md", "symbol": "ToolDefinition", "source": "packages/core/tools/src/index.ts" },
     { "doc": "docs/core-data-structures/tools.md", "symbol": "SchemaProp", "source": "packages/core/tools/src/schema.ts" },

+ 1 - 0
vendor/README.md

@@ -35,6 +35,7 @@ Keep this log exhaustive — every divergence from upstream must be listed.
 3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references.
 4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`.
 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface.
+6. **`cordis/src/events.ts`**: `parallel()` captures each listener invocation in its own promise before awaiting the group. A synchronous throw therefore rejects the dispatch without preventing later parallel listeners from starting.
 
 ## Sync procedure
 

+ 9 - 1
vendor/cordis/src/events.ts

@@ -106,7 +106,15 @@ export class EventsService {
 
   /** Run listeners concurrently and wait for all of them. */
   async parallel(...args: any[]) {
-    await Promise.all(this.dispatch('emit', args).map(cb => cb(...args)))
+    const callbacks = this.dispatch('emit', args)
+    const results = callbacks.map((cb) => {
+      try {
+        return Promise.resolve(cb(...args))
+      } catch (error: unknown) {
+        return Promise.reject(error)
+      }
+    })
+    await Promise.all(results)
   }
 
   /** Run listeners synchronously without waiting for returned promises. */