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

@@ -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')