Review 9a1adb46 (planner-reviewer-codex-01): - R1 [P1] lib.sh new-session: a forwarded binding reached the agent as arguments. With env flags the idle-pane branch prefixed `env K=V ...` to the command that `herdr agent start ... --kind <agent> -- <args>` passes to the agent executable. An idle pane is now reused only when no environment has to be set; otherwise the pane is created by pane split / workspace create, which take --env. The dead `env` prefix code is removed (its send-text fallback would have typed the same line). - R2 [P2] runner: a relative MAM_JOBS_DIR is resolved once against the control root, the way the wrapper (cwd=workspace_root) reads it, and that absolute dir is used for delegation, wait, report lookup and cleanup. - R3 [P2] stop: the row's control_plane.delegate_job_logs_dir is restored (exported unless the caller set DELEGATE_JOB_LOGS_DIR), so the cancellation history lands in the row's audit-log root. Tests: T3-1 and T3-3 now assert that the binding arrives as --env on the pane-creation calls and that no env/K=V token follows `--` in agent start (fresh and reused pane); T3-4c (audit root on stop) and T2-15 (relative MAM_JOBS_DIR) are new. The design doc's compatibility table and a create comment no longer describe the removed idle-pane env path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017R3Eg7MEpSWnm1oB9ZPP6G
777 lines
34 KiB
Python
777 lines
34 KiB
Python
"""Issue #21 phase-1 prerequisites for the worktree operating model.
|
|
|
|
§1 (item 1) delegate-job wrapper: absolute brief / start-command registry /
|
|
audit-log root / Output Report Path, runnable from any cwd.
|
|
§2 (item 2) loop runner --worktree / --base / --review-only and the
|
|
fail-closed empty change-set guard; the control plane stays in the main
|
|
checkout.
|
|
§3 (item 3) control-plane binding persisted on the session row and forwarded
|
|
to the pane; status / reconcile / stop honour MAM_JOBS_DIR.
|
|
|
|
Every test writes only under tmp_path / mam_sandbox (Issue #20).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import signal
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
SKILLS = REPO_ROOT / ".agents" / "skills"
|
|
WRAPPER = SKILLS / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
|
|
RUN_LOOP_SH = SKILLS / "multi-agent-mux-loop" / "scripts" / "run_loop.sh"
|
|
|
|
if str(SKILLS) not in sys.path:
|
|
sys.path.insert(0, str(SKILLS))
|
|
|
|
UNREACHABLE = {"MQTT_BROKER": "127.0.0.1", "MQTT_PORT": "9", "MQTT_TLS": "0"}
|
|
GIT_ID = {"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@example.invalid",
|
|
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@example.invalid"}
|
|
|
|
|
|
def _env(home: Path, extra=None, drop=()) -> dict:
|
|
env = dict(os.environ)
|
|
env["DELEGATE_JOB_PYTHON"] = sys.executable
|
|
env["AGENT_PYTHON_BIN"] = sys.executable
|
|
env["HOME"] = str(home)
|
|
env["MAM_ENV_FILE"] = str(home / ".mam.env.absent")
|
|
env["PYTHONPATH"] = str(SKILLS)
|
|
for k in ("MQTT_BROKER", "MQTT_HOST", "MQTT_PORT", "MQTT_TLS", "MQTT_USERNAME", "MQTT_PASSWORD",
|
|
"MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR", "WORKSPACE_ROOT"):
|
|
env.pop(k, None)
|
|
env.update(UNREACHABLE)
|
|
if extra:
|
|
env.update({k: str(v) for k, v in extra.items()})
|
|
for k in drop:
|
|
env.pop(k, None)
|
|
return env
|
|
|
|
|
|
def _only_job_id(reg: Path) -> str:
|
|
ids = [p.stem for p in reg.glob("*.json")]
|
|
assert len(ids) == 1, ids
|
|
return ids[0]
|
|
|
|
|
|
def _kill_timer(reg: Path, jid: str) -> None:
|
|
try:
|
|
pid = int((reg / f"{jid}.heartbeat.pid").read_text().strip())
|
|
os.kill(pid, signal.SIGKILL)
|
|
except (OSError, ValueError):
|
|
pass
|
|
|
|
|
|
def _dry_submit(cwd: Path, env: dict, *args) -> subprocess.CompletedProcess:
|
|
return subprocess.run(["bash", str(WRAPPER), "submit", "--agent", "human", "--prompt", "p21",
|
|
"--dry-run", *args],
|
|
capture_output=True, text=True, env=env, cwd=str(cwd), timeout=60)
|
|
|
|
|
|
def _injected(stdout: str, name: str) -> str:
|
|
m = re.search(name + r"='(.*?)'", stdout)
|
|
assert m, stdout
|
|
return m.group(1)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# §1 item 1 — the wrapper's injected paths are absolute
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_t1_1_injected_paths_are_absolute(tmp_path):
|
|
a = tmp_path / "A"
|
|
a.mkdir()
|
|
real_a = os.path.realpath(a)
|
|
env = _env(tmp_path, {"WORKSPACE_ROOT": a, "DELEGATE_JOB_LOGS_DIR": a / "logs"})
|
|
res = _dry_submit(a, env, "--workdir", str(a))
|
|
assert res.returncode == 0, res.stderr
|
|
jid = _only_job_id(a / ".mam" / "jobs")
|
|
brief = f"{real_a}/.mam/jobs/{jid}/brief.md"
|
|
assert res.stdout.count(brief) == 2, res.stdout
|
|
start = _injected(res.stdout, "start")
|
|
assert f"--registry-dir {real_a}/.mam/jobs " in start
|
|
assert start.startswith(f"DELEGATE_JOB_LOGS_DIR={real_a}/logs "), start
|
|
# no cwd-relative registry path is left anywhere in the injected text
|
|
assert res.stdout.count(f".mam/jobs/{jid}") == res.stdout.count(f"{real_a}/.mam/jobs/{jid}")
|
|
|
|
|
|
def test_t1_2_start_and_success_commands_run_from_another_cwd(tmp_path):
|
|
"""The core regression: a registry and an audit-log root with a space in
|
|
them, the commands executed as written from a different cwd with no
|
|
workspace env - the job advances, the audit trail stays in the control
|
|
root, and nothing is written under the other cwd."""
|
|
ctl = tmp_path / "ctl root"
|
|
reg, logs, wt = ctl / "jobs", ctl / "logs", tmp_path / "wt"
|
|
for d in (reg, logs, wt):
|
|
d.mkdir(parents=True)
|
|
env = _env(tmp_path, {"WORKSPACE_ROOT": ctl, "DELEGATE_JOB_LOGS_DIR": logs})
|
|
res = _dry_submit(ctl, env, "--registry-dir", str(reg), "--workdir", str(ctl),
|
|
"--heartbeat", "7", "--ack-timeout", "11")
|
|
assert res.returncode == 0, res.stderr
|
|
jid = _only_job_id(reg)
|
|
start = _injected(res.stdout, "start")
|
|
success = _injected(res.stdout, "success").replace("<summary>", "done")
|
|
agent_env = _env(tmp_path) # no WORKSPACE_ROOT / MAM_JOBS_DIR / DELEGATE_JOB_LOGS_DIR
|
|
try:
|
|
r1 = subprocess.run(["bash", "-c", start + " --attempts 1"], capture_output=True, text=True,
|
|
env=agent_env, cwd=str(wt), timeout=120)
|
|
# rc 2 = broker unreachable; the event is recorded locally first (test18 contract)
|
|
assert r1.returncode == 2, r1.stderr
|
|
assert json.loads((reg / f"{jid}.json").read_text())["status"] != "pending"
|
|
r2 = subprocess.run(["bash", "-c", success + " --attempts 1"], capture_output=True, text=True,
|
|
env=agent_env, cwd=str(wt), timeout=120)
|
|
assert r2.returncode == 2, r2.stderr
|
|
assert json.loads((reg / f"{jid}.json").read_text())["status"] == "completed"
|
|
finally:
|
|
_kill_timer(reg, jid)
|
|
audit = [json.loads(l) for l in (logs / jid / "events.ndjson").read_text().splitlines() if l.strip()]
|
|
published = [json.dumps(e) for e in audit if e.get("event") == "published"]
|
|
assert len(published) == 2, audit
|
|
assert '"started"' in published[0] and '"completed"' in published[1], published
|
|
assert not (wt / ".mam").exists(), "the agent's cwd must not grow a .mam"
|
|
|
|
|
|
def test_t1_3_mam_jobs_dir_is_the_default_registry(tmp_path):
|
|
x, y = tmp_path / "X", tmp_path / "Y"
|
|
env = _env(tmp_path, {"MAM_JOBS_DIR": x, "WORKSPACE_ROOT": tmp_path, "DELEGATE_JOB_LOGS_DIR": tmp_path / "logs"})
|
|
res = _dry_submit(tmp_path, env, "--workdir", str(tmp_path))
|
|
assert res.returncode == 0, res.stderr
|
|
assert len(list(x.glob("*.json"))) == 1
|
|
assert not (tmp_path / ".mam" / "jobs").exists()
|
|
|
|
res = _dry_submit(tmp_path, env, "--workdir", str(tmp_path), "--registry-dir", str(y))
|
|
assert res.returncode == 0, res.stderr
|
|
assert len(list(y.glob("*.json"))) == 1, "an explicit --registry-dir wins over MAM_JOBS_DIR"
|
|
assert len(list(x.glob("*.json"))) == 1
|
|
|
|
|
|
def test_t1_4_wrapper_source_guard():
|
|
"""The loop job type never builds its text under --dry-run (it returns
|
|
early), so both paths are pinned to the shared helpers here."""
|
|
src = WRAPPER.read_text()
|
|
assert ".mam/jobs/$JOB_ID" not in src
|
|
assert src.count('instructions="$(_instructions "$pub")"') == 1
|
|
assert src.count('instructions="$(_instructions "$pub" "$iteration")"') == 1
|
|
assert src.count('pub="$(_pub_cmd ') == 2
|
|
report_lines = [l for l in src.splitlines() if "**Output Report Path**" in l]
|
|
assert len(report_lines) == 2 and all("$REGISTRY_DIR/$JOB_ID/" in l for l in report_lines)
|
|
|
|
|
|
def _sent_texts(state) -> list:
|
|
out = []
|
|
|
|
def walk(o):
|
|
if isinstance(o, dict):
|
|
for k, v in o.items():
|
|
if k == "sent_text" and isinstance(v, str):
|
|
out.append(v)
|
|
else:
|
|
walk(v)
|
|
elif isinstance(o, list):
|
|
for v in o:
|
|
walk(v)
|
|
walk(state)
|
|
return out
|
|
|
|
|
|
def test_t1_5_onboarding_publish_command_is_absolute(mam_sandbox, mock_herdr, mock_agents):
|
|
tmp = mam_sandbox
|
|
script = tmp / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
|
|
res = subprocess.run(["bash", str(script), "--workspace", str(tmp), "--agent", "claude",
|
|
"--role", "creator", "--onboard"],
|
|
capture_output=True, text=True, cwd=str(tmp), timeout=180)
|
|
assert res.returncode == 0, res.stdout + res.stderr
|
|
texts = [t for t in _sent_texts(json.loads(Path(mock_herdr).read_text())) if "publish_event.py" in t]
|
|
assert texts, "the onboarding instructions were not injected"
|
|
text = "".join(texts)
|
|
real = os.path.realpath(tmp)
|
|
assert f" {real}/.agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py " in text, text
|
|
assert f"--registry-dir {real}/.mam/jobs " in text, text
|
|
assert f"DELEGATE_JOB_LOGS_DIR={real}/.mam/delegate_job_logs " in text, text
|
|
assert " .agents/skills/" not in text
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# §2 item 2 — loop runner --worktree / --base / --review-only, empty-set guard
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _git(cwd: Path, *args: str) -> str:
|
|
return subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True, check=True,
|
|
env={**os.environ, **GIT_ID}).stdout.strip()
|
|
|
|
|
|
@pytest.fixture
|
|
def wt_repo(tmp_path, monkeypatch):
|
|
"""main checkout (control root) + a linked worktree on branch feat."""
|
|
main, wt = tmp_path / "main", tmp_path / "wt"
|
|
main.mkdir()
|
|
_git(main, "init", "-q")
|
|
(main / "a.txt").write_text("a\n")
|
|
_git(main, "add", "a.txt")
|
|
_git(main, "commit", "-q", "-m", "init")
|
|
_git(main, "worktree", "add", "-q", "-b", "feat", str(wt))
|
|
for k, v in GIT_ID.items():
|
|
monkeypatch.setenv(k, v)
|
|
monkeypatch.setenv("WORKSPACE_ROOT", str(main))
|
|
monkeypatch.delenv("MAM_REAL_ROOT", raising=False)
|
|
monkeypatch.setenv("MAM_JOBS_DIR", str(main / ".mam" / "jobs"))
|
|
monkeypatch.delenv("MAM_LOOP_WORKTREE", raising=False)
|
|
monkeypatch.delenv("MAM_LOOP_BASE", raising=False)
|
|
monkeypatch.setenv("MAM_STATE_JSON", json.dumps({"herdr_sessions": [
|
|
{"name": "creator-claude-01", "agent": "claude", "role": "creator", "status": "running"},
|
|
{"name": "reviewer-claude-01", "agent": "claude", "role": "reviewer", "status": "running"},
|
|
]}))
|
|
return main, wt
|
|
|
|
|
|
def _runner(*extra):
|
|
from lib_py.loop import runner as lr
|
|
return lr.LoopRunner(["--creator", "creator-claude-01", "--task", "t21", *extra])
|
|
|
|
|
|
def _drive(monkeypatch, tmp_path, argv, on_worker=None):
|
|
"""run() end to end with the delegation stubbed: every job 'completes' and
|
|
every report is a PASS. Returns (rc, [(role, prompt)], runner)."""
|
|
from lib_py.loop import runner as lr
|
|
monkeypatch.setattr(lr.signal, "signal", lambda *a, **k: None)
|
|
r = lr.LoopRunner(["--creator", "creator-claude-01", "--task", "t21",
|
|
"--reviewer", "reviewer-claude-01", "--max-loop", "1", *argv])
|
|
calls = []
|
|
report = tmp_path / "pass-report.md"
|
|
report.write_text("looks fine\n\n[VERDICT: PASS]\n")
|
|
|
|
def fake_delegate(session, agent_type, role, prompt):
|
|
calls.append((role, prompt))
|
|
if role == "Worker" and on_worker:
|
|
on_worker(r)
|
|
return f"j{len(calls)}"
|
|
|
|
monkeypatch.setattr(r, "delegate_job", fake_delegate)
|
|
monkeypatch.setattr(r, "wait_for_job", lambda *a, **k: True)
|
|
monkeypatch.setattr(r, "find_report", lambda jid: report)
|
|
return r.run(), calls, r
|
|
|
|
|
|
def _roles(calls, role):
|
|
return [p for (ro, p) in calls if ro == role]
|
|
|
|
|
|
def test_t2_1_diff_and_head_come_from_the_worktree(wt_repo):
|
|
main, wt = wt_repo
|
|
(wt / "a.txt").write_text("a\nworktree change\n")
|
|
(main / "b.txt").write_text("main-only change\n")
|
|
r = _runner("--worktree", str(wt))
|
|
r.parse_args()
|
|
r.base_commit = r._source_head()
|
|
assert r._source_head() == _git(wt, "rev-parse", "HEAD")
|
|
diff = r.collect_changes_diff()
|
|
assert "worktree change" in diff
|
|
assert "main-only change" not in diff and "b.txt" not in diff
|
|
|
|
|
|
def test_t2_2_invalid_worktrees_are_refused(wt_repo, tmp_path):
|
|
main, wt = wt_repo
|
|
(wt / "sub").mkdir()
|
|
(tmp_path / "plain").mkdir()
|
|
other = tmp_path / "other"
|
|
other.mkdir()
|
|
_git(other, "init", "-q")
|
|
for bad in (tmp_path / "missing", tmp_path / "plain", wt / "sub", other):
|
|
with pytest.raises(SystemExit) as e:
|
|
_runner("--worktree", str(bad)).parse_args()
|
|
assert e.value.code == 1, bad
|
|
|
|
|
|
def test_t2_3_delegation_stays_on_the_control_root(wt_repo, monkeypatch):
|
|
main, wt = wt_repo
|
|
from lib_py.loop import runner as lr
|
|
monkeypatch.setenv("MAM_HEARTBEAT_SEC", "20")
|
|
monkeypatch.setenv("MAM_HEARTBEAT_MISSES", "3")
|
|
monkeypatch.setenv("MAM_ACK_TIMEOUT_S", "45")
|
|
r = _runner("--worktree", str(wt))
|
|
r.parse_args()
|
|
seen = []
|
|
|
|
def fake_run(cmd, **kw):
|
|
seen.append((cmd, kw))
|
|
|
|
class P:
|
|
returncode = 0
|
|
stdout = "registered job: cafe21\n"
|
|
stderr = ""
|
|
return P()
|
|
|
|
monkeypatch.setattr(lr.subprocess, "run", fake_run)
|
|
assert r.delegate_job("creator-claude-01", "claude", "Worker", "go") == "cafe21"
|
|
cmd, kw = seen[0]
|
|
assert os.path.realpath(kw["cwd"]) == os.path.realpath(main)
|
|
assert cmd[1].endswith("multi-agent-mux-delegate-job/multi-agent-mux-delegate-job")
|
|
assert not cmd[1].startswith(os.path.realpath(wt))
|
|
assert cmd[cmd.index("--registry-dir") + 1] == str(main / ".mam" / "jobs")
|
|
assert cmd[-6:] == ["--heartbeat", "20", "--heartbeat-misses", "3", "--ack-timeout", "45"]
|
|
|
|
|
|
def test_t2_4_checkpoint_lives_in_the_control_root(wt_repo):
|
|
main, wt = wt_repo
|
|
r = _runner("--worktree", str(wt))
|
|
r.parse_args()
|
|
r._record_checkpoint()
|
|
data = json.loads((main / ".mam" / "loop-checkpoint.json").read_text())
|
|
assert data["worktree_root"] == os.path.realpath(wt)
|
|
assert data["base_source"] == "head" and data["review_only"] is False
|
|
assert data["head_commit"] == _git(wt, "rev-parse", "HEAD")
|
|
assert not (wt / ".mam").exists()
|
|
|
|
|
|
def test_t2_5_prompts_carry_the_worktree_context(wt_repo, monkeypatch, tmp_path):
|
|
main, wt = wt_repo
|
|
head0 = _git(wt, "rev-parse", "HEAD")
|
|
|
|
def implement(_r):
|
|
(wt / "a.txt").write_text("a\nimplemented in wt\n")
|
|
|
|
rc, calls, _ = _drive(monkeypatch, tmp_path, ["--worktree", str(wt)], on_worker=implement)
|
|
assert rc == 0
|
|
worker, review = _roles(calls, "Worker")[0], _roles(calls, "Reviewer")[0]
|
|
for p in (worker, review):
|
|
assert p.startswith("[워크트리 컨텍스트]\n")
|
|
assert f"- 소스 경로: {os.path.realpath(wt)}\n" in p
|
|
assert f"- base: {head0} (출처: 루프 시작 시 HEAD)" in p
|
|
assert f"- head: {head0} (base..head 커밋: 0개" in p
|
|
assert "미커밋 변경: 있음" in review and "implemented in wt" in review
|
|
|
|
|
|
def test_t2_5b_prompts_unchanged_without_options(wt_repo, monkeypatch, tmp_path):
|
|
main, _ = wt_repo
|
|
monkeypatch.setenv("WORKSPACE_ROOT", str(main))
|
|
|
|
def implement(_r):
|
|
(main / "a.txt").write_text("a\nimplemented in main\n")
|
|
|
|
rc, calls, _ = _drive(monkeypatch, tmp_path, [], on_worker=implement)
|
|
assert rc == 0
|
|
assert all("[워크트리 컨텍스트]" not in p for _, p in calls)
|
|
assert _roles(calls, "Worker")[0].startswith("계획서가 존재하지 않으므로")
|
|
assert _roles(calls, "Reviewer")[0].startswith("다음 구현 사항(작업 목표: t21)")
|
|
|
|
|
|
def test_t2_6_worktree_env_fallback_and_no_base_env(wt_repo, tmp_path):
|
|
main, wt = wt_repo
|
|
wt2 = tmp_path / "wt2"
|
|
_git(main, "worktree", "add", "-q", "-b", "feat2", str(wt2))
|
|
os.environ["MAM_LOOP_WORKTREE"] = str(wt)
|
|
os.environ["MAM_LOOP_BASE"] = _git(main, "rev-parse", "HEAD")
|
|
try:
|
|
r = _runner()
|
|
r.parse_args()
|
|
assert r.worktree_root == Path(os.path.realpath(wt)) and r.worktree_given
|
|
assert r.base_ref is None and r.base_sha == "" and r.base_source == "head", "MAM_LOOP_BASE is ignored (D17)"
|
|
r = _runner("--worktree", str(wt2))
|
|
r.parse_args()
|
|
assert r.worktree_root == Path(os.path.realpath(wt2)), "the CLI wins over MAM_LOOP_WORKTREE"
|
|
finally:
|
|
os.environ.pop("MAM_LOOP_WORKTREE", None)
|
|
os.environ.pop("MAM_LOOP_BASE", None)
|
|
|
|
|
|
def test_t2_7_runner_reads_mam_jobs_dir(wt_repo, tmp_path, monkeypatch):
|
|
x = tmp_path / "elsewhere" / "jobs"
|
|
(x / "jid21" / "rev-reports").mkdir(parents=True)
|
|
(x / "jid21.json").write_text(json.dumps({"status": "completed"}))
|
|
(x / "jid21" / "rev-reports" / "report-final.md").write_text("r\n")
|
|
monkeypatch.setenv("MAM_JOBS_DIR", str(x))
|
|
r = _runner()
|
|
r.parse_args()
|
|
assert r.wait_for_job("jid21", max_wait=2) is True
|
|
assert r.find_report("jid21") == x / "jid21" / "rev-reports" / "report-final.md"
|
|
|
|
|
|
@pytest.mark.parametrize("opts,env", [
|
|
(["--worktree", "/nowhere"], {}),
|
|
(["--base", "HEAD"], {}),
|
|
(["--review-only"], {}),
|
|
([], {"MAM_LOOP_WORKTREE": "/nowhere"}),
|
|
])
|
|
def test_t2_8_legacy_shell_loop_refuses_the_new_options(tmp_path, opts, env):
|
|
marker = tmp_path / "loop-marker"
|
|
run_env = {**os.environ, "MAM_LOOP_PY": "0", "MAM_LOOP_NO_FREEZE": "1",
|
|
"MAM_LOOP_MARKER": str(marker), **env}
|
|
res = subprocess.run(["bash", str(RUN_LOOP_SH), "--creator", "c", "--task", "t", *opts],
|
|
capture_output=True, text=True, env=run_env, cwd=str(tmp_path), timeout=60)
|
|
assert res.returncode == 1
|
|
assert "require the Python loop (MAM_LOOP_PY=1)" in res.stderr
|
|
assert not marker.exists(), "refused before the loop lock"
|
|
|
|
|
|
def _commit_impl(wt: Path) -> str:
|
|
"""Implementation committed in the worktree outside the loop; returns the pre-implementation sha."""
|
|
before = _git(wt, "rev-parse", "HEAD")
|
|
(wt / "impl.txt").write_text("impl line\n")
|
|
_git(wt, "add", "impl.txt")
|
|
_git(wt, "commit", "-q", "-m", "impl")
|
|
return before
|
|
|
|
|
|
def test_t2_9a_committed_impl_without_base_is_refused(wt_repo, monkeypatch, tmp_path, capsys):
|
|
main, wt = wt_repo
|
|
_commit_impl(wt)
|
|
rc, calls, _ = _drive(monkeypatch, tmp_path, ["--worktree", str(wt)])
|
|
assert rc == 1
|
|
assert _roles(calls, "Reviewer") == []
|
|
assert "empty change set since base" in capsys.readouterr().err
|
|
|
|
|
|
def test_t2_9b_review_only_reviews_the_committed_range(wt_repo, monkeypatch, tmp_path):
|
|
main, wt = wt_repo
|
|
before = _commit_impl(wt)
|
|
rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", before, "--review-only"])
|
|
assert rc == 0
|
|
assert _roles(calls, "Worker") == [], "Phase 2 is skipped"
|
|
review = _roles(calls, "Reviewer")[0]
|
|
assert "+impl line" in review
|
|
assert f"- base: {before} (출처: --base)" in review
|
|
assert "(base..head 커밋: 1개, 미커밋 변경: 없음)" in review
|
|
|
|
|
|
def test_t2_10_base_must_be_an_ancestor(wt_repo):
|
|
main, wt = wt_repo
|
|
_git(main, "commit", "-q", "--allow-empty", "-m", "side") # on main's branch, not in feat
|
|
side = _git(main, "rev-parse", "HEAD")
|
|
for ref in (side, "no-such-ref"):
|
|
with pytest.raises(SystemExit) as e:
|
|
_runner("--worktree", str(wt), "--base", ref).parse_args()
|
|
assert e.value.code == 1, ref
|
|
|
|
|
|
def test_t2_11_review_only_needs_base_and_no_plan(wt_repo):
|
|
main, wt = wt_repo
|
|
head = _git(wt, "rev-parse", "HEAD")
|
|
for extra in (["--review-only"], ["--review-only", "--base", head, "--plan"]):
|
|
with pytest.raises(SystemExit) as e:
|
|
_runner("--worktree", str(wt), *extra).parse_args()
|
|
assert e.value.code == 1, extra
|
|
|
|
|
|
def test_t2_12_guard_is_off_without_options(wt_repo, monkeypatch, tmp_path):
|
|
"""Compatibility: no --worktree/--base, nothing changed - the review is
|
|
still requested exactly as before."""
|
|
rc, calls, _ = _drive(monkeypatch, tmp_path, [])
|
|
assert rc == 0
|
|
assert len(_roles(calls, "Reviewer")) == 1
|
|
|
|
|
|
def test_t2_13_base_without_review_only_still_implements(wt_repo, monkeypatch, tmp_path):
|
|
main, wt = wt_repo
|
|
before = _commit_impl(wt)
|
|
rc, calls, _ = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", before])
|
|
assert rc == 0
|
|
assert len(_roles(calls, "Worker")) == 1
|
|
assert "+impl line" in _roles(calls, "Reviewer")[0]
|
|
|
|
|
|
def test_t2_14_undeterminable_change_set_is_refused(wt_repo, monkeypatch, tmp_path, capsys):
|
|
main, wt = wt_repo
|
|
|
|
def corrupt(r):
|
|
r.base_commit = "0" * 40 # not an object: git diff exits 128
|
|
|
|
rc, calls, _ = _drive(monkeypatch, tmp_path, ["--worktree", str(wt)], on_worker=corrupt)
|
|
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 _agent_args(mock_state_file, since=0) -> list:
|
|
"""The tokens after `--` of every `herdr agent start` call: herdr passes
|
|
them to the canonical agent executable (--kind), never to a shell."""
|
|
calls = json.loads(Path(mock_state_file).read_text()).get("calls", [])[since:]
|
|
starts = [c for c in calls if any(c[i:i + 2] == ["agent", "start"] for i in range(len(c) - 1))]
|
|
return [c[c.index("--") + 1:] if "--" in c else [] for c in starts]
|
|
|
|
|
|
def _assert_no_env_in_agent_args(mock_state_file, since=0) -> None:
|
|
arg_lists = _agent_args(mock_state_file, since)
|
|
assert arg_lists, "no agent start was recorded"
|
|
for args in arg_lists:
|
|
assert "env" not in args, args
|
|
assert not [a for a in args if a.startswith(tuple(f"{k}=" for k in CP_ENV))], args
|
|
|
|
|
|
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"]
|
|
_assert_no_env_in_agent_args(mock_herdr)
|
|
|
|
|
|
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
|
|
# The stopped session left an idle pane behind. A binding that must be
|
|
# forwarded is never put in front of `agent start --` (herdr passes those
|
|
# tokens to the agent as arguments, review R1): resume takes a new pane,
|
|
# whose creation call carries the binding as --env.
|
|
flags = _env_flags(mock_herdr, since)
|
|
for k, v in zip(CP_ENV, _binding(tmp).values()):
|
|
assert f"{k}={v}" in flags, (res.stderr[-1500:], flags)
|
|
_assert_no_env_in_agent_args(mock_herdr, since)
|
|
(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)
|
|
|
|
|
|
def test_t3_4c_stop_keeps_the_audit_trail_in_the_rows_log_root(mam_sandbox, mock_herdr, mock_agents, monkeypatch):
|
|
"""Review R3: with no MAM_JOBS_DIR / DELEGATE_JOB_LOGS_DIR in the caller,
|
|
stop cancels the job in the row's registry and writes its history to the
|
|
row's audit-log root - not to $WORKSPACE_ROOT/.mam/delegate_job_logs."""
|
|
from lib_py.delegation import registry
|
|
tmp = mam_sandbox
|
|
jobs, logs = tmp / "custom-jobs", tmp / "custom-logs"
|
|
# the job was registered under that binding (its `registered` audit entry lives there)
|
|
monkeypatch.setenv("DELEGATE_JOB_LOGS_DIR", str(logs))
|
|
jid = registry.register_job("t3-4c", registry_dir=str(jobs), agent_session="herdr:logs-creator-claude")
|
|
binding = {**_binding(tmp), "mam_jobs_dir": str(jobs), "delegate_job_logs_dir": str(logs)}
|
|
_mutate(tmp, f"""
|
|
d['herdr_sessions'] = [{{
|
|
'name': 'logs-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 not in ("MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR")}
|
|
res = subprocess.run(["bash", _skill(tmp, "multi-agent-mux-stop", "scripts", "stop_session.sh"),
|
|
"--session", "logs-creator-claude", "--agent", "claude"],
|
|
capture_output=True, text=True, env=env, timeout=120)
|
|
assert res.returncode == 0, res.stdout + res.stderr
|
|
assert json.loads((jobs / f"{jid}.json").read_text())["status"] == "cancelled"
|
|
assert not (tmp / ".mam" / "delegate_job_logs" / jid).exists(), "history misrouted to the default log root"
|
|
audit = [json.loads(l) for l in (logs / jid / "events.ndjson").read_text().splitlines() if l.strip()]
|
|
assert {"progress", "cancelled"} <= {e.get("source_event") for e in audit}, audit
|
|
assert any(e.get("event") == "status_changed" for e in audit), audit
|
|
|
|
|
|
def test_t2_15_relative_mam_jobs_dir_means_the_same_dir_to_runner_and_wrapper(tmp_path, monkeypatch):
|
|
"""Review R2: the wrapper runs with cwd=workspace_root and absolutises a
|
|
relative registry there; the runner must look in that same directory."""
|
|
from lib_py.loop import runner as lr
|
|
ctl, caller = tmp_path / "control", tmp_path / "caller"
|
|
ctl.mkdir()
|
|
caller.mkdir()
|
|
monkeypatch.chdir(caller)
|
|
monkeypatch.setenv("WORKSPACE_ROOT", str(ctl))
|
|
monkeypatch.delenv("MAM_REAL_ROOT", raising=False)
|
|
monkeypatch.setenv("MAM_JOBS_DIR", ".mam/jobs")
|
|
r = _runner()
|
|
jobs = Path(os.path.realpath(ctl)) / ".mam" / "jobs"
|
|
assert r.jobs_dir == jobs and r.jobs_dir.is_absolute()
|
|
report = jobs / "j21" / "reviewer-reports" / "report-final.md"
|
|
report.parent.mkdir(parents=True)
|
|
report.write_text("[VERDICT: PASS]\n")
|
|
(jobs / "j21.json").write_text(json.dumps({"status": "completed"}))
|
|
assert r.find_report("j21") == report
|
|
assert r.wait_for_job("j21", max_wait=2) is True
|
|
seen = []
|
|
|
|
def fake_run(cmd, **kw):
|
|
seen.append(cmd)
|
|
|
|
class P:
|
|
returncode = 0
|
|
stdout = "registered job: j22\n"
|
|
stderr = ""
|
|
return P()
|
|
|
|
monkeypatch.setattr(lr.subprocess, "run", fake_run)
|
|
r.delegate_job("creator-claude-01", "claude", "Worker", "go")
|
|
assert seen[0][seen[0].index("--registry-dir") + 1] == str(jobs), "the wrapper is told the same absolute dir"
|
|
assert not (caller / ".mam").exists()
|