- 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.
823 lines
46 KiB
Markdown
823 lines
46 KiB
Markdown
# 📐 구현 계획서 **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 를 신설했습니다.
|