Files
multi-agent-mux/.agents/reports/canary-projects-multi-agent-mux-creator-claude/plan-55a872a8.md
T

37 KiB
Raw Blame History

📐 구현 계획서 Rev.2 — Job 5801cbe2 (원안: 55a872a8)

  • 역할: Planner (MULTI_AGENT_RULES.md §1 — 저장소 코드/문서 무수정, 산출물은 본 보고서)
  • 기준 커밋: 320f036 (working tree clean)
  • 베이스라인: pytest tests/ --collect-only346 collected
  • 입력: Job 01d929b8 리뷰 [VERDICT: PASS WITH CHALLENGE] (Challenge C-1, Observation C-2·C-3)

0. Rev.1 → Rev.2 변경 요약

항목 판정 조치
Challenge C-1resolve_herdr_workspace() 폴백 우선순위 역전 수용. 실측으로 확인, 지적보다 결함이 한 단계 더 확정적 §4.3 순서 교체 (§1.9)
Observation C-2 — 입양 행에 herdr_workspace 누락 수용. 같은 dict 의 herdr_server 누락(K-2)까지 함께 닫음 신설 S10 (§1.11)
Observation C-3HERDR_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 라벨 → 호출자 wspane.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.shherdr -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_workspaceresolve_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.shHERDR_SESSION_NAME 블록을 :140spawn():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.shD5 대로 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_workspacepane.cwd 파생값과 일치함을 단언.

T12 (tier2) — [Rev.2 신설] 표시 컬럼 분리 (S7)

소켓과 라벨이 다른 행을 심고 status.sh 출력에서 두 값이 각자 컬럼에 나타남을 단언. §1.4 의 헤더/값 불일치 회귀 방지.


6. 뮤테이션 매트릭스

# 뮤테이션 FAIL 해야 하는 테스트
M1 lib.sh:1027or s.get('herdr_workspace') 재도입 T2
M2 resolve_herdr_workspace 를 다시 별칭으로 T1
M3 새 해석기에 or row.get('herdr_session') 폴백 추가 (D4 위반) T1
M3b [Rev.2] ②③ 순서를 Rev.1 로 되돌림 (wspane.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_workspacestart_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 정적 가드 확인

측정 주의: 격리 사본에서 스위트를 돌릴 때는 .gitnats-docker/ 를 함께 복사하십시오. 빠뜨리면 test_d23_compose_image_matches_doc_and_is_alpinetest_d29_env_secrets_never_tracked사본 아티팩트로 실패해 뮤테이션 결과를 오독합니다(§1.6 에서 실제로 발생).


9. 후속 백로그 (범위 밖, 등록만)

ID 내용
K-1 test_o2_18_orphan_steal_lock_recovered 부하 민감 플레이크 — acquire_bg() 의 고정 time.sleep(0.3)
K-2 입양 행 herdr_server 누락S10 으로 범위 내 흡수
K-3 reconcile.sh:392-396herdr -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:216HERDR_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_sessionagent_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 블록을 두 곳에 중복시킨 전력이 있습니다.