Files
multi-agent-mux/.agents/reports/planner-reviewer-claude-01/plan-f4873d82.md
T

30 KiB
Raw Blame History

📑 Issue #6 Implementation Plan (Phase 1–5 Execution & Gate Specification)


1. 개요 및 설계 원칙

본 문서는 Rev.2 합의 사양(§4)을 파일·함수·라인 단위의 실행 계획으로 전개하고, 단계별 완료 게이트·롤백·비범위를 규정한다. 모든 라인 번호는 기준 커밋 f98b958 기준이며, 착수 시 grep -n으로 재확인한다.

📌 핵심 원칙 (Rev.2 §4 합의)

  1. 기존 CAS 엔진 재사용: 타임아웃 fail-closed는 engine.py:50의 deadline_timeout 요청(비-operator, terminal 이후 거부)으로만 수행한다. 신규 --expect-status 플래그와 operator error 오버라이드는 도입하지 않는다.
  2. 사전 핸드셰이크 순서: TUI 유휴 대기(wait_for_agent_idle)는 구독자(job_subscriber.py) 기동 이전에 완료한다. 구독자 idle 시계(job_subscriber.py:265-267, subscribe 시점 기산)를 잠식하지 않는다.
  3. 유휴 예산 120초: SKS_IDLE_TIMEOUT_MS 기본 120000(실측: codex completed→done 31s).
  4. 브로커 장애 격리 (B-15 계약): 구독자 rc=3은 워커 실패가 아니다. 잡 데드라인까지 디스크를 재관측한 뒤에만 deadline_timeout을 적용한다.
  5. 리테인 오염 방지 (O-1): loop/discuss 경로에서는 래퍼가 MQTT error 이벤트를 발행하지 않는다(레지스트리 전이만). direct 경로는 감사·모니터용으로 발행한다.
  6. 롤백 스위치: 각 행위 변경은 환경변수로 즉시 이전 동작으로 복귀 가능해야 한다 (Issue #5 관례).
  7. 미주입 실패는 error가 아니다 (Rev.2 Secondary): 프롬프트가 한 글자도 전달되지 않은 실패(프리플라이트·유휴 대기 타임아웃)는 deadline_timeout이 아니라 cancelled(detail session_busy)로 닫고 래퍼 rc=5를 반환한다. 재스케줄 가능성을 보존하되 레코드는 비종료 상태로 남기지 않는다.
  8. 셸→Python 경계에서 herdr 격리 명시 전달 (Rev.2 Primary): Python 쪽 herdr 호출은 반드시 --session "$HERDR_SESSION_NAME"을 명시하고, 셸 쪽 호출은 mam_herdr/_sks_herdr만 사용한다. 인터프리터는 항상 "$(_delegate_py_bin)"이다.

📌 실측으로 확정된 사실 (계획의 전제)

사실 근거
direct 경로 wait "$sub_pid" || true는 :227, loop 워커/리뷰어 조기 탈출은 :359/:375 grep -n (1차 보고서의 :213은 오기)
명시적 주입 실패(send_keys_safe rc≠0)는 set -e로 exit 1 + EXIT trap(:523)이 error 발행 → 기본 레지스트리에서는 이미 fail-closed 실제 래퍼에 rc=4 주입 실측
단, trap 명령에 --registry-dir가 없어 커스텀 레지스트리에서는 job record not found로 pending 잔존 동일 실측
래퍼 exit 후 구독자 프로세스가 고아로 잔존 동일 실측 (pgrep job_subscriber.py)
deadline_timeout: pending→error 커밋, completed→conflicting error after terminal completed; refusing engine.transition 직접 호출 실측
잡 완료 후 에이전트 상태는 done(idle 아님); working 중에도 interactive_ready: true, ❯ 렌더링 herdr agent list/페인 캡처 실측
herdr agent wait는 이미 목표 상태면 즉시 rc=0, 아니면 timeout 시 rc=1 + stderr JSON 실측, lib_py/loop/watcher.py 모듈 docstring과 일치
dispatch_token은 claim/redispatch에서만 발급(engine.py:87,297-300) → direct 잡은 토큰 없음, loop 2회차부터 존재 코드
실제 herdr 바이너리는 HERDR_SESSION·HERDR_SOCKET_PATH 환경변수와 --session <name> 플래그만 인식하며, MAM의 HERDR_SESSION_NAME은 인식하지 않는다. HERDR_SESSION_NAME은 lib.sh의 herdr()/mam_herdr/_real_herdr(lib.sh:234-243)가 --session으로 번역하는 셸 함수 전용 변수라 Python subprocess에는 상속되지 않는다 (Rev.2 Primary-A) env -u … herdr agent get 5조합 실측: HERDR_SESSION_NAME만 → server_not_running(기본 소켓), HERDR_SESSION/HERDR_SOCKET_PATH/--session → 정상
lib.sh를 source한 셸에서도 Python subprocess.run(["herdr", …])는 /opt/homebrew/bin/herdr로 해석되어 기본 소켓을 조회한다 동일 실측 (f)
loop 모드 워커→리뷰어 전환은 v4.4.0에서 이미 깨져 있다: :276-283의 update --status pending --iteration $iteration이 워커 completed 후 같은 iteration으로 redispatch를 요청 → redispatch iteration 1 does not advance past 1 rc=4 → set -e로 래퍼 종료 (Rev.2 Tertiary) registry.py CLI 실측 (iteration 2면 rc=0)
cancelled 요청은 비-operator 소스로 pending→cancelled 커밋, completed 이후 거부, 이후 operator redispatch(iteration 전진)로 재개 가능 engine.transition 실측

2. 단계별 구현 단위 및 필수 완료 기준 (Roadmap & Gates)

단계 작업 목표 세부 구현 (§3 참조) 필수 완료 게이트 롤백
Phase 1 레지스트리 CAS 표준 연동 및 delegate-job Fail-Closed • registry.py close --request deadline_timeout|cancelled 서브커맨드
• 래퍼 finalize_unfinished/reap_subscriber 헬퍼, direct(:227)·loop(:359,:375)·run_agent 실패·프리플라이트 실패 5개 종료 경로 일원화
• rc=3 디스크 재관측, trap --registry-dir, dry-run 순서, 종료 코드 계약(rc 5 session_busy 포함)
• loop 역할 전환 attempt_id 규약 (§3.1.6)
• --agent human 결정적 재현 4종(§5 T1-1~T1-4)이 수정 전 실패·수정 후 통과
• T1-10(워커 완료→리뷰어 재디스패치 성공) 수정 전 rc=4·수정 후 rc=0
• test_wrapper_exit_code_mapping_literals_are_frozen·F-A 3종 무변경 통과
• MAM_DELEGATE_FAIL_CLOSED=0에서 기존 exit 0 동작 재현
MAM_DELEGATE_FAIL_CLOSED=0 (헬퍼 no-op, || true 복원)
Phase 2 사전 TUI 유휴 핸드셰이크 및 제출 검증 보강 • lib_py/loop/watcher.py CLI 진입점("$(_delegate_py_bin)" -m lib_py.loop.watcher wait … --session "$HERDR_SESSION_NAME")
• lib.sh: wait_for_agent_idle (사전 mam_herdr agent get → unknown/blocked 분기 → watcher CLI, SKS_IDLE_RETRIES 백오프 재시도, 최종 실패 rc 1 → 래퍼 rc 5 cancelled)
• 래퍼 run_agent를 preflight_agent+inject_agent로 분리, 순서: preflight → idle wait → subscriber → inject
• send_keys_safe 폴백 제출 검증에서 pre-submit busy 토큰 배제, agent prompt --wait --until working 수락 확인(재제출 금지)
• mock_herdr 상태 시퀀스 테스트 §5 T2-1~T2-11 통과
• test_h16(Enter 정확히 1회)·test_b8·test_c1_* 무변경 통과
• 래퍼 로그에서 SUBSCRIBED 이전에 idle 대기 완료 로그가 출력됨
• T2-10: watcher CLI argv에 --session 포함, T2-11: lib.sh에 bare python/python3 -m lib_py.loop 호출 0건(정적 린트)
SKS_IDLE_HANDSHAKE=0 (게이트 스킵, 기존 _pane_quiescent만 사용), SKS_PROMPT_WAIT=0
Phase 3 오케스트레이터 러너 Fail-Fast 연결 • runner.delegate_job: rc≠0에서도 registered job: 파싱, created_jobs·checkpoint 기록, id 반환
• rc=5(session_busy)는 MAM_SESSION_BUSY_RETRY_S(기본 30) 대기 후 1회 재위임(새 job id), 재실패 시 None
• 호출부는 wait_for_job이 디스크 error/cancelled로 1s 내 False
• run_loop.sh 레거시 delegate_job_safe 동일 파싱(MAM_LOOP_PY=0 경로 보존)
• LoopRunner.wait_for_job이 fail-closed 잡에서 ≤ 2s 반환(T3-1)
• 실패 job id가 .mam/loop-checkpoint.json에 기록(T3-2)
• rc=5 1회 재위임 후 성공/실패 분기(T3-3)
• test_issue5_phase3_loop.py 무변경 통과
함수 단위 revert (플래그 불필요: rc≠0 경로만 변경)
Phase 4 단위/계약 회귀 스위트 • tests/test_issue6_fail_closed.py, tests/test_issue6_turn_handshake.py
• tests/conftest.py mock_herdr에 agent_status·agent wait·agent prompt --wait 모킹
• 문서: SKILL.md 종료 코드 계약, MESSAGING.md §5.1 보강
• 신규 테스트 전부 수정 전 코드에서 실패함을 역검증(Issue #5 관례)
• 전체 스위트 750 + 신규 ≥ 20, 0-Fail
• git diff --check 클린
테스트 파일 삭제만으로 원복
Phase 5 실기동 4개 에이전트 E2E 및 릴리스 • Claude/Grok/Codex/OpenCode 각 1회: completed 직후 동일 세션 연속 위임 타임라인 기록
• 타임아웃 주입(에이전트 무응답)으로 65분 대신 ≤ IDLE_TIMEOUT+5s 내 error 확인
• 잔존 레코드 3건 폐쇄, IMPROVEMENTS.md 원장, VERSIONS.md v4.4.1 4-way 락스텝
• 4개 백엔드 연속 위임 주입 실패 0건, 중복 제출 0건
• test_version_consistency.py 통과
• 리뷰어 교차 검토 [VERDICT: PASS]
릴리스 태그 미부여 상태로 main revert

3. 상세 구현 사양

3.1 Phase 1 — Fail-Closed

3.1.1 lib_py/delegation/registry.py — close 서브커맨드 (신규, Rev.2: timeout을 일반화)

registry.py [--registry-dir D] close --job <id> --request {deadline_timeout,cancelled} [--detail <text>] [--expect-dispatch-token <tok>]
  • 구현: engine.transition(job_id, request, registry_dir=rd, source="delegate_wrapper", fields={"detail": detail}, expected_dispatch_token=tok_or_None). 두 요청 모두 비-operator이며 terminal 이후 거부된다(실측).
  • 용도 구분: deadline_timeout = 프롬프트가 전달된 뒤 종료 이벤트 없이 데드라인 소진(실행 실패). cancelled + detail session_busy = 프롬프트가 전달되기 전 실패(프리플라이트·유휴 대기 타임아웃; Rev.2 Secondary). 후자는 redispatch로 재개 가능하다.
  • 종료 코드: 커밋 → 0; 거부 사유가 conflicting … after terminal 또는 멱등 → 0(stderr에 사유 1줄; 이미 닫힌 잡은 정상); 레코드 없음 → 1; 토큰 불일치 → 4.
  • status --set는 건드리지 않는다(_operator_write 및 F-C 멱등 계약 보존).

3.1.2 래퍼 multi-agent-mux-delegate-job — 공통 헬퍼 (신규, run_agent 위에 정의)

# reap_subscriber <pid>  — 자기 구독자만 회수. 원래 rc는 호출자가 보존.
reap_subscriber() { kill "$1" 2>/dev/null || true; wait "$1" 2>/dev/null || true; }

# finalize_unfinished <job_id> <job_status> <sub_rc> [<phase>]
#   completed          -> no-op (rc 0)
#   broker_unavailable -> 남은 데드라인(TIMEOUT - 경과) 동안 3s 간격 디스크 재관측;
#                         completed/error 발견 시 그 상태 채택, 아니면 deadline_timeout
#   그 외(timeout/error/running) -> 즉시 registry.py close --request deadline_timeout
#   phase == preflight|idle_wait   -> registry.py close --request cancelled --detail session_busy (rc 5)
#                                    (프롬프트 미전달: deadline_timeout 금지 — Rev.2 Secondary)
#   direct 경로에서만 publish_event --event error|cancelled 추가 발행 (loop/discuss 금지: O-1)
#   MAM_DELEGATE_FAIL_CLOSED=0 이면 전부 no-op (롤백)
  • disk_st 재관측은 기존 :342-347의 인라인 파이썬을 함수 _disk_status로 추출해 재사용.
  • expected_dispatch_token은 구독자 기동 직전 레코드에서 읽어 지역변수에 보관(_disp_tok), 존재할 때만 전달(direct 잡은 없음).

3.1.3 종료 경로 5개 일원화

# 위치 (f98b958) 변경
E1 direct :227 wait "$sub_pid" || true wait "$sub_pid" || sub_rc=$? → 기존 :340-354 매핑 블록을 함수 _map_sub_rc로 추출(리터럴 문자열 보존) → finalize_unfinished → exit_code=$(_rc_for "$job_status")
E2 loop 워커 :359-362 break 직전 finalize_unfinished "$JOB_ID" "$job_status" "$sub_rc" worker
E3 loop 리뷰어 :375-378 동일 (reviewer)
E4 run_agent 실패 (:327 및 direct :204) if ! run_agent …; then reap_subscriber "$sub_pid"; finalize_unfinished "$JOB_ID" error 1 inject; exit 1; fi. trap(:523)은 --registry-dir "$REGISTRY_DIR" 추가 후 최후 보루로만 유지
E5 프리플라이트 실패(:456-473 herdr/세션 부재, :511-513 strict-role) 및 유휴 대기 최종 실패 Phase 2에서 preflight_agent로 이동. 실패 시 잡 등록 후라면 finalize_unfinished … preflight|idle_wait → cancelled(session_busy) 후 exit 5

3.1.4 종료 코드 계약 (SKILL.md에 문서화)

rc 의미
0 completed
1 error (에이전트 발행 / 주입·프리플라이트 실패)
2 구독자 타임아웃 → deadline_timeout 적용됨
3 브로커 장애 + 데드라인 내 디스크 미확인 → deadline_timeout 적용됨
5 session_busy: 프리플라이트/유휴 대기 실패로 프롬프트 미전달 → cancelled 적용됨 (재스케줄 가능)
마지막 stdout 줄(감사 로그 경로)은 모든 경로에서 유지한다(runner.delegate_job/run_loop.sh:373 파싱 계약).

3.1.5 dry-run 순서 (구독자 기동 :166 → dry-run return 0 :224): DRY_RUN=1이면 구독자를 기동하지 않는다(현재는 기동 후 return 0으로 고아 발생).

3.1.6 loop/discuss 역할 전환의 attempt_id 규약 (Rev.2 Tertiary — 기존 결함 수정)

  • 문제: :276-283은 워커·리뷰어 phase 모두 같은 $iteration으로 update --status pending(=redispatch)을 요청한다. engine.py:227-240은 terminal에서의 redispatch에 iteration 전진을 요구하므로 워커 completed 직후 리뷰어 전환이 rc=4로 거부되고 set -e로 래퍼가 죽는다(실측). F-A 테스트(test_issue5_phase2a_loop.py:727)는 iteration 2를 넘겨 이 경로를 비켜간다.
  • 설계: iteration은 루프 회차 라벨로 유지하고, **디스패치 단조 카운터 attempt_id**를 도입한다(엔진 :171이 이미 record.get("attempt_id", record.get("iteration"))으로 읽는 필드).
    1. 래퍼: 잡별 attempt=$((attempt+1))을 각 phase 디스패치 직전에 증가시키고 update … --attempt-id "$attempt"를 함께 넘긴다(워커 r1=1, 리뷰어 r1=2, 워커 r2=3 …).
    2. registry.py update에 --attempt-id 추가(:296-302).
    3. engine._decide redispatch 규칙: fields에 attempt_id가 있으면 attempt_id가 record.attempt_id(없으면 iteration)보다 커야 커밋; 없으면 기존 iteration 규칙 유지(F-A 3종 무변경). attempt_id는 redispatch/claim 커밋 시 레코드에 기록.
    4. publish_event.py:250-255의 --finalize-report 기본 attempt_id는 record.attempt_id를 우선하고 없으면 iteration.
  • 범위 주의: 이 수정으로 리뷰어 전환의 CAS 거부는 사라지지만, loop 모드의 리뷰어 phase는 O-1(리테인 재수락, §4)에 의해 여전히 즉시 종료된다. 따라서 Phase 5 E2E는 direct 모드만 수행하고, loop 모드 종단 검증은 O-1 이슈 완료 후로 명시한다.

3.2 Phase 2 — 사전 TUI 핸드셰이크

3.2.1 lib_py/loop/watcher.py CLI 진입점 (신규 main)

"$(_delegate_py_bin)" -m lib_py.loop.watcher wait <target> --session <herdr_session> [--until S]... [--timeout-ms N]
  stdout: {"kind": "matched|timeout|agent_not_found|transport_error", "agent_status": "...", "error_code": "..."}
  rc: matched 0 / timeout 2 / agent_not_found 3 / transport_error 4
  • wait_once를 그대로 사용(셸 중복 구현 금지). lib_py.delegation은 lib_py.loop를 임포트하지 않는다(패키지 규칙 유지) — 호출자는 셸(lib.sh)이다.
  • 격리 전달 (Rev.2 Primary-A): build_argv(target, until, timeout_ms, session=None)에 session 파라미터를 추가하고, 값이 있고 default가 아니면 argv를 ["herdr", "--session", session, "agent", "wait", …]로 구성한다. CLI --session은 미지정 시 HERDR_SESSION_NAME 환경변수를 읽는다. 실제 herdr는 HERDR_SESSION_NAME을 인식하지 않으므로(실측) 이 명시 전달이 없으면 기본 소켓(~/.config/herdr/herdr.sock)을 조회해 server_not_running/agent_not_found가 된다. 기존 wait_once 호출자(shadow.py 등)는 session=None으로 동작 불변.
  • 인터프리터 (Rev.2 Primary-B): bare python/python3 금지. lib.sh는 "$(_delegate_py_bin)"(lib.sh:1841)만 사용하며 PYTHONPATH는 lib.sh:25,164가 이미 보장한다. T2-11 정적 린트로 고정.

3.2.2 lib.sh: wait_for_agent_idle <sess> [timeout_ms] (신규, send_keys_safe 위)

  1. SKS_IDLE_HANDSHAKE=0 → return 0 (롤백).
  2. _sks_herdr agent get <target>(=mam_herdr, 격리 인지 셸 함수)로 agent_status 조회: unknown/조회 실패 → return 3(휴리스틱 폴백 신호, 호출자는 기존 _pane_quiescent 경로 진행); blocked → return 2(주입 금지; _pane_dialog_open 처리로 위임).
  3. idle/done → return 0. working → "$(_delegate_py_bin)" -m lib_py.loop.watcher wait <target> --session "$HERDR_SESSION_NAME" --until idle --until done --until blocked --timeout-ms ${SKS_IDLE_TIMEOUT_MS:-120000}: matched(idle/done) → 0, matched(blocked) → 2, timeout → 재시도, 그 외 → 3.
  4. 재시도 (Rev.2 Secondary): timeout은 SKS_IDLE_RETRIES(기본 2)회까지 SKS_IDLE_RETRY_BACKOFF_S(기본 10)초 간격으로 반복한다(기본 총 예산 ≈ 3×120s + 20s). 최종 실패 → return 1("agent still working"). 호출자(래퍼)는 rc 1을 finalize_unfinished … idle_wait(→ cancelled/rc 5)로 처리하며 deadline_timeout을 적용하지 않는다.
  • interactive_ready는 어떤 판정에도 사용하지 않는다.

3.2.3 래퍼 실행 순서 재배치 (cmd_submit)

register → brief 작성 → preflight_agent(herdr 존재·세션 존재·역할 검사)
        → wait_for_agent_idle "$sess"  (rc 1/2 → finalize_unfinished … idle_wait → cancelled/session_busy; exit 5)
        → _disp_tok 읽기 → 구독자 기동 + SUBSCRIBED 핸드셰이크
        → inject_agent(send_keys_safe) → wait sub → finalize
  • loop/discuss 반복마다 동일 순서(:286 구독자 기동 앞에 idle wait).

3.2.4 send_keys_safe 보강 (lib.sh:2231-2345)

  • 제출 검증(:2330-2332): pre_submit에 esc to interrupt/● |✽ |[A-Za-z]+ing가 이미 있었으면 해당 토큰을 성공 근거에서 제외하고 marker 소멸 + 화면 변화만 인정.
  • 네이티브 경로(:2279): SKS_PROMPT_WAIT=1(기본)이면 agent prompt <t> <text> --wait --until working --timeout 5000. 성공 → 0. agent_prompt_stalled/timeout → 재제출하지 않고 2s 후 agent get 1회: working → 0, 그 외 → rc 4("delivery unknown"). agent_blocked → rc 2.
  • 폴백 경로 진입 전 busy 토큰 게이트: wait_for_agent_idle이 3(unknown)을 반환한 경우에만 _pane_tail 8의 busy 토큰을 SKS_BUSY_TIMEOUT(기본 120s)까지 대기.

3.3 Phase 3 — 러너 Fail-Fast

  • runner.py:279-289: res.returncode != 0이어도 stdout에서 registered job: 매치 시 created_jobs.append + _record_checkpoint() 후 id 반환(로그는 error 레벨 유지). 매치 실패 시에만 None.
  • rc=5 (session_busy, Rev.2 Secondary): delegate_job이 MAM_SESSION_BUSY_RETRY_S(기본 30)초 대기 후 1회 재위임(새 job id 등록)한다. 재시도도 rc=5면 마지막 id를 반환하고 호출부는 wait_for_job이 cancelled로 즉시 False → 기존 abort 경로. 무한 재시도는 두지 않는다(교착의 새 형태).
  • wait_for_job은 변경하지 않는다(3900s는 최후 안전망; cancelled는 이미 False 조건).
  • run_loop.sh:159-168, 373: delegate_job_safe rc≠0에서도 출력에서 job id 추출(레거시 경로 동등성).

3.4 Phase 4 — 테스트 및 문서 (§5 참조)

  • tests/conftest.py mock_herdr: agent get/list에 agent_status(기본 idle) 추가; agent wait는 상태 파일의 status_sequence(예: ["working","working","done"])를 호출마다 소비, 소진 시 마지막 값 유지, --timeout은 시퀀스 소진 전 종료 시 {"error":{"code":"timeout"}} rc 1; agent prompt --wait는 prompt_wait_result 필드(ok|agent_prompt_stalled|agent_blocked)로 제어.
  • 문서: SKILL.md "Core Commands"에 종료 코드 표(§3.1.4), MESSAGING.md §5.1 구독자 타임아웃의 레지스트리 반영 서술, BOOTSTRAP*.md 에이전트 프로토콜에 "completed 발행 후 도구 호출 없이 턴 종료" 1문장.

3.5 Phase 5 — E2E·정리·릴리스

  • E2E 절차(direct 모드만; loop 모드 종단은 O-1 완료 후): 각 백엔드에 --agent human이 아닌 실세션으로 잡 A 위임 → completed 수신 즉시 잡 B 위임 → 래퍼 로그의 idle 대기 시간, herdr agent get 타임라인, B의 started 지연을 기록(docs/contracts/issue-6/phase5-e2e.md).
  • 타임아웃 주입: 세션에 /exit 없이 프롬프트를 무시하도록 --idle-timeout 30으로 위임 → 래퍼 rc=2, 레지스트리 error, wait_for_job ≤ 2s.
  • 잔존 레코드 93a44347, 2961b822, ee242671: registry.py close --job <id> --request deadline_timeout --detail "Issue #6 backfill".
  • 릴리스: v4.4.1 (PATCH — 공개 계약 삭제 없음, 종료 코드는 오동작 정정, timeout 서브커맨드는 래퍼 내부 배관). 4-way 락스텝: lib.sh:32, eager.py:16, VERSIONS.md(개요+매트릭스+Changelog), 8개 SKILL.md frontmatter. IMPROVEMENTS.md §5에 Issue-6-FailClosed-TurnHandshake 블록 추가(§6 표·헤더 키 목록 동기화).

4. 명시적 비범위 (Out of Scope) — 별도 이슈

  1. O-1 리테인 터미널 재수락: 재디스패치 후 2회차 구독자가 1회차 retained completed를 즉시 수락(실측). loop 모드 기존 결함. 본 이슈에서는 "loop 경로 retained 발행 금지"로 회피만 한다. Rev.2 주의: §3.1.6의 attempt_id 수정 후에도 loop 모드 리뷰어 phase는 O-1 때문에 종단 동작하지 않는다. 별도 이슈를 Issue #6 릴리스 노트에 "loop/discuss 모드 사용 전 필수"로 명시한다.
  2. 레지스트리 오염: .mam/jobs에 pytest 세션명 레코드 467건 누출 원인.
  3. 세션별 전달 직렬화 락, direct 잡 dispatch_token 발급: 현 구조(동기 위임 + loop lock)에서 불필요.
  4. wait_for_job 3900s 단축: 증상 완화이므로 변경하지 않는다.

5. 회귀 테스트 행렬 (Phase 4 산출물)

5.1 tests/test_issue6_fail_closed.py (브로커 무관, --agent human + 짧은 타임아웃)

ID 시나리오 기대
T1-1 direct, 이벤트 없음 (--timeout 6 --idle-timeout 2) rc=2, 레지스트리 error, events.log에 wrapper error 1건, 구독자 pid 소멸
T1-2 loop, 워커 phase 이벤트 없음 rc=2, error, retained 발행 없음
T1-3 구독자 타임아웃 직후 publish_event completed 경쟁 최종 completed 유지, timeout 서브커맨드 rc 0(거부 로그)
T1-4 MQTT_HOST 불가 + 디스크 비종료 (--timeout 8) rc=3, 데드라인까지 재관측 로그, 최종 error
T1-5 rc=3 + 재관측 중 디스크 completed 출현 rc=0, completed
T1-6 close --request deadline_timeout을 completed 레코드에 적용 rc=0, 상태 불변
T1-10 loop: 워커 completed 후 리뷰어 update --status pending --iteration 1 --attempt-id 2 rc=0, pending, attempt_id=2 (수정 전 rc=4 → 역검증)
T1-11 같은 --attempt-id 2 재요청(리플레이) rc=4 거부
T1-12 --attempt-id 없이 iteration 2 (F-A 경로) rc=0 (기존 규칙 보존), F-A 3종 무변경 통과
T1-13 close --request cancelled --detail session_busy: pending→cancelled rc=0, completed→ rc=0·상태 불변, 이후 redispatch iteration 2 → pending 전이 규칙 고정
T1-7 커스텀 --registry-dir + send_keys_safe 실패(BASH_ENV 오버라이드) rc=1, 해당 레지스트리 error
T1-8 MAM_DELEGATE_FAIL_CLOSED=0 T1-1 조건에서 rc=0, pending (구 동작 재현)
T1-9 dry-run direct 구독자 프로세스 미기동
고정 test_wrapper_exit_code_mapping_literals_are_frozen 통과

5.2 tests/test_issue6_turn_handshake.py (mock_herdr 상태 시퀀스)

ID 시나리오 기대
T2-1 status_sequence=["working","done"] agent wait 호출 후 agent prompt 호출(순서 단정), Enter/C-m 0회(네이티브)
T2-2 working 고정, SKS_IDLE_TIMEOUT_MS=1000 rc=1, agent prompt 미호출, 래퍼 rc=1·error
T2-3 unknown agent wait 미호출, _pane_quiescent 폴백 경로 진입
T2-4 blocked rc=2, 주입 없음
T2-5 done 즉시 대기 0회, 즉시 주입
T2-6 폴백 경로, pre_submit에 esc to interrupt 존재 + 페이스트 드롭 rc=4 (기존 코드에서는 0 → 역검증)
T2-7 prompt_wait_result=agent_prompt_stalled + 이후 working rc=0, prompt 호출 1회
T2-8 래퍼 순서: idle 대기 로그가 subscriber pid: 로그보다 앞 로그 순서 단정
T2-9 working 고정 + SKS_IDLE_RETRIES=1, SKS_IDLE_TIMEOUT_MS=500 agent wait 2회 호출, 래퍼 rc=5, 레지스트리 cancelled(detail session_busy), deadline_timeout 미호출, 구독자 미기동
T2-10 HERDR_SESSION_NAME=iso-x에서 wait_for_agent_idle mock 호출 기록에 herdr --session iso-x agent wait … (argv 순서 단정); build_argv(session="default")는 --session 생략
T2-11 정적 린트: lib.sh·래퍼에서 ^\s*python3? -m lib_py\.loop 또는 bare python -m 패턴 0건, watcher 호출은 _delegate_py_bin 경유 grep 단정
호환 test_h16, test_b8, test_c1_*, test_herdr_shim_contract 무변경 통과

5.3 Phase 3 (tests/test_issue6_fail_closed.py 내)

ID 시나리오 기대
T3-1 LoopRunner.delegate_job rc≠0 + registered job: 출력 id 반환, wait_for_job ≤ 2s False
T3-2 동일 .mam/loop-checkpoint.json에 id 기록
T3-3 래퍼 stub이 1회차 rc=5, 2회차 rc=0 (MAM_SESSION_BUSY_RETRY_S=0) 재위임 1회 후 2회차 id 반환; 두 번 다 rc=5면 마지막 id 반환 + wait_for_job False

6. 공수 및 순서

단계 공수 선행
Phase 1 1.25 MD (+0.25 attempt_id) —
Phase 2 1.25–1.75 MD (+0.25 세션 전달·재시도) Phase 1 (finalize 헬퍼 재사용)
Phase 3 0.25 MD Phase 1
Phase 4 0.75 MD (Phase 1~3와 병행 작성, 역검증 포함) —
Phase 5 0.5–1.0 MD 전부
합계 4.0–5.0 MD (통합 보고서 Rev.2 §3 상한 대비 +0.5 MD; 사유 §7)

7. Creator 이의제기 판정 (Rev.1 → Rev.2)

이의 판정 실측 근거 Rev.2 반영
Primary-A watcher.py가 HERDR_SESSION_NAME 격리를 잃는다 수용 (세부 정정: 실제 herdr 플래그는 -L이 아니라 --session; -L은 .mam/shim 번역 전용. 격리 변수는 셸 함수 mam_herdr/_real_herdr가 번역하며 Python 서브프로세스에는 상속되지 않음) HERDR_SESSION_NAME만 있는 환경에서 bare herdr → server_not_running; --session/HERDR_SESSION/HERDR_SOCKET_PATH → 정상. lib.sh source 후 Python subprocess도 동일 실패 §1 원칙 8, §3.2.1 --session 전달, §3.2.2 _sks_herdr 사용, T2-10
Primary-B bare python -m 호출은 ModuleNotFoundError 수용 (Rev.1 표기 오류) lib.sh:1841 _delegate_py_bin, PYTHONPATH export lib.sh:25,164 §3.2.1/3.2.2 "$(_delegate_py_bin)", T2-11 정적 린트
Secondary 주입 전 유휴 대기 실패를 deadline_timeout(terminal error)로 닫으면 미주입 잡이 실행 실패로 낙인 + 러너 즉시 abort 수용 (대안 채택: 래퍼 내 bounded 재시도 → cancelled/session_busy rc 5 → 러너 1회 재위임). "재시도 기회 영구 박탈" 부분은 부분 정정: cancelled도 operator redispatch로 재개 가능(실측) engine.transition: pending→cancelled 커밋, completed→cancelled 거부, cancelled→redispatch 성공 §1 원칙 7, §3.1.1 close, §3.1.2/3.1.4 rc 5, §3.2.2 재시도, §3.3 러너 재위임, T1-13/T2-9/T3-3
Tertiary 워커→리뷰어 전환 시 같은 iteration의 redispatch 거부 수용 (인용 코드는 실제 engine.py:227-240과 다르나 결론은 정확). v4.4.0에서 이미 깨져 있는 기존 결함임을 확인 registry.py update --status pending --iteration 1(워커 completed 후) → rc=4 does not advance past 1; iteration 2 → rc=0 §3.1.6 attempt_id 규약, T1-10~T1-12
(부수) Rev.2 통합 보고서 §3의 lib.sh 경로 Rev.1과 동일하게 실제 경로 .agents/skills/lib.sh 사용 — —

공수 +0.5 MD의 사유: attempt_id 규약(엔진 규칙·CLI·publish_event 기본값·테스트 3종)과 세션 전달·재시도 로직·러너 재위임(테스트 3종)이 Rev.1에 없던 신규 작업이다.