Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
1. aiderw: session_end 補 model+cwd (AI Router feedback loop 修通) 2. repository: model_stats_since SQL 改 COALESCE(session_end, session_start) model 3. aider_event_service: classify_severity 移除 error_count 觸發告警(防假陽性) 4. worker: run_aider_event_processor_loop 包 proc.start() try/except(防靜默崩潰) 2026-04-20 @ Asia/Taipei
183 lines
6.6 KiB
Python
183 lines
6.6 KiB
Python
# aider-watch-client aiderw | 2026-04-20 @ Asia/Taipei
|
||
"""aider subprocess wrapper — capture events, dispatch 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 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:
|
||
red = redact(ev.get("payload", {}))
|
||
f.write(f"[{ev['ts']}] {ev['type']} "
|
||
f"{json.dumps(red, ensure_ascii=False, default=str)[:200]}\n")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
def _dispatch(events: list[dict]) -> None:
|
||
"""batch:redact → live.log → HTTP 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()
|
||
start_epoch = time.time()
|
||
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")
|
||
|
||
# subprocess start
|
||
try:
|
||
proc = subprocess.Popen([aider_bin] + argv, cwd=str(cwd),
|
||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||
text=True, bufsize=1)
|
||
except FileNotFoundError:
|
||
print(f"aider binary not found at {aider_bin}", file=sys.stderr)
|
||
return 127
|
||
|
||
# session_start ASAP
|
||
_dispatch([{
|
||
"ts": start_ts, "session_id": sid, "host": host,
|
||
"type": "session_start",
|
||
"payload": {"cwd": str(cwd), "model": "unknown",
|
||
"aider_args": argv, "aider_pid": proc.pid,
|
||
"cli_version": "unknown"},
|
||
}])
|
||
|
||
# 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 safety
|
||
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": int(time.time() - start_epoch),
|
||
"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 # 正常結束,關閉保險
|
||
|
||
duration = int(time.time() - start_epoch)
|
||
stdout_text = "".join(stdout_buf)
|
||
|
||
events_tail: list[dict] = []
|
||
|
||
# canonical file edits from git diff
|
||
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:]},
|
||
})
|
||
|
||
banner = parse_stdout_banner(stdout_text)
|
||
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,
|
||
"model": banner.get("model", "unknown"),
|
||
"cwd": str(cwd)},
|
||
})
|
||
|
||
if events_tail:
|
||
_dispatch(events_tail)
|
||
|
||
return exit_code
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|