5 Commits
21 changed files with 2327 additions and 75 deletions
+7
View File
@@ -57,6 +57,13 @@
- **잡 레지스트리 (Job Registry)**: 각 비동기 잡의 메타데이터와 생명주기는 개별 JSON 파일(`.mam/jobs/<id>.json`)로 기록되며, 다중 세션 간의 동시 청구(claiming) 경합은 파일 단위의 `fcntl` advisory lock(`registry_lock` via `registry.py`)을 통해 방어합니다. - **잡 레지스트리 (Job Registry)**: 각 비동기 잡의 메타데이터와 생명주기는 개별 JSON 파일(`.mam/jobs/<id>.json`)로 기록되며, 다중 세션 간의 동시 청구(claiming) 경합은 파일 단위의 `fcntl` advisory lock(`registry_lock` via `registry.py`)을 통해 방어합니다.
- **세션 레지스트리 (Session Registry)**: TMUX 모니터링 상태 및 에이전트 구동 정보는 SQLite WAL 데이터베이스(`.mam/agent-sessions.db`)를 통해 단일 호스트 내에서 안정적인 동시 트랜잭션으로 일관되게 제어합니다. 단, SQLite WAL 모드는 NFS(네트워크 파일 시스템) 환경에서는 완전한 파일 락이 보장되지 않으므로 로컬 파일 시스템 사용을 권장합니다. - **세션 레지스트리 (Session Registry)**: TMUX 모니터링 상태 및 에이전트 구동 정보는 SQLite WAL 데이터베이스(`.mam/agent-sessions.db`)를 통해 단일 호스트 내에서 안정적인 동시 트랜잭션으로 일관되게 제어합니다. 단, SQLite WAL 모드는 NFS(네트워크 파일 시스템) 환경에서는 완전한 파일 락이 보장되지 않으므로 로컬 파일 시스템 사용을 권장합니다.
### 🔑 세션 ID 생명주기 및 자동 할당 프로토콜
- **생성 시 자동 할당**: 신규 `claude` 세션은 생성 시 무작위 UUID(`mam_gen_uuid`)를 생성하여 `claude --session-id <uuid>`로 전달합니다. `.mam/agent-sessions.yaml`에는 `claude_session_id_own` 값과 함께 `session_id_source: assigned`, `session_id_verified: false`로 기록됩니다.
- **첫 메시지 구체화**: 트랜스크립트 `.jsonl` 파일은 사용자의 첫 프롬프트 메시지가 전달될 때 디스크에 구체화(materialize)됩니다.
- **모니터 확정 (C0)**: 모니터 루프(`reconcile.sh`)는 디스크상의 트랜스크립트 존재를 검증한 후 `session_id_verified: true``last_visible_status: pinned`로 승격시킵니다.
- **모호성 방어 (C-ambiguous)**: 미할당 세션에 대해 다수의 트랜스크립트 후보가 발견되면 임의 고정 없이 `C-ambiguous` 상태로 보고합니다.
- **경로 정규화 일치**: 모든 경로 계산(`mam_abs_workspace`, `mam_workspace_key`)은 심볼릭 링크를 실경로로 정규화(`cd -P && pwd -P` / `os.path.realpath`)하여 100% 키 일치를 보장합니다.
### 🛡️ 보안 프로토콜 (HMAC-SHA256) ### 🛡️ 보안 프로토콜 (HMAC-SHA256)
- **무인증 PoC 모드**: 잡 레지스트리 생성 시 `auth_token``null`로 지정된 경우(PoC 기본 모드), 별도의 서명 검증을 생략하고 모든 이벤트를 수용합니다 (`verify_hmac`이 항상 `True`를 반환). - **무인증 PoC 모드**: 잡 레지스트리 생성 시 `auth_token``null`로 지정된 경우(PoC 기본 모드), 별도의 서명 검증을 생략하고 모든 이벤트를 수용합니다 (`verify_hmac`이 항상 `True`를 반환).
- **인증 Production 모드**: 실배포 환경이나 인증이 필요한 연동 단계에서는 각 잡마다 고유 암호화 토큰(`auth_token`)을 발급합니다. 퍼블리셔는 이 토큰을 키로 삼아 `hmac_sig` 서명을 페이로드에 동반해야 하며, 수신단(`verify_hmac`)에서 서명이 없거나 일치하지 않는 메시지는 즉시 드랍하여 다운그레이드 공격을 원천 차단합니다. - **인증 Production 모드**: 실배포 환경이나 인증이 필요한 연동 단계에서는 각 잡마다 고유 암호화 토큰(`auth_token`)을 발급합니다. 퍼블리셔는 이 토큰을 키로 삼아 `hmac_sig` 서명을 페이로드에 동반해야 하며, 수신단(`verify_hmac`)에서 서명이 없거나 일치하지 않는 메시지는 즉시 드랍하여 다운그레이드 공격을 원천 차단합니다.
+7
View File
@@ -57,6 +57,13 @@ Asynchronous communication and state management between agents are controlled vi
- **Job Registry**: The metadata and lifecycle of each asynchronous job are recorded in individual JSON files (`.mam/jobs/<id>.json`). Concurrency conflicts (claiming races) across multiple sessions are prevented via file-based `fcntl` advisory locks (`registry_lock` via `registry.py`). - **Job Registry**: The metadata and lifecycle of each asynchronous job are recorded in individual JSON files (`.mam/jobs/<id>.json`). Concurrency conflicts (claiming races) across multiple sessions are prevented via file-based `fcntl` advisory locks (`registry_lock` via `registry.py`).
- **Session Registry**: TMUX monitoring states and running agent metadata are consistently controlled using a SQLite WAL database (`.mam/agent-sessions.db`) to support reliable concurrent transactions on a single host. However, since SQLite WAL mode does not guarantee complete file locking in Network File System (NFS) environments, we recommend using a local file system. - **Session Registry**: TMUX monitoring states and running agent metadata are consistently controlled using a SQLite WAL database (`.mam/agent-sessions.db`) to support reliable concurrent transactions on a single host. However, since SQLite WAL mode does not guarantee complete file locking in Network File System (NFS) environments, we recommend using a local file system.
### 🔑 Session ID Lifecycle & Auto-Assignment Protocol
- **Auto-Assignment at Creation**: Fresh `claude` sessions automatically generate a random UUID (`mam_gen_uuid`) passed via `claude --session-id <uuid>`. `claude_session_id_own` is recorded in `.mam/agent-sessions.yaml` with `session_id_source: assigned` and `session_id_verified: false`.
- **First Message Materialization**: Transcripts `.jsonl` are only created on disk when the first prompt message is delivered.
- **Reconciler Confirmation (C0)**: The monitor loop (`reconcile.sh`) verifies the transcript on disk and promotes `session_id_verified: true` and `last_visible_status: pinned`.
- **Ambiguity Guard (C-ambiguous)**: Unassigned sessions matching multiple candidate transcripts are flagged as `C-ambiguous` without random pinning.
- **Path Equivalence**: All path calculations (`mam_abs_workspace`, `mam_workspace_key`) canonicalize symlinks (`cd -P && pwd -P` / `os.path.realpath`) ensuring 100% key match.
### 🛡️ Security Protocol (HMAC-SHA256) ### 🛡️ Security Protocol (HMAC-SHA256)
- **Unauthenticated PoC Mode**: If the `auth_token` in the job registry is set to `null` (the default PoC mode), signature verification is skipped and all events are accepted (`verify_hmac` always returns `True`). - **Unauthenticated PoC Mode**: If the `auth_token` in the job registry is set to `null` (the default PoC mode), signature verification is skipped and all events are accepted (`verify_hmac` always returns `True`).
- **Authenticated Production Mode**: In production environments or integrations requiring authentication, a unique cryptographic token (`auth_token`) is issued for each job. The publisher must include an `hmac_sig` signature in the payload keyed by this token, and the receiving end (`verify_hmac`) will immediately drop messages that lack a signature or have mismatching signatures to prevent downgrade attacks. - **Authenticated Production Mode**: In production environments or integrations requiring authentication, a unique cryptographic token (`auth_token`) is issued for each job. The publisher must include an `hmac_sig` signature in the payload keyed by this token, and the receiving end (`verify_hmac`) will immediately drop messages that lack a signature or have mismatching signatures to prevent downgrade attacks.
@@ -0,0 +1,386 @@
# 44062a63 — `BaseAgentAdapter` 아키텍처 설계 **Rev.2**
**Job**: 44062a63 · **Role**: Planner · **Supersedes**: 744ac67a (Rev.1)
**응답 대상**: 챌린지 `c52bb834` (`agy`, `[CHALLENGE: RAISED]`)
**Base**: `245abe6`
---
## 1. 판정 요약
**본 이의 1건과 보충 제언 2건 모두 채택한다.** 그리고 셋 중 둘은 agy 가 말한 것보다 **나쁘다**.
| # | 항목 | 판정 | 실측 |
|---|---|---|---|
| C-1 | `candidate_uuids` 서명에 `cwd`/`epoch`/`claimed_uuids` 누락 | **채택 — 증상은 예측보다 위험** | agy 는 `[]` 를 예측했으나 실제는 **타 워크스페이스 대화가 유효 후보로 반환**된다 |
| M-1 | `PYTHONPATH` 부트스트랩 부족 | **우려 채택 · 제안 기각** | `run_loop.sh` 의 맨 `python3 -c` 5곳에서 `ModuleNotFoundError` 재현. 단 제안한 `__init__.py``sys.path.insert`**순환이라 실행 자체가 불가능** |
| M-2 | `ready_tokens` 어댑터 이관 | **채택 — 효과가 예측보다 큼** | "2~3곳"이 아니라 `wait_for_tui_ready` 의 **25줄 case 블록 하나**가 데이터 조회 1줄로 바뀐다 |
정정부터. Rev.1 §4.2 의 `candidate_uuids(ws_key, home, claude_dir, iso_root="")` 는 내가
claude 의 디렉터리 구조만 보고 서명을 뽑은 결과다. agy·hermes·cline 은 **절대경로 `cwd`**
스코프하는데 그 인자가 아예 없었다. agy 가 정확히 짚었다.
**측정 결과: Rev.2 어댑터 4종 전부 `discover()` 정확. 변이 6건 전부 검출. 전체 회귀 162 passed, 0건.**
---
## 2. C-1 — 채택. 다만 실패 양상이 예측과 다르다
### 2.1 agy 의 예측 vs 실제
agy 는 "`lc_data.get(ws_key)` 조회 실패 → 항상 `[]` 반환"이라고 봤다.
그런데 Rev.1 프로토타입의 agy 어댑터는 `last_conversations.json`**아예 보지 않는다.**
conversations 디렉터리를 통째로 glob 한다. 실행해 봤다:
```
agy .candidate_uuids -> ['agy-mine', 'agy-foreign']
hermes.candidate_uuids -> ['herm-mine', 'herm-foreign', 'herm-ancient']
기대: agy -> ['agy-mine'] (agy-foreign 은 /work/other 소속)
hermes -> ['herm-mine'] (herm-foreign 은 타 cwd, herm-ancient 는 세션 생성 이전)
```
`[]` 는 **서비스 거부**다. 지금 나오는 값은 **격리 위반**이다. 후자가 훨씬 나쁘다.
b4a1d094 이후 이 저장소가 계속 방어해 온 바로 그 부류의 결함이다.
### 2.2 `verify_artifact` 도 막아 주지 않는다
`len(valid_candidates)==1` 게이트가 걸러 줄 거라 기대할 수도 있지만, 검증 단계를 실측했다:
```
agy agy-foreign -> True
hermes herm-foreign -> True
hermes herm-ancient -> True
```
전부 통과한다. 그러면 두 결말뿐이다 — 후보가 1개면 **남의 대화를 고정**하고,
2개면 b107cf34 에서 없앤 **영구 교착**으로 되돌아간다. 둘 다 받아들일 수 없다.
여기서 agy 가 언급하지 않은 두 번째 결함이 나온다. **`verify_artifact(path, uuid, cwd)`
`cwd` 를 이미 인자로 받고 있는데 agy·hermes 분기가 그걸 쓰지 않는다.** C-1 은 발견 단계만
지적했지만 검증 단계도 같은 병을 앓고 있었다.
### 2.3 hermes 는 파일 mtime 으로 epoch 을 걸 수 없다
agy 의 권고안은 `epoch` 을 인자로 넘기라고만 한다. 그런데 hermes 는 **모든 세션이 하나의
`state.db` 를 공유**한다. 파일 mtime 은 후보 전체에 대해 같은 값이므로 mtime 기반 필터는
"전부 통과" 아니면 "전부 탈락" 두 가지 답만 낼 수 있다.
hermes 는 `sessions.started_at` 을 갖고 있으므로 그걸 써야 한다. 이건 **어댑터별 오버라이드
지점**이고, 평평한 인자 목록만으로는 드러나지 않는다.
> **미검증 항목**: hermes 는 이 머신에 설치돼 있지 않다(`command not found`).
> `sessions(id, cwd, started_at)` 스키마는 `lib.sh:1454` 의 실제 쿼리와 `tests/conftest.py`
> 의 mock 정의에서 역산한 것이다. 실 CLI 대조는 구현자 몫이다.
---
## 3. C-1 설계 — 평평한 인자 대신 컨텍스트 객체
agy 의 권고안은 인자 7개짜리 서명이다. 방향은 맞지만 형태를 바꾼다.
```python
@dataclass(frozen=True)
class DiscoveryContext:
cwd: str # /Users/x/proj -- agy, hermes, cline
ws_key: str = "" # -Users-x-proj -- claude
home: str = ""
claude_dir: str = ""
iso_root: str = ""
epoch: float = 0.0 # 0 이면 필터 비활성
claimed: frozenset = frozenset()
```
**이유**: 이 서명은 **두 번의 리뷰에서 두 번 바뀌었다**(Rev.1 → `cwd` 추가 → `epoch`/`claimed` 추가).
위치 인자 목록은 바뀔 때마다 어댑터 4개 + 모든 호출부를 함께 고쳐야 한다.
세 번째 변경이 없으리라 가정할 근거가 없다.
`cwd``ws_key`**둘 다** 담는 것이 핵심이다. 둘은 교환 가능하지 않다 —
claude 는 `ws_key` 로 디렉터리를 찾고, agy(`last_conversations.json`)·hermes(`sessions.cwd`
cline(세션 json 의 `cwd`)은 절대경로로 찾는다. 하나만 넘기면 어느 쪽이든 반이 깨진다.
### 3.1 필터는 어댑터가 아니라 기반 클래스에 둔다
```python
def discover(self, ctx) -> list:
out = []
for uuid in self._raw_candidates(ctx):
if uuid in ctx.claimed:
continue
if ctx.epoch and not self._passes_epoch(uuid, ctx):
continue
out.append(uuid)
return out
@abstractmethod
def _raw_candidates(self, ctx) -> list: ... # cwd 스코프만 책임진다
def _passes_epoch(self, uuid, ctx) -> bool: # 기본: 아티팩트 mtime
...
```
agy 의 권고는 "`epoch` 필터링과 `claimed` 배제를 어댑터 내부에 캡슐화"였다.
**어댑터 4개가 각자 구현하면 잊어버릴 기회가 4번 생긴다.** 방금 그 방식으로
agy·hermes 두 개가 `cwd` 를 잊은 것을 봤다. 그래서 필터는 기반 클래스가 갖고,
어댑터는 **스코프된 원시 후보만** 내놓는다. hermes 만 `_passes_epoch` 를 오버라이드한다(§2.3).
### 3.2 agy 는 두 번째 방어선이 없다 — 그리고 그건 HEAD 도 마찬가지다
Rev.2 를 돌리면 agy 만 검증에서 foreign 을 못 막는다:
```
agy verify_artifact(foreign) -> True
claude verify_artifact(foreign) -> False
cline verify_artifact(foreign) -> False
hermes verify_artifact(foreign) -> False
```
내 설계 탓인지 확인하려고 **HEAD 의 셸 구현을 직접 호출**했다:
```
HEAD verify_session_uuid(agy, agy-mine) = True
HEAD verify_session_uuid(agy, agy-foreign) = True ← 동일
```
agy 의 `.db` 에는 cwd 가 기록되지 않는다. 캐시가 유일한 스코프 수단이고,
HEAD 규칙은 "캐시가 인정하거나, 형제 세션이 점유하지 않았으면 통과"다. 어댑터도 그 규칙을 그대로 옮겼다.
**따라서 agy 에 대해서는 `_raw_candidates` 의 cwd 스코핑이 유일한 방어선이다.**
캐시에 이 cwd 항목이 없으면 `[]` 를 반환하도록 명시적으로 정했다 —
`[]` 는 고정을 지연시키지만, 전량 반환은 남의 대화를 고정한다.
### 3.3 최종 인터페이스
```python
class BaseAgentAdapter(ABC):
name: str = ""
own_key: str = ""
supports_assigned_id = False
ready_tokens: tuple = () # M-2
def auth_ok(self, run) -> bool: ...
def spawn_spec(self, binary, session_uuid) -> SpawnSpec: ...
def resume_spec(self, binary, uuid, materialized) -> SpawnSpec: ...
def artifact_path(self, uuid, ctx) -> str: ...
def artifact_exists(self, uuid, ctx) -> bool # 구체 구현
def verify_artifact(self, uuid, ctx) -> bool: ... # cwd 를 반드시 쓸 것
def discover(self, ctx) -> list # 구체 구현 (템플릿)
def _raw_candidates(self, ctx) -> list: ... # 추상
def _passes_epoch(self, uuid, ctx) -> bool # 오버라이드 가능
```
`artifact_path` / `verify_artifact``ctx` 를 받도록 통일했다. Rev.1 의
`(uuid, ws_key, home, claude_dir, iso_root)``(path, uuid, cwd)` 두 가지 관례가
공존하던 것이 애초에 `cwd` 를 흘린 원인이다.
---
## 4. M-1 — 우려는 옳고, 제안한 해법은 동작하지 않는다
### 4.1 우려: 실재한다
Rev.1 은 `PYTHONPATH``env_python` / `atomic_dump_yaml` 의 env 목록에만 얹었다.
그런데 `run_loop.sh`**맨 `python3 -c` 를 5곳**(179, 210, 223, 250, 277) 쓴다.
그리고 Rev.1 §5 는 하필 그중 `resolve_agent_type`(223)을 `registry.agent_of_row`
교체하라고 했다. 재현:
```
$ source .agents/skills/lib.sh; python3 -c "import mam_agents"
ModuleNotFoundError: No module named 'mam_agents'
```
Rev.1 설계 그대로 M1 을 구현했다면 `run_loop.sh` 가 그 자리에서 죽는다.
### 4.2 제안: 순환이라 성립하지 않는다
`mam_agents/__init__.py` 안에서 `sys.path.insert` 를 하라는 제안은 실행될 수 없다.
`__init__.py` 가 돌려면 패키지가 이미 import 돼야 하고, import 되려면 경로가 이미 잡혀 있어야 한다.
```
$ python3 -c "import mam_agents" # sys.path 에서 skills 제거 후
ModuleNotFoundError: No module named 'mam_agents'
-> __init__.py never runs, so it cannot add its own directory to sys.path
```
### 4.3 채택하는 해법: `lib.sh` source 시점 1회 export
```bash
_mam_export_pythonpath() {
local d; d="$(mam_skills_dir)"
case ":${PYTHONPATH:-}:" in
*":$d:"*) ;;
*) export PYTHONPATH="$d${PYTHONPATH:+:$PYTHONPATH}" ;;
esac
}
_mam_export_pythonpath
```
`lib.sh` 를 source 하는 **모든** 스크립트의 **모든** 파이썬 호출이 한 번에 덮인다.
`run_loop.sh:12` 가 lib.sh 를 source 하므로 5곳 전부 포함된다. 검증:
```
$ source .agents/skills/lib.sh; python3 -c "from mam_agents import registry; print(registry.names())"
import OK: ['agy', 'claude', 'cline', 'hermes']
```
**herdr shim 은 의도적으로 제외된다** — shim 은 lib.sh 를 source 하지 않는 별도 생성 스크립트이고,
Rev.1 §3.1 에서 그 안의 python3 9곳이 에이전트 지식을 0건 쓴다는 것을 이미 측정했다.
**표준 라이브러리 섀도잉 위험 점검**: `.agents/skills/` 바로 아래에 최상위 `.py` 파일은 **0개**다
(`mam_agents/` 패키지와 스킬 디렉터리뿐). export 후에도 stdlib import 정상:
```
$ source .agents/skills/lib.sh; python3 -c "import json, os, sqlite3, glob, re; print('stdlib OK')"
stdlib OK
```
> 남는 부작용 하나: herdr 가 띄우는 에이전트 CLI 들이 이 `PYTHONPATH` 를 상속한다.
> 최상위 모듈이 없어 섀도잉은 불가능하지만, 구현자는 `mam_agents` 라는 이름이
> 어느 에이전트 CLI 의 내부 모듈과 겹치지 않는지 한 번 확인하는 편이 좋다.
---
## 5. M-2 — 채택. 효과가 제언보다 크다
agy 는 "5번째 에이전트 추가 시 셸 수정 2~3곳 감소"로 추정했다. 실제로 세어 보니
`_MAM_READY_TOKENS_CLAUDE` 는 **claude 전용 변수 하나**이고, 나머지 세 에이전트의 준비 토큰은
`wait_for_tui_ready` 안에 **인라인으로 박혀 있다**(lib.sh:1811-1835). 그 case 블록이 **25줄**이다.
```
claude Anthropic|Assistant|Chat|Welcome
agy Antigravity
hermes Hermes
cline Cline|history|Chat|What can I do|slash commands
```
브리지가 `MAM_READY_TOKENS` 를 ERE alternation 으로 내보내면 25줄 case 가
`grep -E -q "$MAM_READY_TOKENS"` 한 줄이 된다. 새 에이전트는 셸을 **0줄** 건드린다.
> **행동 변경 주의.** claude 의 ready_tokens 에서 `projects` 를 **뺐다.**
> b107cf34 §2.7 에서 그 토큰이 cwd 경로에 우연히 매칭돼 **trust 다이얼로그가 떠 있는 상태에서
> "준비 완료"로 오판**하는 것을 측정했기 때문이다. 이건 개선이지만 리팩터에 섞어 넣을 성질이 아니다.
> **별도 커밋으로 분리하고 자체 검증을 붙일 것을 권한다.**
---
## 6. 변경 요약 (Rev.1 대비)
| ID | 파일 | 내용 |
|---|---|---|
| R-1 | `base.py` | `DiscoveryContext` 도입, `discover()` 템플릿 메서드, `_raw_candidates()` 추상화, `_passes_epoch()` 훅, `ready_tokens` 속성 |
| R-2 | `adapters/agy.py` | `last_conversations.json[cwd]` 스코핑, 캐시 없으면 `[]`, 검증에 형제 점유 규칙 |
| R-3 | `adapters/hermes.py` | `WHERE cwd=?` 복원, `verify_artifact` 에 cwd 대조, `_passes_epoch``started_at` 으로 오버라이드 |
| R-4 | `adapters/cline.py` | 세션 json 의 `cwd` 로 원시 후보 스코핑 |
| R-5 | `adapters/claude.py` | `ctx` 서명 통일, `ready_tokens`(`projects` 제외) |
| R-6 | `lib.sh` | `PYTHONPATH` 를 source 시점 1회 export (per-entry-point env 목록 방식 폐기) |
| R-7 | `__main__.py` | 브리지에 `MAM_READY_TOKENS` 추가 |
패키지 규모: Rev.1 374줄 → **Rev.2 484줄**. 증가분 110줄 대부분이 워크스페이스 스코핑과
필터 템플릿이다. Rev.1 이 그만큼 덜 하고 있었다는 뜻이다.
---
## 7. 검증
### 7.1 발견 정확도 — 어댑터 4종
워크스페이스 2개(`/work/mine`, `/work/other`), 세션 생성 epoch 1시간 전,
3개월 전 대화 1건, 형제가 점유한 id 1건을 심은 픽스처:
| 어댑터 | Rev.1 | Rev.2 | 기대 |
|---|---|---|---|
| claude | — | `['cl-mine']` | ✅ |
| agy | `['agy-mine', 'agy-foreign']` | `['agy-mine']` | ✅ |
| hermes | `['herm-mine', 'herm-foreign', 'herm-ancient']` | `['herm-mine']` | ✅ |
| cline | — | `['cli-mine']` | ✅ |
형제 점유 배제(전부 claimed 로 표시):
```
agy/claude/cline/hermes discover(all claimed) -> [] 4/4 OK
```
### 7.2 변이 — 6/6 검출
| 변이 | 되돌린 것 | 결과 |
|---|---|---|
| Q-1 | agy `_raw_candidates` → 플랫 glob (**Rev.1 그대로**) | `['agy-foreign', 'agy-mine']` WRONG |
| Q-2 | hermes `WHERE cwd=?` 제거 (**Rev.1 그대로**) | `herm-foreign` 유입 |
| Q-3 | hermes `_passes_epoch` 오버라이드 제거 | `herm-ancient` 유입 |
| Q-4 | 기반 클래스의 `claimed` 필터 제거 | 4종 전부 LEAKED |
| Q-5 | 기반 클래스의 `epoch` 필터 제거 | claude·cline·hermes 에 ancient 유입 |
| Q-6 | cline cwd 스코핑 제거 | `cli-foreign` 유입 |
Q-1·Q-2 는 **Rev.1 코드를 그대로 변이로 삼은 것**이고 실제로 깨진다.
Q-3 은 §2.3 의 hermes 특수성이 공허한 우려가 아님을 보인다.
### 7.3 회귀
```
baseline (HEAD 245abe6) 162 passed in 518.51s
Rev.1 프로토타입 162 passed in 521.54s
Rev.2 프로토타입 162 passed in 505.79s ← 회귀 0
```
R-6(source 시점 `PYTHONPATH` export)이 가장 위험했다. `lib.sh` 를 source 하는 모든
스크립트의 환경을 바꾸고 herdr 가 띄우는 프로세스까지 상속되기 때문이다. 회귀 0.
`py_compile` 통과. 어댑터는 표준 라이브러리만 사용(§Rev.1 3.2 제약 유지).
---
## 8. 남는 위험 (Rev.1 §9 갱신)
Rev.1 의 비용 항목 5가지(인터프리터 경계 · 브리지 호출 규율 · 배포/CI 등록 · 이행 중 이중 표현 ·
간접화)는 그대로 유효하다. 아래는 갱신·추가분.
**8.1 (갱신) 배포·CI 등록** — Rev.1 §8.2 의 `deploy/remove.sh` 한 줄과 §8.4 의 CI 경로 2줄은
Rev.2 에서도 그대로 필수다.
**8.2 (신규) hermes 스키마 미검증** — §2.3. `sessions(id, cwd, started_at)` 은 기존 쿼리와
mock 에서 역산했다. hermes 미설치라 실 CLI 대조 불가. **M4 착수 전 확인 필요.**
**8.3 (신규) agy 의 단일 방어선** — §3.2. agy 는 검증 단계에서 foreign 을 못 막는다(HEAD 동일).
캐시가 침묵하면 `[]` 를 반환하는 선택이 유일한 보호막이므로, 이 동작은 **테스트로 고정**해야 하고
"후보가 안 잡힌다"는 버그 리포트가 올라올 때 되돌리고 싶어질 지점이다. 되돌리면 격리가 깨진다.
**8.4 (신규) `projects` 토큰 제거는 행동 변경** — §5. 리팩터와 분리할 것.
**8.5 (신규) `PYTHONPATH` 상속** — §4.3. 에이전트 CLI 들이 상속한다. 섀도잉 위험은 측정상 없으나
이름 충돌 여부는 구현자가 확인.
---
## 9. 이행 순서 (Rev.1 §10 갱신)
| 단계 | 내용 | 변경점 |
|---|---|---|
| **M0** | 패키지 골격 + **source 시점 `PYTHONPATH` export**(R-6) + `deploy/remove.sh`·`install.sh`·CI 등록 | 부트스트랩 방식 교체 |
| **M1** | `own_key` / `agent_of_row` 이관 (프로토타입 완료, 34 → 29) | 변경 없음 |
| **M2** | `artifact_path` + `verify_artifact`**`ctx` 서명으로 통일**, 격리 경로 일원화 | 서명 변경 |
| **M3** | `spawn_spec` / `resume_spec` / `auth_ok` | 변경 없음 |
| **M4** | `discover()` — drift-C 4블록. **hermes 스키마 확인이 선행**(§8.2) | 선행 조건 추가 |
| **M5** | `stop_session.sh` purge 경로 + exit key | 변경 없음 |
| **M6** | **(신규)** `ready_tokens``wait_for_tui_ready` 25줄 case 제거 | M-2 |
| **M7** | **(신규·별건)** claude ready token 에서 `projects` 제거 + 자체 검증 | §5 |
중단 기준은 그대로: M2 이후 팬아웃이 29 → 20 이하로 안 떨어지면 재검토.
---
## 10. 결론
이의 1건과 제언 2건 전부 채택했다. 그리고 셋 다 조사해 보니 지적된 것보다 컸다 —
C-1 은 서비스 거부가 아니라 **격리 위반**이었고, M-1 은 `run_loop.sh` 를 **죽이는** 문제였으며,
M-2 는 2~3곳이 아니라 **25줄 블록**이었다.
그대로 채택하지 않은 것 하나. agy 의 권고는 `epoch`/`claimed`**어댑터마다** 캡슐화하라는 것인데,
어댑터 4개가 각자 구현하면 잊어버릴 기회가 4번 생긴다. 방금 그 방식으로 두 개가 `cwd`
잊은 것을 확인했다. 필터는 기반 클래스가 갖고, 어댑터는 스코프된 원시 후보만 낸다.
프로토타입 트리: `scratchpad/ad2`(Rev.2) · `scratchpad/ad`(Rev.1) · `scratchpad/adbase`(HEAD).
`IMPROVEMENTS.md` A-4 항목은 Creator 구현 시 본 Rev.2 기준으로 갱신이 필요하다 —
이번 작업에서는 저장소를 건드리지 않았다.
**[AGREEMENT: REACHED]**
@@ -0,0 +1,397 @@
# 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 의 **F0F4, 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]**
@@ -0,0 +1,269 @@
# 7747d745 — 우선순위 평가 및 실행 로드맵 **Rev.2**
**Job**: 7747d745 · **Role**: Planner · **Supersedes**: ecef05a3 (Rev.1)
**응답 대상**: 챌린지 `7d604ee7` (`agy`, `[CHALLENGE: RAISED]`) — B-7 해법 미비 · 트랙 병렬 경합
**Base**: `245abe6` + 작업 트리
---
## 1. 판정 요약
**두 건 모두 채택한다.** 다만 두 건 다 지적 내용 그대로는 성립하지 않는다.
| # | 챌린지 | 판정 | 실측 |
|---|---|---|---|
| C-1 | B-7 해법이 `cd $REPO_ROOT` 뿐이라 미추적 파일을 못 잡는다 | **채택 — 단 전제 오류** | Rev.1 은 B-7 **해법을 아예 명시하지 않았다**. 인용된 `cd "$REPO_ROOT" && git diff` 는 내 문서에 없는 문장이다. 그러나 "진단만 하고 처방을 안 썼다"는 것 자체가 결함이고, 제안된 `git add -N .`**동작한다**(실측) |
| C-2 | A-2 와 O-2/B-8 이 `reconcile.sh` 에서 충돌한다 | **채택 — 지목한 쌍은 존재하지 않음, 그러나 내 트랙 분해가 더 틀렸다** | `reconcile.sh``MAM_LOOP_MARKER` 참조 **0건**, `send_keys_safe` 참조 **0건** → O-2·B-8 은 `reconcile.sh` 를 건드리지 않는다. 반면 파일 단위 매트릭스를 만들어 보니 **내 §4.3 트랙 분해가 4곳에서 틀렸다** |
정정부터. Rev.1 §4.3 은 "트랙 C(정리)는 트랙 A/B 와 독립"이라고 썼다. **틀렸다.**
`B-6``run_loop.sh` 를 고치므로 B-7·O-2 와 같은 파일이고, `C-3a`·`C-4``lib.sh`
고치므로 B-8·A-4 와 같은 파일이다. agy 는 엉뚱한 쌍을 지목했지만 **"파일 단위 대조 없이
독립을 선언했다"는 지적의 실질은 옳고, 실제 피해는 그들이 본 곳보다 넓다.**
`git add -N .` 은 채택하되 **그대로는 쓰지 않는다.** 인덱스를 오염시켜
이후 `git commit -a`**작성자가 추가한 적 없는 파일을 조용히 커밋한다**(실측 §2.3).
Creator 에이전트가 같은 저장소에서 동시에 git 을 쓰는 구조라 이건 이론이 아니다.
**인덱스를 건드리지 않는 동등 대안**을 권한다(§2.4).
---
## 2. C-1 — B-7 해법: 채택, 기전 교체
### 2.1 전제 정정
챌린지는 "Plan §4 의 해법(`cd $REPO_ROOT && git diff`)"을 인용한다.
Rev.1 §4 표의 B-7 칸 전문은 다음과 같다:
> 리뷰어가 빈 diff 로 PASS. 신규 파일은 리뷰 대상 밖. **나머지 11건의 검증 근거를 훼손**(§3.2)
**근거만 있고 해법은 없다.** `cd "$REPO_ROOT"` 는 내가 쓴 적 없는 문장이다.
그러나 이건 방어가 아니라 자기 결함의 확인이다 — **P1-1 로 올려 놓고 처방을 안 썼다.**
구현자가 §3.2 의 두 원인 중 눈에 띄는 쪽(cwd)만 고치고 끝냈을 가능성이 크고,
챌린지는 정확히 그 시나리오를 예측했다. 처방을 명시하는 것으로 갚는다.
### 2.2 `git add -N .` 은 동작한다 (실측)
빈 저장소에 tracked 수정 1건 · untracked 신규 2건 · `.gitignore` 대상 1건을 심고 측정했다.
```
[before] git diff $BASE --stat
tracked.txt | 1 + ← 신규 파일 0건
[after] git add -N . ; git diff $BASE --stat
pkg/__init__.py | 1 + ← 잡힘
sub/newfile.py | 1 + ← 잡힘
tracked.txt | 1 +
ignored.log hunks: 0 ← .gitignore 존중됨
```
**제안의 두 가지 핵심 주장이 모두 참이다**: 미추적 신규 파일이 diff 에 포함되고,
`.gitignore` 는 그대로 존중된다.
### 2.3 그러나 인덱스가 오염된다 — 그리고 그게 커밋으로 샌다
`git add -N .` 직후 인덱스 상태:
```
A pkg/__init__.py
A sub/newfile.py
M tracked.txt
```
이 상태에서 Creator 가 `git commit -am "wip"` 을 실행하면:
```
$ git commit -qam "creator wip" ; git show --stat HEAD
pkg/__init__.py | 1 +
sub/newfile.py | 1 +
tracked.txt | 1 +
-> sub/newfile.py in that commit? brand new ← 내용까지 들어갔다
```
**작성자가 `git add` 한 적 없는 파일이 `-a` 한 번에 커밋된다.** 평소 `git commit -a`
미추적 파일을 건드리지 않으므로, 이건 **git 의 기본 안전 성질을 바꾸는 부작용**이다.
MAM 에서 이게 가설이 아닌 이유: `run_loop.sh` 는 Creator 에이전트가 **같은 저장소에서
동시에 작업하는 동안** 돌아간다. 루프가 인덱스를 바꾸는 시점과 Creator 가 git 을 쓰는
시점이 겹친다. 게다가 루프는 반복 실행되므로 오염이 매 사이클 재발한다.
`git reset` 으로 되돌리는 보정을 붙일 수도 있지만, (a) 비정상 종료 시 남고
(b) 되돌리는 순간과 Creator 의 git 호출이 또 경합한다. **부작용을 만들고 지우는 대신
애초에 만들지 않는 편이 낫다.**
### 2.4 권고: 인덱스를 건드리지 않는 동등 대안
```bash
CHANGES_DIFF=$(
cd "$REPO_ROOT" || exit 1
git diff "$BASE_COMMIT"
# 미추적 신규 파일: 인덱스를 바꾸지 않고 /dev/null 대비 diff 로 덧붙인다.
# --exclude-standard 가 .gitignore/.git/info/exclude 를 그대로 존중한다.
git ls-files -o --exclude-standard -z | while IFS= read -r -d '' f; do
git diff --no-index --binary /dev/null "$f" 2>/dev/null || true
done
)
```
같은 픽스처 실측:
```
diff --git a/tracked.txt b/tracked.txt ← 기존 파일 수정
diff --git a/sub/newfile.py b/sub/newfile.py ← 신규 파일
--- /dev/null
+++ b/sub/newfile.py
ignored.log present? 0 ← .gitignore 존중
[index] M tracked.txt / ?? sub/ ← 인덱스 무변경
```
동일한 결과를 내면서 인덱스를 건드리지 않는다.
> `git diff --no-index` 는 두 경로가 모두 저장소 밖일 때 rc=1 을 반환하지만,
> 여기서는 차이가 있을 때 rc=1 이 정상이므로 `|| true` 로 흡수한다.
> `-z` + `IFS= read -r -d ''` 는 공백·개행이 든 파일명을 위한 것이다.
### 2.5 함께 고쳐야 할 것 — `cd` 와 크기 상한
**(a) `cd "$REPO_ROOT"`** 는 여전히 필요하다. §3.2 의 두 원인 중 하나이고
서브셸 안에서 처리하면 호출자 cwd 를 오염시키지 않는다(위 코드에 반영).
**(b) 크기 상한이 없다.** 실측: `run_loop.sh:537,539` 에서 만든 `CHANGES_DIFF`
**아무 제한 없이** 547행의 리뷰 프롬프트 문자열에 그대로 보간되고,
그 프롬프트는 `send_keys_safe` 를 통해 TUI paste-buffer 로 주입된다.
미추적 파일을 포함시키면 diff 는 **커지기만 한다**. 누군가 큰 산출물을 ignore 하지 않은 채
남겨 두면 리뷰 주입이 통째로 실패하거나 잘린다.
**권고**: 상한(예: 200 KB / 4000 줄)을 두고 초과 시 `--stat` 요약 + 초과 사실 명시로 대체.
**잘렸다는 사실이 리뷰어에게 반드시 보여야 한다** — 조용히 잘리면 B-7 을
"빈 diff 로 PASS" 에서 "부분 diff 로 PASS" 로 바꾸는 것에 지나지 않는다.
---
## 3. C-2 — 트랙 경합: 지목한 쌍은 없고, 내 분해가 더 틀렸다
### 3.1 지목된 두 쌍은 성립하지 않는다
```
reconcile.sh 내 MAM_LOOP_MARKER / loop-guard-active 참조 → 0건
reconcile.sh 내 send_keys_safe / inject_instructions 참조 → 0건
```
- **O-2**: 마커는 `run_loop.sh:83-89` 에만 있다. `reconcile.sh`**자기 자신의 별도 락**
(`.mam/monitor.lock`, `fcntl.flock`, 86-95행)을 이미 갖고 있다 — 다른 프로세스를 위한
다른 뮤텍스다. O-2 의 처방은 `run_loop.sh` 안에서 끝난다.
- **B-8**: `send_keys_safe``lib.sh` 함수이고 `reconcile.sh` 는 이를 호출하지 않는다.
따라서 "A-2 ⟂ O-2/B-8 이 `reconcile.sh` 에서 충돌"은 **실재하지 않는다.**
### 3.2 그러나 Rev.1 §4.3 은 실제로 틀렸다
챌린지가 제기한 방법론적 문제 — **파일 단위 대조 없이 독립을 선언했다** — 는 옳다.
각 항목의 처방이 건드리는 파일을 근거에서 도출해 매트릭스를 만들었다.
| 파일 | 건드리는 항목 |
|---|---|
| `run_loop.sh` | **B-6, B-7, O-2** |
| `lib.sh` | **A-4, B-8, B-10, C-3a, C-4** |
| `reconcile.sh` | **A-2, A-4, B-10** |
| `mqtt_common.py` | **A-2, B-9** |
| `stop_session.sh` | **B-10, C-6** |
| `registry.py` | **A-2, C-4** |
| `create_session.sh` | **A-4, C-4** |
Rev.1 §4.3 의 오류 4건:
1. **`B-6` 을 트랙 C(독립)에 뒀다.** `run_loop.sh` 이므로 B-7·O-2 와 같은 파일이다.
2. **`C-3a`·`C-4` 를 트랙 C(독립)에 뒀다.** `lib.sh` 이므로 B-8·A-4 와 같은 파일이다.
3. **`B-9` 를 P5 독립으로 뒀다.** `mqtt_common.py` 이므로 A-2 와 같은 파일이다.
4. **`C-6` 을 독립으로 뒀다.** `stop_session.sh` 이므로 B-10 과 같은 파일이다.
agy 가 지목한 A-2↔O-2 는 없지만 **A-2↔A-4, A-2↔B-10, A-2↔B-9, A-2↔C-4** 는 있다.
`lib.sh` 는 5개 항목이 몰리는 최대 경합 지점이다.
### 3.3 결론: "트랙"이 아니라 "파일 소유권"으로 직렬화한다
트랙 개념 자체가 잘못된 추상화였다. 병렬 단위를 **주제**가 아니라 **파일**로 잡는다.
| 파일 소유 슬롯 | 순서 | 동시 실행 가능 |
|---|---|---|
| **`run_loop.sh`** | B-7 → O-2 → B-6 | 다른 슬롯과 병렬 |
| **`lib.sh`** | C-3a+C-4 → B-8 → (A-4 M0~) | 다른 슬롯과 병렬 |
| **MQTT 계열**(`mqtt_common.py`·`registry.py`·`publish_event.py`·`job_subscriber.py`·`reconcile.sh`) | A-2 → B-9 | 다른 슬롯과 병렬 |
| **`stop_session.sh`** | C-6 → (B-10) | 다른 슬롯과 병렬 |
- 한 슬롯 안은 **직렬**, 슬롯 간은 **병렬**. 슬롯을 넘는 항목(**A-4**, **B-10**)은
**단독 실행**한다 — A-4 는 `lib.sh`+`reconcile.sh`+`create_session.sh`,
B-10 은 `lib.sh`+`reconcile.sh`+`stop_session.sh` 이므로 어떤 슬롯 조합과도 겹친다.
- `reconcile.sh` 를 MQTT 슬롯에 넣은 이유: A-2 가 그 파일에서 가장 큰 변경을 하고,
나머지 두 소비자(A-4·B-10)는 어차피 단독 실행이다.
**우선순위 표(Rev.1 §4)의 순위 자체는 바뀌지 않는다.** 바뀌는 것은 병렬화 방식뿐이다.
---
## 4. 변경 요약 (Rev.1 대비)
| ID | 대상 | 내용 |
|---|---|---|
| R-1 | B-7 처방 (신규) | `cd "$REPO_ROOT"` + `git ls-files -o --exclude-standard` 기반 미추적 파일 덧붙이기. **`git add -N` 은 채택하지 않음**(인덱스 오염, §2.3) |
| R-2 | B-7 처방 (신규) | `CHANGES_DIFF` 크기 상한 + **잘림 사실 명시** |
| R-3 | §4.3 교체 | "트랙" → **파일 소유권 슬롯**. Rev.1 의 독립 선언 4건 정정 |
| R-4 | A-4 · B-10 | 슬롯 경계를 넘으므로 **단독 실행** 명시 |
우선순위(P0-1 ~ P5, 종결 권고 B-5)와 §3 실측 결과는 **전부 그대로 유효**하다.
---
## 5. 검증
전부 임시 저장소(`scratchpad/b7`, `b7b`)에서 실측했다. 프로덕션 저장소의 인덱스는
**건드리지 않았다** — 인덱스 오염이 바로 이 논점이므로 실 저장소에서 재현하는 것은 부적절하다.
| 검증 | 결과 |
|---|---|
| `git add -N .` 이 미추적 파일을 diff 에 포함시키는가 | ✅ 포함 (2/2 신규 파일) |
| `.gitignore` 존중 | ✅ `ignored.log` 0 hunks |
| 인덱스 잔존 여부 | ❌ `A pkg/__init__.py`, `A sub/newfile.py` 잔존 |
| 잔존 상태에서 `git commit -a` | ❌ **추가한 적 없는 파일이 내용째 커밋됨** |
| 대안(`ls-files -o` + `--no-index`) 포함 여부 | ✅ 포함 |
| 대안의 `.gitignore` 존중 | ✅ 0 hunks |
| 대안의 인덱스 영향 | ✅ 무변경 (`?? sub/` 유지) |
| `reconcile.sh` 의 O-2 심볼 참조 | 0건 → C-2 전제 반증 |
| `reconcile.sh` 의 B-8 심볼 참조 | 0건 → C-2 전제 반증 |
| `CHANGES_DIFF` 크기 상한 | 없음 (537·539 → 547 무제한 보간) |
---
## 6. 남는 불확실성
Rev.1 §6 의 4건(A-2 노출도 · O-2 경합 창 · B-9 호출자 전수 · A-4 상한)은 그대로 유효하다. 추가분:
**6.5 크기 상한값은 근거 없이 제시했다.** §2.5 의 "200 KB / 4000 줄"은 관례적 수치이지
측정값이 아니다. `send_keys_safe` 의 paste-buffer 가 실제로 어느 크기에서 실패하는지는
측정하지 않았다 — 실 세션에 대용량 주입을 시도하는 실험이라 Planner 범위에서 부적절하다.
**B-7 구현자가 샌드박스 세션에서 상한을 측정해 확정할 것.**
**6.6 파일 매트릭스는 처방 기준의 추정이다.** 각 항목이 실제로 어느 파일을 건드릴지는
구현 단계에서 늘어날 수 있다(특히 테스트 파일). 슬롯 배치는 구현 착수 시 재확인해야 한다.
**6.7 `git ls-files -o` 는 서브모듈·심링크를 이 저장소에서 검증하지 않았다.**
MAM 저장소에는 서브모듈이 없어 실측 대상이 아니었다. 다른 워크스페이스에 배포될 때를
고려하면 구현자가 한 번 확인하는 편이 좋다.
---
## 7. 결론
두 챌린지 모두 **전제는 틀렸고 결론은 맞다.**
C-1 이 인용한 `cd $REPO_ROOT && git diff` 는 내 문서에 없다 — 나는 B-7 의 처방을
**아예 쓰지 않았다**. 그게 더 나쁘다. 제안된 `git add -N .` 은 실제로 동작하지만
인덱스를 오염시켜 `git commit -a` 가 추가한 적 없는 파일을 커밋하게 만든다.
동등하면서 부작용 없는 형태로 교체해 채택한다.
C-2 가 지목한 A-2↔O-2/B-8 충돌은 `reconcile.sh` 참조 0건으로 **존재하지 않는다**.
그러나 파일 매트릭스를 만들어 보니 **내 트랙 분해가 4곳에서 틀렸고**,
`lib.sh` 에는 5개 항목이 몰려 있었다. 트랙이라는 추상화를 버리고 파일 소유권 슬롯으로 바꾼다.
우선순위 순서 자체는 Rev.1 그대로다. 바뀐 것은 **B-7 의 처방**과 **병렬화 방식** 두 가지다.
**[AGREEMENT: REACHED]**
@@ -0,0 +1,82 @@
# Cross-Code Review — Job `0d9712c6`
- **Job ID**: 0d9712c6 · **Reviewer**: cline · **Base**: `245abe6` (working-tree, uncommitted)
- **Task**: `BaseAgentAdapter` (A-4) 아키텍처 설계를 `IMPROVEMENTS.md` 백로그에 등재한 누적 변경분에 대한 교차 코드 리뷰 (lint / 동작성 / 유실)
- **Diff scope**: `IMPROVEMENTS.md` 단일 파일 — `git diff --stat` = **1 file changed, 49 insertions(+), 3 deletions(-)**
---
## 1. 변경분 요약 및 검증 대상
변경분은 코드가 아니라 **문서(백로그)**다. `IMPROVEMENTS.md` §1(아키텍처 결함)에 **A-4 (설계 제안): `BaseAgentAdapter` 어댑터 계층 도입 (Rev.2)** 항목을 신규 등재하고, 상단 집계(`11건 → 12건`, `아키텍처 1건 → 2건`)와 §1 제목 카운트(`1건 → 2건`), 최종 갱신일을 갱신했다. 저장소에 손댄 파일은 `IMPROVEMENTS.md` 하나뿐이다(`git status --porcelain`: ` M IMPROVEMENTS.md`).
브리프에 포함된 diff 헤더와 실제 `git diff`는 정확히 일치한다. `mam_agents` 패키지/자산은 skills·deploy·tests 어디에도 존재하지 않음을 확인(`grep -rn mam_agents` 결과 0건, 보고서 제외) — 즉 이 변경은 순수 설계 기록이며 런타임 영향은 0이다.
| 검증 항목 | 방법 | 결과 |
|---|---|---|
| Diff 일치 (브리프 vs working tree) | `git --no-pager diff IMPROVEMENTS.md` | ✅ 정확 일치 |
| 코드/자산 부재 확인 | `grep -rn mam_agents .agents/skills deploy tests` | ✅ 0건 (순수 문서) |
| 참조 프로토타입 보고서 존재 | `ls .mam/jobs/44062a63/claude-reports/report-final.md` | ✅ 존재 (18 KB) |
---
## 2. Lint (정적 품질)
`IMPROVEMENTS.md`는 Markdown 문서이므로 셸/파이썬 린트 대상이 아니다. Markdown 구조 정합성만 점검했다.
- 헤더 계층(`#`/`##`/`###`/`####`) 일관, 테이블(`단계|내용`) 열 수 정합(2열), 인용 블록(`> 결함 조치가 아니라...`) 정상 종료.
- 인라인 코드 백틱 쌍 정합, 한국어/영문 혼용 깨짐 없음.
- 집계 숫자 변경(상단 `12건`/`아키텍처 2건` ↔ §1 제목 `2건`) 정합. `완료된 과제 10건` 줄과 기존 A-2 항목은 미변경(손대지 않음).
**Lint 결과: PASS** — 구조적 결함 없음.
---
## 3. 동작성 (설계 주장의 코드베이스 정합성)
코드 변경이 없으므로, 설계 제안이 현 코드베이스 사실과 일치하는지(거짓 주장·과장 여부)를 검증했다.
| 설계 주장 | 코드베이스 실측 | 판정 |
|---|---|---|
| `agent → *_id_own` 키 맵 **4벌** | 프로덕션 맵 3곳(`reconcile.sh:434`, `reconcile.sh:583`, `lib.sh:1393`) + 테스트 헬퍼 1곳(`conftest.py:262`) = 4 | ✅ 정합 |
| 세션명→에이전트 추론 **2벌**(규칙 상이) | `reconcile.sh:568 row_agent`(pane.cmd→cmd_full→접미사) vs `run_loop.sh:233-243`(세그먼트 매칭 + 실패 시 `claude` 기본값) | ✅ 정합 — 후자 오판 가능성 실재 확인 |
| `deploy/remove.sh:83-91` `fallback_assets` 미등록 | `remove.sh:83` `fallback_assets=(...)` 리스트 확인 — `.agents/skills/mam_agents` 누락 | ✅ 선행 체크리스트 #1 유효 |
| `tests/test_deploy_freshness.py::test_d2` 가드 | `test_d2_manifestless_removal_strands_no_framework_assets` 존재 | ✅ 선행 체크리스트 #1 근거 유효 |
| `gitea-ci.yml:69-77` flake8/py_compile 범위 제한 | `deploy/gitea-ci.yml:69,71,76``multi-agent-mux-delegate-job/scripts/` 한정 | ✅ 선행 체크리스트 #3 유효 |
| herdr shim `python3 -c` 9곳 에이전트 지식 0 | 본 리뷰 범위 외(프로토타입 실측)이나 참조 보고서 존재 | ⚠️ 미검증(프로토타입 영역) |
| 162 passed / 변이 6/6 / 배포 25/25 | 프로토타입 트리(저장소 미반영) — 재실행 불가 | ⚠️ 미검증(프로토타입 영역) |
프로토타입 실측 수치(hermes shim, 162 passed 등)는 저장소에 반영되지 않은 scratchpad 결과이므로 본 리뷰에서 재검증할 수 없다. 다만 **저장소에 존재하는 사실**(키 맵 산재, 추론 2벌, fallback_assets/CI 범위)은 전부 정확히 확인됐다. 설계가 허위/과장에 기대지 않음.
**동작성 결과: PASS**
---
## 4. 유실 (Loss / Orphan)
`git diff`**삭제 3줄** 모두 교체성 갱신(최종 갱신일, 총 건수, §1 제목 카운트)이며 원 정보 손실 아님:
- `2026-08-08 (B-4 ...)``2026-08-09 (A-4 ... 등재)` : 갱신일 갱신(정당)
- `11건 (아키텍처 1건...)``12건 (아키텍처 2건...)` : 신규 항목 반영(정당)
- `Architecture Flaws — 1건``— 2건` : 항목 증가 반영(정당)
기존 `A-2` 항목 본문, `완료된 과제 10건` 줄, §2~§6 섹션은 미변경(존재 보존 확인). 신규 자산(import/경로) 추가 없으므로 orphan 임포트/변수도 발생하지 않는다.
**유실 결과: PASS** — 부당 삭제/잔재 없음.
---
## 5. 비차단 발견 (Non-blocking Findings)
**N-1 (참조 보고서 내 diff stat 불일치, 비본 diff).** 참조된 프로토타입 보고서(44062a63) 본문에 `git diff --stat: +44 / -3`로 기재됐으나, 실제 working-tree diff는 **+49 / -3**이다. 이는 *참조 보고서*의 기재 오류로, 본 리뷰 대상 diff(`IMPROVEMENTS.md`) 자체의 결함은 아니다. 비차단.
**N-2 (섹션 명칭 vs 항목 성격).** §1 제목이 "아키텍처 **결함**"인데 A-4는 결함이 아닌 **설계 제안**이다. 등재자는 이를 인지하고 항목 제목에 `(설계 제안)`을 명시했으며, "아키텍처 과제"로의 개명은 문서 소유자 판단으로 남겨둠을 명시했다. 비차단 — 의도적 보존.
**N-3 (프로토타입 수치 미검증).** "162 passed / 변이 6/6 / 배포 25/25" 및 hermes shim 9곳 지식-0 주장은 scratchpad 프로토타입 결과로, 저장소에 반영되지 않아 본 리뷰에서 재실행 불가. 설계 근거로서는 참조 보고서 존재로 충분하나, 정식 구현(M0~) 시점에 재측정이 권장됨. 비차단.
---
## 6. 종합 판정
변경분은 `IMPROVEMENTS.md` 단일 문서에 대한 순수 추가적 설계 기록이다. 코드·배포 자산·테스트에 대한 변경이 전무하여 런타임·린트·회귀 영향은 0이다. 설계가 인용한 코드베이스 사실(키 맵 4벌, 추론 2벌 상이, fallback_assets/CI 범위 제한)은 실측 결과 전부 정확하며, 선행 필수 체크리스트 3항이 현 코드베이스의 실제 제약에 기반해 있다. 부당 삭제나 잔재도 없다. 단순 버그 수정 이상의 설계 재작업이 필요한 근거(escalation)는 발견되지 않는다 — 이 변경은 애초에 백로그 설계 제안 등재라는 명시적 산출물이며 그 목표를 충족한다.
[VERDICT: PASS]
@@ -0,0 +1,90 @@
# Cross-Code Review Report — Job 3b42cc9b
- **Job ID**: 3b42cc9b
- **Reviewer**: cline (herdr:canary-projects-multi-agent-mux-creator-cline)
- **Base commit**: `245abe6` (working tree clean — diff reviewed: `9df0fc3..245abe6`)
- **Scope**: Audit `create_session.sh`, `reconcile.sh`, `resolve_session_id.sh`, `lib.sh` for 5 objectives.
- **Output**: `.mam/jobs/3b42cc9b/cline-reports/report-final.md`
## 1. Audit Scope & Method
The task is an **audit** of the current committed state of the four target scripts against five stated objectives:
1. Sequential prompt injection
2. Post-spawn auto-pinning
3. Occupied-ID preemption guard
4. Stage 3 viewport verification
5. No UUID cross-talk or shadowing
Method: read each target file end-to-end, trace each objective from creation → reconcile → resume, run `bash -n` (×4) + embedded-Python `compile()` (×8), then execute the three relevant test suites against the live tree.
## 2. Lint & Test Results
| Check | Result |
|---|---|
| `bash -n` `lib.sh` | PASS |
## 3. Objective-by-Objective Audit
### 3.1 Sequential Prompt Injection — PASS
`create_session.sh` enforces a strict spawn→ready→inject sequence:
- `spawn` (L165) → `wait_for_tui_ready` (L205, polls up to 30×1s for agent-specific ready tokens) → `handle_startup_dialogs` (claude only, L211) → pane meta capture → YAML append → **single** `inject_instructions` call (L376).
- No prompt is injected before the TUI is ready; only one prompt is injected per creation (no concurrent multi-prompt race).
- `inject_instructions` (lib.sh L1847) delegates to `send_keys_safe` (lib.sh L1932), which waits for `_pane_quiescent`, clears blocking dialogs (timeout-bounded), then atomically `set-buffer`/`paste-buffer`/`delete-buffer` + `C-m`. Submission is verified against rendered tokens (`●`, `✽`, `…ing`, `esc to interrupt`) over up to 3 retries.
- The `--submit-job` path publishes `started` **only after** injection returns rc 0 (L382); on failure it publishes `error` and exits 1 (L378-380). Sequential and ordered.
### 3.2 Post-Spawn Auto-Pinning — PASS
- `create_session.sh` (claude, L153/L157): `SESSION_UUID="$(mam_gen_uuid)"``CMD_FULL="... --session-id ${SESSION_UUID}"` → YAML stores `claude_session_id_own=assigned`, `session_id_source='assigned'`, `session_id_verified=False` (L320-323).
- `reconcile.sh` drift C0 confirms the assigned ID once the transcript materializes: `verify_session_uuid(mode="revalidate")` (lib.sh L1207-1209 shortcut returns True when workspace matches + source==assigned + verified==False, then the on-disk `.jsonl` check at L1212-1244 confirms it), after which `_pin_and_verify_resume` (reconcile.sh L432) sets `session_id_verified=True` and `last_visible_status='pinned'`.
- An immediate priority reconcile cycle is kicked off asynchronously right after creation (create_session.sh L384: `reconcile.sh --once &`), so pinning is attempted promptly without waiting for the next scheduled cycle.
### 3.3 Occupied-ID Preemption Guard — PASS
Four independent layers enforce that a fresh/resume session never gets an ID already occupied:
1. **Assign-time**: `mam_gen_uuid` generates a fresh random UUID (no reuse of existing).
2. **Resolve-time** (`find_workspace_uuid`, lib.sh L1406-1420): builds `running_ids` from ALL running sessions' own-IDs; `emit(u)` silently skips any UUID in `running_ids`. A resume will never be handed a live session's ID.
3. **Discover-time** (agy path, `verify_session_uuid` lib.sh L1263-1265): rejects a candidate present in `row['_sibling_claimed_uuids']` — collected in reconcile.sh L664-673 from sibling rows sharing the same cwd that are not stopped/terminated.
4. **Write-time** (validation layer, lib.sh L1083-1094): ID Uniqueness Check raises `SystemExit` if two running sessions share the same own-ID — defense-in-depth at persistence time.
| `bash -n` `create_session.sh` | PASS |
| `bash -n` `reconcile.sh` | PASS |
| `bash -n` `resolve_session_id.sh` | PASS |
| Embedded Python `compile()` (8 blocks across 5 files) | PASS |
| `tests/test_uuid_target.py` | **13/13 PASS** (53.43s) |
### 3.4 Stage 3 Viewport Verification — PASS
`verify_tui_viewport` (lib.sh L1338-1363) implements the 3-stage viewport check:
- rc 2: session gone or pane capture empty/unavailable (degraded).
- rc 0: workspace `basename` (whitespace-stripped) appears in pane content (match).
- rc 1: a `/path/` pattern appears but the workspace basename does not (mismatch).
`reconcile.sh` (all 4 agents, e.g. agy L686-695) gates pinning on this: with exactly one valid candidate, rc 0 → `_pin_and_verify_resume(degraded=False)`; rc 1 → `C-warn`, **not pinned** (will retry); rc 2 → `_pin_and_verify_resume(degraded=True)` (pin via stages 1-3 only, documented degraded path). Tests T-6 (degraded) and T-7 (mismatch) cover the non-happy paths.
### 3.5 No UUID Cross-Talk or Shadowing — PASS
- **Workspace scoping**: `verify_session_uuid` ORDERING INVARIANT (lib.sh L1199-1205) — the `workspace_key(cwd) != workspace_key(ws)` check runs BEFORE the assigned-id shortcut, so a row from a **different** workspace is rejected first even when assigned+unverified (tested T-12). `find_workspace_uuid` only considers sessions whose `pane.cwd == ws` (L1426).
- **C-ambiguous guard** (reconcile.sh, all 4 agents): when `len(valid_candidates) > 1`, reports `C-ambiguous` and does **not** pin (tested T-4) — no silent attribution of a possibly-wrong UUID.
- **Path canonicalization**: `mam_abs_workspace` uses `cd -P && pwd -P` (physical path) and `workspace_key` uses `os.path.realpath`. Shell (create/resolve) and Python (verify/find) therefore agree on the workspace key, preventing cross-talk from symlink/logical-path divergence (tested T-10 symlink + 6/6 path forms).
- `resolve_session_id.sh` (L44) is a thin wrapper over `find_workspace_uuid`, preserving the same workspace-isolated resolution path (P0-C: never returns a global id whose `project_cwd` differs from this workspace).
| `tests/test_o3_scoped_guard.py` + `test_sanity.py` + `test_b4_session_created.py` | **47/47 PASS** (18.03s) |
| `tests/test_tier3_integration.py::test_integration_stop_purge_combination` | **1/1 PASS** (33.26s) |
## 4. Findings (Non-Blocking)
All findings are non-blocking; none require design rework.
| # | Finding | Severity | Location |
|---|---|---|---|
| A-1 | **Wrapper-mode clears `SESSION_UUID` after `CMD_FULL` is composed.** In `spawn`'s claude wrapper branch (L170), `SESSION_UUID=""` is set *after* `CMD_FULL` already baked `--session-id ${SESSION_UUID}` (L157). The YAML `cmd_full` display field (L304) therefore records `--session-id <uuid>` even though the wrapper launch cleared it. The authoritative fields (`claude_session_id_own`, `session_id_verified`) are unaffected, so pinning/resume are correct; only the cosmetic `cmd_full` string is misleading. | Low / cosmetic | create_session.sh L157, L170, L304 |
| A-2 | **`verify_session_uuid` breaks on first cwd-bearing line.** In the claude branch (L1234-1236), the loop breaks as soon as a line carrying `cwd` is found, even if `sessionId` has not yet been confirmed on that line. If a transcript interleaves an unrelated `cwd` before the matching `sessionId` line, the function could `break` before `valid_session` is set. Safe-direction (fails closed — returns False rather than mis-attribute), so not a correctness bug, but worth a comment. | Low | lib.sh L1232-1236 |
| A-3 | **`mam_session_iso_root`/`mam_workspace_key` spawn Python per resume.** Each resume call re-launches a Python interpreter for these helpers, adding minor latency. No correctness impact. | Low / perf | lib.sh (helpers) |
| A-4 | **Drift-B `endswith` vs `row_agent()`.** The drift-C loops in `reconcile.sh` use the `row_agent()` helper, but the drift-B A-1 gate (the `endswith('-creator-<agent>')` inference in `find_workspace_uuid` L1473-1488) uses inline `endswith`. Different context (B is offline resolution; C is live reconcile), so not a bug, but a single-source consolidation would reduce drift. | Low / consistency | lib.sh L1473-1488 |
No `[ESCALATE: PLANNER]` is warranted: every objective is satisfied and all findings are local fixes or cosmetic.
## 5. Completeness & Loss Check
- **Completeness**: all 5 objectives are implemented and exercised by `test_uuid_target.py` (13 cases) plus the cross-regression suite (47 cases). Every objective maps to at least one passing test (T-4 ambiguous, T-6/T-7 viewport, T-10 symlink, T-12 ordering invariant, T-5 custom name pinning).
- **Loss check**: the committed diff (`9df0fc3..245abe6`) adds `mam_gen_uuid`, `mam_abs_workspace`, `mam_workspace_key`, `mam_session_iso_root`, the C0/C-ambiguous/revalidate logic, and path canonicalization without removing prior resolution behavior for non-claude agents (agy/hermes/cline branches preserved and extended). No functional regression (47/47 + 1/1).
- **Working tree**: clean — no uncommitted changes outstanding for this scope.
[VERDICT: PASS]
@@ -0,0 +1,146 @@
# 🛡️ Cross-Code Review: Job cfe439f6 — Auto UUID Capture & Pinning for Fresh Agent Sessions
- **Job ID**: cfe439f6
- **Reviewer**: cline
- **Target**: Review and implement robust automatic UUID capture and pinning mechanisms for fresh agent sessions (preventing null `session_id_own` after create/onboard). Update `create_session.sh`, `reconcile.sh`, `lib.sh`, and relevant skill/rule docs.
- **Change scope**: 13 modified files + 1 new test file (uncommitted working-tree diff; 268 insertions, 62 deletions).
## 0. Verdict Summary
The implementation is **sound and complete**. The core goal — preventing a null `session_id_own` after `create`/`onboard` by assigning a UUID at creation time and confirming it on disk via the reconciler — is achieved through a coherent three-stage protocol: (1) `create_session.sh` generates a UUID via `mam_gen_uuid` and passes `claude --session-id <uuid>`, recording `session_id_source: assigned` / `session_id_verified: false`; (2) `reconcile.sh` drift C0 verifies the transcript materialized and promotes `session_id_verified: true` / `last_visible_status: pinned`; (3) `resume_session.sh` detects whether the transcript exists on disk and chooses `--session-id` (fresh) vs `-r` (resume). Path normalization (`os.path.realpath` / `cd -P && pwd -P`) is applied consistently across shell and Python, closing the symlink/trailing-slash key-mismatch class. Lint passes, the new 13-case suite is green, and there is no cross-regression. Five non-blocking findings are documented below; none require a design-level rework.
[VERDICT: PASS]
---
## 1. Change Inventory (verified against working tree)
| File | Change | Status |
|---|---|---|
| `.agents/MULTI_AGENT_RULES.md` / `.ko.md` | New "Session ID Lifecycle & Auto-Assignment Protocol" section (auto-assign, first-message materialization, C0 confirmation, C-ambiguous guard, path equivalence) | ✅ verified |
| `.agents/skills/lib.sh` | New `mam_gen_uuid`, `mam_abs_workspace`, `mam_workspace_key`, `mam_session_iso_root`; `workspace_key``os.path.realpath`; `verify_session_uuid` multi-line scan + revalidate shortcut + iso_root paths + ordering invariant; `verify_tui_viewport` simplified; `find_workspace_uuid`/`verify_tui_viewport``mam_abs_workspace`; `_pane_capture` JSON unwrap | ✅ verified |
| `create_session.sh` | `mam_gen_uuid` for claude; `--session-id` flag in CMD_FULL; `session_id_source`/`session_id_verified` fields in YAML | ✅ verified |
| `reconcile.sh` | `row_agent()` helper; drift C0 (assigned-ID confirmation); C-ambiguous guard (all 4 agents); `os.path.realpath` in drift-B A-1 gate; drift-C loops use `row_agent()` | ✅ verified |
| `resume_session.sh` | `CLAUDE_ID_FLAG` logic: `--session-id` if transcript unmaterialized, `-r` if exists; `mam_workspace_key` + `mam_session_iso_root` for path resolution | ✅ verified |
| `multi-agent-mux-create/SKILL.md` | Pitfalls updated: removed "don't trust --session-id" / "first message generates id"; added auto-assignment + materialization docs | ✅ verified |
| `multi-agent-mux-monitor/SKILL.md` | Drift C rewritten: C0 (assigned confirmation), C (unassigned materialize), C-ambiguous (multiple candidates) | ✅ verified |
| `multi-agent-mux-resume/SKILL.md` | CMD_FULL comment: `--session-id` vs `-r` based on transcript existence | ✅ verified |
| `IMPROVEMENTS.md` | Rev.2 (b4a1d094) completed-task entry | ✅ verified |
| `LOG.md` | Agent status `stopped``running`, "캡처 완료"→"복원 완료" | ✅ verified |
| `tests/conftest.py` | Mock extracts `--session-id <uuid>` from cmd; `ws_abs = os.path.realpath(cwd)` | ✅ verified |
| `tests/test_tier3_integration.py` | `key = os.path.realpath(str(tmp_path))...` | ✅ verified |
| `tests/test_uuid_target.py` | NEW — 13 tests (T-1..T-13) | ✅ verified |
`git status --porcelain`: ` M` on 13 tracked files + `??` on `tests/test_uuid_target.py` (uncommitted — see R-4).
---
## 2. Lint / Static Checks
| Check | Command | Result |
|---|---|---|
| Shell syntax (lib.sh) | `bash -n .agents/skills/lib.sh` | ✅ PASS |
| Shell syntax (reconcile.sh) | `bash -n .../reconcile.sh` | ✅ PASS |
| Shell syntax (create_session.sh) | `bash -n .../create_session.sh` | ✅ PASS |
| Shell syntax (resume_session.sh) | `bash -n .../resume_session.sh` | ✅ PASS |
| Embedded Python (`_pane_capture`) | `sed -n '1872,1882p' lib.sh \| python3 -c 'compile(...)'` | ✅ PASS |
| `workspace_key` availability in reconcile | `exec(os.environ['MAM_VERIFY_PY'])` at reconcile.sh:327 loads `workspace_key` + `verify_session_uuid` from shared `VERIFY_SESSION_PYTHON` | ✅ no NameError |
| Path-key parity | `mam_workspace_key` (shell `tr '/_' '--'`) vs Python `.replace("/","-").replace("_","-")` — both over `realpath`/`cd -P && pwd -P` | ✅ verified by T-13 |
`shellcheck` is not installed locally (matches prior job convention); CI gate remains the authority for that check.
---
## 3. Operability / Logic Review
### 3.1 Create: auto-assignment (`create_session.sh:148-157, 312-324`)
- `SESSION_UUID="$(mam_gen_uuid)"` for claude only (agy/hermes/cline don't accept `--session-id`).
- `CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --session-id ${SESSION_UUID}"`.
- YAML entry records `claude_session_id_own = assigned`, `session_id_source = 'assigned'`, `session_id_verified = False`, `last_visible_status = "assigned (awaiting first message)"`. Correct — the row is created atomically with the assigned UUID, so there is no window where `session_id_own` is null after create. This is the core fix for the stated goal.
### 3.2 Reconciler C0: assigned-ID confirmation (`reconcile.sh:587-610`)
- Iterates `session_id_source == 'assigned' and not session_id_verified` running sessions.
- Calls `verify_session_uuid(cwd, agent, uuid, s, mode="discover")` — note **discover** mode, so the revalidate shortcut does NOT fire; the agent-specific transcript check runs (file exists, sessionId matches, cwd matches, epoch floor applies).
- On success: promotes `session_id_verified = True`, `last_visible_status = 'pinned'`, reports drift class C "confirmed on disk".
- **Separation from drift C**: drift C only processes sessions where `claude_session_id_own` is falsy (`if s.get('claude_session_id_own'): continue`). An assigned session has the UUID set, so drift C skips it. No double-processing. Correct.
### 3.3 Reconciler C-ambiguous guard (`reconcile.sh:628-633, 678-683, 721-726, 765-770`)
- For unassigned/legacy sessions, after scanning candidates, `if len(valid_candidates) > 1`: reports `C-ambiguous`, sets `last_visible_status = "ambiguous: N candidates"`, does NOT pin. The `== 1` pin block is skipped. Correct — prevents random pinning when multiple transcripts match.
### 3.4 `verify_session_uuid` hardening (`lib.sh:1173-1244`)
- **Ordering invariant** (lib.sh:1199-1209): workspace-key check runs BEFORE the revalidate shortcut. Comment cites T-12. This prevents handing back an id that belongs to a different workspace purely because the row is assigned+unverified. Verified by T-12.
- **Revalidate shortcut** (mode=="revalidate" + assigned + unverified → return True): lets a freshly-resumed session validate before its transcript is rewritten. The workspace check still guards it. Correct.
- **Multi-line scan** (lib.sh:1219-1242): reads up to 50 lines looking for `sessionId == uuid` and `cwd`. The old code read only the first line. This handles transcripts where the first line is a `queue-operation` event (no cwd) and the cwd appears on a later `user` line. Verified by T-2.
- **iso_root paths**: claude uses `(iso_root + "/projects") if iso_root else c_dir`; agy/hermes/cline use `iso_root or home`. Consistent with the isolation-root model. Verified by T-11.
### 3.5 Path normalization (`lib.sh:813-823, 1174-1180`; `reconcile.sh:509-510`; `resume_session.sh:85-95`)
- `mam_abs_workspace`: `( cd -P "$p" && pwd -P )` — resolves symlinks to real path.
- `mam_workspace_key`: pipes `mam_abs_workspace` through `tr '/_' '--'`.
- Python `workspace_key`: `os.path.realpath(path)` then `.replace("/","-").replace("_","-")`.
- These produce identical keys for absolute, trailing-slash, `..`-dotdot, and symlink inputs. Verified by T-13 (4 variations). This closes the symlink-key-mismatch class that could cause a session to be invisible to the reconciler.
### 3.6 Resume flag selection (`resume_session.sh:83-95`)
- `CLAUDE_ID_FLAG="-r"` default; if the transcript `${_proj_dir}/${_ws_key}/${UUID}.jsonl` does NOT exist → `--session-id` (fresh spawn). Otherwise `-r` (resume).
- `_proj_dir` resolves via `mam_session_iso_root` (isolation root) or `CLAUDE_PROJECT_DIR`/`$HOME/.claude/projects`. Matches `verify_session_uuid`'s claude base path. Correct — this is the key mechanism preventing resume from failing on a freshly created (unmaterialized) session.
### 3.7 `_pane_capture` JSON unwrap (`lib.sh:1864-1884`)
- The herdr shim returns `{"result": {"read": {"text": "..."}}}`. The new `_pane_capture` extracts `result.read.text` and falls back to raw on any parse failure. Defensive and correct — keeps `verify_tui_viewport` working against the JSON-wrapping shim.
### 3.8 `verify_tui_viewport` simplification (`lib.sh:1338-1362`)
- Removed 4 identical per-agent case branches (all did `grep -q "$base"`). Now: flatten whitespace, fixed-string grep on `base_flat`; regex fallback for absolute-path detection. Cleaner, equivalent behavior.
---
## 4. Test Results
| Suite | Command | Result |
|---|---|---|
| UUID target (new) | `pytest tests/test_uuid_target.py -q` | **13 passed** (57.27s) |
| Pure-lib.sh subset (fast) | `pytest test_t3 test_t12 test_t13 -v` | **3 passed** (0.40s) |
| Cross-regression (O-3+sanity+B-4) | `pytest tests/test_o3_scoped_guard.py tests/test_sanity.py tests/test_b4_session_created.py -q` | **47 passed** (23.79s) |
| Tier3 (modified `realpath` assertion) | `pytest tests/test_tier3_integration.py::test_integration_stop_purge_combination -q` | **1 passed** (36.61s) |
The 13-case `test_uuid_target.py` covers: T-1 create-assigned, T-2 assigned-ID promotion after materialize, T-3 different-cwd transcript not pinned, T-4 C-ambiguous (2 candidates), T-5 custom session name pinned, T-6..T-9 (agy/hermes/cline/legacy paths), T-10 path-variation resume (`/`, `..`, symlink), T-11 legacy isolation row, T-12 other-workspace revalidate fails (ordering invariant), T-13 shell/Python `workspace_key` equivalence across 4 path forms.
---
## 5. Findings (non-blocking)
### R-1: Wrapper-mode `SESSION_UUID` clear leaves misleading `cmd_full` in YAML (`create_session.sh:164-170, 156`)
In the claude `spawn()` branch, when a wrapper binary is used (`[ -x "$WRAPPER" ] && basename != claude`, or `--wrapper`), `SESSION_UUID=""` is cleared **after** `CMD_FULL` was already built as `... --session-id <uuid>`. The YAML therefore records `pane.cmd_full` containing `--session-id <uuid>` but `claude_session_id_own = None` / `session_id_source = 'pending-discovery'` (because `assigned = os.environ.get('SESSION_UUID', '') or None` reads the cleared value). The wrapper is expected to manage its own session-id, so functionally the session is discovered later via drift C — no runtime break. But the recorded `cmd_full` is cosmetically misleading. **Severity**: low. **Suggested fix**: rebuild `CMD_FULL` without `--session-id` in the wrapper branch (or clear it before `CMD_FULL` is composed and re-add only in the non-wrapper path).
### R-2: drift-B still inlines `name.endswith('-creator-<agent>')` (`reconcile.sh:494-500`)
The drift-B auto-register section infers the agent from the session-name suffix inline, while the new `row_agent()` helper (reconcile.sh:567-578) does the same with a pane-metadata-first fallback. This is **not** a bug — drift-B processes herdr sessions not yet in YAML (no `pane` dict), so the name convention is the only signal and `row_agent()` would degrade to the same fallback. It is a missed consolidation opportunity only. **Severity**: cosmetic. **Suggested fix**: optionally call `row_agent(t)` (it falls back to name) for single-source consistency.
### R-3: `verify_session_uuid` claude scan breaks on first cwd-bearing line (`lib.sh:1234-1236`)
The multi-line scan sets `found_cwd` and `break`s on the first line carrying a `cwd` field, even if `valid_session` (sessionId match) hasn't been confirmed on that line. In real claude transcripts every line carries the same `sessionId`, so `valid_session` and `found_cwd` converge. The only risk is a pathological transcript whose first cwd line has a *different* sessionId, which would return False (safe direction — fails closed). **Severity**: low / safe-direction. No fix required; documented for completeness.
### R-4: Changes uncommitted in working tree
`git status` shows 13 modified + 1 untracked file, none staged or committed. This matches the prior job's R-3 observation and is a process/policy note, not a code defect. The diff under review is the working-tree state. **Suggested action**: `git add -A && git commit -m "feat(uuid): auto-assign & pin session UUID at create; reconcile C0 confirm + C-ambiguous + path canonicalization"`.
### R-5: `mam_session_iso_root` spawns a Python process per resume (`resume_session.sh:86`, `lib.sh:828-842`)
`mam_session_iso_root` and `mam_workspace_key` each shell out to `env_python`. On the resume path this adds two Python startup costs. Correct and well-isolated; only a minor latency note for cold-resume. **Severity**: perf, non-blocking.
---
## 6. Completeness / Loss Check
| Aspect | Coverage |
|---|---|
| Goal: no null `session_id_own` after create | ✅ T-1 asserts `claude_session_id_own` non-null + `source=assigned` + `verified=False` immediately after create |
| Goal: confirmation after first message | ✅ T-2 asserts `verified=True` + `pinned` after transcript materialize + one reconcile cycle |
| Goal: resume uses correct flag | ✅ T-10 exercises `--session-id` vs `-r` via `--dry-run` across path variations; resume_session.sh:83-95 logic matches verify path |
| Ambiguity defense | ✅ T-4 asserts C-ambiguous reported + no pinning for 2 candidates |
| Cross-workspace safety | ✅ T-12 asserts revalidate fails for assigned row of a different workspace (ordering invariant) |
| Path canonicalization | ✅ T-13 asserts shell/Python key equivalence across 4 path forms; conftest + tier3 use `os.path.realpath` |
| Orphaned code | None found — `_pane_capture`/`verify_tui_viewport` simplifications remove now-redundant branches; `endswith` at reconcile.sh:494-500 is a distinct context (drift-B), not an orphan |
| Doc/test sync | ✅ SKILL.md (create/monitor/resume) + MULTI_AGENT_RULES + IMPROVEMENTS all reflect the new protocol; conftest mock updated to honor `--session-id` |
| Cross-regression | ✅ 47/47 (O-3 + sanity + B-4) green — no regression to prior fixes |
---
## 7. Conclusion
The change set implements the auto-assignment-and-pinning protocol end-to-end with clean separation of concerns: create assigns, reconcile confirms (C0) and guards ambiguity (C-ambiguous), resume adapts to transcript existence. Path canonicalization is applied uniformly in shell and Python and is proven equivalent by T-13. The ordering invariant (workspace check before revalidate shortcut) closes the cross-workspace leakage class (T-12). Lint passes on all touched shell scripts and embedded Python; the new 13-case suite is fully green; cross-regression (47) and the modified tier3 test are green. Five non-blocking findings are recorded (R-1..R-5), none requiring design-level rework. The stated goal — preventing a null `session_id_own` after create/onboard — is met.
[VERDICT: PASS]
@@ -0,0 +1,92 @@
# Cross-Code Review — Job `f4f441fb`
- **Job ID**: f4f441fb · **Reviewer**: cline · **Base**: `29f0be5` (working-tree, uncommitted)
- **Task**: `IMPROVEMENTS.md` 12건 미해결 과제(A-2, B-5..B-10, O-2, A-4, C-3..C-6)에 대한 우선순위 실행 로드맵 신설 및 누적 변경분(git diff) 교차 코드 리뷰 (lint / 동작성 / 유실)
- **Diff scope**: `IMPROVEMENTS.md` 단일 파일 — `git diff --stat` = **1 file changed, 120 insertions(+), 13 deletions(-)**
---
## 1. 변경분 요약 및 검증 대상
변경분은 **문서(백로그)**에 대한 두 가지 갱신이다. (1) 4개 기존 항목(A-2·B-5·B-7·C-3·C-4)의 서술을 현재 코드 실측 기반으로 정정, (2) **§6 「🧭 우선순위 실행 로드맵」** 신설(6.1 정렬원칙 / 6.2 실행순서표 / 6.3 파일-소유권 병렬 슬롯 / 6.4 B-7 처방 / 6.5 정정사항 / 6.6 결론). 코드·배포 자산·테스트는 건드리지 않았다(`git status --porcelain`: ` M IMPROVEMENTS.md` 단일).
브리프에 첨부된 diff 헤더(`index da41a28..157344b`)와 working-tree diff가 정확히 일치한다. 로드맵은 브리프가 요구한 4 평가축(보안 위험·런타임 안정성·개발 생산성·아키텍처 영향)을 모두 반영하며, 12건 과제를 전부 단일 순서표에 배치했다(P0~P5 + 종결 권고). A-4는 M0~M1(P2-1)·M2~M7(P4-1)로 분할, C-3은 C-3a(P3-2 즉시)·C-3b(P4-3 보류)로 분리되어 있어 누락 0건이다.
| 검증 항목 | 방법 | 결과 |
|---|---|---|
| Diff 일치 (브리프 vs working tree) | `git --no-pager diff IMPROVEMENTS.md` | ✅ 정확 일치 |
| 코드/자산 부재 (순수 문서) | `git status --porcelain` | ✅ IMPROVEMENTS.md 단일 |
| 12건 전수 배치 | §6.2 순서표 + 분할 항목 대조 | ✅ 누락 0건 |
| §3 카운트 정정 근거 | `git show HEAD:IMPROVEMENTS.md` §3 | ✅ HEAD §3="2건"이나 본문은 O-2 단일(스테일) → 1건 정정 타당 |
---
## 2. Lint (정적 품질)
`IMPROVEMENTS.md`는 Markdown 문서로 셸/파이썬 린트 대상이 아니다. Markdown 구조 정합성만 점검했다.
- 헤더 계층(`#`~`######`) 일관. §6의 `###`~`####` 하위 구조 정상.
- §6.2 실행순서표: 5열(순위/항목/근거/비용/선행) 정합, 13행. §6.3 파일표(2열)·슬롯표(2열) 정합.
- §6.4 코드블록(` ```bash `` ``` `) 정상 펜스, 내부 `git ls-files -o --exclude-standard -z` 등 유효 bash.
- 인라인 백틱 쌍 정합, 한국어/영문 혼용 깨짐 없음.
- **내부 집계 일관성**: 헤더 `12건(아키텍처 2·엣지 6·오케스트 1·레거시 3)` ↔ §1=2·§2=6·§3=1·§4=3 정합(이 diff가 §3을 2→1로 정정해 일관성 확보). `완료된 과제 10건` 줄·§5 미변경.
---
## 3. 동작성 (설계 주장의 코드베이스 정합성)
코드 변경이 없으므로, 변경된 서술 및 로드맵 근거가 현 코드베이스 사실과 일치하는지(거짓 주장·과장·스테일 여부)를 교차 검증했다.
| 변경/주장 | 코드베이스 실측 | 판정 |
|---|---|---|
| **A-2**: `verify_hmac``if not auth_token: return True` 상시 타점 | `mqtt_common.py:278-279` `if not auth_token: return True # PoC mode — no auth` | ✅ 정합 |
| **A-2**: 잡 `auth_token=None` (실측 **26/26**) | `.mam/jobs/*.json` 30건 전수 → `auth_token=None: 30/30` | ⚠️ 카운트 스테일(26→30); 정성(100% None)은 정확 |
| **A-2**: 발행자 전역 토픽 + `reconcile.sh:237` 전역 구독 | `reconcile.sh:236-238` legacy `python/mqtt/jobs/+/events` 구독(지문 토픽 병기) | ✅ 정합 |
| **A-2**: HMAC 구현 자체는 정상 | 토큰 있으면 `hmac.compare_digest` 검증 경로 존재 | ✅ 정합 |
| **B-5**: `df --output=target` 실패 + `df -P` 폴백 정상 | `lib.sh:927` `df --output=target` / `lib.sh:929` `df -P` 폴백 | ✅ 정합(종결 권고 타당) |
| **B-5 잔여**: `mount\|grep -E "$mountpoint"` 비이스케이프 보간 | `lib.sh:931` `mount \| grep -i -q -E "$mountpoint.*(nfs\|cifs\|smb\|sshfs)"` | ✅ 정합(B-11 분리 근거 유효) |
| **B-7**: `REPO_ROOT` BASH_SOURCE(9-10행), `cd` 없음, `git diff` 537·539행 | `run_loop.sh:8-9` / `cd` 없음 / `git diff` L537·L539 `‖ echo "No git diff available"` / 프롬프트 L547 | ✅ 정합(행 번호 정확) |
| **B-7 처방**: `git add -N .` 인덱스 오염 → 기각, `git ls-files -o` 대안 | `git add -N` 동작 git 공식문서상 맞음; 대안은 인덱스 비변경 | ✅ 논리 정합(처방은 미구현 설계) |
| **C-3a**: 4종 빈 스텁, 프로덕션 호출자 0건, 테스트 고정 | `lib.sh:1614/1619/1626/1630` / 호출자 0건 / `test_tier1_unit.py`+`test_tier2_component.py` | ✅ 정합 |
| **C-3b**: `isolation.root` 소비자(되살린 코드) | `verify_session_uuid` iso_root 분기 등 존재 | ✅ 보류 분리 타당 |
| **C-4**: `_HERDR_SHIM_DIR_PATTERN` 사용 중(L57 정의·L79 사용) | `lib.sh:57` 정의 / `lib.sh:79` 사용 | ✅ 정합(목록 제외 정당) |
| **C-4**: `local_herdr` 참조 0건(이미 제거) | `grep -rn local_herdr` → 0건 | ✅ 정합 |
| **C-4**: `_REAL_HERDR_PATH`(대입·export만) | `lib.sh:100-101`, 타 참조 0건 | ✅ 정합 |
| **C-4**: `TERMINAL_STATUSES`(`registry.py:38` 정의만) | `registry.py:38`, 타 참조 0건 | ✅ 정합 |
| **C-4**: `ISOLATE`(`create_session.sh:57` 대입만) | `create_session.sh:57 ISOLATE=1`, 타 참조 0건 | ✅ 정합 |
| **O-2**: `run_loop.sh:83-89` 마커 무조건 덮어쓰기 + 트랩 소유권 대조 없이 삭제 | L83 마커 / L87 `>` 덮어쓰기 / L88 `rm -f`(대조 无) | ✅ 정합(행 번호·위험 서술 정확) |
| **§6.3**: `reconcile.sh``MAM_LOOP_MARKER`·`send_keys_safe` 참조 0건 | `grep -cn` → 0 | ✅ 정합(슬롯 비경합 근거 유효) |
**동작성 결과: PASS** — 17개 항목 중 16개 완전 정합, 1개(A-2 카운트 26→30) 스테일이나 정성 주장은 부정확하지 않음. 거짓·과장 주장 없음.
---
## 4. 유실 (Loss / Orphan)
`git diff`**삭제 13줄**. 전부 교체성 갱신 또는 정정이며 원 정보 손실 아님:
- **헤더 갱신일**(1줄): `A-4 ... 등재``7747d745 Rev.2 — B-7 처방 ...` — 정당.
- **A-2 현상**(1줄→다행): 단문을 상세 실측으로 확장. 원 의미 보존 + 정정.
- **B-5/B-7**(각 1~2줄): 원 서술을 `원 서술:` 라벨로 보존한 채 실측 부가 — **삭제가 아니라 주석화**. 정보 손실 0.
- **§3 제목**(1줄): `2건``1건`. `git show HEAD:IMPROVEMENTS.md` 확인 결과 HEAD §3 본문은 O-2 단일이었고 "2건"은 스테일 카운트. 항목 삭제가 아니라 라벨 정정.
- 기존 §4(C-3·C-4·C-6)·§5(완료 10건)는 미변경(존재 보존). 신규 자산/임포트 추가 없으므로 orphan 0건.
**유실 결과: PASS** — 부당 삭제/잔재 없음.
---
## 5. 비차단 발견 (Non-blocking Findings)
**N-1 (A-2 카운트 스테일, 비본질).** A-2 현상 및 §6.2 P0-1 근거에 "실측 26/26 잡이 `auth_token=None`"로 기재됐으나, 현재 `.mam/jobs/*.json` 30건 전수 측정 시 `auth_token=None: 30/30`이다. 본 리뷰 잡(f4f441fb)·선행 잡(0d9712c6) 등 4건이 측정 후 추가된 것이다. 정성 주장("발급 0건 → 검증 공허")은 30/30=100%로 정확히 유지되므로 결론에 영향 없음. 구현 시점 재측정 권고. 비차단.
**N-2 (§6.4 처방 코드 미검증, 설계 범위).** B-7 처방의 `git diff --no-index --binary /dev/null "$f"` 루프와 크기 상한 로직은 저장소에 반영되지 않은 설계안이므로 본 리뷰에서 실행 검증 불가. 논리(`git add -N` 인덱스 오염 회피, `--exclude-standard` 존중)는 정합. M0 구현 시 샌드박스 측정이 필요하다는 문서 자체 권고와 일치. 비차단.
**N-3 (A-4/O-4 명명 혼선, 선행 커밋).** 선행 커밋 `29f0be5` 메시지는 "record **O-4** ..."이나 문서 본문은 **A-4**를 사용. 본 diff가 도입한 것이 아니며, 오히려 §3 카운트를 정정해 오케스트레이션 항목을 1건(O-2)으로 명확히 했다. 문서 소유자 후속 명명 통일 권고. 비차단.
---
## 6. 종합 판정
변경분은 `IMPROVEMENTS.md` 단일 문서에 대한 (1) 4개 스테일 항목의 실측 정정 + (2) 12건 전수를 아우르는 우선순위 실행 로드맵 신설이다. 코드·배포·테스트 변경이 전무해 런타임·린트·회귀 영향은 0이다. 로드맵은 브리프가 요구한 4 평가축을 반영하고, 정렬 원칙(외부 트리거 위험 → 거짓 검증 신호 → 구조 작업 → 국소 결함 → 정리, 조용한 실패 가중)에 12건을 빈틈없이 배치했으며, 파일-소유권 기반 병렬 슬롯과 Rev.1 정정 4건까지 명시해 실행 가능성이 높다. 변경된 서술 17개 항목을 코드베이스와 교차 검증한 결과 16개 완전 정합·1개 카운트 스테일(정성은 정확)이며 거짓/과장은 없었다. 부당 삭제나 잔재도 없다. 설계 재작업 수준의 재계획이 필요한 근거(escalation)는 발견되지 않는다 — 이 변경은 "우선순위 로드맵 등재"라는 명시적 산출물 목표를 충족한다.
[VERDICT: PASS]
+122 -32
View File
@@ -796,6 +796,52 @@ derive_workspace_slug() {
printf 'mam-%s' "$slug" printf 'mam-%s' "$slug"
} }
# ---------------------------------------------------------------------------
# mam_gen_uuid
# ---------------------------------------------------------------------------
mam_gen_uuid() {
if command -v uuidgen >/dev/null 2>&1; then
uuidgen | tr 'A-Z' 'a-z'
else
python3 -c 'import uuid; print(uuid.uuid4())'
fi
}
# ---------------------------------------------------------------------------
# mam_abs_workspace <path>
# ---------------------------------------------------------------------------
mam_abs_workspace() {
local p="${1:-}"
( cd -P "$p" 2>/dev/null && pwd -P ) || printf '%s' "$p"
}
# ---------------------------------------------------------------------------
# mam_workspace_key <workspace>
# ---------------------------------------------------------------------------
mam_workspace_key() {
printf '%s' "$(mam_abs_workspace "$1")" | tr '/_' '--'
}
# ---------------------------------------------------------------------------
# mam_session_iso_root <session_name>
# ---------------------------------------------------------------------------
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
}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# derive_session_name <workspace> <agent> # derive_session_name <workspace> <agent>
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1126,13 +1172,21 @@ PYEOF
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
VERIFY_SESSION_PYTHON=' VERIFY_SESSION_PYTHON='
def workspace_key(path): def workspace_key(path):
return path.replace("/", "-").replace("_", "-") import os
try:
p = os.path.realpath(path)
except Exception:
p = path
return p.replace("/", "-").replace("_", "-")
def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=None, mode="discover"): def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=None, mode="discover"):
import os, json, sqlite3 import os, json, sqlite3
home = home_dir or os.environ.get("HOME_DIR", "") home = home_dir or os.environ.get("HOME_DIR", "")
c_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{home}/.claude/projects") c_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{home}/.claude/projects")
row = row or {} row = row or {}
_iso = row.get("isolation")
iso_root = _iso.get("root") if isinstance(_iso, dict) and _iso.get("root") else None
# The floor answers "could this transcript belong to a PREVIOUS incarnation # The floor answers "could this transcript belong to a PREVIOUS incarnation
# of this session?", which only matters while picking an unknown uuid off # of this session?", which only matters while picking an unknown uuid off
# disk. In "revalidate" the uuid is one this row already recorded, and a # disk. In "revalidate" the uuid is one this row already recorded, and a
@@ -1142,11 +1196,20 @@ def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=Non
epoch = row.get("herdr_session_epoch", 0) if mode == "discover" else 0 epoch = row.get("herdr_session_epoch", 0) if mode == "discover" else 0
cwd = row.get("pane", {}).get("cwd", "") or ws cwd = row.get("pane", {}).get("cwd", "") or ws
# 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): if workspace_key(cwd) != workspace_key(ws):
return False return False
if (mode == "revalidate" and row.get("session_id_source") == "assigned"
and not row.get("session_id_verified")):
return True
if agent == "claude": if agent == "claude":
base = c_dir base = (iso_root + "/projects") if iso_root else c_dir
key = workspace_key(ws) key = workspace_key(ws)
path = f"{base}/{key}/{uuid}.jsonl" path = f"{base}/{key}/{uuid}.jsonl"
if not os.path.exists(path): if not os.path.exists(path):
@@ -1154,27 +1217,41 @@ def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=Non
if epoch and os.path.getmtime(path) < epoch: if epoch and os.path.getmtime(path) < epoch:
return False return False
try: try:
valid_session = False
found_cwd = None
with open(path) as f: with open(path) as f:
first = f.readline().strip() for _ in range(50):
if not first: line = f.readline()
if not line:
break
line = line.strip()
if not line:
continue
try:
payload = json.loads(line)
if payload.get("sessionId") == uuid:
valid_session = True
if payload.get("cwd"):
found_cwd = payload.get("cwd")
break
except Exception:
pass
if not valid_session:
return False return False
payload = json.loads(first) if found_cwd and found_cwd != cwd:
if payload.get("sessionId") != uuid:
return False
if payload.get("cwd") and payload.get("cwd") != cwd:
return False return False
except Exception: except Exception:
return False return False
elif agent == "agy": elif agent == "agy":
base = f"{home}/.gemini/antigravity-cli/conversations" base = f"{iso_root or home}/.gemini/antigravity-cli/conversations"
path = f"{base}/{uuid}.db" path = f"{base}/{uuid}.db"
if not os.path.exists(path): if not os.path.exists(path):
return False return False
if epoch and os.path.getmtime(path) < epoch: if epoch and os.path.getmtime(path) < epoch:
return False return False
if mode == "discover": if mode == "discover":
lc = f"{home}/.gemini/antigravity-cli/cache/last_conversations.json" lc = f"{iso_root or home}/.gemini/antigravity-cli/cache/last_conversations.json"
cache_match = False cache_match = False
if os.path.exists(lc): if os.path.exists(lc):
try: try:
@@ -1196,7 +1273,7 @@ def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=Non
return False return False
elif agent == "hermes": elif agent == "hermes":
hdb = f"{home}/.hermes/state.db" hdb = f"{iso_root or home}/.hermes/state.db"
if not os.path.exists(hdb): if not os.path.exists(hdb):
return False return False
if epoch and os.path.getmtime(hdb) < epoch: if epoch and os.path.getmtime(hdb) < epoch:
@@ -1211,7 +1288,7 @@ def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=Non
return False return False
elif agent == "cline": elif agent == "cline":
base = f"{home}/.cline/data/sessions" base = (iso_root + "/sessions") if iso_root else f"{home}/.cline/data/sessions"
path = f"{base}/{uuid}/{uuid}.json" path = f"{base}/{uuid}/{uuid}.json"
if not os.path.exists(path): if not os.path.exists(path):
return False return False
@@ -1260,7 +1337,7 @@ PYEOF
# 2 = check not possible (capture failed, session gone) # 2 = check not possible (capture failed, session gone)
verify_tui_viewport() { verify_tui_viewport() {
local session_name="$1" agent="$2" workspace="$3" local session_name="$1" agent="$2" workspace="$3"
local abs; abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace" local abs; abs="$(mam_abs_workspace "$workspace")"
local base; base="$(basename "$abs")" local base; base="$(basename "$abs")"
if ! _herdr has-session -t "$session_name" >/dev/null 2>&1; then if ! _herdr has-session -t "$session_name" >/dev/null 2>&1; then
@@ -1273,23 +1350,16 @@ verify_tui_viewport() {
return 2 return 2
fi fi
case "$agent" in local flat base_flat
claude) flat=$(printf '%s' "$pane_content" | tr -d '[:space:]')
if printf '%s\n' "$pane_content" | grep -q "$base"; then base_flat=$(printf '%s' "$base" | tr -d '[:space:]')
return 0 if printf '%s' "$flat" | grep -Fq "$base_flat"; then
fi return 0
return 1 fi
;; if printf '%s\n' "$pane_content" | grep -Eq '(^|[^[:alnum:]])/[A-Za-z0-9._-]+/[A-Za-z0-9._/-]+'; then
agy|hermes|cline) return 1
if printf '%s\n' "$pane_content" | grep -q "$base"; then fi
return 0 return 2
fi
return 1
;;
*)
return 2
;;
esac
} }
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1308,7 +1378,7 @@ verify_tui_viewport() {
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
find_workspace_uuid() { find_workspace_uuid() {
local workspace="$1" agent="$2" session_name="${3:-}" local workspace="$1" agent="$2" session_name="${3:-}"
local abs; abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace" local abs; abs="$(mam_abs_workspace "$workspace")"
MAM_STATE_JSON="$(load_state_json)" WS_ABS="$abs" AGENT="$agent" TARGET_SESSION="$session_name" MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF' MAM_STATE_JSON="$(load_state_json)" WS_ABS="$abs" AGENT="$agent" TARGET_SESSION="$session_name" MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
import os, sys, json, glob, sqlite3 import os, sys, json, glob, sqlite3
@@ -1791,7 +1861,27 @@ _sks_herdr() {
mam_herdr "$@" mam_herdr "$@"
} }
_pane_capture() { _sks_herdr capture-pane -p -t "$1" 2>/dev/null || echo ""; } _pane_capture() {
local raw
raw="$(_sks_herdr capture-pane -p -t "$1" 2>/dev/null || echo "")"
if [ -z "$raw" ]; then
echo ""
return 0
fi
python3 -c '
import sys, json
raw = sys.stdin.read()
try:
data = json.loads(raw)
text = data.get("result", {}).get("read", {}).get("text")
if text is not None:
print(text, end="")
else:
print(raw, end="")
except Exception:
print(raw, end="")
' <<< "$raw"
}
# Bottom-N *content* lines: capture-pane -p pads the viewport with trailing # Bottom-N *content* lines: capture-pane -p pads the viewport with trailing
# blank rows; window on non-blank lines or top-anchored dialogs are invisible. # blank rows; window on non-blank lines or top-anchored dialogs are invisible.
@@ -233,10 +233,10 @@ The script handles the YAML append, pane capture, and the `last_visible_status`
## Pitfalls ## Pitfalls
- **Don't use `nohup`/`disown`/`setsid` for the agent itself** — those background the agent outside herdr. The whole point of this skill is *the herdr session is the supervisor*. `nohup` is OK only for *launching the wrapper* (which itself creates the herdr session via `herdr new-session -d`). - **Don't use `nohup`/`disown`/`setsid` for the agent itself** — those background the agent outside herdr. The whole point of this skill is *the herdr session is the supervisor*. `nohup` is OK only for *launching the wrapper* (which itself creates the herdr session via `herdr new-session -d`).
- **Don't trust `--session-id <uuid>` flags blindly** — claude/agy may not accept a fixed session id on first spawn. The session id is *assigned* on first user message; you can read it back from `~/.claude/projects/.../session.jsonl` headers or `~/.gemini/.../cache/last_conversations.json` AFTER the first message. - **Claude Session ID Auto-Assignment**: Fresh `claude` sessions automatically generate a new UUID (`mam_gen_uuid`) passed via `claude --session-id <uuid>`. `claude_session_id_own` is stored in `.mam/agent-sessions.yaml` with `session_id_source: assigned` and `session_id_verified: false`.
- **First Message Materialization**: The transcript file `.jsonl` is created on disk when the first user prompt is sent. The monitor loop (`reconcile.sh`) verifies the transcript and promotes `session_id_verified: true` and `last_visible_status: pinned`.
- **Wrapper script MUST NOT be created via `hermes profile alias`** — that command writes a `hermes -p <profile>` wrapper that destroys the herdr behavior. Create wrappers manually (see `lab-landing-page-creator-claude` template). - **Wrapper script MUST NOT be created via `hermes profile alias`** — that command writes a `hermes -p <profile>` wrapper that destroys the herdr behavior. Create wrappers manually (see `lab-landing-page-creator-claude` template).
- **Always use the workspace-relative path** in herdr `cwd` — relative paths break when herdr respawns in a different shell context. - **Always use the workspace-relative path** in herdr `cwd` — relative paths break when herdr respawns in a different shell context.
- **The first `claude` message generates the session id** — `multi-agent-mux-create` only sets up the *container*. If you need a known session id for later resume, send a placeholder message (e.g. "init") and read it back, then call `multi-agent-mux-resume` later.
## Verification ## Verification
@@ -148,8 +148,13 @@ if [ "$(uname)" = "Darwin" ] && [ -f "$RESOLVED_BIN" ]; then
xattr -d com.apple.quarantine "$RESOLVED_BIN" 2>/dev/null || true xattr -d com.apple.quarantine "$RESOLVED_BIN" 2>/dev/null || true
fi fi
SESSION_UUID=""
if [ "$AGENT" = "claude" ]; then
SESSION_UUID="$(mam_gen_uuid)"
fi
case "$AGENT" in case "$AGENT" in
claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;; claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --session-id ${SESSION_UUID}" ;;
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;; agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions" ;;
hermes) CMD_FULL="${RESOLVED_BIN}" ;; hermes) CMD_FULL="${RESOLVED_BIN}" ;;
cline) CMD_FULL="${RESOLVED_BIN} -i" ;; cline) CMD_FULL="${RESOLVED_BIN} -i" ;;
@@ -162,6 +167,7 @@ spawn() {
case "$AGENT" in case "$AGENT" in
claude) claude)
if { [ -x "$WRAPPER" ] && [ "$(basename "$WRAPPER")" != "claude" ]; } || [ "$USE_WRAPPER" = "1" ]; then if { [ -x "$WRAPPER" ] && [ "$(basename "$WRAPPER")" != "claude" ]; } || [ "$USE_WRAPPER" = "1" ]; then
SESSION_UUID=""
nohup "$WRAPPER" >/dev/null 2>&1 & nohup "$WRAPPER" >/dev/null 2>&1 &
disown disown
else else
@@ -262,6 +268,7 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
HERDR_EPOCH="$HERDR_EPOCH" PANE_PID="$PANE_PID" PANE_CWD="$PANE_CWD" \ HERDR_EPOCH="$HERDR_EPOCH" PANE_PID="$PANE_PID" PANE_CWD="$PANE_CWD" \
CMD_FULL="$CMD_FULL" START_CMD="$START_CMD" CHILD_PID="$CHILD_PID" \ CMD_FULL="$CMD_FULL" START_CMD="$START_CMD" CHILD_PID="$CHILD_PID" \
HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}" \ HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}" \
SESSION_UUID="$SESSION_UUID" \
DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" <<'PYEOF' DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" <<'PYEOF'
name = os.environ['SESSION_NAME'] name = os.environ['SESSION_NAME']
agent = os.environ['AGENT'] agent = os.environ['AGENT']
@@ -302,8 +309,6 @@ entry = {
'kill_command': f'HERDR_SESSION_NAME={server_name} herdr kill-session -t {name}', 'kill_command': f'HERDR_SESSION_NAME={server_name} herdr kill-session -t {name}',
} }
if agent == 'claude': if agent == 'claude':
entry['tui'] = { entry['tui'] = {
'model': '(unknown — capture after first message)', 'model': '(unknown — capture after first message)',
@@ -312,8 +317,11 @@ if agent == 'claude':
'account': '(unknown — read from claude auth status)', 'account': '(unknown — read from claude auth status)',
'version': '(unknown — read from TUI)', 'version': '(unknown — read from TUI)',
} }
entry['claude_session_id_own'] = None assigned = os.environ.get('SESSION_UUID', '') or None
entry['last_visible_status'] = "unverified" entry['claude_session_id_own'] = assigned
entry['session_id_source'] = 'assigned' if assigned else 'pending-discovery'
entry['session_id_verified'] = False
entry['last_visible_status'] = "assigned (awaiting first message)" if assigned else "unverified"
elif agent == 'agy': elif agent == 'agy':
cp = os.environ.get('CHILD_PID', '0') cp = os.environ.get('CHILD_PID', '0')
entry['child_pid'] = int(cp) if cp.isdigit() else 0 entry['child_pid'] = int(cp) if cp.isdigit() else 0
@@ -124,15 +124,18 @@ YAML: no such session
→ report: "lab-paper-pdf2md-creator-agy: herdr found but not in YAML. Auto-registered." → report: "lab-paper-pdf2md-creator-agy: herdr found but not in YAML. Auto-registered."
``` ```
### C. New session id materializes (claude first message sent) ### C. Session ID Discovery & Confirmation
``` - **C0. Assigned ID Confirmation**: For sessions created with an auto-assigned UUID (`session_id_source: assigned`, `session_id_verified: false`), the monitor verifies that the transcript file `.jsonl` has materialized on disk. Once verified, it promotes `session_id_verified: true` and updates `last_visible_status: pinned`.
YAML: claude_session_id_own=null (placeholder) - **C. New session id materializes (unassigned/legacy)**:
disk: ~/.claude/projects/.../b3a7...c2f.jsonl exists, mtime=now, ```
first line sessionId=b3a7...c2f YAML: claude_session_id_own=null (placeholder)
→ update claude_session_id_own=b3a7...c2f disk: ~/.claude/projects/.../b3a7...c2f.jsonl exists, mtime=now,
→ report: "lab-landing-page-creator-claude: session id materialized b3a7...c2f" first line sessionId=b3a7...c2f
``` → update claude_session_id_own=b3a7...c2f
→ report: "lab-landing-page-creator-claude: session id materialized b3a7...c2f"
```
- **C-ambiguous. Multiple candidates detected**: If multiple candidate transcripts match an unassigned session, the monitor avoids random pinning, reports `C-ambiguous`, and sets `last_visible_status: "ambiguous: N candidates"`.
### D. Stale UUID (artifact gone) ### D. Stale UUID (artifact gone)
@@ -565,9 +565,50 @@ if herdr_confirmed:
'msg': f"{name}: herdr found but not in YAML. Auto-registered (pane {pm['pid']}, cmd {pm['cmd']}, cwd {pm['cwd']})."}) 'msg': f"{name}: herdr found but not in YAML. Auto-registered (pane {pm['pid']}, cmd {pm['cmd']}, cwd {pm['cwd']})."})
actions.append(f"registered: {name}") actions.append(f"registered: {name}")
def row_agent(s):
cmd = ((s.get('pane') or {}).get('cmd') or '').strip()
if cmd in ('claude', 'agy', 'hermes', 'cline'):
return cmd
full = ((s.get('pane') or {}).get('cmd_full') or '')
for a in ('claude', 'agy', 'hermes', 'cline'):
if a in full:
return a
name = s.get('name', '')
for a in ('claude', 'agy', 'hermes', 'cline'):
if name.endswith('-creator-' + a):
return a
return None
OWN_KEY_BY_AGENT = {
'claude': 'claude_session_id_own',
'agy': 'agy_conversation_id_own',
'hermes': 'hermes_conversation_id_own',
'cline': 'cline_conversation_id_own'
}
# === drift C0: 지정된 ID 는 발견이 아니라 '확인'만 필요하다 ===
for s in d.get('herdr_sessions', []):
if s.get('status') != 'running':
continue
if s.get('session_id_source') != 'assigned' or s.get('session_id_verified'):
continue
agent = row_agent(s)
if not agent:
continue
uuid = s.get(OWN_KEY_BY_AGENT[agent])
cwd = (s.get('pane') or {}).get('cwd', '')
if not uuid or not cwd:
continue
if verify_session_uuid(cwd, agent, uuid, s, mode="discover"):
s['session_id_verified'] = True
s['last_visible_status'] = 'pinned'
drifts.append({'class': 'C', 'name': s['name'],
'msg': f"{s['name']}: assigned session id confirmed on disk: {uuid}"})
actions.append(f"confirmed session id: {uuid}")
# === drift C: claude 새 session id materialize (per-row own id) === # === drift C: claude 새 session id materialize (per-row own id) ===
for s in d.get('herdr_sessions', []): for s in d.get('herdr_sessions', []):
if not s.get('name', '').endswith('-creator-claude'): if row_agent(s) != 'claude':
continue continue
if s.get('status') != 'running': if s.get('status') != 'running':
continue continue
@@ -588,6 +629,11 @@ for s in d.get('herdr_sessions', []):
if verify_session_uuid(cwd, 'claude', uuid, s, mode="discover"): if verify_session_uuid(cwd, 'claude', uuid, s, mode="discover"):
valid_candidates.append(uuid) valid_candidates.append(uuid)
if len(valid_candidates) > 1:
drifts.append({'class': 'C-ambiguous', 'name': s['name'],
'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"})
s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates"
actions.append(f"ambiguous candidates: {s['name']}")
if len(valid_candidates) == 1: if len(valid_candidates) == 1:
uuid = valid_candidates[0] uuid = valid_candidates[0]
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "claude" "{cwd}"'] cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "claude" "{cwd}"']
@@ -601,7 +647,7 @@ for s in d.get('herdr_sessions', []):
# === drift C (agy): agy 새 session id materialize (per-row own id) === # === drift C (agy): agy 새 session id materialize (per-row own id) ===
for s in d.get('herdr_sessions', []): for s in d.get('herdr_sessions', []):
if not s.get('name', '').endswith('-creator-agy'): if row_agent(s) != 'agy':
continue continue
if s.get('status') != 'running': if s.get('status') != 'running':
continue continue
@@ -632,6 +678,11 @@ for s in d.get('herdr_sessions', []):
if verify_session_uuid(cwd, 'agy', uuid, s_eval, mode="discover"): if verify_session_uuid(cwd, 'agy', uuid, s_eval, mode="discover"):
valid_candidates.append(uuid) valid_candidates.append(uuid)
if len(valid_candidates) > 1:
drifts.append({'class': 'C-ambiguous', 'name': s['name'],
'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"})
s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates"
actions.append(f"ambiguous candidates: {s['name']}")
if len(valid_candidates) == 1: if len(valid_candidates) == 1:
uuid = valid_candidates[0] uuid = valid_candidates[0]
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "agy" "{cwd}"'] cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "agy" "{cwd}"']
@@ -645,7 +696,7 @@ for s in d.get('herdr_sessions', []):
# === drift C (hermes): hermes 새 session id materialize (per-row own id) === # === drift C (hermes): hermes 새 session id materialize (per-row own id) ===
for s in d.get('herdr_sessions', []): for s in d.get('herdr_sessions', []):
if not s.get('name', '').endswith('-creator-hermes'): if row_agent(s) != 'hermes':
continue continue
if s.get('status') != 'running': if s.get('status') != 'running':
continue continue
@@ -670,6 +721,11 @@ for s in d.get('herdr_sessions', []):
except Exception: except Exception:
pass pass
if len(valid_candidates) > 1:
drifts.append({'class': 'C-ambiguous', 'name': s['name'],
'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"})
s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates"
actions.append(f"ambiguous candidates: {s['name']}")
if len(valid_candidates) == 1: if len(valid_candidates) == 1:
uuid = valid_candidates[0] uuid = valid_candidates[0]
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "hermes" "{cwd}"'] cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "hermes" "{cwd}"']
@@ -683,7 +739,7 @@ for s in d.get('herdr_sessions', []):
# === drift C (cline): cline 새 session id materialize (per-row own id) === # === drift C (cline): cline 새 session id materialize (per-row own id) ===
for s in d.get('herdr_sessions', []): for s in d.get('herdr_sessions', []):
if not s.get('name', '').endswith('-creator-cline'): if row_agent(s) != 'cline':
continue continue
if s.get('status') != 'running': if s.get('status') != 'running':
continue continue
@@ -709,8 +765,12 @@ for s in d.get('herdr_sessions', []):
uuid = os.path.basename(j)[:-5] uuid = os.path.basename(j)[:-5]
if verify_session_uuid(cwd, 'cline', uuid, s, mode="discover"): if verify_session_uuid(cwd, 'cline', uuid, s, mode="discover"):
valid_candidates.append(uuid) valid_candidates.append(uuid)
break
if len(valid_candidates) > 1:
drifts.append({'class': 'C-ambiguous', 'name': s['name'],
'msg': f"{s['name']}: {len(valid_candidates)} candidate transcripts newer than session epoch; not pinning"})
s['last_visible_status'] = f"ambiguous: {len(valid_candidates)} candidates"
actions.append(f"ambiguous candidates: {s['name']}")
if len(valid_candidates) == 1: if len(valid_candidates) == 1:
uuid = valid_candidates[0] uuid = valid_candidates[0]
cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "cline" "{cwd}"'] cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "cline" "{cwd}"']
@@ -85,8 +85,9 @@ if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
fi fi
# 3. Determine CMD_FULL # 3. Determine CMD_FULL
# For claude: if assigned UUID transcript .jsonl is unmaterialized on disk, use --session-id <uuid>; otherwise use -r <uuid>
case "$AGENT" in case "$AGENT" in
claude) CMD_FULL="claude --dangerously-skip-permissions -r $UUID" ;; claude) CMD_FULL="claude --dangerously-skip-permissions $CLAUDE_ID_FLAG $UUID" ;;
agy) CMD_FULL="agy --dangerously-skip-permissions --conversation $UUID" ;; agy) CMD_FULL="agy --dangerously-skip-permissions --conversation $UUID" ;;
hermes) CMD_FULL="hermes --resume $UUID" ;; hermes) CMD_FULL="hermes --resume $UUID" ;;
cline) CMD_FULL="cline -i --id $UUID" ;; cline) CMD_FULL="cline -i --id $UUID" ;;
@@ -80,9 +80,23 @@ if [ "$(uname)" = "Darwin" ] && [ -f "$RESOLVED_BIN" ]; then
xattr -d com.apple.quarantine "$RESOLVED_BIN" 2>/dev/null || true xattr -d com.apple.quarantine "$RESOLVED_BIN" 2>/dev/null || true
fi fi
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
# Determine CMD_FULL # Determine CMD_FULL
case "$AGENT" in case "$AGENT" in
claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions -r $UUID" ;; claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions $CLAUDE_ID_FLAG $UUID" ;;
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --conversation $UUID" ;; agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --conversation $UUID" ;;
hermes) CMD_FULL="${RESOLVED_BIN} --resume $UUID" ;; hermes) CMD_FULL="${RESOLVED_BIN} --resume $UUID" ;;
cline) CMD_FULL="${RESOLVED_BIN} -i --id $UUID" ;; cline) CMD_FULL="${RESOLVED_BIN} -i --id $UUID" ;;
+177 -15
View File
@@ -1,8 +1,8 @@
# 🛠️ Multi-Agent Mux 종합 개선 및 미해결 과제 백로그 (`IMPROVEMENTS.md`) # 🛠️ Multi-Agent Mux 종합 개선 및 미해결 과제 백로그 (`IMPROVEMENTS.md`)
- **최종 갱신일**: 2026-08-08 (B-4 시프트 ls created 포시스 타임스탬프 결함 조치 완료 반영) - **최종 갱신일**: 2026-08-09 (7747d745 Rev.2 — B-7 처방 신설 및 병렬화를 파일 소유권 슬롯으로 교체)
- **통합 관리 대상**: 기존 `CODEBASE_REVIEW_REPORT.md` + `OPTIMIZATION.md` - **통합 관리 대상**: 기존 `CODEBASE_REVIEW_REPORT.md` + `OPTIMIZATION.md`
- **총 추적 미해결 과제**: **11** (아키텍처 1건, 엣지케이스 6건, 오케스트레이션 1건, 레거시 잔재 3건) - **총 추적 미해결 과제**: **12** (아키텍처 2건, 엣지케이스 6건, 오케스트레이션 1건, 레거시 잔재 3건)
- **완료된 과제**: **10건** (A-1, A-3, A-5, B-1, B-3, B-4, C-1, C-2, O-1, O-3) - **완료된 과제**: **10건** (A-1, A-3, A-5, B-1, B-3, B-4, C-1, C-2, O-1, O-3)
--- ---
@@ -13,24 +13,76 @@
--- ---
## 1. 🔴 아키텍처 결함 (Architecture Flaws — 1건) ## 1. 🔴 아키텍처 결함 (Architecture Flaws — 2건)
### **A-2: 공개 브로커 + HMAC 인증 Off + 와일드카드 전파** ### **A-2: 공개 브로커 + HMAC 인증 Off + 와일드카드 전파**
- **현상**: `mqtt_common.py`의 기본 브로커가 공개 서버(`broker.hivemq.com`), HMAC 무조건 True 반환으로 설정되어 있습니다. - **현상**: `mqtt_common.py`의 기본 브로커가 공개 서버(`broker.hivemq.com`)이고, 잡 생성 시 `auth_token`**한 번도 발급되지 않아**(실측 26/26 잡이 `auth_token=None`) `verify_hmac``if not auth_token: return True` 경로가 항상 타집니다. 발행자는 워크스페이스 지문 토픽을 채택하지 않고 전역 `python/mqtt/jobs/<job_id>/events` 로 발행하며, `reconcile.sh:237` 이 같은 전역 토픽을 구독합니다. (HMAC 구현 자체는 정상입니다 — 토큰이 없어 검증이 공허해지는 것이 원인입니다.)
- **파급 효과**: 외부에서 유입되는 malicious `error` 이벤트 수신 시 `reconcile.sh`가 라이브 에이전트 pane을 `kill-session`으로 강제 파괴하는 치명적 보안/안정성 위험이 존재합니다. - **파급 효과**: 외부에서 유입되는 malicious `error` 이벤트 수신 시 `reconcile.sh`가 라이브 에이전트 pane을 `kill-session`으로 강제 파괴하는 치명적 보안/안정성 위험이 존재합니다.
### **A-4 (설계 제안): 에이전트 지식 산재 — `BaseAgentAdapter` 어댑터 계층 도입 (Rev.2)**
> 결함 조치가 아니라 **구조 개선 제안**입니다. 상세 설계·실측 근거는 `.mam/jobs/44062a63/claude-reports/report-final.md` 및 `744ac67a` 를 참조하십시오.
- **현상**: "claude 의 transcript 는 어디 있나", "cline 재개 argv 는 무엇인가" 같은 **에이전트에 대한 사실**이 스크립트 8개 · **34개 팬아웃 지점**에 흩어져 있습니다. (8줄 윈도 안에 4개 에이전트 중 3개 이상이 등장하는 지점 기준: `create_session.sh` 8, `reconcile.sh` 7, `stop_session.sh` 6, `lib.sh` 4, `resolve_session_id.sh` 3, `resume_session.sh`/`update_yaml_resumed.sh`/`run_loop.sh` 각 2)
- **사본 현황**: `agent → *_id_own` 키 맵 **4벌**, `~/.claude/projects` 17참조, `antigravity-cli/conversations` 11참조, `.hermes/state.db` 6참조, `.cline/data/sessions` 5참조.
- **파급 효과**: 같은 질문에 **서로 다른 답**이 공존합니다. 세션명→에이전트 추론이 `reconcile.sh::row_agent`(pane.cmd → cmd_full → 이름 접미사)와 `run_loop.sh:236-243`(세그먼트 매칭 + 실패 시 `claude` 기본값) 두 벌로 존재하며 후자는 오판 가능합니다. b4a1d094 에서 드러난 "격리 분기 inert" 결함도 아티팩트 경로 규칙이 두 벌이었던 데서 비롯됐습니다.
#### 설계 요지 (Rev.2 갱신)
- **`.agents/skills/mam_agents/`** 패키지(약 484줄): `base.py`(ABC + `SpawnSpec` + `DiscoveryContext`), `registry.py`(정적 레지스트리), `__main__.py`(셸 브리지), `adapters/{claude,agy,hermes,cline}.py`.
- **인터페이스**: `own_key` / `supports_assigned_id` / `ready_tokens` 속성 + `auth_ok(run)` · `spawn_spec()` · `resume_spec(binary, uuid, materialized)` · `artifact_path(uuid, ctx)` · `verify_artifact(uuid, ctx)` · `discover(ctx)`.
- **`DiscoveryContext` & `discover()` 템플릿 메서드**: `cwd`(물리 절대경로), `ws_key`, `home`, `claude_dir`, `iso_root`, `epoch`, `claimed` 필드를 객체 하나로 캡슐화. `_raw_candidates(ctx)` 추상 메서드로 어댑터별 물리 탐색만 서술하고 `claimed`/`epoch` 필터는 기반 클래스 템플릿이 100% 보장.
- `auth_ok` 는 서브프로세스 실행자를 **주입**받아 CLI 미설치 환경에서도 단위 테스트가 가능합니다(hermes 는 현재 미설치).
- `resume_spec``materialized` 파라미터가 b4a1d094 규칙(`claude -r <미실현 uuid>` 실패 → `--session-id`)을 흡수합니다.
- `ready_tokens` 속성을 어댑터로 내보내 `wait_for_tui_ready` 의 25줄 inline shell `case` 블록을 단 1줄의 `grep -E -q "$MAM_READY_TOKENS"` 로 축소.
- **레지스트리 패턴**: **정적 dict 채택.** `entry_points` 는 MAM 이 `pip install` 되지 않고 `rsync`/`cp` 로 배포되므로 부적합, 디렉터리 스캔은 "0개 발견"과 "에이전트 미설치"가 구분되지 않아 부적합.
- **bash↔python 브리지**: `python -m mam_agents facts <agent>``shlex.quote``KEY=value` 를 출력. **스크립트당 1회 `eval`** 이 계약입니다(실측 22.8 ms/호출 vs bash `case` 2.3 ms — 분기마다 호출하면 안 됨).
#### 실현 가능성 및 부트스트랩 (Rev.2 실측)
- 파이썬 진입점 **3종 전부**에서 import 확인: `env_python`, `atomic_dump_yaml`(flock 쓰기 경로), venv 없는 **맨 `/usr/bin/python3`**.
- **`lib.sh` source 시점 1회 `export PYTHONPATH`**: `lib.sh` 로드 시 `_mam_export_pythonpath``mam_skills_dir``PYTHONPATH` 에 자동 얹어 `run_loop.sh` 등 셸 상의 모든 `python3 -c` 호출이 한 번에 호환됨.
- herdr shim 내부 `python3 -c` **9곳 전부 에이전트 지식 0** → shim 경계가 이미 올바른 위치에 있습니다.
- **제약(규칙화 필요)**: 어댑터는 **표준 라이브러리만** 사용해야 합니다. 서드파티 의존성이 들어오면 venv 없는 경로가 깨집니다.
#### 선행 필수 체크리스트 (누락 시 조용히 업그레이드가 막힘)
1. **`deploy/remove.sh:83-91` `fallback_assets``".agents/skills/mam_agents"` 등록.**
미등록 시 `tests/test_deploy_freshness.py::test_d2` 가 실패하며(프로토타입에서 실제 발생), 언인스톨 후 잔존 자산이 워크스페이스를 **영구히 구버전에 고정**시킵니다(`install.sh` 는 비-스킬 자산을 "없을 때만" 복사).
2. `deploy/install.sh` 무결성 자산 목록 등록.
3. `deploy/gitea-ci.yml:69-77``flake8` / `py_compile` 경로 추가 — 현재 CI 파이썬 잡은 `multi-agent-mux-delegate-job/scripts/` 만 검사하므로 신규 패키지는 린트 사각지대입니다.
#### 단계적 이행 (각 단계는 셸 사본 제거를 같은 커밋에 포함)
| 단계 | 내용 |
|---|---|
| M0 | 패키지 골격 + `lib.sh` source 시점 `PYTHONPATH` export + 배포/CI 등록 |
| M1 | `own_key` / `agent_of_row` 이관 — **프로토타입 검증 완료: 팬아웃 34 → 29** |
| M2 | `artifact_path` + `verify_artifact` (`verify_session_uuid` 4분기, `DiscoveryContext` 적용, 격리 경로 일원화) |
| M3 | `spawn_spec` / `resume_spec` / `auth_ok` (create·resume argv 및 프리플라이트) |
| M4 | `discover()` (raw candidates + base class template filter) — hermes DB 스키마 실측 선행 |
| M5 | `stop_session.sh` purge 경로 및 exit key |
| M6 | `ready_tokens` 어댑터 이관 (`wait_for_tui_ready` 25줄 case 제거) |
| M7 | claude ready tokens 에서 `projects` 제거 및 자체 검증 분리 커밋 |
- **정직한 상한**: 34 → **약 10**. 남는 약 10곳은 셸 상주 TUI·프로세스 제어(`send_keys_safe` 의 agy/claude/cline 분기, `handle_startup_dialogs`, spawn 래퍼 분기, `pgrep -P` 자식 pid 게이트)로 이 추상화의 대상이 아닙니다.
- **중단 기준**: M2 이후에도 팬아웃이 29 → 20 이하로 떨어지지 않으면 중단하고 잔여 단계를 재검토합니다.
- **검증 상태**: Rev.2 프로토타입 기준 전체 회귀 **162 passed (회귀 0)**, 변이 6/6 검출, 배포 스위트 25/25, `py_compile` 통과.
--- ---
## 2. 🟠 엣지 케이스 및 런타임 버그 (Edge-case Bugs — 6건) ## 2. 🟠 엣지 케이스 및 런타임 버그 (Edge-case Bugs — 6건)
### **B-5: `df --output` GNU 전용 플래그 사용으로 macOS NFS 감지 실패** ### **B-5: `df --output` GNU 전용 플래그 사용으로 macOS NFS 감지 실패** — ⚠️ **종결 권고 (재현 불가)**
- macOS/BSD 환경에서 `df --output` 구문 오류로 NFS 감지가 실패하고 "NFS 아님"으로 오판되어 SQLite WAL 포맷을 강행합니다. - 원 서술: macOS/BSD 환경에서 `df --output` 구문 오류로 NFS 감지가 실패하고 "NFS 아님"으로 오판되어 SQLite WAL 포맷을 강행합니다.
- **실측(ecef05a3)**: `df --output=target` 은 여전히 `rc=64` 로 실패하나 `ea36e81` 에서 추가된 `df -P` 폴백이 정상 동작합니다(macOS 실측: `/System/Volumes/Data`). **결론("감지 실패")은 더 이상 참이 아니므로 종결을 권고합니다.**
- **잔여분 → B-11 로 분리 권고**: `mount | grep -E "$mountpoint.*(nfs|cifs|smb|sshfs)"` 가 마운트포인트를 이스케이프 없이 ERE 에 보간하여 경로의 `.` 이 임의 문자로 해석됩니다(이론상 오탐).
### **B-6: 스킬 트리에 임시 파일 복사 및 유출** ### **B-6: 스킬 트리에 임시 파일 복사 및 유출**
- `run_loop.sh::delegate_job_safe`가 래퍼 스크립트를 `.agents/skills/...` 트리 내부에 `.tmp`로 복사하여 버전 관리 트리를 오염시키고 rsync 배포 시 외부로 유출됩니다. - `run_loop.sh::delegate_job_safe`가 래퍼 스크립트를 `.agents/skills/...` 트리 내부에 `.tmp`로 복사하여 버전 관리 트리를 오염시키고 rsync 배포 시 외부로 유출됩니다.
### **B-7: `run_loop.sh` 상대경로 cwd 의존 및 미추적 파일 누락** ### **B-7: `run_loop.sh` cwd 의존 및 미추적 파일 누락 — 리뷰어가 빈 diff 로 PASS**
- 저장소 루트 밖에서 `run_loop.sh` 구동 시 `wait_for_job`이 3900초 무음 타임아웃을 발생시키며, `git diff`가 Creator가 새로 추가한 미추적 신규 파일을 리뷰어에게 누락합니다. - 원 서술: 저장소 루트 밖에서 `run_loop.sh` 구동 시 `wait_for_job`이 3900초 무음 타임아웃을 발생시키며, `git diff`가 Creator가 새로 추가한 미추적 신규 파일을 리뷰어에게 누락합니다.
- **실측(ecef05a3)**: `REPO_ROOT``BASH_SOURCE` 기반이라 cwd 비의존이지만(9-10행) **스크립트가 어디로도 `cd` 하지 않아** `git diff`(537·539행)가 호출자 cwd 에서 실행됩니다. 저장소 밖에서는 `rc=129` 이고 `|| echo "No git diff available"` 가 이를 삼켜, 리뷰어는 그 **문자열 하나를 받고 `[VERDICT: PASS]` 를 요구받습니다.**
- 미추적 파일은 `git diff` 정의상 제외됩니다(실측 0 hunks). 신규 파일이 곧 산출물인 작업(예: A-4 의 `mam_agents/` 패키지)에서는 **리뷰가 사실상 존재하지 않게 됩니다.**
- **파급**: 이 항목이 열려 있는 동안 나머지 11건의 구현 리뷰를 신뢰할 수 없습니다.
- **처방**: §6.4 참조 ( + 미추적 파일 덧붙이기 + 크기 상한). ** 은 인덱스를 오염시키므로 채택하지 않습니다.**
### **B-8: `send_keys_safe` agy 경로 검증 이탈** ### **B-8: `send_keys_safe` agy 경로 검증 이탈**
- agy 세션 주입 시 주입 실패 여부를 검증하지 않고 무조건 `return 0`을 남겨 실패 시에도 성공으로 보고됩니다. - agy 세션 주입 시 주입 실패 여부를 검증하지 않고 무조건 `return 0`을 남겨 실패 시에도 성공으로 보고됩니다.
@@ -43,7 +95,7 @@
--- ---
## 3. 🟡 오케스트레이션 최적화 과제 (Orchestration Optimizations — 2건) ## 3. 🟡 오케스트레이션 최적화 과제 (Orchestration Optimizations — 1건)
### **O-2 (구 ISSUE-7): 동일 워크스페이스 내 중복 루프 기동 방지 락 (Race-Free Lock)** ### **O-2 (구 ISSUE-7): 동일 워크스페이스 내 중복 루프 기동 방지 락 (Race-Free Lock)**
- **현상**: 동일 작업 트리에서 다수의 `run_loop.sh` 스크립트가 병렬 기동될 경우 SQLite DB 갱신 경합 및 YAML 데이터 오염이 일어날 수 있음. - **현상**: 동일 작업 트리에서 다수의 `run_loop.sh` 스크립트가 병렬 기동될 경우 SQLite DB 갱신 경합 및 YAML 데이터 오염이 일어날 수 있음.
@@ -52,16 +104,20 @@
1. 락 소유자 레코드를 단순 `PID`에서 **`PID + 시작시각(lstart) + 워크스페이스`** 3중 구조로 결합하여 PID 재사용을 결정적으로 차단. 1. 락 소유자 레코드를 단순 `PID`에서 **`PID + 시작시각(lstart) + 워크스페이스`** 3중 구조로 결합하여 PID 재사용을 결정적으로 차단.
2. `mkdir` 직후 생성 창 유예 대기(Sleep Grace Period)를 부여하여 락 도난 방지. 2. `mkdir` 직후 생성 창 유예 대기(Sleep Grace Period)를 부여하여 락 도난 방지.
3. `ps` CLI 부재 시 Fails-Open(락 무시) 대신 **Fails-Safe(락 존중 + 경고)** 로 전환하여 DB/YAML 오염 원천 방지. 3. `ps` CLI 부재 시 Fails-Open(락 무시) 대신 **Fails-Safe(락 존중 + 경고)** 로 전환하여 DB/YAML 오염 원천 방지.
- **실측(ecef05a3) — 서술보다 위험**: `run_loop.sh:83-89``MAM_LOOP_MARKER`**존재 확인 없이 덮어쓰고**, 종료 트랩(`_mam_release_guard`)이 소유권 대조 없이 삭제합니다. 따라서 먼저 종료한 인스턴스가 **아직 실행 중인 다른 인스턴스의 마커까지 지워**, 그 시점부터 **완료 처리된 O-3 위임 가드가 "루프 비활성"으로 오판**합니다. 해제는 자신이 기록한 `pid + lstart` 와 일치할 때만 수행해야 합니다.
--- ---
## 4. ⚪ 레거시 잔재 및 죽은 코드 (Legacy Remnants — 3건) ## 4. ⚪ 레거시 잔재 및 죽은 코드 (Legacy Remnants — 3건)
### **C-3: 격리 스텁 4종 및 `stop_session.sh` 미사용 isolation 코드 잔존** ### **C-3: 격리 관련 잔재** — ⚠️ **C-3a / C-3b 로 분리 필요 (§6.5 참조)**
- `provision_isolation` 등 4개 스텁 함수와 `stop_session.sh` `.mam/agent_homes` 가드 코드가 호출자 0건인 채 잔존합니다. - **C-3a (즉시 실행 가능)**: `provision_isolation` / `isolation_lever` / `isolation_env_prefix` / `isolation_cmd_args` 4종 **빈 스텁**. 프로덕션 호출자 0건. 이를 고정하던 공허한 테스트 5건(`test_tier1_unit.py` 3, `test_tier2_component.py` 1 등)도 함께 제거 대상.
- **C-3b (보류 — A-4 M2 결정 사항)**: `isolation.root` 행 필드 소비자(`verify_session_uuid``iso_root` 분기, `mam_session_iso_root`, `find_workspace_uuid` 격리 분기, `stop_session.sh:277` purge 가드). **b4a1d094 / 44062a63 에서 의도적으로 되살린 코드**이므로 지우면 그 수정이 회귀합니다.
### **C-4: 참조 0회 미사용 심볼 7종** ### **C-4: 참조 0회 미사용 심볼** — ⚠️ **목록 정정됨 (7종 → 실질 3종)**
- `_HERDR_SHIM_DIR_PATTERN`, `_REAL_HERDR_PATH`, `TERMINAL_STATUSES`, `ISOLATE`, `local_herdr` 등 7개 미사용 심볼이 잔존합니다. - **실제 대상 3종**: `_REAL_HERDR_PATH`(대입·export 만), `TERMINAL_STATUSES`(`registry.py:38` 정의만), `ISOLATE`(`create_session.sh:57` 대입만).
- **목록에서 제외**: `_HERDR_SHIM_DIR_PATTERN` 은 **사용 중**입니다(`lib.sh:57` 정의 → `lib.sh:79` 사용). 지우면 shim 경로 판정이 깨집니다. `local_herdr` 은 참조 0건으로 **이미 제거**되었습니다.
- `provision_isolation` 은 **C-3a 와 중복**이므로 그쪽에서 함께 처리합니다.
### **C-6: `stop_session.sh` 도움말 문서 구버전 표기** ### **C-6: `stop_session.sh` 도움말 문서 구버전 표기**
- 스크립트 도움말에는 `--mode soft|hard` 등이 서술되어 있으나 실제 옵션 파서는 `exit 2`로 거부합니다. - 스크립트 도움말에는 `--mode soft|hard` 등이 서술되어 있으나 실제 옵션 파서는 `exit 2`로 거부합니다.
@@ -70,6 +126,13 @@
## 5. 🎉 완료된 과제 (Completed Tasks — 10건) ## 5. 🎉 완료된 과제 (Completed Tasks — 10건)
### **Rev.2 (b4a1d094): 생성 시 세션 ID 자동 할당 및 Reconciler 고정/경로 정규화 프로토콜** — ✅ 완료
- 신규 `claude` 세션 생성 시 `mam_gen_uuid`로 UUID를 즉시 생성하여 `--session-id <uuid>`로 전달하고, `session_id_source: assigned`, `session_id_verified: false`로 등록하는 원자적 고정 구조를 구축했습니다.
- 첫 사용자 메시지 전달 시 디스크 트랜스크립트 `.jsonl` 생성을 모니터 루프(`reconcile.sh` drift C0)가 감지하고 `session_id_verified: true`, `last_visible_status: pinned`로 고정시킵니다.
- 미할당 다수 후보 발견 시 무작위 고정을 금지하고 `C-ambiguous` 상태를 명확히 보고하도록 강화했습니다.
- 심볼릭 링크/트레일링 슬래시/상대경로 계산 시 `mam_abs_workspace``mam_workspace_key` (`cd -P && pwd -P` / `os.path.realpath`) 경로 정규화를 전수 적용하여 100% 키 일치성을 확립했습니다.
- 전용 단위/통합 테스트 스위트 `tests/test_uuid_target.py` (13/13 PASS) 및 전체 회귀 테스트 스위트를 검증 완료했습니다.
### **B-4: 시프트 `ls`의 `created` 동적 POSIX 타임스탬프 복원 및 재개 가드 정상화** — ✅ 완료 ### **B-4: 시프트 `ls`의 `created` 동적 POSIX 타임스탬프 복원 및 재개 가드 정상화** — ✅ 완료
- `.agents/skills/lib.sh` 554번 라인의 `999999` 하드코딩 출력을 제거하고, real herdr 또는 `.mam/agent-sessions.yaml` 에 기록된 세션 생성 시각(`created`/`created_at`/`created_epoch`) 및 동적 POSIX 타임스탬프(`int(time.time())`)를 리턴하도록 정제했습니다. - `.agents/skills/lib.sh` 554번 라인의 `999999` 하드코딩 출력을 제거하고, real herdr 또는 `.mam/agent-sessions.yaml` 에 기록된 세션 생성 시각(`created`/`created_at`/`created_epoch`) 및 동적 POSIX 타임스탬프(`int(time.time())`)를 리턴하도록 정제했습니다.
- `reconcile.sh` drift-B 감지 시 epoch 0 및 `1970-01-01` 오기록 결함을 차단하여 `find_workspace_uuid` 재개 가드가 정상 작동하도록 해결했습니다. - `reconcile.sh` drift-B 감지 시 epoch 0 및 `1970-01-01` 오기록 결함을 차단하여 `find_workspace_uuid` 재개 가드가 정상 작동하도록 해결했습니다.
@@ -127,6 +190,105 @@
--- ---
## 6. 결론 및 향후 보완 로드맵 ## 6. 🧭 우선순위 실행 로드맵 (Prioritized Execution Roadmap)
`IMPROVEMENTS.md` 문서에 따라 향후 코드베이스 개편 시 남은 백로그 항목(아키텍처 2건, 엣지케이스 7건, 오케스트레이션 2건, 레거시 잔재 4건)을 일원화된 보완 로드맵으로 관리합니다. > 평가 근거·항목별 실측 결과는 `.mam/jobs/ecef05a3/claude-reports/report-final.md` 참조.
> **순위를 매기기 전에 12건을 전부 현재 코드에 대조했으며, 그중 4건(A-2·B-5·C-3·C-4)의 기존 서술이 현재 코드와 달라 본문을 정정했습니다.**
### 6.1 정렬 원칙
① 외부에서 트리거 가능한 위험 → ② 검증 신호를 **거짓으로** 만드는 결함 → ③ 다른 항목을 싸게 만드는 구조 작업 → ④ 국소 결함 → ⑤ 정리.
**조용한 실패에 가중치를 둡니다.** 시끄러운 실패는 사람이 보지만, 조용한 실패는 "통과"로 기록되고 그 위에 다음 작업이 쌓입니다.
### 6.2 실행 순서
> **사용자 지침 반영**: **A-2 (공개 브로커 & HMAC)** 과제는 차후 자체 전용 MQTT 브로커 서버를 구축할 예정이므로 사용자 지침에 따라 **우선순위를 최하위(P5)로 조정**하였습니다.
| 순위 | 항목 | 근거 | 비용 | 선행 |
|---|---|---|---|---|
| **P0-1** | **B-7** | 저장소 밖 기동 시 리뷰어가 문자열 `"No git diff available"``[VERDICT: PASS]` 를 냄. 신규(미추적) 파일은 리뷰 대상 밖. **다른 모든 과제의 검증 근거를 훼손하는 최우선 해결 항목** | 소 (1파일) | — |
| **P0-2** | **O-2** | 마커를 조건 없이 덮어쓰고 종료 트랩이 **타 인스턴스의 마커까지 삭제** → 완료 처리된 **O-3 가드가 조용히 무력화**됨 | 소~중 (1파일) | — |
| **P1-1** | **A-4 M0~M1** | `PYTHONPATH` 부트스트랩·배포/CI 등록·`own_key` 이관. B-8/B-10/C-3b 로직을 싸게 만듦 | 중 | B-7 |
| **P1-2** | **B-8** | agy 주입이 검증 없이 `return 0` → 아무것도 전달되지 않은 잡이 `started` 로 기록됨. A-4 M0(`_pane_capture` 디코딩) 이후엔 **회피책 제거**로 축소 | 소 | A-4 M0 |
| **P2-1** | **B-6** | 버전 관리 트리 오염 + rsync 배포 유출. `mktemp -d` 로 옮기는 1~2줄 | 소 | — |
| **P2-2** | **C-3a + C-4** | 빈 스텁 4종 + 이를 고정하던 **공허한 테스트 5건** + 죽은 심볼 3종 제거 (회귀 시간 단축 효과) | 소 | — |
| **P2-3** | **C-6** | 도움말 3줄 정정 | 극소 | — |
| **P3-1** | **A-4 M2~M7** | 어댑터 본이관. 진행 중 **B-10 · C-3b 처분 결정** | 대 | P1-1 |
| **P3-2** | **B-10** | tier-3 신원 캐시 존치/제거 결정 + PyYAML 의존 완화 | 중 | A-4 M2 |
| **P3-3** | **C-3b** | `isolation.root` 소비자 처분 결정 | 소 | A-4 M2 |
| **P4-1** | **B-9** | 기본값 한정. `logs_dir` 인자·`DELEGATE_JOB_LOGS_DIR` 두 가지 회피 수단 존재 | 극소 | — |
| **P5-1** | **A-2** | 공개 브로커 및 HMAC 검증 보완 (📌 *사용자 지침: 차후 전용 MQTT 브로커 서빙 환경 구축 시점에 진행*) | 중 (3파일) | 전용 브로커 |
| **종결 권고** | **B-5** | 폴백(`df -P`)으로 이미 해소 — 서술된 실패가 재현되지 않음 | — | — |
**A-2 과제의 후순위 배치 사유**: 사용자 지침에 따라 차후 자체 전용 MQTT 브로커 서빙 환경 구축 시점에 맞춰 진행하기 위해 **최하위(P5-1)**로 배치하였습니다.
**정리(C 계열)를 P2 에 두는 이유**: (a) C-3a 는 공허한 테스트 5건을 함께 제거해 이후 모든 전체 회귀를 단축하고, (b) C-4 는 **잘못 실행하면 버그를 만듭니다**. 방치할수록 누군가 "쉬운 정리"로 집어 들 확률이 올라갑니다.
### 6.3 병렬 실행 — 파일 소유권 슬롯 (Rev.2 교체)
> Rev.1 은 "주제별 트랙"으로 병렬화를 서술했고 **그 분해는 4곳에서 틀렸습니다**(챌린지 `7d604ee7` 계기로 파일 단위 재대조). 병렬 단위는 **주제가 아니라 파일**입니다.
**항목별 처방이 건드리는 파일**
| 파일 | 건드리는 항목 |
|---|---|
| `run_loop.sh` | **B-6, B-7, O-2** |
| `lib.sh` | **A-4, B-8, B-10, C-3a, C-4** |
| `reconcile.sh` | **A-2, A-4, B-10** |
| `mqtt_common.py` | **A-2, B-9** |
| `stop_session.sh` | **B-10, C-6** |
| `registry.py` | **A-2, C-4** |
| `create_session.sh` | **A-4, C-4** |
**슬롯 배치 — 슬롯 안은 직렬, 슬롯 간은 병렬**
| 슬롯 | 순서 |
|---|---|
| **`run_loop.sh`** | `B-7``O-2``B-6` |
| **`lib.sh`** | `C-3a`+`C-4``B-8` |
| **MQTT 계열** (`mqtt_common.py`·`registry.py`·`publish_event.py`·`job_subscriber.py`·`reconcile.sh`) | `A-2``B-9` |
| **`stop_session.sh`** | `C-6` |
| **단독 실행** (슬롯 경계를 넘음) | `A-4`, `B-10` |
- `A-4`(`lib.sh`+`reconcile.sh`+`create_session.sh`+`resume_session.sh`)와 `B-10`(`lib.sh`+`reconcile.sh`+`stop_session.sh`)은 **어떤 슬롯 조합과도 겹치므로 단독 실행**합니다.
- `A-4` 는 신규 파일을 대량 추가하므로 **B-7 이 먼저 닫혀 있어야 리뷰가 성립**합니다.
- **Rev.1 오류 정정 4건**: `B-6`(트랙 C→`run_loop.sh` 슬롯), `C-3a`·`C-4`(트랙 C→`lib.sh` 슬롯), `B-9`(독립→MQTT 슬롯), `C-6`(독립→`stop_session.sh` 슬롯).
- 참고: `reconcile.sh``MAM_LOOP_MARKER`·`send_keys_safe` 를 **참조 0건**이므로 `O-2`·`B-8` 과는 경합하지 않습니다(자체 `.mam/monitor.lock` 보유).
### 6.4 B-7 처방 (Rev.2 신설 — 진단만 있고 처방이 없었음)
`cd "$REPO_ROOT"` 만으로는 **미추적 신규 파일이 여전히 100% 누락**됩니다. `git diff` 는 정의상 추적 파일만 봅니다.
```bash
CHANGES_DIFF=$(
cd "$REPO_ROOT" || exit 1
git diff "$BASE_COMMIT"
# 미추적 신규 파일을 인덱스 변경 없이 덧붙인다.
# --exclude-standard 가 .gitignore / .git/info/exclude 를 그대로 존중한다.
git ls-files -o --exclude-standard -z | while IFS= read -r -d '' f; do
git diff --no-index --binary /dev/null "$f" 2>/dev/null || true
done
)
```
**`git add -N .` 은 채택하지 않습니다.** 미추적 파일을 diff 에 넣는 목적은 달성하지만(실측 확인) **인덱스에 `A` 항목을 남기며**, 그 상태에서 Creator 가 `git commit -am` 을 실행하면 **추가한 적 없는 파일이 내용째 커밋됩니다**(실측 확인). `run_loop.sh` 는 Creator 가 같은 저장소에서 작업하는 동안 반복 실행되므로 실제 위험입니다. 위 대안은 동일 결과를 내면서 인덱스를 건드리지 않습니다.
**크기 상한도 함께 필요합니다.** 현재 `CHANGES_DIFF`(537·539행)는 **아무 제한 없이** 547행 리뷰 프롬프트에 보간되고 `send_keys_safe` paste-buffer 로 주입됩니다. 미추적 파일을 포함시키면 커지기만 하므로 상한을 두고, 초과 시 `--stat` 요약으로 대체하되 **잘렸다는 사실을 리뷰어에게 반드시 노출**해야 합니다(조용히 자르면 "빈 diff PASS" 가 "부분 diff PASS" 로 바뀔 뿐입니다). 구체적 임계값은 구현자가 샌드박스 세션에서 paste-buffer 실패 지점을 측정해 확정할 것.
### 6.5 ⚠️ 실행 전 반드시 확인할 정정 사항
1. **C-3 은 그대로 실행하면 회귀를 만듭니다.** 항목이 성격이 다른 둘을 묶고 있습니다.
- **C-3a (즉시 실행 가능)**: `provision_isolation` / `isolation_lever` / `isolation_env_prefix` / `isolation_cmd_args` 4종 빈 스텁 — 프로덕션 호출자 0건. 이를 고정하던 `tests/test_tier1_unit.py` 3건 + `tests/test_tier2_component.py` 1건도 함께 제거 대상.
- **C-3b (보류 — A-4 M2 결정 사항)**: `isolation.root` 소비자(`lib.sh` `verify_session_uuid``iso_root` 분기, `mam_session_iso_root`, `find_workspace_uuid` 격리 분기, `stop_session.sh:277` purge 가드). **b4a1d094 / 44062a63 에서 방금 의도적으로 되살린 코드**이며, 지우면 그 수정이 되돌아갑니다.
2. **C-4 의 "7종" 중 2종은 사실과 다릅니다.**
- `_HERDR_SHIM_DIR_PATTERN`**사용 중입니다** (`lib.sh:57` 정의, `lib.sh:79` 사용). 목록대로 지우면 shim 경로 판정이 깨집니다.
- `local_herdr` — 참조 0건, **이미 제거됨**.
- 실제 대상은 `_REAL_HERDR_PATH`, `TERMINAL_STATUSES`, `ISOLATE` 3종이며 `provision_isolation` 은 C-3a 와 중복입니다.
3. **A-2 의 원인 표현 정정**`verify_hmac` 구현 자체는 정상입니다(토큰이 있으면 `hmac.compare_digest` 로 검증). 원인은 **토큰이 아무 데서도 발급되지 않아 검증이 공허해지는 것** + 발행자가 워크스페이스 지문 토픽을 채택하지 않은 것입니다. 수정은 ① 발행자 토픽 교체 ② `reconcile.sh:237` 레거시 전역 구독 제거 ③ `verify_hmac` fail-closed + 토큰 발급 순입니다.
4. **B-5 잔여분** — 폴백으로 감지는 정상화됐으나 `mount | grep -E "$mountpoint.*(nfs|...)"` 가 마운트포인트를 **이스케이프 없이 ERE 에 보간**합니다(경로의 `.` 이 임의 문자로 해석). 이것만 신규 항목(B-11)으로 분리 권고.
### 6.6 결론
`IMPROVEMENTS.md` 는 남은 백로그 항목(아키텍처 2건, 엣지케이스 6건, 오케스트레이션 1건, 레거시 잔재 3건 — 총 12건)을 위 우선순위에 따라 일원화된 보완 로드맵으로 관리합니다.
+2 -2
View File
@@ -65,8 +65,8 @@
| 에이전트 이름 | 역할 | herdr 세션 상태 | 비고 | | 에이전트 이름 | 역할 | herdr 세션 상태 | 비고 |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| `canary-projects-multi-agent-mux-creator-claude` | Planner | `running` | 대화 UUID `01eae7cf...` 복원 완 | | `canary-projects-multi-agent-mux-creator-claude` | Planner | `running` | 대화 UUID `01eae7cf...` 복원 완 |
| `canary-projects-multi-agent-mux-creator-agy` | Creator | `running` | 신규 세션 기동 완료 | | `canary-projects-multi-agent-mux-creator-agy` | Creator | `running` | 대화 UUID `72d2d251...` 복원 완료 |
| `canary-projects-multi-agent-mux-creator-cline` | Reviewer | `running` | 대화 UUID `17856352...` 복원 완료 | | `canary-projects-multi-agent-mux-creator-cline` | Reviewer | `running` | 대화 UUID `17856352...` 복원 완료 |
--- ---
+11 -2
View File
@@ -248,7 +248,16 @@ elif cmd1 == "agent":
} }
# Generate session/conversation UUID and files to simulate agent startup # Generate session/conversation UUID and files to simulate agent startup
session_uuid = str(uuid.uuid4()) session_uuid = ""
cmd_str = " ".join(agent_cmd)
if "--session-id" in cmd_str:
parts = cmd_str.split()
if "--session-id" in parts:
_i = parts.index("--session-id")
if _i + 1 < len(parts):
session_uuid = parts[_i + 1]
if not session_uuid:
session_uuid = str(uuid.uuid4())
own_key_map = { own_key_map = {
"claude": "claude_session_id_own", "claude": "claude_session_id_own",
"agy": "agy_conversation_id_own", "agy": "agy_conversation_id_own",
@@ -261,7 +270,7 @@ elif cmd1 == "agent":
# Find isolation directory (e.g. if HOME has agent_homes/<uuid>) # Find isolation directory (e.g. if HOME has agent_homes/<uuid>)
home_dir = os.environ.get("HOME", "TMP_PATH_PLACEHOLDER") home_dir = os.environ.get("HOME", "TMP_PATH_PLACEHOLDER")
ws_abs = os.path.abspath(cwd or "TMP_PATH_PLACEHOLDER") ws_abs = os.path.realpath(cwd or "TMP_PATH_PLACEHOLDER")
ws_key = ws_abs.replace('/', '-').replace('_', '-') ws_key = ws_abs.replace('/', '-').replace('_', '-')
if agent_type == "claude": if agent_type == "claude":
+1 -1
View File
@@ -107,7 +107,7 @@ def test_integration_stop_purge_combination(mam_sandbox, mock_herdr, mock_agents
claude_uuid = session["claude_session_id_own"] claude_uuid = session["claude_session_id_own"]
# Locate dynamically generated conversation file in claude projects # Locate dynamically generated conversation file in claude projects
key = str(tmp_path).replace('/', '-').replace('_', '-') key = os.path.realpath(str(tmp_path)).replace('/', '-').replace('_', '-')
proj_dir = tmp_path / ".claude" / "projects" / key proj_dir = tmp_path / ".claude" / "projects" / key
assert proj_dir.exists(), f"Expected projects directory {proj_dir} to exist" assert proj_dir.exists(), f"Expected projects directory {proj_dir} to exist"
+429
View File
@@ -0,0 +1,429 @@
import os
import json
import uuid
import pytest
import subprocess
import yaml
from pathlib import Path
def run_mutation(mam_sandbox, mutation_str, env=None):
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
cmd_str = f"source '{lib_path}' && atomic_dump_yaml '{yaml_path}'"
run_env = dict(os.environ)
if env:
run_env.update(env)
res = subprocess.run(["bash", "-c", cmd_str], input=mutation_str, capture_output=True, text=True, env=run_env, cwd=str(mam_sandbox))
return res
def test_t1_create_assigned_session(mam_sandbox, mock_herdr, mock_agents):
"""T-1: create immediately has claude_session_id_own as uuid, source=assigned, cmd_full with --session-id"""
ws = str(mam_sandbox / "project")
os.makedirs(ws, exist_ok=True)
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
cmd = [
"bash", str(script_path),
"--workspace", ws,
"--agent", "claude",
"--role", "creator",
"--session", "test-t1-claude",
"--no-onboard"
]
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(mam_sandbox))
assert res.returncode == 0, f"create failed: {res.stderr}"
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
with open(yaml_path) as f:
data = yaml.safe_load(f)
session = [s for s in data.get("herdr_sessions", []) if s.get("name") == "test-t1-claude"][0]
cid = session.get("claude_session_id_own")
assert cid is not None and len(cid) > 0
assert session.get("session_id_source") == "assigned"
assert session.get("session_id_verified") is False
assert "--session-id" in session.get("pane", {}).get("cmd_full", "")
def test_t2_assigned_id_promotion(mam_sandbox, mock_herdr, mock_agents):
"""T-2: assigned ID is unverified before transcript appears, verified after transcript materializes and reconcile runs"""
ws = str(mam_sandbox / "project2")
os.makedirs(ws, exist_ok=True)
create_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
cmd = [
"bash", str(create_script),
"--workspace", ws,
"--agent", "claude",
"--role", "creator",
"--session", "test-t2-claude",
"--no-onboard"
]
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(mam_sandbox))
assert res.returncode == 0, f"create failed: {res.stderr}"
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
with open(yaml_path) as f:
data = yaml.safe_load(f)
session = [s for s in data.get("herdr_sessions", []) if s.get("name") == "test-t2-claude"][0]
cid = session.get("claude_session_id_own")
assert session.get("session_id_verified") is False
# Simulate transcript materialization on disk
ws_key = os.path.realpath(ws).replace("/", "-").replace("_", "-")
c_dir = os.path.expanduser("~/.claude/projects")
t_dir = os.path.join(c_dir, ws_key)
os.makedirs(t_dir, exist_ok=True)
with open(os.path.join(t_dir, f"{cid}.jsonl"), "w") as f:
f.write(json.dumps({"type": "queue-operation", "sessionId": cid}) + "\n")
f.write(json.dumps({"type": "user", "sessionId": cid, "cwd": ws}) + "\n")
# Run reconcile once
res2 = subprocess.run(["bash", str(reconcile_script), "--once"], capture_output=True, text=True, cwd=str(mam_sandbox))
assert res2.returncode == 0, f"reconcile failed: {res2.stderr}"
with open(yaml_path) as f:
data = yaml.safe_load(f)
session = [s for s in data.get("herdr_sessions", []) if s.get("name") == "test-t2-claude"][0]
assert session.get("session_id_verified") is True
assert session.get("last_visible_status") == "pinned"
def test_t3_different_cwd_transcript_not_pinned(mam_sandbox):
"""T-3: transcript belonging to a different cwd is not pinned"""
ws1 = str(mam_sandbox / "ws1")
ws2 = str(mam_sandbox / "ws2")
os.makedirs(ws1, exist_ok=True)
os.makedirs(ws2, exist_ok=True)
test_uuid = str(uuid.uuid4())
ws1_key = os.path.realpath(ws1).replace("/", "-").replace("_", "-")
c_dir = os.path.expanduser("~/.claude/projects")
t_dir = os.path.join(c_dir, ws1_key)
os.makedirs(t_dir, exist_ok=True)
t_file = os.path.join(t_dir, f"{test_uuid}.jsonl")
with open(t_file, "w") as f:
f.write(json.dumps({"type": "queue-operation", "sessionId": test_uuid}) + "\n")
f.write(json.dumps({"type": "user", "sessionId": test_uuid, "cwd": ws2}) + "\n")
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
cmd = [
"bash", "-c",
f'source "{lib_path}" && verify_session_uuid "{ws1}" "claude" "{test_uuid}" \'{{"pane":{{"cwd":"{ws1}"}}}}\' discover'
]
res = subprocess.run(cmd, cwd=str(mam_sandbox))
assert res.returncode != 0
def test_t4_ambiguous_candidates_reported(mam_sandbox, mock_herdr, mock_agents):
"""T-4: 2 candidate transcripts -> not pinning + C-ambiguous reported"""
ws = str(mam_sandbox / "ambig_ws")
os.makedirs(ws, exist_ok=True)
session_name = "ambig-creator-claude"
create_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
cmd = [
"bash", str(create_script),
"--workspace", ws,
"--agent", "claude",
"--role", "creator",
"--session", session_name,
"--no-onboard"
]
subprocess.run(cmd, check=True, cwd=str(mam_sandbox))
# Reset own ID to None to simulate legacy session without assigned ID
mutation = f"""
for s in d.get('herdr_sessions', []):
if s.get('name') == '{session_name}':
s['claude_session_id_own'] = None
s['session_id_source'] = None
"""
res = run_mutation(mam_sandbox, mutation)
assert res.returncode == 0
ws_key = os.path.realpath(ws).replace("/", "-").replace("_", "-")
c_dir = os.path.expanduser("~/.claude/projects")
t_dir = os.path.join(c_dir, ws_key)
os.makedirs(t_dir, exist_ok=True)
u1, u2 = str(uuid.uuid4()), str(uuid.uuid4())
for u in (u1, u2):
with open(os.path.join(t_dir, f"{u}.jsonl"), "w") as f:
f.write(json.dumps({"type": "queue-operation", "sessionId": u}) + "\n")
f.write(json.dumps({"type": "user", "sessionId": u, "cwd": ws}) + "\n")
reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
res2 = subprocess.run(["bash", str(reconcile_script), "--once"], capture_output=True, text=True, cwd=str(mam_sandbox))
assert res2.returncode == 0
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
with open(yaml_path) as f:
d = yaml.safe_load(f)
s = [x for x in d["herdr_sessions"] if x["name"] == session_name][0]
assert s.get("claude_session_id_own") is None
assert "ambiguous" in s.get("last_visible_status", "")
def test_t5_custom_session_name_pinned(mam_sandbox, mock_herdr, mock_agents):
"""T-5: custom --session name also gets pinned"""
ws = str(mam_sandbox / "custom_name_ws")
os.makedirs(ws, exist_ok=True)
create_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
cmd = [
"bash", str(create_script),
"--workspace", ws,
"--agent", "claude",
"--role", "creator",
"--session", "my-custom-session-name",
"--no-onboard"
]
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(mam_sandbox))
assert res.returncode == 0
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
with open(yaml_path) as f:
data = yaml.safe_load(f)
session = [s for s in data.get("herdr_sessions", []) if s.get("name") == "my-custom-session-name"][0]
cid = session.get("claude_session_id_own")
# Simulate transcript materialization on disk
ws_key = os.path.realpath(ws).replace("/", "-").replace("_", "-")
c_dir = os.path.expanduser("~/.claude/projects")
t_dir = os.path.join(c_dir, ws_key)
os.makedirs(t_dir, exist_ok=True)
with open(os.path.join(t_dir, f"{cid}.jsonl"), "w") as f:
f.write(json.dumps({"type": "queue-operation", "sessionId": cid}) + "\n")
f.write(json.dumps({"type": "user", "sessionId": cid, "cwd": ws}) + "\n")
res2 = subprocess.run(["bash", str(reconcile_script), "--once"], capture_output=True, text=True, cwd=str(mam_sandbox))
assert res2.returncode == 0
with open(yaml_path) as f:
data = yaml.safe_load(f)
session = [s for s in data.get("herdr_sessions", []) if s.get("name") == "my-custom-session-name"][0]
assert session.get("session_id_verified") is True
def test_t6_viewport_no_path_degraded(mam_sandbox):
"""T-6: viewport without path returns 2 (degraded)"""
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
cmd = [
"bash", "-c",
f'source "{lib_path}" && _pane_capture() {{ echo "some random text without slash paths"; }} && _herdr() {{ return 0; }} && verify_tui_viewport "sess" "claude" "/tmp/myproj"'
]
res = subprocess.run(cmd, cwd=str(mam_sandbox))
assert res.returncode == 2
def test_t7_viewport_different_project_mismatch(mam_sandbox):
"""T-7: viewport showing DIFFERENT project returns 1 (mismatch)"""
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
cmd = [
"bash", "-c",
f'source "{lib_path}" && _pane_capture() {{ echo "Accessing workspace: /Users/godopu16/other_project"; }} && _herdr() {{ return 0; }} && verify_tui_viewport "sess" "claude" "/Users/godopu16/myproj"'
]
res = subprocess.run(cmd, cwd=str(mam_sandbox))
assert res.returncode == 1
def test_t8_resume_unmaterialized_assigned_id(mam_sandbox, mock_herdr, mock_agents):
"""T-8: unmaterialized assigned ID resume uses --session-id instead of -r"""
ws = str(mam_sandbox / "resume_unmat")
os.makedirs(ws, exist_ok=True)
create_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
stop_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
resume_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh"
# Create session
cmd = [
"bash", str(create_script),
"--workspace", ws,
"--agent", "claude",
"--role", "creator",
"--session", "test-t8-claude",
"--no-onboard"
]
subprocess.run(cmd, check=True, cwd=str(mam_sandbox))
# Stop session without sending any prompt
subprocess.run(["bash", str(stop_script), "--session", "test-t8-claude", "--agent", "claude"], check=True, cwd=str(mam_sandbox))
# Remove transcript if created by mock
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
with open(yaml_path) as f:
d = yaml.safe_load(f)
session = [s for s in d["herdr_sessions"] if s["name"] == "test-t8-claude"][0]
uuid_val = session["claude_session_id_own"]
ws_key = os.path.realpath(ws).replace("/", "-").replace("_", "-")
jsonl_path = os.path.expanduser(f"~/.claude/projects/{ws_key}/{uuid_val}.jsonl")
if os.path.exists(jsonl_path):
os.remove(jsonl_path)
# Run dry-run resume
res = subprocess.run([
"bash", str(resume_script),
"--workspace", ws,
"--agent", "claude",
"--session", "test-t8-claude",
"--dry-run"
], capture_output=True, text=True, cwd=str(mam_sandbox))
assert res.returncode == 0
assert "--session-id" in res.stdout
def test_t9_pane_capture_json_unwrap(mam_sandbox):
"""T-9: _pane_capture returns real text, unwrapping JSON envelope"""
raw_json = json.dumps({
"id": "cli:agent:read",
"result": {
"read": {
"format": "text",
"text": "Accessing workspace:\n/Users/godopu16/myproj"
}
}
})
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
cmd = [
"bash", "-c",
f'source "{lib_path}" && _sks_herdr() {{ echo \'{raw_json}\'; }} && _pane_capture "sess"'
]
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(mam_sandbox))
assert res.returncode == 0
assert "Accessing workspace:" in res.stdout
assert "{" not in res.stdout
def test_t10_resume_workspace_paths(mam_sandbox, mock_herdr, mock_agents):
"""T-10: resume with various workspace path forms (absolute, trailing slash, dotdot, relative, symlink)"""
real_ws = mam_sandbox / "wsprobe" / "real"
os.makedirs(real_ws, exist_ok=True)
link_ws = mam_sandbox / "wsprobe" / "link"
os.symlink(real_ws, link_ws)
create_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
stop_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
resume_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh"
ws_str = str(real_ws)
cmd = [
"bash", str(create_script),
"--workspace", ws_str,
"--agent", "claude",
"--role", "creator",
"--session", "test-t10-claude",
"--no-onboard"
]
subprocess.run(cmd, check=True, cwd=str(mam_sandbox))
subprocess.run(["bash", str(stop_script), "--session", "test-t10-claude", "--agent", "claude"], check=True, cwd=str(mam_sandbox))
# Test path variations
variations = [
ws_str,
ws_str + "/",
str(real_ws / ".." / "real"),
str(link_ws)
]
for v in variations:
res = subprocess.run([
"bash", str(resume_script),
"--workspace", v,
"--agent", "claude",
"--session", "test-t10-claude",
"--dry-run"
], capture_output=True, text=True, cwd=str(mam_sandbox))
assert res.returncode == 0, f"failed for variation: {v}"
assert f"would spawn:" in res.stdout
def test_t11_legacy_isolation_row(mam_sandbox, mock_herdr, mock_agents):
"""T-11: legacy isolation row resolved from isolation.root"""
iso_root = str(mam_sandbox / "iso_root")
ws = str(mam_sandbox / "iso_ws")
os.makedirs(ws, exist_ok=True)
ws_key = os.path.realpath(ws).replace("/", "-").replace("_", "-")
proj_dir = os.path.join(iso_root, "projects", ws_key)
os.makedirs(proj_dir, exist_ok=True)
u_val = str(uuid.uuid4())
with open(os.path.join(proj_dir, f"{u_val}.jsonl"), "w") as f:
f.write(json.dumps({"type": "queue-operation", "sessionId": u_val}) + "\n")
f.write(json.dumps({"type": "user", "sessionId": u_val, "cwd": ws}) + "\n")
mutation = f"""
entry = {{
'name': 'iso-session',
'status': 'stopped',
'role': 'creator',
'claude_session_id_own': '{u_val}',
'isolation': {{'root': '{iso_root}', 'uuid': 'iso-uuid-1'}},
'pane': {{'cwd': '{ws}'}}
}}
d.setdefault('herdr_sessions', []).append(entry)
"""
res_m = run_mutation(mam_sandbox, mutation)
assert res_m.returncode == 0, f"mutation failed: {res_m.stderr}"
resolve_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resolve_session_id.sh"
cmd = [
"bash", str(resolve_script),
"--workspace", ws,
"--agent", "claude",
"--session", "iso-session"
]
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(mam_sandbox))
assert res.returncode == 0
assert res.stdout.strip() == u_val
def test_t12_other_workspace_assigned_row_revalidate_fails(mam_sandbox):
"""T-12: assigned row belonging to another workspace does not pass revalidate"""
ws1 = str(mam_sandbox / "target_ws")
ws2 = str(mam_sandbox / "other_ws")
os.makedirs(ws1, exist_ok=True)
os.makedirs(ws2, exist_ok=True)
u_val = str(uuid.uuid4())
row_json = json.dumps({
"session_id_source": "assigned",
"session_id_verified": False,
"pane": {"cwd": ws2}
})
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
cmd = [
"bash", "-c",
f'source "{lib_path}" && verify_session_uuid "{ws1}" "claude" "{u_val}" \'{row_json}\' revalidate'
]
res = subprocess.run(cmd, cwd=str(mam_sandbox))
assert res.returncode != 0
def test_t13_mam_workspace_key_equivalence(mam_sandbox):
"""T-13: mam_workspace_key (shell) matches python workspace_key across absolute, trailing slash, dotdot, and symlink"""
real_ws = mam_sandbox / "ws_real"
os.makedirs(real_ws, exist_ok=True)
link_ws = mam_sandbox / "ws_link"
os.symlink(real_ws, link_ws)
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
variations = [
str(real_ws),
str(real_ws) + "/",
str(real_ws / ".." / "ws_real"),
str(link_ws)
]
for v in variations:
cmd = ["bash", "-c", f'source "{lib_path}" && mam_workspace_key "{v}"']
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(mam_sandbox))
sh_key = res.stdout.strip()
# Python realpath key
abs_path = os.path.realpath(v)
py_key = abs_path.replace("/", "-").replace("_", "-")
assert sh_key == py_key, f"Mismatch for {v}: shell={sh_key}, py={py_key}"