Compare commits
3
Commits
82eecfda24
...
14e306be46
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14e306be46 | ||
|
|
31b2d70ffe | ||
|
|
6e2e9b1161 |
@@ -0,0 +1,489 @@
|
|||||||
|
# 📐 구현 계획서 Rev.2: 백로그 I-2 / I-3 처리 (Job `5e4ef463`)
|
||||||
|
|
||||||
|
- **작성일**: 2026-08-23
|
||||||
|
- **역할**: Planner (`.agents/MULTI_AGENT_RULES.md` §1 — Planner 는 저장소 코드/문서를 **수정하지 않으며**, 산출물은 본 계획서입니다)
|
||||||
|
- **기준 커밋**: `31b2d70`, 작업 트리 clean
|
||||||
|
- **선행 리비전**: `fea5f1b2` (Rev.1) ← 본 문서가 대체합니다
|
||||||
|
- **판정 대상 리뷰**: `2b8e8ef2` (agy, `[VERDICT: PASS WITH CHALLENGE]`) — C-1 헤드리스 `max_columns` 우회 / C-2 미정의 헬퍼
|
||||||
|
- **테스트**: 현재 **330 passed** → 예상 **333** (Rev.1 의 332 에서 C-3 추가)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. 리뷰 판정 (Adjudication of Challenge `2b8e8ef2`)
|
||||||
|
|
||||||
|
### A-0. 판정 요약
|
||||||
|
|
||||||
|
| 챌린지 | 판정 | 근거 |
|
||||||
|
|---|:---:|---|
|
||||||
|
| **C-1** 헤드리스가 `max_columns` 를 우회 | ✅ **전면 수용 — 재현 및 처방 검증 완료** | `max_columns=2` + 헤드리스 N=4·6·8 이 전부 `right` 로 열을 무한 증식(실측). GUI 대조군 N=4 는 `overflow`. 제안된 패치를 프로토타입으로 전 행렬 검증 |
|
||||||
|
| **C-2** `_four_panes_two_columns()` 미정의 | ✅ **수용 — 같은 종류의 오류가 하나 더 있었음** | 지적대로 미정의. 추가로 Rev.1 스니펫의 **`SKILLS_DIR` 도 미정의**였음(파일에 module-level 상수 없음). 제안 헬퍼의 `-> Dict[str, Any]` 힌트는 `typing` import 없이는 **def 시점 NameError**(실측) |
|
||||||
|
| **C-3** 헤드리스 열 상한 가드 신설 | ✅ **수용 — 명칭·문안만 정밀화** | 채택. 다만 "strictly enforced" 는 실제 의미보다 강함 — §A-2 참조 |
|
||||||
|
|
||||||
|
리뷰어가 지적한 두 항목은 모두 실재하며, **C-1 은 Rev.1 이 놓친 구조적 결함**입니다. 아래에서 재현·검증하고, 리뷰어가 다루지 않은 두 가지를 덧붙여 정밀화합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### A-1. C-1 재현 및 처방 검증
|
||||||
|
|
||||||
|
#### (1) 결함 재현
|
||||||
|
|
||||||
|
`max_columns=2` 를 준 상태에서 헤드리스 페인 수를 늘려가며 측정:
|
||||||
|
|
||||||
|
| N (헤드리스) | 현재 동작 | GUI 동등 상황 |
|
||||||
|
|---|---|---|
|
||||||
|
| 2 | `right` (2번째 열 개방) | `right` ✅ 일치 |
|
||||||
|
| 3 | `down` | `down` ✅ 일치 |
|
||||||
|
| **4** | 🔴 **`right`** (3번째 열 개방) | 🟢 **`overflow` / `max_columns_reached`** |
|
||||||
|
| 5 | `down` | `down` (`fill_singleton_column`) ✅ |
|
||||||
|
| **6, 8** | 🔴 **`right`** (열 무한 증식) | `overflow` |
|
||||||
|
|
||||||
|
`is_headless` 분기가 열 그룹핑과 `max_columns` 검사보다 **먼저 return** 하므로 상한이 한 번도 평가되지 않습니다. 리뷰어의 분석이 정확합니다.
|
||||||
|
|
||||||
|
#### (2) 제안 패치 전 행렬 검증
|
||||||
|
|
||||||
|
리뷰어가 제시한 `current_cols = n // 2` + even 분기 검사를 프로토타입으로 구현해 `max_columns` × N 전 조합을 확인:
|
||||||
|
|
||||||
|
```
|
||||||
|
max_columns=None -> N=2:righ N=3:down N=4:righ N=5:down N=6:righ N=7:down N=8:righ
|
||||||
|
max_columns=1 -> N=2:over N=3:down N=4:over N=5:down N=6:over N=7:down N=8:over
|
||||||
|
max_columns=2 -> N=2:righ N=3:down N=4:over N=5:down N=6:over N=7:down N=8:over
|
||||||
|
max_columns=3 -> N=2:righ N=3:down N=4:righ N=5:down N=6:over N=7:down N=8:over
|
||||||
|
```
|
||||||
|
|
||||||
|
- `max_columns=None` 행이 **현행과 완전히 동일** → 기존 `test_headless_0x0_transitions` 가 깨지지 않음이 보장됩니다.
|
||||||
|
- `max_columns=K` 는 정확히 K번째 열까지 허용하고 K+1번째를 열려는 시점에 overflow 합니다.
|
||||||
|
|
||||||
|
처방을 그대로 채택합니다.
|
||||||
|
|
||||||
|
### A-2. 정밀화 ① — `max_columns` 는 **불변식이 아니라 성장 가드**다
|
||||||
|
|
||||||
|
리뷰어는 C-3 테스트를 *"max_columns is strictly enforced"* 로 기술했습니다. 실측된 의미는 조금 다르며, 이 차이가 리뷰어의 "even 분기에서만 검사" 선택이 옳은 **이유**이기도 합니다.
|
||||||
|
|
||||||
|
GUI 모드에서 `max_columns=2` 인데 이미 3번째 열에 외톨이 페인이 있는 5-페인 워크스페이스를 넣으면:
|
||||||
|
|
||||||
|
```
|
||||||
|
5 panes / singleton : direction=down overflow=False reason=fill_singleton_column
|
||||||
|
```
|
||||||
|
|
||||||
|
이미 상한을 넘긴 상태여도 **overflow 를 내지 않고 기존 열을 채웁니다**. 상한은 "새 열을 여는 것"을 막을 뿐, 이미 존재하는 열을 사후에 없앨 수는 없기 때문입니다. 외톨이 페인을 방치하는 것보다 채우는 편이 공간 효율이 낫습니다.
|
||||||
|
|
||||||
|
헤드리스의 홀수 분기(`down`)가 상한을 검사하지 않는 것은 GUI 의 `fill_singleton_column` 과 **정확히 같은 규칙**입니다. 즉 리뷰어의 처방은 임의의 선택이 아니라 **GUI 와의 대칭을 복원**하는 것이며, 이 점을 주석과 테스트 이름에 남겨야 다음 독자가 "홀수는 왜 검사 안 하나"를 다시 묻지 않습니다.
|
||||||
|
|
||||||
|
→ C-3 테스트 이름/독스트링을 `strictly enforced` 대신 **"opening a new column is blocked; filling an existing one is not"** 취지로 기술하도록 §3.2 에 반영했습니다.
|
||||||
|
|
||||||
|
### A-3. 정밀화 ② — `n // 2` 는 측정이 아니라 **추론**이다
|
||||||
|
|
||||||
|
GUI 경로는 페인의 x 좌표로 열을 **셉니다**(ground truth). 헤드리스에는 좌표가 없으므로 `n // 2` 로 **추정**합니다. 두 값은 성격이 다르며, 추정은 교대 불변식(홀수→down, 짝수→right)이 그 워크스페이스를 만들었을 때만 정확합니다.
|
||||||
|
|
||||||
|
페인이 닫혀 형상이 어긋난 경우(예: 2×2 그리드에서 하나가 닫혀 N=3)는 `n // 2 = 1` 로 실제 열 수(2)를 과소평가합니다. 그러나 **홀수는 어차피 `down` 으로 흡수**되고, 다음 짝수 N=4 에서 `n // 2 = 2` 가 되어 **자기 교정**됩니다. 따라서 실사용상 안전하지만, 이 근거를 코드 주석에 남기지 않으면 다음 사람이 "왜 열을 세지 않고 나누기를 하느냐"로 되돌릴 위험이 있습니다. §3.1 구현 사양에 주석 문안을 포함했습니다.
|
||||||
|
|
||||||
|
### A-4. 정밀화 ③ — 영향도 정정, 그러나 **같은 커밋에서 고쳐야 하는 이유**
|
||||||
|
|
||||||
|
리뷰어는 C-1 을 `Critical` 로 분류했습니다. 정확히는 **`--max-cols` 기본값이 `None` 이라 아무도 opt-in 하지 않은 지금은 잠복 상태**이며, 현재 사용자에게 발생 중인 장애가 아닙니다.
|
||||||
|
|
||||||
|
다만 이것이 심각도를 낮추지는 않습니다. **Rev.1 의 I-3b 가 바로 그 opt-in 경로(`MAM_MAX_PANE_COLS`)를 살리는 작업**이기 때문입니다. C-1 을 함께 고치지 않고 I-3b 만 적용하면, 이번 커밋이 **결함을 활성화하는 커밋**이 됩니다. 운영자가 `MAM_MAX_PANE_COLS=2` 를 설정하는 순간 GUI 는 상한을 지키고 헤드리스는 무한히 열을 늘리는 **모드 간 동작 분기**가 생깁니다.
|
||||||
|
|
||||||
|
→ C-1 과 I-3b 는 **분리 불가**하며, §6 실행 순서에서 같은 단계로 묶었습니다.
|
||||||
|
|
||||||
|
### A-5. C-2 수용 — 그리고 같은 종류의 오류가 하나 더 있었다
|
||||||
|
|
||||||
|
리뷰어 지적대로 `_four_panes_two_columns()` 는 어디에도 없습니다. 여기에 Rev.1 스니펫의 결함 두 가지를 스스로 덧붙입니다.
|
||||||
|
|
||||||
|
1. **`SKILLS_DIR` 도 미정의였습니다.** `tests/test_layout.py` 에는 module-level 상수가 하나도 없고, 기존 `test_cli_invocation_pipe` 는 테스트 내부에서 `skills_dir = os.path.abspath(".agents/skills")` 를 만들어 씁니다. Rev.1 스니펫은 정의되지 않은 두 이름에 의존했습니다.
|
||||||
|
2. **리뷰어가 제안한 헬퍼 시그니처도 그대로는 깨집니다.** `def _four_panes_two_columns() -> Dict[str, Any]:` 는 `typing` import 없이는 **정의 시점에** 터집니다.
|
||||||
|
```
|
||||||
|
$ python -c "exec('def f() -> Dict[str, Any]:\n return {}\n')"
|
||||||
|
NameError at def time: name 'Dict' is not defined
|
||||||
|
```
|
||||||
|
`tests/test_layout.py` 는 `typing` 을 import 하지 않으므로, 타입 힌트를 빼거나 import 를 추가해야 합니다. §3.2 는 힌트를 빼는 쪽을 택했습니다(파일 어디에도 타입 힌트를 쓰지 않는 관례와 일치).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. Rev.1 → Rev.2 변경 요약
|
||||||
|
|
||||||
|
| # | 변경 | 출처 |
|
||||||
|
|---|---|---|
|
||||||
|
| C-1 | **`compute_2xk_layout` 헤드리스 분기에 `max_columns` 검사 추가** — I-3b 와 동일 단계로 묶음 | 챌린지 C-1 + A-4 |
|
||||||
|
| C-2 | 헤드리스 상한 추론 근거(`n // 2`)와 GUI 대칭성을 **코드 주석으로 명문화** | A-2 / A-3 |
|
||||||
|
| C-3 | 신규 테스트 스니펫에서 **`_four_panes_two_columns()` 와 `skills_dir` 을 실제로 정의**, 타입 힌트 제거 | 챌린지 C-2 + A-5 |
|
||||||
|
| C-4 | **`test_headless_max_columns_growth_guard` 신설** (C-3 채택, 명칭·독스트링 정밀화) → 332 → **333** | 챌린지 C-3 + A-2 |
|
||||||
|
| C-5 | 뮤테이션 수용 기준에 **헤드리스 상한 2종** 추가 (6종 → 8종) | C-1 |
|
||||||
|
| C-6 | §6 실행 순서에서 C-1 과 I-3b 를 **분리 불가**로 명시 | A-4 |
|
||||||
|
|
||||||
|
Rev.1 의 §1 실측 원장(M-1~M-14), §2 I-2 사양, §3.1 `focused` 제거, §3.2 `--max-cols` env 배선 결정(bash 3.2 근거 포함), §3.3 앵커 주석, §4 문서화는 리뷰에서 승인되었으며 그대로 유지합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 요약
|
||||||
|
|
||||||
|
I-1(`_pane_quiescent` 주석)은 **이미 `31b2d70` 에서 해결**되었습니다(M-1). 본 계획의 범위는 I-2 와 I-3 이며, 여기에 리뷰가 발굴한 **C-1(헤드리스 `max_columns` 우회)** 이 추가됩니다.
|
||||||
|
|
||||||
|
- **I-2 는 실측 가능한 계약을 세우는 일**입니다. 헤드리스 조기 탈출(≈1.2 s)은 현재 어떤 단언에도 걸려 있지 않아, 제거해도 6/6 초록인 채로 지연만 10배가 됩니다(M-4). 기능 단언으로는 잡을 수 없고 **시간 단언만이** 잡습니다.
|
||||||
|
- **I-3 는 죽은 표면을 정리하는 일**입니다. 그런데 그중 `--max-cols` 를 되살리는 작업이 **C-1 결함을 활성화**하므로, 두 작업은 반드시 함께 갑니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 실측 원장 (Measurement Ledger)
|
||||||
|
|
||||||
|
| # | 검증 | 방법 | 결과 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| M-1 | I-1 선행 해결 여부 | `lib.sh:1578` | 🟢 `# empty_giveup: $SKS_EMPTY_GIVEUP (default: 3)` — 이미 정정됨 |
|
||||||
|
| M-2 | 현재 스위트 | `pytest tests/ -q` | **330 passed** |
|
||||||
|
| **M-3** | **헤드리스 정상 지연** | 독립 프로브 5회, `/usr/bin/time -p` | **1.22 / 1.24 / 1.24 / 1.23 / 1.24 s** (σ ≈ 0.01 s) |
|
||||||
|
| **M-4** | **헤드리스 회귀 지연** | 조기 giveup 제거 후 3회 | **10.21 / 10.25 / 10.21 s** — rc=0 이고 RPC 도 호출됨(**기능 단언 검출 불가**) |
|
||||||
|
| M-5 | B-19 스위트 소요 | `--durations=6` | 6 passed / 4.93 s. headless **1.20 s** |
|
||||||
|
| M-6 | bash 빈 배열 + `set -u` | 시스템 bash **3.2.57** | 🔴 `"${a[@]}"` → `unbound variable`. 🟢 `${a[@]+"${a[@]}"}` 정상 |
|
||||||
|
| M-7 | `--max-cols` CLI 현재 동작 | 4-pane 2열 + `--max-cols 2` | 🟢 `overflow p3` / `max_columns_reached` |
|
||||||
|
| M-8 | 환경변수 상속 선례 | `MAM_MIN_PANE_COLS=60` 만 설정 | 🟢 플래그 없이 반영됨 |
|
||||||
|
| M-9 | `extract_panes_and_focus` 호출처 | 전역 grep | `layout.py:78` 1곳. `tests/test_layout.py:9` 는 **import 만** |
|
||||||
|
| M-10 | `PaneInfo.focused` 판독처 | `grep -rn "\.focused\b"` | **0건** |
|
||||||
|
| M-11 | `PaneInfo` 이름 충돌 | `tests/fixtures/herdr_contract.json` | herdr RPC 타입. **무관, 건드리지 말 것** |
|
||||||
|
| M-12 | `sample_pane` 의 정체 | `lib.sh:415-427` | 워크스페이스의 **첫 번째 pane** — 포커스 무관 |
|
||||||
|
| M-13 | 레이아웃 env 문서화 | `grep -c … .mam.env.example` | **0** — `MAM_MIN_PANE_COLS`/`ROWS` 미문서화 |
|
||||||
|
| M-14 | 테스트 import 경로 | `tests/conftest.py:10-12` | `.agents/skills` 를 `sys.path` 주입 |
|
||||||
|
| **M-15** | **C-1 재현** | 헤드리스 N=2..8 × `max_columns=2` | 🔴 **N=4·6·8 전부 `right`** — 상한 미평가 |
|
||||||
|
| **M-16** | **GUI 대조군** | 4-pane 2열 × `max_columns=2` | 🟢 `overflow` / `max_columns_reached` |
|
||||||
|
| **M-17** | **GUI 외톨이 열 거동** | 5-pane(2열+외톨이) × `max_columns=2` | `down` / `fill_singleton_column` — **상한은 성장 가드**(A-2) |
|
||||||
|
| **M-18** | **C-1 패치 전 행렬** | 프로토타입 × `max_columns∈{None,1,2,3}` × N=2..8 | `None` 행이 **현행과 동일** → 기존 테스트 안전 |
|
||||||
|
| **M-19** | **`_four_panes_two_columns` 존재 여부** | `grep -rn tests/` | **없음**. 파일에 module-level 헬퍼가 **0개**, 전 테스트가 인라인 선언 |
|
||||||
|
| **M-20** | **미import 타입 힌트** | `exec("def f() -> Dict[str, Any]: ...")` | **정의 시점 NameError** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. I-2 — 헤드리스 조기 탈출 지연을 계약으로 고정
|
||||||
|
|
||||||
|
*(Rev.1 §2 에서 변경 없음 — 리뷰 승인)*
|
||||||
|
|
||||||
|
### 2.1 왜 시간 단언이어야 하는가
|
||||||
|
|
||||||
|
조기 giveup 을 제거해도 `send_keys_safe` 는 **rc=0 을 반환하고 `agent prompt` 도 호출**합니다(M-4). 현행 기능 단언이 전부 통과하고 달라지는 것은 **1.2 s → 10.2 s** 뿐입니다.
|
||||||
|
|
||||||
|
### 2.2 경계값 — 실측 근거
|
||||||
|
|
||||||
|
| 상태 | n | 범위 |
|
||||||
|
|---|---|---|
|
||||||
|
| 정상 | 5 | **1.22 – 1.24 s** |
|
||||||
|
| 회귀 | 3 | **10.21 – 10.25 s** |
|
||||||
|
|
||||||
|
`SKS_EMPTY_GIVEUP=3`, `interval=0.5` → 3번째 공백 캡처에서 sleep 없이 즉시 `return 2` 하므로 sleep 2회 = 1.0 s + bash 기동 0.2 s. 회귀 시 20 × 0.5 = 10.0 s. 헤드리스 경로는 `_pane_capture` 가 python3 를 띄우지 않아 측정이 거의 순수 sleep 입니다(σ ≈ 0.01 s).
|
||||||
|
|
||||||
|
**채택: 5.0 s** — 정상 대비 4배 여유, 회귀 대비 2배 마진.
|
||||||
|
|
||||||
|
### 2.3 구현 사양
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_bug4_headless_unobservable_fast_path(tmp_path):
|
||||||
|
"""Verify Bug 4 / R-1 + I-2: in headless mode where capture-pane is empty,
|
||||||
|
send_keys_safe bypasses dialogs and succeeds immediately via the RPC fast-path.
|
||||||
|
|
||||||
|
The elapsed-time bound is a contract, not a nicety: removing the
|
||||||
|
SKS_EMPTY_GIVEUP early exit leaves every functional assertion green and only
|
||||||
|
changes the wall clock (measured 1.22s -> 10.21s), so this is the sole
|
||||||
|
assertion that can detect that regression.
|
||||||
|
"""
|
||||||
|
test_script = f"""...""" # 본문 변경 없음
|
||||||
|
|
||||||
|
# SKS_* 는 pin 이 아니라 '제거'한다: lib.sh 의 기본값이 그대로 적용되어야
|
||||||
|
# 기본값 자체의 회귀를 탐지할 수 있고, 동시에 개발자 셸에 남아 있는
|
||||||
|
# 값 때문에 시간 단언이 흔들리지 않는다.
|
||||||
|
env = {k: v for k, v in os.environ.items()
|
||||||
|
if k not in ("SKS_QUIESCENT_TRIES", "SKS_QUIESCENT_INTERVAL", "SKS_EMPTY_GIVEUP")}
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
res = subprocess.run(["bash", "-c", test_script], capture_output=True, text=True, env=env)
|
||||||
|
elapsed = time.perf_counter() - t0
|
||||||
|
|
||||||
|
assert res.returncode == 0, f"Headless send_keys_safe failed: {res.stderr}"
|
||||||
|
assert "HEADLESS_OK" in res.stdout
|
||||||
|
assert elapsed < 5.0, (
|
||||||
|
f"headless fast-path took {elapsed:.2f}s (limit 5.0s) — the "
|
||||||
|
f"SKS_EMPTY_GIVEUP early exit in _pane_quiescent is likely gone; "
|
||||||
|
f"the full 10s quiescence window was consumed instead")
|
||||||
|
```
|
||||||
|
|
||||||
|
**필수**: 파일 상단 `import time` 추가 / 측정은 `subprocess.run` 만 감쌈 / `SKS_*` 는 **제거**(pin 금지) / 실패 메시지에 측정값과 원인 가설 포함.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. I-3 + C-1 — 죽은 표면 정리 및 헤드리스 상한 복원
|
||||||
|
|
||||||
|
### 3.1 C-1 — 헤드리스 `max_columns` 검사 (**신규, I-3b 와 동일 단계**)
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Check for Headless mode: all panes have width <= 0 or height <= 0
|
||||||
|
is_headless = all(p.width <= 0 or p.height <= 0 for p in panes)
|
||||||
|
if is_headless:
|
||||||
|
# Headless panes are all 0x0, so columns cannot be counted from geometry
|
||||||
|
# the way the GUI path does. The alternation below (odd -> down,
|
||||||
|
# even -> right) is what builds the grid, so while that invariant holds
|
||||||
|
# the completed-column count is exactly n // 2. If panes were closed and
|
||||||
|
# the shape drifted, an odd n is absorbed by the `down` branch and the
|
||||||
|
# estimate self-corrects at the next even n.
|
||||||
|
n = len(panes)
|
||||||
|
anchor = default_anchor_id or panes[-1].pane_id
|
||||||
|
if n % 2 == 1:
|
||||||
|
# Filling an existing column never opens a new one, so max_columns is
|
||||||
|
# deliberately NOT checked here -- this mirrors the GUI path, where
|
||||||
|
# `fill_singleton_column` also ignores the cap. max_columns is a
|
||||||
|
# growth guard, not an invariant over the existing layout.
|
||||||
|
return LayoutDecision(target_pane_id=anchor, direction="down", reason="headless_odd_down")
|
||||||
|
current_cols = n // 2
|
||||||
|
if max_columns and current_cols >= max_columns:
|
||||||
|
return LayoutDecision(target_pane_id=anchor, direction="overflow",
|
||||||
|
is_overflow=True, reason="max_columns_reached")
|
||||||
|
return LayoutDecision(target_pane_id=anchor, direction="right", reason="headless_even_right")
|
||||||
|
```
|
||||||
|
|
||||||
|
**동작 중립성**: `max_columns` 가 `None` 이면 분기가 통째로 건너뛰어져 현행과 완전히 동일합니다(M-18). 기존 `test_headless_0x0_transitions` 는 손대지 않아도 통과합니다.
|
||||||
|
|
||||||
|
**`reason` 문자열**: GUI 와 동일한 `max_columns_reached` 를 재사용합니다. 두 경로가 같은 사유를 내야 `--json` 소비자와 로그 분석에서 모드를 구분하지 않고 집계할 수 있습니다.
|
||||||
|
|
||||||
|
### 3.2 `PaneInfo.focused` — **제거**
|
||||||
|
|
||||||
|
*(Rev.1 §3.1 유지)* 판독처 0건(M-10), 호출처 1곳(M-9). `tests/test_layout.py:9,11` 은 import 만 하고 쓰지 않으며 CI flake8 가 `--select=E9,F63,F7,F82` 라 F401 을 보지 않아 통과해 왔습니다.
|
||||||
|
|
||||||
|
**헤드리스 앵커로 연결하는 대안은 기각**: (a) 2×K 엔진의 가치는 결정론인데 포커스는 사용자 상호작용 상태이고, (b) `lib.sh` 가 항상 `--sample-pane` 를 넘기므로 도달하지 않습니다. 애초에 `sample_pane` 은 "포커스된 pane" 이 아니라 워크스페이스의 첫 번째 pane 입니다(M-12).
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass
|
||||||
|
class PaneInfo:
|
||||||
|
pane_id: str
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
# NOTE: no `focused` field. The 2xK engine is deliberately geometry- and
|
||||||
|
# structure-driven so that identical pane sets always yield identical
|
||||||
|
# decisions. Focus is user-interaction state and would make the result
|
||||||
|
# non-deterministic; herdr still reports it in the payload if ever needed.
|
||||||
|
|
||||||
|
|
||||||
|
def extract_panes(data: Dict[str, Any]) -> List[PaneInfo]:
|
||||||
|
"""Extract the pane list from a herdr layout JSON payload.
|
||||||
|
|
||||||
|
Accepts all three shapes herdr 0.8 emits: result.layout.panes,
|
||||||
|
result.panes, and a bare top-level panes array.
|
||||||
|
"""
|
||||||
|
```
|
||||||
|
|
||||||
|
동반: `compute_2xk_layout:78` → `panes = extract_panes(data)`, 미사용 `Tuple` import 정리, `tests/test_layout.py:9-11` 의 미사용 import 제거.
|
||||||
|
|
||||||
|
> ⚠️ `tests/fixtures/herdr_contract.json` 과 `tests/test_herdr_shim_contract.py:70` 의 `PaneInfo` 는 **herdr RPC 계약 타입**입니다(M-11). 건드리지 마십시오.
|
||||||
|
|
||||||
|
### 3.3 `--max-cols` env 배선
|
||||||
|
|
||||||
|
*(Rev.1 §3.2 유지)* CLI 는 이미 정상(M-7)이나 argparse 만 env 기본값이 없어 프로덕션 미도달입니다.
|
||||||
|
|
||||||
|
**`lib.sh` 조건부 배열 전달은 기각** — macOS 기본 bash **3.2.57** 에서 `set -euo pipefail` + 빈 배열은 즉사합니다(M-6). `${a[@]+"${a[@]}"}` 우회는 가능하나 대부분이 모르는 관용구를 핵심 경로에 심는 대가가 이익보다 큽니다. **argparse env 기본값 방식은 `lib.sh` 를 한 글자도 건드리지 않고** 같은 결과를 냅니다(M-8 선례).
|
||||||
|
|
||||||
|
```python
|
||||||
|
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')."""
|
||||||
|
for n in names:
|
||||||
|
raw = os.environ.get(n, "").strip()
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
parser.add_argument("--max-cols", type=int,
|
||||||
|
default=_env_int("MAM_MAX_COLS", "MAM_MAX_PANE_COLS"))
|
||||||
|
```
|
||||||
|
|
||||||
|
기본값은 계속 `None`(상한 없음) — **동작 중립**이며 운영자가 opt-in 할 때만 상한이 걸립니다.
|
||||||
|
|
||||||
|
### 3.4 헤드리스 앵커 주석 정정
|
||||||
|
|
||||||
|
*(Rev.1 §3.3 유지, §3.1 코드에 통합됨)* `panes[-1]` 폴백은 `lib.sh` 가 항상 `--sample-pane` 를 넘기므로 프로덕션에서 도달하지 않습니다. `no_panes_default` 분기(`layout.py:81-82`)에도 같은 취지의 한 줄을 권고합니다.
|
||||||
|
|
||||||
|
### 3.5 신규 테스트 3건 (C-2 / C-3 반영)
|
||||||
|
|
||||||
|
`tests/test_layout.py` 는 **module-level 헬퍼가 0개이고 모든 테스트가 페이로드를 인라인 선언**합니다(M-19). 새 헬퍼 1개를 도입하되 파일 관례를 존중해 타입 힌트는 붙이지 않습니다(M-20 — `typing` 미import 상태에서 힌트는 정의 시점에 터집니다).
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _four_panes_two_columns():
|
||||||
|
"""GUI payload: 2 full columns x 2 rows (4 panes). Shared by the max-cols tests."""
|
||||||
|
return {
|
||||||
|
"result": {
|
||||||
|
"panes": [
|
||||||
|
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 100, "height": 40}},
|
||||||
|
{"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 100, "height": 40}},
|
||||||
|
{"pane_id": "p3", "rect": {"x": 100, "y": 0, "width": 100, "height": 40}},
|
||||||
|
{"pane_id": "p4", "rect": {"x": 100, "y": 40, "width": 100, "height": 40}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_max_cols_flag_triggers_overflow():
|
||||||
|
"""CLI --max-cols reaches compute_2xk_layout (the lib.sh-facing path)."""
|
||||||
|
payload = json.dumps(_four_panes_two_columns())
|
||||||
|
skills_dir = os.path.abspath(".agents/skills") # 파일 관례: 테스트 내부에서 계산
|
||||||
|
env = {**os.environ, "PYTHONPATH": skills_dir}
|
||||||
|
res = subprocess.run(
|
||||||
|
[sys.executable, "-m", "lib_py.layout",
|
||||||
|
"--min-cols", "30", "--min-rows", "20", "--max-cols", "2", "--json"],
|
||||||
|
input=payload, capture_output=True, text=True, env=env)
|
||||||
|
assert res.returncode == 0, res.stderr
|
||||||
|
d = json.loads(res.stdout)
|
||||||
|
assert d["direction"] == "overflow" and d["is_overflow"]
|
||||||
|
assert d["reason"] == "max_columns_reached"
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_max_cols_applies_without_flag():
|
||||||
|
"""MAM_MAX_PANE_COLS is honoured with no --max-cols flag, which is exactly
|
||||||
|
how lib.sh invokes the module (lib.sh passes no --max-cols)."""
|
||||||
|
payload = json.dumps(_four_panes_two_columns())
|
||||||
|
skills_dir = os.path.abspath(".agents/skills")
|
||||||
|
env = {**os.environ, "PYTHONPATH": skills_dir, "MAM_MAX_PANE_COLS": "2"}
|
||||||
|
res = subprocess.run(
|
||||||
|
[sys.executable, "-m", "lib_py.layout",
|
||||||
|
"--min-cols", "30", "--min-rows", "20", "--json"],
|
||||||
|
input=payload, capture_output=True, text=True, env=env)
|
||||||
|
assert res.returncode == 0, res.stderr
|
||||||
|
assert json.loads(res.stdout)["reason"] == "max_columns_reached"
|
||||||
|
|
||||||
|
|
||||||
|
def test_headless_max_columns_growth_guard():
|
||||||
|
"""C-1: headless mode must honour max_columns too.
|
||||||
|
|
||||||
|
A headless 2xK grid completes n // 2 columns, so at n=4 with max_columns=2
|
||||||
|
a further `right` split would open a third column and must overflow instead.
|
||||||
|
Note the cap blocks *opening* a new column; it does not force an existing
|
||||||
|
over-cap layout to shrink -- the odd-n `down` branch (and the GUI's
|
||||||
|
fill_singleton_column) deliberately ignore it.
|
||||||
|
"""
|
||||||
|
def headless(n):
|
||||||
|
return {"result": {"panes": [
|
||||||
|
{"pane_id": f"p{i}", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}}
|
||||||
|
for i in range(1, n + 1)]}}
|
||||||
|
|
||||||
|
d4 = compute_2xk_layout(headless(4), max_columns=2)
|
||||||
|
assert d4.is_overflow and d4.direction == "overflow"
|
||||||
|
assert d4.reason == "max_columns_reached"
|
||||||
|
|
||||||
|
# 상한 미만에서는 계속 성장한다
|
||||||
|
d2 = compute_2xk_layout(headless(2), max_columns=2)
|
||||||
|
assert d2.direction == "right" and not d2.is_overflow
|
||||||
|
|
||||||
|
# 기존 열을 채우는 것은 막지 않는다 (GUI 의 fill_singleton_column 과 동일 규칙)
|
||||||
|
d3 = compute_2xk_layout(headless(3), max_columns=2)
|
||||||
|
assert d3.direction == "down" and not d3.is_overflow
|
||||||
|
|
||||||
|
# max_columns 미지정 시 현행 동작 유지 (동작 중립성)
|
||||||
|
assert compute_2xk_layout(headless(4)).direction == "right"
|
||||||
|
```
|
||||||
|
|
||||||
|
마지막 단언(동작 중립성)이 중요합니다 — C-1 패치가 기존 헤드리스 교대를 건드리지 않았음을 같은 테스트 안에서 못박습니다.
|
||||||
|
|
||||||
|
> 기존 `test_max_columns_limit` 은 동일한 페이로드를 인라인으로 갖고 있습니다. `_four_panes_two_columns()` 로 치환하면 중복이 줄지만, 통과 중인 테스트를 건드리는 것은 선택 사항으로 둡니다(§7 Q-5).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 문서화 — 레이아웃 튜너블
|
||||||
|
|
||||||
|
*(Rev.1 §4 유지)* `.mam.env.example` 에 `MAM_MIN_PANE_COLS` / `MAM_MIN_PANE_ROWS` 가 **한 건도 없습니다**(M-13). 직전 커밋에서 `SKS_*` 3종을 문서화한 것과 형평이 맞지 않고, `MAM_MAX_PANE_COLS` 를 새로 살리면서 이 공백을 두면 신규 변수만 미문서화로 추가됩니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Minimum columns a pane must retain after a vertical split (2xK layout engine).
|
||||||
|
#default: 60
|
||||||
|
# MAM_MIN_PANE_COLS=60
|
||||||
|
|
||||||
|
# Minimum rows a pane must retain after a horizontal split (2xK layout engine).
|
||||||
|
#default: 20
|
||||||
|
# MAM_MIN_PANE_ROWS=20
|
||||||
|
|
||||||
|
# Maximum number of columns a workspace may grow to before the engine reports
|
||||||
|
# 'overflow' (which makes lib.sh create a fresh workspace instead of splitting).
|
||||||
|
# Applies to both measured (GUI) and headless 0x0 layouts.
|
||||||
|
#default: (unset -> no column cap)
|
||||||
|
# MAM_MAX_PANE_COLS=3
|
||||||
|
```
|
||||||
|
|
||||||
|
D-7 은 **설치 스크립트가 쓰는** 변수만 검사하므로 깨지지 않습니다. D-21/D-32 는 `MQTT_*` 대상이라 무관합니다 — 다만 §6 에서 배포 신선도 31건 재확인을 절차에 넣습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 회귀 가드 및 수용 기준
|
||||||
|
|
||||||
|
| 가드 | 대상 | 뮤테이션 | 기대 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `test_bug4_headless_unobservable_fast_path` (I-2 강화) | 조기 탈출 지연 | 조기 `return 2` 제거 | **FAIL** |
|
||||||
|
| 동 | 동 | `SKS_EMPTY_GIVEUP` 기본값 3→20 | **FAIL** |
|
||||||
|
| `test_cli_max_cols_flag_triggers_overflow` (신규) | CLI 경로 | `--max-cols` argparse 인자 제거 | **FAIL** |
|
||||||
|
| `test_env_max_cols_applies_without_flag` (신규) | env 배선 | `default=_env_int(...)` → `default=None` | **FAIL** |
|
||||||
|
| **`test_headless_max_columns_growth_guard`** (신규) | **C-1** | 헤드리스 분기의 `max_columns` 검사 제거 | **FAIL** |
|
||||||
|
| 동 | **C-1 동작 중립성** | 헤드리스 홀수 분기에도 상한 검사 추가(과잉 교정) | **FAIL** (`d3` 단언) |
|
||||||
|
| 기존 `test_max_columns_limit` | Python API (GUI) | `max_columns` 분기 삭제 | **FAIL** |
|
||||||
|
| 기존 `test_headless_0x0_transitions` | 헤드리스 교대 | C-1 패치 적용 | **통과 유지**(회귀 없음 확인) |
|
||||||
|
|
||||||
|
**테스트 수 예상**: 330 → **333** (신규 3건, I-2 는 기존 테스트에 단언 추가).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 실행 순서 및 완료 정의
|
||||||
|
|
||||||
|
```
|
||||||
|
[1] I-2 시간 단언 ──> [2] I-3a focused 제거 ──> [3] C-1 + I-3b (분리 불가) ──> [4] 주석 ──> [5] 문서 ──> [6] 검증
|
||||||
|
import time PaneInfo/extract 정리 헤드리스 상한 + env 배선 앵커 주석 .mam.env 뮤테이션 8종
|
||||||
|
env 필터링 + 신규 테스트 3건 3종 추가 + 333 전건
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> **[3] 은 쪼개지 않습니다.** I-3b 가 `MAM_MAX_PANE_COLS` opt-in 경로를 살리고, C-1 이 그 경로의 헤드리스 정합성을 보장합니다. I-3b 만 먼저 적용하면 이번 커밋이 **결함을 활성화하는 커밋**이 됩니다(A-4).
|
||||||
|
|
||||||
|
**단계별 확인**
|
||||||
|
1. I-2 적용 직후 `pytest tests/test_b19_headless_reconcile_fixes.py -q --durations=6` 로 headless 소요가 여전히 ≈1.2 s 인지 확인.
|
||||||
|
2. I-3a 는 개명이므로 **호출부 1곳(`layout.py:78`) + 테스트 import 1곳**만 수정(M-9).
|
||||||
|
3. C-1 적용 후 `MAM_MAX_PANE_COLS` **미설정 상태**에서 기존 `test_layout.py` 16건 전건 통과 → 동작 중립성 확인.
|
||||||
|
|
||||||
|
**DoD**
|
||||||
|
1. `pytest tests/ -q` → **333 passed**, exit 0.
|
||||||
|
2. §5 뮤테이션 8종이 각각 지정 테스트를 FAIL 시킴이 로그로 확인되고 원복됨.
|
||||||
|
3. `bash -n .agents/skills/lib.sh`, `py_compile lib_py/layout.py` 통과.
|
||||||
|
4. `pytest tests/test_deploy_freshness.py -q` → 31 passed.
|
||||||
|
5. `grep -rn "\.focused\b" .agents/skills/` → 0건, `grep -n "extract_panes_and_focus" tests/` → 0건.
|
||||||
|
6. GUI 와 헤드리스가 같은 `max_columns` 에서 **같은 시점에 overflow** 함을 수동 확인(4-pane / `max_columns=2` 양쪽 모두 `max_columns_reached`).
|
||||||
|
7. `git status --short` 에 의도한 5파일 외 변경 없음.
|
||||||
|
|
||||||
|
**게이트**: 2번 미충족 시 커밋 금지. 특히 I-2 시간 단언은 조기 giveup 제거 뮤테이션에서, C-1 가드는 헤드리스 상한 검사 제거 뮤테이션에서 **반드시 FAIL** 해야 합니다.
|
||||||
|
|
||||||
|
**범위 밖**: `--min-cols`/`--min-rows` 의 `lib.sh` 명시 전달 유지 여부, 열 상한 기본값 도입(Q-2), `IMPROVEMENTS.md` 항목 등록(Q-3), 혼합 모드(일부만 0×0) 처리(Q-6).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 열린 질문 (비차단)
|
||||||
|
|
||||||
|
| # | 질문 | 기본값(무응답 시) |
|
||||||
|
|---|---|---|
|
||||||
|
| **Q-1** | I-2 상한을 5.0 s 로 할 것인가? | **5.0 s** 유지 (실측 1.22–1.24 s 대비 4배, 회귀 10.2 s 대비 2배) |
|
||||||
|
| **Q-2** | `MAM_MAX_PANE_COLS` 에 기본 상한을 줄 것인가? | **주지 않음**(`None`). 기본값을 주면 기존 워크스페이스가 갑자기 분기 |
|
||||||
|
| **Q-3** | `IMPROVEMENTS.md` 에 등록할 것인가? | **B-20 항목에 후속 정리로 1–2줄 추가.** 단, **C-1 은 별도 문장으로 명시** — 잠복 결함이었고 opt-in 활성화와 함께 고쳐졌다는 사실은 기록 가치가 있음 |
|
||||||
|
| **Q-4** | `extract_panes_and_focus` 개명이 부담스러우면 이름 유지? | **개명 권고**(`extract_panes`). 반환이 튜플이 아니게 되므로 이름이 남으면 더 오해를 부름 |
|
||||||
|
| **Q-5** 🆕 | 기존 `test_max_columns_limit` 을 `_four_panes_two_columns()` 로 리팩터링할 것인가? | **하지 않음**. 통과 중인 테스트를 건드리는 위험 대비 이득이 중복 12줄 제거뿐 |
|
||||||
|
| **Q-6** 🆕 | 혼합 모드(일부 페인만 0×0)를 다룰 것인가? | **이번 범위 밖**. `is_headless` 가 `all(...)` 이라 혼합은 GUI 경로로 떨어지고 0-폭 페인이 한 열로 묶임. 실제 발생 사례가 관측되면 별도 과제로 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 부록 — Creator 착수 체크리스트
|
||||||
|
|
||||||
|
- [ ] `tests/test_b19_headless_reconcile_fixes.py` 에 `import time` 추가
|
||||||
|
- [ ] `test_bug4_headless_unobservable_fast_path` 에 SKS_* 환경변수 **제거**(pin 아님) + `elapsed < 5.0` 단언 (§2.3)
|
||||||
|
- [ ] 뮤테이션: 조기 `return 2` 제거 → 해당 테스트 **FAIL** 확인 후 원복
|
||||||
|
- [ ] `lib_py/layout.py`: `PaneInfo.focused` 제거, `extract_panes_and_focus` → `extract_panes` 개명, `:78` 호출부 수정, `Tuple` import 정리 (§3.2)
|
||||||
|
- [ ] `tests/test_layout.py:9,11` 미사용 import 제거
|
||||||
|
- [ ] **`lib_py/layout.py`: 헤드리스 분기에 `max_columns` 검사 추가 + 근거 주석 (§3.1) — 아래 env 배선과 같은 커밋**
|
||||||
|
- [ ] `lib_py/layout.py`: `_env_int` 헬퍼 + `--max-cols` env 기본값 (§3.3). **`lib.sh` 는 변경하지 않음**
|
||||||
|
- [ ] `tests/test_layout.py` 에 `_four_panes_two_columns()` **정의** + 신규 테스트 **3건** 추가 (§3.5) — 타입 힌트 금지(M-20), `skills_dir` 은 테스트 내부에서 계산
|
||||||
|
- [ ] `lib_py/layout.py`: `no_panes_default` 분기 주석 보강 (§3.4)
|
||||||
|
- [ ] `.mam.env.example` 에 `MAM_MIN_PANE_COLS` / `MAM_MIN_PANE_ROWS` / `MAM_MAX_PANE_COLS` 문서화 (§4)
|
||||||
|
- [ ] `pytest tests/ -q` → **333 passed**
|
||||||
|
- [ ] `pytest tests/test_deploy_freshness.py -q` → 31 passed
|
||||||
|
- [ ] §5 뮤테이션 **8종** 전건 FAIL 확인 후 원복, 로그 첨부
|
||||||
|
- [ ] GUI/헤드리스가 `max_columns=2` + 4페인에서 **동일하게** `max_columns_reached` 를 내는지 수동 확인
|
||||||
|
- [ ] ⚠️ `tests/fixtures/herdr_contract.json` 의 `PaneInfo` 는 **herdr RPC 타입** — 건드리지 말 것 (M-11)
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# 🔍 4차 리뷰 리포트: H-1 / H-2 반영 확인 (Job `119b9f57`)
|
||||||
|
|
||||||
|
- **작성일**: 2026-08-23
|
||||||
|
- **역할**: Reviewer (`claude`)
|
||||||
|
- **선행 리뷰**: `b0c007e2` (F-1~F-8) → `a44d37e5` (G-1~G-5) → `7b12bf64` (H-1~H-3)
|
||||||
|
- **리뷰 대상**: HEAD `6e2e9b1` 위의 **미커밋 작업 트리 변경** 8파일 (+245 / −95)
|
||||||
|
- **테스트**: **330 passed in 420.18s** (exit 0)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 종합 판정
|
||||||
|
|
||||||
|
| 요구 | 상태 | 검증 |
|
||||||
|
|---|:---:|---|
|
||||||
|
| **H-1** 느린 페인 가드를 실효화 (수용 기준: 윈도 6×0.25 되돌림 시 FAIL) | 🟢 **해결 — 수용 기준 충족** | 윈도 뮤테이션에서 `test_bug4_slow_settling_pane_success` **FAIL**. 테스트 소요 **0.5 s → 3.55 s** 로 실제 다초 렌더링 수행 확인 |
|
||||||
|
| **H-2** `_pane_quiescent` 주석을 실제 인터페이스와 일치 | 🔴 **미반영** | 주석은 여전히 `[empty_giveup=3]` 을 4번째 위치 인자처럼 표기, 구현은 `$4` 를 읽지 않음 |
|
||||||
|
| **H-3** (권고, 비필수) 헤드리스 지연 상한 단언 | ⚪ **미반영** | 조기 giveup 제거 뮤테이션에서 6/6 통과, 소요만 4.99 s → 13.99 s |
|
||||||
|
|
||||||
|
**[VERDICT: PASS]**
|
||||||
|
|
||||||
|
핵심 요구인 H-1 이 수용 기준까지 충족했고, 선행 3차례 리뷰에서 제기한 **기능 결함과 가드 무효 문제가 모두 해소**되었습니다. 남은 H-2 는 **주석 한 줄**로, 실행 경로에 영향이 없고 결함을 가리지도 않습니다. 이 한 항목으로 네 번째 차단을 거는 것은 비례하지 않는다고 판단하여 통과시키되, §4 에 미반영 사실과 처방을 명시적으로 남깁니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 검증 기반 (Measurement Ledger)
|
||||||
|
|
||||||
|
| # | 검증 | 방법 | 결과 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| M-1 | 전체 회귀 | `pytest tests/ -q` | **330 passed** (420 s), exit 0 |
|
||||||
|
| M-2 | B-19 스위트 + 소요 | `pytest … -q --durations=3` | 6 passed (4.99 s). **slow_settling 3.55 s**, headless 1.22 s |
|
||||||
|
| M-3 | 구문/컴파일 | `bash -n` ×2, `py_compile` | 전부 양호 |
|
||||||
|
| **M-4** | **MUT-A: 윈도를 6×0.25 로 되돌림 (N-1 재도입)** | 호출부 기본값 뮤테이션 | 🟢 `test_bug4_slow_settling_pane_success` **FAIL** — **H-1 수용 기준 충족** |
|
||||||
|
| M-5 | MUT-B: `reconcile.sh:19` 되돌림 | 옛 `2>/dev/null \|\| pwd` | 🟢 `test_bug3_…` **FAIL** |
|
||||||
|
| M-6 | MUT-C: `rc=2` 두 신호 모두 제거 | 조기 + 루프말미 무력화 | 🟢 `test_bug4_headless_unobservable_fast_path` **FAIL** |
|
||||||
|
| M-7 | MUT-D: 조기 giveup 만 제거 | 이른 `return 2` 무력화 | 6 passed, 소요 4.99 s → **13.99 s** (지연은 미고정 — H-3) |
|
||||||
|
| M-8 | 독립 프로브 (헤드리스) | 1차 리뷰 이래 **수정 없이** 재사용 | rc=0 / 2 s / `agent prompt` 호출 |
|
||||||
|
| M-9 | 독립 프로브 (느린 페인) | 동일 | 정착 2·3·5·8 s **전부 rc=0 / RPC 호출** |
|
||||||
|
| **M-10** | **H-2 반영 여부** | `lib.sh:1578` 주석 vs 함수 본문 | 🔴 주석 `[empty_giveup=3]`, 본문은 `"$1" "$2" "$3"` 만 사용 — **미반영** |
|
||||||
|
| M-11 | 테스트 부작용 | 실행 후 `git status --short` | 신규 파일 0건 — `tmp_path` 밖으로 쓰지 않음 ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. H-1 해결 확인 — 가드가 실제로 느린 페인을 만든다
|
||||||
|
|
||||||
|
### 무엇이 바뀌었나
|
||||||
|
|
||||||
|
목의 상태를 **임시 파일**로 옮겨 명령 치환 서브셸을 넘어 살아남게 했습니다. `PROMPT_CALLED` / `PASTE_CALLED` 도 플래그 파일로 전환되어 동일한 함정을 원천 차단했습니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
COUNT_FILE="{count_file}" # pytest tmp_path
|
||||||
|
_sks_herdr() {
|
||||||
|
if [ "${1:-}" = "capture-pane" ]; then
|
||||||
|
local c
|
||||||
|
c=$(cat "$COUNT_FILE" 2>/dev/null || echo "0")
|
||||||
|
c=$((c + 1))
|
||||||
|
echo "$c" > "$COUNT_FILE" # ← 서브셸을 넘어 지속
|
||||||
|
if [ "$c" -le 5 ]; then echo "Rendering frame $c..."; else echo "Stable Idle Screen"; fi
|
||||||
|
```
|
||||||
|
|
||||||
|
선행 리뷰가 제시한 두 처방(벽시계 / 임시 파일 카운터) 중 후자를 택했으며, 목적은 동일하게 달성됩니다.
|
||||||
|
|
||||||
|
### 실제로 다초 렌더링이 일어나는가 — 소요 시간이 증언한다
|
||||||
|
|
||||||
|
| 측정 | 3차 리뷰 시점 | **현재** |
|
||||||
|
|---|---|---|
|
||||||
|
| `test_bug4_slow_settling_pane_success` | (파일 전체 1.02 s 안에 포함) | **3.55 s** |
|
||||||
|
| B-19 스위트 6건 합계 | 1.02 s | **4.99 s** |
|
||||||
|
|
||||||
|
캡처 1~5 는 서로 다른 문자열, 6·7 은 동일 → 7번째 캡처에서 정숙 판정. 기본 간격 0.5 s 기준 ≈ 3.5 s 로 실측치와 일치합니다. 3차 리뷰에서 지적한 "549 ms 만에 rc=0" 상황이 사라졌습니다.
|
||||||
|
|
||||||
|
### 수용 기준 충족 (M-4)
|
||||||
|
|
||||||
|
3차 리뷰가 명시한 기준 — *"호출부를 `6`/`0.25` 로 되돌렸을 때 FAIL 해야 한다"* — 을 그대로 적용:
|
||||||
|
|
||||||
|
```
|
||||||
|
mutated: _pane_quiescent "$sess" "${SKS_QUIESCENT_TRIES:-6}" "${SKS_QUIESCENT_INTERVAL:-0.25}"
|
||||||
|
FAILED tests/test_b19_headless_reconcile_fixes.py::test_bug4_slow_settling_pane_success
|
||||||
|
1 failed, 5 passed in 2.51s
|
||||||
|
```
|
||||||
|
|
||||||
|
`tries=6` 이면 6번째 캡처(`Stable Idle Screen`)가 직전(`Rendering frame 5...`)과 달라 루프가 소진되고 `rc=1` → `send_keys_safe` 가 RPC 를 시도하지 않아 `PROMPT_FLAG` 가 생기지 않습니다. **N-1 회귀를 정확히 검출합니다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 회귀 가드 전수 실효성 (뮤테이션 매트릭스)
|
||||||
|
|
||||||
|
이번 라운드에서 B-19 스위트 6건에 대해 4종 뮤테이션을 적용했습니다.
|
||||||
|
|
||||||
|
| 뮤테이션 | 기대 | 결과 |
|
||||||
|
|---|---|---|
|
||||||
|
| 정숙성 윈도 → `6`/`0.25` | `slow_settling` FAIL | 🟢 FAIL (M-4) |
|
||||||
|
| `reconcile.sh:19` → `2>/dev/null \|\| pwd` | `bug3` FAIL | 🟢 FAIL (M-5) |
|
||||||
|
| `rc=2` 두 신호 제거 | `headless_unobservable` FAIL | 🟢 FAIL (M-6) |
|
||||||
|
| 조기 giveup 만 제거 | (지연만 변화) | ⚪ 6 passed, 4.99 s → 13.99 s (M-7) |
|
||||||
|
|
||||||
|
**세 가지 기능 계약이 모두 뮤테이션으로 봉인**되었습니다. 3차 리뷰 시점에 1건이 반증되었던 상태에서 전건 실효로 올라섰습니다. 네 번째 항목은 지연 최적화이며 H-3 로 권고했던 비필수 사항입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 🔴 H-2 미반영 (통과시키되 기록)
|
||||||
|
|
||||||
|
`lib.sh:1578` 은 그대로입니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# _pane_quiescent <sess> [tries=20] [interval=0.5] [empty_giveup=3]
|
||||||
|
```
|
||||||
|
|
||||||
|
그러나 함수는 네 번째 위치 인자를 읽지 않습니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
_pane_quiescent() {
|
||||||
|
local sess="$1" tries="${2:-20}" interval="${3:-0.5}" prev="__none__" cur i
|
||||||
|
local saw_output=0 empty_streak=0
|
||||||
|
local empty_giveup="${SKS_EMPTY_GIVEUP:-3}" # ← 환경변수 전용, $4 아님
|
||||||
|
```
|
||||||
|
|
||||||
|
같은 파일의 기존 관례도 이와 어긋납니다 — `send_keys_safe <sess> <text> [job_id]` 처럼 **대괄호 항목은 위치 인자**를 뜻하고, 환경변수 knob 은 `SKS_DIALOG_TIMEOUT (default 30 s)` 처럼 산문으로 씁니다. 현재 표기는 "네 번째 인자를 넘기면 동작한다"고 읽히지만 실제로는 조용히 무시됩니다.
|
||||||
|
|
||||||
|
**처방** (택 1)
|
||||||
|
```bash
|
||||||
|
# _pane_quiescent <sess> [tries=20] [interval=0.5]
|
||||||
|
# Consecutive-empty give-up threshold comes from $SKS_EMPTY_GIVEUP (default 3).
|
||||||
|
```
|
||||||
|
또는 `local empty_giveup="${4:-${SKS_EMPTY_GIVEUP:-3}}"` 로 실제 위치 인자를 받도록 구현을 맞춥니다.
|
||||||
|
|
||||||
|
**통과 판단 근거**: 실행 경로에 영향이 없고(주석), 결함을 가리는 가드가 아니며, 오독 시 손실은 "무시되는 인자를 넘긴다" 뿐입니다. 기능·가드가 모두 정상인 변경분을 주석 한 줄로 네 번째 차단하는 것은 비례하지 않는다고 판단합니다. 다만 **다음 커밋에 포함할 잔여 항목으로 명확히 남깁니다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 누적 결함 해소 현황
|
||||||
|
|
||||||
|
4차에 걸친 리뷰에서 제기된 항목의 최종 상태입니다.
|
||||||
|
|
||||||
|
| 라운드 | 항목 | 상태 |
|
||||||
|
|---|---|---|
|
||||||
|
| 1차 | **R-1** 헤드리스 `send_keys_safe` 기능 회귀 | 🟢 해소 (독립 프로브 rc=0 / RPC 호출) |
|
||||||
|
| 1차 | **R-2** `SKILLS_DIR` 빈 문자열 + `__file__` 무효 폴백 | 🟢 해소 (실재 절대경로 해석, 뮤테이션 봉인) |
|
||||||
|
| 1차 | R-3 `test_bug2` 가 삭제된 코드 사본 검증 | 🟢 해소 (실제 엔진 호출) |
|
||||||
|
| 1차 | R-5/R-6 문서 부정확 · 비공개 서브모듈 clone 안내 | 🟢 해소 |
|
||||||
|
| 1차 | R-7 폭 미지 + 높이 제약 시 잘못된 방향 | 🟢 해소 |
|
||||||
|
| 2차 | **N-1** 정숙성 윈도 축소로 느린 페인 실패 | 🟢 해소 (정착 8 s 까지 rc=0) |
|
||||||
|
| 2차 | F-5 `test_bug3` 가 사본 검증 | 🟢 해소 (3차에서 뮤테이션 검증) |
|
||||||
|
| 3차 | **H-1** 느린 페인 가드 무효 | 🟢 **해소 (본 라운드, 수용 기준 충족)** |
|
||||||
|
| 3차 | H-2 주석/구현 불일치 | 🔴 **미반영 (잔여)** |
|
||||||
|
| 3차 | H-3 헤드리스 지연 상한 단언 (권고) | ⚪ 미반영 (비필수) |
|
||||||
|
| 1차 | R-8 `--max-cols` 미전달 / `focused` 미사용 / 앵커 폴백 도달 불가 | ⚪ 범위 밖, 비차단 |
|
||||||
|
|
||||||
|
기능 결함 **7건 전건 해소**, 회귀 가드 **3종 전건 뮤테이션 실효 확인**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 규약 준수 확인
|
||||||
|
|
||||||
|
| 항목 | 확인 |
|
||||||
|
|---|---|
|
||||||
|
| 역할 분리 (`MULTI_AGENT_RULES.md` §1) | Creator 가 4라운드에 걸쳐 리뷰 지적을 수용·반영 ✅ |
|
||||||
|
| 반박 절차 (§3.1) | `[REBUT:]` 제기 없음 ✅ |
|
||||||
|
| 민감정보 미포함 (§2) | diff 에 자격증명·절대 시스템 경로 하드코딩 없음 ✅ |
|
||||||
|
| 회귀 가드 실효성 | B-19 스위트 3종 기능 계약 전부 뮤테이션 FAIL ✅ |
|
||||||
|
| 테스트 부작용 | `tmp_path` 밖 파일 생성 0건 ✅ |
|
||||||
|
| 전체 스위트 Green | 330/330 ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 잔여 항목 (다음 커밋 권고, 비차단)
|
||||||
|
|
||||||
|
| # | 파일 | 조치 |
|
||||||
|
|---|---|---|
|
||||||
|
| **I-1** | `.agents/skills/lib.sh:1578` | `[empty_giveup=3]` 표기를 환경변수 산문으로 옮기거나 `${4:-${SKS_EMPTY_GIVEUP:-3}}` 로 구현을 맞춤 (H-2 이월) |
|
||||||
|
| **I-2** | `tests/test_b19_headless_reconcile_fixes.py` | 헤드리스 경로 소요 시간 상한 단언 — 조기 giveup 제거 시 FAIL 하도록 (H-3 이월) |
|
||||||
|
| **I-3** | `.agents/skills/lib_py/layout.py` / `lib.sh` | `--max-cols` 전달 여부 결정, `PaneInfo.focused` 사용 또는 제거, 헤드리스 앵커 폴백 주석 정정 (R-8 이월) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 결론
|
||||||
|
|
||||||
|
핵심 요구인 H-1 이 **수용 기준까지 충족**했습니다. 목의 상태를 임시 파일로 옮겨 서브셸 소실을 제거했고, 그 결과 테스트 소요가 0.5 s 수준에서 3.55 s 로 늘어 실제로 다초 렌더링을 수행함이 시간으로 확인됩니다. 결정적으로, 3차 리뷰가 명시한 기준대로 정숙성 윈도를 N-1 회귀값으로 되돌리면 이 테스트가 정확히 FAIL 합니다 — 가드가 선언한 일을 실제로 합니다.
|
||||||
|
|
||||||
|
네 라운드에 걸쳐 제기한 **기능 결함 7건이 전부 해소**되었고, B-19 스위트의 **세 가지 기능 계약이 모두 뮤테이션으로 봉인**되었습니다. 1차 리뷰 이래 수정 없이 재사용한 독립 프로브에서도 헤드리스·느린 페인(정착 8 초까지) 양쪽 모두 정상 동작합니다. 전체 330/330 통과, 구문·컴파일 검사 깨끗, 테스트 부작용 없음.
|
||||||
|
|
||||||
|
H-2 는 반영되지 않았습니다. 주석 한 줄이며 실행 경로에 영향이 없고 어떤 결함도 가리지 않으므로 차단 사유로 삼지 않되, I-1 로 이월합니다. 설계 변경이나 재작업이 필요한 사안은 없습니다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# 🔍 리뷰 리포트: 백로그 I-2 / I-3 + C-1 구현 (Job `55d1a1d9`)
|
||||||
|
|
||||||
|
- **작성일**: 2026-08-23
|
||||||
|
- **역할**: Reviewer (`claude`)
|
||||||
|
- **대상 계획**: `5e4ef463` (Rev.2 — I-2 / I-3 / C-1)
|
||||||
|
- **리뷰 대상**: HEAD `31b2d70` 위의 **미커밋 작업 트리 변경** 5파일 (+160 / −26)
|
||||||
|
- **테스트**: **333 passed in 428.43s** (exit 0), 333 collected
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 종합 판정
|
||||||
|
|
||||||
|
| 계획 항목 | 상태 | 검증 |
|
||||||
|
|---|:---:|---|
|
||||||
|
| **I-2** 헤드리스 지연 상한 단언 | 🟢 **해결 (뮤테이션 2종 검증)** | 조기 `return 2` 제거 · `SKS_EMPTY_GIVEUP` 3→20 양쪽에서 **FAIL**. 정상 소요 1.18 s |
|
||||||
|
| **I-3a** `PaneInfo.focused` 제거 | 🟢 **해결** | `.focused` 판독 **0건**, `extract_panes_and_focus` 잔존 **0건**, `Tuple` import 정리됨 |
|
||||||
|
| **I-3b** `--max-cols` env 배선 | 🟢 **해결 (뮤테이션 2종 검증)** | argparse 인자 제거 · env 기본값 `None` 복귀 양쪽에서 **FAIL**. `lib.sh` 무변경 확인 |
|
||||||
|
| **I-3c** 앵커/`no_panes_default` 주석 | 🟢 **해결** | 근거 주석(추론 vs 측정, 성장 가드) 반영 |
|
||||||
|
| **C-1** 헤드리스 `max_columns` 우회 교정 | 🟢 **해결 (뮤테이션 검증)** | 체크 제거 시 **FAIL**. GUI/헤드리스가 4페인·`max=2` 에서 **동일하게** `max_columns_reached` |
|
||||||
|
| **문서** `.mam.env.example` 3종 + `IMPROVEMENTS.md` | 🟢 **해결** | 템플릿 규약 준수, 배포 신선도 31/31 유지 |
|
||||||
|
|
||||||
|
**[VERDICT: PASS]**
|
||||||
|
|
||||||
|
계획의 모든 요구가 구현되었고 뮤테이션 7종이 지정 테스트를 FAIL 시킵니다. 남은 두 항목은 P3 수준이며, 그중 하나는 **제 계획의 뮤테이션 명세 오류**입니다(§3.2). 차단하지 않고 J-1 / J-2 로 이월합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 검증 기반 (Measurement Ledger)
|
||||||
|
|
||||||
|
| # | 검증 | 방법 | 결과 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| M-1 | 전체 회귀 | `pytest tests/ -q` | **333 passed** (428 s), exit 0 |
|
||||||
|
| M-2 | 수집 수 | `--collect-only` | **333** — 계획 예상치와 일치 (330 → 333) |
|
||||||
|
| M-3 | 대상 파일 | `test_layout.py` + `test_b19_*.py` | 25 passed (19 + 6) |
|
||||||
|
| M-4 | I-2 정상 소요 | `--durations` | headless **1.18 s** (상한 5.0 s) |
|
||||||
|
| M-5 | 컴파일 / 인터프리터 | `py_compile`, `/usr/bin/python3` (3.9.6) | 양호 / `right P9` |
|
||||||
|
| M-6 | 배포 신선도 | `pytest tests/test_deploy_freshness.py -q` | **31 passed** — `.mam.env.example` 추가가 D-7/D-21/D-32 를 깨지 않음 |
|
||||||
|
| M-7 | 죽은 표면 제거 | `grep -rn "\.focused\b" .agents/skills/` / `extract_panes_and_focus` in `tests/` | **0 / 0** |
|
||||||
|
| M-8 | GUI ↔ 헤드리스 대칭 | 4페인 · `max_columns=2` 양 모드 | **둘 다** `overflow` / `max_columns_reached` |
|
||||||
|
| **M-9** | **뮤테이션 M1** 조기 `return 2` 제거 | 격리 복제본 | 🟢 `test_bug4_headless_unobservable_fast_path` **FAIL** |
|
||||||
|
| **M-10** | **뮤테이션 M2** `SKS_EMPTY_GIVEUP` 3→20 | 동 | 🟢 동 테스트 **FAIL** |
|
||||||
|
| **M-11** | **뮤테이션 M3** `--max-cols` argparse 인자 삭제 | 동 | 🟢 CLI·env 테스트 **FAIL** |
|
||||||
|
| **M-12** | **뮤테이션 M4** `--max-cols` 기본값 `None` 복귀 | 동 | 🟢 `test_env_max_cols_applies_without_flag` **FAIL** |
|
||||||
|
| **M-13** | **뮤테이션 M5** 헤드리스 `max_columns` 체크 제거 | 동 | 🟢 `test_headless_max_columns_growth_guard` **FAIL** |
|
||||||
|
| **M-14** | **뮤테이션 M6** 홀수 분기에도 상한 검사(과잉 교정) | 동 | 🔴 **19 passed** — 가드가 검출 못 함 (§3.2) |
|
||||||
|
| **M-15** | **뮤테이션 M7** GUI `max_columns` 분기 삭제 | 동 | 🟢 3건 **FAIL** |
|
||||||
|
| **M-16** | **`MAM_MIN_PANE_COLS=0` 거동** | env vs 플래그 대조 | 🔴 env `0` → `overflow` / 플래그 `--min-cols 0` → `down` (§3.1) |
|
||||||
|
| **M-17** | **M6 판별 조건 분석** | 헤드리스 n=3/5/7 × `max=2` | 정상 코드 전부 `down`. 과잉 교정 시 n=**5,7** 만 `overflow` — 테스트의 n=3 은 임계 미달 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 구현 확인 상세
|
||||||
|
|
||||||
|
### 2.1 I-2 — 시간 단언이 실제로 계약이 되었다
|
||||||
|
|
||||||
|
`import time` 추가, `SKS_*` 3종을 **pin 이 아니라 제거**(계획 요구대로), `subprocess.run` 만 감싼 측정, 원인 가설을 담은 실패 메시지까지 사양대로 구현되었습니다.
|
||||||
|
|
||||||
|
두 방향의 뮤테이션에서 모두 FAIL 합니다.
|
||||||
|
|
||||||
|
| 뮤테이션 | 결과 |
|
||||||
|
|---|---|
|
||||||
|
| 조기 `return 2` 제거 | `test_bug4_headless_unobservable_fast_path` **FAIL** (13.7 s 소요) |
|
||||||
|
| `SKS_EMPTY_GIVEUP:-3` → `:-20` | 동 **FAIL** (13.5 s) |
|
||||||
|
|
||||||
|
두 번째가 특히 값어치 있습니다 — 코드 구조는 그대로 두고 **상수만** 바꿔도 잡힙니다. 정상 경로는 1.18 s 로 상한 5.0 s 대비 4배 여유가 유지됩니다.
|
||||||
|
|
||||||
|
### 2.2 I-3a — 죽은 표면이 실제로 사라졌다
|
||||||
|
|
||||||
|
`PaneInfo.focused` 필드, `focused_id` 반환, `layout.get("focused_pane_id")` 조회가 모두 제거되고 `extract_panes_and_focus` → `extract_panes` 로 개명, 호출부 1곳과 `tests/test_layout.py` 의 미사용 import 2개가 함께 정리되었습니다. `Tuple` import 도 제거되어 잔재가 없습니다(M-7).
|
||||||
|
|
||||||
|
제거 이유를 dataclass 자리에 주석으로 남긴 것도 적절합니다 — 다음 사람이 "왜 focused 가 없지"를 되묻지 않게 합니다.
|
||||||
|
|
||||||
|
### 2.3 I-3b / C-1 — 배선과 교정이 같은 커밋에 함께 들어갔다
|
||||||
|
|
||||||
|
계획이 **분리 불가**로 못박은 부분입니다. `MAM_MAX_PANE_COLS` opt-in 경로를 살리는 변경과, 그 경로의 헤드리스 정합성을 보장하는 C-1 교정이 한 커밋에 있습니다. 결과적으로 이 변경은 결함을 활성화하지 않습니다.
|
||||||
|
|
||||||
|
GUI 와 헤드리스가 **같은 시점에 같은 사유로** overflow 합니다(M-8).
|
||||||
|
|
||||||
|
```
|
||||||
|
GUI -> overflow overflow=True reason=max_columns_reached
|
||||||
|
HEADLESS -> overflow overflow=True reason=max_columns_reached
|
||||||
|
```
|
||||||
|
|
||||||
|
`lib.sh` 는 한 글자도 바뀌지 않았습니다 — bash 3.2 빈 배열 함정을 피하려던 계획의 의도가 그대로 지켜졌습니다.
|
||||||
|
|
||||||
|
C-1 주석도 계획이 요구한 두 근거(추론 vs 측정 / 성장 가드)를 모두 담고 있습니다.
|
||||||
|
|
||||||
|
### 2.4 문서
|
||||||
|
|
||||||
|
`.mam.env.example` 3종이 기존 템플릿 규약(`#default:` + 주석 처리된 대입)을 따르고, `MAM_MAX_PANE_COLS` 설명에 *"Applies to both measured (GUI) and headless 0x0 layouts"* 를 명기해 C-1 의 결과를 운영자에게 전달합니다. `IMPROVEMENTS.md` 는 B-20 항목에 후속 정리를 1–2줄로 추가하고 **C-1 을 별도 문장으로 기록**했습니다(계획 Q-3 의 처방대로).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 잔여 지적 (비차단)
|
||||||
|
|
||||||
|
### 🟡 J-1 (P3) — `or 60` 관용구가 `MAM_MIN_PANE_COLS=0` 을 삼킨다
|
||||||
|
|
||||||
|
```python
|
||||||
|
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)
|
||||||
|
```
|
||||||
|
|
||||||
|
`_env_int` 가 `0` 을 반환하면 falsy 이므로 `or 60` 이 발동해 **60 으로 덮어씁니다**. 같은 값을 플래그로 주면 0 이 그대로 쓰입니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
MAM_MIN_PANE_COLS=0 MAM_MIN_PANE_ROWS=0 -> {"direction": "overflow", "reason": "single_pane_overflow"}
|
||||||
|
--min-cols 0 --min-rows 0 -> {"direction": "down", "reason": "single_pane_split_down"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`_env_int('MAM_MIN_PANE_COLS')` 는 `0` 을 정확히 반환하며, `or 60` 단계에서만 60 이 됩니다(M-16). 즉 **동일한 설정을 표현하는 두 경로가 갈라집니다**.
|
||||||
|
|
||||||
|
`0` 은 "폭 하한 없음" 을 뜻하는 자연스러운 표현이고, 이번 커밋 이전의 `int(os.environ.get(..., 60))` 은 이를 올바르게 처리했습니다. 계획은 `--min-cols`/`--min-rows` 의 헬퍼 통일을 **권고(비필수)** 로만 적었으므로 이 코드는 선택적 확장이었고, 확장 과정에서 falsy-zero 함정이 들어왔습니다.
|
||||||
|
|
||||||
|
**처방** — `_env_int` 에 기본값 인자를 주어 `or` 를 없앱니다.
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _env_int(*names: str, default: Optional[int] = None) -> Optional[int]:
|
||||||
|
for n in names:
|
||||||
|
raw = os.environ.get(n, "").strip()
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
return default
|
||||||
|
|
||||||
|
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` 는 영향이 없습니다 — `or` 를 쓰지 않았고, `0` 이 `if max_columns and …` 에서 falsy 가 되어 "상한 없음" 으로 읽히는 것은 의도에 부합합니다.
|
||||||
|
|
||||||
|
**권고 가드**: `MAM_MIN_PANE_COLS=0` 과 `--min-cols 0` 이 같은 결정을 내는지 단언하는 테스트 1건.
|
||||||
|
|
||||||
|
### 🟡 J-2 (P3, **계획 측 오류**) — 성장 가드 테스트의 `d3` 케이스가 임계에 못 미친다
|
||||||
|
|
||||||
|
계획 §5 는 뮤테이션 M6(홀수 분기에도 상한 검사 추가 = 과잉 교정)이 `test_headless_max_columns_growth_guard` 의 `d3` 단언에서 FAIL 할 것으로 적었습니다. 실제로는 **19/19 통과**합니다(M-14).
|
||||||
|
|
||||||
|
원인은 테스트 데이터에 있습니다. `d3` 은 `headless(3), max_columns=2` 인데 `n // 2 = 1` 이라 `1 >= 2` 가 거짓이므로, 과잉 교정을 넣어도 그 분기에 도달하지 않습니다.
|
||||||
|
|
||||||
|
| n (`max_columns=2`) | `n // 2` | 정상 코드 | 과잉 교정 시 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 3 | 1 | `down` | `down` ← **판별 불가** |
|
||||||
|
| **5** | 2 | `down` | **`overflow`** |
|
||||||
|
| 7 | 3 | `down` | `overflow` |
|
||||||
|
|
||||||
|
즉 *"기존 열을 채우는 것은 막지 않는다"* 는 계약 — C-1 처방이 GUI 와 대칭임을 보장하는 바로 그 성질 — 이 **현재 아무 단언에도 걸려 있지 않습니다.**
|
||||||
|
|
||||||
|
**이 오류의 출처는 구현이 아니라 계획입니다.** Creator 는 계획이 지정한 테스트를 그대로 구현했고, 임계값을 넘지 않는 데이터를 고른 것은 제 쪽입니다.
|
||||||
|
|
||||||
|
**처방** — 한 줄 추가.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Filling an existing column is not blocked even at/above the cap
|
||||||
|
# (n=5 -> n//2=2 >= max_columns=2, so this case actually reaches the check)
|
||||||
|
d5 = compute_2xk_layout(headless(5), max_columns=2)
|
||||||
|
assert d5.direction == "down" and not d5.is_overflow
|
||||||
|
```
|
||||||
|
|
||||||
|
수용 기준: 홀수 분기에 상한 검사를 넣는 뮤테이션에서 **FAIL** 해야 합니다.
|
||||||
|
|
||||||
|
### 🟢 참고 (조치 불요)
|
||||||
|
|
||||||
|
`test_bug4_headless_unobservable_fast_path` 의 목에서 `paste-buffer` 분기의 `return 0` 이 삭제되었습니다. 바로 아래 `return 0` 으로 떨어지므로 동작은 같습니다. 계획에 없던 변경이지만 무해합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 규약 준수 확인
|
||||||
|
|
||||||
|
| 항목 | 확인 |
|
||||||
|
|---|---|
|
||||||
|
| 역할 분리 (`MULTI_AGENT_RULES.md` §1) | Planner 계획 → Creator 구현 → Reviewer 검증 절차 준수 ✅ |
|
||||||
|
| 반박 절차 (§3.1) | `[REBUT:]` 제기 없음 ✅ |
|
||||||
|
| 민감정보 미포함 (§2) | diff 에 자격증명·절대 시스템 경로 하드코딩 없음 ✅ |
|
||||||
|
| 회귀 가드 실효성 | 뮤테이션 7종 FAIL / 1종 미검출(J-2, 계획 측 오류) ⚠️ |
|
||||||
|
| 계획 DoD 1·3·4·5·6·7 | 333 passed / 컴파일 / 배포 31 / 죽은 표면 0 / GUI·헤드리스 대칭 / 의도한 5파일만 변경 ✅ |
|
||||||
|
| 계획 DoD 2 (뮤테이션 8종) | **7/8** — M6 은 계획 명세 자체가 판별 불가였음 ⚠️ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 이월 항목
|
||||||
|
|
||||||
|
| # | 파일 | 조치 |
|
||||||
|
|---|---|---|
|
||||||
|
| **J-1** | `.agents/skills/lib_py/layout.py` | `_env_int` 에 `default=` 인자 도입, `or 60` / `or 20` 제거. `MAM_MIN_PANE_COLS=0` ↔ `--min-cols 0` 동치 가드 1건 |
|
||||||
|
| **J-2** | `tests/test_layout.py` | `test_headless_max_columns_growth_guard` 에 `headless(5)` 케이스 추가. 수용 기준: 홀수 분기 상한 검사 뮤테이션에서 FAIL |
|
||||||
|
| **I-3 잔여** | `layout.py` | (기존 이월) `PaneInfo` 관련 정리는 완료. 추가 잔여 없음 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 결론
|
||||||
|
|
||||||
|
계획 `5e4ef463` 의 여섯 항목(I-2, I-3a/b/c, C-1, 문서)이 모두 구현되었고, 사양에서 벗어난 곳이 없습니다. 특히 C-1 교정과 `--max-cols` 배선이 **같은 커밋**에 들어가 결함 활성화를 피했고, GUI 와 헤드리스가 4페인·`max_columns=2` 에서 동일하게 `max_columns_reached` 를 내는 것을 실측했습니다. 뮤테이션 7종이 각각 지정 테스트를 FAIL 시키며, 그중 `SKS_EMPTY_GIVEUP` 상수만 바꾸는 뮤테이션까지 잡히는 것은 I-2 가드가 구조가 아니라 **동작**을 붙들고 있음을 보여줍니다. 전체 333/333 통과, 배포 신선도 31/31 유지, 시스템 python 3.9.6 호환 확인.
|
||||||
|
|
||||||
|
남은 두 항목은 모두 P3 입니다. J-1(`MAM_MIN_PANE_COLS=0` 이 60 으로 덮임)은 문서화되지 않은 경계값에서만 나타나는 좁은 회귀이고, J-2(성장 가드의 판별 불가 케이스)는 **제 계획의 뮤테이션 명세 오류**로서 구현 책임이 아닙니다. 어느 쪽도 현재 동작을 해치지 않고 결함을 가리지도 않으므로 차단하지 않으며, 각각 한 줄 수정으로 다음 커밋에 정리하면 충분합니다.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Cross-Code Review — Job 6f18ba0f
|
||||||
|
|
||||||
|
- **Reviewer**: cline (session: herdr:canary-projects-multi-agent-mux-creator-cline)
|
||||||
|
- **Subject**: Backlog items I-2 (headless fast-path timing contract) and I-3 (PaneInfo.focused cleanup, `--max-cols`/`max_columns_reached` coherence, headless anchor comment refinement), plus the C-1 headless max-columns guard.
|
||||||
|
- **Changeset**: `git diff` — `lib_py/layout.py` (69 lines), `tests/test_layout.py` (75 lines, +3 tests), `tests/test_b19_headless_reconcile_fixes.py` (25 lines), `.mam.env.example` (14 lines), `IMPROVEMENTS.md` (3 lines). `lib.sh` is **not** modified (verified: layout block unchanged at :432).
|
||||||
|
- **Date**: 2026-08-23
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §0 Executive Summary
|
||||||
|
|
||||||
|
The changeset cleanly addresses both backlog items and a related headless max-columns defect (C-1). I-2 adds a contractual wall-clock upper bound to the headless fast-path test, with a precise rationale for why a timing assertion is the *only* signal that catches that particular regression. I-3 removes the unused/non-deterministic `PaneInfo.focused` field (with a clear determinism rationale), renames the extractor accordingly, wires `MAM_MAX_PANE_COLS`/`MAM_MAX_COLS` through a graceful `_env_int` helper, and refines the headless anchor comments. The C-1 fix makes headless mode honor `max_columns` on the column-opening (`right`) branch while deliberately leaving the column-filling (`down`) branch uncapped — mirroring the GUI path, and documented as such.
|
||||||
|
|
||||||
|
I verified the API rename introduces no orphan importers, ran the directly-affected suites (layout 19/19, b19 6/6, herdr_shim_contract 5/5 — all pass), and confirmed `lib.sh`'s layout invocation is untouched. No lint, behavioral, or missing-coverage defects found.
|
||||||
|
|
||||||
|
**Verdict: PASS.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §1 I-2 — Headless fast-path timing contract (verified)
|
||||||
|
|
||||||
|
`tests/test_b19_headless_reconcile_fixes.py::test_bug4_headless_unobservable_fast_path`:
|
||||||
|
- Adds `import time` and an `elapsed < 5.0` assertion with a failure message that names the exact regression (`SKS_EMPTY_GIVEUP` early exit removed → full 10s quiescence window consumed). The docstring justifies the bound empirically (1.22s with the optimization vs 10.21s without) and explains why functional assertions alone cannot detect the regression. This is a well-reasoned contractual guard, not a flaky nicety. ✅
|
||||||
|
- Strips `SKS_QUIESCENT_TRIES`/`SKS_QUIESCENT_INTERVAL`/`SKS_EMPTY_GIVEUP` from the subprocess env so lib.sh defaults apply cleanly — making the timing assertion reproducible regardless of the caller's shell env. ✅
|
||||||
|
- The mock's `paste-buffer` branch had its early `return 0` removed; control now falls through to the final `return 0` (line 161) with no intervening branch — **functionally identical** (both return 0), a harmless no-op cleanup. ✅
|
||||||
|
- **Result**: 6/6 b19 tests pass in 4.43s; the fast-path test itself runs well under the 5.0s bound (no flakiness margin concern). ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §2 I-3 — layout.py cleanup & max-cols coherence (verified)
|
||||||
|
|
||||||
|
### PaneInfo.focused removal
|
||||||
|
- The `focused: bool = False` field is deleted and replaced with a NOTE comment: the engine is deliberately geometry/structure-driven so identical pane sets yield identical decisions; focus is user-interaction state that would make results non-deterministic. This is the correct call for a layout engine and the rationale is documented inline. ✅
|
||||||
|
- `extract_panes_and_focus` → `extract_panes`, now returning `List[PaneInfo]` only; all `focused_id` extraction logic removed. Docstring updated to enumerate the three accepted payload shapes. ✅
|
||||||
|
- **Orphan check**: `grep` for `extract_panes_and_focus` / `PaneInfo` / `extract_panes` importers across `.agents` and `tests` → **NONE**. The remaining `focused_pane_id` occurrences (conftest.py:297/315, test_layout.py:177) are **herdr payload data** (herdr 0.8 emits that field), which the engine now correctly ignores — not symbol references. No breakage. ✅
|
||||||
|
|
||||||
|
### --max-cols / max_columns_reached coherence
|
||||||
|
- New `_env_int(*names)` helper reads the first non-empty env var among its arguments, parsing as int and **returning None on bad values** (a typo won't crash the layout call; lib.sh's `|| echo "right …"` fallback still applies). Used for `--min-cols`, `--min-rows`, and `--max-cols` defaults. ✅
|
||||||
|
- `--max-cols` default changed from `None` to `_env_int("MAM_MAX_COLS", "MAM_MAX_PANE_COLS")` — so the column cap is honored **without** a CLI flag, which is exactly how `lib.sh` invokes the module (it passes no `--max-cols`). This is the key behavioral fix. ✅
|
||||||
|
- C-1: the headless even-`n` branch now computes `current_cols = n // 2` and returns `overflow`/`max_columns_reached` when `current_cols >= max_columns`. The odd-`n` `down` branch deliberately ignores the cap (it fills an existing column, never opens one) — mirroring the GUI `fill_singleton_column` path, with an inline comment stating this. Coherent and symmetric with the GUI path. ✅
|
||||||
|
|
||||||
|
### Headless anchor comment refinement
|
||||||
|
- The terse alternation comment was replaced with a detailed explanation of why `n // 2` is the completed-column count under the alternation invariant, and how an odd-`n` drift self-corrects at the next even `n`. Directly satisfies the "refine comments regarding headless anchor fallback" requirement. ✅
|
||||||
|
|
||||||
|
### lib.sh (I-3 scope)
|
||||||
|
- `lib.sh` is unmodified in this changeset (diff stat confirms; `python3 -m lib_py.layout` still at :432). The lib.sh-facing concern — that the env-var path works without a `--max-cols` flag — is covered by `test_env_max_cols_applies_without_flag`. No lib.sh edit is needed. ✅
|
||||||
|
---
|
||||||
|
|
||||||
|
## §3 Test Coverage & DoD
|
||||||
|
|
||||||
|
**New layout tests** (`tests/test_layout.py`, +3, total 19, all PASS in 0.21s):
|
||||||
|
- `test_cli_max_cols_flag_triggers_overflow` — CLI `--max-cols 2` reaches `compute_2xk_layout` and yields `overflow` / `max_columns_reached` on a 4-pane/2-column payload. ✅
|
||||||
|
- `test_env_max_cols_applies_without_flag` — `MAM_MAX_PANE_COLS=2` is honoured with **no** `--max-cols` flag (the lib.sh invocation shape); asserts `max_columns_reached`. ✅
|
||||||
|
- `test_headless_max_columns_growth_guard` — C-1: headless n=4/max=2 → `overflow`; n=2/max=2 → `right` (grows below cap); n=3/max=2 → `down` (fill not blocked); n=4 no cap → `right` (behavior neutrality). Comprehensive. ✅
|
||||||
|
|
||||||
|
**b19 suite** (`tests/test_b19_headless_reconcile_fixes.py`, 6/6 PASS in 4.43s) — I-2 timing contract holds.
|
||||||
|
|
||||||
|
**Shim contract** (`tests/test_herdr_shim_contract.py`, 5/5 PASS in 1.77s) — integration intact after the API rename.
|
||||||
|
|
||||||
|
**Broader suite**: the e2e/tier3-4 files are slow (subprocess-heavy, exceed the 30s run-window). I confirmed in the prior review cycle that `test_tier1_unit` (45), `test_sanity` + `test_deploy_freshness` (33), and `test_herdr_shim_contract` (5) pass, and — critically — a `grep` for importers of `PaneInfo` / `extract_panes` / `extract_panes_and_focus` across `.agents` and `tests` returns **NONE**, so the API rename cannot regress any other suite. No regression risk from this changeset's surface change.
|
||||||
|
|
||||||
|
**Total confirmed passing this cycle: 30 tests (19 layout + 6 b19 + 5 shim-contract), 0 failures.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §4 Soundness & Cleanup
|
||||||
|
|
||||||
|
- **No orphan references**: removed/renamed symbols have zero importers; remaining `focused_pane_id` strings are payload data, correctly ignored.
|
||||||
|
- **`lib.sh` untouched**: the prior G-1 fix (`python3 -m lib_py.layout` at :432) is preserved; no regression to the integration.
|
||||||
|
- **Env wiring documented**: `.mam.env.example` documents `MAM_MIN_PANE_COLS`/`MAM_MIN_PANE_ROWS`/`MAM_MAX_PANE_COLS` with defaults and the overflow semantics; `IMPROVEMENTS.md` records I-2/I-3/C-1 completion and updated test counts.
|
||||||
|
- **Graceful degradation**: `_env_int` returns `None` on bad values rather than raising; combined with lib.sh's `|| echo "right $sample_pane"` fallback, a malformed env var degrades to a safe default instead of crashing the layout call.
|
||||||
|
- **`Tuple` import** removed (no longer needed after the return-type simplification). No unused imports remain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §5 Minor Observations (non-blocking)
|
||||||
|
|
||||||
|
1. **`_env_int` behavior change for min-cols/min-rows on bad env values**: previously `int(bad_value)` would raise (crash → lib.sh fallback to `right`); now it returns `None` → falls back to the 60/20 default. This is a robustness improvement and the docstring states the rationale, but it is a subtle behavior change worth being aware of (a typo no longer surfaces as a hard failure). Acceptable and intentional.
|
||||||
|
2. **b19 mock `return 0` removal** in the `paste-buffer` branch is a pure no-op (falls through to the identical final `return 0`). Harmless, though its presence in the diff adds minor noise with no behavioral effect. Cosmetic.
|
||||||
|
3. **Broader e2e/tier3-4 suites** were not re-run this cycle due to the 30s run-window; the orphan-importer check substantiates that the API rename cannot affect them, but a full `pytest tests/` in an unbounded environment would be the strongest DoD signal. Not a blocker.
|
||||||
|
|
||||||
|
None of the above warrant a NOT PASS or a planner escalation. They are notes for future polish only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §6 Verdict
|
||||||
|
|
||||||
|
Both backlog items (I-2, I-3) and the related C-1 headless max-columns defect are correctly and coherently addressed. The unused/non-deterministic `focused` field is removed with documented rationale, the `--max-cols`/env wiring is clean and tested on both CLI and env paths, headless mode now honors the column cap symmetrically with the GUI path, the fast-path timing is contractually guarded, and 30 directly-relevant tests pass with zero orphan references to the renamed API.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# Cross-Code Review — Job 8f0cb35f
|
||||||
|
|
||||||
|
- **Reviewer**: cline (session: herdr:canary-projects-multi-agent-mux-creator-cline)
|
||||||
|
- **Subject**: Final implementation of the right-growth 2xK grid TUI layout engine (`.agents/skills/lib_py/layout.py`) + `lib.sh` integration, including the changeset that resolves the G-1/G-2 findings from the prior review (job `71741b21`).
|
||||||
|
- **Changeset**: `git diff` — `lib.sh` (layout block refactor, 30 deletions / 4 additions), new `lib_py/layout.py` (199 lines), new `tests/test_layout.py` (333 lines, 16 tests).
|
||||||
|
- **Date**: 2026-08-23
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §0 Executive Summary
|
||||||
|
|
||||||
|
The changeset fully and correctly resolves every finding raised in the prior review cycle (F-1, F-2, F-3, G-1, G-2). The critical regression — the shim invoking the undefined `_delegate_py_bin` bash function, which silently bypassed the layout engine — is eliminated: the layout block now calls `python3 -m lib_py.layout` directly, exactly as the brief required. I verified the fix at three independent levels (source diff, real generated shim artifact, and an empirical `set -euo pipefail` reproduction) and ran the relevant test suites (99 tests across 5 files, all passing).
|
||||||
|
|
||||||
|
The layout engine itself is a clean, pure-stdlib implementation covering the full 2xK transition graph (1->2 ... 5->6), overflow, and headless 0x0 mode. The legacy ~30-line inline Python snippet was removed cleanly with no orphaned references.
|
||||||
|
|
||||||
|
**Verdict: PASS.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §1 Prior-Finding Resolution (all verified fixed)
|
||||||
|
|
||||||
|
### G-1 CRITICAL -> FIXED (root cause eliminated)
|
||||||
|
- **Prior root cause**: The fix in job `71741b21` bridged the shim heredoc to `_delegate_py_bin()` — a bash function defined *outside* the heredoc (lib.sh:1379) and not `export -f`'d — so the standalone shim subprocess hit `command not found`, silently falling back to `right` (engine bypassed).
|
||||||
|
- **Fix**: `lib.sh:432` now invokes the engine as a real module:
|
||||||
|
```
|
||||||
|
read -r split_dir split_target < <(printf '%s' "$layout_raw" | python3 -m lib_py.layout --min-cols "${MAM_MIN_PANE_COLS:-60}" --min-rows "${MAM_MIN_PANE_ROWS:-20}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane")
|
||||||
|
```
|
||||||
|
No bash function is referenced; `python3` (present on PATH, parity with the 17 other `python3 -c` calls in the heredoc) runs the module directly.
|
||||||
|
- **Verification**:
|
||||||
|
1. *Source*: `grep` of the edited block (lib.sh:428-435) -> no `_delegate_py_bin`, no `local`, no `PYTHONPATH=` prefix.
|
||||||
|
2. *Real generated shim* (`$WORKSPACE_ROOT/.mam/shim/herdr`): `grep -c "python3 -m lib_py.layout"` = **1**; `grep -c "_delegate_py_bin"` = **0**; no `local layout_`/`local split_`.
|
||||||
|
3. *Empirical reproduction* (simulated shim, `set -euo pipefail`, no `_delegate_py_bin` in scope): output `split_dir=down split_target=p1`, exit 0, empty stderr — the engine executes and yields the correct direction, with **no `command not found`**.
|
||||||
|
|
||||||
|
### G-2 MAJOR -> FIXED (false-positive test removed)
|
||||||
|
- **Prior issue**: `test_lib_sh_layout_split_in_set_e_subshell` (job `71741b21`) defined `_delegate_py_bin` in its own script, masking G-1 (14/14 pass while the live path was broken).
|
||||||
|
- **Fix**: The test (now at test_layout.py:265) no longer references `_delegate_py_bin`; it runs the exact lib.sh:429-435 snippet verbatim with `python3 -m lib_py.layout` and asserts `SPLIT_DIR=down` / `SAMPLE_PANE=p1` under `set -euo pipefail`. A *real generated shim* integration test (`test_real_generated_shim_layout_split`, line 301) was added that sources `lib.sh`, calls `_init_herdr_isolation`, and inspects the **real artifact** (not heredoc text) for executability and absence of `local layout_`.
|
||||||
|
|
||||||
|
### F-1 -> still FIXED
|
||||||
|
- No `local` keyword in the layout block (plain assignments). Static guard `test_lib_sh_no_local_in_shim_heredoc` (checks `"local "` absent from an 800-char window of the heredoc) plus the real-shim grep guard both present. Real shim grep -> none.
|
||||||
|
|
||||||
|
### F-2 -> still FIXED
|
||||||
|
- No `PYTHONPATH=...` command-prefix. The invocation relies on the `export PYTHONPATH` (lib.sh:25) inherited by the shim subprocess. Confirmed empirically: the module loads under the inherited `PYTHONPATH` and emits `down`.
|
||||||
|
|
||||||
|
### F-3 -> still FIXED
|
||||||
|
- Single `python3 -m lib_py.layout` process, output parsed once by `read -r split_dir split_target`. No double-invocation / double-parse.
|
||||||
|
---
|
||||||
|
|
||||||
|
## §2 Test Coverage & DoD
|
||||||
|
|
||||||
|
**Layout unit/integration suite** (`tests/test_layout.py`, 16 tests, 0.16s) — all PASS:
|
||||||
|
- 1->2 split down; height-constrained -> right; width overflow
|
||||||
|
- 2->3 new column right; 3->4 fill singleton down; 4->5 new column right; 5->6 fill 3rd-col singleton down
|
||||||
|
- 4-panes overflow; max-columns limit; headless 0x0 (count-N alternation)
|
||||||
|
- real-herdr 0.80 nested format; CLI pipe contract (`<dir> <pane>`)
|
||||||
|
- no-`local` static guard; malformed/empty fallback
|
||||||
|
- set-e subshell (F-1/F-2/G-1 live snippet); real generated shim (G-2 artifact inspection)
|
||||||
|
|
||||||
|
**Broader suite** (DoD #4 — sampled; the e2e/tier3-4 files are slow/subprocess-heavy and exceed the 30s run-window; sampled the relevant contracts):
|
||||||
|
- `tests/test_layout.py` -> 16 passed
|
||||||
|
- `tests/test_tier1_unit.py` -> 45 passed
|
||||||
|
- `tests/test_sanity.py` + `tests/test_deploy_freshness.py` -> 33 passed
|
||||||
|
- `tests/test_herdr_shim_contract.py` -> 5 passed
|
||||||
|
|
||||||
|
**Total confirmed passing: 99 tests across 5 files, 0 failures.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §3 Soundness & Cleanup
|
||||||
|
|
||||||
|
- **`layout.py`** (199 lines): pure stdlib (`dataclasses`, `typing`, `json`, `sys`, `os`, `argparse`) — no external dependency, so `python3` on PATH suffices (consistent with the other 17 `python3 -c` heredoc calls).
|
||||||
|
- **CLI contract**: emits `<direction> <target_pane_id>` (or `<direction>`), parsed by the single `read -r` — contract aligned with the integration.
|
||||||
|
- **Cleanup**: orphan scan of the heredoc (lib.sh:142-907) -> no `_delegate_py_bin`, no legacy `MAM_MIN_COLS=`/`MAM_MIN_ROWS=` env-prefix style; the old ~30-line inline snippet was deleted cleanly (no dangling comments/variables).
|
||||||
|
- **Fallback safety net**: `|| echo "right $sample_pane"` + `${split_dir:-right}` preserve graceful degradation if the module ever fails to load, without aborting under `set -e`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §4 Minor Observations (non-blocking)
|
||||||
|
|
||||||
|
1. **`test_real_generated_shim_layout_split` docstring vs. body**: the docstring claims to verify the shim "executes layout.py without command not found", but the body only checks (a) the shim is generated & executable and (b) no `local layout_` appears — it does not run the shim's `new-session` layout path end-to-end. This is adequately compensated by `test_lib_sh_layout_split_in_set_e_subshell`, which runs the exact snippet live and asserts `down`. Recommend aligning the docstring with what the test actually asserts, or adding an end-to-end shim execution step. (Cosmetic/coverage, not a defect.)
|
||||||
|
2. **Real-shim grep pattern** `'^[[:space:]]*local layout_'` is narrower than the heredoc-text test's broad `"local "` check; it would not catch a hypothetical `local split_target`. The two guards together cover the keyword, so this is acceptable. Slightly tightening the pattern to `'^[[:space:]]*local '` would be more robust.
|
||||||
|
3. **PYTHONPATH inheritance dependency**: the shim relies on `export PYTHONPATH` (lib.sh:25) being inherited by the subprocess. This holds whenever the shim is invoked via `mam_herdr` from a context that sourced `lib.sh` (the intended call path) and was confirmed empirically. No regression vs. the prior design; noted for completeness.
|
||||||
|
|
||||||
|
None of the above warrant a NOT PASS verdict or a planner escalation. They are improvement opportunities only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## §5 Verdict
|
||||||
|
|
||||||
|
All findings from the prior review are resolved, the implementation meets the brief's four objectives (algorithm, integration, tests, DoD), the cleanup is complete, and 99 sampled tests pass with the layout engine empirically confirmed to execute in the real shim context.
|
||||||
|
|
||||||
|
[VERDICT: PASS]
|
||||||
+56
-60
@@ -428,36 +428,10 @@ except Exception:
|
|||||||
|
|
||||||
split_dir=""
|
split_dir=""
|
||||||
if [ -n "$sample_pane" ]; then
|
if [ -n "$sample_pane" ]; then
|
||||||
split_dir=$(_real_herdr pane layout --pane "$sample_pane" 2>/dev/null | MAM_MIN_COLS="${MAM_MIN_PANE_COLS:-60}" MAM_MIN_ROWS="${MAM_MIN_PANE_ROWS:-20}" python3 -c "
|
layout_raw=$(_real_herdr pane layout --pane "$sample_pane" 2>/dev/null || echo "")
|
||||||
import sys, json, os
|
read -r split_dir split_target < <(printf '%s' "$layout_raw" | python3 -m lib_py.layout --min-cols "${MAM_MIN_PANE_COLS:-60}" --min-rows "${MAM_MIN_PANE_ROWS:-20}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane")
|
||||||
min_cols = int(os.environ.get('MAM_MIN_COLS', 60))
|
split_dir="${split_dir:-right}"
|
||||||
min_rows = int(os.environ.get('MAM_MIN_ROWS', 20))
|
sample_pane="${split_target:-$sample_pane}"
|
||||||
try:
|
|
||||||
d = json.loads(sys.stdin.read()).get('result', {})
|
|
||||||
focused_id = d.get('focused_pane_id', '')
|
|
||||||
panes = d.get('panes', [])
|
|
||||||
anchor = None
|
|
||||||
for p in panes:
|
|
||||||
if p.get('pane_id') == focused_id:
|
|
||||||
anchor = p.get('rect', {})
|
|
||||||
break
|
|
||||||
if not anchor and panes:
|
|
||||||
anchor = panes[0].get('rect', {})
|
|
||||||
if anchor:
|
|
||||||
w = anchor.get('width', 0)
|
|
||||||
h = anchor.get('height', 0)
|
|
||||||
if w <= 0 or h <= 0:
|
|
||||||
# Headless or detached session with unmeasured/zero dimensions
|
|
||||||
print('right')
|
|
||||||
elif w // 2 >= min_cols:
|
|
||||||
print('right')
|
|
||||||
elif h // 2 >= min_rows:
|
|
||||||
print('down')
|
|
||||||
else:
|
|
||||||
print('overflow')
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
" 2>/dev/null || echo "")
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ "$split_dir" = "right" ] || [ "$split_dir" = "down" ]; then
|
if [ "$split_dir" = "right" ] || [ "$split_dir" = "down" ]; then
|
||||||
@@ -1601,18 +1575,31 @@ _wait_session_gone() {
|
|||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# _pane_quiescent <sess> [tries=20] [interval=0.5]
|
# _pane_quiescent <sess> [tries=20] [interval=0.5] # empty_giveup: $SKS_EMPTY_GIVEUP (default: 3)
|
||||||
# Renderer settled = two consecutive identical non-empty captures.
|
# Renderer settled = two consecutive identical non-empty captures.
|
||||||
# Defeats RC-A (Blessed/Ink renderer bottleneck) without a magic fixed sleep.
|
# Defeats RC-A (Blessed/Ink renderer bottleneck) without a magic fixed sleep.
|
||||||
|
# Returns 0 if renderer settled (two identical non-empty captures).
|
||||||
|
# Returns 2 if unobservable/headless (consecutive empty captures reached empty_giveup without output).
|
||||||
|
# Returns 1 if output was observed but never stabilized within tries limit.
|
||||||
_pane_quiescent() {
|
_pane_quiescent() {
|
||||||
local sess="$1" tries="${2:-20}" interval="${3:-0.5}" prev="__none__" cur i
|
local sess="$1" tries="${2:-20}" interval="${3:-0.5}" prev="__none__" cur i
|
||||||
|
local saw_output=0 empty_streak=0
|
||||||
|
local empty_giveup="${SKS_EMPTY_GIVEUP:-3}"
|
||||||
for ((i = 0; i < tries; i++)); do
|
for ((i = 0; i < tries; i++)); do
|
||||||
cur=$(_pane_capture "$sess")
|
cur=$(_pane_capture "$sess")
|
||||||
[ -z "$cur" ] && { sleep "$interval"; continue; }
|
if [ -z "$cur" ]; then
|
||||||
|
empty_streak=$((empty_streak + 1))
|
||||||
|
[ "$saw_output" = "0" ] && [ "$empty_streak" -ge "$empty_giveup" ] && return 2
|
||||||
|
sleep "$interval"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
saw_output=1
|
||||||
|
empty_streak=0
|
||||||
[ "$cur" = "$prev" ] && return 0
|
[ "$cur" = "$prev" ] && return 0
|
||||||
prev="$cur"
|
prev="$cur"
|
||||||
sleep "$interval"
|
sleep "$interval"
|
||||||
done
|
done
|
||||||
|
[ "$saw_output" = "0" ] && return 2
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1625,17 +1612,50 @@ _pane_dialog_open() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# send_keys_safe <sess> <text> [job_id]
|
# send_keys_safe <sess> <text> [job_id]
|
||||||
# 1. Wait for renderer quiescence (RC-A).
|
# 1. Wait for renderer quiescence (RC-A). If unobservable (headless), bypass visual checks.
|
||||||
# 2. Refuse to paste while a dialog is open (RC-B/RC-C): wait up to
|
# 2. Refuse to paste while a dialog is open (RC-B/RC-C): wait up to
|
||||||
# SKS_DIALOG_TIMEOUT (default 30 s); if SKS_DIALOG_ESCAPE=1, send a single
|
# SKS_DIALOG_TIMEOUT (default 30 s); if SKS_DIALOG_ESCAPE=1, send a single
|
||||||
# Escape per poll and re-check. NEVER a blind Enter.
|
# Escape per poll and re-check. NEVER a blind Enter.
|
||||||
# 3. Paste via unique buffer; verify the text landed (marker visible).
|
# 3. Native herdr 0.8+ RPC fast path: agent prompt handles atomic text + enter submission.
|
||||||
# 4. Submit C-m; verify submission (marker left the input area AND the pane
|
# 4. Paste via unique buffer; verify the text landed (marker visible).
|
||||||
|
# 5. Submit C-m; verify submission (marker left the input area AND the pane
|
||||||
# changed); retry up to 3 times.
|
# changed); retry up to 3 times.
|
||||||
send_keys_safe() {
|
send_keys_safe() {
|
||||||
local sess="$1" text="$2" job_id="${3:-adhoc}"
|
local sess="$1" text="$2" job_id="${3:-adhoc}"
|
||||||
|
local pre_submit deadline try
|
||||||
|
|
||||||
local marker pre_submit deadline try
|
local _q_rc=0
|
||||||
|
_pane_quiescent "$sess" "${SKS_QUIESCENT_TRIES:-20}" "${SKS_QUIESCENT_INTERVAL:-0.5}" || _q_rc=$?
|
||||||
|
if [ "$_q_rc" = "1" ]; then
|
||||||
|
echo "send_keys_safe: pane never quiesced ($sess)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$_q_rc" != "2" ]; then
|
||||||
|
deadline=$(( $(date +%s) + ${SKS_DIALOG_TIMEOUT:-30} ))
|
||||||
|
while _pane_dialog_open "$sess"; do
|
||||||
|
if [ "${SKS_DIALOG_ESCAPE:-0}" = "1" ]; then
|
||||||
|
_sks_herdr send-keys -t "$sess" Escape
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||||
|
echo "send_keys_safe: dialog blocking input ($sess)" >&2
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
local agent_target
|
||||||
|
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
||||||
|
# Native herdr 0.8+ fast path: agent prompt handles atomic text + enter submission
|
||||||
|
# Gated behind quiescence and dialog checks; returns 0 on RPC success to prevent duplicate input
|
||||||
|
if _sks_herdr agent prompt "$agent_target" "$text" >/dev/null 2>&1 || _sks_herdr agent prompt "$sess" "$text" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback: paste buffer submission. Compute verification markers on demand.
|
||||||
|
local marker marker_norm
|
||||||
# Verification token: last 24 *characters* (not bytes — `tail -c` can split a
|
# Verification token: last 24 *characters* (not bytes — `tail -c` can split a
|
||||||
# multi-byte UTF-8 char, e.g. Korean, producing a marker that can never match
|
# multi-byte UTF-8 char, e.g. Korean, producing a marker that can never match
|
||||||
# the properly-decoded rendered pane text) of the last non-empty line.
|
# the properly-decoded rendered pane text) of the last non-empty line.
|
||||||
@@ -1646,32 +1666,8 @@ send_keys_safe() {
|
|||||||
# only '\n' still leaves an extra space that breaks an exact literal match.
|
# only '\n' still leaves an extra space that breaks an exact literal match.
|
||||||
# Matching with all whitespace collapsed out sidesteps wrap formatting
|
# Matching with all whitespace collapsed out sidesteps wrap formatting
|
||||||
# entirely, whatever shape it takes.
|
# entirely, whatever shape it takes.
|
||||||
local marker_norm
|
|
||||||
marker_norm=$(printf '%s' "$marker" | tr -d '[:space:]')
|
marker_norm=$(printf '%s' "$marker" | tr -d '[:space:]')
|
||||||
|
|
||||||
_pane_quiescent "$sess" || { echo "send_keys_safe: pane never quiesced ($sess)" >&2; return 1; }
|
|
||||||
|
|
||||||
deadline=$(( $(date +%s) + ${SKS_DIALOG_TIMEOUT:-30} ))
|
|
||||||
while _pane_dialog_open "$sess"; do
|
|
||||||
if [ "${SKS_DIALOG_ESCAPE:-0}" = "1" ]; then
|
|
||||||
_sks_herdr send-keys -t "$sess" Escape
|
|
||||||
sleep 1
|
|
||||||
fi
|
|
||||||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
|
||||||
echo "send_keys_safe: dialog blocking input ($sess)" >&2
|
|
||||||
return 2
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
local agent_target
|
|
||||||
agent_target=$(_sanitize_herdr_agent_name "$sess")
|
|
||||||
# Native herdr 0.8+ fast path: agent prompt handles atomic text + enter submission
|
|
||||||
# Gated behind quiescence and dialog checks; returns 0 on RPC success to prevent duplicate input
|
|
||||||
if _sks_herdr agent prompt "$agent_target" "$text" >/dev/null 2>&1 || _sks_herdr agent prompt "$sess" "$text" >/dev/null 2>&1; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
local sks_buf="sks_${sess}_${job_id}_$$_${RANDOM}_$(date +%s%N 2>/dev/null || date +%s)"
|
local sks_buf="sks_${sess}_${job_id}_$$_${RANDOM}_$(date +%s%N 2>/dev/null || date +%s)"
|
||||||
_sks_herdr set-buffer -b "$sks_buf" "$text"
|
_sks_herdr set-buffer -b "$sks_buf" "$text"
|
||||||
_sks_herdr paste-buffer -b "$sks_buf" -t "$sess"
|
_sks_herdr paste-buffer -b "$sks_buf" -t "$sess"
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
"""
|
||||||
|
.agents/skills/lib_py/layout.py
|
||||||
|
Shared 2xK grid TUI layout engine for multi-agent workspaces.
|
||||||
|
"""
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List, Dict, Optional, Any
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PaneInfo:
|
||||||
|
pane_id: str
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
# NOTE: no `focused` field. The 2xK engine is deliberately geometry- and
|
||||||
|
# structure-driven so that identical pane sets always yield identical
|
||||||
|
# decisions. Focus is user-interaction state and would make the result
|
||||||
|
# non-deterministic; herdr still reports it in the payload if ever needed.
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LayoutDecision:
|
||||||
|
target_pane_id: str
|
||||||
|
direction: str # 'right' | 'down' | 'overflow'
|
||||||
|
is_overflow: bool = False
|
||||||
|
reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def extract_panes(data: Dict[str, Any]) -> List[PaneInfo]:
|
||||||
|
"""Extracts list of PaneInfo from herdr layout JSON payload.
|
||||||
|
|
||||||
|
Accepts all three shapes herdr 0.8 emits: result.layout.panes,
|
||||||
|
result.panes, and a bare top-level panes array.
|
||||||
|
"""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return []
|
||||||
|
|
||||||
|
res = data.get("result", {})
|
||||||
|
if not isinstance(res, dict):
|
||||||
|
res = {}
|
||||||
|
|
||||||
|
layout = res.get("layout", {})
|
||||||
|
if isinstance(layout, dict) and "panes" in layout:
|
||||||
|
raw_panes = layout.get("panes", [])
|
||||||
|
else:
|
||||||
|
raw_panes = res.get("panes", []) or data.get("panes", [])
|
||||||
|
|
||||||
|
panes: List[PaneInfo] = []
|
||||||
|
for p in raw_panes:
|
||||||
|
if not isinstance(p, dict):
|
||||||
|
continue
|
||||||
|
pid = str(p.get("pane_id", ""))
|
||||||
|
rect = p.get("rect", {})
|
||||||
|
if not isinstance(rect, dict):
|
||||||
|
rect = {}
|
||||||
|
x = int(rect.get("x", 0))
|
||||||
|
y = int(rect.get("y", 0))
|
||||||
|
w = int(rect.get("width", 0))
|
||||||
|
h = int(rect.get("height", 0))
|
||||||
|
if pid:
|
||||||
|
panes.append(PaneInfo(pane_id=pid, x=x, y=y, width=w, height=h))
|
||||||
|
|
||||||
|
return panes
|
||||||
|
|
||||||
|
|
||||||
|
def compute_2xk_layout(
|
||||||
|
data: Dict[str, Any],
|
||||||
|
min_cols: int = 60,
|
||||||
|
min_rows: int = 20,
|
||||||
|
max_columns: Optional[int] = None,
|
||||||
|
default_anchor_id: Optional[str] = None
|
||||||
|
) -> LayoutDecision:
|
||||||
|
"""
|
||||||
|
Computes optimal target pane and direction to maintain a balanced 2xK grid.
|
||||||
|
Only uses Herdr-supported split directions: 'right' and 'down'.
|
||||||
|
"""
|
||||||
|
panes = extract_panes(data)
|
||||||
|
|
||||||
|
if not panes:
|
||||||
|
# no_panes_default: when herdr returns no panes (empty workspace), default to 'right'
|
||||||
|
target = default_anchor_id or ""
|
||||||
|
return LayoutDecision(target_pane_id=target, direction="right", is_overflow=False, reason="no_panes_default")
|
||||||
|
|
||||||
|
# If only 1 pane in workspace
|
||||||
|
if len(panes) == 1:
|
||||||
|
p = panes[0]
|
||||||
|
# In 2xK grid, 1 pane -> 2 panes: split down to create top and bottom rows
|
||||||
|
# Check height overflow if dimensions known
|
||||||
|
if p.height > 0 and p.height // 2 < min_rows:
|
||||||
|
# If height is too small for 2 rows, try splitting right if width allows
|
||||||
|
if p.width > 0 and p.width // 2 >= min_cols:
|
||||||
|
return LayoutDecision(target_pane_id=p.pane_id, direction="right", reason="single_pane_height_constrained")
|
||||||
|
elif p.width > 0 and p.width // 2 < min_cols:
|
||||||
|
return LayoutDecision(target_pane_id=p.pane_id, direction="overflow", is_overflow=True, reason="single_pane_overflow")
|
||||||
|
else:
|
||||||
|
return LayoutDecision(target_pane_id=p.pane_id, direction="right", reason="single_pane_height_constrained_unknown_width")
|
||||||
|
return LayoutDecision(target_pane_id=p.pane_id, direction="down", reason="single_pane_split_down")
|
||||||
|
|
||||||
|
# Check for Headless mode: all panes have width <= 0 or height <= 0
|
||||||
|
is_headless = all(p.width <= 0 or p.height <= 0 for p in panes)
|
||||||
|
if is_headless:
|
||||||
|
# Headless panes are all 0x0, so columns cannot be counted from geometry
|
||||||
|
# the way the GUI path does. The alternation below (odd -> down,
|
||||||
|
# even -> right) is what builds the grid, so while that invariant holds
|
||||||
|
# the completed-column count is exactly n // 2. If panes were closed and
|
||||||
|
# the shape drifted, an odd n is absorbed by the `down` branch and the
|
||||||
|
# estimate self-corrects at the next even n.
|
||||||
|
n = len(panes)
|
||||||
|
anchor = default_anchor_id or panes[-1].pane_id
|
||||||
|
if n % 2 == 1:
|
||||||
|
# Filling an existing column never opens a new one, so max_columns is
|
||||||
|
# deliberately NOT checked here -- this mirrors the GUI path, where
|
||||||
|
# `fill_singleton_column` also ignores the cap. max_columns is a
|
||||||
|
# growth guard, not an invariant over the existing layout.
|
||||||
|
return LayoutDecision(target_pane_id=anchor, direction="down", reason="headless_odd_down")
|
||||||
|
current_cols = n // 2
|
||||||
|
if max_columns and current_cols >= max_columns:
|
||||||
|
return LayoutDecision(target_pane_id=anchor, direction="overflow",
|
||||||
|
is_overflow=True, reason="max_columns_reached")
|
||||||
|
return LayoutDecision(target_pane_id=anchor, direction="right", reason="headless_even_right")
|
||||||
|
|
||||||
|
# Geometry-aware column grouping
|
||||||
|
# Group panes into columns by X coordinate (fuzz threshold 2 cols)
|
||||||
|
sorted_by_x = sorted(panes, key=lambda p: (p.x, p.y))
|
||||||
|
columns: List[List[PaneInfo]] = []
|
||||||
|
for p in sorted_by_x:
|
||||||
|
matched_col = False
|
||||||
|
for col in columns:
|
||||||
|
if abs(col[0].x - p.x) <= 2:
|
||||||
|
col.append(p)
|
||||||
|
matched_col = True
|
||||||
|
break
|
||||||
|
if not matched_col:
|
||||||
|
columns.append([p])
|
||||||
|
|
||||||
|
# Sort each column's panes by Y coordinate (top to bottom)
|
||||||
|
for col in columns:
|
||||||
|
col.sort(key=lambda p: p.y)
|
||||||
|
|
||||||
|
num_cols = len(columns)
|
||||||
|
|
||||||
|
# 1. Check for any singleton column (column with only 1 pane spanning full height)
|
||||||
|
singleton_col = None
|
||||||
|
for col in columns:
|
||||||
|
if len(col) == 1:
|
||||||
|
singleton_col = col
|
||||||
|
break
|
||||||
|
|
||||||
|
if singleton_col is not None:
|
||||||
|
target_p = singleton_col[0]
|
||||||
|
# Check height
|
||||||
|
if target_p.height > 0 and target_p.height // 2 < min_rows:
|
||||||
|
return LayoutDecision(target_pane_id=target_p.pane_id, direction="overflow", is_overflow=True, reason="singleton_height_overflow")
|
||||||
|
return LayoutDecision(target_pane_id=target_p.pane_id, direction="down", reason="fill_singleton_column")
|
||||||
|
|
||||||
|
# 2. All existing columns have 2 (or more) panes -> we need to start a NEW column to the right
|
||||||
|
if max_columns and num_cols >= max_columns:
|
||||||
|
return LayoutDecision(target_pane_id=columns[-1][0].pane_id, direction="overflow", is_overflow=True, reason="max_columns_reached")
|
||||||
|
|
||||||
|
# Target the top pane of the rightmost column to split right
|
||||||
|
rightmost_top_pane = columns[-1][0]
|
||||||
|
|
||||||
|
# Check width constraint on the rightmost column
|
||||||
|
if rightmost_top_pane.width > 0 and rightmost_top_pane.width // 2 < min_cols:
|
||||||
|
return LayoutDecision(target_pane_id=rightmost_top_pane.pane_id, direction="overflow", is_overflow=True, reason="column_width_overflow")
|
||||||
|
|
||||||
|
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')."""
|
||||||
|
for n in names:
|
||||||
|
raw = os.environ.get(n, "").strip()
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
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("--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")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
raw_input = sys.stdin.read().strip()
|
||||||
|
data = {}
|
||||||
|
if raw_input:
|
||||||
|
try:
|
||||||
|
data = json.loads(raw_input)
|
||||||
|
except Exception:
|
||||||
|
data = {}
|
||||||
|
|
||||||
|
decision = compute_2xk_layout(
|
||||||
|
data=data,
|
||||||
|
min_cols=args.min_cols,
|
||||||
|
min_rows=args.min_rows,
|
||||||
|
max_columns=args.max_cols,
|
||||||
|
default_anchor_id=args.sample_pane
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps({
|
||||||
|
"target_pane_id": decision.target_pane_id,
|
||||||
|
"direction": decision.direction,
|
||||||
|
"is_overflow": decision.is_overflow,
|
||||||
|
"reason": decision.reason
|
||||||
|
}))
|
||||||
|
else:
|
||||||
|
if decision.target_pane_id:
|
||||||
|
print(f"{decision.direction} {decision.target_pane_id}")
|
||||||
|
else:
|
||||||
|
print(decision.direction)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
SKILLS_DIR="$(cd "$SCRIPT_DIR/../.." 2>/dev/null || pwd)"
|
SKILLS_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
LIB_SH="$SKILLS_DIR/lib.sh"
|
LIB_SH="$SKILLS_DIR/lib.sh"
|
||||||
[ -f "$LIB_SH" ] || LIB_SH="${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh"
|
[ -f "$LIB_SH" ] || LIB_SH="${WORKSPACE_ROOT:-$PWD}/.agents/skills/lib.sh"
|
||||||
source "$LIB_SH"
|
source "$LIB_SH"
|
||||||
@@ -326,10 +326,11 @@ yaml_path = os.environ['YAML_PATH']
|
|||||||
home = os.environ['HOME_DIR']
|
home = os.environ['HOME_DIR']
|
||||||
skills_dir = os.environ.get('SKILLS_DIR', '')
|
skills_dir = os.environ.get('SKILLS_DIR', '')
|
||||||
if not skills_dir:
|
if not skills_dir:
|
||||||
_ws_root = os.environ.get('WORKSPACE_ROOT')
|
_ws_root = os.environ.get('WORKSPACE_ROOT', '')
|
||||||
if not _ws_root:
|
if _ws_root:
|
||||||
_ws_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..'))
|
skills_dir = os.path.join(_ws_root, '.agents/skills')
|
||||||
skills_dir = os.path.join(_ws_root, '.agents/skills')
|
else:
|
||||||
|
skills_dir = ''
|
||||||
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
|
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
|
||||||
|
|
||||||
now_iso = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
now_iso = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||||
@@ -339,12 +340,16 @@ now_iso = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
|
|||||||
# atomic_dump_yaml predefines `d` -- so drift C's pin raised
|
# atomic_dump_yaml predefines `d` -- so drift C's pin raised
|
||||||
# NameError: name 'lib_sh' is not defined and aborted the whole sweep,
|
# NameError: name 'lib_sh' is not defined and aborted the whole sweep,
|
||||||
# in write mode only.
|
# in write mode only.
|
||||||
lib_sh = os.environ.get('LIB_SH')
|
lib_sh = os.environ.get('LIB_SH', '')
|
||||||
if not lib_sh:
|
if not lib_sh:
|
||||||
_ws_root = os.environ.get('WORKSPACE_ROOT')
|
if skills_dir:
|
||||||
if not _ws_root:
|
lib_sh = os.path.join(skills_dir, 'lib.sh')
|
||||||
_ws_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..'))
|
else:
|
||||||
lib_sh = os.path.join(_ws_root, '.agents/skills/lib.sh')
|
_ws_root = os.environ.get('WORKSPACE_ROOT', '')
|
||||||
|
if _ws_root:
|
||||||
|
lib_sh = os.path.join(_ws_root, '.agents/skills/lib.sh')
|
||||||
|
else:
|
||||||
|
lib_sh = ''
|
||||||
|
|
||||||
try:
|
try:
|
||||||
d
|
d
|
||||||
|
|||||||
@@ -116,6 +116,32 @@
|
|||||||
#default: <cwd>/.mam/delegate_job_logs
|
#default: <cwd>/.mam/delegate_job_logs
|
||||||
# DELEGATE_JOB_LOGS_DIR=/path/to/workspace/.mam/delegate_job_logs
|
# DELEGATE_JOB_LOGS_DIR=/path/to/workspace/.mam/delegate_job_logs
|
||||||
|
|
||||||
|
# Max attempts to poll for pane renderer quiescence in send_keys_safe.
|
||||||
|
#default: 20
|
||||||
|
# SKS_QUIESCENT_TRIES=20
|
||||||
|
|
||||||
|
# Interval (seconds) between pane quiescence capture polls.
|
||||||
|
#default: 0.5
|
||||||
|
# SKS_QUIESCENT_INTERVAL=0.5
|
||||||
|
|
||||||
|
# Consecutive empty captures to conclude unobservable/headless mode early.
|
||||||
|
#default: 3
|
||||||
|
# SKS_EMPTY_GIVEUP=3
|
||||||
|
|
||||||
|
# Minimum columns a pane must retain after a vertical split (2xK layout engine).
|
||||||
|
#default: 60
|
||||||
|
# MAM_MIN_PANE_COLS=60
|
||||||
|
|
||||||
|
# Minimum rows a pane must retain after a horizontal split (2xK layout engine).
|
||||||
|
#default: 20
|
||||||
|
# MAM_MIN_PANE_ROWS=20
|
||||||
|
|
||||||
|
# Maximum number of columns a workspace may grow to before the engine reports
|
||||||
|
# 'overflow' (which makes lib.sh create a fresh workspace instead of splitting).
|
||||||
|
# Applies to both measured (GUI) and headless 0x0 layouts.
|
||||||
|
#default: (unset -> no column cap)
|
||||||
|
# MAM_MAX_PANE_COLS=3
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# deploy / distribution source (for forks/mirrors)
|
# deploy / distribution source (for forks/mirrors)
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|||||||
+19
-9
@@ -1,9 +1,9 @@
|
|||||||
# 🛠️ Multi-Agent Mux 종합 개선 및 미해결 과제 백로그 (`IMPROVEMENTS.md`)
|
# 🛠️ Multi-Agent Mux 종합 개선 및 미해결 과제 백로그 (`IMPROVEMENTS.md`)
|
||||||
|
|
||||||
- **최종 갱신일**: 2026-08-23 (`nats-docker` 서브모듈 분리, B-19 헤드리스 레이아웃/리컨사일/Fast-path 게이팅 개선, 37/37 신규 가드 통과 유지)
|
- **최종 갱신일**: 2026-08-23 (`nats-docker` 서브모듈 분리, B-20 2×K 그리드 TUI 레이아웃 엔진 lib_py/layout.py 공용화 및 lib.sh 인라인 정리 완료)
|
||||||
- **통합 관리 대상**: 기존 `CODEBASE_REVIEW_REPORT.md` + `OPTIMIZATION.md` + `NATS_REPORT.md`
|
- **통합 관리 대상**: 기존 `CODEBASE_REVIEW_REPORT.md` + `OPTIMIZATION.md` + `NATS_REPORT.md`
|
||||||
- **총 추적 미해결 과제**: **5건** (아키텍처 1건: `A-2`, 엣지케이스 및 가용성 3건: `B-16`, `B-17`, `B-18`, 오케스트레이션 1건: `O-5`)
|
- **총 추적 미해결 과제**: **5건** (아키텍처 1건: `A-2`, 엣지케이스 및 가용성 3건: `B-16`, `B-17`, `B-18`, 오케스트레이션 1건: `O-5`)
|
||||||
- **완료된 과제**: **28건** (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, 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)
|
- **완료된 과제**: **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)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -25,17 +25,27 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2. 🟠 엣지 케이스 및 런타임 버그 (Edge-case Bugs — 4건 / 완료 3건)
|
## 2. 🟠 엣지 케이스 및 런타임 버그 (Edge-case Bugs — 5건 / 완료 4건)
|
||||||
|
|
||||||
|
### **B-20 (✅ 완료 — 2×K 그리드 TUI 레이아웃 엔진 `lib_py/layout.py` 공용화 및 `lib.sh` 인라인 레거시 정리)**
|
||||||
|
- **현상**:
|
||||||
|
- 기존 `lib.sh`에 ~30줄 이상의 인라인 Python 계산 스니펫이 하드코딩되어 있어, 헤드리스 모드 및 에이전트 수 증가에 따른 패널 배치가 비결정적이고 단위 테스트가 불가능했음.
|
||||||
|
- Herdr 0.8.0 CLI가 `left`/`up` 방향을 지원하지 않고 `right`/`down`만 지원하는 제약에 부합하는 레이아웃 알고리즘 부재.
|
||||||
|
- **조치 결과 (완료)**:
|
||||||
|
- `.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% 통과).
|
||||||
|
|
||||||
### **B-19 (✅ 완료 — 헤드리스 분할 레이아웃 0×0 예외 처리, reconcile SKILLS_DIR 누락 및 Fast-path 게이팅 보완)**
|
### **B-19 (✅ 완료 — 헤드리스 분할 레이아웃 0×0 예외 처리, reconcile SKILLS_DIR 누락 및 Fast-path 게이팅 보완)**
|
||||||
- **현상**:
|
- **현상**:
|
||||||
1. `lib.sh:449` 헤드리스 환경에서 `herdr pane layout`이 `0×0`을 반환할 때 `overflow`로 오판정되어 새 워크스페이스(`w1, w2, w3`)가 계속 증식하던 결함.
|
1. `lib.sh` 헤드리스 환경에서 `herdr pane layout`이 `0×0`을 반환할 때 `overflow`로 오판정되어 새 워크스페이스(`w1, w2, w3`)가 계속 증식하던 결함 (후속 B-20 2×K 그리드 엔진으로 완전 승계 및 공용화).
|
||||||
2. `reconcile.sh:814, 816`에서 `SKILLS_DIR`을 전달하지 않아 Python 내에서 상대 경로 조립 실패(`resume dry-run failed: No such file or directory`)가 기록되던 결함.
|
2. `reconcile.sh:19`에서 `SKILLS_DIR` 명령 치환 오류(`2>/dev/null || pwd`)로 빈 문자열이 되어 Python 내 상대 경로 조립 실패(`resume dry-run failed: No such file or directory`)가 유발되던 결함.
|
||||||
3. `lib.sh:1667` `send_keys_safe`에서 `herdr agent prompt` Fast-path가 렌더러 안정화 및 다이얼로그 체크 이전에 실행되거나 실패 시 중복 입력이 발생할 수 있던 결함.
|
3. `lib.sh:1620` `send_keys_safe`에서 `herdr agent prompt` Fast-path가 다이얼로그 체크 없이 실행되거나 헤드리스/비표시 상태에서 정숙성 루프가 불필요하게 10초 대기/실패하던 결함.
|
||||||
- **조치 결과 (완료)**:
|
- **조치 결과 (완료)**:
|
||||||
- `lib.sh`: `w <= 0 or h <= 0`인 헤드리스 상태일 때 기본 `'right'` 분할 적용. `send_keys_safe`의 Fast-path를 안정화/다이얼로그 확인 후로 배치하고 단일 성공 즉시 `return 0` 처리.
|
- `lib.sh`: B-20 공용 엔진을 통해 헤드리스 0×0 결정론적 분할 적용. `_pane_quiescent`에 `SKS_EMPTY_GIVEUP`(기본 3회) 연속 공백 감지 시 조기 `rc=2`(관측 불가, ~1.5초 소요) 탈출을 도입하고, 관측 가능한 페인은 20×0.5s(10초) 정숙성 윈도를 보존. `send_keys_safe`가 `rc=2`일 때 시각 다이얼로그 루프를 건너뛰고 RPC Fast-path로 직행하도록 최적화. RPC 성공 즉시 `return 0` 반환하여 중복 입력 방지 및 온디맨드 마커 계산 적용.
|
||||||
- `reconcile.sh`: `env_python` 및 `atomic_dump_yaml` 실행 시 `SKILLS_DIR="$SKILLS_DIR"` 명시 주입 및 3중 fallback 경로 추가.
|
- `reconcile.sh`: `SKILLS_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"`로 절대 경로 즉시 계산 및 `env_python`/`atomic_dump_yaml`로 명시 주입, Python 측 `__file__` 의존성 제거.
|
||||||
- 회귀 가드: `tests/test_b19_headless_reconcile_fixes.py` (4개 테스트 신규 작성 및 100% 통과).
|
- 회귀 가드: `tests/test_b19_headless_reconcile_fixes.py` (6개 기능/통합 테스트 100% 통과).
|
||||||
|
|
||||||
### **B-14 (✅ 완료 — F-1 / P1): `publish_event.py` 브로커 장애 시 `return 2` 조기 탈출로 인한 65분 루프 정지**
|
### **B-14 (✅ 완료 — F-1 / P1): `publish_event.py` 브로커 장애 시 `return 2` 조기 탈출로 인한 65분 루프 정지**
|
||||||
- **현상**: `publish_event.py`에서 브로커 네트워크 장애 발생 시 `return 2`로 조기 종료되어, 뒤따르는 로컬 레지스트리 상태(`update_job_status(status=completed)`) 및 감사 로그(`append_event`, `registry.append_event`) 갱신이 누락되던 결함.
|
- **현상**: `publish_event.py`에서 브로커 네트워크 장애 발생 시 `return 2`로 조기 종료되어, 뒤따르는 로컬 레지스트리 상태(`update_job_status(status=completed)`) 및 감사 로그(`append_event`, `registry.append_event`) 갱신이 누락되던 결함.
|
||||||
|
|||||||
+1
-1
@@ -130,7 +130,7 @@ $ bash .agents/skills/multi-agent-mux-orc-onboard/scripts/orc_onboard.sh --remov
|
|||||||
### 7) 전용 NATS 메시징 브로커 설정 (.mam.env)
|
### 7) 전용 NATS 메시징 브로커 설정 (.mam.env)
|
||||||
MAM은 비동기 작업 위임(`multi-agent-mux-delegate-job`) 및 이벤트 스트림 중계를 위해 MQTT 3.1.1 및 JetStream 기반의 사설 NATS 브로커(`nats-docker`)를 표준으로 지원합니다.
|
MAM은 비동기 작업 위임(`multi-agent-mux-delegate-job`) 및 이벤트 스트림 중계를 위해 MQTT 3.1.1 및 JetStream 기반의 사설 NATS 브로커(`nats-docker`)를 표준으로 지원합니다.
|
||||||
* **환경 설정 생성**: `bash deploy/generate-env.sh` (또는 `cp .mam.env.example .mam.env`)를 실행하여 로컬 `.mam.env`를 생성합니다.
|
* **환경 설정 생성**: `bash deploy/generate-env.sh` (또는 `cp .mam.env.example .mam.env`)를 실행하여 로컬 `.mam.env`를 생성합니다.
|
||||||
* **서브모듈 동기화**: `git submodule update --init --recursive` 명령어로 `nats-docker/` 배포 자산을 초기화합니다.
|
* **서브모듈 동기화**: `git submodule update --init --recursive` 명령어로 `nats-docker/` 배포 자산을 초기화합니다. (사내 비공개 저장소 `laa/nats-docker` 접근 권한이 없는 경우 서브모듈 동기화를 생략해도 표준 MQTT 브로커를 통해 기본 프레임워크 기능이 완비됩니다.)
|
||||||
* **사설 서버 배포 가이드**: 자세한 도커 배포 및 Tailscale 연동 절차는 [`nats-docker/PRIVATE_SERVER.md`](../nats-docker/PRIVATE_SERVER.md) 및 [`MESSAGING.md`](../MESSAGING.md)를 참조하십시오.
|
* **사설 서버 배포 가이드**: 자세한 도커 배포 및 Tailscale 연동 절차는 [`nats-docker/PRIVATE_SERVER.md`](../nats-docker/PRIVATE_SERVER.md) 및 [`MESSAGING.md`](../MESSAGING.md)를 참조하십시오.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+4
-1
@@ -72,13 +72,16 @@ To register these skills globally or for a specific workspace:
|
|||||||
For production deployments and private networks, MAM utilizes a dedicated NATS broker (`nats:2.12-alpine` with MQTT 3.1.1 and JetStream enabled). The container assets and deployment guides are managed in the `nats-docker` submodule:
|
For production deployments and private networks, MAM utilizes a dedicated NATS broker (`nats:2.12-alpine` with MQTT 3.1.1 and JetStream enabled). The container assets and deployment guides are managed in the `nats-docker` submodule:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# When cloning the repository, initialize submodules:
|
# When cloning the repository with internal credentials:
|
||||||
git clone --recurse-submodules https://git.godopu.com/tmpl/multi-agent-mux.git
|
git clone --recurse-submodules https://git.godopu.com/tmpl/multi-agent-mux.git
|
||||||
|
|
||||||
# Or initialize submodules in an existing clone:
|
# Or initialize submodules in an existing clone:
|
||||||
git submodule update --init --recursive
|
git submodule update --init --recursive
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> `nats-docker` is an optional submodule hosted in the private repository `laa/nats-docker`. If cloning without internal credentials, omit `--recurse-submodules`. The MAM framework functions out-of-the-box using standard MQTT brokers configured in `.mam.env`.
|
||||||
|
|
||||||
Refer to [`nats-docker/PRIVATE_SERVER.md`](../nats-docker/PRIVATE_SERVER.md) and [`MESSAGING.md`](../MESSAGING.md) for detailed configuration, `.mam.env` generation, and security guidelines.
|
Refer to [`nats-docker/PRIVATE_SERVER.md`](../nats-docker/PRIVATE_SERVER.md) and [`MESSAGING.md`](../MESSAGING.md) for detailed configuration, `.mam.env` generation, and security guidelines.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -2,62 +2,41 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from lib_py.layout import compute_2xk_layout
|
||||||
|
|
||||||
|
|
||||||
def test_bug2_headless_layout_does_not_overflow():
|
def test_bug2_headless_layout_does_not_overflow():
|
||||||
"""Verify Bug 2: w=0, h=0 in headless mode outputs 'right' (not 'overflow')."""
|
"""Verify Bug 2: w=0, h=0 in headless mode does not trigger overflow."""
|
||||||
calc_script = """
|
|
||||||
import sys, json, os
|
|
||||||
min_cols = int(os.environ.get('MAM_MIN_COLS', 60))
|
|
||||||
min_rows = int(os.environ.get('MAM_MIN_ROWS', 20))
|
|
||||||
try:
|
|
||||||
d = json.loads(sys.stdin.read()).get('result', {})
|
|
||||||
focused_id = d.get('focused_pane_id', '')
|
|
||||||
panes = d.get('panes', [])
|
|
||||||
anchor = None
|
|
||||||
for p in panes:
|
|
||||||
if p.get('pane_id') == focused_id:
|
|
||||||
anchor = p.get('rect', {})
|
|
||||||
break
|
|
||||||
if not anchor and panes:
|
|
||||||
anchor = panes[0].get('rect', {})
|
|
||||||
if anchor:
|
|
||||||
w = anchor.get('width', 0)
|
|
||||||
h = anchor.get('height', 0)
|
|
||||||
if w <= 0 or h <= 0:
|
|
||||||
print('right')
|
|
||||||
elif w // 2 >= min_cols:
|
|
||||||
print('right')
|
|
||||||
elif h // 2 >= min_rows:
|
|
||||||
print('down')
|
|
||||||
else:
|
|
||||||
print('overflow')
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
"""
|
|
||||||
# 1. Headless 0x0
|
# 1. Headless 0x0
|
||||||
payload_0x0 = json.dumps({"result": {"panes": [{"rect": {"width": 0, "height": 0}}]}})
|
payload_0x0 = {"result": {"panes": [{"pane_id": "p1", "rect": {"width": 0, "height": 0}}]}}
|
||||||
res = subprocess.run([sys.executable, "-c", calc_script], input=payload_0x0, capture_output=True, text=True)
|
d_0x0 = compute_2xk_layout(payload_0x0)
|
||||||
assert res.stdout.strip() == "right", f"Headless 0x0 should default to 'right', got {res.stdout.strip()}"
|
assert not d_0x0.is_overflow, f"Headless 0x0 should not overflow, got {d_0x0}"
|
||||||
|
assert d_0x0.direction in ("right", "down"), f"Headless 0x0 direction must be right or down, got {d_0x0.direction}"
|
||||||
|
|
||||||
# 2. Genuine small pane (overflow)
|
# 2. Genuine small pane (overflow)
|
||||||
payload_small = json.dumps({"result": {"panes": [{"rect": {"width": 50, "height": 30}}]}})
|
payload_small = {"result": {"panes": [{"pane_id": "p1", "rect": {"width": 50, "height": 30}}]}}
|
||||||
res = subprocess.run([sys.executable, "-c", calc_script], input=payload_small, capture_output=True, text=True)
|
d_small = compute_2xk_layout(payload_small)
|
||||||
assert res.stdout.strip() == "overflow", f"Small pane should be 'overflow', got {res.stdout.strip()}"
|
assert d_small.is_overflow, f"Small pane should be overflow, got {d_small}"
|
||||||
|
assert d_small.direction == "overflow"
|
||||||
|
|
||||||
# 3. Wide pane (split right)
|
# 3. Wide pane (split right)
|
||||||
payload_wide = json.dumps({"result": {"panes": [{"rect": {"width": 160, "height": 30}}]}})
|
payload_wide = {"result": {"panes": [{"pane_id": "p1", "rect": {"width": 160, "height": 30}}]}}
|
||||||
res = subprocess.run([sys.executable, "-c", calc_script], input=payload_wide, capture_output=True, text=True)
|
d_wide = compute_2xk_layout(payload_wide)
|
||||||
assert res.stdout.strip() == "right", f"Wide pane should be 'right', got {res.stdout.strip()}"
|
assert not d_wide.is_overflow
|
||||||
|
assert d_wide.direction == "right"
|
||||||
|
|
||||||
# 4. Tall pane (split down)
|
# 4. Tall pane (split down)
|
||||||
payload_tall = json.dumps({"result": {"panes": [{"rect": {"width": 80, "height": 60}}]}})
|
payload_tall = {"result": {"panes": [{"pane_id": "p1", "rect": {"width": 80, "height": 60}}]}}
|
||||||
res = subprocess.run([sys.executable, "-c", calc_script], input=payload_tall, capture_output=True, text=True)
|
d_tall = compute_2xk_layout(payload_tall)
|
||||||
assert res.stdout.strip() == "down", f"Tall pane should be 'down', got {res.stdout.strip()}"
|
assert not d_tall.is_overflow
|
||||||
|
assert d_tall.direction == "down"
|
||||||
|
|
||||||
|
|
||||||
def test_bug3_reconcile_skills_dir_passed_and_fallback():
|
def test_bug3_reconcile_skills_dir_passed_and_fallback():
|
||||||
"""Verify Bug 3: reconcile.sh passes SKILLS_DIR to env_python/atomic_dump_yaml and RECON_SRC has fallback."""
|
"""Verify Bug 3: reconcile.sh evaluates valid SKILLS_DIR and passes it to Python subshells."""
|
||||||
recon_path = os.path.abspath(".agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh")
|
recon_path = os.path.abspath(".agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh")
|
||||||
with open(recon_path, "r", encoding="utf-8") as f:
|
with open(recon_path, "r", encoding="utf-8") as f:
|
||||||
content = f.read()
|
content = f.read()
|
||||||
@@ -66,9 +45,32 @@ def test_bug3_reconcile_skills_dir_passed_and_fallback():
|
|||||||
assert 'SKILLS_DIR="$SKILLS_DIR" LIB_SH="$LIB_SH" env_python' in content
|
assert 'SKILLS_DIR="$SKILLS_DIR" LIB_SH="$LIB_SH" env_python' in content
|
||||||
assert 'SKILLS_DIR="$SKILLS_DIR" LIB_SH="$LIB_SH" atomic_dump_yaml' in content
|
assert 'SKILLS_DIR="$SKILLS_DIR" LIB_SH="$LIB_SH" atomic_dump_yaml' in content
|
||||||
|
|
||||||
# Assert fallback exists inside RECON_SRC
|
# Read the actual line from reconcile.sh and verify it uses && pwd instead of || pwd
|
||||||
assert "if not skills_dir:" in content
|
line19 = next(l for l in content.splitlines() if l.startswith("SKILLS_DIR="))
|
||||||
assert "skills_dir = os.path.join(_ws_root, '.agents/skills')" in content
|
assert "&& pwd" in line19 and "|| pwd" not in line19, f"Invalid SKILLS_DIR evaluation: {line19}"
|
||||||
|
|
||||||
|
# Functionally evaluate that exact line from reconcile.sh in bash
|
||||||
|
script = f"""#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
SCRIPT_DIR="$(dirname "{recon_path}")"
|
||||||
|
{line19}
|
||||||
|
echo "RESOLVED_SKILLS_DIR=$SKILLS_DIR"
|
||||||
|
if [ -z "$SKILLS_DIR" ]; then
|
||||||
|
echo "ERROR: SKILLS_DIR is empty" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -d "$SKILLS_DIR" ]; then
|
||||||
|
echo "ERROR: directory does not exist" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ ! -f "$SKILLS_DIR/lib.sh" ]; then
|
||||||
|
echo "ERROR: lib.sh missing" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
"""
|
||||||
|
res = subprocess.run(["bash", "-c", script], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0, f"Script failed: {res.stderr}"
|
||||||
|
assert "RESOLVED_SKILLS_DIR=" in res.stdout
|
||||||
|
|
||||||
|
|
||||||
def test_bug4_send_keys_safe_gating_order():
|
def test_bug4_send_keys_safe_gating_order():
|
||||||
@@ -89,7 +91,7 @@ def test_bug4_send_keys_safe_gating_order():
|
|||||||
assert dialog_idx != -1, "_pane_dialog_open not found in send_keys_safe"
|
assert dialog_idx != -1, "_pane_dialog_open not found in send_keys_safe"
|
||||||
assert prompt_idx != -1, "agent prompt not found in send_keys_safe"
|
assert prompt_idx != -1, "agent prompt not found in send_keys_safe"
|
||||||
|
|
||||||
# Crucial ordering check: quiescence and dialog checks MUST precede agent prompt
|
# Ordering check: quiescence and dialog checks MUST precede agent prompt
|
||||||
assert quiescent_idx < prompt_idx, "_pane_quiescent must execute before agent prompt fast-path"
|
assert quiescent_idx < prompt_idx, "_pane_quiescent must execute before agent prompt fast-path"
|
||||||
assert dialog_idx < prompt_idx, "_pane_dialog_open must execute before agent prompt fast-path"
|
assert dialog_idx < prompt_idx, "_pane_dialog_open must execute before agent prompt fast-path"
|
||||||
|
|
||||||
@@ -126,3 +128,126 @@ echo "SUCCESS"
|
|||||||
assert res.returncode == 0, f"Expected clean exit 0 without duplicate paste-buffer call, got {res.returncode}. Stderr: {res.stderr}"
|
assert res.returncode == 0, f"Expected clean exit 0 without duplicate paste-buffer call, got {res.returncode}. Stderr: {res.stderr}"
|
||||||
assert "SUCCESS" in res.stdout
|
assert "SUCCESS" in res.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_bug4_headless_unobservable_fast_path(tmp_path):
|
||||||
|
"""Verify Bug 4 / R-1 + I-2: in headless mode where capture-pane is empty,
|
||||||
|
send_keys_safe bypasses dialogs and succeeds immediately via the RPC fast-path.
|
||||||
|
|
||||||
|
The elapsed-time bound is a contract, not a nicety: removing the
|
||||||
|
SKS_EMPTY_GIVEUP early exit leaves every functional assertion green and only
|
||||||
|
changes the wall clock (measured 1.22s -> 10.21s), so this is the sole
|
||||||
|
assertion that can detect that regression.
|
||||||
|
"""
|
||||||
|
test_script = f"""#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
SKILL_DIR="{os.path.abspath('.agents/skills')}"
|
||||||
|
source "$SKILL_DIR/lib.sh"
|
||||||
|
|
||||||
|
PROMPT_CALLED=0
|
||||||
|
PASTE_CALLED=0
|
||||||
|
_sks_herdr() {{
|
||||||
|
if [ "${{1:-}}" = "capture-pane" ]; then
|
||||||
|
# Headless / unobservable pane returns empty output
|
||||||
|
echo ""
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "${{1:-}}" = "agent" ] && [ "${{2:-}}" = "prompt" ]; then
|
||||||
|
PROMPT_CALLED=1
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "${{1:-}}" = "paste-buffer" ]; then
|
||||||
|
PASTE_CALLED=1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}}
|
||||||
|
|
||||||
|
# Run send_keys_safe without stubbing _pane_quiescent
|
||||||
|
send_keys_safe "headless-sess" "my prompt" "job-headless"
|
||||||
|
if [ "$PROMPT_CALLED" != "1" ]; then
|
||||||
|
echo "ERROR: agent prompt was not called in headless mode" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$PASTE_CALLED" = "1" ]; then
|
||||||
|
echo "ERROR: paste-buffer was called unexpectedly" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "HEADLESS_OK"
|
||||||
|
"""
|
||||||
|
# Remove SKS_* from env so lib.sh defaults apply cleanly
|
||||||
|
env = {k: v for k, v in os.environ.items()
|
||||||
|
if k not in ("SKS_QUIESCENT_TRIES", "SKS_QUIESCENT_INTERVAL", "SKS_EMPTY_GIVEUP")}
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
res = subprocess.run(["bash", "-c", test_script], capture_output=True, text=True, env=env)
|
||||||
|
elapsed = time.perf_counter() - t0
|
||||||
|
|
||||||
|
assert res.returncode == 0, f"Headless send_keys_safe failed: {res.stderr}"
|
||||||
|
assert "HEADLESS_OK" in res.stdout
|
||||||
|
assert elapsed < 5.0, (
|
||||||
|
f"headless fast-path took {elapsed:.2f}s (limit 5.0s) — the "
|
||||||
|
f"SKS_EMPTY_GIVEUP early exit in _pane_quiescent is likely gone; "
|
||||||
|
f"the full 10s quiescence window was consumed instead"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bug4_slow_settling_pane_success(tmp_path):
|
||||||
|
"""Verify N-1 / G-2: a pane that takes 3 seconds of changing output to settle stabilizes cleanly and executes RPC prompt."""
|
||||||
|
count_file = str(tmp_path / "capture_count.txt")
|
||||||
|
with open(count_file, "w") as f:
|
||||||
|
f.write("0")
|
||||||
|
|
||||||
|
prompt_flag = str(tmp_path / "prompt_called.txt")
|
||||||
|
paste_flag = str(tmp_path / "paste_called.txt")
|
||||||
|
|
||||||
|
test_script = f"""#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
SKILL_DIR="{os.path.abspath('.agents/skills')}"
|
||||||
|
source "$SKILL_DIR/lib.sh"
|
||||||
|
|
||||||
|
COUNT_FILE="{count_file}"
|
||||||
|
PROMPT_FLAG="{prompt_flag}"
|
||||||
|
PASTE_FLAG="{paste_flag}"
|
||||||
|
|
||||||
|
_sks_herdr() {{
|
||||||
|
if [ "${{1:-}}" = "capture-pane" ]; then
|
||||||
|
local c
|
||||||
|
c=$(cat "$COUNT_FILE" 2>/dev/null || echo "0")
|
||||||
|
c=$((c + 1))
|
||||||
|
echo "$c" > "$COUNT_FILE"
|
||||||
|
# Change for first 5 captures (2.5s), then stabilize
|
||||||
|
if [ "$c" -le 5 ]; then
|
||||||
|
echo "Rendering frame $c..."
|
||||||
|
else
|
||||||
|
echo "Stable Idle Screen"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "${{1:-}}" = "agent" ] && [ "${{2:-}}" = "prompt" ]; then
|
||||||
|
touch "$PROMPT_FLAG"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "${{1:-}}" = "paste-buffer" ]; then
|
||||||
|
touch "$PASTE_FLAG"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}}
|
||||||
|
|
||||||
|
# Run send_keys_safe on slow-settling pane with default 20x0.5 window
|
||||||
|
send_keys_safe "slow-sess" "my prompt" "job-slow"
|
||||||
|
if [ ! -f "$PROMPT_FLAG" ]; then
|
||||||
|
echo "ERROR: agent prompt was not called on slow-settling pane" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -f "$PASTE_FLAG" ]; then
|
||||||
|
echo "ERROR: paste-buffer was called unexpectedly" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "SLOW_SETTLE_OK"
|
||||||
|
"""
|
||||||
|
res = subprocess.run(["bash", "-c", test_script], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0, f"Slow settling pane failed: {res.stderr}"
|
||||||
|
assert "SLOW_SETTLE_OK" in res.stdout
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from lib_py.layout import (
|
||||||
|
compute_2xk_layout,
|
||||||
|
LayoutDecision,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_or_malformed_json_fallback():
|
||||||
|
d = compute_2xk_layout({}, default_anchor_id="pane-123")
|
||||||
|
assert d.target_pane_id == "pane-123"
|
||||||
|
assert d.direction == "right"
|
||||||
|
assert not d.is_overflow
|
||||||
|
|
||||||
|
|
||||||
|
def test_1_pane_split_down():
|
||||||
|
payload = {
|
||||||
|
"result": {
|
||||||
|
"panes": [
|
||||||
|
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 120, "height": 80}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d = compute_2xk_layout(payload, min_cols=60, min_rows=20)
|
||||||
|
assert d.target_pane_id == "p1"
|
||||||
|
assert d.direction == "down"
|
||||||
|
assert not d.is_overflow
|
||||||
|
|
||||||
|
|
||||||
|
def test_1_pane_height_constrained_splits_right():
|
||||||
|
payload = {
|
||||||
|
"result": {
|
||||||
|
"panes": [
|
||||||
|
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 160, "height": 30}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d = compute_2xk_layout(payload, min_cols=60, min_rows=20)
|
||||||
|
assert d.target_pane_id == "p1"
|
||||||
|
assert d.direction == "right"
|
||||||
|
assert not d.is_overflow
|
||||||
|
|
||||||
|
|
||||||
|
def test_1_pane_overflow():
|
||||||
|
payload = {
|
||||||
|
"result": {
|
||||||
|
"panes": [
|
||||||
|
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 50, "height": 30}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d = compute_2xk_layout(payload, min_cols=60, min_rows=20)
|
||||||
|
assert d.target_pane_id == "p1"
|
||||||
|
assert d.direction == "overflow"
|
||||||
|
assert d.is_overflow
|
||||||
|
|
||||||
|
|
||||||
|
def test_2_panes_to_3_panes_new_column_right():
|
||||||
|
payload = {
|
||||||
|
"result": {
|
||||||
|
"panes": [
|
||||||
|
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 160, "height": 40}},
|
||||||
|
{"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 160, "height": 40}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d = compute_2xk_layout(payload, min_cols=60, min_rows=20)
|
||||||
|
assert d.target_pane_id == "p1"
|
||||||
|
assert d.direction == "right"
|
||||||
|
assert not d.is_overflow
|
||||||
|
|
||||||
|
|
||||||
|
def test_3_panes_to_4_panes_fill_singleton():
|
||||||
|
payload = {
|
||||||
|
"result": {
|
||||||
|
"panes": [
|
||||||
|
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 80, "height": 40}},
|
||||||
|
{"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 80, "height": 40}},
|
||||||
|
{"pane_id": "p3", "rect": {"x": 80, "y": 0, "width": 80, "height": 80}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d = compute_2xk_layout(payload, min_cols=60, min_rows=20)
|
||||||
|
assert d.target_pane_id == "p3"
|
||||||
|
assert d.direction == "down"
|
||||||
|
assert not d.is_overflow
|
||||||
|
|
||||||
|
|
||||||
|
def test_4_panes_to_5_panes_new_column():
|
||||||
|
payload = {
|
||||||
|
"result": {
|
||||||
|
"panes": [
|
||||||
|
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 120, "height": 40}},
|
||||||
|
{"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 120, "height": 40}},
|
||||||
|
{"pane_id": "p3", "rect": {"x": 120, "y": 0, "width": 120, "height": 40}},
|
||||||
|
{"pane_id": "p4", "rect": {"x": 120, "y": 40, "width": 120, "height": 40}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d = compute_2xk_layout(payload, min_cols=60, min_rows=20)
|
||||||
|
assert d.target_pane_id == "p3"
|
||||||
|
assert d.direction == "right"
|
||||||
|
assert not d.is_overflow
|
||||||
|
|
||||||
|
|
||||||
|
def test_4_panes_overflow_when_width_constrained():
|
||||||
|
payload = {
|
||||||
|
"result": {
|
||||||
|
"panes": [
|
||||||
|
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 60, "height": 40}},
|
||||||
|
{"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 60, "height": 40}},
|
||||||
|
{"pane_id": "p3", "rect": {"x": 60, "y": 0, "width": 60, "height": 40}},
|
||||||
|
{"pane_id": "p4", "rect": {"x": 60, "y": 40, "width": 60, "height": 40}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d = compute_2xk_layout(payload, min_cols=60, min_rows=20)
|
||||||
|
assert d.direction == "overflow"
|
||||||
|
assert d.is_overflow
|
||||||
|
|
||||||
|
|
||||||
|
def test_max_columns_limit():
|
||||||
|
payload = {
|
||||||
|
"result": {
|
||||||
|
"panes": [
|
||||||
|
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 100, "height": 40}},
|
||||||
|
{"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 100, "height": 40}},
|
||||||
|
{"pane_id": "p3", "rect": {"x": 100, "y": 0, "width": 100, "height": 40}},
|
||||||
|
{"pane_id": "p4", "rect": {"x": 100, "y": 40, "width": 100, "height": 40}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d = compute_2xk_layout(payload, min_cols=30, min_rows=20, max_columns=2)
|
||||||
|
assert d.direction == "overflow"
|
||||||
|
assert d.is_overflow
|
||||||
|
|
||||||
|
|
||||||
|
def test_headless_0x0_transitions():
|
||||||
|
# N=1 -> down
|
||||||
|
p1 = {"result": {"panes": [{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}}]}}
|
||||||
|
assert compute_2xk_layout(p1).direction == "down"
|
||||||
|
|
||||||
|
# N=2 -> right
|
||||||
|
p2 = {"result": {"panes": [
|
||||||
|
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}},
|
||||||
|
{"pane_id": "p2", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}}
|
||||||
|
]}}
|
||||||
|
assert compute_2xk_layout(p2).direction == "right"
|
||||||
|
|
||||||
|
# N=3 -> down
|
||||||
|
p3 = {"result": {"panes": [
|
||||||
|
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}},
|
||||||
|
{"pane_id": "p2", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}},
|
||||||
|
{"pane_id": "p3", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}}
|
||||||
|
]}}
|
||||||
|
assert compute_2xk_layout(p3).direction == "down"
|
||||||
|
|
||||||
|
# N=4 -> right
|
||||||
|
p4 = {"result": {"panes": [
|
||||||
|
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}},
|
||||||
|
{"pane_id": "p2", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}},
|
||||||
|
{"pane_id": "p3", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}},
|
||||||
|
{"pane_id": "p4", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}}
|
||||||
|
]}}
|
||||||
|
assert compute_2xk_layout(p4).direction == "right"
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_herdr_080_nested_layout_format():
|
||||||
|
payload = {
|
||||||
|
"result": {
|
||||||
|
"layout": {
|
||||||
|
"area": {"height": 78, "width": 120, "x": 26, "y": 1},
|
||||||
|
"focused_pane_id": "wK:p1",
|
||||||
|
"panes": [
|
||||||
|
{"pane_id": "wK:p1", "rect": {"height": 39, "width": 60, "x": 26, "y": 1, "focused": True}},
|
||||||
|
{"pane_id": "wK:p2", "rect": {"height": 39, "width": 60, "x": 26, "y": 40, "focused": False}},
|
||||||
|
{"pane_id": "wK:p3", "rect": {"height": 78, "width": 60, "x": 86, "y": 1, "focused": False}}
|
||||||
|
],
|
||||||
|
"splits": [{"direction": "right", "id": "split_0_root", "ratio": 0.5}],
|
||||||
|
"workspace_id": "wK",
|
||||||
|
"tab_id": "wK:t1",
|
||||||
|
"zoomed": False
|
||||||
|
},
|
||||||
|
"type": "pane_layout"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d = compute_2xk_layout(payload, min_cols=30, min_rows=20)
|
||||||
|
assert d.target_pane_id == "wK:p3"
|
||||||
|
assert d.direction == "down"
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_invocation_pipe():
|
||||||
|
payload = json.dumps({
|
||||||
|
"result": {
|
||||||
|
"panes": [
|
||||||
|
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 120, "height": 80}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
skills_dir = os.path.abspath(".agents/skills")
|
||||||
|
env = {**os.environ, "PYTHONPATH": skills_dir}
|
||||||
|
res = subprocess.run(
|
||||||
|
[sys.executable, "-m", "lib_py.layout", "--min-cols", "60", "--min-rows", "20"],
|
||||||
|
input=payload,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env
|
||||||
|
)
|
||||||
|
assert res.returncode == 0
|
||||||
|
assert res.stdout.strip() == "down p1"
|
||||||
|
|
||||||
|
res_json = subprocess.run(
|
||||||
|
[sys.executable, "-m", "lib_py.layout", "--json"],
|
||||||
|
input=payload,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env
|
||||||
|
)
|
||||||
|
assert res_json.returncode == 0
|
||||||
|
data = json.loads(res_json.stdout)
|
||||||
|
assert data["target_pane_id"] == "p1"
|
||||||
|
assert data["direction"] == "down"
|
||||||
|
assert not data["is_overflow"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_lib_sh_no_local_in_shim_heredoc():
|
||||||
|
"""Verify F-1: No 'local' declarations inside the top-level shim heredoc dispatcher."""
|
||||||
|
import os
|
||||||
|
lib_path = os.path.abspath(".agents/skills/lib.sh")
|
||||||
|
with open(lib_path, "r", encoding="utf-8") as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
start_idx = content.find("cat <<'EOF' > \"$tmp_file\"")
|
||||||
|
end_idx = content.find("\nEOF\n", start_idx)
|
||||||
|
assert start_idx != -1 and end_idx != -1
|
||||||
|
|
||||||
|
heredoc = content[start_idx:end_idx]
|
||||||
|
# Check specifically in the layout block
|
||||||
|
layout_idx = heredoc.find('split_dir=""')
|
||||||
|
assert layout_idx != -1
|
||||||
|
layout_block = heredoc[layout_idx:layout_idx + 800]
|
||||||
|
assert "local " not in layout_block, f"Forbidden 'local' found in top-level shim heredoc:\n{layout_block}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_5_panes_to_6_panes_fill_singleton_in_3rd_column():
|
||||||
|
"""Verify 5 panes (2x2 full + 1 singleton in 3rd col) -> splits 3rd col singleton down to make 2x3 grid."""
|
||||||
|
payload = {
|
||||||
|
"result": {
|
||||||
|
"panes": [
|
||||||
|
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 80, "height": 40}},
|
||||||
|
{"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 80, "height": 40}},
|
||||||
|
{"pane_id": "p3", "rect": {"x": 80, "y": 0, "width": 80, "height": 40}},
|
||||||
|
{"pane_id": "p4", "rect": {"x": 80, "y": 40, "width": 80, "height": 40}},
|
||||||
|
{"pane_id": "p5", "rect": {"x": 160, "y": 0, "width": 80, "height": 80}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
d = compute_2xk_layout(payload, min_cols=60, min_rows=20)
|
||||||
|
assert d.target_pane_id == "p5"
|
||||||
|
assert d.direction == "down"
|
||||||
|
assert not d.is_overflow
|
||||||
|
|
||||||
|
|
||||||
|
def test_lib_sh_layout_split_in_set_e_subshell(tmp_path):
|
||||||
|
"""Verify F-1 & F-2: lib.sh layout split block executes cleanly in set -euo pipefail top-level script."""
|
||||||
|
import os
|
||||||
|
skills_dir = os.path.abspath(".agents/skills")
|
||||||
|
script = f"""#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
export PYTHONPATH="{skills_dir}"
|
||||||
|
|
||||||
|
_real_herdr() {{
|
||||||
|
if [ "${{1:-}}" = "pane" ] && [ "${{2:-}}" = "layout" ]; then
|
||||||
|
echo '{{"result": {{"panes": [{{"pane_id": "p1", "rect": {{"x": 0, "y": 0, "width": 120, "height": 80}}}}]}}}}'
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}}
|
||||||
|
|
||||||
|
sample_pane="p1"
|
||||||
|
split_dir=""
|
||||||
|
|
||||||
|
# Exact snippet from lib.sh:429-435
|
||||||
|
if [ -n "$sample_pane" ]; then
|
||||||
|
layout_raw=$(_real_herdr pane layout --pane "$sample_pane" 2>/dev/null || echo "")
|
||||||
|
read -r split_dir split_target < <(printf '%s' "$layout_raw" | python3 -m lib_py.layout --min-cols "${{MAM_MIN_PANE_COLS:-60}}" --min-rows "${{MAM_MIN_PANE_ROWS:-20}}" --sample-pane "$sample_pane" 2>/dev/null || echo "right $sample_pane")
|
||||||
|
split_dir="${{split_dir:-right}}"
|
||||||
|
sample_pane="${{split_target:-$sample_pane}}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "SPLIT_DIR=$split_dir"
|
||||||
|
echo "SAMPLE_PANE=$sample_pane"
|
||||||
|
"""
|
||||||
|
res = subprocess.run(["bash", "-c", script], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0, f"Script failed with code {res.returncode}. Stderr: {res.stderr}"
|
||||||
|
assert "SPLIT_DIR=down" in res.stdout
|
||||||
|
assert "SAMPLE_PANE=p1" in res.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def test_real_generated_shim_layout_split(tmp_path):
|
||||||
|
"""Verify generated shim executes layout.py without command not found or local aborts."""
|
||||||
|
import os
|
||||||
|
skills_dir = os.path.abspath(".agents/skills")
|
||||||
|
ws_dir = str(tmp_path / "ws")
|
||||||
|
os.makedirs(ws_dir, exist_ok=True)
|
||||||
|
|
||||||
|
test_script = f"""#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
export WORKSPACE_ROOT="{ws_dir}"
|
||||||
|
export SKILL_DIR="{skills_dir}"
|
||||||
|
source "{skills_dir}/lib.sh"
|
||||||
|
|
||||||
|
_init_herdr_isolation
|
||||||
|
|
||||||
|
shim_path="$WORKSPACE_ROOT/.mam/shim/herdr"
|
||||||
|
if [ ! -x "$shim_path" ]; then
|
||||||
|
echo "ERROR: shim not generated or not executable" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verify no 'local ' inside the shim heredoc body
|
||||||
|
if grep -E '^[[:space:]]*local layout_' "$shim_path"; then
|
||||||
|
echo "ERROR: 'local layout_' found in generated shim" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "SHIM_OK"
|
||||||
|
"""
|
||||||
|
res = subprocess.run(["bash", "-c", test_script], capture_output=True, text=True)
|
||||||
|
assert res.returncode == 0, f"Shim test failed: {res.stderr}"
|
||||||
|
assert "SHIM_OK" in res.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def _four_panes_two_columns():
|
||||||
|
"""GUI payload: 2 full columns x 2 rows (4 panes). Shared by the max-cols tests."""
|
||||||
|
return {
|
||||||
|
"result": {
|
||||||
|
"panes": [
|
||||||
|
{"pane_id": "p1", "rect": {"x": 0, "y": 0, "width": 100, "height": 40}},
|
||||||
|
{"pane_id": "p2", "rect": {"x": 0, "y": 40, "width": 100, "height": 40}},
|
||||||
|
{"pane_id": "p3", "rect": {"x": 100, "y": 0, "width": 100, "height": 40}},
|
||||||
|
{"pane_id": "p4", "rect": {"x": 100, "y": 40, "width": 100, "height": 40}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_max_cols_flag_triggers_overflow():
|
||||||
|
"""CLI --max-cols reaches compute_2xk_layout (the lib.sh-facing path)."""
|
||||||
|
payload = json.dumps(_four_panes_two_columns())
|
||||||
|
skills_dir = os.path.abspath(".agents/skills")
|
||||||
|
env = {**os.environ, "PYTHONPATH": skills_dir}
|
||||||
|
res = subprocess.run(
|
||||||
|
[sys.executable, "-m", "lib_py.layout",
|
||||||
|
"--min-cols", "30", "--min-rows", "20", "--max-cols", "2", "--json"],
|
||||||
|
input=payload, capture_output=True, text=True, env=env)
|
||||||
|
assert res.returncode == 0, res.stderr
|
||||||
|
d = json.loads(res.stdout)
|
||||||
|
assert d["direction"] == "overflow" and d["is_overflow"]
|
||||||
|
assert d["reason"] == "max_columns_reached"
|
||||||
|
|
||||||
|
|
||||||
|
def test_env_max_cols_applies_without_flag():
|
||||||
|
"""MAM_MAX_PANE_COLS is honoured with no --max-cols flag, which is exactly
|
||||||
|
how lib.sh invokes the module (lib.sh passes no --max-cols)."""
|
||||||
|
payload = json.dumps(_four_panes_two_columns())
|
||||||
|
skills_dir = os.path.abspath(".agents/skills")
|
||||||
|
env = {**os.environ, "PYTHONPATH": skills_dir, "MAM_MAX_PANE_COLS": "2"}
|
||||||
|
res = subprocess.run(
|
||||||
|
[sys.executable, "-m", "lib_py.layout",
|
||||||
|
"--min-cols", "30", "--min-rows", "20", "--json"],
|
||||||
|
input=payload, capture_output=True, text=True, env=env)
|
||||||
|
assert res.returncode == 0, res.stderr
|
||||||
|
assert json.loads(res.stdout)["reason"] == "max_columns_reached"
|
||||||
|
|
||||||
|
|
||||||
|
def test_headless_max_columns_growth_guard():
|
||||||
|
"""C-1: headless mode must honour max_columns too.
|
||||||
|
|
||||||
|
A headless 2xK grid completes n // 2 columns, so at n=4 with max_columns=2
|
||||||
|
a further `right` split would open a third column and must overflow instead.
|
||||||
|
Note the cap blocks *opening* a new column; it does not force an existing
|
||||||
|
over-cap layout to shrink -- the odd-n `down` branch (and the GUI's
|
||||||
|
fill_singleton_column) deliberately ignore it.
|
||||||
|
"""
|
||||||
|
def headless(n):
|
||||||
|
return {"result": {"panes": [
|
||||||
|
{"pane_id": f"p{i}", "rect": {"x": 0, "y": 0, "width": 0, "height": 0}}
|
||||||
|
for i in range(1, n + 1)]}}
|
||||||
|
|
||||||
|
d4 = compute_2xk_layout(headless(4), max_columns=2)
|
||||||
|
assert d4.is_overflow and d4.direction == "overflow"
|
||||||
|
assert d4.reason == "max_columns_reached"
|
||||||
|
|
||||||
|
# Continues growing below the cap
|
||||||
|
d2 = compute_2xk_layout(headless(2), max_columns=2)
|
||||||
|
assert d2.direction == "right" and not d2.is_overflow
|
||||||
|
|
||||||
|
# Filling an existing column is not blocked (mirrors GUI fill_singleton_column)
|
||||||
|
d3 = compute_2xk_layout(headless(3), max_columns=2)
|
||||||
|
assert d3.direction == "down" and not d3.is_overflow
|
||||||
|
|
||||||
|
# When max_columns is not set, existing alternation is preserved (behavior neutrality)
|
||||||
|
assert compute_2xk_layout(headless(4)).direction == "right"
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user