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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
This commit is contained in:
1 parent
d907359746
commit
ee1d8966c1
9 files changed
+409
-11
No files matched your search
@@ -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 ... <cmd>` (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)
|
||||
Reference in new issue
Block a user