""" Platform Runtime(Shadow Mode Shell) ====================================== AwoooP Phase 4: 第一個 runtime shell,只跑 shadow,不改 legacy 行為(ADR-106) 2026-05-04 ogt + Claude Sonnet 4.6 Shadow Mode 保證: 1. 0 user-visible response(不發送 Telegram/Slack 任何訊息) 2. 0 destructive tool call(is_destructive=true 的工具全部攔截) 3. 所有執行記錄寫入 awooop_run_state + step_journal(可觀測) 4. budget_service hard kill 同樣作用(防止 shadow 跑出超額費用) Idempotency(ADR-114): (project_id, channel_type, provider_event_id) 複合唯一 Redis NX 先攔(快),PG constraint 最後防(準確) """ from __future__ import annotations import hashlib import json from datetime import datetime, timedelta, timezone from typing import Any from uuid import UUID import structlog from sqlalchemy import select, text from src.db.awooop_models import ( AwoooPRunIdempotency, AwoooPRunState, AwoooPRunStepJournal, ) from src.db.base import get_db_context from src.services.run_state_machine import LEASE_TTL_SECONDS, transition from src.services.runtime_correlation import ( canonical_traceparent, correlation_readback_for_run, new_runtime_correlation, new_uuid7, ) logger = structlog.get_logger(__name__) # Shadow mode 設定 _SHADOW_ENABLED = True # Phase 4 固定 True;Phase 6+ 由 migration_mode 控制 _DESTRUCTIVE_TOOL_KEYWORDS = frozenset({ "delete", "drop", "remove", "kill", "terminate", "destroy", "rollback", "revert", "patch", "apply", "exec", "execute", "restart", "scale", "cordon", "drain", }) # ───────────────────────────────────────────────────────────────────────────── # UUID v7(時間有序) # ───────────────────────────────────────────────────────────────────────────── def _uuid7() -> UUID: """Compatibility wrapper for the canonical RFC 9562 generator.""" return new_uuid7() # ───────────────────────────────────────────────────────────────────────────── # W3C traceparent 生成 # ───────────────────────────────────────────────────────────────────────────── def _new_trace_id(run_id: UUID | None = None) -> str: """Return the canonical W3C traceparent bound to one durable run.""" return canonical_traceparent(run_id or _uuid7()) # ───────────────────────────────────────────────────────────────────────────── # Idempotency # ───────────────────────────────────────────────────────────────────────────── _IDEMPOTENCY_REDIS_PREFIX = "awooop:run:idem:" _IDEMPOTENCY_REDIS_TTL = 86400 # 24h _IDEMPOTENCY_LOCK_PREFIX = "awooop:run:idem:claim:" async def check_idempotency( project_id: str, channel_type: str, provider_event_id: str, ) -> UUID | None: """ 檢查 (project_id, channel_type, provider_event_id) 是否已有對應 run_id。 Layer 1:Redis NX(快速攔截,TTL 24h) Layer 2:PostgreSQL awooop_run_idempotency(準確) Returns: 既有 run_id,或 None(尚未處理) """ idem_key = f"{_IDEMPOTENCY_REDIS_PREFIX}{project_id}:{channel_type}:{provider_event_id}" cached_run_id: UUID | None = None # Redis is only a lookup hint. PostgreSQL remains the durable authority so # an old or interrupted cache write can never select a different run. try: from src.core.redis_client import get_redis redis = get_redis() cached = await redis.get(idem_key) if cached: run_id_str = cached.decode() if isinstance(cached, bytes) else cached cached_run_id = UUID(run_id_str) except Exception as exc: logger.warning("idempotency_redis_check_failed", error=str(exc)) # PostgreSQL is the only authoritative mapping. try: async with get_db_context(project_id) as db: result = await db.execute( select(AwoooPRunIdempotency.run_id).where( AwoooPRunIdempotency.project_id == project_id, AwoooPRunIdempotency.channel_type == channel_type, AwoooPRunIdempotency.provider_event_id == provider_event_id, ) ) row = result.fetchone() if row: durable_run_id = UUID(str(row[0])) if cached_run_id is not None and cached_run_id != durable_run_id: logger.warning( "idempotency_redis_durable_mismatch_ignored", project_id=project_id, provider_event_id=provider_event_id, ) return durable_run_id except Exception as exc: logger.warning("idempotency_pg_check_failed", error=str(exc)) if cached_run_id is not None: logger.warning( "idempotency_redis_orphan_hint_ignored", project_id=project_id, provider_event_id=provider_event_id, ) return None async def _cache_idempotency( project_id: str, channel_type: str, provider_event_id: str, run_id: UUID, ) -> None: """Cache a PostgreSQL-selected winner; never create durable authority.""" idem_key = f"{_IDEMPOTENCY_REDIS_PREFIX}{project_id}:{channel_type}:{provider_event_id}" run_id_str = str(run_id) try: from src.core.redis_client import get_redis redis = get_redis() await redis.set(idem_key, run_id_str, ex=_IDEMPOTENCY_REDIS_TTL) except Exception as exc: logger.warning("idempotency_redis_write_failed", error=str(exc)) # ───────────────────────────────────────────────────────────────────────────── # Shadow destructive tool check # ───────────────────────────────────────────────────────────────────────────── def is_destructive_tool(tool_name: str, is_destructive_flag: bool = False) -> bool: """ 判斷 tool call 是否為破壞性操作。 Shadow mode 下一律攔截。 判斷邏輯: 1. MCP Gateway contract 的 is_destructive=True flag 2. tool_name 包含破壞性關鍵字(fallback,無 contract 時使用) """ if is_destructive_flag: return True tool_lower = tool_name.lower() return any(kw in tool_lower for kw in _DESTRUCTIVE_TOOL_KEYWORDS) # ───────────────────────────────────────────────────────────────────────────── # Run 建立 # ───────────────────────────────────────────────────────────────────────────── async def create_run( *, project_id: str, agent_id: str, trigger_type: str, trigger_ref: str | None = None, input_payload: dict[str, Any] | None = None, channel_type: str | None = None, provider_event_id: str | None = None, timeout_seconds: int = 600, ) -> tuple[UUID, bool]: """ 建立新 run(或返回既有 run,若重複事件)。 Returns: (run_id, is_duplicate) — is_duplicate=True 表示冪等命中 Shadow mode:is_shadow=True,不產生 user response。 """ correlation = new_runtime_correlation(project_id) run_id = correlation.run_id trace_id = correlation.trace_id # AwoooPRunState timestamps use PostgreSQL TIMESTAMP WITHOUT TIME ZONE. # Normalize UTC before binding so asyncpg does not reject an aware value. timeout_at = datetime.now(timezone.utc).replace(tzinfo=None) + timedelta( seconds=timeout_seconds ) # 計算 input_sha256 input_sha256 = None if input_payload: canonical = json.dumps(input_payload, sort_keys=True, separators=(",", ":")) input_sha256 = hashlib.sha256(canonical.encode()).hexdigest() run = AwoooPRunState( run_id=run_id, project_id=project_id, agent_id=agent_id, state="pending", trace_id=trace_id, trigger_type=trigger_type, trigger_ref=trigger_ref, is_shadow=_SHADOW_ENABLED, input_sha256=input_sha256, timeout_at=timeout_at, ) if channel_type and provider_event_id: lock_key = ( f"{_IDEMPOTENCY_LOCK_PREFIX}{project_id}:" f"{channel_type}:{provider_event_id}" ) async with get_db_context(project_id) as db: await db.execute( text("SELECT pg_advisory_xact_lock(hashtext(:lock_key))"), {"lock_key": lock_key}, ) existing_result = await db.execute( select(AwoooPRunIdempotency.run_id).where( AwoooPRunIdempotency.project_id == project_id, AwoooPRunIdempotency.channel_type == channel_type, AwoooPRunIdempotency.provider_event_id == provider_event_id, ) ) existing_row = existing_result.fetchone() if existing_row is not None: selected_run_id = UUID(str(existing_row[0])) is_duplicate = True else: db.add(run) # These mapped classes intentionally have no ORM relationship, # so SQLAlchemy cannot infer the foreign-key insert order. Keep # both writes in this transaction, but materialize the run # before its idempotency mapping. await db.flush() db.add( AwoooPRunIdempotency( project_id=project_id, channel_type=channel_type, provider_event_id=provider_event_id, run_id=run_id, ) ) await db.flush() selected_run_id = run_id is_duplicate = False await _cache_idempotency( project_id, channel_type, provider_event_id, selected_run_id, ) if is_duplicate: logger.info( "run_creation_idempotent", project_id=project_id, channel_type=channel_type, provider_event_id=provider_event_id, existing_run_id=str(selected_run_id), ) return selected_run_id, True else: async with get_db_context(project_id) as db: db.add(run) if channel_type and provider_event_id: # The atomic path can reach here only for the transaction winner. assert selected_run_id == run_id and is_duplicate is False else: selected_run_id = run_id is_duplicate = False logger.info( "run_created", run_id=str(run_id), project_id=project_id, agent_id=agent_id, is_shadow=_SHADOW_ENABLED, trace_id=trace_id, work_item_id=correlation.work_item_id, trigger_type=trigger_type, ) return selected_run_id, is_duplicate # ───────────────────────────────────────────────────────────────────────────── # Shadow Execution(Phase 4 主邏輯) # ───────────────────────────────────────────────────────────────────────────── async def shadow_execute(run: AwoooPRunState) -> None: """ Shadow mode 執行一個 run。 Phase 4 行為: - 解析 agent contract(get_active()) - 執行 tool calls(全部攔截,不實際執行) - 記錄 step_journal - 完成後 COMPLETED(無 user response) Phase 6+ 才接真實 LLM + channel adapter。 """ run_id = run.run_id project_id = run.project_id agent_id = run.agent_id logger.info( "shadow_execute_start", run_id=str(run_id), project_id=project_id, agent_id=agent_id, ) try: # 解析 agent contract(取得工具清單) from src.services.contract_service import get_active_body agent_contract = await get_active_body( project_id=project_id, contract_family="agent", contract_id=agent_id, ) tools = agent_contract.get("tools", []) if agent_contract else [] # Shadow step journal:記錄每個工具會被攔截 step_seq = 0 async with get_db_context(project_id) as db: for tool in tools: tool_name = tool.get("tool_name", "unknown") blocked = is_destructive_tool(tool_name) step = AwoooPRunStepJournal( run_id=run_id, project_id=project_id, step_seq=step_seq, tool_name=tool_name, mcp_gateway_id=tool.get("mcp_gateway_id"), result_status="success" if not blocked else "pending", was_blocked=blocked, block_reason="shadow_mode_destructive_blocked" if blocked else None, ) db.add(step) step_seq += 1 # 完成 run(shadow mode:無 user response) await transition( run_id=run_id, project_id=project_id, to_state="completed", step_count_delta=step_seq, ) logger.info( "shadow_execute_completed", run_id=str(run_id), steps=step_seq, ) except Exception as exc: logger.exception( "shadow_execute_failed", run_id=str(run_id), error=str(exc), ) await transition( run_id=run_id, project_id=project_id, to_state="failed", error_code="E-RUN-001", error_detail=str(exc)[:500], ) async def get_run_status(run_id: UUID, project_id: str) -> dict[str, Any] | None: """ 查詢單一 run 的 FSM 狀態。回傳 None 表示不存在。 Router 層透過此 service 函數存取,不直接操作 DB。 """ async with get_db_context(project_id) as db: result = await db.execute( select(AwoooPRunState).where( AwoooPRunState.run_id == run_id, AwoooPRunState.project_id == project_id, ) ) run = result.scalar_one_or_none() if run is None: return None correlation = correlation_readback_for_run( run.project_id, run.run_id, run.trace_id, ) return { "run_id": str(run.run_id), "project_id": run.project_id, "agent_id": run.agent_id, "state": run.state, "is_shadow": run.is_shadow, **correlation, "attempt_count": run.attempt_count, "cost_usd": float(run.cost_usd), "step_count": run.step_count, "error_code": run.error_code, "created_at": run.created_at.isoformat() if run.created_at else None, "started_at": run.started_at.isoformat() if run.started_at else None, "completed_at": run.completed_at.isoformat() if run.completed_at else None, }