Three `create --onboard` at once into one folder (the control root, so no control-plane prefix reaches the pane and the shim reuses an idle pane): the shim's new-session chooses a pane - an idle one in a workspace on the same cwd, a split, a fresh workspace's root pane - and then claims it with `agent start`; two shims could choose the pane one of them had just made, and the later start failed with agent_pane_busy (one create in three). mam_herdr now runs every new-session call holding one lock per server: a flock on fd 9, opened for the block and taken by lib_py.pane_lock, which also bounds the wait (MAM_PANE_CLAIM_WAIT_S, default 120). It is released with the block or with the process, however it ends. The shim heredoc is unchanged (S11). Other servers' creates are not held. Serialising the claims exposed a second race: a create's end-of-run `reconcile.sh --once` adopted the pane of another create that had not yet written its row, and that create refused its own name as already running (exit 4). Such a row - registered by the monitor, no job, for a pane created after this create began - is now replaced, not refused. Tests: real herdr - shim-level and create-level three-way races into one folder (the real herdr's pane-creating replies held back so the window is hit on every run; both fail on the base with the orchestrator's error), the different-folder test kept; pane_lock unit tests (per-server file, timeout, kill -9 holder, handover, knob); the adoption rule and its three refusals; shim contract h26 (held lock: no pane calls). The herdr box gets a ~/.zshrc: without it every first `agent start` typed into zsh's first-run menu and timed out at 30 s. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
641 lines
27 KiB
Python
641 lines
27 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}"
|
|
)
|
|
|
|
|
|
|
|
def test_h26_new_session_claims_its_pane_under_the_servers_lock(mam_sandbox, mock_herdr, mock_agents):
|
|
"""Issue #50 follow-up: from the pane choice to `agent start` the shim holds
|
|
one lock per herdr server (lib_py.pane_lock, a flock on fd 9). While
|
|
another shim holds it, this one waits MAM_PANE_CLAIM_WAIT_S and then gives
|
|
up without creating, splitting or starting anything; once it is free the
|
|
same call goes through as before."""
|
|
import glob
|
|
import os
|
|
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, res.stderr
|
|
locks = glob.glob(str(tmp_path / ".local" / "state" / "mam" / "pane-lock" / "*.lock"))
|
|
assert len(locks) == 1, locks # one file per server, under the sandbox HOME
|
|
n_calls = len(_load_mock_state(mock_herdr).get("calls", []))
|
|
holder = subprocess.Popen(["bash", "-c", 'exec 9>>"$1"; python3 -m lib_py.pane_lock acquire --fd 9; sleep 600',
|
|
"holder", locks[0]], stdout=subprocess.PIPE, text=True,
|
|
env=dict(os.environ, PYTHONPATH=str(tmp_path / ".agents" / "skills")))
|
|
try:
|
|
assert holder.stdout.readline().strip() == "locked"
|
|
os.environ["MAM_PANE_CLAIM_WAIT_S"] = "1"
|
|
res = _run_lib(tmp_path, f'herdr new-session -d -s "test-reviewer-claude" -c "{tmp_path}" "claude --dangerously-skip-permissions"')
|
|
assert res.returncode == 1 and "did not end within MAM_PANE_CLAIM_WAIT_S" in res.stderr, (res.returncode, res.stderr)
|
|
new_calls = [c[2:] if c[:1] == ["--session"] else c for c in _load_mock_state(mock_herdr).get("calls", [])[n_calls:]]
|
|
assert not [c for c in new_calls if c[:2] in (["workspace", "create"], ["pane", "split"], ["agent", "start"])], new_calls
|
|
assert not [n for n in _load_mock_state(mock_herdr).get("agents", {}) if "test-reviewer-claude" in n]
|
|
finally:
|
|
os.environ.pop("MAM_PANE_CLAIM_WAIT_S", None)
|
|
holder.kill()
|
|
holder.wait()
|
|
res = _run_lib(tmp_path, f'herdr new-session -d -s "test-reviewer-claude" -c "{tmp_path}" "claude --dangerously-skip-permissions"')
|
|
assert res.returncode == 0, res.stderr
|
|
assert [n for n in _load_mock_state(mock_herdr).get("agents", {}) if "test-reviewer-claude" in n]
|