diff --git a/apps/api/src/services/ai_agent_autonomous_runtime_control.py b/apps/api/src/services/ai_agent_autonomous_runtime_control.py index cfe1f4a57..532ca1eb7 100644 --- a/apps/api/src/services/ai_agent_autonomous_runtime_control.py +++ b/apps/api/src/services/ai_agent_autonomous_runtime_control.py @@ -10,12 +10,13 @@ KM, and Telegram receipts are present. from __future__ import annotations from collections.abc import Iterable, Mapping -from datetime import datetime, timezone +from datetime import UTC, datetime from typing import Any +from sqlalchemy import text + from src.core.config import settings from src.core.logging import get_logger -from sqlalchemy import text from src.db.base import get_db_context from src.services.report_generation_service import ( DAILY_REPORT_HOUR_TAIPEI, @@ -54,8 +55,8 @@ def _utc_iso(value: Any) -> str | None: return None if isinstance(value, datetime): if value.tzinfo is None: - value = value.replace(tzinfo=timezone.utc) - return value.astimezone(timezone.utc).isoformat() + value = value.replace(tzinfo=UTC) + return value.astimezone(UTC).isoformat() return str(value) @@ -229,6 +230,188 @@ def _latest_flow_closure( } +def classify_deploy_control_plane_observation( + *, + run_status: str, + is_latest_deploy_intent: bool, + active_task_container_count: int, + production_marker_hit: bool, + latest_flow_closed: bool, + runner_capacity_ok: bool, + runner_forbidden_label_count: int, +) -> dict[str, Any]: + """Classify CD/run noise into an internal PlayBook decision.""" + + normalized_status = str(run_status or "unknown").strip().lower() + has_active_task = active_task_container_count > 0 + runner_lane_safe = runner_capacity_ok and runner_forbidden_label_count == 0 + production_truth_ok = production_marker_hit and latest_flow_closed + + if not is_latest_deploy_intent: + classification = "superseded_run_skip" + action = "skip_cd_work_and_attach_to_superseded_intent" + elif production_truth_ok and normalized_status == "success": + classification = "deploy_succeeded_marker_hit" + action = "close_deploy_intent_and_write_receipts" + elif normalized_status == "running" and has_active_task and runner_lane_safe: + classification = "running_with_controlled_task" + action = "continue_observing_without_restarting_runner" + elif normalized_status == "running" and not has_active_task and production_truth_ok: + classification = "running_no_container_stale_ui" + action = "treat_gitea_spinner_as_stale_and_keep_production_truth" + elif normalized_status == "failure" and production_truth_ok: + classification = "failed_run_superseded_by_marker_hit" + action = "record_non_blocking_failure_and_keep_current_marker" + elif normalized_status == "failure": + classification = "real_failure_requires_playbook_repair" + action = "open_cd_repair_playbook_with_target_selector_and_verifier" + elif not runner_lane_safe: + classification = "runner_lane_guardrail_violation" + action = "fail_closed_runner_lane_and_open_repair_playbook" + else: + classification = "waiting_for_controlled_observation" + action = "wait_for_mcp_observation_or_deploy_intent_update" + + return { + "schema_version": "ai_agent_deploy_control_plane_decision_v1", + "classification": classification, + "action": action, + "inputs": { + "run_status": normalized_status, + "is_latest_deploy_intent": is_latest_deploy_intent, + "active_task_container_count": max(0, active_task_container_count), + "production_marker_hit": production_marker_hit, + "latest_flow_closed": latest_flow_closed, + "runner_capacity_ok": runner_capacity_ok, + "runner_forbidden_label_count": max(0, runner_forbidden_label_count), + }, + "internal_writeback": { + "mcp_event_type": "deploy_run_observation", + "rag_context_required": True, + "km_writeback_required": True, + "playbook_route_required": True, + "log_projection_required": True, + "telegram_receipt_required": classification in { + "deploy_succeeded_marker_hit", + "real_failure_requires_playbook_repair", + "runner_lane_guardrail_violation", + }, + }, + "safety_boundary": { + "reads_raw_sessions": False, + "reads_secret_values": False, + "opens_legacy_runner": False, + "uses_force_push": False, + "writes_runtime_state": classification in { + "deploy_succeeded_marker_hit", + "real_failure_requires_playbook_repair", + "runner_lane_guardrail_violation", + }, + }, + } + + +def _control_plane_integration() -> dict[str, Any]: + classifier_examples = [ + classify_deploy_control_plane_observation( + run_status="success", + is_latest_deploy_intent=True, + active_task_container_count=0, + production_marker_hit=True, + latest_flow_closed=True, + runner_capacity_ok=True, + runner_forbidden_label_count=0, + ), + classify_deploy_control_plane_observation( + run_status="running", + is_latest_deploy_intent=True, + active_task_container_count=0, + production_marker_hit=True, + latest_flow_closed=True, + runner_capacity_ok=True, + runner_forbidden_label_count=0, + ), + classify_deploy_control_plane_observation( + run_status="failure", + is_latest_deploy_intent=True, + active_task_container_count=0, + production_marker_hit=False, + latest_flow_closed=False, + runner_capacity_ok=True, + runner_forbidden_label_count=0, + ), + ] + return { + "schema_version": "ai_agent_autonomous_runtime_internal_loop_v1", + "status": "mcp_rag_km_playbook_log_control_loop_declared", + "purpose": ( + "把 Gitea run、runner lane、production marker、browser smoke 與 executor receipt " + "先收斂成內部事件,再由 PlayBook decision 推進或跳過。" + ), + "mcp_sensors": [ + { + "sensor_id": "gitea_actions_run_observer", + "normalized_event": "RunObservation", + "raw_secret_access_allowed": False, + }, + { + "sensor_id": "controlled_runner_lane_observer", + "normalized_event": "RunnerLaneState", + "raw_runner_token_access_allowed": False, + }, + { + "sensor_id": "production_marker_observer", + "normalized_event": "ProductionTruthSnapshot", + "raw_session_access_allowed": False, + }, + { + "sensor_id": "browser_smoke_observer", + "normalized_event": "FrontendTruthSnapshot", + "raw_conversation_access_allowed": False, + }, + ], + "rag_context_queries": [ + "runner_pressure_buildkit_stockplatform_collision", + "controlled_cd_lane_capacity_label_guardrails", + "autonomous_runtime_marker_receipt_contract", + ], + "playbook_decision_classes": [ + "deploy_succeeded_marker_hit", + "running_with_controlled_task", + "running_no_container_stale_ui", + "superseded_run_skip", + "failed_run_superseded_by_marker_hit", + "real_failure_requires_playbook_repair", + "runner_lane_guardrail_violation", + ], + "km_writeback_contract": { + "knowledge_entry_path_type": "deploy_control_plane_decision:", + "required_refs": [ + "deploy_intent_id", + "target_sha", + "gitea_run_id", + "production_marker", + "latest_flow_closure", + "runner_lane_state", + ], + "stores_raw_logs": False, + "stores_secret_values": False, + }, + "log_projection_contract": { + "timeline_event_type": "ai_agent_deploy_control_plane_decision", + "logbook_projection": "summary_only_after_verifier", + "raw_html_or_long_log_allowed": False, + }, + "classifier_examples": classifier_examples, + "rollups": { + "mcp_sensor_count": 4, + "rag_context_query_count": 3, + "playbook_decision_class_count": 7, + "classifier_example_count": len(classifier_examples), + }, + } + + def build_runtime_receipt_readback_from_rows( *, project_id: str = _DEFAULT_PROJECT_ID, @@ -483,9 +666,10 @@ def build_ai_agent_autonomous_runtime_control() -> dict[str, Any]: "new_behavior": "用 Telegram Gateway 實送報告與 actionable receipt;不直接暴露 Bot API", }, ] + control_plane_integration = _control_plane_integration() payload = { "schema_version": _SCHEMA_VERSION, - "generated_at": datetime.now(timezone.utc).isoformat(), + "generated_at": datetime.now(UTC).isoformat(), "program_status": { "current_task_id": "P2-416-D1N", "status": "current_directive_control_plane_active", @@ -565,6 +749,7 @@ def build_ai_agent_autonomous_runtime_control() -> dict[str, Any]: "telegram_receipt_or_alert", ], }, + "control_plane_integration": control_plane_integration, "legacy_policy_overrides": legacy_overrides, "hard_blockers": hard_blockers, "visibility_contract": { @@ -589,6 +774,10 @@ def build_ai_agent_autonomous_runtime_control() -> dict[str, Any]: 1 for item in executor_receipts if item["writes_runtime_state"] ), "legacy_policy_overridden_count": len(legacy_overrides), + "mcp_sensor_count": control_plane_integration["rollups"]["mcp_sensor_count"], + "rag_context_query_count": control_plane_integration["rollups"]["rag_context_query_count"], + "playbook_decision_class_count": control_plane_integration["rollups"]["playbook_decision_class_count"], + "deploy_control_classifier_example_count": control_plane_integration["rollups"]["classifier_example_count"], }, } _attach_runtime_receipt_readback( diff --git a/apps/api/tests/test_ai_agent_autonomous_runtime_control.py b/apps/api/tests/test_ai_agent_autonomous_runtime_control.py index edee118fd..1afe2034f 100644 --- a/apps/api/tests/test_ai_agent_autonomous_runtime_control.py +++ b/apps/api/tests/test_ai_agent_autonomous_runtime_control.py @@ -1,6 +1,7 @@ from src.services.ai_agent_autonomous_runtime_control import ( build_ai_agent_autonomous_runtime_control, build_runtime_receipt_readback_from_rows, + classify_deploy_control_plane_observation, ) @@ -58,6 +59,57 @@ def test_ai_agent_autonomous_runtime_control_exposes_reports_and_executor_receip assert data["runtime_receipt_readback"]["db_read_status"] == "not_queried" +def test_ai_agent_autonomous_runtime_control_exposes_internal_control_loop(): + data = build_ai_agent_autonomous_runtime_control() + + integration = data["control_plane_integration"] + assert integration["schema_version"] == "ai_agent_autonomous_runtime_internal_loop_v1" + assert integration["status"] == "mcp_rag_km_playbook_log_control_loop_declared" + assert {sensor["normalized_event"] for sensor in integration["mcp_sensors"]} == { + "RunObservation", + "RunnerLaneState", + "ProductionTruthSnapshot", + "FrontendTruthSnapshot", + } + assert "controlled_cd_lane_capacity_label_guardrails" in integration["rag_context_queries"] + assert "running_no_container_stale_ui" in integration["playbook_decision_classes"] + assert integration["km_writeback_contract"]["stores_raw_logs"] is False + assert integration["km_writeback_contract"]["stores_secret_values"] is False + assert integration["log_projection_contract"]["raw_html_or_long_log_allowed"] is False + assert data["rollups"]["mcp_sensor_count"] == 4 + assert data["rollups"]["playbook_decision_class_count"] == 7 + + +def test_deploy_control_plane_classifier_separates_stale_spinner_from_real_failure(): + stale = classify_deploy_control_plane_observation( + run_status="running", + is_latest_deploy_intent=True, + active_task_container_count=0, + production_marker_hit=True, + latest_flow_closed=True, + runner_capacity_ok=True, + runner_forbidden_label_count=0, + ) + assert stale["classification"] == "running_no_container_stale_ui" + assert stale["action"] == "treat_gitea_spinner_as_stale_and_keep_production_truth" + assert stale["safety_boundary"]["writes_runtime_state"] is False + assert stale["internal_writeback"]["km_writeback_required"] is True + + failure = classify_deploy_control_plane_observation( + run_status="failure", + is_latest_deploy_intent=True, + active_task_container_count=0, + production_marker_hit=False, + latest_flow_closed=False, + runner_capacity_ok=True, + runner_forbidden_label_count=0, + ) + assert failure["classification"] == "real_failure_requires_playbook_repair" + assert failure["action"] == "open_cd_repair_playbook_with_target_selector_and_verifier" + assert failure["safety_boundary"]["opens_legacy_runner"] is False + assert failure["internal_writeback"]["playbook_route_required"] is True + + def test_ai_agent_autonomous_runtime_control_keeps_hard_blockers_and_redaction(): data = build_ai_agent_autonomous_runtime_control() diff --git a/apps/api/tests/test_ai_agent_autonomous_runtime_control_api.py b/apps/api/tests/test_ai_agent_autonomous_runtime_control_api.py index b5c3d2324..c6f72c862 100644 --- a/apps/api/tests/test_ai_agent_autonomous_runtime_control_api.py +++ b/apps/api/tests/test_ai_agent_autonomous_runtime_control_api.py @@ -77,6 +77,11 @@ def test_get_ai_agent_autonomous_runtime_control_api(monkeypatch): "ai_agent_autonomous_runtime_receipt_readback_v1" ) assert data["runtime_receipt_readback"]["db_read_status"] == "not_queried" + assert data["control_plane_integration"]["status"] == ( + "mcp_rag_km_playbook_log_control_loop_declared" + ) + assert data["rollups"]["mcp_sensor_count"] == 4 + assert data["rollups"]["deploy_control_classifier_example_count"] == 3 def test_get_ai_agent_autonomous_runtime_control_api_redacts_public_terms(monkeypatch): diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 30951fe59..46d6d6f31 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,16 @@ +## 2026-06-28 — 18:50 AI Agent deploy control plane 內部迴圈 + +**完成內容**: +- `agent-autonomous-runtime-control` 新增 `control_plane_integration` readback,將 Gitea run、controlled runner lane、production marker 與 browser smoke 轉成 MCP sensors、RAG context、PlayBook decision class、KM writeback contract 與 log projection contract。 +- 新增 `classify_deploy_control_plane_observation()`,把 superseded run、production marker hit、controlled task running、stale Gitea spinner、real failure 與 runner lane guardrail violation 分流成 AI PlayBook action,而不是重開 legacy runner 或回到人工判讀。 +- API rollups 增加 `mcp_sensor_count`、`rag_context_query_count`、`playbook_decision_class_count` 與 `deploy_control_classifier_example_count`,讓正式 readback 可直接看出內部控制迴圈資產是否存在。 + +**驗證結果**: +- `DATABASE_URL=sqlite:///test.db PYTHONPATH=apps/api python3.11 -m pytest apps/api/tests/test_ai_agent_autonomous_runtime_control.py apps/api/tests/test_ai_agent_autonomous_runtime_control_api.py -q`:`8 passed`。 +- `python3 -m py_compile apps/api/src/services/ai_agent_autonomous_runtime_control.py apps/api/tests/test_ai_agent_autonomous_runtime_control.py apps/api/tests/test_ai_agent_autonomous_runtime_control_api.py`:通過。 + +**邊界**:沒有讀 raw sessions / secret / runner token;沒有開 legacy runner;沒有 force push;沒有直接寫 runtime,只新增 readback 與分類器 contract。 + ## 2026-06-28 — 18:49 IwoooS Wazuh manager registry accepted 與 controlled apply preflight production readback **完成內容**: