# task-2946 — dispatch 4096자 절벽 해소 (P0)

- **팀**: 개발1팀 (헤르메스)
- **일자**: 2026-08-13
- **merge_policy**: `local_only` (머지는 ANU)
- **repo**: `/home/jay/workspace` (메타인프라, main 작업트리 = task-2942 dirty 라이브 상태)

## 1. 근본 원인 (재확인)

봇 전달 채널(`cokacdir --cron`)은 프롬프트가 **4096자**(문자수, 바이트 아님)를 넘으면
**silent drop** 한다. cron 등록은 `ok` 를 반환하므로 dispatch 는 성공으로 오인하고
봇은 뜨지 않는다(= false-OK).

- task-2945 실측: 4,000자 / 11,764B **성공** · 4,239자 / 6,768B **실패** → 판정 기준은 문자수
- `build_prompt` 가 이미 4,040자대 → task-2942 마커 지시 **197자**가 dev1~7 을 절벽 밖으로 밀어냄
- dev8 은 4,432자로 **선행 초과**(task-2942 무관)

## 2. 조치 (surgical, 3건)

### 2-1. 마커 이전: 프롬프트 주입 → 봇 부팅 훅

| 항목 | 내용 |
|---|---|
| 제거 | `prompts/team_prompts.py` `_build_spawn_confirmed_block` 함수 + 주입부(구 856행) |
| 신규 | `hooks/spawn_confirmed_boot_hook.py` (UserPromptSubmit 훅) |
| 결선 | `scripts/ensure-bot-memory.sh` 최상단에서 위임 |

**부팅 훅 경로가 실재함을 확인**했다. 전역 `~/.claude/settings.json` 의 UserPromptSubmit
체인에 이미 `bash /home/jay/workspace/scripts/ensure-bot-memory.sh` 가 등록돼 있다 —
**workspace 소유 + 이미 등록된 유일한 훅**이므로, 등록 지점(workspace 밖)을 건드리지 않고
결선할 수 있었다. 로직은 전용 모듈이 소유하고 쉘 스크립트는 3줄 위임만 한다.

부수 효과(의도된 개선): 기존 방식은 "봇이 지시를 읽고 첫 명령으로 실행"에 의존했으나,
훅은 세션이 프롬프트를 받는 시점에 무조건 발화하므로 **판정이 봇의 순종도와 무관**해진다.

**false-positive 방지(중요)**: 이 훅은 회장 채팅을 포함한 *모든* 세션에서 돈다. 단순히
`task-NNNN` 문자열만 보고 마커를 쓰면 회장이 task id 를 언급만 해도 dispatch 가 "봇이 떴다"고
오판한다. 따라서 dispatch 프롬프트 지문 **2개가 모두** 있을 때만 마커를 쓴다:
`memory/tasks/<task_id>.md` **AND** `collector_role=ANU`.

**경량화**: 최초 구현은 `from dispatch.spawn_verification import ...` 를 썼는데, 이는 dispatch
패키지 `__init__` 전체(.env.keys 로드 + 로거 설정 + 수십 모듈)를 **모든 세션의 모든 프롬프트마다**
실행시켰다(171ms + stderr 오염). `spawn_verification` 은 stdlib 전용 설계이므로 파일 경로 직접
로드로 교체 → **35ms · stdout 0B · stderr 0B · dispatch 패키지 미로드**.

### 2-2. 4096자 fail-closed 가드

`dispatch/__init__.py` 에 `check_prompt_length()` 추가, `cokacdir --cron` 발사 **직전**
2곳(일반 + composite) 모두 결선. 초과 시 조용한 drop 대신 명시적 error 반환:

```
{"status": "error", "error_code": "PROMPT_TOO_LONG",
 "prompt_chars": N, "prompt_limit": 4096, "task_id": ..., "team_id": ...}
```

- 판정은 **문자수**(`len`). 한글 4,000자(12,000B)는 통과해야 하므로 바이트 판정은 오차단이 된다
- `DISPATCH_PROMPT_MAX_CHARS` env 로 상한 **상향**은 가능하나, `0`/음수/파싱불가로 **끄는 것은 불가**(fail-closed 유지)
- 차단 시 `_cleanup_task()` 호출 — 기존 타임아웃 경로와 동일 패턴

### 2-3. 감량

| 대상 | 전 | 후 | 절감 |
|---|---|---|---|
| self_collector doctrine (전 레벨 공통) | 1,409 | 909 | −500 |
| Edit직후grep + L1 스모크 3블록 (공통) | ~460 | ~250 | −210 |
| 3문서 활용 지침 (Lv.3+) | 737 | 435 | −302 |
| dev8 MCP tool 목록 verbose | — | — | −224 |
| Sanitize 게이트 중복 (Lv.3+) | 2회 | 1회 | −240 |
| 마커 블록 | 197 | 0 | −197 |

**보존 원칙**: 계약 검증 토큰과 테스트가 verbatim 검증하는 문자열은 그대로 두고 산문만 줄였다.

- `spawn_callback_contract_validator` 요구: `SELF_COLLECTOR` / `SENDFILE_ONLY` /
  `NOT_REGISTERED` / `collector_role=ANU` / ANU_KEY `c119085addb0f8b7` → **전부 유지**
- producer contract 필드명(`callback_schedule_created` / `relay_hints` 4종 / `report_path`) → **전부 유지**
- L1 기록 형식(`서버 재시작: [`, `API 응답 확인: [`, `스크린샷: [`, `불완전 처리`) → **전부 유지**
  (압축 1차본이 이 토큰을 깨뜨려 `tests/test_team_prompts.py` 가 잡아냈고, 즉시 복원함)

**중복 Sanitize 처리**: 텍스트를 `team_prompts.SANITIZE_GATE_BLOCK` 단일 정의로 모으고,
dispatch 는 **부재할 때만** append 한다. 단순 삭제하지 않은 이유 — `task_type=research/check`
경로는 `build_prompt` 가 검증 섹션을 생략하므로, 삭제하면 그 경로에서 지시가 **유실**된다.

## 3. 계측 결과

재현: `python3 scripts/measure_dispatch_prompt_sizes.py`
(실제 ANU key 대신 길이 동일 더미 사용 — 계측 오차 0, 비밀 미노출)

### normal 레벨 (기본 · 목표 달성)

| 팀 | 전 | 후 | 여유 |
|---|---|---|---|
| dev1-team | 4,238 | **3,405** | 691 |
| dev2-team | 4,234 | **3,401** | 695 |
| dev3-team | 4,263 | **3,430** | 666 |
| dev4-team | 4,241 | **3,408** | 688 |
| dev5-team | 4,237 | **3,404** | 692 |
| dev6-team | 4,235 | **3,402** | 694 |
| dev7-team | 4,239 | **3,406** | 690 |
| dev8-team | 4,629 | **3,630** | 466 |
| marketing | 3,788 | 2,955 | 1,141 |
| consulting | 3,623 | 2,789 | 1,307 |
| publishing | 3,629 | 2,795 | 1,301 |
| design | 3,814 | 2,984 | 1,112 |
| content | 3,620 | 2,789 | 1,307 |

→ **dev1~8 전원 절벽 아래. 최소 여유 466자(dev8)**. dev8 선행 초과도 해소됐다.

### ★ Lv.3+ (critical/security) — 신규 발견, 미해소

| 팀 | critical | security | 초과 |
|---|---|---|---|
| dev1~7 | 4,642~4,671 | 4,645~4,674 | +546~578 |
| dev8 | **4,871** | **4,874** | **+775** |
| marketing | 4,196 | 4,199 | +100~103 |
| design | 4,225 | 4,228 | +129~132 |
| consulting/publishing/content | 4,030~4,036 | 4,033~4,039 | 통과 |

**이는 task-2942/2945 3자 합의가 포착하지 못한 사실이다.** 감량 전 Lv.3+ 는
5,722~6,116자였고(+중복 sanitize 240), 감량 후에도 dev1~8·marketing·design 이 여전히 초과다.
즉 **Lv.3+ dispatch 는 task-2942 이전부터 이미 전부 silent drop 되고 있었을 개연성이 높다.**

가드 결선으로 이제 이 경로는 **조용히 실패하지 않고 `PROMPT_TOO_LONG` 으로 명시 차단**된다
(가시화 = 개선, 다만 Lv.3+ 위임은 감량 전까지 차단됨).

## 4. 검증

- 신규 `tests/regression/test_dispatch_prompt_4096_guard_2946.py` — **27 PASS**
  (경계 4096/4097, 문자수≠바이트, fail-closed env 4종, 발사 2경로 결선, 팀별 상한+마진 200, sanitize 1회)
- 갱신 `tests/regression/test_dispatch_spawn_verification_2942.py` — **29 PASS**
  (구 "프롬프트 주입" 단언 2건 → 부팅 훅 결선/지문/false-positive 방지 4건으로 교체)
- 부팅 훅 실호출: 마커 생성 → `_verify_bot_spawn` = `SPAWNED` 왕복 확인, 일반 대화는 미생성
- 전체 회귀: 아래 §5

## 5. 회귀 판정 — **내 변경에 귀속되는 회귀 0건**

비교 기준선: `git worktree add --detach /tmp/wt2946base HEAD` (clean HEAD, CI parity).
main 작업트리는 task-2942 등이 dirty 상태로 이미 라이브이므로, HEAD 기준선과
현재 트리를 각각 전량 실행해 실패 노드 ID 집합을 비교했다.

| 실행 | 결과 |
|---|---|
| 최종 (현재 트리) | 6,185 passed / 256 failed / 48 errors |
| 기준선 (clean HEAD) | 5,979 passed / 237 failed / 213 errors |

집합 차이 44건의 귀속을 전수 확인했다:

| 항목 | 건수 | 귀속 |
|---|---|---|
| `test_dispatch.py` / `test_dispatch_workflow.py` | 38 | **사전 결함**. 단독 실행 시 현재 트리 82 failed/108 passed/7 errors = clean HEAD **완전 동일**. 기준선에서는 collection ERROR 로 집계돼 노드 ID 가 안 잡힌 집합 차이 아티팩트 |
| `test_finish_task_worktree_isolation_2726.py` (4) | 4 | **타 작업 dirty**. `scripts/finish-task.sh` · `scripts/worktree_manager.py` 가 작업트리에서 이미 수정된 상태(내 변경 아님) |
| `test_task_1044_1_archive_title_time.py::test_uses_task_timers_end_time` | 1 | **무관 — task-2941 데이터/타임존 포맷**. 실패 단언은 `task-2941.md` 의 `2026-08-13T09:29:38+09:00`(대시보드) vs `...38.849140`(task-timers.json) 불일치다. 내 변경 파일(`dashboard/server.py`·`task-timers.json` 포맷·task-2941)과 교집합 0. clean HEAD 에서 통과한 이유는 해당 보고서 레코드가 HEAD 에 없어 상위 10건에 들지 않았기 때문 |
| `test_regression.py::...test_new_id_is_higher_than_existing` | 1 | **사전 결함** (clean HEAD 에서도 실패) |

★ 작업 도중 **내 변경이 실제로 깨뜨린 회귀 2건을 발견·수정**했다(은폐하지 않음):
`tests/test_team_prompts.py::TestThreeDocsSection` 4건 + `tests/test_3docs_e2e.py` 1건 —
3문서 압축이 `memory/plans/tasks/<id>/plan.md` 전체 경로와 `팀장 의무`,
`보고서 작성 전 3문서 업데이트` 문자열을 깨뜨렸다. 압축 형태를 바꿔 문자열을 복원했고
(Lv.3+ 가 59자 늘었으나 normal 은 불변) 재실행 PASS 확인했다.
`tests/test_3docs_e2e.py::TestDispatchCreateTaskDocs` 3건은 clean HEAD 에서도 실패하는
별개 사전 결함(`tests/dispatch/` shadow 패키지)이다.

## L1 스모크테스트

- 서버 재시작: 해당없음 (메타인프라 라이브러리/훅 변경, 데몬 없음)
- API 응답 확인: 해당없음
- 스크린샷: 해당없음
- 실동작 확인: 부팅 훅 실제 stdin 주입 실행 → 마커 파일 생성 확인 →
  `_verify_bot_spawn` 이 `SPAWNED` 판정. 계측기 실행으로 팀별 문자수 실측.

## 6. 회장 결정 필요

1. **Lv.3+ 절벽 잔여** — dev8 기준 −716자 추가 감량이 필요하다. 남은 감량 후보는
   QC/게이트 지시 본문(인지 검증 552자, 게이트 지시 437자, Codex 사전 검증 230자)뿐이라
   **내용 삭감 = 품질 게이트 약화**다. 기계적 압축으로는 더 못 줄인다. 선택지:
   (a) 인지 검증 블록을 `QC-RULES.md` 참조로 이전(−552, 전 레벨 적용) → Lv.3+ 도 통과
   (b) Lv.3+ 위임을 당분간 차단 상태로 두고 수동 분할
   (c) 채널 상한 자체를 올릴 수 있는지 cokacdir 측 확인
2. **마커 이전 방식 승인** — 등록 지점(`~/.claude/settings.json`)을 건드리지 않기 위해
   `ensure-bot-memory.sh` 에 위임 3줄을 넣었다. 관심사 혼합이 마음에 걸리면 별도 훅으로
   등록하는 방식(= settings.json 수정, repo 밖 변경)으로 바꿀 수 있다.
3. **task-2942 재제출 가부** — 마커 지시가 프롬프트에서 빠졌으므로 2942 의 절벽 리스크는
   해소됐다. 2942 의 나머지(대기시간 기본 25s 등)는 별건으로 남아 있다.
4. **머지** — `merge_policy=local_only` 준수. 커밋/머지는 ANU 몫.

## 7. 변경 파일

| 파일 | 구분 |
|---|---|
| `hooks/spawn_confirmed_boot_hook.py` | 신규 |
| `tests/regression/test_dispatch_prompt_4096_guard_2946.py` | 신규 |
| `scripts/measure_dispatch_prompt_sizes.py` | 신규 (계측기) |
| `prompts/team_prompts.py` | 수정 (마커 제거 · 감량 · sanitize 단일정의) |
| `dispatch/__init__.py` | 수정 (가드 + 발사 2경로 결선 · sanitize 중복방지) |
| `scripts/ensure-bot-memory.sh` | 수정 (부팅 훅 위임 3줄) |
| `tests/regression/test_dispatch_spawn_verification_2942.py` | 수정 (신 결선 반영) |
