fix(platform): bind AI runs to canonical correlation
Some checks failed
CD Pipeline / select-latest-carrier (push) Successful in 51s
CD Pipeline / workflow-shape (push) Successful in 0s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 2m45s
CD Pipeline / revalidate-deploy-carrier (push) Successful in 33s
CD Pipeline / revalidate-post-deploy-carrier (push) Has been cancelled
CD Pipeline / build-and-deploy (push) Has been cancelled
CD Pipeline / post-deploy-checks (push) Has been cancelled

This commit is contained in:
Your Name
2026-07-22 16:23:11 +08:00
parent 1cec67295b
commit 18b5ba25fa
13 changed files with 1051 additions and 105 deletions

View File

@@ -19,17 +19,27 @@ from __future__ import annotations
import hashlib
import json
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
from uuid import UUID
import structlog
from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy import select, text
from src.db.awooop_models import AwoooPRunIdempotency, AwoooPRunState, AwoooPRunStepJournal
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,
canonical_work_item_id,
is_canonical_traceparent,
new_runtime_correlation,
new_uuid7,
)
logger = structlog.get_logger(__name__)
@@ -46,26 +56,20 @@ _DESTRUCTIVE_TOOL_KEYWORDS = frozenset({
# UUID v7時間有序
# ─────────────────────────────────────────────────────────────────────────────
def _uuid7() -> uuid.UUID:
"""
生成 UUID v7時間有序適合資料庫 PK
格式48-bit Unix timestamp ms + version(7) + 74-bit random
"""
now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
rand_bits = int.from_bytes(uuid.uuid4().bytes[6:], "big") & 0x3FFFFFFFFFFFFFFF
val = (now_ms << 80) | (0x7 << 76) | (0x8 << 72) | rand_bits
return uuid.UUID(int=val)
def _uuid7() -> UUID:
"""Compatibility wrapper for the canonical RFC 9562 generator."""
return new_uuid7()
# ─────────────────────────────────────────────────────────────────────────────
# W3C traceparent 生成
# ─────────────────────────────────────────────────────────────────────────────
def _new_trace_id() -> str:
"""生成 W3C traceparent-compatible trace_id"""
trace_id_hex = uuid.uuid4().hex + uuid.uuid4().hex[:16] # 128-bit
span_id_hex = uuid.uuid4().hex[:16] # 64-bit
return f"00-{trace_id_hex}-{span_id_hex}-01"
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())
# ─────────────────────────────────────────────────────────────────────────────
@@ -74,13 +78,14 @@ def _new_trace_id() -> str:
_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.UUID | None:
) -> UUID | None:
"""
檢查 (project_id, channel_type, provider_event_id) 是否已有對應 run_id。
@@ -91,24 +96,20 @@ async def check_idempotency(
"""
idem_key = f"{_IDEMPOTENCY_REDIS_PREFIX}{project_id}:{channel_type}:{provider_event_id}"
# Layer 1: Redis
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
logger.info(
"idempotency_hit_redis",
project_id=project_id,
provider_event_id=provider_event_id,
run_id=run_id_str,
)
return uuid.UUID(run_id_str)
cached_run_id = UUID(run_id_str)
except Exception as exc:
logger.warning("idempotency_redis_check_failed", error=str(exc))
# Layer 2: PostgreSQL
# PostgreSQL is the only authoritative mapping.
try:
async with get_db_context(project_id) as db:
result = await db.execute(
@@ -120,44 +121,43 @@ async def check_idempotency(
)
row = result.fetchone()
if row:
return uuid.UUID(str(row[0]))
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 _register_idempotency(
async def _cache_idempotency(
project_id: str,
channel_type: str,
provider_event_id: str,
run_id: uuid.UUID,
run_id: UUID,
) -> None:
"""寫入 idempotency 記錄Redis + PostgreSQL"""
"""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)
# Redis NX若已有其他 worker 寫入NX 失敗,無害)
try:
from src.core.redis_client import get_redis
redis = get_redis()
await redis.set(idem_key, run_id_str, ex=_IDEMPOTENCY_REDIS_TTL, nx=True)
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))
# PostgreSQLINSERT OR IGNORE
try:
async with get_db_context(project_id) as db:
stmt = pg_insert(AwoooPRunIdempotency).values(
project_id=project_id,
channel_type=channel_type,
provider_event_id=provider_event_id,
run_id=run_id,
).on_conflict_do_nothing(constraint="uix_run_idempotency_key")
await db.execute(stmt)
except Exception as exc:
logger.warning("idempotency_pg_write_failed", error=str(exc))
# ─────────────────────────────────────────────────────────────────────────────
# Shadow destructive tool check
@@ -192,7 +192,7 @@ async def create_run(
channel_type: str | None = None,
provider_event_id: str | None = None,
timeout_seconds: int = 600,
) -> tuple[uuid.UUID, bool]:
) -> tuple[UUID, bool]:
"""
建立新 run或返回既有 run若重複事件
@@ -201,22 +201,14 @@ async def create_run(
Shadow modeis_shadow=True不產生 user response。
"""
# Idempotency 檢查
if channel_type and provider_event_id:
existing_run_id = await check_idempotency(project_id, channel_type, provider_event_id)
if existing_run_id:
logger.info(
"run_creation_idempotent",
project_id=project_id,
channel_type=channel_type,
provider_event_id=provider_event_id,
existing_run_id=str(existing_run_id),
)
return existing_run_id, True
run_id = _uuid7()
trace_id = _new_trace_id()
timeout_at = datetime.now(timezone.utc) + timedelta(seconds=timeout_seconds)
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
@@ -224,24 +216,85 @@ async def create_run(
canonical = json.dumps(input_payload, sort_keys=True, separators=(",", ":"))
input_sha256 = hashlib.sha256(canonical.encode()).hexdigest()
async with get_db_context(project_id) as db:
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,
)
db.add(run)
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,
)
# 寫入 idempotency 記錄
if channel_type and provider_event_id:
await _register_idempotency(project_id, channel_type, provider_event_id, run_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",
@@ -250,9 +303,10 @@ async def create_run(
agent_id=agent_id,
is_shadow=_SHADOW_ENABLED,
trace_id=trace_id,
work_item_id=correlation.work_item_id,
trigger_type=trigger_type,
)
return run_id, False
return selected_run_id, is_duplicate
# ─────────────────────────────────────────────────────────────────────────────
@@ -341,7 +395,7 @@ async def shadow_execute(run: AwoooPRunState) -> None:
)
async def get_run_status(run_id: uuid.UUID, project_id: str) -> dict[str, Any] | None:
async def get_run_status(run_id: UUID, project_id: str) -> dict[str, Any] | None:
"""
查詢單一 run 的 FSM 狀態。回傳 None 表示不存在。
Router 層透過此 service 函數存取,不直接操作 DB。
@@ -358,6 +412,8 @@ async def get_run_status(run_id: uuid.UUID, project_id: str) -> dict[str, Any] |
if run is None:
return None
work_item_id = canonical_work_item_id(run.project_id, run.run_id)
trace_id = str(run.trace_id or "")
return {
"run_id": str(run.run_id),
"project_id": run.project_id,
@@ -365,6 +421,12 @@ async def get_run_status(run_id: uuid.UUID, project_id: str) -> dict[str, Any] |
"state": run.state,
"is_shadow": run.is_shadow,
"trace_id": run.trace_id,
"work_item_id": work_item_id,
"correlation_status": (
"canonical"
if is_canonical_traceparent(trace_id, run_id=run.run_id)
else "legacy_or_invalid_trace"
),
"attempt_count": run.attempt_count,
"cost_usd": float(run.cost_usd),
"step_count": run.step_count,