# ADR-123: Background Loop Migration Strategy

**狀態**：Accepted
**日期**：2026-05-03（台北）
**決策者**：統帥
**範圍**：31 個背景 loop 的三分類、project_id 注入策略、退出時程表
**關聯**：ADR-111（bootstrap order）、ADR-114（worker lease）、INV-3/INV-8

---

## 背景

INV-3 / INV-8 確認 `main.py` 中有 **31 個** background loop，全部在沒有 `project_id` 的情況下運行。這些 loop 是 AwoooP 多租戶化的最大阻礙之一。

**三個核心問題**：
1. 沒有 `project_id` → 無法使用 RLS（ADR-118）
2. 沒有 `trace_id` → OTel 追蹤不完整（ADR-121）
3. 31 個 loop 不可能同時遷移，需要策略性分批

---

## 決策

### D1 — 三分類（Loop 歸屬）

**分類 A：Platform Internal（維持 `__platform__`）**

這些 loop 本就是平台層，不屬於任何 tenant，永遠使用 `project_id = __platform__`：

| Loop 名稱 | main.py 行 | 說明 |
|----------|-----------|------|
| `run_ai_slo_watchdog_loop` | 623 | Platform 層 SLO 監控 |
| `_run_model_version_tracker_loop` | 701 | AI Provider 版本追蹤 |
| `run_contract_outbox_relay` | （Phase 2 新增）| ADR-113 outbox relay |
| `run_stale_run_reaper` | （Phase 2 新增）| ADR-114 stale reaper |

**分類 B：AWOOOI Tenant（遷移到 `project_id = awoooi`）**

這些 loop 實質上只服務 AWOOOI 這個 tenant，遷移後仍只服務 AWOOOI，但有正確的 project_id：

| Loop 類型 | 範例 | 遷移難度 |
|----------|------|---------|
| 告警處理 loop | `run_alert_processor` | LOW（加 ctx var）|
| Incident 管理 loop | `run_incident_timeline_loop` | LOW |
| Approval resolver | `run_approval_timeout_resolver` | MEDIUM（P0 priority）|
| KM / learning loop | `run_aol_writeback_loop` | MEDIUM |
| Telegram 相關 | `resend_stale_ready_tokens` | MEDIUM |

**分類 C：Per-Tenant（Phase 3+，需重構）**

這些 loop 未來需要為每個 tenant 運行一個實例：

| Loop 類型 | 說明 | 目標架構 |
|----------|------|---------|
| Poller / receiver loop | 收取 channel 事件 | 每 tenant 一個 worker |
| Heartbeat / health check | 監控 tenant 的 K8s 狀態 | 每 tenant 一個 worker |

**Phase 3 前，分類 C 的 loop 暫時保留在 legacy_awoooi_default 狀態。**

### D2 — project_id 注入方式（三種）

**方式 1：contextvars 注入（分類 B 優先）**

```python
# 每個 loop 的頂層加入 project_id context
async def run_approval_timeout_resolver():
    project_id_ctx_var.set("awoooi")     # ADR-111 contextvars
    trace_id = generate_trace_id()
    trace_id_ctx_var.set(trace_id)

    while True:
        await _resolve_approval_timeouts()
        await asyncio.sleep(30)
```

**方式 2：platform_admin DB role（分類 A）**

平台層 loop 使用 `awooop_platform_admin` role（BYPASSRLS），project_id 設為 `__platform__`：

```python
async def run_contract_outbox_relay():
    project_id_ctx_var.set("__platform__")
    async with get_platform_db_session() as db:  # 使用 awooop_platform_admin role
        await _relay_outbox_events(db)
```

**方式 3：legacy_awoooi_default 過渡標籤（未遷移的 loop）**

未遷移的 loop 在 `audit_log` 中用 `project_id = legacy_awoooi_default` 標記，讓 Phase 3+ 的審計可追溯：

```python
# 過渡期標記（PR-10）
LOOP_PROJECT_ID = os.getenv("LOOP_PROJECT_ID", "legacy_awoooi_default")

async def run_some_legacy_loop():
    project_id_ctx_var.set(LOOP_PROJECT_ID)
    # ... loop body
```

### D3 — 遷移優先序（與 PR 計畫對齊）

**P0 優先（立即）**：
- `run_approval_timeout_resolver`（main.py:490）— 缺 trace_id，approval 超時無法追蹤
- 加 `project_id_ctx_var.set("awoooi")` + `trace_id` 生成

**P1 優先（Phase 2）**：
- `resend_stale_ready_tokens`（main.py:362）— Telegram 相關，缺 project_id 影響 RLS
- `run_aol_writeback_loop`（main.py:540）— KM 寫入路徑缺 project_id

**P2 優先（Phase 2，PR-10 批量）**：
- 其餘 20+ 個分類 B 的 loop，加 `project_id_ctx_var.set("awoooi")` 標記

**Phase 3（重構）**：
- 分類 C 的 loop 重構為 per-tenant worker 架構

### D4 — 退出時程表（legacy_awoooi_default 清零）

| 里程碑 | 條件 | 目標 Phase |
|--------|------|-----------|
| 分類 A 全部標記 `__platform__` | 4 個 loop | Phase 2 完成 |
| 分類 B 全部標記 `awoooi` | 27 個 loop | Phase 2 完成（PR-10）|
| `legacy_awoooi_default` loop 清零 | RLS 可真正強制 | Phase 3 完成前 |
| 分類 C 重構為 per-tenant | EwoooC 支援前置 | Phase 3 |
| K8s `LOOP_PROJECT_ID` env var 移除 | 不再需要過渡標籤 | Phase 3 |

### D5 — Loop Health 監控

每個 loop 必須有 Prometheus gauge 記錄最後一次執行時間：

```python
# 標準 loop 健康監控模板
from prometheus_client import Gauge

LOOP_LAST_RUN = Gauge(
    "awooop_background_loop_last_run_timestamp",
    "Unix timestamp of last successful loop execution",
    labelnames=["loop_name", "project_id"]
)

async def run_approval_timeout_resolver():
    project_id = "awoooi"
    project_id_ctx_var.set(project_id)

    while True:
        try:
            await _resolve_approval_timeouts()
            LOOP_LAST_RUN.labels(
                loop_name="approval_timeout_resolver",
                project_id=project_id
            ).set_to_current_time()
        except Exception as e:
            logger.error(f"Loop error: {e}")
        await asyncio.sleep(30)
```

**告警規則**：
```yaml
# Prometheus alert: loop 超過 5 分鐘沒有更新
- alert: BackgroundLoopStale
  expr: time() - awooop_background_loop_last_run_timestamp > 300
  labels:
    severity: warning
```

---

## 後果

### Benefits
- 31 個 loop 的 project_id 遷移有明確分類和優先序
- Phase 2 完成後，`legacy_awoooi_default` loop 清零，RLS 可真正強制
- Loop 健康監控：每個 loop 都有 Prometheus gauge，SLO watchdog 可監控

### Costs
- PR-10 需要修改 ~27 個 loop 的頂部（逐個加 ctx var）
- 分類 C 的 loop 重構（Phase 3）是大型工程，需要 planner 拆解

### Risks
- 過渡期有 `legacy_awoooi_default` loop 繞過 RLS（設計上允許）
- 緩解：RLS 的空字串 policy 在 Phase 3 移除，Phase 1~2 可接受
- P0 priority loop（approval_timeout_resolver）若遷移失敗 → approval 超時告警無法送達

---

## 驗收標準

- [ ] `run_approval_timeout_resolver` 加 project_id + trace_id（P0，立即）
- [ ] 全部 31 個 loop 標記 project_id（PR-10，Phase 2）
- [ ] `legacy_awoooi_default` loop 清零（Phase 3 前提條件）
- [ ] 每個 loop 有 Prometheus gauge 健康監控（Phase 2）
- [ ] Loop stale alert 觸發測試（Phase 2 驗收）

## 關聯

- ADR-111（三 identity markers，platform_internal / legacy_awoooi_default / requires_project_id）
- ADR-114（stale reaper，新增 platform_internal loop）
- ADR-118（RLS，loop project_id 正確性是 RLS 前置條件）
- INV-3/INV-8（31 個 loop 詳細清單）
- PR-10（批量加 loop tagging）
