From d9bd599c1c80b52087aea94563e3f88315453435 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Mon, 7 Sep 2026 23:00:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(runtime):=20Gateway=20=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E4=B8=8E=E5=85=AC=E5=85=B1=E5=8D=8F=E8=AE=AE?= =?UTF-8?q?=E8=90=BD=E5=9C=B0=EF=BC=88=E6=94=B9=E9=80=A0=E7=AC=AC=200-3=20?= =?UTF-8?q?=E6=AD=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 闭环修复:审批 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 项存量 --- .../entry_points_audit.md | 162 ++++++++ .../execution_plane_audit.md | 245 +++++++++++ .../research_summary.md | 388 ++++++++++++++++++ .../state_ownership_audit.md | 231 +++++++++++ .../gateway/gateway_current_state.md | 330 +++++++++++++++ modules/personalization_manager.py | 5 +- server/app_legacy.py | 4 +- server/chat_flow.py | 4 +- server/chat_flow_task_main.py | 84 ++-- server/chat_flow_tool_loop.py | 17 +- server/context/broadcast.py | 9 +- server/context/usage.py | 4 +- server/extensions.py | 30 +- server/runtime/service.py | 80 +++- server/tasks/__init__.py | 21 +- server/tasks/api.py | 23 +- server/tasks/blueprint.py | 9 + server/tasks/helpers.py | 1 - server/tasks/media.py | 1 - server/tasks/models.py | 33 +- server/tasks/skills.py | 2 +- server/tasks/web.py | 19 + test/runtime_standalone_checks.py | 298 ++++++++++++++ test/test_runtime_service.py | 10 +- test/test_runtime_standalone_lifecycle.py | 63 +++ utils/atomic_io.py | 32 +- 26 files changed, 1991 insertions(+), 114 deletions(-) create mode 100644 cache_research/gateway/audit_entry_points_v2/entry_points_audit.md create mode 100644 cache_research/gateway/audit_execution_plane/execution_plane_audit.md create mode 100644 cache_research/gateway/audit_research_summary_v2/research_summary.md create mode 100644 cache_research/gateway/audit_state_ownership_v2/state_ownership_audit.md create mode 100644 cache_research/gateway/gateway_current_state.md create mode 100644 server/tasks/blueprint.py create mode 100644 server/tasks/web.py create mode 100644 test/runtime_standalone_checks.py create mode 100644 test/test_runtime_standalone_lifecycle.py diff --git a/cache_research/gateway/audit_entry_points_v2/entry_points_audit.md b/cache_research/gateway/audit_entry_points_v2/entry_points_audit.md new file mode 100644 index 00000000..dab05b1e --- /dev/null +++ b/cache_research/gateway/audit_entry_points_v2/entry_points_audit.md @@ -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` diff --git a/cache_research/gateway/audit_execution_plane/execution_plane_audit.md b/cache_research/gateway/audit_execution_plane/execution_plane_audit.md new file mode 100644 index 00000000..833cfb54 --- /dev/null +++ b/cache_research/gateway/audit_execution_plane/execution_plane_audit.md @@ -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 / /bin/bash -lc `; +- 只读(`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 python ` | +| 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 定位,未经运行时验证。* \ No newline at end of file diff --git a/cache_research/gateway/audit_research_summary_v2/research_summary.md b/cache_research/gateway/audit_research_summary_v2/research_summary.md new file mode 100644 index 00000000..e698c542 --- /dev/null +++ b/cache_research/gateway/audit_research_summary_v2/research_summary.md @@ -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/`。 diff --git a/cache_research/gateway/audit_state_ownership_v2/state_ownership_audit.md b/cache_research/gateway/audit_state_ownership_v2/state_ownership_audit.md new file mode 100644 index 00000000..ad64e789 --- /dev/null +++ b/cache_research/gateway/audit_state_ownership_v2/state_ownership_audit.md @@ -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//projects//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/`。 diff --git a/cache_research/gateway/gateway_current_state.md b/cache_research/gateway/gateway_current_state.md new file mode 100644 index 00000000..ecf20848 --- /dev/null +++ b/cache_research/gateway/gateway_current_state.md @@ -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 当日代码快照,后续变动以符号/函数名为准。 diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index f8aba060..40ef1f4f 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -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 diff --git a/server/app_legacy.py b/server/app_legacy.py index c688f009..19598dd3 100644 --- a/server/app_legacy.py +++ b/server/app_legacy.py @@ -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) diff --git a/server/chat_flow.py b/server/chat_flow.py index f54df92b..81a855d0 100644 --- a/server/chat_flow.py +++ b/server/chat_flow.py @@ -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, diff --git a/server/chat_flow_task_main.py b/server/chat_flow_task_main.py index b0960765..b8056b9d 100644 --- a/server/chat_flow_task_main.py +++ b/server/chat_flow_task_main.py @@ -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( diff --git a/server/chat_flow_tool_loop.py b/server/chat_flow_tool_loop.py index e2095fe6..4735da08 100644 --- a/server/chat_flow_tool_loop.py +++ b/server/chat_flow_tool_loop.py @@ -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, diff --git a/server/context/broadcast.py b/server/context/broadcast.py index 1eab0561..a549c549 100644 --- a/server/context/broadcast.py +++ b/server/context/broadcast.py @@ -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 diff --git a/server/context/usage.py b/server/context/usage.py index 3ccf10be..9c6e1c04 100644 --- a/server/context/usage.py +++ b/server/context/usage.py @@ -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 diff --git a/server/extensions.py b/server/extensions.py index 5d85cbbf..7f07849b 100644 --- a/server/extensions.py +++ b/server/extensions.py @@ -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"] diff --git a/server/runtime/service.py b/server/runtime/service.py index 30038100..186ebfe0 100644 --- a/server/runtime/service.py +++ b/server/runtime/service.py @@ -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 # 进程级单例(无状态,可安全共享) diff --git a/server/tasks/__init__.py b/server/tasks/__init__.py index c93d7269..6bd98f30 100644 --- a/server/tasks/__init__.py +++ b/server/tasks/__init__.py @@ -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 * diff --git a/server/tasks/api.py b/server/tasks/api.py index 294a6fbd..a06397a6 100644 --- a/server/tasks/api.py +++ b/server/tasks/api.py @@ -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,服务层不再回退读隐式上下文。 diff --git a/server/tasks/blueprint.py b/server/tasks/blueprint.py new file mode 100644 index 00000000..7a958c34 --- /dev/null +++ b/server/tasks/blueprint.py @@ -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__) diff --git a/server/tasks/helpers.py b/server/tasks/helpers.py index 19ea6ca2..9e75ba54 100644 --- a/server/tasks/helpers.py +++ b/server/tasks/helpers.py @@ -1,6 +1,5 @@ """简单任务 API:将聊天任务与 WebSocket 解耦,支持后台运行与轮询。""" from __future__ import annotations -from server.tasks import tasks_bp import mimetypes import json import time diff --git a/server/tasks/media.py b/server/tasks/media.py index f2074aa9..f7bf90d9 100644 --- a/server/tasks/media.py +++ b/server/tasks/media.py @@ -1,6 +1,5 @@ """简单任务 API:将聊天任务与 WebSocket 解耦,支持后台运行与轮询。""" from __future__ import annotations -from server.tasks import tasks_bp import mimetypes import json import time diff --git a/server/tasks/models.py b/server/tasks/models.py index 01e1a8e0..7d612fd8 100644 --- a/server/tasks/models.py +++ b/server/tasks/models.py @@ -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}" diff --git a/server/tasks/skills.py b/server/tasks/skills.py index 44eaa91b..7c7582cf 100644 --- a/server/tasks/skills.py +++ b/server/tasks/skills.py @@ -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 diff --git a/server/tasks/web.py b/server/tasks/web.py new file mode 100644 index 00000000..c80e1f51 --- /dev/null +++ b/server/tasks/web.py @@ -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 diff --git a/test/runtime_standalone_checks.py b/test/runtime_standalone_checks.py new file mode 100644 index 00000000..9d735f20 --- /dev/null +++ b/test/runtime_standalone_checks.py @@ -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 +退出码 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 ", file=sys.stderr) + return 2 + print(f"CHECK_OK {mode}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/test_runtime_service.py b/test/test_runtime_service.py index c395b088..1254af3b 100644 --- a/test/test_runtime_service.py +++ b/test/test_runtime_service.py @@ -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) diff --git a/test/test_runtime_standalone_lifecycle.py b/test/test_runtime_standalone_lifecycle.py new file mode 100644 index 00000000..0f738e28 --- /dev/null +++ b/test/test_runtime_standalone_lifecycle.py @@ -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() diff --git a/utils/atomic_io.py b/utils/atomic_io.py index 8b4596bd..751c3e78 100644 --- a/utils/atomic_io.py +++ b/utils/atomic_io.py @@ -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