Files
multi-agent-mux/.agents/reports/planner-reviewer-claude-01/plan-fae58b93.md
T
Godopu c3631e2aa1 fix(herdr): resolve shim routing defects, add workspace scoping, and bump to v3.0.1
- Resolve Herdr shim 5 routing & paste defects (ISSUE-1 ~ ISSUE-5):
  * paste-buffer: use pane send-text without auto-enter, propagate rc=3 to send_keys_safe
  * exact-match pane resolution: remove substring matching ('in tn') across all branches
  * workspace scoping: introduce HERDR_WORKSPACE_ID and .mam/herdr_workspace_id persistence
  * unified resolver: single _resolve_herdr_pane_id helper across shim commands
- Resolve dialog token false-positive on 'Yes, try it' tip and isolate fullscreen modal rejection
- Sync mock Herdr CLI contracts in tests/conftest.py
- Add contract tests H-15~H-23 and regression tests D-4~D-7 (412 tests, 100% PASS)
- Add multi-agent loop plans, review reports, and bug report
- Update framework and skill packages to v3.0.1
2026-08-27 21:42:37 +09:00

30 KiB
Raw Blame History

📐 구현 계획서: Herdr 셈(shim) 패인 라우팅 결함 4종 수정 (ISSUE-1/2/3/5)

  • Job ID: fae58b93
  • Role: Planner (planner-reviewer-claude-01)
  • 작성일: 2026-08-27
  • 대상: .agents/skills/lib.sh, tests/conftest.py, tests/test_herdr_shim_contract.py, tests/test_b19_headless_reconcile_fixes.py
  • 근거 문서: bug_report.md (v1.0)
  • 기준 커밋: 4bbd03b (main)

0. 요약 (TL;DR)

bug_report.md의 5대 결함 중 ISSUE-4는 이미 커밋 4bbd03b에서 해결되어 회귀 테스트(test_agent_start_success_tokens_exclude_startup_timeout)로 고정되어 있다. 남은 ISSUE-1 / 2 / 3 / 5를 다음 순서로 처리한다.

  1. ISSUE-5 선행 — 셈 내부에 공용 헬퍼 _resolve_herdr_pane_id를 신설한다. 나머지 3개 이슈의 수정이 전부 이 헬퍼 안으로 수렴하므로 이것이 반드시 먼저다.
  2. ISSUE-2 — 헬퍼 및 _resolve_herdr_target / has-session에서 agent in tn 부분 매칭을 전면 제거하고 엄격 일치로 대체.
  3. ISSUE-3HERDR_WORKSPACE_ID명시적으로 설정된 경우에만 pane list --workspace로 하드 스코핑.
  4. ISSUE-1paste-bufferpane send-text 단독 삽입으로 교체(엔터 금지), 해결 실패 시 조용히 삼키지 말고 실패를 상위로 전달.

1. 사전 조사에서 확인된 사실 (계획의 전제)

계획 수립 중 실제 herdr 바이너리(/opt/homebrew/bin/herdr)와 현재 lib.sh를 직접 검증했다. 버그 리포트의 권고 코드를 그대로 옮기면 안 되는 지점이 3곳 있다.

1.1 herdr agent send 서브커맨드는 존재하지 않는다 (ISSUE-1의 진짜 뿌리)

$ herdr agent --help
Commands: list  get  read  send-keys  prompt  rename  focus  wait  attach  start  explain

현재 lib.sh:822paste-buffer 구현은 다음 한 줄이 전부다.

_real_herdr agent send "$sess" "$(cat "$buffer_dir/$buf")" >/dev/null 2>&1 || true

agent send는 CLI에 없으므로 이 호출은 항상 실패하고 || true가 실패를 삼킨다. 즉 현재 main에서 paste-buffer 경로는 텍스트를 단 한 글자도 주입하지 못하는 완전한 데드 코드다. 이것이 브리프의 "paste-bufferherdr agent send 부재"가 가리키는 실체이며, send_keys_safe의 폴백 경로 전체가 무력화되어 있음을 뜻한다.

참고: send_keys_safeagent prompt 고속 경로가 성공하면 즉시 반환하므로(lib.sh:1785), 등록된 agent에 대해서는 이 결함이 드러나지 않는다. 결함이 표면화되는 조건은 정확히 버그 리포트가 기술한 상황 — agent prompt가 실패하는 라벨 전용 패인(agent 미등록) — 이다.

1.2 ⚠️ 버그 리포트의 pane_id 정규식은 실제 pane_id를 거부한다

버그 리포트 §3.1은 다음 검증을 제안한다.

if [[ "$pid" =~ ^w[0-9]+:p[0-9]+$ ]]; then

그러나 실제 서버가 반환하는 pane_id는 다음과 같다.

{"pane_id":"w1E:p1","workspace_id":"w1E","tab_id":"w1E:t1", ...}

워크스페이스 세그먼트는 w1E처럼 영문자를 포함한다. 권고 정규식을 그대로 쓰면 모든 실제 pane_id가 거부되어 헬퍼가 항상 실패하고, 결과적으로 ISSUE-1을 고친 뒤에도 주입이 되지 않는다.

채택 정규식: ^w[A-Za-z0-9]+:p[A-Za-z0-9]+$

1.3 ⚠️ 실제 pane list 응답에는 label 키가 없을 수 있다

{"agent":"claude","agent_status":"working","cwd":"...","pane_id":"w1E:p1",
 "tab_id":"w1E:t1","terminal_title":"...","workspace_id":"w1E"}

labelherdr pane rename <PANE_ID> <LABEL>로 설정했을 때만 나타난다. 반면 agent list에는 name 필드가 있다("name":"planner-reviewer-claude-01"). 따라서 헬퍼의 매칭 우선순위는 labelnameagent 순으로 두되, 셋 다 완전 일치만 허용한다. agent 필드는 사실상 CLI 종류(claude/grok)이므로 tn이 그 값과 완전히 같은 경우에만 매칭되며, 이는 부분 매칭과 달리 오라우팅을 만들지 않는다.

1.4 pane list는 서버측 --workspace 필터를 지원한다

$ herdr pane list --help
Options:
      --workspace <WORKSPACE_ID>

ISSUE-3의 스코핑은 파이썬 클라이언트 필터링만이 아니라 서버측 플래그로 1차 차단할 수 있다. 양쪽 모두 적용한다(플래그 미지원 구버전 herdr 대비 이중 방어).

1.5 pane readagent read의 출력 형식은 호환된다

둘 다 평문 텍스트를 반환하며, _pane_capture(lib.sh:1672)는 JSON 파싱 실패 시 원문을 그대로 반환하므로 capture-panepane read로 전환해도 상위 로직이 깨지지 않는다.

1.6 ⚠️ 셈은 set -euo pipefail 아래에서 실행된다

셈 본문은 lib.sh:141cat <<'EOF' ~ lib.sh:959EOF 사이 히어독으로 생성되며 3번째 줄이 set -euo pipefail이다. 따라서 실패를 반환할 수 있는 새 헬퍼는 모든 호출부에서 || true로 감싸야 하며, 그렇지 않으면 셈이 조기 종료된다.

1.7 테스트 목(mock)이 결함을 은폐하고 있다

tests/conftest.py:638의 목 herdr는 존재하지 않는 agent send성공으로 처리한다. 이 때문에 ISSUE-1이 테스트에서 전혀 드러나지 않았다. 목을 실제 CLI 계약에 맞추는 것이 이번 작업의 필수 선행 조건이다.


2. 변경 대상 목록

# 파일 위치 이슈 성격
C1 .agents/skills/lib.sh 셈 히어독, _sanitize_herdr_agent_name 직후 (~L236) 5 신규 헬퍼 _resolve_herdr_workspace_scope, _resolve_herdr_pane_id
C2 .agents/skills/lib.sh _resolve_herdr_target (L249286) 2,3 부분 매칭 제거 + ws 필터
C3 .agents/skills/lib.sh has-session (L305345) 2,3,5 부분 매칭 제거 + ws 필터 + 헬퍼 폴백
C4 .agents/skills/lib.sh new-session (L434530) 3 해결된 workspace_id를 HERDR_WORKSPACE_ID로 export
C5 .agents/skills/lib.sh kill-session (L567603) 5 인라인 파서 → 헬퍼
C6 .agents/skills/lib.sh capture-pane (L687704) 3,5 헬퍼 + pane read 경로
C7 .agents/skills/lib.sh send-keys (L705744) 2,3,5 인라인 파서 → 헬퍼
C8 .agents/skills/lib.sh paste-buffer (L794827) 1,5 agent sendpane send-text, 엔터 금지, 실패 전파
C9 .agents/skills/lib.sh send_keys_safe (L18041806) 1 paste-buffer 종료 코드 확인 → rc 3
C10 tests/conftest.py 목 herdr 1,2,3 pane send-text/pane read/pane rename 추가, pane list 병합·라벨·--workspace, agent send 제거
T1 tests/test_herdr_shim_contract.py 신규 1,2,3,5 H-15 ~ H-20
T2 tests/test_b19_headless_reconcile_fixes.py 신규 1,2,5 D-4 ~ D-7

list-panes(L605686)의 인라인 파서는 pane_id 외에 cwd/agent까지 한 번에 파싱하므로 헬퍼로 대체하지 않는다. ISSUE-5의 대상 목록에도 포함되어 있지 않다.


3. 상세 구현 설계

3.1 [C1] 공용 헬퍼 신설 (ISSUE-5)

lib.sh 셈 히어독 내부, _sanitize_herdr_agent_name 정의 직후(cmd="${1:-}" 앞)에 삽입한다. 이 위치여야 case 분기 전체에서 참조 가능하다.

# ---------------------------------------------------------------------------
# Workspace scoping (ISSUE-3).
#
# 스코핑은 HERDR_WORKSPACE_ID 가 "명시적으로" 설정된 경우에만 하드 필터로
# 동작한다. cwd 로부터 자동 추론하지 않는다 — 자동 추론은 다중 워크스페이스
# 오케스트레이션에서 정당한 교차 워크스페이스 조회를 조용히 막아버린다.
# 미설정 시에는 서버 전역 조회(기존 동작)를 유지한다.
# ---------------------------------------------------------------------------
_herdr_ws_scope() { printf '%s\n' "${HERDR_WORKSPACE_ID:-}"; }

# _resolve_herdr_pane_id <target> [workspace_id]
#
# 세션 이름 / 라벨을 실제 pane_id ("wN:pM") 로 해석한다.
# 엄격한 해석 순서 (부분 문자열 매칭은 어느 단계에서도 사용하지 않는다):
#   1. herdr agent get <sanitized_name>
#   2. herdr agent get <raw_name>
#   3. herdr pane list [--workspace WS] 에서
#        3-a. label 완전 일치
#        3-b. name 완전 일치
#        3-c. agent 완전 일치
# 성공 시 pane_id 를 stdout 에 출력하고 0, 실패 시 아무것도 출력하지 않고 1.
# 호출부는 반드시 `|| true` 로 감쌀 것 (셈은 set -e 하에서 동작한다).
_resolve_herdr_pane_id() {
  local target="$1"
  local target_ws="${2:-$(_herdr_ws_scope)}"
  local sat pid=""
  sat=$(_sanitize_herdr_agent_name "$target")

  local cand
  for cand in "$sat" "$target"; do
    [ -n "$cand" ] || continue
    pid=$(_real_herdr agent get "$cand" 2>/dev/null | TARGET_WS="$target_ws" python3 -c "
import sys, json, os
tws = os.environ.get('TARGET_WS', '')
try:
    a = json.load(sys.stdin).get('result', {}).get('agent', {})
    # ISSUE-3: 워크스페이스가 지정되면 다른 워크스페이스의 동명 agent 는 거부.
    if tws and a.get('workspace_id') and a.get('workspace_id') != tws:
        pass
    else:
        print(a.get('pane_id') or '')
except Exception:
    pass
" 2>/dev/null || echo "")
    [ -n "$pid" ] && break
  done

  if [ -z "$pid" ]; then
    local ws_flag=()
    [ -n "$target_ws" ] && ws_flag=(--workspace "$target_ws")
    pid=$(_real_herdr pane list "${ws_flag[@]+"${ws_flag[@]}"}" 2>/dev/null \
      | TARGET_NAME="$target" TARGET_SAN="$sat" TARGET_WS="$target_ws" python3 -c "
import sys, json, os
tn  = os.environ.get('TARGET_NAME', '')
tsa = os.environ.get('TARGET_SAN', '')
tws = os.environ.get('TARGET_WS', '')
try:
    panes = json.load(sys.stdin).get('result', {}).get('panes', [])
    # 서버가 --workspace 를 무시하는 구버전일 수 있으므로 클라이언트에서 한 번 더 거른다.
    if tws:
        panes = [p for p in panes if p.get('workspace_id') == tws]
    # ISSUE-2: 완전 일치만 허용. 'agent in tn' 부분 매칭은 사용하지 않는다.
    for key in ('label', 'name', 'agent'):
        for p in panes:
            v = p.get(key)
            if v and (v == tn or v == tsa):
                pid = p.get('pane_id') or ''
                if pid:
                    print(pid)
                    sys.exit(0)
except Exception:
    pass
sys.exit(1)
" 2>/dev/null || echo "")
  fi

  # 실제 pane_id 는 'w1E:p1' 처럼 워크스페이스 세그먼트에 영문자를 포함한다.
  # ^w[0-9]+:p[0-9]+$ 로 좁히면 모든 실제 pane_id 가 거부된다.
  if [[ "$pid" =~ ^w[A-Za-z0-9]+:p[A-Za-z0-9]+$ ]]; then
    printf '%s\n' "$pid"
    return 0
  fi
  return 1
}

설계 근거

  • for key in ('label','name','agent') 바깥 루프: 우선순위가 "패인 목록의 등장 순서"가 아니라 "필드의 신뢰도"로 결정된다. 안쪽/바깥쪽 루프를 뒤집으면 목록 첫 항목의 agent 매칭이 뒤쪽 항목의 정확한 label 매칭을 이겨버린다 — 이것이 ISSUE-2가 만든 오라우팅과 동일한 형태의 버그다.
  • tsa(sanitized) 도 비교 대상에 포함: agent start가 이름을 sanitize해서 등록하므로, 라벨은 원본이고 등록명은 sanitize본인 혼재 상황을 커버한다. sanitize는 결정적 함수이므로 부분 매칭과 달리 충돌을 만들지 않는다.
  • ws_flag 배열 + ${ws_flag[@]+...}: set -u 하에서 빈 배열 전개가 unbound 오류를 내지 않도록 하는 표준 관용구.

3.2 [C2] _resolve_herdr_target 엄격화 (ISSUE-2, ISSUE-3)

lib.sh:262275의 파이썬 블록에서 다음 술어를 제거한다.

if name == tn or (not name and agent and agent in tn):   # ← 제거

교체:

tn  = os.environ.get("TARGET_NAME", "")
tsa = os.environ.get("TARGET_SAN", "")
tws = os.environ.get("TARGET_WS", "")
...
agents = d.get("result", {}).get("agents", [])
if tws:
    agents = [a for a in agents if a.get("workspace_id") == tws]
for a in agents:
    name = a.get("name", "")
    if name and (name == tn or name == tsa):
        print(a.get("pane_id") or name)
        sys.exit(0)
sys.exit(1)

pane_id or agentpane_id or name으로 바꾼다. 기존 코드는 매칭에 실패한 항목의 CLI 종류(agent, 예: "claude")를 타깃으로 반환할 수 있었는데, 이는 agent prompt claude ...처럼 전혀 다른 대상에게 프롬프트를 던지는 경로다.

3.3 [C3] has-session 엄격화 + 패인 폴백 (ISSUE-2, ISSUE-3, ISSUE-5)

lib.sh:334의 다음 술어를 제거한다.

or (not an and a.get("agent") and a.get("agent") in tn)   # ← 제거

남는 조건은 an == tn or an == stn이며, 여기에 HERDR_WORKSPACE_ID 필터를 추가한다.

그리고 agent 조회가 모두 실패했을 때 마지막 단계로 헬퍼를 호출한다.

if [ -n "$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)" ]; then
  exit 0
fi
exit 1

의도적 동작 변경: 라벨만 붙은(agent 미등록) 패인도 이제 "세션 존재"로 판정된다. 이것이 정확히 버그 리포트가 보고한 실패 시나리오(label: reviewer-cline-01 패인에 주입 불가)의 해소 조건이다. _resolve_herdr_pane_id가 완전 일치만 허용하므로, 세션 이름 reviewer-creator-grok-01agent == "grok" 패인에 매칭될 일은 없다.

리스크: create_session.sh / reconcile.shhas-session 결과로 재생성 여부를 판단한다면, 라벨만 있고 실제 CLI가 죽은 패인을 "살아 있음"으로 오판할 수 있다. → 3.9의 회귀 검증 범위에 test_orc_onboard.py, test_tier3_integration.py, test_tier4_e2e.py를 명시적으로 포함한다.

3.4 [C4] HERDR_WORKSPACE_ID 전파 (ISSUE-3)

new-session 분기에서 existing_ws 또는 신규 ws_id가 확정된 직후(lib.sh:509 이후 ws_id 확정 지점) 다음을 추가한다.

if [ -n "${ws_id:-}" ]; then
  export HERDR_WORKSPACE_ID="$ws_id"
fi

또한 agent start에 전달하는 env_flags--env HERDR_WORKSPACE_ID=$ws_id를 추가하여, 기동된 에이전트 프로세스가 상속한 셈 호출부터 자동으로 스코프가 걸리도록 한다.

채택하지 않은 대안: 셈이 $PWD/$WORKSPACE_ROOT의 cwd로부터 workspace_id를 자동 추론하는 방식. 추론이 성공하는 순간 교차 워크스페이스 조회가 조용히 막히고, 오케스트레이터가 다른 워크스페이스의 에이전트를 정당하게 다루는 경로가 원인 불명으로 깨진다. 스코핑은 명시적 옵트인이어야 진단 가능하다.

3.5 [C5]~[C7] 분기 리팩터링 (ISSUE-5)

kill-sessionlib.sh:587598의 이중 인라인 파이썬을 삭제.

agent_target=$(_sanitize_herdr_agent_name "$sess")
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
if [ -n "$pane_id" ]; then
  _real_herdr pane close "$pane_id" >/dev/null 2>&1 || true
fi
_real_herdr kill-session -t "$agent_target" >/dev/null 2>&1 \
  || _real_herdr kill-session -t "$sess" >/dev/null 2>&1 || true

capture-pane — 헬퍼로 pane_id를 얻으면 pane read, 아니면 기존 agent read 체인 유지.

agent_target=$(_sanitize_herdr_agent_name "$sess")
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
if [ -n "$pane_id" ]; then
  _real_herdr pane read "$pane_id" --source visible --lines 100 2>/dev/null || true
else
  _real_herdr agent read "$agent_target" --source visible --lines 100 2>/dev/null \
    || _real_herdr agent read "$sess" --source visible --lines 100 2>/dev/null || true
fi

send-keyslib.sh:726738의 이중 인라인 파이썬을 삭제하고 헬퍼 호출로 대체. 폴백(pane send-keys "$agent_target""$sess")은 그대로 유지한다. C-mEnter 정규화(L741–743)도 유지 — 이건 키 이름 번역이지 제출 정책이 아니다.

ISSUE-5의 "데드 파이프라인" 부분: 기존 인라인 파서는 except: pass로 항상 exit 0을 반환해 || 2차 폴백이 절대 실행되지 않았다. 신규 헬퍼는 sys.exit(1) + 정규식 검증 + return 1로 실패를 정확히 신호하므로 이 데드 코드가 구조적으로 제거된다.

3.6 [C8] paste-buffer 재작성 (ISSUE-1)

buffer_dir="${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/buffers}"
buffer_dir="${buffer_dir:-${TMPDIR:-/tmp}/mam_buffers}"
if [ ! -f "$buffer_dir/$buf" ]; then
  echo "Error: buffer $buf not found ($buffer_dir/$buf)" >&2
  exit 1
fi
pane_id=$(_resolve_herdr_pane_id "$sess" 2>/dev/null || true)
if [ -z "$pane_id" ]; then
  # herdr 에는 `agent send` 서브커맨드가 없다. 여기서 조용히 성공을 반환하면
  # send_keys_safe 가 아무것도 붙여넣지 않은 채 Enter 만 치게 된다.
  echo "Error: paste-buffer could not resolve a pane for '$sess'" >&2
  exit 1
fi
# 삽입 전용. Enter/C-m 제출은 전적으로 send_keys_safe 가 통제한다 (ISSUE-1).
# 여기서 `pane run` 을 쓰면 안 된다 — 텍스트와 Enter 를 한 번에 보내 이중 제출이 된다.
if ! _real_herdr pane send-text "$pane_id" "$(cat "$buffer_dir/$buf")" >/dev/null 2>&1; then
  echo "Error: pane send-text failed for '$sess' ($pane_id)" >&2
  exit 1
fi

불변식 (테스트로 고정): paste-buffer 분기 본문에는 Enter, C-m, pane run, agent prompt 중 어떤 것도 등장하지 않는다.

3.7 [C9] send_keys_safe의 붙여넣기 실패 전파 (ISSUE-1)

현재 lib.sh:18041806paste-buffer의 종료 코드를 버린다. 그리고 세션 이름에 cline|claude|agy|grok이 포함되면 붙여넣기 가시성 검증마저 건너뛴다(L1809–1812) — 즉 실제 운영 대상 전부에서 실패가 무성으로 삼켜진다.

_sks_herdr set-buffer -b "$sks_buf" "$text"
local _paste_rc=0
_sks_herdr paste-buffer -b "$sks_buf" -t "$sess" || _paste_rc=$?
_sks_herdr delete-buffer -b "$sks_buf" 2>/dev/null || true
if [ "$_paste_rc" != "0" ]; then
  echo "send_keys_safe: paste-buffer failed rc=$_paste_rc ($sess)" >&2
  return 3
fi

버퍼 정리(delete-buffer)는 조기 반환 에 둔다. 그렇지 않으면 실패 경로마다 버퍼가 누수되어 set-buffer의 A-3 GC 주석이 방어하는 바로 그 문제가 재발한다.

기존 반환 코드 계약(3 = paste not visible)을 재사용하므로 호출자 계약은 바뀌지 않는다.

3.8 [C10] 테스트 목(mock) 정합화 — tests/conftest.py

테스트 코드보다 먼저 처리해야 한다. 목이 실제 CLI와 어긋나 있는 한 어떤 테스트도 결함을 재현할 수 없다.

변경 위치 내용
M1 cmd1 == "agent", cmd2 == "send" (L638664) 핸들러 삭제 → 실제 CLI처럼 unknown subcommand로 exit 1. ISSUE-1 재현의 필수 조건
M2 cmd1 == "pane" send-text 핸들러 추가: pane_id로 대상 조회, sent_text 누적, buffer 갱신, sent_keys건드리지 않음
M3 cmd1 == "pane" read 핸들러 추가: 대상 패인의 buffer 평문 출력
M4 cmd1 == "pane" rename 핸들러 추가: state["panes"]의 해당 항목에 label 기록
M5 pane list (L253281) 현재는 agents가 하나라도 있으면 state["panes"]무시한다. → agent 유래 패인과 state["panes"]pane_id 기준으로 병합(dedupe)하고, label/name 필드를 그대로 실어 보낸다. 라벨 전용 패인 시나리오가 이 변경 없이는 표현 불가
M6 pane list --workspace 필터는 이미 구현되어 있음(L255–261). 유지
M7 agent get (L569) 응답에 workspace_id가 이미 포함됨(L594). 유지

목의 _match_agent(L182)는 이미 엄격(완전 일치 / sanitize 일치)하므로 변경 불필요하다.


4. 테스트 계획

4.1 tests/test_herdr_shim_contract.py — 행위 테스트 (신규 H-15 ~ H-20)

기존 파일의 규약을 따른다: mam_sandbox / mock_herdr / mock_agents 픽스처로 셈을 실제 실행하고, mock_herdr_state.jsoncalls 배열을 검증한다.

H-15 test_h15_paste_buffer_inserts_without_enter (ISSUE-1)

  • 준비: mock_agentstest-creator-claude 기동.
  • 실행: herdr set-buffer -b t1 "hello world"herdr paste-buffer -b t1 -t test-creator-claude.
  • 단언:
    • calls["pane","send-text",<pane_id>,"hello world"]가 정확히 1회.
    • calls["agent","send",...]0회 (M1로 이제 실패하게 되므로 회귀 감지).
    • paste-buffer 실행으로 발생한 callspane send-keys / agent prompt / pane run0회 ← 이중 제출 방지의 핵심 단언.

H-16 test_h16_send_keys_safe_submits_exactly_once (ISSUE-1 종단)

  • agent prompt 고속 경로를 강제로 실패시켜(존재하지 않는 세션명 또는 목의 prompt 실패 주입) 폴백 경로를 타게 한다.
  • 단언: Enter/C-m 키 전송 횟수 총합이 정확히 1. (현재 코드는 paste-buffer 자체가 죽어 0회, 버그 리포트가 기술한 패치 상태에서는 2회 — 양쪽 모두 이 테스트가 잡는다.)

H-17 test_h17_no_substring_cross_pane_routing (ISSUE-2) — 핵심 회귀 테스트

  • 준비: reviewer-creator-grok-01, worker-grok-02 두 agent를 서로 다른 pane_id로 기동.
  • 실행: herdr send-keys -t reviewer-creator-grok-01 C-m.
  • 단언: pane send-keys의 대상 pane_id가 reviewer-creator-grok-01의 것과 일치. worker-grok-02의 pane_id로 간 호출은 0회.
  • 추가: agent 등록 없이 agent: "grok" 라벨 전용 패인만 두고 herdr has-session -t reviewer-creator-grok-01exit 1이어야 한다(예전 부분 매칭이면 0).

H-18 test_h18_workspace_scoped_pane_resolution (ISSUE-3)

  • 준비: state["panes"]에 동일 label: creator-agy-01workspace_id: w1, w2에 각각 1개씩 시드.
  • 실행 A: HERDR_WORKSPACE_ID=w2 herdr send-keys -t creator-agy-01 Enter → 대상이 w2의 pane_id.
  • 실행 B: HERDR_WORKSPACE_ID=w1 → 대상이 w1의 pane_id.
  • 실행 C: HERDR_WORKSPACE_ID 미설정 → 해석은 성공하되 실패하지 않음(기존 전역 동작 보존).

H-19 test_h19_single_resolver_helper_used_by_all_branches (ISSUE-5)

  • 생성된 셈 파일($WORKSPACE_ROOT/.mam/shim/herdr)을 읽어:
    • _resolve_herdr_pane_id() 정의가 정확히 1회 등장.
    • has-session / kill-session / capture-pane / send-keys / paste-buffer 각 분기 본문에서 _resolve_herdr_pane_id 호출이 등장.
    • result', {}).get('agent', {}).get('pane_id' 형태의 인라인 파서 잔존 개수가 헬퍼 내부 1곳으로 한정.
  • bash -n으로 셈 구문 검증.

H-20 test_h20_pane_id_regex_accepts_alphanumeric_workspace (§1.2 회귀 방지)

  • 헬퍼를 직접 호출해 w1E:p1, w10:p3 형태가 통과하고 notapane, w1:p, 빈 문자열이 거부되는지 확인.
  • 이 테스트가 없으면 버그 리포트 원문의 ^w[0-9]+:p[0-9]+$가 나중에 다시 들어와도 아무도 모른다.

4.2 tests/test_b19_headless_reconcile_fixes.py — 소스/헬퍼 단위 테스트 (신규 D-4 ~ D-7)

기존 _run_lib_helpers() 헬퍼(L119–130)와 소스 문자열 검사 패턴을 재사용한다.

D-4 test_resolve_pane_id_fails_cleanly_under_set_e

  • set -euo pipefail 아래에서 _resolve_herdr_pane_id nonexistent || true가 셸을 죽이지 않고 빈 출력 + rc 1을 내는지.

D-5 test_send_keys_safe_returns_3_when_paste_buffer_fails (ISSUE-1)

  • _sks_herdr 스텁: agent prompt → rc 1, paste-buffer → rc 1, send-keys 호출은 파일에 기록.
  • 단언: send_keys_safe rc == 3, 기록 파일에 C-m 없음.
  • 추가 단언: delete-buffer가 호출되었음(버퍼 누수 방지).

D-6 test_no_substring_matching_remains_in_lib_sh (ISSUE-2) — 소스 가드

  • lib.sh 전문에서 정규식 \bin tn\bagent"\) in tn 패턴 매치가 0건.
  • _resolve_herdr_pane_id 본문에 ^w[A-Za-z0-9]+:p[A-Za-z0-9]+$가 존재.

D-7 test_paste_buffer_branch_never_submits (ISSUE-1) — 소스 가드

  • lib.sh에서 paste-buffer) ~ 다음 ;; 구간을 잘라내어 Enter, C-m, pane run, agent prompt 문자열이 없음을 단언.
  • 행위 테스트(H-15)와 중복처럼 보이지만 층이 다르다: H-15는 목 경유라 목이 잘못되면 함께 침묵하고, D-7은 소스를 직접 본다.

4.3 회귀 범위

has-session 의미 변경(3.3)과 capture-pane 경로 변경(3.5)이 넓게 파급되므로, 다음을 우선 확인한 뒤 전체를 돌린다.

.venv/bin/python -m pytest tests/test_herdr_shim_contract.py \
  tests/test_b19_headless_reconcile_fixes.py \
  tests/test_b8_send_keys_verification.py \
  tests/test_orc_onboard.py tests/test_workspace_scope.py \
  tests/test_uuid_target.py tests/test_sanitize_and_mock_errors.py -q

이후 전체:

.venv/bin/python -m pytest -q

기준선 (실측): 작업 착수 시점(4bbd03b)에 test_herdr_shim_contract.py + test_b19_headless_reconcile_fixes.py + test_workspace_scope.py = 17 passed / 9.2s.

전체 스위트(pytest -q) = 397 passed / 502.00s (8분 21초). tier3/tier4 e2e가 herdr 목 프로세스를 다수 포크하는 것이 소요 시간의 대부분이다. 구현자는 다음을 전제로 시간을 배분할 것:

  • 반복 개발 루프에서는 4.3의 우선 범위만 사용한다(약 10초).
  • 전체 회귀는 S7에서 1회만, 백그라운드로 돌린다(약 8~9분).
  • 완료 기준은 397 + 신규 10건 = 407 passed이다. 이보다 적으면 기존 테스트가 사라졌거나 무성 skip된 것이므로 반드시 원인을 규명할 것.
  • pytest-timeout은 이 저장소에 설치되어 있지 않다 — --timeout= 플래그는 unrecognized arguments로 즉시 실패한다. 필요하면 requirements-dev.txt에 추가하거나 셸 레벨에서 제어할 것.

5. 실행 순서 (권장 커밋 단위)

단계 내용 검증
S1 [C10] conftest.py 목 정합화 (M1~M5) 기존 스위트 실행 → 여기서 깨지는 테스트가 곧 은폐되어 있던 결함의 목록. 목록을 기록한다
S2 [C1] _resolve_herdr_pane_id / _herdr_ws_scope 신설 (호출부 변경 없음) bash -n, H-20, D-4
S3 [C2][C3] 부분 매칭 제거 (ISSUE-2) H-17, D-6
S4 [C5][C6][C7] 분기 리팩터링 (ISSUE-5) H-19, 4.3 우선 범위
S5 [C8][C9] paste-buffer 재작성 + 실패 전파 (ISSUE-1) H-15, H-16, D-5, D-7
S6 [C4] HERDR_WORKSPACE_ID 전파 (ISSUE-3) H-18
S7 전체 회귀 pytest -q 전량 그린

S2를 S3~S6보다 먼저 두는 이유: 헬퍼만 추가하고 아무도 호출하지 않는 상태는 정의상 무해하므로, 이 시점에 스위트가 깨지면 원인이 히어독 구문 오류 하나로 좁혀진다.


6. 리스크 및 완화

# 리스크 영향 완화
R1 셈은 lib.sh 내부 히어독이라 편집 시 $, 백틱, 따옴표 이스케이프 사고가 나기 쉽다 셈 전체가 구문 오류로 죽어 모든 herdr 호출 실패 <<'EOF'(따옴표 히어독)이므로 셸 확장은 일어나지 않음. 각 단계마다 _init_herdr_isolation 실행 후 생성물에 bash -n
R2 has-session이 라벨 전용 패인을 "존재"로 판정 (3.3) create_session.sh가 죽은 패인을 재사용해 세션 재생성 실패 4.3 우선 회귀 범위에 test_orc_onboard.py 포함. 문제 시 라벨 폴백을 MAM_HAS_SESSION_PANE_FALLBACK=1 옵트인으로 격하
R3 capture-paneagent readpane read로 전환 출력 포맷 차이로 _pane_quiescent / 준비 토큰 매칭 실패 §1.5에서 실기 검증 완료(양쪽 평문). test_b8_send_keys_verification.py로 회귀 확인
R4 HERDR_WORKSPACE_ID 하드 필터가 정당한 교차 워크스페이스 조회를 차단 다중 워크스페이스 오케스트레이션 기능 상실 자동 추론을 채택하지 않음(3.4). 미설정 = 기존 전역 동작. H-18 실행 C가 이를 고정
R5 구버전 herdr가 pane list --workspace를 모름 플래그 오류로 조회 실패 클라이언트측 workspace_id 필터를 이중으로 유지(3.1). `2>/dev/null
R6 paste-buffer 실패 전파(3.7)로 이전엔 "성공"이던 경로가 rc 3을 반환 상위 오케스트레이터가 새로 실패를 보게 됨 이는 의도된 결과다 — 기존 "성공"은 텍스트가 전달되지 않은 무성 실패였다. 다만 배포 노트에 명시

7. 완료 기준 (Definition of Done)

  1. .agents/skills/lib.sh_resolve_herdr_pane_id정확히 1회 정의되고, has-session / kill-session / capture-pane / send-keys / paste-buffer 5개 분기가 모두 이를 호출한다.
  2. lib.sh 전문에 agent ... in tn 형태의 부분 문자열 매칭이 0건이다.
  3. paste-buffer 분기가 pane send-text만 사용하고 Enter / C-m / pane run / agent prompt를 사용하지 않는다.
  4. HERDR_WORKSPACE_ID가 설정되면 패인 해석이 해당 워크스페이스로 제한되고, 미설정 시 기존 전역 동작이 보존된다.
  5. tests/test_herdr_shim_contract.py에 H-15 ~ H-20, tests/test_b19_headless_reconcile_fixes.py에 D-4 ~ D-7이 추가되고 전부 통과한다.
  6. .venv/bin/python -m pytest -q407 passed(기준선 397 + 신규 10)로 전량 그린. 실패가 남으면 원인과 함께 명시 보고(무성 skip 금지).
  7. bash -nlib.sh 및 생성된 .mam/shim/herdr 양쪽에서 통과한다.

8. 계획 범위 밖으로 남기는 항목

  • ISSUE-4 — 커밋 4bbd03b에서 이미 수정 완료. test_agent_start_success_tokens_exclude_startup_timeout이 회귀를 고정하고 있어 추가 작업 없음.
  • list-panes 분기 — 인라인 파서를 유지한다. pane_id 단독이 아니라 cwd/agent를 함께 파싱하므로 _resolve_herdr_pane_id로 대체 불가이며, ISSUE-5의 대상 목록에도 없다.
  • bug_report.md의 업스트림 반영 — 본 작업은 이 저장소의 lib.sh에 한정한다. multi-agent-mux 업스트림 배포는 별도 릴리스 절차(VERSIONS.md, deploy/)를 따른다.