diff --git a/apps/api/src/services/awooop_truth_chain_service.py b/apps/api/src/services/awooop_truth_chain_service.py index 5c2086ef8..95c4ebf15 100644 --- a/apps/api/src/services/awooop_truth_chain_service.py +++ b/apps/api/src/services/awooop_truth_chain_service.py @@ -406,6 +406,14 @@ def _truth_status( stage_status = "waiting" needs_human = True blockers.append("pending_human_approval") + elif "EXPIRED" in approval_statuses and not has_execution_records: + stage = "approval_expired" + stage_status = "expired" + needs_human = True + blockers.append("approval_expired_without_operator_decision") + elif "REJECTED" in approval_statuses and not has_execution_records: + stage = "approval_rejected" + stage_status = "closed" elif "EXECUTION_FAILED" in approval_statuses and not has_execution_records: stage = "execution_failed" stage_status = "error" @@ -546,6 +554,10 @@ def build_automation_quality( if any(status in {"PENDING", "WAITING_APPROVAL"} for status in approval_statuses): gate("approval_state", "warning", "waiting_approval") + elif "EXPIRED" in approval_statuses and not has_execution: + gate("approval_state", "warning", "expired_without_execution") + elif "REJECTED" in approval_statuses and not has_execution: + gate("approval_state", "passed", "rejected_no_execution") elif approval_statuses and approval_suppressed and not has_execution: gate("approval_state", "warning", "approved_diagnostic_or_observe_only") elif approval_statuses and (approval_no_action or "NO_ACTION" in approval_actions) and not has_execution: @@ -588,6 +600,10 @@ def build_automation_quality( verdict = "auto_repaired_verification_degraded" elif has_execution: verdict = "execution_unverified" + elif "EXPIRED" in approval_statuses: + verdict = "approval_expired_manual_review" + elif "REJECTED" in approval_statuses: + verdict = "approval_rejected_no_execution" elif approval_suppressed: verdict = "manual_required_diagnostic_only" elif approval_statuses and (approval_no_action or "NO_ACTION" in approval_actions): diff --git a/apps/api/src/services/operator_outcome.py b/apps/api/src/services/operator_outcome.py index 6af37cb40..5282f6786 100644 --- a/apps/api/src/services/operator_outcome.py +++ b/apps/api/src/services/operator_outcome.py @@ -142,6 +142,20 @@ def build_operator_outcome( next_action = "manual_review_no_action_decision" summary = "AI 選擇不執行修復,需人工判斷是否接手" reason = first_blocker or "no_action_or_observe" + elif verdict == "approval_rejected_no_execution" or stage == "approval_rejected": + state = "approval_rejected_no_execution" + severity = "info" + needs_human = False + next_action = "monitor_or_reopen_if_alert_recurs" + summary = "已拒絕處置,未執行修復" + reason = first_blocker or "approval_rejected" + elif verdict == "approval_expired_manual_review" or stage == "approval_expired": + state = "approval_expired_manual_review" + severity = "warning" + needs_human = True + next_action = "reopen_close_or_escalate_expired_approval" + summary = "審批已逾期,需人工重新審查" + reason = first_blocker or "approval_expired" elif remediation_state == "read_only": state = "read_only_dry_run_manual_gate" severity = "warning" diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 9389e38ae..4e666d976 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -3533,6 +3533,12 @@ def _build_awooop_status_chain( if verdict == "auto_repaired_verified": repair_state = "auto_repaired_verified" next_step = "monitor_for_regression" + elif verdict == "approval_rejected_no_execution": + repair_state = "approval_rejected_no_execution" + next_step = "monitor_or_reopen_if_alert_recurs" + elif verdict == "approval_expired_manual_review": + repair_state = "approval_expired_manual_review" + next_step = "reopen_close_or_escalate_expired_approval" elif has_repair_execution: repair_state = ( "executed_pending_verification" diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index 90e4cfa21..38305de69 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -1652,6 +1652,72 @@ def test_awooop_status_chain_does_not_treat_audit_ops_as_repair() -> None: assert chain["evidence"]["auto_repair_records"] == 0 +def test_awooop_status_chain_surfaces_rejected_approval_outcome() -> None: + chain = _build_awooop_status_chain( + incident_ids=["INC-REJECTED"], + source_id="INC-REJECTED", + truth_chain={ + "truth_status": { + "current_stage": "approval_rejected", + "stage_status": "closed", + "needs_human": False, + "blockers": [], + }, + "automation_quality": { + "verdict": "approval_rejected_no_execution", + "facts": { + "auto_repair_execution_records": 0, + "automation_operation_records": 0, + "effective_execution_records": 0, + "verification_result": "missing", + "mcp_gateway_total": 1, + "knowledge_entries": 0, + }, + "blockers": [], + }, + }, + remediation_history={"total": 0}, + ) + + assert chain["repair_state"] == "approval_rejected_no_execution" + assert chain["needs_human"] is False + assert chain["operator_outcome"]["state"] == "approval_rejected_no_execution" + assert chain["operator_outcome"]["notification"]["mode"] == "result_only" + + +def test_awooop_status_chain_surfaces_expired_approval_outcome() -> None: + chain = _build_awooop_status_chain( + incident_ids=["INC-EXPIRED"], + source_id="INC-EXPIRED", + truth_chain={ + "truth_status": { + "current_stage": "approval_expired", + "stage_status": "expired", + "needs_human": True, + "blockers": ["approval_expired_without_operator_decision"], + }, + "automation_quality": { + "verdict": "approval_expired_manual_review", + "facts": { + "auto_repair_execution_records": 0, + "automation_operation_records": 0, + "effective_execution_records": 0, + "verification_result": "missing", + "mcp_gateway_total": 1, + "knowledge_entries": 0, + }, + "blockers": [], + }, + }, + remediation_history={"total": 0}, + ) + + assert chain["repair_state"] == "approval_expired_manual_review" + assert chain["needs_human"] is True + assert chain["operator_outcome"]["state"] == "approval_expired_manual_review" + assert chain["operator_outcome"]["notification"]["mode"] == "action_required" + + def test_legacy_mcp_timeline_summary_surfaces_tool_context() -> None: record = { "incident_id": "INC-20260514-F85F21", diff --git a/apps/api/tests/test_awooop_truth_chain_service.py b/apps/api/tests/test_awooop_truth_chain_service.py index 2c5e1910d..3897f7989 100644 --- a/apps/api/tests/test_awooop_truth_chain_service.py +++ b/apps/api/tests/test_awooop_truth_chain_service.py @@ -474,6 +474,85 @@ def test_automation_quality_marks_no_action_without_execution() -> None: assert "execution_recorded" in quality["blockers"] +def test_automation_quality_marks_rejected_approval_closed_without_execution() -> None: + quality = build_automation_quality( + incident={"incident_id": "INC-REJECTED", "status": "INVESTIGATING"}, + approvals=[{"status": "REJECTED", "action": "kubectl rollout restart deployment/api"}], + evidence_rows=[], + automation_ops=[], + auto_repair_executions=[], + gateway_mcp_summary={"total": 1}, + legacy_mcp_summary={"total": 1}, + outbound_rows=[{"message_id": "m1"}], + km_entries=[], + timeline_events=[], + ) + + gates = {row["name"]: row["status"] for row in quality["gates"]} + assert quality["verdict"] == "approval_rejected_no_execution" + assert gates["approval_state"] == "passed" + assert gates["execution_recorded"] == "missing" + + +def test_automation_quality_marks_expired_approval_for_manual_review() -> None: + quality = build_automation_quality( + incident={"incident_id": "INC-EXPIRED", "status": "INVESTIGATING"}, + approvals=[{"status": "EXPIRED", "action": "kubectl rollout restart deployment/api"}], + evidence_rows=[], + automation_ops=[], + auto_repair_executions=[], + gateway_mcp_summary={"total": 1}, + legacy_mcp_summary={"total": 1}, + outbound_rows=[{"message_id": "m1"}], + km_entries=[], + timeline_events=[], + ) + + gates = {row["name"]: row["status"] for row in quality["gates"]} + assert quality["verdict"] == "approval_expired_manual_review" + assert gates["approval_state"] == "warning" + assert "approval_state" not in quality["blockers"] + + +def test_truth_status_marks_rejected_approval_as_closed() -> None: + status = _truth_status( + incident={"incident_id": "INC-REJECTED", "status": "INVESTIGATING"}, + approvals=[{"status": "REJECTED", "action": "kubectl rollout restart deployment/api"}], + evidence_rows=[], + automation_ops=[], + drift=None, + drift_repeat_count=0, + gateway_mcp_total=1, + legacy_mcp_total=1, + outbound_visible_total=1, + auto_repair_executions=[], + ) + + assert status["current_stage"] == "approval_rejected" + assert status["stage_status"] == "closed" + assert status["needs_human"] is False + + +def test_truth_status_marks_expired_approval_as_manual_needed() -> None: + status = _truth_status( + incident={"incident_id": "INC-EXPIRED", "status": "INVESTIGATING"}, + approvals=[{"status": "EXPIRED", "action": "kubectl rollout restart deployment/api"}], + evidence_rows=[], + automation_ops=[], + drift=None, + drift_repeat_count=0, + gateway_mcp_total=1, + legacy_mcp_total=1, + outbound_visible_total=1, + auto_repair_executions=[], + ) + + assert status["current_stage"] == "approval_expired" + assert status["stage_status"] == "expired" + assert status["needs_human"] is True + assert "approval_expired_without_operator_decision" in status["blockers"] + + def test_reconciliation_ignores_no_action_audit_rows_as_execution() -> None: reconciliation = build_incident_reconciliation( incident={"incident_id": "INC-NOOP", "status": "INVESTIGATING"}, diff --git a/apps/api/tests/test_operator_outcome.py b/apps/api/tests/test_operator_outcome.py index 3240c43ba..7b058310c 100644 --- a/apps/api/tests/test_operator_outcome.py +++ b/apps/api/tests/test_operator_outcome.py @@ -87,6 +87,48 @@ def test_operator_outcome_marks_verified_repair_as_result_only() -> None: assert outcome["notification"]["mode"] == "result_only" +def test_operator_outcome_marks_rejected_approval_as_closed_no_execution() -> None: + outcome = build_operator_outcome( + truth_status={ + "current_stage": "approval_rejected", + "stage_status": "closed", + "needs_human": False, + "blockers": [], + }, + automation_quality={ + "verdict": "approval_rejected_no_execution", + "facts": {}, + "blockers": [], + }, + ) + + assert outcome["state"] == "approval_rejected_no_execution" + assert outcome["needs_human"] is False + assert outcome["notification"]["mode"] == "result_only" + assert outcome["next_action"] == "monitor_or_reopen_if_alert_recurs" + + +def test_operator_outcome_marks_expired_approval_as_manual_review() -> None: + outcome = build_operator_outcome( + truth_status={ + "current_stage": "approval_expired", + "stage_status": "expired", + "needs_human": True, + "blockers": ["approval_expired_without_operator_decision"], + }, + automation_quality={ + "verdict": "approval_expired_manual_review", + "facts": {}, + "blockers": [], + }, + ) + + assert outcome["state"] == "approval_expired_manual_review" + assert outcome["needs_human"] is True + assert outcome["notification"]["mode"] == "action_required" + assert outcome["next_action"] == "reopen_close_or_escalate_expired_approval" + + def test_execution_result_message_includes_operator_outcome_and_human_channels() -> None: service = ApprovalExecutionService() approval = SimpleNamespace( diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 9f78aa02a..ff6510e7f 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -53,6 +53,13 @@ NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm --dir apps/web run build ## 2026-05-31|Telegram / AwoooP 處置結果與人工通知契約補齊 +**續修:非執行終局狀態語意補齊(2026-05-31)**: + +- `REJECTED` approval 現在會形成 `operator_outcome.state=approval_rejected_no_execution`,摘要為「已拒絕處置,未執行修復」,`needs_human=false`,通知模式為 result-only。 +- `EXPIRED` approval 現在會形成 `operator_outcome.state=approval_expired_manual_review`,摘要為「審批已逾期,需人工重新審查」,`needs_human=true`,通知通道維持 Telegram SRE 戰情室 + AwoooP console。 +- AwoooP status-chain 的 `repair_state` 同步顯示 `approval_rejected_no_execution` / `approval_expired_manual_review`,避免 rejected/expired 被看成一般 observed/not-found。 +- 驗證:`py_compile` pass;targeted `ruff --select E9,F401,F821,F841` pass;`test_approval_execution_no_action.py` + `test_operator_outcome.py` + `test_awooop_truth_chain_service.py` + `test_awooop_operator_timeline_labels.py` + `test_telegram_message_templates.py` + `test_incident_timeline_service.py` -> 169 passed;`git diff --check` pass。 + **背景**: - 使用者指出 Telegram 告警在批准後、已處理、執行中、degraded 等狀態之間仍缺少專業結論:看不出最後處置結果、是否仍需人工介入、以及人工介入要透過什麼通知方式接手。 diff --git a/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md b/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md index 443b80cbd..f20da3373 100644 --- a/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md +++ b/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md @@ -2692,6 +2692,12 @@ Phase 6 完成後 - Production:Gitea run 3354 tests/build-and-deploy/post-deploy success;run 3355 ai-code-review success;deploy marker `a28f8472 chore(cd): deploy e9a8a2b [skip ci]`。K8s API/Web/Worker image 均為 `e9a8a2b3e90064afbae638b83c3854ac22f7bb25` 且 ready;`/api/v1/health` healthy/prod/mock_mode=false。Production `/api/v1/platform/status-chain?project_id=awoooi&incident_id=INC-20260530-88D960` 回 `operator_outcome.state=diagnostic_only_manual_review`、`summary_zh=只完成診斷/觀察,尚未證明修復`、`needs_human=true`、`notification.channels=telegram_sre_war_room,awooop_operator_console`;API pod truth-chain readback confirms `INC-20260531-BE2B25 -> execution_failed_manual_required`。 - 判讀:T154c 補的是「operator-facing disposition contract」與「result notification reliability」,不是擴權自動修復。新版後新 incident 的批准結果會落到 Telegram / DB / AwoooP 同一份 outcome;歷史全量補發需另開 backfill,避免一次洗版。 +**T154d Approval terminal state outcome(2026-05-31 台北)**: +- 觸發:續查近 7 天 production `approval_records` 發現 `EXPIRED` / `REJECTED` 也缺少清楚 operator-facing 結論;它們不是 execution result,但前台若不翻譯狀態,operator 仍會看不出是否需人工介入。 +- 修正:truth-chain / automation quality / platform status-chain / operator outcome 新增非執行終局語意。`REJECTED` 轉為 `approval_rejected_no_execution`,摘要「已拒絕處置,未執行修復」,`needs_human=false`;`EXPIRED` 轉為 `approval_expired_manual_review`,摘要「審批已逾期,需人工重新審查」,`needs_human=true`,通知通道仍是 Telegram SRE 戰情室 + AwoooP console。 +- Verification:API `py_compile` pass;targeted `ruff --select E9,F401,F821,F841` pass;`test_approval_execution_no_action.py` + `test_operator_outcome.py` + `test_awooop_truth_chain_service.py` + `test_awooop_operator_timeline_labels.py` + `test_telegram_message_templates.py` + `test_incident_timeline_service.py` -> 169 passed;`git diff --check` pass。 +- 判讀:T154d 不把 rejected/expired 偽裝成執行結果;它補的是「審批終局」的 operator outcome,讓前台與 Telegram 摘要可以分辨「已人工拒絕」與「逾期需重新審查」。 + **T152 Ansible runtime readiness surfaced(2026-05-24 台北)**: - 觸發:T151 已讓首頁看到 execution backend / Ansible attribution,但 operator 仍看不到 runtime 端缺什麼,容易把「Ansible 有候選」誤解成「Ansible 已能自動修復」。 - 修正:API image 複製 `infra/ansible/` 作 read-only catalog;`truth-chain/quality/summary` 新增 `ansible_runtime`,回報 playbook binary、catalog、inventory、playbook_count、can_run_check_mode、blockers。首頁 execution evidence 同步顯示 runtime 狀態;目前 production 顯示 `runtime 未就緒:ansible_playbook_binary_missing`。未安裝 `ansible-core`、未啟用 check-mode / apply。