merge: fix/issue-57 (the audit log follows a moved registry; v4.9.4)

Closes #57. Review round 2 (@a7cb90e) 4/4 PASS; full suite on macOS / Python 3.9.6 green apart from the known #55 false positive.
This commit was merged in pull request #58.
This commit is contained in:
Godopu committed 2026-10-15 13:52:09 +00:00
commit 26e2c43753
49 files changed
+1527 -157

No files matched your search

@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261015T123502Z-1b9df6",
"base_sha": "e32c9574644a9185813a4dc881812694d62b6204",
"head_sha": "a7cb90e63abf454ccf22f6ff887cc25393755402",
"tree_sha": "96c5c5f82cb89e4383011d52fb1dcb21d4e787e6",
"review_commit": "a7cb90e63abf454ccf22f6ff887cc25393755402",
"reviewer": "planner-reviewer-claude-01",
"job_id": "b48be8a6",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-15T13:18:35Z"
}
@@ -0,0 +1,58 @@
# PR #58 (fix/issue-57) 병합 전 2차 리뷰 (job b48be8a6)
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
- 리뷰 identity: loop-20261015T123502Z-1b9df6 / base e32c9574 .. head a7cb90e6 (커밋 15개; 1차 이후 대응 4개: 75c0042 F1, 151af99 F2, 1c598bb R1, a7cb90e R2) / tree 96c5c5f8
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261015T123502Z-1b9df6/1` — HEAD·tree 일치, `git status --porcelain` 0 줄, `.mam` 없음(시작·종료 모두). 본 체크아웃 HEAD e32c957(main) 변동 없음.
- 환경: macOS, `/usr/bin/python3` 3.9.6(paho 없음; 이 Mac 의 PATH `python3`), 체크아웃 `.venv` 3.9.6(paho 2.1.0), PATH `bash` = `/bin/bash` 3.2.57.
- 실행 안전: 테스트·탐침은 모두 `env -i`(PATH/HOME/LANG/TMPDIR 만, 필요한 값만 명시)로 브리프의 `-u` 목록 전부를 비웠고, basetemp 는 scratchpad 아래(이름에 "shim" 없음). 수동 재현은 scratchpad 또는 `/tmp/jb48-r2`(종료 후 삭제)의 임시 워크스페이스·cwd 에서만 했다. 수정 전 대조군은 `git archive HEAD` 복사본에 제품 파일만 되돌려 만들었다. 실제 `.mam`·`.mam.env`·모니터·herdr 서버·에이전트 홈 미접촉.
## 1. 1차 지적 4건 — 재현을 다시 돌린 결과
| 지적 | 1차 재현 (63d8164) | head (a7cb90e) | 수정 전 코드에서 회귀 테스트 |
|---|---|---|---|
| **F1** paho 없는 폴백에서 control_plane·status 실패 | `AGENT_PYTHON_BIN=/usr/bin/python3` 로 `mam_control_plane_json` rc=1 `ModuleNotFoundError: paho`; `get_job_status` 예외 | rc=0, 바인딩 JSON 에 로그 루트 포함; `status.sh` 의 `get_job_status` 두 복사본 모두 `('abc12345', 'unknown')` | `test_control_plane_binding_works_without_paho`, `test_status_works_without_paho[json/text]` **실패**(paho 미존재로 control_plane·service import 실패) |
| **F2** 래퍼 `logs` 기본 경로가 cwd 상대 | `WORKSPACE_ROOT=ws`, cwd=elsewhere 에서 `logs --list` 가 잡을 못 찾음 | `logs --list` 가 ws 의 잡을 나열, `logs <id>` 에 `registered` 이벤트, `--registry-dir` 를 주면 그 레지스트리를 따름, elsewhere 에 아무것도 안 생김 | `test_logs_without_registry_dir_follows_the_workspace_from_any_cwd` **실패**(`(no logged jobs under …)`) |
| **R1** 디스크 폴백이 이후 디스패치의 terminal 을 이전 시도로 오인 | (codex 지적) | 감사 상태 채택 직전에 레코드를 다시 읽어 `(dispatch_token, attempt_id, transition_seq)` 가 처음 읽은 것과 같을 때만 채택 | `test_disk_fallback_rejects_a_terminal_that_belongs_to_a_later_dispatch` **실패**(`B's completion was attributed to A`) |
| **R2** jobs 디렉터리가 심볼릭 링크일 때 submit 과 logs 불일치 | (codex 지적) | `/tmp/jb48-r2/alias/jobs -> actual/jobs` 로 `submit --dry-run`: 출력 경로·감사 로그 모두 `actual/delegate_job_logs/<id>`; `logs --registry-dir alias/jobs` 와 `registry.py --registry-dir actual/jobs logs --list` 가 같은 잡을 찾음 | `test_a_symlinked_registry_dir_resolves_to_one_audit_log` 는 63d8164 에서는 `logroot` 부재로 ImportError 라 증거가 안 되므로, **a7cb90e^ (logroot 있음, `abspath` 규칙)** 로 다시 돌려 **실패**(`…/alias/… ≠ …/actual/delegate_job_logs`) 확인 |
## 2. 수정이 새 문제를 만들지 않았는가
**F1 (모듈 이동)**
- `lib_py/delegation/logroot.py` 는 stdlib(`os`, `typing`)만 쓴다. `mqtt_common` 이 `DEFAULT_REGISTRY_DIR`·`_workspace_mam_dir`·`default_registry_dir`·`get_logs_dir`·`sibling_logs_dir` 를 그대로 재노출하므로 기존 import 경로(`mqtt_common.get_logs_dir` 등)는 불변이다.
- `from lib_py.delegation.logroot import …` 는 패키지 `__init__` 을 거쳐 `daemon`·`service`·`registry`·`publish_event`·`publisher` 까지 로드한다. 이 Mac 의 `/usr/bin/python3` 로 직접 import 해 보니 12 개 `lib_py` 모듈이 모두 올라오고 `paho` 는 로드되지 않았다. 직접 paho 를 import 하는 곳은 `mqtt_common`·`publisher` 둘뿐이고 둘 다 `try/except ImportError` 로 감쌌으며 `from __future__ import annotations` 라 `-> mqtt.Client` 주석도 import 시 평가되지 않는다. 실제로 브로커가 필요하면 `make_client` 가 명확한 `ImportError` 를 낸다.
- eager import: `logroot` 는 `eager.CORE_MODULES` 에 없지만 목록에 있는 `mqtt_common` 이 import 시점에 로드하므로 동결 런타임에 함께 들어간다(아래 비차단 메모).
**F2**: `local -a reg=()` + `${reg[@]+"${reg[@]}"}` 를 `/bin/bash -u` 로 직접 확인 — 빈 배열이면 인자 없음, 주어지면 `--registry-dir <dir>`. 래퍼의 다른 subcommand 는 바뀌지 않았다.
**R1 (재읽기)**
- 경합: B 의 terminal 감사 상태는 재디스패치(레코드 토큰·seq 변경)가 커밋된 뒤에만 생긴다. 그러므로 감사 읽기에서 B 의 terminal 이 보였다면 레코드는 이미 그 전에 바뀌었고, 그 뒤의 재읽기가 반드시 차이를 본다. 재읽기 이후의 변경은 이미 읽은 감사 상태와 무관하다.
- 같은 시도 안에서 정상 전이(A running → A completed)가 두 읽기 사이에 끼면 seq 가 달라져 "deferred" 로 한 라운드 늦어질 뿐이고, 다음 라운드는 레코드 자체의 terminal 을 읽는다.
- 성능: 감사 상태가 terminal 이고 stale 이 아닐 때만 레코드 하나를 더 읽는다. 무시할 만하다.
**R2 (realpath)**
- 기본 경로: `WORKSPACE_ROOT=/tmp/...`(macOS 에서 `/tmp → /private/tmp`)일 때 `get_logs_dir()` 은 이제 `/private/tmp/.../.mam/delegate_job_logs` 문자열을 돌려준다. 물리적으로는 이전과 같은 디렉터리임을 확인했다. 결과를 문자열로 비교하는 곳은 `control_plane.forwarded_keys`(이제 양쪽 모두 `_abs`=realpath)와 루프 러너의 제외 목록(`runner.py` 가 `d.resolve().relative_to(root.resolve())`)뿐이라 영향이 없다.
- `forwarded_keys` 탐침: `/tmp`·`/private/tmp` 혼합 두 방향, 후행 `/`, 심볼릭 링크 alias 경유 모두 형제로 판정되어 전파하지 않는다. 비형제만 전파한다. 1차에서 비정규 입력이 "전파" 쪽으로 어긋나던 비대칭도 사라졌다.
- `from_env({"WORKSPACE_ROOT": "/tmp/..."})` 의 기본 바인딩은 `defaults()` 와 같아서, 평소 실행은 pane 에 아무것도 전파하지 않는다.
- 레지스트리 디렉터리 자체가 심볼릭 링크인 경우에만 감사 로그가 링크 대상 옆으로 간다. 이는 R2 가 의도한 동작이며, 래퍼(`pwd -P`)와 바인딩(realpath)이 이미 그렇게 보고 있었다.
## 3. 이 Mac 정합성
- 변경 py 15 개: `ast.parse(feature_version=(3,9))` 와 `/usr/bin/python3 -m py_compile` 모두 통과. 3.10+ 문법·API 없음.
- `lib.sh`·래퍼·`status.sh`: `/bin/bash -n`(3.2) 통과.
## 4. 테스트 (head, 검증 경로)
| 묶음 | 결과 |
|---|---|
| A: issue57·15·21_acceptance·21_worktree_prereqs·tier1·20·17_item4·17_workspace_root_isolation·36·14 | **475 passed, 2 skipped** |
| B: issue18·6_fail_closed·34·33·5_phase1/2a/2b/4_runtime·tier2·12·23_24·21_phase2·b7 | **493 passed** |
`5_phase4_runtime` 의 eager 모듈 검사도 통과했다. 어느 실행도 검증 경로에 `.mam` 을 남기지 않았고 #55 가드 오탐도 나오지 않았다.
## 5. 비차단 메모
- `lib_py.delegation.logroot` 를 `eager.CORE_MODULES` 에 명시하면 의도가 분명해진다. 지금도 `mqtt_common` 을 통해 로드되므로 기능 문제는 없다.
- paho 없이 `publisher` 의 브로커 경로를 직접 타면 `make_client` 의 `ImportError` 가 먼저 나는 구조다. 다른 진입점이 `mqtt.` 를 먼저 건드리면 `AttributeError: 'NoneType'` 로 보일 수 있지만, 브로커가 필요한 경로는 원래 paho 가 필수다.
## 결론
1차 지적 4건(F1·F2·R1·R2)이 모두 해소됐다. 1차 재현을 다시 돌려 확인했고, 각 회귀 테스트가 수정 전 코드에서 의도한 이유로 실패한다(R2 는 직전 부모 커밋 기준). 모듈 이동·재읽기·realpath 는 기본 경로, 바인딩·전파, 기존 import 경로에 새 문제를 만들지 않으며, 이 Mac 의 Python 3.9·bash 3.2 에서 정합하다.
[VERDICT: PASS]
@@ -0,0 +1,67 @@
# PR #58 (fix/issue-57) 병합 전 리뷰 — 이 Mac 정합성 중심 (job b9c7522d)
- 리뷰어: planner-reviewer-claude-01 (Reviewer)
- 리뷰 identity: loop-20261014T152849Z-f1a415 / base e32c9574 .. head 63d81646 (커밋 11개, 21 파일) / tree e8f162f7
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261014T152849Z-f1a415/1` — HEAD·tree 일치, `git status --porcelain` 0 줄, `.mam` 없음(시작·종료 모두). 본 체크아웃 HEAD e32c957(main) 변동 없음.
- 환경: macOS, `/usr/bin/python3` 3.9.6(paho 없음 — 이 Mac 의 PATH `python3` 도 이것), 체크아웃 `.venv` 3.9.6(paho 2.1.0), PATH `bash` = `/bin/bash` 3.2.57.
- 실행 안전: 모든 테스트·탐침은 `env -i`(PATH/HOME/LANG/TMPDIR 만, 필요한 값만 명시)로 브리프의 `-u` 목록 전부를 비웠고 basetemp 는 scratchpad 아래(이름에 "shim" 없음). 수동 재현은 scratchpad 의 임시 워크스페이스·cwd 에서만 했고, base 비교는 `git archive e32c957` 복사본으로 했다. 실제 `.mam`·`.mam.env`·모니터·herdr 서버·에이전트 홈 미접촉.
## 1. 결과 요약
| 항목 | 결과 |
|---|---|
| Python 3.9 문법(`ast.parse(feature_version=(3,9))`, 변경 py 13 개)·`py_compile` | 통과. 3.10+ 문법·API(match, `X \| None`, 내장 제네릭 주석, `removeprefix`, `tomllib`, `zip(strict=)`) 사용 없음 |
| bash 3.2 `bash -n`(lib.sh, 래퍼, worktree_prepare, reconcile, status) | 통과. 래퍼의 `LOGS_DIR_ABS=""` 전역 초기화, `var=$(…) \|\| {…}` 실패 처리, `env_add+=(…)` 모두 3.2 `set -u` 안전 |
| head 관련 모듈 A: issue57·15·20·21_acceptance·21_worktree_prereqs·tier1·17_item4·17_workspace_root_isolation·b7·36 | **477 passed, 2 skipped** |
| head 관련 모듈 B: issue14·18·6_fail_closed·34·33·5_phase1/2a/2b·tier2·12·23_24·21_phase2·**10_13_purge** | **503 passed**(알려진 실패 #28 의 `test_issue10_13_purge` 도 이 Mac 에서 통과) |
| 수정 전 대조군(제품 파일만 base 로 되돌린 복사본) + 새 `test_issue57_logs_follow_registry.py` | **15 failed, 6 errors, 1 passed** — 회귀를 실제로 잡는다 |
| 이전 라운드(ee7cef33) 차단 지적: `test_issue15…:64`, `test_tier1_unit…:512` | 두 곳 모두 `monkeypatch.delenv("MAM_JOBS_DIR")` 추가로 해소, 통과 |
## 2. 맞게 된 부분
- **규칙은 한 곳**: `mqtt_common.sibling_logs_dir`(순수) + `get_logs_dir(registry_dir)`(`DELEGATE_JOB_LOGS_DIR` > 레지스트리 형제). 쓰기·읽기 지점(register·pick·legacy/engine 미러·conflict·publish·subscriber·remote·reconcile·runner·lib.sh·래퍼)이 모두 이 함수를 거친다. lib.sh 의 bash 기본값 사본은 `delegate_logs_dir` 로 대체됐고, 해석 실패 시 감사 확인만 건너뛰고 `events.log` 증거는 유지한다.
- **macOS 경로**: `control_plane._abs` 는 `realpath(expanduser())` 라 `from_env`·행 기록이 모두 `/private/tmp`·`/private/var` 로 정규화된다. `forwarded_keys` 를 직접 탐침: 형제(정규화 후 동일) → 전파 안 함, `/tmp` 와 `/private/tmp` 가 섞인 비정규 입력·후행 `/`·비형제 → 전파. 어긋날 때는 늘 "전파" 쪽(기존 동작)이라 안전하다.
- **디스크 폴백의 재디스패치 안전성**: `job_subscriber._check_disk_fallback` 은 이제 `status.json` 의 `status` 필드를 읽고(이전엔 dict 를 비교해 아무것도 맞지 않았음), 버전 있는 레코드는 감사 `transition_seq` 가 같거나 새로울 때만 채택한다. 이전 시도의 terminal 감사 상태가 새 시도를 끝내지 못하며(`test_old_audit_terminal_does_not_complete_the_redispatched_attempt`), 손상된 seq 는 stale 로 처리한다. 버전 없는 레코드·레코드 없음은 기존 규칙.
- **래퍼**: 16 곳의 `${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}` 제거, 해석 실패 시 fail-closed(exit 1), 해석값을 export 해 자식과 주입 명령이 같은 루트를 쓴다.
- **worktree_prepare / control_plane**: 호출자가 명시한 `DELEGATE_JOB_LOGS_DIR` 만 넘기고, 유도 가능한 형제 루트는 행에만 기록한다. 기존 행 중 레지스트리를 옮겼지만 로그가 워크스페이스에 있던 것은 비형제라 계속 전파되어 resume 짝이 유지된다.
## 3. 차단 지적
**F1. 경로 헬퍼 하나 때문에 create/resume 바인딩과 status 가 paho 를 요구하게 됐다 — 이 Mac 의 문서화된 폴백에서 회귀.**
- `lib_py/control_plane.py:28` 이 `from lib_py.delegation import mqtt_common` 을 새로 하고(base 의 control_plane 은 stdlib 만), `status.sh:171,383` 이 `get_job_status` 안에서 `from lib_py.delegation.mqtt_common import sibling_logs_dir` 를 무조건 한다. `mqtt_common.py:33` 은 모듈 최상단에서 `import paho.mqtt.client` 다.
- 두 경로의 인터프리터는 `_delegate_py_bin`(`lib.sh:2111-2130`): `AGENT_PYTHON_BIN` > 활성 venv > 스킬 위쪽 가장 가까운 `.venv` > **PATH `python3`**. README 도 이 폴백을 문서화한다. 이 Mac 의 PATH `python3` 은 `/usr/bin/python3` 3.9.6 이고 paho 가 없다(install.sh 는 시스템 python3 에 PyYAML 만 요구).
- 재현(임시 워크스페이스, herdr 미호출):
```
AGENT_PYTHON_BIN=/usr/bin/python3 로 lib.sh source 후 mam_control_plane_json <wt>
base: rc=0
head: rc=1 ModuleNotFoundError: No module named 'paho'
status.sh 의 get_job_status 를 그대로 떼어 /usr/bin/python3 로 job 이 있는 행에 실행
base: ('abc12345', 'unknown')
head: ModuleNotFoundError: No module named 'paho'
```
- 영향: 그 폴백에서 `create_session.sh:232-238`·`resume_session.sh:100-106` 은 워크트리 대상이면 바인딩 계산 실패로 **exit 2**(거부), 제어 루트면 `control_plane` 없이 기록한다. 읽기 전용 `status.sh` 는 위임 잡이 걸린 행이 하나라도 있으면 heredoc 이 예외로 죽는다. 둘 다 base 에서는 paho 없이 동작했다.
- 수정(작음): `sibling_logs_dir` 를 paho 를 import 하지 않는 모듈(예: `lib_py/paths.py` 또는 새 `lib_py/delegation/logroot.py`)로 옮기고 `mqtt_common` 은 그것을 재노출, `control_plane`·`status.sh` 는 그 모듈에서 import. (또는 `mqtt_common` 의 paho import 를 지연.) 회귀 테스트로 "paho 없는 인터프리터에서 `control_plane json`·`get_job_status` 가 동작" 을 두면 좋다.
**F2. 래퍼 `logs` 의 기본 경로가 바뀌었다 (레지스트리를 옮기지 않은 평소 실행).**
- `multi-agent-mux-delegate-job` `cmd_logs` 는 이제 항상 `--registry-dir "$reg"` 를 넘기고 `reg` 의 기본은 `REGISTRY_DIR_DEFAULT="${MAM_JOBS_DIR:-.mam/jobs}"`(`:78`) — cwd 상대다. base 는 `--registry-dir` 없이 `registry.py logs` 를 불러 `get_logs_dir()` 이 `$WORKSPACE_ROOT/.mam/delegate_job_logs` 를 썼다(registry.py 자체의 기본 `default=default_registry_dir()`(`:370`) 도 `WORKSPACE_ROOT` 를 존중한다).
- 재현(임시 디렉터리, `WORKSPACE_ROOT=ws` 에 잡 하나 등록, cwd=`elsewhere`):
```
base: logs --list → 그 잡을 나열
head: logs --list → "(no logged jobs under <elsewhere>/.mam/delegate_job_logs)"
```
- 수정(작음): `--registry-dir` 가 주어졌을 때만 넘긴다(이전 라운드 구현처럼 `local reg=()` 배열 + `${reg[@]+"${reg[@]}"}`). 그러면 registry.py 의 기본(`MAM_JOBS_DIR` > `WORKSPACE_ROOT` > cwd)이 그대로 쓰여 기본 경로가 불변이다. "cwd≠워크스페이스에서 `logs`" 테스트 한 건을 권한다.
두 지적 모두 한두 줄 수정이며 설계 변경은 필요 없다.
## 4. 비차단 메모
- `docs/design/worktree-creator-model.md` 의 status 행(“`MAM_JOBS_DIR`·행 `mam_jobs_dir`·감사 로그(`DELEGATE_JOB_LOGS_DIR`·행 `delegate_job_logs_dir`)·기존 후보 순 조회”)에 새로 추가된 "레지스트리 옆 감사 로그" 후보가 빠져 있다.
- `control_plane.forwarded_keys` 는 로그 쪽 값을 정규화 없이 비교한다. 실제 흐름에서는 양쪽이 이미 realpath 라 문제없고 어긋나도 전파 쪽이라 안전하지만, 양쪽에 `_abs` 를 걸면 의도가 분명해진다.
- `cmd_logs` 의 `--registry-dir` 뒤 인자가 없으면 `set -u` 로 "unbound variable" 종료 — fail-closed 이므로 무방.
- 수정 전 대조군의 6 errors 는 base 코드가 테스트가 기대하지 않는 위치에 쓰면서 생긴 setup/teardown 오류로, head 에서는 없다.
## 5. 잔류물
- 복사본·basetemp·탐침 디렉터리는 모두 scratchpad 아래. 검증 경로는 깨끗(`.mam` 없음, `git status` 0 줄). #55 가드 오탐은 이번 실행에서 나오지 않았다.
## 결론
규칙의 일원화, 쓰기·읽기 지점, macOS 경로 정규화, 디스크 폴백의 재디스패치 안전성, bash 3.2·Python 3.9 이식성, 테스트의 회귀 탐지는 모두 맞다. 그러나 (F1) 경로 헬퍼를 paho 를 끌어오는 모듈에서 import 해 이 Mac 의 문서화된 `python3` 폴백에서 create/resume 바인딩과 status 가 깨지고, (F2) 래퍼 `logs` 의 기본 경로가 cwd 상대로 바뀌었다. 둘 다 base 대비 재현됐고 작은 수정으로 끝난다.
[VERDICT: NOT PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261015T123502Z-1b9df6",
"base_sha": "e32c9574644a9185813a4dc881812694d62b6204",
"head_sha": "a7cb90e63abf454ccf22f6ff887cc25393755402",
"tree_sha": "96c5c5f82cb89e4383011d52fb1dcb21d4e787e6",
"review_commit": "a7cb90e63abf454ccf22f6ff887cc25393755402",
"reviewer": "planner-reviewer-codex-01",
"job_id": "2e06828a",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-15T13:18:35Z"
}
@@ -0,0 +1,68 @@
# PR #58 Mac 2차 리뷰 — PASS
Job `2e06828a`, Reviewer. 이전 네 지적 사항(F1/F2/R1/R2)이 해결됐음을 확인했다. 관련 테스트 83건이 통과했으며, 수정 직전 커밋에서는 해당 회귀 테스트 6건이 각각 예상한 이유로 실패했다. 이번 검토 범위에서 새 차단 이슈는 발견하지 못했다. 소스 수정은 하지 않았다.
## 검증 identity
- loop: `loop-20261015T123502Z-1b9df6`
- base: `e32c9574644a9185813a4dc881812694d62b6204`
- head: `a7cb90e63abf454ccf22f6ff887cc25393755402`
- tree: `96c5c5f82cb89e4383011d52fb1dcb21d4e787e6`
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261015T123502Z-1b9df6/1`
- 누적 범위: base..head 15 commits / 23 files. 이전 검토 head `63d8164` 이후 4개 수정 커밋을 집중 검토하고 누적 변경과의 정합성을 대조했다.
- 실제 환경: macOS, Python 3.9.6, GNU Bash 3.2.57(arm64-apple-darwin25).
## 지적 사항별 확인
| 항목 | 수정 전 검증 | 현재 head 검증 | 판정 |
|---|---|---|---|
| F1: paho 없는 Python 경로 계산 | `63d8164`에서 binding 1건 및 status json/text 2건이 ModuleNotFoundError로 실패 | 같은 3건 통과. 별도로 실제 `/usr/bin/python3`의 paho spec이 None임을 확인하고 `mam_control_plane_json` 및 status의 두 `get_job_status` 함수를 실행해 성공 | 해결 |
| F2: 다른 cwd의 기본 logs | `75c0042`에서 WORKSPACE_ROOT의 잡 대신 elsewhere/.mam 로그 루트를 조회하여 실패 | WORKSPACE_ROOT=ws, cwd=elsewhere에서 `logs --list`와 `logs <id>` 모두 잡/registered 이벤트 조회 성공 | 해결 |
| R1: 다른 dispatch 완료 오인 | `151af99`에서 A error → redispatch → B completed interleaving이 A를 completed로 종료시켜 실패 | 동일 interleaving에서 A는 pending 유지, terminal 비어 있음. B 토큰으로 다음 조회 시 completed. 별도 탐침도 동일 결과 | 해결 |
| R2: registry 자체의 symlink | `1c598bb`에서 alias/jobs의 sibling이 actual이 아닌 alias로 계산되어 실패 | alias/jobs → actual/jobs에 submit --dry-run 후 같은 alias 인수로 logs 조회 성공. alias 옆에는 로그를 생성하지 않음 | 해결 |
수정 전 테스트는 검증 저장소에서 `git archive <각 수정 커밋의 부모>`로 만든 임시 복사본에서 실행했다. 해당 복사본에는 현재 head의 `tests/test_issue57_logs_follow_registry.py`만 복사해 새 회귀 테스트를 실행했다. 실제 코드의 이전 동작 때문에 실패했으며 수집 오류나 환경 오류로 실패한 것이 아니다. 원본 검증 트리나 creator 브랜치는 변경하지 않았다.
각 회귀 테스트:
- F1: `test_control_plane_binding_works_without_paho`, `test_status_works_without_paho[json]`, `test_status_works_without_paho[text]`.
- F2: `test_logs_without_registry_dir_follows_the_workspace_from_any_cwd`.
- R1: `test_disk_fallback_rejects_a_terminal_that_belongs_to_a_later_dispatch`.
- R2: `test_a_symlinked_registry_dir_resolves_to_one_audit_log`.
## 코드 및 추가 탐침 근거
F1: `.agents/skills/lib_py/delegation/logroot.py`가 표준 라이브러리만으로 경로 규칙을 정의한다. `control_plane.py`, lib.sh의 경로 helper, status.sh가 이 모듈을 사용한다. 패키지 초기화의 전이 import도 `mqtt_common.py:33` 및 `publisher.py:17`의 선택적 paho import로 통과한다. 실제 paho 미설치 `/usr/bin/python3` + `AGENT_PYTHON_BIN=/usr/bin/python3`에서 lib.sh의 `mam_control_plane_json`을 실행했다. status.sh의 두 `get_job_status` 정의를 그대로 추출해 동일 인터프리터에서 audit-only completed 레코드를 읽었으며 둘 다 `('probejob', 'completed')`를 반환했다. 경로 조회가 MQTT 접속을 필요로 하지 않고, 실제 client 생성은 paho가 없으면 명시적으로 실패한다.
F2: wrapper의 `cmd_logs`는 `--registry-dir`가 명시된 경우에만 배열에 넣어 registry.py로 넘긴다. 기본 실행은 registry.py의 MAM_JOBS_DIR → WORKSPACE_ROOT → cwd 규칙을 사용한다. 비어 있는 배열은 `${reg[@]+"${reg[@]}"}` 형태로 전개되므로 Bash 3.2 + set -u에서도 동작한다. 기본 인수 및 명시적 registry 인수 두 경우 모두 실제 테스트로 통과했다.
R1: `job_subscriber.py:128`에서 감사 상태를 읽은 뒤 레지스트리를 재조회하고 `:132`에서 dispatch_token/attempt_id/transition_seq를 비교한다. 첫 관찰과 다르면 그 라운드에서는 채택하지 않는다. 따라서 정상 엔진이 토큰을 교체하고 seq를 증가시키는 재디스패치가 감사 read 전후에 끼어들어도 이전 토큰에 다음 시도의 성공을 붙이지 않는다. 추가 재조회 이후에 새로운 전이가 일어나더라도 이미 읽어 둔 감사 상태를 새 상태로 바꾸지는 않는다. 기존 레지스트리 terminal 경로는 추가 read 없이 처리한다. 감사 상태를 채택할 후보에만 파일 read 최대 1회가 추가되며 새 내부 polling loop, sleep, 장시간 lock은 없다. 성능 판단은 이 코드 경로에 근거하며 대규모 잡 부하 벤치마크를 수행했다는 뜻은 아니다.
R2: `logroot.py:51`이 registry의 realpath에서 sibling을 구한다. submit의 `pwd -P`, control_plane의 realpath와 일치한다. `control_plane.py:82`의 비교도 로그 경로 양쪽을 정규화한다. 추가 탐침에서 `/tmp`/`/private/tmp`, `/var`/`/private/var` 부모 별칭 및 lexical 별칭을 사용하는 기존 row에서도 파생 로그 키를 제외하는 동작이 일치했다. 명시적 DELEGATE_JOB_LOGS_DIR 우선권은 유지된다. 일반 기본 경로는 같은 물리적 위치이며 Mac에서 문자열의 `/private` 접두사가 정규화되는 것은 의도된 변화이다.
기존 import 호환성: 실제 `/usr/bin/python3`에서 `mqtt_common.sibling_logs_dir`, `get_logs_dir`, `default_registry_dir`가 logroot의 해당 함수와 동일 객체임을 확인했다. venv Python에서 `lib_py.loop.eager.eager_load()` 실행 후 `lib_py.delegation.logroot`가 검증 트리 경로로 sys.modules에 적재됐음을 확인했다. 명시적 eager 목록에 logroot를 중복 추가하지 않아도 mqtt_common의 최상위 import를 통해 시작 시 적재된다.
이전 R1/R2 임시 탐침 파일은 더 이상 존재하지 않아, 이전 리포트에 기록한 동일한 전이 순서 및 symlink submit/logs 절차를 새 임시 스크립트로 재구성했다. R1은 실제 engine 전이를 사용하고 audit read 경계에만 순서를 삽입했으며, R2는 실제 wrapper의 dry-run/logs를 호출했다.
## 실행 결과와 한계
- 현재 head 선택 테스트: **83 passed in 181.30s**, exit 0.
- 모듈 전체: `test_issue57_logs_follow_registry.py`, `test_issue15_registry_isolation.py`, `test_issue12_registry_secrets.py`, `test_issue21_worktree_prereqs.py`, `test_issue21_acceptance.py`.
- 개별 테스트: tier1의 `test_b9_logs_dir_follows_cwd_changes`; issue18의 `test_subscriber_records_seq_gaps_and_recovers_terminal_from_state`, `test_subscriber_state_renews_liveness_only_when_it_advances`, `test_remote_runner_ignores_a_resent_dispatch_of_a_finished_attempt`, `test_remote_runner_never_rolls_a_newer_attempt_back`.
- 변경 Python 파일 전체에 실제 Python 3.9.6의 AST parse 적용, 변경 shell/wrapper 각각 `/bin/bash -n` 검사 통과. `git diff --check base HEAD` 통과.
- 테스트마다 브리프의 지정 환경변수를 `env -u`로 제거했고, 추가로 에이전트 관련 환경과 HOME/XDG 위치를 격리했다. 지정 venv Python, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 검증 트리 밖 basetemp 사용. create/resume은 mock fixture로 검증했다.
- 실제 herdr 서버/monitor/에이전트를 시작하지 않았다. 실제 제어 평면에는 지시된 job lifecycle 이벤트와 본 리포트만 기록했다. creator 작업 트리에서 실행하지 않았다.
- 전체 스위트는 이 리뷰에서 다시 실행하지 않았다. 오케스트레이터의 별도 전체 스위트 결과를 이 리뷰 결과로 포함하지 않았다. 선택 테스트에서 #55 가드 오탐 및 teardown 오류는 없었다.
- 최종 검증 트리 HEAD/tree는 브리프와 일치하고 git status는 clean이다.
검증 산출물 디렉터리: `/tmp/review-2e06828a-66fn_mpg/`.
- `before-F1.log`: 3 failed, 각각 paho import 실패.
- `before-F2.log`: 1 failed, 잘못된 cwd 로그 루트.
- `before-R1.log`: 1 failed, B 완료를 A에 귀속.
- `before-R2.log`: 1 failed, alias와 actual sibling 불일치.
- `head.log`: 83 passed.
- `system-probe.py`, `system-probe.log`: 실제 시스템 Python/no-paho 및 import/eager/구문 검증.
- `repro-r1-r2.py`, `repro-r1-r2.log`: 이전 두 재현 절차 및 Mac 별칭 검증.
[VERDICT: PASS]
@@ -0,0 +1,93 @@
# PR #58 Mac review — NOT PASS
Job: `863e2edf`. Role: Reviewer. 소스 수정 없이 리뷰 및 임시 디렉터리 안의 검증만 수행했다.
- loop: `loop-20261014T152849Z-f1a415`
- base: `e32c9574644a9185813a4dc881812694d62b6204`
- head: `63d816468e6d48d01bd96f31d8a25eeadfbaab60`
- tree: `e8f162f74b7ce8ee8cc6ba7954b7d28dfa3f89f6`
- 검증 트리: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261014T152849Z-f1a415/1`
- 범위: base..head 11 commits / 21 files. 아래 파일:라인은 이 head 기준이다.
관련 테스트 **77 passed in 177.28s**, exit 0. 그러나 테스트가 다루지 않는 두 경우를 추가 탐침으로 재현했다. 병합 전에 아래 두 항목을 수정하고 회귀 테스트를 추가해야 한다. 국소 수정으로 해결 가능하므로 설계 재계획 에스컬레이션은 필요하지 않다.
## R1 — [P1] 디스크 폴백이 다른 dispatch의 완료를 이전 dispatch의 성공으로 오인한다
근거:
- `.agents/skills/lib_py/delegation/job_subscriber.py:89`: 레지스트리의 status/token/attempt/transition_seq를 먼저 읽는다.
- 같은 파일 `:100`, `:114`, `:121`: 나중에 읽은 감사 상태의 seq가 그 스냅샷 이상이면 상태를 채택한다.
- 같은 파일 `:126`, `:132`: 토큰/attempt 검증에는 여전히 처음 읽은 레코드만 사용한다.
- `.agents/skills/lib_py/delegation/engine.py:322`: redispatch는 새 토큰을 발급한다. 감사 상태에는 토큰/attempt가 기록되지 않는다(`:383`).
재현 절차:
1. 임시 cwd/WORKSPACE_ROOT 및 별도 registry에서 토큰 A로 잡을 등록하고 `started`로 전이시켜 running 상태로 만든다.
2. `_check_disk_fallback`을 expected token A로 실행한다.
3. `read_logged_status`를 감싸서, 레지스트리 read가 끝나고 감사 read가 시작되는 지점에 다음 실제 engine 전이를 실행한다: A의 `error` → `redispatch(attempt_id=2)` → 새 토큰 B로 `completed`.
4. 실제 `read_logged_status` 결과를 반환한다. 즉 감사 파일을 조작한 것이 아니라 정상 engine 전이 사이에 subscriber가 일시 중단된 순서를 결정적으로 재현했다.
기대: 토큰 B의 성공으로 A의 대기를 완료하면 안 된다. 최신 레코드와 비교해 토큰 변경을 거부하거나 A의 실제 실패를 구분해야 한다.
실제: 초기 레코드는 A/running이고 최종 레코드는 B/completed인데, `pending=[]`, `terminal={job_id: "completed"}`가 된다. 출력도 `completed [disk-fallback]`이다. 이전 시도가 실패했는데도 호출자가 성공으로 종료할 수 있다.
탐침 출력 발췌:
```text
RACE {"initial_status": "running", "initial_token": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "latest_token": "7b761cb2ea2d4514bbd631467997ad9d", "pending": [], "terminal": {"5b5cd6e8": "completed"}}
```
위 토큰은 임시 테스트 잡의 식별자이다. 이전 감사 상태를 거부하는 현재 회귀 테스트는 통과하지만, **레코드를 읽은 이후 다음 attempt의 더 새로운 감사 상태가 도착하는 경우**는 검사하지 않는다. seq는 최신성만 나타내며 dispatch identity를 보장하지 않는다.
수정 방향: 감사 상태를 채택하기 직전에 레지스트리를 다시 읽어 token/attempt/seq가 같은 관찰에 속하는지 확인하고 변경 시 재시도/거부한다. 또는 레지스트리 lock 아래에서 일관된 관찰을 보장한다. 감사 상태에 dispatch identity를 기록·검증하는 방식도 가능하다. 단순히 `aud_seq >= rec_seq`만으로 다른 시도의 상태를 채택하지 않도록 위 interleaving을 회귀 테스트로 추가한다.
## R2 — [P2] 레지스트리 자체가 심볼릭 링크이면 submit과 logs의 로그 경로가 갈라진다
근거:
- `.agents/skills/lib_py/delegation/mqtt_common.py:189`: sibling 계산은 `abspath`만 사용한다.
- `.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job:84`: submit의 `_abs_registry_dir`는 `cd -P`/`pwd -P`로 링크를 해석한다.
- 같은 파일 `:280`: 물리 경로로 바꾼 다음 로그 루트를 계산한다.
- 같은 파일 `:1108`: logs는 전달받은 링크 경로 그대로 registry CLI에 넘긴다.
- `.agents/skills/lib_py/control_plane.py:34`, `:52`: control-plane binding도 realpath로 정규화하므로 직접 Python resolver와 다를 수 있다.
재현 절차(모두 임시 디렉터리 안에서, DELEGATE_JOB_LOGS_DIR 없이):
1. `<tmp>/actual/jobs`와 `<tmp>/alias`를 만들고 `<tmp>/alias/jobs -> <tmp>/actual/jobs` 심볼릭 링크를 만든다.
2. cwd와 WORKSPACE_ROOT를 `<tmp>`로 둔다.
3. `/bin/bash <review>/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job submit --dry-run --agent human --registry-dir <tmp>/alias/jobs --workdir <tmp> --prompt symlink-wrapper` 실행.
4. 생성된 job ID로 같은 wrapper의 `logs <id> --registry-dir <tmp>/alias/jobs` 실행.
기대: 동일한 registry 인수로 만든 잡의 감사 로그를 조회할 수 있어야 한다.
실제: submit은 rc 0이며 `<tmp>/actual/delegate_job_logs/<id>`에 감사 로그를 생성하지만, logs는 `<tmp>/alias/delegate_job_logs`에서 찾아 rc 1로 실패한다.
```text
job_id: a6cdb85e
submit_log_root: /private/tmp/review-863e2edf-m7fmcbsw/probes-igd0iy09/actual/delegate_job_logs/a6cdb85e
actual_audit_exists: true
logs_rc: 1
no audit log for job a6cdb85e under /tmp/review-863e2edf-m7fmcbsw/probes-igd0iy09/alias/delegate_job_logs
```
별도 직접 registry 호출도 같은 레코드를 가리키면서 alias 옆에 로그를 쓰고, control_plane.from_env는 actual 옆을 기록함을 확인했다. `/tmp -> /private/tmp`처럼 **부모 전체가 같은 위치를 가리키는 별칭**은 통과하며, 이 문제는 **jobs 디렉터리 자체가 다른 부모 아래로 연결되는 링크**에서 발생한다.
수정 방향: 기본 감사 경로를 산출하기 전에 registry 경로를 정규화하는 정책을 중앙 resolver에 통일한다. 예를 들어 registry의 realpath에서 sibling을 구하면 submit/control_plane과 일치한다. 명시적 DELEGATE_JOB_LOGS_DIR 우선권은 유지한다. `submit --dry-run` 후 동일한 symlink 인수로 `logs`를 조회하는 회귀 테스트를 추가한다.
## 검증 결과 및 범위
- 실제 Mac Python 3.9.6에서 변경된 Python 13개 파일 AST 파싱 통과. 변경된 shell/wrapper 5개를 각각 `/bin/bash -n`으로 검사하여 통과. Bash는 `3.2.57(1)-release (arm64-apple-darwin25)`이다.
- 관련 pytest 77건 통과: `test_issue57_logs_follow_registry.py`, `test_issue15_registry_isolation.py`, `test_issue12_registry_secrets.py`, `test_issue21_worktree_prereqs.py`, `test_issue21_acceptance.py` 전체; `test_tier1_unit.py::test_b9_logs_dir_follows_cwd_changes`; issue18의 subscriber state/seq/liveness 및 remote redispatch 관련 4건.
- 실행은 지정 venv Python, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 검증 트리 밖 `/tmp/review-863e2edf-m7fmcbsw/pytest` basetemp를 사용했다. 지정된 HERDR/WORKSPACE_ROOT/MAM/DELEGATE 환경변수를 `env -u`로 제거했고, HOME도 임시 위치로 격리했다. create/resume은 mock fixture를 사용했다. 실제 에이전트/monitor/herdr 서버를 시작하지 않았다.
- 일반 기본 경로, 명시적 로그 override, 상대 registry, 이동 registry, control-plane row 및 prefix, worktree create/resume, audit-only status, 오래된 감사 seq 거부는 관련 테스트로 검증했다.
- 추가 Mac 부모 경로 별칭 탐침(`/tmp`와 `/var`): 정규화된 binding에서 sibling 로그 키를 전달하지 않는 동작 통과.
- `git diff --check base HEAD` 통과. 검증 후 HEAD/tree 일치 및 clean git status 확인. 소스/문서 변경 없음.
- 전체 스위트는 이 리뷰에서 실행하지 않았다(브리프에 따라 오케스트레이터가 별도 실행). 알려진 purge 모듈도 이번 선택에 포함되지 않았다. 선택 테스트에서는 #55 가드 오탐이나 teardown 오류가 없었다.
- 테스트의 Linux 결과를 Mac 결과로 대신 인용하지 않았다. 검증 트리 바깥 creator 소스에서 테스트를 실행하지 않았다.
재현 스크립트: `/tmp/review-863e2edf-m7fmcbsw/probes.py` (실제 대상 모듈 import, 임시 디렉터리 안에서 실행).
탐침 로그: `/tmp/review-863e2edf-m7fmcbsw/probes.log`.
선택 테스트 로그: `/tmp/review-863e2edf-m7fmcbsw/pytest.log`.
재실행: 검증 트리 cwd에서 `PYTHONDONTWRITEBYTECODE=1 /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python /tmp/review-863e2edf-m7fmcbsw/probes.py`. 스크립트가 제어 평면 관련 환경을 제거하고 cwd/WORKSPACE_ROOT/HOME을 새 임시 디렉터리로 설정한다.
[VERDICT: NOT PASS]
@@ -0,0 +1,70 @@
# Review Report — PR #58 (Issue #57: Audit logs follow a moved registry)
- **Reviewer**: `agy` (`herdr:reviewer-agy-01`)
- **Job ID**: `d61d1344`
- **Branch / Range**: `fix/issue-57` (`e32c9574644a9185813a4dc881812694d62b6204..63d816468e6d48d01bd96f31d8a25eeadfbaab60`, 11 commits, 21 files)
- **Verification Worktree**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261014T152849Z-f1a415/1`
- **Platform**: macOS Darwin (`arm64-apple-darwin25`, `/usr/bin/python3` 3.9.6, `.venv/bin/python` 3.9.6, `/bin/bash` 3.2.57)
---
## 1. Executive Summary
PR #58 resolves Issue #57 by establishing a single source of truth (`mqtt_common.get_logs_dir(registry_dir)` and `mqtt_common.sibling_logs_dir(registry_dir)`) so that moving the job registry via `--registry-dir` or `MAM_JOBS_DIR` moves its sibling `delegate_job_logs` audit trail as well, unless `DELEGATE_JOB_LOGS_DIR` is explicitly set. It also fixes the `job_subscriber._check_disk_fallback` bug where the `status.json` dict was compared directly against status strings instead of extracting `.get("status")`, while guarding versioned records against stale audit statuses from prior attempts via `transition_seq` comparison. Furthermore, per §2-B (`c57e766`), `control_plane.forwarded_keys()` omits `delegate_job_logs_dir` from pane `--env` forwarding whenever it is the derivable sibling of `mam_jobs_dir`, allowing `--registry-dir` inside a forwarded worktree pane to relocate both the registry and its audit log cleanly.
All 21 modified files pass Python 3.9.6 AST/syntax checks and Bash 3.2 (`set -u`) syntax checks, and all 135 targeted tests across 5 test suites pass on macOS without leaving any artifacts in the verification worktree or real control plane.
---
## 2. macOS / Python 3.9 / Bash 3.2 Compatibility Verification
1. **Python 3.9 Compatibility (`/usr/bin/python3` 3.9.6)**:
- All 13 modified/added Python files (`control_plane.py`, `engine.py`, `job_subscriber.py`, `mqtt_common.py`, `publish_event.py`, `registry.py`, `remote.py`, `runner.py`, and the 5 test modules) parse cleanly under `/usr/bin/python3` 3.9.6 (`ast.parse`).
- No Python 3.10+ runtime union syntax (`X | Y`), `match`/`case`, `tomllib`, or 3.11-only stdlib APIs are used without `from __future__ import annotations`.
2. **Bash 3.2 + `set -u` Safety (`/bin/bash` 3.2.57)**:
- All 5 modified shell scripts (`lib.sh`, `worktree_prepare.sh`, `multi-agent-mux-delegate-job`, `reconcile.sh`, `status.sh`) pass `/bin/bash -n`.
- In `multi-agent-mux-delegate-job`:
- `LOGS_DIR_ABS=""` is initialized at the top level before functions run under `set -euo pipefail`, and `cmd_submit` resolves it fail-closed (`LOGS_DIR_ABS="$(_logs_dir_abs)" || { ... exit 1; }`) and exports `DELEGATE_JOB_LOGS_DIR="$LOGS_DIR_ABS"`.
- `cmd_logs` uses scalar locals (`local reg="$REGISTRY_DIR_DEFAULT" jid="" list=0`) instead of potentially empty arrays, avoiding Bash 3.2's unbound-variable failure on empty array expansion under `set -u`.
- In `.agents/skills/lib.sh` and `worktree_prepare.sh`:
- `${DELEGATE_JOB_LOGS_DIR:-}` and `${MAM_JOBS_DIR:+$(dirname "$MAM_JOBS_DIR")/delegate_job_logs}` are safely guarded for `set -u`.
3. **macOS Symlink Normalization (`/tmp` -> `/private/tmp`, `/var` -> `/private/var`)**:
- `control_plane._abs(p)` normalizes paths via `os.path.realpath(os.path.expanduser(p))`.
- Both `from_env()` (`b["delegate_job_logs_dir"] = _abs(mqtt_common.sibling_logs_dir(b["mam_jobs_dir"]))`) and `forwarded_keys()` (`desc.get("delegate_job_logs_dir") == _abs(mqtt_common.sibling_logs_dir(desc["mam_jobs_dir"]))`) apply `_abs()`, ensuring symlinked macOS temporary directories (`tmp_path` under `/var/folders/...` vs `/private/var/folders/...`) compare equal.
- `tests/test_issue57_logs_follow_registry.py` consistently uses `os.path.realpath` when comparing paths resolved by `_logs_dir_abs` or `control_plane`.
---
## 3. Functional & Architectural Review
- **Resolver & Single Source of Truth (`mqtt_common.py`)**:
- `sibling_logs_dir(registry_dir)` purely computes `os.path.join(os.path.dirname(os.path.abspath(registry_dir)), "delegate_job_logs")`.
- `get_logs_dir(registry_dir=None)` honors `DELEGATE_JOB_LOGS_DIR` when non-empty, and otherwise delegates to `sibling_logs_dir(registry_dir or default_registry_dir())`.
- PEP 562 module `__getattr__("LOGS_DIR")` remains intact for backwards compatibility.
- **Engine, Registry, Subscriber, Publisher, Remote, Runner**:
- `engine.py` threads `registry_dir` into `_flush(notes, registry_dir)` and `_append_conflict_refused(..., registry_dir)`, ensuring conflict refusals and post-commit audit mirrors write next to the active registry.
- `registry.py` threads `logs = mqtt_common.get_logs_dir(registry_dir)` through `register_job`, legacy `pick_pending` (`MAM_ENGINE_CAS=0`), `get_feedback`, and CLI `logs`.
- `job_subscriber._check_disk_fallback` reads `logged = mqtt_common.read_logged_status(jid, mqtt_common.get_logs_dir(registry_dir))`, checks `stale = rec_seq is not None and (aud_seq is None or int(aud_seq) < int(rec_seq))` (catching `(TypeError, ValueError)` as `stale = True`), and extracts `disk_status = logged.get("status")` only when not stale.
- **Control Plane & Session Forwarding (`control_plane.py`, `worktree_prepare.sh`, `status.sh`)**:
- `control_plane.from_env()` derives `delegate_job_logs_dir` from `mam_jobs_dir` when `DELEGATE_JOB_LOGS_DIR` is unset.
- `control_plane.forwarded_keys()` omits `delegate_job_logs_dir` when it equals `_abs(mqtt_common.sibling_logs_dir(desc["mam_jobs_dir"]))`, so the row still records all 4 keys while the pane only receives `DELEGATE_JOB_LOGS_DIR` if it was explicitly set to a non-sibling location.
- `worktree_prepare.sh` only forwards `DELEGATE_JOB_LOGS_DIR` into `run_cmd` when explicitly set in the caller's environment.
- `status.sh` checks `sibling_logs_dir(r)` for both `MAM_JOBS_DIR` and `cp.get("mam_jobs_dir")`.
---
## 4. Test Execution Summary
Executed in `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261014T152849Z-f1a415/1` with `HERDR_*`, `WORKSPACE_ROOT`, `MAM_ENV_FILE`, `MAM_JOBS_DIR`, and `DELEGATE_JOB_LOGS_DIR` scrubbed, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp=/tmp/mam_verify_pr58_bt`:
- `tests/test_issue57_logs_follow_registry.py`: **12 passed**
- `tests/test_issue15_registry_isolation.py`: **20 passed**
- `tests/test_issue21_worktree_prereqs.py`: **27 passed**
- `tests/test_issue21_acceptance.py`: **9 passed**
- `tests/test_tier1_unit.py`: **67 passed**
Total: **135 passed, 0 failed**. Verification worktree `git status -s` is empty and no `.mam` directory was created.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261015T123502Z-1b9df6",
"base_sha": "e32c9574644a9185813a4dc881812694d62b6204",
"head_sha": "a7cb90e63abf454ccf22f6ff887cc25393755402",
"tree_sha": "96c5c5f82cb89e4383011d52fb1dcb21d4e787e6",
"review_commit": "a7cb90e63abf454ccf22f6ff887cc25393755402",
"reviewer": "reviewer-agy-01",
"job_id": "e6f0a5ad",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-15T13:18:35Z"
}
@@ -0,0 +1,68 @@
# Review Report — PR #58 Round 2 (Issue #57: Audit logs follow a moved registry)
- **Reviewer**: `agy` (`herdr:reviewer-agy-01`)
- **Job ID**: `e6f0a5ad`
- **Branch / Range**: `fix/issue-57` (`e32c9574644a9185813a4dc881812694d62b6204..a7cb90e63abf454ccf22f6ff887cc25393755402`, 15 commits, 23 files)
- **Round 2 Delta**: `63d816468e6d48d01bd96f31d8a25eeadfbaab60..a7cb90e63abf454ccf22f6ff887cc25393755402` (4 commits: `75c0042`, `151af99`, `1c598bb`, `a7cb90e`)
- **Verification Worktree**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261015T123502Z-1b9df6/1`
- **Platform**: macOS Darwin (`arm64-apple-darwin25`, `/usr/bin/python3` 3.9.6 without `paho-mqtt`, `.venv/bin/python` 3.9.6, `/bin/bash` 3.2.57)
---
## 1. Executive Summary
All 4 findings raised in Round 1 (`F1`, `F2`, `R1`, `R2`) are cleanly and completely resolved in `63d8164..a7cb90e`:
1. **F1 (`75c0042`) — Stdlib-only path resolution & `paho`-free package import**:
- Path resolution (`DEFAULT_REGISTRY_DIR`, `_workspace_mam_dir`, `default_registry_dir`, `sibling_logs_dir`, `get_logs_dir`) moved to stdlib-only `.agents/skills/lib_py/delegation/logroot.py`, with `mqtt_common.py` re-exporting all 5 symbols (`getattr(mqtt_common, k) is getattr(logroot, k)`).
- Both `mqtt_common.py` and `publisher.py` guard `import paho.mqtt.client as mqtt` with `try ... except ImportError: mqtt = None` (raising a descriptive `ImportError` inside `make_client()` only when a broker connection is actually requested). This allows `lib_py.delegation.__init__` (`DelegationService` -> `mqtt_common`, `publisher`) as well as `lib_py.control_plane`, `lib.sh`, and `status.sh` to import and execute cleanly under `/usr/bin/python3` when `paho-mqtt` is absent.
2. **F2 (`151af99`) — Wrapper `logs` forwards `--registry-dir` only when explicitly passed**:
- `cmd_logs` in `multi-agent-mux-delegate-job` uses `local -a reg=()` and expands `${reg[@]+"${reg[@]}"}` (Bash 3.2 + `set -u` safe idiom) so `registry.py` receives `--registry-dir` only when the caller passed it. Unadorned `logs --list` and `logs <jid>` from an arbitrary `cwd` continue to honor `MAM_JOBS_DIR` and `WORKSPACE_ROOT`.
3. **R1 (`1c598bb`) — Re-reading the registry record before adopting an audit-only terminal status**:
- In `job_subscriber._check_disk_fallback`, after reading `logged = mqtt_common.read_logged_status(...)` and verifying `not stale`, the subscriber re-reads `now = load_job(jid, registry_dir)` and verifies `(now.get("dispatch_token"), now.get("attempt_id", now.get("iteration")), now.get("transition_seq")) == (rec_token, rec_attempt, rec_seq)`.
- If an `A error -> redispatch (attempt 2) -> B completed` sequence interleaved between the initial `load_job` and `read_logged_status`, `same` is `False` and the subscriber defers to the next poll tick instead of attributing attempt B's completion to caller A's token/attempt.
4. **R2 (`a7cb90e`) — `sibling_logs_dir` resolves `os.path.realpath(registry_dir)`**:
- `logroot.sibling_logs_dir(registry_dir)` now computes `os.path.join(os.path.dirname(os.path.realpath(registry_dir)), "delegate_job_logs")`, matching the wrapper's `cd -P ... && pwd -P` and `control_plane._abs()`. When `--registry-dir` points to a symlinked `jobs` directory (`<tmp>/alias/jobs -> <tmp>/actual/jobs`), both `submit` and `logs` resolve to `<tmp>/actual/delegate_job_logs`.
---
## 2. Verification of Perspective (1): Manual Reproductions & Pre-Fix Failure Check
- **Manual Reproductions on macOS (`/usr/bin/python3` 3.9.6 without `paho`, `/bin/bash` 3.2.57)**:
- **F1**: Confirmed `/usr/bin/python3 -c "import paho"` fails (`ModuleNotFoundError`). With `AGENT_PYTHON_BIN=/usr/bin/python3` and `MAM_JOBS_DIR=$TMP/moved/jobs`, `mam_control_plane_json`, `python3 -m lib_py.control_plane prefix`, `delegate_publish_cmd`, `delegate_logs_dir`, and `status.sh`'s `sibling_logs_dir` candidate resolution all succeeded with zero errors.
- **F2**: With `WORKSPACE_ROOT=$TMP/ws` and `cwd=$TMP/elsewhere`, registered a job into `$TMP/ws/.mam/jobs` and ran `multi-agent-mux-delegate-job logs --list` and `logs <jid>` without `--registry-dir`; both found the job in `$TMP/ws/.mam/delegate_job_logs` and left no `.mam` in `$TMP/elsewhere`.
- **R1 & R2**: Verified `<tmp>/alias/jobs -> <tmp>/actual/jobs` symlink agreement between `submit --dry-run` and `logs`, and verified the `A error -> redispatch -> B completed` interleaving in `_check_disk_fallback`.
- **Pre-Fix Regression Failure Check (`63d8164`)**:
- Ran the 6 new regression test cases from `tests/test_issue57_logs_follow_registry.py` against an exported copy of `63d8164` in `/tmp`: all 6 failed (`test_control_plane_binding_works_without_paho`, `test_status_works_without_paho[json]`, `test_status_works_without_paho[text]`, `test_logs_without_registry_dir_follows_the_workspace_from_any_cwd`, `test_disk_fallback_rejects_a_terminal_that_belongs_to_a_later_dispatch`, `test_a_symlinked_registry_dir_resolves_to_one_audit_log`), and all 6 pass on `a7cb90e`.
---
## 3. Verification of Perspective (2) & (3): Side-Effect Analysis & macOS Consistency
- **R1 Re-Read Concurrency & Performance**:
- Every engine state transition increments `transition_seq` monotonically (and redispatch also rotates `dispatch_token` and increments `attempt_id`), so the tuple `(dispatch_token, attempt_id, transition_seq)` is strictly ABA-safe.
- When no registry record exists on disk (audit-only fallback after registry cleanup), both reads catch `Exception` and produce `(None, None, None) == (None, None, None)` (`same is True`), preserving audit-only terminal resolution.
- If a normal single-attempt job transitions from `running` to `completed` during the microsecond window between the first `load_job` and `read_logged_status`, `same` is `False` for that tick and the next poll tick reads `status == "completed"` directly from the registry record on the first `load_job`.
- **R2 `realpath` Impact on Default Paths & `control_plane.forwarded_keys`**:
- On macOS where `/var` -> `/private/var` and `/tmp` -> `/private/tmp`, `control_plane._abs()` already used `os.path.realpath`. Updating `sibling_logs_dir()` to use `os.path.realpath` makes `get_logs_dir()`, `sibling_logs_dir()`, `control_plane.from_env()`, and `control_plane.forwarded_keys()` (which now checks `if logs and _abs(logs) == _abs(sibling_logs_dir(desc["mam_jobs_dir"])):`) completely consistent.
- **F1 Module Move, Re-Exports & Eager Import (`lib_py/loop/eager.py`)**:
- `mqtt_common` re-exports `DEFAULT_REGISTRY_DIR`, `_workspace_mam_dir`, `default_registry_dir`, `get_logs_dir`, and `sibling_logs_dir` from `lib_py.delegation.logroot` at module top-level.
- Because `lib_py.delegation.mqtt_common` is in `eager.CORE_MODULES`, `eager.eager_load()` imports `lib_py.delegation.logroot` into `sys.modules` at loop startup (`test_issue5_phase4_runtime.py` passes).
---
## 4. Test Suite Execution Summary
Executed in `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261015T123502Z-1b9df6/1` with `HERDR_*`, `WORKSPACE_ROOT`, `MAM_ENV_FILE`, `MAM_JOBS_DIR`, and `DELEGATE_JOB_LOGS_DIR` scrubbed, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp=/tmp/mam_verify_pr58_r2_bt`:
- `tests/test_issue57_logs_follow_registry.py`: **18 passed**
- `tests/test_issue15_registry_isolation.py`: **20 passed**
- `tests/test_tier1_unit.py`: **58 passed**
- `tests/test_version_consistency.py`: **3 passed**
- `tests/test_issue5_phase4_runtime.py`: **5 passed**
- `tests/test_issue21_worktree_prereqs.py`: **36 passed**
- `tests/test_issue21_acceptance.py`: **9 passed**
Total: **149 passed, 0 failed**. Verification worktree `git status -s` is empty and no `.mam` directory was created.
[VERDICT: PASS]
@@ -0,0 +1,50 @@
# Review 5e7e2324 — PR #58, Issue #57 follow-up
Reviewed only the snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261014T152849Z-f1a415/1` (`loop-20261014T152849Z-f1a415`). Head `63d816468e6d48d01bd96f31d8a25eeadfbaab60`, tree `e8f162f74b7ce8ee8cc6ba7954b7d28dfa3f89f6`, base `e32c9574644a9185813a4dc881812694d62b6204` is an ancestor. Eleven commits, 21 files, +659/−95. `git diff --check` is clean. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane main is still `e32c9574644a9185813a4dc881812694d62b6204`, porcelain empty. No product file was edited. The source worktree was not used.
Host tools: `/usr/bin/python3` 3.9.6, the control-plane venv is also 3.9.6, and `/bin/bash` is 3.2.57. The changed Python modules parse with `ast` on 3.9.6. `bash -n` accepts `lib.sh`, the delegate-job wrapper, `worktree_prepare.sh`, `status.sh`, `reconcile.sh`, and `stop_session.sh`. Added lines do not use `match`, `tomllib`, or `X | Y` unions.
## The previous holes are closed
The rule is `mqtt_common.get_logs_dir(registry_dir)` (`mqtt_common.py:192-208`). A non-blank `DELEGATE_JOB_LOGS_DIR` is returned unchanged. Otherwise the audit root is `sibling_logs_dir` (`mqtt_common.py:186-189`): `delegate_job_logs` next to `os.path.abspath` of the registry, or of `default_registry_dir()` when the caller does not pass one. Writers and readers that know a registry pass it: `engine._flush`, `registry.py` register/pick/logs, `publish_event.record_outcome`, `remote.publish_dispatch`, the legacy status mirror (`mqtt_common.py:528`), `reconcile.sh`, and every `job_subscriber` append including the reconnect line. `runner._fingerprint` (`runner.py:1087`) excludes that same root. `Path` treats the absolute result as absolute, so `workspace_root / logs` stays the logs directory.
`control_plane.from_env` (`control_plane.py:54-57`) records `_abs(sibling_logs_dir(mam_jobs_dir))` when `DELEGATE_JOB_LOGS_DIR` is blank. `forwarded_keys` (`control_plane.py:77-84`) drops `delegate_job_logs_dir` when that stored path equals the same expression, and `prefix` emits only those keys. An explicit non-sibling root stays in the prefix. With no overrides, `from_env` still equals `defaults`, so the usual binding is not forwarded.
`worktree_prepare.sh:239-240` appends `DELEGATE_JOB_LOGS_DIR` only when the caller already set it. `stop_session.sh:154-158` still exports the row's `delegate_job_logs_dir` when the environment variable is unset. A row written by the new `from_env` stores the sibling, so that export points at the sibling.
Both `get_job_status` copies in `status.sh` (`:168-173` and `:380-385`) search the explicit logs dir and the row's logs dir, then `sibling_logs_dir` of `MAM_JOBS_DIR` and of the row's `mam_jobs_dir`. The script was not executed against a real checkout. The sandbox test below runs it.
`job_subscriber._check_disk_fallback` (`job_subscriber.py:96-121`) reads `status` from the audit dict. A versioned registry record adopts that status only when `transition_seq` is present and `int(aud_seq) >= int(rec_seq)`. A missing or non-numeric audit sequence is treated as stale for a versioned record. An unversioned record, and a missing record, keep the old rule. A registry status that is already terminal is left as it is. Token and attempt checks still run after that.
The wrapper removes the sixteen `$WORKDIR/delegate_job_logs` defaults. `LOGS_DIR_ABS` is set to `""` before `cmd_submit` (`multi-agent-mux-delegate-job:203`). After `cd` and `cd -P` of the registry, `cmd_submit` assigns `LOGS_DIR_ABS="$(_logs_dir_abs)"` and exits 1 if that command fails (`:281`), then exports it (`:284`). Later prints in that command use `$LOGS_DIR_ABS`. `cmd_logs` (`:1090-1109`) parses `--registry-dir` itself and passes it to `registry.py`. It does not read `LOGS_DIR_ABS`. `delegate_logs_dir` in `lib.sh:2375` is the same one-liner. On failure, `delegate_started_recorded` skips only the audit file and still reads the registry `events.log` (`lib.sh:2386-2389`). `delegate_wait_started` resolves the root once (`lib.sh:2446`).
## This Mac
`/tmp` is a symlink to `/private/tmp`. A probe under `/tmp` (deleted afterward; no herdr, no real `.mam`) called `from_env` and `forwarded_keys` with the jobs directory given as the `/tmp` path and again with the `/var` form of a `/private/var` path:
- The stored logs path equals `_abs(sibling_logs_dir(mam_jobs_dir))`, and `delegate_job_logs_dir` is absent from `forwarded_keys`.
- `get_logs_dir` of the realpathed jobs directory equals that stored path as a string.
- `from_env` of a workspace under `/tmp`, with cwd `/` and no `MAM_JOBS_DIR`, equals `defaults`. `get_logs_dir()` is the same directory as that binding.
- A relative `MAM_JOBS_DIR` is stored absolute. After `chdir("/")` the derivable root is still not forwarded.
- An explicit custom logs directory is forwarded.
- A jobs directory that is itself a symlink to another parent is recorded next to the target, and that binding is not forwarded. `sibling_logs_dir` of the symlink path is a different directory, because it uses `abspath` and does not resolve the link. The wrapper physicalizes the registry with `cd -P` before it resolves logs, and the pane receives the realpathed `MAM_JOBS_DIR`, so those two writers agree. A direct `get_logs_dir` of the symlink path follows the path as given.
`logs --registry-dir` with no value, under `set -euo pipefail`, exits 1 with an unbound `$2`. The same shift pattern is how `parse_opts` reads every flag. The process exits before it writes. A failing command substitution assigned with `||` is caught on this bash.
## Tests
Pytest used the control-plane venv (Python 3.9.6), `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp` `/tmp/mam57-5e7e2324-pytest`. Inherited `HERDR_*`, `MAM_*`, `MQTT_*`, `SKS_*`, `WORKSPACE_ROOT`, `DELEGATE_JOB_LOGS_DIR`, `PYTHONPATH`, `HOME_DIR`, `CLAUDE_PROJECT_DIR`, and `CODEX_HOME` were dropped. `AGENT_PYTHON_BIN` and `DELEGATE_JOB_PYTHON` pointed at that venv.
On the snapshot, 23 tests passed in 122.75s: `tests/test_issue57_logs_follow_registry.py` (resolver, registry CLI, wrapper dry-run and unreachable broker, publish, binding, explicit override, worktree prepare, status json and text, conflict and legacy claim, redispatched-attempt audit, prefix, create, and the pane that moves logs with `--registry-dir`), plus `test_logs_dir_follows_the_same_order`, `test_t3_0_control_plane_module`, `test_t3_1_create_persists_and_forwards_a_foreign_binding`, `test_t3_2_default_binding_is_recorded_not_forwarded`, `test_t3_3_4_stop_keeps_the_binding_and_resume_reforwards_it`, `test_a2_pane_env_carries_the_binding_not_the_agent_args`, and `test_b9_logs_dir_follows_cwd_changes`.
The same four new tests copied onto an archive of base `e32c957` failed in 0.16s: `get_logs_dir()` rejected the registry argument, `from_env` kept the workspace logs directory, the disk fallback did not adopt an audit-only terminal status, and the prefix test did not see the sibling on the row. The archive and both basetemps were removed. Snapshot porcelain stayed empty.
The full suite was left to the orchestrator. `tests/test_issue10_13_purge.py` was not run.
## Documentation left as written
`SKILL.md:139-141` and `registry.md:152-154` state the sibling rule. `SKILL.md:175` (Audit Logs) still names only `.mam/delegate_job_logs/<job_id>/`, which is the directory the rule produces when the registry is `.mam/jobs`. `docs/design/worktree-creator-model.md:126` lists the status candidates and does not mention the sibling lookup that `status.sh` now performs. `README.md` is outside this diff and still names the workspace directory. Those sentences do not change where the code writes.
The pane herdr config file is newer than the mtime recorded at the end of the previous review. This review did not start herdr and did not write that file. `~/.config/herdr/config.toml` is unchanged. That mtime is not part of the verdict.
[VERDICT: PASS]
@@ -0,0 +1,14 @@
{
"loop_id": "loop-20261015T123502Z-1b9df6",
"base_sha": "e32c9574644a9185813a4dc881812694d62b6204",
"head_sha": "a7cb90e63abf454ccf22f6ff887cc25393755402",
"tree_sha": "96c5c5f82cb89e4383011d52fb1dcb21d4e787e6",
"review_commit": "a7cb90e63abf454ccf22f6ff887cc25393755402",
"reviewer": "reviewer-grok-01",
"job_id": "d6ae03a9",
"verdict": "PASS",
"transport": "herdr",
"evidence": "review-tree",
"isolation": "worktree",
"verified_at": "2026-10-15T13:18:35Z"
}
@@ -0,0 +1,47 @@
# Review d6ae03a9 — PR #58, second pass on the four Mac findings
Reviewed only the snapshot `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20261015T123502Z-1b9df6/1` (`loop-20261015T123502Z-1b9df6`). Head `a7cb90e63abf454ccf22f6ff887cc25393755402`, tree `96c5c5f82cb89e4383011d52fb1dcb21d4e787e6`, base `e32c9574644a9185813a4dc881812694d62b6204` is an ancestor. Fifteen commits, 23 files, +908/−131. The four commits after the first-round head `63d8164` are `75c0042`, `151af99`, `1c598bb`, and `a7cb90e`. `git diff --check` is clean. Snapshot porcelain is empty and the snapshot has no `.mam`. Control-plane main is still `e32c9574644a9185813a4dc881812694d62b6204`, porcelain empty. No product file was edited.
Host tools: `/usr/bin/python3` 3.9.6 has no `paho`. The control-plane venv is also 3.9.6 and does have `paho`. `/bin/bash` is 3.2.57. The changed modules parse with `ast` on 3.9.6. `bash -n` accepts the wrapper.
## F1 — path rules import without paho
`lib_py/delegation/logroot.py` is standard library only. `sibling_logs_dir`, `get_logs_dir`, and `default_registry_dir` live there. `mqtt_common.py:33-40` imports `paho` inside `try/except` and re-exports the logroot names, so `mqtt_common.sibling_logs_dir` is the same function. `publisher.py:17-20` does the same for its own `paho` import. `control_plane.py:28` and both `status.sh` copies (`status.sh:171`, and the text-mode copy) import `sibling_logs_dir` from `logroot`, not from `mqtt_common`.
`/usr/bin/python3` imports `lib_py.delegation`, `lib_py.control_plane`, and `logroot` in one process. Sourcing `lib.sh` with `AGENT_PYTHON_BIN=/usr/bin/python3` and `mam_control_plane_json` on a temporary workspace exits 0 and returns a `delegate_job_logs_dir` under that workspace's `.mam`. `make_client` still raises `ImportError` when a broker is actually required (`mqtt_common.py:286-288`).
`lib_py.delegation.logroot` is not named in `eager.CORE_MODULES`. Importing `lib_py.delegation.mqtt_common`, which eager load does, loads `logroot` as a dependency. On this interpreter that module is in `sys.modules` after that import.
## F2 — `logs` keeps the workspace default
`cmd_logs` (`multi-agent-mux-delegate-job:1096-1113`) passes `--registry-dir` only when the caller gave it. The empty array uses `${reg[@]+"${reg[@]}"}`. On bash 3.2 with `set -u`, an empty array expands to nothing and a two-element array expands to both words. `registry.py` then applies its own default: `MAM_JOBS_DIR`, then `WORKSPACE_ROOT`, then the cwd.
With `WORKSPACE_ROOT` set to a temporary workspace and the cwd set to a different directory, `registry.py register` wrote the job under the workspace `.mam/jobs` and the audit log under the workspace `.mam/delegate_job_logs`. `logs --list` from that other cwd printed the job id and exited 0. The other directory gained no `.mam`.
## R1 — a later dispatch is not the earlier one's success
`_check_disk_fallback` (`job_subscriber.py:121-138`) re-reads the registry record after it has read the audit status and before it adopts that status. It adopts only when `dispatch_token`, `attempt_id` (falling back to `iteration`), and `transition_seq` are unchanged. A changed record leaves the job pending for the next poll, which reads both again. The extra read is one `load_job` per non-terminal job on the audit path. The token check below still uses the first snapshot, which this comparison has just required to match the second read, and there is no further I/O between them.
The regression test interleaves `error`, then `redispatch` to attempt 2, then `completed`, inside `read_logged_status`. The caller waiting on attempt 1's token gets an empty terminal set. The next call, waiting on attempt 2's token, gets `completed`. A third call with attempt 1's token stays pending. On an archive of `63d8164` the same test fails with `B's completion was attributed to A`.
## R2 — a symlinked registry has one audit log
`sibling_logs_dir` (`logroot.py:51`) uses `os.path.realpath` of the registry, then the sibling `delegate_job_logs`. A relative registry still resolves from the cwd. `forwarded_keys` (`control_plane.py:82-84`) compares `_abs` of both sides, so a `/tmp` path and its `/private/tmp` real path still count as the same sibling and the derivable root stays off the pane.
A probe under `/tmp` (`/tmp` is a symlink to `/private/tmp`): `from_env` with no `MAM_JOBS_DIR` equals `defaults`; `get_logs_dir()` is that same directory; a jobs path given through `/tmp` is not forwarded; a jobs directory that is itself a symlink to another parent pairs with the target's sibling, and the binding matches that directory and is not forwarded.
`submit --dry-run --registry-dir <tmp>/alias/jobs`, where that path links to `<tmp>/actual/jobs`, wrote `meta.json` under `<tmp>/actual/delegate_job_logs` and printed that directory. `<tmp>/alias/delegate_job_logs` was not created, and neither was the workspace audit root. `logs --registry-dir` of the alias printed `registered`.
## Tests
Pytest used the control-plane venv, `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, and `--basetemp` `/tmp/mamd6-d6ae03a9-pytest`. Inherited `HERDR_*`, `MAM_*`, `MQTT_*`, `SKS_*`, `WORKSPACE_ROOT`, `DELEGATE_JOB_LOGS_DIR`, and `PYTHONPATH` were dropped. `AGENT_PYTHON_BIN` pointed at that venv.
On the snapshot, 25 tests passed in 50.76s: all of `tests/test_issue57_logs_follow_registry.py`, including the four new repros (control-plane without paho, status json and text without paho, logs from another cwd, the interleaved redispatch, and the symlinked registry), plus `test_t3_0_control_plane_module`, `test_logs_dir_follows_the_same_order`, and `test_b9_logs_dir_follows_cwd_changes`.
The six new test cases copied onto an archive of `63d8164` failed in 2.55s: the control-plane import traceback, both status modes, `logs` reading `<cwd>/.mam/delegate_job_logs`, the redispatch attributed to the earlier attempt, and `ImportError` for `logroot`. Scratch directories were removed. Snapshot porcelain stayed empty.
The full suite was left to the orchestrator. `SKILL.md`'s Audit Logs sentence still names `.mam/delegate_job_logs/<job_id>/`, which is the directory this rule produces when the registry is `.mam/jobs`. The design-doc status row now lists the sibling lookup (`docs/design/worktree-creator-model.md:128`).
The pane herdr config file is newer than the mtime seen at the end of the previous review. This review did not start herdr and did not write that file. `~/.config/herdr/config.toml` is unchanged. That mtime is not part of the verdict.
[VERDICT: PASS]
+17 -6
View File
@@ -29,7 +29,7 @@ export WORKSPACE_ROOT
# Framework semantic version. Single runtime source of truth; kept in lockstep
# with VERSIONS.md and the 8 SKILL.md frontmatters by tests/test_version_consistency.py.
# NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable.
MAM_VERSION="4.9.3"
MAM_VERSION="4.9.4"
export MAM_VERSION
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
@@ -2322,7 +2322,7 @@ delegate_publish_cmd() {
pub_reg="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/jobs}}"
pub_reg="${pub_reg:-.mam/jobs}"
mkdir -p "$pub_reg" && pub_reg="$(cd -P "$pub_reg" && pwd -P)"
pub_logs="$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$py_bin" -c 'import os; from lib_py.delegation.mqtt_common import get_logs_dir; print(os.path.abspath(get_logs_dir()))' 2>/dev/null || true)"
pub_logs="$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$py_bin" -c 'import os, sys; from lib_py.delegation.logroot import get_logs_dir; print(os.path.abspath(get_logs_dir(sys.argv[1])))' "$pub_reg" 2>/dev/null || true)"
local -a argv=("$py_bin" "$pub_script" --registry-dir "$pub_reg" --job "$job_id")
[ -n "$tok" ] && argv+=(--expect-dispatch-token "$tok")
[ -n "$att" ] && argv+=(--attempt-id "$att")
@@ -2372,12 +2372,21 @@ except Exception:
# Never the registry status: a record leaves `pending` when another writer
# closes it or another dispatch claims it. Lines are parsed as JSON, never
# pattern-matched. rc 0 = recorded, 1 = not (yet).
delegate_logs_dir() {
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -c 'import os, sys; from lib_py.delegation.logroot import get_logs_dir; print(os.path.abspath(get_logs_dir(sys.argv[1])))' "$1" 2>/dev/null
}
delegate_started_recorded() {
local job_id="$1" tok="${2:-}" logs_dir="${3:-}"
local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}"
[ -n "$logs_dir" ] || logs_dir="${DELEGATE_JOB_LOGS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/delegate_job_logs}"
# Issue #57: the audit log is resolved by the one rule (next to the registry
# unless DELEGATE_JOB_LOGS_DIR says otherwise); no bash copy of the default.
# If that resolution fails, only the audit check is skipped: the registry's
# events.log is still evidence.
[ -n "$logs_dir" ] || logs_dir="$(delegate_logs_dir "$reg_dir")" || logs_dir=""
local ev_log="$reg_dir/$job_id.events.log"
local audit="$logs_dir/$job_id/events.ndjson"
local audit=""
[ -n "$logs_dir" ] && audit="$logs_dir/$job_id/events.ndjson"
[ -f "$ev_log" ] || [ -f "$audit" ] || return 1
EV_LOG="$ev_log" EV_AUDIT="$audit" EV_TOK="$tok" python3 -c '
import hashlib, json, os, sys
@@ -2431,10 +2440,12 @@ delegate_wait_started() {
local job_id="$1" ack_s="${2:-180}" tok="${3:-}"
[ -n "$job_id" ] || return 1
ack_s=$(mam_int "$ack_s" 0) || { echo "WARNING: onboarding ack timeout ${2-} ignored (not a decimal integer >= 0); using 180" >&2; ack_s=180; }
local deadline
local deadline logs_dir
deadline=$(( $(date +%s) + ack_s ))
# Issue #57: resolve the audit-log root once, not once per 1 s poll.
logs_dir="$(delegate_logs_dir "${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}")" || logs_dir=""
while :; do
if delegate_started_recorded "$job_id" "$tok"; then
if delegate_started_recorded "$job_id" "$tok" "$logs_dir"; then
return 0
fi
if [ "$(date +%s)" -ge "$deadline" ]; then
+22 -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>]
@@ -21,6 +25,8 @@ import shlex
import sys
from typing import Dict, Optional
from lib_py.delegation.logroot import sibling_logs_dir
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"}
@@ -45,6 +51,10 @@ def from_env(env=None) -> Dict[str, str]:
v = (env.get(var) or "").strip()
if v:
b[k] = _abs(v)
# Issue #57: without an explicit DELEGATE_JOB_LOGS_DIR the audit log is
# the delegate_job_logs next to the registry in use, not the workspace's.
if not (env.get("DELEGATE_JOB_LOGS_DIR") or "").strip():
b["delegate_job_logs_dir"] = _abs(sibling_logs_dir(b["mam_jobs_dir"]))
return b
@@ -64,10 +74,21 @@ 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)
logs = desc.get("delegate_job_logs_dir")
if logs and _abs(logs) == _abs(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:
+4 -3
View File
@@ -374,13 +374,14 @@ def _flush(notifications, registry_dir) -> None:
Best effort by design: the registry record is already durable, and an audit
write must never be able to fail a transition that has committed.
"""
logs_dir = mqtt_common.get_logs_dir(registry_dir)
for note in notifications:
job_id = note["job_id"]
kind = note["kind"]
try:
if kind == "status_changed":
mqtt_common.update_logged_status(
job_id, note["to"], updated_at=note.get("timestamp"),
job_id, note["to"], logs_dir=logs_dir, updated_at=note.get("timestamp"),
transition_seq=note.get("transition_seq"))
mqtt_common.append_event(job_id, {
"event": "status_changed",
@@ -389,7 +390,7 @@ def _flush(notifications, registry_dir) -> None:
"by": note.get("by"),
"timestamp": note.get("timestamp"),
"transition_seq": note.get("transition_seq"),
})
}, logs_dir=logs_dir)
elif kind == "conflict_refused":
mqtt_common.append_event(job_id, {
"event": "conflict_refused",
@@ -398,6 +399,6 @@ def _flush(notifications, registry_dir) -> None:
"reason": note.get("reason"),
"by": note.get("by"),
"timestamp": note.get("timestamp"),
})
}, logs_dir=logs_dir)
except Exception as exc: # pragma: no cover - best effort
logger.warning("audit mirror failed for %s: %s", job_id, exc)
@@ -84,18 +84,58 @@ def _check_disk_fallback(
disk_status = None
rec_token = None
rec_attempt = None
rec_seq = None
try:
job_rec = load_job(jid, registry_dir)
disk_status = job_rec.get("status")
rec_token = job_rec.get("dispatch_token")
rec_attempt = job_rec.get("attempt_id", job_rec.get("iteration"))
rec_seq = job_rec.get("transition_seq")
except Exception:
job_rec = {}
if not disk_status or disk_status not in ("completed", "error", "cancelled"):
# read_logged_status returns the status.json dict; the status is its
# "status" field (Issue #57, comment 5: comparing the dict matched nothing).
try:
disk_status = mqtt_common.read_logged_status(jid, mqtt_common.get_logs_dir())
logged = mqtt_common.read_logged_status(jid, mqtt_common.get_logs_dir(registry_dir))
except Exception:
pass
logged = None
if logged:
# The audit mirror is written after the registry commits, so a
# record that is not terminal may sit next to the previous
# attempt's terminal status. A versioned record (the engine's
# transition_seq) takes the audit status only when the audit is
# at least as new; an audit status without a sequence cannot
# prove which attempt it describes, so a versioned record
# ignores it. An unversioned record (never transitioned, or
# MAM_ENGINE_CAS=0) and a missing record keep the old rule.
aud_seq = logged.get("transition_seq")
try:
stale = rec_seq is not None and (aud_seq is None or int(aud_seq) < int(rec_seq))
except (TypeError, ValueError):
stale = True # a damaged status.json proves nothing either
if stale:
logger.warning("disk fallback ignored audit status %r for %s: transition_seq %r is older "
"than the record's %r", logged.get("status"), jid, aud_seq, rec_seq)
else:
# The record above was read before the audit status. If
# another dispatch began in between (A error -> redispatch
# -> B completed), the audit terminal is B's while the token
# and attempt checked below are A's. Take it only when the
# record still describes the same observation; otherwise
# leave the job pending for the next round, which reads
# both afresh (Issue #57, Mac review R1).
try:
now = load_job(jid, registry_dir)
except Exception:
now = {}
same = ((now.get("dispatch_token"), now.get("attempt_id", now.get("iteration")),
now.get("transition_seq")) == (rec_token, rec_attempt, rec_seq))
if same:
disk_status = logged.get("status")
else:
logger.warning("disk fallback deferred %s: the record changed while the audit status "
"was being read", jid)
if disk_status in ("completed", "error", "cancelled"):
if expected_dispatch_tokens:
@@ -130,8 +170,10 @@ class _Watcher:
def __init__(self, expected_job_ids: Set[str], expected_tokens: Dict[str, Optional[str]], expected_seqs: Dict[str, int],
expected_dispatch_tokens: Optional[Dict[str, Optional[str]]] = None,
expected_attempts: Optional[Dict[str, Optional[int]]] = None):
expected_attempts: Optional[Dict[str, Optional[int]]] = None,
logs_dir: Optional[str] = None):
self.events: "queue.Queue[Tuple[str, Dict[str, Any]]]" = queue.Queue()
self.logs_dir = logs_dir # Issue #57: the audit log next to the registry being watched
self.expected = set(expected_job_ids)
self.tokens = expected_tokens # job_id -> expected auth_token (or None)
self.last_seq = dict(expected_seqs)
@@ -163,7 +205,7 @@ class _Watcher:
"seq": got,
"via": via,
"topic": topic,
})
}, logs_dir=self.logs_dir)
def _on_state(self, msg, payload: Dict[str, Any], jid: str) -> None:
"""A retained `<prefix>/state` message (Issue #18): learn the job's
@@ -196,7 +238,7 @@ class _Watcher:
"topic": msg.topic,
"timestamp": payload.get("timestamp"),
"detail": "status={} last_event={}".format(payload.get("status"), payload.get("last_event")),
})
}, logs_dir=self.logs_dir)
self.events.put((msg.topic, dict(payload, _renews_liveness=renews)))
def on_message(self, _client, _userdata, msg) -> None:
@@ -317,7 +359,7 @@ class _Watcher:
if expected_disp_tok else None),
"attempt_id": (data.get("attempt_id") if isinstance(data, dict) and data.get("attempt_id") is not None
else self.attempts.get(jid)),
})
}, logs_dir=self.logs_dir)
self.events.put((msg.topic, payload))
@@ -375,7 +417,8 @@ def _run_subscriber(args) -> int:
if args.attempt_id is not None and args.job:
attempts[args.job] = args.attempt_id
watcher = _Watcher(expected_ids, tokens, seqs, dispatch_tokens, attempts)
logs_dir = mqtt_common.get_logs_dir(args.registry_dir)
watcher = _Watcher(expected_ids, tokens, seqs, dispatch_tokens, attempts, logs_dir=logs_dir)
# Resolve timeouts from CLI, falling back to the (first) job's settings.
base_job = jobs[0]
@@ -418,7 +461,8 @@ def _run_subscriber(args) -> int:
# what makes the retained state message arrive again at once.
logger.warning("broker reconnected (connect #%d); re-subscribing", connects["count"])
for jid in expected_ids:
mqtt_common.append_event(jid, {"event": "reconnected", "connect_count": connects["count"]})
mqtt_common.append_event(jid, {"event": "reconnected", "connect_count": connects["count"]},
logs_dir=logs_dir)
for topic in subscribed_topics + state_topics:
_c.subscribe(topic, qos=1)
logger.info("subscribed to %s", topic)
@@ -0,0 +1,70 @@
"""Where the job registry and its audit log live (Issue #15, Issue #57).
Standard library only, on purpose: create/resume's control-plane binding,
status.sh and lib.sh resolve these paths through ``_delegate_py_bin``, whose
last fallback is the PATH's ``python3``, which may not have paho-mqtt.
``mqtt_common`` re-exports every name here, so existing callers are unchanged.
"""
from __future__ import annotations
import os
from typing import Optional
DEFAULT_REGISTRY_DIR = ".mam/jobs"
def _workspace_mam_dir(leaf: str) -> Optional[str]:
"""`$WORKSPACE_ROOT/.mam/<leaf>` when lib.sh has told us which workspace we
serve; None otherwise."""
root = os.environ.get("WORKSPACE_ROOT")
if root and root.strip() and os.path.isdir(root):
return os.path.join(root, ".mam", leaf)
return None
def default_registry_dir() -> str:
"""Registry root, resolved at call time (Issue #15).
``MAM_JOBS_DIR`` wins; otherwise the workspace lib.sh exported as
``WORKSPACE_ROOT``; otherwise the historical cwd-relative ``.mam/jobs``.
Anchoring on the workspace is what keeps a test sandbox (or any caller
whose cwd is not the workspace) from registering jobs into whichever
``.mam/jobs`` happens to sit under the current directory - the pytest
suite used to leave hundreds of onboarding records in the real registry.
"""
env = os.environ.get("MAM_JOBS_DIR")
if env and env.strip():
return env
return _workspace_mam_dir("jobs") or DEFAULT_REGISTRY_DIR
def sibling_logs_dir(registry_dir: str) -> str:
"""The audit-log root that pairs with ``registry_dir``: the
``delegate_job_logs`` next to it. Pure; reads no environment. Issue #57.
The registry is taken by its real path: a registry directory that is itself
a symlink pairs with the audit log next to its target, which is what the
wrapper (``pwd -P``) and the control-plane binding (``realpath``) already
see, so submit and logs agree (Mac review R2). A relative registry resolves
against the cwd, as registry.py opens it.
"""
return os.path.join(os.path.dirname(os.path.realpath(registry_dir)), "delegate_job_logs")
def get_logs_dir(registry_dir: Optional[str] = None) -> str:
"""Audit-log root, resolved at call time (B-9).
``DELEGATE_JOB_LOGS_DIR`` when set: it means the audit log was moved to a
place that cannot be derived from the registry. Otherwise the audit log
follows the registry (Issue #57): the ``delegate_job_logs`` next to
``registry_dir``, or next to ``default_registry_dir()`` when the caller
does not know its registry. The default registry is
``$WORKSPACE_ROOT/.mam/jobs`` (Issue #15), otherwise ``<cwd>/.mam/jobs``,
so the usual roots are unchanged. Resolved per call rather than at import
so a chdir after import cannot strand the audit trail in the old tree —
the same reason ``DEFAULT_REGISTRY_DIR`` stays a relative string.
"""
env = os.environ.get("DELEGATE_JOB_LOGS_DIR")
if env and env.strip():
return env
return sibling_logs_dir(registry_dir or default_registry_dir())
+12 -44
View File
@@ -30,9 +30,14 @@ from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Callable, Dict, Iterable, List, Optional
import paho.mqtt.client as mqtt
try:
import paho.mqtt.client as mqtt
except ImportError: # the registry and the path rules work without a broker library (Issue #57)
mqtt = None # make_client() says so when a broker is actually needed
from lib_py.delegation import envfile
from lib_py.delegation.logroot import ( # noqa: F401 (re-exported: this module is the documented home)
DEFAULT_REGISTRY_DIR, _workspace_mam_dir, default_registry_dir, get_logs_dir, sibling_logs_dir)
logger = logging.getLogger("delegate_job.mqtt_common")
@@ -145,35 +150,10 @@ def load_env_or_report() -> bool:
# Constants
# --------------------------------------------------------------------------
SCHEMA_VERSION = 1
DEFAULT_REGISTRY_DIR = ".mam/jobs"
DEFAULT_TOPIC_ROOT = "python/mqtt/jobs"
LOCK_FILENAME = ".lock"
def _workspace_mam_dir(leaf: str) -> Optional[str]:
"""`$WORKSPACE_ROOT/.mam/<leaf>` when lib.sh has told us which workspace we
serve; None otherwise."""
root = os.environ.get("WORKSPACE_ROOT")
if root and root.strip() and os.path.isdir(root):
return os.path.join(root, ".mam", leaf)
return None
def default_registry_dir() -> str:
"""Registry root, resolved at call time (Issue #15).
``MAM_JOBS_DIR`` wins; otherwise the workspace lib.sh exported as
``WORKSPACE_ROOT``; otherwise the historical cwd-relative ``.mam/jobs``.
Anchoring on the workspace is what keeps a test sandbox (or any caller
whose cwd is not the workspace) from registering jobs into whichever
``.mam/jobs`` happens to sit under the current directory - the pytest
suite used to leave hundreds of onboarding records in the real registry.
"""
env = os.environ.get("MAM_JOBS_DIR")
if env and env.strip():
return env
return _workspace_mam_dir("jobs") or DEFAULT_REGISTRY_DIR
# Persistent audit-log layout: .mam/delegate_job_logs/<job_id>/{meta,events,status}.
# This is a *separate* artifact from the registry: the registry is the live job
# record (mutated in place), the audit log is an append-only history that
@@ -183,22 +163,6 @@ EVENTS_FILENAME = "events.ndjson"
STATUS_FILENAME = "status.json"
def get_logs_dir() -> str:
"""Audit-log root, resolved at call time (B-9).
Overridable with ``DELEGATE_JOB_LOGS_DIR``; otherwise
``$WORKSPACE_ROOT/.mam/delegate_job_logs`` (Issue #15, see
``default_registry_dir``); otherwise ``<cwd>/.mam/delegate_job_logs``.
Resolved per call rather than at import so a chdir after import cannot
strand the audit trail in the old tree — the same reason
``DEFAULT_REGISTRY_DIR`` stays a relative string.
"""
env = os.environ.get("DELEGATE_JOB_LOGS_DIR")
if env and env.strip():
return env
return _workspace_mam_dir("delegate_job_logs") or os.path.join(os.getcwd(), ".mam", "delegate_job_logs")
def __getattr__(name: str): # PEP 562 (3.7+)
"""Keep ``mqtt_common.LOGS_DIR`` working for external consumers
(documented in registry.md) while resolving it dynamically."""
@@ -319,6 +283,9 @@ def make_client(role: str, config: Optional[BrokerConfig] = None) -> mqtt.Client
"""
config = config or broker_config_from_env()
client_id = f"{config.client_id_prefix}-{role}-{uuid.uuid4().hex[:8]}"
if mqtt is None:
raise ImportError("paho-mqtt is required to talk to a broker; install it into the virtualenv "
"(see multi-agent-mux-delegate-job/requirements.txt)")
client = mqtt.Client(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id=client_id,
@@ -516,14 +483,15 @@ def _update_job_status_legacy(job_id: str, registry_dir: str = DEFAULT_REGISTRY_
_atomic_write_record(job_id, registry_dir, record)
if "status" in fields:
new_status = record.get("status")
update_logged_status(job_id, new_status, updated_at=record["updated_at"])
logs_dir = get_logs_dir(registry_dir)
update_logged_status(job_id, new_status, logs_dir=logs_dir, updated_at=record["updated_at"])
if old_status != new_status:
append_event(job_id, {
"event": "status_changed",
"from": old_status,
"to": new_status,
"timestamp": record["updated_at"],
})
}, logs_dir=logs_dir)
return record
@@ -241,7 +241,7 @@ def record_outcome(prepared: PreparedEvent, registry_dir: str,
"payload": prepared.payload,
"published": publish_ok,
"publish_error": publish_error,
})
}, logs_dir=mqtt_common.get_logs_dir(registry_dir))
if not local_event_recorded:
registry.append_event(prepared.job_id, registry_dir, prepared.payload)
@@ -14,7 +14,10 @@ import logging
import time
from typing import Any, Optional, Tuple
import paho.mqtt.client as mqtt
try:
import paho.mqtt.client as mqtt
except ImportError: # importable without a broker library; mqtt_common.make_client() refuses to connect
mqtt = None
from lib_py.delegation import mqtt_common
+7 -6
View File
@@ -175,7 +175,7 @@ def register_job(
_atomic_write_record(job_id, registry_dir, record)
# Seed the persistent audit log (meta.json + status.json + a "registered"
# event). Best-effort inside init_job_log — never blocks registration.
mqtt_common.init_job_log(job_id, meta=record)
mqtt_common.init_job_log(job_id, meta=record, logs_dir=mqtt_common.get_logs_dir(registry_dir))
logger.info("registered job %s (agent=%s session=%s)", job_id, agent, agent_session)
return job_id
@@ -233,14 +233,15 @@ def _pick_pending_legacy(agent_session: str, registry_dir: str = DEFAULT_REGISTR
updated_at = chosen["updated_at"]
# Mirrors the pending->running transition into the audit log outside the
# lock. This is the shape the engine now generalises to every transition.
mqtt_common.update_logged_status(job_id, "running", updated_at=updated_at)
logs_dir = mqtt_common.get_logs_dir(registry_dir)
mqtt_common.update_logged_status(job_id, "running", logs_dir=logs_dir, updated_at=updated_at)
mqtt_common.append_event(job_id, {
"event": "status_changed",
"from": "pending",
"to": "running",
"by": agent_session,
"timestamp": updated_at,
})
}, logs_dir=logs_dir)
return job_id
@@ -328,7 +329,7 @@ def get_feedback(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR,
# 1) Try the unified audit log first (ndjson) since it's written synchronously by the subscriber
try:
from lib_py.delegation import mqtt_common
logs_dir = mqtt_common.get_logs_dir()
logs_dir = mqtt_common.get_logs_dir(registry_dir)
logged = list(mqtt_common.iter_logged_events(job_id, logs_dir))
for e in reversed(logged):
if e.get("source_event") in events:
@@ -450,7 +451,7 @@ def _build_parser() -> argparse.ArgumentParser:
help="summarise every job under the logs dir instead")
p_logs.add_argument("--logs-dir", default=None,
help="override the audit-log root (default: $DELEGATE_JOB_LOGS_DIR "
"or <cwd>/.mam/delegate_job_logs)")
"or the delegate_job_logs next to --registry-dir)")
p_logs.add_argument("--tail", type=int, default=0,
help="show only the last N events (0 = all)")
p_logs.add_argument("--json", action="store_true",
@@ -698,7 +699,7 @@ def main(argv: Optional[List[str]] = None) -> int:
def _cmd_logs(args) -> int:
"""Pretty-print one job's events.ndjson, or summarise all logged jobs."""
logs_dir = args.logs_dir or mqtt_common.get_logs_dir()
logs_dir = args.logs_dir or mqtt_common.get_logs_dir(args.registry_dir)
if args.list_all:
jobs = mqtt_common.list_logged_jobs(logs_dir)
+2 -1
View File
@@ -136,7 +136,8 @@ def publish_dispatch(job_id: str, registry_dir: str, session: str, instructions:
exceptions=(OSError, TimeoutError, ConnectionError, ValueError))(
broker_config_from_job(job), topic, body, True)
mqtt_common.append_event(job_id, {"event": "dispatched", "topic": topic, "session": session,
"attempt_id": payload["attempt_id"]})
"attempt_id": payload["attempt_id"]},
logs_dir=mqtt_common.get_logs_dir(registry_dir))
logger.info("dispatched job %s to remote session %s on %s", job_id, session, topic)
return topic
+1 -1
View File
@@ -13,7 +13,7 @@ import sys
from typing import List, Sequence
# Framework version locked with framework constants
FRAMEWORK_VERSION = "4.9.3"
FRAMEWORK_VERSION = "4.9.4"
CORE_MODULES: Sequence[str] = (
# Standard library modules
+2 -1
View File
@@ -26,6 +26,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from lib_py import ints
from lib_py.delegation import mqtt_common
from lib_py.delegation import registry as job_registry
from lib_py.loop import checkpoint, eager, parser
@@ -1083,7 +1084,7 @@ class LoopRunner:
if not head:
return None
root = self.worktree_root.resolve()
logs = Path(os.environ.get("DELEGATE_JOB_LOGS_DIR") or self.mam_dir / "delegate_job_logs")
logs = Path(mqtt_common.get_logs_dir(str(self.jobs_dir)))
exclude = []
for d in (self.mam_dir, self.jobs_dir, self.workspace_root / logs):
try:
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-create
description: "Create a new agent session (claude, antigravity/agy) in a dedicated herdr session for context-preserving long-running work. Always creates a herdr session — never backgrounds with nohup/disown. Writes the new session to .mam/agent-sessions.yaml. Use when you want to start a fresh agent (no prior UUID) for a new project workspace."
version: 4.9.3
version: 4.9.4
author: godopu
license: MIT
platforms: [linux, macos]
@@ -234,8 +234,10 @@ if [ -n "$CREATOR_AGENT" ]; then
env_add=(WORKSPACE_ROOT="$MAIN"
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$MAIN/.mam/agent-sessions.yaml}"
MAM_JOBS_DIR="${MAM_JOBS_DIR:-$MAIN/.mam/jobs}"
DELEGATE_JOB_LOGS_DIR="${DELEGATE_JOB_LOGS_DIR:-$MAIN/.mam/delegate_job_logs}"
MAM_NO_SUBSCRIBER_DAEMON=1)
# Issue #57: an explicit audit-log root is passed on; otherwise create's
# binding derives it from MAM_JOBS_DIR (the delegate_job_logs next to it).
if [ -n "${DELEGATE_JOB_LOGS_DIR:-}" ]; then env_add+=(DELEGATE_JOB_LOGS_DIR="$DELEGATE_JOB_LOGS_DIR"); fi
if [ -z "${MAM_ENV_FILE:-}" ] && [ -f "$MAIN/.mam.env" ]; then env_add+=(MAM_ENV_FILE="$MAIN/.mam.env"); fi
rc=0
env "${env_add[@]}" bash "$SKILL_DIR/multi-agent-mux-create/scripts/create_session.sh" "${args[@]}" || rc=$?
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-delegate-job
description: "Delegate a unit of work to any autonomous agent (claude-code, hermes, agy, grok-build, codex, or a human) and observe it asynchronously over an MQTT event channel. Supported roles include orchestrator, worker, and reviewer."
version: 4.9.3
version: 4.9.4
author: godopu
license: MIT
platforms: [linux, macos, windows]
@@ -134,10 +134,11 @@ Which tree wins, by execution context:
`python -m`: for `-m` the current directory precedes `PYTHONPATH`, so a frozen
loop whose working directory happened to contain a `lib_py` package would have
silently executed live code. The working directory is left untouched; the
registry and audit-log paths resolve to `MAM_JOBS_DIR` / `DELEGATE_JOB_LOGS_DIR`
when set, otherwise to `$WORKSPACE_ROOT/.mam/jobs` and
`$WORKSPACE_ROOT/.mam/delegate_job_logs` (the workspace `lib.sh` exports), and
only without either to the cwd-relative `.mam/...` (Issue #15 — this is what
registry path resolves to `MAM_JOBS_DIR` when set, otherwise to
`$WORKSPACE_ROOT/.mam/jobs` (the workspace `lib.sh` exports), and only without
either to the cwd-relative `.mam/jobs`; the audit log is the
`delegate_job_logs` next to the registry in use unless `DELEGATE_JOB_LOGS_DIR`
names another root (Issue #57, Issue #15 — this is what
stops a caller whose cwd is not the workspace, such as the pytest suite, from
registering jobs into a foreign registry).
@@ -87,11 +87,12 @@ _abs_registry_dir() {
export SKS_REGISTRY_DIR="$REGISTRY_DIR"
}
# The audit-log root this wrapper's own subscriber resolves (same resolver),
# pinned into the injected commands so an agent publishing from another cwd
# does not fall back to <cwd>/.mam/delegate_job_logs.
# The audit-log root this wrapper's own subscriber resolves (same resolver:
# next to $REGISTRY_DIR unless DELEGATE_JOB_LOGS_DIR says otherwise, Issue
# #57), pinned into the injected commands so an agent publishing from another
# cwd does not fall back to its own default.
_logs_dir_abs() {
"$PY" -c 'import os, sys; sys.path.insert(0, sys.argv[1]); from lib_py.delegation.mqtt_common import get_logs_dir; print(os.path.abspath(get_logs_dir()))' "$SCRIPT_DIR/.."
"$PY" -c 'import os, sys; sys.path.insert(0, sys.argv[1]); from lib_py.delegation.logroot import get_logs_dir; print(os.path.abspath(get_logs_dir(sys.argv[2])))' "$SCRIPT_DIR/.." "$REGISTRY_DIR"
}
# Issue #17 item 4: the wrapper's decisions are lib_py/delegation/wrapper.py;
@@ -184,7 +185,7 @@ multi-agent-mux-delegate-job <command> [options]
list [--registry-dir <dir>]
verify --job <id> --validate <script> [--registry-dir <dir>]
wait [--job <id>] [--timeout <sec>] [--registry-dir <dir>]
logs <job_id> | --list # persistent audit log (delegate_job_logs/)
logs <job_id> | --list [--registry-dir <dir>] # audit log next to the registry (or $DELEGATE_JOB_LOGS_DIR)
EOF
}
@@ -199,6 +200,7 @@ TIMEOUT=$(mam_int_knob MAM_JOB_TIMEOUT_S 3600 1)
# Issue #18 liveness contract (N / K / T). An unset --idle-timeout derives K x N.
HEARTBEAT_SEC=$(mam_int_knob MAM_HEARTBEAT_SEC 30 1); HEARTBEAT_MISSES=$(mam_int_knob MAM_HEARTBEAT_MISSES 4 1); ACK_TIMEOUT=$(mam_int_knob MAM_ACK_TIMEOUT_S 180 1)
JOB_ID=""; REGISTRY_DIR="$REGISTRY_DIR_DEFAULT"; DELEGATE_ROLE="Worker"
LOGS_DIR_ABS=""
TYPE="direct"; REVIEWER="hermes"; REVIEWER_SESSION="herdr:hermes"; MAX_ITERATIONS=5
DEFAULT_COUNTERPART_ROLE="Reviewer"
COUNTERPART_ROLE="$DEFAULT_COUNTERPART_ROLE"
@@ -276,7 +278,10 @@ cmd_submit() {
[[ "$PY" == */* && "$PY" != /* ]] && PY="$(pwd)/$PY"
cd "$WORKDIR"
_abs_registry_dir
LOGS_DIR_ABS="$(_logs_dir_abs 2>/dev/null || true)"
LOGS_DIR_ABS="$(_logs_dir_abs)" || { echo "ERROR: cannot resolve the audit-log root for $REGISTRY_DIR" >&2; exit 1; }
# Issue #57: every child (subscriber, registry.py, validate scripts) sees
# the same root this wrapper prints and pins into the injected commands.
export DELEGATE_JOB_LOGS_DIR="$LOGS_DIR_ABS"
# Issue #34: an explicit --parent-job fails closed in the registry; the
# $MAM_JOB_ID default may name a job of another host's registry (a remote
@@ -345,16 +350,14 @@ EOF
if [[ "$DRY_RUN" == "1" ]]; then
run_agent "$JOB_ID" "$instructions"
local logs_root_dry="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root_dry/$JOB_ID"
echo "$LOGS_DIR_ABS/$JOB_ID"
return 0
fi
# 1) Preflight (a remote session has no local pane to check: Issue #18)
if ! _is_remote "$AGENT_SESSION" && ! preflight_agent "$JOB_ID" "$AGENT_SESSION" 0; then
finalize_unfinished "$JOB_ID" "error" "$SUB_RC_ERROR" "preflight"
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
echo "$LOGS_DIR_ABS/$JOB_ID"
exit 5
fi
@@ -368,8 +371,7 @@ EOF
export _SKS_IDLE_RC="$idle_rc"
if [ $idle_rc -eq 1 ] || [ $idle_rc -eq 2 ]; then
finalize_unfinished "$JOB_ID" "error" "$idle_rc" "idle_wait"
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
echo "$LOGS_DIR_ABS/$JOB_ID"
exit 5
fi
fi
@@ -409,8 +411,7 @@ EOF
_decided mapped_st sub-status "$sub_exit" --job "$JOB_ID" --registry-dir "$REGISTRY_DIR"
finalize_unfinished "$JOB_ID" "$mapped_st" "$sub_exit" "subscriber"
mapped_st="${FINAL_JOB_STATUS:-$mapped_st}"
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
echo "$LOGS_DIR_ABS/$JOB_ID"
_exit_for "$mapped_st"
fi
fi
@@ -424,8 +425,7 @@ EOF
if ! deliver_prompt "$JOB_ID" "$instructions" "$AGENT_SESSION" "$logf"; then
reap_subscriber "$sub_pid"
finalize_unfinished "$JOB_ID" "error" "$SUB_RC_ERROR" "inject"
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
echo "$LOGS_DIR_ABS/$JOB_ID"
exit 1
fi
start_stall_watch "$AGENT_SESSION" "$AGENT" "$_disp_tok" "$_attempt_id"
@@ -454,8 +454,7 @@ EOF
# Last stdout line: the persistent audit-log dir for this job (see SKILL.md
# "Audit Logs"). Callers can scrape `tail -n1` to find it.
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
echo "$LOGS_DIR_ABS/$JOB_ID"
_exit_for "$job_status"
else
# Implement loop/discuss orchestrator
@@ -470,8 +469,7 @@ EOF
if [[ "$DRY_RUN" == "1" ]]; then
echo "[dry-run] orchestrator loop would start for job: $JOB_ID type: $TYPE"
echo "worker session: $AGENT_SESSION, reviewer session: $REVIEWER_SESSION"
local logs_root_dry="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root_dry/$JOB_ID"
echo "$LOGS_DIR_ABS/$JOB_ID"
return 0
fi
@@ -517,8 +515,7 @@ EOF
# Preflight (skipped for a remote session: Issue #18)
if ! _is_remote "$current_session" && ! preflight_agent "$JOB_ID" "$current_session" "$force_warn_only"; then
finalize_unfinished "$JOB_ID" "error" "$SUB_RC_ERROR" "preflight"
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
echo "$LOGS_DIR_ABS/$JOB_ID"
exit 5
fi
@@ -550,8 +547,7 @@ EOF
export _SKS_IDLE_RC="$idle_rc"
if [ $idle_rc -eq 1 ] || [ $idle_rc -eq 2 ]; then
finalize_unfinished "$JOB_ID" "error" "$idle_rc" "idle_wait"
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
echo "$LOGS_DIR_ABS/$JOB_ID"
exit 5
fi
fi
@@ -587,8 +583,7 @@ EOF
_decided mapped_st sub-status "$sub_exit" --job "$JOB_ID" --registry-dir "$REGISTRY_DIR"
finalize_unfinished "$JOB_ID" "$mapped_st" "$sub_exit" "subscriber"
mapped_st="${FINAL_JOB_STATUS:-$mapped_st}"
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
echo "$LOGS_DIR_ABS/$JOB_ID"
_exit_for "$mapped_st"
fi
fi
@@ -609,8 +604,7 @@ EOF
if ! deliver_prompt "$JOB_ID" "$instructions" "$current_session" "$logf"; then
reap_subscriber "$sub_pid"
finalize_unfinished "$JOB_ID" "error" "$SUB_RC_ERROR" "inject"
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
echo "$LOGS_DIR_ABS/$JOB_ID"
exit 1
fi
start_stall_watch "$current_session" "$cur_agent" "$_disp_tok" "$attempt"
@@ -639,8 +633,7 @@ EOF
echo "Worker completed during grace period. Continuing to reviewer."
feedback="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" get-feedback --job "$JOB_ID")"
else
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
echo "$LOGS_DIR_ABS/$JOB_ID"
_exit_for "$job_status"
fi
fi
@@ -664,8 +657,7 @@ EOF
echo "Reviewer completed during grace period. Continuing evaluation."
feedback="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" get-feedback --job "$JOB_ID")"
else
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
echo "$LOGS_DIR_ABS/$JOB_ID"
_exit_for "$job_status"
fi
fi
@@ -720,8 +712,7 @@ EOF
fi
# Last stdout line: the persistent audit-log dir
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
echo "$LOGS_DIR_ABS/$JOB_ID"
exit "$final_loop_rc"
fi
}
@@ -868,14 +859,12 @@ _registry_wait() {
clear) return 0 ;;
superseded)
echo "ERROR: job $job_id was redispatched, moved or closed by someone else while it waited for session '$sess'; this attempt is not dispatched and the record is left as it is" >&2
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$job_id"
echo "$LOGS_DIR_ABS/$job_id"
exit 5 ;;
busy*)
echo "ERROR: session '$sess' still has open jobs in the registry (${res#busy }); job $job_id is not dispatched" >&2
finalize_unfinished "$job_id" "error" "$SUB_RC_ERROR" "registry_wait" "$sess"
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$job_id"
echo "$LOGS_DIR_ABS/$job_id"
exit 5 ;;
esac
_decision_failed wait-open-jobs "answer '$res'"
@@ -1099,16 +1088,29 @@ cmd_verify() {
}
cmd_logs() {
# logs <job_id> | logs --list — delegates to registry.py's logs CLI, which
# reads the persistent audit log under $DELEGATE_JOB_LOGS_DIR (or
# <cwd>/delegate_job_logs). Run from your project dir so the default resolves.
# logs <job_id> | logs --list [--registry-dir <dir>] — delegates to
# registry.py's logs CLI, which reads the audit log next to the registry
# (Issue #57; $DELEGATE_JOB_LOGS_DIR overrides). parse_opts is for submit's
# globals, so the few options are read here.
PY="$(pick_python)"
if [[ "${1:-}" == "--list" ]]; then
"$PY" "$SCRIPT_DIR/scripts/registry.py" logs --list
# --registry-dir is passed on only when given: registry.py's own default
# follows MAM_JOBS_DIR, then WORKSPACE_ROOT, so the usual run works from any
# cwd (the wrapper's REGISTRY_DIR_DEFAULT is cwd-relative). bash 3.2 + set -u:
# an empty array expands through ${reg[@]+"${reg[@]}"}.
local -a reg=()
local jid="" list=0
while [[ $# -gt 0 ]]; do
case "$1" in
--registry-dir) reg=(--registry-dir "$2"); shift 2;;
--list) list=1; shift;;
*) jid="$1"; shift;;
esac
done
if [[ "$list" == "1" ]]; then
"$PY" "$SCRIPT_DIR/scripts/registry.py" ${reg[@]+"${reg[@]}"} logs --list
else
local jid="${1:-}"
[[ -n "$jid" ]] || { echo "logs requires <job_id> or --list" >&2; exit 1; }
"$PY" "$SCRIPT_DIR/scripts/registry.py" logs "$jid"
"$PY" "$SCRIPT_DIR/scripts/registry.py" ${reg[@]+"${reg[@]}"} logs "$jid"
fi
}
@@ -150,7 +150,8 @@ that session.
Separate from the registry, every job is also mirrored to a durable append-only
audit log at `.mam/delegate_job_logs/<job_id>/` (override with
`DELEGATE_JOB_LOGS_DIR`, default `<cwd>/.mam/delegate_job_logs`). The registry
`DELEGATE_JOB_LOGS_DIR`, an audit-log root that is **not** next to the
registry; default: the `delegate_job_logs` next to the registry in use). The registry
is **live state** mutated in place; the audit log is **history** that survives
even after the registry dir is cleaned up. It is git-ignored.
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-loop
description: "Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. Automatically orchestrates plan discussion, code changes, and peer reviews until a unanimous PASS is achieved or the maximum iteration limit is reached."
version: 4.9.3
version: 4.9.4
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-monitor
description: "Run a long-lived reconciler that watches .mam/agent-sessions.yaml against the actual herdr/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Runs as a persistent loop (`reconcile.sh --subscribe`) that keeps going until it times out, idles out, or is interrupted."
version: 4.9.3
version: 4.9.4
author: godopu
license: MIT
platforms: [linux, macos]
@@ -272,7 +272,7 @@ def on_message(_client, _userdata, msg):
"topic": msg.topic,
"timestamp": payload.get("timestamp"),
"detail": payload.get("detail", ""),
})
}, logs_dir=mqtt_common.get_logs_dir(registry_dir))
print(f"MQTT Monitor: recorded event {event} for job {jid} (seq={seq})", flush=True)
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-orc-onboard
description: "Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture."
version: 4.9.3
version: 4.9.4
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-resume
description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose herdr died but the agent's conversation is still on disk."
version: 4.9.3
version: 4.9.4
author: godopu
license: MIT
platforms: [linux, macos]
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-status
description: "Read-only instant snapshot of all agent herdr sessions — name, YAML status, herdr alive, pane cmd/cwd, resume UUID on disk, and any drift. No mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up the monitor loop."
version: 4.9.3
version: 4.9.4
author: godopu
license: MIT
platforms: [linux, macos]
@@ -167,6 +167,10 @@ def get_job_status(s):
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]
# Issue #57: the audit log lives next to the registry unless moved explicitly
from lib_py.delegation.logroot import sibling_logs_dir
candidates += [os.path.join(sibling_logs_dir(r), jid, 'status.json')
for r in (os.environ.get('MAM_JOBS_DIR'), cp.get('mam_jobs_dir')) if r]
candidates += [
os.path.join('.mam', 'jobs', f"{jid}.json"),
os.path.join(project_root, '.mam', 'jobs', f"{jid}.json"),
@@ -375,6 +379,10 @@ def get_job_status(s):
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]
# Issue #57: the audit log lives next to the registry unless moved explicitly
from lib_py.delegation.logroot import sibling_logs_dir
candidates += [os.path.join(sibling_logs_dir(r), jid, 'status.json')
for r in (os.environ.get('MAM_JOBS_DIR'), cp.get('mam_jobs_dir')) if r]
candidates += [
os.path.join('.mam', 'jobs', f"{jid}.json"),
os.path.join(project_root, '.mam', 'jobs', f"{jid}.json"),
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: multi-agent-mux-stop
description: "Stop an agent herdr session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start."
version: 4.9.3
version: 4.9.4
author: godopu
license: MIT
platforms: [linux, macos]
+1 -1
View File
@@ -2,7 +2,7 @@
본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다.
[![Version: v4.9.3](https://img.shields.io/badge/version-v4.9.3-blue.svg)](VERSIONS.md)
[![Version: v4.9.4](https://img.shields.io/badge/version-v4.9.4-blue.svg)](VERSIONS.md)
새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다.
+1 -1
View File
@@ -2,7 +2,7 @@
This document guides you through the setup and initialization procedures required to adopt the `herdr_agent_orchestration` orchestration and messaging backplane workflow in a new project, enabling a new developer or agent to get up and running quickly.
[![Version: v4.9.3](https://img.shields.io/badge/version-v4.9.3-blue.svg)](VERSIONS.md)
[![Version: v4.9.4](https://img.shields.io/badge/version-v4.9.4-blue.svg)](VERSIONS.md)
A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment.
+1 -1
View File
@@ -1,6 +1,6 @@
# Messaging System Technical Analysis & Architecture Report
Applies to: MAM v4.9.3
Applies to: MAM v4.9.4
This report provides a comprehensive, deep-dive analysis of the messaging system implemented in the `multi-agent-mux-delegate-job` skill. It covers the MQTT broker architecture, event protocols, job lifecycles, codebase internals, cross-system integration, and a list of known limitations along with production recommendations.
+1 -1
View File
@@ -2,7 +2,7 @@
[herdr](https://herdr.dev)와 MQTT 위에 만든 **다중 에이전트 오케스트레이션·메시징 백플레인**입니다. 장시간 실행되는 코딩 에이전트(`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`)를 격리된 herdr pane 에서 돌리고, 작업을 추적되는 잡으로 위임하며, 모든 리뷰어가 통과시킬 때까지 planner / creator / reviewer 루프를 돌립니다.
[![Version: v4.9.3](https://img.shields.io/badge/version-v4.9.3-blue.svg)](VERSIONS.md)
[![Version: v4.9.4](https://img.shields.io/badge/version-v4.9.4-blue.svg)](VERSIONS.md)
---
+1 -1
View File
@@ -2,7 +2,7 @@
A **Multi-Agent Orchestration & Messaging Backplane** built on [herdr](https://herdr.dev) and MQTT. It runs long-lived coding agents (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`) in isolated herdr panes, delegates work to them as tracked jobs, and drives planner / creator / reviewer loops until every reviewer passes the change.
[![Version: v4.9.3](https://img.shields.io/badge/version-v4.9.3-blue.svg)](VERSIONS.md)
[![Version: v4.9.4](https://img.shields.io/badge/version-v4.9.4-blue.svg)](VERSIONS.md)
---
+27 -11
View File
@@ -6,8 +6,8 @@
## 📌 현재 버전 개요 (Current Release)
- **프레임워크 버전**: `v4.9.3`
- **최신 릴리스 일시**: 2026-10-13 (KST)
- **프레임워크 버전**: `v4.9.4`
- **최신 릴리스 일시**: 2026-10-15 (KST)
- **기준 브랜치**: `main`
- **핵심 아키텍처**:
- **Terminal 상태 단방향 불변성 (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` 터미널 상태 진입 후 후속 이벤트나 업데이트에 의한 덮어쓰기를 엄격히 차단(Terminal Immutability). `registry.py close` 서브커맨드 및 fail-closed 조기 종료 도입.
@@ -24,23 +24,39 @@
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.9.3`로 동기화되어 배포됩니다.
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.9.4`로 동기화되어 배포됩니다.
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
| :--- | :---: | :--- | :---: |
| **`multi-agent-mux-create`** | `4.9.3` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
| **`multi-agent-mux-stop`** | `4.9.3` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
| **`multi-agent-mux-resume`** | `4.9.3` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
| **`multi-agent-mux-status`** | `4.9.3` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
| **`multi-agent-mux-monitor`** | `4.9.3` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
| **`multi-agent-mux-delegate-job`** | `4.9.3` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
| **`multi-agent-mux-loop`** | `4.9.3` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
| **`multi-agent-mux-orc-onboard`** | `4.9.3` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
| **`multi-agent-mux-create`** | `4.9.4` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
| **`multi-agent-mux-stop`** | `4.9.4` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
| **`multi-agent-mux-resume`** | `4.9.4` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
| **`multi-agent-mux-status`** | `4.9.4` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
| **`multi-agent-mux-monitor`** | `4.9.4` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
| **`multi-agent-mux-delegate-job`** | `4.9.4` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
| **`multi-agent-mux-loop`** | `4.9.4` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
| **`multi-agent-mux-orc-onboard`** | `4.9.4` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
---
## 📋 버전별 상세 변경 내역 (Changelog)
### 🚀 `v4.9.4` — 감사 로그가 옮긴 레지스트리를 따라간다 (Issue #57, PR #58) (2026-10-15)
> **패치 (PATCH Release)**: `--registry-dir`·`MAM_JOBS_DIR` 로 레지스트리만 옮긴 실행이 실제 `.mam/delegate_job_logs` 에 레지스트리 없는 감사 로그를 남기던 결함. 레지스트리를 옮기지 않은 평소 실행의 경로는 바뀌지 않는다.
#### 🌟 주요 개선 사항
1. **형제 규칙 하나**: `DELEGATE_JOB_LOGS_DIR` 가 없으면 감사 로그 루트는 사용 중인 레지스트리(realpath)의 형제 `delegate_job_logs` 다. 규칙은 stdlib 전용 `lib_py/delegation/logroot.py` 한 곳에 있고(`mqtt_common.get_logs_dir(registry_dir)` 가 재노출), 감사 로그를 쓰고 읽는 모든 지점(register·pick·상태 미러·충돌 거부·publish_event·subscriber·remote·reconcile·루프 러너)이 레지스트리를 넘긴다.
2. **bash 기본값 제거**: 래퍼의 `${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}` 16 곳과 `lib.sh` 사본을 없앴다. 래퍼는 루트를 한 번 해석해 자식에게 export 하고(해석 실패 시 fail-closed), `logs` 는 `--registry-dir` 를 받았을 때만 넘긴다.
3. **세션 바인딩**: `control_plane.from_env()` 는 로그 루트를 `mam_jobs_dir` 에서 파생하고, `forwarded_keys()` 는 파생 가능한 형제 루트를 행에만 기록하고 pane 에 넘기지 않는다. `worktree_prepare.sh` 는 호출자가 준 경우만 넘긴다. `status.sh` 는 레지스트리 옆 감사 로그도 조회한다.
4. **디스크 폴백**: `job_subscriber._check_disk_fallback` 이 `status.json` 의 `status` 를 읽고(이전엔 dict 를 비교해 아무것도 맞지 않음), 버전 있는 레코드는 감사 `transition_seq` 가 같거나 새로울 때만, 그리고 채택 직전에 레코드를 다시 읽어 같은 dispatch 일 때만 감사 상태를 받아들인다.
5. **결정**: `submit --dry-run` 의 의미(등록 + 감사 로그 시드)는 유지, 쓰기 시 레지스트리 레코드 존재 가드는 두지 않는다(#20 가드가 실제 루트의 레지스트리 없는 감사 로그를 잡는다).
#### ✅ 품질 검증
- **회귀**: 새 `tests/test_issue57_logs_follow_registry.py`(19 tests)는 명시적 `DELEGATE_JOB_LOGS_DIR` 우선 불변식 하나를 빼고 수정 전 트리에서 실패하며, 모든 테스트가 워크스페이스의 `.mam/delegate_job_logs` 가 생기지 않았음을 단언한다. 기존 테스트 5개의 단언을 새 규칙에 맞췄다(PR #58 본문).
- **리뷰 (`--review-only`, `--all-reviewer`, 이 Mac 정합성 중심)**: 1회차(@63d8164) agy·grok PASS, claude·codex NOT PASS — paho 없는 `/usr/bin/python3` 폴백에서 바인딩·status 실패(F1), `logs` 기본 경로 변경(F2), 디스크 폴백의 재디스패치 interleaving(R1), 심볼릭 링크 레지스트리(R2). PR 댓글로 전달했고 작성자가 커밋 4개로 수정했다. 2회차(@a7cb90e) **4/4 PASS**(1회차 재현이 모두 해소). 리포트는 `.agents/reports/<session>/`.
- **전체 스위트 (macOS, Python 3.9.6, @a7cb90e)**: 1867 passed, 56 skipped(tomllib·서브모듈·Linux 전용), 1 error — #55 가드가 다른 워크스페이스에서 돌던 agy 의 대화 WAL/SHM 을 잡은 알려진 오탐. 실제 herdr 서버 pane 목록·`~/.claude/projects` 불변, 실제 감사 로그에는 리뷰 잡 것만 생겼다.
### 🚀 `v4.9.3` — u1e 테스트가 체크아웃의 `.mam/shim/herdr` 를 쓰지 않는다 (2026-10-13)
> **패치 (PATCH Release, 테스트 전용)**: v4.9.2 README 리뷰(claude 38a64423)가 재현한 결함. 제품 코드는 버전 줄 외에 바뀌지 않았다.
+4 -4
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,14 +123,14 @@ 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 로 대화 키 | — |
| status | `MAM_JOBS_DIR`·행 `mam_jobs_dir`·감사 로그(`DELEGATE_JOB_LOGS_DIR`·행 `delegate_job_logs_dir`)·레지스트리 옆 감사 로그(`MAM_JOBS_DIR`·행 `mam_jobs_dir` 의 형제 `delegate_job_logs`, #57)·기존 후보 순 조회 | 행 cwd 로 대화 키 | — |
| monitor/reconcile | `MAM_JOBS_DIR` 우선(정적 가드로만 검증) | 기존 행은 행 cwd 로 검증 | 루트 밖 cwd 의 미등록 세션은 자동 등록 안 됨 → create/prepare 로만 등록 (남은 제한) |
| 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
View File
@@ -57,6 +57,9 @@ def test_registry_dir_falls_back_to_cwd_relative(monkeypatch, tmp_path):
def test_logs_dir_follows_the_same_order(monkeypatch, tmp_path):
# Issue #57: the audit log follows the registry; conftest's MAM_JOBS_DIR
# would otherwise decide the fallback this test asserts.
monkeypatch.delenv("MAM_JOBS_DIR", raising=False)
monkeypatch.setenv("DELEGATE_JOB_LOGS_DIR", str(tmp_path / "logs"))
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
assert mqtt_common.get_logs_dir() == str(tmp_path / "logs")
+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)
+633
View File
@@ -0,0 +1,633 @@
#!/usr/bin/env python3
"""Issue #57 — the audit log follows the registry.
`--registry-dir` / `MAM_JOBS_DIR` moved the registry, but the audit log kept
its own root (`DELEGATE_JOB_LOGS_DIR`, else the workspace's), so an isolated
experiment left audit-only directories in the real control plane. The rule is
now one function, `mqtt_common.get_logs_dir(registry_dir)`: an explicit
`DELEGATE_JOB_LOGS_DIR` wins, otherwise the audit log is the
`delegate_job_logs` next to the registry in use. Every test ends by asserting
that the workspace's own `.mam/delegate_job_logs` was never created.
Comment 5 of the issue: `job_subscriber._check_disk_fallback` compared the
dict `read_logged_status()` returns with status strings, so an audit-only
terminal status was never recognised.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
import pytest
import _mam_state_guard # noqa: F401
REPO_ROOT = Path(__file__).resolve().parent.parent
SKILLS = REPO_ROOT / ".agents" / "skills"
WRAPPER = SKILLS / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
REGISTRY_PY = SKILLS / "multi-agent-mux-delegate-job" / "scripts" / "registry.py"
PUBLISH_PY = SKILLS / "multi-agent-mux-delegate-job" / "scripts" / "publish_event.py"
if str(SKILLS) not in sys.path:
sys.path.insert(0, str(SKILLS))
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")
@pytest.fixture
def moved(tmp_path, monkeypatch):
"""A workspace whose registry lives elsewhere, with neither override set:
conftest's autouse defaults would otherwise hide the fallback (comment 7)."""
for k in DROP:
monkeypatch.delenv(k, raising=False)
ws = tmp_path / "ws"
(ws / ".mam").mkdir(parents=True)
reg = tmp_path / "elsewhere" / "jobs"
monkeypatch.setenv("WORKSPACE_ROOT", str(ws))
monkeypatch.chdir(ws)
yield ws, reg, tmp_path / "elsewhere" / "delegate_job_logs"
assert not (ws / ".mam" / "delegate_job_logs").exists(), "the workspace's audit-log root was created"
def _env(ws: Path, **extra) -> dict:
env = {k: v for k, v in os.environ.items() if k not in DROP}
env.update(WORKSPACE_ROOT=str(ws), PYTHONPATH=str(SKILLS), DELEGATE_JOB_PYTHON=sys.executable,
AGENT_PYTHON_BIN=sys.executable, MAM_ENV_FILE=str(ws / ".mam.env.absent"), **UNREACHABLE)
env.update({k: str(v) for k, v in extra.items()})
return env
# ---------------------------------------------------------------------------
# comment 5: the disk fallback reads status.json's status, not the dict
# ---------------------------------------------------------------------------
def test_disk_fallback_reads_the_audit_only_terminal_status(moved):
ws, reg, logs = moved
reg.mkdir(parents=True)
# no registry record at all: only the audit log says the job ended
jid = "a57fb001"
mqtt_common.update_logged_status(jid, "completed", logs_dir=mqtt_common.get_logs_dir(str(reg)))
pending, terminal = {jid}, {}
job_subscriber._check_disk_fallback(pending, str(reg), terminal, None, None)
assert terminal == {jid: "completed"}
assert pending == set()
# a pending record with a dispatch token, an audit-only `error`
tok = "0123456789abcdef0123456789abcdef"
jid2 = registry.register_job("fb2", registry_dir=str(reg), agent_session="herdr:t57", dispatch_token=tok)
mqtt_common.update_logged_status(jid2, "error", logs_dir=mqtt_common.get_logs_dir(str(reg)))
pending, terminal = {jid2}, {}
job_subscriber._check_disk_fallback(pending, str(reg), terminal, {jid2: tok}, None)
assert terminal == {jid2: "error"} and pending == set()
# a different expected token still ignores it (#14/#18 rule, unchanged)
pending, terminal = {jid2}, {}
job_subscriber._check_disk_fallback(pending, str(reg), terminal, {jid2: "f" * 32}, None)
assert terminal == {} and pending == {jid2}
# ---------------------------------------------------------------------------
# L1 — the resolver
# ---------------------------------------------------------------------------
def test_resolver_pairs_the_logs_with_the_registry(moved, monkeypatch):
ws, reg, logs = moved
# the usual roots are unchanged
assert mqtt_common.get_logs_dir() == str(ws / ".mam" / "delegate_job_logs")
assert mqtt_common.get_logs_dir(str(ws / ".mam" / "jobs")) == str(ws / ".mam" / "delegate_job_logs")
# a registry the caller names
assert mqtt_common.get_logs_dir(str(reg)) == str(logs)
# a registry moved by MAM_JOBS_DIR, and the compat alias
monkeypatch.setenv("MAM_JOBS_DIR", str(reg))
assert mqtt_common.get_logs_dir() == str(logs)
assert mqtt_common.LOGS_DIR == str(logs)
# an explicit root still wins
monkeypatch.setenv("DELEGATE_JOB_LOGS_DIR", str(ws / "x"))
assert mqtt_common.get_logs_dir() == str(ws / "x")
assert mqtt_common.get_logs_dir(str(reg)) == str(ws / "x")
monkeypatch.delenv("DELEGATE_JOB_LOGS_DIR")
# a relative registry resolves against the cwd, as registry.py opens it
assert mqtt_common.get_logs_dir("r/jobs") == os.path.join(os.getcwd(), "r", "delegate_job_logs")
# the sibling rule itself reads no environment
monkeypatch.setenv("DELEGATE_JOB_LOGS_DIR", str(ws / "x"))
assert mqtt_common.sibling_logs_dir(str(reg)) == str(logs)
# ---------------------------------------------------------------------------
# L2 — registry.py writes and reads next to --registry-dir
# ---------------------------------------------------------------------------
def _registry(ws: Path, reg: Path, *args) -> subprocess.CompletedProcess:
return subprocess.run([sys.executable, str(REGISTRY_PY), "--registry-dir", str(reg), *args],
capture_output=True, text=True, env=_env(ws), cwd=str(ws), timeout=60)
def test_registry_cli_writes_and_reads_next_to_the_registry(moved):
ws, reg, logs = moved
res = _registry(ws, reg, "register", "--prompt", "p57", "--agent", "human", "--agent-session", "herdr:t57")
assert res.returncode == 0, res.stderr
jid = res.stdout.strip()
assert (logs / jid / "meta.json").is_file() and (logs / jid / "status.json").is_file()
assert _registry(ws, reg, "pick", "--agent-session", "herdr:t57").stdout.strip() == jid
assert _registry(ws, reg, "update", "--job", jid, "--status", "completed").returncode == 0
assert json.loads((logs / jid / "status.json").read_text())["status"] == "completed"
assert _registry(ws, reg, "get-feedback", "--job", jid).returncode == 0
res = _registry(ws, reg, "logs", jid)
assert res.returncode == 0, res.stderr
assert "registered" in res.stdout and "status_changed" in res.stdout
res = _registry(ws, reg, "logs", "--list")
assert res.returncode == 0 and jid in res.stdout, res.stdout + res.stderr
events = [json.loads(l) for l in (logs / jid / "events.ndjson").read_text().splitlines() if l.strip()]
assert [e["event"] for e in events][:2] == ["registered", "status_changed"]
# ---------------------------------------------------------------------------
# L3 — the wrapper's last line names the directory it wrote
# ---------------------------------------------------------------------------
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(cwd or ws), timeout=120)
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 test_wrapper_output_names_the_dir_it_wrote(moved):
ws, reg, logs = moved
real_logs = Path(os.path.realpath(logs.parent)) / "delegate_job_logs"
# (a) dry-run: registered and seeded, both next to the registry
res = _wrapper(ws, "submit", "--dry-run", "--agent", "human", "--registry-dir", str(reg),
"--workdir", str(ws), "--prompt", "p57")
assert res.returncode == 0, res.stdout[-800:] + res.stderr[-800:]
jid = _only_job_id(reg)
assert res.stdout.strip().splitlines()[-1] == str(real_logs / jid)
assert (logs / jid / "meta.json").is_file()
# (b) a real human job against an unreachable broker: rc 3, same rule
res = _wrapper(ws, "submit", "--agent", "human", "--registry-dir", str(reg), "--workdir", str(ws),
"--prompt", "p57b", "--timeout", "3", "--idle-timeout", "2")
assert res.returncode == 3, res.stdout[-800:] + res.stderr[-800:]
jid2 = [p.stem for p in reg.glob("*.json") if p.stem != jid]
assert len(jid2) == 1, jid2
jid2 = jid2[0]
assert res.stdout.strip().splitlines()[-1] == str(real_logs / jid2)
events = [json.loads(l) for l in (logs / jid2 / "events.ndjson").read_text().splitlines() if l.strip()]
assert "published" in {e.get("event") for e in events}, events
# (c) wait and (d) logs find the same registry and its audit log
res = _wrapper(ws, "wait", "--registry-dir", str(reg), "--job", str(jid2), "--timeout", "2")
assert res.returncode in (0, 1, 2, 3), res.stdout[-400:] + res.stderr[-400:]
res = _wrapper(ws, "logs", "--registry-dir", str(reg), jid2)
assert res.returncode == 0 and "registered" in res.stdout, res.stdout + res.stderr
res = _wrapper(ws, "logs", "--registry-dir", str(reg), "--list")
assert res.returncode == 0 and jid2 in res.stdout, res.stdout + res.stderr
# ---------------------------------------------------------------------------
# L4 — publish_event.py records next to --registry-dir
# ---------------------------------------------------------------------------
def test_publish_event_records_next_to_the_registry(moved):
ws, reg, logs = moved
jid = registry.register_job("p4", registry_dir=str(reg), agent_session="herdr:t57")
(logs / jid / "events.ndjson").unlink() # only what publish_event writes counts
def publish(*args):
return subprocess.run([sys.executable, str(PUBLISH_PY), "--registry-dir", str(reg), "--job", jid,
"--attempts", "1", *args],
capture_output=True, text=True, env=_env(ws), cwd=str(ws), timeout=60)
publish("--event", "progress", "--detail", "x")
events = [json.loads(l) for l in (logs / jid / "events.ndjson").read_text().splitlines() if l.strip()]
assert [e for e in events if e.get("event") == "published" and e.get("source_event") == "progress"], events
publish("--event", "completed", "--detail", "done")
assert json.loads((logs / jid / "status.json").read_text())["status"] == "completed"
# ---------------------------------------------------------------------------
# the session binding derives the audit-log root from the registry
# ---------------------------------------------------------------------------
def test_from_env_derives_the_logs_dir_from_a_moved_registry(moved):
ws, reg, logs = moved
b = control_plane.from_env({"WORKSPACE_ROOT": str(ws), "MAM_JOBS_DIR": str(reg)})
assert b["mam_jobs_dir"] == os.path.realpath(reg)
assert b["delegate_job_logs_dir"] == os.path.realpath(logs)
# an explicit root is kept as it is
b = control_plane.from_env({"WORKSPACE_ROOT": str(ws), "MAM_JOBS_DIR": str(reg),
"DELEGATE_JOB_LOGS_DIR": str(ws / "custom-logs")})
assert b["delegate_job_logs_dir"] == os.path.realpath(ws / "custom-logs")
# the usual binding is unchanged
assert control_plane.from_env({"WORKSPACE_ROOT": str(ws)}) == control_plane.defaults(str(ws))
# and the CLI lib.sh calls agrees
res = subprocess.run([sys.executable, "-m", "lib_py.control_plane", "json", str(ws / "wt")],
capture_output=True, text=True, env=_env(ws, MAM_JOBS_DIR=str(reg)), cwd=str(ws), timeout=60)
assert res.returncode == 0, res.stderr
assert json.loads(res.stdout)["delegate_job_logs_dir"] == os.path.realpath(logs)
def test_an_explicit_logs_dir_still_wins_over_a_moved_registry(tmp_path, monkeypatch):
"""Comment 1: DELEGATE_JOB_LOGS_DIR means the audit log was moved to a place
that cannot be derived; it wins over --registry-dir as before."""
for k in DROP:
monkeypatch.delenv(k, raising=False)
main, custom, exp = tmp_path / "main", tmp_path / "custom-logs", tmp_path / "exp" / "jobs"
(main / ".mam").mkdir(parents=True)
monkeypatch.setenv("WORKSPACE_ROOT", str(main))
monkeypatch.chdir(main)
env = dict(MAM_JOBS_DIR=str(main / ".mam" / "jobs"), DELEGATE_JOB_LOGS_DIR=str(custom))
res = _wrapper(main, "submit", "--dry-run", "--agent", "human", "--registry-dir", str(exp),
"--workdir", str(main), "--prompt", "p57", **env)
assert res.returncode == 0, res.stdout[-800:] + res.stderr[-800:]
jid = _only_job_id(exp)
assert res.stdout.strip().splitlines()[-1] == str(custom / jid)
assert (custom / jid / "meta.json").is_file()
assert not (tmp_path / "exp" / "delegate_job_logs").exists()
res = subprocess.run([sys.executable, str(PUBLISH_PY), "--registry-dir", str(exp), "--job", jid,
"--attempts", "1", "--event", "progress", "--detail", "x"],
capture_output=True, text=True, env=_env(main, **env), cwd=str(main), timeout=60)
events = [json.loads(l) for l in (custom / jid / "events.ndjson").read_text().splitlines() if l.strip()]
assert [e for e in events if e.get("event") == "published"], events
assert not (tmp_path / "exp" / "delegate_job_logs").exists()
assert not (main / ".mam" / "delegate_job_logs").exists()
# ---------------------------------------------------------------------------
# worktree_prepare keeps the creator's audit log next to the control root's registry
# ---------------------------------------------------------------------------
def test_worktree_prepare_keeps_the_logs_next_to_the_control_root_registry(ctl, mock_herdr, mock_agents, monkeypatch):
main = ctl
reg = main / "elsewhere" / "jobs"
monkeypatch.setenv("MAM_JOBS_DIR", str(reg))
monkeypatch.delenv("DELEGATE_JOB_LOGS_DIR", raising=False)
wt = _wt(main)
res = _prepare(main, wt, "--creator-agent", "claude", "--onboard")
assert res.returncode == 0, res.stdout + res.stderr
(row,) = _rows(main)
assert row["control_plane"]["mam_jobs_dir"] == os.path.realpath(reg)
assert row["control_plane"]["delegate_job_logs_dir"] == os.path.realpath(main / "elsewhere" / "delegate_job_logs")
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
# ---------------------------------------------------------------------------
# status.sh reads the audit log next to a moved registry
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("mode", ["json", "text"])
def test_status_reads_the_audit_log_next_to_a_moved_registry(mam_sandbox, monkeypatch, mode):
tmp = mam_sandbox
reg, logs = tmp / "elsewhere" / "jobs", tmp / "elsewhere" / "delegate_job_logs"
jid = "s57aa001"
(logs / jid).mkdir(parents=True)
(logs / jid / "status.json").write_text('{"job_id": "s57aa001", "status": "completed"}')
reg.mkdir(parents=True) # the registry exists, but the record was cleaned up
_mutate(tmp, f"""
d['herdr_sessions'] = [{{'name': 's57-creator-claude', 'status': 'running', 'delegate_job_id': {jid!r},
'pane': {{'cwd': {str(tmp)!r}}}}}]
""")
monkeypatch.setenv("MAM_JOBS_DIR", str(reg))
monkeypatch.delenv("DELEGATE_JOB_LOGS_DIR", raising=False)
args = ["--json"] if mode == "json" else []
res = subprocess.run(["bash", _skill(tmp, "multi-agent-mux-status", "scripts", "status.sh"), *args],
capture_output=True, text=True, timeout=120)
assert res.returncode == 0, res.stderr
if mode == "json":
assert json.loads(res.stdout)["sessions_detail"][0]["job_status"] == "completed"
else:
(line,) = [l for l in res.stdout.splitlines() if l.startswith("s57-creator-claude")]
assert jid in line and "completed" in line, line
assert not (tmp / ".mam" / "delegate_job_logs").exists()
# ---------------------------------------------------------------------------
# review (codex P2): conflict refusals and legacy claims follow the registry too
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("path", ["conflict", "legacy-claim"])
def test_conflict_refusals_and_legacy_claims_follow_the_registry(moved, monkeypatch, path):
ws, reg, logs = moved
jid = registry.register_job("p2", registry_dir=str(reg), agent_session="herdr:t57")
if path == "conflict":
assert engine.transition(jid, "completed", registry_dir=str(reg), source="operator").committed
assert not engine.transition(jid, "error", registry_dir=str(reg), source="late").committed
expected = "conflict_refused"
else:
monkeypatch.setenv("MAM_ENGINE_CAS", "0")
assert registry.pick_pending("herdr:t57", registry_dir=str(reg)) == jid
expected = "status_changed"
assert expected in [e.get("event") for e in mqtt_common.iter_logged_events(jid, str(logs))]
# ---------------------------------------------------------------------------
# review (codex P1): an old audit terminal must not close a redispatched attempt
# ---------------------------------------------------------------------------
def test_old_audit_terminal_does_not_complete_the_redispatched_attempt(moved, monkeypatch):
ws, reg, logs = moved
jid = registry.register_job("p1", registry_dir=str(reg), agent_session="herdr:t57")
assert engine.transition(jid, "completed", registry_dir=str(reg), source="operator").committed
old_audit = mqtt_common.read_logged_status(jid, str(logs))
flush, observed = engine._flush, {}
def before_the_mirror(notes, registry_dir):
# the real gap: attempt 2 is committed, the audit still says attempt 1 completed
rec = mqtt_common.load_job(jid, registry_dir)
assert rec["status"] == "pending" and rec["attempt_id"] == 2
assert old_audit["transition_seq"] < rec["transition_seq"]
pending, terminal = {jid}, {}
job_subscriber._check_disk_fallback(pending, registry_dir, terminal, {jid: rec["dispatch_token"]}, {jid: 2})
observed.update(pending=pending, terminal=terminal)
flush(notes, registry_dir)
monkeypatch.setattr(engine, "_flush", before_the_mirror)
assert engine.transition(jid, "redispatch", registry_dir=str(reg), source="operator",
fields={"attempt_id": 2}).committed
assert observed == {"pending": {jid}, "terminal": {}}, "the old completion was attributed to attempt 2"
rec = mqtt_common.load_job(jid, str(reg))
# a failed mirror leaves the old status.json behind (update_logged_status
# itself refuses to go backwards, so the file is written directly): still ignored
mqtt_common.job_log_path(jid, mqtt_common.STATUS_FILENAME, str(logs)).write_text(json.dumps(old_audit))
assert mqtt_common.read_logged_status(jid, str(logs))["status"] == "completed"
pending, terminal = {jid}, {}
job_subscriber._check_disk_fallback(pending, str(reg), terminal, {jid: rec["dispatch_token"]}, {jid: 2})
assert pending == {jid} and terminal == {}
# an audit status without a sequence cannot prove it is about this attempt
mqtt_common.update_logged_status(jid, "completed", logs_dir=str(logs))
pending, terminal = {jid}, {}
job_subscriber._check_disk_fallback(pending, str(reg), terminal, {jid: rec["dispatch_token"]}, {jid: 2})
assert pending == {jid} and terminal == {}
# a damaged sequence is ignored too, and does not take the subscriber down
mqtt_common.job_log_path(jid, mqtt_common.STATUS_FILENAME, str(logs)).write_text(
json.dumps({"job_id": jid, "status": "completed", "transition_seq": "n/a"}))
pending, terminal = {jid}, {}
job_subscriber._check_disk_fallback(pending, str(reg), terminal, {jid: rec["dispatch_token"]}, {jid: 2})
assert pending == {jid} and terminal == {}
# a newer audit-only terminal is still taken
mqtt_common.update_logged_status(jid, "completed", logs_dir=str(logs), transition_seq=rec["transition_seq"] + 1)
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()
# ---------------------------------------------------------------------------
# Mac review F1: the path helpers must not need paho
# ---------------------------------------------------------------------------
@pytest.fixture
def nopaho(tmp_path):
"""A `paho` package that fails to import, first on PYTHONPATH: the real
import path then behaves like an interpreter without paho-mqtt."""
d = tmp_path / "nopaho"
(d / "paho").mkdir(parents=True)
(d / "paho" / "__init__.py").write_text("raise ModuleNotFoundError(\"No module named 'paho'\")\n")
probe = subprocess.run([sys.executable, "-c", "import paho.mqtt.client"], capture_output=True, text=True,
env={**os.environ, "PYTHONPATH": f"{d}:{SKILLS}"}, timeout=60)
assert probe.returncode != 0 and "No module named 'paho'" in probe.stderr, probe.stderr
return d
def test_control_plane_binding_works_without_paho(moved, nopaho):
"""_delegate_py_bin's last fallback is the PATH's python3, which may lack
paho: create/resume's binding and lib.sh's publish-command prefix must still
resolve the audit-log root."""
ws, reg, logs = moved
env = _env(ws, MAM_JOBS_DIR=str(reg), PYTHONPATH=f"{nopaho}:{SKILLS}", AGENT_PYTHON_BIN=sys.executable)
res = subprocess.run([sys.executable, "-m", "lib_py.control_plane", "json", str(ws / "wt")],
capture_output=True, text=True, env=env, cwd=str(ws), timeout=60)
assert res.returncode == 0, res.stderr
desc = json.loads(res.stdout)
assert desc["delegate_job_logs_dir"] == os.path.realpath(logs)
res = subprocess.run([sys.executable, "-m", "lib_py.control_plane", "prefix", json.dumps(desc)],
capture_output=True, text=True, env=env, cwd=str(ws), timeout=60)
assert res.returncode == 0 and "MAM_JOBS_DIR=" in res.stdout and "DELEGATE_JOB_LOGS_DIR=" not in res.stdout, res.stdout
# lib.sh's injected publish command still pins the root (it resolved it with the same interpreter)
res = subprocess.run(["bash", "-c", f"source '{SKILLS}/lib.sh' && delegate_publish_cmd j57"],
capture_output=True, text=True, env=env, cwd=str(ws), timeout=60)
assert res.returncode == 0, res.stderr
assert res.stdout.startswith(f"DELEGATE_JOB_LOGS_DIR={os.path.realpath(logs)} "), res.stdout + res.stderr
@pytest.mark.parametrize("mode", ["json", "text"])
def test_status_works_without_paho(mam_sandbox, nopaho, monkeypatch, mode):
tmp = mam_sandbox
reg = tmp / ".mam" / "jobs"
jid = registry.register_job("s57 nopaho", registry_dir=str(reg), agent_session="herdr:s57-nopaho")
registry.update_status(jid, str(reg), "completed")
_mutate(tmp, f"""
d['herdr_sessions'] = [{{'name': 's57-creator-claude', 'status': 'running', 'delegate_job_id': {jid!r},
'pane': {{'cwd': {str(tmp)!r}}}}}]
""")
monkeypatch.setenv("PYTHONPATH", str(nopaho))
monkeypatch.setenv("AGENT_PYTHON_BIN", sys.executable)
args = ["--json"] if mode == "json" else []
res = subprocess.run(["bash", _skill(tmp, "multi-agent-mux-status", "scripts", "status.sh"), *args],
capture_output=True, text=True, timeout=120)
assert res.returncode == 0, res.stdout[-500:] + res.stderr[-800:]
if mode == "json":
assert json.loads(res.stdout)["sessions_detail"][0]["job_status"] == "completed"
else:
(line,) = [l for l in res.stdout.splitlines() if l.startswith("s57-creator-claude")]
assert "completed" in line, line
# ---------------------------------------------------------------------------
# Mac review F2: `logs` without --registry-dir follows the workspace, not the cwd
# ---------------------------------------------------------------------------
def test_logs_without_registry_dir_follows_the_workspace_from_any_cwd(tmp_path, monkeypatch):
for k in DROP:
monkeypatch.delenv(k, raising=False)
ws, elsewhere = tmp_path / "ws", tmp_path / "elsewhere"
(ws / ".mam").mkdir(parents=True)
elsewhere.mkdir()
monkeypatch.setenv("WORKSPACE_ROOT", str(ws))
res = subprocess.run([sys.executable, str(REGISTRY_PY), "register", "--prompt", "f2", "--agent", "human",
"--agent-session", "herdr:f2"], capture_output=True, text=True, env=_env(ws),
cwd=str(elsewhere), timeout=60)
assert res.returncode == 0, res.stderr
jid = res.stdout.strip()
assert (ws / ".mam" / "jobs" / f"{jid}.json").is_file() and (ws / ".mam" / "delegate_job_logs" / jid).is_dir()
res = _wrapper(ws, "logs", "--list", cwd=elsewhere)
assert res.returncode == 0 and jid in res.stdout, res.stdout + res.stderr
res = _wrapper(ws, "logs", jid, cwd=elsewhere)
assert res.returncode == 0 and "registered" in res.stdout, res.stdout + res.stderr
assert not (elsewhere / ".mam").exists()
# ---------------------------------------------------------------------------
# Mac review R1: an audit terminal of a later dispatch is not the earlier one's success
# ---------------------------------------------------------------------------
def test_disk_fallback_rejects_a_terminal_that_belongs_to_a_later_dispatch(moved, monkeypatch):
"""Between the registry read and the audit read: A error -> redispatch
(attempt 2) -> B completed. The caller waiting on A must not be told
"completed"; the caller of B must."""
ws, reg, logs = moved
jid = registry.register_job("r1", registry_dir=str(reg), agent_session="herdr:t57")
assert registry.pick_pending("herdr:t57", registry_dir=str(reg)) == jid # claim: running, token A
rec = mqtt_common.load_job(jid, str(reg))
tok_a = rec["dispatch_token"]
assert rec["status"] == "running" and rec["attempt_id"] == 1
read, fired = mqtt_common.read_logged_status, []
def interleave(job, logs_dir=None):
if not fired: # once, right between the two reads, with real transitions
fired.append(1)
assert engine.transition(jid, "error", registry_dir=str(reg), source="operator").committed
assert engine.transition(jid, "redispatch", registry_dir=str(reg), source="operator",
fields={"attempt_id": 2}).committed
assert engine.transition(jid, "completed", registry_dir=str(reg), source="operator").committed
return read(job, logs_dir)
monkeypatch.setattr(mqtt_common, "read_logged_status", interleave)
pending, terminal = {jid}, {}
job_subscriber._check_disk_fallback(pending, str(reg), terminal, {jid: tok_a}, {jid: 1})
assert fired, "the interleaving did not run"
assert pending == {jid} and terminal == {}, "B's completion was attributed to A"
rec = mqtt_common.load_job(jid, str(reg))
tok_b = rec["dispatch_token"]
assert tok_b != tok_a and rec["attempt_id"] == 2 and rec["status"] == "completed"
# the next round, and the caller of B, see the truth
pending, terminal = {jid}, {}
job_subscriber._check_disk_fallback(pending, str(reg), terminal, {jid: tok_b}, {jid: 2})
assert pending == set() and terminal == {jid: "completed"}
pending, terminal = {jid}, {}
job_subscriber._check_disk_fallback(pending, str(reg), terminal, {jid: tok_a}, {jid: 1})
assert pending == {jid} and terminal == {}
# ---------------------------------------------------------------------------
# Mac review R2: a registry directory that is itself a symlink has one audit log
# ---------------------------------------------------------------------------
def test_a_symlinked_registry_dir_resolves_to_one_audit_log(tmp_path, monkeypatch):
"""submit resolves --registry-dir with pwd -P, so its audit log lands next
to the link's target; the resolver must pair the same place for logs."""
from lib_py.delegation import logroot
for k in DROP:
monkeypatch.delenv(k, raising=False)
ws = tmp_path / "ws"
(ws / ".mam").mkdir(parents=True)
monkeypatch.setenv("WORKSPACE_ROOT", str(ws))
actual = tmp_path / "actual" / "jobs"
actual.mkdir(parents=True)
alias = tmp_path / "alias"
alias.mkdir()
(alias / "jobs").symlink_to(actual)
real_actual = Path(os.path.realpath(tmp_path / "actual"))
assert logroot.sibling_logs_dir(str(alias / "jobs")) == str(real_actual / "delegate_job_logs")
res = _wrapper(ws, "submit", "--dry-run", "--agent", "human", "--registry-dir", str(alias / "jobs"),
"--workdir", str(ws), "--prompt", "t")
assert res.returncode == 0, res.stdout[-800:] + res.stderr[-800:]
jid = _only_job_id(actual)
assert res.stdout.strip().splitlines()[-1] == str(real_actual / "delegate_job_logs" / jid)
assert (real_actual / "delegate_job_logs" / jid / "meta.json").is_file()
assert not (tmp_path / "alias" / "delegate_job_logs").exists()
res = _wrapper(ws, "logs", "--registry-dir", str(alias / "jobs"), jid)
assert res.returncode == 0 and "registered" in res.stdout, res.stdout + res.stderr
assert not (ws / ".mam" / "delegate_job_logs").exists()
+3
View File
@@ -495,6 +495,9 @@ def test_b9_logs_dir_follows_cwd_changes(mam_sandbox, tmp_path, monkeypatch):
monkeypatch.delenv("DELEGATE_JOB_LOGS_DIR", raising=False)
# Issue #15: with WORKSPACE_ROOT set the root is anchored on the workspace
# instead; the cwd only decides when neither override is present.
# Issue #57: the audit log follows the registry, so the sandbox's
# MAM_JOBS_DIR must go too for the cwd to decide.
monkeypatch.delenv("MAM_JOBS_DIR", raising=False)
monkeypatch.delenv("WORKSPACE_ROOT", raising=False)
a = tmp_path / "a"; b = tmp_path / "b"
a.mkdir(); b.mkdir()