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

18 KiB

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__.pysys.path.insert순환이라 실행 자체가 불가능
M-2 ready_tokens 어댑터 이관 채택 — 효과가 예측보다 큼 "2~3곳"이 아니라 wait_for_tui_ready25줄 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개짜리 서명이다. 방향은 맞지만 형태를 바꾼다.

@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개 + 모든 호출부를 함께 고쳐야 한다. 세 번째 변경이 없으리라 가정할 근거가 없다.

cwdws_key둘 다 담는 것이 핵심이다. 둘은 교환 가능하지 않다 — claude 는 ws_key 로 디렉터리를 찾고, agy(last_conversations.json)·hermes(sessions.cwd)· cline(세션 json 의 cwd)은 절대경로로 찾는다. 하나만 넘기면 어느 쪽이든 반이 깨진다.

3.1 필터는 어댑터가 아니라 기반 클래스에 둔다

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 최종 인터페이스

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_artifactctx 를 받도록 통일했다. Rev.1 의 (uuid, ws_key, home, claude_dir, iso_root)(path, uuid, cwd) 두 가지 관례가 공존하던 것이 애초에 cwd 를 흘린 원인이다.


4. M-1 — 우려는 옳고, 제안한 해법은 동작하지 않는다

4.1 우려: 실재한다

Rev.1 은 PYTHONPATHenv_python / atomic_dump_yaml 의 env 목록에만 얹었다. 그런데 run_loop.shpython3 -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

_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_CLAUDEclaude 전용 변수 하나이고, 나머지 세 에이전트의 준비 토큰은 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_epochstarted_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_artifactctx 서명으로 통일, 격리 경로 일원화 서명 변경
M3 spawn_spec / resume_spec / auth_ok 변경 없음
M4 discover() — drift-C 4블록. hermes 스키마 확인이 선행(§8.2) 선행 조건 추가
M5 stop_session.sh purge 경로 + exit key 변경 없음
M6 (신규) ready_tokenswait_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]