# task-447.1: 자기체이닝 인프라 긴급 수정 (6개 항목)

## 레벨: Lv.2 (다수 파일 수정 + 통합 테스트 필수)

## 배경
task-439.1(자기체이닝 인프라 변경) 이후 4건 장애 연속 발생.
에이전트 미팅에서 6개 구조적 결함 확인.
미팅 기록: `/home/jay/workspace/memory/meetings/2026-03-10-self-chaining-instability-review.md`

## 수정 항목 (6개)

### 1. [CRITICAL] notify-completion.py — dispatch 실제 구현
현재 `dispatch_next_phase()`가 chain_manager.py next 결과를 print만 하고 dispatch.py를 호출하지 않음.

**수정**:
- `dispatch_next_phase()` 반환값이 `action=dispatch`이면 dispatch.py를 실제 subprocess 호출
- chain_manager.py next subprocess가 **종료된 후** dispatch.py 호출 (시간적 분리)
- dispatch 실패 시 .done.clear 파일 삭제 → 재시도 허용

```python
# 수정 방향 (main 함수 내)
dispatch_result = dispatch_next_phase(args.task_id)
if dispatch_result.get("action") == "dispatch":
    task_file = dispatch_result.get("task_file")
    team = dispatch_result.get("team")
    if task_file and team:
        dispatch_cmd = f"source {WORKSPACE_ROOT}/.env.keys && python3 {WORKSPACE_ROOT}/dispatch.py --team {team} --task-file {task_file} --level normal"
        result = subprocess.run(["bash", "-c", dispatch_cmd], capture_output=True, text=True, timeout=60)
        if result.returncode != 0:
            # dispatch 실패 → .done.clear 삭제하여 재시도 허용
            done_clear = Path(WORKSPACE_ROOT) / "memory/events" / f"{args.task_id}.done.clear"
            done_clear.unlink(missing_ok=True)
            print(f"ERROR: dispatch 실패, .done.clear 삭제로 재시도 허용: {result.stderr}", file=sys.stderr)
```

### 2. [CRITICAL] chain.py — ANU_KEY 하드코딩 제거
파일: `/home/jay/workspace/chain.py`
35행: `ANU_KEY = os.environ.get("COKACDIR_KEY_ANU", "c119085addb0f8b7")`

**수정**: 기본값 `"c119085addb0f8b7"` → `""` 변경 + `_cron_notify()`에서 키 존재 검증:
```python
ANU_KEY = os.environ.get("COKACDIR_KEY_ANU", "")
# _cron_notify() 내부에서:
if not ANU_KEY:
    logger.warning("COKACDIR_KEY_ANU 미설정, 알림 스킵")
    return
```

### 3. [CRITICAL] chain.py — bash -c 쉘 인젝션 수정
파일: `/home/jay/workspace/chain.py`
`_dispatch_phase()` 메서드 (216~223행) 에서 `cmd = f"source {ENV_KEYS} && python3 {DISPATCH_PY} --team {task['team']} ..."` 형태로 bash -c에 chain_id/team 값을 직접 삽입 → 인젝션 가능

**수정**: subprocess 리스트 방식으로 전환하고, dispatch 전에 `source .env.keys`는 별도로 처리:
```python
import shlex
# 안전한 방식
env = os.environ.copy()
# .env.keys 로드
env_keys_path = str(ENV_KEYS)
if Path(env_keys_path).exists():
    # .env.keys에서 export 된 값들을 env dict에 추가
    result = subprocess.run(["bash", "-c", f"source {shlex.quote(env_keys_path)} && env"],
                          capture_output=True, text=True)
    for line in result.stdout.splitlines():
        if "=" in line:
            k, v = line.split("=", 1)
            env[k] = v

cmd = [
    "python3", str(DISPATCH_PY),
    "--team", task["team"],
    "--task-file", str(task_file_path),
    "--level", task["level"],
    "--chain", chain_id,
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60, env=env)
```
+ chain_id, team, level 값에 대한 화이트리스트 검증 추가 (영숫자+하이픈만 허용)

### 4. [HIGH] team_prompts.py — 모듈레벨 크래시 제거
파일: `/home/jay/workspace/prompts/team_prompts.py`
18-22행:
```python
ANU_KEY = os.environ.get("COKACDIR_KEY_ANU", "")
if not ANU_KEY:
    raise EnvironmentError(...)
```

**수정**: chain_manager.py에서 이미 적용된 `_require_anu_key()` 패턴과 동일하게:
```python
ANU_KEY = os.environ.get("COKACDIR_KEY_ANU", "")

def _get_anu_key() -> str:
    """ANU_KEY를 반환. 없으면 EnvironmentError (사용 시점 fail-fast)."""
    if not ANU_KEY:
        raise EnvironmentError("COKACDIR_KEY_ANU 환경변수가 설정되지 않았습니다.")
    return ANU_KEY
```
- 모듈 레벨 raise 삭제
- ANU_KEY를 프롬프트에 삽입하는 코드에서 `_get_anu_key()` 호출로 변경

### 5. [HIGH] DIRECT-WORKFLOW.md Step 9 조건 분기
파일: `/home/jay/workspace/prompts/DIRECT-WORKFLOW.md`
현재 Step 9:
```
9. (chain_id가 있으면) 체인 Phase 완료 알림: python3 {WORKSPACE_ROOT}/chain.py task-done --chain {chain_id} --task {task_id}
```

**수정**: chain_manager.py 체인과 chain.py 체인을 구분하는 조건 추가:
```
9. (chain_id가 있으면) 체인 Phase 완료 알림:
   - chain_id가 dispatch.py 프롬프트의 --chain 값으로 전달된 경우 (chain.py 체인):
     python3 {WORKSPACE_ROOT}/chain.py task-done --chain {chain_id} --task {task_id}
   - 그 외 (chain_manager.py 체인, 또는 chain_id가 없음): Step 9 스킵
     (notify-completion.py가 Step 8에서 이미 처리)
```

### 6. [MEDIUM] insuwiki-p2p3.lock 방치 파일 정리
파일: `/home/jay/workspace/memory/chains/insuwiki-p2p3.lock`
과거 비정상 종료로 남은 stale lock 파일. 삭제.

## 테스트 (필수)

### 기존 테스트
- pytest 전체 통과 확인

### 통합 테스트 추가 (★ 핵심)
반드시 아래 end-to-end 시나리오를 테스트 파일로 추가:

1. **notify-completion.py mid-chain dispatch 실행 확인**:
   - chain_manager.py check → in_chain=true, is_last=false
   - dispatch_next_phase() → action=dispatch 반환
   - **dispatch.py가 실제로 호출되는지 확인** (subprocess mock으로 호출 여부 검증)

2. **dispatch 실패 시 .done.clear 삭제 확인**:
   - dispatch.py 호출이 returncode=1이면
   - .done.clear 파일이 삭제되는지 확인

3. **team_prompts.py import 시 ANU_KEY 없어도 크래시 안 남 확인**:
   - 환경변수 없는 상태에서 `from prompts.team_prompts import TEAM_INFO` 성공 여부

4. **chain.py _cron_notify ANU_KEY 없으면 graceful skip 확인**

5. **chain.py _dispatch_phase 쉘 인젝션 방어 확인**:
   - chain_id에 `"; rm -rf /"` 같은 값 넣었을 때 차단되는지