fix(issue-9,14): sanitize herdr server env and confirm accepted prompt delivery
- Drop per-session agent runtime markers (CLAUDECODE, CLAUDE_CODE_CHILD_SESSION, CLAUDE_CODE_SESSION_ID, Claude Code messaging/bridge/pid markers, and the agy/hermes/grok/opencode session ids) from the environment lib.sh's shim hands to a bootstrapped `herdr --session <name> server`; user configuration such as CLAUDE_PROJECT_DIR still inherits - Add MAM_NO_AUTOSTART=1 so query-only callers never bootstrap a server as a side effect; status.sh and reconcile.sh export it, and the shim's `ls` reports a down server as "no server running" so reconcile keeps herdr_confirmed instead of freezing drift detection for every server - send_keys_safe: treat `timeout` / `agent_prompt_stalled` from `agent prompt --wait` as an accepted submission and confirm delivery for SKS_DELIVERY_GRACE_S (default 15 s) via agent_status working, a busy token the pane did not show before, or the pane moving on from its pre-submit snapshot; no signal at all still fails closed (rc 4). Codex under herdr 0.9.0 never reports `working`, so every delivered prompt was closed as agent_blocked and its later started/completed events refused - delegate wrapper: before closing an unconfirmed inject as agent_blocked, honour the registry's own acknowledgement for MAM_INJECT_ACK_GRACE_S (default 10 s) - Switch T1-7's failure trigger to transport_error (timeout now means accepted); add test_issue9_server_env.py (5 cases) and test_issue14_delivery_ack.py (7) - Verified live: a freshly bootstrapped multi-agent-mux server carries no markers and the claude panes save transcripts again; codex job d04ebcb5 had its started/completed accepted with no agent_blocked Closes #9 Closes #14 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
This commit is contained in:
1 parent
4d444ce6cd
commit
497633f64a
9 files changed
+551
-18
No files matched your search
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Issue #14 — a prompt that herdr accepted must not be reported as undelivered.
|
||||
|
||||
`herdr agent prompt --wait --until working --timeout 5000` answers `timeout` (or
|
||||
`agent_prompt_stalled`) when the input was sent but no `working` state was
|
||||
observed afterwards. Agents whose agent_status never leaves `idle` mid-turn
|
||||
(codex under herdr 0.9.0) therefore had every delivered prompt closed as
|
||||
`agent_blocked`, and their own `started` / `completed` events, published seconds
|
||||
later, were refused as late. send_keys_safe now confirms an accepted submission
|
||||
through the pane, and the delegate wrapper additionally honours the registry's
|
||||
own acknowledgement before finalising a job as an inject failure.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SKILLS = REPO_ROOT / ".agents" / "skills"
|
||||
LIB_SH = SKILLS / "lib.sh"
|
||||
WRAPPER = SKILLS / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
|
||||
REGISTRY_PY = SKILLS / "multi-agent-mux-delegate-job" / "scripts" / "registry.py"
|
||||
|
||||
TIMEOUT_JSON = '{"error":{"code":"timeout","message":"timed out waiting for agent status"},"id":"cli:agent:prompt"}'
|
||||
IDLE_JSON = '{"result":{"agent":{"agent_status":"idle"}}}'
|
||||
|
||||
|
||||
def _base_env(cwd: Path, extra=None) -> dict:
|
||||
env = dict(os.environ)
|
||||
env["DELEGATE_JOB_PYTHON"] = sys.executable
|
||||
env["AGENT_PYTHON_BIN"] = sys.executable
|
||||
env["WORKSPACE_ROOT"] = str(cwd)
|
||||
env["HOME"] = str(cwd)
|
||||
if extra:
|
||||
env.update({k: str(v) for k, v in extra.items()})
|
||||
return env
|
||||
|
||||
|
||||
def _run_lib_sh(snippet: str, cwd: Path, env=None, timeout=60) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["bash", "-c", f'source "{LIB_SH}" >/dev/null 2>&1; {snippet}'],
|
||||
capture_output=True, text=True, cwd=str(cwd), env=_base_env(cwd, env), timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def _codex_like_mock(pre_pane: str, post_pane: str, prompt_code: str = "timeout") -> str:
|
||||
"""A bash `_sks_herdr` stand-in replaying what herdr 0.9.0 shows for codex:
|
||||
the RPC returns `timeout`, agent_status stays `idle`, and only the pane
|
||||
reveals what happened. Captures switch from PRE to POST once the prompt
|
||||
has been submitted. Any paste-buffer use is a contract violation."""
|
||||
return f'''
|
||||
_pane_quiescent() {{ return 0; }}
|
||||
_pane_dialog_open() {{ return 1; }}
|
||||
FLAG="$WORKSPACE_ROOT/.prompt-submitted"
|
||||
rm -f "$FLAG"
|
||||
_sks_herdr() {{
|
||||
case "${{1:-}}" in
|
||||
agent)
|
||||
case "${{2:-}}" in
|
||||
prompt) touch "$FLAG"; echo '{{"error":{{"code":"{prompt_code}","message":"x"}}}}' >&2; return 1 ;;
|
||||
get) echo '{IDLE_JSON}'; return 0 ;;
|
||||
esac ;;
|
||||
capture-pane)
|
||||
if [ -e "$FLAG" ]; then printf '%s\\n' "{post_pane}"; else printf '%s\\n' "{pre_pane}"; fi
|
||||
return 0 ;;
|
||||
paste-buffer|set-buffer|delete-buffer|send-keys)
|
||||
echo "PASTE_PATH_USED: $*" >&2; return 0 ;;
|
||||
esac
|
||||
return 0
|
||||
}}
|
||||
'''
|
||||
|
||||
|
||||
CODEX_IDLE = "identified.\\n› Ask Codex to do anything"
|
||||
CODEX_WORKING = "Working (0s • esc to interrupt)\\n› Ask Codex to do anything"
|
||||
CODEX_ANSWERED = "• OK\\n› Ask Codex to do anything"
|
||||
|
||||
|
||||
def _assert_single_injection(res: subprocess.CompletedProcess):
|
||||
assert "PASTE_PATH_USED" not in res.stderr, f"must not fall back to paste-buffer: {res.stderr}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# lib.sh: accepted submission confirmed through the pane
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_accepted_timeout_confirmed_by_busy_token(tmp_path):
|
||||
script = _codex_like_mock(CODEX_IDLE, CODEX_WORKING) + \
|
||||
'send_keys_safe "reviewer-codex-01" "review the triage report" "job_14a"'
|
||||
res = _run_lib_sh(script, tmp_path, env={"SKS_DELIVERY_GRACE_S": "5"})
|
||||
assert res.returncode == 0, f"rc={res.returncode}\n{res.stderr}"
|
||||
_assert_single_injection(res)
|
||||
assert "delivery unknown" not in res.stderr
|
||||
|
||||
|
||||
def test_accepted_timeout_confirmed_by_pane_change_after_fast_turn(tmp_path):
|
||||
"""A turn that already finished before the first poll shows no busy token,
|
||||
only the answer; the pane moving on from its pre-submit snapshot is enough."""
|
||||
script = _codex_like_mock(CODEX_IDLE, CODEX_ANSWERED) + \
|
||||
'send_keys_safe "reviewer-codex-01" "Reply with OK" "job_14b"'
|
||||
res = _run_lib_sh(script, tmp_path, env={"SKS_DELIVERY_GRACE_S": "5"})
|
||||
assert res.returncode == 0, f"rc={res.returncode}\n{res.stderr}"
|
||||
_assert_single_injection(res)
|
||||
|
||||
|
||||
def test_accepted_timeout_with_no_signal_still_fails_closed(tmp_path):
|
||||
"""Nothing moves, nothing works: the grace window expires into rc=4 exactly
|
||||
as before, without ever re-submitting."""
|
||||
script = _codex_like_mock(CODEX_IDLE, CODEX_IDLE) + \
|
||||
'send_keys_safe "reviewer-codex-01" "silent prompt" "job_14c"'
|
||||
started = time.time()
|
||||
res = _run_lib_sh(script, tmp_path, env={"SKS_DELIVERY_GRACE_S": "2"})
|
||||
elapsed = time.time() - started
|
||||
assert res.returncode == 4, f"rc={res.returncode}\n{res.stderr}"
|
||||
assert "delivery unknown/failed" in res.stderr
|
||||
_assert_single_injection(res)
|
||||
assert 2 <= elapsed < 20, f"grace window not honoured: {elapsed:.1f}s"
|
||||
|
||||
|
||||
def test_pre_existing_busy_token_is_not_taken_as_delivery(tmp_path):
|
||||
"""A busy token that was already on screen before the prompt is a leftover
|
||||
of the previous turn, not evidence for this one."""
|
||||
script = _codex_like_mock(CODEX_WORKING, CODEX_WORKING) + \
|
||||
'send_keys_safe "reviewer-codex-01" "prompt into a busy pane" "job_14d"'
|
||||
res = _run_lib_sh(script, tmp_path, env={"SKS_DELIVERY_GRACE_S": "2"})
|
||||
assert res.returncode == 4, f"rc={res.returncode}\n{res.stderr}"
|
||||
_assert_single_injection(res)
|
||||
|
||||
|
||||
def test_transport_error_is_not_treated_as_accepted(tmp_path):
|
||||
"""Only `timeout` / `agent_prompt_stalled` mean the input was sent. A
|
||||
transport-level failure keeps the single 2 s probe and rc=4, even if the
|
||||
pane happens to change meanwhile (T2-13 contract, pinned with pane noise)."""
|
||||
script = _codex_like_mock(CODEX_IDLE, CODEX_WORKING, prompt_code="transport_error") + \
|
||||
'send_keys_safe "reviewer-codex-01" "prompt" "job_14e"'
|
||||
started = time.time()
|
||||
res = _run_lib_sh(script, tmp_path, env={"SKS_DELIVERY_GRACE_S": "30"})
|
||||
elapsed = time.time() - started
|
||||
assert res.returncode == 4, f"rc={res.returncode}\n{res.stderr}"
|
||||
_assert_single_injection(res)
|
||||
assert elapsed < 15, f"no grace poll may run for a non-accepted code: {elapsed:.1f}s"
|
||||
|
||||
|
||||
def test_mock_herdr_timeout_with_idle_status_is_confirmed(tmp_path, mock_herdr):
|
||||
"""Same scenario through the shared mock herdr: `timeout` from agent prompt,
|
||||
agent_status pinned to idle, the pane gaining the busy token after
|
||||
submission. Exactly one prompt call, no paste-buffer fallback."""
|
||||
state_file = Path(mock_herdr)
|
||||
state = json.loads(state_file.read_text())
|
||||
state["agents"]["agt_codex"] = {
|
||||
"agent": "codex",
|
||||
"agent_status": "idle",
|
||||
"prompt_wait_result": "timeout",
|
||||
"buffer": "Ready",
|
||||
}
|
||||
state_file.write_text(json.dumps(state, indent=2))
|
||||
|
||||
res = _run_lib_sh(
|
||||
'send_keys_safe "agt_codex" "review please" "job_14f"',
|
||||
tmp_path, env={"SKS_IDLE_HANDSHAKE": "0", "SKS_DELIVERY_GRACE_S": "5"},
|
||||
)
|
||||
assert res.returncode == 0, f"rc={res.returncode}\n{res.stderr}"
|
||||
|
||||
final_state = json.loads(state_file.read_text())
|
||||
calls = final_state.get("calls", [])
|
||||
prompt_calls = [c for c in calls if len(c) >= 2 and c[0] == "agent" and c[1] == "prompt"]
|
||||
paste_calls = [c for c in calls if len(c) >= 2 and (c[0] in ("paste-buffer", "set-buffer") or c[1] in ("paste-buffer", "set-buffer"))]
|
||||
assert len(prompt_calls) == 1, f"prompt must be submitted exactly once, got {len(prompt_calls)}"
|
||||
assert not paste_calls, f"no paste-buffer fallback allowed: {paste_calls}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# wrapper: the registry's own acknowledgement beats an unconfirmed inject
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_wrapper_honours_registry_ack_over_unconfirmed_inject(tmp_path, mock_herdr):
|
||||
"""The RPC fails outright, but the agent has already published `started`
|
||||
(status running) by the time the wrapper decides: the job must not be
|
||||
closed as agent_blocked. With the broker unreachable the wrapper then
|
||||
follows its normal broker_unavailable path (rc=3), never the inject path."""
|
||||
reg_dir = tmp_path / "jobs"
|
||||
reg_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
state_file = Path(mock_herdr)
|
||||
state = json.loads(state_file.read_text())
|
||||
state["agents"]["ack-agent"] = {
|
||||
"agent": "codex",
|
||||
"agent_status": "idle",
|
||||
"prompt_wait_result": "transport_error",
|
||||
}
|
||||
state["prompt_wait_result"] = "transport_error"
|
||||
state_file.write_text(json.dumps(state, indent=2))
|
||||
|
||||
env = _base_env(tmp_path, {
|
||||
"MQTT_HOST": "127.0.0.1", "MQTT_PORT": "65534",
|
||||
"SKS_IDLE_TIMEOUT_MS": "500", "MAM_INJECT_ACK_GRACE_S": "8",
|
||||
})
|
||||
proc = subprocess.Popen(
|
||||
[
|
||||
"bash", str(WRAPPER), "submit",
|
||||
"--agent", "codex",
|
||||
"--agent-session", "herdr:ack-agent",
|
||||
"--prompt", "issue 14 ack",
|
||||
"--registry-dir", str(reg_dir),
|
||||
"--workdir", str(tmp_path),
|
||||
"--timeout", "6",
|
||||
"--idle-timeout", "2",
|
||||
],
|
||||
cwd=str(tmp_path), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||
)
|
||||
|
||||
jid = ""
|
||||
for _ in range(100):
|
||||
job_files = list(reg_dir.glob("*.json"))
|
||||
if job_files:
|
||||
jid = job_files[0].stem
|
||||
break
|
||||
time.sleep(0.1)
|
||||
assert jid, "job was not registered"
|
||||
|
||||
# The agent acknowledges the job out of band, as codex does via publish_event.
|
||||
ack = subprocess.run(
|
||||
[sys.executable, str(REGISTRY_PY), "--registry-dir", str(reg_dir),
|
||||
"update", "--job", jid, "--status", "running"],
|
||||
capture_output=True, text=True, cwd=str(tmp_path), env=env,
|
||||
)
|
||||
assert ack.returncode == 0, ack.stderr
|
||||
|
||||
stdout, stderr = proc.communicate(timeout=90)
|
||||
assert "acknowledged it; continuing" in stderr, f"ack guard did not fire:\n{stderr}\n{stdout}"
|
||||
assert "프롬프트 주입 실패" not in stderr
|
||||
assert proc.returncode == 3, f"expected the broker_unavailable path (rc=3), got {proc.returncode}\n{stderr}"
|
||||
|
||||
events_log = reg_dir / f"{jid}.events.log"
|
||||
if not events_log.exists():
|
||||
events_log = reg_dir / jid / "events.log"
|
||||
if events_log.exists():
|
||||
assert "agent_blocked" not in events_log.read_text(), events_log.read_text()
|
||||
job_data = json.loads((reg_dir / f"{jid}.json").read_text())
|
||||
assert job_data.get("status") != "pending"
|
||||
@@ -259,12 +259,15 @@ def test_t1_7_custom_registry_dir_inject_failure(tmp_path, mock_herdr):
|
||||
# Set up mock herdr with an agent, but prompt will fail
|
||||
state_file = Path(mock_herdr)
|
||||
state = json.loads(state_file.read_text())
|
||||
# A transport-level RPC failure is a definite inject failure. (`timeout` is
|
||||
# not: since Issue #14 it means herdr accepted the submission and only its
|
||||
# status watch timed out, which send_keys_safe then confirms via the pane.)
|
||||
state["agents"]["fail-agent"] = {
|
||||
"agent": "claude",
|
||||
"agent_status": "idle",
|
||||
"prompt_wait_result": "timeout", # will fail prompt
|
||||
"prompt_wait_result": "transport_error", # will fail prompt
|
||||
}
|
||||
state["prompt_wait_result"] = "timeout"
|
||||
state["prompt_wait_result"] = "transport_error"
|
||||
state_file.write_text(json.dumps(state, indent=2))
|
||||
|
||||
res = _run_wrapper(
|
||||
@@ -278,7 +281,7 @@ def test_t1_7_custom_registry_dir_inject_failure(tmp_path, mock_herdr):
|
||||
"--timeout", "15",
|
||||
"--idle-timeout", "10",
|
||||
],
|
||||
env={"SKS_BUSY_TIMEOUT": "1", "SKS_IDLE_TIMEOUT_MS": "500"},
|
||||
env={"SKS_BUSY_TIMEOUT": "1", "SKS_IDLE_TIMEOUT_MS": "500", "MAM_INJECT_ACK_GRACE_S": "1"},
|
||||
cwd=tmp_path,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/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
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
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 _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 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"
|
||||
|
||||
|
||||
def test_ls_reports_down_server_as_no_server_running(mam_sandbox):
|
||||
"""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 = mam_sandbox / "fakebin" / "herdr"
|
||||
fake.write_text(FAKE_HERDR.replace(
|
||||
'print(json.dumps({"result": {"agents": []}}))',
|
||||
'sys.stderr.write(json.dumps({"error": {"code": "server_not_running"}}) + "\\n"); sys.exit(1)',
|
||||
))
|
||||
lib = mam_sandbox / ".agents" / "skills" / "lib.sh"
|
||||
res = subprocess.run(
|
||||
["bash", "-c", f'source "{lib}" >/dev/null 2>&1; herdr -L down-srv ls -F "#{{session_name}}|#{{session_created}}"'],
|
||||
capture_output=True, text=True, cwd=str(mam_sandbox), env=env, timeout=60,
|
||||
)
|
||||
assert res.returncode != 0
|
||||
assert "no server running" in res.stderr, res.stderr
|
||||
assert res.stdout.strip() == ""
|
||||
Reference in new issue
Block a user