feat(tui): resolve resume titles through the projection cache

session-title already registers a title projection unit; /resume now
reads it instead of scanning logs: live rows from the registry
snapshot, persisted rows from the durable checkpoint row
(cachedSnapshot, zero I/O), and only rows without a usable checkpoint
pay a coldSnapshot — checkpoint plus readFrom tail, written back so the
next scan is metadata-only. Cold reads are bounded by the new
resumeScanConcurrency config; compositions without the cache fall back
to the bounded readTitleSnapshots batch. The TUI overlay mounts the
projection registry, storage, and projection-cache rows over the same
storages root the web surface uses, so checkpoints serve both.
This commit is contained in:
Turtle
2026-08-03 21:13:52 +08:00
parent a097e5b9b3
commit 7a26214a81
14 changed files with 217 additions and 25 deletions

View File

@@ -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 packages/ui/tui/README.md
README.md: 5b497be0c849c83c37879ded52ba8bb2031715f8
README.zh.md: a5841d6c37209811da6e3f6eb8526c01281de710
README.md: ac45a0ec9c282f3c872b325fe30a083dd1deed33
README.zh.md: 6bcb431713dd8a247d2b39d3392edc369ad00c90

View File

@@ -34,7 +34,7 @@ The footer sums the session's reported usage as `↑<uncached input> ↓<output>
`/resume` opens a full-viewport keyboard selector instead of a centered dialog. The selector opens as soon as the command runs and takes input focus while the session scan is still pending, showing a loading placeholder until the rows arrive; Escape cancels an in-flight scan the same way it cancels the loaded list. Two scopes cover the same candidate set: the current workspace, which it opens on, and all workspaces, which Tab toggles to. The scope line under the search field names the active scope and the count the other holds, and each row in the all-workspaces scope also reports its own workspace. Toggling clears the search and selection so the highlighted row always belongs to the visible list.
Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Rows fold nothing but each log's title (one bounded batch read): candidates are sorted by metadata activity — a live session's last in-memory event time, otherwise the persisted artifact's mtime, falling back to creation time — and searchable by title or session id, and by workspace label in the all-workspaces scope; each row reports that timestamp plus current/live/persisted state and the id. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, or a session with no recorded workspace to run in remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory.
Its focused search field starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored inside the field. Rows read no whole logs: when the optional projection cache is mounted, titles come from the live projection registry or the durable checkpoint row, with a cold read folding only the log tail since the checkpoint (written back so the next scan is zero-I/O, bounded by `resumeScanConcurrency`); a composition without the cache falls back to one bounded batch title read over the logs. Candidates are sorted by metadata activity — a live session's last in-memory event time, otherwise the persisted artifact's mtime, falling back to creation time — and searchable by title or session id, and by workspace label in the all-workspaces scope; each row reports that timestamp plus current/live/persisted state and the id. Up/Down and Page Up/Page Down navigate, Enter resumes, Escape clears a non-empty search before a second Escape cancels, and Ctrl+C cancels directly. The current session, a session already live in this runtime, an unreadable log, or a session with no recorded workspace to run in remains visible but disabled; a workspace other than the current one is a scope rather than a disabled reason, because resume enters that directory.
Selection repeats those checks, fully reads and replay-validates the one chosen log, rejects it when its logged provider has no current adapter, and requires the current agent to be idle before flushing the current session. The TUI then stops the terminal UI and calls the optional host-owned `TuiRuntime.handoffResume` with the selected id and the workspace re-read at preflight: process cwd, not the restored session header, is what filesystem and shell tools resolve against, so the host must enter that directory. Where `process.execve` is available, the shipped `dsh` host chdirs into it before disposing the app and replacing its process, and rejects an unreachable directory while the terminal can still be restored. Resume restores the same `SessionId`, transcript, title, todos, and durable goal; goal activation remains disarmed and the TUI asks for human confirmation or `/goal resume`.

View File

@@ -34,7 +34,7 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
`/resume` 会打开全 viewport 键盘选择器而非居中对话框。选择器在命令执行时立即打开并接管输入焦点会话扫描仍在进行时显示加载占位符直到行数据就绪Escape 取消进行中的扫描,方式与取消已加载列表相同。两个作用域覆盖同一候选项集合:打开时所处的当前工作区,以及按 Tab 切换到的所有工作区。搜索字段下方的作用域行会给出当前作用域的名称以及另一个作用域包含的数量,且在所有工作区作用域中每行还会报告自身所属的工作区。切换会清除搜索与选择,使高亮行始终属于可见列表。
获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker使终端 IME 组合保持锚定在字段内。行数据除每份日志的标题(一次有界批量读取)外不折叠任何内容:候选项按元数据活动时间排序——实时会话取内存中最后一个事件的时间,否则取持久化产物的 mtime再回退到创建时间——可按标题或会话 id 搜索在所有工作区作用域中还可按工作区标签搜索每行报告该时间戳、current/live/persisted 状态和 id。Up/Down 与 Page Up/Page Down 导航Enter 恢复Escape 会先清除非空搜索再次按下才取消Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志,或没有可运行的已记录工作区的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。
获得焦点的搜索字段紧跟搜索 glyph 开始,并发出 pi-tui 的 cursor marker使终端 IME 组合保持锚定在字段内。行数据不读取任何完整日志:挂载可选的投影缓存时,标题来自实时投影注册表或持久化 checkpoint 行,冷读取只折叠 checkpoint 之后的日志尾部(并写回,使下次扫描零 I/O`resumeScanConcurrency` 约束);未挂载缓存的组合回退到一次对日志的有界批量标题读取。候选项按元数据活动时间排序——实时会话取内存中最后一个事件的时间,否则取持久化产物的 mtime再回退到创建时间——可按标题或会话 id 搜索在所有工作区作用域中还可按工作区标签搜索每行报告该时间戳、current/live/persisted 状态和 id。Up/Down 与 Page Up/Page Down 导航Enter 恢复Escape 会先清除非空搜索再次按下才取消Ctrl+C 则直接取消。当前会话、已在本运行时中活跃的会话、不可读日志,或没有可运行的已记录工作区的会话仍会显示,但不可选择;不同于当前工作区的工作区属于作用域而非禁用原因,因为恢复会进入该目录。
选择时会重复这些检查,完整读取并回放验证所选中的那一份日志,在其日志所记提供方没有当前适配器时拒绝,并要求当前 agent 空闲,随后 flush 当前会话。TUI 接着停止终端 UI并以所选 id 和在预检时重新读取的工作区调用由宿主持有的可选 `TuiRuntime.handoffResume`:文件系统与 shell 工具解析所依据的是进程 cwd而非恢复出的会话头部因此宿主必须进入该目录。存在 `process.execve` 时,发布的 `dsh` 宿主会先 chdir 进入该目录,再对 app 执行 dispose 并替换自身进程,并在终端仍可恢复时拒绝不可达的目录。恢复操作保留相同的 `SessionId`、transcript、标题、todo 和持久目标目标激活仍保持解除TUI 会要求用户确认或执行 `/goal resume`

View File

@@ -42,6 +42,8 @@
"@deepseek-ai/dsh-goal": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-session-projection": "^0.0.1",
"@deepseek-ai/dsh-session-projection-cache": "^0.0.1",
"@deepseek-ai/dsh-session-query": "^0.0.1",
"@deepseek-ai/dsh-session-reference": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
@@ -82,6 +84,8 @@
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-projection": "workspace:^",
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",

View File

@@ -11,6 +11,9 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
import { errorChain } from '@deepseek-ai/dsh-llm'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-persistence'
import type {} from '@deepseek-ai/dsh-session-projection'
import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache'
import type {} from '@deepseek-ai/dsh-session-title'
import type {
SessionQueryService,
SessionRecord,
@@ -114,6 +117,73 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
}
}
/**
* One persisted row's title through the projection-cache ladder: the
* zero-I/O checkpoint row when usable, otherwise a cold read that folds
* only the log tail since the checkpoint and writes the refreshed row
* back — so a store scanned once serves later scans without log reads.
*/
const projectedTitle = async (
cache: SessionProjectionCache,
record: SessionRecord,
signal: AbortSignal,
): Promise<string | null | undefined> => {
const live = ctx.sessions.get(record.header.id)
if (live !== undefined) return ctx.get('sessionProjections')?.snapshot(live).values.title
const cached = cache.cachedSnapshot(record.header)
if (cached !== undefined && 'title' in cached.values) return cached.values.title
return (await cache.coldSnapshot(record.header.id, signal)).values.title
}
/** One per-record title resolution: a title (absent for untitled) or an isolated failure. */
type TitleResolution = { title?: string; failure?: unknown }
/**
* Resolve every row's title without reading whole logs when the projection
* cache is mounted (live registry snapshot / checkpoint row / tail-only
* cold read, bounded by `resumeScanConcurrency`); a composition without
* the cache falls back to one bounded raw-log title batch.
*/
const resolveTitles = async (
listQuery: SessionQueryService,
records: readonly SessionRecord[],
signal: AbortSignal,
): Promise<TitleResolution[]> => {
const cache = ctx.get('sessionProjectionCache')
if (cache === undefined) {
const results = await listQuery.readTitleSnapshots(records.map(record => record.header.id), signal)
return records.map((record, index): TitleResolution => {
const result = results[index]
/* v8 ignore next 2 -- readTitleSnapshots returns one result per unique listed id in input order */
if (result === undefined || result.sessionId !== record.header.id) throw new Error(`resume scan misaligned at "${record.header.id}"`)
if (result.status === 'rejected') return { failure: result.reason }
const title = result.value.title?.title
return title === undefined ? {} : { title }
})
}
const resolutions = new Array<TitleResolution>(records.length)
let cursor = 0
const worker = async (): Promise<void> => {
for (;;) {
const index = cursor
if (index >= records.length) return
cursor += 1
const record = records[index] as SessionRecord
try {
const value = await projectedTitle(cache, record, signal)
resolutions[index] = typeof value === 'string' ? { title: value } : {}
} catch (failure: unknown) {
resolutions[index] = { failure }
}
}
}
await Promise.all(Array.from(
{ length: Math.min(resolved.resumeScanConcurrency, records.length) },
() => worker(),
))
return resolutions
}
/** The latest logged provider/model route, for the preflight availability check. */
const resumeRoute = (events: readonly SessionEvent[]): { provider: string; model: string } | undefined => {
const header = events.findLast(item => item.type === 'request/header')
@@ -266,20 +336,18 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
// current-workspace/all-workspaces scope split over the whole set.
const records = await listQuery.listSessions(scanAbort.signal)
if (scanStale()) return
// Rows need only metadata, an mtime, and the batch-folded title — the
// one per-log read the selector performs. A corrupt neighbor degrades
// to one disabled row.
// Rows need only metadata, an mtime, and a title — resolved without
// whole-log reads when the projection cache is mounted. A corrupt
// neighbor degrades to one disabled row.
const [titles, activity] = await Promise.all([
listQuery.readTitleSnapshots(records.map(record => record.header.id), scanAbort.signal),
resolveTitles(listQuery, records, scanAbort.signal),
Promise.all(records.map(record => lastActivityAt(record))),
])
const candidates = records.map((record, index) => {
const title = titles[index]
/* v8 ignore next 2 -- readTitleSnapshots returns one result per unique listed id in input order */
if (title === undefined || title.sessionId !== record.header.id) throw new Error(`resume scan misaligned at "${record.header.id}"`)
return title.status === 'fulfilled'
? summarize(record, title.value.title?.title, activity[index])
: unreadableCandidate(record, activity[index], title.reason)
const resolution = titles[index] as TitleResolution
return 'failure' in resolution
? unreadableCandidate(record, activity[index], resolution.failure)
: summarize(record, resolution.title, activity[index])
})
candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt
|| a.record.header.id.localeCompare(b.record.header.id))

View File

@@ -42,6 +42,8 @@ export interface TuiConfig {
maxModelOptions?: number
/** Maximum sessions visible at once in the resume selector. */
maxResumeOptions?: number
/** Maximum concurrent cold projection reads in one resume scan. */
resumeScanConcurrency?: number
/** User-question panel width in terminal columns, clamped to the terminal. */
questionDialogWidth?: number
/** User-question panel maximum height in terminal rows. */
@@ -72,6 +74,7 @@ const maxDiffEditLengthSchema = z.number().step(1).min(1).default(1000)
const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8)
const maxModelOptionsSchema = z.number().step(1).min(1).default(8)
const maxResumeOptionsSchema = z.number().step(1).min(1).default(8)
const resumeScanConcurrencySchema = z.number().step(1).min(1).default(4)
const questionDialogWidthSchema = z.number().step(1).min(20).default(200)
const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20)
const modelDialogWidthSchema = z.number().step(1).min(20).default(76)
@@ -105,6 +108,7 @@ const tuiConfigSchemaFields = {
maxQuestionOptions: maxQuestionOptionsSchema,
maxModelOptions: maxModelOptionsSchema,
maxResumeOptions: maxResumeOptionsSchema,
resumeScanConcurrency: resumeScanConcurrencySchema,
questionDialogWidth: questionDialogWidthSchema,
questionDialogMaxHeight: questionDialogMaxHeightSchema,
modelDialogWidth: modelDialogWidthSchema,
@@ -178,6 +182,7 @@ export interface ResolvedTuiConfig {
maxQuestionOptions: number
maxModelOptions: number
maxResumeOptions: number
resumeScanConcurrency: number
questionDialogWidth: number
questionDialogMaxHeight: number
modelDialogWidth: number
@@ -205,6 +210,7 @@ export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConf
maxQuestionOptions: config?.maxQuestionOptions ?? 8,
maxModelOptions: config?.maxModelOptions ?? 8,
maxResumeOptions: config?.maxResumeOptions ?? 8,
resumeScanConcurrency: config?.resumeScanConcurrency ?? 4,
questionDialogWidth: config?.questionDialogWidth ?? 200,
questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20,
modelDialogWidth: config?.modelDialogWidth ?? 76,

View File

@@ -191,6 +191,7 @@ describe('TUI config', () => {
maxQuestionOptions: 8,
maxModelOptions: 8,
maxResumeOptions: 8,
resumeScanConcurrency: 4,
questionDialogWidth: 200,
questionDialogMaxHeight: 20,
modelDialogWidth: 76,
@@ -217,6 +218,7 @@ describe('TUI config', () => {
maxQuestionOptions: 3,
maxModelOptions: 4,
maxResumeOptions: 5,
resumeScanConcurrency: 2,
questionDialogWidth: 60,
questionDialogMaxHeight: 14,
modelDialogWidth: 64,
@@ -235,6 +237,7 @@ describe('TUI config', () => {
maxQuestionOptions: 3,
maxModelOptions: 4,
maxResumeOptions: 5,
resumeScanConcurrency: 2,
questionDialogWidth: 60,
questionDialogMaxHeight: 14,
modelDialogWidth: 64,
@@ -494,6 +497,81 @@ describe('goodbye message and /resume', () => {
await dispose(result)
})
it('resolves titles through the projection cache without scanning logs', async () => {
const current = header('main-session', 5, '/workspace')
const cachedRow = header('cached-title', 40, '/workspace')
const rowless = header('rowless-title', 30, '/workspace')
const untitled = header('untitled-title', 20, '/workspace')
const broken = header('broken-title', 10, '/workspace')
let coldReads = 0
const result = await setup({
cwd: '/workspace',
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
ctx.provide('sessionQuery', {
listSessions: () => Promise.resolve([
{ header: current, live: true, persisted: false },
{ header: cachedRow, live: false, persisted: true },
{ header: rowless, live: false, persisted: true },
{ header: untitled, live: false, persisted: true },
{ header: broken, live: false, persisted: true },
]),
readTitleSnapshots: () => Promise.reject(new Error('the ladder must not scan logs')),
} as never)
ctx.provide('sessionProjections', {
snapshot: () => ({ asOfSeq: 0, values: { title: 'Live projected' } }),
} as never)
ctx.provide('sessionProjectionCache', {
cachedSnapshot: (meta: SessionHeader) => {
if (meta.id === cachedRow.id) return { asOfSeq: 3, values: { title: 'Cached projected' } }
if (meta.id === untitled.id) return { asOfSeq: 3, values: { title: null } }
if (meta.id === rowless.id) return { asOfSeq: 3, values: {} }
return undefined
},
coldSnapshot: async (id: SessionId) => {
coldReads += 1
if (id === broken.id) throw new Error('checkpoint restore failed')
return { asOfSeq: 5, values: { title: 'Cold projected' } }
},
} as never)
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('Live projected')
expect(result.terminal.output).toContain('Cached projected')
expect(result.terminal.output).toContain('Cold projected')
expect(result.terminal.output).toContain('Untitled session')
expect(result.terminal.output).toContain('Unreadable session')
expect(result.terminal.output).toContain('checkpoint restore failed')
expect(result.terminal.output).not.toContain('the ladder must not scan logs')
expect(coldReads).toBe(2)
await dispose(result)
})
it('shows a live row untitled when the cache is mounted without the registry', async () => {
const current = header('main-session', 5, '/workspace')
const result = await setup({
cwd: '/workspace',
async configureContext(ctx) {
ctx.provide('tools', { get: () => undefined } as never)
ctx.provide('sessionQuery', {
listSessions: () => Promise.resolve([{ header: current, live: true, persisted: false }]),
} as never)
ctx.provide('sessionProjectionCache', {
cachedSnapshot: () => undefined,
coldSnapshot: async () => ({ asOfSeq: -1, values: {} }),
} as never)
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick(); await tick()
expect(result.terminal.output).toContain('Untitled session')
await dispose(result)
})
it('orders rows by artifact mtime without reading logs for the timestamp', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-resume-mtime-'))
const stale = join(dir, 'stale.log')

View File

@@ -32,6 +32,12 @@
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../session-projection/session-projection"
},
{
"path": "../../session-projection/session-projection-cache"
},
{
"path": "../../session-query/session-query"
},