- I-2: add 5.0s upper-bound execution time assertion in test_bug4_headless_unobservable_fast_path to contractually guard SKS_EMPTY_GIVEUP early-exit latency - I-3: clean up PaneInfo.focused, wire MAM_MAX_PANE_COLS/MAM_MAX_COLS environment variables and CLI --max-cols flag - C-1: apply max_columns growth guard to headless 0x0 layouts, mirroring GUI behavior - Promoted Planner consensus plan Rev.2 (plan-fea5f1b2.md) and unanimous PASS reports from Reviewers Claude (report-55d1a1d9.md) and Cline (report-6f18ba0f.md) - Verified all 333 test cases pass with exit code 0
490 lines
31 KiB
Markdown
490 lines
31 KiB
Markdown
# 📐 구현 계획서 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)
|