# incident-2460-svarog-timer.md

조사자: 스바로그 (dev6) — 2026-05-05 read-only 사고 조사
대상: timer / merge-done / Gemini 3사고 중 timer 영역
근거 워크트리: `/home/jay/workspace/.worktrees/task-2460-dev6`

---

## A. dispatch가 timer를 등록하는 정확한 함수 + 흐름

**결론.** `dispatch.dispatch()` 가 직접 JSON 을 쓰지 않고 `subprocess.run(["python3", TASK_TIMER, "start", ...])` 로 `memory/task-timer.py start` 를 호출한다. 그 직전에 `_set_task_status()` 로 archived/escalated 박제 가드를 거치고, 이후 `_patch_timer_metadata()` 로 affected_files / batch_id 등을 직접 JSON write 한다.

**Evidence**

- `dispatch/__init__.py:187` `TASK_TIMER = WORKSPACE / "memory" / "task-timer.py"`
- `dispatch/__init__.py:3275-3285` (start 호출 핵심)
  ```python
  if not _set_task_status(timer_task_id, "running"):
      return {"status": "error", ...}
  timer_cmd = ["python3", str(TASK_TIMER), "start", timer_task_id, "--team", team_id, ...]
  timer_result = subprocess.run(timer_cmd, capture_output=True, text=True, timeout=30)
  if timer_result.returncode != 0:
      logger.warning(f"task-timer start 실패 (task_id={timer_task_id}): {timer_result.stderr.strip()}")
  ```
- `dispatch/__init__.py:3420` `_patch_timer_metadata(timer_task_id, affected_files=_af)` (직접 JSON write, lock 보호)
- `generate_task_id()` (`dispatch/__init__.py:1651-1716`) 는 ID 생성 시점에 `status="reserved"` placeholder 를 timers.json 에 기록한다.

**흐름도**

```
dispatch.dispatch()
  → generate_task_id()       # tasks[task_id] = {"status":"reserved", ...}
  → _set_task_status()       # archived/escalated 박제 가드
  → subprocess(task-timer.py start)  # status:reserved → running, start_time 기록
     ↳ TaskTimer.start_task()  (memory/task-timer.py:135)
  → _patch_timer_metadata()  # affected_files / batch_id append (lock)
```

---

## B. task-2453 미등록 메커니즘 가설

**결론 (가설 1, 코드 evidence 있음).** `task-timer.py start` 의 stale 거부 로직 (`memory/task-timer.py:166-168`) 또는 dispatch 흐름의 `_cleanup_task()` 호출이 현재 task-2453 항목을 삭제했을 가능성이 가장 크다. 회장 보고대로 "이미 running" 거부 메시지는 `task-timer.py:154-158` 의 동일 task_id 거부 코드이며, 다른 dev2 task 위임이 아니라 task-2453 자체의 재진입 시도에 발동한다 (team_id 거부는 dispatch.py:3244-3258 에서 별개로 처리).

**Evidence**

- `memory/task-timer.py:154-158` 이중 등록 거부
  ```python
  if existing and existing.get("status") == "running":
      logger.warning(f"이중 등록 시도 거부: {task_id}는 이미 running 상태")
      return {"status": "already_running", ...}
  ```
- `memory/task-timer.py:166-168` stale 거부
  ```python
  if existing and existing.get("status") == "stale":
      logger.warning(f"stale task 재시작 시도 거부: {task_id}")
      return {"status": "error", "reason": ...}
  ```
- `dispatch/__init__.py:3244-3265` 같은 팀 running 검사 (force=False 면 error 반환)
- `dispatch/__init__.py:1938-1988` `_cleanup_task()` — `status in ("reserved","running")` 인 항목을 **`del tasks[task_id]` 로 통째 삭제**. dispatch 후속 단계에서 실패 시 호출된다.
- `cancel_task()` (`dispatch/__init__.py:3815-3963`) 는 timer 항목을 **삭제하지 않는다**. 6단계에서 `task-timer.py end --qc-result CANCELLED` 만 호출한다 (라인 3941-3954). 즉 **정상 cancel 경로면 task-2453 항목은 status=completed/qc_result=CANCELLED 로 남아야 한다.**
- 그러나 실제 `memory/events/task-2453.done.acked` (15:12 생성) 는 `end_time:"2026-05-05T15:12:04.566912"`, `qc_result:"CANCELLED"` 로 timer end 가 정상 호출됐음을 증명한다.
- `memory/events/task-2453.cancelled` 의 `reason` 은 `"manual --cancel"` 이 아니라 `"task 파일 상단 STOP 마커 감지 ... finish-task.sh 호출 차단"`, `detected_by: "odin-dev2"`. → **`dispatch.py --cancel` 은 호출되지 않았다.** dev2-team 봇 odin 이 자기 워크트리에서 `finish-task.sh` 의 STOP 마커 가드 (`scripts/finish-task.sh:36-48`) 로 자가 취소했다.
- `scripts/finish-task.sh:43-44` 가 직접 `task-timer.py end ... --qc-result CANCELLED` 를 호출 → 15:12:04 end_time. 이후 `.done.acked` 는 별도 메커니즘으로 생성됨 (`.done` 부재 + acked 존재 = 좀비 정리 흔적, C 항 참조).
- task-2453 항목이 현재 `task-timers.json` 에서 0건인 직접 evidence 는 production timers.json (worktree 베이스 시점 기준) 에서 task-2453 키가 미존재함. 이것은 `_cleanup_task()` (dispatch/__init__.py:1972 `del tasks[task_id]`) 가 어느 시점에 호출됐다는 가설로만 설명된다 — cancelled task 에 대해 후속 dispatch 시도가 있었거나, 좀비 정리 스크립트가 별도로 삭제했을 가능성. 코드 evidence 로 직접 확정 불가, **가설** 단계.

**흐름도 (관찰된 사실)**

```
14:43  dispatch (codex-gate 통과)  → tasks["task-2453"] 생성 (running)
15:00  retry_count=1 마커
15:07  qc-done / qc-result 생성
15:09  scope-guard-done 생성
15:12  봇 odin-dev2 가 task 파일 STOP 마커 감지
       → finish-task.sh:36-48 → task-timer.py end --qc-result CANCELLED → end_time 박제
       → .cancelled 마커 생성, .done 생성 차단
15:12  .done.acked 별도 생성 (출처 불명, 가설: 좀비 정리)
15:42  .done.escalated 생성
?      tasks["task-2453"] 키 자체 소멸 — _cleanup_task() 호출 가설
```

---

## C. task-2422 end_time 갱신 트리거

**결론.** 13:00 cron `cleanup-stale-tasks.sh` 가 task-2421/2422/2423 을 `running → stale` 전환 (`memory/task-timer.py:823-826`) 했고, 17:46:19 어떤 호출자가 직접 `task-timer.py end` 를 trio 모두에 호출하여 `stale → completed` 로 박제했다 (`memory/task-timer.py:262-266`). end_task 는 stale 상태를 거부하지 않으므로 정상 통과한다. 회장 보고의 17:39:41 시각은 task-2422 production end_time `2026-05-05T17:46:19.570403` 와 7분 차이가 있어 시각 인용에 약간의 오차가 있다.

**Evidence**

- `memory/logs/cleanup-stale.log:818-820` — 13:00:01 stale 전환 trio
  ```
  [2026-05-05 13:00:01] stale 전환: task-2421 (running → stale, reason=timeout_running)
  [2026-05-05 13:00:01] stale 전환: task-2422 (running → stale, reason=timeout_running)
  [2026-05-05 13:00:01] stale 전환: task-2423 (running → stale, reason=timeout_running)
  ```
- `memory/logs/app.log:2441-2443` — 17:46:19 end 호출
  ```
  [2026-05-05 17:46:19] [INFO] [__main__] 태스크 완료: task-2421 (47시간 18분)
  [2026-05-05 17:46:19] [INFO] [__main__] 태스크 완료: task-2422 (46시간 56분)
  [2026-05-05 17:46:19] [INFO] [__main__] 태스크 완료: task-2423 (46시간 32분)
  ```
- `memory/task-timer.py:221-266` `end_task()` 는 status 검증을 `completed` 만 멱등 처리하고, stale 은 통과시켜 `task_data["status"] = "completed"` 로 덮어쓴다.
- production `memory/task-timers.json` (offset 37580~) — task-2421/2422/2423 모두 `status:"completed"`, `end_time:"2026-05-05T17:46:19.*"`, `qc_result:"WARN"`.
- `memory/events/task-2422.done` 본문은 `end_time:"2026-05-05T17:46:19.570403"` — task-timer.py:471-580 `_write_event_file()` 가 end_task 안에서 .done 을 함께 박제했음을 증명.

**흐름도**

```
05/03 18:49  task-2422 start (running)
05/05 13:00  cleanup-stale-tasks.sh → cleanup_stale() → status=stale
05/05 15:19  task-2422.done.acked 생성 (좀비 정리 흔적, end_time 박제)
                ↳ 그러나 production timers.json 상의 end_time 은 이 단계에서 안정 박제되지 못함
05/05 15:49  task-2422.done.escalated 생성
05/05 17:46:19  unknown caller가 task-timer.py end 호출 (trio 동시)
                ↳ end_task() 에서 stale → completed, end_time/duration_human 박제
                ↳ _write_event_file() 가 .done 본문 갱신 (mtime 17:46)
```

worktree 의 베이스 timers.json 은 production 17:46 갱신 이전 시점이라 status="running" 으로 보일 수 있음. 회장이 본 "다시 running" 은 이 시점차 또는 17:46 직전 stale→running 복구 시도 가능성 (코드 evidence 미확보, 가설).

---

## D. dev2-team active task 매핑 추적

**결론.** dev2 의 active 추적은 `memory/events/member-status.json` (페르소나 기반: odin/freya/...) 과 `memory/events/bot-activity.json` (bot-c 기반) 두 채널로 분산. fallback 로직은 `memory/task-timer.py:394-441` `_update_bot_activity()` — end 시 동일 team 의 다른 running task 가 있으면 idle 전환 안 함.

**Evidence**

- `memory/task-timer.py:394-408`
  ```python
  bot_key = team_id.replace("-team", "")
  for tid, tdata in self.timers["tasks"].items():
      if tdata.get("status") == "running" and tdata.get("team_id") == team_id:
          return  # 아직 running task 있으므로 갱신 안 함
  ```
  → task-2453 cancel 시점 (15:12) 에 task-2422 가 still running 이었으므로 dev2 가 idle 로 전환되지 않음.
- `memory/events/member-status.json:53-67` — odin/freya 가 페르소나 단위로 task 문자열을 가짐. `task` 필드는 자유 문자열, task_id 매핑 불명확.
- `scripts/finish-task.sh:683-709` member-status.json 의 working/standby → idle 복원은 task_id substring match 로 추정 처리.

**흐름도**

```
05/03 18:49  task-2422 dispatch (dev2-team, bot-c)
                ↳ bot-activity.json: dev2 → working
05/05 14:43  task-2453 dispatch (dev2-team, bot-c)
                ↳ dispatch._check 같은 팀 running → force or 거부 가능 (3244-3265)
                ↳ 위임 성공한 것은 force or DYNAMIC_BOT_TEAMS 자동 적용 가설
05/05 15:12  task-2453 self-cancel by odin-dev2
                ↳ task-timer.end (CANCELLED) → _update_bot_activity()
                ↳ 단, task-2422 still running → idle 전환 차단 (memory/task-timer.py:404-408)
                ↳ dev2 active 는 task-2422 로 자동 회귀
```

---

## E. 좀비 정리 영향 (task-2421/2422/2423)

**결론.** 좀비 정리에 해당하는 단일 스크립트는 부재. 가시적 자동 정리는 `scripts/cleanup-stale-tasks.sh` (cron) 가 `task-timer.py cleanup` 만 호출하여 stale 마킹만 한다 — `.done` 강제 생성도, `task-timer.py end` 호출도 없다. `scripts/orphan-watchdog.py` 도 read-only (감지만, JSON 출력). 15:19 시각의 `.done.acked` 와 17:46 의 `.done` 본문 갱신은 별도 호출자에 의한 것이며 코드 evidence 미확보 상태로 **가설**.

**Evidence**

- `scripts/cleanup-stale-tasks.sh:23` — `python3 "$WORKSPACE/memory/task-timer.py" cleanup ...` 만 호출. .done 생성 코드 없음.
- `memory/task-timer.py:783-839` `cleanup_stale()` — `status="stale"` 만 박제하고 .done/end_time 안 만든다.
- `scripts/orphan-watchdog.py:23-86` — 좀비 감지 후 stdout JSON 출력만, 상태 변경 없음.
- `scripts/session-watchdog.sh:8` 주석 — `# task-timer.py end 호출 제거 (mutation)` (의도적으로 무력화됨)
- `dispatch/__init__.py:1972` `_cleanup_task()` 의 `del tasks[task_id]` — 외부 dispatch 실패 경로에서만 호출, 정기 cron 아님.
- `memory/events/task-2422.done.acked` 의 end_time `15:19:07` ≠ `task-2422.done` 의 end_time `17:46:19` — **두 시점 모두 task-timer.py end 가 호출되어 박제가 두 번 발생**한 흔적 (앞 호출자는 미식별).

**좀비 정리 후 task-2422 가 다시 살아난 메커니즘 (가설)**

1. 15:19 좀비 정리 호출자(미식별) 가 task-timer.py end 호출 → end_time 1차 박제, .done 생성 → 즉시 .done.acked 로 마크.
2. 어떤 외부 프로세스가 `task-timers.json` 을 수동 편집하여 `status=running`/`end_time=null` 로 되돌린 가설 (코드 evidence 없음). worktree 베이스 시점이 그 사이에 만들어졌을 가능성도 있음.
3. 17:46 다시 task-timer.py end 가 trio 호출되어 `stale → completed` 박제, .done 본문 mtime 갱신.

**흐름도**

```
13:00  cron cleanup-stale-tasks.sh
        → task-timer.py cleanup → status=stale (3 trio)
15:19  미식별 caller → task-timer.py end (3 trio)
        → status=completed, .done 생성, .done.acked 즉시 박제
?      task-timers.json 상태 reset (가설, evidence 미확보)
17:46  미식별 caller → task-timer.py end (3 trio 재호출)
        → end_task() 가 stale 또는 running 상태에서 `completed` 덮어쓰기
        → end_time, duration_human 박제, .done 본문 mtime 갱신
17:51  .done.escalated 마커 trio 일괄 생성 (미식별 trigger)
```

---

## 종합 평가

- A: 명확. dispatch 는 subprocess 로 task-timer.py start 호출.
- B: task-2453 항목이 timers.json 에서 사라진 것은 `_cleanup_task()` 의 del 로 설명 가능하나 호출 trigger 미확정. cancel_task 자체는 timer 항목을 보존한다.
- C: 13:00 stale 전환 → 17:46 mass end 호출이 .done end_time 의 출처. end_task 가 stale 을 거부하지 않는 것이 결함.
- D: dev2 active 는 `_update_bot_activity` 의 "다른 running 있으면 idle 전환 안 함" 정책으로 task-2453 cancel 후 task-2422 로 자동 회귀.
- E: 좀비 정리 스크립트는 stale 마킹만 하고 .done 생성/end 호출 안 함. 15:19/17:46 mass-end 호출자는 코드 evidence 로 식별 불가 — **추가 조사 필요**.

핵심 결함: `end_task()` 가 stale 입력을 거부하지 않아, 임의 caller 의 `task-timer.py end` 가 stale → completed 덮어쓰기를 반복적으로 수행할 수 있다 (`memory/task-timer.py:221-293`).
