diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 7ea7a63..e98976e 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -1671,6 +1671,22 @@ print(resolved) " } +# session_attach_command [server] +# +# Issue #72: the attach hint a script prints - the session row's +# attach_command, else the one lib_py.agents.sanitize.attach_command builds for +# (default: `default`). +session_attach_command() { + MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$1" ATTACH_SERVER="${2:-default}" python3 -c " +import os, json +from lib_py.agents.sanitize import attach_command +name = os.environ['SESSION_NAME'] +d = json.loads(os.environ.get('MAM_STATE_JSON') or '{}') +row = next((s for s in d.get('herdr_sessions', []) if s.get('name') == name), {}) +print(row.get('attach_command') or attach_command(os.environ['ATTACH_SERVER'], name)) +" +} + # resolve_herdr_session [workspace] # # returns the isolated herdr *session* name (socket/daemon) to use for this MAM session row, diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index 6ccab1c..18cfa3b 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -767,6 +767,6 @@ fi trap - EXIT echo "agent-sessions.yaml updated" echo -echo "Attach: herdr session attach $SESSION_NAME" +echo "Attach: $(session_attach_command "$SESSION_NAME" "${HERDR_SESSION_NAME:-default}")" echo "Delete: use multi-agent-mux-stop skill" echo "Resume: use multi-agent-mux-resume skill (after first message creates a session id)" diff --git a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job index b0af734..e77146a 100755 --- a/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job +++ b/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job @@ -1018,7 +1018,7 @@ inject_agent() { return 1 fi - echo "작업이 세션 '$sess'에 전송되었습니다. (연결하려면: HERDR_SESSION_NAME=$HERDR_SESSION_NAME herdr agent attach $sess — lib.sh를 source한 셸에서 실행)" + echo "작업이 세션 '$sess'에 전송되었습니다. (연결하려면: $(session_attach_command "$sess" "${HERDR_SESSION_NAME:-default}"))" trap - EXIT return 0 } diff --git a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh index 3aaf352..50067a5 100755 --- a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh +++ b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh @@ -332,7 +332,7 @@ if [ "$PURGE" = "1" ] && [ "$HERDR_ALIVE" = "1" ]; then if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then echo "ERROR: session '$SESSION_NAME' is still alive after the kill chain." >&2 echo " Refusing registry removal — records preserved (no state was modified)." >&2 - echo " Diagnose the stuck TUI (herdr session attach '$SESSION_NAME'), then re-run" >&2 + echo " Diagnose the stuck TUI ($(session_attach_command "$SESSION_NAME" "${HERDR_SESSION_NAME:-default}")), then re-run" >&2 echo " stop_session.sh --purge-conversation --yes (retry is safe/idempotent)." >&2 delegate_publish_event "$DELEGATE_JOB_ID" cancelled "purge aborted: session still alive; no state was modified" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT" exit 4 diff --git a/deploy/INSTALL.md b/deploy/INSTALL.md index b2b6333..c9a4c65 100644 --- a/deploy/INSTALL.md +++ b/deploy/INSTALL.md @@ -60,7 +60,7 @@ $ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh \ ### 2) 세션 접속 (Attach) 백그라운드에서 구동된 에이전트 TUI 화면에 들어갑니다. ```bash -$ herdr session attach my-project-dev-claude +$ herdr --session multi-agent-mux agent attach my-project-dev-claude ``` * **화면 탈출**: 대화 중 세션을 유지한 채 터미널로 돌아오려면 `Ctrl + B`를 누른 뒤 `D` 키를 차례로 입력합니다. diff --git a/deploy/install_mam.sh b/deploy/install_mam.sh index fa55e3f..03d85a8 100755 --- a/deploy/install_mam.sh +++ b/deploy/install_mam.sh @@ -327,7 +327,7 @@ cat < + $ herdr --session multi-agent-mux agent attach 3. Gracefully stop the session: $ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \\ diff --git a/tests/test_issue72_attach_hints.py b/tests/test_issue72_attach_hints.py new file mode 100644 index 0000000..21c2932 --- /dev/null +++ b/tests/test_issue72_attach_hints.py @@ -0,0 +1,184 @@ +"""Issue #72: the attach hints create, stop and the delegate-job wrapper print +are the session row's attach_command (Issue #71); with no row, the one +lib_py.agents.sanitize.attach_command builds for the server the script used. +""" +import json +import os +import re +import subprocess +import sys +import time +from pathlib import Path + +import pytest +import yaml + +REPO = Path(__file__).resolve().parent.parent +SKILLS = REPO / ".agents" / "skills" +sys.path.insert(0, str(SKILLS)) + +from lib_py.agents.sanitize import attach_command, sanitize_herdr_agent_name # noqa: E402 + +LONG = "lab-issue72-a-very-long-branch-name-creator-claude" # > 32 characters +UPPER = "Lab-Upper72-creator-claude" + + +def _set_mock(mock_herdr, **kw): + state = json.loads(Path(mock_herdr).read_text()) + state.update(kw) + Path(mock_herdr).write_text(json.dumps(state)) + + +def _rows(sandbox): + return yaml.safe_load((sandbox / ".mam" / "agent-sessions.yaml").read_text())["herdr_sessions"] + + +def _seed(sandbox, rows): + lib = sandbox / ".agents" / "skills" / "lib.sh" + mutation = "d['herdr_sessions'] = " + repr(rows) + "\n" + res = subprocess.run(["bash", "-c", 'source "$1" && atomic_dump_yaml "$AGENT_SESSIONS_YAML"', "_", str(lib)], + input=mutation, capture_output=True, text=True) + assert res.returncode == 0, res.stderr + + +def _row(name, server, cwd, pid=1): + return {"name": name, "status": "running", "role": "creator", "agent": "claude", + "herdr_session": server, "herdr_server": server, "attach_command": attach_command(server, name), + "pane": {"index": 0, "pid": pid, "cmd": "claude", "cwd": str(cwd)}} + + +def _seeded_attach(sandbox, name, server): + (row,) = [r for r in _rows(sandbox) if r["name"] == name] + assert row["attach_command"] == attach_command(server, name) + return row["attach_command"] + + +def _agent(cwd, pane, pid=9999): + return {"agent": "claude", "status": "running", "agent_status": "idle", "cwd": str(cwd), "workspace_id": "w1", + "pid": pid, "pane_id": pane, "command": "claude", "buffer": "Ready"} + + +# --- create --------------------------------------------------------------------- + +@pytest.mark.parametrize("name", [LONG, UPPER]) +def test_create_prints_the_rows_attach_command(mam_sandbox, mock_herdr, mock_agents, name): + assert sanitize_herdr_agent_name(name) != name, "the herdr name differs from the session name" + _set_mock(mock_herdr, strict_agent_names=True) + 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", name, "--herdr-session", "mam-iso72", "--no-onboard"], + capture_output=True, text=True, cwd=str(mam_sandbox), timeout=300, + env=dict(os.environ, MAM_NO_SUBSCRIBER_DAEMON="1")) + assert res.returncode == 0, res.stdout + res.stderr + (row,) = [r for r in _rows(mam_sandbox) if r["name"] == name] + hints = [l for l in res.stdout.splitlines() if l.startswith("Attach:")] + assert hints == ["Attach: " + row["attach_command"]], res.stdout + assert row["attach_command"] == attach_command("mam-iso72", name) + + +# --- stop: the stuck-TUI message ------------------------------------------------ + +def _undying_herdr(mam_sandbox): + """The mock herdr behind a wrapper that ignores every way to close a pane, + so the session survives stop's kill chain.""" + bin_dir = mam_sandbox / "bin" + (bin_dir / "herdr").rename(bin_dir / "herdr-mock72") + (bin_dir / "herdr").write_text( + '#!/usr/bin/env bash\nfor a in "$@"; do case "$a" in kill-session|close) exit 0 ;; esac; done\n' + 'exec "%s" "$@"\n' % (bin_dir / "herdr-mock72")) + (bin_dir / "herdr").chmod(0o755) + + +@pytest.mark.parametrize("name", [LONG, UPPER]) +def test_stop_stuck_tui_message_shows_the_rows_attach_command(mam_sandbox, mock_herdr, mock_agents, name): + # The SIGKILL fallback kills the pane pid: make it a child of this test. + pane = subprocess.Popen(["sleep", "300"]) + try: + # The row's server is not the one passed on the command line: the row wins. + _seed(mam_sandbox, [_row(name, "mam-row72", mam_sandbox, pid=pane.pid)]) + want = _seeded_attach(mam_sandbox, name, "mam-row72") + _set_mock(mock_herdr, strict_agent_names=True, + agents={sanitize_herdr_agent_name(name): _agent(mam_sandbox, "w1:p72", pid=pane.pid)}) + _undying_herdr(mam_sandbox) + script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh" + res = subprocess.run(["bash", str(script), "--session", name, "--agent", "claude", + "--herdr-session", "mam-arg72", "--purge-conversation", "--yes"], + capture_output=True, text=True, cwd=str(mam_sandbox), timeout=300, + env=dict(os.environ, SKS_IDLE_TIMEOUT_MS="500")) + finally: + pane.kill() + pane.wait() + assert res.returncode == 4, res.stdout + res.stderr + hints = [l for l in res.stderr.splitlines() if "Diagnose the stuck TUI" in l] + assert hints == [" Diagnose the stuck TUI (%s), then re-run" % want], res.stderr + + +# --- delegate-job wrapper --------------------------------------------------------- + +def _delegate(mam_sandbox, mock_herdr, name): + """Submit a job to herdr:, acknowledge it as the agent would, and + return (stdout, the herdr server the wrapper prompted).""" + reg_dir = mam_sandbox / ".mam" / "jobs" + reg_dir.mkdir(parents=True, exist_ok=True) + wrapper = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job" + publish = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "scripts" / "publish_event.py" + env = dict(os.environ, DELEGATE_JOB_PYTHON=sys.executable, MQTT_HOST="127.0.0.1", MQTT_PORT="65534", + SKS_IDLE_TIMEOUT_MS="500", MAM_INJECT_ACK_GRACE_S="20") + proc = subprocess.Popen(["bash", str(wrapper), "submit", "--agent", "claude-code", + "--agent-session", "herdr:" + name, "--prompt", "issue 72 attach hint", + "--registry-dir", str(reg_dir), "--workdir", str(mam_sandbox), + "--timeout", "6", "--idle-timeout", "2"], + cwd=str(mam_sandbox), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + tok, jid = "", "" + deadline = time.time() + 60 + while time.time() < deadline and not tok: + for f in reg_dir.glob("*.json"): + try: + tok, jid = json.loads(f.read_text()).get("dispatch_token") or "", f.stem + except ValueError: + pass + time.sleep(0.2) + assert tok, "the job was not dispatched" + ack = subprocess.run([sys.executable, str(publish), "--registry-dir", str(reg_dir), "--job", jid, + "--event", "started", "--expect-dispatch-token", tok, "--attempt-id", "1"], + capture_output=True, text=True, cwd=str(mam_sandbox), env=env, timeout=60) + # publish_event records `started` locally before its (here unreachable) broker publish + assert '"started"' in (reg_dir / (jid + ".events.log")).read_text(), ack.stderr + stdout, stderr = proc.communicate(timeout=180) + finally: + if proc.poll() is None: + proc.kill() + proc.communicate() + calls = json.loads(Path(mock_herdr).read_text())["calls"] + prompts = [c for c in calls if "agent" in c[:3] and "prompt" in c[:4]] + assert prompts, (calls, stdout, stderr) + servers = {c[1] if c[0] == "--session" else "default" for c in prompts} + assert len(servers) == 1, prompts + return stdout + stderr, servers.pop() + + +def _delegate_hint(out): + hints = [m.group(1) for m in (re.search(r"\(연결하려면: (.*)\)$", l) for l in out.splitlines()) if m] + assert len(hints) == 1, out + return hints[0] + + +@pytest.mark.parametrize("name", [LONG, UPPER]) +def test_delegate_prints_the_rows_attach_command(mam_sandbox, mock_herdr, mock_agents, name): + _seed(mam_sandbox, [_row(name, "mam-row72", mam_sandbox)]) + want = _seeded_attach(mam_sandbox, name, "mam-row72") + _set_mock(mock_herdr, strict_agent_names=True, + agents={sanitize_herdr_agent_name(name): _agent(mam_sandbox, "w1:p72")}) + out, server = _delegate(mam_sandbox, mock_herdr, name) + assert server == "mam-row72" + assert _delegate_hint(out) == want + + +@pytest.mark.parametrize("name", [LONG, UPPER]) +def test_delegate_without_a_row_builds_it_for_the_server_it_used(mam_sandbox, mock_herdr, mock_agents, name): + assert not any(r["name"] == name for r in _rows(mam_sandbox)) + _set_mock(mock_herdr, strict_agent_names=True, + agents={sanitize_herdr_agent_name(name): _agent(mam_sandbox, "w1:p72")}) + out, server = _delegate(mam_sandbox, mock_herdr, name) + assert _delegate_hint(out) == attach_command(server, name)