评审/返工时间线对客户端可见: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:
co-authored by
Claude Opus 4.8
parent
c04c306d78
commit
a7d2bb2870
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user