Files
multi-agent-mux/tests/test_herdr_shim_contract.py
T
GodopuandClaude Opus 5 13259edb06 test(issue-21): h25 pins the revised env contract - with env flags the idle pane is skipped and the env rides on the pane-creation call
Review N1 (planner-reviewer-claude-01 50ae9fb0, planner-reviewer-codex-01
8779f4b4): af8a54c (R1) stopped reusing an idle pane when env flags are
present, because the old path put `env K=V ...` after `herdr agent start ...
--`, where herdr passes it to the agent executable as arguments. h25 still
required that old behaviour (idle pane reused, env in the agent command) and
failed. It now asserts the new contract instead: a pane split / workspace
create carries MY_TEST_FLAG=active and HERDR_WORKSPACE_ID as --env, the agent
does not land on the idle pane, and nothing env-like follows `--` in agent
start. Finding A's intent (env must not be silently lost) is kept; reuse
without env flags stays pinned by h24. No product code changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
2026-09-14 05:45:12 +00:00

607 lines
24 KiB
Python

import os
import sys
import json
import re
import subprocess
import pytest
import shutil
from pathlib import Path
def test_h1_to_h8_shim_contract(mam_sandbox, mock_herdr, mock_agents):
"""
Phase 3 Contract Tests (H-1 to H-8):
Verifies that lib.sh / herdr shim complies strictly with herdr 0.7.4 CLI contract:
- H-1: Only allowed flags (--cwd, --workspace, --tab, --split, --env, --focus, --no-focus) passed to agent start.
- H-2: New workspace ID is passed to agent start --workspace.
- H-3: Reuse path does not pre-call pane split.
- H-4: Preserves absolute path of executable after --.
- H-5: Immediate abort on usage error.
- H-6: Max 3 retries on transient errors.
- H-7: Env flags formatted correctly.
- H-8: Explicit error on herdr failure.
"""
tmp_path = mam_sandbox
lib_path = tmp_path / ".agents" / "skills" / "lib.sh"
# Test H-1 & H-4: Allowed flags and 0.8.0 contract
cmd_str = f"""
source {lib_path}
_init_herdr_isolation
herdr new-session -d -s "test-h1-creator-claude" -c "{tmp_path}" "claude --dangerously-skip-permissions"
"""
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True, cwd=str(tmp_path))
assert res.returncode == 0, f"Stderr: {res.stderr}\nStdout: {res.stdout}"
# Read state file to verify calls
with open(mock_herdr, 'r') as f:
state = json.load(f)
calls = state.get("calls", [])
agent_start_calls = [c for c in calls if len(c) > 1 and c[0] == "agent" and c[1] == "start"]
assert len(agent_start_calls) > 0
whitelist = {"--kind", "--pane", "--timeout"}
forbidden = {"--cwd", "--workspace", "--tab", "--split", "--env", "--focus", "--no-focus"}
for call in agent_start_calls:
try:
dd_idx = call.index("--")
opts = call[3:dd_idx]
argv = call[dd_idx+1:]
except ValueError:
opts = call[3:]
argv = []
# H-1: Check only 0.8.0 allowed flags are present and forbidden flags are absent
for i in range(len(opts)):
if opts[i].startswith("--"):
assert opts[i] in whitelist, f"Unexpected flag in agent start: {opts[i]}"
assert opts[i] not in forbidden, f"Forbidden 0.7.4 flag in agent start: {opts[i]}"
assert "--kind" in opts, "Missing required --kind in agent start"
assert "--pane" in opts, "Missing required --pane in agent start"
def test_h9_mock_response_contract_schema(mock_herdr):
"""H-9: Verify mock_herdr responses match tests/fixtures/herdr_contract.json schema."""
contract_path = Path(__file__).parent / "fixtures" / "herdr_contract.json"
assert contract_path.exists()
with open(contract_path, 'r') as f:
contract = json.load(f)
assert "WorkspaceInfo" in contract
assert "PaneInfo" in contract
assert "AgentStartFlags" in contract
def test_h10_real_herdr_schema_match():
"""H-10: Skip if herdr CLI missing; otherwise verify real schema matches fixture."""
herdr_bin = shutil.which("herdr")
if not herdr_bin:
pytest.skip("Real herdr binary not found in PATH")
res = subprocess.run([herdr_bin, "api", "schema"], capture_output=True, text=True)
if res.returncode == 0 and res.stdout.strip():
try:
schema = json.loads(res.stdout)
assert "WorkspaceInfo" in schema or "definitions" in schema or True
except Exception:
pass
def test_h11_to_h13_layout_policy(mam_sandbox, mock_herdr, mock_agents):
"""
H-11, H-12, H-13: Layout Policy Tests (W2a & W2b):
- H-11: Wide anchor -> --split right; Tall anchor -> --split down.
- H-12: Overflow threshold -> fresh workspace without --split.
- H-13: MAM_MIN_PANE_COLS / MAM_MIN_PANE_ROWS environment variables override thresholds.
"""
tmp_path = mam_sandbox
lib_path = tmp_path / ".agents" / "skills" / "lib.sh"
cmd_str = f"""
source {lib_path}
_init_herdr_isolation
export MAM_MIN_PANE_COLS=60
export MAM_MIN_PANE_ROWS=20
herdr new-session -d -s "test-h11-creator-claude" -c "{tmp_path}" "claude --dangerously-skip-permissions"
"""
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True, cwd=str(tmp_path))
assert res.returncode == 0, f"Stderr: {res.stderr}"
def test_h14_mock_concurrency_lock_invariant(mock_herdr):
"""H-14: Verify lock invariant under multi-process concurrent mock_herdr updates."""
mock_state = mock_herdr
code = f"""
import subprocess
procs = []
for i in range(10):
p = subprocess.Popen(["python3", "{mock_state.parent / 'bin' / 'herdr'}", "agent", "start", f"agent_{{i}}", "--kind", "claude", "--pane", f"w1:p{{i}}"])
procs.append(p)
for p in procs:
p.wait()
"""
res = subprocess.run(["python3", "-c", code], capture_output=True, text=True)
assert res.returncode == 0
with open(mock_state, 'r') as f:
final_state = json.load(f)
# Verify all 10 agents created without state overwrite loss
assert len(final_state.get("agents", {})) == 10
def _load_mock_state(mock_herdr):
with open(mock_herdr, "r") as f:
return json.load(f)
def _write_mock_state(mock_herdr, state):
with open(mock_herdr, "w") as f:
json.dump(state, f, indent=2)
def _run_lib(tmp_path, body):
lib_path = tmp_path / ".agents" / "skills" / "lib.sh"
script = f"""
unset HERDR_WORKSPACE_ID
source "{lib_path}"
_init_herdr_isolation
{body}
"""
return subprocess.run(
["bash", "-c", script],
capture_output=True,
text=True,
cwd=str(tmp_path),
)
def _case_arm(shim: str, name: str) -> str:
marker = f"\n {name})"
start = shim.find(marker)
assert start != -1, f"case arm {name} not found"
rest = shim[start + len(marker):]
nxt = re.search(r"\n [A-Za-z][A-Za-z0-9-]*\)", rest)
end = start + len(marker) + nxt.start() if nxt else len(shim)
return shim[start:end]
def test_h15_paste_buffer_inserts_without_enter(mam_sandbox, mock_herdr, mock_agents):
"""H-15 (ISSUE-1): paste-buffer inserts via pane send-text and never submits."""
tmp_path = mam_sandbox
res = _run_lib(
tmp_path,
f'herdr new-session -d -s "test-creator-claude" -c "{tmp_path}" "claude --dangerously-skip-permissions"',
)
assert res.returncode == 0, f"Stderr: {res.stderr}\nStdout: {res.stdout}"
state = _load_mock_state(mock_herdr)
agents = state.get("agents", {})
assert agents, "expected agent after new-session"
pane_id = next(iter(agents.values()))["pane_id"]
n_calls = len(state.get("calls", []))
res = _run_lib(
tmp_path,
"""
herdr set-buffer -b t1 "hello world"
herdr paste-buffer -b t1 -t test-creator-claude
""",
)
assert res.returncode == 0, f"Stderr: {res.stderr}\nStdout: {res.stdout}"
new_calls = _load_mock_state(mock_herdr).get("calls", [])[n_calls:]
send_text = [c for c in new_calls if len(c) >= 2 and c[0] == "pane" and c[1] == "send-text"]
assert send_text == [["pane", "send-text", pane_id, "hello world"]], new_calls
assert not any(len(c) >= 2 and c[0] == "agent" and c[1] == "send" for c in new_calls)
submit = [
c for c in new_calls
if len(c) >= 2 and (
(c[0] == "pane" and c[1] == "send-keys")
or (c[0] == "agent" and c[1] == "prompt")
or (c[0] == "pane" and c[1] == "run")
)
]
assert submit == [], new_calls
def test_h16_send_keys_safe_submits_exactly_once(mam_sandbox, mock_herdr, mock_agents):
"""H-16 (ISSUE-1): fallback paste path submits Enter/C-m exactly once."""
tmp_path = mam_sandbox
state = _load_mock_state(mock_herdr)
state["panes"] = [{
"pane_id": "w1E:p1",
"workspace_id": "w1E",
"label": "label-only-sess",
"buffer": "Ready",
"cwd": str(tmp_path),
}]
_write_mock_state(mock_herdr, state)
n_calls = len(state.get("calls", []))
res = _run_lib(
tmp_path,
"""
sleep() { :; }
send_keys_safe "label-only-sess" "hello unique marker 12345" "job-h16"
echo "RC=$?"
""",
)
assert res.returncode == 0, f"Stderr: {res.stderr}\nStdout: {res.stdout}"
assert "RC=0" in res.stdout, res.stdout + res.stderr
new_calls = _load_mock_state(mock_herdr).get("calls", [])[n_calls:]
enters = [
c for c in new_calls
if any(tok in ("Enter", "C-m") for tok in c)
and not (len(c) >= 2 and c[0] == "pane" and c[1] == "send-text")
]
assert len(enters) == 1, new_calls
assert any(len(c) >= 2 and c[0] == "pane" and c[1] == "send-text" for c in new_calls)
def test_h17_no_substring_cross_pane_routing(mam_sandbox, mock_herdr, mock_agents):
"""H-17 (ISSUE-2): session names must not substring-match agent CLI kinds."""
tmp_path = mam_sandbox
state = _load_mock_state(mock_herdr)
state["panes"] = [{
"pane_id": "w1:p99",
"workspace_id": "w1",
"agent": "grok",
"buffer": "Ready",
}]
_write_mock_state(mock_herdr, state)
res = _run_lib(tmp_path, 'herdr has-session -t reviewer-creator-grok-01')
assert res.returncode == 1, f"substring match leaked into has-session: {res.stderr}"
res = _run_lib(
tmp_path,
f"""
herdr new-session -d -s "reviewer-creator-grok-01" -c "{tmp_path}" "grok"
herdr new-session -d -s "worker-grok-02" -c "{tmp_path}" "grok"
""",
)
assert res.returncode == 0, f"Stderr: {res.stderr}\nStdout: {res.stdout}"
state = _load_mock_state(mock_herdr)
agents = state.get("agents", {})
target_pane = None
other_pane = None
for name, data in agents.items():
if "reviewer-creator-grok" in name:
target_pane = data.get("pane_id")
if "worker-grok" in name:
other_pane = data.get("pane_id")
assert target_pane and other_pane and target_pane != other_pane, agents
n_calls = len(state.get("calls", []))
res = _run_lib(tmp_path, 'herdr send-keys -t reviewer-creator-grok-01 C-m')
assert res.returncode == 0, res.stderr
new_calls = _load_mock_state(mock_herdr).get("calls", [])[n_calls:]
sendkeys = [c for c in new_calls if len(c) >= 4 and c[0] == "pane" and c[1] == "send-keys"]
assert any(c[2] == target_pane for c in sendkeys), new_calls
assert not any(c[2] == other_pane for c in sendkeys), new_calls
def test_h18_workspace_scoped_pane_resolution(mam_sandbox, mock_herdr, mock_agents):
"""H-18 (ISSUE-3): HERDR_WORKSPACE_ID scopes pane resolution; unset keeps global lookup."""
tmp_path = mam_sandbox
state = _load_mock_state(mock_herdr)
state["panes"] = [
{"pane_id": "w1:p10", "workspace_id": "w1", "label": "creator-agy-01", "buffer": "Ready"},
{"pane_id": "w2:p10", "workspace_id": "w2", "label": "creator-agy-01", "buffer": "Ready"},
]
_write_mock_state(mock_herdr, state)
def _send(ws):
n_calls = len(_load_mock_state(mock_herdr).get("calls", []))
export = f'export HERDR_WORKSPACE_ID="{ws}"\n' if ws else "unset HERDR_WORKSPACE_ID\n"
res = _run_lib(tmp_path, export + 'herdr send-keys -t creator-agy-01 Enter')
assert res.returncode == 0, res.stderr
calls = _load_mock_state(mock_herdr).get("calls", [])[n_calls:]
return [c for c in calls if len(c) >= 4 and c[0] == "pane" and c[1] == "send-keys"]
keyed_w2 = _send("w2")
assert keyed_w2 and keyed_w2[0][2] == "w2:p10", keyed_w2
keyed_w1 = _send("w1")
assert keyed_w1 and keyed_w1[0][2] == "w1:p10", keyed_w1
keyed_global = _send("")
assert keyed_global, "unset HERDR_WORKSPACE_ID must still resolve a pane"
assert keyed_global[0][2] in ("w1:p10", "w2:p10")
def test_h19_single_resolver_helper_used_by_all_branches(mam_sandbox, mock_herdr):
"""H-19 (ISSUE-5): one helper definition; five shim branches call it."""
tmp_path = mam_sandbox
res = _run_lib(tmp_path, ":")
assert res.returncode == 0, res.stderr
shim_path = tmp_path / ".mam" / "shim" / "herdr"
shim = shim_path.read_text()
assert shim.count("_resolve_herdr_pane_id()") == 1
for branch in ("has-session", "kill-session", "capture-pane", "send-keys", "paste-buffer"):
body = _case_arm(shim, branch)
assert "_resolve_herdr_pane_id" in body, branch
helper_start = shim.find("_herdr_ws_id_file() {")
helper_end = shim.find('cmd="${1:-}"', helper_start)
helper = shim[helper_start:helper_end]
list_arm = _case_arm(shim, "list-panes")
elsewhere = shim.replace(helper, "", 1).replace(list_arm, "", 1)
# Unscoped existence checks (agent get … >/dev/null) must not live in
# has-session / agent-prompt shortcuts — those skip workspace filters.
for branch in ("has-session", "kill-session", "capture-pane", "send-keys", "paste-buffer"):
body = _case_arm(shim, branch)
assert re.search(r'agent get "\$[^"]+" >/dev/null', body) is None, branch
agent_arm = _case_arm(shim, "agent")
assert "_herdr_agent_get_scoped" in agent_arm
assert re.search(r'agent get "\$sat" >/dev/null', agent_arm) is None
assert re.search(r'agent get "\$raw" >/dev/null', agent_arm) is None
assert "_herdr_agent_get_scoped()" in helper
assert elsewhere.count("_herdr_agent_get_scoped()") == 0
n_shim = subprocess.run(["bash", "-n", str(shim_path)], capture_output=True, text=True)
assert n_shim.returncode == 0, n_shim.stderr
n_lib = subprocess.run(
["bash", "-n", str(Path(__file__).resolve().parent.parent / ".agents" / "skills" / "lib.sh")],
capture_output=True,
text=True,
)
assert n_lib.returncode == 0, n_lib.stderr
def test_h20_pane_id_regex_accepts_alphanumeric_workspace(mam_sandbox, mock_herdr):
"""H-20: pane_id regex must accept w1E:p1, not only decimal workspace ids."""
tmp_path = mam_sandbox
res = _run_lib(tmp_path, ":")
assert res.returncode == 0, res.stderr
shim = (tmp_path / ".mam" / "shim" / "herdr").read_text()
start = shim.find("_herdr_ws_id_file() {")
end = shim.find('cmd="${1:-}"', start)
helper = shim[start:end]
script = r"""
set -euo pipefail
unset HERDR_WORKSPACE_ID
_sanitize_herdr_agent_name() { printf '%s\n' "${1:-agent}"; }
_real_herdr() {
if [ "$1" = "agent" ] && [ "$2" = "get" ]; then
python3 -c "import json,os; print(json.dumps({'result':{'agent':{'pane_id':os.environ.get('MOCK_PID',''),'workspace_id':'w1'}}}))"
return 0
fi
return 1
}
HELPER_PLACEHOLDER
check() {
export MOCK_PID="$1"
want="$2"
rc=0
out=$(_resolve_herdr_pane_id foo 2>/dev/null) || rc=$?
if [ "$want" = "ok" ]; then
[ "$out" = "$1" ] && [ "$rc" = "0" ] || { echo "ACCEPT_FAIL pid=$1 out=$out rc=$rc"; exit 1; }
else
[ -z "$out" ] && [ "$rc" != "0" ] || { echo "REJECT_FAIL pid=$1 out=$out rc=$rc"; exit 1; }
fi
}
check "w1E:p1" ok
check "w10:p3" ok
check "notapane" no
check "w1:p" no
check "" no
echo H20_OK
""".replace("HELPER_PLACEHOLDER", helper)
res = subprocess.run(["bash", "-c", script], capture_output=True, text=True)
assert res.returncode == 0, res.stderr + res.stdout
assert "H20_OK" in res.stdout
def _seed_named_agent(mock_herdr, tmp_path, name, workspace_id, pane_id):
state = _load_mock_state(mock_herdr)
agents = state.setdefault("agents", {})
agents[name] = {
"agent": "agy",
"status": "running",
"cwd": str(tmp_path),
"workspace_id": workspace_id,
"pane_id": pane_id,
"pid": 9999,
"command": "agy",
"buffer": "Ready",
}
_write_mock_state(mock_herdr, state)
def test_h21_has_session_agent_get_is_workspace_scoped(mam_sandbox, mock_herdr, mock_agents):
"""F-2b: has-session agent-get shortcut must honour HERDR_WORKSPACE_ID."""
tmp_path = mam_sandbox
_seed_named_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5")
res = _run_lib(tmp_path, 'export HERDR_WORKSPACE_ID=w2\nherdr has-session -t creator-agy-01')
assert res.returncode == 1, res.stderr + res.stdout
res = _run_lib(tmp_path, 'export HERDR_WORKSPACE_ID=w1\nherdr has-session -t creator-agy-01')
assert res.returncode == 0, res.stderr + res.stdout
res = _run_lib(tmp_path, 'unset HERDR_WORKSPACE_ID\nherdr has-session -t creator-agy-01')
assert res.returncode == 0, "unset scope must keep global has-session"
def test_h22_agent_prompt_does_not_cross_workspace(mam_sandbox, mock_herdr, mock_agents):
"""F-2b: agent prompt must not deliver into an out-of-scope same-named agent."""
tmp_path = mam_sandbox
_seed_named_agent(mock_herdr, tmp_path, "creator-agy-01", "w1", "w1:p5")
n_calls = len(_load_mock_state(mock_herdr).get("calls", []))
res = _run_lib(
tmp_path,
'export HERDR_WORKSPACE_ID=w2\nherdr agent prompt creator-agy-01 "hello from w2"',
)
assert res.returncode != 0, res.stdout + res.stderr
new_calls = _load_mock_state(mock_herdr).get("calls", [])[n_calls:]
prompts = [c for c in new_calls if len(c) >= 2 and c[0] == "agent" and c[1] == "prompt"]
assert prompts == [], new_calls
n_calls = len(_load_mock_state(mock_herdr).get("calls", []))
res = _run_lib(
tmp_path,
'export HERDR_WORKSPACE_ID=w1\nherdr agent prompt creator-agy-01 "hello from w1"',
)
assert res.returncode == 0, res.stderr + res.stdout
new_calls = _load_mock_state(mock_herdr).get("calls", [])[n_calls:]
prompts = [c for c in new_calls if len(c) >= 2 and c[0] == "agent" and c[1] == "prompt"]
assert any("hello from w1" in c for c in prompts), new_calls
def test_h23_persisted_workspace_id_scopes_without_env(mam_sandbox, mock_herdr, mock_agents):
"""F-2a: new-session persists ws id; later shim calls read it when env is unset."""
tmp_path = mam_sandbox
state = _load_mock_state(mock_herdr)
state["panes"] = [
{"pane_id": "w1:p10", "workspace_id": "w1", "label": "creator-agy-01", "buffer": "Ready"},
{"pane_id": "w2:p10", "workspace_id": "w2", "label": "creator-agy-01", "buffer": "Ready"},
]
_write_mock_state(mock_herdr, state)
ws_file = tmp_path / ".mam" / "herdr_workspace_id"
ws_file.parent.mkdir(parents=True, exist_ok=True)
ws_file.write_text("w2\n")
n_calls = len(_load_mock_state(mock_herdr).get("calls", []))
res = _run_lib(tmp_path, 'unset HERDR_WORKSPACE_ID\nherdr send-keys -t creator-agy-01 Enter')
assert res.returncode == 0, res.stderr
keyed = [
c for c in _load_mock_state(mock_herdr).get("calls", [])[n_calls:]
if len(c) >= 4 and c[0] == "pane" and c[1] == "send-keys"
]
assert keyed and keyed[0][2] == "w2:p10", keyed
res = _run_lib(
tmp_path,
f'herdr new-session -d -s "persist-creator-claude" -c "{tmp_path}" "claude --dangerously-skip-permissions"',
)
assert res.returncode == 0, res.stderr + res.stdout
persisted = ws_file.read_text().strip()
assert persisted, "new-session must persist workspace id"
assert persisted.startswith("w")
def test_h24_new_session_reuses_idle_pane_without_split(mam_sandbox, mock_herdr, mock_agents):
"""BUG-4: new-session into an existing workspace must reuse an already
idle (agent-less) pane instead of unconditionally splitting a new one."""
tmp_path = mam_sandbox
state = _load_mock_state(mock_herdr)
state["panes"] = [{
"pane_id": "w1:p_idle",
"workspace_id": "w1",
"cwd": str(tmp_path),
"buffer": "",
}]
_write_mock_state(mock_herdr, state)
n_calls = len(state.get("calls", []))
res = _run_lib(
tmp_path,
f'herdr new-session -d -s "test-creator-claude" -c "{tmp_path}" "claude --dangerously-skip-permissions"',
)
assert res.returncode == 0, f"Stderr: {res.stderr}\nStdout: {res.stdout}"
final_state = _load_mock_state(mock_herdr)
agents = final_state.get("agents", {})
matched = [d for n, d in agents.items() if "test-creator-claude" in n]
assert matched, agents
assert matched[0].get("pane_id") == "w1:p_idle", (
f"Expected the idle pane to be reused as-is, not a freshly split one: {matched}"
)
new_calls = final_state.get("calls", [])[n_calls:]
split_calls = [c for c in new_calls if len(c) >= 2 and c[0] == "pane" and c[1] == "split"]
assert not split_calls, f"Expected no pane split call when an idle pane was available: {split_calls}"
def test_h25_new_session_with_env_flags_skips_the_idle_pane_and_uses_env_on_split(mam_sandbox, mock_herdr, mock_agents):
"""Finding A, revised for Issue #21 R1: caller-provided KEY=VAL environment
flags (and HERDR_WORKSPACE_ID) must not be silently lost - and must not be
put after `agent start --` either, because herdr passes those tokens to the
agent executable (--kind) as arguments. With env flags an idle pane is not
reused; the env rides on the pane-creation call (pane split / workspace
create --env). Reuse without env flags stays covered by h24."""
tmp_path = mam_sandbox
state = _load_mock_state(mock_herdr)
state["panes"] = [{
"pane_id": "w1:p_idle",
"workspace_id": "w1",
"cwd": str(tmp_path),
"buffer": "",
}]
_write_mock_state(mock_herdr, state)
n_calls = len(state.get("calls", []))
res = _run_lib(
tmp_path,
f'herdr new-session -d -s "test-creator-claude" -c "{tmp_path}" "MY_TEST_FLAG=active claude --dangerously-skip-permissions"',
)
assert res.returncode == 0, f"Stderr: {res.stderr}\nStdout: {res.stdout}"
final_state = _load_mock_state(mock_herdr)
# the shim may prefix `--session <name>`; compare the subcommands themselves
new_calls = [c[2:] if c[:1] == ["--session"] else c for c in final_state.get("calls", [])[n_calls:]]
creates = [c for c in new_calls if c[:2] in (["pane", "split"], ["workspace", "create"])]
assert creates, f"expected a pane split / workspace create carrying the env: {new_calls}"
env_vals = [c[i + 1] for c in creates for i, t in enumerate(c[:-1]) if t == "--env"]
assert "MY_TEST_FLAG=active" in env_vals and "HERDR_WORKSPACE_ID=w1" in env_vals, env_vals
matched = [d for n, d in final_state.get("agents", {}).items() if "test-creator-claude" in n]
assert matched and matched[0].get("pane_id") != "w1:p_idle", matched
starts = [c for c in new_calls if c[:2] == ["agent", "start"]]
assert starts, new_calls
for c in starts:
args = c[c.index("--") + 1:] if "--" in c else []
assert "env" not in args, args
assert not any(a.startswith(("MY_TEST_FLAG=", "HERDR_WORKSPACE_ID=")) for a in args), args
def test_h26_daemon_spawn_uses_process_group_detachment(mam_sandbox, mock_herdr, mock_agents):
"""H-26 (Issue #3): the headless herdr daemon bootstrap must fully detach into
its own process group/session rather than relying on nohup+disown, which only
masks SIGHUP and drops bash job-table tracking - it does NOT change PGID/SID,
so the daemon previously died on any process-group signal broadcast."""
shim_path = mam_sandbox / ".mam" / "shim" / "herdr"
res = _run_lib(mam_sandbox, "true")
assert res.returncode == 0, res.stderr
shim = shim_path.read_text()
assert 'nohup "$REAL_HERDR" --session "$_MAM_SESSION" server' not in shim
assert "start_new_session=True" in shim
# Extract the actual shipped spawner statement (not a re-implementation) and
# run it standalone against a fake REAL_HERDR that just sleeps, to prove the
# spawned process ends up in a DIFFERENT process group than the caller shell.
start_marker = "_mam_server_pid=$(python3 -c '"
end_marker = "' \"$REAL_HERDR\" --session \"$_MAM_SESSION\" server)"
start = shim.index(start_marker)
end = shim.index(end_marker, start) + len(end_marker)
spawner_stmt = shim[start:end]
assert "start_new_session=True" in spawner_stmt
fake_herdr = mam_sandbox / "fake_herdr.sh"
fake_herdr.write_text("#!/usr/bin/env bash\nexec sleep 30\n")
fake_herdr.chmod(0o755)
script = f"""
set -euo pipefail
REAL_HERDR="{fake_herdr}"
_MAM_SESSION="testsess"
{spawner_stmt}
echo "PID=$_mam_server_pid"
echo "SHELL_PGID=$(ps -o pgid= -p $$ | tr -d ' ')"
sleep 0.3
echo "CHILD_PGID=$(ps -o pgid= -p $_mam_server_pid | tr -d ' ')"
kill "$_mam_server_pid" 2>/dev/null || true
"""
res2 = subprocess.run(["bash", "-c", script], capture_output=True, text=True)
assert res2.returncode == 0, f"stderr: {res2.stderr}\nstdout: {res2.stdout}"
fields = dict(line.split("=", 1) for line in res2.stdout.strip().splitlines())
assert fields["CHILD_PGID"] != fields["SHELL_PGID"], (
f"daemon child stayed in the caller's process group (not detached): {fields}"
)