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:
@@ -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: 8a0256da34b8d7de94d3f13b06fa41d591543fcf
|
||||
2026-07-31-resume-selector-batch-projection.zh.md: a0357a06e95d4a7aad2a5646f9bf2b0946d5e167
|
||||
2026-07-31-resume-selector-batch-projection.md: 39146527f13b20813bb6f5d5f1349ecab5724662
|
||||
2026-07-31-resume-selector-batch-projection.zh.md: 10333ea7cc5e7f2051c37f3374c5dc061bd0586e
|
||||
|
||||
@@ -12,13 +12,13 @@ Opening the TUI `/resume` selector called `sessionQuery.readSession()` once per
|
||||
|
||||
Selector rows fold nothing but titles, and everything else a row shows comes from metadata:
|
||||
|
||||
- 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.
|
||||
- Titles come from the projection system: `session-title` already registers a `title` unit, so a live row reads the registry snapshot, a persisted row reads the durable checkpoint row (`sessionProjectionCache.cachedSnapshot`, zero I/O), and only a row without a usable checkpoint pays a `coldSnapshot` — checkpoint plus a `readFrom` tail, written back so the next scan is zero-I/O. Cold reads are bounded by the TUI `resumeScanConcurrency` config. A composition without the cache falls back to one bounded `readTitleSnapshots` batch over the logs; either path isolates a per-row failure into the 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, 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.
|
||||
No session-query or session-persistence surface changed. The shipped TUI composition gains the projection registry, storage, and projection-cache rows (mirroring the web overlay over the same `storages` root, so checkpoints written by either surface serve both); the first scan over a pre-existing store still reads each log once to seed checkpoints, and every later scan is metadata-only.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -28,8 +28,8 @@ The change is confined to the TUI package: no session-query or session-persisten
|
||||
|
||||
**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.
|
||||
**A bespoke persisted title index or TUI-local title cache.** Rejected: the session-projection cache already is the owned durable checkpoint system with an invalidation contract (`stateVersion`, identity binding, shrunk-log anchoring); mounting it beats adding a parallel cache.
|
||||
|
||||
## Consequences
|
||||
|
||||
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.
|
||||
Opening `/resume` performs one listing, one stat per persisted row, and per-row title reads that touch only checkpoint rows and log tails once checkpoints exist — O(session count) metadata instead of O(total log bytes); the fallback path without the cache remains one bounded title pass. 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.
|
||||
|
||||
@@ -12,13 +12,13 @@ Status: implemented
|
||||
|
||||
选择器行除标题外不折叠任何内容,行内其余信息全部来自元数据:
|
||||
|
||||
- 标题来自既有的公开批量 `readTitleSnapshots`——一次持久化列表查询、最多 `persistedInspectConcurrency` 个并发检查、按 id 隔离失败。这是选择器唯一的按日志读取;标题读取被拒绝时退化为该行的禁用"Unreadable session"回退。
|
||||
- 标题来自投影系统:`session-title` 已注册 `title` 投影单元,因此实时行读取注册表快照,持久化行读取持久 checkpoint 行(`sessionProjectionCache.cachedSnapshot`,零 I/O),只有没有可用 checkpoint 的行才付出一次 `coldSnapshot`——checkpoint 加 `readFrom` 尾部折叠,并写回使下次扫描零 I/O。冷读取受 TUI `resumeScanConcurrency` 配置约束。未挂载缓存的组合回退到一次对日志的有界 `readTitleSnapshots` 批量读取;两条路径都把单行失败隔离为禁用的"Unreadable session"回退。
|
||||
- 活动时间戳从不读取日志:实时会话取内存中最后一个事件的时间;持久化会话对可选 `sessionPersistence.locate()` 命名的产物做 stat(mtime),当后端定位不到按会话的产物(SQLite)或 stat 失败时回退到 header 的创建时间。任何追加都会移动 mtime,因此仅仅一次 pickup 边界也会让浏览过的会话上浮——这是元数据时间戳的代价,予以接受。
|
||||
- 行内不再有最后轮次标签、提供方/模型路由和目标阶段列。路由可用性改由 Enter 时的预检强制:预检通过 `readSession` 完整读取并回放验证选中的那一份日志后才移交。
|
||||
|
||||
选择器 overlay 在 `/resume` 分发时同步打开,早于扫描结算:`undefined` 候选集渲染"Loading sessions…"加载占位符,选择器从第一帧起就拥有终端输入,Enter 提示会话仍在加载,Escape 取消。关闭 overlay 会通过查询方法接受的 `AbortSignal` 中止扫描;忽略信号的后端的迟到结算由过期检查丢弃。扫描完成后通过 `setCandidates`(同时清除过期的仍在加载错误)换入行数据,不替换 overlay;排在正在关闭的前任之后的排队激活会在构造时直接收到已扫描的集合;列表查询、标题与 mtime 共用同一个 catch,因此任何扫描失败都会关闭 overlay 并报告通知,而不会让加载占位符悬置。
|
||||
|
||||
改动局限于 TUI 包:session-query 与 session-persistence 的任何表面都未改变。
|
||||
session-query 与 session-persistence 的任何表面都未改变。随附的 TUI 组合新增投影注册表、storage 与投影缓存行(镜像 web overlay,共用同一 `storages` 根,因此任一表面写下的 checkpoint 都服务两者);对既有存储的首次扫描仍会各读取一次日志以播种 checkpoint,之后的每次扫描都只读元数据。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -28,8 +28,8 @@ Status: implemented
|
||||
|
||||
**通过 `listSnapshots`/`SessionRecord` 暴露最后修改时间。** 从接缝角度最干净,但要触碰持久化契约、两个后端和查询记录形状,而 TUI 已能用 `locate()` 加一次 stat 得到同样的信息。若出现第二个需要元数据活动时间的消费者再引入。
|
||||
|
||||
**持久化摘要/标题索引。** 暂时否决:一次有界标题扫描的选择器延迟可接受,而索引会引入失效契约。若大型存储上的标题读取成为瓶颈再引入。
|
||||
**专门的持久化标题索引或 TUI 本地标题缓存。** 否决:session-projection 缓存本身就是自有的持久 checkpoint 系统,并已带失效契约(`stateVersion`、身份绑定、日志收缩锚定);挂载它优于再造一套并行缓存。
|
||||
|
||||
## Consequences
|
||||
|
||||
打开 `/resume` 只执行一次列表查询、每个持久化行一次 stat、一次有界标题扫描,而不是 N 次列表查询和 N 份经验证的完整副本。行内只显示标题、时间戳、状态和 id;路由问题以 Enter 时预检错误的形式出现,而不再是禁用行;回放会失败的会话由预检而非列表阶段拦截。浏览后放弃的会话会因 pickup 的 mtime 上浮。TUI 测试中的伪造 `sessionQuery` 服务在 `listSessions`/`readSession` 之外提供 `readTitleSnapshots`,测试 harness 会转发可选的 `locate`。由于选择器立即接管焦点,启动第二次扫描需要先关闭当前 overlay——扫描期间输入的第二个 `/resume` 会落入搜索字段,这正是预期的输入捕获行为。
|
||||
打开 `/resume` 只执行一次列表查询、每个持久化行一次 stat,标题读取在 checkpoint 就绪后只触碰 checkpoint 行和日志尾部——O(会话数) 的元数据开销,而非 O(日志总字节数);无缓存的回退路径仍是一次有界标题扫描。行内只显示标题、时间戳、状态和 id;路由问题以 Enter 时预检错误的形式出现,而不再是禁用行;回放会失败的会话由预检而非列表阶段拦截。浏览后放弃的会话会因 pickup 的 mtime 上浮。TUI 测试中的伪造 `sessionQuery` 服务在 `listSessions`/`readSession` 之外提供 `readTitleSnapshots`,测试 harness 会转发可选的 `locate`。由于选择器立即接管焦点,启动第二次扫描需要先关闭当前 overlay——扫描期间输入的第二个 `/resume` 会落入搜索字段,这正是预期的输入捕获行为。
|
||||
|
||||
Reference in New Issue
Block a user