diff --git a/docs/design/worktree-creator-model.md b/docs/design/worktree-creator-model.md index 6a74936..64285f5 100644 --- a/docs/design/worktree-creator-model.md +++ b/docs/design/worktree-creator-model.md @@ -100,9 +100,9 @@ bash "$MAIN/.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh" --pa | 2 | 루프 러너 `--worktree` / `--base` / `--review-only`, 빈 변경분 fail-closed 가드, 러너의 `MAM_JOBS_DIR` 존중 | 완료(v4.6.0) | | 3 | 제어 평면 바인딩 영속(`control_plane`)·명시 전파(create/resume), stop·status·reconcile 의 `MAM_JOBS_DIR`·행 바인딩 존중 | 완료(v4.6.0). 루프 A 에서 바인딩 계산 실패 처리·조기 종료 경로 영속 보강 | | 4 | 워크트리 준비·해체 헬퍼, resume 을 원래 cwd 로만 허용, create 의 reconcile/watchdog 기동 위치, `MAM_NO_SUBSCRIBER_DAEMON`(prepare 가 설정). reconcile 의 루트 밖 cwd 재발견 거부는 제한으로 남김 | 루프 A (이 브랜치) | -| 5 | 리뷰 identity: (loop_id, base_sha, head_sha) 판정 기록·무효화, 리뷰어 검증 격리(detached 워크트리) | 루프 B (이 브랜치): 스냅샷 identity·스냅샷 diff·stale 재리뷰·승격 직전 재확인·`identity.json`(evidence), 원격 리뷰어 거부/diff-only | +| 5 | 리뷰 identity: (loop_id, base_sha, head_sha) 판정 기록·무효화, 리뷰어 검증 격리(detached 워크트리) | 루프 B (이 브랜치): 스냅샷 identity·스냅샷 diff·stale 재리뷰·승격 직전 재확인·`identity.json`(evidence), 원격 리뷰어 거부/diff-only. 리뷰 후속: dirty 서브모듈 거부(스냅샷·stale 검사), identity 모드 셀프 리뷰도 리뷰어 경로로 기록·승격, 리뷰 프롬프트의 검증 대상 단일화 | | 6 | 이 설계 문서 정정 | 완료(v4.6.0), 루프 A 에서 갱신 | -| 7 | 수용 사례 고정(실제 herdr 의 pane env 전파 포함) | 대기 — 수용 사례 8건 테스트는 루프 B 에서 아직 쓰지 않았다(후속), 시험 운영은 단계 C | +| 7 | 수용 사례 고정(실제 herdr 의 pane env 전파 포함) | 루프 B (이 브랜치): 수용 사례 8건 테스트 고정(`tests/test_issue21_acceptance.py`, A1~A8). pane env 전파는 mock herdr 의 `--env` 호출 단언까지이며, 실제 herdr pane 안의 값 확인과 시험 운영은 단계 C | | 8 | 다중 루프 네임스페이스·리뷰어 스케줄러 | 보류 | ## 6. 리뷰어에게 묻는 것 @@ -131,6 +131,6 @@ bash "$MAIN/.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh" --pa | delegate-job | `--registry-dir` > `MAM_JOBS_DIR` > `.mam/jobs`(`--workdir` 기준), 모두 절대화. 발행 명령이 감사 로그 루트(`DELEGATE_JOB_LOGS_DIR`)를 고정 | 문구·명령·리포트 경로 절대 | 작은따옴표가 든 경로는 지원 범위 밖 | | loop | `MAM_JOBS_DIR` 존중, 래퍼에 `--registry-dir` 명시. `--worktree` 로 소스 루트 분리 | diff·base·head 만 소스 루트. **구현이 루프 밖이면 `--base <구현 전 sha> --review-only` 필수**. `--worktree`/`--base` 가 있으면 빈 변경분 거부 | 루프 하나(8). 원격 리뷰어는 identity 모드에서 거부(기본) 또는 diff-only(`--remote-review diff-only`); 원격 리뷰어가 트리를 검증하게 하는 스냅샷 공유(공유 원격의 `refs/mam/review//`)는 범위 밖. 레거시 셸 루프(`MAM_LOOP_PY=0`)는 새 옵션 거부 | -실제 herdr 가 `pane split --env` / `workspace create --env` 로 pane 환경을 넘기는지는 1단계에서 mock 수준으로만 검증했다. 실제 전파는 항목 7 의 수용 사례로 고정한다. +실제 herdr 가 `pane split --env` / `workspace create --env` 로 pane 환경을 넘기는지는 mock 수준으로만 검증했다. 수용 사례 A2 는 러너가 바인딩 4개를 `--env` 로 싣고 `agent start --` 뒤에는 싣지 않음을 고정한다. 실제 herdr pane 안에서 값이 보이는지는 단계 C 시험 운영에서 확인한다. **호환성 메모 (루프 B)**: 1단계부터 있던 `--worktree`/`--base` + 원격 리뷰어 조합은 이전에는 원격 리뷰어가 도달할 수 없는 로컬 경로를 프롬프트에 담아 진행했다. 이제는 어떤 잡도 위임하기 전에 기본으로 거부하며, `--remote-review diff-only` 로 opt-in 할 수 있다. 옵션 없는 루프는 그대로다. 스냅샷 커밋은 ref 가 없어 gc 가 정리하고(기본 2주), 리뷰 워크트리는 라운드마다 지운다. diff --git a/tests/test_issue21_acceptance.py b/tests/test_issue21_acceptance.py new file mode 100644 index 0000000..7faaa77 --- /dev/null +++ b/tests/test_issue21_acceptance.py @@ -0,0 +1,278 @@ +"""Issue #21 item 7: the eight acceptance cases (A1-A8) of the worktree +creator model, one test (or pair) per case. + +Everything runs against mock herdr / stubbed delegation inside mam_sandbox or +tmp_path; nothing touches the real .mam. The real herdr pane (whether a pane +sees the --env values) and the trial operation belong to stage C - A2 pins +what the scripts hand to herdr. +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import time +from pathlib import Path + +from test_issue21_loop_b import LOCAL_ROWS, _drive, _reviews, _sidecars, _state +from test_issue21_phase2_loop_a import (REM, _calls, _prepare, _resume, _rows, _run, _skill, _wt, # noqa: F401 + ctl) +from test_issue21_worktree_prereqs import (CP_ENV, WRAPPER, _assert_no_env_in_agent_args, _binding, _dry_submit, + _env, _env_flags, _injected, _kill_timer, _only_job_id) +from test_issue21_worktree_prereqs import _git as _g +from test_issue21_worktree_prereqs import wt_repo # noqa: F401 + +CREATE = ("multi-agent-mux-create", "scripts", "create_session.sh") +HS = "multi-agent-mux" # listed as running by mock herdr: an existing server + + +def _raw_calls(state_file) -> list: + return json.loads(Path(state_file).read_text()).get("calls", []) + + +def _sibling(main: Path, state_file): + """A1 setup: a session on the existing server in the control root, then a + sibling worktree's creator on the same server. Returns (wt, since, rows before).""" + res = _run(main, CREATE, "--workspace", str(main), "--agent", "claude", "--role", "reviewer", + "--herdr-session", HS, "--no-onboard") + assert res.returncode == 0, res.stdout + res.stderr + before = _rows(main) + since = len(_raw_calls(state_file)) + wt = _wt(main) + res = _prepare(main, wt, "--creator-agent", "claude", "--herdr-session", HS) + assert res.returncode == 0, res.stdout + res.stderr + return wt, since, before + + +# A1 ------------------------------------------------------------------------- + +def test_a1_sibling_worktree_on_an_existing_herdr_server(ctl, mock_herdr, mock_agents): + main = ctl + wt, since, before = _sibling(main, mock_herdr) + new = _raw_calls(mock_herdr)[since:] + assert not [c for c in new if c[-1:] == ["server"]], "no server bootstrap on an existing server" + rows = _rows(main) + assert rows[0] == before[0], "the existing row is untouched" + creator = [r for r in rows if r.get("role") == "creator"] + assert len(rows) == 2 and len(creator) == 1 + assert rows[0].get("herdr_session") == creator[0].get("herdr_session") == HS + assert os.path.realpath(creator[0]["pane"]["cwd"]) == os.path.realpath(wt) + assert creator[0]["control_plane"]["workspace_root"] == os.path.realpath(main) + + +# A2 ------------------------------------------------------------------------- + +def test_a2_pane_env_carries_the_binding_not_the_agent_args(ctl, mock_herdr, mock_agents): + main = ctl + wt, since, _ = _sibling(main, mock_herdr) + flags = _env_flags(mock_herdr, since) + for k, v in zip(CP_ENV, _binding(main).values()): + assert f"{k}={v}" in flags, flags + spawns = [c for c in _calls(mock_herdr) if c[:2] in (["pane", "split"], ["workspace", "create"])] + assert spawns and any("--env" in c for c in spawns), "the binding rides on the pane-creating call" + _assert_no_env_in_agent_args(mock_herdr, since) + + +# A3 ------------------------------------------------------------------------- + +def test_a3_brief_start_and_report_route_to_the_control_root(tmp_path): + main, wt = tmp_path / "main", tmp_path / "wt" + main.mkdir() + _g(main, "init", "-q") + (main / "a.txt").write_text("a\n") + _g(main, "add", "a.txt") + _g(main, "commit", "-q", "-m", "init") + _g(main, "worktree", "add", "-q", "-b", "feat", str(wt)) + real = os.path.realpath(main) + res = _dry_submit(main, _env(tmp_path, {"WORKSPACE_ROOT": main}), "--workdir", str(main)) + assert res.returncode == 0, res.stderr + reg = main / ".mam" / "jobs" + jid = _only_job_id(reg) + assert f"{real}/.mam/jobs/{jid}/brief.md" in res.stdout + start = _injected(res.stdout, "start") + assert f"--registry-dir {real}/.mam/jobs " in start and start.startswith(f"DELEGATE_JOB_LOGS_DIR={real}/") + # the brief (not written by --dry-run) derives its report path from that same + # absolute registry dir, so the report lands under MAIN as well + wrapper = WRAPPER.read_text() + assert "- **Output Report Path**: $REGISTRY_DIR/$JOB_ID/$AGENT-reports/report-final.md" in wrapper + try: + r1 = subprocess.run(["bash", "-c", start + " --attempts 1"], capture_output=True, text=True, + env=_env(tmp_path), cwd=str(wt), timeout=120) # no control env, broker unreachable + assert r1.returncode == 2, r1.stderr + assert json.loads((reg / f"{jid}.json").read_text())["status"] != "pending" + finally: + _kill_timer(reg, jid) + assert not (wt / ".mam").exists(), "nothing lands in the worktree" + + +# A4 ------------------------------------------------------------------------- + +def _main_state(main: Path) -> tuple: + branch = _g(main, "branch", "--show-current") + return (_g(main, "rev-parse", "HEAD"), branch, _g(main, "status", "--porcelain"), + _g(main, "rev-parse", f"refs/heads/{branch}"), _g(main, "worktree", "list", "--porcelain")) + + +def test_a4_main_checkout_is_unchanged_by_the_whole_cycle(ctl, monkeypatch): + main = ctl + # what prepare adds to info/exclude is added up front, so status compares like for like + excl = main / ".git" / "info" / "exclude" + excl.write_text(excl.read_text() + "/.venv\n.pytest_cache/\n") + scratch = main.parent / f"{main.name}-a4" + scratch.mkdir() + pre = _main_state(main) + wt = _wt(main) + res = _prepare(main, wt) + assert res.returncode == 0, res.stdout + res.stderr + base = _g(wt, "rev-parse", "HEAD") + (wt / "impl.txt").write_text("impl line\n") + _g(wt, "add", "impl.txt") + _g(wt, "commit", "-q", "-m", "impl") + _state(monkeypatch, LOCAL_ROWS) + rc, calls, r = _drive(monkeypatch, scratch, ["--worktree", str(wt), "--base", base, "--review-only"]) + assert rc == 0 and len(_reviews(calls)) == 1 + res = _run(main, REM, "--path", str(wt)) + assert res.returncode == 0, res.stdout + res.stderr + assert _main_state(main) == pre, "HEAD, branch, status, branch tip and worktree list are unchanged" + assert "mam-review" not in _g(main, "worktree", "list", "--porcelain") + + +# A5 ------------------------------------------------------------------------- + +def _review_path(prompt: str) -> Path: + m = re.search(r"^- 검증 경로: (.+)$", prompt, re.M) + assert m, prompt[:800] + return Path(m.group(1)) + + +def _commit_h2(wt: Path) -> str: + (wt / "impl.txt").write_text("impl line\nsecond commit\n") + _g(wt, "commit", "-q", "-am", "h2") + return _g(wt, "rev-parse", "HEAD") + + +def test_a5_fixed_sha_review_while_the_creator_keeps_committing(wt_repo, monkeypatch, tmp_path): + main, wt = wt_repo + base = _g(wt, "rev-parse", "HEAD") + (wt / "impl.txt").write_text("impl line\n") + _g(wt, "add", "impl.txt") + _g(wt, "commit", "-q", "-m", "h1") + h1 = _g(wt, "rev-parse", "HEAD") + seen = {} + + def reviewer(r, n, prompt): + if seen: + return + path = _review_path(prompt) + seen["head"] = _g(path, "rev-parse", "HEAD") + seen["symref"] = subprocess.run(["git", "-C", str(path), "symbolic-ref", "-q", "HEAD"]).returncode + seen["content"] = (path / "impl.txt").read_text() + seen["h2"] = _commit_h2(wt) + + rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", base, "--review-only"], + on_review=reviewer, max_loop=2) + assert rc == 0 + assert (seen["head"], seen["symref"], seen["content"]) == (h1, 1, "impl line\n"), "round 1 saw H1, detached" + assert [role for _, role, _ in calls] == ["Reviewer", "Reviewer"], "no correction job for a stale round" + assert f"- head: {seen['h2']}" in _reviews(calls)[1][1] + rounds = r._rounds + assert rounds[0]["stale"] is True and rounds[1]["head_sha"] == seen["h2"] + (side,) = _sidecars(main) + data = json.loads(side.read_text()) + assert (data["head_sha"], data["evidence"]) == (seen["h2"], "review-tree") + + +def test_a5b_a_stale_last_round_is_not_accepted(wt_repo, monkeypatch, tmp_path, capsys): + main, wt = wt_repo + base = _g(wt, "rev-parse", "HEAD") + (wt / "impl.txt").write_text("impl line\n") + _g(wt, "add", "impl.txt") + _g(wt, "commit", "-q", "-m", "h1") + rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", base, "--review-only"], + on_review=lambda *_: _commit_h2(wt), max_loop=1) + assert rc == 1 and "changed during review" in capsys.readouterr().err + assert _sidecars(main) == [] + + +# A6 ------------------------------------------------------------------------- + +def test_a6_row_recovery_and_resume_only_at_the_original_cwd(ctl, mock_herdr, mock_agents): + main = ctl + wt = _wt(main) + res = _prepare(main, wt, "--creator-agent", "claude") + assert res.returncode == 0, res.stdout + res.stderr + (row,) = _rows(main) + name, binding = row["name"], row["control_plane"] + # the creator died: gone from herdr, the row terminated (reconcile drift A) + st = json.loads(Path(mock_herdr).read_text()) + st["agents"] = {} + Path(mock_herdr).write_text(json.dumps(st)) + from test_issue21_phase2_loop_a import _mutate + _mutate(main, "d['herdr_sessions'][0]['status'] = 'terminated'") + res = _resume(main, main, name) + assert res.returncode == 2 and (str(wt) in res.stderr or os.path.realpath(wt) in res.stderr), res.stderr + since = len(_raw_calls(mock_herdr)) + res = _resume(main, wt, name) + assert res.returncode == 0, res.stdout + res.stderr + (row,) = _rows(main) + assert row["status"] == "running" and row["control_plane"] == binding + flags = _env_flags(mock_herdr, since) + assert f"WORKSPACE_ROOT={binding['workspace_root']}" in flags, flags + + +# A7 ------------------------------------------------------------------------- + +def test_a7_reviewer_test_output_stays_out_of_the_source_and_the_sandbox(wt_repo, monkeypatch, tmp_path): + main, wt = wt_repo + base = _g(wt, "rev-parse", "HEAD") + (wt / "impl.txt").write_text("impl line\n") + status0 = _g(wt, "status", "--porcelain") + paths = [] + + def reviewer_runs_tests(r, n, prompt): + path = _review_path(prompt) + paths.append(path) + (path / ".pytest_cache").mkdir() + (path / ".pytest_cache" / "v").write_text("x\n") + (path / "__pycache__").mkdir() + (path / "__pycache__" / "x.pyc").write_bytes(b"\0") + (path / "tmp-output.txt").write_text("output\n") + + rc, calls, r = _drive(monkeypatch, tmp_path, ["--worktree", str(wt), "--base", base, "--review-only"], + on_review=reviewer_runs_tests) + assert rc == 0 and len(paths) == 1 + assert _g(wt, "status", "--porcelain") == status0, "the source worktree did not change" + assert not paths[0].exists() and str(paths[0]) not in _g(main, "worktree", "list", "--porcelain") + prompt = _reviews(calls)[0][1] + assert "PYTHONDONTWRITEBYTECODE=1" in prompt and "-p no:cacheprovider" in prompt + assert not (wt / ".mam").exists() and not (wt / ".agents").exists(), "state stays in the control root" + assert len(_sidecars(main)) == 1 + + +# A8 ------------------------------------------------------------------------- + +def test_a8_second_loop_is_refused_before_a_scheduler_exists(ctl): + main = ctl + wt2 = _wt(main, "wt2") + _g(main, "worktree", "add", "-q", "--detach", str(wt2)) + holder = subprocess.Popen(["sleep", "60"]) + try: + lstart = subprocess.run(["ps", "-p", str(holder.pid), "-o", "lstart="], capture_output=True, + text=True).stdout.strip() + marker = main.parent / f"{main.name}-loop-marker" + content = f"pid={holder.pid}\nlstart={lstart}\nstarted={time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())}\n" + marker.write_text(content) + env = {**os.environ, "WORKSPACE_ROOT": str(main), "MAM_LOOP_MARKER": str(marker), "MAM_LOOP_NO_FREEZE": "1"} + env.pop("MAM_LOOP_PY", None) + res = subprocess.run(["bash", _skill(main, "multi-agent-mux-loop", "scripts", "run_loop.sh"), + "--creator", "c", "--task", "t", "--worktree", str(wt2), "--base", "HEAD", + "--review-only", "--reviewer", "r"], + capture_output=True, text=True, env=env, cwd=str(main), timeout=120) + assert res.returncode == 1 and "already running" in (res.stdout + res.stderr), res.stdout + res.stderr + assert list((main / ".mam" / "jobs").glob("*.json")) == [], "no job was registered" + assert marker.read_text() == content, "the live holder's marker is kept" + finally: + holder.kill() + holder.wait()