Files
multi-agent-mux/.agents/reports/planner-reviewer-claude-01/report-5bdb3a81.md
T

11 KiB

Issue #40 리뷰 — Class A 세션 ID 자동 확정, agy 다른 작업 공간 ID 오등록 방지 (job 5bdb3a81)

  • 리뷰어: planner-reviewer-claude-01
  • 검증 경로: /var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261020T133532Z-a1684a/1
  • identity: HEAD feccfd5e0356c8acf0438bc68bce7ea36ce2588e, tree 0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a 가 일치한다. 작업 트리는 clean 이고 .mam 은 없다.
  • 범위: base ff8b381..head 1커밋, 19개 파일. 판정은 base..head 전체에 대한 것이다.
  • 기준 문서: 이슈 본문과 댓글 전체(issue40.txt)를 끝까지 읽고 합의 사항과 대조했다.
  • 환경: macOS, Python 3.9.6.
    • 실제 에이전트 홈은 ls 와 lsof -p <agy pid>(프로세스 테이블 조회)로만 봤다. 파일 자체를 연 적은 없다.
    • 재현과 테스트는 모두 scratchpad 의 임시 HOME, mock herdr, git archive 사본에서 했다.

결론

합의된 설계(PR #41 리뷰·이슈 댓글)대로 구현됐고, 완료 조건 1-4 가 테스트로 고정됐다. 각 조건의 테스트는 base 에서 실패하고 head 에서 통과한다. 이 변경이 만든 회귀는 찾지 못했다. 남은 것은 테스트 보강 권고와 운영 위험 기록이다.

설계 확인 (합의 대비)

어댑터는 사실과 판정만 제공한다.

  • id_lifecycle: 기본값은 POST_DISCOVERED. claude·grok 만 PRE_ASSIGNED 이다(base.py:378-381, claude.py:188, grok.py:18).
  • capture_active_session_id(base.py:383-401): discover() 후보 가운데 verify_session_uuid 를 통과하고 다른 행이 기록하지 않은 것이 정확히 하나일 때만 captured 를 돌려준다. 그 밖에는 이유가 담긴 결과(capture_result)를 돌려준다. 읽기 전용이다.
  • 설정 추가나 리팩터링은 없다.

agy 경로 (agy.py)

  • verify_artifact discover 모드(:97-106): last_conversations.json 이 그 id 를 다른 cwd 에 매핑하면 거절한다(workspace_key 로 비교). 다른 행이 기록한 id 는 캐시가 일치해도 항상 거절한다. 이제 캐시 일치가 이 검사를 건너뛰지 못한다.
  • capture_active_session_id(:129-152): 후보는 last_conversations.json[cwd] 하나뿐이다. 그 대화의 presence lock 이나 DB 를 그 행의 pane 프로세스 또는 그 자손이 열고 있어야 확정한다(_owner_check, :154-183, lsof/ps). 증명하지 못하면 확정하지 않는다.
  • 이 근거를 실제로 확인했다. 이 Mac 에서 실행 중인 agy 3개(--conversation <id>)는 모두 자기 presence/<id>.lock 을 열고 있다(lsof -p).
  • _holder_pids·_process_parents 를 macOS 에서 직접 확인했다. 아무도 안 연 파일은 [], 연 파일은 보유 pid 를 돌려준다(0.13s). ps 표에는 자기 pid 가 들어 있다.

reconcile drift C (agy·hermes·opencode·codex) 가 하나로 합쳐졌다 (reconcile.sh:778-811).

  • 네 에이전트가 모두 autopin.capture 를 거쳐 같은 어댑터 판정을 쓴다. agy 의 *.db 전체 glob 은 사라졌다.
  • 기존 안전장치는 그대로다: 후보가 둘 이상이면 C-ambiguous, 뷰포트가 다르면(rc 1) C-warn 으로 pin 안 함, 뷰포트 확인이 불가하면(그 밖의 rc) degraded pin.
  • 다른 행이 가진 id 배제는 더 엄격해졌다. 예전에는 같은 cwd 의, 멈추지 않은, 같은 에이전트 행만 봤다. 지금은 다른 모든 행의 모든 own key 를 본다(autopin.claimed_elsewhere).
  • 지운 hermes·opencode·codex SQL 은 각 어댑터의 discover() 와 같은 범위(cwd·epoch·parent_id IS NULL)다. codex 는 CODEX_HOME 을 따른다(codex.py:65).

수신 측 처리기 (lib_py/agents/autopin.py)

  • 호출 지점:
    • 래퍼: finalize_unfinished 뒤 completed 일 때(multi-agent-mux-delegate-job:454-458). stderr 로만 출력하므로 마지막 stdout 줄(audit-log 경로) 계약은 그대로다.
    • create --onboard 의 completed 분기(create_session.sh:754-756)
    • 루프 wait_for_job 직후의 서브프로세스(runner.py:679, 690-705, G9 경계를 지킴)
  • 공통 헬퍼 delegate_autopin_completed(lib.sh:2470-2480)는 || true 라 잡 결과와 종료 코드를 바꾸지 않는다. set -u 에서 쓰는 변수(AGENT_SESSIONS_YAML, _disp_tok, DELEGATE_JOB_TOKEN, task_reg_dir)는 모두 정의돼 있다.
  • 중복 방지:
    • 키는 (job_id, dispatch_token, attempt_id) 이다. 잡 디렉터리의 autopin.lock(flock)과 autopin.json 마커를 쓴다. 마커는 원자 교체로 쓴다.
    • 쓰기는 atomic_dump_yaml 의 mutation 으로 한다. 이 모듈의 _MUTATION 을 sys.modules[__name__] 로 찾아 python -m 에서도 같은 객체를 쓴다. 쓸 것이 없으면 SystemExit(0) 으로 쓰지 않고 끝나며, atomic_yaml.py:195 의 finally 가 정리한다.
  • 건너뛰는 잡: remote, human, completed 가 아닌 잡, 오래된 token. pre-assigned 와 이미 id 가 있는 행은 쓰기 없이 끝난다.
  • 메타데이터: pin 은 session_id_source=discovered, session_id_verified=true 를 함께 기록한다. reconcile 의 _pin_and_verify_resume 도 같은 pin_row 를 쓴다. 이슈 댓글이 짚은 불일치(선택 항목)가 해결됐다.
  • publish_event.py 는 바뀌지 않았다. 에이전트는 제어 평면에 쓰지 않는다(test_call_sites_and_publisher_boundary).
  • 잠금 안에서 뷰포트 확인과 resume --dry-run 을 서브프로세스로 실행한다. 예전 reconcile 도 mutation 안에서 똑같이 했다. load_state_json 은 YAML flock 없이 SQLite 를 읽으므로 교착은 없다.

완료 조건별 테스트 (base 실패 / head 통과)

tests/test_issue40_auto_pin.py(27개)를 base 사본에 넣어 실행하면 16 failed, 13 errors(errors 는 base 에 없는 agy._holder_pids 를 패치하는 fixture 때문), 41 passed(issue64 포함) 이다. head 에서는 issue40+issue64 70 passed.

조건 테스트 base 결과
1 test_agy_verify_rejects_id_cached_for_another_workspace True is False
1 test_reconcile_does_not_pin_another_workspaces_agy_conversation 'b4000002…' is None — 실제로 B 의 id 를 A 행에 기록함
1 test_autopin_does_not_pin_… —
1 issue64 :407 단언 is False 로 바뀜
2 test_completed_onboarding_pins_agy_without_daemon(MAM_NO_SUBSCRIBER_DAEMON=1, B 가 더 늦게 갱신) —
2 test_completed_first_job_pins_opencode_codex_hermes[3] 실패
2 test_reconcile_pins_agy_owned_by_the_pane, test_lib_sh_helper_pins_through_the_cli, test_create_onboard_pins_without_daemon 실패
3 test_wrapper_and_loop_recovery_pin_once(capture 1회, YAML mtime 불변), test_loop_recovery_pins_when_the_wrapper_did_not 실패
3 test_ambiguous_candidates_leave_reason_without_pin(행에 autopin ambiguous (job amb): 2 candidates) 실패
4 test_preassigned_and_existing_ids_are_untouched, test_ineligible_jobs_pin_nothing, test_stale_dispatch_token_pins_nothing —
  • reconcile 테스트는 세션을 mock herdr state 에 running 으로 등록한다(_mock_agent). 그래서 drift A 에서 멈추지 않고 실제로 drift C 까지 간다. base 에서 B 의 id 가 기록되는 것이 그 증거다.

뮤테이션: git archive 사본에서 issue40 모듈만 돌렸다.

뮤테이션 결과
다른 cwd 거절 제거 1 failed
중복 마커 무시 2 failed
모호 판정 제거 1 failed
agy 소유 확인 제거 4 failed(같은 cwd 형제 교차 pin 포함)
이미 id 가 있는 행 보호 제거 1 failed
remote·human 제외 제거 2 failed
래퍼 호출 제거 1 failed(정적 검사만 잡음)
pre-assigned 보호 제거 실패 0 (아래 T1)

관련 테스트 묶음

  • 조건: env -i, 임시 HOME, XDG_DATA_HOME=<scratchpad>, basetemp 는 scratchpad.
  • 대상 18개 모듈: issue40, issue64, a4, issue10_13, issue31, issue50_dispatch, issue55, issue57, issue60_linux_fixes, issue60_opencode_v2, tier1, tier2, uuid_target, orc_onboard, issue21_phase2_loop_a, issue5_phase3_loop, issue21_worktree_prereqs, issue17_workspace_root_isolation.
  • 결과: 577 passed, 1 skipped, 3 failed, 18분.
  • 실패 3개(test_issue21_phase2_loop_a::test_w10_…[install.sh|install_mam.sh], test_w11_…)는 환경 탓이다.
    • 설치 스크립트가 시스템 python3 의 PyYAML 을 찾는데, 이 Mac 의 PyYAML 은 사용자 site(~/Library/Python/3.9/...)에만 있다. 내가 HOME 을 임시로 바꿔서 보이지 않았다.
    • base 사본에서도 같은 3개가 같은 이유로 실패하고, 설치 스크립트는 이 diff 에 없다. 판정 근거로 쓰지 않았다.
  • skip 1개: PATH 에 opencode 가 없는 실바이너리 계약 테스트.
  • 부작용 확인:
    • 실제 ~/.gemini/antigravity-cli/conversations·~/.hermes·~/.local/share/opencode 목록은 전후가 같았다.
    • scratchpad XDG 디렉터리는 실행 뒤에도 비어 있었다.
    • #55 가드 오탐은 없었다.
  • 린트:
    • git diff --check 는 깨끗하다.
    • 바뀐 셸 4개(lib.sh, create_session.sh, 래퍼, reconcile.sh)가 /bin/bash -n(3.2)을 통과한다.
    • 바뀐 Python 은 3.9 로 파싱된다. /usr/bin/python3 로 lib_py.agents.autopin·lib_py.loop.runner 를 import 할 수 있다.

비차단

  • T1. pre-assigned 보호(autopin.capture 의 id_lifecycle != POST_DISCOVERED)는 테스트로 고정되지 않았다.
    • 테스트의 claude·grok 행은 이미 id 가 있어서 already-set 에서 먼저 끝난다.
    • id 가 비어 있는 claude 행이 생기면(예: 행을 손으로 고침), 이 보호 없이는 기본 capture 가 claude discover() 로 남의 transcript 를 pin 할 수 있다.
    • 권고: claude_session_id_own: None 인 claude 행에서 pre-assigned 가 나오는지 확인하는 한 줄을 추가한다.
  • T2. 래퍼 진입점은 정적 검사(test_call_sites_…)로만 고정됐다. 헬퍼(lib.sh)와 CLI, create, 루프는 실행으로 검증됐다.
  • R1. agy 의 pin 은 이제 세 가지에 기댄다(reconcile drift C 도 같다):
    • (a) lsof·ps 가 있어야 한다
    • (b) 행에 pane pid 가 있어야 한다
    • (c) agy 가 presence lock 을 열고 있어야 한다
    • (c) 는 resume 된 agy 3개에서 확인했다. 새로 띄운 agy 의 첫 턴 직후는 실제 agy 를 띄울 수 없어 확인하지 못했다.
    • lsof 가 없는 Linux 에서는 agy 가 영영 pin 되지 않는다. 예전 glob 경로는 pin 했다(오등록 위험을 감수하고). autopin 경로는 행에 autopin unverifiable-owner … lsof is unavailable 을 남기지만, reconcile 은 아무것도 남기지 않는다.
    • 설계상 의도된 안전 쪽 선택이다. 그래도 Linux 의존성으로 문서에 한 줄 적어 두기를 권한다.
  • R2. 중복 마커는 실패 결과도 기록한다. 래퍼 시점에 no-candidate 였던 잡은 루프 복구 경로에서 다시 시도되지 않는다. 다음 잡의 completed 나 reconcile 이 다시 시도한다. 합의("job/token/attempt 단위로 중복 금지")와 맞다.
  • 범위 밖(보고만). stop 의 캡처 경로는 정리하지 않았다(이슈가 "크면 하지 말고 보고만"이라고 함).

[VERDICT: PASS]