merge: fix/issue-12-8-7 (Issue #12 registry secrets, #8 update.sh install stamps, #7 sibling-workspace packing; v4.4.4)

Reviewed with unanimous [VERDICT: PASS] from claude, agy, grok and codex.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
This commit is contained in:
GodopuandClaude Fable 5.1 committed 2026-09-13 15:00:17 +09:00
commit 8e7282c948
32 files changed
+968 -86

No files matched your search

@@ -0,0 +1,144 @@
# 🔍 Review Report — Job b368210a (branch `fix/issue-12-8-7` 2회차, HEAD `ea911d7`)
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-13
- **대상**: `git log 8a09e02..HEAD` 의 네 커밋. 1회차에서 PASS 한 부분은 회귀 중심으로, 새 커밋 2건은 정밀 검토했다.
- `e629cc1`: fix #12·#8·#7
- `4176d75`: release v4.4.4
- **`0fedae2`**: N1 수정 — 새 커밋
- **`ea911d7`**: 비차단 권고 반영 — 새 커밋
- **HEAD 고정**: 리뷰 시작(12:31)부터 끝까지 HEAD 는 `ea911d7`, 작업 트리는 깨끗했다. 같은 HEAD 에 대한 고아 잡 `0cf8d1c0` 의 PASS 판단을 이 잡에서 **새 증거로 다시 확인**했다(샌드박스·테스트 모두 이 잡에서 재실행).
- **방법**:
- 코드 정독
- `$TMPDIR` 샌드박스 재현(모두 끝나고 삭제)
- `scrub-secrets` 레지스트리
- `update.sh` 병합 블록 이전/이후 비교
- `git archive` 사본에서 #7 테스트 변이 검사
- 실제 레지스트리는 **읽기 전용** 집계만 했다(`scrub-secrets` 는 실제 `.mam/jobs` 에 `--dry-run` 으로도 실행하지 않음)
- 브리프 지정 테스트(실제 `.mam/jobs` 스냅샷 비교)
- 정적 검사
- 리뷰 중 45초 간격 `progress` heartbeat 발행. 코드 수정 없음.
## 0. 1회차 결과와 처리 (브리프 기준)
| 리뷰어 | 1회차(`4176d75`) 판정 |
|---|---|
| claude | **NOT PASS** — N1: `scrub-secrets` 가 키의 존재로 대상을 골라 null 비밀번호 레코드 901건을 재기록 |
| agy | PASS |
| grok | PASS |
| codex | 사용량 한도로 리뷰 불가(이번 회차 제외, 한도 복구 후 별도 리뷰 예정) |
creator 는 N1 을 `0fedae2` 로 고쳤고, 비차단 권고 1·2번(`update.sh` 병합 견고화, #7 테스트 보강)을 `ea911d7` 로 반영했다.
## 1. `0fedae2` — `scrub-secrets` 선택 조건 — ✅
### 1-1. 코드·문서
- `registry.py` `_cmd_scrub_secrets` 의 선택 조건이 `"password" in broker` → **`broker.get("password")`** 로 바뀌었다. 저장된 값이 있는 레코드만 대상이다.
- `registry_lock` 아래에서 동작하고, `--dry-run` 이면 기록하지 않으며, 기록은 `_atomic_write_record` 를 거친다.
- 서술도 코드와 일치한다.
- `registry.md:60`: "cleaned on their next write; `registry.py scrub-secrets [--dry-run]` strips …"
- `VERSIONS.md:46`: "비밀번호 값이 실제로 저장된 레코드만 … null 키는 … 대상도 보고도 아니며, 다음 쓰기 때 `_atomic_write_record` 가 정리"
- `registry.py` 는 `0fedae2` 이후 무변경이다(`git diff --stat 0fedae2..HEAD` 는 `update.sh`·#7 테스트뿐).
### 1-2. 샌드박스 재현 (이 잡에서 새로 실행)
- 레코드 네 개를 만들었다: `password: None` / `""` / 가짜 값(uuid4) / 키 없음. 모두 `host/port/tls/username`·`prompt` 를 가진다.
- 대상 안전장치: 최상위 `--registry-dir`(`registry.py:270`, 서브파서는 `--dry-run` 만 정의)에 더해 `MAM_JOBS_DIR` 도 샌드박스로 지정했다. dry-run 출력이 샌드박스 경로를 가리킬 때만 실제 실행으로 넘어가게 했다.
| 단계 | 결과 |
|---|---|
| `--dry-run` | rc 0, "would scrub **1** record(s) under `<sandbox>`: **k_real**", 네 파일 모두 바이트 동일 |
| 실제 실행 | rc 0, "scrubbed 1 record(s) under `<sandbox>`: k_real", 바뀐 파일은 `k_real` 하나 |
| 실행 후 | `k_real` 만 `password` 키 제거. `k_null`·`k_empty` 는 키 유지(다음 쓰기에 맡김), `k_absent` 무변경. 네 레코드 모두 나머지 broker 필드와 `prompt` 보존 |
### 1-3. 실제 레지스트리 (읽기 전용 집계, 명령 미실행)
910건 중 새 규칙으로 선택되는 레코드는 **0건**이다. null/빈 password 키를 가진 901건은 선택되지 않는다.
### 1-4. 회귀 테스트
`test_scrub_secrets_cli_migrates_a_registry` 가 레거시 `password: null` 레코드를 포함한다. dry-run 목록에 없고, 실제 실행 후에도 바이트 단위로 동일함을 단정한다.
## 2. `ea911d7` — `deploy/update.sh:239-257` `install_state` 병합 — ✅
**코드**
- **끝 개행 보장**(241-245): `[ -s … ] && [ -n "$(tail -c1 …)" ]`.
- 명령 치환이 끝 개행을 지우므로, 마지막 바이트가 개행이 아닐 때만 `\n` 을 붙인다. 빈 파일은 건너뛴다.
- **`=` 없는 줄 무시**(247-250): `case "$line" in *=*)`.
- **정확 키 매칭**(254): `awk -F= -v k="$key" '$1 == k …'`.
- `key="${line%%=*}"` 와 awk `$1` 은 둘 다 첫 `=` 앞부분이라 기준이 같다.
- `if !` 조건 안이라 `update.sh:8` 의 `set -euo pipefail` 아래에서도 안전하다.
- 실패 경로 `restore_on_failure` 는 무변경이다.
**샌드박스 비교**(이 잡에서 새로 실행): HEAD 와 `0fedae2` 의 블록을 그대로 추출해, 같은 입력을 `bash -euo pipefail` 로 실행했다.
- 새 파일: `axb=new`, `version=2`(끝 개행 없음)
- 이전 파일: `a.b=old`, `axb=old`, `gitignore_created=1`, `# comment`, `noequals`, `=emptykey`, `k=v=w`, `version=1`(끝 개행 없음)
| 블록 | 결과 |
|---|---|
| `0fedae2` | 두 키가 한 줄로 붙어 `version=2gitignore_created=1` 이 됨. `a.b=old` 유실(정규식 `^a.b=` 가 `axb=` 에 일치). `# comment`·`noequals` 추가 |
| `ea911d7` | `axb=new` / `version=2` / `a.b=old` / `gitignore_created=1` / `k=v=w` — 기대와 일치, rc 0 |
| `ea911d7` 추가 확인 | 두 번째 실행 결과 동일(멱등). 새 파일이 빈 경우·없는 경우 모두 `gitignore_created=1` 한 줄 |
**참고(무해)**: `awk -v` 는 값의 역슬래시 이스케이프를 해석한다. 하지만 `install_state` 키는 `install.sh:597,601` 의 `gitignore_created` 뿐이라 영향이 없다.
`test_issue8_update_state.py`(실제 install→update 사이클), `test_deploy_layout.py`, `test_deploy_freshness.py` 모두 통과했다(§5).
## 3. `ea911d7` — #7 테스트 2건이 실제 코드 경로를 검증하는가 — ✅
### 3-1. 코드 경로
**`test_idle_pane_in_a_later_workspace_is_reused`**
- 상황: w1 은 가득 찼고(4), w2 에 agent 없는 pane `w2:p_idle` 이 있다.
- `lib.sh` 가 따라가는 경로:
1. `lib.sh:764-777`: 후보마다 idle 필터 `not p.get('agent')` 를 적용한다. w1 에서는 찾지 못한다.
2. `lib.sh:812-815`: layout 이 overflow 이므로 `continue`.
3. `lib.sh:778-786`: w2 의 idle pane 을 선택하고 `break`.
- 단정: split·create 가 없고, `agent start` 대상이 `w2:p_idle` 이다.
**`test_ws_label_rename_targets_only_the_chosen_workspace`**
- 단정: rename 호출이 정확히 `["workspace","rename","w2","team-b"]` 하나다(`lib.sh:854-856`).
`lib.sh` 는 `e629cc1` 이후 무변경이다.
### 3-2. 변이 검사 (`git archive HEAD` 사본, 저장소 무변경)
- `conftest.py:22` 가 자기 위치 기준으로 `.agents/skills` 를 복사하므로, 사본의 테스트는 사본의 `lib.sh` 를 실행한다.
| 사본 | 변이 | idle 재사용 테스트 | rename 대상 테스트 |
|---|---|---|---|
| control | 없음 | PASSED | PASSED |
| M1 | `lib.sh:771` idle 필터를 `if False:` 로(idle pane 을 찾지 못함) | **FAILED** — "an idle pane must be reused, not split around"(`w2:p1` 에서 split 발생) | PASSED |
| M2 | `lib.sh:855` rename 대상을 첫 후보(`${candidate_wss%%[[:space:]]*}`)로 | PASSED | **FAILED** — `[['workspace','rename','w1','team-b']]` ≠ 기대값(w2) |
- 각 테스트는 자기 코드 경로의 변이에서만 실패하고, 다른 테스트의 변이에는 영향받지 않는다. 두 테스트가 브리프의 "idle pane 우선"과 "rename 대상"을 **각각 독립적으로 고정**함이 확인되었다.
- 변이 실행 전후 실제 `.mam/jobs` 항목 수는 2401 로 동일했다. 사본은 끝나고 삭제했다.
## 4. 버전 락스텝 · 릴리스 커밋 — ✅
- `lib.sh:32` `MAM_VERSION="4.4.4"`, `eager.py:16` `FRAMEWORK_VERSION = "4.4.4"`, SKILL.md 8개 `version: 4.4.4`.
- `verify_framework_version()` 통과(`env -u MAM_VERSION`).
- 현재 문서(README·README.ko·BOOTSTRAP·BOOTSTRAP.ko·MESSAGING) 모두 4.4.4.
- `VERSIONS.md` 밖에 남은 `4.4.3` 은 보관된 리뷰 리포트(`.agents/reports/…`)의 이력 서술뿐이다.
- 릴리스 커밋 `4176d75` 제목은 `release(v4.4.4): bump framework and eight skills to 4.4.4 in lockstep` 그대로다.
- ⚠️ 비차단: `VERSIONS.md:51` "신규 테스트 **8개** … `test_issue7_workspace_packing.py` **3개**" 는 `ea911d7` 이후 **10개 / 5개**다(§6-1).
## 5. 테스트 · 정적 검사 (HEAD `ea911d7`)
- **브리프 지정 10파일 + `test_version_consistency` + `test_deploy_freshness`**: **275 passed in 154.9 s, rc 0**.
- 실행 시점 HEAD `ea911d7` 을 기록으로 확인했다.
- 실행 전후 실제 `.mam/jobs` 레코드 목록이 동일했다(추가 0건).
- **정적 검사**:
- `git diff --check 8a09e02...HEAD` 클린
- `bash -n deploy/update.sh` 통과
- 작업 트리 깨끗
- shellcheck 는 미설치라 실행하지 못했다(대신 §2 샌드박스 실행으로 확인).
## 6. 비차단 권고 (후속)
1. **`VERSIONS.md:51` 신규 테스트 수 정정**: 8 → 10, #7 3 → 5. 설명에 "뒤 후보의 idle pane 재사용, 선택된 워크스페이스만 rename" 추가. 병합 전 한 줄 수정을 권한다.
2. `scrub-secrets` 서브커맨드 요약 도움말 `"remove broker.password from every job record"` 은 이제 약간 과장이다. `--dry-run` 도움말은 정확하다. 고정 캡처 `help-registry.txt` 와 함께 갱신할 것.
3. `DelegationService` 를 라이브러리로 쓸 때 `ensure_env_loaded()` 선행, 명시 broker override 의 비밀번호를 env 로 제공한다는 점을 문서화.
4. 토큰 우발 노출 심층 방어(이전 트리아지 권고 유지).
## 7. GM 참고
- 이 판단은 `0cf8d1c0` 리포트와 같은 HEAD(`ea911d7`)에 대한 것이고, 결론도 같다.
- `0cf8d1c0` 에서 지적한 "리뷰 중 커밋" 문제는 이번 브리프가 `ea911d7` 을 대상으로 명시하면서 해소되었다. 병합 시에는 agy·grok 의 2회차 리포트도 `ea911d7` 을 대상으로 했는지 확인할 것.
## 8. 종합
- N1 은 최소 수정으로 해소되었다. 샌드박스, 실제 레지스트리 읽기 전용 집계(선택 0건), 회귀 테스트로 확인했다.
- `ea911d7` 의 `update.sh` 병합 보강은 세 가지 가장자리를 바르게 고쳤다. 새 #7 테스트 2건은 해당 코드 경로를 실제로 고정한다(§3-2).
- 버전 락스텝은 완전하다. 남은 것은 `VERSIONS.md:51` 테스트 수 한 줄(비차단)뿐이다. 설계 재검토는 필요 없다.
[VERDICT: PASS]
@@ -0,0 +1,65 @@
# Codex review — fix/issue-12-8-7
Job: `737157bc`. Reviewer: `planner-reviewer-codex-01`.
Base: `8a09e02`. Reviewed HEAD: `fbe27319802a8403ed21691ba59a2c3d6e5c5319`.
PASS. No blocking defect found in the requested branch changes. Reviewed the full branch diff and six commits through fbe2731; archived peer reports were background context, not substitutes for fresh verification.
## Issue #12 — broker secrets
`BrokerConfig.to_registry_block()` excludes the password. `_atomic_write_record()` removes the key before persistence, including caller-supplied broker blocks and subsequent writes to old records. Because the in-memory registration record is scrubbed before `init_job_log`, newly seeded metadata also receives the scrubbed block.
`broker_config_from_job()` overlays record routing/authentication metadata on environment configuration, retaining MQTT_PASSWORD when the record omits it. Publisher CLI and job subscriber call `ensure_env_loaded()` before preparing or connecting; reconcile's embedded subscriber also loads the environment before building its configuration. The service publishes a prepared event using the same configuration resolver and transports. As required by the existing D6 library contract, a direct embedding application's entry point must initialize the environment (or export the variables); DelegationService itself does not load .mam.env. This prerequisite should not be confused with automatic dotenv loading in every library call.
The migration holds the registry lock during selection and rewriting. Only a truthy stored password is selected. An independent synthetic temporary-registry check confirmed:
- Dry-run leaves all record bytes unchanged.
- Missing, null, and empty-string passwords remain byte-identical after actual migration.
- The valued-password record loses only that key; status, sequence, auth token and dispatch token remain unchanged.
The G6 exception for `_cmd_scrub_secrets` is narrowly justified: it changes stored credential data without a lifecycle transition. Retaining auth_token and dispatch_token supports the agent's per-job HMAC signing and attempt binding. This is removal of shared broker credentials from live job records and future writes, not a claim to erase historical transcripts or old audit copies. Legacy record passwords remain usable until scrubbed; environment reconstruction was independently checked with synthetic data.
## Issue #8 — update state
Successful updates keep the installer's new version and hash ledgers. Only old install_state keys absent from the fresh file are merged. Exact awk field comparison avoids regex-key interpretation, lines without `=` and empty keys are skipped, the fresh file's final newline is repaired, and an unterminated old final line is processed. An independent execution of the actual merge block in temporary directories verified these properties with a dotted key, a similar existing key, a malformed line and both missing-final-newline cases.
The failure restoration function is unchanged and still copies install_state, asset_hashes.txt and version.txt from backup. The passing Issue #8 test performs a real local install→stale-metadata→update cycle. Failure-path preservation was checked by code comparison; a separate injected-installer-failure test was not run.
## Issue #7 — workspace packing
Candidate workspace IDs are deduplicated in pane order after normalized CWD matching. Each candidate first offers an agent-less pane; otherwise the unchanged layout engine determines a split. Overflow continues to the next candidate. Selection sets existing_ws before allocating or reusing, and rename applies only to the chosen workspace. Single-candidate behavior and the existing agent-reuse path remain intact.
The shell loop splits machine workspace IDs, not CWDs or display labels. Under the existing herdr ID representation this is appropriate; arbitrary whitespace or glob characters in future ID formats would require a different iteration form. No new handling of labels as IDs was introduced.
The opt-in mock geometry represents one full pane, two full-height columns, a three-pane partial grid, and a full four-pane grid per sampled workspace. Those shapes match the unchanged layout engine's grouping and overflow rules. Passing tests cover sibling packing, all-full allocation, later idle-pane reuse, chosen-workspace rename and single-workspace equivalence, alongside the broader shim and layout tests.
## Heartbeat test and release
fbe2731 still invokes the real publisher and checks eventual registry completion and wrapper success. Launching publishes asynchronously supplies disk heartbeats at a timer-like cadence independently of broker ACK latency, consistent with local recording before network publication. It does not change production wrapper behavior, and separate no-signal and overall-deadline tests remain.
Nonblocking coverage limit: the test still uses broker timing and does not explicitly synchronize on subscriber idle exit. It demonstrates periodic-heartbeat behavior, not equivalence to every agent that serially waits for each publish. A deterministic subscriber-exit signal would strengthen proof that the disk-grace branch was exercised under every broker timing. The current fresh run passed.
Version constants, all eight skill frontmatters, current documentation and the VERSIONS matrix are synchronized at 4.4.4. The release commit is exactly `release(v4.4.4): bump framework and eight skills to 4.4.4 in lockstep`. The checked current documents retain 4.4.3 only as a historical changelog heading. The changelog's original new-test count predates the two additional packing tests; that is a documentation bookkeeping nit, not a runtime failure.
## Fresh validation
**242 passed in 213.89 seconds** at the reviewed HEAD:
```text
tests/test_issue7_workspace_packing.py
tests/test_issue8_update_state.py
tests/test_issue12_registry_secrets.py
tests/test_issue5_phase1_delegation.py
tests/test_issue5_phase2a_loop.py
tests/test_herdr_shim_contract.py
tests/test_layout.py
tests/test_deploy_layout.py
tests/test_issue14_delivery_ack.py
tests/test_version_consistency.py
```
Full-branch `git diff --check`, individual `bash -n` checks for lib.sh and update.sh, and `verify_framework_version()` using the shell constant all passed. The independent scrub, configuration and merge checks above also passed. No full repository suite was rerun.
Review only: no source changes, commits, pushes, merges, production registry migration, or real-conversation purge. Synthetic migration identifiers were generated with uuid4; all mutation probes used temporary directories. Final HEAD and tracked-worktree checks were clean. Progress events were published during review and testing.
[VERDICT: PASS]
@@ -0,0 +1,151 @@
# Peer Review Report: Branch `fix/issue-12-8-7` (Round 2 — Commits `0fedae2` & `ea911d7`)
- **Reviewer**: agy (session: `herdr:reviewer-agy-01`)
- **Job ID**: `3f5d1cd4`
- **Role**: Reviewer
- **Target Branch**: `fix/issue-12-8-7`
- **Base Commit**: `8a09e02` (v4.4.3)
- **Evaluated Commits**:
- `e629cc1` — `fix(issue-12,8,7): keep broker password out of records, keep fresh install stamps, pack sibling workspaces`
- `4176d75` — `release(v4.4.4): bump framework and eight skills to 4.4.4 in lockstep`
- `0fedae2` — `fix(issue-12): scrub-secrets selects only records with a stored password`
- `ea911d7` — `fix(issue-8,7): harden install_state merge and pin sibling-workspace edge cases`
---
## 1. Executive Summary
This Round 2 peer review evaluated the complete branch `fix/issue-12-8-7` up to `ea911d7`, focusing specifically on the resolutions introduced in commits `0fedae2` (N1 fix from Claude's Round 1 review: value-based filtering in `scrub-secrets`) and `ea911d7` (non-blocking hardening recommendations for `update.sh` `install_state` merge and two new edge-case tests for Issue #7 workspace packing).
Key verifications completed:
1. **Commit `0fedae2` (N1 Resolution)**: `_cmd_scrub_secrets` now strictly tests `broker.get("password")` (value truthiness) rather than key presence. Unauthenticated records with `"password": null` (~900 in realistic registries) are ignored, preventing spurious rewrite churn and lock contention. Dry-run verified on live `.mam/jobs` (0 records reported).
2. **Commit `ea911d7` (Update Hardening)**: `deploy/update.sh` ensures trailing newline on preexisting `install_state`, skips lines missing `=`, and utilizes `awk` with literal variable passing for exact key comparison (`$1 == k`). Failure recovery via `restore_on_failure` is intact.
3. **Commit `ea911d7` (Issue #7 Workspace Edge Cases)**: Two new automated tests in `test_issue7_workspace_packing.py` confirm:
- An idle pane in a subsequent candidate workspace is reused before attempting splits or workspace creation.
- `MAM_WS_LABEL` rename applies exclusively to the chosen target workspace.
4. **Version Lockstep v4.4.4**: Maintained consistently across `lib.sh`, `eager.py`, 8 `SKILL.md` manifests, `VERSIONS.md`, and documentation files.
5. **Test Suite Verification**: 13/13 targeted tests and 231/231 regression tests passed cleanly with 0 job registry leaks.
---
## 2. Review of Commit `0fedae2` (Issue #12: Value-Based Password Scrubbing)
### 2.1 Value-Based Record Selection
- In `.agents/skills/lib_py/delegation/registry.py` (`_cmd_scrub_secrets`):
```python
# Old:
if not (isinstance(broker, dict) and "password" in broker):
continue
# New (0fedae2):
if not (isinstance(broker, dict) and broker.get("password")):
continue
```
- **Analysis**:
- Legacy records created under previous versions without authentication stored `"password": null`.
- Testing `broker.get("password")` ensures only truthy values (non-empty strings containing actual passwords) trigger migration.
- Records with `None`, `""`, or omitted `"password"` keys are untouched.
- Verified on live workspace: `registry.py --registry-dir .mam/jobs scrub-secrets --dry-run` reports `would scrub 0 record(s) under .mam/jobs`, avoiding unnecessary rewrites.
### 2.2 Documentation & Specification Alignment
- `registry.md`: Clarifies that `scrub-secrets` strips stored passwords, leaving empty/null keys to be dropped naturally on the record's next write via `_atomic_write_record`.
- `VERSIONS.md`: Section 2 updated to emphasize that `"password": null` is not treated as a secret and is neither reported nor rewritten.
### 2.3 Automated Test Coverage
- In `tests/test_issue12_registry_secrets.py`, `test_scrub_secrets_cli_migrates_a_registry` was expanded to create an unauthenticated job record with `"password": None`.
- Verifies:
- `empty not in dry.stdout` (dry run ignores null password).
- `empty_path.read_text() == empty_before` (file is not modified).
- Only genuine leaked passwords are reported and scrubbed.
---
## 3. Review of Commit `ea911d7` (Issue #8: `update.sh` Merge Hardening)
### 3.1 Install State Trailing Newline Guard
- In `deploy/update.sh`:
```bash
if [ -s .mam/install_state ] && [ -n "$(tail -c1 .mam/install_state)" ]; then
printf '\n' >> .mam/install_state
fi
```
Guarantees that an existing `install_state` missing a final newline does not concatenate appended keys onto the last line.
### 3.2 Non-KV Line Rejection
- Lines lacking `=` are skipped safely:
```bash
case "$line" in
*=*) ;;
*) continue ;;
esac
```
### 3.3 Exact Key Matching without Regex Side-Effects
- Previously used `grep -q "^${key}="`, which could interpret regex metacharacters in key names.
- Now uses `awk`:
```bash
if ! awk -F= -v k="$key" '$1 == k { found = 1 } END { exit !found }' .mam/install_state; then
printf '%s\n' "$line" >> .mam/install_state
fi
```
- Exact literal string equality `$1 == k` prevents false matches and escapes.
- Full cycle verified via `tests/test_issue8_update_state.py`.
---
## 4. Review of Commit `ea911d7` (Issue #7: Workspace Packing Edge Cases)
Two new regression tests were added to `tests/test_issue7_workspace_packing.py`:
1. `test_idle_pane_in_a_later_workspace_is_reused`:
- Configures `w1` as full (4 agents) and `w2` with 1 active agent and 1 idle pane (`w2:p_idle`).
- Starts 7th agent: verifies the candidate traversal checks `w1`, continues past overflow, checks `w2`, finds the idle pane, and starts the agent directly on `w2:p_idle` without splitting or creating a workspace.
2. `test_ws_label_rename_targets_only_the_chosen_workspace`:
- Configures `w1` (4 agents, full) and `w2` (1 agent).
- Starts 8th agent with `MAM_WS_LABEL="team-b"`.
- Confirms that only the target workspace `w2` is renamed: `[["workspace", "rename", "w2", "team-b"]]`. Preceding full candidate workspaces are untouched.
Both tests pass and accurately pin critical edge-case behaviors.
---
## 5. Version Lockstep v4.4.4 Verification
All components remain strictly locked at `4.4.4`:
- `.agents/skills/lib.sh`: `MAM_VERSION="4.4.4"`
- `.agents/skills/lib_py/loop/eager.py`: `FRAMEWORK_VERSION = "4.4.4"`
- Frontmatter `version: 4.4.4` across all 8 skills (`create`, `stop`, `resume`, `status`, `monitor`, `delegate-job`, `loop`, `orc-onboard`).
- `VERSIONS.md`: Current release v4.4.4, 8-row skills matrix, v4.4.4 changelog.
- Badges and headers in `README.md`, `README.ko.md`, `BOOTSTRAP.md`, `BOOTSTRAP.ko.md`, and `MESSAGING.md`.
- `lib_py.loop.eager.verify_framework_version()`: Verified OK.
---
## 6. Test Suite & Registry Integrity Audit
### 6.1 Targeted Test Execution
Command:
```bash
.venv/bin/python -m pytest tests/test_issue7_workspace_packing.py tests/test_issue8_update_state.py tests/test_issue12_registry_secrets.py tests/test_version_consistency.py
```
**Result**: 13 passed in 19.47s (100% success).
### 6.2 Regression Test Execution
Command:
```bash
.venv/bin/python -m pytest tests/test_herdr_shim_contract.py tests/test_layout.py tests/test_deploy_layout.py tests/test_issue5_phase1_delegation.py tests/test_issue5_phase2a_loop.py tests/test_loop_cli.py tests/test_issue5_phase4_runtime.py
```
**Result**: 231 passed in 128.99s (100% success).
### 6.3 Sandboxing & Leak Verification
- Baseline `.mam/jobs` file count before execution: **2405**
- File count after full test execution: **2405**
- Leak delta: **0 files created or modified** in live registry.
---
## 7. Conclusion
The updates in `0fedae2` and `ea911d7` cleanly resolve the N1 finding and further harden update and workspace packing operations. All verification criteria are fully satisfied.
[VERDICT: PASS]
@@ -0,0 +1,62 @@
# 🔍 Review Report — Job cc059645 (`fix/issue-12-8-7` 2회차 @ `ea911d7`)
- **Reviewer**: `reviewer-grok-01` (role: reviewer) · 2026-09-13
- **범위**: `git log`/`git diff` **`8a09e02...HEAD`** (루프 수집 diff 는 비어 있음)
- **HEAD**: `ea911d7` `fix(issue-8,7): harden install_state merge and pin sibling-workspace edge cases`
- **1회차 HEAD**: `4176d75` (grok PASS). 이번 초점: `0fedae2` (claude N1) + `ea911d7` (비차단 권고)
- **안전**: 실제 `.mam/jobs` 에 `scrub-secrets` 미실행. 실제 홈 purge 없음. 소스 무변경. codex 세션 미접촉.
커밋: `e629cc1` → `4176d75` → **`0fedae2`** → **`ea911d7`**. `git diff --check 8a09e02...HEAD` 클린.
## (1) `0fedae2` N1 — scrub-secrets 값 기준 선택
`_cmd_scrub_secrets` (`registry.py:360`): `"password" in broker` → `broker.get("password")`. JSON `null`·빈 문자열·키 없음은 모두 falsy 라 목록에도 재기록에도 안 넣음. 값이 있는 문자열만 pop 후 `_atomic_write_record`. lock 범위는 그대로 전체 순회지만 **쓰기는 비밀이 있는 레코드만**.
`test_scrub_secrets_cli_migrates_a_registry`: dirty(`"leaked-earlier"`) 1건만 dry-run/실제 대상. `password: null` 레코드는 stdout 에 id 가 없고 파일 바이트 불변. 키 없는 clean 레코드도 불변. `--dry-run` 은 dirty 파일의 유출 문자열을 그대로 둠. 4176d75 에서는 null 키까지 세어 `would scrub 2` 가 났을 테스트.
`_atomic_write_record` 의 `_scrub_record_secrets` 는 여전히 키만 보면 pop — null 키는 **다음 일반 쓰기** 때 정리. scrub 가 901건을 재기록하지 않는 분업과 맞음.
문서: `VERSIONS.md` v4.4.4 항목 2, `registry.md:60-62` 가 “저장된 값만 / null 은 다음 쓰기”로 정합.
## (2) `ea911d7` #8 — install_state 병합 보강
`deploy/update.sh:243-256`:
- 비어 있지 않고 마지막 바이트가 개행이 아니면 (`tail -c1` + `$(…)` 가 trailing newline 을 지우는 관용) `printf '\n'` — 첫 병합 키가 마지막 줄에 붙지 않음.
- `case *=*` 로 `=` 없는 줄 무시.
- `awk -F= -v k="$key" '$1 == k'` 정확 문자열 비교. `grep -q "^${key}="` 의 정규식 해석 없음.
- 실패 `restore_on_failure` (`:168-171`) 는 세 파일 `cp -f` 그대로. 성공 경로만 변경.
`test_issue8_update_state.py` 실제 install→stale stamps→`update.sh --force` 통과(아래 스위트).
## (3) `ea911d7` #7 테스트 2건 — 실제 경로
`test_idle_pane_in_a_later_workspace_is_reused`: w1=4(overflow) + w2=1 + `state["panes"]` 에 agent 없는 `w2:p_idle`. mock `pane list` 는 에이전트 pane 에 `agent` 를 넣고 extra pane 은 안 넣으므로, `lib.sh:771` `not p.get('agent')` 가 w2 에서 idle 을 고름. split/create 없음, `agent start … --pane w2:p_idle`. idle 우선이 깨지면 w2 split 으로 실패.
`test_ws_label_rename_targets_only_the_chosen_workspace`: 같은 4+1 에서 `MAM_WS_LABEL=team-b`. `lib.sh:854-856` 은 선택된 `existing_ws` 에만 `workspace rename`. 호출 로그가 정확히 `["workspace", "rename", "w2", "team-b"]` — w1 이나 생성 경로 `--label` 이 아님.
## (4) 1회차 PASS 회귀 + 락스텝
#12 `to_registry_block` password 제외, env 복원, G6 `_cmd_scrub_secrets` 예외, `auth_token` 유지. #7 후보 순회·overflow `continue`·단일 ws 동치. #8 성공 경로 version/hashes 비복원. 회귀 없음.
`lib.sh`/`eager.py`/8 SKILL.md/VERSIONS.md 개요·매트릭스 / README·BOOTSTRAP·MESSAGING = **4.4.4**. 릴리스 커밋 `4176d75` 제목 유지. `env -u MAM_VERSION` 아래 `verify_framework_version` 통과. 현재 버전 안내 문서에 잔여 `4.4.3` 없음.
Non-blocking (1회차와 동일): 락스텝이 v4.4.3 품질 문장을 24/17 → 26/19 로 고친 것은 `fae70c1` 작업이지 이 베이스가 아님. v4.4.4 품질 불릿은 아직 “테스트 8개/issue7 3개” — 이후 테스트 2건·N1 보강은 changelog 항목 2·코드에만 반영. 기능 결함 아님.
## (5) 테스트
```
env -u MAM_VERSION .venv/bin/python -m pytest -q \
tests/test_issue7_workspace_packing.py tests/test_issue8_update_state.py \
tests/test_issue12_registry_secrets.py tests/test_issue5_phase1_delegation.py \
tests/test_issue5_phase2a_loop.py tests/test_herdr_shim_contract.py \
tests/test_layout.py tests/test_deploy_layout.py tests/test_loop_cli.py \
tests/test_issue5_phase4_runtime.py tests/test_version_consistency.py
```
**244 passed in 134.54s** (1회차 242 + #7 테스트 2). `bash -n` lib.sh/`update.sh` 클린. 전체 스위트 미실행.
## 종합
N1(null password 901건 재기록)은 값 기준으로 닫혔고, install_state 병합과 #7 idle/rename 경로가 테스트로 고정됐다. 1회차 PASS 항목 회귀 없음. 설계 재작업 불필요.
[VERDICT: PASS]
+34 -32
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.4.3"
MAM_VERSION="4.4.4"
export MAM_VERSION
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
@@ -727,33 +727,41 @@ except Exception:
print(cmd)
" "$final_cmd" "$kind" 2>/dev/null || echo "$final_cmd")
# W1: Resolve existing_ws by querying pane list for matching CWD (since WorkspaceInfo has no cwd key)
existing_ws=$(_real_herdr pane list 2>/dev/null | TARGET_CWD="${ws:-.}" python3 -c "
# W1: Resolve candidate workspaces by querying pane list for matching CWD
# (WorkspaceInfo has no cwd key). ISSUE-7: every workspace on this cwd is
# a candidate, in pane order; the first one with an idle pane or with
# room in its 2xK grid takes the agent, and a fresh workspace is created
# only when all of them are full. Taking just the first match found the
# full workspace every time and re-judged it overflow, so each agent past
# the fourth opened yet another workspace (4 + 1 + 1 instead of 4 + 2).
candidate_wss=$(_real_herdr pane list 2>/dev/null | TARGET_CWD="${ws:-.}" python3 -c "
import sys, json, os
target_ws = os.environ.get('TARGET_CWD', '')
try:
target_abs = os.path.realpath(target_ws) if target_ws else ''
d = json.loads(sys.stdin.read())
panes = d.get('result', {}).get('panes', [])
matched_ws = ''
seen = []
for p in panes:
p_cwd = os.path.realpath(p.get('cwd', '')) if p.get('cwd') else ''
if target_abs and p_cwd == target_abs and p.get('workspace_id'):
matched_ws = p.get('workspace_id')
break
print(matched_ws)
wid = p.get('workspace_id')
if target_abs and p_cwd == target_abs and wid and wid not in seen:
seen.append(wid)
print('\n'.join(seen))
except Exception:
pass
" 2>/dev/null || echo "")
ws_id=""
target_pane=""
if [ -n "$existing_ws" ]; then
existing_ws=""
for cand_ws in $candidate_wss; do
[ -n "$cand_ws" ] || continue
# BUG-4: prefer an already-idle (agent-less) pane in the existing
# workspace over splitting a new one — avoids unbounded pane
# proliferation and 2x2 grid-layout churn when a prior agent's pane
# was left behind idle.
idle_pane=$(_real_herdr pane list 2>/dev/null | TARGET_WS="$existing_ws" python3 -c "
idle_pane=$(_real_herdr pane list 2>/dev/null | TARGET_WS="$cand_ws" python3 -c "
import sys, json, os
target_ws = os.environ.get('TARGET_WS', '')
try:
@@ -768,15 +776,18 @@ except Exception:
" 2>/dev/null || echo "")
if [ -n "$idle_pane" ]; then
existing_ws="$cand_ws"
target_pane="$idle_pane"
if [ -n "$env_flags" ] && [ -n "$final_cmd" ]; then
idle_env="${env_flags//--env /}"
idle_env="${idle_env:+$idle_env }HERDR_WORKSPACE_ID=$existing_ws"
final_cmd="env $idle_env $final_cmd"
fi
else
break
fi
# W2a: Determine split direction policy via pane layout
sample_pane=$(_real_herdr pane list 2>/dev/null | TARGET_WS="$existing_ws" python3 -c "
sample_pane=$(_real_herdr pane list 2>/dev/null | TARGET_WS="$cand_ws" python3 -c "
import sys, json, os
target_ws = os.environ.get('TARGET_WS', '')
try:
@@ -798,9 +809,15 @@ except Exception:
sample_pane="${split_target:-$sample_pane}"
fi
if [ "$split_dir" = "right" ] || [ "$split_dir" = "down" ]; then
split_json=$(_real_herdr pane split --pane "$sample_pane" --direction "$split_dir" --cwd "${ws:-.}" $env_flags --env HERDR_WORKSPACE_ID="$existing_ws" --no-focus 2>/dev/null || echo "")
target_pane=$(echo "$split_json" | python3 -c "
if [ "$split_dir" = "overflow" ]; then
# W2b: this workspace's grid is full — try the next one on the same cwd
# (ISSUE-7); a fresh workspace is created below only if none has room.
continue
fi
existing_ws="$cand_ws"
[ "$split_dir" = "down" ] || split_dir="right"
split_json=$(_real_herdr pane split --pane "$sample_pane" --direction "$split_dir" --cwd "${ws:-.}" $env_flags --env HERDR_WORKSPACE_ID="$existing_ws" --no-focus 2>/dev/null || echo "")
target_pane=$(echo "$split_json" | python3 -c "
import sys, json
try:
d = json.loads(sys.stdin.read())
@@ -809,23 +826,8 @@ try:
except Exception:
pass
" 2>/dev/null || echo "")
elif [ "$split_dir" = "overflow" ]; then
# W2b: Overflow threshold reached — force create fresh workspace
existing_ws=""
else
split_json=$(_real_herdr pane split --pane "$sample_pane" --direction right --cwd "${ws:-.}" $env_flags --env HERDR_WORKSPACE_ID="$existing_ws" --no-focus 2>/dev/null || echo "")
target_pane=$(echo "$split_json" | python3 -c "
import sys, json
try:
d = json.loads(sys.stdin.read())
res = d.get('result', {})
print(res.get('pane', {}).get('pane_id') or res.get('pane_id', ''))
except Exception:
pass
" 2>/dev/null || echo "")
fi
fi
fi
break
done
if [ -z "$existing_ws" ] || [ -z "$target_pane" ]; then
ws_json=$(_real_herdr workspace create --cwd "${ws:-.}" ${MAM_WS_LABEL:+--label "$MAM_WS_LABEL"} $env_flags --no-focus 2>/dev/null || echo "")
@@ -227,13 +227,19 @@ class BrokerConfig:
return asdict(self)
def to_registry_block(self) -> Dict[str, Any]:
"""The subset that gets persisted into a job record's broker block."""
"""The subset that gets persisted into a job record's broker block.
Never the password (Issue #12): every sub-agent can read the job
record, so a broker secret written there ends up in agent transcripts.
``broker_config_from_job`` layers the block over the environment, so
the password is taken from ``MQTT_PASSWORD`` (``.mam.env``) at connect
time instead.
"""
return {
"host": self.host,
"port": self.port,
"tls": self.tls,
"username": self.username,
"password": self.password,
}
@@ -393,12 +399,23 @@ def load_job(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR) -> Dict[str,
return json.load(fh)
def _scrub_record_secrets(record: Dict[str, Any]) -> None:
"""Issue #12: a broker password never belongs in a job record (see
``BrokerConfig.to_registry_block``). Records written before that rule are
cleaned the next time anything writes them; ``registry.py scrub-secrets``
does it for a whole registry at once."""
broker = record.get("broker")
if isinstance(broker, dict):
broker.pop("password", None)
def _atomic_write_record(job_id: str, registry_dir: str, record: Dict[str, Any]) -> None:
"""Write a record atomically: temp file in the same dir + os.replace.
The rename is atomic on POSIX, so readers never observe a half-written
file. Callers MUST already hold ``registry_lock`` for read-modify-write
correctness."""
_scrub_record_secrets(record)
Path(registry_dir).mkdir(parents=True, exist_ok=True)
path = _job_path(job_id, registry_dir)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{job_id}.", suffix=".tmp")
@@ -333,9 +333,41 @@ def _build_parser() -> argparse.ArgumentParser:
p_logs.add_argument("--json", action="store_true",
help="emit raw JSON lines / records instead of a table")
p_scrub = sub.add_parser(
"scrub-secrets",
help="remove broker.password from every job record (Issue #12 migration)",
)
p_scrub.add_argument("--dry-run", action="store_true",
help="only list the records that still carry a password")
return parser
def _cmd_scrub_secrets(args) -> int:
"""Issue #12: strip broker.password from records written before
to_registry_block stopped persisting it. Every other field is kept.
Only a stored value counts. The old rule wrote `"password": null` even
for an unauthenticated broker; that empty key is no secret, and
_atomic_write_record drops it on the record's next write anyway, so
reporting or rewriting it here would list phantom secrets and hold the
registry lock over every record in a PoC registry."""
rd = args.registry_dir
found = []
with registry_lock(rd):
for record in list(_iter_records(rd)):
broker = record.get("broker")
if not (isinstance(broker, dict) and broker.get("password")):
continue
found.append(record.get("job_id", "?"))
if not args.dry_run:
broker.pop("password", None)
_atomic_write_record(record["job_id"], rd, record)
verb = "would scrub" if args.dry_run else "scrubbed"
print(f"{verb} {len(found)} record(s) under {rd}" + (": " + ", ".join(found) if found else ""))
return 0
def _operator_write(job_id: str, registry_dir: str, **fields) -> int:
"""Apply an operator-authority write and report a refusal on the exit status.
@@ -506,6 +538,9 @@ def main(argv: Optional[List[str]] = None) -> int:
if args.command == "logs":
return _cmd_logs(args)
if args.command == "scrub-secrets":
return _cmd_scrub_secrets(args)
return 1
+1 -1
View File
@@ -13,7 +13,7 @@ import sys
from typing import List, Sequence
# Framework version locked with framework constants
FRAMEWORK_VERSION = "4.4.3"
FRAMEWORK_VERSION = "4.4.4"
CORE_MODULES: Sequence[str] = (
# Standard library modules
@@ -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.4.3
version: 4.4.4
author: godopu
license: MIT
platforms: [linux, macos]
@@ -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.4.3
version: 4.4.4
author: godopu
license: MIT
platforms: [linux, macos, windows]
@@ -42,8 +42,7 @@ Reference implementation: [`./scripts/registry.py`](./scripts/registry.py)
"host": "broker.hivemq.com",
"port": 1883,
"tls": false,
"username": null,
"password": null
"username": null
},
"topic_prefix": "python/mqtt/jobs/abc12345",
"timeout_sec": 3600,
@@ -55,7 +54,14 @@ Reference implementation: [`./scripts/registry.py`](./scripts/registry.py)
```
- `broker` lets `publish_event.py` connect from the record alone (env still
overrides toggles like `MQTT_TLS`).
overrides toggles like `MQTT_TLS`). It never carries the password (Issue #12):
every sub-agent can read the record, so `MQTT_PASSWORD` is layered back from
the environment (`.mam.env`) at connect time. Records written before that rule
are cleaned on their next write; `registry.py scrub-secrets [--dry-run]` strips
every stored password from a whole registry at once (an empty `null` key left
by the old rule is not a secret; it goes on the record's next write). `auth_token` and `dispatch_token` stay in the record
because the agent's own `publish_event` needs them to sign and to pin its
attempt.
- `topic_prefix` → the events topic is `<topic_prefix>/events`.
- `last_seq` backs the monotonic `seq` counter so it survives process restarts.
- `expected_artifacts` is the hook a user `validate.sh` checks (existence/content).
+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.4.3
version: 4.4.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.4.3
version: 4.4.4
author: godopu
license: MIT
platforms: [linux, macos]
@@ -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.4.3
version: 4.4.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.4.3
version: 4.4.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.4.3
version: 4.4.4
author: godopu
license: MIT
platforms: [linux, macos]
+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.4.3
version: 4.4.4
author: godopu
license: MIT
platforms: [linux, macos]
+1 -1
View File
@@ -2,7 +2,7 @@
본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다.
[![Version: v4.4.3](https://img.shields.io/badge/version-v4.4.3-blue.svg)](VERSIONS.md)
[![Version: v4.4.4](https://img.shields.io/badge/version-v4.4.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.4.3](https://img.shields.io/badge/version-v4.4.3-blue.svg)](VERSIONS.md)
[![Version: v4.4.4](https://img.shields.io/badge/version-v4.4.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.4.3
Applies to: MAM v4.4.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와 MQTT 브로커를 기반으로 구축된 고신뢰성 **다중 에이전트 오케스트레이션 및 메시징 백플레인** 프레임워크입니다. `claude`, `agy`, `hermes`, `grok`, `opencode`, `codex` 등 다중 LLM 백엔드 에이전트 전반에 걸쳐 장시간 수행되는 작업(코드 생성, 리팩토링, 보안 검토 등)을 조정, 격리 및 감사할 수 있도록 설계되었습니다.
[![Version: v4.4.3](https://img.shields.io/badge/version-v4.4.3-blue.svg)](VERSIONS.md)
[![Version: v4.4.4](https://img.shields.io/badge/version-v4.4.4-blue.svg)](VERSIONS.md)
---
+1 -1
View File
@@ -2,7 +2,7 @@
An advanced, high-reliability **Multi-Agent Orchestration & Messaging Backplane** framework built on Herdr and MQTT. It is designed to coordinate, isolate, and audit long-running agent tasks (such as code generation, refactoring, and security reviews) across multiple LLM backend clients (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`).
[![Version: v4.4.3](https://img.shields.io/badge/version-v4.4.3-blue.svg)](VERSIONS.md)
[![Version: v4.4.4](https://img.shields.io/badge/version-v4.4.4-blue.svg)](VERSIONS.md)
---
+26 -11
View File
@@ -6,7 +6,7 @@
## 📌 현재 버전 개요 (Current Release)
- **프레임워크 버전**: `v4.4.3`
- **프레임워크 버전**: `v4.4.4`
- **최신 릴리스 일시**: 2026-09-13 (KST)
- **기준 브랜치**: `main`
- **핵심 아키텍처**:
@@ -20,23 +20,38 @@
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.4.3`로 동기화되어 배포됩니다.
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.4.4`로 동기화되어 배포됩니다.
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
| :--- | :---: | :--- | :---: |
| **`multi-agent-mux-create`** | `4.4.3` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
| **`multi-agent-mux-stop`** | `4.4.3` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
| **`multi-agent-mux-resume`** | `4.4.3` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
| **`multi-agent-mux-status`** | `4.4.3` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
| **`multi-agent-mux-monitor`** | `4.4.3` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
| **`multi-agent-mux-delegate-job`** | `4.4.3` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
| **`multi-agent-mux-loop`** | `4.4.3` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
| **`multi-agent-mux-orc-onboard`** | `4.4.3` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
| **`multi-agent-mux-create`** | `4.4.4` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
| **`multi-agent-mux-stop`** | `4.4.4` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
| **`multi-agent-mux-resume`** | `4.4.4` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
| **`multi-agent-mux-status`** | `4.4.4` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
| **`multi-agent-mux-monitor`** | `4.4.4` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
| **`multi-agent-mux-delegate-job`** | `4.4.4` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
| **`multi-agent-mux-loop`** | `4.4.4` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
| **`multi-agent-mux-orc-onboard`** | `4.4.4` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
---
## 📋 버전별 상세 변경 내역 (Changelog)
### 🚀 `v4.4.4` — Issue #12 레지스트리 비밀번호 제거, Issue #8 업데이트 스탬프 보존, Issue #7 형제 워크스페이스 패킹 (2026-09-13)
> **패치 마일스톤 (PATCH Release)**: 모든 서브에이전트가 읽을 수 있는 잡 레코드에 MQTT 브로커 비밀번호가 평문으로 복사되던 문제(Issue #12), `deploy/update.sh` 가 인스톨러가 새로 쓴 `version.txt`·`asset_hashes.txt` 를 업데이트 전 값으로 되돌리던 문제(Issue #8), 같은 cwd 의 첫 워크스페이스만 검사해 다섯 번째 에이전트부터 에이전트마다 워크스페이스를 새로 열던 문제(Issue #7)를 해결했다. 새 `registry.py scrub-secrets` 는 추가 서브커맨드이며 공개 계약·종료 코드 변경은 없다. 브로커 비밀번호는 이제 레코드가 아니라 환경(`MQTT_PASSWORD`, `.mam.env`)에서만 온다.
#### 🌟 주요 개선 사항
1. **잡 레코드의 브로커 비밀번호 제거 (Issue #12, `mqtt_common.BrokerConfig.to_registry_block`, `registry.py`)**: `to_registry_block` 이 더 이상 `password` 를 레코드의 `broker` 블록에 넣지 않는다. `broker_config_from_job` 은 레코드 블록을 환경 설정 위에 겹치므로 접속 시 비밀번호는 `MQTT_PASSWORD`(`.mam.env`)에서 채워지고, `publish_event` 는 여전히 레코드만으로 접속한다. `_atomic_write_record` 는 쓰는 모든 레코드에서 `broker.password` 만 지우므로(다른 필드는 그대로) 이전 규칙으로 쓰인 레코드는 다음 쓰기 때 정리된다. `auth_token`·`dispatch_token` 은 에이전트 자신의 `publish_event` 가 서명과 attempt 고정에 쓰므로 레코드에 남긴다.
2. **`registry.py scrub-secrets [--dry-run]` 마이그레이션 (Issue #12)**: 레지스트리 lock 아래에서 **비밀번호 값이 실제로 저장된** 레코드만 골라 지우고 재기록한다. 이전 규칙이 무인증 모드에서도 남긴 `"password": null` 키는 비밀이 아니므로 대상도 보고도 아니며, 그 레코드의 다음 쓰기 때 `_atomic_write_record` 가 정리한다. `--dry-run` 은 대상 잡 목록만 출력하고 아무것도 바꾸지 않는다. `registry.md` 가 브로커 블록 규칙(비밀번호 비저장, 환경에서 복원, 토큰 유지 이유)을 문서화한다.
3. **업데이트 후 설치 스탬프 보존 (Issue #8, `deploy/update.sh`)**: 성공 경로가 업데이트 전 `asset_hashes.txt`·`version.txt` 를 되돌리지 않는다. 그 복원 탓에 `version.txt` 가 이전 커밋을 가리키고 `remove.sh` 가 갱신된 원본 파일을 "로컬 수정"으로 오판해 백업하던 문제가 사라진다. `install_state` 는 재설치가 쓰지 않은 키(예: `gitignore_created`)만 `key=value` 줄 단위로 병합하며(이미 있는 키는 건너뜀, 마지막 줄에 개행이 없어도 처리), 실패 경로(`restore_on_failure`)는 종전대로 세 파일을 모두 그대로 복원한다.
4. **같은 cwd 형제 워크스페이스 패킹 (Issue #7, `lib.sh` 쉼 `new-session`)**: 첫 번째로 일치한 pane 의 워크스페이스만 보던 탓에 가득 찬 첫 워크스페이스를 매번 overflow 로 판정해 에이전트마다 새 워크스페이스를 열던 문제(4 + 1 + 1)를 고쳤다. 이제 같은 cwd 의 모든 워크스페이스를 pane 순서대로 순회해 idle pane 이 있거나 2xK 그리드에 여유가 있는 첫 워크스페이스에 배치하고(overflow 인 후보는 다음 후보로 넘어감), 모두 가득 찼을 때만 새 워크스페이스를 만든다(4 + 2).
#### ✅ 품질 검증
- **신규 테스트 8개**: `tests/test_issue7_workspace_packing.py` 3개(여유 있는 형제 워크스페이스로 패킹, 모든 후보가 가득 찼을 때만 새 워크스페이스, 여유 있는 첫 워크스페이스 우선 — mock herdr 에 워크스페이스별 2x2 기하를 흉내 내는 opt-in `layout_geometry: "2x2"` 추가), `tests/test_issue8_update_state.py` 1개(실제 install→update 사이클에서 새 `version.txt`·해시 유지와 `install_state` 병합), `tests/test_issue12_registry_secrets.py` 4개(registry 블록에 비밀번호 없음, 등록된 레코드에 비밀번호가 없어도 환경 비밀번호로 접속, 레거시 레코드의 다음 쓰기 때 정리, `scrub-secrets` CLI 마이그레이션).
- **기존 테스트 조정**: registry CLI 계약 스냅샷(`tests/test_issue5_phase1_delegation.py`)과 도움말 캡처(`tests/fixtures/issue_5/phase1/help-registry.txt`)에 의도된 `scrub-secrets` 서브커맨드를 반영하고, G6 가드에 `_cmd_scrub_secrets` 를 선언된 예외로 추가.
- **실측 (커밋 `e629cc1`·위임 브리프 기록)**: 관련 스위트 325개와 전체 스위트(worktree 기준) 통과.
### 🚀 `v4.4.3` — Issue #10·#13 대화 purge 완결, Issue #11 메시지 정정, Issue #15 레지스트리 워크스페이스 고정 (2026-09-13)
> **패치 마일스톤 (PATCH Release)**: agy 대화 purge 가 resume 가능한 부속 파일을 남기던 문제(Issue #10), herdr 세션 행이 없는 오케스트레이터 대화를 어떤 스킬로도 지울 수 없던 문제(Issue #13), `orc_onboard.sh --remove` 의 "removeed" 오타(Issue #11), 호출자 cwd 를 따라가던 잡 레지스트리·감사 로그 때문에 pytest 스위트가 실제 `.mam/jobs` 에 테스트 레코드를 남기던 문제(Issue #15)를 해결했다. 새 서브커맨드·옵션은 모두 추가 기능이다. 레지스트리·감사 로그의 **기본** 경로는 `WORKSPACE_ROOT` 가 있으면 cwd 대신 그 워크스페이스를 따르며, 명시적 `--registry-dir` 와 `MAM_JOBS_DIR`/`DELEGATE_JOB_LOGS_DIR` 는 그대로 우선한다.
@@ -49,7 +64,7 @@
5. **잡 레지스트리·감사 로그의 워크스페이스 고정 (Issue #15, `mqtt_common.default_registry_dir`·`get_logs_dir`)**: 기본 경로가 `MAM_JOBS_DIR`/`DELEGATE_JOB_LOGS_DIR` → `$WORKSPACE_ROOT/.mam/jobs`·`$WORKSPACE_ROOT/.mam/delegate_job_logs` → cwd 기준 `.mam/…` 순으로 정해진다. `registry.py`·`publish_event.py`·`service.py` 와 `create_session.sh`·`stop_session.sh` 가 같은 순서를 쓴다. 저장소 루트에서 돈 pytest 스위트(기본 온보딩 잡을 등록하는 `create_session.sh`)가 호출자 cwd 를 따라 실제 `.mam/jobs` 에 테스트 레코드 593건을 남기던 문제를 해소했고, 샌드박스 픽스처(`tests/conftest.py`)는 두 환경변수를 모두 고정한다.
#### ✅ 품질 검증
- **신규 테스트 24개**: `tests/test_issue10_13_purge.py` 17개(agy 부속 파일·annotation·lock 삭제, 사용 중 lock 보존, 존재한 것만 보고, 자기 계보를 뺀 in-use 판정, 비-에이전트 헬퍼 사슬만 제외하고 에이전트 CLI 조상은 검사, purge CLI 의 auto 판별·사용 중 거부·자기 에이전트 세션 안에서 거부·세션 id 환경변수 5종으로 거부·미해결 uuid, `orc_onboard` purge 의 `--yes` 요구·삭제 후 등록 해제·사용 중 거부·오케스트레이터 자기 세션에서 거부·artifact 없음에도 등록 해제·`--remove` 필수·dry-run 무변경), `tests/test_issue15_registry_isolation.py` 7개(`MAM_JOBS_DIR` 우선, `WORKSPACE_ROOT` 추종, cwd 폴백, 로그 경로 동일 순서, CLI 기본값의 프로세스별 해석, 명시 env 로 온보딩 잡 샌드박스 유지, `WORKSPACE_ROOT` 만으로도 샌드박스 유지).
- **신규 테스트 26개**: `tests/test_issue10_13_purge.py` 19개(agy 부속 파일·annotation·lock 삭제, 사용 중 lock 보존, 존재한 것만 보고, 자기 계보를 뺀 in-use 판정, 비-에이전트 헬퍼 사슬만 제외하고 에이전트 CLI 조상은 검사, purge CLI 의 auto 판별·사용 중 거부·자기 에이전트 세션 안에서 거부·세션 id 환경변수 5종으로 거부·미해결 uuid, `orc_onboard` purge 의 `--yes` 요구·삭제 후 등록 해제·사용 중 거부·오케스트레이터 자기 세션에서 거부·artifact 없음에도 등록 해제·`--remove` 필수·dry-run 무변경, presence lock 만 연 보유자 거부, DB 가 없는 고아 부속 파일로 agy 해결·purge), `tests/test_issue15_registry_isolation.py` 7개(`MAM_JOBS_DIR` 우선, `WORKSPACE_ROOT` 추종, cwd 폴백, 로그 경로 동일 순서, CLI 기본값의 프로세스별 해석, 명시 env 로 온보딩 잡 샌드박스 유지, `WORKSPACE_ROOT` 만으로도 샌드박스 유지).
- **기존 테스트 조정**: O-13(`tests/test_orc_onboard.py`)이 정정된 메시지를 단정하고, R1 헬퍼·B-9 로그 경로 테스트와 `publish_event` 스냅샷(`tests/test_issue5_phase1_delegation.py`·`tests/test_tier1_unit.py`)이 워크스페이스 고정 계약에 맞게 조정됨.
- **실측 (커밋 `c82ea75`·위임 브리프 기록)**: 전체 스위트 839/839 통과, 스위트 실행 전후 실제 `.mam/jobs` 레코드 881건 불변.
+26 -4
View File
@@ -229,11 +229,33 @@ if [ $HAS_MAM -eq 1 ]; then
mkdir -p .mam/delegate_job_logs
cp -rf .mam.update-tmp/delegate_job_logs/* .mam/delegate_job_logs/
fi
for st in install_state asset_hashes.txt version.txt; do
if [ -f ".mam.update-tmp/$st" ]; then
cp -f ".mam.update-tmp/$st" .mam/
# Issue #8: asset_hashes.txt and version.txt describe the tree the
# installer just wrote, so the pre-update copies must NOT come back here -
# restoring them made version.txt name the previous commit and remove.sh
# treat every updated file as locally modified (spurious backups). Only
# install_state carries history worth keeping: merge in any key the fresh
# install did not write (e.g. gitignore_created from the original install).
# The failure path (restore_on_failure) still puts all three back verbatim.
if [ -f ".mam.update-tmp/install_state" ]; then
touch .mam/install_state
# A fresh install_state without a final newline would glue the first
# merged key onto its last line.
if [ -s .mam/install_state ] && [ -n "$(tail -c1 .mam/install_state)" ]; then
printf '\n' >> .mam/install_state
fi
done
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
*=*) ;;
*) continue ;; # not a key=value line
esac
key="${line%%=*}"
[ -n "$key" ] || continue
# exact key match, no regex interpretation of the key
if ! awk -F= -v k="$key" '$1 == k { found = 1 } END { exit !found }' .mam/install_state; then
printf '%s\n' "$line" >> .mam/install_state
fi
done < .mam.update-tmp/install_state
fi
if [ -d ".mam.update-tmp/skill-backups" ]; then
mkdir -p .mam/skill-backups
cp -rf .mam.update-tmp/skill-backups/* .mam/skill-backups/ 2>/dev/null || true
+44 -9
View File
@@ -334,15 +334,50 @@ elif cmd1 == "pane":
focused = "w1:p1"
panes_list = []
agents = state.get("agents", {})
count = max(len(agents), 1)
w_per_pane = 184 // count
idx = 0
for name, data in agents.items():
panes_list.append({
"pane_id": data.get("pane_id", f"w1:p{idx+1}"),
"rect": {"width": w_per_pane, "height": 78}
})
idx += 1
if state.get("layout_geometry") == "2x2":
# Issue #7 tests: real per-workspace geometry. Only the panes of
# the sampled pane's workspace are reported, laid out as the 2x2
# grid herdr builds, so lib_py.layout judges a full workspace as
# overflow while a sibling workspace with room is offered a split.
sample = ""
i = 2
while i < len(args):
if args[i] == "--pane" and i + 1 < len(args):
sample = args[i+1]
i += 2
else:
i += 1
ws_of = None
for data in agents.values():
if data.get("pane_id") == sample:
ws_of = data.get("workspace_id")
break
if ws_of is None and ":" in sample:
ws_of = sample.split(":")[0]
members = [d for d in agents.values() if ws_of is None or d.get("workspace_id", "w1") == ws_of]
n = len(members)
for idx, data in enumerate(members):
if n == 1:
rect = {"x": 0, "y": 0, "width": 184, "height": 78}
elif n == 2:
rect = {"x": 92 * idx, "y": 0, "width": 92, "height": 78}
elif n == 3:
rect = [{"x": 0, "y": 0, "width": 92, "height": 39},
{"x": 0, "y": 39, "width": 92, "height": 39},
{"x": 92, "y": 0, "width": 92, "height": 78}][idx]
else:
rect = {"x": 92 * (idx % 2), "y": 39 * (idx // 2), "width": 92, "height": 39}
panes_list.append({"pane_id": data.get("pane_id", f"w1:p{idx+1}"), "rect": rect})
else:
count = max(len(agents), 1)
w_per_pane = 184 // count
idx = 0
for name, data in agents.items():
panes_list.append({
"pane_id": data.get("pane_id", f"w1:p{idx+1}"),
"rect": {"width": w_per_pane, "height": 78}
})
idx += 1
if not panes_list:
panes_list = [{"pane_id": "w1:p1", "rect": {"width": 184, "height": 78}}]
print(json.dumps({
+4 -2
View File
@@ -1,11 +1,11 @@
usage: registry.py [-h] [--registry-dir REGISTRY_DIR]
{register,list,get,status,update,close,get-feedback,pick,logs}
{register,list,get,status,update,close,get-feedback,pick,logs,scrub-secrets}
...
multi-agent-mux-delegate-job registry CLI
positional arguments:
{register,list,get,status,update,close,get-feedback,pick,logs}
{register,list,get,status,update,close,get-feedback,pick,logs,scrub-secrets}
register create a pending job; prints the job id
list list jobs (optionally by status)
get print one job record as JSON
@@ -17,6 +17,8 @@ positional arguments:
pick claim a pending job for a session; prints id
logs show the persistent audit log for a job, or --list
every logged job
scrub-secrets remove broker.password from every job record (Issue
#12 migration)
optional arguments:
-h, --help show this help message and exit
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Issue #12 — the broker password must not be persisted in job records.
Every sub-agent can read `.mam/jobs/<id>.json`, so `MQTT_PASSWORD` copied
there ends up in agent transcripts. The broker block now carries only
host/port/tls/username; the password is layered back from the environment
(`.mam.env`) at connect time. Records written before this rule are cleaned on
their next write and by `registry.py scrub-secrets`.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SKILLS = REPO_ROOT / ".agents" / "skills"
REGISTRY_PY = SKILLS / "multi-agent-mux-delegate-job" / "scripts" / "registry.py"
sys.path.insert(0, str(SKILLS))
from lib_py.delegation import mqtt_common, registry # noqa: E402
def _secure_env(monkeypatch):
monkeypatch.setenv("MQTT_BROKER", "broker.example.test")
monkeypatch.setenv("MQTT_USERNAME", "mam-user")
monkeypatch.setenv("MQTT_PASSWORD", "s3cret-broker-pass")
def test_registry_block_carries_no_password(monkeypatch):
_secure_env(monkeypatch)
block = mqtt_common.broker_config_from_env().to_registry_block()
assert "password" not in block
assert block["username"] == "mam-user" and block["host"] == "broker.example.test"
def test_registered_record_has_no_password_but_connects_with_it(mam_sandbox, monkeypatch):
_secure_env(monkeypatch)
rd = str(mam_sandbox / ".mam" / "jobs")
job_id = registry.register_job("issue 12", "claude-code", "herdr:claude", registry_dir=rd)
record = json.loads((Path(rd) / f"{job_id}.json").read_text())
assert "password" not in record["broker"], record["broker"]
assert "s3cret-broker-pass" not in json.dumps(record)
# The secure broker still gets its per-job HMAC key (needed by the agent's
# own publish_event to sign; documented as remaining in the record).
assert record["auth_token"]
cfg = mqtt_common.broker_config_from_job(record)
assert cfg.password == "s3cret-broker-pass", "password must come back from the environment"
assert cfg.username == "mam-user"
def test_legacy_record_is_scrubbed_on_next_write(mam_sandbox, monkeypatch):
_secure_env(monkeypatch)
rd = str(mam_sandbox / ".mam" / "jobs")
job_id = registry.register_job("legacy", "claude-code", "herdr:claude", registry_dir=rd)
path = Path(rd) / f"{job_id}.json"
record = json.loads(path.read_text())
record["broker"]["password"] = "leaked-earlier"
path.write_text(json.dumps(record))
registry.update_status(job_id, rd, "running")
after = json.loads(path.read_text())
assert after["status"] == "running"
assert "password" not in after["broker"]
def test_scrub_secrets_cli_migrates_a_registry(mam_sandbox, monkeypatch):
_secure_env(monkeypatch)
rd = str(mam_sandbox / ".mam" / "jobs")
dirty = registry.register_job("dirty", "claude-code", "herdr:claude", registry_dir=rd)
clean = registry.register_job("clean", "claude-code", "herdr:claude", registry_dir=rd)
dirty_path = Path(rd) / f"{dirty}.json"
rec = json.loads(dirty_path.read_text())
rec["broker"]["password"] = "leaked-earlier"
dirty_path.write_text(json.dumps(rec))
clean_before = (Path(rd) / f"{clean}.json").read_text()
# What the old to_registry_block wrote for every unauthenticated job: the key
# with a null value. No secret, so the migration must neither report nor
# rewrite it (a real PoC registry held ~900 of these and no password at all).
empty = registry.register_job("empty-key", "claude-code", "herdr:claude", registry_dir=rd)
empty_path = Path(rd) / f"{empty}.json"
rec_empty = json.loads(empty_path.read_text())
rec_empty["broker"]["password"] = None
empty_path.write_text(json.dumps(rec_empty))
empty_before = empty_path.read_text()
env = dict(os.environ, PYTHONPATH=f"{SKILLS}:{os.environ.get('PYTHONPATH', '')}")
dry = subprocess.run([sys.executable, str(REGISTRY_PY), "--registry-dir", rd, "scrub-secrets", "--dry-run"],
capture_output=True, text=True, env=env, cwd=str(mam_sandbox))
assert dry.returncode == 0, dry.stderr
assert f"would scrub 1 record(s)" in dry.stdout and dirty in dry.stdout
assert empty not in dry.stdout, "an empty password key is not a stored secret"
assert "leaked-earlier" in dirty_path.read_text(), "--dry-run must not write"
res = subprocess.run([sys.executable, str(REGISTRY_PY), "--registry-dir", rd, "scrub-secrets"],
capture_output=True, text=True, env=env, cwd=str(mam_sandbox))
assert res.returncode == 0, res.stderr
assert "scrubbed 1 record(s)" in res.stdout
scrubbed = json.loads(dirty_path.read_text())
assert "password" not in scrubbed["broker"]
assert scrubbed["prompt"] == "dirty" and scrubbed["auth_token"] == rec["auth_token"]
assert (Path(rd) / f"{clean}.json").read_text() == clean_before, "untouched records are not rewritten"
assert empty_path.read_text() == empty_before, "a null password key is left for the record's next write"
+22 -9
View File
@@ -326,7 +326,7 @@ def test_wrapper_idle_timeout_grace_accepts_disk_heartbeat(tmp_path):
"--prompt", "issue 14 disk heartbeat",
"--registry-dir", str(reg_dir),
"--workdir", str(tmp_path),
"--timeout", "60",
"--timeout", "90",
"--idle-timeout", "6",
],
cwd=str(tmp_path), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
@@ -341,20 +341,33 @@ def test_wrapper_idle_timeout_grace_accepts_disk_heartbeat(tmp_path):
time.sleep(0.1)
assert jid, "job was not registered"
# Heartbeats are fired without waiting for the wire publish: publish_event
# records the event in events.log before it touches the broker (ebe58f6),
# and that on-disk timestamp is what the wrapper's grace reads. Blocking on
# each publish (2-6 s against the public broker, with retries) spaced the
# disk records further apart than the 6 s idle window and the job was
# closed before the next heartbeat landed - a broker-latency flake, not a
# wrapper defect. An agent's own heartbeat timer behaves the same way.
beats = []
def heartbeat(detail: str):
subprocess.run(
beats.append(subprocess.Popen(
[sys.executable, str(PUBLISH_PY), "--registry-dir", str(reg_dir),
"--job", jid, "--event", "progress", "--detail", detail],
capture_output=True, text=True, cwd=str(tmp_path), env=env,
)
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=str(tmp_path), env=env,
))
# Subscriber idles out at ~6 s (plus broker connect); heartbeats then keep
# arriving 2 s apart, well inside the 6 s window each one re-opens (the
# wrapper's poll iteration costs ~2 s of herdr RPCs, so the ratio matters).
# Subscriber idles out at ~6 s plus the broker connect (the public broker
# has taken 5-10 s); heartbeats then keep landing 2 s apart for 20 s, well
# inside the 6 s window each one re-opens (the wrapper's poll iteration
# costs ~2 s of herdr RPCs, so the ratio matters) and long enough to
# outlast a slow connect.
time.sleep(9)
for i in range(5):
for i in range(10):
heartbeat(f"still working {i}")
time.sleep(2)
for b in beats:
b.wait(timeout=60)
done = subprocess.run(
[sys.executable, str(REGISTRY_PY), "--registry-dir", str(reg_dir),
"update", "--job", jid, "--status", "completed"],
@@ -362,7 +375,7 @@ def test_wrapper_idle_timeout_grace_accepts_disk_heartbeat(tmp_path):
)
assert done.returncode == 0, done.stderr
stdout, stderr = proc.communicate(timeout=90)
stdout, stderr = proc.communicate(timeout=150)
job_data = json.loads((reg_dir / f"{jid}.json").read_text())
assert job_data.get("status") == "completed", f"status={job_data.get('status')}\n{stdout}\n{stderr}"
assert proc.returncode == 0, f"expected rc=0 while heartbeats kept landing, got {proc.returncode}\n{stdout}\n{stderr}"
+1
View File
@@ -330,6 +330,7 @@ def test_registry_cli_contract_snapshot(mam_sandbox):
commands = set(subparsers[0].choices)
assert commands == {
"register", "list", "get", "status", "update", "close", "get-feedback", "pick", "logs",
"scrub-secrets", # Issue #12 migration
}, commands
reg = _parser_snapshot(subparsers[0].choices["register"])
assert reg["--prompt"][2] is None
+2
View File
@@ -339,6 +339,8 @@ def test_g6_every_record_writer_is_the_engine_or_a_declared_exception():
("mqtt_common.py", "_update_job_status_legacy"),
("registry.py", "_pick_pending_legacy"),
("engine.py", "transition"),
# Issue #12 migration: drops broker.password only, never status
("registry.py", "_cmd_scrub_secrets"),
}
found = set()
for path in list((SKILLS / "lib_py" / "delegation").glob("*.py")) + \
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Issue #7 — a new agent must be packed into any same-cwd workspace that still
has room in its 2xK grid; a fresh workspace is created only when all of them
are full.
The shim's `new-session` used to take the first pane whose cwd matched, which
was always the (full) first workspace, judged it overflow and opened yet
another workspace: six agents came out as 4 + 1 + 1 instead of 4 + 2.
The mock herdr reports real per-workspace geometry when the state carries
``layout_geometry: "2x2"``, so lib_py.layout sees a full workspace as
overflow and a half-empty sibling as splittable.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
CWD_AGENTS = {
# workspace w1: full 2x2 grid
"a1": ("w1", "w1:p1"), "a2": ("w1", "w1:p2"), "a3": ("w1", "w1:p3"), "a4": ("w1", "w1:p4"),
}
def _seed(state_file: Path, sandbox: Path, layout: dict) -> None:
"""layout: workspace id -> number of agents already in it (same cwd)."""
state = json.loads(state_file.read_text())
state["layout_geometry"] = "2x2"
agents = {}
workspaces = []
for ws_id, count in layout.items():
workspaces.append({"workspace_id": ws_id, "label": "default", "cwd": str(sandbox)})
for i in range(1, count + 1):
agents[f"{ws_id}-agent-{i}"] = {
"agent": "claude",
"agent_status": "idle",
"status": "running",
"workspace_id": ws_id,
"pane_id": f"{ws_id}:p{i}",
"cwd": str(sandbox),
"buffer": "Ready",
}
state["workspaces"] = workspaces
state["agents"] = agents
state_file.write_text(json.dumps(state, indent=2))
def _new_session(sandbox: Path, name: str) -> subprocess.CompletedProcess:
lib = sandbox / ".agents" / "skills" / "lib.sh"
script = f'''
source "{lib}"
_init_herdr_isolation
herdr new-session -d -s "{name}" -c "{sandbox}" "claude --dangerously-skip-permissions"
'''
return subprocess.run(["bash", "-c", script], capture_output=True, text=True, cwd=str(sandbox), timeout=180)
def _calls(state_file: Path, *prefix: str) -> list:
calls = json.loads(state_file.read_text()).get("calls", [])
return [c for c in calls if c[:len(prefix)] == list(prefix)]
def test_agent_packs_into_sibling_workspace_with_room(mam_sandbox, mock_herdr, mock_agents):
"""w1 holds 4 agents (full), w2 holds 1: the sixth agent must split w2."""
state_file = Path(mock_herdr)
_seed(state_file, mam_sandbox, {"w1": 4, "w2": 1})
res = _new_session(mam_sandbox, "sixth-creator-claude")
assert res.returncode == 0, res.stderr + res.stdout
splits = _calls(state_file, "pane", "split")
assert splits, "the sixth agent must be placed by splitting a pane"
assert all("HERDR_WORKSPACE_ID=w2" in c and "--pane" in c and c[c.index("--pane") + 1].startswith("w2:")
for c in splits), splits
assert not _calls(state_file, "workspace", "create"), "no third workspace while w2 has room"
def test_new_workspace_only_when_every_candidate_is_full(mam_sandbox, mock_herdr, mock_agents):
"""w1 and w2 both hold 4 agents: only then is a third workspace created."""
state_file = Path(mock_herdr)
_seed(state_file, mam_sandbox, {"w1": 4, "w2": 4})
res = _new_session(mam_sandbox, "ninth-creator-claude")
assert res.returncode == 0, res.stderr + res.stdout
assert not _calls(state_file, "pane", "split"), "a full grid must not be split"
assert len(_calls(state_file, "workspace", "create")) == 1
def test_idle_pane_in_a_later_workspace_is_reused(mam_sandbox, mock_herdr, mock_agents):
"""w1 is full and w2 keeps an agent-less pane behind (BUG-4 reuse): the
candidate walk must land on that idle pane - no split, no new workspace."""
state_file = Path(mock_herdr)
_seed(state_file, mam_sandbox, {"w1": 4, "w2": 1})
state = json.loads(state_file.read_text())
state.setdefault("panes", []).append({"pane_id": "w2:p_idle", "workspace_id": "w2", "cwd": str(mam_sandbox), "tab_id": "w2:t1"})
state_file.write_text(json.dumps(state, indent=2))
res = _new_session(mam_sandbox, "seventh-creator-claude")
assert res.returncode == 0, res.stderr + res.stdout
assert not _calls(state_file, "pane", "split"), "an idle pane must be reused, not split around"
assert not _calls(state_file, "workspace", "create")
starts = _calls(state_file, "agent", "start")
assert starts and "w2:p_idle" in starts[-1], starts
def test_ws_label_rename_targets_only_the_chosen_workspace(mam_sandbox, mock_herdr, mock_agents):
state_file = Path(mock_herdr)
_seed(state_file, mam_sandbox, {"w1": 4, "w2": 1})
lib = mam_sandbox / ".agents" / "skills" / "lib.sh"
script = f'''
source "{lib}"
_init_herdr_isolation
MAM_WS_LABEL="team-b" herdr new-session -d -s "eighth-creator-claude" -c "{mam_sandbox}" "claude --dangerously-skip-permissions"
'''
res = subprocess.run(["bash", "-c", script], capture_output=True, text=True, cwd=str(mam_sandbox), timeout=180)
assert res.returncode == 0, res.stderr + res.stdout
renames = _calls(state_file, "workspace", "rename")
assert renames == [["workspace", "rename", "w2", "team-b"]], renames
def test_first_workspace_with_room_still_wins(mam_sandbox, mock_herdr, mock_agents):
"""Regression guard: with a single half-empty workspace the agent lands
there, exactly as before."""
state_file = Path(mock_herdr)
_seed(state_file, mam_sandbox, {"w1": 2})
res = _new_session(mam_sandbox, "third-creator-claude")
assert res.returncode == 0, res.stderr + res.stdout
splits = _calls(state_file, "pane", "split")
assert len(splits) == 1 and "HERDR_WORKSPACE_ID=w1" in splits[0], splits
assert not _calls(state_file, "workspace", "create")
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Issue #8 — update.sh must keep the installer's fresh version.txt and
asset_hashes.txt instead of restoring the pre-update copies.
Restoring them left version.txt naming the previous commit and made remove.sh
compare against stale hashes, so every updated-but-pristine file was backed up
as "locally modified". install_state is the one file whose old keys are
merged back (gitignore_created from the original install).
"""
from __future__ import annotations
import os
import shutil
import subprocess
import tempfile
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
@pytest.fixture
def installed_workspace():
temp = tempfile.mkdtemp(prefix="mam_issue8_")
ws = Path(temp) / "workspace"
ws.mkdir()
env = os.environ.copy()
env["MAM_REPO_URL"] = str(REPO_ROOT)
env["MAM_SKIP_VENV"] = "1"
env["MAM_INSTALLER_URL"] = "file://" + str(REPO_ROOT / "deploy" / "install.sh")
res = subprocess.run(["bash", str(REPO_ROOT / "deploy" / "install.sh"), str(ws)],
env=env, capture_output=True, text=True, timeout=600)
assert res.returncode == 0, res.stderr
try:
yield ws, env
finally:
shutil.rmtree(temp, ignore_errors=True)
def test_update_keeps_fresh_version_and_hashes_and_merges_install_state(installed_workspace):
ws, env = installed_workspace
mam = ws / ".mam"
fresh_version = (mam / "version.txt").read_text()
assert "commit=" in fresh_version
# Simulate the previous install: stale stamps and an install_state key the
# re-install will not write again.
(mam / "version.txt").write_text("source=old\ncommit=stale0000\nfetched_at=then\nmethod=git\n")
(mam / "asset_hashes.txt").write_text("stale-hash-db\n")
with open(mam / "install_state", "a") as fh:
fh.write("custom_marker=kept\n")
res = subprocess.run(["bash", str(ws / ".mam_deploy" / "update.sh"), "--force"],
cwd=str(ws), env=env, capture_output=True, text=True, timeout=600)
assert res.returncode == 0, res.stderr + res.stdout
version = (mam / "version.txt").read_text()
assert "stale0000" not in version, version
assert "commit=" in version and version.strip() != ""
hashes = (mam / "asset_hashes.txt").read_text()
assert "stale-hash-db" not in hashes and hashes.strip(), "asset_hashes.txt must be the installer's fresh ledger"
state = (mam / "install_state").read_text()
assert "custom_marker=kept" in state, state
assert state.count("gitignore_created=1") == 1, "merged keys must not be duplicated:\n" + state