[架构提案] 引入生命周期所有的模型运行时 Generation,原子发布 Provider / Auth / Catalog / Capabilities
- Dominant language
- TypeScript
- Stars
- 2.7k
- Forks
- 395
- Avg merge
- 21h 48m
- Merged PRs (30d)
- 776
Description
# [架构提案] 引入生命周期所有的模型运行时 Generation,原子发布 Provider / Auth / Catalog / Capabilities
## 摘要
希望 Cindy 在现有“实时模型发现 + active catalog + revision”基础上,再补一层**生命周期所有的模型运行时 Generation(代际快照)**:
- provider 配置、当前账号凭证边界、实时模型目录、路由描述符、两种 Agent capabilities 必须先在候选代中完整构建;
- 只有整代成功后才原子替换当前生效代;
- picker、IM `/model`、Scheduler、Hook、Worker、标题模型、实际 turn 路由等消费者只读同一代快照,不再各自在调用时拼接或读取不同世代的事实;
- turn 需要可获取一个短生命周期 lease,从同一代中 fork 出可变的请求态,避免共享可变 auth / registry 对象;
- auth / config / provider CRUD / discovery 回调只负责发出失效事件,由唯一 owner 串行重建和发布。
这不是“把静态模型列表改为实时接口”这么简单。Cindy 已经具备实时发现;本 issue 要解决的是**实时发现结果的所有权、原子性、代际一致性、热路径成本和并发失效**。
## 上游能力的准确含义
OpenClaw 本轮对应的核心改动是:
- [`openclaw/openclaw#111173`](https://github.com/openclaw/openclaw/pull/111173)
- 主提交:[`06f5f73e47`](https://github.com/openclaw/openclaw/commit/06f5f73e473196624e51960d087af211304b8458)
它并非首次加入 provider live discovery。OpenClaw 原来已经会做模型发现,但 auth store、provider registry、projected catalog 被多个调用路径反复重建,并各有缓存,导致:
1. 请求热路径反复做文件系统、插件和凭证发现;
2. 新 auth store 可能搭配旧 registry / catalog;
3. session status、cron、doctor、TUI、媒体子任务、agent run 可能看到不同世代;
4. config/auth 并发变化时,旧构建结果可能晚到并覆盖新状态。
OpenClaw 的新结构是:
```text
startup / config reload / plugin publication / auth mutation
│
▼
lifecycle owner(每个 agent)
│
构建完整 candidate generation
auth template + model registry + catalog
│
全部成功后一次性 publish
│
┌──────────────┴──────────────┐
▼ ▼
browse/status/UI 读共享不可变快照 turn 获取 lease
fork mutable stores
```
其快照核心形状可概括为:
```ts
type PreparedModelRuntimeSnapshot = Readonly<{
agentId?: string
agentDir: string
workspaceDir?: string
config: RuntimeConfig
metadataSnapshot: PluginMetadataSnapshot
modelCatalog: ModelCatalogSnapshot
createStores: () => {
authStorage: AuthStorage
modelRegistry: ModelRegistry
}
}>
```
关键原则:
- **共享的是不可变事实**:credentials 的已解析模板、registry 基线、catalog 投影。
- **每次运行 fork 可变态**:API key 注入、session extension、请求级 registry 变化不能污染全局模板。
- **先 stale,再重建,再整代发布**:replacement gate 阻止读者看到“新 auth + 旧 catalog”一类混合态。
- **旧构建不能覆盖新代**:owner generation / request epoch / superseded error 共同丢弃迟到结果。
- **按 owner 串行**:同一 owner 的 standalone activation 和 refresh 不并发争抢发布权。
- **热路径不做 discovery**:browse/status/cron/doctor/TUI/PDF/image/turn 都消费已发布快照。
上游给出的基准(Node 24,500 次操作):
- request-time discovery:p50 `1027.666 µs`,p95 `1094.410 µs`
- lifecycle snapshot:p50 `3.349 µs`,p95 `5.864 µs`
- p50 约 `306.81x`;一次性生命周期构建约 `40.479 ms`
这个数字不能直接当 Cindy 的性能目标——Cindy 当前 `getActiveCatalog()` 已经是同步内存读取——但它证明“准备一次、运行期复用”在复杂 provider/runtime 中有实质收益。
## Cindy 当前已经具备的基础
这不是从零开始,现有结构已有以下正确能力:
1. `packages/model-providers/` 已收敛 provider / model / routing 的数据结构和纯函数。
2. `apps/desktop/src/main/maker-host/active-catalog.ts` 已是进程级 active catalog holder,热路径同步读取。
3. Anthropic、OpenAI/Codex、XD 分别已有动态事实源:
- Anthropic:HTTP `/v1/models` + SDK `supportedModels()` + 授权 generation + 磁盘缓存;
- OpenAI/Codex:app-server `model/list` + `models_cache.json`;
- XD:model-access 网关 `/models` 权威清单。
4. makecindy/cindy#63 / PR makecindy/cindy-temp#125 已加入 monotonic revision,并在 main 侧先刷新 Maker capabilities,再广播 provider change。
5. `apps/desktop/src/renderer/lib/localCatalogSnapshot.ts` 已联合刷新 providers + 两份 capabilities,并丢弃乱序结果。
6. PR makecindy/cindy-temp#225 已补齐“存量已登录 + 空 `models_cache`”启动时 live backfill。
7. Anthropic discovery 已有 auth generation、single-flight、迟到结果作废、退化快照护栏和缓存写删串行化。
因此本 issue 不应重写这些 provider mapper,也不应退回静态模型清单;应把现有成熟能力纳入一个统一生命周期。
## 当前仍存在的结构性缺口
### 1. active catalog 是“多个可变片段 + 多个 setter”,不是完整不可变 generation
当前 `active-catalog.ts` 分别持有:
- `base`
- `custom`
- `discoveredCodex`
- `discoveredByProvider`
- `xdGatewayModels`
- `anthropicModels`
每个 setter 都单独 `markChanged()`,立即递增 revision、重算 merged、刷新 capabilities 并广播。
这保证了“一次 setter 之后 catalog 与 capabilities 对齐”,但没有保证一次业务事件的所有相关变化只发布一次。例如启动流程中:
1. `setActiveCatalog(catalog)` 先发布一代;
2. 读取 Codex cache 后 `setDiscoveredCodexModels(...)` 再发布一代;
3. Anthropic 磁盘缓存 / HTTP 回调后继续发布;
4. custom provider、XD 网关清单、账号态可能再独立发布。
中间每一代都是内部自洽的 catalog,但不一定代表完整业务边界,且会制造不必要的 capabilities 重投影和 UI refetch。
### 2. revision 只覆盖 catalog/capabilities,没有覆盖 auth / connection / route generation
`ProviderService` 的连接态通过实时 reader 读取,`provider-route.ts` 又在 turn 热路径读取 `getActiveCatalog()` 和 safeStorage/OAuth token。于是消费者可能在同一次操作中看到:
- 新连接态 + 旧模型目录;
- 新目录 + 尚未完成切换的 app-server / token;
- 新 routing descriptor + 旧会话执行单元;
- 登出已经完成,但旧 discovery 回调稍后又尝试发布。
Anthropic 自己已经用 `authGeneration` 防住一部分,但 Codex、XD、generic OAuth、custom provider 各自实现不同的 `shouldApply` / single-flight / fallback,缺少统一 owner。
### 3. publication 触发点分散,规则难以证明完整
当前 model runtime 变化分散在:
- `createDesktopProviderService.ts`
- `maker-host/index.ts`
- `maker-ipc/auth.ts` / `authHandlers.ts`
- `maker-ipc/register.ts`
- `model-discovery/anthropic.ts`
- `model-access/index.ts`
- custom provider CRUD
- account switch / DB reopen / app-server restart
新增 provider 或新增 auth 路径时,很容易忘记刷新其中一层,#124 正是这类同步遗漏的典型结果。
### 4. 运行路径仍依赖进程全局“当前目录”,缺少 turn admission lease
当前路由和一些工具在执行时直接调用 `getActiveCatalog()`。即使 session.model 本身不被目录刷新修改,同一 turn 内不同阶段仍可能跨过 catalog revision。
需要明确:
- picker / 新会话创建读哪一代;
- turn 开始后是否固定一代;
- turn 执行中 provider/auth 变化时是继续、失败还是下一 turn 重绑;
- 旧 session 所选模型被新目录移除时如何保留明确错误,而不是静默换模型;
- 不允许把旧 secret 长期封进 session snapshot。
### 5. 诊断只能看“现在的合并结果”,看不到来源世代和降级原因
目前较难统一回答:
- 该模型来自 bundled、OSS、Codex live、disk cache、Anthropic HTTP、SDK、XD gateway 还是 user provider?
- 当前显示的是 fresh、last-known-good、static-only、degraded 还是 auth-mismatch?
- 哪个事件让 generation 失效?
- 某个旧 discovery 结果为何被丢弃?
- provider 列表、capabilities 和 route descriptor 是否真的是同一代?
## 目标设计
### A. 增加唯一生命周期协调器
建议新增类似:
```text
apps/desktop/src/main/maker-host/model-runtime/
generation-manager.ts
generation-builder.ts
generation-types.ts
generation-events.ts
generation-diagnostics.ts
```
核心 API 可参考:
```ts
prepareModelRuntimeGeneration(input): Promise
publishModelRuntimeGeneration(candidate): PublishedGeneration
invalidateModelRuntime(scope, reason): void
refreshModelRuntime(scope, reason): Promise
getPublishedModelRuntime(scope): PublishedGeneration | undefined
acquireTurnModelRuntime(scope): Promise<{ snapshot; release() }>
```
所有 auth/config/provider/discovery 代码只发 mutation event,不直接改全局 active state。
### B. Generation 必须包含哪些事实
建议至少包含:
```ts
interface ModelRuntimeGeneration {
id: number
ownerKey: string
accountId: string
createdAt: number
reason: string
catalog: Catalog
providerViews: ProviderView[]
capabilities: {
'claude-code': ModelDescriptor[]
codex: ModelDescriptor[]
}
routes: ReadonlyMap
auth: ReadonlyMap
sources: ReadonlyArray<{
source: string
status: 'fresh' | 'cached' | 'static' | 'degraded' | 'unavailable'
fetchedAt?: number
errorCode?: string
}>
createTurnContext(): {
// 每个 turn 独立,避免共享可变 token/registry/session extension
authContext: MutableAuthContext
routeRegistry: MutableRouteRegistry
}
}
```
注意:
- snapshot 中不保存明文 API key / access token;保存 auth method、credential epoch 和受控读取句柄即可。
- token refresh 可以更新凭证存储,但必须递增 credential epoch 并触发兼容的 auth publication;旧 lease 不得把旧 token 长期固化。
- `Catalog`、`ProviderView[]`、capabilities、routes 必须由同一 candidate 一次派生,不能 publish 后再补。
### C. Owner key 与隔离边界
本地第一阶段建议 owner 至少按以下边界隔离:
```text
local user/account + runtime credential epoch + desktop instance
```
同一 generation 同时产出 Claude/Codex 两份 capabilities,可避免跨 tab 模型投影再次漂移。
后续 SSH 远程能力(已有 makecindy/cindy#65)应使用独立 owner:
```text
remoteHostId + remote account/auth fingerprint + daemon generation
```
不能把本机 generation 借给远端 session。#130 可继续负责远端 discovery/protocol,本 issue 只把 generation manager 预留为可多 owner。
### D. 构建与发布事务
建议 publication 流程:
```text
mutation event
→ 同步标记 owner stale / 创建 replacement gate
→ per-owner FIFO / single-flight 构建 candidate
→ 并行读取相互独立的 sources
→ 校验账号/credential epoch/config epoch 是否仍匹配
→ 纯函数合并 catalog
→ 同一 candidate 派生 providerViews/capabilities/routes
→ 全部成功后 CAS/epoch check
→ 一次性替换 published generation
→ 广播一个 generation id
```
迟到 candidate 必须以 `superseded` 结束,不能覆盖当前代。
### E. 读语义
1. **picker / 设置 / IM `/model` / Scheduler / Hook / Worker 创建**:只读当前 committed generation。
2. **renderer**:最好由一个 IPC 返回 `{ generationId, providers, capabilities }`,从协议上消除“三次 IPC 恰好对齐”的假设;现有 `localCatalogSnapshot.ts` 可作为兼容层逐步简化。
3. **turn admission**:开始 turn 时 acquire lease;本 turn 的模型校验、route descriptor、effort/fast 能力都从该 lease 读取。
4. **运行中 auth 变化**:不允许继续使用已失效 secret。当前 turn 可按 provider 能力选择中断或完成;下一 turn 必须重绑最新 compatible generation。
5. **模型下架**:不能静默切到第一项。存量 session 保留 model/provider id;下一 turn 若无兼容 route,返回明确 `model unavailable in generation N`,由用户选择新模型。
### F. startup 采用“两段式准备”,不要把广泛 live discovery 变成启动硬依赖
OpenClaw 合入 #111173 后又通过 [`#112262`](https://github.com/openclaw/openclaw/pull/112262) 修正:Gateway 启动时执行广泛 live catalog,会因慢 provider / 同步 plugin 阻塞 event loop,最终 `prepared model runtime publication timed out`。
Cindy 建议:
1. splash / Maker readiness 先发布 **startup generation**:bundled/OSS 元数据 + 账号匹配的 last-known-good cache + 已配置 provider 基本事实;
2. UI 和健康检查先可用;
3. 后台做 live discovery;
4. live candidate 成功后原子替换;
5. 如果 auth scope 不匹配,禁止使用上一账号 cache,宁可空/降级。
不要要求所有 provider live endpoint 成功后应用才 ready。
## 建议改造映射
### `active-catalog.ts`
- 从“多段全局可变状态 + setter”收敛为纯函数 builder:
`buildCatalogFromSources(sourceSnapshot): Catalog`。
- 保留兼容的 `getActiveCatalog()`,但它只返回 manager 当前 published generation 的 `catalog`。
- 逐步删除可由任意模块调用的 `setDiscovered*`。
### discovery 模块
- `model-discovery/anthropic.ts`
- `codex-model-discovery.ts`
- `model-access/index.ts`
- generic OAuth / custom provider fetch
改为返回带来源信息的结果,不直接 publish:
```ts
type DiscoveryResult = {
authEpoch: number
sourceEpoch: number
fetchedAt: number
freshness: 'live' | 'cache'
value: T
}
```
Anthropic 现有 authGeneration / shrink guard / cache queue 保留,但最终 apply 权交给 generation manager。
### `createDesktopProviderService.ts`
- 从 catalog 初始化器升级为 lifecycle owner 的 desktop wiring;
- startup、account switch、custom CRUD、login/logout、app-server restart 统一映射成 mutation reason;
- 不再由各 handler 手工决定“刷新 catalog、刷新 capabilities、再发哪个广播”。
### Maker capabilities
- `deriveAvailableModels(catalog, agent)` 在 candidate build 内同时计算;
- publish 后再一次性更新 Maker 内存引用;
- 不允许 provider/capabilities 分两阶段落地。
### `provider-route.ts`
- 新 turn 从 lease/snapshot 读取 prepared route;
- 暂时无法一次改完时,可先让 `getActiveCatalog()` 和 credential epoch 都从同一 generation manager 获取,并加入 generation assertion;
- custom provider key 仍经 safeStorage reader 读取,不能把 key 放进 catalog。
### IPC / renderer
- 新增单一 snapshot IPC / push:`MODEL_RUNTIME_GENERATION_CHANGED`;
- payload 只带 `generationId` 和必要诊断摘要,renderer 再一次拉完整联合快照,或 push 完整小快照;
- 旧 generation 的 fetch 结果全部丢弃。
## 分阶段落地建议
### Phase 0:证据与可观测性
- 给当前 catalog revision、auth epoch、provider source 加统一日志;
- 记录每次 rebuild 原因、耗时、source 状态、模型数、被 supersede 次数;
- 做一次 benchmark,证明当前热路径没有 fs/network discovery,并建立后续 no-regression 基线。
### Phase 1:本地不可变 Generation(不改协议)
- 新增 generation manager;
- 把现有 6 类 mutable source 收进 candidate;
- catalog + providerViews + capabilities 一次派生、一次发布;
- 旧 IPC 暂时从 generation 投影视图,保持 renderer/API 兼容。
### Phase 2:Auth / Config 原子 publication + turn lease
- auth mutation、provider CRUD、account switch、app-server restart 全部走 owner;
- `provider-route`、Worker、Scheduler、Hook、IM、title model 等主路径改读 snapshot;
- turn admission 引入 lease / generation id;
- 删除分散 refresh/broadcast 手工代码。
### Phase 3:远程 owner
- 与 makecindy/cindy#65 协作,为每个 `remoteHostId + agentKind` 发布独立 generation;
- device-link 继续消费被控端事实源,不把控制端本地目录混入远端。
## 必须吸收的上游回归经验
OpenClaw 的 lifecycle-owned catalog 方向正确,但合入后很快出现三类回归,Cindy 实现时必须提前覆盖:
1. **首次启动 auth mutation 早于 owner 注册**
[`#111701`](https://github.com/openclaw/openclaw/pull/111701):旧 mutation 被重放后立刻 stale 掉第一代,导致 startup timeout。
要求:没有任何已发布/待发布 owner 被实际 invalidated 时,不排队“补偿 refresh”。
2. **config reload 与 catalog read 竞争**
[`#112026`](https://github.com/openclaw/openclaw/pull/112026):新建聊天恰逢 config publication 时读到 owner replaced。
要求:使用 typed superseded signal;只在确认 config epoch 已前进时重试,不能吞掉一般错误或无限循环。
3. **live discovery 阻塞启动/健康检查**
[`#112262`](https://github.com/openclaw/openclaw/pull/112262):广泛 live catalog 工作变成 mandatory startup,慢 provider 拖死 readiness。
要求:startup static/cached generation 与后台 live generation 分离;live hook 不得成为启动硬依赖。
## 验收标准
### 一致性
- [ ] 任意时刻对外可见的 provider views、Claude capabilities、Codex capabilities、route descriptors 有同一个 `generationId`。
- [ ] renderer 不会出现“provider 有模型但 capabilities 没有”或相反的帧。
- [ ] 同一个 turn 内模型校验、effort/fast 判断和实际路由来自同一 generation。
- [ ] 显式模型失效时绝不回退 `models[0]`。
### 并发与账号边界
- [ ] login → logout → 旧 login discovery 晚到,旧结果不能复活。
- [ ] A 账号 discovery 在途时切到 B,A 的 cache/result/route 不能进入 B generation。
- [ ] custom provider CRUD、OSS catalog reload、Codex live refresh、XD `/models` 同时完成时,只发布最终有效 candidate;旧 candidate 标记 superseded。
- [ ] auth mutation 发生在首个 owner 创建前,不会制造自我失效或启动超时。
- [ ] config reload 与新会话/turn admission 并发时,只重试 typed superseded,且有次数/epoch 边界。
### 失败与降级
- [ ] 单个 live source 失败不发布半成品;按 provider 明确选择 last-known-good、static-only 或 unavailable。
- [ ] last-known-good 必须验证 account/auth scope;不匹配时 fail closed。
- [ ] startup 不等待广泛 live discovery,慢/卡死 provider 不影响 splash、主窗口和健康探测。
- [ ] generation build 超时后,真实后台构建完成前不得与替代构建并发写同一 owner。
### 生命周期与消费方
- [ ] Settings、picker、IM `/model`、Scheduler、Hook、Worker、title model、turn route 均不自行 discovery。
- [ ] 目录/凭证变化只通过统一 mutation event 进入 publication。
- [ ] 已有 session 的 provider/model id 不被目录刷新静默改写。
- [ ] turn lease release 不会删除已替换的新 generation。
### 性能与诊断
- [ ] picker/status/route 热路径为纯内存读取,不发生 fs/network/plugin discovery。
- [ ] 有 generation build/publish/supersede/fail 指标和结构化日志。
- [ ] 可从诊断页/日志看到每个 provider 的来源、freshness、auth epoch、模型数和最近失败原因。
- [ ] 有 cold start、warm path、100 次连续 auth/config mutation 的压力测试。
## 非目标 / 相关 issue
- 不在本 issue 解决非 chat 模型能力分类;见 makecindy/cindy#101。
- 不在 Phase 1 直接解决 SSH 远端模型发现;见 makecindy/cindy#65,但 manager 必须预留多 owner。
- 不删除 bundled/OSS 产品元数据。实时发现决定“是否存在/可用”,产品目录仍可负责展示名、排序、默认可见性等 overlay。
- 不把 secret 写进 renderer payload、catalog 或不可变 snapshot。
- 不做 picker 打开时实时请求 provider;刷新属于生命周期事件,不属于 UI 热路径。
## 建议测试矩阵
| 场景 | 预期 |
|---|---|
| fresh install,无 cache,provider live 很慢 | 应用先 ready;发布 static/cached generation;live 后台替换 |
| Codex 已登录,无 `models_cache` | backfill 成功后整代替换;期间不发布“auth connected + 空旧 catalog”的混合代 |
| Anthropic SDK 返回退化列表 | 保留当前代并触发 HTTP 仲裁;不发布退化 candidate |
| XD gateway 拉取失败 | XD source 标 degraded/unavailable;不拿 OSS 静态模型冒充实时可用 |
| 登录过程中立刻登出 | 旧请求 superseded;缓存写回和 generation publish 都被 auth epoch 拒绝 |
| config reload 时打开新会话 | 读者等待或 typed retry;不报普通 catalog error,不无限重试 |
| provider 删除时有运行中 turn | 当前 turn 按 lease 策略完成/明确中断;下一 turn 不静默换模型 |
| 两个窗口同时 refetch | 只接受最新 generation;providers/capabilities 同帧提交 |
## 参考资料
- OpenClaw lifecycle owner 主 PR:
- OpenClaw 源码:
- `src/agents/prepared-model-runtime.ts`
- `src/agents/prepared-model-runtime.owner.ts`
- `src/agents/prepared-model-catalog.ts`
- `src/agents/prepared-model-registry.ts`
- OpenClaw 回归修复:
-
-
-
- Cindy 已有基础:
- makecindy/cindy#63:provider catalog 与 capabilities 同步
- PR makecindy/cindy-temp#225:Codex 已登录但空 cache 的启动 backfill
- makecindy/cindy#65:SSH per-host 模型发现
- makecindy/cindy#101:自定义 provider 非 chat 模型能力护栏
## 最终判断
Cindy **不需要照搬 OpenClaw 的 agentDir/workspace owner 细节**,因为 Cindy 的执行架构、Electron host、双 Agent 和远程模型不同;但应该吸收其核心不变量:
> discovery 是生命周期构建工作;运行路径只消费一代完整、不可变、可追溯的模型运行时快照。
现有 active-catalog/revision 是很好的第一步。本 issue 的价值是把“目录与 capabilities 对齐”提升为“账号凭证、provider、目录、能力和路由整代一致”,并让未来新 provider、远程 host、移动控制端不再重复踩分散刷新和世代竞争的问题。
Contributor guide
Research direction
Start by reading apps/desktop/src/main/maker-host/active-catalog.ts, createDesktopProviderService.ts, provider-route.ts, and the listed discovery modules. Map the current publication and consumer paths before attempting the phased design. Done means the agreed lifecycle owner can atomically publish a generation containing catalog, capabilities, auth state, and routes, with consumers using consistent snapshots.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend-api-design, desktop
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100