feat: 新增工具插件、系统日志、workflow配置及前端优化
1. 新增工具插件(edit_file, python_executor, search_file, shell_executor, write_file) 2. 新增系统事件日志模块和API 3. 新增workflow配置文件和详情API 4. 前端增加SSE、错误边界、设置引导等组件 5. 优化认证加密、速率限制、配置加载等工具模块 6. 删除废弃的cluster和health API 7. 补充单元测试和集成测试 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
"""组装层 / 端到端 smoke 测试。
|
||||
|
||||
这一层补 ``tests/unit`` 的盲区:单测全是 mock 出来的纯逻辑,抓不到
|
||||
"import 错误 / 路由冲突 / 真实节点拓扑串联不上" 这类组装层 bug。
|
||||
|
||||
设计原则:
|
||||
- **不依赖真 ray / 真 postgres**:sandbox 里 ``ray.init`` 有 psutil PID 问题,
|
||||
真 postgres 要 docker。这里只验证"组件能正确组装 + 真实拓扑能端到端跑通"。
|
||||
- app 装配:用真实的 ``KiloStarGateway`` 内部 ``app``(触发所有 router import +
|
||||
注册),打 health 探针。
|
||||
- workflow:用真实的 6 节点 graph 拓扑端到端跑,只 mock 最外层 IO(DB 写 / SSE /
|
||||
执行器),不 mock 任何节点逻辑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
# ─── 组装层:整个 FastAPI app 能 import + 路由注册无冲突 ────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_app_imports_and_health_live_ok():
|
||||
"""导入生产 ``app`` 不报错,且 /health/live 返回 alive。
|
||||
|
||||
这一步能抓到的真实 bug:任一 router 模块 import 失败、include_router
|
||||
路由前缀撞车、middleware 装配异常——这些单测都看不到。
|
||||
"""
|
||||
from kilostar.api import app
|
||||
|
||||
transport = ASGITransport(app=app, raise_app_exceptions=False)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
resp = await client.get("/health/live")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"status": "alive"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_app_route_table_has_expected_endpoints():
|
||||
"""关键路由都已注册(拓扑回归保护)。"""
|
||||
from kilostar.api import app
|
||||
|
||||
paths = {getattr(r, "path", None) for r in app.router.routes}
|
||||
assert "/health/live" in paths
|
||||
assert "/health/ready" in paths
|
||||
assert "/api/v1/workflow" in paths
|
||||
# 阶段九/十 新增的 resume / graph 端点
|
||||
assert "/api/v1/workflow/{trace_id}/resume" in paths
|
||||
assert "/api/v1/workflow/{trace_id}/graph" in paths
|
||||
|
||||
|
||||
# ─── 端到端:真实 6 节点 graph 拓扑跑通 ────────────────────────────────────
|
||||
|
||||
|
||||
def _make_real_deps(skill_outputs, consciousness_outputs=None, replies=None):
|
||||
"""构造 WorkflowDeps:只 mock 最外层 IO,节点逻辑全用真实实现。"""
|
||||
from kilostar.core.work.workflow.workflow_engine import WorkflowDeps
|
||||
|
||||
skill_q = list(skill_outputs or [])
|
||||
consc_q = list(consciousness_outputs or [])
|
||||
reply_q = list(replies or [])
|
||||
sink = {"pending": [], "skill": [], "consc": []}
|
||||
|
||||
async def _get_received(tid):
|
||||
return reply_q.pop(0) if reply_q else ""
|
||||
|
||||
async def _run_skill(step, state):
|
||||
sink["skill"].append(step.get("name"))
|
||||
return skill_q.pop(0) if skill_q else ("(none)", True)
|
||||
|
||||
async def _run_consciousness(step, state):
|
||||
sink["consc"].append(step.get("name"))
|
||||
return consc_q.pop(0) if consc_q else ("(none)", True)
|
||||
|
||||
deps = WorkflowDeps(
|
||||
upsert_workflow_context=AsyncMock(),
|
||||
update_workflow_status=AsyncMock(),
|
||||
put_pending=AsyncMock(side_effect=lambda t, m: sink["pending"].append(m)),
|
||||
get_received=_get_received,
|
||||
run_skill=_run_skill,
|
||||
run_consciousness=_run_consciousness,
|
||||
)
|
||||
return deps, sink
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_to_end_mixed_workflow_runs_to_completion():
|
||||
"""混合 skill + consciousness + HITL 的多步 workflow 端到端跑通。
|
||||
|
||||
这是最贴近"真实一次 workflow"的 smoke:3 步分别走不同节点类型 + 一步
|
||||
需要人工审批,全程用真实 Dispatch 派发逻辑。
|
||||
"""
|
||||
from kilostar.core.work.workflow.workflow_engine import run_workflow_graph
|
||||
from kilostar.core.work.workflow.model import WorkflowStatus
|
||||
|
||||
deps, sink = _make_real_deps(
|
||||
skill_outputs=[("s-ok", True), ("s2-ok", True)],
|
||||
consciousness_outputs=[("c-ok", True)],
|
||||
replies=["approve"],
|
||||
)
|
||||
workflow_data = {
|
||||
"work_link": [
|
||||
{"step": 1, "name": "research", "action": "do",
|
||||
"node": "skill_individual", "agent_id": "a1"},
|
||||
{"step": 2, "name": "plan", "action": "do",
|
||||
"node": "consciousness_node"},
|
||||
{"step": 3, "name": "review", "action": "do",
|
||||
"node": "skill_individual", "agent_id": "a1",
|
||||
"require_approval": True},
|
||||
]
|
||||
}
|
||||
|
||||
final = await run_workflow_graph(workflow_data, "smoke-mixed", deps=deps)
|
||||
|
||||
assert final == WorkflowStatus.COMPLETED.value
|
||||
# 真实派发:skill 跑了 research + review(审批通过后),consciousness 跑了 plan
|
||||
assert sink["skill"] == ["research", "review"]
|
||||
assert sink["consc"] == ["plan"]
|
||||
# 审批提示发过
|
||||
assert any("人工审批" in m for m in sink["pending"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_to_end_empty_workflow_completes_immediately():
|
||||
"""空 work_link 直接 COMPLETED(不卡死、不报错)。"""
|
||||
from kilostar.core.work.workflow.workflow_engine import run_workflow_graph
|
||||
from kilostar.core.work.workflow.model import WorkflowStatus
|
||||
|
||||
deps, _ = _make_real_deps(skill_outputs=[])
|
||||
final = await run_workflow_graph({"work_link": []}, "smoke-empty", deps=deps)
|
||||
assert final == WorkflowStatus.COMPLETED.value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_to_end_failed_step_aborts_workflow():
|
||||
"""某步执行失败 → 工作流终态 FAILED(真实 logic gate 行为)。"""
|
||||
from kilostar.core.work.workflow.workflow_engine import run_workflow_graph
|
||||
from kilostar.core.work.workflow.model import WorkflowStatus
|
||||
|
||||
deps, sink = _make_real_deps(skill_outputs=[("boom", False)])
|
||||
workflow_data = {
|
||||
"work_link": [
|
||||
{"step": 1, "name": "will-fail", "action": "do",
|
||||
"node": "skill_individual", "agent_id": "a1"},
|
||||
]
|
||||
}
|
||||
final = await run_workflow_graph(workflow_data, "smoke-fail", deps=deps)
|
||||
assert final == WorkflowStatus.FAILED.value
|
||||
assert sink["skill"] == ["will-fail"]
|
||||
@@ -1,4 +1,4 @@
|
||||
"""``api/health.py`` 健康探针端点。"""
|
||||
"""``api/system.py`` 健康探针端点。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -9,13 +9,13 @@ import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from kilostar.api.health import health_router
|
||||
from kilostar.api.system import system_router
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def health_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.include_router(health_router)
|
||||
app.include_router(system_router)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""workflow 路由鉴权测试:SSE / reply / resume / detail / graph 端点归属校验。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import types
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from httpx import AsyncClient, ASGITransport
|
||||
|
||||
from kilostar.api.workflow import workflow_router
|
||||
from kilostar.utils.access import Accessor, TokenData
|
||||
|
||||
|
||||
def _fake_user(user_id: str = "alice"):
|
||||
return TokenData(user_id=user_id, username=user_id)
|
||||
|
||||
|
||||
def _make_workflow(owner: str = "alice"):
|
||||
return types.SimpleNamespace(
|
||||
trace_id="trace-1",
|
||||
user_id=owner,
|
||||
title="test",
|
||||
status="running",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app_alice():
|
||||
app = FastAPI()
|
||||
app.include_router(workflow_router)
|
||||
app.dependency_overrides[Accessor.get_current_user] = lambda: _fake_user("alice")
|
||||
return app
|
||||
|
||||
|
||||
def _register_pg(fake_actors, owner: str = "alice"):
|
||||
pg = types.SimpleNamespace()
|
||||
pg.get_workflow = types.SimpleNamespace(remote=AsyncMock(return_value=_make_workflow(owner)))
|
||||
pg.get_workflow_context = types.SimpleNamespace(remote=AsyncMock(return_value=None))
|
||||
pg.get_workflow_graph_state = types.SimpleNamespace(remote=AsyncMock(return_value=None))
|
||||
fake_actors.register("postgres_database", pg)
|
||||
return pg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detail_forbidden_other_user(app_alice, fake_actors):
|
||||
_register_pg(fake_actors, owner="bob")
|
||||
async with AsyncClient(transport=ASGITransport(app=app_alice), base_url="http://t") as c:
|
||||
resp = await c.get("/api/v1/workflow/trace-1")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detail_not_found(app_alice, fake_actors):
|
||||
pg = types.SimpleNamespace()
|
||||
pg.get_workflow = types.SimpleNamespace(remote=AsyncMock(return_value=None))
|
||||
fake_actors.register("postgres_database", pg)
|
||||
async with AsyncClient(transport=ASGITransport(app=app_alice), base_url="http://t") as c:
|
||||
resp = await c.get("/api/v1/workflow/trace-nonexist")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_forbidden_other_user(app_alice, fake_actors):
|
||||
_register_pg(fake_actors, owner="bob")
|
||||
async with AsyncClient(transport=ASGITransport(app=app_alice), base_url="http://t") as c:
|
||||
resp = await c.post("/api/v1/workflow/reply/trace-1", json={"message": "hi"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_forbidden_other_user(app_alice, fake_actors):
|
||||
_register_pg(fake_actors, owner="bob")
|
||||
async with AsyncClient(transport=ASGITransport(app=app_alice), base_url="http://t") as c:
|
||||
resp = await c.post("/api/v1/workflow/trace-1/resume")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resume_not_found(app_alice, fake_actors):
|
||||
pg = types.SimpleNamespace()
|
||||
pg.get_workflow = types.SimpleNamespace(remote=AsyncMock(return_value=None))
|
||||
fake_actors.register("postgres_database", pg)
|
||||
async with AsyncClient(transport=ASGITransport(app=app_alice), base_url="http://t") as c:
|
||||
resp = await c.post("/api/v1/workflow/trace-nonexist/resume")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_forbidden_other_user(app_alice, fake_actors):
|
||||
_register_pg(fake_actors, owner="bob")
|
||||
async with AsyncClient(transport=ASGITransport(app=app_alice), base_url="http://t") as c:
|
||||
resp = await c.get("/api/v1/workflow/trace-1/graph")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_forbidden_other_user(app_alice, fake_actors):
|
||||
_register_pg(fake_actors, owner="bob")
|
||||
async with AsyncClient(transport=ASGITransport(app=app_alice), base_url="http://t") as c:
|
||||
resp = await c.get("/api/v1/workflow/sse/trace-1")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sse_not_found(app_alice, fake_actors):
|
||||
pg = types.SimpleNamespace()
|
||||
pg.get_workflow = types.SimpleNamespace(remote=AsyncMock(return_value=None))
|
||||
fake_actors.register("postgres_database", pg)
|
||||
async with AsyncClient(transport=ASGITransport(app=app_alice), base_url="http://t") as c:
|
||||
resp = await c.get("/api/v1/workflow/sse/trace-nonexist")
|
||||
assert resp.status_code == 404
|
||||
@@ -0,0 +1,80 @@
|
||||
"""``api/workflow.py`` 读侧拼装:运行期状态 merge 到静态 step。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from kilostar.api.workflow import _merge_runtime_status
|
||||
|
||||
|
||||
def test_merge_marks_pending_when_no_log():
|
||||
"""没有任何运行日志时,所有 step 默认 pending。"""
|
||||
work_link = [
|
||||
{"step": 1, "name": "s1", "node": "skill_individual", "action": "a"},
|
||||
{"step": 2, "name": "s2", "node": "consciousness_node", "action": "b"},
|
||||
]
|
||||
merged = _merge_runtime_status(work_link, [])
|
||||
assert [s["status"] for s in merged] == ["pending", "pending"]
|
||||
# 静态字段保留
|
||||
assert merged[0]["name"] == "s1"
|
||||
assert merged[1]["node"] == "consciousness_node"
|
||||
|
||||
|
||||
def test_merge_uses_latest_status_per_step():
|
||||
"""同一 step 多条日志时取最后一条(working → completed)。"""
|
||||
work_link = [
|
||||
{"step": 1, "name": "s1", "node": "skill_individual", "action": "a"},
|
||||
]
|
||||
workflow_log = [
|
||||
{"0": ["2026-01-01T00:00:00", "working", "开始"]},
|
||||
{"0": ["2026-01-01T00:00:05", "completed", "成功"]},
|
||||
]
|
||||
merged = _merge_runtime_status(work_link, workflow_log)
|
||||
assert merged[0]["status"] == "completed"
|
||||
|
||||
|
||||
def test_merge_mixed_statuses():
|
||||
"""多 step 各自取自己最新状态;无日志的保持 pending。"""
|
||||
work_link = [
|
||||
{"step": 1, "name": "s1", "node": "skill_individual", "action": "a"},
|
||||
{"step": 2, "name": "s2", "node": "skill_individual", "action": "b"},
|
||||
{"step": 3, "name": "s3", "node": "skill_individual", "action": "c"},
|
||||
]
|
||||
workflow_log = [
|
||||
{"0": ["t", "completed", "ok"]},
|
||||
{"1": ["t", "failed", "boom"]},
|
||||
]
|
||||
merged = _merge_runtime_status(work_link, workflow_log)
|
||||
assert [s["status"] for s in merged] == ["completed", "failed", "pending"]
|
||||
|
||||
|
||||
def test_merge_falls_back_to_position_index_without_step_field():
|
||||
"""step 没有 step 字段时按位置索引匹配日志。"""
|
||||
work_link = [
|
||||
{"name": "s1", "node": "skill_individual", "action": "a"},
|
||||
{"name": "s2", "node": "skill_individual", "action": "b"},
|
||||
]
|
||||
workflow_log = [
|
||||
{"1": ["t", "completed", "ok"]},
|
||||
]
|
||||
merged = _merge_runtime_status(work_link, workflow_log)
|
||||
assert merged[0]["status"] == "pending"
|
||||
assert merged[1]["status"] == "completed"
|
||||
|
||||
|
||||
def test_merge_ignores_malformed_log_entries():
|
||||
"""脏日志(非 dict / payload 不是数组 / key 不是数字)不应炸。"""
|
||||
work_link = [
|
||||
{"step": 1, "name": "s1", "node": "skill_individual", "action": "a"},
|
||||
]
|
||||
workflow_log = [
|
||||
"not-a-dict",
|
||||
{"not-an-int": ["t", "completed", "x"]},
|
||||
{"0": "not-a-list"},
|
||||
{"0": ["t", "working"]},
|
||||
]
|
||||
merged = _merge_runtime_status(work_link, workflow_log)
|
||||
assert merged[0]["status"] == "working"
|
||||
|
||||
|
||||
def test_merge_handles_empty_work_link():
|
||||
assert _merge_runtime_status([], []) == []
|
||||
assert _merge_runtime_status(None, None) == []
|
||||
@@ -0,0 +1,78 @@
|
||||
"""``utils/config_loader.py``:workflow.yaml 读/写/热重载。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import yaml
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolated_yaml(tmp_path, monkeypatch):
|
||||
"""每个用例用独立的临时 yaml,避免污染真实 config/workflow.yaml。"""
|
||||
from kilostar.utils import config_loader
|
||||
|
||||
fake_yaml = tmp_path / "workflow.yaml"
|
||||
monkeypatch.setattr(config_loader, "_WORKFLOW_YAML", fake_yaml)
|
||||
monkeypatch.setattr(config_loader, "_current", None)
|
||||
return fake_yaml
|
||||
|
||||
|
||||
def test_get_workflow_config_returns_default_when_file_absent():
|
||||
from kilostar.utils.config_loader import get_workflow_config
|
||||
|
||||
config = get_workflow_config()
|
||||
assert config.retry.max_attempts == 5
|
||||
|
||||
|
||||
def test_get_workflow_config_reads_from_disk(isolated_yaml):
|
||||
isolated_yaml.write_text("retry:\n max_attempts: 12\n", encoding="utf-8")
|
||||
|
||||
from kilostar.utils.config_loader import reload_workflow_config
|
||||
|
||||
config = reload_workflow_config()
|
||||
assert config.retry.max_attempts == 12
|
||||
|
||||
|
||||
def test_save_workflow_config_writes_yaml_and_reloads(isolated_yaml):
|
||||
from kilostar.utils.config_loader import (
|
||||
save_workflow_config,
|
||||
get_workflow_config,
|
||||
WorkflowConfig,
|
||||
RetryConfig,
|
||||
)
|
||||
|
||||
new_config = WorkflowConfig(retry=RetryConfig(max_attempts=20))
|
||||
save_workflow_config(new_config)
|
||||
|
||||
on_disk = yaml.safe_load(isolated_yaml.read_text(encoding="utf-8"))
|
||||
assert on_disk == {"retry": {"max_attempts": 20}}
|
||||
|
||||
# 热重载:再次 get 应直接拿到新值
|
||||
assert get_workflow_config().retry.max_attempts == 20
|
||||
|
||||
|
||||
def test_max_attempts_validation_rejects_out_of_range():
|
||||
from kilostar.utils.config_loader import RetryConfig
|
||||
|
||||
with pytest.raises(Exception):
|
||||
RetryConfig(max_attempts=0)
|
||||
|
||||
with pytest.raises(Exception):
|
||||
RetryConfig(max_attempts=200)
|
||||
|
||||
|
||||
def test_reload_picks_up_external_file_changes(isolated_yaml):
|
||||
"""模拟运维直接改 yaml 文件,reload 后引擎能拿到新值。"""
|
||||
isolated_yaml.write_text("retry:\n max_attempts: 3\n", encoding="utf-8")
|
||||
|
||||
from kilostar.utils.config_loader import (
|
||||
get_workflow_config,
|
||||
reload_workflow_config,
|
||||
)
|
||||
|
||||
assert reload_workflow_config().retry.max_attempts == 3
|
||||
|
||||
isolated_yaml.write_text("retry:\n max_attempts: 30\n", encoding="utf-8")
|
||||
assert reload_workflow_config().retry.max_attempts == 30
|
||||
# get_workflow_config 也读到最新
|
||||
assert get_workflow_config().retry.max_attempts == 30
|
||||
@@ -13,15 +13,15 @@ def test_approval_metadata():
|
||||
data = ApprovalToolData()
|
||||
assert data.is_system is True
|
||||
assert data.category == "system"
|
||||
assert "control_node" in data.action_scope
|
||||
assert "consciousness_node" in data.action_scope
|
||||
# action_scope 为空表示分配给 default 组(所有节点可用)
|
||||
assert data.action_scope == []
|
||||
|
||||
|
||||
def test_file_reader_metadata():
|
||||
data = FileReaderToolData()
|
||||
assert data.is_system is True
|
||||
assert data.category == "system"
|
||||
assert "control_node" in data.action_scope
|
||||
assert data.action_scope == []
|
||||
|
||||
|
||||
def test_tavily_search_metadata():
|
||||
|
||||
@@ -44,3 +44,64 @@ def test_ray_actor_hook_unknown_actor_raises(fake_actors):
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
ray_actor_hook("does_not_exist")
|
||||
|
||||
|
||||
def test_wait_for_actor_returns_immediately_when_ready(fake_actors):
|
||||
"""actor 已就绪时 wait_for_actor 立刻返回,不进入轮询等待。"""
|
||||
handle = MagicMock()
|
||||
fake_actors.register("postgres_database", handle)
|
||||
|
||||
from kilostar.utils.ray_hook import wait_for_actor
|
||||
|
||||
got = wait_for_actor("postgres_database", timeout=5.0)
|
||||
assert got is handle
|
||||
|
||||
|
||||
def test_wait_for_actor_times_out_with_clear_error(fake_actors):
|
||||
"""超时仍未就绪时抛 TimeoutError,并在 message 里带 actor 名。"""
|
||||
from kilostar.utils.ray_hook import wait_for_actor
|
||||
|
||||
with pytest.raises(TimeoutError) as exc_info:
|
||||
wait_for_actor("never_ready", timeout=0.2, interval=0.05)
|
||||
assert "never_ready" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_wait_for_actor_succeeds_after_delayed_registration(fake_actors):
|
||||
"""actor 在第 N 次轮询时才注册,wait_for_actor 应在它就绪后返回。"""
|
||||
from kilostar.utils.ray_hook import wait_for_actor
|
||||
|
||||
handle = MagicMock()
|
||||
calls = {"n": 0}
|
||||
original_get = fake_actors.get
|
||||
|
||||
def delayed_get(name, namespace="kilostar"):
|
||||
calls["n"] += 1
|
||||
if calls["n"] >= 3:
|
||||
return handle
|
||||
raise ValueError("not ready yet")
|
||||
|
||||
fake_actors.get = delayed_get
|
||||
try:
|
||||
got = wait_for_actor("late_actor", timeout=2.0, interval=0.05)
|
||||
assert got is handle
|
||||
assert calls["n"] >= 3
|
||||
finally:
|
||||
fake_actors.get = original_get
|
||||
|
||||
|
||||
def test_ray_actor_hook_with_timeout_waits(fake_actors):
|
||||
"""ray_actor_hook(timeout>0) 会走 wait_for_actor 等待路径。"""
|
||||
from kilostar.utils.ray_hook import ray_actor_hook
|
||||
|
||||
handle = MagicMock()
|
||||
calls = {"n": 0}
|
||||
|
||||
def delayed_get(name, namespace="kilostar"):
|
||||
calls["n"] += 1
|
||||
if calls["n"] >= 2:
|
||||
return handle
|
||||
raise ValueError("not ready yet")
|
||||
|
||||
fake_actors.get = delayed_get
|
||||
actors = ray_actor_hook("slow_actor", timeout=2.0, interval=0.05)
|
||||
assert actors.slow_actor is handle
|
||||
|
||||
@@ -288,3 +288,37 @@ def test_workflow_graph_state_defaults():
|
||||
assert state.final_status == WorkflowStatus.RUNNING.value
|
||||
assert state.logs == []
|
||||
assert state.original_command == ""
|
||||
assert state.jump_counts == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loop_retry_exceeds_max_attempts_fails(monkeypatch):
|
||||
"""环跳转超过 max_attempts 后,工作流应直接 FAILED 而非无限重试。"""
|
||||
from kilostar.utils import config_loader
|
||||
from kilostar.utils.config_loader import WorkflowConfig, RetryConfig
|
||||
|
||||
monkeypatch.setattr(config_loader, "_current", WorkflowConfig(retry=RetryConfig(max_attempts=2)))
|
||||
|
||||
deps, sink = _make_deps(
|
||||
skill_outputs=[
|
||||
("o1", True),
|
||||
("fail", False),
|
||||
("o1", True),
|
||||
("fail", False),
|
||||
("o1", True),
|
||||
("fail", False),
|
||||
("o1", True),
|
||||
("fail", False),
|
||||
]
|
||||
)
|
||||
workflow_data = {
|
||||
"work_link": [
|
||||
{"step": 1, "name": "s1", "action": "do",
|
||||
"node": "skill_individual", "agent_id": "a1"},
|
||||
{"step": 2, "name": "s2", "action": "do",
|
||||
"node": "skill_individual", "agent_id": "a2",
|
||||
"logic_gate": {"if_fail": "jump_to_step_1", "if_pass": "continue"}},
|
||||
]
|
||||
}
|
||||
final = await run_workflow_graph(workflow_data, "trace-loop-limit", deps=deps)
|
||||
assert final == WorkflowStatus.FAILED.value
|
||||
|
||||
Reference in New Issue
Block a user