docs(issue-5): phase 2b implementation plan (flag-gated auto-completion)

An executable plan for P2B-01..03, grounded in measurement of what phase 2a
actually left behind rather than in restating the contracts.

The design turns on one question: who writes the completion manifest. If the
watcher computes the hash itself, the manifest only attests that the watcher saw
a stable file, which is the self-certification Codex rejected as R1 last round.
So the side that knows it finished declares it, and the watcher re-derives the
hash rather than trusting the declaration.

That choice makes P2B-03's "preserve the explicit path completely" fall out for
free instead of needing a branch: an agent that declares nothing leaves no
manifest, and a missing manifest already holds today. Verified against the
current policy.

Four measurements shaped the rest. Nothing in the tree writes or reads a
manifest, so the protocol is new in full. Records carry iteration but never
attempt_id, and the engine silently substitutes one for the other -- they agree
today only by coincidence of the wrapper's flow, and the auto-completion gate
hangs off that value, so the plan asks for an explicit decision and reviewer
sign-off before any code is written. The pending-notification contract forbids a
separate outbox, so the intent rides in the record the transaction already
writes. And only three backends have observed evidence of a quiescent state, so
the allowlist has a factual basis instead of an optimistic one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd
This commit is contained in:
GodopuandClaude Opus 5 committed 2026-09-06 21:24:04 +09:00
1 parent 4a5916a736
commit 5eb9524fe7
1 file changed
+251
@@ -0,0 +1,251 @@
# 📑 Issue #5 Phase 2b 구현 계획서 — 이중 감지 자동완료 (Flag-Gated / Opt-In)
- **Job**: `df85e978` / **Planner**: `creator-claude-01`
- **기준 커밋**: `4a5916a` (branch `refactor`, working tree clean) / Python `3.9.6` / herdr `0.8.2` / **전체 테스트 664**
- **요구 출처**: `implementation_plan.md` §2 Phase 2b 행, `task.md` P2B-01~P2B-03, `state-transitions.md` §3~§6
- **문서 성격**: Worker가 그대로 실행할 수 있는 단계별 명세. §1의 모든 인용은 라이브 코드·실행으로 직접 확인했다.
---
## 0. 요약 (TL;DR)
1. **매니페스트를 쓰는 주체가 이 Phase의 전부다.** 워처가 스스로 해시를 계산해 매니페스트를 만들면 그것은 "워처가 안정된 파일을 봤다"는 자기 증명일 뿐이며, Round 2에서 Codex가 R1으로 기각한 바로 그 형태다. **끝났다는 사실을 아는 쪽(에이전트)이 확정을 선언해야 한다**(§D1).
2. **그래서 미지원 에이전트는 자동으로 fail-closed가 된다.** 확정 선언을 하지 않으면 매니페스트가 없고, 매니페스트가 없으면 `policy`가 이미 `hold`한다(Phase 2a에서 구현·테스트 완료). P2B-03의 "미지원 경로 100% 보존"은 새 분기가 아니라 **기존 동작의 자연스러운 귀결**이다.
3. **`attempt_id`와 `iteration`의 의미 충돌을 먼저 정리해야 한다.** 엔진은 `record.get("attempt_id", record.get("iteration"))`로 대체하는데, 레코드에는 `attempt_id`가 **없고** `iteration`만 있다(실측). 지금은 우연히 값이 겹쳐 동작하지만 두 개념은 다르며, 자동완료 게이트가 이 값에 걸리므로 **명시적 결정 없이 진행하면 안 된다**(§D2).
4. **지연 알림은 새 저장소를 만들지 않는다.** `state-transitions.md` §5가 "독립 Outbox DB 신설 금지"를 명시했고, 레코드 안 `pending_notification` 필드를 같은 CAS 트랜잭션에서 쓴다(§D4).
5. **자동완료 허용 백엔드는 실측 증거가 있는 것으로 한정한다.** Phase 0 능력 행렬 실측: 정지 상태(`idle`/`done`) 관측 증거가 있는 것은 **claude·grok(idle), codex(done)** 뿐이고 **agy·hermes·opencode는 없다**(§1-5). 플래그가 켜져도 이 셋 외에는 자동완료하지 않는다.
6. **664개 무회귀 + 신규.** Phase 2a와 동일하게 특성화 선행 → 구현 → 문서 순으로 커밋 5개.
---
## 1. Planner 사전 실측
### 1-1. 자동완료에 필요한 것 중 **존재하지 않는 것**
| 필요 | 현황 (실측) |
|---|---|
| 완료 매니페스트 | **없음.** 저장소 어디에도 매니페스트를 쓰거나 읽는 프로덕션 코드가 0건. 리포트 디렉터리에는 `report-final.md` 하나뿐 |
| 리포트 원자적 확정 | **없음.** 에이전트가 `report-final.md`를 직접 쓴다. tmp+rename 없음 |
| `attempt_id` 레코드 필드 | **없음.** 레코드에는 `iteration`만 존재 |
| `pending_notification` | **없음.** `grep` 0건 |
| `MAM_AUTO_COMPLETE` | **없음.** `grep` 0건 (참고: `MAM_ENGINE_CAS` 5건, `MAM_DELEGATION_PY` 4건은 존재) |
### 1-2. 이미 준비되어 있는 것 (Phase 2a 산출물)
| 있음 | 위치 |
|---|---|
| 단일 CAS 전이 경계 + 권한 축 | `lib_py/delegation/engine.py::transition` |
| `expected_attempt` fail-closed | 레코드가 attempt를 못 대면 거부 (Round 1 R2) |
| 관측 증거 **필수** 판정 | `lib_py/loop/policy.py` — 4식별자 + 매니페스트 `attempt_id` 부재 시 `hold` (Round 2 R1) |
| 매니페스트 미확정 시 `hold` | `policy.decide`의 `manifest_committed`/`hash_verified` 게이트 |
| `herdr agent wait` 래핑·백오프 | `lib_py/loop/watcher.py` |
| 쓰기 경로 없는 shadow 러너 | `lib_py/loop/shadow.py` |
| 지연 알림의 순서 보장 기반 | `transition_seq` + per-job 감사 락 (Round 2 R4) |
**즉 Phase 2b는 판정기를 새로 만드는 것이 아니라, 이미 fail-closed로 판정하고 있는 게이트에 "진짜 증거"를 공급하고 그 결과를 커밋에 연결하는 작업이다.**
### 1-3. `attempt_id` vs `iteration` — 실행으로 확인한 충돌
```
record keys with attempt semantics: {'iteration': 1}
transition(completed, expected_attempt=1) -> committed=True running -> completed
transition(completed, expected_attempt=2) -> committed=False stale attempt: expected 2, record has 1
```
엔진은 `attempt_id`가 없으면 `iteration`을 쓴다. 래퍼의 loop 흐름에서는 `redispatch`가 `iteration`을 증가시키므로 두 값이 우연히 일치하지만, `iteration`은 **루프 회차**이고 `attempt_id`는 **디스패치 시도**다. 직접(`--type direct`) 재디스패치나 크래시 후 재시도에서 갈라진다. 자동완료 게이트가 이 값에 걸리므로 §D2에서 명시적으로 결정한다.
### 1-4. 미지원 경로 보존의 실제 메커니즘
`job_subscriber._check_disk_fallback`(`:60-72`)은 레지스트리 status를 읽어 terminal이면 즉시 종료한다. 즉 **자동완료가 `completed`를 쓰면 래퍼는 명시 이벤트와 구별하지 못한다.** 이는 의도된 결합이지만, 동시에 "자동완료가 틀리면 래퍼가 조용히 잘못된 완료를 받는다"는 뜻이므로 §D6의 감사 표식이 필수다.
### 1-5. 자동완료 허용 백엔드 (Phase 0 능력 행렬 실측)
```
claude observed slots: ['idle']
agy observed slots: ['working', 'wait_timeout']
hermes observed slots: NONE
grok observed slots: ['idle']
opencode observed slots: NONE
codex observed slots: ['done']
```
자동완료는 **정지 상태 관측**을 전제로 한다. `idle`/`done` 증거가 있는 **claude·grok·codex**만 후보이며, `agy`는 `working`만 관측돼 정지 판정의 실측 근거가 없고, `hermes`·`opencode`는 관측 0건이다.
### 1-6. 에이전트 지시문 주입 지점 (조정 비용)
`create_session.sh:483`과 래퍼 `:202`/`:319`가 `publish_event.py --job <id>`를 지시문에 넣는다. 확정 선언 명령을 추가하려면 **이 두 곳만** 바꾸면 된다.
---
## 2. 설계 결정
### D1. 매니페스트는 **끝난 쪽이 선언한다** (P2B-01의 핵심)
```
에이전트: 작업 종료 시
publish_event.py --job <id> --finalize-report <path> [--side-effect <kind>=<evidence>] ...
① <path>.tmp 를 <path> 로 원자적 rename (이미 최종본이면 rename 생략)
② SHA-256 계산
③ <report_dir>/report-manifest.json 원자적 기록:
{ schema_version, job_id, attempt_id, report_path, sha256,
finalized_at, side_effects: [...], declared_by }
```
- **워처는 매니페스트를 만들지 않는다.** 만들면 자기 증명이 되어 Round 2 R1에서 기각된 형태가 된다. 워처는 **읽고 검증만** 한다(해시 재계산 후 대조).
- **미지원 에이전트는 아무것도 하지 않으면 된다** — 매니페스트가 없으면 `policy`가 이미 `hold`한다. P2B-03의 "explicit event 경로 100% 보존"이 **새 코드 없이** 성립한다.
- 리포트가 재작성되면 해시가 어긋나 매니페스트가 즉시 무효가 된다(§4 계약 그대로).
### D2. `attempt_id`를 레코드의 1급 필드로 승격 (결정 필요 사항)
**권고: 승격한다.** 근거:
- 엔진의 `expected_attempt`는 이미 "레코드가 attempt를 대지 못하면 거부"로 fail-closed다(Round 1 R2). 자동완료가 이 게이트를 통과하려면 레코드가 attempt를 대야 한다.
- `iteration` 대체는 우연한 일치에 의존한다. 두 개념이 갈라지는 순간 **CAS 가드가 조용히 무력화**된다.
구현:
- `register_job`이 `attempt_id: 1`을 기록. `engine`의 `redispatch`가 `iteration`과 함께 `attempt_id`를 증가.
- 엔진의 `record.get("attempt_id", record.get("iteration"))` 대체를 **제거**하고 `attempt_id`만 본다(대체는 의미 혼동의 원천).
- **스키마 영향**: 필드 추가이므로 기존 리더는 무시하면 되고 `SCHEMA_VERSION`은 1을 유지한다. 다만 Phase 1이 "레코드 필드 불변"을 계약으로 명시했으므로 **이 계획서가 그 계약의 변경을 명시적으로 요청**한다. 리뷰어 승인 없이 진행하지 않는다.
- 마이그레이션: `attempt_id`가 없는 기존 레코드는 `iteration` 값으로 1회 채운다(읽기 시 lazy, 쓰기는 다음 전이에서).
### D3. 자동완료 게이트 (기존 판정기에 증거를 공급)
`shadow.ShadowRunner`를 `AutoCompleteRunner`로 확장하되 **판정 로직은 손대지 않는다**. 순서:
1. `watcher.wait_once`로 정지 상태 관측 → `collector_attribution` 봉투에 4식별자를 채워 증거 구성.
2. `report-manifest.json` 읽기 → 해시 **재계산 후 대조**(매니페스트 자기 신고를 신뢰하지 않는다) → `report_manifest.attempt_id`를 증거에 포함.
3. `policy.decide` → `candidate_approved`가 아니면 종료(로그만).
4. `candidate_approved`면 **게이트 3중 확인 후** `engine.transition(job_id, "completed", expected_attempt=<attempt>, source="auto_complete")`.
- 게이트: 플래그 ON + 백엔드 허용목록(§1-5) + 잡별 opt-in.
5. 커밋 결과가 `committed=False`면 **아무 일도 없었던 것으로 처리**하고 감사에 기록(늦은 conflict는 엔진이 이미 거부한다 — 새 코드 불필요).
### D4. 지연 알림 (P2B-02) — 레코드 안 outbox
`state-transitions.md` §5가 **독립 Outbox DB 신설을 금지**했다. 따라서:
- `engine.transition`이 커밋할 때 같은 락 안에서 `pending_notification: {event, seq, timestamp, signature}`를 레코드에 **함께** 기록한다(추가 I/O 없음 — 이미 쓰는 레코드에 필드 하나).
- 락 해제 후 `notifier.flush(job_id)`가 MQTT 발행 → 성공 시 `pending_notification`을 지우는 **별도 CAS 전이**(`request="notification_settled"`, 상태 불변).
- **재시작 복구**: 기동 시 `pending_notification`이 남은 레코드를 스캔해 재발행. 멱등하다 — 구독자가 `seq`로 중복을 거른다(기존 replay 방어).
- **락 규율 유지**: 발행은 절대 락 안에서 하지 않는다(Phase 2a G5 테스트가 계속 감시).
### D5. 부수효과 증거 (P2B-01 후반)
- 레코드의 기존 `expected_artifacts`(이미 존재, `--artifact`로 설정)를 **선언된 부수효과 목록**으로 사용한다. 새 필드를 만들지 않는다.
- `expected_artifacts`가 비어 있지 않은 job은 매니페스트의 `side_effects`가 **모든 항목을 덮어야** 자동완료 후보가 된다. 하나라도 증거가 없으면 `hold`.
- 증거 형식은 `{kind, reference, verified_at}`이며, Phase 2b는 **존재와 대응만** 검증한다(원격 확인은 비범위 — 정직하게 명시).
### D6. 플래그와 감사 표식 (P2B-03)
| 스위치 | 기본 | 범위 |
|---|---|---|
| `MAM_AUTO_COMPLETE` | **`0` (OFF)** | 전역 킬 스위치. `1`이어야 자동완료가 가능 |
| 백엔드 허용목록 | `claude,grok,codex` | §1-5 실측 근거. `MAM_AUTO_COMPLETE_BACKENDS`로 좁힐 수만 있게(넓히려면 증거 필요) |
| 잡별 opt-in | 레코드 `auto_complete: true` | 셋 다 참일 때만 커밋 |
**감사 표식 필수**: 자동완료로 들어간 terminal은 `completed_by: "auto_complete"`와 판정 근거(매니페스트 해시, 관측 seq)를 감사 로그에 남긴다. §1-4에서 확인했듯 래퍼는 자동/명시를 구별하지 못하므로, **사후에 구별할 수 있는 유일한 수단**이다.
### D7. 무판정 fail-closed
정책이 예외를 던지거나 알 수 없는 결정을 반환하면 `hold`로 취급한다. 자동완료 경로 전체를 `try/except`로 감싸 **어떤 실패도 커밋으로 이어지지 않게** 한다. "판정 못 함"은 절대 "완료"가 아니다.
### D8. 트리·권한 규칙 승계
- `lib_py.loop`는 `lib_py.delegation` 외 형제 임포트 금지(Phase 2a G9).
- 자동완료의 `source`는 **`auto_complete`** 이며 운영자 권한이 **아니다** — terminal을 되돌릴 수 없다.
- `preexec_fn`: 알림 재발행기가 스레드를 쓰면 Phase 1 인계 게이트에 걸린다. **2b는 데몬을 스폰하지 않고** 기존 구독자 프로세스 안에서 flush한다. 스폰이 필요해지면 그때 `preexec_fn`을 교체한다.
---
## 3. 작업 분해 (WBS) — 커밋 5개
### P2B-00 특성화 선행 (커밋 1: `test(issue-5): phase 2b pre-conditions and fail-closed characterization`)
- 현행 고정: 매니페스트 없는 job은 `hold`(이미 참), `MAM_AUTO_COMPLETE` 부재 시 자동완료 0건, `attempt_id` 부재 시 `expected_attempt`가 거부, 미지원 백엔드 목록.
- **완료 기준**: 레거시에서 전부 통과, 664 + 신규 통과.
### P2B-01 확정 계약 (커밋 2: `feat(issue-5): report finalization manifest and side-effect evidence`)
- **파일**: `lib_py/loop/finalize.py`(매니페스트 작성·검증), `publish_event.py`에 `--finalize-report`/`--side-effect`, `create_session.sh:483`·래퍼 `:202/:319` 지시문 1줄 추가.
- **테스트**: tmp→rename 원자성, 해시 재계산 대조(위조 매니페스트 거부), 리포트 재작성 시 무효화, `expected_artifacts` 미충족 시 `hold`, 매니페스트 부재 시 `hold`(불변).
### P2B-02 CAS 연동 + 지연 알림 (커밋 3: `feat(issue-5): auto-complete gate and durable pending notification`)
- **파일**: `lib_py/loop/autocomplete.py`, `lib_py/delegation/notifier.py`, `engine`에 `pending_notification` 기록 및 `notification_settled` 요청.
- **테스트**: 게이트 3중 확인, 늦은 conflict 거부(엔진 기존 동작), 크래시 후 재시작 재발행, 중복 발행 멱등, **락 안 발행 0건**(Phase 2a G5 재사용), `attempt_id` 불일치 시 거부.
### P2B-03 플래그·허용목록 (커밋 4: `feat(issue-5): default-off auto-complete flag with an evidence-backed backend allowlist`)
- **파일**: 플래그 3종, `completed_by` 감사 표식.
- **테스트**: 기본 OFF에서 자동완료 0건이며 명시 경로 결과가 **바이트 동일**, 허용목록 밖 백엔드는 플래그 ON에서도 미커밋, 잡별 opt-in 미설정 시 미커밋, 자동완료 terminal에 `completed_by` 존재.
### P2B-04 시나리오·문서 (커밋 5: `docs(issue-5): phase 2b contracts, scenarios and task checklist`)
- 경쟁/재시작/부분 리포트/부수효과 미충족/무판정 시나리오를 Phase 0 스키마로 추가(기존 `validate_watcher_scenario` 통과 필수).
- `state-transitions.md` §4·§5 구현 매핑, `task.md` P2B-01~03 증거, 운영 노트(`phase2b-autocomplete.md`).
---
## 4. 게이트
| # | 조건 | 측정 |
|---|---|---|
| G1 | 기존 **664** 무회귀 + 신규 통과, skipped 0 | `pytest tests/ -q` |
| G2 | 셸 문법·공백 | `bash -n`, **범위** `git diff --check` |
| G3 | Python 3.9 컴파일 | `compileall` |
| **G4** | **기본 OFF에서 자동완료 0건** | 플래그 미설정 시 레지스트리 전후 해시 동일, 명시 경로 결과 바이트 동일 |
| **G5** | **매니페스트 위조 거부** | 해시 불일치·attempt 불일치·부재 전부 `hold` |
| G6 | 락 안 I/O 0건 | Phase 2a G5 감시 테스트 확장(발행 포함) |
| G7 | 재시작 복구 | 크래시 시뮬레이션 후 `pending_notification` 재발행 및 멱등 |
| G8 | 롤백 | `MAM_AUTO_COMPLETE=0` 즉시 중단, 이미 확정된 terminal은 보존 |
| G9 | 허용목록 | 증거 없는 백엔드는 플래그 ON에서도 미커밋 |
---
## 5. 롤백
1. **즉시**: `MAM_AUTO_COMPLETE=0`. 신규 자동완료만 중단되고 **이미 확정된 terminal 레코드는 보존**한다(`implementation_plan.md` Phase 2b 롤백 조건 그대로).
2. `MAM_ENGINE_CAS=0`은 Phase 2a 경로까지 함께 되돌린다(자동완료는 CAS 위에 있으므로 자동으로 무력화).
3. 커밋 단위 revert는 5→2 순. 커밋 1(특성화)은 남겨도 무해.
---
## 6. 리스크
| # | 리스크 | 완화 |
|---|---|---|
| R1 | 워처가 스스로 매니페스트를 만들어 자기 증명이 됨 | D1: 작성 주체를 에이전트로 고정, 워처는 해시 재계산 대조만 |
| R2 | `attempt_id`/`iteration` 혼동으로 CAS 가드 무력화 | D2: 승격 + 대체 제거. **리뷰어 승인 전 착수 금지** |
| R3 | 잘못된 자동완료를 래퍼가 구별 못 함 | D6 `completed_by` 감사 표식 + 기본 OFF + 허용목록 |
| R4 | 지연 알림이 새 저장소를 만들어 계약 위반 | D4: 레코드 내 필드, §5 명시 준수 |
| R5 | 알림 재발행기가 스레드를 써 `preexec_fn` 게이트에 걸림 | D8: 2b는 스폰 없음 |
| R6 | 부수효과 "검증"을 과대 주장 | D5: 존재·대응만 검증한다고 문서에 명시, 원격 확인은 비범위 |
---
## 7. 명시적 비범위
- `run_loop.sh` 파이썬화 — Phase 3.
- 부수효과의 **원격** 확인(실제 git push 도달 여부 등) — 증거 대응까지만.
- `agy`/`hermes`/`opencode` 자동완료 — 정지 상태 실측 증거 확보 후 별도 판단.
- `preexec_fn` 교체 — 스폰이 필요해지는 시점.
- 스키마 v2 — `attempt_id` 추가는 v1 유지(추가 필드).
---
## 8. Worker 인수인계 체크리스트
- [ ] **착수 전**: §D2(`attempt_id` 승격 = Phase 1 스키마 계약 변경)에 대한 **리뷰어 승인**을 받을 것
- [ ] P2B-00 커밋 1: 현행 fail-closed 특성화가 레거시에서 통과
- [ ] P2B-01 커밋 2: 매니페스트 작성/검증, 해시 재계산, 부수효과 대응
- [ ] P2B-02 커밋 3: 게이트 연동 + `pending_notification` + 재시작 복구
- [ ] P2B-03 커밋 4: 플래그 3종 + 허용목록 + `completed_by`
- [ ] P2B-04 커밋 5: 시나리오·문서
- [ ] 보고서: G1 원문, G4 해시 비교, G7 복구 로그, `grep -n` 인용
- [ ] 커밋 접두: `test/feat/feat/feat/docs(issue-5)`
---
## 부록 A. 계획 수립 시 실행한 검증 명령
```bash
git log --oneline -1 # 4a5916a
.venv/bin/python -m pytest tests/ --collect-only -q | tail -1 # 664
find .mam/jobs -name "*manifest*" # 없음
grep -rn "pending_notification\|MAM_AUTO_COMPLETE" --include='*.py' .agents # 0건
grep -n 'record.get("attempt_id", record.get("iteration"))' .../engine.py # 대체 확인
python -c "... transition(completed, expected_attempt=1|2) ..." # iteration 대체 실증
python -c "... herdr/manifest.json observed slots ..." # 백엔드 증거 행렬
grep -n "publish_event.py --job" create_session.sh 래퍼 # 지시문 주입 2곳
```