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

@@ -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,