From 3c08ca36066da90664b6426513cc4c83518f6057 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 16:09:17 +0800 Subject: [PATCH] perf(tui): open the /resume selector from one batch projection The selector called readSession per listed session under an unbounded Promise.all: each call re-listed the whole persistence store (O(N^2) listings), decompressed and parsed the complete log, replay-validated every event, and deep-cloned it up to three times, only to derive one row's title, activity time, turn label, route, and goal phase. On a real 185-session / 87 MB store the selector took tens of seconds. Candidate rows now come from one projectSessions batch over borrowed logs; a rejected projection degrades to the same disabled unreadable row. Preflight still replay-validates the single chosen session through readSession, which is already live-preferred, so its redundant live shortcut is gone. --- ...resume-selector-batch-projection.i18n.yaml | 6 ++ ...-07-31-resume-selector-batch-projection.md | 27 ++++++ ...-31-resume-selector-batch-projection.zh.md | 27 ++++++ packages/ui/tui/src/chat/resume.ts | 89 +++++++++++-------- packages/ui/tui/src/components/dialogs.ts | 36 ++++---- packages/ui/tui/tests/tui.spec.ts | 56 ++++++++---- 6 files changed, 173 insertions(+), 68 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml new file mode 100644 index 0000000000..c0faa37e8d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md +2026-07-31-resume-selector-batch-projection.md: 0aad3d57079819165d345d494071eb04d0b50abd +2026-07-31-resume-selector-batch-projection.zh.md: 055e11c440114987c0c37a2d268bce3328d4b1bd diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md new file mode 100644 index 0000000000..0aad3d5707 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md @@ -0,0 +1,27 @@ +# Agent Note: Resume selector batch projection + +Status: implemented + +English | [中文](2026-07-31-resume-selector-batch-projection.zh.md) + +## Problem + +Opening the TUI `/resume` selector called `sessionQuery.readSession()` once per listed session under an unbounded `Promise.all`. Each call re-listed the whole persistence store inside `SessionCorpus.load()` (O(N²) listings), read and decompressed the complete log, replay-validated every event through the `Session` constructor, and deep-cloned the header and events up to three times — all to derive one selector row's title, last-activity time, last `turn/end` label, provider/model route, and goal phase. On a real store (185 sessions, 87 MB compressed, ~353k events) the selector took tens of seconds to open, and the cost grows with total log size rather than session count. + +## Decision + +`SessionQueryService` exposes the existing internal `SessionCorpus.projectMany` batch as public `projectSessions(sessionIds, project, signal?)`: one persistence listing, at most `persistedInspectConcurrency` concurrent persisted inspections, per-id failure isolation, and a synchronous projector over a borrowed `LogicalSessionSource` with no replay validation and no cloning. `readTitleSnapshots` now routes through it; `LogicalSessionSource` and `LogicalProjectionResult` are exported and documented in the session-query core-data-structures page. + +The `/resume` selector builds all candidate rows from one `projectSessions` batch; a rejected projection degrades to that row's disabled "Unreadable session" fallback exactly as a failed `readSession` did. `summarizeResumeCandidate` takes the borrowed source and retains only the record and derived scalars. The pre-handoff preflight still reads the single chosen session through `readSession`, keeping full replay validation before the process re-execs; its redundant live-session shortcut was dropped because `readSession` is already live-preferred. + +## Alternatives considered + +**Fix only the O(N²) listing inside `SessionCorpus.load()`.** Rejected as the primary fix: the per-candidate full decompress, replay validation, and triple clone dominate on large logs and remain O(total log bytes). The redundant pre-listing in `load()` is still a candidate cleanup, but it changes not-found/consistency error semantics and is not needed once the selector stops calling `readSession` per row. + +**A resume-specific summary method on `sessionQuery`.** Rejected: resume is a TUI concept, and the service seam should not import consumer vocabulary. The generic synchronous projection mirrors the seam `readTitleSnapshots` already used internally and lets the TUI own its fold. + +**A persisted summary index (e.g. in the SQLite query backend).** Rejected for now: one bounded pass over the store (~1–3 s on the measured machine) is acceptable selector latency, and an index adds an invalidation contract. Reintroduce if stores grow to where one bounded pass is still too slow. + +## Consequences + +Opening `/resume` performs one listing plus one bounded-concurrency pass instead of N listings and N validated full copies; memory stays bounded by the concurrency limit because each projected log is released before its worker dequeues another id. Selector rows are no longer replay-validated — a log that lists and parses but would fail replay shows as a normal row until preflight rejects it, which preflight always re-checks before handoff. Fake `sessionQuery` services in TUI tests must now provide `projectSessions` alongside `listSessions`/`readSession`. diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md new file mode 100644 index 0000000000..055e11c440 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 恢复选择器批量投影 + +Status: implemented + +[English](2026-07-31-resume-selector-batch-projection.md) | 中文 + +## Problem + +打开 TUI `/resume` 选择器时,会在一个无界 `Promise.all` 中对每个列出的会话调用一次 `sessionQuery.readSession()`。每次调用都会在 `SessionCorpus.load()` 内部重新列出整个持久化存储(O(N²) 次列表查询)、读取并解压完整日志、通过 `Session` 构造函数对每个事件做回放验证,并将 header 和事件深克隆多达三次——而这一切只为推导一行选择器条目的标题、最近活动时间、最后一个 `turn/end` 标签、提供方/模型路由和目标阶段。在真实存储上(185 个会话、压缩后 87 MB、约 35.3 万个事件),选择器需要数十秒才能打开,且开销随日志总大小而非会话数量增长。 + +## Decision + +`SessionQueryService` 将既有的内部 `SessionCorpus.projectMany` 批量能力公开为 `projectSessions(sessionIds, project, signal?)`:一次持久化列表查询、最多 `persistedInspectConcurrency` 个并发持久化检查、按 id 隔离失败,以及一个在借用的 `LogicalSessionSource` 上运行的同步投影函数——不做回放验证也不克隆。`readTitleSnapshots` 现在经由它实现;`LogicalSessionSource` 和 `LogicalProjectionResult` 被导出,并记录在 session-query 核心数据结构页面中。 + +`/resume` 选择器通过一次 `projectSessions` 批量调用构建全部候选行;被拒绝的投影会退化为该行的禁用"Unreadable session"回退,与之前 `readSession` 失败时的行为完全一致。`summarizeResumeCandidate` 接受借用的来源,且只保留记录和推导出的标量。移交前的预检仍通过 `readSession` 读取用户选中的单个会话,在进程 re-exec 前保留完整回放验证;其中冗余的实时会话捷径被删除,因为 `readSession` 本身已是实时优先。 + +## Alternatives considered + +**只修复 `SessionCorpus.load()` 内部的 O(N²) 列表查询。** 作为主要修复被拒绝:在大日志上,按候选行执行的完整解压、回放验证和三重克隆才是主要开销,且仍是 O(日志总字节数)。`load()` 中的冗余预列表查询仍是一个候选清理项,但它会改变 not-found/一致性错误语义,而且一旦选择器不再按行调用 `readSession`,这项清理就不再必要。 + +**在 `sessionQuery` 上添加恢复专用的摘要方法。** 被拒绝:恢复是 TUI 概念,服务接缝不应引入消费者词汇。通用同步投影复用了 `readTitleSnapshots` 已在内部使用的接缝,并让 TUI 拥有自己的 fold。 + +**持久化摘要索引(例如放在 SQLite 查询后端中)。** 暂时被拒绝:对存储做一次有界扫描(在测量机器上约 1–3 秒)是可接受的选择器延迟,而索引会引入失效契约。若存储增长到一次有界扫描仍然过慢时再重新引入。 + +## Consequences + +打开 `/resume` 只执行一次列表查询加一次有界并发扫描,而不是 N 次列表查询和 N 份经验证的完整副本;内存受并发上限约束,因为每个投影完的日志会在其 worker 出队下一个 id 前被释放。选择器行不再经过回放验证——一份可列出、可解析但回放会失败的日志会显示为普通行,直到预检拒绝它,而预检在移交前总会重新检查。TUI 测试中的伪造 `sessionQuery` 服务现在必须在 `listSessions`/`readSession` 之外提供 `projectSessions`。 diff --git a/packages/ui/tui/src/chat/resume.ts b/packages/ui/tui/src/chat/resume.ts index 18f7944fc2..e6558ee4c3 100644 --- a/packages/ui/tui/src/chat/resume.ts +++ b/packages/ui/tui/src/chat/resume.ts @@ -1,6 +1,6 @@ /** * Session-resume sub-controller for the interactive chat channel: the - * `/resume` selector, per-candidate summary reads that tolerate a corrupt + * `/resume` selector, one batch summary projection that tolerates a corrupt * neighbor, the pre-handoff preflight, and the terminal handoff itself. * @module @deepseek-ai/dsh-tui/chat/resume */ @@ -10,7 +10,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import { errorChain } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' import type { - SessionLogSnapshot, + LogicalSessionSource, SessionQueryService, SessionRecord, } from '@deepseek-ai/dsh-session-query' @@ -66,44 +66,45 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro const workspaceLabel = (cwd: string | undefined): string => runtime.formatCwd?.(cwd) ?? formatCwd(cwd) - /** Build one display candidate without letting a corrupt neighbor abort the selector. */ + /** Summarize one record from a borrowed source, retaining only the record and derived scalars. */ + const summarize = ( + record: SessionRecord, + source: LogicalSessionSource, + providers: ReadonlySet, + ): ResumeCandidate => summarizeResumeCandidate( + record, + source, + agent.session.id, + agent.session.header.cwd, + providers, + workspaceLabel, + ) + + /** The disabled fallback row for a session whose log cannot be summarized. */ + const unreadableCandidate = (record: SessionRecord, error: unknown): ResumeCandidate => ({ + record, + title: 'Unreadable session', + lastActivityAt: record.header.createdAt, + lastTurn: 'log unavailable', + currentWorkspace: record.header.cwd === agent.session.header.cwd, + workspaceLabel: workspaceLabel(record.header.cwd), + disabledReason: `session cannot be loaded: ${errorChain(error)}`, + }) + + /** Build one exact candidate from a live-preferred read that replay-validates a persisted log. */ const readResumeCandidate = async ( record: SessionRecord, providers: ReadonlySet, ): Promise => { try { - let snapshot: SessionLogSnapshot - const live = ctx.sessions.get(record.header.id) - if (live !== undefined) { - snapshot = { - session: structuredClone(live.header), - events: live.events.map(event => structuredClone(event)), - } - } else { - const readQuery = sessionQuery() - /* v8 ignore start -- caller proves the optional service before mapping records */ - if (readQuery === undefined) throw new Error('session query is unavailable') - /* v8 ignore stop */ - snapshot = await readQuery.readSession(record.header.id) - } - return summarizeResumeCandidate( - record, - snapshot, - agent.session.id, - agent.session.header.cwd, - providers, - workspaceLabel, - ) + const readQuery = sessionQuery() + /* v8 ignore start -- caller proves the optional service before mapping records */ + if (readQuery === undefined) throw new Error('session query is unavailable') + /* v8 ignore stop */ + const snapshot = await readQuery.readSession(record.header.id) + return summarize(record, { header: snapshot.session, events: snapshot.events }, providers) } catch (error: unknown) { - return { - record, - title: 'Unreadable session', - lastActivityAt: record.header.createdAt, - lastTurn: 'log unavailable', - currentWorkspace: record.header.cwd === agent.session.header.cwd, - workspaceLabel: workspaceLabel(record.header.cwd), - disabledReason: `session cannot be loaded: ${errorChain(error)}`, - } + return unreadableCandidate(record, error) } } @@ -199,7 +200,25 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro // Every workspace in the store is summarized; the picker owns the // current-workspace/all-workspaces scope split over the whole set. const providers = new Set(ctx.llm.listProviders().map(provider => provider.id)) - const candidates = await Promise.all(records.map(record => readResumeCandidate(record, providers))) + // One bounded batch projection over borrowed logs: unlike a + // per-candidate readSession, it lists persistence once and skips + // replay validation and log cloning, so opening the selector scales + // with session count instead of total log size. A corrupt neighbor + // degrades to one disabled row. + const recordById = new Map(records.map(record => [record.header.id, record])) + const listedRecord = (id: SessionId): SessionRecord => { + const record = recordById.get(id) + /* v8 ignore next 2 -- projection ids come from this map; the corpus verifies each loaded header id */ + if (record === undefined) throw new Error(`resume scan returned unlisted session "${id}"`) + return record + } + const results = await listQuery.projectSessions( + records.map(record => record.header.id), + source => summarize(listedRecord(source.header.id), source, providers), + ) + const candidates = results.map(result => result.status === 'fulfilled' + ? result.value + : unreadableCandidate(listedRecord(result.sessionId), result.reason)) candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.record.header.id.localeCompare(b.record.header.id)) if (deps.isDisposed() || scan !== resumeScan) return diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index bad6a5458b..604f1858de 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -28,7 +28,7 @@ import type { SessionId } from '@deepseek-ai/dsh-session' import { foldGoal, type GoalPhase } from '@deepseek-ai/dsh-goal' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { - SessionLogSnapshot, + LogicalSessionSource, SessionRecord, } from '@deepseek-ai/dsh-session-query' import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction' @@ -453,8 +453,8 @@ export interface ResumeCandidate { disabledReason?: string } -function resumeTurnLabel(snapshot: SessionLogSnapshot): string { - const event = snapshot.events.findLast(item => item.type === 'turn/end') +function resumeTurnLabel(source: LogicalSessionSource): string { + const event = source.events.findLast(item => item.type === 'turn/end') if (event === undefined) return 'no completed turn' const reason = event.data.reason switch (reason.kind) { @@ -468,24 +468,26 @@ function resumeTurnLabel(snapshot: SessionLogSnapshot): string { } } -function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined { - const header = snapshot.events.findLast(item => item.type === 'request/header') +function resumeRoute(source: LogicalSessionSource): ResumeRoute | undefined { + const header = source.events.findLast(item => item.type === 'request/header') if (header?.type === 'request/header') { return { provider: header.data.header.config.provider, model: header.data.header.config.model } } - const assistant = snapshot.events.findLast(item => item.type === 'assistant/message') + const assistant = source.events.findLast(item => item.type === 'assistant/message') return assistant?.type === 'assistant/message' ? { provider: assistant.data.message.source.provider, model: assistant.data.message.source.model } : undefined } /** - * Build one resume selector row from a record and its log snapshot, deriving the - * title, route, goal phase, workspace scope, and any reason the session cannot - * be resumed here. A workspace other than the current one is a scope, not a - * disabled reason: resuming it hands the process off into that directory. + * Build one resume selector row from a record and its borrowed log source, + * deriving the title, route, goal phase, workspace scope, and any reason the + * session cannot be resumed here. A workspace other than the current one is a + * scope, not a disabled reason: resuming it hands the process off into that + * directory. The result retains only the record and derived scalars, so a + * borrowed source stays valid for exactly this call. * @param record - The session record. - * @param snapshot - The session's log snapshot. + * @param source - The session's borrowed header and raw event log. * @param currentId - The current session id. * @param cwd - The CURRENT session's workspace, which decides the picker scope this row falls in. * @param availableProviders - Providers registered in this runtime. @@ -494,15 +496,15 @@ function resumeRoute(snapshot: SessionLogSnapshot): ResumeRoute | undefined { */ export function summarizeResumeCandidate( record: SessionRecord, - snapshot: SessionLogSnapshot, + source: LogicalSessionSource, currentId: SessionId, cwd: string | undefined, availableProviders: ReadonlySet, formatWorkspace: (cwd: string | undefined) => string, ): ResumeCandidate { - const title = foldSessionTitle(snapshot.events)?.title ?? 'Untitled session' - const route = resumeRoute(snapshot) - const foldedGoal = foldGoal(snapshot.events).goal + const title = foldSessionTitle(source.events)?.title ?? 'Untitled session' + const route = resumeRoute(source) + const foldedGoal = foldGoal(source.events).goal let disabledReason: string | undefined if (record.header.id === currentId) disabledReason = 'current session' else if (record.live) disabledReason = 'session is already live in this runtime' @@ -514,8 +516,8 @@ export function summarizeResumeCandidate( record, title, // Excludes a prior pickup's boundary, or every browsed session floats up. - lastActivityAt: lastActivityTime(snapshot.events) ?? snapshot.session.createdAt, - lastTurn: resumeTurnLabel(snapshot), + lastActivityAt: lastActivityTime(source.events) ?? source.header.createdAt, + lastTurn: resumeTurnLabel(source), currentWorkspace: record.header.cwd === cwd, workspaceLabel: formatWorkspace(record.header.cwd), ...route === undefined ? {} : { route }, diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 8231dc34d1..3729a1bd5b 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -273,6 +273,20 @@ describe('goodbye message and /resume', () => { { type: 'turn/end', seq: 6, time: time + 6, data: { turn: 1, reason } }, { type: 'session/title', seq: 7, time: time + 7, data: { title, messageSeqs: [1], source: { kind: 'fallback' } } }, ] + /** Derive the selector's batch projection from a fake per-session readSession. */ + const projectViaReadSession = ( + readSession: (id: SessionId) => Promise<{ session: SessionHeader; events: SessionEvent[] }>, + ) => ( + ids: readonly SessionId[], + project: (source: { header: SessionHeader; events: readonly SessionEvent[] }) => unknown, + ) => Promise.all(ids.map(async (sessionId) => { + try { + const snapshot = await readSession(sessionId) + return { sessionId, status: 'fulfilled', value: project({ header: snapshot.session, events: snapshot.events }) } + } catch (reason) { + return { sessionId, status: 'rejected', reason } + } + })) it('prints the host goodbye message on exit', async () => { const result = await setup({ @@ -516,6 +530,7 @@ describe('goodbye message and /resume', () => { queryCtx = child child.provide('sessionQuery', { listSessions: async () => { listCalls++; return [] }, + projectSessions: async () => [], } as never) }, }) @@ -546,16 +561,18 @@ describe('goodbye message and /resume', () => { cwd: '/workspace', async configureContext(ctx) { ctx.provide('tools', { get: () => undefined } as never) + const readSession = () => Promise.resolve({ + session: target, + events: resumeEvents('Query-only persisted session'), + }) ctx.provide('sessionQuery', { listSessions: () => Promise.resolve([{ header: target, live: false, persisted: true, }]), - readSession: () => Promise.resolve({ - session: target, - events: resumeEvents('Query-only persisted session'), - }), + readSession, + projectSessions: projectViaReadSession(readSession), } as never) }, }) @@ -593,6 +610,7 @@ describe('goodbye message and /resume', () => { ctx.provide('tools', { get: () => undefined } as never) ctx.provide('sessionQuery', { listSessions: () => ++calls === 1 ? first.promise : Promise.resolve([]), + projectSessions: async () => [], } as never) }, }) @@ -685,16 +703,18 @@ describe('goodbye message and /resume', () => { handoffResume: handoff, async configureContext(ctx) { ctx.provide('tools', { get: () => undefined } as never) + const readSession = () => Promise.resolve({ + session: target, + events: resumeEvents('Live target'), + }) ctx.provide('sessionQuery', { listSessions: () => Promise.resolve([{ header: target, live: true, persisted: true, }]), - readSession: () => Promise.resolve({ - session: target, - events: resumeEvents('Live target'), - }), + readSession, + projectSessions: projectViaReadSession(readSession), } as never) }, }) @@ -815,12 +835,14 @@ describe('goodbye message and /resume', () => { async configureContext(ctx) { ctx.provide('tools', { get: () => undefined } as never) ctx.on('session/flush', flush) + const readSession = () => Promise.resolve({ + session: target, + events: resumeEvents('Dispose during preflight'), + }) ctx.provide('sessionQuery', { listSessions: () => ++listings === 1 ? Promise.resolve([record]) : secondListing.promise, - readSession: () => Promise.resolve({ - session: target, - events: resumeEvents('Dispose during preflight'), - }), + readSession, + projectSessions: projectViaReadSession(readSession), } as never) }, }) @@ -847,16 +869,18 @@ describe('goodbye message and /resume', () => { handoffResume: handoff, async configureContext(ctx) { ctx.provide('tools', { get: () => undefined } as never) + const readSession = () => Promise.resolve({ + session: target, + events: resumeEvents('Query without persistence'), + }) ctx.provide('sessionQuery', { listSessions: () => Promise.resolve([{ header: target, live: false, persisted: true, }]), - readSession: () => Promise.resolve({ - session: target, - events: resumeEvents('Query without persistence'), - }), + readSession, + projectSessions: projectViaReadSession(readSession), } as never) }, })