feat(runtime): 审批/提问纳入公共入口,①②③链路收口

- RuntimeService 新增 list_pending_approvals(三类分桶)与 resolve_approval
  (kind 路由 tool/plan/question,透传 manager 单次裁决/越权/参数校验语义)
- 协议能力 run.*/approval.*/session.* 全部有 RuntimeService 公共方法
- 现状文档记录本轮收口决策:①②③贯通为终点,③↔④全量贯通后置(§7.5-补四件事)
- 全量回归:失败恰为 4 项存量,与改造无关
This commit is contained in:
JOJO 2026-09-07 23:34:42 +08:00
parent 4378eb6a46
commit d7cb854f0c
3 changed files with 102 additions and 11 deletions

View File

@ -246,16 +246,18 @@ v1 原文把「内存态」直接判为「状态唯一 Owner 的障碍」,混
> 总原则:协议草案与接口适配可迭代推进;**每一步保留现有门闸、保存和恢复保障**四层不要求四进程G13Remote Worker 不是本轮完成条件。
> **实施状态速览2026-09-07commit d9bd599c / 27aeed70**
> **实施状态速览2026-09-07commit d9bd599c / 27aeed70 / 4378eb6a 起**
> | 步骤 | 状态 | 证据 |
> |---|---|---|
> | 第 0 步 | ✅ 代码侧完成N3 人工验证待用户) | §6.1 状态注记 |
> | 第 1 步 | ✅ | `StandaloneRuntimeLifecycleTest``server/tasks` 拆解emit_event/run_background 安全包装 |
> | 第 2 步 | ✅ | `docs/runtime_protocol.md``ProtocolSmokeChainTest` 全链路(含服务层补建对话) |
> | 第 2 步 | ✅ | `docs/runtime_protocol.md``ProtocolSmokeChainTest` 全链路(含服务层补建对话、审批公共入口路由 |
> | 第 3 步 | ✅ | 协议 §5.2-5.4(序号作用域/window_start 缺口检测/重启三场景/多端共享);`get_task_events` meta 水位 |
> | 第 4 步 | ✅ 首版E1-E4 面) | `modules/execution_plane` + `docs/execution_contract.md` + `ExecutionPlaneFakeBackendTest`E5-E10 与 Host/Docker 同契约接入为后续项 |
> | 第 5 步 | ✅ 收窄完成(代码就绪度) | 公共入口覆盖清单run.*/approval.*/session.* 全绿(协议 §3/§4正式客户端开发不在本轮 |
> | 遗留 | ⬜ | N3 真实环境人工验证、N4/N5 疑点、E5-E10 纳入、定时任务§7.6 后置) |
> | 第 4 步 | ✅ 首版E1-E4 面) | `modules/execution_plane` + `docs/execution_contract.md` + `ExecutionPlaneFakeBackendTest`E5-E10 与 Host/Docker 同契约接入为后续项§7.5-补) |
> | 第 5 步 | ✅ 收窄完成(代码就绪度) | 公共入口覆盖run.*/approval.*/session.* 全部进入 RuntimeService协议 §3/§4正式客户端开发不在本轮 |
> | 遗留 | ⬜ | N3 真实环境人工验证、N4/N5 疑点、③↔④ 全量贯通§7.5-补、定时任务§7.6 后置) |
>
> **本轮收口决策2026-09-07 用户拍板)**:①②③ 链路Client ↔ Gateway ↔ Runtime贯通即为本轮终点③↔④ 全量贯通Host/Docker 迁入 ExecutionBackend 契约、E5-E10 纳入)后置为独立工作,期间默认路径 `execution_backend=None`(现有真实链路不变)。
### 7.1 第 0 步:闭环疑点与小修复(不依赖架构决策)
@ -289,6 +291,18 @@ v1 原文把「内存态」直接判为「状态唯一 Owner 的障碍」,混
- 定义 Execution Contract相同 Runtime 可接测试执行器(替身)及现有 Host/Docker 实现;执行链无需了解 Web 会话与客户端连接。
- 验收Runtime + 替身执行器可独立测试Host/Docker 后端经同一契约接入。
#### 7.5-补 ③↔④ 全量贯通工作分解2026-09-07 定,后置独立工作)
> 首版已完成:契约文档 + ExecutionBackend 协议E1-E4+ 替身执行器 + 接入点
> `MainTerminal.execution_backend`,默认 None。以下为「Host/Docker 迁入契约」的四件事:
1. **接口面补全 E5-E10**:文件读三模式 / 文件 CRUD 族 / 持久终端 6 操作 / path_validate / 执行环境快照 / 命令校验。照搬现状语义(返回结构、截断、错误形态),不发明新能力。主改 `modules/execution_plane/base.py`
2. **真实适配器 HostDockerBackend**:新实现类内部委托现有 terminal_ops / file_manager / terminal_manager / background_command_manager。**决策点**薄适配纯转发快、风险小4 处后端选择复制的结构债留着vs 厚适配(顺手收敛 terminal_ops/run.py、background_command_manager.py、persistent_terminal/start.py、container_file_proxy.py 的后端选择逻辑,根治但动四个核心执行文件)。**既定方向:先薄适配**。
3. **编排层全面切换**tools_execution.py 给 E5-E10 分支插 backend 注入(约 6-10 处);默认装配由 None 改为 HostDockerBackend 实例。**风险点是行为等价**:权限裁决/先读后写/浅备份/编辑摘要/字符截断留在编排层,执行细节归后端,边界必须切干净。
4. **验收**:替身测试保持绿 + 全量回归 + N3 真实环境人工验证一并做。
完成后收益Runtime 对执行环境零直接依赖换后端Remote Worker、子智能体独立沙箱只需换实现不碰智能体循环。
### 7.6 第 5 步:正式客户端接入 + 定时任务后置接入
- Web 与正式 CLI/GUI/IDE 接入同一业务服务与协议。

View File

@ -147,6 +147,66 @@ class RuntimeService:
return task_manager.promote_runtime_pending_to_guidance(username, task_id, message_id)
# ---- 审批/提问(公共入口;三类 manager 的薄路由,裁决语义由 manager 保证)----
_APPROVAL_KINDS = ("tool", "plan", "question")
def list_pending_approvals(
self, username: str, conversation_id: Optional[str] = None, *, kind: Optional[str] = None
) -> Dict[str, List[Dict[str, Any]]]:
"""待决审批/提问合并查询approval.list。kind=None 返回三类全量(按键分桶)。"""
from server.state import plan_approval_manager, tool_approval_manager, user_question_manager
kinds = (kind,) if kind else self._APPROVAL_KINDS
if any(k not in self._APPROVAL_KINDS for k in kinds):
raise ValueError(f"runtime_context: 未知审批类型 {kind}")
result: Dict[str, List[Dict[str, Any]]] = {}
if "tool" in kinds:
result["tool"] = tool_approval_manager.list_pending(username, conversation_id)
if "plan" in kinds:
result["plan"] = plan_approval_manager.list_pending(username, conversation_id)
if "question" in kinds:
result["question"] = user_question_manager.list_pending(username, conversation_id)
return result
def resolve_approval(
self,
kind: str,
*,
username: str,
item_id: str,
decision: Optional[str] = None,
approved: Optional[bool] = None,
comment: Optional[str] = None,
selected_option_id: Optional[str] = None,
text: Optional[str] = None,
dismissed: bool = False,
) -> Dict[str, Any]:
"""统一裁决入口approval.resolve。kind 路由:
- tool: decision {"approved","rejected"}锁内单次裁决重复回答返回现状
- plan: approved bool + 可选 comment
- question: selected_option_id / text / dismissed 三选一语义
错误语义与 manager 一致越权 PermissionError不存在 KeyError参数非法 ValueError
"""
from server.state import plan_approval_manager, tool_approval_manager, user_question_manager
if kind == "tool":
if decision is None:
raise ValueError("runtime_context: tool 审批需 decision")
return tool_approval_manager.decide(item_id, username, str(decision), reason=comment)
if kind == "plan":
if approved is None:
raise ValueError("runtime_context: plan 审批需 approved")
return plan_approval_manager.answer(
approval_id=item_id, username=username, approved=bool(approved), comment=comment
)
if kind == "question":
return user_question_manager.answer(
question_id=item_id, username=username,
selected_option_id=selected_option_id, text=text, dismissed=dismissed,
)
raise ValueError(f"runtime_context: 未知审批类型 {kind}")
# ---- 会话查询公共入口CLI/定时器等非 Web 调用方不依赖 Web 路由)----
def list_sessions(

View File

@ -214,7 +214,8 @@ def check_chain():
else:
raise AssertionError("principal 与查询目标不一致必须抛 PermissionError")
# 6. 审批语义create → list_pending → decide → 重复 decide 返回现状(单次裁决)
# 6. 审批语义:公共入口 list_pending_approvals / resolve_approval
# manager 层语义已有覆盖,这里验收公共入口路由与错误语义透传)
from server.state import tool_approval_manager
item = tool_approval_manager.create_request(
@ -222,11 +223,16 @@ def check_chain():
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")
pending = runtime_service.list_pending_approvals("gw_smoke_user", conv_id)
assert any(p.get("approval_id") == item["approval_id"] for p in pending.get("tool", [])), \
"公共入口审批列表应含该项"
first = runtime_service.resolve_approval(
"tool", username="gw_smoke_user", item_id=item["approval_id"], decision="approved"
)
assert first.get("status") == "approved"
second = tool_approval_manager.decide(item["approval_id"], "gw_smoke_user", "rejected")
second = runtime_service.resolve_approval(
"tool", username="gw_smoke_user", item_id=item["approval_id"], decision="rejected"
)
assert second.get("status") == "approved", "重复裁决必须返回现状(单次裁决)"
# 越权防护:他人裁决应拒绝
item2 = tool_approval_manager.create_request(
@ -234,11 +240,22 @@ def check_chain():
tool_call_id="tc_smoke2", tool_name="write_file", arguments={}, preview={},
)
try:
tool_approval_manager.decide(item2["approval_id"], "other_user", "approved")
runtime_service.resolve_approval(
"tool", username="other_user", item_id=item2["approval_id"], decision="approved"
)
except PermissionError:
pass
else:
raise AssertionError("越权裁决必须抛 PermissionError")
# 未知审批类型应拒绝
try:
runtime_service.resolve_approval(
"bogus", username="gw_smoke_user", item_id="x", decision="approved"
)
except ValueError:
pass
else:
raise AssertionError("未知审批类型必须抛 ValueError")
def check_fake_exec():