Files
awoooi/apps/api/src/services/platform_runtime.py
2026-07-22 17:48:43 +08:00

434 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Platform RuntimeShadow 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 callis_destructive=true 的工具全部攔截)
3. 所有執行記錄寫入 awooop_run_state + step_journal可觀測
4. budget_service hard kill 同樣作用(防止 shadow 跑出超額費用)
IdempotencyADR-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 固定 TruePhase 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 1Redis NX快速攔截TTL 24h
Layer 2PostgreSQL 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 modeis_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 ExecutionPhase 4 主邏輯)
# ─────────────────────────────────────────────────────────────────────────────
async def shadow_execute(run: AwoooPRunState) -> None:
"""
Shadow mode 執行一個 run。
Phase 4 行為:
- 解析 agent contractget_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
# 完成 runshadow 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,
}