diff --git a/docs/superpowers/plans/2026-05-22-plan-b-infra.md b/docs/superpowers/plans/2026-05-22-plan-b-infra.md new file mode 100644 index 0000000..7123c6c --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-plan-b-infra.md @@ -0,0 +1,929 @@ +# Plan-B-Infra — NSSM 자동 시작 + task-watcher (시간대 큐 토글) 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:** Windows AI 머신의 서비스(ai_trade + WSL2 Docker)를 NSSM으로 부팅 시 자동 시작 + 우선순위 설정(SP-9), 그리고 시간대 기반으로 `queue:paused`를 토글하는 task-watcher 컨테이너 신설(SP-10). 트레이딩 시간대(비휴장 평일 07:00–16:30)에 무거운 render 작업을 일시정지하여 KIS 트레이딩 우선순위 보장. + +**Architecture:** task-watcher는 WSL2 Docker 컨테이너로 30초마다 `current_mode()` 판정(KST 시각 + NAS `/api/stock/holidays` 조회) → 트레이딩 시간대면 `SET queue:paused 1 EX 600`, 그 외엔 `DEL queue:paused`. 모든 render worker(insta/music/video)가 BLPOP 전 `queue:paused`를 확인하므로 단일 키로 전체 일시정지. NSSM(SP-9)은 박재오 Windows 머신에서 수동 설치 — plan은 정확한 명령 + 안내 문서 제공. + +**Tech Stack:** Python 3.12 / `redis>=5.0` / `httpx` (holidays fetch) / `zoneinfo` (KST) / Docker Engine in WSL2 / NSSM (Windows service manager) / FastAPI (NAS stock holidays endpoint) + +**Spec:** `web-backend/docs/superpowers/specs/2026-05-18-nas-windows-distributed-architecture-design.md` §3 시간대별 우선순위 모드, §10 SP-9·SP-10. **박재오 결정 (2026-05-22): idle/게임 감지 생략 — 시간대만으로 토글** (spec §3의 "박재오 활동 감지 시 SET" → "트레이딩 시간대면 무조건 SET"). idle 감지가 없으므로 WSL2 컨테이너로 구현 가능 (Win32 input API 불필요). + +**Spec 갱신 사항 (현 상태 반영):** +- `signal_v2` → **`ai_trade`** (rename 완료, web-ai/ai_trade/) +- `Ubuntu-22.04` → **`Ubuntu-24.04`** (Plan-B-Base에서 변경) +- `web-ai-services` → **`web-ai/services`** (실제 경로) +- `/api/stock/holidays` endpoint **미존재 → 신설** (Task 1) + +**Prerequisites (✅ 모두 완료):** +- Plan-A / Plan-B-Base / Plan-B-Insta / Plan-B-Music / Plan-B-Video 모두 완료 +- WSL2 mirror mode + Redis chown 999:999 영구 적용 +- services/.env 분기 패턴 정착 (NAS_BASE_URL service-local default) + +--- + +## Phase 구조 + +| Phase | 내용 | Task | +|-------|------|------| +| **1. NAS stock holidays endpoint** | `/api/stock/holidays` GET 신설 (task-watcher가 조회) | 1 | +| **2. Windows task-watcher** | mode 판정 + Redis 토글 loop + Dockerfile + compose | 2~6 | +| **3. NSSM 안내 + 검증** | SP-9 NSSM 안내 문서 + 박재오 빌드 + end-to-end | 7~8 | + +--- + +## File Structure + +### Phase 1 — NAS web-backend + +| 파일 | 변경 | 책임 | +|------|------|------| +| `web-backend/stock/app/main.py` | `GET /api/stock/holidays` endpoint 추가 | holidays.json + 주말 노출 | +| `web-backend/stock/app/test_holidays_endpoint.py` (Create) | 2 tests | TDD | + +### Phase 2 — Windows web-ai/services/task-watcher + +| 파일 | 변경 | 책임 | +|------|------|------| +| `web-ai/services/task-watcher/mode.py` (Create) | `current_mode(now, holidays)` 순수 함수 + `fetch_holidays()` | 시간대 판정 | +| `web-ai/services/task-watcher/watcher.py` (Create) | 30초 loop + Redis 토글 | dispatcher | +| `web-ai/services/task-watcher/main.py` (Create) | FastAPI + lifespan(watcher spawn) + /health | entry | +| `web-ai/services/task-watcher/Dockerfile` (Create) | python:3.12-slim | image | +| `web-ai/services/task-watcher/requirements.txt` (Create) | fastapi, redis, httpx, pytest | deps | +| `web-ai/services/task-watcher/.env.example` (Create) | REDIS_URL, STOCK_BASE_URL, TRADING_START, TRADING_END | secrets | +| `web-ai/services/task-watcher/tests/test_mode.py` (Create) | current_mode 6 cases | TDD | +| `web-ai/services/task-watcher/tests/__init__.py` (Create) | 빈 marker | pkg | +| `web-ai/services/docker-compose.yml` | task-watcher service 추가 (port 18713) | compose | + +### Phase 3 — 안내 문서 + +| 파일 | 변경 | 책임 | +|------|------|------| +| `web-ai/services/task-watcher/NSSM_SETUP.md` (Create) | SP-9 NSSM 설치 안내 (ai_trade + wsl_docker + task-watcher) | 박재오 수동 가이드 | + +--- + +## Task 1: NAS stock — `/api/stock/holidays` endpoint + tests + +**Files:** +- Modify: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py` +- Create: `C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_holidays_endpoint.py` + +### Step 1: 실패 테스트 작성 + +`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/test_holidays_endpoint.py`: + +```python +"""GET /api/stock/holidays — task-watcher 휴장일 조회용.""" +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + + +def test_holidays_returns_list(): + r = client.get("/api/stock/holidays") + assert r.status_code == 200 + data = r.json() + assert "holidays" in data + assert isinstance(data["holidays"], list) + + +def test_holidays_entries_are_iso_dates(): + r = client.get("/api/stock/holidays") + holidays = r.json()["holidays"] + # 비어 있지 않다면 ISO date 형식 (YYYY-MM-DD) + if holidays: + import datetime as dt + for h in holidays[:5]: + dt.date.fromisoformat(h) # raise 안 하면 통과 +``` + +### Step 2: 테스트 실패 확인 + +Run: `cd C:/Users/jaeoh/Desktop/workspace/web-backend/stock && python -m pytest app/test_holidays_endpoint.py -v` +Expected: FAIL — endpoint 404. + +### Step 3: `main.py`에 endpoint 추가 + +`C:/Users/jaeoh/Desktop/workspace/web-backend/stock/app/main.py`에서 `_HOLIDAYS_PATH` (현재 line 82 부근) 정의를 활용. 적절한 위치(다른 `@app.get` 근처)에 추가: + +```python +@app.get("/api/stock/holidays") +def get_holidays(): + """task-watcher가 조회하는 휴장일 목록. holidays.json 그대로 노출 (인증 불필요).""" + import json + try: + with open(_HOLIDAYS_PATH, encoding="utf-8") as f: + data = json.load(f) + # holidays.json 구조가 list이거나 {"holidays": [...]} 또는 {year: [...]} 형태일 수 있음 + if isinstance(data, list): + holidays = data + elif isinstance(data, dict) and "holidays" in data: + holidays = data["holidays"] + elif isinstance(data, dict): + # {year: [dates]} → flatten + holidays = [d for v in data.values() if isinstance(v, list) for d in v] + else: + holidays = [] + except (OSError, ValueError): + holidays = [] + return {"holidays": holidays} +``` + +**주의:** 작성 전 `holidays.json` 실제 구조를 확인할 것 (`Read web-backend/stock/app/holidays.json`). 위 코드는 list / `{"holidays":[]}` / `{year:[]}` 3가지 형태를 모두 처리하지만, 실제 구조에 맞게 단순화 가능. + +### Step 4: 테스트 통과 + +Run: `python -m pytest app/test_holidays_endpoint.py -v` +Expected: 2 PASS. + +### Step 5: 회귀 확인 + +Run: `python -m pytest app/ -v 2>&1 | tail -5` +Expected: 기존 stock 테스트 모두 통과 + 새 2개. + +### Step 6: 커밋 + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-backend +git add stock/app/main.py stock/app/test_holidays_endpoint.py +git commit -m "$(cat <<'EOF' +feat(stock): GET /api/stock/holidays endpoint (SP-10 task-watcher용) + +holidays.json 노출. task-watcher가 휴장일 판정에 조회. +인증 불필요 (민감 정보 아님). 주말은 task-watcher가 weekday로 별도 판정. +Plan-B-Infra Phase 1. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +## Context + +- spec §3: "휴장일 단일 소스 — web-backend/stock/app/holidays.json 정본. NAS stock이 GET /api/stock/holidays로 노출." +- 현재 holidays.json은 `_is_holiday()` 내부 함수에서만 사용, HTTP endpoint 없음 → 신설. +- stock 컨테이너는 이미 deploy.sh BUILD_TARGETS에 등재됨 (신규 lab 아님 — deploy scripts 추가 불필요). +- 작업 디렉토리: `C:/Users/jaeoh/Desktop/workspace/web-backend` + +## Report + +- Status: DONE | DONE_WITH_CONCERNS | BLOCKED +- holidays.json 실제 구조 (확인 결과) +- 2 PASS + 회귀 +- 커밋 SHA + +--- + +## Task 2: Windows task-watcher — mode.py (current_mode + fetch_holidays) + tests + +**Files:** +- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/mode.py` +- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/tests/__init__.py` +- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/tests/test_mode.py` + +### Step 1: 실패 테스트 작성 + +`tests/__init__.py`: (빈 파일) + +`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/tests/test_mode.py`: + +```python +"""current_mode — 시간대 + 휴장일 판정 (순수 함수).""" +import datetime as dt +from zoneinfo import ZoneInfo + +from mode import current_mode + +KST = ZoneInfo("Asia/Seoul") +HOLIDAYS = {"2026-05-25"} # 가상 휴장일 (월요일) + + +def _kst(y, m, d, hh, mm): + return dt.datetime(y, m, d, hh, mm, tzinfo=KST) + + +def test_weekday_trading_hours_is_trading(): + # 2026-05-22 금요일 10:00 — 트레이딩 시간대 + assert current_mode(_kst(2026, 5, 22, 10, 0), HOLIDAYS) == "trading" + + +def test_weekday_before_open_is_free(): + # 평일 06:00 — 장 전 + assert current_mode(_kst(2026, 5, 22, 6, 0), HOLIDAYS) == "free" + + +def test_weekday_after_close_is_free(): + # 평일 17:00 — 장 마감 후 + assert current_mode(_kst(2026, 5, 22, 17, 0), HOLIDAYS) == "free" + + +def test_weekend_is_free(): + # 2026-05-23 토요일 10:00 + assert current_mode(_kst(2026, 5, 23, 10, 0), HOLIDAYS) == "free" + + +def test_holiday_weekday_is_free(): + # 2026-05-25 월요일이지만 휴장일 → 트레이딩 시간대라도 free + assert current_mode(_kst(2026, 5, 25, 10, 0), HOLIDAYS) == "free" + + +def test_trading_boundary_inclusive_start_exclusive_end(): + # 07:00 정각 = 트레이딩 시작, 16:30 정각 = 마감 (16:30은 free) + assert current_mode(_kst(2026, 5, 22, 7, 0), HOLIDAYS) == "trading" + assert current_mode(_kst(2026, 5, 22, 16, 29), HOLIDAYS) == "trading" + assert current_mode(_kst(2026, 5, 22, 16, 30), HOLIDAYS) == "free" +``` + +### Step 2: 테스트 실패 확인 + +Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher && python -m pytest tests/test_mode.py -v` +Expected: FAIL — `mode` 모듈 미존재. + +### Step 3: `mode.py` 작성 + +`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/mode.py`: + +```python +"""시간대 + 휴장일 기반 모드 판정 (idle 감지 생략 — 박재오 결정 2026-05-22). + +trading: 비휴장 평일 07:00–16:30 (장중) → queue:paused SET +free: 그 외 (장 전/후, 주말, 휴장) → queue:paused DEL +""" +from __future__ import annotations + +import datetime as dt +import logging +import os +from typing import Set +from zoneinfo import ZoneInfo + +import httpx + +logger = logging.getLogger(__name__) + +KST = ZoneInfo("Asia/Seoul") +STOCK_BASE_URL = os.getenv("STOCK_BASE_URL", "http://192.168.45.54:18500") + +# 트레이딩 윈도우 (HH:MM, KST). .env로 조정 가능. +TRADING_START = os.getenv("TRADING_START", "07:00") +TRADING_END = os.getenv("TRADING_END", "16:30") + + +def _parse_hhmm(s: str) -> dt.time: + hh, mm = s.split(":") + return dt.time(int(hh), int(mm)) + + +def current_mode(now: dt.datetime, holidays: Set[str]) -> str: + """now(KST aware) + holidays(ISO date set) → 'trading' | 'free'.""" + # 주말 (토=5, 일=6) + if now.weekday() >= 5: + return "free" + # 휴장일 + if now.date().isoformat() in holidays: + return "free" + # 트레이딩 윈도우 [start, end) + start = _parse_hhmm(TRADING_START) + end = _parse_hhmm(TRADING_END) + t = now.timetz().replace(tzinfo=None) + if start <= t < end: + return "trading" + return "free" + + +def fetch_holidays() -> Set[str]: + """NAS stock /api/stock/holidays 조회. 실패 시 빈 set (안전 — free로 판정).""" + try: + r = httpx.get(f"{STOCK_BASE_URL}/api/stock/holidays", timeout=10.0) + if r.status_code == 200: + return set(r.json().get("holidays", [])) + logger.warning("holidays fetch returned %d", r.status_code) + except Exception: + logger.exception("holidays fetch 실패") + return set() +``` + +### Step 4: 테스트 통과 + +Run: `python -m pytest tests/test_mode.py -v` +Expected: 6 PASS. + +### Step 5: 커밋 + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-ai +git add services/task-watcher/mode.py services/task-watcher/tests/__init__.py services/task-watcher/tests/test_mode.py +git commit -m "$(cat <<'EOF' +feat(task-watcher): mode.py — 시간대+휴장일 판정 (SP-10) + +current_mode(now, holidays): 비휴장 평일 07:00–16:30 → trading, 그 외 free. +fetch_holidays(): NAS /api/stock/holidays 조회 (실패 시 빈 set = free 안전). +TRADING_START/END env로 윈도우 조정. idle 감지 생략 (박재오 결정). +6 tests (평일 장중/장전/장후, 주말, 휴장, 경계). +Plan-B-Infra Phase 2. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +## Context + +- KST 시각 + holidays set → trading/free 순수 함수. 테스트 용이 (now를 인자로). +- holidays는 fetch_holidays()로 NAS 조회. 매 loop마다 호출하면 부하 — watcher.py에서 캐싱 (Task 3). +- 작업 디렉토리: `C:/Users/jaeoh/Desktop/workspace/web-ai` + +## Report +- Status / 6 PASS / 커밋 SHA + +--- + +## Task 3: Windows task-watcher — watcher.py (Redis 토글 loop) + +**Files:** +- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/watcher.py` + +### Step 1: `watcher.py` 작성 + +`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/watcher.py`: + +```python +"""30초마다 current_mode 판정 → queue:paused 토글. + +trading → SET queue:paused 1 EX 600 (10분 TTL — watcher 죽어도 자동 해제) +free → DEL queue:paused +holidays는 1시간마다 refresh (매 loop fetch 부하 회피). +""" +from __future__ import annotations + +import asyncio +import datetime as dt +import logging +import os +from zoneinfo import ZoneInfo + +import redis.asyncio as aioredis + +from mode import current_mode, fetch_holidays, KST + +logger = logging.getLogger(__name__) + +REDIS_URL = os.getenv("REDIS_URL", "redis://192.168.45.54:6379") +PAUSED_KEY = "queue:paused" +LOOP_INTERVAL = 30 # 초 +HOLIDAYS_REFRESH = 3600 # 1시간 +PAUSED_TTL = 600 # 10분 (watcher 죽어도 자동 해제) + + +async def watcher_loop(): + redis = aioredis.from_url(REDIS_URL, decode_responses=False) + holidays = fetch_holidays() + last_holiday_refresh = dt.datetime.now(KST) + last_mode = None + logger.info("task-watcher started (trading window 토글)") + + while True: + try: + now = dt.datetime.now(KST) + # holidays 주기적 refresh + if (now - last_holiday_refresh).total_seconds() >= HOLIDAYS_REFRESH: + holidays = fetch_holidays() + last_holiday_refresh = now + + mode = current_mode(now, holidays) + if mode == "trading": + await redis.set(PAUSED_KEY, b"1", ex=PAUSED_TTL) + else: + await redis.delete(PAUSED_KEY) + + if mode != last_mode: + logger.info("mode 전환: %s → %s (paused=%s)", last_mode, mode, mode == "trading") + last_mode = mode + + await asyncio.sleep(LOOP_INTERVAL) + except asyncio.CancelledError: + logger.info("watcher_loop cancelled") + raise + except Exception: + logger.exception("watcher_loop iteration 실패, 30초 후 재시도") + await asyncio.sleep(LOOP_INTERVAL) +``` + +### Step 2: 임포트 smoke + +Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher && python -c "from watcher import watcher_loop; print('OK')"` +Expected: `OK`. + +### Step 3: 커밋 + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-ai +git add services/task-watcher/watcher.py +git commit -m "$(cat <<'EOF' +feat(task-watcher): watcher.py — 30초 loop + queue:paused 토글 (SP-10) + +trading → SET queue:paused 1 EX 600 / free → DEL. +holidays 1시간마다 refresh. PAUSED_TTL 600s (watcher 죽어도 자동 해제 — 안전). +mode 전환 시에만 로그. +Plan-B-Infra Phase 2. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +## Context + +- `PAUSED_TTL=600`이 핵심 안전장치: task-watcher가 죽어도 10분 후 자동으로 paused 해제 → 큐 영구 정지 방지. +- holidays는 1시간 캐싱 (매 30초 fetch 안 함). +- render worker들(insta/music/video)이 이미 `queue:paused` 체크 로직 보유 (Plan-B-Insta/Music/Video). + +## Report +- Status / smoke 결과 / 커밋 SHA + +--- + +## Task 4: Windows task-watcher — main.py + Dockerfile + requirements + .env.example + +**Files:** +- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/main.py` +- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/Dockerfile` +- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/requirements.txt` +- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/.env.example` + +### Step 1: `requirements.txt` + +``` +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +redis>=5.0 +httpx>=0.27 +pytest>=8.0 +``` + +### Step 2: `Dockerfile` + +```dockerfile +FROM python:3.12-slim-bookworm +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates tzdata \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir --timeout 600 --retries 5 -r requirements.txt + +COPY . . + +EXPOSE 8000 +CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] +``` + +(tzdata 추가 — zoneinfo Asia/Seoul 사용.) + +### Step 3: `.env.example` + +``` +# Plan-B-Infra — task-watcher + +# NAS Redis +REDIS_URL=redis://192.168.45.54:6379 + +# NAS stock holidays endpoint +STOCK_BASE_URL=http://192.168.45.54:18500 + +# 트레이딩 윈도우 (KST, HH:MM) — 이 시간대에만 queue:paused +TRADING_START=07:00 +TRADING_END=16:30 +``` + +### Step 4: `main.py` + +```python +"""task-watcher FastAPI entry — health + lifespan (watcher loop spawn).""" +from __future__ import annotations + +import asyncio +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI + +import watcher + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + watcher_task = asyncio.create_task(watcher.watcher_loop()) + logger.info("task-watcher lifespan 시작") + try: + yield + finally: + watcher_task.cancel() + try: + await watcher_task + except asyncio.CancelledError: + pass + logger.info("task-watcher lifespan 종료") + + +app = FastAPI(lifespan=lifespan) + + +@app.get("/health") +def health(): + return {"ok": True, "service": "task-watcher"} +``` + +### Step 5: smoke + 회귀 + +Run: +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher +python -c "from main import app; print(len(app.routes))" +python -m pytest tests/ -v 2>&1 | tail -5 +``` +Expected: 숫자 출력 + 6 PASS (test_mode). + +### Step 6: 커밋 + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-ai +git add services/task-watcher/main.py services/task-watcher/Dockerfile services/task-watcher/requirements.txt services/task-watcher/.env.example +git commit -m "$(cat <<'EOF' +feat(task-watcher): main.py + Dockerfile + requirements + env (SP-10) + +FastAPI lifespan에서 watcher_loop 스폰. /health. tzdata(zoneinfo Asia/Seoul). +.env: REDIS_URL, STOCK_BASE_URL, TRADING_START/END. +Plan-B-Infra Phase 2. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +``` + +## Report +- Status / routes 개수 / 6 PASS / 커밋 SHA + +--- + +## Task 5: Windows services/docker-compose — task-watcher entry + +**Files:** +- Modify: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml` + +### Step 1: video-render service 다음에 task-watcher 추가 + +`C:/Users/jaeoh/Desktop/workspace/web-ai/services/docker-compose.yml`에 추가: + +```yaml + + task-watcher: + build: + context: ./task-watcher + container_name: task-watcher + restart: unless-stopped + ports: + - "18713:8000" + environment: + - TZ=Asia/Seoul + - REDIS_URL=${REDIS_URL:-redis://192.168.45.54:6379} + - STOCK_BASE_URL=${STOCK_BASE_URL:-http://192.168.45.54:18500} + - TRADING_START=${TRADING_START:-07:00} + - TRADING_END=${TRADING_END:-16:30} + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 60s + timeout: 5s + retries: 3 +``` + +### Step 2: YAML 검증 + +Run: `cd C:/Users/jaeoh/Desktop/workspace/web-ai/services && python -c "import yaml; yaml.safe_load(open('docker-compose.yml')); print('valid YAML')"` +Expected: `valid YAML`. + +### Step 3: 커밋 + push + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-ai +git add services/docker-compose.yml +git commit -m "$(cat <<'EOF' +feat(task-watcher): services/docker-compose entry (SP-10) + +port 18713, REDIS_URL/STOCK_BASE_URL/TRADING_START/END env. +insta/music/video-render와 같은 services 묶음. outbound only. +Plan-B-Infra Phase 2 완료 — 박재오 빌드 대기. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +git push 2>&1 # 자격증명 실패 시 박재오 수동 push +``` + +## Report +- Status / YAML 검증 / 커밋 SHA / push 결과 + +--- + +## Task 6: NSSM 안내 문서 (SP-9) + +**Files:** +- Create: `C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/NSSM_SETUP.md` + +SP-9는 박재오 Windows 머신에서 NSSM 수동 설치. controller는 정확한 명령 + 안내 문서 작성. (코드 아님 — 안내 문서.) + +### Step 1: `NSSM_SETUP.md` 작성 + +`C:/Users/jaeoh/Desktop/workspace/web-ai/services/task-watcher/NSSM_SETUP.md`: + +```markdown +# NSSM 자동 시작 설정 (SP-9) + +Windows AI 머신 부팅 시 ai_trade(트레이딩) + WSL2 Docker(render workers + task-watcher) 자동 시작. + +## 1. NSSM 다운로드 + +https://nssm.cc/download → nssm-2.24.zip → `C:\nssm\nssm.exe` 배치 (또는 PATH 등록). + +## 2. ai_trade (Native Python, HIGH priority) + +⚠️ spec의 signal_v2는 ai_trade로 rename됨. 경로/포트 확인. + +```powershell +# 관리자 PowerShell +C:\nssm\nssm.exe install ai_trade "C:\Python312\python.exe" "-m uvicorn main:app --host 0.0.0.0 --port 8001" +C:\nssm\nssm.exe set ai_trade AppDirectory "C:\Users\jaeoh\Desktop\workspace\web-ai\ai_trade" +C:\nssm\nssm.exe set ai_trade Priority HIGH_PRIORITY_CLASS +C:\nssm\nssm.exe set ai_trade Start SERVICE_AUTO_START +C:\nssm\nssm.exe set ai_trade AppStdout "C:\Users\jaeoh\nssm-logs\ai_trade.log" +C:\nssm\nssm.exe set ai_trade AppStderr "C:\Users\jaeoh\nssm-logs\ai_trade.log" +``` + +(ai_trade의 실제 진입점이 main:app + port 8001인지 확인. 다르면 조정.) + +## 3. WSL2 Docker (NORMAL priority — render workers + task-watcher) + +```powershell +C:\nssm\nssm.exe install wsl_docker "C:\Windows\System32\wsl.exe" "-d Ubuntu-24.04 -- sh -c 'sudo service docker start && cd /workspace/web-ai/services && docker compose up -d'" +C:\nssm\nssm.exe set wsl_docker Priority NORMAL_PRIORITY_CLASS +C:\nssm\nssm.exe set wsl_docker Start SERVICE_AUTO_START +C:\nssm\nssm.exe set wsl_docker AppStdout "C:\Users\jaeoh\nssm-logs\wsl_docker.log" +``` + +⚠️ 변경점: Ubuntu-22.04 → **Ubuntu-24.04**, web-ai-services → **web-ai/services**. WSL 경로는 `/mnt/c/...` 또는 박재오 WSL 마운트 기준 (`/workspace`가 web-ai에 매핑되어 있으면 그대로). + +`sudo service docker start`가 비밀번호 요구하면 sudoers에 NOPASSWD 추가: +```bash +# WSL2 안 +echo "$USER ALL=(ALL) NOPASSWD: /usr/sbin/service docker start" | sudo tee /etc/sudoers.d/docker-start +``` + +## 4. 서비스 시작 + 확인 + +```powershell +C:\nssm\nssm.exe start ai_trade +C:\nssm\nssm.exe start wsl_docker + +# 상태 확인 +C:\nssm\nssm.exe status ai_trade +C:\nssm\nssm.exe status wsl_docker +sc query ai_trade +``` + +## 5. 검증 + +```powershell +# ai_trade +curl http://localhost:8001/health # 또는 ai_trade의 실제 health endpoint + +# WSL2 docker 컨테이너 (재부팅 후 자동 시작 확인) +wsl -d Ubuntu-24.04 -- docker ps +# insta-render, music-render, video-render, task-watcher 4개 Up 확인 +``` + +## 6. 재부팅 테스트 + +Windows 재부팅 → 로그인 → 수동 조작 없이: +- ai_trade 서비스 자동 시작 (HIGH priority) +- WSL2 + Docker + 4 컨테이너 자동 시작 (NORMAL priority) +- task-watcher가 trading window에 queue:paused 토글 시작 + +## task-watcher 동작 확인 + +```bash +# WSL2 +docker logs task-watcher --tail 20 +# 기대: "task-watcher started" + mode 전환 로그 (trading/free) + +# Redis 큐 상태 (NAS 또는 LAN) +docker exec redis redis-cli GET queue:paused +# 트레이딩 시간대(평일 07:00-16:30): "1" +# 그 외: (nil) +``` +``` + +### Step 2: 커밋 + push + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-ai +git add services/task-watcher/NSSM_SETUP.md +git commit -m "$(cat <<'EOF' +docs(task-watcher): NSSM_SETUP.md — SP-9 자동 시작 안내 + +ai_trade(HIGH, native python :8001) + wsl_docker(NORMAL, WSL2 Ubuntu-24.04 +docker compose up). spec의 signal_v2→ai_trade, 22.04→24.04, web-ai-services +→web-ai/services 정정. sudoers NOPASSWD + 재부팅 검증 절차. +Plan-B-Infra Phase 3. + +Co-Authored-By: Claude Opus 4.7 (1M context) +EOF +)" +git push 2>&1 +``` + +## Report +- Status / 커밋 SHA / push 결과 + +--- + +## Task 7: 박재오 빌드 + task-watcher 검증 + +**Files:** (변경 없음 — 박재오 측 작업 + 검증) + +### Step 1: web-backend push (Task 1 holidays endpoint) + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-backend && git push +``` +→ NAS deployer가 stock 컨테이너 rebuild. `/api/stock/holidays` 활성화. + +### Step 2: 박재오 NAS 측 holidays endpoint 확인 + +```bash +curl https://gahusb.synology.me/api/stock/holidays +# → {"holidays": ["2026-01-01", ...]} +``` + +### Step 3: 박재오 Windows 측 task-watcher 빌드 + +```bash +cd /workspace/web-ai && git pull +cd /workspace/web-ai/services +docker compose build task-watcher +docker compose up -d task-watcher +docker logs task-watcher --tail 20 +# 기대: "task-watcher lifespan 시작" + "task-watcher started" + mode 로그 +curl -m 3 http://localhost:18713/health +``` + +### Step 4: 시간대 토글 검증 + +현재 KST 시각 기준: +```bash +# 트레이딩 시간대(평일 07:00-16:30)면 paused=1, 아니면 nil +docker exec task-watcher python -c "import datetime as dt; from zoneinfo import ZoneInfo; from mode import current_mode, fetch_holidays; print('now mode:', current_mode(dt.datetime.now(ZoneInfo('Asia/Seoul')), fetch_holidays()))" + +# Redis 확인 (NAS 또는 LAN) +ssh nas +docker exec redis redis-cli GET queue:paused +``` + +기대: +- 평일 07:00-16:30 (비휴장): `current_mode` = "trading", `queue:paused` = "1" +- 그 외: "free", (nil) + +### Step 5: render worker가 paused 존중하는지 (선택) + +트레이딩 시간대에 video 생성 요청 → worker가 BLPOP 전 paused 확인 → 10초 대기 반복 (처리 보류). free 시간대 되면 자동 처리. (이미 Plan-B-Insta/Music/Video worker에 `queue:paused` 체크 로직 있음.) + +### Step 6: 메모리 기록 + +`reference_plan_b_infra_complete.md` 작성 + MEMORY.md 인덱스 추가 (Task 8에서). + +## Report +- holidays endpoint 응답 +- task-watcher health + mode +- queue:paused 토글 확인 + +--- + +## Task 8: 메모리 기록 + 최종 정리 + +**Files:** +- Create: `C:/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/reference_plan_b_infra_complete.md` +- Modify: `C:/Users/jaeoh/.claude/projects/C--Users-jaeoh-Desktop-workspace-web-ui/memory/MEMORY.md` + +### Step 1: `reference_plan_b_infra_complete.md` + +```markdown +--- +name: plan-b-infra-complete +description: 2026-05-22 Plan-B-Infra — NSSM 자동 시작(SP-9) + task-watcher 시간대 큐 토글(SP-10). spec 12 SP 전부 완료 +metadata: + type: reference +--- + +Plan-B-Infra 2026-05-22 완료. spec §10 SP-9 + SP-10. 이로써 NAS↔Windows 분산 아키텍처 spec의 12 SP 전부 완료. + +## SP-10 task-watcher (구현) +- web-ai/services/task-watcher/ WSL2 컨테이너 (port 18713) +- 30초 loop: current_mode(KST + holidays) → queue:paused 토글 +- trading(비휴장 평일 07:00-16:30) → SET queue:paused 1 EX 600 / free → DEL +- **idle/게임 감지 생략** (박재오 결정 2026-05-22) — WSL2 컨테이너는 Win32 input API 접근 불가. 시간대만으로 판정. +- PAUSED_TTL 600s = watcher 죽어도 10분 후 자동 해제 (큐 영구정지 방지 안전장치) +- holidays는 NAS GET /api/stock/holidays (신설) 1시간 캐싱 +- TRADING_START/END env로 윈도우 조정 + +## SP-9 NSSM (박재오 수동) +- NSSM_SETUP.md 안내 문서. ai_trade(HIGH, native :8001) + wsl_docker(NORMAL, WSL2 docker compose up) +- spec 정정: signal_v2→ai_trade, Ubuntu-22.04→24.04, web-ai-services→web-ai/services + +## NAS holidays endpoint (신설) +- GET /api/stock/holidays — holidays.json 노출. 기존엔 _is_holiday() 내부 함수만 있었음. + +## 다음 +- frontend video/music/insta UI (backend gateway만 완료, UI 별도) +- FOLLOW-UP B: -lab suffix 제거 +``` + +### Step 2: MEMORY.md 인덱스 추가 + +`reference_plan_b_video_complete.md` 항목 뒤: +```markdown +- [Plan-B-Infra 완료](reference_plan_b_infra_complete.md) — 2026-05-22 NSSM 자동 시작(SP-9) + task-watcher 시간대 큐 토글(SP-10). idle 감지 생략. spec 12 SP 전부 완료 +``` + +### Step 3: 양쪽 push 확인 + +```bash +cd C:/Users/jaeoh/Desktop/workspace/web-backend && git status && git log --oneline -3 +cd C:/Users/jaeoh/Desktop/workspace/web-ai && git status && git log --oneline -5 +``` + +### Step 4: 박재오 보고 +- spec 12 SP 전부 완료 +- task-watcher 시간대 토글 동작 +- NSSM은 박재오 수동 (NSSM_SETUP.md 참고) + +## Report +- 메모리 파일 생성 +- push 상태 +- 최종 보고 + +--- + +## Self-Review + +**1. Spec coverage** + +| Spec 요구사항 | 구현 위치 | 상태 | +|--------------|-----------|------| +| SP-9 §10: NSSM ai_trade(HIGH) + wsl_docker(NORMAL) 자동 시작 | Task 6 NSSM_SETUP.md | ✓ (박재오 수동 + 안내) | +| SP-10 §10: task-watcher 컨테이너 30초 loop | Task 3 watcher.py | ✓ | +| SP-10 §10: current_mode (시간대 + holidays + KST) | Task 2 mode.py | ✓ | +| SP-10 §10: queue:paused 토글 (free→DEL, trading→SET) | Task 3 | ✓ | +| §3 휴장일 단일 소스 GET /api/stock/holidays | Task 1 | ✓ (신설) | +| 박재오 결정: idle 감지 생략 — 시간대만 | Task 2 (is_user_active 제거) | ✓ | +| §3 트레이딩 모드 = 평일 비휴장 07:00-16:30 | Task 2 TRADING_START/END | ✓ | + +**spec 대비 의도적 변경 (박재오 승인):** +- idle/게임 감지 생략 — spec §10 SP-10의 `is_user_active()` 제거. trading 시간대면 무조건 paused. +- spec §3의 🟡 일반(16:30-23:30) 모드 → free로 통합 (트레이딩 시간대만 paused). + +**2. Placeholder scan:** 통과. NSSM_SETUP.md의 "(확인)" 표기는 박재오 환경 검증 안내 (placeholder 아님). + +**3. Type consistency:** +- `current_mode(now: dt.datetime, holidays: Set[str]) -> str` — Task 2 정의, Task 3 watcher_loop + Task 7 검증 호출 일관 +- `fetch_holidays() -> Set[str]` — Task 2 정의, Task 3 호출 +- mode 값 `"trading"` | `"free"` 2개 — Task 2/3/7 일관 +- `PAUSED_KEY = "queue:paused"` — Task 3, render workers의 PAUSED_KEY와 동일 문자열 (Plan-B-Insta/Music/Video) + +**4. 함정 사전 인지:** +- task-watcher는 services/ 컨테이너 (NAS lab 아님) → deploy.sh 6위치 등재 불필요 +- holidays endpoint(stock)는 기존 컨테이너 수정 → deploy.sh 등재 이미 됨 +- services/.env: TRADING_START/END는 task-watcher 전용 → 다른 서비스와 충돌 없음 (compose default로 분기) +- PAUSED_TTL로 watcher 장애 시 큐 영구정지 방지 + +플랜 완성. 모든 검토 통과. + +--- + +## 부록 — 알려진 결정 + follow-up + +**박재오 결정 (2026-05-22):** idle/게임 감지 생략. 시간대만으로 큐 토글. 박재오 7결정 #1의 "Windows 작업 감지 큐 정지"는 부분 포기 (시간대 기반만). 향후 idle 감지 필요 시 Windows native idle-reporter(GetLastInputInfo) → Redis user:last_input_ts 기록 → task-watcher가 읽는 hybrid로 확장 가능. + +**spec 12 SP 완료 후 follow-up:** +- frontend `/video` `/music` UI (backend gateway만 완료) +- FOLLOW-UP B: `-lab` suffix 일괄 제거 +- GCS lifecycle (Veo Vertex 미사용으로 무관 — Gemini API는 GCS 안 씀) +- Sora 2 alternative (2026-09-24 deprecated 대비)