Files

183 lines
8.8 KiB
Python

#!/usr/bin/env python3
"""test_p2_orchestrator.py — 항목4: 얇은 end-to-end 오케스트레이터(state_engine next + /run-cascade).
리뷰 항목4: 상위 오케스트레이터가 없어 사람이 각 stage 커맨드를 수동 호출해야 했다. `state_engine.py
next` 는 현재/다음 stage·guard·사람게이트·커맨드를 결정론적으로 반환하는 얇은 조회층이고,
`/run-cascade` 는 그걸 따라 걷되 **사람 결정 지점에서 멈춘다**(자동 승인·완주 금지). 검증:
1. next_info: current/next stage·command, terminal, advance{ok,reasons,human-gate} 정확.
2. _human_gate_for: DECIDE go/no-go · RELEASE 는 human-gate, 그 외 전이는 아님.
3. next CLI 가 유효 JSON.
4. guard block 이 advance.reasons 로 노출(선행조건 미충족).
5. resumable: 사람 signoff 후 human-gate 해제.
6. terminal 정확.
7. /run-cascade 배선: state_engine next 재사용 + 사람게이트 정지 + 자동승인 금지.
실행: CLAUDE_PROJECT_DIR="$PWD" python3 .claude/tests/test_p2_orchestrator.py
"""
import json
import os
import subprocess
import sys
import tempfile
ROOT = os.environ.get("CLAUDE_PROJECT_DIR") or os.path.dirname(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
HOOKS = os.path.join(ROOT, ".claude", "hooks")
sys.path.insert(0, HOOKS)
_WS = tempfile.mkdtemp(prefix="p2orch_")
os.environ["CLAUDE_PROJECT_DIR"] = ROOT
os.environ["ORGOS_WORKSPACE"] = _WS
import state_engine as SE # noqa: E402
import acceptance_log as AL # noqa: E402
PY = sys.executable
SEP = os.path.join(HOOKS, "state_engine.py")
passed = failed = 0
def check(name, ok):
global passed, failed
if ok:
passed += 1
print(f" PASS {name}")
else:
failed += 1
print(f" FAIL {name}")
def _cli(args):
e = dict(os.environ)
return subprocess.run([PY, SEP] + args, capture_output=True, text=True, env=e)
# ── 1) next_info: 신선한 cascade 워크플로(intake) ───────────────────────
print("== next_info: intake 상태 ==")
SE.init_ledger("wf-a", plan="cascade", tier="standard", overwrite=True)
info = SE.next_info("wf-a")
check("current-stage=intake", info.get("current-stage") == "intake")
check("current-command=/ceo-intake", info.get("current-command") == "/ceo-intake")
check("next-stage=discovery", info.get("next-stage") == "discovery")
check("next-command=/ground", info.get("next-command") == "/ground")
check("not terminal", info.get("terminal") is False)
adv = info.get("advance", {})
check("advance present", isinstance(adv, dict) and "ok" in adv)
check("intake->discovery blocked (no decision-brief)", adv.get("ok") is False)
check("guard reason surfaces decision-brief", any("decision-brief" in r for r in adv.get("reasons", [])))
check("intake->discovery not human-gate", adv.get("human-gate", {}).get("required") is False)
# ── 2) _human_gate_for ─────────────────────────────────────────────────
print("== _human_gate_for: DECIDE/RELEASE 만 사람게이트 ==")
hg_dec = SE._human_gate_for("decide", "design")
check("decide->design human-gate (go/no-go)", hg_dec[0] is True and hg_dec[1] is not None)
check("decide->design approver is HUMAN-001", "HUMAN-001" in (hg_dec[1] or ""))
check("acceptance->released human-gate (release)", SE._human_gate_for("acceptance", "released")[0] is True)
check("design->spec NOT human-gate", SE._human_gate_for("design", "spec")[0] is False)
check("spec->build NOT human-gate", SE._human_gate_for("spec", "build")[0] is False)
check("build->verification NOT human-gate", SE._human_gate_for("build", "verification")[0] is False)
check("discovery->decide NOT human-gate", SE._human_gate_for("discovery", "decide")[0] is False)
# ── 3) next CLI -> 유효 JSON ────────────────────────────────────────────
print("== next CLI -> JSON ==")
r = _cli(["next", "--workflow", "wf-a"])
check("next CLI exit 0", r.returncode == 0)
try:
cli_info = json.loads(r.stdout)
ok_json = cli_info.get("current-stage") == "intake"
except Exception:
ok_json = False
check("next CLI emits parseable JSON", ok_json)
check("next CLI needs --workflow (usage)", _cli(["next"]).returncode != 0)
# ── 4) DECIDE 사람게이트 + resumable(signoff) ───────────────────────────
print("== DECIDE human-gate + resumable ==")
led = SE._default_ledger("wf-d")
led["stage"] = "decide"
led["plan"] = "cascade"
led["tier"] = "standard"
SE._write_ledger("wf-d", led)
info_d = SE.next_info("wf-d")
check("decide current-command=/decide", info_d.get("current-command") == "/decide")
check("decide next-stage=design", info_d.get("next-stage") == "design")
check("decide->design human-gate REQUIRED (before signoff)",
info_d.get("advance", {}).get("human-gate", {}).get("required") is True)
check("human-gate names approver", info_d.get("advance", {}).get("human-gate", {}).get("approver"))
# 사람이 세션 밖에서 승인(signoff) — 이후 게이트 해제(재개 가능).
ok_sign, _ = SE.record_signoff("wf-d", "decide", "HUMAN-001")
check("record_signoff(HUMAN-001) ok", ok_sign is True)
info_d2 = SE.next_info("wf-d")
check("decide->design human-gate CLEARED after signoff",
info_d2.get("advance", {}).get("human-gate", {}).get("required") is False)
# 동일한 결정의 exact HUMAN artifact acceptance 자체가 게이트를 충족한다. 별도 signoff를
# 다시 요구하지 않는다(id+sha가 다른 이벤트나 비-human reviewer는 충족하지 못함).
led_exact = SE._default_ledger("wf-exact")
led_exact["stage"] = "decide"
led_exact["plan"] = "cascade"
SE._write_ledger("wf-exact", led_exact)
_orig_trusted, _orig_read_events = SE._trusted_artifacts, AL.read_events
try:
SE._trusted_artifacts = lambda wf: [{
"artifact-id": "packet-1", "artifact-sha256": "a" * 64,
"artifact-kind": "executive-decision-packet",
}] if wf == "wf-exact" else _orig_trusted(wf)
AL.read_events = lambda: [{
"workflow-id": "wf-exact", "report-id": "packet-1", "artifact-sha256": "a" * 64,
"decision": "accepted", "role-id": "HUMAN-001",
"reviewer": {"actor-id": "HUMAN-001"},
}]
info_exact = SE.next_info("wf-exact")
check("exact HUMAN decision-packet acceptance clears gate without duplicate signoff",
info_exact.get("advance", {}).get("human-gate", {}).get("required") is False)
finally:
SE._trusted_artifacts, AL.read_events = _orig_trusted, _orig_read_events
# 비-HUMAN signoff 는 거부(사람만 유효)
ok_bad, _ = SE.record_signoff("wf-d2", "decide", "OPS-ORCH")
check("non-HUMAN signoff rejected", ok_bad is False)
# ── 5) terminal: terminal stage 진입과 완료를 구분 ─────────────────────
print("== terminal 감지(current vs last-completed) ==")
ledr = SE._default_ledger("wf-r")
ledr["stage"] = "released"
SE._write_ledger("wf-r", ledr)
info_r = SE.next_info("wf-r")
check("released.running is not terminal yet", info_r.get("terminal") is False)
check("released -> no next-command", info_r.get("next-command") is None)
ok_complete, _ = SE.complete_stage("wf-r", "OPS-ORCH")
info_r_done = SE.next_info("wf-r")
check("released completed successfully", ok_complete is True)
check("released.completed -> terminal true", info_r_done.get("terminal") is True)
led_light = SE._default_ledger("wf-light-terminal")
led_light["plan"] = "light"
led_light["tier"] = "light"
led_light["stage"] = "acceptance"
led_light["stage-status"] = "completed"
SE._write_ledger("wf-light-terminal", led_light)
info_light = SE.next_info("wf-light-terminal")
check("light acceptance.completed is terminal", info_light.get("terminal") is True)
check("light terminal has no release command", info_light.get("next-command") is None)
# ── 6) 배선: /run-cascade.md ────────────────────────────────────────────
print("== 배선: run-cascade.md ==")
RC = os.path.join(ROOT, ".claude/commands/run-cascade.md")
check("/run-cascade command exists", os.path.exists(RC))
_rc = open(RC, encoding="utf-8").read() if os.path.exists(RC) else ""
check("run-cascade reuses state_engine next", "state_engine.py next" in _rc)
check("run-cascade stops at human-gate", "human-gate" in _rc and ("정지" in _rc or "멈춘" in _rc or "멈춘다" in _rc))
check("run-cascade forbids auto-approve/auto-complete", "자동 승인" in _rc and ("자동 완주" in _rc or "완주" in _rc))
check("run-cascade forbids parallel engine", "평행 엔진" in _rc)
check("run-cascade names approver path (HUMAN)", "HUMAN-001" in _rc)
print(f"\n{passed} passed, {failed} failed")
sys.exit(1 if failed else 0)