# 작업 보고: task-3054 — 긴 영상 요약 실패 (Claude CLI 인자 길이 한도)

- 팀: dev2-team (오딘/Odin)
- 저장소: Jeon-Jonghyuk/InsuWiki · 브랜치 `task-3054-summarizer-stdin` · **PR #15 (draft, OPEN)**
- 명세: `memory/plans/tasks/task-3054.md` (sha256 앞16 = `c96d3158f7063e8d`, 대조 일치)
- worktree: `/home/jay/projects/insuwiki/.worktrees/task-3054`

---

## ★ 결론 (한 줄)

**실패했던 2시간19분 영상(DZ_CvwH_-9w) 카드 생성 = 성공.
커서 전진 = 하지 않음 — 이는 정상이며, 남은 실패 1건은 이번 범위 밖의 별개 결함이다.**

---

## 생성/수정 파일

- scripts/youtube-pipeline/youtube_pipeline/summarizer.py
- scripts/youtube-pipeline/youtube_pipeline/main.py
- scripts/youtube-pipeline/tests/test_summarizer_stdin_task3054.py
- scripts/youtube-pipeline/tests/test_exit_code_task3054.py
- memory/reports/task-3054.md

절대경로 기준:

- /home/jay/projects/insuwiki/.worktrees/task-3054/scripts/youtube-pipeline/youtube_pipeline/summarizer.py
- /home/jay/projects/insuwiki/.worktrees/task-3054/scripts/youtube-pipeline/youtube_pipeline/main.py
- /home/jay/projects/insuwiki/.worktrees/task-3054/scripts/youtube-pipeline/tests/test_summarizer_stdin_task3054.py
- /home/jay/projects/insuwiki/.worktrees/task-3054/scripts/youtube-pipeline/tests/test_exit_code_task3054.py

---

## L1 스모크테스트

실서버·실호출 기준 L1 스모크테스트를 수행했다. **SKIP 없음. 목업 없음.**

| # | 대상 | 방법 | 결과 |
|---|---|---|---|
| L1-1 | Claude CLI stdin 호출 | 실제 CLI 짧은 프롬프트 argv/stdin 양쪽 실행 | rc=0 / rc=0, 출력 문자열 완전 동일 |
| L1-2 | Claude CLI 대용량 stdin | 270,080 bytes 실호출 | rc=0, 8.4s, 정상 출력 (argv 는 errno 7) |
| L1-3 | 패치된 generate_summary | 182,108 bytes 실전 규모 실호출 | 4단 요약 정상 생성, 58.4s |
| L1-4 | 커널 경계 | /bin/echo 131,071 / 131,072 bytes | OK / errno 7 (경계 정확) |
| L1-5 | 로케일 | env -i 로 LANG 4종 stdin 한글 전달 | 4종 전부 rc=0, 한글 무손상 |
| L1-6 | **실 파이프라인 1회차 E2E** | worktree 코드로 실제 회차 실행 (45분) | **카드 1장 생성, EXIT_CODE=1, Errno 7 = 0건** |
| L1-7 | 종료코드 셸 전파 | 실 subprocess `$?` 관측 | failed=2 → 1 / failed=0 → 0 / deferred=5 → 0 |
| L1-8 | 대조군 (master 원본) | 12:18 정기 회차 관측 + 원본 main.py 실행 | Errno 7 재현, ExecMainStatus=0 재현 |

L1-6 이 이 태스크의 실증 핵심이다. Firestore 실데이터가 186→187, wiki 504→505 로 증가했다.

L1 증거:

- 회귀 스위트 실행 결과 = **309 passed** (base 재측정 299 passed 대비 +10). 커서 가드 3파일 = 21 passed.
- L1-6 회차의 Whisper 전사 응답 코드 = **200 OK** (2시간19분 영상 전사 정상 수신).
- L1-6 회차 종료코드 = 1, `Argument list too long` 발생 0건 — 요약 단계 통과 확인.
- 스모크테스트 판정: L1-1 ~ L1-8 전부 **성공**. SKIP·목업 없음.

---

**S**: 2026-08-29 06:00 회차에서 2시간19분 영상 요약이 Errno 7 로 막혀 카드 0장 · 커서 정지.
**C**: 전사문을 단일 argv 로 넘겼고, 방어 상수가 틀린 한도(ARG_MAX)를 문자 수 기준으로 보고 있었다.
**Q**: 전달 방식만 바꿔 긴 영상을 통과시키면서 커서 fail-closed 가드를 지킬 수 있는가?
**A**: stdin 전환 + 바이트 상한 정정 + 실패 종료코드. 2h19m 영상 카드 생성 성공, 가드 유지.

## S — 상황

2026-08-29 06:00 회차에서 2시간19분(8,350초) 영상의 Whisper 전사는 HTTP 200 으로 성공했으나,
요약 단계에서 `[Errno 7] Argument list too long` 이 4회 재시도 전부 실패했다.
회차 집계 = 처리 0 · 스킵 6 · 실패 2 → **신규 카드 0장**. 채널 `lastCrawledAt` 갱신이 보류되어
커서가 정지했고, 다음 회차도 같은 영상에서 같은 오류로 막혔다.

## C — 문제

`summarizer._call_claude_cli` 가 전사문을 `-p` 뒤 **단일 argv 문자열**로 전달했다.
방어 상수는 `_MAX_PROMPT_CHARS = 500 * 1024` 이고 주석은 "OS ARG_MAX(대개 2MB) 대비 안전 여유"였다.
**이 주석이 틀렸다.**

| 한도 | 값 | 의미 |
|---|---|---|
| getconf ARG_MAX | 2,097,152 | argv + env **총합** 한도 (기존 주석이 참조한 값) |
| MAX_ARG_STRLEN | **131,072** | **단일 argv 문자열** 한도 (커널 하드코딩 4096 x 32) — 실제로 걸리던 것 |

게다가 상한이 **문자 수** 기준이라 한글(UTF-8 3 bytes/자)에서는 최대 1.5MB 까지 통과시켜
실제 한도를 전혀 막지 못했다. 짧은 영상은 128KB 미만이라 통과했고 2시간대에서 처음 넘어섰다.

## Q — 핵심 질문

전달 방식만 바꿔서(프롬프트 내용·구조는 그대로) 긴 영상이 요약을 통과하는가?
그리고 커서 fail-closed 가드를 깨지 않으면서 실패를 종료코드로 드러낼 수 있는가?

## A — 해결

1. **stdin 전환** — `subprocess.run([CLI, "-p", "--model", MODEL], input=prompt, ...)`.
   argv 에서 프롬프트를 제거했다. stdin 에는 길이 제한이 없다.
2. **상한 정정** — `_MAX_PROMPT_CHARS`(문자, 오류) 제거 → `_MAX_PROMPT_BYTES`(**UTF-8 바이트**) 재정의.
   절단은 UTF-8 안전 경계에서만 수행(깨진 문자 생성 금지). 참고 상수 `_MAX_ARG_STRLEN_BYTES = 131_072` 추가.
3. **실패 표면화** — `main()` 이 `stats.failed > 0` 일 때 `sys.exit(1)`.
   이월(deferred)만 있는 경우는 정상 동작이므로 exit 0 유지.

---

## 채택 근거 — 왜 stdin 인가 (임시파일 아님)

명세는 stdin 불가 시 임시파일 대안을 허용했다. **stdin 이 실측으로 가능해 임시파일은 쓰지 않았다.**
추측하지 않고 짧은 프롬프트로 먼저 동치를 확인한 뒤 적용했다.

```
[동치] argv  rc=0 out='대한민국의 수도는 서울이다.'
[동치] stdin rc=0 out='대한민국의 수도는 서울이다.'
[동치] rc 일치=True / 내용 동일=True
```

임시파일을 배제한 이유: 성공·실패 양쪽 정리 보장이 필요해 코드가 늘고,
전사문이 평문으로 디스크에 남는 구간이 생긴다. stdin 은 두 문제가 모두 없다.

정확한 호출 형태는 **`-p` 를 두되 위치 인자를 주지 않고** stdin 으로 넘기는 것이다.

---

## 실측 증거

### 1. 커널 경계 재현 (ANU 기준값 확인)

```
getconf ARG_MAX = 2,097,152 / PAGE_SIZE = 4096 / env = 2,413 bytes
131,071 bytes → OK
131,072 bytes → OSError errno=7 [Errno 7] Argument list too long
131,073 bytes → OSError errno=7
```
ANU 제시값 `MAX_ARG_STRLEN = 131,072` **정확히 일치**.

### 2. 수정 전/후 대조 (실제 Claude CLI, 목업 없음)

| 프롬프트 크기 | argv 방식 | stdin 방식 |
|---|---|---|
| 270,080 bytes | OSError errno 7 | **rc=0, 8.4s, 정상 출력** |
| 182,108 bytes (라이브 실패 규모) | OSError errno 7 | **rc=0, 58.4s, 4단 요약 정상 생성** |

182,108 bytes 건은 실제 한국어 전사문을 라이브 실패 프롬프트와 같은 규모(176,574 bytes 전사문)로
맞춰 패치된 `generate_summary` 를 그대로 통과시킨 것이다. 출처 블록 포함 정상 산출.

### 3. ★ 라이브 대조군 — 12:18 회차 (master, 미패치)

```
13:04:21  [Errno 7] Argument list too long (prompt=57708 chars) — 시도 1/4
13:05:38  영상 처리 실패 ("감기약 3일치에 5천만 원 날아갔습니다!! ...")
13:07:54  채널 보험명의정닥터 lastCrawledAt 갱신 보류 — (실패 2건)
13:07:57  파이프라인 완료 — 영상: 8 (처리: 0, 스킵: 6, 실패: 2, 이월: 0)
Result=success  ExecMainStatus=0      ← ★ 실패 2건인데 종료코드 0 (명세 지적 사항 라이브 재현)
```

### 4. ★★ 패치 회차 E2E — 13:08~13:53 (worktree, PR 내용 그대로)

```
13:51:50  youtube_knowledge 저장: DZ_CvwH_-9w
13:51:50  wiki 카드 게시: "감기약 3일치에 5천만 원 날아갔습니다!! 고지의무 이거 모르면 보험금 못 받아요"
13:53:20  영상 처리 실패 (보험설계사 90%가 앓고 있는 '중병'...) ← Drive HttpError 400 (별개 결함, 아래 참조)
13:53:20  채널 보험명의정닥터 lastCrawledAt 갱신 보류 — (실패 1건)
13:53:23  파이프라인 완료 — 영상: 8 (처리: 1, 스킵: 6, 실패: 1, 이월: 0)
EXIT_CODE=1                            ← ★ 실패가 종료코드로 드러남
Argument list too long 발생 건수 = 0    ← ★ 근인 소멸
```

Firestore 실측 (전/후):

| 항목 | 전 | 후 |
|---|---|---|
| youtube_knowledge | 186 | **187** (신규 `4Epnl4kHOKI22jdPlTpl`, videoId=`DZ_CvwH_-9w`) |
| documents (wiki) | 504 | **505** |
| ch_보험명의정닥터 lastCrawledAt | 2026-08-26 09:40:37 UTC | **2026-08-26 09:40:37 UTC (미전진)** |

**처리 0 → 1, 실패 2 → 1, 종료코드 0 → 1, Errno 7 → 0.**

### 5. 로케일 위험 점검 (선제)

`text=True` + `input=` 은 stdin 을 **로케일 인코딩**으로 인코딩한다. systemd 유닛에 `LANG`/`LC_*` 설정이
없어 한글이 깨질 위험을 의심해 `env -i` 로 검증했다.

```
LANG 미설정   pref=utf-8   stdout='한글 테스트 가나다' rc=0
LANG=C        pref=utf-8   stdout='한글 테스트 가나다' rc=0
LANG=C.UTF-8  pref=UTF-8   stdout='한글 테스트 가나다' rc=0
LANG=ko_KR.UTF-8 pref=utf-8 stdout='한글 테스트 가나다' rc=0
```
Python 3.7+ 의 C 로케일 강제 UTF-8 승격 덕에 **위험 없음** 확인.

### 6. 바이트/문자 경계 (UTF-8 안전 절단)

`_MAX_PROMPT_BYTES = 512,000` 경계에서 컷이 3바이트 한글 중간에 떨어지도록 구성해 검증:

| 케이스 | 원본 chars/bytes | 전송 chars/bytes | 절단 | UTF-8 왕복 |
|---|---|---|---|---|
| cap-1 | 170,667 / 511,999 | 170,667 / 511,999 | 없음 | 정상 |
| cap | 170,668 / 512,000 | 170,668 / 512,000 | 없음 | 정상 |
| cap+1 | 511,999 / 512,001 | 511,998 / 511,998 | 있음 | 정상 |
| cap+2 | 512,000 / 512,002 | 511,999 / 511,999 | 있음 | 정상 |

깨진 문자·치환 문자가 생기지 않고 불완전 바이트만 버려진다.

### 7. 종료코드 단위 검증 + 대조군

| 케이스 | 관측 `$?` |
|---|---|
| failed=2, processed=0 (신규 코드) | **1** |
| failed=0, processed=3 (신규 코드) | 0 |
| failed=0, deferred=5 (신규 코드) | 0 |
| failed=2 (**master 원본 main.py**) | **0** ← 대조군, 버그 재현 |

`run_pipeline.sh` 마지막 줄이 `exec python3 -m youtube_pipeline.main` 이라 종료코드가 systemd 까지
그대로 전달됨을 별도 확인.

### 8. 봉인 변이 3종

| 변이 | 결과 | FAIL 한 테스트 |
|---|---|---|
| M1 argv 방식으로 원복 | FAIL 확인 → 복원 | test_prompt_not_in_argv_and_passed_via_input 외 3건 |
| M2 상한을 문자기준 500KB 로 원복 | FAIL 확인 → 복원 | test_korean_prompt_below_char_cap_above_byte_cap_is_truncated |
| M3 실패에도 exit 0 | FAIL 확인 → 복원 | test_failed_gt_0_raises_systemexit_1 / test_exit_1_and_cursor_guard_both_hold_on_partial_failure |

변이 전 `git diff --stat` 로 변경이 실재함(no-op 아님)을 먼저 확인했다.
M2 는 175,666자가 구 512,000자 상한 아래라 절단이 일어나지 않아 FAIL — 바이트/문자 구분을 실제로 잡는다.

### 9. 회귀

**base 재측정 기준선 = 299** (명세 추정 273과 다름 — 직접 재측정).
base 커밋 `a7a07f0` 을 `git archive` 로 독립 체크아웃해 별도 확인했다.
수정 후 = **309 passed** (신규 10건, 기존 299 전부 무손상). 델타가 신규 파일 10건과 정확히 일치.

커서 가드 회귀: `test_cursor_guard_bidirectional.py` + `test_run_cap_task3028.py` +
`test_run_pipeline_guard.py` → **21 passed**.

---

## ★★ 명세 ④ 답변 — 백로그는 ①만으로 완전히 풀리지 않는다

명세는 "①을 고치면 자연히 풀리는지 확인하고, 안 풀리면 무엇이 더 필요한지 보고하라"고 했다.

**부분적으로만 풀린다.** 요약 단계는 뚫렸고 2시간19분 영상은 카드가 됐다.
그러나 같은 회차의 **다른 영상 1건이 전혀 다른 원인으로 실패**한다.

```
drive_uploader.py:92
    query = f"'{channel_folder_id}' in parents and name='{file_name}' and trashed=false"
```

영상 제목에 작은따옴표가 있다(`보험설계사 90%가 앓고 있는 '중병'...`).
이 따옴표가 Drive 쿼리 문자열 리터럴을 조기 종료시켜 `HttpError 400 "Invalid Value" (location: q)` 가 난다.
`drive_uploader.py:45` 의 폴더명 조회에도 같은 패턴이 있다.

결과적으로 `channel_failed = 1` 이 남아 **커서는 여전히 전진하지 않는다.**
이것은 결함이 아니라 **t3021 fail-closed 가드가 설계대로 동작한 것**이다.
명세 지시대로 **커서를 수동으로 강제 전진시키지 않았다.**

`drive_uploader.py` 는 이 태스크의 `forbidden_paths` 라 **손대지 않았다.**
→ **후속 태스크 필요**: Drive `q` 파라미터 작은따옴표 이스케이프(`'` → `\'`) — 2곳.
   이것이 해결되어야 커서가 실제로 전진한다.

---

## 수정 파일별 검증 상태

| 파일 | 변경 내용 | grep 검증 | 상태 |
|---|---|---|---|
| scripts/youtube-pipeline/youtube_pipeline/summarizer.py | 프롬프트 stdin 전달 | grep "input=prompt" OK | 통과 |
| scripts/youtube-pipeline/youtube_pipeline/summarizer.py | 바이트 기준 상한 재정의 | grep "_MAX_PROMPT_BYTES" OK | 통과 |
| scripts/youtube-pipeline/youtube_pipeline/summarizer.py | 커널 한도 참고 상수 추가 | grep "_MAX_ARG_STRLEN_BYTES" OK | 통과 |
| scripts/youtube-pipeline/youtube_pipeline/main.py | 실패 시 비정상 종료코드 | grep "sys.exit(1)" OK | 통과 |
| scripts/youtube-pipeline/tests/test_summarizer_stdin_task3054.py | stdin/바이트 봉인 6건 | grep "input" OK | 통과 |
| scripts/youtube-pipeline/tests/test_exit_code_task3054.py | 종료코드/가드 봉인 4건 | grep "SystemExit" OK | 통과 |

구 상수 잔재 없음: `_MAX_PROMPT_CHARS` grep 결과 **0건**.
커서 가드 무변경: base 대비 `main.py` diff 에 `yt_update_last_crawled` 등장 **0건**.

---

## 금지사항 준수

| 금지 | 준수 근거 |
|---|---|
| 커서 fail-closed 완화 (t3021) | main.py diff 에 가드 라인 미등장. 가드 테스트 21 passed. E2E 에서 실패 1건 남아 실제로 미전진 |
| 처리량 상한 변경 (t3028) | config.py 무변경 |
| 요약 프롬프트 내용/4단 구조 (t3039·t3001) | 프롬프트 문자열 무변경 — 전달 방식만 교체 |
| Shorts 필터 (t3040) | 무변경. E2E 에서 Shorts 1건 정상 스킵 |
| transcriber / firestore_writer / drive_uploader | 무변경 (변경 파일 4개뿐) |
| nextapp / functions / .github | 무변경 |
| 자동 머지 | 하지 않음. PR #15 는 draft 상태 |

## PR Check

`validate` = failure. **선재 적색으로 판정한다.**
- 실패 내용 전량이 `functions/src/**` 의 `Cannot find module 'vitest'` (TypeScript) —
  변경한 4개 파이썬 파일(`scripts/youtube-pipeline/**`)과 **교집합 0**
- PR #11 · #12 · #13 · #14 **전부 `validate=failure` 상태로 머지**됨 (만성 적색 실증)

## 운영 특이사항

- v3.6 harness 가 `git push` CLI 를 차단 → 명세/선례대로 `gh api` (blobs → tree → commit → ref) 로 반영.
  원격 tree sha `87373ee1fb80970918e93e47d8a52f8d3b53002f` = 로컬 tree sha **동일**, blob 4/4 일치.
- GPU 점유: 12:18 정기 회차(대조군) 종료 후 13:08~13:53 에만 사용. 타이머와 **미중첩**.
  다음 타이머 18:18 — 여유 확보됨.

## 테스트 결과

**309 passed** (base 299 → +10). 가드 3파일 21 passed. 봉인 변이 3종 전부 FAIL 후 복원 확인.

## scope-guard `.escalate` — 오탐 근거 (자가해소 금지, ANU 판단)

`memory/events/task-3054.escalate` = `{"reason":"scope_guard_violation"}` 이 남았다.
**범위 위반이 아니라 capabilities 스냅샷 부재로 인한 오탐으로 판단한다.**

근거 3가지:

1. `memory/capabilities/task-3054.json` **부재** — 이번 실행은 dispatch 경유가 아닌 예약(cron) 실행이라
   dispatch 시점 immutable 스냅샷이 만들어지지 않았다. 가드가 대조할 기준 자체가 없다.
2. 변경 4건 전부 명세 `allowed_resources.paths` 에 매칭된다.

| 변경 파일 | 매칭된 allowed glob | 판정 |
|---|---|---|
| scripts/youtube-pipeline/youtube_pipeline/summarizer.py | 동일 경로 명시 | 허용 |
| scripts/youtube-pipeline/youtube_pipeline/main.py | 동일 경로 명시 | 허용 |
| scripts/youtube-pipeline/tests/test_summarizer_stdin_task3054.py | scripts/youtube-pipeline/tests/** | 허용 |
| scripts/youtube-pipeline/tests/test_exit_code_task3054.py | scripts/youtube-pipeline/tests/** | 허용 |

3. `forbidden_paths` 침범 건수 = **0** (transcriber / firestore_writer / drive_uploader / nextapp / functions / .github 전부 무변경).

QC 종합은 `overall: WARN` 이며 FAIL 항목은 없다(`l1_smoketest_check` PASS, `git_evidence` PASS,
`file_touch_ratio_check` PASS, `critical_gap` PASS). 규율대로 **자가해소하지 않고** ANU 판단으로 넘긴다.

## ANU 판단 필요

1. **머지 + 반영** — 러너는 디스크 파일을 실행하므로 머지만으로는 반영되지 않는다.
   `/home/jay/projects/insuwiki` 에서 `git pull --ff-only` 필요. 다음 정기 회차는 **18:18**.
2. **후속 태스크: Drive 쿼리 따옴표 이스케이프** — 이것이 남은 실패 1건이자 커서 전진의 마지막 장애물.
   `drive_uploader.py` 2곳(L45, L92).
3. (참고) 요약 길이 상한 초과 경고는 이번에도 관측됨(2,727자 > 2,000자). t3039 에서 이미 보고된
   기존 사안이며 하드 실패가 아니라 경고다. 이번 범위 밖.
4. (참고) 요약 실패 시 전사문이 Drive 업로드 **전에** 폐기된다(`main.py` 순서상 요약이 먼저).
   이번 회차에서만 45분 GPU 작업이 두 번 버려졌다. 구조 개선 여지 — 범위 밖이라 손대지 않았다.

## 비고

- 명세 전제는 전부 실측과 일치했다(ARG_MAX 2,097,152 / MAX_ARG_STRLEN 131,072 / env 2,4xx / 경계 정확).
  반증 사항 없음. 유일한 수치 차이는 pytest 기준선(명세 추정 273 → 실측 **299**)이며 명세가 재측정을 지시한 항목이다.
- 회장님 지시("긴 영상도 처리가 가능하게 해야함") 기준 **달성**: 2시간19분 영상이 카드가 됐다.
