回应 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>
420 lines
16 KiB
Python
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()
|