# task-3011 — 유튜브 파이프라인 러너 배포

- **레벨**: Lv.2 · **팀**: dev2-team (오딘) · **작업일**: 2026-08-25
- **저장소**: `Jeon-Jonghyuk/InsuWiki` (base=`master` `41190d5`)
- **PR**: **#8** https://github.com/Jeon-Jonghyuk/InsuWiki/pull/8 (**OPEN · 머지는 ANU**)
- **원격 커밋**: `e9bcf03c35d802a536f8e07d90f1dfdc06022ade`
- **워크트리**: `/home/jay/projects/insuwiki/.worktrees/task-3011-dev2`

---

## S — 상황

`insuwiki-youtube-pipeline.timer` 는 6시간마다 정상 기동했고 systemd 는 매번
`Finished`(성공)로 표시했다. 그러나 생성된 wiki 카드는 0건이었다.

**실측 (2026-08-20 ~ 08-25 journal 전수)**

| 지표 | 값 |
|---|---|
| 서비스 기동 횟수 | 23 |
| `Finished`(성공) 표시 | 23 |
| `failed` 표시 | **0** |
| "Phase 1 러너 미배포" 로그 | 23 |

증거: `memory/reports/artifacts/task-3011/before-journal.txt`

## C — 복잡성

### ★ 명세 전제 2건이 반증됐다

**① 경로 이원화는 존재하지 않는다.**
`/home/jay/workspace/projects/insuwiki` 는 `/home/jay/projects/insuwiki` 로의
**심볼릭 링크**다. `readlink -f` 결과 양쪽 동일. 별도 클론이 아니므로
"한쪽만 최신" 상태가 **구조적으로 불가능**하다. 명세의 우려는 해소된 것이 아니라
**애초에 성립하지 않았다.**

**② `run_pipeline.sh` 는 사라진 것이 아니라 머지된 적이 없다.**
- 커밋 `fd88601` [task-2980] (dev1-hermes, 2026-08-20), 브랜치 `task/task-2980-dev1`
- `git merge-base --is-ancestor fd88601 origin/master` → **ANCESTOR_NO**
- `--diff-filter=D` 삭제 이력 **0건** → 삭제된 적 없음

### ★ 조사 결론: 복원이 아니라 신규 작성이어야 한다

명세 ①이 요구한 판단이다. 근거 2가지:

1. **옛 러너 자체가 이번에 없애라는 병의 원흉이다.** 실패 경로 **4곳 전부 `exit 0`**
   (`grep -c 'exit 0'` = 4). 파일 주석에 *"하나라도 불충족이면 파이프라인을 돌리지 않고
   0 으로 종료한다(타이머가 실패 루프에 빠지지 않게)"* 라고 **설계 의도로 명시**돼 있다.
2. **복원해도 여전히 무동작이다.** 옛 러너의 가드 #2 는 `youtube_pipeline/state.py`
   존재를 요구하는데 **master 에 그 파일이 없다**(t2980 브랜치 전용 산출물).
   그대로 복원하면 `WARN: Phase 1 상태머신 미배포` + `exit 0`.
   **층만 바뀐 채 조용한 실패가 유지된다.**

증거: `memory/reports/artifacts/task-3011/old-runner-fd88601.sh`

## Q — 해결

### 러너 신설 — `scripts/youtube-pipeline/run_pipeline.sh` (106줄, mode 755)

| 전제조건 | 불충족 시 종료코드 |
|---|---|
| env 파일 존재·읽기 가능 | 10 |
| 파이프라인 디렉토리 + `main.py` | 11 |
| python3 + 필수 의존성 import | 12 |
| `yt-dlp` 실행 가능 | 13 |
| Whisper GPU 헬스 (`${WHISPER_URL}/v1/health`) | 14 |
| 러너 부재/비실행 (부트스트랩 층) | 20 |

- 유일한 `exit 0` 은 `PIPELINE_DRY_RUN=1` **성공** 경로뿐. 모든 실패는 `fail()` → 비-0.
- **재시도 루프 없음** — `while`/`until`/`sleep`/`retry` 패턴 grep 0건.
  `Restart=` 미사용, `NRestarts=0`, 기동 후 `failed` 로 정지 유지(루프 아님 실증).
- 자격증명은 **존재만 확인하고 값을 로그에 출력하지 않는다.**

### 봉인 테스트 — `tests/test_run_pipeline_guard.py` (9종)

env 부재 / main.py 부재 / Whisper 미응답 / DRY_RUN 성공 / 부트스트랩 러너부재 /
부트스트랩 비실행권한 / 재시도루프 정적검증 / **exit-0 마스킹 정적검증** / 실행권한.

## A — 검증 결과

| # | 검증 항목 | 결과 |
|---|---|---|
| 1 | 현재 상태 재현 | ✅ 23회 전부 `status=0/SUCCESS` |
| 2 | 러너 배포 후 실행 | ✅ 전제조건 5/5 통과 → 본체 진입 |
| 3 | wiki 카드 생성 | ❌ **0건** (차단요인 아래) |
| 4 | 실패가 실패로 잡히는가 | ✅ 실제 systemd `failed` / `status=20` |
| 5 | 기존 경로 무손상 | ✅ 4개 컬렉션 전부 무변경, 금지경로 0건 |
| 6 | 봉인(변이 실증) | ✅ 변이 4 failed → 원복 9 passed |

### 검증 4 — `exit 0` 제거 실증 (핵심 성과)

실제 `systemctl --user start` 결과:

```
Active: failed (Result: exit-code) since Tue 2026-08-25 16:02:21 KST
Process: ExecStart=/home/jay/.local/bin/insuwiki-youtube-pipeline (code=exited, status=20)
[bootstrap] ERROR(20): 러너 없음/실행 불가 — .../run_pipeline.sh
```
`systemctl is-failed` → **failed**. 타이머는 정상 예약 유지(다음 22:02).
증거: `artifacts/task-3011/after-systemd-failed.txt`

### 검증 5 — 무손상

| 컬렉션 | baseline | 실행 후 |
|---|---|---|
| `documents` | 489 | 489 |
| `youtube_channels` | 2 | 2 |
| `youtube_knowledge` | 152 | 152 |
| `insurance_chunks` | 151 | 151 |

금지경로(`.github/workflows/` · `functions/` · `nextapp/` · `firestore.rules`) 변경 **0건**.
임베딩은 task-2989 의 네이티브 `Vector` 타입 유지 확인.

### 검증 6 — 변이 실증

`fail()` 을 강제로 `exit 0`(옛 러너 패턴)으로 변이 → **4 failed / 5 passed**
(env부재·main.py부재·Whisper미응답·exit-0마스킹 검증이 정확히 포착).
원복 → **9 passed**. 증거: `artifacts/task-3011/mutation-proof.txt`

### 회귀

**104 passed → 113 passed** (신규 9, 회귀 **0**). red-team scan 취약점 **0건**(러너·테스트 모두 low).

---

## ★★ 실행 여부와 카드 생성 여부 — 구분 보고

- **파이프라인 실제 실행: 예.**
  전제조건 5/5 → 활성 채널 2개 로드 → `신규 영상 20개` 조회 →
  **Whisper 전사 성공(269자·249자 실측)** → 요약 단계 진입.
- **wiki 카드 생성: 아니오 (0건).** 차단 지점은 **요약(Gemini)**.

증거: `artifacts/task-3011/live-run.log`

### 차단 요인 — 이 PR 로 해결 불가

**`GEMINI_API_KEY` 무효** — `400 API_KEY_INVALID`, 4회 재시도 후 영상 처리 실패(8회 오류 기록).

env 파일의 키가 `config.py:18` 에 **평문 커밋된 기본값과 sha256 동일**함을 확인했다
(값 미출력, 해시 대조). 즉 **env 를 설정해도 같은 무효 키**이며, 저장소 노출로 인한
실효로 보인다. **키 로테이션 없이는 카드가 생성될 수 없다** — 러너·부트스트랩 문제가 아니다.
task-3001 보고서의 "판단 대기 #2" 와 동일 사안.

---

## L1 스모크테스트

- **서버 재시작**: **성공** — `systemctl --user start insuwiki-youtube-pipeline.service` 실제 기동.
  결과 `Active: failed (Result: exit-code)`, `status=20`, `is-failed`=failed.
  변경 전(23회 연속 `status=0/SUCCESS`)과 대비되는 실동작 확인.
- **API 응답 확인**: Whisper GPU `curl http://localhost:8200/v1/health` → **200**
  `{"status":"ok","device":"cuda",...}` / `/health` → **404**(설정 오독 실증).
  실제 파이프라인 1회 기동으로 Firestore·YouTube API·Whisper 왕복 실행 확인
  (`live-run.log`). pytest PASS 가 아닌 **실서버 실행 결과**다.
- **스크린샷**: 해당없음 (백엔드 systemd/CLI 작업, UI 없음)

---

## 보고만 하고 손대지 않은 결함

### ★ 1. `main.py:398` — 실패해도 `lastCrawledAt` 을 전진시킨다 (데이터 유실 위험)

```python
# lastCrawledAt 갱신
if new_videos and doc_id:          # ← 성공 여부를 보지 않는다
    yt_update_last_crawled(db, doc_id)
```
t2980 브랜치에는 `and not channel_deferred` (유실방지) 가드가 있으나 **master 에는 없다.**

→ 무효 Gemini 키 상태로 완주시키면 두 채널의 `lastCrawledAt` 이 2026-04-11 에서
현재로 전진해 **4개월치 백로그가 영구 소실**된다.
**이 때문에 실증 실행을 완주시키지 않고 중단했다.** 중단 후 `lastCrawledAt`
두 채널 모두 `2026-04-11` 무변경 확인. 잔존 프로세스 0.

★ **Gemini 키 복구 전에 파이프라인을 완주시키지 말 것.**

### 2. `config.py:30` Whisper 헬스 경로 오독 — 기능 영향은 **없음**

`WHISPER_HEALTH_ENDPOINT = f"{WHISPER_URL}/health"` → 404 (정상은 `/v1/health`, 200).
다만 `whisper_ok` 는 `main.py:165-166` 로그에만 쓰이고 `transcribe()` 에 전달되지 않는
**죽은 변수**다. 실행 로그가 `Whisper 서비스 미응답 — fallback 비활성` 이라 경고하는데도
**Whisper 전사는 실제로 성공**했다(269자·249자). 즉 **거짓 경고**만 남는다.
(초기 가설은 "전사 전면 차단"이었으나 실측으로 반증됨.)

### 3. `config.py` 평문 시크릿 커밋
`GEMINI_API_KEY`, `DRIVE_CLIENT_ID/SECRET` 등이 `os.getenv` **기본값으로 평문 커밋**돼 있다.
회수 + 로테이션 필요. env 의 `COKACDIR_KEY_FILE` 은 **빈 값**이라 현재 Telegram 보고는
`config.py:74` 하드코딩 키(ANU key)에 의존한다 — 회수 시 알림이 함께 죽으므로 동시 처리 필요.

### 4. 별개의 Cloud Functions 파이프라인 (범위 밖)
`functions/src/crawlYoutubeChannels.ts` 가 `wiki`·`youtube_summaries`(151)·
`youtube_video_states`(40)·`pipeline_logs`(686) 등 **다른 컬렉션**에 쓴다.
2026-08-19 이후 정지, 최근 5건 전부 `status='running'` 미완결. **어느 것이 정본
wiki 카드 소스인지 혼란 여지가 있다.** 별도 판단 필요.

### 5. `documents/youtube_JRp2zoZAZ9E` — 기준선 오염원
`visibility=public/docType=wiki` 로 라이브 노출 중이나, id 스킴(`youtube_` 언더스코어)과
`authorId`(`youtube-pipeline-bot`)가 현재 코드(`youtube-` 하이픈,
`system-youtube-pipeline`)와 다르다. audit-trail 대조 결과 2026-08-19 task-2980 이
임시 스크립트로 **수동 생성**한 것. **자동 실행 성공 증거로 오인 금지.**

---

## 실패 알림 수단 — 제안만 (구현 별도)

- InsuRo 의 `alert_notify`(task-3008)는 **다른 저장소**라 그대로 이식 불가.
- 현재 user systemd 유닛 **31개 중 `OnFailure=` 사용 0건** — 실패 알림 배선이 전무하다.
- **제안**: `OnFailure=insuwiki-pipeline-alert@%n.service` 유닛 추가 +
  `cokacdir --sendfile` 로 저널 발췌 발송.
  - ★ `--sendmsg` 플래그는 **존재하지 않는다**(`--help` 실측). 발송 정본은 `--sendfile`.
  - ★ `--key` 는 **동작하지만** 프로세스 목록 노출 경고가 뜬다 → `--key-file` 권장.
    (당초 "`--key` 미지원" 가설은 실측으로 반증됨.)
  - ★ `COKACDIR_KEY_FILE` 이 빈 값이므로 이 배선 전에 키 파일 경로부터 채워야 한다.

---

## 범위 밖 변경 (저장소 아님 · PR 미포함)

`~/.local/bin/insuwiki-youtube-pipeline` — 러너 부재 시 `exit 0` → **`exit 20`**.
원본 백업: `memory/reports/artifacts/task-3011/bootstrap.orig.sh`

★ **이미 라이브 적용됨.** 러너가 아직 master 에 없으므로 **머지 전까지 6시간마다
`failed` 로 표시된다.** 이는 의도된 정직한 상태이며 PR #8 머지 시 해소된다.

## 수정 파일별 검증 상태

| 파일 | 변경 내용 | grep 검증 | 상태 |
|---|---|---|---|
| /home/jay/projects/insuwiki/.worktrees/task-3011-dev2/scripts/youtube-pipeline/run_pipeline.sh | 신규 106줄 · 전제조건 5종 · 고유 종료코드 10~14 | grep "fail 14" OK | verified |
| /home/jay/projects/insuwiki/.worktrees/task-3011-dev2/scripts/youtube-pipeline/run_pipeline.sh | 드라이런 모드 추가 | grep "PIPELINE_DRY_RUN" OK | verified |
| /home/jay/projects/insuwiki/.worktrees/task-3011-dev2/scripts/youtube-pipeline/tests/test_run_pipeline_guard.py | 신규 215줄 · 봉인 테스트 9종 · 변이 실증 완료 | grep "nonzero_exit" OK | verified |
| /home/jay/.local/bin/insuwiki-youtube-pipeline | exit 0 을 exit 20 으로 교체 (저장소 밖 · PR 미포함) | grep "exit 20" OK | verified |
| /home/jay/workspace/memory/reports/task-3011.md | 본 보고서 신규 | grep "GEMINI_API_KEY" OK | verified |

원격 blob 동일성: run_pipeline.sh = 579ff8c13861e533db95284c6039b64313804142,
test_run_pipeline_guard.py = 3b35d07e565f03f348314d16a31982e467a979a8 — 로컬
git hash-object 와 2/2 일치. 실행권한 100755 보존 확인.

## trip-wire 5종 실측

| 항목 | 실측 |
|---|---|
| Critical7 | 0 |
| PII net-new | 0 |
| 회귀 실패 | 0 (104→113 passed) |
| forbidden_paths 침범 | 0 |
| nonce=task_id 일치 | task-3011 |

## 산출물

`/home/jay/workspace/memory/reports/artifacts/task-3011/`
`before-journal.txt` · `after-systemd-failed.txt` · `live-run.log` ·
`mutation-proof.txt` · `firestore-baseline.md` · `probe_baseline.py` ·
`old-runner-fd88601.sh` · `bootstrap.orig.sh`

## ★ ANU 판단 요청

1. **`GEMINI_API_KEY` 로테이션** — 이것 없이는 wiki 카드가 영구히 0건이다.
2. **`main.py:398` lastCrawledAt 유실방지 가드** — 키 복구보다 **먼저** 처리 권장.
   순서를 바꾸면 4개월 백로그가 소실된다.
3. **PR #8 머지** — 머지 전까지 6시간마다 `failed` 표시(의도된 동작).
4. 별개 Cloud Functions 파이프라인의 존치/정리 판단.

---

# ★★★ 종결 후 사태 변경 — PR #8 머지됨 (오딘 미머지)

보고서 작성·종결(FINALIZE_ONLY) 이후 **PR #8 이 머지됐다. 개발2팀은 머지하지 않았다.**

| 항목 | 실측 |
|---|---|
| state | closed / `merged=true` |
| merged_by | `JonghyukJeon` (회장 계정) |
| mergedAt | 2026-08-25T07:21:11Z (= 16:21:11 KST) |
| merge_commit | `ac726de175dd7fcd03e73a5e8a00dd6ee1beb1c9` |
| timeline `auto_merge_enabled` | **없음** |
| 종결 시점 마커 | `.finalize-only` (merge 미수행) |

★ `merged_by` 가 회장 계정이라는 것이 **회장 직접 머지를 뜻하지는 않는다**(owner PAT 경유 가능).
본 태스크 명세는 2026-08-25 에 워처 경로 자동 머지가 4회 발생했다고 적고 있으며,
**본 건이 그 통제 복구 중에 발생한 추가 사례인지 ANU 확인이 필요하다.**

master 반영 확인: `contents/scripts/youtube-pipeline/run_pipeline.sh?ref=master`
→ blob `579ff8c13861e533db95284c6039b64313804142` (PR 산출물과 동일).

## ★★★ 이 머지로 새로 생긴 위험 — 회장/ANU 즉시 판단 요망

러너가 master 에 올라갔으므로, **`/home/jay/projects/insuwiki` 에서 `git pull` 이 일어나는
순간** 부트스트랩이 러너를 찾아 파이프라인을 **실제로 완주**시킨다. 그런데:

1. `GEMINI_API_KEY` 가 무효라 **전 영상이 요약 단계에서 실패**한다(실측 확정).
2. `main.py:398` 에 유실방지 가드가 없어 **실패해도 `lastCrawledAt` 이 전진**한다.
3. → 두 채널의 **4개월치 백로그(2026-04-11~)가 첫 완주에서 영구 소실**된다.

**현재 상태 (2026-08-25 16:2x KST 실측)**
- 워킹트리 HEAD = `41190d5` (**아직 pull 전**) → 러너 디스크에 부재 → 위험은 **잠재**
- 서비스는 현재 `failed (status=20)` 로 **올바르게 큰 소리로 실패 중**
- 다음 타이머: **2026-08-25 22:02:21 KST**
- `lastCrawledAt` 두 채널 모두 `2026-04-11` **무변경**(소실 없음)

**권고 조치 순서 (이 순서를 바꾸면 백로그가 소실된다)**
1. `main.py:398` 에 유실방지 가드 추가 (`and not channel_deferred` 상당) — **최우선**
2. 그 다음 `GEMINI_API_KEY` 로테이션
3. 그 다음에야 파이프라인 완주 허용

★ 타이머를 임의로 정지시키지 않았다. 정지시키면 이번에 확보한 **"실패가 실패로 보이는"
신호가 사라지기 때문**이며, 인프라 임의 변경은 ANU 판단 사항이다.
즉시 차단이 필요하다면 `systemctl --user stop insuwiki-youtube-pipeline.timer` 가 가장 작은 조치다.

## 세션 통계
- 총 도구 호출: 0회

