评审/返工时间线对客户端可见:review/rework 4 类脱敏事件纳入冻结集(Refs #34)

文档:event-schema §4 增 4 类(标 ⭐ + 脱敏说明)、header 13→17;frontend-event-api 评审/返工时间线行推进为已定义 + header 注明部分推进 + 剩余跨仓项(HM 注册、cockpit 渲染、仅脱敏摘要);review-loop-protocol §3.2 由"事件不进 Manager 流"更正为"已脱敏外发"。

测试:新增 scripts/test-review-timeline-events.py(单元投影脱敏 + run_cross_review 真实站点发出 + 断言无 evidence/summary 泄漏 + 4 类在冻结集);test-contract-freeze 的 13-精确断言改为"13 核心为子集"(因 #34 扩到 17)。接入 CI。本地全绿(含 e2e/cross-review 回归)。

影响范围:仅 agent_swarm(orchestrator + docs + 测试 + CI)。Manager:回调新增 4 类(向后兼容;HM agent_callback.go 需登记方可对客户端暴露)。密钥/内容:脱敏投影不含 evidence/summary/原文/密钥。不改契约鉴权/计费/审批链。

Refs #34

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Songhaoz666
2026-06-11 17:10:57 +08:00
co-authored by Claude Opus 4.8
parent c04c306d78
commit a7d2bb2870
9 changed files with 264 additions and 8 deletions
+4 -2
View File
@@ -68,13 +68,15 @@ async def test_sequence_monotonic():
async def test_frozen_event_types_roundtrip():
check("frozen set has exactly 13 client event types", len(FROZEN_CLIENT_EVENT_TYPES) == 13)
# The #28 freeze defined these 13 core client types; #34 later added 4 review/rework types
# (review.started/decision_made, rework.requested/completed). Assert the 13 core are present
# (subset), not an exact count, so adding client events doesn't break this contract test.
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)
check("the 13 #28-core client types are all in the frozen set", expected.issubset(set(FROZEN_CLIENT_EVENT_TYPES)))
run = await new_run()
for et in FROZEN_CLIENT_EVENT_TYPES:
+131
View File
@@ -0,0 +1,131 @@
"""Review/rework client-timeline event tests (issue #34).
The runtime cross-review (#11) was internal-only. #34 promotes a REDACTED client projection of
review.started / review.decision_made / rework.requested / rework.completed to the frozen
client-visible set so the cockpit can render the review/rework timeline.
Verifies:
* the 4 types are in FROZEN_CLIENT_EVENT_TYPES;
* run_cross_review emits review.started + review.decision_made + rework.requested on a failing
cross-review round (real site);
* emitted payloads carry ONLY the allowlist — NO evidence / summary / rework_reason free text
(security-boundary / #34 redaction);
* the client-projection builders drop content fields.
Hermetic: REDIS_FAKE, no model key, no callback url.
Run from agent_swarm_v6 (install deps first):
pip install -r orchestrator/requirements.txt
REDIS_FAKE=1 MAX_REVIEW_CYCLES=2 python scripts/test-review-timeline-events.py
"""
import asyncio
import json
import os
import sys
from pathlib import Path
os.environ["REDIS_FAKE"] = "1"
os.environ["MAX_REVIEW_CYCLES"] = "2"
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from orchestrator import cross_review as cr
from orchestrator.swarm_runtime import FROZEN_CLIENT_EVENT_TYPES
failures = []
SECRET_EVIDENCE = "SECRET-EVIDENCE-prompt-and-code-content"
SECRET_SUMMARY = "SECRET-SUMMARY-free-text"
def check(name, cond):
print(("PASS" if cond else "FAIL"), "-", name)
if not cond:
failures.append(name)
def test_frozen_set_and_builders():
for et in cr.CLIENT_REVIEW_EVENT_TYPES:
check(f"{et} in frozen client set", et in FROZEN_CLIENT_EVENT_TYPES)
check("frozen set now has 17 client types", len(FROZEN_CLIENT_EVENT_TYPES) == 17)
# decision projection drops evidence/summary/confidence/weight
decisions = [
cr.ReviewDecision(verdict="pass", reviewer_agent_id="r1", evidence=[SECRET_EVIDENCE], summary=SECRET_SUMMARY),
cr.ReviewDecision(verdict="fail", reviewer_agent_id="r2", failed_criteria=["tests missing"],
recommended_rework=["t1"], evidence=[SECRET_EVIDENCE], summary=SECRET_SUMMARY),
]
verdict = cr.aggregate_reviews(decisions)
p = cr.review_decision_client_payload("s1", verdict, cycle=0)
blob = json.dumps(p)
check("decision projection has no evidence/summary content", SECRET_EVIDENCE not in blob and SECRET_SUMMARY not in blob)
check("decision projection reviewers expose only allowlist keys",
all(set(r.keys()) == {"reviewer_agent_id", "verdict", "failed_criteria", "affected_tasks", "recommended_rework"}
for r in p["reviewers"]))
# rework.requested projection drops rework_reason/evidence
attr = cr.ReworkAttribution(target_task_id="t1", rework_reason=SECRET_SUMMARY,
root_cause=cr.ReworkCategory.TEST, evidence=[SECRET_EVIDENCE])
rp = cr.rework_requested_client_payload("s1", attr, cycle=1)
rblob = json.dumps(rp)
check("rework projection has no reason/evidence content", SECRET_SUMMARY not in rblob and SECRET_EVIDENCE not in rblob)
check("rework projection exposes root_cause", rp.get("root_cause") == "test" and rp.get("task_id") == "t1")
async def test_emitted_from_run_cross_review():
from orchestrator.redis_client import redis_client
from orchestrator.swarm_runtime import swarm_runtime
from orchestrator.task_queue import task_queue
from orchestrator import main as orch
await redis_client.connect()
body = {"mode": "swarm", "orchestration_plan": {"objective": "review me"},
"callback": {"url": "", "subscribed_events": []},
"metadata": {"manager_deployment_id": "m-rev"}}
run, _ = await swarm_runtime.get_or_create_run(body=body, idempotency_key=None, correlation_id="c-rev")
t1 = await task_queue.create_task(task_id=f"{run.swarm_id}-t1", description="impl",
agent_role="impl", required_capabilities=["python"], enqueue=True)
await swarm_runtime.attach_task(run, t1.task_id)
# Must be terminal for reopen_task to send it back for rework.
await task_queue.complete_task(t1.task_id, result=json.dumps({"summary": "done"}))
# Two reviews: 1 pass / 1 fail (tie -> safe-reject) with the fail recommending rework on t1.
run.metadata["reviews"] = [
cr.ReviewDecision(verdict="pass", reviewer_agent_id="rev-1",
evidence=[SECRET_EVIDENCE], summary=SECRET_SUMMARY).to_dict(),
cr.ReviewDecision(verdict="fail", reviewer_agent_id="rev-2", failed_criteria=["missing tests"],
recommended_rework=[t1.task_id], evidence=[SECRET_EVIDENCE], summary=SECRET_SUMMARY).to_dict(),
]
await swarm_runtime.save_run(run)
reopened = await orch.run_cross_review(run, [t1])
check("run_cross_review reopened (failing round)", reopened is True)
raw = await redis_client.lrange(f"{swarm_runtime.EVENT_KEY_PREFIX}{run.swarm_id}", 0, -1)
events = [json.loads(r) for r in raw]
types = [e["event_type"] for e in events]
check("review.started emitted", "review.started" in types)
check("review.decision_made emitted", "review.decision_made" in types)
check("rework.requested emitted", "rework.requested" in types)
full = json.dumps(events)
check("NO evidence/summary content leaked in any emitted event",
SECRET_EVIDENCE not in full and SECRET_SUMMARY not in full)
check("no 'evidence'/'rework_reason' keys in emitted review/rework payloads",
all("evidence" not in (e.get("payload") or {}) and "rework_reason" not in (e.get("payload") or {})
for e in events if e["event_type"].startswith(("review.", "rework."))))
dm = [e for e in events if e["event_type"] == "review.decision_made"][0]
check("review.decision_made carries rework_targets + reviewers", "rework_targets" in dm["payload"] and "reviewers" in dm["payload"])
async def main():
test_frozen_set_and_builders()
await test_emitted_from_run_cross_review()
print()
if failures:
print(f"{len(failures)} review-timeline check(s) FAILED: {failures}")
sys.exit(1)
print("all review-timeline event checks passed")
if __name__ == "__main__":
asyncio.run(main())