# ADR-0011: 引擎 per-scope memory + Dream seam - 让记忆作用域可配, 服务端多 scope 隔离 - **Status**: Accepted (PM 拍板 2026-05-28) - **Date**: 2026-05-28 - **Deciders**: PM (产品经理) + Flyto Agent core team - **Related code**: `core/pkg/engine/engine.go` (Config.ScopeRoot / SessionProvider / modelsMu), `core/pkg/engine/dream.go` (DreamConfig.LockPath/StatePath), `core/pkg/engine/dream_lock.go`, `core/pkg/engine/dream_prompt.go`, `core/pkg/memory/memory.go` (NewFileStoreWithBaseDirAndOptions), `platform/common/quotedispatch/engine_factory.go` (DisableDream) - **Related TODO**: 引擎对外 SDK 化 (CLAUDE.md 中期计划) - per-scope 能力是服务端消费者真跑 Dream/memory 的前置 - **Related memory**: `project_dream_contradiction.md` / `project_engine_neutralization.md` / `feedback_agent_teams_review_mode.md` - **Related ADRs**: ADR-0005 (引擎中性化 - 公共 API 纪律 + 中性 transport 铁律是本 seam 的设计前提), ADR-0010 (engine module path go get - 同期为同一个新消费者做的接入基础设施, 本 ADR 是它的能力侧续) - **Commit chain**: `ca42e45` (Dream + memory per-scope seam) + `c9d1698` (Agent Teams 并发 race -> Config.modelsMu) + `6390517` (checkpoint nil-deref panic fix) + `5c86f0f` (plan_queue 单调 counter) + 本 ADR --- ## 1. 背景 / Context ### 1.1 新消费者 - 微信客服, 一仓几百群, 每群一独立 scope 出现新消费者: 微信客服场景. 飞驼一个仓有几百个客户微信群, 每个群 = 一个独立会话 scope, agent 通过群服务对应客户. 这个消费者要 per-scope 的记忆 (memory) + per-scope 的 Dream 巩固 - 不同客户群的记忆和 Dream 状态必须互不污染. A new consumer appears: a WeChat customer-service scenario. One Flyto warehouse runs hundreds of customer WeChat groups; each group is an independent conversation scope, and the agent serves that customer through the group. This consumer needs per-scope memory plus per-scope Dream consolidation - memory and Dream state across different customer groups must not cross-contaminate. ### 1.2 为什么引擎原状态跑不了服务端多 scope 引擎原 Dream (`core/pkg/engine/dream.go`) + memory (`core/pkg/memory`) 是**单用户单 FS** 假设: 全落 `~/.flyto/` (memory 目录 / `dream.lock` / `dream_state.json`). 这套假设服务于 CLI 单进程单用户, 但服务端 `platform/common` 一个进程要承载几百 scope, 套不上: The engine's original Dream + memory assumes a **single user with a single filesystem**: everything lands under `~/.flyto/`. That assumption fits the single-process single-user CLI, but the server (`platform/common`) carries hundreds of scopes in one process and cannot reuse it: - **跨 scope FS 污染**: 单一全局 FS 多 scope 共写, A 群的记忆漂进 B 群. - **全局 flock 冲突**: 单个 `~/.flyto/dream.lock` 被几百 scope 抢, 串成全局瓶颈. 正因如此, `platform/common/quotedispatch/engine_factory.go` 当前对所有服务端 engine 强制 `DisableDream: true` - 服务端干脆关掉 Dream 绕开这两个坑. 这是回避不是解决. For exactly this reason `platform/common/quotedispatch/engine_factory.go` currently forces `DisableDream: true` on every server-side engine - the server just turns Dream off to dodge both pitfalls. That is avoidance, not a fix. ### 1.3 PM 纠正 - 核心不是多租户隔离, 是 "记忆作用域可配" 第一直觉会把这个需求叙述成 "多租户隔离". PM 纠正: 核心不是多租户, 是 **"记忆作用域 (scope) 要可配"**. 引擎不该知道 "租户" 这个概念 (那是消费者业务语义), 它只需要提供一个**可配的记忆作用域锚点**, 让消费者按自己的 scope 切分边界即可. 这个 framing 直接决定了 § 2 的接口形态 (单字符串锚点, 不是租户对象 / 不是 provider). The first instinct is to frame this as "multi-tenant isolation". The PM corrected it: the core is not multi-tenancy, it is **"the memory scope must be configurable"**. The engine must not know what a "tenant" is (that is consumer business semantics); it only needs to expose a **configurable memory-scope anchor** so the consumer can carve boundaries by its own scope. This framing directly drives the interface shape in § 2 (a single string anchor, not a tenant object, not a provider). --- ## 2. 决策 / Decision 引擎加一个 **additive, optional** 的 per-scope seam: `engine.Config` 增 `ScopeRoot` + `SessionProvider`, Dream 与 memory 在 `ScopeRoot` 非空时按该锚点派生 per-scope 路径. **`ScopeRoot` 为空 = CLI 现行为逐字节不变**, 零破坏. The engine adds an **additive, optional** per-scope seam: `engine.Config` gains `ScopeRoot` + `SessionProvider`, and when `ScopeRoot` is non-empty both Dream and memory derive per-scope paths from that anchor. **Empty `ScopeRoot` == byte-for-byte current CLI behavior**, zero breakage. ### 2.1 ScopeRoot 是单字符串锚点 (最易被误改, 锁死) `engine.Config.ScopeRoot` 是**一个 string**, 不是一组路径字段, 也不是 provider. 引擎从这一个锚点派生全部 per-scope 路径: `engine.Config.ScopeRoot` is **one string**, not a set of path fields and not a provider. The engine derives all per-scope paths from this single anchor: | 派生物 | 路径 | |---|---| | memory 目录 | `/memory` | | Dream lock | `/dream.lock` | | Dream state | `/dream_state.json` | **为什么必须是单字符串** (防将来误改): 消费者每 scope 只传一个 base dir, 引擎内部负责 join 派生. 派生逻辑收在引擎一处, 永不漂移. 否决方案 (多 path 字段 / provider interface) 见 § 3. **Why it must be a single string** (guard against future edits): the consumer passes one base dir per scope, and the engine owns the join/derivation internally. The derivation lives in exactly one place inside the engine and can never drift. Rejected alternatives (multiple path fields / a provider interface) are in § 3. ### 2.2 配置层: ScopeRoot + SessionProvider 均 additive optional `engine.Config` 加两个可选字段: - `ScopeRoot string` - § 2.1 的锚点, 空走原 `cfg.Cwd` 行为. - `SessionProvider SessionProvider` - 透传进 `DreamConfig`. CLI 传 `&FileSessionProvider{Dir: transcriptDir}` 扫 JSONL; 服务端注入自己的 DB-backed provider (从 DB 查会话列表). 引擎只认 interface, 不认实现. `buildMemory` 在 `ScopeRoot` 非空时走 `/memory` 且**保留** memOpts; `buildDreamEngine` 在 `ScopeRoot` 非空时派生 dream 路径 + 透传 `SessionProvider`. 两条路径空 ScopeRoot 时完全回退原行为. ### 2.3 DreamConfig 加 LockPath / StatePath `DreamConfig` 加 `LockPath` / `StatePath` 两个可选字段, 空则 fallback 到 `~/.flyto` 原路径. `NewDreamEngine` 对 per-scope 目录做 `MkdirAll` (几百 scope 各自的目录首跑即建). 这让 Dream 的两个 FS 写入点 (lock + state) 都能被 per-scope 重定向. ### 2.4 memory 加 NewFileStoreWithBaseDirAndOptions (补构造空缺) memory 包原有两个构造无法表达 "per-scope baseDir + options" 的组合: - `NewFileStoreWithBaseDir(baseDir)` - 收 baseDir 但**没有** options. - `NewFileStoreWithOptions(cwd, opts...)` - 收 options 但 baseDir 经 `memoryDirForProject(cwd)` **派生成全局**, 拿不到裸 baseDir. 新增 `NewFileStoreWithBaseDirAndOptions(baseDir, opts...)` 解开 baseDir 与 options 的组合坑 - 服务端要给每个 scope 一个独立 baseDir **同时**带自己的 options. 这是 § 2.2 `buildMemory` 保留 memOpts 的前提. The memory package's two existing constructors could not express the "per-scope baseDir + options" combination. The new `NewFileStoreWithBaseDirAndOptions(baseDir, opts...)` unties that knot - the server needs each scope to have an independent baseDir **while** carrying its own options. ### 2.5 触发分层 - 引擎给中性触发, 中央调度归消费者 Dream 的触发能力**分两层**, 边界严格: - **引擎层 (内建, 中性)**: `DreamEngine.RecordSession()` + `DreamEngine.CheckAndRun(ctx)`. engine query-loop 结束时自动触发, 是引擎自带的中性钩子 - 任何消费者都能用, 不假设任何场景. - **消费者层 (不进引擎)**: 几百 scope 的**中央调度器** + **全局并发 cap**. 防几百个群同时 fire 几百个 LLM 调用的 fork storm - 这是服务端运维语义, 是微信客服项目自己的事, 引擎不该承载. Dream triggering is split into **two layers** with a strict boundary. The engine layer (`RecordSession` + `CheckAndRun`, auto-fired at query-loop end) is the built-in neutral hook any consumer can use. The central scheduler across hundreds of scopes plus a global concurrency cap - which prevents a fork storm of hundreds of simultaneous LLM calls - is server operations semantics and belongs to the WeChat consumer project, not the engine. ### 2.6 锁三层 - 各司其职, 不要合并 per-scope 化触碰三个并发点, 各有独立锁, **不要试图合一**: The per-scope work touches three concurrency points, each with its own lock; **do not try to merge them**: 1. **per-scope flock** (`dream_lock.go`): `FileLock` 已是 path-parameterized (`NewFileLock(path)`), 喂 per-scope 路径即可 - 每个 scope 一把锁, 天然不互抢. 这一层本来就在, 只是被 § 1.2 全局单锁的用法掩盖了. 2. **config.ModelRegistry RWMutex**: registry 内部自带 RWMutex, `SetRole` / 读已并发安全. 这一层不用动. 3. **engine.Config.modelsMu** (新加 `sync.Mutex`): 护 `Config.Models` 字段的 lazy init nil-check-then-assign. 这个**字段层**原本裸奔 (registry 内部锁护不到字段本身的赋值), Agent Teams 并发 spawn 共享 parent Config 时并发触发 data race. 详见 § 2.7. ### 2.7 Agent Teams 并发 race 用 mutex 不用 sync.Once (锁死) 第三层为什么用 `sync.Mutex` 而不是 `sync.Once`, 是个反直觉决策, 锁死防误改: 深查发现真正裸奔的只是 `Config.Models` 字段的 lazy init - `config.ModelRegistry` 本身的 RWMutex 已护内部读写. mutex 护字段赋值, **语义完全不变**: `SetRole` 每次照跑, registry 内部锁兜并发. 而 `sync.Once` 会让初始化只跑一次 - 但 `Model` 在 `engine.New` 里**可能在首次 `ModelRegistry()` 调用之后才定**, Once 会把错误的早期配置 freeze 死, 留下时序坑. mutex 每次重入照算正确状态, Once 不行. 这是**为什么是 mutex**. Why the third layer uses `sync.Mutex` and not `sync.Once` is a counter-intuitive decision, locked against future edits. A deep look showed the only un-guarded thing is the lazy-init of the `Config.Models` field - `config.ModelRegistry`'s own RWMutex already guards its internal reads/writes. The mutex guards the field assignment and **changes no semantics**: `SetRole` still runs every time. `sync.Once` would freeze initialization to a single run - but `Model` may be set in `engine.New` **after** the first `ModelRegistry()` call, so Once would freeze a wrong early config, leaving a timing bug. The mutex recomputes the correct state on every entry; Once cannot. --- ## 3. 替代方案 / Alternatives ### 3.1 多 path 字段 (DreamLockPath + DreamStatePath + MemoryDir 各自独立) - rejected **做法**: `engine.Config` 不加单 `ScopeRoot`, 而是加三个独立路径字段, 让消费者自己填齐. **否决**: 消费者每个 scope 都要重复 join 逻辑 (`/memory` / `/dream.lock` / `/dream_state.json`), 派生逻辑漂到消费者侧 N 份. 三个字段任一漏填 / 填错前缀, 三个 FS 写入点就会分散到不一致的位置, 难查. 单 `ScopeRoot` 把派生收在引擎一处, 永不漂. **Rejected**: each scope would repeat the join logic, scattering derivation into N copies on the consumer side; any single field omitted or mis-prefixed splits the three FS write points to inconsistent locations. A single `ScopeRoot` keeps derivation in one place inside the engine. ### 3.2 ScopePathProvider interface - rejected (over-engineered) **做法**: 抽象一个 `ScopePathProvider` interface, 由消费者实现 `MemoryDir() / DreamLockPath() / DreamStatePath()`. **否决**: over-engineered. 一个 base dir 字符串就够表达 per-scope 隔离了, 没有任何消费者需求要求路径派生策略本身可插拔. 引入 interface 只是给消费者制造无谓的实现负担, 换不来任何弹性. 接口化是 Flyto 的常用拳 (见 memory `feedback_interface_over_binary_choice`), 但这里**单值参数化就足够**, 强行接口化是反模式. **Rejected (over-engineered)**: a single base-dir string fully expresses per-scope isolation; no consumer requirement demands a pluggable path-derivation strategy. Interface-ifying only burdens the consumer with no flexibility gained. Parameterizing a single value is enough here. ### 3.3 引擎内置中央调度器 + 全局并发 cap - rejected (违中性化) **做法**: 把 § 2.5 的几百 scope 中央调度 + 并发 cap 做进引擎. **否决**: 调度策略 / 并发 cap 是服务端运维语义, 假设了 "一个进程承载多 scope 且要限流" 这个**特定部署形态**. 引擎不能假设部署形态 (中性化铁律, ADR-0005 延续). 引擎只给中性触发钩子 (`RecordSession` / `CheckAndRun`), 调度归消费者. 见 § 4.3. ### 3.4 第三层并发用 sync.Once - rejected (时序坑) **做法**: `Config.Models` lazy init 用 `sync.Once` 保证只初始化一次. **否决**: `Model` 可能在 `engine.New` 里首次 `ModelRegistry()` 之后才定, Once 会 freeze 错误的早期配置. 改 mutex, 每次重入照算正确状态, 语义零变. 完整论证见 § 2.7. 原 sync.Once 直觉作为替代方案记于此, 防将来 "Once 更省" 的误改回退. --- ## 4. 影响 / Consequences ### 4.1 得益 1. **服务端可开 Dream**: `platform/common` 不再被迫 `DisableDream: true` - 给每个 scope 一个 `ScopeRoot`, Dream + memory 各自隔离, 几百群互不污染. 2. **记忆作用域可配**: 兑现 PM 的 framing - 记忆边界由消费者按自己的 scope 切, 引擎不假设 "租户" / "群" 这些业务概念. 3. **Agent Teams 并发安全**: 第三层 `modelsMu` 修掉并发 spawn sub-agent 共享 parent Config 的 data race. 4. **CLI 零行为变化**: `ScopeRoot` 空 = 逐字节现行为, 单用户单 FS 路径完全不动. ### 4.2 不便 / 代价 1. **消费者要接 wiring 才真跑**: 引擎只给中性 seam (ScopeRoot + SessionProvider + 触发钩子). 微信客服专属 wiring (DBSessionProvider / 中央调度器 / scope_id 数据模型) 是消费者项目 (对方 repo) 的事 - 引擎落地后消费者侧仍有接入工作. 引擎可出 reference 示例 (§ 8 follow-up). 2. **服务端 Dream 初版质量稍薄**: transcript 物化走 "no-transcript" 降级 (见 § 4.4), 验证有价值后才升 "读会话原文" 厚版. 3. **memory 构造面扩大**: 新增第三个 FileStore 构造 (`...WithBaseDirAndOptions`), 三个构造并存. 对外公共 API 表面变大, 需保持 ADR-0010 § 2.5 "只增不改" 契约 (是增, 不破坏). ### 4.3 跟 ADR-0005 引擎中性化的关系 - 一脉相承 ADR-0005 把引擎重定位为中性 transport, 不假设任何 domain. 本 ADR 严守这条线: 引擎**只给中性 per-scope 能力** (一个可配的记忆作用域锚点 + 中性触发钩子), 微信客服场景的全部业务 wiring (从 DB 查会话 / 几百群调度 / "群" 数据模型) 都在消费者仓, 不进引擎. 引擎不能假设 "微信群" 这个场景 (CLAUDE.md 原则 9). § 3.3 否决内置调度器正是这条铁律的直接应用. ### 4.4 服务端 Dream 初版的渐进路线 `dream_prompt.go` 的 `BuildConsolidationPrompt(memoryRoot, transcriptDir, sessionIDs)` 把 "grep transcript" 块 **gated on `transcriptDir != ""`**. 服务端初版 `transcriptDir` 留空 -> 走 "no-transcript" 降级: Dream 靠 memory-drift + session-ID hint 巩固, 不读会话原文. 质量稍薄但**能跑且零成本** (不需要先建 transcript 物化管道). 验证 "服务端 Dream 有价值" 后, 再升级 "读会话原文" 厚版 (消费者补 DB-backed transcript 落盘 + 传 `transcriptDir`). 这是刻意的渐进 - 先证价值再投物化成本. --- ## 5. 验证 / Validation ### 5.1 per-scope 隔离单测 新增 `TestEngine_ScopeRoot_IsolatesMemoryAndDream` (`dream_test.go`): 两个用不同 `Config.ScopeRoot` 构造的 engine, 各自的 memory 与 Dream 状态写入**互不相交**的目录 (`/memory` + `/dream.{lock,_state.json}` 验 disjoint). ### 5.2 engine 套件 -race 全绿 engine 套件 `go test -race` 全绿. 本决策落地顺带修了 **3 个 pre-existing bug** - 它们长期被 `emitCheckpointSuggested` 的 nil-deref panic 掩盖 (panic 中断 test binary, 其后测试全不跑): | Bug | Commit | 内容 | |---|---|---| | checkpoint nil-deref panic | `6390517` | `newTestEngine()` 只设 observer 不设 cfg, `e.cfg=nil` 触发 panic (ADR-0005 Bug L 加的 cfg 读取暴露). 测试 setup 缺陷, 补 `cfg: &Config{}`, 不在生产加防御. | | plan_queue 字典序 | `5c86f0f` | `generatePlanID` 随机 hex 后缀同纳秒不保字典序, 改进程级单调 atomic counter (`%08x`). 原随机方案存 LEGACY 注释. | | Agent Teams race | `c9d1698` | § 2.6 第三层, `Config.Models` 字段 lazy init 裸奔, 加 `modelsMu`. panic 修后才暴露. | panic 修复 (`6390517`) 解除掩盖后, 字典序 fail (`5c86f0f`) 与 Team race (`c9d1698`) 才浮出水面 - 三者是一条因果链. --- ## 6. 触发重新评估的条件 / Trigger conditions 1. **隔离维度超出 FS**: 若 per-scope 不止要隔离文件路径, 还要隔离 ModelRegistry / provider 凭据 (e.g. 每个客户群用不同 API key), 单 `ScopeRoot` 字符串不够, 重新评估是否要 § 3.2 的 provider 形态. 2. **厚版 transcript 升级**: 服务端 Dream 升 "读会话原文" 厚版时, 若 `SessionProvider` interface (只给 session-ID 列表) 不足以表达 "按 scope 拉 transcript 内容", 重审 provider 契约 (§ 2.2 / § 4.4). 3. **引擎内置调度需求出现**: 若多个消费者都要 "几百 scope 限流调度" 且消费者侧各自重造, 重审 § 3.3 否决 - 是否该把一个中性并发 cap 钩子 (非场景调度器) 上移进引擎. 4. **FS backend 不够**: 若 scope 状态要落对象存储 (S3) 而非本地 FS (e.g. 多 replica 共享), `ScopeRoot` 作为本地路径前提失效, 重新评估 store backend 抽象. 5. **跨进程多 replica 写同一 ScopeRoot**: 若两个 replica 进程指向同一 `ScopeRoot` (共享卷), per-scope flock 的进程内语义不够, 锁需升级到 fcntl / 外部分布式锁 (对齐 ADR-0003 multi-replica 物理事实). --- ## 7. 工程量 / Engineering footprint | Commit | 内容 | 范围 | |---|---|---| | `ca42e45` | Dream + memory per-scope seam | `engine.go` (Config.ScopeRoot + SessionProvider + buildMemory/buildDreamEngine 派生) + `dream.go` (DreamConfig.LockPath/StatePath + MkdirAll) + `memory.go` (NewFileStoreWithBaseDirAndOptions) + `dream_test.go` (隔离单测). 4 文件 +264/-5. 全 additive 可选字段. | | `c9d1698` | Config.modelsMu 修 Agent Teams race | `engine.go` +25. § 2.6 第三层锁. | | `6390517` | checkpoint nil-deref panic fix | `checkpoint_test.go`. 测试 setup 补 cfg, 解除套件掩盖. | | `5c86f0f` | plan_queue 单调 counter | `plan_queue.go`. 随机后缀 -> atomic counter, 保字典序. | | 本 ADR | 决策记录 | doc | **关键约束**: 全部 4 commit 的引擎改动 (除 test fix) 均为 additive optional 字段 / 新构造 / 新锁, `ScopeRoot` 空时 CLI 路径逐字节不变. 消费者侧 wiring (DBSessionProvider / 调度器 / scope 数据模型) **不在本工程量内** - 那是微信客服项目对方 repo 的事 (中性化边界, § 4.3). --- ## 8. 修订记录 / Revision history - **v1.0 (2026-05-28)**: 初版. 新消费者 (微信客服, 一仓几百群每群一 scope) 触发. 引擎加 additive optional per-scope seam: Config.ScopeRoot (单字符串锚点派生 memory + dream lock/state 路径) + SessionProvider, ScopeRoot 空 = CLI 逐字节不变. PM 纠正 framing 为 "记忆作用域可配" 而非多租户隔离. 否决多 path 字段 / ScopePathProvider interface (over-engineered) / 引擎内置调度器 (违中性化) / sync.Once (时序坑) 四方案. 锁三层 (per-scope flock / ModelRegistry RWMutex / 新加 Config.modelsMu), 第三层 mutex 不用 Once 锁死. 服务端 Dream 初版走 no-transcript 降级渐进. 落地 `ca42e45` + 顺带修 3 pre-existing bug (`6390517` panic -> `5c86f0f` 字典序 -> `c9d1698` Team race 因果链). engine 套件 -race 全绿. 注: `ca42e45` commit body 内的 "ADR-0010" 前向指针写于编号分配前, 本 ADR 实际编号为 0011.