Merge pull request #28 from xmindlab-heicode/feat/contract-freeze

契约冻结 v1:Manager/客户端 Swarm Run 查询契约(Refs #2 #14 #15)
This commit is contained in:
Fasthei
2026-06-10 18:36:38 +08:00
committed by GitHub
11 changed files with 350 additions and 38 deletions
+4
View File
@@ -59,6 +59,10 @@ jobs:
env: { REDIS_FAKE: "1" }
run: python scripts/test-contract-events.py
- name: Manager/client contract freeze (sequence + 13 events + artifact) (#14/#15)
env: { REDIS_FAKE: "1" }
run: python scripts/test-contract-freeze.py
- name: Benchmark metric formulas (v2.1)
env: { REDIS_FAKE: "1" }
run: python scripts/test-benchmark-metrics.py
+2
View File
@@ -31,7 +31,9 @@
python scripts/test-runtime-contract.py
python scripts/test-merge-smoke.py
python scripts/test-workflow-e2e.py
python scripts/test-contract-freeze.py # Manager/client 契约冻结(sequence + 13 事件 + artifact,#14/#15)
```
- **Manager/客户端契约冻结(FROZEN v1)**:回调 envelope 带 per-swarm 严格递增 `sequence`;客户端 13 类事件冻结于 `swarm_runtime.FROZEN_CLIENT_EVENT_TYPES`(task.* / handoff.created / approval.requested|approved|rejected / artifact.created / swarm.completed|failed|stopped);artifact 扁平字段 `{uri,checksum,task_id,size_bytes?,created_at}`;状态机映射见 `docs/integration/runtime-contract.md §4.1`。改动这些字段/类型/状态前必须先读 `docs/integration/{event-schema,runtime-contract}.md` 并同步 HM(agent_swarm#14/#15)。
## Agent / Teammate 协作
- 每个 teammate / agent 必须遵循本 `CLAUDE.md` 与 heicodeDocs。
+45 -29
View File
@@ -1,6 +1,12 @@
# Swarm 运行时事件 Schema(回调 / 事件流)
> 状态:**已对齐 HM 实现**(依据 `heicode-mananger/heicode/controller/agent_callback.go`,handler `POST /api/agent/callbacks/runtime-events`)。本文是 Swarm → HM 回调与 `GET …/{id}/events` 事件流的统一 schema。
> 状态:**FROZEN v1(契约冻结)** —— 回应 `agent_swarm#15`(来自 HM #28/#45/#46 客户端「任务驾驶舱」消费需求)。本文是 Swarm → HM 回调与 `GET …/{id}/events` 事件流的统一 schema,已对齐 HM 实现(`heicode-mananger/heicode/controller/agent_callback.go`,handler `POST /api/agent/callbacks/runtime-events`)。
>
> 冻结要点(实现见 `orchestrator/swarm_runtime.py`,契约测试 `scripts/test-contract-freeze.py`):
> 1. envelope 带 **per-swarm 严格递增 `sequence`**(从 1、无空洞,供客户端 `events?after=` 去重/续传,§2)。
> 2. **客户端 13 类事件**(§4 标 ⭐)冻结为 `FROZEN_CLIENT_EVENT_TYPES`(`swarm_runtime.py`)。
> 3. **artifact** 扁平字段 `{uri, checksum, task_id, size_bytes?, created_at}`(§3)。
> 4. **凭据红线**:`secret_ref`/`credential_ref`/`signing_secret_ref` 为 `azkv://` 引用(非明文),Swarm 侧 `_redact_sensitive` 透传引用、剔除明文 `*_token/_secret/_key/password`;客户端可见视图由 HM 再做递归脱敏。
>
> 配套:生命周期与签名见 [`runtime-contract.md`](./runtime-contract.md)。
@@ -22,6 +28,7 @@
{
"event_id": "evt-...", // 必填;去重主键
"idempotency_key": "...", // 可选;缺省回退 event_id
"sequence": 12, // 必填;per-swarm 严格递增(从 1、无空洞);客户端按此去重/升序/续传
"event_type": "task.completed", // 必填;取值见 §4
"deployment_id": "runtime-dep-...",// 运行时部署 ID
"swarm_id": "swarm-...", // 工作流 ID(事件按此持久化)
@@ -44,41 +51,47 @@
"artifact_type": "code_patch | document | deployment_manifest",
"title": "...",
"summary": "...",
"uri": "git://repo#branch | runtime://...",
"uri": "git://repo#branch | runtime://...", // 仅 uri,绝不含 secret_ref/凭据
"checksum": "<commit_sha>",
"task_id": "...", // 缺省回填 envelope.task_id(#15.4)
"size_bytes": 1234, // 可选;未知则省略(不伪造,规则 #9)
"created_at": "2026-06-08T...Z", // 缺省回填 envelope.occurred_at
"metadata": { "redacted": true, "agent_role": "...", "files_modified": [] }
}
```
> 客户端 artifact 视图(HM #28)取扁平 `{uri, checksum, task_id, size_bytes?, created_at}`;HM 由 `artifact.created` envelope 的上述字段直接产出,无 `secret_ref`/密钥。
## 4. 事件类型注册表(与 HM 一致)
⭐ = **冻结的客户端 13 类**(`FROZEN_CLIENT_EVENT_TYPES`,驾驶舱据此渲染;HM `agent_callback.go` 据此对齐注册表)。其余为 Swarm 也发的辅助/运行时事件(HM 仍消费,但非客户端必需)。
| event_type | payload 必填字段(HM 校验) | 分类 | Swarm 是否发出 |
|---|---|---|---|
| `deployment.status_changed` | `status` | deployment | ✅ |
| `phase.changed` | `stage` 或 `checkpoint` | ordinary_sub | ❌(暂未用) |
| `agent.started` | `agent_role` | ordinary_sub | ❌ |
| `agent.completed` | `agent_role` | ordinary_sub | ❌ |
| `agent.crashed` | `agent_role`, `reason` | ordinary_sub | ❌ |
| `task.created` | `task_id`, `title` | swarm_task_flow | ✅ |
| `task.claimed` | `task_id`, `agent_role` | swarm_task_flow | ✅ |
| `task.running` | `task_id`, `agent_role` | swarm_task_flow | ✅ |
| ⭐ `task.created` | `task_id`, `title` | swarm_task_flow | ✅ |
| ⭐ `task.claimed` | `task_id`, `agent_role` | swarm_task_flow | ✅ |
| ⭐ `task.running` | `task_id`, `agent_role` | swarm_task_flow | ✅ |
| ⭐ `task.completed` | `task_id` | swarm_task_flow | ✅ |
| ⭐ `task.failed` | `task_id`, `reason` | swarm_task_flow | ✅ |
| ⭐ `handoff.created` | `task_id`, `from_role`, `to_role` | swarm_task_flow | ✅(child 任务建立时,与 `handoff.requested` 并发) |
| ⭐ `approval.requested` | `approval_id`, `operation`, `risk_level` | approval | ✅ |
| ⭐ `approval.approved` | `approval_id` | approval | ✅(审批决定落地时) |
| ⭐ `approval.rejected` | `approval_id` | approval | ✅(审批决定落地时) |
| ⭐ `artifact.created` | `artifact_id`, `uri`, `checksum` | artifact | ✅(带 §3 扁平字段) |
| ⭐ `swarm.completed` | `status` | swarm_terminal | ✅(终态,与 `deployment.status_changed` 并发) |
| ⭐ `swarm.failed` | `status` | swarm_terminal | ✅(终态) |
| ⭐ `swarm.stopped` | `status` | swarm_terminal | ✅(`stop_run`) |
| `deployment.status_changed` | `status` | deployment | ✅(HM 用于 AgentDeployment.Status;客户端用 swarm.* 终态) |
| `task.heartbeat` | `task_id`, `agent_role` | swarm_task_flow | ✅ |
| `task.blocked` | `task_id`, `reason` | swarm_task_flow | ✅ |
| `task.retried` | `task_id`, `attempt` | swarm_task_flow | ✅ |
| `task.released` | `task_id`, `agent_role` | swarm_task_flow | ⚠️ 未发出(见 §6) |
| `task.failed` | `task_id`, `reason` | swarm_task_flow | ✅ |
| `task.completed` | `task_id` | swarm_task_flow | ✅ |
| `task.released` | `task_id`, `agent_role` | swarm_task_flow | ✅ |
| `handoff.requested` | `task_id`, `from_role`, `to_role` | swarm_task_flow | ✅ |
| `handoff.completed` | `task_id`, `from_role`, `to_role` | swarm_task_flow | ⚠️ 缺 `from_role`/`to_role`(见 §6) |
| `approval.requested` | `approval_id`, `operation`, `risk_level` | approval | ✅ |
| `artifact.created` | `artifact_id` | artifact | ✅ |
| `timeline.updated` | `title` | timeline | ⚠️ 发出 `summary`,缺 `title`(见 §6) |
| `sk_tool.called` | `tool_name`, `tool_invocation_id` | sk | ❌(无 SK 工具) |
| `sk_tool.completed` | `tool_name`, `tool_invocation_id` | sk | ❌ |
| `sk_tool.failed` | `tool_name`, `tool_invocation_id`, `reason` | sk | ❌ |
| `budget.alert` | `threshold_pct` | budget | ⚠️ 发出 `threshold`,缺 `threshold_pct`(见 §6) |
| `timeline.updated` | `title` | timeline | ✅(emit 时回填 `title`=`summary`) |
| `budget.alert` | `threshold_pct` | budget | ✅(emit 时回填 `threshold_pct`) |
| `phase.changed` / `agent.*` / `sk_tool.*` | — | ordinary_sub / sk | ❌(本运行时不产生) |
> 说明:评审/重做循环复用 `task.retried` + `timeline.updated`(`Review cycle N`)表达,无单独 review 事件类型;如 HM 前端需要独立 review 事件,列为待对齐项。
> 说明:评审/重做循环复用 `task.retried` + `timeline.updated`(`Review cycle N`)表达,无单独 review 事件类型;蜂群内部遥测(提案/竞价/交叉评审/收敛/health)**不经回调外发**(见各协议文档「事件不进 Manager 流」),故不在本表。`swarm.completed/failed/stopped` 与 `approval.approved/rejected`、`handoff.created` 为本次冻结**新增**,HM 注册表需据此登记(agent_swarm#15.2)。
## 5. HM 响应
@@ -86,13 +99,16 @@
{ "success": true, "event_id": "evt-...", "inserted": true, "idempotent": false, "deduplicated": false, "deployment_id": "dep_..." }
```
## 6. 已知对齐缺口(需在 Swarm 代码修正)
## 6. 对齐状态(冻结时已修正 / 剩余小项)
下列为本仓 `orchestrator/` 当前发出字段与 HM 校验的差异,应在对应 emit 处修正后再宣称完全对齐:
冻结时 `orchestrator/swarm_runtime.emit_event` 已统一处理下列历史缺口:
1. **`timeline.updated`**:HM 要求 `title`;当前发 `summary`。修正:payload 增加 `title`(可复用 `summary`)。
2. **`budget.alert`**:HM 要求 `threshold_pct`;当前发 `threshold`(如 `0.8`)。修正:增加 `threshold_pct`(百分比,如 `80`)。
3. **`handoff.completed`**:HM 要求 `from_role`/`to_role`;当前缺。修正:在 `finalize_parent_after_child` / handoff 完成处补 `from_role`/`to_role`。
4. **`task.released`**:HM 注册表含此事件;`task_queue.release_task` 当前静默。修正:释放任务时发 `task.released`(`task_id`, `agent_role`)。
1. ✅ **`timeline.updated`**:emit 时若缺 `title` 自动回填(复用 `summary`)。
2. ✅ **`budget.alert`**:emit 时若缺 `threshold_pct` 由 `threshold` 自动换算(≤1 视为比例 ×100)。
3. ✅ **`sequence`**:每事件带 per-swarm 严格递增序号(INCR 计数键 `swarm_event_seq:{swarm_id}`)。
4. ✅ **artifact 扁平字段**:`created_at`/`task_id` 自动回填,`size_bytes` 透传(未知则省略,不伪造)。
> 这些是确定性的小修,建议配 contract test(按 HM 必填字段断言每类事件 payload)。
剩余小项(不影响客户端 13 类冻结):
- `handoff.completed` 的 `from_role`/`to_role`:辅助事件,客户端 13 类用 `handoff.created`(已带 from/to);如 HM 仍消费 `handoff.completed`,在 handoff 完成处补两字段为后续小修。
> 契约由 `scripts/test-contract-freeze.py` 守护(断言 sequence 单调、13 类 round-trip、artifact 形状、审批/停止真实发出、明文凭据脱敏);`scripts/test-workflow-e2e.py` 在全流程 e2e 中额外断言 `swarm.completed` + sequence 无空洞。
+32 -8
View File
@@ -1,12 +1,16 @@
# Manager ↔ Swarm Runtime Contract(Swarm 侧拥有)
> 状态:**草案(Swarm 侧拥有,待 HM 对齐)** · 对齐对象:Heicode Manager Runtime Team
> 状态:**FROZEN v1(契约冻结)** —— 回应 `agent_swarm#14`(来自 HM #28/#45 客户端「任务驾驶舱」查询面:stop 端点 + ID 映射 + 状态机)。对齐对象:Heicode Manager Runtime Team。
>
> 冻结结论(供 HM #45 接 stop 真实调用):
> - **stop 端点**:`POST /api/agent/swarm/deployments/{id}/stop`(§3),已实现,幂等键 `X-Idempotency-Key`。
> - **ID 映射**:`deployment_id ↔ swarm_id ↔ manager_deployment_id`(§3.2),`get_run_by_identifier` 支持任一 id 查询。
> - **状态机**:运行时真实状态见 §4;与客户端 #28 期望(含 `preparing/degraded/verifying`)的**映射表见 §4.1**(运行时不新增臆造状态,规则 #9)。
>
> 依据:
> - HM 侧裁定 `heicode-mananger/docs/integration/heicode-swarm-deferred.md`:**HM 当前不实现 swarm runtime**;多 Agent 蜂群归 `agent_swarm` / `HeiCode-Swarm`(本仓);HM 侧待本仓给出正式 `/api/agent/swarm/*` 接口后,在 HM 的 `docs/integration/` 另立契约跟踪。
> - HM 侧裁定 `heicode-mananger/docs/integration/heicode-swarm-deferred.md`:**HM 当前不实现 swarm runtime**;多 Agent 蜂群归 `agent_swarm` / `HeiCode-Swarm`(本仓)。
> - 既有运行时集成范式 `heicode-mananger/docs/integration/heicode-am-contract.md`(单 Agent 模板 Agent,经 AM 启动)。本契约沿用其鉴权、回调、env、路径覆盖等约定。
>
> 本文是 HM 侧 deferred 锚点所要求的「Swarm 侧正式接口」起草。冻结需 Manager Runtime Team 评审。
> - 事件 envelope/类型见 [`event-schema.md`](./event-schema.md)(同批冻结,`agent_swarm#15`)。
## 1. 角色与边界
@@ -65,7 +69,23 @@ create 响应 `data`:`deployment_id`、`runtime_deployment_id`、`manager_depl
| `failed` | 存在失败且不可恢复 | 任务终态含 failed |
| `stopped` | Manager 主动停止 | `…/stop` |
每次状态变更通过回调 `deployment.status_changed` 推送(见 §5)。
每次状态变更通过回调 `deployment.status_changed` 推送(见 §5);**终态另发** `swarm.completed`/`swarm.failed`/`swarm.stopped`(客户端驾驶舱据此切终态横幅,见 event-schema §4)。
### 4.1 与客户端期望状态的映射(#14.3 决议:映射,不新增运行时状态)
客户端 #28 期望 `created/preparing/running/waiting_approval/degraded/verifying/completed/failed/stopped`。本运行时**不臆造** `preparing/degraded/verifying` 等中间态(规则 #9:无真实信号不造态),由 HM/客户端按下表映射现有真实状态:
| 客户端期望态 | 运行时真实来源 | 映射口径 |
|---|---|---|
| `created` | create 成功、尚无在途任务 | 初始 `running` 前的瞬态;HM 可在落 `runtime_swarm_id` 后、首个 `task.*` 前显示 |
| `preparing` | 同上(播种中) | 映射到 `running`(种子已注入、Agent 尚未自选);无独立状态 |
| `running` | `running` | 直通 |
| `waiting_approval` | `waiting_approval` | 直通 |
| `degraded` | `blocked` | `blocked`(移交/依赖/审批驳回阻塞)映射为 `degraded`;P-guard `run.metadata["health"]` 不健康亦可佐证 |
| `verifying` | `running` + 交叉评审进行中 | 评审期仍是 `running`;如需细分,由 `timeline.updated`(`Review cycle N`) / `run.metadata["cross_review"]` 提示 |
| `completed`/`failed`/`stopped` | 同名终态 | 直通;并有 `swarm.{completed,failed,stopped}` 终态事件 |
> 即:运行时真实状态集 = `waiting_approval/running/blocked/completed/failed/stopped`(§4 表);`created/preparing/degraded/verifying` 是**展示层别名**,不改变运行时语义,也不进入回调 `status` 字段。如客户端坚持要真实细分态,需另立工单评估状态机扩展(非本次冻结范围)。
## 5. 回调(已与 HM 处理器对齐)
@@ -88,9 +108,13 @@ HM 侧可用 env 覆盖:`AGENT_RUNTIME_BASE_URL`、`AGENT_RUNTIME_SERVICE_TOKE
- `env`/请求体不得含明文密钥;凭据经 `secret_ref`(`azkv://`)注入。详见 [`security-boundary.md`](./security-boundary.md)。
- Swarm 启动接口必须 HTTPS / 私网;回调走 HTTPS。
## 8. 待对齐项(冻结前需 Manager Runtime Team 确认)
## 8. 冻结状态与剩余对齐项
- [ ] resume / retry 是否需要独立外部端点,还是沿用 approvals + 内部重做。
本次冻结(agent_swarm#14)已定稿:stop 端点(§3)、ID 映射(§3.2)、状态机 + 客户端映射表(§4/§4.1)、终态 swarm.* 事件。HM #45 可据此接 stop 真实调用。
剩余非阻塞项(不影响 #45 Phase1 接入):
- [ ] resume / retry 是否需要独立外部端点,还是沿用 approvals + 内部重做(当前:无独立端点)。
- [ ] HM 是否以 AM 同款 `/agents` 生命周期(而非 `/api/agent/swarm/*`)调用 Swarm;若是,需路径映射。
- [ ] `event-schema` 中各 event_type 的必填字段与 HM 注册表逐项核对(见 event-schema.md)。
- [ ] HM 注册表(`agent_callback.go`)登记本次新增的 `swarm.completed/failed/stopped`、`approval.approved/rejected`、`handoff.created`(见 event-schema §4 ⭐)。
- [ ] 更新 HM 侧 `heicode-swarm-deferred.md` 锚点,登记本契约。
- [ ] #46 Phase2(SSE 长连 + 追加输入写语义):待本契约 + event-schema 冻结后由 HM 把轮询升级 SSE(见 frontend-event-api.md §4)。
+27
View File
@@ -594,6 +594,16 @@ async def refresh_swarm_run_status(run):
deliverable=deliverable,
),
)
# Frozen terminal swarm event (agent_swarm#15.2): the client cockpit keys its terminal banner
# off swarm.{completed|failed|stopped}. Emitted alongside deployment.status_changed (which HM
# still consumes for AgentDeployment.Status); stopped is emitted in swarm_runtime.stop_run.
swarm_terminal_payload = {
"status": next_status,
"task_count": len(tasks),
}
if termination_reason:
swarm_terminal_payload["termination_reason"] = termination_reason
await swarm_runtime.emit_event(run, f"swarm.{next_status}", payload=swarm_terminal_payload)
timeline_payload = {
"summary": final_summary or f"Swarm run {next_status}",
"status": next_status,
@@ -2350,6 +2360,23 @@ async def websocket_endpoint(websocket: WebSocket, agent_id: str):
"parent_task_id": task.task_id,
},
)
# Frozen handoff event (agent_swarm#15.2): the client's 13-type set uses
# handoff.created (a new handoff/child task exists). Emitted additively
# alongside handoff.requested when a child task was actually created.
if child_task:
await swarm_runtime.emit_event(
run,
"handoff.created",
task_id=task_id,
agent_instance_id=agent_id,
payload={
"task_id": task_id,
"from_role": from_role,
"to_role": to_role,
"child_task_id": child_task.task_id,
"parent_task_id": task.task_id,
},
)
await swarm_runtime.emit_event(
run,
"timeline.updated",
+8
View File
@@ -82,6 +82,14 @@ class RedisClient:
"""Set key-value pair with optional expiration."""
await self.client.set(key, value, ex=ex)
async def incr(self, key: str) -> int:
"""Atomically increment an integer counter and return the new value.
Used for the per-swarm event sequence (strictly increasing from 1); INCR is
atomic so concurrent emits on the same swarm never collide on a number.
"""
return await self.client.incr(key)
async def get(self, key: str) -> Optional[str]:
"""Get value by key."""
return await self.client.get(key)
+53 -1
View File
@@ -79,6 +79,28 @@ class RuntimeValidationError(ValueError):
self.code = code
# Frozen client-facing event-type set (agent_swarm#15.2 / HM #28). These 13 types are the
# contract the desktop "task cockpit" renders against; HM (agent_callback.go) registers exactly
# these. The runtime emits other types too (deployment.status_changed, timeline.updated,
# budget.alert, task.heartbeat/blocked/retried/released) and many INTERNAL-only telemetry events
# that are NOT emitted to Manager (see assess_swarm_health / proposals / bids / reviews).
FROZEN_CLIENT_EVENT_TYPES = (
"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",
)
class SwarmRuntime:
"""Stores swarm runs and emits Agent Manager callback events."""
@@ -86,6 +108,7 @@ class SwarmRuntime:
TASK_RUN_KEY_PREFIX = "swarm_task:"
IDEMPOTENCY_KEY_PREFIX = "swarm_idempotency:"
EVENT_KEY_PREFIX = "swarm_events:"
EVENT_SEQ_KEY_PREFIX = "swarm_event_seq:"
def __init__(self):
self.callback_service_token = (
@@ -413,6 +436,12 @@ class SwarmRuntime:
reason=reason or "Heicode Manager requested stop",
),
)
# Frozen terminal swarm event (agent_swarm#15.2): the client cockpit keys its terminal
# banner off swarm.{completed|failed|stopped}, not deployment.status_changed.
await self.emit_event(run, "swarm.stopped", payload={
"status": "stopped",
"reason": reason or "Heicode Manager requested stop",
})
return run
async def find_run_by_deployment_id(self, deployment_id: str) -> Optional[SwarmRun]:
@@ -460,6 +489,15 @@ class SwarmRuntime:
reason=decision.get("reason"),
),
)
# Frozen approval-outcome events (agent_swarm#15.2): the client renders an approval
# timeline from approval.requested -> approval.approved|rejected.
decision_value = decision.get("decision")
if decision_value in ("approved", "rejected"):
await self.emit_event(run, f"approval.{decision_value}", payload={
"approval_id": approval_id,
"decision": decision_value,
"reason": decision.get("reason"),
})
if run.status == "blocked":
await self.emit_event(run, "task.blocked", payload={
"approval_id": approval_id,
@@ -483,9 +521,18 @@ class SwarmRuntime:
):
"""Record and optionally emit a Manager callback event."""
callback = run.callback
occurred_at = self._now_iso()
redacted_payload = self._redact_sensitive(payload or {})
redacted_artifact = self._redact_sensitive(artifact) if artifact else None
if redacted_artifact:
# Freeze (agent_swarm#15.4): the client artifact view needs a stable flat shape
# {uri, checksum, task_id, size_bytes, created_at}. Default created_at to the event time;
# leave size_bytes absent when unknown (rule #9: do not fabricate a size).
redacted_artifact.setdefault("created_at", occurred_at)
if task_id and "task_id" not in redacted_artifact:
redacted_artifact["task_id"] = task_id
if redacted_artifact and event_type == "artifact.created":
redacted_payload = {
**redacted_artifact,
@@ -502,16 +549,21 @@ class SwarmRuntime:
redacted_payload["threshold_pct"] = threshold * 100 if threshold <= 1 else threshold
event_id = f"evt_{uuid.uuid4().hex}"
# Per-swarm strictly-increasing sequence (agent_swarm#15.1): the client polls
# `events?after=<sequence>` and dedups/orders by it. INCR is atomic so concurrent emits on
# the same swarm get distinct, gap-free numbers starting at 1.
sequence = await redis_client.incr(f"{self.EVENT_SEQ_KEY_PREFIX}{run.swarm_id}")
body: Dict[str, Any] = {
"event_id": event_id,
"idempotency_key": event_id,
"sequence": sequence,
"event_type": event_type,
"deployment_id": run.manager_deployment_id or run.deployment_id,
"runtime_deployment_id": run.deployment_id,
"swarm_id": run.swarm_id,
"agent_instance_id": agent_instance_id,
"task_id": task_id,
"occurred_at": self._now_iso(),
"occurred_at": occurred_at,
"correlation_id": run.correlation_id,
"source": self.runtime_source,
"payload": redacted_payload,
@@ -23,6 +23,10 @@ class FakeRedisClient:
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)
+163
View File
@@ -0,0 +1,163 @@
"""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())
+4
View File
@@ -47,6 +47,10 @@ class FakeRedis:
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)]
+8
View File
@@ -107,6 +107,8 @@ async def main():
sources = [t.get("source") for t in tasks]
payloads = [(e.get("payload") or {}) for e in events]
termination_seen = any(p.get("termination_reason") for p in payloads)
ev_types = [e.get("event_type") for e in events]
seqs = [e.get("sequence") for e in events]
# ---- swarm-flow assertions (seed → agent-decompose → self-select → converge) ----
check("seed: a single objective seed task was injected (no Master plan)",
@@ -120,6 +122,12 @@ async def main():
check("converge: run reached completed", status == "completed")
check("converge: a termination_reason was emitted (convergence report)", termination_seen)
check("synthesize: a final unified summary is present", bool(wf.get("summary")))
# ---- frozen Manager/client contract (agent_swarm#14/#15) ----
check("contract: terminal swarm.completed event emitted", "swarm.completed" in ev_types)
check("contract: every event carries a per-swarm sequence",
bool(seqs) and all(isinstance(s, int) for s in seqs))
check("contract: sequence is strictly increasing and gap-free",
seqs == list(range(1, len(seqs) + 1)))
finally:
agent.running = False
if agent_task: