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.
This commit is contained in:
Turtle
2026-07-31 16:09:17 +08:00
parent 6e577843c8
commit 3c08ca3606
6 changed files with 173 additions and 68 deletions
@@ -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
@@ -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 (~13 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`.
@@ -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`
+54 -35
View File
@@ -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<string>,
): 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<string>,
): Promise<ResumeCandidate> => {
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
+19 -17
View File
@@ -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<string>,
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 },
+40 -16
View File
@@ -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)
},
})