- 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
31 KiB
📐 구현 계획서 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 스니펫의 결함 두 가지를 스스로 덧붙입니다.
SKILLS_DIR도 미정의였습니다.tests/test_layout.py에는 module-level 상수가 하나도 없고, 기존test_cli_invocation_pipe는 테스트 내부에서skills_dir = os.path.abspath(".agents/skills")를 만들어 씁니다. Rev.1 스니펫은 정의되지 않은 두 이름에 의존했습니다.- 리뷰어가 제안한 헬퍼 시그니처도 그대로는 깨집니다.
def _four_panes_two_columns() -> Dict[str, Any]:는typingimport 없이는 정의 시점에 터집니다.$ python -c "exec('def f() -> Dict[str, Any]:\n return {}\n')" NameError at def time: name 'Dict' is not definedtests/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 구현 사양
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 와 동일 단계)
# 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).
@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 선례).
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 상태에서 힌트는 정의 시점에 터집니다).
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 를 새로 살리면서 이 공백을 두면 신규 변수만 미문서화로 추가됩니다.
# 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_COLSopt-in 경로를 살리고, C-1 이 그 경로의 헤드리스 정합성을 보장합니다. I-3b 만 먼저 적용하면 이번 커밋이 결함을 활성화하는 커밋이 됩니다(A-4).
단계별 확인
- I-2 적용 직후
pytest tests/test_b19_headless_reconcile_fixes.py -q --durations=6로 headless 소요가 여전히 ≈1.2 s 인지 확인. - I-3a 는 개명이므로 호출부 1곳(
layout.py:78) + 테스트 import 1곳만 수정(M-9). - C-1 적용 후
MAM_MAX_PANE_COLS미설정 상태에서 기존test_layout.py16건 전건 통과 → 동작 중립성 확인.
DoD
pytest tests/ -q→ 333 passed, exit 0.- §5 뮤테이션 8종이 각각 지정 테스트를 FAIL 시킴이 로그로 확인되고 원복됨.
bash -n .agents/skills/lib.sh,py_compile lib_py/layout.py통과.pytest tests/test_deploy_freshness.py -q→ 31 passed.grep -rn "\.focused\b" .agents/skills/→ 0건,grep -n "extract_panes_and_focus" tests/→ 0건.- GUI 와 헤드리스가 같은
max_columns에서 같은 시점에 overflow 함을 수동 확인(4-pane /max_columns=2양쪽 모두max_columns_reached). 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호출부 수정,Tupleimport 정리 (§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-colsenv 기본값 (§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 passedpytest 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)