27 KiB
본 리포트는 Job
2770df36(role: Planner)의 산출물인 Phase 2a 구현 계획서다. durable 사본:.agents/reports/creator-claude-01/plan-2770df36.md(커밋됨).범위 명시: 본 job의 지시는 "구현 계획서를 작성"이며, 목표 문장의 (5) 테스트 작성·(6) 리뷰어 만장일치 PASS는 이 계획을 실행할 후속 Worker/Reviewer job의 완료 조건이다. 본 계획서는 그 job들이 그대로 실행할 수 있도록 커밋 5개·게이트 9개·인수인계 체크리스트까지 명세했다.
📑 Issue #5 Phase 2a 구현 계획서 — lib_py/loop/ 코어 & Shadow Watcher
- Job:
2770df36/ Planner:creator-claude-01 - 기준 커밋:
015ab70(branchrefactor, working tree clean) / Python3.9.6/ paho-mqtt2.1.0/ herdr0.8.2/ 전체 테스트 수집 576 - 요구 출처:
implementation_plan.md§2 Phase 2a 행,task.mdP2A-01P2A-04,§5state-transitions.md§2 - 문서 성격: Worker가 그대로 실행할 수 있는 단계별 명세. §1의 모든 인용은 계획 수립 중 라이브 코드·라이브 CLI로 직접 확인했다.
0. 요약 (TL;DR)
- Phase 2a는 레지스트리에 단 1바이트도 쓰지 않는다. Shadow watcher는 "Would-Complete" 판정을 로그로만 남긴다. 이 불변식을 문장이 아니라 구조와 테스트로 강제한다(§D5, G4).
- CAS 경계는 신규 파일이 아니라 기존 writer 3곳을 한 함수 뒤로 모으는 작업이다. 현재 상태 writer는 3개이고 그중 하나(
pick_pending)는 이미 문서화된 chokepoint를 우회한다(§1-2 실측). - 4중 귀속에 필요한 필드가 job 레코드에 없다(
attempt_id/agent_session_uuid/pane_process_generation전부 부재, §1-4). Phase 1이 레코드 스키마 불변을 약속했고 Phase 2a는 레지스트리 쓰기가 금지되므로, 귀속 증거는 shadow 전용 저장소에 둔다. 레코드 스키마 결정은 Phase 2b로 미룬다. herdr agent wait --timeout의 단위는 밀리초다(라이브--help확인, Phase 0 fixture도integer_ms로 정확히 기록). 초 단위로 오해하면 1000배 오차가 난다.- 락 안에서 감사 로그 I/O가 일어나고 있다(
update_job_status가 락 보유 중update_logged_status/append_event호출, §1-3). Phase 2a는 이를 "락 안에서 결정 + 알림 큐잉 → 락 해제 → 밖에서 flush"로 바꾼다. - 판정 로직의 테스트 벡터는 새로 만들지 않는다. 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가 게이트)
preexec_fn(3인 리뷰어 공통 지적):daemon.spawn은preexec_fn으로 SIGHUP을 무시하는데 이는 멀티스레드 프로세스에서 안전하지 않다. Shadow watcher가 스레드를 쓰면서DelegationService.ensure_subscriber_daemon을 호출하면 이 게이트에 걸린다(§D9).- O-4 (Claude): freeze 트리의
_delegate_py_bin은.venv를 찾지 못해 paho 없는 시스템 python으로 떨어진다. Phase 2a가 freezelib.sh헬퍼를 호출하지 않는 한 무해하다(§D9에서 재확인). - 연결 지문 하드코딩 필드 목록:
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가 된다.
# 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_job1회 +_atomic_write_record1회. - 감사 로그 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 래핑
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)
# 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을 소유한다. 담기는 것:
{ "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경로의 현행 부작용 스냅샷.
- X-01
- 완료 기준: 레거시 코드에서 전부 통과, 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/stderrtimeout, exit 1/stderragent_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.mdP2A-01~04 체크 + 증거,implementation_plan.mdPhase 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. 롤백
- 즉시:
MAM_ENGINE_CAS=0→ W1/W2가 보존된 legacy 무조건 병합으로 분기. Shadow는 애초에 쓰기가 없으므로 프로세스 종료만으로 흔적이 사라진다. - 프로세스: Shadow 러너 종료 + 인메모리 큐 폐기.
.mam/shadow/는 삭제해도 무해(레지스트리와 분리되어 있음). - 커밋 단위: 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. 계획 수립 시 실행한 검증 명령 (재현용)
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 부재)