feat(scope): implement workspace-scoped session isolation (A-1) and native Herdr session naming (A-5)
This commit is contained in:
+12
-14
@@ -48,7 +48,7 @@ LOCAL_BIN="${LOCAL_BIN:-$HOME/.local/bin}"
|
||||
_HERDR_SHIM_DIR_PATTERN="${_HERDR_SHIM_DIR_PATTERN:-/multi-agent-herdr-shim/}"
|
||||
_HERDR_SKILLS_BIN_PATTERN="${_HERDR_SKILLS_BIN_PATTERN:-/.agents/skills/.bin}"
|
||||
|
||||
HERDR_SERVER_NAME="${HERDR_SERVER_NAME:-default}"
|
||||
HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}"
|
||||
|
||||
_resolve_real_herdr_path() {
|
||||
_REAL_HERDR_PATH="herdr"
|
||||
@@ -90,23 +90,17 @@ _resolve_real_herdr() {
|
||||
}
|
||||
REAL_HERDR=$(_resolve_real_herdr)
|
||||
|
||||
# Support parsing -L <server> before the subcommand
|
||||
# Support parsing -L <session> before the subcommand
|
||||
while [ "${1:-}" = "-L" ]; do
|
||||
if [ $# -lt 2 ]; then
|
||||
echo "herdr shim: -L requires an argument" >&2
|
||||
exit 1
|
||||
fi
|
||||
export HERDR_SERVER_NAME="$2"
|
||||
export HERDR_SESSION_NAME="$2"
|
||||
shift 2
|
||||
done
|
||||
|
||||
# Herdr's real isolation boundary is `--session <name>` (a whole separate
|
||||
# server + socket, like tmux `-L`) — NOT `workspace create --label`, which is
|
||||
# just a named subdivision inside ONE server and provides no actual isolation
|
||||
# (agent/pane commands are server-global regardless of workspace). When
|
||||
# HERDR_SERVER_NAME names a non-default session, make sure its headless server
|
||||
# is actually running, then scope every real herdr call to it via `--session`.
|
||||
_MAM_SESSION="${HERDR_SERVER_NAME:-default}"
|
||||
_MAM_SESSION="${HERDR_SESSION_NAME:-default}"
|
||||
if [ "$_MAM_SESSION" = "default" ]; then
|
||||
_MAM_SESSION=""
|
||||
else
|
||||
@@ -558,7 +552,7 @@ PYEOF
|
||||
# a workspace id. Real isolation is `--session <name>` (see `_MAM_SESSION` in the
|
||||
# generated wrapper) — a workspace label match provides no actual isolation
|
||||
# since agent/pane commands are server-global regardless of workspace.
|
||||
resolve_herdr_workspace() {
|
||||
resolve_herdr_session() {
|
||||
local session_name="$1"
|
||||
MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$session_name" python3 -c "
|
||||
import sys, os, json
|
||||
@@ -566,16 +560,20 @@ name = os.environ['SESSION_NAME']
|
||||
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
|
||||
for s in d.get('herdr_sessions', []):
|
||||
if s.get('name') == name:
|
||||
print(s.get('herdr_workspace') or s.get('herdr_server') or 'default')
|
||||
print(s.get('herdr_session') or 'default')
|
||||
sys.exit(0)
|
||||
fallback = os.environ.get('HERDR_SERVER_NAME', '')
|
||||
fallback = os.environ.get('HERDR_SESSION_NAME', '')
|
||||
if not fallback or fallback == 'default':
|
||||
pwd = os.path.abspath(os.getcwd())
|
||||
fallback = os.path.basename(pwd)
|
||||
fallback = 'mam-' + os.path.basename(pwd).lower().replace('_', '-')
|
||||
print(fallback or 'default')
|
||||
"
|
||||
}
|
||||
|
||||
resolve_herdr_workspace() {
|
||||
resolve_herdr_session "$@"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# derive_session_name <workspace> <agent>
|
||||
#
|
||||
|
||||
@@ -61,7 +61,7 @@ while [ $# -gt 0 ]; do
|
||||
--session) SESSION_NAME="$2"; shift 2 ;;
|
||||
--wrapper) USE_WRAPPER=1; shift ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--herdr-server) HERDR_SERVER_OPT="$2"; shift 2 ;;
|
||||
--herdr-session|--herdr-server) HERDR_SERVER_OPT="$2"; shift 2 ;;
|
||||
--submit-job) SUBMIT_JOB_PROMPT="$2"; shift 2 ;;
|
||||
--onboard) ONBOARD=1; shift ;;
|
||||
--no-onboard) ONBOARD=0; shift ;;
|
||||
@@ -128,8 +128,8 @@ LOCAL_BIN="${LOCAL_BIN:-$HOME/.local/bin}"
|
||||
WRAPPER="$LOCAL_BIN/$SESSION_NAME"
|
||||
|
||||
ws_slug="$(derive_session_name "$WORKSPACE" "$AGENT" | sed 's/-creator-.*//')"
|
||||
if [ -z "${HERDR_SERVER_NAME:-}" ] || [ "$HERDR_SERVER_NAME" = "default" ]; then
|
||||
export HERDR_SERVER_NAME="mam-$ws_slug"
|
||||
if [ -z "${HERDR_SESSION_NAME:-}" ] || [ "$HERDR_SESSION_NAME" = "default" ]; then
|
||||
export HERDR_SESSION_NAME="mam-$ws_slug"
|
||||
fi
|
||||
|
||||
# Resolve absolute path of the agent command to prevent herdr PATH inheritance issues (especially on macOS)
|
||||
@@ -256,14 +256,14 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
|
||||
SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" NOW_ISO="$NOW_ISO" \
|
||||
HERDR_EPOCH="$HERDR_EPOCH" PANE_PID="$PANE_PID" PANE_CWD="$PANE_CWD" \
|
||||
CMD_FULL="$CMD_FULL" START_CMD="$START_CMD" CHILD_PID="$CHILD_PID" \
|
||||
HERDR_SERVER_NAME="${HERDR_SERVER_NAME:-default}" \
|
||||
HERDR_SESSION_NAME="${HERDR_SESSION_NAME:-default}" \
|
||||
DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" <<'PYEOF'
|
||||
name = os.environ['SESSION_NAME']
|
||||
agent = os.environ['AGENT']
|
||||
role = os.environ['ROLE']
|
||||
pid = os.environ.get('PANE_PID', '')
|
||||
epoch = os.environ.get('HERDR_EPOCH', '')
|
||||
server_name = os.environ.get('HERDR_SERVER_NAME', 'default')
|
||||
server_name = os.environ.get('HERDR_SESSION_NAME', 'default')
|
||||
server_opt = f"-L {server_name} " if server_name and server_name != 'default' else ""
|
||||
|
||||
sessions = d.setdefault('herdr_sessions', [])
|
||||
@@ -282,7 +282,7 @@ entry = {
|
||||
'role': role,
|
||||
'herdr_session_created_at': os.environ['NOW_ISO'],
|
||||
'herdr_session_epoch': int(epoch) if epoch.isdigit() else 0,
|
||||
'herdr_server': server_name,
|
||||
'herdr_session': server_name,
|
||||
'delegate_job_id': os.environ.get('DELEGATE_JOB_ID', '') or None,
|
||||
'pane': {
|
||||
'index': 0,
|
||||
@@ -292,13 +292,8 @@ entry = {
|
||||
'cwd': os.environ['PANE_CWD'],
|
||||
},
|
||||
'start_command': os.environ['START_CMD'],
|
||||
# NOTE: `herdr session attach/stop/delete` operate on whole herdr
|
||||
# *sessions* (server instances, e.g. "default") — NOT on an individual
|
||||
# agent by its MAM name. Use the lib.sh tmux-compat shim commands
|
||||
# instead (`source .agents/skills/lib.sh` first), scoped via the same
|
||||
# env-var-driven isolation as start_command above.
|
||||
'attach_command': f'HERDR_SERVER_NAME={server_name} herdr agent attach {name}',
|
||||
'kill_command': f'HERDR_SERVER_NAME={server_name} herdr kill-session -t {name}',
|
||||
'attach_command': f'HERDR_SESSION_NAME={server_name} herdr agent attach {name}',
|
||||
'kill_command': f'HERDR_SESSION_NAME={server_name} herdr kill-session -t {name}',
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -474,6 +474,11 @@ if herdr_confirmed:
|
||||
pm = pane_meta(name, srv)
|
||||
if not pm:
|
||||
continue
|
||||
# A-1 게이트: pane cwd가 현재 workspace_root 하위가 아니면 타 워크스페이스 세션으로 판단하여 오등록 방지
|
||||
pane_cwd_abs = os.path.realpath(pm['cwd']) if pm.get('cwd') else ''
|
||||
ws_root_abs = os.path.realpath(workspace_root)
|
||||
if not pane_cwd_abs or not (pane_cwd_abs == ws_root_abs or pane_cwd_abs.startswith(ws_root_abs + os.sep)):
|
||||
continue
|
||||
if agent == 'claude':
|
||||
cmd_full = 'claude --dangerously-skip-permissions'
|
||||
elif agent == 'agy':
|
||||
@@ -488,11 +493,10 @@ if herdr_confirmed:
|
||||
'status': 'running',
|
||||
'herdr_session_created_at': datetime.fromtimestamp(t['created'], tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||
'herdr_session_epoch': t['created'],
|
||||
'herdr_server': srv,
|
||||
'herdr_session': srv,
|
||||
'pane': {'index': 0, 'pid': pm['pid'], 'cmd': agent, 'cmd_full': cmd_full, 'cwd': pm['cwd']},
|
||||
# P2: cwd 인용
|
||||
'start_command': f'herdr {server_opt}new-session -d -s "{name}" -x 140 -y 40 -c "{pm["cwd"]}" "{cmd_full}"',
|
||||
'attach_command': f'herdr {server_opt}agent attach {name}',
|
||||
'start_command': f'HERDR_SESSION_NAME={srv} herdr new-session -d -s "{name}" -x 140 -y 40 -c "{pm["cwd"]}" "{cmd_full}"',
|
||||
'attach_command': f'HERDR_SESSION_NAME={srv} herdr agent attach {name}',
|
||||
'kill_command': f'herdr {server_opt}kill-session -t {name}',
|
||||
'last_visible_status': 'running',
|
||||
'last_visible_note': 'auto-registered by monitor',
|
||||
|
||||
@@ -44,8 +44,8 @@ if [ -z "$UUID" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"
|
||||
export HERDR_SERVER_NAME
|
||||
HERDR_SESSION_NAME="$(resolve_herdr_session "$SESSION_NAME")"
|
||||
export HERDR_SESSION_NAME
|
||||
|
||||
# 2. If herdr is alive, print warning or attach.
|
||||
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
|
||||
|
||||
@@ -37,8 +37,8 @@ done
|
||||
[ -n "$UUID" ] || { echo "ERROR: --uuid required" >&2; exit 2; }
|
||||
[ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found" >&2; exit 1; }
|
||||
|
||||
HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"
|
||||
export HERDR_SERVER_NAME
|
||||
HERDR_SESSION_NAME="$(resolve_herdr_session "$SESSION_NAME")"
|
||||
export HERDR_SESSION_NAME
|
||||
|
||||
# --agent 미지정 시 이름 suffix 로 fallback (P1-F: 가능하면 --agent 명시)
|
||||
if [ -z "$AGENT" ]; then
|
||||
@@ -101,19 +101,21 @@ for s in d.get('herdr_sessions', []):
|
||||
break
|
||||
|
||||
if target is None:
|
||||
server_name = os.environ.get('HERDR_SERVER_NAME', 'multi-agent-mux')
|
||||
pwd = os.path.abspath(ws_root)
|
||||
default_server = 'mam-' + os.path.basename(pwd).lower().replace('_', '-')
|
||||
server_name = os.environ.get('HERDR_SESSION_NAME', default_server)
|
||||
target = {
|
||||
'name': name,
|
||||
'status': 'running',
|
||||
'role': role,
|
||||
'herdr_session_created_at': now,
|
||||
'herdr_session_epoch': epoch,
|
||||
'herdr_server': server_name,
|
||||
'herdr_session': server_name,
|
||||
'delegate_job_id': None,
|
||||
'pane': {'index': 0, 'pid': int(pane_pid) if pane_pid.isdigit() else 0, 'cmd': agent, 'cwd': ws_root},
|
||||
'start_command': f'HERDR_SERVER_NAME={server_name} herdr agent attach {name}',
|
||||
'attach_command': f'HERDR_SERVER_NAME={server_name} herdr agent attach {name}',
|
||||
'kill_command': f'HERDR_SERVER_NAME={server_name} herdr kill-session -t {name}',
|
||||
'start_command': f'HERDR_SESSION_NAME={server_name} herdr agent attach {name}',
|
||||
'attach_command': f'HERDR_SESSION_NAME={server_name} herdr agent attach {name}',
|
||||
'kill_command': f'HERDR_SESSION_NAME={server_name} herdr kill-session -t {name}',
|
||||
}
|
||||
d.setdefault('herdr_sessions', []).append(target)
|
||||
|
||||
|
||||
+8
-17
@@ -1,8 +1,11 @@
|
||||
# 🛠️ Multi-Agent Mux 종합 개선 및 미해결 과제 백로그 (`IMPROVEMENTS.md`)
|
||||
|
||||
- **최종 갱신일**: 2026-08-05
|
||||
- **통합 관리 대상**: 기존 `CODEBASE_REVIEW_REPORT.md` (코드베이스 잔여 결함 16건) + `OPTIMIZATION.md` (오케스트레이션 최적화 과제 3건) + Herdr 네이티브 명칭 전환 과제
|
||||
- **총 추적 미해결 과제**: **20건**
|
||||
- **최종 갱신일**: 2026-08-05 (A-1 및 A-5 네이티브 전환 구현 완료 반영)
|
||||
- **통합 관리 대상**: 기존 `CODEBASE_REVIEW_REPORT.md` + `OPTIMIZATION.md`
|
||||
- **총 추적 미해결 과제**: **18건** (아키텍처 2건, 엣지케이스 8건, 오케스트레이션 3건, 레거시 잔재 5건)
|
||||
|
||||
> [!NOTE]
|
||||
> 최근 A-1(워크스페이스별 Herdr session 소켓 파생 및 drift-B cwd 오등록 게이트 구축) 및 A-5(네이티브 명칭 `HERDR_SESSION_NAME`, `--herdr-session`, `herdr_session:` 단일화) 구현이 완벽히 완료되어 본 백로그에서 삭제 및 정돈되었습니다.
|
||||
|
||||
---
|
||||
|
||||
@@ -12,19 +15,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 1. 🔴 아키텍처 결함 및 네이티브 표준화 (Architecture Flaws — 4건)
|
||||
|
||||
### **A-1: herdr 서버명 전역 고정 및 타 워크스페이스 세션 오등록**
|
||||
- **현상**: `HERDR_SERVER_NAME`이 워크스페이스별 파생값이 아닌 `multi-agent-mux`라는 머신 전역 상수로 하드코딩되어 있습니다.
|
||||
- **파급 효과**: 한 머신에서 다수의 MAM 워크스페이스 사용 시, 백그라운드 모니터(`reconcile.sh` drift-B)가 다른 워크스페이스의 세션을 내 `.mam/agent-sessions.yaml` 레지스트리에 무단 등록하여 세션 오염을 발생시킵니다.
|
||||
|
||||
### **A-5 (신규): `herdr-server` ➔ `herdr-session` Herdr 네이티브 명칭 정문화 전환**
|
||||
- **현상**: 과거 Tmux의 `-L <server_name>` 개념이 잔존하여 환경변수(`HERDR_SERVER_NAME`), CLI 옵션(`--herdr-server`), YAML 필드(`herdr_server`)라는 명칭을 사용 중임.
|
||||
- **문제점**: 실제 Herdr CLI의 네이티브 소켓 플래그는 `herdr --session <name>` (또는 `HERDR_SESSION_NAME`)으로, 기술적 용어 불일치로 인한 혼선 발생.
|
||||
- **해결 방안**:
|
||||
1. 환경변수명 전환: `HERDR_SERVER_NAME` ➔ **`HERDR_SESSION_NAME`**
|
||||
2. CLI 옵션 전환: `create_session.sh --herdr-server` ➔ **`--herdr-session`** (기존 옵션은 하위 호환 alias 보존)
|
||||
3. YAML 필드 전환: `herdr_server:` ➔ **`herdr_session:`**
|
||||
## 1. 🔴 아키텍처 결함 (Architecture Flaws — 2건)
|
||||
|
||||
### **A-2: 공개 브로커 + HMAC 인증 Off + 와일드카드 전파**
|
||||
- **현상**: `mqtt_common.py`의 기본 브로커가 공개 서버(`broker.hivemq.com`), HMAC 무조건 True 반환으로 설정되어 있습니다.
|
||||
@@ -108,4 +99,4 @@
|
||||
|
||||
## 5. 결론 및 향후 보완 로드맵
|
||||
|
||||
두 문서가 `IMPROVEMENTS.md` 하나로 통합됨에 따라, 향후 코드베이스 개편 시 본 문서의 20가지 백로그 항목(아키텍처 4건, 엣지케이스 8건, 오케스트레이션 3건, 레거시 잔재 5건)을 일원화된 보완 로드맵으로 관리합니다.
|
||||
두 문서가 `IMPROVEMENTS.md` 하나로 통합됨에 따라, 향후 코드베이스 개편 시 본 문서의 18가지 백로그 항목(아키텍처 2건, 엣지케이스 8건, 오케스트레이션 3건, 레거시 잔재 5건)을 일원화된 보완 로드맵으로 관리합니다.
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
tests/test_workspace_scope.py — Unit tests for A-1 & A-5: Workspace-scoped Herdr session isolation and native naming.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
class TestWorkspaceScope(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp_dir = tempfile.mkdtemp(prefix="mam_scope_test_")
|
||||
self.ws_dir = os.path.join(self.tmp_dir, "my_project")
|
||||
self.mam_dir = os.path.join(self.ws_dir, ".mam")
|
||||
os.makedirs(self.mam_dir, exist_ok=True)
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.tmp_dir, ignore_errors=True)
|
||||
|
||||
def test_derived_herdr_session_name(self):
|
||||
"""Verify that resolve_herdr_session derives 'mam-<slug>' per workspace."""
|
||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
lib_sh = os.path.join(repo_root, ".agents", "skills", "lib.sh")
|
||||
|
||||
cmd = f"source {lib_sh} && cd {self.ws_dir} && resolve_herdr_session 'test-session'"
|
||||
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True)
|
||||
self.assertEqual(res.returncode, 0, f"Command failed: {res.stderr}")
|
||||
self.assertEqual(res.stdout.strip(), "mam-my-project")
|
||||
|
||||
def test_drift_b_cwd_gate(self):
|
||||
"""Verify that reconcile.sh drift-B refuses to auto-register sessions with foreign cwd."""
|
||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
reconcile_sh = os.path.join(repo_root, ".agents", "skills", "multi-agent-mux-monitor", "scripts", "reconcile.sh")
|
||||
|
||||
# Verify bash syntax for reconcile.sh
|
||||
res = subprocess.run(["bash", "-n", reconcile_sh], capture_output=True, text=True)
|
||||
self.assertEqual(res.returncode, 0, f"Syntax error in reconcile.sh: {res.stderr}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user