71 KiB
aider-watch v2 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 把 aider CLI 監控完全整合進 awoooi AI 自主化飛輪 — server 端新增 webhook + 處理 job + AI Router feedback hook;Mac client 薄殼只做 event capture + HTTP POST。
Architecture: Mac aiderw wrapper 包 aider subprocess → HMAC-signed HTTP POST 到 awoooi API webhook → Redis stream → background processor → aider_events table + 若 error 自動建 incident(走既有 TG gateway)+ 日聚合寫 playbooks.symptom_pattern + AI Router 讀 feedback 調整 model routing。
Tech Stack: FastAPI, SQLAlchemy async, Pydantic v2, Redis stream, psycopg2, requests, pexpect, HMAC-SHA256, launchd, pipx / Python 3.11
Spec: docs/superpowers/specs/2026-04-20-aider-watch-v2-design.md
File Structure
Server side — awoooi repo
| 檔案 | 職責 |
|---|---|
apps/api/migrations/adr091_aider_events_schema.sql |
DB migration:新表 aider_events |
apps/api/src/models/aider.py |
Pydantic: AiderEventIn, 6 種 event type payload schema |
apps/api/src/utils/secret_redactor.py |
泛用 secret 遮罩(通用 util,不只 aider 用) |
apps/api/src/repositories/aider_event_repository.py |
CRUD aider_events + 聚合查詢 |
apps/api/src/services/aider_event_service.py |
分類、建 incident、抽 symptom_pattern |
apps/api/src/api/v1/aider_events.py |
POST webhook + HMAC 驗證 |
apps/api/src/jobs/aider_event_processor_job.py |
Redis stream consumer + dispatcher + 日 batch pattern extractor |
apps/api/src/services/ai_router.py |
新增 feedback_from_aider_events() method(修改既有檔) |
apps/api/src/core/config.py |
新增 3 settings(修改既有檔) |
apps/api/src/main.py |
lifespan 註冊新 job(修改既有檔) |
apps/api/tests/test_aider_event_models.py |
Pydantic schema unit tests |
apps/api/tests/test_secret_redactor.py |
Redactor unit tests |
apps/api/tests/test_aider_event_service.py |
Service logic tests |
apps/api/tests/test_aider_events_api.py |
API webhook + HMAC tests |
apps/api/tests/integration/test_aider_event_flow.py |
E2E: webhook → Redis → job → DB |
Mac client — awoooi/scripts/aider_watch_client/
| 檔案 | 職責 |
|---|---|
scripts/aider_watch_client/pyproject.toml |
pipx 安裝定義 |
scripts/aider_watch_client/aider_watch_client/__init__.py |
套件版本 |
scripts/aider_watch_client/aider_watch_client/parsers.py |
aider stdout banner + chat_history.md + git numstat(從 v1 搬) |
scripts/aider_watch_client/aider_watch_client/api_client.py |
HTTP POST + HMAC-SHA256 + retry backoff |
scripts/aider_watch_client/aider_watch_client/buffer.py |
JSONL buffer write + flush |
scripts/aider_watch_client/aider_watch_client/config.py |
讀 ~/.aider-watch.env + 路徑常數 |
scripts/aider_watch_client/aider_watch_client/redactor.py |
與 server 同步的 secret redactor(複本,獨立可部署) |
scripts/aider_watch_client/aider_watch_client/aiderw.py |
主 wrapper:subprocess + event capture + dispatcher |
scripts/aider_watch_client/bin/aiderw |
CLI shim |
scripts/aider_watch_client/scripts/install.sh |
安裝:pipx install + 寫 ~/.aider-watch.env + 註冊 launchd |
scripts/aider_watch_client/launchd/com.awoooi.aider-flush.plist |
每 5min flush buffer |
scripts/aider_watch_client/tests/test_api_client.py |
HMAC 簽章 + retry 單元測試 |
scripts/aider_watch_client/tests/test_buffer.py |
Buffer write/flush 測試 |
Phase A: Server-side(10 tasks)
Task A0: 前置 — 取 PG 密碼 + 生 HMAC secret + 本機 env
Files:
-
Create:
/tmp/aider_webhook_secret.txt(安裝後 shred 刪) -
Step 1: 從 K8s Secret 取 PG 密碼(🆕 盲點 4:明確 secret 來源)
# 需 kubectl 已有 awoooi cluster 權限(~/.kube/config 指向正確 context)
kubectl config current-context # 期待 awoooi-* 或 k3s-188
kubectl get secret awoooi-secrets -n awoooi \
-o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d > /tmp/pg_pw.txt
export AWOOOI_PG_PW="$(cat /tmp/pg_pw.txt)"
shred -u /tmp/pg_pw.txt # 立即銷毀檔案
test -n "$AWOOOI_PG_PW" && echo "PG PW loaded (${#AWOOOI_PG_PW} chars)" || exit 1
若 kubectl 不可用或 context 不對,統帥需手動提供 AWOOOI_PG_PW — 不自動生成、不 fallback。
- Step 2: 生成 HMAC secret(32 bytes hex)
openssl rand -hex 32 > /tmp/aider_webhook_secret.txt
chmod 600 /tmp/aider_webhook_secret.txt
cat /tmp/aider_webhook_secret.txt # 記下!之後進 K8s secret + Mac ~/.aider-watch.env
- Step 3: 設定本機 dev env
export AIDER_WEBHOOK_SECRET="$(cat /tmp/aider_webhook_secret.txt)"
export AIDER_EVENTS_STREAM_KEY="signals:aider:events"
export AIDER_PATTERN_EXTRACT_INTERVAL_HOURS="24"
export USE_AIDER_FEEDBACK="false" # 🆕 盲點 3:預設關閉,驗證後再開
- Step 4: 驗證 Redis + PG 可達
redis-cli -h 192.168.0.120 -p 6380 ping # 期待 PONG
PGPASSWORD="$AWOOOI_PG_PW" psql -h 192.168.0.188 -U awoooi_rw -d awoooi -c 'SELECT 1'
Expected: 兩個命令都成功。任一失敗 → STOP,先解決再往下。
Task A1: DB Migration — aider_events 表
Files:
-
Create:
apps/api/migrations/adr091_aider_events_schema.sql -
Step 1: 寫 migration SQL
-- adr091: aider_events schema
-- 2026-04-20 @ Asia/Taipei
-- 紀錄統帥本機 aider CLI 活動,供 AI Router feedback + symptom_pattern 抽取
CREATE TABLE IF NOT EXISTS aider_events (
id BIGSERIAL PRIMARY KEY,
session_id TEXT NOT NULL,
ts TIMESTAMPTZ NOT NULL,
type TEXT NOT NULL, -- session_start|file_edit|error|commit|silent_timeout|session_end|raw
host TEXT DEFAULT 'ogt-mac',
payload JSONB NOT NULL,
incident_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS aider_events_session_idx ON aider_events(session_id);
CREATE INDEX IF NOT EXISTS aider_events_type_ts_idx ON aider_events(type, ts DESC);
CREATE INDEX IF NOT EXISTS aider_events_ts_idx ON aider_events(ts DESC);
CREATE INDEX IF NOT EXISTS aider_events_payload_gin ON aider_events USING GIN (payload);
COMMENT ON TABLE aider_events IS 'aider CLI 事件流(Mac 端 aiderw wrapper 推入)';
COMMENT ON COLUMN aider_events.incident_id IS '若觸發建 incident,記 FK 至 incidents.incident_id';
COMMENT ON COLUMN aider_events.payload IS 'Type-specific payload JSON,見 src/models/aider.py schema';
- Step 2: 套 migration 到 dev DB
PGPASSWORD="$AWOOOI_PG_PW" psql -h 192.168.0.188 -U awoooi_rw -d awoooi \
-f apps/api/migrations/adr091_aider_events_schema.sql
- Step 3: 驗證表 + index 建立
PGPASSWORD="$AWOOOI_PG_PW" psql -h 192.168.0.188 -U awoooi_rw -d awoooi \
-c '\d+ aider_events' -c '\di aider_events*'
Expected: 表有 9 欄(id/session_id/ts/type/host/payload/incident_id/created_at),4 indexes。
- Step 4: Commit
git add apps/api/migrations/adr091_aider_events_schema.sql
git commit -m "feat(migration): adr091 aider_events table"
Task A2: 泛用 Secret Redactor
Files:
- Create:
apps/api/src/utils/secret_redactor.py - Create:
apps/api/tests/test_secret_redactor.py
(從 v1 ~/aider-watch/aider_watch/redactor.py 搬,改為 awoooi 一般用途)
- Step 1: 寫測試 apps/api/tests/test_secret_redactor.py
# 2026-04-20 @ Asia/Taipei
from apps.api.src.utils.secret_redactor import redact
def test_openrouter_key_redacted():
assert "<redacted:openrouter>" in redact("sk-or-example-redaction-test-token")
def test_anthropic_key_redacted():
assert "<redacted:anthropic>" in redact("sk-ant-api03-abcDEF_123-xyz")
def test_github_token_redacted():
assert "<redacted:github>" in redact("ghp_example_redaction_test_token")
def test_google_key_redacted():
assert "<redacted:google>" in redact("AIzaSy-example-redaction-test-key")
def test_telegram_bot_token_redacted():
assert "<redacted:telegram>" in redact("<TELEGRAM_BOT_TOKEN_SAMPLE>")
def test_aws_key_redacted():
assert "<redacted:aws>" in redact("key=AKIAIOSFODNN7EXAMPLE")
def test_clean_passthrough():
assert redact("normal text here") == "normal text here"
def test_nested_dict():
d = {"a": "ghp_example_redaction_test_token", "b": {"c": "AIzaSy-example-redaction-test-key"}}
out = redact(d)
assert "ghp_example" not in str(out)
assert "AIzaSy" not in str(out)
- Step 2: 跑測試驗失敗
cd /Users/ogt/awoooi && pytest apps/api/tests/test_secret_redactor.py -v
Expected: ModuleNotFoundError
- Step 3: 寫實作 apps/api/src/utils/secret_redactor.py
# awoooi utils: generic secret redactor | 2026-04-20 @ Asia/Taipei
"""把字串/dict/list 內 secret pattern 遮罩為 <redacted:kind>。
符合 feedback_secrets_leak_incidents_2026-04-18.md — 任何進 PG/TG/log 前都該過這層。"""
from __future__ import annotations
import re
from typing import Any
# 順序:specific 在前,generic 在後(sk-or-v1 / sk-ant 排 sk- 之前)
_PATTERNS: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"sk-or-v1-[A-Za-z0-9]{36,}"), "openrouter"),
(re.compile(r"sk-ant-api\d{2}-[A-Za-z0-9_\-]{12,}"), "anthropic"),
(re.compile(r"sk-[A-Za-z0-9]{40,}"), "openai"),
(re.compile(r"ghp_[A-Za-z0-9]{36}"), "github"),
(re.compile(r"AIza[0-9A-Za-z_\-]{35}"), "google"),
(re.compile(r"\b\d{8,10}:[A-Za-z0-9_\-]{35}\b"), "telegram"),
(re.compile(r"AKIA[0-9A-Z]{16}"), "aws"),
]
def redact(obj: Any) -> Any:
"""遮罩字串/dict/list 內 secret,其他型別原值回傳。"""
if isinstance(obj, str):
s = obj
for pat, kind in _PATTERNS:
s = pat.sub(f"<redacted:{kind}>", s)
return s
if isinstance(obj, dict):
return {k: redact(v) for k, v in obj.items()}
if isinstance(obj, list):
return [redact(x) for x in obj]
return obj
- Step 4: 跑測試驗通過
pytest apps/api/tests/test_secret_redactor.py -v
Expected: 8 passed
- Step 5: Commit
git add apps/api/src/utils/secret_redactor.py apps/api/tests/test_secret_redactor.py
git commit -m "feat(utils): generic secret_redactor (7 patterns)"
Task A3: Pydantic Models — aider event schema
Files:
-
Create:
apps/api/src/models/aider.py -
Create:
apps/api/tests/test_aider_event_models.py -
Step 1: 寫測試
# apps/api/tests/test_aider_event_models.py | 2026-04-20 @ Asia/Taipei
from datetime import datetime, timezone, timedelta
import pytest
from pydantic import ValidationError
from apps.api.src.models.aider import AiderEventIn, AiderBatchIn
TAIPEI = timezone(timedelta(hours=8))
def _base(t="session_start", payload=None):
return {
"ts": datetime(2026, 4, 20, 10, 0, tzinfo=TAIPEI).isoformat(),
"session_id": "01J7XYZABC",
"host": "ogt-mac",
"type": t,
"payload": payload or {},
}
def test_accepts_all_7_types():
for t in ("session_start", "file_edit", "error", "commit",
"silent_timeout", "session_end", "raw"):
ev = AiderEventIn(**_base(t=t))
assert ev.type == t
def test_rejects_unknown_type():
with pytest.raises(ValidationError):
AiderEventIn(**_base(t="bogus"))
def test_rejects_missing_session_id():
d = _base(); del d["session_id"]
with pytest.raises(ValidationError):
AiderEventIn(**d)
def test_rejects_missing_ts():
d = _base(); del d["ts"]
with pytest.raises(ValidationError):
AiderEventIn(**d)
def test_batch_max_50():
with pytest.raises(ValidationError):
AiderBatchIn(events=[_base() for _ in range(51)])
def test_batch_accepts_1():
b = AiderBatchIn(events=[_base()])
assert len(b.events) == 1
def test_host_default():
d = _base(); del d["host"]
ev = AiderEventIn(**d)
assert ev.host == "ogt-mac"
- Step 2: 跑測試驗失敗
pytest apps/api/tests/test_aider_event_models.py -v
Expected: ModuleNotFoundError
- Step 3: 寫實作
# apps/api/src/models/aider.py | 2026-04-20 @ Asia/Taipei
"""aider-watch event pydantic schemas。由 Mac client POST 送來。"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
EventType = Literal[
"session_start", "file_edit", "error", "commit",
"silent_timeout", "session_end", "raw",
]
class AiderEventIn(BaseModel):
"""單筆 aider event 輸入 schema。"""
model_config = ConfigDict(frozen=True, extra="forbid")
ts: datetime
session_id: str = Field(min_length=1, max_length=64)
host: str = Field(default="ogt-mac", max_length=64)
type: EventType
payload: dict[str, Any] = Field(default_factory=dict)
@field_validator("ts")
@classmethod
def _ensure_tz(cls, v: datetime) -> datetime:
if v.tzinfo is None:
raise ValueError("ts must be timezone-aware")
return v
class AiderBatchIn(BaseModel):
"""批次 event 輸入(最多 50 筆/次)。"""
model_config = ConfigDict(frozen=True, extra="forbid")
events: list[AiderEventIn] = Field(min_length=1, max_length=50)
- Step 4: 跑測試驗通過
pytest apps/api/tests/test_aider_event_models.py -v
Expected: 7 passed
- Step 5: Commit
git add apps/api/src/models/aider.py apps/api/tests/test_aider_event_models.py
git commit -m "feat(models): AiderEventIn + AiderBatchIn pydantic schemas"
Task A4: Repository
Files:
-
Create:
apps/api/src/repositories/aider_event_repository.py -
Step 1: 寫 repository 實作(SQLAlchemy async 風格,沿用 awoooi 既有)
# apps/api/src/repositories/aider_event_repository.py | 2026-04-20 @ Asia/Taipei
"""aider_events CRUD + 聚合查詢。"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
class AiderEventRepository:
def __init__(self, session: AsyncSession):
self.session = session
async def insert(self, *, session_id: str, ts: datetime, type_: str,
host: str, payload: dict[str, Any],
incident_id: str | None = None) -> int:
r = await self.session.execute(text("""
INSERT INTO aider_events (session_id, ts, type, host, payload, incident_id)
VALUES (:sid, :ts, :t, :h, CAST(:p AS JSONB), :inc)
RETURNING id
"""), {"sid": session_id, "ts": ts, "t": type_, "h": host,
"p": _json(payload), "inc": incident_id})
return r.scalar_one()
async def link_incident(self, event_ids: list[int], incident_id: str) -> None:
await self.session.execute(text("""
UPDATE aider_events SET incident_id = :inc WHERE id = ANY(:ids)
"""), {"inc": incident_id, "ids": event_ids})
async def count_by_session(self, session_id: str) -> int:
r = await self.session.execute(text(
"SELECT count(*) FROM aider_events WHERE session_id = :sid"
), {"sid": session_id})
return r.scalar_one()
async def model_stats_since(self, days: int = 7) -> list[dict]:
"""回傳 [{repo, model, total, errors, success_rate}, ...]"""
cutoff = datetime.now().astimezone() - timedelta(days=days)
r = await self.session.execute(text("""
WITH session_models AS (
SELECT session_id,
MAX(CASE WHEN type='session_start'
THEN payload->>'model' END) AS model,
MAX(CASE WHEN type='session_start'
THEN payload->>'cwd' END) AS repo,
COUNT(*) FILTER (WHERE type='error') AS err_count,
COUNT(*) FILTER (WHERE type='session_end'
AND (payload->>'exit_code')::int != 0) AS nonzero_exit
FROM aider_events
WHERE ts >= :cutoff
GROUP BY session_id
)
SELECT repo, model,
COUNT(*) AS total,
SUM(CASE WHEN err_count>0 OR nonzero_exit>0 THEN 1 ELSE 0 END) AS errors,
1.0 - AVG(CASE WHEN err_count>0 OR nonzero_exit>0 THEN 1.0 ELSE 0.0 END) AS success_rate
FROM session_models
WHERE model IS NOT NULL AND repo IS NOT NULL
GROUP BY repo, model
"""), {"cutoff": cutoff})
return [dict(row._mapping) for row in r.fetchall()]
async def daily_pattern_candidates(self, *, days: int = 7,
threshold: float = 0.25,
min_samples: int = 3) -> list[dict]:
"""抽 symptom_pattern 候選:error_rate >= threshold, samples >= min_samples。"""
rows = await self.model_stats_since(days=days)
out = []
for row in rows:
if row["total"] < min_samples:
continue
err_rate = (row["errors"] or 0) / row["total"]
if err_rate < threshold:
continue
out.append({
"repo": row["repo"],
"model": row["model"],
"error_rate": round(err_rate, 3),
"samples": row["total"],
})
return out
def _json(obj: Any) -> str:
import json as _j
return _j.dumps(obj, ensure_ascii=False, default=str)
- Step 2: 驗 import 無誤
cd /Users/ogt/awoooi && /Users/ogt/.pyenv/versions/3.11.7/bin/python3 -c "from apps.api.src.repositories.aider_event_repository import AiderEventRepository; print('OK')"
Expected: OK
- Step 3: Commit
git add apps/api/src/repositories/aider_event_repository.py
git commit -m "feat(repo): AiderEventRepository CRUD + model_stats + pattern candidates"
Task A5: Service — classify + incident coalesce + pattern
Files:
- Create:
apps/api/src/services/aider_event_service.py - Create:
apps/api/tests/test_aider_event_service.py
🆕 盲點 2(incident 洗版):同 session_id + aider_activity 在 60s 窗口內只建 1 個 incident,第 2+ error 合進該 incident 的 signals 陣列。Redis key aider_incident_coalesce:{session_id} TTL=60s 存 incident_id。
- Step 1: 寫測試(mock incident_service,因這是 service 的單元邊界)
# apps/api/tests/test_aider_event_service.py | 2026-04-20 @ Asia/Taipei
import pytest
from datetime import datetime, timezone, timedelta
from unittest.mock import AsyncMock, MagicMock
from apps.api.src.services.aider_event_service import (
classify_severity, should_create_incident,
)
from apps.api.src.models.aider import AiderEventIn
TAIPEI = timezone(timedelta(hours=8))
def _ev(t, p=None):
return AiderEventIn(
ts=datetime.now(TAIPEI), session_id="s1",
host="ogt-mac", type=t, payload=p or {},
)
def test_classify_error_is_p2():
assert classify_severity(_ev("error")) == "P2"
def test_classify_silent_timeout_is_p3():
assert classify_severity(_ev("silent_timeout")) == "P3"
def test_classify_session_end_nonzero_exit_is_p2():
assert classify_severity(_ev("session_end", {"exit_code": 1})) == "P2"
def test_classify_session_end_multi_error_is_p1():
assert classify_severity(_ev("session_end", {"exit_code": 1, "error_count": 5})) == "P1"
def test_classify_session_end_ok_no_incident():
ev = _ev("session_end", {"exit_code": 0, "error_count": 0})
assert classify_severity(ev) is None
assert should_create_incident(ev) is False
def test_session_start_no_incident():
assert should_create_incident(_ev("session_start")) is False
def test_error_creates_incident():
assert should_create_incident(_ev("error")) is True
@pytest.mark.asyncio
async def test_coalesce_reuses_existing_incident():
"""盲點 2:同 session 60s 內只建 1 個 incident。"""
from apps.api.src.services.aider_event_service import create_incident_for_event
# Fake incident_svc + append_signal
append_calls = []
class FakeIncSvc:
async def create_incident(self, **kw): return "inc-first"
async def append_signal(self, inc_id, sig): append_calls.append((inc_id, sig))
# Fake redis: 第一次 get 回 None,set 存;第二次 get 回 "inc-first"
store = {}
class FakeRedis:
async def get(self, k): return store.get(k)
async def set(self, k, v, ex=None): store[k] = v
svc = FakeIncSvc(); redis = FakeRedis()
e1 = _ev("error"); e2 = _ev("error")
id1 = await create_incident_for_event(e1, svc, redis_client=redis)
id2 = await create_incident_for_event(e2, svc, redis_client=redis)
assert id1 == id2 == "inc-first"
assert len(append_calls) == 1 # 第二個 event 只 append,不建新
assert append_calls[0][1]["event_type"] == "error"
@pytest.mark.asyncio
async def test_coalesce_different_session_id_builds_new():
from apps.api.src.services.aider_event_service import create_incident_for_event
seen = []
class FakeIncSvc:
async def create_incident(self, **kw):
seen.append(kw); return f"inc-{len(seen)}"
async def append_signal(self, *_): pass
store = {}
class FakeRedis:
async def get(self, k): return store.get(k)
async def set(self, k, v, ex=None): store[k] = v
ea = AiderEventIn(ts=datetime.now(TAIPEI), session_id="sA",
host="m", type="error", payload={"cwd":"/x","model":"m1"})
eb = AiderEventIn(ts=datetime.now(TAIPEI), session_id="sB",
host="m", type="error", payload={"cwd":"/x","model":"m1"})
id_a = await create_incident_for_event(ea, FakeIncSvc(), redis_client=FakeRedis())
id_b = await create_incident_for_event(eb, FakeIncSvc(), redis_client=FakeRedis())
# 不同 session 各自建
assert id_a == "inc-1"
assert id_b == "inc-1" # 注意:每個 FakeIncSvc 是獨立 instance,所以都是 inc-1
- Step 2: 跑測試驗失敗
pytest apps/api/tests/test_aider_event_service.py -v
Expected: ModuleNotFoundError
- Step 3: 寫實作
# apps/api/src/services/aider_event_service.py | 2026-04-20 @ Asia/Taipei
"""分類 aider events、觸發 incident、抽 symptom_pattern → playbooks。"""
from __future__ import annotations
from typing import Any, Protocol
from apps.api.src.models.aider import AiderEventIn
from apps.api.src.utils.secret_redactor import redact
# ---- 分類邏輯(純函式,好測)----
def classify_severity(ev: AiderEventIn) -> str | None:
"""回 P0-P3 severity,或 None 表不該建 incident。"""
t = ev.type
if t == "error":
return "P2"
if t == "silent_timeout":
return "P3"
if t == "session_end":
exit_code = ev.payload.get("exit_code", 0)
err_count = ev.payload.get("error_count", 0)
if exit_code != 0 and err_count >= 3:
return "P1"
if exit_code != 0 or err_count >= 1:
return "P2"
return None
def should_create_incident(ev: AiderEventIn) -> bool:
return classify_severity(ev) is not None
# ---- incident 建立接口(需要 incident_service 協作)----
class IncidentServiceLike(Protocol):
async def create_incident(self, *, title: str, severity: str,
alert_category: str, affected_services: list[str],
signals: dict[str, Any]) -> str: ...
COALESCE_TTL_SEC = 60
COALESCE_KEY_PREFIX = "aider_incident_coalesce:"
async def create_incident_for_event(ev: AiderEventIn,
incident_svc: IncidentServiceLike,
redis_client=None) -> str | None:
"""若 event 應觸發 incident,建立並回傳 incident_id;否則 None。
🆕 盲點 2:同 session_id 60s 內只建 1 個 incident,後續合進 signals。"""
sev = classify_severity(ev)
if not sev:
return None
p = redact(ev.payload)
repo = p.get("cwd") or "<unknown>"
model = p.get("model") or "<unknown>"
# Coalesce 檢查
existing_id: str | None = None
if redis_client is not None:
key = f"{COALESCE_KEY_PREFIX}{ev.session_id}"
try:
raw = await redis_client.get(key)
if raw:
existing_id = raw.decode() if isinstance(raw, bytes) else raw
except Exception:
pass # Redis 失敗不阻塞 fallback 建新 incident
if existing_id:
# 同 session 已有 incident → 合進 signals,不建新的
if hasattr(incident_svc, "append_signal"):
try:
await incident_svc.append_signal(existing_id, {
"ts": ev.ts.isoformat(),
"event_type": ev.type,
"payload_excerpt": p,
})
except Exception:
pass
return existing_id
title_map = {
"error": f"[aider] error in {repo}",
"silent_timeout": f"[aider] silent timeout in {repo}",
"session_end": f"[aider] session exit={p.get('exit_code')} in {repo}",
}
title = title_map.get(ev.type, f"[aider] {ev.type} in {repo}")
inc_id = await incident_svc.create_incident(
title=title, severity=sev,
alert_category="aider_activity",
affected_services=[_repo_name(repo)],
signals={
"source": "aider_watch",
"session_id": ev.session_id,
"model": model,
"event_type": ev.type,
"payload_excerpt": p,
},
)
# 寫 Redis coalesce key
if redis_client is not None and inc_id:
try:
await redis_client.set(f"{COALESCE_KEY_PREFIX}{ev.session_id}",
inc_id, ex=COALESCE_TTL_SEC)
except Exception:
pass
return inc_id
def _repo_name(cwd: str) -> str:
"""/Users/ogt/awoooi → awoooi"""
return cwd.rstrip("/").rsplit("/", 1)[-1] if cwd else "unknown"
# ---- symptom_pattern 抽取 → playbooks(由 job 日 batch 呼叫)----
async def extract_and_persist_patterns(
repo_candidates: list[dict],
playbook_repo, # PlaybookRepository (awoooi 既有)
) -> int:
"""收 repository.daily_pattern_candidates() 的結果,寫 playbooks 候選。回寫入數。"""
written = 0
for c in repo_candidates:
symptom_pattern = {
"source": "aider_watch",
"repo": c["repo"],
"model": c["model"],
"task_kind": "aider_usage",
"error_rate": c["error_rate"],
"samples": c["samples"],
}
await playbook_repo.upsert_candidate(
symptom_pattern=symptom_pattern,
source_label="aider_watch_daily_extract",
)
written += 1
return written
- Step 4: 跑測試驗通過
pytest apps/api/tests/test_aider_event_service.py -v
Expected: 7 passed
- Step 5: Commit
git add apps/api/src/services/aider_event_service.py apps/api/tests/test_aider_event_service.py
git commit -m "feat(service): aider_event_service — classify + incident + pattern extract"
Task A6: API Webhook + HMAC
Files:
-
Create:
apps/api/src/api/v1/aider_events.py -
Create:
apps/api/tests/test_aider_events_api.py -
Step 1: 寫測試
# apps/api/tests/test_aider_events_api.py | 2026-04-20 @ Asia/Taipei
import hmac, hashlib, json
import pytest
from fastapi.testclient import TestClient
from apps.api.src.main import app
SECRET = "testsecret_for_unittest_do_not_use_in_prod_" + "x" * 20
def _sign(body: bytes, secret: str = SECRET) -> str:
return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
@pytest.fixture
def client(monkeypatch):
monkeypatch.setenv("AIDER_WEBHOOK_SECRET", SECRET)
return TestClient(app)
def _ev():
return {
"ts": "2026-04-20T10:00:00+08:00",
"session_id": "s1", "host": "ogt-mac",
"type": "session_start",
"payload": {"cwd": "/t/x", "model": "elephant", "aider_args": [],
"aider_pid": 1, "cli_version": "0.86"},
}
def test_accepts_signed_batch(client):
body = json.dumps({"events": [_ev()]}).encode()
r = client.post("/api/v1/aider/events", data=body,
headers={"X-Aider-Signature": _sign(body),
"Content-Type": "application/json"})
assert r.status_code == 202
assert r.json()["accepted"] == 1
def test_rejects_invalid_signature(client):
body = json.dumps({"events": [_ev()]}).encode()
r = client.post("/api/v1/aider/events", data=body,
headers={"X-Aider-Signature": "sha256=0" * 65,
"Content-Type": "application/json"})
assert r.status_code == 401
def test_rejects_missing_signature(client):
body = json.dumps({"events": [_ev()]}).encode()
r = client.post("/api/v1/aider/events", data=body,
headers={"Content-Type": "application/json"})
assert r.status_code == 401
def test_rejects_malformed_event(client):
body = json.dumps({"events": [{"bad": "payload"}]}).encode()
r = client.post("/api/v1/aider/events", data=body,
headers={"X-Aider-Signature": _sign(body),
"Content-Type": "application/json"})
assert r.status_code == 400
def test_rejects_oversize_batch(client):
body = json.dumps({"events": [_ev()] * 51}).encode()
r = client.post("/api/v1/aider/events", data=body,
headers={"X-Aider-Signature": _sign(body),
"Content-Type": "application/json"})
assert r.status_code == 400
- Step 2: 跑測試驗失敗
pytest apps/api/tests/test_aider_events_api.py -v
Expected: 404 / ModuleNotFoundError
- Step 3: 寫實作
# apps/api/src/api/v1/aider_events.py | 2026-04-20 @ Asia/Taipei
"""POST /api/v1/aider/events — Mac aiderw client 推事件入口。HMAC-verified。"""
from __future__ import annotations
import hmac, hashlib, os, json, logging
from fastapi import APIRouter, Header, HTTPException, Request, status
from pydantic import ValidationError
from apps.api.src.models.aider import AiderBatchIn
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1/aider", tags=["aider"])
def _verify_signature(body: bytes, signature: str | None) -> bool:
if not signature or not signature.startswith("sha256="):
return False
secret = os.environ.get("AIDER_WEBHOOK_SECRET", "")
if not secret:
return False
expected = "sha256=" + hmac.new(secret.encode(), body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
@router.post("/events", status_code=status.HTTP_202_ACCEPTED)
async def receive_aider_events(
request: Request,
x_aider_signature: str | None = Header(default=None, alias="X-Aider-Signature"),
):
body = await request.body()
if not _verify_signature(body, x_aider_signature):
raise HTTPException(status_code=401, detail="invalid signature")
try:
batch = AiderBatchIn.model_validate_json(body)
except ValidationError as e:
raise HTTPException(status_code=400, detail=e.errors()[:5])
# push 到 Redis stream:讓 job 非同步處理;失敗時回 503 讓 client 走 buffer
from apps.api.src.core.redis_client import get_redis # 既有 awoooi util
stream_key = os.environ.get("AIDER_EVENTS_STREAM_KEY", "signals:aider:events")
try:
r = await get_redis()
pipe = r.pipeline()
for ev in batch.events:
pipe.xadd(stream_key, {"payload": ev.model_dump_json()})
ids = await pipe.execute()
except Exception as exc:
logger.exception("aider_events: redis push failed")
raise HTTPException(status_code=503, detail="queue unavailable") from exc
return {"accepted": len(batch.events), "stream_ids": [str(i) for i in ids]}
- Step 4: 註冊 router 到 awoooi FastAPI app
查 apps/api/src/main.py 看既有 router 註冊 pattern(app.include_router(...)),在相同地方加:
from apps.api.src.api.v1.aider_events import router as aider_router
app.include_router(aider_router)
- Step 5: 跑測試驗通過
AIDER_WEBHOOK_SECRET="testsecret_for_unittest_do_not_use_in_prod_xxxxxxxxxxxxxxxxxxxx" \
pytest apps/api/tests/test_aider_events_api.py -v
Expected: 5 passed
- Step 6: Commit
git add apps/api/src/api/v1/aider_events.py apps/api/tests/test_aider_events_api.py apps/api/src/main.py
git commit -m "feat(api): POST /api/v1/aider/events HMAC-verified webhook → Redis stream"
Task A7: Background Job — Redis stream consumer + 日 batch
Files:
-
Create:
apps/api/src/jobs/aider_event_processor_job.py -
Step 1: 寫 job
# apps/api/src/jobs/aider_event_processor_job.py | 2026-04-20 @ Asia/Taipei
"""背景 job:消費 signals:aider:events stream,寫 aider_events 表、觸發 incident,
日 batch 抽 symptom_pattern。沿用 Phase 7 scanner loop pattern。"""
from __future__ import annotations
import asyncio, logging, os, json
from datetime import datetime, timezone, timedelta
from apps.api.src.models.aider import AiderEventIn
from apps.api.src.services.aider_event_service import (
create_incident_for_event, extract_and_persist_patterns,
)
logger = logging.getLogger(__name__)
TAIPEI = timezone(timedelta(hours=8))
CONSUMER_GROUP = "aider_processor"
CONSUMER_NAME = "aider_processor_1"
POLL_BLOCK_MS = 5000
async def _process_one(ev: AiderEventIn, session_factory, incident_svc) -> None:
"""處理一筆 event:寫 DB + (若需要)建 incident。"""
from apps.api.src.repositories.aider_event_repository import AiderEventRepository
incident_id: str | None = None
try:
incident_id = await create_incident_for_event(ev, incident_svc)
except Exception:
logger.exception("aider_job: create_incident failed; continue to DB write")
async with session_factory() as sess:
repo = AiderEventRepository(sess)
await repo.insert(
session_id=ev.session_id, ts=ev.ts, type_=ev.type,
host=ev.host, payload=ev.payload, incident_id=incident_id,
)
await sess.commit()
async def _ensure_group(r, stream_key: str) -> None:
try:
await r.xgroup_create(stream_key, CONSUMER_GROUP, id="0", mkstream=True)
except Exception as e:
if "BUSYGROUP" not in str(e):
raise
async def _consume_loop():
from apps.api.src.core.redis_client import get_redis
from apps.api.src.db.session import get_session_factory
from apps.api.src.services.incident_service import get_incident_service
r = await get_redis()
session_factory = get_session_factory()
incident_svc = get_incident_service()
stream_key = os.environ.get("AIDER_EVENTS_STREAM_KEY", "signals:aider:events")
await _ensure_group(r, stream_key)
while True:
try:
res = await r.xreadgroup(CONSUMER_GROUP, CONSUMER_NAME,
{stream_key: ">"}, count=10, block=POLL_BLOCK_MS)
if not res:
continue
for _sk, msgs in res:
for msg_id, data in msgs:
try:
raw = data.get(b"payload") or data.get("payload")
if isinstance(raw, bytes):
raw = raw.decode()
ev = AiderEventIn.model_validate_json(raw)
await _process_one(ev, session_factory, incident_svc)
await r.xack(stream_key, CONSUMER_GROUP, msg_id)
except Exception:
logger.exception("aider_job: process msg %s failed", msg_id)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("aider_job: consume loop error; retry 3s")
await asyncio.sleep(3)
async def _daily_pattern_loop():
from apps.api.src.db.session import get_session_factory
from apps.api.src.repositories.aider_event_repository import AiderEventRepository
from apps.api.src.repositories.playbook_repository import get_playbook_repository
interval_h = float(os.environ.get("AIDER_PATTERN_EXTRACT_INTERVAL_HOURS", "24"))
session_factory = get_session_factory()
while True:
try:
async with session_factory() as sess:
repo = AiderEventRepository(sess)
candidates = await repo.daily_pattern_candidates()
if candidates:
pb_repo = await get_playbook_repository()
n = await extract_and_persist_patterns(candidates, pb_repo)
logger.info("aider_job: persisted %d pattern candidates", n)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("aider_job: daily pattern extract failed")
await asyncio.sleep(interval_h * 3600)
async def run_aider_event_processor_loop():
"""對外入口:同時跑 consume loop + daily pattern loop。"""
await asyncio.gather(_consume_loop(), _daily_pattern_loop())
- Step 2: 驗 import 無誤
/Users/ogt/.pyenv/versions/3.11.7/bin/python3 -c "from apps.api.src.jobs.aider_event_processor_job import run_aider_event_processor_loop; print('OK')"
Expected: OK
- Step 3: Commit
git add apps/api/src/jobs/aider_event_processor_job.py
git commit -m "feat(job): aider_event_processor_job — consume Redis + daily pattern"
Task A8: AI Router feedback hook
Files:
-
Modify:
apps/api/src/services/ai_router.py(既有檔;加 1 method + 改route()) -
Step 1: 讀既有 ai_router.py 了解 route() 簽章
grep -n "def route\|class AIRouter\|AIProvider" apps/api/src/services/ai_router.py | head -20
- Step 2: 在 AIRouter class 新增 method
在 class AIRouter 定義底部(或相近處)加:
async def feedback_from_aider_events(self, repo: str | None = None,
days: int = 7) -> dict[str, float]:
"""從 aider_events 聚合近 N 日 success rate,key 為 model name。
若 repo 給定只算該 repo;否則全站。"""
from apps.api.src.db.session import get_session_factory
from apps.api.src.repositories.aider_event_repository import AiderEventRepository
sf = get_session_factory()
async with sf() as sess:
repo_obj = AiderEventRepository(sess)
stats = await repo_obj.model_stats_since(days=days)
out: dict[str, float] = {}
for row in stats:
if repo and row["repo"] != repo:
continue
out[row["model"]] = float(row["success_rate"] or 0)
return out
- Step 3: 在 AIRouter.route() 內加入 feedback 影響(🆕 盲點 3:Flag 保護)
找到 def route(...) 或 async def route(...) 決策主體,在 provider 優先序計算後、return 前加:
# Phase 24 feedback hook: aider 實戰成功率 × 原權重
# 🆕 盲點 3:USE_AIDER_FEEDBACK flag 保護首次 rollout
from apps.api.src.core.config import settings
if getattr(settings, "USE_AIDER_FEEDBACK", False):
try:
feedback = await self.feedback_from_aider_events(days=7)
# feedback = {"elephant-alpha": 0.85, "gemini-pro": 0.92}
if feedback:
for p in providers: # 假設 providers 是 list[ProviderCandidate]
key = p.name.lower()
rate = next((v for k, v in feedback.items()
if key in k.lower()), None)
if rate is not None:
p.weight *= max(rate, 0.1) # 最低 10% 保底
logger.info("ai_router: aider feedback applied (%d models)", len(feedback))
except Exception:
logger.debug("ai_router: feedback hook skipped (non-critical)")
else:
logger.debug("ai_router: feedback disabled via USE_AIDER_FEEDBACK=false")
注意:
- 實際
providers與p.weight命名需依 route() 當下代碼調整 - 預設 Flag 關:上線後跑 7 天真實 aider 資料,比對
ai_router決策是否合理(SignOz trace 差異 < 5%)再打開 Flag - 若 Flag 關 → 該 hook 完全不影響既有 route 決策 — zero-risk rollout
- Step 4: 寫最小測試(若 route 難測,測 feedback method 即可)
# apps/api/tests/test_ai_router_feedback.py | 2026-04-20 @ Asia/Taipei
import pytest
from unittest.mock import AsyncMock, patch
from apps.api.src.services.ai_router import AIRouter
@pytest.mark.asyncio
async def test_feedback_reads_model_stats(monkeypatch):
mock_stats = [
{"repo": "awoooi", "model": "elephant-alpha", "total": 10,
"errors": 2, "success_rate": 0.8},
{"repo": "awoooi", "model": "gemini-pro", "total": 5,
"errors": 0, "success_rate": 1.0},
]
class FakeRepo:
async def model_stats_since(self, days): return mock_stats
class FakeSess:
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
def fake_sf(): return FakeSess()
# patch
monkeypatch.setattr("apps.api.src.jobs.aider_event_processor_job.get_session_factory",
lambda: (lambda: FakeSess()), raising=False)
# 若你的架構不同,這個測試可暫時 skip
# ai_router = AIRouter()
# feedback = await ai_router.feedback_from_aider_events()
# assert feedback["elephant-alpha"] == 0.8
pytest.skip("depends on repo factory mock — enable when di pattern clarified")
- Step 5: Commit
git add apps/api/src/services/ai_router.py apps/api/tests/test_ai_router_feedback.py
git commit -m "feat(ai_router): feedback_from_aider_events hook (Phase 24 ADR-052)"
Task A9: Config 3 settings + main.py lifespan register
Files:
-
Modify:
apps/api/src/core/config.py -
Modify:
apps/api/src/main.py -
Step 1: config.py 加 4 個 settings(🆕 盲點 3 新增 USE_AIDER_FEEDBACK)
找到 class Settings(BaseSettings) 或類似,加:
# aider-watch integration
AIDER_WEBHOOK_SECRET: str = ""
AIDER_EVENTS_STREAM_KEY: str = "signals:aider:events"
AIDER_PATTERN_EXTRACT_INTERVAL_HOURS: float = 24.0
USE_AIDER_FEEDBACK: bool = False # 🆕 預設關;灰度 7 天後手動開
- Step 2: main.py lifespan 註冊 aider job
找 asyncio.create_task 區段(line 322+),加:
from apps.api.src.jobs.aider_event_processor_job import run_aider_event_processor_loop
logger.info("aider-watch: starting event processor + daily pattern extract")
asyncio.create_task(run_aider_event_processor_loop())
- Step 3: 本機冒煙
cd /Users/ogt/awoooi/apps/api
AIDER_WEBHOOK_SECRET="smoke-test-$(openssl rand -hex 16)" \
/Users/ogt/.pyenv/versions/3.11.7/bin/python3 -c "from src.main import app; print('app OK:', app.title)"
Expected: app OK: AWOOOI API or similar
- Step 4: Commit
git add apps/api/src/core/config.py apps/api/src/main.py
git commit -m "feat(config): aider-watch 3 settings + lifespan register"
Phase B: Mac Client(5 tasks)
Task B1: Client scaffolding + parsers + config
Files:
-
Create:
scripts/aider_watch_client/pyproject.toml -
Create:
scripts/aider_watch_client/aider_watch_client/__init__.py -
Create:
scripts/aider_watch_client/aider_watch_client/config.py -
Create:
scripts/aider_watch_client/aider_watch_client/parsers.py -
Create:
scripts/aider_watch_client/aider_watch_client/redactor.py(與 server 同內容) -
Step 1: 寫 pyproject.toml
[project]
name = "aider-watch-client"
version = "0.2.0"
description = "Mac client for aider-watch (posts events to awoooi API)"
requires-python = ">=3.11"
dependencies = ["requests>=2.31", "python-ulid>=3.0"]
[project.scripts]
aiderw = "aider_watch_client.aiderw:main"
aider-watch = "aider_watch_client.cli:main"
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[tool.setuptools.packages.find]
include = ["aider_watch_client*"]
- Step 2: 寫 init.py
# aider-watch-client | 2026-04-20 @ Asia/Taipei
__version__ = "0.2.0"
- Step 3: 寫 config.py(client 端精簡版)
# aider-watch-client config | 2026-04-20 @ Asia/Taipei
from __future__ import annotations
import os
from pathlib import Path
from datetime import timezone, timedelta
HOME = Path.home()
WATCH_ROOT = HOME / "aider-watch"
BUFFER_DIR = WATCH_ROOT / "buffer"
LIVE_LOG = WATCH_ROOT / "live.log"
LOGS_DIR = WATCH_ROOT / "logs"
ENV_FILE = HOME / ".aider-watch.env"
TAIPEI = timezone(timedelta(hours=8))
_loaded = False
def load_env() -> None:
global _loaded
if _loaded or not ENV_FILE.exists():
_loaded = True
return
for line in ENV_FILE.read_text().splitlines():
s = line.strip()
if not s or s.startswith("#") or "=" not in s:
continue
k, _, v = s.partition("=")
os.environ.setdefault(k.strip(), v.strip())
_loaded = True
def get(name: str, default: str = "", required: bool = False) -> str:
load_env()
v = os.environ.get(name, default)
if required and not v:
raise RuntimeError(f"{name} not set in {ENV_FILE}")
return v
def ensure_dirs() -> None:
for d in (WATCH_ROOT, BUFFER_DIR, LOGS_DIR):
d.mkdir(parents=True, exist_ok=True)
- Step 4: 寫 parsers.py(從 v1 搬)
與 v1 ~/aider-watch/aider_watch/parsers.py 相同 — 若 v1 未完成,照 v1 plan Task 8 Step 4 的 parsers.py 內容寫(banner / chat_history / git numstat / git diff head)。
- Step 5: 寫 redactor.py(與 server 一模一樣)
複製 apps/api/src/utils/secret_redactor.py 的 _PATTERNS 與 redact() 函式,改檔頭註解為「aider-watch-client secret redactor」。
- Step 6: Commit
git add scripts/aider_watch_client/
git commit -m "feat(client): scaffolding + config + parsers + redactor"
Task B2: API Client (HTTP POST + HMAC + retry + buffer fallback)
Files:
-
Create:
scripts/aider_watch_client/aider_watch_client/api_client.py -
Create:
scripts/aider_watch_client/tests/test_api_client.py -
Step 1: 寫測試
# scripts/aider_watch_client/tests/test_api_client.py | 2026-04-20 @ Asia/Taipei
import hmac, hashlib
from aider_watch_client.api_client import sign_body
def test_sign_body_format():
body = b'{"events":[]}'
sig = sign_body(body, "secret")
assert sig.startswith("sha256=")
expected = "sha256=" + hmac.new(b"secret", body, hashlib.sha256).hexdigest()
assert sig == expected
def test_sign_body_deterministic():
assert sign_body(b"same", "secret") == sign_body(b"same", "secret")
- Step 2: 寫實作
# aider-watch-client api_client | 2026-04-20 @ Asia/Taipei
from __future__ import annotations
import hmac, hashlib, json, time
from typing import Any
import requests
from aider_watch_client.config import get
def sign_body(body: bytes, secret: str) -> str:
return "sha256=" + hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
def post_events(events: list[dict[str, Any]]) -> bool:
"""POST batch to awoooi. True=2xx; False=client should buffer。絕不 raise。"""
url = get("AIDER_API_URL", required=True) # e.g. http://192.168.0.120:32334/api/v1/aider/events
secret = get("AIDER_WEBHOOK_SECRET", required=True)
body = json.dumps({"events": events}, ensure_ascii=False).encode()
sig = sign_body(body, secret)
backoff = 1
for _ in range(3):
try:
r = requests.post(url, data=body,
headers={"Content-Type": "application/json",
"X-Aider-Signature": sig,
"X-Aider-Client": "aiderw/0.2.0"},
timeout=5)
if 200 <= r.status_code < 300:
return True
if r.status_code == 429:
time.sleep(backoff); backoff *= 4; continue
if r.status_code >= 500:
time.sleep(backoff); backoff *= 4; continue
# 400/401/403 — 不重試,client 端問題
return False
except Exception:
time.sleep(backoff); backoff *= 4
return False
- Step 3: 跑測試
cd scripts/aider_watch_client && pip install -e . --quiet && pytest tests/test_api_client.py -v
Expected: 2 passed
- Step 4: Commit
git add scripts/aider_watch_client/aider_watch_client/api_client.py scripts/aider_watch_client/tests/test_api_client.py
git commit -m "feat(client): api_client with HMAC sign + retry backoff"
Task B3: JSONL Buffer + flush
Files:
-
Create:
scripts/aider_watch_client/aider_watch_client/buffer.py -
Create:
scripts/aider_watch_client/tests/test_buffer.py -
Step 1: 寫測試
# scripts/aider_watch_client/tests/test_buffer.py | 2026-04-20 @ Asia/Taipei
import json
from pathlib import Path
from aider_watch_client import buffer
def test_write_reads_back(tmp_path, monkeypatch):
monkeypatch.setattr(buffer, "BUFFER_DIR", tmp_path)
ev = {"type":"session_start","session_id":"s1","ts":"2026-04-20T10:00:00+08:00",
"host":"ogt-mac","payload":{}}
buffer.write([ev])
files = list(tmp_path.glob("pending_*.jsonl"))
assert len(files) == 1
lines = [json.loads(l) for l in files[0].read_text().splitlines() if l.strip()]
assert lines[0]["session_id"] == "s1"
def test_flush_all_drained(tmp_path, monkeypatch):
monkeypatch.setattr(buffer, "BUFFER_DIR", tmp_path)
ev = {"type":"session_start","session_id":"s1","ts":"2026-04-20T10:00:00+08:00",
"host":"ogt-mac","payload":{}}
buffer.write([ev])
sent = []
def fake_post(evs): sent.extend(evs); return True
n = buffer.flush(post_fn=fake_post)
assert n == 1
assert len(sent) == 1
assert list(tmp_path.glob("pending_*.jsonl")) == []
def test_flush_keeps_on_fail(tmp_path, monkeypatch):
monkeypatch.setattr(buffer, "BUFFER_DIR", tmp_path)
ev = {"type":"session_start","session_id":"s1","ts":"2026-04-20T10:00:00+08:00",
"host":"ogt-mac","payload":{}}
buffer.write([ev])
n = buffer.flush(post_fn=lambda evs: False)
assert n == 0
assert len(list(tmp_path.glob("pending_*.jsonl"))) == 1
- Step 2: 寫實作
# aider-watch-client buffer | 2026-04-20 @ Asia/Taipei
from __future__ import annotations
import json
from pathlib import Path
from typing import Callable
from aider_watch_client.config import BUFFER_DIR, ensure_dirs
from ulid import ULID
def write(events: list[dict]) -> Path:
"""批次寫一個 pending file。"""
ensure_dirs()
fp = BUFFER_DIR / f"pending_{ULID()}.jsonl"
with fp.open("w") as f:
for ev in events:
f.write(json.dumps(ev, ensure_ascii=False, default=str) + "\n")
return fp
def flush(post_fn: Callable[[list[dict]], bool]) -> int:
"""把所有 pending_*.jsonl 批次送出。成功一批刪一個檔;失敗保留。回傳總成功筆數。"""
ensure_dirs()
total = 0
for fp in list(BUFFER_DIR.glob("pending_*.jsonl")):
lines = [l for l in fp.read_text().splitlines() if l.strip()]
if not lines:
fp.unlink(); continue
batch = [json.loads(l) for l in lines]
# 一次最多 50 筆(配合 API limit)
ok_all = True
for i in range(0, len(batch), 50):
chunk = batch[i:i+50]
if not post_fn(chunk):
ok_all = False
break
total += len(chunk)
if ok_all:
fp.unlink()
return total
- Step 3: 跑測試
cd scripts/aider_watch_client && pytest tests/test_buffer.py -v
Expected: 3 passed
- Step 4: Commit
git add scripts/aider_watch_client/aider_watch_client/buffer.py scripts/aider_watch_client/tests/test_buffer.py
git commit -m "feat(client): JSONL buffer write + flush"
Task B4: aiderw.py 主 wrapper + CLI
Files:
-
Create:
scripts/aider_watch_client/aider_watch_client/aiderw.py -
Create:
scripts/aider_watch_client/aider_watch_client/cli.py -
Create:
scripts/aider_watch_client/bin/aiderw -
Step 1: 寫 aiderw.py
# aider-watch-client aiderw | 2026-04-20 @ Asia/Taipei
"""aider subprocess wrapper — capture events, POST to awoooi (fallback buffer)."""
from __future__ import annotations
import atexit, json, os, subprocess, sys, threading, time
from datetime import datetime
from pathlib import Path
from typing import Any
from ulid import ULID
from aider_watch_client import config, buffer
from aider_watch_client.api_client import post_events
from aider_watch_client.parsers import (
parse_stdout_banner, parse_chat_history, git_numstat, git_diff_head,
)
from aider_watch_client.redactor import redact
IDLE_THRESHOLD_SEC = 120
def _now_iso() -> str:
return datetime.now(config.TAIPEI).isoformat()
def _emit_live(ev: dict) -> None:
try:
with config.LIVE_LOG.open("a") as f:
f.write(f"[{ev['ts']}] {ev['type']} "
f"{json.dumps(redact(ev['payload']), ensure_ascii=False, default=str)[:200]}\n")
except Exception:
pass
def _dispatch(events: list[dict]) -> None:
"""把一批 event:(1) 寫 live.log (2) POST;失敗進 buffer。"""
redacted = [{**e, "payload": redact(e.get("payload", {}))} for e in events]
for e in redacted:
_emit_live(e)
if not post_events(redacted):
buffer.write(redacted)
def _pre_git_sha(cwd: Path) -> str | None:
try:
r = subprocess.run(["git","-C",str(cwd),"rev-parse","HEAD"],
capture_output=True, text=True, timeout=3)
return r.stdout.strip() if r.returncode == 0 else None
except Exception:
return None
def main(argv: list[str] | None = None) -> int:
argv = list(argv if argv is not None else sys.argv[1:])
config.ensure_dirs()
cwd = Path.cwd()
sid = str(ULID())
start_ts = _now_iso()
stdout_buf: list[str] = []
last_output = [time.time()]
error_count = [0]
pre_sha = _pre_git_sha(cwd)
host = os.environ.get("AIDER_WATCH_HOSTNAME") or os.uname().nodename
aider_bin = os.environ.get("AIDER_BIN") or str(Path.home() / ".local/bin/aider")
# dispatch session_start ASAP
start_ev = {
"ts": start_ts, "session_id": sid, "host": host,
"type": "session_start",
"payload": {"cwd": str(cwd), "model": "unknown",
"aider_args": argv, "aider_pid": 0, "cli_version": "unknown"},
}
# PID fill 在 subprocess 啟動後
proc = subprocess.Popen([aider_bin] + argv, cwd=str(cwd),
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1)
start_ev["payload"]["aider_pid"] = proc.pid
_dispatch([start_ev])
# stdout reader
def _reader():
for line in proc.stdout: # type: ignore[arg-type]
sys.stdout.write(line); sys.stdout.flush()
stdout_buf.append(line)
last_output[0] = time.time()
lo = line.lower()
if "error" in lo or "exception" in lo:
error_count[0] += 1
t = threading.Thread(target=_reader, daemon=True); t.start()
# silent watcher
silent_fired = [False]
def _silent_watch():
while proc.poll() is None:
time.sleep(10)
idle = time.time() - last_output[0]
if idle >= IDLE_THRESHOLD_SEC and not silent_fired[0]:
silent_fired[0] = True
tail = "".join(stdout_buf[-1:])[-80:] if stdout_buf else ""
_dispatch([{"ts": _now_iso(), "session_id": sid, "host": host,
"type": "silent_timeout",
"payload": {"idle_sec": int(idle), "last_output_tail": tail}}])
w = threading.Thread(target=_silent_watch, daemon=True); w.start()
# atexit: 即使 wrapper 崩潰也補一筆 session_end
emergency_fired = [False]
def _emergency():
if emergency_fired[0]:
return
emergency_fired[0] = True
_dispatch([{"ts": _now_iso(), "session_id": sid, "host": host,
"type": "session_end",
"payload": {"duration_sec": 0, "tokens_sent": 0, "tokens_received": 0,
"cost_usd": 0, "files_changed": 0,
"error_count": error_count[0], "exit_code": -999,
"wrapper_crash": True}}])
atexit.register(_emergency)
exit_code = proc.wait()
emergency_fired[0] = True # 關掉 atexit 保險
duration = int(time.time() - time.mktime(datetime.fromisoformat(start_ts).timetuple()))
stdout_text = "".join(stdout_buf)
# canonical file edits from git diff
events_tail: list[dict] = []
if pre_sha:
for e in git_numstat(cwd, pre_sha, "HEAD"):
head = git_diff_head(cwd, e["path"], pre_sha, 3)
events_tail.append({
"ts": _now_iso(), "session_id": sid, "host": host,
"type": "file_edit",
"payload": {"path": e["path"], "lines_added": e["lines_added"],
"lines_deleted": e["lines_deleted"],
"diff_head": head, "is_new_file": e.get("is_new_file", False)},
})
# tokens from chat_history
tokens_sent = tokens_recv = 0
chat_md = cwd / ".aider.chat.history.md"
if chat_md.exists():
stats = parse_chat_history(chat_md.read_text(errors="ignore"))
tokens_sent = stats["tokens_sent"]; tokens_recv = stats["tokens_received"]
if exit_code != 0:
events_tail.append({
"ts": _now_iso(), "session_id": sid, "host": host,
"type": "error",
"payload": {"kind": "non_zero_exit",
"message": f"aider exited with {exit_code}",
"context_50chars": stdout_text[-50:]},
})
events_tail.append({
"ts": _now_iso(), "session_id": sid, "host": host,
"type": "session_end",
"payload": {"duration_sec": duration, "tokens_sent": tokens_sent,
"tokens_received": tokens_recv, "cost_usd": 0,
"files_changed": len([e for e in events_tail if e["type"] == "file_edit"]),
"error_count": error_count[0], "exit_code": exit_code},
})
if events_tail:
_dispatch(events_tail)
return exit_code
if __name__ == "__main__":
sys.exit(main())
- Step 2: 寫 cli.py
# aider-watch-client cli | 2026-04-20 @ Asia/Taipei
"""aider-watch subcommands: doctor, flush."""
from __future__ import annotations
import argparse, sys, subprocess
from aider_watch_client import config, buffer
from aider_watch_client.api_client import post_events
def cmd_doctor(_a) -> int:
ok = True
for k in ("AIDER_API_URL", "AIDER_WEBHOOK_SECRET"):
v = config.get(k)
mark = "✅" if v else "❌"
print(f" env {k:30s} {mark}")
if not v: ok = False
import requests
try:
url = config.get("AIDER_API_URL", required=True).replace("/events", "/healthz")
r = requests.get(url, timeout=3)
print(f" awoooi API reachable ✅ {r.status_code}")
except Exception as e:
print(f" awoooi API reachable ❌ {e}")
ok = False
res = subprocess.run(["launchctl","list"], capture_output=True, text=True)
mark = "✅" if "com.awoooi.aider-flush" in res.stdout else "❌"
print(f" launchd aider-flush {mark}")
return 0 if ok else 1
def cmd_flush(_a) -> int:
n = buffer.flush(post_fn=post_events)
print(f"flushed {n} events")
return 0
def main() -> int:
p = argparse.ArgumentParser(prog="aider-watch")
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("doctor").set_defaults(func=cmd_doctor)
sub.add_parser("flush").set_defaults(func=cmd_flush)
a = p.parse_args()
return a.func(a)
if __name__ == "__main__":
sys.exit(main())
- Step 3: 寫 bin/aiderw
#!/usr/bin/env python3
# aider-watch-client shim | 2026-04-20 @ Asia/Taipei
import sys
from aider_watch_client.aiderw import main
sys.exit(main())
chmod +x scripts/aider_watch_client/bin/aiderw
- Step 4: 本機煙測(需啟動 awoooi dev server 或 mock API)
cd scripts/aider_watch_client && pip install -e . --quiet
AIDER_API_URL="http://httpbin.org/post" \
AIDER_WEBHOOK_SECRET="test" \
python -c "from aider_watch_client.api_client import post_events; \
print(post_events([{'ts':'2026-04-20T10:00:00+08:00','session_id':'s','host':'m','type':'raw','payload':{}}]))"
Expected: True (httpbin returns 200 for POST, 雖簽章無效但不做驗證 server 能回 200;真 awoooi 會 401 — 這步只驗連線通)
- Step 5: Commit
git add scripts/aider_watch_client/aider_watch_client/aiderw.py \
scripts/aider_watch_client/aider_watch_client/cli.py \
scripts/aider_watch_client/bin/aiderw
git commit -m "feat(client): aiderw main wrapper + CLI (doctor/flush)"
Task B5: install.sh + launchd flush plist
Files:
-
Create:
scripts/aider_watch_client/scripts/install.sh -
Create:
scripts/aider_watch_client/launchd/com.awoooi.aider-flush.plist -
Step 1: 寫 launchd plist
<?xml version="1.0" encoding="UTF-8"?>
<!-- aider-watch-client flush | 2026-04-20 @ Asia/Taipei -->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>com.awoooi.aider-flush</string>
<key>ProgramArguments</key>
<array>
<string>/Users/ogt/.local/bin/aider-watch</string>
<string>flush</string>
</array>
<key>StartInterval</key><integer>300</integer>
<key>EnvironmentVariables</key>
<dict><key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string></dict>
<key>StandardOutPath</key><string>/Users/ogt/aider-watch/logs/flush.log</string>
<key>StandardErrorPath</key><string>/Users/ogt/aider-watch/logs/flush.log</string>
<key>RunAtLoad</key><false/>
</dict></plist>
🆕 盲點 1(Mac 外網可達性)— 三選一配置:
安裝前,統帥需先決定 AIDER_API_URL 的來源:
| 方式 | URL 範例 | 前置工作 |
|---|---|---|
| a. awoooi nginx 公網 TLS | https://awoooi.example/api/v1/aider/events |
awoooi nginx 加一條 location block,proxy_pass 到 K3s svc 32334;enable cert |
| b. Tailscale(若已裝) | http://<tailscale-100-ip>:32334/api/v1/aider/events |
tailscale up;確認 awoooi 188 host 也在 Tailnet |
| c. VPN(WireGuard) | http://192.168.0.120:32334/api/v1/aider/events |
WireGuard peer config |
裝 install.sh 前,寫 ~/.aider-watch.env 填入選定的 URL。
- Step 2: 寫 install.sh
#!/usr/bin/env bash
# aider-watch-client installer | 2026-04-20 @ Asia/Taipei
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
ROOT="$(cd "$HERE/.." && pwd)"
# 1. 裝 client(pipx 或直接 pip 到使用者目錄)
if command -v pipx >/dev/null 2>&1; then
pipx install --python /Users/ogt/.pyenv/versions/3.11.7/bin/python3 -e "$ROOT"
else
/Users/ogt/.pyenv/versions/3.11.7/bin/python3 -m pip install --user -e "$ROOT"
fi
# 2. symlink aiderw 到 PATH
ln -sf "$HOME/.local/bin/aiderw" /opt/homebrew/bin/aiderw
echo "✓ aiderw linked to /opt/homebrew/bin/aiderw"
# 3. 檢查 ~/.aider-watch.env
if [[ ! -f "$HOME/.aider-watch.env" ]]; then
cat <<EOF
⚠️ ~/.aider-watch.env 不存在。請建立:
cat > ~/.aider-watch.env <<'EV'
AIDER_API_URL=http://192.168.0.120:32334/api/v1/aider/events
AIDER_WEBHOOK_SECRET=<statesman-provides>
AIDER_WATCH_HOSTNAME=ogt-mac
EV
chmod 600 ~/.aider-watch.env
EOF
exit 1
fi
chmod 600 "$HOME/.aider-watch.env"
# 4. 工作目錄
mkdir -p "$HOME/aider-watch/"{buffer,logs}
touch "$HOME/aider-watch/live.log"
# 5. launchd
DEST="$HOME/Library/LaunchAgents"
mkdir -p "$DEST"
cp "$ROOT/launchd/com.awoooi.aider-flush.plist" "$DEST/"
launchctl unload "$DEST/com.awoooi.aider-flush.plist" 2>/dev/null || true
launchctl load -w "$DEST/com.awoooi.aider-flush.plist"
launchctl list | grep aider-flush || { echo "launchd load failed"; exit 2; }
# 6. doctor
aider-watch doctor || { echo "doctor failed — fix env and re-run"; exit 3; }
echo "✓ install OK"
- Step 3: Commit
chmod +x scripts/aider_watch_client/scripts/install.sh
git add scripts/aider_watch_client/scripts/install.sh scripts/aider_watch_client/launchd/
git commit -m "feat(client): install.sh + launchd flush plist (5min)"
Phase C: 整合驗收(3 tasks)
Task C1: E2E Happy Path(Mac → awoooi)
需要前置:
-
awoooi API 跑起來(K3s 或本機 dev)
-
~/.aider-watch.env有 AIDER_API_URL + AIDER_WEBHOOK_SECRET -
K8s Secret 或 env 有 AIDER_WEBHOOK_SECRET 匹配
-
Step 1: 跑 install.sh
cd /Users/ogt/awoooi && bash scripts/aider_watch_client/scripts/install.sh
Expected: install OK + doctor 全 ✅
- Step 2: 跑一次 aiderw
mkdir -p /tmp/aw-e2e && cd /tmp/aw-e2e
git init -q && echo "def f(): return 0" > calc.py && git add -A && git commit -m init -q
aiderw --yes --no-stream --no-pretty \
--message "fix calc.py f should return 42" --exit
Expected: aider 正常跑,terminal 有輸出。
- Step 3: 查 awoooi PG 有事件
PGPASSWORD="$AWOOOI_PG_PW" psql -h 192.168.0.188 -U awoooi_rw -d awoooi -c \
"SELECT type, ts, payload->>'model' AS model FROM aider_events ORDER BY ts DESC LIMIT 10"
Expected: 至少 session_start + session_end 2 筆;若 aider 改了檔有 file_edit 若改 exit=0 無 error。
- Step 4: 若 aider 失敗(e.g. 模型 timeout),看 TG
(若 Step 2 產生 error event)檢查統帥 @tsenyangbot DM 有無 incident 告警。
Expected: TG 收到 [aider] ... incident 告警;incidents 表有新記錄且 alert_category='aider_activity'。
Task C2: E2E Degradation(斷線 → buffer → flush)
- Step 1: 拔網路模擬 API 不可達
# 方法 A:改 env 指向壞 URL
export AIDER_API_URL="http://127.0.0.1:1/nope"
cd /tmp/aw-e2e && aiderw --yes --no-stream --no-pretty --message "add comment" --exit
unset AIDER_API_URL
- Step 2: 檢查 buffer 有檔
ls ~/aider-watch/buffer/
Expected: 至少 1 個 pending_*.jsonl
- Step 3: 恢復正常 env,跑 flush
aider-watch flush
Expected: flushed N events (N ≥ 1)
- Step 4: buffer 應清空,PG 應有記錄
ls ~/aider-watch/buffer/ # 應為空(或無 pending_*)
PGPASSWORD="$AWOOOI_PG_PW" psql -h 192.168.0.188 -U awoooi_rw -d awoooi -c \
"SELECT count(*) FROM aider_events WHERE ts > now() - interval '10 min'"
Expected: buffer 清空;PG count 增加。
Task C3: AI Router Feedback 驗證
- Step 1: 灌 fixture:3 場模擬 session(2 成功 1 失敗 elephant-alpha,1 成功 gemini-pro)
PGPASSWORD="$AWOOOI_PG_PW" psql -h 192.168.0.188 -U awoooi_rw -d awoooi <<'SQL'
INSERT INTO aider_events (session_id, ts, type, host, payload) VALUES
('t-e1', now()-interval '2h', 'session_start', 'm', '{"cwd":"/repo/x","model":"elephant-alpha","aider_args":[],"aider_pid":1,"cli_version":"0"}'),
('t-e1', now()-interval '2h', 'session_end', 'm', '{"duration_sec":60,"tokens_sent":100,"tokens_received":50,"cost_usd":0,"files_changed":1,"error_count":0,"exit_code":0}'),
('t-e2', now()-interval '1h', 'session_start', 'm', '{"cwd":"/repo/x","model":"elephant-alpha","aider_args":[],"aider_pid":2,"cli_version":"0"}'),
('t-e2', now()-interval '1h', 'session_end', 'm', '{"duration_sec":120,"tokens_sent":200,"tokens_received":80,"cost_usd":0,"files_changed":0,"error_count":3,"exit_code":1}'),
('t-e3', now()-interval '30min', 'session_start', 'm', '{"cwd":"/repo/x","model":"elephant-alpha","aider_args":[],"aider_pid":3,"cli_version":"0"}'),
('t-e3', now()-interval '30min', 'session_end', 'm', '{"duration_sec":90,"tokens_sent":150,"tokens_received":60,"cost_usd":0,"files_changed":2,"error_count":0,"exit_code":0}'),
('t-g1', now()-interval '20min', 'session_start', 'm', '{"cwd":"/repo/x","model":"gemini-pro","aider_args":[],"aider_pid":4,"cli_version":"0"}'),
('t-g1', now()-interval '20min', 'session_end', 'm', '{"duration_sec":50,"tokens_sent":80,"tokens_received":40,"cost_usd":0,"files_changed":1,"error_count":0,"exit_code":0}');
SQL
- Step 2: 呼叫 ai_router.feedback_from_aider_events()
cd /Users/ogt/awoooi && /Users/ogt/.pyenv/versions/3.11.7/bin/python3 <<'PY'
import asyncio
from apps.api.src.services.ai_router import AIRouter
async def main():
r = AIRouter()
out = await r.feedback_from_aider_events(days=7)
print(out)
asyncio.run(main())
PY
Expected: {'elephant-alpha': 0.67, 'gemini-pro': 1.0} 或相近(success_rate = 2/3 for elephant, 1/1 for gemini)
- Step 3: 清理 fixture
PGPASSWORD="$AWOOOI_PG_PW" psql -h 192.168.0.188 -U awoooi_rw -d awoooi -c \
"DELETE FROM aider_events WHERE session_id LIKE 't-%'"
- Step 4: 最終 commit
cd /Users/ogt/awoooi
git add -A
git commit --allow-empty -m "chore(aider-watch): v2 E2E verified 2026-04-20"
Self-Review
Spec coverage:
| spec 章節 | Plan Task |
|---|---|
| §1 架構總覽 | A5+A6+A7+B4 一起湊成完整路徑 |
| §2.1 server 元件 10 項 | A1(S1) + A2(S8) + A3(S2) + A4(S3) + A5(S4) + A6(S5) + A7(S6) + A8(S7) + A9(S9+S10) |
| §2.2 client 元件 6 項 | B1(M1-M2) + B2(M3) + B3(M4) + B4(M5 aiderw) + B5(M6 plist + install) |
| §2.3 v1 產物去向 | 隱含在 A2/A3/B1 的「從 v1 搬」註解 |
| §3 Event Schema | A3 |
| §4 DB schema | A1 |
| §5 API contract HMAC | A6 |
| §6 AI 學習回路 (incident/pattern/router) | A5 + A7 + A8 |
| §7 Telegram | 走既有 gateway,零新增;A5 的 incident_service 會自動觸發 |
| §8 降級策略 | A6 回 503 + B2 retry + B3 buffer + B4 atexit |
| §9 測試策略 | Unit: A2/A3/A5/B2/B3;Integration: A6;E2E: C1/C2;AI feedback: C3 |
| §10 Memory 依據 | (cross-cut) |
Placeholder 掃描:無 TBD / TODO / "similar to Task N"。每個 code step 都有完整 code。
Type 一致性:AiderEventIn 在 A3 定義,A4/A5/A6/A7 全部引用同名;post_events(events: list[dict]) 在 B2 定義,B3/B4 一致使用。
Execution Handoff
Plan complete and saved to docs/superpowers/plans/2026-04-20-aider-watch-v2.md。
兩種執行方式(你選一):
- Subagent-Driven(spec 原推薦)— 每 task 派 subagent + 兩階段 review。
- Inline Execution(v1 後段我已切成這個)— 主 session 直接跑,效率高但 context 消耗大。
考量本 plan 跨兩個 subsystem(server + client)+ 整合點多(既有 ai_router / incident_service / telegram_gateway 都要對接),建議分段:
- Phase A 1-5(模型/util/repo/service):inline — 單純 code,好測
- Phase A 6-9(webhook/job/router hook/config):subagent-driven — 整合點多,需 independent review
- Phase B 全部 client:inline — 獨立 module 無耦合
- Phase C E2E:inline + 統帥監督
你要照這策略,還是要求全 subagent / 全 inline?