Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
11 KiB
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, tree0f0f33cc22f5e492a0ddfccb6bac5be888c10d1a가 일치한다. 작업 트리는 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_artifactdiscover 모드(: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 에 없다. 판정 근거로 쓰지 않았다.
- 설치 스크립트가 시스템 python3 의 PyYAML 을 찾는데, 이 Mac 의 PyYAML 은 사용자 site(
- 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가 나오는지 확인하는 한 줄을 추가한다.
- 테스트의 claude·grok 행은 이미 id 가 있어서
- 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]