refactor: standardize --agent option usage, improve YAML registry fallback, and fix layout falsy-zero trap (J-1)

- In stop_session.sh and update_yaml_resumed.sh: standardize explicit --agent option and use resolve_agent_type_from_registry to read agent type from YAML/DB state rather than brittle suffix-only regex inference.
- Update multi-agent-mux-stop/SKILL.md, multi-agent-mux-resume/SKILL.md, multi-agent-mux-create/SKILL.md, and deploy/INSTALL.md to standardize passing --agent explicitly.
- Fix J-1 in layout.py: refactor _env_int(*names, default=None) to take an explicit default parameter, eliminating the falsy-zero trap so MAM_MIN_PANE_COLS=0 is respected.
- Add regression and contract tests: test_j1_env_zero_min_cols_matches_flag_zero, test_j1_env_zero_min_rows_matches_flag_zero, test_comp_stop_agent_fallback_*, test_comp_docs_stop_examples_pass_agent.
- Verified 100% UNANIMOUS PASS from Planner claude and Reviewers claude and cline.
This commit is contained in:
2026-08-24 10:08:27 +09:00
parent 14e306be46
commit f7e1513585
17 changed files with 1375 additions and 46 deletions
@@ -0,0 +1,822 @@
# 📐 구현 계획서 **Rev.2** — Job `d6f54b7f` (원안: `79ff98ed`)
- **역할**: Planner (`MULTI_AGENT_RULES.md` §1 — 저장소 코드/문서 무수정, 산출물은 본 보고서)
- **기준 커밋**: `14e306b` (branch `refactor`, working tree clean)
- **베이스라인**: `pytest tests/ --collect-only`**333 collected**
- **입력**: Job `9f85218e` 리뷰 `[VERDICT: PASS WITH CHALLENGE]` (Challenge C-1, Observation C-2)
---
## 0. Rev.1 → Rev.2 변경 요약
| 항목 | 판정 | 조치 |
|---|---|---|
| **Challenge C-1** — T5 문서 가드의 블록 카운팅 오류 + 부분 문자열 허점 | **전면 수용. 두 갈래 모두 실측 확인** | §5 T5 재설계 (§1.10에 실측 근거) |
| **Observation C-2**`_env_int``ValueError``continue` | **수용. 챌린저가 제시한 것보다 근거가 더 강함** | §4.5 S5 변경 + 전용 테스트 T1b 신설 (§1.11) |
| (자체 정정) Rev.1 §5 의 "신설 8건" | **오산 — 실제 7건** | Rev.2 는 8건(C-2 테스트 1건 추가). 기대 collected 341 은 동일하나 근거가 달라짐 |
| (신규) K-5 | — | 문서화되지 않은 `MAM_MIN_COLS` 가 문서화된 `MAM_MIN_PANE_COLS` 보다 **우선순위가 높다** (§9) |
C-1 은 계획대로 구현하면 **테스트가 100% 실패**하는 결함이었습니다. 챌린저의 지적이 정확했고, 실측으로 재현했습니다(§1.10). 다만 챌린저가 제시한 수정안은 **다른 실패 모드를 새로 만듭니다** — 문서 전체를 스캔하므로 산문 속 파일명 언급을 명령으로 오인합니다. 그래서 **커맨드 단위 검증(챌린저의 핵심 교정)****펜스 스코프(추가 보강)** 를 합성했습니다. §1.10.3 에 두 실패 모드를 각각 실측했습니다.
C-2 는 챌린저가 "다중 fallback 취지에 부합" 정도로 완곡하게 제기했지만, 실측해 보니 **`.mam.env.example` 이 문서화한 유일한 이름이 조용히 무시되는** 경로였습니다. 근거를 강화해 수용합니다(§1.11).
---
## 1. 실측 (Measurements)
> §1.1 ~ §1.9 는 Rev.1 에서 확정된 실측이며 재검증 없이 유지합니다. §1.10 · §1.11 이 Rev.2 신규입니다.
### 1.1 에이전트 해석기가 저장소에 **4개** 존재한다
| # | 위치 | 우선순위 | 실패 시 |
|---|---|---|---|
| **1** | `lib_py/agents/registry.py:26` `agent_of_row()` | `agent` 필드 → 이름 접미사 → `pane.cmd` | `None` |
| **2** | `stop_session.sh:101-109` | 이름 접미사만 (역할 한정) | `exit 2` |
| **3** | `update_yaml_resumed.sh:44-51` | **#2 와 완전 동일한 복사본** | `exit 2` |
| **4** | `run_loop.sh:278` | `agent` 필드 → `pane.cmd` → 하이픈 세그먼트 → **`claude` 기본값** | 실패 없음 |
```
SESSION_NAME stop/upd run_loop registry
---------------------------------- ---------- ---------- ----------
x-creator-claude claude claude claude
agy-creator-01 EXIT2 agy None ← 라이브 세션
my-project-dev-claude EXIT2 claude claude ← INSTALL.md 예제 이름
worker-1-agy EXIT2 agy agy
foo-cline EXIT2 cline cline
bad-session-name EXIT2 claude None ← run_loop 은 조용히 claude
orc-hermes-main EXIT2 hermes None
```
1. **`agy-creator-01` 은 지금 이 워크스페이스에 running 으로 등록된 실제 세션입니다.** `pane.cmd = 'agy'` 가 기록돼 있는데도 `--agent` 없이는 `exit 2` 로 거부됩니다. 브리프가 지목한 결함의 재현 가능한 구체 사례입니다.
2. `my-project-dev-claude``deploy/INSTALL.md:95` 가 스스로 문서화한 세션 이름입니다. 접미사가 `-dev-claude`#2 의 역할 한정 케이스에 걸리지 않습니다. INSTALL.md 가 `--agent claude` 를 명시해 사고가 안 났을 뿐입니다.
3. `run_loop.sh` 는 해석 실패를 `claude` 로 흡수합니다. 호출 12곳이라 이번 범위 밖(§9 K-1).
라이브 3개 행에 `agent_of_row` 직접 적용:
```
canary-projects-multi-agent-mux-creator-claude agent_of_row='claude' match_cmd=False → 'claude'
canary-projects-multi-agent-mux-creator-cline agent_of_row='cline' match_cmd=False → 'cline'
agy-creator-01 agent_of_row='agy' match_cmd=False → None
```
`match_cmd=True` 는 docstring 상 **비-입양(non-adoption) 조회**용이고 `stop`/`update_yaml_resumed` 가 정확히 그 경우입니다. (`reconcile.sh` 입양 루프 금지라는 `3aee63cf` §1.2 반증은 유효하며, 이 계획은 `reconcile.sh` 를 건드리지 않습니다.)
### 1.2 `agent` 필드는 존재하지 않는다
```
row keys 합집합:
['agy_conversation_id_own', 'attach_command', 'child_pid', 'claude_session_id_own',
'cline_conversation_id_own', 'delegate_job_id', 'herdr_server', 'herdr_session',
'herdr_session_created_at', 'herdr_session_epoch', 'kill_command',
'last_visible_status', 'last_visible_status_at_termination', 'mcp_attachments',
'name', 'pane', 'role', 'start_command', 'status', 'tui']
```
3개 행 전부 `agent=None`, `pane.cmd` 는 3개 전부 채워짐. → `agent` 필드를 **쓰는** 코드는 추가하지 않고, 우선순위 ①은 테스트로만 고정합니다(T4b).
### 1.3 `load_state_json` 은 YAML 이 아니라 SQLite 를 읽는다
`lib.sh:938-978``.db` 우선, 없을 때만 `.yaml`. 라이브에 `.mam/agent-sessions.db`(40 KiB) 존재. 문서·커밋 메시지에서 "레지스트리" 로 표현합니다.
### 1.4 비용
| 항목 | 실측 |
|---|---|
| `load_state_json` 1회 | ~34 ms |
| `python3` 기동 + `import lib_py.agents.registry` | ~27 ms |
| `stop_session.sh` 가 이미 수행하는 `load_state_json` | **2회** (`:97`, `:113`) |
`PYTHONPATH``lib.sh:25` 가 export 하므로 맨 `python3` 로 임포트 가능. venv 없는 시스템 파이썬(3.9.6)에서 `env -i` 검증 완료. `registry` 는 서드파티 의존 없음(`yaml` 불필요 — 상태는 JSON 으로 env 전달).
### 1.5 J-1 재현
페이로드: 1패널 `width=50, height=30`
| 경로 | 결과 |
|---|---|
| `--min-cols 0` | `right` / `single_pane_height_constrained` |
| `MAM_MIN_PANE_COLS=0` | **`overflow`** |
| `MAM_MIN_COLS=0` | **`overflow`** |
| `--min-rows 0` | `down` |
| `MAM_MIN_PANE_ROWS=0` | **`overflow`** |
| **대조군** `--min-cols 25` vs `MAM_MIN_PANE_COLS=25` | **양쪽 동일** (`right`) |
대조군이 결함을 `or` 관용구의 falsy-zero 하나로 국소화합니다.
### 1.6 J-2 임계값
```
n=3 n//2=1 -> down ← 현행 d3 단언. 상한 검사 도달 불가
n=4 n//2=2 -> overflow
n=5 n//2=2 -> down ← 판별 가능한 최소 홀수
n=6 n//2=3 -> overflow
n=7 n//2=3 -> down
```
### 1.7 문서 실태
| 파일 | 현상 |
|---|---|
| `multi-agent-mux-stop/SKILL.md` | `--agent` **0회**. 워크플로 예제 3개(`:68, :72, :77`) 전부 생략 |
| `deploy/INSTALL.md:94, :98` | `--agent claude` **이미 명시** — 유일한 모범 사례 |
| `multi-agent-mux-create/SKILL.md:146` | `AGENT=claude # or agy` |
| `multi-agent-mux-create/SKILL.md:171` | `must be claude or agy` — 실물 `create_session.sh:86` 은 4종을 받음 |
| `multi-agent-mux-resume/SKILL.md:61` | `# or agy or hermes` (cline 누락) |
| `create_session.sh:4`, `resolve_session_id.sh:4` | 헤더 주석 `<claude\|agy>` |
| `update_yaml_resumed.sh:7, :14` | `[--agent claude\|agy]` |
`create_session.sh` 는 이미 `--agent` 필수 + 4종 검증(`:83`, `:85-86`). create 쪽은 **문서 동기화뿐**입니다.
### 1.8 기존 테스트 계약
| 테스트 | 세션명 | 현행 |
|---|---|---|
| `tests/test_tier1_unit.py:142` | `bad-session-name` | rc=2, `cannot infer agent` |
| `tests/test_tier3_integration.py:398` | `bad-name` | rc=2, `cannot infer agent` |
두 이름 모두 샌드박스 레지스트리(`herdr_sessions: []`)에 없습니다. §3 설계 결정을 지배합니다.
### 1.9 (부수) `cd … 2>/dev/null || pwd` 결함
```
line35 result: [/lib.sh] → 존재하지 않음, 항상 :36 폴백
correct form : [/Users/.../.agents/skills/lib.sh]
```
잔존: `stop_session.sh:35`, `create_session.sh:23`, `resume_session.sh:6`. (`update_yaml_resumed.sh:10` 은 이미 정상.) `31b2d70` 의 R-2 와 동일 결함. 1차 소싱 경로가 100% 죽어 `${WORKSPACE_ROOT:-$PWD}` 폴백에만 의존합니다.
---
### 1.10 **[Rev.2 신규] Challenge C-1 검증**
#### 1.10.1 갈래 ① — `checked == 2` 로 단언이 실패한다 → **확인**
Rev.1 T5 의 블록 단위 정규식을 현재 문서에 그대로 적용:
```
SKILL.md: total fenced bash/sh blocks=3, containing stop_session.sh=1
-> one block holds 3 stop_session.sh invocations; '--agent' present in block: False
INSTALL.md: total fenced bash/sh blocks=7, containing stop_session.sh=1
-> one block holds 2 stop_session.sh invocations; '--agent' present in block: True
CHECKED = 2 (planner asserted >= 4)
```
`assert checked >= 4`**결정론적으로 실패**합니다. 챌린저의 지적이 정확합니다. 제가 §1.7 에서 "예제 3개(`:68, :72, :77`)" 를 세면서도 그것이 **하나의 펜스 안에 들어 있다**는 사실을 확인하지 않은 것이 원인입니다 — 개수는 셌지만 **경계를 세지 않았습니다**.
#### 1.10.2 갈래 ② — 블록 단위 단언의 위양성(False Positive) → **확인**
INSTALL.md 사본에서 **두 호출 중 하나에서만** `--agent` 를 제거하는 뮤테이션:
```
mutation applied (agent count 2 -> 1)
설계 A (블록 단위, Rev.1 원안): blocks=1 all pass? True ← 뮤테이션 미검출
설계 C (펜스+커맨드, Rev.2 정제안): checked=2 missing=1 ← 뮤테이션 검출
```
블록에 `--agent`**한 번이라도** 나오면 통과합니다. 회귀를 못 잡는 가드는 가드가 아니라 주석입니다. 챌린저의 지적이 정확합니다.
#### 1.10.3 챌린저 수정안의 잔여 실패 모드 → **문서 전체 스캔이 산문을 명령으로 오인한다**
챌린저 수정안은 `doc.read_text()` **전체**에 커맨드 정규식을 돌립니다. 산문 속 파일명 언급이 있는 문서로 실측:
```
=== 챌린저 수정안 (문서 전체 스캔) ===
[1] --agent=NO | '`stop_session.sh` does not delete report trees.' ← 위양성
[2] --agent=NO | '`stop_session.sh --purge-conversation` note below.' ← 위양성
[3] --agent=YES | 'bash .../stop_session.sh --session "$S" --agent "$A"'
[4] --agent=NO | 'bash .../stop_session.sh --session "$S"'
=== 펜스 스코프 + 커맨드 단위 (Rev.2) ===
[1] --agent=YES | 'bash .../stop_session.sh --session "$S" --agent "$A"'
[2] --agent=NO | 'bash .../stop_session.sh --session "$S"'
checked=2
```
산문 두 줄이 각각 `checked += 1` 되고 `--agent` 가 없으므로 **테스트가 실패**합니다. 이것이 가설이 아니라 임박한 문제인 이유:
- 이 계획 **§4.4 자체가 stop/SKILL.md 에 산문 문단을 추가**합니다.
- `stop/SKILL.md``## Pitfalls` · `## When NOT to use` 절은 성격상 스크립트를 산문으로 언급하게 되는 자리입니다.
- 문장을 하나 썼다고 실패하는 가드는 다음 사람이 **지웁니다**.
현재 두 문서에는 펜스 밖 언급이 0건이라(SKILL.md 3회·INSTALL.md 2회 모두 펜스 안) 챌린저 수정안도 **지금은** 통과합니다. 하지만 가드의 존재 이유는 미래의 편집을 견디는 것이므로, 지금 통과하는 것만으로는 부족합니다.
#### 1.10.4 정제안 검증 — 계획 §4.4 적용 후
`§4.4` 대로 편집한 사본(3개 예제에 `--agent "$AGENT"` 추가 + `stop_session.sh` 문자열을 포함하지 않는 산문 문단 추가)에 정제안 적용:
```
SKILL.md checked=3 missing_agent=0
INSTALL.md checked=2 missing_agent=0
```
총 5건, 전건 통과. 펜스 스코프 덕분에 **"산문에 파일명을 쓰지 말라"는 제약이 계획에서 사라집니다** — 이것이 챌린저 수정안 대비 실질 이득입니다.
### 1.11 **[Rev.2 신규] Observation C-2 검증 — 근거는 챌린저가 제시한 것보다 강하다**
#### 1.11.1 어느 이름이 정본인가
```
.mam.env.example:133 # MAM_MIN_PANE_COLS=60
.mam.env.example:137 # MAM_MIN_PANE_ROWS=20
.mam.env.example:143 # MAM_MAX_PANE_COLS=3
lib.sh:432 --min-cols "${MAM_MIN_PANE_COLS:-60}" --min-rows "${MAM_MIN_PANE_ROWS:-20}"
test_herdr_shim_contract.py:100-101 export MAM_MIN_PANE_COLS=60 / MAM_MIN_PANE_ROWS=20
```
`MAM_MIN_COLS` / `MAM_MIN_ROWS` / `MAM_MAX_COLS` 단축형은 **`layout.py:191-193` 안에서만** 등장합니다. 생산 코드·문서·템플릿·테스트 어디에도 없습니다. `report-8f0cb35f.md:72` 는 정리 작업 당시 *"no legacy `MAM_MIN_COLS=`/`MAM_MIN_ROWS=` env-prefix style"* 을 확인 사항으로 적고 있습니다 — 단축형은 **레거시 별칭**입니다.
그런데 `_env_int("MAM_MIN_COLS", "MAM_MIN_PANE_COLS")`**레거시 단축형을 먼저** 봅니다.
#### 1.11.2 결과: 문서화된 유일한 이름이 조용히 무시된다
```
env 현행 or 60 return default continue
{} 60 60 60
{'MAM_MIN_PANE_COLS': '0'} 60 0 0
{'MAM_MIN_PANE_COLS': '25'} 25 25 25
{'MAM_MIN_COLS': 'foo'} 60 60 60
{'MAM_MIN_COLS': '', 'MAM_MIN_PANE_COLS': '25'} 25 25 25
{'MAM_MIN_COLS': 'foo', 'MAM_MIN_PANE_COLS': '25'} 60 60 25 ← 차이
{'MAM_MIN_COLS': 'foo', 'MAM_MIN_PANE_COLS': 'bar'} 60 60 60
{'MAM_MIN_COLS': 'foo', 'MAM_MIN_PANE_COLS': '0'} 60 60 0 ← 차이
```
읽어야 할 두 가지:
1. **`""``"foo"` 가 다르게 취급됩니다.** 빈 문자열은 다음 후보로 넘어가고(`if raw:` 가 걸러냄), 무효 문자열은 즉시 탈출합니다. 둘 다 "쓸 수 없는 값"인데 처리가 정반대입니다. `continue` 는 이 비대칭을 없앱니다.
2. 차이가 나는 두 행에서 무시되는 값은 **`.mam.env.example` 이 문서화한 바로 그 변수**입니다. 운영자가 템플릿대로 `MAM_MIN_PANE_COLS=25` 를 설정했는데, 셸 어딘가에 남은 `MAM_MIN_COLS=foo` 하나 때문에 60 이 적용됩니다.
3. **차이는 정확히 2행뿐입니다.** 나머지 7행은 세 구현이 완전히 일치합니다. 즉 `continue` 는 J-1 수정과 직교하고, 행동 변경 표면이 "첫 후보 무효 + 후속 후보 유효" 라는 한 조건으로 좁혀집니다. 테스트 1건으로 완전히 고정할 수 있습니다(T1b).
**결론: C-2 수용.** 챌린저는 "다중 fallback 취지에 부합" 이라는 설계 논거로 제기했는데, 실측하면 **문서화된 설정이 무시되는 실동작 결함**이라 근거가 더 강합니다. Rev.1 이 `return default` 를 고른 이유는 "오타 입력에 대한 행동 동등성 보존" 이었고 그 목표 자체는 유효하지만, 위 표의 5·7행이 보여주듯 **`continue` 도 그 목표를 똑같이 만족**합니다(모든 후보가 무효면 `default`). Rev.1 은 더 좁은 불변식을 지키느라 더 나은 것을 놓쳤습니다.
---
## 2. 범위
**포함**
| # | 항목 |
|---|---|
| S1 | `lib.sh``resolve_agent_type_from_registry()` 공용 헬퍼 신설 |
| S2 | `stop_session.sh` 폴백을 S1 로 교체 + 헤더/`usage()` 갱신 |
| S3 | `update_yaml_resumed.sh` 의 동일 복사본을 S1 로 교체 + 헤더/`usage()` 갱신 |
| S4 | `stop`/`resume`/`create` SKILL.md 및 3개 스크립트 헤더 주석 문서 동기화 |
| S5 | J-1: `_env_int(*names, default=None)` 리팩터(+ **C-2 `continue`**) 및 `main()` 배선 |
| S6 | 회귀 테스트 **8건** 신설 + 기존 J-2 가드 1건 보강 |
| S7 | `IMPROVEMENTS.md` 백로그 등록 및 완료 카운트 갱신 |
| S8 | (분리 커밋) §1.9 `cd … && pwd` 3곳 |
**제외**
| 항목 | 제외 사유 |
|---|---|
| `run_loop.sh:278` 통합 | 호출 12곳 + `claude` 기본값 제거는 행동 변경 → K-1 |
| `reconcile.sh` 해석 경로 | `3aee63cf` §1.2 실측 반증 유효 |
| `agent_of_row` 세그먼트 매칭 | `reconcile.sh` 입양 판정에 영향 → K-4 |
| `max_columns` falsy-zero | 브리프가 min-cols/min-rows 만 지목 → K-2 |
| **`_env_int` 후보 순서 뒤집기** | 문서화된 `MAM_MIN_PANE_COLS` 를 앞으로 옮기는 것은 **우선순위 변경**이라 C-2 (무효값 건너뛰기)와 별개 사안 → **K-5** |
| 레지스트리에 `agent` 필드 쓰기 | 쓰는 코드가 0건이고 요구되지 않음 |
---
## 3. 설계 결정 — 폴백을 **어디에** 넣는가 (Rev.1 유지)
`stop_session.sh` 현재 순서:
```
:88 --session 검사 → exit 2
:89 YAML 파일 존재 검사 → exit 1
:97 resolve_herdr_workspace (load_state_json #1)
:101 AGENT 접미사 추론 → exit 2 ← 교체 대상
:113 MAPPED_DATA: row 조회 (load_state_json #2)
:124 row 없음 → exit 1
:152 AGENT 최초 사용
```
**안 A (기각)**`:113` 블록에 병합. 프로세스 1개 절약, 코드도 가장 깔끔. **기각 사유**: 해석이 row 조회 뒤로 밀려 "미등록 + 이름 해석 실패" 세션의 종료 코드가 **2 → 1** 로 바뀝니다. §1.8 의 두 테스트가 깨지고 헤더 `:28-30` 의 계약도 바뀝니다. 얻는 것은 34 ms 뿐입니다.
**안 B (채택)**`:101` 자리를 그대로 두고 해석기만 교체.
| 성질 | 결과 |
|---|---|
| 종료 코드 계약 | **불변** (`exit 2`, 동일 메시지) |
| §1.8 기존 테스트 2건 | **수정 불필요** |
| `agy-creator-01` | `EXIT2``agy` ✅ |
| `my-project-dev-claude`, `foo-cline`, `worker-1-agy` | `EXIT2` → 정상 해석 ✅ |
| 미래의 `agent` 명시 필드 | 자동 지원 ✅ |
| 비용 | `--agent` 생략 시에만 `load_state_json` 1회 (~34 ms) |
`set -euo pipefail` 주의: 실패 가능한 명령 치환을 대입에 쓰므로 반드시 `|| AGENT=""` 로 감쌉니다(`test_lib_sh_layout_split_in_set_e_subshell` 선례). `stderr` 는 억제하지 않습니다 — 정상 해석 실패는 `sys.exit(1)` 이라 무출력이고, `PYTHONPATH` 파손 같은 진짜 오류의 traceback 은 보여야 합니다. 기존 테스트는 부분 문자열 단언이라 traceback 이 섞여도 무영향입니다.
---
## 4. 구현
### 4.1 S1 — `lib.sh` 공용 헬퍼
`resolve_herdr_session()`(`:989`) 바로 앞에 추가.
```bash
# resolve_agent_type_from_registry <session_name>
#
# 레지스트리(YAML/DB)에 기록된 사실로 에이전트 종류를 해석한다. 우선순위는
# lib_py.agents.registry.agent_of_row 의 계약을 그대로 따른다:
# ① row['agent'] 명시 필드
# ② 세션명 접미사 (*-{creator,planner,reviewer}-<agent> 및 *-<agent>)
# ③ pane.cmd (정확히 일치하거나 .../<agent> 바이너리 경로)
# 성공하면 에이전트명을 stdout 에 출력하고 0 을, 셋 다 실패하면 아무것도
# 출력하지 않고 1 을 반환한다. 오류 메시지는 호출자가 소유한다 — 각 스크립트가
# 문서화한 종료 코드를 그대로 유지하기 위해서다.
#
# NOTE: agent_of_row 의 match_cmd=True 는 "비-입양 조회" 계약이다. reconcile.sh
# 입양 루프는 이 헬퍼를 쓰면 안 된다 (3aee63cf §1.2 실측 반증).
resolve_agent_type_from_registry() {
local name="$1"
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$name" python3 -c "
import os, json, sys
from lib_py.agents.registry import agent_of_row
name = os.environ['SESSION_NAME']
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
row = next((s for s in d.get('herdr_sessions', []) if s.get('name') == name), {})
resolved = agent_of_row(row, session_name=name)
if not resolved:
sys.exit(1)
print(resolved)
"
}
```
**이름을 `resolve_agent_type` 로 하지 않는 이유**: `run_loop.sh:278` 이 동명 함수를 정의하며 `lib.sh` 를 source 합니다. 동명이면 run_loop 의 나중 정의가 조용히 덮어써서 12개 호출 지점이 어느 구현을 쓰는지 읽어서는 알 수 없게 됩니다.
### 4.2 S2 — `stop_session.sh`
`:100-109` 교체:
```bash
# --agent 미지정 시 레지스트리 기록으로 해석 (B-21).
# ① row['agent'] → ② 세션명 접미사 → ③ pane.cmd 순. 셋 다 실패하면
# 종전과 동일하게 exit 2 (헤더 :27-30 의 종료 코드 계약 유지).
if [ -z "$AGENT" ]; then
AGENT="$(resolve_agent_type_from_registry "$SESSION_NAME")" || AGENT=""
[ -n "$AGENT" ] || {
echo "ERROR: cannot infer agent from '$SESSION_NAME'; pass --agent" >&2
exit 2
}
fi
```
헤더 `:15-16`:
```
# --agent <type> — claude | agy | hermes | cline
# (권장: 항상 명시. 미지정 시 레지스트리 기록으로
# 해석 — agent 필드 → 세션명 접미사 → pane.cmd;
# 셋 다 실패하면 exit 2)
```
`usage()` `:46-47`:
```
--agent <type> — claude | agy | hermes | cline (recommended: always pass it)
(falls back to the registry record: agent field ->
session-name suffix -> pane.cmd)
```
`usage()` 에 4개 에이전트명이 모두 남아야 합니다 — `test_comp_stop_usage_matches_parser`(`test_tier2_component.py:711-712`)가 단언합니다.
### 4.3 S3 — `update_yaml_resumed.sh`
`:43-52` 를 S2 와 동일한 블록으로 교체(메시지·종료 코드 동일). 헤더 `:7` / `usage()` `:14``[--agent claude|agy]``[--agent claude|agy|hermes|cline]`. `:10` 은 이미 올바른 소싱 형태이므로 손대지 않습니다.
### 4.4 S4 — 문서 동기화
**`multi-agent-mux-stop/SKILL.md`**
Pre-flight(`:38-40`):
```bash
SESSION_NAME=<workspace>-creator-<agent> # convention
AGENT=claude # claude | agy | hermes | cline — always pass it
AGENT_SESSIONS_YAML=.mam/agent-sessions.yaml
```
워크플로 예제 3개(`:68, :72, :77`)에 `--agent "$AGENT"` 추가:
```bash
# 1. Stop gracefully (default — captures ID, shuts down safely, status=stopped)
bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
--session "$SESSION_NAME" --agent "$AGENT"
```
"Idempotency" 문단(`:81`) 아래에 추가:
```markdown
**`--agent` is the standard.** Pass it on every invocation. If omitted, the script
resolves the agent from the registry record — the row's `agent` field, then the
session-name suffix, then `pane.cmd` — and exits 2 if none of the three resolve.
The fallback exists for recovery, not as the normal calling convention: a session
whose name carries no agent suffix (e.g. `agy-creator-01`) is only resolvable
while its registry row survives.
```
> **Rev.1 에 있던 제약 삭제.** Rev.1 은 이 산문에 `stop_session.sh` 문자열을 쓰지 말라는 제약을 걸어야 했습니다. Rev.2 의 T5 가 펜스 스코프이므로 **그 제약이 필요 없습니다**(§1.10.3~4). 산문을 자유롭게 쓰십시오.
**`multi-agent-mux-resume/SKILL.md:61`** — `AGENT=claude # or agy or hermes``AGENT=claude # claude | agy | hermes | cline — pass it explicitly`
**`multi-agent-mux-create/SKILL.md`**
- `:146` 동일 수정
- `:171` — 실물 `create_session.sh:86` 과 동일한 `claude, agy, hermes or cline` 문구로. 같은 `case`(`:158-172`)에 `hermes`/`cline` arm 이 없으므로, **스니펫을 축약하고 실물 스크립트를 가리키게 하는 쪽을 권장**합니다. SKILL.md 스니펫이 실물과 갈라지는 것 자체가 이번에 고치는 결함군입니다.
**스크립트 헤더 주석**`create_session.sh:4`, `resolve_session_id.sh:4``--agent <claude|agy>``<claude|agy|hermes|cline>`
### 4.5 S5 — J-1 (+ C-2)
`layout.py:175-186`:
```python
def _env_int(*names: str, default: Optional[int] = None) -> Optional[int]:
"""First *valid* int among the env vars in *names*, else `default`.
`default` is an explicit parameter rather than an `or` at the call site so a
legitimate 0 survives (MAM_MIN_PANE_COLS=0 means 0, not the 60 default).
An unparsable value is skipped rather than raised or treated as terminal: a
typo in an operator's shell must not take the whole layout call down (lib.sh
would silently fall back to 'right'), and must not shadow a later candidate
that IS set correctly -- MAM_MIN_COLS is a legacy alias while
MAM_MIN_PANE_COLS is the name .mam.env.example documents, so aborting on the
first bad value would discard the documented setting. Empty values already
fell through; this makes invalid values behave the same way.
"""
for n in names:
raw = os.environ.get(n, "").strip()
if raw:
try:
return int(raw)
except ValueError:
continue
return default
```
`main()` `:191-193`:
```python
parser.add_argument("--min-cols", type=int, default=_env_int("MAM_MIN_COLS", "MAM_MIN_PANE_COLS", default=60))
parser.add_argument("--min-rows", type=int, default=_env_int("MAM_MIN_ROWS", "MAM_MIN_PANE_ROWS", default=20))
parser.add_argument("--max-cols", type=int, default=_env_int("MAM_MAX_COLS", "MAM_MAX_PANE_COLS"))
```
`--max-cols``default=None` 이 의도된 의미(미지정 = 상한 없음)이므로 그대로 둡니다.
**`return default` 가 아니라 `continue` 여야 하는 이유(불변식 확인)**: 모든 후보가 없거나 무효이면 루프가 끝나 `return default` 에 도달합니다. 즉 Rev.1 이 지키려던 "오타 입력은 문서화된 기본값으로 흡수된다"는 성질은 **그대로 유지**되며(§1.11.2 표 4·7행), 달라지는 것은 "첫 후보 무효 + 후속 후보 유효" 한 조건뿐입니다. `min_cols=None` 으로 `compute_2xk_layout` 에 들어가 `TypeError` 가 나는 경로는 두 안 모두에서 발생하지 않습니다.
`*names` 뒤의 키워드 전용 `default` 는 Python 3.9 에서 유효합니다(시스템 인터프리터 3.9.6 실측). `_env_int` 호출자는 `main()` 3곳뿐입니다.
---
## 5. 테스트 계획
신설 **8건**, 기존 가드 보강 **1건**. 예상 collected: **333 → 341**.
> **Rev.1 자체 정정**: Rev.1 은 "신설 8건 → 341" 이라고 적었으나 실제 열거는 7건이었습니다(T1 3 + T3 1 + T4 2 + T5 1). Rev.2 는 C-2 전용 테스트 T1b 를 더해 실제로 8건이 되며, 341 이라는 수치가 비로소 맞아떨어집니다.
### T1 — J-1 env/flag 등가성 (`tests/test_layout.py`, 3건)
```python
_LAYOUT_ENV_VARS = ("MAM_MIN_COLS", "MAM_MIN_PANE_COLS", "MAM_MIN_ROWS",
"MAM_MIN_PANE_ROWS", "MAM_MAX_COLS", "MAM_MAX_PANE_COLS")
def _run_layout(payload, args=(), env_extra=None):
env = {**os.environ, "PYTHONPATH": os.path.abspath(".agents/skills")}
for k in _LAYOUT_ENV_VARS:
env.pop(k, None) # 호출자 셸의 오염 차단
env.update(env_extra or {})
res = subprocess.run([sys.executable, "-m", "lib_py.layout", "--json", *args],
input=json.dumps(payload), capture_output=True, text=True, env=env)
assert res.returncode == 0, res.stderr
return json.loads(res.stdout)
# height//2 = 15 < min_rows(20) 로 제약 분기 진입, width//2 = 25 가 min_cols 와 비교됨.
_ZERO_TRAP = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 50, "height": 30}}]}}
def test_j1_env_zero_min_cols_matches_flag_zero():
"""J-1: MAM_MIN_PANE_COLS=0 must mean 0, not fall through to the 60 default."""
flag = _run_layout(_ZERO_TRAP, ("--min-cols", "0"))
assert flag["direction"] == "right" and flag["reason"] == "single_pane_height_constrained"
for var in ("MAM_MIN_COLS", "MAM_MIN_PANE_COLS"):
assert _run_layout(_ZERO_TRAP, (), {var: "0"}) == flag, var
def test_j1_env_zero_min_rows_matches_flag_zero():
flag = _run_layout(_ZERO_TRAP, ("--min-rows", "0"))
assert flag["direction"] == "down" and flag["reason"] == "single_pane_split_down"
for var in ("MAM_MIN_ROWS", "MAM_MIN_PANE_ROWS"):
assert _run_layout(_ZERO_TRAP, (), {var: "0"}) == flag, var
def test_j1_nonzero_and_malformed_env_behaviour_unchanged():
"""Behaviour neutrality: non-zero env still applies, and a lone typo still
lands on the documented default instead of crashing on a None comparison."""
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_PANE_COLS": "25"}) == \
_run_layout(_ZERO_TRAP, ("--min-cols", "25"))
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_PANE_COLS": "abc"}) == _run_layout(_ZERO_TRAP)
```
### T1b — **[Rev.2 신규]** C-2: 무효값이 뒤 후보를 가리지 않는다 (1건)
```python
def test_j1b_invalid_alias_does_not_shadow_the_documented_var():
"""C-2: MAM_MIN_COLS is a legacy alias checked first; MAM_MIN_PANE_COLS is the
name .mam.env.example documents. An unparsable value in the alias must be
skipped, not abort the search and discard the documented setting.
Empty values already fell through (`if raw:`); this makes invalid values
behave the same way. When every candidate is unusable, `default` still wins.
"""
good = _run_layout(_ZERO_TRAP, (), {"MAM_MIN_PANE_COLS": "25"})
assert good["direction"] == "right"
# 별칭이 깨져 있어도 문서화된 변수가 적용된다
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_COLS": "foo",
"MAM_MIN_PANE_COLS": "25"}) == good
# 0 도 마찬가지 (J-1 과의 상호작용)
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_COLS": "foo",
"MAM_MIN_PANE_COLS": "0"}) == \
_run_layout(_ZERO_TRAP, ("--min-cols", "0"))
# 모든 후보가 무효면 문서화된 기본값으로 흡수 (Rev.1 불변식 보존)
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_COLS": "foo",
"MAM_MIN_PANE_COLS": "bar"}) == _run_layout(_ZERO_TRAP)
```
마지막 단언이 중요합니다 — `continue` 로 바꾸면서 Rev.1 이 지키려던 성질이 깨지지 않았음을 같은 테스트 안에서 못 박습니다.
### T2 — J-2 임계값 보강 (기존 `test_headless_max_columns_growth_guard` 확장, 신설 0건)
```python
# n=5 is the first odd n that can discriminate: n//2 == 2 == max_columns, so an
# over-correction that also checked the cap on the odd branch would return
# overflow here. n=3 has n//2 == 1 and cannot reach the check at all.
d5 = compute_2xk_layout(headless(5), max_columns=2)
assert d5.direction == "down" and not d5.is_overflow
assert d5.reason == "headless_odd_down"
```
계획 `5e4ef463` 의 뮤테이션 M6 사양 오류(제가 `n=3` 을 골랐고 그 값으로는 판별 불가)를 닫습니다.
### T3 — `agent_of_row` 단위 보강 (`tests/test_a4_adapter_contract.py`, 1건)
```python
def test_agent_of_row_pane_cmd_binary_path_and_failure():
# pane.cmd 가 절대 경로 형태여도 해석된다
assert agent_of_row({'pane': {'cmd': '/usr/local/bin/agy'}}) == 'agy'
# 세 경로 모두 실패하면 None — 호출자가 오류를 소유한다
assert agent_of_row({}, session_name='bad-session-name') is None
# 입양 조회용 match_cmd=False 에서는 pane.cmd 를 보지 않는다
assert agent_of_row({'name': 'agy-creator-01', 'pane': {'cmd': 'agy'}},
match_cmd=False) is None
```
### T4 — `stop_session.sh` 폴백 (`tests/test_tier2_component.py`, 2건)
기존 `test_comp_stop_sqlite_state_update``run_mutation` 패턴 사용(herdr 부재 → "herdr already dead, just updating YAML" 경로로 rc=0 완주, 실제 세션 미영향).
```python
def test_comp_stop_agent_fallback_reads_pane_cmd(mam_sandbox):
"""B-21: --agent 생략 시 세션명에 에이전트 접미사가 없어도 레지스트리 행의
pane.cmd 로 해석된다 (라이브 `agy-creator-01` 형태)."""
mutation = """
d['herdr_sessions'] = [{
'name': 'agy-creator-01',
'status': 'running',
'pane': {'cwd': 'WS_PLACEHOLDER', 'cmd': 'agy'}
}]
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
run_mutation(mam_sandbox, mutation)
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
res = subprocess.run(["bash", str(script), "--session", "agy-creator-01"],
capture_output=True, text=True)
assert res.returncode == 0, res.stderr
assert re.search(r"^\s*agent:\s+agy\s*$", res.stdout, re.M), res.stdout
def test_comp_stop_agent_fallback_prefers_explicit_agent_field(mam_sandbox):
"""우선순위 계약: 명시 `agent` 필드가 세션명 접미사와 pane.cmd 를 모두 이긴다."""
mutation = """
d['herdr_sessions'] = [{
'name': 'x-creator-claude',
'status': 'running',
'agent': 'hermes',
'pane': {'cwd': 'WS_PLACEHOLDER', 'cmd': 'claude'}
}]
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
run_mutation(mam_sandbox, mutation)
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
res = subprocess.run(["bash", str(script), "--session", "x-creator-claude"],
capture_output=True, text=True)
assert res.returncode == 0, res.stderr
assert re.search(r"^\s*agent:\s+hermes\s*$", res.stdout, re.M), res.stdout
```
두 번째가 T4 를 "pane.cmd 를 읽는다" 가 아니라 **"`agent_of_row` 계약을 호출한다"** 로 고정합니다. 첫 번째만 있으면 `pane.cmd` 만 직접 읽는 얕은 구현도 통과합니다.
**미해결 계약(exit 2)** 은 이미 `test_tier1_unit.py:142``test_tier3_integration.py:398` 이 지킵니다. 두 파일을 **수정하지 않은 채 통과하는 것**이 안 B 의 증거이므로 중복 테스트를 추가하지 않습니다.
### T5 — **[Rev.2 재설계]** 문서 가드 (`tests/test_tier2_component.py`, 1건)
Rev.1 원안은 §1.10.1 의 실측대로 `checked == 2` 로 확정 실패하고, §1.10.2 대로 단일 호출 회귀를 놓칩니다. 챌린저의 **커맨드 단위** 교정을 채택하되, §1.10.3 의 산문 위양성을 막기 위해 **펜스 스코프**를 합성합니다.
```python
# 코드 펜스 안의 stop_session.sh 호출을 '명령 단위'로 잘라낸다.
# - 펜스 스코프: 산문 속 `stop_session.sh` 언급을 명령으로 오인하지 않는다
# (Pitfalls / When-NOT-to-use 절은 성격상 스크립트를 산문으로 언급한다).
# - 명령 단위: 한 펜스에 여러 호출이 들어 있어도 각각을 따로 검증한다
# (블록 단위로 보면 그중 하나만 --agent 를 가져도 통과해 버린다).
_FENCE_RE = re.compile(r"```(?:bash|sh)\n(.*?)```", re.S)
_STOP_CALL_RE = re.compile(r"(?:bash\s+)?\S*stop_session\.sh[^\n\\]*(?:\\\n[^\n\\]*)*")
def test_comp_docs_stop_examples_pass_agent():
"""B-21 문서 계약: 문서의 모든 stop_session.sh 예제는 --agent 를 넘긴다.
문서 변경은 뮤테이션 감도가 없으므로 이 가드가 표준의 유일한 집행 장치다."""
repo = Path(__file__).resolve().parent.parent
expected = { # 문서별 최소 예제 수 — 예제를 지워 가드를 무력화하는 것을 막는다
repo / ".agents/skills/multi-agent-mux-stop/SKILL.md": 3,
repo / "deploy/INSTALL.md": 2,
}
for doc, floor in expected.items():
seen = 0
for block in _FENCE_RE.findall(doc.read_text()):
for m in _STOP_CALL_RE.finditer(block):
snippet = m.group(0)
seen += 1
assert "--agent" in snippet, \
f"{doc.name}: stop_session.sh example without --agent:\n{snippet}"
assert seen >= floor, f"{doc.name}: expected >= {floor} examples, saw {seen}"
```
Rev.1/챌린저안 대비 세 가지가 다릅니다.
| | Rev.1 원안 | 챌린저 수정안 | **Rev.2** |
|---|---|---|---|
| 검증 단위 | 코드 블록 | 명령 | 명령 |
| 스캔 범위 | 펜스 | **문서 전체** | 펜스 |
| 개수 하한 | 전역 `>= 4` (**실패**) | 전역 `>= 5` | **문서별** (3 / 2) |
전역 카운트를 문서별로 쪼갠 이유: 전역이면 SKILL.md 예제 1개가 사라져도 INSTALL.md 가 6개면 통과합니다. 문서별 하한은 실패를 발생 지점에 국소화합니다.
**실측 확인** (§1.10.4): §4.4 적용 후 사본에서 `SKILL.md checked=3 missing=0`, `INSTALL.md checked=2 missing=0`.
### T6 — 회귀 무영향 확인
`bash -n`: `lib.sh`, `stop_session.sh`, `update_yaml_resumed.sh`, `create_session.sh`, `resume_session.sh`, `resolve_session_id.sh`.
`py_compile`: `lib_py/layout.py`. 시스템 파이썬 **3.9.6** 임포트 확인.
### 테스트 파일 사전 조건 2건
1. `test_tier2_component.py:296``FEATURE 3: Stop Session (4 Test Cases)` 주석 개수 갱신(→ 7). 같은 종류의 드리프트를 새로 만들지 않도록.
2. `test_tier2_component.py``Path` 는 임포트하지만 **`re` 는 임포트하지 않습니다**(`:1-10`). T4/T5 가 `re` 를 쓰므로 `import re` 추가 필요. `test_layout.py` 는 T1/T1b 가 쓰는 `os/json/subprocess/sys` 를 모두 이미 임포트하고 있어 추가 불필요합니다.
---
## 6. 뮤테이션 매트릭스
격리 사본(`rsync`)에 적용해 지정 테스트가 **FAIL** 하는지 확인.
| # | 뮤테이션 | FAIL 해야 하는 테스트 |
|---|---|---|
| M1 | `stop_session.sh` 폴백을 옛 `case` 블록으로 복원 | `test_comp_stop_agent_fallback_reads_pane_cmd` |
| M2 | 헬퍼에서 `agent_of_row(row, …)``agent_of_row({}, session_name=name)` | 위 + `…prefers_explicit_agent_field` |
| M3 | 헬퍼에 `match_cmd=False` 추가 | `…reads_pane_cmd` **만** (두 테스트가 서로 다른 성질을 잡음을 증명) |
| M4 | `_env_int(…, default=60)``_env_int(…) or 60` | `test_j1_env_zero_min_cols_matches_flag_zero` |
| M5 | `_env_int``except ValueError: continue``return None` | `test_j1_nonzero_and_malformed_env_behaviour_unchanged` (rc≠0) |
| **M5b** | **[Rev.2]** `except ValueError: continue``return default` | `test_j1b_invalid_alias_does_not_shadow_the_documented_var` |
| M6 | 헤드리스 홀수 분기에도 `max_columns` 검사 추가 (과잉 교정) | `test_headless_max_columns_growth_guard` (신설 `d5` 단언) |
| M7 | `SKILL.md` 예제 **한 곳**에서 `--agent` 삭제 | `test_comp_docs_stop_examples_pass_agent` |
| **M7b** | **[Rev.2]** `INSTALL.md` 의 **두 호출 중 하나**에서만 `--agent` 삭제 | 동일 (§1.10.2 에서 이미 선실측: Rev.1 설계는 미검출, Rev.2 설계는 `missing=1` 검출) |
| **M7c** | **[Rev.2]** `SKILL.md` 워크플로 예제 1개를 통째로 삭제 | 동일 (`seen >= 3` 하한) |
| M8 | `update_yaml_resumed.sh` 폴백을 옛 `case` 블록으로 복원 | — **가드 없음** |
**M8 을 정직하게 남깁니다.** `update_yaml_resumed.sh` 의 폴백은 유일한 생산 호출자인 `resume_session.sh:66, :129` 가 항상 `--agent "$AGENT"` 를 명시하므로 **그 경로에서 도달 불가**합니다. 직접 호출 시에만 살아납니다. 도달 불가 경로를 위해 별도 픽스처를 세우는 대신 S3 는 "중복 제거"로 정당화하고 가드 없음을 명시합니다. 리뷰어가 이 판단에 이의가 있으면 T4 와 동형의 테스트 추가가 옳은 처방입니다.
**M5 와 M5b 가 서로 다른 테스트를 깨는 것**이 C-2 반영의 검증 조건입니다. M5(=`None` 복귀)는 크래시 경로를, M5b(=Rev.1 안으로 복귀)는 별칭 섀도잉을 각각 잡습니다. 둘 다 잡히지 않으면 T1b 가 의미 없는 테스트라는 뜻입니다.
---
## 7. 커밋 분할
| # | 커밋 | 내용 |
|---|---|---|
| 1 | `feat(lib,stop,resume): resolve --agent from the registry via agent_of_row (B-21)` | S1 + S2 + S3 + T3 + T4 |
| 2 | `docs(skills): standardize explicit --agent across stop/resume/create guides (B-21)` | S4 + T5 |
| 3 | `fix(layout): make _env_int take an explicit default and skip invalid values (J-1)` | S5 + T1 + T1b |
| 4 | `test(layout): cover the headless growth-guard threshold at n=5 (J-2)` | T2 |
| 5 | `docs(improvements): register J-1/J-2/B-21 and refresh the completed count` | S7 |
| 6 | `fix(scripts): repair the dead lib.sh sourcing path in stop/create/resume` | S8 (§1.9) |
커밋 1~4 는 각각 독립 revert 가능합니다. 커밋 6 은 §1.9 가 브리프 범위 밖의 별개 사안이므로 분리합니다 — 리뷰어가 범위 이탈로 판단하면 이 커밋만 드롭하면 됩니다.
커밋 3 의 제목이 Rev.1 에서 바뀌었습니다(`… and skip invalid values` 추가). C-2 가 J-1 과 다른 성질의 변경이므로 제목이 그 사실을 담아야 합니다.
---
## 8. `IMPROVEMENTS.md` 갱신 (S7)
J-1 / J-2 는 현재 `IMPROVEMENTS.md`**등록돼 있지 않습니다**(`55d1a1d9` 리뷰 보고서에만 존재).
**ID 충돌 경고**: `IMPROVEMENTS.md:301``C-1`("Kanban 문서 29회 언급 vs 실제 구현 0건")과 `:37` 이 참조하는 `C-1`(레이아웃 헤드리스 `max_columns`)은 **서로 다른 두 과제가 같은 ID** 를 씁니다. 신규는 `J-1`/`J-2`/`B-21` 을 씁니다. 기존 충돌은 K-3.
갱신 항목:
1. `:3` 최종 갱신일
2. `:6` 총 추적 미해결 과제 카운트
3. `:7` 완료 과제 **29 → 30** 및 목록에 `B-21` 추가
4. `:37` B-20 후속 정리 줄에 J-1/J-2 해소 한 줄
5. §2 에 `B-21` 절 신설 — 현상(라이브 `agy-creator-01``--agent` 없이 `exit 2`), 원인(해석기 4중화), 조치, 회귀 가드
6. §6.2 로드맵 표에 완료 행
7. §6.3 파일 소유권 슬롯 표 갱신
---
## 9. 후속 백로그 (이번 범위 밖, 등록만)
| ID | 내용 | 근거 |
|---|---|---|
| **K-1** | `run_loop.sh:278` `resolve_agent_type` 통합 | §1.1 — 해석 실패를 `claude` 로 흡수. cline 세션에 claude 종료키를 보내는 오분류가 구조적으로 가능. 호출 12곳이라 별도 계획 필요 |
| **K-2** | `compute_2xk_layout``if max_columns and …` falsy-zero | `--max-cols 0`("열 0개")이 "상한 없음"으로 흡수됨. J-1 과 동일 부류 |
| **K-3** | `IMPROVEMENTS.md``C-1` ID 충돌 정리 | §8 |
| **K-4** | `agent_of_row` 에 하이픈 세그먼트 매칭 추가 여부 | `orc-hermes-main` 류 미해결. `reconcile.sh` 입양 판정 영향 → 실측 선행 |
| **K-5** | **[Rev.2 신규]** `_env_int` 후보 **순서** 재검토 | §1.11.1 — 문서화되지 않은 레거시 `MAM_MIN_COLS``.mam.env.example` 이 문서화한 `MAM_MIN_PANE_COLS` 보다 **우선**합니다. C-2(무효값 건너뛰기)는 이 순서 문제를 완화할 뿐 해소하지 않습니다. 둘 다 유효한 값이면 여전히 레거시가 이깁니다. 순서 변경은 행동 변경이므로 별도 항목 |
---
## 10. 검증 절차 (Creator 실행)
```bash
# 1) 구문
for f in .agents/skills/lib.sh \
.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh \
.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh \
.agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh \
.agents/skills/multi-agent-mux-create/scripts/create_session.sh; do
bash -n "$f" || echo "FAIL $f"
done
python3 -m py_compile .agents/skills/lib_py/layout.py
# 2) J-1 직접 확인 (0 이 살아남는가)
P='{"result":{"panes":[{"pane_id":"p1","rect":{"x":0,"y":0,"width":50,"height":30}}]}}'
printf '%s' "$P" | PYTHONPATH=.agents/skills MAM_MIN_PANE_COLS=0 python3 -m lib_py.layout --json
printf '%s' "$P" | PYTHONPATH=.agents/skills python3 -m lib_py.layout --json --min-cols 0
# → 두 출력이 완전히 동일하고 direction=right
# 3) C-2 직접 확인 (무효 별칭이 문서화된 변수를 가리지 않는가)
printf '%s' "$P" | PYTHONPATH=.agents/skills \
env MAM_MIN_COLS=foo MAM_MIN_PANE_COLS=25 python3 -m lib_py.layout --json
# → direction=right (수정 전에는 overflow)
# 4) 전체 스위트 (베이스라인 333 → 기대 341)
.venv/bin/python -m pytest tests/ -q
# 5) 배포 신선도 (기존 31건 유지)
.venv/bin/python -m pytest tests/test_deploy_freshness.py -q
# 6) 뮤테이션 M1~M7c (격리 사본에서)
```
**금지 사항**: `tests/test_tier1_unit.py:142``tests/test_tier3_integration.py:398`**수정하지 않습니다**. 두 건이 무수정으로 PASS 하는 것이 안 B 의 종료 코드 계약 보존을 입증하는 증거입니다. 고쳐야 통과한다면 구현이 안 A 로 흘러간 것이므로 되돌려야 합니다.
**라이브 세션 보호**: T4 는 `mam_sandbox` 안에서만 동작하며 실제 `.mam/agent-sessions.yaml` 을 건드리지 않습니다. 개발 중 `stop_session.sh` 를 실 워크스페이스에서 수동 실행하지 마십시오 — `canary-projects-multi-agent-mux-creator-claude` 가 이 세션입니다.
---
## 11. 규모 추정
| 파일 | 변경 |
|---|---|
| `.agents/skills/lib.sh` | +22 (헬퍼 1개) |
| `stop_session.sh` | +8 / 10, 헤더·usage +6 |
| `update_yaml_resumed.sh` | +8 / 9, 헤더·usage +2 |
| `lib_py/layout.py` | +10 / 6 (docstring 확장 포함) |
| `multi-agent-mux-stop/SKILL.md` | +12 |
| `multi-agent-mux-resume/SKILL.md` | +1 / 1 |
| `multi-agent-mux-create/SKILL.md` | +2 / 2 |
| `create_session.sh` / `resolve_session_id.sh` | 헤더 각 +1 / 1 |
| `tests/test_layout.py` | +62 (T1 45 + T1b 17) |
| `tests/test_a4_adapter_contract.py` | +9 |
| `tests/test_tier2_component.py` | +58 |
| `IMPROVEMENTS.md` | +20 |
| (커밋 6) 3개 스크립트 소싱 줄 | +3 / −3 |
**약 +215 / 35 줄**, 파일 12개. 규모 **소~중**.
---
## 12. 챌린저에게
C-1 은 계획대로 짜면 확정 실패하는 결함이었고, 두 갈래 모두 정확했습니다. 특히 갈래 ②(부분 문자열 위양성)는 **테스트가 통과하기 때문에 아무도 눈치채지 못하는** 종류라 더 값어치가 있습니다. §1.10.2 에서 뮤테이션으로 재현했습니다.
수정안을 그대로 채택하지 않은 부분은 한 곳입니다 — 문서 전체 스캔이 산문 속 파일명 언급을 명령으로 오인합니다(§1.10.3, 위양성 2건 실측). 이 계획 §4.4 자체가 SKILL.md 에 산문을 추가하므로 임박한 문제였습니다. 커맨드 단위라는 **핵심 교정은 그대로 채택**하고 펜스 스코프를 얹었습니다.
C-2 는 제기하신 근거(다중 fallback 취지)보다 강한 근거가 실측에서 나왔습니다. `MAM_MIN_COLS``layout.py` 밖 어디에도 없는 레거시 별칭이고, 가려지는 `MAM_MIN_PANE_COLS``.mam.env.example` 이 문서화한 **유일한** 이름입니다. 게다가 현행 코드는 `""` 는 건너뛰고 `"foo"` 는 탈출하는 비대칭을 갖고 있습니다. 수용하고 전용 테스트 T1b 를 신설했습니다.
@@ -0,0 +1,176 @@
# 🔍 교차 코드 리뷰 — Job `dcbdf349`
- **역할**: Reviewer
- **대상**: Rev.2 계획서(`d6f54b7f`) 구현분 — 워킹 트리 수정 14파일 (`+255 / 46`)
- **기준 커밋**: `14e306b` / 미추적 파일 0건
---
## 1. 결론
계획서 Rev.2 의 8개 작업항목(S1~S8)이 **전부 사양대로** 구현됐습니다. 전체 스위트 **341 passed / 425.16s / exit 0** 으로 계획이 예측한 수치와 정확히 일치하고, 뮤테이션 **10종 전부 지정 테스트를 FAIL** 시킵니다.
차단 사유 없음. P3 관찰 3건만 기록합니다.
---
## 2. 검증 결과 요약
| 검증 | 결과 |
|---|---|
| 전체 스위트 | **341 passed, 425.16s, exit 0** (계획 예측 341 과 일치) |
| 배포 신선도 단독 | **31 passed** (기존 유지) |
| `bash -n` 6개 스크립트 | 6/6 OK |
| `py_compile` (시스템 Python **3.9.6**) | OK |
| 수집 수 | 333 → **341** (신설 8건) |
| `test_layout.py` | 19 → **23** (IMPROVEMENTS.md 기재치와 일치) |
| **보호 대상 2파일 무수정** | `test_tier1_unit.py` / `test_tier3_integration.py``git diff --stat` 출력 **0줄** |
| 뮤테이션 | **10/10 검출** |
---
## 3. 뮤테이션 매트릭스 — 10/10 검출
격리 `rsync` 사본에서 실행. 무뮤테이션 대조군은 대상 7건 전건 통과(`7 passed in 2.73s`).
| # | 뮤테이션 | 결과 |
|---|---|---|
| M1 | `stop_session.sh` 폴백을 옛 `case` 블록으로 복원 | `…reads_pane_cmd` **FAILED** |
| M2 | 헬퍼에서 row 폐기 (`agent_of_row({}, …)`) | **2건 모두 FAILED** |
| M3 | 헬퍼에 `match_cmd=False` | `…reads_pane_cmd` **FAILED** / `…prefers_explicit_agent_field` PASSED |
| M4 | `default=60``… or 60` | `test_j1_env_zero_min_cols…` **FAILED** |
| M5 | `except ValueError: continue``return None` | **2건 모두 FAILED** |
| M5b | `continue``return default` (Rev.1 안으로 복귀) | `test_j1b…` **FAILED** / `…malformed_env_behaviour_unchanged` PASSED |
| M6 | 헤드리스 홀수 분기에도 상한 검사 추가 | `test_headless_max_columns_growth_guard` **FAILED** |
| M7 | SKILL.md 예제 1곳에서 `--agent` 삭제 | 문서 가드 **FAILED** |
| M7b | INSTALL.md **두 호출 중 하나만** `--agent` 삭제 | 문서 가드 **FAILED** |
| M7c | SKILL.md 워크플로 예제 1개 통째 삭제 | 문서 가드 **FAILED** |
### 값어치 있는 세 가지
**M3 이 정확히 하나만 깬다.** 두 T4 테스트가 서로 다른 성질을 잡는다는 것이 실증됐습니다. `…reads_pane_cmd` 하나만 있었다면 `pane.cmd` 를 직접 긁는 얕은 구현도 통과했을 것이고, `…prefers_explicit_agent_field` 가 그 구현을 배제합니다.
**M5 와 M5b 가 서로 다른 테스트를 깬다.** C-2 반영의 검증 조건이 그대로 성립했습니다 — M5(`None` 복귀)는 크래시 경로를, M5b(Rev.1 안 복귀)는 별칭 섀도잉을 각각 잡습니다. 둘 중 하나라도 잡히지 않았다면 `T1b` 는 장식이었을 것입니다.
**M7b / M7c 가 서로 다른 사유로 깨진다.** 문서 가드의 두 독립 기제가 각각 살아 있다는 뜻입니다.
```
[M7b] AssertionError: INSTALL.md: stop_session.sh example without --agent: ← 커맨드 단위 검사
[M7c] AssertionError: SKILL.md: expected >= 3 examples, saw 2 ← 문서별 개수 하한
```
M7b 는 Rev.1 원안(블록 단위)이 **놓쳤던** 바로 그 케이스입니다. 챌린저 `9f85218e` 의 지적이 실물 가드에서 값어치를 증명했습니다.
---
## 4. 동작 실측
### 4.1 핵심 결함 — 라이브 세션 해석
```
agy-creator-01 -> agy
canary-projects-multi-agent-mux-creator-cline -> cline
bad-session-name -> <none rc=1>
```
`--agent` 없이 `exit 2` 로 거부되던 실제 running 세션 `agy-creator-01``pane.cmd` 로 해석됩니다. 동시에 `bad-session-name` 은 rc=1 로 실패해 호출자의 `exit 2` 계약이 유지됩니다.
계약 테스트 직접 확인: `test_stop_session_invalid_agent_suffix` **PASSED** (무수정 상태). 계획 §3 안 B 의 "기존 테스트를 한 줄도 안 고치고 결함만 제거" 라는 수용 조건이 충족됐습니다.
### 4.2 J-1 / C-2
```
MAM_MIN_PANE_COLS=0 : {"direction": "right", "reason": "single_pane_height_constrained"}
--min-cols 0 : {"direction": "right", "reason": "single_pane_height_constrained"} ← 동치
MAM_MIN_COLS=foo +PANE_COLS=25 : {"direction": "right", "reason": "single_pane_height_constrained"} ← C-2
MAM_MIN_COLS=foo +PANE_COLS=bar : {"direction": "overflow", "reason": "single_pane_overflow"} ← 불변식 보존
baseline : {"direction": "overflow", "reason": "single_pane_overflow"}
```
3행이 C-2 수정(무효 별칭이 문서화된 변수를 가리지 않음), 4행이 Rev.1 불변식 보존(모든 후보 무효 → 문서화된 기본값)입니다. 두 성질이 한 구현에 공존합니다.
### 4.3 실패 경로 — `set -euo pipefail` 하 안전성
`PYTHONPATH` 를 파손시킨 상태에서:
```
rc-guarded, AGENT=[] (빈 값이면 호출자가 exit 2 로 처리)
stderr 첫 줄: Traceback (most recent call last):
```
`AGENT="$(...)" || AGENT=""``set -e` 조기 종료를 막고, 계획대로 **stderr 를 억제하지 않아** traceback 이 보입니다. 진짜 오류와 정상 해석 실패가 구분됩니다. 명시 `--agent bogus` 검증도 그대로입니다(`invalid agent type 'bogus'`).
### 4.4 S8 소싱 경로 복구 — 실효 확인
계획이 "드롭 가능한 별도 커밋" 으로 분리했던 항목이라, 실제 효과가 있는지 되돌려 봤습니다.
```
[되돌린 사본] / 에서 WORKSPACE_ROOT 없이 실행
→ stop_session.sh: line 39: //.agents/skills/lib.sh: No such file or directory
[현행] / 에서 WORKSPACE_ROOT 없이 실행
→ Usage: ... --session <name> [--agent claude|agy|hermes|cline] ...
```
장식이 아니라 실제 장애를 닫습니다. `cd` 가 **성공**할 때 빈 문자열이 되던 결함이라 1차 소싱 경로가 100% 죽어 있었고, 이제 살아났습니다.
---
## 5. 관찰 사항 (P3 — 전부 비차단)
### 🟡 O-1: S8 소싱 복구에 회귀 가드가 없다
되돌린 사본에 tier1+tier2+tier3 전체를 돌린 결과 **78 passed** — 아무 테스트도 잡지 못합니다. 모든 테스트가 `WORKSPACE_ROOT` 를 설정하거나 저장소 루트에서 실행되므로 `:36` 폴백이 항상 성공하기 때문입니다.
계획이 M8(`update_yaml_resumed.sh` 폴백 무가드)을 정직하게 남긴 것과 같은 성격입니다. 다만 S8 은 **도달 불가 경로가 아니라 실측된 실동작 결함**(§4.4)을 고친 것이므로 M8 보다 가드 부재의 무게가 큽니다. 처방은 한 줄입니다 — 저장소 밖 cwd + `WORKSPACE_ROOT` 미설정으로 `--help` 를 실행해 rc=0 을 단언.
차단하지 않는 이유: 변경 자체가 순수 개선이고(되돌리면 명백히 실패), 계획이 이 커밋을 분리 가능하도록 설계했으며, 가드 부재가 다른 어떤 것도 가리지 않습니다.
### 🟡 O-2: `create/SKILL.md` 스니펫이 자기모순 상태가 됐다
```bash
agy)
herdr new-session ... "agy --dangerously-skip-permissions"
;;
*) echo "ERROR: --agent must be claude, agy, hermes or cline, got: $AGENT"; exit 2 ;;
```
오류 메시지는 4종을 허용한다고 광고하는데 `case` arm 은 `claude`/`agy` 둘뿐입니다. 스니펫을 그대로 따라 `--agent hermes` 를 주면 `*)` 로 떨어져 "hermes 는 허용된다" 는 메시지를 내며 죽습니다. 변경 **전에는** 메시지와 구현이 (둘 다 2종으로) 일치했으므로, 이 한 스니펫의 내부 정합성은 오히려 나빠졌습니다.
실물 `create_session.sh:187``agy|hermes|cline)` 로 4종을 정상 처리하므로 **생산 코드에는 결함이 없습니다**. 계획 §4.4 는 이 지점에 대해 "스니펫을 축약하고 실물을 가리키게 하는 쪽을 권장" 했고 Creator 는 메시지 수정 쪽을 골랐는데, 그 선택이 계획이 축약을 권한 이유를 그대로 드러냈습니다. `agy)``agy|hermes|cline)` 한 글자 수정이면 정합해집니다.
### 🟡 O-3: `update_yaml_resumed.sh:45` 주석의 라인 참조가 남의 것
```bash
# 종전과 동일하게 exit 2 (헤더 :27-30 의 종료 코드 계약 유지).
```
`:27-30``stop_session.sh` 의 종료 코드 헤더 위치입니다. `update_yaml_resumed.sh``:27-30` 은 인자 파서이고, 이 파일에는 종료 코드 계약 헤더 자체가 없습니다. 두 스크립트에 같은 블록을 심으면서 따라온 복사 흔적입니다. 동작 무영향.
---
## 6. 계획 대비 이행 점검
| 항목 | 이행 |
|---|---|
| S1 `resolve_agent_type_from_registry()` | ✅ 계획 코드 그대로. 이름 분리(run_loop 동명 함수 섀도잉 회피) 반영 |
| S2 `stop_session.sh` | ✅ `:101` 위치 유지, 메시지·종료 코드 불변, 헤더/`usage()` 갱신 |
| S3 `update_yaml_resumed.sh` | ✅ 동일 블록 + 4종 표기 |
| S4 문서 동기화 | ✅ SKILL.md 3종 + 스크립트 헤더 2종 |
| S5 J-1 + C-2 | ✅ `default=` 인자화 + `continue`, docstring 이 두 결정 근거를 모두 기술 |
| S6 테스트 8건 + J-2 보강 | ✅ 341 collected |
| S7 `IMPROVEMENTS.md` | ✅ B-21 절 신설, 완료 29→30, 테스트 수 19→23 갱신 |
| S8 소싱 복구 3곳 | ✅ (가드 없음 — O-1) |
| 범위 준수 | ✅ `run_loop.sh`·`reconcile.sh` 무수정 (K-1/K-4 유지) |
`usage()` 에 4개 에이전트명이 모두 남아야 하는 제약(`test_comp_stop_usage_matches_parser`)도 유지됐고, `FEATURE 3` 주석 개수(4→7)와 `import re` 추가라는 사전 조건 2건도 반영됐습니다.
---
## 7. 총평
계획 Rev.2 는 챌린지 라운드를 거치며 문서 가드가 두 번 재설계됐는데, 그 결과가 M7b 에서 값어치를 증명했습니다 — Rev.1 설계였다면 통과했을 회귀를 현행 가드가 잡습니다. C-2 도 M5/M5b 가 서로 다른 테스트를 깨면서 "행동 변경이지만 기존 불변식은 보존" 이라는 주장이 실증됐습니다.
남은 3건은 전부 P3 이고 어느 것도 결함을 가리지 않습니다. O-2 는 문서 스니펫 한 글자, O-3 은 주석 한 줄, O-1 은 가드 신설 권고입니다. 다음 커밋에 함께 정리하면 충분합니다.
[VERDICT: PASS]
@@ -0,0 +1,122 @@
# Cross-Code Review — Job `34201859`
- **Reviewer**: cline (herdr:canary-projects-multi-agent-mux-creator-cline)
- **Scope**: `--agent` 표준화(B-21), 백로그 J-1(`_env_int` falsy-zero), C-2(무효 별칭 skip), J-2(임계값), 회귀 테스트, 전체 PASS
- **Changeset**: 14 files, +255 / 46 (`git diff --stat HEAD`)
- **Baseline**: 작업 트리 modified(커밋 전). `pytest tests/ --collect-only` 기준 약 341 건.
---
## §0. 결론 (TL;DR)
4개 작업 목표 모두 구현되었고, 변경분의 직접 회귀 테스트 39 건은 100% 통과한다. 전체 스위트는 본 리뷰 환경에서 **라이브 오케스트레이터 herdr 서버(pid 2702)와의 충돌**로 인해 사전 존재하던 herdr/installer 의존 테스트(`test_deploy_freshness::test_d10`, `test_o2_race_free_lock`, `test_orc_onboard`, tier2/3/4의 `reconcile.sh --subscribe --idle-timeout 0` 스폰 테스트)가 hang/강제 종료되어 단일 run으로 끝까지 닿지 못한다. 이들은 **본 변경분이 건드리지 않는 사전 존재 테스트**이며, 어느 run 에서도 `FAILED`/`ERROR` 를 낸 적이 없다(아래 §6). 설계 변경/재작업 수준의 재계획은 불필요하다. 최종 판정은 리포트 마지막 단독 행에 명시(§7).
---
## §1. 변경 파일 범위
| 파일 | 변경 | 요지 |
|---|---|---|
| `.agents/skills/lib.sh` | +28 | `resolve_agent_type_from_registry()` 공용 헬퍼 신설 (`agent_of_row` 위임) |
| `.agents/skills/lib_py/layout.py` | +20/6 | `_env_int(*names, default=None)` 리팩터 + `main()` `default=60/20` |
| `.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh` | +14/13 | 접미사 case → 공용 헬퍼, usage/헤더 동기화, 소싱 경로 복구 |
| `.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh` | +13/−7 | 동일 폴백 교체 + 헤더 동기화 |
| `.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh` | +1/1 | `lib.sh` 소싱 경로(`2>/dev/null \|\| pwd` 제거) |
| `.agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh` | +1/1 | 헤더 4-에이전트 표준 |
| `.agents/skills/multi-agent-mux-create/scripts/create_session.sh` | +2/2 | 헤더 + 소싱 경로 |
| `.agents/skills/multi-agent-mux-{stop,resume,create}/SKILL.md` | +11/3 | `--agent` 명시 표준(4종), 예제에 `--agent` 부여 |
| `tests/test_layout.py` | +72 | J-1/C-2 4건 + J-2(n=5/max=2) 1건 |
| `tests/test_a4_adapter_contract.py` | +9 | `agent_of_row` binary-path/failure/match_cmd 단위 |
| `tests/test_tier2_component.py` | +67/2 | B-21 stop 폴백 2건 + 문서 펜스/명령단위 가드 1건 |
| `IMPROVEMENTS.md` | +18/4 | B-21 완료, J-1/J-2, 카운트 갱신(완료 30건) |
---
## §2. 목표별 검증
### B-21 — `--agent` 표준화 + 레지스트리 폴백
- `lib.sh:995` `resolve_agent_type_from_registry()``agent_of_row(row, session_name=name)` 에 해석을 전적으로 위임한다. 우선순위 ① `row['agent']` → ② 이름 접미사 → ③ `pane.cmd`(`registry.py:26` 계약과 일치). 실패 시 stdout 미출력 + `sys.exit(1)`.
- `stop_session.sh:106-112``update_yaml_resumed.sh:46-52` 이 접미사 전용 case 블록을 공용 헬퍼로 교체. `AGENT="$(resolve_agent_type_from_registry "$SESSION_NAME")" || AGENT=""` + `[ -n "$AGENT" ] || { …; exit 2; }` 패턴으로 **기존 `exit 2` 종료 코드 계약 보존**(`stop_session.sh` 헤더 :27-30 명시).
- `--agent` 명시 시 유효값 검증(`stop_session.sh:85-90`)은 4종 `claude|agy|hermes|cline` 으로 유지.
- SKILL.md(stop/resume/create) 예제가 모두 `--agent "$AGENT"` 부여, 4-에이전트 표준문구 통일. `deploy/INSTALL.md` 예제도 문서 가드 대상(§6 T5).
- **라이브 사례 해결**: `agy-creator-01`(접미사 없음, `pane.cmd='agy'`)이 이제 `--agent` 생략 시 `agy`로 해석됨(T4 실측).
### J-1 — `_env_int` falsy-zero trap
- `layout.py:175` `def _env_int(*names, default: Optional[int] = None) -> Optional[int]`. `default` 가 명시 파라미터.
- `main()` `layout.py:201-202``or 60`/`or 20` 대신 `_env_int(..., default=60)` / `default=20` 사용 → `MAM_MIN_PANE_COLS=0` 이 0 으로 존중됨(60 으로 뭉개지지 않음).
- `except ValueError: continue`(`layout.py:194-195`) — 무효값을 탈출이 아닌 skip. 이는 C-2 의 전제이기도 하다.
### C-2 — 무효 별칭이 문서화 변수를 가리지 않음
- `MAM_MIN_COLS`(레거시 별칭, `layout.py` 외 사용처 없음)이 `foo` 면 skip → `MAM_MIN_PANE_COLS`(`.mam.env.example` 문서명)이 적용. T1b 실측: `MAM_MIN_COLS=foo MAM_MIN_PANE_COLS=25``direction=right`(= `--min-cols 25`). 전 후보 무효 시 `default` 흡수(Rev.1 불변식 보존).
### J-2 — 헤드리스 n=5 임계값
- `test_layout.py` `test_headless_max_columns_growth_guard``n=5/max=2` 케이스 추가: `n//2 == 2 == max_columns` 이므로 홀수 분기에서 cap 과교정 여부를 판별 가능(현재 `direction=down`, `reason=headless_odd_down`, `is_overflow=False`). `n=3`(`n//2==1`)은 검사에 도달하지 못해 판별 불가 — n=5 선택 정당.
### 회귀 테스트
- `test_layout.py`: 4건(J-1 zero min-cols/min-rows, J-1 동작 중립, C-2 alias skip) + J-2 1건 → 총 23건.
- `test_a4_adapter_contract.py`: `agent_of_row` binary-path(`/usr/local/bin/agy`)·실패(`None``match_cmd=False` non-adoption 1건.
- `test_tier2_component.py`: B-21 stop 폴백(pane.cmd 해석, 명시 agent 필드 우선) 2건 + 문서 가드(펜스 스코프 + 명령 단위) 1건.
### 목표 4 — 전체 PASS
- §6 참조. 변경분 직접 테스트 39건 100% 통과. 사전 존재 herdr/installer 테스트의 환경적 hang 로 인해 단일 full-run 은 불가했으나, 어느 run 에서도 실패 없음.
---
## §3. 로직 감사
1. **`resolve_agent_type_from_registry` 환경 의존성**: `MAM_STATE_JSON="$(load_state_json)"``load_state_json()``lib.sh:938` 에 존재(실측). `from lib_py.agents.registry import agent_of_row` import 는 `lib.sh:25` `export PYTHONPATH="$SKILL_DIR:…"` 로 해결(스크립트가 `source lib.sh` 후 호출하므로 자식 python 에 상속). `herdr_sessions` 키는 `load_state_json` 출력(`lib.sh:959/1025`)과 동일. ✅
2. **`set -euo pipefail` 호환**: `AGENT="$(…)" \|\| AGENT=""` 은 OR-list 이므로 `set -e` 가 비동작. 실패 시 helper 가 출력 없이 exit 1 → `AGENT=""` 확정 후 `[ -n ] \|\| exit 2`. 정확. ✅
3. **소싱 경로 복구**: `stop_session.sh:37`, `create_session.sh:23`, `resume_session.sh``cd "$_script_dir/../.." && pwd` (사장된 `2>/dev/null \|\| pwd` 제거). `cd` 실패 시 `set -e` 로 즉시 종료 → 잘못된 `lib.sh` 경로로 넘어가지 않음(안전 강화). ✅
4. **`_env_int` `default` 위치 인자 위험**: 호출처가 모두 `default=` 키워드로 전달(`layout.py:201-203`) → 가변 `*names` 와 충돌 없음. ✅
---
## §4. 린트 / 정적 검사
| 검사 | 명령 | 결과 |
|---|---|---|
| bash 구문 | `bash -n` on lib.sh, stop_session.sh, update_yaml_resumed.sh, resume_session.sh, resolve_session_id.sh, create_session.sh | **6/6 OK** |
| python 컴파일 | `python -m py_compile lib_py/layout.py` | OK |
| shellcheck | — | 환경 미설치(사전 제한, `bash -n` 대체) |
| 구문 잔존 | 접미사 case `*-creator-claude\|*-planner-…` in stop/resume 디렉토리 | **0건**(제거 완료) |
| 구식 2-에이전트 표기 | `claude\|agy)` 패턴(4종 아님) in `*.sh`/`*.md` | **0건** |
---
## §5. 유실 / 일관성 / orphan
- **제거 심볼 orphan**: 접미사 case 블록 제거 후 남는 참조 없음(grep 실측). `agent_of_row` 는 신규 헬퍼가 사용. ✅
- **문서-스크립트 일치**: SKILL.md 예제의 `--agent` 부여가 `test_comp_docs_stop_examples_pass_agent`(펜스+명령단위) 가드로 집행. `INSTALL.md`(floor=2), `stop/SKILL.md`(floor=3) 최소 예제 수 하한으로 무력화 방지. ✅
- **종료 코드 계약**: `stop_session.sh`/`update_yaml_resumed.sh` `exit 2` 유지. `test_tier1_unit.py:142`, `test_tier3_integration.py:398`(무수정 PASS 계약)은 본 변경분이 미접촉. ✅
- **의도치 않은 수정**: `lib.sh` 외부 동작 변경 없음(헬퍼 신규 추가만). ✅
---
## §6. 테스트 결과
### 6.1 변경분 직접 회귀 (clean, 단독 run)
```
tests/test_layout.py + tests/test_a4_adapter_contract.py → 36 passed in 1.22s
tests/test_tier2_component.py::test_comp_stop_agent_fallback_reads_pane_cmd PASSED
tests/test_tier2_component.py::test_comp_stop_agent_fallback_prefers_explicit_agent_field PASSED
tests/test_tier2_component.py::test_comp_docs_stop_examples_pass_agent PASSED
→ 3 passed in 2.23s
```
변경분 직접 회귀 **39건 100% 통과**.
### 6.2 광역 스위트 (라이브 오케스트레이터 환경)
- verbose run(`--ignore=tier2/3/4`, `-v`): **69 PASSED, 0 FAILED, 0 ERROR**`test_deploy_freshness::test_d10_customization_survives_repeated_refresh`(70번째, 사전 존재 deploy/installer 테스트)에서 hang. 본 변경분 미접촉.
- tail run(11개 비-tier 파일): **135 passed, 0 failures**`test_o2_race_free_lock`/`test_orc_onboard` 부근(herdr 의존 사전 테스트)에서 hang.
- 요약: **어느 run 에서도 `F`/`E` 없음**; 130+ 건 통과 후 환경적 hang. 사전 존재 `reconcile.sh --subscribe --idle-timeout 0` 데몬이 라이브 herdr(2702)과 NATS/자원 충돌.
### 6.3 환경적 제약(비-블로킹, 본 변경분 무관)
본 리뷰는 loop-active 오케스트레이션 환경에서 수행되어 라이브 `herdr --session multi-agent-mux server`(pid 2702)가 활성. 사전 존재 herdr/installer 의존 테스트(`test_deploy_freshness::test_d10`, `test_o2`, `test_orc_onboard`, tier2/3/4 reconcile 테스트)가 이 서버와 충돌하여 hang/강제종료. 이들은 **변경분이 건드리지 않는 테스트**이며 실패(단정 위반)가 아닌 환경적 hang. 동형 변경분에 대한 선행 리뷰(예: job `6f18ba0f`, `79ff98ed`)는 clean 환경에서 전체 100% PASS 를 보고함. 재현은 라이브 오케스트레이터 비활성 환경에서 권장.
> 관찰: 사전 존재 테스트 인프라 개선 후보 — `reconcile.sh --idle-timeout 0` 데몬이 run 강제종료 시 orphan 로 잔존(본 리뷰 중 21건 수거). 테스트 fixture teardown 강화 또는 유한 idle-timeout 기본값이 향후 환경 안정성에 기여. **본 변경분 책임 아님.**
---
## §7. 총평
4개 목표가 정확·완전하게 구현되었고, 변경분 직접 회귀 39건이 100% 통과하며, 린트/orphan/일관성 검사가 모두 clean 하다. 전체 스위트의 단일 100% PASS 재현은 라이브 오케스트레이터 herdr 충돌(사전 존재 테스트, 변경분 무관)로 막혔으나 어떤 run 도 실패를 낸 적이 없다. 설계 재작업 수준의 재계획은 불필요 — 모든 발견은 비-블로킹 관찰 또는 환경 제약이다.
[VERDICT: PASS]
+28
View File
@@ -979,6 +979,34 @@ print(json.dumps(d, ensure_ascii=False))
PYEOF
}
# resolve_agent_type_from_registry <session_name>
#
# 레지스트리(YAML/DB)에 기록된 사실로 에이전트 종류를 해석한다. 우선순위는
# lib_py.agents.registry.agent_of_row 의 계약을 그대로 따른다:
# ① row['agent'] 명시 필드
# ② 세션명 접미사 (*-{creator,planner,reviewer}-<agent> 및 *-<agent>)
# ③ pane.cmd (정확히 일치하거나 .../<agent> 바이너리 경로)
# 성공하면 에이전트명을 stdout 에 출력하고 0 을, 셋 다 실패하면 아무것도
# 출력하지 않고 1 을 반환한다. 오류 메시지는 호출자가 소유한다 — 각 스크립트가
# 문서화한 종료 코드를 그대로 유지하기 위해서다.
#
# NOTE: agent_of_row 의 match_cmd=True 는 "비-입양 조회" 계약이다. reconcile.sh
# 입양 루프는 이 헬퍼를 쓰면 안 된다 (3aee63cf §1.2 실측 반증).
resolve_agent_type_from_registry() {
local name="$1"
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$name" python3 -c "
import os, json, sys
from lib_py.agents.registry import agent_of_row
name = os.environ['SESSION_NAME']
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
row = next((s for s in d.get('herdr_sessions', []) if s.get('name') == name), {})
resolved = agent_of_row(row, session_name=name)
if not resolved:
sys.exit(1)
print(resolved)
"
}
# Despite the name (kept for caller compatibility — resume/stop/update_yaml_resumed
# all do `HERDR_SESSION_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"`), this
# returns the isolated herdr *session* name to use for this MAM session row, not
+18 -8
View File
@@ -172,24 +172,34 @@ def compute_2xk_layout(
return LayoutDecision(target_pane_id=rightmost_top_pane.pane_id, direction="right", reason="new_column_right")
def _env_int(*names: str) -> Optional[int]:
"""First non-empty env var among *names, parsed as int. Bad values are
ignored rather than raised: a typo in an operator's shell must not take the
whole layout call down (lib.sh would silently fall back to 'right')."""
def _env_int(*names: str, default: Optional[int] = None) -> Optional[int]:
"""First *valid* int among the env vars in *names*, else `default`.
`default` is an explicit parameter rather than an `or` at the call site so a
legitimate 0 survives (MAM_MIN_PANE_COLS=0 means 0, not the 60 default).
An unparsable value is skipped rather than raised or treated as terminal: a
typo in an operator's shell must not take the whole layout call down (lib.sh
would silently fall back to 'right'), and must not shadow a later candidate
that IS set correctly -- MAM_MIN_COLS is a legacy alias while
MAM_MIN_PANE_COLS is the name .mam.env.example documents, so aborting on the
first bad value would discard the documented setting. Empty values already
fell through; this makes invalid values behave the same way.
"""
for n in names:
raw = os.environ.get(n, "").strip()
if raw:
try:
return int(raw)
except ValueError:
return None
return None
continue
return default
def main():
parser = argparse.ArgumentParser(description="Compute 2xK grid TUI layout split direction")
parser.add_argument("--min-cols", type=int, default=_env_int("MAM_MIN_COLS", "MAM_MIN_PANE_COLS") or 60)
parser.add_argument("--min-rows", type=int, default=_env_int("MAM_MIN_ROWS", "MAM_MIN_PANE_ROWS") or 20)
parser.add_argument("--min-cols", type=int, default=_env_int("MAM_MIN_COLS", "MAM_MIN_PANE_COLS", default=60))
parser.add_argument("--min-rows", type=int, default=_env_int("MAM_MIN_ROWS", "MAM_MIN_PANE_ROWS", default=20))
parser.add_argument("--max-cols", type=int, default=_env_int("MAM_MAX_COLS", "MAM_MAX_PANE_COLS"))
parser.add_argument("--sample-pane", type=str, default=None)
parser.add_argument("--json", action="store_true", help="Output full JSON decision")
@@ -143,7 +143,7 @@ herdr_sessions:
```bash
WORKSPACE=/path/to/project
AGENT=claude # or agy
AGENT=claude # claude | agy | hermes | cline — always pass it explicitly
source .agents/skills/lib.sh
SESSION_NAME="$(derive_session_name "$WORKSPACE" "$AGENT")"
@@ -168,7 +168,7 @@ case "$AGENT" in
agy)
herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "agy --dangerously-skip-permissions"
;;
*) echo "ERROR: --agent must be claude or agy, got: $AGENT"; exit 2 ;;
*) echo "ERROR: --agent must be claude, agy, hermes or cline, got: $AGENT"; exit 2 ;;
esac
# 3. Wait for agent TUI to be ready (varies: claude ~5s, agy ~3s)
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# create_session.sh — multi-agent-mux-create 의 부속 스크립트
# Usage:
# bash create_session.sh --workspace <path> --agent <claude|agy> --role <role> [--session <name>] [--wrapper]
# bash create_session.sh --workspace <path> --agent <claude|agy|hermes|cline> --role <role> [--session <name>] [--wrapper]
#
# 동작:
# 1) preflight: herdr/claude/agy 가용성, workspace 존재
@@ -20,7 +20,7 @@
set -euo pipefail
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_lib_sh="$(cd "$_script_dir/../.." 2>/dev/null || pwd)/lib.sh"
_lib_sh="$(cd "$_script_dir/../.." && pwd)/lib.sh"
[ -f "$_lib_sh" ] || _lib_sh="${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh"
source "$_lib_sh"
@@ -58,7 +58,7 @@ If both are empty → the workspace has no conversation yet. Fall back to `multi
```bash
WORKSPACE=/path/to/project
AGENT=claude # or agy or hermes
AGENT=claude # claude | agy | hermes | cline — pass it explicitly
SESSION_NAME=<workspace>-creator-<agent> # same convention as multi-agent-mux-create
# Resolve the isolated herdr server name & load common utils
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# resolve_session_id.sh — multi-agent-mux-resume 의 부속 스크립트
# Usage:
# bash resolve_session_id.sh --workspace <path> --agent <claude|agy>
# bash resolve_session_id.sh --workspace <path> --agent <claude|agy|hermes|cline>
# 출력: stdout 으로 UUID 한 줄 (없으면 빈 줄 + exit 0)
#
# P0-C: 전역 agent_identities 를 즉시 반환하지 않는다. lib.sh::find_workspace_uuid
@@ -3,7 +3,7 @@
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB_SH="$(cd "$SCRIPT_DIR/../.." 2>/dev/null || pwd)/lib.sh"
LIB_SH="$(cd "$SCRIPT_DIR/../.." && pwd)/lib.sh"
[ -f "$LIB_SH" ] || LIB_SH="${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh"
source "$LIB_SH"
@@ -4,14 +4,14 @@
# resume UUID 를 per-row own id (claude_session_id_own / agy_conversation_id_own)
# 에 박는다 — agent_identities 전역은 더 이상 primary 아님 (cache 로 강등, P0-C/단계 e).
#
# Usage: bash update_yaml_resumed.sh --session <name> --uuid <id> [--agent claude|agy]
# Usage: bash update_yaml_resumed.sh --session <name> --uuid <id> [--agent claude|agy|hermes|cline]
set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
usage() {
cat <<EOF
Usage: $0 --session <name> --uuid <id> [--agent claude|agy]
Usage: $0 --session <name> --uuid <id> [--agent claude|agy|hermes|cline]
EOF
}
@@ -40,15 +40,15 @@ done
HERDR_SESSION_NAME="$(resolve_herdr_session "$SESSION_NAME" "${WORKSPACE:-}")"
export HERDR_SESSION_NAME
# --agent 미지정 시 이름 suffix 로 fallback (P1-F: 가능하면 --agent 명시)
# --agent 미지정 시 레지스트리 기록으로 해석 (B-21).
# ① row['agent'] → ② 세션명 접미사 → ③ pane.cmd 순. 셋 다 실패하면
# 종전과 동일하게 exit 2 (헤더 :27-30 의 종료 코드 계약 유지).
if [ -z "$AGENT" ]; then
case "$SESSION_NAME" in
*-creator-claude|*-planner-claude|*-reviewer-claude) AGENT=claude ;;
*-creator-agy|*-planner-agy|*-reviewer-agy) AGENT=agy ;;
*-creator-hermes|*-planner-hermes|*-reviewer-hermes) AGENT=hermes ;;
*-creator-cline|*-planner-cline|*-reviewer-cline) AGENT=cline ;;
*) echo "ERROR: cannot infer agent from '$SESSION_NAME'; pass --agent" >&2; exit 2 ;;
esac
AGENT="$(resolve_agent_type_from_registry "$SESSION_NAME")" || AGENT=""
[ -n "$AGENT" ] || {
echo "ERROR: cannot infer agent from '$SESSION_NAME'; pass --agent" >&2
exit 2
}
fi
if [ -z "$ROLE" ]; then
+11 -3
View File
@@ -37,6 +37,7 @@ The stop command is always **graceful by default**:
```bash
SESSION_NAME=<workspace>-creator-<agent> # convention
AGENT=claude # claude | agy | hermes | cline — always pass it
AGENT_SESSIONS_YAML=.mam/agent-sessions.yaml
# 1) Session is registered?
@@ -66,20 +67,27 @@ fi
```bash
# 1. Stop gracefully (default — captures ID, shuts down safely, status=stopped)
bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
--session "$SESSION_NAME"
--session "$SESSION_NAME" --agent "$AGENT"
# 2. Stop gracefully + record a custom stop reason
bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
--session "$SESSION_NAME" --reason api_error
--session "$SESSION_NAME" --agent "$AGENT" --reason api_error
# 3. Stop gracefully + clean up on-disk conversation (DANGEROUS)
# — this prevents any future resume (status=terminated, resumable=false).
bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
--session "$SESSION_NAME" --purge-conversation
--session "$SESSION_NAME" --agent "$AGENT" --purge-conversation
```
**Idempotency**: if the row is already `status: stopped`, the script prints `already stopped (...)` and exits 0 — re-running is a safe no-op.
**`--agent` is the standard.** Pass it on every invocation. If omitted, the script
resolves the agent from the registry record — the row's `agent` field, then the
session-name suffix, then `pane.cmd` — and exits 2 if none of the three resolve.
The fallback exists for recovery, not as the normal calling convention: a session
whose name carries no agent suffix (e.g. `agy-creator-01`) is only resolvable
while its registry row survives.
### State machine
```
@@ -13,7 +13,9 @@
# 옵션:
# --session <name> — 대상 세션 (필수)
# --agent <type> — claude | agy | hermes | cline
# (미지정 시 세션명 접미사로 추론; 추론 실패 시 exit 2)
# (권장: 항상 명시. 미지정 시 레지스트리 기록으로
# 해석 — agent 필드 → 세션명 접미사 → pane.cmd;
# 셋 다 실패하면 exit 2)
# --reason <reason> — 상태 전이 사유 (stop_reason). 기본값 manual_stop
# --purge-conversation — 디스크의 conversation artifact 까지 삭제.
# status=terminated, resumable=false 로 전이하며
@@ -32,7 +34,7 @@ set -euo pipefail
# shellcheck disable=SC1091
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_lib_sh="$(cd "$_script_dir/../.." 2>/dev/null || pwd)/lib.sh"
_lib_sh="$(cd "$_script_dir/../.." && pwd)/lib.sh"
[ -f "$_lib_sh" ] || _lib_sh="${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh"
source "$_lib_sh"
@@ -43,8 +45,9 @@ Usage: $0 --session <name> [--agent claude|agy|hermes|cline] [--reason <reason>]
Arguments:
--session <name> — target session name (required)
--agent <type> — claude | agy | hermes | cline
(inferred from the session-name suffix when omitted)
--agent <type> — claude | agy | hermes | cline (recommended: always pass it)
(falls back to the registry record: agent field ->
session-name suffix -> pane.cmd)
--reason <reason> — stop_reason field (default: manual_stop)
--purge-conversation — also delete on-disk conversation artifacts;
status becomes terminated and resume is impossible
@@ -97,15 +100,15 @@ fi
HERDR_SESSION_NAME="$(resolve_herdr_workspace "$SESSION_NAME" "${WORKSPACE:-$WORKSPACE_ROOT}")"
export HERDR_SESSION_NAME
# --agent 미지정 시 이름 suffix 로 fallback (P1-F)
# --agent 미지정 시 레지스트리 기록으로 해석 (B-21).
# ① row['agent'] → ② 세션명 접미사 → ③ pane.cmd 순. 셋 다 실패하면
# 종전과 동일하게 exit 2 (헤더 :27-30 의 종료 코드 계약 유지).
if [ -z "$AGENT" ]; then
case "$SESSION_NAME" in
*-creator-claude|*-planner-claude|*-reviewer-claude) AGENT=claude ;;
*-creator-agy|*-planner-agy|*-reviewer-agy) AGENT=agy ;;
*-creator-hermes|*-planner-hermes|*-reviewer-hermes) AGENT=hermes ;;
*-creator-cline|*-planner-cline|*-reviewer-cline) AGENT=cline ;;
*) echo "ERROR: cannot infer agent from '$SESSION_NAME'; pass --agent" >&2; exit 2 ;;
esac
AGENT="$(resolve_agent_type_from_registry "$SESSION_NAME")" || AGENT=""
[ -n "$AGENT" ] || {
echo "ERROR: cannot infer agent from '$SESSION_NAME'; pass --agent" >&2
exit 2
}
fi
# 세션이 YAML 에 있는지 + 해당 row 의 워크스페이스 cwd 및 delegate_job_id 추출.
+19 -5
View File
@@ -1,9 +1,9 @@
# 🛠️ Multi-Agent Mux 종합 개선 및 미해결 과제 백로그 (`IMPROVEMENTS.md`)
- **최종 갱신일**: 2026-08-23 (`nats-docker` 서브모듈 분리, B-20 2×K 그리드 TUI 레이아웃 엔진 lib_py/layout.py 공용화 및 lib.sh 인라인 정리 완료)
- **최종 갱신일**: 2026-08-24 (`nats-docker` 서브모듈 분리, B-20 2×K 그리드 TUI 레이아웃 엔진, J-1/J-2 레이아웃 환경변수/임계값 보강, B-21 `--agent` 표준화 및 레지스트리 agent_of_row 폴백 통합 완료)
- **통합 관리 대상**: 기존 `CODEBASE_REVIEW_REPORT.md` + `OPTIMIZATION.md` + `NATS_REPORT.md`
- **총 추적 미해결 과제**: **5건** (아키텍처 1건: `A-2`, 엣지케이스 및 가용성 3건: `B-16`, `B-17`, `B-18`, 오케스트레이션 1건: `O-5`)
- **완료된 과제**: **29** (A-1, A-3, A-4, A-5, B-1, B-3, B-4, B-5, B-7, B-8, B-9, B-10, B-13, B-14, B-15, B-19, B-20, C-1, C-2, C-3b, C-6, O-1, O-2, O-3, O-4-OrcOnboard, O-6, Herdr-0.8.0-Compat-SanitizeHash, P2-1-DelegateJobSafe-TrapFix, P2-2-C3a-C4-LegacyCleanup)
- **완료된 과제**: **30** (A-1, A-3, A-4, A-5, B-1, B-3, B-4, B-5, B-7, B-8, B-9, B-10, B-13, B-14, B-15, B-19, B-20, B-21, C-1, C-2, C-3b, C-6, O-1, O-2, O-3, O-4-OrcOnboard, O-6, Herdr-0.8.0-Compat-SanitizeHash, P2-1-DelegateJobSafe-TrapFix, P2-2-C3a-C4-LegacyCleanup)
---
@@ -25,7 +25,21 @@
---
## 2. 🟠 엣지 케이스 및 런타임 버그 (Edge-case Bugs — 5건 / 완료 4건)
## 2. 🟠 엣지 케이스 및 런타임 버그 (Edge-case Bugs — 6건 / 완료 5건)
### **B-21 (✅ 완료 — `--agent` 플래그 표준화 및 `stop_session.sh`/`update_yaml_resumed.sh` 레지스트리 `agent_of_row` 폴백 통합)**
- **현상**:
- `stop_session.sh``update_yaml_resumed.sh``--agent` 생략 시 세션명 접미사 regex에만 의존하여, 라이브 세션인 `agy-creator-01` 등 유효하게 실행 중인 세션이 `exit 2`로 거부되던 결함.
- 에이전트 해석기가 4중화(`registry.py`, `stop_session.sh`, `update_yaml_resumed.sh`, `run_loop.sh`)되어 일관성이 결여됨.
- 가이드 문서(SKILL.md) 예제 및 스크립트 헤더에서 `--agent` 전달이 누락되거나 에이전트 타입(4종: `claude|agy|hermes|cline`)이 불일치함.
- **조치 결과 (완료)**:
- `lib.sh``resolve_agent_type_from_registry()` 공용 헬퍼 신설: `agent_of_row` 우선순위(① `row['agent']` → ② 이름 접미사 → ③ `pane.cmd`)를 엄격히 준수하여 레지스트리 기반 해석 지원.
- `stop_session.sh``update_yaml_resumed.sh`의 접미사 전용 case 블록을 공용 헬퍼로 교체하고, 미해석 시 기존 `exit 2` 계약 및 헤더/usage 문서 동기화.
- `stop_session.sh`, `create_session.sh`, `resume_session.sh`의 사장된 `lib.sh` 소싱 경로(`cd ... 2>/dev/null || pwd`) 복구.
- `lib_py/layout.py`: `_env_int(*names, default=None)` 헬퍼로 리팩터하여 `MAM_MIN_PANE_COLS=0` 등 falsy-zero 버그(J-1)를 해결하고, 잘못된 별칭 입력 시 후속 유효 환경변수로 fallback 하도록 `continue` 처리(C-2).
- `multi-agent-mux-stop`, `multi-agent-mux-resume`, `multi-agent-mux-create`의 SKILL.md 및 스크립트 헤더를 4개 에이전트 명시 표준으로 동기화.
- **회귀 가드**:
- `tests/test_layout.py` (J-1 zero min-cols/min-rows 및 C-2 무효값 fallback 테스트 4건, J-2 n=5 임계값 보강 1건), `tests/test_a4_adapter_contract.py` (T3 1건), `tests/test_tier2_component.py` (T4 fallback/priority 2건, T5 펜스+명령 단위 문서 가드 1건).
### **B-20 (✅ 완료 — 2×K 그리드 TUI 레이아웃 엔진 `lib_py/layout.py` 공용화 및 `lib.sh` 인라인 레거시 정리)**
- **현상**:
@@ -34,8 +48,8 @@
- **조치 결과 (완료)**:
- `.agents/skills/lib_py/layout.py` 공용 엔진 신설: 오른쪽 확장 2×K 그리드 알고리즘, 해상도 오버플로 가드(`min_cols=60`, `min_rows=20`), 헤드리스 0×0 결정론적 분할 지원.
- `lib.sh`: 인라인 Python 스니펫을 `python3 -m lib_py.layout` 단일 호출로 교체하고 레거시 변수/주석 정리.
- 후속 정리 (I-2/I-3/C-1): `PaneInfo.focused` 미사용 필드 정리, `MAM_MAX_PANE_COLS`/`MAM_MAX_COLS` env 배선 완료, 헤드리스 모드에서 `max_columns`를 우회하던 결함(C-1)을 교정하여 GUI와 동일한 `max_columns_reached` 성장 가드 적용. `test_bug4_headless_unobservable_fast_path`에 5.0초 상한 시간 단언을 계약으로 고정.
- 회귀 가드: `tests/test_layout.py` (19개 테스트 100% 통과), `tests/test_b19_headless_reconcile_fixes.py` (6개 테스트 100% 통과).
- 후속 정리 (I-2/I-3/C-1/J-1/J-2): `PaneInfo.focused` 미사용 필드 정리, `MAM_MAX_PANE_COLS`/`MAM_MAX_COLS` env 배선 완료, 헤드리스 모드에서 `max_columns`를 우회하던 결함(C-1)을 교정하여 GUI와 동일한 `max_columns_reached` 성장 가드 적용. `test_bug4_headless_unobservable_fast_path`에 5.0초 상한 시간 단언을 계약으로 고정. `_env_int`의 falsy-zero trap(J-1) 및 무효 별칭 skip(C-2) 해소, 헤드리스 n=5 홀수 임계값 검증(J-2).
- 회귀 가드: `tests/test_layout.py` (23개 테스트 100% 통과), `tests/test_b19_headless_reconcile_fixes.py` (6개 테스트 100% 통과).
### **B-19 (✅ 완료 — 헤드리스 분할 레이아웃 0×0 예외 처리, reconcile SKILLS_DIR 누락 및 Fast-path 게이팅 보완)**
- **현상**:
+9
View File
@@ -46,6 +46,15 @@ def test_agent_of_row_priority():
row3 = {'pane': {'cmd': 'cline'}}
assert agent_of_row(row3) == 'cline'
def test_agent_of_row_pane_cmd_binary_path_and_failure():
# pane.cmd 가 절대 경로 형태여도 해석된다
assert agent_of_row({'pane': {'cmd': '/usr/local/bin/agy'}}) == 'agy'
# 세 경로 모두 실패하면 None — 호출자가 오류를 소유한다
assert agent_of_row({}, session_name='bad-session-name') is None
# 입양 조회용 match_cmd=False 에서는 pane.cmd 를 보지 않는다
assert agent_of_row({'name': 'agy-creator-01', 'pane': {'cmd': 'agy'}},
match_cmd=False) is None
def test_adapter_required_properties():
from lib_py.agents.base import BaseAgentAdapter
base = BaseAgentAdapter()
+72
View File
@@ -403,7 +403,79 @@ def test_headless_max_columns_growth_guard():
d3 = compute_2xk_layout(headless(3), max_columns=2)
assert d3.direction == "down" and not d3.is_overflow
# n=5 is the first odd n that can discriminate: n//2 == 2 == max_columns, so an
# over-correction that also checked the cap on the odd branch would return
# overflow here. n=3 has n//2 == 1 and cannot reach the check at all.
d5 = compute_2xk_layout(headless(5), max_columns=2)
assert d5.direction == "down" and not d5.is_overflow
assert d5.reason == "headless_odd_down"
# When max_columns is not set, existing alternation is preserved (behavior neutrality)
assert compute_2xk_layout(headless(4)).direction == "right"
_LAYOUT_ENV_VARS = ("MAM_MIN_COLS", "MAM_MIN_PANE_COLS", "MAM_MIN_ROWS",
"MAM_MIN_PANE_ROWS", "MAM_MAX_COLS", "MAM_MAX_PANE_COLS")
def _run_layout(payload, args=(), env_extra=None):
env = {**os.environ, "PYTHONPATH": os.path.abspath(".agents/skills")}
for k in _LAYOUT_ENV_VARS:
env.pop(k, None) # 호출자 셸의 오염 차단
env.update(env_extra or {})
res = subprocess.run([sys.executable, "-m", "lib_py.layout", "--json", *args],
input=json.dumps(payload), capture_output=True, text=True, env=env)
assert res.returncode == 0, res.stderr
return json.loads(res.stdout)
# height//2 = 15 < min_rows(20) 로 제약 분기 진입, width//2 = 25 가 min_cols 와 비교됨.
_ZERO_TRAP = {"result": {"panes": [
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 50, "height": 30}}]}}
def test_j1_env_zero_min_cols_matches_flag_zero():
"""J-1: MAM_MIN_PANE_COLS=0 must mean 0, not fall through to the 60 default."""
flag = _run_layout(_ZERO_TRAP, ("--min-cols", "0"))
assert flag["direction"] == "right" and flag["reason"] == "single_pane_height_constrained"
for var in ("MAM_MIN_COLS", "MAM_MIN_PANE_COLS"):
assert _run_layout(_ZERO_TRAP, (), {var: "0"}) == flag, var
def test_j1_env_zero_min_rows_matches_flag_zero():
flag = _run_layout(_ZERO_TRAP, ("--min-rows", "0"))
assert flag["direction"] == "down" and flag["reason"] == "single_pane_split_down"
for var in ("MAM_MIN_ROWS", "MAM_MIN_PANE_ROWS"):
assert _run_layout(_ZERO_TRAP, (), {var: "0"}) == flag, var
def test_j1_nonzero_and_malformed_env_behaviour_unchanged():
"""Behaviour neutrality: non-zero env still applies, and a lone typo still
lands on the documented default instead of crashing on a None comparison."""
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_PANE_COLS": "25"}) == \
_run_layout(_ZERO_TRAP, ("--min-cols", "25"))
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_PANE_COLS": "abc"}) == _run_layout(_ZERO_TRAP)
def test_j1b_invalid_alias_does_not_shadow_the_documented_var():
"""C-2: MAM_MIN_COLS is a legacy alias checked first; MAM_MIN_PANE_COLS is the
name .mam.env.example documents. An unparsable value in the alias must be
skipped, not abort the search and discard the documented setting.
Empty values already fell through (`if raw:`); this makes invalid values
behave the same way. When every candidate is unusable, `default` still wins.
"""
good = _run_layout(_ZERO_TRAP, (), {"MAM_MIN_PANE_COLS": "25"})
assert good["direction"] == "right"
# 별칭이 깨져 있어도 문서화된 변수가 적용된다
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_COLS": "foo",
"MAM_MIN_PANE_COLS": "25"}) == good
# 0 도 마찬가지 (J-1 과의 상호작용)
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_COLS": "foo",
"MAM_MIN_PANE_COLS": "0"}) == \
_run_layout(_ZERO_TRAP, ("--min-cols", "0"))
# 모든 후보가 무효면 문서화된 기본값으로 흡수 (Rev.1 불변식 보존)
assert _run_layout(_ZERO_TRAP, (), {"MAM_MIN_COLS": "foo",
"MAM_MIN_PANE_COLS": "bar"}) == _run_layout(_ZERO_TRAP)
+66 -1
View File
@@ -1,4 +1,5 @@
import os
import re
import shutil
import json
import sqlite3
@@ -293,7 +294,7 @@ d['herdr_sessions'] = [{
# ==============================================================================
# FEATURE 3: Stop Session (4 Test Cases)
# FEATURE 3: Stop Session (7 Test Cases)
# ==============================================================================
def test_comp_stop_sqlite_state_update(mam_sandbox):
@@ -411,6 +412,70 @@ d['herdr_sessions'] = [{
assert any(("send" in call or "prompt" in call) and "/exit" in call for call in calls)
def test_comp_stop_agent_fallback_reads_pane_cmd(mam_sandbox):
"""B-21: --agent 생략 시 세션명에 에이전트 접미사가 없어도 레지스트리 행의
pane.cmd 로 해석된다 (라이브 `agy-creator-01` 형태)."""
mutation = """
d['herdr_sessions'] = [{
'name': 'agy-creator-01',
'status': 'running',
'pane': {'cwd': 'WS_PLACEHOLDER', 'cmd': 'agy'}
}]
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
run_mutation(mam_sandbox, mutation)
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
res = subprocess.run(["bash", str(script), "--session", "agy-creator-01"],
capture_output=True, text=True)
assert res.returncode == 0, res.stderr
assert re.search(r"^\s*agent:\s+agy\s*$", res.stdout, re.M), res.stdout
def test_comp_stop_agent_fallback_prefers_explicit_agent_field(mam_sandbox):
"""우선순위 계약: 명시 `agent` 필드가 세션명 접미사와 pane.cmd 를 모두 이긴다."""
mutation = """
d['herdr_sessions'] = [{
'name': 'x-creator-claude',
'status': 'running',
'agent': 'hermes',
'pane': {'cwd': 'WS_PLACEHOLDER', 'cmd': 'claude'}
}]
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
run_mutation(mam_sandbox, mutation)
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
res = subprocess.run(["bash", str(script), "--session", "x-creator-claude"],
capture_output=True, text=True)
assert res.returncode == 0, res.stderr
assert re.search(r"^\s*agent:\s+hermes\s*$", res.stdout, re.M), res.stdout
# 코드 펜스 안의 stop_session.sh 호출을 '명령 단위'로 잘라낸다.
# - 펜스 스코프: 산문 속 `stop_session.sh` 언급을 명령으로 오인하지 않는다
# (Pitfalls / When-NOT-to-use 절은 성격상 스크립트를 산문으로 언급한다).
# - 명령 단위: 한 펜스에 여러 호출이 들어 있어도 각각을 따로 검증한다
# (블록 단위로 보면 그중 하나만 --agent 를 가져도 통과해 버린다).
_FENCE_RE = re.compile(r"```(?:bash|sh)\n(.*?)```", re.S)
_STOP_CALL_RE = re.compile(r"(?:bash\s+)?\S*stop_session\.sh[^\n\\]*(?:\\\n[^\n\\]*)*")
def test_comp_docs_stop_examples_pass_agent():
"""B-21 문서 계약: 문서의 모든 stop_session.sh 예제는 --agent 를 넘긴다.
문서 변경은 뮤테이션 감도가 없으므로 이 가드가 표준의 유일한 집행 장치다."""
repo = Path(__file__).resolve().parent.parent
expected = { # 문서별 최소 예제 수 — 예제를 지워 가드를 무력화하는 것을 막는다
repo / ".agents/skills/multi-agent-mux-stop/SKILL.md": 3,
repo / "deploy/INSTALL.md": 2,
}
for doc, floor in expected.items():
seen = 0
for block in _FENCE_RE.findall(doc.read_text()):
for m in _STOP_CALL_RE.finditer(block):
snippet = m.group(0)
seen += 1
assert "--agent" in snippet, \
f"{doc.name}: stop_session.sh example without --agent:\n{snippet}"
assert seen >= floor, f"{doc.name}: expected >= {floor} examples, saw {seen}"
# ==============================================================================
# FEATURE 4: Status Query (5 Test Cases)
# ==============================================================================