37 KiB
📐 구현 계획서 Rev.2 — Job 5801cbe2 (원안: 55a872a8)
- 역할: Planner (
MULTI_AGENT_RULES.md§1 — 저장소 코드/문서 무수정, 산출물은 본 보고서) - 기준 커밋:
320f036(working tree clean) - 베이스라인:
pytest tests/ --collect-only→ 346 collected - 입력: Job
01d929b8리뷰[VERDICT: PASS WITH CHALLENGE](Challenge C-1, Observation C-2·C-3)
0. Rev.1 → Rev.2 변경 요약
| 항목 | 판정 | 조치 |
|---|---|---|
Challenge C-1 — resolve_herdr_workspace() 폴백 우선순위 역전 |
수용. 실측으로 확인, 지적보다 결함이 한 단계 더 확정적 | §4.3 순서 교체 (§1.9) |
Observation C-2 — 입양 행에 herdr_workspace 누락 |
수용. 같은 dict 의 herdr_server 누락(K-2)까지 함께 닫음 |
신설 S10 (§1.11) |
Observation C-3 — HERDR_WORKSPACE 환경변수 비대칭 |
수용. set -u 하 자기참조 확장이 안전함을 실측 |
§4.4 (§1.12) |
| (자체 재감사) 신규 | Rev.1 의 공백 | 재정의된 resolve_herdr_workspace 의 호출자 집합이 Rev.1 에 없었음. C-1 을 반영하면 create 는 이 함수를 써서는 안 됨이 드러남 (§1.10, §3 D5) |
C-1 은 정확합니다. 그리고 챌린저가 제시한 것보다 한 단계 더 확정적인 결함입니다 — 챌린저는 "호출자가 대부분 ws 를 넘긴다" 고 썼는데, 실측하면 stop_session.sh 에는 --workspace 파서 자체가 없어서 ${WORKSPACE:-$WORKSPACE_ROOT} 가 구조적으로 항상 호출자의 루트로 고정됩니다(§1.9.1). "다를 수도 있다"가 아니라 "세션의 cwd 가 될 수 없다"입니다.
다만 C-1 을 반영하면 Rev.1 이 덮지 않은 문제가 새로 드러납니다. 행을 먼저 보는 해석기를 create_session.sh 가 쓰면 재생성 시 낡은 라벨을 물려받습니다 — create 는 terminated/archived 동명 행 위에 재생성할 수 있기 때문입니다(§1.10 실측). Rev.2 는 이 함정을 §3 D5 로 명시적으로 닫습니다.
1. 실측 (Measurements)
§1.1 ~ §1.8 은 Rev.1 에서 확정된 실측이며 재검증 없이 유지합니다. §1.9 ~ §1.12 가 Rev.2 신규입니다.
1.1 herdr_workspace — 읽기 6곳, 쓰기 0곳
| # | 위치 | 용도 | 오염 시 결과 |
|---|---|---|---|
| 1 | lib.sh:1027 resolve_herdr_session() |
소켓 이름 해석 | 모든 하위 소비자로 전파 |
| 2 | reconcile.sh:135 _srv |
herdr -L <_srv> kill-session |
🔴 파괴적 — 잘못된 소켓에 kill |
| 3 | reconcile.sh:389 unique_servers |
살아있는 세션 열거 | 🔴 세션을 못 찾음 → terminated 오판 |
| 4 | reconcile.sh:486 drift 판정 |
(name, srv) not in alive_set |
🔴 라이브 세션을 terminated 로 덮어씀 |
| 5 | status.sh:132 |
JSON 출력 | 🟡 표시 오류 |
| 6 | status.sh:241 |
테이블 출력 | 🟡 표시 오류 |
'herdr_session': create_session.sh:314, update_yaml_resumed.sh:121/135, reconcile.sh:566
'herdr_server': create_session.sh:315, update_yaml_resumed.sh:122/136
'herdr_workspace': (0건)
라이브 레지스트리 3개 행 모두 herdr_workspace=None.
1.2 오인 재현
resolve_herdr_session (소켓 이름을 돌려줘야 함)
legacy(herdr_workspace만 있음) -> my-workspace-label ← 라벨이 소켓 이름으로
both(herdr_session+workspace) -> real-socket
resolve_herdr_workspace (별칭 — 동일한가?)
legacy -> my-workspace-label
both -> real-socket ← 라벨을 물었는데 소켓이 나옴
1.3 resolve_herdr_workspace() 는 순수 별칭이고 호출자 4곳 전부 소켓을 원한다
| 호출자 | 대입 대상 | 원하는 것 |
|---|---|---|
create_session.sh:217 |
HERDR_SESSION_NAME |
소켓 |
stop_session.sh:107 |
HERDR_SESSION_NAME |
소켓 |
multi-agent-mux-delegate-job:466 |
HERDR_SESSION_NAME |
소켓 |
multi-agent-mux-resume/SKILL.md:76 (문서) |
HERDR_SESSION_NAME |
소켓 |
1.4 status.sh 는 이미 라벨과 값이 어긋나 있다
:232 print(f"{'NAME':<44} {'WORKSPACE':<12} ...") ← 헤더는 WORKSPACE
:241 server = s.get('herdr_session') or s.get('herdr_server') ... ← 값은 소켓
1.5 기존 테스트 2건이 이름과 반대로 동작한다
tests/test_tier1_unit.py:79/85 는 함수명이 ..._resolve_herdr_session_... 인데 resolve_herdr_workspace 를 호출합니다. 호출만 바꾸면 이름과 내용이 처음으로 일치합니다.
1.6 목표 ① 행동 중립성
6개 지점에서 폴백 항 제거 → 346건 중 추가 실패 0건. (test_d23/test_d29 2건 실패는 무뮤테이션 대조군에서도 동일 — .git·nats-docker 누락 사본 아티팩트.)
동시에 커버리지 공백의 증거이기도 합니다: 폴백을 타는 테스트가 0건.
1.7 --herdr-workspace 기본값의 판별 가능성
derive_workspace_slug(<repo>) → mam-canary-projects-multi-agent-mux. herdr_session 기본값과 글자 그대로 동일해질 위험 → §3 D3.
1.8 (Rev.1 §1.1 부수) reconcile.sh:566 입양 행은 herdr_server 를 쓰지 않는다
1.9 [Rev.2] Challenge C-1 검증
1.9.1 전제 확인 — stop_session.sh 에는 --workspace 파서가 없다
$ grep -n -- "--workspace\|^WORKSPACE=\|WORKSPACE:-" stop_session.sh
107: HERDR_SESSION_NAME="$(resolve_herdr_workspace "$SESSION_NAME" "${WORKSPACE:-$WORKSPACE_ROOT}")"
--workspace case arm 도, WORKSPACE= 대입도 없습니다. 즉 $WORKSPACE 는 항상 미설정이고 ${WORKSPACE:-$WORKSPACE_ROOT} 는 항상 $WORKSPACE_ROOT — 운영자가 서 있는 디렉터리입니다. 세션의 실제 cwd 는 TARGET_CWD 로 :113-130 에서 따로 뽑습니다.
챌린저는 "대부분의 호출자는 ws 를 항상 넘긴다" 고 썼는데, stop 의 경우는 그보다 강합니다 — 넘기는 값이 세션의 워크스페이스일 수가 없습니다.
1.9.2 두 순서의 차이 — 실측
session ws 인자 Rev.1 챌린지안
------------------------------------------------------------------------------------
registered-with-label /path/to/project_b explicit-label explicit-label
registered-no-label /path/to/project_b to-project-b to-project-a <-- 차이
registered-no-label (없음) to-project-a to-project-a
registered-no-cwd /path/to/project_b to-project-b to-project-b
unregistered-session /path/to/project_b to-project-b to-project-b
unregistered-session (없음) (빈값) (빈값)
차이는 정확히 한 행뿐입니다 — 등록된 행 + 라벨 없음 + 호출자의 ws 가 행의 pane.cwd 와 다름. 이 경우 Rev.1 은 호출자의 워크스페이스를, 챌린지안은 세션 자신의 워크스페이스를 돌려줍니다.
그리고 데드 코드 주장도 성립합니다: Rev.1 의 3순위(if row: pane.cwd)는 ws 가 빈 경우에만 도달하는데, 현재 호출자 3곳 전부 값을 넘기므로 어느 생산 경로에서도 도달 불가합니다. 새로 쓰는 함수에 도달 불가 분기를 넣는 것은 그 자체로 설계 오류입니다.
1.9.3 왜 챌린지안이 옳은가 — 저장소의 기존 계약과 일치
| 해석기 | 우선순위 | 호출자 인자의 위치 |
|---|---|---|
resolve_herdr_session (lib.sh:1025-1044) |
행 → 폴백 | 행이 없을 때만 |
agent_of_row (registry.py:26) |
agent 필드 → 이름 → pane.cmd |
없음 (전부 행 유래) |
| Rev.1 §4.3 | 라벨 → 호출자 ws → pane.cwd |
행 유래 사실보다 위 ❌ |
Rev.1 은 자기 §D4 가 세운 원칙("엉뚱한 출처가 새어 들어오면 안 된다")을 자기 구현에서 어겼습니다. 등록된 행이 있으면 행에 적힌 사실이 호출자 인자를 이깁니다. 챌린지 수용.
1.10 [Rev.2 자체 재감사] C-1 을 반영하면 create 는 이 함수를 쓰면 안 된다
C-1 을 반영하면 해석기가 행을 먼저 봅니다. 그런데 create_session.sh:296-307 은 동명 행 위에 재생성이 가능합니다:
running_same = [s for s in sessions if s.get('name') == name and s.get('status') == 'running']
if running_same:
raise SystemExit(4) # running 이면 거부
sessions[:] = [s for s in sessions if s.get('name') != name] # terminated/archived 는 제거 후 재등록
따라서 --session <기존 이름> 으로 다른 디렉터리에서 재생성할 때, 행-우선 해석기를 쓰면 낡은 pane.cwd 에서 파생된 라벨을 물려받습니다. create 는 새 사실을 세우는 쪽이지 조회하는 쪽이 아닙니다.
→ create 의 기본값은 $WORKSPACE 에서 직접 계산합니다(§3 D5). 이것이 안전한 이유는 두 슬러그 구현의 패리티가 성립하기 때문입니다:
경로 bash derive_workspace_slug(-mam) python slug()
/Users/.../canary_projects/multi-agent-mux canary-projects-multi-agent-mux canary-projects-multi-agent-mux 일치
/tmp workspace-tmp workspace-tmp 일치
/private/var/folders/q_/x q--x q--x 일치
/Users/godopu16/My_Proj.v2 godopu16-my-projv2 godopu16-my-projv2 일치
/ workspace-root workspace-root 일치
5/5 일치(_→- 치환, . 제거, 루트 처리 포함). 다만 두 구현이 존재한다는 사실 자체가 리스크이므로 §5 T10 으로 패리티를 계약화합니다.
1.11 [Rev.2] Observation C-2 검증
reconcile.sh:560-573 입양 dict:
entry = {
'name': name, 'status': 'running', 'role': role,
'herdr_session_created_at': ..., 'herdr_session_epoch': created_epoch,
'herdr_session': srv, ← herdr_server 없음 (K-2)
'pane': {..., 'cwd': pm['cwd']}, ← cwd 는 여기 이미 있음
'start_command': f'... -c "{pm["cwd"]}" ...',
...
}
herdr_workspace 도 없고 herdr_server 도 없습니다. 그리고 파생에 필요한 pm['cwd'] 는 같은 dict 안에 이미 있습니다. 두 줄 추가로 C-2 와 K-2 를 동시에 닫을 수 있어, Rev.1 이 범위 밖(K-2)으로 뒀던 판단을 뒤집습니다 — 비용이 사실상 0 이고 §4.7 이 이 필드를 표시하기 시작하는 이상 입양 행만 - 로 뜨는 것은 새 드리프트입니다.
1.12 [Rev.2] Observation C-3 검증 — set -u 안전
[env 미설정] [env 설정]
OPT=(없음) env=(미설정) -> proj-x OPT=(없음) env=from-env -> from-env
OPT=from-flag env=(미설정) -> from-flag OPT=from-flag env=from-env -> from-flag
set -euo pipefail 하에서 ${HERDR_WORKSPACE_OPT:-${HERDR_WORKSPACE:-${ws_slug#mam-}}} 는 unbound 오류 없이 플래그 > env > 슬러그 순으로 동작합니다. HERDR_SESSION_NAME 과 대칭이 맞습니다. 수용.
2. 범위
포함
| # | 항목 |
|---|---|
| S1 | 6개 읽기 지점에서 herdr_workspace 폴백 항 제거 → herdr_session or herdr_server 고정 |
| S2 | 호출자 4곳 → resolve_herdr_session 이관 + test_tier1_unit.py 2건 정정 (게이트) |
| S3 | resolve_herdr_workspace() 재정의 — C-1 순서 적용 |
| S4 | create_session.sh: --herdr-workspace 파싱·usage·env 폴백(C-3)·기본값·YAML |
| S5 | resume_session.sh / update_yaml_resumed.sh: --herdr-workspace 지원·영속화 |
| S6 | stop_session.sh: --herdr-workspace usage/parser |
| S7 | status.sh 컬럼 분리, reconcile.sh 라벨 표시 |
| S8 | SKILL.md 3종 + resume/SKILL.md:76 |
| S9 | 테스트 tier1 + tier2 신설 |
| S10 | [Rev.2 신설] reconcile.sh:566 입양 행에 herdr_workspace + herdr_server 기입 (C-2 + K-2) |
제외
| 항목 | 사유 |
|---|---|
multi-agent-mux-delegate-job 소켓 lookup 재설계 |
:466 한 줄이 전부이고 S2 로 해소 (§1.3 전수 확인) |
reconcile.sh 의 herdr -L <srv> vs 심의 --session 불일치 |
선재 이슈, 브리프와 무관 → K-3 |
herdr_server 필드 제거 |
하위 호환 별칭으로 유지 (S10 은 추가이지 제거가 아님) |
3. 설계 결정
D1 — 순서: ①이 ②보다 반드시 먼저 (Rev.1 유지)
herdr_workspace writer 가 0 이라 결함이 잠복 상태이고, 목표 ②가 바로 그 writer 를 만듭니다. S1 없이 S4 만 넣으면 그 커밋이 결함을 활성화합니다. S1 은 §1.6 대로 오늘 무해합니다.
D2 — 이름 되찾기: 호출자 이관 → 재정의 2단계 (Rev.1 유지)
1단계 후 grep -rn 'resolve_herdr_workspace' --include='*.sh' --include='*.py' . 이 정의 1줄 외 0건임을 게이트로 확인하고 2단계 진입.
D3 — --herdr-workspace 기본값: mam- 접두사 없는 슬러그 (Rev.1 유지)
접두사를 유지하면 두 필드가 기본 상태에서 동일 문자열이 되어 테스트가 두 필드를 구분하지 못합니다(J-2 의 n=3 함정과 동형). derive_session_name() 이 이미 쓰는 ${base_slug#mam-} 관용구를 재사용합니다.
D4 — 폴백 체인의 최종 형태 (Rev.1 유지)
srv = s.get('herdr_session') or s.get('herdr_server') or 'default' # 라벨은 절대 들어오지 않음
ws = s.get('herdr_workspace') or <pane.cwd 파생> # 소켓으로 폴백하지 않음
D5 — [Rev.2 신설] 재정의된 해석기의 호출자 집합
Rev.1 은 함수를 재정의하면서 누가 부를지 적지 않았습니다. C-1 을 반영하면 이 공백이 실제 함정이 됩니다(§1.10).
| 소비자 | 해석 방법 | 이유 |
|---|---|---|
update_yaml_resumed.sh |
resolve_herdr_workspace 호출 |
등록된 행의 사실이 우선이어야 함 — C-1 이 겨냥한 정확한 경우 |
create_session.sh |
${ws_slug#mam-} 직접 계산 (함수 미사용) |
재생성 시 낡은 행의 pane.cwd 를 물려받지 않기 위해 (§1.10 실측) |
status.sh / reconcile.sh |
행의 herdr_workspace 를 읽고, 없으면 pane.cwd 에서 인라인 파생 |
표시 전용, 인라인 Python 이라 lib.sh 를 거치지 않음 |
stop_session.sh |
사용하지 않음 | 소켓만 필요 (§4.6) |
create 가 함수를 쓰지 않는다는 결정이 D5 의 핵심입니다. 두 슬러그 구현이 갈릴 위험은 §5 T10 패리티 테스트로 막습니다.
D6 — [Rev.2 신설] --workspace 는 라벨링 수단이 아니다
C-1 의 이면입니다. 운영자가 라벨을 바꾸고 싶으면 --herdr-workspace 를 씁니다. --workspace 는 "이 명령이 실행되는 맥락"이지 "세션이 속한 워크스페이스"가 아닙니다. 이 구분을 §4.6 usage 와 SKILL.md 에 한 줄씩 명시합니다.
4. 구현
4.1 S1 — 폴백 항 제거 (6곳)
- val = s.get('herdr_session') or s.get('herdr_server') or s.get('herdr_workspace')
+ val = s.get('herdr_session') or s.get('herdr_server')
lib.sh:1027. 동형으로 reconcile.sh:135/389/486, status.sh:132/241 (뒤 넷은 ... or 'default' 유지).
각 지점 주석:
# herdr_workspace 는 워크스페이스 *라벨* 이지 소켓 이름이 아니다. 폴백에 넣으면
# 라벨이 `herdr -L <name>` 의 소켓 인자로 흘러들어간다 (reconcile.sh:135 는 kill).
4.2 S2 — 호출자 이관 (게이트)
| 파일:줄 | 변경 |
|---|---|
create_session.sh:217, stop_session.sh:107, multi-agent-mux-delegate-job:466 |
resolve_herdr_workspace → resolve_herdr_session |
multi-agent-mux-resume/SKILL.md:76, multi-agent-mux-delegate-job:43(주석), lib.sh:1011(주석) |
〃 |
tests/test_tier1_unit.py:82, :88, :92 |
〃 (§1.5) |
4.3 S3 — resolve_herdr_workspace() 재정의 (C-1 반영)
# resolve_herdr_workspace <session_name> [workspace]
#
# 이 MAM 세션 행의 워크스페이스 *라벨* 을 돌려준다. herdr 소켓/데몬 이름이
# 아니다 — 그쪽은 resolve_herdr_session() 이다. 라벨이 소켓 인자로 흘러가면
# reconcile.sh 가 엉뚱한 소켓에 kill-session 을 날린다.
#
# 우선순위 (C-1: 등록된 행의 사실이 호출자 인자를 이긴다):
# ① row['herdr_workspace'] — 명시 기록
# ② row['pane']['cwd'] 의 슬러그 — 등록된 세션의 실제 작업 디렉터리
# ③ 인자 workspace 의 슬러그 — 미등록 세션 전용 폴백
# ④ 빈 문자열
# 주의 1: herdr_session / herdr_server 로는 절대 폴백하지 않는다 (D4).
# 주의 2: create_session.sh 는 이 함수를 쓰지 않는다 — 재생성 시 낡은 행의
# pane.cwd 를 물려받기 때문 (D5).
resolve_herdr_workspace() {
local session_name="$1"
local workspace="${2:-}"
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$session_name" TARGET_WS="$workspace" python3 -c "
import sys, os, json, re
name = os.environ['SESSION_NAME']
ws = os.environ.get('TARGET_WS', '').strip()
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
def slug(path):
if not path:
return ''
a = os.path.abspath(path)
parent = os.path.basename(os.path.dirname(a)) or 'workspace'
work = os.path.basename(a) or 'root'
if parent in ('/', '.'): parent = 'workspace'
if work in ('/', '.'): work = 'root'
s = f'{parent}-{work}'.lower().replace('_', '-')
return re.sub(r'[^a-zA-Z0-9-]', '', s).lstrip('-')
row = next((s for s in d.get('herdr_sessions', []) if s.get('name') == name), None)
# ① 명시 기록
if row and row.get('herdr_workspace'):
print(row['herdr_workspace']); sys.exit(0)
# ② 등록된 행의 실제 cwd — 호출자 인자보다 우선 (C-1)
if row:
derived = slug((row.get('pane') or {}).get('cwd', ''))
if derived:
print(derived); sys.exit(0)
# ③ 미등록(또는 cwd 부재) 세션 폴백
if ws:
derived = slug(ws)
if derived:
print(derived); sys.exit(0)
print('')
"
}
Rev.1 대비 바뀐 것은 ②와 ③의 순서, 그리고 ②가 빈 값을 낼 때 ③으로 흘러가도록 if derived: 가드를 둔 점입니다(챌린저 처방 그대로).
4.4 S4 — create_session.sh (C-3 + D5 반영)
HERDR_WORKSPACE_OPT="" # :56 부근, set -u 안전
...
--herdr-workspace) HERDR_WORKSPACE_OPT="$2"; shift 2 ;; # :68 부근
usage:
--herdr-workspace NAME workspace label recorded in the registry
(flag > $HERDR_WORKSPACE > workspace slug without mam-).
A label only — it never selects a herdr socket;
use --herdr-session for that.
기본값 — ws_slug 계산 직후 한 곳에서만 계산합니다:
# 플래그 > 환경변수 > 워크스페이스 슬러그 (C-3: HERDR_SESSION_NAME 과 대칭).
# D5: resolve_herdr_workspace 를 쓰지 않는다 — 동명 terminated 행 위에 재생성할 때
# 낡은 pane.cwd 에서 파생된 라벨을 물려받기 때문 (create 는 사실을 세우는 쪽).
MAM_WS_LABEL="${HERDR_WORKSPACE_OPT:-${HERDR_WORKSPACE:-${ws_slug#mam-}}}"
내부 변수를
HERDR_WORKSPACE가 아니라MAM_WS_LABEL로 둡니다. 같은 이름을 쓰면 이후atomic_dump_yaml ... HERDR_WORKSPACE="$HERDR_WORKSPACE"에서 입력 채널과 출력 채널이 한 이름을 공유해 읽는 사람이 어느 쪽인지 판단할 수 없게 됩니다.create_session.sh는HERDR_SESSION_NAME블록을:140과spawn():176두 곳에 중복시킨 전력이 있으므로, 이 계산은 단일 지점임을 주석으로 못박습니다.
dry-run 출력에 실어 파싱 감도를 확보합니다(1b18eb9a §4.1 교훈):
echo "[dry-run] would spawn: herdr session '$SESSION_NAME' in $WORKSPACE (agent=$AGENT, herdr_session=${HERDR_SESSION_NAME:-default}, herdr_workspace=${MAM_WS_LABEL})"
YAML 직렬화 (:314-315 옆, env 는 MAM_WS_LABEL="$MAM_WS_LABEL" 로 전달):
'herdr_session': server_name,
'herdr_server': server_name,
'herdr_workspace': os.environ.get('MAM_WS_LABEL', ''),
4.5 S5 — resume 계열
resume_session.sh / update_yaml_resumed.sh 에 --herdr-workspace 파싱을 추가하고, resume_session.sh 는 두 호출 지점 모두(:72-74, :136-138)에 전달합니다. 2d3fef82 에서 --herdr-session 이 정확히 이 대칭 누락으로 반려됐습니다.
update_yaml_resumed.sh 는 D5 대로 resolve_herdr_workspace 를 사용합니다:
if [ -n "$HERDR_WORKSPACE_OPT" ]; then
MAM_WS_LABEL="$HERDR_WORKSPACE_OPT"
export MAM_WS_LABEL_EXPLICIT="1"
else
MAM_WS_LABEL="$(resolve_herdr_workspace "$SESSION_NAME" "${WORKSPACE:-}")"
export MAM_WS_LABEL_EXPLICIT="0"
fi
export MAM_WS_LABEL
영속화는 --herdr-session 이 확립한 명시/백필 패턴을 그대로 따릅니다:
else:
wsl = os.environ.get('MAM_WS_LABEL', '')
ws_explicit = os.environ.get('MAM_WS_LABEL_EXPLICIT') == '1'
if wsl and (ws_explicit or not target.get('herdr_workspace')):
target['herdr_workspace'] = wsl
신규 행(target is None) 분기에도 'herdr_workspace': wsl 을 추가합니다 — 1b18eb9a §O-1 이 지적한 커버리지 공백을 §5 T7 로 함께 닫습니다.
4.6 S6 — stop_session.sh
usage/parser 에 추가하되 라우팅에는 쓰지 않습니다(D6):
--herdr-workspace <name> — recorded label only; never selects a socket
(use --herdr-session for that). Note: stop has no
--workspace flag — the session's own workspace is
read from its registry row, not from where you stand.
4.7 S7 — 표시
print(f"{'NAME':<44} {'SOCKET':<12} {'WORKSPACE':<14} {'YAML':<10} {'HERDR':<6} ...")
...
socket = s.get('herdr_session') or s.get('herdr_server') or 'default'
wslabel = s.get('herdr_workspace') or _slug((s.get('pane') or {}).get('cwd','')) or '-'
§1.4 의 라벨/값 불일치가 여기서 해소됩니다. status.sh:132 JSON 에도 herdr_workspace 키 추가(기존 server 키는 계약이므로 유지).
4.8 S10 — [Rev.2 신설] 입양 행 (C-2 + K-2)
reconcile.sh:566 부근, 같은 dict 안에 이미 있는 pm['cwd'] 를 재사용:
'herdr_session': srv,
'herdr_server': srv, # K-2: 다른 두 writer 와 필드 세트 정합
'herdr_workspace': _slug(pm['cwd']), # C-2: 입양 행만 WORKSPACE 가 '-' 로 뜨지 않도록
_slug() 는 reconcile.sh 인라인 Python 안의 헬퍼로 두되, §5 T10 이 lib.sh 구현과의 패리티를 계약화합니다.
5. 테스트 계획
신설 13건 (Rev.1 8건 + Rev.2 5건). 예상 collected 346 → 359.
T1 (tier1) — 두 해석기가 다른 것을 돌려준다
seed_row(name="d-creator-claude", herdr_session="socket-A", herdr_workspace="label-B")
assert resolve_herdr_session(...) == "socket-A"
assert resolve_herdr_workspace(...) == "label-B"
T2 (tier1) — 라벨이 소켓으로 새지 않는다 (핵심 가드)
seed_row(name="legacy-creator-claude", herdr_workspace="my-label") # herdr_session 없음
assert resolve_herdr_session("legacy-creator-claude") != "my-label"
§1.6 대로 현재 스위트에 이 성질을 잡는 테스트가 0건입니다. 제거 확인이 아니라 재도입 검출이 목적입니다.
T3 (tier1) — 소켓 해석기 폴백 항이 정확히 둘
herdr_server 만 있는 행 → 그 값. 둘 다 없는 행 → 기존 계약 유지.
T3b (tier1) — [Rev.2 신설] C-1 우선순위 계약
def test_workspace_resolver_prefers_the_row_over_the_caller_argument(mam_sandbox):
"""C-1: 등록된 행에는 herdr_workspace 가 없지만 pane.cwd 가 있다.
호출자가 '다른' 워크스페이스를 넘겨도 행의 cwd 가 이긴다.
(stop_session.sh 는 --workspace 파서가 없어 항상 호출자의 루트를 넘긴다.)"""
seed_row(name="pa-creator-claude", pane_cwd="/path/to/project_a") # 라벨 없음
r = run_lib_func(mam_sandbox, "resolve_herdr_workspace",
"pa-creator-claude", "/path/to/project_b")
assert r.stdout.strip() == "to-project-a" # ← project_b 가 아님
def test_workspace_resolver_uses_the_argument_only_when_unregistered(mam_sandbox):
"""③ 분기가 살아 있음을 확인 — 미등록 세션에서는 인자가 쓰인다."""
r = run_lib_func(mam_sandbox, "resolve_herdr_workspace",
"not-registered", "/path/to/project_b")
assert r.stdout.strip() == "to-project-b"
두 번째 단언이 중요합니다 — C-1 을 반영하면서 ③ 분기를 통째로 죽이지 않았음을 고정합니다.
T4 (tier2) — --herdr-workspace 파싱 + 기본값 + env 폴백(C-3)
assert "herdr_workspace=my-label" in dry_run(flag="my-label")
# 생략 + env 설정 → env 가 이긴다 (C-3)
assert "herdr_workspace=from-env" in dry_run(env={"HERDR_WORKSPACE": "from-env"})
# 플래그와 env 동시 → 플래그가 이긴다
assert "herdr_workspace=my-label" in dry_run(flag="my-label", env={"HERDR_WORKSPACE": "from-env"})
# 둘 다 없음 → 접두사 없는 슬러그, 그리고 herdr_session 기본값과 다르다 (D3)
out = dry_run()
assert f"herdr_workspace={bare}" in out and f"herdr_session=mam-{bare}" in out
마지막 줄이 한 테스트 안에서 두 필드가 서로 다름을 고정합니다.
T5 (tier2) — create YAML 전파
herdr_session / herdr_server / herdr_workspace 3개를 각각 단언하고, herdr_workspace 값이 start_command/attach_command/kill_command 에 들어가지 않음을 함께 단언(라벨이 라우팅에 새지 않음).
T6 (tier2) — resume 전파 (양쪽 호출 지점)
--herdr-workspace NEW-LABEL → 행의 herdr_workspace 갱신, herdr_session 불변.
T7 (tier2) — resume 신규 행 분기
herdr_sessions: [] 로 시작 → herdr_session·herdr_server·herdr_workspace 3개 모두 기록. (1b18eb9a §O-1)
T8 (tier2) — stop 인자 수용
test_comp_stop_usage_matches_parser 플래그 목록에 --herdr-workspace 추가.
T9 (tier2) — [Rev.2 신설] create 재생성 함정 (D5)
def test_create_does_not_inherit_a_stale_workspace_label(mam_sandbox, mock_herdr, mock_agents):
"""D5: 동명 terminated 행이 다른 cwd 를 갖고 있어도, 재생성은 --workspace 에서
라벨을 파생한다. (행-우선 해석기를 쓰면 낡은 라벨을 물려받는다.)"""
seed_row(name="reuse-creator-claude", status="terminated",
pane_cwd="/old/place", herdr_workspace="old-label")
run_create(workspace=mam_sandbox, session="reuse-creator-claude") # --herdr-workspace 없음
row = read_row("reuse-creator-claude")
assert row["herdr_workspace"] != "old-label"
assert row["herdr_workspace"] == expected_bare_slug(mam_sandbox)
T10 (tier1) — [Rev.2 신설] 슬러그 구현 패리티
@pytest.mark.parametrize("path", ["/tmp", "/", "/a/My_Proj.v2", "/private/var/folders/q_/x"])
def test_slug_parity_between_bash_and_python(mam_sandbox, path):
"""D5 는 두 슬러그 구현의 일치에 의존한다 (lib.sh derive_workspace_slug 와
resolve_herdr_workspace / reconcile.sh 의 인라인 slug())."""
b = run_lib_func(mam_sandbox, "derive_workspace_slug", path).stdout.strip()
p = run_lib_func(mam_sandbox, "resolve_herdr_workspace", "not-registered", path).stdout.strip()
assert b.removeprefix("mam-") == p
§1.10 에서 5/5 일치를 실측했으므로 이 테스트는 현재 통과합니다. 값어치는 미래의 분기 방지입니다.
T11 (tier2) — [Rev.2 신설] 입양 행 (S10)
reconcile drift-B 입양을 태우고 새로 등록된 행에 herdr_session·herdr_server·herdr_workspace 3개가 모두 있고, herdr_workspace 가 pane.cwd 파생값과 일치함을 단언.
T12 (tier2) — [Rev.2 신설] 표시 컬럼 분리 (S7)
소켓과 라벨이 다른 행을 심고 status.sh 출력에서 두 값이 각자 컬럼에 나타남을 단언. §1.4 의 헤더/값 불일치 회귀 방지.
6. 뮤테이션 매트릭스
| # | 뮤테이션 | FAIL 해야 하는 테스트 |
|---|---|---|
| M1 | lib.sh:1027 에 or s.get('herdr_workspace') 재도입 |
T2 |
| M2 | resolve_herdr_workspace 를 다시 별칭으로 |
T1 |
| M3 | 새 해석기에 or row.get('herdr_session') 폴백 추가 (D4 위반) |
T1 |
| M3b | [Rev.2] ②③ 순서를 Rev.1 로 되돌림 (ws 를 pane.cwd 앞으로) |
T3b 첫 단언 |
| M3c | [Rev.2] ③ 분기 삭제 (과잉 교정) | T3b 둘째 단언 |
| M4 | reconcile.sh:486 에 폴백 항 재도입 |
미검출 — 아래 정적 가드로 대응 |
| M5 | create 파서가 --herdr-workspace 값을 버림 |
T4, T5 |
| M6 | 기본값을 ${ws_slug} (접두사 유지)로 |
T4 |
| M6b | [Rev.2] env 폴백 제거 (${HERDR_WORKSPACE:-} 항 삭제) |
T4 둘째 단언 |
| M7 | herdr_workspace 를 start_command 에 주입 |
T5 |
| M8 | resume 주 경로에서 --herdr-workspace 미전달 |
T6 |
| M9 | 신규 행 dict 에서 herdr_workspace 제거 |
T7 |
| M10 | [Rev.2] create 가 resolve_herdr_workspace 를 쓰도록 변경 (D5 위반) |
T9 |
| M11 | [Rev.2] 입양 dict 에서 herdr_workspace 제거 |
T11 |
| M12 | [Rev.2] status.sh 가 두 컬럼에 같은 값을 출력 |
T12 |
M3b 와 M3c 가 서로 다른 단언을 깨야 합니다. 하나는 순서 역전을, 다른 하나는 과잉 교정(ws 분기 제거)을 잡습니다. 둘 중 하나라도 잡히지 않으면 T3b 가 한쪽만 보는 테스트라는 뜻입니다 — J-2 에서 n=3 을 골라 M6 을 판별하지 못했던 실수를 반복하지 않기 위한 조건입니다.
M4 를 정직하게 남깁니다. reconcile.sh/status.sh 의 4개 지점은 각자 인라인 Python 이라 lib.sh 해석기를 거치지 않습니다. T2 는 lib.sh 만 지킵니다. 픽스처 4개 대신 소스 수준 정적 가드 1건으로 묶습니다.
def test_no_socket_lookup_falls_back_to_workspace_label():
"""B-22 구조 가드: 소켓 lookup 표현식에 herdr_workspace 가 다시 끼어들지 못한다.
reconcile.sh:135 는 이 값을 `herdr -L <name> kill-session` 에 넘긴다."""
pat = re.compile(r"herdr_session'\)\s*or\s*.*herdr_workspace")
for f in (LIB_SH, RECONCILE_SH, STATUS_SH):
for i, line in enumerate(f.read_text().splitlines(), 1):
assert not pat.search(line), f"{f.name}:{i} — socket lookup falls back to the workspace label:\n{line}"
문자열 가드는 원래 감도가 약하지만, 이 결함은 형태 자체가 한 줄 관용구라 정확히 겨냥할 수 있습니다. M4 를 실제로 검출하는지 뮤테이션으로 확인하는 것을 수용 조건에 넣습니다.
7. 커밋 분할
| # | 커밋 | 내용 | 선행 |
|---|---|---|---|
| 1 | fix(lib,monitor,status): stop resolving the workspace label as a herdr socket name (B-22) |
S1 + T2 + M4 정적 가드 | — |
| 2 | refactor(lib,skills): point every caller at resolve_herdr_session (B-22) |
S2 (게이트 포함) | 1 |
| 3 | feat(lib): make resolve_herdr_workspace return the workspace label (B-22) |
S3 + T1 + T3 + T3b + T10 | 2 |
| 4 | feat(create): add --herdr-workspace and serialize it as a distinct field |
S4 + T4 + T5 + T9 | 3 |
| 5 | feat(resume,stop): support --herdr-workspace end to end |
S5 + S6 + T6 + T7 + T8 | 4 |
| 6 | feat(status,monitor): record and show the workspace label |
S7 + S10 + T11 + T12 | 4 |
| 7 | docs(skills): document --herdr-workspace and the socket/label split |
S8 | 5, 6 |
커밋 1 이 반드시 첫 번째여야 합니다(D1). 커밋 1~3 은 §1.6 대로 전부 행동 중립이며 실제 기능은 커밋 4 부터 시작합니다. 커밋 2/3 분리는 D2 게이트 때문입니다.
Rev.1 대비 변경: 커밋 3 에 T3b·T10, 커밋 4 에 T9, 커밋 6 에 S10·T11·T12 가 추가됐습니다. 커밋 개수는 그대로입니다.
8. 검증 절차 (Creator 실행)
# 1) 구문 — 변경 7개 스크립트 bash -n
# 2) D2 게이트 (커밋 2 직후) — 정의 1줄만 남아야 함
grep -rn 'resolve_herdr_workspace' --include='*.sh' --include='*.py' . | grep -v '^./.agents/reports/'
# 3) 폴백 항 소멸 (커밋 1 직후)
grep -rn "or s.get('herdr_workspace')" --include='*.sh' . | grep -v '^./.agents/reports/'
# → 0건
# 4) C-1 순서 직접 확인 (커밋 3 직후)
# herdr_workspace 없고 pane.cwd=/path/to/project_a 인 행에
# resolve_herdr_workspace <name> /path/to/project_b
# → to-project-a 여야 함 (to-project-b 면 순서가 역전된 것)
# 5) 전체 스위트 (베이스라인 346 → 기대 359)
.venv/bin/python -m pytest tests/ -q
# 6) 뮤테이션 M1~M12 + M4 정적 가드 확인
측정 주의: 격리 사본에서 스위트를 돌릴 때는
.git과nats-docker/를 함께 복사하십시오. 빠뜨리면test_d23_compose_image_matches_doc_and_is_alpine와test_d29_env_secrets_never_tracked가 사본 아티팩트로 실패해 뮤테이션 결과를 오독합니다(§1.6 에서 실제로 발생).
9. 후속 백로그 (범위 밖, 등록만)
| ID | 내용 |
|---|---|
| K-1 | test_o2_18_orphan_steal_lock_recovered 부하 민감 플레이크 — acquire_bg() 의 고정 time.sleep(0.3) |
herdr_server 누락 |
|
| K-3 | reconcile.sh:392-396 이 herdr -L <srv> 를 subprocess.run 으로 직접 호출 — lib.sh 심의 --session 경로 우회. 소켓 스코핑이 실제로 걸리는지 미검증 |
| K-4 | README.md:98,100 / README.ko.md:80,82 의 구 herdr -L <server> 서술 (선재 드리프트) |
| K-5 | create_session.sh:216 의 HERDR_SERVER_OPT 가드 무동작 (1b18eb9a §O-2) |
| K-6 | [Rev.2 신설] stop_session.sh 에 --workspace 파서 부재 — ${WORKSPACE:-$WORKSPACE_ROOT} 가 항상 후자로 고정(§1.9.1). D6 대로 stop 은 행에서 읽으면 되므로 이번 범위에서는 결함이 아니지만, resolve_herdr_session 의 미등록 폴백 품질에는 영향 |
10. 규모 추정
| 파일 | 변경 |
|---|---|
lib.sh |
+36 / −3 |
reconcile.sh |
+9 / −3 (S10 포함) |
status.sh |
+10 / −2 |
create_session.sh |
+15 |
resume_session.sh |
+8 |
update_yaml_resumed.sh |
+18 |
stop_session.sh |
+6 |
multi-agent-mux-delegate-job |
+1 / −1 |
SKILL.md 3종 + resume/SKILL.md |
+20 |
tests/test_tier1_unit.py |
+60 (T1~T3b, T10, 기존 2건 정정) |
tests/test_tier2_component.py |
+140 (T4~T9, T11, T12) |
| 정적 가드 | +12 |
총 약 +335 / −9 줄, 파일 12개, 커밋 7개. 규모 중 (Rev.1 대비 테스트 +87줄).
11. 챌린저에게
C-1 은 정확하고, 실측해 보니 지적보다 한 단계 더 확정적이었습니다. stop_session.sh 에는 --workspace 파서가 아예 없어서(§1.9.1) 넘어가는 값이 세션의 워크스페이스일 가능성 자체가 없습니다. "다를 수 있다"가 아니라 "구조적으로 다르다"입니다. 그리고 Rev.1 의 3순위가 어느 생산 경로에서도 도달 불가라는 데드 코드 지적도 그대로 성립합니다.
무엇보다, Rev.1 은 자기 §D4 가 세운 원칙("엉뚱한 출처가 새어 들어오면 안 된다")을 자기 §4.3 구현에서 어겼습니다. 같은 저장소의 resolve_herdr_session 과 agent_of_row 는 둘 다 행 유래 사실을 호출자 인자보다 앞에 둡니다. 제 구현만 예외였습니다.
C-1 을 반영하면서 Rev.1 이 덮지 않은 문제가 하나 새로 드러났습니다 — 재정의된 함수를 누가 부를지 Rev.1 에 없었고, 행-우선 해석기를 create_session.sh 가 쓰면 동명 terminated 행 위에 재생성할 때 낡은 라벨을 물려받습니다(§1.10). D5 와 T9/M10 으로 닫았습니다. 지적 하나가 계획의 다른 구멍을 드러낸 셈입니다.
C-2 는 수용하면서 Rev.1 이 범위 밖(K-2)으로 뒀던 herdr_server 누락도 함께 끌어왔습니다. 같은 dict 두 줄이고, §4.7 이 이 필드를 표시하기 시작하는 이상 입양 행만 - 로 뜨는 것은 새 드리프트이기 때문입니다.
C-3 도 수용했습니다. 다만 내부 변수명을 HERDR_WORKSPACE 대신 MAM_WS_LABEL 로 둡니다 — 같은 이름이면 입력 채널(사용자 env)과 출력 채널(atomic_dump_yaml 전달)이 한 이름을 공유해 읽는 사람이 구분할 수 없게 되고, 이 파일은 HERDR_SESSION_NAME 블록을 두 곳에 중복시킨 전력이 있습니다.