Compare commits

...
2 Commits
Author SHA1 Message Date
GodopuandClaude Fable 5.1 63d816468e test(issue-57): the derivable audit-log root is recorded, not forwarded
New: prefix()/CLI forward three keys for a sibling binding and four for an
explicit root; create records the sibling on the row, omits it from the
pane's --env and still pins it in the injected publish commands; the
issue's scene (a pane with WORKSPACE_ROOT and MAM_JOBS_DIR only) moves its
audit log with --registry-dir and leaves the control root alone;
worktree_prepare's pane --env carries no DELEGATE_JOB_LOGS_DIR.

The three existing four-key forwarding assertions (user-approved) compare
FORWARDED_ENV = CP_ENV[:3]; CP_ENV and the negative assertions are kept.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 13:11:29 +00:00
GodopuandClaude Fable 5.1 c57e7667aa fix(control-plane): forward only what the pane cannot derive - the sibling audit-log root stays on the row (Issue #57)
A worktree creator's binding always differs from its --workspace default,
so prefix() put DELEGATE_JOB_LOGS_DIR=<control root>/.mam/delegate_job_logs
into every such pane for good, and the env-first rule then kept an
experiment's --registry-dir from moving its audit log. forwarded_keys()
leaves that key out when it equals the sibling of mam_jobs_dir; a
non-sibling (explicit) root is forwarded as before. describe(), the forward
and whitespace checks and the row's four keys are unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 13:11:29 +00:00
5 changed files with 119 additions and 10 deletions

No files matched your search

+15 -1
View File
@@ -5,6 +5,10 @@ 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`.
The audit-log root is recorded but not forwarded when it is derivable from
the registry (its sibling delegate_job_logs, Issue #57): in the pane the
registry alone then decides where the audit log goes, so `--registry-dir`
moves both.
CLI (used by lib.sh):
python -m lib_py.control_plane json <workspace> [<row control_plane JSON>]
@@ -70,10 +74,20 @@ def describe(workspace: str, row: Optional[dict] = None) -> dict:
"whitespace": any(any(c.isspace() for c in v) for v in b.values()), "source": source}
def forwarded_keys(desc: dict) -> list:
"""The binding keys the pane receives: all four, minus the audit-log root
when it is the sibling of the registry and so derivable there (Issue #57).
An explicit, non-sibling root is forwarded as before."""
keys = list(KEYS)
if desc.get("delegate_job_logs_dir") == _abs(mqtt_common.sibling_logs_dir(desc["mam_jobs_dir"])):
keys.remove("delegate_job_logs_dir")
return keys
def prefix(desc: dict) -> str:
if not desc.get("forward"):
return ""
return "".join(f"{ENV[k]}={shlex.quote(desc[k])} " for k in KEYS)
return "".join(f"{ENV[k]}={shlex.quote(desc[k])} " for k in forwarded_keys(desc))
def main(argv=None) -> int:
+3 -3
View File
@@ -35,7 +35,7 @@ MAM 은 **워크스페이스 = `.agents/skills` 가 들어 있는 체크아웃
- **creator = 워크트리 전용 인스턴스.** 제품 코드를 편집·커밋하는 유일한 에이전트이므로 cwd 가 곧 이슈 브랜치여야 한다. 오케스트레이터 커밋과 루프의 리포트 승격(`runner.py` 의 Promotion 블록 → 제어 루트 `.agents/reports/`)은 별도 소유다. 이슈마다 `creator-<agent>-<issue>` 로 만들고 끝나면 정지(대화 보존, 필요 시 재개). 재개하려면 같은 워크트리 경로가 있어야 한다(대화 키가 cwd).
- **리뷰어 풀 = 본 체크아웃 공유.** 리뷰어는 소스를 편집하지 않는다. 그러나 테스트 실행은 쓰기를 동반하므로 '읽기 전용'이 아니다. `--worktree`/`--base` 에서는 러너가 매 라운드를 스냅샷 `(loop_id, base, head, tree)` 에 묶고(항목 5), 리뷰어는 그 스냅샷의 detached 리뷰 워크트리(기본) 또는 env 격리 지침으로 소스 루트에서 검증한다. 리뷰 중 소스가 바뀐 라운드의 승인은 버리고 다시 리뷰하며, 승격 직전에도 다시 확인한다. 원격 리뷰어는 리뷰 트리에 닿을 수 없어 기본 거부, `--remote-review diff-only` 로만 받는다. 리뷰가 병목이면 리뷰어 인스턴스를 추가한다.
- **오케스트레이터 커밋 = 워크트리.** 본 체크아웃은 리뷰어의 작업 공간이므로 오케스트레이터는 그곳에서 브랜치를 바꾸지 않는다.
- **제어 평면 = 본 체크아웃의 `.mam` 하나.** create/resume 이 바인딩(`WORKSPACE_ROOT`·`AGENT_SESSIONS_YAML`·`MAM_JOBS_DIR`·`DELEGATE_JOB_LOGS_DIR`)을 행(`control_plane`)에 영속하고, `--workspace` 기본값과 다르면 pane 에 명시 전파한다(항목 3). 호출자 env 만으로는 pane 상속이 보장되지 않는다(lib.sh:667-680 — new-session 은 선행 `K=V` 토큰만 `--env` 로 바꾼다). 행의 `pane.cwd` 는 워크트리다.
- **제어 평면 = 본 체크아웃의 `.mam` 하나.** create/resume 이 바인딩(`WORKSPACE_ROOT`·`AGENT_SESSIONS_YAML`·`MAM_JOBS_DIR`·`DELEGATE_JOB_LOGS_DIR`)을 행(`control_plane`)에 영속하고, `--workspace` 기본값과 다르면 pane 에 명시 전파한다(항목 3; 유도 가능한 감사 로그 루트 = `MAM_JOBS_DIR` 의 형제는 행에만 기록하고 전파하지 않는다, #57). 호출자 env 만으로는 pane 상속이 보장되지 않는다(lib.sh:667-680 — new-session 은 선행 `K=V` 토큰만 `--env` 로 바꾼다). 행의 `pane.cwd` 는 워크트리다.
## 3. C 안의 동작 방식 (1단계 + 2단계 루프 A 반영)
@@ -123,7 +123,7 @@ bash "$MAIN/.agents/skills/multi-agent-mux-stop/scripts/worktree_remove.sh" --pa
| 스킬 | 제어 평면 바인딩 | 워크트리 cwd 행 | 1단계 이후 남은 제한 (항목) |
|---|---|---|---|
| create | env 에서 결정 → 행 `control_plane` 에 항상 영속. `--workspace` 기본값과 다르면 새 pane 의 생성 호출(`pane split`/`workspace create`)에 `--env` 로 전파한다. 전파가 필요하면 idle pane 을 재사용하지 않는다(`agent start -- <args>` 는 `--` 뒤를 에이전트 인자로 넘기므로 `env K=V` 를 쓸 수 없다). 전파가 필요한데 경로에 공백이 있으면 exit 2. `~/.local/bin` wrapper 경로는 기록만 하고 경고 | pane.cwd = `--workspace`. 바인딩 계산 실패 시 워크트리면 exit 2, 제어 루트면 경고 | — (루프 A: 즉시 reconcile·watchdog 은 제어 루트 코드와 `WORKSPACE_ROOT` 로 기동, prepare 가 `MAM_NO_SUBSCRIBER_DAEMON=1`) |
| create | env 에서 결정 → 행 `control_plane` 에 항상 영속. `--workspace` 기본값과 다르면 새 pane 의 생성 호출(`pane split`/`workspace create`)에 바인딩(유도 가능한 감사 로그 루트 제외)을 `--env` 로 전파한다. 전파가 필요하면 idle pane 을 재사용하지 않는다(`agent start -- <args>` 는 `--` 뒤를 에이전트 인자로 넘기므로 `env K=V` 를 쓸 수 없다). 전파가 필요한데 경로에 공백이 있으면 exit 2. `~/.local/bin` wrapper 경로는 기록만 하고 경고 | pane.cwd = `--workspace`. 바인딩 계산 실패 시 워크트리면 exit 2, 제어 루트면 경고 | — (루프 A: 즉시 reconcile·watchdog 은 제어 루트 코드와 `WORKSPACE_ROOT` 로 기동, prepare 가 `MAM_NO_SUBSCRIBER_DAEMON=1`) |
| resume | 행 바인딩 우선(호출자 env 와 다르면 stderr 경고) 재전파. 바인딩 없는 기존 행은 호출자 바인딩으로 채움. 이미 살아 있는 세션에도 바인딩을 영속한다. 계산 실패 시 워크트리면 exit 2 | 원래 cwd 로만: 경로 불일치·부재 시 exit 2 | 워크트리 삭제 시 재개 불가(대화 보존, 같은 경로에 다시 만들면 재개) |
| stop | `MAM_JOBS_DIR` > 행의 `mam_jobs_dir` > `$WORKSPACE_ROOT/.mam/jobs` 순으로 잡 조회·발행. 감사 로그도 `DELEGATE_JOB_LOGS_DIR` > 행의 `delegate_job_logs_dir` 순 | 행 cwd 로 uuid 캡처·purge. 워크트리 해체는 `worktree_remove.sh` 가 판정(잠금·세션·잡·청결·서브모듈) 뒤에만 정지·제거, 서브모듈 워크트리는 검증 후 `--force` 1회 | — |
| status | `MAM_JOBS_DIR`·행 `mam_jobs_dir`·감사 로그(`DELEGATE_JOB_LOGS_DIR`·행 `delegate_job_logs_dir`)·기존 후보 순 조회 | 행 cwd 로 대화 키 | — |
@@ -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/<loop_id>/<n>`)는 범위 밖. 레거시 셸 루프(`MAM_LOOP_PY=0`)는 새 옵션 거부 |
실제 herdr 가 `pane split --env` / `workspace create --env` 로 pane 환경을 넘기는지는 mock 수준으로만 검증했다. 수용 사례 A2 는 러너가 바인딩 4개를 `--env` 로 싣고 `agent start --` 뒤에는 싣지 않음을 고정한다. 실제 herdr pane 안에서 값이 보이는지는 단계 C 시험 운영에서 확인한다.
실제 herdr 가 `pane split --env` / `workspace create --env` 로 pane 환경을 넘기는지는 mock 수준으로만 검증했다. 수용 사례 A2 는 러너가 바인딩(유도 가능한 감사 로그 루트 제외)을 `--env` 로 싣고 `agent start --` 뒤에는 싣지 않음을 고정한다. 실제 herdr pane 안에서 값이 보이는지는 단계 C 시험 운영에서 확인한다.
**호환성 메모 (루프 B)**: 1단계부터 있던 `--worktree`/`--base` + 원격 리뷰어 조합은 이전에는 원격 리뷰어가 도달할 수 없는 로컬 경로를 프롬프트에 담아 진행했다. 이제는 어떤 잡도 위임하기 전에 기본으로 거부하며, `--remote-review diff-only` 로 opt-in 할 수 있다. 옵션 없는 루프는 그대로다. 스냅샷 커밋은 ref 가 없어 gc 가 정리하고(기본 2주), 리뷰 워크트리는 라운드마다 지운다.
+3 -2
View File
@@ -21,7 +21,8 @@ import pytest
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,
from test_issue21_worktree_prereqs import (CP_ENV, FORWARDED_ENV, WRAPPER, _assert_no_env_in_agent_args, _binding, # noqa: F401
_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
@@ -70,7 +71,7 @@ def test_a2_pane_env_carries_the_binding_not_the_agent_args(ctl, mock_herdr, moc
main = ctl
wt, since, _ = _sibling(main, mock_herdr)
flags = _env_flags(mock_herdr, since)
for k, v in zip(CP_ENV, _binding(main).values()):
for k, v in zip(FORWARDED_ENV, list(_binding(main).values())[:3]):
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"
+4 -2
View File
@@ -493,6 +493,8 @@ def test_t2_14_undeterminable_change_set_is_refused(wt_repo, monkeypatch, tmp_pa
# ---------------------------------------------------------------------------
CP_ENV = ("WORKSPACE_ROOT", "AGENT_SESSIONS_YAML", "MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR")
# Issue #57: the derivable audit-log root is recorded on the row, not forwarded
FORWARDED_ENV = CP_ENV[:3]
def _binding(root: Path) -> dict:
@@ -576,7 +578,7 @@ def test_t3_1_create_persists_and_forwards_a_foreign_binding(mam_sandbox, mock_h
(row,) = _rows(tmp)
assert row["control_plane"] == _binding(tmp)
flags = _env_flags(mock_herdr)
for k, v in zip(CP_ENV, _binding(tmp).values()):
for k, v in zip(FORWARDED_ENV, list(_binding(tmp).values())[:3]):
assert f"{k}={v}" in flags, flags
assert "WORKSPACE_ROOT=" not in row["pane"]["cmd_full"]
assert "WORKSPACE_ROOT=" not in row["start_command"]
@@ -625,7 +627,7 @@ def test_t3_3_4_stop_keeps_the_binding_and_resume_reforwards_it(mam_sandbox, moc
# 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()):
for k, v in zip(FORWARDED_ENV, list(_binding(tmp).values())[:3]):
assert f"{k}={v}" in flags, (res.stderr[-1500:], flags)
_assert_no_env_in_agent_args(mock_herdr, since)
(row,) = _rows(tmp)
+94 -2
View File
@@ -38,6 +38,7 @@ from lib_py import control_plane # noqa: E402
from lib_py.delegation import engine, job_subscriber, mqtt_common, registry # noqa: E402
from test_issue21_phase2_loop_a import _mutate, _prepare, _rows, _skill, _wt # noqa: E402
from test_issue21_phase2_loop_a import ctl # noqa: E402,F401
from test_issue21_worktree_prereqs import _env_flags, _sent_texts # noqa: E402
UNREACHABLE = {"MQTT_BROKER": "127.0.0.1", "MQTT_PORT": "9", "MQTT_TLS": "0"}
DROP = ("MAM_JOBS_DIR", "DELEGATE_JOB_LOGS_DIR")
@@ -153,9 +154,9 @@ def test_registry_cli_writes_and_reads_next_to_the_registry(moved):
# L3 — the wrapper's last line names the directory it wrote
# ---------------------------------------------------------------------------
def _wrapper(ws: Path, *args, **extra) -> subprocess.CompletedProcess:
def _wrapper(ws: Path, *args, cwd: Path = None, **extra) -> subprocess.CompletedProcess:
return subprocess.run(["bash", str(WRAPPER), *args], capture_output=True, text=True,
env=_env(ws, **extra), cwd=str(ws), timeout=120)
env=_env(ws, **extra), cwd=str(cwd or ws), timeout=120)
def _only_job_id(reg: Path) -> str:
@@ -280,6 +281,10 @@ def test_worktree_prepare_keeps_the_logs_next_to_the_control_root_registry(ctl,
jid = row["delegate_job_id"]
assert (main / "elsewhere" / "delegate_job_logs" / jid / "status.json").is_file()
assert not (main / ".mam" / "delegate_job_logs").exists()
# the pane carries the registry, not the audit-log root derived from it
flags = _env_flags(mock_herdr)
assert any(f == f"MAM_JOBS_DIR={os.path.realpath(reg)}" for f in flags), flags
assert not [f for f in flags if f.startswith("DELEGATE_JOB_LOGS_DIR=")], flags
# ---------------------------------------------------------------------------
@@ -380,3 +385,90 @@ def test_old_audit_terminal_does_not_complete_the_redispatched_attempt(moved, mo
pending, terminal = {jid}, {}
job_subscriber._check_disk_fallback(pending, str(reg), terminal, {jid: rec["dispatch_token"]}, {jid: 2})
assert pending == set() and terminal == {jid: "completed"}
# ---------------------------------------------------------------------------
# plan §2-B (user decision): the derivable audit-log root is recorded, not forwarded
# ---------------------------------------------------------------------------
def _cp_cli(ws: Path, *args, **extra) -> subprocess.CompletedProcess:
return subprocess.run([sys.executable, "-m", "lib_py.control_plane", *args], capture_output=True, text=True,
env=_env(ws, **extra), cwd=str(ws), timeout=60)
def test_prefix_forwards_only_what_cannot_be_derived(moved, monkeypatch, tmp_path):
ws, reg, logs = moved
monkeypatch.setenv("MAM_JOBS_DIR", str(reg))
d = control_plane.describe(str(ws / "wt"))
assert d["forward"] is True
assert d["delegate_job_logs_dir"] == os.path.realpath(logs), "the row still records the sibling"
p = control_plane.prefix(d)
assert "WORKSPACE_ROOT=" in p and "AGENT_SESSIONS_YAML=" in p and f"MAM_JOBS_DIR={os.path.realpath(reg)} " in p, p
assert "DELEGATE_JOB_LOGS_DIR=" not in p, p
# an audit-log root that cannot be derived is still forwarded
custom = tmp_path / "custom-logs"
monkeypatch.setenv("DELEGATE_JOB_LOGS_DIR", str(custom))
d2 = control_plane.describe(str(ws / "wt"))
assert d2["delegate_job_logs_dir"] == os.path.realpath(custom)
assert f"DELEGATE_JOB_LOGS_DIR={os.path.realpath(custom)} " in control_plane.prefix(d2)
# the CLI lib.sh calls agrees on both
monkeypatch.delenv("DELEGATE_JOB_LOGS_DIR")
res = _cp_cli(ws, "json", str(ws / "wt"), MAM_JOBS_DIR=str(reg))
assert res.returncode == 0, res.stderr
cli = _cp_cli(ws, "prefix", res.stdout.strip(), MAM_JOBS_DIR=str(reg))
assert cli.returncode == 0 and "MAM_JOBS_DIR=" in cli.stdout and "DELEGATE_JOB_LOGS_DIR=" not in cli.stdout, cli.stdout
res = _cp_cli(ws, "json", str(ws / "wt"), MAM_JOBS_DIR=str(reg), DELEGATE_JOB_LOGS_DIR=str(custom))
cli = _cp_cli(ws, "prefix", res.stdout.strip(), MAM_JOBS_DIR=str(reg), DELEGATE_JOB_LOGS_DIR=str(custom))
assert f"DELEGATE_JOB_LOGS_DIR={os.path.realpath(custom)} " in cli.stdout, cli.stdout
def test_create_binds_the_sibling_and_does_not_forward_it(mam_sandbox, mock_herdr, mock_agents, monkeypatch):
tmp = mam_sandbox
reg, sibling = tmp / "elsewhere" / "jobs", tmp / "elsewhere" / "delegate_job_logs"
monkeypatch.setenv("MAM_JOBS_DIR", str(reg))
monkeypatch.delenv("DELEGATE_JOB_LOGS_DIR", raising=False)
wt = tmp / "wt"
wt.mkdir()
res = subprocess.run(["bash", _skill(tmp, "multi-agent-mux-create", "scripts", "create_session.sh"),
"--workspace", str(wt), "--agent", "claude", "--role", "creator", "--onboard"],
capture_output=True, text=True, cwd=str(tmp), timeout=180)
assert res.returncode == 0, res.stdout + res.stderr
(row,) = _rows(tmp)
assert row["control_plane"]["mam_jobs_dir"] == os.path.realpath(reg)
assert row["control_plane"]["delegate_job_logs_dir"] == os.path.realpath(sibling)
flags = _env_flags(mock_herdr)
assert any(f == f"MAM_JOBS_DIR={os.path.realpath(reg)}" for f in flags), flags
assert not [f for f in flags if f.startswith("DELEGATE_JOB_LOGS_DIR=")], flags
# the injected publish commands still pin the root per command
texts = [t for t in _sent_texts(json.loads(Path(mock_herdr).read_text())) if "publish_event.py" in t]
assert texts and f"DELEGATE_JOB_LOGS_DIR={os.path.realpath(sibling)} " in "".join(texts), texts
jid = row["delegate_job_id"]
assert (sibling / jid / "status.json").is_file()
assert not (tmp / ".mam" / "delegate_job_logs").exists()
def test_a_forwarded_pane_moves_its_logs_with_the_registry(tmp_path, monkeypatch):
"""The issue's scene: a worktree creator pane after §2-B carries the control
root's WORKSPACE_ROOT and MAM_JOBS_DIR but no DELEGATE_JOB_LOGS_DIR."""
for k in DROP:
monkeypatch.delenv(k, raising=False)
main, wt, exp = tmp_path / "main", tmp_path / "wt", tmp_path / "exp" / "jobs"
real_logs = main / ".mam" / "delegate_job_logs"
real_logs.mkdir(parents=True)
wt.mkdir()
pane = dict(WORKSPACE_ROOT=str(main), MAM_JOBS_DIR=str(main / ".mam" / "jobs"))
res = _wrapper(main, "submit", "--dry-run", "--agent", "human", "--registry-dir", str(exp),
"--workdir", str(wt), "--prompt", "t", cwd=wt, **pane)
assert res.returncode == 0, res.stdout[-800:] + res.stderr[-800:]
jid = _only_job_id(exp)
assert res.stdout.strip().splitlines()[-1] == str(Path(os.path.realpath(tmp_path / "exp")) / "delegate_job_logs" / jid)
assert (tmp_path / "exp" / "delegate_job_logs" / jid / "meta.json").is_file()
assert os.listdir(real_logs) == [], "the experiment leaked into the control root"
# the usual run in that pane is unchanged: the default registry's sibling
res = _wrapper(main, "submit", "--dry-run", "--agent", "human", "--workdir", str(wt), "--prompt", "t",
cwd=wt, **pane)
assert res.returncode == 0, res.stdout[-800:] + res.stderr[-800:]
jid2 = _only_job_id(main / ".mam" / "jobs")
assert res.stdout.strip().splitlines()[-1] == str(Path(os.path.realpath(main)) / ".mam" / "delegate_job_logs" / jid2)
assert os.listdir(real_logs) == [jid2]
assert not (wt / ".mam" / "delegate_job_logs").exists()