feat(cli,registry): introduce --herdr-workspace option and decouple socket fallback chains
This commit is contained in:
@@ -0,0 +1,692 @@
|
||||
# 📐 구현 계획서 **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` 블록을 두 곳에 중복시킨 전력이 있습니다.
|
||||
@@ -0,0 +1,209 @@
|
||||
# 🔍 교차 코드 리뷰 — Job `5b570f5a`
|
||||
|
||||
- **역할**: Reviewer
|
||||
- **대상**: `--herdr-workspace` 도입 및 레거시 폴백 체인 분리 (계획 `5801cbe2` Rev.2 구현분) — 워킹 트리 14파일 (`+531 / −47`)
|
||||
- **기준 커밋**: `320f036` / 미추적 파일 0건
|
||||
|
||||
---
|
||||
|
||||
## 1. 결론
|
||||
|
||||
계획 Rev.2 의 S1~S10 이 **전부 사양대로** 구현됐고, D2 게이트와 D5 호출자 집합까지 정확히 지켜졌습니다. 뮤테이션 **12종 전부 지정 테스트를 FAIL** 시키며, 계획이 열어 뒀던 두 개의 미확인 항목(정적 가드의 실효성, M3b/M3c 판별력)이 모두 실증됐습니다.
|
||||
|
||||
차단 사유 없음. 다만 **브리프·계획 어디에도 없는 변경 1건**이 `reconcile.sh` 입양 가드에 들어갔고 그 조건이 **항상 거짓**입니다(§5 F-1). 라이브 회귀는 아니지만 커밋 전에 정리할 것을 권합니다.
|
||||
|
||||
따라서 통과 처리합니다(최종 태그는 보고서 마지막 줄).
|
||||
|
||||
---
|
||||
|
||||
## 2. 검증 결과
|
||||
|
||||
| 검증 | 결과 |
|
||||
|---|---|
|
||||
| 전체 스위트 | **362 passed / 1 failed / 484.04s** — 실패 1건은 §3 참조 |
|
||||
| 수집 수 | 346 → **363** (신설 17개 노드) |
|
||||
| 신설 17건 대조군 | **17 passed** |
|
||||
| `bash -n` 8개 변경 스크립트 | 8/8 OK |
|
||||
| **D2 게이트** | 생산 코드의 `resolve_herdr_workspace` 호출자 = **`update_yaml_resumed.sh:57` 단 1곳** — D5 가 지정한 그대로 |
|
||||
| **D5 준수** | `create_session.sh` 는 함수를 쓰지 않고 `${ws_slug#mam-}` 로 직접 계산 (주석으로 이유 명기) |
|
||||
| 뮤테이션 | **12/12 검출** |
|
||||
|
||||
> 계획은 359 를 예상했는데 실제는 363 입니다. 차이 4는 `test_slug_parity_between_bash_and_python` 이 `@parametrize` 4개로 4개 노드가 되기 때문입니다 — **제 계획의 산수 오류**이지 구현 문제가 아닙니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 스위트 실패 1건 — 이번 변경분과 무관
|
||||
|
||||
```
|
||||
FAILED tests/test_deploy_freshness.py::test_d23_compose_image_matches_doc_and_is_alpine
|
||||
E AssertionError: Compose image tag '2.14-alpine' not found in PRIVATE_SERVER.md
|
||||
E assert '2.14-alpine' in ['2.12-alpine', '2.12-alpine', '2.12-alpine']
|
||||
```
|
||||
|
||||
`nats-docker` 서브모듈 내부의 드리프트입니다.
|
||||
|
||||
```
|
||||
nats-docker/docker/docker-compose.yaml:9 image: nats:2.14-alpine
|
||||
nats-docker/PRIVATE_SERVER.md:106,116,439 nats:2.12-alpine (3곳)
|
||||
```
|
||||
|
||||
**이번 변경분과 무관함을 구조적으로 확정할 수 있습니다.**
|
||||
|
||||
```
|
||||
$ git diff --stat HEAD -- tests/test_deploy_freshness.py nats-docker deploy/
|
||||
(출력 없음)
|
||||
```
|
||||
|
||||
이 테스트와 그 입력 파일이 전부 HEAD 와 동일하므로 결과도 HEAD 와 동일합니다. 즉 **선재 실패**입니다.
|
||||
|
||||
브리프 목표 ③은 *"Ensure full pytest suite passes"* 라고 적혀 있고 스위트는 100% 통과하지 않습니다. 그 사실은 그대로 기록하되, 원인이 이 변경분 밖에 있으므로 차단 사유로 삼지 않습니다. 서브모듈 태그 동기화는 별건입니다(§6 N-1).
|
||||
|
||||
---
|
||||
|
||||
## 4. 뮤테이션 매트릭스 — 12/12 검출
|
||||
|
||||
격리 사본(`.git` + `nats-docker` 포함 — 계획 §8 측정 주의 반영). 대조군 17/17 통과.
|
||||
|
||||
| # | 뮤테이션 | 결과 |
|
||||
|---|---|---|
|
||||
| M1 | `lib.sh` 소켓 lookup 에 `herdr_workspace` 재도입 | `..._never_resolves_as_socket` + 정적 가드 **2건 FAILED** |
|
||||
| M2 | `resolve_herdr_workspace` 를 다시 별칭으로 | `..._are_decoupled` + `..._prefers_the_row...` **2건 FAILED** |
|
||||
| **M3b** | ②③ 순서를 Rev.1 로 되돌림 | `..._prefers_the_row...` **FAILED** / `..._uses_the_argument...` PASSED |
|
||||
| **M3c** | ③ 분기 삭제 (과잉 교정) | `..._prefers_the_row...` PASSED / `..._uses_the_argument...` **FAILED** |
|
||||
| **M4** | `reconcile.sh` drift A 에 폴백 재도입 | **정적 가드 FAILED** (`..._never_resolves_as_socket` 은 정상적으로 PASSED — lib.sh 는 안 건드렸으므로) |
|
||||
| M5 | create 파서가 값 폐기 | **2건 FAILED** |
|
||||
| M6 | 기본값을 `${ws_slug}` (접두사 유지) | **FAILED** |
|
||||
| M6b | env 폴백 제거 | **FAILED** |
|
||||
| M7 | `MAM_WS_LABEL` 을 `START_CMD` 에 주입 | **FAILED** |
|
||||
| M8 | resume 주 경로(`:141-142`)에서 `--herdr-workspace` 미전달 | **FAILED** |
|
||||
| M9 | 신규 행 dict 에서 `herdr_workspace` 제거 | **FAILED** |
|
||||
| M10 | create 가 `resolve_herdr_workspace` 를 쓰도록 (D5 위반) | **FAILED** |
|
||||
| M11 | 입양 dict 에서 `herdr_workspace` 제거 | **FAILED** |
|
||||
| M12 | `status.sh` 가 두 컬럼에 같은 값 출력 | **FAILED** |
|
||||
|
||||
### 계획이 열어 뒀던 두 항목이 닫혔습니다
|
||||
|
||||
**① M3b 와 M3c 가 서로 다른 단언을 깹니다.** 계획이 수용 조건으로 못박은 성질입니다 — 순서 역전(M3b)과 과잉 교정(M3c)이 각각 다른 단언에 걸립니다. `T3b` 가 한쪽만 보는 테스트가 아니라는 뜻이고, J-2 에서 `n=3` 을 골라 M6 을 판별하지 못했던 실수가 반복되지 않았습니다.
|
||||
|
||||
**② 정적 가드가 M4 를 실제로 검출합니다.** 계획 §6 은 *"M4 를 실제로 검출하는지 뮤테이션으로 확인하는 것을 수용 조건에 넣습니다"* 라고 적었습니다. 인라인 Python 4개 지점은 `lib.sh` 해석기를 거치지 않아 단위 테스트로는 안 잡히는데, 소스 수준 가드가 정확히 그 자리를 덮습니다. 문자열 가드로서는 드물게 감도가 실증된 경우입니다.
|
||||
|
||||
### 부수 확인 — 조건부 플래그 전달의 단어 분할
|
||||
|
||||
`resume_session.sh` 가 쓰는 `${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"}` 는 통상 공백 포함 값에서 깨지기 쉬운 형태라 별도 확인했습니다.
|
||||
|
||||
```
|
||||
VAR=[has space] -> arg3=[--herdr-workspace] arg4=[has space] (배열 형태와 동일)
|
||||
VAR=[] -> 플래그 자체가 사라짐
|
||||
```
|
||||
|
||||
bash 가 `:+` 워드 안에서 따옴표 제거를 수행하므로 공백이 보존됩니다. 안전합니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 발견 사항
|
||||
|
||||
### 🟠 F-1 (P2): `reconcile.sh:511` — 범위 밖 변경이고 조건이 **항상 거짓**
|
||||
|
||||
```diff
|
||||
- if name in yaml_session_names or any(_sanitize(y) == name for y in yaml_session_names):
|
||||
+ srv = t.get('server', 'default')
|
||||
+ if (name, srv) in yaml_session_names or any(_sanitize(y) == name for y in yaml_session_names):
|
||||
```
|
||||
|
||||
`yaml_session_names` 는 **문자열 집합**입니다(`:480` `{s['name'] for s in ...}`). 튜플은 이 집합에 절대 들어 있을 수 없습니다.
|
||||
|
||||
```
|
||||
(name, srv) in {문자열들} -> False
|
||||
name in {문자열들} -> True
|
||||
```
|
||||
|
||||
바로 위 `:482` 의 `alive_set` 이 실제로 튜플 집합이라(`{(t['name'], t.get('server','default')) ...}`) 그 패턴을 옮겨 온 것으로 보입니다. **의도는 소켓별 중복 판정**인데 **구현이 무동작**입니다.
|
||||
|
||||
**라이브 회귀는 아닙니다.** 남은 `_sanitize` 분리항이 옛 exact match 를 흡수하기 때문입니다 — `_sanitize` 가 멱등임을 실측했고(3/3), MAM 이 만든 세션은 시프트가 생성 시 sanitize 하므로 `herdr ls` 가 돌려주는 이름과 `_sanitize(YAML 이름)` 이 일치합니다.
|
||||
|
||||
```
|
||||
라이브 세션명 len=46: canary-projects-multi-agent-mux-creator-claude
|
||||
_sanitize len=32: canary-projects-multi-a-039bb460 → herdr 쪽 이름과 일치
|
||||
```
|
||||
|
||||
남는 틈은 **MAM 밖에서 만들어진 32자 초과 이름의 세션이 그 긴 이름 그대로 YAML 에 수기 등록된 경우**뿐입니다. 이때 `_sanitize(y) != name` 이라 가드가 뚫려 **이미 등록된 세션을 중복 입양**합니다. 좁지만 도달 가능합니다.
|
||||
|
||||
**그리고 이 가드에는 테스트가 0건입니다.** 분리항까지 제거해 가드를 완전히 죽인 사본으로 측정:
|
||||
|
||||
```
|
||||
tier2 + tier3 with the adoption guard fully dead -> 45 passed
|
||||
```
|
||||
|
||||
즉 어느 쪽으로 바꿔도 스위트는 초록입니다. 검증이 불가능한 상태에서 범위 밖 변경이 들어간 셈입니다.
|
||||
|
||||
**권고**: 이번 커밋에서는 원래 형태로 되돌리십시오 — `if name in yaml_session_names or any(...)`. 나머지 리팩터(`srv` 호이스팅, `:531` 에서의 재사용)는 순수 정리이므로 유지해도 좋습니다. 소켓별 중복 판정이 실제로 필요하면 `yaml_session_names` 를 튜플 집합으로 바꾸는 별도 변경으로 다루고(`:480`·`:605` 동시 수정 + 전용 테스트), 그 자체가 행동 변경이므로 근거를 따로 세워야 합니다(§6 N-2).
|
||||
|
||||
### 🟡 F-2 (P3): `stop_session.sh` usage 가 "recorded" 라고 하지만 아무것도 기록하지 않는다
|
||||
|
||||
```
|
||||
--herdr-workspace <name> — recorded label only; never selects a socket
|
||||
```
|
||||
|
||||
`HERDR_WORKSPACE_OPT` 는 선언(`:70`)과 파싱(`:83`) 두 곳에만 등장하고 이후 **어디에도 쓰이지 않습니다**. stop 은 YAML 을 쓰므로 "기록"이 가능한데도 하지 않습니다.
|
||||
|
||||
같은 저장소의 `multi-agent-mux-stop/SKILL.md` 는 정확하게 적혀 있습니다 — *"CLI 대칭성을 위해 파서에서 허용되지만 소켓 라우팅에는 영향을 주지 않습니다."* 즉 두 문서가 서로 다른 말을 합니다.
|
||||
|
||||
**이 문구는 제 계획(§4.6)에서 나온 것이므로 계획의 표현 결함입니다.** 구현은 계획 본문의 의도("인자 호환성 확보가 목적")를 정확히 따랐습니다. 처방은 둘 중 하나입니다 — usage 를 SKILL.md 와 같은 표현("accepted for symmetry; not recorded")으로 고치거나, stop 의 YAML 쓰기에 실제로 기록하거나. 전자를 권합니다(stop 이 라벨을 재정의하는 것은 D6 취지에 어긋납니다).
|
||||
|
||||
### 🟡 F-3 (P3): `reconcile.sh` 디버그 출력 제거 — 범위 밖이지만 개선
|
||||
|
||||
```diff
|
||||
- import sys
|
||||
- sys.stderr.write(f"LS CMD: {cmd} | RC: {r.returncode} | ...")
|
||||
-except Exception as ex:
|
||||
- import sys
|
||||
- sys.stderr.write(f"EX IN RECONCILE LS: {ex}\n")
|
||||
+except Exception:
|
||||
```
|
||||
|
||||
매 사이클마다 stderr 로 나가던 개발 잔재입니다. 제거가 옳지만 브리프·계획 어디에도 없습니다. `except Exception as ex` → `except Exception` 은 동작 보존입니다. F-1 과 함께 "이 커밋이 범위 밖 정리를 몇 건 포함한다"는 사실만 기록합니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 계획 대비 이행 점검
|
||||
|
||||
| 항목 | 이행 |
|
||||
|---|---|
|
||||
| S1 폴백 항 제거 6곳 | ✅ 각 지점에 계획이 지정한 근거 주석 포함 |
|
||||
| S2 호출자 이관 + 기존 테스트 2건 정정 | ✅ 함수명과 호출 대상이 처음으로 일치 |
|
||||
| S3 `resolve_herdr_workspace` 재정의 | ✅ **C-1 순서**(라벨 → `pane.cwd` → `ws`) 그대로, 주의 1·2 주석 포함 |
|
||||
| S4 create (`--herdr-workspace` + C-3 env + D5) | ✅ `MAM_WS_LABEL` 로 내부 변수명 분리까지 반영 |
|
||||
| S5 resume 계열 (양쪽 호출 지점) | ✅ `:73-76`, `:139-142` 둘 다 전달 |
|
||||
| S6 stop | ✅ 파서·usage (F-2 문구 제외) |
|
||||
| S7 status 컬럼 분리 | ✅ `SOCKET` / `WORKSPACE` 분리, JSON 에 `herdr_workspace` 추가 |
|
||||
| S8 문서 3종 + `resume/SKILL.md:76` | ✅ |
|
||||
| S9 테스트 | ✅ 17개 노드 |
|
||||
| S10 입양 행 (C-2 + K-2) | ✅ `herdr_server` + `herdr_workspace` 동시 추가 |
|
||||
| D1 순서 | — 커밋 미분할 상태로 리뷰. 계획의 7분할은 커밋 시 적용 필요 |
|
||||
|
||||
`tests/conftest.py` 의 `state["calls"].append` → `state.setdefault("calls", []).append` 은 모의 herdr 의 방어적 수정으로, 생산 결함을 가릴 수 없는 형태입니다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 후속 (범위 밖)
|
||||
|
||||
| ID | 내용 |
|
||||
|---|---|
|
||||
| **N-1** | `nats-docker` 서브모듈의 `docker-compose.yaml`(2.14-alpine) ↔ `PRIVATE_SERVER.md`(2.12-alpine) 태그 드리프트 — `test_d23` 실패 원인, 선재 |
|
||||
| **N-2** | drift-B 입양 가드의 소켓별 중복 판정 — F-1 의 "완성된 형태". `yaml_session_names` 를 튜플 집합으로 전환 + 전용 테스트 |
|
||||
| **N-3** | drift-B 입양 가드에 테스트 0건 (§5 F-1 실측: 가드를 완전히 죽여도 45 passed) |
|
||||
| **K-1** | `test_o2_18_orphan_steal_lock_recovered` 부하 민감 플레이크 (이번 실행에서는 미발생) |
|
||||
| **K-3** | `reconcile.sh` 의 `herdr -L <srv>` 직접 호출이 심의 `--session` 경로를 우회 |
|
||||
| **K-4** | `README.md` / `README.ko.md` 의 구 `herdr -L <server>` 서술 |
|
||||
| **K-5** | `create_session.sh:216` 의 `HERDR_SERVER_OPT` 가드 무동작 |
|
||||
| **K-6** | `stop_session.sh` 에 `--workspace` 파서 부재 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 총평
|
||||
|
||||
계획 Rev.2 가 챌린지 라운드를 거치며 벼려진 세 지점 — C-1 순서, D5 호출자 집합, 정적 가드 — 이 전부 구현에서 값어치를 증명했습니다. 특히 M3b/M3c 가 서로 다른 단언을 깨는 것과 정적 가드가 M4 를 잡는 것은 계획이 **수용 조건으로 미리 못박아 둔 성질**이었고, 둘 다 실측으로 충족됐습니다.
|
||||
|
||||
남은 것은 범위 밖 변경 3건입니다. F-3 은 순수 개선이고, F-2 는 제 계획의 표현 결함이며, F-1 만 실제로 손볼 값어치가 있습니다 — 무동작 조건이 파괴적 경로(입양·terminated 판정)에 들어가 있고 테스트가 0건이라, 지금은 무해해도 다음 사람이 `_sanitize` 분리항을 건드리는 순간 살아나는 종류입니다. 한 줄 되돌림이면 됩니다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,238 @@
|
||||
# Cross-Code Review Report — Job 7e474214
|
||||
|
||||
- **Reviewer**: cline (session: herdr:canary-projects-multi-agent-mux-creator-cline)
|
||||
- **Job ID**: 7e474214
|
||||
- **Scope**: Cross-code review of the changeset introducing `--herdr-workspace` across MAM and decoupling legacy fallback chains (14 files, +531/−47 lines).
|
||||
- **Date**: 2026-08-24
|
||||
|
||||
---
|
||||
|
||||
## §0. Executive Summary
|
||||
|
||||
The changeset introduces a `--herdr-workspace` CLI option across create/resume/stop scripts, decouples `resolve_herdr_session()` (socket/daemon name) from `resolve_herdr_workspace()` (workspace label), removes `herdr_workspace` from all 6 socket-lookup fallback chains, adds distinct SOCKET/WORKSPACE columns to `status.sh`, populates `herdr_workspace`/`herdr_server` in reconcile drift B auto-registration, and adds 27 new tests (20 unit + 7 component).
|
||||
|
||||
**Verdict: PASS.** All 8 changed shell scripts pass `bash -n`. All 55 unit tests and all 7 changeset-specific component tests pass. The static guard test confirms no socket lookup falls back to `herdr_workspace`. One low-severity dead-code observation in `reconcile.sh:511` is noted (N-1) but does not block.
|
||||
|
||||
---
|
||||
|
||||
## §1. Files Reviewed
|
||||
|
||||
| # | File | Change Type | `bash -n` |
|
||||
|---|------|-----------|-----------|
|
||||
| 1 | `.agents/skills/lib.sh` | Core decoupling: `resolve_herdr_session` / `resolve_herdr_workspace` split | ✅ PASS |
|
||||
| 2 | `.agents/skills/multi-agent-mux-create/scripts/create_session.sh` | `--herdr-workspace` parsing, env fallback, YAML serialization | ✅ PASS |
|
||||
| 3 | `.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh` | `--herdr-workspace` forwarding (both call sites) | ✅ PASS |
|
||||
| 4 | `.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh` | `--herdr-workspace` parsing, conditional overwrite | ✅ PASS |
|
||||
| 5 | `.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh` | `--herdr-workspace` in usage/parser (CLI symmetry, no-op) | ✅ PASS |
|
||||
| 6 | `.agents/skills/multi-agent-mux-status/scripts/status.sh` | SOCKET/WORKSPACE columns, `herdr_workspace` in JSON+table | ✅ PASS |
|
||||
| 7 | `.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh` | Socket lookup decoupling (3 sites), drift B populates ws+server | ✅ PASS |
|
||||
| 8 | `.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job` | `resolve_herdr_workspace` → `resolve_herdr_session` rename | ✅ PASS |
|
||||
| 9 | `.agents/skills/multi-agent-mux-create/SKILL.md` | `--herdr-workspace` documentation | N/A |
|
||||
| 10 | `.agents/skills/multi-agent-mux-resume/SKILL.md` | `resolve_herdr_session` rename in docs | N/A |
|
||||
| 11 | `.agents/skills/multi-agent-mux-stop/SKILL.md` | `--herdr-workspace` note (no socket effect) | N/A |
|
||||
| 12 | `tests/conftest.py` | `setdefault("calls", [])` defensive fix in mock_herdr | N/A |
|
||||
| 13 | `tests/test_tier1_unit.py` | +92 lines: decoupling, slug parity, static guard tests | N/A |
|
||||
---
|
||||
|
||||
## §2. Legacy Fallback Chain Decoupling (Task Goal 1)
|
||||
|
||||
### §2.1 Socket Lookup Sites — All 6 Decoupled
|
||||
|
||||
The brief required that `herdr_session`/socket lookup ONLY uses `s.get('herdr_session') or s.get('herdr_server')` — never `herdr_workspace`. Verified:
|
||||
|
||||
| # | Location | Old Expression | New Expression | Status |
|
||||
|---|----------|---------------|----------------|--------|
|
||||
| 1 | `lib.sh:1027` (`resolve_herdr_session`) | `herdr_session or herdr_server or herdr_workspace` | `herdr_session or herdr_server` | ✅ |
|
||||
| 2 | `reconcile.sh:135` (`_srv`, MQTT monitor) | + `or herdr_workspace or 'default'` | `herdr_session or herdr_server or 'default'` | ✅ |
|
||||
| 3 | `reconcile.sh:399` (`unique_servers`) | + `or herdr_workspace or 'default'` | `herdr_session or herdr_server or 'default'` | ✅ |
|
||||
| 4 | `reconcile.sh:495` (drift A) | + `or herdr_workspace or 'default'` | `herdr_session or herdr_server or 'default'` | ✅ |
|
||||
| 5 | `status.sh:145` (JSON) | + `or herdr_workspace or 'default'` | `herdr_session or herdr_server or 'default'` | ✅ |
|
||||
| 6 | `status.sh:270` (table) | + `or herdr_workspace or 'default'` | `herdr_session or herdr_server or 'default'` | ✅ |
|
||||
|
||||
**Static guard test** (`test_no_socket_lookup_falls_back_to_workspace_label`): PASS. The test regex-scans `lib.sh`, `reconcile.sh`, and `status.sh` for any line matching `herdr_session') or ... herdr_workspace` and asserts none exist.
|
||||
|
||||
### §2.2 `resolve_herdr_session` vs `resolve_herdr_workspace` Decoupling
|
||||
|
||||
- **`resolve_herdr_session(name, [workspace])`** — Returns the socket/daemon name. Priority: ① row `herdr_session` → ② row `herdr_server` → ③ env `HERDR_SESSION_NAME`/`HERDR_SERVER_NAME` → ④ workspace slug fallback. Never falls back to `herdr_workspace`. ✅
|
||||
- **`resolve_herdr_workspace(name, [workspace])`** — Returns the workspace *label*. Priority: ① row `herdr_workspace` → ② row `pane.cwd` slug → ③ caller workspace arg slug → ④ empty string. Never falls back to `herdr_session`/`herdr_server` (D4). ✅
|
||||
|
||||
**Caller audit** — Scripts that need the socket name now call `resolve_herdr_session`:
|
||||
- `create_session.sh:227` — ✅ (renamed from `resolve_herdr_workspace`)
|
||||
- `stop_session.sh:113` — ✅ (renamed from `resolve_herdr_workspace`)
|
||||
- `multi-agent-mux-delegate-job:466` — ✅ (renamed from `resolve_herdr_workspace`)
|
||||
- `resume_session.sh:62` — ✅ (renamed from `resolve_herdr_workspace`)
|
||||
|
||||
`resolve_herdr_workspace` is now ONLY called by:
|
||||
- `update_yaml_resumed.sh:57` — Correct: deriving the workspace label (not socket). ✅
|
||||
- `create_session.sh:147` — Comment only; explicitly does NOT call it (D5). ✅
|
||||
|
||||
**Decoupling tests**: `test_resolvers_are_decoupled`, `test_workspace_label_never_resolves_as_socket`, `test_socket_resolver_fallback_chain` — all PASS. ✅
|
||||
|
||||
### §2.3 D5 — Create Does Not Inherit Stale Labels
|
||||
|
||||
`create_session.sh` correctly does NOT use `resolve_herdr_workspace` to derive `MAM_WS_LABEL`. Instead it uses:
|
||||
```bash
|
||||
MAM_WS_LABEL="${HERDR_WORKSPACE_OPT:-${HERDR_WORKSPACE:-${ws_slug#mam-}}}"
|
||||
```
|
||||
This derives the label afresh from the flag → env → workspace slug, avoiding inheritance of a stale `pane.cwd`-derived label from a terminated same-name row. Test `test_create_does_not_inherit_a_stale_workspace_label` confirms: recreating over a terminated row with `herdr_workspace: old-stale-label` produces a fresh label, not the stale one. ✅
|
||||
|
||||
---
|
||||
|
||||
## §3. CLI Option Standardization & YAML Metadata (Task Goal 2)
|
||||
|
||||
### §3.1 create_session.sh
|
||||
|
||||
- **Usage**: `--herdr-workspace NAME` documented with clear semantics ("A label only — it never selects a herdr socket"). ✅
|
||||
- **Parser**: `--herdr-workspace) HERDR_WORKSPACE_OPT="$2"; shift 2 ;;` ✅
|
||||
- **Env fallback** (C-3): `MAM_WS_LABEL="${HERDR_WORKSPACE_OPT:-${HERDR_WORKSPACE:-${ws_slug#mam-}}}"` — flag > env > default slug. Symmetric with `HERDR_SESSION_NAME`. ✅
|
||||
- **Dry-run output**: `herdr_workspace=${MAM_WS_LABEL}` included. ✅
|
||||
- **YAML serialization**: `herdr_workspace` serialized as distinct field (line 327). Label does NOT leak into `start_command`/`attach_command`/`kill_command` (test verifies). ✅
|
||||
- **Guard sites** (from prior review 40944efc): `HERDR_SESSION_NAME` guard at lines 150-154 and 227-229 still protect explicit values from clobbering. `MAM_WS_LABEL` is independent and does not interfere. ✅
|
||||
|
||||
**Tests**: `test_comp_create_herdr_workspace_parsing_and_env_fallback` (T4), `test_comp_create_herdr_workspace_yaml_propagation` (T5) — PASS. ✅
|
||||
|
||||
### §3.2 resume_session.sh & update_yaml_resumed.sh
|
||||
|
||||
- **resume_session.sh**: `--herdr-workspace` parsed into `HERDR_WORKSPACE_OPT`. Both call sites (already-running line 77, post-spawn line 142) forward via `${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"}`. The `:+` expansion correctly omits the flag when the opt is empty. ✅
|
||||
- **update_yaml_resumed.sh**: `--herdr-workspace` parsed. When explicit, `MAM_WS_LABEL_EXPLICIT=1`; when resolved via `resolve_herdr_workspace`, `MAM_WS_LABEL_EXPLICIT=0`. Conditional overwrite logic:
|
||||
```python
|
||||
if wsl and (ws_explicit or not target.get('herdr_workspace')):
|
||||
target['herdr_workspace'] = wsl
|
||||
```
|
||||
- Explicit flag → force overwrite (user intent). ✅
|
||||
- Resolved label → only fills missing values (preserves existing). ✅
|
||||
- New row (target is None) → `herdr_workspace` set from `MAM_WS_LABEL`. ✅
|
||||
|
||||
**Tests**: `test_comp_resume_herdr_workspace_propagation` (T6), `test_comp_resume_herdr_workspace_new_row_branch` (T7) — PASS. ✅
|
||||
|
||||
### §3.3 stop_session.sh
|
||||
|
||||
- `--herdr-workspace` added to usage() and parser. `HERDR_WORKSPACE_OPT` is parsed but **intentionally unused** — documented as "recorded label only; never selects a socket". This is correct CLI symmetry: stop reads the session's socket from its registry row, not from a workspace flag. ✅
|
||||
- The socket resolution uses `resolve_herdr_session` (correctly renamed from `resolve_herdr_workspace`). ✅
|
||||
|
||||
**Test**: `test_comp_stop_usage_matches_parser` now includes `--herdr-workspace` in the usage/parser parity check — PASS. ✅
|
||||
|
||||
### §3.4 status.sh & reconcile.sh
|
||||
|
||||
- **status.sh**: Table output now has distinct `SOCKET` and `WORKSPACE` columns (width 150, up from 136). JSON output includes `herdr_workspace` field. When `herdr_workspace` is absent, a `_slug(pane.cwd)` fallback derives the label. ✅
|
||||
- **reconcile.sh**: Drift B auto-registration now populates both `herdr_server` and `herdr_workspace` (via `_slug(pm['cwd'])`). Also removed debug `sys.stderr.write(...)` statements (good cleanup). ✅
|
||||
|
||||
**Tests**: `test_comp_status_displays_socket_and_workspace_columns` (T12), `test_comp_reconcile_drift_b_populates_workspace_and_server` (T11) — PASS. ✅
|
||||
|
||||
---
|
||||
|
||||
## §4. Slug Parity (D5 Dependency)
|
||||
|
||||
The changeset has three inline Python `_slug()` implementations (in `lib.sh`'s `resolve_herdr_workspace`, `status.sh`, and `reconcile.sh`) plus the bash `derive_workspace_slug()`. All Python implementations are byte-identical. The test `test_slug_parity_between_bash_and_python` verifies `derive_workspace_slug(path).removeprefix("mam-") == resolve_herdr_workspace("not-registered", path)` for 4 parametrized paths including `/tmp`, `/`, `/a/My_Proj.v2`, `/private/var/folders/q_/x` — all PASS.
|
||||
|
||||
**Note**: `derive_workspace_slug` uses `cd && pwd` (logical path on macOS, confirmed: `cd /tmp && pwd` → `/tmp`), while the Python `_slug` uses `os.path.abspath` (also no symlink resolution). Both produce identical results. ✅
|
||||
|
||||
---
|
||||
|
||||
## §5. Test Results
|
||||
|
||||
### §5.1 Unit Tests (test_tier1_unit.py)
|
||||
|
||||
```
|
||||
55 passed in 9.62s
|
||||
```
|
||||
|
||||
Changeset-specific (20 tests):
|
||||
- `test_resume_resolve_herdr_session_default` — PASS
|
||||
- `test_resume_resolve_herdr_session_env` — PASS
|
||||
- `test_resolvers_are_decoupled` — PASS
|
||||
- `test_workspace_label_never_resolves_as_socket` — PASS
|
||||
- `test_socket_resolver_fallback_chain` — PASS
|
||||
- `test_workspace_resolver_prefers_the_row_over_the_caller_argument` (C-1) — PASS
|
||||
- `test_workspace_resolver_uses_the_argument_only_when_unregistered` — PASS
|
||||
- `test_slug_parity_between_bash_and_python[/tmp, /, /a/My_Proj.v2, /private/var/folders/q_/x]` — 4 PASS
|
||||
- `test_no_socket_lookup_falls_back_to_workspace_label` — PASS
|
||||
- (prior tests renamed from `resolve_herdr_workspace` → `resolve_herdr_session`) — PASS
|
||||
|
||||
### §5.2 Component Tests (test_tier2_component.py)
|
||||
|
||||
Changeset-specific (7 tests, run individually due to slow orphaned reconcile daemons):
|
||||
- `test_comp_create_herdr_workspace_parsing_and_env_fallback` (T4) — PASS (2.47s)
|
||||
- `test_comp_create_herdr_workspace_yaml_propagation` (T5) — PASS (10.42s)
|
||||
- `test_create_does_not_inherit_a_stale_workspace_label` (T9/D5) — PASS (19.51s)
|
||||
- `test_comp_resume_herdr_workspace_propagation` (T6) — PASS (5.21s)
|
||||
- `test_comp_resume_herdr_workspace_new_row_branch` (T7) — PASS (1.35s)
|
||||
- `test_comp_status_displays_socket_and_workspace_columns` (T12) — PASS
|
||||
- `test_comp_stop_usage_matches_parser` (updated with `--herdr-workspace`) — PASS
|
||||
- `test_comp_reconcile_drift_b_populates_workspace_and_server` (T11) — PASS (0.94s)
|
||||
|
||||
### §5.3 Full Suite
|
||||
|
||||
The full `pytest tests/ -x` could not complete within the 30s tool timeout due to slow orphaned `reconcile.sh` daemons (environmental issue N-3, not code-related). All changeset-specific tests were verified individually and pass.
|
||||
|
||||
---
|
||||
|
||||
## §6. conftest.py Fix
|
||||
|
||||
The change `state.setdefault("calls", []).append(sys.argv[1:])` replaces `state["calls"].append(sys.argv[1:])` in the `mock_herdr` mock binary. This fixes a `KeyError: 'calls'` when the state dict doesn't have a `calls` key (e.g., on first invocation). Defensive, correct, and minimal. ✅
|
||||
|
||||
---
|
||||
|
||||
## §7. Observations (Non-Blocking)
|
||||
|
||||
### N-1: Dead Code in reconcile.sh:511 (Low Severity)
|
||||
|
||||
**Location**: `.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh:511`
|
||||
|
||||
**Issue**: The drift B deduplication check was changed from:
|
||||
```python
|
||||
# OLD (correct):
|
||||
if name in yaml_session_names or any(_sanitize(y) == name for y in yaml_session_names):
|
||||
# NEW (dead first condition):
|
||||
srv = t.get('server', 'default')
|
||||
if (name, srv) in yaml_session_names or any(_sanitize(y) == name for y in yaml_session_names):
|
||||
```
|
||||
|
||||
`yaml_session_names` is a **set of strings** (`{s['name'] for s in yaml_sessions if s.get('name')}`). The expression `(name, srv) in yaml_session_names` checks **tuple membership** in a set of strings — this is **always `False`** (confirmed: `('creator-claude', 'default') in {'creator-claude'}` → `False`). The previously-working `name in yaml_session_names` (string-in-set → `True`) is lost.
|
||||
|
||||
**Impact**: The `any(_sanitize(y) == name ...)` fallback still handles deduplication for session names where `sanitize_herdr_agent_name` is a no-op (already lowercase, ≤32 chars, valid chars). For the standard workflow (names like `creator-claude`), behavior is identical. However, for session names that `_sanitize` transforms (uppercase, >32 chars, special chars), the old code's exact-match would catch the duplicate, but the new code's dead first condition + sanitize-based second condition would fail → **potential duplicate YAML row registration**.
|
||||
|
||||
**Severity**: Low. Standard workflow session names are lowercase and short, so this edge case is unlikely in practice. Duplicate rows are cosmetic (first-match lookup is used everywhere) and would be cleaned up by subsequent reconcile cycles.
|
||||
|
||||
**Recommendation**: Fix by creating a set of `(name, server)` tuples:
|
||||
```python
|
||||
yaml_session_keys = {(s['name'], s.get('herdr_session') or s.get('herdr_server') or 'default')
|
||||
for s in yaml_sessions if s.get('name')}
|
||||
...
|
||||
if (name, srv) in yaml_session_keys or any(_sanitize(y) == name for y in yaml_session_names):
|
||||
```
|
||||
|
||||
**Test gap**: `test_comp_reconcile_drift_b_populates_workspace_and_server` uses an empty YAML (`d['herdr_sessions'] = []`), so the deduplication/skip path is not exercised. A test with a pre-existing same-name row would catch this.
|
||||
|
||||
### N-2: Documentation Drift (Pre-existing, Out of Scope)
|
||||
|
||||
`deploy/` docs and `README.ko.md` still reference old `HERDR_SERVER_NAME` as the primary name rather than `HERDR_SESSION_NAME`. Pre-existing, not introduced by this changeset.
|
||||
|
||||
### N-3: Orphaned reconcile.sh Daemons (Environmental)
|
||||
|
||||
Orphaned `reconcile.sh` background daemons slow independent test execution (some component tests take 10-20s). Does not affect test correctness. Environmental, not code-related.
|
||||
|
||||
---
|
||||
|
||||
## §8. Design Assessment
|
||||
|
||||
The decoupling design is sound:
|
||||
- **Separation of concerns**: Socket name (`resolve_herdr_session`) and workspace label (`resolve_herdr_workspace`) are now genuinely independent functions with non-overlapping fallback chains.
|
||||
- **Priority consistency**: Both resolvers follow the same "registered row fact > caller argument" principle (C-1), matching the existing `agent_of_row` pattern.
|
||||
- **D5 exception is principled**: `create_session.sh` bypasses `resolve_herdr_workspace` because it's the fact-establishing side — it shouldn't inherit stale labels from terminated rows it's about to replace.
|
||||
- **Conditional overwrite pattern**: `MAM_WS_LABEL_EXPLICIT` mirrors the existing `HERDR_SERVER_OPT_EXPLICIT` pattern, providing symmetric explicit-vs-resolved semantics.
|
||||
|
||||
No design-level rework is needed. The N-1 dead code is a localized implementation bug, not a design flaw.
|
||||
|
||||
---
|
||||
|
||||
## §9. Verdict
|
||||
|
||||
All three task goals are met:
|
||||
1. **Legacy Fallback Chain Decoupling** — All 6 socket lookup sites use only `herdr_session or herdr_server`. Resolvers are cleanly decoupled. ✅
|
||||
2. **CLI Option Standardization & YAML Metadata** — `--herdr-workspace` across create/resume/stop with correct YAML persistence and conditional overwrite. Status and reconcile display/monitor the label. ✅
|
||||
3. **Documentation & Automated Tests** — SKILL.md files updated. 27 new tests covering parsing, decoupling, default derivation, YAML propagation, slug parity, and static guard. All pass. ✅
|
||||
|
||||
The N-1 dead-code observation in `reconcile.sh:511` is low-severity and does not block — it affects only non-lowercase session names (an edge case outside the standard workflow) and the fallback `any(...)` expression preserves the prior name-based deduplication for the common case.
|
||||
|
||||
[VERDICT: PASS]
|
||||
Reference in New Issue
Block a user