回应 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>
164 lines
7.3 KiB
Python
164 lines
7.3 KiB
Python
"""Contract-freeze tests (agent_swarm#14 / #15) for the Manager/client query contract.
|
|
|
|
Verifies the frozen Swarm->Manager callback contract that the desktop "task cockpit"
|
|
(heicode-mananger #28/#45/#46) consumes:
|
|
* every event envelope carries a per-swarm strictly-increasing, gap-free `sequence` (#15.1);
|
|
* the 13 frozen client-facing event types exist and round-trip with a sequence (#15.2);
|
|
* approval.approved/rejected fire from the real approval-decision site (#15.2);
|
|
* swarm.stopped fires from the real stop site; swarm.completed/failed are defined (#15.2);
|
|
* artifact.created carries the flat {uri, checksum, task_id, created_at} shape (#15.4);
|
|
* secret_ref/credential_ref pass through as azkv refs but plaintext creds are redacted (#15.3).
|
|
|
|
Hermetic: REDIS_FAKE, no model key, no real Manager callback (callback url empty).
|
|
|
|
Run from agent_swarm_v6 (install deps first — needs fakeredis):
|
|
pip install -r orchestrator/requirements.txt
|
|
REDIS_FAKE=1 python scripts/test-contract-freeze.py
|
|
"""
|
|
import asyncio
|
|
import json
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
os.environ["REDIS_FAKE"] = "1"
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
from orchestrator.redis_client import redis_client
|
|
from orchestrator import swarm_runtime as sr_mod
|
|
from orchestrator.swarm_runtime import swarm_runtime, FROZEN_CLIENT_EVENT_TYPES
|
|
|
|
failures = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(("PASS" if cond else "FAIL"), "-", name)
|
|
if not cond:
|
|
failures.append(name)
|
|
|
|
|
|
async def stored_events(swarm_id):
|
|
raw = await redis_client.lrange(f"{swarm_runtime.EVENT_KEY_PREFIX}{swarm_id}", 0, -1)
|
|
return [json.loads(r) for r in raw]
|
|
|
|
|
|
async def new_run(objective="freeze test"):
|
|
body = {"mode": "swarm", "orchestration_plan": {"objective": objective},
|
|
# no callback url -> nothing is POSTed; events are still stored locally
|
|
"callback": {"url": "", "subscribed_events": []},
|
|
"metadata": {"manager_deployment_id": "m-freeze"}}
|
|
run, _ = await swarm_runtime.get_or_create_run(body=body, idempotency_key=None, correlation_id="cf")
|
|
return run
|
|
|
|
|
|
async def test_sequence_monotonic():
|
|
run = await new_run()
|
|
for i in range(5):
|
|
await swarm_runtime.emit_event(run, "task.running", task_id=f"t{i}", payload={"agent_role": "impl"})
|
|
evs = await stored_events(run.swarm_id)
|
|
seqs = [e.get("sequence") for e in evs]
|
|
check("every event carries a sequence", all(isinstance(s, int) for s in seqs))
|
|
check("sequence starts at 1 and is gap-free/increasing", seqs == list(range(1, len(seqs) + 1)))
|
|
|
|
# A second run has its OWN sequence space starting at 1 (per-swarm, not global).
|
|
run2 = await new_run()
|
|
await swarm_runtime.emit_event(run2, "task.running", task_id="x", payload={"agent_role": "impl"})
|
|
evs2 = await stored_events(run2.swarm_id)
|
|
check("sequence is per-swarm (second run restarts at 1)", evs2[0]["sequence"] == 1)
|
|
|
|
|
|
async def test_frozen_event_types_roundtrip():
|
|
check("frozen set has exactly 13 client event types", len(FROZEN_CLIENT_EVENT_TYPES) == 13)
|
|
expected = {
|
|
"task.created", "task.claimed", "task.running", "task.completed", "task.failed",
|
|
"handoff.created", "approval.requested", "approval.approved", "approval.rejected",
|
|
"artifact.created", "swarm.completed", "swarm.failed", "swarm.stopped",
|
|
}
|
|
check("frozen set matches the agreed 13", set(FROZEN_CLIENT_EVENT_TYPES) == expected)
|
|
|
|
run = await new_run()
|
|
for et in FROZEN_CLIENT_EVENT_TYPES:
|
|
await swarm_runtime.emit_event(run, et, payload={"status": "x"})
|
|
evs = await stored_events(run.swarm_id)
|
|
types = [e["event_type"] for e in evs]
|
|
# The run also emits deployment.status_changed on creation; assert the frozen 13 are all present.
|
|
check("all 13 frozen types round-trip with an envelope", expected.issubset(set(types)))
|
|
check("every frozen-type envelope has a sequence", all(isinstance(e["sequence"], int) for e in evs))
|
|
|
|
|
|
async def test_artifact_shape():
|
|
run = await new_run()
|
|
await swarm_runtime.emit_event(
|
|
run, "artifact.created", task_id="t-art",
|
|
artifact={"artifact_id": "art_1", "uri": "git://repo#main", "checksum": "abc123",
|
|
"size_bytes": 42},
|
|
)
|
|
ev = (await stored_events(run.swarm_id))[-1]
|
|
p = ev["payload"]
|
|
check("artifact payload has uri/checksum", p.get("uri") == "git://repo#main" and p.get("checksum") == "abc123")
|
|
check("artifact task_id defaulted from event task_id", p.get("task_id") == "t-art")
|
|
check("artifact created_at defaulted to event time", bool(p.get("created_at")))
|
|
check("artifact size_bytes preserved when known", p.get("size_bytes") == 42)
|
|
|
|
# size unknown -> NOT fabricated (rule #9)
|
|
run2 = await new_run()
|
|
await swarm_runtime.emit_event(run2, "artifact.created", task_id="t2",
|
|
artifact={"artifact_id": "art_2", "uri": "runtime://x", "checksum": "d"})
|
|
p2 = (await stored_events(run2.swarm_id))[-1]["payload"]
|
|
check("unknown size_bytes is omitted, not faked", "size_bytes" not in p2)
|
|
|
|
|
|
async def test_approval_and_stop_sites():
|
|
# approval.approved from the real decision site
|
|
run = await new_run()
|
|
run.approvals["ap1"] = {"approval_id": "ap1"}
|
|
await swarm_runtime.save_run(run)
|
|
await swarm_runtime.record_approval_decision(run.swarm_id, "ap1", {"decision": "approved"})
|
|
types = [e["event_type"] for e in await stored_events(run.swarm_id)]
|
|
check("approval.approved emitted on approve", "approval.approved" in types)
|
|
|
|
run2 = await new_run()
|
|
run2.approvals["ap2"] = {"approval_id": "ap2"}
|
|
await swarm_runtime.save_run(run2)
|
|
await swarm_runtime.record_approval_decision(run2.swarm_id, "ap2", {"decision": "rejected", "reason": "no"})
|
|
types2 = [e["event_type"] for e in await stored_events(run2.swarm_id)]
|
|
check("approval.rejected emitted on reject", "approval.rejected" in types2)
|
|
|
|
# swarm.stopped from the real stop site
|
|
run3 = await new_run()
|
|
await swarm_runtime.stop_run(run3.deployment_id, reason="manager stop")
|
|
ev3 = await stored_events(run3.swarm_id)
|
|
check("swarm.stopped emitted on stop_run", "swarm.stopped" in [e["event_type"] for e in ev3])
|
|
stopped = [e for e in ev3 if e["event_type"] == "swarm.stopped"][0]
|
|
check("swarm.stopped payload status=stopped", stopped["payload"].get("status") == "stopped")
|
|
|
|
|
|
async def test_redaction():
|
|
run = await new_run()
|
|
await swarm_runtime.emit_event(run, "approval.requested", payload={
|
|
"approval_id": "ap",
|
|
"secret_ref": "azkv://vault/secrets/x", # azkv reference: passes through (not plaintext)
|
|
"access_token": "PLAINTEXT-SHOULD-VANISH",
|
|
})
|
|
p = (await stored_events(run.swarm_id))[-1]["payload"]
|
|
check("secret_ref (azkv ref) passes through for HM to strip", p.get("secret_ref") == "azkv://vault/secrets/x")
|
|
check("plaintext access_token is redacted", p.get("access_token") == "[redacted]")
|
|
|
|
|
|
async def main():
|
|
await redis_client.connect()
|
|
await test_sequence_monotonic()
|
|
await test_frozen_event_types_roundtrip()
|
|
await test_artifact_shape()
|
|
await test_approval_and_stop_sites()
|
|
await test_redaction()
|
|
print()
|
|
if failures:
|
|
print(f"{len(failures)} contract-freeze check(s) FAILED: {failures}")
|
|
sys.exit(1)
|
|
print("all contract-freeze checks passed")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main())
|