7.2 KiB
控制平面:身份、持久化、不变量
这是 ST-BME 最关键的一块。过去反复出现的 bug——"提取卡住"、"未进入聊天"、"reroll 乱召回"、"一致性审计永远说有漂移"——几乎全部源于这块的状态管理。本文档记录其结构和必须维持的不变量。
根本问题(历史背景)
早期实现里,身份、持久化确认、加载状态、图谱可写性、向量脏标记、召回复用,全都从多个异步事件路径读写同一份模块级可变状态(currentGraph、graphPersistenceState、pending 标志、commit marker)。
没有单一事实源,也没有把"做决定"和"写入"分开。每个修复都是在这些接缝上打补丁,于是每修一个就冒出下一个。
解决方向:把控制平面抽成纯逻辑/注入式模块,让整类 bug 在结构上不可能发生。
身份解析
聊天身份是一切持久化的主键。问题从来不是"chatId 这个键选错了",而是"有好几个来源都自称是当前身份,还互相偷偷顶替"。
身份核心在 runtime/identity-resolver.js,把身份明确按四类语义区分对待,绝不互相顶替:
| 类别 | 含义 | 来源 |
|---|---|---|
| active / current identity | 当前宿主活动聊天 | 只来自宿主上下文(context integrity / hostChatId 的已知别名 / hostChatId) |
| graph-owner identity | 图谱自带的所属身份 | 图谱 meta,只用于校验/恢复 |
| queued identity | 排队持久化的身份 | 持久化状态,只用于校验/恢复 |
| marker identity | commit marker 的身份 | commit marker,只用于校验/恢复 |
实现上:
- active/current 由独立入口
resolveCurrentChatIdentityCore()解析,只认宿主上下文来源。 - graph-owner 由
resolveGraphOwnerIdentityCore()解析。 - queued / marker 没有各自独立的解析入口——它们和 graph-owner 一起,在
resolveRuntimeGraphFallbackIdentityCore()/resolvePersistenceChatIdCore()这类恢复/兜底聚合里被读取(含persistenceState.queuedPersistChatId和persistenceState.commitMarker.chatId),并配合调用方的身份等值校验使用。
关键不在于"每类都有独立函数",而在于只有 active/current 这条通道能产出"当前聊天",其余通道一律只进校验/恢复,不能升格为活动身份。
核心不变量:
active identity 只能来自宿主上下文。graph-owner / queued / marker 身份只能用于校验和恢复,绝不能"偷偷"变成当前聊天。
这正是"未进入聊天"那类 bug 的根:旧代码用一个"优先级抽奖"函数接受十几个竞争来源,结果某个非活动身份被当成了活动聊天。现在 active/current 有专门入口,恢复/兜底身份走单独的 fallback 聚合,不给"非活动身份污染活动身份"留口子。
身份是每次操作解析一次、显式传递的,不是从全局随用随取。
持久化确认状态机
持久化确认逻辑收敛在 sync/persistence-reducer.js,是纯函数:无 IO、无图谱变更、无 UI 副作用。
它把"这批记忆到底存好了没"变成关于 (身份, 存储层 tier, 版本 revision, 证据) 的纯计算。核心不变量:
已确认版本 >= 排队版本
且 同一身份
且 是规范 tier(canonical:authority-sql / indexeddb / opfs / luker-chat-state)
⟹ pendingPersist 必须为 false
实现说明:这条不变量不是某个单一 reducer 事件全包的。
reducePersistenceStatePatch()处理通用ACCEPTED/QUEUED事件;buildAcceptedPersistenceStatePatch()在规范 tier 被接受时清pendingPersist(但它本身不查排队版本/身份);"陈旧 pending 自动清除"的规划逻辑在纯函数planAcceptedPendingClear()(sync/legacy-persistence-repair.js,经 reducer re-export);身份等值校验在调用方(index.js接受路径)完成后才调用应用函数。所以这条不变量 = 纯规划器 + 调用方身份门禁的合成,而非单个事件转换。
派生不变量:
recovery-only tier(
shadow/metadata-full/runtime-recovery等)永远不能推进确认状态。它们只用于灾难恢复,不能被当作"数据已安全落地"的证据。
当
lastAcceptedRevision >= max(batchRevision, queuedPersistRevision)且排队聊天与当前聊天一致时,陈旧的pendingPersist标志自动清除。
这条解决了"SQL 已确认 rev=2、但 pendingPersist 赖着不走、把提取一直卡死"的 bug。reducer 让"陈旧 pending 卡住提取"在结构上不可能发生。
历史上的语义修复(Phase 2 引入不变量、Phase 5 把调用点改为显式事件)都保留在该文件头注释里。
图谱可写性门禁
sync/graph-mutation-gate.js 决定"现在能不能改图谱",避免在加载中/恢复中/未进入聊天时误写。
关键判定(注入式 impl):
ensureGraphMutationReady— 操作前的总门禁getGraphMutationBlockReason— 给用户的暂停原因文案assertRecoveryChatStillActive— 异步恢复过程中,校验聊天没被切走(切走则抛 abort)getGraphPersistenceLiveState— 把内部状态投影成面板/调试可读形态
向量门禁与 reroll 边界
vector/vector-gate.js— 向量准备/修复前置门禁,决定 skip / repair / blocked / sync。runtime/reroll-transaction-boundary.js— reroll 召回复用事务边界。
reroll 不变量:
reroll 助手楼层时,若上方用户楼层未变且存在可复用的持久召回记录,则跳过预召回历史恢复和新向量检索;但被 reroll 的助手楼层的图谱回滚必须保留(走既有
onReroll路径)。
换句话说:召回注入可以复用,但图谱状态该回滚还得回滚。两者不能混为一谈——这是"reroll 乱召回"修复的核心。
副本一致性模型
Authority 场景下有三处存储,它们不是平级的版本副本:
| 存储 | 角色 |
|---|---|
| Authority SQL | 规范主源(canonical primary) |
| Blob checkpoint | 备份副本(backup replica) |
| Trivium | 搜索副本(search replica) |
不变量:
只有 Authority SQL 有可靠的图谱版本。当 SQL rev > Blob/Trivium rev 时,状态是"副本待同步",不是"数据漂移"。SQL 领先时不建议从 checkpoint 恢复(那会用旧数据覆盖新数据)。
checkpoint 生成时,若 SQL 是主存储层,必须以 Authority SQL 快照为源;SQL 导出失败/为空时,checkpoint 生成失败(
authority-sql-checkpoint-source-empty),绝不回退到可能陈旧的运行时图谱。
副本同步动作(checkpoint 写入、Trivium/向量同步)相互独立执行,一个失败不阻塞其余。
依赖注入接缝
控制平面模块通过一个 runtime 对象拿到所有依赖,由 index.js 的 create*Runtime() builder 提供。这有个隐患:模块"期望"的 runtime.X 必须全部被 builder 提供,否则运行时(尤其 fallback 路径)才炸。
tests/runtime-deps-completeness.mjs 守住这条线。详见 ../contributing/conventions.md 和 ../contributing/testing.md。