# task-483.1 보고서: 고스트 태스크 근본 원인 분석 (task-1.1, task-4.1)

**팀**: dev2-team (오딘)
**작업 유형**: 읽기 전용 조사 (코드 수정 없음)
**일시**: 2026-03-12

---

## 1. 현재 상태 확인

### task-timers.json 상태

- **task-1.1**: `status: "completed"`, start=2026-03-12T01:12:30, end=2026-03-12T06:46:09, team=dev1-team, desc="작업"
- **task-4.1**: `status: "completed"`, start=2026-03-12T01:12:30, end=2026-03-12T06:46:09, team=marketing, desc="마케팅 작업"
- **현재 running**: task-483.1 (본 조사 태스크) 1건만 존재

### events/ 파일 상태

- `task-1.1.done.clear` 존재 (end_time=06:46:09.748826)
- `task-4.1.done.clear` 존재 (end_time=06:46:09.806314)
- `.done` 파일은 없음 (이미 `.done.clear`로 처리 완료)

### cron 스케줄 확인

- 등록된 스케줄: **0건** (task-1.1, task-4.1 반복 생성 스케줄 없음)

### 종합: 현재 시점에서 task-1.1, task-4.1은 정상 완료 상태. 불일치 없음.

---

## 2. 근본 원인 분석

### 원인 1 (확정, 과거 발생): 팀장 봇의 task-timer.py start 이중 호출

**이미 알려진 원인** (`dispatch-ghost-task-fix.md`, task-464.1에서 대응):

- Bot B(1팀장: 헤르메스)가 dispatch를 받을 때마다 autoset `CLAUDE.md`의 "작업 기록 규칙"을 읽고, `task-timer.py start task-1.1 --team dev1-team --desc "작업"`을 직접 호출
- 3월 5일, 3월 11일 등 반복 발생

**현행 방어 조치**:
- `CLAUDE.md`에 "⚠️ 이 규칙은 아누 세션 전용" 경고 추가
- `task-timer.py start_task()`에 completed 상태 덮어쓰기 거부 가드 추가 (L117-120)

**잔존 위험**: 방어 조치가 자연어 경고 + 코드 가드로 이루어져 있어 **근본적 해소가 아님**. 아래 원인 2~4와 결합 시 재발 가능.

---

### 원인 2 (버그): `_cleanup_task()`의 task_id 불일치

**파일**: `/home/jay/workspace/dispatch.py`

```
L406: timer_task_id = task_id if "." in task_id else f"{task_id}.1"
L407: timer_cmd = [..., "start", timer_task_id, ...]   ← timer_task_id로 running 기록
...
L416: _cleanup_task(task_id)   ← task_id로 정리 시도 (timer_task_id 아님!)
L463: _cleanup_task(task_id)
L469: _cleanup_task(task_id)
L481: _cleanup_task(task_id)
L497: _cleanup_task(task_id)
```

**시나리오**: `dispatch(task_id="task-4")` 호출 시:
1. `timer_task_id = "task-4.1"` → task-timer.py start로 running 기록
2. 이후 project_id 검증 실패 등으로 에러 발생
3. `_cleanup_task("task-4")` 호출 → task-timers.json에서 "task-4" 검색 → **없음** (기록된 것은 "task-4.1")
4. **task-4.1이 running 상태로 orphan 남음** = 고스트 태스크 생성

**재현 조건**: dispatch 호출 시 task_id에 점(.)이 없는 경우 + dispatch 중간 실패

---

### 원인 3 (버그): L476-477 cleanup 누락

**파일**: `/home/jay/workspace/dispatch.py`, L474-477

```python
bot_id = TEAM_BOT.get(team_id)
if not bot_id or bot_id not in BOT_KEYS:
    return {"status": "error", ...}  # ← _cleanup_task 호출 없이 return!
```

이 지점은 task-timer.py start 실행(L407) 이후이므로, running 상태가 이미 기록된 상태에서 cleanup 없이 반환 → orphan 생성.

---

### 원인 4 (설계 결함): `_save_timers()`의 비원자적 쓰기 + 이중 락

**파일**: `/home/jay/workspace/memory/task-timer.py`, L81-93

```python
def _save_timers(self):
    with open(self.timer_file, "w") as f:   # ← 즉시 0바이트로 truncate
        fcntl.flock(f, fcntl.LOCK_EX)        # ← truncate 이후에 락 획득
        json.dump(self.timers, f, ...)
```

**문제**: `open("w")`는 파일을 **즉시 0바이트로 truncate**한 후에야 `LOCK_EX`를 획득. truncate와 lock 사이에 다른 프로세스가 파일을 읽으면 빈 파일을 읽게 됨.

**이중 락 문제**:
- `generate_task_id()`: `.task-id.lock` 파일을 사용
- `_save_timers()`: `task-timers.json` 파일 자체에 flock 사용
- **두 락은 독립적이어서 상호 배제 불가**

**시나리오 (task-1.1 재생성)**:
1. `_save_timers()` 실행 → `open("w")`로 task-timers.json 0바이트 truncate
2. 동시에 `generate_task_id()` 실행 → `json.load(f)` → JSONDecodeError (빈 파일)
3. `data = {"tasks": {}}` → `next_id = "task-1.1"` (하드코딩 기본값, L173)
4. `timer_data = {"tasks": {}}` → `timer_data["tasks"]["task-1.1"] = {"status": "reserved"}` → `json.dump`으로 **기존 전체 데이터 덮어쓰기**
5. 기존 completed task-1.1 레코드가 소멸, reserved로 교체됨
6. 이후 `start_task("task-1.1")` → completed 가드 무력화 (existing은 reserved) → **running 상태로 부활**

---

### 원인 5 (설정): teams/*/CLAUDE.md의 이중 호출 지시

아래 파일들이 팀장 봇에게 task-timer start 수동 호출을 지시:

- `/home/jay/workspace/teams/dev1/CLAUDE.md` L17
- `/home/jay/workspace/teams/dev2/CLAUDE.md` L17
- `/home/jay/workspace/teams/dev3/CLAUDE.md` L19

동시에 `dispatch.py` L407도 자동으로 start를 호출하므로 이중 호출 구조.
`DIRECT-WORKFLOW.md` L40에 "중복 호출 불필요"라고 명시했으나, teams/*/CLAUDE.md는 수정되지 않아 **상충하는 지시**가 존재.

현재는 `already_running` 가드(L111-115)로 이중 시작이 차단되지만, 원인 4의 레이스 컨디션과 결합 시 방어 우회 가능.

---

## 3. 재현 조건

### 조건 A (원인 2 + 3 결합)
1. 아누가 `dispatch(task_id="task-N", team_id=...)` 호출 (점 없는 ID)
2. dispatch.py가 `task-timer.py start task-N.1` 실행 → running 기록
3. 봇 키 미설정 등으로 L476-477에서 cleanup 없이 return
4. **결과**: task-N.1이 영구 running (고스트)

### 조건 B (원인 4: 레이스 컨디션)
1. 두 개의 프로세스가 동시에 dispatch 호출
2. 프로세스 A의 `_save_timers()`가 파일을 truncate
3. 프로세스 B의 `generate_task_id()`가 빈 파일을 읽음
4. 프로세스 B가 `next_id = "task-1.1"` (하드코딩 기본값) 생성
5. 프로세스 B가 기존 전체 데이터를 `{"tasks": {"task-1.1": {"status": "reserved"}}}` 로 덮어씀
6. **결과**: 기존 completed 데이터 전체 유실 + task-1.1이 running으로 부활

### 조건 C (원인 1 + 5 결합)
1. 팀장 봇이 teams/*/CLAUDE.md L17의 지시를 따라 `task-timer.py start task-1.1` 호출
2. completed 가드(L117-120)에 의해 정상적으로는 차단됨
3. **단**, 조건 B가 선행 발생하여 completed 레코드가 소멸된 상태라면 차단 실패
4. **결과**: task-1.1이 running으로 부활

---

## 4. 수정 방안 (코드 변경 제안)

### 수정 1: `_cleanup_task()` 호출 시 `timer_task_id` 사용 [Critical]

**파일**: `/home/jay/workspace/dispatch.py`
**위치**: L416, L463, L469, L481, L497

```python
# 변경 전
_cleanup_task(task_id)

# 변경 후
_cleanup_task(timer_task_id)
```

이를 위해 `timer_task_id`가 모든 cleanup 호출 지점에서 접근 가능하도록, timer_task_id 계산을 dispatch() 함수 초반으로 이동하거나, cleanup 호출 시 인자로 전달.

### 수정 2: L476-477에 `_cleanup_task()` 추가 [Critical]

**파일**: `/home/jay/workspace/dispatch.py`, L476-477

```python
# 변경 전
if not bot_id or bot_id not in BOT_KEYS:
    return {"status": "error", ...}

# 변경 후
if not bot_id or bot_id not in BOT_KEYS:
    _cleanup_task(timer_task_id)
    return {"status": "error", ...}
```

### 수정 3: `_save_timers()` 원자적 쓰기 구현 [Important]

**파일**: `/home/jay/workspace/memory/task-timer.py`, L81-93

```python
# 변경 후
def _save_timers(self) -> None:
    import tempfile, os
    try:
        self.timer_file.parent.mkdir(parents=True, exist_ok=True)
        with tempfile.NamedTemporaryFile(
            "w", dir=self.timer_file.parent, delete=False,
            suffix=".tmp", encoding="utf-8"
        ) as tmp:
            json.dump(self.timers, tmp, ensure_ascii=False, indent=2)
        os.replace(tmp.name, str(self.timer_file))  # atomic rename
    except Exception as e:
        logger.error(f"타이머 파일 저장 실패: {self.timer_file} - {e}")
```

### 수정 4: 락 메커니즘 통일 [Important]

**파일**: `/home/jay/workspace/dispatch.py`, `/home/jay/workspace/memory/task-timer.py`

두 파일 모두 동일한 락 파일 (`WORKSPACE/memory/.task-timers.lock`)을 사용하도록 통일하여, task-timers.json 읽기/쓰기의 상호 배제 보장.

### 수정 5: teams/*/CLAUDE.md에서 task-timer start 수동 호출 지시 제거 [Recommended]

**파일**:
- `/home/jay/workspace/teams/dev1/CLAUDE.md` L17
- `/home/jay/workspace/teams/dev2/CLAUDE.md` L17
- `/home/jay/workspace/teams/dev3/CLAUDE.md` L19

"작업 시작 시 반드시 task-timer 시작" 지시를 제거하고, "dispatch.py가 자동 처리하므로 중복 호출 불필요" (DIRECT-WORKFLOW.md L40과 동일)로 교체.

### 수정 6: `generate_task_id()` 하드코딩 기본값 제거 [Recommended]

**파일**: `/home/jay/workspace/dispatch.py`, L173

```python
# 변경 전
next_id = "task-1.1"

# 변경 후 (파일 읽기 실패 시 에러 반환, 하드코딩 기본값 사용 금지)
if not timer_file.exists():
    timer_data = {"tasks": {}}
    # 새로 생성하는 경우만 task-1.1 허용 (의도적 초기화)
    next_id = "task-1.1"
else:
    try:
        with open(timer_file, "r") as f:
            data = json.load(f)
    except (json.JSONDecodeError, OSError) as e:
        # 파일 존재하는데 읽기 실패 = 손상. 기본값 사용 금지, 에러 반환
        raise RuntimeError(f"task-timers.json이 손상됨. 수동 복구 필요: {e}")
```

---

## 5. 영향 범위

- task-timers.json에 쓰기하는 코드: `dispatch.py` (4곳), `task-timer.py` (1곳)
- 읽기 전용: `orphan-watchdog.py`, `orchestrator.py`, `health-check.sh`, `data_integrity.py`
- `notify-completion.py`: task-timer.py를 직접 호출하지 않음 (안전)
- `chain_manager.py`: task-timer.py를 호출하지 않음 (안전)
- `_register_followup()`: task-timer.py start 지시 없음 (안전). 단, 자연어 프롬프트로 아누 봇에게 재확인 스케줄 등록을 지시하므로 간접 위험은 존재

---

## 6. 셀프 QC

- [x] 1. 다른 파일 영향: 없음 (읽기 전용 조사)
- [x] 2. 엣지 케이스: 레이스 컨디션 시나리오 분석 완료
- [x] 3. 작업 지시 일치: 근본 원인 + 재현 조건 + 수정 방안 모두 포함
- [x] 4. 보안: .env.keys 내용 미포함 확인
- [x] 5. 코드 수정 없음 (읽기 전용 조사 원칙 준수)

---

## 7. QC 자동 검증 결과

- 코드 변경 없으므로 pyright/test_runner/style_check 해당 없음
- file_check: 본 보고서 파일 존재 확인
- data_integrity: task-timers.json과 .done 파일 일치 확인 완료 (위 Section 1)
