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
18 KiB
📑 Issue #5 Phase 2b 구현 계획서 — 이중 감지 자동완료 (Flag-Gated / Opt-In)
- Job:
df85e978/ Planner:creator-claude-01 - 기준 커밋:
4a5916a(branchrefactor, working tree clean) / Python3.9.6/ herdr0.8.2/ 전체 테스트 664 - 요구 출처:
implementation_plan.md§2 Phase 2b 행,task.mdP2B-01P2B-03,§6state-transitions.md§3 - 문서 성격: Worker가 그대로 실행할 수 있는 단계별 명세. §1의 모든 인용은 라이브 코드·실행으로 직접 확인했다.
0. 요약 (TL;DR)
- 매니페스트를 쓰는 주체가 이 Phase의 전부다. 워처가 스스로 해시를 계산해 매니페스트를 만들면 그것은 "워처가 안정된 파일을 봤다"는 자기 증명일 뿐이며, Round 2에서 Codex가 R1으로 기각한 바로 그 형태다. 끝났다는 사실을 아는 쪽(에이전트)이 확정을 선언해야 한다(§D1).
- 그래서 미지원 에이전트는 자동으로 fail-closed가 된다. 확정 선언을 하지 않으면 매니페스트가 없고, 매니페스트가 없으면
policy가 이미hold한다(Phase 2a에서 구현·테스트 완료). P2B-03의 "미지원 경로 100% 보존"은 새 분기가 아니라 기존 동작의 자연스러운 귀결이다. attempt_id와iteration의 의미 충돌을 먼저 정리해야 한다. 엔진은record.get("attempt_id", record.get("iteration"))로 대체하는데, 레코드에는attempt_id가 없고iteration만 있다(실측). 지금은 우연히 값이 겹쳐 동작하지만 두 개념은 다르며, 자동완료 게이트가 이 값에 걸리므로 명시적 결정 없이 진행하면 안 된다(§D2).- 지연 알림은 새 저장소를 만들지 않는다.
state-transitions.md§5가 "독립 Outbox DB 신설 금지"를 명시했고, 레코드 안pending_notification필드를 같은 CAS 트랜잭션에서 쓴다(§D4). - 자동완료 허용 백엔드는 실측 증거가 있는 것으로 한정한다. Phase 0 능력 행렬 실측: 정지 상태(
idle/done) 관측 증거가 있는 것은 claude·grok(idle), codex(done) 뿐이고 agy·hermes·opencode는 없다(§1-5). 플래그가 켜져도 이 셋 외에는 자동완료하지 않는다. - 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로 확장하되 판정 로직은 손대지 않는다. 순서:
watcher.wait_once로 정지 상태 관측 →collector_attribution봉투에 4식별자를 채워 증거 구성.report-manifest.json읽기 → 해시 재계산 후 대조(매니페스트 자기 신고를 신뢰하지 않는다) →report_manifest.attempt_id를 증거에 포함.policy.decide→candidate_approved가 아니면 종료(로그만).candidate_approved면 게이트 3중 확인 후engine.transition(job_id, "completed", expected_attempt=<attempt>, source="auto_complete").- 게이트: 플래그 ON + 백엔드 허용목록(§1-5) + 잡별 opt-in.
- 커밋 결과가
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.mdP2B-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. 롤백
- 즉시:
MAM_AUTO_COMPLETE=0. 신규 자동완료만 중단되고 이미 확정된 terminal 레코드는 보존한다(implementation_plan.mdPhase 2b 롤백 조건 그대로). MAM_ENGINE_CAS=0은 Phase 2a 경로까지 함께 되돌린다(자동완료는 CAS 위에 있으므로 자동으로 무력화).- 커밋 단위 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. 계획 수립 시 실행한 검증 명령
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곳