# .done 자동 감지 메커니즘 설계 스펙

**버전**: 1.0
**작성일**: 2026-03-24
**태스크**: task-897.1
**작성자**: 헤르메스 (1팀장)
**승인**: 로키(보안), 펜리르(보안) 만장일치 합의

---

## 1. 개요

### 1.1 목적
팀 작업 완료 시 생성되는 `.done` 파일을 **토큰 소모 없이 빠른 주기(30초 이내)**로 자동 감지하여 아누에게 즉시 알려주는 메커니즘.

### 1.2 설계 원칙
1. **토큰 제로**: Claude API/세션을 소비하지 않는 감지 방식
2. **즉시 감지**: Primary path 즉시, Fallback path 최대 30초
3. **기존 호환**: .done/.done.acked 프로토콜, finish-task.sh, cokacdir 시스템과 호환
4. **레이스 컨디션 방지**: mv 기반 원자적 소유권 패턴
5. **최소 변경**: 기존 인프라 수정 위주, 신규 데몬 도입 금지

### 1.3 채택하지 않은 방안
| 방안 | 기각 사유 |
|------|-----------|
| inotifywait 데몬 | 미설치, 신규 데몬 = 2026-03-10 결정 위반 |
| Systemd Path Unit | events 디렉토리 노이즈 과다 (bot-activity.json 등 매초 갱신) |
| SQLite 마이그레이션 | 현재 8팀 규모에서 파일 기반 충분 (2026-03-14 기각) |

---

## 2. 아키텍처

### 2.1 알림 경로 (2-Layer)

```
Layer 1 (Primary — 즉시):
  팀장 완료
  → finish-task.sh
    → .done 생성 (O_EXCL, 원자적)
    → task-timer end
    → notify-completion.py
      → requests.post() Telegram Bot API 직접 호출 (토큰 0)
      → 성공: .done.notified 마커 생성 (O_EXCL)
      → 실패: 로그 기록, Fallback에 위임

Layer 2 (Fallback — 최대 30초):
  done-watcher.sh (systemd 30초 타이머)
  → *.done 스캔
  → .done.notified 없는 건만 처리
  → mv .done → .done.processing (원자적 소유권 획득)
  → requests.post() Telegram Bot API 직접 호출
  → 성공: mv .done.processing → .done.notified
  → 실패: mv .done.processing → .done (원상 복구, 다음 사이클 재시도)

보조 (bot-activity 전환):
  activity-watcher.py (systemd service, 3초 폴링)
  → bot-activity.json processing→idle 감지
  → .done 존재 확인
  → bot-activity idle 전환 (알림은 Layer 1/2에 위임)
```

### 2.2 상태 다이어그램

```
.done 파일 생명 주기:

  [생성] → .done (미처리)
           ↓ (Layer 1 notify-completion.py)
         .done.notified (알림 발송 완료)
           ↓ (아누가 보고서 확인 후)
         .done.acked (처리 완료)
           ↓ (24시간 후 done-watcher.sh)
         archive/ 디렉토리로 이동

  Fallback 경로:
  .done (미처리)
    ↓ (Layer 2 done-watcher.sh, .done.notified 없으면)
  .done.processing (원자적 소유권)
    ↓ 성공 → .done.notified
    ↓ 실패 → .done (원상 복구)

  에스컬레이션 경로 (기존 유지):
  .done (30분 이상 미처리)
    ↓ (done-watcher.sh stale 감지)
  .done.escalated + Telegram 경보
```

### 2.3 디듀프 메커니즘

| 경로 | 디듀프 방식 | 원자성 |
|------|-------------|--------|
| Layer 1 → Layer 2 | .done.notified 마커 존재 확인 | O_EXCL 원자적 생성 |
| Layer 2 동시 인스턴스 | flock 파일 락 | 커널 수준 보장 |
| Layer 2 내 TOCTOU | mv .done → .done.processing | 같은 파티션 내 atomic |
| 에스컬레이션 | .done.escalated 마커 | O_EXCL 원자적 생성 |

---

## 3. 컴포넌트별 상세 설계

### 3.1 notify-completion.py 수정

**변경 내용**: `send_telegram_notification()` 함수에서 `cokacdir --cron` → `requests.post()` 직접 API

**Before (현재):**
```python
def send_telegram_notification(chat_id, anu_key, message):
    cmd = ["cokacdir", "--cron", message, "--at", "1m",
           "--chat", chat_id, "--key", anu_key, "--once"]
    subprocess.run(cmd, ...)
```

**After (수정 후):**
```python
import requests

def send_telegram_notification(chat_id: str, message: str) -> bool:
    """직접 Telegram Bot API 호출 (토큰 0, Claude 세션 미생성)"""
    bot_token = os.environ.get("ANU_BOT_TOKEN")
    if not bot_token:
        log_protocol("WARN: ANU_BOT_TOKEN 미설정, 알림 스킵")
        return False

    url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
    payload = {"chat_id": chat_id, "text": message}

    for attempt in range(3):
        try:
            resp = requests.post(url, json=payload, timeout=10)
            if resp.status_code == 200:
                return True
            if resp.status_code == 429:
                retry_after = int(resp.headers.get("Retry-After", 5))
                time.sleep(retry_after)
                continue
            log_protocol(f"WARN: Telegram API {resp.status_code}")
        except requests.RequestException as e:
            log_protocol(f"WARN: Telegram 전송 실패 (attempt {attempt+1}): {e}")
            time.sleep(2 ** attempt)

    return False
```

**보안 조건 (로키 승인):**
- Bot Token은 환경변수에서만 로드 (하드코딩 금지)
- curl 미사용 (프로세스 목록 토큰 노출 방지)
- Python requests 사용 (메모리 내 처리)
- 타임아웃 10초 필수

### 3.2 done-watcher.sh 강화

**추가 기능**: 미알림 .done 감지 시 직접 Telegram 알림

```bash
#!/bin/bash
# done-watcher.sh - .done 감지 + 직접 알림 + stale 에스컬레이션
set -euo pipefail

EVENTS_DIR="/home/jay/workspace/memory/events"
LOG="/home/jay/workspace/logs/done-protocol.log"
LOCK_FILE="/tmp/done-watcher.lock"

# flock으로 동시 실행 방지 (펜리르 시나리오 7, 8 방어)
exec 9>"$LOCK_FILE"
flock -n 9 || { echo "already running"; exit 0; }

log() { echo "[$(date -Iseconds)] [done-watcher] $1" >> "$LOG"; }

# === NEW: 미알림 .done 감지 → 직접 Telegram 알림 ===
batch_tasks=()
for done_file in "$EVENTS_DIR"/*.done; do
    [ -f "$done_file" ] || continue
    [ -L "$done_file" ] && continue  # symlink 거부 (펜리르 시나리오 6)

    task_id=$(basename "$done_file" .done)
    notified_file="$EVENTS_DIR/${task_id}.done.notified"

    # 이미 알림 완료된 건은 스킵
    [ -f "$notified_file" ] && continue

    # 원자적 소유권 획득 (펜리르 TOCTOU 방어)
    proc_file="$EVENTS_DIR/${task_id}.done.processing"
    mv "$done_file" "$proc_file" 2>/dev/null || continue

    batch_tasks+=("$task_id:$proc_file")
done

if [ ${#batch_tasks[@]} -gt 0 ]; then
    # 배치 알림 메시지 구성 (펜리르 시나리오 1, 7 방어)
    if [ ${#batch_tasks[@]} -eq 1 ]; then
        task_id="${batch_tasks[0]%%:*}"
        msg="✅ ${task_id} 완료. 보고서: /home/jay/workspace/memory/reports/${task_id}.md"
    else
        msg="✅ 일괄 완료 (${#batch_tasks[@]}건):"
        for entry in "${batch_tasks[@]}"; do
            task_id="${entry%%:*}"
            msg+=$'\n'"  - ${task_id}"
        done
    fi

    # 직접 Telegram API 호출
    if python3 -c "
import requests, os, sys
token = os.environ.get('ANU_BOT_TOKEN', '')
chat_id = os.environ.get('COKACDIR_CHAT_ID', '6937032012')
if not token:
    sys.exit(1)
r = requests.post(f'https://api.telegram.org/bot{token}/sendMessage',
                   json={'chat_id': chat_id, 'text': '''${msg}'''},
                   timeout=10)
sys.exit(0 if r.status_code == 200 else 1)
" 2>/dev/null; then
        # 성공: .done.processing → .done.notified (원본 .done도 복구)
        for entry in "${batch_tasks[@]}"; do
            task_id="${entry%%:*}"
            proc_file="${entry#*:}"
            original_done="$EVENTS_DIR/${task_id}.done"
            mv "$proc_file" "$original_done" 2>/dev/null || true
            # .done.notified 마커 생성 (O_EXCL)
            python3 -c "
import os
try:
    fd = os.open('$EVENTS_DIR/${task_id}.done.notified', os.O_CREAT|os.O_EXCL|os.O_WRONLY)
    os.close(fd)
except FileExistsError:
    pass
"
        done
        log "Fallback 알림 전송 성공: ${#batch_tasks[@]}건"
    else
        # 실패: .done.processing → .done 원상 복구 (다음 사이클 재시도)
        for entry in "${batch_tasks[@]}"; do
            task_id="${entry%%:*}"
            proc_file="${entry#*:}"
            mv "$proc_file" "$EVENTS_DIR/${task_id}.done" 2>/dev/null || true
        done
        log "Fallback 알림 전송 실패, 원상 복구"
    fi
fi

# === 기존: stale .done 에스컬레이션 (30분 이상) ===
for done_file in $(find "$EVENTS_DIR" -maxdepth 1 -name "*.done" ! -name "*.done.*" -type f 2>/dev/null); do
    task_id=$(basename "$done_file" .done)
    file_age=$(( $(date +%s) - $(stat -c %Y "$done_file") ))
    if [ "$file_age" -ge 1800 ]; then
        escalated_file="$EVENTS_DIR/${task_id}.done.escalated"
        [ -f "$escalated_file" ] && continue
        # O_EXCL 원자적 생성 (로키 수정)
        python3 -c "
import os, sys
try:
    fd = os.open('$escalated_file', os.O_CREAT|os.O_EXCL|os.O_WRONLY)
    os.close(fd)
except FileExistsError:
    sys.exit(1)
" 2>/dev/null || continue
        log "${task_id}: stale .done (${file_age}s) → 에스컬레이션"
        # Telegram 에스컬레이션 알림 (기존 방식)
        source /home/jay/workspace/.env.keys 2>/dev/null || true
        cokacdir --cron "⚠️ ${task_id} 완료 후 ${file_age}초 경과, 아직 미처리." \
            --at "1m" --chat "$COKACDIR_CHAT_ID" --key "$COKACDIR_KEY_ANU" --once 2>/dev/null || true
    fi
done

# === 기존: 오래된 .done.acked 정리 (24시간 이상) ===
for acked_file in $(find "$EVENTS_DIR" -maxdepth 1 -name "*.done.acked" -type f 2>/dev/null); do
    file_age=$(( $(date +%s) - $(stat -c %Y "$acked_file") ))
    if [ "$file_age" -ge 86400 ]; then
        task_id=$(basename "$acked_file" .done.acked)
        ARCHIVE_DIR="$EVENTS_DIR/archive"
        mkdir -p "$ARCHIVE_DIR"
        mv "$acked_file" "$ARCHIVE_DIR/" 2>/dev/null && log "${task_id}: .done.acked → archive"
        [ -f "$EVENTS_DIR/${task_id}.done.escalated" ] && mv "$EVENTS_DIR/${task_id}.done.escalated" "$ARCHIVE_DIR/" 2>/dev/null
        [ -f "$EVENTS_DIR/${task_id}.done.notified" ] && mv "$EVENTS_DIR/${task_id}.done.notified" "$ARCHIVE_DIR/" 2>/dev/null
    fi
done
```

### 3.3 activity-watcher.py 버그 수정

**수정 1: find_done_file 팀 기반 매칭**

```python
# Before (버그)
def find_done_file(bot_name: str) -> Path | None:
    for done_file in EVENTS_DIR.glob("*.done"):
        return done_file  # 팀 무관하게 첫 번째 반환

# After (수정)
def find_done_file(bot_name: str) -> Path | None:
    team = BOT_TEAM_MAP.get(bot_name)
    if not team:
        return None
    processed_exts = [".acked", ".clear", ".notified", ".escalated", ".processing"]
    # task-timers.json에서 해당 팀의 활성 task_id 조회
    active_task = get_active_task_for_team(team)
    if active_task:
        done_file = EVENTS_DIR / f"{active_task}.done"
        if done_file.exists() and not any(
            (EVENTS_DIR / (done_file.name + ext)).exists() for ext in processed_exts
        ):
            return done_file
    return None
```

**수정 2: BOT_TEAM_MAP 확장**

```python
BOT_TEAM_MAP = {
    "dev1": "dev1", "dev2": "dev2", "dev3": "dev3",
    "dev4": "dev4", "dev5": "dev5", "dev6": "dev6",
    "dev7": "dev7", "dev8": "dev8",
    "anu": None, "anu-direct": None,
}
```

**수정 3: systemd user service 등록**

```ini
# ~/.config/systemd/user/activity-watcher.service
[Unit]
Description=bot-activity 실시간 감시 (idle 전환)
After=network.target

[Service]
Type=simple
EnvironmentFile=/home/jay/workspace/.env.keys
WorkingDirectory=/home/jay/workspace
ExecStart=/usr/bin/python3 /home/jay/workspace/scripts/activity-watcher.py
Restart=always
RestartSec=5s
StandardOutput=append:/home/jay/workspace/logs/activity-watcher.log
StandardError=append:/home/jay/workspace/logs/activity-watcher.log

[Install]
WantedBy=default.target
```

---

## 4. 보안 요구사항 (로키 승인)

### 4.1 필수 조건
1. **Bot Token**: 환경변수에서만 로드. 하드코딩, curl 명령줄 전달 금지
2. **최소 권한**: done-watcher.service의 EnvironmentFile은 필요 키만 포함 (장기)
3. **symlink 거부**: .done 파일이 symlink이면 처리 거부
4. **원자적 상태 전환**: 모든 마커 파일 생성은 O_EXCL, 상태 전환은 mv
5. **로그 기록**: 모든 알림 성공/실패를 done-protocol.log에 기록

### 4.2 향후 개선 (P2)
1. `.env.done-watcher` 파일 분리 (최소 키만)
2. activity-watcher.py 환경변수 선택적 로드
3. Telegram Bot Token 로테이션 정책

---

## 5. 테스트 계획 (펜리르 시나리오 기반)

### 5.1 필수 테스트 시나리오

| # | 시나리오 | 검증 방법 | 기대 결과 |
|---|----------|-----------|-----------|
| T1 | 단일 팀 완료 | finish-task.sh 실행 → 알림 확인 | Telegram 메시지 1건 수신 |
| T2 | 3팀 동시 완료 | 3개 .done 동시 생성 → 알림 확인 | 중복 없이 3건 또는 배치 1건 수신 |
| T3 | Layer 1 실패 → Layer 2 fallback | notify-completion.py 실패 후 30초 대기 | done-watcher가 알림 발송 |
| T4 | 서버 재부팅 후 | systemctl --user restart → .done 생성 | 모든 서비스 자동 재시작, 알림 정상 |
| T5 | Telegram API 장애 | 네트워크 차단 상태에서 .done 생성 | 로컬 로그 기록 + 복구 후 재시도 |
| T6 | symlink .done | ln -s /etc/passwd test.done | 처리 거부, 로그 기록 |
| T7 | 16팀 동시 완료 | 16개 .done 생성 → 처리 시간 측정 | rate limit 없이 처리 완료 |
| T8 | TOCTOU 경합 | done-watcher + activity-watcher 동시 실행 | 중복 알림 0건 |

---

## 6. 구현 로드맵

### Phase 1 (즉시 - P0)
1. notify-completion.py: cokacdir --cron → requests.post 교체
2. activity-watcher.py: find_done_file 버그 수정 + BOT_TEAM_MAP 확장
3. activity-watcher.py: systemd user service 등록 + 시작

### Phase 2 (1주 내 - P1)
4. done-watcher.sh: 미알림 감지 + flock + O_EXCL + 배치 알림
5. 통합 테스트 (T1-T8)
6. 기존 에스컬레이션 경로 확인 + 정합성 검사

### Phase 3 (장기 - P2)
7. EnvironmentFile 범위 제한 (.env.done-watcher 분리)
8. activity-watcher.py 환경변수 선택적 로드
9. 팀 16개 확장 시 배치 알림 성능 테스트

---

## 7. 영향 범위

### 수정 파일
| 파일 | 변경 내용 | 하위 호환성 |
|------|-----------|-------------|
| scripts/notify-completion.py | send_telegram_notification 함수 교체 | 함수 시그니처 변경 (anu_key 제거, bot_token 환경변수 사용) |
| scripts/done-watcher.sh | 미알림 감지 로직 추가, O_EXCL 복구 | 기존 에스컬레이션 로직 유지, 추가만 |
| scripts/activity-watcher.py | find_done_file + BOT_TEAM_MAP | 기존 기능 보존, 정확도 향상 |
| systemd user services | activity-watcher.service 신규 | 기존 done-watcher.timer 영향 없음 |

### 영향 없는 파일 (변경 금지)
- finish-task.sh: 수정 없음 (기존 워크플로우 유지)
- DIRECT-WORKFLOW.md: 수정 없음 (팀장 워크플로우 변경 없음)
- dispatch.py: 수정 없음
- qc_verify.py: 수정 없음
