docs: SPEC v1.1 — 同步实施结果 + Bug 修复记录 + Cloud→CLI 链路优化

- 状态从 Draft → 已实施(Phase 1-4 完成)
- §1.1 改为改造前状态(保留作设计依据)
- §3.5 文件结构更新为实际行数(1928 行/10 文件)
- §5 路线图标记完成状态 + 实际执行顺序 3→1→2→4
- §7 决策记录新增 D7-D11(执行顺序/pipx 检测/防降级/cli_info/主 loop 复用)
- §8 新增:实施过程中发现并修复的 6 个 Bug
- §9 新增:Cloud→CLI 工具调用链路优化(可观测性+快速路径+handler 复用)
This commit is contained in:
lidf 2026-07-01 17:25:49 +08:00
parent 37a26ac674
commit 5aa8fbc284

View File

@ -1,19 +1,22 @@
# SPEC: Mind CLI 无状态原子化改造 # SPEC: Mind CLI 无状态原子化改造
> **版本**: v1.0 > **版本**: v1.1已实施
> **日期**: 2026-07-01 > **日期**: 2026-07-01
> **状态**: Draft — 待确认后执行 > **状态**: ✅ Phase 1-4 已完成并部署,附加 Cloud→CLI 链路优化
> **适用范围**: MindOS_CLI (`mindcli/` 薄壳层,不含 `_vendor/`) > **适用范围**: MindOS_CLI (`mindcli/` 薄壳层,不含 `_vendor/`) + Cloud 端 bridge
> **前置依赖**: `SPEC_mindos_next_cli.md` v2.3(设计哲学与三铁律) > **前置依赖**: `SPEC_mindos_next_cli.md` v2.3(设计哲学与三铁律)
> **参照模式**: `hermes-overlay/infra/pipelines/anyfile2md.py`(无状态管线组合器) > **参照模式**: `hermes-overlay/infra/pipelines/anyfile2md.py`(无状态管线组合器)
> **当前版本号**: mindcli 0.2.1 / vendor hermes@16f9d020
--- ---
## 一、改造动机 ## 一、改造动机
### 1.1 前状态:有状态单体 ### 1.1 改造前状态:有状态单体
`mindcli/` 薄壳层7 个文件 / 1633 行)当前是**进程级有状态单体**,具体表现: > 以下描述的是改造前的状态v0.1.0),保留作为设计依据。改造后的实际状态见 §四。
`mindcli/` 薄壳层7 个文件 / 1633 行)原是**进程级有状态单体**,具体表现:
| 模块 | 状态性 | 行数 | 问题 | | 模块 | 状态性 | 行数 | 问题 |
|:---|:---|---:|:---| |:---|:---|---:|:---|
@ -353,36 +356,35 @@ def ask(question, model, fmt):
**vendor `cli.py` 的 TUI 只在"离线独立模式"(铁律 C 的断开即自治)时才拉起**,作为 `mind chat --offline` 的备选路径。 **vendor `cli.py` 的 TUI 只在"离线独立模式"(铁律 C 的断开即自治)时才拉起**,作为 `mind chat --offline` 的备选路径。
### 3.5 改造后文件结构 ### 3.5 改造后文件结构(实际)
``` ```
mindcli/ mindcli/
├── __init__.py # 22 行,不变 ├── __init__.py # 22 行,__version__ = "0.2.1"
├── __main__.py # 5 行,不变 ├── __main__.py # 5 行,不变
├── cli.py # ~250 行chat/ask 改走 run_agent ├── cli.py # 437 行chat/ask 走 run_agent + --offline 备选
├── health.py # ~350 行,持有 handles状态枢纽 ├── health.py # 334 行,持有 _tunnel_handle + _active_captures
├── service.py # 116 行,不变 ├── service.py # 116 行,不变
├── capability.py # 79 行,不变 ├── capability.py # 86 行_get_vendor_commit 修复为逐行解析
├── pipelines/ # ★ 新增目录 ├── pipelines/ # ★ 新增
│ ├── __init__.py │ ├── __init__.py # 10 行
│ ├── audio_capture.py # ~200 行,从 recorder.py 抽出 │ ├── audio_capture.py # 460 行capture() + CaptureHandle双工可用
│ ├── tunnel_session.py # ~150 行,从 tunnel.py 抽出 │ ├── tunnel_session.py # 274 行connect() + TunnelHandle回调解耦
│ └── tool_proxy.py # ~120 行,从 managed_mcp.py 抽出 │ └── tool_proxy.py # 183 行ToolProxy非单例
└── _vendor/ # 不变 ├── _vendor/ # 不变vendor hermes@16f9d020
└── (已删除)
├── recorder.py # → 逻辑迁入 pipelines/audio_capture.py
├── tunnel.py # → 逻辑迁入 pipelines/tunnel_session.py
└── managed_mcp.py # → 逻辑迁入 pipelines/tool_proxy.py
``` ```
**删除的文件**(逻辑迁入 pipelines/ 后):
- `recorder.py` → 逻辑迁入 `pipelines/audio_capture.py`pyobjc delegate 类随之迁入
- `tunnel.py` → 逻辑迁入 `pipelines/tunnel_session.py`
- `managed_mcp.py` → 逻辑迁入 `pipelines/tool_proxy.py`
**薄壳净行数变化** **薄壳净行数变化**
| 改造前 | 改造后 | | 改造前 | 改造后 |
|:---|:---| |:---|:---|
| 1633 行7 文件) | ~1167 行8 文件,含 pipelines/ 3 个新文件) | | 1633 行7 文件) | 1928 行10 文件,含 pipelines/ 4 个新文件) |
减约 466 行(单例/工厂/全局变量的模板代码消除),同时新增"双工模式可用"和"循环耦合消除"两个能力。 增约 295 行——增量来自 `cli.py` 的 headless chat/ask 逻辑、pipx 自动更新、以及 pipelines 层的工厂函数接口。单例/工厂/全局变量的模板代码被消除,同时新增"双工模式可用""循环耦合消除""Cloud Gateway 路由"三个能力。
--- ---
@ -430,51 +432,54 @@ cli.py → run_agent.AIAgent (headless走 Cloud Gateway)
--- ---
## 五、实施路线图 ## 五、实施路线图(已完成)
### Phase 1: audio_capture 管线 + 双工解锁2 天) > 执行顺序调整为 Phase 3 → Phase 1 → Phase 2 → Phase 4Phase 2 的 tunnel_session import Phase 3 的 ToolProxy
### Phase 3: tool_proxy 管线 ✅
``` ```
□ 创建 mindcli/pipelines/ 目录 + __init__.py ✅ 从 managed_mcp.py 抽出 ToolProxy → pipelines/tool_proxy.py
□ 从 recorder.py 抽出 capture() / CaptureHandle / pyobjc delegate → pipelines/audio_capture.py ✅ 白名单 set 由调用方传入,不再经 tunnel 单例
□ 修改 health.py/record/start 用 _active_captures[chatId] 替代 get_recorder() ✅ 删除 managed_mcp.py逻辑已迁出
□ 修改 health.py/record/stop 从 _active_captures pop 后调 handle.stop() ✅ 验证:工具调用正常,白名单过滤生效
□ 删除 recorder.py逻辑已迁出
□ 验证mind record start --source system + mind record start --source mic 并行成功
□ 验证mind record stop 各自独立停止
``` ```
### Phase 2: tunnel_session 管线 + 解循环1.5 天) ### Phase 1: audio_capture 管线 + 双工解锁 ✅
``` ```
□ 从 tunnel.py 抽出 connect() / TunnelHandle → pipelines/tunnel_session.py ✅ 创建 mindcli/pipelines/ 目录 + __init__.py
□ on_status 回调替代反向 import health.set_tunnel_status ✅ 从 recorder.py 抽出 capture() / CaptureHandle / pyobjc delegate → pipelines/audio_capture.py
□ 修改 health.py/tunnel/activate 持有 _tunnel_handle ✅ 修改 health.py/record/start 用 _active_captures[chatId] 替代 get_recorder()
□ 删除 tunnel.py逻辑已迁出 ✅ 修改 health.py/record/stop 从 _active_captures pop 后调 handle.stop()(支持 ?chatId= 停单路)
□ 验证mind tunnel connect → Cloud LLM 看到 local_* 工具 ✅ /record/status 返回所有活跃录音列表(双工可见)
□ 验证:断开后 _tunnel_handle = None状态正确回退 ✅ 删除 recorder.py逻辑已迁出
□ 验证mind record start --source system + mind record start --source mic 并行成功(待实机)
□ 验证mind record stop --chat-id 各自独立停止(待实机)
``` ```
### Phase 3: tool_proxy 管线0.5 天) ### Phase 2: tunnel_session 管线 + 解循环 ✅
``` ```
□ 从 managed_mcp.py 抽出 execute() / _EXECUTORS → pipelines/tool_proxy.py ✅ 从 tunnel.py 抽出 connect() / TunnelHandle → pipelines/tunnel_session.py
□ 白名单 set 由调用方传入,不再经 tunnel 单例 ✅ on_status 回调替代反向 import health.set_tunnel_status
□ 删除 managed_mcp.py逻辑已迁出 ✅ 修改 health.py/tunnel/activate 持有 _tunnel_handle
□ 验证:工具调用正常,白名单过滤生效 ✅ 删除 tunnel.py逻辑已迁出
✅ 验证mind tunnel connect → Cloud LLM 看到 local_* 工具
✅ 验证:断开后状态正确回退
``` ```
### Phase 4: chat/ask 走 run_agent1 天) ### Phase 4: chat/ask 走 run_agent
``` ```
cli.py chat/ask 改用 _vendor/run_agent.py 的 AIAgent headless 接口 cli.py chat/ask 改用 _vendor/run_agent.py 的 AIAgent headless 接口
base_url 指向 Cloud Gatewayapi_key 用 JWT base_url 指向 Cloud Gatewayapi_key 用 JWT
vendor cli.py TUI 保留为 mind chat --offline 备选 vendor cli.py TUI 保留为 mind chat --offline 备选
□ 验证mind ask "hello" → 走 Cloud Gateway → 积分扣费 □ 验证mind ask "hello" → 走 Cloud Gateway → 积分扣费(待 JWT 链路通)
□ 验证mind chat → 交互模式 → 走 Cloud Gateway □ 验证mind chat → 交互模式 → 走 Cloud Gateway(待 JWT 链路通)
``` ```
**总计 ~5 工作日。** **总计实际耗时 ~4 小时**(含调试 + 部署,远低于预估的 5 工作日
--- ---
@ -501,6 +506,83 @@ cli.py → run_agent.AIAgent (headless走 Cloud Gateway)
| D4 | 不把 CLI 变成 SSE 无状态原子 | CLI 是执行节点,依赖 Cloud Gateway/ASR连接态必须存在 | | D4 | 不把 CLI 变成 SSE 无状态原子 | CLI 是执行节点,依赖 Cloud Gateway/ASR连接态必须存在 |
| D5 | 保留 vendor cli.py 作为 `--offline` 备选 | 铁律 C断开即自治要求离线模式可用 | | D5 | 保留 vendor cli.py 作为 `--offline` 备选 | 铁律 C断开即自治要求离线模式可用 |
| D6 | pipelines/ 目录而非 infra/ | MindOS_CLI 是独立包,不依赖 hermes-overlaypipelines/ 是自包含的无状态层 | | D6 | pipelines/ 目录而非 infra/ | MindOS_CLI 是独立包,不依赖 hermes-overlaypipelines/ 是自包含的无状态层 |
| D7 | 执行顺序调整为 3→1→2→4 | Phase 2 的 tunnel_session 需 import Phase 3 的 ToolProxy先做 3 解除依赖 |
| D8 | pipx 自动更新检测安装方式 | `pip install --upgrade` 不影响 pipx 隔离 venv检测 `sys.executable` 路径含 `pipx/venvs``pipx reinstall` |
| D9 | 防降级保护 | 服务器 versions.json 未同步时,`_compare_versions` 阻止本地被降级 |
| D10 | cli_info 快速路径工具 | Bridge 握手时已缓存 capability_report含 versionLLM 查版本直接从缓存返回0ms vs 10s Tunnel 往返),不调 cli_terminal 试探 |
| D11 | handler 复用主 event loop | `set_bridge` 捕获 `_main_loop`handler 用 `run_coroutine_threadsafe` 替代每次新建 `ThreadPoolExecutor + asyncio.run` |
---
## 八、实施过程中发现并修复的 Bug
以下 bug 在改造过程中暴露,不属于原 SPEC 范围但必须修复:
| # | 文件 | Bug | 修复 | Commit |
|:---:|:---|:---|:---|:---|
| 1 | `pyproject.toml` | 缺 `[tool.setuptools.package-data]``VENDOR_COMMIT` 未打包进 pipx | 声明 `mindcli = ["_vendor/VENDOR_COMMIT", "_vendor/**/*"]` | `bcf586b` |
| 2 | `health.py` `_get_vendor_commit()` | `f.read().strip()` 返回整个文件内容而非 commit hash | 改为逐行 `commit:` 前缀解析 | `2075fe6` |
| 3 | `capability.py` `_get_vendor_commit()` | 同上(上报给 bridge 的 vendor 字段是多行文本) | 同上对齐 | `37a26ac` |
| 4 | `health.py` / `capability.py` | 读 `HERMES_COMMIT`(文件不存在),实际文件名 `VENDOR_COMMIT` | 统一为 `VENDOR_COMMIT` | `aecd81e` |
| 5 | `cli.py` `mind update` | `pip install --upgrade` 不影响 pipx 隔离 venv`pip` 在 venv PATH 里找不到 | 检测 pipx → `pipx reinstall`;非 pipx → `sys.executable -m pip` | `aecd81e` |
| 6 | `cli.py` `mind update` | 无版本比较,服务器 versions.json 未同步时误降级 | `_compare_versions` 防降级保护 | `aecd81e` |
---
## 九、Cloud→CLI 工具调用链路优化
### 9.1 问题
用户在云脑问"当前连接的 mindCLI 是什么版本"时LLM 做 3 次 `cli_terminal` 工具调用试探(`which mindcli` / `pip show` / `ls ~/.hermes/`),每次往返 7-10s总耗时 30s+ 逼近 300s 超时。
### 9.2 链路分析与超时层次
```
用户在云脑提问
│ Nginx proxy_read_timeout = 3600s
BaseSSEServer._handleChat()
│ await _runChat(...) → _run_agent_task(fn, userId, chatId)
│ asyncio.wait_for(timeout=MINDOS_CHAT_TIMEOUT_SECONDS=300)
│ loop.run_in_executor → 同步线程池执行 agent.run_conversation()
AIAgent.run_conversation() max_iterations=90
│ LLM 决定调 cli_terminal 工具
cli_tunnel_tool._handler(args)
│ ★ 旧:每次新建 ThreadPoolExecutor + asyncio.run临时 loop
│ ★ 新run_coroutine_threadsafe(coro, _main_loop)(复用主 loop
MindCLIBridge.dispatch_tool_call()
│ asyncio.wait_for(future, timeout=TOOL_CALL_TIMEOUT=120)
│ WebSocket → CLI → subprocess → 回流
ToolProxy.execute() subprocess timeout=30s
```
### 9.3 三层修复
| 层 | 改动 | 文件 | 效果 |
|:---|:---|:---|:---|
| **可观测性** | per-call 耗时日志 + `_call_stats` + `_run_agent_task` 记录对话总耗时/调用次数 | `mindcli_bridge.py` / `sse_server.py` | 每次对话后日志可见耗时分解 |
| **快速路径** | `cli_info` 工具直接从 Bridge `_capabilities` 缓存返回,不走 Tunnel | `cli_tunnel_tool.py` | "CLI 是什么版本"类查询 0ms 返回vs 10s×3 次) |
| **handler 复用主 loop** | `set_bridge` 捕获 `_main_loop``run_coroutine_threadsafe` 替代临时 `asyncio.run` | `cli_tunnel_tool.py` | 消除临时 event loop 创建/销毁开销 |
### 9.4 预期效果
| 场景 | 改造前 | 改造后 |
|:---|:---|:---|
| "CLI 是什么版本?" | LLM 调 3 次 `cli_terminal`~30s | LLM 调 1 次 `cli_info`(缓存命中),~3-5s |
| 任意 `cli_terminal` 调用 | 每次新建临时 loop~50-100ms 额外开销 | 复用主 loop~5-10ms |
### 9.5 服务器端日志格式(改造后)
```
[Bridge] 能力报告: userId=11c1cece, cli_version=0.2.1, vendor=16f9d020, platform=Darwin, tools=6, mcp=0
[Bridge] → 11c1cece: cli_terminal (id=ab12cd34)
[Bridge] ← 11c1cece: cli_terminal 完成 (id=ab12cd34, 120ms)
[MindOSSSEServer] Chat 完成 userId=11c1cece chatId=chat_xxx 耗时=9.4s 工具调用=1次
```
--- ---
@ -595,4 +677,4 @@ def chat(model, skills, resume):
--- ---
*最后更新2026-07-01 v1.0* *最后更新2026-07-01 v1.1已实施*