The ls translation searched stderr and the whole stdout for the substring server_not_running, so a successful `agent list` that merely carried the text (an agent named server_not_running-codex, or metadata) made the shim report "no server running". reconcile.sh reads that as a confirmed-empty server, and drift A would then mark every live row on it terminated (codex review of0918917). - keep the RPC exit code and translate only a failed call whose error envelope (the whole stream or any line of it) has error.code == "server_not_running"; every other failure keeps the unknown path (nonzero exit, herdr_confirmed False) as before - tests: the real herdr 0.9 envelope, a successful list carrying the substring in a name and in metadata, other failures staying unknown, and reconcile keeping both live rows with no drift A; the new cases fail on0918917- delegate wrapper: log a non-pending status seen during the inject ack grace as "registry advanced" instead of attributing it to the agent - VERSIONS.md v4.4.2: describe the structured check and count 16 new tests Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
280 lines
12 KiB
Python
280 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""Issue #9 — herdr server bootstrap must not inherit the caller's agent-runtime
|
|
markers, and query-only skills must never bootstrap a server at all.
|
|
|
|
The bootstrap lives in the `.mam/shim/herdr` wrapper that lib.sh writes, so every
|
|
test drives that shim against a fake `herdr` binary placed first on PATH: it logs
|
|
each call, answers `session list --json` with "not running", and dumps its own
|
|
environment when asked to run as the `server`.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
RECONCILE_SH = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
|
STATUS_SH = REPO_ROOT / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
|
|
|
FAKE_HERDR = r'''#!/usr/bin/env python3
|
|
import json, os, sys
|
|
args = sys.argv[1:]
|
|
with open(os.environ["FAKE_HERDR_LOG"], "a") as f:
|
|
f.write(json.dumps(args) + "\n")
|
|
if args[-1:] == ["server"]:
|
|
with open(os.environ["FAKE_HERDR_ENV_DUMP"], "w") as f:
|
|
json.dump(dict(os.environ), f)
|
|
sys.exit(0)
|
|
if args[-3:] == ["session", "list", "--json"]:
|
|
print(json.dumps({"sessions": []}))
|
|
sys.exit(0)
|
|
print(json.dumps({"result": {"agents": []}}))
|
|
'''
|
|
|
|
# Per-session runtime markers of the calling agent CLI: none of these may reach
|
|
# the daemon (and through it, every pane it later spawns).
|
|
RUNTIME_MARKERS = {
|
|
"CLAUDECODE": "1",
|
|
"CLAUDE_CODE_CHILD_SESSION": "1",
|
|
"CLAUDE_CODE_SESSION_ID": "orchestrator-uuid",
|
|
"CLAUDE_CODE_MESSAGING_SOCKET": "/tmp/cc-socks/1.sock",
|
|
"CLAUDE_PID": "12345",
|
|
"ANTIGRAVITY_CONVERSATION_ID": "agy-uuid",
|
|
"HERMES_SESSION_ID": "hermes-uuid",
|
|
"GROK_SESSION_ID": "grok-uuid",
|
|
"OPENCODE_SESSION_ID": "opencode-uuid",
|
|
}
|
|
|
|
# Ordinary configuration must keep flowing through unchanged.
|
|
KEPT_CONFIG = {
|
|
"CLAUDE_CONFIG_DIR": "/keep/claude-config",
|
|
"MAM_CUSTOM_SETTING": "kept",
|
|
}
|
|
|
|
# What herdr 0.9 writes to stderr (exit 1) for `agent list` against a stopped
|
|
# session server.
|
|
REAL_DOWN_ENVELOPE = {
|
|
"id": "cli:agent:list",
|
|
"error": {
|
|
"code": "server_not_running",
|
|
"message": "no herdr server is running at /tmp/sessions/down-srv/herdr.sock; "
|
|
"run `herdr session attach down-srv` to start or attach it",
|
|
},
|
|
}
|
|
|
|
# A successful list in which the down-server code appears only as data: in an
|
|
# agent name (the sanitizer allows underscores) and in unrelated metadata.
|
|
LISTING_WITH_SUBSTRING = {"result": {"agents": [
|
|
{"name": "server_not_running-codex", "agent": "codex"},
|
|
{"name": "creator-claude-01", "agent": "claude", "title": "server_not_running"},
|
|
]}}
|
|
|
|
|
|
def _install_fake_herdr(sandbox: Path) -> dict:
|
|
fakebin = sandbox / "fakebin"
|
|
fakebin.mkdir(exist_ok=True)
|
|
binary = fakebin / "herdr"
|
|
binary.write_text(FAKE_HERDR)
|
|
binary.chmod(0o755)
|
|
env = dict(os.environ)
|
|
env.update({
|
|
"PATH": f"{fakebin}:{env['PATH']}",
|
|
"FAKE_HERDR_LOG": str(sandbox / "fake_herdr_calls.log"),
|
|
"FAKE_HERDR_ENV_DUMP": str(sandbox / "server_env.json"),
|
|
"AGENT_PYTHON_BIN": sys.executable,
|
|
"DELEGATE_JOB_PYTHON": sys.executable,
|
|
})
|
|
return env
|
|
|
|
|
|
def _fake_agent_list(sandbox: Path, body: str) -> None:
|
|
"""Replace the fake's fallback answer (what `agent list` gets) with `body`."""
|
|
fake = sandbox / "fakebin" / "herdr"
|
|
fake.write_text(FAKE_HERDR.replace('print(json.dumps({"result": {"agents": []}}))', body))
|
|
|
|
|
|
def _calls(env: dict) -> list:
|
|
log = Path(env["FAKE_HERDR_LOG"])
|
|
if not log.exists():
|
|
return []
|
|
return [json.loads(line) for line in log.read_text().splitlines() if line.strip()]
|
|
|
|
|
|
def _run_through_shim(sandbox: Path, env: dict, session: str) -> subprocess.CompletedProcess:
|
|
"""`herdr agent list` through lib.sh's shim, scoped to an isolated session
|
|
whose server the fake reports as not running."""
|
|
lib = sandbox / ".agents" / "skills" / "lib.sh"
|
|
env = dict(env, HERDR_SESSION_NAME=session)
|
|
return subprocess.run(
|
|
["bash", "-c", f'source "{lib}" >/dev/null 2>&1; herdr agent list'],
|
|
capture_output=True, text=True, cwd=str(sandbox), env=env, timeout=60,
|
|
)
|
|
|
|
|
|
def _shim_ls(sandbox: Path, env: dict, server: str) -> subprocess.CompletedProcess:
|
|
"""The exact `ls` query reconcile.sh sends for each registered server."""
|
|
lib = sandbox / ".agents" / "skills" / "lib.sh"
|
|
return subprocess.run(
|
|
["bash", "-c", f'source "{lib}" >/dev/null 2>&1; herdr -L {server} ls -F "#{{session_name}}|#{{session_created}}"'],
|
|
capture_output=True, text=True, cwd=str(sandbox), env=env, timeout=60,
|
|
)
|
|
|
|
|
|
def test_bootstrap_strips_agent_runtime_markers(mam_sandbox):
|
|
env = _install_fake_herdr(mam_sandbox)
|
|
env.update(RUNTIME_MARKERS)
|
|
env.update(KEPT_CONFIG)
|
|
|
|
res = _run_through_shim(mam_sandbox, env, "issue9-probe")
|
|
assert res.returncode == 0, res.stderr
|
|
|
|
server_calls = [c for c in _calls(env) if c[-1:] == ["server"]]
|
|
assert server_calls == [["--session", "issue9-probe", "server"]], _calls(env)
|
|
|
|
dump = Path(env["FAKE_HERDR_ENV_DUMP"])
|
|
assert dump.exists(), "server was bootstrapped but never received an environment"
|
|
server_env = json.loads(dump.read_text())
|
|
|
|
leaked = sorted(k for k in RUNTIME_MARKERS if k in server_env)
|
|
assert not leaked, f"runtime markers leaked into the herdr server: {leaked}"
|
|
for key, value in KEPT_CONFIG.items():
|
|
assert server_env.get(key) == value, f"{key} must be inherited unchanged"
|
|
# MAM's own configuration is configuration, not a marker.
|
|
assert server_env.get("CLAUDE_PROJECT_DIR") == os.environ["CLAUDE_PROJECT_DIR"]
|
|
for key in ("HOME", "PATH", "WORKSPACE_ROOT"):
|
|
assert key in server_env
|
|
|
|
|
|
def test_no_autostart_skips_bootstrap_but_still_routes_the_call(mam_sandbox):
|
|
env = _install_fake_herdr(mam_sandbox)
|
|
env["MAM_NO_AUTOSTART"] = "1"
|
|
|
|
res = _run_through_shim(mam_sandbox, env, "issue9-quiet")
|
|
assert res.returncode == 0, res.stderr
|
|
|
|
calls = _calls(env)
|
|
assert not any(c[-1:] == ["server"] for c in calls), f"server must not be started: {calls}"
|
|
assert not Path(env["FAKE_HERDR_ENV_DUMP"]).exists()
|
|
# The command itself still goes to the named session, whose server answers.
|
|
assert ["--session", "issue9-quiet", "agent", "list"] in calls, calls
|
|
|
|
|
|
def test_status_query_never_starts_a_server(mam_sandbox):
|
|
"""status.sh sweeps every herdr_session named in the YAML; a stopped
|
|
isolated server must be reported dead, not bootstrapped to answer."""
|
|
env = _install_fake_herdr(mam_sandbox)
|
|
(mam_sandbox / ".mam" / "agent-sessions.yaml").write_text(
|
|
"herdr_sessions:\n"
|
|
" - name: ghost-creator-claude\n"
|
|
" status: stopped\n"
|
|
" herdr_session: ghost-srv\n"
|
|
" pane:\n"
|
|
" cwd: /tmp/nonexistent-workspace\n"
|
|
)
|
|
status_sh = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
|
|
|
|
res = subprocess.run(["bash", str(status_sh), "--json"], capture_output=True, text=True,
|
|
cwd=str(mam_sandbox), env=env, timeout=120)
|
|
assert res.returncode == 0, res.stderr
|
|
|
|
calls = _calls(env)
|
|
assert any("--session" in c and "ghost-srv" in c for c in calls), \
|
|
f"the ghost-srv session was never queried: {calls}"
|
|
assert not any(c[-1:] == ["server"] for c in calls), f"status.sh started a server: {calls}"
|
|
assert not Path(env["FAKE_HERDR_ENV_DUMP"]).exists()
|
|
|
|
|
|
def test_reconcile_and_status_opt_out_of_autostart_statically():
|
|
for script in (RECONCILE_SH, STATUS_SH):
|
|
assert "export MAM_NO_AUTOSTART=1" in script.read_text(), \
|
|
f"{script.name} must opt out of the shim's server bootstrap"
|
|
|
|
|
|
@pytest.mark.parametrize("envelope", [
|
|
{"error": {"code": "server_not_running"}},
|
|
REAL_DOWN_ENVELOPE,
|
|
], ids=["minimal", "herdr-0.9"])
|
|
def test_ls_reports_down_server_as_no_server_running(mam_sandbox, envelope):
|
|
"""reconcile.sh treats a nonzero `ls` as 'empty' only when stderr says
|
|
'no server running'; anything else flips herdr_confirmed=False for every
|
|
server. The shim must therefore translate server_not_running explicitly."""
|
|
env = _install_fake_herdr(mam_sandbox)
|
|
env["MAM_NO_AUTOSTART"] = "1"
|
|
_fake_agent_list(mam_sandbox,
|
|
f'sys.stderr.write({json.dumps(json.dumps(envelope))} + "\\n"); sys.exit(1)')
|
|
|
|
res = _shim_ls(mam_sandbox, env, "down-srv")
|
|
assert res.returncode != 0
|
|
assert "no server running" in res.stderr, res.stderr
|
|
assert res.stdout.strip() == ""
|
|
|
|
|
|
def test_ls_lists_agents_whose_names_contain_server_not_running(mam_sandbox):
|
|
"""A successful list is never a down server, whatever text it carries.
|
|
Matching the bare substring let one agent named server_not_running-codex
|
|
hide every agent its server returned."""
|
|
env = _install_fake_herdr(mam_sandbox)
|
|
env["MAM_NO_AUTOSTART"] = "1"
|
|
_fake_agent_list(mam_sandbox, f"print({json.dumps(json.dumps(LISTING_WITH_SUBSTRING))})")
|
|
|
|
res = _shim_ls(mam_sandbox, env, "live-srv")
|
|
assert res.returncode == 0, res.stderr
|
|
assert "no server running" not in res.stderr, res.stderr
|
|
names = [line.split("|", 1)[0] for line in res.stdout.splitlines() if line.strip()]
|
|
assert names == ["server_not_running-codex", "creator-claude-01"], res.stdout
|
|
|
|
|
|
@pytest.mark.parametrize("stderr_text", [
|
|
json.dumps({"error": {"code": "transport_error",
|
|
"message": "socket reset while probing server_not_running"}}),
|
|
"warning: server_not_running cache entry is stale",
|
|
"",
|
|
], ids=["other-code", "plain-text", "empty"])
|
|
def test_ls_leaves_other_failures_unknown(mam_sandbox, stderr_text):
|
|
"""Only the structured code means "down". Any other failure keeps the
|
|
unknown path (nonzero exit without 'no server running'), so reconcile
|
|
drops herdr_confirmed instead of reading a confirmed-empty server."""
|
|
env = _install_fake_herdr(mam_sandbox)
|
|
env["MAM_NO_AUTOSTART"] = "1"
|
|
_fake_agent_list(mam_sandbox,
|
|
f'sys.stderr.write({json.dumps(stderr_text)} + "\\n"); sys.exit(1)')
|
|
|
|
res = _shim_ls(mam_sandbox, env, "flaky-srv")
|
|
assert res.returncode != 0
|
|
assert "no server running" not in res.stderr, res.stderr
|
|
assert res.stdout.strip() == ""
|
|
|
|
|
|
def test_reconcile_keeps_live_rows_when_a_name_contains_server_not_running(mam_sandbox):
|
|
"""End to end through reconcile.sh: the server answers a successful list in
|
|
which the down-server code appears as data. Both registered rows must stay
|
|
alive with herdr_confirmed intact, so drift A cannot mark them terminated."""
|
|
env = _install_fake_herdr(mam_sandbox)
|
|
_fake_agent_list(mam_sandbox, f"print({json.dumps(json.dumps(LISTING_WITH_SUBSTRING))})")
|
|
rows = "".join(
|
|
f" - name: {name}\n"
|
|
" status: running\n"
|
|
" herdr_session: live-srv\n"
|
|
" pane:\n"
|
|
" cwd: /tmp/nonexistent-workspace\n"
|
|
for name in ("server_not_running-codex", "creator-claude-01")
|
|
)
|
|
(mam_sandbox / ".mam" / "agent-sessions.yaml").write_text("herdr_sessions:\n" + rows)
|
|
reconcile = mam_sandbox / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
|
|
|
|
res = subprocess.run(["bash", str(reconcile), "--once", "--emit-diff", "--dry-run"],
|
|
capture_output=True, text=True, cwd=str(mam_sandbox), env=env, timeout=120)
|
|
assert res.returncode == 0, res.stderr
|
|
|
|
report = json.loads(res.stdout)
|
|
assert report["herdr_confirmed"] is True, report
|
|
alive = set(report["herdr_sessions_alive"])
|
|
assert {"server_not_running-codex|live-srv", "creator-claude-01|live-srv"} <= alive, alive
|
|
drift_a = [d for d in report["drifts"] if d.get("class") == "A"]
|
|
assert not drift_a, drift_a
|