回应 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>
158 lines
5.0 KiB
Python
158 lines
5.0 KiB
Python
"""Lightweight contract checks for the HeiCode Agent Manager runtime bridge."""
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
import types
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
if "redis.asyncio" not in sys.modules:
|
|
redis_module = types.ModuleType("redis")
|
|
redis_asyncio_module = types.ModuleType("redis.asyncio")
|
|
redis_asyncio_module.Redis = object
|
|
redis_module.asyncio = redis_asyncio_module
|
|
sys.modules["redis"] = redis_module
|
|
sys.modules["redis.asyncio"] = redis_asyncio_module
|
|
if "httpx" not in sys.modules:
|
|
httpx_module = types.ModuleType("httpx")
|
|
|
|
class AsyncClient:
|
|
def __init__(self, *args, **kwargs):
|
|
pass
|
|
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
async def post(self, *args, **kwargs):
|
|
return types.SimpleNamespace(status_code=204, text="")
|
|
|
|
httpx_module.AsyncClient = AsyncClient
|
|
sys.modules["httpx"] = httpx_module
|
|
|
|
from orchestrator.redis_client import redis_client
|
|
from orchestrator.swarm_runtime import RuntimeValidationError, SwarmRuntime
|
|
|
|
|
|
class FakeRedis:
|
|
def __init__(self):
|
|
self.values = {}
|
|
self.lists = {}
|
|
|
|
async def set(self, key, value, ex=None):
|
|
self.values[key] = value
|
|
|
|
async def get(self, key):
|
|
return self.values.get(key)
|
|
|
|
async def incr(self, key):
|
|
self.values[key] = int(self.values.get(key, 0)) + 1
|
|
return self.values[key]
|
|
|
|
async def keys(self, pattern):
|
|
prefix = pattern.rstrip("*")
|
|
return [key for key in self.values if key.startswith(prefix)]
|
|
|
|
async def rpush(self, key, *values):
|
|
self.lists.setdefault(key, []).extend(values)
|
|
|
|
async def lrange(self, key, start, end):
|
|
items = self.lists.get(key, [])
|
|
if end == -1:
|
|
return items[start:]
|
|
return items[start : end + 1]
|
|
|
|
|
|
def valid_body():
|
|
return {
|
|
"orchestration_plan": {
|
|
"objective": "Add a multiply helper and push the branch",
|
|
"sub_mode": "code",
|
|
"risk_level": "low",
|
|
"budget": {"duration_seconds": 3600, "token_limit": 20000},
|
|
"agents": [
|
|
{
|
|
"task_id": "backend-1",
|
|
"role": "backend",
|
|
"title": "Backend change",
|
|
"description": "Implement the helper",
|
|
"depends_on": [],
|
|
"resource_grants": [
|
|
{"secret_ref": "azkv://heicode/git-write-token"}
|
|
],
|
|
}
|
|
],
|
|
},
|
|
"callback": {
|
|
"url": "http://manager.local/api/agnet/callbacks/swarm-events",
|
|
"subscribed_events": ["task.created"],
|
|
},
|
|
"metadata": {
|
|
"manager_deployment_id": "dep_manager_123",
|
|
"correlation_id": "corr_123",
|
|
},
|
|
"billing_context": {"secret_ref": "azkv://heicode/billing"},
|
|
"resource_grants": [{"ref": "azkv://heicode/repo-main"}],
|
|
}
|
|
|
|
|
|
async def main():
|
|
fake = FakeRedis()
|
|
redis_client.client = fake
|
|
runtime = SwarmRuntime()
|
|
os.environ["ENABLE_SUBTASK_HANDOFF"] = "true"
|
|
|
|
body = valid_body()
|
|
runtime.validate_create_request(body)
|
|
|
|
invalid = valid_body()
|
|
invalid["resource_grants"][0]["ref"] = "plain-token"
|
|
try:
|
|
runtime.validate_create_request(invalid)
|
|
raise AssertionError("plain resource ref was accepted")
|
|
except RuntimeValidationError:
|
|
pass
|
|
|
|
invalid = valid_body()
|
|
invalid["metadata"]["api_key"] = "secret-value"
|
|
try:
|
|
runtime.validate_create_request(invalid)
|
|
raise AssertionError("plaintext secret metadata was accepted")
|
|
except RuntimeValidationError:
|
|
pass
|
|
|
|
run, created = await runtime.get_or_create_run(body, "idem-1", "corr_123")
|
|
assert created is True
|
|
same_run, created_again = await runtime.get_or_create_run(body, "idem-1", "corr_123")
|
|
assert created_again is False
|
|
assert same_run.swarm_id == run.swarm_id
|
|
|
|
tasks = runtime.build_task_descriptions(body)
|
|
assert tasks[0]["task_id"] == "backend-1"
|
|
assert tasks[0]["depends_on"] == []
|
|
assert tasks[0]["workflow_mode"] == "multi_agent"
|
|
|
|
await runtime.emit_event(run, "timeline.updated", payload={"summary": "ok"})
|
|
logs = await runtime.list_events(run.swarm_id)
|
|
assert logs["events"][0]["event_type"] == "deployment.status_changed"
|
|
assert logs["events"][-1]["event_type"] == "timeline.updated"
|
|
|
|
approval_body = valid_body()
|
|
approval_body["orchestration_plan"]["risk_level"] = "high"
|
|
approval_run, _ = await runtime.get_or_create_run(approval_body, "idem-2", "corr_123")
|
|
approval_id = next(iter(approval_run.approvals))
|
|
rejected = await runtime.record_approval_decision(
|
|
approval_run.swarm_id,
|
|
approval_id,
|
|
{"decision": "rejected", "reason": "user rejected"},
|
|
)
|
|
assert rejected.status == "blocked"
|
|
|
|
print("runtime contract checks passed")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|