From ee1d8966c174eac64c23a542a6e7b502ba3fc41f Mon Sep 17 00:00:00 2001 From: Godopu Date: Mon, 14 Sep 2026 03:34:48 +0000 Subject: [PATCH] feat(issue-21): persist and forward the control-plane binding in create/resume/stop; status and reconcile honor MAM_JOBS_DIR A session row now records the control plane it belongs to, and the pane gets it explicitly instead of relying on env inheritance. - lib_py/control_plane.py (+ thin lib.sh wrappers mam_control_plane_json / mam_control_plane_prefix): the binding is WORKSPACE_ROOT, AGENT_SESSIONS_YAML, MAM_JOBS_DIR, DELEGATE_JOB_LOGS_DIR as absolute paths. - create: the binding is always persisted on the row (`control_plane`) and forwarded to the pane only when it differs from what --workspace alone implies, as leading K=V tokens that lib.sh's new-session already turns into --env (fresh pane) or `env K=V` (reused idle pane). pane.cmd_full and start_command stay unprefixed. A binding with whitespace that would have to be forwarded is refused with exit 2 (lib.sh splits the flags unquoted); the ~/.local/bin wrapper path records the binding but warns it is not forwarded. - resume: the row's binding wins over the caller's env (a difference is reported on stderr) and is forwarded again; a legacy row without one uses the caller's binding and update_yaml_resumed.sh records it. - stop: registry order MAM_JOBS_DIR > the row's mam_jobs_dir > $WORKSPACE_ROOT/.mam/jobs > .mam/jobs, exported for delegate_publish_event. - status: both get_job_status copies look in MAM_JOBS_DIR, the row's registry and the matching audit logs before the old candidates. - reconcile: MAM_JOBS_DIR wins in the subscribe heredoc and is passed in. Tests T3-0..T3-8 (mock herdr: forwarding is verified at the herdr-call level only; real pane env propagation is left to item 7). T3-7 is a static guard. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G --- .agents/skills/lib.sh | 16 ++ .agents/skills/lib_py/control_plane.py | 95 ++++++++ .../scripts/create_session.sh | 34 ++- .../scripts/reconcile.sh | 6 +- .../scripts/resume_session.sh | 27 ++- .../scripts/update_yaml_resumed.sh | 10 + .../multi-agent-mux-status/scripts/status.sh | 18 +- .../scripts/stop_session.sh | 10 +- tests/test_issue21_worktree_prereqs.py | 204 ++++++++++++++++++ 9 files changed, 409 insertions(+), 11 deletions(-) create mode 100644 .agents/skills/lib_py/control_plane.py diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 2ff6327..c86c5b8 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -2693,3 +2693,19 @@ handle_startup_dialogs() { # Auto-initialize the herdr translation shim wrapper on library source _init_herdr_isolation + +# --------------------------------------------------------------------------- +# Issue #21 (item 3): control-plane binding of a session row. The logic lives +# in lib_py/control_plane.py; these are thin shell entry points. +# mam_control_plane_json [] +mam_control_plane_json() { + PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.control_plane json "$@" +} + +# mam_control_plane_prefix : the leading K=V tokens for new-session, or +# nothing when no forward is needed. rc 3: a forward is needed but a path +# contains whitespace (lib.sh splits the --env flags unquoted). +mam_control_plane_prefix() { + [ -n "${1:-}" ] || return 0 + PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.control_plane prefix "$1" +} diff --git a/.agents/skills/lib_py/control_plane.py b/.agents/skills/lib_py/control_plane.py new file mode 100644 index 0000000..3fb41ee --- /dev/null +++ b/.agents/skills/lib_py/control_plane.py @@ -0,0 +1,95 @@ +"""Control-plane binding of a session row (Issue #21, item 3). + +The binding is the four absolute paths a session's tools must agree on: +WORKSPACE_ROOT, AGENT_SESSIONS_YAML, MAM_JOBS_DIR, DELEGATE_JOB_LOGS_DIR. +create/resume persist it on the row (`control_plane`) and forward it to the +pane only when it differs from what `--workspace` alone would imply, through +the leading `K=V` tokens lib.sh's new-session already turns into `--env`. + +CLI (used by lib.sh): + python -m lib_py.control_plane json [] + -> {4 keys, "forward": bool, "whitespace": bool, "source": "row"|"env"} + python -m lib_py.control_plane prefix + -> "WORKSPACE_ROOT=... AGENT_SESSIONS_YAML=... ... " or "" (no forward) +""" + +from __future__ import annotations + +import json +import os +import shlex +import sys +from typing import Dict, Optional + +KEYS = ("workspace_root", "agent_sessions_yaml", "mam_jobs_dir", "delegate_job_logs_dir") +ENV = {"workspace_root": "WORKSPACE_ROOT", "agent_sessions_yaml": "AGENT_SESSIONS_YAML", + "mam_jobs_dir": "MAM_JOBS_DIR", "delegate_job_logs_dir": "DELEGATE_JOB_LOGS_DIR"} + + +def _abs(p: str) -> str: + return os.path.realpath(os.path.expanduser(p)) + + +def defaults(workspace: str) -> Dict[str, str]: + """D(ws): the binding `--workspace` implies on its own.""" + ws = _abs(workspace) + mam = os.path.join(ws, ".mam") + return {"workspace_root": ws, "agent_sessions_yaml": os.path.join(mam, "agent-sessions.yaml"), + "mam_jobs_dir": os.path.join(mam, "jobs"), "delegate_job_logs_dir": os.path.join(mam, "delegate_job_logs")} + + +def from_env(env=None) -> Dict[str, str]: + env = os.environ if env is None else env + b = defaults(env.get("WORKSPACE_ROOT") or os.getcwd()) + for k, var in ENV.items(): + v = (env.get(var) or "").strip() + if v: + b[k] = _abs(v) + return b + + +def describe(workspace: str, row: Optional[dict] = None) -> dict: + """The binding to use: the row's persisted one if complete (D11), else the + caller's env. A row binding that differs from the env is reported on stderr.""" + env_b = from_env() + source = "env" + b = env_b + if isinstance(row, dict) and all(isinstance(row.get(k), str) and row.get(k) for k in KEYS): + b = {k: row[k] for k in KEYS} + source = "row" + if b != env_b: + print("WARNING: using the control-plane binding recorded in the row " + f"({json.dumps(b, sort_keys=True)}) instead of the caller's environment", file=sys.stderr) + return {**b, "forward": b != defaults(workspace), + "whitespace": any(any(c.isspace() for c in v) for v in b.values()), "source": source} + + +def prefix(desc: dict) -> str: + if not desc.get("forward"): + return "" + return "".join(f"{ENV[k]}={shlex.quote(desc[k])} " for k in KEYS) + + +def main(argv=None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + if len(argv) >= 2 and argv[0] == "json": + row = None + if len(argv) >= 3 and argv[2].strip(): + try: + row = json.loads(argv[2]) + except json.JSONDecodeError: + row = None + print(json.dumps(describe(argv[1], row))) + return 0 + if len(argv) == 2 and argv[0] == "prefix": + desc = json.loads(argv[1]) + if desc.get("forward") and desc.get("whitespace"): + return 3 # lib.sh splits --env flags unquoted: refuse rather than break silently + sys.stdout.write(prefix(desc)) + return 0 + print(__doc__, file=sys.stderr) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index 0655499..28c7047 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -202,6 +202,22 @@ if [ -z "$CMD_FULL" ]; then esac fi +# Issue #21 (item 3): the control-plane binding. Persisted on the row always; +# forwarded to the pane (leading K=V tokens that lib.sh's new-session turns +# into --env) only when it differs from what --workspace alone implies. +# lib.sh splits those flags unquoted (and the idle-pane path evals them), so a +# binding with whitespace that would have to be forwarded is refused. +CP_JSON="$(mam_control_plane_json "$WORKSPACE" 2>/dev/null || true)" +CP_PREFIX="" +if [ -n "$CP_JSON" ]; then + cp_rc=0 + CP_PREFIX="$(mam_control_plane_prefix "$CP_JSON")" || cp_rc=$? + if [ "$cp_rc" -eq 3 ]; then + echo "ERROR: control-plane path contains whitespace; cannot forward to the pane" >&2 + exit 2 + fi +fi + spawn() { if [ -z "$HERDR_SERVER_OPT" ]; then if [ -z "${HERDR_SESSION_NAME:-}" ] || [ "$HERDR_SESSION_NAME" = "default" ]; then @@ -213,20 +229,21 @@ spawn() { if { [ -x "$WRAPPER" ] && [ "$(basename "$WRAPPER")" != "claude" ]; } || [ "$USE_WRAPPER" = "1" ]; then SESSION_UUID="" CMD_FULL="$("$(_delegate_py_bin)" -m lib_py.agents spawn-spec "$AGENT" "$RESOLVED_BIN" "" "true" 2>/dev/null || echo "${RESOLVED_BIN} --dangerously-skip-permissions")" + [ -z "$CP_PREFIX" ] || echo "WARNING: the ~/.local/bin wrapper path bypasses herdr; the control-plane binding is recorded but not forwarded" >&2 nohup "$WRAPPER" >/dev/null 2>&1 & disown else - HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL" + HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL" fi ;; agy|hermes|grok|codex) - HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL" + HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL" ;; opencode) if [ -z "${OPENCODE_PERMISSION:-}" ]; then export OPENCODE_PERMISSION='{"*":"allow"}' fi - HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL" + HERDR_SESSION_NAME="$HERDR_SESSION_NAME" _herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL" ;; *) echo "ERROR: --agent must be claude, agy, hermes, grok, opencode, or codex, got: $AGENT" >&2; exit 2 ;; esac @@ -391,7 +408,7 @@ atomic_dump_yaml "$AGENT_SESSIONS_YAML" \ MAM_WS_LABEL="$MAM_WS_LABEL" \ SESSION_UUID="$SESSION_UUID" \ LAST_VISIBLE_STATUS_OVERRIDE="${LAST_VISIBLE_STATUS_OVERRIDE:-}" \ - DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" <<'PYEOF' + DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" CONTROL_PLANE_JSON="$CP_JSON" <<'PYEOF' name = os.environ['SESSION_NAME'] agent = os.environ['AGENT'] role = os.environ['ROLE'] @@ -487,6 +504,15 @@ last_vis_override = os.environ.get('LAST_VISIBLE_STATUS_OVERRIDE', '') if last_vis_override: entry['last_visible_status'] = last_vis_override +# Issue #21: the control-plane binding this row was created against. +_cp_raw = os.environ.get('CONTROL_PLANE_JSON', '') +if _cp_raw: + try: + import json as _json + _cp = _json.loads(_cp_raw) + entry['control_plane'] = {k: _cp[k] for k in ('workspace_root', 'agent_sessions_yaml', 'mam_jobs_dir', 'delegate_job_logs_dir')} + except Exception: + pass sessions.append(entry) snap = d.setdefault('snapshot', {}) diff --git a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh index fbf25fc..e6d02ce 100755 --- a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh +++ b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh @@ -100,7 +100,7 @@ if [ "$SUBSCRIBE" = "1" ]; then # The MQTT subscribe loop exits 3 to signal "broker unavailable → poll instead". set +e YAML_PATH="$AGENT_SESSIONS_YAML" HOME_DIR="$HOME_DIR" CLAUDE_PROJECT_DIR="$CLAUDE_PROJECT_DIR" LOCAL_BIN="$LOCAL_BIN" \ - WORKSPACE_ROOT="$WORKSPACE_ROOT" SUB_TIMEOUT="$SUB_TIMEOUT" SUB_IDLE_TIMEOUT="$SUB_IDLE_TIMEOUT" \ + WORKSPACE_ROOT="$WORKSPACE_ROOT" MAM_JOBS_DIR="${MAM_JOBS_DIR:-}" SUB_TIMEOUT="$SUB_TIMEOUT" SUB_IDLE_TIMEOUT="$SUB_IDLE_TIMEOUT" \ SKILLS_DIR="$SKILLS_DIR" LIB_SH="$LIB_SH" \ "$PYBIN" - <<'PYEOF' & import os, sys, json, time, subprocess @@ -236,7 +236,9 @@ def on_message(_client, _userdata, msg): if not jid or not event: return - if workspace_root: + if os.environ.get('MAM_JOBS_DIR'): + registry_dir = os.environ['MAM_JOBS_DIR'] # Issue #21: MAM_JOBS_DIR wins + elif workspace_root: registry_dir = os.path.join(workspace_root, '.mam', 'jobs') else: yaml_dir = os.path.dirname(yaml_path) if yaml_path else "" diff --git a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh index 13c1a97..72fee21 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh @@ -150,6 +150,29 @@ else fi fi +# Issue #21 (item 3, D11): the control-plane binding recorded on the row wins +# over the caller's environment (a difference is reported on stderr); a legacy +# row without one uses the caller's binding, which update_yaml_resumed.sh then +# records. Forwarded to the pane only when it differs from --workspace's own. +ROW_CP="$(MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$SESSION_NAME" python3 -c " +import json, os +d = json.loads(os.environ.get('MAM_STATE_JSON') or '{}') +for s in d.get('herdr_sessions', []): + if s.get('name') == os.environ['SESSION_NAME'] and isinstance(s.get('control_plane'), dict): + print(json.dumps(s['control_plane'])) + break +" 2>/dev/null || true)" +CP_JSON="$(mam_control_plane_json "$WORKSPACE" "$ROW_CP" || true)" +CP_PREFIX="" +if [ -n "$CP_JSON" ]; then + cp_rc=0 + CP_PREFIX="$(mam_control_plane_prefix "$CP_JSON")" || cp_rc=$? + if [ "$cp_rc" -eq 3 ]; then + echo "ERROR: control-plane path contains whitespace; cannot forward to the pane" >&2 + exit 2 + fi +fi + if [ "${DRY_RUN:-0}" = "1" ]; then echo "[dry-run] would spawn: $CMD_FULL" exit 0 @@ -162,7 +185,7 @@ if [ "$AGENT" = "opencode" ]; then fi # 4. Spawn new agent session (delegates to herdr translation shim) -_herdr new-session -d -s "$SESSION_NAME" -c "$WORKSPACE" "$CMD_FULL" +_herdr new-session -d -s "$SESSION_NAME" -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL" if [ "$AGENT" = "claude" ]; then # auto-handle trust / bypass dialogs handle_startup_dialogs "$SESSION_NAME" 20 @@ -172,7 +195,7 @@ fi sleep 2 # 5. Update agent-sessions.yaml: status running, last_visible_status -bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \ +CONTROL_PLANE_JSON="$CP_JSON" bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \ --session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT" --workspace "$WORKSPACE" \ --herdr-session "$HERDR_SESSION_NAME" --cmd-full "$CMD_FULL" \ ${HERDR_WORKSPACE_OPT:+--herdr-workspace "$HERDR_WORKSPACE_OPT"} diff --git a/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh b/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh index bc65cec..d30ad3b 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh @@ -163,6 +163,16 @@ else: target['herdr_workspace'] = wsl target['status'] = 'running' +# Issue #21 (D11): a row keeps the control-plane binding it was created with; +# a legacy row without one gets the binding this resume used. +_cp_raw = os.environ.get('CONTROL_PLANE_JSON', '') +if _cp_raw and not isinstance(target.get('control_plane'), dict): + try: + import json as _json + _cp = _json.loads(_cp_raw) + target['control_plane'] = {k: _cp[k] for k in ('workspace_root', 'agent_sessions_yaml', 'mam_jobs_dir', 'delegate_job_logs_dir')} + except Exception: + pass if not uuid: # ISSUE-3 (challenge c4b0a075): a Class A fresh-spawn resume re-arms # discovery from scratch - the resumed agent has not spoken yet, so any diff --git a/.agents/skills/multi-agent-mux-status/scripts/status.sh b/.agents/skills/multi-agent-mux-status/scripts/status.sh index 90ba9e6..23d2711 100755 --- a/.agents/skills/multi-agent-mux-status/scripts/status.sh +++ b/.agents/skills/multi-agent-mux-status/scripts/status.sh @@ -160,7 +160,14 @@ def get_job_status(s): return ('-', '-') project_root = os.environ.get('PROJECT_ROOT', '.') - candidates = [ + # Issue #21: MAM_JOBS_DIR and the registry the row was created against + # (control_plane) first, then the matching audit logs, then the old ones. + cp = s.get('control_plane') or {} + candidates = [os.path.join(r, f"{jid}.json") + for r in (os.environ.get('MAM_JOBS_DIR'), cp.get('mam_jobs_dir')) if r] + candidates += [os.path.join(r, jid, 'status.json') + for r in (os.environ.get('DELEGATE_JOB_LOGS_DIR'), cp.get('delegate_job_logs_dir')) if r] + candidates += [ os.path.join('.mam', 'jobs', f"{jid}.json"), os.path.join(project_root, '.mam', 'jobs', f"{jid}.json"), os.path.join(project_root, '.mam', 'delegate_job_logs', jid, 'status.json'), @@ -361,7 +368,14 @@ def get_job_status(s): project_root = os.environ.get('PROJECT_ROOT', '.') # Candidate locations (review item 6: project-root-relative, no hardcoded abs paths): # 1) cwd-relative registry 2) project-root registry 3) project-root audit log - candidates = [ + # Issue #21: MAM_JOBS_DIR and the registry the row was created against + # (control_plane) first, then the matching audit logs, then the old ones. + cp = s.get('control_plane') or {} + candidates = [os.path.join(r, f"{jid}.json") + for r in (os.environ.get('MAM_JOBS_DIR'), cp.get('mam_jobs_dir')) if r] + candidates += [os.path.join(r, jid, 'status.json') + for r in (os.environ.get('DELEGATE_JOB_LOGS_DIR'), cp.get('delegate_job_logs_dir')) if r] + candidates += [ os.path.join('.mam', 'jobs', f"{jid}.json"), os.path.join(project_root, '.mam', 'jobs', f"{jid}.json"), os.path.join(project_root, '.mam', 'delegate_job_logs', jid, 'status.json'), diff --git a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh index b006783..2ef251b 100755 --- a/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh +++ b/.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh @@ -132,7 +132,8 @@ for s in d.get('herdr_sessions', []): if s.get('name') == name: cwd = (s.get('pane') or {}).get('cwd', '') jid = s.get('delegate_job_id', '') or '' - print(json.dumps({'cwd': cwd, 'job_id': jid})) + cp = s.get('control_plane') or {} + print(json.dumps({'cwd': cwd, 'job_id': jid, 'mam_jobs_dir': cp.get('mam_jobs_dir', '') or ''})) sys.exit(0) sys.exit(7) ") || { @@ -142,6 +143,13 @@ sys.exit(7) TARGET_CWD=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("cwd",""))') DELEGATE_JOB_ID=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("job_id",""))') +# Issue #21: a row created against another control plane names its registry. +# Order: MAM_JOBS_DIR > the row's control_plane.mam_jobs_dir > WORKSPACE_ROOT > +# .mam/jobs; exported so delegate_publish_event (lib.sh) uses the same one. +ROW_JOBS_DIR=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("mam_jobs_dir",""))') +if [ -z "${MAM_JOBS_DIR:-}" ] && [ -n "$ROW_JOBS_DIR" ]; then + export MAM_JOBS_DIR="$ROW_JOBS_DIR" +fi DELEGATE_JOB_TOKEN="" DELEGATE_JOB_ATTEMPT="" if [ -n "$DELEGATE_JOB_ID" ]; then diff --git a/tests/test_issue21_worktree_prereqs.py b/tests/test_issue21_worktree_prereqs.py index e848c89..c1f8345 100644 --- a/tests/test_issue21_worktree_prereqs.py +++ b/tests/test_issue21_worktree_prereqs.py @@ -486,3 +486,207 @@ def test_t2_14_undeterminable_change_set_is_refused(wt_repo, monkeypatch, tmp_pa assert rc == 1 assert _roles(calls, "Reviewer") == [] assert "refusing to request reviews on no evidence" in capsys.readouterr().err + + +# --------------------------------------------------------------------------- +# §3 item 3 — control-plane binding persisted on the row, forwarded to the pane +# --------------------------------------------------------------------------- + +CP_ENV = ("WORKSPACE_ROOT", "AGENT_SESSIONS_YAML", "MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR") + + +def _binding(root: Path) -> dict: + r = os.path.realpath(root) + return {"workspace_root": r, "agent_sessions_yaml": f"{r}/.mam/agent-sessions.yaml", + "mam_jobs_dir": f"{r}/.mam/jobs", "delegate_job_logs_dir": f"{r}/.mam/delegate_job_logs"} + + +def _rows(tmp: Path) -> list: + import yaml + return (yaml.safe_load((tmp / ".mam" / "agent-sessions.yaml").read_text()) or {}).get("herdr_sessions") or [] + + +def _env_flags(mock_state_file, since=0) -> list: + calls = json.loads(Path(mock_state_file).read_text()).get("calls", [])[since:] + return [c[i + 1] for c in calls for i, t in enumerate(c[:-1]) if t == "--env"] + + +def _ncalls(mock_state_file) -> int: + return len(json.loads(Path(mock_state_file).read_text()).get("calls", [])) + + +def _skill(tmp: Path, *parts) -> str: + return str(tmp / ".agents" / "skills" / Path(*parts)) + + +def _create(tmp: Path, ws: Path, env=None) -> subprocess.CompletedProcess: + return subprocess.run(["bash", _skill(tmp, "multi-agent-mux-create", "scripts", "create_session.sh"), + "--workspace", str(ws), "--agent", "claude", "--role", "creator", "--no-onboard"], + capture_output=True, text=True, cwd=str(tmp), env=env or dict(os.environ), timeout=180) + + +def _mutate(tmp: Path, code: str) -> None: + res = subprocess.run(["bash", "-c", f"source {_skill(tmp, 'lib.sh')} && atomic_dump_yaml {tmp / '.mam' / 'agent-sessions.yaml'}"], + input=code, capture_output=True, text=True) + assert res.returncode == 0, res.stderr + + +def test_t3_0_control_plane_module(tmp_path, monkeypatch): + from lib_py import control_plane as cp + for k in CP_ENV: + monkeypatch.delenv(k, raising=False) + monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path)) + d = cp.describe(str(tmp_path)) + assert d["forward"] is False and d["source"] == "env" and cp.prefix(d) == "" + d = cp.describe(str(tmp_path / "wt")) + assert d["forward"] is True + assert cp.prefix(d).startswith(f"WORKSPACE_ROOT={os.path.realpath(tmp_path)} ") + row = _binding(tmp_path / "other") + d = cp.describe(str(tmp_path / "wt"), row) + assert d["source"] == "row" and d["workspace_root"] == row["workspace_root"] + monkeypatch.setenv("MAM_JOBS_DIR", str(tmp_path / "a b")) + d = cp.describe(str(tmp_path)) + assert d["forward"] and d["whitespace"] + assert cp.main(["prefix", json.dumps(d)]) == 3 + + +def test_t3_1_create_persists_and_forwards_a_foreign_binding(mam_sandbox, mock_herdr, mock_agents): + tmp = mam_sandbox + wt = tmp / "wt" + wt.mkdir() + res = _create(tmp, wt) + assert res.returncode == 0, res.stdout + res.stderr + (row,) = _rows(tmp) + assert row["control_plane"] == _binding(tmp) + flags = _env_flags(mock_herdr) + for k, v in zip(CP_ENV, _binding(tmp).values()): + assert f"{k}={v}" in flags, flags + assert "WORKSPACE_ROOT=" not in row["pane"]["cmd_full"] + assert "WORKSPACE_ROOT=" not in row["start_command"] + + +def test_t3_2_default_binding_is_recorded_not_forwarded(mam_sandbox, mock_herdr, mock_agents): + tmp = mam_sandbox + res = _create(tmp, tmp) + assert res.returncode == 0, res.stdout + res.stderr + (row,) = _rows(tmp) + assert row["control_plane"] == _binding(tmp) + assert not [f for f in _env_flags(mock_herdr) if f.startswith(CP_ENV)], "no forward for the default binding" + + +def test_t3_5_whitespace_binding_that_needs_forwarding_is_refused(mam_sandbox, mock_herdr, mock_agents): + tmp = mam_sandbox + env = {**os.environ, "MAM_JOBS_DIR": str(tmp / "jobs dir")} + res = _create(tmp, tmp, env) + assert res.returncode == 2, res.stdout + res.stderr + assert "control-plane path contains whitespace" in res.stderr + assert _rows(tmp) == [] + + +def test_t3_3_4_stop_keeps_the_binding_and_resume_reforwards_it(mam_sandbox, mock_herdr, mock_agents): + tmp = mam_sandbox + wt = tmp / "wt" + wt.mkdir() + assert _create(tmp, wt).returncode == 0 + (row,) = _rows(tmp) + name = row["name"] + res = subprocess.run(["bash", _skill(tmp, "multi-agent-mux-stop", "scripts", "stop_session.sh"), + "--session", name, "--agent", "claude"], capture_output=True, text=True, timeout=120) + assert res.returncode == 0, res.stdout + res.stderr + (row,) = _rows(tmp) + assert row["control_plane"] == _binding(tmp), "stop edits the row in place" + + env = {k: v for k, v in os.environ.items() if k not in ("MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR")} + since = _ncalls(mock_herdr) + res = subprocess.run(["bash", _skill(tmp, "multi-agent-mux-resume", "scripts", "resume_session.sh"), + "--workspace", str(wt), "--agent", "claude", "--session", name], + capture_output=True, text=True, env=env, timeout=180) + assert res.returncode == 0, res.stdout + res.stderr + # A fresh pane gets `--env K=V` flags; a reused idle pane (the stopped + # session's) gets the same K=V tokens as `env K=V ... ` (lib.sh D9). + state = json.loads(Path(mock_herdr).read_text()) + calls = state.get("calls", [])[since:] + args = [a for c in calls for a in c if isinstance(a, str)] + _sent_texts(state) + forwarded = {tok for a in args for tok in a.split()} + for k, v in zip(CP_ENV, _binding(tmp).values()): + assert f"{k}={v}" in forwarded, (res.stderr[-1500:], json.dumps(calls)[:1500]) + (row,) = _rows(tmp) + assert row["control_plane"] == _binding(tmp) + + +def test_t3_4b_stop_finds_the_job_in_the_rows_registry(mam_sandbox, mock_herdr, mock_agents, monkeypatch): + from lib_py.delegation import registry + tmp = mam_sandbox + ctl_jobs = tmp / "ctl" / "jobs" + jid = registry.register_job("t3-4", registry_dir=str(ctl_jobs), agent_session="herdr:t34-creator-claude") + binding = {**_binding(tmp), "mam_jobs_dir": os.path.realpath(ctl_jobs)} + _mutate(tmp, f""" +d['herdr_sessions'] = [{{ + 'name': 't34-creator-claude', 'status': 'running', 'role': 'creator', + 'delegate_job_id': {jid!r}, 'pane': {{'cwd': {str(tmp)!r}, 'cmd': 'claude'}}, + 'control_plane': {binding!r}, +}}] +""") + env = {k: v for k, v in os.environ.items() if k != "MAM_JOBS_DIR"} + res = subprocess.run(["bash", _skill(tmp, "multi-agent-mux-stop", "scripts", "stop_session.sh"), + "--session", "t34-creator-claude", "--agent", "claude"], + capture_output=True, text=True, env=env, timeout=120) + assert res.returncode == 0, res.stdout + res.stderr + assert (ctl_jobs / f"{jid}.events.log").exists(), "stop published into the row's registry" + assert not (tmp / ".mam" / "jobs" / f"{jid}.json").exists() + + +def test_t3_6_status_reads_mam_jobs_dir_and_the_row_binding(mam_sandbox, monkeypatch): + tmp = mam_sandbox + x, y = tmp.parent / f"x-{tmp.name}", tmp / "row-jobs" + for d, jid in ((x, "job21x"), (y, "job21y")): + d.mkdir(parents=True, exist_ok=True) + (d / f"{jid}.json").write_text('{"status": "completed"}') + status_sh = _skill(tmp, "multi-agent-mux-status", "scripts", "status.sh") + try: + _mutate(tmp, f""" +d['herdr_sessions'] = [{{'name': 's21-creator-claude', 'status': 'running', 'delegate_job_id': 'job21x', + 'pane': {{'cwd': {str(tmp)!r}}}}}] +""") + env = {**os.environ, "MAM_JOBS_DIR": str(x)} + res = subprocess.run(["bash", status_sh, "--json"], capture_output=True, text=True, env=env, timeout=120) + assert res.returncode == 0, res.stderr + assert json.loads(res.stdout)["sessions_detail"][0]["job_status"] == "completed" + + _mutate(tmp, f""" +d['herdr_sessions'] = [{{'name': 's21-creator-claude', 'status': 'running', 'delegate_job_id': 'job21y', + 'pane': {{'cwd': {str(tmp)!r}}}, 'control_plane': {{'mam_jobs_dir': {str(y)!r}}}}}] +""") + env = {k: v for k, v in os.environ.items() if k != "MAM_JOBS_DIR"} + res = subprocess.run(["bash", status_sh, "--json"], capture_output=True, text=True, env=env, timeout=120) + assert json.loads(res.stdout)["sessions_detail"][0]["job_status"] == "completed" + finally: + import shutil + shutil.rmtree(x, ignore_errors=True) + + +def test_t3_7_reconcile_prefers_mam_jobs_dir_static_guard(): + """Weak by design: the subscribe loop is a heredoc run only against a live + broker, so this pins the source text.""" + src = (SKILLS / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh").read_text() + assert "if os.environ.get('MAM_JOBS_DIR'):\n registry_dir = os.environ['MAM_JOBS_DIR']" in src + assert 'MAM_JOBS_DIR="${MAM_JOBS_DIR:-}"' in src + + +def test_t3_8_legacy_row_without_binding_is_filled_on_resume(mam_sandbox, mock_herdr, mock_agents): + tmp = mam_sandbox + _mutate(tmp, f""" +d['herdr_sessions'] = [{{ + 'name': 'legacy-creator-claude', 'status': 'stopped', 'role': 'creator', + 'herdr_session': 'default', 'herdr_server': 'default', + 'pane': {{'cwd': {str(tmp)!r}, 'cmd': 'claude'}}, + 'claude_session_id_own': '11111111-2222-4333-8444-555555555555', + 'session_id_source': 'assigned', 'session_id_verified': False, +}}] +""") + res = subprocess.run(["bash", _skill(tmp, "multi-agent-mux-resume", "scripts", "resume_session.sh"), + "--workspace", str(tmp), "--agent", "claude", "--session", "legacy-creator-claude"], + capture_output=True, text=True, timeout=180) + assert res.returncode == 0, res.stdout + res.stderr + (row,) = _rows(tmp) + assert row["control_plane"] == _binding(tmp)