# b4a1d094 — 신규 세션 UUID 자동 확보·고정 구현 계획서 **Rev.2**

**Job**: b4a1d094 · **Role**: Planner · **Supersedes**: b107cf34 (Rev.1)
**응답 대상**: 챌린지 `7e6aa90d` (`agy`, `[CHALLENGE]`) — F8 경로 정규화 / F8 격리 루트 / F5 배치 순서
**Base**: `9df0fc3` (working tree 는 `LOG.md` 만 수정 — 본 작업으로 저장소를 건드리지 않았다)

---

## 1. 판정 요약

**세 건 모두 채택한다.** 그중 둘은 agy 가 말한 것보다 **더 크다**.

| # | 챌린지 | 판정 | 근거 |
|---|---|---|---|
| C-1 | F8 의 `$WORKSPACE` 미정규화 | **채택 + 강화** | 6가지 경로 형태 중 Rev.1 은 **1/6** 만 맞다. agy 의 제안(`cd && pwd`)은 5/6 — **심볼릭 링크에서 여전히 틀린다.** `cd -P`/`pwd -P` 라야 6/6 |
| C-2 | F8 의 isolation root 누락 | **채택 — 그리고 더 깊다** | `--isolate` 는 `f0a2103` 이후 **no-op**이라 새 격리 세션은 생기지 않는다. 그러나 legacy 행은 여전히 읽히고, 확인해 보니 `verify_session_uuid` 자체가 isolation 을 모른다 → `find_workspace_uuid` 의 격리 분기는 **이미 죽은 코드**였다 |
| C-3 | F5 가 워크스페이스 검사보다 앞설 위험 | **채택 (문서 결함)** | 프로토타입은 이미 검사 **뒤**에 있었다. 틀린 것은 코드가 아니라 Rev.1 §5 의 `"lib.sh:1145 뒤"` 라는 모호한 표현이다. 불변식으로 승격하고 순서를 뒤집으면 깨지는 테스트를 붙였다 |

측정 결과: **HEAD 6/17 · Rev.1 11/17 · Rev.2 17/17.**
신규 변이 5건 전부 의도한 테스트가 잡았다. 그중 N-1 은 **agy 의 제안 그대로를 적용한 변이**이고, 실제로 깨진다.

정정 하나. Rev.1 §5 F8 은 내가 `find_workspace_uuid` 가 이미 하고 있던 정규화를 확인하지 않고
경로 문자열을 그대로 쓴 것이다. agy 가 정확히 짚었다.

---

## 2. C-1 — 경로 정규화: 채택하되 제안보다 한 단계 더

### 2.1 실측

`$SB/wsprobe/real` 을 만들고 `$SB/wsprobe/link → real` 심링크를 건 뒤,
실제 `claude --session-id ... -p ok` 을 **두 경로에서** 돌려 ground truth 를 잡았다.

```
cd real  → ~/.claude/projects/…-scratchpad-wsprobe-real
cd link  → ~/.claude/projects/…-scratchpad-wsprobe-real     ← 링크로 들어가도 real 키
```

즉 **claude 는 물리 경로(realpath)로 키를 만든다.** 이 기준으로 세 가지 키 계산을 비교했다:

| `--workspace` 입력 | Rev.1 (raw `tr`) | agy 제안 (`cd && pwd`) | Rev.2 (`cd -P && pwd -P`) |
|---|---|---|---|
| `.` | MISS | OK | OK |
| `./` | MISS | OK | OK |
| `/…/wsprobe/real` | OK | OK | OK |
| `/…/wsprobe/real/` | MISS | OK | OK |
| `/…/wsprobe/link` | MISS | **MISS** | OK |
| `/…/wsprobe/../wsprobe/real` | MISS | OK | OK |
| **합계** | **1/6** | **5/6** | **6/6** |

agy 의 실패 모드 서술은 맞다. 다만 `cd && pwd` 는 **논리 경로**를 돌려준다 —
`pwd` 는 `$PWD` 를, `pwd -P` 는 해석된 경로를 준다. 심링크 워크스페이스에서는 링크 이름이
그대로 남아 존재하지 않는 디렉터리를 가리킨다.

### 2.2 이 결함이 실제로 무엇을 하는가

키가 틀리면 `-f` 검사가 실패하고 → `CLAUDE_ID_FLAG="--session-id"` 로 떨어진다.
**이미 대화가 있는 세션에 대해 새 대화를 시작한다.** 조용히. 사용자는 재개했다고 믿는다.
`null` 보다 나쁜 종류의 실패다.

### 2.3 같은 결함이 F8 밖에도 있다

`find_workspace_uuid`(lib.sh:1303, 1357)도 `cd "$workspace" && pwd` 를 쓴다 — 논리 경로다.
그래서 심링크 워크스페이스에서는 F8 에 도달하기도 전에 깨진다. 실측:
**HEAD 에서 T-10[symlink] 이 `ERROR: No saved session` 으로 실패한다.** F8 이 없는 HEAD 에서도.

정규화를 한 곳으로 모아야 하는 이유가 이것이다. 두 군데가 서로 다른 규칙을 쓰면
한쪽을 고쳐도 다른 쪽이 되돌린다.

---

## 3. C-2 — 격리 루트: 메커니즘은 죽었지만, 파고들자 더 큰 게 나왔다

### 3.1 `--isolate` 는 더 이상 아무것도 만들지 않는다

```
$ grep -rn "\['isolation'\] =" .agents/skills/     → (없음)
$ grep -c isolation .mam/agent-sessions.yaml       → 0
$ git log --oneline -S"entry['isolation']"
  f0a2103 refactor(isolation): simplify agent session isolation and remove legacy home-isolation helpers
```

`create_session.sh:71-72` 도 `--isolate/--no-isolate` 를 NOTE 만 찍는 no-op 으로 선언한다.
따라서 **앞으로 격리 세션은 생기지 않는다.** agy 가 상정한 "`--isolate` 로 만들어 정상 대화한 세션"은
현재 코드로는 만들 수 없다.

### 3.2 그런데 읽는 쪽은 살아 있다 — 그리고 고장 나 있다

`find_workspace_uuid`(lib.sh:1327, 1363-1397)는 여전히 `isolation.root` 를 읽고
`{iso}/projects/{key}/*.jsonl` 을 glob 한다. 그런데 각 후보를 `verify_session_uuid` 로 검증하는데,
`verify_session_uuid` 는 `c_dir`(= `CLAUDE_PROJECT_DIR`) 만 본다. **isolation 을 모른다.**

결과: glob 이 찾아낸 모든 후보가 검증에서 떨어진다. **격리 분기 전체가 inert 다.**
Rev.2 의 T-11 을 HEAD 에 돌리면 그대로 재현된다 — 격리 루트에만 transcript 가 있는 행은
`No saved session` 이 난다.

agy 는 F8 하나만 지적했지만, F8 만 고치면 resume 의 `-f` 검사는 통과하고
`resolve_session_id.sh` 는 여전히 빈 값을 뱉는다. 그래서 **양쪽 다** 고친다(G3 + G5).

### 3.3 agy 가 제안한 헬퍼는 존재하지 않는다

```
$ grep -c get_session_isolation_root .agents/skills/lib.sh
0
```

개선안 1 의 `get_session_isolation_root` 는 코드베이스에 없는 함수다.
Rev.2 는 이름이 같은 헬퍼를 **새로 정의**해서 쓴다(G1 의 `mam_session_iso_root`).
없는 함수를 호출하는 명세를 그대로 넘기면 구현자가 `command not found` 를 만난다.

---

## 4. C-3 — F5 배치 순서: 코드는 이미 옳았고, 명세가 모호했다

Rev.1 프로토타입의 실제 배치:

```python
    cwd = row.get("pane", {}).get("cwd", "") or ws

    if workspace_key(cwd) != workspace_key(ws):
        return False

    if (mode == "revalidate" and row.get("session_id_source") == "assigned"
            and not row.get("session_id_verified")):
        return True
```

검사 **뒤**다. 그러니 "우회가 일어난다"는 실패는 발생하지 않았다 —
T-12 는 HEAD·Rev.1·Rev.2 **세 트리 모두에서 PASS** 한다.

그렇다고 챌린지가 공허하지는 않다. 틀린 것은 코드가 아니라 **Rev.1 §5 의 `"lib.sh:1145 뒤"`** 라는
표현이다. 1145 는 `if workspace_key(...)` 그 줄이고, "뒤"는 `if` 뒤인지 `return False` 뒤인지
읽는 사람에 따라 갈린다. 구현자가 앞에 붙였다면 격리 보장이 깨졌을 것이다.
**명세 결함은 코드 결함과 같은 값으로 취급한다.**

그래서 두 가지를 한다.

1. 코드에 **ORDERING INVARIANT** 주석을 박아 이유와 함께 순서를 고정한다.
2. 순서를 뒤집으면 깨지는 테스트(T-12)를 붙인다. 변이 N-3 으로 검증했다 —
   F5 를 검사 위로 옮기면 T-12 **만** FAIL 한다. 세 트리에서 모두 PASS 라는 사실이
   이 테스트를 무용하게 만들지 않는다. **불변식 보호 장치**이고, 그게 정확히 이 챌린지가 요구한 것이다.

(T-7 과 같은 성격이다. Rev.1 §7.2 에서도 같은 구분을 해 뒀다.)

---

## 5. 변경 명세 — Rev.1 대비 델타

Rev.1 의 **F0–F4, F6, F7, F9 는 그대로**다. 아래 G1–G5 가 추가·교체분이다.
(Rev.1 전문은 `.mam/jobs/b107cf34/claude-reports/report-final.md`)

### G1 · `lib.sh` — 정규화·키·격리루트 헬퍼 3종 (신규, `mam_gen_uuid` 앞)

```bash
mam_abs_workspace() {
  local p="${1:-}"
  ( cd -P "$p" 2>/dev/null && pwd -P ) || printf '%s' "$p"
}

mam_workspace_key() {
  printf '%s' "$(mam_abs_workspace "$1")" | tr '/_' '--'
}

mam_session_iso_root() {
  MAM_STATE_JSON="$(load_state_json)" MAM_ISO_SESSION="$1" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
import json, os
name = os.environ.get('MAM_ISO_SESSION', '')
try:
    d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
except Exception:
    d = {}
for s in (d.get('herdr_sessions') or []):
    if s.get('name') == name:
        iso = s.get('isolation')
        if isinstance(iso, dict) and iso.get('root'):
            print(iso['root'])
        break
PYEOF
}
```

> `env_python` 은 `atomic_dump_yaml` 과 달리 **`d` 를 미리 정의해 주지 않는다.**
> 프로토타입 1차에서 이걸 빠뜨려 `NameError` 가 났다. `load_state_json` 으로 직접 실어야 한다.
> `mam_workspace_key` 는 `VERIFY_SESSION_PYTHON` 의 `workspace_key()` 와 **같은 값을 내야 한다** — T-13 이 지킨다.

### G2 · `lib.sh:1303, 1357` — `find_workspace_uuid` 도 같은 정규화를 쓴다

```bash
-  local abs; abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace"
+  local abs; abs="$(mam_abs_workspace "$workspace")"
```

두 군데 모두. §2.3 의 심링크 결함이 여기서 온다.

### G3 · `resume_session.sh` — **F8 교체** (Rev.1 F8 은 폐기)

```bash
CLAUDE_ID_FLAG="-r"
if [ "$AGENT" = "claude" ]; then
  _ws_key="$(mam_workspace_key "$WORKSPACE")"
  _iso_root="$(mam_session_iso_root "$SESSION_NAME" 2>/dev/null || true)"
  if [ -n "$_iso_root" ]; then
    _proj_dir="$_iso_root/projects"
  else
    _proj_dir="${CLAUDE_PROJECT_DIR:-$HOME/.claude/projects}"
  fi
  if [ ! -f "${_proj_dir}/${_ws_key}/${UUID}.jsonl" ]; then
    CLAUDE_ID_FLAG="--session-id"
  fi
fi

case "$AGENT" in
  claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions $CLAUDE_ID_FLAG $UUID" ;;
```

### G4 · `lib.sh` — F5 순서를 불변식으로 명문화

```python
    # ORDERING INVARIANT: the workspace check below MUST run before the
    # assigned-id shortcut. Moving the shortcut above it would return True for a
    # row belonging to a different workspace purely because it is assigned and
    # unverified, breaking the one guarantee find_workspace_uuid exists to give
    # -- never hand back an id that belongs to a different workspace. (T-12)
    if workspace_key(cwd) != workspace_key(ws):
        return False

    if (mode == "revalidate" and row.get("session_id_source") == "assigned"
            and not row.get("session_id_verified")):
        return True
```

> 주석에 아포스트로피를 쓰지 말 것. `VERIFY_SESSION_PYTHON` 은 **작은따옴표로 감싼 bash 문자열**이라
> `workspace's` 하나가 문자열을 끊고 `syntax error near unexpected token` 을 낸다.
> 프로토타입에서 실제로 났다.

### G5 · `lib.sh` — `verify_session_uuid` 가 isolation 을 안다

```python
    row = row or {}
    _iso = row.get("isolation")
    iso_root = _iso.get("root") if isinstance(_iso, dict) and _iso.get("root") else None
    …
    if agent == "claude":
        base = (iso_root + "/projects") if iso_root else c_dir
    elif agent == "agy":
        base = f"{iso_root or home}/.gemini/antigravity-cli/conversations"
    elif agent == "hermes":
        hdb = f"{iso_root or home}/.hermes/state.db"
    elif agent == "cline":
        base = (iso_root + "/sessions") if iso_root else f"{home}/.cline/data/sessions"
```

경로 레이아웃은 `find_workspace_uuid` 의 격리 분기(lib.sh:1370-1397)와
`stop_session.sh` 의 `clin_base` 오버라이드에서 그대로 가져왔다. 새로 정하지 않았다.

---

## 6. 문서 변경 (Rev.1 §6 에 추가)

| 파일 | 추가 |
|---|---|
| `.agents/skills/multi-agent-mux-resume/SKILL.md` | 워크스페이스 인자는 **물리 절대경로로 정규화된 뒤** 키가 계산된다. 상대경로·끝슬래시·심링크 모두 같은 세션으로 해석된다 |
| `.agents/MULTI_AGENT_RULES.md` / `.ko.md` | 워크스페이스 키의 단일 정의: `mam_workspace_key` (shell) ≡ `workspace_key` (python), 둘 다 물리 경로 기준. 새 코드가 `cd && pwd` 를 다시 쓰지 않도록 명시 |
| `.agents/skills/multi-agent-mux-monitor/SKILL.md` | `verify_session_uuid` 의 **순서 불변식**(워크스페이스 검사 → assigned 지름길)을 규칙으로 기재 |
| `IMPROVEMENTS.md` | 격리 분기가 inert 였다는 사실을 별도 항목으로. 지금은 legacy 행에만 영향이지만 조용히 죽어 있던 코드다 |

---

## 7. 테스트

### 7.1 신규 (Rev.2)

| ID | 무엇을 | HEAD | Rev.1 | Rev.2 |
|---|---|---|---|---|
| T-10[absolute] | 절대경로 재개 → `-r` | PASS | PASS | PASS |
| T-10[trailing_slash] | 끝 슬래시 | PASS¹ | **FAIL** | PASS |
| T-10[dotdot] | `../` 포함 | PASS¹ | **FAIL** | PASS |
| T-10[relative] | `--workspace .` | PASS¹ | **FAIL** | PASS |
| T-10[symlink] | 심링크 워크스페이스 | **FAIL** | **FAIL** | PASS |
| T-11 | legacy 격리 행 → `isolation.root` 아래에서 찾는다 | **FAIL** | **FAIL** | PASS |
| T-12 | 타 워크스페이스 assigned 행은 revalidate 통과 못 한다 | PASS | PASS | PASS |
| T-13 | `mam_workspace_key` ≡ python `workspace_key` (4형태) | **FAIL** | **FAIL** | PASS |

¹ HEAD 에는 F8 자체가 없어 항상 `-r` 이다. 통과하지만 **아무것도 증명하지 않는다** —
Rev.1 이 도입한 회귀를 잡는 테스트이지 HEAD 결함을 잡는 테스트가 아니다. 표를 그렇게 읽어야 한다.

### 7.2 전체

**HEAD 6/17 · Rev.1 11/17 · Rev.2 17/17.**
Rev.1 이 떨어뜨리는 6건이 정확히 C-1(4) + C-2(2) 이다. 챌린지가 실제로 무엇을 잡았는지가 이 숫자다.

### 7.3 변이 — 신규 5건

| 변이 | 되돌린 것 | 잡은 테스트 |
|---|---|---|
| N-1 | `pwd -P` → `pwd` (**agy 제안 그대로**) | T-10[symlink], T-13 |
| N-2 | `mam_workspace_key` → raw `tr` (**Rev.1 F8 그대로**) | T-10[trailing_slash, dotdot, relative, symlink] |
| N-3 | F5 를 워크스페이스 검사 **위로** | T-12 |
| N-4 | resume 이 `isolation.root` 무시 | T-11 |
| N-5 | `verify_session_uuid` 가 `isolation.root` 무시 | T-11 |

5/5 검출. Rev.1 의 변이 6건(M-1…M-6)도 그대로 유효하다 → **누적 11건**.

N-1 과 N-3 은 특별히 짚어 둔다. N-1 은 **제안된 수정안을 변이로 삼은 것**이고 실제로 깨진다 —
그래서 agy 의 remedy 를 그대로 채택하지 않았다. N-3 은 T-12 가 공허하지 않음을 보인다.

### 7.4 회귀

세 트리 모두 동일 조건(`pytest tests/ -q`, 신규 스위트 2개 제외)으로 전체 실행:

```
base (HEAD)  149 passed in 419.60s
fix  (Rev.1) 149 passed in 420.02s
rev2 (Rev.2) 149 passed in 431.81s
```

**회귀 0.** G2 가 `find_workspace_uuid` 의 정규화를 논리→물리로 바꾸므로 여기가 제일 위험했는데,
심링크가 없는 경로에서는 두 값이 같아 기존 테스트에 영향이 없다(§8.8 에 남은 조건을 적었다).

### 7.5 변경 규모

```
.agents/skills/lib.sh                                    180 lines
.agents/skills/multi-agent-mux-monitor/…/reconcile.sh     68
.agents/skills/multi-agent-mux-resume/…/resume_session.sh 26
.agents/skills/multi-agent-mux-create/…/create_session.sh 23
tests/conftest.py                                         13
```

프로토타입 트리: `scratchpad/base`(HEAD) · `scratchpad/fix`(Rev.1) · `scratchpad/rev2`(Rev.2) ·
`scratchpad/n1…n5`(신규 변이). 패치 스크립트 `patch_b107.py` → `patch_rev2.py` 순서로 적용된다.
저장소에는 반영하지 않았다.

---

## 8. 남는 위험 (Rev.1 §8 갱신)

Rev.1 의 8.1(agy/cline 발견 정확도), 8.3(hermes 미설치), 8.4(trust 다이얼로그 문구),
8.5(`stop --capture-id` 덮어쓰기), 8.6(shellcheck 로컬 부재)는 **그대로 유효**하다. 아래는 변경분.

**8.2 (갱신) F1b wrapper 경로** — 여전히 미재현. Rev.1 의 (a) 권고 유지.

**8.7 (신규) 격리 지원의 처분을 정해야 한다.** §3 에서 드러난 것은
"격리 분기에 버그가 있다"가 아니라 **"격리 분기가 처음부터 동작한 적이 없을 가능성이 높다"** 이다.
G5 는 그것을 되살린다. 두 갈래 중 하나를 골라야 한다 —
(a) **되살린다**(G5 채택, 지금 계획): legacy 행이 정상 재개된다. 단 아무도 안 쓰는 경로를 유지한다.
(b) **걷어낸다**: `find_workspace_uuid` 의 격리 분기와 `stop_session.sh` 의 purge 분기를 함께 제거.
**(a) 를 권한다** — 제거는 legacy YAML 을 가진 사용자에게 파괴적이고, 이 브리프의 범위도 아니다.
다만 (b) 를 별도 티켓으로 남기는 편이 정직하다.

**8.8 (신규) 물리 경로 정규화의 파급.** `mam_abs_workspace` 는 `find_workspace_uuid` 의
동작을 바꾼다(논리→물리). 심링크가 없는 환경에서는 값이 동일하고, 전체 스위트에 회귀가 없음을
확인했다(§7.4). 그러나 **심링크 워크스페이스를 쓰는 기존 YAML 행이 있다면**
`pane.cwd` 는 herdr 가 기록한 값이라 물리/논리 중 무엇인지 이 머신에서 확정하지 못했다.
`workspace_key(cwd) != workspace_key(ws)` 비교의 양변이 어긋날 여지가 남는다.
구현자는 실제 심링크 워크스페이스로 세션 1개를 띄워 `pane.cwd` 를 확인할 것.

---

## 9. 구현 순서 (Rev.1 §9 교체)

G1 이 모든 것의 선행 조건이다. G2/G3 은 G1 없이는 컴파일도 안 된다.

1. **F0** `mam_gen_uuid` · **G1** `mam_abs_workspace` / `mam_workspace_key` / `mam_session_iso_root`
2. **G4** F5 + ORDERING INVARIANT 주석 (F1 의 선행 조건)
3. **G5** `verify_session_uuid` 격리 인식
4. **G2** `find_workspace_uuid` 정규화 통일
5. **F1 (+F1b 결정)** 생성 시 지정
6. **F2** drift C0 + `row_agent`
7. **G3** 재개 분기 (Rev.1 F8 대체)
8. **F4** cwd 스캔 · **F6** pane 디코드 · **F7** 뷰포트 semantics (상호 독립)
9. **F3** C-ambiguous 보고
10. **F9** mock 충실도 — F1 과 **같은 커밋**에
11. 문서 (Rev.1 §6 + §6 위)

**수용 기준**

- `tests/test_uuid_target.py` **17/17**
- 기존 스위트 149 passed, 회귀 0
- 변이 **11건**(M-1…M-6, N-1…N-5) 전부 검출
- `bash -n` 4파일 + CI shellcheck 통과
- 실 세션 1개: 생성 직후 `session_id_verified: false`, 첫 응답 뒤 모니터 1사이클에 `true`
- 심링크 워크스페이스 1개로 `pane.cwd` 실측 (§8.8)

---

## 10. 결론

챌린지 세 건 중 둘은 실제 결함이었고, 하나는 명세의 모호함이었다. 셋 다 고쳤다.
그리고 C-2 를 따라 들어가다 **격리 분기가 이미 inert 였다**는, 양쪽 다 보지 못했던 것이 나왔다.

한 가지는 그대로 채택하지 않았다. agy 의 `cd && pwd` 는 6가지 경로 형태 중 5개만 맞는다.
그 제안을 변이(N-1)로 만들어 돌려 보면 심링크 케이스가 깨진다. `cd -P`/`pwd -P` 를 쓴다.

`.mam/` 산출물 외에 저장소는 건드리지 않았다.

**[AGREEMENT: REACHED]**
