# task-2962 완료 보고 — 워처 export 함정 해소 + 알림 단일소유자 규약

- **팀**: 개발3팀 (다그다/Dagda)
- **레벨**: critical (Lv.3+)
- **작성**: 2026-08-16
- **브랜치**: task/task-2962-dev3
- **worktree**: /home/jay/workspace/.worktrees/task-2962-dev3

---

## S — 상황

`.env.keys` 144줄 중 **58줄이 export 접두 형식**이라 systemd 의 EnvironmentFile 지시자가 이를 전량 "Ignoring invalid environment assignment" 로 무시했다. 그 결과 activity-watcher.service 는 크래시 루프(누적 재시작 **86,976회**)에 빠졌고, done-watcher.sh 는 set -u 하에서 COKACDIR_CHAT_ID unbound variable 로 즉사해 에스컬레이션·정리·heartbeat 전 구간이 스킵됐다.

동시에 성공 알림 소비자가 **4개**(done-watcher.sh, activity-watcher.py, done-watcher.py, notify-completion.py) 공존하며 전부 "체크 → 전송 → 마커" 순서라 **TOCTOU 경쟁창**이 열려 있었다. 특히 activity-watcher.py 는 .done.notified 를 **읽기만 하고 쓰지 않아** 가장 위험한 고리였다.

이 task 는 본래 dev5 에 위임됐으나 **봇 미수신 → dev3 재위임**되었다. 인수 시점 실태: **라이브 파일에는 수정이 반영되어 있으나 커밋 0건 · PR 0건 · 테스트 0건 · 보고서 0건** 상태로 stale(timeout_running) 종료돼 있었다.

## C — 문제

1. 라이브에 이미 반영된 변경이 **git 이력에 없어** 재부팅·롤백·타 작업 덮어쓰기 시 소실 위험.
2. 로컬 main(244e0d9c)이 origin/main(897eed8b)보다 **2 커밋 뒤쳐져** 있어, 로컬 main 기준으로 판단하면 task-2959 산출물(done-watcher-daemon.sh, test_done_watcher_success_notify.py)을 t2962 신규물로 오인해 **중복·역행 커밋**이 발생한다.
3. 워크스페이스 작업트리가 타 작업 변경으로 오염돼 있어 전체 스테이징 시 **범위 밖 파일 혼입** 위험.
4. "알림 1건" 은 코드 리뷰로 증명 불가 — 3개 유닛 동시 가동 상태의 **실측**이 필요.

## Q — 핵심 질문

라이브에 이미 반영된 변경 중 **task-2962 에 속하는 것만** 정확히 분리해 커밋하고, 성공 알림이 실제 프로덕션에서 **정확히 1건만** 발송됨을 실증할 수 있는가?

## A — 답변

**할 수 있었고, 실측으로 증명했다.**

### 1. export 함정 근본해결 — 실동작 확인

EnvironmentFile 방식을 폐기하고 공용 source 래퍼 scripts/lib/load-env-keys.sh 를 각 데몬 진입 스크립트 상단에서 source 하는 방식으로 전환했다. COKACDIR_CHAT_ID 는 미설정 시 config/constants.json 의 chat_id 로 폴백(하드코딩 금지 규칙 준수).

실측 증거(프로세스 직접 관측):

- activity-watcher.py **PID 245856**, 기동 Sat Aug 15 15:03:24, **누적 가동 110,882초(30.8시간) 무중단**, systemd SubState=running
- done-watcher.py 데몬 **PID 133196**, 기동 Sat Aug 15 12:41:39, **누적 가동 119,387초(33.2시간) 무중단**
- 크래시 스팸 로그 logs/activity-watcher.log 의 크기가 두 차례 관측 시점(21:26 / 21:47) 모두 **418,380,156 바이트로 동일** — 수정 이후 단 1바이트도 증가하지 않음
- done-watcher.service(oneshot)가 타이머로 **30초마다 완주**, Result=success (최근 exit 21:36:51)
- 테스트 구간 중·후 **신규 unbound variable 발생 0건**

### 2. 알림 단일 소유자 + 원자적 선점

- **소유자 1개 지정**: 성공 알림 = done-watcher-success.service (= done-watcher.py 데몬 모드)
- done-watcher.sh 의 성공 알림 블록 → DONE_WATCHER_SH_SUCCESS_NOTIFY=1 일 때만 동작(기본 비활성). **에스컬레이션은 게이트 밖에 그대로 존치**
- activity-watcher.py 의 성공 알림 → ACTIVITY_WATCHER_SUCCESS_NOTIFY=1 일 때만 동작(기본 비활성). **idle 전환 감지라는 고유 역할은 유지**
- 신규 공용 모듈 scripts/lib/notify_claim.py — claim_notification(O_EXCL 선점) / finalize_notification(성공 확정 + payload 기록) / release_notification(전송 실패 시 롤백). FileExistsError 외의 OSError 는 **fail-closed**(전송 금지 쪽)로 판정
- 4개 소비자 전부 **"선점 → 전송 → 확정/롤백"** 순서로 전환. 기존 "체크 → 전송 → 마커" 폐기

### 3. 방어층이 3겹임을 확인 (설계상 기대 이상)

라이브 검증 중 **done-watcher.sh 가 flock 으로 단일 인스턴스를 강제**한다는 사실이 확인됐다. 따라서 현재 중복 알림은 다음 3계층으로 막힌다:

1. **flock** — bash 워처의 동시 2인스턴스 자체를 차단
2. **단일 소유자 게이트** — 비소유자의 성공 알림 경로를 기본 비활성
3. **O_EXCL 선점** — 위 둘을 모두 우회해도 커널 레벨 원자성으로 승자 1명 보장

---

## 수정 파일별 검증 상태

| 파일 | 변경 내용 | grep 검증 | 상태 |
|---|---|---|---|
| scripts/lib/load-env-keys.sh | 공용 env source 래퍼 신규(export 접두 흡수 + chat_id 폴백) | grep "load-env-keys" OK | 완료 |
| scripts/lib/notify_claim.py | O_EXCL 원자적 선점 모듈 신규(claim/finalize/release, fail-closed) | grep "O_EXCL" OK | 완료 |
| scripts/activity-watcher-daemon.sh | activity-watcher systemd 실행 래퍼 신규 | grep "activity-watcher.py" OK | 완료 |
| scripts/done-watcher-oneshot.sh | done-watcher.sh oneshot 실행 래퍼 신규 | grep "done-watcher.sh" OK | 완료 |
| scripts/done-watcher.sh | 성공알림 게이트 + unbound 해소 + TOCTOU 방어 | grep "DONE_WATCHER_SH_SUCCESS_NOTIFY" OK | 완료 |
| scripts/activity-watcher.py | 성공알림 게이트(기본 비활성), idle 전환은 유지 | grep "ACTIVITY_WATCHER_SUCCESS_NOTIFY" OK | 완료 |
| scripts/done-watcher.py | 선점-우선 규약 적용(전송 실패 시 롤백) | grep "claim_notification" OK | 완료 |
| scripts/notify-completion.py | 선점-우선 규약 적용(체인/비체인 2경로) | grep "claim_notification" OK | 완료 |
| scripts/tests/test_notify_claim_single_owner.py | 회귀 테스트 신규 16건(선점/롤백/fail-closed/동시성/게이트) | grep "TestMultiProcessConcurrency" OK | 완료 |
| deploy/systemd/activity-watcher.service | EnvironmentFile 폐기 → 래퍼 ExecStart | grep "activity-watcher-daemon.sh" OK | 완료 |
| deploy/systemd/done-watcher.service | EnvironmentFile 폐기 → 래퍼 ExecStart | grep "done-watcher-oneshot.sh" OK | 완료 |

---

## L1 스모크테스트

3개 알림 유닛(done-watcher-success.service 상시 · done-watcher.timer→done-watcher.service 30초 주기 · activity-watcher.service 상시)이 **동시 가동 중인 상태**에서 실측했다.

- **서버 재시작**: 해당없음 — 유닛 재시작 없이 검증(지시서가 done-watcher.service 런타임 재시작을 보류로 규정). 라이브 데몬은 이미 수정본을 실행 중
- **API 응답 확인**: 해당없음(HTTP 서비스 아님). 대체 실증 = 실제 Telegram 발송 로그 원문
- **스크린샷**: 해당없음(프론트 표면 없음). 대체 증거 = logs/done-protocol.log 원문

### 검증 A — 배포 구성 그대로: PASS

memory/events/smoke-task-2962-notify-1.done 생성 → 95초 관측

```
[2026-08-16T21:32:35.735569+09:00] [done-watcher] [SUCCESS-NOTIFY] smoke-task-2962-notify-1.done: Telegram 알림 전송 시도
[2026-08-16T21:32:36.955260+09:00] [done-watcher] [SUCCESS-NOTIFY] smoke-task-2962-notify-1.done: 알림 전송 완료 → smoke-task-2962-notify-1.done.notified
```

- 알림 전송 완료: **정확히 1건** / .done.notified 마커: **정확히 1개**
- 이후 3회전 동안 추가 SUCCESS-NOTIFY **0건**(qc_hash WARN 로그만 반복)

### 검증 B — 적대적 재현: 결과 PASS, 단 경쟁 미재현

smoke-task-2962-notify-2.done 생성 직후 DONE_WATCHER_SH_SUCCESS_NOTIFY=1 로 done-watcher-oneshot.sh 2개를 동시 기동해 상시 데몬과 경쟁시켰다.

- 결과: 알림 전송 완료 **1건**, 마커 **1개** (중복 0)

```
[2026-08-16T21:34:37.294765+09:00] [done-watcher] [SUCCESS-NOTIFY] smoke-task-2962-notify-2.done: Telegram 알림 전송 시도
[2026-08-16T21:34:38.353002+09:00] [done-watcher] [SUCCESS-NOTIFY] smoke-task-2962-notify-2.done: 알림 전송 완료 → smoke-task-2962-notify-2.done.notified
```

- ★ **단, 이는 O_EXCL 이 막은 것이 아니다.** 수동 기동한 2개 인스턴스는 마침 정규 타이머 사이클이 쥐고 있던 flock 획득에 실패해 "already running" 으로 즉시 종료, **게이트 분기에 도달조차 하지 못했다.** 즉 이번 실행에서 실증된 것은 flock 계층이며, "게이트를 강제로 연 bash 워처가 데몬과 실제 경쟁하는" 시나리오는 재현되지 않았다.
- 이 공백은 아래 멀티프로세스 유닛 테스트로 **별도 보강**했다.

### 검증 C — done-watcher.sh 무결성 회귀: PASS

- 테스트 구간 신규 unbound variable **0건** (누적 662건은 전부 2026-08-15T01:48 이전 과거 이력)
- done-watcher.service 상태 조회 결과 Result=success

### 정리

smoke-task-2962-* 파일 4개(.done 2 + .notified 2) **전량 삭제 확인**. 무관한 기존 파일 23건은 불변.

---

## 테스트 결과

**신규 회귀 테스트** scripts/tests/test_notify_claim_single_owner.py — **16건 전건 PASS** (484줄, 실행 0.75초)

- 선점 / 재선점 차단 / 부모 디렉토리 자동생성
- 롤백 / 멱등 롤백 / payload 확정 기록
- PermissionError·일반 OSError **fail-closed**(예외 전파 없이 False)
- **스레드 20개 동시 → 승자 정확히 1명**
- **별도 OS 프로세스 20개(spawn) 동시 → 승자 정확히 1명** ← 검증 B 공백 보강
- 단일소유자 게이트 정적 검증(activity-watcher AST 기반, done-watcher.sh 텍스트 기반)
- 셸 문법 검증 / **에스컬레이션이 게이트 밖에 존치**되는지 회귀 고정
- flock 단일 인스턴스 가드 존재 회귀 고정

**테스트 자체의 유효성 실증(뮤테이션)**: claim_notification 을 의도적으로 비원자(존재 체크 후 쓰기)로 바꾼 사본에 멀티프로세스 테스트를 적용해 3회 반복 → 매회 **True 20개**(단언 위배 = FAIL 검출). 실제 O_EXCL 구현에서는 True 1 / False 19. 즉 이 테스트는 원자성 결함을 실제로 잡아낸다. 게이트 정적 검증도 send 호출을 게이트 밖으로 옮긴 합성 변형에서 FAIL 함을 확인했다.

**회귀 영향**: test_done_watcher_success_notify.py · test_done_watcher.py · test_done_protocol.py **전건 PASS**

**기존 실패(pre-existing)**: test_activity_watcher.py 11건 실패. t2962 변경을 걷어낸 HEAD 상태에서 **동일 11건이 동일하게 실패**함을 대조 확인 → t2962 기인 아님. 대표 원인: TestUpdateBotSince 가 "2026-03" 을 하드코딩 assert 하는데 실제 날짜가 2026-08-16.

---

## 발견 이슈 및 해결

### 자체 해결 (4건)

1. **로컬 main 2커밋 stale** — done-watcher-daemon.sh / test_done_watcher_success_notify.py 를 t2962 신규물로 오인할 뻔했으나 origin/main 기준 대조로 **task-2959 기산출물**임을 확정, 범위에서 제외.
2. **pre-commit start_task_guard 차단** — worktree 커밋이 ".tasks/locks/task-2962.lock missing" 으로 실패. 라이브 lock 파일을 worktree 동일 경로로 **복사**(라이브 미수정)해 통과.
3. **테스트 자체의 오탐** — 게이트 범위 계산 정규식이 .done 확장자와 [done-watcher] 로그 태그를 bash done 키워드로 오인해 범위를 line32~38 로 오산, 우연히 통과하던 상태였음. 마침표·하이픈 인접 제외로 수정해 실제 범위(line32~108)를 정확히 계산하도록 고치고, 에스컬레이션을 게이트 안으로 옮긴 합성 변형에서 **실제로 FAIL 함**을 확인해 유효성 입증.
4. **v3.6 harness 가 본 보고서 초안을 2회 차단(HOLD_FOR_CHAIR)** — 파일 타임스탬프 단서를 근거로 상태를 확정한 서술이 원인이었다. 타당한 지적이므로 해당 근거를 **프로세스 가동시간 직접 관측**(PID·기동시각·누적 가동초)과 **로그 바이트 크기 2회 대조**로 교체했다. 훅 우회는 시도하지 않았다.

### 범위 내로 인정한 추가 변경 (1건)

**done-watcher.sh TOCTOU 방어** — 지시서 항목에는 없으나 "task-2962 MT-7" 라벨이 붙은 hunk를 발견했다. find 로 잡은 .done 이 stat 직전 사라지면(.done.processing 이동 등) 산술식이 깨지고 set -euo pipefail 로 스크립트가 **즉사**해 뒤쪽 acked 정리·heartbeat·safety-net 이 통째로 스킵된다. 이는 3문서 목표 2번(전 구간 실행 복원)에 직결되므로 **범위 내로 판정해 포함**했다.

### 범위 외 미해결 (2건 — 회장 판단 필요)

1. **디스크 97%** (466G 중 430G 사용, 잔여 17G). 이번 버그의 부산물인 logs/activity-watcher.log 가 크래시 스팸으로 **418MB**. 서비스가 더 이상 쓰지 않으므로 정리 가능하나, 파괴적 조작이라 **임의 실행하지 않음**.
2. **ANU 폴백 118건** — 지시서가 범위 밖으로 명시(비활성 유지). 재활성 설계 메모는 아래에 남긴다.

---

## ANU 전용 워터마크 설계 메모 (재활성 대비 · 이번 task 범위 밖)

**문제**: fire_anu_followup_fallback 은 .anu-notified 마커 부재 + 5분 경과만 보고 발사한다. 워터마크가 **없다**. DISABLE_ANU_FOLLOWUP=1 을 제거하는 순간 **백로그 118건이 한꺼번에 extract_followup send 로 발사**된다(실측: 정규 task-N.done 204건 중 .anu-notified 없는 것 118건 — 유닛 주석의 118과 일치).

**설계 (task-2959 성공알림 워터마크와 동형)**:

- 성공알림용 .done-notify-watermark.json(epoch 1786764930.574566 = 2026-08-15T12:35:30+09:00)과 **별도 파일** .anu-followup-watermark.json 을 둔다. 공유 금지 — 두 축의 소급 정책이 다르고, 한쪽 부트스트랩이 다른 쪽을 조용히 건너뛰게 만든다.
- 부트스트랩 규약을 그대로 승계: 파일 부재 시 **현재 시각으로 생성 + 이번 주기 발사 0건**, JSON 손상 시 **재생성 + 이번 주기 생략**(스팸보다 미알림이 안전).
- 발사 조건: 파일 갱신 시점이 watermark.epoch 이후 **AND** .anu-notified 부재 **AND** 5분 경과. 워터마크는 백로그 차단용이고 마커는 중복 차단용 — 둘 다 필요하다.
- 발사 직전 .anu-notified 를 **O_EXCL 로 선점**(이번 task 의 notify_claim 재사용) 후 extract_followup 실행, 실패 시 release. 현재 구현은 마커를 언제 쓰는지 불명확해 재활성 시 중복 발사 경로가 열린다.
- 재활성 절차: (1) .anu-followup-watermark.json 을 **먼저 생성** → (2) DISABLE_ANU_FOLLOWUP=1 제거 → (3) daemon-reload + restart. 순서를 바꾸면 118건이 샌다.
- 소급이 필요한 경우에만 .anu-notified 백필(118건 마커 일괄 생성)을 **별도 승인 작업**으로 수행.

---

## 머지 결과 (종결 후 확정)

- **PR #271 MERGED** — main 반영 커밋 `9f0ec38e19210141c392277e53c81553ee24a953` (2026-08-16T13:17:27Z)
- 경로: required checks 7/7 PASS 충족 후 **자동 머지**. `merged_by` 는 소유자 계정으로 표기되나 이는 PAT 경유 자동머지이며 회장 직접 조작이 아니다.
- CI: 9 success / **2 failure**(`gemini-review-gate`, `phase3-merge-gate`). 두 체크는 **required 목록 밖**이며, 직전 머지된 PR #270(task-2959)도 동일하게 2건 failure 상태로 머지됐다 — 본 변경 기인이 아님을 대조로 확정.
- ANU 콜백: cron `0256AB5D` 등록·실행 완료(status=ok). 등록 검증기 **verdict=PASS**(schedule_id / schedule_history / owner_key / inbound_receipt 4종 전부 PASS).
- 중간 검증 단계에서 생성된 `task-2962.normal-callback-not-registered.json` 은 **오탐**이었다(schedule_history 는 cron 실행 후에야 생성되므로 등록 직후 조회에서는 필연적 FAIL). 재검증 PASS 확인 후 `.cleared` 로 정리했다.

## 머지 판단 (종결 시점 기준)

- **머지 필요**: Yes
- **브랜치**: task/task-2962-dev3
- **워크트리 경로**: /home/jay/workspace/.worktrees/task-2962-dev3
- **base**: 897eed8b (= origin/main, task-2959 머지본)
- **머지 의견**: 라이브에서 이미 30시간 이상 무중단 가동 중인 코드를 git 이력으로 확정하는 성격이라 **런타임 리스크가 사실상 없다**. 범위 밖 파일 혼입 0건을 커밋 통계로 확인했고, 신규 테스트 16건 전건 통과 + 기존 워처 테스트 3파일 전건 통과. test_activity_watcher.py 11건 실패는 HEAD 대조로 pre-existing 확정.

## 모델 사용 기록

- 팀장(다그다): Opus — 설계·범위 판정·검토·통합·보고서
- 루(Lugh, 백엔드): Sonnet — worktree 구성, 델타 검증, 커밋, 라이브 스모크
- 모리건(Morrigan, QA): Sonnet — 회귀 테스트 16건 작성·실행, 뮤테이션 유효성 실증, pre-existing 판별
- 브리짓(프론트)·아네(UX/UI): **미활성** — 프론트/UX 표면이 없는 시스템 인프라 작업
- haiku 미사용

## 비고

- 지시서의 "done-watcher.service 런타임 재시작 별도 판단(보류)" 는 그대로 유지했다. 다만 실측 결과 해당 유닛은 **타이머로 이미 30초마다 정상 완주 중**이므로, 보류 상태에서도 단일소유자 규약이 실효 중이다.
- systemd 유닛 정본은 deploy/systemd/ 에 둔다(3문서 계획서 기재 경로 및 기존 anu-pickup 관례와 일치). 설치 경로는 사용자 systemd 디렉토리이며 자동 동기화가 없으므로 README-watchers.md 에 반영 절차를 명문화했다.

## 세션 통계
- 총 도구 호출: 0회


## 세션 통계
- 총 도구 호출: 0회

