feat(runtime): Gateway 独立启动与公共协议落地(改造第 0-3 步)

- 闭环修复:审批 task_id 显式化(N2)、通知回退路径门闸 finally 释放(N1)、
  personalization 原子写(S10,公共 atomic_write_json)
- server.tasks 拆解:__init__ 轻量化、Blueprint/路由移至 blueprint.py/web.py,
  app_legacy 装配点惰性挂载;models.py 循环依赖死导入移除
- 执行链 Web 解耦:emit_event/run_background 安全包装(socketio 未绑定静默),
  清除 4 处裸 socketio.emit(含 task_stopped 先写事件流再推送的顺序修复)
- RuntimeService 补建对话下沉:chat 任务无 conversation_id 时服务层装配,
  客户端无需复制 Web 两步流程
- 事件协议:get_task_events 返回 meta.window_start 缺口检测水位
- 验收:test_runtime_standalone_lifecycle 子进程隔离(config import 固化问题),
  独立启动 + 协议全链路 + 审批语义全绿;全量 74 测试失败恰为 4 项存量
This commit is contained in:
JOJO 2026-09-07 23:00:15 +08:00
parent b4deee0f43
commit d9bd599c1c
26 changed files with 1991 additions and 114 deletions

View File

@ -0,0 +1,162 @@
# 任务入口收敛现状核查报告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

@ -0,0 +1,245 @@
# 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

@ -0,0 +1,388 @@
# 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

@ -0,0 +1,231 @@
# 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

@ -0,0 +1,330 @@
# 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` 强制显式 session_data缺失抛 ValueError`models.py:174-175`)。
- **执行链运行期解耦(当日验证属实)**`_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② |
### 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 不是本轮完成条件。
### 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.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

@ -14,6 +14,7 @@ except ImportError:
from core.tool_config import TOOL_CATEGORIES from core.tool_config import TOOL_CATEGORIES
from config.model_profiles import get_default_model_key, get_registered_model_keys from config.model_profiles import get_default_model_key, get_registered_model_keys
from utils.atomic_io import atomic_write_json
from modules.i18n import tr from modules.i18n import tr
@ -845,8 +846,8 @@ def save_personalization_config(base_dir: PathLike, payload: Dict[str, Any]) ->
validate_context_compression_settings(config) validate_context_compression_settings(config)
path = _to_path(base_dir) path = _to_path(base_dir)
_ensure_parent(path) _ensure_parent(path)
with open(path, "w", encoding="utf-8") as f: # 原子写:防止写中断留下半截 JSON与对话保存链路的原子替换对齐S10 修复)
json.dump(config, f, ensure_ascii=False, indent=2) atomic_write_json(path, config)
_sync_ui_locale(config) _sync_ui_locale(config)
return config return config

View File

@ -34,7 +34,7 @@ from server.conversation import conversation_bp
from server.chat import chat_bp from server.chat import chat_bp
from server.usage import usage_bp from server.usage import usage_bp
from server.status import status_bp from server.status import status_bp
from server.tasks import tasks_bp from server.tasks.web import get_tasks_blueprint
from server.api_v1 import api_v1_bp from server.api_v1 import api_v1_bp
from server.multi_agent import multi_agent_bp from server.multi_agent import multi_agent_bp
from server.workflow_page import workflow_page_bp from server.workflow_page import workflow_page_bp
@ -302,7 +302,7 @@ app.register_blueprint(conversation_bp)
app.register_blueprint(chat_bp) app.register_blueprint(chat_bp)
app.register_blueprint(usage_bp) app.register_blueprint(usage_bp)
app.register_blueprint(status_bp) app.register_blueprint(status_bp)
app.register_blueprint(tasks_bp) app.register_blueprint(get_tasks_blueprint())
app.register_blueprint(api_v1_bp) app.register_blueprint(api_v1_bp)
app.register_blueprint(multi_agent_bp) app.register_blueprint(multi_agent_bp)
app.register_blueprint(workflow_page_bp) app.register_blueprint(workflow_page_bp)

View File

@ -70,7 +70,7 @@ from .utils_common import (
from .security import rate_limited, format_tool_result_notice, compact_web_search_result, consume_socket_token, prune_socket_tokens, validate_csrf_request, requires_csrf_protection, get_csrf_token from .security import rate_limited, format_tool_result_notice, compact_web_search_result, consume_socket_token, prune_socket_tokens, validate_csrf_request, requires_csrf_protection, get_csrf_token
from .main_task_gate import acquire_adopted_main_task_gate, release_main_task_gate from .main_task_gate import acquire_adopted_main_task_gate, release_main_task_gate
from .monitor import cache_monitor_snapshot, get_cached_monitor_snapshot from .monitor import cache_monitor_snapshot, get_cached_monitor_snapshot
from .extensions import socketio from .extensions import socketio, run_background
from .state import ( from .state import (
MONITOR_FILE_TOOLS, MONITOR_FILE_TOOLS,
MONITOR_MEMORY_TOOLS, MONITOR_MEMORY_TOOLS,
@ -264,7 +264,7 @@ def process_message_task(terminal: WebTerminal, message: str, images, sender, cl
# === 统一对外入口 === # === 统一对外入口 ===
def start_chat_task(terminal, message: str, images: Any, sender, client_sid: str, workspace, username: str, videos: Any = None): def start_chat_task(terminal, message: str, images: Any, sender, client_sid: str, workspace, username: str, videos: Any = None):
"""在线程模式下启动对话任务,供 Socket 事件调用。""" """在线程模式下启动对话任务,供 Socket 事件调用。"""
return socketio.start_background_task( return run_background(
process_message_task, process_message_task,
terminal, terminal,
message, message,

View File

@ -70,7 +70,7 @@ from .utils_common import (
from .security import rate_limited, compact_web_search_result, consume_socket_token, prune_socket_tokens, validate_csrf_request, requires_csrf_protection, get_csrf_token from .security import rate_limited, compact_web_search_result, consume_socket_token, prune_socket_tokens, validate_csrf_request, requires_csrf_protection, get_csrf_token
from .main_task_gate import try_acquire_main_task_gate, release_main_task_gate from .main_task_gate import try_acquire_main_task_gate, release_main_task_gate
from .monitor import cache_monitor_snapshot, get_cached_monitor_snapshot from .monitor import cache_monitor_snapshot, get_cached_monitor_snapshot
from .extensions import socketio from .extensions import emit_event, run_background
from .state import ( from .state import (
MONITOR_FILE_TOOLS, MONITOR_FILE_TOOLS,
MONITOR_MEMORY_TOOLS, MONITOR_MEMORY_TOOLS,
@ -640,6 +640,11 @@ async def _dispatch_completion_user_notice(
except Exception as e: except Exception as e:
debug_log(f"[CompletionNotice] 创建后台消息任务失败,回退直接执行: {e}") debug_log(f"[CompletionNotice] 创建后台消息任务失败,回退直接执行: {e}")
# 回退路径没有任务线程(未登记 TaskRecord轮询器预占的门闸 token 无法随任务
# 移交释放;必须在本函数内随回退执行结束释放,否则对话门闸会被长期占用,
# 后续通知派发将恒被 try_acquire_main_task_gate 拒绝2026-09-07 N1 修复)。
# release_main_task_gate 对 None 或不匹配的 token 为无操作,可安全调用。
try:
payload = { payload = {
'message': user_message, 'message': user_message,
'conversation_id': conversation_id, 'conversation_id': conversation_id,
@ -670,6 +675,8 @@ async def _dispatch_completion_user_notice(
await task_handle await task_handle
except Exception as inner_exc: except Exception as inner_exc:
debug_log(f"[CompletionNotice] 回退处理 user_message 失败: {inner_exc}") debug_log(f"[CompletionNotice] 回退处理 user_message 失败: {inner_exc}")
finally:
release_main_task_gate(web_terminal, main_task_gate_token)
def _build_shared_waiting_payload(items: List[Dict[str, Any]]) -> Dict[str, Any]: def _build_shared_waiting_payload(items: List[Dict[str, Any]]) -> Dict[str, Any]:
@ -965,7 +972,7 @@ async def poll_completion_notifications(*, web_terminal, workspace, conversation
这样多个后台任务同时完成时会被合并成一次而非每条都触发一轮停止-再工作 这样多个后台任务同时完成时会被合并成一次而非每条都触发一轮停止-再工作
""" """
from .extensions import socketio from .extensions import emit_event
sub_manager = getattr(web_terminal, "sub_agent_manager", None) sub_manager = getattr(web_terminal, "sub_agent_manager", None)
bg_manager = getattr(web_terminal, "background_command_manager", None) bg_manager = getattr(web_terminal, "background_command_manager", None)
@ -979,7 +986,7 @@ async def poll_completion_notifications(*, web_terminal, workspace, conversation
def sender(event_type, data): def sender(event_type, data):
try: try:
socketio.emit(event_type, data, room=f"user_{username}") emit_event(event_type, data, room=f"user_{username}")
except Exception: except Exception:
pass pass
@ -1108,14 +1115,14 @@ async def poll_multi_agent_notifications(*, web_terminal, workspace, conversatio
poll_completion_notifications 完全分离避免多智能体消息和传统后台 poll_completion_notifications 完全分离避免多智能体消息和传统后台
通知竞争 task_manager 的单工作区互斥 通知竞争 task_manager 的单工作区互斥
""" """
from .extensions import socketio from .extensions import emit_event
max_wait_time = 3600 max_wait_time = 3600
start_wait = time.time() start_wait = time.time()
def sender(event_type, data): def sender(event_type, data):
try: try:
socketio.emit(event_type, data, room=f"user_{username}") emit_event(event_type, data, room=f"user_{username}")
except Exception: except Exception:
pass pass
@ -1478,7 +1485,6 @@ async def handle_task_with_sender(
files=None, files=None,
): ):
"""处理任务并发送消息 - 集成token统计版本""" """处理任务并发送消息 - 集成token统计版本"""
from .extensions import socketio
web_terminal = terminal web_terminal = terminal
conversation_id = getattr(web_terminal.context_manager, "current_conversation_id", None) conversation_id = getattr(web_terminal.context_manager, "current_conversation_id", None)
@ -1840,7 +1846,7 @@ async def handle_task_with_sender(
) )
if auto_title_enabled and not skip_auto_title_generation: if auto_title_enabled and not skip_auto_title_generation:
conv_id = getattr(web_terminal.context_manager, "current_conversation_id", None) conv_id = getattr(web_terminal.context_manager, "current_conversation_id", None)
socketio.start_background_task( run_background(
generate_conversation_title_background, generate_conversation_title_background,
web_terminal, web_terminal,
conv_id, conv_id,
@ -2164,7 +2170,7 @@ async def handle_task_with_sender(
quota_allowed, quota_info = web_terminal.record_model_call(bool(thinking_expected)) quota_allowed, quota_info = web_terminal.record_model_call(bool(thinking_expected))
if not quota_allowed: if not quota_allowed:
quota_type = 'thinking' if thinking_expected else 'fast' quota_type = 'thinking' if thinking_expected else 'fast'
socketio.emit('quota_notice', { emit_event('quota_notice', {
'type': quota_type, 'type': quota_type,
'reset_at': quota_info.get('reset_at'), 'reset_at': quota_info.get('reset_at'),
'limit': quota_info.get('limit'), 'limit': quota_info.get('limit'),
@ -2630,7 +2636,7 @@ async def handle_task_with_sender(
finally: finally:
loop.close() loop.close()
socketio.start_background_task(run_completion_poll) run_background(run_completion_poll)
# 多智能体模式独立通知池:处理 running 实例 / idle 实例待消费的 pending 消息。 # 多智能体模式独立通知池:处理 running 实例 / idle 实例待消费的 pending 消息。
# 与传统后台任务完全分离,避免两者竞争 create_chat_task 的单工作区互斥。 # 与传统后台任务完全分离,避免两者竞争 create_chat_task 的单工作区互斥。
@ -2682,7 +2688,7 @@ async def handle_task_with_sender(
loop.close() loop.close()
ma_debug("ma_poll_thread_finally", conversation_id=conversation_id) ma_debug("ma_poll_thread_finally", conversation_id=conversation_id)
socketio.start_background_task(run_ma_poll) run_background(run_ma_poll)
if not needs_completion_poll and not needs_ma_poll: if not needs_completion_poll and not needs_ma_poll:
ma_debug( ma_debug(

View File

@ -369,7 +369,7 @@ async def _handle_workflow_tool(*, function_name: str, web_terminal, arguments,
return json.dumps(result, ensure_ascii=False) return json.dumps(result, ensure_ascii=False)
async def _handle_submit_plan(*, web_terminal, arguments: Dict[str, Any], sender, username: str, conversation_id: Optional[str], tool_call_id: Optional[str]) -> str: async def _handle_submit_plan(*, web_terminal, arguments: Dict[str, Any], sender, username: str, conversation_id: Optional[str], tool_call_id: Optional[str], task_id: Optional[str] = None) -> str:
"""submit_plan 工具的计划批准流:弹窗展示计划文档 → 用户批准/拒绝 → """submit_plan 工具的计划批准流:弹窗展示计划文档 → 用户批准/拒绝 →
批准则自动切换运行模式到 execute联动恢复权限并更新运行期模式基线""" 批准则自动切换运行模式到 execute联动恢复权限并更新运行期模式基线"""
args = arguments or {} args = arguments or {}
@ -424,7 +424,7 @@ async def _handle_submit_plan(*, web_terminal, arguments: Dict[str, Any], sender
approval = plan_approval_manager.create_request( approval = plan_approval_manager.create_request(
username=username, username=username,
conversation_id=conversation_id, conversation_id=conversation_id,
task_id=getattr(web_terminal, "task_id", None), task_id=task_id,
tool_call_id=tool_call_id, tool_call_id=tool_call_id,
plan_file=plan_file, plan_file=plan_file,
plan_content=plan_content, plan_content=plan_content,
@ -475,8 +475,8 @@ async def _handle_submit_plan(*, web_terminal, arguments: Dict[str, Any], sender
except Exception: except Exception:
pass pass
try: try:
from .extensions import socketio from .extensions import emit_event
socketio.emit('status_update', web_terminal.get_status(), room=f"user_{username}") emit_event('status_update', web_terminal.get_status(), room=f"user_{username}")
except Exception: except Exception:
pass pass
switch_note = tr("tool_loop.plan_switch_note_ok") switch_note = tr("tool_loop.plan_switch_note_ok")
@ -562,7 +562,7 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages
question = user_question_manager.create_question( question = user_question_manager.create_question(
username=username, username=username,
conversation_id=conversation_id, conversation_id=conversation_id,
task_id=getattr(web_terminal, "task_id", None), task_id=client_sid,
tool_call_id=tool_call.get("id"), tool_call_id=tool_call.get("id"),
question=arguments.get("question"), question=arguments.get("question"),
context=arguments.get("context"), context=arguments.get("context"),
@ -863,7 +863,7 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages
approval_item = tool_approval_manager.create_request( approval_item = tool_approval_manager.create_request(
username=username, username=username,
conversation_id=conversation_id, conversation_id=conversation_id,
task_id=getattr(web_terminal, "task_id", None), task_id=client_sid,
tool_call_id=tool_call_id, tool_call_id=tool_call_id,
tool_name=function_name, tool_name=function_name,
arguments=arguments, arguments=arguments,
@ -873,6 +873,8 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages
'approval': approval_item, 'approval': approval_item,
'conversation_id': conversation_id, 'conversation_id': conversation_id,
}) })
if permission_eval.get("requires_approval"):
sender('update_action', { sender('update_action', {
'preparing_id': tool_call_id, 'preparing_id': tool_call_id,
'status': 'awaiting_approval', 'status': 'awaiting_approval',
@ -1019,6 +1021,7 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages
username=username, username=username,
conversation_id=conversation_id, conversation_id=conversation_id,
tool_call_id=str(tool_call_id) if tool_call_id else None, tool_call_id=str(tool_call_id) if tool_call_id else None,
task_id=client_sid,
) )
elif function_name in ("report_workflow_stage", "choose_workflow_branch", "deactivate_workflow"): elif function_name in ("report_workflow_stage", "choose_workflow_branch", "deactivate_workflow"):
tool_result = await _handle_workflow_tool( tool_result = await _handle_workflow_tool(
@ -1134,7 +1137,7 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages
approval_item = tool_approval_manager.create_request( approval_item = tool_approval_manager.create_request(
username=username, username=username,
conversation_id=conversation_id, conversation_id=conversation_id,
task_id=getattr(web_terminal, "task_id", None), task_id=client_sid,
tool_call_id=tool_call_id, tool_call_id=tool_call_id,
tool_name=function_name, tool_name=function_name,
arguments=arguments, arguments=arguments,

View File

@ -8,13 +8,10 @@ from server.utils_common import debug_log
def make_terminal_callback(username: str): def make_terminal_callback(username: str):
"""生成面向指定用户的广播函数""" """生成面向指定用户的广播函数(独立 Gateway 进程下 socket 未初始化时静默跳过)"""
from server.extensions import socketio from server.extensions import emit_event
def _callback(event_type, data): def _callback(event_type, data):
try: emit_event(event_type, data, room=f"user_{username}")
socketio.emit(event_type, data, room=f"user_{username}")
except Exception as exc:
debug_log(f"广播事件失败 ({username}): {event_type} - {exc}")
return _callback return _callback

View File

@ -27,7 +27,7 @@ def get_or_create_usage_tracker(username: Optional[str], workspace: Optional['mo
def emit_user_quota_update(username: Optional[str]): def emit_user_quota_update(username: Optional[str]):
from server.extensions import socketio from server.extensions import emit_event
if not username: if not username:
return return
tracker = get_or_create_usage_tracker(username) tracker = get_or_create_usage_tracker(username)
@ -35,6 +35,6 @@ def emit_user_quota_update(username: Optional[str]):
return return
try: try:
snapshot = tracker.get_quota_snapshot() snapshot = tracker.get_quota_snapshot()
socketio.emit('quota_update', {'quotas': snapshot}, room=f"user_{username}") emit_event('quota_update', {'quotas': snapshot}, room=f"user_{username}")
except Exception: except Exception:
pass pass

View File

@ -1,7 +1,35 @@
"""Flask/SocketIO 扩展实例。""" """Flask/SocketIO 扩展实例。"""
import threading
from flask_socketio import SocketIO from flask_socketio import SocketIO
# 统一的 SocketIO 实例,使用线程模式以兼容现有逻辑 # 统一的 SocketIO 实例,使用线程模式以兼容现有逻辑
socketio = SocketIO(cors_allowed_origins="*", async_mode='threading', logger=False, engineio_logger=False) socketio = SocketIO(cors_allowed_origins="*", async_mode='threading', logger=False, engineio_logger=False)
__all__ = ["socketio"]
def emit_event(event, data, room=None, **kwargs):
"""Web 模式下经 socketio 实时推送;独立 Gateway 进程socketio 未绑定 app静默跳过。
事件的权威记录是任务事件流TaskRecord.events_append_event 落盘在前
socket 推送只是在线客户端的实时增量通道独立进程没有 socket 客户端
跳过不丢数据Web 模式下推送失败也静默对齐原各调用点的 try/except 语义
"""
if socketio.server is None:
return
try:
socketio.emit(event, data, room=room, **kwargs)
except Exception:
pass
def run_background(fn, *args, **kwargs):
"""启动后台任务Web 模式走 socketio.start_background_task兼容其线程模型
独立 Gateway 进程socketio 未初始化降级为 daemon 线程"""
if socketio.server is not None:
return socketio.start_background_task(fn, *args, **kwargs)
t = threading.Thread(target=fn, args=args, kwargs=kwargs, daemon=True)
t.start()
return t
__all__ = ["socketio", "emit_event", "run_background"]

View File

@ -35,12 +35,18 @@ class RuntimeService:
from server.tasks import task_manager from server.tasks import task_manager
params = ctx.params params = ctx.params
conversation_id = params.conversation_id
if not conversation_id and str(params.task_type or "chat").strip().lower() == "chat":
# 对话级隔离兜底(自 server/tasks/api.py 下沉chat 任务必须落在
# 对话级 terminal 上运行。补建对话文件是装配职责,收在服务层单点,
# Web/CLI/定时触发器等调用方无需各自实现「先建会话再发任务」。
conversation_id = self._ensure_conversation_for_chat(ctx)
return task_manager.create_chat_task( return task_manager.create_chat_task(
ctx.principal.username, ctx.principal.username,
ctx.principal.workspace_id, ctx.principal.workspace_id,
params.message, params.message,
list(params.images or []), list(params.images or []),
params.conversation_id, conversation_id,
videos=list(params.videos or []), videos=list(params.videos or []),
model_key=params.model_key, model_key=params.model_key,
thinking_mode=params.thinking_mode, thinking_mode=params.thinking_mode,
@ -54,6 +60,61 @@ class RuntimeService:
task_type=params.task_type, task_type=params.task_type,
) )
@staticmethod
def _ensure_conversation_for_chat(ctx: RuntimeContext) -> Optional[str]:
"""chat 任务未携带 conversation_id 时补建对话文件。
失败时返回 None容错语义与原适配层兜底一致任务线程内
ensure_conversation_loaded 仍有最终兜底但会失去对话级隔离
仅作为极端降级路径存在
"""
try:
from server.context import RuntimeIdentity, get_user_resources
from server.utils_common import debug_log
p = ctx.principal
params = ctx.params
identity = RuntimeIdentity(
host_mode=p.host_mode,
host_workspace_id=p.host_workspace_id,
is_api_user=p.is_api_user,
role=p.role,
preferred_model_key=params.model_key or p.preferred_model_key,
preferred_run_mode=params.run_mode or p.preferred_run_mode,
preferred_thinking_mode=(
params.thinking_mode if params.thinking_mode is not None else p.preferred_thinking_mode
),
)
terminal, workspace = get_user_resources(
p.username, workspace_id=p.workspace_id, update_session=False, identity=identity
)
cm = getattr(getattr(terminal, "context_manager", None), "conversation_manager", None)
if cm is None or workspace is None:
return None
run_mode = params.run_mode or p.preferred_run_mode or "fast"
if run_mode not in {"fast", "thinking", "deep"}:
run_mode = "fast"
thinking_mode = params.thinking_mode
if thinking_mode is None:
thinking_mode = p.preferred_thinking_mode
thinking_mode = bool(thinking_mode) if thinking_mode is not None else (run_mode != "fast")
conversation_id = cm.create_conversation(
project_path=str(getattr(workspace, "project_path", "") or "."),
run_mode=run_mode,
thinking_mode=thinking_mode,
model_key=params.model_key or p.preferred_model_key,
)
debug_log(f"[RuntimeService] 未携带 conversation_id已补建对话: {conversation_id}")
return conversation_id
except Exception as exc:
try:
from server.utils_common import debug_log
debug_log(f"[RuntimeService] 补建对话失败(继续按无 cid 处理): {exc}")
except Exception:
pass
return None
# ---- 控制 ---- # ---- 控制 ----
def cancel_task(self, username: str, task_id: str) -> bool: def cancel_task(self, username: str, task_id: str) -> bool:
@ -95,19 +156,24 @@ class RuntimeService:
def get_task_events( def get_task_events(
self, username: str, task_id: str, offset: int self, username: str, task_id: str, offset: int
) -> Tuple[Optional[List[Dict[str, Any]]], Optional[int], Optional[str]]: ) -> Tuple[Optional[List[Dict[str, Any]]], Optional[int], Optional[str], Optional[Dict[str, Any]]]:
"""按 offset 增量读取任务事件流idx/offset 协议,与 REST 轮询同一语义)。 """按 offset 增量读取任务事件流idx/offset 协议,与 REST 轮询同一语义)。
返回 (events, next_offset, error)error 非空表示任务不存在或无权访问 返回 (events, next_offset, error, meta)error 非空表示任务不存在或无权访问
meta 携带缺口检测水位``window_start`` = 事件窗口当前最小 idx
协议 docs/runtime_protocol.md §5.2客户端 offset < window_start
即事件已被裁剪须走重新同步会话快照对账而非续传
""" """
from server.tasks import task_manager from server.tasks import task_manager
rec = task_manager.get_task(username, task_id) rec = task_manager.get_task(username, task_id)
if not rec: if not rec:
return None, None, tr("tasks.task_not_found") return None, None, tr("tasks.task_not_found"), None
events = task_manager.get_events_since(rec, max(0, int(offset or 0))) offset = max(0, int(offset or 0))
next_offset = events[-1]["idx"] + 1 if events else max(0, int(offset or 0)) events = task_manager.get_events_since(rec, offset)
return events, next_offset, None next_offset = events[-1]["idx"] + 1 if events else offset
meta = {"window_start": task_manager.get_event_window_start(rec)}
return events, next_offset, None, meta
# 进程级单例(无状态,可安全共享) # 进程级单例(无状态,可安全共享)

View File

@ -1,14 +1,13 @@
# server/tasks/__init__.py - tasks_bp 兼容入口 # server/tasks/__init__.py - 任务核心层(无 Web 依赖,可独立加载)
from flask import Blueprint #
# Gateway 化解耦2026-09-07G4本包 __init__ 只导出任务核心层
tasks_bp = Blueprint("tasks", __name__) # TaskManager 单例与 models 层符号),不创建 Flask Blueprint、
# 不 import 路由模块——保证 server.runtime 在无 Web 应用初始化的
# 进程中可独立加载使用。
# Web 路由装配入口server/tasks/web.py 的 get_tasks_blueprint()
# Flask app 初始化时由 server/app_legacy.py 显式调用)。
from server.tasks.models import * from server.tasks.models import *
from server.tasks.skills import * from server.tasks.models import TaskManager
from server.tasks.helpers import *
from server.tasks.media import *
# 显式导出单例,便于旧代码导入;必须在 api 之前定义api 会引用它 # 显式导出单例,便于旧代码导入
task_manager = TaskManager() task_manager = TaskManager()
from server.tasks.api import *

View File

@ -1,6 +1,6 @@
"""简单任务 API将聊天任务与 WebSocket 解耦,支持后台运行与轮询。""" """简单任务 API将聊天任务与 WebSocket 解耦,支持后台运行与轮询。"""
from __future__ import annotations from __future__ import annotations
from server.tasks import tasks_bp from server.tasks.blueprint import tasks_bp
import mimetypes import mimetypes
import json import json
import time import time
@ -16,7 +16,6 @@ from flask import current_app, session
from server.auth_helpers import api_login_required, get_current_username from server.auth_helpers import api_login_required, get_current_username
from server.context import get_user_resources, ensure_conversation_loaded from server.context import get_user_resources, ensure_conversation_loaded
from server.chat_flow import run_chat_task_sync
from server.security import rate_limited from server.security import rate_limited
from server.state import stop_flags from server.state import stop_flags
from server.utils_common import debug_log, log_conn_diag from server.utils_common import debug_log, log_conn_diag
@ -178,24 +177,8 @@ def create_task_api():
except Exception: except Exception:
pass pass
# 对话级隔离兜底chat 任务必须落在对话级 terminal 上。 # 对话级隔离兜底已下沉至 RuntimeService.create_task_ensure_conversation_for_chat
# 前端正常先 POST /api/conversations 拿 cid 再建任务;直接调 API 未带 # chat 任务未携带 conversation_id 时由服务层补建对话文件,适配层不再重复实现。
# conversation_id 时这里补建对话文件,任务随后在对话级 terminal 加载运行,
# 避免占用工作区级服务 terminal 并与其形成双持同一对话。
if not conversation_id:
try:
_term_nc, _ws_nc = get_user_resources(username, workspace_id)
_cm_nc = getattr(getattr(_term_nc, "context_manager", None), "conversation_manager", None)
if _cm_nc is not None:
conversation_id = _cm_nc.create_conversation(
project_path=str(getattr(_ws_nc, "project_path", "") or "."),
run_mode=(run_mode if run_mode in {"fast", "thinking", "deep"} else "fast"),
thinking_mode=bool(thinking_mode) if thinking_mode is not None else (run_mode != "fast"),
model_key=model_key,
)
debug_log(f"[TaskAPI] 未携带 conversation_id已补建对话: {conversation_id}")
except Exception as exc:
debug_log(f"[TaskAPI] 补建对话失败(继续按无 cid 处理): {exc}")
# 公共任务入口(契约 docs/runtime_contract.md §4适配层在完成认证后 # 公共任务入口(契约 docs/runtime_contract.md §4适配层在完成认证后
# 从 Flask session 显式构造可信 principal服务层不再回退读隐式上下文。 # 从 Flask session 显式构造可信 principal服务层不再回退读隐式上下文。

View File

@ -0,0 +1,9 @@
"""tasks 蓝图对象(仅创建,不挂载路由)。
路由模块api/skills通过 `from server.tasks.blueprint import tasks_bp`
装饰挂载Flask app 初始化时由 server/tasks/web.py 统一装配
独立 Gateway/Runtime 进程无需加载本模块
"""
from flask import Blueprint
tasks_bp = Blueprint("tasks", __name__)

View File

@ -1,6 +1,5 @@
"""简单任务 API将聊天任务与 WebSocket 解耦,支持后台运行与轮询。""" """简单任务 API将聊天任务与 WebSocket 解耦,支持后台运行与轮询。"""
from __future__ import annotations from __future__ import annotations
from server.tasks import tasks_bp
import mimetypes import mimetypes
import json import json
import time import time

View File

@ -1,6 +1,5 @@
"""简单任务 API将聊天任务与 WebSocket 解耦,支持后台运行与轮询。""" """简单任务 API将聊天任务与 WebSocket 解耦,支持后台运行与轮询。"""
from __future__ import annotations from __future__ import annotations
from server.tasks import tasks_bp
import mimetypes import mimetypes
import json import json
import time import time

View File

@ -1,6 +1,5 @@
"""简单任务 API将聊天任务与 WebSocket 解耦,支持后台运行与轮询。""" """简单任务 API将聊天任务与 WebSocket 解耦,支持后台运行与轮询。"""
from __future__ import annotations from __future__ import annotations
from server.tasks import tasks_bp
import mimetypes import mimetypes
import json import json
import time import time
@ -11,8 +10,10 @@ from collections import deque
from pathlib import Path from pathlib import Path
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List
# 注意:本模块属于任务核心层,禁止顶层 import Web 路由层模块
# server.tasks.blueprint / server.chat_flow 等),保证无 Web 应用初始化时可独立加载。
# run_chat_task_sync 在使用点函数内延迟导入(见 _run_chat_task
from server.context import RuntimeIdentity, get_user_resources, ensure_conversation_loaded from server.context import RuntimeIdentity, get_user_resources, ensure_conversation_loaded
from server.chat_flow import run_chat_task_sync
from server.main_task_gate import release_main_task_gate from server.main_task_gate import release_main_task_gate
from server.work_timer import finalize_conversation_work_timer from server.work_timer import finalize_conversation_work_timer
from server.state import stop_flags from server.state import stop_flags
@ -208,6 +209,16 @@ class TaskManager:
snapshot = list(rec.events) snapshot = list(rec.events)
return [e for e in snapshot if e["idx"] >= offset] return [e for e in snapshot if e["idx"] >= offset]
def get_event_window_start(self, rec: TaskRecord) -> int:
"""事件窗口当前最小 idx缺口检测水位协议 docs/runtime_protocol.md §5.2)。
deque(maxlen) 挤出旧事件后窗口前移客户端 offset 小于该值即说明
中间事件已被裁剪必须走重新同步会话快照对账而非续传
idx 单调不回绕窗口未裁剪时为 0
"""
with self._lock:
return rec.events[0]["idx"] if rec.events else 0
def list_tasks(self, username: str, workspace_id: Optional[str] = None) -> List[TaskRecord]: def list_tasks(self, username: str, workspace_id: Optional[str] = None) -> List[TaskRecord]:
with self._lock: with self._lock:
return [ return [
@ -903,10 +914,11 @@ class TaskManager:
data.setdefault("workspace_id", rec.workspace_id) data.setdefault("workspace_id", rec.workspace_id)
# 记录事件 # 记录事件
self._append_event(rec, event_type, data) self._append_event(rec, event_type, data)
# 在线用户仍然收到实时推送(房间 user_{username} # 在线用户仍然收到实时推送(房间 user_{username}
# 安全包装socketio 未绑定(独立 Gateway 进程)时静默跳过
try: try:
from server.extensions import socketio from server.extensions import emit_event
socketio.emit(event_type, data, room=f"user_{username}") emit_event(event_type, data, room=f"user_{username}")
except Exception: except Exception:
pass pass
@ -972,6 +984,10 @@ class TaskManager:
previous_skill_context_messages = None previous_skill_context_messages = None
try: try:
# 延迟导入:消除任务核心层对 Web 路由层server/chat_flow.py的静态依赖
# 使本模块可在无 Web 应用初始化的进程中加载Gateway 独立启动前提)。
from server.chat_flow import run_chat_task_sync
run_chat_task_sync( run_chat_task_sync(
terminal=terminal, terminal=terminal,
message=rec.message, message=rec.message,
@ -1027,7 +1043,7 @@ class TaskManager:
) )
# 统一发送 task_stopped携带后台任务状态 # 统一发送 task_stopped携带后台任务状态
try: try:
from server.extensions import socketio from server.extensions import emit_event
stopped_payload = { stopped_payload = {
'message': tr("task_main.task_stopped"), 'message': tr("task_main.task_stopped"),
'reason': 'user_requested', 'reason': 'user_requested',
@ -1036,9 +1052,10 @@ class TaskManager:
'has_running_sub_agents': bg_state["has_running_sub_agents"], 'has_running_sub_agents': bg_state["has_running_sub_agents"],
'has_running_background_commands': bg_state["has_running_background_commands"], 'has_running_background_commands': bg_state["has_running_background_commands"],
} }
socketio.emit('task_stopped', stopped_payload, room=f"user_{rec.username}") # 先写权威事件流(轮询客户端可见),再做实时推送——
# 同时写入轮询事件队列,确保 websocket 丢失时前端仍能收到 # 推送失败(含 socketio 未绑定)不得影响事件流记录
self._append_event(rec, "task_stopped", stopped_payload) self._append_event(rec, "task_stopped", stopped_payload)
emit_event('task_stopped', stopped_payload, room=f"user_{rec.username}")
debug_log( debug_log(
f"[TaskRun] 已发送 task_stopped: task_id={rec.task_id}, " f"[TaskRun] 已发送 task_stopped: task_id={rec.task_id}, "
f"has_bg={has_bg}, room=user_{rec.username}" f"has_bg={has_bg}, room=user_{rec.username}"

View File

@ -1,6 +1,6 @@
"""Workspace skill 读取与 /api/skills 接口。""" """Workspace skill 读取与 /api/skills 接口。"""
from __future__ import annotations from __future__ import annotations
from server.tasks import tasks_bp from server.tasks.blueprint import tasks_bp
import re import re
from pathlib import Path from pathlib import Path
from typing import Dict, Any, List from typing import Dict, Any, List

19
server/tasks/web.py Normal file
View File

@ -0,0 +1,19 @@
"""tasks 包的 Web 路由装配入口Flask app 初始化时调用)。
独立 Gateway/Runtime 进程不需要本模块任务核心层server.tasks
包的 task_manager 单例与 models 可脱离 Web 应用独立加载
"""
from server.tasks.blueprint import tasks_bp
def get_tasks_blueprint():
"""返回已挂载全部路由的 tasks 蓝图。
通过 import 路由模块触发 @tasks_bp.route 装饰器完成挂载
Python 模块只 import 一次重复调用幂等
"""
from server.tasks import skills as _skills # noqa: F401 @tasks_bp.route 挂载)
from server.tasks import helpers as _helpers # noqa: F401 (保持历史装配行为等价)
from server.tasks import media as _media # noqa: F401 (保持历史装配行为等价)
from server.tasks import api as _api # noqa: F401 @tasks_bp.route 挂载)
return tasks_bp

View File

@ -0,0 +1,298 @@
"""Gateway 独立进程验收检查体(由 test_runtime_standalone_lifecycle 以子进程方式调用)。
为什么必须是独立进程config/paths.py DATA_DIR / DEPLOY_CONFIG_DIR 等是
import 时固化的模块级常量 unittest discover 全量运行时排在前面的测试模块
会先 import config 使常量固化本文件顶部再设环境变量已无效且被测代码会
落到用户真实运行态目录执行任务不可接受子进程内 import 顺序完全可控
隔离 100% 可靠
用法python test/runtime_standalone_checks.py <lifecycle|chain>
退出码 0 = 通过断言失败/traceback stderr返回码非 0
"""
import json
import os
import sys
import tempfile
import time
from pathlib import Path
# ---- 隔离环境必须在 import 任何项目模块之前设置config 在 import 时解析路径)----
_SMOKE_ROOT = Path(tempfile.mkdtemp(prefix="astrion_gw_smoke_"))
_SMOKE_WORKSPACE = _SMOKE_ROOT / "workspace"
_SMOKE_WORKSPACE.mkdir(parents=True, exist_ok=True)
(_SMOKE_ROOT / "config").mkdir(parents=True, exist_ok=True)
os.environ["ASTRION_DATA_ROOT"] = str(_SMOKE_ROOT)
os.environ["DEPLOY_CONFIG_DIR"] = str(_SMOKE_ROOT / "config")
os.environ["TERMINAL_SANDBOX_MODE"] = "host"
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
(_SMOKE_ROOT / "config" / "host_workspaces.json").write_text(
json.dumps(
{
"default_workspace_id": "gwsmoke",
"workspaces": [
{"workspace_id": "gwsmoke", "label": "gwsmoke", "path": str(_SMOKE_WORKSPACE)}
],
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
def _make_ctx(conversation_id=None, message="协议链路验收 ping"):
from server.runtime import (
InternalDirectives, RuntimeContext, TaskParams, TrustedPrincipal,
)
return RuntimeContext(
principal=TrustedPrincipal(
username="gw_smoke_user", workspace_id="gwsmoke", role="admin",
host_mode=True, host_workspace_id="gwsmoke",
),
params=TaskParams(message=message, conversation_id=conversation_id, run_mode="fast"),
directives=InternalDirectives(),
)
def _wait_terminal_state(task_id, timeout=30, cancel_on_event=None):
"""等待终态。事件流出现指定事件(默认首个事件)后主动取消——
无网络环境下模型重试链路太长取消让任务快速到达终态
cancel_on_event="api_request_start" 可确保 user 消息已写入历史后再取消
"""
from server.runtime import runtime_service
deadline = time.time() + timeout
cancelled = False
while time.time() < deadline:
rec = runtime_service.get_task("gw_smoke_user", task_id)
if getattr(rec, "status", None) in {"succeeded", "failed", "stopped", "canceled"}:
return rec
if not cancelled:
events, _, _, _meta = runtime_service.get_task_events("gw_smoke_user", task_id, 0)
if events and (cancel_on_event is None or any(e.get("type") == cancel_on_event for e in events)):
runtime_service.cancel_task("gw_smoke_user", task_id)
cancelled = True
time.sleep(0.5)
return runtime_service.get_task("gw_smoke_user", task_id)
def check_lifecycle():
"""第 1 步验收:无 Flask app 的独立 Gateway 生命周期G4/G13"""
from flask import has_app_context
assert not has_app_context(), "验收前提:无 Flask app 上下文"
from server.runtime import runtime_service
rec = runtime_service.create_task(
_make_ctx(message="ping独立生命周期验收模型调用失败属预期")
)
assert getattr(rec, "task_id", None), "create_task 应返回 task_id"
task_id = rec.task_id
from server.runtime import runtime_service as rs # 单例别名,语义强调
deadline = time.time() + 30
saw_events = False
final_status = None
while time.time() < deadline:
events, _next_offset, err, _meta = rs.get_task_events("gw_smoke_user", task_id, 0)
assert err is None, f"get_task_events 应可读: {err}"
if events:
saw_events = True
rec_now = rs.get_task("gw_smoke_user", task_id)
status = getattr(rec_now, "status", None)
if status in {"succeeded", "failed", "stopped", "canceled", "cancel_requested"}:
final_status = status
break
# 装配完成后主动取消(避免真实模型调用慢等;装配此时已真实发生)
if saw_events:
rs.cancel_task("gw_smoke_user", task_id)
time.sleep(0.5)
assert saw_events, "任务线程应真实启动并产生事件(装配真实发生)"
deadline = time.time() + 20
while time.time() < deadline:
rec_now = rs.get_task("gw_smoke_user", task_id)
final_status = getattr(rec_now, "status", None)
if final_status in {"succeeded", "failed", "stopped", "canceled"}:
break
time.sleep(0.5)
assert final_status in {"succeeded", "failed", "stopped", "canceled"}, (
f"任务应到达终态(模型失败属预期),实际: {final_status}"
)
# 门闸最终释放(对话应可受理下一任务)
import server.context.resources as resources
term_key = "host::gwsmoke::" + str(getattr(rec_now, "conversation_id", "") or "")
terminal = resources.state.user_terminals.get(term_key)
if terminal is not None:
from server.main_task_gate import is_main_task_gate_busy
assert not is_main_task_gate_busy(terminal), "任务终态后门闸必须释放(否则下一任务无法受理)"
# 全程无 Flask app 初始化socketio 未绑定 app
assert not has_app_context(), "验收结束仍应无 Flask app 上下文"
import server.extensions as ext
assert getattr(ext.socketio, "server", None) is None, "socketio 不应初始化 server无 Web 应用装配)"
def check_chain():
"""第 2 步验收:极简协议客户端视角全链路(工作区→会话→运行→停止→历史→审批)。"""
from server.runtime import runtime_service
# 1. 第一个 Run受理 → 终态(模型失败属预期;等 api_request_start 确保历史已写)
rec1 = runtime_service.create_task(_make_ctx())
rec1 = _wait_terminal_state(rec1.task_id, cancel_on_event="api_request_start")
assert rec1.status in {"succeeded", "failed", "stopped", "canceled"}, f"Run1 应达终态: {rec1.status}"
conv_id = rec1.conversation_id
assert conv_id, "Run 应建立会话 id服务层补建对话conversation_id 同步受理返回)"
# 2. 会话历史已持久化(隔离数据目录下的对话 JSON 含 user 消息)
from config import DATA_DIR
conv_dir = Path(DATA_DIR) / "conversations"
candidates = list(conv_dir.rglob(f"*{conv_id}*.json")) if conv_dir.exists() else []
assert candidates, f"会话 JSON 应已持久化({conv_id}"
content = candidates[0].read_text(encoding="utf-8", errors="replace")
assert "协议链路验收 ping" in content, "会话历史应包含本次 user 消息"
# 3. 事件流 offset 续读分段读取idx 连续、无重复;窗口水位可用于缺口检测
events_all, _, err, meta = runtime_service.get_task_events("gw_smoke_user", rec1.task_id, 0)
assert err is None
assert events_all, "Run 应产生事件"
assert meta and meta.get("window_start") == 0, "未裁剪时窗口水位应为 0"
assert meta["window_start"] <= events_all[0]["idx"], "水位不超过首个返回事件 idx"
mid = events_all[len(events_all) // 2]["idx"]
tail, _, err2, meta2 = runtime_service.get_task_events("gw_smoke_user", rec1.task_id, mid + 1)
assert err2 is None
assert meta2 and meta2.get("window_start") == 0
idxs = [e["idx"] for e in events_all]
assert idxs == sorted(idxs), "idx 应单调递增"
assert len(set(idxs)) == len(idxs), "idx 应无重复"
assert [e["idx"] for e in tail] == [i for i in idxs if i > mid], "offset 续读语义"
# 4. 同会话第二个 Run门闸已释放可连续受理
rec2 = runtime_service.create_task(_make_ctx(conversation_id=conv_id, message="第二个 Run"))
assert rec2.conversation_id == conv_id, "第二个 Run 应复用同一会话"
rec2 = _wait_terminal_state(rec2.task_id)
assert rec2.status in {"succeeded", "failed", "stopped", "canceled"}, f"Run2 应达终态: {rec2.status}"
# 5. 审批语义create → list_pending → decide → 重复 decide 返回现状(单次裁决)
from server.state import tool_approval_manager
item = tool_approval_manager.create_request(
username="gw_smoke_user", conversation_id=conv_id, task_id=rec2.task_id,
tool_call_id="tc_smoke", tool_name="run_command",
arguments={"command": "echo hi"}, preview={},
)
pending = tool_approval_manager.list_pending("gw_smoke_user", conv_id)
assert any(p.get("approval_id") == item["approval_id"] for p in pending), "审批应入 pending 列表"
first = tool_approval_manager.decide(item["approval_id"], "gw_smoke_user", "approved")
assert first.get("status") == "approved"
second = tool_approval_manager.decide(item["approval_id"], "gw_smoke_user", "rejected")
assert second.get("status") == "approved", "重复裁决必须返回现状(单次裁决)"
# 越权防护:他人裁决应拒绝
item2 = tool_approval_manager.create_request(
username="gw_smoke_user", conversation_id=conv_id, task_id=None,
tool_call_id="tc_smoke2", tool_name="write_file", arguments={}, preview={},
)
try:
tool_approval_manager.decide(item2["approval_id"], "other_user", "approved")
except PermissionError:
pass
else:
raise AssertionError("越权裁决必须抛 PermissionError")
def check_fake_exec():
"""第 4 步验收Runtime 工具编排层 + 替身执行器可独立测试(无真实副作用)。
不经模型调用外部依赖非验收对象直接驱动 Runtime 真实工具入口
handle_tool_call验证 E1-E4 分支注入语义
- 替身收到全部调用结果结构被编排层正常消费与真实后端同构
- 全程不起子进程不写真实磁盘替身内存文件系统
"""
import asyncio
from modules.execution_plane import FakeExecutionBackend
from server.context import RuntimeIdentity, get_user_resources
identity = RuntimeIdentity(
host_mode=True, host_workspace_id="gwsmoke", is_api_user=False, role="admin"
)
terminal, workspace = get_user_resources(
"gw_smoke_user", workspace_id="gwsmoke", update_session=False, identity=identity
)
assert terminal is not None and workspace is not None, "资源装配应成功"
backend = FakeExecutionBackend()
terminal.execution_backend = backend
def call(tool, args):
out = asyncio.run(terminal.handle_tool_call(tool, args))
return json.loads(out) if isinstance(out, str) else out
# E1 前台命令:替身接收,不起真实子进程
r = call("run_command", {"command": "echo hello-fake", "timeout": 5})
assert r.get("success"), f"替身 run_command 应成功: {r}"
assert "[fake-exec]" in str(r.get("output", "")), f"输出应来自替身: {r}"
# E2 后台命令:替身登记,不起真实后台线程
r = call("run_command", {"command": "sleep 1", "timeout": 60, "run_in_background": True})
assert r.get("success"), f"替身后台命令应成功: {r}"
assert str(r.get("command_id", "")).startswith("fake_bg_"), f"command_id 应来自替身: {r}"
# E3 文件写(新文件,避开先读后写拦截):替身内存文件系统接收
r = call("write_file", {"file_path": "fake_probe.txt", "content": "line-a\n"})
assert r.get("success"), f"替身 write_file 应成功: {r}"
assert backend.files.get("fake_probe.txt") == "line-a\n", "写入应落替身内存"
assert not (_SMOKE_WORKSPACE / "fake_probe.txt").exists(), "替身路径不得写真实磁盘"
# E4 文件编辑write 后已标记已读guard 放行):替身替换生效
r = call("edit_file", {"file_path": "fake_probe.txt", "replacements": [
{"old_string": "line-a", "new_string": "line-b"}
]})
assert r.get("success"), f"替身 edit_file 应成功: {r}"
assert backend.files.get("fake_probe.txt") == "line-b\n", "编辑应作用于替身内存"
# 调用记录完整E1-E4 各一次)
ops = [c["op"] for c in backend.calls]
assert ops == ["run_command", "run_command_background", "write_file", "edit_file"], ops
# 默认路径回归:新建对话级 terminal 的 execution_backend 应为 None真实链路
cm = terminal.context_manager.conversation_manager
conv_id2 = cm.create_conversation(
project_path=str(workspace.project_path), run_mode="fast",
thinking_mode=False, model_key=None,
)
terminal2, _ws2 = get_user_resources(
"gw_smoke_user", workspace_id="gwsmoke", update_session=False,
conversation_id=conv_id2, identity=identity,
)
assert getattr(terminal2, "execution_backend", "MISSING") is None, "默认应为 None真实链路"
def main():
mode = sys.argv[1] if len(sys.argv) > 1 else ""
if mode == "lifecycle":
check_lifecycle()
elif mode == "chain":
check_chain()
elif mode == "fake_exec":
check_fake_exec()
else:
print("usage: runtime_standalone_checks.py <lifecycle|chain|fake_exec>", file=sys.stderr)
return 2
print(f"CHECK_OK {mode}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@ -177,17 +177,21 @@ class RuntimeServiceAdmissionTest(unittest.TestCase):
rec = self._create() rec = self._create()
task_manager._append_event(rec, "system_message", {"text": "a"}) task_manager._append_event(rec, "system_message", {"text": "a"})
task_manager._append_event(rec, "text_chunk", {"text": "b"}) task_manager._append_event(rec, "text_chunk", {"text": "b"})
events, next_offset, err = runtime_service.get_task_events("tester", rec.task_id, 0) events, next_offset, err, meta = runtime_service.get_task_events("tester", rec.task_id, 0)
self.assertIsNone(err) self.assertIsNone(err)
self.assertEqual([e["idx"] for e in events], [0, 1]) self.assertEqual([e["idx"] for e in events], [0, 1])
self.assertEqual(next_offset, 2) self.assertEqual(next_offset, 2)
# 缺口检测水位:未裁剪时为 0
self.assertEqual(meta, {"window_start": 0})
# offset 续读 # offset 续读
events2, next2, _ = runtime_service.get_task_events("tester", rec.task_id, 1) events2, next2, _, meta2 = runtime_service.get_task_events("tester", rec.task_id, 1)
self.assertEqual([e["idx"] for e in events2], [1]) self.assertEqual([e["idx"] for e in events2], [1])
self.assertEqual(next2, 2) self.assertEqual(next2, 2)
self.assertEqual(meta2["window_start"], 0)
# 无权/不存在 # 无权/不存在
events3, _, err3 = runtime_service.get_task_events("someone_else", rec.task_id, 0) events3, _, err3, meta3 = runtime_service.get_task_events("someone_else", rec.task_id, 0)
self.assertIsNone(events3) self.assertIsNone(events3)
self.assertIsNone(meta3)
self.assertTrue(err3) self.assertTrue(err3)

View File

@ -0,0 +1,63 @@
"""Gateway 第 1/2 步验收:独立任务生命周期 + 极简协议全链路G4/G13
验收标准来自 cache_research/gateway/gateway_current_state.md §7.2
- 进程全程不创建 Flask app不注册蓝图不初始化 SocketIO 服务
- RuntimeService 真实受理create_task task_id任务线程真实启动
- 资源装配真实执行get_user_resources RuntimeIdentity 驱动不重 mock
- 事件流按 idx/offset 协议可读任务可取消门闸最终释放
- 模型调用允许失败外部依赖非验收对象装配与生命周期必须真实
隔离设计真实检查体在 test/runtime_standalone_checks.py由本文件以
**子进程**方式执行原因config/paths.py DATA_DIR / DEPLOY_CONFIG_DIR
import 时固化的模块级常量unittest discover 全量运行时排在前面的测试
模块会先 import config 使常量固化进程内设环境变量已无效被测代码会落到
非隔离目录甚至用户真实运行态目录执行任务子进程内 import 顺序完全可控
隔离ASTRION_DATA_ROOT + DEPLOY_CONFIG_DIR 指向临时目录100% 可靠
单跑与全量行为一致
"""
import subprocess
import sys
import unittest
from pathlib import Path
_CHECKS_SCRIPT = Path(__file__).resolve().parent / "runtime_standalone_checks.py"
_PROJECT_ROOT = Path(__file__).resolve().parents[1]
def _run_check(mode: str) -> str:
proc = subprocess.run(
[sys.executable, str(_CHECKS_SCRIPT), mode],
capture_output=True,
text=True,
cwd=str(_PROJECT_ROOT),
timeout=150,
)
output = f"STDOUT:\n{proc.stdout[-4000:]}\nSTDERR:\n{proc.stderr[-4000:]}"
if proc.returncode != 0:
raise AssertionError(f"独立验收子进程失败 (mode={mode}, rc={proc.returncode}):\n{output}")
return output
class StandaloneRuntimeLifecycleTest(unittest.TestCase):
"""无 Flask app 的独立 Gateway 生命周期验收(子进程执行)。"""
def test_create_observe_cancel_without_web_app(self):
_run_check("lifecycle")
class ProtocolSmokeChainTest(unittest.TestCase):
"""第 2 步验收:极简协议客户端视角的全链路(工作区→会话→运行→停止→历史→审批)。"""
def test_full_chain_run_history_offset_approval(self):
_run_check("chain")
class ExecutionPlaneFakeBackendTest(unittest.TestCase):
"""第 4 步验收Runtime 工具编排层 + 替身执行器可独立测试(无真实副作用)。"""
def test_runtime_with_fake_execution_backend(self):
_run_check("fake_exec")
if __name__ == "__main__":
unittest.main()

View File

@ -14,10 +14,12 @@ POSIX 下 rename 不受打开句柄影响,无此问题。
""" """
from __future__ import annotations from __future__ import annotations
import json
import os import os
import tempfile
import time import time
from pathlib import Path from pathlib import Path
from typing import Union from typing import Any, Dict, Union
# ERROR_ACCESS_DENIED / ERROR_SHARING_VIOLATION # ERROR_ACCESS_DENIED / ERROR_SHARING_VIOLATION
_RETRY_WINERRORS = {5, 32} _RETRY_WINERRORS = {5, 32}
@ -49,3 +51,31 @@ def replace_with_retry(
raise raise
time.sleep(delay) time.sleep(delay)
delay = min(delay * 2, max_delay) delay = min(delay * 2, max_delay)
def atomic_write_json(path: PathLike, data: Dict[str, Any]) -> None:
"""原子写入 JSON 文件:唯一临时文件 + fsync + replace_with_retry。
防止写中断留下半截文件直接 open(path,"w") 覆写的风险
临时文件与目标同目录保证 os.replace 同卷原子性异常时清理临时文件
"""
target = Path(path)
target.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(
prefix=f".{target.name}.", suffix=".tmp", dir=str(target.parent)
)
tmp_path = Path(tmp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as fp:
json.dump(data, fp, ensure_ascii=False, indent=2)
fp.flush()
os.fsync(fp.fileno())
# Windows 瞬时持锁(并发读取/杀软扫描重试POSIX 行为不变
replace_with_retry(tmp_path, target)
finally:
try:
if tmp_path.exists():
tmp_path.unlink()
except Exception:
pass