docs(issue-5): phase 2a implementation plan (lib_py/loop core and shadow watcher)

An executable plan for P2A-01..04, grounded in live measurement rather than
restating the phase-0 contracts.

Four measurements shaped the design:

  * The job record has no attempt_id, agent_session_uuid or
    pane_process_generation, so the four-fold attribution contract has nowhere
    to live in it. Phase 1 froze the record schema and P2A-03 forbids registry
    writes outright, so attribution goes in a shadow-owned store and the record
    schema question is deferred to 2b.
  * Every status write is already inside python: the shell side only reads. A
    single boundary can therefore cover all writers.
  * update_job_status merges fields unconditionally, and pick_pending is a
    second path around it. pick_pending is not wrong -- it filters on pending
    under the lock, so it is already a correct swap -- it is a duplicate. That
    is the O-1 shape from phase 1, where the copy that drifts is the one that
    breaks. So P2A-01 generalises the swap that exists rather than inventing one.
  * herdr's wait timeout is milliseconds. Phase 0 recorded it correctly; the
    plan names the parameter timeout_ms so the unit cannot be misread.

Two invariants are made structural rather than promised. The shadow runner is
never given a reference to the engine, so it cannot write; and the gate hashes
the whole registry directory before and after a full run, which is a stronger
claim than "status was not changed". Lock discipline follows a pattern already
in the tree: pick_pending finishes its record write under the lock and mirrors
to the audit log after releasing it.

Carries the three phase-1 handoffs forward with explicit gates: no daemon spawn
in 2a so preexec_fn stays unreachable, no frozen-helper calls so the interpreter
resolution gap stays dormant, and a coverage assertion for the connection
fingerprint's field list.

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 18:45:15 +09:00
1 parent 015ab70e51
commit 02a89490df
1 file changed
+364
@@ -0,0 +1,364 @@
# 📑 Issue #5 Phase 2a 구현 계획서 — `lib_py/loop/` 코어 & Shadow Watcher
- **Job**: `2770df36` / **Planner**: `creator-claude-01`
- **기준 커밋**: `015ab70` (branch `refactor`, working tree clean) / Python `3.9.6` / paho-mqtt `2.1.0` / herdr `0.8.2` / **전체 테스트 수집 576**
- **요구 출처**: `implementation_plan.md` §2 Phase 2a 행, `task.md` P2A-01~P2A-04, `state-transitions.md` §2~§5
- **문서 성격**: Worker가 그대로 실행할 수 있는 단계별 명세. §1의 모든 인용은 계획 수립 중 **라이브 코드·라이브 CLI로 직접 확인**했다.
---
## 0. 요약 (TL;DR)
1. **Phase 2a는 레지스트리에 단 1바이트도 쓰지 않는다.** Shadow watcher는 "Would-Complete" 판정을 로그로만 남긴다. 이 불변식을 문장이 아니라 **구조와 테스트로** 강제한다(§D5, G4).
2. **CAS 경계는 신규 파일이 아니라 기존 writer 3곳을 한 함수 뒤로 모으는 작업이다.** 현재 상태 writer는 3개이고 그중 **하나(`pick_pending`)는 이미 문서화된 chokepoint를 우회**한다(§1-2 실측).
3. **4중 귀속에 필요한 필드가 job 레코드에 없다**(`attempt_id`/`agent_session_uuid`/`pane_process_generation` 전부 부재, §1-4). Phase 1이 레코드 스키마 불변을 약속했고 Phase 2a는 레지스트리 쓰기가 금지되므로, **귀속 증거는 shadow 전용 저장소에 둔다.** 레코드 스키마 결정은 Phase 2b로 미룬다.
4. **`herdr agent wait --timeout`의 단위는 밀리초다**(라이브 `--help` 확인, Phase 0 fixture도 `integer_ms`로 정확히 기록). 초 단위로 오해하면 1000배 오차가 난다.
5. **락 안에서 감사 로그 I/O가 일어나고 있다**(`update_job_status`가 락 보유 중 `update_logged_status`/`append_event` 호출, §1-3). Phase 2a는 이를 "락 안에서 결정 + 알림 큐잉 → 락 해제 → 밖에서 flush"로 바꾼다.
6. **판정 로직의 테스트 벡터는 새로 만들지 않는다.** Phase 0에서 3인 리뷰로 검증된 watcher 시나리오 7종과 그 `expected_policy`가 그대로 엔진의 결정 테이블 테스트가 된다(§D7).
---
## 1. Planner 사전 실측
### 1-1. 대상 파일과 규모
| 항목 | 실측 |
|---|---|
| 기준 커밋 | `015ab70` |
| 전체 테스트 | **576 수집** (Phase 1 종료 시점) |
| `lib_py/` 기존 패키지 | `agents/`, `delegation/`, `atomic_yaml.py`, `layout.py`, `paths.py`, `state.py`, `verify_session.py`, `workspace_uuid.py` |
| `MAM_ENGINE_CAS` | **아직 존재하지 않음** (`grep` 결과 `implementation_plan.md` 언급 1건뿐) — Phase 2a가 신설 |
### 1-2. job 레코드 상태 writer 전수 조사 (P2A-01의 실제 범위)
`_atomic_write_record` 직접 호출자 = **5곳**, 그중 상태를 바꾸는 것은 **3곳**이다.
| # | 위치 | 하는 일 | CAS 여부 |
|---|---|---|---|
| W1 | `mqtt_common.py:410` (`update_job_status`) | `record.update(fields)` **무조건 병합** | ❌ 없음 |
| W2 | `registry.py:131` (`pick_pending`) | 락 안에서 `status == "pending"` 필터 후 `running`으로 기록 | ⚠️ **사실상 CAS이나 W1을 우회한 별도 구현** |
| W3 | `mqtt_common.py:433` (`next_seq`) | `last_seq` 증가 (상태 무관) | 해당 없음 |
| — | `registry.py:107` (`register_job`) | 레코드 생성 | 해당 없음 |
| — | `registry.py:151` (`update_status`) → W1 | | |
| — | `registry.py:365` (`update` CLI) → W1 | | |
| — | `publish_event.py:179` (`record_outcome`) → W1 | | |
> **핵심 (정확히)**: `update_job_status`의 docstring은 자신을 "the single chokepoint for status writes"라고 선언하지만, `registry.py:135`의 주석이 스스로 인정하듯 `pick_pending`은 이를 우회한다.
>
> 단, **`pick_pending`이 틀린 것은 아니다.** 락 안에서 `status == "pending"`을 필터한 뒤 기록하므로 `pending → running`에 대해서는 **이미 올바른 compare-and-swap**이다(`registry.py:123`). 문제는 정확성이 아니라 **중복**이다: CAS 판정과 감사 로그 mirror가 두 벌로 존재하며, Phase 1의 O-1(1회성 전송 이중 구현)에서 확인했듯 **같은 로직의 사본은 한쪽에만 결함이 생기는 경로**다.
>
> 따라서 **P2A-01은 "없는 CAS를 새로 만드는" 작업이 아니라 "한쪽에만 있는 CAS를 모든 전이로 일반화하고 두 사본을 하나로 합치는" 작업이다.** 실제로 CAS가 전혀 없는 것은 W1(`record.update(fields)` 무조건 병합)이다.
셸/래퍼 측은 job 레코드를 직접 쓰지 않는다: 래퍼는 `registry.py status --set`(→W1)을, `reconcile.sh:192`는 `registry.load_job`(읽기 전용)만 쓴다. `run_loop.sh:254`는 job JSON을 **읽기만** 한다(3초 폴링). 즉 **모든 쓰기가 파이썬 안에 있다** — CAS 경계를 파이썬 한 곳에 두면 전수 커버가 가능하다.
### 1-3. 현재 락 트랜잭션의 I/O 실측
`update_job_status`(`:405-418`)는 `registry_lock` **보유 중에** 다음을 수행한다:
- `load_job` (읽기 I/O)
- `_atomic_write_record` (쓰기 + `fsync` + `os.replace`)
- `update_logged_status` (**감사 로그 디렉터리 쓰기**)
- `append_event` (**감사 로그 append**)
네트워크/서브프로세스는 락 안에 없다(`publish_event.main`의 순서: `next_seq`(락) → 발행(네트워크, 락 없음) → `record_outcome`(락)). **이 성질은 Phase 2a에서 반드시 보존해야 하며**, 추가로 감사 로그 I/O를 락 밖으로 빼는 것이 P2A-01의 "락 내 I/O 배제" 요구다.
### 1-4. 4중 귀속에 필요한 필드 부재 (설계를 좌우하는 사실)
라이브 job 레코드의 실제 키:
```
agent, agent_session, auth_token, broker, created_at, expected_artifacts,
idle_timeout_sec, iteration, job_id, job_type, last_seq, max_iterations,
prompt, reviewer, reviewer_session, role, schema_version, status,
timeout_sec, topic_prefix, updated_at
```
`state-transitions.md` §2가 요구하는 4중 귀속 중 **`attempt_id`·`agent_session_uuid`·`pane_process_generation`이 전부 없다**. `iteration`(루프 회차)은 있으나 attempt와 의미가 다르다.
동시에 Phase 1은 "스키마 불변(`SCHEMA_VERSION = 1`, 레지스트리 레코드 필드 불변)"을 계약으로 못박았고, P2A-03은 **레지스트리 쓰기 자체를 금지**한다. → **Phase 2a에서 귀속 증거를 레코드에 넣는 것은 불가능하며, 넣어서도 안 된다.** §D6이 이를 해결한다.
### 1-5. `herdr agent wait` 라이브 계약 (watcher.py의 입력)
```
Usage: herdr agent wait <TARGET> [OPTIONS]
--until <STATUS> idle|working|blocked|done|unknown (반복 가능)
--timeout <MS> Fail after this many milliseconds
Without --until, matches idle, done, or blocked.
Without --timeout, waits indefinitely.
```
- **단위는 밀리초**다. Phase 0 fixture(`wait-contract.json`)도 `"type": "integer_ms"`로 정확히 기록되어 있다.
- 성공 → `exit 0`, **stdout**에 `{"id":"cli:agent:wait","result":{"agent":{...}}}`.
- 타임아웃/대상 부재 → `exit 1`, **stderr**에 에러 JSON(`error.code` = `timeout` / `agent_not_found`). stdout은 비어 있다.
- `--until` 생략 시 기본 매칭은 `idle,done,blocked` — **`working`은 매칭되지 않는다.** watcher는 항상 `--until`을 명시한다.
- **단일 `wait`는 1개의 최종 스냅샷만 주는 동기 장벽**이다(`herdr-runtime.md` §2-1). 연속 관측은 반복 호출로 구성해야 한다.
### 1-6. Phase 0에서 이미 검증된 판정 벡터
`tests/fixtures/issue_5/watcher/`의 7종과 `expected_policy.decision`은 3인 교차 리뷰를 통과한 계약이다. Phase 2a 엔진의 결정 함수는 **이 7종을 그대로 입력받아 같은 결정을 내야 한다**.
| 시나리오 | 기대 결정 |
|---|---|
| `initial_idle` | `hold` (실행 앵커 없음) |
| `tool_running` | `hold` (도구 실행 중) |
| `report_pause_15s` | `hold` (매니페스트 없음 — 디바운스만으로 완료 금지) |
| `finalized_candidate` | `candidate_approved` |
| `blocked_permission` | `hold_blocked` |
| `transport_disconnected` | `hold_reconnect` |
| `restarted_generation` | `reject_stale` |
`dispatch_context` 필드: `job_id`, `attempt_id`, `agent_session_uuid`, `pane_process_generation`, `dispatch_ack`, `execution_anchor_observed`.
### 1-7. Phase 1에서 넘어온 인계 사항 (본 Phase가 게이트)
1. **`preexec_fn` (3인 리뷰어 공통 지적)**: `daemon.spawn`은 `preexec_fn`으로 SIGHUP을 무시하는데 이는 **멀티스레드 프로세스에서 안전하지 않다**. Shadow watcher가 스레드를 쓰면서 `DelegationService.ensure_subscriber_daemon`을 호출하면 이 게이트에 걸린다(§D9).
2. **O-4 (Claude)**: freeze 트리의 `_delegate_py_bin`은 `.venv`를 찾지 못해 paho 없는 시스템 python으로 떨어진다. Phase 2a가 freeze `lib.sh` 헬퍼를 호출하지 않는 한 무해하다(§D9에서 재확인).
3. **연결 지문 하드코딩 필드 목록**: `BrokerConfig` 필드 추가 시 조용히 누락된다. Phase 2a에서 `dataclasses.fields()` 유도로 바꾸거나 커버리지 단정 테스트를 추가한다(§3 P2A-04에 포함).
---
## 2. 설계 결정 (Design Decisions)
### D1. 패키지 배치
```
.agents/skills/lib_py/loop/
__init__.py # 공개 API 재수출
evidence.py # 증거 튜플 정규화 (watcher → engine 사이의 유일한 자료형)
attribution.py # 4중 귀속 + dispatch anchor 판정 (순수 함수)
policy.py # 결정 함수: 증거 -> hold|hold_blocked|hold_reconnect|reject_stale|candidate_approved
engine.py # 단일 전이 주체 + CAS 트랜잭션 경계
watcher.py # herdr agent wait 래핑, 재연결 백오프, 스냅샷 수집
shadow.py # Shadow 러너: watcher -> policy -> "would-complete" 로깅 (쓰기 없음)
store.py # shadow 전용 귀속/증거 저장소 (레지스트리와 분리)
```
- **`policy.py`는 순수 함수여야 한다**(I/O·시계·전역 상태 금지). Phase 0 fixture 7종을 그대로 먹여 테스트하기 위한 필수 조건이다.
- **D1(b) 재적용**: `lib_py.loop`는 `lib_py.delegation` **외의** 형제 `lib_py` 모듈을 임포트하지 않는다. 이유는 Phase 1과 동일하다 — freeze 하에서 부모 `lib_py`와 바인딩된 패키지가 다른 트리일 수 있다. AST grep 테스트로 고정한다(§D8).
- Python 3.9 호환 필수(`match`, PEP 604 런타임 유니온 금지). 게이트에서 `compileall`.
### D2. 단일 CAS 전이 경계 (P2A-01)
**`engine.transition()`이 유일한 상태 writer가 된다.**
```python
# lib_py/loop/engine.py
class TransitionResult(object):
__slots__ = ("committed", "from_status", "to_status", "reason", "record", "notifications")
def transition(job_id, request, *, registry_dir, expected_attempt=None,
expected_status=None, fields=None, source="unknown") -> TransitionResult:
"""유일한 상태 전이 주체.
락 안에서 하는 일: load -> CAS 판정 -> (허용 시) 기록 -> 알림 큐잉.
락 밖에서 하는 일: 알림 flush(감사 로그), MQTT 발행.
"""
```
CAS 규칙은 `state-transitions.md` §3 표를 그대로 구현한다:
| 현재 | 요청 | 조건 | 결과 |
|---|---|---|---|
| `pending` | `claim`/`started` | 세션·attempt 일치 | `running` |
| `running` | `progress`/`permission_required` | — | `running` (메타만 갱신) |
| `running` | explicit terminal | 유효 세션 서명 | 요청 terminal |
| `terminal` | 동일 terminal 재수신 | — | **No-op 멱등 ACK** |
| `terminal` | 상충 신호 | — | **거부 + conflict 감사 기록** |
| 임의 | stale attempt/generation | 불일치 | **거부 + stale 경고** |
| `running` | `deadline_timeout` | 타임아웃 확인 | `error` |
**기존 writer 3곳의 라우팅**:
- W1 `update_job_status` → 내부적으로 `engine.transition(request="merge", ...)`를 호출하도록 교체. **시그니처·반환값·예외는 불변**(Phase 1의 G-1~G-4가 이 함수 계약에 걸려 있다).
- W2 `pick_pending` → `engine.transition(request="claim", expected_status="pending")`로 교체. 이로써 우회가 사라진다.
- W3 `next_seq` → 상태를 바꾸지 않으므로 그대로 두되, 같은 락을 쓰므로 **트랜잭션 시간 예산**에만 포함한다.
**롤백 스위치 `MAM_ENGINE_CAS`(기본 `1`)**: `0`이면 W1/W2가 **현재의 무조건 병합 본체**로 분기한다. Phase 1의 `MAM_DELEGATION_PY`와 동일한 패턴이며, legacy 본체는 `_*_legacy`로 보존한다(제거는 Phase 4).
### D3. 락 규율 — 락 안에서는 결정만, I/O는 밖에서
```
with registry_lock(registry_dir): # 짧게
record = load_job(...)
decision = _cas_decide(record, request, expected_attempt, expected_status)
if decision.commit:
_atomic_write_record(...) # 레코드 1회 쓰기만 허용
pending = decision.notifications # 감사 로그는 "쓰지 않고" 큐에 담는다
# 락 해제 후
_flush_notifications(pending) # 감사 로그 append / status mirror
```
- **금지**: 락 보유 중 `subprocess`, 소켓, MQTT, `herdr` 호출, `time.sleep`.
- **허용**: `load_job` 1회 + `_atomic_write_record` 1회.
- 감사 로그 mirror(`update_logged_status`/`append_event`)를 락 밖으로 옮긴다. **순서 보장 손실 우려**: 현재는 락 안이라 감사 이력이 직렬화된다. 이를 유지하기 위해 알림에 **CAS가 부여한 단조 `transition_seq`**를 실어 보내고, flush가 늦게 도착해도 순서를 복원할 수 있게 한다.
- 이 규율은 문장이 아니라 테스트로 강제한다: 락 획득 중 `subprocess.run`/`socket.socket`/`time.sleep`을 monkeypatch로 감시해 호출 0건을 단정한다(G5).
- **저장소에 이미 선례가 있다**: `pick_pending`은 레코드 쓰기를 락 안에서 끝내고 감사 로그 mirror(`update_logged_status`/`append_event`)를 **락 해제 후에** 수행한다(`registry.py:131-145`). D3은 새 패턴을 발명하는 것이 아니라 **이 기존 패턴을 `update_job_status`에도 적용해 일반화**하는 것이다.
### D4. `watcher.py` — `herdr agent wait` 래핑
```python
def wait_once(target, until=("idle", "done", "blocked"), timeout_ms=15000, run=subprocess.run) -> WaitOutcome
```
- `--timeout`은 **밀리초**로 전달한다(§1-5). 초 단위 인자를 받지 않는다 — 단위 혼동을 타입 이름(`timeout_ms`)으로 차단한다.
- `--until`을 **항상 명시**한다(생략 시 `working`이 빠지는 기본값에 의존하지 않는다).
- `exit 0` → stdout JSON 파싱 → `result.agent` 스냅샷.
- `exit 1` → **stderr** JSON 파싱 → `error.code`로 분기: `timeout`(정상적 무이벤트), `agent_not_found`(대상 소멸).
- 그 외 exit code / JSON 파싱 실패 / 실행 실패 → `transport_error`.
- **재연결 백오프**: `transport_error` 연속 발생 시 1s → 2s → 4s → … → 최대 16s. 백오프 대기는 **락 밖에서만** 한다. 복구 시 **캐시된 스냅샷을 폐기하고 재스냅샷**한다(`transport_disconnected` → `hold_reconnect` 정책과 일치).
- watcher는 **판정하지 않는다.** 증거 튜플만 만들어 큐에 넣는다(Phase 0 합의: watcher는 관측, engine은 결정).
### D5. Shadow 모드 — 쓰기 불가를 구조로 보장 (P2A-03)
```python
# shadow.py
class ShadowRunner:
def __init__(self, ..., decide=policy.decide, sink=logging_sink):
... # engine 을 아예 주입받지 않는다
```
- **ShadowRunner는 `engine.transition`에 대한 참조를 갖지 않는다.** "쓰지 않기로 약속"이 아니라 **쓸 수단이 없다**. 이것이 P2A-03을 문장이 아니라 구조로 만드는 방법이다.
- 판정 결과는 `would_complete` 레코드로 **shadow 전용 로그**에만 남는다: `.mam/shadow/<job_id>.ndjson` (레지스트리와 다른 디렉터리).
- 검증(G4): shadow 러너를 시나리오 전량으로 돌린 전후에 **레지스트리 디렉터리 전체의 파일 목록·mtime·SHA-256이 바이트 동일**함을 단정한다. "status를 안 썼다"보다 강한 단정이다.
### D6. 귀속 증거는 shadow 전용 저장소에 (§1-4 결론)
`store.py`가 `.mam/shadow/attribution/<job_id>.json`을 소유한다. 담기는 것:
```json
{ "job_id": "...", "attempt_id": 1, "agent_session_uuid": "...",
"pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z",
"dispatch_ack": true, "dispatch_ack_at": "...", "execution_anchor_observed": false }
```
- **job 레코드는 건드리지 않는다** → Phase 1의 스키마 불변 계약과 P2A-03을 동시에 만족한다.
- `attempt_id`는 Phase 2a에서 **shadow가 자체 부여**한다(디스패치 관측 시 1부터). Phase 2b에서 레코드로 승격할지는 그때 결정한다 — **이 계획서는 레코드 스키마를 바꾸지 않는다.**
- `pane_process_generation`은 `<pane_id>:<pid>:<start_iso>` 형식이며, Phase 0의 정체성 검증(`_validate_observed_herdr_item`)이 이미 이 형식을 강제한다.
### D7. 판정 테스트 벡터 = Phase 0 fixture (P2A-04의 절반)
`policy.decide(scenario) -> decision` 이 Phase 0의 7종 시나리오에 대해 `expected_policy.decision`과 **정확히 일치**해야 한다. 신규 fixture를 만들기 전에 **기존 계약을 먼저 만족**시킨다. 이렇게 하면:
- 판정 로직이 3인 리뷰를 통과한 계약에 묶인다.
- Phase 0의 `validate_watcher_scenario`가 fixture 무결성을 계속 지킨다.
추가 시나리오(P2A-04 요구)는 같은 스키마로 작성하고 **`validate_watcher_scenario`를 통과해야 한다**: `race_double_dispatch`, `attempt_reuse`, `partial_report`, `unknown_status`, `restart_midflight`.
### D8. 트리 일관성 (Phase 1 R1의 교훈 재적용)
- `lib_py.loop`는 `lib_py.delegation`만 형제로 임포트한다. 그 외 `lib_py.*` 금지 → AST 테스트.
- 루프 프로세스는 shim을 인프로세스로 임포트하지 않으므로 `lib_py.loop`와 `lib_py.delegation`은 부모 `lib_py`를 통해 **같은 트리**로 해석된다. 이를 테스트로 고정한다(`__file__` 접두 비교).
- Shadow 러너를 서브프로세스로 띄울 때는 Phase 1의 `_delegation_py`와 **동일한 명시 부트스트랩**을 쓴다. `python -m`은 cwd가 `PYTHONPATH`를 이기므로 금지한다(R1 재발 방지).
### D9. Phase 1 인계 게이트 해소
- **`preexec_fn`**: Shadow watcher가 스레드를 쓸 경우 `daemon.spawn`의 `preexec_fn`이 위험해진다. **Phase 2a에서는 shadow 러너가 데몬을 스폰하지 않는다**(관측만). `ensure_subscriber_daemon` 호출 금지를 grep 테스트로 고정하고, 실제 제거/대체는 Phase 2b 스폰 도입 시 처리한다.
- **O-4**: shadow는 freeze `lib.sh` 헬퍼를 호출하지 않으므로 인터프리터 해석 문제에 노출되지 않는다. 호출 0건을 grep으로 고정한다.
- **연결 지문 필드 목록**: `dataclasses.fields(BrokerConfig)` 전량이 지문에 포함됨을 단정하는 테스트를 추가한다(필드 추가 시 조용히 누락되는 것을 차단).
---
## 3. 작업 분해 (WBS) — 커밋 5개
### P2A-00 특성화 선행 (커밋 1: `test(issue-5): phase 2a writer inventory and lock discipline characterization`)
- **파일**: `tests/test_issue5_phase2a_loop.py`(신규)
- 현행 동작을 **먼저 고정**한다(Phase 1에서 효과가 입증된 순서):
- X-01 `update_job_status`가 무조건 병합임을 단정(현행 사실).
- X-02 `pick_pending`의 현행 계약 고정: `pending`만 claim하고 동시 claim 시 한 세션만 이긴다(라우팅 후에도 이 성질이 유지되어야 한다).
- X-03 락 보유 중 감사 로그 I/O가 **발생함**을 단정(현행 사실 — D3 이후 이 테스트는 반대로 뒤집힌다).
- X-04 `next_seq`/`register_job`/`record_outcome` 경로의 현행 부작용 스냅샷.
- **완료 기준**: 레거시 코드에서 전부 통과, 576 + 신규 통과.
### P2A-01 엔진·CAS 경계 (커밋 2: `feat(issue-5): single CAS transition boundary for every job-status writer`)
- **파일**: `lib_py/loop/{__init__,engine,evidence,attribution}.py`; `mqtt_common.update_job_status`·`registry.pick_pending` 라우팅; `MAM_ENGINE_CAS` 분기 + `_*_legacy` 보존.
- **테스트**: `state-transitions.md` §3 표 9행 전부에 대한 전이 테스트, 멱등 재수신, 상충 거부 + conflict 감사, stale attempt 거부, `MAM_ENGINE_CAS=0` 양 경로 parametrize, **락 내 I/O 0건 단정**(subprocess/socket/sleep monkeypatch).
- **완료 기준**: Phase 1의 G-1~G-4가 그대로 통과(계약 불변 증명), 전체 스위트 무회귀.
### P2A-02 watcher (커밋 3: `feat(issue-5): herdr agent wait watcher with reconnect backoff`)
- **파일**: `lib_py/loop/watcher.py`, `store.py`
- **테스트**: 가짜 `run`으로 exit 0/stdout, exit 1/stderr `timeout`, exit 1/stderr `agent_not_found`, 파싱 실패, 실행 실패 5경로; 백오프 수열(1,2,4,8,16 상한)과 **재연결 시 캐시 폐기**; `--timeout`이 **밀리초로 전달**됨을 argv 단정; `--until` 항상 명시됨을 argv 단정. **herdr 실제 호출 0건**(전부 주입된 가짜).
- **완료 기준**: 위 통과 + 실제 herdr로 1회 스모크(수동, 보고서 첨부).
### P2A-03 shadow 러너 (커밋 4: `feat(issue-5): shadow watcher that logs would-complete without writing`)
- **파일**: `lib_py/loop/{policy,shadow}.py`
- **테스트**: Phase 0 시나리오 7종 → `policy.decide`가 `expected_policy.decision`과 일치; 신규 시나리오 5종 추가(`validate_watcher_scenario` 통과 필수); **레지스트리 디렉터리 SHA-256 전후 동일** 단정; `ShadowRunner`가 `engine`/`update_job_status`/`_atomic_write_record`를 참조하지 않음을 AST로 단정.
- **완료 기준**: shadow 전량 실행 후 레지스트리 바이트 불변.
### P2A-04 통합·문서 (커밋 5: `docs(issue-5): phase 2a contracts, scenarios and task checklist`)
- race/attempt 재사용/부분 리포트/도구 실행/blocked/unknown/재시작 시나리오 실행 결과 기록.
- `state-transitions.md`에 구현 매핑 각주, `task.md` P2A-01~04 체크 + 증거, `implementation_plan.md` Phase 2a 상태 갱신, `MAM_ENGINE_CAS` 운영 문서.
- Phase 1 인계 3건 처리 상태 명시(§D9).
---
## 4. 게이트
| # | 조건 | 측정 |
|---|---|---|
| G1 | 기존 **576** 전부 통과 + 신규 전부 통과, skipped 0 | `pytest tests/ -q` (약 13분) |
| G2 | 셸 문법·공백 | `bash -n`, `git diff --check` (**범위** 기준) |
| G3 | Python 3.9 컴파일 | `compileall .agents/skills/lib_py/loop` |
| **G4** | **Shadow가 레지스트리에 0바이트 기록** | 실행 전후 레지스트리 디렉터리 파일 목록·SHA-256 동일 |
| **G5** | **락 내 I/O 배제** | 락 보유 중 subprocess/socket/sleep 호출 0건 (monkeypatch 감시) |
| G6 | 모든 상태 writer가 CAS 경유 | `_atomic_write_record` 직접 호출자가 engine·`register_job`·`next_seq`뿐임을 AST로 단정 |
| G7 | 판정이 Phase 0 계약과 일치 | 시나리오 7종 + 신규 5종 `expected_policy` 일치 |
| G8 | 롤백 | `MAM_ENGINE_CAS=0`에서 특성화 통과 + 라이브 스모크 |
| G9 | 트리 일관성 | `lib_py.loop`/`lib_py.delegation` 동일 트리, 형제 임포트 0건 |
Worker 보고서에는 G1 pytest 요약 원문, G4의 해시 비교 출력, G5의 감시 로그, G7 표를 **그대로** 붙인다. `file:line` 인용은 `grep -n` 출력으로 뒷받침한다.
---
## 5. 롤백
1. **즉시**: `MAM_ENGINE_CAS=0` → W1/W2가 보존된 legacy 무조건 병합으로 분기. Shadow는 애초에 쓰기가 없으므로 프로세스 종료만으로 흔적이 사라진다.
2. **프로세스**: Shadow 러너 종료 + 인메모리 큐 폐기. `.mam/shadow/`는 삭제해도 무해(레지스트리와 분리되어 있음).
3. **커밋 단위**: 5→4→3→2 순 `git revert`. 커밋 1(특성화)은 레거시에서도 통과하므로 남겨도 된다.
---
## 6. 리스크와 완화
| # | 리스크 | 완화 |
|---|---|---|
| R1 | CAS 도입이 Phase 1의 G-1~G-4 계약을 깬다 | `update_job_status` 시그니처·반환·예외 불변 유지, 두 경로 parametrize |
| R2 | 감사 로그를 락 밖으로 빼면서 순서가 뒤바뀐다 | CAS가 부여하는 단조 `transition_seq`를 알림에 실어 순서 복원 |
| R3 | `pick_pending` 라우팅이 **이미 올바른** 동시 claim 경쟁을 깨뜨린다 | 라우팅 전에 X-02로 현행 성질을 고정하고, `expected_status="pending"` CAS가 동일 성질을 유지함을 동시 claim 테스트로 검증 |
| R4 | `--timeout` 단위 혼동(초 vs ms) | 인자명 `timeout_ms`, argv 단정 테스트 |
| R5 | shadow가 실수로 쓰기 경로를 얻는다 | 구조적 차단(engine 미주입) + AST 단정 + 해시 불변 단정 |
| R6 | freeze/live 트리 혼재 | D8 (형제 임포트 금지 + 명시 부트스트랩) |
| R7 | `preexec_fn` 스레드 비안전 | Phase 2a는 데몬 스폰 없음(grep 고정), 실제 대체는 Phase 2b |
| R8 | 실브로커/실herdr 의존 테스트 | 전부 주입된 가짜, 실호출 0건. 라이브 스모크는 수동·정보성 |
---
## 7. 명시적 비범위
- **자동완료 실제 커밋** — Phase 2b. Phase 2a는 `would_complete` 로깅까지만.
- job 레코드 스키마 변경(`attempt_id` 등 승격) — Phase 2b.
- `run_loop.sh`의 파이썬화 — Phase 3.
- 래퍼·`reconcile.sh`의 상태 머신 변경 — 전 Phase 공통 불변.
- `preexec_fn` 제거 — Phase 2b.
- `pgrep` 다중 PID 정리, Windows — Phase 4 후보.
---
## 8. Worker 인수인계 체크리스트
- [ ] P2A-00 커밋 1: 현행 동작 특성화(W1 무조건 병합, W2 우회, 락 내 I/O 발생)가 **레거시 코드에서** 통과
- [ ] P2A-01 커밋 2: `engine.transition` 단일 경계, W1/W2 라우팅, `MAM_ENGINE_CAS` 양 경로, 락 내 I/O 0건
- [ ] P2A-02 커밋 3: watcher 5경로 + 백오프 + ms 단위 argv 단정
- [ ] P2A-03 커밋 4: Phase 0 시나리오 7종 일치 + 신규 5종, 레지스트리 해시 불변
- [ ] P2A-04 커밋 5: 문서·체크리스트·인계 3건 처리 상태
- [ ] 보고서: G1 원문, G4 해시 출력, G5 감시 로그, G7 표, `grep -n` 인용
- [ ] 커밋 접두: `test(issue-5)`, `feat(issue-5)`, `feat(issue-5)`, `feat(issue-5)`, `docs(issue-5)`
---
## 부록 A. 계획 수립 시 실행한 검증 명령 (재현용)
```bash
git log --oneline -1 # 015ab70
.venv/bin/python -m pytest tests/ --collect-only -q | tail -1 # 576
grep -rn "_atomic_write_record" --include='*.py' .agents | grep -v "def " # writer 5곳
sed -n '125,155p' .agents/skills/lib_py/delegation/registry.py # pick_pending 우회
sed -n '395,420p' .agents/skills/lib_py/delegation/mqtt_common.py # 락 내 감사 I/O
HERDR_SESSION_NAME=multi-agent-mux herdr agent wait --help # --timeout <MS>
cat tests/fixtures/issue_5/herdr/wait-contract.json # integer_ms
grep -rn "MAM_ENGINE_CAS" --include='*.py' --include='*.sh' . # 미존재
.venv/bin/python -c "import json,glob; print(sorted(json.load(open(sorted(glob.glob('.mam/jobs/*.json'))[-1]))))" # 레코드 키 (attempt_id 부재)
```