From a942c726f5f2551c7ff7413ee028bd879de3d292 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 31 Jul 2026 18:57:24 +0800 Subject: [PATCH] fix(tui): abort the resume scan with its overlay Review findings from ds-review-bot: closing the loading picker now aborts the scan through the AbortSignal both query methods accept, a signal-ignoring backend's late settlement is dropped by a staleness check, one catch spans listing and projection so a projection failure closes the overlay instead of stranding the loading placeholder, setCandidates clears a stale still-loading error, and the batch comment no longer overstates the win as scaling with session count. --- ...resume-selector-batch-projection.i18n.yaml | 4 +- ...-07-31-resume-selector-batch-projection.md | 2 +- ...-31-resume-selector-batch-projection.zh.md | 2 +- packages/ui/tui/src/chat/resume.ts | 28 ++++-- packages/ui/tui/src/components/dialogs.ts | 2 + packages/ui/tui/tests/tui.spec.ts | 93 ++++++++++++++++++- 6 files changed, 118 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml index 0e733bbb4d..9eb6325c04 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.i18n.yaml @@ -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 .agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md -2026-07-31-resume-selector-batch-projection.md: 1120a0777bf6a6ca17dd0704d145a3c742be0695 -2026-07-31-resume-selector-batch-projection.zh.md: 79db30170765925a00be180ba2b56de8a87ba130 +2026-07-31-resume-selector-batch-projection.md: e1777d67b6f1822fd11d2d38bc6fe45ed11b179f +2026-07-31-resume-selector-batch-projection.zh.md: 031293a76afbf88c8da16f61ddef65f891c05382 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md index 1120a0777b..e1777d67b6 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.md @@ -14,7 +14,7 @@ Opening the TUI `/resume` selector called `sessionQuery.readSession()` once per The `/resume` selector builds all candidate rows from one `projectSessions` batch; a rejected projection degrades to that row's disabled "Unreadable session" fallback exactly as a failed `readSession` did. `summarizeResumeCandidate` takes the borrowed source and retains only the record and derived scalars. The pre-handoff preflight still reads the single chosen session through `readSession`, keeping full replay validation before the process re-execs; its redundant live-session shortcut was dropped because `readSession` is already live-preferred. -The selector overlay opens synchronously when `/resume` dispatches, before the scan settles: an `undefined` candidate set renders a "Loading sessions…" placeholder, the picker owns terminal input from its first frame (so keystrokes during a long scan reach the search field rather than the editor), Enter reports that sessions are still loading, and Escape cancels exactly as it does on the loaded list. The finished scan swaps rows in through `setCandidates` without replacing the overlay; a queued activation behind a closing predecessor receives an already-scanned set at construction; a scan failure closes the overlay and reports the existing failure notice. +The selector overlay opens synchronously when `/resume` dispatches, before the scan settles: an `undefined` candidate set renders a "Loading sessions…" placeholder, the picker owns terminal input from its first frame (so keystrokes during a long scan reach the search field rather than the editor), Enter reports that sessions are still loading, and Escape cancels exactly as it does on the loaded list. Closing the overlay aborts the scan through the `AbortSignal` both service methods accept, so a dismissed picker does not keep decompressing a large store; a signal-ignoring backend's late settlement is dropped by a staleness check instead. The finished scan swaps rows in through `setCandidates` (which also clears a stale still-loading error) without replacing the overlay; a queued activation behind a closing predecessor receives an already-scanned set at construction; one catch spans listing and projection, so any scan failure closes the overlay and reports the existing failure notice rather than stranding the loading placeholder. ## Alternatives considered diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md index 79db301707..031293a76a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-resume-selector-batch-projection.zh.md @@ -14,7 +14,7 @@ Status: implemented `/resume` 选择器通过一次 `projectSessions` 批量调用构建全部候选行;被拒绝的投影会退化为该行的禁用"Unreadable session"回退,与之前 `readSession` 失败时的行为完全一致。`summarizeResumeCandidate` 接受借用的来源,且只保留记录和推导出的标量。移交前的预检仍通过 `readSession` 读取用户选中的单个会话,在进程 re-exec 前保留完整回放验证;其中冗余的实时会话捷径被删除,因为 `readSession` 本身已是实时优先。 -选择器 overlay 在 `/resume` 分发时同步打开,早于扫描结算:`undefined` 候选集渲染"Loading sessions…"加载占位符,选择器从第一帧起就拥有终端输入(长扫描期间的按键会进入搜索字段而非编辑器),Enter 提示会话仍在加载,Escape 的取消方式与已加载列表完全相同。扫描完成后通过 `setCandidates` 换入行数据,不替换 overlay;排在正在关闭的前任之后的排队激活会在构造时直接收到已扫描的集合;扫描失败会关闭 overlay 并报告既有的失败通知。 +选择器 overlay 在 `/resume` 分发时同步打开,早于扫描结算:`undefined` 候选集渲染"Loading sessions…"加载占位符,选择器从第一帧起就拥有终端输入(长扫描期间的按键会进入搜索字段而非编辑器),Enter 提示会话仍在加载,Escape 的取消方式与已加载列表完全相同。关闭 overlay 会通过两个服务方法都接受的 `AbortSignal` 中止扫描,因此被关闭的选择器不会继续解压大型存储;忽略信号的后端在中止后的迟到结算则由过期检查丢弃。扫描完成后通过 `setCandidates`(同时清除过期的仍在加载错误)换入行数据,不替换 overlay;排在正在关闭的前任之后的排队激活会在构造时直接收到已扫描的集合;列表查询与投影共用同一个 catch,因此任何扫描失败都会关闭 overlay 并报告既有的失败通知,而不会让加载占位符悬置。 ## Alternatives considered diff --git a/packages/ui/tui/src/chat/resume.ts b/packages/ui/tui/src/chat/resume.ts index b1386ba10d..aa211f70ef 100644 --- a/packages/ui/tui/src/chat/resume.ts +++ b/packages/ui/tui/src/chat/resume.ts @@ -222,21 +222,28 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro }, }) resumeOverlay = session + // Closing the picker — Escape, supersession, disposal — aborts the scan: + // the borrowed-log pass over a large store must not outlive its overlay. + const scanAbort = new AbortController() void session.closed.then(() => { + scanAbort.abort() /* v8 ignore next -- overlay FIFO closes this session before a replacement can become the tracked resume overlay */ if (resumeOverlay === session) resumeOverlay = undefined }) deps.requestRender() - void listQuery.listSessions().then(async (records) => { - if (deps.isDisposed() || scan !== resumeScan) return + /** Whether this scan's overlay, session generation, or TUI is gone. */ + const scanStale = (): boolean => + deps.isDisposed() || scan !== resumeScan || scanAbort.signal.aborted + const scanCandidates = async (): Promise => { + 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, so opening the selector scales - // with session count instead of total log size. A corrupt neighbor - // degrades to one disabled row. + // 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) @@ -247,18 +254,23 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro 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)) candidates.sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.record.header.id.localeCompare(b.record.header.id)) - if (deps.isDisposed() || scan !== resumeScan) return + if (scanStale()) return scanned = candidates picker?.setCandidates(candidates) deps.requestRender() - }, (error: unknown) => { - if (deps.isDisposed() || scan !== resumeScan) return + } + // 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. + void scanCandidates().catch((error: unknown) => { + if (scanStale()) return void session.close() deps.appendNotice(`Resume session scan failed: ${errorChain(error)}`, 'error') }) diff --git a/packages/ui/tui/src/components/dialogs.ts b/packages/ui/tui/src/components/dialogs.ts index 6139a94773..a5390a97b7 100644 --- a/packages/ui/tui/src/components/dialogs.ts +++ b/packages/ui/tui/src/components/dialogs.ts @@ -574,6 +574,8 @@ export class ResumePicker implements Component, Focusable { setCandidates(candidates: readonly ResumeCandidate[]): void { this.candidates = candidates this.selectedIndex = 0 + // A still-loading error is false the moment rows exist. + this.error = '' this.invalidate() } diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index bf7d646e24..8bd0e50bd6 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -42,7 +42,8 @@ import { type TuiRuntime, } from '../src/index.ts' import { WorkspaceFileSearch } from '../src/chat/file-autocomplete.ts' -import { ATTRIBUTE_ROLES, COLOR_ROLES, paletteSpec } from '../src/components/theme.ts' +import { ResumePicker } from '../src/components/dialogs.ts' +import { ATTRIBUTE_ROLES, COLOR_ROLES, createPalette, paletteSpec } from '../src/components/theme.ts' import { appendAssistant, appendUser, @@ -651,6 +652,96 @@ describe('goodbye message and /resume', () => { expect(result.terminal.stopped).toBeGreaterThan(0) }) + it('clears the still-loading error the moment scanned rows arrive', () => { + const picker = new ResumePicker( + undefined, + 10, + '/workspace', + () => 30, + createPalette(false), + () => {}, + () => {}, + ) + picker.focused = true + picker.handleInput('\r') + expect(picker.render(80).join('\n')).toContain('Sessions are still loading.') + picker.setCandidates([]) + const rendered = picker.render(80).join('\n') + expect(rendered).not.toContain('Sessions are still loading.') + expect(rendered).toContain('No matching sessions.') + }) + + it('aborts an in-flight scan when the loading picker is dismissed', async () => { + const listing = Promise.withResolvers() + let scanSignal: AbortSignal | undefined + let projections = 0 + const result = await setup({ + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { + listSessions: (signal?: AbortSignal) => { scanSignal = signal; return listing.promise }, + projectSessions: async () => { projections += 1; return [] }, + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Loading sessions…') + result.terminal.send('\u001B') + await tick() + expect(scanSignal?.aborted).toBe(true) + // A signal-ignoring backend can still fulfill after dismissal: the stale + // scan must neither project nor report. + listing.resolve([]) + await tick() + expect(projections).toBe(0) + expect(result.terminal.output).not.toContain('Resume session scan failed') + await dispose(result) + }) + + it('drops a projection that settles after the picker was dismissed', async () => { + const projecting = Promise.withResolvers() + const result = await setup({ + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { + listSessions: async () => [], + projectSessions: () => projecting.promise, + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick() + result.terminal.send('\u001B') + await tick() + projecting.resolve([]) + await tick() + expect(result.terminal.output).not.toContain('(0 of 0)') + expect(result.terminal.output).not.toContain('Resume session scan failed') + await dispose(result) + }) + + it('closes the loading picker and reports a scan that fails after listing', async () => { + const target = header('projection-explodes', 10, '/workspace') + const result = await setup({ + async configureContext(ctx) { + ctx.provide('tools', { get: () => undefined } as never) + ctx.provide('sessionQuery', { + listSessions: () => Promise.resolve([{ header: target, live: false, persisted: true }]), + projectSessions: () => Promise.reject(new Error('projection exploded')), + } as never) + }, + }) + result.terminal.send('/resume') + result.terminal.send('\r') + await tick(); await tick() + expect(result.terminal.output).toContain('Resume session scan failed: projection exploded') + expect(result.terminal.stopped).toBe(0) + await dispose(result) + }) + it('opens a loading picker immediately and swaps in the scanned rows', async () => { const target = header('late-listing', 10, '/workspace') const listing = Promise.withResolvers()