Projection cache read-only face matches the lifecycle identity; the client store separates cached and sequenced rows
Projection cache 只读面按 lifecycle 身份匹配,客户端 store 区分 cached 与 sequenced 行
After a Host process restart, every forked session (`SessionHeader.isSeeded === true`) shows no title in the sidebar session list, `@` reference completion shows only the session id, and list ordering degrades to creation time. Opening the session once restores all of it. On one development machine on 2026-09-19, 44 of 241 projcache records were seeded, all 44 carried a valid `title` row, and the list never read any
English
Problem
After a Host process restart, every forked session (SessionHeader.isSeeded === true) shows no title in the sidebar session list, @ reference completion shows only the session id, and list ordering degrades to creation time. Opening the session once restores all of it. On one development machine on 2026-09-19, 44 of 241 projcache records were seeded, all 44 carried a valid title row, and the list never read any of them.
This is neither cache corruption nor a version mismatch. The projcache record's domain version is 7 and its identity.formatVersion is 4; those are the cache's own on-disk format generation and the Session log format generation the fold ran under, and both are current. The defect is on the read path.
Mechanism
A cache record (record format and predecessor recovery: Projection-cache predecessor recovery and Session-format binding) is bound to a lifecycle identity: formatVersion + createdAt + cwd + isSeeded + inheritedEventCount, matched by identityMatches on full equality. Since #3346, inheritedEventCount (the length of the event prefix a fork inherits, the cut below) no longer appears in the logical header: the header keeps only the isSeeded bit and the exact cut follows the body. Since Session format v2 (#3398) the physical header line no longer stores seedLength either; the reader derives the cut from the seq of the session/end-seed {inherited: true} marker in the body.
A header-only read therefore cannot obtain the cut: the JSONL backend's fromHeaderLine hard-codes inheritedEventCount: 0 for header-only reads, and SessionPersistenceSnapshot carries only the header, the revision, and an optional eventCount. The Session-list and reference consumers grew the same guard:
| Consumer | Guard | Fallback | Consequence |
|---|---|---|---|
packages/api/session-controller/src/list.ts projectionsFor | header.isSeeded ? undefined : cachedSnapshot(header, 0) ?? cachedPredecessorTitle(header, 0) | none | no title, no sessionListMetadata; blank falls back to false, ordering falls back to createdAt |
packages/context/session-reference/src/index.ts projectedLabels | same | none | @ completion labels by id; the title cannot be searched |
When the guard landed (#3346, 2026-09-01), list.ts still had probeSmallCold: on a cache miss it read the body when the log file was at most DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024 bytes. Its purpose was blank-session detection; it covered only logs under 1KB and never applied to an ordinary fork. #3400 (2026-09-03) removed it and the list returned to metadata plus cache only. #4320 (2026-09-19) made forking a first-class feature (packages/core/session/src/fork.ts, fork at any seq); every fork is isSeeded: true with an exact cut, seeded sessions went from a handful to dozens, all of them cold after a restart, and the gap became visible at once.
The cache's two uses and where validation lives
The read and write faces of SessionProjectionCache and their callers:
| Face | What it does with the record | Writes back | Callers |
|---|---|---|---|
write(session) | writes the checkpoint at three mandatory points plus a throttle | yes | the cache's own listeners |
hydratePrepared(session, events) | uses rows as the fold starting point and applies events from row.seq + 1; the result lands in live cells | the next checkpoint | session-query/src/observation.ts |
coldSnapshot(meta, cut, events) | fold starting point plus write-back | yes | no production caller |
cachedSnapshot(meta, cut, keys?) | viewCheckpoint: each row passes ver and stateSchema, is viewed, and returned | no | list.ts, session-reference |
cachedPredecessorTitle(meta, cut) | the same, title only, allowing an older formatVersion | no | list.ts |
Identity validation lives entirely inside the cache (recordFor → identityMatches; viewCheckpoint checks ver and schema per row). list.ts validates nothing itself; it merely declines to call when it cannot supply a cut.
spec.ts and the README describe the purpose of the identity check with one verb: preventing "seed state folded from an unrelated log", "cannot seed the caller". That guards the fold face. The read-only face has never seeded anything.
The client store cannot guarantee that connected data replaces hints
Each session has exactly one ProjectionValueStore on the client (manager.projectionStores; its merge rules were recorded in Session observations and projection-owned client state). The block delivered by the list, the history first-page baseline on open, the control baseline, push frames, and rename results all write into that one object, and useProjection reads it. All writers are peers under one higher-seq-wins rule: apply drops a new value when seq <= row.seq, and seed clears omitted keys only when row.seq <= cut. A list hint whose seq is equal or higher survives untouched after the session is opened. The hint's seq comes from a disk record; after a crash-repair truncation it can be numerically higher than the connected cursor, which is exactly the case where the cache is wrong and the connection is right. Comparing seqs against a hint is the wrong tool.
Decision
Two read faces; the cut is compared only on the fold face
| Face | Identity | Who can supply it | Methods |
|---|---|---|---|
| read-only face | lifecycle identity: formatVersion + createdAt + cwd + isSeeded, all from the header | any header-only caller | cachedSnapshot(header, keys?), cachedPredecessorTitle(header) |
| fold face | lifecycle identity plus inheritedEventCount | callers holding the Session or its body | hydratePrepared, write, coldSnapshot, all through recordFor |
The two read-only methods no longer take a cut parameter. inheritedEventCount is still written into every record and the fold face still compares it on full equality; the original design expectation, that records folded under different fork cuts never seed each other, is unchanged.
In the block the read-only face returns, asOfSeq is the lowest watermark among the served rows, the stored record's own position. A header can vouch neither for the cut nor for the comparability of that watermark with the current log, so comparability is not expressed by this number: the Session list adds an independent field kind to each summary's projections block (SessionProjectionHints). A block a cold session's row viewed from projcache is cached; a block the Host's live registry computed for an attached session is sequenced. The two fields are independent facts: kind names the sequence space asOfSeq belongs to, and asOfSeq is the watermark within that space.
Why the read-only face may skip the cut
- Within one
formatVersion, a log's cut is determined by(id, createdAt, cwd, isSeeded): it is fixed at fork creation, and only a cardinality-changing format migration can alter it, which necessarily bumpsformatVersion. The cut is a derived fact of the lifecycle, not an independent coordinate. - What the read-only face actually did until now: unseeded callers passed the constant 0 (true by construction) and seeded callers never called. The cut was never compared against any real fact on this face. Removing it changes the rejection set not at all: a record from another lifecycle (any of the four fields differing) is still rejected, unseeded results are identical, and seeded lifecycles go from unaddressable to addressable.
- The read-only face never seeds and never writes back; its output is a pure function of the stored record, the cut plays no part in it, and it flows nowhere.
- The one new exposure: a record whose four fields all match but whose cut differs. The system cannot produce it; only copying a session directory by hand and editing files can. Even then the list shows that record's values until the session is opened, and nothing enters any fold.
Consumers
| Location | Change |
|---|---|
list.ts projectionsFor | drop the isSeeded branch and SessionLogOffset(0); every cold row reads cachedSnapshot(header) ?? cachedPredecessorTitle(header) |
session-reference projectedLabels | same symptom, same change |
The client store separates cached and sequenced rows
Rows in ProjectionValueStore come in two kinds, named for the property the rule depends on: whether the value carries a seq comparable within this connection.
| Row | Meaning | Carries |
|---|---|---|
cached | viewed from the persisted checkpoint without a Session; its seq is not comparable with this connection | value only |
sequenced | computed by the Host for this Session within this connection; seqs share one space | value and seq |
Rules:
| Write | Against a cached row | Against a sequenced row |
|---|---|---|
applyCached(values) | replaces | ignored |
seed(baseline) | discards every cached row first, then writes the block; omitted keys clear under seq <= cut | higher-seq-wins |
apply(key, value, seq) | replaces | higher-seq-wins |
Seq comparison happens only within one Host connection: handleConnected clears the whole store, then the list writes cached rows again and opening a session seeds again. From cached to sequenced is an unconditional replacement without any seq comparison.
Writer classification:
| Entry | Row kind |
|---|---|
the projections block of each summary in the session.list response (manager.refreshList) | by the block's kind: cached for a cold session, sequenced for a live one |
the projections block of an api-session/added summary (manager.handleSessionAdded) | by the block's kind; the summary comes from a live session, so in practice sequenced |
the history first page's projections (projections.seed in session.ts) | sequenced |
the control baseline, live sessions only (manager.replaceControlBaseline) | sequenced |
the session.projections result of refreshProjections | sequenced for an exact read; cached only when migration is required |
push frames (the projection frame handled by manager) | sequenced |
the title after a successful rename (session.ts) | sequenced |
The seed and apply signatures are unchanged. The client routes by kind: a sequenced block goes through apply key by key, a cached block through applyCached, which does not read its asOfSeq.
What stays untouched
- The Session format, the physical header line,
fromHeaderLine, persistence,SessionPersistenceSnapshot. - Hydration and the checkpoint write path;
recordForkeeps full equality on all five fields. coldSnapshotstays; having no production caller is not a reason to delete it here.- Old records (domain v4/v5, lacking
isSeededandinheritedEventCount) keep missing for seeded sessions until the session is opened and rewritten as a v7 record. - The list still delivers every row with a wire view (
contextBreakdown,turnOutline, and so on); narrowing that is out of scope.
Worst case, walked through
- The list serves a wrong record; the sidebar shows a wrong title, held in a cached row.
- The session is opened; for the instant before the first-page baseline arrives the wrong value is still shown. Every pre-population has this window.
- The first-page baseline
seeds: every cached row is discarded unconditionally, then the block is written. Nothing of the wrong value survives, whatever its seq. - A later list refresh writes cached values that hit sequenced rows and are ignored; the wrong value does not come back.
Alternatives considered
Write the cut back into the physical header line. Header-only reads would obtain the cut directly and the read-only face would keep its identity. The cost is another Session format generation right after V4 landed, and reversing #3346's decision that the exact cut does not pose as body-free metadata. Rejected: the header is not changed for this.
An index of each session's cut maintained by persistence or session-query. A new durable index with its own consistency maintenance, serving one display read. Rejected.
Restore a bounded body probe. The probeSmallCold idea #3400 removed, or a client-side asynchronous refreshProjections for visible seeded sessions. It breaks the list's zero-I/O principle, and the historical 1KB threshold shows it never covered an ordinary fork. Rejected.
Only the asOfSeq: -1 sentinel, without store tiers. One server-side change makes hints always lose under the seq rule. It rests on a convention: when a hint's seq equals or exceeds the baseline cut (crash-repair truncation), apply keeps the old row and the wrong value survives until the next frame. The user requires connected data to replace hints unconditionally, so the rule belongs in the store rather than in a seq convention.
Express cached through asOfSeq: -1 and let the client route on the sentinel. The read-only face always emits -1 and the client treats every list block as cached. Rejected: one field would carry two meanings, and the second could only be inferred by convention; a live session's list block carries a real seq comparable within the connection, and treating every block as cached demoted those too. The PR review reproduced the regression: a delayed control baseline at a lower cut overwrote a newer list value. The independent field kind replaces the sentinel, and asOfSeq keeps each source's own watermark.
Remove the cut from the cache identity entirely. The fold face needs it: restore continues applying from the cached row's state, and schedule, subagentCatalog, permissions.seeded, and owned/inherited classification encode the cut; an error would be written back and persisted. Rejected.
Naming the row kinds hint / authoritative. Those words speak of trust, while the rule depends on seq comparability. Renamed cached / sequenced so the names state the property the rule uses.
Consequences
Bought:
- After a restart, forked sessions immediately show their title,
sessionListMetadata(blank, lastPromptAt), and every other cached wire value in the sidebar, ordered by the last prompt time. @completion shows and searches forks by title.- Connected data replaces list hints unconditionally, independent of seq ordering.
- The two levels of the cache identity have names: the lifecycle identity answers "is this the same log", the fold identity answers "may this continue a fold".
Paid:
cachedSnapshotandcachedPredecessorTitlechange signature; the Session-list and reference callers use the header-only signatures.SessionProjectionHintsgains the required fieldkind; every producer of a list summary and every test fixture that builds one carries it.- A hand-crafted record with the same four fields and a different cut is displayed in the list until the session is opened.
- Keys the baseline omits clear together with their hints: when a Host does not mount
schedule, the schedule mark the list hinted disappears after the session opens. Under "connected data is the truth" this is the correct behavior. - Old records still miss for seeded sessions until an open rewrites them.
Testing
session-projection-cache/tests/cache.spec.ts: a seeded cold header obtains every version-matching row throughcachedSnapshot(header)at the row's watermark; an unseeded header is refused for the same id's seeded record;coldSnapshotcontinues from the row when the cut matches, refolds the whole log when it differs, and throws for an unseeded call with a nonzero cut;cachedPredecessorTitle(header)serves onlytitlefrom a seeded record of an olderformatVersion; rows with differing watermarks form one block whoseasOfSeqis the lowest row.session-projection-cache/tests/fixtures.spec.ts: archived v3 to v6 records still expose only the predecessor title; lineage-less archives still miss for seeded callers.api/session-controller/tests/session-cold.host.spec.ts: a seeded cold summary carrieskind: 'cached',title, andsessionListMetadata,updatedAttakeslastPromptAt, the cache is queried, and the body is not read.api/session-controller/tests/session-projections.host.spec.ts: a real fork made throughsessions.forkis checkpointed into projcache, the whole Context is disposed, and a Host restarted over the same storage root reads thekind: 'cached'titleandsessionListMetadatathroughsession.listfrom the header alone;inspectandopenare never called.api/session-controller/tests/projection-store.client.spec.ts:applyCachedfills empty keys only and never displaces a sequenced row; every sequenced write (including a frame at cursor-1and a baseline at a lower cut) replaces cached rows; a baseline discards all cached rows before clearing omitted keys; face subscribers are notified on cached fills and their discard. Manager path: acachedlist block at any watermark is replaced by a control baseline at a lower cut, and a later list refresh cannot bring it back; asequencedlist block merges under higher-seq-wins, so a delayed baseline at a lower cut neither overwrites nor clears it while a higher-seq frame still advances it.api/session-controller/tests/manager.client.spec.ts: acachedapi-session/addedblock is replaced by a control baseline at the same cursor; acachedlist block does not displace an existing sequenced title.api/session-controller/tests/inbox-projection.client.spec.ts: unchanged; a live session'ssequencedlist block still outranks a delayed control baseline at a lower cut.context/session-reference/tests/session-reference.spec.ts: a seeded cold session is labeled and searchable by its cached title, a session without a cache record is still labeled by id, and neither reads a log.
中文
Problem
Host 进程重启后,所有 fork 出来的会话(SessionHeader.isSeeded === true)在侧边栏列表里没有 title,@ 引用补全只显示会话 id,列表排序退化到创建时间。打开一次之后恢复。2026-09-19 的一台开发机上,241 条 projcache 记录里有 44 条 seeded,44 条的 title 行全部合法,列表一次都没读到。
这不是缓存损坏,也不是版本失配。projcache 记录的 domain version 是 7,identity.formatVersion 是 4,两者分别是缓存自身的磁盘格式代际和 fold 所依据的 Session 日志格式代际,都是当前值。问题在读取路径。
机制
缓存记录(记录格式与前代恢复见 投影缓存前代恢复与 Session 格式绑定)绑定一份 lifecycle identity:formatVersion + createdAt + cwd + isSeeded + inheritedEventCount,identityMatches 做全等匹配。其中 inheritedEventCount(fork 继承的事件前缀长度,下称 cut)从 #3346 起不再出现在逻辑 header 里:header 只保留 isSeeded 这一位,精确 cut 跟随正文。Session 格式 v2 起(#3398),物理 header 行也不再存 seedLength,reader 从正文里 session/end-seed {inherited: true} 标记的 seq 推出 cut。
于是 header-only 的读取拿不到 cut:JSONL 后端 fromHeaderLine 对 header-only 读取硬编码 inheritedEventCount: 0,SessionPersistenceSnapshot 只有 header、revision 和可选的 eventCount。Session 列表与引用消费者都因此加了同一个守卫:
| 消费者 | 守卫 | 兜底 | 后果 |
|---|---|---|---|
packages/api/session-controller/src/list.ts projectionsFor | header.isSeeded ? undefined : cachedSnapshot(header, 0) ?? cachedPredecessorTitle(header, 0) | 无 | 无 title,无 sessionListMetadata,blank 判定退到 false,排序退到 createdAt |
packages/context/session-reference/src/index.ts projectedLabels | 同 | 无 | @ 补全按 id 显示,搜不到 title |
守卫存在时(#3346,2026-09-01),list.ts 还有 probeSmallCold:缓存 miss 且日志文件不超过 DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024 字节时读正文补全。它的目的是识别 blank 会话,只覆盖不到 1KB 的日志,对正常 fork 从未生效。#3400(2026-09-03)删掉它,列表回到纯元数据加缓存。#4320(2026-09-19)把 fork 变成一等功能(packages/core/session/src/fork.ts,任意 seq fork),每个 fork 都是 isSeeded: true 加精确 cut,seeded 会话数量从个位数变成几十条,重启后全部冷,问题集中显形。
缓存的两种用途与校验位置
SessionProjectionCache 的读写面和调用方:
| 面 | 对记录做什么 | 写回 | 调用方 |
|---|---|---|---|
write(session) | 三个强制点加节流写 checkpoint | 有 | 缓存内部监听 |
hydratePrepared(session, events) | 把 rows 当 fold 起点,从 row.seq + 1 继续 apply,结果进 live cell | 后续 checkpoint 写回 | session-query/src/observation.ts |
coldSnapshot(meta, cut, events) | fold 起点加回写 | 有 | 无生产调用方 |
cachedSnapshot(meta, cut, keys?) | viewCheckpoint:每行过 ver 和 stateSchema,view 后返回 | 无 | list.ts、session-reference |
cachedPredecessorTitle(meta, cut) | 同上,只看 title,允许更旧的 formatVersion | 无 | list.ts |
身份校验全部在缓存内部(recordFor → identityMatches;viewCheckpoint 逐行校验 ver 与 schema)。list.ts 自己不校验,只是给不出 cut 时不调。
spec.ts 与 README 描述身份检查的目的时用的都是同一个动词:防止 "seed state folded from an unrelated log"、"cannot seed the caller"。它防的是 fold 面。只读面从未做过 seed。
客户端 store 无法保证建连数据覆盖提示
客户端每个会话只有一个 ProjectionValueStore(manager.projectionStores;合并规则原记录于 Session observation 与 projection 所有的客户端状态),列表下发的 block、打开会话的 history 首页基线、control 基线、推送 frame、rename 结果全部写进同一个对象,useProjection 读的也是它。所有写入者平等,只有一条 higher-seq-wins 规则:apply 在 seq <= row.seq 时丢弃新值,seed 清理没带的键时只清 row.seq <= cut 的行。一个 seq 相等或偏高的列表提示会在会话打开后原样留在格子里。列表提示的 seq 来自磁盘记录,crash-repair 截断日志后它可以数字上高于建连 cursor,而那正是缓存错、建连对的情形。对提示做 seq 比较是用错了工具。
Decision
缓存分成两个读面,cut 只在 fold 面比对
| 面 | 身份 | 谁能提供 | 方法 |
|---|---|---|---|
| 只读面 | lifecycle 身份:formatVersion + createdAt + cwd + isSeeded,全部来自 header | 任何 header-only 调用方 | cachedSnapshot(header, keys?)、cachedPredecessorTitle(header) |
| fold 面 | lifecycle 身份加 inheritedEventCount | 握有 Session 或正文的调用方 | recordFor 所服务的 hydratePrepared、write、coldSnapshot |
只读面的两个方法不再接受 cut 参数。inheritedEventCount 继续写入记录,fold 面继续全等比对它;"不同 fork cut 的记录不能互相当种子"这条原始设计预期不变。
只读面返回的 block 里,asOfSeq 是所服务各行中最低的水位,也就是存储记录自己的位置。header 既作证不了 cut,也作证不了这个水位与当前日志的可比性,所以可比性不由这个数字表达:Session list 给每条摘要的 projections block(SessionProjectionHints)加独立字段 kind,冷会话从 projcache 看出来的 block 标 cached,活会话由 Host live registry 算出的 block 标 sequenced。两个字段各管各的:kind 说明 asOfSeq 属于哪个序列空间,asOfSeq 是那个空间里的水位。
只读面为什么可以不比 cut
- 在同一个
formatVersion下,一条日志的 cut 由(id, createdAt, cwd, isSeeded)唯一决定:fork 创建时写死,只有改变事件基数的格式迁移能改它,而那种迁移必然改formatVersion。cut 是 lifecycle 的派生事实,不是独立坐标。 - 只读面今天实际发生的是:unseeded 传常量 0(构造上恒真),seeded 直接不调。cut 在只读面上从未和任何真实事实比过。拿掉它,拒绝集合一个都不变:另一个 lifecycle 的记录(四字段任一不同)照样被拒;unseeded 结果相同;seeded 从不可寻址变成可寻址。
- 只读面没有 seed、没有写回,输出是存储记录的纯函数,cut 不参与计算,也不流向任何地方。
- 唯一多出来的暴露面:一份四字段全同、只有 cut 不同的记录。系统自身产生不出它,只有手工复制会话目录再改文件才行。就算出现,后果是列表显示它的值直到会话被打开,仍然不进任何 fold。
消费者
| 位置 | 改法 |
|---|---|
list.ts projectionsFor | 删 isSeeded 分支和 SessionLogOffset(0),冷会话统一 cachedSnapshot(header) ?? cachedPredecessorTitle(header) |
session-reference projectedLabels | 同一症状,同一改法 |
客户端 store 区分 cached 与 sequenced 行
ProjectionValueStore 的行分两类,按"这个值有没有本连接可比的 seq"命名:
| 行 | 含义 | 携带 |
|---|---|---|
cached | 从持久化 checkpoint 只读看出来的值,没有 Session 参与,seq 与本连接不可比 | 只有 value |
sequenced | 本连接内由 Host 对该 Session 算出的值,seq 在同一空间可比 | value 加 seq |
规则:
| 写入 | 遇到 cached 行 | 遇到 sequenced 行 |
|---|---|---|
applyCached(values) | 覆盖 | 忽略 |
seed(baseline) | 整表丢弃 cached 行,再按 block 写入;block 没带的键按 seq <= cut 清理 | higher-seq-wins |
apply(key, value, seq) | 覆盖 | higher-seq-wins |
seq 比较只在同一条 Host 连接内发生:handleConnected 先整表 clear(),再由列表重新写 cached、打开会话重新 seed。从 cached 到 sequenced 是无条件替换,不看 seq。
写入口归类:
| 入口 | 行类型 |
|---|---|
session.list 响应里每条摘要的 projections block(manager.refreshList) | 按 block 的 kind:冷会话 cached,活会话 sequenced |
api-session/added 摘要的 projections block(manager.handleSessionAdded) | 按 block 的 kind;该摘要来自活会话,实际为 sequenced |
history 首页 projections(session.ts 的 projections.seed) | sequenced |
control 基线,仅活会话(manager.replaceControlBaseline) | sequenced |
refreshProjections 的 session.projections 结果 | 准确读取为 sequenced;仅需要迁移时为 cached |
推送 frame(manager 处理 projection frame) | sequenced |
rename 成功后的 title(session.ts) | sequenced |
seed 与 apply 签名不变。客户端按 kind 分流:sequenced block 逐键 apply,cached block 走 applyCached,不读它的 asOfSeq。
不动的部分
- Session 格式、物理 header 行、
fromHeaderLine、persistence、SessionPersistenceSnapshot。 - hydration 与 checkpoint 写路径,
recordFor的五字段全等。 coldSnapshot保留,不因没有生产调用方而删除。- 老记录(domain v4/v5,缺
isSeeded与inheritedEventCount)对 seeded 会话继续 miss,等会话打开后被 v7 记录改写。 - 列表下发全部带 wire 视图的行(
contextBreakdown、turnOutline等)这一点不在本次范围。
最坏情形推演
- 列表用了一份错误记录,侧边栏显示错 title,格子里是 cached 行。
- 点进会话,首页基线到达前一瞬仍是错值。这是任何预填充都有的窗口。
- 首页基线
seed:所有 cached 行先被无条件丢弃,再按 block 写入。错值零残留,与 seq 大小无关。 - 之后列表刷新,cached 写入遇到 sequenced 行直接忽略,错值不会被刷回来。
Alternatives considered
把 cut 写回物理 header 行。 header-only 读取能直接拿到 cut,只读面无需改身份。代价是 Session 格式再升一版,V4 刚落地;并且要推翻 #3346"精确 cut 不伪装成 body-free metadata"的决定。否决:不为此改 header。
由 persistence 或 session-query 维护每个会话 cut 的索引。 新增一套 durable 索引及其一致性维护,只为服务一个展示读取。否决。
恢复有界正文探测。 #3400 删掉的 probeSmallCold 思路,或由客户端对可见 seeded 会话异步 refreshProjections。违背列表零 I/O 原则,且历史阈值 1KB 说明它从未覆盖过正常 fork。否决。
只用 asOfSeq: -1 哨兵,不给 store 分层。 服务端一处改动即可让提示在 seq 规则下永远落败。但它靠约定成立:一旦提示 seq 与基线 cut 相等或偏高(crash-repair 截断),apply 保留旧行,错值存活到下一帧。用户要求建连数据无条件覆盖提示,规则要写进 store 而不是靠 seq 约定模拟。
用 asOfSeq: -1 表达 cached,客户端按哨兵分流。 只读面统一输出 -1,客户端把所有列表 block 当 cached。否决:一个字段同时承担两个含义,另一个含义只能靠约定推断;活会话的列表 block 带本连接可比的真实 seq,一律当 cached 就把它们也降级了,PR 评审复现了延迟到达、cut 更低的 control 基线覆盖更新列表值的回归。改为独立字段 kind,asOfSeq 保持各自来源的水位。
把 cut 从缓存身份里彻底删掉。 fold 面需要它:restore 从缓存行状态继续 apply,schedule、subagentCatalog、permissions.seeded 与 owned/inherited 归属都编码了 cut,错误会被写回并持久化。否决。
行类型命名 hint / authoritative。 说的是可信度,而规则依据的是 seq 可比性。改为 cached / sequenced,名字直接说明规则用到的性质。
Consequences
买到的:
- 重启后 fork 会话在侧边栏立即有 title、
sessionListMetadata(blank、lastPromptAt)和其余带 wire 的缓存值,排序按最后一次提问时间。 @补全对 fork 按 title 显示和搜索。- 建连后的数据无条件替换列表提示,不再依赖 seq 大小。
- 缓存身份的两级含义有了名字:lifecycle 身份回答"是不是同一份日志",fold 身份回答"能不能当续算起点"。
付出的:
cachedSnapshot与cachedPredecessorTitle签名变化,Session 列表与引用调用方使用仅需 header 的签名。SessionProjectionHints新增必填字段kind,所有产出列表摘要的地方和构造摘要的测试夹具都要带上。- 手工构造的同四字段、不同 cut 的记录会在列表上显示到会话被打开为止。
- 基线没带的键连提示一起清掉:某个 Host 未挂载
schedule时,打开会话后列表提示过的 schedule 标记消失。按"建连数据是真值"这是正确行为。 - 老记录对 seeded 会话仍然 miss,直到打开重写。
Testing
session-projection-cache/tests/cache.spec.ts:seeded 冷 header 通过cachedSnapshot(header)取到全部版本匹配的行,asOfSeq为行水位;unseeded header 对同一 id 的 seeded 记录被拒;coldSnapshot对 cut 一致的记录从行续算、对 cut 不一致的记录整段重新 fold,对 unseeded 且 cut 非 0 的调用抛错;cachedPredecessorTitle(header)对 seeded 的更旧formatVersion记录只给title;不同 watermark 的多行合成一个 block,asOfSeq取最低行。session-projection-cache/tests/fixtures.spec.ts:归档的 v3 到 v6 记录继续只透出 predecessor title;无 lineage 字段的归档对 seeded 调用方继续 miss。api/session-controller/tests/session-cold.host.spec.ts:seeded 冷会话摘要携带kind: 'cached'、title与sessionListMetadata,updatedAt取lastPromptAt,缓存确实被查询,正文没有被读。api/session-controller/tests/session-projections.host.spec.ts:用sessions.fork造一个真实 fork 并写入 projcache,销毁整个 Context 后在同一存储根上重启 Host,只凭 header 通过session.list读到kind: 'cached'的title与sessionListMetadata,inspect/open一次都没被调用。api/session-controller/tests/projection-store.client.spec.ts:applyCached只填空位、不覆盖 sequenced 行;任何 sequenced 写入(包括 cursor-1的 frame 和 cut 更低的基线)替换 cached 行;基线先丢弃全部 cached 行再清理没带的键;面订阅在 cached 填充与丢弃时都收到通知。manager 路径:cached列表 block 的水位再高,也被 cut 更低的 control 基线替换,之后的列表刷新拿不回来;sequenced列表 block 按 higher-seq-wins,cut 更低的延迟基线既不覆盖也不清掉它,更高 seq 的 frame 仍能推进它。api/session-controller/tests/manager.client.spec.ts:api-session/added的cachedblock 被同 cursor 的 control 基线替换;cached列表 block 不覆盖已有的 sequenced title。api/session-controller/tests/inbox-projection.client.spec.ts:不变,活会话的sequenced列表 block 仍压过延迟到达、cut 更低的 control 基线。context/session-reference/tests/session-reference.spec.ts:seeded 冷会话按缓存 title 标注并可按 title 搜到,没有缓存记录的会话仍按 id 标注,两者都不读日志。