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:
parent
b4deee0f43
commit
d9bd599c1c
@ -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.py:RuntimeContext` 由三层分离组成:
|
||||
- `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 快照构造可信 principal(context.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_data;api_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_notices(chat_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`
|
||||
@ -0,0 +1,245 @@
|
||||
# Execution Plane 盘点与耦合点报告(Gateway 化第 4 步前置调研)
|
||||
|
||||
> 版本:v1(2026-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 helper(container_file_proxy.py)
|
||||
```
|
||||
|
||||
**现状核心观察**:工具 handler(Runtime 侧)直接持有三个"执行环境句柄"属性——`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")` 授权范围限制);docker:container 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:进程内替换;docker:proxy |
|
||||
| create_file / delete_file / rename_file / create_folder | `tools_execution.py:1569/1589/1604/1677` | `crud_mixin.py:53/93/133/193` | host:进程内;docker:proxy |
|
||||
| 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.json,S10) |
|
||||
|
||||
> 注:`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`(:315);host:`_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_output(sleep) | `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` | 外部网络 API(Tavily);save 落盘见 B |
|
||||
| todo / memory / conversation_search / manage_personalization | `tools_execution.py:2132/1955/2026/2503` | 内存/运行态文件(TODO 存储在 context_manager,记忆在 memory_manager,personalization 覆写文件) |
|
||||
| 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/回调适配层 sender:tasks/api.py / 通知链(chat_flow_task_main.py:618 dispatch_completion_create_chat_task 完成通知派发)
|
||||
terminal.broadcast / context_manager._web_terminal_callback(shell 输出、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_callback(Web 侧指向 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_callback(web_terminal.py:161);task 运行时被 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)` 恒 None(WebTerminal 全仓无 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:83;chat_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.py),host/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)、:391(evaluate_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 定位,未经运行时验证。*
|
||||
@ -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.md,2026-09-07 修订版)
|
||||
|
||||
**总体目标**:让 Astrion 的内部职责、状态修改规则和任务入口更清晰,使后续功能沿稳定边界扩展。近期明确新功能场景 = **定时任务**。长期方向是 Gateway(Runtime/Gateway),近期交付是在**现有进程内**整理运行时服务边界,让 Web、CLI 和定时触发器复用同一套任务受理/执行/控制逻辑。验收依据 = 新增入口所需理解和修改的范围 + 现有行为是否保留。
|
||||
|
||||
**近期主线(三件事)**:
|
||||
1. 固定已有状态边界和正确性保障。
|
||||
2. 抽出接收显式运行上下文的公共任务入口。
|
||||
3. 通过定时任务验证该入口,并补齐调度所需的持久记录和生命周期。
|
||||
|
||||
**非本次前置条件**:事件持久化、传输替换、公开 SDK、设备配对和远程执行,分别评估。
|
||||
|
||||
**参考资料边界(work plan 原文)**:
|
||||
- 原始愿景 `.astrion/user_upload/Astrion_Architecture_Product_Roadmap_Review_1.md`(重点 §10–14、§35、§37–38)。
|
||||
- 原始盘点 `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=现有 conversation,Run=一轮主任务,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:794);has_request_context ~17 处全在 context.py;B 类(必须消除)集中在 2 文件:tasks/models.py(session 15 行)+ context.py(session 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_handlers(flask-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 POST(Bearer) | 低(无 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 修订)
|
||||
总体判定:**事件/取消/保存三条可直接复用(需适配层);审批链路需改造**。
|
||||
- **事件链路:直接复用**。载体 TaskRecord(deque maxlen=20000 + 每任务 idx),调用方只需 create_chat_task 拿 task_id 即可轮询/取消;socket 推送按 `user_{username}` 房间(离线无影响,但用户在线会收到定时任务事件 → **前台干扰**,需加 source 字段或确认产品预期)。适配点:消除 test_request_context、定义定时事件来源标识。
|
||||
- **取消链路:直接复用**。寻址=username+task_id(stop_flags 任务级 key=task_id=client_sid),不依赖 terminal(conversation 仅副作用);保留"独立事件循环 + entry 持 loop/task"模式以支持硬取消;公共入口须把 task_id 持久化到 Occurrence。
|
||||
- **保存链路:直接复用,前提明确**。写入口收敛于 `context_manager.add_conversation`(每消息 auto_save);merge-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 删仍被等待的 pending;task_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(缺失即 ValueError,i18n key `tasks.missing_session_data`)。
|
||||
- 6 处调用点全部迁移到 `runtime_service.create_task()`:`server/tasks/api.py`、`server/api_v1.py`、`server/workflow_runtime_api.py`×2、`server/chat_flow_task_main.py`×2(完成通知派发 :613、多智能体 idle 派发 :1416)。门闸 token 移交、`task_type="notice"` 互斥豁免语义原样保留。
|
||||
- `server/context.py`(989 行)拆分为 `server/context/` 子包:identity / broadcast / personalization / usage / upload / conversation / resources / decorators / reaper 共 9 模块 + `__init__.py` 兼容 re-export(外部 import 路径不变)。
|
||||
- `get_user_resources` 参数化:新增 `RuntimeIdentity` 显式身份快照(定义于 `server/context/identity.py`,runtime 包引用,依赖方向自下而上无循环)。
|
||||
- **test_request_context 桥已拆除**:`server/tasks/models.py::_run_chat_task` 不再建立 Flask 请求上下文,任务线程全程 RuntimeIdentity 驱动。
|
||||
- 审批超时透传管道:terminal 属性 `_approval_timeout_seconds`(默认语义 3600s 不变),`_run_chat_task` setattr → `server/chat_flow_tool_loop.py::_approval_timeout_for()` helper → 4 个 `_wait_*` 调用点。
|
||||
- 附带修复:`config/_load_dotenv` 对禁读 `.env` 的沙箱环境加 try/except(不再 PermissionError 崩溃)。
|
||||
|
||||
### 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 个蓝图 REST(tasks_bp 轮询主线)+ 1 个 Socket.IO 辅助通道;消息发送与进度事件已收敛到 POST/GET /api/tasks 轮询模型;socket `send_message` 已废弃。
|
||||
2. **事件通道**:单一 sender 抽象(写入事件流 + socket 推送合并);per-task idx、无全局序号;事件流是任务作用域 + 内存 + 1h TTL(deque 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-task,socket 事件不带 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 只走 socket;token_update/todo_updated/edited_files_updated 全局广播不一定进任务事件流)。
|
||||
12. **认证两套并存**:session+CSRF(Web/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-task(models.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 + expectedRevision,patch/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 §遗留待办 2;eval_summary §4;support_chains §2.3 A3;R6 重申)
|
||||
- G3. **工作区改动未 commit**。(phase12 状态;项目记忆)
|
||||
- G4. **存量测试失败 4 项**(conversation_workspace_storage / host_workspace_manager / skills_manager / token_usage_extractor),甄别与本次改动零相关、未修。(phase12 §测试验收)
|
||||
- G5. **socket 软 stop 不打断审批等待**(REST 硬取消可以),留待阶段三或独立决策。(phase12 §遗留待办 3;support_chains §2.4.3;eval_summary §4)
|
||||
- G6. 阶段二未加事件 source 字段(前台干扰为阶段三产品决策)。(phase12 §风险对策 5)
|
||||
|
||||
### 来自 work plan(gateway_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.4;eval_summary R5)
|
||||
- G12. **审批 manager 条目无 TTL/清理**,未决条目永久留在内存;需先定义超时/取消终态与迟到回答处理再设保留期。(support_chains §2.4.4;R6)
|
||||
- G13. **事件前台干扰**:定时任务事件会推给在线用户 socket 房间,需加 source 或确认产品预期。(support_chains §1.5;eval_summary §3.5)
|
||||
- G14. **执行链异常回退路径**(chat_flow_task_main.py:645-675)绕过 TaskRecord 登记,迁移验收必须覆盖其记录/事件/取消/门闸生命周期。(R3;task_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 R4;flask_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/`。
|
||||
@ -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-172,notice 豁免);`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` 按 offset;idx 单调分配;`_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_id(REST 即 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 审批/提问条目 —— ✅ 一致(含契约已标注的缺口/疑点,均被代码证实)
|
||||
|
||||
**声明**:三个内存 manager(tool/plan/user_question);键=approval_id/question_id;list 按 username+conversation_id;执行链 create;REST 置终态(锁内单次裁决);无持久化、无 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**:审批条目无法关联到具体 Run(task_id 字段形同虚设),影响「审计/对账/阶段三超时语义」定位到任务。
|
||||
|
||||
**对「Gateway 状态唯一 Owner」判断的关键输入**:当前每一类状态在**单一进程内**基本都有唯一 owner 与互斥;但 S2/S3/S6 是进程内内存态(无跨进程 owner),S5/S10 存在分散/非原子写点。若 Gateway 化目标要求「跨进程/重启后状态唯一」,则内存态三大块(S2/S3/S6)与 S5 全局仍是障碍;若仅要求「进程内单写者」已达度较高。S4 回退路径与 S6 task_id 归属属需先闭环的两个具体疑点。
|
||||
|
||||
---
|
||||
|
||||
## 附:审计方法与人眼注意
|
||||
|
||||
- 全程只读,未修改任何项目文件。
|
||||
- 静态结论均来自代码阅读;「有很大概率」的推断(S4 回退释放、S6 task_id 恒 None、socket 软 stop 不打断)明确标注,未以运行验证冒充确定结论。
|
||||
- 交付目录:`cache_research/gateway/audit_state_ownership_v2/`。
|
||||
330
cache_research/gateway/gateway_current_state.md
Normal file
330
cache_research/gateway/gateway_current_state.md
Normal 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 state;Clients 只是 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 已有(TaskRecord,pending/running/终态) | **未采用 Turn 命名本身不是架构缺口**;真正要做的是主 Run、子智能体任务、后台执行的**映射关系明确**,不能只按名称合并 |
|
||||
| Item(UI 可见事件统一单元) | 事件类型已事实存在(assistant/tool_call/approval...) | Item 的稳定标识、内容更新与生命周期(started/delta/completed)未定义;应**逐步适配现有事件**,避免同时重写所有前端渲染 |
|
||||
| Event(Server→Client 通知) | task 级 idx/offset 协议 + socket 推送已有 | 序号作用域、事件覆盖范围、授权订阅、缺口检测、快照水位与续传衔接未定义(详见 §7 第 3 步) |
|
||||
|
||||
公共协议还需明确(当前均未定义):**Commands、Queries、Events、授权范围、错误码、请求重试与兼容版本策略**——不能只列实体字段。
|
||||
|
||||
---
|
||||
|
||||
## 2. 当前进度总览
|
||||
|
||||
### 2.1 三阶段路线状态(gateway_work_plan.md,2026-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 的进程想使用 RuntimeService,import 时仍会拉起整条 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 | ✅ | 唯一 TaskManager;chat 互斥 + 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` 模块级 dict;REST 硬取消 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 §4(research_summary.md §1 全量保留);本轮路线重排后**后置**(§7.6)。
|
||||
- **后续独立决策**(有明确需求再启动):SSE/WS 传输替换、对话级订阅、durable 事件与快照恢复、审批持久化、TS/SDK 生成、身份体系整理(不能直接合并 Web/API 用户数据空间)、设备配对/Remote Worker。
|
||||
- 存量测试失败 4 项(与改造无关,未修)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 后续路线(按 G9 重排为五步 + 前置第 0 步)
|
||||
|
||||
> 总原则:协议草案与接口适配可迭代推进;**每一步保留现有门闸、保存和恢复保障**;四层不要求四进程(G13);Remote 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`(§38):Entities/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 专属流程而非 HTTP;stdio 防独立 Runtime 陷阱;极简客户端提前验收 | §7.3 |
|
||||
| G13 完成判据 | 行为验收六条;测试须覆盖真实执行装配 | §7.7、§2.1 |
|
||||
|
||||
> 注:本文所有「文件:行号」证据为 2026-09-07 当日代码快照,后续变动以符号/函数名为准。
|
||||
@ -14,6 +14,7 @@ except ImportError:
|
||||
|
||||
from core.tool_config import TOOL_CATEGORIES
|
||||
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
|
||||
|
||||
@ -845,8 +846,8 @@ def save_personalization_config(base_dir: PathLike, payload: Dict[str, Any]) ->
|
||||
validate_context_compression_settings(config)
|
||||
path = _to_path(base_dir)
|
||||
_ensure_parent(path)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, ensure_ascii=False, indent=2)
|
||||
# 原子写:防止写中断留下半截 JSON(与对话保存链路的原子替换对齐,S10 修复)
|
||||
atomic_write_json(path, config)
|
||||
_sync_ui_locale(config)
|
||||
return config
|
||||
|
||||
|
||||
@ -34,7 +34,7 @@ from server.conversation import conversation_bp
|
||||
from server.chat import chat_bp
|
||||
from server.usage import usage_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.multi_agent import multi_agent_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(usage_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(multi_agent_bp)
|
||||
app.register_blueprint(workflow_page_bp)
|
||||
|
||||
@ -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 .main_task_gate import acquire_adopted_main_task_gate, release_main_task_gate
|
||||
from .monitor import cache_monitor_snapshot, get_cached_monitor_snapshot
|
||||
from .extensions import socketio
|
||||
from .extensions import socketio, run_background
|
||||
from .state import (
|
||||
MONITOR_FILE_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):
|
||||
"""在线程模式下启动对话任务,供 Socket 事件调用。"""
|
||||
return socketio.start_background_task(
|
||||
return run_background(
|
||||
process_message_task,
|
||||
terminal,
|
||||
message,
|
||||
|
||||
@ -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 .main_task_gate import try_acquire_main_task_gate, release_main_task_gate
|
||||
from .monitor import cache_monitor_snapshot, get_cached_monitor_snapshot
|
||||
from .extensions import socketio
|
||||
from .extensions import emit_event, run_background
|
||||
from .state import (
|
||||
MONITOR_FILE_TOOLS,
|
||||
MONITOR_MEMORY_TOOLS,
|
||||
@ -640,36 +640,43 @@ async def _dispatch_completion_user_notice(
|
||||
except Exception as e:
|
||||
debug_log(f"[CompletionNotice] 创建后台消息任务失败,回退直接执行: {e}")
|
||||
|
||||
payload = {
|
||||
'message': user_message,
|
||||
'conversation_id': conversation_id,
|
||||
'message_source': message_source,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
}
|
||||
payload.update(_user_message_ui_defaults(message_source, auto_user_message_event=True))
|
||||
payload.update(extra_payload)
|
||||
payload["metadata"] = {
|
||||
"message_source": message_source,
|
||||
"visibility": payload.get("visibility"),
|
||||
"starts_work": payload.get("starts_work"),
|
||||
**(payload.get("metadata") or {}),
|
||||
}
|
||||
sender('user_message', payload)
|
||||
# 回退路径没有任务线程(未登记 TaskRecord),轮询器预占的门闸 token 无法随任务
|
||||
# 移交释放;必须在本函数内随回退执行结束释放,否则对话门闸会被长期占用,
|
||||
# 后续通知派发将恒被 try_acquire_main_task_gate 拒绝(2026-09-07 N1 修复)。
|
||||
# release_main_task_gate 对 None 或不匹配的 token 为无操作,可安全调用。
|
||||
try:
|
||||
task_handle = asyncio.create_task(handle_task_with_sender(
|
||||
terminal=web_terminal,
|
||||
workspace=workspace,
|
||||
message=user_message,
|
||||
images=[],
|
||||
sender=sender,
|
||||
client_sid=client_sid,
|
||||
username=username,
|
||||
videos=[],
|
||||
auto_user_message_event=True,
|
||||
))
|
||||
await task_handle
|
||||
except Exception as inner_exc:
|
||||
debug_log(f"[CompletionNotice] 回退处理 user_message 失败: {inner_exc}")
|
||||
payload = {
|
||||
'message': user_message,
|
||||
'conversation_id': conversation_id,
|
||||
'message_source': message_source,
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
}
|
||||
payload.update(_user_message_ui_defaults(message_source, auto_user_message_event=True))
|
||||
payload.update(extra_payload)
|
||||
payload["metadata"] = {
|
||||
"message_source": message_source,
|
||||
"visibility": payload.get("visibility"),
|
||||
"starts_work": payload.get("starts_work"),
|
||||
**(payload.get("metadata") or {}),
|
||||
}
|
||||
sender('user_message', payload)
|
||||
try:
|
||||
task_handle = asyncio.create_task(handle_task_with_sender(
|
||||
terminal=web_terminal,
|
||||
workspace=workspace,
|
||||
message=user_message,
|
||||
images=[],
|
||||
sender=sender,
|
||||
client_sid=client_sid,
|
||||
username=username,
|
||||
videos=[],
|
||||
auto_user_message_event=True,
|
||||
))
|
||||
await task_handle
|
||||
except Exception as 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]:
|
||||
@ -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)
|
||||
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):
|
||||
try:
|
||||
socketio.emit(event_type, data, room=f"user_{username}")
|
||||
emit_event(event_type, data, room=f"user_{username}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@ -1108,14 +1115,14 @@ async def poll_multi_agent_notifications(*, web_terminal, workspace, conversatio
|
||||
与 poll_completion_notifications 完全分离,避免多智能体消息和传统后台
|
||||
通知竞争 task_manager 的单工作区互斥。
|
||||
"""
|
||||
from .extensions import socketio
|
||||
from .extensions import emit_event
|
||||
|
||||
max_wait_time = 3600
|
||||
start_wait = time.time()
|
||||
|
||||
def sender(event_type, data):
|
||||
try:
|
||||
socketio.emit(event_type, data, room=f"user_{username}")
|
||||
emit_event(event_type, data, room=f"user_{username}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@ -1478,7 +1485,6 @@ async def handle_task_with_sender(
|
||||
files=None,
|
||||
):
|
||||
"""处理任务并发送消息 - 集成token统计版本"""
|
||||
from .extensions import socketio
|
||||
|
||||
web_terminal = terminal
|
||||
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:
|
||||
conv_id = getattr(web_terminal.context_manager, "current_conversation_id", None)
|
||||
socketio.start_background_task(
|
||||
run_background(
|
||||
generate_conversation_title_background,
|
||||
web_terminal,
|
||||
conv_id,
|
||||
@ -2164,7 +2170,7 @@ async def handle_task_with_sender(
|
||||
quota_allowed, quota_info = web_terminal.record_model_call(bool(thinking_expected))
|
||||
if not quota_allowed:
|
||||
quota_type = 'thinking' if thinking_expected else 'fast'
|
||||
socketio.emit('quota_notice', {
|
||||
emit_event('quota_notice', {
|
||||
'type': quota_type,
|
||||
'reset_at': quota_info.get('reset_at'),
|
||||
'limit': quota_info.get('limit'),
|
||||
@ -2630,7 +2636,7 @@ async def handle_task_with_sender(
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
socketio.start_background_task(run_completion_poll)
|
||||
run_background(run_completion_poll)
|
||||
|
||||
# 多智能体模式独立通知池:处理 running 实例 / idle 实例待消费的 pending 消息。
|
||||
# 与传统后台任务完全分离,避免两者竞争 create_chat_task 的单工作区互斥。
|
||||
@ -2682,7 +2688,7 @@ async def handle_task_with_sender(
|
||||
loop.close()
|
||||
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:
|
||||
ma_debug(
|
||||
|
||||
@ -369,7 +369,7 @@ async def _handle_workflow_tool(*, function_name: str, web_terminal, arguments,
|
||||
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 工具的计划批准流:弹窗展示计划文档 → 用户批准/拒绝 →
|
||||
批准则自动切换运行模式到 execute(联动恢复权限)并更新运行期模式基线。"""
|
||||
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(
|
||||
username=username,
|
||||
conversation_id=conversation_id,
|
||||
task_id=getattr(web_terminal, "task_id", None),
|
||||
task_id=task_id,
|
||||
tool_call_id=tool_call_id,
|
||||
plan_file=plan_file,
|
||||
plan_content=plan_content,
|
||||
@ -475,8 +475,8 @@ async def _handle_submit_plan(*, web_terminal, arguments: Dict[str, Any], sender
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from .extensions import socketio
|
||||
socketio.emit('status_update', web_terminal.get_status(), room=f"user_{username}")
|
||||
from .extensions import emit_event
|
||||
emit_event('status_update', web_terminal.get_status(), room=f"user_{username}")
|
||||
except Exception:
|
||||
pass
|
||||
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(
|
||||
username=username,
|
||||
conversation_id=conversation_id,
|
||||
task_id=getattr(web_terminal, "task_id", None),
|
||||
task_id=client_sid,
|
||||
tool_call_id=tool_call.get("id"),
|
||||
question=arguments.get("question"),
|
||||
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(
|
||||
username=username,
|
||||
conversation_id=conversation_id,
|
||||
task_id=getattr(web_terminal, "task_id", None),
|
||||
task_id=client_sid,
|
||||
tool_call_id=tool_call_id,
|
||||
tool_name=function_name,
|
||||
arguments=arguments,
|
||||
@ -873,6 +873,8 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages
|
||||
'approval': approval_item,
|
||||
'conversation_id': conversation_id,
|
||||
})
|
||||
|
||||
if permission_eval.get("requires_approval"):
|
||||
sender('update_action', {
|
||||
'preparing_id': tool_call_id,
|
||||
'status': 'awaiting_approval',
|
||||
@ -1019,6 +1021,7 @@ async def _execute_tool_calls_impl(*, web_terminal, tool_calls, sender, messages
|
||||
username=username,
|
||||
conversation_id=conversation_id,
|
||||
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"):
|
||||
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(
|
||||
username=username,
|
||||
conversation_id=conversation_id,
|
||||
task_id=getattr(web_terminal, "task_id", None),
|
||||
task_id=client_sid,
|
||||
tool_call_id=tool_call_id,
|
||||
tool_name=function_name,
|
||||
arguments=arguments,
|
||||
|
||||
@ -8,13 +8,10 @@ from server.utils_common import debug_log
|
||||
|
||||
|
||||
def make_terminal_callback(username: str):
|
||||
"""生成面向指定用户的广播函数"""
|
||||
from server.extensions import socketio
|
||||
"""生成面向指定用户的广播函数(独立 Gateway 进程下 socket 未初始化时静默跳过)"""
|
||||
from server.extensions import emit_event
|
||||
def _callback(event_type, data):
|
||||
try:
|
||||
socketio.emit(event_type, data, room=f"user_{username}")
|
||||
except Exception as exc:
|
||||
debug_log(f"广播事件失败 ({username}): {event_type} - {exc}")
|
||||
emit_event(event_type, data, room=f"user_{username}")
|
||||
return _callback
|
||||
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ def get_or_create_usage_tracker(username: Optional[str], workspace: Optional['mo
|
||||
|
||||
|
||||
def emit_user_quota_update(username: Optional[str]):
|
||||
from server.extensions import socketio
|
||||
from server.extensions import emit_event
|
||||
if not username:
|
||||
return
|
||||
tracker = get_or_create_usage_tracker(username)
|
||||
@ -35,6 +35,6 @@ def emit_user_quota_update(username: Optional[str]):
|
||||
return
|
||||
try:
|
||||
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:
|
||||
pass
|
||||
|
||||
@ -1,7 +1,35 @@
|
||||
"""Flask/SocketIO 扩展实例。"""
|
||||
import threading
|
||||
|
||||
from flask_socketio import SocketIO
|
||||
|
||||
# 统一的 SocketIO 实例,使用线程模式以兼容现有逻辑
|
||||
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"]
|
||||
|
||||
@ -35,12 +35,18 @@ class RuntimeService:
|
||||
from server.tasks import task_manager
|
||||
|
||||
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(
|
||||
ctx.principal.username,
|
||||
ctx.principal.workspace_id,
|
||||
params.message,
|
||||
list(params.images or []),
|
||||
params.conversation_id,
|
||||
conversation_id,
|
||||
videos=list(params.videos or []),
|
||||
model_key=params.model_key,
|
||||
thinking_mode=params.thinking_mode,
|
||||
@ -54,6 +60,61 @@ class RuntimeService:
|
||||
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:
|
||||
@ -95,19 +156,24 @@ class RuntimeService:
|
||||
|
||||
def get_task_events(
|
||||
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 轮询同一语义)。
|
||||
|
||||
返回 (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
|
||||
|
||||
rec = task_manager.get_task(username, task_id)
|
||||
if not rec:
|
||||
return None, None, tr("tasks.task_not_found")
|
||||
events = task_manager.get_events_since(rec, max(0, int(offset or 0)))
|
||||
next_offset = events[-1]["idx"] + 1 if events else max(0, int(offset or 0))
|
||||
return events, next_offset, None
|
||||
return None, None, tr("tasks.task_not_found"), None
|
||||
offset = max(0, int(offset or 0))
|
||||
events = task_manager.get_events_since(rec, offset)
|
||||
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
|
||||
|
||||
|
||||
# 进程级单例(无状态,可安全共享)
|
||||
|
||||
@ -1,14 +1,13 @@
|
||||
# server/tasks/__init__.py - tasks_bp 兼容入口
|
||||
from flask import Blueprint
|
||||
|
||||
tasks_bp = Blueprint("tasks", __name__)
|
||||
|
||||
# server/tasks/__init__.py - 任务核心层(无 Web 依赖,可独立加载)
|
||||
#
|
||||
# Gateway 化解耦(2026-09-07,G4):本包 __init__ 只导出任务核心层
|
||||
# (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.skills import *
|
||||
from server.tasks.helpers import *
|
||||
from server.tasks.media import *
|
||||
from server.tasks.models import TaskManager
|
||||
|
||||
# 显式导出单例,便于旧代码导入;必须在 api 之前定义,api 会引用它
|
||||
# 显式导出单例,便于旧代码导入
|
||||
task_manager = TaskManager()
|
||||
|
||||
from server.tasks.api import *
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"""简单任务 API:将聊天任务与 WebSocket 解耦,支持后台运行与轮询。"""
|
||||
from __future__ import annotations
|
||||
from server.tasks import tasks_bp
|
||||
from server.tasks.blueprint import tasks_bp
|
||||
import mimetypes
|
||||
import json
|
||||
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.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.state import stop_flags
|
||||
from server.utils_common import debug_log, log_conn_diag
|
||||
@ -178,24 +177,8 @@ def create_task_api():
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 对话级隔离兜底:chat 任务必须落在对话级 terminal 上。
|
||||
# 前端正常先 POST /api/conversations 拿 cid 再建任务;直接调 API 未带
|
||||
# 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}")
|
||||
# 对话级隔离兜底已下沉至 RuntimeService.create_task(_ensure_conversation_for_chat):
|
||||
# chat 任务未携带 conversation_id 时由服务层补建对话文件,适配层不再重复实现。
|
||||
|
||||
# 公共任务入口(契约 docs/runtime_contract.md §4):适配层在完成认证后
|
||||
# 从 Flask session 显式构造可信 principal,服务层不再回退读隐式上下文。
|
||||
|
||||
9
server/tasks/blueprint.py
Normal file
9
server/tasks/blueprint.py
Normal 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__)
|
||||
@ -1,6 +1,5 @@
|
||||
"""简单任务 API:将聊天任务与 WebSocket 解耦,支持后台运行与轮询。"""
|
||||
from __future__ import annotations
|
||||
from server.tasks import tasks_bp
|
||||
import mimetypes
|
||||
import json
|
||||
import time
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
"""简单任务 API:将聊天任务与 WebSocket 解耦,支持后台运行与轮询。"""
|
||||
from __future__ import annotations
|
||||
from server.tasks import tasks_bp
|
||||
import mimetypes
|
||||
import json
|
||||
import time
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
"""简单任务 API:将聊天任务与 WebSocket 解耦,支持后台运行与轮询。"""
|
||||
from __future__ import annotations
|
||||
from server.tasks import tasks_bp
|
||||
import mimetypes
|
||||
import json
|
||||
import time
|
||||
@ -11,8 +10,10 @@ from collections import deque
|
||||
from pathlib import Path
|
||||
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.chat_flow import run_chat_task_sync
|
||||
from server.main_task_gate import release_main_task_gate
|
||||
from server.work_timer import finalize_conversation_work_timer
|
||||
from server.state import stop_flags
|
||||
@ -208,6 +209,16 @@ class TaskManager:
|
||||
snapshot = list(rec.events)
|
||||
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]:
|
||||
with self._lock:
|
||||
return [
|
||||
@ -903,10 +914,11 @@ class TaskManager:
|
||||
data.setdefault("workspace_id", rec.workspace_id)
|
||||
# 记录事件
|
||||
self._append_event(rec, event_type, data)
|
||||
# 在线用户仍然收到实时推送(房间 user_{username})
|
||||
# 在线用户仍然收到实时推送(房间 user_{username});
|
||||
# 安全包装:socketio 未绑定(独立 Gateway 进程)时静默跳过
|
||||
try:
|
||||
from server.extensions import socketio
|
||||
socketio.emit(event_type, data, room=f"user_{username}")
|
||||
from server.extensions import emit_event
|
||||
emit_event(event_type, data, room=f"user_{username}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@ -972,6 +984,10 @@ class TaskManager:
|
||||
previous_skill_context_messages = None
|
||||
|
||||
try:
|
||||
# 延迟导入:消除任务核心层对 Web 路由层(server/chat_flow.py)的静态依赖,
|
||||
# 使本模块可在无 Web 应用初始化的进程中加载(Gateway 独立启动前提)。
|
||||
from server.chat_flow import run_chat_task_sync
|
||||
|
||||
run_chat_task_sync(
|
||||
terminal=terminal,
|
||||
message=rec.message,
|
||||
@ -1027,7 +1043,7 @@ class TaskManager:
|
||||
)
|
||||
# 统一发送 task_stopped,携带后台任务状态
|
||||
try:
|
||||
from server.extensions import socketio
|
||||
from server.extensions import emit_event
|
||||
stopped_payload = {
|
||||
'message': tr("task_main.task_stopped"),
|
||||
'reason': 'user_requested',
|
||||
@ -1036,9 +1052,10 @@ class TaskManager:
|
||||
'has_running_sub_agents': bg_state["has_running_sub_agents"],
|
||||
'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)
|
||||
emit_event('task_stopped', stopped_payload, room=f"user_{rec.username}")
|
||||
debug_log(
|
||||
f"[TaskRun] 已发送 task_stopped: task_id={rec.task_id}, "
|
||||
f"has_bg={has_bg}, room=user_{rec.username}"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"""Workspace skill 读取与 /api/skills 接口。"""
|
||||
from __future__ import annotations
|
||||
from server.tasks import tasks_bp
|
||||
from server.tasks.blueprint import tasks_bp
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List
|
||||
|
||||
19
server/tasks/web.py
Normal file
19
server/tasks/web.py
Normal 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
|
||||
298
test/runtime_standalone_checks.py
Normal file
298
test/runtime_standalone_checks.py
Normal 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())
|
||||
@ -177,17 +177,21 @@ class RuntimeServiceAdmissionTest(unittest.TestCase):
|
||||
rec = self._create()
|
||||
task_manager._append_event(rec, "system_message", {"text": "a"})
|
||||
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.assertEqual([e["idx"] for e in events], [0, 1])
|
||||
self.assertEqual(next_offset, 2)
|
||||
# 缺口检测水位:未裁剪时为 0
|
||||
self.assertEqual(meta, {"window_start": 0})
|
||||
# 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(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(meta3)
|
||||
self.assertTrue(err3)
|
||||
|
||||
|
||||
|
||||
63
test/test_runtime_standalone_lifecycle.py
Normal file
63
test/test_runtime_standalone_lifecycle.py
Normal 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()
|
||||
@ -14,10 +14,12 @@ POSIX 下 rename 不受打开句柄影响,无此问题。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
from typing import Any, Dict, Union
|
||||
|
||||
# ERROR_ACCESS_DENIED / ERROR_SHARING_VIOLATION
|
||||
_RETRY_WINERRORS = {5, 32}
|
||||
@ -49,3 +51,31 @@ def replace_with_retry(
|
||||
raise
|
||||
time.sleep(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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user