Files
Agentswarm/orchestrator/test_multi_agent_workflow.py
T
Songhaoz666andClaude Opus 4.8 15fe5d379b 契约冻结 v1:Manager/客户端 Swarm Run 查询契约(Refs #2 #14 #15)
回应 HM 驾驶舱(heicode-mananger #28/#45/#46)经 agent_swarm#14(runtime-contract)
+ #15(event-schema)提出的消费需求。HM 只读查询已落地(HM PR #53),唯一前置是本
仓契约冻结。本 PR 把回调契约冻结为 v1 并落代码 + 测试。

代码(orchestrator/):
- swarm_runtime.emit_event:回调 envelope 新增 **per-swarm 严格递增 `sequence`**
  (INCR 计数键 swarm_event_seq:{swarm_id},从 1、无空洞,供客户端 events?after= 去重/续传)。
- 新增冻结的客户端 13 类事件(FROZEN_CLIENT_EVENT_TYPES)中此前缺的 6 类,均**附加**发出
  (不动既有 deployment.status_changed,HM 仍用其更新 AgentDeployment.Status):
  · swarm.completed/failed(refresh_swarm_run_status 终态)、swarm.stopped(stop_run);
  · approval.approved/rejected(record_approval_decision 决定落地);
  · handoff.created(child 任务建立时)。
- artifact.created envelope 补扁平字段:created_at(默认 occurred_at)、task_id(回填)、
  size_bytes 透传(未知则省略,不伪造,规则 #9)。
- redis_client 新增原子 incr(真实 + 两处 fake stub)。

文档(docs/integration/,FROZEN v1):
- event-schema.md:envelope sequence、artifact 扁平字段、事件注册表标注 ⭐13 类 + 新增 6 类、
  对齐状态更新(title/threshold_pct/sequence/artifact 已在 emit 统一处理)。
- runtime-contract.md:冻结 stop 端点 + ID 映射;§4.1 新增**状态机映射表**——运行时不臆造
  preparing/degraded/verifying(规则 #9),由 HM/客户端按表映射真实状态
  (blocked→degraded、评审期→verifying 等);终态另发 swarm.* 事件。

测试:
- 新增 scripts/test-contract-freeze.py(hermetic):sequence 单调/每-swarm/无空洞、13 类
  round-trip、artifact 形状(含未知 size 不伪造)、approval.*/swarm.stopped 真实发出、
  明文凭据脱敏而 azkv secret_ref 透传。接入 CI + CLAUDE.md 提交前清单。
- test-workflow-e2e.py:全流程 e2e 额外断言 swarm.completed + sequence 无空洞。
- 两处 FakeRedis stub 补 incr。

影响范围:仅 agent_swarm(orchestrator + docs/integration + 测试 + CI + CLAUDE.md)。
- Manager:回调**新增** sequence 字段与 6 类事件——向后兼容(旧消费方忽略新字段/新类型即可);
  HM 注册表需登记新 6 类方能对外暴露(agent_swarm#15.2,已在 doc 列为剩余项)。
- 计费/审计:不涉及(查询面不计费由 HM 保证;本仓未改计费/审计字段)。
- 密钥:envelope 不含明文凭据;secret_ref 仍为 azkv 引用,HM 对客户端再脱敏。

Refs #2
Refs #14
Refs #15

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:35:04 +08:00

420 lines
16 KiB
Python

"""Regression tests for the multi-agent DAG workflow."""
import fnmatch
import os
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from orchestrator import main as orchestrator_main
from orchestrator import swarm_runtime as swarm_runtime_module
from orchestrator import task_queue as task_queue_module
class FakeRedisClient:
"""Tiny in-memory async Redis replacement for task-queue tests."""
def __init__(self):
self.kv = {}
self.lists = {}
async def set(self, key, value, ex=None):
self.kv[key] = value
async def get(self, key):
return self.kv.get(key)
async def incr(self, key):
self.kv[key] = int(self.kv.get(key, 0)) + 1
return self.kv[key]
async def delete(self, key):
self.kv.pop(key, None)
self.lists.pop(key, None)
async def keys(self, pattern):
keys = list(self.kv.keys()) + list(self.lists.keys())
return sorted([key for key in keys if fnmatch.fnmatch(key, pattern)])
async def lpush(self, key, *values):
self.lists.setdefault(key, [])
for value in values:
self.lists[key].insert(0, value)
async def rpush(self, key, *values):
self.lists.setdefault(key, [])
self.lists[key].extend(values)
async def rpop(self, key):
items = self.lists.get(key, [])
if not items:
return None
return items.pop()
async def llen(self, key):
return len(self.lists.get(key, []))
async def lrange(self, key, start, end):
items = list(self.lists.get(key, []))
if end == -1:
end = len(items) - 1
return items[start : end + 1]
async def lrem(self, key, count, value):
items = self.lists.get(key, [])
removed = 0
kept = []
for item in items:
if item == value and (count == 0 or removed < count):
removed += 1
continue
kept.append(item)
self.lists[key] = kept
return removed
class MultiAgentWorkflowTests(unittest.IsolatedAsyncioTestCase):
"""Covers DAG task creation, dependency dispatch, and parent-child resolution."""
def setUp(self):
self.fake_redis = FakeRedisClient()
self.redis_patch = patch.object(task_queue_module, "redis_client", self.fake_redis)
self.redis_patch.start()
self.addCleanup(self.redis_patch.stop)
async def test_build_task_descriptions_respects_feature_flag(self):
body = {
"orchestration_plan": {
"objective": "Implement a feature",
"sub_mode": "code",
"risk_level": "low",
"budget": {"duration_seconds": 600, "token_limit": 1000},
"agents": [
{
"task_id": "backend-1",
"role": "backend",
"title": "Backend task",
"description": "Implement backend changes",
"depends_on": [],
},
{
"task_id": "tests-1",
"role": "testing",
"title": "Testing task",
"description": "Add tests",
"depends_on": ["backend-1"],
},
],
}
}
runtime = swarm_runtime_module.SwarmRuntime()
with patch.dict(os.environ, {"ENABLE_SUBTASK_HANDOFF": "false"}, clear=False):
tasks = runtime.build_task_descriptions(body)
self.assertEqual(len(tasks), 1)
self.assertEqual(tasks[0]["workflow_mode"], "single_agent")
with patch.dict(os.environ, {"ENABLE_SUBTASK_HANDOFF": "true"}, clear=False):
tasks = runtime.build_task_descriptions(body)
self.assertEqual(len(tasks), 2)
self.assertEqual(tasks[0]["task_id"], "backend-1")
self.assertEqual(tasks[1]["depends_on"], ["backend-1"])
self.assertEqual(tasks[1]["workflow_mode"], "multi_agent")
async def test_ready_pending_task_respects_dependencies_and_capabilities(self):
parent = await task_queue_module.task_queue.create_task(
task_id="parent-1",
title="Backend",
description="Implement backend",
agent_role="backend",
required_capabilities=["backend"],
source="runtime_bridge",
)
child = await task_queue_module.task_queue.create_task(
task_id="child-1",
title="Tests",
description="Add tests",
agent_role="testing",
required_capabilities=["testing"],
depends_on=[parent.task_id],
root_task_id=parent.task_id,
source="runtime_bridge",
)
task = await task_queue_module.task_queue.get_ready_pending_task(["testing"])
self.assertIsNone(task, "dependent task should not dispatch before parent completion")
task = await task_queue_module.task_queue.get_ready_pending_task(["backend"])
self.assertIsNotNone(task)
self.assertEqual(task.task_id, parent.task_id)
await task_queue_module.task_queue.complete_task(parent.task_id, "done")
task = await task_queue_module.task_queue.get_ready_pending_task(["testing"])
self.assertIsNotNone(task)
self.assertEqual(task.task_id, child.task_id)
async def test_finalize_parent_after_child_success(self):
event_calls = []
async def fake_emit_event(*args, **kwargs):
event_calls.append((args, kwargs))
runtime_patch = patch.object(orchestrator_main.swarm_runtime, "emit_event", fake_emit_event)
runtime_patch.start()
self.addCleanup(runtime_patch.stop)
parent = await task_queue_module.task_queue.create_task(
task_id="parent-1",
description="Parent task",
title="Parent",
agent_role="backend",
source="runtime_bridge",
)
child = await task_queue_module.task_queue.create_task(
task_id="child-1",
description="Child task",
title="Child",
agent_role="testing",
parent_task_id=parent.task_id,
root_task_id=parent.task_id,
source="dynamic_handoff",
)
await task_queue_module.task_queue.add_child_task(parent.task_id, child.task_id)
await task_queue_module.task_queue.block_task(parent.task_id, "Waiting on child")
await task_queue_module.task_queue.complete_task(child.task_id, "child complete")
refreshed_child = await task_queue_module.task_queue.get_task(child.task_id)
run = SimpleNamespace()
await orchestrator_main.finalize_parent_after_child(
run,
refreshed_child,
agent_id="agent-1",
success=True,
summary="Delegated child task completed",
)
refreshed_parent = await task_queue_module.task_queue.get_task(parent.task_id)
self.assertEqual(refreshed_parent.status, task_queue_module.TaskStatus.COMPLETED)
self.assertGreaterEqual(len(event_calls), 2)
async def test_finalize_parent_after_child_failure_is_terminal(self):
async def fake_emit_event(*args, **kwargs):
return None
runtime_patch = patch.object(orchestrator_main.swarm_runtime, "emit_event", fake_emit_event)
runtime_patch.start()
self.addCleanup(runtime_patch.stop)
parent = await task_queue_module.task_queue.create_task(
task_id="parent-2",
description="Parent task",
title="Parent",
agent_role="backend",
source="runtime_bridge",
max_retries=3,
)
child = await task_queue_module.task_queue.create_task(
task_id="child-2",
description="Child task",
title="Child",
agent_role="testing",
parent_task_id=parent.task_id,
root_task_id=parent.task_id,
source="dynamic_handoff",
max_retries=1,
)
await task_queue_module.task_queue.add_child_task(parent.task_id, child.task_id)
await task_queue_module.task_queue.block_task(parent.task_id, "Waiting on child")
await task_queue_module.task_queue.fail_task(child.task_id, "child failed")
refreshed_child = await task_queue_module.task_queue.get_task(child.task_id)
run = SimpleNamespace()
await orchestrator_main.finalize_parent_after_child(
run,
refreshed_child,
agent_id="agent-1",
success=False,
summary="child failed",
)
refreshed_parent = await task_queue_module.task_queue.get_task(parent.task_id)
self.assertEqual(refreshed_parent.status, task_queue_module.TaskStatus.FAILED)
async def test_build_result_artifact_emits_document_without_git_branch(self):
run = SimpleNamespace(
deployment_id="runtime-dep-1",
manager_deployment_id="dep-manager-1",
swarm_id="swarm-1",
)
task = SimpleNamespace(
task_id="task-1",
title="Backend implementation",
agent_role="backend",
parent_task_id=None,
root_task_id="task-1",
context={},
)
result = {
"summary": "Implemented backend endpoints and startup notes",
"files_modified": ["backend/app.py", "README.md"],
"changes": "Added handlers and docs",
"git_skipped": "Workspace is not a Git checkout",
}
artifact = orchestrator_main.build_result_artifact(run, task, result)
self.assertEqual(artifact["artifact_type"], "deployment_manifest")
self.assertEqual(artifact["uri"], "runtime://swarm-1/artifacts/task-1")
self.assertEqual(
artifact["metadata"]["files_modified"],
["backend/app.py", "README.md"],
)
async def test_artifact_created_event_copies_artifact_into_payload(self):
fake_runtime_redis = FakeRedisClient()
runtime_patch = patch.object(
swarm_runtime_module,
"redis_client",
SimpleNamespace(
set=fake_runtime_redis.set,
get=fake_runtime_redis.get,
keys=fake_runtime_redis.keys,
rpush=fake_runtime_redis.rpush,
lrange=fake_runtime_redis.lrange,
),
)
runtime_patch.start()
self.addCleanup(runtime_patch.stop)
runtime = swarm_runtime_module.SwarmRuntime()
run = swarm_runtime_module.SwarmRun(
deployment_id="runtime-dep-1",
swarm_id="swarm-1",
status="running",
objective="Test callback payload",
manager_deployment_id="dep-manager-1",
)
await runtime.emit_event(
run,
"artifact.created",
task_id="task-1",
artifact={
"artifact_id": "art_task-1",
"artifact_type": "document",
"title": "Task result",
"summary": "Generated a result summary",
"uri": "runtime://swarm-1/artifacts/task-1",
},
)
logs = await runtime.list_events(run.swarm_id)
event = logs["events"][-1]
self.assertEqual(event["payload"]["artifact_id"], "art_task-1")
self.assertEqual(event["payload"]["artifact_type"], "document")
self.assertEqual(event["artifact"]["uri"], "runtime://swarm-1/artifacts/task-1")
async def test_validate_create_request_accepts_new_swarm_shape(self):
runtime = swarm_runtime_module.SwarmRuntime()
runtime.validate_create_request({
"mode": "swarm",
"conversation_id": "conv-1",
"requirement": {
"objective": "Build a swarm-delivered feature",
"context": ["ticket-123"],
"attachments": [],
"constraints": ["keep existing API"],
"acceptance_criteria": ["tests pass"],
},
"model_selection": {
"type": "primary",
"primary_model": "gpt-5.4",
},
"metadata": {
"manager_deployment_id": "dep-manager-1",
"correlation_id": "corr-1",
},
"callback": {
"url": "https://manager.example/api/agent/callbacks/runtime-events",
},
})
async def test_validate_create_request_rejects_missing_objective(self):
runtime = swarm_runtime_module.SwarmRuntime()
with self.assertRaises(swarm_runtime_module.RuntimeValidationError):
runtime.validate_create_request({
"mode": "swarm",
"requirement": {},
"metadata": {"manager_deployment_id": "dep-manager-1"},
"callback": {"url": "https://manager.example/callback"},
})
async def test_get_run_by_identifier_supports_manager_deployment_id(self):
fake_runtime_redis = FakeRedisClient()
runtime_patch = patch.object(
swarm_runtime_module,
"redis_client",
SimpleNamespace(
set=fake_runtime_redis.set,
get=fake_runtime_redis.get,
keys=fake_runtime_redis.keys,
rpush=fake_runtime_redis.rpush,
lrange=fake_runtime_redis.lrange,
),
)
runtime_patch.start()
self.addCleanup(runtime_patch.stop)
runtime = swarm_runtime_module.SwarmRuntime()
run = swarm_runtime_module.SwarmRun(
deployment_id="runtime-dep-1",
swarm_id="swarm-1",
status="running",
objective="Test identifier lookup",
manager_deployment_id="dep-manager-1",
)
await runtime.save_run(run)
loaded = await runtime.get_run_by_identifier("dep-manager-1")
self.assertIsNotNone(loaded)
self.assertEqual(loaded.swarm_id, "swarm-1")
async def test_runtime_routes_include_agent_swarm_paths(self):
route_paths = {route.path for route in orchestrator_main.app.routes}
self.assertIn("/api/agent/health", route_paths)
self.assertIn("/api/agent/swarm/deployments", route_paths)
self.assertIn("/api/agent/swarm/deployments/{deployment_id}/events", route_paths)
self.assertIn("/api/agent/swarm/deployments/{deployment_id}/workflow", route_paths)
self.assertIn("/api/agent/swarm/deployments/{deployment_id}/diagnostics", route_paths)
async def test_build_deliverable_fact_marks_summary_only_without_diff(self):
result = {"summary": "Only a plan document"}
deliverable = orchestrator_main.build_deliverable_fact(result)
self.assertFalse(deliverable["has_deliverable"])
self.assertTrue(deliverable["summary_only"])
self.assertFalse(deliverable["has_diff"])
async def test_build_workflow_phases_returns_fixed_phase_order(self):
run = SimpleNamespace(status="running", approvals={})
task = SimpleNamespace(
task_id="task-1",
title="Backend",
agent_role="backend",
status=SimpleNamespace(value="in_progress"),
started_at=10,
completed_at=None,
result=None,
)
phases = orchestrator_main.build_workflow_phases(
run,
[task],
[{"event_type": "task.created", "payload": {}}],
)
self.assertEqual(
[phase["name"] for phase in phases],
["Plan", "Dispatch", "Execute", "Handoff", "Review", "Deliver"],
)
if __name__ == "__main__":
unittest.main()