From e629cc137ff961c42040c4e6db277c13d5227ed5 Mon Sep 17 00:00:00 2001 From: Godopu Date: Sun, 13 Sep 2026 08:33:10 +0900 Subject: [PATCH 1/7] fix(issue-12,8,7): keep broker password out of records, keep fresh install stamps, pack sibling workspaces Issue #12 - MQTT_PASSWORD was copied into every job record, which any sub-agent can read. BrokerConfig.to_registry_block no longer persists it; broker_config_from_job layers the password back from the environment (.mam.env) at connect time, so publish_event still connects from the record. _atomic_write_record scrubs broker.password from any record it writes, and `registry.py scrub-secrets [--dry-run]` migrates an existing registry. auth_token/dispatch_token stay: the agent's own publish_event needs them. Issue #8 - update.sh restored the pre-update asset_hashes.txt and version.txt over the installer's fresh ones, so version.txt named the previous commit and remove.sh backed up every pristine updated file as "locally modified". The success path now keeps the installer's stamps and only merges install_state keys the re-install did not write (gitignore_created); the failure path still restores everything verbatim. Issue #7 - the shim's new-session took the first same-cwd pane, always found the full first workspace, judged it overflow and opened another workspace per agent (4 + 1 + 1). It now walks every same-cwd workspace in pane order and takes the first with an idle pane or room in its 2xK grid; a workspace is created only when all are full (4 + 2). Tests: test_issue7_workspace_packing.py (3; the mock herdr gains an opt-in per-workspace 2x2 geometry), test_issue8_update_state.py (1, real install+update cycle), test_issue12_registry_secrets.py (4); registry CLI snapshot and help capture refreshed for the intentional scrub-secrets subcommand; registry.md documents the broker block. Refs #12 #8 #7 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff --- .agents/skills/lib.sh | 64 +++++------ .../skills/lib_py/delegation/mqtt_common.py | 21 +++- .agents/skills/lib_py/delegation/registry.py | 29 +++++ .../multi-agent-mux-delegate-job/registry.md | 11 +- deploy/update.sh | 22 +++- tests/conftest.py | 53 +++++++-- .../fixtures/issue_5/phase1/help-registry.txt | 6 +- tests/test_issue12_registry_secrets.py | 97 +++++++++++++++++ tests/test_issue5_phase1_delegation.py | 1 + tests/test_issue5_phase2a_loop.py | 2 + tests/test_issue7_workspace_packing.py | 102 ++++++++++++++++++ tests/test_issue8_update_state.py | 66 ++++++++++++ 12 files changed, 422 insertions(+), 52 deletions(-) create mode 100644 tests/test_issue12_registry_secrets.py create mode 100644 tests/test_issue7_workspace_packing.py create mode 100644 tests/test_issue8_update_state.py diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 9815408..f860596 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -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 "") diff --git a/.agents/skills/lib_py/delegation/mqtt_common.py b/.agents/skills/lib_py/delegation/mqtt_common.py index 533a1d5..a413779 100644 --- a/.agents/skills/lib_py/delegation/mqtt_common.py +++ b/.agents/skills/lib_py/delegation/mqtt_common.py @@ -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") diff --git a/.agents/skills/lib_py/delegation/registry.py b/.agents/skills/lib_py/delegation/registry.py index d73bd15..a8c2706 100755 --- a/.agents/skills/lib_py/delegation/registry.py +++ b/.agents/skills/lib_py/delegation/registry.py @@ -333,9 +333,35 @@ 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.""" + 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 "password" in broker): + 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 +532,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 diff --git a/.agents/skills/multi-agent-mux-delegate-job/registry.md b/.agents/skills/multi-agent-mux-delegate-job/registry.md index cd902df..cbdafef 100644 --- a/.agents/skills/multi-agent-mux-delegate-job/registry.md +++ b/.agents/skills/multi-agent-mux-delegate-job/registry.md @@ -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,13 @@ 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]` cleans + a whole registry at once. `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 `/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). diff --git a/deploy/update.sh b/deploy/update.sh index 645da06..941c41c 100644 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -229,11 +229,23 @@ 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/ - fi - done + # 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 + while IFS= read -r line || [ -n "$line" ]; do + key="${line%%=*}" + [ -n "$key" ] || continue + if ! grep -q "^${key}=" .mam/install_state 2>/dev/null; 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 diff --git a/tests/conftest.py b/tests/conftest.py index 5164733..93f144d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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({ diff --git a/tests/fixtures/issue_5/phase1/help-registry.txt b/tests/fixtures/issue_5/phase1/help-registry.txt index 3d63ce0..6a660e8 100644 --- a/tests/fixtures/issue_5/phase1/help-registry.txt +++ b/tests/fixtures/issue_5/phase1/help-registry.txt @@ -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 diff --git a/tests/test_issue12_registry_secrets.py b/tests/test_issue12_registry_secrets.py new file mode 100644 index 0000000..7bb5cdc --- /dev/null +++ b/tests/test_issue12_registry_secrets.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Issue #12 — the broker password must not be persisted in job records. + +Every sub-agent can read `.mam/jobs/.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() + + 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 "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" diff --git a/tests/test_issue5_phase1_delegation.py b/tests/test_issue5_phase1_delegation.py index 32c851a..dd08c8e 100644 --- a/tests/test_issue5_phase1_delegation.py +++ b/tests/test_issue5_phase1_delegation.py @@ -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 diff --git a/tests/test_issue5_phase2a_loop.py b/tests/test_issue5_phase2a_loop.py index 3149f61..1837849 100644 --- a/tests/test_issue5_phase2a_loop.py +++ b/tests/test_issue5_phase2a_loop.py @@ -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")) + \ diff --git a/tests/test_issue7_workspace_packing.py b/tests/test_issue7_workspace_packing.py new file mode 100644 index 0000000..088f50c --- /dev/null +++ b/tests/test_issue7_workspace_packing.py @@ -0,0 +1,102 @@ +#!/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_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") diff --git a/tests/test_issue8_update_state.py b/tests/test_issue8_update_state.py new file mode 100644 index 0000000..e572e01 --- /dev/null +++ b/tests/test_issue8_update_state.py @@ -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 From 4176d7570f7adb96affd60a2e52e0a85f8ae3cb2 Mon Sep 17 00:00:00 2001 From: Godopu Date: Sun, 13 Sep 2026 11:30:33 +0900 Subject: [PATCH 2/7] release(v4.4.4): bump framework and eight skills to 4.4.4 in lockstep PATCH release for the fix/issue-12-8-7 branch (Issue #12 broker password kept out of job records, #8 fresh install stamps kept on update, #7 same-cwd workspaces packed). No code logic changes. - lib.sh MAM_VERSION, lib_py/loop/eager.py FRAMEWORK_VERSION and the eight SKILL.md frontmatters (create, stop, resume, status, monitor, delegate-job, loop, orc-onboard) move from 4.4.3 to 4.4.4 - VERSIONS.md: current release v4.4.4 (2026-09-13 KST), the eight-row skill matrix, and a v4.4.4 changelog section in the v4.4.3 format covering the password-free registry block restored from MQTT_PASSWORD at connect time, the scrub on every record write, `registry.py scrub-secrets [--dry-run]`, why auth_token/dispatch_token stay, the update.sh stamp and install_state merge rules, the sibling-workspace walk in new-session, and the 8 new tests - VERSIONS.md v4.4.3 ledger correction: its new-test count predated fae70c1, which added two purge tests; now 26 (19 + 7) with both listed - README, README.ko, BOOTSTRAP and BOOTSTRAP.ko badges and the MESSAGING.md "Applies to" header follow the current version; historical 4.4.3 mentions (v4.4.3 changelog, reviewer reports) stay as history Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe --- .agents/skills/lib.sh | 2 +- .agents/skills/lib_py/loop/eager.py | 2 +- .../skills/multi-agent-mux-create/SKILL.md | 2 +- .../multi-agent-mux-delegate-job/SKILL.md | 2 +- .agents/skills/multi-agent-mux-loop/SKILL.md | 2 +- .../skills/multi-agent-mux-monitor/SKILL.md | 2 +- .../multi-agent-mux-orc-onboard/SKILL.md | 2 +- .../skills/multi-agent-mux-resume/SKILL.md | 2 +- .../skills/multi-agent-mux-status/SKILL.md | 2 +- .agents/skills/multi-agent-mux-stop/SKILL.md | 2 +- BOOTSTRAP.ko.md | 2 +- BOOTSTRAP.md | 2 +- MESSAGING.md | 2 +- README.ko.md | 2 +- README.md | 2 +- VERSIONS.md | 37 +++++++++++++------ 16 files changed, 41 insertions(+), 26 deletions(-) diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index f860596..fb8d439 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -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}" diff --git a/.agents/skills/lib_py/loop/eager.py b/.agents/skills/lib_py/loop/eager.py index 4ebe3d7..8ffa4bb 100644 --- a/.agents/skills/lib_py/loop/eager.py +++ b/.agents/skills/lib_py/loop/eager.py @@ -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 diff --git a/.agents/skills/multi-agent-mux-create/SKILL.md b/.agents/skills/multi-agent-mux-create/SKILL.md index cc6ca7d..5af6232 100644 --- a/.agents/skills/multi-agent-mux-create/SKILL.md +++ b/.agents/skills/multi-agent-mux-create/SKILL.md @@ -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] diff --git a/.agents/skills/multi-agent-mux-delegate-job/SKILL.md b/.agents/skills/multi-agent-mux-delegate-job/SKILL.md index 079ea22..048bb57 100644 --- a/.agents/skills/multi-agent-mux-delegate-job/SKILL.md +++ b/.agents/skills/multi-agent-mux-delegate-job/SKILL.md @@ -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] diff --git a/.agents/skills/multi-agent-mux-loop/SKILL.md b/.agents/skills/multi-agent-mux-loop/SKILL.md index 8d57b6b..2c07186 100644 --- a/.agents/skills/multi-agent-mux-loop/SKILL.md +++ b/.agents/skills/multi-agent-mux-loop/SKILL.md @@ -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] diff --git a/.agents/skills/multi-agent-mux-monitor/SKILL.md b/.agents/skills/multi-agent-mux-monitor/SKILL.md index 2f946cf..7c39a27 100644 --- a/.agents/skills/multi-agent-mux-monitor/SKILL.md +++ b/.agents/skills/multi-agent-mux-monitor/SKILL.md @@ -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] diff --git a/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md b/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md index 5cf72d0..a3cc1e4 100644 --- a/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md +++ b/.agents/skills/multi-agent-mux-orc-onboard/SKILL.md @@ -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] diff --git a/.agents/skills/multi-agent-mux-resume/SKILL.md b/.agents/skills/multi-agent-mux-resume/SKILL.md index 5237b5e..b18d204 100644 --- a/.agents/skills/multi-agent-mux-resume/SKILL.md +++ b/.agents/skills/multi-agent-mux-resume/SKILL.md @@ -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 ` or `agy --conversation ` 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] diff --git a/.agents/skills/multi-agent-mux-status/SKILL.md b/.agents/skills/multi-agent-mux-status/SKILL.md index c713ee1..3b13993 100644 --- a/.agents/skills/multi-agent-mux-status/SKILL.md +++ b/.agents/skills/multi-agent-mux-status/SKILL.md @@ -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] diff --git a/.agents/skills/multi-agent-mux-stop/SKILL.md b/.agents/skills/multi-agent-mux-stop/SKILL.md index 1dbd462..9a4f0fc 100644 --- a/.agents/skills/multi-agent-mux-stop/SKILL.md +++ b/.agents/skills/multi-agent-mux-stop/SKILL.md @@ -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] diff --git a/BOOTSTRAP.ko.md b/BOOTSTRAP.ko.md index 1434d63..f5c26bf 100644 --- a/BOOTSTRAP.ko.md +++ b/BOOTSTRAP.ko.md @@ -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) 새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다. diff --git a/BOOTSTRAP.md b/BOOTSTRAP.md index 7495c57..76cb5fe 100644 --- a/BOOTSTRAP.md +++ b/BOOTSTRAP.md @@ -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. diff --git a/MESSAGING.md b/MESSAGING.md index 02fe79f..5abf578 100644 --- a/MESSAGING.md +++ b/MESSAGING.md @@ -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. diff --git a/README.ko.md b/README.ko.md index 53c7177..17d11c0 100644 --- a/README.ko.md +++ b/README.ko.md @@ -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) --- diff --git a/README.md b/README.md index 76dcb32..14dd2ee 100644 --- a/README.md +++ b/README.md @@ -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) --- diff --git a/VERSIONS.md b/VERSIONS.md index 898eca4..715e74d 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -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 아래에서 `broker.password` 를 가진 레코드만 골라 비밀번호를 지우고 재기록한다. `--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건 불변. From 0fedae2f4616c9c5751dae29d83b95a1619e55e9 Mon Sep 17 00:00:00 2001 From: Godopu Date: Sun, 13 Sep 2026 12:07:42 +0900 Subject: [PATCH 3/7] fix(issue-12): scrub-secrets selects only records with a stored password Review of 4176d75 (planner-reviewer-claude-01, N1): _cmd_scrub_secrets chose records by the presence of broker.password, not by its value. The old to_registry_block wrote "password": null even for an unauthenticated broker, so on this repo's registry (901 of 903 records carry the null key, none a real password) --dry-run reported "would scrub 901 record(s)" and a real run would rewrite every one of them while holding the registry lock. - registry.py: select on broker.get("password"); an empty key is no secret, and _atomic_write_record drops it on the record's next write anyway - tests: the migration test gains a legacy "password": null record that must be neither listed nor rewritten (fails on 4176d75 with "would scrub 2") - VERSIONS.md v4.4.4 item 2 and registry.md now describe the stored-value rule Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe --- .agents/skills/lib_py/delegation/registry.py | 10 ++++++++-- .../skills/multi-agent-mux-delegate-job/registry.md | 5 +++-- VERSIONS.md | 2 +- tests/test_issue12_registry_secrets.py | 11 +++++++++++ 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.agents/skills/lib_py/delegation/registry.py b/.agents/skills/lib_py/delegation/registry.py index a8c2706..189ecb8 100755 --- a/.agents/skills/lib_py/delegation/registry.py +++ b/.agents/skills/lib_py/delegation/registry.py @@ -345,13 +345,19 @@ def _build_parser() -> argparse.ArgumentParser: 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.""" + 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 "password" in broker): + if not (isinstance(broker, dict) and broker.get("password")): continue found.append(record.get("job_id", "?")) if not args.dry_run: diff --git a/.agents/skills/multi-agent-mux-delegate-job/registry.md b/.agents/skills/multi-agent-mux-delegate-job/registry.md index cbdafef..907cb04 100644 --- a/.agents/skills/multi-agent-mux-delegate-job/registry.md +++ b/.agents/skills/multi-agent-mux-delegate-job/registry.md @@ -57,8 +57,9 @@ Reference implementation: [`./scripts/registry.py`](./scripts/registry.py) 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]` cleans - a whole registry at once. `auth_token` and `dispatch_token` stay in the record + 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 `/events`. diff --git a/VERSIONS.md b/VERSIONS.md index 715e74d..18e1a6e 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -43,7 +43,7 @@ #### 🌟 주요 개선 사항 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 아래에서 `broker.password` 를 가진 레코드만 골라 비밀번호를 지우고 재기록한다. `--dry-run` 은 대상 잡 목록만 출력하고 아무것도 바꾸지 않는다. `registry.md` 가 브로커 블록 규칙(비밀번호 비저장, 환경에서 복원, 토큰 유지 이유)을 문서화한다. +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). diff --git a/tests/test_issue12_registry_secrets.py b/tests/test_issue12_registry_secrets.py index 7bb5cdc..16142f5 100644 --- a/tests/test_issue12_registry_secrets.py +++ b/tests/test_issue12_registry_secrets.py @@ -79,12 +79,22 @@ def test_scrub_secrets_cli_migrates_a_registry(mam_sandbox, monkeypatch): 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"], @@ -95,3 +105,4 @@ def test_scrub_secrets_cli_migrates_a_registry(mam_sandbox, monkeypatch): 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" From ea911d7842161fec0055f2b496bb7a672587c1e1 Mon Sep 17 00:00:00 2001 From: Godopu Date: Sun, 13 Sep 2026 12:15:26 +0900 Subject: [PATCH 4/7] fix(issue-8,7): harden install_state merge and pin sibling-workspace edge cases Round-1 review of 4176d75 (planner-reviewer-claude-01, non-blocking): - update.sh: merge install_state by exact key match (awk, no regex interpretation of the key), skip lines without '=', and terminate a fresh install_state that lacks a final newline before appending - tests (#7): an agent-less pane left behind in a later same-cwd workspace is reused instead of splitting or creating; MAM_WS_LABEL renames only the workspace that was chosen Refs #8 #7 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff --- deploy/update.sh | 12 ++++++++- tests/test_issue7_workspace_packing.py | 34 ++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/deploy/update.sh b/deploy/update.sh index 941c41c..48b1836 100644 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -238,10 +238,20 @@ if [ $HAS_MAM -eq 1 ]; then # 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 while IFS= read -r line || [ -n "$line" ]; do + case "$line" in + *=*) ;; + *) continue ;; # not a key=value line + esac key="${line%%=*}" [ -n "$key" ] || continue - if ! grep -q "^${key}=" .mam/install_state 2>/dev/null; then + # 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 diff --git a/tests/test_issue7_workspace_packing.py b/tests/test_issue7_workspace_packing.py index 088f50c..0c49f50 100644 --- a/tests/test_issue7_workspace_packing.py +++ b/tests/test_issue7_workspace_packing.py @@ -88,6 +88,40 @@ def test_new_workspace_only_when_every_candidate_is_full(mam_sandbox, mock_herdr 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.""" From 9d52bb702476da22adfe811fc41d1d73851f4f55 Mon Sep 17 00:00:00 2001 From: Godopu Date: Sun, 13 Sep 2026 12:47:31 +0900 Subject: [PATCH 5/7] docs(reports): archive v4.4.4 PASS review reports from claude, agy and grok (Issues #12, #8, #7) Second-round reports of the fix/issue-12-8-7 review loop on ea911d7: planner-reviewer-claude-01 (b368210a), reviewer-agy-01 (3f5d1cd4) and reviewer-grok-01 (cc059645), all [VERDICT: PASS]. The codex review is deferred until its usage limit resets; the branch merges only after it. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff --- .../report-b368210a.md | 144 +++++++++++++++++ .../reviewer-agy-01/report-3f5d1cd4.md | 151 ++++++++++++++++++ .../reviewer-grok-01/report-cc059645.md | 62 +++++++ 3 files changed, 357 insertions(+) create mode 100644 .agents/reports/planner-reviewer-claude-01/report-b368210a.md create mode 100644 .agents/reports/reviewer-agy-01/report-3f5d1cd4.md create mode 100644 .agents/reports/reviewer-grok-01/report-cc059645.md diff --git a/.agents/reports/planner-reviewer-claude-01/report-b368210a.md b/.agents/reports/planner-reviewer-claude-01/report-b368210a.md new file mode 100644 index 0000000..096c18e --- /dev/null +++ b/.agents/reports/planner-reviewer-claude-01/report-b368210a.md @@ -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 ``: **k_real**", 네 파일 모두 바이트 동일 | +| 실제 실행 | rc 0, "scrubbed 1 record(s) under ``: 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] diff --git a/.agents/reports/reviewer-agy-01/report-3f5d1cd4.md b/.agents/reports/reviewer-agy-01/report-3f5d1cd4.md new file mode 100644 index 0000000..f7248da --- /dev/null +++ b/.agents/reports/reviewer-agy-01/report-3f5d1cd4.md @@ -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] diff --git a/.agents/reports/reviewer-grok-01/report-cc059645.md b/.agents/reports/reviewer-grok-01/report-cc059645.md new file mode 100644 index 0000000..ced2330 --- /dev/null +++ b/.agents/reports/reviewer-grok-01/report-cc059645.md @@ -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] From fbe27319802a8403ed21691ba59a2c3d6e5c5319 Mon Sep 17 00:00:00 2001 From: Godopu Date: Sun, 13 Sep 2026 13:24:30 +0900 Subject: [PATCH 6/7] test(issue-14): decouple the disk-heartbeat wrapper test from broker latency The test blocked on each publish_event heartbeat, and against the public broker a publish (with retries) takes 2-6 s, so consecutive events.log records landed further apart than the 6 s idle window and the wrapper closed the job before the next heartbeat - a broker-latency flake that failed the full suite once and passed on re-run. Heartbeats are now fired without waiting for the wire publish (the disk record is written before the broker is touched, ebe58f6) and keep coming for 20 s to outlast a slow connect; the wall clock is widened to match. 3/3 green in a row afterwards. Refs #14 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff --- tests/test_issue14_delivery_ack.py | 31 +++++++++++++++++++++--------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/tests/test_issue14_delivery_ack.py b/tests/test_issue14_delivery_ack.py index a21042f..be0e967 100644 --- a/tests/test_issue14_delivery_ack.py +++ b/tests/test_issue14_delivery_ack.py @@ -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}" From c714323dcff84b50d8c3bd27bf4a1dfbccb8f23a Mon Sep 17 00:00:00 2001 From: Godopu Date: Sun, 13 Sep 2026 15:00:05 +0900 Subject: [PATCH 7/7] docs(reports): archive v4.4.4 codex PASS review report (Issues #12, #8, #7) Reviewed HEAD fbe2731 against base 8a09e02; 242 selected tests passed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff --- .../report-737157bc.md | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .agents/reports/planner-reviewer-codex-01/report-737157bc.md diff --git a/.agents/reports/planner-reviewer-codex-01/report-737157bc.md b/.agents/reports/planner-reviewer-codex-01/report-737157bc.md new file mode 100644 index 0000000..f398d19 --- /dev/null +++ b/.agents/reports/planner-reviewer-codex-01/report-737157bc.md @@ -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]