Files
ST-Bionic-Memory-Ecology/docs/architecture/control-plane.md

5.9 KiB
Raw Blame History

控制平面:身份、持久化、不变量

这是 ST-BME 最关键的一块。过去反复出现的 bug——"提取卡住"、"未进入聊天"、"reroll 乱召回"、"一致性审计永远说有漂移"——几乎全部源于这块的状态管理。本文档记录其结构和必须维持的不变量

根本问题(历史背景)

早期实现里,身份、持久化确认、加载状态、图谱可写性、向量脏标记、召回复用,全都从多个异步事件路径读写同一份模块级可变状态currentGraphgraphPersistenceState、pending 标志、commit marker

没有单一事实源,也没有把"做决定"和"写入"分开。每个修复都是在这些接缝上打补丁,于是每修一个就冒出下一个。

解决方向:把控制平面抽成纯逻辑/注入式模块,让整类 bug 在结构上不可能发生。

身份解析

聊天身份是一切持久化的主键。问题从来不是"chatId 这个键选错了",而是"有好几个来源都自称是当前身份,还互相偷偷顶替"。

身份核心在 runtime/identity-resolver.js,把身份明确拆成四条独立通道,绝不互相顶替:

通道 含义 唯一来源
active identity 当前宿主活动聊天 只来自宿主上下文context integrity / hostChatId 的已知别名 / hostChatId
graph-owner identity 图谱自带的所属身份 图谱 meta只用于校验/恢复
queued identity 排队持久化的身份 持久化队列状态,只用于校验
marker identity commit marker 的身份 commit marker只用于校验/恢复

核心不变量:

active identity 只能来自宿主上下文。graph-owner / queued / marker 身份只能用于校验和恢复,绝不能"偷偷"变成当前聊天

这正是"未进入聊天"那类 bug 的根:旧代码用一个"优先级抽奖"函数接受十几个竞争来源,结果某个非活动身份被当成了活动聊天。现在 resolveActiveIdentity / resolveGraphOwnerIdentity / resolveQueuedIdentity / resolveMarkerIdentity 是分开的入口,不给聚合入口诱导污染。

身份是每次操作解析一次、显式传递的,不是从全局随用随取。

持久化确认状态机

持久化确认逻辑收敛在 sync/persistence-reducer.js,是纯函数:无 IO、无图谱变更、无 UI 副作用。

它把"这批记忆到底存好了没"变成 (身份, 存储层 tier, 版本 revision, 证据) 的纯函数。核心不变量写死进 reducer而不是到处打补丁

已确认版本 >= 排队版本
  且 同一身份
  且 是规范 tiercanonicalauthority-sql / indexeddb / opfs / luker-chat-state
  ⟹ pendingPersist 必须为 false

派生不变量:

recovery-only tiershadow / 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.jscreate*Runtime() builder 提供。这有个隐患:模块"期望"的 runtime.X 必须全部被 builder 提供,否则运行时(尤其 fallback 路径)才炸。

tests/runtime-deps-completeness.mjs 守住这条线。详见 ../contributing/conventions.md../contributing/testing.md