feat(cli,registry): introduce --herdr-workspace option and decouple socket fallback chains

This commit is contained in:
2026-08-24 12:50:09 +09:00
parent 320f036575
commit e6e70dbb21
17 changed files with 1670 additions and 47 deletions
+1 -1
View File
@@ -112,7 +112,7 @@ if os.path.exists(state_file):
time.sleep(0.02)
# Record the command call
state["calls"].append(sys.argv[1:])
state.setdefault("calls", []).append(sys.argv[1:])
try:
with open(state_file + ".trace", "a") as tf:
tf.write(f"PID {os.getpid()} ARGS: {sys.argv[1:]}\\n")
+87 -5
View File
@@ -78,21 +78,103 @@ def test_create_validate_env_key(mam_sandbox):
# ==============================================================================
def test_resume_resolve_herdr_session_default(mam_sandbox):
"""Test resolve_herdr_workspace fallback behavior when session is not in YAML."""
res = run_lib_func(mam_sandbox, "resolve_herdr_workspace", "non-existent-session")
"""Test resolve_herdr_session fallback behavior when session is not in YAML."""
res = run_lib_func(mam_sandbox, "resolve_herdr_session", "non-existent-session")
assert res.returncode == 0
assert res.stdout.strip() != ""
def test_resume_resolve_herdr_session_env(mam_sandbox):
"""Test resolve_herdr_workspace fallback to HERDR_SESSION_NAME or HERDR_SERVER_NAME env var."""
res = run_lib_func(mam_sandbox, "resolve_herdr_workspace", "non-existent-session", env={"HERDR_SESSION_NAME": "custom_session"})
"""Test resolve_herdr_session fallback to HERDR_SESSION_NAME or HERDR_SERVER_NAME env var."""
res = run_lib_func(mam_sandbox, "resolve_herdr_session", "non-existent-session", env={"HERDR_SESSION_NAME": "custom_session"})
assert res.returncode == 0
assert res.stdout.strip() == "custom_session"
res_legacy = run_lib_func(mam_sandbox, "resolve_herdr_workspace", "non-existent-session", env={"HERDR_SERVER_NAME": "custom_server"})
res_legacy = run_lib_func(mam_sandbox, "resolve_herdr_session", "non-existent-session", env={"HERDR_SERVER_NAME": "custom_server"})
assert res_legacy.returncode == 0
assert res_legacy.stdout.strip() == "custom_server"
def test_resolvers_are_decoupled(mam_sandbox):
"""소켓과 워크스페이스 라벨이 다른 행에서 두 함수가 서로 다른 값을 낸다."""
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
yaml_path.write_text("""herdr_sessions:
- name: d-creator-claude
status: running
herdr_session: socket-A
herdr_server: socket-A
herdr_workspace: label-B
pane:
cwd: /tmp
""")
s = run_lib_func(mam_sandbox, "resolve_herdr_session", "d-creator-claude")
w = run_lib_func(mam_sandbox, "resolve_herdr_workspace", "d-creator-claude")
assert s.stdout.strip() == "socket-A"
assert w.stdout.strip() == "label-B"
def test_workspace_label_never_resolves_as_socket(mam_sandbox):
"""B-22: herdr_session 이 없는 행에서도 herdr_workspace 는 소켓 이름이 되지 않는다."""
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
yaml_path.write_text("""herdr_sessions:
- name: legacy-creator-claude
status: running
herdr_workspace: my-label
pane:
cwd: /tmp
""")
s = run_lib_func(mam_sandbox, "resolve_herdr_session", "legacy-creator-claude")
assert s.stdout.strip() != "my-label"
def test_socket_resolver_fallback_chain(mam_sandbox):
"""herdr_server 만 있는 행 -> herdr_server 반환, 둘 다 없으면 기본/슬러그 fallback."""
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
yaml_path.write_text("""herdr_sessions:
- name: srv-only-creator-claude
status: running
herdr_server: socket-from-srv
pane:
cwd: /tmp
""")
s = run_lib_func(mam_sandbox, "resolve_herdr_session", "srv-only-creator-claude")
assert s.stdout.strip() == "socket-from-srv"
def test_workspace_resolver_prefers_the_row_over_the_caller_argument(mam_sandbox):
"""C-1: 등록된 행에는 herdr_workspace 가 없지만 pane.cwd 가 있다.
호출자가 '다른' 워크스페이스를 넘겨도 행의 cwd 가 이긴다."""
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
yaml_path.write_text("""herdr_sessions:
- name: pa-creator-claude
status: running
pane:
cwd: /path/to/project_a
""")
r = run_lib_func(mam_sandbox, "resolve_herdr_workspace",
"pa-creator-claude", "/path/to/project_b")
assert r.stdout.strip() == "to-project-a"
def test_workspace_resolver_uses_the_argument_only_when_unregistered(mam_sandbox):
"""③ 분기가 살아 있음을 확인 — 미등록 세션에서는 인자가 쓰인다."""
r = run_lib_func(mam_sandbox, "resolve_herdr_workspace",
"not-registered", "/path/to/project_b")
assert r.stdout.strip() == "to-project-b"
@pytest.mark.parametrize("path", ["/tmp", "/", "/a/My_Proj.v2", "/private/var/folders/q_/x"])
def test_slug_parity_between_bash_and_python(mam_sandbox, path):
"""D5 는 두 슬러그 구현의 일치에 의존한다 (lib.sh derive_workspace_slug 와
resolve_herdr_workspace / reconcile.sh 의 인라인 slug())."""
b = run_lib_func(mam_sandbox, "derive_workspace_slug", path).stdout.strip()
p = run_lib_func(mam_sandbox, "resolve_herdr_workspace", "not-registered", path).stdout.strip()
assert b.removeprefix("mam-") == p
def test_no_socket_lookup_falls_back_to_workspace_label(mam_sandbox):
"""B-22 구조 가드: 소켓 lookup 표현식에 herdr_workspace 가 다시 끼어들지 못한다."""
import re
pat = re.compile(r"herdr_session'\)\s*or\s*.*herdr_workspace")
lib_sh = mam_sandbox / "skills" / "lib.sh"
reconcile_sh = mam_sandbox / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
status_sh = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
for f in (lib_sh, reconcile_sh, status_sh):
for i, line in enumerate(f.read_text().splitlines(), 1):
assert not pat.search(line), f"{f.name}:{i} — socket lookup falls back to workspace label:\n{line}"
def test_resume_find_workspace_uuid_empty(mam_sandbox):
"""Test find_workspace_uuid returns empty string for non-existent workspace."""
res = run_lib_func(mam_sandbox, "find_workspace_uuid", "/non/existent/path", "claude")
+270 -3
View File
@@ -247,8 +247,133 @@ def test_comp_create_herdr_session_yaml_propagation(mam_sandbox, mock_herdr, moc
assert "HERDR_SESSION_NAME=isolated-suite-01" in s["kill_command"]
def test_comp_create_herdr_workspace_parsing_and_env_fallback(mam_sandbox, mock_herdr, mock_agents):
"""T4: Verify --herdr-workspace CLI flag, HERDR_WORKSPACE env fallback, and default bare slug."""
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
# Flag passed
res1 = subprocess.run([
"bash", str(script),
"--workspace", str(mam_sandbox),
"--agent", "claude",
"--role", "Creator",
"--herdr-workspace", "my-explicit-label",
"--dry-run"
], capture_output=True, text=True)
assert res1.returncode == 0
assert "herdr_workspace=my-explicit-label" in res1.stdout
# Env set, flag omitted -> env wins (C-3)
res2 = subprocess.run([
"bash", str(script),
"--workspace", str(mam_sandbox),
"--agent", "claude",
"--role", "Creator",
"--dry-run"
], capture_output=True, text=True, env={**os.environ, "HERDR_WORKSPACE": "from-env-label"})
assert res2.returncode == 0
assert "herdr_workspace=from-env-label" in res2.stdout
# Both flag and env -> flag wins
res3 = subprocess.run([
"bash", str(script),
"--workspace", str(mam_sandbox),
"--agent", "claude",
"--role", "Creator",
"--herdr-workspace", "my-explicit-label",
"--dry-run"
], capture_output=True, text=True, env={**os.environ, "HERDR_WORKSPACE": "from-env-label"})
assert res3.returncode == 0
assert "herdr_workspace=my-explicit-label" in res3.stdout
# Neither -> default bare slug (D3), distinct from herdr_session
run_env = dict(os.environ)
run_env.pop("HERDR_WORKSPACE", None)
run_env.pop("HERDR_SESSION_NAME", None)
res4 = subprocess.run([
"bash", str(script),
"--workspace", str(mam_sandbox),
"--agent", "claude",
"--role", "Creator",
"--dry-run"
], capture_output=True, text=True, env=run_env)
assert res4.returncode == 0
parent = os.path.basename(os.path.dirname(str(mam_sandbox))).lower().replace('_', '-')
work = os.path.basename(str(mam_sandbox)).lower().replace('_', '-')
bare = f"{parent}-{work}".replace('_', '-')
import re
bare = re.sub(r'[^a-zA-Z0-9-]', '', bare).lstrip('-')
assert f"herdr_workspace={bare}" in res4.stdout
assert f"herdr_session=mam-{bare}" in res4.stdout
def test_comp_create_herdr_workspace_yaml_propagation(mam_sandbox, mock_herdr, mock_agents):
"""T5: Verify herdr_workspace distinct persistence in YAML and no leakage into commands."""
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
res = subprocess.run([
"bash", str(script),
"--workspace", str(mam_sandbox),
"--agent", "claude",
"--role", "Creator",
"--session", "custom-ws-creator-claude",
"--herdr-session", "isolated-sock-01",
"--herdr-workspace", "distinct-ws-label"
], capture_output=True, text=True)
assert res.returncode == 0, res.stderr
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
import yaml
with open(yaml_path) as f:
data = yaml.safe_load(f)
sessions = data.get("herdr_sessions", [])
assert len(sessions) == 1
s = sessions[0]
assert s["name"] == "custom-ws-creator-claude"
assert s["herdr_session"] == "isolated-sock-01"
assert s["herdr_server"] == "isolated-sock-01"
assert s["herdr_workspace"] == "distinct-ws-label"
assert "distinct-ws-label" not in s["start_command"]
assert "distinct-ws-label" not in s["attach_command"]
assert "distinct-ws-label" not in s["kill_command"]
def test_create_does_not_inherit_a_stale_workspace_label(mam_sandbox, mock_herdr, mock_agents):
"""T9 / D5: Recreating over a terminated row derives label afresh from --workspace."""
mutation = """
d['herdr_sessions'] = [{
'name': 'reuse-creator-claude',
'status': 'terminated',
'herdr_session': 'old-sock',
'herdr_server': 'old-sock',
'herdr_workspace': 'old-stale-label',
'pane': {'cwd': '/old/place', 'cmd': 'claude'}
}]
"""
run_mutation(mam_sandbox, mutation)
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
res = subprocess.run([
"bash", str(script),
"--workspace", str(mam_sandbox),
"--agent", "claude",
"--role", "Creator",
"--session", "reuse-creator-claude"
], capture_output=True, text=True)
assert res.returncode == 0, res.stderr
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
import yaml
with open(yaml_path) as f:
data = yaml.safe_load(f)
sessions = data.get("herdr_sessions", [])
assert len(sessions) == 1
s = sessions[0]
assert s["status"] == "running"
assert s["herdr_workspace"] != "old-stale-label"
# ==============================================================================
# FEATURE 2: Resume Session (6 Test Cases)
# FEATURE 2: Resume Session (8 Test Cases)
# ==============================================================================
def test_comp_resume_config_restore(mam_sandbox):
@@ -452,6 +577,78 @@ d['herdr_sessions'] = [{{
assert "HERDR_SESSION_NAME=NEW-HERDR-SESSION" in s["kill_command"]
def test_comp_resume_herdr_workspace_propagation(mam_sandbox, mock_herdr, mock_agents):
"""T6: Verify resume_session.sh with --herdr-workspace updates herdr_workspace while preserving herdr_session."""
conv_id = "22222222-3333-4444-5555-666666666666"
key = str(mam_sandbox).replace('/', '-').replace('_', '-')
proj_dir = mam_sandbox / ".claude" / "projects" / key
proj_dir.mkdir(parents=True, exist_ok=True)
(proj_dir / f"{conv_id}.jsonl").write_text(f'{{"sessionId": "{conv_id}"}}')
mutation = f"""
d['herdr_sessions'] = [{{
'name': 'test-proj-ws-creator-claude',
'status': 'stopped',
'herdr_session': 'PRESERVED-SESSION',
'herdr_server': 'PRESERVED-SESSION',
'herdr_workspace': 'OLD-WS-LABEL',
'claude_session_id_own': '{conv_id}',
'pane': {{'cwd': '{str(mam_sandbox)}', 'cmd': 'claude'}}
}}]
"""
run_mutation(mam_sandbox, mutation)
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh"
res = subprocess.run([
"bash", str(script),
"--workspace", str(mam_sandbox),
"--agent", "claude",
"--session", "test-proj-ws-creator-claude",
"--herdr-workspace", "NEW-WS-LABEL"
], capture_output=True, text=True)
assert res.returncode == 0, res.stderr
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
import yaml
with open(yaml_path) as f:
data = yaml.safe_load(f)
sessions = data.get("herdr_sessions", [])
assert len(sessions) == 1
s = sessions[0]
assert s["status"] == "running"
assert s["herdr_workspace"] == "NEW-WS-LABEL"
assert s["herdr_session"] == "PRESERVED-SESSION"
def test_comp_resume_herdr_workspace_new_row_branch(mam_sandbox, mock_herdr, mock_agents):
"""T7: Verify update_yaml_resumed.sh creates a new row with herdr_workspace when target is None."""
run_mutation(mam_sandbox, "d['herdr_sessions'] = []")
script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "update_yaml_resumed.sh"
res = subprocess.run([
"bash", str(script),
"--workspace", str(mam_sandbox),
"--agent", "claude",
"--session", "brand-new-resumed-session",
"--uuid", "33333333-4444-5555-6666-777777777777",
"--herdr-session", "explicit-sock",
"--herdr-workspace", "explicit-ws"
], capture_output=True, text=True)
assert res.returncode == 0, res.stderr
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
import yaml
with open(yaml_path) as f:
data = yaml.safe_load(f)
sessions = data.get("herdr_sessions", [])
assert len(sessions) == 1
s = sessions[0]
assert s["name"] == "brand-new-resumed-session"
assert s["herdr_session"] == "explicit-sock"
assert s["herdr_server"] == "explicit-sock"
assert s["herdr_workspace"] == "explicit-ws"
# ==============================================================================
# FEATURE 3: Stop Session (7 Test Cases)
# ==============================================================================
@@ -773,8 +970,38 @@ d['herdr_sessions'] = [{
assert session["pane_cwd"] == "/tmp"
def test_comp_status_displays_socket_and_workspace_columns(mam_sandbox):
"""T12: Verify status.sh displays distinct SOCKET and WORKSPACE columns."""
mutation = """
d['herdr_sessions'] = [{
'name': 'test-cols-creator-claude',
'status': 'running',
'herdr_session': 'socket-AAA',
'herdr_server': 'socket-AAA',
'herdr_workspace': 'label-BBB',
'pane': {'cwd': '/tmp', 'cmd': 'claude'}
}]
"""
run_mutation(mam_sandbox, mutation)
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
res = subprocess.run(["bash", str(script_path)], capture_output=True, text=True)
assert res.returncode == 0
assert "SOCKET" in res.stdout
assert "WORKSPACE" in res.stdout
assert "socket-AAA" in res.stdout
assert "label-BBB" in res.stdout
# Also verify --json has herdr_workspace
res_json = subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
assert res_json.returncode == 0
data = json.loads(res_json.stdout)
assert data["sessions_detail"][0]["herdr_workspace"] == "label-BBB"
assert data["sessions_detail"][0]["server"] == "socket-AAA"
# ==============================================================================
# FEATURE 5: Monitor/Reconcile (6 Test Cases)
# FEATURE 5: Monitor/Reconcile (7 Test Cases)
# ==============================================================================
def test_comp_monitor_concurrency_lock(mam_sandbox):
@@ -940,7 +1167,8 @@ def test_comp_stop_usage_matches_parser(mam_sandbox):
("--purge-conversation", ["--purge-conversation"]),
("--yes", ["--yes"]),
("--agent", ["--agent", "hermes"]),
("--herdr-session", ["--herdr-session", "isolated-sess"])):
("--herdr-session", ["--herdr-session", "isolated-sess"]),
("--herdr-workspace", ["--herdr-workspace", "isolated-ws"])):
r = subprocess.run(["bash", str(script), "--session", VALID] + args,
capture_output=True, text=True)
assert "unknown arg" not in r.stderr, f"usage() advertises {flag} but parser rejects it: {r.stderr}"
@@ -958,3 +1186,42 @@ def test_comp_stop_usage_matches_parser(mam_sandbox):
head = "".join(script.read_text().splitlines(keepends=True)[:35])
assert "--mode soft|hard" not in head
def test_comp_reconcile_drift_b_populates_workspace_and_server(mam_sandbox, mock_herdr, mock_agents):
"""T11 / S10: Verify drift B auto-registration populates herdr_workspace and herdr_server."""
session_name = "canary-test-creator-claude"
state = {
"workspaces": [{"workspace_id": "w1", "label": "default", "cwd": str(mam_sandbox)}],
"agents": {
session_name: {
"name": session_name,
"status": "running",
"cwd": str(mam_sandbox),
"command": "claude",
"pid": 98765
}
},
"calls": []
}
with open(mock_herdr, "w") as f:
json.dump(state, f)
run_mutation(mam_sandbox, "d['herdr_sessions'] = []")
reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
res = subprocess.run(["bash", str(reconcile_script)], capture_output=True, text=True, cwd=str(mam_sandbox), env={**os.environ, "WORKSPACE_ROOT": str(mam_sandbox)})
assert res.returncode == 0, res.stderr
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
import yaml
with open(yaml_path) as f:
data = yaml.safe_load(f)
sessions = data.get("herdr_sessions", [])
matching = [s for s in sessions if s.get("name") == session_name]
assert len(matching) == 1
s = matching[0]
assert s["herdr_session"] == "default"
assert s["herdr_server"] == "default"
assert s["herdr_workspace"] != ""
assert s["herdr_workspace"] != "-"