refactor(tui): resume rows fold titles only, timestamp from artifact mtime

Rows no longer read logs for anything but the batch title fold: the
activity timestamp is a live session's last in-memory event time or the
artifact mtime via the optional sessionPersistence.locate(), falling
back to creation time; the last-turn, route, and goal columns are gone.
Route availability moves to the Enter-time preflight, which already
fully reads and replay-validates the one chosen log. The projectSessions
public API this PR had added to session-query is reverted — the change
is now confined to the TUI package.
This commit is contained in:
Turtle
2026-08-03 16:40:47 +08:00
parent 776cc6889a
commit 7077d6befc
25 changed files with 295 additions and 372 deletions

View File

@@ -1,16 +1,17 @@
/**
* Session-resume sub-controller for the interactive chat channel: the
* `/resume` selector, one batch summary projection that tolerates a corrupt
* `/resume` selector, one metadata-plus-title scan that tolerates a corrupt
* neighbor, the pre-handoff preflight, and the terminal handoff itself.
* @module @deepseek-ai/dsh-tui/chat/resume
*/
import { stat } from 'node:fs/promises'
import type { TUI } from '@earendil-works/pi-tui'
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 { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-persistence'
import type {
LogicalSessionSource,
SessionQueryService,
SessionRecord,
} from '@deepseek-ai/dsh-session-query'
@@ -66,51 +67,70 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
const workspaceLabel = (cwd: string | undefined): string =>
runtime.formatCwd?.(cwd) ?? formatCwd(cwd)
/** Summarize one record from a borrowed source, retaining only the record and derived scalars. */
/** Summarize one record from metadata and its batch-folded title. */
const summarize = (
record: SessionRecord,
source: LogicalSessionSource,
providers: ReadonlySet<string>,
title: string | undefined,
lastActivityAt: number | undefined,
): ResumeCandidate => summarizeResumeCandidate(
record,
source,
title,
lastActivityAt,
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 => ({
/** The disabled fallback row for a session whose title read failed. */
const unreadableCandidate = (
record: SessionRecord,
lastActivityAt: number | undefined,
error: unknown,
): ResumeCandidate => ({
record,
title: 'Unreadable session',
lastActivityAt: record.header.createdAt,
lastTurn: 'log unavailable',
lastActivityAt: lastActivityAt ?? record.header.createdAt,
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> => {
/**
* Metadata-only activity time: a live session's last in-memory event time,
* otherwise the persisted artifact's mtime. Never reads a log, so browsing
* cost stays independent of log size; any append (including bookkeeping)
* moves it.
*/
const lastActivityAt = async (record: SessionRecord): Promise<number | undefined> => {
const live = ctx.sessions.get(record.header.id)
if (live !== undefined) return live.events.at(-1)?.time
const location = ctx.get('sessionPersistence')?.locate(record.header)
if (location === undefined) return undefined
try {
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 unreadableCandidate(record, error)
return (await stat(location.path)).mtimeMs
} catch {
// Only a just-deleted or never-materialized artifact fails stat; the row falls back to created-at.
return undefined
}
}
/** 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')
if (header?.type === 'request/header') {
return { provider: header.data.header.config.provider, model: header.data.header.config.model }
}
const assistant = 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
}
/**
* Re-read every mutable precondition immediately before terminal handoff and
* resolve the exact identity and workspace the host will re-exec into.
* resolve the exact identity and workspace the host will re-exec into. This
* is where the one chosen log is fully read, replay-validated, and checked
* for a currently-available route — the listing never does any of that.
*/
const preflightResume = async (sessionId: SessionId): Promise<{ id: SessionId; cwd: string }> => {
const query = sessionQuery()
@@ -121,17 +141,24 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`)
const record = (await query.listSessions()).find(candidate => candidate.header.id === sessionId)
if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`)
const candidate = await readResumeCandidate(
record,
new Set(ctx.llm.listProviders().map(provider => provider.id)),
)
const candidate = summarize(record, undefined, undefined)
if (candidate.disabledReason !== undefined) throw new Error(candidate.disabledReason)
const cwd = candidate.record.header.cwd
let events: readonly SessionEvent[]
try {
events = (await query.readSession(record.header.id)).events
} catch (error: unknown) {
throw new Error(`session cannot be loaded: ${errorChain(error)}`)
}
const route = resumeRoute(events)
if (route !== undefined && !ctx.llm.listProviders().some(provider => provider.id === route.provider)) {
throw new Error(`session is complete, but route is currently unavailable (${route.provider}/${route.model})`)
}
const cwd = record.header.cwd
/* v8 ignore next -- summarizeResumeCandidate disables a cwd-less record, so the check above already rejected it */
if (cwd === undefined) throw new Error(`Session "${sessionId}" has no recorded workspace to resume in.`)
const finalStatus = deps.agentStatus()
if (finalStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${finalStatus}).`)
return { id: candidate.record.header.id, cwd }
return { id: record.header.id, cwd }
}
const handoffResume = async (candidate: ResumeCandidate, overlay: TuiOverlaySession): Promise<void> => {
@@ -235,30 +262,25 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
const scanStale = (): boolean =>
deps.isDisposed() || scan !== resumeScan || scanAbort.signal.aborted
const scanCandidates = async (): Promise<void> => {
// Every workspace in the store is listed; the picker owns the
// current-workspace/all-workspaces scope split over the whole set.
const records = await listQuery.listSessions(scanAbort.signal)
if (scanStale()) return
// 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))
// One bounded batch projection over borrowed logs: unlike a
// per-candidate readSession, it lists persistence once and skips
// replay validation and log cloning, bounding memory by what each
// summary retains. 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),
scanAbort.signal,
)
const candidates = results.map(result => result.status === 'fulfilled'
? result.value
: unreadableCandidate(listedRecord(result.sessionId), result.reason))
// 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.
const [titles, activity] = await Promise.all([
listQuery.readTitleSnapshots(records.map(record => record.header.id), 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)
})
candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt
|| a.record.header.id.localeCompare(b.record.header.id))
if (scanStale()) return
@@ -266,9 +288,10 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro
picker?.setCandidates(candidates)
deps.requestRender()
}
// One catch covers both stages, so a projection failure cannot strand
// the overlay on its loading placeholder; an aborted scan's rejection
// stays silent because the user already dismissed the picker.
// One catch covers listing, titles, and mtimes, so a scan failure
// cannot strand the overlay on its loading placeholder; an aborted
// scan's rejection stays silent because the user already dismissed the
// picker.
void scanCandidates().catch((error: unknown) => {
if (scanStale()) return
void session.close()

View File

@@ -23,14 +23,8 @@ import {
type AgentLlmTarget,
} from '@deepseek-ai/dsh-agent'
import type { LlmModelInfo, LlmModelReasoningInfo, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { lastActivityTime } from '@deepseek-ai/dsh-session'
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 {
LogicalSessionSource,
SessionRecord,
} from '@deepseek-ai/dsh-session-query'
import type { SessionRecord } from '@deepseek-ai/dsh-session-query'
import type { AskUserQuestionItem } from '@deepseek-ai/dsh-user-interaction'
import { BRACKETED_PASTE_END, BRACKETED_PASTE_START, displayText, sanitizePastedText } from './text.ts'
import { dialogSelectTheme, type Palette } from './theme.ts'
@@ -432,97 +426,53 @@ export class ModelDialog implements Component {
}
}
/** The provider/model route recovered from a resume candidate's log. */
export interface ResumeRoute {
provider: string
model: string
}
/** A preflighted resume selector row summarizing one persisted session. */
/** A resume selector row summarizing one session from metadata and its folded title. */
export interface ResumeCandidate {
record: SessionRecord
title: string
/** Last observed change: live last-event time or artifact mtime, falling back to creation. */
lastActivityAt: number
lastTurn: string
/** Whether the session's workspace is the one the current session runs in, which selects the picker scope that lists it. */
currentWorkspace: boolean
/** The session's own workspace as a prompt-style label; the all-workspaces scope shows it per row. */
workspaceLabel: string
route?: ResumeRoute
goalPhase?: GoalPhase
disabledReason?: string
}
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) {
case 'completed': return `turn ${event.data.turn}: completed`
case 'aborted': return `turn ${event.data.turn}: cancelled`
case 'error': return `turn ${event.data.turn}: error`
case 'disposed': return `turn ${event.data.turn}: disposed`
case 'max-tokens': return `turn ${event.data.turn}: max tokens`
case 'interrupted': return `turn ${event.data.turn}: interrupted`
default: return `turn ${event.data.turn}: unknown result`
}
}
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 = 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 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.
* Build one resume selector row from a record, its batch-folded title, and a
* metadata-derived activity time, deriving the 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. Rows carry no per-log detail beyond the title — route and
* replay validity are checked by the Enter-time preflight against the one
* chosen log.
* @param record - The session record.
* @param source - The session's borrowed header and raw event log.
* @param title - The session's batch-folded title, absent for an untitled log.
* @param lastActivityAt - Metadata activity time; absent falls back to the header's creation time.
* @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.
* @param formatWorkspace - Renders THIS record's own cwd as its prompt-style label.
* @returns The summarized resume candidate.
*/
export function summarizeResumeCandidate(
record: SessionRecord,
source: LogicalSessionSource,
title: string | undefined,
lastActivityAt: number | undefined,
currentId: SessionId,
cwd: string | undefined,
availableProviders: ReadonlySet<string>,
formatWorkspace: (cwd: string | undefined) => string,
): ResumeCandidate {
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'
else if (record.header.cwd === undefined) disabledReason = 'session has no recorded workspace'
else if (route !== undefined && !availableProviders.has(route.provider)) {
disabledReason = `session is complete, but route is currently unavailable (${route.provider}/${route.model})`
}
return {
record,
title,
// Excludes a prior pickup's boundary, or every browsed session floats up.
lastActivityAt: lastActivityTime(source.events) ?? source.header.createdAt,
lastTurn: resumeTurnLabel(source),
title: title ?? 'Untitled session',
lastActivityAt: lastActivityAt ?? record.header.createdAt,
currentWorkspace: record.header.cwd === cwd,
workspaceLabel: formatWorkspace(record.header.cwd),
...route === undefined ? {} : { route },
/* v8 ignore next -- goal-bearing resume records are covered by the goal/session integration surface. */
...foldedGoal === undefined ? {} : { goalPhase: foldedGoal.phase },
...disabledReason === undefined ? {} : { disabledReason },
}
}
@@ -601,7 +551,7 @@ export class ResumePicker implements Component, Focusable {
private visibleCandidateCount(): number {
// The all-workspaces scope adds a per-row workspace line, so a row costs
// one more terminal row there than in the single-workspace scope.
const rowHeight = this.scope === 'all' ? 5 : 4
const rowHeight = this.scope === 'all' ? 4 : 3
const candidateBudget = Math.max(1, Math.floor((Math.max(1, this.viewportRows()) - 13) / rowHeight))
return Math.min(this.maxVisible, candidateBudget)
}
@@ -748,11 +698,7 @@ export class ResumePicker implements Component, Focusable {
].filter((value): value is string => value !== undefined).join(' · ')
const lead = `${active ? '❯' : ' '} ${displayText(candidate.title)}`
push(active ? this.palette.bold(this.palette.accent(lead)) : lead)
const route = candidate.route === undefined ? 'route unavailable' : `${candidate.route.provider}/${candidate.route.model}`
/* v8 ignore next -- only goal-bearing resume records add this integration-owned suffix. */
const goal = candidate.goalPhase === undefined ? '' : ` · goal ${candidate.goalPhase}`
push(this.palette.dim(` ${new Date(candidate.lastActivityAt).toISOString()} · ${candidate.lastTurn} · ${route}${goal}`))
push(this.palette.dim(` ${status} · ${displayText(candidate.record.header.id)}`))
push(this.palette.dim(` ${new Date(candidate.lastActivityAt).toISOString()} · ${status} · ${displayText(candidate.record.header.id)}`))
// Only the all-workspaces scope mixes directories, so the per-row
// workspace is redundant in the scope that already names one.
if (this.scope === 'all') {