fix(attach): hints print the row's attach_command (Issue #72, comments 926/930, U1)
create's closing hint, the delegate-job wrapper's dispatch hint and stop's stuck-TUI message printed the pre-#71 forms (`herdr session attach <name>`, `HERDR_SESSION_NAME=... herdr agent attach <name>`), which miss an agent whose name is over 32 characters or has upper case. They now print the session row's attach_command, or lib_py.agents.sanitize.attach_command(server, name) when there is no row (lib.sh session_attach_command). deploy/INSTALL.md and install_mam.sh show the `herdr --session <server> agent attach <herdr name>` form. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0e1e2e87de
commit
ec378e82c9
7 files changed
+205
-5
No files matched your search
@@ -1671,6 +1671,22 @@ print(resolved)
|
|||||||
"
|
"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# session_attach_command <session_name> [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
|
||||||
|
# <server> (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 <session_name> [workspace]
|
# resolve_herdr_session <session_name> [workspace]
|
||||||
#
|
#
|
||||||
# returns the isolated herdr *session* name (socket/daemon) to use for this MAM session row,
|
# returns the isolated herdr *session* name (socket/daemon) to use for this MAM session row,
|
||||||
|
|||||||
@@ -767,6 +767,6 @@ fi
|
|||||||
trap - EXIT
|
trap - EXIT
|
||||||
echo "agent-sessions.yaml updated"
|
echo "agent-sessions.yaml updated"
|
||||||
echo
|
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 "Delete: use multi-agent-mux-stop skill"
|
||||||
echo "Resume: use multi-agent-mux-resume skill (after first message creates a session id)"
|
echo "Resume: use multi-agent-mux-resume skill (after first message creates a session id)"
|
||||||
@@ -1018,7 +1018,7 @@ inject_agent() {
|
|||||||
return 1
|
return 1
|
||||||
fi
|
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
|
trap - EXIT
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ if [ "$PURGE" = "1" ] && [ "$HERDR_ALIVE" = "1" ]; then
|
|||||||
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; 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 "ERROR: session '$SESSION_NAME' is still alive after the kill chain." >&2
|
||||||
echo " Refusing registry removal — records preserved (no state was modified)." >&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
|
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"
|
delegate_publish_event "$DELEGATE_JOB_ID" cancelled "purge aborted: session still alive; no state was modified" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
|
||||||
exit 4
|
exit 4
|
||||||
|
|||||||
+1
-1
@@ -60,7 +60,7 @@ $ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh \
|
|||||||
### 2) 세션 접속 (Attach)
|
### 2) 세션 접속 (Attach)
|
||||||
백그라운드에서 구동된 에이전트 TUI 화면에 들어갑니다.
|
백그라운드에서 구동된 에이전트 TUI 화면에 들어갑니다.
|
||||||
```bash
|
```bash
|
||||||
$ herdr session attach my-project-dev-claude
|
$ herdr --session multi-agent-mux agent attach my-project-dev-claude
|
||||||
```
|
```
|
||||||
* **화면 탈출**: 대화 중 세션을 유지한 채 터미널로 돌아오려면 `Ctrl + B`를 누른 뒤 `D` 키를 차례로 입력합니다.
|
* **화면 탈출**: 대화 중 세션을 유지한 채 터미널로 돌아오려면 `Ctrl + B`를 누른 뒤 `D` 키를 차례로 입력합니다.
|
||||||
|
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ cat <<EOF
|
|||||||
--herdr-server multi-agent-mux
|
--herdr-server multi-agent-mux
|
||||||
|
|
||||||
2. Attach to the running session:
|
2. Attach to the running session:
|
||||||
$ HERDR_SERVER_NAME=multi-agent-mux herdr agent attach <session_name>
|
$ herdr --session multi-agent-mux agent attach <herdr name>
|
||||||
|
|
||||||
3. Gracefully stop the session:
|
3. Gracefully stop the session:
|
||||||
$ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \\
|
$ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \\
|
||||||
|
|||||||
@@ -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:<name>, 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)
|
||||||
Reference in new issue
Block a user