# music/YouTube 파이프라인 신뢰성·복구 — 설계

> 작성 2026-06-12. YouTube 자동화 파이프라인의 step 실패를 자동 재시도(일시적)하고, 영구 실패는 실패 step부터 수동 재개(텔레그램 [🔄재시도])할 수 있게 한다. "music/YouTube 파이프라인 고도화" 중 **신뢰성/복구** 슬라이스.

## 1. 목표

파이프라인 step(`cover→video→thumb→meta→review→publish`) 실패가 ① 일시적이면 자동 재시도로 흡수하고, ② 영구적이면 terminal `failed`로 둔 뒤 **이전 산출물을 보존한 채 실패 step부터 재개**할 수 있게 한다. 현재는 step 한 번 실패하면 전체 파이프라인이 terminal `failed`가 되고 복구 경로가 없어 처음부터 다시 만들어야 한다.

## 2. 배경 (현재 동작)

- `orchestrator.run_step(pipeline_id, step, feedback)`: `pipeline_jobs` row 생성 → step 실행 → 성공 시 `update_pipeline_state(next_state)`, 예외 시 `pipeline_jobs.status='failed'` + 파이프라인 `state='failed'` + `failed_reason="{step}: {e}"`. **재시도/재개 없음.**
- 항상 `bg.add_task(orchestrator.run_step, pid, step, ...)`로 BackgroundTask 호출(start_pipeline→cover, feedback→next_step, publish_pipeline→publish).
- 이전 step 산출물(`cover_url`/`video_url`/`thumbnail_url`/`metadata_json`/`review_json`)은 파이프라인 row에 **보존**됨 → 실패 step만 재실행하면 이어갈 수 있는 구조.
- `state_machine`: STEPS, `_APPROVE_NEXT`, TERMINAL_STATES={published, cancelled, **failed**, awaiting_manual}.
- `agent-office youtube_publisher.poll_state_changes`: `*_pending` 신규 진입만 텔레그램 알림. **`failed`는 무알림(silent)** — 사용자가 실패를 모름.

## 3. 요구사항 (확정)

- **자동 재시도**: step 실행 실패 시 `STEP_MAX_RETRIES`(기본 2 → 총 3회)까지 backoff 재시도. 소진 후 terminal `failed`.
  - `_resolve_input` 에러(입력/설정)는 재시도 안 함(재시도해도 안 고쳐짐).
  - **`publish` step은 자동 재시도 제외** — youtube 업로드는 비멱등(중복 업로드 위험). 1회 시도 후 실패면 즉시 terminal.
  - 재시도 대상 = `cover/video/thumb/meta/review`.
- **수동 재개**: terminal `failed` 파이프라인을 실패 step부터 재실행. 이전 산출물 보존.
  - publish 재개 가드: `youtube_video_id`가 이미 있으면 재개 거부(원 업로드 성공 가능성 → 중복 방지).
- **실패 알림**: 영구 실패 시 텔레그램 알림 + 인라인 `[🔄재시도]` 버튼(현재 silent 갭 해소).
- **범위 밖(YAGNI)**: stuck 감지(*_running hang / *_pending 방치). 수동 재시도로 복구 가능하므로 이번 슬라이스 제외.

## 4. 아키텍처

3 컴포넌트:
```
[music-lab orchestrator] run_step: step 실행을 재시도 루프로 (publish 제외) → 소진 시 failed
[music-lab API]          POST /api/music/pipeline/{id}/retry → 실패 step부터 run_step 재트리거
[agent-office]           youtube_publisher: failed 감지 → 텔레그램 알림+[🔄재시도]
                         webhook: ytpub_retry_{pid} → service_proxy.pipeline_retry → music-lab retry
```

## 5. music-lab 상세

### 5.1 자동 재시도 (`pipeline/orchestrator.py`)
- 상수: `STEP_MAX_RETRIES = 2`, `STEP_RETRY_BACKOFF_SEC = [5, 15]`(시도 간 대기), `NON_RETRY_STEPS = {"publish"}`.
- `run_step`의 step 실행부(현재 try lines 31-47)를 루프로:
  ```
  attempts = 1 if step in NON_RETRY_STEPS else (STEP_MAX_RETRIES + 1)
  for i in range(attempts):
      try:
          result = await _dispatch_step(step, p, ctx, feedback)
          update_pipeline_job(job_id, status="succeeded")
          update_pipeline_state(pipeline_id, result["next_state"], **fields)
          return
      except Exception as e:
          last = e
          if i < attempts - 1:
              add_log/pipeline_job note "retry {i+1}"
              await asyncio.sleep(STEP_RETRY_BACKOFF_SEC[min(i, len-1)])
  # 소진
  update_pipeline_job(job_id, status="failed", error=str(last))
  update_pipeline_state(pipeline_id, "failed", failed_reason=f"{step}: {last}")
  ```
- `_resolve_input` 실패는 루프 진입 전 early-return(현행 유지, 재시도 X).
- 재시도 시도 가시화: `pipeline_jobs`에 attempt별 기록(또는 error 메시지에 "attempt n/N").

### 5.2 resume 엔드포인트 (`main.py`)
- `POST /api/music/pipeline/{id}/retry`:
  - 파이프라인 조회 없으면 404.
  - `state != "failed"` → 409 "재개 불가 (state=...)".
  - 실패 step 판별: `db.get_last_failed_step(pipeline_id)` (pipeline_jobs에서 status='failed' 최신 step). 없으면 `failed_reason.split(":")[0].strip()` 폴백.
  - 실패 step이 `publish`이고 `youtube_video_id`가 이미 있으면 → 409 "이미 업로드됨 (중복 방지)".
  - `bg.add_task(orchestrator.run_step, pid, failed_step)` 재트리거. 반환 `{ok: true, retrying_step}`.
- `db.get_last_failed_step(pipeline_id) -> str | None` 헬퍼 신규.

## 6. agent-office 상세

### 6.1 실패 알림 (`agents/youtube_publisher.py`)
- `poll_state_changes`: `_STEP_TITLES`(*_pending) 처리 후, `state == "failed"` 인 파이프라인도 검사.
  - 신규 failed(중복 방지: `self._notified_failed: set[int]`, 또는 기존 dict에 ('failed', reason_hash))면 텔레그램 발송:
    `⚠️ [{track_title}] 파이프라인 #{id} '{step}' 실패\n사유: {failed_reason}` + 인라인 `[🔄 재시도]` (callback_data `ytpub_retry_{id}`).
  - 발송 후 notified 기록.
- `service_proxy.list_active_pipelines()`가 failed를 포함하는지 확인 — 미포함이면 failed도 반환하도록 보강(또는 별도 조회). (plan에서 확인.)

### 6.2 재시도 콜백 (`telegram/webhook.py`)
- `_handle_callback`에 `callback_id.startswith("ytpub_retry_")` 분기 → `_handle_ytpub_retry`.
- `_handle_ytpub_retry`: `pid = int(callback_id.removeprefix("ytpub_retry_"))` → `service_proxy.pipeline_retry(pid)` → 결과 텔레그램 회신("재개: {step}" / 거부 사유).
- `service_proxy.pipeline_retry(pid)` 신규: `POST {MUSIC_LAB_URL}/api/music/pipeline/{pid}/retry`.

## 7. 에러 처리 / 엣지

- 재시도 backoff 중 컨테이너 재시작 → 해당 step 작업 유실, 파이프라인 비-terminal stuck. 범위 밖이나 수동 [🔄재시도]로 복구 가능(안전망).
- resume 시 state≠failed → 409(중복 재개·동시성 방지). 텔레그램 [🔄재시도] 중복 탭도 멱등 거부.
- pipeline_jobs에 failed row 없고 state만 failed → `failed_reason` prefix 폴백.
- publish 재개 + `youtube_video_id` 존재 → 409(중복 업로드 방지).
- 알림 중복: notified 기록으로 같은 failed 1회만 발송.

## 8. 테스트

- **orchestrator (재시도)**: step 2회 실패 후 성공 → next_state 도달(3시도). 끝까지 실패 → failed. publish는 1시도 후 즉시 failed(재시도 X). `_resolve_input` 실패 → 재시도 없이 failed.
- **API retry**: failed→run_step 재트리거(mock 확인) + retrying_step 반환. 비-failed→409. publish+youtube_video_id→409.
- **db**: `get_last_failed_step` — 최신 failed job step 반환, 없으면 None.
- **agent-office**: poll 신규 failed→텔레그램 발송(중복 방지). `_handle_ytpub_retry`→service_proxy.pipeline_retry 호출 + pid 파싱.

## 9. 영향받는 파일

- music-lab: `app/pipeline/orchestrator.py`(재시도 루프 + `_dispatch_step` 추출), `app/main.py`(retry 엔드포인트), `app/db.py`(`get_last_failed_step`), `tests/`.
- agent-office: `app/agents/youtube_publisher.py`(failed 알림), `app/telegram/webhook.py`(ytpub_retry 디스패치), `app/service_proxy.py`(`pipeline_retry`, 필요 시 `list_active_pipelines` failed 포함), `tests/`.
- web-backend/CLAUDE.md music API 표 + `service_music.md` 메모리 갱신.
