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

693 lines
37 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 📐 구현 계획서 **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` 는 이미 라벨과 값이 어긋나 있다
```python
: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` 은 동명 행 위에 **재생성이 가능**합니다:
```python
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:
```python
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 유지)
```python
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곳)
```diff
- 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'` 유지).
각 지점 주석:
```python
# 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 반영**)
```bash
# 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 반영**)
```bash
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` 계산 직후 **한 곳에서만** 계산합니다:
```bash
# 플래그 > 환경변수 > 워크스페이스 슬러그 (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 교훈):
```bash
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"` 로 전달):
```python
'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` 를 사용**합니다:
```bash
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` 이 확립한 명시/백필 패턴을 그대로 따릅니다:
```python
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 — 표시
```python
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']` 를 재사용:
```python
'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) — 두 해석기가 다른 것을 돌려준다
```python
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) — 라벨이 소켓으로 새지 않는다 (**핵심 가드**)
```python
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 우선순위 계약
```python
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)**
```python
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)
```python
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 신설]** 슬러그 구현 패리티
```python
@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건**으로 묶습니다.
```python
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 실행)
```bash
# 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)` |
| ~~K-2~~ | ~~입양 행 `herdr_server` 누락~~**S10 으로 범위 내 흡수** |
| **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` 블록을 두 곳에 중복시킨 전력이 있습니다.