diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml index 5ec76b0d61..cc975b746d 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-24-model-facing-session-query-tools.md: 2f057292acac2c565e6b9dac61ed1e013b998550 -2026-07-24-model-facing-session-query-tools.zh.md: 6ccf60f39afc4021899df5c422ae455259c2ecc3 +2026-07-24-model-facing-session-query-tools.md: 75aa8eef1b27ca64b49710ba055b33d091f514ad +2026-07-24-model-facing-session-query-tools.zh.md: a59f88b38ac88b9a88cb55e799847c283145205a diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md index 2f057292ac..75aa8eef1b 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.md @@ -26,7 +26,7 @@ The search tools expose prior work rather than the operation that is performing Neither search tool exposes a cursor, offset, page size, or model-controlled result limit. One execution follows provider cursors while the observed generation remains valid and collects up to the configured `maxSearchResults`, which defaults to 100. A capped result tells the model to narrow its query or filters; a generation change reports that the whole search must be retried. Search execution carries a configurable `searchTimeoutMs`, defaulting to 30 seconds, through the tool deadline and the service abort signal. Because internal pages share generation-bound cursors, both search tools are exclusive in the agent-loop scheduler; the exact trace and read tools opt into parallel sibling execution because their observations tolerate intervening commits. -Trace and read tools likewise expose no lineage or character pagination. Canonical results are plain text and remain complete within the service's existing event-window and search-count resource bounds. The generic `tools/post-execute` spill policy owns inline byte retention: when a configured deployment receives oversized text, it replaces that text with a bounded preview plus an opaque locator and retrieval hint while preserving the complete result in its spill store. The session-query consumer neither imports `ctx.spillStore` nor implements a second truncation format. +Trace and read tools likewise expose no lineage or character pagination. Canonical results are plain text and remain complete within the service's existing event-window and search-count resource bounds. Each exact executor passes its unchanged tool-execution signal through target authorization and the service trace or read. Within service resolution, known-live event traces, event reads, and title reads remain persistence-free while honoring pre-abort. Session lineage tracing passes the signal to whole-corpus persistence listing; persisted event tracing and reading pass it to target listing and inspection. Each started backend call is awaited for cleanup before the exact abort reason is preserved, even when that backend ignored cancellation. The generic `tools/post-execute` spill policy owns inline byte retention: when a configured deployment receives oversized text, it replaces that text with a bounded preview plus an opaque locator and retrieval hint while preserving the complete result in its spill store. The session-query consumer neither imports `ctx.spillStore` nor implements a second truncation format. Session-level results include the latest folded title when available. Each tool execution batches its unique title ids through one live-preferred corpus observation with at most the service's configured `persistedInspectConcurrency` workers, which defaults to four, and passes the exact tool-execution signal through persisted listing and inspection. Live sources fold directly; each persisted worker folds its completed source to a detached header/title observation and releases the full log before dequeuing another id, so the batch retains only small projected values. For the search tools, the execution signal carries the configured search deadline. Cancellation starts no queued title inspections and rejects the complete tool execution after already-started inspections settle; a missing, malformed, or operationally failed title remains isolated to that id, preserves the base result, renders an unavailable marker, and logs the underlying error, while an authorization mismatch fails closed. Search results include the strongest matching event and provider excerpt, traces include complete authorized relationships, and event reads keep neighbor presentation readable while reserving exact JSON for the requested target. @@ -44,7 +44,7 @@ The shipped ACP, TUI, and Web compositions all mount the consumer beside `ctx.se ## Verification -Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, changed-observation rejection, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, exclusive search and parallel exact-read classification, count caps, cancellation, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Loader and assembled-host coverage proves that ACP, TUI, and Web register the tools with timeout and spill support, while keyless assembled ACP snapshots pin the prompt guidance and schemas plus path-independent exact event-read spill and retention behavior. +Package tests pin argument validation, filter translation, timestamp normalization, exact-workspace authorization, changed-observation rejection, missing-identity behavior, hidden-boundary pruning, current-step exclusion, internal provider paging, exclusive search and parallel exact-read classification, count caps, exact-signal forwarding, abort-reason preservation, persistence cleanup quiescence, one-scan bounded batch title enrichment, projection-before-dequeue ordering, queued-work suppression, started-worker quiescence, per-header validation, title fallbacks, representative search/trace/read rendering, generic presentation, and disposable registration. Integration coverage uses the real SQLite FTS provider over live and persisted sessions. Loader and assembled-host coverage proves that ACP, TUI, and Web register the tools with timeout and spill support, while keyless assembled ACP snapshots pin the prompt guidance and schemas plus path-independent exact event-read spill and retention behavior. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md index 6ccf60f39a..a59f88b38a 100644 --- a/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-model-facing-session-query-tools.zh.md @@ -26,7 +26,7 @@ Status: implemented 两个搜索工具都不向模型公开游标、偏移量、页大小或模型可控的结果限制。一次执行会在观察到的代保持有效时持续跟随提供方游标,并收集不超过配置项 `maxSearchResults` 的结果,其默认值为 100。达到上限的结果会要求模型缩小查询或过滤范围;代发生变化时会报告必须重试完整搜索。搜索执行通过工具截止时间与服务中止信号传递可配置的 `searchTimeoutMs`,默认值为 30 秒。由于内部页面共享与代绑定的游标,两个搜索工具在 agent loop 调度器中都以独占方式执行;精确追踪与读取工具则允许和兄弟工具并行执行,因为其观测可以容忍期间发生的提交。 -追踪与读取工具同样不公开谱系分页或字符分页。规范结果采用纯文本,并在服务已有的事件窗口与搜索数量资源边界内保持完整。通用的 `tools/post-execute` spill 策略负责行内字节保留:当已配置的部署收到过大的文本时,该策略会用有界预览、不可透明推导的定位符与读取提示替换文本,同时在 spill 存储中保留完整结果。会话查询消费者既不导入 `ctx.spillStore`,也不实现第二套截断格式。 +追踪与读取工具同样不公开谱系分页或字符分页。规范结果采用纯文本,并在服务已有的事件窗口与搜索数量资源边界内保持完整。每个精确执行器都会将未经替换的工具执行信号传递给目标授权与服务追踪或读取。在服务解析过程中,已知实时事件追踪、事件读取与标题读取在遵循预中止的同时仍不访问持久化。会话谱系追踪会将该信号传递给全语料持久化列表;持久化事件追踪与读取则将其传递给目标列表和检查。每个已启动的后端调用都会等待清理完成后再保留准确的中止原因,即使该后端忽略了取消也不例外。通用的 `tools/post-execute` spill 策略负责行内字节保留:当已配置的部署收到过大的文本时,该策略会用有界预览、不可透明推导的定位符与读取提示替换文本,同时在 spill 存储中保留完整结果。会话查询消费者既不导入 `ctx.spillStore`,也不实现第二套截断格式。 会话级结果在可用时包含最新折叠标题。每次工具执行都会通过一次优先使用实时数据的语料观测批量读取唯一标题 id,最多使用服务通过 `persistedInspectConcurrency` 配置的持久化检查 worker,其默认值为 4,并将准确的工具执行信号传递给持久化列表与检查操作。实时来源会直接折叠;每个持久化 worker 都会把已完成的来源折叠为分离的会话头/标题观测,并在取出下一个 id 前释放完整日志,因此批次只保留小型投影值。对于搜索工具,该执行信号携带已配置的搜索截止时间。取消不会启动排队中的标题检查,并会在已经启动的检查全部完成后拒绝完整的工具执行;标题缺失、格式错误或发生操作性失败时,错误只影响对应 id,同时保留基础结果、渲染不可用标记并记录底层错误,而授权不匹配则按失败关闭处理。搜索结果包含最强匹配事件与提供方摘录,追踪包含完整的已授权关系,事件读取保持邻近事件表现易读,同时只为被请求的目标保留精确 JSON。 @@ -44,7 +44,7 @@ Status: implemented ## 验证 -包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、变更观测拒绝、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、取消、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。Loader 与组装宿主覆盖证明 ACP、TUI 和 Web 会注册带超时及 spill 支持的工具;无密钥组装 ACP 快照则固定提示词指导与 schema,以及与路径无关的精确事件读取 spill 与保留行为。 +包级测试固定参数校验、过滤条件转换、时间戳规范化、精确工作区授权、变更观测拒绝、身份缺失行为、隐藏边界裁剪、当前步骤排除、内部提供方翻页、搜索独占与精确读取并行分类、数量上限、精确信号传递、中止原因保留、持久化清理静止、单次扫描且并发有界的批量标题扩充、先投影再取出下一个任务的顺序、抑制排队工作、等待已启动 worker 静止、逐会话头校验、标题回退、代表性搜索/追踪/读取渲染、通用表现与可释放注册。集成覆盖使用真实 SQLite FTS 提供方查询实时与持久化会话。Loader 与组装宿主覆盖证明 ACP、TUI 和 Web 会注册带超时及 spill 支持的工具;无密钥组装 ACP 快照则固定提示词指导与 schema,以及与路径无关的精确事件读取 spill 与保留行为。 ## 后果 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index b15fc29ee9..4348fb4d39 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1068,25 +1068,28 @@ async readSurface(sessionId: SessionId): Promise /** * Trace known ancestry and descendants from one corpus observation. * @param sessionId - logical session id to trace. + * @param signal - optional cancellation for persistence listing. * @returns a complete lineage or an explicit unresolved parent boundary. * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles. */ -async traceSession(sessionId: SessionId): Promise +async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise /** * Trace one event's direct positional and provenance relationships. * @param request - target session id and event seq. + * @param signal - optional cancellation for persisted source resolution. * @returns source header, direct links, and the target's positional replacement chain. * @throws when source resolution fails, the target is absent, or surface/provenance validation fails. */ -async traceEvent(request: SessionEventTraceRequest): Promise +async traceEvent(request: SessionEventTraceRequest, signal?: AbortSignal): Promise /** * Read one full event plus a bounded raw-log context window. * @param request - target session/seq and context sizes. + * @param signal - optional cancellation for persisted source resolution. * @returns cloned target and neighboring events. */ -async readEvent(request: SessionEventReadRequest): Promise +async readEvent(request: SessionEventReadRequest, signal?: AbortSignal): Promise ``` Types: [SessionEventReadRequest](../core-data-structures/session-query.md) · [SessionEventRecord](../core-data-structures/session-query.md) · [SessionEventResultFilter](../core-data-structures/session-query.md) · [SessionEventSearchDocument](../core-data-structures/session-query.md) · [SessionEventSearchPage](../core-data-structures/session-query.md) · [SessionEventSearchRequest](../core-data-structures/session-query.md) · [SessionEventTraceObservation](../core-data-structures/session-query.md) · [SessionEventTraceRequest](../core-data-structures/session-query.md) · [SessionEventWindow](../core-data-structures/session-query.md) · [SessionId](../core-data-structures/core.md) · [SessionLineageTrace](../core-data-structures/session-query.md) · [SessionLogSnapshot](../core-data-structures/session-query.md) · [SessionRecord](../core-data-structures/session-query.md) · [SessionResultFilter](../core-data-structures/session-query.md) · [SessionSearchExecContext](../core-data-structures/session-query.md) · [SessionSearchHit](../core-data-structures/session-query.md) · [SessionSearchPage](../core-data-structures/session-query.md) · [SessionSearchRequest](../core-data-structures/session-query.md) · [SessionSurfaceSnapshot](../core-data-structures/session-query.md) · [SessionTitleObservation](../core-data-structures/session-query.md) · [SessionTitleObservationResult](../core-data-structures/session-query.md) · [SessionTitleSnapshot](../core-data-structures/session-title.md) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index c9e827ff4b..4c0dce90cf 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -531,16 +531,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ jsDoc: '/**\n * Read one session\'s complete current model surface from one corpus observation.\n * @param sessionId - live-preferred session id to read.\n * @returns cloned header, current surface, and raw-log capture boundary.\n * @throws when source resolution fails or the session surface is invalid.\n */', }, { - signature: 'async traceSession(sessionId: SessionId): Promise', - jsDoc: '/**\n * Trace known ancestry and descendants from one corpus observation.\n * @param sessionId - logical session id to trace.\n * @returns a complete lineage or an explicit unresolved parent boundary.\n * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.\n */', + signature: 'async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Trace known ancestry and descendants from one corpus observation.\n * @param sessionId - logical session id to trace.\n * @param signal - optional cancellation for persistence listing.\n * @returns a complete lineage or an explicit unresolved parent boundary.\n * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.\n */', }, { - signature: 'async traceEvent(request: SessionEventTraceRequest): Promise', - jsDoc: '/**\n * Trace one event\'s direct positional and provenance relationships.\n * @param request - target session id and event seq.\n * @returns source header, direct links, and the target\'s positional replacement chain.\n * @throws when source resolution fails, the target is absent, or surface/provenance validation fails.\n */', + signature: 'async traceEvent(request: SessionEventTraceRequest, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Trace one event\'s direct positional and provenance relationships.\n * @param request - target session id and event seq.\n * @param signal - optional cancellation for persisted source resolution.\n * @returns source header, direct links, and the target\'s positional replacement chain.\n * @throws when source resolution fails, the target is absent, or surface/provenance validation fails.\n */', }, { - signature: 'async readEvent(request: SessionEventReadRequest): Promise', - jsDoc: '/**\n * Read one full event plus a bounded raw-log context window.\n * @param request - target session/seq and context sizes.\n * @returns cloned target and neighboring events.\n */', + signature: 'async readEvent(request: SessionEventReadRequest, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Read one full event plus a bounded raw-log context window.\n * @param request - target session/seq and context sizes.\n * @param signal - optional cancellation for persisted source resolution.\n * @returns cloned target and neighboring events.\n */', }, ], }, diff --git a/packages/session-query/session-query/README.md b/packages/session-query/session-query/README.md index 82d32f5119..27191c3e5f 100644 --- a/packages/session-query/session-query/README.md +++ b/packages/session-query/session-query/README.md @@ -11,11 +11,11 @@ - `readTitleSnapshots(sessionIds, signal?)` resolves unique ids from one live-preferred corpus observation, passes cancellation through persisted listing and inspection, and returns ordered per-session settlements so one missing or malformed title source does not discard its peers. Each live source is folded directly, and each persisted worker folds to a detached header/title result and releases the full log before dequeuing another id. Cancellation rejects the whole batch. `readTitleSnapshot(sessionId, signal?)` is the one-observation view; `readTitle(sessionId, signal?)` returns only its optional folded `session/title`. - `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold. - `readSurface(sessionId)` returns one cloned header, raw-log capture boundary, and the complete folded current surface in model-history order. A live session wins over persistence; compaction is observed before or after its replacement append, never as a synthetic mixture. -- `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`. -- `traceSession(sessionId)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`. -- `traceEvent(request)` loads the logical log once and returns its cloned source header with direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive. +- `readEvent(request, signal?)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`. +- `traceSession(sessionId, signal?)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`. +- `traceEvent(request, signal?)` loads the logical log once and returns its cloned source header with direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive. -Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title, event read, or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. A batch title observation performs one metadata listing, inspects its unique persisted ids with at most `persistedInspectConcurrency` workers, and preserves each title's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles. +Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title read, event trace, or event read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. Lineage-trace cancellation is passed to persisted listing; event-trace and event-read cancellation is passed to persisted listing and inspection. Each waits for the started backend call to settle, then rejects with the signal's exact reason even when the backend ignored that signal. A pre-aborted known-live title read, event trace, or event read rejects before folding or snapshotting without consulting persistence. A batch title observation performs one metadata listing, inspects its unique persisted ids with at most `persistedInspectConcurrency` workers, and preserves each title's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles. ## Filtering and extraction diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index 5ed04a4808..649a80965a 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -82,23 +82,37 @@ export class SessionCorpus { * A known live target never consults persistence, so an optional backend's * failure cannot make current in-memory history unreadable. * @param sessionId - session to resolve. + * @param signal - optional cancellation for persisted source resolution. * @returns detached live-preferred header and events. */ - async load(sessionId: SessionId): Promise { + async load(sessionId: SessionId, signal?: AbortSignal): Promise { + signal?.throwIfAborted() const live = this._ctx.sessions.get(sessionId) - if (live !== undefined) return snapshotLive(live) + if (live !== undefined) { + const snapshot = snapshotLive(live) + signal?.throwIfAborted() + return snapshot + } const persistence = this._persistence if (persistence === undefined) throw notFound(sessionId) - const listed = (await listPersisted(persistence)).find(header => header.id === sessionId) + const listed = (await listPersisted(persistence, signal)).find(header => header.id === sessionId) + signal?.throwIfAborted() if (listed === undefined) throw notFound(sessionId) - const loaded = await inspectPersisted(persistence, sessionId) + const loaded = await inspectPersisted(persistence, sessionId, signal) + signal?.throwIfAborted() const attached = this._ctx.sessions.get(sessionId) - if (attached !== undefined) return snapshotLive(attached) + if (attached !== undefined) { + const snapshot = snapshotLive(attached) + signal?.throwIfAborted() + return snapshot + } assertSessionHeadersCompatible(loaded.meta, listed) - return { + const snapshot = { header: structuredClone(loaded.meta), events: loaded.events.map(event => structuredClone(event)), } + signal?.throwIfAborted() + return snapshot } /** diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index a16c8e9047..4dc982f8f3 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -272,22 +272,26 @@ export abstract class SessionQueryService extends Service { /** * Trace known ancestry and descendants from one corpus observation. * @param sessionId - logical session id to trace. + * @param signal - optional cancellation for persistence listing. * @returns a complete lineage or an explicit unresolved parent boundary. * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles. */ - async traceSession(sessionId: SessionId): Promise { - const records = await this._corpus.listSessions() + async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise { + const records = await this._corpus.listSessions(signal) + signal?.throwIfAborted() return tracing.traceSession(records, sessionId) } /** * Trace one event's direct positional and provenance relationships. * @param request - target session id and event seq. + * @param signal - optional cancellation for persisted source resolution. * @returns source header, direct links, and the target's positional replacement chain. * @throws when source resolution fails, the target is absent, or surface/provenance validation fails. */ - async traceEvent(request: SessionEventTraceRequest): Promise { - const loaded = await this._corpus.load(request.sessionId) + async traceEvent(request: SessionEventTraceRequest, signal?: AbortSignal): Promise { + const loaded = await this._corpus.load(request.sessionId, signal) + signal?.throwIfAborted() return { session: loaded.header, ...tracing.traceEvent(request.sessionId, loaded.events, request.seq), @@ -297,14 +301,15 @@ export abstract class SessionQueryService extends Service { /** * Read one full event plus a bounded raw-log context window. * @param request - target session/seq and context sizes. + * @param signal - optional cancellation for persisted source resolution. * @returns cloned target and neighboring events. */ - async readEvent(request: SessionEventReadRequest): Promise { + async readEvent(request: SessionEventReadRequest, signal?: AbortSignal): Promise { const before = this._readWindow('before', request.before) const after = this._readWindow('after', request.after) const sessionId = request.sessionId const seq = request.seq - return this._readEvent(sessionId, seq, before, after) + return this._readEvent(sessionId, seq, before, after, signal) } private async _readEvent( @@ -312,8 +317,10 @@ export abstract class SessionQueryService extends Service { seq: number, before: number, after: number, + signal?: AbortSignal, ): Promise { - const loaded = await this._corpus.load(sessionId) + const loaded = await this._corpus.load(sessionId, signal) + signal?.throwIfAborted() const target = loaded.events[seq] if (target === undefined || target.seq !== seq) { throw new SessionQueryError( diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index 4d093360ab..b830158be7 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -142,6 +142,34 @@ const cancellableSessionListings = [ }, ] as const +interface CancellableExactRead { + readonly name: 'traceSession' | 'traceEvent' | 'readEvent' + readonly inspects: boolean + readonly run: ( + ctx: Context, + sessionId: SessionIdType, + signal: AbortSignal, + ) => Promise +} + +const cancellableExactReads: readonly CancellableExactRead[] = [ + { + name: 'traceSession', + inspects: false, + run: (ctx, sessionId, signal) => ctx.sessionQuery.traceSession(sessionId, signal), + }, + { + name: 'traceEvent', + inspects: true, + run: (ctx, sessionId, signal) => ctx.sessionQuery.traceEvent({ sessionId, seq: 0 }, signal), + }, + { + name: 'readEvent', + inspects: true, + run: (ctx, sessionId, signal) => ctx.sessionQuery.readEvent({ sessionId, seq: 0 }, signal), + }, +] as const + describe.each(cancellableSessionListings)('$name cancellation', ({ run }) => { it('preserves an exact pre-abort reason without entering persistence', async () => { TestPersistence.reset() @@ -223,6 +251,167 @@ describe.each(cancellableSessionListings)('$name cancellation', ({ run }) => { }) }) +describe.each(cancellableExactReads)('$name cancellation', ({ inspects, run }) => { + it('preserves an exact pre-abort reason without entering persistence', async () => { + const persisted = header('pre-aborted-exact-read') + TestPersistence.reset([{ meta: persisted, events: eventLog() }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const controller = new AbortController() + const reason = new Error('exact read cancelled before start') + controller.abort(reason) + + await expect(run(ctx, persisted.id, controller.signal)).rejects.toBe(reason) + expect(TestPersistence.listCalls).toBe(0) + expect(TestPersistence.inspectCalls).toEqual([]) + }) + + it('forwards in-flight list cancellation and waits for cleanup before rejecting', async () => { + const persisted = header('cancelled-exact-list') + TestPersistence.reset([{ meta: persisted, events: eventLog() }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const controller = new AbortController() + const reason = new Error('exact read list cancelled in flight') + const started = Promise.withResolvers() + const abortObserved = Promise.withResolvers() + const cleanup = Promise.withResolvers() + let active = false + TestPersistence.listOverride = async (signal) => { + if (signal === undefined) throw new Error('expected exact-read listing signal') + active = true + const aborted = new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + started.resolve(undefined) + await aborted + abortObserved.resolve(undefined) + await cleanup.promise + active = false + signal.throwIfAborted() + return [] + } + + const pending = run(ctx, persisted.id, controller.signal) + let settled = false + void pending.then( + () => { settled = true }, + () => { settled = true }, + ) + await started.promise + controller.abort(reason) + await abortObserved.promise + + expect(settled).toBe(false) + expect(active).toBe(true) + expect(TestPersistence.listSignals).toEqual([controller.signal]) + expect(TestPersistence.inspectCalls).toEqual([]) + + cleanup.resolve(undefined) + await expect(pending).rejects.toBe(reason) + expect(active).toBe(false) + }) + + it('waits for an ignoring backend to return before preserving the abort reason', async () => { + const persisted = header('ignored-exact-signal') + const entry = { meta: persisted, events: eventLog() } + TestPersistence.reset([entry]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const controller = new AbortController() + const reason = new Error('exact read cancelled while backend ignored signal') + const started = Promise.withResolvers() + const release = Promise.withResolvers() + let active = false + if (inspects) { + TestPersistence.inspectOverride = async () => { + active = true + started.resolve(undefined) + await release.promise + active = false + return structuredClone(entry) + } + } else { + TestPersistence.listOverride = async () => { + active = true + started.resolve(undefined) + await release.promise + active = false + return [structuredClone(persisted)] + } + } + + const pending = run(ctx, persisted.id, controller.signal) + let settled = false + void pending.then( + () => { settled = true }, + () => { settled = true }, + ) + await started.promise + controller.abort(reason) + + expect(settled).toBe(false) + expect(active).toBe(true) + expect(TestPersistence.listSignals).toEqual([controller.signal]) + expect(TestPersistence.inspectSignals).toEqual(inspects ? [controller.signal] : []) + + release.resolve(undefined) + await expect(pending).rejects.toBe(reason) + expect(active).toBe(false) + }) +}) + +describe.each(cancellableExactReads.filter(read => read.inspects))( + '$name persisted inspection cancellation', + ({ run }) => { + it('forwards cancellation and waits for inspection cleanup before rejecting', async () => { + const persisted = header('cancelled-exact-inspect') + TestPersistence.reset([{ meta: persisted, events: eventLog() }]) + const ctx = await liveContext() + await ctx.plugin(TestPersistence) + const controller = new AbortController() + const reason = new Error('exact read inspection cancelled in flight') + const started = Promise.withResolvers() + const abortObserved = Promise.withResolvers() + const cleanup = Promise.withResolvers() + let active = false + TestPersistence.inspectOverride = async (_sessionId, signal) => { + if (signal === undefined) throw new Error('expected exact-read inspection signal') + active = true + const aborted = new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + started.resolve(undefined) + await aborted + abortObserved.resolve(undefined) + await cleanup.promise + active = false + signal.throwIfAborted() + throw new Error('unreachable after exact-read cancellation') + } + + const pending = run(ctx, persisted.id, controller.signal) + let settled = false + void pending.then( + () => { settled = true }, + () => { settled = true }, + ) + await started.promise + controller.abort(reason) + await abortObserved.promise + + expect(settled).toBe(false) + expect(active).toBe(true) + expect(TestPersistence.listSignals).toEqual([controller.signal]) + expect(TestPersistence.inspectSignals).toEqual([controller.signal]) + + cleanup.resolve(undefined) + await expect(pending).rejects.toBe(reason) + expect(active).toBe(false) + }) + }, +) + describe('session-query exact reads', () => { it('returns a detached replay-valid full log and rejects a corrupt persisted seed', async () => { const valid = header('valid-log', 2) @@ -862,9 +1051,15 @@ describe('session-query exact reads', () => { await ctx.plugin(TestPersistence) TestPersistence.listFailure = new Error('list unavailable') TestPersistence.inspectFailure = new Error('inspect unavailable') + const signal = new AbortController().signal await expect(ctx.sessionQuery.listEvents(live.id)).resolves.toHaveLength(2) - await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 1 })).resolves.toMatchObject({ target: { seq: 1 } }) + await expect(ctx.sessionQuery.traceEvent({ sessionId: live.id, seq: 1 }, signal)) + .resolves.toMatchObject({ session: { id: live.id }, target: { seq: 1 } }) + await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 1 }, signal)) + .resolves.toMatchObject({ target: { seq: 1 } }) + expect(TestPersistence.listSignals).toEqual([]) + expect(TestPersistence.inspectSignals).toEqual([]) await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) await expect(ctx.sessionQuery.listEvents(SessionId('durable'))).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED')) }) diff --git a/packages/session-query/tool-session-query/README.md b/packages/session-query/tool-session-query/README.md index f9e466f4a0..7b40527f62 100644 --- a/packages/session-query/tool-session-query/README.md +++ b/packages/session-query/tool-session-query/README.md @@ -9,7 +9,7 @@ Workspace-authorized model tools over `ctx.sessionQuery`. The package depends on | `maxSearchResults` | `100` | Maximum authorized non-self hits collected across internal provider pages | | `searchTimeoutMs` | `30000` | Cooperative deadline attached to both full-text search tools | -The caller comes exclusively from `ToolExecution.exec.agent`. Cross-session access requires exact equality between the target and caller session `cwd` values; a caller without `cwd` can inspect only itself. Search never exposes provider cursors, offsets, page sizes, or a model-controlled limit. Because one search consumes generation-bound provider cursors internally, both search tools execute exclusively with sibling tool calls; the three exact trace/read tools opt into parallel execution. Timestamps at the tool boundary require an explicit `Z` or numeric offset and become inclusive epoch-millisecond filters. +The caller comes exclusively from `ToolExecution.exec.agent`. Cross-session access requires exact equality between the target and caller session `cwd` values; a caller without `cwd` can inspect only itself. Search never exposes provider cursors, offsets, page sizes, or a model-controlled limit. Because one search consumes generation-bound provider cursors internally, both search tools execute exclusively with sibling tool calls; the three exact trace/read tools opt into parallel execution. Every exact executor passes its unchanged execution signal through authorization and the service trace/read, so cancellation waits for cooperative persistence cleanup and retains the signal's exact reason. Timestamps at the tool boundary require an explicit `Z` or numeric offset and become inclusive epoch-millisecond filters. `session_search` always omits the caller session. A current-session `session_event_search` stops immediately before the step that invoked it, so the active assistant output and logged tool call cannot match themselves. Direct targets are authorized before trace, event, or title reads. Lineage output replaces unauthorized ancestor and descendant boundaries with markers that contain no hidden session id. diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts index 186e2ca75c..0ffbeeb836 100644 --- a/packages/session-query/tool-session-query/src/index.ts +++ b/packages/session-query/tool-session-query/src/index.ts @@ -428,7 +428,7 @@ async function executeSessionTrace( await authorizeTarget(ctx, caller, sessionId, exec.signal) let trace: SessionLineageTrace try { - trace = await ctx.sessionQuery.traceSession(sessionId) + trace = await ctx.sessionQuery.traceSession(sessionId, exec.signal) } catch (error: unknown) { exec.signal.throwIfAborted() if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_INVALID_LINEAGE') { @@ -471,7 +471,7 @@ async function executeEventTrace( const caller = callerOf(exec) const sessionId = targetId(args, caller) await authorizeTarget(ctx, caller, sessionId, exec.signal) - const trace = await ctx.sessionQuery.traceEvent({ sessionId, seq: args.seq }) + const trace = await ctx.sessionQuery.traceEvent({ sessionId, seq: args.seq }, exec.signal) exec.signal.throwIfAborted() assertObservedTargetAuthorized(caller, sessionId, trace.session) const title = await readTitle(ctx, caller, sessionId, exec.signal) @@ -494,7 +494,7 @@ async function executeEventRead( seq: args.seq, ...args.before === undefined ? {} : { before: args.before }, ...args.after === undefined ? {} : { after: args.after }, - }) + }, exec.signal) exec.signal.throwIfAborted() assertObservedTargetAuthorized(caller, sessionId, window.session) const title = await readTitle(ctx, caller, sessionId, exec.signal) diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index f403b3f5ba..d52a4d48aa 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -661,6 +661,78 @@ describe('workspace authority and lineage redaction', () => { expect(text(result)).toBe(`Error: ${message}`) }) + it.each([ + 'session_trace', + 'session_event_trace', + 'session_event_read', + ] as const)('forwards the exact signal to %s and waits for service cleanup', async (toolName) => { + const mounted = await mount() + const target = createSession(mounted.ctx, `cancelled-${toolName}`, '/work') + target.append( + 'user/message', + { content: [{ type: 'text', text: 'pending exact read' }], source: { kind: 'user' } }, + { surfaceOp: 'append' }, + ) + const controller = new AbortController() + const cancellation = new SessionQueryError( + `${toolName} cancelled`, + 'SESSION_QUERY_ABORTED', + ) + const started = Promise.withResolvers() + const abortObserved = Promise.withResolvers() + const cleanup = Promise.withResolvers() + let observedSignal: AbortSignal | undefined + let active = false + const holdExactRead = async (signal?: AbortSignal): Promise => { + if (signal === undefined) throw new Error('expected exact tool execution signal') + observedSignal = signal + active = true + const aborted = new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + started.resolve(undefined) + await aborted + abortObserved.resolve(undefined) + await cleanup.promise + active = false + signal.throwIfAborted() + throw new Error('unreachable after exact tool cancellation') + } + if (toolName === 'session_trace') { + vi.spyOn(mounted.ctx.sessionQuery, 'traceSession') + .mockImplementation((_sessionId, signal) => holdExactRead(signal)) + } else if (toolName === 'session_event_trace') { + vi.spyOn(mounted.ctx.sessionQuery, 'traceEvent') + .mockImplementation((_request, signal) => holdExactRead(signal)) + } else { + vi.spyOn(mounted.ctx.sessionQuery, 'readEvent') + .mockImplementation((_request, signal) => holdExactRead(signal)) + } + const args = toolName === 'session_trace' + ? { session_id: target.id } + : { session_id: target.id, seq: 0 } + + const pending = mounted.call(toolName, args, { signal: controller.signal }) + let settled = false + void pending.then( + () => { settled = true }, + () => { settled = true }, + ) + await started.promise + controller.abort(cancellation) + await abortObserved.promise + + expect(settled).toBe(false) + expect(active).toBe(true) + expect(observedSignal).toBe(controller.signal) + + cleanup.resolve(undefined) + const result = await pending + expect(active).toBe(false) + expect(errorCode(result)).toBe('SESSION_QUERY_ABORTED') + expect(text(result)).toBe(`Error: ${toolName} cancelled`) + }) + it('preserves caller cancellation while a lineage trace is pending', async () => { const mounted = await mount() const target = createSession(mounted.ctx, 'cancelled-trace-target', '/work')