docs(issue-6): establish phase 1-5 implementation plan and task checklist (Rev.2)
This commit is contained in:
1 parent
f98b9582c7
commit
7b761be7b8
6 files changed
+827
No files matched your search
@@ -0,0 +1,121 @@
|
||||
# 📑 Issue #6 종합 리뷰 및 아키텍처 개선 통합 보고서 (Rev. 2)
|
||||
|
||||
- **작성 주체**: 오케스트레이터 (Orchestrator)
|
||||
- **참여 리뷰어**: `planner-reviewer-claude-01` (Claude), `reviewer-creator-grok-01` (Grok), `planner-reviewer-codex-01` (Codex), `reviewer-opencode-01` (OpenCode)
|
||||
- **검토 대상**: Gitea [Issue #6](https://git.godopu.com/tmpl/multi-agent-mux/issues/6) — `[Bug] delegate-job 워커 실패 시 레지스트리 미갱신(Fail-Silent) 및 TUI 턴 마감 레이스 컨디션 해결`
|
||||
- **검토 기준 커밋**: `f98b958` (`main`, `refactor`, v4.4.0)
|
||||
- **문서 버전**: Revision 2 (Rev. 2 — 2차 라운드 리뷰어 피드백 전건 반영)
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요 및 총평
|
||||
|
||||
오케스트레이터 루프(`run_loop.sh` / `lib_py.loop`) 실행 중 발생했던 3,900초 영구 대기(hang) 결함(Issue #6)에 대해 4명의 리뷰어가 독립적으로 소스 코드 추적, 단위 격리 재현, 실측 포렌식, Herdr 0.8.2 네이티브 API 계약 분석을 수행했습니다.
|
||||
|
||||
**리뷰어 4인 전원의 만장일치 판정 결과:**
|
||||
> **"Issue #6에서 제기한 결함 ①과 결함 ②는 모두 실제 코드베이스에 존재하는 심각한 실재 버그 및 설계 결함이다."**
|
||||
|
||||
특히 2차 라운드 교차 검토를 통해 **루프 실사용 경로(`--type direct`)의 숨은 결함**, **기존 `deadline_timeout` CAS 엔진 재사용 원칙**, **구독자 타임아웃 예산 자가포식 방지를 위한 사전 핸드셰이크 순서**, **브로커 장애(`rc=3`) 격리** 등 프로덕션 수준의 정밀한 가드레일이 통합 보고서 Rev.2에 완벽히 보강되었습니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 결함별 진위 검토 및 심층 분석
|
||||
|
||||
### 2.1 결함 ①: `multi-agent-mux-delegate-job` 워커 실패 시 레지스트리 미갱신 (Fail-Silent 버그)
|
||||
- **판정**: **실재하는 핵심 결함 (CONFIRMED REAL, P0/P1)**
|
||||
- **실측 및 재현 결과**:
|
||||
1. **이슈 지목 위치(`:358-362`)의 진위**:
|
||||
- `_phase == "worker"`에서 `job_status != "completed"`일 때 `break` 후 레지스트리(`registry.py status --set error`) 갱신 없이 함수 끝에서 로그 경로를 출력하며 `exit 0`으로 종료되는 현상이 정확히 확인되었습니다.
|
||||
- 동일한 결함이 리뷰어 단계(`:375-378`) 조기 탈출 분기에도 동일하게 존재합니다.
|
||||
2. **오케스트레이터 실사용 경로(`--type direct:227`)의 숨은 결함 (전원 공통 발견)**:
|
||||
- 상위 오케스트레이터(`lib_py/loop/runner.py:271` 및 `run_loop.sh`)는 루프 내부에서 `--type loop`가 아닌 **`--type direct`**로 에이전트 작업을 위임합니다.
|
||||
- direct 경로(`:227`)는 `run_agent` 실행 후 `wait "$sub_pid" || true`로 구독자 실패를 삼켜버리고, 상태 전이를 전혀 거치지 않은 채 `exit 0`을 반환합니다.
|
||||
- 따라서 이슈가 언급한 루프 분기뿐만 아니라 **오케스트레이터가 실제로 타는 direct 경로 역시 완전히 침묵(Fail-Silent)**하고 있었습니다.
|
||||
3. **파급 효과 (65분 Hang)**:
|
||||
- 상위 루프의 `wait_for_job`은 `completed`, `error`, `cancelled` 3개 터미널 상태만 종료 조건으로 인식합니다.
|
||||
- 구독자가 120초 idle timeout(`sub_rc=2`)으로 죽었음에도 레지스트리가 `pending` 또는 `running`으로 방치되어 상위 루프가 기본값인 **3,900초(65분) 동안 블로킹**됩니다.
|
||||
- 실제 운영 레지스트리 포렌식 결과 `93a44347`, `2961b822`, `ee242671` 3건의 실운영 작업이 정확히 이 결함으로 장시간 교착 상태에 방치되었던 사실이 실증되었습니다.
|
||||
|
||||
### 2.2 결함 ②: 에이전트 이벤트 발행과 TUI 복귀 간의 레이스 컨디션
|
||||
- **판정**: **실재하는 설계 결함 (CONFIRMED REAL, P1)**
|
||||
- **실측 및 재현 결과**:
|
||||
1. **비동기 상태 불일치 레이스**:
|
||||
- 에이전트 프로세스가 MQTT로 `completed` 이벤트를 발행한 직후에도 터미널 화면은 마크다운 렌더링, 버퍼 플러시, 툴 호출 정리 등으로 인해 여전히 `working/busy` 상태일 수 있습니다.
|
||||
- 실측 결과 Codex 등 대형 모델의 경우 `completed` 발행 후 TUI가 `done`으로 떨어지는 데 최대 31초가 소요되기도 했습니다.
|
||||
2. **프롬프트 묵살 및 유실**:
|
||||
- 다음 단계(리뷰어 등)가 즉시 프롬프트를 전송하면 TUI가 입력을 무시하거나 첫 글자/단어가 잘린 채 주입되어 문맥이 깨진 채 실행되거나 무응답 상태에 빠집니다.
|
||||
3. **두 결함 간의 악순환 증폭**:
|
||||
- 결함 ②(TUI 미준비 프롬프트 유실) 발생 → 워커 무응답 → 구독자 120초 타임아웃(`sub_rc=2`) → 결함 ①(Fail-Silent) 발동 → 레지스트리 `pending/running` 방치 → 상위 루프 65분 영구 교착(Hang).
|
||||
|
||||
---
|
||||
|
||||
## 3. 수정 난이도 및 공수 산정 (Consensus)
|
||||
|
||||
| 영역 | 난이도 | 예상 공수 | 핵심 변경 대상 |
|
||||
| :--- | :---: | :---: | :--- |
|
||||
| **결함 ① (Fail-Closed CAS)** | **중하 (Low-Med)** | 1.0 ~ 1.5 MD | `skills/.../multi-agent-mux-delegate-job`, `lib_py/delegation/engine.py` |
|
||||
| **결함 ② (TUI 턴 핸드셰이크)** | **중 (Medium)** | 1.0 ~ 1.5 MD | `.agents/skills/multi-agent-mux-create/scripts/lib.sh` (`send_keys_safe`), Herdr API 연동 |
|
||||
| **통합 테스트 & 다중 에이전트 실측** | **중 (Medium)** | 1.0 ~ 1.5 MD | 4개 에이전트(Claude/Grok/Codex/OpenCode) 실기동 E2E 검증, Mock 단위 테스트 |
|
||||
| **총계** | **중 (Medium)** | **3.0 ~ 4.5 Engineer-Days** | 안전하고 통제된 점진적 구현 가능 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 상세 기술 아키텍처 및 구현 사양 (Rev. 2 보강)
|
||||
|
||||
### 4.1 결함 ① 해결책: 레지스트리 Fail-Closed 및 CAS 엔진 연동
|
||||
1. **표준 CAS 전이(`deadline_timeout`) 활용**:
|
||||
- 임의의 신규 CLI 플래그(`--expect-status`)나 운영자 강제 오버라이드를 발명하지 않고, 이미 검증된 `lib_py/delegation/engine.py:50`의 `deadline_timeout` 전이를 호출합니다.
|
||||
- `deadline_timeout`은 잡이 `pending` 또는 `running`일 때만 원자적으로 `error`로 전이하며, 구독자 타임아웃 직후 백엔드에서 늦게 도착한 `completed` 이벤트가 이미 커밋된 경우 `"conflicting error after terminal completed; refusing"`으로 안전하게 거부(No-op)합니다.
|
||||
- `expected_dispatch_token`을 전달하여 이전 시도의 늦은 타임아웃이 재시도 잡을 오염시키는 ABA 레이스를 원천 차단합니다.
|
||||
2. **`multi-agent-mux-delegate-job` 공통 종료 헬퍼 (`finalize_unfinished`) 도입**:
|
||||
- direct 경로(`:227`) 및 loop/discuss 경로(`:358`, `:375`)에 공통 적용.
|
||||
- 구독자 종료 시 `wait "$sub_pid"` 수집 및 미완료 잡에 대해 `finalize_unfinished`를 호출하여 상태를 Fail-Closed 처리.
|
||||
- 래퍼 자체도 자식 구독자 프로세스를 안전하게 수거(`kill "$sub_pid" 2>/dev/null; wait "$sub_pid"`)하여 3,600초 고아 프로세스 방지.
|
||||
3. **브로커 장애(`rc=3`)와 워커 실패 격리 (B-15 계약 준수)**:
|
||||
- 구독자가 `rc=3`(`broker_unavailable`)으로 종료되었으나 디스크 상에 이벤트가 살아있을 가능성이 있는 경우, 즉시 실패로 단정하지 않고 잡 데드라인까지 레지스트리를 재확인하여 브로커 일시 순단에 의한 거짓 실패를 방지합니다.
|
||||
4. **리테인 이벤트 오염 방지 (O-1)**:
|
||||
- loop/discuss 모드 실패 시 MQTT에 `error` 이벤트를 retain 상태로 발행하지 않아, 재시도 시 이전 에러 이벤트가 즉시 재수신되는 현상을 방지합니다.
|
||||
5. **오케스트레이터 러너 Fail-Fast 연결**:
|
||||
- `lib_py/loop/runner.py:delegate_job`이 래퍼 실패(rc≠0) 시에도 `registered job:` 출력을 파싱하여 `wait_for_job`에 넘기도록 보장 (디스크가 이미 `error`이므로 1초 내 False 반환하여 65분 교착 원천 차단).
|
||||
|
||||
### 4.2 결함 ② 해결책: TUI 턴 핸드셰이크 시퀀스 및 예산 보강
|
||||
1. **핸드셰이크 실행 순서 (구독자 시작 전 배치)**:
|
||||
- **중요**: `wait_for_agent_idle` 핸드셰이크는 반드시 **구독자(`job_subscriber.py`) 기동 전**에 완료되어야 합니다.
|
||||
- 구독자 실행 후에 대기할 경우, 에이전트의 유휴 대기 시간(15~30초)이 구독자의 120초 idle timeout 예산을 잠식(자가 포식)하는 결함이 발생합니다.
|
||||
2. **유휴 대기 예산 확대 (15초 → 120초)**:
|
||||
- Codex 등 복잡한 마크다운/도구 출력을 렌더링하는 대형 모델은 `completed` 이벤트 후 TUI가 완전히 `done`으로 복귀하는 데 실측 31초가 걸렸습니다.
|
||||
- 따라서 15초 하드코딩 대신 기본 120초(`SKS_IDLE_TIMEOUT_MS=120000`)를 적용하고 환경변수로 유연하게 조절할 수 있도록 합니다.
|
||||
3. **Herdr 네이티브 대기 API 우선 사용 및 Fallback 견고화**:
|
||||
- `herdr agent wait <session> --until idle --until done --timeout "$SKS_IDLE_TIMEOUT_MS"`를 우선 호출.
|
||||
- `lib_py.loop.watcher.wait_once` 메커니즘을 재사용하여 쉘 중복 구현을 방지.
|
||||
- 헤드리스 환경 등 `agent_status`가 `unknown`인 환경에서는 기존의 `_pane_quiescent` 정밀 휴리스틱으로 우아하게 Fallback.
|
||||
4. **수락 확인 및 이전 턴 잔여 토큰 오판 방지**:
|
||||
- 키 전송 후 `herdr agent prompt --wait --until working` 수락 확인.
|
||||
- Fallback 경로에서 이전 턴의 잔여 busy 토큰(`esc to interrupt` 등)이 키 전송 전 캡처 화면에 이미 존재했던 경우, 이를 새 입력의 작업 시작 근거로 오판하지 않도록 사전 스냅샷과 비교 검증.
|
||||
|
||||
---
|
||||
|
||||
## 5. 단계별 실행 계획 (5-Phase Roadmap)
|
||||
|
||||
- **Phase 1: 레지스트리 CAS 표준 연동 및 delegate-job Fail-Closed 구현**
|
||||
- `engine.py` `deadline_timeout` 호출 경로 확립
|
||||
- `multi-agent-mux-delegate-job` 내 `direct`(:227) 및 `loop/discuss` 종료 정리부 일원화
|
||||
- **Phase 2: `send_keys_safe` 사전 유휴 핸드셰이크(Pre-Subscriber Idle Wait) 구현**
|
||||
- `SKS_IDLE_TIMEOUT_MS` 기본 120초 지원
|
||||
- `herdr agent wait` 1차 게이트 및 잔여 토큰 오판 방지
|
||||
- **Phase 3: 오케스트레이터 러너 Fail-Fast 방어막 보강**
|
||||
- `lib_py/loop/runner.py` 예외 및 비정상 종료 시 조기 수거 연결
|
||||
- **Phase 4: 단위/계약 격리 회귀 테스트 슈트 구축**
|
||||
- `tests/test_issue6_fail_closed.py`
|
||||
- `tests/test_issue6_turn_handshake.py`
|
||||
- 기존 750개 pytest 100% 무결성 검증
|
||||
- **Phase 5: 4개 실기동 에이전트(Claude, Grok, Codex, OpenCode) 대상 E2E 검증**
|
||||
- 타임아웃 주입 및 TUI 연속 프롬프트 주입 실측 통과 확인
|
||||
|
||||
---
|
||||
|
||||
## 6. 결론
|
||||
|
||||
Issue #6은 코드베이스의 구조적 결함이라기보다, **비동기 이벤트 분산 환경에서 발생하는 상태 불일치(Event vs TUI)와 비정상 종료 시의 예외 처리 누락(Fail-Silent)**이 맞물려 발생한 전형적인 분산 시스템 레이스 컨디션입니다.
|
||||
|
||||
모든 리뷰어가 진단과 해결책에 완전히 합의하였고, 2차 라운드를 통해 엣지케이스까지 방어 사양이 완벽히 수립되었으므로 본 Rev.2 사양에 따라 즉시 구현에 착수할 수 있습니다.
|
||||
@@ -0,0 +1,242 @@
|
||||
# 📑 Issue #6 Implementation Plan (Phase 1–5 Execution & Gate Specification)
|
||||
|
||||
- **Document Version**: Rev.2 (Planner `planner-reviewer-claude-01`, job `f4873d82` — Creator `creator-agy-01` 이의제기 3건 반영; Rev.1은 job `d2d70f57`)
|
||||
- **Issue**: [Gitea Issue #6 — `[Bug] delegate-job 워커 실패 시 레지스트리 미갱신(Fail-Silent) 및 TUI 턴 마감 레이스 컨디션 해결`](https://git.godopu.com/tmpl/multi-agent-mux/issues/6)
|
||||
- **Source of Truth**: `.agents/reports/issue_6_consolidated_review_report.md` (Rev. 2) — 4인 리뷰어 합의 사양
|
||||
- **Baseline Commit**: `f98b958` (`main`, v4.4.0), Herdr `0.8.2`, Python 3.9.6, 전체 스위트 750 passed
|
||||
- **Status**: 착수 전 (Phase 1 대기)
|
||||
|
||||
---
|
||||
|
||||
## 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` 서브커맨드<br>• 래퍼 `finalize_unfinished`/`reap_subscriber` 헬퍼, direct(`:227`)·loop(`:359`,`:375`)·`run_agent` 실패·프리플라이트 실패 5개 종료 경로 일원화<br>• rc=3 디스크 재관측, trap `--registry-dir`, dry-run 순서, 종료 코드 계약(rc 5 `session_busy` 포함)<br>• **loop 역할 전환 `attempt_id` 규약** (§3.1.6) | • `--agent human` 결정적 재현 4종(§5 T1-1~T1-4)이 수정 전 실패·수정 후 통과<br>• T1-10(워커 완료→리뷰어 재디스패치 성공) 수정 전 rc=4·수정 후 rc=0<br>• `test_wrapper_exit_code_mapping_literals_are_frozen`·F-A 3종 무변경 통과<br>• `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"`)<br>• `lib.sh: wait_for_agent_idle` (사전 `mam_herdr agent get` → `unknown`/`blocked` 분기 → watcher CLI, `SKS_IDLE_RETRIES` 백오프 재시도, 최종 실패 rc 1 → 래퍼 rc 5 `cancelled`)<br>• 래퍼 `run_agent`를 `preflight_agent`+`inject_agent`로 분리, 순서: preflight → idle wait → subscriber → inject<br>• `send_keys_safe` 폴백 제출 검증에서 pre-submit busy 토큰 배제, `agent prompt --wait --until working` 수락 확인(재제출 금지) | • mock_herdr 상태 시퀀스 테스트 §5 T2-1~T2-11 통과<br>• `test_h16`(Enter 정확히 1회)·`test_b8`·`test_c1_*` 무변경 통과<br>• 래퍼 로그에서 `SUBSCRIBED` 이전에 idle 대기 완료 로그가 출력됨<br>• 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 반환<br>• **rc=5(`session_busy`)는 `MAM_SESSION_BUSY_RETRY_S`(기본 30) 대기 후 1회 재위임(새 job id)**, 재실패 시 None<br>• 호출부는 `wait_for_job`이 디스크 `error`/`cancelled`로 1s 내 False<br>• `run_loop.sh` 레거시 `delegate_job_safe` 동일 파싱(`MAM_LOOP_PY=0` 경로 보존) | • `LoopRunner.wait_for_job`이 fail-closed 잡에서 ≤ 2s 반환(T3-1)<br>• 실패 job id가 `.mam/loop-checkpoint.json`에 기록(T3-2)<br>• rc=5 1회 재위임 후 성공/실패 분기(T3-3)<br>• `test_issue5_phase3_loop.py` 무변경 통과 | 함수 단위 revert (플래그 불필요: rc≠0 경로만 변경) |
|
||||
| **Phase 4** | **단위/계약 회귀 스위트** | • `tests/test_issue6_fail_closed.py`, `tests/test_issue6_turn_handshake.py`<br>• `tests/conftest.py` mock_herdr에 `agent_status`·`agent wait`·`agent prompt --wait` 모킹<br>• 문서: SKILL.md 종료 코드 계약, MESSAGING.md §5.1 보강 | • 신규 테스트 전부 **수정 전 코드에서 실패함을 역검증**(Issue #5 관례)<br>• 전체 스위트 750 + 신규 ≥ 20, 0-Fail<br>• `git diff --check` 클린 | 테스트 파일 삭제만으로 원복 |
|
||||
| **Phase 5** | **실기동 4개 에이전트 E2E 및 릴리스** | • Claude/Grok/Codex/OpenCode 각 1회: `completed` 직후 동일 세션 연속 위임 타임라인 기록<br>• 타임아웃 주입(에이전트 무응답)으로 65분 대신 ≤ `IDLE_TIMEOUT`+5s 내 `error` 확인<br>• 잔존 레코드 3건 폐쇄, IMPROVEMENTS.md 원장, VERSIONS.md `v4.4.1` 4-way 락스텝 | • 4개 백엔드 연속 위임 주입 실패 0건, 중복 제출 0건<br>• `test_version_consistency.py` 통과<br>• 리뷰어 교차 검토 `[VERDICT: PASS]` | 릴리스 태그 미부여 상태로 `main` revert |
|
||||
|
||||
---
|
||||
|
||||
## 3. 상세 구현 사양
|
||||
|
||||
### 3.1 Phase 1 — Fail-Closed
|
||||
|
||||
**3.1.1 `lib_py/delegation/registry.py` — `close` 서브커맨드 (신규, Rev.2: `timeout`을 일반화)**
|
||||
```text
|
||||
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` 위에 정의)**
|
||||
```bash
|
||||
# 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`)**
|
||||
```text
|
||||
"$(_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`)**
|
||||
```text
|
||||
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에 없던 신규 작업이다.
|
||||
@@ -0,0 +1,67 @@
|
||||
### [reviewer-opencode-01] Issue #6 정밀 검토 및 해결 방안 보고서
|
||||
|
||||
- **Job**: `17f2e688` / **검토자**: `reviewer-opencode-01` (opencode)
|
||||
- **검토 방법**: 이슈가 인용한 모든 위치를 라이브 소스에서 직접 읽고, 결함 체인 전체(worker break → 레지스트리 → wait_for_job)를 코드 라인 단위로 추적하며, 셸 루프(`run_loop.sh`)와 Python 루프(`lib_py/loop/runner.py`) 양쪽 소비자를 대조하고, 기존 테스트 커버리지를 확인했다. 요약을 신뢰하지 않고 재현 경로를 직접 검증했다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 버그 진위 여부 검토 결과
|
||||
|
||||
### 결함 ① (Fail-Silent on Worker Failure) — **실재 확인 (CONFIRMED REAL)**
|
||||
|
||||
이슈의 인용은 정확하다. `multi-agent-mux-delegate-job`의 반복 루프:
|
||||
|
||||
- **:351-361** — `_phase == "worker"`에서 `job_status != "completed"`인 경우(주입 실패로 인한 subscriber timeout, 프롬프트 미수신 idle timeout 등) `echo ... Terminating workflow.` 후 `break`.
|
||||
- 이 `break` 경로에는 **어디에도** `registry.py status --set` 호출이 없다. 반비교: 루프 내 다른 종료 경로는 레지스트리를 올바르게 갱신한다 — reviewer 승인 시 `--set "completed"` (:394), 최대 반복 도달 시 `--set "error"` (:400).
|
||||
- 즉 worker 실패 조기 탈출이 **유일하게 레지스트리를 방치하는 종료 경로**다. 잡은 생성 시 `status: "pending"`(`lib_py/delegation/registry.py:84`)으로 등록되므로, 이 경로를 밟으면 `<job_id>.json`은 **영구히 `pending`**으로 남는다.
|
||||
- 함수는 이후 validation hook을 돌고 audit-log 경로를 stdout으로 출력한 뒤 정상 종료한다 — `exit 0`(fail-silent). 이슈 기술과 정확히 일치.
|
||||
|
||||
**소비자 측 교착 확인**: 셸 `wait_for_job`(`run_loop.sh:270-306`)과 Python `wait_for_job`(`lib_py/loop/runner.py:291-323`) 모두 종료 조건이 `completed`(→rc 0), `error`/`cancelled`(→rc 1)뿐이고, 그 외 상태(`pending` 포함)는 `sleep $check_interval` 후 재폴링한다. `pending`은 어떤 종료 조건에도 해당하지 않으므로 **`max_wait=3900s`를 전량 소진한 뒤 타임아웃으로 rc=1** — 정확히 이슈가 기술한 65분 영구 대기. 두 런타임(v4.4.0의 Python 코어 포함) 모두 동일하게 노출된다.
|
||||
|
||||
### 결함 ② (Race Condition on TUI Turn Handshake) — **실재 확인 (CONFIRMED REAL, 그러나 이미 부분 완화 장치 존재 — 임계값/대기시간 문제)**
|
||||
|
||||
- `send_keys_safe`(`lib.sh:2231-`)는 주입 전 `_pane_quiescent`(:2189)로 **연속 2회 동일 캡처**(렌더러 정지)를 확인한다. 이것이 바로 이슈가 요구하는 settling 브리미티브의 존재 자체는 이미 구현되어 있다.
|
||||
- 다만 실측 파라미터: `SKS_QUIESCENT_TRIES=20` × `SKS_QUIESCENT_INTERVAL=0.5s` = **최대 10초 대기**. 에이전트가 도구로 `completed` 이벤트를 발행한 직후(턴 내부) 오케스트레이터가 즉시 다음 잡을 생성해 주입하면, 에이전트 TUI는 아직 턴 출력을 렌더링 중이므로 캡처 내용이 계속 변한다. 렌더링이 10초 이상 지속되면 `_pane_quiescent`가 rc=1을 반환하고 `send_keys_safe`는 "pane never quiesced"와 함께 **주입 자체를 거부**한다.
|
||||
- **결함 ②와 ①의 연쇄(핵심)**: `run_agent`의 주입 실패는 rc=1을 반환하지만, 루프의 호출부(:327) `run_agent "$JOB_ID" ... `는 **반환값을 무시한다**(`if ! run_agent` 아님). 이후 `wait "$sub_pid"`는 프롬프트가 주입되지 않은 에이전트를 대기하다 idle timeout으로 rc≥2 → `job_status="timeout"` → worker break → 결함 ①의 fail-silent → `pending` 교착. 즉 **②는 ①의 주요 트리거이며, ①은 ②의 피해를 교착으로 증폭**한다. 두 결함은 독립 패치가 아니라 하나의 수정 단위로 다뤄야 한다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 결함 메커니즘 및 파급 효과 분석
|
||||
|
||||
| 항목 | 분석 |
|
||||
|---|---|
|
||||
| **발생 빈도** | 결함 ②는 렌더링 지연 > 10s인 에이전트/대형 출력에서 현실적으로 발생(이슈 자체가 실관측 사례). ②가 터질 때마다 ①로 인해 65분 교착이 뒤따른다. |
|
||||
| **루프 관점 파급** | `run_loop.sh`의 `--max-loop` 사이클 전체가 단일 worker 실패 하나로 최대 65분 정지. 병렬 리뷰어(`--all-reviewer`) 펜딘트 시 리소스 점유 + 대기 누적. |
|
||||
| **데이터 무결성** | `<job_id>.json`의 `pending`은 "미수행"과 "수행 실패"를 구별 불가하게 만든다 — 감사 로그(events.log)에 subscriber 기록은 남지만, 상태 원장 자체가 거짓("아직 안 함"). 이는 B-14(로컬 디스크 100% 동기화)에서 수립한 "디스크 상태가 진실"이라는 계약의 잔여 예외다. |
|
||||
| **심각도** | ①: **HIGH** (영구 교착 + 원장 오염). ②: **MEDIUM-HIGH** (교착의 주 트리거, 다만 quiescent 브리미티브가 있어 완전 무방비는 아님). |
|
||||
|
||||
## 3. 수정 난이도 및 위험도 평가
|
||||
|
||||
| 결함 | 난이도 | 공수 | 파급 범위 / 위험 |
|
||||
|---|---|---|---|
|
||||
| ① 레지스트리 fail-closed 전이 | **하 (Low)** | ~0.5일 (테스트 포함) | worker/reviewer 양 break 경로에 `status --set error` 1줄씩 추가. 소비자(`wait_for_job` 2개)는 이미 `error`를 정상 종료 조건으로 다루므로 **소비자 변경 불필요** — 파급 최소. 유일 위험: break 시점의 상태를 `error`로 덮어쓸 때 이미 디스크에 terminal 상태가 있는 경우(rc=3 broker_unavailable 분기가 이미 디스크 우선 병합을 하므로 그대로 재사용). |
|
||||
| ② TUI settling 안전화 | **중 (Medium)** | ~1일 | `run_agent` 반환값 접수(루프 :327의 `if ! run_agent` 화) + quiescent 파라미터 튜닝/환경변수화(`SKS_QUIESCENT_TRIES/INTERVAL`는 이미 튜너블). 주입 실패를 즉시 `error` 전이로 연결하면 ②가 터려도 ① 덕분에 루프가 **빠르게 실패 종료**된다(사실상 ②의 폭발 반경을 ① 패치가 절단). 위험: quiescent 대기 연장은 루프 지연을 늘리므로 상한 환경변수로 봉인 필요. |
|
||||
|
||||
## 4. 구체적 해결 방안 및 아키텍처 개선안
|
||||
|
||||
### 결함 ① — fail-closed 레지스트리 전이
|
||||
1. **worker break (:360) 직전**: `"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" status --job "$JOB_ID" --set "error"` 추가 — `job_status` 변수가 이미 있으므로, 보존을 원하면 `--set "$job_status"` 계열로 세분화해도 되나 `wait_for_job`의 종료 어휘가 `error`이므로 **정규화해서 `error`로 기록** 권장.
|
||||
2. **reviewer break (:365) 동일 적용** (대칭 결함, 이슈에는 미기재지만 동일 라인 구조).
|
||||
3. **`run_agent` 실패 즉시 전이(② 연쇄 절단)**: 루프 :327을 `if ! run_agent ...; then <set error>; break; fi`로 변경 — 주입 실패가 subscriber 120초 대기를 거치지 않고 즉시 실패로 확정.
|
||||
4. **중복 방지**: rc=3 broker_unavailable 분기(:342-347)의 디스크 우선 병합 결과(`completed`/`error`)를 그대로 존중 — 이미 terminal이면 덮어쓰지 않는 가드(`disk_st` 체크 재사용).
|
||||
|
||||
### 결함 ② — TUI settling 안전 처리
|
||||
1. **즉시 조치**: 주입 실패의 즉시 error 전이(위 4-3)가 핵심 — settling은 완화 장치일 뿐, 실패 시의 원장 정직성이 본질.
|
||||
2. **quiescent 파라미터 환경변수화 튜닝**: `SKS_QUIESCENT_TRIES`(기본 20)를 잡 주입 맥락에서 `MAM_SKS_INJECT_TRIES`(예: 40회×0.5s=20s)로 상향하는 옵션 + **프롬프트 복귀(`>`) 감지 시 조기 탈출** — `_pane_quiescent`가 "동일 캡처"만 보는 것을, adapter의 `input_prompt`/`input_placeholder` 사실(`lib_py.agents facts`)과 교차 확인하면 idle 복귀를 정확히 포착(사실 브리지가 이미 내보내는 `MAM_INPUT_PROMPT`/`MAM_INPUT_PLACEHOLDER`/`MAM_INPUT_RULE_PATTERN`을 `send_keys_safe`가 이미 수신하므로, quiescent 성공 판정에 placeholder 프레즌스 조건을 추가하는 것이 최소 변경).
|
||||
3. **선택(중기)**: 주입 재시도 1회 — 실패 시 settle 재대기 후 1회 재주입, 재실패 시 error 전이. 무한 재시도는 금지(교착의 새 형태).
|
||||
|
||||
## 5. 검증 및 테스트 계획
|
||||
|
||||
1. **결함 ① 회귀 가드 (신규)**: mock herdr/agent 환경에서 worker 주입 실패를 유도(`run_agent`가 rc=1 반환하도록) → 잡 `.json`이 `error`로 갱신되고 `wait_for_job`이 `max_wait` 소진 없이 rc=1로 즉시 복귀함을 단언. 반례: broker_unavailable+디스크 completed 시 덮어쓰기 없음.
|
||||
2. **결함 ② 회귀 가드 (신규)**: `_pane_quiescent`가 연속 변화 캡처(렌더링 중)를 시뮬레이션하는 mock으로 `send_keys_safe`의 거부 → `run_agent` rc=1 → 레지스트리 `error` 전이 연쇄를 단언.
|
||||
3. **기존 스위트**: `tests/test_o2_race_free_lock.py`(loop lock), Issue #5 Phase 2a/2b G-가드들, B-14/B-15의 G-1~G-10(`tests/test_b13*`, `test_issue5_*`)이 이번 변경과 충돌 없음을 확인 — 특히 `test_g5_no_blocking_io_while_the_registry_lock_is_held`가 새 `status --set` 호출 위치(락 밖)와 정합.
|
||||
4. **전체 스위트**: 현재 기준선(750 passed) 유지 확인.
|
||||
5. **라이브 스모크**: 실제 2-에이전트 위임에서 일부러 주입을 방해(세션 일시 정지) → 루프가 65분이 아니라 수십 초 내에 `error`로 종료되는지 실측.
|
||||
|
||||
### 총평
|
||||
두 결함 모두 **실재하며 상호 증폭 관계**다(②가 트리거, ①이 교착으로 변환). ①은 원장 정직성 계약(B-14 계열)의 마지막 잔여 구멍이고 수정 비용이 매우 낮다(난이도 하, ~0.5일). ②는 이미 존재하는 quiescent 프리미티브를 주입 맥락에 맞게 튜닝하고 `run_agent` 반환값을 루프가 수용하도록 하는 중간 난이도 작업(~1일)이다. 둘을 한 수정 단위로 묶어 진행할 것을 권고하며, 설계 변경 수준의 재계획은 불필요하다.
|
||||
@@ -0,0 +1,51 @@
|
||||
### [reviewer-opencode-01] Issue #6 통합 보고서 검토 의견
|
||||
|
||||
- **Job**: `58fba2b1` / **검토자**: `reviewer-opencode-01` (원 1차 리뷰 Job `17f2e688`의 작성자)
|
||||
- **검토 대상**: `.agents/reports/issue_6_consolidated_review_report.md` (오케스트레이터 작성, 4인 리뷰 종합)
|
||||
- **검토 방법**: 통합 보고서의 모든 핵심 주장을 라이브 소스·실측으로 독립 재검증 — direct 경로(`:200-232`), 레지스트리 CLI/엔진 CAS 코드, Herdr 0.8.2 CLI 실도움, 좀비 잡 포렌식, mock_herdr 커버리지. 내 1차 리뷰(`17f2e688`)의 권고 대비 증감도 대조했다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 총평 및 통합 보고서 정합성 평가
|
||||
|
||||
**정합성 높음 — 종합적으로 충실하고 정확한 통합이다.** 핵심 판정(①② 모두 실재, 상호 증폭, 국소 패치로 해결 가능)은 나를 포함한 4인 리뷰의 실측과 일치하며, 통합 과정에서 왜곡·축소는 발견되지 않았다. 오히려 내 1차 리뷰를 **초월**하는 두 가지 실질 기여가 있다:
|
||||
|
||||
1. **`--type direct` 숨은 결함 발견 (§2.1-2)**: 나는 loop/discuss 경로(`:351-361`, `:375-378`)만 추적했으나, 오케스트레이터가 실제로 타는 경로는 `runner.py:269`의 `--type direct`이고 이 경로(:213 `run_agent` 무가드, :227 `wait "$sub_pid" || true`, 상태 전이 부재, `exit 0`)가 완전 침묵이라는 발견 — **직접 확인했다. 이것이 통합 보고서의 가장 큰 기여다.**
|
||||
2. **CAS 선행조건 (`expected_status in (pending, running)`)**: 내 1차 리뷰가 "이미 terminal이면 덮어쓰지 않는 가드"를 논점으로만 언급한 반면, 통합 보고서는 이를 구체적 메커니즘(엔진의 기존 CAS 계약 재사용 + CLI 노출)으로 발전시켰다. 실측: `engine.py:188`에 `expected_status` 선행조건이 이미 존재하나 **`registry.py` status 서브커맨드(:292-295)는 `--job`/`--set`만 받고 이를 노출하지 않는다** — 보고서의 §4.1-1이 정확히 이 누락 배선을 메우는 제안임을 확인.
|
||||
|
||||
**실증 포렌식 검증 (강력한 1차 증거)**: 보고서 §2.1-3이 인용한 좀비 잡 3건을 내가 직접 재확인: `93a44347`와 `2961b822`는 **지금 이 순간에도 `status: "pending"`으로 방치**되어 있고, `ee242671`(바로 내가 이전에 "인터럽트된 라운드"로 진단했던 내 잡)은 `running`에 `started` 이벤트 하나만 남은 채 표류 중 — 즉 그때의 중단은 인터럽트가 아니라 **direct 경로가 내 subscriber 실패를 침묵시킨 것**이었을 개연성이 높다. 결함 ①의 라이브 피해 사례로서 이보다 좋은 증거는 없다.
|
||||
|
||||
## 2. 누락되거나 보강이 필요한 핵심 사항 (Missing)
|
||||
|
||||
1. **`run_agent` 반환값 접수의 명문화 필요** (내 1차 리뷰 4-3의 핵심이 통합 보고서에 암묵적): `finalize_unfinished`는 "구독자 rc≠0 + 비터미널 디스크"에서 발동하므로 주입 실패를 **간접적으로만**(주입 실패 → started 미발행 → 구독자 idle timeout → rc=2 → finalize) 포착한다. 이는 동작하나 (a) 주입 실패를 잡는 데 최대 120초(구독자 idle timeout)가 걸리고, (b) `run_agent`가 이미 rc=1로 실패 사실을 즉시 알고 있는데 이를 버리는 구조는 그대로다. **권고**: `run_agent` 실패 시 즉시 finalize 호출을 명시적으로 설계에 포함하라 — 실패 전파가 120초 → 수 초로 단축되고, "주입은 실패했으나 에이전트가 늦게 started를 발행하는" 엣지에서 두 경로의 경합을 없앤다.
|
||||
2. **`herdr agent wait` 실패 모드 표준화**: wait가 타임아웃/대상 부재로 실패할 때의 rc와 출력 계약(성공과 실패를 구별할 수 있는가)이 §4.2에 명시되지 않았다. 폴백 휴리스틱 진입 조건이 이 rc에 의존하므로, Phase 0 스파이크에 "wait 실패 rc 실측" 항목을 추가해야 한다.
|
||||
3. **`MAM_SKS_*` 환경변수 문서화**: 기존 `SKS_QUIESCENT_TRIES/INTERVAL`과 신규 네이티브 타임아웃(15000/5000ms)의 튜닝 변수군을 `.mam.env.example`에 문서화하는 항목이 §5에 빠져 있다(과거 v4.1.x 패치의 표준 절차).
|
||||
|
||||
## 3. 불필요하거나 과도한 제약/설계 (Unnecessary)
|
||||
|
||||
**과도한 설계는 없다.** 명시적으로 검토한 결과:
|
||||
|
||||
- `--expect-status` 신설이 아닌 **기존 CAS 엔진(:188)의 CLI 노출** — 새 메커니즘이 아니라 배선. 최소 비용 최대 효과.
|
||||
- `agent wait --until idle --until done`의 `done` 포함: 에이전트가 턴이 아니라 대화 자체를 종료하는 상태를 놓치지 않는 정당한 포함.
|
||||
- 0.7.x 화면 휴리스틱 폴백 유지: 제거가 아닌 2차 계층 유지 — 호환성 보험이며 비용이 낮다.
|
||||
- 유일한 사소한 지적: §4.2-2의 "잔여 busy 토큰 사전 캡처 대조" 가드는 폴백 경로에서만 필요하다. 1차 게이트(wait --until idle)를 통과한 뒤에는 상태가 이미 idle이므로 네이티브 경로에서 이 가드는 중복 — 구현 시 **폴백 경로에 한정**해 배치하는 것이 코드를 줄인다 (동작에는 무해하므로 차단 사항 아님).
|
||||
|
||||
## 4. 간과된 잠재적 위험 및 엣지 케이스 (Overlooked)
|
||||
|
||||
1. **`agent prompt --wait --until working`의 턴 비추적성**: 실측한 herdr 도움말에 "It does not track turns: if the agent is already working, that active turn's completion may match" — 즉 working 상태에서 제출하면 **이전 턴**의 완료가 새 입력의 수락 증거로 오판될 수 있다. 통합 보고서의 1차→2차 순서(wait로 idle 확보 후 prompt)가 이를 구조적으로 방지하지만, 이 보장이 **순서에 의존**한다는 사실이 문서에 명시되지 않았다. "prompt는 반드시 wait 성공 후에만 호출"을 계약 조건으로 못박아야 한다.
|
||||
2. **wait 무한 대기 함정**: `agent wait`는 `--timeout` 없으면 **무한 대기**한다(실측). §4.2-1이 15000ms를 명시한 것은 정확하나, 타임아웃 누락 시 새로운 교착 유형이 생긴다 — 구현 가드(타임아웃 인자 강제)와 테스트(`--timeout` 없는 호출 금지 lint성 단언)가 필요.
|
||||
3. **구독자 rc=3(broker_unavailable)과 finalize의 상호작용**: loop 경로의 기존 디스크 우선 병합(:342-347)이 finalize와 만날 때, 브로커 장애 중 도착한 늦은 completed 이벤트와의 경합이 새 패치로 어떻게 정렬되는지 테스트 매트릭스에 명시적 시나리오가 필요하다 (§5-1의 CAS 보존 테스트가 이를 커버하나 rc=3 변형 포함 권고).
|
||||
4. **좀비 잡 청소**: `93a44347`/`2961b822`는 패치 후에도 `pending`으로 남는다 — 패치는 **신규** 실패만 막는다. 구현 잡에 과거 좀비 잡 수동 정리(`status --set error` 또는 삭제)를 부수 절차로 포함할 것.
|
||||
|
||||
## 5. 구현 착수를 위한 최종 제안 및 권고사항
|
||||
|
||||
1. **착수 승인**: 공수 산정(2.0~3.5 인일)은 터치포인트 실측(3개 finalize 호출부 + CLI 인자 ~15줄 + 러너 파싱 ~10줄 + 핸드셰이크 게이트 ~60줄 + mock ~50줄 + 테스트 2파일)과 정합 — 타당하다.
|
||||
2. **구현 순서 권고**: (1) `registry.py` CLI `--expect-status` 노출(독립 테스트 가능, 무위험) → (2) `finalize_unfinished` direct+loop 적용 → (3) 러너 rc-관용 파싱 → (4) 네이티브 핸드셰이크 + 폴백 → (5) mock/회귀 테스트. 각 단계 후 750 전체 스위트 유지 확인.
|
||||
3. **테스트 보강**: §5의 2개 신규 파일에 위 §4의 엣지 3건(wait 실패 rc, 턴 비추적성 방지 계약, rc=3 경합)과 내 1차 리뷰 §5-1(주입 실패 → 즉시 error 전이 연쇄)을 시나리오로 추가.
|
||||
4. **문서 동기화**: MESSAGING.md(§5.1 wapper 종료 계약 갱신)와 `.mam.env.example`(타임아웃 변수군)을 패치 커밋에 포함 — v4.4.0 문서 원장 체계의 표준 절차.
|
||||
|
||||
## 6. 최종 판정
|
||||
|
||||
통합 보고서는 4인 리뷰를 왜곡 없이 종합했고, direct 경로 발견과 CAS 선행조건 설계로 원 리뷰들(내 것 포함)을 실질적으로 초월했다. 포렌식 증거(좀비 잡)는 라이브 레지스트리에서 재검증되었으며, 해결안은 기존 CAS 엔진과 Herdr 0.8.2 네이티브 API의 정확한 재사용으로 과설계 없이 최소 비용이다. 내가 보강한 4개 항목(run_agent 즉시 finalize, wait 실패 rc 계약, 턴 비추적성 순서 계약 명문화, 좀비 청소)은 모든 것이 구현 착수 전/중 반영 가능한 권고 사항이지 통합 보고서의 결함이 아니다. 구현 착수 준비 완료 판정에 동의한다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,242 @@
|
||||
# 📑 Issue #6 Implementation Plan (Phase 1–5 Execution & Gate Specification)
|
||||
|
||||
- **Document Version**: Rev.2 (Planner `planner-reviewer-claude-01`, job `f4873d82` — Creator `creator-agy-01` 이의제기 3건 반영; Rev.1은 job `d2d70f57`)
|
||||
- **Issue**: [Gitea Issue #6 — `[Bug] delegate-job 워커 실패 시 레지스트리 미갱신(Fail-Silent) 및 TUI 턴 마감 레이스 컨디션 해결`](https://git.godopu.com/tmpl/multi-agent-mux/issues/6)
|
||||
- **Source of Truth**: `.agents/reports/issue_6_consolidated_review_report.md` (Rev. 2) — 4인 리뷰어 합의 사양
|
||||
- **Baseline Commit**: `f98b958` (`main`, v4.4.0), Herdr `0.8.2`, Python 3.9.6, 전체 스위트 750 passed
|
||||
- **Status**: 착수 전 (Phase 1 대기)
|
||||
|
||||
---
|
||||
|
||||
## 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` 서브커맨드<br>• 래퍼 `finalize_unfinished`/`reap_subscriber` 헬퍼, direct(`:227`)·loop(`:359`,`:375`)·`run_agent` 실패·프리플라이트 실패 5개 종료 경로 일원화<br>• rc=3 디스크 재관측, trap `--registry-dir`, dry-run 순서, 종료 코드 계약(rc 5 `session_busy` 포함)<br>• **loop 역할 전환 `attempt_id` 규약** (§3.1.6) | • `--agent human` 결정적 재현 4종(§5 T1-1~T1-4)이 수정 전 실패·수정 후 통과<br>• T1-10(워커 완료→리뷰어 재디스패치 성공) 수정 전 rc=4·수정 후 rc=0<br>• `test_wrapper_exit_code_mapping_literals_are_frozen`·F-A 3종 무변경 통과<br>• `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"`)<br>• `lib.sh: wait_for_agent_idle` (사전 `mam_herdr agent get` → `unknown`/`blocked` 분기 → watcher CLI, `SKS_IDLE_RETRIES` 백오프 재시도, 최종 실패 rc 1 → 래퍼 rc 5 `cancelled`)<br>• 래퍼 `run_agent`를 `preflight_agent`+`inject_agent`로 분리, 순서: preflight → idle wait → subscriber → inject<br>• `send_keys_safe` 폴백 제출 검증에서 pre-submit busy 토큰 배제, `agent prompt --wait --until working` 수락 확인(재제출 금지) | • mock_herdr 상태 시퀀스 테스트 §5 T2-1~T2-11 통과<br>• `test_h16`(Enter 정확히 1회)·`test_b8`·`test_c1_*` 무변경 통과<br>• 래퍼 로그에서 `SUBSCRIBED` 이전에 idle 대기 완료 로그가 출력됨<br>• 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 반환<br>• **rc=5(`session_busy`)는 `MAM_SESSION_BUSY_RETRY_S`(기본 30) 대기 후 1회 재위임(새 job id)**, 재실패 시 None<br>• 호출부는 `wait_for_job`이 디스크 `error`/`cancelled`로 1s 내 False<br>• `run_loop.sh` 레거시 `delegate_job_safe` 동일 파싱(`MAM_LOOP_PY=0` 경로 보존) | • `LoopRunner.wait_for_job`이 fail-closed 잡에서 ≤ 2s 반환(T3-1)<br>• 실패 job id가 `.mam/loop-checkpoint.json`에 기록(T3-2)<br>• rc=5 1회 재위임 후 성공/실패 분기(T3-3)<br>• `test_issue5_phase3_loop.py` 무변경 통과 | 함수 단위 revert (플래그 불필요: rc≠0 경로만 변경) |
|
||||
| **Phase 4** | **단위/계약 회귀 스위트** | • `tests/test_issue6_fail_closed.py`, `tests/test_issue6_turn_handshake.py`<br>• `tests/conftest.py` mock_herdr에 `agent_status`·`agent wait`·`agent prompt --wait` 모킹<br>• 문서: SKILL.md 종료 코드 계약, MESSAGING.md §5.1 보강 | • 신규 테스트 전부 **수정 전 코드에서 실패함을 역검증**(Issue #5 관례)<br>• 전체 스위트 750 + 신규 ≥ 20, 0-Fail<br>• `git diff --check` 클린 | 테스트 파일 삭제만으로 원복 |
|
||||
| **Phase 5** | **실기동 4개 에이전트 E2E 및 릴리스** | • Claude/Grok/Codex/OpenCode 각 1회: `completed` 직후 동일 세션 연속 위임 타임라인 기록<br>• 타임아웃 주입(에이전트 무응답)으로 65분 대신 ≤ `IDLE_TIMEOUT`+5s 내 `error` 확인<br>• 잔존 레코드 3건 폐쇄, IMPROVEMENTS.md 원장, VERSIONS.md `v4.4.1` 4-way 락스텝 | • 4개 백엔드 연속 위임 주입 실패 0건, 중복 제출 0건<br>• `test_version_consistency.py` 통과<br>• 리뷰어 교차 검토 `[VERDICT: PASS]` | 릴리스 태그 미부여 상태로 `main` revert |
|
||||
|
||||
---
|
||||
|
||||
## 3. 상세 구현 사양
|
||||
|
||||
### 3.1 Phase 1 — Fail-Closed
|
||||
|
||||
**3.1.1 `lib_py/delegation/registry.py` — `close` 서브커맨드 (신규, Rev.2: `timeout`을 일반화)**
|
||||
```text
|
||||
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` 위에 정의)**
|
||||
```bash
|
||||
# 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`)**
|
||||
```text
|
||||
"$(_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`)**
|
||||
```text
|
||||
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에 없던 신규 작업이다.
|
||||
@@ -0,0 +1,104 @@
|
||||
# 📑 Issue #6 Task Checklist & Verification Evidence
|
||||
|
||||
- **Document Version**: Rev.2 (Planner `planner-reviewer-claude-01`, job `f4873d82` — Creator 이의제기 3건 반영)
|
||||
- **Issue**: [Gitea Issue #6](https://git.godopu.com/tmpl/multi-agent-mux/issues/6)
|
||||
- **Plan**: `docs/contracts/issue-6/implementation_plan.md` (Rev.2)
|
||||
- **Baseline**: `f98b958` (v4.4.0), 750 passed
|
||||
- **Status**: Phase 1 대기
|
||||
|
||||
> 각 항목은 완료 시 `[x]`로 바꾸고 바로 아래 `*증거*:` 줄에 커밋 해시·테스트 ID·실측 수치를 기록한다(Issue #5 관례). 신규 회귀 테스트는 **수정 전 코드에서 실패함을 역검증**한 뒤 통과 증거를 남긴다.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 레지스트리 CAS 표준 연동 및 delegate-job Fail-Closed
|
||||
|
||||
- [ ] **P1-01** `registry.py close --job <id> --request {deadline_timeout,cancelled} [--detail] [--expect-dispatch-token]` 서브커맨드 추가 (`engine.transition(..., request, source="delegate_wrapper")`). rc: 커밋/멱등/terminal 거부 0, 레코드 없음 1, 토큰 불일치 4.
|
||||
- *증거*:
|
||||
- [ ] **P1-02** 래퍼에 `reap_subscriber`, `_disk_status`, `_map_sub_rc`(기존 `:340-354` 리터럴 보존), `_rc_for`, `finalize_unfinished` 헬퍼 추가. phase `preflight|idle_wait`는 `cancelled`/`session_busy`(rc 5), 그 외는 `deadline_timeout`. `MAM_DELEGATE_FAIL_CLOSED=0` 롤백 분기 포함.
|
||||
- *증거*:
|
||||
- [ ] **P1-03** direct 경로 `:227` `|| true` 제거 → `sub_rc` 매핑 → `finalize_unfinished` → 종료 코드 0/1/2/3/5. 마지막 stdout 줄(감사 경로) 유지.
|
||||
- *증거*:
|
||||
- [ ] **P1-04** loop/discuss 워커(`:359`)·리뷰어(`:375`) `break` 직전 `finalize_unfinished` 호출. **loop 경로에서 `publish_event --event error` 발행 금지**(O-1).
|
||||
- *증거*:
|
||||
- [ ] **P1-05** `run_agent` 실패 분기 명시화(`if ! run_agent`): `reap_subscriber` + `finalize_unfinished … inject` + `exit 1`. trap(`:523`)에 `--registry-dir "$REGISTRY_DIR"` 추가.
|
||||
- *증거*:
|
||||
- [ ] **P1-06** rc=3 처리: 남은 데드라인 동안 3s 간격 디스크 재관측, terminal 발견 시 채택, 아니면 `deadline_timeout`(rc=3).
|
||||
- *증거*:
|
||||
- [ ] **P1-07** dry-run direct에서 구독자 미기동 (현재 `:166` 기동 후 `:224` `return 0`).
|
||||
- *증거*:
|
||||
- [ ] **P1-08** 결정적 재현 4종(T1-1, T1-2, T1-4, T1-7)이 수정 전 실패·수정 후 통과. `test_wrapper_exit_code_mapping_literals_are_frozen` 통과.
|
||||
- *증거*:
|
||||
- [ ] **P1-09** 롤백 드릴: `MAM_DELEGATE_FAIL_CLOSED=0`에서 T1-8 재현(rc=0, `pending`).
|
||||
- *증거*:
|
||||
- [ ] **P1-10** (Rev.2 Tertiary) `attempt_id` 규약: `registry.py update --attempt-id`, `engine._decide` redispatch가 `attempt_id` 공급 시 이를 전진 조건으로 사용(미공급 시 기존 iteration 규칙), `redispatch`/`claim` 커밋 시 레코드 기록, `publish_event --finalize-report` 기본 attempt_id가 `record.attempt_id` 우선.
|
||||
- *증거*:
|
||||
- [ ] **P1-11** 래퍼 loop 경로: phase 디스패치마다 `attempt` 증가 후 `update … --attempt-id "$attempt"`. T1-10(수정 전 rc=4 역검증)/T1-11/T1-12, F-A 3종 무변경 통과.
|
||||
- *증거*:
|
||||
- [ ] **P1-12** T1-13 `close --request cancelled` 전이 규칙 고정.
|
||||
- *증거*:
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 사전 TUI 유휴 핸드셰이크 및 제출 검증 보강
|
||||
|
||||
- [ ] **P2-01** `lib_py/loop/watcher.py`에 `main()`/`__main__` 추가: `wait <target> --session S --until … --timeout-ms N`, JSON 1줄 stdout, rc matched 0 / timeout 2 / agent_not_found 3 / transport_error 4. `build_argv(..., session=None)`가 `default`가 아닌 세션이면 `herdr --session S agent wait …`로 구성(Rev.2 Primary-A). `lib_py.delegation`→`lib_py.loop` 임포트 0건 유지(`test_delegation_package_imports_no_sibling_lib_py_modules`).
|
||||
- *증거*:
|
||||
- [ ] **P2-02** `lib.sh: wait_for_agent_idle`: `SKS_IDLE_HANDSHAKE`, 사전 `_sks_herdr agent get`(`unknown`→3, `blocked`→2, `idle|done`→0), `working`→`"$(_delegate_py_bin)" -m lib_py.loop.watcher wait <t> --session "$HERDR_SESSION_NAME" --until idle --until done --until blocked --timeout-ms ${SKS_IDLE_TIMEOUT_MS:-120000}`, timeout은 `SKS_IDLE_RETRIES`(2)×`SKS_IDLE_RETRY_BACKOFF_S`(10) 재시도 후 rc 1. bare `python` 금지. `interactive_ready` 미사용.
|
||||
- *증거*:
|
||||
- [ ] **P2-03** 래퍼 `run_agent`를 `preflight_agent` + `inject_agent`로 분리. 순서: register → preflight → idle wait → `_disp_tok` 읽기 → 구독자 기동 → inject → wait → finalize. direct·loop 양 경로 적용. 프리플라이트/idle 실패 시 `finalize_unfinished … preflight|idle_wait`(→ `cancelled`/`session_busy`) + `exit 5`.
|
||||
- *증거*:
|
||||
- [ ] **P2-04** `send_keys_safe` 제출 검증: pre_submit에 존재한 busy 토큰 배제(`:2330-2332`).
|
||||
- *증거*:
|
||||
- [ ] **P2-05** `send_keys_safe` 네이티브 경로 `agent prompt --wait --until working --timeout 5000` (`SKS_PROMPT_WAIT=1` 기본). stalled/timeout → 재제출 없이 `agent get` 1회 확인, `agent_blocked` → rc 2, 불명 → rc 4.
|
||||
- *증거*:
|
||||
- [ ] **P2-06** 폴백(`unknown`) 경로 busy 토큰 게이트(`SKS_BUSY_TIMEOUT` 기본 120s).
|
||||
- *증거*:
|
||||
- [ ] **P2-07** T2-1~T2-11 통과, `test_h16`/`test_b8`/`test_c1_*`/`test_herdr_shim_contract` 무변경 통과. 롤백 드릴 `SKS_IDLE_HANDSHAKE=0`.
|
||||
- *증거*:
|
||||
- [ ] **P2-08** (Rev.2 Primary) T2-10 `--session` argv 단정, T2-11 bare `python` 정적 린트 0건.
|
||||
- *증거*:
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 오케스트레이터 러너 Fail-Fast 연결
|
||||
|
||||
- [ ] **P3-01** `runner.delegate_job`: rc≠0에서도 `registered job:` 파싱 → `created_jobs`·checkpoint 기록 → id 반환. 미매치 시만 `None`.
|
||||
- *증거*:
|
||||
- [ ] **P3-02** `run_loop.sh` `delegate_job_safe`/`:373` 파싱 동등성(`MAM_LOOP_PY=0`).
|
||||
- *증거*:
|
||||
- [ ] **P3-03** T3-1(`wait_for_job` ≤ 2s False), T3-2(checkpoint 기록), `test_issue5_phase3_loop.py` 무변경 통과.
|
||||
- *증거*:
|
||||
- [ ] **P3-04** (Rev.2 Secondary) `delegate_job` rc=5 → `MAM_SESSION_BUSY_RETRY_S`(30) 대기 후 1회 재위임. T3-3.
|
||||
- *증거*:
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 단위/계약 회귀 스위트 및 문서
|
||||
|
||||
- [ ] **P4-01** `tests/conftest.py` mock_herdr 확장: `agent get/list`에 `agent_status`(기본 `idle`), `agent wait`(`status_sequence` 소비, timeout 시 stderr JSON rc 1), `agent prompt --wait`(`prompt_wait_result`), 전역 `--session <name>` 인자 수용·호출 기록. 기존 테스트 무변경 통과.
|
||||
- *증거*:
|
||||
- [ ] **P4-02** `tests/test_issue6_fail_closed.py` T1-1~T1-13, T3-1~T3-3 작성 및 역검증.
|
||||
- *증거*:
|
||||
- [ ] **P4-03** `tests/test_issue6_turn_handshake.py` T2-1~T2-11 작성 및 역검증.
|
||||
- *증거*:
|
||||
- [ ] **P4-04** 문서: `multi-agent-mux-delegate-job/SKILL.md` 종료 코드 표, `MESSAGING.md` §5.1 구독자 타임아웃→레지스트리 반영, `BOOTSTRAP.md`/`BOOTSTRAP.ko.md` "completed 발행 후 도구 호출 없이 턴 종료" 1문장. `test_deploy_freshness.py`(`MQTT_*` 10종)·`test_o1_rebuttal.py` 통과.
|
||||
- *증거*:
|
||||
- [ ] **P4-05** 전체 스위트 0-Fail (750 + 신규 ≥ 20), `git diff --check` 클린, 수집 수·통과 수·소요 시간 기록.
|
||||
- *증거*:
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: 실기동 E2E, 정리, 릴리스
|
||||
|
||||
- [ ] **P5-01** 4개 백엔드(Claude/Grok/Codex/OpenCode) 연속 위임 E2E(**direct 모드**; loop 모드 종단은 O-1 이슈 완료 후): `completed`→다음 잡 `started` 타임라인, idle 대기 시간, 주입 실패·중복 0건. `docs/contracts/issue-6/phase5-e2e.md` 작성.
|
||||
- *증거*:
|
||||
- [ ] **P5-02** 타임아웃 주입 E2E(`--idle-timeout 30`, 프롬프트 무시): 래퍼 rc=2, `error`, `wait_for_job` ≤ 2s.
|
||||
- *증거*:
|
||||
- [ ] **P5-03** 잔존 레코드 `93a44347`, `2961b822`, `ee242671` `registry.py close --request deadline_timeout … --detail "Issue #6 backfill"`로 폐쇄.
|
||||
- *증거*:
|
||||
- [ ] **P5-04** `IMPROVEMENTS.md` §5 `Issue-6-FailClosed-TurnHandshake` 블록 추가(§6 표·헤더 키 목록·건수 동기화). 별도 이슈 등록: O-1 리테인 재수락(**loop/discuss 모드 사용 전 필수**로 표기), 레지스트리 오염.
|
||||
- *증거*:
|
||||
- [ ] **P5-05** `v4.4.1` 4-way 락스텝(`lib.sh:32`, `eager.py:16`, `VERSIONS.md` 개요·매트릭스·Changelog, 8개 `SKILL.md`), `test_version_consistency.py` 통과.
|
||||
- *증거*:
|
||||
- [ ] **P5-06** 리뷰어 교차 검토 `[VERDICT: PASS]` 후 `main` 병합.
|
||||
- *증거*:
|
||||
Reference in new issue
Block a user