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

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)
},
})