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:
@@ -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: e1777d67b6f1822fd11d2d38bc6fe45ed11b179f
|
||||
2026-07-31-resume-selector-batch-projection.zh.md: 031293a76afbf88c8da16f61ddef65f891c05382
|
||||
2026-07-31-resume-selector-batch-projection.md: 8a0256da34b8d7de94d3f13b06fa41d591543fcf
|
||||
2026-07-31-resume-selector-batch-projection.zh.md: a0357a06e95d4a7aad2a5646f9bf2b0946d5e167
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: Resume selector batch projection
|
||||
# Agent Note: Resume selector folds titles only
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,24 +6,30 @@ English | [中文](2026-07-31-resume-selector-batch-projection.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Opening the TUI `/resume` selector called `sessionQuery.readSession()` once per listed session under an unbounded `Promise.all`. Each call re-listed the whole persistence store inside `SessionCorpus.load()` (O(N²) listings), read and decompressed the complete log, replay-validated every event through the `Session` constructor, and deep-cloned the header and events up to three times — all to derive one selector row's title, last-activity time, last `turn/end` label, provider/model route, and goal phase. On a real store (185 sessions, 87 MB compressed, ~353k events) the selector took tens of seconds to open, and the cost grows with total log size rather than session count.
|
||||
Opening the TUI `/resume` selector called `sessionQuery.readSession()` once per listed session under an unbounded `Promise.all`. Each call re-listed the whole persistence store inside `SessionCorpus.load()` (O(N²) listings), read and decompressed the complete log, replay-validated every event through the `Session` constructor, and deep-cloned the header and events up to three times — all to derive one selector row's title, last-activity time, last `turn/end` label, provider/model route, and goal phase. On a real store (185 sessions, 87 MB compressed, ~353k events) the selector took tens of seconds to open, and the cost grew with total log size rather than session count.
|
||||
|
||||
## Decision
|
||||
|
||||
`SessionQueryService` exposes the existing internal `SessionCorpus.projectMany` batch as public `projectSessions(sessionIds, project, signal?)`: one persistence listing, at most `persistedInspectConcurrency` concurrent persisted inspections, per-id failure isolation, and a synchronous projector over a borrowed `LogicalSessionSource` with no replay validation and no cloning. `readTitleSnapshots` now routes through it; `LogicalSessionSource` and `LogicalProjectionResult` are exported and documented in the session-query core-data-structures page.
|
||||
Selector rows fold nothing but titles, and everything else a row shows comes from metadata:
|
||||
|
||||
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.
|
||||
- Titles come from the existing public batch `readTitleSnapshots` — one persistence listing, at most `persistedInspectConcurrency` concurrent inspections, per-id failure isolation. This is the selector's only per-log read; a rejected title read degrades to that row's disabled "Unreadable session" fallback.
|
||||
- The activity timestamp never reads a log: a live session uses its last in-memory event time; a persisted session stats the artifact named by the optional `sessionPersistence.locate()` (mtime), falling back to the header's creation time when the backend locates no per-session artifact (SQLite) or the stat fails. Any append moves the mtime, so a mere pickup boundary now floats a browsed session up — accepted as the price of a metadata-only timestamp.
|
||||
- The last-turn label, provider/model route, and goal phase columns are gone from rows. Route availability is now enforced by the Enter-time preflight, which fully reads and replay-validates the one chosen log through `readSession` before handoff.
|
||||
|
||||
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.
|
||||
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, Enter reports that sessions are still loading, and Escape cancels. Closing the overlay aborts the scan through the `AbortSignal` the query methods accept; a signal-ignoring backend's late settlement is dropped by a staleness check. The finished scan swaps rows in through `setCandidates` (clearing 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, titles, and mtimes, so any scan failure closes the overlay and reports a notice rather than stranding the loading placeholder.
|
||||
|
||||
The change is confined to the TUI package: no session-query or session-persistence surface changed.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Fix only the O(N²) listing inside `SessionCorpus.load()`.** Rejected as the primary fix: the per-candidate full decompress, replay validation, and triple clone dominate on large logs and remain O(total log bytes). The redundant pre-listing in `load()` is still a candidate cleanup, but it changes not-found/consistency error semantics and is not needed once the selector stops calling `readSession` per row.
|
||||
**Keep per-row route/turn/goal columns via a generic batch projection (`projectSessions`).** Implemented first, then rejected: it still decompressed and parsed every log on every `/resume`, so browsing cost stayed O(total log bytes), and it grew the session-query public API for one consumer. The public seam was reverted; `readTitleSnapshots` keeps using the internal `projectMany` unchanged.
|
||||
|
||||
**A resume-specific summary method on `sessionQuery`.** Rejected: resume is a TUI concept, and the service seam should not import consumer vocabulary. The generic synchronous projection mirrors the seam `readTitleSnapshots` already used internally and lets the TUI own its fold.
|
||||
**Fix only the O(N²) listing inside `SessionCorpus.load()`.** Rejected as the primary fix: the per-candidate full decompress, replay validation, and triple clone dominated on large logs. The redundant pre-listing in `load()` remains a candidate cleanup with error-semantics implications.
|
||||
|
||||
**A persisted summary index (e.g. in the SQLite query backend).** Rejected for now: one bounded pass over the store (~1–3 s on the measured machine) is acceptable selector latency, and an index adds an invalidation contract. Reintroduce if stores grow to where one bounded pass is still too slow.
|
||||
**Surface a last-modified time through `listSnapshots`/`SessionRecord`.** Cleanest seam-wise, but touches the persistence contract, both backends, and the query record shape for what the TUI can already derive from `locate()` plus one stat. Reintroduce if a second consumer needs metadata activity times.
|
||||
|
||||
**A persisted summary/title index.** Rejected for now: one bounded title pass is acceptable selector latency, and an index adds an invalidation contract. Reintroduce if title reads over large stores become the bottleneck.
|
||||
|
||||
## Consequences
|
||||
|
||||
Opening `/resume` performs one listing plus one bounded-concurrency pass instead of N listings and N validated full copies; memory stays bounded by the concurrency limit because each projected log is released before its worker dequeues another id. Selector rows are no longer replay-validated — a log that lists and parses but would fail replay shows as a normal row until preflight rejects it, which preflight always re-checks before handoff. Fake `sessionQuery` services in TUI tests must now provide `projectSessions` alongside `listSessions`/`readSession`. Because the picker takes focus immediately, starting a second scan requires dismissing the current overlay first — a second `/resume` typed during a scan lands in the search field, which is the intended input capture.
|
||||
Opening `/resume` performs one listing, one stat per persisted row, and one bounded title pass instead of N listings and N validated full copies. Rows show title, timestamp, status, and id only; route problems surface as an Enter-time preflight error instead of a disabled row, and a session that fails replay is caught by preflight rather than the listing. Browsed-then-abandoned sessions float up on their pickup mtime. Fake `sessionQuery` services in TUI tests provide `readTitleSnapshots` alongside `listSessions`/`readSession`, and the test harness forwards an optional `locate`. Because the picker takes focus immediately, starting a second scan requires dismissing the current overlay first — a second `/resume` typed during a scan lands in the search field, which is the intended input capture.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Agent Note: 恢复选择器批量投影
|
||||
# Agent Note: 恢复选择器只折叠标题
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -10,20 +10,26 @@ Status: implemented
|
||||
|
||||
## Decision
|
||||
|
||||
`SessionQueryService` 将既有的内部 `SessionCorpus.projectMany` 批量能力公开为 `projectSessions(sessionIds, project, signal?)`:一次持久化列表查询、最多 `persistedInspectConcurrency` 个并发持久化检查、按 id 隔离失败,以及一个在借用的 `LogicalSessionSource` 上运行的同步投影函数——不做回放验证也不克隆。`readTitleSnapshots` 现在经由它实现;`LogicalSessionSource` 和 `LogicalProjectionResult` 被导出,并记录在 session-query 核心数据结构页面中。
|
||||
选择器行除标题外不折叠任何内容,行内其余信息全部来自元数据:
|
||||
|
||||
`/resume` 选择器通过一次 `projectSessions` 批量调用构建全部候选行;被拒绝的投影会退化为该行的禁用"Unreadable session"回退,与之前 `readSession` 失败时的行为完全一致。`summarizeResumeCandidate` 接受借用的来源,且只保留记录和推导出的标量。移交前的预检仍通过 `readSession` 读取用户选中的单个会话,在进程 re-exec 前保留完整回放验证;其中冗余的实时会话捷径被删除,因为 `readSession` 本身已是实时优先。
|
||||
- 标题来自既有的公开批量 `readTitleSnapshots`——一次持久化列表查询、最多 `persistedInspectConcurrency` 个并发检查、按 id 隔离失败。这是选择器唯一的按日志读取;标题读取被拒绝时退化为该行的禁用"Unreadable session"回退。
|
||||
- 活动时间戳从不读取日志:实时会话取内存中最后一个事件的时间;持久化会话对可选 `sessionPersistence.locate()` 命名的产物做 stat(mtime),当后端定位不到按会话的产物(SQLite)或 stat 失败时回退到 header 的创建时间。任何追加都会移动 mtime,因此仅仅一次 pickup 边界也会让浏览过的会话上浮——这是元数据时间戳的代价,予以接受。
|
||||
- 行内不再有最后轮次标签、提供方/模型路由和目标阶段列。路由可用性改由 Enter 时的预检强制:预检通过 `readSession` 完整读取并回放验证选中的那一份日志后才移交。
|
||||
|
||||
选择器 overlay 在 `/resume` 分发时同步打开,早于扫描结算:`undefined` 候选集渲染"Loading sessions…"加载占位符,选择器从第一帧起就拥有终端输入(长扫描期间的按键会进入搜索字段而非编辑器),Enter 提示会话仍在加载,Escape 的取消方式与已加载列表完全相同。关闭 overlay 会通过两个服务方法都接受的 `AbortSignal` 中止扫描,因此被关闭的选择器不会继续解压大型存储;忽略信号的后端在中止后的迟到结算则由过期检查丢弃。扫描完成后通过 `setCandidates`(同时清除过期的仍在加载错误)换入行数据,不替换 overlay;排在正在关闭的前任之后的排队激活会在构造时直接收到已扫描的集合;列表查询与投影共用同一个 catch,因此任何扫描失败都会关闭 overlay 并报告既有的失败通知,而不会让加载占位符悬置。
|
||||
选择器 overlay 在 `/resume` 分发时同步打开,早于扫描结算:`undefined` 候选集渲染"Loading sessions…"加载占位符,选择器从第一帧起就拥有终端输入,Enter 提示会话仍在加载,Escape 取消。关闭 overlay 会通过查询方法接受的 `AbortSignal` 中止扫描;忽略信号的后端的迟到结算由过期检查丢弃。扫描完成后通过 `setCandidates`(同时清除过期的仍在加载错误)换入行数据,不替换 overlay;排在正在关闭的前任之后的排队激活会在构造时直接收到已扫描的集合;列表查询、标题与 mtime 共用同一个 catch,因此任何扫描失败都会关闭 overlay 并报告通知,而不会让加载占位符悬置。
|
||||
|
||||
改动局限于 TUI 包:session-query 与 session-persistence 的任何表面都未改变。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**只修复 `SessionCorpus.load()` 内部的 O(N²) 列表查询。** 作为主要修复被拒绝:在大日志上,按候选行执行的完整解压、回放验证和三重克隆才是主要开销,且仍是 O(日志总字节数)。`load()` 中的冗余预列表查询仍是一个候选清理项,但它会改变 not-found/一致性错误语义,而且一旦选择器不再按行调用 `readSession`,这项清理就不再必要。
|
||||
**通过通用批量投影(`projectSessions`)保留每行的路由/轮次/目标列。** 先实现后否决:它仍在每次 `/resume` 时解压并解析全部日志,浏览开销依旧是 O(日志总字节数),且为单一消费者扩大了 session-query 公开 API。该公开接缝已回退;`readTitleSnapshots` 继续使用内部 `projectMany`,保持不变。
|
||||
|
||||
**在 `sessionQuery` 上添加恢复专用的摘要方法。** 被拒绝:恢复是 TUI 概念,服务接缝不应引入消费者词汇。通用同步投影复用了 `readTitleSnapshots` 已在内部使用的接缝,并让 TUI 拥有自己的 fold。
|
||||
**只修复 `SessionCorpus.load()` 内部的 O(N²) 列表查询。** 作为主要修复被否决:在大日志上,按候选行执行的完整解压、回放验证和三重克隆才是主要开销。`load()` 中的冗余预列表查询仍是一个候选清理项,但涉及错误语义。
|
||||
|
||||
**持久化摘要索引(例如放在 SQLite 查询后端中)。** 暂时被拒绝:对存储做一次有界扫描(在测量机器上约 1–3 秒)是可接受的选择器延迟,而索引会引入失效契约。若存储增长到一次有界扫描仍然过慢时再重新引入。
|
||||
**通过 `listSnapshots`/`SessionRecord` 暴露最后修改时间。** 从接缝角度最干净,但要触碰持久化契约、两个后端和查询记录形状,而 TUI 已能用 `locate()` 加一次 stat 得到同样的信息。若出现第二个需要元数据活动时间的消费者再引入。
|
||||
|
||||
**持久化摘要/标题索引。** 暂时否决:一次有界标题扫描的选择器延迟可接受,而索引会引入失效契约。若大型存储上的标题读取成为瓶颈再引入。
|
||||
|
||||
## Consequences
|
||||
|
||||
打开 `/resume` 只执行一次列表查询加一次有界并发扫描,而不是 N 次列表查询和 N 份经验证的完整副本;内存受并发上限约束,因为每个投影完的日志会在其 worker 出队下一个 id 前被释放。选择器行不再经过回放验证——一份可列出、可解析但回放会失败的日志会显示为普通行,直到预检拒绝它,而预检在移交前总会重新检查。TUI 测试中的伪造 `sessionQuery` 服务现在必须在 `listSessions`/`readSession` 之外提供 `projectSessions`。由于选择器立即接管焦点,启动第二次扫描需要先关闭当前 overlay——扫描期间输入的第二个 `/resume` 会落入搜索字段,这正是预期的输入捕获行为。
|
||||
打开 `/resume` 只执行一次列表查询、每个持久化行一次 stat、一次有界标题扫描,而不是 N 次列表查询和 N 份经验证的完整副本。行内只显示标题、时间戳、状态和 id;路由问题以 Enter 时预检错误的形式出现,而不再是禁用行;回放会失败的会话由预检而非列表阶段拦截。浏览后放弃的会话会因 pickup 的 mtime 上浮。TUI 测试中的伪造 `sessionQuery` 服务在 `listSessions`/`readSession` 之外提供 `readTitleSnapshots`,测试 harness 会转发可选的 `locate`。由于选择器立即接管焦点,启动第二次扫描需要先关闭当前 overlay——扫描期间输入的第二个 `/resume` 会落入搜索字段,这正是预期的输入捕获行为。
|
||||
|
||||
Reference in New Issue
Block a user