chore(repo): 根目录杂项归档 _experiments,完善 .gitignore

- 停止追踪 study.md、3 个沙箱调研 md、2 张无引用散图及 cache_research/(25 个文件),实体全部移入 _experiments/
- .gitignore 补全:cli-redesign-demo/、_packaging/、.pytest_cache/、.runtime/、.upload_quarantine/、opencode_key_fix_output/、cache_research/;去除重复的 sub_agent_results/
- 同步修正 test/utils 注释与本地文档中的调研资料路径引用

Co-authored-by: Astrion powered by Kimi-K3 <astrion-agent@users.noreply.github.com>
This commit is contained in:
JOJO 2026-09-09 21:11:58 +08:00
parent a50a793306
commit d574159f82
34 changed files with 11 additions and 14266 deletions

13
.gitignore vendored
View File

@ -10,6 +10,7 @@ __pycache__/
*.pyc *.pyc
.venv/ .venv/
venv/ venv/
.pytest_cache/
# Runtime data (main agent) # Runtime data (main agent)
# 注意:运行态数据默认已迁出源码树到 ~/.astrion/astrion/<mode>/。 # 注意:运行态数据默认已迁出源码树到 ~/.astrion/astrion/<mode>/。
@ -23,10 +24,9 @@ project/
users/ users/
api/ api/
webapp.pid webapp.pid
.runtime/
# Runtime data (sub agent) .upload_quarantine/
# 旧版 sub_agent/ 子项目已移除,子智能体逻辑现统一在 easyagent/。 opencode_key_fix_output/
sub_agent_results/
# Misc # Misc
*.pid *.pid
@ -40,6 +40,9 @@ skills/
# 本地实验残留与历史文档归档(不进仓库) # 本地实验残留与历史文档归档(不进仓库)
_experiments/ _experiments/
# 本地打包产物dmg/App 构建输出)
_packaging/
# Ignore docsdoc/ 与 docs/ 均为本地文档,不纳入版本控制) # Ignore docsdoc/ 与 docs/ 均为本地文档,不纳入版本控制)
doc/ doc/
docs/ docs/
@ -67,6 +70,7 @@ sub_agent_results/
easyagent/ easyagent/
demo/ demo/
cli-react-demo/ cli-react-demo/
cli-redesign-demo/
static/demo/ static/demo/
static/liquid-glass-demo/ static/liquid-glass-demo/
static/liquid-glass-demo.html static/liquid-glass-demo.html
@ -76,6 +80,7 @@ static/debug-theme.html
# 个人开发笔记与研究资料 # 个人开发笔记与研究资料
research/ research/
cache_research/
# 含私人信息 / 个人工作流约定(脱敏前不进仓库) # 含私人信息 / 个人工作流约定(脱敏前不进仓库)
CLAUDE.md CLAUDE.md

View File

@ -1,84 +0,0 @@
# LLM API 缓存命中 Token 字段调研 · 总汇总
> 调研时间2026-08-29 · 目的:为「验证各家 API 是否返回缓存命中 token」实验提供字段对照
> 详细报告:`official_overseas_v2/report.md`(海外官方)、`official_china/README.md`(国内官方)、`aggregators/report.md`(聚合层)
> ⚠️ 全部为官方文档 + 社区证据调研结论,**未经实请求验证**;实验时以实际返回为准。
---
## 一、缓存命中字段总对照表
### 官方 API
| 提供商 | 命中字段完整路径 | 写入字段 | 自动/显式 | 最低门槛 | 命中价折扣 |
|---|---|---|---|---|---|
| OpenAI Chat Completions | `usage.prompt_tokens_details.cached_tokens` | `...cache_write_tokens`GPT-5.6+ | 自动5.6+ 可显式断点) | 10245.5 及更早 2048 | 读 0.1×5.6+/ 0.5×(老模型) |
| OpenAI Responses API | `usage.input_tokens_details.cached_tokens` | `...cache_write_tokens` | 同上 | 同上 | 同上 |
| Anthropic Claude | `usage.cache_read_input_tokens`(顶层) | `usage.cache_creation_input_tokens`(另有 `cache_creation.ephemeral_5m/1h_input_tokens` 细分) | **显式** `cache_control` | 按模型 512/1024/2048/4096 | 读 0.1×、写 1.25×5m/ 2×1h |
| Google Gemini | `usageMetadata.cachedContentTokenCount`SDK`cached_content_token_count` | 无 usage 内写入字段(显式缓存按资源 TTL 计费) | 隐式自动 + 显式 cachedContents | 隐式 20482.5/ 40963.x | 命中 ~0.1×2.5+ |
| xAI Grok | `usage.prompt_tokens_details.cached_tokens`Responses`input_tokens_details.cached_tokens` | 无 | 自动(建议 `x-grok-conv-id`/`prompt_cache_key` | 未公布 | 有缓存价 |
| Mistral | `usage.prompt_tokens_details.cached_tokens` | 无 | 半显式(建议 `prompt_cache_key` | 64 tokens 起,恒为 64 的倍数 | 读 0.1× |
| **DeepSeek** | **`usage.prompt_cache_hit_tokens`(顶层!)** + `prompt_cache_miss_tokens` | 无(自动) | 自动 | 未公布 | **读 ≈0.03×$0.014 vs $0.44,折扣最大)** |
| **Kimi / Moonshot** | **`usage.cached_tokens`(顶层)**;部分官方示例为 `prompt_tokens_details.cached_tokens`——**两处都要读** | 无 | 自动(可用请求参数 `prompt_cache_key` 提命中率) | 未公布 | 读 0.1×~0.2×k3 为 0.1× |
| Qwen / DashScope | `usage.prompt_tokens_details.cached_tokens`;显式另有 `cache_creation_input_tokens`Anthropic 兼容模式为 `cache_read_input_tokens` | 显式时上报创建量 | 隐式自动 + 显式 `cache_control` | 隐式 256部分模型 2000/ 显式块 1024 | 隐式读 0.2×;显式读 0.1×、写 1.25× |
| 智谱 GLM | `usage.prompt_tokens_details.cached_tokens` | 无 | 自动 | 512 | 读 0.5× |
| 豆包 / 火山方舟 | `usage.prompt_tokens_details.cached_tokens` | 创建接口响应同路径 | **仅显式**Context API / Responses API `caching` 参数) | — | 缓存输入折扣价 + 存储费 |
| MiniMax | OpenAI 模式:`prompt_tokens_details.cached_tokens`Anthropic 模式:`cache_read_input_tokens` | Anthropic 模式:`cache_creation_input_tokens` | 自动 + 显式Anthropic 模式) | 512 | 读 0.1×~0.2× |
| 阶跃 Step | **`usage.cached_tokens`(顶层)** | 无 | 自动 | 256 | 读 0.2× |
| 百度千帆 | `usage.prompt_tokens_details.cached_tokens` | 无 | 自动 | 未公布 | 读 0.4× |
### 聚合层 / 中转(实验时最容易踩坑的一层)
| 服务 | 缓存字段行为 | 关键坑 |
|---|---|---|
| **OpenRouter** | 规范化为 `usage.prompt_tokens_details.cached_tokens` + 扩展 `cache_write_tokens` / `cache_discount` / `cost` | ⚠️ 它另有「响应缓存」`X-OpenRouter-Cache-Status: HIT`——命中时 **usage 全为 0**,与 prompt 缓存是两回事;个别上游(如 DeepSeek缓存不过网关 |
| **opencode Zen / Go** | Zen 价格表单列 Cached Read/Write必然解析了上游缓存字段「opencode go」= **$10/月订阅服务**,非 Go 语言版 | ⚠️ opencode 客户端流式解析有 bug#33997`tokens_cache_read` 恒 0——别看客户端展示值抓原始 SSE |
| **one-api / new-api / one-hub** | 意图透传 `cached_tokens`,但流式渠道多个已证实 bug字段清零/计费错误/负 token | ⚠️ 客户端收到的 usage ≠ 网关账单;非流式作基线对照 |
| **国内中转站packycode、灵眸AI 等)** | 口碑「官转」站透传 Anthropic 原生 `cache_creation/read_input_tokens` 并按 5m cache write 计费;逆向接口站无缓存 | 社区验收标准=响应 usage 里有没有这两个字段 |
| **LiteLLM / Portkey / CF AI Gateway** | LiteLLM 双格式并存但 Anthropic 透传路径有 bugPortkey 明确规范化CF 未文档化(推测透传) | LiteLLM `/v1/messages` 路径不映射 `cached_tokens`#27763 |
| **订阅制Copilot/Cursor/Windsurf/Augment** | 无公开 per-request usage APICursor/Augment 面板展示 cache read/write数据来自上游响应 | 无法从响应侧做本实验,跳过 |
---
## 二、实验用统一读取器Python 伪代码)
```python
def extract_cache_hit(usage: dict, body: dict | None = None) -> dict:
"""按优先级从各家 usage 中提取缓存命中 token 数。"""
u = usage or {}
details = u.get("prompt_tokens_details") or {}
in_details = u.get("input_tokens_details") or {}
candidates = [
("prompt_cache_hit_tokens", u.get("prompt_cache_hit_tokens")), # DeepSeek顶层
("cached_tokens@top", u.get("cached_tokens")), # Kimi / Step / 部分 DashScope顶层
("prompt_tokens_details", details.get("cached_tokens")), # OpenAI Chat / Qwen / GLM / MiniMax / 千帆 / xAI / Mistral / OpenRouter
("input_tokens_details", in_details.get("cached_tokens")), # OpenAI/xAI Responses API
("cache_read_input_tokens", u.get("cache_read_input_tokens")), # Anthropic / Bedrock / MiniMax-Anthropic / 中转站
]
hit = next(((k, v) for k, v in candidates if v), (None, 0))
# Gemini 走完全独立的 usageMetadatacamelCase从响应体而非 usage 取
gemini = ((body or {}).get("usageMetadata") or {}).get("cachedContentTokenCount")
return {"hit_tokens": hit[1] or gemini or 0, "field": hit[0] or ("usageMetadata" if gemini else None)}
```
---
## 三、实验设计要点(三份报告的共同结论)
1. **两轮法**:第 1 轮建缓存(命中=0 或走写入字段),第 2 轮同前缀不同后缀(命中>0。两轮间隔必须在缓存 TTL 内Anthropic/Qwen 显式 = 5 分钟)。
2. **前缀 ≥2048 tokens**避开各家阈值差异256~4096 不等)。
3. **流式必须 `stream_options: {"include_usage": true}`**,否则 OpenAI 系协议流式响应没有 usage chunkKimi 流式末 chunk 带 usageAnthropic 看 `message_start` 事件。
4. **语义差异**OpenAI 系 `prompt_tokens` **包含**缓存部分Anthropic `input_tokens` **不含**缓存部分cache_read 另算)。对账时别混。
5. **区分两种「缓存」**网关级响应缓存result cache命中时 usage 可能归零)≠ prompt 前缀缓存KV cache本实验目标
6. **聚合层要抓三个视图**:客户端响应 usage、网关账单/消费日志、可直连时的上游原生 usage——三者可能互不一致new-api #6144 教训)。
7. **首轮 `cache_read=0` 是预期行为**,不是字段丢失;写入字段(`cache_creation_input_tokens` / `cache_write_tokens`>0 反而证明缓存机制在运作。
---
## 四、详细报告索引
| 报告 | 路径 | 覆盖 |
|---|---|---|
| 海外官方 | `official_overseas_v2/report.md` | OpenAI / Anthropic / Gemini / xAI / Mistral / Bedrock / Azure |
| 国内官方 | `official_china/README.md` + `usage_fields_reference.md` | DeepSeek / Kimi / Qwen / GLM / 豆包 / MiniMax / Step / 千帆 |
| 聚合层 | `aggregators/report.md` | OpenRouter / opencode Zen·Go / one-api·new-api·one-hub / 中转站 / Copilot·Cursor·Windsurf·Augment / LiteLLM·Portkey·CF |

View File

@ -1,297 +0,0 @@
# 聚合层调研报告:聚合 API / 中转服务 / coding plan 的「缓存命中 token」字段透传情况
- 撰写时间2026-08-29
- 调研人:子智能体 #3(聚焦聚合层)
- 配套调研(其他子智能体负责):官方海外 APIOpenAI/Anthropic/DeepSeek 等)、官方国内 API
- **重要说明**:本领域大量结论来自 GitHub issue、论坛/社区讨论而非官方文档。每条结论都标注了证据等级:
- **官方文档**:服务方官方文档/博客
- **官方源码**:服务方开源仓库源码(本文直接读取了 new-api 的 `relay/channel/openai/helper.go`
- **Issue 讨论**GitHub issue / 论坛讨论(含用户实测)
- **第三方调研**:独立第三方测评/文档(如 cuihuan/awesome-ai-gateway 的逐 commit 源码审查)
- **社区讨论**LINUX DO、Cursor 论坛、Reddit 等社区帖子
- **推测**:无直接证据,基于已有事实的合理推断;此类结论已明确标注「推测」
- 未找到明确证据的,一律写「未找到证据」。
---
## 1. 总览对照表
| 服务 | 是否透传/保留缓存字段 | 字段格式 / 重命名情况 | 流式中的表现 | 计费显示 | 证据等级 | 来源 |
|---|---|---|---|---|---|---|
| **OpenRouter** | ✅ 保留并**统一规范化**为 OpenAI 风格 | `usage.prompt_tokens_details.cached_tokens` + 自有扩展 `cache_write_tokens`、`cache_discount`、`cost`、`cost_details` | 需 `stream_options.include_usage=true`;末 chunk 带回 usage官方格式**OpenRouter 自身的响应缓存 HIT 时 usage 全为 0** | ✅ `usage.cost` 会按缓存读取折扣计价;`cache_discount` 表示本 generation 的缓存折扣Activity 页与 `/api/v1/generation` 可查 | 官方文档 | [OpenRouter chat completion 文档](https://openrouter.ai/docs/api/api-reference/chat/create-a-chat-completion)、[Prompt Caching 教程博客](https://openrouter.ai/blog/tutorials/prompt-caching-sticky-routing)、[Response caching 文档](https://openrouter.ai/docs/guides/features/response-caching) |
| **opencode开源 agent** | 客户端**解析**用法字段(含缓存),但 TUI 默认不显示 | `session.tokens_cache_read` / `info.tokens.cache.read` | 已知 bugOpenAI-compatible 流式路径下 `tokens_cache_read` 恒为 0上游明明返回了 `cached_tokens`#33997 | opencode 内部按模型计费TUI 不展示缓存明细(有多个第三方插件补足) | 官方源码(基于 issue 定位) + Issue 讨论 | [anomalyco/opencode#33997](https://github.com/anomalyco/opencode/issues/33997)、[#34296](https://github.com/anomalyco/opencode/issues/34296)、[#13003](https://github.com/anomalyco/opencode/issues/13003) |
| **opencode ZenPAUG 网关)** | 见下;同时提供 OpenAI 兼容 / Anthropic 兼容 / Gemini 兼容端点;**官方价格表单独列出 Cached Read / Cached Write 两列**(按模型计费,说明其必然解析上游缓存字段) | 端点协议原生格式(`v1/chat/completions` 走 OpenAI 格式,`v1/messages` 走 Anthropic 格式) | 未找到官方对流式 usage 的专门描述 | ✅ 官方按 Cached Read/Write 定价 | 官方文档(价格表)+ 第三方Bifrost 文档,见下) | [opencode.ai/docs/zen](https://opencode.ai/docs/zen)、[docs.getbifrost.ai OpenCode 页](https://docs.getbifrost.ai/providers/supported-providers/opencode) |
| **opencode Go订阅** | 见下「opencode go」= OpenCode Go 订阅服务($5 首月/$10 每月),**不是**「Go 语言版本」 | 同上 | 同上 | 订阅制,固定月费 + 用量限额,**不按缓存计费** | 官方文档 | [opencode.ai/docs/go](https://opencode.ai/docs/go)、[opencode.ai/zh/go](https://opencode.ai/zh/go) |
| **one-apisongquanpeng** | 大体透传上游 OpenAI 格式 usage**计费模型不含缓存折扣**`额度 = 分组倍率 × 模型倍率 × (提示 token + 补全 token × 补全倍率)` | OpenAI 风格(其主干只做 OpenAI 兼容转发) | 依赖 `stream_options.include_usage`README 中有可选 env `ENFORCE_INCLUDE_USAGE` | ❌ 计费不区分缓存命中;缓存 token 按全价输入计 | 第三方调研(逐 commit 源码审查)+ 官方 README | [awesome-ai-gateway virtual-keys-metering](https://github.com/cuihuan/awesome-ai-gateway/blob/main/docs/virtual-keys-metering.zh-CN.md)、[one-api README](https://github.com/songquanpeng/one-api) |
| **new-apiQuantumNous** | ✅ 转发路径基本保留缓存字段OpenAI 渠道流式 `*usage = lastStreamResponse.Usage` 整体拷贝);**但存在多个已证实的 bug**:自定义渠道/火山方舟流式把 `cached_tokens` 打成 0#5672xAI 渠道流式转发对但内部计费 usage 损坏(#6144缓存命中导致输入 token 变负数(#5003/#5005缓存写入 token 未计费(#6353 | OpenAI 风格 `prompt_tokens_details.cached_tokens`;清理/重建 usage 时会注入大量默认字段(`text_tokens/audio_tokens/claude_cache_creation_*` 等) | 多个渠道的流式 usage 处理有 bug见上「透传模式」直连上游→字段原样 | ⚠️ 内部计费有 `CacheRatio` + `CacheCreationRatio`5m/1h 拆分),但多个 bug 导致缓存计费错误甚至倒扣 | 官方源码 + Issue 讨论 + 第三方调研 | new-api#6144、#5672、#5003、#6353源码 `relay/channel/openai/helper.go`awesome-ai-gateway 文档 |
| **one-hubMartialBE** | ✅ 基本透传;**曾被证实 Responses API 的 `cached_tokens``omitempty` 标签被省略**,导致 Codex CLI 报 `missing field 'cached_tokens'`已修复PR #910 | OpenAI 风格 | Responses SSE 的 `input_tokens_details.cached_tokens` 曾缺失(已修复) | v0.14.26 起为 Bedrock 渠道的 Claude 增加 prompt caching 支持;计费沿用 one-api/new-api 体系 | Issue/PR 讨论 + Release 说明 | [one-hub PR #910](https://github.com/MartialBE/one-hub/pull/910)、[Release v0.14.26](https://github.com/MartialBE/one-hub/releases) |
| **国内中转站packycode、灵眸AI 等)** | 参差不齐:宣称「官转」的站会解析并透传 usagepackycode 明说「透传用户的请求…解析 claude 传来的 usage tokens」部分站逆向接口不缓存 | Anthropic 原生格式Claude Code 场景)或 OpenAI 风格 | 实测有的站「完整透传 `cache_creation_input_tokens` / `cache_read_input_tokens`灵眸AI | ⚠️ 中转站按 usage 计费,且**默认按 5m Cache Write 计缓存**packycode缓存命中占比极高用户实测 82.9% cache read | 社区讨论 | LINUX DO 帖、fulitimes 博客,见 §5 |
| **GitHub Copilot** | 终端用户**拿不到 per-request usage**(订阅制)。订阅用量属 token 配额制2026-06 起转 token 计费);企业版 REST metrics API 只给每日聚合 `prompt_tokens_sum/output_tokens_sum`**无缓存拆分**VS Code 的 OTLP 指标不暴露 cached input | 其内部 OpenAI 兼容后端 SSE **会**把 `prompt_tokens_details.cached_tokens` 与 DeepSeek 原生 `prompt_cache_hit_tokens` 透给客户端社区实测free 计划 DeepSeek | 同上(社区实测见原始 SSE | 订阅/token 配额内,无 per-request 缓存折扣展示 | 官方文档 + Issue/社区实测 | GitHub REST Copilot metrics 文档、microsoft/vscode#317837、obsidian-copilot discussion #2380 |
| **Cursor** | 订阅与 BYOK 的用量面板都**展示 Cache Read / Cache Write**官方客服口径usage 报告里显示的是「AI provider 随响应返回的精确 token」BYOK 直连时缓存字段来自 Anthropic/OpenAI | Anthropic/OpenAI 原生 | 多个论坛帖证实 Auto 模式曾路由到不支持缓存的模型导致 cache=0版本问题 | ✅ 面板单列 Cache Read/Write 并计费cache read 价约输入价 10% | 社区讨论(官方客服回复)+ 官方文档未直接确认 | Cursor 论坛帖,见 §6 |
| **Windsurf** | 订阅/credits 制;**计量按 token 且明确区分 cache-read 单价**(如 Sonnetinput 90 credits/M、cache read 9 credits/M、output 450 credits/M说明网关侧跟踪缓存 token | 不暴露原始 usage 给用户,走 credits 换算 | 未找到 per-request usage 暴露证据 | ✅ cache-read 以低价 credit 计费 | 第三方文档 + 官方价格说明 | flexprice.io、Windsurf 官方文档(见 §6 |
| **Augment Code** | token 计费制官方文档明说「自动缓存稳定上下文cached input 按供应商缓存价(约 10%计费」Usage 面板展示 input/output/cache read/cache write 单价 | 不暴露原始 usage 字段 | 未找到 | ✅ 缓存读取按折扣计费 | 官方文档 | [docs.augmentcode.com/models/token-based-pricing](https://docs.augmentcode.com/models/token-based-pricing) |
| **Cloudflare AI Gateway** | 作为透明代理转发(推测透传 usage**官方文档未明确描述缓存 usage 字段的保留/规范化**;其自带「响应缓存」是网关级缓存(`cf-aig-cache-status: HIT/MISS`),与 prompt cache 是两回事;社区实测 `cache_control` 请求体能透传 | 上游协议原样 | 未找到官方文档 | 网关自己的日志/analytics 记录 token usage 供计费统计,不向调用方展示 | 官方文档(缓存功能)+ Issue 讨论cache_control 透传) | Cloudflare AI Gateway docs、openclaw#46709 |
| **Portkey** | ✅ **明确规范化到 OpenAI 格式并保留缓存字段**`prompt_tokens = input + cache_read + cache_creation``cached_tokens` 出现在 `prompt_tokens_details`|Bedrock 场景有明确文档) | Portkey 透传模式下响应按供应商原样;其观测端展示 `cached_tokens` | ✅ 定价公式单独处理 base input / cache read / cache write | 官方文档 | [Portkey Bedrock Prompt Caching](https://docs.portkey.ai/docs/integrations/llms/bedrock/prompt-caching)、[Portkey docs](https://docs.portkey.ai/docs/integrations/llms/openai/prompt-caching-openai) |
| **LiteLLM** | ✅ OpenAI 兼容端点规范化到 OpenAI 风格 `prompt_tokens_details.cached_tokens`,同时在同一 usage 对象中保留 Anthropic 原生 `cache_creation_input_tokens` / `cache_read_input_tokens`**但 Anthropic `/v1/messages` 透传路径不把原生字段映射到 `cached_tokens`,导致指标/计费不识别缓存bug #27763** | 双格式并存OpenAI 风格 + Anthropic 原生) | 流式 usage 合成有历史 bug如 synth chunk 的 `choices` 非空);默认不强制 include_usage | ⚠️ 有独立 cache read/write 单价,但多个计费 bug缓存 token 按全价算(#26807多收 1.67×、cache write 未计入(#33772等 | 官方文档 + Issue 讨论 + 第三方调研 | litellm docs Prompt Caching、#27763、#26807、#33772、awesome-ai-gateway |
| **Vercel AI Gateway顺带** | 面板正确展示 cache read但**缓存 token 按全价输入计费**Kimi 案例 6× 成本) | 上游协议原样 | 未细查 | ⚠️ 计费不应用缓存折扣issue 讨论) | Issue 讨论 | [vercel/ai#13907](https://github.com/vercel/ai/issues/13907) |
---
## 2. OpenRouter重点
**结论先行**OpenRouter 是少数把「缓存命中 token」做成**一等公民**的聚合层——它把各上游Anthropic/OpenAI/Gemini/DeepSeek…的缓存字段**统一规范化**成 OpenAI 风格的 `prompt_tokens_details.cached_tokens`,并增加自有扩展字段 `cache_write_tokens`(缓存写入)与 `cache_discount`(本次缓存折扣金额)。
### 2.1 usage 字段是否原样透传 / 规范化成什么
- 官方 API 参考(`ResponseUsage` 类型):
- `usage.prompt_tokens` / `completion_tokens` / `total_tokens`
- `usage.prompt_tokens_details.cached_tokens`"Tokens cached by the endpoint"+ 可选 `cache_write_tokens`"Tokens written to cache (models with explicit caching)"
- 另有 `completion_tokens_details.reasoning_tokens`、`cost`、`cost_details`(含 `upstream_inference_prompt_cost` 等)、`is_byok`、`server_tool_use_details` 等 OpenRouter 扩展。
- 官方示例:`"usage": { "prompt_tokens": 10339, "completion_tokens": 60, "total_tokens": 10399, "prompt_tokens_details": { "cached_tokens": 10318, "cache_write_tokens": 0 } }`。
- 也就是说:**Anthropic 的 `cache_read_input_tokens` 会被折算进 `cached_tokens`**并参与折扣计费。OpenRouter 官方博客明确说明:缓存读取价格约为正常输入价的 0.1×0.5×Anthropic/DeepSeek/Qwen 0.1×OpenAI 0.25×0.5×……)。
- 没有找到 OpenRouter 会把 Anthropic 原生 `cache_creation_input_tokens` 原样透传的证据——它统一到 OpenAI 风格。OpenRouter 自己的扩展字段就叫 `cache_write_tokens`
### 2.2 流式响应
- 与 OpenAI 相同:需 `stream_options: { include_usage: true }`,最后一个 SSE chunk 带 `usage`(官方博客称**每个响应都包含** `usage.prompt_tokens_details``cached_tokens`/`cache_write_tokens`)。
- 注意OpenRouter 官方「Response caching响应缓存」是**另一回事**——它缓存的是整条响应(`X-OpenRouter-Cache-Status: HIT/MISS` 头);**HIT 时返回的 usage 是 `prompt_tokens: 0, completion_tokens: 0, total_tokens: 0`**官方文档示例。实验时不要把「OpenRouter 响应缓存」当成「prompt cache」。
### 2.3 计费显示
- `usage.cost` 体现缓存折扣后的实际金额;`usage.cost_details` 细分上游各项成本;`cache_discount` 表示本 generation 因缓存省下/付出的金额(写入缓存的那一轮可能为负折扣,因为写缓存更贵)。
- Activity 页面与 `GET /api/v1/generation` 可逐条查看 `cached_tokens` / `cache_write_tokens` / `cache_discount`
- 社区实测2026-07china-llm.comGLM-5 经 OpenRouter 重复调用返回 3200 cached tokens、价格降 75%;同时**同一前缀 DeepSeek 经 OpenRouter 报 0 cached tokens**(原生端点几分钟内有 98% 命中)——**说明 OpenRouter 某些模型/上游不保留缓存,不能一概而论**。Paul's Programming Notes 也实测 Kimi K3 的缓存折扣「过不了 OpenRouter」。
- 第三方安全测评Tarun Chitra 文章)指出:存在供应商「把缓存 token 按全额重新计价」的多收费现象OpenRouter 本身对上游的缓存识别并不总是生效——意味着 **`cached_tokens` 字段是否存在、是否 >0可作为判断上游是否真正给了缓存折扣的观测点**。
**证据等级**:缓存字段设计=官方文档;折扣细节=官方博客;个别模型缓存不过网关=第三方实测上游「repricing」问题=第三方文章。
### 2.4 来源
- https://openrouter.ai/docs/api/api-reference/chat/create-a-chat-completion 官方ResponseUsage 定义/示例)
- https://openrouter.ai/blog/tutorials/prompt-caching-sticky-routing (官方,缓存字段与折扣)
- https://openrouter.ai/docs/guides/features/response-caching (官方,响应缓存 HIT 时 usage 归零)
- https://china-llm.com/blog/openrouter-prompt-caching 第三方实测2026-07-28
- https://www.paulsprogrammingnotes.com/2026/08/kimi-k3-cache-discount-openrouter.html (第三方实测)
---
## 3. opencode / opencode Zen / opencode Go
### 3.1 先说清楚「opencode go」是什么任务要求查清
- `opencode`sst/opencode现仓库 `anomalyco/opencode`,作者 Anomaly前 SST 团队)是**用 Go 写的开源 terminal coding agent**MIT
- **「opencode go」= OpenCode Go**,是 Anomaly 推出的**低价订阅服务**(首月 $5之后 $10/月),提供一批开源/开源权重 coding 模型Kimi、GLM、MiniMax、DeepSeek、Qwen、Grok、GPT-5.6 Luna 等)。**它不是「Go 语言版本的 opencode」而是「一个叫 Go 的订阅套餐」**。它诞生背景是 Anthropic 2026-01 禁止第三方工具使用 Claude 订阅凭据后Anomaly 顺势推出的三个订阅产品之一:**Go$10/月开源模型)**、**Zen按量付费网关**、Black企业网关
- 官方描述Go 是面向国际用户的低成本订阅,通过 OpenAI 兼容 / Anthropic 兼容端点提供Docker 文档确认:`openai_chatcompletions`base URL 为 opencode.ai 的 Go 端点MiniMax/Qwen 等走 Anthropic 客户端)。**订阅制=固定月费+用量限额,不按 token/缓存计费**,因此对「缓存命中计费」不敏感——用户看不到用量明细。
- Zen 才是按量付费:`https://opencode.ai/zen/v1/chat/completions`OpenAI 兼容)、`/v1/messages`Anthropic 兼容)、`/v1/responses`OpenAI Responses、Gemini 风格端点。
### 3.2 Zen 是否保留/计费缓存字段
- **官方价格表opencode.ai/docs/zen对每个模型单独列出 `Cached Read` 和 `Cached Write` 两列单价**(如 MiniMax M3Input $0.30/M、Output $1.20/M、Cached Read $0.06/MClaude SonnetCached Read $0.20/M、Cached Write $2.50/MQwen 3.7 PlusCached Read $0.04、Cached Write $0.50)。**既然按缓存读取/写入单独定价Zen 网关必然解析上游响应里的缓存 usage 字段**——这是「Zen 保留缓存字段」的最强官方证据(间接)。
- 第三方佐证——Bifrost 的 OpenCode provider 文档docs.getbifrost.aiBifrost 用同一套 OpenCode Zen/Go provider 实现):
- OpenCode 返回 `usage.prompt_tokens` / `usage.completion_tokens` / `usage.total_tokens` / **`usage.prompt_tokens_details.cached_tokens`** / `usage.completion_tokens_details.reasoning_tokens`
- 「有些模型上报 `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens`Bifrost 会把这些映射成标准 `cached_tokens` 参与定价计算」。
- 「缓存行为取决于底层供应商;有的模型(如 Go 上的 DeepSeek V4 Flash可能根本不缓存」。
- opencode 客户端侧:`packages/llm/src/protocols/openai-chat.ts` 的 `mapUsage` 会映射 `prompt_tokens_details.cached_tokens`issue #33997 里确认);会话级字段 `session.tokens_cache_read`、`info.tokens.cache.read`。**但存在一个已知 bugOpenAI-compatible自定义 baseURL如 LiteLLM 代理)流式路径下 `tokens_cache_read` 恒为 0即使上游 SSE usage chunk 里明明有 `cached_tokens`(实测 5888/6004 ≈98% 命中)**#339972026-06。即**opencode 客户端本身对流式缓存的解析有坑,实验者别只看 opencode 的展示值**。
- opencode TUI 默认不展示缓存明细——有多个第三方插件补足opencode-visual-cache、opencode-cache-hit、oc-plugin-caching说明多模型含 Zen返回的缓存 usage 是**可达**的(插件从 opencode session API 读 `tokens`/`cost`)。
**回答五个问题**
1. 透传?— Zen/Go 后端按协议原生透传(表单即 OpenAI/Anthropic 兼容格式opencode 客户端解析 `cached_tokens`OpenAI 兼容路径有流式 bug
2. 规范化?— 未见 Zen 官方文档说明是否统一改名Bifrost 实现会把 `prompt_cache_hit_tokens` 映射成 `cached_tokens`。未找到 Zen 对 Anthropic 端点的缓存字段改名证据(推测为 Anthropic 原生格式透传)。
3. 流式?— opencode #33997 证实上游流式 chunk 带 `cached_tokens`,但 opencode 展示为 0客户端 bug
4. 计费?— Zen 按 Cached Read/Write 单价收费官方价格表Go 订阅制不按缓存计费。
5. 来源 — 见下。
来源https://opencode.ai/docs/zen、https://opencode.ai/docs/go、https://opencode.ai/zh/go、https://docs.getbifrost.ai/providers/supported-providers/opencode、https://github.com/anomalyco/opencode/issues/33997、#13003、#23109、#34296、https://ai.miraheze.org/wiki/OpenCode_Go第三方介绍、https://thomas-wiegold.com/blog/opencode-go-review第三方评测
---
## 4. 开源自建中转网关one-api / new-api / one-hub
### 4.1 one-apisongquanpeng
- 主干是「OpenAI 兼容格式 `chat/completions` 转发」,上游 OpenAI 协议响应的 usage 基本原样转发;但**计费模型完全没有缓存折扣**:官方 FAQ 的额度公式 = 分组倍率 × 模型倍率 ×(提示 token 数 + 补全 token 数 × 补全倍率)。
- 第三方逐 commit 源码审查cuihuan/awesome-ai-gateway, 2026-07-29结论
- one-api 的 `quota = ceil((promptTokens + completionTokens*completionRatio) * ratio)`**全仓没有 cache read/write 单价**(六网关对比中 one-api/Kong/Higress 是仅有的三家无独立缓存价格的)。
- one-api 最新 commit 停留在 2025-02-21v0.6.10),计量代码比 new-api 旧约 17 个月;流式 usage 缺失时用 tiktoken 兜底重算(但仅对 gpt-3.5/4 前缀建了真编码器Claude/Gemini 流会退回 gpt-3.5-turbo 编码器)。
- 用户视角:**客户端拿到的 usage 里缓存字段大概率保留OpenAI 渠道),但账单不会给缓存折扣**。
- 流式README 有可选环境变量 `ENFORCE_INCLUDE_USAGE`(是否强制在 stream 下返回 usage
- 未找到 one-api 专门讨论 cached_tokens 透传的 issue搜索「one-api cached_tokens」无直接命中其 issue #204 是登录 token 缓存导致额度超额,与 prompt cache 无关,不采用)。
**结论**one-api = usage 大体透传、缓存字段保留与否取决于客户端是否请求 include_usage**计费无缓存折扣**。证据等级:第三方源码审查 + 官方 README。
### 4.2 new-apiQuantumNousone-api 的主要活跃 fork包装「官转」最多的底座
- **转发路径**OpenAI 渠道流式 `handleLastResponse``*usage = lastStreamResponse.Usage`**整体拷贝**`cached_tokens` 保留)——本文直接读取源码 `relay/channel/openai/helper.go` 确认。非流式 `xAIHandler` 直接 `return xaiResponse.Usage, nil`
- **但有一批已证实的 bug全部为 issue 讨论 + 部分有源码定位)**
1. **#6144xAI 渠道)**:流式 handler 双路径分叉——转发给客户端的 usage 是完整的(`cached_tokens=1792` 正确返回),但内部计费用的是手动重建的残缺 usage只拷 3 个标量),`cache_tokens` 记成 0缓存 token 全按全价计费。非流式正常。已提交修复 PR #6145`*usage = *xAIResp.Usage` 整体拷贝)。**「客户端看到的 usage 是对的,网关自己计费是错的」的典型例子**。
2. **#5672(自定义渠道/火山方舟)**:流式模式下 `usage.prompt_tokens_details.cached_tokens` 恒为 0`prompt_tokens` 从 3513 膨胀到 4540+29%`reasoning_tokens` 被清零,并被注入大量默认字段(`text_tokens:0, audio_tokens:0, claude_cache_creation_*:0` 等。非流式正常。已关闭not planned
3. **#5003 / #5005(缓存命中→输入 token 为负数)**:上游按 Anthropic 排除语义返回cache read 已从输入中排除new-api 又减了一次,输入算出 16,638账单反而「倒贴」给用户第三方文档给出可复算算术。重视用户实测「站长亏损」。
4. **#6353Claude 缓存写入 token 未计费)**5m/1h TTL 拆分缺席时级联 bug 把 cache creation 值清零,最贵的写入 token 打了 100% 折。开放中。
5. **#1103Gemini reasoning 未计费,开放 14 个月)**`completion_tokens`124不含 `reasoning_tokens`109790% 输出 token 未计费(属推理字段,非缓存,顺带记录)。
- **透传模式**new-api 的 issue 模板明确写「透传模式会直接转发请求,请自行确认上游行为;开启透传后的转发相关反馈不接受 issue」→ **存在「透传(直连上游)」开关,开启后缓存字段随上游原样返回**;反之普通中继模式会走上面的 usage 规范化逻辑(可能补默认字段、改计数)。
- **计费**`service/text_quota.go`OpenAI 语义)`promptQuota = (PromptTokens - CacheTokens) + CacheTokens * CacheRatio`,并有 `CacheCreationRatio`5m/1h 拆分)——**new-api 是少数原生支持缓存折扣计费的开源网关**,但 bug 多。
**结论**new-api「会」保留缓存字段多个渠道/修复后),但**流式+自定义渠道/部分内置渠道历史上会丢/损坏缓存字段或计费错误**;实验透过 new-api 必须同时看「客户端收到的 usage」与「网关消费日志/账单」两处。证据等级官方源码helper.go + issue 中源码定位)+ issue 讨论 + 第三方调研awesome-ai-gateway
### 4.3 one-hubMartialBEone-api 的另一活跃 fork
- 与 new-api 同源(都 fork 自 one-api能力上对齐 new-api 的缓存计费方向README 称「支持更多模型」)。
- **直接证据PR #9102026-01由 done-hub 转来)——「修复 Responses API cached_tokens 字段缺失问题」**:原代码对 `ResponsesUsageInputTokensDetails.CachedTokens` 用了 `omitempty` 标签,**值为 0 时字段被省略**,导致 Codex CLI 解析 `response.completed` 事件时报 `missing field 'cached_tokens'` 并无限重试。修复=移除 omitempty 保证零值也输出。→ **说明网关在 Responses 路径会把 `cached_tokens` 弄丢(至少历史版本)**
- Release v0.14.26:「为通过 AWS Bedrock 渠道访问的 Claude 模型添加 prompt caching 支持」PR #850)→ one-hub 主动做缓存透传/支持。
- 计费沿用 one-api/new-api 体系new-api 特性 `CacheRatio` 等是否完全同步需逐个版本核对,未找到独立证据)。
**结论**one-hub 基本透传,但历史上有 Responses API 丢 `cached_tokens` 的 bug 并已修复;实验者用 Codex Responses 端点时建议对照上游原始响应。证据等级PR 讨论 + Release 说明。
### 4.4 来源汇总
- https://github.com/songquanpeng/one-api README额度公式、ENFORCE_INCLUDE_USAGE
- https://github.com/QuantumNous/new-api/issues/6144 、#5672 、#5003 、#5005 、#6353 、#1103
- https://raw.githubusercontent.com/QuantumNous/new-api/main/relay/channel/openai/helper.go (源码)
- https://github.com/MartialBE/one-hub/pull/910 、https://github.com/MartialBE/one-hub/releases v0.14.26
- https://github.com/cuihuan/awesome-ai-gateway/blob/main/docs/virtual-keys-metering.zh-CN.md (第三方逐 commit 审查2026-07-29含上述 issue 的状态核实与可复算算术)
---
## 5. 国内常见中转/拼车 API 站packycode、灵眸AI 等)与缓存计费讨论
### 5.1 packycodePackyAPI自称「官转」
- LINUX DO 官方商家帖2025-07「Packycode 的计费保持和官网的 api 计费方式一样」「**我们会透传用户的请求(保护隐私),最后解析 claude 传过来的 usage tokens我们默认使用 5m Cache Writes 做 cache 的计费**」——**明说基于上游 usage 计费、缓存按 5m cache write 计费**。同时有用户问「Claude code 拼车的时候,背后是 Claude code 的池子,不会没有办法命中 cache 吗」——官方回复大意:全局用 Claude Code 的话缓存命中由 Claude Code 自管,实际消耗不大。
- GitHub 宣传页2026PackyAPI 主站按量付费、计费对标 Claude/OpenAI 官网价格Codex 有独立包月站。
- 用户实测(什么值得买/其他帖Claude Code 场景 cache read 占输入大头(另一帖统计 82.9% cache read / 15.6% cache write / 1.5% fresh input**cache 命中基本决定中转实际价格**。
### 5.2 灵眸AI 等(社区实测透传)
- fulitimes 博客2026Claude Code 缓存指南「实测灵眸AI **完整透传** `cache_creation_input_tokens``cache_read_input_tokens` 这两个字段,可在后台账单中查看每次请求的 cache 命中情况」;并警告「**很多便宜平台用逆向接口,不支持 Prompt Caching**——表面价低但无缓存差距」;验证方法=在响应 usage 里查这两个字段是否存在。
- 知乎/博客普遍教程:判断中转是否支持缓存的唯一方法是看响应 usage 里有没有 `cache_creation_input_tokens` / `cache_read_input_tokens`Anthropic 风格)。说明**社区已把「usage 缓存字段是否透传」当作中转站质量的验收标准**。
### 5.3 结论(针对四个问题)
1. 是否透传缓存字段:**参差不齐**。口碑「官转」站大多解析上游 usage 并据此计费packycode 明说灵眸AI 实测透传);逆向/低价接口通常无缓存。**没有统一规范**。
2. 规范化/改名一般保持上游协议原生Claude Code 场景=Anthropic 原生字段OpenAI 兼容场景=OpenAI 风格)。
3. 流式Claude Code 流式 usage 走 Anthropic `message_start`/`message_delta`;有 issue 表明 Claude Code 类客户端对 messageDelta 里的缓存计数有兼容问题cline#4346 讨论 Anthropic API 在 messageDelta 增加累计缓存计数的兼容问题)。
4. 计费显示:中转站按解析后的 usage 计费并**普遍把缓存写入按 5m 档定价**1.25×输入价),缓存读取按 0.1×用户可看到余额消耗部分站如灵眸AI后台可查 cache 命中明细。
- 证据等级:除 GitHub 宣传页外几乎全部为社区讨论/用户实测(无官方文档)。**未找到「中转站统一丢弃缓存字段」的系统性证据**;相反,多个实测表明主流中转会透传。
来源:
- https://linux.do/t/topic/771392 Packycode 计费说明帖)
- https://linux.do/t/topic/1620430 cache read 占比 82.9% 实测)
- https://linux.do/t/topic/2591545 Sub2API 中转 Claude Code 消耗统计)
- https://blog.fulitimes.com/claude-code-cost-optimization 灵眸AI 透传实测、逆向接口无缓存)
- https://github.com/CherryHQ/cherry-studio/discussions/15278 Feiyuan API「原生透传 cache_control」的站长自述Claude 中转缓存讨论)
- https://github.com/cline/cline/issues/4346 Anthropic messageDelta 缓存计数的客户端兼容问题)
---
## 6. 订阅制 coding planGitHub Copilot / Cursor / Windsurf / Augment Code
统一先回答「是否向终端用户暴露 token usage/缓存信息」:**多数不暴露原始 per-request usage但 Cursor/Augment 等会在用量面板里展示缓存拆分明细Copilot/Windsurf 只给聚合/credit 换算后的信息**。
### 6.1 GitHub Copilot
- 经典订阅制token 配额):用户拿不到 per-request usage。2026-06 起逐步转 token 计费Medium/官方博客)。
- 企业版提供 REST Copilot usage metrics APIenterprise/org 级):返回**每日聚合**的 `prompt_tokens_sum`、`output_tokens_sum`、`avg_tokens_per_request` 等,**没有缓存 token 拆分字段**(官方文档示例可见)。→ 官方聚合指标里**看不到 cached tokens**。
- VS Code 内 OTLP 指标microsoft/vscode#317837 确认 **Copilot Chat 的 OTLP metrics 不暴露 cached input token usage**;但 GitHub 定价区分 normal input 与 cached input说明**平台侧在按缓存计费**,只是不暴露给用户)。
- 有趣的实证obsidian-copilot 的讨论(#2380贴出免费 Copilot 计划DeepSeek v4的**原始 SSE**——`usage.prompt_tokens_details.cached_tokens` 和顶层 `prompt_cache_hit_tokens`/`prompt_cache_miss_tokens` **都原样出现在流里**(总计 128 cached。→ **Copilot 的 OpenAI 兼容后端(至少 DeepSeek 路径)会把缓存字段透传给流式客户端**,尽管官方不提供 per-request 文档。该讨论同时指出 DeepSeek 的缓存折扣对 Copilot 免费用户「用不上」(因为系统提示没被缓存)。
- copilot-cli issue #3808:请求 Copilot CLI 对 Claude Sonnet 启用 Anthropic 缓存断点(当前「无可见优化」)——说明 Copilot CLI 订阅路径**目前不刻意利用/暴露 Anthropic prompt cache**。
- **结论**Copilot=订阅+token 配额;缓存字段**不面向终端用户文档化**;企业聚合 API 无缓存拆分;底层 SSE 有透传迹象社区实测。证据等级官方文档metrics API 字段)+ issue 讨论。
来源https://docs.github.com/rest/copilot/copilot-usage-metrics 、https://github.com/microsoft/vscode/issues/317837 、https://github.com/logancyang/obsidian-copilot/discussions/2380 、https://github.com/github/copilot-cli/issues/3808 、https://code.visualstudio.com/blogs/2026/06/17/improving-token-efficiency-in-github-copilot
### 6.2 Cursor
- 论坛官方账号客服口径thread「Why are cache read and write chargeable?」「In all cases we show the precise token consumed in Usage report **as provided by AI provider sent back with AI response**」——**用量面板展示的缓存拆分明细来自上游 API 响应原样**;「有些供应商把 cache write 算进 Input 只单列 cache read有些Anthropic单独分开我们按供应商返回的展示」。
- 订阅Pro与 BYOK 的用量面板都单列 **Cache Read / Cache Write**且按缓存价计费cache read ≈ 输入价 10%。多篇论坛帖用「0 cache read / 0 cache write → usage 暴涨」排查 Auto 模式路由到不支持缓存的模型(版本 2.6.12 → 2.6.18 修复)。
- **注意**:这说明 Cursor 订阅计划**会展示**缓存 token 明细(这是少数订阅制里对用户可见的);但这只是「面板展示」,非公开 API —— Cursor 不提供获取原始 usage 的 API未找到
- 另一个相关实证microsoft/vscode#312939OpenRouter BYOK in Copilot**经 OpenRouter 的 Claude 在 agent 模式里 `cached_tokens` 恒 0**,与原生 Anthropic BYOK 对比 10 倍成本差异——聚合层缓存是否生效对 agent 成本影响极大。
**结论**Cursor=订阅制但用量面板单列 cache read/write透传自上游响应无公开 usage API。证据等级社区讨论官方客服回复+ 论坛实测;官方文档未直接确认面板字段。
来源https://forum.cursor.com/t/someone-please-explain-why-are-cache-read-and-write-chargeable/153538/8 、https://forum.cursor.com/t/auto-mode-not-using-prompt-caching-0-cache-read-write-sudden-usage-spike/154278 、https://forum.cursor.com/t/cache-read-token/153794 、https://github.com/microsoft/vscode/issues/312939
### 6.3 Windsurf
- credits + token 混合计费:外部模型按「模型供应商 API 价 + 20% 加成」换算 credit**明确区分 input / cache-read / output 三种单价**flexprice.io 整理Claude Sonnet 4input 90 credits/M、**cache read 9 credits/M**、output 450 credits/M1 credit=$0.04)。→ Windsurf 计量层**按 cache-read 打折计费**,说明其网关解析并保留了缓存字段。
- 用户侧**看不到原始 usage 字段**,只能看到 credit 消耗与用量面板Tokenminning 的 Windsurf 页提到「Quota & billingdaily/weekly quota, cache reads, enterprise ACUs」→ 官方文档存在 cache reads 相关条目(推测在用量说明中,未逐字核验)。
- **结论**:订阅/credits 制;缓存 token 参与折扣计费(第三方资料);未找到向用户暴露 per-request usage 的证据。证据等级:第三方价格分析 + 官方文档存在性(未逐字核验)。
来源https://flexprice.io/blog/windsurf-ai-pricing-breakdown 、https://tokenminning.ai/ides/windsurf 、Windsurf 官方文档quota & billing未逐字核验
### 6.4 Augment Code
- 官方文档Token-Based Pricing「Augment **自动缓存稳定上下文**repo index、AGENTS.md、最近文件**cached input tokens 按供应商缓存价计费(约输入价 10%**服务费随缩水」「Usage → Models 面板展示每个模型的 input/output/**cache read/cache write** 单价」。
- 定价体系2025-10 起从 message 制改 credit 制token 制文档较新,网页 2026 版本同时提到 token-based pricing 与 credit
- **结论**:订阅/credit 制,官方明确缓存读取按折扣计费并在面板展示缓存单价——但没有公开 API 暴露原始 usage 字段。证据等级:官方文档。
来源https://docs.augmentcode.com/models/token-based-pricing 、https://www.augmentcode.com/blog/augment-codes-pricing-is-changing
---
## 7. Cloudflare AI Gateway / Portkey / LiteLLM及顺带 Vercel AI Gateway
### 7.1 Cloudflare AI Gateway
- 官方「Caching」文档指的是**网关级响应缓存**:按 provider+endpoint+model+auth+body 构造 SHA-256 cache key`cf-aig-cache-status: HIT/MISS` 头标识;**命中时直接返回缓存响应,不再调用上游**——这是「cache 掉整条响应」,不是 prompt cache。命中响应的 usage 含义取决于缓存内容(官方未在此文档中说明 usage 归零;**与 OpenRouter 响应缓存把 usage 清零不同Cloudflare 文档未写明**,实验时注意区分)。
- Anthropic provider 文档:给出把 base URL 指向 AI Gateway 的示例(`/ai/v1/messages`**未提到会规范化/丢弃 Anthropic 的 `cache_read_input_tokens`**。社区openclaw#46709实测请求体的 `cache_control` 能透传到 gatewaybug 是在 openclaw 侧 TTL 设置,不是网关丢弃)。
- **未找到**官方文档明确说明 Cloudflare AI Gateway 对上游 usage 缓存字段的保留/改名策略——按「透明代理」设计推测为原样透传(推测,证据不足)。
- Workers AI非网关文档确认其在 `usage` 对象里返回 cached token 计数——但那是 Cloudflare 自营推理,不是聚合层。
**结论**Cloudflare AI Gateway 未文档化缓存 usage 字段处理;其自带缓存是响应级缓存(有 HIT/MISS 头);请求侧 cache_control 可达。证据等级:官方文档(缓存功能)+ issue 讨论cache_control 透传)+ 推测usage 透传)。
来源https://developers.cloudflare.com/ai-gateway/features/caching 、https://developers.cloudflare.com/ai-gateway/usage/providers/anthropic 、https://github.com/openclaw/openclaw/issues/46709 、https://developers.cloudflare.com/workers-ai/features/prompt-caching
### 7.2 Portkey
- **有明确的规范化文档**Bedrock Prompt Caching 页):
- 「Portkey normalizes responses to the OpenAI format」`prompt_tokens` **包含**缓存 token`prompt_tokens = inputTokens + cache_read_input_tokens + cache_creation_input_tokens`。
- `cached_tokens` 出现在 usage 里OpenAI 风格);定价时先从 prompt_tokens 减去缓存部分,再分别按 base input / cache read折扣价/ cache write 计价。
- 其观测端/Inference API Responses 返回 `usage.input_tokens_details.cached_tokens`(官方 API 参考示例)。
- 自带「响应缓存simple/semantic」与 prompt cache 是两回事Portkey blog 明说两者可叠加)。
- **结论**Portkey 会保留并**主动规范化**缓存字段到 OpenAI 风格(`prompt_tokens_details.cached_tokens`),且计费按缓存分项。证据等级:官方文档。
来源https://docs.portkey.ai/docs/integrations/llms/bedrock/prompt-caching 、https://docs.portkey.ai/docs/integrations/llms/openai/prompt-caching-openai 、https://docs.portkey.ai/docs/api-reference/inference-api/responses/retrieve-response 、https://portkey.ai/blog/openais-prompt-caching-a-deep-dive
### 7.3 LiteLLM
- 官方 Prompt Caching 文档「For the supported providers, **LiteLLM follows the OpenAI prompt caching usage object format**」→ OpenAI 兼容 `completion()` 返回 `usage.prompt_tokens_details.cached_tokens`;同时返回对象里也带 Anthropic 原生 `cache_creation_input_tokens` / `cache_read_input_tokens`(官方示例的 Usage 对象同时含两者)。即**双格式并存**(规范化 + 保留原生)。
- `/v1/messages`Anthropic 兼容端点):按 Anthropic 原生返回 `cache_creation_input_tokens` / `cache_read_input_tokens`(官方 anthropic_unified 文档)。
- **已知 bug #27763**Anthropic `/v1/messages`(含 Vertex/Bedrock 透传路径)**不会把原生 `cache_read_input_tokens` 映射成 `prompt_tokens_details.cached_tokens`**,导致 Prometheus 的 `litellm_cached_tokens_metric_total` 恒为 0、缓存命中看起来像没发生`litellm_spend_metric` 可能把缓存读取按全价算。
- 计费:有 `cache_read_input_token_cost` / `cache_creation_input_token_cost` 单价,但**计费 bug 多**litellm#26807自定义定价路径缓存 token 按全价算,用户多付 1.67×)、#33772OpenAI `cache_write_tokens` 未计入成本,消费远低于厂商账单)、#11364Anthropic 缓存成本算错)、#34875生产流式 80.7% 行成本 $0并发竞态
- 流式:默认不强制 include_usage`always_include_stream_usage` 默认关);合成末端 usage chunk 曾有 `choices` 非空的历史 bug#28735 等)。
- **结论**LiteLLM 意图是「OpenAI 风格规范化 + 保留原生」,但 Anthropic 透传路径的功能与计费都有多个已知坑,实验中应同时对比原生字段与 `cached_tokens`。证据等级:官方文档 + issue 讨论 + 第三方调研。
来源https://docs.litellm.ai/docs/completion/prompt_caching 、https://docs.litellm.ai/docs/anthropic_unified 、https://github.com/BerriAI/litellm/issues/27763 、#26807 、#33772 、#11364 、https://github.com/cuihuan/awesome-ai-gateway/blob/main/docs/virtual-keys-metering.zh-CN.md
### 7.4 Vercel AI Gateway顺带
- vercel/ai#139072026-03经 Vercel AI Gateway 调 `moonshotai/kimi-k2.5`,面板正确显示 Cache Read 5.8M93.5% 命中),但**账单按全价输入计费**——真实成本 $4.00 vs 直连 $1.286×。→ 网关侧「显示缓存但不应用缓存折扣」的实例。证据等级issue 讨论。
- 来源https://github.com/vercel/ai/issues/13907
---
## 8. 实验建议(通过聚合层验证缓存字段时的检查清单与坑)
### 8.1 该检查哪些字段(按入口格式)
- **OpenAI 兼容入口(大多数聚合层采用)**
- `usage.prompt_tokens_details.cached_tokens`(聚合层规范化后应在此)
- 扩展字段OpenRouter `cache_write_tokens`、`cache_discount`、`cost_details`LiteLLM 同对象里还可能带 `cache_creation_input_tokens` / `cache_read_input_tokens`
- Responses API 入口Codex 类客户端):`usage.input_tokens_details.cached_tokens`one-hub 曾因 omitempty 漏掉此字段)
- **Anthropic 兼容入口(`/v1/messages`**`usage.input_tokens`、`cache_creation_input_tokens`、`cache_read_input_tokens`+新格式 `cache_creation.ephemeral_5m/1h_input_tokens`
- **DeepSeek/部分上游**:顶层 `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens`(注意有些聚合层会原样透传、有些会映射进 `cached_tokens`——Bifrost 的做法是映射)
- **同一个请求的三种视图都要抓,不能只看一种**
1. 客户端收到的响应 usage
2. 网关消费日志/账单里的 token 拆分new-api#6144 的教训:这两者可能不一致——响应是对的、账单是坏的);
3. 上游(如果可直连对照)原生 usage——用于判断聚合层是「透传」「改名」还是「丢弃」。
### 8.2 已知的坑(汇总自本节调研)
1. **流式 usage 必须有 `stream_options.include_usage=true`**,否则 OpenAI Chat Completions 风格的流根本没有 usage chunkOpenRouter 同规则LiteLLM 有合成兜底但历史上有格式 bug。实验脚本务必显式带该参数并对齐最后一个 chunk。
2. **网关自身另有「响应缓存」result cache**OpenRouter 的 `X-OpenRouter-Cache-Status: HIT` 时 usage 全为 0Cloudflare AI Gateway 有 `cf-aig-cache-status`;此类命中不是 prompt cache别误读为「缓存命中 token=0」。
3. **前缀漂移/路由漂移**:聚合层多供应商路由会让同一 session 落到不同上游导致缓存失效OpenRouter 用 `session_id` 做 sticky routing 以保缓存。实验中固定供应商(`provider` 参数)或固定 session_id 再测。
4. **客户端解析 bug 会掩盖真相**opencode 对 OpenAI-compatible 流式 provider 的 `tokens_cache_read` 恒 0#33997——不要用 opencode 的展示值当结论,要看原始 SSE。
5. **显示 vs 计费分离**new-api xAI 渠道(#6144响应正确但账单按全价Vercel AI Gateway#13907面板显示缓存但账单全价。**验证「缓存字段是否透传」和「缓存是否影响账单」是两件事**,后者在中转站/订阅网关里只能靠站方后台,无法从响应验证。
6. **供应商/模型差异**同一聚合层下DeepSeek 缓存可能不过网关china-llm 实测 OR 上 0 cached而 GLM 正常Kimi K3 缓存折扣不经过 OpenRouter。实验要按模型逐个测不能拿一个模型代表全部。
7. **语义差异**Anthropic 的 `input_tokens` 是「最后一个缓存断点之后的 token」缓存读取已排除OpenAI 的 `prompt_tokens` **包含**缓存读取。字段 `cached_tokens > input 总量` 只有在排除语义下才可能出现new-api#5003 曾因此把输入算成负数)。取值与对账时务必按供应商语义。
8. **订阅制服务Copilot/Windsurf/Augment/Cursor 订阅)没有公开 per-request usage API**无法从响应侧做该实验Cursor 面板展示的 cache read/write 数据点据客服称来自上游响应。若实验目标是「验证缓存命中 token」应优先选按量 APIOpenRouter、Zen、中转站
9. **国内中转站验证**Claude Code 场景看 `cache_creation_input_tokens` / `cache_read_input_tokens` 是否存在且随轮次递增(命中);缺失=该站(逆向/无缓存)不保留缓存字段。社区普遍以「响应 usage 是否带缓存字段」作为中转是否『支持缓存计费』的验收标准。
10. **缓存写入也有计费折扣的镜像**OpenRouter 用 `cache_write_tokens`、Anthropic 用 `cache_creation_input_tokens`5m=1.25×、1h=2× 输入价)。实验前两轮必然出现 cache write>0、cache read=0符合预期别把首轮 cache read=0 当成「网关丢字段」。
### 8.3 建议的最小实验矩阵
| 层 | 建议入口 | 必查字段 | 对照 |
|---|---|---|---|
| 直连官方(对照组) | Anthropic/OpenAI/DeepSeek 原生 | `cache_read_input_tokens` / `cached_tokens` / `prompt_cache_hit_tokens` | — |
| OpenRouter | `chat/completions` + include_usage | `cached_tokens`+`cache_write_tokens`+`cost`/`cache_discount` | 与直连对照;固定 provider+session_id |
| opencode Zen/Go | `v1/chat/completions`/`v1/messages` | 协议原生缓存字段 | 与官方价格表 Cached Read 列对照 |
| new-api/one-hub | chat/completions流式+非流式各一遍) | `cached_tokens`;同时看网关消费日志 | 非流式作为基线(历史上流式丢字段 bug 多) |
| LiteLLM | completion + /v1/messages | `cached_tokens``cache_read_input_tokens` 是否同时出现 | 抓 `litellm_cached_tokens_metric` 是否>0 |
| 国内中转站 | Anthropic 兼容 | `cache_creation/read_input_tokens` | 两轮同前缀请求,命中应>0 |
---
## 9. 一句话总结
- **透传且规范化得最好**OpenRouter统一 OpenAI 风格 `cached_tokens`+扩展、Portkey明确规范化并分项计价、Bifrost`prompt_cache_hit_tokens` 映射为 `cached_tokens`)。
- **意图透传但坑多**new-api / one-hub多个流式/Responses bug、LiteLLMAnthropic 透传路径不映射、计费 bug 多、Cloudflare AI Gateway未文档化推测透传
- **计费不含缓存或订阅不暴露**one-api无缓存单价、Copilot聚合 API 无缓存拆分、Windsurf/Augment按缓存折扣计费但不暴露原始字段、Cursor面板展示缓存明细但没有公开 API
- **核心陷阱**:「客户端收到的 usage」≠「网关账单」≠「上游计费」三者要分开验证流式必须 `include_usage`;注意区分网关的 prompt cacheKV cache 命中与网关的响应缓存result cache可能返回 usage 全 0
---
*报告完。所有引用为调研时2026-08-29可访问的 URL证据等级逐条标注凡「未找到证据」处均已如实说明。*

View File

@ -1,211 +0,0 @@
# Astrion Gateway 视角现状盘点(只读审计)
> 审计范围:工作区根目录 `<workspace>`
> 审计时间2026-09-07代码快照以当日工作区为主
> 审计方式:只读分析,未修改任何文件
> 分析视角:把 Flask server 渐进升级为 "Runtime/Gateway"——session/run/approval/event 的唯一 owner
> Web/CLI/Desktop/Android 仅作状态投影。以下结论均以具体文件/函数/类名佐证。
>
> 说明:本报告为“现状盘点 + 差距清单”,属于**静态代码分析结论**,未做运行时验证;
> 涉及“重启丢失”“断线行为”等结论源自代码路径推演,标注了不确定度。
---
## 1. API 表面清单
### 1.1 蓝图注册REST 面)
注册点:`server/app_legacy.py:298-310``app.register_blueprint(...)`),共 12 个蓝图:
| 蓝图 | 定义文件 | 覆盖类别 | 代表性端点 |
|---|---|---|---|
| `auth_bp` | `server/auth.py` | 登录/注册/会话/CSRF | `POST /login`L123、`POST /host-login`L223、`POST /register`L291、`GET /api/csrf-token`L109、`GET /api/session-status`L369 |
| `files_bp` | `server/files.py` | 文件浏览/上传下载 | `GET /api/files`、`GET/POST /api/gui/files/text` |
| `admin_bp` | `server/admin.py` | 管理后台/策略/API 用户 | `GET /api/admin/dashboard`、`GET/POST /api/admin/api-users`、`GET /api/admin/api-users/<u>/token` |
| `conversation_bp` | `server/conversation.py` | 对话 CRUD/草稿/压缩/版本化 | `GET/POST /api/conversations`L571/L639、`GET /api/conversations/<id>/messages`L1137、`GET/PUT /api/input-draft`L431/L457、`/api/conversations/<id>/versioning*` |
| `chat_bp` | `server/chat/{approval,permission,settings,terminal,files,misc}.py` | 审批/模式/设置/终端/杂项 | `GET/POST /api/permission-mode`permission.py L138/152、`GET/POST /api/work-mode`L404/420、`GET /api/plan-approvals/pending`approval.py L92、`GET /api/socket-token`terminal.py L64、`GET /api/gui/monitor_snapshot`misc.py L62 |
| `usage_bp` | `server/usage.py` | 用量配额 | `GET /api/usage` |
| `status_bp` | `server/status/{base,app,docker,file_open,git,host_workspace,sandbox}.py` | 健康/状态/项目/工作区/沙箱 | `GET /api/health`base.py L69、`GET /api/status`L90、`GET /api/projects`docker.py L91、`GET /api/host/workspaces`host_workspace.py L57、`GET /api/sandbox/status`sandbox.py L30 |
| `tasks_bp` | `server/tasks/{api,skills,media}.py` | **REST 任务轮询主线** | `POST /api/tasks`api.py L111、`GET /api/tasks/<id>?from=idx`L229事件轮询核心、`POST /api/tasks/<id>/cancel`L284、`GET /api/conversations/<id>/running-status`L56对账接口、`POST /api/tasks/<id>/runtime_guidance`L305 |
| `api_v1_bp` | `server/api_v1.py` | 面向 API/CLI 的 v1Bearer token | `GET /tools`L24、`POST /workspaces/<ws>/messages`L254、`GET /workspaces/<ws>/conversations`L346、`GET /tasks/<id>`L415、`GET /models`L718**无鉴权**)、`GET /health`L742无鉴权 |
| `multi_agent_bp` | `server/multi_agent.py` | 多智能体角色/设置/对话创建 | `POST /api/multiagent/conversations`L288、`GET /api/multiagent/active_sub_agents`L483 |
| `workflow_page_bp` / `workflow_runtime_bp` | `server/workflow_page.py` / `server/workflow_runtime_api.py` | 工作流页面与运行时 | `POST /api/workflow/activate`、`GET /api/workflow/status`、`GET/PUT/DELETE /api/workflows/<name>` |
| `conversation_bootstrap_bp` | `server/conversation_bootstrap.py` | 对话首屏恢复 | `GET /api/conversations/<id>/bootstrap` |
**REST 为主**AGENTS.md §1 明示 "REST 任务轮询为主Socket.IO 主要用于兼容与实时辅助通道"`server/tasks/api.py` 注释同("将聊天任务与 WebSocket 解耦,支持后台运行与轮询")。
### 1.2 Socket.IO 事件面
注册点:`server/socket_handlers.py`(全部 `@socketio.on(...)`。Socket=连接面,**不是业务消息主通道**
- **客户端→服务端**`connect`L20携带 `socket_token` 认证)、`disconnect`L79、`stop_task`L130、`terminal_subscribe`L154、`terminal_unsubscribe`L199、`get_terminal_output`L208、`send_message`L241**已废弃**,直接返回 `DEPRECATED` 提示)、`client_chunk_log`L338、`client_stream_debug_log`L358
- **服务端→客户端(房间规则,见 §4**
- 用户房间广播:`user_{username}`(任务/流事件、通知),`user_{username}_terminal`(终端事件)
- 终端专属房:`user_{username}_terminal_{session_name}`(订阅某个持久终端)
- `terminal_subscribers` 全局房(`app_legacy.py:terminal_broadcast`L1039-1048
- 全局广播(不带房间):`token_update` / `todo_updated` / `edited_files_updated`
**业务事件类型**emit 点见 §2
- 流式:`thinking_start/thinking_chunk/thinking_end`、`text_start/text_chunk/text_end`、`tool_intent/tool_preparing/tool_status/tool_start/update_action`、`api_request_start`、`stream_reset`、`error`、`task_stopped`
- 任务:`user_message`、`task_complete`、`system_message`、`quota_notice/quota_exceeded`
- 审批:`tool_approval_required/tool_approval_resolved`、`plan_approval_required/plan_approval_resolved`
- 系统:`system_ready`、`status_update`、`conversation_changed`、`conversation_list_update`、`conversation_resolved`、`conversation_loaded`、`token_update`、`context_warning`、`todo_updated`、`edited_files_updated`、`terminal_list_update`、`terminal_started`、`terminal_history`、`terminal_output_history`、`compression_state/compression_finished`、`trim_memory_message` 等
结论:**API 面 = 12 个蓝图 REST其中 tasks_bp 是轮询主线)+ 1 个 Socket.IO 辅助通道**;消息发送与进度事件均已收敛到 `POST/GET /api/tasks` 轮询模型Socket 通道退化为“实时补充 + 终端 + 系统广播”。
---
## 2. 事件通道现状
### 2.1 agent loop 进度事件的两条出口
**单一 sender 抽象**:任务线程内定义局部 `sender(event_type, data)`把【写入事件流】与【Socket 实时推送】合并为一个动作:
- 主任务:`server/tasks/models.py::TaskManager._run_chat_task` 内 `def sender`L907-931
1. `self._append_event(rec, event_type, data)`——写入任务事件流(供轮询)
2. `socketio.emit(event_type, data, room=f"user_{username}")`——同键实时推送
- Socket 兼容链路 `server/socket_handlers.py::handle_message` 里的 `send_to_client/send_with_activity`L306/L324仅发 socket、不写事件流——**但该入口已废弃**。
- 通知池轮询器 `server/chat_flow_task_main.py::poll_completion_notifications`L960 附近)与 `poll_multi_agent_notifications`L1115 附近)各自内联 `sender`(只 socket不写 task events其中前置通知的**消息本身**随后续任务经 `_dispatch_completion_user_notice`/`inject_multi_agent_master_message` 注入历史/事件流,见 AGENTS.md §11.3)。
- 标题后台生成:`server/chat_flow_helpers.py::generate_conversation_title_background`L100 起)——找到 running task 时 `task_manager._append_event(running_task, 'conversation_changed', ...)`L105-120**若没有 running task 则只走 socket 广播L130-138REST-only 客户端收不到**。
### 2.2 事件流存储idx/offset 机制)
实现文件:**`server/tasks/models.py`**
- 存储:`TaskRecord.events: deque(maxlen=20000)`L82纯**进程内内存**,随任务存活;`TaskManager.cleanup_old_tasks(max_age_seconds=3600)`L1081 小时清理已完成任务。
- 序号:`TaskRecord.next_event_idx`L92`_append_event`L762-782为每条事件分配单调递增 `idx`(从 0 起,`{idx, type, data, ts}`)。**序号是 per-task 的,任务之间无全局序号**。
- 消费:`TaskManager.get_events_since(rec, offset)`L226-234在锁内快照后过滤 `e["idx"] >= offset`
- API`GET /api/tasks/<task_id>?from=<offset>``server/tasks/api.py::get_task_api` L229→ 返回 `events + next_offset`=最后一条 idx+1无新事件则原样返回 offset
- 并发保护:追加与快照都在 `threading.Lock` 下,注释明确说明 deque 并发迭代会 `RuntimeError`L227-231
### 2.3 断线重连后客户端如何追数据(三层机制)
1. **事件流偏移继续**Web 前端 `static/src/stores/task.ts` 每 250ms 轮询 `?from=next_offset`L167-177`pollingIntervalMs: 250`CLI `cli/src/api.ts::pollTask`L233+ `cli/src/App.tsx::pollTask`L491-516700ms 间隔)。轮询与 socket 相互独立socket 断线期间错过的块由下一次轮询补齐。
2. **对账接口兜底**`GET /api/conversations/<id>/running-status``server/tasks/api.py` L56-108聚合主 task/子智能体/后台命令/多智能体四类“正在运行”状态;前端每 2.5s 调一次 `startRunningStateReconcile``static/src/app/methods/taskPolling/probe.ts` L47-120。若发现服务端有活动主任务而本地没在轮询 → `resumeTask(main_task_id, { resetOffset: true })` **从 0 全量重放事件**
3. **前端去重**`_processedEventIndices: Set``lifecycle.ts` L105-120 附近)以 `task_id:idx` 为 key 去重,重放事件第二次出现直接跳过。
4. **Socket 重连**`static/src/composables/useLegacySocket.ts` — `reconnect_attempt` 时重新取一次性 socket-tokenL588-597`connect` 后 `resetAllStates` + `scheduleHistoryReload`L599-624从 REST 重拉对话历史。
**结论**
- 有“每任务单调 idx”**没有全局事件序号**socket 事件不携带 idx两条通道的合并去重完全靠前端轮询模式甚至直接跳过 socket 流事件,`useLegacySocket.ts``if (ctx.usePollingMode && !ctx.waitingForSubAgent) return;`)。
- 事件流是**任务作用域 + 内存 + 1h TTL + 20000 条上限**,任务结束后即失去可追性;重启服务后所有事件流丢失,客户端只能靠对话文件重建最终态。
---
## 3. 状态归属清单(“真状态”在哪)
| 状态类别 | 真状态位置 | 佐证(文件:函数/行) |
|---|---|---|
| **对话历史(持久)** | 文件:`{data_dir}/conversations/{workspace_id}/{conversation_id}.json` + `index.json` | `utils/conversation_manager/base.py` L46/L54-55conversations_root/index_file、`metadata_mixin.py` L57`f"{conversation_id}.json"`)、`crud_mixin.py` L298/L589save/load_conversation |
| **对话历史(运行时缓存)** | 每对话一个 WebTerminal 实例的 `context_manager.conversation_history` 内存列表 | `core/web_terminal.py` L673`conversation_count: len(self.context_manager.conversation_history)` |
| **终端缓存key 设计)** | `server/state.py::user_terminals` 进程 dictkey=`username::workspace_id::conversation_id`(不传 conversation_id 时两段 key | `server/context.py::_make_terminal_key` L57-70、`get_user_resources` L226-236 等24h TTL 回收 `context.py` L856-935`CONVERSATION_TERMINAL_TTL_SECONDS` |
| **任务状态** | `server/tasks/models.py::TaskManager._tasks` 进程内 dict`threading.Lock` 保护TaskRecord 含 status/events/next_event_idx1h 清理 | `models.py` L101-107TaskManager.__init__、L108cleanup_old_tasks**进程重启丢失** |
| **审批状态tool / user-question / plan** | 三个**进程内单例**`server/state.py` L30-32 `tool_approval_manager / user_question_manager / plan_approval_manager`;各自 `_items` dict + lock绑定 `username + conversation_id` | `modules/tool_approval_manager.py` L11-30create_request 字段)、`modules/plan_approval_manager.py` L22-58、`modules/user_question_manager.py`**重启丢失** |
| **权限模式 / 执行环境 / 网络权限** | WebTerminal 实例属性(`current_permission_mode`、`current_execution_mode`、`host_network_permission`、`pending_*` 排队态);对话 metadata 持久化(`pre_plan_*`、`pre_readonly_execution_mode` 等);个性化默认值 | `core/main_terminal_parts/tools_policy.py` L236-330get/set/switch_work_mode、set_permission_mode`core/main_terminal.py` L290-326get_pending_runtime_modes / queue_*AGENTS.md §10.6 |
| **work_modeplan/ask/execute** | 同上terminal 实例 `current_work_mode` + 对话 metadata `work_mode` + 个性化 `default_work_mode` | `tools_policy.py` L256-330`server/chat/permission.py` L404-420`GET/POST /api/work-mode`,运行中 409 |
| **子智能体任务** | `SubAgentManager``modules/sub_agent/manager.py` L52-105挂在对话级 WebTerminal 上:内存 `tasks`/`_running_tasks`/`conversation_agents` + 文件 `{data_dir}/sub_agents.json`、`{data_dir}/sub_agent_tasks/` | manager.py L63-64state_file/base_dir、L105-122_load_state/reconcile/restore |
| **多智能体会话运行态** | `GLOBAL_MULTI_AGENT_STATES: Dict[conversation_id, MultiAgentState]` **进程级注册表**(特意从 manager 实例属性提升,根治多副本分裂) | `modules/multi_agent/state.py` L668-677注册表 + RLockL262-277MultiAgentState.__init__conversation_id/pending_master_messagesL605-612to_snapshot |
| **模型选择** | 对话 metadata `model_key` 为权威(对话级 terminal 加载恢复)+ terminal 实例 `model_key` + flask session `model_key`(仅工作区级) | `core/web_terminal.py` L503-508加载对话模式、L272-315create 路径);`core/main_terminal.py` L105-106默认模型 |
| **personalization** | 文件:每工作区 `{data_dir}/personalization.json``default_run_mode`/`default_permission_mode`/`default_work_mode`/`review_agents` 等) | `modules/personalization_manager.py` L36`PERSONALIZATION_FILENAME`、L196load_personalization_config`server/chat/settings.py` L279-327`GET/POST /api/personalization` |
| **运行时模式fast/thinking/deep** | terminal 实例 `run_mode`/`thinking_mode`/`reasoning_effort` + 会话同步 + drift 通知基线 | `core/web_terminal.py` L618-660get_status`server/chat_flow_task_main.py` L1702-1732collect_runtime_mode_drift |
| **monitor 快照** | `server/monitor.py` 进程内缓存 `MONITOR_SNAPSHOT_CACHE`(上限 120 | `server/monitor.py` L10-60 |
总评:**“真状态”分散在 4 层**——①进程内内存对象terminal、task_manager、三个 approval manager、GLOBAL_MULTI_AGENT_STATES、usage trackers、socket token/stop flags、②运行态文件conversation/*.json、sub_agents.json、personalization.json、settings.json、③Flask session登录态、workspace_id、model_key 等)、④前端本地(渲染消息列表、输入草稿 `input-draft` 文件在 `server/conversation.py` L298-327、对话类型 localStorage。目前没有一个“系统级 owner”统一持有 session/run/approval/event。
---
## 4. 多客户端假设排查
### 4.1 Socket.IO room / 命名空间的使用(无 conversation 级隔离)
- 房间全部以**用户**为粒度:`user_{username}`、`user_{username}_terminal`、`user_{username}_terminal_{session_name}`、全局 `terminal_subscribers``server/socket_handlers.py` L62-73、L154-198`server/app_legacy.py` L1039-1048
- **没有 per-conversation room/命名空间**。对话级终端事件靠 `server/context.py::_wrap_callback_with_conversation_id`L72-86`conversation_id` 塞进事件 payload由**前端自行过滤**`useLegacySocket.ts` 每个 handler 开头 `if (data?.conversation_id && data?.conversation_id !== ctx.currentConversationId) return;`)。
- 这意味着服务端不知道“哪个客户端正在看哪个对话”,广播只能“发给全用户,客户端自己挑”。多客户端(尤其不同对话的多标签页)会收到彼此对话的全部事件流量。
### 4.2 终端会话绑定
- socket 事件处理都通过 `get_terminal_for_sid``get_user_resources(username, conversation_id=...)` 解析终端(`server/socket_handlers.py` L156/L211`terminal_subscribe` 带 `conversation_id` 只是用于“选对终端实例”,房间名仍是用户级。
- 持久终端(`TerminalManager`)挂在**对话级 WebTerminal** 上key=`username::workspace_id::conversation_id`§3——**同对话的多个客户端共享同一个 WebTerminal 实例**,这方向正确,但该实例是多写者可变对象:`attach_user_broadcast(terminal, username)` 在**每个请求**都会重绑回调(`context.py` L509 等),多端并发请求会互相重置 terminal 上的会话级状态(已用 conversation-bound 保护模型,但模式/message_callback 仍是共享可变面)。
### 4.3 socket token 发放互踩(多客户端缺陷)
`server/chat/terminal.py::issue_socket_token`L62-80`prune_socket_tokens()`,然后**清空该用户名下所有旧 pending token**,再发一个新 token45s TTL`SOCKET_TOKEN_TTL_SECONDS`)。同一用户两个客户端几乎同时请求 socket-token 并握手时,后发请求会让先发 token 失效,导致先连的 socket 认证失败。单用户单端假设的残留。
### 4.4 断线即停任务假设
`server/socket_handlers.py::handle_disconnect`L77-125只有“同用户**没有其他活跃连接**且**没有 REST running 任务**”时才置停标志/取消任务。这隐含“socket 是任务生命周期的一部分”的旧假设;当前主链路任务实际由 REST 发起task_id 级 stop flag`server/state.py::make_stop_keys` L140-155socket 侧的 stop 只是兼容索引。Gateway 化后任务必须与任何客户端连接解耦。
### 4.5 审批弹窗状态
- 审批请求体绑定 `username + conversation_id`(非 sid`modules/tool_approval_manager.py::create_request` L16-45 —— 方向上利于任意同用户端审批CLI 也能通过事件流 `tool_approval_required` 渲染审批 UI`cli/src/App.tsx` L503-512
- 但**等待方**是任务线程对进程内存 dict 的轮询(`server/chat_flow_tool_loop.py::_wait_for_tool_approval` L232-244、`_wait_for_plan_approval` L294-303审批状态无文件/DB 持久化,**服务重启后 pending 审批全部丢失**,等待中的任务线程只能得到 `approval_missing` 拒绝结果。
- 前端弹窗 UI`PlanApprovalDialog.vue` 等)是各端本地渲染;多端同时打开同一对话时,审批事件会广播给所有端,谁先 answer 谁生效(同 username 校验,`tool_approval_manager.py::decide` L78-91无“审批被某端认领”的概念。
### 4.6 stream 进行中的对话切换
前端已做双向防护(这是现状中做得最完整的部分):
- `static/src/app/methods/taskPolling/lifecycle.ts`conversation_id 不匹配丢弃L50-90、task_id 不匹配丢弃L96-133、`_processedEventIndices` 去重L105-120
- `static/src/stores/task.ts` L223-236轮询在途响应若发现 `currentTaskId` 已变则整包丢弃stale-response-ignored
- `static/src/app/methods/conversation/action.ts` L99“创建新对话只切换视图不再取消后台任务停止当前轮询避免事件串写”。
- 回到正在运行的对话时由 2.5s 对账循环 `resumeTask(resetOffset: true)` 全量重放§2.3)。
### 4.7 其它隐含“单客户端”的点
- **输入草稿**`/api/input-draft` 以 username/workspace 为粒度写文件(`server/conversation.py::_resolve_input_draft_path` L298-309多端同用户共用会互相覆盖。
- **`active_polling_tasks: Dict[conversation_id, bool]`**`server/state.py` L39仍以“一个对话一个轮询者”建模。
- **CSS/前端状态**`currentConversationType`/`newConversationType`/`sidebarConversationType` 等 localStorage 键AGENTS.md §11.0)都是“每浏览器”视角,与服务器状态无关(对投影架构不构成阻塞,但说明端状态模型是单端假设的)。
---
## 5. 认证现状
| 链路 | 实现 | 佐证 |
|---|---|---|
| **Web 用户体系** | Flask sessioncookie+ `login_nonce` 双因子:`is_logged_in()` 要求 `session.username` 且 nonce 在 `state.active_login_nonces` 集合内;邮箱+密码 `user_manager.authenticate`IP/账号维度限流与锁定5 次/300s | `server/auth_helpers.py` L15-28`server/auth.py::login` L123-221`server/security.py` L101-155 |
| **host-login** | `POST /host-login`:仅 `TERMINAL_SANDBOX_MODE=host` 且回环地址127.0.0.1/::1/localhost可调免密创建 admin 会话 `username=host, role=admin, host_mode=True` | `server/auth.py::host_login` L224-270 |
| **CSRF** | `X-CSRF-Token` header或表单字段比对 session 内 token`requires_csrf_protection``/api/*` 前缀全部受检(`CSRF_PROTECTED_PREFIXES``/api/v1/*` 与 `Bearer` 请求豁免;`/login /register /logout /host-login` 受检 | `server/security.py` L157-190`server/state.py` L103-108常量 |
| **API token/api/v1** | `/api/v1/*` 全部 `api_token_required``Authorization: Bearer <token>` → SHA256 匹配 `api_user_manager` 存储的 `token_sha256` → 写入 session + `g.api_username`**两个例外** `GET /api/v1/models`、`GET /api/v1/health` 无鉴权 | `server/api_auth.py` L12-54`server/api_v1.py` L718/L742无装饰器 |
| **socket 认证** | `GET /api/socket-token` 发一次性 token45s TTL绑定 username + User-Agent fingerprintconnect 时 `consume_socket_token` 一次性消费,失败则 disconnect | `server/chat/terminal.py` L64-80`server/security.py` L191-214`server/socket_handlers.py::handle_connect` L20-73 |
| **CLI 用的链** | host-login无凭证回环→ 拿到 session cookie + CSRF token → 全部走 **Web API非 /api/v1**`/api/status`、`/api/tasks` 轮询、`/host/workspaces` 等REST 轮询 700ms | `cli/src/api.ts` L38-52fetchCsrf、L96hostLogin、L226-239createTask/pollTask、L310默认 8091 |
要点ClI及未来 Desktop复用“会话 cookie + CSRF”的**宿主态**,不是独立的运行时身份;`/api/v1` Bearer token 体系与 Web/CLI 会话体系**并存但互不相通**API 用户有自己的工作区目录 `api/users/`,见 AGENTS.md §1.5.1。Android 是 WebView 壳(`android-webview-app/`Brige 走 `$BASE_URL`),认证跟随 Web 前端会话。
---
## 6. 差距清单(距“任意客户端投影同一状态”还差什么)
按严重度排序:
1. **无统一事件序号 / 无事件总线**。事件 idx 是 per-task 的(`TaskRecord.next_event_idx``server/tasks/models.py` L762-782socket 事件不带 idx任务之间、通道之间无法对齐全局时间序。Gateway 需要对话级(或全局)持久化事件日志 + 全局 seq/游标,客户端按游标订阅/追平。
2. **事件流是任务作用域、内存态、会蒸发**。`deque(maxlen=20000)` + 1h cleanup`server/tasks/models.py` L108/L82任务结束/进程重启后事件流即失,后续客户端只能靠对话文件重建“最终态”,无法重放“过程”。断线追赶完全依赖前端“从 0 重放 + 去重”的脆机制(`probe.ts::resumeTask(resetOffset)`)。
3. **审批/提问/计划批准 = 进程内存 + 绑定 username+conversation_id无端认领**。`tool/user_question/plan_approval_manager` 三个单例(`server/state.py` L30-32重启即失等待侧在任务线程轮询内存 dict`server/chat_flow_tool_loop.py` L232-244。Gateway 需要把 approval 做成持久化实体 + 审计 + 多端可见的“认领/副作用”语义;目前任何同用户端可 answer但无“哪个端在展示、谁决定”的记录。
4. **状态真源分四层、无唯一 owner**§3 总评)。尤其工作区级 vs 对话级 WebTerminal、`GLOBAL_MULTI_AGENT_STATES`key=conversation_id、task_manager、session 四处都可写“运行模式/模型/任务”相关状态,未来任一 owner 化都要先收敛。
5. **终端缓存 key 已按对话隔离(好),但实例是共享可变对象**。`username::workspace_id::conversation_id``server/context.py::_make_terminal_key`)让同对话多端共享实例;但每请求 `attach_user_broadcast` 重绑回调 + `current_conversation_id`/`model_key`/模式属性可变,多端并发读写下仍有覆盖面(主任务写入已由 `server/main_task_gate.py` 单写者门闸保护,见 AGENTS.md §12——这是 gateway 唯一 owner 的一个雏形)。
6. **广播粒度是 user 房间,无 conversation 级订阅/投影**。服务端无法回答“谁在看对话 X”`_wrap_callback_with_conversation_id` + 前端过滤是补丁不是投影。Gateway 需要 conversation 级 topic/room + 客户端显式订阅。
7. **socket-token 发放互踩**§4.3`issue_socket_token` 清空同用户旧 token多标签/多端并发建连会互相踢。
8. **“停止/活动”仍假设任务绑定连接**`handle_disconnect` 的 `has_other_connection` / REST running 任务判断(`server/socket_handlers.py` L77-125Gateway 下任务生命周期必须独立于任何连接。
9. **REST-only 客户端存在事件盲区**:标题更新无 running task 时只走 socket`server/chat_flow_helpers.py` L130-138`token_update/todo_updated/edited_files_updated` 是全局广播不一定进任务事件流terminal_broadcast`app_legacy.py` L1039-1048
10. **认证两套并存**session+CSRFWeb/CLI/host-login与 Bearer token/api/v1不互通真要“多端以同一身份投影”需要统一身份/会话模型token 化 + 会话复用到 socket
---
## 现状速查表
| # | 项 | 现状一句话 |
|---|---|---|
| 1 | 传输主线 | REST 250ms(Web)/700ms(CLI) 轮询 `GET /api/tasks/<id>?from=idx`Socket.IO 是兼容/辅助通道(`tasks/api.py`、`socket_handlers.py` |
| 2 | 事件序号 | 仅 per-task `next_event_idx`0 起deque 20000 上限);**无全局序号**socket 事件无 idx`tasks/models.py` L762-782 |
| 3 | 断线/换端追赶 | 2.5s `running-status` 对账 → `resumeTask(resetOffset=true)` 从 0 重放 + 前端 `task_id:idx` 去重(`probe.ts`、`lifecycle.ts` |
| 4 | 对话历史 | 文件 `{data}/conversations/{ws}/{conv}.json` 权威;每对话 WebTerminal 内存缓存24h TTL 回收(`conversation_manager/base.py`、`context.py` L856 |
| 5 | 任务状态 | `TaskManager` 进程内 dict1h 清理、重启丢失(`tasks/models.py` L101-108 |
| 6 | 审批状态 | `tool/user_question/plan_approval_manager` 三个进程内单例,绑定 username+conversation_id重启丢失`state.py` L30-32 |
| 7 | 子智能体/多智能体 | SubAgentManager 挂对话级 terminal内存+sub_agents.jsonMultiAgentState 走进程级 `GLOBAL_MULTI_AGENT_STATES`key=conv_id`sub_agent/manager.py`、`multi_agent/state.py` L668 |
| 8 | 模式work/permission/execution | terminal 实例属性 + 对话 metadata + personalization.json 默认值;运行中切换用 pending 队列(`tools_policy.py`、`main_terminal.py` L290-326 |
| 9 | 认证 | Web=session+login_nonce+CSRFhost-login=回环免密 admin/api/v1=Bearer tokensocket=一次性 token 45s`auth.py`、`api_auth.py`、`security.py` |
| 10 | 多客户端 | 同用户同对话共享同一 WebTerminal广播按 user 房间、前端按 conversation_id 过滤socket-token 并发互踩、无 conversation 级订阅(`socket_handlers.py`、`chat/terminal.py` L64-80 |
---
### 附录:本报告结论的不确定度声明
- “状态分布”“事件序号”“认证实现”等结论来自**直接代码阅读**(证据如上),确定度最高。
- “重启丢失”“断线追数行为”“多标签互踩”等结论是**代码路径推演**未经运行时复现验证其中“重启丢失”由类定义无文件落盘可基本确证“socket token 互踩”依赖时序竞争,标注为**很大概率**。
- 任务要求只读盘点,未运行服务;如需确认运行时行为,应以实测为准。

View File

@ -1,129 +0,0 @@
# Client ↔ Gateway 链路审核报告(前端/CLI 消费面)
**审核日期**2026-09-08
**审核对象**`<repo>`Flask 后端 + Vue3 Web + React/Ink CLI
**审核方式**只读。grep/符号定位 + 精确行段阅读,禁止整文件通读。行号以本次审核时代码为准。
**背景**:验证设计文档「①②③ 链路Client ↔ Gateway ↔ Runtime已贯通」四个实例声明是否属实。
**结论前缀**:✅属实 / ⚠️部分属实 / ❌不属实 / 「未验证」
---
## 1. 每项声明的验证结论 + 代码证据
### 声明 A全部任务创建入口已收敛到 runtime_service.create_task(ctx)
**结论:✅ 属实(主 Run 受理层面)。**
**证据:**
- 唯一的 `task_manager.create_chat_task` 生产调用点收敛在网关内:
- `server/runtime/service.py:44``return task_manager.create_chat_task(ctx, conversation_id=conversation_id)`
- 全仓 `grep '\.create_chat_task('` 除以下之外无生产调用:`test/test_runtime_service.py:164`(测试用,验证 None ctx 抛错)、`.claude/worktrees/stoic-matsumoto-ac8ca5/`(旧 worktree不在当前主工作树、`_experiments/verify_conversation_level_terminal.py:83,93`(实验脚本,非生产入口)。
- `runtime_service.create_task(ctx)` 调用点共 6 处,均经公开入口:
- `server/tasks/api.py:187`Web 创建任务路由)
- `server/api_v1.py:335`API v1 消息路由,`@api_token_required`
- `server/workflow_runtime_api.py:192`workflow 激活)、`:269`workflow notice 派发)
- `server/chat_flow_task_main.py:618`(多智能体完成通知派发 `_dispatch_completion_user_notice`)、`:1424`(多智能体闲时派发 `dispatch_ma_idle`
- `RuntimeContext` 三层构造符合契约:适配层直接构造两个点(`server/tasks/api.py:170`、`server/api_v1.py:323`principal 经 `principal_from_session_snapshot` 由认证后的 session 构造);内部通知链/工作流/多智能体用 `RuntimeContext.from_terminal``server/runtime/context.py` 定义,身份取自 terminal/workspace不带客户端可提交的门闸 token 等内部指令。
- 执行链 `create_chat_task → 线程 → _run_chat_task → run_chat_task_sync → process_message_task → handle_task_with_sender` 的调用均在运行线程内部(`server/tasks/models.py:991` 调用 `run_chat_task_sync``chat_flow_task_main.py:664/1899/2500` 在运行线程内 spawn 子分支),不存在独立于网关的直拉过程。
**说明/例外:**
- 子智能体任务、传统后台命令是**独立的派生工作者实体**,不走 `runtime_service.create_task`(被 `server/chat_flow_task_main.py:2642` 注释明确说明「与传统后台任务完全分离,避免两者竞争 create_chat_task 的单工作区互斥」)。这与 `docs/runtime_protocol.md` §2「子智能体/后台命令是主 Run 派生的后台工作者」一致,不算漏网。
### 声明 Bserver/chat/approval.py 六个路由全部转调 runtime_service
**结论:✅ 属实。**
**证据(`server/chat/approval.py`,六路由全部经公共入口):**
- `list_pending_user_questions``runtime_service.list_pending_approvals(..., kind="question")``:56`
- `answer_user_question``runtime_service.resolve_approval("question", ...)``:71`
- `list_pending_plan_approvals``runtime_service.list_pending_approvals(..., kind="plan")``:101`
- `answer_plan_approval``runtime_service.resolve_approval("plan", ...)``:116`
- `list_pending_tool_approvals``runtime_service.list_pending_approvals(..., kind="tool")``:145`
- `decide_tool_approval``runtime_service.resolve_approval("tool", ...)``:161`
**仪表验证:** `server/runtime/service.py``resolve_approval`/`list_pending_approvals` 均作了 kind 路由 + 权限/参数校验,再委托给 `server/state` 下的三个 manager。
**例外观察(非 Web 路由,不算入口违规):**
- `modules/auto_approval_service.py:89` 内部 `tool_approval_manager.decide(...)` 直调,是**自动审批 agent**(执行侧自动裁决),仅被 `server/chat_flow_tool_loop.py:894,1166` 调用(运行循环内)。属于 Execution Plane/Agent Runtime 侧机制,不经客户端入口,但若后续要统一裁决语义可考虑让其复用 `resolve_approval`
### 声明 C公共查询入口 list_runs / get_task / get_task_events / list_pending_approvals / list_sessions / get_session_history
**结论:⚠️ 部分属实run.* 与 approval.* 已供 HTTPsession.list/history 无传输暴露)。**
**证据:**
- `run.list`HTTP 暴露 `/api/tasks` GET`server/tasks/api.py:33-44` → `runtime_service.list_runs`)。
- `run.get`HTTP 暴露 `/api/tasks/<task_id>` GET`server/tasks/api.py:205` → `runtime_service.get_task`)。
- `run.events`HTTP 暴露 `/api/tasks/<task_id>?from=``server/tasks/api.py:220` → `runtime_service.get_task_events`,响应透传 `window_start`)。
- `approval.list`HTTP 暴露 approval.py 三个 pending 路由。
- **`session.list` / `session.history`:❌ 仅进程内服务方法,无任何 HTTP 路由调用。**
- `grep` `list_sessions|get_session_history` 仅命中 `server/runtime/service.py` 定义 + `test/runtime_standalone_checks.py:273/282/290/300`(测试)。
- Web/API v1 的会话查询走的是**旧链路**,未复用公共入口:
- `server/conversation.py:571` `GET /api/conversations` 直接 `terminal.get_conversations_list(...)`
- `server/api_v1.py:351` `GET /workspaces/<id>/conversations` 直接 `conv_dir.glob("conv_*.json")``:385` `GET .../<conv_id>` 直接 `json.loads(_conversation_path...)`
- 即:`list_sessions`/`get_session_history` 是「为 CLI/定时任务预留的进程内直调入口」,但当前 Web/CLI 实际消费会话列表/历史仍在走旧 Web 路由。
### 声明 D事件协议 task 级 idx/offset + meta.window_start 缺口Web 与 CLI 客户端缺口处理
**结论:✅ 属实的服务端/Web 实现;⚠️ CLI 缺口处理不完整(见差距清单)。**
**证据:**
- 服务端:`server/runtime/service.py:get_task_events` 返回 `meta.window_start`(缺口水位);`server/tasks/api.py:220-225` 从 `ev_meta``window_start` 透传 HTTP。
- Web`static/src/stores/task.ts`
- 轮询 `/api/tasks/<id>?from=<offset>``:191`
- 读 `data.window_start``:264`
- 缺口检测 `if (fromOffset > 0 && fromOffset < windowStart)` → 发合成事件 `event_window_gap``:263-285`
- `static/src/app/methods/taskPolling/lifecycle.ts:395-408` `case 'event_window_gap'` → 跳过重建期后 `this.fetchAndDisplayHistory({ force: true })` + toasti18n `stores.eventWindowGap*``locales/zh-CN/stores.ts:63-64`/`en-US/stores.ts:63-64`)。**缺口 → 快照对账链路真实存在。**
- CLI`cli/src/App.tsx:491-503` `pollTask``result.window_start``if (offset > 0 && offset < windowStart)` 提示部分实时输出已被裁剪以最终结果为准 `offset = windowStart` 对齐续读。**检测+对齐窗口存在但仅提示不对账 §3 差距 2)。**
### 声明 ECLI 当前是「Web API 消费者」fetch 127.0.0.1:8091 + cookie/CSRF
**结论:✅ 属实(且依赖度比声明更重)。**
**证据(`cli/src/api.ts`**
- 认证:`ensureConnected` 调 `/api/csrf-token``fetchCsrf`)、`/host-login``hostLogin`);请求头携带 `Cookie` + `X-CSRF-Token``request`/`captureCookies``:160-215`401 时自动 `fetchCsrf`+`hostLogin` 重试。
- 直连目标:`createDefaultApiClient` 默认 `http://127.0.0.1:${port}``AGENTS_API_PORT || WEB_SERVER_PORT || 8091`)。
- 若连不通,`startServer` 直接 `spawn('python3', ['-m', 'server.app', ...])``:52-75`)拉起完整 Flask/SocketIO Web 服务;`server/app.py` → `app_legacy.run_server`(含 socketio
---
## 2. 发现的漏网路径 / 不一致清单
| # | 类型 | 描述 | 影响 | 代码证据 |
|---|---|---|---|---|
| 1 | **双轨会话查询** | `session.list/history` 公共入口 `list_sessions`/`get_session_history` **无任何 HTTP 路由调用**Web`server/conversation.py:571`)与 API v1`server/api_v1.py:351/385`)会话查询仍在直读磁盘/terminal绕过公共入口 | 会话能力未统一到协议面;「新客户端可经公共协议查询会话」目前仅能靠进程内直调 | protocol §4 表 `session.list/history``runtime_service.list_sessions/get_session_history`+ `grep` 无 HTTP 调用方 |
| 2 | **API v1 会话路由旧实施** | `GET /workspaces/<id>/conversations`、`GET .../<conv_id>` 用 `Path.glob("conv_*.json")` / `json.loads` 直读,独立于 `runtime_service` 且未做属主/工作区一致性校验的同构去重 | 与公共 `list_sessions` 载荷/校验不一致;多端字段未统一 | `server/api_v1.py:351-404` |
| 3 | **自动审批直调 manager** | `modules/auto_approval_service.py:89` `tool_approval_manager.decide(...)` 绕过 `resolve_approval` | 统一裁决语义分叉(审批条目来源/回调/审计若日后收敛到公共入口会遗漏该路径) | `modules/auto_approval_service.py:72-96`,仅被 `chat_flow_tool_loop.py:894/1166` 调用 |
| 4 | **旧 worktree/实验脚本直调** | `.claude/worktrees/stoic-matsumoto-ac8ca5/server/…` 多处 `task_manager.create_chat_task``_experiments/verify_conversation_level_terminal.py:83,93` | 不在当前主工作树/非生产;仅提示清理,不构成本机违规 | grep `.create_chat_task(` 结果 |
| 5 | **run.guide / queue 端点存在但 CLI 未消费** | `/api/tasks/<id>/runtime_guidance`tasks/api.py:282、`runtime_queue`:310/338/360已有 HTTP 路由,但 CLI `api.ts` 无对应方法 | CLI 作为待实现「Runtime Client」缺失 run.guide/追问队列能力 | `server/tasks/api.py:282-364` vs `cli/src/api.ts` |
---
## 3. CLI 成为「独立 Runtime Client」的差距清单
| # | 差距 | 证据 | 说明 |
|---|---|---|---|
| 1 | **依赖 Web 专属认证流程** | `cli/src/api.ts:48-90``/api/csrf-token` + `/host-login` + Cookie/CSRF 头 | 协议 §6 明确的 Web 适配层认证;非通道无关 Token。独立客户端不应走 CSRF。 |
| 2 | **缺口只对齐不重同步** | `cli/src/App.tsx:497-502` 检测 `offset < windowStart` 仅提示并 `offset=windowStart`**未触发 §5.4 快照对账**Web 走 `fetchAndDisplayHistory({force:true})` | 协议 §5.2 要求缺口必须「走重新同步(§5.4)」CLI 会丢被裁剪的中间内容,仅以「最终结果为准」凑合。 |
| 3 | **仅支持 tool 审批,缺 plan/question** | `cli/src/App.tsx:505-511` 只处理 `tool_approval_required``api.ts:244` 只打 `/api/tool-approvals/<id>/decision` | approval.resolve 的 plansubmit_plan/questionask_user能力 CLI 不可用。 |
| 4 | **自拉起完整 Web 服务** | `api.ts:startServer` spawn `python3 -m server.app`SocketIO Web 全量) | 协议 §6「禁止每个客户端各起一份独立状态 Runtime 却宣称共享 Gateway」的边界上本地单机场景可用但非「只连公共 Gateway」。 |
| 5 | **依赖大量 Web-only 端点完成配置/调研** | `api.ts``/api/status`、`/api/conversations*`、`/api/model`、`/api/thinking-mode`、`/api/permission-mode`、`/api/execution-mode`、`/api/tool-settings`、`/api/path-authorization`、`/api/host/workspaces` 等 | 这些不受 protocol Commands/Queries 覆盖;独立客户端需等价 run.*/session.* 能力或明确这些为 Web adapter 私有。 |
| 6 | **无 run.listRun 发现)消费** | `cli/src/api.ts` 未调用 `/api/tasks` GET 或 `list_runs` | 主 Run 发现能力(审核 F1 已落在服务层CLI 未使用;不利于 A/B 观察场景。 |
---
## 4. 总体判断
**「①②③ 链路Client ↔ Gateway ↔ Runtime已贯通」 部分成立,未达到「新客户端可不复制 Web 专属流程直接接入」的程度。**
- **服务端收敛度**:高。主 Run 创建的 6 个入口全部经 `runtime_service.create_task`approval.py 六路由全部经 `resolve_approval`/`list_pending_approvals`run.list/get/events/cancel/guide/queue 均有 HTTP 公开路由并透传 `window_start`。声明 A、B、D(服务端/Web)、E 属实。
- **Web 消费面**:已消费公共协议面的 HTTP 端点task 轮询 + approval + 缺口快照对账),基本属「公共协议能力消费方」。
- **主要缺口集中在「会话查询未传输暴露」与「CLI 独立性」两点**
1. `session.list/history``session.create/load` 显式命令**没有 HTTP 传输暴露**Web/API v1 仍在走旧会话路由——协议表虽标「✅/⚠️」但事实是**只有进程内直调入口**。
2. CLI 仍是「Web API 消费者」cookie/CSRF/hostLogin + 自拉起完整 Web 服务,仅 tool 审批、缺口不重同步、依赖大量 Web-only 端点。
- **结论**Gateway服务层公共入口本身已基本贯通且 Web 消费面已对齐;但「新客户端不复制 Web 专属流程即可接入」的**完整性目标未达成**,卡在 (a) 会话查询/创建的传输暴露、(b) CLI 认证与启动方式的 Web 耦合、以及 (c) 审批三类型覆盖与缺口快照对账。严格意义下应表述为「服务端公共入口贯通完成客户端侧仍处『Web adapter 消费者』过渡态」。
---
### 附:未验证项
- 未在本次审核中执行真实运行验收(如 ApprovalWaitChainTest / ProtocolSmokeChainTest 的运行通过性),仅静态符号与行段核对;测试伪实现(`test_runtime_service.py` 曾把 `_run_chat_task` 替换为 lambda的历史问题本报告未复验。
- HTTP 端点归属(哪些路由挂载到哪个 blueprint / app_legacy的注册细节未逐一核对仅核对路由定义文件内部映射。

View File

@ -1,162 +0,0 @@
# 任务入口收敛现状核查报告v2
- 审计范围Astrion「Gateway 化改造」中「全部 6 处 create_chat_task 调用点已迁移到
runtime_service.create_task()」这一声明的真伪核查。
- 审计方式:全程只读,未修改任何项目文件。
- 关键词库:`create_chat_task` / `runtime_service` / `test_request_context` / `session[`
- 说明grep 结果中所有 `__pycache__/*.pyc` 二进制命中均已排除(为旧字节码缓存,不代表源码)。
- 审计时间2026-09-07
---
## 0. 背景RuntimeService / RuntimeContext 结构evidence
文件:`server/runtime/service.py`、`server/runtime/context.py`、`server/runtime/__init__.py`
- `server/runtime/service.py:114``runtime_service = RuntimeService()`(进程级单例)。
- `server/runtime/service.py:38``create_task()` 内部唯一真实调用点
`return task_manager.create_chat_task(...)`(约 15 个显式参数,含
`session_data=ctx.to_session_data()`)。即:**全项目唯一直接 create_chat_task 调用在 service.py**。
- `server/runtime/context.pyRuntimeContext` 由三层分离组成:
- `TrustedPrincipal`username / workspace_id / role / is_api_user / host_mode /
preferred_model_key / preferred_run_mode / preferred_thinking_mode……
- `TaskParams`message / images / videos / files / conversation_id / model_key /
run_mode / thinking_mode / max_iterations / goal_mode / skill_context_messages /
message_source / task_type / approval_timeout_seconds……
- `InternalDirectives`main_task_gate_token / auto_user_message_event /
auto_user_message_payload / preceding_user_notices
- `RuntimeContext.from_terminal()``context.py:76`):内部调用方专用入口,把对话级
terminal/工作区逐字段映射为 principal + params + directives含 host_mode、role、
is_api_user、偏好快照
- `RuntimeContext.to_session_data()``context.py:133`):兼容转换——把三层合并为
session_data 快照 dict承载身份/偏好/门闸 token/事件回放/terminal 属性设置。
- `principal_from_session_snapshot()``context.py:165`):适配层工具,认证后从 Flask
session dict 快照构造可信 principalcontext.py 不 import flask
- `server/runtime/__init__.py:__all__` 导出:`InternalDirectives`、`RuntimeContext`、
`RuntimeService`、`TaskParams`、`TrustedPrincipal`、`principal_from_session_snapshot`、
`runtime_service`。(全 7 项)
---
## 1. 六处声称迁移点逐一核查结论表
| # | 声称位置 | 实际调用行号 | 实际调用方式 | 上下文构造方式 | 特殊语义保留 | 与声明是否一致 |
|---|---------|------------|------------|--------------|------------|--------------|
| 1 | `server/tasks/api.py`Web 聊天 POST /api/tasks | `server/tasks/api.py:220` | `runtime_service.create_task(ctx)` ✅ | `RuntimeContext(principal=principal_from_session_snapshot(session, workspace_id, username=username), params=TaskParams(...))`api.py:211-219无 directives | Web 客户端不应提交 gate token故无 InternalDirectives符合低权限语义词session 快照承载 username/workspace_id/role 等 | ✅ 一致 |
| 2 | `server/api_v1.py`API v1 Bearer token 路径) | `server/api_v1.py:335` | `runtime_service.create_task(ctx)` ✅ | `RuntimeContext(principal=principal_from_session_snapshot(session, ws.workspace_id, username=username), params=TaskParams(...))`api_v1.py:330-338 | **is_api_user/role 传递保留**`server/api_auth.py:43` 设 `session["is_api_user"]=True` → snapshot → to_session_data 写入 session_dataapi_v1.py:321 注释明确「is_api_user=True / role="api",丢失会静默串资源管理器」 | ✅ 一致 |
| 3a | `server/workflow_runtime_api.py`workflow 激活) | `server/workflow_runtime_api.py:192` | `runtime_service.create_task(ctx)` ✅ | `RuntimeContext.from_terminal(terminal, workspace, username, params=TaskParams(...message_source="workflow"), directives=InternalDirectives(main_task_gate_token=..., auto_user_message_event=True, auto_user_message_payload=...))`workflow_runtime_api.py:171-191 | **门闸 token 移交**main_task_gate_token+ **auto_user_message 事件回放** 均走 InternalDirectives | ✅ 一致 |
| 3b | `server/workflow_runtime_api.py`workflow 停用/通知派发) | `server/workflow_runtime_api.py:269` | `runtime_service.create_task(ctx)` ✅ | `RuntimeContext.from_terminal(...)` + `InternalDirectives(main_task_gate_token, auto_user_message_event, auto_user_message_payload)`workflow_runtime_api.py:253-268 | 门闸 token 移交 + auto_user_message 事件回放 | ✅ 一致 |
| 4a | `server/chat_flow_task_main.py`(完成通知派发 dispatch_completion | `server/chat_flow_task_main.py:618` | `runtime_service.create_task(ctx)` ✅ | `RuntimeContext.from_terminal(web_terminal, workspace, username, params=..., directives=InternalDirectives(main_task_gate_token, auto_user_message_event=True, auto_user_message_payload, preceding_user_notices))`chat_flow_task_main.py:597-616 | 门闸 token、auto_user_message、preceding_user_notices 回放request source/terminal 属性传递 | ✅ 一致 |
| 4b | `server/chat_flow_task_main.py`(多智能体 idle 派发 dispatch_ma_idle | `server/chat_flow_task_main.py:1417` | `runtime_service.create_task(ctx)` ✅ | `RuntimeContext.from_terminal(...)`params 含 `task_type="notice"`directives 含 auto_user_message_event/auto_user_message_payload/preceding_user_noticeschat_flow_task_main.py:1395-1416 | **notice task_type 豁免互斥**models.py 单对话互斥对 notice 豁免);多智能体事件回放 | ✅ 一致 |
> 结论:**全部 6 处调用点均已迁到 `runtime_service.create_task(ctx)`**,与声明一致。
> 无任何一处仍直接调用 `task_manager.create_chat_task`
**各点快速证据(调用宏)**`grep -n "runtime_service.create_task"`
- `server/tasks/api.py:220`
- `server/api_v1.py:335`
- `server/workflow_runtime_api.py:192`、`:269`
- `server/chat_flow_task_main.py:618`、`:1417`
---
## 2. 漏网扫描结果
### 2.1 所有 `create_chat_task` 出现位置(排除 pycache
- `server/tasks/models.py:128` —— **定义处**`def create_chat_task(self, ...)`)。
- `server/runtime/service.py:38` —— **唯一直调处**RuntimeService.create_task 内部委托)。
- 其余命中全部为**注释/文档字符串**`chat_flow_task_main.py:613`ma_debug 标签字符串
"dispatch_completion_create_chat_task")、`chat_flow_task_main.py:2636`(注释)、
`server/main_task_gate.py:4`(注释)、`server/runtime/service.py:9/27`docstring
`server/runtime/context.py:133`docstring
**→ 无任何“定义处 + service.py 委托处”之外的实体 create_chat_task 调用点。✅**
### 2.2 所有 `runtime_service` 出现位置
- `server/chat_flow_task_main.py:46`import、`:618`、`:1417`(调用)
- `server/tasks/api.py:27`import、`:220`(调用)
- `server/workflow_runtime_api.py:17`import、`:192`、`:269`(调用)
- `server/api_v1.py:13`import、`:335`(调用)
- `server/runtime/service.py:114`(定义单例)
- `server/runtime/__init__.py:9/:18`(导出)
**→ modules/、core/、utils/ 目录下均无 runtime_service 出现;所有调用点与 6 处迁移点完全重合。✅**
### 2.3 session_data 强制校验models.py
- `server/tasks/models.py:174``if session_data is None:` → `:175` `raise ValueError(tr("tasks.missing_session_data"))`
- 位置位于 `create_chat_task` 内、登记任务前注释models.py:172-173明确
「必须显式传入:禁止在受理层回退读 Flask session」。**确认强制显式 session_data缺失抛 ValueError。✅**
- 测试覆盖:`test/test_runtime_service.py:161` `test_create_chat_task_requires_explicit_session_data`
:164 直调 `task_manager.create_chat_task("tester","default","msg",[],"conv_x")` 断言抛 ValueError
---
## 3. Flask 依赖核查
### 3.1 `test_request_context` 在 server/ core/ modules/ utils/ 下
grep 命中(排除 pycache
```
server/tasks/models.py:768: # 直接驱动资源装配——不再伪造 Flask 请求上下文(原 test_request_context
server/runtime/service.py:10: 驱动资源装配test_request_context 桥已拆除session_data 快照仍承载
```
两处均为**注释**,无实体代码调用。→ **server/ core/ modules/ utils/ 下 test_request_context
实际调用 = 0。声明「已拆桥」在服务端源码层面属实。✅**
> ⚠️ 附注(不推翻结论,但需明示):在 **test/** 目录下仍有 `test_request_context` 的实际代码使用:
> `test/test_runtime_identity_resources.py:170``ctx = app.test_request_context("/")`
> (该测试用于 host 工作区策略分支的 `get_user_resources` 测试,属于测试辅助构造 Flask 上下文,
> 不是任务线程桥;声明范围仅 server/core/modules/utils故不违反声明。标注为「附带发现」。
另:`test/test_runtime_service.py:4` docstring 提及 "无 test_request_context",为说明性文字非调用。
### 3.2 `session[``server/tasks/models.py`
```
grep -n "session\[" server/tasks/models.py → 0 命中exit=1
```
**→ 任务线程models.py不再读 Flask session。✅** 与声明一致。
---
## 4. 相关测试文件清单
- `test/test_runtime_service.py` —— 覆盖 RuntimeService / RuntimeContext用例
- `RuntimeContextModelTest.test_validate_rejects_empty_username`:53
- `RuntimeContextModelTest.test_to_session_data_carries_identity_and_preferences`:59
- `RuntimeContextModelTest.test_to_session_data_directives`:78
- `RuntimeContextModelTest.test_principal_from_session_snapshot`:95
- `RuntimeServiceAdmissionTest.test_t01_create_task_without_http_context`:132
- `RuntimeServiceAdmissionTest.test_t02_same_conversation_chat_mutex_and_notice_exempt`:141含 notice 豁免)
- `RuntimeServiceAdmissionTest.test_create_task_validates_context`:155
- `RuntimeServiceAdmissionTest.test_create_chat_task_requires_explicit_session_data`:161
- `RuntimeServiceAdmissionTest.test_t04_cancel_task`:168
- `RuntimeServiceAdmissionTest.test_get_task_events_offset_protocol`:176
- `test/test_runtime_identity_resources.py`:170 使用 test_request_context—— 见 §3.1 附注。
其核心范围为 RuntimeIdentity + 资源装配get_user_resources非直接覆盖 runtime.service。
---
## 5. 总结
**入口收敛声明是否属实:属实。✅**
1. **6 处迁移**:全部 6 处调用点均已调用 `runtime_service.create_task(ctx)`,无一仍直调
`task_manager.create_chat_task`;唯一真实直调位于 `server/runtime/service.py:38`create_task 内部委托)。
2. **无漏网调用点**`create_chat_task` 实体调用仅 2 处(定义 models.py:128 + service.py:38 委托),
其余为注释/docstring无任何第三处实体调用。
3. **上下文显式化**:所有调用点均走 RuntimeContext三元组构造不再手工 session_data dict
特殊语义notice 豁免 task_type="notice"、门闸 token 移交 main_task_gate_token、
is_api_user/role 传递、auto_user_message/前置信事件回放)均在 context.py.to_session_data /
from_terminal 中保留。
4. **session_data 强制**models.py:174-175 强制显式 session_data缺失抛 ValueError。
5. **Flask 拆桥**server/core/modules/utils 下 test_request_context 实体调用 = 0仅注释
models.py 无 `session[` 读取。附带发现 test/ 下 test_runtime_identity_resources.py:170 有
测试辅助用 test_request_context非任务线程桥不违反声明特此标注
**未确认项**:无(全部结论均有 文件:行号 证据支撑)。
### 证据索引
- create_chat_task 定义:`server/tasks/models.py:128`
- create_chat_task 唯一委托直调:`server/runtime/service.py:38`
- session_data 强制校验:`server/tasks/models.py:174-175`
- 六处迁移调用:`tasks/api.py:220` / `api_v1.py:335` / `workflow_runtime_api.py:192,269` / `chat_flow_task_main.py:618,1417`
- is_api_user 注入:`server/api_auth.py:43`

View File

@ -1,245 +0,0 @@
# Execution Plane 盘点与耦合点报告Gateway 化第 4 步前置调研)
> 版本v12026-09-07
> 定位为「Runtime 与执行环境分层Execution Contract」盘点工具执行链路现状纯只读调研未修改任何项目代码。
> 范围:`core/main_terminal_parts/`、`modules/terminal_ops/`、`modules/persistent_terminal/`、`modules/file_manager/`、`modules/host_sandbox_runner.py`、`modules/docker_readonly_exec.py`、`modules/landlock_launcher.py`、`core/web_terminal.py`、`core/main_terminal.py`、`server/chat_flow_tool_loop.py` 及必要的关联文件sender/事件链)。
> 行号以 2026-09-07 工作区代码为准;若与代码冲突,以代码为准。
---
## 0. 执行链路全景(一次理解)
```
模型循环 (Runtime 层)
chat_flow_tool_loop.py:522 _execute_tool_calls_impl
├─ web_terminal.evaluate_tool_permission() # 权限裁决S8
├─ capture_monitor_snapshot(file_manager) # 执行前快照读tool_start 附带)
└─ web_terminal.handle_tool_call(function_name, args) # tools_execution.py:1057 单一入口
↓ 按 tool_name 分派(约 30 个分支)
命令执行 → self.terminal_ops.run_command(...) / bg_manager.create_background_command(...)
文件读写 → self.file_manager.*host 本地 or ContainerFileProxy→docker exec
终端会话 → self.terminal_manager.open_terminal/send_to_terminal/get_terminal_snapshot
子智能体 → self.sub_agent_manager.create_sub_agent/execute_tool_for_sub_agent
其他 → memory/search/ocr/todo/workflow/personalization 等 manager
↓ 最终执行环境
Host: asyncio.subprocess[(sandbox-exec|bwrap|WSL bwrap) 或 裸 shell]
Docker: docker exec [-u readonly-uid] [/bin/bash -lc cmd](只读走 docker_readonly_exec + landlock
文件: 进程内 直接读写host或 docker exec python helpercontainer_file_proxy.py
```
**现状核心观察**:工具 handlerRuntime 侧)直接持有三个"执行环境句柄"属性——`self.terminal_ops`、`self.file_manager`、`self.terminal_manager`(外加 `self.sub_agent_manager`、`self.background_command_manager`。执行环境选择逻辑host 沙箱 vs docker vs direct**内嵌在** terminal_ops/file_manager 各自的方法内部,由 `self.container_session``ContainerHandle``mode ∈ {docker, host}`)与 `self.host_execution_mode``sandbox/direct`两把开关驱动。Runtime 与 Execution Plane 之间目前**没有独立的接口层**。
---
## 1. 执行面清单
### 约定
- ① = 工具入口handler 分支);② = 中间层;③ = 最终执行后端;「触达执行环境」= 会产生真实子进程 / 真实文件写 / 真实容器会话。
- 所有行号 = 该分支/函数定义行handler 分支为 `elif tool_name ==` 行)。
### A. 命令执行类
| 子类 | ① 入口handler | ② 中间层 | ③ 最终执行后端 | 执行环境来源 |
|---|---|---|---|---|
| run_command前台 | `tools_execution.py:1879` + `:1936` `await self.terminal_ops.run_command(...)` | `terminal_ops/run.py:434 run_command``:160 _run_command_subprocess` | 见下方 A1/A2/A3 三分支 | `self.terminal_ops``container_session` + `host_execution_mode` |
| run_command后台 | `tools_execution.py:1909-1924` `bg_manager.create_background_command(terminal_ops=...)` | `background_command_manager.py:39 create_background_command``:185 _run_command_thread`**自带完整 docker/host 分支,不调 terminal_ops.run_command** | 同 A1/A2/A3逻辑为复制实现 | terminal_ops 的属性快照session/python_env/host_execution_mode/网络权限/写权限) |
| custom_tool自定义工具 | `tools_execution.py:1218` `await self.custom_tool_executor.run(...)` | `custom_tool_executor.py:40 run``:69 await self.terminal_ops.run_command(...)` | 同 run_command | `self.custom_tool_executor` 构造时注入的 `self.terminal_ops`main_terminal.py:167 |
| install_package若暴露 | `terminal_ops/run.py:410 install_package` | → `_run_command_subprocess` | 同 run_command | 同上 |
**A1 后端Docker 容器内执行**(当 `container_session.mode == "docker"`
- `terminal_ops/run.py:177-207``docker exec -e PATH=... -w <mount_path>/<rel> <container_name> /bin/bash -lc <command>`
- 只读(`sandbox_write_access=False`)时追加 `docker_readonly_exec_args()``docker_readonly_exec.py:66``-u 10001:10001` 非特权 uid 强制 DAC 只读),并 `docker_readonly_wrap_inner``docker_readonly_exec.py:172`)加 Landlock 进程级只读域(`landlock_launcher.py:91 install_readonly_domain``_landlock_enabled` 开关,失败自动降级纯 DAC
- 后台同构:`background_command_manager.py:232-260`。
**A2 后端:宿主机 sandbox 内执行**`host_execution_mode != "direct"` 且 `host_sandbox_enabled()`
- `terminal_ops/run.py:218-242`:先 `build_host_sandbox_plan`(可写,`host_sandbox_runner.py:197`)或 `build_host_sandbox_readonly_plan`(只读,`:213`)生成 `SandboxPlan`,再 `asyncio.create_subprocess_exec`
- 按平台展开macOS `sandbox-exec``_build_macos_plan` :251、Linux `bwrap``_build_linux_plan` :409、Windows WSL bwrap`_build_windows_plan` → `_build_windows_wsl_plan` :626网络权限由 plan 注入(`_build_macos_network_policy` :186`restricted/full/none`)。
- 后台同构:`background_command_manager.py:262-290`。
**A3 后端裸宿主机执行direct无沙箱**
- `terminal_ops/run.py:255-262` `asyncio.create_subprocess_shell`;后台同构 `background_command_manager.py:290+`
- `host_execution_mode == "sandbox"``host_sandbox_enabled()` 为假时直接返回错误(`terminal_ops/run.py:249-253`)。
### B. 文件读写类(文件工具 = 进程内操作,不经 OS 沙箱子进程;沙箱语义在进程内复刻)
| 子类 | ① 入口handler | ② 中间层 | ③ 最终执行后端 |
|---|---|---|---|
| read_file/read_skill | `tools_execution.py:1219/1223``tools_read.py:419 _handle_read_tool``read/serach/extract` 3 模式)→ `file_manager.read_file/read_text_segment/search_text/extract_segments``read_mixin.py:95/130/221/295` | `file_manager``base.py:50` | host进程内路径读写先过 `_validate_path``path_mixin.py:83` + `_ensure_host_access``path_mixin.py:215` 复刻 macOS 禁读清单docker`_container_call` → `ContainerFileProxy.run``container_file_proxy.py:464`)→ `docker exec -i <container> python <helper>` |
| write_file | `tools_execution.py:1625-1650`(写前 `_track_shallow_versioning` 浅备份)→ `file_manager.write_file``crud_mixin.py:242` | 同左 | host直接 `Path.write_text`(受 `_ensure_host_access("write")` 授权范围限制dockercontainer proxy `_write_file``container_file_proxy.py:246` |
| edit_file | `tools_execution.py:1652-1675``file_manager.replace_many_in_file``replace_mixin.py:125` | 同左 | host进程内替换dockerproxy |
| create_file / delete_file / rename_file / create_folder | `tools_execution.py:1569/1589/1604/1677` | `crud_mixin.py:53/93/133/193` | host进程内dockerproxy |
| save_webpage写文件 | `tools_execution.py:1847` `file_manager.write_file(target_path, ...)` | 同 write_file | 同 write_file |
| conversation_review写文件 | `tools_execution.py:2094` `save_review_file()``target.write_text(...)`**绕过 file_manager**,直写工作区 `WORKSPACE_REVIEW_DIRNAME` | ― | host 进程内直写 |
| view_image / view_video | `tools_execution.py:1243/1281`**只 stat/read 元数据**,设置 `pending_image_view/pending_video_view`,不读内容) | ― | 进程内 `Path.stat` |
| update_project_memory / manage_personalization | `tools_execution.py:681/2519` | `_handle_update_project_memory` / `_execute_manage_personalization` | 写运行态目录文件(.astrion/memory、personalization.jsonS10 |
> 注:`save_webpage` 与 `conversation_review` 是文件工具里**绕开 FileManager 抽象**的两个直写点(前者最终仍走 file_manager.write_file后者完全绕过
### C. 终端会话类(持久终端)
| 子类 | ① 入口handler | ② 中间层 | ③ 最终执行后端 |
|---|---|---|---|
| terminal_session open | `tools_execution.py:1315-1337``terminal_manager.open_terminal``terminal_manager.py:212` | `PersistentTerminal.start``persistent_terminal/start.py:72` | docker`_start_docker_terminal`:241`_start_existing_container_terminal`:258/ `_start_new_container_terminal`:315host`_start_host_terminal`:141`build_host_sandbox_shell_plan`)或 direct `_start_plain_host_terminal`:227 |
| terminal_input | `tools_execution.py:1349-1357``terminal_manager.send_to_terminal``terminal_manager.py:521` | `PersistentTerminal.send_command``persistent_terminal/command.py:70` | 向已启动的子进程 stdin 写 + 读 stdout`io.py:91 _read_output` |
| terminal_snapshot | `tools_execution.py:1361-1366``terminal_manager.get_terminal_snapshot``terminal_manager.py:697` | `PersistentTerminal.get_snapshot``lifecycle.py:58` | 纯缓冲读,不触执行 |
| terminal_session close/reset/list | `tools_execution.py:1328-1345` | `terminal_manager.close_terminal/reset_terminal/list_terminals`:325/375/500 | 进程终止/信号 |
> 终端会话的 sandbox 后端选择:`terminal_manager.py:100-107 / 132-178``sandbox_mode` 默认 host`container_session.mode=="docker"` 时转 docker`_build_sandbox_options` 注入 `docker_readonly_exec``container_name/mount_path`)。终端的 broadcast 回调 = 上级 terminal 注入的 `message_callback``web_terminal.py:135/147`Web 侧经 `attach_user_broadcast` 指向 emit_event 安全包装)。
### D. 子智能体类(执行派生,工具执行复用主进程链路)
| 子类 | ① 入口handler | ② 中间层 | ③ 最终执行后端 |
|---|---|---|---|
| create_sub_agent阻塞/后台) | `tools_execution.py:2147` 分支,调用点 `:2213`(多智能体)/ `:2248`(传统) | `sub_agent/manager.py:225 create_sub_agent``execute_tool_for_sub_agent``manager.py:1003`)→ `self.terminal.handle_tool_call(...)``:1019`)→ **回到主进程执行链 A/B/C** | 复用主 terminal 环境container_session/host_execution_mode 同源,`manager.py:167 set_container_session` / `:171 set_host_execution_mode` |
| terminate_sub_agent / get_sub_agent_status / send_message_to_sub_agent / stop_sub_agent / answer_sub_agent_question / wait_sub_agent_outputsleep | `tools_execution.py:2299/2325/2356/2407/2427/1369` | `sub_agent/manager.py:629/809/1307/570/…` | 控制面(状态/消息不直接触执行环境wait 阻塞等输出 |
| read_mediafile子智能体专用 | `sub_agent/manager.py:1015``handle_read_mediafile` | ― | 进程内读媒体文件(返回 base64 给模型) |
### E. 其他(触达但与执行环境弱相关)
| 子类 | ① 入口handler | 最终后端 |
|---|---|---|
| sleep含 wait_sub_agent_* | `tools_execution.py:1369` | asyncio 等待,不触执行环境 |
| ocr_image / vlm_analyze | `tools_execution.py:1237` | `self.ocr_client.vlm_analyze`(读工作区图片文件 + 模型推理) |
| web_search / extract_webpage / save_webpage | `tools_execution.py:1679/1740/1784` | 外部网络 APITavilysave 落盘见 B |
| todo / memory / conversation_search / manage_personalization | `tools_execution.py:2132/1955/2026/2503` | 内存/运行态文件TODO 存储在 context_manager记忆在 memory_managerpersonalization 覆写文件) |
| trigger_easter_egg | `tools_execution.py:2500` | 前端效果,不触执行 |
**一句话执行面清单**:真正触碰"执行环境"(能产生真实副作用)的工具 = **命令执行**run_command 前台/后台、custom_tool、install_package、**文件写**write_file/edit_file/create_file/delete_file/rename_file/create_folder/save_webpage/conversation_review/update_project_memory/manage_personalization、**文件读**read_file 族、read_mediafile、ocr、**终端会话**terminal_session/terminal_input、**子智能体**(其内部工具执行复用主链路)。其余为控制面/查询面。
---
## 2. 耦合点清单:执行链路中的 Web 概念依赖
### 2.1 依赖链总览sender / 广播怎么流进执行链路)
```
执行链路事件出口(工具循环内 sender('tool_start'/'tool_approval_required'/...)
chat_flow_tool_loop.py:522 _execute_tool_calls_impl ← sender 参数透传
└─ 上游 sender 定义点:
(1) tasks/models.py:901 任务级 sender_run_chat_task 内定义run_chat_task_sync 传入)
├─ _append_event(rec, ...) # TaskRecord 事件流S3非 Web
└─ socketio.emit(..., room=f"user_{username}") # ←【仍耦合 · 直接 import server.extensions.socketio】
(2) chat_flow_task_main.py:1511 raw_sender 包装(补 conversation_id/task_id/client_sid
(3) REST/回调适配层 sendertasks/api.py / 通知链chat_flow_task_main.py:618 dispatch_completion_create_chat_task 完成通知派发)
terminal.broadcast / context_manager._web_terminal_callbackshell 输出、token_update、todo_updated
└─ web_terminal.py:135/147/161 注入 message_callback
└─ server/context/broadcast.py:16 make_terminal_callback → emit_event(..., room=f"user_{username}")
└─ server/extensions.py:10 emit_event # ←【已解耦安全包装socketio 未绑定即静默】
```
### 2.2 分档清单
#### ✅ 已在第 1/2 步解耦经安全包装Gateway 可独立初始化)
| 位置 | 内容 | 说明 |
|---|---|---|
| `server/extensions.py:10-23` | `emit_event(event, data, room, ...)` | `socketio.server is None`(未绑定 app时静默跳过 → 非 Web 进程零副作用 |
| `server/extensions.py:25-29` | `run_background(fn, ...)` | 未绑定时降级 daemon 线程 → 任务线程可脱离 socketio 启动 |
| `server/context/broadcast.py:16-25` | `make_terminal_callback(username)` | 只调 `emit_event`(安全包装),不直接 import socketio.emit |
| `server/context/broadcast.py:28-38` | `attach_user_broadcast(terminal, username)` | 把 terminal.message_callback / terminal_manager.broadcast 指到安全包装 |
| `terminal_manager.broadcast`、`PersistentTerminal.broadcast` | 终端 IO 事件出口 | 值为 terminal 注入的 message_callbackWeb 侧指向 emit_event 包装CLI 侧为 None`terminal.py` 构造时 `broadcast_callback=None`main_terminal.py:134 |
| `context_manager._web_terminal_callback`token_update/todo_updated/编辑摘要) | `utils/context_manager/token_mixin.py:236`、`todo_annotation_mixin.py:138` | 值 = terminal.message_callbackweb_terminal.py:161task 运行时被 models.py:930 切到任务 sender |
| `chat_flow_tool_loop.py:478-479` | `emit_event('status_update', ...)` | 经 extensions 安全包装 |
| `chat_flow_task_main.py:1850/2640/2692` | `run_background(...)` | 经 extensions 安全包装 |
| 执行链路对 Flask session 的读取 | `server/chat_flow_task_main.py / chat_flow_tool_loop.py / tasks/models.py / tools_execution.py / terminal_ops / file_manager / persistent_terminal` | **grep `session[` = 0 命中**T12 已成立session 读取只剩适配层 `resources.py:149`(带 `has_request_context()` 兜底) |
#### ⚠️ / ❌ 仍耦合(执行链路直接依赖 Web 广播对象)
| 位置 | 内容 | 耦合性质 |
|---|---|---|
| **`server/tasks/models.py:919-920`**(任务级 sender | `from server.extensions import socketio; socketio.emit(event_type, data, room=f"user_{rec.username}")` | ❌ **直接 import socketio 实例并调用 .emit**——绕过了 emit_event 包装,未绑定时**会抛异常**socketio.server None 时 emit 行为未定义/异常)。这是主 Run 全部事件的实时推送口含工具事件Gateway 独立进程下会炸 |
| **`server/tasks/models.py:1045-1054`** | `socketio.emit('task_stopped', stopped_payload, room=f"user_{rec.username}")` | ❌ 同上(终态推送口) |
| **`server/chat_flow_task_main.py:975-989`**(完成通知链预写回显) | `from .extensions import socketio; socketio.emit(...)` | ❌ 直接 socketio.emit完成通知派发链路`poll_completion_notifications` |
| **`server/chat_flow_task_main.py:1118-1125`**(多智能体 idle 派发回显) | 同上 `socketio.emit(...)` | ❌ 直接 socketio.emit多智能体 idle 消息回显) |
| `server/chat_flow_task_main.py:1488` | `handle_task_with_sender``from .extensions import socketio` | ⚠️ 导入但未见直接使用(仅靠 sender 参数),属残留导入 |
| `sender` 函数签名贯穿工具循环 | `chat_flow_tool_loop.py:522` `_execute_tool_calls_impl(*, web_terminal, tool_calls, sender, ...)`,约 30+ 处 `sender('...')` | ⚠️ 工具执行循环把「事件发送」以 **sender 回调参数**贯穿设计上已与具体传输解耦——sender 是可注入的),但**调用方**models.py:901绑死了 socketio等于参数化了接口、没参数化实现 |
| `web_terminal`WebTerminal 特有属性) | 工具循环参数 `web_terminal``_execute_tool_calls_impl:522`handler 内大量 `getattr(self, ...)` 读取 WebTerminal 特有属性:`task_id`chat_flow_task_main.py:1531 `getattr(web_terminal, "task_id", None)`)、`multi_agent_mode`、`sub_agent_manager`、`mcp_client_manager`、`background_command_manager`、`custom_tool_executor`、`ocr_client`、`easter_egg_manager`、`host_network_permission`、`current_permission_mode`、`data_dir` 中 `/web/users/` 路径判断tools_execution.py:2163、:2466、:2482 `_is_web = '/web/users/' in _data_dir` | ❌ **Runtime/工具实现把「身份是 Web 用户」编进了执行逻辑**`_is_web` 判定影响 custom-role 目录解析);`getattr(web_terminal, "task_id", None)` 恒 NoneWebTerminal 全仓无 task_id 赋值点,记忆 N2 |
| `web_mode` / `api_client.web_mode` | `web_terminal.py:127-128` | ⚠️ 输出静默是 Web 概念,但包在 `WEB_API_SILENT` 环境变量开关内,非硬耦合 |
### 2.3 结论
- **第 1/2 步已把「传输出口」包装成了 emit_event/run_background + sender 回调注入**,工具循环内部已不出现 `socketio.*` 直接调用(唯一例外 `chat_flow_tool_loop.py:478` 用的也是安全包装 emit_event
- **残余硬耦合集中在 4 处直接 `socketio.emit`**`tasks/models.py:920 / :1054`、`chat_flow_task_main.py:989 / :1125`。它们是「Gateway 独立启动验收」的最后障碍T12 只查了 `test_request_context``session[`,未查 `socketio.emit` 裸调用)。
- **执行环境的 Web 语义残留**`tools_execution.py` 中 `_is_web``'/web/users/' in data_dir`)影响 role 目录解析——这是身份/路径耦合不在纯执行环境范围内但同属「Runtime 依赖 Web 概念」,第 4 步应一并划界(建议归入 Execution Contract 的 workspace 解析,或至少记录)。
---
## 3. 抽象接口建议(只归纳现状,不发明新能力)
### 3.1 Execution Plane 接口面应覆盖的操作集(全部来自现状调用点)
| # | 操作 | 现状调用点handler → 后端) | 参数(现状签名) | 关键语义(不许丢) |
|---|---|---|---|---|
| E1 | `run_command(cmd, workdir, timeout, write_access, network)` | tools_execution.py:1936 → terminal_ops/run.py:434 | `command, working_dir, timeout, sandbox_write_access, network_permission` | 返回 `{success,status,output,return_code,truncated,elapsed_ms}``status ∈ {completed, timeout, error, cancelled}`;字符上限 MAX_RUN_COMMAND_CHARS |
| E2 | `run_command_background(cmd, timeout, network, write_access)` | tools_execution.py:1916 → background_command_manager.py:39 | `terminal_ops, command, timeout, conversation_id, wait_seconds, network_permission, sandbox_write_access` | 返回 `command_id/status=running_background`;轮询/等待需按 command_id 寻址 |
| E3 | `write_file(path, content, mode)` | tools_execution.py:1638 → crud_mixin.py:242 | `path, content, mode` | 返回 `{path, original_file, new_file}`(编辑摘要依赖 original/new 全文) |
| E4 | `edit_file(path, replacements)` | tools_execution.py:1665 → replace_mixin.py:125 | `path, replacements` | 同上 |
| E5 | `read_file(path, type, start_line, end_line, max_chars, ...)` | tools_read.py:419 → read_mixin.py | `path/type(3 模式)/range/max_chars/size_limit` | 返回 `{path, content, truncated, char_count}`不含原文全文type=search/extract 各有参数) |
| E6 | `create_file / delete_file / rename_file / create_folder / delete_folder` | tools_execution.py:1569-1677 → crud_mixin.py | 同名 | 路径相对工作区 |
| E7 | `open_terminal / send_input / snapshot / close / reset / list` | tools_execution.py:1315-1368 → terminal_manager.py | 见 C 表 | 终端只读身份(`docker_readonly_exec` / readonly shell plan`broadcast`IO 事件流出口) |
| E8 | `path_validate(path)`(执行前授权检查) | tools_execution.py:446/489/960 → path_mixin.py:83chat_flow_tool_loop.py:61/396 | `path``(valid, error, full_path, rel_path?)` | 权限模式/沙箱范围/禁读清单host_execution_mode 参与判定) |
| E9 | 执行环境快照(供子智能体提示词) | `get_execution_mode_state()`main_terminal.py:448、`execution_env_text.py` | ― | 注入到子智能体 system prompt 的 execution_mode 说明 |
| E10 | 命令校验/超时钳制 | terminal_ops/command.py:53 `_validate_command` / :76 `_clamp_timeout` | ― | FORBIDDEN_COMMANDS / TERMINAL_COMMAND_TIMEOUT 语义 |
(未列入的「查询/控制面」——memory、todo、approval、workflow、easter_egg、sender 事件——不属于 Execution Plane它们应留在 Runtime 层。)
### 3.2 现有后端与实现文件对应
| 后端 | 最终执行实现文件 | 入口函数 | 适用工具 |
|---|---|---|---|
| **Docker 容器**(会话句柄 mode=docker | `modules/terminal_ops/run.py:177-207`(前台)、`modules/background_command_manager.py:232-260`(后台)、`modules/persistent_terminal/start.py:241-413`(终端)、`modules/container_file_proxy.py:448-501`(文件) | `_run_command_subprocess` / `_run_command_thread` / `_start_docker_terminal` / `ContainerFileProxy.run` | 命令、终端、文件 |
| **Docker 只读加固** | `modules/docker_readonly_exec.py:55-190`uid/gid、exec args、landlock wrap、`modules/landlock_launcher.py:74-175`probe_abi/install_readonly_domain/selftest | `docker_readonly_exec_args` / `docker_readonly_wrap_inner` / `ensure_landlock_ready` | 受限档命令/终端 |
| **宿主机 OS 沙箱** | `modules/host_sandbox_runner.py:174-640``host_sandbox_enabled` + 三平台 plan 构建) | `build_host_sandbox_plan`:197`build_host_sandbox_readonly_plan`:213`build_host_sandbox_shell_plan`:229 | 命令(前台/后台)、持久终端 shell |
| **宿主机 direct** | `modules/terminal_ops/run.py:255-262`、`persistent_terminal/start.py:227-240`、`file_manager` host 直读写 | `create_subprocess_shell` / `_start_plain_host_terminal` | 命令、终端unrestricted+direct |
| **进程内文件操作host** | `modules/file_manager/*`read/crud/replace/patch/list mixin、`modules/host_sandbox_policy.py:125-170`(禁读/可写清单) | `_validate_path` + `_ensure_host_access` + 各 mixin 方法 | 全部文件工具 |
> 结构性提示(仅归纳):同一个「执行命令」语义在 **4 个文件里各有一份后端选择代码**terminal_ops/run.py、background_command_manager.py 的 `_run_command_thread`、persistent_terminal/start.py、container_file_proxy.pyhost/docker 分支判定条件互相复制。Execution Plane 接口一旦建立,这 4 处是天然的收敛点(本报告不要求本次重构,仅指出现状)。
---
## 4. 替身执行器接入点评估(内存替身,供 Runtime 测试)
### 前提约束(现状决定接入点形态)
1. 工具 handler 直接持有 `self.terminal_ops` / `self.file_manager` / `self.terminal_manager`main_terminal.py:123/125/131 构造);
2. `handle_tool_call` 是**单一工具入口**tools_execution.py:1057所有执行分支都在里面
3. 后台命令**不走 terminal_ops.run_command**,而是 `BackgroundCommandManager._run_command_thread`background_command_manager.py:185自己的实现——**只替换 terminal_ops 会漏掉后台 run_command**
4. `CustomToolExecutor` 只依赖 `terminal_ops.run_command`构造注入main_terminal.py:167替换 terminal_ops 即覆盖;
5. 子智能体工具执行复用主 terminal 的 `handle_tool_call`sub_agent/manager.py:1019——**替换主执行链即覆盖子智能体**
6. 文件工具 = 进程内操作 + `container_session` 代理file_manager 与 terminal_ops 是**两个独立对象**,替身需分别处理。
### 候选方案
| 方案 | 做法 | 侵入面估计 | 优点 | 缺点 |
|---|---|---|---|---|
| **A. handler 分支注入「执行后端」接口**(最小侵入,推荐) | 在 `MainTerminal`main_terminal.py 构造区 ~:121加一个可选属性 `execution_backend`(默认 None`handle_tool_call` 的 run_command 分支tools_execution.py:1879-1943含后台分支 :1909与文件写分支:1625 write_file / :1652 edit_file可选 :1569 create_file/:1589 delete_file/:1604 rename_file/:1677 create_folder插入 `if self.execution_backend: ... else: 原逻辑` | **改 1 个文件tools_execution.py+ 1 个构造点main_terminal.py~6-8 处小分支插入**需要为替身定义最小接口E1/E2/E3/E4约 4 个方法) | 语义最清晰Runtime 侧显式「执行环境依赖」,替身可同时挡命令+后台+文件写;不改 terminal_ops/background_command_manager 源码;后台命令也覆盖(在分支层拦截) | 需在 handler 内维护分支双轨(真实/替身有长期漂移风险文件读E5如果也要替身需再加分支 |
| **B. 构造期替换三件套**(对象级替换) | 给 MainTerminal 构造加参数(或测试子类覆写),用 FakeTerminalOps / FakeFileManager / FakeTerminalManager 替换 `self.terminal_ops/file_manager/terminal_manager`main_terminal.py:123/125/131 三行);后台命令因在 :1916 透传 `terminal_ops=self.terminal_ops`FakeTerminalOps 需实现 `_resolve_active_container_session/_validate_command/_resolve_work_path` 等被 BackgroundCommandManager 读取的属性 | **改 1 个文件main_terminal.py 构造区 3 行 + 可选工厂)+ 需实现 3 个替身类**接口面较大TerminalOperator 约 15+ 公开方法、FileManager 约 10+ 方法、TerminalManager 约 8 个);后台命令**无法被完全拦截**`_run_command_thread` 只取 terminal_ops 快照后自己执行,需另 patch | 对 handler 零侵入;贴近真实装配(资源解析同路径) | 替身类面太大、后台命令漏网点;容易「替了个寂寞」 |
| **C. 实例方法级 monkeypatch**(测试夹具式,零源码改动) | pytest fixture / 测试装配中直接替换实例方法:`TerminalOperator.run_command`、`_run_command_subprocess`、`BackgroundCommandManager._run_command_thread`、`FileManager.write_file/replace_many_in_file/create_file/delete_file/rename_file/create_folder` | **0 个源码文件改动**,但需列出 ~8-10 个方法覆盖清单(易漏);依赖内部方法名稳定性 | 对产品代码零侵入,快速验证 | 脆弱(内部改名即失效);只覆盖了「当前测到的路径」,后台命令线程路径尤其容易漏 |
### 推荐
**第 4 步首版建议方案 A**handler 分支注入 `execution_backend`),理由:
- 与「Runtime ↔ Execution Plane 分层」目标同构——替身执行器就是 Execution Plane 的第一个非 Web 实现;
- 覆盖齐全:前台/后台命令、custom_tool其唯一出口就是 terminal_ops.run_command被分支层拦截、子智能体复用 handle_tool_call、文件写
- 侵入面可数1 个文件tools_execution.py+ 1 个构造点main_terminal.py约 6-8 个小分支;
- 后续接真实后端Docker/host/远端)时,替身接口可直接演进为正式 Execution Plane 接口E1-E4 起步)。
若只想先验证「Runtime 循环不触发真实副作用」而不关心文件工具,可先只拦 run_command 前台+后台 2 个分支tools_execution.py:1909-1943侵入面缩到最小~2 处 + 1 属性)。
---
## 附:关键文件:行号索引
- 工具入口:`core/main_terminal_parts/tools_execution.py:1057`handle_tool_call、:391evaluate_tool_permission
- 执行器构造:`core/main_terminal.py:121-142`
- Web 广播注入:`core/web_terminal.py:100-152`
- 命令后端:`modules/terminal_ops/run.py:160/434`;后台:`modules/background_command_manager.py:39/185`;自定义:`modules/custom_tool_executor.py:40`
- 终端后端:`modules/terminal_manager.py:212/521``modules/persistent_terminal/start.py:72/141/241``modules/persistent_terminal/base.py:66`
- 文件后端:`modules/file_manager/base.py:50/77-100``modules/container_file_proxy.py:448-501``modules/host_sandbox_policy.py:125-170`
- OS 沙箱:`modules/host_sandbox_runner.py:174/197/213/229`Docker 只读:`modules/docker_readonly_exec.py:55/66/172`Landlock`modules/landlock_launcher.py:74/91/138`
- 事件/sender`server/tasks/models.py:901-920/1045-1054``server/chat_flow_task_main.py:989/1125/1511``server/chat_flow_tool_loop.py:505/522/828/969/986/1037``server/extensions.py:10/25``server/context/broadcast.py:16/28`
- 身份/执行环境装配:`server/context/resources.py:221/296/414/450`host/docker 容器句柄来源)
---
*报告完毕。本报告为纯只读调研产物;所有结论均基于静态阅读与 grep 定位,未经运行时验证。*

View File

@ -1,388 +0,0 @@
# Gateway 既有研究文档汇总research_summary
> 生成日期2026-09-07
> 任务性质:**全程只读**,未修改任何项目文件(仅在交付目录生成本汇总)。
> 汇总者:子智能体 #6
> 汇总原则:不掺入自身推测,所有结论注明来源文件;文档间有冲突时并列呈现并注明。
## 来源文件清单(实际读取的文件)
| 优先级 | 任务指定 | 实际路径(已确认) | 状态 |
|---|---|---|---|
| 1 | `cache_research/gateway/gateway_work_plan.md` | `cache_research/gateway/gateway_work_plan.md`2026-09-07 修订版) | ✅ 已读 |
| 2 | `cache_research/gateway/phase12_implementation_plan.md` | `cache_research/gateway/phase12_implementation_plan.md` | ✅ 已读 |
| 3 | `cache_research/gateway/eval_summary.md` | `cache_research/gateway/eval_summary.md`(含 R1-R6 审阅注释) | ✅ 已读 |
| 4 | 四份评估子报告 | `cache_research/gateway/eval_task_entry_points/task_entry_points.md` | ✅ 已读 |
| | | `cache_research/gateway/eval_flask_context_deps/flask_context_deps.md` | ✅ 已读 |
| | | `cache_research/gateway/eval_endpoint_classification/endpoint_classification.md` | ✅ 已读 |
| | | `cache_research/gateway/eval_event_approval_coupling/support_chains_coupling.md` | ✅ 已读 |
| 5 | `astrion_audit/astrion_gateway_gap.md` | `cache_research/gateway/astrion_audit/astrion_gateway_gap.md`**实际位于此路径**,工作区根无 `astrion_audit/` 目录,见下方说明) | ✅ 已读 |
| 6 | 外部参考 | `cache_research/gateway/opencode_study/opencode_architecture.md` | ✅ 已读 |
| | | `cache_research/gateway/openclaw_study/openclaw_gateway.md` | ✅ 已读 |
**路径说明**:任务指定 #5`astrion_audit/astrion_gateway_gap.md`,但工作区根目录不存在 `astrion_audit/`;实测唯一一份 `astrion_gateway_gap.md` 位于 `cache_research/gateway/astrion_audit/astrion_gateway_gap.md`(已被 gateway 相关文档同置于 `cache_research/gateway/` 下),本汇总即基于该文件。已按要求**未读取** `audit_entry_points_v2/` 等其他目录(属他人产出)。外部参考只需提炼被本项目借鉴的设计点,未做全文复述。
---
## 1. 三阶段路线全文要点来源gateway_work_plan.md2026-09-07 修订版)
**总体目标**:让 Astrion 的内部职责、状态修改规则和任务入口更清晰,使后续功能沿稳定边界扩展。近期明确新功能场景 = **定时任务**。长期方向是 GatewayRuntime/Gateway近期交付是在**现有进程内**整理运行时服务边界,让 Web、CLI 和定时触发器复用同一套任务受理/执行/控制逻辑。验收依据 = 新增入口所需理解和修改的范围 + 现有行为是否保留。
**近期主线(三件事)**
1. 固定已有状态边界和正确性保障。
2. 抽出接收显式运行上下文的公共任务入口。
3. 通过定时任务验证该入口,并补齐调度所需的持久记录和生命周期。
**非本次前置条件**:事件持久化、传输替换、公开 SDK、设备配对和远程执行分别评估。
**参考资料边界work plan 原文)**
- 原始愿景 `.astrion/user_upload/Astrion_Architecture_Product_Roadmap_Review_1.md`(重点 §1014、§35、§3738
- 原始盘点 `astrion_audit/astrion_gateway_gap.md` 的"无唯一 owner""补丁不是投影""恢复机制脆弱"等结论**须结合新核查理解,不能直接当作未修复故障**。
- 外部研究只参考职责分离/契约/幂等/恢复设计,不复制其部署/存储/认证方案。
### 现状关键结论work plan §1静态代码分析
- **已有保障**
- 对话级资源隔离:`server/context.py:58` 按 username/workspace/conversation 生成终端 key。
- 主任务门闸:`server/chat_flow.py:139` 获取、`:258` finally 释放,实现于 `server/main_task_gate.py`
- 对话保存保护:`utils/conversation_manager/crud_mixin.py:335` 按 message_id 合并防缩减;`:194` I/O 锁;`index_mixin.py:104` 原子替换文件。
- 客户端恢复:`static/src/stores/task.ts:156` 偏移轮询;`probe.ts:73` 对账恢复;`lifecycle.ts:137` 按 task_id/idx 去重。
- 审批单次决定:`modules/tool_approval_manager.py:65` 锁内裁决 pending已决定时返回现状。
- **耦合**
- 执行依赖 Web 环境:`server/tasks/models.py:785` 后台任务用 test_request_context + 回填 session 获取资源执行。
- 写入口分散:任务 manager、terminal、conversation manager 各有职责和直接调用方。
- 过程记录为内存态:`models.py:75` 有界事件 deque、`:108` 清理旧任务、`:762` 分配任务级 idx审批 manager 为进程内实例。
- **优化候选**`server/context.py:75` 用户级广播(当前也是投影方案)。
- **新能力(定时任务)**本轮搜索只发现任务清理、idle reaper 等维护定时器,**未发现用户 Schedule 实体与到期派发链路** → 需单独设计调度状态与记录,复用现有任务执行。
- **两个有界核验项**(勿将静态推断升级为故障):
1. `models.py:157`"检查运行中任务→创建记录"与最终执行门闸分属两处 → 核验并发是否产生重复任务记录。
2. 原报告提到 `server/chat/terminal.py::issue_socket_token` 发放竞争,本轮未复现;若仍存在作为独立小缺陷处理。
### 阶段一:固定契约与已有不变量(目标/范围/验收)
**目标**:明确状态属于谁、新入口应该调用哪里。
**范围清单**
- [ ] 产出 `docs/runtime_contract.md`(先描述内部服务契约;暂不强制公开网络协议/握手/实体全面改名)。
- [ ] 建立状态责任表:每项列明权威来源、允许的修改方、持久化入口、缓存刷新、并发裁决、失效条件。内存/文件/客户端缓存可共存,**权威关系必须明确**。
- [ ] 对齐概念:**Session=现有 conversationRun=一轮主任务Schedule=计划Occurrence=某次到期触发Event=变化通知**。子 agent、后台命令与主任务的关系单独说明不把现有 task 全部等同主 Run。
- [ ] 审批与用户提问保留不同语义;可共享 ID/关联/等待/回答基础设施,**提问答案不能被当作工具执行授权**。
- [ ] 明确公共入口所需 principal、workspace、conversation、模型/运行配置与事件输出接口;默认值由明确解析步骤产生。
- [ ] 建立调用方迁移表Web/CLI 对应 API、Workflow 激活、通知派发、定时触发;每项标注上下文来源、门闸获取/释放、取消传播。
- [ ] 围绕修改范围保留/补充回归用例:同对话并发、不同对话隔离、保存不丢消息、取消、审批重复回答、偏移恢复、过期响应过滤。
**完成标准**:目标入口的状态修改路径和执行裁决可定位;已有保障成为明确约束,未验证风险有独立记录。
### 阶段二:抽出显式上下文与公共任务入口(目标/范围/验收)
**目标**:无浏览器也能通过受控入口启动、观察和停止一轮任务。
**架构示意**
```text
Web / CLI 适配层 定时触发器 Workflow / 通知派发
\ | /
公共任务受理与控制入口
|
现有执行门闸与 Agent 执行
|
现有保存、审批、事件和取消链路
```
**范围清单**
- [ ] 以 `TaskManager.create_chat_task` 及执行链路为基础抽出服务接口(`RuntimeService` 为名称候选;文件位置按真实职责确定,避免把所有 manager/状态塞入新类)。
- [ ] 服务接口至少覆盖提交、查询、取消;审批回答复用现有 manager 通过明确关联接入;会话创建按需复用,第一条验证链路可用已有会话。
- [ ] HTTP 参数解析、Cookie/CSRF/Bearer 验证放适配层,向服务层传可信 principal 和经校验资源范围;**不能接受客户端或 Schedule payload 自报 role 作为授权依据**。
- [ ] 消除目标执行链路对隐式 Flask session 的读取;迁移期可保留兼容适配层但要列出剩余依赖;**只把 test_request_context 包进新方法不算完成解耦**。
- [ ] 复用门闸/取消/审批/保存规则;分别定义**任务受理去重**与**实际执行互斥**,防重复启动或门闸泄漏。
- [ ] Web 任务入口先接入服务,再逐条迁移 Workflow/通知等调用方;每次明确哪些旧写路径已封闭。**不能把"门面转发成功"写成"全部状态唯一 Owner 已完成"**。
- [ ] 内部错误采用稳定状态/错误码HTTP 状态码由适配层映射;事件先适配现有 idx/offset 和 sender不同时重写客户端。
**完成标准**:不创建浏览器会话、不伪造 HTTP 请求,测试能通过显式身份和资源上下文启动一次受控任务、读取结果并取消;同对话重入仍受门闸保护;现有 Web/CLI 行为兼容。执行可用可控模型/工具替身验证,无需真实外部副作用。
### 阶段三:定时任务纵向落地(目标/详细设计要求——本节最重要)
**目标**:时钟成为公共任务入口的另一个调用方。本节为**待实施设计**不代表当前已有调度器下列保守默认作为讨论起点UI 与默认行为在实现前确认。
#### 4.1 计划与触发记录
- Schedule 至少保存ID、所属 principal、目标 workspace、会话策略、提示词/任务配置、时间规则、时区、启用状态、配置版本。**明确夏令时重复/不存在时刻的处理**。
- 支持创建、暂停、恢复、删除计划。**建议暂停/删除只影响未来触发,已受理的 Run 另行取消**;最终行为须明确。
- 明确使用已有会话还是每次新建会话;首版可只实现一种,但须说明**上下文累积、目标删除和工作区失效时的行为**。
- 模型配置确定"创建时固定"还是"触发时解析"**权限在触发时按当前有效授权重新校验,不保存可绕过权限变更的长期授权快照**。
- 每个 Occurrence 有**稳定触发标识**(例如 schedule_id + 计划时间点保存所用配置版本、受理状态、run_id 和终态。计划编辑后的未来触发身份规则须明确。
#### 4.2 重复、重叠与停机
- 为 Occurrence 登记和 Run 受理定义**持久幂等规则**:同一触发重试返回同一受理结果,**相同键不同参数拒绝**;记录保留期覆盖允许的重试窗口。
- 明确"登记后未启动""已启动但关联未写完"等**崩溃窗口**。登记/受理应**原子提交或具备可验证的恢复对账**;不能只用内存 TTL 承诺跨重启不重复执行。
- 选定**单个活动调度器**的启动与所有权规则,防重载器或多个服务进程重复派发;不要求因此引入分布式基础设施。
- 同会话已有任务或上一轮仍运行时,定义 **skip/queue/parallel** 策略。**建议首版跳过并记录原因**,沿用会话门闸,不默默增加无限队列。
- 定义服务关闭期间错过触发的策略。**建议首版记录错过并等待下个未来时点,不集中补发**;服务需运行才会触发,本阶段不包含 OS 唤醒/开机自启。
- 重启后恢复 Schedule 和触发记录;**上一进程未确认完成的 Run 标为中断或结果待核验,不显示仍在正常运行,不自动重做可能已产生副作用的动作**。
- 触发去重仅保证任务受理规则,**不承诺任意外部工具副作用 exactly-once**。无法确认的执行结果进入显式待核验状态。
#### 4.3 审批、存储与验收
- 无人在线时仍保持原有权限限制:**遇到审批/提问按明确超时等待,超时结束或中断本轮并记录原因,不自动扩大权限**;等待中的任务也遵守重叠策略。
- 审批关联当前 Run/会话,复用现有 pending/answer 链路。**重启后旧请求不能被当作仍有执行现场的有效审批**;持久审批记录与继续执行分别设计。
- 为 Schedule/Occurrence/必要的运行摘要选持久化方案,**先列出原子更新、唯一性、查询和恢复要求,再决定文件或 SQLite**。数据走运行态路径解析,不写源码树;不强制迁移全部对话历史。
- 沿用当前任务事件读取;补充触发记录查询,使旧内存任务被清理或重启后仍能解释触发结果。日志记录 schedule_id/occurrence_id/run_id复用既有设施。
**完成标准**:可控时钟与执行替身验证一次触发、重复触发、任务重叠、计划暂停/恢复、目标失效、无人审批超时、停机错过触发及重启对账;真实入口验证不依赖浏览器。**明确只恢复计划与记录,不承诺从任意执行位置续跑**。
### 后续独立决策(有明确需求再启动)
| 决策 | 启动条件 | 决策前必须补充内容 |
|---|---|---|
| SSE / WS / 保持轮询 | 延迟/连接数/带宽不满足,或新增双向交互 | worker 模型、代理缓冲、重连、慢消费者;传输更换≠状态正确性提升 |
| 对话级订阅 | 需减少无关广播、精确受众 | 订阅与资源授权分别校验,区分任务/会话事件与用户级通知 |
| durable 事件与快照恢复 | 需超出内存窗口的过程追溯/跨重启生命周期查询 | 提交时机、序号作用域、快照边界、日志裁剪、缺口处理、schema 版本 |
| 审批持久化与可恢复执行 | 需重启后继续等待并执行原动作 | 重建上下文与待执行动作、权限重校验、过期请求处理、结果不确定性;恢复 pending 记录不能实现续跑 |
| TS 类型 / SDK 生成 | 对外契约或多客户端类型维护成实际成本 | 单一 schema 权威源、兼容策略、生成检查;不强制新网络握手 |
| 身份体系整理 | 跨凭证访问同一资源或统一授权成需求 | 统一 principal/resource/authorization各适配层可保留不同凭证不能直接合并 Web/API 用户数据空间 |
| 设备配对 / Remote Worker | 明确需多设备接入或远程执行 | 执行契约、连接身份、所有权转移、故障语义,独立设计验收 |
**事件恢复改造五条硬边界**(若启动):
1. 快照注明覆盖的事件水位 N 并与同一状态版本一致;续传从 N 之后开始;快照生成/订阅/历史读取之间不得漏事件窗口;重复投递仍需幂等应用。
2. durable 事件只承诺已提交记录可恢复live delta 是否恢复单独定义;运行中未完成 Item 需当前内容快照/覆盖式更新/不完整标记,不能只依赖最终 completed 事件。
3. 状态与事件写入需一致提交/恢复规则;对话 JSON、事件 JSONL、审批 JSONL 不天然构成一致快照与日志。
4. 连接序号、任务偏移、持久会话序号不能混用;连接重建/历史裁剪/会话重置时给出明确重新同步规则。
5. JSONL 与 SQLite 都需定义恢复、保留、迁移方案;选择依据是事务与查询边界,不承诺以后可低成本平移。
### 改造完成判断work plan §6
- 新增任务来源只需构造显式上下文、校验目标并调用公共入口,无需复制 Web 聊天启动流程。
- 已迁移路径有明确状态权威与写入规则,现有门闸、保存、客户端恢复保护保留。
- 定时任务有可查询的计划、触发记录和终态;重复、重叠、权限变化与停机行为可解释。
- 各阶段可独立验证;只为当前边界迁移做必要接口调整,不同时重写 Agent loop、前端状态管理或部署拓扑。
- 每阶段说明实际迁移入口、剩余兼容依赖、验证结果。**完成门面、生成架构图或更换传输本身不算完成改造**。
---
## 2. 范围评估核心结论来源eval_summary.md + 四份子报告,含 R1-R6 审阅修订)
### 2.1 196 个端点分类结果来源eval_endpoint_classification/endpoint_classification.md经 R1/R2 修订)
统计口径:以 Flask 路由注册Methods 合并计数)为准,共 **196** 个,分 26 个文件,与给定分布一致。
| 类别 | 数量 | 占比 | 是否迁移 |
|---|---|---|---|
| T1 任务受理 | 3 | 1.5% | ✅ 必须POST /api/tasks、/api/v1/.../messages、/api/workflow/activate |
| T2 任务控制 | 13 | 6.6% | ✅ 必须cancel×2、runtime_guidance/queue×4、workflow deactivate、sub_agents/background 停止×3、审批回答×3 |
| T3 任务观察 | 14 | 7.1% | ⚠️ 可不动(只读 REST轮询协议天然可复用 |
| C CRUD | 131 | 66.8% | ❌ 不动 |
| S 状态查询 | 20 | 10.2% | ❌ 不动 |
| A 认证管理 | 15 | 7.7% | ❌ 不动 |
| **合计** | **196** | 100% | — |
**核心答案:必须迁移的最低集合 = T1 + T2 = 16 个端点8.2%**。16 个端点全部收敛到同一批 manager 单例方法task_manager / sub_agent_manager / background_command_manager / 三个 approval manager。**Gateway 化实质 = 把这几个 manager 方法提升为 RuntimeService 公共入口**HTTP 端点从"直接调 manager"改为"调 RuntimeService",而非改写端点本身。
**迁移复杂度分布16 个)****12 小 / 3 中 / 1 大**(唯一大项 = workflow activate因门闸 token 移交 + 状态机编排)。
**审阅修订要点**
- **R1范围**16 个是当前分类下的受理/控制端点集合,**不是架构改造完成的充分条件**。`server/chat/permission.py` 的权限/执行环境/网络权限变更在任务运行期会排队生效,**必须进入阶段一状态责任表**;其余端点应描述为"多数可保持 HTTP 兼容",不能概括为"与运行时零耦合"。查询端点可保留原 URL但后台调用方CLI/定时任务)应能通过内部接口(如 `get_task_events`)查询,不必为观察任务发 HTTP。**新增门面不等于全部写入口已收敛**。对应子报告 §4.4 明确:`chat/permission.py:166-185 / :265-284 / :352-368` 分别排队修改运行中的权限/执行环境/网络权限,`work-mode` 在 `:420-445` 拒绝运行中切换。
- **R2计数与编排**11+3+1=15 应为 **12 小 / 3 中 / 1 大 = 16**仍为定性估算。Workflow 编排(会话补建、激活、门闸移交、失败回滚)留在 HTTP 层只适合作为**过渡**,长期应按复用需求下沉到工作流服务供非 HTTP 入口调用;无需全部塞入 RuntimeService。
- **R3依赖与异常路径**core/modules/utils 无直接 Flask 导入已确认,但**不能外推为所有间接调用都不依赖请求上下文**"无旁路"须限定为正常受理路径:`chat_flow_task_main.py:645-675` 在任务创建失败后直接执行 `handle_task_with_sender`**绕过 TaskRecord 登记**(无事件 deque、不可按 task_id 轮询/取消);外围轮询器有预占门闸,不能仅凭回退认定并发写入 bug但**迁移验收必须覆盖回退路径的记录、事件、取消、门闸生命周期**。socket `send_message``socket_handlers.py:259` 已短路返回 DEPRECATED属死代码应与活跃回退分开。"5 个调用点"实为 5 类来源、6 处调用位置。
- **R4上下文设计与工作量**9 字段是旧 session 依赖的搬迁清单,不是最终领域模型;应区分"可信身份与资源范围""本次任务参数""内部执行信息"(门闸 token/通知回滚**不得成为普通客户端可提交字段**);明确默认值/对话配置/本次覆盖的解析优先级。6-8 文件属估算;`get_user_resources` 有约 **170+ 调用处**(含 83 处 @with_terminal 装饰器路径),影响面需 host/Web/API 身份 × 会话 × 默认值来源回归覆盖,不宜承诺每处都小。
- **R5审批产品边界**:拒绝当前工具后继续运行**不自动意味着不安全**(后续动作仍受权限约束)。等待人工/到期终止/拒绝当前动作后继续是不同产品策略,**不能把"定时任务禁止人工审批"当作必选技术条件**。工具审批、计划审批、用户提问应分别定义超时含义。超时策略可在阶段三明确,**不阻塞**保持原有语义的阶段二上下文重构。
- **R6取消与生命周期**:标准停止按钮走 **REST 硬取消**,可打断审批等待;缺口应**限定为仅设置软停止标志的路径**socket stop_task 软 stop不能描述成所有停止按钮失效。需跟进超时/取消后 pending 终态更新、审批与 Run 关联;条清理不能只按 TTL 删仍有合法等待者的请求。task_id 恒 None 目前仍是**静态疑点**,须验证运行时载荷后定性。
### 2.2 任务链路 Flask 依赖性质来源eval_flask_context_deps/flask_context_deps.md经 R3 修订)
**总体判定:依赖属于「浅层入口型」,集中两个枢纽函数,执行体干净。**
- **出口即枢纽**
1. 入口 `server/tasks/models.py::create_chat_task`(直读 session 快照)+ `_run_chat_task`test_request_context 包裹后台线程再调 get_user_resources
2. 资源获取 `server/context.py::get_user_resources`(及内部 `_apply_workspace_personalization_preferences`、admin policy
- **执行体干净**`core/`、`modules/`、`utils/` **零 Flask 隐式上下文**`session` 同名变量全是终端会话/容器句柄或 dict 参数,非 Flask`server/chat_flow*.py` 9 个文件函数体内**零** session/request/has_request_context 使用(只有 import 行)。
- 命中统计:测试 request_context 全仓仅 1 处models.py:794has_request_context ~17 处全在 context.pyB 类(必须消除)集中在 2 文件tasks/models.pysession 15 行)+ context.pysession 31 行)。
- **B 类依赖清单**(必须消除/显式化):
- **B-1** `models.py:785-812` `_run_chat_task` 的 test_request_context 包装(核心);难点不在删 wrapper 而在 get_user_resources 参数化。
- **B-2** `models.py:180-203` `create_chat_task` 直接读 Flask session含 else 分支全量直读 http 上下文、setdefault 兜底,见下方"冲突/补充")。
- **B-3** `context.py::get_user_resources`L221-535核心内部 8-10 处 has_request_context 守卫读写 host_mode/workspace_id/host_workspace_id/run_mode/thinking_mode/model_key/is_api_user + 无守卫 record/role 读。
- **B-4** `context.py::_apply_workspace_personalization_preferences`L116-178读 model_key/回写)。
- **B-5** `context.py::ensure_conversation_loaded`L750-810写 session 回写,任务线程内已天然跳过但属隐式耦合)。
- **B-6** `server/auth_helpers.py:35-49` 认证辅助被任务链路经 get_user_resources 无守卫调用。
- **C 类均已核查归 A可保留**`server/conversation.py` 的 host_mode/input_draft/terminal 相关函数、`with_terminal` 装饰器83 处全部路由、socket_handlersflask-socketio 适配层)。
- 依赖深度结论:**浅层**。B 类无条件耦合仅 2 处(创建入口 + 线程包装),守卫型耦合集中在 context.py 一个文件。
- **R3 修订**:直接搜索不能证明所有间接调用都无上下文依赖;"浅层"描述依赖位置不等于改动风险低get_user_resources 的身份/资源分支需行为验证,不能以删除 import 或零关键词命中代替验收。
### 2.3 任务入口五点评估来源eval_task_entry_points/task_entry_points.md经 R3 修订)
- 执行链正常受理路径已单一收敛5 类来源、6 处调用位置共用 `create_chat_task → 线程 → _run_chat_task → run_chat_task_sync → process_message_task → asyncio loop.create_task(handle_task_with_sender)`
- 六处调用点概览:
| # | 文件:行 | 入口 | 触发来源 | 显式化程度 | 迁移难度 |
|---|---|---|---|---|---|
| ① | tasks/api.py:200 | create_task_api | Web HTTP POST | 低(无 session_data | 小~中 |
| ② | api_v1.py:320 | send_message_api | Web HTTP POSTBearer | 低(无 session_data | **中**is_api_user/role 必须显式) |
| ③a | workflow_runtime_api.py:184 | api_activate_workflow | Web HTTP POST | 高session_data 显式) | 小~中 |
| ③b | workflow_runtime_api.py:264 | api_deactivate_workflow | Web HTTP POST | 高(同上,+通知池/门闸回滚) | 小~中 |
| ④ | chat_flow_task_main.py:613 | _dispatch_completion_user_notice | 内部后台轮询线程 | 极高(全显式) | **小** |
| ⑤ | chat_flow_task_main.py:1416 | _dispatch_multi_agent_idle_messages | 内部后台轮询线程 | 极高(全显式 + task_type=notice | **小** |
- **迁移真实难点**(从大到小):
1. `get_user_resources` 的 session 隐式读取参数化host_mode/is_api_user 分支选错即静默串工作区,最高风险)。
2. API 身份字段is_api_user/role正确传递调用点②唯一无 session_data 的 API 来源)。
3. main_task_gate 门闸移交语义预占→token 随 session_data 移交→线程认领→finally 释放/失败回滚)。
4. `task_type="notice"` 互斥豁免与单对话互斥语义保持(调用点⑤)。
5. 三套身份取数来源统一web session / token session / web_terminal 属性)。
- 另项(不在本 5 点但相关):`socket_handlers.py:345 → start_chat_task`socket 直连执行链,**不可达死代码**`chat_flow_task_main.py:664` 通知回退直接执行(活跃,见 R3
### 2.4 四支撑链路评估来源eval_event_approval_coupling/support_chains_coupling.md经 R5/R6 修订)
总体判定:**事件/取消/保存三条可直接复用(需适配层);审批链路需改造**。
- **事件链路:直接复用**。载体 TaskRecorddeque maxlen=20000 + 每任务 idx调用方只需 create_chat_task 拿 task_id 即可轮询/取消socket 推送按 `user_{username}` 房间(离线无影响,但用户在线会收到定时任务事件 → **前台干扰**,需加 source 字段或确认产品预期)。适配点:消除 test_request_context、定义定时事件来源标识。
- **取消链路:直接复用**。寻址=username+task_idstop_flags 任务级 key=task_id=client_sid不依赖 terminalconversation 仅副作用);保留"独立事件循环 + entry 持 loop/task"模式以支持硬取消;公共入口须把 task_id 持久化到 Occurrence。
- **保存链路:直接复用,前提明确**。写入口收敛于 `context_manager.add_conversation`(每消息 auto_savemerge-on-save + `_io_lock`manager 实例级,跨实例不互斥)+ 主任务门闸三道防线。前提:新调用方必须(1)走对话级 terminal、(2)遵守主任务门闸、(3)会话策略决定 conversation_id。存在执行链之外第二批写者设置/压缩/CLI共用同一保护。
- **审批链路:需改造**。核心问题§2.4
1. 等待循环默认超时 3600s 且调用点(:435/:574/:883/:1153未传 timeout 参数(均有参数但调用点没传)。
2. 超时语义现状=**拒绝该工具、任务继续**(非结束任务)——产品决策项,两种候选:(a)拒绝该工具继续(现状,风险无监督继续);(b)结束整个任务。
3. 等待期间软停止无效(下一工具调用行首 :600 才生效)。
4. manager 条目无 TTL/清理。
5. 存储 manager 可复用(与 Web 前端共用 pending/answer 数据);`auto_approval` 分支ApprovalAgent是唯一现成无人工决策路径但仅覆盖 tool 审批。
- 候选改造最小改动排序注入可配置超时session_data 透传)→ 定义超时语义 → 等待循环加 stop 检查 → manager 条目 TTL/清理 → 会话策略决定审批是否可达。
- **R5 修订**§2.4 的"禁止人工审批 / 60-300s 超时"均为**待讨论选项,不是定时任务的技术前提**;拒绝当前工具后继续仍受权限限制,不自动等于不安全;可等用户上线回答。工具审批、计划审批、用户提问应分别确定超时处理。客户端离线但服务运行时可保留现有 pending跨服务重启保留记录与恢复等待执行是另外两层需求。
- **R6 修订**:停止按钮无效应限定为只置软停止标志的路径(标准按钮走 REST 硬取消可打断);条目生命周期应先定义超时/取消终态与迟到回答处理,再设保留期清理,不能只按 TTL 删仍被等待的 pendingtask_id 恒 None 保持静态疑点,待运行时关联验证。
### 2.5 审阅修订要点汇总R1-R6 已嵌入各节)
见上述各节 R1/R2/R3/R4/R5/R6 处。
---
## 3. 阶段一/二实施与验收记录来源phase12_implementation_plan.md另见项目记忆 gateway_runtime_work_plan
**状态**:阶段一、阶段二代码工作**全部完成**;改造相关测试 **24/24 全绿****工作区改动尚未 commit**;阶段三(定时任务)未做(功能未讨论)。
### 3.1 设计决策(按 R4 审阅意见RuntimeContext 三层分离
```
TrustedPrincipal 可信身份与资源范围username / workspace_id / host_mode /
host_workspace_id / is_api_user / role
——只能由适配层认证后构造,客户端不可自报
TaskParams 本次任务参数message / images / videos / files / model_key /
run_mode / thinking_mode / max_iterations / conversation_id /
goal_mode / skill_context_messages / message_source
InternalDirectives 内部执行信息main_task_gate_token / auto_user_message_event /
auto_user_message_payload / preceding_user_notices /
approval_timeout_seconds仅透传机制默认语义不变
——普通客户端不可提交,仅内部调用方(通知链/工作流/派发器)使用
```
**默认值解析优先级**:本次显式传参 > 对话元数据绑定 > 会话/用户偏好快照 > 系统默认。会话配置恢复仍在 `get_user_resources` + 会话加载链路内完成。
**RuntimeService 最小接口集**`create_task(principal, params, directives) -> task_id``cancel_task(username, task_id)``enqueue_runtime_guidance / enqueue_runtime_pending_message / remove_runtime_pending_message / promote_runtime_pending_to_guidance``get_task / get_task_events(username, task_id, offset)`内部查询接口CLI/定时任务不必走 HTTP。审批回答复用现有三个 manager通过明确关联接入不进 RuntimeService 首版。
**两步走**步骤①兼容期RuntimeContext + RuntimeService 骨架落地,`create_chat_task` 接受显式上下文6 处调用点迁移test_request_context 桥保留作兜底步骤②拆桥get_user_resources 增加显式参数变体,`_run_chat_task` 改走显式路径后删 test_request_context 桥ensure_conversation_loaded 的 session 回写移出任务路径。
### 3.2 关键风险与对策
1. `get_user_resources` 参数化最高风险host_mode/is_api_user 分支选错 → 静默串工作区。对策:显式变体与 web 路径并存,先任务线程单点切换,回归验证后再推广。
2. 门闸 token 移交语义InternalDirectives 原样承载;通知链"预占→移交→认领→失败回滚"不变。
3. `task_type="notice"` 互斥豁免保留。
4. 异常回退路径chat_flow_task_main.py:645-675 直接执行 handle_task_with_sender保留语义验收覆盖其门闸/事件/取消生命周期。
5. 事件前台干扰:阶段二不加 source 字段(阶段三产品决策),现有行为不变。
### 3.3 实施内容清单(阶段一/二)
**阶段一交付**
- `docs/runtime_contract.md`概念对齐Session/Run/Schedule/Occurrence/Event、10 项状态责任表、已有保障约束清单、RuntimeService 契约(含 RuntimeContext 三层模型定义、调用方迁移表、12 项回归用例 T01-T12。
**阶段二交付**
- 新建 `server/runtime/` 包:`context.py`RuntimeContext 三层模型,`from_terminal()`、`principal_from_session_snapshot()`、`to_session_data()`+ `service.py`RuntimeService + 进程级单例 `runtime_service`)。
- `TaskManager.create_chat_task` 强制显式 session_data缺失即 ValueErrori18n key `tasks.missing_session_data`)。
- 6 处调用点全部迁移到 `runtime_service.create_task()``server/tasks/api.py`、`server/api_v1.py`、`server/workflow_runtime_api.py`×2、`server/chat_flow_task_main.py`×2完成通知派发 :613、多智能体 idle 派发 :1416。门闸 token 移交、`task_type="notice"` 互斥豁免语义原样保留。
- `server/context.py`989 行)拆分为 `server/context/` 子包identity / broadcast / personalization / usage / upload / conversation / resources / decorators / reaper 共 9 模块 + `__init__.py` 兼容 re-export外部 import 路径不变)。
- `get_user_resources` 参数化:新增 `RuntimeIdentity` 显式身份快照(定义于 `server/context/identity.py`runtime 包引用,依赖方向自下而上无循环)。
- **test_request_context 桥已拆除**`server/tasks/models.py::_run_chat_task` 不再建立 Flask 请求上下文,任务线程全程 RuntimeIdentity 驱动。
- 审批超时透传管道terminal 属性 `_approval_timeout_seconds`(默认语义 3600s 不变),`_run_chat_task` setattr → `server/chat_flow_tool_loop.py::_approval_timeout_for()` helper → 4 个 `_wait_*` 调用点。
- 附带修复:`config/_load_dotenv` 对禁读 `.env` 的沙箱环境加 try/except不再 PermissionError 崩溃)。
### 3.4 测试构成24/24 绿的明细)
- 改造相关 **24/24 全绿**
- `test_server_refactor_smoke`**6**
- `test_runtime_service`**10**,新增)
- `test_conversation_model_persistence`**4**patch 目标随迁)
- `test_runtime_identity_resources`**4**,新增,覆盖 get_user_resources 的 web/host/api 身份路由)
- 验收还含:`python3 -m py_compile` 触及文件全过;测试显式上下文(无 HTTP 请求)受理任务 → 拒绝同对话并发 chat 任务 → 取消;不触达真实模型调用。
- **存量失败 4 项**conversation_workspace_storage / host_workspace_manager / skills_manager / token_usage_extractor经甄别与本次改动**零相关,未修**。
### 3.5 遗留待办 / 未验证项
1. **真实运行环境验证**Web 聊天/停止/审批/workflow 激活/多智能体派发)需**用户重启服务后人工完成**——get_user_resources 分支选错会静默串工作区,是最高风险点。
2. **审批条目 task_id 恒 None**(静态疑点,待运行时验证)。
3. **socket 软 stop 不打断审批等待**REST 硬取消可以),留待阶段三或独立决策。
4. 工作区改动未 commit。
---
## 4. astrion_gateway_gap.md 的早期结论及其被新核查修正情况
> 来源:`cache_research/gateway/astrion_audit/astrion_gateway_gap.md`2026-09-07 早期盘点,静态代码分析,未做运行复现)。其结论须与 2026-09-07 的 gateway_work_plan / eval_summary 等新核查对照。
### 4.1 早期主要论断(原文)
1. **API 面**12 个蓝图 RESTtasks_bp 轮询主线)+ 1 个 Socket.IO 辅助通道;消息发送与进度事件已收敛到 POST/GET /api/tasks 轮询模型socket `send_message` 已废弃。
2. **事件通道**:单一 sender 抽象(写入事件流 + socket 推送合并per-task idx、无全局序号事件流是任务作用域 + 内存 + 1h TTLdeque 20000 上限cleanup 1h
3. **断线追数据三层**:事件流偏移继续 → 对账接口 resumeTask(resetOffset) 全量重放 → 前端 task_id:idx 去重socket 重连取一次性 token。
4. **状态归属四层**①进程内存对象terminal/task_manager/三个 approval manager/GLOBAL_MULTI_AGENT_STATES/usage/socket-token/stop flags②运行态文件conversation/*.json、sub_agents.json、personalization.json、settings.json③Flask session ④前端本地。**无系统级唯一 owner**。
5. **无统一事件序号/事件总线**idx 是 per-tasksocket 事件不带 idx通道间无法全局对齐
6. **审批/提问/计划 = 进程内存 + 绑定 username+conversation_id无端认领**,重启即失;无"哪个端在展示/谁决定"记录。
7. **终端缓存 key 已按对话隔离(好),但实例是共享可变对象**;每请求 attach_user_broadcast 重绑回调。
8. **广播粒度是 user 房间,无 conversation 级订阅/投影**"补丁不是投影"。
9. **socket-token 发放互踩**issue_socket_token 清空同用户旧 token
10. **"停止/活动"仍假设任务绑定连接**handle_disconnect 的 has_other_connection / REST running 判断)。
11. **REST-only 客户端存在事件盲区**(标题更新无 running task 只走 sockettoken_update/todo_updated/edited_files_updated 全局广播不一定进任务事件流)。
12. **认证两套并存**session+CSRFWeb/CLI/host-login与 Bearer token/api/v1不互通。
13. **socket-token 发放互踩**、**断线即停任务假设**等多客户端缺陷尤甚。
### 4.2 哪些已被 2026-09-07 新核查推翻或修正(对照 work plan / eval_summary / 子报告)
新核查在 `gateway_work_plan.md §0 参考资料的使用边界` 明确要求:早期盘点的"无唯一 owner / 补丁不是投影 / 恢复机制脆弱"等结论**不能直接当作未修复故障**,须结合新核查理解。逐条对照:
| 早期论断 | 新核查状态 |
|---|---|
| "无统一事件序号 / 无事件总线"作为差距 | ✅ 部分成立:新核查确认事件 idx 是 per-taskmodels.py:762-782、无全局序号但 work plan 已把它列入**后续独立决策**durable 事件),不构成阶段一/二/三的前置条件。子报告确认事件链路"可直接复用(需适配层)"。 |
| "状态真源分四层、无唯一 owner"、"补丁不是投影" | ⚠️ 需谨慎work plan §0 明确不能直接当未修复故障;阶段一目标正是"固定状态责任表、让权威关系明确",阶段二哲理性反对"把门面转发当 owner 完成"。这是**改造目标陈述**而非已证故障。 |
| "恢复机制脆弱"(断线靠前端从 0 重放 + 去重) | ⚠️ 修正work plan 现状确认客户端恢复保护task.ts 偏移轮询 + probe.ts 对账 + lifecycle.ts 去重)**是要保留的已有保障**;事件链路 sub 报告判定可直接复用。脆弱性被纳入阶段三"持久化选型"与"只恢复计划/记录"的边界。 |
| "socket-token 发放互踩"issue_socket_token 清空旧 token | ⚠️ 未复现work plan §1 保留为**待核验项**"本轮未重新复现;若仍存在作为独立小范围缺陷处理,不捆绑整个运行时改造")。早期报告自标"很大概率(依赖时序竞争)"。 |
| "审批状态进程内存、重启丢失、无端认领" | ⚠️ 部分成立并纳入阶段三:子报告确认三个 manager 纯内存、无 TTL/清理work plan 阶段三列为"重启后旧请求不能被当作有效审批"、审批持久化列为**后续独立决策**。 |
| "停止假设任务绑定连接"handle_disconnect 判断) | ✅ 方向正确work plan 阶段三要求"任务生命周期独立于任何连接"但子报告R6补充**标准停止按钮已是 REST 硬取消、可打断审批等待**,软 stop 缺口才需处理。 |
| "REST-only 盲区 / 广播按 user 房间" | ⚠️ 部分成立work plan 把对话级订阅列入后续独立决策;事件链路判定可直接复用但需处理"前台干扰"。 |
| "认证两套并存不通" | ✅ 成立但列为后续独立决策work plan 身份体系整理,明确**不能直接合并 Web/API 用户数据空间**)。 |
> 结论说明:早期盘点提供**素材与差距方向**,但多数"差距"已被新核查重新定位为"后续独立决策"或"保留的已有保障"**没有一条被新核查完全证实为当场需要修复的 bug**,仅 socket-token 互踩与审批 task_id 恒 None 保留为待核验疑点。更详细的早期事实(蓝图清单、事件类型、状态归属表)见 astrion_gateway_gap.md 原文,此处不重复全文。
---
## 5. 外部参考借鉴点来源opencode_architecture.md / openclaw_gateway.md
> 按 work plan / eval 定位:仅提炼被本项目认可的职责分离 / 契约 / 幂等 / 恢复设计,**不复制其部署、存储或认证方案**;外部实现未在本轮重新核验。
### 5.1 opencode 被本项目认可的设计原则3-5 条)
1. **事件 durable 化 + after=N 重放续传**SQLite 事件表aggregate_id, seq, type, data+ `durable(after)` 先重放历史再续传实时;"live-only delta 不入库、ended 终值入库可重放"降本。→ 对应 work plan 后续"durable 事件与快照恢复"及五条硬边界。
2. **"单进程多 workspace 懒加载实例 + 请求头路由实例"**`x-opencode-directory`)而非一项目一进程 → 对应 work plan "单活动调度器/单进程"思路。
3. **协议包与实现分离 + handler 注入**protocol/server/core 分层)→ 对应 RuntimeService 职责单一、避免把 manager 塞进新类。
4. **OpenAPI/协议单一事实源 + 代码生成 SDK**(文档与代码同源,避免过期)→ 对应后续"TS 类型/SDK 生成"决策(单一 schema 权威源)。
5. **审批 = 异步请求对象 + 阻塞等待 + 事件广播 + REST 回复,多客户端共享同一请求 ID、先到先得**(无需连接路由,只需请求对象全局唯一、回复幂等)→ 对应后续"审批持久化与可恢复执行"决策的幂等/认领思路。
### 5.2 openclaw 被本项目认可的设计原则3-5 条)
1. **"Gateway 单所有者 + 客户端只读投影"**:真状态归 gateway 进程,前端只是 snapshot+增量事件镜像,写操作一律走 RPC 并做冲突检测 → 对应 work plan 状态责任表 / "不能把门面转发当 owner 完成"。
2. **乐观锁lifecycleRevision + expectedRevisionpatch/send 带 expected_revision**:轻量冲突检测 → 对应持久化"原子更新/唯一性"选型要求。
3. **幂等键idempotencyKey**:协议 schema 层强制必填;"内存 Map + TTL + 容量上限 + inflight 共享 Promise + 同 key 同参数校验"以及消息类"幂等键进 transcript + 落盘扫描"持久化去重 → 直接对应阶段三「持久幂等」要求(同触发重试返回同受理结果,同键不同参数拒绝)。
4. **事件不重放 + seq 间隙检测 + 断线重连全量 snapshot**(客户端无状态投影,服务端不缓存事件/只做 per-connection seq 计数)→ 对比 Astrion 现有"从 0 重放 + 去重"前端机制,为将来 durable 事件/重连语义提供参考。
5. **事件广播受众过滤(按 sessionKey + 订阅者集合,只发订阅了该 session 的连接)+ 方法/事件清单白名单下发feature discovery** → 对应对话级订阅决策的"订阅与资源授权分别校验"与内部查询接口思路。
> 说明:上述均为"被本项目认可/借鉴"的提炼,不构成对 opencode/openclaw 自身的全面评价。
---
## 6. 遗留问题全集(「待办/待验证/已知缺口」去重汇总,每条注明来源)
> 按来源文档分组;同名跨文档者已去重合并,标注多来源。
### 来自阶段一/二实施记录与项目记忆phase12 / gateway_runtime_work_plan
- G1. **真实运行环境验证未完成(最高风险)**Web 聊天/停止/审批/workflow 激活/多智能体派发需用户重启服务后人工完成get_user_resources 的 host/Web/API 分支选错会**静默串工作区**。phase12 §遗留待办 1
- G2. **审批条目 task_id 恒 None静态疑点待运行时验证**`getattr(web_terminal,"task_id",None)` 全仓无赋值点;影响审批无法按 task_id 检索关联。phase12 §遗留待办 2eval_summary §4support_chains §2.3 A3R6 重申)
- G3. **工作区改动未 commit**phase12 状态;项目记忆)
- G4. **存量测试失败 4 项**conversation_workspace_storage / host_workspace_manager / skills_manager / token_usage_extractor甄别与本次改动零相关、未修。phase12 §测试验收)
- G5. **socket 软 stop 不打断审批等待**REST 硬取消可以留待阶段三或独立决策。phase12 §遗留待办 3support_chains §2.4.3eval_summary §4
- G6. 阶段二未加事件 source 字段前台干扰为阶段三产品决策phase12 §风险对策 5
### 来自 work plangateway_work_plan / 项目记忆)
- G7. **两个待核验项**:① `models.py:157`"检查运行中→创建记录"与最终门闸分属两处,并发是否产生重复任务记录未验证;② `server/chat/terminal.py::issue_socket_token` 发放竞争未复现若存在作为独立小缺陷。work plan §1
- G8. **阶段三整体未实施**Schedule/Occurrence 无代码;幂等/重叠/停机/审批超时/持久化选型全部待实现与讨论work plan §4项目记忆
- G9. 后续独立决策均"有明确需求再启动"SSE/WS/轮询、对话级订阅、durable 事件与快照恢复、审批持久化、TS/SDK 生成、身份体系整理、设备配对/Remote Worker。work plan §5
### 来自范围评估与子报告eval_summary + 四子报告)
- G10. **并发重复任务记录未验证**(同 G7①工作区/交互层静态推断不能据此断言并发修改同一对话eval_summary / task_entry_points R3
- G11. **审批等待循环超时 3600s 且调用点未传参**;超时语义(拒绝工具继续 vs 结束任务)为**产品决策未定**;工具/计划/提问三类超时含义分别定义。R5 明确它不是定时任务技术前提。support_chains §2.4eval_summary R5
- G12. **审批 manager 条目无 TTL/清理**,未决条目永久留在内存;需先定义超时/取消终态与迟到回答处理再设保留期。support_chains §2.4.4R6
- G13. **事件前台干扰**:定时任务事件会推给在线用户 socket 房间,需加 source 或确认产品预期。support_chains §1.5eval_summary §3.5
- G14. **执行链异常回退路径**chat_flow_task_main.py:645-675绕过 TaskRecord 登记,迁移验收必须覆盖其记录/事件/取消/门闸生命周期。R3task_entry_points
- G15. **task_id 必须回传并持久化给发起方**Occurrence 记录 run_id=task_id取消才可寻址support_chains §3.4
- G16. 保存链路前提未全验证:新调用方须走对话级 terminal、遵守门闸、会话策略决定 conversation_id跨实例锁不互斥风险依赖多道防线。support_chains §4.4
- G17. `get_user_resources` 约 170+ 调用处(含 83 处 @with_terminal)影响面需 host/Web/API × 会话 × 默认值来源回归验证不宜承诺每处改动都小。eval_summary R4flask_context_deps B-3
### 来自早期盘点astrion_gateway_gap.md多数已被新核查降级/转为决策,见 §4.2
- G18. 无统一事件序号/事件总线、状态无唯一 owner、审批重启丢失、REST-only 盲区、广播按 user 房间、token 互踩、停止绑定连接、认证两套并存等早期差距——**均已按 §4.2 修正为后续独立决策或保留保障**勿当作当场故障。astrion_gateway_gap
---
## 附:汇总口径与一致性说明
- 本汇总**未掺入子智能体自身推测**,所有小节结论均标注来源文件;文档间冲突(如 R2 计数 15 vs 16、R5 审批超时作为技术前提与否)已在对应处并列呈现并注明修订来源。
- 路径/行号均为各源文档读取时标注,可能因后续代码变动而失效,应以符号/函数名为主。
- 交付物仅 `research_summary.md` 一份,存放于 `cache_research/gateway/audit_research_summary_v2/`

View File

@ -1,335 +0,0 @@
# Runtime ↔ Execution Plane 链路审核报告
- 审核日期2026-09-08
- 审核方式只读grep/符号定位 + 精确行段阅读 + 隔离 import 实验 + 子进程验收测试运行观察)
- 审核对象Gatewayserver/runtime/)→ Agent Runtimeserver/chat_flow*.py、core/main_terminal.py→ Execution Planecore/main_terminal_parts/tools_execution.py、modules/execution_plane/
- 结论分级:✅属实 / ⚠️部分属实 / ❌不属实 / 未验证
- 验收测试运行结果(本机 venv 实测):
- `check_lifecycle`**PASSED (1.6s)**(无 Flask app 真实生命周期)
- `check_fake_exec`**PASSED (0.7s)**(替身执行 E1-E4零真实副作用
- `check_chain`**PASSED (2.7s)**(双客户端发现/观察/取消 + 历史 + 审批)
- `check_approval_wait`**PASSED (3.5s)**(执行中审批等待 → 公共入口批准 → 继续)
---
## 一、逐项声明验证结论
### 1. Runtime 层本体server/runtime/context.py + service.py
#### (a) 是否真的不 import flask —— ✅ 属实
证据:
- `server/runtime/context.py` 顶层 import 仅:`__future__` / `dataclasses` / `typing`L14-17
- `server/runtime/service.py` 顶层 import 仅:`__future__` / `typing` / `modules.i18n.tr` / `server.runtime.context`L13-18
- `server/runtime/` 全文 grep `flask|socketio`:唯一命中的是 `context.py:141` 的**注释文本**「本模块不 import flask」说明性文档非导入
- `grep -rniE "import flask|from flask|import socketio|from socketio" server/runtime/`**零实际导入命中**
import 实验佐证(本机 `.venv/bin/python3`
```
>>> import server.runtime.service
OK
>>> 'flask' in sys.modules # False未拉起
>>> 'flask_socketio' in sys.modules # False
>>> 'server.tasks' in sys.modules # False延迟导入未触发
```
结论Runtime 层本体零 Flask 依赖 ✅。注意:**模块本体不依赖 flask ≠ 调用链不依赖 flask**(见第 3 节 import 链实测)。
#### (b) TrustedPrincipal 只能由适配层构造?防自报 role 机制 —— ⚠️ 部分属实
证据:
- `context.py:20-41``TrustedPrincipal` 是普通 `@dataclass(frozen=True)`**没有私有构造/工厂限制**——任何代码均可直接 `TrustedPrincipal(username=..., workspace_id=..., role="admin")` 自报角色。
- `validate()`L30-34只校验 `username`、`workspace_id` 非空,**不校验 role 白名单、不校验 is_api_user/host_mode 组合**。
- `RuntimeContext.from_terminal`L79-105`principal_from_session_snapshot`L139-168是仅有的两个"可信构造路径"
- `from_terminal`host_mode 取 `workspace.username == "host"`、role 取 `terminal.user_role`(对话级 terminal 既有属性,非客户端自报);
- `principal_from_session_snapshot`:显式声明「调用方必须在完成认证后调用」,从适配层传入的 session dict 快照构造。
- 防御纵深确实存在:`service._resources_for_query`service.py:269-283校验 `principal.username == username``principal.workspace_id == workspace_id`**跨身份/跨工作区查询抛 PermissionError**(测试 check_chain 第 5.5 节已验证此语义)。
结论:**「只能由适配层构造」是模块 docstring 与调用约定的强语义 + 查询侧纵深防御,不是 dataclass 层面的强制**。同一进程内任何持有代码写入权的调用方(或未来 Schedule payload仍可直接构造高权限 principal。当前所有生产调用点均经由适配层HTTP session 或 from_terminal实战安全 ✅;但"防自报 role 的机制"属于**约定约束而非结构约束**,标注 ⚠️。
#### (c) service 方法集与文档声称一致性 —— ✅ 属实
文档声称create_task / cancel_task / guidance / pending 队列 / get_task / get_task_events / list_runs / list_sessions / get_session_history / resolve_approval。
实测方法集service.py
| 文档声称 | 实际方法 | 证据行 |
|---|---|---|
| create_task | `create_task(ctx)` + `_ensure_conversation_for_chat` | L47-57, L60-96 |
| cancel_task | `cancel_task(username, task_id)` | L100-103 |
| guidance | `enqueue_runtime_guidance` | L106-109 |
| pending 队列 | `enqueue_runtime_pending_message` / `remove_runtime_pending_message` / `promote_runtime_pending_to_guidance` / `get_runtime_pending_messages` | L112-124, L312-316 |
| get_task | `get_task(username, task_id)` | L318-320 |
| get_task_events | `get_task_events(username, task_id, offset)`(返回 events/next_offset/error/meta含 window_start 缺口水位) | L323-342 |
| list_runs | `list_runs(username, workspace_id, conversation_id/status 筛选)`(复用 `task_public_payload` 单序列化实现) | L294-310 |
| list_sessions | `list_sessions(username, workspace_id, principal, ...)` | L158-174 |
| get_session_history | `get_session_history(username, workspace_id, conversation_id, principal)` | L177-192 |
| resolve_approval | `resolve_approval(kind, ...)`tool/plan/question 三路由)+ `list_pending_approvals` | L127-156, L117-125 |
方法集与文档完全对齐 ✅(文档未列的 `_resources_for_query`/`_ensure_conversation_for_chat` 是私有实现细节)。
#### (d) service 是否确实不持有任务状态 —— ✅ 属实
证据:
- `service.py` docstringL8-9「本服务只做受理裁决 + 显式上下文转发 + 控制委托不持有任务状态」L24「无状态全部状态委托给 task_manager 单例」。
- 全部状态操作均**延迟导入**并委托:`from server.tasks import task_manager`L52、L101、L107 等 8 处)、任务记录/事件流/门闸/保存保护由 TaskManager / main_task_gate / conversation_manager 承载。
- 方法体内无 `self._xxx` 状态字段;`RuntimeService` 类没有 `__init__` 之外的实例属性。
- 进程级单例 `runtime_service = RuntimeService()`L353可安全共享。
结论 ✅。
---
### 2. 执行链贯通验证
链:`server/tasks/models.py::create_chat_task`L136→ 线程 `_run_chat_task`L771→ 延迟 `from server.chat_flow import run_chat_task_sync`L958-959`run_chat_task_sync`chat_flow.py:280-282`process_message_task`chat_flow.py:133→ 门闸获取L141`handle_task_with_sender`(由 chat_flow_runner → chat_flow_task_runner re-export定义于 chat_flow_task_main.py:1475
#### (a) session_data dict 零残留 —— ✅ 属实
grep `session_data`server/ core/ modules/ utils/ config/ 全部生产代码,排除 __pycache__)命中仅 3 处,全部为**注释或 i18n key 文案,无 dict 状态**
- `server/tasks/models.py:54` 注释「不再有 session_data 兼容快照 dict」
- `server/tasks/models.py:148` `raise ValueError(tr("tasks.missing_session_data"))`(错误消息 key语义已改为「缺少运行上下文请通过公共任务入口提交」——见 modules/i18n_messages/api_tasks.py:105
- `server/runtime/service.py:11` 注释。
- `TaskRecord.__slots__`models.py:35-66`principal / task_params / directives / goal_progress` 四层结构化字段,`to_session_data()` 已不存在grep 零命中)。
结论:生产代码 dict 零残留 ✅i18n key 名称未改属命名瑕疵,不影响语义)。
#### (b) test_request_context 零残留 —— ✅ 属实
grep `test_request_context`server/ core/ modules/)→ **零命中**(返回码 1 表示无匹配)。
`models.py:771-780` 任务线程装配:从 `rec.principal` 映射 `RuntimeIdentity(...)` 后直接 `get_user_resources(..., update_session=False, identity=identity)`——全程无 Flask 请求上下文。`get_user_resources` 内部resources.py:140-143`explicit = identity is not None`、`can_write_session = (not explicit) and update_session and has_request_context()`——显式身份模式下与 session 完全解耦。
#### (c) RuntimeIdentity 驱动的资源解析链路 —— ✅ 属实
- `server/context/identity.py:19-31``RuntimeIdentity` dataclasshost_mode / host_workspace_id / is_api_user / role / preferred_* 偏好快照docstring 明确「传入 get_user_resources 后,资源装配完全不读写 Flask session为 None 时保持既有行为HTTP 适配层在请求上下文内读取 session」。
- `identity.py:38-44` `_resolve_user_role`:显式身份模式不触碰 session。
- `resources.py:118-123` `get_user_resources(username, workspace_id=..., update_session=True, conversation_id=None, identity=None)`——identity 参数化真实存在。
- 调用点:
- 任务线程 `_run_chat_task`models.py:782-795传 identity
- service 查询面 `_resources_for_query`service.py:276-288传 identity
- service 补建对话 `_ensure_conversation_for_chat`service.py:71-76传 identity。
结论 ✅。
---
### 3. 独立启动验证
#### (a) 验收真实性 —— ✅ 属实(真生命周期,非假验收)
读取 `test/runtime_standalone_checks.py` 全文 + `test/test_runtime_standalone_lifecycle.py`
- **没有将 `_run_chat_task` 替换为空 lambda**。薄壳 `test_runtime_standalone_lifecycle.py` 仅用 `subprocess.run([sys.executable, checks_script, mode])` 隔离执行L26-36验收逻辑全在 checks 脚本:
- `check_lifecycle``from flask import has_app_context` 断言无 app 上下文 → 真实 `runtime_service.create_task(_make_ctx(...))` → 轮询 `get_task_events`**`api_request_start` 出现(装配证据,历史/请求构造完成、模型调用前)** → 装配后主动取消 → 断言终态 → 断言对话级 terminal 存在(`resources.state.user_terminals.get(term_key) is not None`)→ 断言 `is_main_task_gate_busy(terminal)` 为 False门闸释放→ 结尾再断言无 app 上下文且 `ext.socketio.server is None`
- 审核 F4 的"假通过"修复点明确存在:`api_request_start 之前出现 error = 装配失败,验收必须明确失败`L108-113——杜绝"模型调用失败被误判为通过"。
- `check_chain`B 客户端不持 task_id`runtime_service.list_runs` 发现 A 的活动 Run → 观察事件流等 user_message 落盘 → 跨端取消 → 会话 JSON 持久化断言 → 事件流 idx 单调/无重复/offset 续读 → 第二 Run 复用会话 → list_sessions/get_session_history + principal 不一致抛 PermissionError跨用户 & 跨工作区两条)→ list_runs 归属/会话/状态筛选 → 审批公共入口(重复裁决返回现状、越权 PermissionError、未知类型 ValueError
- `check_fake_exec`:真实 `terminal.handle_tool_call` 驱动 FakeExecutionBackend断言替身收到 E1-E4 全部调用、输出结构被编排层消费、**零真实磁盘写入**`assert not (_SMOKE_WORKSPACE / "fake_probe.txt").exists()`)、默认路径新建 terminal 的 `execution_backend is None`
- `check_approval_wait`:真实工具编排层 `_execute_tool_calls_impl` + approval 权限模式,线程执行中产生 `tool_approval_required` 事件 → 主线程经 `resolve_approval` 批准 → 替身收到命令、工具循环退出。
**本机实测venv**:四次检查全部 PASSEDlifecycle 1.6s / fake_exec 0.7s / chain 2.7s / approval_wait 3.5s)——独立 Gateway 在**无 Flask app 上下文**环境下完成了 受理→装配api_request_start 证据)→事件→取消→终态→门闸释放 的全流程。模型调用因指向 127.0.0.1:9 连接拒绝失败属预期(外部依赖非验收对象),装配与生命周期真实发生。
#### (b) import 依赖链现状 —— ⚠️ 部分属实(存在实质缺陷)
实测追踪import 钩子打印调用栈 + sys.modules 观察):
**链路 A`import server.runtime.service`** → 不拉起 flask / flask_socketio / server.tasks ✓
**链路 B`from server.tasks import task_manager`service 方法体内延迟触发)**
```
>>> flask imported BY server.auth_helpers
>>> flask imported BY server.context.personalization
>>> flask imported BY server.context.upload
>>> flask imported BY server.context.conversation
>>> flask imported BY server.context.resources
>>> flask imported BY server.context.decorators
flask: True | flask_socketio: False | server.extensions: False | server.chat_flow: False
```
即:**server.tasks 的导入链仍会级联拉起 Flask 包本体**。具体断点:`server/tasks/models.py:16` 顶层 `from server.context import RuntimeIdentity, get_user_resources, ...``server/context/__init__.py` 顶层 re-export 会加载 identity/resources/personalization 等 → `server/context/identity.py:7` 顶层 `from server.auth_helpers import get_current_user_role``server/auth_helpers.py:5` 顶层 `from flask import session, redirect, jsonify`。`resources.py:15` 也直接 `from flask import session, has_request_context`
且**在未安装 flask 的裸环境(系统 python3`from server.tasks import task_manager` 直接 ModuleNotFoundError**——"server/tasks/__init__.py 注释声称『无 Web 依赖,可独立加载』"__init__.py L5-8与事实不符不依赖 **Web app 初始化/Blueprint/SocketIO** ✅,但依赖 **flask 包本体** ❌。
**链路 C`from server.chat_flow import run_chat_task_sync`_run_chat_task 线程内延迟导入)**
```
flask: True | flask_socketio: True | server.extensions: True | server.app: False | server.app_legacy: False
```
chat_flow.py:19 顶层 `from flask import ...` + L73 `from .extensions import socketio, run_background` → extensions.py:4 `from flask_socketio import SocketIO` + L7 实例化 `socketio = SocketIO(...)`。注意:**实例化 SocketIO 对象但不 init_app**`socketio.server is None`),且不触发 `server.app` / `server.app_legacy`(无 Flask app 创建、无蓝图注册)——与"无 Flask app 上下文"验收一致。
**综合结论 ⚠️**:任务线程拆除 test_request_context、RuntimeIdentity 驱动、验收测试无 Flask app 全部属实;但**「Gateway 可脱离 Flask 初始化」的声明过头了**——真正的表述应为:
- ✅ Runtime 层本体server.runtime零 Flask 依赖,可 import
- ⚠️ 一旦调用 create_task延迟导入 server.tasks**flask 包必然被拉起**(经 server.context.identity → auth_helpers无 flask 环境直接 import 失败;
- ⚠️ 任务实际执行_run_chat_task → 延迟导入 chat_flow会拉起 **flask + flask_socketio + SocketIO 实例**(但不 init_app、不创建 app、不注册蓝图
- 即:**进程可无 Flask app 上下文运行,但无法在未安装 flask/flask_socketio 的纯环境运行**——装修层Web 适配模块)侵入核心 import 链的实质未完全消除。
残留耦合点(建议未来拆解):
1. `server/context/identity.py:7` 顶层 `from server.auth_helpers import get_current_user_role`——identity 是核心身份模型,却依赖 Web 认证适配层即使显式身份路径不用它_resolve_user_role 的 None 分支使用);
2. `server/context/resources.py:15` 顶层 `from flask import session, has_request_context`——资源装配核心模块顶层依赖 flask
3. `server/context/__init__.py` 一次 re-export 全量子模块,导致 identity/resources/personalization/upload/conversation/decorators 连带加载。
---
### 4. Runtime ↔ 执行层边界modules/execution_plane/ + docs/execution_contract.md
#### (a) ExecutionBackend 协议 E1-E4 方法签名 —— ✅ 属实
`modules/execution_plane/base.py`
```
@runtime_checkable
class ExecutionBackend(Protocol):
async def run_command(command, *, timeout, sandbox_write_access, network_permission) -> Dict # E1
def run_command_background(command, *, timeout, conversation_id, wait_seconds, network_permission, sandbox_write_access) -> Dict # E2
def write_file(path, content, *, mode="w") -> Dict # E3
def edit_file(path, replacements: List[Dict]) -> Dict # E4
```
结果结构约定docstring
- run_command: `{success, status, output, return_code, truncated, elapsed_ms}`status ∈ completed/timeout/error/cancelled
- run_command_background: `{success, command_id, status}`
- write/edit: `{success, path, original_file, new_file}`
`FakeExecutionBackend`fake.py实现全部 4 方法(内存文件系统 files dict + calls 记录 + background_commands dict签名与协议逐一对应。
#### (b) MainTerminal.execution_backend 四注入分支真实存在且默认 None 走原路径 —— ✅ 属实
- 初始化:`core/main_terminal.py:149` `self.execution_backend = None`注释None = 现有 Host/Docker 真实链路;注入 ExecutionBackend 实现后 E1-E4 分支改走该后端)。
- 四分支tools_execution.py `handle_tool_call`L1057
- **write_file**L1635-1648`backend is not None` → `backend.write_file(path, content, mode)`跳过浅备份else → `_track_shallow_versioning` + `self.file_manager.write_file`
- **edit_file**L1669-1683backend → `backend.edit_file(path, replacements)`else → `_track_shallow_versioning` + `self.file_manager.replace_many_in_file`
- **run_command 后台**L1923-1932backend → `backend.run_command_background(command, timeout=..., conversation_id=..., wait_seconds=5.0, network_permission=..., sandbox_write_access=...)`else → `bg_manager.create_background_command(...)`
- **run_command 前台**L1958-1966backend → `await backend.run_command(command, timeout=..., sandbox_write_access=..., network_permission=...)`else → `await self.terminal_ops.run_command(...)`
- 四分支共用前置权限裁决在进入分支前完成L1891-1906`permission_mode = self.get_permission_mode()` → `sandbox_write_access = not (readonly or (approval/auto_approval and not write_granted_once))``network_permission` 解析);后端分支**不重复裁决**(协议"只承诺执行语义,不构成安全边界")。
- 默认路径回归验证check_fake_exec 断言新建对话级 terminal 的 `execution_backend is None` → 真实链路。
#### (c) 权限裁决/沙箱计划在哪一层 —— ✅ 属实Runtime 编排层裁决,执行层不重复)
- `tools_execution.py:391` `evaluate_tool_permission(tool_name, arguments)`permission mode 语义readonly/approval/auto_approval/unrestricted完整在此层L394-470 多档分支)。
- 注入分支处显式传递**裁决结论**`sandbox_write_access` / `network_permission` 已解析ExecutionBackend 收到的即结论不再二次裁决base.py docstring L8-10、execution_contract.md §4
- 先读后写拦截(`_check_read_before_edit_prerequisite`与浅版本备份也在编排层write/edit 分支前置)。
- **契约注记(执行层安全边界分工)**命令校验_validate_command/FORBIDDEN_COMMANDS与路径授权_validate_path/禁读清单)仍由旧 terminal_ops/file_manager 链路承担execution_contract.md §4 + base.py docstring——审核 §4 修正后的表述OS 层强制力Seatbelt/bwrap/DAC+Landlock是最终边界。
#### (d) Host/Docker 迁入还差什么 —— ✅ 属实E5-E10 后置为独立工作)
依据 `docs/execution_contract.md` §3 表:
- 已入协议E1 run_commandterminal_ops/run.py、E2 run_command_backgroundbackground_command_manager.py、E3 write_filefile_manager/crud_mixin.py、E4 edit_filefile_manager/replace_mixin.py
- 后续纳入E5 read_file 三模式、E6 create/delete/rename/mkdir 族、E7 持久终端会话、E8 path_validate、E9 执行环境快照、E10 命令校验/超时钳制。
- 剩余工作(文档 §6 + 记忆 gateway_runtime_work_plan 中用户拍板):
1. HostDockerBackend 适配器(真实后端实现 E1-E10 + 保留现有校验分配);
2. 编排层全面切换(现仅 E1-E4 四分支注入,其余工具仍直挂 terminal_ops/file_manager
3. 结构债:执行命令后端选择逻辑在 terminal_ops/run.py、background_command_manager.py、persistent_terminal/start.py、container_file_proxy.py **4 处各有一份复制**(契约 §5E5-E10 接口面完整化后是天然收敛点。
- 生产路径不变:默认 `execution_backend=None`,无替换行为。
---
### 5. 智能体循环层独立性core/ 对 Flask 依赖残留)
#### —— ✅ 属实Agent Runtime 层零 Flask 反向依赖)
- `grep -rnE "^from flask|^import flask|^from flask_socketio|socketio" core/`**零命中**(两次独立 grep 均为空,返回码 1
- `core/main_terminal.py` 顶层 importL3-72全为 modules/*、utils/*、config/model_profiles、core.main_terminal_parts、modules.i18n——**无 flask/socketio**。
- `core/main_terminal_parts/tools_execution.py`:顶层无 flask/socketiogrep 零命中);`core/main_terminal_parts/` 其余文件同样零命中。
- 唯一注意点:`tools_execution.py:2195/2498/2514` 有 `_is_web = '/web/users/' in _data_dir` 的**路径字符串判定**Web/CLI 数据目录布局差异),属身份/路径耦合(记忆里已归入遗留),非 Flask API 依赖。
结论Agent Runtime③ 层)不反向依赖 Web 层(②)✅。
---
## 二、Gateway → Runtime → Execution 实际分层图(按代码事实)
```
┌──────────────────────────────────────────────────────────────────────┐
│ ① ClientWeb 前端 / CLI / 未来定时触发器) │
└───────────────┬──────────────────────────────────────────────────────┘
│ HTTP / 轮询事件流idx/offset 协议)
┌───────────────▼──────────────────────────────────────────────────────┐
│ ② Gatewayserver/runtime/
│ context.py: RuntimeContext = TrustedPrincipal + TaskParams │
│ + InternalDirectives门闸 token 走 internal不接受 │
│ 客户端提交principal 由适配层构造【约定级约束】) │
│ service.py: RuntimeService 无状态薄层(受理/控制/查询/审批/会话, │
│ 全部延迟委托 task_manager
│ ─── 边界清晰 ⚠️:本体零 Flask但延迟导入 server.tasks 链 │
│ 仍级联拉起 flask 包context.identity → auth_helpers
└───────────────┬──────────────────────────────────────────────────────┘
│ create_task → create_chat_task → 线程 _run_chat_task
│ RuntimeIdentity 驱动,全程无隐式上下文 / 无 │
│ test_request_context / 无 session_data │
┌───────────────▼──────────────────────────────────────────────────────┐
│ ③ Agent Runtimeserver/chat_flow*.py + core/main_terminal.py
│ 执行链run_chat_task_sync → process_message_task门闸获取/ │
│ finally 释放)→ handle_task_with_sender模型循环
│ → core/main_terminal_parts/tools_execution.py::handle_tool_call │
│ ─── core/ 零 Flask 依赖 ✅ 边界清晰 │
│ ⚠️ 残留chat_flow.py/chat_flow_task_main.py 顶层仍在 │
│ (延迟导入 chat_flow 时拉起 flask+extensions
└───────────────┬──────────────────────────────────────────────────────┘
│ E1-E4 注入分支execution_backendNone → 原路径;
│ 注入 → 替身/未来远端后端);权限裁决【编排层】完成,
│ 传裁决结论sandbox_write_access/network_permission
┌───────────────▼──────────────────────────────────────────────────────┐
│ ④ Execution Planemodules/execution_plane/
│ ExecutionBackend 协议 E1-E4Protocol, runtime_checkable
│ FakeExecutionBackend 替身(内存,验收通过) │
│ Host/Docker 真实后端terminal_ops/file_manager 直挂) │
│ ─── 边界清晰但不完整:仅 E1-E4 协议化E5-E10 + 4 处后端选择逻辑 │
│ 复制收敛 = 后置独立工作 │
└──────────────────────────────────────────────────────────────────────┘
边界评级:
[①→②] 清晰(公共协议 + 事件流 + 三层上下文;但 principal 自报 role 为约定级防线)
[②→③] ⚠️ 半清晰(任务线程纯 RuntimeIdentity但 flask 包经 server.context
identity/resources 顶层 import 侵入核心链,无 flask 环境无法运行)
[③→④] 半清晰core/ 零 Flask ✅E1-E4 注入 ✅;但权限校验分两处——
编排层裁决 + 旧链路命令校验/路径授权Host/Docker 未迁入协议)
```
---
## 三、与文档声称不符的发现清单
| # | 严重度 | 文档声称 | 代码事实 | 证据 |
|---|---|---|---|---|
| F1 | 中 | tasks/__init__.py 注释「本包只导出任务核心层…保证 server.runtime 在无 Web 应用初始化的进程中可独立加载使用」 | 可脱离 **Web app 初始化** 加载 ✅,但不可脱离 **flask 包** 加载:`from server.tasks import task_manager` 级联拉起 flask经 context.identity→auth_helpers裸环境直接 ModuleNotFoundError | identity.py:7 `from server.auth_helpers import get_current_user_role`resources.py:15 `from flask import session, has_request_context`auth_helpers.py:5 `from flask import session, redirect, jsonify`;裸 python3 import 实验 FAIL |
| F2 | 中 | 任务线程「全程 RuntimeIdentity 驱动」隐含"模块级已解耦 Web" | 线程内确实 RuntimeIdentity 驱动(无 test_request_context但**执行阶段** `_run_chat_task` 延迟导入 `server.chat_flow` → 拉起 flask_socketio + `SocketIO()` 实例(即时不 init_app、`has_app_context()` 仍 Falsesocketio.server=None 静默跳过) | models.py:958-959chat_flow.py:19,73extensions.py:4-7venv import 实验3 |
| F3 | 低 | TrustedPrincipal「只能由适配层构造」 | **约定 + 校验侧纵深防御,非结构强制**dataclass 无构造限制、validate() 不校验 role 白名单;任何代码可直接构造 admin principal | context.py:20-41 validate L30-34service.py `_resources_for_query` L269-283 仅约束查询侧 |
| F4 | 低 | 「拆除了 session_data 兼容桥」的表述 | 属实 ✅;仅 i18n key `tasks.missing_session_data` 命名保留旧词(错误文案已更新为「缺少任务运行上下文,请通过公共任务入口提交」),语义无残留 | models.py:148i18n api_tasks.py:105 |
| F5 | 低 | execution_contract.md §4「权限裁决在 Runtime 编排层」 | 属实但要精确:**编排层做①permission mode 裁决②审批一次性授权③网络权限解析**,但**命令校验FORBIDDEN_COMMANDS与路径授权禁读清单仍在旧 terminal_ops/file_manager 链路**,经本接口注入的后端分支不会自动获得这些校验(契约已加注记,与代码一致) | base.py docstring L9-11execution_contract.md §4 注记tools_execution.py:1891-1906 |
未标注项1(a)(c)(d)、2(a)(b)(c)、3(a)、4(a)(b)(c)(d)、5 全部与文档一致 ✅。
**未验证项**:无(全部声明均已通过代码定位 + import 实验 + 验收测试运行验证唯一无法在本环境复现的是「Web 生产环境真实运行中 socketio 推送」——需要用户重启服务后人工验证,属记忆 gateway_runtime_work_plan 中的遗留待办 N3
---
## 四、总体判断
**目标「③④ 层达到 Runtime 与执行环境可通过替身独立测试、换执行后端不碰智能体循环」——达到了 70% 达成度(③↔④ 边界达标;②→③ 尚有一处实质耦合)。**
逐项对照:
1. **「Runtime 与执行环境可通过替身独立测试」✅ 完全达成**`check_fake_exec`0.7s PASSED`check_approval_wait`3.5s PASSED证明真实工具编排层 handle_tool_call + FakeExecutionBackend 可以零真实副作用跑通 E1-E4且默认路径backend=None行为回归断言存在。
2. **「换执行后端不碰智能体循环」✅ 达成(限 E1-E4**:注入点是 `MainTerminal.execution_backend` 属性 + handle_tool_call 四分支,智能体循环(模型循环/工具分派/事件)不感知后端具体实现;替换 Host/Docker 后端只需实现协议并注入。❌ **但完整达成需 E5-E10 全部协议化**——当前 read_file、mkdir、终端会话、路径校验等仍直挂 terminal_ops/file_managerHost/Docker 真实后端「换后端不碰循环」仅对 E1-E4 成立。
3. **③④ 层的方向独立性已建立,但存在一个实质耦合点**:核心链 `server.tasks``server.context.identity``server.auth_helpers``flask` 使 **Gateway 进程仍必须安装 flask 包**(虽然不需要 Flask app / SocketIO 服务 / 蓝图)。独立启动验收之所以能过,是因为验收环境装了 flask+flask_socketio —— 若在未安装 Flask 的精简部署环境运行,`create_task` 将直接 ModuleNotFoundError。
4. **对「Gateway 独立启动(无 Web 初始化)」声明**:验收测试已证明无 app 上下文、无 SocketIO 服务、无蓝图注册、门闸/终态/事件流/审批全生命周期真实通过 ✅;但「零 Flask 依赖」不成立 ⚠️——准确表述应为「无 Flask **应用初始化** 依赖,仍有 Flask **包** 依赖」。
**建议(按优先级)**
1. 拆 `server/context/identity.py:7` 顶层 `from server.auth_helpers import get_current_user_role`(改为延迟导入或在 context 包内实现角色默认逻辑),`resources.py:15` 的 flask session 依赖同理下沉到适配层——可一次性切断 ②→③ 链的 flask 包依赖;
2. server/context/__init__.py 目前一次 re-export 全量子模块(连带加载),可考虑按需分包或延迟绑定;
3. Host/Docker 迁入E5-E10 + HostDockerBackend + 编排层切换 + 4 处后端逻辑收敛)按既定后置计划执行,与本轮结论无冲突。
---
### 附录import 实验记录(本机 venv=python3.9.6 / 裸 python3=3.9.6
| 实验 | 命令 | 结果 |
|---|---|---|
| 1 | `import server.runtime.service`(裸 python3 | OKflask=False、flask_socketio=False、server.tasks=False |
| 2 | `from server.tasks import task_manager`(裸 python3 | **FAIL: ModuleNotFoundError 'flask'**追踪server.tasks→models→server.context→identity→auth_helpers→flask |
| 3 | `from server.tasks import task_manager`venv | OK**flask=True**、flask_socketio=False、extensions=False、chat_flow=False |
| 4 | `from server.chat_flow import run_chat_task_sync`venv | OK**flask=True、flask_socketio=True、server.extensions=True**、server.app=False、app_legacy=False |
### 附录:验收测试实测记录
| 检查 | 用时 | 结果 | 关键证据 |
|---|---|---|---|
| check_lifecycle | 1.6s | PASSED | 无 app 上下文 → create_task → api_request_start 装配证据 → 取消 → 终态 → terminal 存在 → 门闸释放 → socketio.server is None |
| check_fake_exec | 0.7s | PASSED | E1-E4 四分支替身全部收到零真实磁盘写入backend=None 回归 |
| check_chain | 2.7s | PASSED | list_runs 发现 → 事件观察 → 跨端取消 → 历史落盘 → offset 续读 → 会话查询 → 审批语义 |
| check_approval_wait | 3.5s | PASSED | 执行中审批等待 → 公共入口批准 → 替身收到命令 → 循环退出 |

View File

@ -1,231 +0,0 @@
# Astrion 状态责任表 S1-S10 落地核查报告(只读审计)
- 审计日期2026-09-07
- 审计范围:`docs/runtime_contract.md` §2 状态责任表 S1-S10 + §3 正确性保障 + §6 已知缺口
- 方法:全程只读,逐项对照代码,给出「文件:行号」证据;凡无法静态定论处显式标注推断等级。
- 推断等级:✅ 已确认 / ⚠️ 存在偏差或疑点 / ❌ 不一致 / ❓ 未找到或无法静态验证
---
## 一、S1-S10 逐项核查表
### S1 对话历史与元数据 —— ✅ 一致
**声明**:权威=磁盘 conversation JSON写入口=执行链统一 `ContextManager.add_conversation``utils/context_manager/message_mixin.py:127`);保存保护=`save_conversation``crud_mixin.py:298`merge-on-save 按 message_id 合并防缩减 + `_io_lock` RLock + `_atomic_write_json` 原子替换);失效条件=删除对话(检查点恢复是唯一 allow_shrink 豁免)。
**代码证据**
- `utils/context_manager/message_mixin.py:127``def add_conversation(` 行号精确匹配契约。
- `utils/conversation_manager/crud_mixin.py:298``def save_conversation(` 行号精确匹配。
- merge-on-save 与缩减拒绝:`utils/conversation_manager/crud_mixin.py:252-296``_merge_messages_by_id`,方向 C同 id 取内存版、磁盘独有保留、内存独有追加、无 message_id 防御性跳过);`:335-358`(调用 merge`new_len < old_len and not allow_shrink` 时拒绝保存返回 False
- `_io_lock` RLock`utils/conversation_manager/base.py:47``self._io_lock = threading.RLock()`)。
- 原子替换:`utils/conversation_manager/index_mixin.py:104``_atomic_write_json`:唯一临时文件 + json.dump + fsync + `replace_with_retry`);保存路径 `crud_mixin.py:193-201``_save_conversation_file` 内 `with self._io_lock:` + `_atomic_write_json`)。
- 检查点恢复唯一豁免:`crud_mixin.py:341-358``allow_shrink` 参数仅豁免路径为 True
**结论**:契约声明与实现一致。外部写者(设置/压缩/CLI复用同一保存链路的假设属契约 §2 标注的「已知现状」,本次未逐一枚举其是否全部走同一 `_io_lock`/原子写(见 Q(a)/总结)。
---
### S2 任务记录TaskRecord—— ✅ 一致
**声明**:权威=内存 `task_manager._tasks`;仅 `TaskManager` 可改;无持久化;`_lock` + 单对话 chat 互斥(:160-172notice 豁免);`cleanup_old_tasks`(终态超 3600s
**代码证据**
- `self._tasks: Dict[str, TaskRecord] = {}` + `self._lock = threading.Lock()``server/tasks/models.py:98-100`。
- 单对话 chat 互斥 + notice 豁免:`server/tasks/models.py:158-173`(仅当 `normalized_task_type == "chat"` 时对同对话 `status in {pending,running}` 的 chat 任务排重并 raise `task_already_running`notice 类型不参与排重)。
- 409 映射:`server/tasks/api.py:223-224``except RuntimeError ... return ... 409`)。
- `cleanup_old_tasks(3600)``server/tasks/models.py:105-130`(终态集合 `succeeded/failed/stopped/canceled/cancel_requested` 超 max_age 删除);调度器 `start_task_cleanup_scheduler` `server/tasks/models.py:1100-1111`(每 600s 调 `cleanup_old_tasks(3600)`)。
- 无持久化:`TaskManager` 仅内存 dict无落盘代码。
**结论**:一致。纯内存态(见 Q(a))。
---
### S3 任务事件流 —— ✅ 一致
**声明**`TaskRecord.events` 有界 deque maxlen=20000执行链经 `_append_event`:762`get_events_since` 按 offsetidx 单调分配;`_lock` 内分配。
**代码证据**
- `self.events: deque[...] = deque(maxlen=20000)``server/tasks/models.py:74`。
- `_append_event``server/tasks/models.py:757-782``_lock` 内 `idx = rec.next_event_idx``rec.next_event_idx = idx+1``rec.events.append({...idx...})`;如 `next_event_idx` 缺失则回退 `rec.events[-1]['idx']+1`)。
- `get_events_since(rec, offset)``server/tasks/models.py:200-206`(锁内快照 `list(rec.events)` 后过滤 `e['idx'] >= offset`)。
- socket 房间推送 `user_{username}``server/tasks/models.py:824-826`sender 内 `socketio.emit(event_type, data, room=f"user_{username}")`)。
**结论**一致。idx 单调、有界、offset 续读全部命中。纯内存态(见 Q(a))。
---
### S4 对话级主任务门闸 —— ⚠️ 基本一致,存在一处静态疑点
**声明**:权威=terminal 属性 `_main_task_gate_token``process_message_task` 唯一入口获取/finally 释放;通知链预占 + token 移交认领token 匹配才能释放;任务结束/异常兜底释放。
**代码证据**
- 门闸组件(零 Flask 依赖):`server/main_task_gate.py`(整文件 73 行;`try_acquire_main_task_gate` / `acquire_adopted_main_task_gate` / `release_main_task_gate`token 匹配才释放)/ `is_main_task_gate_busy`;存储终端属性 `_GATE_ATTR = "_main_task_gate_token"`)。
- 唯一入口获取/finally 释放:`server/chat_flow.py:142``gate_token = acquire_adopted_main_task_gate(terminal, main_task_gate_token)`None 则拒绝并发并 return`:262``finally: release_main_task_gate(terminal, gate_token)`)。
- REST 路径:`server/tasks/models.py:895-900``_run_chat_task` 调 `run_chat_task_sync(...main_task_gate_token=...)``chat_flow.py:280-282``process_message_task`)。
- 通知链预占 + token 移交 + 失败回滚:`server/chat_flow_task_main.py:1014``gate_token = try_acquire_main_task_gate(web_terminal)``:1069``main_task_gate_token=gate_token` 随 session_data 移交);`:1029`(无通知释放);`:1074-1075`(派发异常时 `release_main_task_gate` + `_rollback_completion_notice_marks``_dispatch_completion_user_notice` 经 `RuntimeContext.from_terminal` + `InternalDirectives(main_task_gate_token=...)` 构造(`:590-614`)。
- `_run_chat_task` finally 兜底释放:`server/tasks/models.py:1088-1094`(按 session_data 的 `main_task_gate_token` 释放)。
- 异常回退路径create 失败→直接执行):`server/chat_flow_task_main.py:644-690``except Exception` → `report_*``asyncio.create_task(handle_task_with_sender(...))`,绕过 TaskRecord
**⚠️ 静态疑点(级别:有很大概率,未运行时验证)**
- `handle_task_with_sender``server/chat_flow_task_main.py:1468+`)正文**不含任何**门闸获取/释放调用grep 全文件仅 :1014/:1029/:1074 及 :71 导入命中)。
- 在「完成通知 create_task 失败 → 直接回退执行」路径上,门闸由轮询器在 :1014 预占持有;回退的 `handle_task_with_sender` 自身不释放;而轮询器在 `_dispatch_completion_user_notice` 正常返回后走到 :1080-1082 的 `return`(注释断言「门闸随任务移交(由任务线程 finally 释放)」),但该路径**没有任务线程**(未登记 TaskRecord
- 由此静态推断:回退路径执行完后门闸可能一直保持占用(`is_main_task_gate_busy` 恒 True导致下一次通知轮询在 :1014 恒返回 None。这只是代码阅读推断需运行时验证若成立则与契约 §3 第 1/6 条强调的「回退路径必须保持门闸获取/释放语义」存在偏差。
**结论**:✅ 核心语义唯一入口、finally 释放、token 匹配、通知链预占移交、失败回滚、任务线程 finally 兜底)全部一致;⚠️ 回退路径的门闸是否最终释放存在静态疑点。
---
### S5 停止标志stop_flags—— ✅ 一致(存放位置为模块级 dict非对象属性
**声明**`state.stop_flags` dict任务级键 = task_idREST 即 client_sid`cancel_task`REST 硬取消、socket `stop_task`(软标志);任务级键隔离;收尾 pop。
**代码证据**
- 存放位置:`server/state.py:38``stop_flags: Dict[str, Dict[str, Any]] = {}`,模块级全局;引用方 `from server.state import stop_flags`,如 `models.py:22`)。—— 契约写作 `state.stop_flags`,实际是 `server.state` 模块级 dict非某个 `state` 对象的属性。语义一致。
- 任务级键 = client_sid`server/state.py:174-177``make_stop_keys``client_sid` 键 + `user:{username}` 索引键);`:193-205``clear_stop_flag` 只 pop 本任务 key`user:` 索引仅在仍指向本 entry 时才清,防误清其它并行任务)。
- REST 硬取消:`server/state.py` 键 + `server/tasks/models.py:250-264``loop.call_soon_threadsafe(task.cancel)` 投递硬取消 + `entry['stop']=True` + `rec.stop_requested=True`);端点 `server/tasks/api.py:291-309`
- socket `stop_task` 软标志:`server/socket_handlers.py:128-150`(仅 `task_info['stop'] = True`,注释明确「改为通过停止标志让任务内部处理」,直接取消已注释掉)。
- 执行链检查点轮询:`server/chat_flow_stream_loop.py:61`、`server/chat_flow_task_support.py:576`、`server/chat_flow_tool_loop.py:623``client_stop_info.get('stop')`)。
- 收尾 pop`server/tasks/models.py:1083``finally: stop_flags.pop(rec.task_id, None)`)。
**结论**一致。任务级键隔离、REST 硬取消 vs socket 软标志均成立。
---
### S6 审批/提问条目 —— ✅ 一致(含契约已标注的缺口/疑点,均被代码证实)
**声明**:三个内存 managertool/plan/user_question键=approval_id/question_idlist 按 username+conversation_id执行链 createREST 置终态(锁内单次裁决);无持久化、无 TTL轮询 0.2~0.3s、默认超时 3600s超时/取消不回写终态(已知缺口);审批条目 task_id 疑恒 None静态疑点
**代码证据**
- 三个 manager 文件:`modules/tool_approval_manager.py`92 行)、`modules/plan_approval_manager.py`100 行)、`modules/user_question_manager.py`。均 `self._items: Dict = {}` + `threading.Lock()`,纯内存。
- 锁内单次裁决(已决定返回现状):`tool_approval_manager.py:63-91``decide` 内 `if item.get("status") != "pending": return dict(item)``plan_approval_manager.py:75-90``answer``user_question_manager.py:143-164``answer`)。
- list 按 username(+conversation_id) 过滤:`tool_approval_manager.py:51-63`、`plan_approval_manager.py:50-63`、`user_question_manager.py:126-141`。
- 无 TTL三个 manager 均无过期/清理逻辑;`_items` 只在 create/decide 改,无 TTL 字段。
- 轮询 0.2~0.3s 默认超时 3600`server/chat_flow_tool_loop.py:248-263``_wait_for_tool_approval``asyncio.sleep(0.2)`)、`:281-307``_wait_for_user_questions``sleep(0.2)`)、`:310-324``_wait_for_plan_approval``sleep(0.3)`);超时经 `_approval_timeout_for(web_terminal) or 3600.0``:232-246`、`:454/:597/:907/:1178`)。
- 超时/取消不回写终态(已知缺口被证实):三个 `_wait_*` 在超时时**只本地返回** timeout 状态(`:258-260`、`:303-305`、`:322-324`**不调用 manager 把条目置为终态**,条目保持 `pending` 常驻内存直至重启。
- task_id 疑恒 None静态疑点被强烈支持
- create 调用点均传 `task_id=getattr(web_terminal, "task_id", None)``server/chat_flow_tool_loop.py:427`plan、`:565`user_question、`:866`、`:1137`tool
- `WebTerminal.__init__``core/web_terminal.py:81-135`**未定义/未设置任何 `self.task_id`**;全代码库未发现 `terminal.task_id = ...` 赋值(仅 `modules/multi_agent/state.py:254` 是 AgentInstance 的 task_id无关
- `handle_task_with_sender` 内事件 task_id 也回退到 `getattr(web_terminal,"task_id",None) or client_sid``server/chat_flow_task_main.py:1524`),反证 terminal 上无有效 task_id。
- → 结论(有很大概率,静态):审批/提问条目的 task_id 字段恒 None。需运行时打点最终确认。
**结论**:一致,且契约 §6 标注的缺口(不回写、无 TTL、task_id 恒 None在代码层面均被证实socket 软 stop 不打断审批等待见 Q(c)。
---
### S7 对话级 terminal 实例 —— ✅ 一致
**声明**`state.user_terminals` key=`username::workspace_id::conversation_id``get_user_resources` 创建/重建;回收器 24h 无活动关闭;回收 pop 前校验实例身份。
**代码证据**
- key 结构:`server/context/resources.py:52-54``_make_terminal_key``base = f"{username}::{workspace_id}"`,有 conversation_id 则 `f"{base}::{conversation_id}"`)。
- 存储:`server/context/resources.py:304`、`:458``state.user_terminals[term_key] = terminal`)。
- 创建/重建逻辑:`get_user_resources` `server/context/resources.py:118``conversation_id 非空时返回对话级 terminal``:218`、`:411``_make_terminal_key` 构造并创建/复用);重建(`_reaper_closing` 标记)见 reaper。
- 回收器 24h`server/context/reaper.py:40``CONVERSATION_TERMINAL_TTL_SECONDS = 24*3600``:92-130``reap_idle_conversation_terminals`:只回收三段 key、超 TTL 且 `_conversation_terminal_has_running_work` 为空,先打 `_reaper_closing` 标记,二次确认,最后 `if state.user_terminals.get(term_key) is terminal` 才 pop
**结论**:一致。
---
### S8 权限模式 / 执行环境 / 网络权限 —— ✅ 一致
**声明**:权威=对话 metadata + terminal 当前值;`server/chat/permission.py` 端点空闲立即生效、运行中修改入队由工具循环消费metadata 持久化;排队保证单写者。
**代码证据**
- 端点:`server/chat/permission.py:138-233``/api/permission-mode` GET/POST、`:236-321``/api/execution-mode`)、`:323-395``/api/network-permission`)。
- 空闲立即生效 / 运行中入队:`:172-185`(运行中 `terminal.queue_permission_mode_change(target_mode)` + `_sync_workspace_terminal_mode`)、`:207-215`(空闲 `set_permission_mode` 立即生效execution 同结构 `:271-295`/`:303`network `:357-369`
- 队列消费端:`core/main_terminal.py:325-376``queue_permission_mode_change`/`queue_execution_mode_change`/`queue_network_permission_change` 写入 pending`apply_pending_runtime_mode_changes` 统一应用);被工具循环在检查点消费:`server/chat_flow_tool_loop.py:1544-1549`。
- metadata 持久化:`core/main_terminal_parts/tools_policy.py:369-414``set_permission_mode` 内 `update_conversation_metadata(conv_id, {"permission_mode": normalized})`);恢复:`core/web_terminal.py:509`(从 meta 恢复 pending_permission_mode
**结论**:一致。
---
### S9 多智能体实例状态 —— ✅ 一致
**声明**`GLOBAL_MULTI_AGENT_STATES` 进程级单例 + 磁盘快照;`SubAgentManager`/dispatch 链路;`_load_state` 恢复校准;`GLOBAL_MULTI_AGENT_STATES_LOCK`;失效=terminate/对话删除。
**代码证据**
- 进程级单例 + 锁:`modules/multi_agent/state.py:668``GLOBAL_MULTI_AGENT_STATES: Dict[str, "MultiAgentState"] = {}`)、`:671``GLOBAL_MULTI_AGENT_STATES_LOCK = threading.RLock()``SubAgentManager.multi_agent_states = GLOBAL_MULTI_AGENT_STATES``modules/sub_agent/manager.py:76`)。
- 磁盘快照:`modules/sub_agent/state.py:168-196``_save_state`/`_save_state_unsafe`payload 含 `tasks`/`conversation_agents` + `multi_agent_states` 快照,写 `self.state_file`= `data_dir/sub_agents.json``manager.py:67`)。多智能体专用 data_dir → `mutiagents/``server/multi_agent.py:77`。
- `_load_state` 恢复校准:`modules/sub_agent/state.py:32-168`:恢复 state_file → 用 `GLOBAL_MULTI_AGENT_STATES_LOCK` 加锁 `from_snapshot` 还原 `multi_agent_states`:75-101跳过已在内存的 conv**终态校准**`:104-133``load_state_calibrate_agent_status`,按任务记录 `TERMINAL_STATUSES {terminated}` 纠正实例 status防已终结实例以 idle 复活);`_None` 显示名自愈(:138-167
- terminate/失效路径:`MultiAgentState.shutdown``modules/multi_agent/state.py:632-658`,清 output_waits/agents/queues
**结论**:一致。
---
### S10 用户偏好(模型/模式/个性化)—— ⚠️ 基本一致,原子写声明与代码不完全吻合
**声明**settings.json / personalization.json运行态路径设置类端点执行链只读快照**配置文件原子写**会话快照session_data受理时固化。
**代码证据**
- 运行态路径:`modules/personalization_manager.py:36``PERSONALIZATION_FILENAME = "personalization.json"`);实际落盘 `.runtime/host/users/<user>/projects/<ws>/data/personalization.json`(运行态目录)。**未发现**运行态 `settings.json`host 沙箱策略的 settings.json 在 `modules/host_sandbox_policy.py:70-121`,是另一文件,非用户偏好)。
- 设置端点:`server/chat/settings.py:73-129`thinking/run mode、`:194-232`model、`:327-365``/api/personalization` POST → `save_personalization_config`)。
- 执行链只读快照 + 受理时固化:`server/tasks/models.py:180-190``create_chat_task` 要求显式 `session_data`,缺失即 ValueError `tasks.missing_session_data``:881-914``_run_chat_task` 从 `rec.session_data` 还原 `RuntimeIdentity`,不读 Flask session`server/context/personalization.py:23-106``_apply_workspace_personalization_preferences` 用 `session_model` 快照优先)。
- ⚠️ **"原子写"偏差**`save_personalization_config` 使用**直接写** `with open(path,"w") as f: json.dump(...)``modules/personalization_manager.py:851-856`)——**非** temp-file+replace 原子写(对比 S1 的 `_atomic_write_json`。settings.py:338/352 调用同一函数。因此「配置文件原子写」对 personalization.json 这条链**不成立**(直接覆写,存在写中断损坏风险),与对话 JSON 的原子写形成不对称。个人认为这是偏差,非致命——写入频率低、单进程单写者。
**结论**:⚠️ 部分一致——路径、只读快照、会话固化均正确;「原子写」声明对 personalization.json 不符合(直接覆写,非原子替换)。
---
## 二、三个整体问题
### (a) S2/S3/S6 是否确为纯内存态(进程重启即失)?有无持久化?
**结论:是,确为纯内存态,均无任何持久化。** 证据:
- **S2 TaskRecord**`TaskManager.__init__` 只建内存 dict `self._tasks``server/tasks/models.py:98-100`),无落盘代码;`cleanup_old_tasks` 只是删除终态(:105-130不是持久化。重启即失。
- **S3 TaskRecord.events**`deque(maxlen=20000)``models.py:74`),只存在于 TaskRecord 内存对象,随 S2 一起消失。
- **S6 三个 manager**`self._items` 均为内存 dict`tool_approval_manager.py:14`、`plan_approval_manager.py:14`、`user_question_manager.py:14` 附近),无磁盘写。重启即失。
- 契约 §2「S2/S3/S6 为内存态不能承诺重启续跑或无限期回放」与实现一致§2 结尾「清理有 3600s 窗口」= `cleanup_old_tasks(3600)` 每 600s 跑一次(`models.py:1100-1111`)成立。
- 补充同一进程内S2/S3 在 3600s 窗口内不立即丢cleanup 定时清S6 条目则**永不自动清理**(无 TTL直到重启——两者内存生命周期行为不同值得注意。
### (b) 契约 §3 列出的 6 项「已有正确性保障」在代码中是否都能找到对应实现?
**6 项全部可找到对应实现:**
1. **单写者不变量**(门闸):`server/main_task_gate.py` 整文件 + `chat_flow.py:142/262` + 通知链 `chat_flow_task_main.py:1014/1069/1074-1075` + `models.py:1088-1094` 兜底。✅
2. **受理互斥**create_chat_task chat 互斥 409 + notice 豁免):`models.py:155-173` + `api.py:223-224`409。✅ 受理去重(S2)与执行互斥(S4)确实分属两层。
3. **保存保护**merge-on-save + I/O 锁 + 原子替换 + 缩减拒绝):`crud_mixin.py:252-296/335-358/193-201`、`base.py:47`、`index_mixin.py:104`。✅
4. **客户端恢复**idx+offset 轮询 + running-status 对账 + task_id/idx 去重 + 过期响应过滤):`models.py:757-782/200-206`idx+offset`static/src/app/methods/taskPolling/probe.ts`(对账);`static/src/stores/task.ts:197``task-poll-stale-response-ignored` 过期过滤)、`:267``stale-event-loop-abort`)、`:527`(去重集合)、`:313`(按 idx 去重)。✅
5. **审批单次裁决**(锁内裁决 pending已决定返回现状`tool_approval_manager.py:63-91/83-86`、`plan_approval_manager.py:79-82`、`user_question_manager.py:151-154`。✅
6. **异常回退路径**create 失败→直接 `handle_task_with_sender`,门闸保护下运行、绕过 TaskRecord`chat_flow_task_main.py:644-690`。⚠️ 但存在与 S4 一致的静态疑点(回退路径门闸释放问题,见 S4
### (c) 契约 §6 已知缺口的实际代码状态
| 契约 §6 缺口 | 代码实际状态 | 证据 |
|---|---|---|
| 超时/取消不回写 pending 终态 | **存在**。`_wait_for_tool_approval/_wait_for_user_questions/_wait_for_plan_approval` 超时仅本地返回,不把 manager 条目置终态。 | `chat_flow_tool_loop.py:248-263/281-307/310-324` |
| 条目无 TTL | **存在**。三个 manager 的 `_items` 无过期/清理逻辑。 | 三个 manager 全文 |
| 审批条目 task_id 疑恒 None | **静态强烈支持**create 调用点传 `getattr(web_terminal,"task_id",None)`WebTerminal 从不设置 task_id。需运行时打点确认。 | `chat_flow_tool_loop.py:427/565/866/1137``core/web_terminal.py:81-135` |
| socket 软 stop 不打断审批等待 | **大概率成立**socket `stop_task` 只置软标志不取消 asyncio task`socket_handlers.py:128-150`);审批等待在工具循环内 `asyncio.sleep` 轮询,软标志不会让它立刻退出(等待循环只检查 manager 状态与 timeout不检查 stop。REST 硬取消通过 `call_soon_threadsafe(task.cancel)``models.py:255`)可打断。 | `socket_handlers.py:128-150`、`models.py:250-264`、`chat_flow_tool_loop.py:248-324` |
补充说明socket 软 stop「不打断」结论为静态推断有很大概率。执行链检查点会轮询 stop 标志,但审批等待循环本身不检查它,因此软 stop 只能等审批超时/回答后下一检查点才生效。
---
## 三、总结:状态唯一 Owner 达成度
**已有唯一 Owner实现与契约一致**
- **S1 对话历史与元数据**单保存保护merge + 锁 + 原子写)→ owner 明确ContextManager / crud_mixin 保存链)。✅
- **S2 任务记录**:唯一 `TaskManager``server/tasks/models.py`)持有 `_tasks`。✅
- **S3 任务事件流**`TaskRecord.events` 由 `_append_event` 唯一追加、`TaskManager` 读写。✅
- **S4 对话级主任务门闸**:进程级门闸组件唯一裁决,`process_message_task` 唯一获取入口。✅(回退路径释放存疑)
- **S6 审批条目**:三个 manager 各自唯一持有条目(`threading.Lock` 内单写裁决)。✅(内存态)
- **S7 对话级 terminal**`get_user_resources` 唯一创建 + reaper 唯一回收。✅
- **S9 多智能体状态**:进程级 `GLOBAL_MULTI_AGENT_STATES` 单例 + `_load_state` 恢复校准 + 快照。✅
**仍属分散 / 内存态影响「Gateway 状态唯一 Owner」达成**
- **S2/S3/S6 彻底的内存态**:进程重启即失,无持久化、无跨进程一致性(`models.py`、三个 approval manager。这是「重启后续跑/回放」无法承诺的主因。
- **S5 stop_flags**:模块级全局 dict`server/state.py:38`读写点分散REST models.py、socket handlers、执行链多个检查点本质是进程内共享可变全局无锁依赖单线程 asyncio 事件循环 + 任务线程隔离),跨进程不成立。
- **S10 用户偏好**:写路径虽收敛到 `save_personalization_config`,但该写**非原子**(直接覆写 `personalization_manager.py:851-856`),与 S1 对话原子写不对称;且「执行链只读快照」依赖受理时 `session_data` 固化,若未来新增写入口不复制该快照机制则可能偏离。
- **S4 回退路径门闸释放疑点**:若成立,会使一个对话的门闸在回退场景下长期占用(动态疑点,需运行时确认)。
- **S6 task_id 恒 None**:审批条目无法关联到具体 Runtask_id 字段形同虚设),影响「审计/对账/阶段三超时语义」定位到任务。
**对「Gateway 状态唯一 Owner」判断的关键输入**:当前每一类状态在**单一进程内**基本都有唯一 owner 与互斥;但 S2/S3/S6 是进程内内存态(无跨进程 ownerS5/S10 存在分散/非原子写点。若 Gateway 化目标要求「跨进程/重启后状态唯一」则内存态三大块S2/S3/S6与 S5 全局仍是障碍若仅要求「进程内单写者」已达度较高。S4 回退路径与 S6 task_id 归属属需先闭环的两个具体疑点。
---
## 附:审计方法与人眼注意
- 全程只读,未修改任何项目文件。
- 静态结论均来自代码阅读「有很大概率」的推断S4 回退释放、S6 task_id 恒 None、socket 软 stop 不打断)明确标注,未以运行验证冒充确定结论。
- 交付目录:`cache_research/gateway/audit_state_ownership_v2/`。

View File

@ -1,197 +0,0 @@
# Astrion `server/` 196 个 API 端点职责分类盘点报告
> 任务运行时边界整理Gateway/RuntimeService前置普查 —— 回答「196 个端点里到底有多少需要动」。
> 分析方法只读代码分析grep 全部 `@<bp>.route` 装饰器后逐个读取端点函数体),未修改任何文件。
> 统计口径:以 Flask 路由注册Methods 合并计数)为准,共 **196** 个,与已知分布完全一致(逐文件核验过)。
---
## 0. 结论速览(先给答案)
| 类别 | 数量 | 占 196 的比例 | 是否要动 |
|---|---|---|---|
| **T1 任务受理**(创建/启动一轮 Agent 任务) | **3** | **1.5%** | ✅ 必须迁移到 RuntimeService 公共入口 |
| **T2 任务控制**(取消/停止/审批回答/提问回答) | **13** | **6.6%** | ✅ 必须迁移(收敛为公共控制接口) |
| **T3 任务观察**(轮询状态/事件流 idx/offset | **14** | **7.1%** | ⚠️ 可不迁移:只读 REST 语义HTTP 适配层保留即可 |
| **C CRUD**(会话/工作区/配置/文件/页面) | **131** | **66.8%** | ❌ 不动 |
| **S 状态查询**(系统状态/git/docker/usage | **20** | **10.2%** | ❌ 不动 |
| **A 认证管理**(登录/token/用户/API 用户) | **15** | **7.7%** | ❌ 不动 |
| **合计** | **196** | 100% | — |
**核心答案真正需要「Gateway 化」的最低必要集合 = T1 + T2 = 16 个端点,占 196 的 8.2%。**
若把任务观察T3共享事件流读取协议也纳入公共接口设计边界则为 30 个端点15.3%
但 T3 中绝大多数只是读内存任务记录/磁盘状态,**可以不动**——观察类本来就是 REST 语义本身,
Gateway 只需在 T1 受理时返回 task_idCLI/定时任务各自复用 T3 的轮询协议即可。
> **审阅注释2026-09-07R1 范围)**:此分类用于估算 HTTP 改动面,不能据此判断运行时职责是否已经收敛。保留查询端点/轮询协议与提供内部查询接口不冲突;定时触发器不应被迫通过 HTTP 才能观察任务。迁移完成标准应来自状态责任表和调用链验收,而非固定端点数量。
10 个 socketio 事件中,与任务运行耦合的仅 2 个:`send_message`T1已废弃短路`stop_task`T2
---
## 1. 分类统计总表
### 1.1 类别 × 数量 × 文件分布
| 类别 | 数量 | 分布(文件:数量) |
|---|---|---|
| **T1 任务受理** | 3 | api_v1.py:1tasks/api.py:1workflow_runtime_api.py:1 |
| **T2 任务控制** | 13 | tasks/api.py:5conversation.py:3chat/approval.py:3api_v1.py:1workflow_runtime_api.py:1 |
| **T3 任务观察** | 14 | conversation.py:4tasks/api.py:3chat/approval.py:3api_v1.py:1workflow_runtime_api.py:1multi_agent.py:1conversation_bootstrap.py:1 |
| **C CRUD** | 131 | conversation.py:26api_v1.py:19admin.py:17multi_agent.py:11chat/permission.py:10workflow_page.py:6status/host_workspace.py:6chat/settings.py:5files.py:5status/docker.py:5status/file_open.py:4chat/files.py:3chat/misc.py:2status/sandbox.py:1status/app.py:1auth.py:7app_legacy.py:2tasks/skills.py:1 |
| **S 状态查询** | 20 | status/base.py:4status/git.py:2status/sandbox.py:2chat/misc.py:3status/docker.py:1status/app.py:1usage.py:1admin.py:3auth.py:1api_v1.py:1chat/terminal.py:1 |
| **A 认证管理** | 15 | auth.py:8admin.py:6chat/terminal.py:1 |
| **合计** | **196** | 26 个文件(与任务给定的分布完全一致) |
### 1.2 按文件展开
| 文件 | 总数 | T1 | T2 | T3 | C | S | A |
|---|---|---|---|---|---|---|---|
| conversation.py | 33 | 0 | 3 | 4 | 26 | 0 | 0 |
| admin.py | 26 | 0 | 0 | 0 | 17 | 3 | 6 |
| api_v1.py | 23 | 1 | 1 | 1 | 19 | 1 | 0 |
| auth.py | 16 | 0 | 0 | 0 | 7 | 1 | 8 |
| multi_agent.py | 12 | 0 | 0 | 1 | 11 | 0 | 0 |
| chat/permission.py | 10 | 0 | 0 | 0 | 10 | 0 | 0 |
| tasks/api.py | 9 | 1 | 5 | 3 | 0 | 0 | 0 |
| workflow_page.py | 6 | 0 | 0 | 0 | 6 | 0 | 0 |
| status/host_workspace.py | 6 | 0 | 0 | 0 | 6 | 0 | 0 |
| status/docker.py | 6 | 0 | 0 | 0 | 5 | 1 | 0 |
| chat/approval.py | 6 | 0 | 3 | 3 | 0 | 0 | 0 |
| files.py | 5 | 0 | 0 | 0 | 5 | 0 | 0 |
| chat/settings.py | 5 | 0 | 0 | 0 | 5 | 0 | 0 |
| chat/misc.py | 5 | 0 | 0 | 0 | 2 | 3 | 0 |
| status/file_open.py | 4 | 0 | 0 | 0 | 4 | 0 | 0 |
| status/base.py | 4 | 0 | 0 | 0 | 0 | 4 | 0 |
| workflow_runtime_api.py | 3 | 1 | 1 | 1 | 0 | 0 | 0 |
| status/sandbox.py | 3 | 0 | 0 | 0 | 1 | 2 | 0 |
| chat/files.py | 3 | 0 | 0 | 0 | 3 | 0 | 0 |
| status/git.py | 2 | 0 | 0 | 0 | 0 | 2 | 0 |
| status/app.py | 2 | 0 | 0 | 0 | 1 | 1 | 0 |
| chat/terminal.py | 2 | 0 | 0 | 0 | 0 | 1 | 1 |
| app_legacy.py | 2 | 0 | 0 | 0 | 2 | 0 | 0 |
| usage.py | 1 | 0 | 0 | 0 | 0 | 1 | 0 |
| tasks/skills.py | 1 | 0 | 0 | 0 | 1 | 0 | 0 |
| conversation_bootstrap.py | 1 | 0 | 0 | 1 | 0 | 0 | 0 |
| **合计** | **196** | **3** | **13** | **14** | **131** | **20** | **15** |
---
## 2. T1 / T2 / T3 类端点详细清单
> 内部调用均指**端点函数体最终触达的运行时函数**。所有任务受理/控制最终收敛到
> `server/tasks/models.py` 的单例 `task_manager`TaskManager
> `create_chat_task` 创建 TaskRecord 并 spawn 后台线程 `_run_chat_task``chat_flow.run_chat_task_sync` 执行;
> 控制类调 `cancel_task / enqueue_runtime_guidance / enqueue_runtime_pending_message / remove_runtime_pending_message / promote_runtime_pending_to_guidance`
> 观察类调 `get_task / get_events_since / list_tasks / get_conversation_running_status`
> 这正是「公共任务入口RuntimeService」要收敛的核心面。
### 2.1 T1 任务受理类3 个)—— 必须 Gateway 化
| # | 路径 | 方法 | 文件:行 | 内部调用 | 迁移复杂度 |
|---|---|---|---|---|---|
| T1-1 | `/api/tasks` | POST | tasks/api.py:111 `create_task_api` | `task_manager.create_chat_task(...)`前置goal 状态清理、`_build_skill_context_messages` 技能上下文构建、conversation 补建、`_normalize_media/files_payload` 媒体归一化) | **中**:受理本身已是 task_manager 薄封装,但 HTTP 层有 ~100 行参数归一化/技能上下文/补建对话逻辑可下沉给 RuntimeService 统一处理CLI/定时任务同样需要) |
| T1-2 | `/api/v1/workspaces/<id>/messages` | POST | api_v1.py:254 `send_message_api` | `ensure_conversation_loaded` + prompt/personalization 应用到 `terminal.context_manager` + `apply_personalization_preferences` + `task_manager.create_chat_task(...)` | **中**prompt/personalization 文件校验与应用属于 HTTP 适配层职责(可保留),核心受理改为调用 RuntimeService |
| T1-3 | `/api/workflow/activate` | POST | workflow_runtime_api.py:38 `api_activate_workflow` | `activate_workflow()`modules/workflow_flow`try_acquire_main_task_gate` 门闸 → `task_manager.create_chat_task(..., message_source="workflow", session_data=含 gate_token)` | **大**:工作流激活语义(会话补建/模式继承/msg_index 游标)+ 主任务门闸 token 移交任务线程,逻辑最重;建议 HTTP 层保留状态机编排,仅把 `create_chat_task` 调用统一切到 RuntimeService 入口 |
### 2.2 T2 任务控制类13 个)—— 必须 Gateway 化
| # | 路径 | 方法 | 文件:行 | 内部调用 | 迁移复杂度 |
|---|---|---|---|---|---|
| T2-1 | `/api/tasks/<task_id>/cancel` | POST | tasks/api.py:284 `cancel_task_api` | `task_manager.cancel_task()` + `GoalStateManager.mark_stopped(REASON_USER_CANCEL)` 停止对话残留目标 | **小** |
| T2-2 | `/api/v1/tasks/<task_id>/cancel` | POST | api_v1.py:444 `cancel_task_api_v1` | `task_manager.cancel_task()` | **小** |
| T2-3 | `/api/tasks/<task_id>/runtime_guidance` | POST | tasks/api.py:305 `enqueue_runtime_guidance_api` | `task_manager.enqueue_runtime_guidance()`(向运行中任务注入引导消息) | **小** |
| T2-4 | `/api/tasks/<task_id>/runtime_queue` | POST | tasks/api.py:333 `enqueue_runtime_queue_message_api` | `task_manager.enqueue_runtime_pending_message()`(运行中追问入队) | **小** |
| T2-5 | `/api/tasks/<task_id>/runtime_queue/<message_id>` | DELETE | tasks/api.py:361 `delete_runtime_queue_message_api` | `task_manager.remove_runtime_pending_message()` | **小** |
| T2-6 | `/api/tasks/<task_id>/runtime_queue/<message_id>/guide` | POST | tasks/api.py:383 `guide_runtime_queue_message_api` | `task_manager.promote_runtime_pending_to_guidance()` | **小** |
| T2-7 | `/api/workflow/deactivate` | POST | workflow_runtime_api.py:210 `api_deactivate_workflow` | `deactivate_workflow_by_user()`;若池中有通知且门闸空闲则再 `task_manager.create_chat_task(...)` 派发一轮 | **中**:控制 + 条件受理二合一,建议保留编排、收敛 Task 调用 |
| T2-8 | `/api/sub_agents/stop_all` | POST | conversation.py:2059 `stop_all_sub_agents` | `sub_agent_manager.soft_stop_all_agents()` / `sub_agent_manager.terminate_sub_agent()`(多智能体软停 / 传统模式终结) | **小** |
| T2-9 | `/api/sub_agents/<task_id>/terminate` | POST | conversation.py:2108 `terminate_sub_agent` | `sub_agent_manager.terminate_sub_agent(task_id=...)` | **小** |
| T2-10 | `/api/background_commands/<command_id>/cancel` | POST | conversation.py:2222 `cancel_background_command` | `background_command_manager.cancel_command(command_id)` | **小** |
| T2-11 | `/api/user-questions/<question_id>/answer` | POST | chat/approval.py:63 `answer_user_question` | `user_question_manager.answer(...)`(回答喂回运行中任务工具循环) | **小** |
| T2-12 | `/api/plan-approvals/<approval_id>/answer` | POST | chat/approval.py:107 `answer_plan_approval` | `plan_approval_manager.answer(...)`(计划通过后工具循环切 execute 模式) | **小** |
| T2-13 | `/api/tool-approvals/<approval_id>/decision` | POST | chat/approval.py:150 `decide_tool_approval` | `tool_approval_manager.decide(...)` | **小** |
### 2.3 T3 任务观察类14 个)—— 可不迁移只读HTTP 适配层保留)
| # | 路径 | 方法 | 文件:行 | 内部调用 | 迁移复杂度 |
|---|---|---|---|---|---|
| T3-1 | `/api/tasks` | GET | tasks/api.py:34 `list_tasks_api` | `task_manager.list_tasks()`(任务列表/状态过滤) | 小(不需迁移) |
| T3-2 | `/api/conversations/<cid>/running-status` | GET | tasks/api.py:56 | `task_manager.get_conversation_running_status()` 聚合主任务+子智能体+后台命令+多智能体 | 小 |
| T3-3 | `/api/tasks/<task_id>` | GET | tasks/api.py:229 `get_task_api` | `task_manager.get_task()` + `get_events_since(rec, offset)`**事件流 idx/offset 轮询协议** | 小 |
| T3-4 | `/api/v1/tasks/<task_id>` | GET | api_v1.py:415 `get_task_events` | `task_manager.get_task()` + `get_events_since(rec, offset)`(同一协议) | 小 |
| T3-5 | `/api/workflow/status` | GET | workflow_runtime_api.py:295 | `WorkflowStateManager.progress_snapshot()` | 小 |
| T3-6 | `/api/sub_agents` | GET | conversation.py:1892 `list_sub_agents` | `sub_agent_manager.get_overview()` + 通知去重计算 | 小 |
| T3-7 | `/api/sub_agents/<task_id>/activity` | GET | conversation.py:2001 | 读 `progress.jsonl` 活动记录(含 limit 上限) | 小 |
| T3-8 | `/api/background_commands` | GET | conversation.py:2139 | `background_command_manager.list_records()` | 小 |
| T3-9 | `/api/background_commands/<command_id>` | GET | conversation.py:2184 | `background_command_manager.get_record_with_output()`(实时输出) | 小 |
| T3-10 | `/api/user-questions/pending` | GET | chat/approval.py:48 | `user_question_manager.list_pending()` | 小 |
| T3-11 | `/api/plan-approvals/pending` | GET | chat/approval.py:92 | `plan_approval_manager.list_pending()` | 小 |
| T3-12 | `/api/tool-approvals/pending` | GET | chat/approval.py:135 | `tool_approval_manager.list_pending()` | 小 |
| T3-13 | `/api/multiagent/active_sub_agents` | GET | multi_agent.py:483 | `sub_agent_manager.get_multi_agent_state()`(子智能体实例+token 统计) | 小 |
| T3-14 | `/api/conversations/<cid>/bootstrap` | GET | conversation_bootstrap.py:146 | 聚合 meta+messages+`task_manager.get_conversation_running_status()`+task_replay纯只读 | 小 |
---
## 3. socketio 事件盘点10 个)
| # | 事件 | 文件:行 | 职责 | 分类 |
|---|---|---|---|---|
| S1 | `connect` | socket_handlers.py:24 | socket_token 握手鉴权、绑定 user/terminal | A |
| S2 | `disconnect` | socket_handlers.py:76 | 清理 sid→user 映射、stop flag 生命周期 | A |
| S3 | `stop_task` | socket_handlers.py:128 | 置 sid 级 stop flag指挥运行中任务停流唯一 WS 任务控制通道) | **T2** |
| S4 | `terminal_subscribe` | socket_handlers.py:152 | 订阅真实终端输出流 | S/C |
| S5 | `terminal_unsubscribe` | socket_handlers.py:198 | 取消订阅 | S/C |
| S6 | `get_terminal_output` | socket_handlers.py:211 | 拉取终端输出片段 | S |
| S7 | `send_message` | socket_handlers.py:240 | **WS 聊天入口(已废弃)**:函数体已短路返回 `DEPRECATED`,死代码保留紧急回退(原路径:`start_chat_task` → 任务线程);任务受理已全部改走 REST `/api/tasks` | **T1废弃** |
| S8 | `client_chunk_log` | socket_handlers.py:372 | 前端分块渲染日志上报(限流写盘) | C |
| S9 | `client_stream_debug_log` | socket_handlers.py:386 | 前端流调试日志上报 | C |
| S10 | `send_command` | conversation.py:2462 | 系统命令 `clear/status/terminals`(只读/清空历史,无 Agent 执行REST 对应 `/api/commands` | C |
**WS 侧结论**10 个事件中仅 `stop_task`T2活跃`send_message`T1已废弃与任务运行时耦合`send_message` 已是死代码。
事件流观察task/terminal output由 WS 直接转发 HTTP 轮询结果,无需 Gateway 化。
---
## 4. 结论真正需要「Gateway 化」的端点占比与理由
### 4.1 数字结论
- **必须迁移到 RuntimeService 公共入口(受理 + 控制)= T1(3) + T2(13) = 16 个 = 8.2%**
- 其中 T1 仅 **3 个1.5%**`POST /api/tasks`、`POST /api/v1/.../messages`、`POST /api/workflow/activate`。
- 这 16 个端点的函数体**最终都调用同一批 task_manager / sub_agent_manager / background_command_manager / approval 方法**
即「运行时控制面」已经收敛在几个单例方法上——Gateway 化的实质就是把这几个方法提升为 RuntimeService 的公共入口,
Web / CLI / 定时任务复用HTTP 端点从「直接调 manager」改为「调 RuntimeService」。
- **可迁移但不强制 = T3(14) = 7.1%**纯只读。轮询协议task_id + from/offset + events本身就是 REST 语义,
CLI/定时任务可直接复用相同协议HTTP 适配层保留即可;未来若要统一查询接口可让 RuntimeService 暴露 `get_task_events`,但**不是本次改造的必要条件**。
- **完全不需要动 = C(131) + S(20) + A(15) = 166 = 84.7%**CRUD、状态查询、认证管理与 Agent 运行时零耦合。
> **审阅注释2026-09-07R1 配置耦合)**:“零耦合”与下文灰区说明不一致。窄核验确认 `server/chat/permission.py:166185`、`:265284`、`:352368` 分别排队修改运行中的权限、执行环境和网络权限work-mode 则在 `:420445` 拒绝运行中切换。可保留 HTTP 路由,但这些状态修改/校验规则需要明确归属。建议理解为“多数无需改动外部接口,涉及运行态的内部调用按契约评审”,不是排除在运行时审查之外。
### 4.2 剩余 166 个端点为何可以不动
1. **C131**:会话/工作区/文件/配置/工作流定义/多智能体角色与设置的增删改查,全部是磁盘读写 + terminal 状态读写,
不触发、不控制、不观察任何 Agent 任务执行。典型如 `POST /api/conversations`(有活跃任务时只建视图文件,不启动任务)、
`POST /api/multiagent/conversations`(仅建对话并写 metadata不发派 agent、`PUT /api/workflows/<name>`WORKFLOW.md 落盘)。
唯一接近灰区的是 `POST /api/conversations/<id>/compress`(深层压缩会同步 `asyncio.run(run_deep_compression)` 调用 LLM
但**不创建 task_manager 任务、不经事件流、无状态轮询**,属于 HTTP 层可同步执行的对话维护操作,判定为 C 并建议改造时单独评审)。
2. **S20**status/docker/git/usage/sandbox/health 等只读查询,返回宿主环境与系统状态,与任务运行时无关。
3. **A15**:登录/注册/CSRF/socket-token/API 用户管理/二级口令,纯认证域。
4. **页面/静态路由**(已计入 C`/`、`/new`、`/workflows`、`/multiagent/*`、`/admin/*` 等 17 个 SPA 入口与静态资源路由,只是 `send_static_file`
5. **灰区说明(已统计进 C**`chat/permission.py` 的 3 个模式切换 POSTpermission-mode / execution-mode / network-permission在任务运行期间会把切换**入队queue_permission_mode_change 等 pending 机制)**由工具循环消费,对运行中任务有延迟影响;但它们的本质是**配置写入**(空闲时立即生效、运行中排队后生效),与「取消/停止/审批」这类任务控制操作性质不同,故判 C。若 RuntimeService 需要支持「运行中改权限」,仅需把这 3 个方法也纳入公共入口的可选能力,不构成本次改造的必要条件。
### 4.3 改造建议要点(基于分类的推论)
- RuntimeService 首批公共方法最小集:`create_task(受理)`、`cancel_task`、`enqueue_runtime_guidance`、`enqueue_runtime_pending_message(+remove/promote)`、
以及可选的 `get_task_events`(观察,供 CLI 复用)。
- `chat/approval.py` 的 3 个 answer/decisionT2`conversation.py` 的 3 个 stop/terminate/cancelT2走同一批 manager 方法,
属于同一控制面,建议一并收敛(或至少让 CLI 具备「提交审批答复」能力时复用)。
- workflow 激活/停用的状态机编排(门闸 token、会话补建、通知派发建议保留在 HTTP 层,只下沉 Task 创建调用,控制迁移风险。
> **审阅注释2026-09-07R2 过渡边界)**认可作为分步迁移手段。长期职责应让工作流服务承接激活、会话补建、门闸移交与失败回滚HTTP 只解析请求和映射响应若暂不迁移应标注剩余兼容依赖。同样API 入口中的模型/偏好应用等业务规则是否留在路由,应以非 HTTP 调用是否需要复用来判断。
### 4.4 方法与口径声明
- 全部 196 个端点经 `grep -E "@[a-zA-Z_]+\.route"` 逐文件提取并核对数量26 文件合计 196与任务给定的分布逐文件一致
- 分类依据为端点函数体实际行为(阅读每一处路由函数),非凭路径猜——凡能触发 `task_manager.create_chat_task` / 后台线程 / 任务控制方法者才归入 T1/T2。
- WS 侧 `send_message` 已确认函数体首行即短路返回废弃提示,其 T1 归类基于历史职责(死代码保留的回退分支),已单独标注。

View File

@ -1,240 +0,0 @@
# 支撑链路耦合点盘点:事件推送 / 审批 / 取消 / 保存
- 任务为「运行时边界整理RuntimeService 公共任务入口)」评估四条支撑链路的复用可行性
- 性质:**只读静态代码分析**(基于当前 checkout 源码阅读与 grep未做运行复现行号均指当前文件版本
- 范围server/、modules/、utils/、core/
- 结论确定性说明:文中「确认」指源码直接可见;「推断」指由代码结构推导、未运行时验证;「待核验」指存在多解释、需复现确认。未特别标注处为源码直接确认。
---
## 0. 四条链路一图快照
| 链路 | 核心载体 | 耦合标识符 | 等待/反馈机制 | 关键文件:行号 |
|---|---|---|---|---|
| 事件推送 | `TaskRecord.events`(有界 deque maxlen=20000+ 每任务 `next_event_idx` | task_ididx+轮询、usernamesocket 房间、conversation_id事件补全 | 拉GET /api/tasks/<id>?from=offsetsocket room `user_{username}` | tasks/models.py:86/:762、context.py:33 |
| 审批/提问 | `ToolApprovalManager` / `PlanApprovalManager` / `UserQuestionManager`(纯内存存储) | username + conversation_idmanager 键approval_id/question_id等待键 | 执行侧 `asyncio.sleep` 轮询0.2~0.3s,默认超时 3600s回答走 REST 写 manager | chat_flow_tool_loop.py:232、chat/approval.py |
| 取消 | `state.stop_flags`Dict[client_sid→entry] | task_idREST 任务即 client_sidsocket 场景为 request.sid | 标志位轮询100ms 工具期)+ 硬取消asyncio task.cancel | state.py:135、models.py:245、chat_flow.py:178 |
| 保存 | `ContextManager` → conversation 文件merge-on-save | conversation_id + message_id合并键对话文件路径 | 每消息 append → auto_save`_io_lock` 原子写) | message_mixin.py:127、crud_mixin.py:335 |
关键结论先行:**四条链路均以「任务记录/内存对象」为中心标识符语义基本收敛task_id / username / conversation_id无对 Flask request / socket sid 的硬依赖(执行线程用 test_request_context 包装属「软依赖」,可剥离);但审批/提问链路在无人值守下会阻塞直到 3600s 超时,且超时语义是「拒绝该工具、任务继续」而非「结束任务」,需改造。总体判定:事件/取消/保存三条可直接复用(需适配层),审批链路需改造。**
---
## 1. 事件推送链路
### 1.1 事件如何产生与编号
- 每个 REST 任务 = 一个 `TaskRecord`,事件存于 `self.events: deque(maxlen=20000)`**有界**,超出丢弃最旧;注释说明 1000→20000 是为刷新恢复时前端从事件流重建长流式输出——tasks/models.py:86。
- 事件编号:`TaskRecord.next_event_idx`(初始 0models.py:95单调递增`_append_event`models.py:762-783在全局锁内取出 idx → `rec.next_event_idx += 1` → 追加 `{"idx", "type", "data", "ts"}`,并在 `data``setdefault(task_id / conversation_id / workspace_id)`。**idx 是 task 级单调序号,无全局唯一性需求**,客户端按 offset 断点续读,因此 idx 语义只对「同一 task 的事件流」成立(任务清理后失效,前端靠对账接口兜底)。
- 执行期事件来源分两路(都汇到同一个 recorder
1. `_run_chat_task` 内部定义的 `sender(event_type, data)`models.py:907-928追加事件 + `socketio.emit(event_type, data, room=f"user_{username}")`
2. 执行链内部大量直接调 `sender(...)`chat_flow_task_main.py 的 thinking_chunk/text_chunk/tool_preparing/update_action/system_message/task_complete 等,见 chat_flow_task_main.py:1478-1540 的 sender 包装);
3. token_update 等 context_manager 回调事件:`_run_chat_task` 在运行期间把 `terminal.context_manager._web_terminal_callback` 临时切到任务 `sender`models.py:935-936结束后恢复models.py:1011-1013
- **补齐 conversation_id 的层次**(双重包装):
- 对话级 terminal 广播:`_wrap_callback_with_conversation_id`context.py:75-90为 dict 事件 `setdefault(conversation_id)`
- 任务执行链:`handle_task_with_sender` 内层 senderchat_flow_task_main.py:1516-1546为全部事件补 conversation_id为 error/quota_exceeded/task_stopped/task_complete 补 task_id/client_sid。
- 因此事件流里 conversation_id **不是权威来源**(只是为前端定位/过滤而注入),权威在 conversation 文件侧。
### 1.2 事件如何到达 socketio 客户端
- 广播目标 = 用户房间 `user_{username}`,见 context.py:33-42`make_terminal_callback`)与 models.py:926、:1048。socket 前端 connect 时 `join_room(f"user_{username}")`socket_handlers.py:45
- **房间粒度是「用户」,不是「对话」也不是「任务」**:同用户全部对话的事件都发到同一房间,前端按 payload 的 conversation_id 过滤、按 task_id 区分任务流。
- Web 聊天socket 模式)另有 `send_to_client` 直发 `room=request.sid`socket_handlers.py:315-322此为单连接定向广播与 REST 任务的用户房间广播是两条独立路径。
### 1.3 客户端轮询端点如何按 offset 读
- `GET /api/tasks/<task_id>?from=<offset>`tasks/api.py:231-287`get_events_since(rec, offset)`models.py:226-235锁内 O(n) 浅拷贝快照再过滤 `e["idx"] >= offset`)→ `next_offset = events[-1]["idx"] + 1`
- 前端 task 轮询 250ms 一次、带 `X-Task-Poll` 头与 `from` 游标static/src/stores/task.ts:64/:156另有 running-status 对账接口tasks/api.py:38-89作正确性兜底。
### 1.4 耦合点清单(事件链路)
| # | 耦合对象 | 位置 | 说明 |
|---|---|---|---|
| E1 | **task_id** | models.py:71(:TaskRecord.task_id)、:762-770(_append_event 写入 data)、api.py:231-287(poll 寻址)、models.py:940(client_sid=rec.task_id) | 事件 idx/缓冲/轮询游标全部挂在 TaskRecord 上,一切以 task_id 寻址 |
| E2 | **usernamesocket 房间)** | context.py:38、models.py:926/:1048、socket_handlers.py:45 | socket 推送按 `user_{username}` 房间;离线时无人接收(事件仍落 deque |
| E3 | **conversation_id事件补全非权威** | context.py:75-90、chat_flow_task_main.py:1516-1546、models.py:765-769 | 事件内注入用;权威在对话文件 |
| E4 | **terminal + conversation 配对(回调临时劫持)** | models.py:935-936/:1011-1013set_web_terminal_callback 切换/恢复) | REST 任务运行期独占该对话级 terminal 的 context 回调;对话级隔离下安全,但新调用方若复用同一对话级 terminal 需注意「同一时刻一个主任务」约束main_task_gate |
| E5 | 有界 deque 容量/清理 | models.py:86maxlen=20000、:108-128cleanup_old_tasks 终态>3600s 清理) | 事件只存活于任务生命周期,重启/清理后消失前端需对账恢复todoprobe.ts 对账属前端侧) |
| E6 | 事件类型契约 | chat_flow_task_main.py 各 sender 调用点、socket 前端监听 | 事件 type 字符串是前后端隐式契约(无 schema/版本),新增事件类型要与前端联调 |
### 1.5 新调用方(如定时任务触发器)能否直接复用?
**判定:可直接复用,适配点在「构造 TaskRecord + 会话上下文」。**
- 链路本身不依赖 HTTP request`_run_chat_task` 里的 `test_request_context`models.py:790-797只用于从 `rec.session_data` 回填 Flask session 以兼容下游读取属于可剥离的软耦合阶段二已计划消除models.py:785 注释确认「只包装不算解耦」)。
- 已有非 Web 触发先例完成通知链chat_flow_task_main.py:613 派发 `create_chat_task` + `session_data["main_task_gate_token"]` 移交门闸、workflow_runtime_api.py:184/:264、api_v1.py:320——它们服务的对象仍是「用户在线场景」。
- **新调用方必须提供/生成的耦合键**`username`principal、`workspace_id`、`conversation_id`(可让运行时补建)、`task_id`uuid事件/取消/轮询共用)。不需要 terminal_id——terminal 由 `get_user_resources(username, workspace_id, conversation_id)` 派生(对话级缓存键 = `username::workspace_id::conversation_id`context.py:62-71
- 需要适配/确认的两点:
1. 事件仍会 `socketio.emit``user_{username}` 房间——用户离线时无影响无人接收deque 仍在),但用户在线时会收到定时任务的事件(**前台干扰**:需要确认产品预期,或给事件加 source 字段让前端过滤);
2. 与在线聊天任务的并发约束:`create_chat_task` 的单对话互斥models.py:158-177已覆盖 chat 类型;通知链已演示通过 `main_task_gate_token` 预占门闸的模式,定时任务的会话策略(独立会话 or 复用会话)需显式定义后才能确定复用模式。
---
## 2. 审批链路tool 审批 / plan 审批 / ask_user 提问)
### 2.1 pending/answer 机制(三个 manager 同构)
- `ToolApprovalManager`modules/tool_approval_manager.py92 行):`create_request`:17-40生成 `approval_id = approval_{uuid}`,条目含 username / conversation_id / task_id / tool_call_id / tool_name / arguments / previewstatus=pending`decide`:56-92把 status 置 approved/rejected。**纯内存 dict + threading.Lock无等待原语、无 TTL、无清理**。
- `PlanApprovalManager`modules/plan_approval_manager.py`create_request`:25-64存计划文档内容截断 20000 字符);`answer`:88-100置 approved/rejected + comment。同样纯内存。
- `UserQuestionManager`modules/user_question_manager.py`create_question`:70-121支持 batchbatch_id/batch_index/batch_total前端一次弹多问`answer`:137-176支持 option / free_text / dismissed。
- 三者都提供 `list_pending(username, conversation_id)`——**关联键是 username + conversation_id**。
### 2.2 等待/回答如何回到执行链路
- **等待原语不是 asyncio.Event而是 asyncio.sleep 轮询**
- `_wait_for_tool_approval`chat_flow_tool_loop.py:232-263每 0.2s `tool_approval_manager.get(approval_id)` 看 status**默认超时 3600s**,超时返回 decision=rejected + code=approval_timeout
- `_wait_for_user_questions`:265-2920.2s 轮询多问题批量等到全部 answered超时置 timeout
- `_wait_for_plan_approval`:294-3090.3s 轮询,超时返回 status=timeout。
- 调用点plan :435、ask_user :574、tool :883 与 :1153:883 走 `run_auto_approval`——若权限模式为 auto_approval则由 `ApprovalAgent` 审核决定modules/auto_approval_service.py:56-105期间仍可人工接管 `_manual_takeover`)。**各调用点均未传 timeout_seconds一律吃默认 3600s**。
- **前端如何收到审批请求**:执行链路 `sender('tool_approval_required' / 'plan_approval_required' / 'user_questions_required', ...)`chat_flow_tool_loop.py:429/:569/:851/:1121事件汇入任务事件流REST 轮询可见)+ socket 用户房间。另有 REST 端点轮询 pending`/api/tool-approvals/pending`、`/api/user-questions/pending`、`/api/plan-approvals/pending`server/chat/approval.py:51/:95/:138
- **回答如何回到执行链路**REST 端点 `/decision`、`/answer`chat/approval.py:67/:111/:154只是「写 manager 条目」;执行侧的 sleep 轮询下一次 tick 读到 status 变化即继续。**没有事件总线回环、没有回调**——执行线程与回答线程仅通过 manager 内存对象耦合。
### 2.3 耦合点清单(审批链路)
| # | 耦合对象 | 位置 | 说明 |
|---|---|---|---|
| A1 | **approval_id / question_id等待键** | chat_flow_tool_loop.py:232-309manager get() | 等待循环只认 id轮询 manager |
| A2 | **username + conversation_id授权与归属** | manager list_pending/decide/answer三处 username 校验chat/approval.py | 回答端点校验当前登录用户==条目 username前端按 conversation_id 过滤 |
| A3 | **task_id 字段(实际恒 None** | chat_flow_tool_loop.py:411/:545/:845/:1115`getattr(web_terminal, "task_id", None)` | 全仓 grep 未发现 `web_terminal.task_id` 的赋值点core/web_terminal.py 无此属性)→ 审批条目里的 task_id 目前**始终为 None**(高置信推断,未排除动态 setattr 路径);关联任务仅靠事件流里的 task_id 补全_append_event setdefaultmanager 本身不按 task_id 检索 |
| A4 | **执行线程阻塞挂起** | 等待循环捕获不到 stop 标志(见 2.4 | 主任务线程在等待期间处于 sleep 轮询,软取消不可中断等待 |
| A5 | **manager 内存常驻,无 TTL/清理** | modules/tool_approval_manager.py、plan_approval_manager.py、user_question_manager.py | 未决条目永久留在 `_items`(无 reaper长时间运行会累积 |
| A6 | **REST 端点依赖 @with_terminal** | chat/approval.py装饰器 | 回答/列等待办的端点需会话上下文;`with_terminal` 内部按 session 归属取 terminal |
### 2.4 无人值守场景(定时任务)的具体改造点
现状行为(确认):
1. 无人回答 → 等待循环跑满 3600s 才按「超时=拒绝该工具」放行,**任务继续执行**(并不结束任务);
2. 期间用户若点了停止REST 取消会 `loop.call_soon_threadsafe(task.cancel)` **硬取消**CancelledError 会打断 sleep 轮询 → 任务收尾成 stopped这条路径可用但依赖「有人发起取消」socket 的软 stop 标志(仅 set flag**不会**打断审批等待——下一次工具调用的行首检查(:600才生效
3. `auto_approval` 权限模式(若已配置)由审核智能体决策,天然不依赖人在线——这是无人值守可复用的现成机制,但仅覆盖「需要审批」的工具,且默认各用户是否启用取决于权限策略配置。
改造点(按最小改动排序):
1. **给等待循环注入可配置超时**(必须):`_wait_for_tool_approval / _wait_for_user_questions / _wait_for_plan_approval` 均已有 `timeout_seconds` 参数,但调用点(:435/:574/:883/:1153没传。改造 = 从 `TaskRecord.session_data` 传入(如 `{scheduled: True, approval_timeout_seconds: N}`),经 `handle_task_with_sender → handle_task_with_sender args → _execute_tool_calls_impl` 透传。无人值守建议超时远小于 3600s如 60~300s
2. **定义超时语义**(需要产品决策,当前语义=拒绝该工具继续跑):定时任务场景两种候选——(a) 超时=拒绝该工具、任务继续(现状,风险:任务在无监督下继续下一步);(b) 超时=结束整个任务(需在等待返回后检查 `timeout` 状态并向运行循环抛「终止」信号,或直接复用取消链路 `task_manager.cancel_task`)。方案不同,改动位置不同:前者零改动(只调参),后者要动 chat_flow_tool_loop 的返回路径。
3. **等待循环内增加 stop 检查**(推荐顺带):与工具执行期 100ms 停止轮询(:1018-1045对齐在三个 `_wait_*` 循环里轮询 `get_stop_flag`,让软取消也能中断等待——这同时修复「在线用户停止按钮在审批等待期间无效」的现状 gap确认现状软 stop 确实无法中断等待)。
4. **manager 条目 TTL/清理**(工程债):未决条目需按创建时间清理(与任务 cleanup 类似),避免定时任务多次触发后 pending 条目膨胀;超时/取消时应把条目置终态(目前只有 decide/answer 能置终态,等待方超时不回写 manager——`approval_timeout`/`timeout` 只存在于返回值)。
5. **定时任务的会话策略决定审批是否可达**:若定时任务复用用户既有对话,用户上线时仍可在该对话看到审批请求(事件进用户房间 + pending 端点);若用户长期离线,则依赖超时/auto_approval。建议首版定时任务会话默认不产生人工审批策略层在派发前预检权限模式需要审批的工具直接失败重试或跳过把「no-human-approval」作为定时任务会话的硬约束写进会话策略。
6. 仅当需要「审批请求在离线后也能被用户补答」时才考虑持久化 manager当前全是内存态重启即丢——阶段三路线已把「无人在线遇审批/提问按超时结束,不扩大权限」列为原则,与上述 1/2 一致。
### 2.5 复用判定(审批链路)
> **审阅注释2026-09-07R5 产品策略)**§2.4 的“禁止人工审批”“60300s 超时”等均为待讨论选项,不是定时任务的技术前提。拒绝当前工具后继续运行仍受权限限制,不自动等于不安全;用户也可能希望任务等自己上线回答。工具审批、计划审批和用户提问应分别确定超时处理。客户端离线但服务仍运行时,现有 pending 可保留;跨服务重启保留记录与恢复等待执行是另外两层需求。
> **审阅注释2026-09-07R6 取消与条目状态)**§2.4 所称“停止按钮无效”应限定为只置软停止标志的路径;本报告 §3.1 已说明标准按钮使用 REST 硬取消,可打断等待。条目生命周期应先定义超时/取消终态与迟到回答处理,再设置保留期清理,不能只按 TTL 删除仍被等待的 pending。task_id 恒 None 保持“静态疑点”定性,待运行时关联验证。
- **不可直接复用**(无人值守语义不成立):等待循环机制本身与任务执行深度内嵌(定义在执行链工具循环里),但**存储 manager 可复用**(与 Web 前端共用 pending/answer 数据),需新增:超时策略注入 + 超时语义决策 + stop 检查 + 条目清理。
- 计划审批/提问同理都是「模型工具阻塞等人工」无人值守下按同一套超时策略处理auto_approval 分支ApprovalAgent是唯一现成的无人工决策路径但只覆盖 tool 审批plan/ask_user 无 agent 替代)。
---
## 3. 取消链路
### 3.1 停止按钮到任务取消的完整链路
- **REST统一入口**:前端停止按钮现在的标准路径 = `POST /api/tasks/<task_id>/cancel`tasks/api.py:289-320`task_manager.cancel_task(username, task_id)`models.py:245-322
1. 按 task_id 取记录(校验 username 归属);
2. 若 stop_flags entry 里还持有 asyncio loop/task 引用 → `loop.call_soon_threadsafe(task.cancel)` **硬取消**
3. 置 `stop_flags[task_id] = {'stop': True, ...}` + `rec.stop_requested = True`
4. 停止该对话目标模式GoalStateManager
5. 丢弃 runtime_guidance_queuestatus 置 `cancel_requested`**秒级瞬态**_run_chat_task 收尾定终态)。
- **Socket旧路径语义=停最近任务)**`stop_task`socket_handlers.py:128-151`get_stop_flag(request.sid, username)` 置 stop 标志disconnect 兜底(:96-118仅在「无其他连接且无 REST 运行任务」时 stop + hard cancel。
- **运行期检查点**(全部 `include_user=False`,只查任务级键):
- 流式输出循环chat_flow_stream_loop.py:59/:287
- 主任务循环chat_flow_task_main.py:996/:1140/:2058
- 工具循环chat_flow_tool_loop.py:600每个 tool_call 前)、:1018-1045工具执行中 100ms 轮询并 `tool_task.cancel()`
- 重试延迟chat_flow_task_support.py:568-582wait_retry_delay
- 入口处预读models.py:813stop_hint
- **收尾**`_run_chat_task` finallymodels.py:1018-1049canceled_flag 为真 → status 一律 `stopped` + `task_stopped` 事件(含 has_running_sub_agents/has_running_background_commands 让前端决定停止按钮显隐);:1088 `stop_flags.pop(rec.task_id)` 清理。
- **后台任务/子智能体如何响应**主任务只停主智能体models.py:245-249 注释明确);后台命令与子智能体有独立 API/api/sub_agents/stop_all、/api/background_commands/stop_all`_cleanup_background_tasks`models.py:633-682负责把相关后台命令置取消状态。
### 3.2 取消寻址依据
- **寻址依据 = `username + task_id`**REST cancel 校验两条);底层 stop_flags 的**任务级 key 就是 client_sidREST 任务下即 task_id**,用户级 `user:{username}` 仅是 socket 索引不参与运行期判定state.py:135-159、记忆 stop_flags_per_task_isolation 确认)。
- 不依赖 terminal_idconversation_id 只用于副作用(停目标模式)。
### 3.3 耦合点清单(取消链路)
| # | 耦合对象 | 位置 | 说明 |
|---|---|---|---|
| C1 | **task_id=stop_flags 键)** | state.py:135-159、models.py:245-322、chat_flow.py:178 | 停止状态唯一真相REST 任务 task_id 即 client_sid |
| C2 | **asyncio loop/task 引用** | chat_flow.py:166-179entry 持有 loop+task、models.py:281-287硬取消用 | 每个任务独立事件循环process_message_task 内 `asyncio.new_event_loop`);硬取消 = loop.call_soon_threadsafe(task.cancel) |
| C3 | **checkpoint 散点** | 见 3.1 运行期检查点 | 软停止靠各检查点 100ms~每轮粒度生效 |
| C4 | `cancel_requested` 瞬态语义 | models.py:309-319、:1018-1035 | 活跃集合成员判定pending/running/cancel_requested收尾统一置 stopped |
| C5 | 后台任务独立性 | models.py:633-682、socket_handlers/API 层 | 停止主任务不自动停后台;公共服务需保留这两类独立控制入口 |
### 3.4 公共入口要支持取消需保留什么
- **判定:可直接复用**REST cancel 路径对调用方透明——只要持有 task_id
- 需保留/注意:
1. **task_id 必须回传并持久化给发起方**(定时任务 Occurrence 记录 run_id=task_id取消才可寻址
2. 保留 `process_message_task` 的「独立事件循环 + entry 持有 loop/task」模式——硬取消依赖它公共入口若换线程池/事件循环策略,需保持该契约;
3. 保留任务级 stop 键语义(不能让定时任务与同用户在线任务共享 stop 键);`cancel_task` 已是 task_id 精确取消,天然满足;
4. 移除/包装对 `session` 的依赖后,`cancel_task` 中取 terminal 的副作用路径get_user_resources要用显式上下文调用它已按 rec.workspace_id/conversation_id 构造,仅需注入 username 上下文)。
---
## 4. 保存链路(对话历史写入)
### 4.1 保存保护机制(三层)
- **merge-on-save防缩减**`save_conversation`crud_mixin.py:298-360merge 自 :335默认把磁盘旧消息与内存新消息**按 message_id 合并**`_merge_messages_by_id`:252-296磁盘独有保留、内存独有追加、无 message_id 防御性跳过并带守卫断言——merge 后消息数若缩减且非 `allow_shrink`(仅检查点恢复豁免)→ **拒绝保存**:355-360
- **I/O 锁**`_save_conversation_file`crud_mixin.py:194-203`_update_index`:205-244 读-改-写整体持锁)共用 `self._io_lock = threading.RLock()`base.py:47——锁在 ConversationManager 实例上,**同一对话同一 manager 内的并发写被串行化**(跨进程/跨实例不覆盖)。
- **原子替换**`_atomic_write_json`index_mixin.py:104-133同目录 NamedTemporaryFile唯一前缀+ json.dump + flush/fsync + `replace_with_retry`Windows 持锁退避),失败转存 `.last_failed.tmp` 留证。
### 4.2 任务执行写对话历史的入口是否唯一收敛
- **执行链写入口收敛**:任务执行内全部走 `web_terminal.context_manager.add_conversation(...)`message_mixin.py:127-229主任务各路径 chat_flow_task_main.py:1611/:1641/:1657/:1670/:1832/:2000/:2346工具循环 chat_flow_tool_loop.py:674/:743/:784/:826/:916/:1068/:1186/:1402注入路径 chat_flow_task_support.py:121/:455。`add_conversation` 每次 append 后**立即 `auto_save_conversation()`**:229 → conversation_mixin.py:385-414 → crud_mixin.save_conversation即每条消息落盘一次。
- **但存在执行链之外的第二批写者**(同一 conversation 文件的其他写入口,均复用同一 save_conversation 保护,但语义不同):
- 用户设置/恢复server/chat/settings.py:63/:164、server/conversation.py:1573、core/main_terminal_parts/commands.py:142/:189/:391CLI 命令交互、core/web_terminal.py:308terminal 内部管理、edit_summary.py:203、compression_mixin.py:203压缩续接、todo_annotation_mixin.py:131。
- 因此:**「任务执行写历史」收敛于 context_manager但「conversation 文件写」不是全局唯一收敛**——存在多处直接调 save_conversation/auto_save 的外部写者。保护正确性靠merge-on-save + 每对话 manager 级 _io_lock + 对话级主任务门闸main_task_gate.py一个对话同一时刻一个主任务三者叠加。
- 风险点:`_io_lock` 是 manager 实例级,**两个不同 ConversationManager 实例写同一对话文件**(如对话级 terminal 与工作区级服务实例双持同一对话)时锁不互斥;代码已有对策(对话级隔离 + create_chat_task 单对话互斥 + 主任务门闸),但这是「并行写保护依赖多道防线而非单一锁」的现状,新入口必须遵守同一套防线(见 4.3)。
### 4.3 耦合点清单(保存链路)
| # | 耦合对象 | 位置 | 说明 |
|---|---|---|---|
| S1 | **conversation_id文件/目录定位)** | conversation_manager 各 load/save | 保存寻址键 |
| S2 | **message_id合并键** | crud_mixin.py:252-296、message_mixin.py:142 | `_generate_message_id()` 生成;合并去重依赖其唯一性与新消息必带 |
| S3 | **manager 实例级 `_io_lock` (RLock)** | base.py:47、crud_mixin.py:194-203/:205-244 | 读-改-写互斥;跨实例不互斥 |
| S4 | **对话级主任务门闸** | main_task_gate.pyprocess_message_task 统一获取/释放chat_flow.py:139/:258 | 对话单写者约束,与保存锁互补 |
| S5 | **ContextManager 实例与对话绑定** | context.py:62-71缓存键含 conversation_id | 每对话独立 terminal/context_manager 是并发写隔离的前提 |
| S6 | **自动保存频率** | message_mixin.py:229每消息 auto_save | 长流式回复高频落盘;性能与原子写并存的既定取舍 |
### 4.4 新调用方复用是否有额外前提
- **判定:可直接复用**(写入口已收敛、保护机制完善),前提:
1. 必须走「对话级 terminal + 该对话专属 ContextManager」路径`get_user_resources(username, workspace_id, conversation_id)`不要落到工作区级服务实例否则双持同一对话时锁不互斥S3 边界);
2. 必须遵守主任务门闸:一次性主任务在 process_message_task 统一获取;若新调用方是「轮询器预占→移交 token」模式按通知链现有模式session_data["main_task_gate_token"]chat_flow_task_main.py:613-620复用
3. 定时任务的会话策略(使用独立会话 or 复用既有对话)决定消息写入哪个 conversation_id若复用用户对话注意与用户在线任务的互斥create_chat_task 单对话互斥会直接拒绝并发 chat 任务models.py:158-177——**这是现成防线,保持即可**)。
---
## 5. 结论:复用现有链路的总体可行性评估
1. **事件链路:直接复用(需适配层)**。核心载体 TaskRecorddeque+idx对调用方不可见调用方只需 `create_chat_task` 拿到 task_id 即可轮询/取消socket 推送按用户房间,离线无影响。适配点:消除对 Flask session 的隐式读取test_request_context 包装)、定义定时任务事件的来源标识(防前台干扰)。
2. **取消链路:直接复用**。task_id 精确取消已达成「寻址=username+task_id」不依赖 terminal/conversationconversation 仅副作用);保留「独立事件循环+entry 持 loop/task」即可支持硬取消。公共入口需把 task_id 持久化到 Occurrence。
3. **保存链路:直接复用,前提明确**。写入口收敛于 `context_manager.add_conversation`merge-on-save + 锁 + 门闸三道防线可迁移;新调用方必须(1)走对话级 terminal、(2)遵守主任务门闸、(3)会话策略决定 conversation_id。注意保存链路存在执行链之外的次要写者设置/压缩/CLI它们共用同一保护非执行链耦合。
4. **审批/提问链路:需改造**。存储 manager 可复用,但等待循环(1)默认超时 3600s 且调用点不传参、(2)超时语义=拒绝工具继续跑而未结束任务、(3)等待期间软停止无效、(4)manager 条目无 TTL。无人值守改造点按 §2.4注入超时参数session_data 透传)、定义超时语义(拒绝继续 or 结束任务)、等待循环加 stop 检查、条目清理、会话策略禁止人工审批或依赖 auto_approval 分支。
**总体**:以 `create_chat_task + run_chat_task_sync` 为骨架抽 RuntimeService 的方案成立——事件/取消/保存三链的标识符语义task_id/username/conversation_id已收敛且耦合点可枚举审批链路是唯一需要先改造语义超时再复用的链路。改造顺序建议审批超时策略 → 公共入口 → 定时任务派发(与 gateway_runtime_work_plan 三阶段路线一致)。
> **审阅注释2026-09-07实施顺序**:现有审批语义可在阶段二解耦上下文时先保持兼容;无人值守超时策略在阶段三实现前确定即可,不必阻塞公共入口整理。三条可复用支撑链也需核验异常回退是否仍有任务记录、事件和取消关联,不能把“可复用”理解为无需验证。
---
## 附:主要引用文件清单
- server/tasks/models.pyTaskRecord/deque/idx/清理、_append_event、sender、cancel_task、_run_chat_task 收尾)
- server/tasks/api.pypoll 端点、cancel 端点、running-status 对账)
- server/context.pymake_terminal_callback、attach_user_broadcast、_wrap_callback_with_conversation_id、get_user_resources
- server/state.pystop_flags/set/get/clear
- server/chat_flow.pyprocess_message_task、run_chat_task_sync
- server/chat_flow_task_main.pyhandle_task_with_sender、sender 包装、通知链 create_chat_task
- server/chat_flow_tool_loop.py三个 _wait_*、审批事件发送、工具期停止轮询)
- server/chat_flow_stream_loop.py / server/chat_flow_task_support.py运行期停止检查点
- server/socket_handlers.pyconnect/disconnect/stop_task
- server/chat/approval.py三个 pending/answer REST 端点)
- server/main_task_gate.py对话级主任务门闸
- modules/tool_approval_manager.py / plan_approval_manager.py / user_question_manager.py / auto_approval_service.py
- utils/context_manager/message_mixin.py、conversation_mixin.py
- utils/conversation_manager/crud_mixin.py、index_mixin.py、base.py

View File

@ -1,266 +0,0 @@
# Flask 隐式上下文依赖盘点session / request / g / has_request_context
- 分析日期2026-09-07子智能体 #2 静态只读分析,未修改任何文件)
- 分析范围:`server/`、`core/`、`modules/`、`utils/`(仅 `*.py`,排除注释行)
- 目的:评估「运行时边界整理」改造范围——区分**任务执行链路上的依赖(必须消除/显式化)**与**纯 HTTP 适配层的依赖(可保留)**
- 前置参考:`.astrion/memory/gateway_runtime_work_plan.md`(阶段二明确要求消除执行链路对隐式 Flask session 的读取,`server/tasks/models.py:785` 的 test_request_context 模式"只包装不算解耦"
---
## 0. 总体结论(先说结果)
**任务执行链路对 Flask 隐式上下文的依赖属于「浅层」**,但依赖**集中在两个枢纽函数**上,是显式化的主战场:
1. **入口**`server/tasks/models.py::create_chat_task`(直接读 `session` 做快照)+ `_run_chat_task`(用 `test_request_context()` 把后台线程包进隐式上下文,再调 `get_user_resources`)。
2. **资源获取**`server/context.py::get_user_resources`(及其内部 `_apply_workspace_personalization_preferences`、admin policy 应用),用 `has_request_context()` 守卫读写 session 的 host_mode / workspace_id / run_mode / thinking_mode / model_key / is_api_user。
**关键发现:真正的执行体是干净的。**
| 层 | Flask 隐式上下文使用 | 说明 |
|---|---|---|
| `core/`main_terminal*.py、web_terminal.py、main_terminal_parts/ | **零** | `session` 同名变量全部是「终端会话/容器句柄」ContainerHandle或局部 dict与 Flask 无关 |
| `modules/` | **零** | `request` 两处是 stdlib `urllib.request` 与 stdin 协议局部变量;`session` 全是 `container_session` |
| `utils/` | **零** | 无任何 Flask 导入;`session.get` 命中是格式化函数的 dict 参数 |
| `server/` 的非任务文件 | 大量(约 190 行) | 几乎全部在 HTTP 路由处理器/认证层内A 类,可保留) |
| `server/` 的任务链路文件 | **极少且集中在 2 个文件** | `tasks/models.py`15 行)+ `context.py`31 行)|
**特别地**`server/chat_flow*.py` 全部 9 个文件(含任务执行主体 `chat_flow_task_main.py`、`run_chat_task_sync`)在函数体内 **零** session/request/has_request_context 使用——只有 import 行。任务线程在 `test_request_context` 包裹退出后,后续整条执行链路都不再触碰 Flask 隐式上下文。
> **审阅注释2026-09-07R3 依赖口径)**:窄核验支持 core/modules/utils 无直接 Flask 导入但直接搜索不能证明所有间接调用都无上下文依赖。“浅层”描述的是依赖位置不等于改动风险低get_user_resources 的身份和资源分支需要行为验证,不能以删除 import 或零关键词命中代替验收。
---
## 1. 分类统计表
### 1.1 模式命中总量
| 模式 | 总命中 | A 类 | B 类 | C 类 | 假阳性(非 Flask |
|---|---|---|---|---|---|
| `from flask import session/request/g/…` | 37 个文件 | 34 文件 | 2 文件tasks/models.py、context.py+ auth_helpers.py | — | core/modules/utils 0 |
| `session[...]` / `session.get/pop/clear` 等 | ~202 行 | ~186 行 | ~16 行(见 §2 | 0 | core/commands.py 7、utils/tool_result_formatter 5 |
| `request.`args/json/headers 等) | 214 处 | 212 处 | 0 | 0 | modules/container_file_proxy.py:430stdin dict、modules/sandbox_setup_manager.py:454urllib |
| `has_request_context(` | ~17 处 | 0 | ~17 处(全部在 context.py | 0 | 0 |
| `test_request_context(` | 1 处 | 0 | 1 处tasks/models.py:794 | 0 | 0 |
| `g.`flask.g | 1 处 | 1 处api_auth.py:44 | 0 | 0 | 0 |
| `current_user` | 0 | — | — | — | 项目用自研 session 认证auth_helpers.py |
| 认证装饰器(@login_required/@api_login_required/@admin_* | 266 处 | 266 处 | 0 | 0 | — |
| @with_terminal(自定义资源注入装饰器) | 83 处 | 83 处 | 0 | 0 | 装饰器本身context.py读 request见 §2.2 |
> 说明:`@with_terminal` 是**适配层装饰器**context.py:685它读 `request.args` 解析 conversation_id 并调 `get_user_resources`83 处使用点全部是路由函数。
### 1.2 按目录分布
| 目录 | Flask 导入文件数 | session 读写行 | request. 行 | has/test_request_context | 判定 |
|---|---|---|---|---|---|
| server/ | 35 | ~190 | 212 | 17 / 1 | A 类为主 + B 类集中在 tasks/models.py、context.py |
| core/ | 0 | 0同名终端会话变量非 Flask | 0 | 0 | 干净 |
| modules/ | 0 | 0container_session | 0假阳性 | 0 | 干净 |
| utils/ | 0 | 0dict 参数) | 0 | 0 | 干净 |
### 1.3 按文件的 session 使用量Flask 真命中,排除假阳性)
| 文件 | session 读写行数 | 类别 | 备注 |
|---|---|---|---|
| server/auth.py | 46 | A | 登录/登出/会话状态路由 + 认证辅助函数get_current_user 等,供路由使用) |
| server/context.py | 31 | **B枢纽** | get_user_resources、personalization、ensure_conversation_loaded、with_terminal |
| server/status/host_workspace.py | 21 | A | 路由 |
| server/api_v1.py | 20 | A | 路由 |
| server/status/docker.py | 19 | A | 路由 |
| server/conversation.py | 16 | A | 路由处理器 + 3 个被路由调用的工具函数(见 §2.3 |
| server/tasks/models.py | 15 | **B入口+线程)** | create_chat_task 快照9+ _run_chat_task 上下文填充6 |
| server/status/base.py | 13 | A | 路由/状态采集 |
| server/app_legacy.py | 10 | A | 遗留路由 |
| server/chat/settings.py、auth_helpers.py、security.py、api_auth.py、tasks/api.py、tasks/skills.py、multi_agent.py、admin.py、conversation_bootstrap.py、chat/permission.py、status/sandbox.py | 1~7 | A | 路由 / 认证 / CSRF / API 认证中间件 |
| core/main_terminal_parts/commands.py | 7 | 假阳性 | 终端会话 dictlist_terminals 结果) |
| utils/tool_result_formatter/terminal.py | 5 | 假阳性 | dict 参数 |
---
## 2. B 类依赖详细清单(任务执行链路,必须消除或显式化)
### 2.1 强耦合点(无条件、无守卫)
#### B-1 `server/tasks/models.py:785-812``_run_chat_task` 的 test_request_context 包装 ★核心
```python
# 为后台线程构造最小请求上下文,填充 session
from server.app import app as flask_app
with flask_app.test_request_context():
try:
for k, v in (rec.session_data or {}).items():
if v is not None:
session[k] = v # 785-800把快照灌回隐式 session
if session.get("host_mode"): # 799-804host 模式对齐 workspace_id
session["workspace_id"] = workspace_id
session["host_workspace_id"] = session.get("host_workspace_id") or workspace_id
except Exception:
pass
terminal, workspace = get_user_resources(username, workspace_id=workspace_id, conversation_id=rec.conversation_id)
```
| 项 | 内容 |
|---|---|
| 文件:行号 | server/tasks/models.py:794`with flask_app.test_request_context():`、798-807session 填充与 get_user_resources |
| 读取/写入字段 | 写 session`session_data` 全量快照username/role/is_api_user/host_mode/host_workspace_id/workspace_id/run_mode/thinking_mode/model_key随后续 get_user_resources 读取);读 session`host_mode`、`host_workspace_id`、`workspace_id` |
| 用途 | ① 为后台线程伪造请求上下文,使 `get_user_resources`(含其内部 `get_current_user_record()/get_current_user_role()` 等无守卫 session 读)不抛 "Working outside of request context";② 把快照中的工作区定位配置host_mode/workspace_id还原给隐式 session |
| 显式化难度 | **中**。难点不在本函数(删除 wrapper 很容易),而在被调用的 `get_user_resources`B-4必须改为接收显式 principal + 会话快照参数。改造完成后此 wrapper 整体删除,任务线程直接以显式参数调资源获取 |
| 备注 | 这是项目记忆点名"只包装不算解耦"的现场。`with` 块在 `get_user_resources` 返回后**立即退出**,异常被 `except Exception: pass` 吞掉session 填充失败静默降级为空快照)——隐式依赖还带来"失败不可见"问题 |
#### B-2 `server/tasks/models.py:180-203``create_chat_task` 直接读 Flask session
```python
if session_data is not None:
snapshot = dict(session_data)
...
try: # 185-193有显式快照时仅 setdefault 兜底(已包 try/except
snapshot.setdefault("host_mode", session.get("host_mode"))
if snapshot.get("host_mode"):
snapshot.setdefault("host_workspace_id", session.get("host_workspace_id") or workspace_id)
except Exception: ...
else: # 194-203无显式快照时全量直读 session
try:
record.session_data = {
"username": session.get("username"),
"role": session.get("role"),
"is_api_user": session.get("is_api_user"),
"host_mode": session.get("host_mode"),
"host_workspace_id": session.get("host_workspace_id") or workspace_id,
"workspace_id": workspace_id,
"run_mode": session.get("run_mode"),
"thinking_mode": session.get("thinking_mode"),
"model_key": session.get("model_key"),
...
}
except Exception:
record.session_data = {}
```
| 项 | 内容 |
|---|---|
| 文件:行号 | server/tasks/models.py:185-193setdefault 兜底、194-203全量直读分支 |
| 读取字段 | username、role、is_api_user、host_mode、host_workspace_id、workspace_id、run_mode、thinking_mode、model_key |
| 用途 | 将 HTTP 会话上下文快照进 TaskRecord.session_data供后台线程还原认证身份 + 用户配置 + 工作区定位) |
| 调用方 | 全部是 HTTP 路由处理器chat_flow_task_main.py:613/1416、tasks/api.py:200、api_v1.py:320、workflow_runtime_api.py:184/264当前均天然有请求上下文 → 直读不报错 |
| 显式化难度 | **小~中**。公共任务入口的契约应改为「强制接收显式 session_data/principal 快照」,函数内禁止 fallback 读 session当前 `except Exception` 已兜底,改造只需删除 `else` 分支并把 setdefault 兜底改为纯快照合并。注意:今后若定时任务(阶段三)直接调 create_chat_task走的就是 else 分支会崩或静默空快照——必须在入口层消除 |
| 备注 | 此函数即项目记忆阶段二要抽的「公共任务入口」基座,是本次改造首当其冲的文件 |
### 2.2 枢纽函数(守卫型,任务链路与 HTTP 共用)
> **审阅注释2026-09-07调用方修正**:上一节 B-2 的“调用方全部是 HTTP 路由、均有请求上下文”与调用点子报告不一致:`chat_flow_task_main.py` 的完成通知和多智能体 idle 派发来自后台轮询,主要依靠显式 session_data。它们恰好证明非 HTTP 调用已存在,不能将无请求上下文仅视为未来定时器场景。
#### B-3 `server/context.py::get_user_resources`L221-535★核心
任务线程依赖的**唯一资源获取入口**,内部用 `has_request_context()` 守卫读写 session
| 行号 | 操作 | 字段 | 用途 |
|---|---|---|---|
| 239 | 读(守卫) | `host_mode` | host 工作区模式判定 |
| 246-247 | 读(守卫) | `host_workspace_id` / `workspace_id` | host 模式下工作区选择(多工作区并行) |
| 368-369 | 读(守卫) | `run_mode` / `thinking_mode` | 新建 terminal 时恢复用户运行模式配置 |
| 390-394 / 400-402 | 写(守卫) | run_mode / thinking_mode / workspace_id / host_workspace_id | 新建/复用 terminal 后回写 session 同步 |
| 440-441 | 写(守卫) | `model_key` | admin policy 禁用模型时回写 |
| 462 | 读(守卫) | `is_api_user` | 路由到 api_user_manager vs user_manager |
| 473 | 读(守卫) | `workspace_id` | 常规用户工作区选择 |
| 495-496 | 读(守卫) | `run_mode` / `thinking_mode` | 新建 terminal 恢复配置(常规分支) |
| 531-535 | 写(守卫) | run_mode / thinking_mode / model_key / workspace_id | 新建 terminal 后回写 |
| 407 / 465-466 / 520-521 | 读(**无守卫** | `get_current_user_record()` / `get_current_user_role()` → session.username/role | admin policy 应用、user_role 赋值 |
| 项 | 内容 |
|---|---|
| 用途 | 认证record/role → admin policyuser_role用户配置run_mode/thinking_mode/model_key工作区定位workspace_id/host_workspace_id/host_modeAPI 用户识别is_api_user |
| 为什么任务链路依赖它 | `_run_chat_task` 调它时必须已有请求上下文(否则 407/465/520 行无守卫读崩)→ 因此 B-1 的 test_request_context 是为它服务的 |
| 显式化难度 | **中**。改造路径:函数签名增加 `principal`username + user_record + role + is_api_user`session_snapshot`workspace_id/host_workspace_id/host_mode/run_mode/thinking_mode/model_key参数所有 `session.get(...) if has_request_context() else ...` 分支改为 `snapshot.get(...)`;所有回写(`if has_request_context() and update_session`在适配层路由中保留、在纯任务路径删除。涉及分支较多host/docker/api 三套),建议先建参数化版本再逐个替换调用方 |
| 调用方分布 | HTTP 路由约 170+ 处(含 @with_terminal 83 处、各路由直接调用)+ 任务线程 1 处tasks/models.py:809 |
#### B-4 `server/context.py::_apply_workspace_personalization_preferences`L116-178
| 行号 | 操作 | 字段 | 用途 |
|---|---|---|---|
| 121-122 | 读(守卫) | `model_key` | 恢复会话级模型选择(不覆盖对话绑定模型) |
| 175-178 | 写(守卫) | run_mode / thinking_mode / model_key | 应用偏好后回写 session |
| 项 | 内容 |
|---|---|
| 调用链 | `get_user_resources` 末尾调用L450、L540→ 任务线程经 B-1 的 wrapper 进入 |
| 显式化难度 | **小**。增加显式 `session_model: Optional[str]` / `update_session` 参数即可;守卫分支改为参数判断。任务线程经 wrapper 时读到的是灌入快照的 session语义等价于显式参数 |
#### B-5 `server/context.py::ensure_conversation_loaded`L750-810
| 行号 | 操作 | 字段 | 用途 |
|---|---|---|---|
| 761-763、796-799 | 写(守卫 has_request_context | run_mode / thinking_mode / model_key | 加载/新建对话后把 terminal 最新模式回写 session |
| 项 | 内容 |
|---|---|
| 调用方 | `chat_flow_task_main.py` 导入并在任务受理路径调用;也被路由使用 |
| 关键点 | 任务线程中该函数在 `test_request_context` 块**之外**执行 → `has_request_context()` 为 False → 写操作自然跳过,不崩溃。但**语义上仍是隐式耦合**(回写是"刷新用户会话"的副作用语义,应在适配层做或在改造中明确删除) |
| 显式化难度 | **小**。把 session 回写上移到 HTTP 路由处理器;或参数化 `update_session` |
#### B-6 `server/auth_helpers.py:35-49` — 认证辅助被任务链路间接使用
| 函数 | 行号 | 字段 | 用途 | 何时被任务链路触发 |
|---|---|---|---|---|
| `get_current_username` | 35-36 | session.username | 用户身份 | get_user_resources 传了显式 username → 不触发;但不传时会触发 |
| `get_current_user_record` | 38-40 | session.username → user_manager | 用户记录 → admin policy | **get_user_resources 内 L407/465 无守卫调用 → 任务线程必触发** |
| `get_current_user_role` | 42-49 | session.role | 角色 | get_user_resources 内 L466/520 触发 |
| 项 | 内容 |
|---|---|
| 显式化难度 | **中**。这些函数本身是 HTTP 适配层工具(保留),但 get_user_resources 内部的调用必须改为接收显式 `record`/`role`create_chat_task 快照里已有 username/role可在线程入口恢复 record 快照) |
| 备注 | 认证装饰器 `login_required`(L17-25) / `api_login_required`(L27-32) / `admin_*` 属于纯适配层 → A 类,保留 |
### 2.3 C 类说明(需看调用方——核查后均归 A
| 位置 | 函数 | 读的字段 | 调用方核查结果 | 归类 |
|---|---|---|---|---|
| server/conversation.py:275-276 | `_is_host_mode_request` | session.host_mode | 仅被路由处理器链调用versioning 作用域判断等);任务链路不调 server/conversation.py | **A**(可保留) |
| server/conversation.py:298-306 | `_resolve_input_draft_path` | host_workspace_id / workspace_id | 仅 get_input_draft/upsert_input_draft 两个路由调用 | **A** |
| server/conversation.py:529-540 | `_resolve_target_terminal_for_workspace` | workspace_id | 仅 get_conversations/create_conversation/load_conversation 等路由调用 | **A** |
| server/context.py:685-719 | `with_terminal` 装饰器 | request.args/request.is_json/get_json + get_current_username | 83 处全部是路由函数 | **A**(装饰器本身是适配层) |
| server/socket_handlers.py | `request` 42 处 | request.args/json/sid | socketio 事件处理器flask-socketio 提供请求上下文,属于 Web 实时适配层) | **A** |
| core/main_terminal_parts/commands.py:658-665 | `session["is_running"]` 等 | 终端会话 dict | `list_terminals()` 返回结果,非 Flask | **假阳性** |
| modules/container_file_proxy.py:427-430 | `request.get("payload")` | stdin JSON dict | 子进程协议局部变量 | **假阳性** |
---
## 3. 特别检查core/main_terminal.py 与 main_terminal_parts/ 的「session」
任务描述要求核查 WebTerminal 的 session 属性。结论:**WebTerminal 没有 `self.session` 属性**,任务中遇到的所有 `session` 都不是 Flask
| 位置 | 形式 | 真实对象 |
|---|---|---|
| core/main_terminal.py:117-139 | `self.container_session`(构造参数注入) | `ContainerHandle`(容器句柄,来自 modules/user_container_manager |
| core/main_terminal.py:248-251 `_apply_container_session` | 参数 `session: ContainerHandle` | 容器句柄:读 `session.mode` / `session.mount_path` |
| core/main_terminal.py:497-509 `update_container_session` | 参数 `session` | 容器切换入口,透传给 terminal_manager/terminal_ops/file_manager/mcp_client_manager/sub_agent_manager |
| core/main_terminal_parts/commands.py:552-557 | `session = getattr(self, "container_session", None)` | 容器句柄:读 `session.mode` / `container_name` / `sandbox_bin` |
| core/main_terminal_parts/commands.py:658-665 | `for session in result["sessions"]` | `list_terminals()` 返回的会话快照 dict |
| core/main_terminal_parts/tools_execution.py:129-130,177-178tools_definition/base.py:94-95context/mode.py:155 | `getattr(self, "container_session", None)` / `_session` | 容器句柄:判定 docker mode |
| modules/terminal_manager.py:157,173-175toolbox_container.py:52-54,94-97file_manager/base.py:81-82 | `session.container_name/mount_path` | 容器句柄 |
**结论**任务执行体core/、modules/)对"会话"的引用全部是**显式对象属性**`terminal.container_session`、dict 参数),与 Flask 的 `session`/`request` 零关联。任务链路唯一的隐式依赖只存在于「入口 + 资源获取」§2底层执行引擎无需任何改动。
---
## 4. 改造范围建议(供主智能体决策,不实施)
按「依赖深度由浅入深」排序:
1. **入口契约**B-2`create_chat_task` 强制显式 `session_data`,删除 `else` 直读 session 分支 + setdefault 兜底 → **小改动**,立即解除"公共入口 = 必须 HTTP 上下文"的耦合。
2. **资源获取参数化**B-3/B-4配套 B-1/B-6`get_user_resources` 增加 `principal` + `session_snapshot` 显式参数,内部 `has_request_context` 分支改为快照分支;`_apply_workspace_personalization_preferences` 参数化 → **中改动**,是本次改造的主体。
3. **删除 test_request_context 包装**B-1步骤 2 完成后直接删除,任务线程全程无隐式上下文 → 验证点:`_run_chat_task` 不再 import flask。
4. **回写副作用清理**B-5`ensure_conversation_loaded` 的 session 回写上移到适配层(或用参数关掉),避免任务链路产生"写 session"语义。
5. **适配层保留**auth.py、security.pyCSRF、api_auth.pyg.api_username、chat/*、status/*、conversation.py 路由、auth_helpers 装饰器、@with_terminal、socket_handlers.py —— 全部 A 类,不动。
**依赖深度评估:浅层**。命中点统计B 类真正的"无条件耦合"仅 2 处tasks/models.py 的创建入口与线程包装),守卫型耦合集中在 context.py 一个文件;执行链路 9 个 chat_flow 文件 + core/* + modules/* + utils/* 全部干净。显式化的关键是「快照 + principal 显式传递」,无需动 Agent loop。
---
## 5. 附B 类一键核对清单(改造后可回归验证)
> **审阅注释2026-09-07R4 验收范围)**:以下搜索可作辅助检查,不应要求整个 server/context.py 零 has_request_contextHTTP 适配层保留合法请求上下文与本报告的适配层保留原则一致。应验证已迁移的公共执行路径不再依赖请求上下文,并覆盖 host/Web/API 资源选择、显式参数与默认值优先级、正常与异常入口。
- [ ] `grep -rn "test_request_context" server core modules utils` → 0 命中
- [ ] `grep -rn "session\[" server/tasks/models.py` → 0 命中(入口与线程)
- [ ] `grep -rn "has_request_context" server/context.py` → 0 命中(改显式参数后)
- [ ] `_run_chat_task` 线程主体:`from flask` / `session` 0 引用
- [ ] `create_chat_task``session_data` 参数时拒绝受理(而不是静默读 session 或 try/except 吞错)

View File

@ -1,102 +0,0 @@
# Gateway 化改造范围与复杂度评估(汇总)
> 日期2026-09-07
> 依据:主智能体核心链路精读 + 4 个子智能体只读盘点(静态分析,未做运行复现)
> 子报告eval_task_entry_points/、eval_flask_context_deps/、eval_endpoint_classification/、eval_event_approval_coupling/
> 对照计划:../gateway_work_plan.md三阶段路线
> **审阅注释2026-09-07阅读说明**:以下保留原始评估,通过注释标出修正和设计边界。总体认可三阶段路线:阶段二是范围可控、少数枢纽风险较高的重构,阶段三是主要新增工作量;静态分析与端点计数不能替代行为验收。对照计划实际位于同目录 `gateway_work_plan.md`。下一步建议编写 `docs/runtime_contract.md`,不再扩大端点普查。
## 0. 核心结论(直接回答「工作量是否非常大」)
**「196 个 API 端点需要 Gateway 化」是误解。** 逐一读完 196 个端点函数体后确认:
| 类别 | 数量 | 占比 | 是否迁移 |
|---|---|---|---|
| T1 任务受理 | 3 | 1.5% | ✅ 必须POST /api/tasks、/api/v1/.../messages、/api/workflow/activate |
| T2 任务控制 | 13 | 6.6% | ✅ 必须cancel×2、runtime_guidance/queue×4、workflow deactivate、sub_agents/background 停止×3、审批回答×3 |
| T3 任务观察 | 14 | 7.1% | ⚠️ 可不动(只读 REST轮询协议天然可复用 |
| C CRUD | 131 | 66.8% | ❌ 不动 |
| S 状态查询 | 20 | 10.2% | ❌ 不动 |
| A 认证管理 | 15 | 7.7% | ❌ 不动 |
**必须迁移的最低集合 = 16 个端点8.2%**,且它们全部收敛到同一批 manager 单例方法task_manager / sub_agent_manager / background_command_manager / 三个 approval manager。Gateway 化的实质 = **把这几个 manager 方法提升为 RuntimeService 公共入口**HTTP 端点从「直接调 manager」改为「调 RuntimeService」而不是改写端点本身。
范围边界(按 R1 修订16 个是当前分类下的受理/控制端点集合,**不是架构改造完成的充分条件**。`server/chat/permission.py` 的权限/执行环境/网络权限变更端点在任务运行中会向运行态排队生效,必须进入阶段一的状态责任表;其余 166 个端点应描述为「多数可保持 HTTP 兼容不动」,不能概括为与运行时零耦合。查询端点可保留原 URL但 RuntimeService 应同时暴露内部查询接口(如 `get_task_events`后台调用方CLI/定时任务)不必为观察任务再发 HTTP 请求。新增门面也不等于全部写入口已收敛。
> **审阅注释2026-09-07R1 范围)**16 个是当前分类下的受理/控制端点集合,不是架构改造完成的充分条件。`server/chat/permission.py` 的权限、执行环境和网络权限变更会向运行态排队,必须进入状态责任表;其余端点应描述为“多数可保持 HTTP 兼容”,不能概括为与运行时零耦合。查询端点可保留原 URL但后台调用方应能通过内部接口查询不必为了观察任务再发 HTTP 请求。新增门面也不等于全部写入口已经收敛。
迁移复杂度分布16 个,按端点子报告明细表核对):**12 小 / 3 中 / 1 大**(唯一的大项 = workflow activate因门闸 token 移交 + 状态机编排)。过渡方案是只下沉 Task 创建调用、编排留 HTTP 层;按 R2会话补建、激活、门闸移交和失败回滚属于业务流程长期应按复用需求下沉到工作流服务供非 HTTP 入口调用(无需全部塞入 RuntimeService
> **审阅注释2026-09-07R2 计数与编排)**11 + 3 + 1 = 15按端点子报告详细表应为 **12 小 / 3 中 / 1 大 = 16**这仍是定性估算。Workflow 编排留在 HTTP 层只适合作为过渡:会话补建、激活、门闸移交和失败回滚属于业务流程,应按复用需求下沉到工作流服务,供非 HTTP 入口调用;无需全部塞入 RuntimeService。
Socket.IO 侧10 个事件中仅 `stop_task`T2活跃`send_message` 已短路废弃(死代码),聊天主交互全走 REST。
## 1. 关键利好:代码现状比预期更适合改造
1. **任务链路 Flask 依赖是「浅层入口型」**B 类(必须消除)仅 6 项,集中在 2 个文件——`server/tasks/models.py`15 行)+ `server/context.py`31 行)。执行体完全干净:`core/`、`modules/`、`utils/` 零 Flask 依赖9 个 `chat_flow*.py` 文件函数体内零 session 使用WebTerminal 无 `self.session` 属性(同名变量全是容器句柄)。
2. **执行链已是单一收敛的(限正常受理路径)**5 类来源、6 处调用位置共用 `create_chat_task → 线程 → _run_chat_task → run_chat_task_sync → process_message_task → handle_task_with_sender` 一条链。例外(按 R3 补充):`chat_flow_task_main.py:645-675` 存在异常回退——任务创建失败后直接 `handle_task_with_sender`**绕过 TaskRecord 登记**(无事件 deque、不可按 task_id 轮询/取消);该回退在外围轮询器预占门闸保护下运行,不能仅凭此认定并发写入 bug但**迁移验收必须覆盖回退路径的记录、事件、取消和门闸生命周期**。socket `send_message`socket_handlers.py:259已短路返回 DEPRECATED属死代码与该活跃回退是两回事。另按 R3`core/modules/utils` 无直接 Flask 导入已确认,但不能外推为所有间接调用都不依赖请求上下文,需回归验证兜底。
> **审阅注释2026-09-07R3 依赖与异常路径)**:本轮窄核验确认 `core/modules/utils` 无直接 Flask 导入,但不能外推为所有间接调用都不依赖请求上下文。“无旁路”须限定为正常受理路径:`server/chat_flow_task_main.py:645675` 在任务创建失败后直接执行 `handle_task_with_sender`,绕过新的 TaskRecord 登记;外围轮询器有预占门闸,因此不能仅凭回退认定并发写入 bug。迁移验收必须覆盖异常回退的记录、事件、取消和门闸生命周期。Socket `send_message``server/socket_handlers.py:259` 已短路返回应与该活跃回退分开。“5 个调用点”实际是 5 类来源、6 处调用位置。
3. **签名已大部分显式化**`create_chat_task` 15 个参数、`process_message_task(terminal, message, sender, workspace, username, gate_token...)` 已是显式签名。
4. **门闸是独立干净组件**main_task_gate.py 73 行,挂 terminal 对象,零 Flask 依赖),直接复用。
5. **支撑链路 3/4 可直接复用**事件task_id+idx+用户房间、取消username+task_id 寻址+硬取消、保存merge-on-save + I/O 锁 + 门闸三道防线)——耦合键语义已收敛为 task_id/username/conversation_id无 terminal_id 耦合。
## 2. 真实工作量构成
### 阶段一:固定契约(文档为主)
- 产出 `docs/runtime_contract.md` + 状态责任表 + 概念对齐 + 调用方迁移表 + 回归用例
- **复杂度:低**。不写生产代码,但需要精读现状(本次盘点已完成大部分素材积累)
### 阶段二:公共任务入口(核心改造)
| 改动项 | 位置 | 复杂度 |
|---|---|---|
| 新建 RuntimeContext + RuntimeService 接口。RuntimeContext 按 R4 分三层:**可信身份与资源范围**username/workspace_id/host_mode/host_workspace_id/is_api_user/role/ **本次任务参数**run_mode/thinking_mode/model_key/message 等)/ **内部执行信息**(门闸 token、通知回滚数据——**不得成为普通客户端可提交字段**);明确默认值、对话配置与本次覆盖的解析优先级,避免仅将 session_data 大字典换名 | 新文件 | 小 |
| create_chat_task 快照显式化(删除 session 直读 else 分支与 setdefault 兜底,无上下文时拒绝受理而非静默空快照) | models.py:177-209 | 中 |
| 拆除 test_request_context 桥 | models.py:794-810 | 中 |
| **get_user_resources 参数化**host/docker/api 三分支is_api_user/host_mode 选错即静默串工作区) | context.py:221-535 | **中~大(最高风险)** |
| 迁移 6 个调用点到 RuntimeContext | tasks/api.py、api_v1.py、workflow_runtime_api.py×2、chat_flow_task_main.py×2 | 各小~中 |
| 审批超时参数透传(调用点补传 timeout_seconds | chat_flow_tool_loop.py:435/574/883/1153 | 小 |
| 配套:`_apply_workspace_personalization_preferences` 参数化、`ensure_conversation_loaded` 回写上移、auth_helpers record/role 显式化 | context.py、auth_helpers.py | 小 |
| 回归测试(同对话并发/跨对话隔离/保存不丢消息/取消/审批重复回答/偏移恢复) | test/ | 中 |
- **复杂度:中**。触及生产代码约 6-8 个文件(估算),每处改动有明确的回退策略(先加显式参数变体、保留兼容期、再拆桥——两步法)。按 R4 补充:资源解析 `get_user_resources` 有约 170+ 调用处(含 83 处 @with_terminal 装饰器路径),影响面需通过 host/Web/API 身份 × 不同会话 × 不同默认值来源的回归验证覆盖,不宜承诺每处改动都小。
> **审阅注释2026-09-07R4 上下文设计与工作量)**9 字段是旧 session 依赖的搬迁清单,不是最终领域模型。至少区分“可信身份与资源范围”“本次任务参数”“内部执行信息”;门闸 token、通知回滚等内部信息不得成为普通客户端可提交字段。明确默认值、对话配置与本次覆盖的解析优先级避免仅将 session_data 大字典换名。68 个文件属于估算,资源解析有约 170+ 调用处(含装饰器路径,见上下文子报告),影响面需通过 host/Web/API 身份、不同会话、不同默认值来源的回归验证;不宜承诺每处改动都小。
### 阶段三:定时任务(净新增子系统)
| 新增项 | 说明 | 复杂度 |
|---|---|---|
| Schedule/Occurrence 持久化 | 选型文件或 SQLite先列原子更新/唯一性/查询/恢复要求),走运行态路径 | 中 |
| 调度器循环 | 单活动所有权防多进程重复派发tick 扫描到期 Occurrence | 中 |
| 幂等与恢复 | 触发标识 = schedule_id+计划时间点;崩溃窗口对账;重启恢复计划与记录 | 中~大 |
| 审批无人值守语义 | 超时注入 + 超时语义决策(拒绝工具继续 vs 结束任务,**产品决策**+ 等待循环 stop 检查 + 条目 TTL | 中 |
| 触发记录查询端点 | 新增 T3 类端点(旧任务清理/重启后仍能解释触发结果) | 小 |
| 前端 UI | 计划管理界面(创建/暂停/恢复/删除/触发历史) | 中 |
- **复杂度:中~大**。全新代码,但可与阶段二解耦验证(可控时钟 + 执行替身)
## 3. 风险与难点排序
1. **get_user_resources 参数化**阶段二——host_mode / is_api_user 分支选错会**静默串工作区**,是全改造最高风险点。缓解:先加显式参数变体与 web 路径并存,逐个调用方迁移。
2. **门闸 token 移交语义**阶段二——「预占→session_data 移交→线程认领→finally 释放/失败回滚」是跨线程隐式协议RuntimeContext 必须原样承载。
3. **`task_type="notice"` 互斥豁免**(阶段二)——多智能体/完成通知链路依赖它跳过单对话互斥,重排互斥规则会引发并发回退。
4. **审批超时语义**(阶段三,按 R5 修订)——拒绝一个工具后继续运行不自动意味着不安全(后续动作仍受权限约束);等待人工、到期终止、拒绝当前动作后继续是不同产品策略,「定时任务禁止人工审批」不是必选技术条件;工具审批、计划审批、用户提问应分别定义超时含义。**该决策不阻塞阶段二**(阶段二上下文重构保持原有语义即可先行)。
> **审阅注释2026-09-07R5 审批产品边界)**:拒绝一个工具后继续运行不自动意味着不安全,后续动作仍受权限约束。等待人工、到期终止、拒绝当前动作后继续是不同产品策略,不能把“定时任务禁止人工审批”当作必选技术条件。工具审批、计划审批、用户提问也应分别定义超时含义。超时策略可在阶段三明确,不必阻塞保持原有语义的阶段二上下文重构。
5. **事件前台干扰**(阶段三)——定时任务事件会推到在线用户的 socket 房间,需加 source 字段或确认产品预期。
6. **三套身份取数来源统一**阶段二——web session / token session / web_terminal 属性,语义等价但路径不同,需收敛为 `RuntimeContext.from_*` 构造族。
## 4. 附带发现(与改造无直接依赖,建议独立处理)
- **疑似 bug静态疑点待运行时验证**:审批条目的 `task_id` 字段实际恒 None——`getattr(web_terminal, "task_id", None)`chat_flow_tool_loop.py:411/545/845/1115全仓无赋值点。高置信推断未排除动态 setattr按 R6 应验证运行时载荷后定性。影响:审批无法按 task_id 检索关联。
- **静默降级隐患**`create_chat_task` 无 session_data 时 `except Exception` 吞错后得到空快照——阶段三定时任务若直调旧入口会静默丢身份。阶段二的显式化会顺带消除。
- **审批等待期间软停止无效(按 R6 限定范围)**:标准停止按钮走 REST 硬取消,**可以**打断审批等待缺口仅限「仅设置软停止标志」的路径socket `stop_task` 软 stop下一次工具调用行首检查才生效。另需跟进超时/取消后 pending 条目的终态更新、审批与 Run 的关联;条目清理不能只靠 TTL 删除仍有合法等待者的请求。
> **审阅注释2026-09-07R6 取消与生命周期)**:支撑链报告 §3.1 明确标准停止按钮走 REST 硬取消,能够打断审批等待;缺口应限定为仅设置软停止标志的路径,不能描述成所有停止按钮失效。另需跟进超时/取消后的 pending 终态更新和审批与 Run 的关联task_id 恒 None 目前仍是静态疑点,应验证运行时载荷后定性。条目清理不能只靠 TTL 删除仍有合法等待者的请求。
## 5. 建议实施顺序
1. 阶段一契约文档(本次盘点报告可直接作为素材底稿)
2. 阶段二两步走:① RuntimeContext三层分离+ 显式受理签名(保留 test_request_context 兼容兜底)→ ② get_user_resources 参数化后拆桥。**审批超时语义决策不阻塞本阶段**(保持原有语义,仅建立参数透传机制)
3. 阶段三定时任务(可控时钟 + 执行替身先行验证,再接真实入口;审批/提问/计划三类超时含义在本阶段分别定义)

View File

@ -1,248 +0,0 @@
# create_chat_task 调用点盘点与「显式运行时上下文」迁移难度评估
- 分析范围:`server/` 目录(只读分析,未修改任何文件)
- 对象:`TaskManager.create_chat_task``server/tasks/models.py:132`)的全部 5 处真实调用点
- 目标:评估「抽 RuntimeService 公共入口(显式运行上下文)」的改造范围与难度
- 结论可信度:代码级事实(函数/行号/参数)经逐一阅读,**百分百确定**;迁移难度与工作量为分析师判断(清晰标注为估计)
> **审阅注释2026-09-07证据口径**:本报告为静态分析,“百分百确定”不适用于可达性、间接依赖和完整行为。下表按 5 类来源列出 6 处调用;数量应统一按“类别”或“代码位置”表述。下面对 Socket 活跃性的判断已有交叉核验修正。
---
## 0. 一张图看懂执行链5 个调用点共用)
```
调用点(5 处)
└─> task_manager.create_chat_task(...) models.py:132
├─ ① 参数归一化 + 单对话互斥检查 models.py:151-172
├─ ② 构造 TaskRecord + session 快照 models.py:174-209 ← 隐式上下文在这里被固化
└─ ③ threading.Thread(_run_chat_task).start() models.py:212 ← 每个任务一个 daemon 线程
└─ _run_chat_task(rec, images, videos, files) models.py:785-1096
├─ test_request_context + 灌 session + get_user_resources models.py:794-810 ← 唯一 Flask 隐式依赖
├─ ensure_conversation_loaded / 模式覆盖 ...
└─ run_chat_task_sync(...) models.py:985
└─ process_message_task(...) chat_flow.py:133run_chat_task_sync = process_message_taskchat_flow.py:280
└─ loop.create_task(handle_task_with_sender(...)) chat_flow.py:159-166
└─ handle_task_with_sender(...) chat_flow_task_main.py:1478 ← 真正的执行主循环
└─ 尾部按需 spawn 两个通知轮询线程socketio.start_background_taskL2643 / L2695
```
**要点**5 处调用点中**没有任何一处直接 spawn `handle_task_with_sender`**——全部经由
`create_chat_task → 线程 → _run_chat_task → run_chat_task_sync → process_message_task → asyncio.create_task(handle_task_with_sender)`
只有两处例外(非本次 5 点范围,但相关):
- `chat_flow_task_main.py:664``_dispatch_completion_user_notice` 在 `create_chat_task` 抛异常时的**回退路径**,用 `asyncio.create_task(handle_task_with_sender(...))` 直接在当前事件循环执行;
- `socket_handlers.py:345``start_chat_task`chat_flow.py:267`socketio.start_background_task(process_message_task, ...)`WebSocket 实时消息**绕开 task_manager**、直连执行链的并行路径。
> **审阅注释2026-09-07R3 可达性修正)**`server/socket_handlers.py:259` 已直接返回 `DEPRECATED`,上述 Socket 路径属于不可达遗留代码,不应计为当前活跃入口。完成通知异常回退则仍可达(`server/chat_flow_task_main.py:645675`),会绕过新的任务记录;外围已有门闸预占,不能仅据此断言并发写入错误。迁移时应明确异常回退的受理记录、取消关联和门闸收尾,而非默认原样保留直接执行即可。
---
## 1. 五个调用点逐一分析
### 1.1 调用点①:`server/tasks/api.py:200`create_task_apiPOST /api/tasks
| 维度 | 内容 |
|---|---|
| 触发来源 | **Web HTTP POST 请求**Flask 路由,真实 request context 内)。`@tasks_bp.route("/api/tasks")`L110、`@api_login_required`L111、`@rate_limited("chat_task_create",30,60)`L112、`def create_task_api`L114 |
| 隐式上下文信息 | ① `username = get_current_username()`L115 `session["username"]`auth_helpers.py:42-44`workspace_id = session.get("workspace_id") or "default"`L116**未传 `session_data`**`create_chat_task` 内部直接快照真实 sessionmodels.py:194-205`username / role / is_api_user / host_mode / host_workspace_id / workspace_id / run_mode / thinking_mode / model_key`;④ 请求体全来自 `request.get_json()`L117 |
| 传给 create_chat_task 的参数 | L200-212`username, workspace_id, message, images, conversation_id, videos=, model_key=, thinking_mode=, run_mode=, max_iterations=, message_source=, goal_mode=, skill_context_messages=, files=` |
| 其中来自隐式上下文的参数 | `username`session、`workspace_id`session、以及整个 session 快照models.py:194-205`record.session_data` |
| handle_task_with_sender 触发方式 | 不自接 spawn`create_chat_task`L212 线程)→ `_run_chat_task``run_chat_task_sync``process_message_task``loop.create_task(handle_task_with_sender)`chat_flow.py:159-166 |
| 迁移难点 | **小~中**。请求体字段已全部显式只需把「session 快照」models.py:194-205改为构造显式上下文对象。注意 `get_current_username()` 不保证非空None 时 create_chat_task 会照常登记,属既有行为,需在公共入口统一校验)。`role / is_api_user / host_mode` 三字段必须从 session 取出传入,否则后台线程会退化为网页默认语义 |
### 1.2 调用点②:`server/api_v1.py:320`send_message_apiPOST /workspaces/\<workspace_id\>/messages
| 维度 | 内容 |
|---|---|
| 触发来源 | **Web HTTP POST 请求**Bearer Token API。`@api_v1_bp.route(...)`L256、`@api_token_required`L257、`@rate_limited("api_v1_send_msg",20,60)`L258、`def send_message_api`L259 |
| 隐式上下文信息 | ① `api_token_required`api_auth.py:47-49**`session["username"]`、`session["role"]="api"`、`session["is_api_user"]=True`** 写入 session复用现有上下文/工作区逻辑);② `username = session.get("username")`L260`workspace_id` 来自 URL 路径(`_resolve_workspace` L221-225 → `state.api_user_manager.ensure_workspace`);④ **未传 `session_data`**`create_chat_task` 快照 sessionmodels.py:194-205其中 `is_api_user=True、role="api"` 是 API 语义的关键字段 |
| 传给 create_chat_task 的参数 | L320-329`username, workspace_id=ws.workspace_id, message, images, conversation_id, model_key=, thinking_mode=, run_mode=, max_iterations=`**无 session_data、无 files/videos** |
| 其中来自隐式上下文的参数 | `username`session、`role/is_api_user/host_mode/host_workspace_id/workspace_id/run_mode/thinking_mode/model_key`session 快照models.py:194-205 |
| handle_task_with_sender 触发方式 | 同 1.1(线程 → run_chat_task_sync → loop.create_task |
| 迁移难点 | **中**。最大风险:`is_api_user=True / role="api"` 必须显式进入运行时上下文,否则后台任务线程内 `get_user_resources`context.py:462会把 API 用户当网页用户走 `user_manager` → 工作区解析错乱或抛错。另外 API 用户的 `get_user_resources` 要求 `workspace_id` 非空context.py:465-468本入口已满足。`terminal.user_role="api"`context.py:525/538还会影响配额/角色语义,上下文必须携带 `role` |
### 1.3 调用点③:`server/workflow_runtime_api.py`工作流入口2 处子调用)
**3aL184api_activate_workflowPOST /api/workflow/activate**
| 维度 | 内容 |
|---|---|
| 触发来源 | **Web HTTP POST 请求**slash 菜单激活。routeL38、`@api_login_required`L39、`@with_terminal`L40、`def`L41。真实 request context 内 |
| 隐式上下文信息 | ① `username``@with_terminal`context.py:685-711`get_current_username()`session`terminal/workspace``@with_terminal` 内 `get_user_resources`context.py:699——该函数自身大量读 session`host_mode` L239、`host_workspace_id/workspace_id` L246-247、`is_api_user` L462、`run_mode/thinking_mode` L368-369、`model_key` L122`session_data` **显式构造**L168-183username、message_source="workflow"、main_task_gate_token、auto_user_message_event、auto_user_message_payload——但**未含 host_mode** → `create_chat_task` L185-187 仍从真实 session `setdefault("host_mode", session.get("host_mode"))` 读取 |
| 传给 create_chat_task 的参数 | L184-192`username, workspace_id(workspace.workspace_id), prompt, [], conversation_id, message_source="workflow", session_data=session_data` |
| 其中来自隐式上下文的参数 | `username`session`session_data["host_mode"]`create_chat_task L185 从真实 session 补gate token 预占在真实 request context 内完成try_acquire_main_task_gate |
| handle_task_with_sender 触发方式 | 同主链路create_chat_task → 线程) |
| 迁移难点 | **小~中**。已约 80% 显式化。剩余host_mode 的 session 读取L185、以及「门闸 token 预占 → 随 session_data 移交 → 任务线程认领释放」main_task_gate.py的隐式语义必须保留在运行时上下文中 |
**3bL264api_deactivate_workflowPOST /api/workflow/deactivate**
| 维度 | 内容 |
|---|---|
| 触发来源 | **Web HTTP POST 请求**(用户主动"停止工作流"。routeL210、`@api_login_required`L211、`@with_terminal`L212、`def`L213。内部先 `wsm.poll_notices()` 取柔性通知;**仅当主任务门闸可预占**try_acquire_main_task_gate 成功)时立即派发一轮任务,否则通知留池(多一轮轮询器/工具循环消费) |
| 隐式上下文信息 | 同 3ausername/terminal/workspace 来自 `@with_terminal``session_data` 显式L251-262含 main_task_gate_tokenhost_mode 由 create_chat_task L185 从真实 session 补 |
| 传给 create_chat_task 的参数 | L264-272`username, workspace_id, notice_text, [], conversation_id, message_source="workflow", session_data=session_data` |
| 其中来自隐式上下文的参数 | `username`sessionhost_modeL185 补) |
| handle_task_with_sender 触发方式 | 同主链路 |
| 迁移难点 | **小~中**。与 3a 同模式;额外注意派发失败时的 `restore_notices` 回滚与门闸释放语义L277-303——显式化改造不得吞掉这些副作用 |
### 1.4 调用点④:`server/chat_flow_task_main.py:613`(完成通知派发)
| 维度 | 内容 |
|---|---|
| 触发来源 | **内部后台轮询线程(任务完成通知派发链)**。`_dispatch_completion_user_notice`L521def`poll_completion_notifications`L961L1064 处 await 调用)触发;该轮询器由 `handle_task_with_sender` 尾部L2643`socketio.start_background_task(run_completion_poll)` 在**独立线程**中启动(自带 asyncio 新事件循环 run_until_complete5s 间隔轮询、最长 1h。启动条件主任务结束时检测到子智能体/后台命令仍在运行或有待通知项/工作流待通知L2610-2626。**触发源头仍是用户某次会话产生的后台工作者完成事件**,但执行时点与主请求已完全解耦 |
| 隐式上下文信息 | **无 Flask session/request 依赖**(后台线程内无请求上下文)。`session_data` 由 `web_terminal` 属性显式构造L570-576`username / role=web_terminal.user_role / is_api_user=(user_role=="api") / host_mode=(workspace.username=="host") / host_workspace_id / workspace_id / run_mode / thinking_mode / model_key`。唯一残留create_chat_task L185 的 `session.get("host_mode")` 在无上下文时抛 RuntimeError、被 except 吞掉后走显式值——**无副作用但属隐式残留** |
| 传给 create_chat_task 的参数 | L613-623`username, workspace_id, user_message, [], conversation_id, model_key=session_data.get(...), thinking_mode=session_data.get(...), run_mode=session_data.get(...), session_data=session_data`(含 main_task_gate_tokenL584轮询器预占移交、auto_user_message_event、auto_user_message_payload、preceding_user_notices |
| 其中来自隐式上下文的参数 | 无(全部来自 web_terminal/workspace 已显式值) |
| handle_task_with_sender 触发方式 | 主链路create_chat_task → 线程)。**另有回退**L664 `asyncio.create_task(handle_task_with_sender(...))`,当 create_chat_task 抛异常时在当前轮询事件循环内直接执行L663-674 |
| 迁移难点 | **小(五处中最顺)**。已全显式只需把「web_terminal/workspace 属性 → session_data」的构造提取为共享 helper`RuntimeContext.from_terminal(terminal, workspace, username, ...)`并保留「gate token 移交 + 回退直接执行」两条语义 |
### 1.5 调用点⑤:`server/chat_flow_task_main.py:1416`(多智能体 idle 派发)
| 维度 | 内容 |
|---|---|
| 触发来源 | **内部后台轮询线程(多智能体 pending 消息派发)**。`_dispatch_multi_agent_idle_messages`L1247def`poll_multi_agent_notifications`L1101L1189 处 await 调用)触发;该轮询器由 handle_task_with_sender 尾部L2695`socketio.start_background_task(run_ma_poll)` 在**独立线程**中启动。仅多智能体模式;主对话空闲且 `MultiAgentState` 有 pending_master_messages 时 drain 并触发新一轮工作(与调用点④的轮询器完全分离,避免竞争单工作区互斥,见 L2646 注释) |
| 隐式上下文信息 | **无 Flask session/request 依赖**。`session_data` 显式构造L1351-1360同调用点④模式`task_type="notice"`L1427用于**绕过普通 chat 任务的单对话互斥**models.py:160-172 只对 task_type=="chat" 做互斥) |
| 传给 create_chat_task 的参数 | L1416-1428`username, workspace_id, last["text"], [], conversation_id, model_key=session_data.get(...), thinking_mode=session_data.get(...), run_mode=session_data.get(...), session_data=session_data, task_type="notice"` |
| 其中来自隐式上下文的参数 | 无(显式) |
| handle_task_with_sender 触发方式 | 主链路create_chat_task → 线程)。异常时直接 raise 回轮询器由调用方处理L1429-1437 |
| 迁移难点 | **小**。注意保留 `task_type="notice"` 的互斥豁免语义与 `preceding_user_notices` 回放L1398-1411无 gate token 参与 |
### 1.6 汇总表
| # | 文件:行 | 入口函数 | 触发来源 | 是否有隐式 Flask 依赖 | 显式化程度 | 迁移难度 |
|---|---|---|---|---|---|---|
| ① | tasks/api.py:200 | create_task_api | Web HTTP POST | 强username/workspace_id/session 快照 L194-205 | 低(无 session_data | 小~中 |
| ② | api_v1.py:320 | send_message_api | Web HTTP POSTBearer token | 强session["username"/"role"/"is_api_user"]、session 快照) | 低(无 session_data | **中** |
| ③a | workflow_runtime_api.py:184 | api_activate_workflow | Web HTTP POST | 中usernamehost_mode 补读 L185gate token 隐式移交) | 高session_data 显式) | 小~中 |
| ③b | workflow_runtime_api.py:264 | api_deactivate_workflow | Web HTTP POST | 中(同上;+通知池/门闸回滚) | 高session_data 显式) | 小~中 |
| ④ | chat_flow_task_main.py:613 | _dispatch_completion_user_notice | 内部后台轮询线程socketio.start_background_taskL2643 | 极弱(仅 L185 异常吞掉的 session.get 残留) | 极高(全显式) | **小** |
| ⑤ | chat_flow_task_main.py:1416 | _dispatch_multi_agent_idle_messages | 内部后台轮询线程L2695 | 极弱(同上) | 极高(全显式 + task_type="notice" | **小** |
---
## 2. `create_chat_task` 函数本身与执行链路的关系
**位置**`server/tasks/models.py:132-215`TaskManager 方法)。
**签名**L132-149
```python
def create_chat_task(self, username, workspace_id, message, images, conversation_id,
videos=None, model_key=None, thinking_mode=None, run_mode=None,
max_iterations=None, session_data=None, message_source=None,
goal_mode=False, skill_context_messages=None, files=None,
task_type="chat") -> TaskRecord
```
**做了什么**(按顺序):
1. **参数归一化**L151-158run_mode 白名单校验fast/thinking/deep非法即抛 ValueErrortask_type 归一化(默认 "chat")。
2. **单对话互斥**L160-172`task_type=="chat"` 时,同一对话(业务 id 去 `conv_` 前缀后比对)存在 `status ∈ {pending, running}` 且 task_type=="chat" 的任务 → 抛 `RuntimeError`(前端 409。完成通知/多智能体派发用 `task_type="notice"` 豁免。
3. **构造 TaskRecord**L173-175生成 uuid task_idL173`TaskRecord(...)`L174初始 status="pending"。
4. **session 快照**L177-209——**隐式上下文核心**
- `session_data is not None`(显式分支,工作流/通知/多智能体调用L178-191`setdefault workspace_id/message_source/goal_mode/skill_context_messages`,并 **仍读 session**L185 `snapshot.setdefault("host_mode", session.get("host_mode"))`、L187 `host_workspace_id ← session.get(...) or workspace_id`(无请求上下文时抛 RuntimeError 被 except 吞掉,走显式值)。
- `session_data is None`隐式分支Web/API v1 调用L193-205从**真实 Flask session** 快照 `username / role / is_api_user / host_mode / host_workspace_id / workspace_id / run_mode / thinking_mode / model_key`(均 session.get可能为 None+ message_source/goal_mode/skill_context_messages。
- 快照结果存 `record.session_data`,后台线程据此**重建** session。
5. **登记 + 起线程**L210-214锁内注册 `self._tasks[task_id]``threading.Thread(target=self._run_chat_task, args=(record, images, videos or [], files or []), daemon=True)`status="running"`thread.start()`;返回 record。
**与执行链路的关系**`create_chat_task` 是 **「受理(互斥)+ 快照(隐式上下文固化)+ 执行spawn 线程)」三合一的公共受理入口**。
线程内 `_run_chat_task`L785-1096完成重建 sessionL794-810→ 解析终端/工作区 → 加载对话 → 覆盖模型/模式 → 注入 user_message 事件 → 挂 sender事件入队 + socketio 推送)→ `run_chat_task_sync`= `process_message_task`chat_flow.py:280/133→ asyncio `handle_task_with_sender`chat_flow.py:159-166→ 结束态处理stopped/succeeded + work_timer + gate 兜底释放)。
因此「把 5 个调用点收敛到 RuntimeService」在实现上等价于**保留 create_chat_task 的受理/执行骨架,把第 4 步的 session 快照替换为显式上下文对象**。
---
## 3. `test_request_context` 依赖清单(需要显式化的字段列表)
**唯一位置**`server/tasks/models.py:794-810``server/` 与 `modules/` 全域 grep 仅此一处)。
**包装结构**
```
792 # 为后台线程构造最小请求上下文,填充 session
793 from server.app import app as flask_app
794 with flask_app.test_request_context():
795 try:
796 for k, v in (rec.session_data or {}).items(): # 把快照灌回 session
797 if v is not None:
798 session[k] = v
799 if session.get("host_mode"):
800 session["workspace_id"] = workspace_id
801 session["host_workspace_id"] = session.get("host_workspace_id") or workspace_id
802-807 write_host_workspace_debug(...)
808 except Exception:
809 pass
810 terminal, workspace = get_user_resources(username, workspace_id=workspace_id, conversation_id=rec.conversation_id)
```
**为什么需要它**`_run_chat_task` 跑在**裸 `threading.Thread`** 里,没有 Flask 请求上下文。而 `get_user_resources`context.py:221内部以 `has_request_context()` 为开关读取 session
- L239 `host_mode_session = bool(session.get("host_mode"))` → 决定是否走**宿主机多工作区解析路径**
- L246-247 `session.get("host_workspace_id") / session.get("workspace_id")` → 宿主机路径下 workspace 选择兜底;
- L462 `is_api_user = bool(session.get("is_api_user"))` → 决定走 `api_user_manager` 还是 `user_manager`**最关键,错选会串工作区**
- L368-369 `session.get('run_mode') / session.get('thinking_mode')` → 新建 terminal 的默认档;
- L122/L175-178 `session.get("model_key")``_apply_workspace_personalization_preferences`)→ 恢复会话模型;
- auth_helpers.py:51-52 `get_current_user_role``session["role"]` → 决定 `terminal.user_role`context.py:525/538影响配额/角色语义)。
若去掉该包装,`has_request_context()` 恒 False → host_mode、is_api_user 恒 False → 宿主机多用户、API token 用户的任务会解析到错误工作区,或直接 NoWorkspaceError。**它本质上是「把调用时点(请求上下文内)的身份/偏好搬运到异步执行时点(后台线程)的桥」。**
**包装体内读取/写入的 Flask 隐式状态**
| 项 | 位置 | 方向 | 用途 |
|---|---|---|---|
| session[\*](全部 session_data 键) | models.py:796-798 | 写 | 重建请求上下文 |
| session["workspace_id"] | models.py:800 | 写 | host_mode 下回写 |
| session["host_workspace_id"] | models.py:801 | 写+读 | host_mode 下回写 |
| session["host_mode"] | context.py:239 | 读 | 宿主机路径选择 |
| session["host_workspace_id"] | context.py:246 | 读 | 宿主机 workspace 兜底 |
| session["workspace_id"] | context.py:247 | 读 | 同上 |
| session["run_mode"] | context.py:368 | 读 | 新建 terminal 默认档 |
| session["thinking_mode"] | context.py:369 | 读 | 同上 |
| session["is_api_user"] | context.py:462 | 读 | api vs web 资源管理器 |
| session["model_key"] | context.py:122 | 读 | 恢复会话模型 |
| session["role"] | auth_helpers.py:51 | 读 | terminal.user_role |
**消除它需要显式传入的字段清单RuntimeContext 最小字段集9 项)**
1. `username`已显式rec.usernameL787
2. `workspace_id`已显式rec.workspace_idL788get_user_resources L810 已显式传参)
3. `host_mode: bool` ← 现读 session_data["host_mode"];决定宿主机路径
4. `host_workspace_id: Optional[str]` ← 现读 session_data宿主机多工作区兜底
5. `is_api_user: bool` ← 现读 session_data**决定 api_user_manager / user_manager**(最高风险)
6. `role: str` ← 现读 session_data写入 terminal.user_role
7. `run_mode: Optional[str]` ← 现读 session_data新建 terminal 默认档
8. `thinking_mode: Optional[bool]` ← 现读 session_data同上
9. `model_key: Optional[str]` ← 现读 session_data恢复会话模型
**附注(不需要显式化的)**`message_source / goal_mode / skill_context_messages / main_task_gate_token / auto_user_message_event / auto_user_message_payload / preceding_user_notices` 已由调用方经 `session_data` 显式传入,可原样并入 RuntimeContext只是换一个容器。
> **审阅注释2026-09-07R4 职责分组)**:上述清单说明迁移时不能丢字段,不表示应全部并入一个公共 RuntimeContext。区分可信身份/资源范围、本次任务参数、内部执行信息;门闸 token 与通知回滚信息保持内部生成和传递,不能由普通客户端任意提交。迁移契约还需定义默认值、对话配置和请求覆盖的解析优先级,避免只替换字典名称。
**改造路径**:把 models.py:794-810 替换为:
`context = RuntimeContext.from_session_snapshot(rec.session_data)`(或直接由调用方传入)
`terminal, workspace = get_user_resources_explicit(username, workspace_id, conversation_id, runtime_context=context)`
并在 context.py 中为 `get_user_resources` 增加显式参数变体web 路径保持不变)。
---
## 4. 总体结论
### 4.1 收敛为公共入口需要动多少处
| 面 | 位置 | 改动性质 | 量级 |
|---|---|---|---|
| 调用方5 处 6 个函数位置) | tasks/api.py:200、api_v1.py:320、workflow_runtime_api.py:184 与 264、chat_flow_task_main.py:613 与 1416 | 把「各自取 session / 拼 session_data」改为构造统一 RuntimeContext 对象 + 调公共入口 | 6 处,各 **小**(合计小~中) |
| 受理入口 | models.py:132-215create_chat_task | 第 4 步 session 快照L177-209显式化暴露公共入口签名显式上下文入参 | **中** |
| 后台执行桥 | models.py:794-810test_request_context | 拆除,改传显式上下文调用资源解析 | **中** |
| 资源解析 | context.py:221 `get_user_resources` 及其内部 8-10 处 `session` 读取L239/246/247/368/369/462/122 + auth_helpers role | 增加显式上下文参数变体has_request_context 分支与显式分支并存 | **中~大**(最高风险) |
| 关联但不在本 5 点内 | socket_handlers.py:345 → start_chat_tasksocket 直连执行链chat_flow_task_main.py:664通知回退直接执行 handle_task_with_sendermain_task_gate 门闸移交/认领/释放语义 | 若要「全入口收敛」socket 实时路径也需接入公共入口(否则公共入口不覆盖主交互通道) | 另计,**中** |
### 4.2 核心难点排序(按风险/工作量)
1. **`get_user_resources` 的 session 隐式读取参数化**context.py
这是拆除 test_request_context 的前提,也是风险最高的点:`host_mode`(宿主机多工作区)与 `is_api_user`API vs Web 资源管理器)两处分支选错即**静默串工作区**。需要新增显式上下文变体、保持 web 路径不动,改动面涉及其调用链上 `_apply_workspace_personalization_preferences`、`get_current_user_role` 等。
2. **API 身份字段is_api_user / role的正确传递**api_v1.py 入口)
调用点②是唯一没有 session_data 的 API 来源快照逻辑models.py:194-205移到显式上下文后必须保证这两个字段不丢、不误判否则 API 用户后台任务退化为网页语义。
3. **main_task_gate 门闸移交语义**workflow_runtime_api / 通知轮询链)
「预占 → token 随 session_data 移交 → 任务线程认领 → finally 释放 / 失败回滚restore_notices」是跨线程的隐式协议RuntimeContext 必须原样承载 token 与失败回滚语义。
4. **`task_type="notice"` 互斥豁免与单对话互斥的语义保持**chat_flow_task_main.py:1416
多智能体 idle 派发依赖 notice 跳过 chat 互斥models.py:160-172若公共入口重排互斥规则需防止完成通知/多智能体链路的并发回退。
5. **各入口身份取数的三套来源统一**web session / token session / web_terminal 属性)
①读真实 session、②读 token 注入的 session、④⑤读 web_terminal 属性——三者语义等价但取数路径不同,需收敛为同一个 `RuntimeContext.from_*` 构造 helper避免迁移后行为漂移。
### 4.3 一句话总结
5 个调用点本身**都不难迁**(④⑤已全显式、①②只需打包 session 字段、③已 80% 显式),真正的大头在 `server/tasks/models.py` 的快照 + `test_request_context`(唯一隐式桥)与 `server/context.py` 的资源解析显式化;若把 socket 实时路径也纳入收敛,则为 6 个入口、整体工作量**中~大**建议分两步先做「RuntimeContext 对象 + 显式受理签名(保留 test_request_context 兜底期兼容)」,再拆 `get_user_resources` 的 session 读取。

View File

@ -1,369 +0,0 @@
# Astrion Gateway 架构现状文档(修订版 v2
> 生成日期2026-09-07同日经外部模型审阅修订13 条批注 G1-G13 已消化进正文,消化对照见附录 §8.2
>
> **本轮目标锚定G1**:以**四层架构 + CLI/GUI/IDE 插件独立接入**为目标。前期「先用定时任务验证公共入口」的路线适用于当时唯一明确的新调用方现在优先级调整为Gateway 独立启动 → 公共协议与多客户端交互闭环 → 正式客户端接入;定时任务随后接入该边界(见 §7
>
> 定位:回答一个问题——**对照四层架构愿景,当前代码实际做到哪一步、差距在哪、通往「完整 Gateway」还剩哪些工作。** 本文只做现状盘点与路线规划,不做实现。
>
> **信息来源(三方交叉验证)**
> 1. 愿景文档:`.astrion/user_upload/Astrion_Architecture_Product_Roadmap_Review_1.md`§10-15、§16-18、§35-38、§41-43
> 2. 契约与研究:`docs/runtime_contract.md`、`cache_research/gateway/`work_plan / phase12_implementation_plan / eval_summary + 四份子报告)
> 3. 2026-09-07 当日三份只读代码核查(全文见附录 §8.1
> - `audit_entry_points_v2/entry_points_audit.md`(任务入口收敛核查)
> - `audit_state_ownership_v2/state_ownership_audit.md`(状态责任表 S1-S10 落地核查)
> - `audit_research_summary_v2/research_summary.md`(既有研究汇总 + G1-G18 遗留全集)
> 4. 当日实测:改造相关测试复跑 **25/25 全绿**unittest 方式);`git log` 确认改动已提交G4 依赖链论断经主智能体亲自复验§3.3)。
---
## 1. 愿景参照系
### 1.1 四层目标架构§10.1 / §43 合并)
```text
┌──────────────────────────────────────────────┐
│ Client Layer │
│ Web / Desktop / CLI/TUI / IDE / Android / SDK
└──────────────────────┬───────────────────────┘
│ Stable Runtime Protocol
┌──────────────────────▼───────────────────────┐
│ Gateway │
│ identity/auth · session ownership · run/task
│ lifecycle · routing · approval routing ·
│ event stream · reconnect/persistence · policy
└──────────────────────┬───────────────────────┘
│ Runtime Internal API
┌──────────────────────▼───────────────────────┐
│ Agent Runtime │
│ agent loop · context/compaction · workflow ·
│ sub-agent/AgentSession · memory · tool planning
└──────────────────────┬───────────────────────┘
│ Execution Contract
┌──────────────────────▼───────────────────────┐
│ Execution Plane │
│ Host Sandbox / Docker / Remote Worker / │
│ Browser / Tool Broker │
└──────────────────────────────────────────────┘
```
### 1.2 四层职责划分G2 澄清)
| 层 | 职责 |
|---|---|
| 前端 | 输入、展示、状态投影 |
| Gateway | 身份与资源授权、会话管理、Run 受理与可查询生命周期、审批裁决、订阅与恢复 |
| Agent Runtime | 模型循环、上下文、工作流、子智能体编排、工具调用决策;向 Gateway 报告执行进展 |
| 执行环境 | 具体工具执行,落实路径/网络/沙箱限制,返回结构化结果与取消结果 |
层间接口Gateway↔Runtime 使用明确的执行上下文、事件输出与交互接口Runtime↔执行环境使用 Execution Contract。**Gateway 可以内部委托多个 manager 管理状态——无需把所有数据放进一个大类,也无需将四层拆成四个进程。**
### 1.3 关键设计原则Roadmap 原文立场)
| 原则 | 出处 | 要点 |
|---|---|---|
| 逻辑边界先行,不拆微服务 | §11 | 先做进程内 `RuntimeService` 类边界Flask 只是 Adapter**先稳定 contract再决定 deployment topology** |
| Transport 与 Protocol 分离 | §13 | 协议语义同一传输按通道裁剪HTTP/WS、stdio/socket 均可承载);**客户端只理解稳定 protocol不理解 Agent 内部实现** |
| State 只有一个 Owner | §14 | Gateway owns session/run/approval/event history/task stateClients 只是 state projection |
| 去 Flask 化语义(≠不用 Flask | §37 | 依赖方向单向Flask → Gateway Service → Runtime Service → Core/Modules禁止反向 import 读 request/cookie |
| 正式 Runtime Contract 文档 | §38 | 先定义 Entities/Commands/Events再选传输 |
| 验收标准Phase B | §41 | 见本文 §4.2 逐项对照 |
### 1.4 协议原语现有基础与真实缺口G3 口径)
Roadmap §12 的四个原语与当前实现的映射:
| 原语 | 当前基础 | 真实缺口 |
|---|---|---|
| Session长期会话 | conversation 已有id/user/workspace/metadata/持久化 JSON | 公共契约中的字段子集与生命周期语义未定义 |
| Turn一轮执行 | Run/task 已有TaskRecordpending/running/终态) | **未采用 Turn 命名本身不是架构缺口**;真正要做的是主 Run、子智能体任务、后台执行的**映射关系明确**,不能只按名称合并 |
| ItemUI 可见事件统一单元) | 事件类型已事实存在assistant/tool_call/approval... | Item 的稳定标识、内容更新与生命周期started/delta/completed未定义应**逐步适配现有事件**,避免同时重写所有前端渲染 |
| EventServer→Client 通知) | task 级 idx/offset 协议 + socket 推送已有 | 序号作用域、事件覆盖范围、授权订阅、缺口检测、快照水位与续传衔接未定义(详见 §7 第 3 步) |
公共协议还需明确(当前均未定义):**Commands、Queries、Events、授权范围、错误码、请求重试与兼容版本策略**——不能只列实体字段。
---
## 2. 当前进度总览
### 2.1 三阶段路线状态gateway_work_plan.md2026-09-07 修订版)
| 阶段 | 目标 | 状态 | 证据 |
|---|---|---|---|
| 阶段一 · 固定契约 | 状态责任表 + RuntimeService 契约 + 回归用例 | ✅ 完成 | `docs/runtime_contract.md`143 行) |
| 阶段二 · 公共任务入口 | RuntimeContext + RuntimeService + 6 处调用方迁移 + 拆 Flask 桥 | ✅ 完成(但见 §3.3 的边界收窄) | `server/runtime/`commit `652c1606` |
| 阶段三 · 定时任务 | Schedule/Occurrence 实体 + 幂等/重叠/停机语义 + 持久化 | ⬜ 未做,**本轮路线重排后后置**§7.6 | 无任何调度器代码 |
**提交状态**:阶段一/二改动**已提交**`652c1606`,前置 `7d290d17`/`6e043389`,后续还有 `9a17b381`/`c80bc4fb`/`b4deee0f` 三个修复)。
**测试现状**:改造相关测试 **25/25 全绿**(当日 unittest 复跑;项目 .venv 无 pytest。构成`test_server_refactor_smoke`(6) + `test_runtime_service`(10) + `test_conversation_model_persistence`(4) + `test_runtime_identity_resources`(4) + 新增 1 例。存量失败 4 项与本次改造无关。
**测试覆盖口径警示G13**`test_runtime_service.py:117-118` 将 `task_manager._run_chat_task` 替换为空 lambda线程即刻结束——入口测试**绕过了真实执行装配**,不能以这类测试通过认定边界完成;后续验收必须覆盖真实执行装配与生命周期。
### 2.2 范围评估的关键数字eval_summary经 R1-R6 审阅修订)
- 全部 Flask 端点 **196 个**:必须迁移的 **16 个8.2%** = T1 任务受理 3 + T2 任务控制 13其余 180 个(观察/CRUD/状态/认证)不需要迁移。
- 16 个端点复杂度12 小 / 3 中 / 1 大(唯一大项 = workflow activate
- 四支撑链路评估:事件/取消/保存可直接复用(需适配层),**审批链路需改造**。
- **口径警示G6**16 个端点是「任务受理/控制」的范围估算,**不是多端独立接入所需的公共能力范围上限**(见 §4.3)。
---
## 3. 已实现清单(阶段一/二交付物)
### 3.1 契约层(阶段一)
- `docs/runtime_contract.md`概念对齐Session=conversation / Run=一轮主任务 / Event=task events 条目);状态责任表 S1-S10已有正确性保障 6 项RuntimeService 契约;调用方迁移表;回归用例 T01-T12。
### 3.2 入口层(阶段二,当日核查验证)
- **`server/runtime/` 包**(新):
- `context.py`197 行RuntimeContext 三层模型——`TrustedPrincipal`(只能由适配层认证后构造)/ `TaskParams` / `InternalDirectives`(普通客户端不可提交);**不 import flask**。
- `service.py`114 行):`RuntimeService` 无状态薄层——`create_task / cancel_task / guidance 与 pending 队列×4 / get_task / get_task_events`;进程级单例;**不持有任务状态**。
- **入口收敛(当日验证,声明属实)**
- 全部 6 处任务创建调用点已迁移到 `runtime_service.create_task(ctx)``server/tasks/api.py:220`、`server/api_v1.py:335`、`server/workflow_runtime_api.py:192/:269`、`server/chat_flow_task_main.py:618/:1417`。
- **无漏网调用点**`create_chat_task` 实体调用全仓仅 2 处 = 定义 `models.py:128` + 委托 `service.py:38`
- `create_chat_task` 强制显式 RuntimeContext缺失抛 ValueError**2026-09-08 已拆除 session_data 兼容桥**TaskRecord 三层结构化直存(`principal`/`task_params`/`directives` + 可变 `goal_progress``to_session_data()` 已删除,全库生产代码无 session_data dict 残留。
- **执行链运行期解耦(当日验证属实)**`_run_chat_task` 不再建立 `test_request_context`,任务线程全程 `RuntimeIdentity` 驱动(`server/context/identity.py`server/core/modules/utils 下 `test_request_context` 实体调用 = 0`models.py` 无 `session[` 读取。
- **依赖方向(结论收窄,见 §3.3**`server/context.py`989 行)已拆分为 `server/context/` 子包;`get_user_resources` 已参数化RuntimeIdentity 显式身份快照)。**「符合单向依赖」的结论仅适用于「任务所需运行期上下文已显式化」这一层。**
- **审批超时透传管道**terminal `_approval_timeout_seconds` → 工具循环 `_approval_timeout_for()` → 4 个 `_wait_*` 调用点(默认 3600s 语义不变)。
### 3.3 独立启动缺口G4当日主智能体复验属实
**论断**:移除 test_request_context ≠ 独立服务初始化与依赖解耦完成。**运行期**上下文已显式化,但**模块加载期**依赖仍锚定 Web 栈:
```text
runtime_service.create_task()
→ from server.tasks import task_manager # service.py:35 等 8 处延迟导入
→ server/tasks/__init__.py:3 # from flask import Blueprint创建 tasks_bp级联 import 路由模块
→ server/tasks/models.py:15 # from server.chat_flow import run_chat_task_sync
→ server/chat_flow.py:19/55/73 # 导入 Flask(request/session)、认证辅助、socketio
```
**后果**:任何无 Web 的进程想使用 RuntimeServiceimport 时仍会拉起整条 Flask/SocketIO 依赖链——「不启动 Web 应用、独立初始化 Gateway/Runtime」目前做不到。
**下一步方向**:抽出不依赖 Web 应用初始化的服务装配入口显式注入资源解析、事件输出及交互接口Web 适配层继续使用 Flask。**不能用「整仓 Flask 关键词零命中」代替边界验收**;验收标准 = 真实服务在无 Flask 环境中完成初始化与任务生命周期§7.2)。
---
## 4. 达成度矩阵(核心差距)
### 4.1 四层逐层对照G5 口径修正)
| 层 | Roadmap 要求 | 现状 | 达成度 |
|---|---|---|---|
| **Client Layer** | Web/Desktop/CLI/IDE/Android/SDK | Web、Android WebView、React/Ink CLI 存在;**CLI 仍是「Web API 消费者」**`cli/src/api.ts`fetch `127.0.0.1:8091` + cookie/CSRF/hostLogin并自启 `python -m server.app` | ⚠️ 客户端存在,但全部消费 Web 专属业务流程 |
| **Stable Runtime Protocol** | Session/Turn/Item/Event 原语 + Commands/Events 契约 | **稳定公共契约不存在**。已有基础:`_append_event` 已注入 task_id/conversation_id/workspace_id 并分配任务内 idx`models.py:757-782`)。真正缺的:稳定公共契约、**跨任务及非运行中状态变化的覆盖**、可查询快照与同步规则 | ❌ 未开始(有可用基础) |
| **Gateway** | 状态唯一 Owner可独立初始化 | 进程内公共入口已建立RuntimeService + 状态责任表);但 **import 依赖链仍耦合 Flask**§3.3),无法独立启动;状态持有仍分散在既有 manager§5 | ⚠️ 入口收敛 ✅ / 独立初始化 ❌ |
| **Agent Runtime** | agent loop / context / workflow / sub-agent | 已存在且**运行期**请求上下文依赖已消除;但 §3.3 的模块依赖与输出耦合仍在「达成」限定为进程内、Web 进程伴生形态 | ⚠️ 进程内达成 |
| **Execution Plane** | Host/Docker/Remote + 统一 Execution Contract | Host 沙箱Seatbelt/bwrap/WSL2+ Docker非特权 uid + Landlock已成熟无统一 Execution Contract 抽象Remote Worker 未做P2 | ⚠️ 部分 |
> **口径说明G5**:薄入口、命名差异及端点数量**不能直接折算成四层边界完成比例**本表「达成度」按各层职责§1.2)能否独立履职衡量。
### 4.2 Phase B 验收清单逐项对照§41
| §41 Phase B 条目 | 状态 | 说明 |
|---|---|---|
| 定义 Session / Turn / Item / Event | ⚠️ 部分 | 概念对齐已做Item 生命周期未定义;主 Run/子智能体/后台任务的映射未契约化 |
| 抽出 RuntimeService | ✅ | 已收敛全部 6 处入口§3.2独立启动未达成§3.3 |
| Flask route 只做 adapter | ⚠️ 部分 | 任务受理/控制链路16 端点)已是 adapter其余 180 端点保持 Flask 直写(评估结论:不需要迁移);**审批回答端点仍直调 manager见 §4.3** |
| CLI 改为 Runtime Client 语义 | ❌ | 后置§7.6 |
| 定义 event sequence / reconnect 模型 | ⚠️ 部分 | task 级 idx/offset + 前端对账去重已有;序号作用域/快照水位/续传衔接未定义 |
| 写 docs/runtime_protocol.md | ❌ | 现有 `runtime_contract.md` 是内部契约协议文档Entities/Commands/Events/错误码/版本)未写 |
| 为 protocol 生成 TS types | ❌ | 未做(启动条件:多客户端类型维护成实际成本) |
**小结7 项中 1 项完成、3 项部分、3 项未做。** 「Web 与 CLI 使用同一套 Runtime contract不需要知道彼此存在」尚未达到。
### 4.3 公共能力范围G6
「16 个端点」只是任务受理/控制的迁移估算。多端独立接入需要的公共能力应按**客户端操作**列清单,再确定所属服务:
> 工作区选择 · 会话创建/加载/历史 · 运行/取消/引导 · 审批与提问 · 模型及权限设置 · 必要附件能力 …
现状核查发现:**审批回答(`server/chat/approval.py`)仍直接调用 manager**,未经公共入口(契约 §4.2 明确审批回答不进 RuntimeService 首版)。旧 URL 可以保留,但这些业务能力**不能要求新客户端复制 Web 路由内的装配与编排**;与 Agent 使用无关的管理页面无需机械迁移。
---
## 5. 状态责任表 S1-S10 落地核查
> 全文见 `audit_state_ownership_v2/state_ownership_audit.md`
| # | 状态 | 一致性 | 关键点 |
|---|---|---|---|
| S1 | 对话历史与元数据 | ✅ | merge-on-save + `_io_lock` + 原子替换 + 缩减拒绝(`crud_mixin.py:252-358`、`base.py:47`、`index_mixin.py:104` |
| S2 | 任务记录 TaskRecord | ✅ | 唯一 TaskManagerchat 互斥 + notice 豁免(`models.py:158-173`);纯内存 |
| S3 | 任务事件流 | ✅ | deque maxlen=20000、idx 单调、offset 续读(`models.py:74/757-782/200-206`);纯内存 |
| S4 | 主任务门闸 | ⚠️ 基本一致 | 唯一入口/finally 释放/token 移交/失败回滚全部命中;**疑点 N1异常回退路径`chat_flow_task_main.py:644-690` 直跑 `handle_task_with_sender`)自身不释放门闸且无任务线程,回退场景门闸可能长期占用**(静态推断,待复现) |
| S5 | 停止标志 | ✅ | `server/state.py:38` 模块级 dictREST 硬取消 vs socket 软标志;任务级键隔离;无锁全局可变(进程内依赖事件循环+线程隔离) |
| S6 | 审批/提问条目 | ✅(含缺口证实) | 三个内存 manager + 锁内单次裁决;契约 §6 四个缺口全部证实:超时不回写终态 / 无 TTL / **task_id 恒 None静态证实WebTerminal 全仓无赋值点)** / socket 软 stop 不打断等待(大概率) |
| S7 | 对话级 terminal | ✅ | 三段 key`resources.py:52-54`);回收器 24h + 实例身份校验(`reaper.py:40/92-130` |
| S8 | 权限模式/执行环境 | ✅ | 运行中入队由工具循环消费(`permission.py` + `main_terminal.py:325-376` + `chat_flow_tool_loop.py:1544` |
| S9 | 多智能体实例状态 | ✅ | `GLOBAL_MULTI_AGENT_STATES` 进程级单例 + RLock + 快照 + 终态校准(`multi_agent/state.py:668/671`、`sub_agent/state.py:104-133` |
| S10 | 用户偏好 | ⚠️ 基本一致 | **新发现偏差:`save_personalization_config` 直接覆写 `open(path,"w")`,非原子写**`personalization_manager.py:851-856` |
**契约 §3 六项正确性保障**:代码中全部找到对应实现(第 6 项附 N1 疑点)。
### 5.1 关键判断归属、共享、恢复是三件事G7 口径改写)
v1 原文把「内存态」直接判为「状态唯一 Owner 的障碍」,混淆了三个不同问题,现拆开:
1. **状态归属(谁是权威)**:进程内达成度较高——每类状态基本都有唯一 owner 与互斥9/10 一致或基本一致)。✅
2. **多客户端共享§14 场景CLI 发起、GUI 审批)****只需两个客户端连接同一个 Gateway 实例,内存中的任务与审批即可共享**。Gateway 是统一管理边界,内部可继续由 TaskManager、审批 manager、会话存储各自持有状态关键是**授权、写入和裁决路径统一**。**内存态不构成此场景的障碍。**
3. **跨重启恢复**这才是内存态S2/S3/S6真正限制的能力——重启后任务记录/事件流/审批条目即失。是否补齐由跨重启查询/审计需求决定(持久化被列为后续独立决策),**不能单凭未落盘判定「无唯一 Owner」**。
锁、原子写和丢更新风险应按真实写入并发分别处理S5 无锁、S10 非原子写属此类,见 §6持久化本身也不会自动解决这些问题。
---
## 6. 已知缺口与遗留待办
### 6.1 必须先闭环的疑点
| # | 事项 | 口径G8 修正后) | 来源 |
|---|---|---|---|
| N1 | S4 异常回退路径门闸释放 | **保持待复现**:不能仅因回退函数自身没有 release 就判定泄漏;验证应覆盖预占→受理失败→回退执行→最终释放全链路 | 当日 S4 核查 |
| N2 | 审批条目 task_id 恒 None | 静态证实。修复方向:**在审批创建处显式传入当前 Run 上下文**;不宜把「给会话级 terminal 加 task_id」直接确定为方案——若沿用可变属性必须处理通知任务重叠、作用域恢复和清理。审批/计划确认/用户提问需保持各自语义,并补齐超时/取消终态与迟到回答规则 | 当日 S6 核查 + G2 |
| N3 | 真实运行环境验证未做 | Web 聊天/停止/审批/workflow 激活/多智能体派发需重启服务后人工完成;`get_user_resources` 分支选错会静默串工作区(最高风险点) | G1 |
| N4 | 并发重复任务记录 | `models.py:157`「检查运行中→创建记录」与门闸分属两处,并发下是否产生重复任务记录未验证 | G7①/G10 |
| N5 | `issue_socket_token` 发放竞争 | 未复现;若存在作为独立小缺陷处理 | G7② |
> **实施状态2026-09-07 第 0-4 步完成后)**N1 ✅已修复(回退路径 finally 释放,`chat_flow_task_main.py`N2 ✅已修复(审批创建处显式 `task_id=client_sid``chat_flow_tool_loop.py` 4 处 + `_handle_submit_plan`N3 ⬜仍待用户重启服务后人工验证(清单见 §7.1N4/N5 保持原口径未动S10 ✅已修复(`save_personalization_config` 改用公共 `atomic_write_json`)。另修复两处盘点后新发现:执行链 4 处裸 `socketio.emit`(未绑定抛 AttributeError`task_stopped` 曾因异常顺序跳过事件流记录已改为先写事件流再推送chat 任务无 cid 时服务层补建对话(`RuntimeService._ensure_conversation_for_chat`,自 `tasks/api.py` 下沉)。
### 6.2 结构性缺口
- **跨重启恢复能力**S2/S3/S6 纯内存态重启即失S6 条目永不清理(无 TTL。TTL 应在终态与迟到回答规则明确后设计不能直接删除仍合法等待的请求G11
- **审批链路四个缺口**:超时不回写终态 / 无 TTL / task_id 恒 None=N2/ socket 软 stop 不打断等待REST 硬取消可打断);超时语义(拒绝当前工具继续 vs 结束任务是产品决策未定R5 明确其不是定时任务的技术前提。
- **S10 personalization 非原子写**原子替换可防写中断损坏但读改写并发的丢更新风险需另行判断G8
- **独立启动缺口**§3.3 的 import 依赖链G4
- **事件前台干扰**:定时任务事件会推给在线用户 socket 房间,需 source 字段或产品确认G13-old
- **异常回退路径绕过 TaskRecord 登记**:无事件 deque、不可按 task_id 轮询/取消G14-old
- **阶段三定时任务未做**:详细设计要求见 work_plan §4research_summary.md §1 全量保留);本轮路线重排后**后置**§7.6)。
- **后续独立决策**有明确需求再启动SSE/WS 传输替换、对话级订阅、durable 事件与快照恢复、审批持久化、TS/SDK 生成、身份体系整理(不能直接合并 Web/API 用户数据空间)、设备配对/Remote Worker。
- 存量测试失败 4 项(与改造无关,未修)。
---
## 7. 后续路线(按 G9 重排为五步 + 前置第 0 步)
> 总原则:协议草案与接口适配可迭代推进;**每一步保留现有门闸、保存和恢复保障**四层不要求四进程G13Remote Worker 不是本轮完成条件。
> **实施状态速览2026-09-07三档口径接口存在 / 适配完成 / 行为验收)**
> | 能力 | 接口存在 | 适配完成(既有入口转调) | 行为验收 |
> |---|---|---|---|
> | run.start / cancel / guide / queue | ✅ | ✅tasks/api.py、api_v1.py 全部转调) | ✅ chain 双客户端 + lifecycle |
> | run.get / run.events含 window_start | ✅ | ✅HTTP 轮询透传水位) | ✅ offset/水位断言 + 客户端缺口处理2026-09-08Web 检测后发 `event_window_gap` → 会话快照对账CLI 提示并对齐窗口续读) |
> | run.list发现审核 F1 | ✅ | ✅(/api/tasks 列表、running-status、api_v1 删除保护转调) | ✅ B 发现 A 的活动 Run 并取消 |
> | approval.list / resolve | ✅ | ✅chat/approval.py 三类六个路由转调) | ✅ 等待→批准→继续approval_wait |
> | session.list / historyprincipal 用户+工作区双校验F3 | ✅ | ✅2026-09-08新增 server/gateway_api.py `GET/POST /api/runtime/sessions` + `GET .../history` 传输暴露api_v1 两会话路由转调公共入口消双轨,载荷字段经索引补字段保持兼容) | ✅ 含越权/跨工作区拒绝断言 |
> | 通道认证host Bearer token | ✅2026-09-08server/gateway_auth.pyhost 模式+回环限定token 存 `<DATA_DIR>/host_api_token` 0600 | ✅gateway_api 3 路由 + tasks/api.py 9 路由 + chat/approval.py 6 路由均支持双通道) | ⬜ 待真实环境验证 |
> | Gateway 独立初始化 | — | — | ✅ 子进程隔离ASTRION_IGNORE_DOTENV 逃生门 + 假模型自包含 + 装配证据断言F4 |
> | ExecutionBackend 替身E1-E4 | ✅ | —(默认 None生产路径不变 | ✅ fake_exec |
> | ③↔④ 全量贯通Host/Docker 迁入、E5-E10 | ⬜ | ⬜ | ⬜ 后置§7.5-补) |
>
> **审核收口记录gateway_implementation_review_2026-09-07.md**F1 已补 run.list 公共发现入口F2 已完成 15+ 处路由转调tasks/api.py 7、api_v1.py 3、chat/approval.py 6 + 载荷序列化收敛至 models.py 单一实现死导入清零F3 已补 principal 工作区一致性校验F4 已修config .env 逃生门 + 测试自包含 + 假通过修复 + 审批等待链)。审核 §4 契约注释误导已修正execution_plane/base.py命令校验/路径授权仍在旧链路,真实后端接入时必须保留)。
>
> **第一档+第二档收尾2026-09-08 实施)**
> - **flask 包依赖拆解G4 收口)**:新增 `server/context/_flask_bridge.py`(延迟桥接 has_request_context/session_get/session_setflask 缺失按无上下文处理identity/resources/conversation/personalization 四子模块顶层 flask+auth_helpers 依赖全拆;`server/context/__init__.py` 改 PEP 562 懒加载chat_flow.py 清理死导入。实测:`import server.tasks` / `import server.runtime` 不再拉起 flask/flask_socketio/auth_helpers/chat_flowvenv 验证)。**剩余形态**任务执行阶段_run_chat_task 线程内延迟导入 chat_flow → security/extensions仍拉起 flask+flask_socketioSocketIO 空壳实例不 init_app执行层彻底脱 flask 属更大工程,本轮未动。
> - **审批链路三缺口收口**:三个 manager 新增 `mark_expired`(幂等)+ 终态 TTL 惰性清理RESOLVED_TTL_SECONDS=3600pending 永不自动清理);三个 _wait_* 等待函数超时/软停止stop_check 注入)/协程取消CancelledError三路径均回写 expired 终态;软停止打断对齐 REST 硬取消。i18n 新增 approval_stopped/approval_expired/question_stopped/question_expired 四键。
> - **测试 patch 点随迁**test_conversation_model_persistencesession→session_get/session_set、test_runtime_identity_resourcesget_current_user_*→_get_current_user_* wrapper。全量 75 测试失败恰为 4 项存量(与改造无关)。
> - **存量索引兼容注记**:会话列表 items 新增 run_mode/model_key/custom_prompt_name/personalization_name 字段(索引两个写入点同步补齐);存量索引条目在对话下次更新前这两个新字段为 None。
>
> **本轮收口决策2026-09-07 用户拍板)**:①②③ 链路Client ↔ Gateway ↔ Runtime贯通即为本轮终点③↔④ 全量贯通Host/Docker 迁入 ExecutionBackend 契约、E5-E10 纳入)后置为独立工作,期间默认路径 `execution_backend=None`(现有真实链路不变)。
>
> **②↔③ 内部形态结构化2026-09-08**`to_session_data()` 兼容桥已拆除——`create_chat_task` 签名改为收 `RuntimeContext` 必填,`TaskRecord` 三层结构化直存(`principal`/`task_params`/`directives` + 可变 `goal_progress` 字段);`_run_chat_task` 身份还原/门闸认领/事件注入/terminal 属性设置全部改为按层属性访问;`to_session_data()` 方法删除,生产代码 session_data dict 零残留。验证75 测试全量回归失败恰为 4 项存量。
### 7.1 第 0 步:闭环疑点与小修复(不依赖架构决策)
1. 复现验证 N1回退路径门闸覆盖预占/受理失败/回退执行/最终释放全链路)。
2. 修复 N2审批创建处显式传入当前 Run 上下文(方案比选后实施,见 §6.1)。
3. 修复 S10`save_personalization_config` 改用原子替换写。
4. 完成 N3 真实环境验证清单。
### 7.2 第 1 步独立服务启动Gateway 可脱离 Web 初始化)
- 拆解 §3.3 的 import 依赖链:`server/tasks/__init__.py` 的 Blueprint 创建与级联路由 import、`models.py → chat_flow` 的静态引用。
- 抽出**不依赖 Web 应用初始化的服务装配入口**显式注入资源解析、事件输出与交互接口Web 适配层继续用 Flask。
- **验收**:不启动 Web 应用也能创建会话、运行受控任务、查询并取消——以真实执行装配验证不接受替换执行线程的替身测试G13
### 7.3 第 2 步:完整公共协议 + 极简客户端验收
- 写 `docs/runtime_protocol.md`§38Entities/Commands/Queries/Events + **授权范围、错误码、请求重试、兼容版本策略**G3
- 能力清单按客户端操作划分§4.3),审批回答等直调 manager 的路径纳入公共入口。
- **用非 Web 的极简测试客户端提前参与验收**G12验证 工作区→会话→运行→审批/提问→停止→历史 全链路。
- 传输选择G12**HTTP 可以承载公共 Gateway 协议,本地 socket 也可以——摆脱的是旧 Web 专属的登录、会话装配及路由业务流程,不是 HTTP 这个传输**。新客户端不依赖 Web Cookie/CSRF/hostLogin但必须经过适合通道的认证与资源授权**不能自报可信身份或内部门闸 token**。采用 stdio 时须区分连接适配器与服务实例——**不能让每个客户端各启动一份独立状态的 Runtime 却宣称共享 Gateway**。跨凭证访问同一资源需明确身份映射,不直接合并现有 Web/API 数据空间。
### 7.4 第 3 步:多客户端共享与恢复
- 场景验收A 发起、B 查询并操作A 断开不结束任务;重连后状态一致。
- **事件模型定义G10**:先定义序号作用域(会话或其他明确作用域即可,**不要求跨所有用户/会话的全局总序**)、事件覆盖、授权订阅、缺口检测、快照水位与续传衔接;保留现有任务 idx 时,须区分它与连接序号及其他游标的含义,并定义窗口过期后的重新同步规则。
- **重启语义三场景分开G11**:客户端断开 ≠ Gateway 重启 ≠ 原执行继续。多端接入首先要求任务独立于连接Gateway 重启后旧 Run 的查询与失效规则须明确;若保留旧 Run 记录,恢复为中断/待核验等明确状态,**不无依据宣告成功、不自动重做可能已有副作用的动作**。持久化 pending 审批不会重建原执行线程;只有另行实现待执行动作恢复、权限重校验及结果对账后,才能承诺重启续跑。
- 持久化范围由跨重启查询/审计需求决定;当前内容快照与已提交生命周期记录可分别设计,**live delta 无需默认全部持久化**;实现仍须遵守原 work plan 的一致提交、快照水位、裁剪与恢复规则。
### 7.5 第 4 步Runtime 与执行环境分层
- 定义 Execution Contract相同 Runtime 可接测试执行器(替身)及现有 Host/Docker 实现;执行链无需了解 Web 会话与客户端连接。
- 验收Runtime + 替身执行器可独立测试Host/Docker 后端经同一契约接入。
#### 7.5-补 ③↔④ 全量贯通工作分解2026-09-07 定,后置独立工作)
> 首版已完成:契约文档 + ExecutionBackend 协议E1-E4+ 替身执行器 + 接入点
> `MainTerminal.execution_backend`,默认 None。以下为「Host/Docker 迁入契约」的四件事:
1. **接口面补全 E5-E10**:文件读三模式 / 文件 CRUD 族 / 持久终端 6 操作 / path_validate / 执行环境快照 / 命令校验。照搬现状语义(返回结构、截断、错误形态),不发明新能力。主改 `modules/execution_plane/base.py`
2. **真实适配器 HostDockerBackend**:新实现类内部委托现有 terminal_ops / file_manager / terminal_manager / background_command_manager。**决策点**薄适配纯转发快、风险小4 处后端选择复制的结构债留着vs 厚适配(顺手收敛 terminal_ops/run.py、background_command_manager.py、persistent_terminal/start.py、container_file_proxy.py 的后端选择逻辑,根治但动四个核心执行文件)。**既定方向:先薄适配**。
3. **编排层全面切换**tools_execution.py 给 E5-E10 分支插 backend 注入(约 6-10 处);默认装配由 None 改为 HostDockerBackend 实例。**风险点是行为等价**:权限裁决/先读后写/浅备份/编辑摘要/字符截断留在编排层,执行细节归后端,边界必须切干净。
4. **验收**:替身测试保持绿 + 全量回归 + N3 真实环境人工验证一并做。
完成后收益Runtime 对执行环境零直接依赖换后端Remote Worker、子智能体独立沙箱只需换实现不碰智能体循环。
### 7.6 第 5 步:正式客户端接入 + 定时任务后置接入
- Web 与正式 CLI/GUI/IDE 接入同一业务服务与协议。
- **定时任务(原阶段三)后置接入该边界**不作为公共协议建设的前置条件G9——按 work_plan §4 设计实施(幂等/重叠/停机/审批超时/持久化选型)。
### 7.7 本轮 Gateway 化的完成判据G13行为验收
1. 新客户端通过**公开承诺的协议能力**完成 Agent 使用全流程,无需复刻 Web 启动逻辑。
2. Web 与其他端对同一会话/Run/审批状态一致。
3. 断连不终止已受理任务;重连与 Gateway 重启分别有明确处理规则。
4. 所有任务来源遵守统一受理、取消、授权、保存及事件规则。
5. Gateway 可独立初始化Runtime 与执行环境可通过替身独立测试。
6. **测试必须覆盖真实执行装配与生命周期**——不能以替换整个执行线程后的入口测试通过认定完成。
### 7.8 不在路线内Roadmap §42 明确反对)
- 拆微服务 / 引入 K8s重写前后端每个 sub-agent 独立进程;把四档权限 UI 改成 policy DSL 暴露给普通用户。
---
## 8. 附录
### 8.1 本次核查产物索引
| 产物 | 路径 |
|---|---|
| 任务入口收敛核查6 处迁移点 + 漏网扫描 + Flask 拆桥) | `cache_research/gateway/audit_entry_points_v2/entry_points_audit.md` |
| 状态责任表 S1-S10 落地核查(含 N1/N2/S10 新发现) | `cache_research/gateway/audit_state_ownership_v2/state_ownership_audit.md` |
| 既有研究汇总(三阶段要点 / R1-R6 / 早期盘点修正对照 / G1-G18 遗留全集 / 外部借鉴) | `cache_research/gateway/audit_research_summary_v2/research_summary.md` |
| 内部契约 | `docs/runtime_contract.md` |
| 三阶段工作计划 | `cache_research/gateway/gateway_work_plan.md` |
| 阶段一/二实施与验收记录 | `cache_research/gateway/phase12_implementation_plan.md` |
### 8.2 审阅批注消化对照表G1-G13 → 本版落点)
| 批注 | 核心意见 | 本版落点 |
|---|---|---|
| G1 本轮目标 | 目标重锚为四层+多端独立接入,优先级调整 | 文首锚定 + §7 全线重排 |
| G2 四层职责 | 四层职责明确划分Gateway 内部可委托多 manager | §1.2 |
| G3 协议演进 | Turn 命名不是缺口Item 逐步适配;协议需 Commands/Queries/Events/授权/错误码/重试/版本 | §1.4、§7.3 |
| G4 独立启动缺口 | import 依赖链仍耦合 Flask运行期显式化 ≠ 加载期解耦 | §3.3含复验证据、§7.2 |
| G5 达成度口径 | _append_event 已注入关联字段;薄入口/命名/端点数不折算完成比例 | §4.1 |
| G6 公共能力范围 | 16 端点非能力上限;按客户端操作列能力清单;审批回答仍直调 manager | §4.3 |
| G7 Owner 与恢复分开 | 归属/共享/恢复三件事分开;内存态不构成多端共享障碍 | §5.1 |
| G8 缺陷验证口径 | N1 保持待复现N2 修复走显式 Run 上下文S10 丢更新另行判断 | §6.1、§6.2 |
| G9 路线重排 | 五步路线;定时任务后置 | §7 |
| G10 事件持久化边界 | 序号作用域先行非全局总序live delta 无需默认持久化 | §7.4 |
| G11 重启语义 | 断开/重启/执行继续三场景分开;持久化 pending ≠ 续跑TTL 后设 | §7.4、§6.2 |
| G12 传输与客户端验证 | 摆脱 Web 专属流程而非 HTTPstdio 防独立 Runtime 陷阱;极简客户端提前验收 | §7.3 |
| G13 完成判据 | 行为验收六条;测试须覆盖真实执行装配 | §7.7、§2.1 |
> 注:本文所有「文件:行号」证据为 2026-09-07 当日代码快照,后续变动以符号/函数名为准。

View File

@ -1,115 +0,0 @@
# Gateway 改造实现审核记录
> 审核日期2026-09-07
> 审核方式主智能体综合审阅3 个 Luna 子智能体分别核查服务初始化、协议与多端交互、执行环境边界。
> 审核基线:`b4deee0f`;目标提交:`d7cb854f`。结论只描述该提交快照,后续修复须重新验证。
> 本轮只审核并记录,未修改业务代码。本文不替换既有规划或子报告。
## 1. 范围与结论
本次审核以下 4 个提交:
| 提交 | 内容 |
|---|---|
| `d9bd599c` | Gateway 独立启动与公共协议落地 |
| `27aeed70` | Execution Contract 与替身执行器 |
| `4378eb6a` | 会话查询公共入口与代码就绪度收口 |
| `d7cb854f` | 审批/提问公共入口与①②③链路收口声明 |
对照依据:[当前状态规划](gateway_current_state.md) §7、[公共协议](../../docs/runtime_protocol.md)、[执行契约](../../docs/execution_contract.md),以及前序关于四层职责、公共入口、状态归属和行为验收的约定。
**结论改造已有实质进展但现有证据尚不能支持“①客户端—②Gateway—③Runtime 已完整贯通”的完成声明。** 当前完成的是主要进程内服务能力、独立初始化相关拆分和 E1E4 替身接入;公共任务发现、既有适配入口收敛、资源范围校验及验收可靠性仍需收口。
按规划中记录的本轮收口范围,生产 Host/Docker 迁入 ExecutionBackend、E5E10 全量接入、正式 CLI/GUI/IDE 客户端开发均已后置。本文不把这些已明确后置的工作算作本轮违约,也不要求事件全量持久化、重启续跑或 Remote Worker。
## 2. 完成度核查
| 能力 | 审核结论 | 证据与限制 |
|---|---|---|
| 任务核心与 Web 路由加载拆分 | 已实现 | `server/tasks/__init__.py` 不再顶层装配 Blueprint 与路由;`server/tasks/web.py` 承担 Web 装配 |
| 非 Web 环境的消息输出与后台任务启动 | 已提供适配 | `server/extensions.py` 增加 `emit_event` / `run_background`;独立生命周期仍需加强验收 |
| Run、审批、会话查询公共方法 | 主要方法已实现 | `server/runtime/service.py`;存在 F1、F3且 Web 调用尚未全部迁移F2 |
| 事件 offset 与窗口水位 | 进程内接口已实现 | `get_task_events` 返回 `meta.window_start`;现有 HTTP 轮询未透传该信息 |
| 多客户端发现、操作与恢复闭环 | 部分完成,未充分验收 | 新客户端缺活动 Run 发现入口;测试没有覆盖真实 A 发起/B 操作和断连重连 |
| CLI/GUI/IDE 独立接入 | 部分服务基础已具备 | 进程内直调成功不等于已有独立传输适配器;正式客户端后置可以接受,但应准确限定“代码就绪度” |
| ExecutionBackend | E1E4 替身接入已实现 | 默认 `execution_backend=None`;生产 Host/Docker 与 E5E10 后置,符合当前范围记录 |
## 3. 本轮需要收口的发现
以下均列为 **P2本轮完成验收前应修正或明确处理的事项**。其中 F1/F2 是能力与迁移缺口F3 是新增公共接口的资源校验缺口F4 是验证缺陷;不应全部表述为线上行为回归。
### F1新客户端无法通过公共入口发现已有任务
- **位置**`server/runtime/service.py:277`、`docs/runtime_protocol.md:4956`。
- **现状**RuntimeService 只有按已知 task_id 查询的 `get_task` / `get_task_events`,没有 `list_runs` / `list_tasks`。底层 `TaskManager.list_tasks` 已存在,旧 Web `/api/tasks` 也提供列表,但没有提升到公共服务。
- **触发场景**:客户端 A 发起任务后,客户端 B 新连接,尚不知道该 task_id。B 无法仅通过当前公共协议发现 A 的活动 Run仍需旧 Web 列表接口或其他协议外信息。
- **影响**:仅验证 `get_task(task_id)`不足以证明“B 经公共入口查询并操作”已经成立。
- **建议**:增加受授权的 Run 发现查询,可按工作区、会话和活动状态筛选;复用底层能力,返回适合公共协议的结果。
- **验收**A 创建 RunB 只持已授权身份及工作区/会话范围,通过公共查询取得该 Run再观察、回答审批或取消不从 A 的测试变量直接获取 task_id。
### F2既有适配入口未统一新恢复信息未到达实际客户端
- **位置**`server/tasks/api.py:236/281/304/332/355/377`、`server/api_v1.py:424/432/453`、`server/chat/approval.py:56/71/100/115/143/159`。
- **现状**Web/API 的事件轮询、取消、引导、队列操作仍有直接调用 task_manager 的路径;三类审批查询/回答仍直接调用对应 manager。
- **具体差异**RuntimeService 的事件查询新增 `meta.window_start`,旧 HTTP 轮询仍用 `get_events_since` 构造响应,不包含该水位。新增协议的缺口检测信息尚未到达这些客户端。
- **影响**:“公共方法已提供”不能等同于“既有入口均已转调”。多条路径仍可独立演进,新增语义也不会自动共享。直接调用同一 manager 不等于出现多份状态,但不满足本轮入口收敛约定。
- **建议**:相关 Web/API 路由转调公共服务,保留现有认证及兼容响应;将事件窗口信息适配到响应,并验证消费者的缺口处理。更新完成速览,区分接口存在、适配完成和行为验收。
- **验收**:同一组受理、取消、队列和审批行为,经 Web 适配与直接服务调用得到等价结果;裁剪事件窗口后,调用方能检测缺口并按已定义规则重新同步,不仅验证未裁剪时 window_start=0。
### F3会话查询未检查 principal 绑定的工作区
- **位置**`server/runtime/service.py:250270`,重点为 `:254269`
- **现状**`_resources_for_query` 只检查 `principal.username == username`,没有检查 principal 的 workspace_id 与查询目标一致,随后直接按调用参数的 workspace_id 装配资源。
- **已复现**:主侧用资源解析替身调用 `_resources_for_query('alice', 'B', TrustedPrincipal(username='alice', workspace_id='A'))`,得到 `resource_workspace='B'`,没有被拒绝。
- **影响**:服务接口未守住 TrustedPrincipal 所声明的资源范围。当前是进程内可信调用接口,不能据此断言已有外部可利用漏洞;但新适配器将外部查询参数传入时,这个不一致必须被阻止或经明确重新授权处理。
- **建议**:查询目标与已授权 principal 范围保持一致;若允许切换工作区,应显式完成目标授权,而不是直接信任第二份 workspace_id 参数。
- **验收**:覆盖同名用户不同工作区、用户名不一致、正确范围查询,以及 host/API/web 身份。当前测试只检查用户名不一致。
### F4独立验收测试存在假通过、隔离及配置依赖
- **位置**`test/runtime_standalone_checks.py:2527/83144/147258`、`config/__init__.py:5152`。
- **假通过**:生命周期测试接受任意 failed 终态terminal 不存在时跳过门闸检查。主侧在缺少模型配置、资源装配失败的环境中,观察到该生命周期测试仍通过。
- **交互覆盖不足**chain 测试在 Run 结束后手工向 manager 创建审批,再验证公共入口路由。这能验证查询、裁决和重复回答的服务行为,但不能证明“执行中的任务进入等待→另一客户端回答→原任务继续”。提问与计划确认的完整等待链同样缺少对应证据。
- **隔离缺口**:测试设置临时 ASTRION_DATA_ROOT 后config 仍会用源码目录 `.env` 的同名值覆盖。子审核在当前工作区执行时,观察到尝试访问真实运行态目录并被写权限拒绝;因此原隔离设置不能作为可靠保证。
- **配置依赖**:主侧在无 `.env`、无真实凭证的隔离源码副本中执行fake_exec 因“未配置可用模型”失败chain 因没有建立会话 ID 失败。测试未提供自包含的模型配置与受控响应。
- **建议**:提供确定的测试资源根、模型配置和模型响应替身,保留真实任务线程与资源装配。分别断言成功、取消、审批等待/继续、预期异常及清理结果,不用任意 failed 替代成功验收;强制确认 terminal、会话历史和门闸状态。
- **验收**:全新隔离目录、无真实凭证、禁用网络时可稳定复现;改变开发者 `.env` 不改变测试数据路径;人为使装配失败时,成功生命周期用例必须失败。
## 4. 已后置执行层工作的契约提醒
`modules/execution_plane/base.py:29` 声称路径授权与 `_validate_command` 已在 Runtime 编排层完成,但当前真实命令校验仍位于旧 `terminal_ops` 链路,文件路径检查也不能只依赖“先读后写”前置检查。注入 backend 的新分支未完整复用这些旧后端校验。
**当前默认生产路径未因此改变,不将其报告为现有 Host/Docker 权限绕过。** 但未来接入真实 ExecutionBackend 前,必须准确分配并保留命令校验、路径授权和实际执行限制;不能按照当前注释误认为上层已完成全部检查。
此项跟随已后置的③↔④工作处理,不作为本轮①②③收口的额外扩张。
## 5. 验证记录与限制
主侧测试方法:从目标 HEAD 导出临时源码副本(不带未跟踪 `.env`),指定临时数据/部署配置目录,移除继承的模型凭证,并通过测试进程的网络连接拦截禁止外部调用。未修改被审核源码。运行器使用仓库 `.venv/bin/python` 的 unittest discovery未依赖 pytest。
运行以下 5 组测试,共 **28 项26 通过、2 失败**
- `test_server_refactor_smoke.py`
- `test_runtime_service.py`
- `test_runtime_identity_resources.py`
- `test_conversation_model_persistence.py`
- `test_runtime_standalone_lifecycle.py`
| 失败用例 | 观察结果 | 解释 |
|---|---|---|
| `ExecutionPlaneFakeBackendTest.test_runtime_with_fake_execution_backend` | get_default_model_key 抛“未配置可用模型” | 未进入替身工具断言,不能认定工具实现回归;证明测试配置不自包含 |
| `ProtocolSmokeChainTest.test_full_chain_run_history_offset_approval` | “Run 应建立会话 id”断言失败 | 本次隔离环境下会话装配未成功,不能作为全链路完成证据 |
对照观察:同一环境下 `StandaloneRuntimeLifecycleTest` 通过,结合其接受 failed 及可跳过 terminal 检查的逻辑,确认 F4 的验收假通过问题。
另外完成了 F3 的最小替身复现、适配入口静态对照及最近提交范围核验。未执行真实模型调用、真实多客户端联调或 UI 人工验收;不据上述测试结果声称这些场景已通过,也不将这两项失败直接归为生产业务回归。
## 6. 建议收口顺序
1. 补公共 Run 发现查询,修复会话查询的工作区范围校验。
2. 将本轮承诺的 Web/API 控制、审批与事件观察入口转调公共服务,落实事件缺口响应与处理。
3. 修复测试隔离和自包含配置,用受控模型响应验证真实装配、执行中审批/提问、取消及清理。
4. 用两个相互独立的客户端调用方完成 A 发起、B 发现并操作、断连及重连验收;传输适配尚未实现时,明确记录进程内调用的实际覆盖范围。
5. 根据证据更新 `gateway_current_state.md` 完成速览;其余生产执行后端与正式客户端工作继续按已后置范围推进。
完成上述收口后,再将“主要服务方法已具备”升级为“本轮①②③边界已按约定验收”。

View File

@ -1,148 +0,0 @@
# Astrion 运行时边界整理与定时任务工作清单
> 更新日期2026-09-07
> 状态:修订后的设计与实施建议,尚未实施。
> 原题为「Astrion Gateway 化工作清单」,保留文件名便于既有引用。
> 依据:用户对目标的澄清、三份研究报告,以及本轮三个 Luna 子智能体的只读核查。
> 本轮核查为静态代码分析,没有运行并发、断线或进程重启复现;下文区分已有机制、待核验风险与拟新增能力。
## 0. 改造目标与范围
本次目标是让 Astrion 的内部职责、状态修改规则和任务入口更清晰,使后续功能沿稳定边界扩展。当前明确的新功能场景是**定时任务**。
Gateway 是长期架构方向;近期交付是在现有进程中整理运行时服务边界,让 Web、CLI 和定时触发器复用任务受理、执行与控制逻辑。验收依据是新增入口所需理解和修改的范围,以及现有行为是否得到保留。
近期主线:
1. 固定已有状态边界和正确性保障。
2. 抽出接收显式运行上下文的公共任务入口。
3. 通过定时任务验证该入口,并补齐调度所需的持久记录和生命周期。
事件持久化、传输替换、公开 SDK、设备配对和远程执行分别评估不构成本次完成的前置条件。
### 参考资料的使用边界
- 原始愿景:`.astrion/user_upload/Astrion_Architecture_Product_Roadmap_Review_1.md`,重点 §1014、§35、§3738。
- 原始盘点:`astrion_audit/astrion_gateway_gap.md`。其中“无唯一 owner”“补丁不是投影”“恢复机制脆弱”等结论须结合下表理解不能直接当作未修复故障。
- 外部研究:`opencode_study/opencode_architecture.md`、`openclaw_study/openclaw_gateway.md`。参考其职责分离、契约、幂等和恢复设计,不要求复制其部署、存储或认证方案;外部实现未在本轮重新核验。
## 1. 现状:已有保障、剩余耦合与待核验事项
以下路径相对仓库根目录;行号是本轮读取时的定位锚点,后续以符号为准。
| 类别 | 当前证据 | 对改造的含义 |
|---|---|---|
| 已有:对话级资源隔离 | `server/context.py:58` 按 username/workspace/conversation 生成终端 key | 保留每对话资源边界;多端共享同一对话资源本身合理 |
| 已有:主任务门闸 | `server/chat_flow.py:139` 获取门闸,`:258` 在 finally 释放;实现位于 `server/main_task_gate.py` | 公共入口复用同一执行裁决,不新增平行门闸 |
| 已有:对话保存保护 | `utils/conversation_manager/crud_mixin.py:335` 按 message_id 合并、防止意外缩减;`:194` 使用 I/O 锁;`index_mixin.py:104` 原子替换文件 | 将保存语义纳入契约;不能因存在内存和文件副本就断言写入竞争未解决 |
| 已有:客户端恢复 | `static/src/stores/task.ts:156` 偏移轮询;`static/src/app/methods/taskPolling/probe.ts:73` 对账恢复;同目录 `lifecycle.ts:137` 按 task_id/idx 去重 | 保留恢复闭环和过期响应过滤;当前轮询是增量事件读取,不是每轮全量读取 |
| 已有:审批单次决定 | `modules/tool_approval_manager.py:65` 在锁内裁决 pending已决定时返回现状 | 复用决定规则;记录谁决定与提前认领审批是不同需求 |
| 耦合:执行依赖 Web 环境 | `server/tasks/models.py:785` 后台任务建立 test_request_context、回填 session 后获取资源和执行 | 优先抽出显式上下文,减少隐式请求状态依赖 |
| 耦合:写入口分散 | 任务 manager、terminal、conversation manager 各有职责和直接调用方 | 按状态明确权威、写入口和缓存更新;新增门面不是完成所有权收敛的证明 |
| 边界:过程记录为内存态 | `server/tasks/models.py:75` 有界事件 deque`:108` 清理旧任务,`:762` 分配任务级 idx审批 manager 为进程内实例 | 不能承诺服务重启续跑或无限期回放;也不能说任务一结束事件就立即丢失 |
| 优化候选:用户级广播 | `server/context.py:75` 给事件补 conversation_id由客户端筛选 | 当前也是投影方案;对话级订阅用于减少无关流量和明确受众,不是 Owner 正确性的必选前提 |
| 新能力:用户定时任务 | 本轮搜索只发现任务清理、idle reaper 等维护定时器,未发现用户 Schedule 实体与到期派发链路 | 单独设计调度状态与记录,复用现有任务执行 |
保留两个有界核验项,避免将静态推断直接升级为故障结论:
- `server/tasks/models.py:157` 的“检查运行中任务→创建记录”与最终执行门闸分属两处。核验并发请求是否产生重复任务记录;不能据此断言会并发修改同一对话。
- 原报告提到 `server/chat/terminal.py::issue_socket_token` 发放竞争。本轮未重新复现;若仍存在,作为独立的小范围缺陷处理,不捆绑整个运行时改造。
## 2. 阶段一:固定契约与已有不变量
**目标:明确状态属于谁、新入口应该调用哪里。**
- [ ] 产出 `docs/runtime_contract.md`,先描述内部服务契约;暂不强制公开网络协议、握手或实体全面改名。
- [ ] 建立状态责任表:每项列明权威来源、允许的修改方、持久化入口、缓存刷新、并发裁决和失效条件。内存/文件/客户端缓存可以共存,权威关系必须明确。
- [ ] 对齐概念Session 对应现有 conversationRun 对应一轮主任务Schedule 表示计划Occurrence 表示某次到期触发Event 表示变化通知。子 agent、后台命令与主任务的关系单独说明不直接把所有现有 task 等同主 Run。
- [ ] 审批与用户提问保留不同语义。可共享 ID、关联、等待和回答的基础设施但提问答案不能被当作工具执行授权。
- [ ] 明确公共入口所需的 principal、workspace、conversation、模型/运行配置与事件输出接口;默认值由明确的解析步骤产生。
- [ ] 建立调用方迁移表Web/CLI 对应 API、Workflow 激活、通知派发、定时触发。每项标注上下文来源、门闸获取/释放和取消传播。
- [ ] 围绕修改范围保留或补充回归用例:同对话并发、不同对话隔离、保存不丢消息、取消、审批重复回答、偏移恢复和过期响应过滤。
**完成标准:**目标入口的状态修改路径和执行裁决可定位;已有保障成为明确约束,未验证风险有独立记录。
## 3. 阶段二:抽出显式上下文与公共任务入口
**目标:无浏览器也能通过受控入口启动、观察和停止一轮任务。**
```text
Web / CLI 适配层 定时触发器 Workflow / 通知派发
\ | /
公共任务受理与控制入口
|
现有执行门闸与 Agent 执行
|
现有保存、审批、事件和取消链路
```
- [ ] 以 `TaskManager.create_chat_task` 及执行链路为基础抽出服务接口。`RuntimeService` 可作为名称候选;文件位置按真实职责确定,避免把所有 manager 和状态塞入一个新类。
- [ ] 服务接口至少覆盖提交、查询和取消;审批回答复用现有 manager通过明确关联接入服务层。会话创建按需要复用现有服务第一条验证链路可使用已有会话。
- [ ] HTTP 参数解析、Cookie/CSRF/Bearer 验证放在适配层,向服务层传入可信 principal 和经校验的资源范围。不能接受客户端或 Schedule payload 自报的 role 作为授权依据。
- [ ] 消除目标执行链路对隐式 Flask session 的读取。迁移期可在明确的兼容适配层保留旧上下文,但须列出剩余依赖;只把 test_request_context 包进新方法不算完成解耦。
- [ ] 复用门闸、取消、审批和保存规则;分别定义任务受理去重与实际执行互斥,防止重复启动或门闸泄漏。
- [ ] Web 任务入口先接入服务,再逐条迁移 Workflow/通知等调用方;每次明确哪些旧写路径已封闭。不能把“门面转发成功”写成“全部状态唯一 Owner 已完成”。
- [ ] 内部错误采用稳定状态/错误码HTTP 状态码由适配层映射;事件先适配现有 idx/offset 和 sender不同时重写客户端。
**完成标准:**不创建浏览器会话、不伪造 HTTP 请求,测试能通过显式身份和资源上下文启动一次受控任务、读取结果并取消;同对话重入仍受门闸保护;现有 Web/CLI 行为兼容。执行可使用可控模型/工具替身验证,无需真实外部副作用。
## 4. 阶段三:定时任务纵向落地
**目标:时钟成为公共任务入口的另一个调用方。**
本节是待实施设计,不代表当前已有调度器。具体 UI 和默认行为在实现前确认,下列保守默认作为讨论起点。
### 4.1 计划与触发记录
- [ ] Schedule 至少保存ID、所属 principal、目标 workspace、会话策略、提示词/任务配置、时间规则、时区、启用状态和配置版本。明确夏令时重复/不存在时刻的处理。
- [ ] 支持创建、暂停、恢复和删除计划。建议暂停/删除只影响未来触发,已受理的 Run 另行取消;最终行为须明确。
- [ ] 明确使用已有会话还是每次新建会话;首版可只实现一种,须说明上下文累积、目标删除和工作区失效时的行为。
- [ ] 模型配置确定“创建时固定”还是“触发时解析”;权限在触发时按当前有效授权重新校验,不保存可绕过权限变更的长期授权快照。
- [ ] 每个 Occurrence 有稳定触发标识,例如 schedule_id + 计划时间点保存所用配置版本、受理状态、run_id 和终态。计划编辑后的未来触发身份规则须明确。
### 4.2 重复、重叠与停机
- [ ] 为 Occurrence 登记和 Run 受理定义持久幂等规则:同一触发重试返回同一受理结果,相同键不同参数拒绝;记录保留期覆盖允许的重试窗口。
- [ ] 明确“登记后未启动”“已启动但关联未写完”等崩溃窗口。登记/受理应原子提交或具备可验证的恢复对账;不能只用内存 TTL 承诺跨重启不重复执行。
- [ ] 选定单个活动调度器的启动与所有权规则,防止重载器或多个服务进程重复派发;不要求因此引入分布式基础设施。
- [ ] 同会话已有任务或上一轮仍在运行时,定义 skip/queue/parallel 策略。建议首版跳过并记录原因,沿用会话门闸,不默默增加无限队列。
- [ ] 定义服务关闭期间错过触发的策略。建议首版记录错过并等待下个未来时点,不集中补发;服务需运行才会触发,本阶段不包含操作系统唤醒/开机自启。
- [ ] 重启后恢复 Schedule 和触发记录;上一进程未确认完成的 Run 标为中断或结果待核验,不显示仍在正常运行,不自动重做可能已产生副作用的动作。
- [ ] 触发去重仅保证任务受理规则,不承诺任意外部工具副作用 exactly-once。无法确认的执行结果进入显式待核验状态。
### 4.3 审批、存储与验收
- [ ] 无人在线时仍保持原有权限限制:遇到审批/提问按明确超时等待,超时结束或中断本轮并记录原因,不自动扩大权限。等待中的任务也遵守重叠策略。
- [ ] 审批关联当前 Run/会话,复用现有 pending/answer 链路。重启后旧请求不能被当作仍有执行现场的有效审批;持久审批记录与继续执行分别设计。
- [ ] 为 Schedule/Occurrence/必要的运行摘要选择持久化方案,先列出原子更新、唯一性、查询和恢复要求,再决定文件或 SQLite。数据走运行态路径解析不写源码树不强制迁移全部对话历史。
- [ ] 沿用当前任务事件读取;补充触发记录查询,使旧内存任务被清理或重启后仍能解释触发结果。日志记录 schedule_id/occurrence_id/run_id复用既有设施。
**完成标准:**可控时钟与执行替身验证一次触发、重复触发、任务重叠、计划暂停/恢复、目标失效、无人审批超时、停机错过触发及重启对账;真实入口验证不依赖浏览器。明确只恢复计划与记录,不承诺从任意执行位置续跑。
## 5. 后续独立决策:有明确需求再启动
| 决策 | 启动条件 | 决策前必须补充的内容 |
|---|---|---|
| SSE / WebSocket / 保持轮询 | 现有延迟、连接数或带宽不能满足目标,或新增双向交互需求 | 部署 worker 模型、代理缓冲、重连、慢消费者处理;传输更换不等于状态正确性提升 |
| 对话级订阅 | 需要减少无关广播、精确控制受众 | 订阅与资源授权分别校验,区分任务/会话事件与用户级通知 |
| durable 事件与快照恢复 | 需要超出内存保留窗口的过程追溯、跨重启生命周期查询 | 提交时机、序号作用域、快照边界、日志裁剪、缺口处理与 schema 版本 |
| 审批持久化与可恢复执行 | 需要重启后继续等待并执行原动作 | 重建上下文和待执行动作、权限重校验、过期请求处理及结果不确定性;恢复 pending 记录不能实现续跑 |
| TS 类型 / SDK 生成 | 对外契约或多客户端类型维护成为实际成本 | 选一个 schema 权威源、兼容策略与生成检查;不强制新网络握手 |
| 身份体系整理 | 跨凭证访问同一资源或统一授权成为需求 | 统一 principal/resource/authorization各适配层可保留不同凭证机制不能直接合并 Web/API 用户数据空间 |
| 设备配对 / Remote Worker | 明确需要多设备接入或远程执行 | 执行契约、连接身份、所有权转移及故障语义,独立设计验收 |
若进入事件恢复改造,必须遵守以下边界:
1. 快照注明覆盖的事件水位 N并与同一状态版本一致续传从 N 之后开始。快照生成、订阅和历史读取之间不得有漏事件窗口,重复投递仍需幂等应用。
2. durable 事件只承诺已提交记录可恢复live delta 是否恢复单独定义。运行中未完成的 Item 需要当前内容快照、覆盖式更新或不完整标记,不能只依赖最终 completed 事件。
3. 状态和事件写入需要一致的提交/恢复规则。对话 JSON、事件 JSONL、审批 JSONL 不天然构成一致快照与日志。
4. 连接序号、任务偏移和持久会话序号不能混用;连接重建、历史裁剪或会话重置时给出明确的重新同步规则。
5. JSONL 与 SQLite 都需定义恢复、保留和迁移方案。选择依据是事务与查询边界,不承诺以后可低成本平移。
## 6. 改造完成的判断
- 新增任务来源只需构造显式上下文、校验目标并调用公共入口,无需复制 Web 聊天启动流程。
- 已迁移路径有明确状态权威与写入规则,现有门闸、保存和客户端恢复保护得到保留。
- 定时任务有可查询的计划、触发记录和终态;重复、重叠、权限变化与停机行为可解释。
- 各阶段可独立验证;只为当前边界迁移做必要的接口调整,不同时重写 Agent loop、前端状态管理或部署拓扑。
- 每阶段说明实际迁移的入口、剩余兼容依赖和验证结果。完成门面、生成架构图或更换传输本身不算完成改造。

View File

@ -1,744 +0,0 @@
[OpenClawDocs](/)
[**View as Markdown**View this page as plain text↗](/gateway/protocol.md)[**Open in ChatGPT**Ask questions about this page↗](https://chatgpt.com/?hints=search&q=Read%20from%20https%3A%2F%2Fdocs.openclaw.ai%2Fgateway%2Fprotocol.md%20so%20I%20can%20ask%20questions%20about%20it.)[**Open in Claude**Ask questions about this page↗](https://claude.ai/new?q=Read%20from%20https%3A%2F%2Fdocs.openclaw.ai%2Fgateway%2Fprotocol.md%20so%20I%20can%20ask%20questions%20about%20it.)
Gateway
# Gateway protocol
Gateway & OpsGateway
The Gateway WS protocol is the single control plane and node transport for OpenClaw. Operator and node clients (CLI, web UI, macOS app, iOS/Android nodes, headless nodes) connect over WebSocket and declare a **role** and **scope** at handshake time.
## npm packages
The verified stable package release is `2026.8.1`. Follow [Install the packages](/gateway/clients#install-the-packages) for exact-version commands and compatibility guidance. Package release versions are separate from the wire protocol version and the root `openclaw` CLI release.
* [`@openclaw/gateway-protocol`](https://www.npmjs.com/package/@openclaw/gateway-protocol) publishes the schemas, validators, TypeScript types, lightweight frame and error helpers, and version constants. Its tarball includes the generated [`protocol.schema.json`](https://unpkg.com/@openclaw/gateway-protocol@2026.8.1/protocol.schema.json) machine-readable contract as a downloadable file, not an exported import subpath.
* [`@openclaw/gateway-client`](https://www.npmjs.com/package/@openclaw/gateway-client) publishes the reference Node client and a browser-safe entry at `@openclaw/gateway-client/browser`.
For application lifecycle guidance, see [Building a Gateway client](https://docs.openclaw.ai/gateway/clients). For apps that supervise the Gateway as a child process, see [Embedding OpenClaw](https://docs.openclaw.ai/gateway/embedding).
## Transport and framing
* WebSocket, text frames, JSON payloads.
* First frame **must** be a `connect` request.
* Pre-connect frames are capped at 64 KiB (`MAX_PREAUTH_PAYLOAD_BYTES`). After handshake, follow `hello-ok.policy.maxPayload` and `hello-ok.policy.maxBufferedBytes`. With diagnostics enabled, oversized inbound frames and slow outbound buffers emit `payload.large` events before the gateway closes or drops the frame. These events carry `surface`, byte sizes, limits, and a safe reason code, never message bodies, attachment contents, raw frame bytes, tokens, cookies, or secrets.
* The Gateway offers `permessage-deflate`. Peers that negotiate it (browsers, `ws` clients) receive frames of 4 KiB and up compressed; smaller frames such as streaming deltas stay raw. Context takeover is disabled in both directions, so each frame compresses independently. Peers that do not offer the extension are unaffected. Payload limits apply to the inflated size.
Frame shapes:
* Request: `{type:"req", id, method, params, traceparent?}`
* Response: `{type:"res", id, ok, payload|error}`
* Event: `{type:"event", event, payload, seq?, stateVersion?}`
After authentication, a client may include a W3C `traceparent` string on each request frame. The Gateway continues a valid value as a child trace context for that request. Missing or syntactically malformed values within the 128-character field limit keep the default fresh request trace and do not fail the RPC; longer values make the request frame invalid. The initial `connect` request never establishes trace context for later frames. Use a separate `traceparent` for each logical request on a long-lived connection; do not treat the WebSocket itself as one trace.
Response errors use `{ code, message, details?, retryable?, retryAfterMs? }`. Authenticated operator requests share a bounded queue for starting RPC handlers. When waiting capacity is exhausted, the Gateway returns retryable `UNAVAILABLE` before the method runs; retry within the request's budget. Started requests complete concurrently, so responses can arrive out of order.
Ordinary UI/SDK requests may outlive a socket disconnect, but cannot start a handler in a retiring Gateway instance. Shutdown fences new request entry and joins pending handler loading and authorization before releasing their runtime. Already-started methods retain their own shutdown behavior; shutdown does not wait for every RPC to finish. Exact pending node progress and result replies remain available during node cleanup, until transport shutdown seals entry.
Clients should branch on `code` and `details.code`; `message` remains human-readable and can change except where a compatibility note says otherwise. Method-level authorization failures use top-level `code: "FORBIDDEN"` with structured missing-scope details:
* Missing scope: `{ code: "MISSING_SCOPE", missingScope, requiredScopes }`. `requiredScopes` is the complete known scope set for the requested operation. The legacy `missing scope:` message is retained for older clients.
Clients should read `details` first and use the legacy message only as a compatibility fallback. `readMissingScopeError` and `readMissingScopeErrorDetails` are exported from `@openclaw/gateway-protocol/gateway-error-details`; the browser-safe gateway client re-exports them from `@openclaw/gateway-client/browser`.
The schemas are exported as `GatewayErrorDetailsSchema`, `MissingScopeErrorDetailsSchema` from `@openclaw/gateway-protocol/schema`. HTTP scope failures mirror the `MISSING_SCOPE` object under `error.details` and use HTTP status `403`.
Side-effecting methods require idempotency keys (see schema).
## Gateway-controlled WebRTC Talk
`talk.client.create` accepts the additive capability `gateway-control-v1`. OpenAI GA Realtime requires resolvable Platform API-key authentication for this mode. Native GPT-Live retains its configured ChatGPT OAuth or Platform API-key authentication. A successful result includes `clientControl: { owner: "gateway" }`, a 60-second single-use Gateway broker token in `clientSecret`, and the relative `offerUrl: "/plugins/openai/realtime/calls"`.
The client sends only `application/sdp` to that route with the broker token. It must not create a provider control data channel. The Gateway creates the call, attaches the provider sideband before returning the answer SDP, and owns tool, transcript, steering, cancellation, and close lifecycle. Clients that omit the capability retain the existing browser session behavior. A Gateway or configured authentication path that cannot provide the requested owner returns `UNAVAILABLE`; it never downgrades the request to client-owned control.
Clients must close their local media peer if the Gateway connection is lost or a `talk.event` for their current `voiceSessionId` contains `talkEvent.type: "session.closed"`. Ignore terminal events for other calls; a recoverable `session.error` alone is not a close notification.
## Handshake
Gateway sends a pre-connect challenge:
Device-auth clients use the challenge `ts` as `connect.params.device.signedAt`. For WebSocket challenges, `ts` must be a non-negative integer. Clients that explicitly support Gateways from before `connect.challenge` existed may use local time only when no challenge arrives; a received challenge with an absent or malformed `ts` is invalid.
Client replies with `connect`:
Gateway responds with `hello-ok`:
`server`, `features`, `snapshot`, `policy`, and `auth` are all required by `HelloOkSchema` (`packages/gateway-protocol/src/schema/frames.ts`). `auth` reports the negotiated role and the current socket's effective authorization scopes even when no device token is issued (shape above). `deviceToken`, when present, is the primary reusable credential for the same device and role. `controlUiUrl` optionally advertises the Gateway's configured public Control UI origin and base path for shareable links, independent of the client's tunnel or development-server address. It is omitted when `gateway.publicOrigin` is unset or the Control UI is disabled. It contains no credentials and grants no access. `policy.attachments` is optional (older gateways omit it) and advertises the decoded-size ceilings chat attachments face on `chat.send`, `sessions.send`, and session-creation initial turns:
| Field | Meaning |
| --- | --- |
| `maxBytes` | Largest decoded size accepted for a single attachment (`agents.defaults.mediaMaxMb`, default 20 MB) |
| `maxImageBytes` | Largest decoded size accepted for a single image: `min(maxBytes, 6 MB agent-hydration cap)` |
Validating before send:
1. Check each file's decoded size against `maxImageBytes` for images and `maxBytes` for everything else.
2. Serialize the whole request and check its encoded size against `policy.maxPayload`. `policy.attachments` is a per-attachment ceiling, never a promise the frame fits: attachments travel as base64, so a 20 MB file is about 26.7 MB on the wire and exceeds the default 25 MiB frame limit on its own.
3. Treat the server as authoritative for everything else. Accepted MIME types and per-message handling are deliberately not advertised because they depend on the entrypoint, the resolved model, and payload sniffing. The gateway can return a typed rejection, while text-only model runs can omit additional images after their offload cap and still complete the request.
4. Re-read the values on every reconnect. They are a connection-time snapshot, so a live `mediaMaxMb` edit reaches existing connections only after they reconnect.
`pluginSurfaceUrls` is optional and maps plugin surface names (e.g. `canvas`) to scoped hosted URLs; it may expire, so nodes call `node.pluginSurface.refresh` with `{ "surface": "canvas" }` for a fresh entry. The deprecated `canvasHostUrl` / `canvasCapability` / `node.canvas.capability.refresh` path is not supported; use plugin surfaces. The `sessions.observer.ask` method was removed; use `sessions.companion.ask`. The snapshot's optional `appliedConfigHash` is the resolved source-config revision accepted by the active Gateway runtime. Clients can compare it with `config.get.configRevisionHash` to determine whether a newer saved config still needs a restart. `config.get.hash` remains the raw root-file revision used by config write conflict guards.
The snapshot's optional `controlUiIdentityUrl` advertises the active Gateway's HTTPS dashboard URL when it uses trusted-proxy or Tailscale Serve identity. Operator clients can open this URL for personal browser sign-in instead of forwarding shared device credentials. The URL includes the Control UI base path; clients must use normal HTTPS trust instead of native TLS pins and must not send native connection tokens or passwords to it. Re-read it from each authenticated hello snapshot and discard it when that connection closes. If the managed Serve route exits or is replaced, the Gateway closes connections that received its identity URL with code `1012`; reconnect to discover the current route.
`openclaw.setup.verify` additionally checks the Gateway's current application and restart state before and after its live inference probe. It returns `{ ok: false, status: "unavailable", error }` while saved settings are not active, restart work remains, or the verified runtime changes during the probe. Clients should preserve the selected model and retry after application or restart finishes. Standalone CLI verification still tests saved configuration without requiring a running Gateway.
While the gateway is still finishing startup sidecars, `connect` can return a retryable `UNAVAILABLE` error with `details.reason: "startup-sidecars"` and `retryAfterMs`. Retry within your connection budget instead of treating it as a terminal handshake failure.
When a device token is issued, `hello-ok.auth` adds it:
Built-in QR/setup-code bootstrap is a mobile handoff path. A successful baseline setup-code connect returns a primary node token plus one bounded operator token:
This operator handoff is bounded on purpose: enough to start the mobile operator loop and native setup, with `operator.write` satisfying Talk sessions and `operator.talk.secrets` covering Talk config reads, but no pairing-mutation scopes and no `operator.admin`. Broader pairing/admin access needs a separate approved pairing or token flow. Persist `hello-ok.auth.deviceTokens` only when bootstrap auth ran over a trusted transport (`wss://` or loopback/local pairing).
Trusted local backend clients (`client.id: "gateway-client"`, `client.mode: "backend"`) may omit `device` on direct loopback connections when authenticating with the shared gateway token/password. This path is reserved for internal control-plane RPCs (e.g. subagent session updates) and avoids stale CLI/device pairing baselines blocking local backend work. The exception also applies when that backend supplies a signed device identity: it does not create a pairing record, so an unpaired identity receives no device token. Remote, browser-origin, node, and non-backend clients follow their normal pairing and scope-upgrade policies. Device-token authentication still validates the existing token's role and scopes before any local-backend pairing exception.
### Worker role and closed protocol
Workers use a closed protocol through either the public `/__openclaw__/worker` WebSocket path on the main TLS endpoint or the dedicated loopback ingress reached through the gateway-owned, host-key-pinned SSH tunnel. The route selects worker mode before reading frames, so it never dispatches general auth, node events, operator RPCs, or plugin methods. Public admission shares the main per-client pre-auth budget and authentication rate limiter; its wire errors collapse credential and environment details to `admission-rejected`, while trusted gateway diagnostics retain the internal reason. A strict `connect` verifies a hash-at-rest, short-lived credential bound to the environment, bundle hash, owner epoch, RPC-set version, expiry, and one nullable session; it separately checks the current version and feature set. Success returns minimal `worker-hello-ok`; feature negotiation is independent of the general protocol version. Frames stay under 64 KiB, except a negotiated `worker.inference.start` frame may be up to 25 MiB. The closed allowlist contains `worker.heartbeat`, `worker.transcript.commit`, `worker.live-event`, `worker.inference.start`, and `worker.inference.cancel`.
For an identity-audited attached run, the live turn capability can record the credential, build, owner-epoch, and placement checks as one enforced admission receipt. The receipt contains none of the credential, build hashes, tokens, environment id, or session id. Worker operation rows and placement state remain their authoritative owners; successful connection is not an action-success receipt.
Transcript commits use owner-epoch fencing, a gateway-owned session binding, base-leaf compare-and-swap, and durable sequence replay; the gateway generates transcript entry and parent IDs through the normal session writer. Ownership and expiry are rechecked on each RPC.
### Client capabilities
Operator clients may advertise optional capabilities in `connect.params.caps`:
* `tool-events`: accepts structured tool lifecycle events.
* `inline-widgets`: can render hosted inline widget tool results.
Client capabilities describe the connected client, not authorization. Agent tools may declare required capabilities; the Gateway omits those tools unless every requirement appears in the originating client's `caps`. Channel-originated runs have no Gateway client capabilities, so capability-gated tools are unavailable even when tool policy explicitly allows them.
### Node connect example
Nodes declare capability claims at connect time:
* `caps`: high-level categories such as `camera`, `canvas`, `screen`, `location`, `voice`, `talk`.
* `commands`: command allowlist for invoke.
* `permissions`: granular toggles (e.g. `screen.record`, `camera.capture`).
The gateway treats these as claims and enforces server-side allowlists.
## Roles and scopes
For the full operator scope model, approval-time checks, and shared-secret semantics, see [Operator scopes](/gateway/operator-scopes).
Roles:
* `operator`: control-plane client (CLI/UI/automation).
* `node`: capability host (camera/screen/canvas/system.run).
* `worker`: cloud execution host on the dedicated, closed worker protocol.
Operator scopes (`src/gateway/operator-scopes.ts`), the full closed set:
* `operator.read`
* `operator.write`
* `operator.admin`
* `operator.approvals`
* `operator.questions`
* `operator.pairing`
* `operator.talk`
* `operator.talk.secrets`
`operator.write` continues to satisfy `operator.talk` for compatibility with existing clients. Voice-device setup can issue the narrower Talk grant without general Gateway write access.
`talk.config` with `includeSecrets: true` requires `operator.talk.secrets` (or `operator.admin`). When secrets are included, read the active Talk provider credential from `talk.resolved.config.apiKey`; `talk.providers..apiKey` stays source-shaped and may be a SecretRef object or a redacted string.
Plugin-registered gateway RPC methods may request their own operator scope, but these reserved core prefixes always resolve to `operator.admin` (`src/shared/gateway-method-policy.ts`): `config.*`, `exec.approvals.*`, `wizard.*`, `update.*`.
Method scope is only the first gate. Some slash commands reached through `chat.send` apply stricter command-level checks: persistent `/config set` and `/config unset` writes require `operator.admin` even for gateway clients that already hold a lower operator scope.
`node.pair.approve` has an extra approval-time scope check on top of the base method scope (`operator.pairing`), based on the pending request's declared `commands` (`src/infra/node-pairing-authz.ts`):
| Declared commands | Required scopes |
| --- | --- |
| none | `operator.pairing` |
| ordinary commands | `operator.pairing` + `operator.write` |
| includes `system.run`, `system.run.prepare`, `system.which`, `browser.proxy`, `browser.proxy.upload.v1`, `fs.listDir`, or `system.execApprovals.get/set` | `operator.pairing` + `operator.admin` |
In this table, `fs.listDir` is the node command relayed through `node.invoke`. The top-level Gateway `fs.listDir` RPC needs `operator.write` for workspace-contained host browsing and `operator.admin` when `nodeId` is present. Pass directory paths exactly as returned by `fs.listDir`: whitespace in directory names, including trailing spaces, is significant.
### Caps/commands/permissions (node)
Nodes declare capability claims at connect time:
* `caps`: high-level capability categories such as `camera`, `canvas`, `screen`, `location`, `voice`, and `talk`.
* `commands`: command allowlist for invoke.
* `permissions`: granular toggles (e.g. `screen.record`, `camera.capture`).
The Gateway treats these as **claims** and enforces server-side allowlists. Connected nodes can publish optional agent-visible plugin or MCP tool descriptors with `node.pluginTools.update` after a successful connect or reconnect. Headless node hosts restart to apply declarative MCP inventory changes. This update method is the only publication path; plugin tool descriptors are not accepted in `connect` params. Each descriptor must use a provider-safe tool `name` and name a `command` in the node's current command allowlist. The Gateway trusts descriptor metadata from the paired node, filters descriptors outside the approved command surface, removes them when the node disconnects, and rejects operator attempts to mutate another node's catalog. Set `gateway.nodes.pluginTools.enabled: false` to ignore node-published descriptors.
Connected node hosts publish their complete skill replacement catalog with `node.skills.update`. This node-role method is the only node skill publication path; skills are not accepted in `connect` params. Each descriptor contains a safe name, description, and bounded `SKILL.md` content. The Gateway parses that content with the normal skills loader, includes it in agent skill snapshots while the node is connected, and removes it on disconnect. Set `gateway.nodes.allowSkills: false` to ignore node-published skills.
## Presence
* `system-presence` returns entries keyed by device identity, including `deviceId`, `roles`, and `scopes`, so UIs can show one row per device even when it connects as both operator and node.
* `node.list` includes optional `lastSeenAtMs` and `lastSeenReason`. Connected nodes report current connection time with reason `connect`; paired nodes can also report durable background presence via a trusted node event.
Native macOS nodes can also send authenticated `node.presence.activity` events with bounded input idle time. The Gateway derives activity timestamps on its own clock, exposes the freshest connected Mac through `node.list` and `node.describe`, and broadcasts `node.presence` updates to read-scoped clients. The app sends `{ "action": "clear" }` when the user disables activity sharing; the Gateway clears timestamps only for that exact authenticated node connection. Gateways that predate this acknowledged action return it as unhandled, so the Mac node reconnects once and lets disconnect cleanup remove the old connection state. See [Active computer presence](/nodes/presence) for selection, privacy, model context, and notification-routing behavior.
### Node host stats
Connected CLI node hosts and the macOS app's shared node-host worker send a resource snapshot immediately after connecting, then every 60 seconds. They call `node.event` with `event: "node.host.stats"` and an object `payload` (or its JSON encoding in `payloadJSON`):
`cpuCount` is an integer from 1 to 4096. Optional `loadAverage` contains the 1-, 5-, and 15-minute averages, each finite and between 0 and 100000. Windows has no load average; hosts omit the field when all three readings are zero. Memory and disk values are non-negative integer bytes, with free or available bytes no greater than their total. Disk fields appear together only when the host can read capacity for the volume containing its home directory, independent of the worker's current directory.
The Gateway accepts updates only from the current node connection and stamps `updatedAtMs` with its own receipt time; nodes never send a timestamp. Successful updates appear as `hostStats` in `node.list` and `node.describe` and broadcast `node.hostStats` with `{ nodeId, hostStats }` to read-scoped operators, using `dropIfSlow: true`. Stats are operator-facing and do not update model-visible node context. When received, the Gateway persists the snapshot as `lastHostStats` on the paired node record. Disconnecting or reconnecting without a new snapshot leaves the previous value intact. `node.list` and `node.describe` use live session stats while connected and project the saved snapshot as `hostStats` while offline, keeping its original `updatedAtMs` so clients can show the last-known age.
The structured `node.event` result uses `reason: "updated"`, `"stale_connection"`, or `"invalid_payload"`. An older Gateway may return `handled: false`; the node continues at the normal cadence without an immediate retry.
### Node background alive event
Nodes call `node.event` with `event: "node.presence.alive"` to record that a paired node was alive during a background wake, without marking it connected:
`trigger` is a closed enum: `background`, `silent_push`, `bg_app_refresh`, `significant_location`, `manual`, `connect`. Unknown values normalize to `background` (`src/shared/node-presence.ts`). The event only persists for authenticated node device sessions; device-less or unpaired sessions return `handled: false`.
Successful gateways return a structured result:
Older gateways may return only `{ "ok": true }` for `node.event`; treat that as an acknowledged RPC, not durable presence persistence.
## Broadcast event scoping
Server-pushed broadcast events are scope-gated so pairing-scoped or node-only sessions do not passively receive session content (`src/gateway/server-broadcast.ts`):
* Chat, agent, and tool-result frames (streamed `agent` events, tool-result events) require at least `operator.read`. Sessions without it skip these frames entirely.
* Plugin-defined `plugin.*` broadcasts are gated to `operator.write` or `operator.admin` by default; explicit entries such as `plugin.approval.requested` / `plugin.approval.resolved` use `operator.approvals` instead.
* Status/transport events (`heartbeat`, `presence`, `tick`, connect/disconnect lifecycle) stay unrestricted so transport health is observable to every authenticated session.
* Unknown broadcast event families are scope-gated by default (fail-closed) unless a registered handler explicitly relaxes them.
Each client connection keeps its own per-client sequence number, so broadcasts stay monotonically ordered on that socket even when different clients see different scope-filtered subsets of the event stream.
`hello-ok.features.capabilities` advertises additive wire contracts. Native clients send `sessionKey` in `chat.metadata` only when `session-scoped-chat-metadata` is present; otherwise they retain the agent-only request supported by stable `v2026.7.1-2`. That older response describes agent-wide availability, not a session's selected profile. Retire this negotiation only when the minimum supported Gateway contract guarantees session-scoped metadata. Method or event presence alone is insufficient.
## RPC method families
`hello-ok.features.methods` is a conservative discovery list built from `src/gateway/server-methods-list.ts` plus loaded plugin/channel method exports — it is not a generated dump of every method, and some methods (for example `push.test`, `web.login.start`, `web.login.wait`, `sessions.usage`) are intentionally excluded from discovery even though they are real, callable methods. Treat this as feature discovery, not a full enumeration of `src/gateway/server-methods/*.ts`.
System and identity
* `health` returns the cached or freshly probed gateway health snapshot.
* `diagnostics.stability` returns the recent bounded diagnostic stability recorder: event names, counts, byte sizes, memory readings, queue/session state, channel/plugin names, session ids. No chat text, webhook bodies, tool outputs, raw request/response bodies, tokens, cookies, or secrets. Requires `operator.read`.
* `status` returns the `/status`-style gateway summary; sensitive fields only for admin-scoped operator clients.
* `gateway.identity.get` returns the gateway device identity used by relay and pairing flows.
* `system-presence` returns the current presence snapshot for connected operator/node devices.
* `system-event` appends a system event and can update/broadcast presence context.
* `last-heartbeat` returns the latest persisted heartbeat event.
* `set-heartbeats` toggles heartbeat processing on the gateway.
* `gateway.restart.preflight` is a deprecated, read-only compatibility preview of restart-specific active work. It does not close admission, create a suspension lease, or provide the atomic full-work fence of `gateway.suspend.prepare`; new restart flows should call `gateway.restart.request`.
* `gateway.suspend.prepare` creates a short cooperative-suspension lease only when tracked Gateway work is idle. While prepared, authenticated WebSocket connects remain available, but only `gateway.suspend.*` and an exact targeted non-safe `gateway.restart.request` may run; safe and untargeted restarts remain fenced. `gateway.suspend.status` checks the lease, and `gateway.suspend.resume` releases it after thaw or an aborted host operation.
Models and usage
* `models.list` returns the runtime-allowed model catalog. See "`models.list` views" below.
* `usage.status` returns provider usage windows/remaining quota summaries. Clients advertising `usage-refreshing` receive an immediate `refreshing: true` placeholder on a cold cache and must refetch on a bounded schedule; other callers block for the cold provider read.
* `usage.cost` returns aggregated cost usage summaries for a date range. Pass `agentId` for one agent, or `agentScope: "all"` to aggregate configured agents.
* `doctor.memory.status` returns vector-memory / cached embedding readiness for the active default agent workspace. Pass `{ "probe": true }` or `{ "deep": true }` only for an explicit live embedding provider ping. Pass `{ "agentId": "agent-id" }` to scope Dreaming store stats to one agent workspace; omitting it aggregates configured Dreaming workspaces.
* `doctor.memory.dreamDiary`, `doctor.memory.backfillDreamDiary`, `doctor.memory.resetDreamDiary`, `doctor.memory.resetGroundedShortTerm`, `doctor.memory.repairDreamingArtifacts`, and `doctor.memory.dedupeDreamDiary` accept optional `{ "agentId": "agent-id" }`; omitted, they operate on the configured default agent workspace.
* `sessions.usage` returns per-session usage summaries. Pass `agentId` for one agent, or `agentScope: "all"` to list configured agents together. Both usage methods accept `mode: "specific"` with an IANA `timeZone` for DST-aware calendar-day boundaries and buckets. `utcOffset` remains supported for older clients and as a fallback when the Gateway runtime does not recognize the requested zone.
* `sessions.usage.timeseries` returns timeseries usage for one session.
* `sessions.usage.logs` returns usage log entries for one session.
Channels and login helpers
* `channels.status` returns built-in + bundled channel/plugin status summaries.
* `channels.start` (`operator.admin`) starts one channel account runtime without re-authenticating. Params `{ channel, accountId? }`; omitted `accountId` selects the default account. Responds `{ channel, accountId, started, outcome }`, with `started` true only when the resulting runtime snapshot reports `running: true`. `outcome` carries the account lifecycle decision: `{ status: "handed-off" }`, `{ status: "retry", reason }`, or `{ status: "skipped", reason }`. The RPC is a manual override of automatic-start suppression; no `manual` parameter is accepted. This is not a provider-connectivity check; see [Per-account recovery](/cli/channels#per-account-recovery-non-destructive) for reasons and recovery guidance.
* `channels.stop` (`operator.admin`) stops one channel account runtime without clearing auth state. Params `{ channel, accountId? }`; omitted `accountId` selects the default account. Responds `{ channel, accountId, stopped }`, with `stopped` true when the resulting runtime snapshot does not report `running: true`. Unlike `channels.logout`, it retains the account's credentials.
* `channels.logout` logs out a specific channel/account where the channel supports it.
* `web.login.start` starts a QR/web login flow. Params include optional `{ channel, accountId, force, timeoutMs, verbose }`. When `channel` is present, the Gateway normalizes its canonical id or alias and dispatches only to that installed channel plugin. Omitting `channel` preserves the legacy behavior of selecting the first loaded QR-capable provider. A provider may return an opaque `sessionKey` with its QR response.
* `web.login.wait` waits for that flow to complete and starts the channel on success. Params include optional `{ channel, accountId, sessionKey, timeoutMs, currentQrDataUrl }`. Use the same `channel` as `web.login.start` and pass its returned `sessionKey` through unchanged so the provider can correlate the wait request with the QR session. Omitting `channel` retains the same legacy provider fallback as `web.login.start`.
* `push.test` sends a test APNs push to a registered iOS node.
* `voicewake.get` returns the stored wake-word triggers.
* `voicewake.set` updates wake-word triggers and broadcasts the change.
Plugin management
* `plugins.list` (`operator.read`) returns the installed plugin inventory plus locally curated official picks, diagnostics, and whether the current install mode allows mutations.
* `plugins.search` (`operator.read`) searches installable ClawHub code-plugin and bundle-plugin families. Pass non-empty `query` and optional `limit` from 1 to 100.
* `plugins.install` (`operator.admin`) installs either an official catalog entry with `{ source: "official", pluginId, acknowledgeInstallPolicyWarning? }` or a ClawHub package with `{ source: "clawhub", packageName, version?, acknowledgeInstallPolicyWarning? }`. When install policy returns `warn`, the error `details` include `installPolicyCode: "install_policy_warning_acknowledgement_required"`, the target, reason, and optional findings. After review, retrying the same action with `acknowledgeInstallPolicyWarning: true` approves every warning in that install invocation; each warning is freshly evaluated before installation continues. `block` and policy failures remain terminal. ClawHub installs preserve Gateway trust and integrity checks. Successful installs require a Gateway restart.
* `plugins.setEnabled` (`operator.admin`) changes one installed plugin's enabled policy with `{ pluginId, enabled }`. The response includes the updated catalog entry, restart metadata, and any slot-selection warnings.
* `plugins.uninstall` (`operator.admin`) removes one externally installed plugin with `{ pluginId }`: config references, the install record, and managed files. Bundled plugins cannot be uninstalled, only disabled. The response lists the removal actions and always requires a Gateway restart.
Messaging and logs
* `send` is the direct outbound-delivery RPC for channel/account/thread-targeted sends outside the chat runner.
* `logs.tail` returns the configured gateway file-log tail with cursor/limit and max-byte controls.
Operator terminal
* `terminal.open` starts a host PTY for an explicit `agentId` or the default agent and returns the resolved agent, working directory, shell, and confinement state. Passing `sessionKey` binds the PTY to that exact agent session and attaches the calling connection as its first viewer; omitting it creates a connection-owned operator terminal.
* `terminal.input` and `terminal.resize` operate on sessions owned by the calling connection and agent-owned sessions where that connection is an attached viewer. `terminal.close` kills a connection-owned session, but only detaches the calling viewer from an established agent-owned session. For a new session-bound Control UI terminal, the initiating viewer's close or disconnect discards the PTY until the browser or exact-session agent first adopts it through an authorized operation.
* `terminal.upload` accepts one base64 file up to 16 MiB, stages it in a private 24-hour temporary directory on the session's Gateway or paired-node host, and returns the absolute path. The caller must still paste or otherwise use that path; the RPC never writes terminal input or executes a command.
* `terminal.data` and `terminal.exit` events stream to the connection owner and attached viewers. Conversation-owned terminals remain persistent. The agent-facing `terminal` tool can list, read, resize, or close only terminals an operator opened for its exact session; it cannot open terminals. Agent input follows effective session and exec policy: `full` (YOLO) sends immediately, `guarded` and `workspace` (including accept-only or Guardian-reviewed flows) require explicit one-time approval of that exact input, and `read-only` or `deny` blocks it.
* Connection-owned sessions whose connection drops are detached, not killed: they stay reattachable for `gateway.terminal.detachedSessionTimeoutSeconds` (default 300; `0` restores kill-on-disconnect) while recent output accumulates in a bounded server-side buffer. Established agent-owned sessions likewise survive viewer disconnect.
* `terminal.list` returns attachable sessions. `terminal.attach` returns the replay buffer and either rebinds a connection-owned session (tmux-style take-over — a previous live owner receives `terminal.exit` with reason `detached`) or adds the connection as a viewer of an agent-owned session.
* Every terminal method requires `operator.admin`; `gateway.terminal.enabled` is on by default and refuses every method when set to `false`. Fully sandboxed agents are refused, and an agent policy change closes existing and in-flight PTYs, detached ones included.
Talk and TTS
* `talk.catalog` returns the read-only Talk provider catalog for speech, streaming transcription, and realtime voice: canonical provider ids, registry aliases, labels, configured state, an optional group-level `ready` result, exposed model/voice ids, canonical modes, transports, brain strategies, and realtime audio/capability flags, without returning provider secrets or mutating global config. Current gateways set `ready` after applying runtime provider selection; treat its absence as unverified on older gateways.
* `talk.config` returns the effective Talk config payload; `includeSecrets` requires `operator.talk.secrets` (or `operator.admin`).
* `talk.session.create` (`operator.talk`) creates a gateway-owned Talk session for `realtime/gateway-relay`, `transcription/gateway-relay`, or `stt-tts/managed-room`. For `stt-tts/managed-room`, non-admin callers that pass `sessionKey` must also pass `spawnedBy` for scoped session-key visibility; unscoped `sessionKey` creation and `brain: "direct-tools"` require `operator.admin`.
* `talk.session.appendAudio` appends base64 PCM input audio to gateway-owned realtime relay and transcription sessions.
* `talk.session.cancelOutput` stops assistant audio output, primarily for VAD-gated barge-in in gateway relay sessions. Send the current `talk.event.turnId`; the result is `applied`, `stale`, or `idle`.
* `talk.session.submitToolResult` completes a provider tool call emitted by a gateway-owned realtime relay session. The request waits for any asynchronous completion signal exposed by the provider bridge; failed submissions keep the linked run active and do not emit a successful tool-result event. Pass `options: { willContinue: true }` for interim tool output or `options: { suppressResponse: true }` when the provider bridge advertises suppression support and the result should not start another response.
* `talk.session.steer` sends active-run voice control into a gateway-owned agent-backed Talk session: `{ sessionId, text, mode? }`, where `mode` is `status`, `steer`, `cancel`, or `followup`; omitted mode is classified from the spoken text. It selects only work bound to that logical voice call, not another call sharing the connection and agent session.
* `talk.session.close` closes a gateway-owned relay, transcription, or managed-room session and emits terminal Talk events.
* `talk.mode` sets/broadcasts the current Talk mode state for WebChat/Control UI clients.
* `talk.client.create` creates or resumes a client-owned realtime provider session using `webrtc` or `provider-websocket` while the gateway owns credentials, instructions, tool policy, and the returned `voiceSessionId`. Clients pass `sessionKey` and reuse `voiceSessionId` when replacing the provider transport during one call. Clients that negotiate `gateway-control-v1` keep WebRTC media direct but move the provider control channel and tool lifecycle to the Gateway.
* `talk.client.transcript` appends one finalized `{ role, text }` item to the normal agent session. The required `entryId` is idempotent within `voiceSessionId`; retries do not duplicate transcript messages.
* `talk.client.close` closes the logical voice session after pending transcript writes. Closing is idempotent and may deliver a mutation-only call digest to the session's last non-WebChat channel.
* `talk.client.toolCall` lets client-owned realtime transports forward provider tool calls to gateway policy. The first supported tool is `openclaw_agent_consult`; clients get `runId`, `agentId`, and canonical `agentSessionKey` and wait for normal chat lifecycle events before submitting the provider-specific tool result. Use the returned target for `chat.abort` and `chat.history`; keep the original key for voice-session requests. Voice-bound high-impact actions return `VOICE_CONFIRMATION_REQUIRED:` until a later finalized user utterance explicitly confirms that exact final execution action and the next consult supplies the `confirmationId`; policy or hook rewrites require confirmation again.
* `talk.client.steer` sends session-scoped active-run voice control for client-owned realtime transports. The gateway resolves owned active work from `sessionKey`, without a voice call ID, and returns a structured accepted/rejected result instead of silently dropping steering. Provider-attached Gateway controls are call-scoped instead.
* `talk.event` is the single Talk event channel for realtime, transcription, STT/TTS, managed-room, telephony, and meeting adapters.
* `talk.speak` synthesizes speech through the active Talk speech provider.
* `tts.status` returns TTS enabled state, active provider, fallback providers, and provider config state.
* `tts.providers` returns the visible TTS provider inventory.
* `tts.enable` and `tts.disable` toggle TTS prefs state.
* `tts.setProvider` updates the preferred TTS provider.
* `tts.convert` runs one-shot text-to-speech conversion.
* `tts.speak` (`operator.write`) renders non-empty `text` with the configured general TTS provider chain and returns one whole clip inline as `audioBase64`, plus `provider` and optional `outputFormat`, `mimeType`, and `fileExtension` metadata. Unlike `tts.convert`, it does not return a Gateway-local path; unlike `talk.speak`, it does not require a Talk provider. Text above `tts.maxTextLength` returns `INVALID_REQUEST`; synthesis failures return `UNAVAILABLE`.
Secrets, config, update, and wizard
* `secrets.reload` re-resolves active SecretRefs and atomically publishes owner-aware runtime state. Eligible owner failures can publish as cold or stale degradation with `warningCount`; strict or unmapped failures reject the reload and preserve the active snapshot.
* `secrets.resolve` resolves command-target secret assignments for a specific command/target set.
* `secrets.store.list` (`operator.admin`) returns team-scoped metadata and values only for `kind: "env"` entries. `kind: "secret"` entries use a distinct result shape with no value field; there is no reveal method.
* `secrets.store.set` and `secrets.store.delete` (`operator.admin`) create/update or soft-delete one team-scoped entry. After a successful write, the Gateway refreshes the active secrets runtime only when the name is referenced by a `store` SecretRef in the active source config.
* `config.get` returns the current on-disk config snapshot, raw root-file `hash`, resolved `configRevisionHash`, and optional `appliedConfigHash` for the resolved revision accepted by the active Gateway runtime.
* `config.set` writes a validated config payload.
* `config.patch` merges a partial config update. Destructive array replacement requires the affected path in `replacePaths`; nested arrays under array entries use `[]` paths such as `agents.entries.*.skills`.
* `config.apply` validates + replaces the full config payload.
* `config.schema` returns the live config schema payload used by Control UI and CLI tooling: schema, `uiHints`, version, generation metadata, plugin + channel schema metadata when loadable. It includes `title` / `description` metadata from the same labels/help text as the UI, including nested object, wildcard, array-item, and `anyOf` / `oneOf` / `allOf` composition branches when matching field documentation exists.
* `config.schema.lookup` returns a path-scoped lookup payload for one config path: normalized path, a shallow schema node, matched hint + `hintPath`, optional `reloadKind`, and immediate child summaries for UI/CLI drill-down. `reloadKind` is one of `restart`, `hot`, or `none` (`src/config/schema.ts`) and mirrors the gateway config reload planner for the requested path. Lookup schema nodes keep the user-facing docs and common validation fields (`title`, `description`, `type`, `enum`, `const`, `format`, `pattern`, numeric/string/array/object bounds, `additionalProperties`, `deprecated`, `readOnly`, `writeOnly`). Child summaries expose `key`, normalized `path`, `type`, `required`, `hasChildren`, optional `reloadKind`, plus the matched `hint` / `hintPath`.
* `update.run` runs the gateway update flow and schedules a restart only if the update succeeded; callers with a session can include `continuationMessage` so startup resumes one follow-up agent turn through the restart continuation queue. Package-manager updates and supervised git-checkout updates from the control plane use a detached managed-service handoff instead of replacing the package tree or mutating checkout/build output inside the live gateway. A started handoff returns `ok: true` with `result.reason: "managed-service-handoff-started"` and `handoff.status: "started"`. A second concurrent `update.run` handled by the same Gateway process returns `ok: false` with `result.reason: "managed-service-handoff-already-running"` and `handoff.status: "already-running"`; its continuation is not accepted, so the caller can retry after the active update completes. Standalone CLI updaters and replacement Gateway processes are outside this process-local guard. Unavailable or failed handoffs return `ok: false` with `managed-service-handoff-unavailable` or `managed-service-handoff-failed`, plus `handoff.command` when a manual shell update is required. Unavailable means OpenClaw lacks a safe supervisor boundary or durable service identity, such as `OPENCLAW_SYSTEMD_UNIT` for systemd. During a started handoff, the restart sentinel may briefly report `stats.reason: "restart-health-pending"`; the continuation is delayed until the CLI verifies the restarted gateway and writes the final `ok` sentinel.
* `update.status` refreshes and returns the latest update restart sentinel, including the post-restart running version when available.
* `wizard.start`, `wizard.next`, `wizard.status`, and `wizard.cancel` expose the onboarding wizard over WS RPC.
Agent and workspace helpers
* `agents.list` returns gateway-visible agent entries, including effective model/runtime metadata and optional semantic `kind` (`agent` or `system`). Entries with recorded creation provenance also include `createdVia` (`operator`, `agent`, or `claw`), nullable `creatorAgentId`, and millisecond `createdAt`; entries without provenance omit those fields. Clients advertise the `agent-kind` handshake capability to receive the complete typed roster; clients without it keep the legacy selector-safe roster without system rows. Kind-aware clients exclude `system` rows from ordinary selectors while retaining them in diagnostic views. Older v4 gateways may return rows without `kind`.
* `agents.create`, `agents.update`, and `agents.delete` manage agent records and workspace wiring.
* `agents.files.list`, `agents.files.get`, and `agents.files.set` manage the bootstrap workspace files exposed for an agent.
* `audit.activity.list` returns the versioned metadata-only activity ledger; `audit.run.inspect` discovers execution ids or inspects one exact execution identity context; `audit.list` remains the compatibility-safe run/tool RPC.
* `agents.workspace.list` and `agents.workspace.get` (`operator.read`) expose read-only, paginated browsing of an agent's workspace directory for clients in the trusted operator domain described in [Operator scopes](/gateway/operator-scopes). Requests accept workspace-relative paths only; reads stay confined to the realpathed workspace root (symlink and hardlink escapes rejected), size-capped, and limited to UTF-8 text plus common image types (base64). Responses do not expose the host workspace path. There are no write operations in this namespace.
* `transcripts.list` (`operator.read`) lists durable meeting captures newest first. Optional `limit` accepts 1200 (default 50); `providerId` filters the source. The `sessions` result includes selectors, provider/source locators, times, active state, utterance counts, participants, summary availability, optional model/heuristic provenance, and an overview preview capped at 280 characters. Source locators expose only `providerId`, `accountId`, `guildId`, `channelId`, and `meetingUrl`, never free-form metadata.
* `transcripts.get` (`operator.read`) accepts `selector` and optional `includeUtterances`. It returns the session and stored summary, including its canonical Markdown; requested utterances are sanitized and bounded by the capture limit of 2,000. Missing summaries omit `summary` rather than generating notes. Both transcript methods read across one trusted Gateway domain, like `agents.workspace.*`; separate domains are required for reader isolation. They do not export files or change capture state. See [Transcripts CLI](/cli/transcripts#gateway-and-control-ui-reads).
* `tasks.list`, `tasks.get`, and `tasks.cancel` expose the gateway task ledger to SDK and operator clients. See [Task ledger RPCs](#task-ledger-rpcs) below.
* `artifacts.list`, `artifacts.get`, and `artifacts.download` expose transcript-derived artifact summaries and downloads for an explicit `sessionKey`, `runId`, or `taskId` scope. Run and task queries resolve the owning session server-side and only return transcript media with matching provenance; unsafe or local URL sources return unsupported downloads instead of fetching server-side.
* `environments.list` and `environments.status` (`operator.read`) remain available without cloud-worker profiles and preserve gateway-local and node environment discovery. `environments.list` also accepts an optional `runtimeId` from callers with `operator.write`. That request adds one Gateway-owned `requiredNodeCommand` result to each connected node when the runtime requires a node command. Its closed state is `invocable`, `pending-approval`, `undeclared`, or `unauthorized`; it never exposes the node's full pending declaration. Node environments include the durable `sessionHost` identity used to keep a known offline host visible, while current connected inventory is authoritative over that history. Missing identity means false. Exact bounded `{ total, available }` worker slots are live-only and omitted offline; worker-turn admission consumes a slot, while node-backed remote-exec does not. Configured profile summaries expose their bounded, canonically ordered `executionModes` array plus the existing singular `executionMode` primary/default display projection. Current clients select profiles only by membership in `executionModes`. Configured cloud workers and durable records left by earlier profiles add `worker` metadata with `providerId`, optional `leaseId`, `state`, `ageMs`, optional `idleMs`, and `attachedSessionIds`. Worker lifecycle states are `requested`, `provisioning`, `bootstrapping`, `ready`, `attached`, `idle`, `draining`, `destroying`, `destroyed`, `failed`, and `orphaned`. A connected node may also include `workerBundle: { status: "installed", version }` or `workerBundle: { status: "missing" }`. This optional observation is reconnect-scoped and reports validation of one Gateway-retained bundle; it is not launch authority. The public result never exposes the bundle hash, Gateway namespace, node filesystem path, receipt, or protocol-feature details.
* `environments.create` (`{ profileId, idempotencyKey }`) provisions an environment from a configured plugin provider profile; retries with the same key reuse the durable operation. Direct creation without a session does not select an execution mode, so the provider uses its intentional default; Crabbox prepares `worker-turn`. `environments.destroy` (`{ environmentId }`) requests idempotent teardown of a durable worker environment. Both require `operator.admin`, are control-plane writes, and return the same environment summary shape used by status responses.
* `worker.desktop.observe` (`{ environmentId, control? }`, `operator.admin`) starts or reuses the environment's desktop forward and returns `{ transport, wsPath, expiresAtMs, control, vncPassword? }`. `wsPath` carries a single-use 60-second token for the Gateway's desktop observer WebSocket; reconnecting requires a fresh observe call. Environments with an observable desktop advertise `worker.desktop: true` in `environments.list`. The method is advertised only when the `cloudWorkers.desktop` lab is enabled. See [Cloud workers](/gateway/cloud-workers#desktop-interactive).
* `agent.identity.get` returns the effective assistant identity for an agent or session.
* `agent.wait` waits for a run to finish and returns the terminal snapshot when available.
Session control
* `sessions.list` returns the current session index, including per-row `agentRuntime` metadata when an agent runtime backend is configured. `hasActiveRun` is the authoritative aggregate direct-session activity fact. When projected, `activeRunIds` is the complete exact active set; an empty array proves the session is idle. If aggregate activity is true while the field is omitted, another runtime owner is active but its exact identities are unavailable. Snapshot omission means identities unavailable. On incremental events, omission means no change, `null` is the event-only tombstone that clears cached exact IDs to unavailable, and an array replaces the cache. Clients correlate only exact IDs they own locally or received from requests, history, or events and never select the first list entry as an owner. When cloud-worker placement is enabled or durable recovery state exists, session rows also include a closed `placement` state (`local`, `requested`, `provisioning`, `syncing`, `starting`, `active`, `draining`, `reconciling`, `reclaimed`, or `failed`) plus state-specific environment, owner-epoch, workspace, bundle, ACK-cursor, or recovery fields. Active placements may include an advisory `diskSpace` sample with `status` (`ok`, `warning`, or `critical`), `availableBytes`, `totalBytes`, and `observedAtMs`. An active paired-device placement also includes `runner: { kind: "device", status: "available" | "offline", deviceId? }`; `deviceId` names the paired device hosting the placement (the selected host for `autoDevice` dispatch), and non-device placements omit the field. This availability is process-current, derived from the exact active environment binding and reconnect-scoped node-runner proof, and starts offline after Gateway restart until that runner reconnects. Inventory changes emit `sessions.changed` so clients refresh the canonical row. Rows carry ownership projections — write-once `createdActor`, the mutable `owner` (actor plus `assignedBy`/`assignedAt`), a bounded `participants` list (owner excluded, up to 4 actors), and the full `participantCount`; actor display labels and avatars are resolved from current profiles and agent identities at read time. Pass `creatorId` to filter by immutable `createdActor.id`; pass `ownerId` to filter by the current assignable owner, falling back to `createdActor` when no owner is assigned. The complete `owners` facet is independent of pagination and remains unfiltered by either query, so clients can render the full owner picker. Authenticated callers can pass `involvingMe: true` to keep only sessions the caller owns or has prompted, evaluated against the full participant history (profile-backed human participants only).
* `sessions.subscribe` enables session change events for the current WebSocket client and accepts the same parameters as `sessions.list` to return an initial list in the same response. Empty `{}` parameters return only the subscription acknowledgment. The subscription ends when that client disconnects. See [Session list bootstrap](/gateway/protocol#session-list-bootstrap).
* `sessions.messages.subscribe` and `sessions.messages.unsubscribe` toggle transcript/message event subscriptions for one session. Pass `includeApprovals: true` to also receive sanitized `session.approval` lifecycle events for approvals whose persisted audience includes that exact session and whose reviewer binding authorizes the subscribing client. The subscribe response then includes a bounded pending `approvalReplay`; it is authoritative when `truncated` is false. The opt-in is per subscribe call, not sticky: re-subscribing to the same session without `includeApprovals: true` removes an existing approval subscription. In addition to normal session-read authority, this opt-in requires `operator.admin`, or `operator.approvals` on a paired device.
* `sessions.preview` returns bounded transcript previews for specific session keys.
* `sessions.describe` returns one gateway session row for an exact session key.
* `sessions.github.options`, `sessions.github.publish`, `sessions.github.status`, and `sessions.github.confirm` accept optional `agentId` alongside `sessionKey`. Carry the selected session's agent through all four calls, especially for the shared key `global`, which does not identify its owner. An explicit agent must be configured and match any agent-qualified session key; malformed, unknown, or conflicting owners return `INVALID_REQUEST` before publication. Tool-originated publication remains bound to the tool caller's session and agent.
* `sessions.resolve` resolves or canonicalizes a session target by key, raw session ID, label, Control UI short ID, or `reference: { key, slug? }`. A reference searches visible active and archived sessions: its exact canonical key wins, then an optional display-name slug is matched against UUID-backed sessions. Reference discovery retains session-list visibility rules; the separate `key` selector retains exact-key read semantics. Ambiguous references and short IDs return at most ten candidates as a successful RPC result. Set `allowMissing: true` to receive `{ ok: false }` when no session matches.
* `sessions.create` creates a new session entry. When sandbox containment applies, local `cwd` and project paths are checked against the selected agent's canonical workspace: aliases inside it are accepted, and symlinks resolving outside it are rejected. Optional `model`, `contextWindow`, and `thinkingLevel` values persist the initial model, advertised context-window choice, and reasoning overrides atomically; optional `category` assigns the session to a custom group and registers that group when first used. `worktree: true` provisions a managed worktree; optional `worktreeBaseRef`/`worktreeName` select the base ref and branch name, and `execNode` (`operator.admin`) binds session exec to a node host. Without `worktreeName`, OpenClaw derives a readable name from the session label or generated first-message title, then falls back to a crustacean-themed name; names already occupied by another owner, local branch, or unmanaged path receive a numeric suffix. The created worktree is echoed in the result and persisted on the session row (`worktree: { id, branch, repoRoot }`). When the entry is created but its nested initial `chat.send` is rejected, the successful result includes `runStarted: false` and `runError`; clients can preserve the prompt and retry against the returned session key. A caller that passes `parentSessionKey` with `emitCommandHooks: true` should also declare the lifecycle disposition of a distinct child: `succeedsParent: true` ends the parent with `session_end`, while `false` keeps the parent active and emits only the child's `session_start`. Omitting `succeedsParent` preserves the legacy parent-rollover behavior for existing clients. The disposition requires both parent linkage and command hooks; a fork cannot succeed its parent. Main-session reset-in-place behavior is unchanged because no distinct child is created. New rows are stamped with write-once creation provenance (`createdVia`, `createdActor`, `createdAt`) from the trusted creation seam; adopting an existing key never restamps it. For human profile actors, `createdActor.label` is resolved from the current user profile when the row is projected and is never stored on the session entry, so profile renames do not drift. Session rows also carry `parentSessionKey` (navigation parent, persisted), `controlOwnerSessionKey` (runtime controller when live), `forkSource` (exact source key + transcript generation for forks), and `previousSessionId` (prior transcript generation under the same key).
* `sessions.dispatch` moves an authorized local OpenClaw or Codex session with a live, registry-owned session managed worktree to a paired device or configured cloud profile. Pass `{ key, deviceId, agentId? }` for an explicit device, `{ key, autoDevice: true, agentId? }` for automatic paired-device selection, `{ key, profileId, machineClass?, agentId? }` for an explicit profile, or `{ key, agentId? }` to look up the managed worktree's normalized origin in `cloudWorkers.projectProfiles`. These target modes are mutually exclusive and explicit targets take precedence over project-profile lookup. Automatic selection ranks worker-slot runtimes by available slots and then device ID; runtimes without worker slots use device ID order. If a candidate becomes ineligible during dispatch, up to three ranked candidates are attempted; other errors are not retried. Explicit and automatic device dispatch require `operator.write`; explicit-profile and project-profile dispatch require `operator.admin`. A missing origin, unmatched mapping, or mapping to an unconfigured profile returns a typed `INVALID_REQUEST` without provisioning or falling back to another target. Malformed params use the write scope before schema validation. A missing cloud profile hides only cloud targets; eligible paired-device dispatch remains available. Dispatch closes local turn admission before draining active work and returns only after placement reaches `active`, with worker-child ownership for `worker-turn` or Gateway-owned harness execution for `remote-exec`. Arbitrary plain directories are not dispatchable; after admission, the workspace transport may use manifest mirroring if the managed worktree's Git metadata later becomes unavailable. SSH fallback candidates rotate only for idempotent probes, content-addressed transfers, receipt/lock-guarded artifact installation, convergent managed-worktree mirroring, and tunnel reconnects. Ambiguous unguarded stateful commands fail closed and are not replayed. Dispatch is one-way; worker-to-local pull-back is not part of this RPC.
* `sessions.reclaim` (`operator.write`) safely stops a session placement by key. It waits for an in-flight dispatch, drains admitted work, reconciles active workspace changes, and retries pending failed-environment teardown through the placement owner. Callers never need raw environment-destroy authority.
* `sessions.move` moves an authorized active session to the Gateway, a paired device, or a configured profile. Gateway and device targets require `operator.write`; profile targets require `operator.admin`; malformed targets use the write scope before schema validation. The caller supplies the exact observed generation, environment, and owner epoch; session authorization and those source facts are revalidated before the move commits. Ordinary moves always reconcile the source. Only a Gateway target may add `abandonSource: true`, and only when the exact source is a currently offline paired-device placement. That durable decision force-fences and destroys the remote owner, skips remote workspace reconciliation, and continues from the last Gateway-synced state without replay; unsynced files and in-flight work may be lost. Available, unknown, profile, and other-worker sources reject explicit abandonment.
* `sessions.groups.list`, `sessions.groups.put`, `sessions.groups.rename`, and `sessions.groups.delete` manage the gateway-owned custom session group catalog (names + display order). The read-scoped list result is intentionally path-free. `sessions.groups.defaults` and `sessions.groups.update` require `operator.write` and read or replace one custom group's optional working-directory and worktree defaults. Non-admin callers can save only directories inside a configured agent workspace; other absolute Gateway paths require `operator.admin`. Membership stays on each session's `category` field; rename and delete update member sessions server-side. `sessions.groups.put` replaces only the name list and order, and rejects dropping a group that still has member sessions — delete it explicitly first. Dropping a group participates in the same member-session authorization as delete.
* `sessions.send` sends a message into an existing session.
* `sessions.steer` is a deprecated alias for `chat.send` with `queueMode: "interrupt"`; removal follows the protocol deprecation policy.
* `sessions.abort` aborts active work for a session. Pass `key` plus optional `runId`, or `runId` alone for active runs the gateway can resolve to a session. Supplying `runId` keeps cancellation scoped to that run. Set `clearQueued: true` on a key-only non-global request to also discard followup and lane queues owned by that session. Existing callers that omit `clearQueued` preserve those queues. The literal `global` key keeps the existing agent-qualified `chat.abort` ownership rules and does not perform non-global followup or lane cleanup.
* `sessions.patch` updates session metadata/overrides and reports the resolved canonical model plus effective `agentRuntime`. `contextWindow` accepts only an id advertised by the selected model's `contextWindows` array; `null` restores `contextWindowDefault`. Session organization fields and the per-session `model` override require `operator.write`; thinking, fast, verbose, trace, reasoning, and other privileged overrides require `operator.admin`. Only an admin model selection can persist as the configured agent default. Archive and restore patches require the caller-observed `sessionId` from `sessions.list` or `sessions.describe` as `expectedSessionId`; missing or changed targets fail without materializing or mutating a replacement. With `archived: true`, the Gateway protects agent main sessions (including `global` when global scope is configured) and the `unknown` sentinel; for every other real session it first fences new admission, cancels exact-session active, pending, queued, reply, embedded, and worker work, and waits for admission and runtime terminal-persistence drains before committing `archivedAt`. A cancellation, drain, or persistence failure returns retryable `UNAVAILABLE` and leaves the session unarchived. `sessions.patchMany` carries `expectedSessionId` per target, prepares archive targets in input order inside the same batch lifecycle fence, and returns ordered per-target outcomes. Spawn lineage (`spawnedBy`, `spawnedWorkspaceDir`, `spawnedCwd`, `spawnDepth`, `subagentRole`, `subagentControlScope`) is no longer publicly patchable; those facts are written once by trusted creation paths, and requests that still send them are rejected.
* `sessions.assignOwner` (`operator.write`) reassigns the session's mutable owner to a person or configured agent (`{ key, owner: { type, id } }`). It requires an identified caller (authenticated profile or trusted agent identity), authorizes by session visibility, and records `assignedBy`/`assignedAt` on the row's `owner` field. The write-once `createdActor` and creator-anchored sharing authority are unchanged; see [Multi-user mode](/concepts/multi-user#assigning-an-owner).
* `sessions.reset`, `sessions.delete`, and `sessions.compact` perform session maintenance.
* `sessions.get` returns the full stored session row.
* Chat execution still uses `chat.history`, `chat.send`, `chat.abort`, and `chat.inject`. Its `sessionInfo` uses the same aggregate `hasActiveRun` and optional complete-exact `activeRunIds` semantics as `sessions.list`. `chat.history` is display-normalized for UI clients: inline directive tags are stripped from visible text, plain-text tool-call XML payloads (`...`, `...`, `...`, `...`, and truncated tool-call blocks) and leaked ASCII/full-width model control tokens are stripped, pure silent-token assistant rows (exact `NO_REPLY` / `no_reply`) are omitted, and oversized rows can be replaced with placeholders. Tail responses can include an opaque `deltaCursor`. Pass it back as `cursor` to `chat.history` or `chat.startup` instead of `offset` or `messageId`. A successful catch-up returns `{ kind: "delta", messages, deltaCursor, sessionInfo }`; replay each `messages` entry through the same reducer as a live `session.message` payload. `{ kind: "reset" }` means the cursor is invalid, stale, belongs to another session, crossed a reset or compaction, or is too far behind; fetch a normal tail page. Catch-up never returns a partial page or continuation: more than 200 raw events or the 1 MB payload budget resets to a tail fetch.
* `chat.message.get` is the additive bounded full-message reader for a single visible transcript entry. Pass `sessionKey`, optional `agentId` when session selection is agent-scoped, and a transcript `messageId` previously surfaced through `chat.history`; the gateway returns the same display-normalized projection without the lightweight history truncation cap when the stored entry is still available and not oversized.
* `chat.toolTitles` is deprecated. It validates the existing bounded request shape and returns `{ titles: {}, disabled: true }` so older clients stop requesting titles. It makes no model calls and does not access the old title cache. Current Control UI clients display descriptions supplied with tool calls automatically.
* `chat.send` accepts one-turn `fastMode: "auto"` to use fast mode for model calls started before the auto cutoff, then start later retry, fallback, tool-result, or continuation calls without fast mode. The cutoff defaults to 60 seconds (`DEFAULT_FAST_MODE_AUTO_ON_SECONDS`) and can be configured per model with `agents.defaults.models["/"].params.fastAutoOnSeconds`. A `chat.send` caller can pass one-turn `fastAutoOnSeconds` to override the cutoff for that request. Pass `queueMode` (`steer`, `followup`, `collect`, or `interrupt`) to override the stored queue mode for this request only; explicit Control UI steer actions use `queueMode: "steer"`. Interrupt mode captures and aborts the session's current admitted turn, waits for that exact owner to settle, then starts the new turn; an idle session starts normally. A steer send targets the selected session's current state: the Gateway atomically injects the message into that session's direct active run, or starts a new turn when the session is idle. Activity in descendant subagent sessions never makes the selected session busy for this decision. `expectedLeafEntryId` is an independent transcript-branch compare-and-swap for non-steer interactive sends: pass the displayed branch leaf (or deliberate `null` for an authoritative empty transcript) and the send rejects with `details.reason: "active-leaf-changed"` if another client switched transcript branches first; steer sends ignore it.
* `chat.send`, `sessions.send`, and initial-turn `sessions.create` acknowledgments report admission separately from transcript persistence. Optional `messageSeq` is the one-based position from an actual committed user-turn receipt; it is absent while the input exists only in pending custody. `status: "started"` and `runStarted: true` alone do not establish a transcript row. Reconcile provisional input by its submission identity against accepted custody or canonical transcript identity, never a predicted position or matching content.
* `sessions.create.fastMode` accepts `true`, `false`, or `"auto"` and persists that speed override before the initial turn starts.
* `sessions.title.prepare` (`{ agentId, message, model?, catalogId?, incognito? }`, `operator.write`, rate-limited as a control-plane write) returns `{ title }` from the selected agent's utility model only, without creating or renaming a session; it returns `title: null` for incognito, empty, slash-command, or unavailable-utility input and never falls back to the primary model. A client passes a ready result as `sessions.create.displayName`: a presentation title stored like a generated first-message title, so it is not unique, never claims `label`, and is ignored when adopting an existing key.
Device pairing and device tokens
* `device.pair.list` returns pending and approved paired devices.
* `device.pair.setupCode` creates a mobile setup code and, by default, a PNG QR data URL. It requires `operator.admin` and is intentionally omitted from advertised discovery. Current gateways include an opaque non-secret `setupId`, authoritative `expiresAtMs`, `setupCode`, optional `qrDataUrl`, `gatewayUrl`, the non-secret `auth` label, `urlSource`, and the issued `access` level (`full`, `limited`, or `node`). Older protocol-v4 gateways omit `setupId` and `expiresAtMs`, so separately shipped clients must treat those lifecycle fields as optional. The `setupId` is independent from the bootstrap credential and is not embedded in the setup code.
* `device.pair.setupStatus` reconciles one setup credential the caller already issued (`{ setupId }`). It requires `operator.admin`, is omitted from advertised discovery, and returns either `{ completion }` after the credential-bearing response finishes or `{ deliveryUncertain }` when the bearer was retired but response delivery could not be confirmed. Both use the same non-secret payload as their corresponding events. When both fields are absent, the gateway holds no retained outcome for that `setupId`.
* `device.pair.approve`, `device.pair.reject`, and `device.pair.remove` manage device-pairing records.
* `device.pair.rename` assigns an operator label (`{ deviceId, label }`) that is preferred over the client-reported display name and survives device repair or re-approval.
* `device.token.rotate` rotates a paired device token within its approved role and caller scope bounds.
* `device.token.revoke` revokes a paired device token within its approved role and caller scope bounds.
The setup code embeds a short-lived bootstrap credential. Clients must not log or persist it beyond the pairing flow.
Pairing-scoped clients receive `device.pair.setup.completed` only after the exact setup handoff has delivered its credentials. Its payload is `{ setupId, deviceId, deviceName?, access, ts }`; it never includes the bootstrap credential or token-derived identifiers.
If the response closes before delivery can be confirmed, the gateway keeps the bearer retired and emits `device.pair.setup.deliveryUncertain` instead of success. The presenting client should offer the operator a path to inspect or remove the paired device and generate a new setup code.
The gateway records an uncertain outcome when it consumes the bearer, then promotes it to completion only after response delivery finishes. Operator event frames are best effort and drop for slow subscribers rather than closing their socket. A client that displayed a setup code must therefore call `device.pair.setupStatus` before presenting the code as expired. Outcomes are retained past the credential's own expiry.
Node pairing, invoke, and pending work
* `node.pair.list`, `node.pair.approve`, `node.pair.reject`, and `node.pair.remove` cover node capability approvals. `node.pair.request` and `node.pair.verify` were removed in 2026.7 together with the standalone node pairing store; pending requests are created by the Gateway during node connects.
* `node.list` and `node.describe` return known/connected node state.
* `node.rename` updates a paired node label.
* `node.invoke` forwards a command to a connected node.
* `node.invoke.result` returns the result for an invoke request. A node may return `NODE_NOT_READY` only when lifecycle cleanup prevented execution, before calling a command handler or emitting progress. The Gateway retries this rejection up to four times within the original invoke deadline, rechecking the connection, pairing, and command authorization at each dispatch. General `UNAVAILABLE` errors, disconnects, timeouts, and failures after progress are not retried.
* `mcp.tools.call.v1` is the headless node-host command for calling a configured node-local MCP tool. It is carried through `node.invoke`, requires the node to declare the command, and remains subject to pairing approval and `gateway.nodes.commands.deny`.
* `node.event` carries node-originated events back into the gateway.
* `node.pluginTools.update` is the only publication path for replacing the connected node's agent-visible plugin/MCP tool descriptors; `connect` params do not carry them.
* `node.pending.pull` and `node.pending.ack` are the connected-node queue APIs.
* `node.pending.enqueue` and `node.pending.drain` manage durable pending work for offline/disconnected nodes.
Approval families
* `approval.history` returns newest-first terminal approvals retained for 30 days for exec, plugin, and system-agent requests (scope `operator.approvals`). It supports cursor pagination plus an optional kind filter; pending approvals are not history rows. Treat each cursor as an opaque server token and return the exact value without padding, rewriting, or adding fields.
* `approval.get` and `approval.resolve` are the kind-agnostic durable approval methods (scope `operator.approvals`). `approval.get` returns a sanitized pending or retained terminal projection with a stable `urlPath`; `approval.resolve` accepts the canonical approval id, an explicit `kind`, and a decision, applies first-answer-wins resolution, and always returns the recorded canonical result.
* `exec.approval.request`, `exec.approval.get`, `exec.approval.list`, and `exec.approval.resolve` cover one-shot exec approval requests plus pending approval lookup/replay. They are protocol-boundary adapters over the same durable approval registry.
* `exec.approval.waitDecision` waits on one pending exec approval and returns the final decision (or `null` on timeout).
* `exec.approvals.get` and `exec.approvals.set` manage gateway exec approval policy snapshots.
* `exec.approvals.node.get` and `exec.approvals.node.set` manage node-local exec approval policy via node relay commands.
* `plugin.approval.request`, `plugin.approval.list`, `plugin.approval.waitDecision`, and `plugin.approval.resolve` cover plugin-defined approval flows.
Control UI commands
* `ui.command` lets an `operator.write` caller send typed layout and navigation commands to connected Control UI clients that advertise the `ui-commands` capability.
* Commands cover pane split/close/focus, sidebar visibility, terminal/browser panel visibility and dock, and session navigation.
* Protocol v1 intentionally fans out to every connected capable Control UI. If none is connected, the request fails with `UNAVAILABLE` instead of pretending the layout changed.
Automation, skills, and tools
* Automation: `wake` schedules an immediate or next-heartbeat wake text injection; `cron.get`, `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`, `cron.run`, `cron.runs` manage scheduled work.
* `cron.run` remains an enqueue-style RPC for manual runs. Clients that need completion semantics should read the returned `runId` and poll `cron.runs`.
* `cron.runs` accepts an optional non-empty `runId` filter so clients can follow one queued manual run without racing against other history entries for the same job.
* Skills and tools: `commands.list`, `skills.*`, `tools.catalog`, `tools.effective`, `tools.invoke`. See [Operator helper methods](#operator-helper-methods) below.
### Session list bootstrap
Call `sessions.subscribe` with a non-empty `sessions.list` parameter object, such as `{ limit: 60, ownerFirst: true }`, to subscribe and load the initial roster in one request. A successful WebSocket response has the payload `{ subscribed: true, list }`, where `list` is the normal `SessionsListResult`. Calling with `{}` preserves the acknowledgment-only response `{ subscribed: true }` and does not read a snapshot. List parameters select the snapshot; they do not filter the connection's session event subscription.
The Gateway registers the subscription before projecting the list. Clients must listen for `sessions.changed` before making the request: events can arrive while the snapshot is being built. Reconcile those events with the response and issue a trailing `sessions.list` refresh when needed, including when an event only invalidates the cached list. Reconnects require a new subscription and snapshot.
Both methods accept `ownerFirst: true` to prepend up to 60 matching viewer-owned rows (or `limit`, when smaller) to the normal first page, deduplicated by session key. This applies only when `offset` is zero or omitted; later pages use normal pagination. Owned rows must pass the same visibility and list filters as the shared page. The Gateway resolves the viewer from the authenticated connection; no client-supplied identity selects these rows. Without an authenticated viewer identity, or when `ownerFirst` is false or omitted, the list uses normal ordering.
The shared page still determines `limitApplied`, `offset`, `nextOffset`, `hasMore`, and `totalCount`. Prepended rows can make `sessions.length` and `count` exceed the shared page size. Use `nextOffset` to advance and deduplicate rows by session key across pages; do not derive the next offset from the displayed row count.
### Common event families
* `chat`: UI chat updates such as `chat.inject` and other transcript-only chat events. In protocol v4, delta payloads carry `deltaText`; `message` remains the cumulative assistant snapshot. Non-prefix replacements set `replace=true` and use `deltaText` as the replacement text. Failed runs (`state: "error"`) may include `errorDetail` alongside the coarse `errorKind` and human-readable `errorMessage`. This closed object has seven optional fields: `provider`, `model`, `failoverReason`, `providerRuntimeFailureKind`, `providerErrorType`, `httpStatus`, and `providerErrorMessagePreview`. Strings are capped at 300 characters; `httpStatus` is an integer from 100 through 599. Details come from the failed attempt's sanitized provider observation, not from reparsing the user-facing message. The preview is credential-redacted and may be shorter than the protocol cap. Raw bodies, raw previews, and diagnostic hashes are never included in `errorDetail`. Runs without provider observations omit it; successful and canceled events do not carry it. This is an additive protocol-v4 field.
* `session.message`, `session.operation`, `session.tool`: transcript, in-flight session operation, and event-stream updates for a subscribed session.
* `session.approval`: sanitized pending and terminal approval truth for an explicitly opted-in exact-session subscriber. Child approvals use the persisted ancestor audience; events never mutate transcripts or wake agents.
* `session.observer`: safe live session headline and status digest. A model-authored preamble can update the headline immediately; utility-model assessments replace it later when available. Web, iOS, and Android use the same run-scoped digest. Clients show its headline or inspector link only while the digest's exact `runId` is present in `activeRunIds`.
* `sessions.changed`: session index or metadata changed. Active-run fields use the same aggregate and complete-exact semantics as `sessions.list`; `activeRunIds: null` clears cached exact identities to unavailable, omission leaves the cache unchanged, and an array replaces it. Delete notifications from `sessions.delete` and incognito reset carry the removed generation's `sessionId`, without a current-row snapshot. Clients must not delete a replacement with a different ID. A key-only delete event or a rowless global notification invalidates the canonical session list; it does not identify the current generation as deleted.
* `presence`: system presence snapshot updates.
* `tick`: periodic keepalive/liveness event.
* `health`: gateway health snapshot update.
* `heartbeat`: heartbeat event stream update.
* `cron`: cron run/job change event.
* `shutdown`: gateway shutdown notification.
* `node.pair.requested` / `node.pair.resolved`: node pairing lifecycle.
* `node.invoke.request`: node invoke request broadcast.
* `device.pair.requested` / `device.pair.resolved`: paired-device approval lifecycle.
* `device.pair.setup.completed`: exact setup-code handoff completion, scoped to `operator.pairing`.
* `device.pair.setup.deliveryUncertain`: replay-safe setup-code retirement whose credential response delivery could not be confirmed, scoped to `operator.pairing`.
* `voicewake.changed`: wake-word trigger config changed.
* `config.changed`: a config write persisted (payload carries the config path, the new snapshot hash, and a timestamp — never config content). Operator-read scoped; clients refresh via `config.get`.
* `skills.changed`: connectivity, the skill catalog, config, or eligibility changed after the gateway invalidated its skills snapshot. The payload's `reason` is `watch`, `watch-targets`, `manual`, `remote-node`, `config-change`, or `workshop`. Operator-read scoped; clients refresh via `skills.status`.
* `exec.approval.requested` / `exec.approval.resolved`: exec approval lifecycle.
* `plugin.approval.requested` / `plugin.approval.resolved`: plugin approval lifecycle.
### Node helper methods
Nodes may call `skills.bins` to fetch the current list of skill executables for auto-allow checks.
### Node exec lifecycle events
Nodes report `system.run` lifecycle through the node-role `node.event` RPC with `event: "exec.started"`, `"exec.finished"`, or `"exec.denied"`. These are not the operator `exec.approval.*` broadcasts and do not use the retired TCP bridge.
The RPC accepts a JSON string in `payloadJSON` or an object in `payload`. A string `payloadJSON` takes precedence when both are supplied. For example:
Current headless nodes include `sessionKey`, `runId`, and `host: "node"`. Additional fields are:
| Field | Meaning |
| --- | --- |
| `command` | Raw or formatted command text. |
| `exitCode`, `timedOut` | Process completion code and timeout flag. |
| `success` | Producer result flag, not the notification-gating predicate. |
| `output` | Bounded combined stdout, stderr, and error text. |
| `reason` | Denial reason for `exec.denied`. |
| `suppressNotifyOnExit` | Suppress this invocation's system notification. |
Echo the correlation fields forwarded with `system.run`; neither an ID nor the payload's `host` field grants authority. The Gateway matches the authenticated node and connection, run ID, and session key when the invocation binds one. Unmatched events return `handled: false` with `reason: "unmatched_exec_event"` and produce no system notification. A narrow legacy macOS-client path may match a missing or mismatched run ID only to one unambiguous invocation on that connection/session; new clients must send the issued run ID.
`exec.started` retains the authorization record; `exec.finished` and `exec.denied` consume it before notification filtering. `tools.exec.notifyOnExit: false` or `suppressNotifyOnExit: true` suppresses notifications. Denied events never enqueue a system event or wake agent work. Finished events notify only for timeout, nonzero or unknown exit code, or nonempty compacted output; successful exit 0 with no output stays quiet. Finished notifications with a run ID are deduplicated by canonical session and run ID. A heartbeat wake is requested only after a system event is queued.
Node event delivery is best-effort, not a durable completion ledger.
## Audit ledger RPC
`audit.activity.list` gives operator clients a stable newest-first view of agent run, tool action, inbound-message, and terminal outbound-message metadata. It requires `operator.read`. Queries exclude records older than 30 days, and the shared SQLite ledger is capped at 100,000 records. Expired rows are deleted during Gateway startup, hourly maintenance, and later writes. See [Audit history](/gateway/audit) for the data model and privacy semantics.
* Params: optional exact `agentId`, `sessionKey`, or `runId`; optional `kind` (`"agent_run"`, `"tool_action"`, or `"message"`); optional `status` (`"started"`, `"succeeded"`, `"failed"`, `"cancelled"`, `"timed_out"`, `"blocked"`, or `"unknown"`); optional message `direction` (`"inbound"` or `"outbound"`) and exact `channel`; optional inclusive `after` / `before` Unix-millisecond bounds; optional `limit` from `1` to `500`; and optional string `cursor` from the preceding page.
* Result: `{ "events": AuditActivityEventV1[], "nextCursor"?: string }`.
The named V1 result union has separate agent-run, tool-action, inbound-message, and outbound-message schemas. The `eventType` discriminator is respectively `agent_run`, `tool_action`, `inbound_message`, or `outbound_message`; `kind` and message `direction` remain available for filtering and display. Every event has integer `schemaVersion: 1`. Message identity references use the exact `hmac-sha256:v1:<32 hex key id>:<64 hex digest>` format; a channel-sender actor id uses the same format.
All variants require `eventType`, `schemaVersion`, `eventId`, `sequence`, `sourceSequence`, `occurredAt`, `kind`, `action`, `status`, `actor`, and `redaction`. Variant fields are:
| `eventType` | Required fields | Optional fields |
| --- | --- | --- |
| `agent_run` | `agentId`, `runId`; `kind: "agent_run"` | `sessionKey`, `sessionId`, `errorCode` |
| `tool_action` | `agentId`, `runId`; `kind: "tool_action"` | `sessionKey`, `sessionId`, `toolCallId`, `toolName`, `errorCode` |
| `inbound_message` | `direction: "inbound"`, `channel`, `conversationKind`, `outcome` | `agentId`, `runId`, `durationMs`, `resultCount`, identity references, `reasonCode`, `errorCode` |
| `outbound_message` | `direction: "outbound"`, `channel`, `conversationKind`, `outcome` | `agentId`, `runId`, `durationMs`, `resultCount`, identity references, `reasonCode`, `deliveryKind`, `failureStage`, `errorCode` |
The closed message enums are:
* `conversationKind`: `direct`, `group`, `channel`, or `unknown`.
* Inbound `outcome`: `completed`, `skipped`, or `failed`; optional `reasonCode`: `duplicate`, `reply_operation_active`, `reply_operation_aborted`, `fast_abort`, `plugin_bound_handled`, `plugin_bound_unavailable`, `plugin_bound_declined`, `plugin_bound_error`, `before_dispatch_handled`, `acp_dispatch_completed`, `acp_dispatch_failed`, `acp_dispatch_empty`, or `acp_dispatch_aborted`.
* Outbound `outcome`: `sent`, `suppressed`, `failed`, or `unknown`; optional `reasonCode`: `cancelled_by_message_sending_hook`, `cancelled_by_reply_payload_sending_hook`, `empty_after_message_sending_hook`, `empty_after_reply_payload_sending_hook`, or `no_visible_payload`. An adapter that returns no platform identity is `unknown`, because the external side effect cannot be disproved.
* `deliveryKind`: `text`, `media`, or `other`; `failureStage`: `platform_send`, `queue`, or `unknown`.
Terminal fields are correlated, not independently optional:
| Variant | Terminal mapping |
| --- | --- |
| Agent run | `started` has no `errorCode`; each non-success finished status requires its matching `run_*` code. |
| Tool action | `started` and succeeded have no `errorCode`; each other finished status requires its matching `tool_*` code. |
| Inbound message | succeeded = `completed`; blocked = `skipped`; failed = `failed` plus `message_processing_failed`. `reasonCode`, when present, must belong to that terminal family. |
| Outbound message | succeeded = `sent`; blocked = `suppressed` plus `reasonCode`; failed = `failed` plus `errorCode` and `failureStage`; unknown = `unknown` plus `failureStage`. |
Each activity event includes a stable event id, monotonic ledger sequence, source event sequence, timestamp, actor, action, status, integer `schemaVersion: 1`, and `redaction: "metadata_only"`. Run and tool records require agent and run provenance and may include session provenance. Message records may include agent and run ids, but intentionally never include `sessionKey` or `sessionId`; the `sessionKey` query filter therefore applies to run and tool rows only. Tool events may include tool call id and tool name.
The activity ledger returns `message.inbound.processed` and `message.outbound.finished` records and adds direction, channel, conversation kind, normalized outcome, and optional delivery kind, failure stage, duration, result count, reason code, and installation-local keyed account/conversation/message/target pseudonyms. These pseudonyms aid correlation but are not anonymization: the state database contains their key, while RPC and CLI exports do not. The ledger does not store prompts, message bodies, tool arguments, tool results, command output, or raw error text. Run/tool `sessionKey` values remain raw correlation metadata and can embed platform account or peer ids; message records omit session keys.
For inbound rows, `durationMs` measures core dispatch through its terminal and `resultCount` counts finalized queued tool, block, and reply payloads. For outbound rows, `durationMs` spans delivery ownership through acknowledgement, dead letter, or reconciliation (including queued wait time), and `resultCount` counts identified physical platform sends. `deliveryKind`, when present, describes the effective payload after hooks and rendering; suppressed or crash-ambiguous rows omit it.
Current message coverage includes accepted inbound messages that reach core dispatch, including core duplicate/terminal outcomes. Outbound coverage writes replay-safe queue and platform-start records to a lazy owner-native companion and one terminal activity row per original logical reply payload that reaches shared durable delivery; run inspection merges those sources. Chunking and adapter fan-out are aggregated in terminal `resultCount`. Ambiguous sends reach a terminal only after acknowledgement, dead letter, or reconciliation. Plugin-local and direct-send paths that bypass those shared boundaries are not yet covered. The bounded process-owned async queue is best-effort and may drop records on saturation, terminal persistence failure, or shutdown timeout, so this surface is not a lossless compliance archive.
Recording is on by default and controlled by [`logging.audit.enabled`](/gateway/configuration-reference#audit). Message recording is separately controlled by `logging.audit.messages` and defaults to `"off"`. When recording is disabled, `audit.activity.list` keeps serving records written earlier until they expire.
`audit.run.inspect` also requires `operator.read`. Its closed request selects exactly one `executionId` for exact inspection or one `runId` for bounded execution discovery. One run match resolves directly; multiple matches return an explicit `ambiguous` result with at most 50 candidates and require exact execution selection. Decision pages contain at most 100 receipts. Execution identity collection is separately off by default and requires `logging.audit.executionIdentity: true` plus an enabled audit ledger after Gateway restart. Missing best-effort evidence never proves that a run did not occur.
For a selected run, decision receipts merge terminal outbound activity with owner-native `queued` and `platform_started` progress. Progress is attribution-only, lives in the lazy companion store, and is not part of the `audit.activity.list` result schema.
The shipped `audit.list` request, result, and `AuditEvent` schemas remain unchanged and return only agent-run and tool-action records. New operator clients should call `audit.activity.list` when the Gateway advertises it. Older Gateways may report either `unknown method: audit.activity.list` or, because authorization preceded method lookup in shipped versions, `missing scope: operator.admin` to a read-scoped request. Treat the latter as method absence only when the method was not advertised. A client may then retry `audit.list` only when its filters do not require message kind, direction, or channel support.
Use [`openclaw audit`](/cli/audit) for text queries and bounded JSON exports.
## Task ledger RPCs
Operator clients inspect and cancel gateway background task records through the task ledger RPCs (`packages/gateway-protocol/src/schema/tasks.ts`). These return sanitized task summaries, not raw runtime state.
* `tasks.list` requires `operator.read`.
+ Params: optional `status` (`"queued"`, `"running"`, `"completed"`, `"failed"`, `"cancelled"`, or `"timed_out"`) or an array of those statuses, optional `agentId`, optional `sessionKey`, optional `limit` from `1` to `500`, optional string `cursor`, and optional `sortBy` (`"updatedAt"` or `"endedAt"`). Ordering is descending; omitted `sortBy` uses last activity. Use `"endedAt"` with terminal status filters when page membership must reflect completion order. Legacy terminal rows without a stored `endedAt` use their recorded terminal activity time, then creation time, as the canonical completion timestamp before pagination.
+ Result: `{ "tasks": TaskSummary[], "nextCursor"?: string }`.
* `tasks.get` requires `operator.read`.
+ Params: `{ "taskId": string }`.
+ Result: `{ "task": TaskSummary }`.
+ Missing task ids return the gateway not-found error shape.
* `tasks.cancel` requires `operator.write`.
+ Params: `{ "taskId": string, "reason"?: string }`.
+ Result: `{ "found": boolean, "cancelled": boolean, "reason"?: string, "task"?: TaskSummary }`.
+ `found` reports whether the ledger had a matching task. `cancelled` reports whether the runtime accepted or recorded cancellation.
`TaskSummary` includes `id`, `status`, and optional metadata: `kind`, `runtime`, `title`, `agentId`, `sessionKey`, `childSessionKey`, `ownerKey`, `runId`, `taskId`, `flowId`, `parentTaskId`, `sourceId`, timestamps, progress, terminal summary, and sanitized error text. `agentId` identifies the agent executing the task; `sessionKey` and `ownerKey` preserve requester and control context.
## Operator helper methods
* `commands.list` (`operator.read`) fetches the runtime command inventory for an agent.
+ `agentId` is optional; omit it to read the default agent workspace.
+ `scope` controls which surface the primary `name` targets: `text` returns the primary text command token without the leading `/`; `native` and the default `both` path return provider-aware native names when available.
+ `textAliases` carries exact slash aliases such as `/model` and `/m`.
+ `nativeName` carries the provider-aware native command name when one exists.
+ `provider` is optional and only affects native naming plus native plugin command availability.
+ `includeArgs=false` omits serialized argument metadata from the response.
* `tools.catalog` (`operator.read`) fetches the runtime tool catalog for an agent. The response includes grouped tools and provenance metadata:
+ `source`: `core` or `plugin`
+ `pluginId`: plugin owner when `source="plugin"`
+ `optional`: whether a plugin tool is optional
* `tools.effective` (`operator.read`) fetches the runtime-effective tool inventory for a session.
+ `sessionKey` is required.
+ The gateway derives trusted runtime context from the session server-side instead of accepting caller-supplied auth or delivery context.
+ The response is a session-scoped server-derived projection of the active inventory, including core, plugin, channel, and already-discovered MCP server tools.
+ `tools.effective` is read-only for MCP: it may project a warm session MCP catalog through the final tool policy, but does not create MCP runtimes, connect transports, or issue `tools/list`. If no matching warm catalog exists, the response may include a notice such as `mcp-not-yet-connected`, `mcp-not-yet-listed`, or `mcp-stale-catalog`.
+ Effective tool entries use `source="core"`, `source="plugin"`, `source="channel"`, or `source="mcp"`.
* `tools.invoke` (`operator.write`) invokes one available tool through the same gateway policy path as `/tools/invoke`.
+ `name` is required. `args`, `sessionKey`, `agentId`, `confirm`, and `idempotencyKey` are optional.
+ If both `sessionKey` and `agentId` are present, the resolved session agent must match `agentId`.
+ Owner-only core wrappers such as `cron`, `gateway`, and `nodes` require owner/admin identity (`operator.admin`) even though `tools.invoke` itself is `operator.write`.
+ The response is an SDK-facing envelope with `ok`, `toolName`, optional `output`, and typed `error` fields. Approval or policy refusals return `ok:false` in the payload rather than bypassing the gateway tool policy pipeline.
* `skills.status` (`operator.read`) fetches the visible skill inventory for an agent.
+ `agentId` is optional; omit it to read the default agent workspace.
+ The response includes eligibility, missing requirements, config checks, and sanitized install options without exposing raw secret values.
* `skills.search` and `skills.detail` (`operator.read`) return ClawHub discovery metadata.
* `skills.upload.begin`, `skills.upload.chunk`, and `skills.upload.commit` (`operator.admin`) stage a private skill archive before installing it. This is a separate admin upload path for trusted clients, not the normal ClawHub skill install flow, and is disabled by default unless `skills.install.allowUploadedArchives` is enabled.
+ `skills.upload.begin({ kind: "skill-archive", slug, sizeBytes, sha256?, force?, idempotencyKey? })` creates an upload bound to that slug and force value.
+ `skills.upload.chunk({ uploadId, offset, dataBase64 })` appends bytes at the exact decoded offset.
+ `skills.upload.commit({ uploadId, sha256? })` verifies the final size and SHA-256. Commit only finalizes the upload; it does not install the skill.
+ Uploaded skill archives are zip archives containing a `SKILL.md` root. The archive's internal directory name never selects the install target.
* `skills.install` (`operator.admin`) has three modes:
+ ClawHub mode: `{ source: "clawhub", slug, version?, force? }` installs a skill folder into the default agent workspace `skills/` directory.
+ Upload mode: `{ source: "upload", uploadId, slug, force?, sha256?, timeoutMs? }` installs a committed upload into the default agent workspace `skills/` directory. The slug and force value must match the original `skills.upload.begin` request. Rejected unless `skills.install.allowUploadedArchives` is enabled; the setting does not affect ClawHub installs.
+ Gateway installer mode: `{ name, installId, timeoutMs? }` runs a declared `metadata.openclaw.install` action on the gateway host. Older clients may still send `dangerouslyForceUnsafeInstall`; this field is deprecated, accepted only for protocol compatibility, and ignored. Use `security.installPolicy` for operator-owned install decisions.
* `skills.update` (`operator.admin`) has two modes:
+ ClawHub mode updates one tracked slug or all tracked ClawHub installs in the default agent workspace. Updates that would replace a skill directory whose installed files no longer match the recorded install digests are refused; the per-skill failure in `details.results` carries `code: "force_required"`. Retry with the optional `force: true` parameter to replace such a skill anyway.
+ Config mode patches `skills.entries.` values such as `enabled`, `apiKey`, and `env`.
### `models.list` views
`models.list` accepts an optional `view` parameter (`src/agents/model-catalog-visibility.ts`):
* Omitted or `"default"`: if `agents.defaults.modelPolicy.allow` is configured, the response is the allowed catalog, including dynamically discovered models for `provider/*` entries. Otherwise the response is the full gateway catalog.
* `"configured"`: picker-sized behavior. If `agents.defaults.modelPolicy.allow` is configured, it still wins, including provider-scoped discovery for `provider/*` entries. Without an allowlist, the response uses explicit `models.providers..models` entries, falling back to the full catalog only when no configured model rows exist.
* `"provider-config"`: source-authored `models.providers.*.models` inventory, independent of picker allowlists. Rows include public model capabilities and route-aware availability, but omit provider endpoints, auth material, and runtime request configuration.
* `"all"`: full gateway catalog, bypassing `agents.defaults.modelPolicy.allow`. Use for diagnostics/discovery UIs, not normal model pickers.
Two optional controls separate automatic reads from operator-requested discovery:
* `preparedOnly: true` reuses the current prepared catalog or a completed catalog for that runtime generation without starting provider discovery. Control UI startup and polling use this mode.
* `refresh: true` replaces a completed full catalog when the selected view requires discovery. Concurrent refreshes share one build; a failed refresh leaves the previous completed catalog available and returns the failure to the caller.
`preparedOnly: true` and `refresh: true` are mutually exclusive because one forbids discovery while the other requests it.
## Exec approvals
* When an exec request needs approval, the gateway broadcasts `exec.approval.requested`.
* Operator clients resolve by calling `exec.approval.resolve` (requires `operator.approvals`).
* For `host=node`, `exec.approval.request` must include `systemRunPlan` (canonical `argv`/`cwd`/`rawCommand`/session metadata). Requests missing `systemRunPlan` are rejected.
* After approval, forwarded `node.invoke system.run` calls reuse that canonical `systemRunPlan` as the authoritative command/cwd/session context.
* If a caller mutates `command`, `rawCommand`, `cwd`, `agentId`, or `sessionKey` between prepare and the final approved `system.run` forward, the gateway rejects the run instead of trusting the mutated payload.
## Agent delivery fallback
* `agent` requests can include `deliver=true` to request outbound delivery.
* `bestEffortDeliver=false` (the default) keeps strict behavior: unresolved or internal-only delivery targets return `INVALID_REQUEST`.
* `bestEffortDeliver=true` allows fallback to session-only execution when no external deliverable route can be resolved (for example internal/webchat sessions or ambiguous multi-channel configs).
* Final `agent` results may include `result.deliveryStatus` when delivery was requested, using the same `sent`, `suppressed`, `partial_failed`, and `failed` statuses documented for [`openclaw agent --json --deliver`](/cli/agent#json-delivery-status).
## Versioning
* `PROTOCOL_VERSION`, `MIN_CLIENT_PROTOCOL_VERSION`, `MIN_NODE_PROTOCOL_VERSION`, and `MIN_PROBE_PROTOCOL_VERSION` live in `packages/gateway-protocol/src/version.ts`.
* Clients send `minProtocol` + `maxProtocol`. Operator and UI clients must include the current protocol in that range; current clients and servers run protocol v4.
* Authenticated clients with both `role: "node"` and `client.mode: "node"` may use the N-1 node protocol (currently v3). Lightweight restart probes use the same N-1 window. Device auth, pairing, scopes, command policy, and exec approvals are unchanged by this compatibility window. Plugin-owned node capabilities and commands are withheld until the node upgrades to the current protocol because their hosted surfaces are not part of the N-1 contract.
* Schemas and models are generated from TypeBox definitions:
+ `pnpm protocol:gen`
+ `pnpm protocol:gen:swift`
+ `pnpm protocol:check`
### Client constants
The reference client implementation lives in `packages/gateway-client/src/` (OpenClaw wraps it via the thin `src/gateway/client.ts` facade). These defaults are stable across protocol v4 and are the expected baseline for third-party clients.
| Constant | Default | Source |
| --- | --- | --- |
| `PROTOCOL_VERSION` | `4` | `packages/gateway-protocol/src/version.ts` |
| `MIN_CLIENT_PROTOCOL_VERSION` | `4` | `packages/gateway-protocol/src/version.ts` |
| `MIN_NODE_PROTOCOL_VERSION` | `3` | `packages/gateway-protocol/src/version.ts` |
| `MIN_PROBE_PROTOCOL_VERSION` | `3` | `packages/gateway-protocol/src/version.ts` |
| Request timeout (per RPC) | `30_000` ms | `packages/gateway-client/src/client.ts` (`requestTimeoutMs`) |
| Preauth / connect-challenge timeout | `15_000` ms | `packages/gateway-client/src/timeouts.ts` (`OPENCLAW_HANDSHAKE_TIMEOUT_MS` env can raise the paired server/client budget) |
| Initial reconnect backoff | `1_000` ms | `packages/gateway-client/src/client.ts` (`GATEWAY_RECONNECT_POLICY`) |
| Max reconnect backoff | `30_000` ms | `packages/gateway-client/src/client.ts` (`GATEWAY_RECONNECT_POLICY`) |
| Fast-retry clamp after device-token close | `250` ms | `packages/gateway-client/src/client.ts` |
| Force-stop grace before `terminate()` | `250` ms | `FORCE_STOP_TERMINATE_GRACE_MS` |
| `stopAndWait()` default timeout | `1_000` ms | `STOP_AND_WAIT_TIMEOUT_MS` |
| Default tick interval (pre `hello-ok`) | `30_000` ms | `packages/gateway-client/src/client.ts` |
| Tick-timeout close | code `4000` when silence exceeds `tickIntervalMs * 2` | `packages/gateway-client/src/client.ts` |
| `MAX_PAYLOAD_BYTES` | `25 * 1024 * 1024` (25 MB) | `src/gateway/server-constants.ts` |
| Chat attachment ceiling | `agents.defaults.mediaMaxMb`, default 20 MB decoded | `src/gateway/chat-attachment-policy.ts` |
| Chat attachment image ceiling | `min(attachment ceiling, 6 MB)` | `src/gateway/chat-attachment-policy.ts`, `packages/media-core/src/constants.ts` |
The server advertises the effective `policy.tickIntervalMs`, `policy.maxPayload`, `policy.maxBufferedBytes`, and `policy.attachments` in `hello-ok`; clients should honor those values rather than the pre-handshake defaults or hardcoded attachment sizes.
The reference client lets finite requests own their configured deadline when every pending request has one. An `expectFinal` request without a finite `timeoutMs`, any request with `timeoutMs: null`, or a mix of finite and unbounded requests keeps the tick watchdog active. If inbound events and responses remain silent past the tick-timeout threshold, the client closes the socket with code `4000`, rejects every pending request, and reconnects. It does not replay rejected requests after reconnecting.
## Auth
* Shared-secret gateway auth uses `connect.params.auth.token` or `connect.params.auth.password`, depending on the configured `gateway.auth.mode` (`"none" | "token" | "password" | "trusted-proxy"`).
* Identity-bearing modes such as Tailscale Serve (`gateway.auth.allowTailscale: true`) or non-loopback `gateway.auth.mode: "trusted-proxy"` satisfy the connect auth check from request headers instead of `connect.params.auth.*`.
* Private-ingress `gateway.auth.mode: "none"` skips shared-secret connect auth entirely; do not expose that mode on public/untrusted ingress.
* After pairing, the gateway issues a device token scoped to the connection role + approved grant, returned in `hello-ok.auth.deviceToken`. Clients should persist it with `hello-ok.auth.scopes` after a successful connect when the token is new or different from the stored token.
* `hello-ok.auth.scopes` is the current socket's live authority and matches the scopes enforced by RPC dispatch.
* When `hello-ok.auth.deviceToken` exactly matches the token already stored for the same gateway, device, client, and role, preserve that record's stored scopes instead of replacing them with a narrower live scope set. A newly issued or rotated token uses `hello-ok.auth.scopes`; its approved grant matches that connection when it is issued.
* Reconnecting with that stored device token should also reuse the stored approved scope set for that token. This preserves read/probe/status access already granted and avoids silently collapsing reconnects to a narrower implicit admin-only scope.
* Client-side connect auth assembly (`selectConnectAuth` in `packages/gateway-client/src/client.ts`):
+ `auth.password` is orthogonal and always forwarded when set.
+ `auth.token` is populated in priority order: explicit shared token first, then an explicit `deviceToken`, then a stored per-device token (keyed by `deviceId` + `role`).
+ `auth.bootstrapToken` is sent only when none of the above resolved `auth.token`. A shared token or any resolved device token suppresses it.
+ Auto-promotion of a stored device token on the one-shot `AUTH_TOKEN_MISMATCH` retry is gated to trusted endpoints only: loopback, or `wss://` with a pinned `tlsFingerprint`. Public `wss://` without pinning does not qualify.
* Built-in setup-code bootstrap returns the primary node `hello-ok.auth.deviceToken` plus a bounded operator token in `hello-ok.auth.deviceTokens` for trusted mobile handoff. The operator token includes `operator.talk.secrets` for native Talk configuration reads, but excludes pairing-mutation scopes and `operator.admin`.
* `hello-ok.auth.deviceTokens` contains only additional bootstrap-handoff tokens. Do not use it as metadata for the primary `deviceToken` reconnect record.
* While a non-baseline setup-code bootstrap waits for approval, `PAIRING_REQUIRED` details include `recommendedNextStep: "wait_then_retry"`, `retryable: true`, and `pauseReconnect: false`. Keep reconnecting with the same bootstrap token until the request is approved or the token becomes invalid.
* Persist `hello-ok.auth.deviceTokens` only when the connect used bootstrap auth on a trusted transport such as `wss://` or loopback/local pairing.
* If a client supplies an explicit `deviceToken` or explicit `scopes`, that caller-requested scope set remains authoritative for the live connection and is reported in `hello-ok.auth.scopes`; cached token-grant scopes are only reused when the client is reusing the stored per-device token.
* Device tokens can be rotated/revoked via `device.token.rotate` and `device.token.revoke` (requires `operator.pairing`). Rotating or revoking a node or other non-operator role also requires `operator.admin`.
* `device.token.rotate` returns rotation metadata. It echoes the replacement bearer token only for same-device calls already authenticated with that device token, so token-only clients can persist their replacement before reconnecting. Shared/admin rotations do not echo the bearer token.
* Token issuance, rotation, and revocation stay bounded to the approved role set recorded in that device's pairing entry; token mutation cannot expand or target a device role that pairing approval never granted.
* For paired-device token sessions, device management is self-scoped unless the caller also has `operator.admin`: non-admin callers can manage only the operator token for their own device entry. Node and other non-operator token management is admin-only, even for the caller's own device.
* `device.token.rotate` and `device.token.revoke` also check the target operator token scope set against the caller's current session scopes. Non-admin callers cannot rotate or revoke a broader operator token than they already hold.
* Auth failures include `error.details.code` plus recovery hints:
+ `error.details.canRetryWithDeviceToken` (boolean)
+ `error.details.recommendedNextStep`: one of `retry_with_device_token`, `update_auth_configuration`, `update_auth_credentials`, `wait_then_retry`, `review_auth_configuration` (`packages/gateway-protocol/src/connect-error-details.ts`).
* Client behavior for `AUTH_TOKEN_MISMATCH`:
+ Trusted clients may attempt one bounded retry with a cached per-device token.
+ If that retry fails, stop automatic reconnect loops and surface operator action guidance.
* `AUTH_SCOPE_MISMATCH` means the device token was recognized but does not cover the requested role/scopes. Do not present this as a bad token; prompt the operator to re-pair or approve the narrower/broader scope contract.
## Device identity and pairing
* Nodes should include a stable device identity (`device.id`) derived from a keypair fingerprint.
* Gateways issue tokens per device + role.
* Pairing approvals are required for new device IDs unless local auto-approval is enabled.
* Pairing auto-approval is centered on direct local loopback connects.
* OpenClaw also has a narrow backend/container-local self-connect path for trusted shared-secret helper flows.
* Same-host tailnet or LAN connects are still treated as remote for pairing and require approval.
* WS clients normally include `device` identity during `connect` (operator + node). The only device-less operator exceptions are explicit trust paths:
+ successful `gateway.auth.mode: "trusted-proxy"` operator Control UI auth.
+ direct-loopback `gateway-client` backend RPCs on the reserved internal helper path.
* Omitting device identity has scope consequences. When a device-less operator connection is allowed through an explicit trust path, OpenClaw still clears self-declared scopes to an empty set unless that path has a named scope-preservation exception. Scope-gated methods then fail with `missing scope`.
* The reserved direct-loopback `gateway-client` backend helper path preserves scopes only for internal local control-plane RPCs; custom backend IDs do not receive this exception.
* All connections must sign the server-provided `connect.challenge` nonce.
### Device auth migration diagnostics
For legacy clients that still use pre-challenge signing behavior, `connect` returns `DEVICE_AUTH_*` detail codes under `error.details.code` with a stable `error.details.reason`.
Common migration failures:
| Message | details.code | details.reason | Meaning |
| --- | --- | --- | --- |
| `device nonce required` | `DEVICE_AUTH_NONCE_REQUIRED` | `device-nonce-missing` | Client omitted `device.nonce` (or sent blank). |
| `device nonce mismatch` | `DEVICE_AUTH_NONCE_MISMATCH` | `device-nonce-mismatch` | Client signed with a stale/wrong nonce. |
| `device signature invalid` | `DEVICE_AUTH_SIGNATURE_INVALID` | `device-signature` | Signature payload does not match v2 payload. |
| `device signature expired` | `DEVICE_AUTH_SIGNATURE_EXPIRED` | `device-signature-stale` | Signed timestamp is outside allowed skew. |
| `device identity mismatch` | `DEVICE_AUTH_DEVICE_ID_MISMATCH` | `device-id-mismatch` | `device.id` does not match public key fingerprint. |
| `device public key invalid` | `DEVICE_AUTH_PUBLIC_KEY_INVALID` | `device-public-key` | Public key format/canonicalization failed. |
Migration target:
* Always wait for `connect.challenge`.
* Use `connect.challenge.payload.ts` as `connect.params.device.signedAt`.
* Sign the v2 payload that includes the server nonce.
* Send the same nonce in `connect.params.device.nonce`.
* Preferred signature payload is `v3` (`buildDeviceAuthPayloadV3` in `packages/gateway-client/src/device-auth.ts`), which binds `platform` and `deviceFamily` in addition to device/client/role/scopes/token/nonce fields.
* Legacy `v2` signatures remain accepted for compatibility, but paired-device metadata pinning still controls command policy on reconnect.
## TLS and pinning
* TLS is supported for WS connections (`gateway.tls` config).
* Clients may optionally pin the gateway cert fingerprint via `gateway.remote.tlsFingerprint` or CLI `--tls-fingerprint`.
## Scope
This protocol exposes the full gateway API: status, channels, models, chat, agent, sessions, nodes, approvals, and more. The exact surface is defined by the TypeBox schemas re-exported from `packages/gateway-protocol/src/schema.ts`.
## Related
* [Building a Gateway client](https://docs.openclaw.ai/gateway/clients)
* [Embedding OpenClaw](https://docs.openclaw.ai/gateway/embedding)
* [Gateway runbook](/gateway)
Was this useful?
Open issue
On this page
## On this page
Responses are generated using AI and may contain mistakes.

View File

@ -1,383 +0,0 @@
# openclaw Gateway 架构研究报告
> 研究对象openclaw 源码仓库(只读),用于为 Python Agent 项目 **Astrion** 的 "gateway 化" 改造提供借鉴。
> 研究方式:以文档要点为线索,直接在源码中定位实现并给出佐证(文件路径 + 关键代码)。
> 所有路径相对仓库根 `<local-clones>/openclaw`
---
## 1. Gateway 进程结构
### 结论
单一长驻守护进程:`openclaw.mjs`(二进制入口)→ CLI 路由 `gateway` 命令 → `startGatewayServer()` 拉起 HTTP/WS 服务。WS server 用 `noServer: true` 模式挂在 HTTP server 上,由统一的 upgrade 路由分配连接归属core WS / 插件 / worker / desktop 流)。每条连接由一个 `ws-connection.ts` handler 全权管理预认证预算、connect 握手门禁、消息分发、keepalive、慢消费者关闭连接注册进全局 `GatewayClientRegistry`(一个 `Set<GatewayWsClient>`)。方法分发通过 `GatewayMethodRegistry`(方法名 → handler/scope 的路由表)。
### 关键文件与代码证据
**入口链:**
- `openclaw.mjs``src/entry.ts``runCliWithExitFinalization`)→ `src/cli/run-main.ts``arg !== "gateway"` 分支L130`import("./gateway-cli/run-command.js")` L182`src/cli/gateway-cli/run.ts`L687 `const { startGatewayServer } = await loadServerModule()`L1131 `return await startGatewayServer(port, {...})`)。
- `src/gateway/server.ts`:公开入口,默认端口 `18789`,动态 import 真正的实现:
```ts
export async function startGatewayServer(port = 18789, opts: GatewayServerOptions = {}) {
const mod = await loadServerStart();
return await mod.startGatewayServerCore(port, { ...opts, startupStartedAt });
}
```
- `src/gateway/server-start.ts``startGatewayServerCore()` → `createGatewayKernel(port, opts)``server-kernel.ts`)→ `createGatewayHttpTransport()``server-runtime-state.ts`)→ `finishGatewayStartup()``server-startup-finish.ts`,在其中 `attachGatewayWsHandlers(...)`L147-170
**WS server 组织:**
- `src/gateway/server-runtime-state.ts` L303-304`const wss = new NpmWebSocketServer({ noServer: true, ... })`HTTP server 的 `upgrade` 事件统一路由。
- `src/gateway/server-http-upgrades.ts``httpServer.on("upgrade", ...)`L221按路径/来源分派core 路径最终 `wss.handleUpgrade(req, socket, head, (ws) => { wss.emit("connection", ws, req); })`L163-167
- `src/gateway/server/ws-connection.ts``attachGatewayWsConnectionHandler()`L121注册 `wss.on("connection", ...)`,每连接:
- 生成 `connId = randomUUID()`;预认证预算 `preauthConnectionBudget`;握手超时 `resolvePreauthHandshakeTimeoutMs`
- keepalive`src/gateway/websocket-keepalive.ts` —— 每 25s `socket.ping()`,错过 pong 判死;
- `send(obj)` 检查 `socket.bufferedAmount > MAX_BUFFERED_BYTES`50MB拒绝/关闭;
- 慢消费者:`server-broadcast.ts` 中 `slow && opts?.dropIfSlow` 则丢弃但**消耗 seq**;否则 `close(1008, "slow consumer")`
- 连接注册表:`src/gateway/server/client-registry.ts` —— `class GatewayClientRegistry extends Set<GatewayWsClient>`,支持按 connId 索引。
- 连接状态/存在感:`src/gateway/server/client-presence.ts`、`presence-events.ts`presence 快照广播)。
**帧校验与分发路由表:**
- 首帧门禁:`src/gateway/server/ws-connection/message-handler.ts``handleMessage`)——未认证连接只接受 `{type:"req", method:"connect"}``validateConnectParams` 通过,否则 `close(1008, "invalid handshake")`;成功后进入 `authenticated-request-dispatch.ts`
- 方法路由表:`src/gateway/methods/registry.ts` —— `createGatewayMethodRegistry(inputs)` 把描述符(含 handler`byName: Map<string, GatewayMethodDescriptor>`,重复方法名直接抛错;`getHandler(name)` / `getScope(name)` 供分发使用。核心方法策略表在 `src/gateway/methods/core-descriptors.ts``CORE_GATEWAY_METHOD_SPECS`,每行 `[name, family, scope, since, policy]`)。
- 分发执行:`src/gateway/server-methods.ts` 的 `handleGatewayRequest` —— `authorizeGatewayMethod()`role/scope 校验L148 起)→ 注册表取 handler → 执行;请求进入时先 `validateRequestFrame`,非法即回 `res {ok:false, error}`
- 方法组装:`src/gateway/server-core-runtime.ts` L407 `createGatewayMethodRegistry(...)` 汇聚 core handlers + `extraHandlers``server-startup-finish.ts` 把 `getMethodRegistry`、`gatewayMethods`(方法名清单)、`events`(事件清单)注入 WS 层hello-ok 里下发给客户端。
### 对 Astrion 的借鉴意义
- 用 "HTTP server + WebSocketServer(noServer) + 统一 upgrade 路由" 的组织方式,天然支持未来把插件/worker/控制面放在不同 WS 路径上且共享同一生命周期shutdown drain、连接预算
- 方法路由表(名称 → handler → scope → since与 handlers 分离注册,是 Astrion 在 Python 侧可以照抄的骨架:一个 `@method("sessions.send", scope="write")` 式装饰器注册表,启动时查重。
- 预认证预算 + 慢消费者策略dropIfSlow 或 close 1008值得直接抄单网关面对大量控制面客户端时这是稳健性的关键。
---
## 2. 协议实现connect / req / res / event / seq / stateVersion / 重连恢复 / 事件不重放)
### 结论
- 线协议WS 文本帧 JSON**首帧必须是 `connect` 请求**;服务端在连接建立后先推送 `connect.challenge` 事件(含一次性 nonce客户端用设备 Ed25519 私钥签名 nonce 后再发 connect。
- `req/res` 配对靠客户端生成的 `id``event` 帧带 `seq`(每连接单调,服务端生成)与可选 `stateVersion`presence/health 两个单调计数器)。
- **事件不重放**:服务端不缓存事件;客户端靠 seq 间隙检测(`seq > lastSeq+1`)触发 `onGap`UI 的做法是直接重连,重连后通过 hello-ok 拿到**全量 snapshot**。重连期间 `lastSeq` 被重置为新 generation。
- `stateVersion` 用于"有缺口就刷新状态"hello-ok 的 snapshot 带 `stateVersion`,事件帧可选携带,客户端可据此判断 presence/health 子树是否过期并定向刷新。
### 关键文件与代码证据
**帧 schemaTypeBox** `packages/gateway-protocol/src/schema/frames.ts`
```ts
export const RequestFrameSchema = closedObject({
type: Type.Literal("req"), id: NonEmptyString, method: NonEmptyString,
params: Type.Optional(Type.Unknown()), traceparent: Type.Optional(...),
});
export const ResponseFrameSchema = closedObject({
type: Type.Literal("res"), id: NonEmptyString, ok: Type.Boolean(),
payload: Type.Optional(Type.Unknown()), error: Type.Optional(ErrorShapeSchema),
});
export const EventFrameSchema = closedObject({
type: Type.Literal("event"), event: NonEmptyString, payload: Type.Optional(Type.Unknown()),
seq: Type.Optional(Type.Integer({ minimum: 0 })),
stateVersion: Type.Optional(StateVersionSchema),
});
```
`ConnectParamsSchema`(同文件)含 `client{id, version, mode, ...}`、`device{id, publicKey, signature, signedAt, nonce}`、`auth{token, bootstrapToken, deviceToken, password, ...}``HelloOkSchema` 含 `features{methods, events}`、`snapshot: SnapshotSchema`、`auth{deviceToken, role, scopes}`、`policy{maxPayload, ...}`。
**challenge 握手(服务端):** `src/gateway/server/ws-connection.ts` L349-354 —— 连接建立即发:
```ts
const connectNonce = randomUUID();
if (connectionKind === "gateway") {
send({ type: "event", event: "connect.challenge", payload: { nonce: connectNonce, ts: Date.now() } });
}
```
首帧门禁:`message-handler.ts`(见 §1
**challenge 签名(客户端):** `packages/gateway-client/src/device-auth.ts`
```ts
export function buildDeviceAuthPayload(params): string {
return ["v2", params.deviceId, params.clientId, params.clientMode, params.role,
scopes.join(","), String(params.signedAtMs), token ?? "", params.nonce].join("|");
}
```
客户端 `protocol-client.ts` `handleMessage` 里拦截 `connect.challenge`,取出 `nonce`/`ts` 后 `sendConnect(socket, generation)``buildConnectPlan({nonce, challengeTs})` 组装签名后的 connect 参数。
**服务端验签:** `src/gateway/server/ws-connection/connect-device-proof.ts` —— `verifyGatewayConnectDeviceProof()`
- `derivedId = deriveDeviceIdFromPublicKey(device.publicKey)`,必须等于 `device.id`
- `Math.abs(Date.now() - signedAt) > DEVICE_SIGNATURE_SKEW_MS (2min)` → 拒绝;
- `device.nonce !== context.handler.connectNonce` → 拒绝;
- `resolveDeviceSignaturePayloadVersion()``handshake-auth-helpers.ts` L297-340用 v3/v2 两种 payload 分别 `verifyDeviceSignature(publicKey, payload, signature)`,全失败 → 拒绝。
**seq 生成(服务端):** `src/gateway/server-broadcast.ts`
```ts
const clientSeq = new WeakMap<GatewayWsClient, number>();
...
const nextSeq = (clientSeq.get(c) ?? 0) + 1;
frame = frameWithSequence(base, nextSeq, payloadFragment); // {"type":"event","event":...,"seq":N,...}
clientSeq.set(c, nextSeq);
```
关键注释L490`// Consume the seq for the dropped frame so the client's gap detector sees the loss instead of a silently thinner stream.`dropIfSlow 时仍 `clientSeq.set(c, nextSeq)`。L596-599`// Targeted frames ride the same per-client sequence as fanout frames...`。
**stateVersion服务端** `packages/gateway-protocol/src/schema/snapshot.ts`
```ts
/** Monotonic version counters for snapshot subtrees. */
export const StateVersionSchema = closedObject({ presence: Type.Integer(), health: Type.Integer() });
```
`src/gateway/server/health-state.ts`:模块级 `let presenceVersion = 1; let healthVersion = 1;``incrementPresenceVersion()` / health 刷新时 `healthVersion += 1``buildGatewaySnapshot()` 把 `stateVersion: { presence: presenceVersion, health: healthVersion }` 放进 snapshot。广播时 `opts.stateVersion` 可序列化进事件帧(`server-broadcast.ts` `serializeFrameField("stateVersion", opts.stateVersion)`)。
**客户端 seq 检测 + 事件不重放:** `packages/gateway-client/src/protocol-client.ts`
```ts
private lastSeq: number | null = null;
private connect(): void {
...
this.lastSeq = null; // Outer event sequences belong to one WebSocket generation.
...
}
```
```ts
const seq = typeof parsed.seq === "number" ? parsed.seq : null;
if (seq !== null) {
if (this.lastSeq !== null && seq > this.lastSeq + 1) {
const expected = this.lastSeq + 1;
this.invoke("gap", () => this.opts.onGap?.({ expected, received: seq }));
...
}
this.lastSeq = seq;
}
```
`ui/src/app/gateway-store.ts`Control UI 消费端):`onGap: ({ expected, received }) => { ...setSnapshot(...); if (isCurrentClient(nextClient)) connect(); }` —— **检测到缺口就整体重连**,重连的 hello-ok 带全量 snapshot完成"刷新状态"。
**重连恢复:** `protocol-client.ts` `handleClose()``opts.resolveClose(context)`(客户端策略)→ 若 `decision.retry``scheduleReconnect(decision.reconnectDelayMs ?? connectFailure?.reconnectDelayMs, retryAfterMs)``RetrySupervisor` 初始 1s、×2、上限 30s`client.ts` 的 `reconnect: { initialMs: 1_000, multiplier: 2, maxMs: 30_000 }`),服务器可通过 `retryable + retryAfterMs` 施加更长的退避。`generation` 每次 `connect()` 递增,旧 socket 的迟到帧被 `isActive(socket, generation)` 丢弃(防止重连竞态混帧)。
### 对 Astrion 的借鉴意义
- 协议要显式区分"握手期"与"认证后",并强制"首帧 connect"challenge→签名→hello 三步式认证能同时防重放nonce和防中间人签名绑定 deviceId+clientId+role+scopes+token+nonce
- "事件不重放 + seq 间隙检测 + 断线重连全量 snapshot" 是**无状态客户端投影**范式的核心Astrion 的 gateway 可以完全照搬:服务端只做 `seq = per-connection counter`,客户端重连后向 `snapshot` 类方法全量拉取,避免服务端维护事件缓存与游标。
- `stateVersion` 用"子树单调计数器"presence/health而非全局版本号客户端可精确知道哪个子树过期。Astrion 可扩展为 `{sessions, agents, runs, presence}` 多组计数器。
---
## 3. Session 状态归属
### 结论
真状态sessions/transcript/runs**全部归 gateway 进程所有**:默认存文件(每 agent 一个 `sessions.json`),也支持 SQLite`session.store` 配置transcript 存 `transcript_events`共享状态设备身份、配对、token`state/openclaw.sqlite`。多客户端只是"投影":通过 `sessions.list/describe/catalog` 读取、`sessions.subscribe / sessions.messages.subscribe` 订阅、`sessions.changed / session.message` 事件增量更新。存在 CAS/etag 机制session entry 的 `lifecycleRevision`randomUUID每次变更轮换+ 写操作的 `expectedLifecycleRevision`/`expectedSessionId`乐观锁transcript 层还有 `expectedLeafEntryId` 分支叶 CAS。
### 关键文件与代码证据
**存储实现:**
- `src/config/sessions/paths.ts``resolveDefaultSessionStorePath(agentId) = <stateDir>/agents/<id>/sessions/sessions.json``resolveSessionStorePathForScope()``session-store-path.ts`)支持 `storePath` 覆盖与 `sqlite:` 前缀。
- `src/config/sessions/session-accessor.sqlite-transcript-store.ts`SQLite transcript 实现,`createTranscriptEventInserter()` 插入 `transcript_events` 表(含 `seq`、`eventJson`、`createdAt`);配套 `session-transcript-index.fs.ts`(文件索引,`seq` 由 index+1 生成)。
- 共享 SQLite`src/state/openclaw-state-db.paths.ts` —— `<stateDir>/state/openclaw.sqlite`,存设备身份/配对/token见 §5
**会话行与 lifecycleRevisionetag**
- `src/config/sessions/session-accessor.sqlite-entry-store.ts` L442`previousEntry.lifecycleRevision === normalizedEntry.lifecycleRevision`(写前比对/冲突检测)。
- `session-accessor.sqlite-message-cut.ts` L300`currentEntry.lifecycleRevision !== params.expectedState.lifecycleRevision` → 拒绝L577`lifecycleRevision: params.forked ? randomUUID() : params.currentEntry.lifecycleRevision`。
- `session-accessor.sqlite-transcript-write-guard.ts` L24`entry.lifecycleRevision === scope.expectedLifecycleRevision && ...` —— transcript 写入围栏。
**CAS 参数(协议侧):** `packages/gateway-protocol/src/schema/sessions-patch.ts`
```ts
export const SessionsPatchParamsSchema = closedObject({
key: NonEmptyString, agentId: Type.Optional(NonEmptyString),
/** Reject the mutation if the session was reset or replaced before it commits. */
expectedSessionId: Type.Optional(NonEmptyString),
expectedLifecycleRevision: Type.Optional(NonEmptyString),
...
});
```
`logs-chat.ts` `ChatSendParamsSchema``expectedLeafEntryId``Transcript-branch CAS ... the client's displayed branch leaf`)、`expectedSessionRoutingContract` 等。
**多客户端投影:**
- 读取:`src/gateway/server-methods/sessions-read.ts` L196 `"sessions.list"`(含 store_load / materialization / sharing / active_run_flags 等阶段,并把结果按客户端权限过滤)。
- 订阅:`sessions.subscribe`、`sessions.messages.subscribe/unsubscribe``core-descriptors.ts` 方法表:`sessions.subscribe`, `sessions.messages.subscribe`, `sessions.messages.unsubscribe`, `sessions.viewers.set`)。
- 变更广播:`src/gateway/server-methods/session-change-event.ts` —— `context.broadcastToConnIds("sessions.changed", eventPayload, connIds, ...)`,按 sessionKey/agentId 计算受众(`resolvePrivateSessionEventBroadcastScope`),只发给订阅了该 session 的连接。
- 会话事件带 transcript 消息级 seq`src/gateway/server-session-events.ts``messageSeq = stored.seq`)→ `session-transcript-message.ts``params.messageSeq` 投射为事件 `seq`
- 订阅注册表:`src/gateway/server-chat-state.ts``SessionMessageSubscriberRegistry`),广播端用 `sessionMessageSubscribers.get(sessionKey)` 判断该连接是否订阅后才推 `session.message` 等(`server-broadcast.ts` `requiresSessionSubscription` 分支)。
### 对 Astrion 的借鉴意义
- 采用"Gateway 单所有者 + 客户端投影"模型:客户端拿到的只是 hello snapshot + 增量事件拼接的镜像,写操作一律走 RPC 并在 gateway 内做冲突检测——Astrion 应把 session/run 状态收敛进 gateway 进程,控制面完全不落盘。
- `lifecycleRevision + expectedLifecycleRevision` 的乐观锁范式非常轻量Astrion 可在 Python 侧实现为每个 session 一个 `revision: UUID`,所有 patch/send 带 `expected_revision`
- 事件广播的"受众过滤"(按 sessionKey + 订阅者集合)值得借鉴,避免把私密 transcript 广播给未订阅连接。
---
## 4. 幂等与去重
### 结论
副作用方法sessions.create、sessions.send、channels.send、node.invoke 等)在参数 schema 层强制 `idempotencyKey`;服务端实现分两类:
1. `sessions.create`**内存级去重缓存**(按 principal/device 分组的 MapTTL 5 分钟,容量上限),同时去重**并发 inflight**(同 key 共享同一个 Promise并校验"同 key 同参数"params 的 sha256
2. 消息类chat send / source reply / channel 消息):幂等键由 `runId + 投递指纹 + 操作 id` 组合,去重靠**扫描已持久化 transcript 中同 idempotencyKey 的 message** + FIFO 租约互斥。
### 关键文件与代码证据
**sessions.create内存去重缓存** `src/gateway/server-methods/session-create-idempotency.ts`
```ts
const sessionCreatesByContext = new WeakMap<GatewayRequestContext, Map<string, Map<string, SessionCreateEntry>>>();
// owner = principal ? `principal:${principal}` : `device:${deviceId}`
// requestIdentity = sha256(stableStringify(request.params))
const existing = entries?.get(idempotencyKey);
if (existing) {
if (existing.requestIdentity !== requestIdentity) → INVALID_REQUEST "idempotency key was reused with different parameters"
... scope/authorization 变化也拒绝
const result = existing.state.kind === "completed" ? existing.state.result : await existing.state.work; // inflight 去重
request.respond(result.ok, ..., { ...result.meta, cached: true });
return;
}
// 容量DEDUPE_MAX=1000server-constants.ts超限返回 UNAVAILABLE
```
TTL`packages/gateway-protocol/src/schema/sessions-create.ts` —— `export const SESSION_CREATE_IDEMPOTENCY_RETENTION_MS = 5 * 60_000;`(仅 ok 结果保留缓存,失败即释放)。
**消息类幂等键构造:** `src/agents/tools/message-tool-idempotency.ts`
```ts
export function buildMessageToolDeliveryFingerprint(params) {
const canonical = JSON.stringify(canonicalizeMessageToolIdempotencyValue({ action, params: stripEnvelope(params.params) }));
return sha256Base64UrlPrefix(canonical, 24);
}
export function buildMessageToolAutogeneratedIdempotencyKey({ runId, deliveryFingerprint, operationId }) {
return `${runId}:message-tool:${deliveryFingerprint}:${operationId}`;
}
```
**消息去重(持久化扫描 + 租约):** `src/gateway/internal-source-reply-persistence.ts`
```ts
const lease = leaseKey ? internalSourceReplyPersistenceLeases.reserve([leaseKey]) : undefined; // FIFO 租约,并发互斥
await lease?.wait();
// 先扫描completePersistedInternalSourceReply() → findTranscriptEvent(scope, event =>
// message?.idempotencyKey === params.idempotencyKey && isOpenClawDeliveryMirrorAssistantMessage(message))
```
transcript 写入层支持显式去重模式:`session-accessor.sqlite-transcript-store.ts` 中 `idempotencyKeyMode?: "dedupe" | "preserve-owner" | "relocate-owner"`,以及 `idempotencyLookup: "scan"`
**node.invoke节点侧队列去重** `src/gateway/node-runtime-state.ts` L83 `const existing = queue.find((entry) => entry.idempotencyKey === params.idempotencyKey);`——执行节点侧用内存队列按 key 去重。
### 对 Astrion 的借鉴意义
- 协议 schema 层把 `idempotencyKey` 设为必填(非空字符串),客户端重试时必须复用同一 key这是网关化系统在网络抖动下不产生重复副作用的根基。
- "内存 Map + TTL + 容量上限 + inflight 共享 Promise" 是极简且正确的服务端去重缓存实现Astrion 在 Python 可用 `dict + asyncio.Future` 复刻,注意同样要校验"同 key 同参数"hash 参数)与授权元数据不变。
- 消息类侧采用"幂等键进 transcript + 落盘扫描"的持久化去重比纯内存缓存更抗网关重启Astrion 若把 transcript 落盘,可把 idempotencyKey 作为消息的唯一索引来做幂等。
---
## 5. 设备配对与认证
### 结论
- 每次 gateway WS 连接建立后,服务器先发 `connect.challenge`(一次性 nonce + ts客户端用设备 Ed25519 私钥对 `v2/v3` 拼接载荷签名后放进 connect 的 `device{id, publicKey, signature, signedAt, nonce}`
- 服务端验签:`device.id` 必须由 publicKey 派生、签名时间在 2 分钟窗口内、nonce 必须等于该连接下发的 nonce、Ed25519 签名必须通过。
- 配对流程:`device.pair.requested/resolved` 事件 + `device.pair.approve/reject` 方法(`PAIRING_SCOPE` 权限配对记录pending/paired与 bootstrap token 都持久化在共享 SQLite`state/openclaw.sqlite`)。
- 设备 token连接成功授权`ensureDeviceToken` 颁发/轮换hello-ok 返回 `deviceToken`;支持 `device.token.rotate/revoke` 主动吊销,吊销会 invalidate 已连接客户端close 4001
- bootstrap token 是短时效一次性凭据(首次配对用),兑换后才有完整 profile。
### 关键文件与代码证据
**challenge 下发:** `src/gateway/server/ws-connection.ts` L349-354见 §2
**验签:** `src/gateway/server/ws-connection/connect-device-proof.ts`(见 §2
**配对存储:** `src/infra/device-pairing-store.ts` —— SQLite 表 `device_pairing_paired` / `device_pairing_pending` / `device_bootstrap_tokens``openOpenClawStateDatabase``state/openclaw-state-db.js``DevicePairingStoreState = { pendingById, pairedByDeviceId }`,写事务持锁(`withDevicePairingLock`)。
- 路径:`state/openclaw-state-db.paths.ts` —— `resolveOpenClawStateSqlitePath() = <stateDir>/state/openclaw.sqlite`
**配对请求/审批:** `src/infra/device-pairing.ts``requestDevicePairing`,事件 `device.pair.requested``src/infra/device-pairing-approval.ts``approveDevicePairing`,支持 auto-approve、并发协调、scope 基线校验);`src/gateway/server/ws-connection/connect-device-pairing.ts``authorizeGatewayConnectDevice`,把 pending 转 paired 并下发 scopes
**token 颁发/轮换/吊销:** `src/infra/device-pairing-tokens.ts`
```ts
export type RotateDeviceTokenDenyReason = "unknown-device-or-role" | "missing-approved-scope-baseline"
| "scope-outside-approved-baseline" | "caller-missing-scope";
// ensureDeviceToken / rotateDeviceToken / revokeDeviceToken 均在配对锁内读写 paired record 的 tokens
```
token 生成:`src/infra/pairing-token.ts` —— `randomBytes(32).toString("base64url")``verifyPairingToken` 用常数时间比较(`safeEqualSecret`)。
**bootstrap token** `src/infra/device-bootstrap.ts` —— `generatePairingToken()` 颁发TTL `DEVICE_BOOTSTRAP_TOKEN_TTL_MS`),一次性兑换 `redeemDeviceBootstrapTokenProfile`;兑换入口在 `sendGatewayHello``connect-hello.ts`)的 `authMethod === "bootstrap-token"` 分支。
**吊销联动:** `src/gateway/server/ws-connection/authenticated-request-dispatch.ts`
```ts
const DEVICE_CREDENTIAL_INVALIDATING_METHODS = new Set([
"device.pair.remove", "device.token.rotate", "device.token.revoke", "node.pair.remove",
]);
```
这些方法成功后,携带旧凭据的连接被 `invalidateGatewayPolicyClient(...close(4001, "client invalidated: ..."))` 踢下线。
**设备身份gateway 自身):** `src/infra/device-identity.ts` —— Ed25519 身份存共享 SQLite`device_identity` 表),`deriveDeviceIdFromPublicKey`、`signEd25519Payload`、`verifyEd25519Signature``ed25519-signature.ts`)。
### 对 Astrion 的借鉴意义
- "服务器先发 challenge 再收 connect" 的次序很关键nonce 必须由服务器生成,客户端签名后回传,才能防重放。
- "deviceId 由公钥派生"(无注册中心也能识别同一物理设备)+"配对审批+token 轮换/吊销+踢线" 是完整闭环Astrion 可先在本地做"首台设备自动 approved"`autoApproveNewDeviceScopes`),再扩展审批流。
- token 落 SQLite + 常数时间比较是低成本高安全性的做法Python 端secrets.compare_digest同样适用。
---
## 6. 协议 codegenTypeBox → JSON Schema → Swift
### 结论
TypeBox schema 本身就是 JSON Schema 兼容对象;每 feature 一个 `protocol-schema-fragment-*.ts`,合成唯一注册表 `ProtocolSchemas`;运行时校验用 TypeBox/compile 惰性编译出 validator**Swift 模型由脚本直接吃 TypeBox schema 对象(当作 JSON Schema 遍历)生成** `GatewayModels.swift`CI 用 `--check` 比对防止漂移。`closedObject` 用 `additionalProperties:false` 强化封闭性,并打隐藏 symbol 标记以保留名义身份。
### 关键文件与代码证据
**TypeBox 定义:** `packages/gateway-protocol/src/schema/frames.ts` / `snapshot.ts` / 各 feature fragment原始类型在 `primitives.ts``NonEmptyString = Type.String({minLength:1})`);封闭对象工厂 `closed-object.ts`
```ts
export function closedObject<Properties extends TProperties>(properties: Properties) {
const schema = Type.Object(properties, { additionalProperties: false });
Object.defineProperty(schema, identityKey, { value: Symbol("closedObject") }); // 隐藏 symbol不入 JSON
return schema;
}
```
**注册表合成:** `schema/protocol-schemas.ts` —— `ProtocolSchemas = composeProtocolSchemaFragments([...16 个 fragment])``schema/protocol-schema-composer.ts` 遍历合成并**拒绝重复 key**。
**JSON Schema → 运行时 validator** `protocol-validator.ts`
```ts
import { Compile, type Validator as TypeBoxValidator } from "typebox/compile";
compiled ??= Compile(schema as never); // 惰性编译
```
`validator-registry.ts` 给每个协议类型导出 `validateXxx = compile(S.XxxSchema)`,服务端/客户端共用。
**Swift 生成链路:** `scripts/protocol-gen-swift.ts`
- 导入 `ProtocolSchemas``ErrorCodes``packages/gateway-protocol/src/schema/error-codes.js`
- 把每个 TypeBox/JSON Schema 对象经 `stableJson()` / `schemaSignature()` 归一化后**直接当作 JSON Schema 遍历**(读 `properties/required/items/enum/anyOf/oneOf/patternProperties` 等),生成 Swift struct/class
- 输出写到 `apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift`header 注明 "Generated by scripts/protocol-gen-swift.ts — do not edit by hand"
- `--check` 模式用于 CI 校验产物与当前 schema 一致;配套 `scripts/format-swift.sh`
- Schema 对象在 JS 侧还用于 `GatewayFrameSchema` 判别联合(`discriminator: "type"`),供 quicktype/codegen 产出更紧的类型。
**协议版本:** `packages/gateway-protocol/src/version.ts` —— `PROTOCOL_VERSION = 4`, `MIN_CLIENT_PROTOCOL_VERSION = 4`, `MIN_NODE_PROTOCOL_VERSION = 3`connect 请求带 `minProtocol/maxProtocol` 区间hello-ok 回显服务器当前版本(`connect-hello.ts`)。
### 对 Astrion 的借鉴意义
- "单一 schema 源 →(服务端校验器 / 客户端模型 / 文档)" 的单一事实源值得照搬。Astrion 若用 Python可选择 Pydantic v2`model_json_schema()` 直接产 JSON Schema+ JS 客户端侧校验或生成 TS 类型;关键是**禁止手工再写一遍协议类型**。
- "封闭对象additionalProperties:false"强制了前向兼容纪律:客户端发未知字段直接被拒绝,服务器加字段必须走 optional + 版本协商。
- 一对协议版本区间min/max+ hello-ok 回显版本,让新旧客户端/节点共存变得可管理Astrion 的 gateway 协议应从一开始就带版本协商。
---
## 7. 方法与事件目录
### 结论
方法以"名称.类别"扁平命名,核心方法在 `CORE_GATEWAY_METHOD_SPECS` 一张策略表里集中声明名称、family、scope、since 版本、是否 control-plane write / advertise / startup插件方法由插件注册表附加。事件由 `GATEWAY_EVENTS` 清单声明,广播时按 `EVENT_SCOPE_GUARDS` 做 scope 过滤,部分事件要求 session 订阅。
### 关键文件
**方法:** `src/gateway/methods/core-descriptors.ts``CORE_GATEWAY_METHOD_SPECS`,核心方法约 400+ 个),`src/gateway/methods/registry.ts`(注册表),`src/gateway/server-methods/core-handlers.ts`handlers 挂载)。
**事件:** `src/gateway/server-methods-list.ts``GATEWAY_EVENTS``src/gateway/server-broadcast.ts``EVENT_SCOPE_GUARDS` + `SESSION_SUBSCRIPTION_EVENTS`)。
### 方法类别(按 family 聚合统计)
- **会话与运行**(最大族):`sessions.*` 57+list/get/describe/create/send/abort/patch/reset/delete/compact/recover/fork/rewind/subscribe/viewers/goal/groups/branches/usage/diff/files/compaction…`runs` 相关融入 send/abort/subscribe。
- **节点/worker**`node.*` 19pair.list/approve/reject/remove、list/describe、invoke、pending.pull/ack/enqueue/drain、runnerInventory.update、pluginTools/skills.update、event…
- **设备**`device.*` 9pair.list/approve/reject/remove/rename、token.rotate/revoke、scopes.requestUpgrade/waitUpgrade
- **agent/模型/技能**`agents.*`list/create/update/delete/files/workspace、`models.*`、`skills.*`36 项,含建议接受)。
- **配置与运维**`config.get/set/apply/patch/schema`、`health`、`status`、`diagnostics.*`、`doctor.*`、`logs.tail`、`update.*`、`migrations.*`、`gateway.*`(重启/suspend、`cron.*`10、`channels.*`7、`secrets.*`、`worktrees.*`、`projects.*`、`desktop.*`。
- **人机交互/审批**`exec.approval.*` / `exec.approvals.*`11、`question.*`5、`plugin.approval.*`、`openclaw.approval.*`、`talk.*`16语音
- **系统 agent 引导**`openclaw.chat/history/setup.*/changes.list`、`wizard.*`。
- **工具/媒体/周边**`tools.*`7、`terminal.*`7、`board.*`9、`canvas.*`、`portal.*`、`progressCard.*`、`tts.*`9、`push.*`7、`users.*`20、`voicewake.*`、`mentions.*`、`messages.*`、`assistant.*`、`attach.*`、`artifacts.*`、`audit.*`、`mcp.*`7、`conversations.*`4等。
### 事件类别GATEWAY_EVENTS约 90 个)
- 握手:`connect.challenge`。
- 会话/聊天:`sessions.changed`、`chat`、`chat.metadata.changed`、`ui.command`、`session.message/observer/operation/sharing/typing/tool/suggestion/approval`。
- 存在与健康:`presence`、`health`、`heartbeat`、`tick`、`shutdown`、`gateway.suspension`。
- 节点/设备:`node.pair.requested/resolved`、`node.presence`、`node.hostStats`、`node.invoke.*`cancel/input/request、`device.pair.*`changed/requested/resolved/setup.completed/deliveryUncertain
- 审批/提问:`exec.approval.requested/resolved`、`question.requested/resolved`、`plugin.approval.*`、`openclaw.approval.*`。
- 系统:`cron`、`task`、`task.suggestion`、`update.available`、`update run changed`、`config.changed`、`skills.changed`、`users.prefs.changed`、`mentions.changed`、`voicewake.*`、`talk.mode/event`、`terminal.data/exit`、`portal.changed`、`progressCard.changed`、`controlUi.sessionPullRequests.changed`、`plugins.controlUi.changed`、`sessions.catalog.host`。
### 对 Astrion 的借鉴意义
- "一张集中策略表(方法名 + scope + since + 属性)+ 注册表查重 + hello-ok 下发 methods/events 白名单" 让能力发现feature discovery成为协议一等公民。Astrion 可在 hello 响应里下发 `features.methods/events`,客户端据此决定 UI 能力。
- 事件统一走 `EVENT_SCOPE_GUARDS` 过滤矩阵event → 需要的 scope 列表),比在 handler 里各自检查权限更不易漏Python 侧可做一个装饰器 `@event("session.message", requires="read")` 并集中注册。
---
## openclaw 设计要点速查表≤10 行)
1. **单 Gateway 长驻**HTTP+WS(noServer) 统一 upgrade 路由;每连接一个 handler认证门禁/keepalive/慢消费/预算),连接注册表 = `Set<GatewayWsClient>`
2. **协议**WS 文本 JSON首帧必须 `connect``{type:req,id,method,params}` → `{type:res,id,ok,payload|error}`;事件 `{type:event,event,payload,seq?,stateVersion?}`;协议版本 = 4min/max 协商)。
3. **认证**:连接先收 `connect.challenge{nonce,ts}`,客户端用设备 Ed25519 私钥签名 `v2|deviceId|clientId|mode|role|scopes|signedAt|token|nonce` 回传;服务端验 id 派生、2min 时间窗、nonce、签名。
4. **事件不重放**seq 为每连接单调计数(被丢弃的帧也消耗 seq客户端 `seq > lastSeq+1``onGap` → 整体重连;重连 hello-ok 带**全量 snapshot**`stateVersion{presence,health}` 定子树版本。
5. **会话状态归 Gateway**sessions.json每 agent或 SQLite transcript`transcript_events` 带 seq共享状态在 `state/openclaw.sqlite`客户端只投影sessions.list/subscribe + sessions.changed 事件)。
6. **CAS/乐观锁**session `lifecycleRevision=UUID` 每次变更轮换;写操作带 `expectedLifecycleRevision/expectedSessionId/expectedLeafEntryId`
7. **幂等**:副作用方法必填 `idempotencyKey`sessions.create 用内存 MapTTL 5min、容量 1000、inflight 同 key 共享 Promise、sha256 参数校验);消息类用"RunId:指纹:opId"键 + transcript 扫描去重 + FIFO 租约。
8. **配对/租约吊销**bootstrap token32B base64url、短时效一次性→ 配对审批pending/paired 表)→ `ensureDeviceToken` 轮换rotate/revoke/pair.remove 使旧连接 close(4001)。
9. **Schema 单一源**TypeBox schemaclosedObject=additionalProperties:false→ 惰性编译 validator + `scripts/protocol-gen-swift.ts` 直读 schema 生成 `GatewayModels.swift`CI --check 防漂移)。
10. **方法/事件目录**:核心方法集中在 `CORE_GATEWAY_METHOD_SPECS`name/family/scope/since一张表事件在 `GATEWAY_EVENTS` + `EVENT_SCOPE_GUARDS` 过滤矩阵hello-ok 下发 `features.methods/events` 供客户端能力发现。

View File

@ -1,294 +0,0 @@
# opencode server/client 架构研究报告
> 研究对象opencode 仓库(版本 1.18.29,克隆位于 `<local-clones>/opencode`,只读)
> 研究目的:为 AstrionPython Agent 项目)的 "gateway 化" 改造提供借鉴
> 研究方法:源码阅读,所有结论均附文件路径与关键代码证据(行号以本次阅读时为准)
---
## 总览30 秒版)
opencode 有两个并存的 HTTP 服务层:
1. **主服务packages/opencodeTUI 实际使用)**Effect `HttpApi` 定义 + `HttpRouter.serve` 运行在 Node http server 上,路由分 Root`/global/*`、`/control/*`、Instance`/session/*`、`/tui/*`、`/event` 等、v2 protocol`/api/*`、PublicUI 静态资源。OpenAPI spec 从 Effect HttpApi 定义**代码生成**`bun dev generate` → `openapi.json``@hey-api/openapi-ts` → TS SDK
2. **精简实验服务packages/server + packages/protocol**:同样是 Effect HttpApi把协议定义下沉到 `@opencode-ai/protocol`server 只注入 middleware/handler 实现,挂载 `/api/event`、`/api/session/*` 等 v2 路由。
核心设计:**TUI 是 server 的瘦客户端**,通过生成的 SDK + SSE`/global/event`、`/event`消费事件server 通过事件总线反向驱动 TUI`/tui/*`)。
---
## 1. Server 架构框架、入口、路由、OpenAPI、SDK 生成
### 1.1 用的是什么框架
**不是 Hono也不是 Elysia而是 Effect 生态的 `HttpApi` / `HttpRouter`**Effect 自带的声明式 HTTP 框架,`effect/unstable/httpapi`),底层走 `@effect/platform-node``NodeHttpServer`node:http
证据:
- `packages/server/src/api.ts``import { HttpApi, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"``HttpApi.make("server").add(...)` 组装所有 group。
- `packages/server/src/routes.ts``HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" })``webHandler()` 用 `HttpRouter.toWebHandler(...)` 导出 fetch handler。
- `packages/opencode/src/server/server.ts``HttpRouter.serve(HttpApiApp.createRoutes(opts), ...)` + `NodeHttpServer.layer(() => server, ...)`createServer 来自 `node:http`)。
- 全局搜 `hono` 仅命中 server.ts 一处注释性匹配,无 Hono 依赖。
### 1.2 入口在哪、谁调用它
三个入口,同一套实现:
| 入口 | 文件 | 说明 |
|---|---|---|
| `opencode serve`headless | `packages/opencode/src/cli/cmd/serve.ts` | `Server.listen(opts)`,监听 `--port`(默认 4096/`--hostname` |
| TUI 进程内嵌 worker | `packages/opencode/src/cli/tui/worker.ts` | worker 内 `Server.listen(input)``rpc.server` 方法)或纯内嵌 `Server.Default().app.fetch``rpc.fetch` |
| 独立精简 server | `packages/cli/src/commands/handlers/serve.ts` | `HttpRouter.serve(createRoutes(password), ...)``opencode serve`(老 CLI |
`packages/opencode/src/server/server.ts``listen()` 实现端口回退:`startWithPortFallback` 先试 4096失败再随机端口。
### 1.3 路由如何组织
**分组HttpApiGroup+ 累积式组装**,两层:
- `packages/opencode/src/server/routes/instance/httpapi/api.ts`
- `RootHttpApi` = Control + ControlPlane + Global`/global/*`
- `InstanceHttpApi` = Config/Experimental/File/Instance/Mcp/Project/Question/Permission/Provider/Session/Sync/Tui/Workspace`/session/*`、`/tui/*`、`/permission/*` 等)
- `OpenCodeHttpApi` = Root + Event(`/event`) + Instance + Server(`/api/*`) + PtyConnect(WS)
- 每个 group 一个文件,如 `groups/session.ts`、`groups/permission.ts`、`groups/tui.ts`、`groups/event.ts`handler 对应 `handlers/*.ts`
路由路径以组内常量定义(如 `groups/session.ts``SessionPaths = { permissions: "/session/:sessionID/permissions/:permissionID", ... }`)。
### 1.4 OpenAPI spec 是手写还是代码生成?
**纯代码生成**。Effect HttpApi 的每个 `HttpApiEndpoint``Schema` 声明入参/出参/错误,用 `OpenApi.annotations` 附加 summary/description/identifierEffect 在运行时把整个 Api 编译成 OpenAPI 文档:
- `packages/opencode/src/server/server.ts``export async function openapi() { return OpenApi.fromApi(PublicApi) }`
- schema 定义集中在 `packages/schema/src`zod 风格由 Effect `Schema` 实现,无 TypeBox/zod`session.ts`、`session-message.ts`、`session-event.ts`、`permission.ts`、`tui-event.ts` 等。
- 产物:`packages/sdk/openapi.json`openapi 3.1.0188 个 operationId
### 1.5 SDK 如何从 spec 生成
`packages/sdk/js/script/build.ts` 全流程:
1. `bun dev generate > openapi.json`:调用 CLI generate 命令(`packages/opencode/src/cli/cmd/generate.ts`)→ `Server.openapi()` 输出 spec并给每个 operation 注入 `x-codeSamples`
2. `@hey-api/openapi-ts``createClient`)生成:
- `src/v2/gen/types.gen.ts`TS 类型)
- `src/v2/gen/sdk.gen.ts``OpencodeClient` 实例化 SDK`paramsStructure: "flat"`
- `src/v2/gen/client/*`fetch clientbaseUrl 默认 `http://localhost:4096`
3. 若干手工 patchsession.history 分页类型 string→number、SSE 泛型 bug
4. `bun prettier` + `bun tsc` 校验。
SDK 对外暴露:`packages/sdk/js/src/v2/client.ts``createOpencodeClient`,支持自定义 fetch、`x-opencode-directory` 头路由到指定目录),`packages/sdk/js/src/v2/server.ts``createOpencodeServer` 用 cross-spawn 拉起 `opencode serve` 子进程)、`process.ts`。
### 对 Astrion 的借鉴意义
- 如果 Astrion 也要"HTTP API + 生成 SDK",可以用类似双轨:自己手写或代码生成 OpenAPI v3.1,再接入 openapi-typescript / openapi-generator / hey-api 生成 TS SDKPython 侧可用 openapi-python-client
- "协议包protocol与实现server分离 + handler 注入"的分层(`@opencode-ai/protocol` 定义 group 与 error`@opencode-ai/server` 注入 middleware/handler值得借鉴便于多入口复用同一协议。
- OpenAPI 元数据summary/description直接写在 schema 附近,文档与代码同源,避免过期。
---
## 2. 事件系统:/event、/global/event、事件总线、序号与重连
### 2.1 两条事件通路
**A) 全局总线 `/global/event`RootHttpApiGlobalApi**
- 总线本体:`packages/opencode/src/bus/global.ts` —— 单例 Node `EventEmitter``GlobalBus.emit("event", {...})`),事件带 `directory/project/workspace/payload`
- SSE 出口:`packages/opencode/src/server/routes/instance/httpapi/handlers/global.ts` 的 `eventResponse()``Stream.callback` 把 `GlobalBus.on("event")` 转成 Effect Stream先发 `server.connected`10 秒心跳,`Stream.pipeThroughChannel(Sse.encode())`。
- 事件源:`packages/opencode/src/event-v2-bridge.ts` —— `events.listen(...)` 把 core EventV2 的每个事件转发到 GlobalBus`payload: {id, type, properties: data}`durable 事件额外发一条 `{type:"sync", syncEvent:{...}}`
**B) 实例流 `/event`InstanceHttpApiEventApi**
- `packages/opencode/src/server/routes/instance/httpapi/handlers/event.ts``events.listen` 全量订阅 EventV2然后按 `event.location.directory === instance.directory` **在服务端过滤**`WorkspaceRoutingMiddleware` 用 `directory` query/`x-opencode-directory` 头选中实例),发 `server.connected` + 10s 心跳,遇 `server.instance.disposed` 关闭流。
**C) v2 精简流 `/api/event`packages/server**
- `packages/server/src/handlers/event.ts``EventV2.allBounded(events, 256)`(有界 dropping 队列,容量 256溢出即断开报错`server.connected`(类型由 `OpenCodeEvent` union 合一15s 心跳。
### 2.2 事件类型
由 schema 中 `Event.define` 声明,按 manifest 汇总(`packages/schema/src/event-manifest.ts`
- **session 事件v2 增量式)**`packages/schema/src/session-event.ts` —— `session.next.prompted / prompt.admitted / context.updated / synthetic / shell.started|ended / step.started|ended|failed / text.started|delta|ended / reasoning.* / tool.input.*|called|progress|success|failed / retried / compaction.* / revert.*` 等约 30 种。Delta 类事件text.delta、reasoning.delta、tool.input.delta**live-only、不入库**Ended 类是 replayable 边界。
- **v1 事件(面向现有 TUI**`packages/schema/src/v1/session.ts` 的 `PartDelta`/`MessageUpdated` 等 + `permission.asked/replied`v1`permission.v2.asked/replied`v2、`question.asked/replied`、`tui.*``tui-event.ts`)、`server.connected``server-event.ts`)、插件/集成事件等。
- 事件体统一形状:`{ id: "evt_xx", type, data/properties, location?, durable?: {aggregateID, seq, version}, metadata? }``packages/schema/src/event.ts`)。
### 2.3 序号 / offset / 重放语义
**核心机制在 `packages/core/src/event.ts`EventV2 service+ `packages/core/src/event/sql.ts`SQLite**
- 表 `event_sequence(aggregate_id PK, seq, owner_id)``event(id PK, aggregate_id, seq, type(版本化), data JSON)``uniqueIndex(aggregate_id, seq)`。
- `publish()`:若是 durable 事件 → **事务内** 更新 seq`latest+1`)、先跑 projectors`project` 注册的投影回调,和写库同事务)、再插 EventTable非 durable 事件只走内存 PubSub。
- 事件有**每聚合per-aggregate如 sessionID单调 seq** 与 **versionschema 演进版本)**type 落库时是版本化串(`EventV2.versionedType(type, version)`)。
- `durable({aggregateID, after})` 流:先 `readAfter(aggregateID, after)` **从 SQLite 重放 seq > after 的历史事件**,再 `Stream.concat` 内存 pubsub 的实时事件(`subscribeDurable` 用 per-aggregate 的 sliding PubSub 唤醒读库)。→ **断线重连 = 重放历史 + 续传实时**,这是"重放事件"路线。
- v1 老的那套 `/global/event``/event`**live-only、无 seq 无重放**
**对外的重放/追平接口v2**
- `GET /api/session/:sessionID/event?after=N``StreamSse(SessionEvent.Durable)`"Replay durable events after an aggregate sequence, then continue with new durable events"`packages/protocol/src/groups/session.ts`)。
- `GET /api/session/:sessionID/history?limit&after` → 分页读 durable 事件SessionHistory
- 多 agent 协同:`/sync/replay`、`/sync/history``packages/opencode/src/server/routes/instance/httpapi/groups/sync.ts`)返回 `{ aggregateID: lastKnownSeq } → seq 之后的事件`,用于客户端从同步点追平。
### 2.4 客户端断线后怎么追数据
- **TUI 主路径v1 事件)**`packages/tui/src/context/sdk.tsx` 里 `startSSE()``sdk.global.event()`,断了之后**指数退避重连**1s→30s**不重放** —— 因为 `/global/event` 无历史TUI 靠**重新拉取状态**补齐:重连后重新 GET session/messages/status实验性`sync.start()` 开启工作区同步。
- **v2 路径durable 事件)**:带 `after` seq 的订阅天然支持重放续传2.3)。
- 结论:**"轻事件总线(全量广播、可丢、无 seq+ 有 seq 的 durable 事件SQLite 持久化、可重放)+ 客户端主动拉状态" 三层组合**,视客户端对可靠性的要求选层。
### 对 Astrion 的借鉴意义
- Python 侧实现 SSE 事件总线很简单asyncio Queue + EventEmitter 等价物),关键是**给事件加 durable 序号并落库**,提供 `after=N` 重放订阅,才能让弱网/多客户端可靠追平。
- 把"live-only delta"与"durable 终值"分离text.delta 不入库、text.ended 入库可重放)是很好的降本设计。
- 服务端按 `directory/workspace` 过滤事件而非客户端过滤减少带宽Astrion 可按 workspace/agent 维度订阅。
---
## 3. Session 状态归属:存储、真状态、消息/part 模型
### 3.1 存哪
**SQLitebun:sqlite + drizzle-orm + Effect 封装)**,单库文件:
- `packages/core/src/database/database.ts``PRAGMA journal_mode=WAL; synchronous=NORMAL; busy_timeout=5000; foreign_keys=ON`,库文件 `Global.Path.data/opencode.db``opencode-${channel}.db`)。
- 表都在 `packages/core/src/session/sql.ts``session`、`message`、`part`、`session_message`v2 投影)、`session_input`、`todo`、`session_context_epoch`;事件表在 `packages/core/src/event/sql.ts`
- 老 v1 的会话TUI 现行 API 用的 `SessionService`)读写 SQLite 的 `session/message/part` 表;新的 v2 由 `SessionProjector``packages/core/src/session/projector.ts`)把 durable 事件投影进 `session_message` 表。
### 3.2 谁拥有"真状态"
**Servercore 进程内)**Session`packages/opencode/src/session/session.ts` + `packages/core/src/v1/session`)和 SessionV2`packages/core/src/session`,事件溯源风格)都在 server 进程的 Effect 服务里;**消息/part 以 SQLite 为持久真源事件总线只广播变化客户端TUI/SDK是只读投影**。v2 的"真状态"本质是 durable 事件日志SQLite投影表/API 视图都是派生物 —— 事件即真相。所有权通过 `EventSequenceTable.owner_id` 表达(`claim(aggregateID, ownerID)``replay` 支持 `strictOwner`),多进程共享 session 时只能有一个 owner 追加事件。
### 3.3 数据模型长什么样
- **v1 message**`packages/schema/src/v1/session.ts``MessageID = "msg_..."`message = `{ id, sessionID, time:{created}, role, ... }`**Part** 是 tagged union`Text / Subtask / Reasoning / File / Tool / StepStart / StepFinish / Snapshot / Patch / Agent / Retry / Compaction``export const Part = Schema.Union([...])`discriminator "type"。SQLite `message.data`/`part.data` 以 JSON 存。
- **v2 session_message**`packages/schema/src/session-message.ts``SessionMessage.ID = "msg_..."`tagged union`agent-switched / model-switched / user / synthetic / system / shell / step-start / step-finish / reasoning / text / tool / ...`,带 `time:{created}`、`metadata`。投影表 `session_message(id, session_id, type, seq, data JSON)``uniqueIndex(session_id, seq)` —— **消息有每会话 seq和事件 seq 对应**
- **v2 session_input**`packages/core/src/session/input.ts` + `sql.ts`):输入先 `admitted_seq` 落库durable admit`promoted_seq` 被 agent 循环取走 —— 输入也是持久化的。
### 对 Astrion 的借鉴意义
- "事件日志为真相 + 投影表 + 视图 API"的 CQRS/事件溯源结构在 Python 侧可用 `sqlite3`/`SQLAlchemy` + 事件表aggregate_id, seq, type, data实现成本可控。
- 文件级 Session 与项目隔离(`project_id`/`directory` 列)对 Astrion 多 agent、多工作区是现成参考。
- 输入 admit/promote 两段式(先持久化再消费)能天然解决"请求丢失/重复"问题。
---
## 4. 权限 / 审批流:`POST /session/:id/permissions/:permissionID`
### 4.1 路由在哪
当前主服务的定义在 `packages/opencode/src/server/routes/instance/httpapi/groups/session.ts`
```ts
SessionPaths = {
...
permissions: `${root}/:sessionID/permissions/:permissionID`, // root = "/session"
}
```
handler 在 `packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts`
```ts
const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx) {
yield* requireSession(ctx.params.sessionID)
yield* permissionSvc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response })
.pipe(Effect.catchTag("Permission.NotFoundError", ...))
return true
})
```
repo 中也存在 v2 等价路径 `POST /api/session/:sessionID/permission/:requestID/reply`,见 `packages/protocol/src/groups/permission.ts`。)
### 4.2 审批请求如何产生
审批服务于两代实现机制相同pending map + Deferred 阻塞 + 事件广播):
- **老 v1**`packages/opencode/src/permission/index.ts` —— `ask()` 先对 `ruleset + approved` 求值(`evaluate(permission, pattern, ...rulesets)``Wildcard.match` 匹配 ruleallow/deny/ask 默认 ask需要问人时创建 `PendingEntry{info, deferred}` 放入内存 `pending: Map<ID, PendingEntry>`,然后 `events.publish(Event.Asked, info)` 广播,最后 `Deferred.await(deferred)` **阻塞住 agent 的工具调用**直到有人回复。
- **新 v2**`packages/core/src/permission.ts` —— `PermissionV2.ask()`(返回 `{id, effect}`)与 `assert()`(阻塞式,供 agent 内部用):问人时 `create(request, agent)``{request, agent, deferred}` 放进 `pending: Map<ID, Pending>``events.publish(Event.Asked, request)`
### 4.3 如何推给客户端
**通过事件总线广播,不是 HTTP 推送**
- `events.publish(Event.Asked, info)``EventV2Bridge``packages/opencode/src/event-v2-bridge.ts`)→ `GlobalBus.emit("event", ...)``/global/event` SSE 推给所有已连接客户端。
- TUI 侧:`packages/tui/src/context/permission.tsx` / `feature-plugins/system/notifications.ts` 监听 `permission.asked` 事件弹审批 UI同时 `context/sync.tsx` 的 case `permission.asked`/`permission.replied` 更新本地状态。
### 4.4 多个客户端同时连接时审批路由给谁
**不做"路由",做"共享待办 + 先到先得"**
- pending 表在 **server 进程内存**`Map<ID, Pending/PendingEntry>`),所有客户端共享;
- `permission.asked` 广播给**所有** SS E 连接TUI、web、其他 SDK 客户端都能看到并都能回复);
- 回复接口按 `sessionID + requestID` 定位 pending 项(`reply()` 里 `pending.get(input.requestID)`),任何客户端 POST 都算;`Deferred` 只能被 resolve/fail 一次后到的回复找不到请求404 PermissionNotFoundError
- 不存在"审批钉死给某个 client"的机制 —— **谁先回复谁生效**。当多个 TUI 同时开着,`requestID` 是共享主键service 层不区分连接。
- 附加:回复 `"always"` 时保存规则到 `permission_saved` 表(`packages/core/src/permission/saved.ts` + `sql.ts`),并把其他 pending 的同类问题自动放行;`"reject"` 会级联拒绝同 session 所有 pending。
### 对 Astrion 的借鉴意义
- 审批 = "异步请求对象(内存/DB+ 阻塞等待Deferred/Promise+ 事件广播(总线)+ 客户端主动回复HTTP POST",这是 HTTP 世界做 human-in-the-loop 的最小可靠模型。
- **多客户端审批共享同一请求 ID、先到先得** 意味着 Astrion 不需要做"连接路由",只需保证请求对象全局唯一、回复幂等。
- 用**事件permission.asked驱动 UI、用 REST 回复**的做法,比 RPC 回调更解耦,值得在 gateway 中复用。
---
## 5. TUI 与 server 的关系:连接方式与 /tui/* 反向控制
### 5.1 TUI 进程如何连接 server
**两种模式**`packages/opencode/src/cli/cmd/tui.ts`
1. **内嵌 worker 模式(默认)**CLI handler 用 `new Worker(file, {...})` 拉起 `packages/opencode/src/cli/tui/worker.ts` 作为 **Bun worker 子进程**worker 内 `Server.Default().app.fetch` 直接处理请求不监听端口URL 伪装成 `http://opencode.internal`
- HTTP 请求走 RPC`createWorkerFetch(client)` → `client.call("fetch", {...})` → worker `rpc.fetch``Server.Default().app.fetch(request)` 返回 Response
- 事件走 RPC 事件:`createEventSource(client)` → `client.on("global.event")` 把 worker 里 `GlobalBus.on("event")` 转发出来的事件喂给 TUI 的 EventSource 接口。
2. **外部模式(`--port`/`--hostname`/`--mdns`**:先 `client.call("server", network)` 让 worker 真正 `Server.listen()` 起 HTTP 端口TUI 用真实 URL + `Authorization` 头(`ServerAuth.headers()``packages/opencode/src/server/auth.ts``OPENCODE_SERVER_PASSWORD`)直连。
TUI 内全部通过**生成的 SDK**`@opencode-ai/sdk/v2` 的 `createOpencodeClient`)访问 API`packages/tui/src/context/sdk.tsx` 里 `startSSE()``sdk.global.event()` 开 SSE见 §2.4 的重连逻辑)。
### 5.2 /tui/* 反向控制接口的设计意图
定义:`packages/opencode/src/server/routes/instance/httpapi/groups/tui.ts``TuiPaths``/tui/append-prompt`、`open-help`、`open-sessions`、`open-themes`、`open-models`、`submit-prompt`、`clear-prompt`、`execute-command`、`show-toast`、`publish`、`select-session`、`control/next`、`control/response`)。
**意图:把 TUI 当作一个可被 server 及任何客户端web、插件、MCP、Agent控制的"界面设备"**,实现方式不是私有的进程内回调,而是**两条标准通道**
1. **事件通道(多数 /tui/* 端点)**handler`handlers/tui.ts`)并不直接调 TUI 内部函数,而是 `events.publish(TuiEvent.PromptAppend / CommandExecute / ToastShow / SessionSelect, ...)` **把"UI 指令"作为普通事件发布到事件总线**TUI 作为 SSE 消费者收到 `tui.toast.show`、`tui.command.execute`command 恒为 `session.list`、`help.show`、`model.list` 等字符串命令)后自己执行弹窗/切换。例如:
- `openHelp``publishCommand("help.show")`
- `openSessions``publishCommand("session.list")`
- MCP 认证失败时 server 自己 `events.publish(TuiEvent.ToastShow, {title:"MCP Authentication Required", ...})``packages/opencode/src/mcp/index.ts`)—— 同一通道、任意调用方。
2. **请求/响应队列通道(`/tui/control/next` + `/tui/control/response`**`packages/opencode/src/server/shared/tui-control.ts` 用两个模块级 `AsyncQueue``packages/opencode/src/util/queue.ts`,阻塞式队列)实现 `submitTuiRequest({path, body})` / `nextTuiRequest()` / `submitTuiResponse(body)` / `nextTuiResponse()`TUI 拉取 `control/next`long-poll执行、POST `control/response` 交回结果。适合"必须拿到返回值"的 UI 操作(如 TUI 弹一个选择框server 等它的结果)。
**结论**`/tui/*` = "通过标准 HTTP 接口把事件塞进总线、由 TUI 自主消费",使 TUI 与 server 彻底解耦 —— 同一台 server 可以同时被 TUI、桌面端、web 端、Agent 进程控制,且 UI 指令事件天然对所有客户端可见。
### 对 Astrion 的借鉴意义
- "UI 即客户端设备、指令走事件总线、回执走请求队列"是 gateway 化后"远程控制本地交互界面"的标准答案Astrion 的"弹确认框/提示"可以由任意调用方发布指令事件,前端订阅执行。
- 内嵌 worker + RPC 屏蔽 HTTP 与进程内调用的差异(`rpc.fetch` 模式),方便单元测试与本机零端口运行;对外则暴露真实端口 + 密码认证。Astrion 可在"进程内 gateway"与"独立 gateway 服务"之间无缝切换。
---
## 6. 实例模型:单实例单项目 vs 多项目多会话
**是"单 server 进程 + 多 project 实例 + 每目录懒加载"**。
- **instance 概念**`packages/opencode/src/project/instance-store.ts` 里 `InstanceStore.load({directory, ...})` —— 每个**目录directory**是一个 `InstanceContext {directory, worktree, project}`server 进程用 `cache: Map<string, Entry>` 按目录缓存懒加载的实例;实例内包含一套完整 core 服务Session/Permission/EventV2 等 Effect 层,`AppNodeBuilder` 组装)。
- **路由如何选实例**`WorkspaceRoutingMiddleware``middleware/workspace-routing.ts`)读 query 的 `directory`/`workspace` 或 header `x-opencode-directory``InstanceContextMiddleware``middleware/instance-context.ts``store.load({directory})` 把请求路由到该目录的实例上下文(`InstanceRef`。SDK 客户端可在创建时传 `directory``packages/sdk/js/src/v2/client.ts` 自动加 `x-opencode-directory` 头)。
- `opencode serve` 的注释直接说明:"Server loads instances per-request via x-opencode-directory header — no need for an ambient project InstanceContext at startup."`packages/opencode/src/cli/cmd/serve.ts`
- **workspace实验性**`WorkspaceV2`control-plane 的 `workspace.ts`)在 directory 之上再加一层,`WorkspaceRouteContext {directory, workspaceID}`;事件按 `location.directory + workspaceID` 过滤。
- 会话session挂在 project`SessionTable.project_id`)下,同一 server 可同时服务多个 project/session每个项目有自己的 SQLite 数据(同库分目录),隔离靠 directory/project 列与实例上下文。
- 多实例并发时,单写者原则由 `EventSequenceTable.owner_id` + `claim`/`strictOwner` 保证§3.2)。
### 对 Astrion 的借鉴意义
- Astrion gateway 可以是"单进程多 workspace 懒加载实例"而非"一项目一进程"进程常驻、按请求头路由实例上下文大幅简化部署实例级状态session、事件流天然隔离。
- 用请求头/query 选实例的方案(`x-opencode-directory`)可作为 Astrion 多租户路由的样板。
---
## opencode 设计要点速查表
| # | 要点 | 一句话 |
|---|---|---|
| 1 | 框架 | Effect `HttpApi/HttpRouter`(非 HonoNode http 底层,`OpenApi.fromApi` 代码生成 OpenAPI 3.1 |
| 2 | SDK | `bun dev generate` → openapi.json → `@hey-api/openapi-ts` → TS SDKfetch client + SSE 类型) |
| 3 | Server 分层 | `@opencode-ai/protocol`(协议/group + `@opencode-ai/server`middleware/handler 注入) + `@opencode-ai/core`(领域服务) |
| 4 | 事件总线 | `GlobalBus`EventEmitter`/global/event` SSE全量广播、live-only、10s 心跳);`/event` 按 directory 过滤 |
| 5 | 可靠事件 | EventV2SQLite `event/event_sequence` 表 + per-aggregate seq + version`durable(after)` 先重放后续传 |
| 6 | session 真状态 | Server 进程 + SQLiteWAL消息/part 是 JSON 投影v1 message/part 表、v2 session_message 表),事件日志即真相 |
| 7 | 消息模型 | Message/Part 都是 taggged uniontype 判别v2 session_message 带 per-session seq |
| 8 | 审批流 | pending Map + Deferred 阻塞等待 + `permission.asked` 事件广播;按 requestID 先到先得回复,无连接路由 |
| 9 | TUI 关系 | TUI=瘦客户端:内嵌 Bun worker + RPC fetch/事件,或 `--port` 真 HTTP + 密码认证;`/tui/*` 把 UI 指令发布为事件、TUI 自主消费 |
| 10 | 实例模型 | 单进程多项目:按 directory/`x-opencode-directory` 头懒加载 InstanceContext实例内一套 core 服务;单写者由事件 ownership 保证 |

View File

@ -1,92 +0,0 @@
# 阶段一/二实施计划记录
> 日期2026-09-07
> 上游文档:`gateway_work_plan.md`(三阶段路线)、`eval_summary.md`(范围与复杂度评估,含 R1-R6 审阅修订)
> 范围:**阶段一(契约文档)+ 阶段二(公共任务入口)**;阶段三定时任务未讨论功能与实现,明确不做。
## 目标
按 work plan 阶段一/二的完成标准交付:
- 阶段一:`docs/runtime_contract.md` 落地——状态责任表、概念对齐、公共入口上下文契约、调用方迁移表、回归用例清单。目标入口的状态修改路径和执行裁决可定位;已有保障成为明确约束。
- 阶段二:不创建浏览器会话、不伪造 HTTP 请求,能通过显式身份和资源上下文启动一次受控任务、读取结果并取消;同对话重入仍受门闸保护;现有 Web/CLI 行为兼容。
## 阶段二设计决策(按 R4 审阅意见)
### RuntimeContext 三层分离
```
TrustedPrincipal 可信身份与资源范围username / workspace_id / host_mode /
host_workspace_id / is_api_user / role
——只能由适配层认证后构造,客户端不可自报
TaskParams 本次任务参数message / images / videos / files / model_key /
run_mode / thinking_mode / max_iterations / conversation_id /
goal_mode / skill_context_messages / message_source
InternalDirectives 内部执行信息main_task_gate_token / auto_user_message_event /
auto_user_message_payload / preceding_user_notices /
approval_timeout_seconds仅透传机制默认语义不变
——普通客户端不可提交,仅内部调用方(通知链/工作流/派发器)使用
```
默认值解析优先级:**本次显式传参 > 对话元数据绑定 > 会话/用户偏好快照 > 系统默认**。
会话配置恢复(模型/模式)仍在 `get_user_resources` + 会话加载链路内完成RuntimeContext 携带的是「本次覆盖」与「身份快照」,不替代既有恢复逻辑。
### RuntimeService 最小接口集
- `create_task(principal, params, directives) -> task_id`(受理:互斥裁决 + 登记 + 起执行)
- `cancel_task(username, task_id)`
- `enqueue_runtime_guidance / enqueue_runtime_pending_message / remove_runtime_pending_message / promote_runtime_pending_to_guidance`
- `get_task / get_task_events(username, task_id, offset)`内部查询接口CLI/定时任务不必走 HTTP
- 审批回答复用现有三个 manager通过明确关联接入不进 RuntimeService 首版。
### 两步走
- **步骤①(兼容期)**RuntimeContext + RuntimeService 骨架落地,`create_chat_task` 接受显式上下文6 处调用点迁移;`test_request_context` 桥**保留**作为兜底(显式快照灌回 session 的既有行为不变)。
- **步骤②(拆桥)**`get_user_resources` 增加显式参数变体principal + 快照参数),`_apply_workspace_personalization_preferences` 参数化,`auth_helpers` 提供显式 record/role 入口;`_run_chat_task` 改走显式路径后删除 test_request_context 桥;`ensure_conversation_loaded` 的 session 回写移出任务路径。
## 关键风险与对策
1. `get_user_resources` 参数化最高风险host_mode/is_api_user 分支选错 → 静默串工作区。对策:显式变体与现有 web 路径并存,先任务线程单点切换,回归验证后再推广。
2. 门闸 token 移交语义InternalDirectives 原样承载;通知链「预占→移交→认领→失败回滚」不变。
3. `task_type="notice"` 互斥豁免保留。
4. 异常回退路径chat_flow_task_main.py:645-675 直接执行 handle_task_with_sender保留语义验收覆盖其门闸/事件/取消生命周期。
5. 事件前台干扰:阶段二不加 source 字段(阶段三产品决策),现有行为不变。
## 验收
- `python3 -m py_compile` 触及文件全过
- `python -m pytest test/test_server_refactor_smoke.py -q` 通过
- 新增测试:显式上下文(无 HTTP 请求)受理任务 → 拒绝同对话并发 chat 任务 → 取消;不触达真实模型调用
- 服务重启与人工验证交用户执行(不擅自重启 8091/8092
## 实施结果2026-09-07 完成,待 commit
**状态:阶段一/二代码工作全部完成,改造相关测试 24/24 全绿;工作区改动未提交。**
### 阶段一交付
- `docs/runtime_contract.md`概念对齐Session/Run/Schedule/Occurrence/Event、10 项状态责任表、已有保障约束清单、RuntimeService 契约(含 RuntimeContext 三层模型定义、调用方迁移表、12 项回归用例 T01-T12。
### 阶段二交付
- 新建 `server/runtime/` 包:
- `context.py`RuntimeContext 三层模型TrustedPrincipal / TaskParams / InternalDirectives`from_terminal()`、`principal_from_session_snapshot()`、`to_session_data()`。
- `service.py``RuntimeService`create_task / cancel / guidance / queue / get_task_events+ 进程级单例 `runtime_service`
- `TaskManager.create_chat_task` 强制显式 session_data缺失即 `ValueError`i18n key `tasks.missing_session_data`)。
- 6 处调用点全部迁移到 `runtime_service.create_task()``server/tasks/api.py`、`server/api_v1.py`、`server/workflow_runtime_api.py`×2、`server/chat_flow_task_main.py`×2完成通知派发 :613、多智能体 idle 派发 :1416。门闸 token 移交、`task_type="notice"` 互斥豁免语义原样保留。
- `server/context.py`989 行)拆分为 `server/context/` 子包identity / broadcast / personalization / usage / upload / conversation / resources / decorators / reaper 共 9 模块 + `__init__.py` 兼容 re-export外部 import 路径不变)。
- `get_user_resources` 参数化:新增 `RuntimeIdentity` 显式身份快照(定义在 `server/context/identity.py`runtime 包引用之,依赖方向自下而上无循环)。
- **test_request_context 桥已拆除**`server/tasks/models.py::_run_chat_task` 不再建立 Flask 请求上下文,任务线程全程 RuntimeIdentity 驱动。
- 审批超时透传管道terminal 属性 `_approval_timeout_seconds`(默认语义 3600s 不变),`_run_chat_task` setattr → `server/chat_flow_tool_loop.py::_approval_timeout_for()` helper → 4 个 `_wait_*` 调用点。
- 附带修复:`config/_load_dotenv` 对禁读 `.env` 的沙箱环境加 try/except不再 PermissionError 崩溃)。
### 测试验收
- 改造相关 24/24 全绿:`test_server_refactor_smoke`6+ `test_runtime_service`10新增+ `test_conversation_model_persistence`4patch 目标随迁)+ `test_runtime_identity_resources`4新增覆盖 get_user_resources 的 web/host/api 身份路由)。
- 存量失败 4 项conversation_workspace_storage / host_workspace_manager / skills_manager / token_usage_extractor经甄别与本次改动零相关未修。
### 遗留待办
1. 真实运行环境验证Web 聊天 / 停止 / 审批 / workflow 激活 / 多智能体派发需用户重启服务后人工完成——get_user_resources 分支选错会静默串工作区,这是最高风险点。
2. 审批条目 task_id 恒 None静态疑点待运行时验证
3. socket 软 stop 不打断审批等待REST 硬取消可以),留待阶段三或独立决策。

View File

@ -1,304 +0,0 @@
# 国内官方 LLM API「缓存命中 Token」字段对照报告
> 调研子智能体 #2 · 调研时间2026-08-29
> 调研范围:**国内官方 API**DeepSeek / Moonshot Kimi / 阿里通义千问 / 智谱 GLM / 字节豆包 / MiniMax / 阶跃星辰 Step / 百度文心千帆)
> 数据来源:以**各厂商官方文档**为准(文末附全部 URL个别引用了第三方报道处已单独标注。
> 结论确定性说明:本文所有"字段名 / 官方示例 JSON / 官方计费规则"均直接取自官方文档,可据此设计实验;**本文只做了文档调研,未实际跑请求验证**,实测时字段是否如实返回以实验为准。
---
## 一、总览表(速查)
| # | 厂商 | 官方平台 | 缓存机制 | 命中字段usage 内位置) | 最小触发阈值 | 命中计费折扣(官方口径) |
|---|---|---|---|---|---|---|
| 1 | DeepSeek | api.deepseek.com | **自动**(无需配置) | 顶层 `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens`**注意:不在 details 里** | 文档未给出固定值 | deepseek-v4-flash 峰值:缓存命中 $0.014/M vs 未命中 $0.44/M≈3.2%,约 1/31 |
| 2 | Moonshot Kimi | platform.moonshot.cn / platform.kimi.com | 自动(当前推荐);历史上曾有显式 cache API | `usage.cached_tokens`(顶层,官方示例);部分资料为 `usage.prompt_tokens_details.cached_tokens` | 文档未给出固定值(按前缀匹配) | kimi-k3 为 $0.30/M vs $3.00/M = **10%**k2.7-code ≈20%k2.6 ≈16.8%k2.5 ≈16.7% |
| 3 | 通义千问 Qwen | DashScope / 阿里云百炼 | OpenAI 兼容:**隐式自动** + 显式 `cache_control`;原生 DashScope同一套显式机制 | OpenAI 兼容:`usage.prompt_tokens_details.cached_tokens`(命中)、`...cache_creation_input_tokens`创建DashScope`usage.prompt_tokens_details.cached_tokens`(部分海外地域 `usage.cached_tokens` 顶层) | 隐式 ≥256 tokensQwen3.7 系列约 2000显式块 ≥1024 tokens | 隐式命中 20%(阿里部署常规);显式命中 10%、创建 125%qwen3.8-max 例外(以控制台为准) |
| 4 | 智谱 GLM | bigmodel.cn | **自动**(默认开启) | `usage.prompt_tokens_details.cached_tokens` | 智谱部署 GLM 为 512 tokens阿里云文档口径 | 命中按标准价格 **50%**(智谱官方);阿里云转售口径 25% |
| 5 | 字节豆包 Doubao | 火山方舟 volces.com | **仅显式**Context API 待下线Responses API 推荐) | `usage.prompt_tokens_details.cached_tokens` | 无自动缓存;最大缓存长度≈上下文窗口-最大输出 | 缓存输入折扣价(低于新输入)+ 存储费(元/千 token/小时) |
| 6 | MiniMax | platform.minimaxi.com国内/ platform.minimax.io海外 | 自动(被动)+ 显式 Anthropic 兼容 | OpenAI 格式:`usage.prompt_tokens_details.cached_tokens`Anthropic 格式:`usage.cache_read_input_tokens` / `cache_creation_input_tokens` | **≥512 tokens**(自动) | M3命中 $0.12/M vs 输入 $0.60/M = **20%**M2.7$0.06 vs $0.30 = 20%M2.5/M2.1$0.03 vs $0.30 = 10%(显式写入 $0.375/M |
| 7 | 阶跃星辰 Step | platform.stepfun.com | **自动** | 顶层 `usage.cached_tokens`**注意:在顶层,不在 details 里** | **≥256 tokens** | 缓存部分按该模型费用 **20%** 计费 |
| 8 | 百度文心 ERNIE | 千帆 ModelBuilderqianfan.baidubce.com | **自动**(默认开启,无需改代码) | `usage.prompt_tokens_details.cached_tokens` | 文档未给出固定值 | 命中按 prompt 单价 **40%** |
> ⚠️ 最容易踩坑的两点:
> 1. **DeepSeek 和 Step 的字段在 `usage` 顶层**`prompt_cache_hit_tokens` / `cached_tokens`),其余六家在 `usage.prompt_tokens_details` 下——实验代码要同时兼容这两种形态。
> 2. **只有豆包是纯显式缓存**(需要创建缓存或传 `caching` 参数),其余六家自动缓存 + 千问可显式。不传任何参数就指望豆包返回 cached_tokens 是无效的。
---
## 二、逐家明细
### 2.1 DeepSeek深度求索api.deepseek.com
- **官方文档**
- API 参考usage 结构https://api-docs.deepseek.com/api/create-chat-completion
- 定价页缓存命中价https://api-docs.deepseek.com/quick_start/pricing
- **缓存机制**:自动 context cache无需任何配置有"高峰期/非高峰期"分时定价(峰值=非峰值的 2 倍)。
- **usage 字段(官方 API 参考 schema 原文)**DeepSeek 是**独立字段模型**——
```json
"usage": {
"completion_tokens": 10,
"prompt_tokens": 16, // = prompt_cache_hit_tokens + prompt_cache_miss_tokens
"prompt_cache_hit_tokens": 0, // ← 命中缓存的 token 数(顶层!)
"prompt_cache_miss_tokens": 16, // ← 未命中缓存的 token 数(顶层!)
"total_tokens": 26,
"completion_tokens_details": { "reasoning_tokens": 0 }
}
```
- 官方定义原文:"Number of tokens in the prompt that hits the context cache."
- **流式行为**:官方流式示例中,最后一个 chunk`finish_reason=stop`)携带 `usage`;若设置 `stream_options.include_usage=true`,会在 `data: [DONE]` 前再补一个 `choices` 为空的 usage chunk其余 chunk 的 `usage``null`
- **计费折扣(官方定价页,单位 $/1M tokens**
| 模型 | 输入(缓存命中) 峰值/非峰值 | 输入(缓存未命中) 峰值/非峰值 |
|---|---|---|
| deepseek-v4-flash | $0.014 / $0.007 | $0.44 / $0.22 |
| deepseek-v4-pro | $0.044 / $0.022 | $1.32 / $0.66 |
即命中≈未命中的 **1/31≈3.2%**折扣力度为国内最大。峰值时段UTC 周一至五 01:0004:00 与 06:0010:00。
> 第三方报道知乎非官方DeepSeek V4-Pro 人民币口径"缓存命中 0.1 元/百万 vs 未命中 3 元/百万(差 30 倍),促销窗口 0.025 元"。此条为第三方转述,仅作参考。
- **实验要点**:读取顶层 `usage.prompt_cache_hit_tokens`;未命中时该字段为 `0`(官方示例即返回 0不要把它当缺失。
---
### 2.2 Moonshot Kimiplatform.moonshot.cn / platform.kimi.com
- **官方文档**
- API 参考Chathttps://platform.kimi.com/docs/api/chat
- 上下文缓存指南https://platform.kimi.com/docs/guides/context-caching
- 定价页https://platform.kimi.com/docs/pricing/chat
- **缓存机制**:默认会自动缓存(官方指南:"当请求包含相同前缀时自动缓存,无需手动调用;命中后自动续期")。历史上曾公测显式缓存 API`POST /v1/caching`2024 年月之暗面文档),当前 platform.moonshot.cn API 列表已不含该端点,以自动缓存为准。
- **usage 字段(官方 API 参考示例原文)**
```json
"usage": {
"prompt_tokens": 19,
"completion_tokens": 21,
"total_tokens": 40,
"cached_tokens": 10 // ← 命中缓存 token顶层官方示例原文
}
```
官方《上下文缓存指南》PDF中的 usage 示例则为 `usage.prompt_tokens_details.cached_tokens`。**两处官方文档形态不一致**,实验时两个位置都要读。
- **请求参数**:官方请求体字段 `prompt_cache_key`(官方原文)——“用于缓存相似请求的响应以优化缓存命中率。对于 Coding Agent通常是代表单个会话的 session id 或 task id退出并恢复会话时应保持不变。对于 Kimi Code Plan此字段为必填以提高缓存命中率。”不传时按前缀自动匹配。
- **流式行为**:官方指南明确"**流式返回时,最后一个 chunk 会携带 usage含 cached_tokens**"。
- **计费折扣(官方定价,$/1M**
| 模型 | 输入(未命中) | 输入(命中) | 折扣 |
|---|---|---|---|
| kimi-k3 | $3.00 | $0.30 | **10%** |
| kimi-k2.7-code | $0.95 | $0.19 | 20% |
| kimi-k2.6 | $0.95 | $0.16 | ≈16.8% |
| kimi-k2.5 | $0.60 | $0.10 | ≈16.7% |
| moonshot-v1 系列 | — | 无 | 无缓存折扣 |
- **实验要点**:最后一轮流式 chunk 的 usage 是主战场;`cached_tokens` 与 `prompt_tokens_details.cached_tokens` 两个位置都要探测。
---
### 2.3 通义千问 QwenDashScope / 阿里云百炼)
- **官方文档**阿里云百炼《上下文缓存Context Cache》https://help.aliyun.com/zh/model-studio/context-cache
- **缓存机制OpenAI 兼容模式与原生 DashScope 模式已分别核实)**
- **隐式缓存(自动)**对所有支持模型默认开启、不可关闭按前缀匹配。OpenAI 兼容与 DashScope 均可命中。
- **显式缓存(需主动开启)**:在 messages 的 content 中加 `"cache_control": {"type": "ephemeral"}`(仅此一种 type从 messages 开头到标记位置创建缓存块OpenAI 兼容、DashScope、Anthropic 兼容三种协议均支持。单次最多 4 个标记;向后回溯最近 20 个 content 块;最小缓存块 **1024 tokens**;有效期 **5 分钟(命中则重置)**
- **usage 字段**
- OpenAI 兼容 · 隐式命中(官方示例原文):
```json
"usage": {
"prompt_tokens": 3019,
"completion_tokens": 104,
"total_tokens": 3123,
"prompt_tokens_details": { "cached_tokens": 2048 }
}
```
- OpenAI 兼容 / DashScope · 显式缓存:同时上报创建与命中(官方示例原文):
```json
// 第一次请求(创建缓存) // 第二次请求(命中缓存)
"cache_creation_input_tokens": 1605, "cache_creation_input_tokens": 0,
"cached_tokens": 0, "cached_tokens": 1605,
// 均位于 usage.prompt_tokens_details 下
```
- 原生 DashScope · 视觉模型海外地域(新加坡):命中字段一度为顶层 `usage.cached_tokens`(文档注明"后续将升级至 `prompt_tokens_details.cached_tokens`");国内(北京)地域直接在 `usage.prompt_tokens_details.cached_tokens`
- Anthropic 兼容:`usage.cache_read_input_tokens`(命中,**不计入** `input_tokens`)、`usage.cache_creation_input_tokens`(创建)。
- **计费折扣(官方)**
- 隐式:命中 token 按输入标准价 **20%**(阿里百炼部署常规模型;`qwen3.8-max` 例外,以控制台为准)。
- 显式:**创建**缓存 token 按标准输入价 **125%****命中**按 **10%**qwen3.8-max 例外)。
- **触发阈值(官方)**:阿里云百炼部署模型的隐式缓存最少 **256 tokens**Qwen3.7 系列约 **2000 tokens**
- **实验要点**多协议多形态是千问的特色——OpenAI 兼容/DashScope 看 `prompt_tokens_details`Anthropic 兼容看 `cache_read_input_tokens`,部分海外地域 DashScope 看顶层 `cached_tokens`。本任务重点实验是 OpenAI 兼容 + 原生 DashScope 两种。
---
### 2.4 智谱 GLMbigmodel.cn
- **官方文档**《上下文缓存》https://docs.bigmodel.cn/cn/guide/capabilities/cache
- **缓存机制****自动(隐式)缓存**,默认启用,无需手动配置;基于内容相似度/前缀自动触发。
- **usage 字段(官方原文)**"响应字段 `usage.prompt_tokens_details.cached_tokens`"——
```json
"usage": {
"prompt_tokens": …,
"completion_tokens": …,
"total_tokens": …,
"prompt_tokens_details": { "cached_tokens": … } // ← 命中缓存 token
}
```
官方示例代码取值方式:`response.usage.prompt_tokens_details.cached_tokens`(未命中时需判空/缺省为 0
- **计费折扣(官方)**:缓存命中 Token 按优惠价格计费,"**通常为标准价格的 50%**"新内容按标准价、输出按标准价。GLM Coding Plan 套餐内积分抵扣口径官方套餐页GLM-5.3 Input 系数 6.9 / Cached Input 系数 1.7≈24.6%)。
- **有效期(官方)**"缓存有合理的时效性,过期后会重新计算",未公布固定数值。
- **第三方口径**(阿里云百炼文档):智谱部署的 GLM 触发隐式缓存最少 **512 tokens**;阿里云转售 GLMZHIPU/GLM-5.2 等)命中按 25%。
- **实验要点**:普通对话请求即可验证;同一 system 前缀连续请求看 `prompt_tokens_details.cached_tokens` 是否增长。
---
### 2.5 字节豆包 Doubao火山方舟 volces.com
- **官方文档**
- 《上下文缓存Context API待下线》https://www.volcengine.com/docs/82379/1396491
- 《上下文缓存》主文档 https://www.volcengine.com/docs/82379/1398933
- **缓存机制****仅显式缓存,无自动缓存**。两种 API
1. **Context API待下线**:先 `POST /api/v3/context/create` 创建缓存(`mode: "session"` 会话缓存 / `"common_prefix"` 前缀缓存,返回 `ctx-*` ID再调用 `POST /api/v3/context/chat/completions`(请求体带 `context_id`使用。TTL 可配,范围 1 小时7 天([3600,604800] 秒),未使用则过期、使用则重置。
2. **Responses API推荐**:请求体传 `"caching": {"type": "enabled"}`(加 `"prefix": true` 为前缀缓存)创建 Session/前缀缓存,返回缓存 ID后续用 `"previous_response_id": "<ID>"` 复用。过期时刻用 Unix 时间戳配置,最大当前时间 +604800 秒7 天)。支持多模态与工具缓存、可手动删除任意缓存 ID。
- 需在控制台「开通管理」→「推理(缓存)定价」开启缓存。
- **usage 字段官方示例原文Context Chat API 响应)**
```json
"usage": {
"prompt_tokens": 28,
"completion_tokens": 4,
"total_tokens": 32,
"prompt_tokens_details": { "cached_tokens": 18 } // ← 缓存输入 token
}
```
创建缓存接口的响应同样带 `usage.prompt_tokens_details.cached_tokens`(首建时为 0
- **流式行为**:官方 SDK 示例用 `stream_options={"include_usage": True}`chunk 的 `usage` 非空(含 cached_tokens
- **计费(官方)**:四类——新输入(标准价);**缓存输入(折扣价,显著低于新输入)**;输出(标准价);**存储费**(元/千 token/小时,按每自然小时缓存最大 token 量计,直到 TTL 到期或删除)。官方举例存储单价 0.000017 元/千 token/小时示例值Doubao-1.5-pro-32k 示例缓存输入 1.6 元/千万 tokens。实际单价以《模型价格》页为准。
- **实验要点**:不传缓存参数直接调 `/chat/completions` 是**不会**返回缓存字段的(有自动 KV 缓存但不在 usage 中体现);必须走 Context Chat API`context_id`)或 Responses API`caching`/`previous_response_id`)才能看到 `cached_tokens`
---
### 2.6 MiniMaxplatform.minimaxi.com 国内 / platform.minimax.io 海外)
- **官方文档**https://platform.minimax.io/docs/api-reference/text-prompt-caching国内域名为同一套文档platform.minimaxi.com
- **缓存机制**:两套并行——
1. **自动缓存(被动 Prompt Caching**:无需改调用方式。前缀匹配顺序为"工具列表 → 系统提示 → 用户消息"。有效期由系统按负载自动调整,命中则续期。
2. **显式缓存(仅 Anthropic 兼容 API**:在 content 中加 `"cache_control": {"type": "ephemeral"}`**5 分钟 TTL命中自动续期**;首次写入缓存有额外费用。
- **usage 字段**OpenAI 格式官方示例原文):
```json
"usage": {
"prompt_tokens": 1200,
"completion_tokens": 300,
"total_tokens": 1500,
"prompt_tokens_details": { "cached_tokens": 800 } // ← 自动缓存命中
}
```
Anthropic 格式(显式/自动均可出现):
```json
"usage": { "input_tokens": 108, "output_tokens": 91,
"cache_creation_input_tokens": 0, // 创建缓存(显式)
"cache_read_input_tokens": 14813 } // 命中缓存
```
- **触发阈值(官方)**:自动缓存适用于**输入 ≥512 tokens** 的请求。
- **计费折扣(官方 PayGo 示例)**
- MiniMax-M3输入 $0.60/M命中 $0.12/M**20%**
- MiniMax-M2.7:输入 $0.30/M命中 $0.06/M20%),显式写入 $0.375/M
- MiniMax-M2.5 / M2.1:输入 $0.30/M命中 $0.03/M10%),显式写入 $0.375/M。
- **支持模型**自动缓存——M3 / M2.7 / M2.5 / M2.1 系列显式缓存——M2.7 / M2.5 / M2.1 / M2 系列。
- **实验要点**OpenAI 兼容接口看 `prompt_tokens_details.cached_tokens`;如果用 Anthropic 协议则看 `cache_read_input_tokens`。首次请求建立缓存(可能为 0第二次请求读取。
---
### 2.7 阶跃星辰 Stepplatform.stepfun.com
- **官方文档**《Prompt 缓存最佳实践》https://platform.stepfun.com/docs/zh/guides/developer/prompt-cache
- **缓存机制****自动**;请求超过 **256 tokens 时自动启用**,按 Prompt 前缀匹配。缓存淘汰采用 **LRU最近最少使用**,不设固定 TTL高峰期缓存更容易被逐出。
- **usage 字段(官方示例原文)**——**顶层 `cached_tokens`,不在 details 里**
```json
"usage": {
"cached_tokens": 512, // ← 命中缓存 token顶层
"prompt_tokens": 591,
"completion_tokens": 120,
"total_tokens": 711
}
```
官方判定方法原文:"如果 response.usage 存在 cached_tokens 字段则表明该请求命中缓存cached_tokens 的值即为命中的 Token 长度。"
- **流式行为**:官方 Web 搜索示例显示**每个流式 chunk 都带 usage含 cached_tokens**,与 OpenAI 惯例(仅末 chunk不同需多次读取。
- **计费折扣(官方)**:缓存部分 Token 按"**对应模型费用的 20%**"计费。
- **支持模型(官方)**step-3.7-flash、step-3.5-flash、step-3.5-flash-2603、step-1o-turbo-vision 等(文档列出的系列);其他模型暂不支持。
- **实验要点**prompt 要 ≥256 tokens 才有缓存;命中读取顶层 `usage.cached_tokens`(注意与 DeepSeek 顶层字段不同名)。
---
### 2.8 百度文心 ERNIE千帆 ModelBuilder
- **官方文档**《prompt cache 上线公告》https://ai.baidu.com/ai-doc/WENXINWORKSHOP/Rm6uq7jy9
- **缓存机制****自动**,对所有用户默认开启,无需修改代码(官方原文)。
- **usage 字段(官方响应示例原文)**
```json
"usage": {
"prompt_tokens": 159,
"completion_tokens": 89,
"total_tokens": 248,
"prompt_tokens_details": { "cached_tokens": 128 } // ← 命中缓存 token
}
```
官方说明:"当本次请求已命中缓存usage 中返回 cached_tokens 字段……代表命中缓存的 token 数量。"(即未命中时该字段可能缺失。)
- **计费折扣(官方)**:命中缓存的 `cached_tokens``prompt_tokens` 单价的 **40%** 计算。模型示例ERNIE-4.0-Turbo-8K 输入命中0.0012 元/千 tokens vs 输入未命中0.003 元/千 tokens输出 0.009 元/千 tokens。
- **有效期(官方)**"系统将定期清理一段时间没有使用过的缓存";官方同时明确"命中概率并不是 100%,即使上下文完全一致的请求也存在无法命中的概率"。
- **实验要点**:同一 prompt 连续请求(官方示例即为同样长文案换问题),观察 `prompt_tokens_details.cached_tokens`;未命中时字段可能缺失,需容错。
---
## 三、跨厂商对照(实验脚本设计要点)
### 3.1 字段位置差异(最重要)
| 厂商 | 命中字段完整路径 | 未命中时表现 |
|---|---|---|
| DeepSeek | `usage.prompt_cache_hit_tokens`(顶层,另有 `prompt_cache_miss_tokens` | 返回 0 |
| Kimi | `usage.cached_tokens`(顶层)或 `usage.prompt_tokens_details.cached_tokens` | 文档两种示例并存 |
| QwenOpenAI/百炼) | `usage.prompt_tokens_details.cached_tokens`;显式另有 `cache_creation_input_tokens` | 未命中为 0/缺失 |
| QwenDashScope 海外部分模型) | `usage.cached_tokens`(顶层) | — |
| GLM | `usage.prompt_tokens_details.cached_tokens` | 缺失(官方例程判空) |
| 豆包 | `usage.prompt_tokens_details.cached_tokens` | 需要显式缓存才出现 |
| MiniMax | OpenAI 格式:`usage.prompt_tokens_details.cached_tokens`Anthropic 格式:`usage.cache_read_input_tokens` / `cache_creation_input_tokens` | 首次请求命中可能为 0 |
| Step | `usage.cached_tokens`(顶层) | 未命中时无该字段(官方判定) |
| 百度千帆 | `usage.prompt_tokens_details.cached_tokens` | 缺失 |
**兼容读取建议**:统一读取器按以下优先级取值——
```
candidates = [
usage.get("prompt_cache_hit_tokens"), # DeepSeek
usage.get("cached_tokens"), # Kimi / Step / 部分 DashScope
(usage.get("prompt_tokens_details") or {}).get("cached_tokens"), # 其余各家
(usage.get("prompt_tokens_details") or {}).get("cache_read_input_tokens"), # Anthropic 兼容
]
```
### 3.2 流式 usage 位置差异
- **DeepSeek**:末 chunk 或 include_usage 追加 chunk。
- **Kimi**:流式末 chunk 携带 usage。
- **Step**:每个 chunk 都可能带 usage。
- **豆包**SDK 需 `stream_options={"include_usage": True}`
- 其余Qwen/GLM/MiniMax/千帆):按 OpenAI 惯例,`stream_options.include_usage=true` 时末 chunk 带 usage非流式直接看响应 usage。
### 3.3 缓存触发阈值
- Step≥256 tokensMiniMax≥512 tokensQwen 隐式≥256部分模型更高全局建议构造 **≥2048 tokens 的稳定前缀** 再测,避开各家阈值差异。
### 3.4 显式 vs 自动(决定实验脚本形态)
- 只发普通请求即可验证DeepSeek、Kimi、Qwen隐式、GLM、MiniMax、Step、百度千帆。
- 必须额外走显式流程:**豆包**(先建缓存/传 caching 参数Qwen 如需显式命中cache_control ephemeral5 分钟 TTL、1024 tokens 起)也需加标记。
---
## 四、来源清单与确定性分级
| 事实 | 确定性 | 依据 |
|---|---|---|
| DeepSeek usage 顶层 `prompt_cache_hit_tokens/miss_tokens`V4 缓存命中价($0.014 vs $0.44 峰值) | 高(官方 API 参考与定价页原文) | https://api-docs.deepseek.com/api/create-chat-completion · /quick_start/pricing |
| Kimi `usage.cached_tokens`(顶层);`prompt_cache_key`;流式末 chunk 带 usageK3 命中 10%$0.30/$3.00 | 高(官方指南/API/定价页K3 价格亦有第三方 blog 复述一致) | https://platform.kimi.com/docs/api/chat · /docs/guides/context-caching · /docs/pricing/chat |
| Qwen OpenAI 兼容 & DashScope 隐式/显式缓存字段、1024 阈值、5min TTL、20%/10%/125% 计费 | 高(阿里云官方文档原文+示例 JSON | https://help.aliyun.com/zh/model-studio/context-cache |
| GLM 自动缓存、`prompt_tokens_details.cached_tokens`、命中约 50% | 高(智谱官方);智谱部署 512 阈值、25% 折扣为阿里云文档转述(中) | https://docs.bigmodel.cn/cn/guide/capabilities/cache |
| 豆包仅显式缓存、`prompt_tokens_details.cached_tokens`、TTL 1h7d、Responses API caching 参数 | 高(火山方舟官方文档原文) | https://www.volcengine.com/docs/82379/1396491 · 1398933 |
| MiniMax 自动≥512、OpenAI/Anthropic 双字段、M3 命中 20%$0.12/$0.60 | 高官方文档RooCode issue 表格亦一致 | https://platform.minimax.io/docs/api-reference/text-prompt-caching |
| Step 自动≥256、顶层 `cached_tokens`、20% 计费、LRU | 高(官方文档原文) | https://platform.stepfun.com/docs/zh/guides/developer/prompt-cache |
| 百度千帆自动默认开启、`prompt_tokens_details.cached_tokens`、40% 计费 | 高(官方公告响应示例) | https://ai.baidu.com/ai-doc/WENXINWORKSHOP/Rm6uq7jy9 |
| DeepSeek V4-Pro 人民币缓存价0.1 元 vs 3 元/百万) | 低-中(仅第三方知乎转述,非官方) | 第三方报道 |
| Kimi 命中价逐模型数值 | 中(官方定价页为主,第三方 blog 佐证) | platform.kimi.com pricing + 第三方 blog |
**验证状态**:以上均为**官方文档调研结论**,尚未做真实 API 请求实测。建议下一步按第三节要点构造实验脚本逐家验证字段如实返回。

View File

@ -1,91 +0,0 @@
# 国内官方 LLM API 缓存字段速查(官方示例 JSON 摘录)
> 配套报告:同目录 `README.md`。以下 JSON 均为各厂商**官方文档原文示例**摘录,直接复制进实验脚本对照。
## 1. DeepSeek —— usage 顶层命中/未命中
```json
"usage": {
"prompt_tokens": 16,
"completion_tokens": 10,
"total_tokens": 26,
"prompt_cache_hit_tokens": 0,
"prompt_cache_miss_tokens": 16,
"completion_tokens_details": { "reasoning_tokens": 0 }
}
```
## 2. Moonshot Kimi —— usage 顶层 cached_tokens官方示例原文
非流式响应:
```json
"usage": { "prompt_tokens": 19, "completion_tokens": 21, "total_tokens": 40, "cached_tokens": 10 }
```
流式响应(最后一个 chunkfinish_reason=stop 时携带):
```json
"usage": {"prompt_tokens":19,"completion_tokens":13,"total_tokens":32,"cached_tokens":12}
```
请求参数 `prompt_cache_key`(官方原文):“用于缓存相似请求的响应以优化缓存命中率。对于 Coding Agent通常是代表单个会话的 session id 或 task id退出并恢复会话时应保持不变。对于 Kimi Code Plan此字段为必填以提高缓存命中率。”
官方《上下文缓存指南》PDF 示例亦出现 `usage.prompt_tokens_details.cached_tokens`,两处并存,实验需双读。)
## 3. 通义千问 Qwen阿里云百炼OpenAI 兼容)
隐式命中:
```json
"usage": { "prompt_tokens": 3019, "completion_tokens": 104, "total_tokens": 3123,
"prompt_tokens_details": { "cached_tokens": 2048 } }
```
显式cache_control ephemeral
```json
"usage": { "prompt_tokens": 2174, "completion_tokens": 0,
"prompt_tokens_details": { "cache_creation_input_tokens": 2156, "cached_tokens": 0 } }
// 第二次请求命中cache_creation_input_tokens=0, cached_tokens=2156
```
原生 DashScope`usage.prompt_tokens_details['cached_tokens']`(部分海外地域视觉模型为顶层 `usage.cached_tokens`,官方注明后续升级)。
## 4. 智谱 GLM
> 官方文档只给出字段名,未公布具体示例数字,以下为字段结构示意(值用占位符):
```json
"usage": { "prompt_tokens": <int>, "completion_tokens": <int>, "total_tokens": <int>,
"prompt_tokens_details": { "cached_tokens": <int> } }
```
## 5. 字节豆包(火山方舟 Context Chat API
```json
"usage": { "prompt_tokens": 28, "completion_tokens": 4, "total_tokens": 32,
"prompt_tokens_details": { "cached_tokens": 18 } }
```
(需创建 ctx-* 缓存并传 context_id或 Responses API 传 `"caching":{"type":"enabled"}` / `previous_response_id`。)
## 6. MiniMax
OpenAI 兼容格式:
```json
"usage": { "prompt_tokens": 1200, "completion_tokens": 300, "total_tokens": 1500,
"prompt_tokens_details": { "cached_tokens": 800 } }
```
Anthropic/Messages 格式(自动或显式均可出现):
```json
"usage": { "input_tokens": 108, "output_tokens": 91,
"cache_creation_input_tokens": 0, "cache_read_input_tokens": 14813 }
```
## 7. 阶跃星辰 Step —— usage 顶层 cached_tokens
```json
"usage": { "cached_tokens": 512, "prompt_tokens": 591, "completion_tokens": 120, "total_tokens": 711 }
```
## 8. 百度文心(千帆 ModelBuilder
```json
"usage": { "prompt_tokens": 159, "completion_tokens": 89, "total_tokens": 248,
"prompt_tokens_details": { "cached_tokens": 128 } }
```
## 统一读取优先级(实验脚本建议)
```python
usage = resp.get("usage") or {}
pdet = usage.get("prompt_tokens_details") or {}
cached = (
usage.get("prompt_cache_hit_tokens") # DeepSeek
or usage.get("cached_tokens") # Kimi / Step / 部分 DashScope
or pdet.get("cached_tokens") # Qwen/GLM/豆包/MiniMax/千帆
or pdet.get("cache_read_input_tokens") # Anthropic 兼容
or 0
)
```

View File

@ -1,422 +0,0 @@
# 海外官方 LLM API「缓存命中 token 字段」调研报告v2 重试版)
> 调研时间2026-08-29
> 调研人:子智能体 #4
> 范围:海外**官方 API**OpenAI / Anthropic / Google Gemini / xAI Grok / Mistral AIAWS Bedrock 与 Azure OpenAI 仅简述透传方式)
> 方法以官方文档为准platform.openai.com / docs.anthropic.com / platform.claude.com / ai.google.dev / docs.x.ai / docs.mistral.ai / learn.microsoft.com / aws.amazon.com 官方博客),社区与第三方内容仅作辅助并标注来源等级。
> 说明官方文档随时间变化2026 年的文档已覆盖 GPT-5.x、Claude Opus/Sonnet 5 等新模型),本报告同时保留「历史经典行为」(如 OpenAI 1024 阈值、Anthropic 1024/2048与「文档当前状态」供实验对照。
---
## 1. 总览对照表
| 提供商 | 缓存类型 / 启用方式 | 命中字段 JSON 路径(非流式) | 写入(创建)字段 | 自动 / 显式 | 最低门槛 | 官方文档链接 |
|---|---|---|---|---|---|---|
| **OpenAI** Chat Completions | prompt cachingKV cache | `usage.prompt_tokens_details.cached_tokens` | `usage.prompt_tokens_details.cache_write_tokens`GPT-5.6+ 上报;老模型无写入字段) | 自动implicitGPT-5.6+ 可选显式 breakpoint | 历史 1024 tokens128 递增当前文档GPT-5.6+ = 1024更早模型 = 2048 | https://platform.openai.com/docs/guides/prompt-caching |
| **OpenAI** Responses API | prompt caching | `usage.input_tokens_details.cached_tokens` | `usage.input_tokens_details.cache_write_tokens` | 自动 / 显式(`prompt_cache_options.mode` + `prompt_cache_breakpoint` | 同上 | https://platform.openai.com/docs/guides/prompt-caching |
| **Anthropic** Claude Messages API | prompt caching前缀缓存 | `usage.cache_read_input_tokens` | `usage.cache_creation_input_tokens`(另有细分对象 `usage.cache_creation.ephemeral_5m_input_tokens` / `ephemeral_1h_input_tokens` | 显式:块级 `cache_control``{"type":"ephemeral"}`);也提供顶层 `cache_control` 自动断点 | 因模型而异512 / 1024 / 2048 / 4096 均有(详见 §3 表) | https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching即 platform.claude.com/docs/en/build-with-claude/prompt-caching |
| **Google Gemini** GLMGenerative Language API | 隐式缓存 + 显式 Context Caching`cachedContents` 资源) | `usageMetadata.cachedContentTokenCount`SDK snake_case`usage_metadata.cached_content_token_count`;另有带 modality 细分 `cacheTokensDetails` | 无「usage 内写入字段」;显式缓存通过 `cachedContents.create` 创建资源TTL 计费) | 隐式2.5 及更新模型自动;显式:须先创建 CachedContent 再传 `cachedContent` | 隐式Gemini 2.5 = 2048 tokensGemini 3.x = 4096 tokens历史2.5 Flash 曾 1024 / 2.5 Pro 曾 2048显式缓存资源 ≥1 分钟 TTL按 token·时长计费 | https://ai.google.dev/gemini-api/docs/generate-content/caching |
| **xAI** Grok | prompt cachingmessages 前缀缓存) | Chat Completions`usage.prompt_tokens_details.cached_tokens`Responses API`usage.input_tokens_details.cached_tokens` | 无独立字段(官方仅暴露 `cached_tokens` | 自动;建议设 `x-grok-conv-id` / `prompt_cache_key` 提升命中率 | 官方文档未公布固定 token 门槛(按消息前缀整段匹配) | https://docs.x.ai/developers/advanced-api-usage/prompt-caching |
| **Mistral AI** | prompt caching前缀缓存OpenAI 兼容格式) | `usage.prompt_tokens_details.cached_tokens` | 无独立字段(未命中时该字段为 0 或省略) | 显式:须在请求中传 `prompt_cache_key` 提高命中;命中与否由服务端决定 | 缓存块 = 64 tokens`cached_tokens` 恒为 64 的倍数;<64 token 无命中 | https://docs.mistral.ai/studio/conversations/advanced/prompt-caching |
| **AWS Bedrock** | 透传Claude 系用 `cachePoint`system/tools 内Amazon Nova 自动缓存 | `usage`(原生透传 Anthropic 的 `cacheReadInputTokens`/`cacheCreationInputTokens`converse 返回 `usage.cacheReadInputTokens`SDK 中为 `usage_metadata` | `cacheCreationInputTokens` | 显式cachePoint/ Nova 自动 | Claude 按模型(同 AnthropicNova 最高 20K tokens | https://aws.amazon.com/blogs/machine-learning/effectively-use-prompt-caching-on-amazon-bedrock |
| **Azure OpenAI** | 透传:与 OpenAI 字段一致prompt caching | Chat Completions`usage.prompt_tokens_details.cached_tokens`Responses API`usage.input_tokens_details.cached_tokens` | `usage.prompt_tokens_details.cache_write_tokens`GPT-5.6+ | 自动GPT-5.6+ 支持 breakpoint / `prompt_cache_key` | 最低 1024 tokens前 1024 必须完全一致 | https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/prompt-caching |
**一句话结论**:五家海外官方 API 中OpenAI / xAI / Mistral / Gemini 用「自动或半自动 + `cached_tokens` 类字段」Anthropic 用「显式 cache_control + read/write 双字段」。唯一同时提供「命中 + 写入」双向拆分的官方原生字段是 **Anthropic**`cache_read_input_tokens` / `cache_creation_input_tokens`)与 **OpenAI GPT-5.6+ / Responses API**`cached_tokens` / `cache_write_tokens`)。
---
## 2. 各家详细信息
### 2.1 OpenAIChat Completions API + Responses API
**官方文档**https://platform.openai.com/docs/guides/prompt-caching2026-08 抓取,官方文档原文;历史公告 https://openai.com/index/api-prompt-caching 作辅助)
**1) 是否支持 / 自动或显式**
- 支持,**默认自动启用**implicit caching无需改代码。
- GPT-5.6 及更新模型支持**显式缓存断点**`prompt_cache_options.mode: "explicit"` + 块上 `prompt_cache_breakpoint: {"mode":"explicit"}`)与 `prompt_cache_key`(影响路由、帮助同前缀请求命中同一台机器)。
- 更早模型仅有隐式缓存,断点由 OpenAI 按模型间隔自动放置。
**2) 命中字段完整 JSON 路径**
- Chat Completions API`usage.prompt_tokens_details.cached_tokens`
- Responses API`usage.input_tokens_details.cached_tokens`
- 官方文档定价示例Responses API 用法,摘自官方 docs 原文):
```json
// Responses API官方文档 "Request 1 · Response usage"
{
"usage": {
"input_tokens": 12000,
"input_tokens_details": {
"cached_tokens": 0,
"cache_write_tokens": 12000
}
}
}
// 第二次请求命中:
{
"usage": {
"input_tokens": 15000,
"input_tokens_details": {
"cached_tokens": 12000,
"cache_write_tokens": 3000
}
}
}
```
- Chat Completions经典格式官方社区/公告示例):`usage.prompt_tokens_details.cached_tokens`
```json
{
"usage": {
"prompt_tokens": 1253,
"completion_tokens": 72,
"total_tokens": 1325,
"prompt_tokens_details": {
"cached_tokens": 1024
},
"completion_tokens_details": {
"reasoning_tokens": 0
}
}
}
```
**3) 缓存写入/创建字段**
- 有Responses API `usage.input_tokens_details.cache_write_tokens`Chat Completions `usage.prompt_tokens_details.cache_write_tokens`GPT-5.6+ 上报;更早模型不计写入费、也不上报该字段)。
- 注:官方 docs 的成本计算示例同时读取 `cached_tokens``cache_write_tokens` 计算输入成本。
**4) 流式响应stream=true**
- Chat Completionsusage含 cached_tokens只在**最后一个 chunk** 返回,且必须设置 `stream_options: {"include_usage": true}`,否则流式响应不含 usage。
- Responses API流式下 usage 在 `response.completed` 事件中携带,字段路径不变。
- 字段路径在流式与批式下**完全一致**。
**5) 最低门槛**
- 历史2024-10 公告GPT-4o/o1 时代):**≥1024 tokens** 自动缓存,命中按 **128 tokens 递增**1024/1152/1280/1408…缓存通常 510 分钟无活动后清除、最长 1 小时。
- 当前官方文档2026-08GPT-5.6 及以后 = **1024 visible tokens**GPT-5.5 及更早 = **2048 visible tokens**个别老模型可更短GPT-5.6 不再按 128 取整(精确到缓存断点),旧模型上报时向下取整到 128 倍数。
**6) 计费折扣**
- 历史模型:命中 token 打 5 折50% off
- 当前GPT-5.6+ 缓存读 0.1×、缓存写 1.25×(写一次 + 读一次 = 1.35× vs 不缓存 2×更早模型读价为模型相关折扣、写入不额外计费。
**7) 注意事项**
- 缓存匹配的是「完整渲染前缀」model、tools、parallel_tool_calls、格式参数等任何相关设置变化都可能破坏前缀。
- 命中不保证 100%(路由溢出、机器未持有缓存)。官方建议用 `prompt_cache_key` 提高路由一致性。
- 实验时优先用 Responses API 的 `input_tokens_details`,或 Chat Completions 的 `prompt_tokens_details`;两处都要注意老模型字段可能为 null/缺省(`cached_tokens` 为 0 也算明确返回)。
### 2.2 Anthropic ClaudeMessages API
**官方文档**https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching与 platform.claude.com/docs/en/build-with-claude/prompt-caching 同源2026-08 抓取)
**1) 是否支持 / 自动或显式**
- 支持,**必须显式标记**
- 显式块级:在 `system` / `tools` / `messages.content` 块上加 `"cache_control": {"type": "ephemeral"}`(可加 `"ttl": "1h"` 延长到 1 小时)。
- 自动断点:在请求**顶层**加一个 `cache_control`系统自动把断点放到最后一个可缓存块并随对话增长前移2026 新增的 automatic caching 模式)。
- 断点最多 4 个;缓存前缀顺序 tools → system → messages。
**2) 命中字段完整 JSON 路径**
- `usage.cache_read_input_tokens`(本次请求从缓存读取的 token 数)
- `usage.input_tokens`(未命中、实际处理的 token 数)
- 官方文档示例1 小时 TTL 输出):
```json
{
"usage": {
"input_tokens": 2048,
"cache_read_input_tokens": 1800,
"cache_creation_input_tokens": 248,
"output_tokens": 503,
"cache_creation": {
"ephemeral_5m_input_tokens": 148,
"ephemeral_1h_input_tokens": 100
}
}
}
```
**3) 缓存写入/创建字段**
- 有:`usage.cache_creation_input_tokens`(写入缓存的新 token 数1 小时 TTL 时另有细分对象 `usage.cache_creation.ephemeral_5m_input_tokens` / `ephemeral_1h_input_tokens`(二者之和 = cache_creation_input_tokens
**4) 流式响应stream=true**
- usage 在 **`message_start` 事件**的 `message.usage` 中(含 input_tokens / cache_creation_input_tokens / cache_read_input_tokens`output_tokens` 增量在 `message_delta` 事件。字段路径与批式一致。
- 官方原文:"Monitor cache performance using these API response fields, within `usage` in the response (or `message_start` event if streaming)。"
**5) 最低门槛**(当前文档,按模型)
| 模型 | 最低可缓存 token 数 |
|---|---|
| Claude Opus 5 / Fable 5 / Mythos 5 | 512 |
| Claude Mythos Preview / Opus 4.7 | 2,048 |
| Claude Opus 4.6 / 4.5 | 4,096 |
| Claude Opus 4.8 / Sonnet 5 / Sonnet 4.6 / 4.5 / Opus 4.1 / Opus 4 / Sonnet 4 | 1,024 |
| Claude Haiku 4.5 | 4,096 |
| Claude Haiku 3.5 | 2,048 |
- 历史经典值claude-3/3.5 时代Sonnet/Opus = 1024、Haiku = 2048。低于门槛即使打了 cache_control 也不会缓存、不报错,只会在 usage 里两个缓存字段都为 0。
**6) 计费折扣**
- 缓存写5m TTL1.25× 基础输入价缓存写1h TTL2× 基础输入价;**缓存读/刷新0.1× 基础输入价**(约 90% 折扣)。
**7) 注意事项**
- 命中要求前缀 100% 一致(一个字符差异即 miss缓存生命周期 5 分钟1h 可选),从请求开始计时。
- 思考块thinking不能单独打 cache_control但可作为助手轮内容被缓存enabling/disabling web search、citations、effort 等设置会失效部分缓存。
- 可用 `max_tokens: 0` 预热缓存(不产生输出)。
- 官方提供 cache diagnostics 接口用于排查前缀差异。
### 2.3 Google GeminiGenerative Language API / Vertex AI
**官方文档**https://ai.google.dev/gemini-api/docs/generate-content/caching 与 REST 参考 https://ai.google.dev/api/generate-content2026-08 抓取)
**1) 是否支持 / 自动或显式**
- 支持两种:
- **隐式缓存**Gemini 2.5 及更新模型默认自动启用,请求里什么都不用加。
- **显式 Context Caching**:用 `cachedContents.create` 创建 CachedContent 资源(含 model/contents/systemInstruction/ttl然后在 generateContent 请求传 `cachedContent: "<cache 资源名>"` 引用。可用 OpenAI 兼容库时在 `extra_body``cached_content`
- Vertex AI 同样支持context caching字段名一致。
**2) 命中字段完整 JSON 路径**
- REST`GenerateContentResponse.usageMetadata.cachedContentTokenCount`
- SDKsnake_casePython/Node`response.usage_metadata.cached_content_token_count`
- 细分字段:`usageMetadata.cacheTokensDetails[]`(按 modality 的命中 token 明细)。
- 官方 REST 参考中 UsageMetadata JSON节选
```json
{
"promptTokenCount": integer,
"cachedContentTokenCount": integer,
"candidatesTokenCount": integer,
"toolUsePromptTokenCount": integer,
"thoughtsTokenCount": integer,
"totalTokenCount": integer,
"promptTokensDetails": [ { "modality": "...", "tokenCount": integer } ],
"cacheTokensDetails": [ { "modality": "...", "tokenCount": integer } ],
"candidatesTokensDetails": [ { "modality": "...", "tokenCount": integer } ]
}
```
- 社区实测示例Gemini 2.5来源discuss.ai.google.dev等级=辅助):显式缓存命中时 `cached_content_token_count=4115`、`cache_tokens_details=[{modality:'TEXT', token_count:4115}]`。
**3) 缓存写入/创建字段**
- usage 内**没有**缓存写入字段;「写入」体现在显式 CachedContent 资源的计费(按 token 数 × 存储时长 TTL 计费),资源元数据中有 `usageMetadata.totalTokenCount`。隐式缓存的写入由 Google 内部处理,不暴露字段。
**4) 流式响应stream=true / streamGenerateContent**
- `usageMetadata` 在**最后一个 chunk** 返回REST `streamGenerateContent` 的末帧SDK 中亦在流结束的响应对象上)。当前官方文档没有为缓存命中另设流式事件,字段路径不变。
- 社区有多起「流式末尾 usageMetadata 里 cachedContentTokenCount 缺失」的报告discuss.ai.google.dev等级=辅助,未 100% 确认为官方 bug实验时建议同时打印非流式结果对照。
**5) 最低门槛**
- 隐式当前文档表Gemini 2.5 Flash / 2.5 Pro = **2,048 tokens**Gemini 3.x3.1 Pro Preview / 3.5 / 3.6 / 3.7 Flash= **4,096 tokens**
- 历史Google 官方博客 2025-052.5 Flash 曾 1,024、2.5 Pro 曾 2,048——阈值随版本调整以文档当前值为准。
- 显式缓存:无 token 下限但资源有 TTL默认 1h可 300s 起),按 token×时间计费≥1 分钟 TTL。
**6) 计费折扣**
- 隐式缓存命中按缓存价计费Gemini 2.5 起缓存输入约为基础价 10% 档;具体以官方定价页为准)。
- 显式缓存:命中 token 折扣 90%2.5+ 模型)/ 75%2.0 模型),外加缓存存储费($/token·hour
**7) 注意事项**
- 隐式缓存命中率不受控制、非保证显式缓存保证计费折扣但要多维护资源生命周期create/list/update/delete API
- `cachedContentTokenCount` 只统计命中的 token`promptTokenCount` 仍含全部输入;计算未命中部分 = promptTokenCount cachedContentTokenCount。
- 实验时注意 SDK 属性名 snake_case`cached_content_token_count`)与 REST camelCase`cachedContentTokenCount`)的差异。
### 2.4 xAI Grok
**官方文档**https://docs.x.ai/developers/advanced-api-usage/prompt-cachingHow it works / Usage & Pricing / Best Practices & FAQ2026-08 抓取)
**1) 是否支持 / 自动或显式**
- 支持,**完全自动**(按 messages 数组起始匹配前缀);建议设置 HTTP 头 `x-grok-conv-id`(或 Responses API 的 `prompt_cache_key`)提升命中率。
- 无 cache_control 之类显式标记。
**2) 命中字段完整 JSON 路径**
- Chat Completions API`usage.prompt_tokens_details.cached_tokens`
- Responses API`usage.input_tokens_details.cached_tokens`
- 官方示例Chat Completions
```json
{
"usage": {
"prompt_tokens": 125,
"completion_tokens": 48,
"total_tokens": 173,
"prompt_tokens_details": {
"text_tokens": 125,
"audio_tokens": 0,
"image_tokens": 0,
"cached_tokens": 98
},
"completion_tokens_details": {
"reasoning_tokens": 0,
"audio_tokens": 0,
"accepted_prediction_tokens": 0,
"rejected_prediction_tokens": 0
}
}
}
```
- 官方示例Responses API`usage.input_tokens_details.cached_tokens`(路径同 OpenAI Responses API
**3) 缓存写入/创建字段**
- 无。官方只暴露 `cached_tokens`≤0 表示 miss等于 prompt_tokens 表示整段命中)。
**4) 流式响应stream=true**
- 官方 FAQ**流式与非流式均支持缓存**;流式下第一个空 token 对应缓存查找与 prefill 阶段。usage 聚合到最后一个 chunk沿用 OpenAI 风格,需 `stream_options.include_usage`)。字段路径不变。
**5) 最低门槛**
- 官方文档未公布固定 token 门槛;机制按「消息前缀精确匹配」整段生效(示例中 3 条消息被整体缓存)。实验时建议让共享前缀 ≥ 数百 token 并保持多轮对话以观察命中增长。
**6) 计费折扣**
- 缓存命中 token 按「cached prompt token 价」计费(低于常规输入价;具体比率见各模型定价页)。
**7) 注意事项**
- 命中无保证(内存压力可驱逐缓存、请求可能路由到别的机器);换 `x-grok-conv-id` 可强制 miss便于对照实验。
- 典型多轮turn1 cached=0建缓存→ turn2 cached=前 50 → turn3 cached=前 120官方示例
### 2.5 Mistral AI
**官方文档**https://docs.mistral.ai/studio/conversations/advanced/prompt-caching 与 API 参考 https://docs.mistral.ai/api/endpoint/chat2026-08 抓取)
**1) 是否支持 / 自动或显式**
- 支持 prompt caching**需在请求中显式传 `prompt_cache_key`**(会话/工作流 ID来提升命中且请求体必须保留共享前缀多轮重发完整历史。命中与否由服务端决定key 不保证命中。
- 接口格式 OpenAI 兼容(/v1/chat/completions
**2) 命中字段完整 JSON 路径**
- `usage.prompt_tokens_details.cached_tokens`
- 官方示例:
```json
{
"id": "a4db7c530548494f8ff9986bcd2a7737",
"created": 1773840064,
"model": "mistral-large-latest",
"usage": {
"prompt_tokens": 1013,
"total_tokens": 1043,
"completion_tokens": 30,
"prompt_tokens_details": {
"cached_tokens": 1008
}
},
"object": "chat.completion"
}
```
- 未命中时 `cached_tokens` 为 0 或字段被省略。
**3) 缓存写入/创建字段**
- 无独立写入字段;计费侧「可收费未缓存输入 = prompt_tokens cached_tokens」。
**4) 流式响应stream=true**
- 官方文档未单列流式差异API 与 OpenAI 兼容,流式下 usage含 cached_tokens在最后一个 chunk需 stream_options.include_usage 等开关)。实验时建议以「最后一个 chunk 的 usage」为准核对并用非流式对照等级基于兼容性推断官方未明示
**5) 最低门槛**
- **缓存块 = 64 tokens**`cached_tokens` 恒为 64 的倍数prompt < 64 tokens 不会命中共享前缀越长可复用越多
**6) 计费折扣**
- 缓存命中 token 按标准输入价 **10%** 计费(官方原文)。
**7) 注意事项**
- `prompt_cache_key` 不应包含密钥/敏感数据;变更 prompt 开头部分会导致 miss命中率可在 Admin Panel Usage 按模型查看。
### 2.6 AWS Bedrock / Azure OpenAI透传简述
**AWS Bedrock**
- 透传方式Claude 系模型在 `converse` / `invoke_model` 请求的 system/tools 内放 `{"cachePoint": {"type": "default"}}` 标记缓存点Claude 平台另有 cache_control 等效写法Amazon Nova 模型则自动缓存(文本 prompt最多 20K tokens
- 响应中透传 Anthropic 原样字段:`usage.cacheReadInputTokens` / `usage.cacheCreationInputTokens`REST/ SDK `usage_metadata``cache_read_input_tokens` / `cache_creation_input_tokens`AWS 官方博客示例:
```json
"usage": {
"input_tokens": 10,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 37209,
"output_tokens": 324
}
```
- 门槛/折扣与 Anthropic 一致ClaudeClaude 之外模型(截至 2026-06 官方/社区口径多数尚未支持缓存AWS re:Post 有用户确认 Nova 支持、Mistral 在 Bedrock 上不支持)。
- 官方https://aws.amazon.com/blogs/machine-learning/effectively-use-prompt-caching-on-amazon-bedrock
**Azure OpenAI**
- 透传方式:与 OpenAI 同名请求结构与字段,模型名改为 Azure 部署名。
- 命中字段Chat Completions `usage.prompt_tokens_details.cached_tokens`Responses API `usage.input_tokens_details.cached_tokens`GPT-5.6+ 另有 `usage.prompt_tokens_details.cache_write_tokens`
- 门槛:**≥1024 tokens** 且前 1024 tokens 完全一致GPT-5.5 及更早按 128 递增取整GPT-5.6+ 不取整默认自动启用GPT-5.6+ 支持 `prompt_cache_key` / `prompt_cache_options.mode` / `prompt_cache_breakpoint`、`prompt_cache_options.ttl="30m"`。
- 官方示例:
```json
{
"usage": {
"prompt_tokens": 1566,
"completion_tokens": 1518,
"total_tokens": 3084,
"prompt_tokens_details": {
"audio_tokens": null,
"cached_tokens": 1408,
"cache_write_tokens": 0
},
"completion_tokens_details": { "audio_tokens": null, "reasoning_tokens": 576 }
}
}
```
- 官方https://learn.microsoft.com/en-us/azure/foundry/openai/how-to/prompt-caching
---
## 3. 实验建议(按供应商)
通用思路:**连续发两条「共享固定前缀 + 不同后缀」的请求**,记录第 1 次(写入/未命中)与第 2 次(命中)的 usage 字段;前缀长度务必超出各家门槛;两次请求间隔须在缓存 TTL 内。
### OpenAI
- 构造:固定 system/developer 指令≥1024 tokens建议 2000+)放最前 + 每次变化的 user 后缀;两次请求只改后缀。
- 读取字段Chat Completions `usage.prompt_tokens_details.cached_tokens`Responses API `usage.input_tokens_details.cached_tokens`GPT-5.6+ 可同时看 `cache_write_tokens`/`cache_read` 0.1× 计费)。
- 断言:第 2 次请求 `cached_tokens > 0`(且应为前缀长度附近,老模型按 128 取整)。
- 流式:必须 `stream_options: {"include_usage": true}`,取最后一个 chunk 的 usage。
- 建议同时打印完整 `usage` 对象,防止 SDK 解包时字段为 None。
### Anthropic Claude
- 构造system 数组放长文本(≥对应模型门槛,如 Sonnet 4.5/5 = 1024Haiku 4.5 = 4096块尾加 `"cache_control": {"type": "ephemeral"}`;连续发两条相同前缀请求。
- 读取字段:`usage.cache_read_input_tokens`(命中)与 `usage.cache_creation_input_tokens`(首次写入);`input_tokens` 为未命中部分。
- 断言:第 1 次 `cache_creation_input_tokens ≈ 前缀长`、第 2 次 `cache_read_input_tokens ≈ 前缀长`;两次都在 5 分钟(默认 TTL内。
- 流式:读 `message_start` 事件的 `message.usage`
- 额外可试:`max_tokens: 0` 预热 + 顶层 `cache_control`automatic caching两种模式各跑一轮。
### Google Gemini
- 构造:
- 隐式:长 system_instruction / 长首条 user 消息≥2048 tokensGemini 2.5≥4096 若用 3.x连续请求、保持前缀不变。
- 显式:先 `cachedContents.create`model + contents + ttl如 300s~1h再 generateContent 传 `cachedContent`,用于对照验证。
- 读取字段:`response.usage_metadata.cached_content_token_count`REST 为 `usageMetadata.cachedContentTokenCount`);明细看 `cache_tokens_details[]`
- 断言:显式缓存第 1 次 cached=0或写资源、之后 cached ≈ 缓存内容 token 数;隐式缓存命中需 ≥ 门槛且不保证,多试几轮或调高峰时段。
- 流式:取流末尾 chunk 的 usageMetadata 核对;若缺失可与非流式对照。
- 注意promptTokenCount 含缓存 token未命中部分 = promptTokenCount cachedContentTokenCount。
### xAI Grok
- 构造:固定 system + 固定历史轮次 + 变化的最新 user 消息;设置 `x-grok-conv-id`Chat Completions`prompt_cache_key`Responses API保持一致。
- 读取字段Chat Completions `usage.prompt_tokens_details.cached_tokens`Responses API `usage.input_tokens_details.cached_tokens`
- 断言:多轮递增 —— turn1 cached=0 → turn2 cached=前几轮总 token → turn3 更大;若一直为 0改用不同/省略 conv-id 强制 miss 对照已验证机制。
- 流式:可观察第一个空 tokenprefillusage 取最后 chunk。
### Mistral AI
- 构造:固定 system + 历史 + 变化的最后 user 消息,**每条请求都传相同 `prompt_cache_key`(如会话 ID并完整重发前缀**;前缀建议 ≥128 tokens至少 2 个 64 块)以便观察倍数。
- 读取字段:`usage.prompt_tokens_details.cached_tokens`。
- 断言:第 2 次 `cached_tokens > 0` 且为 64 的倍数;未命中时为 0 或字段缺失。计费未缓存输入 = prompt_tokens cached_tokens10% 折扣)。
- 流式:取最后一个 chunk 的 usage必要时非流式对照。
### AWS Bedrock / Azure OpenAI顺带
- BedrockClaudesystem 里加 `{"cachePoint": {"type": "default"}}`,看 `usage.cacheReadInputTokens` / `cacheCreationInputTokens` 增减。
- Azure OpenAI与 OpenAI 实验相同≥1024 tokens、前缀不动`usage.prompt_tokens_details.cached_tokens` 与 GPT-5.6+ 的 `cache_write_tokens`
---
## 附:信息来源与等级
| 内容 | 来源 | 等级 |
|---|---|---|
| OpenAI 全部字段/门槛/计费 | platform.openai.com/docs/guides/prompt-caching官方2026-08 抓取) | 官方文档 |
| OpenAI 历史 1024/128 递增/5 折 | openai.com/index/api-prompt-caching官方公告 | 官方发布 |
| Anthropic 全部字段/门槛/计费/流式 | docs.anthropic.com= platform.claude.comprompt-caching官方2026-08 抓取) | 官方文档 |
| Gemini 隐式/显式/门槛 | ai.google.dev/gemini-api/docs/generate-content/caching、ai.google.dev/api/generate-content官方 | 官方文档 |
| Gemini usageMetadata JSON | ai.google.dev/api/generate-content官方 REST 参考) | 官方文档 |
| Gemini 2.5 隐式缓存历史阈值 | developers.googleblog.comGoogle 官方博客) | 官方发布(辅助) |
| Gemini 流式/字段缺失现象 | discuss.ai.google.dev 社区帖 | 第三方(辅助) |
| xAI 全部字段/机制/流式 FAQ | docs.x.ai/developers/advanced-api-usage/prompt-caching官方2026-08 抓取) | 官方文档 |
| Mistral 全部字段/64 块/10% 计费 | docs.mistral.ai/studio/conversations/advanced/prompt-caching、docs.mistral.ai/api/endpoint/chat官方 | 官方文档 |
| Bedrock cachePoint/usage 透传 | aws.amazon.com 官方博客 + AWS re:Post + Portkey 文档 | 官方博客/第三方辅助 |
| Azure OpenAI 字段/门槛 | learn.microsoft.com微软官方 | 官方文档 |
> 提示:以上信息抓取于 2026-08-29部分字段/门槛随时间迭代(如 Gemini 阈值、Anthropic 门槛、OpenAI GPT-5.6 行为改动)。实验前建议按本报告给出的官方链接复核最新值;凡第三方转述均已在文中标注「等级=辅助」。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

926
study.md
View File

@ -1,926 +0,0 @@
你可以不从 0 写代码,现在也没必要要求自己能脱离 AI 手搓一整个项目。
但是你必须知道**什么是好代码什么是烂代码AI 写出来的代码大概在干什么,有没有明显问题。**
所以最基础的代码概念和软件开发概念还是要知道。
这部分咱们主要说**大模型应用开发**,暂时不说模型算法。
### 基础代码概念
什么是变量,什么是字符串、整数、浮点数、布尔值
什么是 list、dict、set、tuple这些东西分别是干什么的
尤其是 dict 很重要,因为你以后会天天看到类似 JSON 的结构
什么是输入和输出
什么是 if / else
什么是 for / while 循环
什么是函数(非常重要)
什么是 def
什么是参数
什么是返回值
为什么代码要拆成一个个函数,而不是从头写到尾
什么是作用域,为什么一个函数里面定义的变量外面不一定能直接用
什么是 None / null
什么是对象,什么是类
这部分不用深入学面向对象,但是至少看到:
`client.xxx.xxx()`
这种代码的时候,要大概知道是什么意思
什么是 import
什么是模块
什么是包
为什么代码可以从别的文件或者第三方库里面导入东西
什么是文件路径
相对路径和绝对路径有什么区别
怎么读取和写入文件
什么是命令行
最基础的 cd、ls、运行 python 文件是什么意思
什么是cli
cmd powershell这些黑窗口是什么
什么是 pip / npm
为什么很多代码安装一个包以后才能运行
---
### 错误和调试
什么是报错,程序为什么会报错
怎么看报错信息和 traceback
什么是异常
什么是 try / except / finally
为什么有些代码需要捕获异常
为什么不能为了“程序不报错”就在所有地方乱套 try
为什么 `except: pass` 很危险
什么是调试
什么是 debug
什么是日志
为什么程序不能只靠 print 调试
为什么要保存日志
什么是 debug / info / warning / error 日志
为什么线上程序出问题以后,日志非常重要
---
### 数据格式
什么是纯文本
什么是 Markdown 格式
什么是 JSON 格式(很重要)
什么是 CSV 格式
什么是 YAML 格式
为什么程序更喜欢:
```json
{
"name": "小明",
"age": 18
}
```
而不是:
```text
小明今年18岁
```
什么叫结构化数据
什么是 JSON Schema
为什么有时候不能只让大模型“随便输出一段文字”,而是要让它按照固定 JSON 格式输出
---
### Git
什么是 Git重要
什么是 Git 仓库
什么是 commit
什么是 push
什么是 pull
什么是 diff
什么是 branch
什么是 merge
什么是 .gitignore
什么是 revert / rollback
你可以不完全会自己敲 Git 命令,但是这些东西最基础的意思一定要知道。
以后完全可以让 AI 帮你操作 Git但是
**你必须知道 AI 到底改了什么,以及出问题以后怎么退回去。**
现在 AI 一次可能帮你改十几个文件,不懂 Git 的话其实挺危险的。
---
### 项目和运行环境
什么是依赖
为什么别人电脑能运行的项目,你电脑上不一定能直接运行
什么是虚拟环境
为什么不同项目最好使用不同的虚拟环境
什么是环境变量
为什么 API Key、数据库密码这些东西不能直接写死在代码里面
什么是 `.env`
为什么 `.env` 一般不能提交到 Git
什么是 requirements.txt / pyproject.toml / package.json
什么是配置文件
为什么配置和代码最好分开
---
### 前端和后端
什么是前端
什么是后端
浏览器里面运行的代码和服务器里面运行的代码有什么区别
为什么 API Key 不能直接放在前端 JS 里面
一个网页点击“发送”以后,到底发生了什么:
```text
用户点击按钮
前端 JavaScript
HTTP 请求
你的后端
大模型 API
模型返回结果
你的后端
前端
显示在网页上
```
这整个流程一定要搞明白。
---
### Web 基础
什么是 HTTP
什么是 HTTPS
什么是 Request
什么是 Response
什么是 GET / POST
什么是状态码
200、400、401、403、404、429、500 大概是什么意思
什么是 REST API
什么是 WebSocket
什么是 SSE
为什么 ChatGPT 可以一个字一个字地输出,而不是等答案全部生成完才显示
---
### 数据存储
为什么不能什么东西都往 JSON 文件里面存
什么时候适合用 JSON 文件
什么时候应该使用数据库
什么是 SQLite
什么是数据库里的表、行、列
什么是主键
最基础的 SELECT / INSERT / UPDATE / DELETE 是什么意思
为什么聊天记录这种东西用 SQLite 通常比一个 messages.json 更合适
但是也不是“SQLite 永远比 JSON 高级”,比如 config.json 拿来保存配置就完全没问题
以后再知道 PostgreSQL 这些正式数据库是干什么的
---
### 同步、异步和并发
先知道普通程序为什么一般是从上往下一行一行执行
为什么请求一个 API 的时候程序可能会在那里等几秒
什么是同步
什么是异步
什么是 async / await
什么是并发
为什么同时处理 20 个模型请求,不一定需要一个处理完再处理下一个
什么是竞态条件race condition
为什么多个任务同时修改同一个数据的时候可能出问题
这些不用一开始研究得特别深,但是做稍微复杂一点的项目以后一定会碰到。
---
### 前端技术
为什么最基础的网页可以只用 HTML
CSS 是干什么的
JavaScript 是干什么的
为什么通常会把 HTML / CSS / JS 分开
什么时候原生 HTML / CSS / JS 已经够用了
为什么项目复杂以后会使用 Vue / React
什么是组件
什么是状态
为什么 UI 复杂以后Vue / React 会比手写一大堆 DOM 操作方便
不是说 Vue / React 一定比原生 JS “高级”,而是它们解决复杂前端项目的组织问题。
---
# 然后开始大模型部分
### 什么是 API 调用
什么叫 API
为什么你写程序可以调用 OpenAI、Claude、Gemini 这些模型
API Key 是干什么的
Request 里面一般会发送什么
Response 里面一般会返回什么
为什么 API 请求可能失败
什么是 timeout
什么是 retry
什么是 rate limit
什么是 429
为什么不能无限并发请求模型
模型 API 为什么要花钱
输入和输出为什么都会产生费用
---
### Message 和 Role
什么是 message
什么是 role
什么是 user
什么是 assistant
什么是 system
什么是 developer
什么是 tool
这些 role 分别是干什么的
为什么不同位置的指令优先级可能不一样
注意这些 role 并不是所有厂商的 API 都完全一样,要结合具体模型 API 看。
---
### Prompt
什么是 prompt
什么是 system prompt
system prompt 和用户输入有什么区别
怎么写一个比较好的 system prompt
为什么不要把所有要求全部堆成一大坨文字
为什么最好把:
身份
任务
规则
输出格式
边界条件
写清楚
为什么 prompt 不是越长越好
---
### Token 和上下文
什么是 token
为什么字符数不等于 token 数
什么是上下文窗口context window
什么叫最大上下文
什么叫最大输出长度
为什么:
```text
System Prompt
+
聊天记录
+
用户当前消息
+
RAG 搜索到的资料
+
Tool 返回结果
```
全部都会占上下文
为什么聊天越来越长以后,不能把所有历史记录永远全部塞给模型
为什么需要上下文压缩
---
### 模型生成参数
为什么同一个问题问模型两次,答案不一定完全一样
什么是 temperature
什么是 top_p
什么时候希望模型输出更加稳定
什么时候可以让模型更加随机
这部分不需要研究背后的数学公式,知道它们会影响模型生成方式就可以。
---
### Streaming
什么是流式输出
为什么 ChatGPT 是一边生成一边显示
为什么模型 API 不一定要等完整答案生成完再返回
Streaming 和普通 API 请求有什么区别
Streaming 会怎么影响前端和后端代码
---
### Structured Output
什么是 Structured Output
为什么做程序的时候:
```text
这个人的名字叫小明他18岁
```
通常没有:
```json
{
"name": "小明",
"age": 18
}
```
好处理
为什么要让模型按照 Schema 输出
为什么即使是大模型生成的 JSON也应该进行验证
为什么永远不能默认“大模型输出的一定是合法数据”
---
### Tool Calling
什么是 Tool
为什么模型本身不能真的替你查数据库、发 HTTP 请求、读硬盘
什么叫 Tool Calling / Function Calling
大模型是怎么决定调用 Tool 的
最重要的是理解:
```text
用户
大模型判断需要工具
大模型输出:
我要调用 get_weather(city="上海")
你的代码真正执行 get_weather
把结果返回给模型
模型根据工具结果回答用户
```
也就是说:
**很多时候不是模型自己执行了工具,而是模型告诉你的程序它想调用什么工具,你的程序负责真正执行。**
这一点理解以后Agent 就没那么神秘了。
---
### 多模态模型
什么是纯文本模型
什么是多模态模型
什么是文本输入
什么是图片输入
什么是音频输入
什么是视频输入
什么是模型的文本输出 / 图片输出 / 音频输出
不同模型分别支持哪些输入输出形式
---
# 再往后就是现代大模型应用最常碰到的东西
### Embedding
什么是 Embedding
为什么一句话可以变成一堆数字
为什么可以用这些数字判断两段文字语义上像不像
不需要先学背后的向量算法。
先知道:
```text
文本
Embedding
向量
计算相似度
找到语义相近的内容
```
就可以了。
---
### RAG
什么是 RAG
为什么大模型不知道你自己的私人文档
怎么先搜索相关资料,再把资料给模型
基本流程:
```text
用户提问
搜索相关内容
找到最相关的几段资料
把资料放进上下文
让模型根据资料回答
```
一定要理解:
**RAG 不是让模型“学会了”这些资料。**
只是每次问问题的时候,临时找出相关资料给模型看。
---
### 向量数据库
什么是 Vector Database
为什么普通数据库适合查:
```text
user_id = 123
```
但是向量搜索适合查:
```text
找和“猫为什么喜欢纸箱”意思最接近的文章
```
向量数据库和 SQLite / PostgreSQL 不是完全一种用途。
---
### Memory
什么是聊天记录
什么是上下文
什么是长期记忆
什么是用户 Profile
这几个一定不要混起来。
比如:
```text
数据库:
保存所有聊天记录
上下文:
这一次真正发送给模型看的内容
短期记忆:
最近聊天的重要信息
长期记忆:
以前聊天里面值得以后继续记住的信息
Profile
用户名、偏好、角色设定这些相对稳定的信息
```
模型自己并不会天然记得你几个月以前聊过什么。
所谓“大模型记忆”通常都是你的应用程序帮模型实现的。
---
### 上下文压缩
为什么不能永远把 5000 条历史消息全塞给模型
什么叫滑动窗口
什么叫摘要
什么叫对话压缩
哪些信息应该保留
哪些信息可以丢掉
为什么摘要本身也可能损失信息
什么是上下文缓存?
为什么发给api请求时之前的内容不能变
为什么这样能极大程度的省钱?
---
### Agent
什么是 Agent
最简单理解:
```text
用户给一个目标
模型思考下一步做什么
选择 Tool
执行 Tool
查看结果
决定下一步
继续
直到完成任务
```
先理解这个循环。
不要一开始就直接学 LangChain、LangGraph 这些框架。
最好先自己写一个最简单的 Tool Calling Loop知道 Agent 到底在干什么以后,再去用框架。
不然很容易变成会调框架,但是不知道里面发生了什么。
---
### 大模型应用安全
什么是 Prompt Injection
为什么用户可能故意给模型输入恶意指令
什么是 Indirect Prompt Injection
为什么模型从网页、文档、邮件里面读到的文字也不能完全信任
为什么:
```text
用户输入
网页内容
文件内容
Tool 返回内容
模型输出
```
全部都应该当成“不可信数据”
为什么 Tool 要限制权限
为什么不能给一个 Agent 一个“可以随便删数据库”的 Tool然后完全相信模型不会乱调用
为什么重要操作要有权限控制和二次确认
---
# 最后再慢慢接触“生产级代码”
什么叫 Demo
什么叫“我电脑上能跑”
什么叫生产环境
什么叫生产级代码
为什么代码不能永远全部写在一个文件里面
为什么要把:
```text
路由
数据库
模型调用
配置
工具
业务逻辑
前端
```
慢慢拆开
为什么配置和代码要分开
为什么 API Key 不能硬编码
为什么请求要有 timeout
为什么失败以后有时候要 retry
为什么 retry 也不能无限 retry
为什么要处理 rate limit
为什么需要日志
为什么需要测试
什么是单元测试
为什么修改一个功能以后,要确认没有把其他功能搞坏
什么是缓存
为什么有些模型结果或者数据库结果没必要重复计算
什么是 Docker
为什么“我电脑上能运行”不代表服务器也能运行
什么是部署
什么是域名
什么是服务器
什么是监控
程序上线以后怎么知道它挂了
怎么知道模型 API 报错了
怎么知道用户请求变慢了
怎么知道一天烧了多少钱的 Token
---
然后不要想着:
**“我要把这些东西全部学完以后,才能开始写项目。”**
不是这样的。
应该是一边写,一边遇到问题,一边学。
第一个项目可以直接写:
**一个支持多轮对话、对话记录存储、上下文自动压缩、角色人设自定义卡片的角色扮演聊天网页应用。**
第一版甚至可以特别垃圾:
```text
一个输入框
+
一个发送按钮
+
调用一次模型 API
```
然后一点一点加东西。
加多轮聊天
→ 学 message / role / context
加聊天记录保存
→ 学 SQLite
加角色卡
→ 学 system prompt
加逐字输出
→ 学 Streaming
聊天太长开始失忆
→ 学 token / context window / 上下文压缩
角色记住以前发生过的事情
→ 学 Memory
角色从几百篇设定资料里面找相关内容
→ 学 Embedding / RAG
角色可以查天气
→ 学 Tool Calling
角色可以自己决定查什么东西
→ 学 Agent
多人同时用
→ 学 async / 并发 / 竞态条件
模型偶尔请求失败
→ 学 timeout / retry / rate limit
想给朋友真正用
→ 学部署 / Docker / 日志 / 安全
所以整个学习过程最好不是:
```text
学完 Python
学完前端
学完后端
学完数据库
学完大模型
终于开始做项目
```
而是:
```text
学一点
写一点
遇到问题
再学一点
把问题解决
继续加功能
```
尤其现在有 AI 帮你写代码以后,**会不会背语法已经不是最重要的了。**
真正重要的是:
**你知不知道自己在做什么。**
**AI 给你的东西你看不看得懂大概结构。**
**出了问题你知不知道应该往哪查。**
**AI 提出的方案,你有没有能力判断它到底是在认真解决问题,还是在胡写。**

View File

@ -1,6 +1,6 @@
"""Gateway 第 1/2 步验收:独立任务生命周期 + 极简协议全链路G4/G13 """Gateway 第 1/2 步验收:独立任务生命周期 + 极简协议全链路G4/G13
验收标准来自 cache_research/gateway/gateway_current_state.md §7.2 验收标准来自 _experiments/cache_research/gateway/gateway_current_state.md §7.2本地归档未随仓库发布
- 进程全程不创建 Flask app不注册蓝图不初始化 SocketIO 服务 - 进程全程不创建 Flask app不注册蓝图不初始化 SocketIO 服务
- RuntimeService 真实受理create_task task_id任务线程真实启动 - RuntimeService 真实受理create_task task_id任务线程真实启动
- 资源装配真实执行get_user_resources RuntimeIdentity 驱动不重 mock - 资源装配真实执行get_user_resources RuntimeIdentity 驱动不重 mock

View File

@ -33,7 +33,7 @@ TOTAL_TOKEN_KEYS = (
"total_token_count", "total_token_count",
"totalTokenCount", "totalTokenCount",
) )
# 缓存命中 token 数的所有已知字段位置2026-08 调研,见 cache_research/SUMMARY.md # 缓存命中 token 数的所有已知字段位置2026-08 调研,见 _experiments/cache_research/SUMMARY.md,本地归档未随仓库发布
# - OpenAI 系/Qwen/GLM/MiniMax/xAI/Mistral/千帆/OpenRouter: usage.prompt_tokens_details.cached_tokens # - OpenAI 系/Qwen/GLM/MiniMax/xAI/Mistral/千帆/OpenRouter: usage.prompt_tokens_details.cached_tokens
# Responses API 为 usage.input_tokens_details.cached_tokens # Responses API 为 usage.input_tokens_details.cached_tokens
# - DeepSeek: usage.prompt_cache_hit_tokens顶层 # - DeepSeek: usage.prompt_cache_hit_tokens顶层

View File

@ -1,168 +0,0 @@
# Windows 沙箱执行不可信命令:开源组件/项目调研报告
> 调研日期2026-07以实际检索为准目标场景Python 智能体运行时在 Windows 上执行不可信命令(一次性命令 + 交互式 shell
> 需求速记:**R1** 文件只读模式(工作区以外只读)、**R2** 工作区可写、**R3** 敏感路径禁读、**R4** 网络三档(禁网/仅回环/全开)、**R5** Python asyncio subprocess 可集成。
> 所有结论均来自本轮实际检索,检索不到的已明确标注。
---
## 0. 结论速览(先看这个)
| 方案 | 一句话结论 |
|---|---|
| **OpenAI Codex 的 windows-sandbox-rs** | 需求匹配度最高的"参考答案"restricted token + ACL + WFP 防火墙五条需求全覆盖Apache-2.0,但是 Rust 代码Python 侧需要借鉴重写或把 codex CLI 当子进程调用 |
| **GRR `unprivileged/windows/sandbox_lib.py`** | 唯一一个**纯 Pythonpywin32+ctypes的 AppContainer 沙箱实现**Apache-2.0,核心代码量小,可直接 vendor但无网络控制、写授权模型需补齐 |
| **Sandboxie-Plus** | 隔离粒度最细(文件/注册表/网络逐规则配置命令行可驱动GPL-3.0 且依赖内核驱动,**不能嵌入闭源产品**,只能作为"用户自行安装的外部依赖"调用 |
| **Windows Sandbox.wsb** | 微软官方一次性 VM映射文件夹只读/可写、可禁网,但**无 stdout/stderr 捕获管道、无编程 API**,不适合 agent 自动化 |
| **Windows Containerscontainerd/runhcs** | 不用 Docker 可以跑,但镜像 260MB~5GB+、process isolation 要求镜像与宿主 build 严格匹配,运维成本高,不适合桌面 agent 场景 |
| **Dokan/WinFsp 只读视图** | 只解决 R1只读视图不提供进程隔离与网络控制需叠加 token/ACL属于"可选增强"而非独立方案 |
| **winjail / Firejail Windows 版** | **查不到**,不存在可用品(详见第 3 节) |
**推荐路线**:以 Codex 的 Windows 沙箱设计restricted token + 合成 SID + ACL 授权 + WFP 网络过滤)为蓝本,用 pywin32/ctypes 在 Python 内实现GRR 代码可作起点);把 Sandboxie 作为可选的外部加固后端。
---
## 1. Python 可用的 Windows 沙箱库
### 1.1 GRR `grr_response_client.unprivileged.windows`(本轮最大发现)
- 链接https://github.com/google/grr (模块路径 `grr/client/grr_response_client/unprivileged/windows/sandbox_lib.py`
- 维护状态google/grr 仓库 5084 starApache-2.0,最近提交 2026-05活跃
- 这是什么:一个**纯 Python 的 Windows AppContainer 沙箱库**docstring 原文:"Windows Sandboxing library based on AppContainers… works only on Windows >= 8"。用 ctypes 调 `userenv.CreateAppContainerProfile` / `DeriveAppContainerSidFromAppContainerName`,用 pywin32`win32security`/`win32service`/`ntsecuritycon`)做 ACL 授权,并创建独立的 window station/desktop。
- 能力:`InitSandbox(name, paths_read_only)` 创建 AppContainer 并对指定路径授予 `GENERIC_READ|GENERIC_EXECUTE``Sandbox` 类管理 window station/desktop 生命周期;同目录 `process.py`9KB负责在沙箱里拉起进程。PyInstaller issue #8289 中有真实用户用它把 exe 跑进 AppContainer 的实例。
- 需求覆盖R1 ✅未授权路径默认拒读写、R3 ✅不授权即禁读、R5 ✅(纯 Python可与 asyncio 集成R2 ⚠️(示例只演示只读授权,可写授权需要同样用 ACL 加 `GENERIC_WRITE`代码路径现成R4 ❌(无网络控制,需自行叠加 WFP
- 注意:它不是独立 PyPI 包,需要 vendor 该文件Apache-2.0 允许),依赖 pywin32。
### 1.2 pywin32 本身
- 链接https://github.com/mhammond/pywin32
- 已核实源码包含 `win32job.i`Job ObjectCreateJobObject/SetInformationJobObject/AssignProcessToJobObject、`win32security.i`restricted token、ACL、`win32process.i`。即"restricted token + Job Object + ACL"三件套在 Python 里**不需要写 C 扩展**就能拼出来——这是自研路线的地基。
- 许可PSF 风格,闭源友好。
### 1.3 PyPI 上的候选包(均已核实)
| 包名 | 状态 |
|---|---|
| `sandbox` 0.3.5 | 存在,但简介为 "The Sandbox Libraries (Python)"——是早年 Linux seccomp-nurse 的绑定,**与 Windows 无关** |
| `pyjail` 0.1.4 | 存在MIT是**进程内** Python 代码沙箱(非 OS 级),不满足需求 |
| `winsandbox` / `appcontainer` / `winjail` / `seccomp` | **404不存在** |
结论PyPI 上**没有**成熟的、封装 AppContainer/Job Object/restricted token 的 Windows 沙箱包。成熟度最高的可直接借鉴物就是 GRR 的那个模块。
---
## 2. Sandboxie / Sandboxie-Plus
- 链接https://github.com/sandboxie-plus/Sandboxie 19034 star**GPL-3.0**2026-07-29 仍有提交,非常活跃;社区 fork维护者 David Xanatos项目历史 Ronen Tzur → Invincea → Sophos → 2020 开源)
- 命令行驱动:✅ 有官方文档https://sandboxie-plus.github.io/sandboxie-docs/Content/StartCommandLine.html
- `"C:\Program Files\Sandboxie\Start.exe" /box:MyBox /wait /hide_window cmd.exe /c xxx`——`/wait` 会等进程结束并**透传退出码**
- `/terminate` / `/terminate_all` 可清空沙箱内进程。
- **stdout 捕获有坑**:文档明确 "Start.exe is a Win32 application and not a console application",即它**不转发子进程 stdio**。可行做法是在沙箱内重定向:`Start.exe /box:X /wait cmd /c "cmdline > C:\boxpath\out.txt 2>&1"`,然后从宿主侧读沙箱文件系统根目录下的输出文件。交互式 shell 同理需要走管道桥接,做不到 `asyncio.create_subprocess_exec` 那种原生 PIPE 体验。
- 隔离粒度(文档核实):
- 文件:`ClosedFilePath`**禁读禁写**,官方文档明确 "deny all access by sandboxed programs, including read")、`ReadFilePath`(可读、写入被虚拟化)、`OpenFilePath`直通直写默认所有写都落在沙箱虚拟层copy-on-write不污染宿主。
- 注册表:`ClosedKeyPath` 等同类规则。
- 网络Plus 版内置**基于 WFP 的每沙箱防火墙**`NetworkEnableWFP=y` + `NetworkAccess=<program>,Block/Allow;Protocol=...`。Issue #5109https://github.com/sandboxie-plus/Sandboxie/issues/5109 )正是"禁外网但放行本地代理(回环)"的配置讨论——三档网络均可表达。
- 许可与嵌入GPL-3.0 + 需要安装**签名内核驱动 + 系统服务**SbieDrv/SbieSvc。结论**不能以库形式嵌入闭源产品**GPL 传染性 + 驱动安装要求)。合法用法是检测用户机器上是否已装 Sandboxie有则调用没有则提示安装——属于外部可选依赖。
- 其他 agent 项目使用情况:没有找到把 Sandboxie 作为嵌入式沙箱后端的知名 agent 项目;但有用户自发把 Cursor 整个 IDE 跑进 Sandboxie 的实践Discussion #5077https://github.com/sandboxie-plus/Sandboxie/discussions/5077 )。
- 需求覆盖R1 ✅ReadFilePath+虚拟化、R2 ✅OpenFilePath 或沙箱内写+回收、R3 ✅ClosedFilePath、R4 ✅WFP 三档可配、R5 ⚠️(一次性命令可行但 stdout 要走文件;交互式 shell 需自建桥接)。
---
## 3. nsjail / minijail / bwrap / Firejail 的 Windows 等价物
- **winjail**GitHub 全库搜索API 实测)只命中一个不相关的玩笑项目 `netcrawlerr/WinJail`2 starWinForms 弹窗玩具)。**作为沙箱项目的 winjail 查不到,不存在。**PyPI 同名也 404。
- **Firejail**https://github.com/netblue30/firejail 明确是 "Linux namespaces and seccomp-bpf sandbox"**没有 Windows 移植**issue 区讨论也确认无 Windows 支持。
- **nsjail**https://github.com/google/nsjail ):基于 Linux namespaces/cgroups/seccomp-bpfLinux 专用。
- **minijail**ChromiumOS 项目,同样 Linux 专用。
- Windows 生态里事实上的"等价物"是三样东西的组合:**AppContainer能力沙箱+ restricted token/完整性级别MIC+ Job Object资源限制**。Chromium 的 Windows sandbox 是这套组合最著名的工程实现2026 年 GSoC 有个面向 Gemini CLI 的 AppContainer PoChttps://github.com/AnushkaPandit-21/appcontainer-node-sandbox ,学生 PoCC++/Node-API 实现,验证了 `CreateAppContainerProfile`+ACL+`PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES` 全链路),可参考但成熟度低。
---
## 4. 容器路线Windows Containers不装 Docker
- 官方文档https://learn.microsoft.com/en-us/virtualization/windowscontainers/manage-containers/hyperv-container ——process isolation 与 Hyper-V isolation 两种。
- **不装 Docker 能不能跑**能。Windows 10 1809+ / Server 2019+ 内置 HCSHost Compute Service可用 **containerd + runhcs** 直接跑 OCI 容器TechTarget 综述、James Sturtevant 的实操博客、docker/roadmap#151 均有 walkthrough。hcsshimGo 库)是底层 API 封装。
- 坑(做命令沙箱的硬伤):
1. **process isolation 要求镜像与宿主内核 build 严格匹配**https://jfreeman.dev/blog/2021/09/01/how-to-run-a-windows-container-from-a-windows-10-virtual-machine/ ),用户机器 build 号千奇百怪,镜像要么全量预置要么 fallback 到 Hyper-V isolation需要启用 Hyper-V/虚拟机平台,专业版以上)。
2. **镜像体积**Nano Server ~260290MB、Server Core 解压后 ~4.86.9GBltsc2022 5.16GB、ltsc2025 6.86GBhttps://github.com/microsoft/Windows-Containers/issues/584 )。首次拉取 GB 级,冷启动数秒到十数秒。
3. Python 集成只能走 `ctr`/`runhcs` CLI 子进程或自研 hcsshim 绑定。
- 需求覆盖R1/R2/R3 ✅volume mount 可 read-only、R4 ⚠️(`--network none` 可禁网,"仅回环"需自建 HNS 网络策略、R5 ⚠️(经 CLI 间接集成)。**结论:技术上可行,但对"桌面 agent 跑一条命令"的场景过重,不推荐。**
---
## 5. 特殊文件系统过滤Dokan / WinFsp 只读视图
- WinFsphttps://github.com/winfsp/winfsp 8774 star活跃许可为 **GPLv3 + 商业双许可**GitHub API 返回 NOASSERTION 即自定义/双许可)
- Dokanyhttps://github.com/dokan-dev/dokany (仓库内含 `license.lgpl.txt``license.mit.txt`,用户态库 LGPL、部分组件 MIT
- 可行性:两者都能在用户态实现一个"把宿主目录映射成只读视图"的虚拟盘WinFsp 官方 passthrough 教程就是现成起点;只读语义由 FS 实现对写操作返回拒绝即可cgofuse issue #15 讨论了 Windows 上 `-o ro` 的实现方式)。
- **但作为沙箱手段有关键缺陷**:只读视图管不住"进程直接打开原始路径"——沙箱内进程只要还有权限访问原路径就能绕过挂载点。所以它**不能替代 token/ACL 级隔离**,只能做:把敏感路径"藏起来不给视图"+ 配合 AppContainer/restricted token 收回原路径访问权。此外还需要安装内核驱动(分发负担 + WinFsp 的 GPL/商业许可成本)。
- 需求覆盖R1 ✅仅视图层面、R2 ⚠️(叠加 overlay 可实现写重定向、R3 ⚠️(需配合 token、R4 ❌、R5 ❌(与进程管理无关)。
- **结论:可选增强项,不是独立方案。**若最终走 ACL 路线,本项可以跳过。
---
## 6. 微软官方 20232025 的新东西
1. **Win32 App Isolation**Build 2023 宣布、2024 持续推):
- Windows Blog 2023-06 公测公告https://blogs.windows.com/windowsdeveloper/2023/06/14/public-preview-improve-win32-app-security-via-app-isolation/ "built on AppContainers";微软还专门发文 Sandboxing Python with Win32 App Isolationhttps://blogs.windows.com/windowsdeveloper/2024/03/06/sandboxing-python-with-win32-app-isolation/ 2024-03
- 要点:面向 **MSIX 打包应用**的隔离(能力声明式授权、隐私式提示),不是一个"给任意命令行进程套沙箱"的 API。对 agent 运行时场景:理念可借鉴,接口形态不匹配。
2. **Windows Sandbox消费级功能+ .wsb 配置**
- MS Learn 配置文档https://learn.microsoft.com/en-us/windows/security/application-security/application-isolation/windows-sandbox/windows-sandbox-configure-using-wsb-file ,本轮全文核实):`<MappedFolders>` 支持 `ReadOnly=true/false`、`<Networking>Enable/Disable`、`<LogonCommand>` 开机执行一条命令、内存上限。Win10 18342+/Win11**仅 Pro/Enterprise/EducationHome 不可用**。
- 致命短板:**没有任何 stdout/stderr/退出码回传机制**,是 RDP 交互桌面;启动是完整 VM秒级~十秒级);无编程 API。适合人工试毒不适合 agent 自动化管道。
3. **Agent WorkspaceWindows 112025-10/11 起面向 Insider 推出)**
- 来源https://4sysops.com/archives/what-is-agent-workspace-in-windows-11/ 、https://support.microsoft.com/en-us/windows/ai/ai-features/experimental-agentic-features 、https://techcommunity.microsoft.com/blog/windows-itpro-blog/evolving-windows-new-copilot-and-ai-experiences-at-ignite-2025/4469466 。
- 要点:给 AI agent 一个**独立的 Windows 会话**——每个 agent 有自己的账户、桌面,"runtime isolation and scoped authorization",策略可控、可审计。这是微软官方对"agent 沙箱"的回答,方向上与 Codex 的"专用本地账户"思路一致。目前为实验性、Insider 通道,**尚无公开的第三方可编程 API**,属于"盯着看"项。
- 旁证Codex 评估过 Windows Sandbox 和 MIC 后放弃(见第 7 节),说明微软现有官方原语确实拼不出开箱即用的 agent 沙箱。
---
## 7. Codex 的 Windows 实现openai/codex 仓库核实)
- 仓库https://github.com/openai/codex 102379 star**Apache-2.0**,极活跃)
- 源码位置:`codex-rs/windows-sandbox-rs/`,本轮用 GitHub API 核实了完整文件清单,关键模块:`acl.rs`(27KB)、`token.rs`(18KB)、`cap.rs`、`proc_thread_attr.rs`、`process.rs`、`desktop.rs`、`identity.rs`、`elevated/elevated_impl.rs`、`wfp.rs`(16KB)+`wfp_setup.rs`、`deny_read_*.rs`、`setup.rs`(77KB)、`conpty/`、`unified_exec/`,外加 `sandbox_smoketests.py`Python 冒烟测试)。
- 架构InfoQ 2026-06 报道 https://www.infoq.com/news/2026/06/codex-windows-sandbox-design/ + 仓库 issue/讨论交叉验证):
- **评估后放弃** Windows Sandbox要直接访问开发者工作区、且非全 SKU 可用)与单纯 MIC。
- **unelevated 沙箱**(第一版):当前用户上下文下用 **restricted token + 合成 SID "sandbox-write"**,只对工作区和显式配置的目录授写权限;`.git` 等敏感元数据用 ACL 拒读(`deny_read_*` 系列文件就是干这个的)。
- **elevated 沙箱**(现行版):安装时创建**专用本地账户**CodexSandboxOffline / CodexSandboxOnline命令以这些账户 + restricted token 运行(`CreateProcessWithLogonW`**网络用 WFP 防火墙规则**控制——offline 账户禁网、online 账户放网,正好对应"禁网/全开";配置项 `[windows] sandbox = "elevated"|"unelevated"`issue #25566、#33073 等可佐证)。
- `conpty/``unified_exec/` 目录的存在说明他们 ship 了**交互式 PTY 会话**能力(对应"交互式 shell"需求)。
- 工程教训(从 issue 区看到的真实代价setup helper 需要条件 ACE 规避 UACissue #26087 的 740 错误)、配置文件损坏导致 `CreateProcessWithLogonW: 1168`#33073、helper 进程泄漏(#32194。说明这条路**能走通但边角多**,自研需要预留测试预算。
- 需求覆盖:**R1 ✅ R2 ✅ R3 ✅ R4 ✅WFP 三档,仅回环可用 WFP 规则表达R5 ⚠️**Rust crate不是 Python 库Python 集成路径有两条:① 把 `codex sandbox -- <cmd>` 当外部子进程调用——最省事但引入重依赖;② 以它为蓝本用 pywin32/ctypes 重写核心——token+ACL 部分 GRR 已给出 Python 模板WFP 部分(`fwpuclnt.dll`)需自行绑定,工作量集中在网络档)。
---
## 8. 需求 × 方案 覆盖矩阵
| 方案 | R1 只读 | R2 工作区可写 | R3 敏感路径禁读 | R4 网络三档 | R5 asyncio 集成 | 许可 | 嵌入闭源产品 |
|---|---|---|---|---|---|---|---|
| 自研Codex 蓝本 + pywin32/ctypes | ✅ ACL | ✅ 授权写 | ✅ deny ACL | ✅ WFP | ✅ 原生 | 自研 | ✅ |
| GRR sandbox_libvendor | ✅ | ⚠️ 需补写授权 | ✅ | ❌ 需补 WFP | ✅ | Apache-2.0 | ✅ |
| Sandboxie-Plus外部调用 | ✅ | ✅ | ✅ | ✅ WFP | ⚠️ stdout 走文件 | GPL-3.0 + 驱动 | ❌ 仅外部依赖 |
| Windows Sandbox (.wsb) | ✅ | ✅ | ⚠️ 只映射该映射的 | ⚠️ 只有开/关 | ❌ 无 stdio | OS 内置 | — |
| Windows Containers (runhcs) | ✅ | ✅ | ✅ | ⚠️ 回环档麻烦 | ⚠️ CLI 间接 | Apache-2.0 组件 | ⚠️ 运维重 |
| Dokan/WinFsp 只读视图 | ✅ 仅视图 | ⚠️ overlay | ⚠️ 需配合 token | ❌ | ❌ | GPL/商业 或 LGPL/MIT | ⚠️ |
| PyPI 现成包 | — | — | — | — | — | — | **不存在** |
## 9. 落地建议(给运行时的技术选型)
1. **主路线(自研,强烈推荐)**:以 Codex windows-sandbox-rs 为设计蓝本、以 GRR sandbox_lib.py 为 Python 代码起点:
- 文件隔离AppContainer 或 restricted token + 合成 SID工作区授 `GENERIC_ALL`,其余默认拒写;敏感路径(`~/.ssh`、`~/.aws`、浏览器凭据目录等)显式 deny-read ACE
- 网络三档ctypes 绑 `fwpuclnt.dll` 加 WFP 过滤器(禁网=block all、仅回环=allow 127.0.0.1/::1、全开=不加规则Codex 的 wfp.rs 可作逻辑参照;
- 进程管理Job Objectpywin32 `win32job` 现成)做内存/进程数限制与树杀;
- asyncio 集成:`asyncio.create_subprocess_exec` 无法直接带 token 启动,需要先用同步 API CreateProcess* 再包装句柄,或封装成 `loop.run_in_executor` + 管道线程;交互 shell 参考 Codex 的 ConPTY 方案Python 侧有 `pywinpty` 可用)。
2. **可选加固后端**:检测用户已安装 Sandboxie 时提供"在 Sandboxie 中运行"选项GPL 不构成对闭源主程序的传染,因为是外部进程调用而非链接)。
3. **不要选**Windows Containers、Windows Sandbox无 stdio、WinFsp/Dokan 单用(不隔离进程)、等待 winjail 类项目(不存在)。
4. **值得关注**Windows 11 Agent Workspace 的 API 开放情况——一旦微软开放第三方接入,可能成为官方托管方案。
---
## 附:主要信息来源
- OpenAI Codex 仓库https://github.com/openai/codex InfoQ 报道https://www.infoq.com/news/2026/06/codex-windows-sandbox-design/
- GRRhttps://github.com/google/grr sandbox_lib.py 源码https://raw.githubusercontent.com/google/grr/master/grr/client/grr_response_client/unprivileged/windows/sandbox_lib.py
- Sandboxiehttps://github.com/sandboxie-plus/Sandboxie 命令行文档https://sandboxie-plus.github.io/sandboxie-docs/Content/StartCommandLine.html ClosedFilePathhttps://sandboxie-plus.github.io/sandboxie-docs/Content/ClosedFilePath.html WFP 网络配置 issuehttps://github.com/sandboxie-plus/Sandboxie/issues/5109
- Windows Sandbox 配置https://learn.microsoft.com/en-us/windows/security/application-security/application-isolation/windows-sandbox/windows-sandbox-configure-using-wsb-file
- Windows Containershttps://learn.microsoft.com/en-us/virtualization/windowscontainers/manage-containers/hyperv-container 镜像尺寸https://github.com/microsoft/Windows-Containers/issues/584
- Win32 App Isolationhttps://blogs.windows.com/windowsdeveloper/2023/06/14/public-preview-improve-win32-app-security-via-app-isolation/ Python 沙箱化https://blogs.windows.com/windowsdeveloper/2024/03/06/sandboxing-python-with-win32-app-isolation/
- Agent Workspacehttps://4sysops.com/archives/what-is-agent-workspace-in-windows-11/ https://techcommunity.microsoft.com/blog/windows-itpro-blog/evolving-windows-new-copilot-and-ai-experiences-at-ignite-2025/4469466
- WinFsphttps://github.com/winfsp/winfsp Dokanyhttps://github.com/dokan-dev/dokany
- GSoC AppContainer PoChttps://github.com/AnushkaPandit-21/appcontainer-node-sandbox

View File

@ -1,90 +0,0 @@
# WSL2 + bwrap 沙箱概念验证PoC实验报告
> 日期2026-07-30 · 实验机Windows 11 (26200) · WSL2 内核 5.15.167.4
> 结论先行:**路线可行,隔离强度达到调研预期,可以进入源码改造。**
> 前提条件:必须使用专用发行版并关闭 Windows 互操作interop否则存在沙箱逃逸见 E8
## 1. 实验环境搭建(已验证的部署流程)
```cmd
:: 1. 下载 Alpine minirootfs~3.5MB
curl -L -o alpine-minirootfs.tar.gz https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/x86_64/alpine-minirootfs-3.21.3-x86_64.tar.gz
:: 2. 导入为专用沙箱发行版(非交互,可编程)
wsl --import astrion-sandbox <安装目录> alpine-minirootfs.tar.gz --version 2
:: 3. 发行版内配置(详见 §3 的发现)
:: - /etc/wsl.conf: [interop] enabled=false / appendWindowsPath=false[network] generateResolvConf=false
:: - /etc/resolv.conf: 固定公共 DNS223.5.5.5 / 119.29.29.29
:: - /etc/apk/repositories: 换国内镜像mirrors.aliyun.com
:: - apk add bubblewrap bash ncurses-libs python3 git
```
## 2. 实验结果总表24 项23 PASS / 1 INFO
沙箱命令模板(与项目现有 `_build_linux_common_plan` 同构):
```
只读模式: bwrap --die-with-parent --new-session --unshare-all --share-net \
--ro-bind / / --ro-bind <ws> <ws> --chdir <ws> \
--proc /proc --dev /dev --tmpfs /tmp -- bash -lc <cmd>
工作区可写: 同上,工作区改为 --bind <ws> <ws>
网络全禁/仅回环: 去掉 --share-net--unshare-all 自带的 unshare-netlo 自动可用)
```
| 分组 | 实验 | 结果 | 说明 |
|---|---|---|---|
| E0 基础 | 发行版执行 / bwrap 0.11 / user namespace | ✅ | WSL2 满足 bwrap 全部前提 |
| E0 路径 | `E:\...``/mnt/e/...` 转换 + 工作目录 | ✅ | 机械转换即可,含空格路径正常 |
| E0 编码 | `WSL_UTF8=1` 解决 wsl.exe UTF-16 乱码 | ✅ | 必须设置 |
| E0 输出 | localhost 代理警告仅在 stderr | ✅ | 可在执行器层过滤 |
| E1 只读 | 工作区内写入 → `Read-only file system` | ✅ | 报错关键词与 mac 语义一致,审批关键词匹配可直接复用 |
| E1 只读 | 系统/工作区读取正常 | ✅ | |
| E1 只读 | 写 Linux 系统目录被拒 | ✅ | |
| E1 只读 | **写工作区外 Windows 盘9P 子挂载)被拒** | ✅ | 关键项ro-bind / 对 9P 挂载同样生效 |
| E2 可写 | 工作区内写入真实落盘 Windows | ✅ | |
| E2 可写 | 工作区外 Windows 盘写入被拒 | ✅ | |
| E2 可写 | Linux 系统目录写入被拒tmpfs /tmp 可写 | ✅ | 对齐 macOS /tmp 语义 |
| E3 禁读 | 未掩蔽时敏感文件可读(风险确认) | ✅ | 前置确认 |
| E3 禁读 | `--ro-bind /dev/null <file>` 掩蔽单文件 | ✅ | |
| E3 禁读 | `--tmpfs <dir>` 掩蔽整目录 | ✅ | 可实现 macOS deny-read 等价 |
| E4 网络 | share-net 外网可达 | ✅ | |
| E4 网络 | unshare-net 外网不可达 | ✅ | |
| E4 网络 | unshare-net 下 lo 可用、127.0.0.1 实际通信 | ✅ | 等价 macOS restricted仅回环语义 |
| E5 综合 | 只读模式 `rm -rf` 工作区被拦截 | ✅ | |
| E5 综合 | 可写沙箱 `rm -rf /mnt/c/Windows/...` 全部 Read-only | ✅ | |
| E6 交互 | bwrap 内 `bash -i` stdio 逐条往返 | ✅ | 持久终端方案成立 |
| E7 性能 | 单次 wsl.exe 调用 ~950-1000ms | | 后续需长驻进程复用优化 |
| E8 工具链 | git init/add/commit 全流程、python3 写文件 | ✅ | 工作区内正常,工作区外 OSError 拒绝 |
## 3. 关键发现(实验中新暴露、调研未覆盖的)
### E8-逃逸(最严重):必须关闭 Windows interop
- **未关闭时**:只读 bwrap 沙箱内执行 `cmd.exe /c echo pwned > E:\...\file.txt` **成功写入 Windows 盘**。interop 进程由 WSL 会话 init 在命名空间外拉起bwrap 完全管不到 → 沙箱形同虚设。
- **封堵方式**(已验证):专用发行版 `/etc/wsl.conf``[interop] enabled=false` + `appendWindowsPath=false`,重启发行版后 `cmd.exe: command not found`,逃逸封死。
- 结论:沙箱发行版必须是**专用、受控配置**的,绝不能复用用户自己的 Ubuntu 等发行版。Claude Code 的 WSL 沙箱同样禁 interop属行业共识做法。
### 网络环境适配(本机实测暴露)
- 宿主使用 localhost 代理时WSL NAT 模式的 DNS 转发10.255.255.254 → 宿主解析器)**会失效**(时好时坏);直连公共 DNS223.5.5.5)正常 → 发行版需固化 `generateResolvConf=false` + 公共 DNS。
- apk 官方源大包下载会卡死,换阿里云镜像后秒装 → 发行版应内置国内镜像(或做成可配置)。
- wsl.exe 每次启动都在 stderr 打 localhost 代理警告 → 执行器需过滤 stderr 该行(或在 `.wslconfig` 全局设 `autoProxy=false`,影响面大,不推荐默认做)。
### 工程细节
- Git Bash 调用 wsl.exe 会被 MSYS2 路径转换毁掉 Unix 参数(`/etc/...` → `E:/KimiData/...`)→ 仅影响开发调试(设 `MSYS2_ARG_CONV_EXCL='*'`Python subprocess 不受影响。
- `bash` 需显式安装Alpine 默认 busybox sh且需 `ncurses-libs` 才能跑。
## 4. 与 macOS 沙箱的能力对照(最终形态)
| 能力 | macOS现状 | Windows WSL2+bwrapPoC 验证后) |
|---|---|---|
| 只读模式 | sandbox-exec deny-write | bwrap 全 ro-bind ✅ |
| 工作区可写 | profile writable paths | ro-bind / + bind ws ✅ |
| 敏感路径禁读 | deny subpath / regex | tmpfs/dev-null 掩蔽(无 regex需逐个路径 |
| 网络三档 | profile 网络规则 | share-net / unshare-netrestricted≈仅回环✅ |
| 审批链语境 | Unix 报错关键词 | 完全一致Read-only file system / Permission denied✅ |
| 只读命令白名单 | Unix 命令集 | 完全一致(同为 Linux bash✅ |
| 交互终端 | sandbox-exec bash -i | bwrap bash -i ✅ |
遗留限制(二期):单次调用 ~950ms 开销需长驻 WSL 进程复用;禁读不支持 regex可改为启动前枚举 .env 类文件逐个掩蔽seccomp 过滤器暂未接入bwrap 本身已满足 FS/网络隔离需求)。
## 5. 原始实验日志
完整 24 项原始输出见 `.wsl-poc/results.md`(含 E5.2 逐文件 Read-only 报错洪流,可佐证 9P 挂载保护的真实性)。实验脚本:`.wsl-poc/run_experiments.sh`。

View File

@ -1,169 +0,0 @@
# WSL2 等价强度沙箱可行性调研报告
调研日期2026本轮。结论先行**WSL2 可以做出与 macOS sandbox-exec 等价强度的沙箱**,核心路径是「专用 WSL2 发行版 + 发行版内 user/mount/network namespaceunshare 或 bubblewrap」。因为 WSL2 跑的是真实 Linux 内核(如 6.6.x-microsoft-standard-WSL2Linux 侧的方案unshare / bwrap基本可以完全复用最大的工程成本在 Windows 侧的发行版供给、路径转换和 wsl.exe 的编码/开销问题。
---
## 1. 发行版供给:可行(编程式部署专用沙箱发行版)
### 结论
**可行**。不要依赖用户已有的发行版,而是用 `wsl --import` 导入一个官方 rootfs tar创建专用 distro`astrion-sandbox`)。这是官方支持的、完全非交互的路径。
### 具体做法
**rootfs 官方来源:**
- Ubuntu WSL 专用 rootfs`https://cloud-images.ubuntu.com/wsl/<release>/current/*-wsl.rootfs.tar.gz`。Ubuntu 官方明确提供 tar-based WSL 发行格式用于「distribute, install, and manage Ubuntu WSL instances」[Ubuntu 官方博客](https://ubuntu.com/blog/ubuntu-wsl-new-format-available))。社区实践即用 `wsl --import` 导入这些 rootfs[buildroot GettingStarted](https://github.com/TiMaMi-GmbH/buildroot-external-timami/blob/main/GettingStarted.md)、[Zenn 教程](https://zenn.dev/dozo/articles/a633794f6d7575))。
- Alpine minirootfs更轻量~3MB`https://dl-cdn.alpinelinux.org/alpine/v3.xx/releases/x86_64/alpine-minirootfs-*-x86_64.tar.gz`,官方下载页提供 MINI ROOTFS社区有完整 WSL2 导入实践([tonym.us: Porting Alpine Linux RootFS to WSL2](https://tonym.us/porting-alpine-linux-rootfs-wsl2.html)、[PowerShell-Wsl-Alpine](https://github.com/antoinemartin/PowerShell-Wsl-Alpine)、[nathanchance: 从 LXC 镜像创建 WSL2 发行版](https://nathanchance.dev/posts/wsl2-distros-from-lxc-images/))。
- 许可Ubuntu/Alpine 均为自由软件rootfs tarball 由官方公开发布供再分发与导入,无许可障碍。
**导入命令序列(官方文档,[Microsoft Learn — Basic commands for WSL](https://learn.microsoft.com/en-us/windows/wsl/basic-commands)**
```powershell
wsl --import astrion-sandbox C:\Path\To\InstallDir ubuntu-wsl.rootfs.tar.gz --version 2
```
`--import` 完全非交互,导入的 distro 默认 root 登录无首启用户创建向导Store 版 distro 才有 OOBE。注意import 的 distro 没有 launcher exe改默认用户要靠发行版内 `/etc/wsl.conf``[user] default=`
**`wsl --install` 的交互问题:** Store 渠道安装的 distro 首启会进入交互式用户创建,且 `--install` 本身支持 `--no-launch` / `--no-distribution` 选项规避部分交互([Microsoft Learn](https://learn.microsoft.com/en-us/windows/wsl/basic-commands),另见 [WSL issue #10386](https://github.com/microsoft/WSL/issues/10386) 讨论 `--no-launch` 后自行脚本化配置的做法)。但对沙箱场景,**推荐完全绕开 `--install`,用 `--import`**:可控、可重复、无 OOBE、与用户的其他发行版零耦合。
**检测并排除 docker-desktop 等不可用 distro**
- 已安装发行版记录在注册表 `HKCU\SOFTWARE\Microsoft\Windows\CurrentVersion\Lxss`(每个 GUID 子键含 `DistributionName`、`BasePath`、`Version` 等)([Learning in the Open: Lxss 注册表结构](https://learningintheopen.org/tag/hkcusoftwaremicrosoftwindowscurrentversionlxss/))。
- `docker-desktop` / `docker-desktop-data` 会出现在 `wsl -l -v` 里,但它们是 Docker Desktop 的内部基础设施 distro[Super User 讨论](https://superuser.com/questions/1729811/why-are-these-multiple-wsl-distributions-on-windows))。
- 程序化排除策略(建议多层):① 名字黑名单(`docker-desktop*`);② 试运行探测 `wsl -d <name> -e true`,失败即排除;③ 最稳妥:根本不枚举用户 distro只认自己创建的 `astrion-sandbox`(先 `wsl -l -q` 检查是否已存在,存在则直接用或校验后重建)。注意 `wsl -l` 的输出是 UTF-16见第 7 节)。
---
## 2. Windows 路径访问与 drvfs 语义:可行,但要注意 9P 元数据语义
### 结论
**可行**。`E:\path` → `/mnt/e/path` 是机械转换(盘符小写、反斜杠转正斜杠)。权限语义取决于 drvfs 挂载选项WSL2 下 Windows 盘实际以 9p 协议挂载。
### 要点
- WSL2 中 Windows 盘的 mount 输出形如:`C:\ on /mnt/c type 9p (rw,...,aname=drvfs;path=C:;...)`[WSL issue #4774](https://github.com/microsoft/WSL/issues/4774)——WSL1 是 drvfs 内核驱动WSL2 是 9P over virtio语义基本对齐。
- `/etc/wsl.conf``[automount]` 可配 `options = "metadata,uid=...,gid=...,umask=...,fmask=...,dmask=...,case=off|dir|force"`[Microsoft Learn — Advanced settings configuration in WSL](https://learn.microsoft.com/en-us/windows/wsl/wsl-config))。
- **关键坑:`metadata` 默认关闭**。未开 metadata 时9p/drvfs 上 `chmod` 不生效、权限由 umask/fmask 统一伪造;开 metadata 后 chmod 结果会持久化到 NTFS 扩展属性([Super User](https://superuser.com/questions/1323645/unable-to-change-file-permissions-on-ubuntu-bash-for-windows-10)、[akr.am](https://akr.am/blog/posts/fix-wsl-file-system-behavior))。因此**不要用 chmod 作为 /mnt 下文件的只读控制手段**——权限语义不可靠。只读控制应走 mount 层(见第 3 节的 ro bind mount那是 VFS 层强制,与底层 9p 无关。
- 另一坑9p 不支持 inotify 跨 Windows 侧变更Rolldown 文档明确说明 [Windows 侧修改文件 WSL2 内 watch 收不到事件](https://rolldown.rs/reference/inputoptions.watch)),与本任务关系不大但值得知道。
- 反向保护:如果希望沙箱内根本看不到其他 Windows 盘,可以在专用发行版的 `/etc/wsl.conf` 里设 `[automount] enabled=false`再按需只挂载工作区所在盘fstab 里也支持 ro 挂载([tonym.us: Improve WSL Security with Read-Only Filesystem](https://tonym.us/improve-wsl-security-with-read-only-filesystem.html))。
---
## 3. 文件只读与写范围mount namespace / bubblewrap可行
### 结论
**可行,且是整套方案的核心**。WSL2 是真实 Linux 内核user/mount/network namespace 均可用;`unshare --mount` + ro bind 链条成立;**bubblewrap 可以直接装进 WSL2 使用**Linux 方案可完全复用。
### 证据
- OpenAI Codex CLI 的 Linux 沙箱就是 bubblewrap其在 WSL2 上实际运行([codex issue #22469](https://github.com/openai/codex/issues/22469):环境明确为 `WSL2 (kernel 6.6.114.1-microsoft-standard-WSL2)`,问题只是 bwrap 的 `--dev /dev` 没暴露 `/dev/dxg` GPU 节点——说明 bwrap 沙箱本体在 WSL2 工作正常)。
- WSL2 内核支持 user namespace 与 network namespace内核 6.6.87.2-microsoft-standard-WSL2 上 `unshare(CLONE_NEWUSER|CLONE_NEWNET)` 可用([CVE-2026-43284 PoC 的实测环境记录](https://sploitus.com/exploit?id=9B3D53CA-E954-5110-8888-1957B931A61B)`unshare -Urm` + bind + remount ro 的完整链条在 codex issue #9254 中有逐命令验证([链接](https://github.com/openai/codex/issues/9254))。
- **WSL1 不可用**WSL1 内核不支持 user namespace`unshare -Ur` 直接 EINVALbwrap 报 "kernel does not support user namespaces"[codex issue #16076](https://github.com/openai/codex/issues/16076))。另外 WSL1 有 ro bind mount 在有可写 fd 时 EBUSY 的 bug[WSL issue #3549](https://github.com/microsoft/WSL/issues/3549))。**方案必须强制 WSL2**(导入时 `--version 2`,启动前 `wsl -l -v` 校验)。
- 已知小限制:`unshare --time` 在 WSL 上不支持([WSL issue #9223](https://github.com/microsoft/WSL/issues/9223)),与本需求无关。
### 推荐命令链发行版内root 或 userns 均可)
方案 A纯 unshare无需装包
```bash
wsl -d astrion-sandbox -u root -- bash -c '
unshare --mount --propagation private bash -c "
mount --make-rprivate / # 关键:先切断 shared 传播,避免污染宿主/其他会话
mount --bind / / # 如需整树 ro先 bind 再 remount,robind+ro 一步到位有内核老 bug必须两步
mount -o remount,bind,ro /
mount --bind /mnt/e/workspace /mnt/e/workspace
mount -o remount,bind,rw /mnt/e/workspace # 工作区 rw 放回去
mount --bind /tmp /tmp && mount -o remount,bind,rw /tmp
exec sudo -u sandboxuser bash # 降权后 exec 目标 shell
"
'
```
方案 Bbubblewrap推荐与 macOS/Linux 共用同一套策略描述):
```bash
bwrap \
--ro-bind / / \
--bind /mnt/e/workspace /mnt/e/workspace \
--bind /tmp /tmp \
--unshare-net \
--die-with-parent \
-- bash
```
bwrap 的 `--unshare-net` 会在新 netns 里创建 loopback[fromager issue #472](https://github.com/python-wheel-build/fromager/issues/472)),天然实现「禁网但保留 lo」。
### 两个必须注意的工程细节
1. **mount 传播**WSL2 的挂载默认是 shared propagation。在新 mount namespace 里 remount ro 之前如果不 `mount --make-rprivate /`ro 事件可能传播回初始 namespace影响同 VM 里的其他会话/发行版。先私有化再操作是标准做法。
2. **bind ro 要两步**`mount --bind` 本身不继承 ro 选项,必须 `mount -o remount,bind,ro`(经典内核行为,[Unix.SE](https://unix.stackexchange.com/questions/128336/why-doesnt-mount-respect-the-read-only-option-for-bind-mounts)、[LWN: Read-only bind mounts](https://lwn.net/Articles/281157/))。
3. 只读是 VFS 层强制9p 后端无法绕过(进程在 Linux 侧的一切写都被 VFS 挡掉);但**从 Windows 侧的直接写入不受此约束**——沙箱只防 WSL 内的不可信进程。
---
## 4. 网络隔离:可行(三档都能实现)
### 结论
**可行**。三档映射:
- **禁网**`unshare --net`(新 netns 无任何外部接口)或 `bwrap --unshare-net`
- **仅回环**`unshare --net` 后 `ip link set lo up`(在新 ns 内有 CAP_NET_ADMIN可用或直接用 bwrap自动带 lo
- **全开**:不加 net namespace。
### 依据与细节
- `unshare -rn` 创建无外部连通性的 netns是构建工具的常用隔离手段[fromager issue #472](https://github.com/python-wheel-build/fromager/issues/472));进程隔离教学也覆盖 `ip netns`/`unshare` 路径([oneuptime 教程](https://oneuptime.com/blog/post/2026-03-20-isolate-process-network-namespace/view))。
- 新 netns 里 lo 存在但初始 DOWN需要 `ip link set lo up`bwrap 较新版本自动完成这步。个别环境 bwrap 的 loopback 配置会失败RTM_NEWADDR EPERM[codex issue #15982](https://github.com/openai/codex/issues/15982))——若在目标环境复现,降级为 `unshare --net` + 手动 `ip link set lo up`
- 发行版级 iptables/nftables 也可行WSL2 是完整内核,实例内 root 有完整 netfilter 能力),但 netns 方案更彻底(连路由表都不存在,无法绕过),优先推荐。
- **localhostForwarding 的影响**:默认 `localhostForwarding=true`[Microsoft Learn](https://learn.microsoft.com/en-us/windows/wsl/wsl-config)Windows 侧可通过 localhost 访问 WSL 内监听的端口。对隔离的含义:① 沙箱内进程监听端口可能被 Windows 宿主触达(本地跨进程攻击面);② NAT 模式下 WSL 内可经宿主网关 IP 访问 Windows 上的 localhost 服务(如代理 7890 端口)。**`unshare --net` 把这两条路一起断掉**(新 ns 里根本没有到宿主的链路),所以 netns 方案天然覆盖该问题;若走 iptables 方案则必须额外阻断到宿主网关 IP 的流量。
- 另可在 `.wslconfig``networkingMode=none` 做 VM 级断网,但它是全局开关、影响所有发行版,不适合按需沙箱。
---
## 5. 交互式 shell持久会话可行
### 结论
**可行**。namespace 隔离是进程树属性,把沙箱包装器作为长驻 shell 的父进程即可,对一次性命令和持久终端同样成立。
### 做法
- 一次性命令:`wsl -d astrion-sandbox -u root -- bwrap <flags> -- bash -lc "cd ... && cmd"`。
- 持久交互终端:把同样的包装器包在交互 shell 外面——`wsl -d astrion-sandbox -- bwrap <flags> -- bash`(或先 `unshare` 进 namespace 再 exec bash。bwrap 是 exec 语义,整个会话生命周期都活在沙箱里;加 `--die-with-parent` 保证 wsl.exe 断开时沙箱内进程树随之回收。
- 会话内多条命令复用同一沙箱:沙箱内起 tmux 或直接复用该 bash 的 stdin/stdout 管道host 侧通过持有 wsl.exe 进程的 stdio 持续下发命令。
- 若要「先配置一次隔离、后续多次进入」,也可以 `unshare --mount --net --fork` 后用 `nsenter -t <pid> -m -n` 反复进入同一组 namespacensenter 是标准 Linux 能力WSL2 可用)。
---
## 6. 性能:有限(每次起 wsl.exe 有数百 ms 开销,必须做进程复用)
### 结论
**可行但需要工程优化**。实测/社区数据一致指向wsl.exe 每次调用有数百毫秒级固定开销,高频命令场景必须保活 + 复用长驻进程。
### 数据点
- 从 Windows 侧经 wsl.exe 调用 Linux 程序,单次约 500800ms[Super User #1663063](https://superuser.com/questions/1663063/slow-bash-under-wsl))。
- 极端病态案例wsl.exe 会话内命令 >5s 而 SSH 进同一 WSL <0.1s[WSL issue #4712](https://github.com/microsoft/WSL/issues/4712)说明 wsl.exe 通道本身可能是瓶颈
- 第三方 cold boot 基准:基于 WSL2 的 agent 运行时冷启动约 3500ms[ZeroClaw 对比基准](https://skywork.ai/skypage/en/openclaw-windows-compatibility-guide/2048651699598520321),社区数据,仅供参考量级)。
- 对照Linux 原生 bwrap/容器级沙箱开销约 300ms 以内([Julia Evans 的 benchmark](https://jvns.ca/blog/2022/06/28/some-notes-on-bubblewrap/)),即大部分开销在 wsl.exe 桥接层而非沙箱本身。
### 优化手段(都已验证可行)
1. **防 VM 休眠**`.wslconfig` 里 `vmIdleTimeout=-1`(默认 60000ms 空闲即关 VM[Microsoft Learn](https://learn.microsoft.com/en-us/windows/wsl/wsl-config));新版 WSL 还有 `[general] instanceIdleTimeout`(默认 15000ms[GreenGorych .wslconfig 参考](https://greengorych.io/blog/complete-wslconfig-reference-and-template/?q=))。
2. **保活**:后台跑一个无害长驻命令,如 `wsl --exec dbus-launch true`[知乎实践](https://www.zhihu.com/question/662699218/answer/3578847718))。
3. **复用**host 侧持有一条长驻的 `wsl -d astrion-sandbox -- bwrap ... bash` 进程,通过其 stdin/stdout 反复下发命令推荐或沙箱内起一个小的命令服务FIFO/socket后续命令经 wsl.exe -e 投递。
---
## 7. 编码与输出污染:有限(有开关,但需逐项处理)
### UTF-16 输出
- **结论:可控。** wsl.exe 自身输出(如 `--list`)是 UTF-16LE 无 BOM不尊重系统代码页[WSL issue #4607](https://github.com/microsoft/WSL/issues/4607))。
- **解法:设置环境变量 `WSL_UTF8=1`**wsl.exe 即改用 UTF-8 输出微软官方支持的开关VS Code 曾因此产生解析 bug反向证实了该行为[vscode issue #276253](https://github.com/microsoft/vscode/issues/276253)。host 侧启动 wsl.exe 子进程时统一注入 `WSL_UTF8=1` 即可。
### localhost 代理警告
- **结论:可抑制,但最佳路径建议实测确认。** 警告 `wsl: A localhost proxy configuration was detected but not mirrored into WSL. WSL in NAT mode does not support localhost proxies.` 由 wsl.exe 在每次启动时打印([WSL issue #13456](https://github.com/microsoft/WSL/issues/13456)、[MathWorks 论坛](https://fr.mathworks.com/matlabcentral/answers/2174804-wsl-proxy-issue-affecting-compiler-package-microservicedockerimage-in-matlab)、[kevinskii.dev](https://kevinskii.dev/posts/wsl-networking/))。触发条件是 `autoProxy=true`(默认)+ Windows 配了监听 127.0.0.1 的代理 + NAT 模式。
- 抑制手段:
1. `.wslconfig``[wsl2] autoProxy=false`官方文档确认该键控制「Enforces WSL to use Windows' HTTP proxy information」[Microsoft Learn](https://learn.microsoft.com/en-us/windows/wsl/wsl-config);社区模板即推荐关代理时设 false[feamcor gist](https://gist.github.com/feamcor/46687e86513c106c438bfa2715249609))。逻辑上检测关闭则警告不再产生——**建议落地前实测一轮**,因为微软没有单独文档承诺「该警告由 autoProxy 开关控制」。
2. 或切 `networkingMode=mirrored`(镜像模式下 localhost 代理天然可用,警告消失;但会改变整体网络行为,并存在与代理工具冲突后回退 NAT 的已知问题,[issue #13456](https://github.com/microsoft/WSL/issues/13456)、[issue #10794](https://github.com/microsoft/WSL/issues/10794))。
3. 兜底:该警告走 stderr可在 host 侧对 wsl.exe 的 stderr 做已知模式过滤(不影响子命令的退出码和 stdout
- `.wslconfig` 修改后需 `wsl --shutdown` 重启 VM 生效8 秒规则,[Microsoft Learn](https://learn.microsoft.com/en-us/windows/wsl/wsl-config)。WSLENV 是环境变量透传机制(仅传白名单变量,[Zenn 实践](https://zenn.dev/kwrkb/articles/d927a0eac72d3d?locale=en)),与该警告无关。
---
## 总体评估矩阵
| 能力 | 结论 | 关键手段 |
|---|---|---|
| 专用沙箱发行版供给 | 可行 | `wsl --import` + Ubuntu cloud WSL rootfs / Alpine minirootfs注册表 Lxss + 名字黑名单 + 试运行探测排除 docker-desktop推荐只认自建 distro |
| Windows 路径访问 | 可行 | 盘符机械转换;权限控制走 mount 层而非 chmod9p metadata 默认关) |
| 只读/写范围mount ns | 可行 | `unshare --mount` + `make-rprivate` + 两步 ro bind + 工作区 rw 放回;或 bubblewrap必须 WSL2WSL1 无 userns |
| 网络三档 | 可行 | `bwrap --unshare-net` / `unshare --net` + `ip link set lo up`localhostForwarding 风险被 netns 天然覆盖 |
| 持久交互 shell | 可行 | 沙箱包装器 exec 长驻 bash或 nsenter 重入 |
| 性能 | 有限 | wsl.exe 单次数百 ms必须 VM 保活 + 长驻进程复用 |
| 编码/输出污染 | 有限 | `WSL_UTF8=1` 解决编码;`autoProxy=false` 抑制代理警告建议实测stderr 模式过滤兜底 |
**落地建议优先级**:专用 distro--import Alpine/Ubuntu rootfs→ 发行版内 bubblewrap 统一沙箱ro-bind / + 工作区 bind + --unshare-net 三档)→ 长驻 bwrap bash 会话复用 → `WSL_UTF8=1` + `autoProxy=false`。这样 Linux 与 Windows(WSL2) 可共用几乎同一份沙箱策略代码。