agent-Specialization/test/runtime_standalone_checks.py
JOJO f77f1d67a8 refactor(runtime): 审核 F1-F4 收口——发现入口、范围校验、入口统一转调、验收加固
- F1: RuntimeService.list_runs 公共 Run 发现(工作区/会话/状态筛选);
  task_public_payload 序列化收敛至 models.py 核心层单一实现(Web/公共入口同构,
  helpers.py 旧实现删除,无新旧双轨)
- F2: tasks/api.py 7 处、api_v1.py 3 处、chat/approval.py 6 处全部转调
  runtime_service;HTTP 轮询透传 window_start 缺口水位;死导入清零
- F3: _resources_for_query 补 principal workspace_id 一致性校验
  (跨工作区查询必须重新认证)
- F4: config 新增 ASTRION_IGNORE_DOTENV 逃生门(.env 对 ASTRION_DATA_ROOT 的
  优先覆盖是刻意设计,保留);验收测试自包含(假模型 127.0.0.1:9 快失败)+
  隔离生效断言;lifecycle 改 api_request_start 装配证据断言;
  新增审批等待链验收(approval 档真实工具循环:等待→公共入口批准→继续);
  chain 首段改双客户端场景(B 经 list_runs 发现 A 的 Run 并观察/取消)
- execution_plane/base.py 注释修正:命令校验/路径授权仍在旧链路(审核 §4)
- 全量 75 测试:失败恰为 4 项存量,与改造无关
2026-09-08 01:29:43 +08:00

555 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Gateway 独立进程验收检查体(由 test_runtime_standalone_lifecycle 以子进程方式调用)。
为什么必须是独立进程config/paths.py 的 DATA_DIR / DEPLOY_CONFIG_DIR 等是
import 时固化的模块级常量;在 unittest discover 全量运行时,排在前面的测试模块
会先 import config 使常量固化,本文件顶部再设环境变量已无效——且被测代码会
落到用户真实运行态目录执行任务(不可接受)。子进程内 import 顺序完全可控,
隔离 100% 可靠。
用法python test/runtime_standalone_checks.py <lifecycle|chain>
退出码 0 = 通过;断言失败/traceback 走 stderr返回码非 0。
"""
import json
import os
import sys
import tempfile
import time
from pathlib import Path
# ---- 隔离环境必须在 import 任何项目模块之前设置config 在 import 时解析路径)----
_SMOKE_ROOT = Path(tempfile.mkdtemp(prefix="astrion_gw_smoke_"))
_SMOKE_WORKSPACE = _SMOKE_ROOT / "workspace"
_SMOKE_WORKSPACE.mkdir(parents=True, exist_ok=True)
(_SMOKE_ROOT / "config").mkdir(parents=True, exist_ok=True)
os.environ["ASTRION_DATA_ROOT"] = str(_SMOKE_ROOT)
os.environ["DEPLOY_CONFIG_DIR"] = str(_SMOKE_ROOT / "config")
os.environ["TERMINAL_SANDBOX_MODE"] = "host"
# 隔离逃生门(审核 F4仓库根 .env 对 ASTRION_DATA_ROOT 有「.env 优先」的刻意覆盖,
# 测试进程必须显式禁用它,否则隔离目录会被 .env 值穿透(静默落到真实/clone 数据根)。
os.environ["ASTRION_IGNORE_DOTENV"] = "1"
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",
)
# 自包含模型配置(审核 F4装配需要至少一个已注册模型指向 127.0.0.1:9discard
# 端口,几乎必无监听)使模型调用以「连接拒绝」快速失败——不依赖 DNS/外部网络,
# 也不需要真实凭证。验收对象是装配与生命周期,不是模型响应。
(_SMOKE_ROOT / "config" / "custom_models.json").write_text(
json.dumps(
{
"models": [
{
"model_name": "fake-smoke-model",
"visible": True,
"url": "http://127.0.0.1:9",
"apikey": "fake-smoke-key",
"reasoning_capability": "fast,thinking",
"thinkmode_status": {"type": "param_toggle", "model_id": "fake-smoke-model"},
}
]
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
# 隔离生效断言(审核 F4config import 后路径常量必须落在隔离根内,
# 否则说明隔离被穿透(如 .env 覆盖)——宁可明确失败,绝不静默使用真实目录。
import config as _smoke_config # noqa: E402
_assert_root = str(_SMOKE_ROOT.resolve())
assert str(Path(_smoke_config.DATA_DIR).resolve()).startswith(_assert_root), (
f"隔离失败DATA_DIR={_smoke_config.DATA_DIR} 不在 {_assert_root}"
)
assert str(Path(_smoke_config.DEPLOY_CONFIG_DIR).resolve()).startswith(_assert_root), (
f"隔离失败DEPLOY_CONFIG_DIR={_smoke_config.DEPLOY_CONFIG_DIR} 不在 {_assert_root}"
)
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_assembly = False # 装配证据api_request_start 事件(历史/请求构造完成,模型调用前)
final_status = None
assembly_error = 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}"
event_types = {e.get("type") for e in (events or [])}
# 装配失败(资源/会话/请求构造)会以 error 事件终结任务,必须与模型失败区分:
# api_request_start 之前出现 error = 装配失败,验收必须明确失败(审核 F4 假通过修复)
if "api_request_start" not in event_types and "error" in event_types:
assembly_error = [e for e in events if e.get("type") == "error"][-1]
break
if "api_request_start" in event_types:
saw_assembly = 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_assembly:
rs.cancel_task("gw_smoke_user", task_id)
time.sleep(0.5)
assert assembly_error is None, (
f"装配阶段失败(非模型调用):{assembly_error}"
)
assert saw_assembly, "任务线程应真实完成装配(出现 api_request_start 事件)"
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}"
)
# 门闸最终释放对话应可受理下一任务terminal 必须存在——
# 跳过检查会掩盖装配失败(审核 F4 假通过修复)
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)
assert terminal is not None, "装配成功后对话级 terminal 必须存在(否则装配未真实发生)"
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. 双客户端场景(审核 F1 验收A 受理 RunB 不持有 task_id仅凭身份+
# 工作区经公共入口发现 A 的活动 Run → 观察事件流 → 取消A 的 Run 到终态。
rec1 = runtime_service.create_task(_make_ctx())
# B 视角:轮询 list_runs 直到发现该 Run按 message 识别,不读 A 侧变量)
discovered = None
deadline = time.time() + 20
while time.time() < deadline:
active = runtime_service.list_runs("gw_smoke_user", "gwsmoke", status="active")
found = [r for r in active if "协议链路验收 ping" in (r.get("message") or "")]
if found:
discovered = found[0]
break
if getattr(rec1, "status", None) in {"succeeded", "failed", "stopped", "canceled"}:
break
time.sleep(0.4)
assert discovered, "B 应能经 list_runs 发现 A 发起的活动 RunF1"
assert discovered["task_id"] == rec1.task_id, "发现结果应指向同一 Run"
# B 观察事件流(用发现的 task_id等 user_message 落盘证据后取消
deadline = time.time() + 20
saw_user_msg = False
while time.time() < deadline:
ev_b, _, err_b, _meta_b = runtime_service.get_task_events("gw_smoke_user", discovered["task_id"], 0)
assert err_b is None, "B 应可读 A 的事件流"
if any(e.get("type") == "user_message" for e in (ev_b or [])):
saw_user_msg = True
break
if any(e.get("type") == "error" for e in (ev_b or [])):
raise AssertionError(f"装配阶段失败(非模型调用): {ev_b[-1] if ev_b else None}")
time.sleep(0.4)
assert saw_user_msg, "B 应观察到 A 的 user_message 事件(历史已落盘)"
# B 取消 A 的 Run跨端操作任务到终态
assert runtime_service.cancel_task("gw_smoke_user", discovered["task_id"]), "B 应可取消 A 的 Run"
rec1 = _wait_terminal_state(discovered["task_id"])
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.5 会话查询公共入口CLI/非 Web 调用方不依赖 Web 路由)
from server.runtime import TrustedPrincipal as _TP
listing = runtime_service.list_sessions(
"gw_smoke_user", "gwsmoke",
principal=_TP(username="gw_smoke_user", workspace_id="gwsmoke", role="admin",
host_mode=True, host_workspace_id="gwsmoke"),
)
items = listing.get("items") or listing.get("conversations") or []
assert any((it.get("conversation_id") or it.get("id")) == conv_id for it in items), (
f"会话列表应含本次会话 {conv_id}keys={list(listing.keys())}"
)
history = runtime_service.get_session_history(
"gw_smoke_user", "gwsmoke", conv_id,
principal=_TP(username="gw_smoke_user", workspace_id="gwsmoke", role="admin",
host_mode=True, host_workspace_id="gwsmoke"),
)
assert history and "协议链路验收 ping" in json.dumps(history, ensure_ascii=False), \
"会话历史公共入口应可读且含 user 消息"
try:
runtime_service.get_session_history(
"gw_smoke_user", "gwsmoke", conv_id,
principal=_TP(username="mallory", workspace_id="gwsmoke"),
)
except PermissionError:
pass
else:
raise AssertionError("principal 与查询目标不一致必须抛 PermissionError")
# F3principal 声明的工作区与查询目标不一致也必须拒绝(同名用户跨工作区)
try:
runtime_service.get_session_history(
"gw_smoke_user", "gwsmoke", conv_id,
principal=_TP(username="gw_smoke_user", workspace_id="other_ws"),
)
except PermissionError:
pass
else:
raise AssertionError("principal 工作区与查询目标不一致必须抛 PermissionError")
# 5.6 Run 发现公共入口F1仅凭身份+工作区可列出活动/全部 Run
runs_all = runtime_service.list_runs("gw_smoke_user", "gwsmoke")
run_ids = {r["task_id"] for r in runs_all}
assert {rec1.task_id, rec2.task_id} <= run_ids, "list_runs 应含两个 Run"
assert all(r["username"] == "gw_smoke_user" for r in runs_all), "归属过滤"
runs_conv = runtime_service.list_runs("gw_smoke_user", "gwsmoke", conversation_id=conv_id)
assert {r["task_id"] for r in runs_conv} == {rec1.task_id, rec2.task_id}, "会话筛选"
runs_done = runtime_service.list_runs("gw_smoke_user", "gwsmoke", status="active")
assert not any(r["task_id"] in run_ids for r in runs_done), "终态后 active 筛选应为空"
payload_keys = set(runs_all[0].keys())
assert {"task_id", "status", "conversation_id", "task_type", "created_at"} <= payload_keys
# 6. 审批语义:公共入口 list_pending_approvals / resolve_approval
# manager 层语义已有覆盖,这里验收公共入口路由与错误语义透传)
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 = 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 = 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(
username="gw_smoke_user", conversation_id=conv_id, task_id=None,
tool_call_id="tc_smoke2", tool_name="write_file", arguments={}, preview={},
)
try:
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():
"""第 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 check_approval_wait():
"""审核 F4 交互覆盖:执行中审批等待 → 另一调用方经公共入口回答 → 执行继续。
驱动真实工具编排层_execute_tool_calls_impl + approval 权限模式),不经模型
(模型只是 tool_calls 的生产者,与等待/裁决/继续语义无关);执行环境为替身
(零真实副作用)。覆盖:审批创建事件发出 → 公共入口可发现 → 批准 → 工具继续执行。
"""
import asyncio
import threading
from modules.execution_plane import FakeExecutionBackend
from server.chat_flow_tool_loop import _execute_tool_calls_impl
from server.context import RuntimeIdentity, get_user_resources
from server.runtime import runtime_service
identity = RuntimeIdentity(host_mode=True, host_workspace_id="gwsmoke", is_api_user=False, role="admin")
ws_term, workspace = get_user_resources(
"gw_smoke_user", workspace_id="gwsmoke", update_session=False, identity=identity
)
conv_id = ws_term.context_manager.conversation_manager.create_conversation(
project_path=str(workspace.project_path), run_mode="fast",
thinking_mode=False, model_key="fake-smoke-model",
)
terminal, _ = get_user_resources(
"gw_smoke_user", workspace_id="gwsmoke", update_session=False,
conversation_id=conv_id, identity=identity,
)
backend = FakeExecutionBackend()
terminal.execution_backend = backend
# 默认 work_mode=plan 会把权限锁为只读AGENTS.md §10.6);审批链路验收需 approval 档
terminal.set_work_mode("execute", persist=False, conversation_id=conv_id)
terminal.set_permission_mode("approval", persist=False, conversation_id=conv_id)
sent_events = []
def sender(event_type, data):
sent_events.append((event_type, data))
tool_calls = [{
"id": "tc_wait_1",
"type": "function",
"function": {"name": "run_command", "arguments": json.dumps({"command": "echo approved-cmd", "timeout": 5})},
}]
loop_result = {}
async def _noop_process(**_kwargs):
return None
def _get_stop_flag(*_args, **_kwargs):
return None
def _clear_stop_flag(*_args, **_kwargs):
return None
def run_loop():
async def _main():
return await _execute_tool_calls_impl(
web_terminal=terminal,
tool_calls=tool_calls,
sender=sender,
messages=[],
client_sid="approval_wait_sid",
username="gw_smoke_user",
iteration=1,
conversation_id=conv_id,
last_tool_call_time=time.time(),
process_sub_agent_updates=_noop_process,
process_background_command_updates=_noop_process,
get_stop_flag=_get_stop_flag,
clear_stop_flag=_clear_stop_flag,
workspace=workspace,
)
try:
loop_result["value"] = asyncio.run(_main())
except Exception as exc:
loop_result["error"] = exc
th = threading.Thread(target=run_loop, daemon=True)
th.start()
# 主线程扮演「另一调用方」:经公共入口发现待决审批并批准
deadline = time.time() + 20
approved = False
while time.time() < deadline:
pending = runtime_service.list_pending_approvals("gw_smoke_user", conv_id).get("tool", [])
if pending:
runtime_service.resolve_approval(
"tool", username="gw_smoke_user",
item_id=pending[0]["approval_id"], decision="approved",
)
approved = True
break
if "error" in loop_result:
break
time.sleep(0.3)
th.join(timeout=20)
assert "error" not in loop_result, f"工具循环异常: {loop_result.get('error')}"
assert approved, "执行中的任务应产生待决审批(另一调用方可经公共入口发现)"
assert any(t == "tool_approval_required" for t, _ in sent_events), "应发出 tool_approval_required 事件"
assert any(c["op"] == "run_command" for c in backend.calls), "批准后工具应继续执行(替身收到命令)"
assert not th.is_alive(), "批准后工具循环应退出"
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()
elif mode == "approval_wait":
check_approval_wait()
else:
print("usage: runtime_standalone_checks.py <lifecycle|chain|fake_exec|approval_wait>", file=sys.stderr)
return 2
print(f"CHECK_OK {mode}")
return 0
if __name__ == "__main__":
sys.exit(main())