78 Commits
Author SHA1 Message Date
Antigravity e4b1fb3329 fix(skills): support tmux -S flag in capture-pane shim of lib.sh 2026-07-20 23:44:02 +09:00
Antigravity 6378471702 docs(rules): migrate MULTI_AGENT_RULES from tmux to herdr and archive reviewer reports 2026-07-20 12:30:51 +09:00
Antigravity 974941bdb4 fix(deploy): migrate deployment scripts and docs from tmux to herdr 2026-07-20 12:17:12 +09:00
Antigravity c00fbb1356 docs: add implementation plan for deploy/ tmux to herdr migration by planner claude 2026-07-20 12:07:42 +09:00
Antigravity 87bb2780ac refactor: rename resolve_herdr_workspace to resolve_herdr_session and use herdr_session database field consistently 2026-07-20 11:13:17 +09:00
Antigravity daa1476714 fix: add empty marker_norm guard in send_keys_safe to avoid always-true grep matches 2026-07-20 10:41:47 +09:00
Antigravity 087a294135 fix: use setsid for herdr server bootstrap to prevent early termination when parent shell exits 2026-07-20 10:36:49 +09:00
Antigravity 90afd45aba fix: remove dead proxy variables from isolation environment prefix 2026-07-20 09:47:45 +09:00
Antigravity 336aa5fd9d fix: resolve review blockers (D1, D2, D3) and reconcile.sh subprocess bugs 2026-07-20 09:32:58 +09:00
Antigravity e2b3ee7e82 test: update test_create_isolation_env_prefix assertion for proxy variables 2026-07-20 09:25:12 +09:00
Antigravity d7fa9af410 refactor: complete tmux-to-herdr migration review and enhance workspace reuse logic 2026-07-20 09:21:25 +09:00
Godopu efadc231fb Delete Flutter-based multi-agent-mux-ui folder and root Melos/Flutter config files 2026-07-20 07:58:43 +09:00
Godopu 3a6e4da1a3 Fix mock pane close command in conftest and resolve pane_id collisions in E2E Scenario 5 tests 2026-07-20 07:48:17 +09:00
Godopu 6df4b03661 Align conftest mock herdr and unit test assertions with Claude's simplified session-based isolation and native herdr command updates 2026-07-20 07:42:05 +09:00
GodopuandClaude Fable 5 cccc30a8ac chore: gitignore Flutter/Dart build artifacts and IDE files, untrack committed .dart_tool
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 20:38:04 +09:00
Godopu ff7a2873f9 Implement full E2E, integration, component, and unit tests, and resolve all leftover tmux-to-herdr issues in UI and core scripts 2026-07-19 20:05:48 +09:00
Godopu 30e606b0fa Clean up all tmux occurrences from dev skills and integrate herdr wrapper translation shim with dynamic env and workspace ID parsing 2026-07-19 16:57:00 +09:00
Godopu 42b54d7643 Migrate backend to herdr using a seamless translation shim wrapper 2026-07-19 16:22:58 +09:00
Godopu 507ac1847b docs: add PLAN_HERDR.md and herdr_docs.md reference file 2026-07-19 16:18:09 +09:00
Godopu 945edbe837 docs: archive macOS Keychain and Preflight bypass review reports from cline and claude 2026-07-18 23:29:55 +09:00
Godopu 36b3910ff2 fix(mac-compat): bypass keyring auth check hang and link macOS Library/Keychains to isolated home 2026-07-18 23:23:40 +09:00
Godopu fc24af4683 docs: archive macOS compatibility review reports from cline and claude 2026-07-18 22:37:15 +09:00
Godopu f79fd99de7 fix(mac-compat): resolve absolute path of agent binary and strip macos quarantine attribute to prevent gatekeeper and path-resolution timeouts 2026-07-18 22:26:23 +09:00
Godopu 31ca11c57d docs: archive planning/review reports and issue report from previous multi-agent-mux-loop runs 2026-07-17 21:33:31 +09:00
Godopu b45649de76 feat(delegate-job): support role aliases mapping and run delegate job safely using isolated temp copy with signal cleanups 2026-07-17 17:07:30 +09:00
Godopu 5a6cb91fb0 feat(delegate-job): support --role parameter in submit and update commands to resolve role suitability mismatches 2026-07-17 15:59:14 +09:00
Godopu be46484108 fix(loop): fix wait_for_job hang bug and finalize Creator Self-Planning documentation 2026-07-17 15:51:37 +09:00
Godopu 6c903420d8 fix(skill): resolve hardcoded planner session name and plan file paths dynamically in run_loop.sh 2026-07-16 23:48:13 +09:00
Godopu f85fdfc1f9 docs(skill): genericize multi-agent-mux-loop SKILL manual by replacing hardcoded agent session names with placeholders 2026-07-16 23:46:27 +09:00
Godopu 52c270eea7 docs(skill): update multi-agent-mux-loop SKILL manual to reflect skipped planning mode when --plan is omitted 2026-07-16 23:43:53 +09:00
Godopu 7c94eefb6d docs: add development plan for MAM Web PTY WebSocket Bridge architecture 2026-07-16 23:34:23 +09:00
Godopu f0e2bd26c2 fix(ui): correct libc symbol lookup for direct _exit syscall to achieve async-signal-safety 2026-07-16 23:27:55 +09:00
Godopu 7f1a7e5a50 fix(ui): enforce async-signal-safe exit, blocking waitpid reaping, and unsetenv env isolation inside child PTY process 2026-07-16 23:20:59 +09:00
Godopu a6e4dc97a4 fix(ui): prevent Dart event loop freezing by switching master PTY fd to non-blocking mode via fcntl 2026-07-16 23:19:24 +09:00
Godopu 7781e797aa fix(ui): implement async-signal-safe fork process layout and waitpid child zombie reaping for PTY 2026-07-16 23:11:37 +09:00
Godopu f52f6eb2af fix(ui): resolve M2 blocking bugs with full POSIX fork/exec PTY spawn and tmux environment isolation 2026-07-16 22:56:19 +09:00
Godopu b7901bcce5 feat(ui): complete M2 Milestone - Desktop POSIX PTY FFI implementation and attach terminal tab integration 2026-07-16 22:50:59 +09:00
Godopu 7e4cab6c09 fix(ui): expose stale banner under cold-start failures when no successful snapshot exists 2026-07-16 21:21:07 +09:00
Godopu 7c981549a2 fix(ui): implement cached startup pre-flight check in SessionService and propagate errors in StaleBanner 2026-07-16 21:18:14 +09:00
Godopu 2eb85866b3 feat(ui): complete M1 Milestone - read-only Dashboard and Detail Pane with status.sh integration 2026-07-16 21:09:31 +09:00
Godopu 7d22774d76 feat(loop): allow run_loop.sh to automatically load existing plan if --plan is omitted 2026-07-16 20:54:38 +09:00
Godopu 9778f38d9f feat(ui): scaffold multi-agent-mux-ui monorepo structure with Flutter and Melos configurations 2026-07-16 17:30:03 +09:00
Godopu 39be8d7b43 docs(resume): remove obsolete, hardcoded resume_all.sh script 2026-07-16 15:42:08 +09:00
GodopuandClaude Sonnet 5 35fc44f269 fix(loop): repair review-diff/verdict-parsing bugs, deduplicate session lookups
Applies the P0-P3 fixes from the multi-agent-mux-loop audit
(.mam/jobs/ab686e47/claude-reports/report-final.md):

- P0-1: capture BASE_COMMIT before Phase 2 and diff against it, so reviewer
  diffs stay non-empty and cumulative even after the Creator commits per the
  documented DoD (bare `git diff` alone showed nothing once committed).
- P0-2: has_verdict now matches only the report's last non-blank line, so a
  stray [VERDICT: ...] token quoted mid-report as a formatting example can no
  longer flip the outcome.
- P1-1: replace the English-only refactor/complex/design/architect keyword
  sniff (dead code against Korean-language reviewer reports) with an explicit
  [ESCALATE: PLANNER] tag the reviewer prompt now asks for.
- P1-2: resolve_all_reviewers/resolve_agent_type/resolve_planner_session now
  read through lib.sh's load_state_json single source of truth instead of
  each hand-rolling its own SQLite+YAML lookup; resolve_agent_type's name
  fallback matches exact hyphen segments instead of a substring `in` check.
- P2-1: warn when --all-reviewer and --reviewer are both given, since the
  latter is silently discarded.
- P2-2: correct the SKILL.md CLI-mapping table row that overstated an
  automated lint gate and an unconditional Planner feedback loop.
- P3: fix lib.sh shellcheck SC2164 (unguarded cd in start_watchdog) and
  annotate the intentional SC2317 dual source/exec guard.

Verified: shellcheck clean on both scripts, bash -n syntax OK, and the
rewritten has_verdict/resolve_* functions were unit-tested against this
repo's live .mam/agent-sessions state.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-16 13:50:25 +09:00
Godopu 7fde1d2b8a docs(loop): merge multi_agent_workflow.md into SKILL.md, drop duplicate 2026-07-16 13:36:12 +09:00
Godopu 626f35adfc refactor: harden run_loop.sh verdict parser, add atomic promotion, and revise multi_agent_workflow.md guidelines 2026-07-16 12:57:56 +09:00
Godopu 230e262414 feat(stop): fully purge registry entries on stop --purge-conversation and add zombie pre-gate 2026-07-16 12:06:37 +09:00
Godopu dfdbf69187 fix(loop): correct self-relative REPO_ROOT depth in run_loop.sh 2026-07-16 12:06:37 +09:00
Godopu 9d0fbaec40 fix(deploy): remove deleted creator-claude agent from resume_all.sh 2026-07-16 11:29:57 +09:00
Godopu a671dfbb9d docs(deploy): append git repository recommendation to INSTALL.md 2026-07-16 11:25:50 +09:00
Godopu 91cd08b139 docs(deploy): add multi-agent-mux-loop quick start guide to INSTALL.md 2026-07-16 10:40:16 +09:00
Godopu 0f970b97fa fix(loop): address planner and reviewer architecture defects
- Fix B-1: Correct Mermaid sequence diagram syntax (fi -> end) in SKILL.md and PLAN_LOOP.md
- Fix B-2: Force target agent exclusion from active reviewers in run_loop.sh and correct creator session role in registry
- Fix B-3 & B-4: Integrate WAIT_TIMEOUT deadline inside wait_for_job
- Fix M-3: Update CHANGES_DIFF to use dynamic cumulative git diff
- Fix M-2: Resolve verdict string parsing and substring collisions
2026-07-16 08:28:57 +09:00
Godopu bacf139447 feat(skills): add multi-agent-mux-loop autonomous loop orchestrator
Implement a new autonomous planning-execution-review orchestration loop
supporting:
- Collaborative planning & creator-planner challenge discussions (--plan-talk)
- Self-planning and self-review fallbacks
- Custom reviewer target list and all-reviewer unanimous PASS verdicts
- API-cost runaway safety limits via --max-loop

Integrated inside deploy/install.sh checklist and deploy/gitea-ci.yml.
2026-07-16 08:12:24 +09:00
Godopu ff01984a69 docs(workflow): clarify agent standby behavior after PASS verdict
Update the multi-agent workflow guidelines to explicitly specify that
agent sessions should not be stopped automatically upon receiving a PASS
verdict. Instead, they must remain in a standby (running) state, awaiting
further task instructions from the user, consistent with the project's
lifecycle charter.
2026-07-12 23:18:30 +09:00
Godopu 787fe58298 fix(skills): robustify load_state_json against lone surrogates
Implement a clean_surrogates helper inside lib.sh's load_state_json function
to recursively replace lone surrogates (e.g. from partial TUI screen dumps)
with replacement chars before printing, preventing UnicodeEncodeError on stdout.
2026-07-12 17:19:44 +09:00
Godopu f8675ab377 docs(roadmap): sync FW-D4 path and lint count in FUTURE_WORKS
Correct scripts/generate-env.sh -> deploy/generate-env.sh and update shellcheck unlinted count in both English and Korean versions, as eb733cf already resolved the generate-env.sh lint gap.

Addresses Reviewer Claude's non-blocking nit.
2026-07-12 16:59:58 +09:00
Godopu eb733cf7c1 refactor(deploy): consolidate install scripts and INSTALL.md into deploy/
- Move scripts/install_mam.sh → deploy/install_mam.sh (local-clone installer)
- Move scripts/generate-env.sh → deploy/generate-env.sh (env helper)
- Move .agents/INSTALL.md → deploy/INSTALL.md (user manual)
- Update install_mam.sh to copy INSTALL.md + generate-env.sh from new paths
- Ship INSTALL.md via deploy/install.sh remote path too (manifest-tracked)
- Update README/BOOTSTRAP generate-env.sh references & repository ASCII layout
- Extend deploy/README.md structure section; extend gitea-ci.yml lint list
- Remove now-empty scripts/ directory
- Fix duplicate ### 2. subsection headers in deploy/README.md (address B-2)
- Correct deploy/INSTALL.md dependency description to match actual checks (address M-1)
- Add local-clone lifecycle caveat (no update.sh/remove.sh) (address M-2)

Closes the deploy consolidation plan and addresses Planner / Reviewer feedback.
2026-07-12 16:30:15 +09:00
Godopu 2d2510f391 refactor(lib): add robust TUI readiness tokens for cline
- Include 'What can I do' and 'slash commands' to grep search pattern
- Avoid false-positive timeouts when welcome screen branding logo is skipped
2026-07-12 15:44:01 +09:00
Godopu dc1a2718d1 refactor(lib): extend wait_for_tui_ready timeout to 30 seconds
- Allow slow-starting node standalone CLI processes to boot without false-positive timeouts
- Mitigate disk I/O constraints on isolated DB provisioning
2026-07-12 15:43:04 +09:00
Godopu 742e71b784 refactor(lib): map cline isolation paths directly to root without data subfolder
- Align symlink and copy paths directly under isolation root (no data/ intermediate directory)
- Correctly restore global settings and SQLite databases for standalone CLI execution
2026-07-12 15:42:21 +09:00
Godopu f1e754d73e refactor(lib): physically copy sqlite DBs instead of symlinking for cline
- Prevent sqlite DB locking errors across concurrent isolated sessions
- Use cp -p for files inside data/db/ during provision_isolation
2026-07-12 15:41:59 +09:00
Godopu a57ce0a1b8 refactor(lib): seed db/ subfolder for cline isolation to preserve oauth and provider states
- Symlink all database files inside data/db/ under isolation root
- Prevent cline CLI from bouncing back to the initial Welcome provider selection screen
2026-07-12 15:41:17 +09:00
Godopu ae8ea9b939 refactor(lib): target correct data subfolder for cline isolation seeding
- Place data/settings/ and data/globalState.json inside isolation root
- Align directories with cli --data-dir internal structure to prevent startup authentication crashes
2026-07-12 15:35:25 +09:00
Godopu 559240f3c8 refactor(create): force state isolation by default for new sessions
- Update default ISOLATE value to 1 in create_session.sh
- Add --no-isolate option to allow opting out of directory isolation if desired
- Keep --isolate flag for legacy syntax compatibility
2026-07-12 15:34:00 +09:00
Godopu ed126ba37f docs(install): drop broken PATH export and un-escape $UUID in resume guide
- Fix R-1 bug by dropping PATH='$PATH' to prevent PATH environment variable corruption
- Allow calling shell to expand $UUID inline prior to tmux spawn
2026-07-12 15:19:51 +09:00
Godopu 9182d89dbd docs(install): address R-1, R-2, and R-3 feedback in resume guidelines
- Align step label languages to Korean (R-2)
- Reintroduce -r $UUID and --dangerously-skip-permissions to claude spawn (R-1)
- Add isolation environment path mapping caveats pointing back to authorative SKILL.md (R-3)
2026-07-12 15:17:31 +09:00
Godopu 875740788a docs(install): clarify install pre-requisites and concrete resume command examples
- Add source repo clone pre-requisite statement in INSTALL.md section 2
- Incorporate concrete tmux new-session and update_yaml_resumed commands in INSTALL.md section 3
2026-07-12 14:52:30 +09:00
Godopu 49176b43b6 refactor(lib): hash entire session dictionary to widen YAML write-gate
- Prevent YAML<->SQLite sync drift on completed jobs (R-1 Option A)
- Hash session dict to trigger YAML rewrite on any field updates
2026-07-12 14:37:42 +09:00
Godopu 60f3af4af9 refactor(monitor): keep sessions alive on job completion, kill only on errors
- Modify reconcile.sh mutation logic to clear delegate_job_id instead of calling tmux kill-session on 'completed' events
- Retain process termination behavior on 'error' events for safety
2026-07-12 14:24:02 +09:00
Godopu 288132c7ba refactor(install): port venv bootstrap, config tools and gitignore exclusions to installer
- Port .venv creation and dependency pip install bootstrap sequence from deploy/install.sh into scripts/install_mam.sh
- Include .env.example and scripts/generate-env.sh copies under rsync target deployment
- Add .venv/ to target gitignore list to prevent virtualenv bloating
- Add empty-UUID resume safety guard in INSTALL.md
2026-07-12 14:15:04 +09:00
Godopu 382c314d5e refactor(rules): resolve loop report paths colon-safety and terminology gaps
- Sanitize clean_session in loop path report-final.md Output Report Path to prevent colon characters in folders
- Reconcile automated reports path token in MULTI_AGENT_RULES.md and .ko.md from <agent-session> to <agent_name> or <clean_session_name>
2026-07-12 13:57:04 +09:00
Godopu 65843e0557 docs(rules): update multi-agent rules with job-centric structure pointers
- Document automated job brief path under .mam/jobs/<job_id>/brief.md
- Document automated report redirection under .mam/jobs/<job_id>/<agent-session>-reports/report-final.md
- Correct stop_session.sh report cleanup claim to reflect manual cleanup
- Clarify onboarding brief mechanism under onboarding handshake protocol
2026-07-12 13:50:30 +09:00
Godopu 6186673fb2 chore(cleanup): remove obsolete brief markdown files from reports 2026-07-12 13:30:11 +09:00
Godopu d776273ae9 Revert "chore(cleanup): remove obsolete DONE.md task tracker file"
This reverts commit e305bcb083.
2026-07-12 13:29:37 +09:00
Godopu e305bcb083 chore(cleanup): remove obsolete DONE.md task tracker file 2026-07-12 13:23:28 +09:00
Godopu e44b587c2e chore(cleanup): remove handoff.md and clean temporary test job artifacts
- Remove handoff.md as the optimization is fully merged and verified
- Clean up temporary test jobs and logs from .mam/jobs/
2026-07-12 13:19:09 +09:00
Godopu 35af8e33a2 feat(skills): implement state loader DRY (OP-5) and Job-centric directory structure
- Extract load_state_json centralized helper inside lib.sh to unify state querying
- Refactor status, resume, stop, and monitor scripts to fetch state via MAM_STATE_JSON env var to avoid stdin pipeline collisions
- Restructure delegate-job to provision .mam/jobs/<job_id>/brief.md and direct agents to it, minimizing token size and preventing TUI paste freezes
- Harden send_keys_safe submission loop with was_popup state capture and edge case guards, preventing timing spin false-positives
- Passed cross-verification approved PASS from Planner Claude session
2026-07-12 11:50:38 +09:00
Godopu 76ec0dd929 fix(skills): support automatic bypass of large-session resume warning dialogs
- Add 'Resuming the full session' and 'Resume from summary' patterns to TUI validation constants
- Update handle_startup_dialogs to automatically submit the choice on large-session warnings, preventing start/resume lockups
2026-07-12 10:27:59 +09:00
78 changed files with 7738 additions and 1049 deletions
+15 -12
View File
@@ -1,6 +1,6 @@
# MULTI_AGENT_RULES.md
본 문서는 새로운 프로젝트에 **MQTT 메시징 백플레인 및 Tmux 기반 멀티 에이전트 오케스트레이션 워크플로우**를 도입하고, 협업하는 에이전트들이 일관된 규칙과 아키텍처에 따라 안전하고 견고하게 작업을 수행할 수 있도록 정의한 공통 지침 및 규약입니다.
본 문서는 새로운 프로젝트에 **MQTT 메시징 백플레인 및 Herdr 기반 멀티 에이전트 오케스트레이션 워크플로우**를 도입하고, 협업하는 에이전트들이 일관된 규칙과 아키텍처에 따라 안전하고 견고하게 작업을 수행할 수 있도록 정의한 공통 지침 및 규약입니다.
새로운 프로젝트에서 작업하는 모든 에이전트는 작업을 시작하기 전 이 문서를 반드시 정독하고 규약을 준수해야 합니다.
@@ -54,7 +54,7 @@
### 🗃️ 레지스트리 및 상태 관리
- 본 아키텍처는 목적에 따라 두 가지 레지스트리를 분리하여 운영합니다:
- **잡 레지스트리 (Job Registry)**: 각 비동기 잡의 메타데이터와 생명주기는 개별 JSON 파일(`.mam/jobs/<id>.json`)로 기록되며, 다중 세션 간의 동시 청구(claiming) 경합은 파일 단위의 `fcntl` advisory lock(`registry_lock` via `registry.py`)을 통해 방어합니다.
- **세션 레지스트리 (Session Registry)**: TMUX 모니터링 상태 및 에이전트 구동 정보는 SQLite WAL 데이터베이스(`.mam/agent-sessions.db`)를 통해 단일 호스트 내에서 안정적인 동시 트랜잭션으로 일관되게 제어합니다. 단, SQLite WAL 모드는 NFS(네트워크 파일 시스템) 환경에서는 완전한 파일 락이 보장되지 않으므로 로컬 파일 시스템 사용을 권장합니다.
- **세션 레지스트리 (Session Registry)**: Herdr 모니터링 상태 및 에이전트 구동 정보는 SQLite WAL 데이터베이스(`.mam/agent-sessions.db`)를 통해 단일 호스트 내에서 안정적인 동시 트랜잭션으로 일관되게 제어합니다. 단, SQLite WAL 모드는 NFS(네트워크 파일 시스템) 환경에서는 완전한 파일 락이 보장되지 않으므로 로컬 파일 시스템 사용을 권장합니다.
### 🛡️ 보안 프로토콜 (HMAC-SHA256)
- **무인증 PoC 모드**: 잡 레지스트리 생성 시 `auth_token``null`로 지정된 경우(PoC 기본 모드), 별도의 서명 검증을 생략하고 모든 이벤트를 수용합니다 (`verify_hmac`이 항상 `True`를 반환).
@@ -113,21 +113,23 @@ sequenceDiagram
장기 실행 에이전트 분석 중 발생하는 유실 및 인프라적 장애를 예방하기 위한 중요 지침입니다.
### 📸 TUI 뷰포트 절단 방지 (Pane Snapshotting 3대 규칙)
TMUX 환경에서 실행되는 에이전트가 화면 스크롤 한계로 인해 이전 출력이나 장문의 디버깅 로그를 잃지 않도록 아래의 **스냅샷 패턴을 의무적으로 수행**합니다.
Herdr 환경에서 실행되는 에이전트가 화면 스크롤 한계로 인해 이전 출력이나 장문의 디버깅 로그를 잃지 않도록 아래의 **스냅샷 패턴을 의무적으로 수행**합니다.
1. **Pre-brief Capture**: 작업 지침(Brief)을 전송한 직후, 즉시 해당 세션의 pane을 캡처(`capture-pane -S -200`)해두어 입력 기록의 시작점을 백업합니다.
2. **Loop Snapshot**: 장기 실행(5분 이상) 중인 에이전트 세션의 경우, 주기적으로(예: 30초마다) 뷰포트를 스캔하여 증분 데이터를 `/tmp/pane-snap.txt`에 계속 누적(append) 기록합니다.
3. **Post-job Capture**: 잡 완료/에러 반환 즉시 전체 pane 상태를 마지막으로 캡처하여 전체 작업 궤적을 보존합니다.
### 📄 마크다운 기반 협업 및 결과 전달 (Markdown-Based Workflow & Communication)
- **핵심 원칙**: TMUX `send-keys`나 입력 버퍼를 통해 긴 지시사항을 직렬로 입력하는 과정에서 문자 누락이나 레이아웃 유실이 발생하는 것을 방지하기 위해, 에이전트 간의 모든 주요 협업 소통은 파일 기반 마크다운 문서 생성을 원칙으로 합니다.
- **핵심 원칙**: herdr `send-keys`나 입력 버퍼를 통해 긴 지시사항을 직렬로 입력하는 과정에서 문자 누락이나 레이아웃 유실이 발생하는 것을 방지하기 위해, 에이전트 간의 모든 주요 협업 소통은 파일 기반 마크다운 문서 생성을 원칙으로 합니다.
- **세부 규칙 및 규약**:
- **예외 사항**: 1~2줄 내외의 매우 단순한 재평가 요청, 상태 확인, 수락 진행 등의 단발성 프롬프트는 기존처럼 tmux 입력을 통해 직접 보낼 수 있습니다.
- **작업 위임**: 상세 사양과 계획 수립 등의 복잡한 작업 지시는 먼저 로컬 마크다운 파일(예: `.mam/reports/brief-<job_id>.md` 또는 지정된 워크스페이스 경로)로 작성한 후, 에이전트에게 `"Read <파일경로> and execute."` 라는 단순화된 실행 명령만 전달하십시오.
- **결과 안내 및 피드백**: 상세 리뷰 결과, 설계 제안서, 구현 완료 리포트 및 감사 주석 등은 반드시 마크다운 파일로 영속화하여 제공해야 합니다. 기계 전용인 잡 레지스트리 평면(`.mam/jobs/`)과의 물리적 분리를 위해, 모든 에이전트는 `.mam/reports/<tmux_session_name>/` 아래에 전용 디렉터리를 형성하고 결과 보고 마크다운 파일을 저장해야 합니다 (여기서 `<tmux_session_name>``.mam/agent-sessions.yaml``name` 필드와 완전히 일치해야 합니다. 예: `.mam/reports/<workspace_slug>-creator-<agent>/report-<job_id>.md`). 수신 에이전트는 잘린 터미널 화면 캡처에 의존하는 대신 디스크에서 해당 파일을 직접 로드하여 확인합니다.
- **디스크 정리 및 보존 정책 계약 (Cleanup & Retention)**:
- `.mam/reports/` 폴더 아래의 파일들은 감사 이력(audit-trail) 산출물로 보존됩니다.
- 해당 격리 디렉터리들은 `stop_session.sh` 등을 통해 세션이 정상적으로 종료되거나 파기(`--purge-conversation`)될 때 자동으로 함께 정리되어야 합니다.
- 버전 관리가 필요한 영구 보존용 주요 산출물(최종 설계 계획, 최종 리뷰 보고서, 보안 감사 리포트 등)은 gitignore 대상인 `.mam/` 하위가 아닌, 버전 관리 대상 경로(구체적으로 `.agents/reports/<tmux_session_name>/` 또는 `docs/reports/` 등)로 명시적으로 복사하여 기록을 이관 보존해야 합니다.
- **예외 사항**: 1~2줄 내외의 매우 단순한 요청, 상태 확인, 수락 진행 등의 단발성 프롬프트는 herdr 입력을 통해 직접 보낼 수 있습니다.
- **작업 위임**:
- *수동 경로*: 상세 사양과 계획 수립 등의 복잡한 작업 지시는 먼저 로컬 마크다운 파일(예: `.mam/reports/brief-<job_id>.md` 또는 지정된 워크스페이스 경로)로 작성한 후, 에이전트에게 `"Read <파일경로> and execute."` 라는 실행 명령만 전달하십시오.
- *자동 경로*: 자동화 잡 런너(`multi-agent-mux-delegate-job submit`)는 잡 등록 시 `.mam/jobs/<job_id>/brief.md` 디렉터리에 지시서를 자동 집필하고 단일 라인 포인터 프롬프트만 에이전트 세션에 인가합니다.
- **결과 안내 및 피드백**:
- *수동/영구 리뷰*: 상세 리뷰 결과, 설계 제안서 등은 `.mam/reports/<herdr_session_name>/report-<job_id>.md` 경로에 저장합니다.
- *자동화 잡 보고서*: 자동 위임된 비동기 작업의 완료 결과는 잡 디렉터리 하위인 `.mam/jobs/<job_id>/<agent_name>-reports/report-final.md` (루프/Discuss 위임 시에는 `<clean_session_name>-reports/`) 경로에 기록해야 합니다.
- *버전 관리 이관*: 버전 관리가 필요한 주요 산출물(최종 설계 계획, 최종 리뷰 보고서, 보안 감사 리포트 등)은 gitignore 대상인 `.mam/` 하위가 아닌, 버전 관리 대상 경로(구체적으로 `.agents/reports/<herdr_session_name>/` 또는 `docs/reports/` 등)로 명시적으로 복사하여 이관 보존해야 합니다.
- **디스크 정리 및 보존 정책 계약 (Cleanup & Retention)**: `.mam/jobs/<job_id>/``.mam/reports/` 폴더 아래의 파일들은 휘발성 감사 이력(audit-trail) 산출물입니다. 버전 관리가 필요한 문서들은 `.agents/reports/` 하위로 수동 복사하여 커밋해야 하며, `stop_session.sh` 세션 종료 스크립트는 이들 보고서 디렉터리를 자동으로 삭제하지 않으므로 수동 또는 주기적 클린업이 권장됩니다.
### ⏱️ 타임아웃 구성 및 정렬 규칙
- **잡 실행 제한 (`timeout_sec` & `idle_timeout_sec`)**: 각 잡은 전체 실행 만료 시간(`timeout_sec`, 기본 3600s)과 메세지 미수신 유휴 시간(`idle_timeout_sec`, 기본 120s)을 독립적으로 가집니다.
@@ -157,7 +159,8 @@ TMUX 환경에서 실행되는 에이전트가 화면 스크롤 한계로 인해
```bash
bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace "$(pwd)" --agent <agent> --role <role> --onboard
```
2. **에이전트 맥락 동기화**: 에이전트가 시작되면 아래 지시사항이 담긴 잡이 자동으로 인가됩니다:
이 자동화된 온보딩 워크플로우는 백그라운드 잡을 등록하여 `.mam/jobs/<job_id>/brief.md` 에 온보딩 지시서를 작성하고 에이전트 세션에 지시서 포인터만 입력합니다.
2. **에이전트 맥락 동기화**: 에이전트가 시작되면 아래 지시사항이 담긴 온보딩 brief를 자동으로 수임하여 확인합니다:
- `README.md` 및 `.agents/MULTI_AGENT_RULES.md`를 필독하여 설계 규약과 제약사항을 인지한다.
- `git status` 및 `git diff`를 실행하여 레포지토리의 활성 수정 내역을 분석한다.
- `.mam/agent-sessions.yaml`을 읽어 현재 러닝 상태인 타 에이전트 목록을 확인하고, 자신의 지정된 `role`을 검증한다.
+14 -11
View File
@@ -1,6 +1,6 @@
# MULTI_AGENT_RULES.md
This document serves as the common guidelines and protocol for introducing the **MQTT messaging backplane and Tmux-based multi-agent orchestration workflow** to a new project. It defines the rules and architecture to ensure collaborating agents perform tasks safely, robustly, and consistently.
This document serves as the common guidelines and protocol for introducing the **MQTT messaging backplane and Herdr-based multi-agent orchestration workflow** to a new project. It defines the rules and architecture to ensure collaborating agents perform tasks safely, robustly, and consistently.
All agents working on a new project must read this document thoroughly and comply with the defined protocols before starting any tasks.
@@ -54,7 +54,7 @@ Asynchronous communication and state management between agents are controlled vi
### 🗃️ Registry & State Management
- This architecture maintains two distinct registries based on their purpose:
- **Job Registry**: The metadata and lifecycle of each asynchronous job are recorded in individual JSON files (`.mam/jobs/<id>.json`). Concurrency conflicts (claiming races) across multiple sessions are prevented via file-based `fcntl` advisory locks (`registry_lock` via `registry.py`).
- **Session Registry**: TMUX monitoring states and running agent metadata are consistently controlled using a SQLite WAL database (`.mam/agent-sessions.db`) to support reliable concurrent transactions on a single host. However, since SQLite WAL mode does not guarantee complete file locking in Network File System (NFS) environments, we recommend using a local file system.
- **Session Registry**: Herdr monitoring states and running agent metadata are consistently controlled using a SQLite WAL database (`.mam/agent-sessions.db`) to support reliable concurrent transactions on a single host. However, since SQLite WAL mode does not guarantee complete file locking in Network File System (NFS) environments, we recommend using a local file system.
### 🛡️ Security Protocol (HMAC-SHA256)
- **Unauthenticated PoC Mode**: If the `auth_token` in the job registry is set to `null` (the default PoC mode), signature verification is skipped and all events are accepted (`verify_hmac` always returns `True`).
@@ -113,7 +113,7 @@ sequenceDiagram
These are critical instructions for preventing data loss and infrastructure-level failures during long-running agent analyses.
### 📸 Preventing TUI Viewport Truncation (The 3 Pane Snapshotting Rules)
To ensure that agents running in TMUX environments do not lose debug logs or previous outputs due to screen scrollback limits, the following **snapshotting pattern must be enforced**:
To ensure that agents running in Herdr environments do not lose debug logs or previous outputs due to screen scrollback limits, the following **snapshotting pattern must be enforced**:
1. **Pre-brief Capture**: Capture the pane (`capture-pane -S -200`) immediately after sending the task instruction (Brief) to back up the starting point of the input history.
2. **Loop Snapshot**: For long-running agent sessions (5 minutes or more), periodically (e.g., every 30 seconds) scan the viewport and append the incremental data to `/tmp/pane-snap.txt`.
3. **Post-job Capture**: Capture the complete pane state one final time immediately after a job completes or returns an error to preserve the entire execution trajectory.
@@ -121,13 +121,15 @@ To ensure that agents running in TMUX environments do not lose debug logs or pre
### 📄 Markdown-Based Workflow & Communication (마크다운 기반 협업 및 결과 전달)
- **Core Principle**: To prevent TUI character loss, truncation, and layout breakage during sequential input typing, all collaborative workflows must favor file-based markdown communication.
- **Rules & Protocols**:
- **Exception**: Extremely simple prompts (e.g., "Re-evaluate", "Check status", "Proceed") of 1 or 2 lines may be sent directly via tmux input buffers.
- **Task Delegation**: All detailed task briefs, specifications, and instructions must be written to a local Markdown file (e.g., `.mam/reports/brief-<job_id>.md` or a workspace path) first. The sender then issues a simple trigger command: `"Read <file_path> and execute."`
- **Result Reporting & Feedback**: All detailed review results, design proposals, implementation reports, and audit comments must be saved as Markdown files. To separate human-readable documents from the machine job registry (`.mam/jobs/`), every agent must save its outputs under `.mam/reports/<tmux_session_name>/` (using the full tmux session name matching the `name` field in `.mam/agent-sessions.yaml`, e.g., `.mam/reports/<workspace_slug>-creator-<agent>/report-<job_id>.md`) to ensure strict isolation across multiple roles/instances.
- **Cleanup & Retention Contract**:
- Files under `.mam/reports/` are audit-trail artifacts.
- These folders should be cleaned up automatically during `stop_session.sh` when a session is gracefully stopped or purged (`--purge-conversation`).
- Durable outcomes (such as final design plans, review verdicts, or security audit reports) that require version control must be explicitly copied to tracked directory paths (specifically under `.agents/reports/<tmux_session_name>/` or `docs/reports/`) to preserve collaborative audit trails in version history, instead of remaining in the gitignored `.mam/` runtime tree.
- **Exception**: Extremely simple prompts (e.g., "Re-evaluate", "Check status", "Proceed") of 1 or 2 lines may be sent directly via herdr input buffers.
- **Task Delegation**:
- *Manual path*: Detailed task briefs may be written to a local Markdown file (e.g., `.mam/reports/brief-<job_id>.md` or a workspace path) first. The sender then issues a simple trigger command: `"Read <file_path> and execute."`
- *Automated path*: The automated job runner (`multi-agent-mux-delegate-job submit`) automatically provisions the brief at `.mam/jobs/<job_id>/brief.md` and sends a short pointer instruction to the agent.
- **Result Reporting & Feedback**:
- *Manual/Durable reviews*: Detailed reviews, design proposals, or audit reports must be saved under `.mam/reports/<herdr_session_name>/report-<job_id>.md`.
- *Automated job reports*: Automated execution results are saved directly to `.mam/jobs/<job_id>/<agent_name>-reports/report-final.md` (or `<clean_session_name>-reports/` for loops) as transient files.
- *Versioned promotions*: Any final design plans, review verdicts, or security audit reports that require version control must be explicitly copied to tracked directory paths (specifically under `.agents/reports/<herdr_session_name>/` or `docs/reports/`).
- **Cleanup & Retention Contract**: Files under `.mam/jobs/<job_id>/` and `.mam/reports/` are transient audit-trail artifacts. While durable outcomes are committed to version control under `.agents/reports/`, ephemeral directory trees can be cleaned up manually as needed; `stop_session.sh` does not automatically purge these report trees during session exit.
### ⏱️ Timeout Configuration & Alignment Rules
- **Job Execution Limits (`timeout_sec` & `idle_timeout_sec`)**: Each job independently manages its overall execution timeout (`timeout_sec`, default 3600s) and idle timeout without receiving messages (`idle_timeout_sec`, default 120s).
@@ -157,7 +159,8 @@ Newly spawned Team Leader agents must align their context using the `--onboard`
```bash
bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh --workspace "$(pwd)" --agent <agent> --role <role> --onboard
```
2. **Orienting the Agent**: The agent session starts up and automatically receives a registered job instructing it to:
This automated onboarding workflow registers a job, provisioning the brief under `.mam/jobs/<job_id>/brief.md` and sending a short pointer to the agent session.
2. **Orienting the Agent**: The agent session starts up and automatically receives the registered onboarding brief instructing it to:
- Read `README.md` and `.agents/MULTI_AGENT_RULES.md` to align with design principles and constraints.
- Run `git status` and `git diff` to analyze active modifications.
- Read `.mam/agent-sessions.yaml` to identify other running agents and verify its own assigned `role`.
-105
View File
@@ -1,105 +0,0 @@
# Multi-Agent Collaboration Workflow Reference Guide
본 문서는 대규모 프로젝트나 정밀한 설계·구현 요구사항을 처리하기 위해 **Planner, Developer, Reviewer** 에이전트 간의 역할 분담 및 피드백 루프를 운영하는 **다중 에이전트 협업 워크플로우(Multi-Agent Collaboration Workflow)**를 정의합니다.
---
## 1. 역할 정의 및 분담 (Roles & Responsibilities)
협업 시스템은 각 에이전트의 책임 영역을 명확히 격리하여 상호 교차 검증을 강제합니다.
```
┌──────────────────────┐
│ User Prompt │
└──────────┬───────────┘
┌──────────────────────┐
│ 1. Planner Agent │ ◄──────────────────┐
│ - Plan & Checklist │ │
└──────────┬───────────┘ │
▼ │
┌──────────────────────┐ │
│ 2. Developer Agent │ │
│ - Code & DoD Verify │ │
└──────────┬───────────┘ │
▼ │ (NOT PASS Feedback)
┌──────────────────────┐ │
│ 3. Reviewer Agents │ │
│ - Dual Peer Review │ ───────────────────┘
└──────────┬───────────┘
▼ (PASS)
┌──────────────────────┐
│ 4. Done & Commit │
└──────────────────────┘
```
### 1.1. Planner Agent (설계 및 통제)
- **목적**: 요구사항을 명세화하고, 구현 단계의 설계 결함이나 모순(Contradiction)을 사전에 차단합니다.
- **역할**:
- 사용자 요구사항에 따른 구현 목표 및 범위 수립.
- `implementation_plan.md``task.md` (체크리스트) 작성 및 버전 관리(Rev.1, Rev.2, ...).
- 리뷰어 피드백 발생 시 설계 변경의 파급 범위를 계산하여 계획 갱신.
- **핵심 원칙**: 직접 코드를 수정하지 않고 오직 설계와 체크리스트 자산만 관리합니다.
### 1.2. Developer Agent (구현 및 자가 검증)
- **목적**: Planner가 제공한 체크리스트를 기반으로 실제 리포지토리 코드를 물리적으로 수정 및 구현합니다.
- **역할**:
- `task.md`를 순차적으로 완료 상태(`[x]`)로 업데이트하며 구현 수행.
- 커밋 전 **Definition of Done (DoD)** 체크리스트를 자체 실행하여 금지된 코드 패턴, 메모리/구조적 사이드 이펙트 유무 자가 검토.
- 수정 사항을 단일 원자적(Atomic) 커밋으로 마감하고 리뷰어에게 전달.
### 1.3. Reviewer Agents (교차 피드백 및 검증)
- **목적**: 구현된 결과물이 최초 설계서 및 학술적 제약 요건에 일치하는지 제3자의 관점에서 엄격하게 검토합니다.
- **역할**:
- **Reviewer A (Claude)**: 학술 서사와 코드 설계 간의 상위 논리적 정합성 및 결함(예: HOLB 해소 주장과 단일 커넥션 다이얼러의 모순 등) 검증.
- **Reviewer B (Cline)**: 전이 조건, 예외 처리, 타입 시그니처, 텔레메트리 매핑 등 하위 레벨 구현의 세부 사항 기계적 검증.
- **판정 규칙**: 두 리뷰어 모두 **PASS** 판정을 내릴 때까지 개발자는 마감할 수 없으며, 반려 시 **1단계(Planner)**로 피드백이 환류됩니다.
---
## 2. 세부 운영 단계 (Step-by-Step Workflow)
### 1단계: 설계 수립 (Planning Phase)
1. 사용자가 요구사항을 제시하면, **Planner 에이전트**가 프로젝트의 전반적인 구조를 파악합니다.
2. `implementation_plan.md``task.md`를 작성하여 개발을 위한 로드맵을 제공합니다.
3. 사용자가 해당 구현 계획을 승인하면 다음 단계로 이행합니다.
### 2단계: 코드 구현 및 자가 검증 (Development Phase)
1. **Developer 에이전트**가 배정된 태스크의 코드를 수정합니다.
2. 작업 진행 중 예상치 못한 설계 변경 필요성이 감지되면 작업을 멈추고 **Planner 에이전트**에게 계획 수정을 먼저 위임합니다.
3. 구현 완료 후 아래의 **DoD 검증**을 수행합니다:
- 핵심 기능의 타입 매핑 확인.
- 공유 자원(`tls.Config` 등) 변경 시 사이드 이펙트 방지(복제 후 변이 적용 등).
- 문서-코드 간 주장의 정합성 체크.
4. 검증 완료 후 단일 커밋을 작성합니다.
### 3단계: 피드백 루프 및 통과 (Review Phase)
1. Developer 에이전트가 리뷰어 세션에 작업 완료 사실과 변경 범위를 전달합니다.
2. 리뷰어들은 `git diff`를 바탕으로 개별 검증을 수행하고 보고서 형태의 리뷰 피드백을 출력합니다.
- **반려 (`NOT PASS`)** -> 피드백 요약본을 Planner 에이전트에게 전송하여 상위 레벨 계획(Rev.n) 개시.
- **통과 (`PASS`)** -> 모든 검토 사항이 해결되었음을 명시.
3. 모든 리뷰어가 PASS를 발행하면 작업을 완결하고 세션을 안전하게 종료(`multi-agent-mux-stop`)합니다.
---
## 3. Best Practices & 템플릿
### 3.1. 작업 시작 시 Planner 에이전트 프롬프트 템플릿
```
[역할 요구]
프로젝트의 구조 및 내용을 파악하고, 현재 작업 요건에 맞춰 구현 계획서(implementation_plan.md) 및 태스크 체크리스트(task.md)를 작성해 주세요.
[검토 초점]
- 설계 수준에서 논리적 모순이 발생할 여지가 없는지
- naive 구현과 대비되는 핵심 차별점이 코드 명세에 정확히 명시되었는지
```
### 3.2. 피드백 루프 환류 시 프롬프트 템플릿
```
Reviewer 검토 결과 구현 코드 차원에서 아래의 블로킹(NOT PASS) 피드백이 발생했습니다.
위 피드백을 수용하여 설계 문서를 수정하는 계획을 수립하고, implementation_plan.md (Rev.[N]) 및 task.md 내용을 업데이트해 주세요.
[피드백 내용]
- [블로킹 항목 1]
- [블로킹 항목 2]
```
+59
View File
@@ -0,0 +1,59 @@
# 📘 herdr_docs.md: Herdr 공식 문서 사이드맵 및 문서 구조 레퍼런스
이 문서는 AI 에이전트 인지형 멀티플렉서인 **herdr**의 공식 문서 구조 및 핵심 경로 링크들을 정리한 참고서입니다. 개발 과정 및 스킬 설계 시 참조용 사양으로 활용합니다.
---
## 🔗 Herdr 공식 문서 사이트 구조
공식 홈페이지 및 메인 설명: **[herdr.dev](https://herdr.dev)**
### 1. 🚀 시작하기 (Start here)
* **Overview (개요)**: [herdr.dev/docs/](https://herdr.dev/docs/)
* Herdr의 도입 목적 및 핵심 철학
* **Install (설치 방법)**: [herdr.dev/docs/install/](https://herdr.dev/docs/install/)
* 시스템 요구사항 및 바이너리 설치 스크립트 제공
* **Quick start (빠른 시작)**: [herdr.dev/docs/quick-start/](https://herdr.dev/docs/quick-start/)
* 기본 워크스페이스 생성 및 에이전트 실행 예시
* **Concepts (핵심 개념)**: [herdr.dev/docs/concepts/](https://herdr.dev/docs/concepts/)
* 에이전트 인지식 터미널 구조, Pane, Tab, Workspace 관계
* **Keyboard (키보드 단축키)**: [herdr.dev/docs/keyboard/](https://herdr.dev/docs/keyboard/)
* 멀티플렉서 제어를 위한 주요 기본 단축키 목록
### 2. 🤖 Herdr 실무 활용 (Using Herdr)
* **How to work with Herdr (작업 워크플로우)**: [herdr.dev/docs/how-to-work/](https://herdr.dev/docs/how-to-work/)
* 개발자와 에이전트 간의 화면 분할 및 협업 모범 사례
* **Agents (에이전트 제어)**: [herdr.dev/docs/agents/](https://herdr.dev/docs/agents/)
* Claude Code, Cline, Agy 등 주요 코딩 에이전트 실행 및 연동 규칙
* **Session state and restore (세션 상태 및 복원)**: [herdr.dev/docs/session-state/](https://herdr.dev/docs/session-state/)
* 호스트 리부팅 및 연결 유실 시 대화 상태 원자적 백업 및 복원
* **Persistence and remote access (영속성 및 원격 접속)**: [herdr.dev/docs/persistence-remote/](https://herdr.dev/docs/persistence-remote/)
* 백그라운드 영속 구동 및 원격 터미널에서의 Attach 방법
### 3. ⚙️ 설정 가이드 (Configure)
* **Configuration (설정 기초)**: [herdr.dev/docs/configuration/](https://herdr.dev/docs/configuration/)
* 사용자 프로필 설정 및 환경 변수 연동
* **Config reference (설정 참조)**: [herdr.dev/docs/config-reference/](https://herdr.dev/docs/config-reference/)
* `config.toml` 구조 및 전역 키 맵 변경 스펙
* **Plugins (플러그인)**: [herdr.dev/docs/plugins/](https://herdr.dev/docs/plugins/)
* Herdr 확장용 플러그인 사양 및 연동
* **Marketplace (마켓플레이스)**: [herdr.dev/docs/marketplace/](https://herdr.dev/docs/marketplace/)
* 커뮤니티 플러그인 공유 및 다운로드
### 4. 📚 레퍼런스 및 사양 (Reference)
* **CLI reference (명령어 참조)**: [herdr.dev/docs/cli-reference/](https://herdr.dev/docs/cli-reference/)
* `herdr run`, `herdr capture`, `herdr kill` 등 CLI 인자 설명
* **Socket API (소켓 API)**: [herdr.dev/docs/socket-api/](https://herdr.dev/docs/socket-api/)
* 프로그래밍 방식으로 창 분할, 입력 전송, 상태 조회를 수행하기 위한 로컬 Unix 소켓 규격
* **Integrations (외부 연동)**: [herdr.dev/docs/integrations/](https://herdr.dev/docs/integrations/)
* CI/CD 환경 및 외부 IDE 어댑터 연동 방안
* **Agent skill file (에이전트 스킬 파일)**: [herdr.dev/docs/agent-skill/](https://herdr.dev/docs/agent-skill/)
* 에이전트가 자체적으로 Herdr 환경을 진단할 때 읽는 규칙 정의
* **Windows beta (윈도우 베타)**: [herdr.dev/docs/windows-beta/](https://herdr.dev/docs/windows-beta/)
* Windows 환경 구동 현황 및 제약 사항
### 5. 🚑 문제 해결 및 기타 (Help)
* **Troubleshooting (문제 해결)**: [herdr.dev/docs/troubleshooting/](https://herdr.dev/docs/troubleshooting/)
* 인증 실패, PTY 블로킹, 세션 크래시 자가 진단 및 대처법
* **Preview docs (프리뷰 문서)**: [herdr.dev/docs/preview/](https://herdr.dev/docs/preview/)
* 차기 업데이트 예정 기능 문서
-36
View File
@@ -1,36 +0,0 @@
# 📋 MAM Installer & Manual Alignment Re-Review Brief
We have resolved all structural inconsistencies and runtime blockers identified by Planner Claude, Creator Claude, and Reviewer Cline. Please perform a final review and run diagnostics.
---
## 🛠️ Refactoring & Alignment Highlights
1. **RC-1 (Attach Inconsistency Resolved)**:
- Aligned `create_session.sh` command examples in `INSTALL.md` and the installer epilogue to consistently include the `--tmux-server multi-agent-mux` flag. This matches the attach instructions (`tmux -L multi-agent-mux attach`).
2. **RC-2 (Hard Dependency Check Resolved)**:
- Python `pyyaml` library is now a **hard dependency**; the installer exits with `exit 1` if it is not found.
- Added `uuidgen` and `flock` to the `DEPS` array in `install_mam.sh` to ensure they are diagnosed and checked at install time.
3. **Durable Reports Path Conflict Resolved**:
- Updated `MULTI_AGENT_RULES.md` and `INSTALL.md` guidelines to instruct that durable/version-controlled reports must be copied and tracked under `.agents/reports/<session_name>/` instead of the gitignored `.mam/reports/` runtime cache.
- Migrated all existing reviewer reports from `.mam/reports/` to `.agents/reports/`.
4. **AGENTS.md Overwrite Protection**:
- `install_mam.sh` no longer clobbers an existing `AGENTS.md` by default. Instead, it checks for a MAM marker block and appends a pointer to `.agents/MULTI_AGENT_RULES.md` if not present.
5. **rsync reports/ Anchor Fix**:
- Switched `--exclude='reports/'` to `--exclude='/reports/'` in `rsync` to avoid unanchored directory mismatches.
---
## 🎯 Re-Review Instructions for Agents
1. **Reviewer Cline**:
- Re-run syntax, lint (`shellcheck`), symlink tests, and check dependencies.
- Save your final re-review verdict and write a Markdown report under `.agents/reports/canary-projects-multi-agent-mux-reviewer-cline/report-mam-installer-final.md`.
2. **Creator Claude**:
- Review the latest codebase diff (`git diff d7e19fe~1 d7e19fe`).
- Validate RC-1 and RC-2 fixes and verify if all guidelines and manual scripts are synchronized.
- Output your final verdict (PASS/NOT PASS) and details.
3. **Planner Claude**:
- Evaluate the refactored layout and verify the architecture alignments against MAM standards.
- Confirm if the report migration and non-invasive AGENTS.md injection satisfy version control safety.
- Save your feedback or final verdict.
@@ -1,28 +0,0 @@
# 📋 세션 ID 중복 충돌 해결 종합 설계안 리뷰 요청 지시서
- **요청자**: Planner Agent (Antigravity)
- **수신자**: Reviewer Agent (Claude)
- **대상 세션**: `canary-projects-multi-agent-mux-creator-claude`
- **검토 대상 파일**: [session_isolation_discussion.md](file:///home/godopu16/PuKi/laa/canary_projects/multi-agent-mux/session_isolation_discussion.md)
---
## 1. 개요 및 검토 요청 사항
기존에 진행되었던 세션 ID 중복 충돌 해결 설계 토론 보고서에 Planner Agent가 새로 작성한 구체적 구현 계획(Rev.2)을 성공적으로 통합 및 단일화하였습니다.
수신자(Claude) 에이전트님은 통합된 [session_isolation_discussion.md](file:///home/godopu16/PuKi/laa/canary_projects/multi-agent-mux/session_isolation_discussion.md) 문서를 검토하시어 아래 기준을 만족하는지 검사해 주시기 바랍니다.
### 주요 검토 기준
1. **의견 반영의 정합성**: Claude 에이전트 본인이 2차 토론 및 `implementation_plan.session_isolation.md`에서 개진했던 핵심 논지(3계층 하이브리드 격리 체계, Phase 0 검증 게이트, claimed-set resolver 필터 등)가 유실 없이 충실히 설계 및 구현 계획안에 녹아 들어가 있는지 검사하십시오.
2. **논리적 정합성**: Phase 0 ~ Phase 4 로드맵이 논리적인 순서로 설계되어 있으며, Phase 0(실측 검증)의 게이트로서의 기능이 올바르게 설계되었는지 확인하십시오.
3. **누락 확인**: 문제 해결을 위해 이전에 논의되었던 내용(예: RC-2 청소 계약, R1/R2 이중 안전장치 등)이 누락 없이 적절하게 기입되었는지 점검하십시오.
---
## 2. 작업 결과 보고 양식
검토를 마친 후, 본인의 의견과 최종 승인 여부를 아래에 명시된 경로에 기록해 주십시오.
* **리포트 작성 경로**: `.mam/reports/canary-projects-multi-agent-mux-creator-claude/report-isolation-review.md`
* **승인 불변식**: 검토 결과 설계가 완벽하고 구현 계획으로 전환하는 데 이견이 없다면 리포트 및 최종 응답(Completed Event Detail)의 마지막에 반드시 **`"PASS"`** 문자열을 포함하여 응답해 주십시오. 만약 수정이나 보완이 필요하다면 구체적인 피드백을 전달해 주십시오.
@@ -0,0 +1,46 @@
# 리뷰 리포트 — Job 20a83d73
- **리뷰 대상**: 커밋 `f79fd99``create_session.sh` / `resume_session.sh`에 에이전트 바이너리 절대 경로 해석(`command -v`) 및 macOS 격리 속성 해제(`xattr -d com.apple.quarantine`) 추가로 macOS 타임아웃 오류 수정
- **리뷰어**: claude (planner-reviewer)
- **리뷰 방식**: 정적 분석(bash -n, shellcheck 기준선 대비) + 격리 tmux 서버에서의 실제 실행 재현 검증
## 1. 설계 타당성 — 실행으로 검증함
macOS에서의 실제 고장 메커니즘은 "tmux 서버가 축소된 PATH로 기동 → pane에서 `claude`/`agy` 미발견 → pane 즉사 → `wait_for_tui_ready` 타임아웃"이다. 이 메커니즘과 수정 효과를 Linux에서 격리 tmux 서버(`-L mam_rev_20a83d73`, `env -i PATH=/usr/bin:/bin`)로 직접 재현했다:
- **Case A (수정 전 시나리오)**: PATH 밖의 가짜 에이전트를 bare name으로 `new-session`**pane 즉사 확인** (타임아웃 전조 재현 성공).
- **Case B (수정 후 시나리오)**: 동일 조건에서 절대 경로로 `new-session`**세션 생존 + 에이전트 실제 실행 확인** (마커 파일 기록됨).
호출 스크립트(전체 PATH 보유) 시점에 `command -v`로 해석해 절대 경로를 명령 문자열에 굽는 설계는 이 문제의 정확한 해법이다. Gatekeeper quarantine 해제도 macOS 최초 실행 지연/행에 대한 합리적 보완책이다(Darwin 전용 가드로 Linux 무영향).
## 2. 정적 분석
- `bash -n` 양 파일 통과.
- `shellcheck -S warning`: 변경 전 기준선(31ca11c 시점 파일을 추출해 비교) 대비 **신규 경고 0건**. `resume_session.sh:40`의 SC2155 1건은 이번 diff와 무관한 기존 경고로 변화 없음.
## 3. 동작성 검증 (실행 기반)
- **해석 스니펫 단독 실행**: PATH에 있는 `claude``/home/godopu16/.local/bin/claude`로 정상 해석. PATH에 없는 이름 → bare name으로 안전한 폴백, `set -euo pipefail` 하에서 exit 0 (조건문 내 `command -v` 실패가 set -e를 트립하지 않음을 실측).
- **실제 스크립트 스모크**: `create_session.sh --dry-run`(실제 claude 에이전트, 격리 서버명 지정)으로 신규 블록 포함 전체 경로가 exit 0으로 통과 — 부수효과 없이 CMD_FULL 확정 지점까지 실행됨.
- **xattr 안전성**: Darwin 가드로 Linux에서 완전 스킵. macOS에서 `xattr` 부재/실패 시에도 `2>/dev/null || true` 패턴이 `set -e`를 트립하지 않음을 동형 재현으로 확인. `[ -f "$RESOLVED_BIN" ]` 가드 덕에 미해석(bare name) 상태에서는 실행 자체가 스킵됨.
## 4. 유실 검사
- 4개 에이전트(claude/agy/hermes/cline)의 플래그(`--dangerously-skip-permissions`, `-i`, `-r/--conversation/--resume/--id $UUID`) 및 `ISO_ENV_PREFIX`/`ISO_CMD_ARGS` 배치가 변경 전과 전부 동일하게 보존됨. cline이 env prefix를 받지 않는 기존 비대칭도 그대로 유지(회귀 없음).
- claude wrapper 경로(비격리 시 `~/.local/bin/<session>` 우선)는 양 스크립트 모두 변경되지 않음.
- 다운스트림 영향: drift 클래스 AD(reconcile.sh)와 status.sh는 `cmd_full`/`start_command`를 비교 로직에 사용하지 않고 표시용으로만 전달함을 확인 — 절대 경로가 들어가도 오탐 없음.
## 5. 비차단(Non-blocking) 지적 사항
1. **경로 내 공백 취약**`RESOLVED_BIN`이 공백 포함 경로로 해석되면 CMD_FULL이 깨짐을 격리 tmux에서 실측으로 확인(pane 즉사). 다만 대상 CLI들의 표준 설치 경로(`/opt/homebrew/bin`, `~/.local/bin`, npm global 등)에는 공백이 없고 macOS 홈 디렉터리 short name에도 공백이 없어 실사용 확률은 낮음. 후속 개선 시 `printf %q` 또는 인용 부호 처리를 권장(단, resume 쪽 `eval` 이중 해석 계층 고려 필요).
2. **중복 분기**`cline` 분기와 else 분기가 기능적으로 완전 동일(`command -v cline` == `command -v "$AGENT"` when AGENT=cline). 동작 문제는 없으나 단순화 여지 있음(양 파일 공통).
3. **resume 후 메타데이터 불일치(외관상)**`update_yaml_resumed.sh`가 resume 후 `cmd_full`을 bare name 형태로 되써서, 실제 pane은 절대 경로로 실행됐는데 YAML 기록은 bare name이 됨. 비교 로직에 쓰이지 않는 표시 전용 필드라 실해는 없음.
4. **macOS 실기기 미검증** — 본 리뷰 환경은 Linux이므로 `xattr` 실효(quarantine 속성 실제 제거) 자체는 실측 불가. 가드/에러 억제 로직의 안전성은 동형 재현으로 확인했고, 명령·플래그는 표준 macOS 관행과 일치함.
참고: 리뷰 중 발견된 저장소 내 `multi-agent-mux-delegate-job.27194_12342.tmp` 파일은 고아 파일이 아니라 **본 job(20a83d73)을 디스패치 중인 살아있는 delegate_job_safe 임시 사본**(PID 확인됨)으로, 직전 라운드에서 검증한 trap 정리 대상이다. 결함 아님.
## 6. 결론
수정의 핵심 메커니즘(절대 경로 baking)이 재현 실험으로 실효성이 입증되었고, 기존 동작 유실·신규 경고·다운스트림 회귀가 전무하다. 비차단 지적 4건은 모두 후속 개선 수준이며 설계 재작업이 필요한 사항은 없다.
[VERDICT: PASS]
@@ -0,0 +1,33 @@
# 리뷰 리포트 — Job 4094502a (MULTI_AGENT_RULES.md/ko.md tmux→herdr 개정)
- **리뷰 대상**: 작업 트리 미커밋 diff — `.agents/MULTI_AGENT_RULES.md`(6개소), `.agents/MULTI_AGENT_RULES.ko.md`(7개소), cline 작성
- **리뷰어**: claude (planner-reviewer)
## 1. 변경 무결성 — 토큰 단위 검증
`git diff --word-diff` 전수 집계 결과, 변경은 **정확히 23개 토큰 치환**(TMUX/Tmux/tmux → Herdr/herdr, `Tmux-based``Herdr-based`, `<tmux_session_name>``<herdr_session_name>` 경로 플레이스홀더 4건 포함)뿐이며 **문장 추가·삭제·구조 변경 0건**. 번역 유실이나 mermaid/sequenceDiagram 블록 훼손 없음. 양 언어판의 치환 지점이 상호 대응함(ko의 send-keys 문구 1건은 원래 ko에만 존재하는 기존 번역 차이로, 이번 diff와 무관).
## 2. 잔존 레거시 스윕
- 두 파일 모두 대소문자 무시 `tmux` 검색 **0건** — 누락된 레거시 없음.
- 저장소 전체에서 `<tmux_session_name>` 플레이스홀더 잔존은 `.agents/reports/` 하위 **아카이브된 과거 리뷰 리포트 3건뿐** — 역사적 감사 기록이므로 개정 대상이 아님(방치 아님).
## 3. herdr 사양 교차 검증 (실구현 대조)
| 문서 표기 | 실구현 근거 | 판정 |
|---|---|---|
| "herdr `send-keys`" / "herdr 입력" | shim(`.mam/shim/herdr:318`)에 `send-keys` 의사 명령 실재 | ✅ 부합 |
| `capture-pane -S -200` (유지된 기존 문구) | shim `capture-pane` 의사 명령 실재(:301) | ⚠️ 명령은 실재하나 아래 비차단 지적 1 참조 |
| `.agents/reports/<herdr_session_name>/` 관례 | 실제 디렉터리(`.agents/reports/canary-projects-…-cline` 등)가 세션명 기반으로 운영 중 | ✅ 부합 |
| "Herdr 모니터링 상태 … SQLite WAL" | `.mam/agent-sessions.db` + `herdr_sessions` 스키마 현행 일치 | ✅ 부합 |
## 4. 비차단(Non-blocking) 지적
1. **`capture-pane -S -200`의 시맨틱 공백(기존 문구, 이번 diff 무관)**: shim의 `capture-pane``-S -200` 플래그를 파싱하지 않고 조용히 무시하며 항상 `agent read --source visible --lines 100`으로 동작한다. 즉 문서가 약속하는 "스크롤백 200행 백업"이 실제로는 "가시 영역 100행"으로 축소 실행된다. 스냅샷 규칙의 취지(뷰포트 절단 방지)가 약화되므로, 후속 개선으로 (a) shim이 `-S -N``--lines N`으로 매핑하거나 (b) 문서에서 플래그 표기를 herdr 실사양으로 갱신할 것을 권장.
2. **의사 명령 전제 미표기**: 다른 SKILL.md들은 `send-keys`/`capture-pane`이 "lib.sh 소싱 후에만 동작하는 tmux-compat 의사 명령"임을 명시하나, 본 규칙 문서는 전제 없이 사용한다. 규칙서가 신규 에이전트의 첫 관문임을 고려하면 각주 1줄 추가 가치가 있음.
## 5. 결론
치환은 토큰 단위로 정밀하고(내용 유실 0), 잔존 레거시 0건이며, 도입된 어휘가 현행 스킬/shim/디렉터리 관례와 전부 부합한다. 비차단 2건은 이번 diff가 만들지 않은 기존 문구의 후속 개선 사항이다.
[VERDICT: PASS]
@@ -0,0 +1,77 @@
# 구현 계획서 — deploy/ 배포 설정 tmux→herdr 전환 (Job baa15c96)
- **Planner**: claude (planner-reviewer)
- **입력**: `.mam/deploy_brief.md` + `deploy/` 전수 분석 + 현행 스킬 구현(단일 진실 소스) 대조
- **핵심 원칙**: 문서·스크립트가 브리프의 *추정* 어휘가 아니라 **현행 스킬이 실제 소비하는 어휘**와 일치해야 한다. 실측 결과 브리프의 제안 중 2건은 실제 구현과 다르므로 아래와 같이 교정한다:
- ~~`HERDR_SESSION_NAME`~~ → **`HERDR_SERVER_NAME`** (스킬 전체가 이 이름만 소비, `TMUX_SERVER_NAME` 소비처는 0곳)
- ~~`--herdr-session`~~ → **`--herdr-server`** (`create_session.sh:63`이 수용하는 유일한 플래그, `--tmux-server`는 이미 제거되어 **미지원**)
## 0. 실측 현황 (전수 스윕: `grep -in "tmux" deploy/` + 스킬 대조)
| 파일 | 행 | 현재 내용 | 판정 |
|---|---|---|---|
| install_mam.sh | 224 | `--tmux-server multi-agent-mux` (Quick Start 예시) | 🔴 **기능 파손** — create_session.sh가 이 플래그를 거부(unknown arg, exit 2). 사용자가 복붙 시 즉시 실패 |
| install_mam.sh | 227 | `$ tmux -L multi-agent-mux attach -t <session_name>` | 🔴 죽은 명령 |
| install.sh | 31 | `check_cmd tmux` | 🟠 잘못된 의존성 진단(herdr 미검사) |
| install.sh | 249 | `.env``TMUX_SERVER_NAME=default` 기록 | 🟠 죽은 설정(소비처 0) |
| README.md | 9 | requirements "(`tmux`, `python3`)" | 🟡 문서 불일치 |
| INSTALL.md | 21, 37 | 진단 목록에 `tmux` | 🟡 문서 불일치(install_mam.sh:86 실제 DEPS는 이미 `herdr python3 rsync uuidgen`) |
| INSTALL.md | 69 | "호스트 재기동으로 tmux가 소멸한 경우" | 🟡 문서 불일치 |
| plugin.json | 3 | `"... Backplane on Tmux & MQTT."` | 🟡 메타데이터 불일치 |
| remove.sh / update.sh | — | tmux/kill 로직 **없음**(파일 삭제·venv·문서 갱신뿐) | ✅ 수정 불요(브리프 검토 항목 종결) |
| generate-env.sh / gitea-ci.yml | — | tmux 참조 없음 | ✅ 수정 불요 |
## 1. 파일별 수정 계획 (변경 대비표)
### 1-1. `deploy/install_mam.sh` (우선순위 1 — 기능 파손 수정)
| 행 | 변경 전 | 변경 후 |
|---|---|---|
| 224 | `--tmux-server multi-agent-mux` | `--herdr-server multi-agent-mux` |
| 227 | `$ tmux -L multi-agent-mux attach -t <session_name>` | `$ HERDR_SERVER_NAME=multi-agent-mux herdr agent attach <session_name>` |
근거: 224는 `create_session.sh:63`의 실제 플래그. 227은 create_session.sh:328이 YAML `attach_command`로 방출하는 **canonical 형식**(`HERDR_SERVER_NAME=<server> herdr agent attach <name>`)과 동일하게 맞춘다(개별 에이전트 pane attach). 전체 서버 화면이 필요하면 `herdr session attach multi-agent-mux`도 각주로 병기 가능.
### 1-2. `deploy/install.sh`
| 행 | 변경 전 | 변경 후 |
|---|---|---|
| 31 | `check_cmd tmux` | `check_cmd herdr` |
| 249 | `TMUX_SERVER_NAME=default` | `HERDR_SERVER_NAME=default` |
권장 추가(선택): `check_cmd herdr` 실패 시 install_mam.sh:9698과 동일한 설치 안내(`curl -fsSL https://herdr.dev/install.sh \| sh`)를 출력하도록 `check_cmd` 호출부 뒤에 힌트 블록 추가 — 두 인스톨러의 UX 일관성 확보.
마이그레이션 노트: 기존 설치본 `.env``TMUX_SERVER_NAME`은 소비처가 없어 잔존해도 무해하므로 자동 치환 로직은 불요(계획서 기록으로 갈음).
### 1-3. `deploy/README.md`
| 행 | 변경 전 | 변경 후 |
|---|---|---|
| 9 | ``checks system requirements (`tmux`, `python3`)`` | ``checks system requirements (`herdr`, `python3`)`` |
### 1-4. `deploy/INSTALL.md`
| 행 | 변경 전 | 변경 후 |
|---|---|---|
| 21 | ``시스템의 `tmux`, `python3`, `rsync`, `uuidgen` …을 진단`` | ``시스템의 `herdr`, `python3`, `rsync`, `uuidgen` …을 진단`` |
| 37 | ``**의존성 진단**: … `tmux`, `python3`, …`` | ``**의존성 진단**: … `herdr`, `python3`, …`` |
| 69 | `호스트 재기동으로 tmux가 소멸한 경우에도` | `호스트 재기동으로 herdr 서버가 소멸한 경우에도` |
### 1-5. `deploy/plugin.json`
| 행 | 변경 전 | 변경 후 |
|---|---|---|
| 3 | `"… Backplane on Tmux & MQTT."` | `"… Backplane on Herdr & MQTT."` |
### 1-6. `deploy/remove.sh`, `deploy/update.sh` — **수정 없음** (분석 결과 기록)
두 스크립트 모두 세션 킬링 로직 자체가 존재하지 않고 파일 자산 삭제/갱신만 수행하므로 tmux→herdr 마이그레이션 대상이 아니다. (선택적 후속 개선: remove.sh가 스킬 제거 전 실행 중인 herdr 에이전트 세션을 `multi-agent-mux-stop`으로 정리하도록 권고하는 안내 문구 추가 — 본 브리프 범위 밖이므로 별도 결정.)
## 2. 구현 순서
1. install_mam.sh (기능 파손 우선) → 2. install.sh → 3. 문서 3종(README/INSTALL/plugin.json) → 4. 검증 → 5. 커밋(예: `fix(deploy): migrate deployment scripts and docs from tmux to herdr`).
## 3. 검증 계획 (Reviewer/Creator 공용 DoD)
1. **잔존 스윕**: `grep -rin "tmux" deploy/` → **0건** (대소문자 무시 필수 — `Tmux` 표기가 plugin.json에 존재했음).
2. **정적**: 수정된 .sh에 `bash -n` + `shellcheck -S warning`, 변경 전 대비 신규 경고 0건. plugin.json은 `python3 -m json.tool`로 유효성 확인.
3. **기능(핵심)**: Quick Start 예시를 **그대로 복붙 실행** — 스크래치 워크스페이스에서 `create_session.sh --workspace <scratch> --agent claude --role developer --isolate --herdr-server <scratch서버명> --dry-run` 이 exit 0. (--dry-run이 spawn 전에 종료하므로 라이브 무접촉. `--isolate` 플래그는 create_session.sh:66에서 여전히 수용됨을 확인 완료.)
4. **attach 명령 실증**: 문서의 attach 형식이 실제 YAML `attach_command`(예: `.mam/agent-sessions.yaml:18`)와 동일 형식인지 대조.
5. **회귀**: `.env` 신규 생성 경로에서 `HERDR_SERVER_NAME=default` 기록 확인, `TMUX_SERVER_NAME` 소비처 0곳 재확인(`grep -rn TMUX_SERVER_NAME .agents/skills` = 0).
## 4. 완료 기준
- deploy/ 내 tmux 참조(대소문자 무관) 0건, Quick Start 예시 실행 가능, 정적 검사 클린, 문서 어휘가 현행 스킬 구현(`--herdr-server`/`HERDR_SERVER_NAME`/`herdr agent attach`)과 1:1 일치.
@@ -0,0 +1,95 @@
# ✅ Peer Review Report: multi-agent-mux-loop SKILL.md & run_loop.sh Refactoring (Commits 52c270e, f85fdfc, 6c90342)
**Job**: `47d1dce6` · **Reviewer**: Reviewer B (Cline, `canary-projects-multi-agent-mux-reviewer-cline`)
**Review Targets**:
- Commit `52c270e` — "docs(skill): update multi-agent-mux-loop SKILL manual to reflect skipped planning mode when --plan is omitted"
- Commit `f85fdfc` — "docs(skill): genericize multi-agent-mux-loop SKILL manual by replacing hardcoded agent session names with placeholders"
- Commit `6c90342` — "fix(skill): resolve hardcoded planner session name and plan file paths dynamically in run_loop.sh"
**Prior Context**: PTY 리뷰 5회차 완료 (cc09bae5 PASS). 본 잡은 multi-agent-mux-loop 오케스트레이션 스킬의 문서/스크립트 리팩토링 리뷰.
**Plan Reference**: `.agents/reports/canary-projects-multi-agent-mux-planner-reviewer-claude/report-final.md` (Rev.3)
**Review Scope**: 브리프가 요청한 "잠재적인 문법 오류나 셸 스크립트 오작동 여부 꼼꼼한 검토"
**Method**: 라인 단위 diff 분석 + `bash -n` 문법 검사 + `shellcheck` 정적 분석 + Python 임베디드 코드 4시나리오 런타임实证 + Self-Planning Mode bash 로직 3시나리오 `set -euo pipefail` 시뮬레이션 + mermaid 다이어그램 문법 검증 + 플레이스홀더 일관성 교차 검증
---
## 1. 커밋 개요
3개 커밋이 multi-agent-mux-loop 오케스트레이션 스킬의 문서와 스크립트를 리팩토링:
| 커밋 | 파일 | 변경량 | 내용 |
|------|------|--------|------|
| 52c270e | SKILL.md | 문서 | 계획 생략 모드 설명 업데이트 + mermaid 시퀀스 다이어그램 "Use Existing Plan (No --plan)" 분기 추가 |
| f85fdfc | SKILL.md | 문서 | 하드코딩 에이전트명 → 범용 플레이스홀더(`<creator-session-name>`, `<reviewer-session-name>`) 정제 |
| 6c90342 | run_loop.sh | +5/-3 | 하드코딩 fallback 플래너 세션명/계획 파일 경로 → 동적 `$PLANNER_SESSION` 변수 기반 리팩토링 |
---
## 2. 핵심 검증: run_loop.sh 셸 스크립트 (6c90342)
### 2.1 정적 분석 — ✅ 통과
| 검증 | 방법 | 결과 |
|------|------|------|
| bash 문법 검사 | `bash -n run_loop.sh` | ✅ SYNTAX OK |
| shellcheck (기본) | `shellcheck run_loop.sh` | ✅ EXIT 0 (경고/에러 전무) |
| shellcheck (-x 외부 소스 제외) | `shellcheck -x -S warning run_loop.sh` | ✅ EXIT 0 |
| shellcheck 버전 | 0.11.0 | 최신 분석 도구 |
**평가**: ✅ 셸 스크립트 정적 분석 완벽 통과. 문법 오류, 미정의 변수, 인용 오류, 조건부 파이프라인 등 shellcheck가 감지할 수 있는 모든 결함이 전무.
### 2.2 변경 1: `resolve_planner_session` 함수 (라인 185 영역)
**diff**:
```diff
-planner = 'canary-projects-multi-agent-mux-planner-reviewer-claude'
+planner = ''
for s in d.get('tmux_sessions', []):
if 'planner' in s.get('role', ''):
planner = s.get('name')
```
**분석**: 하드코딩된 플래너 세션명을 빈 문자열 초기값으로 변경. 이후 루프가 `tmux_sessions` 배열에서 `role`에 'planner'가 포함된 세션을 동적으로 검색하여 할당. 찾지 못하면 빈 문자열 반환.
**Python 임베디드 코드 런타임实证 (4시나리오)**:
| 시나리오 | 입력 MAM_STATE_JSON | 출력 | 기대 | 결과 |
|----------|---------------------|------|------|------|
| 1. 플래너 발견 | `{tmux_sessions:[{name:test-creator,role:creator},{name:test-planner-xyz,role:planner}]}` | `test-planner-xyz` | 동적 세션명 | ✅ |
| 2. 플래너 없음 | `{tmux_sessions:[{name:test-creator,role:creator}]}` | ``(빈) | 빈 문자열 | ✅ |
| 3. 빈 상태 | `{}` | ``(빈) | 빈 문자열 | ✅ |
| 4. env var 없음 | unset | ``(빈) | 빈 문자열 | ✅ |
**평가**: ✅ Python 임베디드 코드가 4가지 시나리오에서 모두 올바르게 동작. 동적 세션명 할당 및 빈 문자열 안전 반환 확인.
### 2.3 변경 2: Self-Planning Mode 계획 파일 로드 (라인 312 영역)
**diff**:
```diff
- EXISTING_PLAN_FILE=".agents/reports/canary-projects-multi-agent-mux-planner-reviewer-claude/report-final.md"
- if [ -f "$EXISTING_PLAN_FILE" ]; then
+ EXISTING_PLAN_FILE=""
+ if [ -n "$PLANNER_SESSION" ]; then
+ EXISTING_PLAN_FILE=".agents/reports/$PLANNER_SESSION/report-final.md"
+ fi
+ if [ -n "$EXISTING_PLAN_FILE" ] && [ -f "$EXISTING_PLAN_FILE" ]; then
```
**분석**: 하드코딩된 경로를 `$PLANNER_SESSION` 동적 변수 기반 경로로 변경. 2단계 가드 추가:
1. `[ -n "$PLANNER_SESSION" ]` — 빈 세션명이면 경로 구성 스킵
2. `[ -n "$EXISTING_PLAN_FILE" ] && [ -f "$EXISTING_PLAN_FILE" ]` — 빈 경로이거나 파일이 없으면 로드 스킵
**변수 할당 흐름 추적**:
- `PLANNER_SESSION`는 라인 211에서 `resolve_planner_session()` 호출로 할당 — Self-Planning Mode(라인 312) **이전**에 실행 ✅
- `CURRENT_PLAN`는 라인 213에서 `CURRENT_PLAN=""`로 초기화 — `set -u` (nounset) 오류 방지 ✅
- 라인 335: `if [ -n "$CURRENT_PLAN" ]` — 빈 문자열이면 false → `EXECUTION_PROMPT`에 계획서 미포함 ✅
- 라인 543: `if [ "$PLAN_MODE" = true ] && [ -n "${CURRENT_PLAN:-}" ]` — `${CURRENT_PLAN:-}` 기본값 확장으로 `set -u` 추가 방어 ✅
**Self-Planning Mode bash 로직 시뮬레이션 (3시나리오, `set -euo pipefail` 하)**:
| 시나리오 | PLANNER_SESSION | 동작 | CURRENT_PLAN | 결과 |
|----------|-----------------|------|--------------|------|
| 1. 실제 플래너 (파일 존재) | `canary-...-planner-reviewer-claude` | 계획 로드 | 2220자 | ✅ |
| 2. 빈 문자열 | `` | 파일 로드 스킵 | 0자 | ✅ |
| 3. 다른 플래너 (파일 없음) | `some-other-planner` | 파일 없음 스킵 | 0자 | ✅ |
**평가**: ✅ `set -euo pipefail` (특히 `set -u` nounset) 하에서 3가지 시나리오 모두 에러 없이 통과. 변수 안전성 확보. 2단계 가드 로직이 빈 세션명/존재하지 않는 파일을 올바르게 처리.
@@ -0,0 +1,138 @@
# ✅ Peer Review Report: M2 PTY _exit Syscall Symbol Correction (Commit f0e2bd2)
**Job**: `cc09bae5` · **Reviewer**: Reviewer B (Cline, `canary-projects-multi-agent-mux-reviewer-cline`)
**Review Target**: Commit `f0e2bd2` — "fix(ui): correct libc symbol lookup for direct _exit syscall to achieve async-signal-safety"
**Prior Reviews**:
- Job `66ec158f` (b7901bc) → 3 BLOCKING 결함 지적
- Job `fcf4c9d0` (f52f6eb) → DEFECT 1/2 해결, DEFECT 3 미해결
- Job `7448cb2f` (7781e79) → async-signal-safety/waitpid 해결, DEFECT 3 미해결 (3회차)
- Job `ef0b32ff` (7f1a7e5) → **DEFECT 3 해결 (4회차) + 모든 결함 PASS** — 본 커밋은 ef0b32ff 리뷰의 NON-BLOCKING 관찰 #1 정밀 수정
**Plan Reference**: `.agents/reports/canary-projects-multi-agent-mux-planner-reviewer-claude/report-final.md` (Rev.3, §6.7 PTY 메커니즘 / §10 DoD)
**Review Scope**: 브리프가 명시한 `cExit` lookup 심볼 오타 수정 (`'exit'``'_exit'`) 검증 + 회귀 확인
**Method**: 라인 단위 diff 분석 + `dart analyze`/`flutter analyze`/`dart test` + **`_exit` 심볼 glibc resolve实证** + TMUX env 격리 회귀实证
---
## 1. 커밋 개요
커밋 `f0e2bd2`는 이전 리뷰(ef0b32ff)의 NON-BLOCKING 관찰 #1을 정밀 수정. 1개 파일, +1/-1행 (단일 라인 변경).
**변경 내용** (`pty_session.dart:111`):
```diff
- final cExit = libc.lookupFunction<_exit_c, _exit_dart>('exit');
+ final cExit = libc.lookupFunction<_exit_c, _exit_dart>('_exit');
```
---
## 2. 수정 항목 교차 검증
### 2.1 이전 리뷰 관찰 (ef0b32ff, NON-BLOCKING #1)
> **`cExit` lookup 이름 (정확성)**: 라인 111 `lookup('exit')`는 C `exit()`를 바인딩 (async-signal-unsafe, atexit handlers 실행). 브리프가 "libc exit syscall"이라고 서술했으나, 진정한 async-signal-safe는 `lookup('_exit')` 또는 `lookup('_Exit')`. 단, 자식이 fork 직후이므로 Dart 런타임 atexit handlers가 미등록 상태이며, 기능적으로 자식 종료를 달성하므로 실질적 영향 없음. 향후 정확성을 위해 `_exit` 권장.
### 2.2 수정 검증
**diff 분석**: 라인 111에서 `lookup('exit')``lookup('_exit')`로 정확히 1행 수정. 다른 라인 무변경 ✅.
**C `exit()` vs `_exit()` 구분**:
- `exit(int status)` (stdlib.h): async-signal-**unsafe** — `atexit()` 등록 핸들러 실행, `stdio` 버퍼 flush, `_exit()` 최종 호출
- `_exit(int status)` (unistd.h): async-signal-**safe** — 커널 syscall 직접 호출, 버퍼 flush/handlers 미실행
POSIX async-signal-safety 규칙에 따르면, fork 후 exec 실패 시 자식에서 호출할 수 있는 함수는 async-signal-safe 목록에 있는 함수만. `_exit()`는 이 목록에 포함되나, `exit()`는 포함되지 않음. 본 수정으로 자식 분기의 예외 퇴장 경로(`cExit(-1)` at 라인 192, `cExit(-2)` at 라인 211)가 진정한 async-signal-safe `_exit` syscall을 사용하게 됨.
**FFI 시그니처 일관성**: typedef `_exit_c = ffi.Void Function(ffi.Int32 status)` / `_exit_dart = void Function(int status)`는 C `_exit(int)` 시그니처와 정확히 일치 ✅. 변경 전에도 시그니처는 `_exit` 기준이었으나 lookup 이름만 `exit`였던 불일치가 해결됨.
---
## 3. `_exit` 심볼 glibc resolve实证
**검증 방법**: `nm -D /lib/x86_64-linux-gnu/libc.so.6`로 glibc에서 `_exit` 심볼 존재 확인 + Dart FFI `lookupFunction<_exit_c, _exit_dart>('_exit')` 실행实证.
**결과 1 — glibc 심볼 확인**:
```
$ nm -D /lib/x86_64-linux-gnu/libc.so.6 | grep -w '_exit'
00000000000f7480 T _exit@@GLIBC_2.2.5
```
`T` (Text segment, exported symbol) — `_exit`가 glibc에 존재하며 export됨 ✅.
**결과 2 — Dart FFI lookup实证**:
```
SUCCESS: _exit symbol resolved from libc.so.6 - async-signal-safe exit syscall available
(lookupFunction throws if symbol not found, so reaching here means _exit is bound)
```
`lookupFunction<_exit_c, _exit_dart>('_exit')`가 예외 없이 성공 — 런타임에 `_exit` 심볼이 올바르게 바인딩됨을实证 ✅. `lookupFunction`은 심볼을 찾지 못하면 `ArgumentError`를 throw하므로, 정상 실행 자체가 resolve 성공의 증거.
**평가**: ✅ `lookup('_exit')`가 glibc의 `_exit@@GLIBC_2.2.5` 심볼을 올바르게 바인딩. 런타임에 자식 분기의 `cExit(-1)`/`cExit(-2)` 호출이 진정한 async-signal-safe `_exit` syscall을 기동함.
---
## 4. 정적 분석 및 회귀 검증
| 항목 | 검증 방법 | 결과 |
|------|----------|------|
| `dart analyze` (mam_pty) | 실행 | ✅ No issues found! |
| `flutter analyze` (mam_desktop) | 실행 | ✅ No issues found! |
| M1 회귀 (`dart test` mam_core) | 실행 | ✅ 3/3 All tests passed |
| 런타임 PTY 동작 (`dart test` echo) | 실행 | ✅ echo `hello-pty-ok` 출력 정상 |
| DEFECT 3 TMUX env 격리 (회귀) | 런타임实证 (TMUX 설정 + printenv) | ✅ PASS — 자식 printenv 빈 출력 (회귀 없음) |
| `_exit` 심볼 glibc resolve | `nm -D` + Dart FFI lookup实证 | ✅ `_exit@@GLIBC_2.2.5` 바인딩 성공 |
| 기존 스크립트 회귀 | git diff --stat | ✅ status.sh 외 기존 스크립트 무변경 |
**전체 테스트 실행 결과** (부모에 `TMUX=fake-server,12345,0 TMUX_PANE=%5` 설정):
```
00:00 +0: test/pty_runtime_test.dart: PtySession runtime execution resolves process output
PTY Runtime stdout verified: hello-pty-ok
00:00 +1: test/pty_runtime_test.dart: PtySession strips TMUX/TMUX_PANE from child environment
printenv TMUX TMUX_PANE output: []
00:00 +2: All tests passed!
```
이전 리뷰(ef0b32ff)에서 PASS 판정된 모든 기능이 회귀 없이 유지됨:
- DEFECT 1 (/proc/self/fd 경로): ✅ 유지
- DEFECT 2 (자식 stdio PTY 연결): ✅ 유지
- DEFECT 3 (TMUX env 격리, unsetenv): ✅ 유지 (회귀 없음)
- async-signal-safety: ✅ 유지 + `_exit` 정확성 향상
- waitpid zombie reaping (blocking): ✅ 유지
- non-blocking master fd (fcntl): ✅ 유지
---
## 5. AGENTS.md 원칙 검증
- **Surgical Changes (§3)**: 단일 라인 수정 (`'exit'``'_exit'`) — 이전 리뷰 관찰에 정확히 대응하는 최소 변경 ✅. 다른 코드/포맷/주석 무변경. "Every changed line should trace directly to the user's request" — 본 수정은 1행이며 리뷰 관찰 #1에 직접 추적됨.
- **Simplicity First (§2)**: 단일 라인 정밀 수정 — 더 단순할 수 없는 최소 변경 ✅.
- **Goal-Driven Execution (§4)**: 본 수정의 성공 기준은 "async-signal-safe `_exit` syscall 바인딩" → `nm -D` + Dart FFI lookup实证으로 검증 완료 ✅.
- **Think Before Coding (§1)**: 이전 리뷰(ef0b32ff)에서 `exit()` vs `_exit()`의 async-signal-safety 차이를 명확히 지적했으며, 주 개발자가 이를 정확히 이해하고 수정 — §1 원칙 이행.
---
## 6. 종합 평가
커밋 `f0e2bd2`는 이전 리뷰(ef0b32ff)의 NON-BLOCKING 관찰 #1을 **정확히 단일 라인으로 해결**:
### 수정 항목 — 해결
1.**`cExit` lookup 심볼 정확성**: `lookup('exit')``lookup('_exit')`로 수정. C `exit()` (async-signal-unsafe, atexit handlers 실행) 대신 C `_exit()` (async-signal-safe, 커널 syscall 직접 호출)를 바인딩. 자식 분기의 예외 퇴장 경로(`cExit(-1)` slave open 실패, `cExit(-2)` execvp 실패)가 진정한 async-signal-safe `_exit` syscall을 사용.
### 검증 결과
- `dart analyze`: No issues found ✅
- `flutter analyze`: No issues found ✅
- M1 회귀: 3/3 All tests passed ✅
- 런타임 PTY echo: 정상 동작 ✅
- 런타임 TMUX env 격리: ✅ PASS (회귀 없음)
- **`_exit` 심볼 glibc resolve实证**: ✅ `_exit@@GLIBC_2.2.5` 바인딩 성공 (nm -D + Dart FFI lookup)
- 기존 스크립트 회귀: 없음 ✅
### 전체 리뷰 이력 (5회차 누적)
| 회차 | 커밋 | 판정 | 핵심 |
|------|------|------|------|
| 1 (66ec158f) | b7901bc | NOT PASS | 3 BLOCKING 결함 지적 |
| 2 (fcf4c9d0) | f52f6eb | NOT PASS | DEFECT 1/2 해결, DEFECT 3 미해결 |
| 3 (7448cb2f) | 7781e79 | NOT PASS | async-signal-safety/waitpid 해결, DEFECT 3 미해결 |
| 4 (ef0b32ff) | 7f1a7e5 | **PASS** | DEFECT 3 해결 (unsetenv), 모든 결함 해결 |
| 5 (본 리뷰) | f0e2bd2 | **PASS** | NON-BLOCKING 관찰 #1 정밀 수정 (_exit 심볼) |
ef0b32ff에서 PASS 판정된 모든 기능이 회귀 없이 유지되며, 추가로 `_exit` syscall 바인딩 정확성이 향상됨. M2 마일스톤(Desktop PTY 연동 + attach terminal tab)의 모든 핵심 계약(§6.7 PTY 메커니즘, §10 DoD)이 런타임实证으로 검증됨. 정적 분석과 런타임实证 테스트가 모두 통과.
[VERDICT: PASS]
@@ -0,0 +1,179 @@
# ✅ Peer Review Report: M1 Dashboard & Detail Pane Implementation (Commit 2eb8586)
**Job**: `cb97a36f` · **Reviewer**: Reviewer B (Cline, `canary-projects-multi-agent-mux-reviewer-cline`)
**Review Target**: Commit `2eb8586` — "feat(ui): complete M1 Milestone - read-only Dashboard and Detail Pane with status.sh integration"
**Plan Reference**: `.agents/reports/canary-projects-multi-agent-mux-planner-reviewer-claude/report-final.md` (Rev.3 — Flutter 전면 재작성 계획서)
**Review Scope**: 계획서에 입각하여 제출된 코드가 안전하고 모순 없이 구현되었는지 교차 검증 (구현하지 않음, 리뷰만 수행)
**Method**: 계획서 §3(D8), §5(아키텍처), §6(예외처리/보안 계약), §10(DoD)를 실제 커밋 코드와 라인 단위 교차 검증 + 라이브 실행实证 + Dart 테스트/정적 분석 실행
---
## 1. 커밋 개요
커밋 `2eb8586`는 M1 마일스톤(읽기 전용 대시보드 + Detail Pane)을 구현. 22개 파일, +1941/-326행. 핵심 변경:
- `status.sh` additive 스키마 확장 (D8 해법 구현, +103/-5행)
- `packages/mam_core/` — 순수 Dart 데이터/서비스 계층 (models, command_runner, session_service, status_repository)
- `apps/mam_desktop/` — Flutter Desktop UI (main, session_table, detail_pane, stale_banner, providers, theme, status_script_locator)
- `packages/mam_core/test/session_service_test.dart` — 3개 단위 테스트
---
## 2. D8 — `status.sh --json` additive 스키마 확장 (§3.1) 검증
**계획서 요구**: 기존 5개 키(timestamp/yaml_path/tmux_sessions_alive/tmux_confirmed/drifts/actions) 무변경 + 신규 `sessions_detail` 키 추가. 텍스트 모드 byte-identical 회귀 없음.
**라이브 실행实证**:
```
$ bash status.sh --json | python3 -m json.tool
top keys: ['timestamp', 'yaml_path', 'tmux_sessions_alive', 'tmux_confirmed', 'drifts', 'actions', 'sessions_detail']
sessions_detail count: 2
sessions_detail[0] keys: ['name', 'server', 'status', 'tmux_alive', 'cmd', 'role', 'resume_state',
'job_id', 'job_status', 'pane_cwd', 'attach_command', 'drift_classes', 'pane_pid', 'cmd_full',
'start_command', 'last_visible_status']
```
- 기존 6개 키(timestamp/yaml_path/tmux_sessions_alive/tmux_confirmed/drifts/actions) **전부 보존**
- 신규 `sessions_detail` 키 추가 ✅
- `sessions_detail` 필드가 계획서 §3.1의 D8 계약(name/server/status/tmux_alive/cmd/role/resume_state/job_id/job_status/pane_cwd/attach_command/drift_classes)과 **field-for-field 일치**
- additive beyond D8: `pane_pid`/`cmd_full`/`start_command`/`last_visible_status` — Detail Pane용 추가 필드, 계획서가 "세션명/워크스페이스 등을 계산하는 부분"이라 명시한 범위 내 ✅
**텍스트 모드 회귀 검증 (DoD-1)**:
```
$ diff <(old status.sh text output) <(new status.sh text output)
1c1
< agent-sessions status — 2026-07-16T12:16:37Z (tmux_confirmed=True)
---
> agent-sessions status — 2026-07-16T12:16:38Z (tmux_confirmed=True)
```
유일한 차이는 타임스탬프(1초) — 본문 byte-identical ✅. git diff 분석: 변경은 `--json` 분기(조기 exit 제거 + 새 Python 블록 추가)에만 국한, 텍스트 모드 Python 블록(라인 31~119)은 **무변경** ✅.
---
## 3. 아키텍처 준수 (§5) 검증
### 3.1 모노레포 패키지 구조 (§5.2)
**검증**: `packages/mam_core/`(순수 Dart, Flutter 비의존) + `apps/mam_desktop/`(Flutter Desktop) 분리 구현 ✅. `mam_core``dart:io`/`dart:convert`/`package:meta`만 의존하고 Flutter 엔진 의존성이 없음을 확인 — 헤드리스 실행 가능 원칙 준수. `mam_core.dart` barrel export가 models/services/command_runner를 깔끔히 노출.
### 3.2 `command_runner.dart` — 유일한 서브프로세스 실행 지점 (§6.1, D5)
**검증**:
- `Process.start(argv.first, argv.sublist(1), runInShell: false)` — argv list 강제, `runInShell: false` 명시 ✅ (D5 계약)
- `Future.any([exitFuture, Future.delayed(timeout)])`로 클라이언트측 타임아웃 강제 ✅ (§6.1)
- `killOnTimeout` 파라미터: `true`면 SIGTERM→5s→SIGKILL, `false`면 프로세스 백그라운드 완주 + `backgroundFuture` 반환 ✅ (D-Critical purge 계약)
- `CommandResult``timedOut`/`backgroundFuture` 필드로 타임아웃 상태 명확히 구분 ✅
**평가**: ✅ §6.1 의사코드 계약을 정확히 구현. D5(명령 주입 방지) + D-Critical(purge 원자성 보존) 모두 충족.
### 3.3 `status_repository.dart` — 폴링 + stale/backoff (§6.6, D6)
**검증**:
- `Stream<SessionsPoll> watch()` — 폴링 루프, 실패 시 `lastGood` 스냅샷 유지 + `stale: true` 표시 ✅ (D6)
- 백오프: `failureBackoff = [3s, 6s, 15s]` — 계획서 §6.6 "3s→6s→최대 15s"와 일치 ✅
- `SessionsPoll` 모델: `snapshot`/`stale`/`lastOkAt`/`error` — stale 배너에 필요한 정보 전부 포함 ✅
- 기본 폴링 간격 4초(계획서는 3초 권장) — 경미한 차이이나 계획서가 "기본 3초, 설정 가능"이라 했으므로 구현 재량 범위 내
**평가**: ✅ D6 계약 정확히 구현. UI가 null/blank dashboard를 보지 않도록 보장.
### 3.4 `session_service.dart` — status.sh --json 래핑 (§2, Rev.1 §1)
**검증**:
- `runCommand(['bash', statusScriptPath, '--json'], timeout: 5s)` — 조회 5초 타임아웃(§6.1) ✅
- `timedOut`/`rc != 0`/`jsonDecode` 실패 시 `StatusFetchException` throw — `StatusRepository`가 이를 catch해 stale 처리 ✅
- `decoded is! Map<String, dynamic>` 타입 가드 ✅
- "이 코드는 YAML/SQLite/jsonl을 직접 읽지 않는다" — `status.sh --json` 출력만 소비, Rev.1 §1 원칙 준수 ✅
**평가**: ✅ 단일 진실 공급원 원칙 준수.
---
## 4. UI 계층 검증 (§7 화면 설계)
### 4.1 `main.dart` — DashboardScreen (§7 Sessions 대시보드)
**검증**:
- `ProviderScope` + `ConsumerWidget` — Riverpod 상태관리 (§5.1) ✅
- `sessionsPollProvider` StreamProvider 구독 → `pollAsync.when(data/loading/error)`
- Master-Detail 레이아웃: `SessionTable`(flex:3) + `DetailPane`(width:380) ✅ (§7)
- `_ErrorScreen` — 폴링 시작 실패 시 에러 화면 ✅
- `StaleBanner` — stale 상태 표시 ✅ (D6)
### 4.2 `session_table.dart` — DataTable2 (§7)
**검증**:
- `data_table_2` 사용 (§5.1 스택 선정) ✅
- 컬럼: `NAME/SERVER/YAML/TMUX/CMD/RESUME/JOB_ID/JOB_STATUS/DRIFT` — 계획서 §7 "Rev.1 §4.1과 동일 컬럼 셋" 정확히 일치 ✅
- 행 선택(`onTap``onSelect`) → `selectedSessionNameProvider` 업데이트 ✅
- `_StatusChip`/`_TmuxChip` — 상태별 색상 코딩(running=success, dead=danger) ✅
- 빈 상태 처리(`empty:` widget) ✅
### 4.3 `detail_pane.dart` — Detail Pane (§7)
**검증**:
- `SessionRow?` null 처리 → `_EmptyDetail`("Select a session") ✅
- PANE 섹션: pid/cwd/cmd/cmd_full ✅
- ATTACH 섹션: attach_command/start_command + 복사 버튼(`Clipboard.setData`) ✅ (§4 "복사 버튼" 요구사항)
- STATUS 섹션: last_visible_status/resume_state/job_id/job_status/drift_classes ✅
- `SelectableText` — 텍스트 선택 가능 ✅
- `_Header` — 세션명 + 상태 pill(status/tmux/role/server) ✅
### 4.4 `stale_banner.dart` — D6 stale 배너 (§6.6)
**검증**:
- `poll.stale` false → `SizedBox.shrink()` (숨김) ✅
- stale true → 경고 배너 "⚠ status snapshot stale (last ok: HH:MM:SS)" ✅
- `lastOkAt` 포맷팅(HH:MM:SS) ✅
### 4.5 `status_script_locator.dart` — 스크립트 경로 해석
**검증**: `.git` 마커로 repo root walk-up → 고정 경로 하강. 하드코딩 절대경로 없음. `flutter run` 실행 디렉터리 무관 robustness ✅. 계획서가 명시하지 않았으나 구현 품질 향상(Rev.1 §8 "no hardcoded absolute path" 원칙 계승).
### 4.6 `session_providers.dart` — Riverpod wiring
**검증**: `sessionServiceProvider``statusRepositoryProvider``sessionsPollProvider` 계층적 의존성 주입 ✅. `apps/mam_desktop`이 폴링/백오프 로직을 재구현하지 않고 `mam_core`에 위임 ✅ (§6.6 "Framework agnostic" 원칙).
---
## 5. DoD (§10) 실증 검증
계획서 §10의 12개 DoD 항목 중 M1 범위에서 검증 가능한 항목들을 실제로 실행 검증:
| DoD | 항목 | 검증 방법 | 결과 |
|-----|------|----------|------|
| 1 | `status.sh` 회귀 없음 (텍스트 모드 byte-identical) | old vs new text output diff | ✅ PASS (타임스탬프만 차이, 본문 동일) |
| 2 | 비파괴 검증 (mam_core/pty에 파일 쓰기/삭제 없음) | `grep -rn` | ✅ PASS (코드 전무) |
| 3 | 명령 주입 방어 (`runInShell: true` 금지) | `grep -rn 'runInShell'` | ✅ PASS (`runInShell: false`만 존재) |
| 9 | 정적 분석 (`dart analyze` clean) | `dart analyze` 실행 | ✅ PASS (No issues found!) |
| 11 | 회귀 없음 (stop/create/resume/monitor/lib.sh 무변경) | `git diff --stat` | ✅ PASS (status.sh만 변경) |
| — | Dart 단위 테스트 | `dart test` 실행 | ✅ PASS (3/3 All tests passed!) |
**DoD-1 상세 (jq diff 대체 검증)**: 기존 5개 키(timestamp/yaml_path/tmux_sessions_alive/tmux_confirmed/drifts) + actions 키가 신규 `sessions_detail` 추가 전후로 동일함을 라이브 실행으로 확인. `sessions_detail`은 순수 additive.
**테스트 커버리지** (`session_service_test.dart`):
1. `SessionsSnapshot.fromJson` well-formed payload 파싱 — drift 클래스, role, resume_state, pane.pid, attach_command 전부 정확히 매핑 ✅
2. 누락 필드 허용(`{"name": "bare"}`) — 기본값(`?`/`-`/null) 적용 ✅
3. 실제 `status.sh --json` 출력 파싱 — 라이브 연동 검증 ✅
**평가**: ✅ M1 범위 DoD 전부 충족. 테스트는 실제 `status.sh` 라이브 연동까지 검증하여 매우 견고함.
---
## 6. 코드 품질 관찰 (NON-BLOCKING — PASS에 영향 없음)
아래 항목들은 통과를 막는 결함이 아니며, 향후 마일스톤에서 고려하면 더 견고해지는 사항이다.
1. **폴링 간격 (선택)**: 계획서 §6.6/§7이 "기본 3초"를 권장했으나 `StatusRepository` 기본값이 4초(`pollInterval: Duration(seconds: 4)`). 경미한 차이이며 계획서가 "설정 가능"이라 명시했으므로 구현 재량 범위. 향후 사용자 피드백에 따라 조정 가능.
2. **`sessions_detail` additive 필드 (주의 권고)**: `pane_pid`/`cmd_full`/`start_command`/`last_visible_status` 4개 필드가 계획서 §3.1의 D8 예시 스키마를 초과해 추가됨. 코드 주석이 "Additive beyond the D8 example — needed by the M1 Detail Pane"이라 명시했으므로 의도적 확장이며, `SessionRow.fromJson`이 이를 안전히 파싱(누락 시 null). 회귀 위험 없음. 단, 향후 `status.sh` 출력 스키마를 문서화할 때 이 4개 필드도 계획서에 갱신하면 추적성 향상.
3. **`status_script_locator.dart` 예외 메시지 (선택)**: `.git` 디렉터리를 못 찾았을 때 "Run mam_desktop from within the multi-agent-mux repo checkout"이라는 안내가 명확. 다만 submodule/worktree 환경에서 `.git`이 파일인 경우(`.git` 디렉터리가 아님)를 고려하면 더 robust해짐. (현재 환경에서는 이슈 없음)
4. **`_StatusChip` switch 표현식 (선택)**: `case 'stopped': case 'terminated': case 'archived':` fallthrough가 의도한 대로 동작하나, Dart 3 switch 표현식에서 여러 case가 연속일 때 가독성이 약간 떨어질 수 있음. 기능적으로 정확하므로 스타일 선호 영역.
---
## 7. AGENTS.md 원칙 준수 검증
- **Surgical Changes (§3)**: 변경이 M1 대시보드/Detail Pane + D8 `status.sh` 확장에만 국한. 기존 스크립트(stop/create/resume/monitor/lib.sh) 무변경. `git diff --stat`로 확인 ✅
- **Simplicity First (§2)**: `mam_core`(순수 Dart) + `mam_desktop`(Flutter) 관심사 분리. `command_runner.dart` 유일 실행 지점으로 과잉 추상화 없음. 각 모델 클래스 단일 책임 ✅
- **Goal-Driven Execution (§4)**: §10 DoD 항목 전부 관측 가능(grep/diff/dart test/dart analyze). 라이브 실행实证으로 회귀 없음 입증 ✅
- **문서-코드 정합성**: 계획서 §3.1 D8 스키마 ↔ `status.sh` `sessions_detail` 출력 ↔ `SessionRow.fromJson` 매핑 — 3계층 전부 field-for-field 일치 ✅
---
## 8. 종합 평가
커밋 `2eb8586`는 계획서(Rev.3)의 M1 마일스톤(읽기 전용 대시보드 + Detail Pane)을 충실하게 구현했다. 핵심 성과:
1. **D8 additive 스키마 확장 정확 구현**: `status.sh --json`이 기존 6개 키를 무변경으로 보존하면서 `sessions_detail` 신규 키를 추가. 라이브 실행实证으로 기존 소비자 회귀 없음을 확인했으며, 텍스트 모드는 byte-identical(타임스탬프만 차이).
2. **불변 안전 계약 정확 이식**: `command_runner.dart`가 D5(명령 주입 방지, `runInShell: false`) + D-Critical(purge `killOnTimeout: false` + `backgroundFuture`) + §6.1 타임아웃(조회 5초)을 정확히 구현. `status_repository.dart`가 D6(stale 스냅샷 유지 + 백오프 3s→6s→15s)을 충족.
3. **모노레포 관심사 분리**: `mam_core`(순수 Dart, Flutter 비의존)가 데이터/서비스 계층을 담당하고 `mam_desktop`이 Riverpod으로 wiring — 계획서 §5.2 구조 정확히 반영.
4. **견고한 테스트**: 3개 단위 테스트(파싱 정확성 + 누락 필드 허용 + 실제 `status.sh` 라이브 연동) 전부 통과. `dart analyze` No issues found.
5. **회귀 없음**: 기존 셸 스크립트(stop/create/resume/monitor/lib.sh) 전부 무변경, `status.sh``--json` 분기 내부에만 additive 변경.
개선 권고 4건은 모두 NON-BLOCKING(구현 재량/스타일/향후 문서화)으로 통과 판정에 영향을 주지 않는다. 코드는 계획서에 입각해 안전하고 모순 없이 구현되었다.
[VERDICT: PASS]
@@ -0,0 +1,41 @@
# 리뷰 리포트 — Job dbab0e07
- **리뷰 대상**: 커밋 `36b3910` — (1) `create_session.sh` agy 인증 사전검증을 파일 기반으로 우회해 macOS 키체인 접근 Hang 방지, (2) `lib.sh provision_isolation()`에 Darwin 전용 `~/Library/Keychains` 심링크 시딩 추가로 격리 모드 인증 토큰 소실 해결
- **리뷰어**: claude (planner-reviewer)
- **리뷰 방식**: 정적 분석(bash -n, shellcheck 기준선 대비) + 계측 스텁/가짜 HOME/uname 오버라이드 기반 실행 검증
## 1. 설계 타당성
- **Hang 우회**: agy 격리 lever가 `HOME=<root>`이고(lib.sh 주석의 Phase 0 실측 매트릭스), macOS에서 `agy models`가 키체인 접근 프롬프트로 비대화식 환경에서 블로킹되는 문제를, 디스크상 토큰 파일(`~/.gemini/oauth_creds.json` 또는 `~/.gemini/antigravity-cli/antigravity-oauth-token`) 존재 시 CLI 호출 자체를 생략하는 방식으로 회피 — 검사 파일 경로 2개가 `provision_isolation()`이 agy 자격증명으로 시딩하는 파일 목록과 정확히 일치함(저장소 내부 지식과 정합).
- **토큰 소실 해결**: 격리 시 `HOME=<root>`로 바뀌면 macOS 키체인 경로(`$HOME/Library/Keychains`)가 빈 격리 홈을 가리켜 자격증명 조회가 실패하는 구조 — 실제 Keychains 디렉터리를 심링크로 시딩하는 것은 이 파일의 기존 철학("auth/config files are SYMLINKED ... never copied — token refresh must converge on the real files")과 일치하는 올바른 해법.
## 2. 실행 검증 (전부 실측)
- **사전검증 우회(Case A)**: 가짜 HOME에 토큰 파일 배치 + 호출 기록 스텁 `agy`를 PATH 선두에 두고 `create_session.sh --dry-run --agent agy` 실행 → **`agy` 바이너리가 단 한 번도 실행되지 않음**(Hang 원인 원천 제거 확인), exit 0.
- **폴백 보존(Case B)**: 토큰 파일 없는 빈 HOME → `agy models`가 정확히 1회 호출되고 스텁 실패 시 기존 오류 메시지("agy is not authenticated")와 exit 1이 그대로 동작 — 미인증 조기 차단 시맨틱 유실 없음.
- **Keychains 시딩**: lib.sh를 소싱한 격리 하네스에서 `uname`을 Darwin으로 오버라이드하고 가짜 HOME(`Library/Keychains/login.keychain-db` 포함)으로 `provision_isolation agy` 실행 →
- 심링크 정상 생성, 격리 홈 경유 read-through로 실제 키체인 데이터 접근 확인.
- `seeded` 출력에 `Library/Keychains`가 기존 포맷대로 병합됨.
- **재프로비저닝 멱등성**: 2회 실행에도 `ln -sfn``-n` 덕에 중첩 링크(`Keychains/Keychains`) 없이 동일 결과.
- **🔑 삭제 안전성(최중요)**: create rollback의 `rm -rf "$ISOLATION_ROOT"` 시뮬레이션 → **심링크만 제거되고 실제 키체인 파일은 온전히 생존**함을 실측 확인(rm -rf는 심링크를 따라 들어가지 않음). `seeded` 목록을 순회하며 삭제하는 소비자는 코드베이스에 존재하지 않음(생성·기록 전용)도 grep으로 확인.
## 3. 정적 분석
- `bash -n` 양 파일 통과. `shellcheck -S warning`: 변경 전 기준선(fc24af4) 대비 양 파일 모두 **경고 0건 → 0건, 신규 경고 없음**.
## 4. 유실 검사
- agy 외 에이전트(claude/cline/hermes)의 provision 분기·사전검증 분기는 바이트 단위로 무변경. Darwin 가드로 Linux에서 Keychains 시딩 완전 스킵(Linux 회귀 없음).
## 5. 비차단(Non-blocking) 지적 사항
1. **사전검증 약화** — 파일 존재가 토큰 유효성을 보증하지 않으므로, 만료/폐기된 토큰은 이제 preflight를 통과하고 TUI 기동 단계에서야 실패가 드러남. Hang 대비 합리적 트레이드오프이나 오류 표면화 시점이 늦어짐.
2. **Darwin 미게이팅** — 우회 분기가 OS 무관하게 적용되어, Hang이 없던 Linux에서도 엄격 검사가 생략됨(부수적으로 네트워크 호출 생략이라 빨라지는 이점은 있음). 엄격성이 중요해지면 `uname` 게이트 추가 고려.
3. **자격증명 격리 부재(의도된 설계)** — 격리 세션이 실제 키체인을 공유하게 되나, 시딩의 목적 자체가 인증 공유이므로 기존 심링크 시딩 철학과 일치. 기록 차원의 언급.
4. **macOS 실기기 미검증** — Security.framework가 심링크된 `$HOME/Library/Keychains`를 실제로 수용하는지는 Linux 환경에서 실측 불가. 메커니즘 수준(경로 해석·링크·멱등성·삭제 안전성)은 전부 검증 완료.
## 6. 결론
두 수정 모두 고장 메커니즘을 정확히 겨냥했고, 우회·폴백·시딩·멱등성·삭제 안전성이 전부 실행으로 입증되었으며 정적 분석 신규 경고와 기존 동작 유실이 없다. 비차단 4건은 후속 개선/기록 수준이다.
[VERDICT: PASS]
@@ -0,0 +1,32 @@
# Peer Review: `multi-agent-mux-loop` SKILL 문서 정비 + `run_loop.sh` 하드코딩 제거 (커밋 `52c270e`/`f85fdfc`/`6c90342`)
## Scope
세 커밋을 검토했다: (1) `52c270e` — SKILL.md의 "Self-Planning"(계획 완전 생략) 서술을 "Existing Plan Execution"(기존 승격 계획서 로드 후 즉시 구현)으로 정정, (2) `f85fdfc` — SKILL.md 내 하드코딩된 세션명(`canary-projects-multi-agent-mux-*`)을 플레이스홀더(`<planner-session-name>`/`<creator-session-name>`/`<reviewer-session-name-N>`)로 치환, (3) `6c90342``run_loop.sh``resolve_planner_session()` 폴백과 기존 계획 파일 경로를 실제로 동적화. 문서 변경(1, 2)은 렌더링/의미 정합성 위주로, 셸 스크립트 변경(3)은 문법·동작 검증 위주로 리뷰했다.
## 1, 2. SKILL.md 문서 변경 검토
- **`52c270e`**: `--plan` 미지정 시의 실제 동작(계획서 승격 파일을 로드해 즉시 구현 착수)과 서술("Self-Planning", "계획을 거치지 않고 직접 구현")이 이전엔 어긋나 있었다 — 실제로는 완전한 무계획 실행이 아니라 "기존 계획서가 있으면 그걸 쓴다"는 동작이므로, 이번 수정으로 프로즈/표/mermaid 다이어그램의 분기 라벨("Use Existing Plan (No --plan)")이 셋 다 일관되게 정정되었다. 세 위치(설명 불릿, 표, 다이어그램) 모두 누락 없이 반영됨을 확인.
- **`f85fdfc`**: 하드코딩된 세션명이 매뉴얼 예시 곳곳(다이어그램 참가자 라벨, `--target-agent`/`--reviewer` 예시 값)에 있었는데, 전부 제네릭 플레이스홀더로 치환됨. `grep -n "canary-projects-multi-agent-mux" SKILL.md` 기준으로 잔여 하드코딩이 없는지 확인했다(아래 §3 참고 — 실제로는 no-arg `--plan`을 하드코딩 언급 없이 완전히 정리했음을 확인).
두 커밋 모두 마크다운/mermaid 문법 오류 없이 코드펜스와 표 구조를 그대로 유지했다.
## 3. `run_loop.sh` 변경 검토 (실행 검증 포함)
### 변경 내용
- `resolve_planner_session()`의 폴백 값이 `'canary-projects-multi-agent-mux-planner-reviewer-claude'`(하드코딩)에서 `''`(빈 문자열)로 변경 — 이제 `role``'planner'`를 포함하는 tmux 세션을 찾지 못하면 특정 프로젝트 이름으로 잘못 추측하지 않고 정직하게 "찾지 못함"을 반환한다.
- 기존 계획서 로드 블록(`else` 분기, 314-322행)이 `EXISTING_PLAN_FILE=".agents/reports/canary-projects-multi-agent-mux-planner-reviewer-claude/report-final.md"`(하드코딩)에서 `PLANNER_SESSION` 기반 동적 경로로 변경되고, `[ -n "$PLANNER_SESSION" ]` 가드가 추가되어 세션을 못 찾은 경우 경로 조합 자체를 건너뛴다.
### 검증
- `bash -n run_loop.sh` → 문법 오류 없음.
- `shellcheck run_loop.sh` → 경고/오류 0건(종료 코드 0).
- **`resolve_planner_session()`을 실제로 발췌·소싱해 현재 라이브 상태에 대해 실행**: `canary-projects-multi-agent-mux-planner-reviewer-claude`를 정확히 반환함(현재 이 세션의 role이 `planner-reviewer`이므로 `'planner' in role` 매치) — 우연이 아니라 실제 동작 확인. 이어서 이 값으로 조합된 경로(`.agents/reports/canary-projects-multi-agent-mux-planner-reviewer-claude/report-final.md`)가 실제로 파일시스템에 존재함을 확인해, 이 프로젝트에서는 하드코딩 시절과 동일한 결과를 내면서도 이제는 진짜로 동적임을 증명했다.
- **빈 `PLANNER_SESSION` 엣지 케이스**(플래너 역할 세션이 아예 없는 워크스페이스를 시뮬레이션): 동일한 `set -euo pipefail` 하에서 새 로직 스니펫만 분리 실행 → `EXISTING_PLAN_FILE`이 빈 문자열로 남고 "계획 로드 건너뜀" 분기가 정상 작동, `set -u`(nounset)로 인한 미정의 변수 오류도 없음(`PLANNER_SESSION`은 항상 대입되므로 빈 문자열이어도 unset이 아님) — 하드코딩이 없어진 대신 도입될 수 있었던 "다른 워크스페이스에서 조용히 깨짐" 위험이 실제로는 없음을 확인.
- `--plan` 모드 경로(224-228, 285-286, 496-497, 543-548행)의 `$PLANNER_SESSION` 사용처는 이번 diff의 대상이 아니며, 플래너 세션이 비어 있을 경우 `multi-agent-mux-delegate-job submit`이 초반에 실패로 이어지는 fail-fast 구조라 이번 변경으로 인한 새로운 침묵 실패 경로는 없다.
- `git diff 7c94eef 6c90342 --stat` → 이 세 커밋이 건드린 파일은 `SKILL.md``run_loop.sh` 딱 둘뿐, 회귀 없음.
## 결론
문서 두 건은 실제 동작과 서술의 불일치를 바로잡고 하드코딩된 예시를 제네릭화한 정확한 수정이며, 셸 스크립트 변경은 실제로 실행해 정상 케이스(현재 세션 정확히 해석)와 엣지 케이스(플래너 세션 부재 시 안전한 스킵) 모두를 검증했다. 문법 오류, shellcheck 경고, 회귀 모두 없다.
[VERDICT: PASS]
@@ -0,0 +1,40 @@
# Peer Review (Round 5, 최종): `exit`→`_exit` 심볼 정정 (commit `f0e2bd2`) — `multi-agent-mux-ui` M2
## Scope
`604fdecf` 리뷰에서 지적한 마지막 1건 — `cExit``lookupFunction<...>('exit')`로 잘못된(async-signal-unsafe) libc 심볼에 바인딩되어 있던 문제 — 에 대한 수정 커밋 `f0e2bd2`("fix(ui): correct libc symbol lookup for direct _exit syscall to achieve async-signal-safety")를 검토했다.
## 변경 확인
`pty_session.dart:111`, 문자열 리터럴 한 글자(정확히는 언더스코어 하나) 수정:
```diff
- final cExit = libc.lookupFunction<_exit_c, _exit_dart>('exit');
+ final cExit = libc.lookupFunction<_exit_c, _exit_dart>('_exit');
```
이 파일에 대한 이번 커밋의 변경은 이 한 줄이 전부다(그 외 diff는 `.dart_tool` 빌드 캐시 바이너리뿐).
## 실행 검증
1. **심볼 재확인**: `libc.lookup('_exit').address`가 이제 실제로 `cExit`가 가리키는 주소와 일치함을 별도 스크립트로 재확인(이전 라운드에서 `'exit'`/`'_exit'`가 서로 다른 주소임을 이미 확정했던 것과 대조).
2. **실제 실패 경로 재현**: 존재하지 않는 실행파일(`this-binary-does-not-exist-xyz`)로 `PtySession.start()`를 호출해 `execvp()` 실패 → `cExit(-2)` 경로를 실제로 타게 만들었다. 결과: `start()`는 15ms 만에 정상 반환했고, 자식 프로세스는 **100ms 이내에 완전히 사라짐**(`ps`로 확인, 좀비도 아니고 행도 아님) — 이전 라운드에서 우려했던 "잘못된 심볼로 인한 잠재적 행/불안정 종료" 없이 자식이 즉시, 깨끗하게 종료됨을 확인.
3. **회귀 테스트**: `mam_pty``pty_runtime_test.dart`에 이번 리뷰 체인 동안 검증해온 항목에 대응하는 자동화 테스트가 추가되어 있음을 확인 — echo 케이스에 더해 **"PtySession strips TMUX/TMUX_PANE from child environment"** 테스트가 신규로 존재하며 통과한다. 이제 이전까지 매 라운드 내가 수작업 스크래치 스크립트로 검증해야 했던 env 격리가 저장소 자체의 회귀 테스트로 편입되었다.
4. **전체 회귀 스위트**: `dart analyze`(mam_pty/mam_core) + `flutter analyze`(mam_desktop) 전부 clean. `dart test`(mam_pty 2/2, mam_core 3/3) + `flutter test`(mam_desktop 3/3) 전부 통과.
5. **DoD**: `git show f0e2bd2 --stat -- '*.sh'` → 셸 스크립트 변경 없음. `grep -rn "runInShell: *true"` → 없음. `grep -rn "\.writeAsString\|\.writeAsBytes\|\.delete(\|openWrite("`(mam_core/mam_pty) → 없음.
## M2 전체 검증 이력 요약 (이번 라운드로 완결)
이 마일스톤은 5라운드에 걸쳐 검토되었고, 매 라운드 실제 실행으로 재현/반증했다:
| 라운드 | 커밋 | 발견 | 상태 |
| :-- | :-- | :-- | :-- |
| 1 (`c2503ed6`) | `b7901bc` | `/proc/self/fd/` 즉시 예외, PTY 슬레이브 미연결, env 격리 없음 | NOT PASS |
| 2 (`1fc02bc2`) | `f52f6eb` | 위 3건 해결(fork/exec 재작성) — 좀비 누수, fork-unsafe 호출 신규 발견 | NOT PASS |
| 3 (`a3f7449e`) | `7781e79` | fork-unsafe 부분개선 — 이벤트루프 정지(가장 심각), env 격리 죽은 코드 신규 발견 | NOT PASS |
| 4 (`604fdecf`) | `7f1a7e5`(+`a6e4dc9`) | 이벤트루프 정지/env 격리/좀비회수 전부 해결 — `exit``_exit` 심볼 오류 발견 | NOT PASS |
| 5 (본 리뷰) | `f0e2bd2` | 심볼 오류 정정, 실패 경로 실행 재현으로 정상 종료 확인 | **PASS** |
계획서 §5.2(구조)/§6.7(PTY 메커니즘, env 격리, TOCTOU, 리사이즈)/§10(DoD)의 요구사항이 모두 실제 실행 검증을 통과했고, 더 이상 미해결 항목이 없다.
[VERDICT: PASS]
@@ -0,0 +1,34 @@
# Peer Review (Round 3): 콜드스타트 에러 침묵 버그 수정 (commit `7e4cab6`) — `multi-agent-mux-ui`
## Scope
`50ed0559` 리뷰에서 지적한 잔여 결함 — "콜드스타트(한 번도 성공한 적 없는 폴링 실패)가 여전히 완전히 침묵됨, `stale_banner.dart:15``if (!poll.stale) return shrink` 게이트가 원인" — 에 대한 수정 커밋 `7e4cab6`("fix(ui): expose stale banner under cold-start failures when no successful snapshot exists")를 검토했다.
## 변경 내용 확인
`stale_banner.dart` 5줄 변경(그 외 파일은 무관한 dart_tool 캐시 바이너리 1개뿐):
```dart
final shouldShow = poll.stale || (poll.snapshot == null && poll.error != null);
if (!shouldShow) return const SizedBox.shrink();
...
final lastOkText = lastOk == null
? 'never'
: '...'
```
내가 `50ed0559`에서 제안한 수정안과 조건식이 정확히 일치한다 — `poll.stale`뿐 아니라 `poll.snapshot == null && poll.error != null`(콜드스타트: 한 번도 성공하지 못했지만 에러는 있는 상태)도 노출 조건에 포함시켰고, `lastOkAt == null`일 때 문구도 의미 없는 시각 대신 `'never'`로 분기했다.
## 검증
1. **경로 추적**: `main.dart``_DashboardBody``snapshot`이 null이어도 `StaleBanner(poll: poll)`를 항상 마운트한다(`sessions`/`count`는 각각 `?? const []`/`?? 0`로 안전 처리) — 배너 표시 조건이 고쳐지면 실제로 화면에 그려질 경로가 이미 존재함을 재확인.
2. **스트림 도달성**: `StatusRepository.watch()`는 모든 폴링 실패를 내부에서 흡수해 항상 `SessionsPoll`을 yield하므로(예외를 스트림 밖으로 던지지 않음), Riverpod `sessionsPollProvider`는 첫 실패 시에도 `AsyncError`가 아니라 `AsyncData(poll)`로 즉시 전이 — `DashboardScreen``_ErrorScreen`이 아니라 `_DashboardBody`(그리고 그 안의 `StaleBanner`)로 정상 도달함을 재확인.
3. **실제 렌더링 재현(직접 실행)**: `SessionsPoll(snapshot: null, stale: false, lastOkAt: null, error: 'StatusFetchException: preflight failed: tmux is missing or not executable')``StaleBanner`를 단독 렌더링하는 위젯 테스트를 임시 작성해 `flutter test`로 직접 실행 — `'never'` 텍스트와 에러 메시지(`'tmux is missing'`) 문자열이 모두 실제로 화면에 렌더링됨을 확인(테스트는 검증 후 삭제, 저장소에는 남기지 않음 — 리뷰 산출물 오염 방지). 이전 라운드(`50ed0559`)에서 재현했던 "배너가 전혀 뜨지 않는" 상황이 이제 재현되지 않는다.
4. **회귀 없음**: `dart analyze`(mam_core)/`flutter analyze`(mam_desktop) 모두 No issues found. 기존 6개 테스트(`mam_core` 3 + `mam_desktop` 3) 전부 통과.
5. **스코프 확인**: 이번 커밋은 `stale_banner.dart` 한 파일만 수정 — 이전 라운드에서 요청한 "좁은 범위 수정" 요구와 정확히 일치, 다른 파일에 부작용 없음.
## 결론
`765e2329`(pre-flight 체크 누락) → `50ed0559`(수정이 잘못된 조건 분기에 적용됨) → 이번 `7e4cab6`까지 이어진 콜드스타트 에러 침묵 버그가 정확한 근본 원인(단일 `if` 게이트)에 대한 정밀 수정으로 완전히 해소되었다. 실제 위젯 렌더링까지 직접 실행해 확인했고, 회귀도 없다. M1 스코프에서 더 이상 남은 이슈가 없다.
[VERDICT: PASS]
@@ -1,28 +0,0 @@
# 📋 세션 ID 중복 충돌 해결 종합 설계안 리뷰 요청 지시서
- **요청자**: Planner Agent (Antigravity)
- **수신자**: Reviewer Agent (Cline)
- **대상 세션**: `canary-projects-multi-agent-mux-reviewer-cline`
- **검토 대상 파일**: [session_isolation_discussion.md](file:///home/godopu16/PuKi/laa/canary_projects/multi-agent-mux/session_isolation_discussion.md)
---
## 1. 개요 및 검토 요청 사항
기존에 진행되었던 세션 ID 중복 충돌 해결 설계 토론 보고서에 Planner Agent가 새로 작성한 구체적 구현 계획(Rev.2)을 성공적으로 통합 및 단일화하였습니다. 특히 모든 에이전트들을 격리 디렉터리 방식으로 일원화 관리하자는 사용자 피드백을 반영하였습니다.
수신자(Cline) 에이전트님은 통합 및 단순화된 [session_isolation_discussion.md](file:///home/godopu16/PuKi/laa/canary_projects/multi-agent-mux/session_isolation_discussion.md) 문서를 검토하시어 아래 기준을 만족하는지 검사해 주시기 바랍니다.
### 주요 검토 기준
1. **의견 반영의 정합성**: 모든 에이전트가 격리 디렉터리 오버라이드 방식(L2)으로 일원화된 설계 구조가 논리적/구조적으로 타당한지 검사하십시오.
2. **누락 확인**: 문제 해결을 위해 이전에 논의되었던 내용(예: RC-2 청소 계약, R1/R2 이중 안전장치 등)이 누락 없이 적절하게 기입되었는지 점검하십시오.
3. **DoD 검증성**: 각 단계별 정의된 DoD(Definition of Done) 및 최종 검증(V1~V3) 단계가 실제 Cline 에이전트의 관점에서도 무결하고 재현 가능한지 평가하십시오.
---
## 2. 작업 결과 보고 양식
검토를 마친 후, 본인의 의견과 최종 승인 여부를 아래에 명시된 경로에 기록해 주십시오.
* **리포트 작성 경로**: `.mam/reports/canary-projects-multi-agent-mux-reviewer-cline/report-isolation-review.md`
* **승인 불변식**: 검토 결과 설계가 완벽하고 구현 계획으로 전환하는 데 이견이 없다면 리포트 및 최종 응답(Completed Event Detail)의 마지막에 반드시 **`"PASS"`** 문자열을 포함하여 응답해 주십시오. 만약 수정이나 보완이 필요하다면 구체적인 피드백을 전달해 주십시오.
@@ -1,20 +0,0 @@
# Onboarding & Project Context Analysis Brief
안녕하세요 Reviewer Cline Agent.
당신은 본 프로젝트의 **리뷰어(Reviewer)** 역할을 위임받았습니다.
실무 또는 검수 작업을 위임받기 전에, 설계 규약에 따라 다음 맥락 파악 작업을 완료해 주세요.
## 📋 온보딩 요구사항 (Onboarding Checklist)
1. **설계 규약 및 제약사항 숙지**
- [README.md](../../README.md) 및 [.agents/MULTI_AGENT_RULES.ko.md](../../.agents/MULTI_AGENT_RULES.ko.md)를 꼼꼼히 읽고 본 프레임워크의 규칙을 인지해 주세요.
- 특히 **TUI 뷰포트 절단 방지(Pane Snapshotting 3대 규칙)** 및 **마크다운 기반 협업 규약**을 지켜야 합니다.
2. **레포지토리 활성 수정 내역 분석**
- `git status``git diff`를 실행하여 현재 진행 중인 배포 스크립트 URL 파라미터화 작업 관련 변경 사항을 분석하세요.
3. **역할 및 타 에이전트 정보 검증**
- [.mam/agent-sessions.yaml](../agent-sessions.yaml)을 읽어 자신의 지정된 역할(`role: reviewer`)과 현재 러닝 상태인 타 에이전트 목록을 확인하세요.
## 🏁 완료 보고 (Handshake Complete)
위 파악이 끝나면 본 세션의 터미널 상에서 분석 결과 요약과 함께 **"Onboarding complete; aligned with role reviewer"** 라는 단말 완료 이벤트를 발행하거나 메시지를 출력해 주세요.
@@ -0,0 +1,162 @@
# ✅ Peer Review Report: macOS 키체인 Hang 우회 및 격리 모드 인증 토큰 소실 수정 (Job 14943484)
**Job**: `14943484` · **Reviewer**: Reviewer B (Cline, `canary-projects-multi-agent-mux-reviewer-cline`)
**Review Target**: 커밋 `36b3910` "fix(mac-compat): bypass keyring auth check hang and link macOS Library/Keychains to isolated home"
**Files Changed**: `lib.sh` (+8/-0), `create_session.sh` (+4/-1) — 2 files, 12 insertions, 1 deletion
**Review Scope**: 작업 목표 "create_session.sh 및 lib.sh에서 macOS 키체인(keyring) 접근 차단으로 인한 비대화식 Hang 현상과 격리 모드(--isolate) 시 인증 토큰 소실 문제를 각각 파일 기반 사전 검증 우회 및 Library/Keychains 폴더 링크 추가를 통해 해결" — 린트, 동작성, 유실 관점 교차 리뷰
**Method**: 커밋 diff 분석 + `bash -n`/`shellcheck` 정적 분석 + 인증 바이패스 로직 4케이스 검증 + Darwin 가드 검증 + 경로 일치성 확인 + seeded 패턴 일관성 확인 + 타 agent keychain 필요성 분석
---
## 1. 변경 사항 개요
### 1.1 파일 기반 사전 검증 우회 (create_session.sh 라인 92-98)
```diff
elif [ "$AGENT" = "agy" ]; then
- if ! agy models >/dev/null 2>&1; then
+ # Fast, non-blocking check: if token or credentials exist on disk, assume authenticated to prevent keyring hang
+ if [ -f "$HOME/.gemini/oauth_creds.json" ] || [ -f "$HOME/.gemini/antigravity-cli/antigravity-oauth-token" ]; then
+ true
+ elif ! agy models >/dev/null 2>&1; then
echo "ERROR: agy is not authenticated. Please log in first." >&2
exit 1
fi
```
**목적**: `agy models` 명령이 macOS에서 키체인 접근 시 비대화식 Hang 유발. 토큰/자격증명 파일 존재 시 파일 기반으로 인증 가정하여 Hang 우회.
### 1.2 Library/Keychains 폴더 링크 추가 (lib.sh 라인 841-848)
```diff
+ # On macOS, seed ~/Library/Keychains to allow isolated agy to query Keychain Access credentials
+ if [ "$(uname)" = "Darwin" ]; then
+ mkdir -p "$root/Library"
+ if [ -d "$HOME/Library/Keychains" ]; then
+ ln -sfn "$HOME/Library/Keychains" "$root/Library/Keychains"
+ seeded="${seeded:+$seeded,}Library/Keychains"
+ fi
+ fi
```
**목적**: `--isolate` 모드 시 격리된 홈 디렉토리에 `~/Library/Keychains` 심볼릭 링크 추가 → 격리 agy가 Keychain Access 자격증명 조회 가능.
---
## 2. 작업 목표 달성도
| 목표 | 상태 | 확인 |
|------|------|------|
| macOS 키체인 Hang 우회 (파일 기반 사전 검증) | ✅ | 토큰 파일 존재 시 `agy models` 스킵 |
| 격리 모드 인증 토큰 소실 해결 (Keychains 링크) | ✅ | Darwin 가드 + Library/Keychains 심볼릭 링크 |
| create_session.sh 적용 | ✅ | 라인 92-98 |
| lib.sh 적용 | ✅ | 라인 841-848 (agy case) |
---
## 3. 정적 분석
| 파일 | bash -n | shellcheck | 비고 |
|------|---------|------------|------|
| lib.sh | ✅ SYNTAX OK | ✅ 경고 없음 (clean) | 본 diff 새 경고 0건 |
| create_session.sh | ✅ SYNTAX OK | SC1091 (info, 기존 source) — **본 diff 새 경고 없음** | EXIT 1 (기존) |
---
## 4. 동작성 검증
### 4.1 ✅ 인증 바이패스 로직 4케이스 검증
| 케이스 | 조건 | 결과 | 판정 |
|--------|------|------|------|
| 1 | `antigravity-oauth-token` 파일 존재 | BYPASS (token found) | ✅ Hang 우회 |
| 2 | `oauth_creds.json` 파일 존재 | BYPASS (oauth_creds found) | ✅ Hang 우회 |
| 3 | 파일 없음 + agy models 실패 | ERROR (not authenticated) | ✅ 정상 에러 |
| 4 | 파일 없음 + agy models 성공 | PASS (agy models succeeded) | ✅ 정상 통과 |
**검증**: 파일 존재 시 `agy models` 호출 스킵 → macOS 키체인 Hang 방지. 파일 부재 시 기존 `agy models` 체크 유지 → 미인증 감지.
### 4.2 ✅ Darwin 가드 검증 (Keychains 링크)
| 조건 | 결과 | 판정 |
|------|------|------|
| `uname` = Linux | Darwin 체크 실패 → 블록 스킵 | ✅ Linux에서 Keychains 링크 미생성 |
| `uname` = Darwin + `~/Library/Keychains` 존재 | `mkdir -p $root/Library` + `ln -sfn` 실행 | ✅ macOS에서 심볼릭 링크 생성 |
| `uname` = Darwin + `~/Library/Keychains` 부재 | `[ -d ]` 실패 → 링크 미생성 | ✅ graceful (seeded 미추가) |
### 4.3 ✅ 경로 일치성 (auth check vs provisioning)
| 파일 | create_session.sh 체크 경로 | lib.sh provisioning 경로 | 일치 |
|------|---------------------------|-------------------------|------|
| oauth_creds.json | `$HOME/.gemini/oauth_creds.json` | `$HOME/.gemini/oauth_creds.json` (라인 835) | ✅ |
| antigravity-oauth-token | `$HOME/.gemini/antigravity-cli/antigravity-oauth-token` | `$HOME/.gemini/antigravity-cli/antigravity-oauth-token` (라인 838) | ✅ |
인증 체크 파일과 격리 provisioning 파일 경로가 완전 일치 → 일관성 확보.
### 4.4 ✅ seeded 패턴 일관성
`seeded="${seeded:+$seeded,}Library/Keychains"` (라인 846) — 기존 패턴(라인 836, 839, 853)과 동일한 `${seeded:+$seeded,}` 누적 패턴. 일관성 확보 ✅
### 4.5 ✅ 타 agent keychain 필요성 분석
| Agent | 인증 방식 | Keychain 필요 | Keychains 링크 적용 |
|-------|----------|---------------|---------------------|
| claude | `.credentials.json` 파일 기반 | 아니오 | 불필요 (맞음) |
| cline | 파일 기반 settings + DB | 아니오 | 불필요 (맞음) |
| agy | macOS Keychain Access | **예** | **적용됨** ✅ |
| hermes | `auth.json` 파일 기반 | 아니오 | 불필요 (맞음) |
Keychains 링크가 agy case에만 추가된 것은 **정확한 타겟팅** — agy만 macOS Keychain 사용, 타 agent는 파일 기반 인증.
### 4.6 ✅ true 문 유효성
`if` 블록 본문으로 `true` 사용 — bash에서 유효 (no-op). `if true; then true; fi` 검증 통과. 의도: 파일 존재 시 아무 동작 없이 통과(바이패스).
---
## 5. 잔여 결함 (LOW — INFORMATIONAL)
### 5.1 ⚠️ 만료된 토큰 시 false positive 가능성 (LOW, 설계 트레이드오프)
**위치**: create_session.sh 라인 93
**분석**: 토큰 파일이 존재하지만 **만료/무효**한 경우, 바이패스가 `agy models` 체크를 스킵하여 세션 시작 → agy 실행 시 인증 실패 가능.
**평가**: 의도적 트레이드오프 — 원 문제는 **Hang**(무한 대기)이며, 만료 토큰으로 인한 후속 실패는 Hang보다 나음(진단 가능). 주석(라인 92)이 의도 명시.
**심각도**: LOW — BLOCKING 아님. 설계 결정으로 수용 가능.
### 5.2 ️ 작업 트리 잔여 .tmp 파일 (INFO, unrelated)
**위치**: `.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job.14657_36745.tmp` (untracked)
**분석**: 이전 delegate_job_safe 실행 잔여물. 본 diff와 무관. 무해하지만 정리 권장.
**심각도**: INFO — 본 리뷰 범위 외.
---
## 6. 종합 평가
### 작업 목표 달성도
"create_session.sh 및 lib.sh에서 macOS 키체인(keyring) 접근 차단으로 인한 비대화식 Hang 현상과 격리 모드(--isolate) 시 인증 토큰 소실 문제를 각각 파일 기반 사전 검증 우회 및 Library/Keychains 폴더 링크 추가를 통해 해결" — **달성**.
### 변경 품질
1.**파일 기반 Hang 우회**: 토큰/자격증명 파일 존재 시 `agy models` 스킵 — 4케이스 검증 모두 PASS
2.**Keychains 심볼릭 링크**: Darwin 가드 + `[ -d ]` 존재 확인 + `ln -sfn` — 안전한 조건부 생성
3.**경로 일치성**: auth check 파일과 provisioning 파일 경로 완전 일치
4.**타겟팅 정확**: agy case에만 Keychains 링크 추가 — 타 agent는 파일 기반 인증으로 불필요
5.**seeded 패턴 일관**: 기존 누적 패턴과 동일
6.**Darwin 가드**: Linux에서 미실행, macOS에서만 동작
### 검증 결과
- 정적 분석: `bash -n` 2/2 OK, `shellcheck` 본 diff 새 경고 없음 (lib.sh clean, create SC1091 기존만) ✅
- 인증 바이패스: 4케이스(토큰 존재/ oauth_creds 존재/ 파일 없음+실패/ 파일 없음+성공) 모두 PASS ✅
- Darwin 가드: Linux 스킵 확인 ✅
- 경로 일치성: auth check ↔ provisioning 완전 일치 ✅
- seeded 일관성: 기존 패턴과 동일 ✅
- 타 agent 분석: agy만 Keychain 사용, 타겟팅 정확 ✅
### 잔여 LOW 1건 + INFO 1건
- LOW 5.1: 만료 토큰 false positive — 의도적 트레이드오프 (Hang > 후속 실패), 주석 명시
- INFO 5.2: 잔여 .tmp 파일 (본 diff 무관)
### 판정 근거
작업 목표(키체인 Hang 우회 + 격리 토큰 소실 해결) 완전 달성. 파일 기반 바이패스 4케이스 검증 PASS, Darwin 가드 동작 확인, 경로 일치성 확보, agy 타겟팅 정확. 정적 분석 통과. 잔여 LOW 1건은 의도적 설계 트레이드오프(Hang 방지가 만료 토큰 후속 실패보다 우선). 주 개발자가 macOS 키체인 문제를 정확히 진단하고 파일 기반 우회 + Keychains 링크로 해결했으므로 PASS 판정이 타당.
[VERDICT: PASS]
@@ -0,0 +1,221 @@
# ✅ Peer Review Report: multi-agent-mux-loop Self-Planning Mode 수정 diff 교차 검증 (Job 417d0efd)
**Job**: `417d0efd` · **Reviewer**: Reviewer B (Cline, `canary-projects-multi-agent-mux-reviewer-cline`)
**Review Target**: 주 개발자(Antigravity)가 제출한 미커밋 git diff — SKILL.md, run_loop.sh, PLAN_LOOP.md 3개 파일 Self-Planning Mode 반영 수정 (3차 시도)
**Prior Context**:
-`71d5a6f2`: 4건 BLOCKING 결함 발견 (DEFECT A/B/C: mermaid `fi` 문법 오류, DEFECT D: PLAN_LOOP.md 하드코딩) → NOT PASS + ESCALATE
-`22e70ce2`: 동일 4건 결함 0/4 해결 (diff가 `fi` 유지) → NOT PASS + ESCALATE
- 본 잡 `417d0efd`: 주 개발자 3차 시도 — 4건 결함 해결 시도
**Review Scope**: 작업 목표 "multi-agent-mux-loop에서 --plan 옵션이 없을 때 계획과 개발을 모두 creator가 수행하는 수정사항(run_loop.sh, SKILL.md, PLAN_LOOP.md의 변경내역)이 올바르게 반영되었는지 확인" — 린트, 동작성, 유실 관점 교차 리뷰
**Method**: 라인 단위 diff 분석 + `bash -n`/`shellcheck` 정적 분석 + **mermaid CLI 11.16.0 렌더링实证** + 이전 결함 추적 비교 + `Loop` 예약어 충돌 근본 원인 분석
---
## 1. diff 개요 (5개 파일, +24/-13)
| 파일 | 변경 | 내용 |
|------|------|------|
| SKILL.md | +14/-6 | (1) "Existing Plan Execution" → "Creator Self-Planning & Development" 설명 (2) planning mermaid 블록 2단계 분기 추가 (3) **review mermaid 블록 `fi`→`end` 교체 (라인 117)** (4) Feedback Loop Cadence Self-Planning 설명 추가 |
| run_loop.sh | +2/-2 | (1) `wait_for_job` 잡 경로 `.mam/jobs/$job_id/job.json``.mam/jobs/$job_id.json` (2) EXECUTION_PROMPT Creator 자율 계획 지시로 변경 |
| PLAN_LOOP.md | +8/-4 | (1) `--target-agent` 하드코딩 → `<creator-session-name>` 플레이스홀더 (2) participant `Planner Claude`/`Creator Claude``Planner Agent`/`Creator Agent` (3) `--plan` 옵션 설명 Self-Planning 추가 (4) **planning mermaid `fi`→`end` 교체 (라인 66)** (5) **review mermaid `fi`→`end` 교체 (라인 84)** |
| dart_tool binary x2 | (무관) | 캐시 파일 — 리뷰 범위 외 |
---
## 2. 이전 4건 BLOCKING 결함 해결 추적 — 4/4 해결 ✅
### 2.1 ✅ DEFECT A (해결): SKILL.md 라인 117 `fi`→`end`
**이전 상태** (잡 71d5a6f2): SKILL.md mermaid review 블록 라인 117에 `fi` → mermaid CLI 파싱 에러
**본 diff**:
```diff
- fi
+ end
```
**현재 상태**: `grep -nc ' fi' SKILL.md` = **0**
**평가**: ✅ 해결. `fi``end`로 정확히 교체됨.
### 2.2 ✅ DEFECT B (해결): PLAN_LOOP.md 라인 66 `fi`→`end`
**이전 상태**: PLAN_LOOP.md mermaid 블록 라인 66에 `fi` → mermaid CLI 파싱 에러
**본 diff**:
```diff
- fi
+ end
+ end
```
**현재 상태**: `grep -nc ' fi' PLAN_LOOP.md` = **0**
**평가**: ✅ 해결. `fi``end`로 교체되고, 상위 `else --plan 미지정` 분기를 닫는 `end` 추가.
### 2.3 ✅ DEFECT C (해결): PLAN_LOOP.md 라인 84 `fi`→`end`
**이전 상태**: PLAN_LOOP.md mermaid review 블록 라인 84에 `fi`
**본 diff**:
```diff
- fi
+ end
```
**현재 상태**: 라인 84 `fi` 제거, `end`로 교체 ✅
**평가**: ✅ 해결.
### 2.4 ✅ DEFECT D (해결): PLAN_LOOP.md 하드코딩 3건 → 플레이스홀더/일반화
**이전 상태**: PLAN_LOOP.md 라인 19, 45, 46에 하드코딩 에이전트명
**본 diff**:
```diff
- --target-agent "canary-projects-multi-agent-mux-creator-claude" \
+ --target-agent "<creator-session-name>" \
- participant Plan as Planner Claude
- participant Dev as Creator Claude
+ participant Plan as Planner Agent
+ participant Dev as Creator Agent
```
**현재 상태**:
- `grep 'creator-claude\|Planner Claude\|Creator Claude' PLAN_LOOP.md` = **0건**
- `grep 'creator-session-name\|Planner Agent\|Creator Agent' PLAN_LOOP.md` = **3건** (플레이스홀더/일반화 확인) ✅
**평가**: ✅ 해결. SKILL.md(`f85fdfc`)와 일관성 확보. 3건 모두 정제.
### 2.5 이전 결함 추적 요약
| 결함 | 이전 상태 | 잡 22e70ce2 후 | 본 diff 후 | 해결? |
|------|-----------|----------------|------------|-------|
| DEFECT A: SKILL.md `fi` | 1개 | 1개 (미해결) | **0개** | ✅ 해결 |
| DEFECT B: PLAN_LOOP.md `fi` (66) | 1개 | 1개 (미해결) | **0개** | ✅ 해결 |
| DEFECT C: PLAN_LOOP.md `fi` (84) | 1개 | 1개 (미해결) | **0개** | ✅ 해결 |
| DEFECT D: PLAN_LOOP.md 하드코딩 | 3건 | 3건 (미해결) | **0건** | ✅ 해결 |
---
## 3. mermaid 렌더링实证 (CLI 11.16.0)
### 3.1 ✅ PLAN_LOOP.md — 렌더링 성공
```
$ npx @mermaid-js/mermaid-cli -i planloop2.mmd -o planloop2.svg
Generating single mermaid chart
→ SVG 생성: 41401 bytes ✅
```
**PLAN_LOOP.md mermaid 블록 구조 분석** (라인 41-92):
```
alt --plan 지정 시 → alt #1 open
loop ... → loop #1 open
end → loop #1 close ✅
else --plan 미지정 → alt #1 else
alt 기존 계획 존재 시 → alt #2 open
else 계획 미존재 시 → alt #2 else
end → alt #2 close ✅
end → alt #1 close ✅ (이전 fi, 이제 end)
loop 최대 --max-loop → loop #2 open
alt 리뷰어 옵션 지정 시 → alt #3 open
alt 100% PASS 충족 시 → alt #4 open
else NOT PASS 검출 시 → alt #4 else
end → alt #4 close ✅
else 리뷰어 미지정 → alt #3 else
end → alt #3 close ✅ (이전 fi, 이제 end)
end → loop #2 close ✅
alt --cleanup 지정 시 → alt #5 open
end → alt #5 close ✅
```
**밸런스**: alt=5, else=4, end=7, loop=2 → 열린 7 = 닫힌 7 ✅
**평가**: ✅ PLAN_LOOP.md mermaid 다이어그램이 정상 렌더링됨. `fi` 문제 2건 + 하드코딩 3건 모두 해결로 완전한 복구.
### 3.2 ⚠️ SKILL.md — `Loop` 예약어 충돌로 렌더링 실패 (기존 문제, 본 diff 외)
```
$ npx @mermaid-js/mermaid-cli -i skill2.mmd -o skill2.svg
Error: Parse error on line 12:
...ign Plan-->>Loop: plan report ge
Expecting '+', '-', '()', 'ACTOR', got 'loop'
```
**근본 원인 분석 (이진 탐색 +隔离 테스트)**:
- `Loop` participant 이름이 mermaid 11.16.0에서 예약어/키워드 충돌
- **隔离实证**: `actor Lp as run_loop.sh`로 변경 시 SVG 25575 bytes 정상 렌더링 ✅
- **`Loop` 사용 시**: 파싱 에러 (라인 7 `Loop->>Plan: delegate plan design`에서 실패)
- `Loop`는 mermaid 시퀀스 다이어그램에서 `loop` 키워드와 충돌하는 것으로 판단 — mermaid 파서가 participant `Loop``loop` 키워드로 오인
**기존 문제 여부 확인**:
- HEAD 버전(수정 전) SKILL.md에도 `actor Loop as run_loop.sh` 존재 (라인 79)
-`Loop` participant는 본 diff가 **도입한 문제가 아님** — 원래부터 존재
- 이전 `fi` 문제가 먼저 파싱을 깨뜨렸기 때문에 `Loop` 문제가 가려져 있었음
- `fi` 해결 후 `Loop` 문제가 드러남 — 본 diff의 수정이 올바르게 이루어져서 다음 계층의 기존 문제가 노출된 것
**평가**: ⚠️ SKILL.md mermaid 렌더링은 여전히 실패하나, 이는 **본 diff의 책임 범위 밖** — 본 diff는 `fi``end` 교체(지정 결함)를 올바르게 수행했으며, `Loop` participant는 건드리지 않음. `Loop` 예약어 충돌은 별개의 기존 결함(DEFECT E)으로 다음 라운드에서 다룰 사안.
---
## 4. 긍정적 변경 상세 (POSITIVE)
### 4.1 ✅ run_loop.sh 잡 경로 수정 (hang 버그 해결) — 런타임实证 (잡 22e70ce2와 동일)
```diff
- with open('.mam/jobs/$job_id/job.json') as f:
+ with open('.mam/jobs/$job_id.json') as f:
```
- 실제 레지스트리 구조: `.mam/jobs/<job_id>.json` (플랫 파일) — 신규 경로 일치 ✅
- 런타임实证: 신규 경로 `status: running` 정상 읽기, 구버전 `unknown (No such file)` → hang 버그 해결
- `bash -n`: SYNTAX OK ✅, `shellcheck`: EXIT 0 ✅
### 4.2 ✅ run_loop.sh EXECUTION_PROMPT Creator 자율 계획 지시
```diff
-EXECUTION_PROMPT="다음 작업 목표를 완성해주세요: $TASK"
+EXECUTION_PROMPT="계획서가 존재하지 않으므로, 작업자(Creator)의 판단하에 스스로 구현 계획 및 설계를 수립한 뒤, 이를 바탕으로 코드를 구현하고 다음 작업 목표를 완성해주세요. 작업 목표: $TASK"
```
- 작업 목표 "계획과 개발을 모두 creator가 수행" 정확히 반영 ✅
- `if [ -n "$CURRENT_PLAN" ]` 가드로 계획서 존재 시 기존 프롬프트 유지 ✅
### 4.3 ✅ SKILL.md 설명/Feedback Loop Cadence 업데이트
- 라인 13: "Creator Self-Planning & Development" — "계획서가 존재하지 않는 경우 작업자(Creator: developer/writer)가 스스로 구현 계획 및 설계 수립을 포함한 개발 전 과정을 직접 진행" 명시 ✅
- 라인 126-131: Feedback Loop Cadence "Creator Self-Planning (No `--plan`)" 설명 추가 ✅
- 라인 156: Workflow 예시 "Creator Self-Planning & Development" 업데이트 ✅
### 4.4 ✅ PLAN_LOOP.md Self-Planning Mode 반영
- 라인 27: `--plan` 옵션 설명 "(비활성화 시 기존 계획서를 로드하며, 계획서가 없는 경우 Creator가 직접 계획 및 설계를 수립하여 구동)" 추가 ✅
- 라인 60-65: planning mermaid 블록 `alt 기존 계획 존재 시`/`else 계획 미존재 시` 2단계 분기 추가 ✅
- mermaid 렌더링 성공 (§3.1) ✅
---
## 5. 새로 발견된 결함 (INFORMATIONAL — 본 diff 외)
### 5.1 ⚠️ DEFECT E (NON-BLOCKING for 본 diff, BLOCKING for 전체 mermaid 렌더링): SKILL.md `Loop` participant 예약어 충돌
| 항목 | 내용 |
|------|------|
| 파일 | SKILL.md |
| 위치 | 라인 79 `actor Loop as run_loop.sh` (및 mermaid 블록 내 `Loop` 참조 전체) |
| 문제 | `Loop`가 mermaid 11.16.0에서 `loop` 키워드와 충돌 — participant 이름으로 사용 시 파싱 에러 |
|实证 | `actor Lp as run_loop.sh`로 변경 시 정상 렌더링 (SVG 25575 bytes) |
| 본 diff 책임 | ❌ 아님 — `Loop`는 HEAD 버전부터 존재, 본 diff가 도입/수정하지 않음 |
| 심각도 | SKILL.md mermaid 렌더링 실패의 근본 원인이나, 본 diff의 4건 결함과는 별개 |
| 권고 | 다음 라운드에서 `Loop``Orch` (Orchestrator) 또는 `Runner` 등 비-예약어로 변경 |
---
## 6. 종합 평가
### 작업 목표 달성도
"multi-agent-mux-loop에서 --plan 옵션이 없을 때 계획과 개발을 모두 creator가 수행하는 수정사항(run_loop.sh, SKILL.md, PLAN_LOOP.md의 변경내역)이 올바르게 반영되었는지 확인"에 대한 검증:
#### 달성 — ✅
- **이전 4건 BLOCKING 결함 4/4 해결**: DEFECT A (`fi` SKILL.md), DEFECT B (`fi` PLAN_LOOP.md 66), DEFECT C (`fi` PLAN_LOOP.md 84), DEFECT D (하드코딩 3건) — 주 개발자가 2회 연속 NOT PASS 후 3차 시도에서 모든 지적 사항 수용/수정
- **PLAN_LOOP.md mermaid 렌더링 성공** (SVG 41401 bytes, CLI 11.16.0实证) — `fi` 2건 + 하드코딩 3건 해결로 완전 복구
- **run_loop.sh**: 잡 경로 hang 버그 해결 (런타임实证) + EXECUTION_PROMPT Creator 자율 계획 지시 + `bash -n` OK + `shellcheck` EXIT 0
- **SKILL.md**: `fi``end` 교체 + "Creator Self-Planning & Development" 설명 + Feedback Loop Cadence Self-Planning 모드 설명
#### 잔여 (본 diff 범위 외, INFORMATIONAL) — ⚠️
- **SKILL.md mermaid 렌더링**: `Loop` participant 예약어 충돌로 여전히 실패 — 그러나 이는 본 diff가 도입/수정한 부분이 아님 (HEAD부터 존재). `fi` 해결 후 드러난 기존 결함(DEFECT E). 본 diff의 4건 결함 해결과는 별개.
### 검증 결과
- run_loop.sh: `bash -n` OK ✅, `shellcheck` EXIT 0 ✅, Self-Planning Mode 로직 정상 ✅, hang 버그 해결 ✅
- PLAN_LOOP.md: mermaid 렌더링 성공 ✅, `fi` 0건 ✅, 하드코딩 0건 ✅
- SKILL.md: `fi` 0건 ✅, Self-Planning 설명 반영 ✅ — 그러나 `Loop` 예약어 충돌로 mermaid 렌더링 실패 (기존 문제, 본 diff 외)
### 판정 근거
본 diff는 이전 2회 리뷰(71d5a6f2, 22e70ce2)에서 명확히 지적한 4건 BLOCKING 결함을 **모두 해결**함. PLAN_LOOP.md는 mermaid 렌더링이 완전히 복구되었고, run_loop.sh는 정상 동작함. SKILL.md의 `Loop` 예약어 충돌은 본 diff가 도입한 문제가 아니며, 본 diff가 수정하라고 지정받은 범위 밖. 주 개발자가 지정된 작업을 성실히 완수했으므로 PASS 판정이 타당. `Loop` 문제는 다음 라운드에서 별도로 다룰 사안으로 informational note로 기록.
[VERDICT: PASS]
@@ -0,0 +1,144 @@
# ✅ Peer Review Report: delegate-job run_agent() herdr 버그 3건 수정 검토
**Reviewer**: Reviewer B (Cline, `canary-projects-multi-agent-mux-reviewer-cline`)
**Job ID**: 7f25e72e
**Review Target**: `.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job``run_agent()` 함수 herdr 관련 버그 3건 수정 (커밋 `6df4b03`에 반영됨, working tree의 해당 파일은 clean)
**Review Scope**: (a) 수정이 실제 herdr CLI 문법과 맞는지, (b) source 순서 변경이 스크립트 다른 부분과 충돌하지 않는지, (c) HERDR_SERVER_NAME 자동 해석이 비격리(default) 세션에 회귀 없이 동작하는지
**Method**: 실제 herdr v0.7.4 도움말 직접 열람 + shim has-session/agent attach 실행 + resolve_herdr_workspace python3 시뮬레이션 + 형제 스킬(resume/stop) 일관성 비교 + 55개 테스트 실행
---
## 1. 발견된 버그 3건과 수정 내용 (커밋 6df4b03)
### 버그 1: lib.sh source 순서 — has-session이 실제 바이너리 호출 (항상 실패)
- **문제**: lib.sh를 `run_agent()` 내부, has-session 체크(라인 437)보다 늦게(라인 444) source → has-session 호출 시점엔 `herdr`이 shim이 아닌 실제 바이너리 → 존재하지 않는 `has-session` 서브커맨드 → 항상 실패
- **수정**: `source "$SCRIPT_DIR/../lib.sh"`를 라인 31(스크립트 상단, `run_agent()` 정의 라인 408보다 377줄 앞)로 이동 + 주석 "Source EARLY (before any herdr usage in run_agent)"
- **결과**: `herdr has-session -t`가 shim 경유로 `_real_herdr agent get "$sess"`로 번역되어 정상 동작
### 버그 2: HERDR_SERVER_NAME 자동 해석 누락 — 격리 세션 위임 실패
- **문제**: 호출자가 `HERDR_SERVER_NAME`를 수동 export하길 기대 → 격리된 세션 위임 시 default 세션에서 찾아 실패
- **수정**: `export HERDR_SERVER_NAME="$(resolve_herdr_workspace "$sess")"` 자동 해석 추가 (라인 443) + 주석 "Auto-resolve isolation the same way resume/stop/create do"
- **이전 코드**: `local _herdr="herdr"; if [ -n "${HERDR_SERVER_NAME:-}" ]; then _herdr="herdr -L $HERDR_SERVER_NAME"; fi` — 수동 env 의존 + `herdr -L` 직접 사용(shim 경유 아님)
- **신규 코드**: 자동 해석 + `herdr has-session -t` (shim이 `-L`를 내부 처리)
### 버그 3: 안내 메시지 'session attach' → 'agent attach'
- **문제**: 마지막 안내가 `herdr session attach $sess``session attach`는 서버 전체 단위 명령이라 개별 에이전트 이름으로 못 찾음
- **수정**: `HERDR_SERVER_NAME=$HERDR_SERVER_NAME herdr agent attach $sess` (라인 513) + 주석 "`session attach` operates on whole herdr *sessions*, not an individual agent"
- **추가**: `HERDR_SERVER_NAME` 인라인 포함 — source 안 된 fresh shell에서도 copy-paste 가능
---
## 2. 검증 관점 (a): 실제 herdr CLI 문법 일치 여부
### 2.1 ✅ has-session — shim 번역 정확
- 실제 herdr v0.7.4에 `has-session` 서브커맨드 **없음** (도움말에 없음)
- shim(lib.sh 147-163): `has-session -t <sess>``_real_herdr agent get "$sess" >/dev/null 2>&1` — agent 존재 여부로 세션 존재 판정
- **실행 검증**: 존재 세션 → exit 0, 비존재 세션 → exit 1 ✅
### 2.2 ✅ agent attach — 실제 서브커맨드 확인
- `herdr agent --help` 출력: `herdr agent attach <target> [--takeover]`**실제 존재**
- `herdr agent attach <target>`는 **개별 에이전트 target**을 받음 — `$sess`(MAM 에이전트 이름)와 일치 ✅
### 2.3 ✅ session attach vs agent attach 구분 정확
- `herdr session --help` 출력: `herdr session attach <name>`**서버 전체 session** name을 받음
- `herdr agent attach <target>`**개별 에이전트** target을 받음
- delegate-job의 `$sess`는 MAM 에이전트 이름(예: `canary-projects-multi-agent-mux-reviewer-cline`) → `agent attach`가 정답 ✅
- 수정 전 `session attach $sess`는 에이전트 이름을 session 이름으로 잘못 전달 → 실패 확정 ✅ (버그 재현 논리 타당)
### 2.4 ✅ HERDR_SERVER_NAME 인라인 메시지
---
## 3. 검증 관점 (b): source 순서 변경 충돌 여부
### 3.1 ✅ source 위치 — run_agent보다 377줄 앞
- `source "$SCRIPT_DIR/../lib.sh"` (라인 31) vs `run_agent()` 정의 (라인 408) — 377줄 선행 ✅
- has-session 호출(라인 445) 시점엔 shim 활성화 보장 ✅
### 3.2 ✅ 다른 함수/변수와 충돌 없음
- lib.sh source 시 정의되는 함수: `herdr`(shim), `resolve_herdr_workspace`, `send_keys_safe`, `load_state_json`, `derive_session_name`, `env_python`
- delegate-job이 이미 사용 중인 함수(`send_keys_safe`, `load_state_json`) — source 순서 변경 후에도 동일 동작 ✅
- `pick_python()` (라인 34)는 source 이후 정의 — lib.sh가 `pick_python`에 의존하지 않으므로 순서 충돌 없음 ✅
- 라인 26-30 주석이 "Source EARLY" 의도 명시 — 유지보수자에게 경고 ✅
### 3.3 ✅ 기존 `source` 라인(라인 444) 제거 확인
- 6df4b03 diff: `source "$SCRIPT_DIR/../lib.sh"`가 run_agent 내부(구 라인 444)에서 제거되고 상단(라인 31)로 이동 — 중복 source 아님 ✅
---
## 4. 검증 관점 (c): HERDR_SERVER_NAME 자동 해석 — 비격리(default) 회귀 여부
### 4.1 ✅ resolve_herdr_workspace 구현 (lib.sh 550-562)
- state JSON에서 session name 매칭 → `herdr_workspace`/`herdr_server` 반환
- 매칭 실패 시 `os.environ.get('HERDR_SERVER_NAME', 'default')` fallback
- **비격리(default) 경로**: session이 JSON에 없거나 `herdr_workspace` 미설정 → `default`
### 4.2 ✅ python3 시뮬레이션 검증
- **비격리**: `MAM_STATE_JSON='{}' SESSION_NAME='nonexistent'` (HERDR_SERVER_NAME unset) → `default`
- **격리**: `MAM_STATE_JSON='{"herdr_sessions":[{"name":"isolated-agent","herdr_workspace":"iso-server"}]}' SESSION_NAME='isolated-agent'``iso-server`
- **env fallback**: `HERDR_SERVER_NAME=custom_server``custom_server` (test_resume_resolve_herdr_workspace_env 검증) ✅
### 4.3 ✅ 형제 스킬 일관성
| 스크립트 | 패턴 |
|---------|------|
| resume_session.sh:40 | `HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"` |
| update_yaml_resumed.sh:36 | `HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"` |
| stop_session.sh:85 | `HERDR_SERVER_NAME="$(resolve_herdr_workspace "$SESSION_NAME")"` |
| **delegate-job:443 (신규)** | `export HERDR_SERVER_NAME="$(resolve_herdr_workspace "$sess")"` |
- delegate-job이 형제 스킬과 **동일 패턴** 채택 — 일관성 확보 ✅
- 유일한 차이: `export` 추가 — delegate-job은 subprocess(send_keys_safe 등)에 전달 필요 → export 정당 ✅
### 4.4 ✅ 비격리 회귀 없음
- default 세션의 에이전트: `resolve_herdr_workspace``default` 반환 → `HERDR_SERVER_NAME=default` → shim이 `_MAM_SESSION=""`(빈) → `_real_herdr``--session` 없이 호출 → default 서버 사용 ✅
- 55개 테스트(깨끗한 환경) 통과 — 비격리 경로 회귀 없음 ✅
---
## 5. 추가 검증: 정적 분석 & 테스트
### 5.1 ✅ bash -n
| 파일 | 결과 |
|------|------|
| `multi-agent-mux-delegate-job` | SYNTAX OK (exit 0) ✅ |
| `.agents/skills/lib.sh` | SYNTAX OK (exit 0) ✅ |
### 5.2 ✅ 테스트 (깨끗한 환경)
- `env -u HERDR_SERVER_NAME pytest tests/test_tier1_unit.py tests/test_tier2_component.py`: **55 passed**
- **주의**: 리뷰어 세션 환경(`HERDR_SERVER_NAME=multi-agent-mux`)에서 실행 시 `test_resume_resolve_herdr_workspace_default` 실패 — 이는 **테스트 하네스 env 격리 한계**(test가 `env=` 미전달하여 부모 환경 상속), 코드 결함 아님. `env -u HERDR_SERVER_NAME`로 실행 시 통과 ✅
---
## 6. 잔여 결함
### 6.1 ⚠️ test_resume_resolve_herdr_workspace_default env 격리 부족 (LOW, 본 수정 외)
- 테스트가 `run_lib_func` 호출 시 `env=` 미전달 → 부모 shell의 `HERDR_SERVER_NAME` 상속
- 격리 herdr 세션 내에서 pytest 실행 시 실패(환경 artifact)
- **영향**: 본 delegate-job 수정과 무관, 기존 테스트 하네스 한계. CI(깨끗한 env)에서는 통과
- **심각도**: LOW — 테스트 격로 보강 권장(`env={}` 명시 또는 `monkeypatch.delenv`)
### 6.2 ️ 주석 "tmux-compat shim" (INFO)
- 라인 27 주석 "turns plain `herdr` into the tmux-compat shim" — tmux 호환성 레퍼런스는 의도된 설명(실제 shim이 tmux 문법을 herdr로 번역)
- **심각도**: INFO — 유지
---
## 7. 종합 평가
### 버그 3건 수정 품질
1.**버그 1 (source 순서)**: lib.sh를 라인 31로 조기 이동 — has-session이 shim 경유 `agent get`으로 정상 동작. run_agent보다 377줄 선행, 다른 함수와 충돌 없음
2.**버그 2 (HERDR_SERVER_NAME 자동 해석)**: `resolve_herdr_workspace` 자동 호출 — 형제 스킬(resume/stop)과 동일 패턴, 비격리(default) 회귀 없음, 격리 세션 정상 해석
3.**버그 3 (session attach → agent attach)**: 실제 herdr v0.7.4 도움말로 `agent attach <target>` 존재 확인 — 에이전트 이름 전달 정확, `session attach`는 서버 단위로 부적절
### 검증 관점 충족
- **(a) herdr CLI 문법**: has-session(shim `agent get` 번역), agent attach(실제 서브커맨드), session attach(서버 단위 구분) — 모두 실제 도움말로 확인 ✅
- **(b) source 순서 충돌**: 라인 31 조기 source, run_agent(408) 선행, 중복 source 없음, 다른 함수 충돌 없음 ✅
- **(c) 비격리 회귀**: resolve_herdr_workspace가 default fallback, 55개 테스트(깨끗한 env) 통과, 형제 스킬 일관성 ✅
### 잔여 결함 (본 수정 외, LOW/INFO)
- LOW 6.1: test env 격리 부족 (본 수정 무관, CI 통과)
- INFO 6.2: "tmux-compat shim" 주석 (의도된 설명)
### 판정 근거
3건 버그 수정 모두 실제 herdr v0.7.4 CLI 문법과 정확히 일치(has-session/agent attach/session attach 도움말 직접 확인). source 순서 변경은 run_agent 선행 보장 + 다른 함수 충돌 없음. HERDR_SERVER_NAME 자동 해석은 형제 스킬과 동일 패턴으로 일관성 확보 + 비격리 회귀 없음. 정적 분석 통과, 55개 테스트(깨끗한 환경) 통과. 잔여 결함은 본 수정 외 LOW/INFO로 BLOCKING 아님. 설계 변경/재작업 불필요. PASS.
[VERDICT: PASS]
@@ -0,0 +1,46 @@
# ✅ deploy/ 배포 설정 tmux→herdr 전환 수정 완료 보고
**Job ID**: 94ce2fcc (동일 작업: 60af70e4)
**Role**: Implementation (Cline)
**Plan**: [report-baa15c96.md](.agents/reports/canary-projects-multi-agent-mux-planner-reviewer-claude/report-baa15c96.md)
**Brief**: [.mam/deploy_patch_brief.md](.mam/deploy_patch_brief.md)
## 적용된 변경 (5 파일, 7 에디트)
### 1. deploy/install_mam.sh (기능 파손 수정 — 우선순위 1)
- **L224**: `--tmux-server multi-agent-mux``--herdr-server multi-agent-mux`
- 근거: `create_session.sh:63`의 실제 플래그(`--herdr-server`) — 기존 `--tmux-server`는 create_session.sh가 거부(unknown arg, exit 2)하여 복붙 시 즉시 실패하는 기능 파손
- **L227**: `tmux -L multi-agent-mux attach -t <session_name>``HERDR_SERVER_NAME=multi-agent-mux herdr agent attach <session_name>`
- 근거: `create_session.sh:328`이 YAML `attach_command`로 방출하는 canonical 형식과 1:1 일치 (`HERDR_SERVER_NAME={server} herdr agent attach {name}`)
### 2. deploy/install.sh
- **L31**: `check_cmd tmux``check_cmd herdr` (올바른 의존성 진단)
- **L249**: `TMUX_SERVER_NAME=default``HERDR_SERVER_NAME=default` (소비처 0인 죽은 설정 → 실제 소비 변수)
### 3. deploy/README.md
- **L9**: `checks system requirements (\`tmux\`, \`python3\`)` → `\`herdr\`, \`python3\``
### 4. deploy/INSTALL.md
- **L21**: 진단 목록 `tmux``herdr`
- **L37**: 의존성 진단 `tmux``herdr`
- **L69**: `tmux가 소멸한 경우``herdr 서버가 소멸한 경우`
### 5. deploy/plugin.json
- **L3**: `Backplane on Tmux & MQTT.``Backplane on Herdr & MQTT.`
## 검증 결과 (DoD)
1.**잔존 스윕**: `grep -rin tmux deploy/`**0건** (대소문자 무시)
2.**정적**: `bash -n` install_mam.sh/install.sh 모두 exit 0; `shellcheck -S warning` exit 0 (신규 경고 0건); `python3 -m json.tool plugin.json` VALID
3.**기능**: `--herdr-server``create_session.sh:35/63`의 실제 플래그 확인; attach 형식이 `create_session.sh:328` canonical 형식과 1:1 일치
4.**회귀**: `env -u HERDR_SERVER_NAME pytest tests/test_tier1_unit.py tests/test_tier2_component.py`**55 passed**
## 계획서 교정사항 반영
- ~~`HERDR_SESSION_NAME`~~ → **`HERDR_SERVER_NAME`** (스킬 전체가 소비하는 유일한 변수명) ✅
- ~~`--herdr-session`~~ → **`--herdr-server`** (create_session.sh:63의 유일한 플래그) ✅
## 제외 항목 (계획서 §1-6)
- remove.sh / update.sh: tmux/kill 로직 없음 → 수정 불요 ✅
- generate-env.sh / gitea-ci.yml: tmux 참조 없음 → 수정 불요 ✅
[VERDICT: PASS]
@@ -0,0 +1,58 @@
# ✅ MULTI_AGENT_RULES tmux→herdr 전환 수정 완료 보고
**Job ID**: b92c34d7
**Role**: Implementation (Cline)
**Brief**: [.mam/rules_patch_brief.md](.mam/rules_patch_brief.md)
**Target Files**: `.agents/MULTI_AGENT_RULES.md` (EN) + `.agents/MULTI_AGENT_RULES.ko.md` (KO)
## 적용된 변경 (2 파일, 13 에디트 — 정확한 1:1 치환)
### 1. MULTI_AGENT_RULES.md (EN) — 6 에디트
| 행 | 변경 전 | 변경 후 |
|---|---|---|
| 3 | `Tmux-based multi-agent orchestration` | `Herdr-based multi-agent orchestration` |
| 57 | `TMUX monitoring states` | `Herdr monitoring states` |
| 116 | `running in TMUX environments` | `running in Herdr environments` |
| 124 | `via tmux input buffers` | `via herdr input buffers` |
| 129 | `<tmux_session_name>` | `<herdr_session_name>` |
| 131 | `<tmux_session_name>` | `<herdr_session_name>` |
### 2. MULTI_AGENT_RULES.ko.md (KO) — 7 에디트
| 행 | 변경 전 | 변경 후 |
|---|---|---|
| 3 | `Tmux 기반 멀티 에이전트` | `Herdr 기반 멀티 에이전트` |
| 57 | `TMUX 모니터링 상태` | `Herdr 모니터링 상태` |
| 116 | `TMUX 환경에서 실행되는` | `Herdr 환경에서 실행되는` |
| 122 | `TMUX `send-keys`나 입력 버퍼` | `herdr `send-keys`나 입력 버퍼` |
| 124 | `tmux 입력을 통해` | `herdr 입력을 통해` |
| 129 | `<tmux_session_name>` | `<herdr_session_name>` |
| 131 | `<tmux_session_name>` | `<herdr_session_name>` |
## 치환/보완 가이드 대응표 (브리프 §치환/보완 가이드)
| 브리프 지시 | 적용 | 비고 |
|---|---|---|
| `Tmux-based``Herdr-based` | ✅ EN L3, KO L3 | |
| `TMUX monitoring states``Herdr monitoring states` | ✅ EN L57, KO L57 | |
| `running in TMUX environments``running in Herdr environments` | ✅ EN L116, KO L116 | |
| `via tmux input buffers``via herdr input buffers` | ✅ EN L124 | KO L124 `tmux 입력``herdr 입력` (맥락 동일) |
| `tmux_session_name``herdr_session_name` | ✅ EN L129/L131, KO L129/L131 | 경로 placeholder 4곳 |
| KO: `herdr`/`herdr 서버`로 개정 | ✅ KO L3/L57/L116/L122/L124 | |
## 검증 결과 (DoD)
1.**잔존 스윕**: `grep -in 'tmux\|TMUX'` 두 파일 → **0건** (대소문자 무시)
2.**마크다운 포맷팅**: 헤딩 개수 EN 17 / KO 17 (변경 전후 동일 — 레이아웃 유지); diff 13 insertions / 13 deletions (정확한 1:1 치환, 구조 변경 없음)
3.**herdr 치환 확인**: EN 6곳, KO 7곳 herdr/Herdr 표기 존재
4.**회귀**: `env -u HERDR_SERVER_NAME pytest tests/test_tier1_unit.py tests/test_tier2_component.py`**55 passed**
## 관찰 사항 (INFO, 본 브리프 범위 외)
### INFO: `capture-pane -S -200` 명령어 예시 잔존
- 스냅샷 3대 규칙 섹션(EN L117-118, KO L117-118)에 `capture-pane -S -200` 명령어 예시가 잔존
- 이는 tmux 명령어이나 본 브리프의 치환 대상(`tmux`/`TMUX`/`tmux_session_name` 텍스트 표기)이 아님 — "tmux" 단어를 포함하지 않으므로 grep 스윕에 걸리지 않음
- **심각도**: INFO — 본 브리프 범위 밖. 후속 작업에서 `herdr agent read --lines 200` 등 herdr 네이티브 명령으로 교체 권장 (별도 결정 사항)
## 제외 항목
- 본 브리프는 지침 문서 2종만 대상 — 다른 파일은 수정하지 않음 ✅
[VERDICT: PASS]
@@ -0,0 +1,172 @@
# ✅ Peer Review Report: macOS 타임아웃 오류 수정 — 절대 경로 분석 및 quarantine 해제 (Job e81e70d6)
**Job**: `e81e70d6` · **Reviewer**: Reviewer B (Cline, `canary-projects-multi-agent-mux-reviewer-cline`)
**Review Target**: 커밋 `f79fd99` "fix(mac-compat): resolve absolute path of agent binary and strip macos quarantine attribute to prevent gatekeeper and path-resolution timeouts"
**Files Changed**: `create_session.sh` (+18/-4), `resume_session.sh` (+17/-4) — 2 files, 43 insertions, 8 deletions
**Review Scope**: 작업 목표 "create_session.sh 및 resume_session.sh에서 에이전트 실행 시 절대 경로 분석(command -v)과 macOS 격리 해제(xattr) 처리로 macOS 타임아웃 오류를 수정" — 린트, 동작성, 유실 관점 교차 리뷰
**Method**: 커밋 diff 분석 + `bash -n`/`shellcheck` 정적 분석 + `command -v` 해상도 검증 + Darwin/xattr 가드 검증 + 양 파일 블록 일치성 비교 + 사전 패턴 회귀 확인 + cline 특수 케이스 중복성 검증
---
## 1. 변경 사항 개요
### 1.1 절대 경로 분석 (command -v)
두 파일 모두 동일한 블록 추가:
```bash
RESOLVED_BIN="$AGENT"
if [ "$AGENT" = "cline" ]; then
if command -v cline >/dev/null 2>&1; then
RESOLVED_BIN="$(command -v cline)"
fi
else
if command -v "$AGENT" >/dev/null 2>&1; then
RESOLVED_BIN="$(command -v "$AGENT")"
fi
fi
```
**목적**: tmux 세션 spawn 시 PATH 상속 문제 방지. `command -v`로 절대 경로 해상 → tmux가 올바른 바이너리 실행.
### 1.2 macOS quarantine 속성 제거 (xattr)
```bash
if [ "$(uname)" = "Darwin" ] && [ -f "$RESOLVED_BIN" ]; then
xattr -d com.apple.quarantine "$RESOLVED_BIN" 2>/dev/null || true
fi
```
**목적**: macOS Gatekeeper가 quarantine 속성으로 인해 바이너리 실행 시 확인 대화상자 표시 → 타임아웃 발생. `xattr -d`로 속성 제거.
### 1.3 case 문 RESOLVED_BIN 적용
모든 agent 케이스(claude/agy/hermes/cline)의 `CMD_FULL`에서 bare 이름 → `${RESOLVED_BIN}` 교체.
---
## 2. 작업 목표 달성도
| 목표 | 상태 | 확인 |
|------|------|------|
| 절대 경로 분석 (command -v) | ✅ | 두 파일 모두 RESOLVED_BIN 블록 추가 |
| macOS 격리 해제 (xattr) | ✅ | Darwin 가드 + xattr -d com.apple.quarantine |
| macOS 타임아웃 오류 수정 | ✅ | PATH 해상 + Gatekeeper 방지로 근원 해결 |
| create_session.sh 적용 | ✅ | 라인 152-174 |
| resume_session.sh 적용 | ✅ | 라인 90-113 |
---
## 3. 정적 분석
| 파일 | bash -n | shellcheck | 비고 |
|------|---------|------------|------|
| create_session.sh | ✅ SYNTAX OK | SC1091 (info, 기존 source) — **본 diff 새 경고 없음** | EXIT 1 (기존) |
| resume_session.sh | ✅ SYNTAX OK | SC1091 (info, 기존), SC2155 (warning, 라인 40, 기존) — **본 diff 새 경고 없음** | EXIT 1 (기존) |
`shellcheck -x`(source follow)에서도 본 diff 관련 새 경고 없음 ✅
---
## 4. 동작성 검증
### 4.1 ✅ command -v 해상도 검증
| 조건 | 결과 | 판정 |
|------|------|------|
| agent 바이너리 PATH에 있음 | `command -v` → 절대 경로 | ✅ 정상 (예: `/home/godopu16/.npm-global/bin/cline`) |
| agent 바이너리 PATH에 없음 | `command -v` 실패 → `RESOLVED_BIN` stays as `$AGENT` | ✅ graceful fallback |
| Linux 환경 | 모든 agent NOT FOUND → fallback | ✅ 정상 동작 |
### 4.2 ✅ Darwin/xattr 가드 검증
| 조건 | 결과 | 판정 |
|------|------|------|
| `uname` = Linux | Darwin 체크 실패 → xattr 블록 스킵 | ✅ Linux에서 xattr 미호출 |
| `uname` = Darwin + 파일 존재 | `xattr -d com.apple.quarantine` 실행 | ✅ macOS에서 quarantine 제거 |
| `uname` = Darwin + quarantine 없음 | `xattr -d` 실패 → `\|\| true`로 무시 | ✅ graceful |
| `RESOLVED_BIN` = bare 이름(해상 실패) | `[ -f "$RESOLVED_BIN" ]` 실패 → xattr 스킵 | ✅ 파일이 아닌 경우 안전 |
### 4.3 ✅ 양 파일 블록 일치성
`RESOLVED_BIN` 해상 블록 + `xattr` 블록이 create_session.sh(라인 152-167)와 resume_session.sh(라인 90-105)에서 **byte-identical** ✅. `diff`로 확인 — IDENTICAL.
### 4.4 ✅ 사전 패턴 회귀 확인
- `cline` case의 `ISO_ENV_PREFIX` 누락: **사전 패턴** (원본 `cline -i...``ISO_ENV_PREFIX` 없음). 본 diff는 `cline``${RESOLVED_BIN}`만 교체, 패턴 유지. 회귀 아님 ✅
- `case` 문의 `CMD_FULL` 구조: bare 이름 → `${RESOLVED_BIN}` 교체만, 나머지 인자/플래그 동일 ✅
- auth check(라인 86-102)는 bare 이름 사용: `RESOLVED_BIN` 블록 **이전** pre-flight 검사이므로 PATH 기반 조회가 적절. 본 diff 범위 외 ✅
### 4.5 ✅ spawn 경로 모두 CMD_FULL 사용
- create_session.sh spawn(): claude(라인 184), agy|hermes|cline(라인 188) 모두 `"$CMD_FULL"` 사용 → `RESOLVED_BIN` 반영 ✅
- resume_session.sh: claude wrapper 경로(라인 127)는 사전 패턴(하드코딩 wrapper), else(라인 129) + agy|hermes|cline(라인 136)은 `$CMD_FULL``RESOLVED_BIN` 반영 ✅
---
## 5. 잔여 결함 (LOW — INFORMATIONAL)
### 5.1 ⚠️ cline 특수 케이스 중복 (LOW, code smell)
**위치**: 양 파일 라인 154-162
```bash
if [ "$AGENT" = "cline" ]; then
if command -v cline >/dev/null 2>&1; then
RESOLVED_BIN="$(command -v cline)" # hardcode "cline"
fi
else
if command -v "$AGENT" >/dev/null 2>&1; then
RESOLVED_BIN="$(command -v "$AGENT")" # variable "$AGENT"
fi
fi
```
**분석**: `AGENT=cline`일 때 else 브랜치 `command -v "$AGENT"`(= `command -v cline`)와 동일 결과.实证: 두 방법 모두 `/home/godopu16/.npm-global/bin/cline` 반환 → **IDENTICAL**.
**평가**: 특수 케이스가 기능적으로 중복. else 브랜치만으로 충분. 단, 버그 아님 — 올바르게 동작함. 단순 code smell.
**심각도**: LOW — BLOCKING 아님.
**권고**: 향후 `if command -v "$AGENT"` 단일 브랜치로 단순화 고려.
### 5.2 ⚠️ RESOLVED_BIN 경로 내 공백 시 eval 분할 (LOW, theoretical)
**위치**: resume_session.sh 라인 136 `eval "tmux ... \"$CMD_FULL\""`
**분석**: `RESOLVED_BIN`이 공백 포함 경로(예: `/path with spaces/claude`)인 경우, `CMD_FULL` 내 공백이 eval에 의해 단어 분할 → 잘못된 실행.
**현재 영향**: macOS/Linux 표준 설치 경로(`/usr/local/bin`, `/opt/homebrew/bin`, `~/.npm-global/bin`)는 공백 없음. 이론적 가능성만 존재.
**참고**: 사전 패턴 — 원본도 `CMD_FULL="claude --dangerously..."`를 eval로 실행. 본 diff가 도입한 문제 아님.
**심각도**: LOW — 이론적, BLOCKING 아님.
### 5.3 ️ 작업 트리 잔여 .tmp 파일 (INFO, unrelated)
**위치**: `.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job.16035_12342.tmp` (untracked)
**분석**: 이전 delegate_job_safe 실행 잔여물. 본 diff와 무관. 무해하지만 정리 권장.
**심각도**: INFO — 본 리뷰 범위 외.
---
## 6. 종합 평가
### 작업 목표 달성도
"create_session.sh 및 resume_session.sh에서 에이전트 실행 시 절대 경로 분석(command -v)과 macOS 격리 해제(xattr) 처리로 macOS 타임아웃 오류를 수정" — **달성**.
### 변경 품질
1.**절대 경로 해상**: `command -v`로 PATH 상속 문제 해결, graceful fallback(해상 실패 시 bare 이름 유지)
2.**quarantine 제거**: Darwin 가드 + `xattr -d ... || true`로 안전 처리, Linux에서 미실행
3.**양 파일 일치**: RESOLVED_BIN + xattr 블록이 byte-identical — 일관성 확보
4.**사전 패턴 존중**: cline ISO_ENV_PREFIX 누락 등 기존 설계 유지, 회귀 없음
5.**모든 spawn 경로 반영**: create/resume 모든 case에서 `${RESOLVED_BIN}` 적용
### 검증 결과
- 정적 분석: `bash -n` 2/2 OK, `shellcheck` 본 diff 새 경고 없음 ✅
- command -v 해상: 정상(절대 경로) + fallback(bare 이름) 모두 확인 ✅
- Darwin/xattr 가드: Linux 스킵, macOS 실행, quarantine 없음 시 graceful ✅
- 양 파일 일치성: IDENTICAL ✅
- 사전 패턴 회귀: 없음 ✅
### 잔여 LOW 2건 + INFO 1건
- LOW 5.1: cline 특수 케이스 중복 (code smell, 버그 아님)
- LOW 5.2: RESOLVED_BIN 공백 시 eval 분할 (이론적, 사전 패턴)
- INFO 5.3: 잔여 .tmp 파일 (본 diff 무관)
### 판정 근거
작업 목표(절대 경로 분석 + quarantine 해제) 완전 달성. 양 파일에 동일 블록 추가로 일관성 확보. 정적 분석 통과, 동작성 검증(command -v fallback, Darwin 가드, 일치성) 모두 PASS. 사전 패턴 회귀 없음. 잔여 LOW 2건은 모두 BLOCKING 아닌 code smell/이론적 가능성. 주 개발자가 macOS 타임아웃 근원(PATH 해상 + Gatekeeper)을 정확히 진단하고 수정했으므로 PASS 판정이 타당.
[VERDICT: PASS]
+636 -214
View File
File diff suppressed because it is too large Load Diff
+63 -48
View File
@@ -1,28 +1,28 @@
---
name: multi-agent-mux-create
description: "Create a new agent session (claude, antigravity/agy) in a dedicated tmux session for context-preserving long-running work. Always creates a tmux 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."
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: 1.0.0
author: godopu
license: MIT
platforms: [linux, macos]
environments: [terminal, tmux]
environments: [terminal, herdr]
metadata:
hermes:
tags: [agent, tmux, claude, antigravity, agy, multi-agent, context, session]
tags: [agent, herdr, claude, antigravity, agy, multi-agent, context, session]
related_skills: [multi-agent-mux-resume, multi-agent-mux-stop, multi-agent-mux-monitor, claude-code]
prereq_skills: [claude-code]
---
# Multi-Agent Create — Start a Fresh Agent in a tmux Session
# Multi-Agent Create — Start a Fresh Agent in a herdr Session
> **Companion skills**: `multi-agent-mux-resume` (resume an existing UUID), `multi-agent-mux-stop` (terminate), `multi-agent-mux-monitor` (live status).
> **Single source of truth**: `./.mam/agent-sessions.yaml` (this skill writes to it; never read it ad-hoc — go through this skill).
## What this skill does
Spawn a new agent (`claude` or `agy`/antigravity-cli) in a **dedicated tmux session** for context-preserving long-running work. The tmux session is the *container*; the agent's session ID is *data* inside the container. **This skill creates the container + starts the agent — but does not resume an old conversation** (use `multi-agent-mux-resume` for that).
Spawn a new agent (`claude` or `agy`/antigravity-cli) in a **dedicated herdr session** for context-preserving long-running work. The herdr session is the *container*; the agent's session ID is *data* inside the container. **This skill creates the container + starts the agent — but does not resume an old conversation** (use `multi-agent-mux-resume` for that).
For all agents: the tmux session name is produced by **`lib.sh::derive_session_name`** — the single source of truth shared by create/resume/stop/status/monitor (P0-A). The rule (verbatim from the function):
For all agents: the herdr session name is produced by **`lib.sh::derive_session_name`** — the single source of truth shared by create/resume/stop/status/monitor (P0-A). The rule (verbatim from the function):
> slug = the **two trailing path components** of the absolute workspace, `_`→`-`, lowercased, joined with `-`; name = `<slug>-creator-<agent>`.
@@ -33,9 +33,9 @@ So `$WORKSPACE_ROOT/landing_page/refer_landing_page` + `claude` → `landing-pag
Before doing anything, verify the environment:
```bash
# 1) tmux available and isolated server status
command -v tmux || { echo "ERROR: tmux not installed"; exit 1; }
echo "Tmux server name: ${TMUX_SERVER_NAME:-default}"
# 1) herdr available and isolated server status
command -v herdr || { echo "ERROR: herdr not installed"; exit 1; }
echo "Herdr server name: ${HERDR_SERVER_NAME:-default}"
# 2) claude / agy available
command -v claude # required for --agent claude
@@ -52,29 +52,31 @@ If any check fails → `kanban_block(reason="...")` (worker path) or report to u
## Standard names
- **tmux session name**: `derive_session_name <workspace> <agent>` (lib.sh)
- **herdr session name**: `derive_session_name <workspace> <agent>` (lib.sh)
- `<workspace-slug>` = `basename $(dirname $WORKSPACE)` `-` `basename $WORKSPACE` (lowercase, `_``-`)
- examples: `landing-page-refer-landing-page-creator-claude`, `paper-pdf2md-creator-agy`
- never re-derive this by hand — source lib.sh and call the function
- **wrapper script** (claude only): `~/.local/bin/<workspace-slug>-creator-claude`
- contents: tmux new-session with `claude` inside, auto-handles trust/bypass dialogs
- contents: herdr new-session with `claude` inside, auto-handles trust/bypass dialogs
- see `<workdir>/agent_sessions.md` for the canonical wrapper template
## Tmux Server Isolation (격리 서버)
## Herdr Server Isolation (격리 서버)
When running multiple agent sessions alongside other workflows (e.g., cmux, Kanban workers, manual tmux sessions), sharing the default tmux server can lead to session name conflicts, monitoring clutter, and accidental destruction of user sessions via global commands.
When running multiple agent sessions alongside other workflows (e.g., cmux, Kanban workers, manual herdr sessions), sharing the default herdr server can lead to session name conflicts, monitoring clutter, and accidental destruction of user sessions via global commands.
To prevent this, you can run this skill inside an **isolated tmux server** using the `TMUX_SERVER_NAME` environment variable or the `--tmux-server <name>` flag (opt-in).
To prevent this, you can run this skill inside an **isolated herdr server** using the `HERDR_SERVER_NAME` environment variable or the `--herdr-server <name>` flag (opt-in).
Under the hood this now maps to a real, separate herdr **session** (`herdr --session <name>` — its own socket, its own `agent list`/`workspace list`, completely invisible to the default session and vice versa), not just a workspace label inside the same server. `lib.sh`'s shim bootstraps the named session's server headlessly (`herdr --session <name> server`, backgrounded) the first time it's needed, and scopes every subsequent herdr call to it automatically — this headless bootstrap is what lets it work even when the skill itself is running from inside another herdr-managed pane (a plain interactive `herdr --session <name>` launch is blocked there by herdr's "nested herdr is disabled" guard; headless `server` mode isn't).
### How to use
1. **Via Environment Variable**:
```bash
export TMUX_SERVER_NAME=multi-agent-canary
# All subsequent commands (create, status, stop, etc.) will run in the isolated 'multi-agent-canary' tmux server.
export HERDR_SERVER_NAME=multi-agent-canary
# All subsequent commands (create, status, stop, etc.) will run in the isolated 'multi-agent-canary' herdr server.
```
2. **Via Option Flag**:
```bash
bash scripts/create_session.sh --workspace /path/to/project --agent claude --role developer --tmux-server multi-agent-canary
bash scripts/create_session.sh --workspace /path/to/project --agent claude --role developer --herdr-server multi-agent-canary
```
3. **Submit Job Integration**:
You can automatically register a delegated job with a prompt when creating a session:
@@ -90,13 +92,14 @@ To prevent this, you can run this skill inside an **isolated tmux server** using
### Recommended Alias
You can set an alias in your shell to easily query sessions on the isolated server:
```bash
alias tmc='tmux -L multi-agent-canary'
alias tmc='herdr -L multi-agent-canary'
tmc ls # Lists only your multi-agent sessions
```
### Safety Rules (Pitfall 29 Summary)
- Never use global server termination commands like `tmux kill-server` or `tmux kill-session -a` as they will destroy all sessions on that server (including your own workspace sessions if they share the server).
- By using an isolated server via `TMUX_SERVER_NAME`, your agent sessions are completely separated from your default user workspace, ensuring 0% interference.
- Never use global server termination commands like `herdr server stop` as they will destroy every workspace/agent on that server (including your own workspace sessions if they share the server). (`kill-server`/`kill-session -a` are tmux-era names that don't exist in herdr's real CLI — see Pitfalls below.)
- By using an isolated server via `HERDR_SERVER_NAME`, your agent sessions are completely separated from your default user workspace, ensuring 0% interference — this is now backed by a genuinely separate `herdr` session/socket, not merely a workspace label.
- To deliberately tear down an *entire* isolated group at once (all its workspaces and agents), use `herdr session stop <HERDR_SERVER_NAME>` followed by `herdr session delete <HERDR_SERVER_NAME>` — this only affects that named session, never the default one.
## Workflow
@@ -107,25 +110,25 @@ source .agents/skills/lib.sh
SESSION_NAME="$(derive_session_name "$WORKSPACE" "$AGENT")"
# 1. If session already alive, fail fast
tmux has-session -t "$SESSION_NAME" 2>/dev/null && {
echo "ERROR: tmux session '$SESSION_NAME' already exists. Use multi-agent-mux-resume to attach or multi-agent-mux-stop first."
herdr has-session -t "$SESSION_NAME" 2>/dev/null && {
echo "ERROR: herdr session '$SESSION_NAME' already exists. Use multi-agent-mux-resume to attach or multi-agent-mux-stop first."
exit 1
}
# 2. Spawn the tmux session with the agent inside
# 2. Spawn the herdr session with the agent inside
case "$AGENT" in
claude)
# Use the wrapper if it exists, else inline tmux new-session
# Use the wrapper if it exists, else inline herdr new-session
# Use the wrapper if it exists (LOCAL_BIN env var overrides default $HOME/.local/bin)
local_bin="${LOCAL_BIN:-$HOME/.local/bin}"
if [ -x "$local_bin/$SESSION_NAME" ]; then
nohup "$local_bin/$SESSION_NAME" >/dev/null 2>&1 &
else
tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "claude"
herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "claude"
fi
;;
agy)
tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "agy --dangerously-skip-permissions"
herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "agy --dangerously-skip-permissions"
;;
*) echo "ERROR: --agent must be claude or agy, got: $AGENT"; exit 2 ;;
esac
@@ -134,22 +137,24 @@ esac
sleep 6
# 4. Capture pane metadata
PANE_PID=$(tmux list-panes -t "$SESSION_NAME" -F '#{pane_pid}')
PANE_CWD=$(tmux list-panes -t "$SESSION_NAME" -F '#{pane_current_path}')
PANE_CMD=$(tmux list-panes -t "$SESSION_NAME" -F '#{pane_current_command}')
TMUX_EPOCH=$(tmux list-sessions -F '#{session_created}' -t "$SESSION_NAME" 2>/dev/null | head -1)
PANE_PID=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}')
PANE_CWD=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_path}')
PANE_CMD=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_command}')
# `herdr list-sessions` doesn't exist (real or shimmed) — we just spawned this
# session ourselves, so stamp the epoch locally instead of round-tripping herdr.
HERDR_EPOCH=$(date +%s)
```
## Registering the session in agent-sessions.yaml
After spawn, append a new `tmux_sessions[]` entry to `.mam/agent-sessions.yaml`:
After spawn, append a new `herdr_sessions[]` entry to `.mam/agent-sessions.yaml`:
```yaml
- name: <SESSION_NAME>
status: running
tmux_session_created_at: 2026-06-17T...Z # ISO 8601 UTC
tmux_session_epoch: <TMUX_EPOCH>
tmux_server: <TMUX_SERVER_NAME> # Isolated server name (default: 'default')
herdr_session_created_at: 2026-06-17T...Z # ISO 8601 UTC
herdr_session_epoch: <HERDR_EPOCH>
herdr_server: <HERDR_SERVER_NAME> # Isolated server name (default: 'default')
pane:
index: 0
pid: <PANE_PID>
@@ -162,9 +167,13 @@ After spawn, append a new `tmux_sessions[]` entry to `.mam/agent-sessions.yaml`:
plan: <from TUI status>
account: <from TUI status>
version: <from TUI status>
start_command: <the exact tmux new-session command used>
attach_command: "tmux attach -t <SESSION_NAME>"
kill_command: "tmux kill-session -t <SESSION_NAME>"
start_command: "HERDR_SERVER_NAME=<herdr_server> herdr new-session -d -s <SESSION_NAME> -x 140 -y 40 -c <WORKSPACE> <CMD_FULL>"
attach_command: "HERDR_SERVER_NAME=<herdr_server> herdr agent attach <SESSION_NAME>"
kill_command: "HERDR_SERVER_NAME=<herdr_server> herdr kill-session -t <SESSION_NAME>"
# All three require `source .agents/skills/lib.sh` first — `new-session`/`kill-session`
# are tmux-compat pseudo-commands the shim translates, and `HERDR_SERVER_NAME` is what
# the shim reads to route to the right isolated herdr *session* (real `herdr` has no
# env-var-based scoping of its own; `herdr_server: default` needs no prefix at all).
```
`cmd_full` per agent (this is the actual command line in the pane, not the resume command):
@@ -185,10 +194,10 @@ The script handles the YAML append, pane capture, and the `last_visible_status`
## Pitfalls
- **Don't use `nohup`/`disown`/`setsid` for the agent itself** — those background the agent outside tmux. The whole point of this skill is *the tmux session is the supervisor*. `nohup` is OK only for *launching the wrapper* (which itself creates the tmux session via `tmux new-session -d`).
- **Don't use `nohup`/`disown`/`setsid` for the agent itself** — those background the agent outside herdr. The whole point of this skill is *the herdr session is the supervisor*. `nohup` is OK only for *launching the wrapper* (which itself creates the herdr session via `herdr new-session -d`).
- **Don't trust `--session-id <uuid>` flags blindly** — claude/agy may not accept a fixed session id on first spawn. The session id is *assigned* on first user message; you can read it back from `~/.claude/projects/.../session.jsonl` headers or `~/.gemini/.../cache/last_conversations.json` AFTER the first message.
- **Wrapper script MUST NOT be created via `hermes profile alias`** — that command writes a `hermes -p <profile>` wrapper that destroys the tmux behavior. Create wrappers manually (see `lab-landing-page-creator-claude` template).
- **Always use the workspace-relative path** in tmux `cwd` — relative paths break when tmux respawns in a different shell context.
- **Wrapper script MUST NOT be created via `hermes profile alias`** — that command writes a `hermes -p <profile>` wrapper that destroys the herdr behavior. Create wrappers manually (see `lab-landing-page-creator-claude` template).
- **Always use the workspace-relative path** in herdr `cwd` — relative paths break when herdr respawns in a different shell context.
- **The first `claude` message generates the session id** — `multi-agent-mux-create` only sets up the *container*. If you need a known session id for later resume, send a placeholder message (e.g. "init") and read it back, then call `multi-agent-mux-resume` later.
## Verification
@@ -196,28 +205,34 @@ The script handles the YAML append, pane capture, and the `last_visible_status`
After spawn + YAML append:
```bash
# 1. tmux session is alive
tmux has-session -t "$SESSION_NAME" && echo OK || echo MISSING
# 1. herdr session is alive (real native command — no lib.sh needed)
herdr agent get "$SESSION_NAME" >/dev/null 2>&1 && echo OK || echo MISSING
# 2. pane has the expected cmd + cwd
tmux list-panes -t "$SESSION_NAME" -F 'cmd=#{pane_current_command} cwd=#{pane_current_path}'
herdr agent get "$SESSION_NAME" | python3 -c "
import sys, json
a = json.load(sys.stdin)['result']['agent']
print(f\"cmd={a['agent']} cwd={a['cwd']}\")
"
# 3. agent-sessions.yaml has the new entry
python3 -c "
import yaml
d = yaml.safe_load(open('.mam/agent-sessions.yaml'))
names = [s['name'] for s in d['tmux_sessions']]
names = [s['name'] for s in d['herdr_sessions']]
assert '$SESSION_NAME' in names, 'session not registered'
print('OK:', names)
"
# 4. Optional: check the TUI status via capture-pane
tmux capture-pane -t "$SESSION_NAME" -p -S -20 # TUI ready = agent banner visible, no dialog text
# 4. Optional: check the TUI status (real native command)
herdr agent read "$SESSION_NAME" --source visible --lines 20 # TUI ready = agent banner visible, no dialog text
```
> `herdr has-session` / `herdr list-panes` / `herdr capture-pane` above are tmux-compat pseudo-commands only understood after `source .agents/skills/lib.sh` (see `Workflow`) — the real `herdr` binary has no such subcommands. The block above uses the real `herdr agent get`/`herdr agent read` equivalents so it also works standalone.
## When NOT to use this skill
- **Resuming an old conversation** → `multi-agent-mux-resume`
- **Killing an existing session** → `multi-agent-mux-stop`
- **Just attaching to an existing session** → `tmux attach -t <name>` (no skill needed)
- **One-shot print mode (claude -p "...")** → no tmux needed; use `claude-code` skill's print mode
- **Just attaching to an existing session** → `herdr agent attach <name>` (no skill needed)
- **One-shot print mode (claude -p "...")** → no herdr needed; use `claude-code` skill's print mode
@@ -4,18 +4,18 @@
# bash create_session.sh --workspace <path> --agent <claude|agy> --role <role> [--session <name>] [--wrapper]
#
# 동작:
# 1) preflight: tmux/claude/agy 가용성, workspace 존재
# 2) tmux 세션 이름 결정 (--session 없으면 자동)
# 3) tmux 세션 시작 (claude 는 wrapper 우선, agy 는 인라인)
# 1) preflight: herdr/claude/agy 가용성, workspace 존재
# 2) herdr 세션 이름 결정 (--session 없으면 자동)
# 3) herdr 세션 시작 (claude 는 wrapper 우선, agy 는 인라인)
# 4) pane 메타 캡처 (pid, cmd, cwd)
# 5) agent-sessions.yaml 에 tmux_sessions[] 엔트리 append
# 5) agent-sessions.yaml 에 herdr_sessions[] 엔트리 append
# 6) 검증 출력
#
# Exit codes:
# 0 = success
# 1 = preflight failure
# 2 = invalid args
# 3 = tmux session already exists (use multi-agent-mux-resume or delete first)
# 3 = herdr session already exists (use multi-agent-mux-resume or delete first)
# 4 = agent-sessions.yaml append failure
set -euo pipefail
@@ -29,14 +29,14 @@ Options:
--workspace PATH project directory (required)
--agent AGENT claude | agy | hermes | cline (required)
--role ROLE assigned role (required)
--session NAME tmux session name (default: derived from workspace)
--session NAME herdr session name (default: derived from workspace)
--wrapper force use of ~/.local/bin/<session> wrapper even if not present
--dry-run print commands without executing
--tmux-server NAME specify isolated tmux server name
--herdr-server NAME specify isolated herdr server name
--submit-job PROMPT submit a job to multi-agent-mux-delegate-job registry with the given prompt
--onboard automatically submit a project alignment/orientation job to the new agent
--isolate provision an isolated per-session state home (.mam/agent_homes/<uuid>)
so same-CLI multi-role sessions never share a conversation id
--no-isolate disable state isolation (shares global configuration/history)
[default: isolated mode is always active]
-h, --help this help
EOF
}
@@ -47,10 +47,10 @@ ROLE=""
SESSION_NAME=""
USE_WRAPPER=0
DRY_RUN=0
TMUX_SERVER_OPT=""
HERDR_SERVER_OPT=""
SUBMIT_JOB_PROMPT=""
ONBOARD=0
ISOLATE=0
ISOLATE=1
while [ $# -gt 0 ]; do
case "$1" in
@@ -60,17 +60,18 @@ while [ $# -gt 0 ]; do
--session) SESSION_NAME="$2"; shift 2 ;;
--wrapper) USE_WRAPPER=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
--tmux-server) TMUX_SERVER_OPT="$2"; shift 2 ;;
--herdr-server) HERDR_SERVER_OPT="$2"; shift 2 ;;
--submit-job) SUBMIT_JOB_PROMPT="$2"; shift 2 ;;
--onboard) ONBOARD=1; shift ;;
--isolate) ISOLATE=1; shift ;;
--isolate) ISOLATE=1; shift ;; # legacy compatibility
--no-isolate) ISOLATE=0; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "ERROR: unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
if [ -n "$TMUX_SERVER_OPT" ]; then
export TMUX_SERVER_NAME="$TMUX_SERVER_OPT"
if [ -n "$HERDR_SERVER_OPT" ]; then
export HERDR_SERVER_NAME="$HERDR_SERVER_OPT"
fi
# Preflight
@@ -78,7 +79,7 @@ fi
[ -n "$AGENT" ] || { echo "ERROR: --agent required" >&2; usage; exit 2; }
[ -n "$ROLE" ] || { echo "ERROR: --role required" >&2; usage; exit 2; }
[ -d "$WORKSPACE" ] || { echo "ERROR: workspace $WORKSPACE not a directory" >&2; exit 1; }
command -v tmux >/dev/null || { echo "ERROR: tmux not installed" >&2; exit 1; }
command -v herdr >/dev/null || { echo "ERROR: herdr not installed" >&2; exit 1; }
command -v "$AGENT" >/dev/null || { echo "ERROR: $AGENT CLI not in PATH" >&2; exit 1; }
# Auth Check (OAuth check for agy, loggedIn check for claude, status for hermes)
@@ -88,7 +89,10 @@ if [ "$AGENT" = "claude" ]; then
exit 1
fi
elif [ "$AGENT" = "agy" ]; then
if ! agy models >/dev/null 2>&1; then
# Fast, non-blocking check: if token or credentials exist on disk, assume authenticated to prevent keyring hang
if [ -f "$HOME/.gemini/oauth_creds.json" ] || [ -f "$HOME/.gemini/antigravity-cli/antigravity-oauth-token" ]; then
true
elif ! agy models >/dev/null 2>&1; then
echo "ERROR: agy is not authenticated. Please log in first." >&2
exit 1
fi
@@ -110,8 +114,8 @@ if [ -z "$SESSION_NAME" ]; then
fi
# 이미 살아있으면 실패
if _tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "ERROR: tmux session '$SESSION_NAME' already exists. Use multi-agent-mux-resume to attach, or multi-agent-mux-stop first." >&2
if _herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "ERROR: herdr session '$SESSION_NAME' already exists. Use multi-agent-mux-resume to attach, or multi-agent-mux-stop first." >&2
exit 3
fi
@@ -135,7 +139,7 @@ if [ "$ISOLATE" = "1" ]; then
fi
fi
# tmux 세션 띄우기
# herdr 세션 띄우기
LOCAL_BIN="${LOCAL_BIN:-$HOME/.local/bin}"
WRAPPER="$LOCAL_BIN/$SESSION_NAME"
@@ -147,11 +151,29 @@ if [ -n "$ISOLATION_ROOT" ]; then
ISO_ENV_PREFIX="$(isolation_env_prefix "$AGENT" "$ISOLATION_ROOT")"
ISO_CMD_ARGS="$(isolation_cmd_args "$AGENT" "$ISOLATION_ROOT")"
fi
# Resolve absolute path of the agent command to prevent herdr PATH inheritance issues (especially on macOS)
RESOLVED_BIN="$AGENT"
if [ "$AGENT" = "cline" ]; then
if command -v cline >/dev/null 2>&1; then
RESOLVED_BIN="$(command -v cline)"
fi
else
if command -v "$AGENT" >/dev/null 2>&1; then
RESOLVED_BIN="$(command -v "$AGENT")"
fi
fi
# On macOS, clear quarantine attribute for the agent binary to prevent Gatekeeper hangs
if [ "$(uname)" = "Darwin" ] && [ -f "$RESOLVED_BIN" ]; then
xattr -d com.apple.quarantine "$RESOLVED_BIN" 2>/dev/null || true
fi
case "$AGENT" in
claude) CMD_FULL="${ISO_ENV_PREFIX}claude --dangerously-skip-permissions" ;;
agy) CMD_FULL="${ISO_ENV_PREFIX}agy --dangerously-skip-permissions" ;;
hermes) CMD_FULL="${ISO_ENV_PREFIX}hermes" ;;
cline) CMD_FULL="cline -i${ISO_CMD_ARGS:+ $ISO_CMD_ARGS}" ;;
claude) CMD_FULL="${ISO_ENV_PREFIX}${RESOLVED_BIN} --dangerously-skip-permissions" ;;
agy) CMD_FULL="${ISO_ENV_PREFIX}${RESOLVED_BIN} --dangerously-skip-permissions" ;;
hermes) CMD_FULL="${ISO_ENV_PREFIX}${RESOLVED_BIN}" ;;
cline) CMD_FULL="${RESOLVED_BIN} -i${ISO_CMD_ARGS:+ $ISO_CMD_ARGS}" ;;
esac
spawn() {
@@ -162,29 +184,29 @@ spawn() {
nohup "$WRAPPER" >/dev/null 2>&1 &
disown
else
_tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL"
_herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL"
fi
;;
agy|hermes|cline)
_tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL"
_herdr new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "$CMD_FULL"
;;
*) echo "ERROR: --agent must be claude, agy, hermes or cline, got: $AGENT" >&2; exit 2 ;;
esac
}
if [ "$DRY_RUN" = "1" ]; then
echo "[dry-run] would spawn: tmux session '$SESSION_NAME' in $WORKSPACE (agent=$AGENT)"
echo "[dry-run] would spawn: herdr session '$SESSION_NAME' in $WORKSPACE (agent=$AGENT)"
exit 0
fi
spawn
# Trap for rolling back/cleaning up tmux session if script exits due to error
cleanup_tmux_on_error() {
# Trap for rolling back/cleaning up herdr session if script exits due to error
cleanup_herdr_on_error() {
local exit_code=$?
if [ $exit_code -ne 0 ]; then
echo "⚠️ Error occurred during initialization. Rolling back and killing tmux session '$SESSION_NAME'..." >&2
_tmux kill-session -t "$SESSION_NAME" 2>/dev/null || true
echo "⚠️ Error occurred during initialization. Rolling back and killing herdr session '$SESSION_NAME'..." >&2
_herdr kill-session -t "$SESSION_NAME" 2>/dev/null || true
# T3 rollback: 이 세션용으로 프로비저닝한 격리 홈 제거 (경로 가드 후 rm)
if [ -n "$ISOLATION_ROOT" ] && [ -d "$ISOLATION_ROOT" ]; then
case "$ISOLATION_ROOT" in
@@ -193,7 +215,7 @@ cleanup_tmux_on_error() {
fi
fi
}
trap cleanup_tmux_on_error EXIT
trap cleanup_herdr_on_error EXIT
# TUI 준비 대기
if ! wait_for_tui_ready "$SESSION_NAME" "$AGENT"; then
@@ -202,30 +224,18 @@ if ! wait_for_tui_ready "$SESSION_NAME" "$AGENT"; then
fi
# pane 메타 캡처
PANE_PID=$(_tmux list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null || echo "")
PANE_CWD=$(_tmux list-panes -t "$SESSION_NAME" -F '#{pane_current_path}' 2>/dev/null || echo "$WORKSPACE")
PANE_CMD=$(_tmux list-panes -t "$SESSION_NAME" -F '#{pane_current_command}' 2>/dev/null || echo "$AGENT")
TMUX_EPOCH=$(date +%s)
PANE_PID=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null || echo "")
PANE_CWD=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_path}' 2>/dev/null || echo "$WORKSPACE")
PANE_CMD=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_current_command}' 2>/dev/null || echo "$AGENT")
HERDR_EPOCH=$(date +%s)
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
# 시작 명령 (CMD_FULL 은 spawn 전에 isolation 디스패치를 반영해 확정됨 — T4)
local_tmux="tmux"
if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then
local_tmux="tmux -L $TMUX_SERVER_NAME"
fi
case "$AGENT" in
claude)
if [ "$ISOLATE" != "1" ] && [ -x "$WRAPPER" ]; then
START_CMD="$WRAPPER # ~/.local/bin 의 래퍼"
else
START_CMD="$local_tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
fi
;;
agy|hermes|cline)
START_CMD="$local_tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
;;
esac
# NOTE: this must match what `spawn()` actually ran above — env-var-driven
# isolation (HERDR_SERVER_NAME picked up by the lib.sh shim), not a
# `--workspace <label>` flag (real `herdr agent start --workspace` wants an
# actual workspace id like "w2", which HERDR_SERVER_NAME is not).
START_CMD="HERDR_SERVER_NAME=${HERDR_SERVER_NAME:-default} herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
# If --onboard is specified, automatically build the onboarding prompt
if [ "$ONBOARD" = "1" ] && [ -z "$SUBMIT_JOB_PROMPT" ]; then
@@ -249,14 +259,14 @@ if [ -n "$SUBMIT_JOB_PROMPT" ]; then
else
delegate_agent="antigravity-cli"
fi
agent_session="tmux:$SESSION_NAME"
agent_session="herdr:$SESSION_NAME"
DELEGATE_JOB_ID=$(delegate_submit_job "$SUBMIT_JOB_PROMPT" "$delegate_agent" "$agent_session")
echo "Submitted delegated job: $DELEGATE_JOB_ID"
fi
if [ ! -f "$AGENT_SESSIONS_YAML" ]; then
mkdir -p "$(dirname "$AGENT_SESSIONS_YAML")"
echo "tmux_sessions: []" > "$AGENT_SESSIONS_YAML"
echo "herdr_sessions: []" > "$AGENT_SESSIONS_YAML"
fi
# atomic_dump_yaml: flock + temp+rename + .bak + schema validate (P0-B).
@@ -270,9 +280,9 @@ fi
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" NOW_ISO="$NOW_ISO" \
TMUX_EPOCH="$TMUX_EPOCH" PANE_PID="$PANE_PID" PANE_CWD="$PANE_CWD" \
HERDR_EPOCH="$HERDR_EPOCH" PANE_PID="$PANE_PID" PANE_CWD="$PANE_CWD" \
CMD_FULL="$CMD_FULL" START_CMD="$START_CMD" CHILD_PID="$CHILD_PID" \
TMUX_SERVER_NAME="${TMUX_SERVER_NAME:-default}" \
HERDR_SERVER_NAME="${HERDR_SERVER_NAME:-default}" \
DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" \
ISOLATION_UUID="$ISOLATION_UUID" ISOLATION_ROOT="$ISOLATION_ROOT" \
ISOLATION_LEVER="$ISOLATION_LEVER" ISOLATION_SEEDED="$ISOLATION_SEEDED" <<'PYEOF'
@@ -280,11 +290,11 @@ name = os.environ['SESSION_NAME']
agent = os.environ['AGENT']
role = os.environ['ROLE']
pid = os.environ.get('PANE_PID', '')
epoch = os.environ.get('TMUX_EPOCH', '')
server_name = os.environ.get('TMUX_SERVER_NAME', 'default')
epoch = os.environ.get('HERDR_EPOCH', '')
server_name = os.environ.get('HERDR_SERVER_NAME', 'default')
server_opt = f"-L {server_name} " if server_name and server_name != 'default' else ""
sessions = d.setdefault('tmux_sessions', [])
sessions = d.setdefault('herdr_sessions', [])
# P0-D: 같은 이름 엔트리가 status=running 이면만 거부. terminated/archived 는
# 재사용 가능 — 낡은 엔트리를 제거하고 새로 append (create -> delete -> create).
@@ -298,9 +308,9 @@ entry = {
'name': name,
'status': 'running',
'role': role,
'tmux_session_created_at': os.environ['NOW_ISO'],
'tmux_session_epoch': int(epoch) if epoch.isdigit() else 0,
'tmux_server': server_name,
'herdr_session_created_at': os.environ['NOW_ISO'],
'herdr_session_epoch': int(epoch) if epoch.isdigit() else 0,
'herdr_session': server_name,
'delegate_job_id': os.environ.get('DELEGATE_JOB_ID', '') or None,
'pane': {
'index': 0,
@@ -310,8 +320,13 @@ entry = {
'cwd': os.environ['PANE_CWD'],
},
'start_command': os.environ['START_CMD'],
'attach_command': f'tmux {server_opt}attach -t {name}',
'kill_command': f'tmux {server_opt}kill-session -t {name}',
# NOTE: `herdr session attach/stop/delete` operate on whole herdr
# *sessions* (server instances, e.g. "default") — NOT on an individual
# agent by its MAM name. Use the lib.sh tmux-compat shim commands
# instead (`source .agents/skills/lib.sh` first), scoped via the same
# env-var-driven isolation as start_command above.
'attach_command': f'HERDR_SERVER_NAME={server_name} herdr agent attach {name}',
'kill_command': f'HERDR_SERVER_NAME={server_name} herdr kill-session -t {name}',
}
# T5: isolation 블록 영속화 (all-L2) — resume/resolve/stop 이 재적용의 단일 소스로 사용
@@ -367,7 +382,7 @@ PYEOF
echo
echo "=== created ==="
echo "tmux session: $SESSION_NAME (pane pid $PANE_PID, cmd $PANE_CMD, cwd $PANE_CWD)"
echo "herdr session: $SESSION_NAME (pane pid $PANE_PID, cmd $PANE_CMD, cwd $PANE_CWD)"
if [ -n "$DELEGATE_JOB_ID" ]; then
echo "delegate job: $DELEGATE_JOB_ID"
@@ -383,7 +398,7 @@ On failure run: $pub --event error --detail '<one-line reason>'.
Task: $SUBMIT_JOB_PROMPT"
# Inject instructions into the tmux pane
# Inject instructions into the herdr pane
rc=0
inject_instructions "$SESSION_NAME" "$instructions" "$DELEGATE_JOB_ID" || rc=$?
if [ "$rc" -ne 0 ]; then
@@ -399,10 +414,6 @@ fi
trap - EXIT
echo "agent-sessions.yaml updated"
echo
if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then
echo "Attach: tmux -L $TMUX_SERVER_NAME attach -t $SESSION_NAME"
else
echo "Attach: tmux attach -t $SESSION_NAME"
fi
echo "Attach: herdr session attach $SESSION_NAME"
echo "Delete: use multi-agent-mux-stop skill"
echo "Resume: use multi-agent-mux-resume skill (after first message creates a session id)"
@@ -45,7 +45,7 @@ multi-agent-mux-delegate-job submit \
### 신규 옵션 상세:
* `--type`: 작업 위임 타입을 지정합니다. (`direct`, `loop`, `discuss`)
* `--reviewer`: 리뷰를 담당할 에이전트 이름입니다 (기본값: `hermes`).
* `--reviewer-session`: 리뷰어 에이전트가 돌고 있는 tmux 세션 이름입니다 (기본값: `tmux:hermes`).
* `--reviewer-session`: 리뷰어 에이전트가 돌고 있는 herdr 세션 이름입니다 (기본값: `herdr:hermes`).
* `--max-iterations`: 루프 또는 토론의 최대 반복 횟수입니다 (기본값: `5`).
---
@@ -73,10 +73,10 @@ stateDiagram-v2
### 단계별 상세 동작 프로토콜:
1. **작업자(Worker) 실행**:
* 오케스트레이터는 작업을 `pending`으로 등록하고, `agent_session`을 작업자 세션(예: `tmux:claude`)으로 설정하여 전달합니다.
* 오케스트레이터는 작업을 `pending`으로 등록하고, `agent_session`을 작업자 세션(예: `herdr:claude`)으로 설정하여 전달합니다.
* 작업자가 수행을 완료하고 `completed` 이벤트를 발행하면 오케스트레이터가 이를 가로챕니다.
2. **리뷰어(Reviewer)로 스위칭**:
* 오케스트레이터는 전체 작업을 종료하지 않고, 작업 레코드의 `agent_session`을 리뷰어 세션(예: `tmux:hermes`)으로 변경합니다.
* 오케스트레이터는 전체 작업을 종료하지 않고, 작업 레코드의 `agent_session`을 리뷰어 세션(예: `herdr:hermes`)으로 변경합니다.
* 리뷰어에게 전달할 프롬프트를 자동으로 조립합니다:
> *"Review the changes/artifacts generated for job $JOB_ID. Check if they meet the requirements. If correct, publish completed event with 'PASS'. If there are issues, publish error event with detailed feedback/nits."*
* 상태를 다시 `pending`으로 리셋하여 리뷰어 세션이 잡을 집어갈 수 있도록 합니다.
@@ -28,17 +28,18 @@ This skill allows any agent (`claude-code`, `hermes`, `agy`, `cline`, etc.) to p
The `multi-agent-mux-delegate-job` bash wrapper handles job registration, subscriber management, agent session targeting, and validation hooks:
```bash
# 1) Submit a new job to a targeted agent session (e.g. tmux session name 'demo')
# 1) Submit a new job to a targeted agent session (e.g. herdr session name 'demo')
multi-agent-mux-delegate-job submit \
--agent <claude-code|hermes-agent|agy-agent|cline-agent|human> \
--agent-session tmux:<session_name> \
--agent-session herdr:<session_name> \
--prompt "Task description or instructions here" \
--role <Worker|Planner|Reviewer> \
--timeout 3600 --idle-timeout 120
# 2) Submit a job with a feedback loop (Worker-Reviewer Loop)
multi-agent-mux-delegate-job submit \
--agent <worker_agent> --agent-session tmux:<worker_session> \
--type loop --reviewer <reviewer_agent> --reviewer-session tmux:<reviewer_session> \
--agent <worker_agent> --agent-session herdr:<worker_session> \
--type loop --reviewer <reviewer_agent> --reviewer-session herdr:<reviewer_session> \
--prompt "Task description"
# 3) Check job status and audit logs
@@ -91,4 +92,5 @@ Job lifecycle execution events are persistently mirrored to an append-only log u
- **Subscribe-Before-Publish**: The subscriber must be running before the agent starts publishing. The `submit` command handles this automatically by launching the subscriber in the background first.
- **Fresh job_id Propagation**: Make sure the worker agent receives the correct `JOB_ID` generated for the current run, rather than reusing stale IDs from previous sessions.
- **Brief delivery via file path**: For long or complex prompts, write the instructions to a file (e.g. `/tmp/task-brief.md`) and pass a short prompt pointing to the file path to prevent terminal buffer overflows.
- **Prompts injected into a live agent session MUST be English, ASCII-only, and short** — this is exactly what `--prompt`/the `instructions` string sent to `run_agent()` end up as. Any Korean (or other non-ASCII) content the task needs to convey must go in a markdown brief file (e.g. `.mam/jobs/<id>/brief.md`, written in Korean is fine) that the injected prompt merely tells the agent to read. Two independent bugs in `send_keys_safe`'s paste-verification (in `lib.sh`) made this matter in practice: (a) its marker was taken with a byte-based `tail -c 24`, which can slice a multi-byte UTF-8 (e.g. Korean) character in half; (b) the rendered pane soft-wraps long lines at the terminal width, which can split the marker across two visual lines. Both are now fixed at the source (character-safe truncation + newline-stripped matching before comparison), but keeping injected prompts short/English/file-referencing is still the cheapest way to avoid ever exercising this edge case at all — it's also simply what `submit`'s own default instruction template already does (see `Core Commands` above).
- **Batch Grouping**: Group non-overlapping tasks into batches to parallelize execution across multiple agent sessions, reducing overhead.
@@ -11,7 +11,7 @@
#
# This is a reference wrapper: it shells out to the python scripts that live
# next to it. Copy it into your project and customise as needed. It never hard
# fails if `claude`/`codex`/`tmux` are missing — it prints what it would run.
# fails if `claude`/`codex`/`herdr` are missing — it prints what it would run.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -23,11 +23,20 @@ elif [[ -f "$SCRIPT_DIR/../../.env" ]]; then
set -a; source "$SCRIPT_DIR/../../.env"; set +a
fi
# Source EARLY (before any herdr usage in run_agent) — this is what turns
# plain `herdr` into the tmux-compat shim (herdr() function) and provides
# resolve_herdr_workspace/send_keys_safe. Sourcing it late meant the
# has-session pre-flight check below used to hit the real herdr binary with
# a nonexistent subcommand and always fail.
source "$SCRIPT_DIR/../lib.sh"
# Pick an interpreter: prefer a project .venv, else python3.
pick_python() {
local py_bin
if [[ -n "${DELEGATE_JOB_PYTHON:-}" ]]; then
py_bin="$DELEGATE_JOB_PYTHON"
elif [[ -n "${AGENT_PYTHON_BIN:-}" ]] && [[ -x "$AGENT_PYTHON_BIN" ]]; then
py_bin="$AGENT_PYTHON_BIN"
elif [[ -x "${WORKDIR:-.}/.venv/bin/python" ]]; then
py_bin="${WORKDIR}/.venv/bin/python"
elif [[ -x ".venv/bin/python" ]]; then
@@ -52,10 +61,11 @@ multi-agent-mux-delegate-job <command> [options]
submit --agent <name> --prompt <text> [--workdir <dir>] [--agent-session <label>]
[--timeout <sec>] [--idle-timeout <sec>] [--validate <script>]
[--registry-dir <dir>] [--dry-run]
[--registry-dir <dir>] [--dry-run] [--role <role_name>]
[--type <direct|loop|discuss>] [--reviewer <reviewer_agent>]
[--reviewer-session <reviewer_session>] [--max-iterations <count>]
# The skill is tmux-interactive only; --mode print was removed.
[--counterpart-role <role_name>] [--strict-role-check]
# The skill is herdr-interactive only; --mode print was removed.
status --job <id> [--registry-dir <dir>]
list [--registry-dir <dir>]
verify --job <id> --validate <script> [--registry-dir <dir>]
@@ -65,10 +75,15 @@ EOF
}
# ---- arg parsing helpers --------------------------------------------------
AGENT="claude-code"; PROMPT=""; WORKDIR="$(pwd)"; AGENT_SESSION="tmux:claude"
AGENT="claude-code"; PROMPT=""; WORKDIR="$(pwd)"; AGENT_SESSION="herdr:claude"
TIMEOUT=3600; IDLE_TIMEOUT=120; VALIDATE=""; DRY_RUN=0
JOB_ID=""; REGISTRY_DIR="$REGISTRY_DIR_DEFAULT"
TYPE="direct"; REVIEWER="hermes"; REVIEWER_SESSION="tmux:hermes"; MAX_ITERATIONS=5
JOB_ID=""; REGISTRY_DIR="$REGISTRY_DIR_DEFAULT"; DELEGATE_ROLE="Worker"
TYPE="direct"; REVIEWER="hermes"; REVIEWER_SESSION="herdr:hermes"; MAX_ITERATIONS=5
DEFAULT_COUNTERPART_ROLE="Reviewer"
COUNTERPART_ROLE="$DEFAULT_COUNTERPART_ROLE"
STRICT_ROLE_CHECK=0
ROLE_ALIASES_JSON='{"worker": ["worker", "creator"], "planner": ["planner"], "reviewer": ["reviewer"]}'
COUNTERPART_ROLE_EXPLICIT=0
parse_opts() {
while [[ $# -gt 0 ]]; do
@@ -83,10 +98,13 @@ parse_opts() {
--job) JOB_ID="$2"; shift 2;;
--registry-dir) REGISTRY_DIR="$2"; shift 2;;
--dry-run) DRY_RUN=1; shift;;
--role) DELEGATE_ROLE="$2"; shift 2;;
--type) TYPE="$2"; shift 2;;
--reviewer) REVIEWER="$2"; shift 2;;
--reviewer-session) REVIEWER_SESSION="$2"; shift 2;;
--max-iterations) MAX_ITERATIONS="$2"; shift 2;;
--counterpart-role) COUNTERPART_ROLE="$2"; COUNTERPART_ROLE_EXPLICIT=1; shift 2;;
--strict-role-check) STRICT_ROLE_CHECK=1; shift;;
*) echo "unknown option: $1" >&2; usage; exit 1;;
esac
done
@@ -101,12 +119,44 @@ cmd_submit() {
# 1) register job (prints the new job id)
JOB_ID="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" register \
--prompt "$PROMPT" --agent "$AGENT" --agent-session "$AGENT_SESSION" \
--prompt "$PROMPT" --agent "$AGENT" --agent-session "$AGENT_SESSION" --role "$DELEGATE_ROLE" \
--timeout "$TIMEOUT" --idle-timeout "$IDLE_TIMEOUT" \
--job-type "$TYPE" --reviewer "$REVIEWER" --reviewer-session "$REVIEWER_SESSION" \
--max-iterations "$MAX_ITERATIONS")"
echo "registered job: $JOB_ID"
# 1-1) Provision job directory and write direct brief.md (MAM Job Restructuring)
local job_dir="$REGISTRY_DIR/$JOB_ID"
if [[ "$DRY_RUN" != "1" ]]; then
mkdir -p "$job_dir"
cat <<EOF > "$job_dir/brief.md"
# 📋 Brief: Job $JOB_ID Delegation
- **Job ID**: $JOB_ID
- **Target Agent**: $AGENT (session: $AGENT_SESSION)
- **Role**: $DELEGATE_ROLE
- **Timeout**: $TIMEOUT s (Idle: $IDLE_TIMEOUT s)
- **Output Report Path**: .mam/jobs/$JOB_ID/$AGENT-reports/report-final.md
## 🔔 Execution Commands
On start run:
python3 .agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py --registry-dir .mam/jobs --job $JOB_ID --event started
On progress (optional):
python3 .agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py --registry-dir .mam/jobs --job $JOB_ID --event progress --detail '<short status>'
On success run:
python3 .agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py --registry-dir .mam/jobs --job $JOB_ID --event completed --detail '<one-line summary>'
On failure run:
python3 .agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py --registry-dir .mam/jobs --job $JOB_ID --event error --detail '<one-line reason>'
## 🔎 Task Description
$PROMPT
EOF
echo "provisioned job directory: $job_dir"
fi
if [[ "$TYPE" == "direct" ]]; then
# 2) START THE SUBSCRIBER FIRST (ordering dependency — MQTT does not queue
# non-retained messages for absent subscribers).
@@ -125,8 +175,15 @@ cmd_submit() {
break
fi
else
echo "ERROR: subscriber died early (pid=$sub_pid, check $logf)" >&2
exit 1
wait "$sub_pid" 2>/dev/null
local sub_exit=$?
if [ $sub_exit -eq 0 ]; then
sub_ready=1
break
else
echo "ERROR: subscriber died early (pid=$sub_pid, exit=$sub_exit, check $logf)" >&2
exit 1
fi
fi
sleep 0.2
done
@@ -140,17 +197,8 @@ cmd_submit() {
# an id from an earlier session is the #1 reason a delegated job sits idle and
# times out (see SKILL.md "Wrong job_id propagated to the agent"). We make the
# freshness explicit in the instruction header.
local instructions="Your job_id is \"$JOB_ID\" (the one just registered for THIS delegation — read it from the registry record, do NOT reuse any job_id you saw in earlier runs).
On start run: $pub --event started.
On permission/tool prompt run: $pub --event permission_required --detail '<tool>:<what>'.
On progress (optional): $pub --event progress --detail '<short status>'.
On success run: $pub --event completed --detail '<one-line summary>'.
On failure run: $pub --event error --detail '<one-line reason>'.
The subscriber for this job_id is already running; your completed/error event ends the job. Exit codes: 0 completed, 1 error, 2 publish failure.
Task: $PROMPT"
# Keep this short and ASCII-only to prevent paste/wrap rendering issues in CLI REPLs.
local instructions="Job $JOB_ID: Read .mam/jobs/$JOB_ID/brief.md and complete the task."
run_agent "$JOB_ID" "$instructions"
@@ -187,7 +235,8 @@ Task: $PROMPT"
local iteration=1
local current_prompt="$PROMPT"
local current_session="$AGENT_SESSION"
local current_role="worker"
local _phase="worker"
local display_role="$DELEGATE_ROLE"
if [[ "$DRY_RUN" == "1" ]]; then
echo "[dry-run] orchestrator loop would start for job: $JOB_ID type: $TYPE"
@@ -199,20 +248,41 @@ Task: $PROMPT"
while true; do
echo "=================================================="
echo "Iteration $iteration - Role: $current_role"
echo "Iteration $iteration - Role: $display_role"
echo "Session: $current_session"
echo "=================================================="
# 1-1) Provision job directory and write iteration brief.md (MAM Job Restructuring)
local job_dir="$REGISTRY_DIR/$JOB_ID"
local clean_session="${current_session#herdr:}"
if [[ "$DRY_RUN" != "1" ]]; then
mkdir -p "$job_dir"
cat <<EOF > "$job_dir/brief.md"
# 📋 Brief: Job $JOB_ID Delegation (Iteration $iteration)
- **Job ID**: $JOB_ID
- **Target Agent/Session**: $current_session
- **Role**: $display_role
- **Iteration**: $iteration
- **Output Report Path**: .mam/jobs/$JOB_ID/${clean_session}-reports/report-final.md
## 🔎 Task Description
$current_prompt
EOF
echo "provisioned iteration brief: $job_dir/brief.md"
fi
# Update job details in registry
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" update \
--job "$JOB_ID" \
--agent-session "$current_session" \
--prompt "$current_prompt" \
--iteration "$iteration" \
--role "$display_role" \
--status "pending"
# Start subscriber
local logf="$REGISTRY_DIR/${JOB_ID}.iter_${iteration}_${current_role}.subscriber.out"
local logf="$REGISTRY_DIR/${JOB_ID}.iter_${iteration}_${display_role}.subscriber.out"
"$PY" "$SCRIPT_DIR/scripts/job_subscriber.py" --registry-dir "$REGISTRY_DIR" \
--job "$JOB_ID" --timeout "$TIMEOUT" --idle-timeout "$IDLE_TIMEOUT" \
>"$logf" 2>&1 &
@@ -227,8 +297,15 @@ Task: $PROMPT"
break
fi
else
echo "ERROR: subscriber died early (pid=$sub_pid, check $logf)" >&2
exit 1
wait "$sub_pid" 2>/dev/null
local sub_exit=$?
if [ $sub_exit -eq 0 ]; then
sub_ready=1
break
else
echo "ERROR: subscriber died early (pid=$sub_pid, exit=$sub_exit, check $logf)" >&2
exit 1
fi
fi
sleep 0.2
done
@@ -238,20 +315,15 @@ Task: $PROMPT"
# Format instruction block
local pub="$PY $SCRIPT_DIR/scripts/publish_event.py --registry-dir $REGISTRY_DIR --job $JOB_ID"
local instructions="Your job_id is \"$JOB_ID\" (the one just registered for THIS delegation — read it from the registry record, do NOT reuse any job_id you saw in earlier runs).
On start run: $pub --event started.
On permission/tool prompt run: $pub --event permission_required --detail '<tool>:<what>'.
On progress (optional): $pub --event progress --detail '<short status>'.
On success run: $pub --event completed --detail '<one-line summary>'.
On failure run: $pub --event error --detail '<one-line reason>'.
The subscriber for this job_id is already running; your completed/error event ends the job. Exit codes: 0 completed, 1 error, 2 publish failure.
Task: $current_prompt"
local instructions="Your job_id is \"$JOB_ID\". Detailed task requirements, instructions, and target output paths for iteration $iteration are documented in the task brief file at: .mam/jobs/$JOB_ID/brief.md. Please READ and follow .mam/jobs/$JOB_ID/brief.md to complete your work. Commands: start='$pub --event started', success='$pub --event completed --detail <summary>', error='$pub --event error --detail <reason>'."
# Trigger agent
run_agent "$JOB_ID" "$instructions" "$current_session"
local force_warn_only=0
if [[ "$_phase" == "reviewer" && "$COUNTERPART_ROLE_EXPLICIT" -eq 1 \
&& "${COUNTERPART_ROLE,,}" != "${DEFAULT_COUNTERPART_ROLE,,}" ]]; then
force_warn_only=1
fi
run_agent "$JOB_ID" "$instructions" "$current_session" "$force_warn_only"
# Wait for subscriber
local sub_rc=0
@@ -268,21 +340,22 @@ Task: $current_prompt"
job_status="timeout"
fi
echo "Job role $current_role finished with status: $job_status"
echo "Job role $display_role finished with status: $job_status"
# Retrieve feedback from the last event
local feedback
feedback="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" get-feedback --job "$JOB_ID")"
echo "Feedback/Detail: $feedback"
if [[ "$current_role" == "worker" ]]; then
if [[ "$_phase" == "worker" ]]; then
if [[ "$job_status" != "completed" ]]; then
echo "Worker did not complete successfully (status: $job_status). Terminating workflow."
break
fi
# Worker completed successfully, now switch to reviewer
current_role="reviewer"
_phase="reviewer"
display_role="$COUNTERPART_ROLE"
current_session="$REVIEWER_SESSION"
# Build reviewer prompt based on type
if [[ "$TYPE" == "loop" ]]; then
@@ -321,7 +394,8 @@ Task: $current_prompt"
fi
iteration=$((iteration + 1))
current_role="worker"
_phase="worker"
display_role="$DELEGATE_ROLE"
current_session="$AGENT_SESSION"
current_prompt="The reviewer provided the following feedback for job $JOB_ID: $feedback. Please modify the code/artifacts to address these comments. CRITICAL: As the Developer Team Leader, you must thoroughly review the suggested modifications, verify their validity, adopt/implement them if valid, and if you judge any recommendation to be invalid, do NOT implement it but instead explain your reasons clearly in your response and send it back to the reviewer (수정안을 최대한 꼼꼼히 검토하여 타당성을 검증하고, 타당하다면 수렴하여 수정을 진행하되, 타당하지 않다고 판단되는 부분이 있다면 그 이유를 명확히 밝혀 리뷰어에게 전달하십시오)."
fi
@@ -346,61 +420,111 @@ Task: $current_prompt"
}
run_agent() {
local job_id="$1"; local instructions="$2"; local target_session="${3:-$AGENT_SESSION}"
local job_id="$1"; local instructions="$2"; local target_session="${3:-$AGENT_SESSION}"; local force_warn_only="${4:-0}"
# The skill is INTERACTIVE-ONLY. We never invoke `claude -p` or any other
# one-shot print mode, because:
# - claude -p exits the moment stdin is drained, so there's nothing to
# `tmux attach` to afterwards.
# `herdr session attach` to afterwards.
# - fire-and-forget via wrapper defeats the whole point of the audit log
# (you can't tell what happened if the agent crashes mid-turn).
# - the job registry already gives us an authoritative completion signal,
# so we don't need a wrapper-side exit code to know "done".
# The user attaches with `tmux attach -t <session>` and types follow-up
# The user attaches with `herdr session attach <session>` and types follow-up
# prompts themselves. We pre-load the first prompt via stdin and `read`
# keeps the pane open after the agent exits so the user can review.
if [ "$AGENT" = "human" ]; then
echo "[human agent] complete the task, then run publish_event.py --event completed"
return
fi
local sess="${target_session#tmux:}"
local sess="${target_session#herdr:}"
if [[ "$DRY_RUN" == "1" ]]; then
echo "[dry-run] would delegate task to running agent '$AGENT' in tmux session '$sess' with instructions:"
echo "[dry-run] would delegate task to running agent '$AGENT' in herdr session '$sess' with instructions:"
echo "----"; echo "$instructions"; echo "----"
return
fi
if ! command -v tmux >/dev/null 2>&1; then
echo "ERROR: this skill requires tmux (interactive agent sessions)." >&2
echo " Install with: brew install tmux (or your package manager)" >&2
if ! command -v herdr >/dev/null 2>&1; then
echo "ERROR: this skill requires herdr (interactive agent sessions)." >&2
echo " Ensure herdr is installed and executable." >&2
return 1
fi
local _tmux="tmux"
if [ -n "${TMUX_SERVER_NAME:-}" ]; then
_tmux="tmux -L $TMUX_SERVER_NAME"
fi
# Auto-resolve isolation the same way resume/stop/create do — don't rely on
# the caller having exported HERDR_SERVER_NAME by hand. This is what lets
# delegation reach an agent living in an isolated herdr session (e.g. one
# created with --herdr-server) instead of silently looking in "default".
export HERDR_SERVER_NAME="$(resolve_herdr_session "$sess")"
if ! $_tmux has-session -t "$sess" 2>/dev/null; then
if ! herdr has-session -t "$sess" 2>/dev/null; then
echo "ERROR: 에이전트 세션 '$sess'이 존재하지 않습니다. 작업을 위임하기 전에 먼저 에이전트 세션을 기동해 주세요." >&2
echo " 팁: 'multi-agent-mux-resume' 또는 'multi-agent-mux-create'를 통해 에이전트를 먼저 생성할 수 있습니다." >&2
return 1
fi
# Check role suitability
local sess_role job_role
sess_role=$(SESS_NAME="$sess" MAM_STATE_JSON="$(load_state_json)" "$PY" -c "
import os, json
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
name = os.environ.get('SESS_NAME')
for s in d.get('herdr_sessions', []):
if s.get('name') == name:
print(s.get('role', ''))
break
" 2>/dev/null || echo "")
job_role=$("$PY" -c "
import json
try:
with open('$REGISTRY_DIR/$job_id.json') as f:
print(json.load(f).get('role', ''))
except Exception:
pass
" 2>/dev/null || echo "")
if [[ -n "$job_role" && -n "$sess_role" ]]; then
local check_result
check_result=$(JOB_ROLE="$job_role" SESS_ROLE="$sess_role" ROLE_ALIASES_JSON="$ROLE_ALIASES_JSON" "$PY" -c "
import os, json
job = os.environ.get('JOB_ROLE', '').lower()
sess = os.environ.get('SESS_ROLE', '').lower()
aliases = json.loads(os.environ.get('ROLE_ALIASES_JSON', '{}'))
candidates = aliases.get(job, [job])
if any(c in sess for c in candidates):
print('OK')
else:
print('MISMATCH')
" 2>/dev/null || echo "OK")
if [[ "$check_result" == "MISMATCH" ]]; then
local mismatch_msg="Target session '$sess' has role '$sess_role' which does not match job role '$job_role'."
if [[ "$STRICT_ROLE_CHECK" -eq 1 && "$force_warn_only" -ne 1 ]]; then
echo "ERROR: role suitability mismatch. $mismatch_msg" >&2
return 1
else
echo "WARNING: $mismatch_msg" >&2
fi
fi
fi
# Before launching the agent, set up error trap to publish error event
if [ -n "${job_id:-}" ] && [ -n "${PY:-}" ]; then
local pub_script="$SCRIPT_DIR/scripts/publish_event.py"
trap 'rc=$?; if [ $rc -ne 0 ]; then "$PY" "$pub_script" --job "$job_id" --event error --detail "agent bootstrap failed (exit $rc)"; fi' EXIT
pub_script="$SCRIPT_DIR/scripts/publish_event.py"
trap "rc=\$?; if [ \$rc -ne 0 ]; then \"$PY\" \"$pub_script\" --job '$job_id' --event error --detail 'agent bootstrap failed (exit '\$rc')'; fi" EXIT
fi
echo "살아있는 에이전트 세션 '$sess'에 작업을 위임합니다..."
source "$SCRIPT_DIR/../lib.sh"
if ! send_keys_safe "$sess" "$instructions" "$job_id"; then
echo "ERROR: 프롬프트 주입 실패 — 세션 '$sess' (프롬프트 잠금 의심)" >&2
return 1
fi
echo "작업이 세션 '$sess'에 전송되었습니다. (연결하려면: $_tmux attach -t $sess)"
# NOTE: `herdr session attach` operates on whole herdr *sessions* (server
# instances), not an individual agent by its MAM name — `agent attach` is
# the real command for that. HERDR_SERVER_NAME is inlined so the printed
# command is copy-pasteable in a fresh shell that hasn't sourced lib.sh.
echo "작업이 세션 '$sess'에 전송되었습니다. (연결하려면: HERDR_SERVER_NAME=$HERDR_SERVER_NAME herdr agent attach $sess — lib.sh를 source한 셸에서 실행)"
trap - EXIT
}
@@ -2,7 +2,7 @@
The registry is the **single source of truth** for delegated work. Job metadata
(id, prompt, broker, status, timeouts) lives in files, **not** environment
variables — so one tmux session can handle many jobs sequentially or in
variables — so one herdr session can handle many jobs sequentially or in
parallel without collisions, and `publish_event.py` / `job_subscriber.py` can
reconstruct everything they need from the registry alone.
@@ -37,7 +37,7 @@ Reference implementation: [`./scripts/registry.py`](./scripts/registry.py)
"updated_at": "2026-06-19T09:32:00Z",
"prompt": "정렬 문제 10개를 만들어 sort_problems.md로 저장…",
"agent": "claude-code",
"agent_session": "tmux:claude",
"agent_session": "herdr:claude",
"broker": {
"host": "broker.hivemq.com",
"port": 1883,
@@ -69,7 +69,7 @@ Reference implementation: [`./scripts/registry.py`](./scripts/registry.py)
Every read-modify-write (`register_job`, `pick_pending`, `update_status`,
`next_seq`) runs inside `registry_lock(registry_dir)`, an exclusive
`fcntl.flock` over `.lock`. Single-host, good enough for many tmux sessions on
`fcntl.flock` over `.lock`. Single-host, good enough for many herdr sessions on
one machine.
### Production — SQLite WAL
@@ -83,8 +83,8 @@ signatures stay identical; only the storage backend changes.
## 4. How multiple sessions take only their own work
Each tmux session carries an `agent_session` label (`tmux:claude`,
`tmux:claude-a`, `tmux:claude-b`, …). `pick_pending(agent_session)`:
Each herdr session carries an `agent_session` label (`herdr:claude`,
`herdr:claude-a`, `herdr:claude-b`, …). `pick_pending(agent_session)`:
1. acquires the registry lock,
2. scans for the **oldest** record with `status == "pending"` **and**
@@ -99,7 +99,7 @@ the job already `running` and moves on.
```bash
# session A only ever runs its own pending jobs
PY scripts/registry.py pick --agent-session tmux:claude-a # prints id or exits 3
PY scripts/registry.py pick --agent-session herdr:claude-a # prints id or exits 3
```
---
@@ -127,12 +127,12 @@ SQLite transaction when you migrate.
```bash
PY=.venv/bin/python
$PY scripts/registry.py register --prompt "…" --agent claude-code \
--agent-session tmux:claude --timeout 3600 --idle-timeout 120 # → prints job_id
--agent-session herdr:claude --timeout 3600 --idle-timeout 120 # → prints job_id
$PY scripts/registry.py list # human table
$PY scripts/registry.py list --json # full records
$PY scripts/registry.py get --job <id> # one record
$PY scripts/registry.py status --job <id> --set completed # set status
$PY scripts/registry.py pick --agent-session tmux:claude # claim → running
$PY scripts/registry.py pick --agent-session herdr:claude # claim → running
```
Exit codes: `0` ok, `1` not found / bad status, `3` (`pick`) no pending job for
@@ -153,7 +153,11 @@ def main(argv=None) -> int:
expected_ids: Set[str] = {j["job_id"] for j in jobs}
tokens = {j["job_id"]: j.get("auth_token") for j in jobs}
seqs = {j["job_id"]: int(j.get("last_seq", 0)) for j in jobs}
seqs = {}
for j in jobs:
jid = j["job_id"]
last_seq = int(j.get("last_seq", 0))
seqs[jid] = max(0, last_seq - 1)
watcher = _Watcher(expected_ids, tokens, seqs)
# Resolve timeouts from CLI, falling back to the (first) job's settings.
@@ -266,7 +266,7 @@ def _lock_path(registry_dir: str) -> Path:
def registry_lock(registry_dir: str):
"""Advisory exclusive lock over the whole registry dir via fcntl.
PoC-grade single-host concurrency control. Multiple tmux sessions / scripts
PoC-grade single-host concurrency control. Multiple herdr sessions / scripts
serialise their read-modify-write of job records through this lock so two
sessions never claim the same pending job. For multi-host delegation move
to SQLite WAL (see references/registry.md)."""
@@ -16,7 +16,7 @@ Exit codes:
Usage:
publish_event.py --job <id> --event started [--detail "..."] [--data '{...}']
publish_event.py --pick-pending --agent-session tmux:claude --event completed
publish_event.py --pick-pending --agent-session herdr:claude --event completed
publish_event.py --job <id> --event completed --retained
"""
from __future__ import annotations
@@ -135,7 +135,7 @@ def main(argv=None) -> int:
target.add_argument("--job", help="job id to publish for")
target.add_argument("--pick-pending", action="store_true",
help="auto-select a pending job for --agent-session")
parser.add_argument("--agent-session", default="tmux:claude",
parser.add_argument("--agent-session", default="herdr:claude",
help="session label used with --pick-pending")
parser.add_argument("--event", default="progress", choices=VALID_EVENTS)
parser.add_argument("--detail", default="")
@@ -50,7 +50,8 @@ def generate_job_id(bits: int = 32) -> str:
def register_job(
prompt: str,
agent: str = "claude-code",
agent_session: str = "tmux:claude",
agent_session: str = "herdr:claude",
role: str = "Worker",
broker: Optional[Dict[str, Any]] = None,
timeout_sec: int = 3600,
idle_timeout_sec: int = 120,
@@ -87,6 +88,7 @@ def register_job(
"prompt": prompt,
"agent": agent,
"agent_session": agent_session,
"role": role,
"broker": broker,
"topic_prefix": topic_prefix_for(job_id),
"timeout_sec": int(timeout_sec),
@@ -114,7 +116,7 @@ def register_job(
def pick_pending(agent_session: str, registry_dir: str = DEFAULT_REGISTRY_DIR) -> Optional[str]:
"""Claim the oldest ``pending`` job for ``agent_session``, flipping it to
``running`` atomically under the lock. Returns the job id, or None if no
pending job matches. This is how each tmux session takes only its own work
pending job matches. This is how each herdr session takes only its own work
without two sessions grabbing the same job."""
with registry_lock(registry_dir):
candidates = []
@@ -238,7 +240,8 @@ def _build_parser() -> argparse.ArgumentParser:
p_reg = sub.add_parser("register", help="create a pending job; prints the job id")
p_reg.add_argument("--prompt", required=True)
p_reg.add_argument("--agent", default="claude-code")
p_reg.add_argument("--agent-session", default="tmux:claude")
p_reg.add_argument("--agent-session", default="herdr:claude")
p_reg.add_argument("--role", default="Worker", help="logical role for the delegated agent (e.g. Worker, Planner, Reviewer)")
p_reg.add_argument("--timeout", type=int, default=3600)
p_reg.add_argument("--idle-timeout", type=int, default=120)
p_reg.add_argument("--bits", type=int, default=32, help="32 (PoC) or 128 (prod)")
@@ -266,12 +269,13 @@ def _build_parser() -> argparse.ArgumentParser:
p_update.add_argument("--agent-session", default=None)
p_update.add_argument("--prompt", default=None)
p_update.add_argument("--iteration", type=int, default=None)
p_update.add_argument("--role", default=None)
p_feedback = sub.add_parser("get-feedback", help="get the last feedback detail (completed/error) for a job")
p_feedback.add_argument("--job", required=True)
p_pick = sub.add_parser("pick", help="claim a pending job for a session; prints id")
p_pick.add_argument("--agent-session", default="tmux:claude")
p_pick.add_argument("--agent-session", default="herdr:claude")
p_logs = sub.add_parser(
"logs",
@@ -302,6 +306,7 @@ def main(argv: Optional[List[str]] = None) -> int:
prompt=args.prompt,
agent=args.agent,
agent_session=args.agent_session,
role=args.role,
timeout_sec=args.timeout,
idle_timeout_sec=args.idle_timeout,
registry_dir=rd,
@@ -354,6 +359,8 @@ def main(argv: Optional[List[str]] = None) -> int:
fields["prompt"] = args.prompt
if args.iteration is not None:
fields["iteration"] = args.iteration
if args.role is not None:
fields["role"] = args.role
try:
mqtt_common.update_job_status(args.job, rd, **fields)
except FileNotFoundError as exc:
@@ -0,0 +1,191 @@
# Multi-Agent Mux Loop — Autonomous Orchestration Loop
> **Companion skills**: `multi-agent-mux-create` (start), `multi-agent-mux-resume` (re-attach), `multi-agent-mux-delegate-job` (delegate).
> **Safety Guard**: `--max-loop` and `--plan-talk` restrict API cost runaways.
> **Single source of truth**: `./.mam/agent-sessions.yaml`.
수동 템플릿 작성 및 수동 프롬프트 환류는 폐지되었습니다. Planner, Creator, Reviewer 간의 모든 협업 피드백 루프는 본 스킬(`run_loop.sh`)만을 단독으로 사용하여 자동으로 오케스트레이션합니다.
## What this skill does
Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. It supports:
- **Collaborative Planning** (`--plan` and `--plan-talk N`): Planner designs the solution, Creator challenges the plan for N turns to resolve edge cases, then implementation starts.
- **Creator Self-Planning & Development** (default without `--plan`): Planner 에이전트에게 계획 작성을 위임하지 않고, 기존에 승격된 계획서가 있다면 이를 로드하여 코드를 구현하며, 계획서가 존재하지 않는 경우 작업자(Creator: developer/writer)가 스스로 구현 계획 및 설계 수립을 포함한 개발 전 과정을 직접 진행합니다.
- **Targeted Peer-Review** (`--reviewer`): Runs custom-selected reviewer agents to verify code changes.
- **Total Peer-Review** (`--all-reviewer`): Enforces a unanimous PASS verdict from all registered reviewer sessions.
- **Self-Review** (default): Creator verifies its code changes autonomously without peer reviews.
- **Safety Limits** (`--max-loop N`): Aborts execution if reviews fail to PASS after N iterations.
---
## Roles & Responsibilities
협업 시스템은 각 에이전트의 책임 영역을 명확히 격리하여 상호 교차 검증을 강제합니다.
```
┌──────────────────────┐
│ User Prompt │
└──────────┬───────────┘
┌──────────────────────┐
│ 1. Planner Agent │ ◄──────────────────┐
│ - Plan & Checklist │ │
└──────────┬───────────┘ │
▼ │
┌──────────────────────┐ │
│ 2. Creator Agent │ │
│ - Code & DoD Verify │ │
└──────────┬───────────┘ │
▼ │ (NOT PASS Feedback)
┌──────────────────────┐ │
│ 3. Reviewer Agents │ │
│ - Dual Peer Review │ ───────────────────┘
└──────────┬───────────┘
▼ (PASS)
┌──────────────────────┐
│ 4. Done & Standby │
└──────────────────────┘
```
### Planner (설계 및 통제)
- **목적**: 요구사항을 명세화하고, 구현 단계의 설계 결함이나 모순(Contradiction)을 사전에 차단합니다.
- **역할**:
- 사용자 요구사항에 따른 구현 목표 및 범위 수립.
- `implementation_plan.md``task.md` (체크리스트) 작성 및 버전 관리(Rev.1, Rev.2, ...).
- 리뷰어 피드백 발생 시 설계 변경의 파급 범위를 계산하여 계획 갱신.
- **핵심 원칙**: 직접 코드를 수정하지 않고 오직 설계와 체크리스트 자산만 관리합니다.
### Creator (구현 및 자가 검증)
- **목적**: Planner가 제공한 체크리스트를 기반으로 실제 리포지토리 코드를 물리적으로 수정 및 구현합니다.
- **역할**:
- `task.md`를 순차적으로 완료 상태(`[x]`)로 업데이트하며 구현 수행.
- 커밋 전 **Definition of Done (DoD)** 체크리스트를 자체 실행하여 금지된 코드 패턴, 메모리/구조적 사이드 이펙트 유무 자가 검토.
- 수정 사항을 단일 원자적(Atomic) 커밋으로 마감하고 리뷰어에게 전달.
### Reviewer Agents (교차 피드백 및 검증)
- **목적**: 구현된 결과물이 최초 설계서 및 제약 요건에 일치하는지 제3자의 관점에서 엄격하게 검토합니다.
- **역할**:
- 상위 논리적 정합성(설계 주장과 구현 간 모순 여부) 검증.
- 전이 조건, 예외 처리, 타입 시그니처 등 하위 레벨 구현의 세부 사항 기계적 검증.
- **판정 규칙**: 지정 또는 자동으로 수집된 모든 리뷰어 세션이 만장일치로 **PASS** 판정을 내릴 때까지 Creator는 마감할 수 없으며, 반려 시 **Planner**에게 피드백이 환류됩니다.
---
## Specification & Flow
```mermaid
sequenceDiagram
autonumber
actor Loop as run_loop.sh
participant Plan as Planner Agent
participant Dev as Creator Agent
participant Rev as Reviewer Agents
Loop->>Loop: Parse args & validate session states
alt --plan enabled
Loop->>Plan: delegate plan design
Plan-->>Loop: plan report generated
loop for --plan-talk turns (default 1)
Loop->>Dev: delegate plan review & challenge
Dev->>Plan: send critiques (Discussion)
Plan-->>Dev: update plan & reach consensus
end
else Use Existing Plan or Creator Self-Plan (No --plan)
alt Existing Plan Found
Loop->>Dev: notify task execution using existing plan
else No Plan Found
Loop->>Dev: request Creator self-planning and code execution
end
end
Loop->>Dev: delegate code implementation
Dev-->>Loop: code modification complete
loop up to --max-loop times (default 3)
alt Reviewers specified (--reviewer / --all-reviewer)
Loop->>Rev: delegate code validation
Rev-->>Loop: Verdict report ([VERDICT: PASS] / [VERDICT: NOT PASS])
alt Unanimous PASS achieved
Note over Loop,Rev: Break loop (Success)
else NOT PASS detected
Loop->>Dev: delegate code correction with reviewer feedback
end
else Self-Review (default)
Loop->>Dev: notify self-evaluation
Dev-->>Loop: verification complete
end
end
alt --cleanup enabled
Loop->>Loop: purge temporary job folders
end
```
---
## Feedback Loop Cadence
1. **Planning Phase**:
- **Collaborative Planning (`--plan`)**: Planner가 프로젝트 구조를 파악하고 `implementation_plan.md`/`task.md`로 로드맵을 제공하며, Creator와의 피드백 루프를 통해 정제됩니다.
- **Creator Self-Planning (No `--plan`)**: Planner의 개입 없이, 기존 계획서가 있다면 이를 기반으로 하고, 그렇지 않다면 Creator가 독자적으로 설계 및 태스크 단위를 구상한 후 구현에 착수합니다.
2. **Execution Phase**: Creator가 배정된 태스크의 코드를 수정합니다. `--plan` 모드 진행 중 예상치 못한 설계 변경 필요성이 감지되면 작업을 멈추고 Planner에게 계획 수정을 먼저 위임합니다. (Creator 자율 계획 모드에서는 Creator가 직접 설계를 변경하며 진행합니다.) 구현 완료 후 DoD(타입 매핑, 공유 자원 사이드 이펙트 방지, 문서-코드 정합성)를 자체 검증한 뒤 단일 커밋을 작성합니다.
3. **Review Phase**: Creator가 리뷰어 세션에 작업 완료 사실과 변경 범위(`git diff`)를 전달합니다. 리뷰어는 검증 후 리포트 **마지막에 단독 행**으로 판정을 남깁니다:
- **반려 (`[VERDICT: NOT PASS]`)** → 피드백 요약을 Planner에게 전송하여 상위 레벨 계획(Rev.n)을 개시합니다.
- **통과 (`[VERDICT: PASS]`)** → 모든 검토 사항이 해결되었음을 명시합니다.
4. 지정되거나 자동 수집된 리뷰어 전원이 PASS를 발행해야 완결되며, `--max-loop N`회 내에 도달하지 못하면 안전을 위해 루프를 중단합니다.
5. 완결 후에도 에이전트 세션은 종료하지 않고, 다음 태스크 지시가 있을 때까지 프롬프트 대기 상태(Standby)로 유지됩니다.
---
## CLI Option ↔ Workflow Phase Mapping
워크플로우 단계별로 활용할 수 있는 `run_loop.sh` 옵션 규격은 다음과 같습니다:
| 워크플로우 단계 | 해당 CLI 옵션 | 설명 |
| :--- | :--- | :--- |
| **Phase 1: Planning** | `--plan` | Planner 에이전트를 기동하여 최초 계획 작성을 강제합니다. (옵션을 지정하지 않을 경우 새 계획서 작성을 생략하며, 기존 계획서가 있는 경우 이를 로드하고, 없는 경우 Creator가 직접 계획 및 설계를 수립하여 즉시 구현에 착수합니다.) |
| **Phase 1: Debate** | `--plan-talk N` | Planner와 Creator가 상호 대화식 챌린지 루프를 `N`회 돌며 계획을 교차 정제합니다. |
| **Phase 2: Execution** | (기본값) | `--target-agent`로 명시한 주 작업 세션에 코딩 태스크를 주입합니다. |
| **Phase 3: Review** | `--reviewer "A,B"` | 지정된 리뷰어 세션 리스트(`A`, `B` 등)에 교차 Peer Review를 위임합니다. |
| **Phase 3: Consensus** | `--all-reviewer` | 레지스트리에 등록된 모든 active 리뷰어 세션을 자동으로 수집하여 리뷰를 돌립니다. (지정/수집된 모든 리뷰어의 PASS 만장일치가 항상 필요합니다.) |
| **Iterative Loop** | `--max-loop M` | NOT PASS 판정 시 최대 `M`회까지 Creator가 자체 수정합니다. `--plan` 모드에서 리뷰어가 리포트에 `[ESCALATE: PLANNER]` 태그를 남기면 설계 변경 수준으로 판단하여 Planner에게 계획 갱신을 위임합니다 (린트는 리뷰어가 검토 관점 중 하나로 확인할 뿐, 별도의 자동 게이트는 아닙니다). |
---
## Workflow
```bash
# 1. Creator Self-Planning & Development + Self-review (direct task execution using existing promoted plan or Creator's own self-plan)
bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
--target-agent "<creator-session-name>" \
--task "Fix typo in deploy/README.md"
# 2. Collaborative planning + Targeted Reviewers + Safety limits
# (실전 자율 루프 기동의 표준 패턴 — 리뷰어 2인 지정 + 전원 합의 + 최대 3회 반복)
bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
--plan \
--plan-talk 1 \
--reviewer "<reviewer-session-name-1>,<reviewer-session-name-2>" \
--all-reviewer \
--max-loop 3 \
--verbose \
--target-agent "<creator-session-name>" \
--task "Refactor the session backup mechanism to handle NFS flock"
# 3. Total validation (all reviewers must PASS)
bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
--all-reviewer \
--max-loop 5 \
--cleanup \
--target-agent "<creator-session-name>" \
--task "Close CI shellcheck coverage gaps"
```
## Pitfalls
- **Incorrect Verdict format (앵커링 파서 하드닝)**: 리뷰 리포트 파일 내에서 `[VERDICT: PASS]` 또는 `[VERDICT: NOT PASS]` 토큰은 반드시 리포트의 **마지막에 단독 행**으로 기재되어야 합니다. 코드 인용이나 변경 diff 내에 등장하는 토큰은 매칭 대상에서 완전 배제됩니다.
- **Fail-closed on missing verdict**: 최종 Verdict 토큰이 누락되거나 리포트 픽업에 실패하면, 파서는 **경고 후 통과시키는 것이 아니라** 안전을 위해 즉시 `NOT PASS`로 판정(fail-closed)하고 교정 사이클을 수행합니다. 리뷰어에게는 반드시 리포트 끝에 단독 행으로 토큰을 찍도록 지시해야 합니다.
- **Session Availability**: `run_loop.sh` 기동 전에 참조되는 Planner, Target Agent, Reviewer 세션들이 모두 herdr 세션으로 기동되어 (`status.sh` 기준 `alive``running`) 있어야 합니다.
- **동시 루프 기동 금지 (NFS Lock Shadowing)**: 동일한 작업 트리 내에서 다수의 `run_loop.sh` 제어기를 동시에 기동하면 SQLite DB 갱신 경합 및 YAML 데이터 오염이 발생합니다. 하나의 루프가 끝날 때까지 다른 루프를 병렬로 기동하지 마십시오.
- **원자적 아카이빙 (Promotion)**: 루프 성공 종료 시 최종 계획서와 검증 리포트들은 `.agents/reports/<session_name>/` 디렉토리로 원자적으로 덮어쓰기(`mv -f`)되어 보존됩니다. 해당 경로의 리포트들로 VCS 추적성을 확보해야 합니다.
@@ -0,0 +1,604 @@
#!/usr/bin/env bash
# ===========================================================================
# run_loop.sh — Autonomous Planning, Execution, and Peer-Review Orchestrator
# ===========================================================================
set -euo pipefail
# 1. Load Common Framework Library
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
# shellcheck disable=SC1091
source "$REPO_ROOT/.agents/skills/lib.sh"
# Default configuration parameters
PLAN_MODE=false
PLAN_TALK_TURNS=1
ALL_REVIEWERS=false
MAX_LOOP=3
VERBOSE=false
CLEANUP=false
TARGET_AGENT=""
TASK=""
REVIEWER_LIST=""
# Print usage instructions
usage() {
echo "Usage: $0 [options] --target-agent <agent-session-name> --task <goal-text>"
echo "Options:"
echo " --plan Enable Planner agent intervention & design phase"
echo " --plan-talk N Planner-Creator discussion limit turns (default: 1)"
echo " --reviewer \"A,B\" Targeted reviewer session name list (comma-separated)"
echo " --all-reviewer Enforce PASS verdict from all active reviewer sessions"
echo " --max-loop N Max execution-review corrective loop runs (default: 3)"
echo " --verbose Print detailed execution timeline traces"
echo " --cleanup Purge temporary job directories upon success"
exit 1
}
# Parse options safely
while [[ "$#" -gt 0 ]]; do
case "$1" in
--plan) PLAN_MODE=true; shift ;;
--plan-talk)
if [[ ! "$2" =~ ^[0-9]+$ ]]; then
echo "ERROR: --plan-talk requires a positive integer."
exit 1
fi
PLAN_TALK_TURNS="$2"; shift 2 ;;
--reviewer) REVIEWER_LIST="$2"; shift 2 ;;
--all-reviewer) ALL_REVIEWERS=true; shift ;;
--max-loop)
if [[ ! "$2" =~ ^[0-9]+$ ]] || [ "$2" -le 0 ]; then
echo "ERROR: --max-loop requires a positive non-zero integer."
exit 1
fi
MAX_LOOP="$2"; shift 2 ;;
--verbose) VERBOSE=true; shift ;;
--cleanup) CLEANUP=true; shift ;;
--target-agent) TARGET_AGENT="$2"; shift 2 ;;
--task) TASK="$2"; shift 2 ;;
-h|--help) usage ;;
*) echo "Unknown option: $1"; usage ;;
esac
done
if [ -z "$TARGET_AGENT" ] || [ -z "$TASK" ]; then
echo "ERROR: --target-agent and --task are mandatory fields."
usage
fi
delegate_job_safe() {
local orig_script="$REPO_ROOT/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job"
local tmp_script
tmp_script="${orig_script}.${RANDOM}_$$.tmp"
cp "$orig_script" "$tmp_script"
trap 'rm -f "$tmp_script"' EXIT INT TERM HUP
local rc=0
bash "$tmp_script" "$@" || rc=$?
rm -f "$tmp_script"
trap - EXIT INT TERM HUP
return $rc
}
log_info() {
echo -e "\033[1;34m[*]\033[0m $1"
}
log_success() {
echo -e "\033[1;32m[✓]\033[0m $1"
}
log_warn() {
echo -e "\033[1;33m[!]\033[0m $1"
}
log_error() {
echo -e "\033[1;31m[✗]\033[0m $1"
}
# --all-reviewer silently takes precedence over an explicit --reviewer list;
# warn so the discarded list isn't mistaken for having been honored (P2-1).
if [ "$ALL_REVIEWERS" = true ] && [ -n "$REVIEWER_LIST" ]; then
log_warn "--all-reviewer takes precedence; ignoring --reviewer list ('$REVIEWER_LIST')."
fi
# Verdict must occupy the report's last non-blank line — a standalone token
# quoted mid-report (e.g. as a formatting example) never matches (P0-2).
has_verdict() {
local file="$1" verdict="$2"
local last_line pattern
last_line=$(grep -v '^[[:space:]]*$' "$file" 2>/dev/null | tail -n 1)
pattern="^\[VERDICT: ${verdict}\][[:space:]]*\r?\$"
[[ "$last_line" =~ $pattern ]]
}
# Helper: Blocking wait for a delegate job's completion or error state (with safety timeout)
wait_for_job() {
local job_id="$1"
local check_interval=3
local max_wait="${2:-3900}"
local deadline
deadline=$((SECONDS + max_wait))
if [ "$VERBOSE" = true ]; then
log_info "Monitoring job '$job_id' for status changes (timeout: ${max_wait}s)..."
fi
while [ "$SECONDS" -lt "$deadline" ]; do
local status
status=$(python3 -c "
import json, os
try:
with open('.mam/jobs/$job_id.json') as f:
print(json.load(f).get('status', 'unknown'))
except Exception:
print('unknown')
" 2>/dev/null || echo "unknown")
if [ "$status" = "completed" ]; then
if [ "$VERBOSE" = true ]; then
log_success "Job '$job_id' completed successfully."
fi
return 0
elif [ "$status" = "error" ]; then
log_error "Job '$job_id' finished with errors."
return 1
fi
sleep "$check_interval"
done
log_error "Job '$job_id' timed out after ${max_wait}s."
return 1
}
# Resolve active reviewers (excluding $TARGET_AGENT) via lib.sh's
# load_state_json — the single source of truth for the merged DB/YAML
# session state, instead of hand-rolling a 4th copy of that lookup (P1-2).
resolve_all_reviewers() {
TARGET_AGENT="$TARGET_AGENT" MAM_STATE_JSON="$(load_state_json)" python3 -c "
import os, json
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
target_agent = os.environ.get('TARGET_AGENT')
reviewers = [s.get('name') for s in d.get('herdr_sessions', [])
if 'reviewer' in s.get('role', '').lower() and s.get('name') != target_agent]
print(','.join(reviewers))
"
}
# Resolve target agent type (claude, cline, agy) via load_state_json.
resolve_agent_type() {
local name="$1"
NAME="$name" MAM_STATE_JSON="$(load_state_json)" python3 -c "
import os, json
name = os.environ.get('NAME')
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
agent = None
for s in d.get('herdr_sessions', []):
if s.get('name') == name:
agent = s.get('agent') or s.get('pane', {}).get('cmd')
break
if not agent:
# Exact hyphen-segment match, not a naive substring 'in' check, so a
# decoy substring inside an unrelated segment can't misclassify.
segments = name.split('-')
if 'agy' in segments:
agent = 'agy'
elif 'cline' in segments:
agent = 'cline'
elif 'hermes' in segments:
agent = 'hermes'
else:
agent = 'claude'
print(agent)
"
}
# Resolve planner session dynamically via load_state_json.
resolve_planner_session() {
MAM_STATE_JSON="$(load_state_json)" python3 -c "
import os, json
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
planner = ''
for s in d.get('herdr_sessions', []):
if 'planner' in s.get('role', '').lower():
planner = s.get('name')
break
print(planner)
"
}
# Portable Job ID extraction helper (fails-safe, avoids SC1091/grep GNU dependency)
extract_job_id() {
local output="$1"
local job_id
# Portable extraction equivalent to PCRE K
job_id=$(echo "$output" | grep -o 'registered job: [A-Za-z0-9]*' | awk '{print $3}' || true)
echo "$job_id"
}
# Main Execution Loop Flow
log_info "Initializing multi-agent-mux-loop controller..."
log_info "Target Agent: $TARGET_AGENT"
log_info "Task Goal: $TASK"
PLANNER_SESSION=$(resolve_planner_session)
log_info "Resolved Planner session: $PLANNER_SESSION"
CURRENT_PLAN=""
CREATED_JOBS=()
# ===========================================================================
# PHASE 1: PLANNING & DISCUSSIONS
# ===========================================================================
if [ "$PLAN_MODE" = true ]; then
log_info "=== Phase 1: Interactive Planning Phase ==="
# Step 1.1: Request initial plan from Planner
log_info "Requesting initial implementation plan from Planner..."
PLAN_JOB_OUTPUT=$(delegate_job_safe submit \
--agent-session "herdr:$PLANNER_SESSION" \
--agent "$(resolve_agent_type "$PLANNER_SESSION")" \
--type "direct" \
--role "Planner" \
--prompt "태스크 목표를 바탕으로 구체적인 구현 계획서를 작성해주세요. 목표: $TASK")
PLAN_JOB_ID=$(extract_job_id "$PLAN_JOB_OUTPUT")
if [ -z "$PLAN_JOB_ID" ]; then
log_error "Failed to register planner job. Output:\n$PLAN_JOB_OUTPUT"
exit 1
fi
CREATED_JOBS+=("$PLAN_JOB_ID")
log_info "Planner Job ID: $PLAN_JOB_ID"
if ! wait_for_job "$PLAN_JOB_ID"; then
log_error "Planning phase failed during initial plan design."
exit 1
fi
# Retrieve plan text safely
PLAN_FILE=$(find ".mam/jobs/$PLAN_JOB_ID" -maxdepth 2 -name "report-final.md" 2>/dev/null | head -n 1 || true)
if [ -z "$PLAN_FILE" ] || [ ! -f "$PLAN_FILE" ]; then
log_error "Final plan file not found."
exit 1
fi
CURRENT_PLAN=$(cat "$PLAN_FILE")
# Step 1.2: Interactive Creator-Planner Debate
turn=1
while [ "$turn" -le "$PLAN_TALK_TURNS" ]; do
log_info "Discussion Turn $turn/$PLAN_TALK_TURNS: Creator challenging the plan..."
# Creator critique job
DEBATE_JOB_OUTPUT=$(delegate_job_safe submit \
--agent-session "herdr:$TARGET_AGENT" \
--agent "$(resolve_agent_type "$TARGET_AGENT")" \
--type "direct" \
--role "Worker" \
--prompt "Planner가 제시한 다음 계획서를 꼼꼼히 검토하고, 실제 구현 시 마주할 수 있는 맹점이나 제약사항 1가지를 발굴하여 Planner에게 이의를 제기(Challenge)해주세요. 계획서:\n$CURRENT_PLAN")
DEBATE_JOB_ID=$(extract_job_id "$DEBATE_JOB_OUTPUT")
if [ -z "$DEBATE_JOB_ID" ]; then
log_error "Failed to register Creator critique job. Output:\n$DEBATE_JOB_OUTPUT"
exit 1
fi
CREATED_JOBS+=("$DEBATE_JOB_ID")
log_info "Critique Job ID: $DEBATE_JOB_ID"
if ! wait_for_job "$DEBATE_JOB_ID"; then
log_error "Creator critique step failed."
exit 1
fi
CRITIQUE_FILE=$(find ".mam/jobs/$DEBATE_JOB_ID" -maxdepth 2 -name "report-final.md" 2>/dev/null | head -n 1 || true)
if [ -z "$CRITIQUE_FILE" ] || [ ! -f "$CRITIQUE_FILE" ]; then
log_error "Critique file not found."
exit 1
fi
CRITIQUE_TEXT=$(cat "$CRITIQUE_FILE")
log_info "Planner refining plan with Creator's feedback..."
REFINE_JOB_OUTPUT=$(delegate_job_safe submit \
--agent-session "herdr:$PLANNER_SESSION" \
--agent "$(resolve_agent_type "$PLANNER_SESSION")" \
--type "direct" \
--role "Planner" \
--prompt "작업자(Creator)로부터 다음 이의제기 피드백을 받았습니다. 피드백을 반영하여 계획서를 정교하게 업데이트(Refine)하여 다시 출력해주세요. 피드백:\n$CRITIQUE_TEXT\n기존 계획서:\n$CURRENT_PLAN")
REFINE_JOB_ID=$(extract_job_id "$REFINE_JOB_OUTPUT")
if [ -z "$REFINE_JOB_ID" ]; then
log_error "Failed to register plan refinement job. Output:\n$REFINE_JOB_OUTPUT"
exit 1
fi
CREATED_JOBS+=("$REFINE_JOB_ID")
log_info "Refinement Job ID: $REFINE_JOB_ID"
if ! wait_for_job "$REFINE_JOB_ID"; then
log_error "Planner refinement step failed."
exit 1
fi
REFINE_FILE=$(find ".mam/jobs/$REFINE_JOB_ID" -maxdepth 2 -name "report-final.md" 2>/dev/null | head -n 1 || true)
if [ -z "$REFINE_FILE" ] || [ ! -f "$REFINE_FILE" ]; then
log_error "Refinement plan file not found."
exit 1
fi
CURRENT_PLAN=$(cat "$REFINE_FILE")
turn=$((turn + 1))
done
log_success "Interactive planning completed. Plan finalized."
else
log_info "=== Phase 1: Self-Planning Mode (Direct Execution) ==="
EXISTING_PLAN_FILE=""
if [ -n "$PLANNER_SESSION" ]; then
EXISTING_PLAN_FILE=".agents/reports/$PLANNER_SESSION/report-final.md"
fi
if [ -n "$EXISTING_PLAN_FILE" ] && [ -f "$EXISTING_PLAN_FILE" ]; then
log_info "Found existing promoted plan at '$EXISTING_PLAN_FILE'. Loading plan..."
CURRENT_PLAN=$(cat "$EXISTING_PLAN_FILE")
fi
fi
# ===========================================================================
# PHASE 2: IMPLEMENTATION (CREATION)
# ===========================================================================
log_info "=== Phase 2: Code Implementation ==="
# Fix the pre-implementation commit as the diff baseline so review diffs stay
# cumulative and non-empty even after the Creator commits per DoD (P0-1).
BASE_COMMIT=$(git rev-parse HEAD 2>/dev/null || echo "")
EXECUTION_PROMPT="계획서가 존재하지 않으므로, 작업자(Creator)의 판단하에 스스로 구현 계획 및 설계를 수립한 뒤, 이를 바탕으로 코드를 구현하고 다음 작업 목표를 완성해주세요. 작업 목표: $TASK"
if [ -n "$CURRENT_PLAN" ]; then
EXECUTION_PROMPT="이미 수립된 다음 계획서에 입각하여 작업자의 판단하에 코드를 구현하고 작업 목표를 완성해주세요. 계획서:\n$CURRENT_PLAN\n\n작업 목표: $TASK"
fi
EXEC_JOB_OUTPUT=$(delegate_job_safe submit \
--agent-session "herdr:$TARGET_AGENT" \
--agent "$(resolve_agent_type "$TARGET_AGENT")" \
--type "direct" \
--role "Worker" \
--prompt "$EXECUTION_PROMPT")
EXEC_JOB_ID=$(extract_job_id "$EXEC_JOB_OUTPUT")
if [ -z "$EXEC_JOB_ID" ]; then
log_error "Failed to register Creator execution job. Output:\n$EXEC_JOB_OUTPUT"
exit 1
fi
CREATED_JOBS+=("$EXEC_JOB_ID")
log_info "Creator Job ID: $EXEC_JOB_ID"
if ! wait_for_job "$EXEC_JOB_ID"; then
log_error "Creator execution failed."
exit 1
fi
log_success "Initial implementation finished."
# ===========================================================================
# PHASE 3: VERIFICATION LOOP (PEER REVIEW)
# ===========================================================================
log_info "=== Phase 3: Verification & Corrective Review Loop ==="
# Resolve reviewer array
REVIEWERS=()
if [ "$ALL_REVIEWERS" = true ]; then
# Parse list safely using command substitution + fallback
RESOLVED_REVS=$(resolve_all_reviewers)
if [ -n "$RESOLVED_REVS" ]; then
IFS=' ,' read -r -a REVIEWERS <<< "$RESOLVED_REVS"
fi
elif [ -n "$REVIEWER_LIST" ]; then
IFS=' ,' read -r -a REVIEWERS <<< "$REVIEWER_LIST"
fi
loop_count=1
while [ "$loop_count" -le "$MAX_LOOP" ]; do
log_info "Review Loop Iteration $loop_count/$MAX_LOOP..."
if [ "${#REVIEWERS[@]}" -eq 0 ]; then
log_warn "No reviewers specified. Conducting Creator Self-Review..."
SELF_REV_OUTPUT=$(delegate_job_safe submit \
--agent-session "herdr:$TARGET_AGENT" \
--agent "$(resolve_agent_type "$TARGET_AGENT")" \
--type "direct" \
--role "Reviewer" \
--prompt "작업 완료 상태에 대해 스스로 검증(Self-Review)하여 결함이 없음을 확인하고 종결해주세요. 리뷰 리포트 마지막에 단독 행으로 반드시 '[VERDICT: PASS]' 혹은 '[VERDICT: NOT PASS]' 태그를 명시해주세요.")
SELF_REV_ID=$(extract_job_id "$SELF_REV_OUTPUT")
if [ -z "$SELF_REV_ID" ]; then
log_error "Failed to register Self-Review job."
exit 1
fi
CREATED_JOBS+=("$SELF_REV_ID")
wait_for_job "$SELF_REV_ID"
REPORT_FILE=$(find ".mam/jobs/$SELF_REV_ID" -maxdepth 2 -name "report-final.md" 2>/dev/null | head -n 1 || true)
if [ -n "$REPORT_FILE" ] && [ -f "$REPORT_FILE" ] && has_verdict "$REPORT_FILE" "PASS" && ! has_verdict "$REPORT_FILE" "NOT PASS"; then
log_success "Self-Review PASS."
break
else
log_warn "Self-Review NOT PASS."
if [ "$loop_count" -eq "$MAX_LOOP" ]; then
log_error "Reached max loop count. Self-review loop aborted with failures."
exit 1
fi
fi
else
log_info "Active reviewers: ${REVIEWERS[*]}"
# We use space-separated lists or simple loops to bypass bash-4 associative array requirement (M-7 macOS compatibility)
declare -a JOB_IDS=()
declare -a JOB_REVS=()
for rev in "${REVIEWERS[@]}"; do
log_info "Requesting code review from Reviewer '$rev'..."
# Cumulative diff since BASE_COMMIT (M-6): includes committed AND
# uncommitted changes, so it stays non-empty even after the Creator
# commits per the documented DoD (bare `git diff` alone would not).
if [ -n "$BASE_COMMIT" ]; then
CHANGES_DIFF=$(git diff "$BASE_COMMIT" 2>/dev/null || echo "No git diff available")
else
CHANGES_DIFF=$(git diff 2>/dev/null || echo "No git diff available")
fi
REV_OUTPUT=$(delegate_job_safe submit \
--agent-session "herdr:$rev" \
--agent "$(resolve_agent_type "$rev")" \
--type "direct" \
--role "Reviewer" \
--prompt "다음 구현 사항(작업 목표: $TASK) 및 누적 변경분(git diff)에 대해 린트, 동작성, 유실 등의 관점에서 교차 코드 리뷰를 수행해주세요. 확인 후 최종 Verdict로 '[VERDICT: PASS]' 혹은 '[VERDICT: NOT PASS]' 태그를 리뷰 리포트 마지막에 단독 행으로 명시적으로 작성해주세요. 만약 단순 버그 수정으로는 부족하고 설계 변경/재작업 수준의 재계획이 필요하다고 판단되면, 리포트 아무 곳에나 단독 행으로 '[ESCALATE: PLANNER]' 태그도 함께 남겨주세요. 변경분:\n$CHANGES_DIFF")
REV_JOB_ID=$(extract_job_id "$REV_OUTPUT")
if [ -z "$REV_JOB_ID" ]; then
log_error "Failed to register review job for '$rev'."
exit 1
fi
CREATED_JOBS+=("$REV_JOB_ID")
JOB_IDS+=("$REV_JOB_ID")
JOB_REVS+=("$rev")
done
# Wait for all reviews
all_passed=true
FEEDBACK_AGGREGATE=""
for idx in "${!JOB_IDS[@]}"; do
job_id="${JOB_IDS[$idx]}"
rev="${JOB_REVS[$idx]}"
if ! wait_for_job "$job_id"; then
log_warn "Reviewer '$rev' job crashed."
all_passed=false
continue
fi
# Parse verdict from report file (fails-safe, M-2 anchored checks)
REPORT_FILE=$(find ".mam/jobs/$job_id" -maxdepth 2 -name "report-final.md" 2>/dev/null | head -n 1 || true)
if [ -z "$REPORT_FILE" ] || [ ! -f "$REPORT_FILE" ]; then
log_warn "Reviewer '$rev' report not found. Counting as NOT PASS."
all_passed=false
continue
fi
REPORT_CONTENT=$(cat "$REPORT_FILE" 2>/dev/null || echo "")
# Precedence rules: NOT PASS wins over PASS. Absence of verdict tags is treated as NOT PASS (fail-closed)
if has_verdict "$REPORT_FILE" "NOT PASS" || ! has_verdict "$REPORT_FILE" "PASS"; then
log_warn "Reviewer '$rev': NOT PASS"
all_passed=false
FEEDBACK_AGGREGATE="$FEEDBACK_AGGREGATE\n--- Reviewer ($rev) Feedback ---\n$REPORT_CONTENT"
else
log_success "Reviewer '$rev': PASS"
fi
done
if [ "$all_passed" = true ]; then
log_success "All reviewers issued [VERDICT: PASS]. Loop completed successfully."
break
else
if [ "$loop_count" -eq "$MAX_LOOP" ]; then
log_error "Reached max loop count ($MAX_LOOP). Review loop aborted with failures."
exit 1
fi
# Re-planning check: rely on the explicit '[ESCALATE: PLANNER]' tag a
# reviewer is instructed to emit, rather than sniffing English keywords
# (reviewers report in Korean, so keyword matching never fired) (P1-1).
COMPLEX_FIX=false
if echo "$FEEDBACK_AGGREGATE" | grep -qE '^\[ESCALATE: PLANNER\][[:space:]]*\r?$'; then
COMPLEX_FIX=true
fi
if [ "$PLAN_MODE" = true ] && [ "$COMPLEX_FIX" = true ]; then
log_warn "Feedback involves complex code modifications. Diverting to Planner to revise plan..."
REFINE_PLAN_OUTPUT=$(delegate_job_safe submit \
--agent-session "herdr:$PLANNER_SESSION" \
--agent "$(resolve_agent_type "$PLANNER_SESSION")" \
--type "direct" \
--role "Planner" \
--prompt "리뷰어들로부터 다음과 같이 정교한 코드 수정 피드백이 도착했습니다. 해당 피드백을 수렴하여 구현 계획서(Plan)를 갱신(Refine)하여 다시 작성해주세요. 피드백:\n$FEEDBACK_AGGREGATE\n기존 계획서:\n$CURRENT_PLAN")
REFINE_PLAN_ID=$(extract_job_id "$REFINE_PLAN_OUTPUT")
if [ -z "$REFINE_PLAN_ID" ]; then
log_error "Failed to register plan refinement job."
exit 1
fi
CREATED_JOBS+=("$REFINE_PLAN_ID")
wait_for_job "$REFINE_PLAN_ID"
REFINE_PLAN_FILE=$(find ".mam/jobs/$REFINE_PLAN_ID" -maxdepth 2 -name "report-final.md" 2>/dev/null | head -n 1 || true)
if [ -z "$REFINE_PLAN_FILE" ] || [ ! -f "$REFINE_PLAN_FILE" ]; then
log_error "Refined plan file not found."
exit 1
fi
CURRENT_PLAN=$(cat "$REFINE_PLAN_FILE")
CORRECTION_PROMPT="갱신된 다음 계획서에 입각하여 지적된 오류들을 수정하고 코드를 다시 구현해주세요. 계획서:\n$CURRENT_PLAN\n피드백 상세:\n$FEEDBACK_AGGREGATE"
else
log_info "Applying straight bugfixes based on reviewer feedback..."
CORRECTION_PROMPT="리뷰어들이 지적한 다음 피드백에 입각하여 코드를 수정해주세요. 피드백:\n$FEEDBACK_AGGREGATE"
fi
# Creator execution corrective job
CORRECT_JOB_OUTPUT=$(delegate_job_safe submit \
--agent-session "herdr:$TARGET_AGENT" \
--agent "$(resolve_agent_type "$TARGET_AGENT")" \
--type "direct" \
--role "Worker" \
--prompt "$CORRECTION_PROMPT")
CORRECT_JOB_ID=$(extract_job_id "$CORRECT_JOB_OUTPUT")
if [ -z "$CORRECT_JOB_ID" ]; then
log_error "Failed to register Creator correction job."
exit 1
fi
CREATED_JOBS+=("$CORRECT_JOB_ID")
wait_for_job "$CORRECT_JOB_ID"
fi
fi
loop_count=$((loop_count + 1))
done
# Promote finalized plan and passed reviewer reports to durable location
if [ "$PLAN_MODE" = true ] && [ -n "${CURRENT_PLAN:-}" ] && [ -n "${PLAN_JOB_ID:-}" ]; then
plan_dest_dir=".agents/reports/$PLANNER_SESSION"
log_info "Promoting final plan to durable location: $plan_dest_dir"
mkdir -p "$plan_dest_dir"
echo "$CURRENT_PLAN" > "$plan_dest_dir/plan-${PLAN_JOB_ID}.md.tmp"
mv -f "$plan_dest_dir/plan-${PLAN_JOB_ID}.md.tmp" "$plan_dest_dir/plan-${PLAN_JOB_ID}.md"
fi
if [ "${#REVIEWERS[@]}" -gt 0 ]; then
log_info "Promoting final review reports to durable location..."
for idx in "${!JOB_IDS[@]}"; do
job_id="${JOB_IDS[$idx]}"
rev="${JOB_REVS[$idx]}"
report_file=$(find ".mam/jobs/$job_id" -maxdepth 2 -name "report-final.md" 2>/dev/null | head -n 1 || true)
if [ -n "$report_file" ] && [ -f "$report_file" ]; then
dest_dir=".agents/reports/$rev"
mkdir -p "$dest_dir"
cp "$report_file" "$dest_dir/report-${job_id}.md.tmp"
mv -f "$dest_dir/report-${job_id}.md.tmp" "$dest_dir/report-${job_id}.md"
fi
done
fi
# ===========================================================================
# PHASE 4: CLEANUP
# ===========================================================================
if [ "$CLEANUP" = true ]; then
log_info "Cleaning up temporary job directories created during this loop run..."
for job in "${CREATED_JOBS[@]}"; do
if [ -d ".mam/jobs/$job" ]; then
rm -rf ".mam/jobs/$job"
rm -f ".mam/jobs/$job.subscriber.out"
if [ "$VERBOSE" = true ]; then
log_info "Purged temp assets for job: $job"
fi
fi
done
log_success "Cleanup complete."
fi
log_success "Mux loop finished with 100% PASS verdicts."
exit 0
+26 -26
View File
@@ -1,14 +1,14 @@
---
name: multi-agent-mux-monitor
description: "Run a long-lived Kanban worker that polls .mam/agent-sessions.yaml against the actual tmux/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. Designed to be dispatched as a Kanban goal_mode task (--goal) so it keeps running until the user stops it."
description: "Run a long-lived Kanban worker that polls .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. Designed to be dispatched as a Kanban goal_mode task (--goal) so it keeps running until the user stops it."
version: 1.0.0
author: godopu
license: MIT
platforms: [linux, macos]
environments: [kanban, terminal, tmux]
environments: [kanban, terminal, herdr]
metadata:
hermes:
tags: [agent, tmux, claude, antigravity, agy, monitor, kanban, observation, reconciliation]
tags: [agent, herdr, claude, antigravity, agy, monitor, kanban, observation, reconciliation]
related_skills: [multi-agent-mux-create, multi-agent-mux-resume, multi-agent-mux-stop, kanban-orchestrator]
prereq_skills: [kanban-worker, multi-agent-mux-create]
---
@@ -23,16 +23,16 @@ metadata:
Dispatch a **Kanban worker** (in `goal_mode`) that:
1. Every ~30s polls the actual state of:
- `tmux ls` (which sessions are alive)
- `tmux list-panes -t <session> ...` (pane cmd, cwd, pid)
- `herdr agent list` (which sessions are alive)
- `herdr agent get <session>` (pane cmd, cwd)
- `~/.claude/projects/<workspace-key>/*.jsonl` mtime + first-line sessionId
- `~/.gemini/antigravity-cli/cache/last_conversations.json` (agy workspace → conversation mapping)
- `~/.gemini/antigravity-cli/conversations/<uuid>.db` mtime (agy)
2. Compares the live state to `agent-sessions.yaml`
3. Detects 4 classes of drift:
- **yaml-only terminated/archived/stopped**: tmux dead, YAML says `terminated`, `archived`, or `stopped` → OK, left untouched (deliberate end states)
- **yaml-only running, tmux dead**: YAML says `running`, tmux is gone → mark `terminated` with timestamp
- **tmux-only running, not in YAML**: tmux session exists with `<workspace>-creator-*` naming but YAML doesn't know about it → register as a new entry
- **yaml-only terminated/archived/stopped**: herdr dead, YAML says `terminated`, `archived`, or `stopped` → OK, left untouched (deliberate end states)
- **yaml-only running, herdr dead**: YAML says `running`, herdr is gone → mark `terminated` with timestamp
- **herdr-only running, not in YAML**: herdr session exists with `<workspace>-creator-*` naming but YAML doesn't know about it → register as a new entry
- **stale UUID**: YAML has a UUID, but the on-disk artifact is gone → flag in comment
4. Writes a Kanban `kanban_comment` on every drift event with diff details
5. Heartbeat every 5 minutes
@@ -40,14 +40,14 @@ Dispatch a **Kanban worker** (in `goal_mode`) that:
## When to use
- You have multiple workspaces with tmux agent sessions and want a single source of truth
- You have multiple workspaces with herdr agent sessions and want a single source of truth
- You suspect YAML drift after a host reboot / crash
- You want a notification when a session id was just created (so you can record it before next restart)
- You're running multi-day work and want to know "what's actually running right now"
## When NOT to use
- One-off interactive session — just check `tmux ls` and read the YAML
- One-off interactive session — just check `herdr agent list` and read the YAML
- A single, short session — overhead > benefit
- You don't have a Kanban dispatcher running
@@ -69,9 +69,9 @@ hermes kanban create \
You are the agent-sessions monitor. Every 30 seconds, do:
1. Read .mam/agent-sessions.yaml
2. Run `tmux ls` and `tmux list-panes -F 'session=#{session_name} pid=#{pane_pid} cmd=#{pane_current_command} cwd=#{pane_current_path}'`
3. For each session in the YAML, check the corresponding tmux state
4. For each tmux session matching `*-creator-claude` or `*-creator-agy` that's not in the YAML, register it
2. Run `herdr agent list` and `herdr agent get <session>` for each tracked session name (these are real native herdr commands — do not use tmux-era names like `herdr ls`/`herdr list-panes` outside a shell that has sourced `.agents/skills/lib.sh`)
3. For each session in the YAML, check the corresponding herdr state
4. For each herdr session matching `*-creator-claude` or `*-creator-agy` that's not in the YAML, register it
5. For any drift, call `kanban_comment` with the diff
6. Sleep 30 seconds, then repeat
@@ -88,7 +88,7 @@ EOF
The worker calls this script every 30s. It:
1. Diffs YAML ↔ tmux ↔ disk artifacts
1. Diffs YAML ↔ herdr ↔ disk artifacts
2. Updates YAML if needed (only when changes are real, not on every poll — avoids spamming)
3. Emits a JSON diff to stdout that the worker turns into a `kanban_comment`
@@ -115,13 +115,13 @@ Flags: `--once` (single pass), `--emit-diff` (print JSON), `--dry-run` (P1-E —
The `status` and `last_visible_status` fields MUST be one of the following exact strings: `running`, `stopped`, `terminated`, `archived`.
Any unstructured comments or reasons for the status change should be placed in `last_visible_note` or `termination_mode`.
### A. tmux dead, YAML says running → auto-terminate
### A. herdr dead, YAML says running → auto-terminate
```
YAML: status=running, pane.pid=201132, cmd=claude
tmux: no session
herdr: no session
→ set status=terminated, terminated_at=<now>, termination_mode=auto-detected
→ comment: "lab-landing-page-creator-claude: tmux gone (was pane 201132, cmd claude). Marked terminated."
→ comment: "lab-landing-page-creator-claude: herdr gone (was pane 201132, cmd claude). Marked terminated."
```
**Skip-set**: the auto-terminate only fires for sessions whose status is `running`.
@@ -129,16 +129,16 @@ Rows already in a deliberate end state — `terminated`, `archived`, or **`stopp
(set by `multi-agent-mux-stop`) — are
left untouched. This is critical: a `stopped` row keeps its `resumable: true` and
captured `*_session_id_own`, so the monitor must **not** overwrite it with
`terminated ("auto-detected")` when its tmux is (expectedly) gone.
`terminated ("auto-detected")` when its herdr is (expectedly) gone.
### B. tmux alive, not in YAML → auto-register
### B. herdr alive, not in YAML → auto-register
```
tmux: session=lab-paper-pdf2md-creator-agy, pid=...,
herdr: session=lab-paper-pdf2md-creator-agy, pid=...,
cmd=agy, cwd=$WORKSPACE_ROOT/paper-pdf2md
YAML: no such session
→ register as new entry: status=running, last_visible_status=running, last_visible_note=auto-registered
→ comment: "lab-paper-pdf2md-creator-agy: tmux found but not in YAML. Auto-registered."
→ comment: "lab-paper-pdf2md-creator-agy: herdr found but not in YAML. Auto-registered."
```
### C. New session id materializes (claude first message sent)
@@ -166,7 +166,7 @@ disk: ~/.claude/projects/.../87dc548e-...jsonl: missing
- **Don't run the monitor without `--goal`** — without goal mode, a single turn will spawn, do one reconcile, and complete. Goal mode keeps the worker alive across many turns.
- **The 30s poll is a default** — workers may override if they detect heavy churn. A workspace with 5+ agent sessions should bump to 60s to avoid noise.
- **`kanban_comment` rate limits** — Kanban may throttle if you comment too fast. Coalesce: only comment when the diff is *new* (not the same drift on every poll). The script tracks a state file at `.cache/multi-agent-mux-monitor/<workspace>.state` in the workspace root for this (overridable via `AGENT_SESSIONS_STATE_DIR`).
- **Don't fight the user's explicit action** — if `multi-agent-mux-stop` is mid-flight and the monitor sees the same session in two states within 5s, prefer the user's most recent action. The monitor should not auto-revert a fresh `terminated` to `running` because of a stale `tmux has-session` check.
- **Don't fight the user's explicit action** — if `multi-agent-mux-stop` is mid-flight and the monitor sees the same session in two states within 5s, prefer the user's most recent action. The monitor should not auto-revert a fresh `terminated` to `running` because of a stale `herdr has-session` check.
- **The monitor should never modify the conversation artifacts** (jsonl, db) — only the YAML. If you see a stale UUID, comment about it but don't delete the file.
- **TUI capture-pane is expensive** — only capture when you need to update `last_visible_status`, not every poll.
@@ -194,15 +194,15 @@ If `$HERMES_KANBAN_TASK` card has any comment containing "stop" or "stop monitor
## Drift responses
- A. tmux dead + YAML running: auto-terminate YAML, comment
- B. tmux alive not in YAML: auto-register, comment
- A. herdr dead + YAML running: auto-terminate YAML, comment
- B. herdr alive not in YAML: auto-register, comment
- C. New session id from *.jsonl: update YAML, comment
- D. Stale UUID: comment only, no YAML change
## Hard rules
- Do NOT modify conversation artifacts (jsonl, db, brain/)
- Do NOT spawn/delete tmux sessions — that's the create/delete skills' job
- Do NOT spawn/delete herdr sessions — that's the create/delete skills' job
- Do NOT call multi-agent-mux-create or multi-agent-mux-stop — only the user initiates those
- Do NOT call `git commit` / `git push`
```
@@ -217,7 +217,7 @@ When using `--subscribe` with the default PoC public broker
event from a third party can terminate your agent session.
3. **Mitigation**: Use `--subscribe` only on private TLS-enabled brokers
(production mode). For PoC, prefer polling-based monitor (`--once` or
no `--subscribe`) which reads YAML/tmux state directly without MQTT.
no `--subscribe`) which reads YAML/herdr state directly without MQTT.
4. **HMAC verification**: Events are now verified via `verify_hmac()` in
`mqtt_common.py` (see FW-05). Ensure `auth_token` is set for each job
to enable signature validation — unauthenticated events will be dropped.
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# reconcile.sh — multi-agent-mux-monitor 의 부속 스크립트
# YAML ↔ tmux ↔ 디스크 artifact 간 drift 감지 (+ YAML 자동 갱신).
# YAML ↔ herdr ↔ 디스크 artifact 간 drift 감지 (+ YAML 자동 갱신).
#
# Usage:
# bash reconcile.sh --once --emit-diff # drift 감지 + 갱신
@@ -9,12 +9,15 @@
# --dry-run: 부수효과 없는 read-only. "지금 뭐 돌고 있지?" 질문에 안전.
# multi-agent-mux-status 스킬이 이걸 재사용.
#
# 출력 (JSON): {timestamp, yaml_path, tmux_sessions_alive, tmux_confirmed, drifts, actions}
# 출력 (JSON): {timestamp, yaml_path, herdr_sessions_alive, herdr_confirmed, drifts, actions}
#
# Exit codes: 0 = ok | 1 = YAML not found | 2 = error
set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
SKILLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
LIB_SH="$SKILLS_DIR/lib.sh"
source "$LIB_SH"
export WORKSPACE_ROOT
STATE_DIR="${AGENT_SESSIONS_STATE_DIR:-$WORKSPACE_ROOT/.cache/multi-agent-mux-monitor}"
@@ -105,7 +108,7 @@ import registry
# Executed INSIDE lib.sh::atomic_dump_yaml (system python3 + PyYAML), under the
# YAML flock with schema-validate + .bak (review item 5). Marks matching running
# sessions terminated and kills their tmux (review item 3 behaviour preserved),
# sessions terminated and kills their herdr (review item 3 behaviour preserved),
# or aborts the write entirely when nothing matches. The untrusted MQTT job id /
# event arrive via env (MQTT_JID / MQTT_EVENT) — never spliced into source (P1-B).
_MUTATION = r'''
@@ -115,18 +118,24 @@ _jid = os.environ['MQTT_JID']
_event = os.environ['MQTT_EVENT']
_now = datetime.now(timezone.utc)
_changed = False
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if s.get('delegate_job_id') == _jid and s.get('status') == 'running':
s['status'] = 'terminated'
s['terminated_at'] = _now.strftime('%Y-%m-%dT%H:%M:%SZ')
s['terminated_at_epoch'] = int(_now.timestamp())
s['termination_mode'] = 'auto-detected (MQTT ' + _event + ')'
_name = s.get('name')
_srv = s.get('tmux_server') or 'default'
_cmd = ['tmux'] + (['-L', _srv] if _srv != 'default' else []) + ['kill-session', '-t', _name]
subprocess.run(_cmd, capture_output=True)
print('MQTT Monitor: terminated + killed ' + str(_name) + ' on ' + str(_srv), flush=True)
_changed = True
_srv = s.get('herdr_session') or s.get('herdr_workspace') or s.get('herdr_server') or 'default'
if _event == 'completed':
s['delegate_job_id'] = None
print('MQTT Monitor: job completed on ' + str(_name) + ' — session kept alive', flush=True)
_changed = True
else:
s['status'] = 'terminated'
s['terminated_at'] = _now.strftime('%Y-%m-%dT%H:%M:%SZ')
s['terminated_at_epoch'] = int(_now.timestamp())
s['termination_mode'] = 'auto-detected (MQTT ' + _event + ')'
_shim = os.path.join(os.environ.get('WORKSPACE_ROOT', os.getcwd()), '.mam/shim/herdr')
_cmd = [_shim] + (['-L', _srv] if _srv != 'default' else []) + ['kill-session', '-t', _name]
subprocess.run(_cmd, capture_output=True)
print('MQTT Monitor: terminated + killed ' + str(_name) + ' on ' + str(_srv) + ' due to MQTT ' + _event, flush=True)
_changed = True
if not _changed:
raise SystemExit(0) # nothing matched — skip the write entirely
'''
@@ -301,55 +310,47 @@ import yaml
yaml_path = os.environ['YAML_PATH']
home = os.environ['HOME_DIR']
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
workspace_root = os.environ.get('WORKSPACE_ROOT', os.getcwd())
shim_herdr = os.path.join(workspace_root, '.mam/shim/herdr')
now_iso = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
# atomic 래퍼에서는 d 가 이미 로드돼 있음. env_python(dry-run)에서는 여기서 로드.
try:
d
except NameError:
import sqlite3
db_path = os.path.splitext(yaml_path)[0] + '.db'
import subprocess
d = {}
try:
if os.path.exists(db_path):
conn = sqlite3.connect(db_path, timeout=60.0)
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
if row: d = json.loads(row[0])
try:
db_sessions = []
cursor = conn.execute('SELECT data FROM sessions')
for s_row in cursor.fetchall():
db_sessions.append(json.loads(s_row[0]))
d['tmux_sessions'] = db_sessions
except sqlite3.OperationalError:
pass
conn.close()
elif os.path.exists(yaml_path):
with open(yaml_path) as f:
d = yaml.safe_load(f) or {}
lib_sh = os.environ.get('LIB_SH')
if not lib_sh:
ws_root = os.environ.get('WORKSPACE_ROOT')
if not ws_root:
ws_root = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../../..'))
lib_sh = os.path.join(ws_root, '.agents/skills/lib.sh')
script = f"source '{lib_sh}' && load_state_json"
out = subprocess.check_output(['bash', '-c', script], stderr=subprocess.DEVNULL)
d = json.loads(out.decode('utf-8'))
except Exception:
pass
drifts = []
actions = []
# === 현재 tmux 상태 — transient 실패를 'no sessions' 와 구분 (P1-E) ===
tmux_sessions = []
tmux_confirmed = True
# === 현재 herdr 상태 — transient 실패를 'no sessions' 와 구분 (P1-E) ===
herdr_sessions = []
herdr_confirmed = True
# YAML 에 등록된 고유한 tmux_server 목록 수집 + 환경변수 TMUX_SERVER_NAME 포함
# YAML 에 등록된 고유한 herdr_server 목록 수집 + 환경변수 HERDR_SERVER_NAME 포함
unique_servers = {'default'}
if 'TMUX_SERVER_NAME' in os.environ:
unique_servers.add(os.environ['TMUX_SERVER_NAME'])
for s in d.get('tmux_sessions', []):
srv = s.get('tmux_server') or 'default'
if 'HERDR_SERVER_NAME' in os.environ:
unique_servers.add(os.environ['HERDR_SERVER_NAME'])
for s in d.get('herdr_sessions', []):
srv = s.get('herdr_session') or s.get('herdr_workspace') or s.get('herdr_server') or 'default'
unique_servers.add(srv)
try:
for srv in sorted(unique_servers):
cmd = ['tmux']
cmd = [shim_herdr]
if srv != 'default':
cmd += ['-L', srv]
cmd += ['ls', '-F', '#{session_name}|#{session_created}']
@@ -359,19 +360,19 @@ try:
if not line:
continue
name, created = line.split('|', 1)
tmux_sessions.append({'name': name, 'created': int(created), 'server': srv})
herdr_sessions.append({'name': name, 'created': int(created), 'server': srv})
else:
err = (r.stderr or '').lower()
is_empty = ('no server running' in err) or ('no sessions' in err) or ('failed to connect' in err)
if not is_empty:
tmux_confirmed = False
herdr_confirmed = False
except Exception:
tmux_confirmed = False
herdr_confirmed = False
def pane_meta(session, srv):
try:
cmd = ['tmux']
cmd = [shim_herdr]
if srv != 'default':
cmd += ['-L', srv]
cmd += ['list-panes', '-t', session, '-F',
@@ -383,38 +384,43 @@ def pane_meta(session, srv):
return None
yaml_sessions = d.get('tmux_sessions', [])
yaml_sessions = d.get('herdr_sessions', [])
yaml_session_names = {s['name'] for s in yaml_sessions if s.get('name')}
alive_set = {(t['name'], t.get('server', 'default')) for t in tmux_sessions}
alive_set = {(t['name'], t.get('server', 'default')) for t in herdr_sessions}
# === drift A: tmux dead + YAML running → auto-terminate ===
# tmux 응답을 확정했을 때만. transient 실패 시 모두 terminated 로 마크하지 않음 (P1-E)
if tmux_confirmed:
# === drift A: herdr dead + YAML running → auto-terminate ===
# herdr 응답을 확정했을 때만. transient 실패 시 모두 terminated 로 마크하지 않음 (P1-E)
if herdr_confirmed:
for s in yaml_sessions:
name = s.get('name')
if not name:
continue
# 'stopped' 도 deliberate한 종료 상태 — drift 로 보지 않고 그대로 둔다.
# (없으면 tmux-dead stopped 세션을 'terminated' 로 덮어써 resumable 플래그가 소실됨)
# (없으면 herdr-dead stopped 세션을 'terminated' 로 덮어써 resumable 플래그가 소실됨)
if s.get('status') in ('terminated', 'archived', 'stopped'):
continue
srv = s.get('tmux_server') or 'default'
srv = s.get('herdr_session') or s.get('herdr_workspace') or s.get('herdr_server') or 'default'
if (name, srv) not in alive_set:
s['status'] = 'terminated'
s['terminated_at'] = now_iso
s['terminated_at_epoch'] = int(datetime.now(timezone.utc).timestamp())
s['termination_mode'] = 'auto-detected (tmux gone)'
s['termination_mode'] = 'auto-detected (herdr gone)'
pane = s.get('pane') or {}
drifts.append({'class': 'A', 'name': name,
'msg': f"{name}: tmux gone (was pane {pane.get('pid')}, cmd {pane.get('cmd')}). Marked terminated."})
'msg': f"{name}: herdr gone (was pane {pane.get('pid')}, cmd {pane.get('cmd')}). Marked terminated."})
actions.append(f"terminated: {name}")
# === drift B: tmux alive + not in YAML → auto-register ===
if tmux_confirmed:
for t in tmux_sessions:
# === drift B: herdr alive + not in YAML → auto-register ===
if herdr_confirmed:
for t in herdr_sessions:
name = t['name']
if name in yaml_session_names:
continue
workspace_root = os.environ.get('WORKSPACE_ROOT')
if not workspace_root:
workspace_root = os.path.abspath(os.path.join(os.path.dirname(yaml_path), '..'))
if os.path.exists(os.path.join(workspace_root, '.mam', f"purging-{name}")):
continue
if name.endswith('-creator-claude'):
agent = 'claude'
elif name.endswith('-creator-agy'):
@@ -441,14 +447,14 @@ if tmux_confirmed:
entry = {
'name': name,
'status': 'running',
'tmux_session_created_at': datetime.fromtimestamp(t['created'], tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
'tmux_session_epoch': t['created'],
'tmux_server': srv,
'herdr_session_created_at': datetime.fromtimestamp(t['created'], tz=timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
'herdr_session_epoch': t['created'],
'herdr_server': srv,
'pane': {'index': 0, 'pid': pm['pid'], 'cmd': agent, 'cmd_full': cmd_full, 'cwd': pm['cwd']},
# P2: cwd 인용
'start_command': f'tmux {server_opt}new-session -d -s "{name}" -x 140 -y 40 -c "{pm["cwd"]}" "{cmd_full}"',
'attach_command': f'tmux {server_opt}attach -t {name}',
'kill_command': f'tmux {server_opt}kill-session -t {name}',
'start_command': f'herdr {server_opt}new-session -d -s "{name}" -x 140 -y 40 -c "{pm["cwd"]}" "{cmd_full}"',
'attach_command': f'herdr {server_opt}agent attach {name}',
'kill_command': f'herdr {server_opt}kill-session -t {name}',
'last_visible_status': 'running',
'last_visible_note': 'auto-registered by monitor',
}
@@ -472,14 +478,14 @@ if tmux_confirmed:
elif agent == 'cline':
entry['child_pid'] = 0
entry['cline_conversation_id_own'] = None
d.setdefault('tmux_sessions', []).append(entry)
d.setdefault('herdr_sessions', []).append(entry)
yaml_session_names.add(name)
drifts.append({'class': 'B', 'name': name,
'msg': f"{name}: tmux found but not in YAML. Auto-registered (pane {pm['pid']}, cmd {pm['cmd']}, cwd {pm['cwd']})."})
'msg': f"{name}: herdr found but not in YAML. Auto-registered (pane {pm['pid']}, cmd {pm['cmd']}, cwd {pm['cwd']})."})
actions.append(f"registered: {name}")
# === drift C: claude 새 session id materialize (per-row own id) ===
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if not s.get('name', '').endswith('-creator-claude'):
continue
if s.get('status') != 'running':
@@ -514,7 +520,7 @@ for s in d.get('tmux_sessions', []):
actions.append(f"updated session id: {sid}")
# === drift C (agy): agy 새 session id materialize (per-row own id) ===
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if not s.get('name', '').endswith('-creator-agy'):
continue
if s.get('status') != 'running':
@@ -538,7 +544,7 @@ for s in d.get('tmux_sessions', []):
pass
# === drift C (hermes): hermes 새 session id materialize (per-row own id) ===
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if not s.get('name', '').endswith('-creator-hermes'):
continue
if s.get('status') != 'running':
@@ -563,7 +569,7 @@ for s in d.get('tmux_sessions', []):
pass
# === drift C (cline): cline 새 session id materialize (per-row own id) ===
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if not s.get('name', '').endswith('-creator-cline'):
continue
if s.get('status') != 'running':
@@ -637,8 +643,8 @@ if cn.get('session_id'):
result = {
'timestamp': now_iso,
'yaml_path': yaml_path,
'tmux_sessions_alive': sorted(f"{t['name']}|{t.get('server', 'default')}" for t in tmux_sessions),
'tmux_confirmed': tmux_confirmed,
'herdr_sessions_alive': sorted(f"{t['name']}|{t.get('server', 'default')}" for t in herdr_sessions),
'herdr_confirmed': herdr_confirmed,
'drifts': drifts,
'actions': actions,
}
@@ -650,7 +656,7 @@ if not actions:
PYEOF
if [ "$DRY_RUN" = "1" ]; then
printf '%s' "$RECON_SRC" | env_python "$AGENT_SESSIONS_YAML"
printf '%s' "$RECON_SRC" | LIB_SH="$LIB_SH" env_python "$AGENT_SESSIONS_YAML"
else
printf '%s' "$RECON_SRC" | atomic_dump_yaml "$AGENT_SESSIONS_YAML"
printf '%s' "$RECON_SRC" | LIB_SH="$LIB_SH" atomic_dump_yaml "$AGENT_SESSIONS_YAML"
fi
+37 -27
View File
@@ -1,14 +1,14 @@
---
name: multi-agent-mux-resume
description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a tmux session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a tmux session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose tmux died but the agent's conversation is still on disk."
description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose herdr died but the agent's conversation is still on disk."
version: 1.0.0
author: godopu
license: MIT
platforms: [linux, macos]
environments: [terminal, tmux]
environments: [terminal, herdr]
metadata:
hermes:
tags: [agent, tmux, claude, antigravity, agy, multi-agent, context, resume, session-id]
tags: [agent, herdr, claude, antigravity, agy, multi-agent, context, resume, session-id]
related_skills: [multi-agent-mux-create, multi-agent-mux-stop, multi-agent-mux-monitor, claude-code]
prereq_skills: [multi-agent-mux-create]
---
@@ -16,18 +16,18 @@ metadata:
# Multi-Agent Resume — Reattach to a Saved Conversation
> **Companion skills**: `multi-agent-mux-create` (start a fresh agent), `multi-agent-mux-stop` (terminate), `multi-agent-mux-monitor` (live status).
> **Tmux Isolation**: `TMUX_SERVER_NAME` env var를 create에서 설정한 경우, 동일 서버에서 동작합니다. 자세한 격리 패턴은 [multi-agent-mux-create/SKILL.md](../multi-agent-mux-create/SKILL.md) 참조.
> **Herdr Isolation**: `HERDR_SERVER_NAME` env var를 create에서 설정한 경우, 동일 서버에서 동작합니다. 자세한 격리 패턴은 [multi-agent-mux-create/SKILL.md](../multi-agent-mux-create/SKILL.md) 참조.
> **Single source of truth**: `./.mam/agent-sessions.yaml`.
## What this skill does
**Container + data reconstruction**: spawn a tmux session (the container), then run the agent inside with a specific session id (the data) so the previous conversation's context is restored.
**Container + data reconstruction**: spawn a herdr session (the container), then run the agent inside with a specific session id (the data) so the previous conversation's context is restored.
Three cases this skill handles:
1. **tmux is dead, conversation lives**`agent-sessions.yaml` has the UUID. The JSONL/db is on disk. Re-spawn the tmux session + run `claude -r <id>` / `agy --conversation <id>`.
2. **tmux is alive but empty** — You started a session with `multi-agent-mux-create` but haven't sent a message yet (so no session id was assigned). The user can either send their first message (and the id is auto-assigned), or you can read the *workspace's* most recent conversation from `$HOME_DIR/.gemini/antigravity-cli/cache/last_conversations.json` (defaults to `~/.gemini/...`) for agy, or the latest `*.jsonl` in `$CLAUDE_PROJECT_DIR/<workspace-key>/` (defaults to `~/.claude/projects/`) for claude.
3. **tmux is alive AND the agent inside is already running** — Just attach. No re-spawn needed.
1. **herdr is dead, conversation lives**`agent-sessions.yaml` has the UUID. The JSONL/db is on disk. Re-spawn the herdr session + run `claude -r <id>` / `agy --conversation <id>`.
2. **herdr is alive but empty** — You started a session with `multi-agent-mux-create` but haven't sent a message yet (so no session id was assigned). The user can either send their first message (and the id is auto-assigned), or you can read the *workspace's* most recent conversation from `$HOME_DIR/.gemini/antigravity-cli/cache/last_conversations.json` (defaults to `~/.gemini/...`) for agy, or the latest `*.jsonl` in `$CLAUDE_PROJECT_DIR/<workspace-key>/` (defaults to `~/.claude/projects/`) for claude.
3. **herdr is alive AND the agent inside is already running** — Just attach. No re-spawn needed.
### Resuming a `stopped` session (`stopped → running`)
@@ -64,7 +64,7 @@ WORKSPACE=/path/to/project
AGENT=claude # or agy or hermes
SESSION_NAME=<workspace>-creator-<agent> # same convention as multi-agent-mux-create
# Resolve the isolated tmux server name & load isolation utils
# Resolve the isolated herdr server name & load isolation utils
source .agents/skills/lib.sh
# 1. Resolve the session id (T5: pass session name for target-row isolation check)
@@ -76,12 +76,12 @@ if [ -z "$UUID" ]; then
exit 1
fi
export TMUX_SERVER_NAME="$(resolve_tmux_server "$SESSION_NAME")"
export HERDR_SERVER_NAME="$(resolve_herdr_server "$SESSION_NAME")"
# 2. If tmux is alive, attach. Done.
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "tmux '$SESSION_NAME' already running. Attaching..."
exec tmux attach -t "$SESSION_NAME"
# 2. If herdr is alive, attach. Done.
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "herdr '$SESSION_NAME' already running. Attaching..."
exec herdr agent attach "$SESSION_NAME"
fi
# 3. Resolve isolation settings for this session (T4/T5 re-apply)
@@ -108,7 +108,7 @@ try:
d = yaml.safe_load(f) or {}
except Exception:
pass
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if s.get('name') == name:
print(json.dumps(s.get('isolation') or {}))
raise SystemExit(0)
@@ -138,20 +138,20 @@ if [ -n "$ISO_ARGS" ]; then
CMD_FULL="$CMD_FULL $ISO_ARGS"
fi
# 4. Spawn new tmux session + run agent with the saved id (and re-applied isolation)
# 4. Spawn new herdr session + run agent with the saved id (and re-applied isolation)
case "$AGENT" in
claude)
if [ -z "$ISO_ROOT" ] && [ -x "$HOME/.local/bin/canary-projects-multi-agent-mux-creator-claude" ]; then
START_CMD="tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$HOME/.local/bin/canary-projects-multi-agent-mux-creator-claude\""
START_CMD="herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$HOME/.local/bin/canary-projects-multi-agent-mux-creator-claude\""
else
START_CMD="tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
START_CMD="herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
fi
eval "$START_CMD"
# auto-handle trust / bypass dialogs
handle_startup_dialogs "$SESSION_NAME" 20
;;
agy|hermes|cline)
eval "tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
eval "herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
;;
esac
@@ -160,8 +160,9 @@ esac
bash .agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh \
--session "$SESSION_NAME" --uuid "$UUID"
# 5. Attach
tmux attach -t "$SESSION_NAME"
# 5. Attach (real native command — "attach" isn't in the lib.sh tmux-compat shim,
# and real herdr has no `-t` flag here, only a positional target)
herdr agent attach "$SESSION_NAME"
```
## Pitfalls
@@ -175,26 +176,35 @@ tmux attach -t "$SESSION_NAME"
## Verification
```bash
# 1. tmux alive with the right cmd
tmux list-panes -t "$SESSION_NAME" -F 'cmd=#{pane_current_command} cwd=#{pane_current_path}'
# 1. herdr alive with the right cmd (real native command, no lib.sh needed)
herdr agent get "$SESSION_NAME" | python3 -c "
import sys, json
a = json.load(sys.stdin)['result']['agent']
print(f\"cmd={a['agent']} cwd={a['cwd']}\")
"
# 2. agent-sessions.yaml updated
python3 -c "
import yaml
d = yaml.safe_load(open('.mam/agent-sessions.yaml'))
s = [s for s in d['tmux_sessions'] if s['name'] == '$SESSION_NAME'][0]
s = [s for s in d['herdr_sessions'] if s['name'] == '$SESSION_NAME'][0]
print(f' status: {s[\"status\"]}')
print(f' pane.cmd_full: {s[\"pane\"][\"cmd_full\"]}')
"
# 3. TUI shows resumed conversation (capture-pane to verify)
# 3. TUI shows resumed conversation (real native command, no lib.sh needed)
sleep 5
tmux capture-pane -t "$SESSION_NAME" -p -S -30
herdr agent read "$SESSION_NAME" --source visible --lines 30
# look for the previous message at top of the buffer (claude) or last_visible_status set (agy)
```
> `herdr list-panes` / `herdr capture-pane` here are tmux-compat pseudo-commands that only
> work after `source .agents/skills/lib.sh` (as done in `Workflow` above) — the real `herdr`
> binary doesn't have those subcommands. This block uses the real `herdr agent get`/`herdr agent read`
> equivalents instead so it also works standalone.
## When NOT to use this skill
- **No saved session yet** → `multi-agent-mux-create`
- **Killing an existing session** → `multi-agent-mux-stop`
- **Just attaching** → `tmux attach -t <name>` (no skill needed)
- **Just attaching** → `herdr agent attach <name>` (no skill needed)
@@ -0,0 +1,138 @@
#!/usr/bin/env bash
# resume_session.sh — resume a stopped session
set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
usage() {
cat <<EOF
Usage: $0 --workspace <path> --agent <claude|agy|hermes|cline> --session <name>
EOF
}
WORKSPACE=""
AGENT=""
SESSION_NAME=""
while [ $# -gt 0 ]; do
case "$1" in
--workspace) WORKSPACE="$2"; shift 2 ;;
--agent) AGENT="$2"; shift 2 ;;
--session) SESSION_NAME="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
esac
done
[ -n "$WORKSPACE" ] || { echo "ERROR: --workspace required" >&2; exit 2; }
[ -n "$AGENT" ] || { echo "ERROR: --agent required" >&2; exit 2; }
[ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; exit 2; }
# 1. Resolve the session id
UUID=$(bash "$(dirname "${BASH_SOURCE[0]}")/resolve_session_id.sh" \
--workspace "$WORKSPACE" --agent "$AGENT" --session "$SESSION_NAME")
if [ -z "$UUID" ]; then
echo "ERROR: No saved session for $WORKSPACE ($AGENT). Use multi-agent-mux-create first." >&2
exit 1
fi
HERDR_SERVER_NAME="$(resolve_herdr_session "$SESSION_NAME")"
export HERDR_SERVER_NAME
# 2. If herdr is alive, print warning or attach.
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "herdr '$SESSION_NAME' already running."
# Just update YAML to make sure it's set to running
bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \
--session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT"
exit 0
fi
# 3. Resolve isolation settings for this session
ISO_ROOT=""
ISO_ENV=""
ISO_ARGS=""
ISO_DATA=$(env_python "$AGENT_SESSIONS_YAML" SESSION_NAME="$SESSION_NAME" <<'PYEOF'
import os, json, yaml, sqlite3
name = os.environ['SESSION_NAME']
yaml_path = os.environ['YAML_PATH']
db_path = os.path.splitext(yaml_path)[0] + '.db'
d = {}
try:
if os.path.exists(db_path):
conn = sqlite3.connect(db_path, timeout=60.0)
row = conn.execute('SELECT data FROM sessions WHERE name=?', (name,)).fetchone()
if row:
s = json.loads(row[0])
print(json.dumps(s.get('isolation') or {}))
raise SystemExit(0)
elif os.path.exists(yaml_path):
with open(yaml_path) as f:
d = yaml.safe_load(f) or {}
except Exception:
pass
for s in d.get('herdr_sessions', []):
if s.get('name') == name:
print(json.dumps(s.get('isolation') or {}))
raise SystemExit(0)
print("{}")
PYEOF
)
ISO_ROOT=$(printf '%s' "$ISO_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("root",""))')
if [ -n "$ISO_ROOT" ]; then
ISO_ENV="$(isolation_env_prefix "$AGENT" "$ISO_ROOT")"
ISO_ARGS="$(isolation_cmd_args "$AGENT" "$ISO_ROOT")"
echo "Re-applying isolation: root=$ISO_ROOT env=$ISO_ENV args=$ISO_ARGS"
fi
# Resolve absolute path of the agent command to prevent herdr PATH inheritance issues (especially on macOS)
RESOLVED_BIN="$AGENT"
if [ "$AGENT" = "cline" ]; then
if command -v cline >/dev/null 2>&1; then
RESOLVED_BIN="$(command -v cline)"
fi
else
if command -v "$AGENT" >/dev/null 2>&1; then
RESOLVED_BIN="$(command -v "$AGENT")"
fi
fi
# On macOS, clear quarantine attribute for the agent binary to prevent Gatekeeper hangs
if [ "$(uname)" = "Darwin" ] && [ -f "$RESOLVED_BIN" ]; then
xattr -d com.apple.quarantine "$RESOLVED_BIN" 2>/dev/null || true
fi
# Determine CMD_FULL with isolation applied
case "$AGENT" in
claude) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions -r $UUID" ;;
agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --conversation $UUID" ;;
hermes) CMD_FULL="${RESOLVED_BIN} --resume $UUID" ;;
cline) CMD_FULL="${RESOLVED_BIN} -i --id $UUID" ;;
esac
# Prepend env prefix and append command args (T4)
if [ -n "$ISO_ENV" ]; then
CMD_FULL="$ISO_ENV $CMD_FULL"
fi
if [ -n "$ISO_ARGS" ]; then
CMD_FULL="$CMD_FULL $ISO_ARGS"
fi
# 4. Spawn new agent session (delegates to herdr translation shim)
_herdr new-session -d -s "$SESSION_NAME" -c "$WORKSPACE" "$CMD_FULL"
if [ "$AGENT" = "claude" ]; then
# auto-handle trust / bypass dialogs
handle_startup_dialogs "$SESSION_NAME" 20
fi
# Wait for TUI readiness or let it settle
sleep 2
# 5. Update agent-sessions.yaml: status running, last_visible_status
bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \
--session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT"
echo "Successfully resumed $SESSION_NAME ($AGENT)"
@@ -33,7 +33,8 @@ done
[ -n "$UUID" ] || { echo "ERROR: --uuid required" >&2; exit 2; }
[ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found" >&2; exit 1; }
export TMUX_SERVER_NAME="$(resolve_tmux_server "$SESSION_NAME")"
HERDR_SERVER_NAME="$(resolve_herdr_session "$SESSION_NAME")"
export HERDR_SERVER_NAME
# --agent 미지정 시 이름 suffix 로 fallback (P1-F: 가능하면 --agent 명시)
if [ -z "$AGENT" ]; then
@@ -48,8 +49,8 @@ fi
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
# 새 tmux pane pid / 자식 pid 를 bash 에서 캡처 (env 로 전달, P1-B)
PANE_PID=$(tmux list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
# 새 herdr pane pid / 자식 pid 를 bash 에서 캡처 (env 로 전달, P1-B)
PANE_PID=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
PANE_PID="${PANE_PID:-}"
CHILD_PID=0
if { [ "$AGENT" = "agy" ] || [ "$AGENT" = "hermes" ] || [ "$AGENT" = "cline" ]; } && [ -n "$PANE_PID" ]; then
@@ -57,40 +58,15 @@ if { [ "$AGENT" = "agy" ] || [ "$AGENT" = "hermes" ] || [ "$AGENT" = "cline" ];
CHILD_PID="${CHILD_PID:-0}"
fi
DELEGATE_JOB_ID=$(env_python "$AGENT_SESSIONS_YAML" SESSION_NAME="$SESSION_NAME" <<'PYEOF'
import os, sys, sqlite3, json, yaml
DELEGATE_JOB_ID=$(MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$SESSION_NAME" python3 -c "
import sys, os, json
name = os.environ['SESSION_NAME']
yaml_path = os.environ['YAML_PATH']
db_path = os.path.splitext(yaml_path)[0] + '.db'
d = {}
try:
if os.path.exists(db_path):
conn = sqlite3.connect(db_path, timeout=60.0)
try:
row = conn.execute('SELECT data FROM sessions WHERE name=?', (name,)).fetchone()
if row:
s = json.loads(row[0])
print(s.get('delegate_job_id', '') or '')
raise SystemExit(0)
except sqlite3.OperationalError:
pass
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
if row:
d = json.loads(row[0])
conn.close()
elif os.path.exists(yaml_path):
with open(yaml_path) as f:
d = yaml.safe_load(f) or {}
except Exception:
pass
for s in d.get('tmux_sessions', []):
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
for s in d.get('herdr_sessions', []):
if s.get('name') == name:
print(s.get('delegate_job_id', '') or '')
raise SystemExit(0)
raise SystemExit(0)
PYEOF
)
sys.exit(0)
")
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
SESSION_NAME="$SESSION_NAME" UUID="$UUID" AGENT="$AGENT" NOW_ISO="$NOW_ISO" \
@@ -102,7 +78,7 @@ now = os.environ['NOW_ISO']
pane_pid = os.environ.get('PANE_PID', '')
target = None
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if s.get('name') == name:
target = s
break
+18 -18
View File
@@ -1,14 +1,14 @@
---
name: multi-agent-mux-status
description: "Read-only instant snapshot of all agent tmux sessions — name, YAML status, tmux alive, pane cmd/cwd, resume UUID on disk, and any drift. No Kanban, 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 a Kanban monitor worker."
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 Kanban, 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 a Kanban monitor worker."
version: 1.0.0
author: godopu
license: MIT
platforms: [linux, macos]
environments: [terminal, tmux]
environments: [terminal, herdr]
metadata:
hermes:
tags: [agent, tmux, claude, antigravity, agy, status, read-only, snapshot]
tags: [agent, herdr, claude, antigravity, agy, status, read-only, snapshot]
related_skills: [multi-agent-mux-create, multi-agent-mux-resume, multi-agent-mux-stop, multi-agent-mux-monitor]
prereq_skills: [multi-agent-mux-create, multi-agent-mux-monitor]
---
@@ -16,19 +16,19 @@ metadata:
# Multi-Agent Status — Read-Only Instant Snapshot
> **Companion skills**: `multi-agent-mux-create` (start), `multi-agent-mux-resume` (re-attach), `multi-agent-mux-stop` (terminate), `multi-agent-mux-monitor` (live polling).
> **Tmux Isolation**: `status` 명령은 YAML에 등록된 모든 세션의 격리 서버(`tmux_server` 필드)를 자동으로 조회하여 상태를 확인하므로, `TMUX_SERVER_NAME` 환경변수를 수동으로 지정하지 않아도 모든 격리 서버의 세션 상태를 통합 조회합니다.
> **Herdr Isolation**: `status` 명령은 YAML에 등록된 모든 세션의 격리 서버(`herdr_server` 필드)를 자동으로 조회하여 상태를 확인하므로, `HERDR_SERVER_NAME` 환경변수를 수동으로 지정하지 않아도 모든 격리 서버의 세션 상태를 통합 조회합니다.
> **Single source of truth**: `./.mam/agent-sessions.yaml`.
## What this skill does
Print a single table of every agent tmux session, comparing YAML state to actual tmux state. **No mutation. No Kanban. No polling loop.**
Print a single table of every agent herdr session, comparing YAML state to actual herdr state. **No mutation. No Kanban. No polling loop.**
This is the "what's running right now?" answer — faster than dispatching `multi-agent-mux-monitor` (which polls every 30s) and safer than `reconcile.sh --once --emit-diff` (which mutates as a side effect).
## Pre-flight
```bash
command -v tmux
command -v herdr
command -v python3
test -f .mam/agent-sessions.yaml
```
@@ -45,19 +45,19 @@ The script:
1. Calls `reconcile.sh --once --emit-diff --dry-run` (read-only; no YAML mutation) for the drift snapshot
2. Loads `agent-sessions.yaml` (read-only) to enrich the table
3. For each row in `tmux_sessions[]`:
- tmux alive? (via `tmux has-session -t <name>`)
- pane cmd, cwd (via `tmux list-panes`)
3. For each row in `herdr_sessions[]`:
- herdr alive? (via `herdr agent get <name>`, real native command — the script sources `lib.sh` internally, which is what lets it also spell this as `herdr has-session -t <name>`)
- pane cmd, cwd (via `herdr agent get <name>`, likewise shimmed as `herdr list-panes` internally)
- resume UUID on disk? (claude: `$CLAUDE_PROJECT_DIR/<key>/<uuid>.jsonl` with default `~/.claude/projects/`; agy: `$HOME_DIR/.gemini/antigravity-cli/conversations/<uuid>.db` with default `~/.gemini/...`)
4. For each tmux session matching `*-creator-*` not in YAML → flag as "unregistered"
4. For each herdr session matching `*-creator-*` not in YAML → flag as "unregistered"
5. Prints a table (default) or JSON (with `--json`)
## Output format (default = aligned table)
```
agent-sessions status — 2026-06-19T14:20:00Z (tmux_confirmed=True)
agent-sessions status — 2026-06-19T14:20:00Z (herdr_confirmed=True)
========================================================================================================================================
NAME SERVER YAML TMUX CMD RESUME JOB_ID JOB_STATUS DRIFT
NAME SERVER YAML HERDR CMD RESUME JOB_ID JOB_STATUS DRIFT
----------------------------------------------------------------------------------------------------------------------------------------
lab-landing-page-creator-claude default running alive claude yes - - -
lab-landing-page-creator-agy default terminated dead agy yes 5fe09ba8 completed -
@@ -70,13 +70,13 @@ lab-paper-pdf2md-creator-claude default running alive clau
```json
{
"yaml_path": "...",
"tmux_sessions_alive": ["..."],
"herdr_sessions_alive": ["..."],
"yaml_entries": [...],
"rows": [
{
"name": "lab-landing-page-creator-claude",
"yaml_status": "running",
"tmux_alive": true,
"herdr_alive": true,
"pane_cmd": "claude",
"pane_cwd": "/home/.../refer_landing_page",
"resume_uuid_on_disk": true,
@@ -85,7 +85,7 @@ lab-paper-pdf2md-creator-claude default running alive clau
{
"name": "lab-landing-page-creator-agy",
"yaml_status": "terminated",
"tmux_alive": false,
"herdr_alive": false,
"drift": "yaml-says-terminated-but-disk-uuid-still-present"
}
],
@@ -98,15 +98,15 @@ lab-paper-pdf2md-creator-claude default running alive clau
| Class | Detection | Meaning |
|---|---|---|
| `A` | YAML `running`, tmux dead | session died without going through `multi-agent-mux-stop`. *Could* auto-terminate but won't — that's `multi-agent-mux-monitor`'s job. |
| `B` | tmux alive, not in YAML | ad-hoc session someone started without `multi-agent-mux-create`. Suggest: "use multi-agent-mux-create to register, or tmux kill-session to clean up." |
| `A` | YAML `running`, herdr dead | session died without going through `multi-agent-mux-stop`. *Could* auto-terminate but won't — that's `multi-agent-mux-monitor`'s job. |
| `B` | herdr alive, not in YAML | ad-hoc session someone started without `multi-agent-mux-create`. Suggest: "use multi-agent-mux-create to register, or multi-agent-mux-stop to clean up." |
| `C` | YAML has `claude_session_id_own: null` AND a new *.jsonl exists | new session id materialized; suggest: "run multi-agent-mux-resume or reconcile to register it." |
| `D` | YAML has UUID in `agent_identities`, but the on-disk artifact is gone | stale UUID; user should `multi-agent-mux-stop --purge-conversation` to clean up. |
## Pitfalls
- **Do NOT use this skill to drive mutations** — the output is a snapshot, not a call to action. If you need to fix drifts, dispatch `multi-agent-mux-monitor` (Kanban worker) or run `multi-agent-mux-resume` / `multi-agent-mux-stop` manually.
- **Read-only is enforced by script** — `status.sh` opens the YAML with `open(path)` (no `'w'`), never calls `tmux kill-session`, never writes anywhere. The `reconcile.sh --dry-run` mode is the same path.
- **Read-only is enforced by script** — `status.sh` opens the YAML with `open(path)` (no `'w'`), never calls `herdr kill-session`, never writes anywhere. The `reconcile.sh --dry-run` mode is the same path.
- **If `agent-sessions.yaml` is malformed** — print the YAML error verbatim and exit 1. Do NOT attempt recovery (that's `multi-agent-mux-stop --purge-conversation` or manual edit's job).
- **Sessions outside the `<workspace>-creator-*` naming convention** are still shown but tagged `ad-hoc` — they didn't go through `multi-agent-mux-create` and aren't tracked in YAML.
@@ -19,49 +19,126 @@ JSON=0
# read-only drift snapshot — reconcile.sh --dry-run (no side effects)
DRIFT_JSON="$(bash "$RECONCILE" --once --emit-diff --dry-run)"
if [ "$JSON" = "1" ]; then
printf '%s\n' "$DRIFT_JSON"
exit 0
fi
# Project root (parent of .agents/) holds the multi-agent-mux-delegate-job .mam registry.
# Resolved relative to this script — no hardcoded absolute path (review item 6).
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../" && pwd)"
DRIFT_JSON="$DRIFT_JSON" env_python "$AGENT_SESSIONS_YAML" PROJECT_ROOT="$PROJECT_ROOT" <<'PYEOF'
if [ "$JSON" = "1" ]; then
# D8: --json historically only carried reconcile.sh's drift subset (timestamp/
# yaml_path/herdr_sessions_alive/herdr_confirmed/drifts/actions), not the enriched
# per-row fields (RESUME/JOB_ID/JOB_STATUS/CMD/attach_command/pane.cwd/...) that
# only this script's text-mode block computed. Fixed additively below via a
# 'sessions_detail' key — every existing key is passed through untouched, so
# any consumer of the pre-fix schema keeps working unmodified.
MAM_STATE_JSON="$(load_state_json)" DRIFT_JSON="$DRIFT_JSON" env_python "$AGENT_SESSIONS_YAML" PROJECT_ROOT="$PROJECT_ROOT" <<'PYEOF'
import os, json, glob
import yaml
yaml_path = os.environ['YAML_PATH']
home = os.environ['HOME_DIR']
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
drift = json.loads(os.environ['DRIFT_JSON'])
db_path = os.path.splitext(yaml_path)[0] + '.db'
d = {}
import sqlite3
try:
if os.path.exists(db_path):
conn = sqlite3.connect(db_path, timeout=60.0)
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
if row: d = json.loads(row[0])
try:
db_sessions = []
cursor = conn.execute('SELECT data FROM sessions')
for s_row in cursor.fetchall():
db_sessions.append(json.loads(s_row[0]))
d['tmux_sessions'] = db_sessions
except sqlite3.OperationalError:
pass
conn.close()
elif os.path.exists(yaml_path):
with open(yaml_path) as f:
d = yaml.safe_load(f) or {}
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
except Exception:
pass
d = {}
alive = set(drift.get('tmux_sessions_alive', []))
alive = set(drift.get('herdr_sessions_alive', []))
drift_by_name = {}
for dr in drift.get('drifts', []):
drift_by_name.setdefault(dr['name'], []).append(dr['class'])
def resume_on_disk(s):
# workspace-SCOPED check only — per-row own id, never a global identity (P0-C)
name = s.get('name', '')
cwd = (s.get('pane') or {}).get('cwd', '')
if name.endswith('-creator-claude'):
u = s.get('claude_session_id_own')
if u:
key = cwd.replace('/', '-').replace('_', '-')
return 'yes' if os.path.exists(f"{claude_project_dir}/{key}/{u}.jsonl") else 'MISSING'
key = cwd.replace('/', '-').replace('_', '-')
return 'scan' if glob.glob(f"{claude_project_dir}/{key}/*.jsonl") else 'no'
if name.endswith('-creator-agy'):
u = s.get('agy_conversation_id_own')
if u:
return 'yes' if os.path.exists(f"{home}/.gemini/antigravity-cli/conversations/{u}.db") else 'MISSING'
return 'no'
return '?'
def get_job_status(s):
jid = s.get('delegate_job_id')
if not jid:
return ('-', '-')
project_root = os.environ.get('PROJECT_ROOT', '.')
candidates = [
os.path.join('.mam', 'jobs', f"{jid}.json"),
os.path.join(project_root, '.mam', 'jobs', f"{jid}.json"),
os.path.join(project_root, '.mam', 'delegate_job_logs', jid, 'status.json'),
]
for path in candidates:
if os.path.exists(path):
try:
with open(path) as jf:
job_data = json.load(jf)
return (jid, job_data.get('status', 'unknown'))
except Exception:
pass
return (jid, 'unknown')
sessions_detail = []
for s in d.get('herdr_sessions', []):
name = s.get('name', '?')
server = s.get('herdr_session') or s.get('herdr_workspace') or s.get('herdr_server') or 'default'
jid, jstatus = get_job_status(s)
pane = s.get('pane') or {}
sessions_detail.append({
# Fields named/typed to match the reviewed D8 contract
# (.mam/jobs/40bdce88/claude-reports/report-final.md §3.1) exactly —
# mam_core maps this straight onto its Session/Pane/Drift models.
'name': name,
'server': server,
'status': s.get('status', '?'),
'herdr_alive': f"{name}|{server}" in alive,
'cmd': pane.get('cmd'),
'role': s.get('role'),
'resume_state': resume_on_disk(s),
'job_id': jid,
'job_status': jstatus,
'pane_cwd': pane.get('cwd'),
'attach_command': s.get('attach_command'),
'drift_classes': drift_by_name.get(name, []),
# Additive beyond the D8 example — needed by the M1 Detail Pane
# (pane pid / full launch command / start command / last banner text).
'pane_pid': pane.get('pid'),
'cmd_full': pane.get('cmd_full'),
'start_command': s.get('start_command'),
'last_visible_status': s.get('last_visible_status'),
})
drift['sessions_detail'] = sessions_detail
print(json.dumps(drift, ensure_ascii=False))
PYEOF
exit 0
fi
MAM_STATE_JSON="$(load_state_json)" DRIFT_JSON="$DRIFT_JSON" env_python "$AGENT_SESSIONS_YAML" PROJECT_ROOT="$PROJECT_ROOT" <<'PYEOF'
import os, sys, json, glob
home = os.environ['HOME_DIR']
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
drift = json.loads(os.environ['DRIFT_JSON'])
try:
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
except Exception:
d = {}
alive = set(drift.get('herdr_sessions_alive', []))
drift_by_name = {}
for dr in drift.get('drifts', []):
drift_by_name.setdefault(dr['name'], []).append(dr['class'])
@@ -111,23 +188,23 @@ def get_job_status(s):
return (jid, 'unknown')
sessions = d.get('tmux_sessions', [])
print(f"agent-sessions status — {drift['timestamp']} (tmux_confirmed={drift['tmux_confirmed']})")
sessions = d.get('herdr_sessions', [])
print(f"agent-sessions status — {drift['timestamp']} (herdr_confirmed={drift['herdr_confirmed']})")
print("=" * 136)
print(f"{'NAME':<44} {'SERVER':<12} {'YAML':<10} {'TMUX':<6} {'CMD':<6} {'RESUME':<8} {'JOB_ID':<10} {'JOB_STATUS':<12} DRIFT")
print(f"{'NAME':<44} {'WORKSPACE':<12} {'YAML':<10} {'HERDR':<6} {'CMD':<6} {'RESUME':<8} {'JOB_ID':<10} {'JOB_STATUS':<12} DRIFT")
print("-" * 136)
if not sessions:
print("(no sessions registered)")
for s in sessions:
name = s.get('name', '?')
server = s.get('tmux_server') or 'default'
server = s.get('herdr_session') or s.get('herdr_workspace') or s.get('herdr_server') or 'default'
status = s.get('status', '?')
tmux = 'alive' if f"{name}|{server}" in alive else 'dead'
herdr = 'alive' if f"{name}|{server}" in alive else 'dead'
cmd = (s.get('pane') or {}).get('cmd', '?')
res = resume_on_disk(s)
jid, jstatus = get_job_status(s)
drs = ','.join(drift_by_name.get(name, [])) or '-'
print(f"{name:<44} {server:<12} {status:<10} {tmux:<6} {cmd:<6} {res:<8} {jid:<10} {jstatus:<12} {drs}")
print(f"{name:<44} {server:<12} {status:<10} {herdr:<6} {cmd:<6} {res:<8} {jid:<10} {jstatus:<12} {drs}")
# drifts not tied to a registered row (e.g. class B unregistered, class D cache)
known = {s.get('name') for s in sessions}
extra = [dr for dr in drift.get('drifts', []) if dr['name'] not in known]
@@ -136,5 +213,5 @@ if extra:
for dr in extra:
print(f" [{dr['class']}] {dr['msg']}")
print("=" * 136)
print(f"alive tmux: {sorted(alive)}")
print(f"alive herdr: {sorted(alive)}")
PYEOF
+20 -19
View File
@@ -1,35 +1,35 @@
---
name: multi-agent-mux-stop
description: "Stop an agent tmux 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."
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: 1.0.0
author: godopu
license: MIT
platforms: [linux, macos]
environments: [terminal, tmux]
environments: [terminal, herdr]
metadata:
hermes:
tags: [agent, tmux, claude, antigravity, agy, multi-agent, stop, terminate, cleanup]
tags: [agent, herdr, claude, antigravity, agy, multi-agent, stop, terminate, cleanup]
related_skills: [multi-agent-mux-create, multi-agent-mux-resume, multi-agent-mux-monitor]
prereq_skills: [multi-agent-mux-create, multi-agent-mux-resume]
---
# Multi-Agent Stop — Stop an Agent tmux Session
# Multi-Agent Stop — Stop an Agent herdr Session
> **Companion skills**: `multi-agent-mux-create` (start), `multi-agent-mux-resume` (re-attach), `multi-agent-mux-monitor` (live status).
> **Tmux Isolation**: `stop` 명령은 YAML의 `tmux_server` 필드를 자동으로 파싱하여 해당 격리 서버의 세션을 안전하게 종료(kill)하므로, `TMUX_SERVER_NAME` 환경변수를 수동으로 지정할 필요가 없습니다.
> **Herdr Isolation**: `stop` 명령은 YAML의 `herdr_server` 필드를 자동으로 파싱하여 해당 격리 서버의 세션을 안전하게 종료(kill)하므로, `HERDR_SERVER_NAME` 환경변수를 수동으로 지정할 필요가 없습니다.
> **Single source of truth**: `./.mam/agent-sessions.yaml`.
## What this skill does
Stop an agent's tmux session gracefully, resolve and store the conversation ID, and **mark the YAML entry (status=stopped)**. Preserves:
Stop an agent's herdr session gracefully, resolve and store the conversation ID, and **mark the YAML entry (status=stopped)**. Preserves:
- The tmux session's recorded `pane.pid / cmd / cwd / mcp_attachments` for audit
- The herdr session's recorded `pane.pid / cmd / cwd / mcp_attachments` for audit
- The agent's on-disk conversation (claude `*.jsonl`, agy `conversations/*.db`) — so the user can `multi-agent-mux-resume` later
- The `start_command` so a future `multi-agent-mux-create --session <name>` reproduces the same tmux spec
- The `start_command` so a future `multi-agent-mux-create --session <name>` reproduces the same herdr spec
The stop command is always **graceful by default**:
1. Sends exit keys to the agent TUI (`/exit` for Claude, `Exit` for Agy) and waits 3 seconds.
2. If still alive, issues `tmux kill-session` (SIGTERM) and waits 5 seconds.
2. If still alive, issues `herdr kill-session` (SIGTERM) and waits 5 seconds.
3. If still alive, kills the pane PID via SIGKILL (`kill -9`) as a last resort.
4. Auto-captures the conversation ID into the row (`claude_session_id_own`/`agy_conversation_id_own`) before killing, ensuring the next resume uses a race-free tier-1 lookup.
@@ -43,7 +43,7 @@ AGENT_SESSIONS_YAML=.mam/agent-sessions.yaml
python3 -c "
import yaml
d = yaml.safe_load(open('$AGENT_SESSIONS_YAML'))
names = [s['name'] for s in d.get('tmux_sessions', [])]
names = [s['name'] for s in d.get('herdr_sessions', [])]
if '$SESSION_NAME' not in names:
print('NOT in YAML — refusing to stop (no audit trail). Use multi-agent-mux-create first, or pass --force-no-yaml.')
raise SystemExit(1)
@@ -53,7 +53,7 @@ if '$SESSION_NAME' not in names:
ALREADY=$(python3 -c "
import yaml
d = yaml.safe_load(open('$AGENT_SESSIONS_YAML'))
s = [x for x in d['tmux_sessions'] if x['name']=='$SESSION_NAME'][0]
s = [x for x in d['herdr_sessions'] if x['name']=='$SESSION_NAME'][0]
print(s.get('status', 'unknown'))
")
if [ "$ALREADY" = "stopped" ]; then
@@ -95,7 +95,7 @@ If `--purge-conversation` is used: `status: terminated`, `terminated_at`, `termi
The script:
1. Verifies the session is in agent-sessions.yaml
2. If `delegate_job_id` is set, automatically publishes a `progress --detail "terminating"` event to the multi-agent-mux-delegate-job registry
3. Captures the `last_visible_status` from `tmux capture-pane` (so we have a final TUI snapshot for audit)
3. Captures the `last_visible_status` from `herdr capture-pane` (so we have a final TUI snapshot for audit)
4. Attempts graceful exit keys → SIGTERM kill-session → SIGKILL fallback
5. For `purge-conversation`: deletes `~/.claude/projects/.../jsonl` (claude) or `~/.gemini/antigravity-cli/conversations/...db` + `brain/...` (agy)
6. Updates the YAML entry and SQLite database atomically
@@ -104,21 +104,22 @@ The script:
## Pitfalls
- **Don't delete on-disk artifacts by default** — the agent's `*.jsonl` / `conversations/*.db` is the data that `multi-agent-mux-resume` needs. `--purge-conversation` is for when the user is genuinely done with the conversation and wants zero recovery chance.
- **YAML is append-only until you write a stop** — if a previous run left the entry as `running` but tmux is actually dead (crash, host reboot), the YAML is stale. Running `multi-agent-mux-stop` will detect "tmux already dead, just update YAML" and proceed.
- **YAML is append-only until you write a stop** — if a previous run left the entry as `running` but herdr is actually dead (crash, host reboot), the YAML is stale. Running `multi-agent-mux-stop` will detect "herdr already dead, just update YAML" and proceed.
- **Don't delete the `claude_session_id_own: null` placeholder** — when the user creates a fresh session with `multi-agent-mux-create` and never sent a message, the entry has `claude_session_id_own: null`. Stopping must preserve that field.
- **Monitor skill may still be tracking** — if `multi-agent-mux-monitor` is running a heartbeat loop, stopping a session while it watches will trigger its `tmux ls != yaml` reconciliation. That's expected — let the monitor run, it will mark the entry as `terminated` on its own.
- **Monitor skill may still be tracking** — if `multi-agent-mux-monitor` is running a heartbeat loop, stopping a session while it watches will trigger its `herdr ls != yaml` reconciliation. That's expected — let the monitor run, it will mark the entry as `terminated` on its own.
## Verification
```bash
# 1. tmux gone
tmux has-session -t "$SESSION_NAME" 2>/dev/null && echo "STILL ALIVE" || echo "OK: tmux gone"
# 1. herdr gone (real native command — `herdr has-session` is a lib.sh
# tmux-compat pseudo-command and needs `source .agents/skills/lib.sh` first)
herdr agent get "$SESSION_NAME" >/dev/null 2>&1 && echo "STILL ALIVE" || echo "OK: herdr gone"
# 2. YAML has stopped entry
python3 -c "
import yaml
d = yaml.safe_load(open('$AGENT_SESSIONS_YAML'))
s = [x for x in d['tmux_sessions'] if x['name']=='$SESSION_NAME'][0]
s = [x for x in d['herdr_sessions'] if x['name']=='$SESSION_NAME'][0]
assert s['status'] == 'stopped', f'expected stopped, got {s[\"status\"]}'
assert s.get('stopped_at'), 'missing stopped_at'
print(f'OK: stopped at {s[\"stopped_at\"]}')
@@ -131,6 +132,6 @@ print(f' preserved: pane.pid={s[\"pane\"][\"pid\"]}, cmd={s[\"pane\"][\"cmd\"]}
## When NOT to use this skill
- **Just detaching** → `tmux detach` (Ctrl-B d) or just close the terminal. The tmux session keeps running.
- **Stopping the agent inside but keeping tmux** → send `Ctrl-C` or `/exit` (claude) / `Ctrl-D` (agy) via `tmux send-keys`. The tmux session stays but the agent process is gone.
- **Just detaching** → there's no `herdr detach` CLI command; press the herdr detach keybinding inside the pane, or just close the terminal. The herdr session keeps running.
- **Stopping the agent inside but keeping herdr** → send `Ctrl-C` or `/exit` (claude) / `Ctrl-D` (agy) via `herdr agent send <target> <text>` (real native command; `herdr send-keys` is a lib.sh tmux-compat pseudo-command that needs `source .agents/skills/lib.sh` first). The herdr session stays but the agent process is gone.
- **Replacing an existing session with a new one** → `multi-agent-mux-stop` first, then `multi-agent-mux-create`.
@@ -5,9 +5,9 @@
# [--mode soft|hard] [--purge-conversation] [--yes]
#
# mode:
# soft — YAML 을 status=archived 로 마크, tmux 세션은 그대로 둠 (P1-A:
# terminated 는 tmux 가 실제로 죽은 상태에만 사용)
# hard — tmux kill-session + YAML status=terminated
# soft — YAML 을 status=archived 로 마크, herdr 세션은 그대로 둠 (P1-A:
# terminated 는 herdr 가 실제로 죽은 상태에만 사용)
# hard — herdr kill-session + YAML status=terminated
# --purge-conversation: --mode hard 일 때만. 삭제 대상 세션의 *워크스페이스에
# 격리된* conversation artifact 만 삭제 (P0-C). 전역
# agent_identities 를 참조하지 않음. resume 불가.
@@ -27,8 +27,10 @@
# Exit codes:
# 0 = success (or already-stopped no-op) | 1 = YAML not found / not registered
# 2 = invalid args | 3 = interactive confirmation required (--yes 누락)
# 4 = purge aborted (herdr session survived the kill chain)
set -euo pipefail
# shellcheck disable=SC1091
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
usage() {
@@ -65,10 +67,23 @@ while [ $# -gt 0 ]; do
*) echo "ERROR: unknown arg: $1" >&2; usage; exit 2 ;;
esac
done
if [ -n "$AGENT" ]; then
case "$AGENT" in
claude|agy|hermes|cline) ;;
*) echo "ERROR: invalid agent type '$AGENT'. Allowed types are: claude, agy, hermes, cline." >&2; exit 2 ;;
esac
fi
[ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; usage; exit 2; }
[ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found" >&2; exit 1; }
export TMUX_SERVER_NAME="$(resolve_tmux_server "$SESSION_NAME")"
# Implement the purging-<session> file lock mechanism
if [ "$PURGE" = "1" ]; then
touch "$WORKSPACE_ROOT/.mam/purging-$SESSION_NAME"
trap 'rm -f "$WORKSPACE_ROOT/.mam/purging-$SESSION_NAME"' EXIT
fi
HERDR_SERVER_NAME="$(resolve_herdr_session "$SESSION_NAME")"
export HERDR_SERVER_NAME
# --agent 미지정 시 이름 suffix 로 fallback (P1-F)
if [ -z "$AGENT" ]; then
@@ -83,44 +98,18 @@ fi
# 세션이 YAML 에 있는지 + 해당 row 의 워크스페이스 cwd 및 delegate_job_id 추출.
# JSON 으로 emit — cwd 에 '|' 가 들어가도 안전 (review item 7; 기존 cwd|jid 파서 대체).
MAPPED_DATA=$(env_python "$AGENT_SESSIONS_YAML" SESSION_NAME="$SESSION_NAME" <<'PYEOF'
import os, sys, json, yaml, sqlite3
MAPPED_DATA=$(MAM_STATE_JSON="$(load_state_json)" SESSION_NAME="$SESSION_NAME" python3 -c "
import sys, os, json
name = os.environ['SESSION_NAME']
yaml_path = os.environ['YAML_PATH']
db_path = os.path.splitext(yaml_path)[0] + '.db'
d = {}
try:
if os.path.exists(db_path):
conn = sqlite3.connect(db_path, timeout=60.0)
try:
row = conn.execute('SELECT data FROM sessions WHERE name=?', (name,)).fetchone()
if row:
s = json.loads(row[0])
cwd = (s.get('pane') or {}).get('cwd', '')
jid = s.get('delegate_job_id', '') or ''
print(json.dumps({"cwd": cwd, "job_id": jid}))
raise SystemExit(0)
except sqlite3.OperationalError:
pass
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
if row:
d = json.loads(row[0])
conn.close()
elif os.path.exists(yaml_path):
with open(yaml_path) as f:
d = yaml.safe_load(f) or {}
except Exception:
pass
for s in d.get('tmux_sessions', []):
d = json.loads(os.environ.get('MAM_STATE_JSON', '{}'))
for s in d.get('herdr_sessions', []):
if s.get('name') == name:
cwd = (s.get('pane') or {}).get('cwd', '')
jid = s.get('delegate_job_id', '') or ''
print(json.dumps({"cwd": cwd, "job_id": jid}))
raise SystemExit(0)
raise SystemExit(7)
PYEOF
) || {
print(json.dumps({'cwd': cwd, 'job_id': jid}))
sys.exit(0)
sys.exit(7)
") || {
echo "ERROR: session '$SESSION_NAME' not in $AGENT_SESSIONS_YAML" >&2
exit 1
}
@@ -154,17 +143,17 @@ fi
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
NOW_EPOCH=$(date +%s)
# tmux 상태 + 마지막 TUI 스냅샷 (살아있을 때만; capture-pane 내용은 env 로만 전달)
TMUX_ALIVE=0
# herdr 상태 + 마지막 TUI 스냅샷 (살아있을 때만; capture-pane 내용은 env 로만 전달)
HERDR_ALIVE=0
LAST_STATUS=""
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
TMUX_ALIVE=1
LAST_STATUS=$(tmux capture-pane -t "$SESSION_NAME" -p -S -10 2>/dev/null | tr '\n' ' ' | head -c 500 || true)
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
HERDR_ALIVE=1
LAST_STATUS=$(herdr capture-pane -t "$SESSION_NAME" -p -S -10 2>/dev/null | tr '\n' ' ' | head -c 500 || true)
fi
# --capture-id: kill 직전에 conversation id 를 해결 (process/jsonl 이 아직 살아있을 때).
# find_workspace_uuid 가 tier-1(row) -> tier-2(workspace-scoped disk scan) -> tier-3(cache)
# 를 알아서 시도하므로 tmux 생사와 무관하게 동작.
# 를 알아서 시도하므로 herdr 생사와 무관하게 동작.
CAPTURED_UUID=""
if [ "$CAPTURE_ID" = "1" ] && [ -n "$TARGET_CWD" ]; then
CAPTURED_UUID=$(capture_conversation_id "$AGENT" "$TARGET_CWD" "$SESSION_NAME" || true)
@@ -180,7 +169,7 @@ delegate_publish_event "$DELEGATE_JOB_ID" progress "terminating"
# --graceful: send-keys 로 정상 종료 유도 → 폴백 체인 (SIGTERM → SIGKILL).
graceful_stop() {
local pane_pid exitkey
pane_pid=$(tmux list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
pane_pid=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true)
case "$AGENT" in
claude) exitkey="/exit" ;;
agy) exitkey="Exit" ;;
@@ -191,14 +180,14 @@ graceful_stop() {
echo "graceful: send-keys '$exitkey' to $SESSION_NAME"
send_keys_safe "$SESSION_NAME" "$exitkey" "stop$$" || echo "graceful: safe delivery failed (rc=$?) — falling back to kill chain"
_wait_session_gone "$SESSION_NAME" 5 || true
if ! tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
if ! herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "graceful: exited cleanly"
return 0
fi
echo "graceful: still alive → kill-session (SIGTERM)"
tmux kill-session -t "$SESSION_NAME" 2>/dev/null || true
herdr kill-session -t "$SESSION_NAME" 2>/dev/null || true
_wait_session_gone "$SESSION_NAME" 8 || true
if ! tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
if ! herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "graceful: terminated after kill-session"
return 0
fi
@@ -206,14 +195,29 @@ graceful_stop() {
[ -n "$pane_pid" ] && kill -9 "$pane_pid" 2>/dev/null || true
}
# tmux 종료: graceful 이면 폴백 체인, 아니면 기존 hard kill.
if [ "$GRACEFUL" = "1" ] && [ "$TMUX_ALIVE" = "1" ]; then
# herdr 종료: graceful 이면 폴백 체인, 아니면 기존 hard kill.
if [ "$GRACEFUL" = "1" ] && [ "$HERDR_ALIVE" = "1" ]; then
graceful_stop
elif [ "$TMUX_ALIVE" = "1" ]; then
tmux kill-session -t "$SESSION_NAME"
echo "killed tmux: $SESSION_NAME"
elif [ "$HERDR_ALIVE" = "1" ]; then
herdr kill-session -t "$SESSION_NAME"
echo "killed herdr: $SESSION_NAME"
else
echo "tmux already dead, just updating YAML"
echo "herdr already dead, just updating YAML"
fi
# Purge pre-gate: 레코드 제거는 herdr 사망이 확인된 경우에만 허용한다.
# (kill 체인은 best-effort — 세션이 살아남으면 monitor drift-B 가
# 레코드 없는 세션을 running 으로 자동 재등록해 purge 가 조용히 뒤집힌다)
if [ "$PURGE" = "1" ] && [ "$HERDR_ALIVE" = "1" ]; then
_wait_session_gone "$SESSION_NAME" 5 || true # SIGKILL 폴백의 비동기 회수 윈도우 흡수
if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "ERROR: session '$SESSION_NAME' is still alive after the kill chain." >&2
echo " Refusing registry removal — records preserved (no state was modified)." >&2
echo " Diagnose the stuck TUI (herdr session attach '$SESSION_NAME'), then re-run" >&2
echo " stop_session.sh --purge-conversation --yes (retry is safe/idempotent)." >&2
delegate_publish_event "$DELEGATE_JOB_ID" error "purge aborted: herdr session still alive"
exit 4
fi
fi
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
@@ -234,7 +238,7 @@ reason = os.environ.get('REASON', '') or 'manual_stop'
captured = os.environ.get('CAPTURED_UUID', '').strip()
target = None
for s in d.get('tmux_sessions', []):
for s in d.get('herdr_sessions', []):
if s.get('name') == name:
target = s
break
@@ -242,12 +246,7 @@ if target is None:
print(f"ERROR: disappeared during script: {name}", flush=True)
raise SystemExit(1)
if purge:
target['status'] = 'terminated'
target['terminated_at'] = now
target['terminated_at_epoch'] = int(os.environ['NOW_EPOCH'])
target['termination_mode'] = 'purge'
else:
if not purge:
target['status'] = 'stopped'
target['stopped_at'] = now
target['stopped_at_epoch'] = int(os.environ['NOW_EPOCH'])
@@ -350,9 +349,13 @@ elif purge and not purge_uuid:
print("WARN: --purge-conversation requested but no workspace-scoped UUID resolved; nothing purged", flush=True)
if purge:
target['resumable'] = False
print(f"updated: {name} status={target['status']}", flush=True)
d['herdr_sessions'] = [s for s in d.get('herdr_sessions', []) if s.get('name') != name]
if purge_uuid:
print(f"removed: {name} (registry entry fully purged from YAML+DB)", flush=True)
else:
print(f"removed: {name} (registry entry purged from YAML+DB; WARN: conversation artifacts unresolved — disk copies may remain)", flush=True)
else:
print(f"updated: {name} status={target['status']}", flush=True)
PYEOF
delegate_publish_event "$DELEGATE_JOB_ID" completed "session terminated"
+7
View File
@@ -21,3 +21,10 @@ __pycache__/
# 빌드/배포 HTML 산출물
.agents/skills/multi-agent-mux-delegate-job/USER_MANUAL.html
.agents/skills/multi-agent-mux-delegate-job/mqtt-broker-setup.html
# Flutter/Dart 빌드 산출물 및 IDE 파일
.dart_tool/
build/
.idea/
*.iml
ephemeral/
+1 -1
View File
@@ -67,4 +67,4 @@ Strong success criteria let you loop independently. Weak criteria ("make it work
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
Read [MULTI_AGENT_RULES.md](.agents/MULTI_AGENT_RULES.md) (or [Korean version](.agents/MULTI_AGENT_RULES.ko.md)) and [multi_agent_workflow.md](.agents/multi_agent_workflow.md) first before working and follow the instructions for orchestration and collaboration.
Read [MULTI_AGENT_RULES.md](.agents/MULTI_AGENT_RULES.md) (or [Korean version](.agents/MULTI_AGENT_RULES.ko.md)) and [multi-agent-mux-loop/SKILL.md](.agents/skills/multi-agent-mux-loop/SKILL.md) first before working and follow the instructions for orchestration and collaboration.
+2 -2
View File
@@ -58,10 +58,10 @@ curl -fsSL https://git.godopu.com/tmpl/multi-agent-mux/raw/branch/main/deploy/in
```bash
# .env.example를 .env로 자동 복제 (이미 존재하면 덮어쓰지 않고 보호됨)
./scripts/generate-env.sh
./deploy/generate-env.sh
# 만약 강제로 덮어쓰고 백업을 생성하고 싶은 경우:
./scripts/generate-env.sh --force
./deploy/generate-env.sh --force
```
### 단계 3.2: 환경 변수 수정 및 설정
+2 -2
View File
@@ -58,10 +58,10 @@ Run the environment template copy script provided in the project root:
```bash
# Automatically copy .env.example to .env (does not overwrite if it already exists)
./scripts/generate-env.sh
./deploy/generate-env.sh
# To force overwrite and create a backup of the existing .env:
./scripts/generate-env.sh --force
./deploy/generate-env.sh --force
```
### Step 3.2: Modify Environment Variables
+102
View File
@@ -0,0 +1,102 @@
# CLAUDE_WORK_LOGS.md
작업 일자: 2026-07-19
작업자: Claude (herdr 세션 `default:w3:p1`, `claude`)
## 개요
`.agents/skills/` 아래 multi-agent-mux 스킬 세트가 tmux 시절 문법(`capture-pane`, `list-panes`, `kill-session`, `send-keys`, `session attach` 등)을 실제 herdr CLI 문법인 것처럼 잘못 사용하고 있던 문제를 발견하고 전수 조사·수정했다. 추가로 `HERDR_SERVER_NAME` 격리 기능이 진짜 herdr 서버 격리가 아니라 workspace label 흉내에 불과했던 것을 실제 `herdr --session <name>` 격리로 재설계했고, 프롬프트 주입 검증 로직(`send_keys_safe`)의 멀티바이트/줄바꿈 버그도 잡았다. 모든 수정은 herdr 실제 바이너리(v0.7.4) 대조 + 디스포저블 herdr 세션 실구동 테스트로 검증했으며, 일부는 실제 cline reviewer 에이전트(`canary-projects-multi-agent-mux-reviewer-cline`)에게 `multi-agent-mux-delegate-job`으로 위임하여 독립 교차검증(`[VERDICT: PASS]`)까지 받았다.
커밋은 아직 하지 않았다 (전부 워킹 트리 변경 상태).
---
## 1. 수정한 내용
### 1.1 `.agents/skills/lib.sh` (공유 라이브러리)
- **`mam_herdr` shim의 `kill-session` 케이스**: `herdr session stop/delete "$sess"` (herdr의 `session`은 서버 전체 단위 개념이라 개별 에이전트 이름으로는 절대 못 찾음, 항상 조용히 실패) → `agent get`으로 `pane_id`를 먼저 해석한 뒤 `pane close <pane_id>`로 교체.
- **`send-keys` 케이스**: 동일하게 `pane send-keys "$sess" ...`(pane_id가 아니라 이름을 넘겨서 항상 실패)를 `agent get`으로 `pane_id` 선해석 후 `pane send-keys <pane_id> ...`로 교체.
- **`list-panes` 케이스**: JSON 경로가 `d.get('pane', d)`로 완전히 틀려있었음 (실제 응답은 `result.agent.{cwd, pane_id, agent}`) → cwd/cmd는 `agent get`에서, pid는 별도로 `pane process-info --pane <pane_id>`에서 조회하도록 재작성. 이 버그로 인해 `create_session.sh`/`update_yaml_resumed.sh``PANE_PID`/`PANE_CWD`/`PANE_CMD` 캡처가 전부 항상 빈 값이었음.
- **`HERDR_SERVER_NAME` 격리를 진짜 herdr session 격리로 통일**:
- `_init_herdr_isolation`에 세션 부트스트랩 로직 추가 — `HERDR_SERVER_NAME != default`일 때 `herdr session list`로 확인 후 없으면 `herdr --session <name> server`를 헤드리스로 백그라운드 기동 (인터랙티브 launch가 걸리는 "nested herdr is disabled" 제한을 회피).
- `_real_herdr()` 헬퍼 도입 — 모든 실제 herdr 호출에 `--session "$HERDR_SERVER_NAME"`를 자동 스코핑.
- `new-session` 케이스에서 기존의 "workspace label 매칭으로 격리 흉내"(진짜 격리가 전혀 아니었음, `agent list`가 서버 전역이라 아무 효과 없었음) 로직을 통째로 제거하고, 활성 세션 안에 fresh workspace를 만들도록 단순화.
- `resolve_herdr_workspace()` 단순화 — workspace_id를 찾던 로직 제거, YAML에 저장된 세션 라벨을 그대로 반환 (호출자 3곳 — resume/stop/update_yaml_resumed — 호환 유지를 위해 함수명은 유지).
- **`send_keys_safe()` paste 검증 로직 버그 2건 수정**:
1. 마커를 `tail -c 24`(바이트 기준)로 잘라서 한글 등 멀티바이트 UTF-8 문자를 중간에서 자를 위험 → `python3` 문자 기준 슬라이싱(`[-24:]`)으로 교체.
2. 렌더링된 pane은 터미널 폭에 맞춰 자동 줄바꿈하는데(cline은 이어지는 줄에 공백 들여쓰기까지 추가) 마커가 그 지점에 걸리면 `grep -F`(줄 단위)가 못 찾음 → 매칭 직전에 `tr -d '[:space:]'`로 공백/개행을 전부 제거하고 매칭 (paste 확인 지점 + Enter 제출 확인 지점 둘 다 적용).
### 1.2 `.agents/skills/multi-agent-mux-create/`
- `SKILL.md`: 문서 예시 명령 정정(`herdr attach``agent attach` 등), 존재하지 않는 `list-sessions` 제거, 격리 섹션에 실제 메커니즘(헤드리스 세션 부트스트랩) 설명 추가.
- `scripts/create_session.sh`: YAML에 저장하는 `attach_command`/`kill_command`/`start_command` 템플릿이 `herdr session attach/stop/delete`(서버 전체 단위 명령을 개별 에이전트 이름으로 잘못 호출)로 깨져 있던 것을 `HERDR_SERVER_NAME=<server> herdr ...` 형태로 수정.
### 1.3 `.agents/skills/multi-agent-mux-resume/`
- `SKILL.md`: Verification 블록의 pseudo-명령 정정, `herdr attach -t`(존재하지 않는 문법) → `herdr agent attach`.
- (스크립트 자체는 버그 없었음 — herdr 관련 이슈 전수조사 완료.)
### 1.4 `.agents/skills/multi-agent-mux-monitor/`
- `SKILL.md`: `herdr ls`/`list-panes` 문서 예시 정정.
- `scripts/reconcile.sh`: `attach_command` 템플릿의 존재하지 않는 `attach -t``agent attach` 수정. (이 파일은 원래 다른 목적 — purging 세션 자동등록 스킵 — 으로 이미 일부 수정되어 있었음.)
### 1.5 `.agents/skills/multi-agent-mux-status/`
- `SKILL.md`: `herdr has-session`/`list-panes` 설명 정정, 드리프트 안내 메시지의 `herdr kill-session` 제안을 스킬 경유 안내로 변경.
### 1.6 `.agents/skills/multi-agent-mux-stop/`
- `SKILL.md`: Verification 블록 정정.
- `scripts/stop_session.sh`: (원래 다른 목적으로 이미 수정 중이던) purge 락 파일(`purging-<session>`) 추가, `--agent` 값 검증 추가.
### 1.7 `.agents/skills/multi-agent-mux-delegate-job/`
- `multi-agent-mux-delegate-job` (메인 스크립트) `run_agent()` 함수의 herdr 버그 3건:
1. `lib.sh``has-session` 체크보다 늦게 source하고 있어서 체크 시점엔 아직 shim이 아니라 진짜 바이너리 → 존재하지 않는 `has-session` 서브커맨드 호출 → 항상 실패. `source lib.sh`를 파일 최상단으로 이동.
2. `HERDR_SERVER_NAME`을 레지스트리에서 자동 해석 안 하고 호출자가 export해뒀길 기대함 → 격리 세션에 위임 시 실패 가능 → `resolve_herdr_workspace "$sess"` 자동 호출 추가.
3. 안내 메시지의 `herdr session attach`(서버 전체 단위) → `herdr agent attach`로 수정.
- `SKILL.md`: "프롬프트는 영어/ASCII/짧게, 한국어 상세 내용은 마크다운 브리핑 파일로" 운영 규칙 추가 (send_keys_safe 버그의 실질적 완화책이자, `submit`의 기본 instructions 템플릿이 이미 따르고 있던 패턴을 명문화).
### 1.8 기타
- 루트 `.gitignore`에 Flutter/Dart 빌드 산출물, IDE 파일 패턴 추가 (`multi-agent-mux-ui`), 커밋 완료(`cccc30a`).
- `.mam/agent-sessions.yaml`의 stale 3개 세션(herdr 죽었는데 YAML엔 running으로 남아있던 것) `reconcile.sh`로 정리 → `terminated` 처리.
---
## 2. 검증된 스킬
### 2.1 cline reviewer 독립 교차검증 완료 (`[VERDICT: PASS]`)
**1차 — job `14943484`** (`.mam/jobs/14943484/cline-reports/herdr-cli-fix-review.md`)
- 대상: `lib.sh`(kill-session/send-keys/list-panes 수정 + session 격리 통일), `multi-agent-mux-create/scripts/create_session.sh`, `multi-agent-mux-monitor/scripts/reconcile.sh`, `multi-agent-mux-stop/scripts/stop_session.sh`, 5개 SKILL.md (create/resume/monitor/status/stop)
- 방법: 실제 herdr v0.7.4 바이너리로 10개 명령 문법 + 6개 JSON 스키마 직접 대조, 실구동 테스트, default 경로 회귀 테스트, pipefail 런타임 테스트, 정적분석.
- 잔여 LOW 2건 + INFO 1건 (전부 non-blocking, 비회귀).
**2차 — job `80040741`** (`.mam/jobs/80040741/cline-agent-reports/report-final.md`, 정식 delegate-job 프로토콜로 완주)
- 대상: `lib.sh``send_keys_safe()` 공백 정규화 수정, `multi-agent-mux-delegate-job/SKILL.md`의 새 규칙.
- 방법: python3 슬라이싱 시뮬레이션(엣지 케이스 15개) + 실제 bash 파이프라인 재현 + 실제 delegate-job 기본 템플릿으로 회귀 테스트.
- 잔여 INFO 1건(빈 marker_norm 위양성 가능성 — 실사용 경로에서 도달 불가로 확인, non-blocking).
### 2.2 실제 프로덕션 사용으로 검증 (정식 리뷰 없음)
- **`multi-agent-mux-delegate-job` 메인 스크립트의 `run_agent()` 수정 3건**: 정식 리뷰(job `e84698d7`)가 세션 hang으로 유실됐지만, 그 이후 실제로 여러 차례 `submit`을 성공 실행하면서(격리 세션 안의 살아있는 에이전트를 정확히 찾아내는 것 포함) 실사용 검증됨.
- **`multi-agent-mux-stop/scripts/stop_session.sh`**: 실제로 hung된 `canary-projects-multi-agent-mux-reviewer-cline` 세션을 대상으로 진짜 stop을 실행 — graceful exit-keys → kill-session(수정된 pane close 경로) → conversation id 캡처까지 전 과정 실전 확인.
- **`multi-agent-mux-resume/scripts/resume_session.sh`**: 같은 세션을 동일 conversation id로 실제 resume하여 대화 이력이 정확히 복원되는 것 확인 (2회 반복).
---
## 3. 추후 검증할 스킬
| 스킬 | 상태 |
|---|---|
| **`multi-agent-mux-delegate-job` 메인 스크립트** | 정식 cline 리뷰(job `e84698d7`)가 세션 hang으로 미완료. 재위임하면 공식 PASS 서명을 받을 수 있음. |
| **`multi-agent-mux-status/scripts/status.sh`** | `SKILL.md` 문구만 검토됨. 스크립트 자체는 이번 작업에서 한 번도 직접 실행 안 함 (내부적으로 쓰는 `reconcile.sh`는 검증됨). |
| **`multi-agent-mux-loop`** | 완전히 손대지 않음. `run_loop.sh`가 herdr를 직접 다루지 않고 `delegate-job`을 통해서만 위임하는 구조라 영향권 밖일 가능성 높지만 미확인. |
| **`multi-agent-mux-ui`** (Flutter/Dart) | `.gitignore` 정리만 진행. `mam_core``status.sh`를 래핑하는 로직 자체는 이번 herdr 문법 수정과 별개로 전혀 검토 안 함. |
---
## 4. 추후 작업
1. **커밋**: 지금까지의 모든 수정(11개 파일, lib.sh + 6개 스킬)이 워킹 트리에 uncommitted 상태. 커밋 여부/단위 결정 필요.
2. **`multi-agent-mux-delegate-job` 메인 스크립트 정식 리뷰 재위임** — job `e84698d7` 재시도 (섹션 3 참조).
3. **`send_keys_safe`의 INFO급 잔여 이슈** — 빈 `marker_norm`일 때 `grep -Fq ''`가 항상 매칭되는 위양성 가능성. 실사용 경로에서 도달 불가로 확인됐지만, 원하면 방어적으로 빈 텍스트 가드를 추가할 수 있음.
4. **`multi-agent-mux-status/scripts/status.sh` 실제 실행 검증** — 아직 한 번도 직접 실행 안 됨.
5. **`multi-agent-mux-loop`/`multi-agent-mux-ui` 전수 조사** — 이번 herdr 문법 감사 범위 밖.
6. **`.mam/agent-sessions.yaml``herdr_workspace`/`herdr_server` 필드명 정리** — 현재 `resolve_herdr_workspace()`가 반환하는 값의 실제 의미(workspace_id가 아니라 session 라벨)와 함수명이 불일치하는 상태(호출자 호환을 위해 이름은 유지함). 장기적으로는 필드명/함수명을 실제 의미(session)에 맞게 리네이밍하는 리팩터링을 고려할 수 있음.
+1 -1
View File
@@ -29,7 +29,7 @@
| ~~**FW-D1**~~ | ✅ **해결됨 (2026-06-24)** — 설치 스크립트가 더 이상 in-place 추출하지 않음 | — | — | **배포 / 안전성**: `deploy/install.sh`는 이제 다운로드를 `mktemp -d` 임시 디렉터리에 스테이징하고 `.agents/skills/lib.sh` 존재를 검증한 뒤, 런타임 자산(`.agents/`, `.env.example`)만 per-file no-clobber 가드(`[ ! -e ]`)로 타겟에 복사한다. 따라서 기존 타겟 파일이 항상 우선하며 레포 개발 문서가 워크스페이스에 들어가지 않는다. fetch 후 sanity 체크도 디렉터리가 아닌 파일을 검사하도록 변경 | 완료 |
| **FW-D2** | 설치 스크립트가 다운로드하는 소스를 sourcing 전에 고정 및 검증 | P2 (Medium) | 소 | **배포 / 공급망**: 설치 스크립트는 네트워크로 이동형 `main` 브랜치를 clone/추출하고, 워크스페이스는 이후 해당 셸 스크립트(`lib.sh` 등)를 `source`한다. *부분 해결 (2026-06-24): 복사 전에 스테이징된 트리에 `.agents/skills/lib.sh`가 존재하는지 검증함.* **남은 작업:** 릴리스 태그나 커밋 SHA로 고정하고 공개 체크섬을 검증하여 구조적 존재 여부뿐 아니라 콘텐츠 무결성까지 보장 | 없음 |
| **FW-D3** | `install.sh``lib.sh` 간 NFS 감지 로직 중복 제거 | P2 (Medium) | 소 | **배포 / 이식성**: `deploy/install.sh``lib.sh::_check_is_nfs`에 이미 존재하는 GNU 전용 `df --output=target` + `mount` NFS 검사를 재구현한다. FW-P1 이식성 수정이 이 두 번째 사본까지 포함하도록, 단일 공유 헬퍼로 추출하여 macOS/BSD에서 두 호출 지점 모두 올바르게 동작하게 한다 | FW-P1 |
| **FW-D4** | CI shellcheck 커버리지 공백 해소 | P3 (Low) | 소 | **배포 / 품질**: `deploy/gitea-ci.yml`5개 스크립트만 shellcheck하며, `status.sh`, `resolve_session_id.sh`, `update_yaml_resumed.sh`, `scripts/generate-env.sh`는 검사되지 않는다. 추적되는 모든 `*.sh`를 glob 처리하여 신규 스크립트가 자동 포함되도록 한다 | 없음 |
| **FW-D4** | CI shellcheck 커버리지 공백 해소 | P3 (Low) | 소 | **배포 / 품질**: `deploy/gitea-ci.yml`9개 스크립트만 shellcheck하며, `status.sh`, `resolve_session_id.sh`, `update_yaml_resumed.sh`는 검사되지 않는다. 추적되는 모든 `*.sh`를 glob 처리하여 신규 스크립트가 자동 포함되도록 한다 | 없음 |
---
+1 -1
View File
@@ -30,7 +30,7 @@ Below is the list of pending future work items. These items were proposed based
| ~~**FW-D1**~~ | ✅ **RESOLVED (2026-06-24)** — installer no longer extracts in-place | — | — | **Deploy / Safety**: `deploy/install.sh` now stages the download into a `mktemp -d` dir, verifies `.agents/skills/lib.sh` is present, then copies only the runtime assets (`.agents/`, `.env.example`) into the target with per-file no-clobber guards (`[ ! -e ]`), so existing target files always win and repo dev docs never land in the workspace. The post-fetch sanity check now tests a file, not just the directory. | Done |
| **FW-D2** | Pin and verify the source the installer downloads before sourcing it | P2 (Medium) | Small | **Deploy / Supply-chain**: The installer clones/extracts the moving `main` branch over the network, and the workspace later `source`s those shell scripts (`lib.sh` et al.). *Partially addressed (2026-06-24): the staged tree is now verified to contain `.agents/skills/lib.sh` before any file is copied.* **Remaining:** pin to a release tag or commit SHA and/or verify a published checksum so the fetched content is integrity-checked, not merely structurally present. | None |
| **FW-D3** | De-duplicate NFS detection between `install.sh` and `lib.sh` | P2 (Medium) | Small | **Deploy / Portability**: `deploy/install.sh` re-implements the GNU-specific `df --output=target` + `mount` NFS check already present in `lib.sh::_check_is_nfs`. The FW-P1 portability fix must cover this second copy — extract a single shared helper so both call sites stay correct on macOS/BSD. | FW-P1 |
| **FW-D4** | Close CI shellcheck coverage gaps | P3 (Low) | Small | **Deploy / Quality**: `deploy/gitea-ci.yml` shellchecks only 5 scripts; `status.sh`, `resolve_session_id.sh`, `update_yaml_resumed.sh`, and `scripts/generate-env.sh` are never linted. Glob all tracked `*.sh` so new scripts are covered automatically. | None |
| **FW-D4** | Close CI shellcheck coverage gaps | P3 (Low) | Small | **Deploy / Quality**: `deploy/gitea-ci.yml` shellchecks only 9 scripts; `status.sh`, `resolve_session_id.sh`, and `update_yaml_resumed.sh` are never linted. Glob all tracked `*.sh` so new scripts are covered automatically. | None |
---
+74
View File
@@ -0,0 +1,74 @@
# 📋 PLAN_HERDR.md: herdr 기반 멀티플렉서 백엔드 전환 작업 계획서
이 문서는 기존 `tmux` 기반의 에이전트 라이프사이클 관리를 Rust 기반의 에이전트 인지형 멀티플렉서인 **herdr**로 전면 전환하기 위한 도입 배경, 아키텍처 전략 및 상세 작업 단계들을 정의합니다.
---
## 1. 🔍 도입 배경 및 필요성
현재 운영 중인 `tmux` 기반 백엔드는 훌륭한 호환성을 제공하지만, 다음과 같은 구조적 한계와 간헐적인 프롬프트 유실 오류(Prompt-lock)를 동반합니다.
### 🔴 기존 tmux 환경의 한계
* **대략적인 정적 상태 감지 (Coarse Quiescence)**: 입력을 주입하기 전에 터미널이 키를 수락할 수 있는 휴지 상태인지 확인하기 위해, 셸 스크립트 상에서 `capture-pane`을 0.1~0.5초 주기로 돌려 화면 변경 여부를 체크합니다. 이로 인해 CPU 자원이 급증하는 멀티 에이전트 구동 상황에서 입력을 유실하거나 `Enter` 키가 씹히는 현상이 발생합니다.
* **TUI 모달 상태 기계 파싱의 비효율**: 에이전트가 띄운 다이얼로그(예: 인증, 신뢰 확인)를 인식하기 위해 터미널 하단 20줄의 문자열을 정규식으로 직접 파싱하므로, 에이전트 버전업에 따른 TUI 레이아웃 변경에 매우 취약합니다.
### 🟢 herdr 도입 시 기대 효과
* **PTY 레벨의 밀리초(ms) 단위 이벤트 제어**: `herdr`은 Rust 네이티브로 작성되어 PTY(가상 터미널) 입출력 스트림의 유휴 상태를 서브-밀리초 레벨로 감지합니다. 이로 인해 프롬프트 주입 실패 및 명령 유실 오류가 **근본적으로 제로(0)에 가깝게 줄어듭니다.**
* **에이전트 상태 인지 API**: 에이전트 프로세스의 상태(Working, Idle, Blocked, Done)를 멀티플렉서 레벨에서 해석해 소켓 API로 제공하므로, 지저분한 화면 파싱 코드 없이 정교한 자율 관제가 가능합니다.
---
## 2. 🔀 형상 관리 및 배포 전략
두 백엔드(tmux/herdr)를 단일 코드베이스에서 듀얼 스위칭(`if/else`) 방식으로 지원하면 코드가 과도하게 무거워지고 버그 가능성이 높아집니다. 따라서 **독립된 브랜치 구조**로 깨끗하게 이원화하여 제공합니다.
* **`main` 브랜치 (tmux 기반)**:
* **목표**: 어디서나 즉시 실행 가능한 고호환성 프로덕션 버전.
* **의존성**: 추가 설치가 필요 없는 표준 `tmux` 환경.
* **`herdr` 브랜치 (herdr 기반)**:
* **목표**: 대화식 락 오류가 완벽히 통제되는 워크스테이션(macOS/Linux) 최적화 고안전성 버전.
* **의존성**: `herdr` CLI 및 Unix 소켓 API 환경.
---
## 3. 🎯 상세 구현 마일스톤 및 작업 계획
### 📍 Milestone 1: 개발 환경 구성 및 의존성 진단
* [ ] **브랜치 격리**: `git checkout -b herdr` 브랜치 생성 및 격리 개발 공간 확보.
* [ ] **인스톨러 개정 (`deploy/install_mam.sh`)**:
* 호스트 의존성 체크 대상에 `herdr` 추가 (`tmux` 진단 제거).
* `herdr`이 미설치된 경우, 공식 설치 가이드라인(`https://herdr.dev/install.sh`) 안내 출력 및 조기 종료 처리.
* `.mam/` 격리 폴더 및 환경설정 배포 규칙을 `herdr` 스펙에 맞게 조정.
### 📍 Milestone 2: 로우레벨 어댑터 전면 리팩토링 (`lib.sh`)
* [ ] **명령어 매핑**: `lib.sh` 내의 모든 `tmux` API 호출을 `herdr` 명령으로 전면 개정.
* `_tmux new-session` ➡️ `herdr run -d --name "$SESSION_NAME" -- "$CMD_FULL"`
* `_tmux capture-pane` ➡️ `herdr capture --name "$SESSION_NAME"`
* `_tmux send-keys` ➡️ `herdr send-keys --name "$SESSION_NAME" "$KEYS"`
* `_tmux kill-session` ➡️ `herdr kill --name "$SESSION_NAME"`
* [ ] **정적 상태 감지 함수 재작성 (`_pane_quiescent`)**:
* `herdr`이 기본 제공하는 세션 상태 조회 API를 파싱하여 PTY 정적 상태 여부를 판별하도록 대폭 경량화 및 고도화.
* [ ] **인풋 주입 엔진 고도화 (`send_keys_safe`)**:
* 복잡한 버퍼 제어(`set-buffer`/`paste-buffer`) 대신, `herdr` API를 경유한 다이렉트 프롬프트 주입 방식으로 단순화.
### 📍 Milestone 3: 에이전트 라이프사이클 관리 도구 이관
* [ ] **`create_session.sh` 수정**:
* `herdr` 기동 방식 및 pane PID 수집 로직 교체.
* `.mam/agent-sessions.yaml` 메타데이터 규격을 `herdr` 사양(예: `tmux_server` ➡️ `herdr_workspace`)에 맞게 정렬.
* [ ] **`resume_session.sh` 수정**:
* 죽은 `herdr` 프로세스를 감지하고 저장된 대화 ID와 함께 `herdr run`으로 복원하는 흐름 이식.
* [ ] **`stop_session.sh` 수정**:
* 에이전트 세션의 깔끔한 graceful 종료 및 최종 TUI 캡처 흐름을 `herdr` 규격으로 전환.
### 📍 Milestone 4: 검증 및 루프 완주
* [ ] **정적 분석**: `bash -n``shellcheck` 신규 경고 0건 검증.
* [ ] **오케스트레이션 루프 검증 (`run_loop.sh`)**:
* `run_loop.sh` 내부의 `delegate_job_safe` 실행을 `herdr` 세션 기반으로 연동하여 100% 자율 루프 구동 확인.
* 피어 리뷰어(`cline`, `claude`)들로부터 최종 `[VERDICT: PASS]` 서명 획득.
---
## 4. 📈 사후 관리 및 형상 병합 정책
* `herdr` 브랜치의 개발 및 검증이 완주되어 `PASS` 서명이 누적되면, `deploy/INSTALL.md``README.md` 문서를 개정하여 각 브랜치별 설치 절차를 문서화합니다.
* `main` 브랜치의 공통 규칙 버그 수정 사항(예: `AGENTS.md` 수정 등)은 주기적으로 `herdr` 브랜치로 `git merge`하여 정책적 일치성을 유지합니다.
+108
View File
@@ -0,0 +1,108 @@
# 📑 자율 반복 정제 루프 스킬 (`multi-agent-mux-loop`) 개발 계획서
이 문서는 멀티 에이전트 자율 오케스트레이션 루프(`multi-agent-mux-loop`)의 **최종 안전/가드레일 옵션 규격을 포함하여 완벽하게 정제된 마스터 계획서**입니다.
리뷰어 에이전트들의 교차 2차 피드백(Verdict 파싱, 자가 리뷰 방지, 타임아웃 보강)을 완벽하게 수렴하여 정교하게 갱신되었습니다.
---
## 1. ⚙️ 최종 스킬 명령 및 전체 옵션 세트 명세 (CLI Spec)
```bash
$ bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
[--plan] \
[--plan-talk N] \
[--reviewer "reviewer-1,reviewer-2"] \
[--all-reviewer] \
[--max-loop N] \
[--verbose] \
[--cleanup] \
--target-agent "<creator-session-name>" \
--task "수행할 작업 목표"
```
### 📥 옵션 상세 리스트 및 가드레일 제약
| 옵션명 | 기본값 | 분류 | 역할 및 안전 조치 |
|---|---|---|---|
| `--plan` | 비활성 | 기능 | Planner 에이전트를 기동하여 협력 계획 수립 및 토론 단계 개시. (비활성화 시 기존 계획서를 로드하며, 계획서가 없는 경우 Creator가 직접 계획 및 설계를 수립하여 구동) |
| `--plan-talk N` | `1` | 안전 | 플래너-작업자 간 토론 왕복 횟수 상한선. 토큰 낭비 무한 토론 차단. |
| `--reviewer "A,B"` | 비활성 | 기능 | 지정된 peer 리뷰어 에이전트 세션(들)에 피드백 루프 의뢰 (주 작업자 세션은 강제 제외). |
| `--all-reviewer` | 비활성 | 기능 | 레지스트리 상의 모든 `role: reviewer` 세션들을 전수 자동 수집하여 의뢰 (주 작업자 세션은 강제 제외). |
| `--max-loop N` | `3` | **안전 (필수)** | 반려(`NOT PASS`) 시 최대 수정 횟수 제한. **토큰 비용 폭주 방지 가드레일.** |
| `--verbose` | 비활성 | 편의 | 단계별 타임라인 진행 상태 및 잡 매핑 로그의 실시간 상세 출력. |
| `--cleanup` | 비활성 | 편의 | 루프 완료 후 성공한 임시 잡 파일(`.mam/jobs/`)들의 자동 클린업 청소. |
| `--target-agent` | (필수) | 인프라 | 구현을 처리할 주 개발자(Creator) 세션 이름 명시. |
| `--task` | (필수) | 인프라 | 자율 루프에 전달할 최종 구현 지시사항 텍스트. |
---
## 🔄 2. 자율 오케스트레이션 상세 파이프라인 (Sequence Flow)
```mermaid
sequenceDiagram
autonumber
actor User as 사용자 / run_loop.sh
participant Plan as Planner Agent
participant Dev as Creator Agent
participant Rev as Reviewer Agents
User->>User: run_loop.sh 기동 (옵션 세트 검증 및 대상 예외 필터링)
%% Planning & Challenge discussion
alt --plan 지정 시
User->>Plan: delegate-job (계획 수립 지시)
Plan-->>User: 계획서 도출 완료
loop 지정된 --plan-talk 횟수 동안 반복 (기본 1회)
User->>Dev: delegate-job (계획서 비판적 검토 및 이의제기 지시)
Dev->>Plan: 계획서의 맹점 1가지 이상 Challenge 메일 교환
Plan-->>Dev: 수정 반영 및 최종 계획 합의
end
else --plan 미지정
alt 기존 계획 존재 시
User->>Dev: 기존 계획서 로드 및 구현 지시
else 계획 미존재 시
User->>Dev: Self-planning 지시 (스스로 계획/설계 수립하여 구현)
end
end
%% Execution
User->>Dev: delegate-job (작업 지시)
Dev-->>User: 구현 완료 (git diff 발생)
%% Peer-Review Loop with Max-Loop constraint
loop 최대 --max-loop 횟수 동안 반복 (기본 3회)
alt 리뷰어 옵션 지정 시 (--reviewer or --all-reviewer)
User->>Rev: delegate-job (정식 peer 코드 리뷰 위임)
Rev-->>User: [VERDICT: PASS] 또는 [VERDICT: NOT PASS] 태그 리포트 제출
alt 100% PASS 충족 시
Note over User,Rev: 루프 즉시 탈출 (성공)
else NOT PASS 검출 시
User->>Dev: 피드백 전달 및 수정 지시 (피드백 난이도에 따라 Planner 우회 계획 갱신 적용)
end
else 리뷰어 미지정
User->>Dev: Self-Review 지시 (자가 검증 및 자율 종결)
end
end
%% Cleanup & Final Report
alt --cleanup 지정 시
User->>User: 임시 잡 폴더 청소
end
User-->>User: 최종 결과 요약 출력 및 마감
```
---
## 🛠️ 3. 개발 로직 및 안전 파싱 체크포인트
### 1) Verdict 판정 파서 안전 가이드라인 (Fail-Closed & Precedence)
* **NOT PASS 우선권**: 리뷰 리포트 본문 내에 `[VERDICT: NOT PASS]` 가 단 한 번이라도 등장하면, `[VERDICT: PASS]` 문구 존재 여부와 상관없이 무조건 **NOT PASS**로 처리하여 오독 필터링을 방지합니다.
* **Fail-Closed 기본 실패주의**: 태그 누락이나 malformed 리포트로 인해 두 토큰이 모두 스캔되지 않을 경우, 통과시키지 않고 **NOT PASS(실패)** 로 취급하여 루프 무한 기동 및 맹점 통과를 원천 차단합니다.
* **템플릿 명시**: 리뷰어 위임 잡 발행 시, 최종 결과 요약 행에 정형화된 태그 `[VERDICT: PASS]` 혹은 `[VERDICT: NOT PASS]`를 리포트 본문 하단에 반드시 기재하도록 프롬프트 템플릿에 명시적으로 추가합니다.
### 2) 자가 리뷰 방지 가드 (Exclusion Rule)
* `--all-reviewer` 혹은 `--reviewer` 목록을 소집할 때, 해당 작업을 수행한 대상 개발자 세션인 `$TARGET_AGENT`**리뷰어 매핑 목록에서 강제로 배제(Exclude)** 하도록 파싱 쉘 스크립트에서 필터링을 적용합니다.
### 3) 쉘 예외 처리 및 대기 타임아웃 (Error Guard & Timeout)
* `grep -oP``find | head` 시 매칭이 없을 때 `set -eo pipefail`에 의해 쉘 스크립트 전체가 비명횡사하지 않도록 `|| true` 가드 및 공백 체크문을 엄밀히 적용합니다.
* `wait_for_job` 함수 실행 시 타임아웃 가드레일(`WAIT_TIMEOUT`, 기본값 3600초)을 명시적으로 설계하여 무한 루프 행(Hang) 현상을 차단합니다.
+8 -3
View File
@@ -154,8 +154,13 @@ sequenceDiagram
│ ├── agent-sessions.db # SQLite WAL 세션 데이터베이스
│ ├── agent-sessions.yaml # 텍스트 형식의 세션 레지스트리 스냅샷
│ └── jobs/ # 비동기 잡 메타데이터 JSON 파일들
├── scripts/
── generate-env.sh # 환경 파일(.env) 템플릿 복사 스크립트
├── deploy/ # 배포 및 설치 도구 패키지 폴더
── INSTALL.md # 설치 가이드 및 퀵스타트 매뉴얼
│ ├── install_mam.sh # 로컬/클론 인스톨러 스크립트
│ ├── generate-env.sh # 환경 파일(.env) 템플릿 복사 스크립트
│ ├── install.sh # 원격/네트워크 인스톨러 스크립트
│ ├── update.sh # 업데이트 헬퍼 스크립트
│ └── remove.sh # 삭제/언인스톨 헬퍼 스크립트
├── BOOTSTRAP.ko.md # 프로젝트 초기 설치 가이드 (한국어 백업)
├── BOOTSTRAP.md # 프로젝트 초기 설치 및 검증 상세 가이드
├── MESSAGING.md # MQTT 메시징 프로토콜 와이어 규격서
@@ -170,7 +175,7 @@ sequenceDiagram
1. **환경 설정 파일(.env) 생성:**
```bash
./scripts/generate-env.sh
./deploy/generate-env.sh
```
2. **가상환경 생성 및 의존성 패키지 설치:**
```bash
+8 -3
View File
@@ -172,8 +172,13 @@ To ensure communication integrity across public MQTT brokers, the backplane inte
│ ├── agent-sessions.db # SQLite WAL session database
│ ├── agent-sessions.yaml # Human-readable session registry
│ └── jobs/ # Asynchronous job metadata files
├── scripts/
── generate-env.sh # Environment bootstrap helper
├── deploy/ # Distribution and installation package
── INSTALL.md # User manual for installation and quick-start
│ ├── install_mam.sh # Local/Clone installer script
│ ├── generate-env.sh # Environment bootstrap helper
│ ├── install.sh # Remote/Network installer script
│ ├── update.sh # Updater script
│ └── remove.sh # Uninstaller script
├── BOOTSTRAP.md # Detailed installation and verification guide
├── MESSAGING.md # MQTT wire protocol specification
└── README.md # Project introduction and overview (this file)
@@ -187,7 +192,7 @@ For detailed setup instructions, please consult the **[BOOTSTRAP.md](./BOOTSTRAP
1. **Initialize Environment Config:**
```bash
./scripts/generate-env.sh
./deploy/generate-env.sh
```
2. **Create Virtual Environment and Install Dependencies:**
```bash
+101
View File
@@ -0,0 +1,101 @@
# 📋 Recommended Multi-Agent Session Architecture Guide
본 문서는 `multi-agent-mux` 환경에서 오케스트레이션 루프(`multi-agent-mux-loop`)를 활용해 고품질 소프트웨어를 개발할 때 가장 권장되는 **3-에이전트 역할 분리 아키텍처**와 설정 방법 및 추천 이유에 대해 설명합니다.
---
## 👥 1. 추천 3-에이전트 구성 (Roles & Configuration)
`multi-agent-mux` 환경에서는 다음 세 가지 전문 세션을 생성하여 상시 기동해 두는 것이 가장 이상적입니다.
```mermaid
graph TD
User([사용자/Orchestrator]) <--> AGY_Parent[Antigravity Parent]
AGY_Parent -->|1. 계획 수립 위임| Planner[Planner 세션 <br> claude]
AGY_Parent -->|2. 구현 위임| Creator[Creator 세션 <br> agy]
AGY_Parent -->|3. 교차 검증 위임| Reviewer[Reviewer 세션 <br> cline]
Planner -->|설계/피드백 루프| Creator
Creator -->|구현 완료| Reviewer
Reviewer -->|Verdict PASS/NOT PASS| Planner
```
### ① Planner 에이전트
* **역할 (Role)**: `planner-reviewer`
* **주요 임무**: 전체 아키텍처 아웃라인 설계, 구현 계획서 수립, 이의 제기 수렴 및 계획 개정(Refinement).
* **추천 에이전트 종류**: `claude` (긴 추론 맥락과 설계 완성도가 높음)
* **생성 명령어**:
```bash
# planner-reviewer 역할로 claude 세션 기동
bash .agents/skills/multi-agent-mux-create/multi-agent-mux-create \
--agent claude \
--role planner-reviewer \
--name canary-projects-multi-agent-mux-planner-reviewer-claude
```
### ② Creator 에이전트 (주작업자)
* **역할 (Role)**: `creator`
* **주요 임무**: 계획서상의 제약조건 검토 및 이의제기(Challenge), 실제 코드베이스 구현 편집, DoD 자가 검증.
* **추천 에이전트 종류**: `agy` (기민한 도구 실행 속도 및 로컬 파일 편집 최적화)
* **생성 명령어**:
```bash
# creator 역할로 agy 세션 기동
bash .agents/skills/multi-agent-mux-create/multi-agent-mux-create \
--agent agy \
--role creator \
--name canary-projects-multi-agent-mux-creator-agy
```
### ③ Reviewer 에이전트
* **역할 (Role)**: `reviewer`
* **주요 임무**: 구현된 변경분(`git diff`)과 구현 계획서를 기반으로 빌드 가능성, 린트, 로직 유실 교차 피어 리뷰.
* **추천 에이전트 종류**: `cline` (안정적인 컴파일 도구 활용 및 린터 체크 강점)
* **생성 명령어**:
```bash
# reviewer 역할로 cline 세션 기동
bash .agents/skills/multi-agent-mux-create/multi-agent-mux-create \
--agent cline \
--role reviewer \
--name canary-projects-multi-agent-mux-reviewer-cline
```
---
## 💡 2. 왜 3개의 에이전트 분리를 강력히 추천하는가?
부모 에이전트(Antigravity)가 오케스트레이션과 코드 개발을 모두 처리하지 않고, 별도의 격리된 3개의 역할 세션을 두는 데에는 다음과 같은 명확한 공학적 이유가 있습니다.
### ① 대화창 컨텍스트(Context Window) 오염 방지
* **디테일의 지옥**: 에이전트가 코드를 탐색하고, 컴파일 오류를 잡고, 수많은 파일라인을 편집하는 세부 구현 과정은 수십만 토큰에 달하는 방대한 런타임 로그와 코드를 누적시킵니다.
* **해결책**: 만약 오케스트레이터(부모 에이전트)가 이를 직접 수행하면 사용자님과의 대화창 컨텍스트가 구현 로그로 가득 차, 이전에 의논했던 아키텍처 제약이나 중요 요구사항을 쉽게 잊어버립니다. 역할을 격리함으로써 각 세션은 자신의 세부 구현 컨텍스트만 소비하고 소멸합니다.
### ② 비동기 개발 자율성 (Asynchronous Autonomy)
* **대기 시간 최소화**: 오케스트레이션 루프가 설계 검토, 피드백, 자가 수정 등을 수차례 반복하며 백그라운드(tmux)에서 스스로 문제를 해결해 나가는 동안, 사용자님은 저(부모 에이전트)와 멈춤 없이 계속해서 고수준 설계 및 다른 기능에 대한 논의를 이어나갈 수 있습니다.
* **생산성 극대화**: 부모 에이전트가 코딩을 하느라 대화를 블로킹하는 현상이 발생하지 않습니다.
### ③ 교차 검증을 통한 객관성 확보 (Peer Review Objectivity)
* **작성자와 검증자의 분리**: 코드를 직접 짠 에이전트가 자기 자신의 코드를 완벽하게 리뷰하는 것은 불가능에 가깝습니다(인지 편향 발생).
* **해결책**: 구현을 전담한 `Creator`와, 이를 객관적인 삼자 관점에서 검토하는 `Reviewer` 세션을 철저히 독립시킴으로써 코드 품질 결함을 높은 확률로 선제 필터링할 수 있습니다.
### ④ 이기종 모델/도구의 결합 (Heterogeneous Collaboration)
* **각자 잘하는 분야의 극대화**:
* **Planner (Claude)**: 설계 및 아키텍처 정합성 수립에 특화
* **Creator (Antigravity/Agy)**: 신속하고 정확한 로컬 파일 편집 및 도구 호출에 특화
* **Reviewer (Cline)**: 린트 체크, 빌드 테스트 등 철저한 안전망 검증에 특화
* 이러한 하이브리드 조합을 구성할 때 루프 전체의 최종 도달 성공률이 가장 높게 나타납니다.
---
## 🛠️ 3. 3-에이전트 루프 실행 방법
에이전트들이 생성되어 기동(Running) 중인 경우, 다음과 같이 계획 수립(`--plan`) 및 전체 교차 리뷰(`--all-reviewer`) 옵션을 주어 자율 협업 개발을 시작할 수 있습니다.
```bash
bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
--target-agent "canary-projects-multi-agent-mux-creator-agy" \
--plan \
--all-reviewer \
--task "여기에 개발하고자 하는 태스크의 최종 목표를 상세히 기술합니다."
```
이 루프는 **기획 ➡️ 작업자 이의제기 ➡️ 계획 개정 ➡️ 코드 개발 ➡️ 교차 피어 리뷰 ➡️ 피드백 수렴 재구현**의 전 과정을 자동으로 진행하여, 빌드 및 린트가 보장되는 코드를 저장소에 자동으로 커밋 및 병합합니다.
+49
View File
@@ -0,0 +1,49 @@
# Test Infrastructure Specification
## Test Philosophy
We adopt an **opaque-box, requirement-driven** testing philosophy for the tmux-to-herdr migration scripts.
This approach ensures that the test suite validates external behaviors, input/output contracts, and side-effects rather than asserting internal code structure or layout. The scripts are treated as black boxes that:
- Accept CLI arguments and environment variables.
- Query/interact with the `herdr` daemon through the `_herdr` shim (using mock executable interception).
- Perform state mutations inside `.mam/agent-sessions.yaml` and `.mam/agent-sessions.db`.
- Interact with background agent runners (`claude`, `agy`, `hermes`, `cline`).
This guarantees that our test assertions remain stable even if the script implementation details are refactored, as long as the functional requirements are met.
## Feature Inventory
The test suite is structured around five core features, mapping out verification checks across Tiers 1, 2, and 3:
| Feature | Tier 1 (Unit Checks) | Tier 2 (Component Checks) | Tier 3 (Integration Checks) |
|---|---|---|---|
| **Create Session** | - `derive_session_name` slug generation checks<br>- Workspace-to-slug character translation<br>- Invalid workspace path filtering<br>- Role parameter sanity validations<br>- Session override string generation | - Verification of state serialization to YAML schema<br>- Isolation home directory structure validation<br>- SQLite DB connection verification<br>- Concurrency check for database registration lock<br>- Database schema validation on write | - Spawn session execution with mock `herdr` and mock agent<br>- TUI readiness wait check<br>- Cleanup trap execution on crash<br>- Argument validation logic verification<br>- Isolation directory creation checks |
| **Resume Session** | - Workspace UUID resolution order unit tests<br>- CLI session ID parser validations<br>- Check prioritization (yaml file -> disk scan -> cache)<br>- Workspace path boundary check<br>- Empty UUID handling logic | - Configuration restore verification<br>- Environment overrides assertion<br>- Integrity check on retrieved SQLite metadata<br>- Validation of session ownership verification<br>- Config parsing for resume options | - Run `resume_session.sh` with mock agents<br>- Intercept agent command structure inside mock `herdr`<br>- Verify agent receives correct conversation UUID flag<br>- Invalid/missing UUID recovery path test<br>- Workspace resume CLI args verification |
| **Stop Session** | - Session name verification check<br>- Purge verification confirmations logic<br>- Command derivation format validation<br>- Timeout calculation helper tests<br>- Reason logging serializer test | - Safe folder path validation (shutil protection)<br>- Database status field mutation serialization<br>- Isolation folder cleanup check<br>- Lock file release checks on stop<br>- Concurrency handling of stop mutations | - Execute `stop_session.sh` with graceful key delivery (`/exit`)<br>- Fallback to forcible termination (`herdr kill-session`) check<br>- Fallback to PID termination (`kill -9`) verify<br>- Purge files verification on disk (`--purge-conversation`)<br>- CLI flag verification with yes/no confirmation |
| **Status Query** | - JSON converter unit tests<br>- Diff formatter text generators<br>- Output alignment tests<br>- Table grid column math verify<br>- CLI status argument parse tests | - Status read locks verification<br>- Parsing of drift status classifications<br>- Concurrency read protection test<br>- Registry YAML-to-JSON structural translation<br>- Verification of database read access checks | - Running `status.sh` with `--json`<br>- Verify console output match formatting rules<br>- Verify exit status codes on different states<br>- Integration test with `reconcile.sh` read-only diff emission<br>- Verify status command doesn't trigger side effects |
| **Monitor/Reconcile** | - Drift state classification unit tests<br>- Signature verification checks<br>- Subscription topic parsing tests<br>- MQTT message structure validator<br>- HMAC validation logic tests | - Concurrency lock checks (`.mam/monitor.lock`)<br>- Verify YAML and SQLite database reconciliation logic<br>- DB validation on drift updates<br>- HMAC signature signature verification<br>- SQLite journal mode fallback check (WAL vs DELETE) | - Execute `reconcile.sh` in single-pass mode (`--once`)<br>- MQTT subscription execution with mock messages<br>- Verify auto-termination of orphaned herdr sessions<br>- Verify auto-registration of untracked herdr sessions<br>- Lock contention handling testing |
## Test Architecture
The E2E testing framework is built using **pytest** and relies on two main pillars to ensure hermetic and reproducible test runs:
1. **Environment Sandboxing**:
All tests run inside a temporary, isolated directory structure provided by the pytest `tmp_path` fixture. The workspace environment is sandboxed by:
- Creating a temporary `.mam/` directory.
- Using the `monkeypatch` fixture to override `AGENT_SESSIONS_YAML` pointing to the sandboxed path.
- Overriding relevant environment variables (like `HOME`, `WORKSPACE_ROOT`, etc.) to prevent tests from modifying the developer's system state.
2. **Mock Binaries Interception**:
To prevent tests from interacting with external systems or relying on running daemons:
- A mock `herdr` script is dynamically generated and placed in a temporary bin folder, which is prepended to the system `PATH`. This mock binary reads/writes to a JSON file (`mock_herdr_state.json`) which acts as the control pane for tests to assert that `herdr` was called with correct arguments and return mocked outputs (session list, capture-pane output, exit codes).
- Mock agent binaries (`claude`, `agy`, `hermes`, `cline`) are also generated and prepended to `PATH`. They emulate successful login verification commands (e.g. `claude auth status`) and mock conversation UUID generation on disk.
## Real-World Application Scenarios (Tier 4)
We define five key E2E scenarios representing end-to-end user workflows:
1. **Standard Agent Session Lifecycle**: Spawning a new worker agent session via `create_session.sh`, verifying it is registered correctly in the YAML database, checking its status via `status.sh`, and then gracefully stopping it via `stop_session.sh`.
2. **Session Disconnect and Resume**: Creating a session, simulating a network disconnect/agent pane termination (updating herdr state), calling `resume_session.sh` to restore it using the workspace-scoped UUID, and asserting that the session returns to the active state in both herdr and the registry.
3. **Drift Detection and Auto-Reconciliation**: Artificially introducing drift (e.g. terminating a herdr session manually from the backend while keeping it registered in the YAML registry, or starting a herdr session outside the scripts), running `reconcile.sh --once`, and verifying that orphaned sessions are terminated and registry state is updated.
4. **Parallel Session Operations with flock Locking**: Simulating concurrent creation/stop script invocations to verify that SQLite flock transactions block lost update races, and that the registry data remains consistent.
5. **Multi-Agent Orchestrator Review Loop**: Running the orchestrator loop (`run_loop.sh`) where a worker agent and a reviewer agent are spawned, reviewer verdicts (`PASS` and `NOT PASS`) are processed, loops are iterated, and planner escalation is triggered on failure.
## Coverage Thresholds
To ensure the test suite is comprehensive, we define the following coverage thresholds:
- **Tier 1 (Unit Tests)**: Minimum >=5 unit tests per feature (total >=25 unit tests).
- **Tier 2 (Component Tests)**: Minimum >=5 component tests per feature (total >=25 component tests).
- **Tier 3 (Integration Tests)**: Pairwise combination testing covering CLI options and environment overrides for all features.
- **Tier 4 (E2E Scenarios)**: At least 5 full real-world scenario tests implemented and passing.
+129
View File
@@ -0,0 +1,129 @@
# Test Ready Report
## Test Runner
- **Command**: `.venv/bin/pytest tests/`
- **Expected**: All tests pass with exit code 0
## Coverage Summary
- **1. Feature Coverage (Tier 1)**: 29 tests
- **2. Boundary & Corner (Tier 2)**: 26 tests
- **3. Cross-Feature (Tier 3)**: 5 tests
- **4. Real-World Application (Tier 4)**: 5 tests
- **Sanity Checks**: 2 tests
- **Challenger/M2 Unit**: 7 tests
- **Total**: 74 tests
## Feature Checklist
### 1. Create Session
- **Tier 1 (Unit Checks)**
- [x] `derive_session_name` slug generation checks
- [x] Workspace-to-slug character translation
- [x] Invalid workspace path filtering
- [x] Role parameter sanity validations
- [x] Session override string generation
- **Tier 2 (Component Checks)**
- [x] Verification of state serialization to YAML schema
- [x] Isolation home directory structure validation
- [x] SQLite DB connection verification
- [x] Concurrency check for database registration lock
- [x] Database schema validation on write
- **Tier 3 (Integration Checks)**
- [x] Spawn session execution with mock `herdr` and mock agent
- [x] TUI readiness wait check
- [x] Cleanup trap execution on crash
- [x] Argument validation logic verification
- [x] Isolation directory creation checks
- **Tier 4 (Real-World Application Scenarios)**
- [x] Standard Agent Session Lifecycle E2E test (Scenario 1)
- [x] Parallel Session Operations with flock Locking E2E test (Scenario 4)
### 2. Resume Session
- **Tier 1 (Unit Checks)**
- [x] Workspace UUID resolution order unit tests
- [x] CLI session ID parser validations
- [x] Check prioritization (yaml file -> disk scan -> cache)
- [x] Workspace path boundary check
- [x] Empty UUID handling logic
- **Tier 2 (Component Checks)**
- [x] Configuration restore verification
- [x] Environment overrides assertion
- [x] Integrity check on retrieved SQLite metadata
- [x] Validation of session ownership verification
- [x] Config parsing for resume options
- **Tier 3 (Integration Checks)**
- [x] Run `resume_session.sh` with mock agents
- [x] Intercept agent command structure inside mock `herdr`
- [x] Verify agent receives correct conversation UUID flag
- [x] Invalid/missing UUID recovery path test
- [x] Workspace resume CLI args verification
- **Tier 4 (Real-World Application Scenarios)**
- [x] Session Disconnect and Resume E2E test (Scenario 2)
### 3. Stop Session
- **Tier 1 (Unit Checks)**
- [x] Session name verification check
- [x] Purge verification confirmations logic
- [x] Command derivation format validation
- [x] Timeout calculation helper tests
- [x] Reason logging serializer test
- **Tier 2 (Component Checks)**
- [x] Safe folder path validation (shutil protection)
- [x] Database status field mutation serialization
- [x] Isolation folder cleanup check
- [x] Lock file release checks on stop
- [x] Concurrency handling of stop mutations
- **Tier 3 (Integration Checks)**
- [x] Execute `stop_session.sh` with graceful key delivery (`/exit`)
- [x] Fallback to forcible termination (`herdr kill-session`) check
- [x] Fallback to PID termination (`kill -9`) verify
- [x] Purge files verification on disk (`--purge-conversation`)
- [x] CLI flag verification with yes/no confirmation
- **Tier 4 (Real-World Application Scenarios)**
- [x] Standard Agent Session Lifecycle E2E test (Scenario 1)
- [x] Parallel Session Operations with flock Locking E2E test (Scenario 4)
### 4. Status Query
- **Tier 1 (Unit Checks)**
- [x] JSON converter unit tests
- [x] Diff formatter text generators
- [x] Output alignment tests
- [x] Table grid column math verify
- [x] CLI status argument parse tests
- **Tier 2 (Component Checks)**
- [x] Status read locks verification
- [x] Parsing of drift status classifications
- [x] Concurrency read protection test
- [x] Registry YAML-to-JSON structural translation
- [x] Verification of database read access checks
- **Tier 3 (Integration Checks)**
- [x] Running `status.sh` with `--json`
- [x] Verify console output match formatting rules
- [x] Verify exit status codes on different states
- [x] Integration test with `reconcile.sh` read-only diff emission
- [x] Verify status command doesn't trigger side effects
- **Tier 4 (Real-World Application Scenarios)**
- [x] Standard Agent Session Lifecycle E2E test (Scenario 1)
- [x] Drift Detection and Auto-Reconciliation E2E test (Scenario 3)
### 5. Monitor/Reconcile
- **Tier 1 (Unit Checks)**
- [x] Drift state classification unit tests
- [x] Signature verification checks
- [x] Subscription topic parsing tests
- [x] MQTT message structure validator
- [x] HMAC validation logic tests
- **Tier 2 (Component Checks)**
- [x] Concurrency lock checks (`.mam/monitor.lock`)
- [x] Verify YAML and SQLite database reconciliation logic
- [x] DB validation on drift updates
- [x] HMAC signature signature verification
- [x] SQLite journal mode fallback check (WAL vs DELETE)
- **Tier 3 (Integration Checks)**
- [x] Execute `reconcile.sh` in single-pass mode (`--once`)
- [x] MQTT subscription execution with mock messages
- [x] Verify auto-termination of orphaned herdr sessions
- [x] Verify auto-registration of untracked herdr sessions
- [x] Lock contention handling testing
- **Tier 4 (Real-World Application Scenarios)**
- [x] Drift Detection and Auto-Reconciliation E2E test (Scenario 3)
+41 -14
View File
@@ -8,30 +8,33 @@ MAM은 단일 워크스페이스 상에서 복수의 에이전트(Claude, Cline,
## 1. ⚙️ 사전 요구사항
MAM 스킬 및 스크립트들은 호스트 시스템의 다음 도구들에 의존합니다. 설치 전에 확인해 주세요.
* **tmux**: 에이전트를 백그라운드 격리 Pane에서 구동하기 위한 프로세스 컨테이너
* **herdr**: 에이전트를 백그라운드 격리 Pane/Workspace에서 구동 및 관제하기 위한 프로세스 컨테이너
* **python3**: 세션 레지스트리(YAML/SQLite DB) 파싱 및 유효성 검사 (내장 `sqlite3` 모듈 필수)
* **uuidgen**: 격리 세션 생성 시 고유의 UUID 할당
* **rsync**: 인스톨러(`install_mam.sh`)가 `.agents/` 오케스트레이터 및 스킬 폴더를 타겟 프로젝트에 복제하는 데 사용 (설치 시 필요)
* **rsync**: 인스톨러(`deploy/install_mam.sh`)가 `.agents/` 오케스트레이터 및 스킬 폴더를 타겟 프로젝트에 복제하는 데 사용 (설치 시 필요)
* **python3-yaml (pyyaml)**: 세션 데이터 YAML 저장 및 로드 의존성 (`pip install pyyaml`)
---
## 2. 🚀 자동 설치 방법
MAM의 자동 설치 스크립트(`install_mam.sh`)를 사용하여 10초 만에 필요한 규칙과 라이프사이클 툴킷을 타겟 프로젝트에 이식할 수 있습니다. 스크립트는 실행 시 자동으로 시스템의 `tmux`, `python3`, `rsync`, `uuidgen` 및 필수 파이썬 모듈들을 진단합니다.
MAM의 자동 설치 스크립트(`deploy/install_mam.sh`)를 사용하여 10초 만에 필요한 규칙과 라이프사이클 툴킷을 타겟 프로젝트에 이식할 수 있습니다. 스크립트는 실행 시 자동으로 시스템의 `herdr`, `python3`, `rsync`, `uuidgen` 및 필수 파이썬 모듈들을 진단합니다.
> [!IMPORTANT]
> **설치 전제조건**: MAM 스킬을 타겟 프로젝트에 설치하려면 **먼저 MAM 레포지토리가 로컬 머신에 clone 되어 있어야 합니다.**
### 설치 스크립트 실행
MAM 레포지토리 루트에서 다음 명령어를 실행합니다.
MAM 레포지토리 루트 디렉토리로 이동한 후 다음 명령어를 실행합니다.
```bash
# 기본 사용법 (타겟 프로젝트 경로 지정)
$ bash scripts/install_mam.sh --target /path/to/your/project
$ bash deploy/install_mam.sh --target /path/to/your/project
# 만약 이미 타겟에 AGENTS.md 가 존재하여 강제로 덮어쓰고 싶다면:
$ bash scripts/install_mam.sh --target /path/to/your/project --force
$ bash deploy/install_mam.sh --target /path/to/your/project --force
```
### 설치 스크립트가 수행하는 작업:
1. **의존성 진단**: 시스템에 `tmux`, `python3`, `sqlite3` 설치되어 있는지 확인합니다.
1. **의존성 진단**: 시스템에 `herdr`, `python3`, `rsync`, `uuidgen` CLI 바이너리와 파이썬 `pyyaml`/`sqlite3` 모듈이 설치되어 있는지 확인합니다.
2. **규칙 및 스킬 복제**: 오케스트레이션 가이드(`.agents/` 하위 전체)를 타겟 프로젝트 하위로 이식합니다.
3. **지침 전파**: 에이전트가 로드하고 복종할 행동 지침 문서(`AGENTS.md`)를 프로젝트 루트에 복사합니다.
4. **형상 제외 설정**: 세션 DB 및 격리 캐시 저장소인 `.mam/` 디렉토리를 타겟 프로젝트의 `.gitignore` 에 자동 주입하여 불필요한 형상 관리를 방지합니다.
@@ -51,21 +54,21 @@ $ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh \
--role developer \
--session my-project-dev-claude \
--isolate \
--tmux-server multi-agent-mux
--herdr-workspace multi-agent-mux
```
* `--isolate` 옵션을 주면 `.mam/agent_homes/<uuid>/` 하위에 로그인 및 설정은 유지하되 대화 내역은 격리되는 홈이 형성됩니다.
### 2) 세션 접속 (Attach)
백그라운드에서 구동된 에이전트 TUI 화면에 들어갑니다. (세션 생성 시 지정한 독립 격리 tmux 서버 소켓 `-L multi-agent-mux` 를 경유해 접속합니다.)
백그라운드에서 구동된 에이전트 TUI 화면에 들어갑니다.
```bash
$ tmux -L multi-agent-mux attach -t my-project-dev-claude
$ herdr session attach my-project-dev-claude
```
* **화면 탈출**: 대화 중 세션을 유지한 채 터미널로 돌아오려면 `Ctrl + B`를 누른 뒤 `D` 키를 차례로 입력합니다.
### 3) 에이전트 상태 복원 (Resume)
세션이 중지되었거나, 호스트 재기동으로 tmux가 소멸한 경우에도 이전 대화 ID 및 격리 디렉토리를 원자적으로 이어받아 다시 기동할 수 있습니다.
세션이 중지되었거나, 호스트 재기동으로 herdr 서버가 소멸한 경우에도 이전 대화 ID 및 격리 디렉토리를 원자적으로 이어받아 다시 기동할 수 있습니다.
```bash
# 1단계: resume/SKILL.md를 참고하여 복원 스크립트 실행 (YAML/DB의 UUID 자동 로드 및 T4 격리 재바인딩)
# 1단계: 복원 대상 세션의 UUID 자동 조회 (DB/YAML 레지스트리 기반)
$ WORKSPACE="/path/to/your/project"
$ AGENT="claude"
$ SESSION_NAME="my-project-dev-claude"
@@ -73,8 +76,17 @@ $ SESSION_NAME="my-project-dev-claude"
$ UUID=$(bash .agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh \
--workspace "$WORKSPACE" --agent "$AGENT" --session "$SESSION_NAME")
# 2단계: 동적 격리 인자 주입 스폰 수행 후 레지스트리 상태 running 복구
# (상세 쉘 명령어는 .agents/skills/multi-agent-mux-resume/SKILL.md 참조)
# 복원 대상 세션의 유효성 검사 (M-1)
$ [ -n "$UUID" ] || { echo "[ERROR] 매칭되는 활성 세션 이력이 없습니다. create_session.sh를 통해 먼저 세션을 생성해 주세요."; exit 1; }
# 2단계: 세션 재기동 (이전 대화 컨텍스트 복원 기동)
# (주의: 만약 create 시 격리(--isolate) 세션으로 생성했다면, CLAUDE_CONFIG_DIR 환경변수를 YAML에 기록된 isolation.root 경로로 지정하여 띄워야 합니다. 상세 격리 복원 커맨드는 .agents/skills/multi-agent-mux-resume/SKILL.md 문서를 필독해 주세요.)
$ herdr run -d --name "$SESSION_NAME" --workspace "$WORKSPACE" -- \
"claude --dangerously-skip-permissions -r $UUID"
# 3단계: 레지스트리 세션 상태를 running 으로 동기화 갱신
$ bash .agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh \
--session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT"
```
### 4) 세션 종료 및 정리 (Stop / Purge)
@@ -89,6 +101,21 @@ $ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
--session my-project-dev-claude --agent claude --purge-conversation --yes
```
### 5) 자율 반복 정제 루프 기동 (Mux-Loop)
계획 수립(Planner) ➜ 코드 수정(Creator) ➜ 교차 검증(Reviewer) ➜ 수정 정제 피드백을 단일 명령으로 자동 순환하는 반복 정밀 관제 루프를 기동합니다.
```bash
# 플래너 협력 계획 단계를 활성화하고, 리뷰어의 PASS 합의 하에 자율 루프 구동 (최대 3회 교정)
$ bash .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh \
--plan \
--plan-talk 1 \
--reviewer "reviewer-a,reviewer-b" \
--max-loop 3 \
--target-agent my-project-dev-claude \
--task "구현할 명확한 개발 작업 목표"
```
* `--max-loop`는 코드 오류 발견 시 최대 교정(반복 수정) 횟수 제한 가드레일 역할을 합니다.
* **참고**: 리뷰 단계에서 코드 변경분을 정확하게 추적하기 위해, 타겟 프로젝트 디렉토리는 `git` 저장소로 기동 및 관리되고 있는 것을 권장합니다.
---
## 🛡️ 협업 및 보안 가이드라인
+16 -3
View File
@@ -6,7 +6,10 @@ This directory contains packaging templates and installation scripts to deploy t
## 📁 Deployment Directory Structure
* **`install.sh`**: A self-contained, idempotent shell installer that checks system requirements (`tmux`, `python3`, `pip3`), detects NFS/network filesystem mounts, sets up a local python virtual environment (`.venv`), and initializes environment configuration (`.env`).
* **`install.sh`**: A self-contained, idempotent remote shell installer (via curl) that checks system requirements (`herdr`, `python3`), detects NFS/network filesystem mounts, sets up a local python virtual environment (`.venv`), and initializes environment configuration (`.env`).
* **`install_mam.sh`**: A local-clone installer that copies rules/skills (`.agents/`), `AGENTS.md`, and sets up environment bootstrap on target projects.
* **`generate-env.sh`**: Environment configuration bootstrap helper.
* **`INSTALL.md`**: Detailed installation and quick-start user manual.
* **`plugin.json`**: Metadata declaration file to register MAM as an installable plugin for AI Agent coding platforms (such as Claude Code, Antigravity, or other TUI clients).
* **`gitea-ci.yml`**: CI/CD pipeline definition template for Gitea Actions (running ShellCheck linting on bash scripts, validation on python scripts, and compilation tests).
@@ -26,7 +29,17 @@ Alternatively, if they have cloned the repository, they can execute:
bash deploy/install.sh
```
### 2. Custom Fork / Private Mirror Installations
### 2. Local-Clone Installation
If you already have cloned this repository locally, you can port MAM to other local target projects:
```bash
bash deploy/install_mam.sh --target /path/to/your/project
```
Refer to **`INSTALL.md`** inside this directory for the full instructions and workflows.
> [!NOTE]
> The local-clone installer does not ship `update.sh`/`remove.sh` to targets. To enable in-place updates, re-run the remote installer (`curl ... | bash`) or copy `deploy/update.sh` + `deploy/remove.sh` manually.
### 3. Custom Fork / Private Mirror Installations
If you run a private mirror or fork, you can override the source URLs during installation using environment variables:
```bash
@@ -40,7 +53,7 @@ curl -fsSL https://my-mirror.example.com/.../install.sh \
MAM_INSTALLER_URL=https://my-mirror.example.com/.../install.sh bash deploy/update.sh
```
### 2. Registering as a Workspace Plugin
### 4. Registering as a Workspace Plugin
To register these skills globally or for a specific workspace:
* **Workspace Level**: Copy the `.agents/` folder into your project root.
* **Global Level (Gemini/Antigravity)**: Register the plugin path in your global config file at `~/.gemini/config/skills.json`:
@@ -6,10 +6,10 @@
# - .env present → no-op (leaves your edits intact), exit 0.
# - .env present --force → overwrite .env from .env.example (backs up to .env.bak).
#
# Paths are resolved relative to this script (repo root = parent of scripts/),
# Paths are resolved relative to this script (repo root = parent of deploy/),
# so it works regardless of the caller's cwd.
#
# Usage: scripts/generate-env.sh [--force] [-h|--help]
# Usage: deploy/generate-env.sh [--force] [-h|--help]
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+5
View File
@@ -32,7 +32,12 @@ jobs:
shellcheck .agents/skills/multi-agent-mux-create/scripts/create_session.sh
shellcheck .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh
shellcheck .agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh
shellcheck .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh
shellcheck deploy/install.sh
shellcheck deploy/install_mam.sh
shellcheck deploy/generate-env.sh
shellcheck deploy/update.sh
shellcheck deploy/remove.sh
echo "✅ ShellCheck completed successfully."
lint-python:
+12 -2
View File
@@ -28,7 +28,7 @@ check_cmd() {
fi
}
check_cmd tmux
check_cmd herdr
check_cmd python3
# Verify Python Version
@@ -66,6 +66,7 @@ check_assets_present() {
".agents/skills/multi-agent-mux-create/scripts/create_session.sh"
".agents/skills/multi-agent-mux-delegate-job/scripts/registry.py"
".agents/skills/multi-agent-mux-status/scripts/status.sh"
".agents/skills/multi-agent-mux-loop/scripts/run_loop.sh"
)
for f in "${core_files[@]}"; do
if [ ! -f "$dir/$f" ]; then
@@ -152,6 +153,15 @@ if ! check_assets_present "."; then
echo ".env.example" >> "$MANIFEST_FILE"
fi
# Ship the user manual into the target's .agents/ (consistent with install_mam.sh)
if [ -f "$STAGE_DIR/deploy/INSTALL.md" ]; then
mkdir -p .agents
if [ ! -e ".agents/INSTALL.md" ]; then
cp "$STAGE_DIR/deploy/INSTALL.md" .agents/INSTALL.md || { echo "❌ Error: Failed to copy INSTALL.md" >&2; exit 1; }
echo ".agents/INSTALL.md" >> "$MANIFEST_FILE"
fi
fi
rm -rf "$STAGE_DIR"
trap - EXIT
echo "✅ Skills staged into workspace (existing files preserved)."
@@ -236,7 +246,7 @@ MQTT_BROKER=broker.hivemq.com
MQTT_PORT=1883
MQTT_TLS=0
MQTT_CLIENT_ID_PREFIX=mam-agent
TMUX_SERVER_NAME=default
HERDR_SERVER_NAME=default
EOF
chmod 0600 "$ENV_FILE"
echo "✅ Config file .env initialized with chmod 0600."
@@ -83,7 +83,7 @@ fi
# 1. Dependency Checks
log_info "Verifying host dependencies..."
DEPS=(tmux python3 rsync uuidgen)
DEPS=(herdr python3 rsync uuidgen)
MISSING_DEPS=()
for dep in "${DEPS[@]}"; do
if ! command -v "$dep" &>/dev/null; then
@@ -93,6 +93,10 @@ done
if [ ${#MISSING_DEPS[@]} -ne 0 ]; then
log_error "Missing required dependencies: ${MISSING_DEPS[*]}"
if [[ " ${MISSING_DEPS[*]} " == *" herdr "* ]]; then
log_error "To install herdr, please refer to the official installation guide:"
log_error " curl -fsSL https://herdr.dev/install.sh | sh"
fi
log_error "Please install them before using MAM."
exit 1
fi
@@ -105,7 +109,7 @@ if ! python3 -c "import yaml, sqlite3" &>/dev/null; then
fi
log_ok "Dependency checks completed."
# 2. Copy .agents/ folder
# 2. Copy .agents/ folder and config tools
log_info "Deploying orchestration rules & skills (.agents/)..."
mkdir -p "$TARGET_DIR/.agents"
@@ -114,6 +118,23 @@ mkdir -p "$TARGET_DIR/.agents"
rsync -a --exclude='.git/' --exclude='/reports/' --exclude='*.log' --exclude='__pycache__/' --exclude='*.pyc' "$SRC_DIR/.agents/" "$TARGET_DIR/.agents/"
log_ok "Deployed Rules and Skills under target's .agents/"
# Copy config templates and generate scripts (M-2)
if [ -f "$SRC_DIR/.env.example" ]; then
cp "$SRC_DIR/.env.example" "$TARGET_DIR/.env.example"
log_ok "Copied .env.example configuration template"
fi
if [ -f "$SRC_DIR/deploy/generate-env.sh" ]; then
mkdir -p "$TARGET_DIR/scripts"
cp "$SRC_DIR/deploy/generate-env.sh" "$TARGET_DIR/scripts/generate-env.sh"
chmod +x "$TARGET_DIR/scripts/generate-env.sh"
log_ok "Copied deploy/generate-env.sh helper tool"
fi
# Copy the user manual into the target's .agents/ (previously came via rsync of .agents/)
if [ -f "$SRC_DIR/deploy/INSTALL.md" ]; then
cp "$SRC_DIR/deploy/INSTALL.md" "$TARGET_DIR/.agents/INSTALL.md"
log_ok "Copied INSTALL.md user manual into target .agents/"
fi
# 3. Copy AGENTS.md to root or inject guidelines pointer
log_info "Configuring developer guidelines (AGENTS.md)..."
AGENTS_FILE="$TARGET_DIR/AGENTS.md"
@@ -143,19 +164,54 @@ fi
log_info "Registering runtime isolation blocks in .gitignore..."
GITIGNORE="$TARGET_DIR/.gitignore"
MAM_PATTERN="/.mam/"
VENV_PATTERN="/.venv/"
if [ -f "$GITIGNORE" ]; then
# Register .mam/ if absent
if grep -Eq '^/?\.mam/?$' "$GITIGNORE"; then
log_ok ".mam/ already registered in target's .gitignore."
else
echo -e "\n# Multi-Agent Mux (MAM) runtime databases and isolation cache\n$MAM_PATTERN" >> "$GITIGNORE"
log_ok "Appended /.mam/ registration to .gitignore."
fi
# Register .venv/ if absent
if grep -Eq '^/?\.venv/?$' "$GITIGNORE"; then
log_ok ".venv/ already registered in target's .gitignore."
else
echo -e "\n# Python virtual environment\n$VENV_PATTERN" >> "$GITIGNORE"
log_ok "Appended /.venv/ registration to .gitignore."
fi
else
echo -e "# Multi-Agent Mux (MAM) runtime databases and isolation cache\n$MAM_PATTERN" > "$GITIGNORE"
log_ok "Created .gitignore with /.mam/ exclusion."
echo -e "# Multi-Agent Mux (MAM) runtime databases and isolation cache\n$MAM_PATTERN\n\n# Python virtual environment\n$VENV_PATTERN" > "$GITIGNORE"
log_ok "Created .gitignore with MAM and .venv exclusions."
fi
# 5. Python Virtual Environment Setup (F-1)
log_info "Bootstrapping Python virtual environment (.venv) in target..."
VENV_NAME="$TARGET_DIR/.venv"
if [ ! -d "$VENV_NAME" ]; then
python3 -m venv "$VENV_NAME"
log_ok "Virtual environment created."
else
log_info "Virtual environment (.venv) already exists. Skipping creation."
fi
# Upgrade pip and install dependencies inside target venv
# shellcheck disable=SC1091
source "$VENV_NAME"/bin/activate
pip install --upgrade pip
REQ_FILE="$TARGET_DIR/.agents/skills/multi-agent-mux-delegate-job/requirements.txt"
if [ -f "$REQ_FILE" ]; then
log_info "Installing dependencies from $REQ_FILE..."
pip install -r "$REQ_FILE"
log_ok "Dependencies installed successfully."
else
log_warn "Could not find requirements file: $REQ_FILE. Installing defaults."
pip install "paho-mqtt>=2.0.0" pyyaml
fi
deactivate
# Done
log_ok "MAM Installation completed successfully!"
cat <<EOF
@@ -165,10 +221,10 @@ cat <<EOF
1. Initialize a new isolated session:
$ bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh \\
--workspace "$TARGET_DIR" --agent claude --role developer --isolate \\
--tmux-server multi-agent-mux
--herdr-server multi-agent-mux
2. Attach to the running session:
$ tmux -L multi-agent-mux attach -t <session_name>
$ HERDR_SERVER_NAME=multi-agent-mux herdr agent attach <session_name>
3. Gracefully stop the session:
$ bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \\
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "multi-agent-mux",
"description": "Multi-Agent Orchestration & Messaging Backplane on Tmux & MQTT.",
"description": "Multi-Agent Orchestration & Messaging Backplane on Herdr & MQTT.",
"disabled": false
}
+56
View File
@@ -0,0 +1,56 @@
# 📑 MAM Web PTY WebSocket Bridge 개발 계획서
이 문서는 브라우저 환경에서 POSIX 가상 터미널(PTY) FFI에 직접 액세스할 수 없는 샌드박스 제약을 극복하고, `apps/mam_web` 클라이언트를 통해 세션 attach 및 제어 기능을 온전히 구동하기 위한 **WebSocket PTY 브릿지 아키텍처 및 구현 계획**을 정의합니다.
---
## 1. 아키텍처 개요 (Architecture Overview)
브라우저 단독으로는 로컬 리눅스의 시스템 호출(`fork()`, `execvp()`, `fcntl()`, `waitpid()`)을 호출할 수 없으므로, 로컬 데몬으로 동작하는 초경량 WebSocket 프록시 브릿지 서버를 경유하여 양방향 터미널 스트림을 중계합니다.
```mermaid
graph TD
Client[\"MAM Web Client (Browser)\" - apps/mam_web]
Bridge[\"Shelf WebSocket Bridge (Daemon)\" - packages/mam_bridge]
PTY[\"POSIX PtySession (FFI)\" - packages/mam_pty]
Tmux[\"tmux -L multi-agent-mux attach\" - Local process]
Client -- \"1. ws://localhost:8080/attach?session=demo\" --> Bridge
Bridge -- \"2. spawn PTY (fork-safe FFI)\" --> PTY
PTY -- \"3. dup2 redirect\" --> Tmux
Client -- \"4. Send inputs (keystrokes)\" --> Bridge
Bridge -- \"5. pty.writeString()\" --> PTY
PTY -- \"6. Stream stdout bytes\" --> Bridge
Bridge -- \"7. WebSocket Frame (text/binary)" --> Client
```
---
## 2. 상세 구현 사양 (Implementation Details)
### 2.1 PTY WebSocket Bridge 데몬 (`packages/mam_bridge`)
* **역할**: `shelf``shelf_web_socket`을 사용하여 로컬 루프백(`127.0.0.1`) 또는 지정 바인딩 포트에서 대기하는 HTTP/WebSocket 중계 서버 구현.
* **접속 엔드포인트**: `/ws/attach?session=<name>&server=<server_name>`
* **동작 시퀀스**:
1. WebSocket 핸드셰이크 요청이 도달하면 쿼리 파라미터(`session`, `server`)를 검증합니다.
2. `packages/mam_pty``PtySession.start('tmux', ['-L', server, 'attach', '-t', session])`을 안전하게 비동기 스폰합니다.
3. `PtySession.stdout` 바이트 스트림을 수신하는 즉시 WebSocket binary/text 프레임으로 래핑해 웹 브라우저로 전송합니다.
4. 웹 브라우저가 WebSocket 채널로 보낸 키보드/마우스 입력 데이터는 `PtySession.writeString()`으로 포워딩합니다.
5. WebSocket 연결이 끊어지거나 브라우저 탭이 닫히면 `PtySession.close()`를 즉시 호출하여 자식 tmux 프로세스를 `waitpid()`로 소거(reap)하고 PTY FFI 핸들을 원자적으로 닫습니다.
### 2.2 MAM Web 클라이언트 UI (`apps/mam_web`)
* **역할**: 대시보드 그리드 및 WebSocket 기반 터미널 위젯 이식.
* **상태 관리**: 기존 M1/M2의 `StatusRepository` 및 Riverpod 폴링 스트림을 웹 클라이언트 사양으로 동일하게 공유하여 대시보드 그리드를 유지합니다.
* **`TerminalPane` 웹 전용 컴포넌트**:
* PTY FFI 라이브러리를 임포트하지 않고 `package:web_socket_channel/web_socket_channel.dart`를 사용하여 백엔드 브릿지 서버에 소켓을 연결합니다.
* 소켓 스트림(`channel.stream`)을 `xterm` v4.0.0 `Terminal`로 바인딩합니다.
* `Terminal.onOutput` 콜백을 통해 발생하는 사용자 타이핑 데이터는 `channel.sink.add()`를 통해 WebSocket 프레임으로 쏩니다.
* 터미널 리사이즈(`Terminal.onResize`) 이벤트가 발생하면 `{"action": "resize", "cols": cols, "rows": rows}` 형태의 JSON 제어 프레임을 소켓으로 전송하여 백엔드 PTY FFI 단에서 `ioctl(TIOCSWINSZ)`이 기동되도록 연동합니다.
---
## 3. 안정성 및 보안 요구사항 (DoD Requirements)
* **자원 누수 방지 (Anti-Leak)**: 웹 브라우저가 갑자기 종료되거나 네트워크 끊김 현상이 발생할 때, 백엔드 데몬이 하트비트 (Ping-Pong) 또는 소켓 에러 이벤트를 즉각 감지해 `waitpid()`를 호출함으로써 좀비 defunct 프로세스가 시스템에 잔존하지 않도록 확실히 보증합니다.
* **비블로킹 보장 (Non-blocking)**: 데몬 서버 단에서도 `fcntl` O_NONBLOCK 및 non-blocking read loop가 동일하게 가동되어 다중 브라우저가 접속하더라도 백엔드 이벤트 루프가 정지되지 않도록 차단합니다.
* **접속 권한 제어**: 브릿지 서버는 기본적으로 로컬호스트(`127.0.0.1`) 바인딩으로 기동하여 외부 원격지로부터의 악성 터미널 탈취 공격을 원천 봉쇄합니다.
-48
View File
@@ -1,48 +0,0 @@
# 📑 MAM 스킬 최적화 세션 인수인계 요약서 (handoff.md)
이 인수인계서는 **Multi-Agent Mux (MAM)** 프로젝트에서 진행된 **개발 스킬 최적화 Phase 1 및 Phase 2 일부 구현 완료** 상태와, 에이전트 세션 토큰 한계(Rate limit) 및 검수 범위 제약으로 인해 보류된 **나머지 잔여 최적화 과제들**을 다음 차수에 연계하기 위해 작성되었습니다.
---
## 1. 🏁 현재 완료된 최적화 성과 (Phase 1 & 2 완료)
* **OP-1 (반응형 세션 종료 - Latency)**: `lib.sh``_wait_session_gone` 헬퍼를 도입하여 고정 8초 대기하던 문제를 조기 종료 감지 방식으로 해결 (평균 종료 시간 **8초 ➡️ 0.3초 미만**). `stop_session.sh``set -e` 강제 폭사 방지 `|| true` 가드 탑재.
* **OP-2 (MQTT 구독 동기화 핸드셰이크 - Latency)**: `job_subscriber.py``on_subscribe` 콜백 신호를 추가하고, `delegate-job`에서 `SUBSCRIBED` sentinel 폴링 및 `kill -0 $sub_pid` 프로세스 생존 검사를 병행 기입하여 **WAN 환경의 이벤트 유실을 박멸**하고 즉시 기동하도록 변경.
* **OP-3 (Event Loop CPU 점유 최적화 - Latency)**: `reconcile.sh` 백그라운드 파이썬 대기 루프를 `threading.Event().wait` Pacing으로 교체하여 **유휴 상태 CPU 점유율을 0%**로 단축.
* **OP-4 (Tmux Dispatcher 단일화 - DRY)**: 격리 서버 대응을 단일 canonical helper인 `mam_tmux`로 일원화하고, `_REAL_TMUX_PATH` 매핑을 강제하여 **무한 재귀(Stack Overflow) 리스크를 완전히 제거**.
* **OP-6 & OP-7 (토큰 상수화 및 zsh 소싱 가드 - Portability)**: 대화창 감지 정규식 토큰 단일화 및 zsh sourcing 방지를 위한 안전 경고 트랩 탑재.
두 리뷰어(Cline & Creator) 및 플래너 에이전트로부터 최종 정식 **PASS****APPROVED** 서명을 획득하여 릴리스 커밋 완수 (`7eeb4b7`).
---
## ⏭️ 추후 수행할 잔여 최적화 과제 (Next Action Items)
다음 세션에서 에이전트(Claude 등)에게 검수받고 실제로 이행해야 할 목록입니다:
### 1. OP-5: SQLite / YAML 데이터 로더 중복 제거 (DRY)
* **대상 파일**: `lib.sh` (3개소), `stop_session.sh:87`, `status.sh:42`, `update_yaml_resumed.sh:66`, `reconcile.sh:298`
* **현황 및 원인**: SQLite와 YAML에서 에이전트 병합 세션 상태를 로드하기 위한 파이썬 heredoc 블록이 **7개 파일에 복사-붙여넣기**되어 있습니다.
* **개선 방향**: `lib.sh` 내에 `load_state_json` 공통 함수를 단일 구현하고 스크립트 파일들이 이를 통해 파싱된 JSON 스트림만 읽도록 개선한 뒤, 플래너/리뷰어 검수 PASS를 획득해야 합니다.
### 2. OP-8: Degraded 모드 복구 루프 예외 처리 강화 (Observability)
* **대상 파일**: `reconcile.sh:269`
* **현황 및 원인**: 브로커 단절 시 복구를 유도하는 루프의 에러 출력이 `>/dev/null 2>&1 || true` 로 차단되어 있어 데이터베이스 락 등의 심각한 영구 장애 시에도 모니터가 성공인 척 묵인됩니다.
* **개선 방향**: 에러 출력을 캡처 및 로깅하고, 5회 연속 장애 시 복구 감시 프로세스를 종료 후 자가 재기동(Supervisor restart)하도록 보완하고 검수받아야 합니다.
### 3. Phase 3 이식성 보완 과제
* **OP-9 (POSIX NFS 탐지)**: GNU 전용 `df --output`을 POSIX 호환 `df -P`로 교체하여 macOS/BSD에서 flock WAL 안전 스위치가 침묵 속에서 비활성화되던 이슈 수정 (FUTURE_WORKS FW-P1/FW-D3 대응).
* **OP-10 (Marker-walk 루트 탐색)**: 깊이가 하드코딩된 `../../../../` 경로 추적을 폐지하고, 최상위 워크스페이스 마커 파일 감지 방식으로 루트 절대 경로 탐색기 적용 (FUTURE_WORKS FW-P6 대응).
### 4. 📂 [신규 구상] 작업 단위(Job) 중심 디렉터리 구조 재구조화 검토 및 스킬 수정 계획
* **개요 및 아이디어 검토**:
* 현재 지시사항(brief) 파일은 다양한 경로에 산재하고 있으며, 에이전트들의 보고서 파일은 일괄적으로 `.agents/reports/<agent_session_name>/` 아래에 적재되어 특정 작업(Job) 단위의 보고서 역추적이 번거롭습니다.
* **사용자 제안 아키텍처**:
1. 작업이 등록되면 **`.mam/jobs/<job_id>/`** 라는 고유 작업 단위 폴더를 생성합니다.
2. 해당 폴더 하위에 **지시사항 brief 파일**을 기록해 다른 에이전트들에게 전달합니다.
3. 작업을 위임받아 수행한 다른 에이전트들의 결과 보고서들은 기존 공통 폴더 대신 해당 작업 폴더 내부인 **`.mam/jobs/<job_id>/{agent-id}-reports/`** 디렉터리에 격리 저장하도록 경로를 치환합니다.
* **검토 의견 (APPROVED)**: 작업(Job)을 중심으로 한 지시서와 결과 보고서의 귀속성과 추적성(Audit Trail)을 극대화할 수 있는 매우 훌륭한 구조적 개선안입니다.
* **스킬 수정 영향 범위 및 이행 계획**:
* `multi-agent-mux-delegate-job` 스크립트 수정: 잡 등록 시 `.mam/jobs/<job_id>/` 디렉터리를 자동 생성하고 지시서 마크다운을 그 하위로 동적 출력하도록 변경.
* 리뷰어 및 크리에이터 에이전트의 구동 인자 및 스크립트 수정: 보고서 출력 경로를 환경변수 또는 인자로 전달받은 잡 하위 디렉터리(`$JOB_DIR/$AGENT_ID-reports/`)로 치환하여 라이팅하도록 패치.
+77
View File
@@ -0,0 +1,77 @@
# [보고서] MAM 위임 도구의 역할(Role) 지정 옵션 누락 이슈 분석
본 문서는 멀티 에이전트 오케스트레이션 프레임워크(`multi-agent-mux`)의 핵심 CLI 도구인 `multi-agent-mux-delegate-job`에서 세션의 역할(Role)을 지정할 수 있는 옵션이 누락되어 발생하는 정합성 충돌 문제와 이에 대한 원인 분석 및 해결 방안을 정의합니다.
---
## 1. 문제가 발생한 정확한 상황 (Context)
프로젝트 개발을 오케스트레이션하는 과정에서 아래와 같은 에이전트 간 역할 분담을 적용하고자 했습니다.
* **개발 팀장 (Antigravity)**: 실제 저장소의 문서 수정 및 구현 진행 (**Worker/Implementer**)
* **리뷰 에이전트 (Claude)**: 문서 구조의 설계 및 계획안 수립 (**Planner**)
이 분담에 따라 Claude 세션(`canary-projects-grpccanary-creator-claude`)에 "문서 모듈화 계획 및 체크리스트 작성" 작업을 위임하기 위해 `multi-agent-mux-delegate-job` 도구로 비동기 작업을 요청했습니다.
그러나 자동 생성된 잡 지시서인 `.mam/jobs/<job_id>/brief.md` 파일의 메타데이터에 다음과 같이 **구현자의 역할이 `Worker`로 강제 지정**되어 나가는 상황이 발생했습니다:
```markdown
# 📋 Brief: Job ed31b5fb Delegation
- **Job ID**: ed31b5fb
- **Target Agent**: claude (session: tmux:canary-projects-grpccanary-creator-claude)
- **Role**: Worker <-- [이슈 발생 지점: Planner가 아닌 Worker로 강제 지정됨]
- **Timeout**: 3600 s (Idle: 120 s)
```
이는 프로젝트 협업 규칙(`.agents/MULTI_AGENT_RULES.ko.md`)에 명시된 **"에이전트 역할 범위 준수 원칙(Role Suitability Check)"**에 위배되며, `claude`가 문서 작성이 아닌 파일 직접 수정을 시도할 위험이 있는 정합성 모순을 유발합니다.
---
## 2. 문제 사유 (Root Cause)
이 문제의 근본적인 기술적 원인은 **CLI 인수 파싱 로직 및 지시서(Brief) 생성 템플릿의 하드코딩**에 있습니다.
1. **CLI 옵션 설계 누락**:
* `multi-agent-mux-delegate-job submit` 명령어의 헬프 스펙을 확인한 결과, `--agent`, `--agent-session`, `--prompt` 등의 인수는 정의되어 있으나, 작업의 논리적 성격을 조율하는 **`--role <role_name>` 파라미터가 구현되어 있지 않습니다**.
2. **템플릿 내부의 상수 고정**:
* API를 통해 비동기 잡이 수임될 때 생성되는 `brief.md` 파일과 잡 레지스트리 JSON의 생성기 로직 내부에 `Role` 값이 **`Worker` 문자열 상수로 하드코딩**되어 동작하고 있습니다. 이로 인해 어떤 에이전트에 어떤 종류의 명령을 위임하더라도 메타데이터상으로는 항상 `Worker`로 바인딩됩니다.
---
## 3. 문제 해결 방법 (Remediation & Workarounds)
### 3.1 단기적 우회 방법 (Workaround)
프레임워크 CLI 소스코드를 수정하기 어려운 제한적 상황에서는 **프롬프트 페이로드(Prompt Payload) 하드닝** 기법을 사용하여 에이전트의 오작동을 차단합니다.
* **해결 원리**: brief.md의 메타데이터상 `Role: Worker` 지정을 덮어쓸 수 있도록, 프롬프트 문맥 내부에 **"너의 역할은 실제 문서를 수정하지 않고 계획만 수립하는 Planner이다. 절대 문서를 직접 수정하지 말라"**는 강력한 지시 제약(System-level Rule Override)을 포함하여 송신합니다.
* **효과**: AI 에이전트는 메타데이터보다 프롬프트 지시어의 행위 제약을 우선 순위로 받아들이므로, 의도한 대로 설계서 및 계획안만 수립하는 Planner 동작을 정상 수행하게 됩니다.
### 3.2 근본적인 해결 방법 (Remediation)
프레임워크의 CLI 래퍼인 `multi-agent-mux-delegate-job` 파일의 파싱 로직 및 brief.md 빌더 로직을 다음과 같이 수정합니다.
#### 1단계: CLI 인수 파서 수정 (`submit` 옵션 추가)
스크립트의 인수 파싱 영역에 `--role` 파라미터를 식별할 수 있는 변수 및 분기 로직을 선언합니다.
```bash
# 옵션 분석 루프 예시
while [[ $# -gt 0 ]]; do
case $1 in
--role)
DELEGATE_ROLE="$2"
shift 2
;;
# ... 기존 옵션 파싱 ...
esac
done
# 기본값 정의
DELEGATE_ROLE="${DELEGATE_ROLE:-Worker}"
```
#### 2단계: `brief.md` 생성 템플릿 연동
잡 디렉토리 내에 `brief.md`를 기입하여 내보내는 빌더 영역(Python 혹은 쉘 스크립트 에코 영역)을 다음과 같이 동적 변수와 연결합니다.
```diff
- echo "- **Role**: Worker" >> "$BRIEF_PATH"
+ echo "- **Role**: ${DELEGATE_ROLE}" >> "$BRIEF_PATH"
```
#### 3단계: 잡 레지스트리 JSON 메타데이터 갱신
동일하게 생성되는 `.mam/jobs/<job_id>.json` 파일 등의 메타데이터 생성 객체 내에 `role: DELEGATE_ROLE` 매핑 키를 추가하여, 타 모니터링 도구(예: `reconcile.sh``status.sh`)에서도 해당 에이전트의 잡 실행 역할을 정확하게 대시보드에 모니터링할 수 있도록 보완합니다.
+568
View File
@@ -0,0 +1,568 @@
import os
import shutil
import json
import uuid
import sqlite3
import pytest
@pytest.fixture
def mam_sandbox(tmp_path, monkeypatch):
"""
Manages a temporary sandboxed directory for .mam/ configuration files
and copies the scripts from .agents/skills to prevent contaminating the real codebase.
Also overrides environments (HOME, PATH, AGENT_SESSIONS_YAML, etc.) for isolation.
"""
# 1. Copy the skill scripts to sandboxed tmp_path/skills and tmp_path/.agents/skills
src_skills = "/home/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.agents/skills"
shutil.copytree(src_skills, tmp_path / "skills")
shutil.copytree(src_skills, tmp_path / ".agents" / "skills")
# 2. Create sandboxed directory structure
mam_dir = tmp_path / ".mam"
mam_dir.mkdir(parents=True, exist_ok=True)
yaml_path = mam_dir / "agent-sessions.yaml"
# Create empty registry structure
yaml_path.write_text("herdr_sessions: []\ndelegation_jobs: []\n")
# 3. Setup mock home directory contents
(tmp_path / ".claude" / "projects").mkdir(parents=True, exist_ok=True)
(tmp_path / ".local" / "bin").mkdir(parents=True, exist_ok=True)
# 4. Monkeypatch environment variables to point inside the sandbox
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("HOME_DIR", str(tmp_path))
monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(tmp_path / ".claude" / "projects"))
monkeypatch.setenv("LOCAL_BIN", str(tmp_path / ".local" / "bin"))
monkeypatch.setenv("AGENT_SESSIONS_YAML", str(yaml_path))
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
import sys
monkeypatch.setenv("AGENT_PYTHON_BIN", sys.executable)
return tmp_path
@pytest.fixture
def mock_herdr(mam_sandbox, monkeypatch):
"""
Generates a mock 'herdr' executable and prepends it to PATH.
Allows tests to control and verify state via mock_herdr_state.json.
Automatically generates mock session database files upon 'agent start'.
"""
tmp_path = mam_sandbox
state_file = tmp_path / "mock_herdr_state.json"
# Initial mock state
initial_state = {
"workspaces": [
{
"workspace_id": "w1",
"label": "default",
"cwd": str(tmp_path)
}
],
"agents": {},
"calls": []
}
state_file.write_text(json.dumps(initial_state, indent=2))
bin_dir = tmp_path / "bin"
bin_dir.mkdir(parents=True, exist_ok=True)
herdr_bin = bin_dir / "herdr"
# Write Python implementation of mock herdr (using string replacement to avoid f-string escaping issues)
# Double escape backslashes for newline (\\\\n) and write correctly
code = """#!/usr/bin/env python3
import sys
import os
import json
import uuid
import sqlite3
import fcntl
state_file = "STATE_FILE_PLACEHOLDER"
if not os.path.exists(state_file):
sys.exit(0)
# Lock the state file exclusively to prevent concurrent race conditions
lock_f = open(state_file + ".lock", "w")
fcntl.flock(lock_f, fcntl.LOCK_EX)
with open(state_file, 'r') as f:
state = json.load(f)
# Record the command call
state["calls"].append(sys.argv[1:])
def save_state():
with open(state_file, 'w') as f:
json.dump(state, f, indent=2)
# Save calls immediately so they persist even if we exit early or error out
save_state()
args = sys.argv[1:]
if args and args[0] == "--session":
if len(args) > 1:
args = args[2:]
else:
args = args[1:]
if not args:
sys.exit(0)
cmd1 = args[0]
if cmd1 == "workspace":
if len(args) < 2:
sys.exit(0)
cmd2 = args[1]
if cmd2 == "list":
res = {"workspaces": state.get("workspaces", [])}
print(json.dumps({"result": res}))
sys.exit(0)
elif cmd2 == "create":
label = "default"
cwd = "."
i = 2
while i < len(args):
if args[i] == "--label":
label = args[i+1]
i += 2
elif args[i] == "--cwd":
cwd = args[i+1]
i += 2
else:
i += 1
workspaces = state.get("workspaces", [])
if not any(w["label"] == label for w in workspaces):
ws_id = f"w{len(workspaces) + 1}"
workspaces.append({"workspace_id": ws_id, "label": label, "cwd": cwd})
state["workspaces"] = workspaces
save_state()
sys.exit(0)
elif cmd1 == "agent":
if len(args) < 2:
sys.exit(0)
cmd2 = args[1]
if cmd2 == "list":
agents_list = []
for name, data in state.get("agents", {}).items():
agents_list.append({
"name": name,
"agent": data.get("agent", "claude"),
"agent_status": data.get("status", "running"),
"cwd": data.get("cwd", ""),
"pane_id": data.get("pane_id", "w1:p1"),
"workspace_id": data.get("workspace_id", "w1")
})
print(json.dumps({"result": {"agents": agents_list}}))
sys.exit(0)
elif cmd2 == "start":
if len(args) < 3:
sys.exit(1)
name = args[2]
ws = ""
cwd = ""
# Find where -- is
try:
double_dash_idx = args.index("--")
agent_cmd = args[double_dash_idx+1:]
opts = args[3:double_dash_idx]
except ValueError:
agent_cmd = []
opts = args[3:]
i = 0
while i < len(opts):
if opts[i] == "--workspace":
ws = opts[i+1]
i += 2
elif opts[i] == "--cwd":
cwd = opts[i+1]
i += 2
elif opts[i] == "--env":
env_val = opts[i+1]
if "=" in env_val:
k, v = env_val.split("=", 1)
os.environ[k] = v
i += 2
else:
i += 1
# Determine the agent type (claude, agy, hermes, cline)
agent_type = "claude"
if agent_cmd:
if "claude" in agent_cmd[0]:
agent_type = "claude"
elif "agy" in agent_cmd[0]:
agent_type = "agy"
elif "hermes" in agent_cmd[0]:
agent_type = "hermes"
elif "cline" in agent_cmd[0]:
agent_type = "cline"
else:
# guess from name
if "claude" in name:
agent_type = "claude"
elif "agy" in name:
agent_type = "agy"
elif "hermes" in name:
agent_type = "hermes"
elif "cline" in name:
agent_type = "cline"
# TUI Welcome Tokens definition to prevent TUI readiness check timeout
buffer_content = {
"claude": "Anthropic Claude Ready",
"agy": "Antigravity Ready",
"hermes": "Hermes Ready",
"cline": "Cline Chat Ready"
}.get(agent_type, "Ready")
agents = state.get("agents", {})
agents[name] = {
"agent": agent_type,
"status": "running",
"cwd": cwd or "TMP_PATH_PLACEHOLDER",
"workspace_id": ws or "w1",
"pid": 9999,
"pane_id": "w1:p1",
"command": " ".join(agent_cmd),
"buffer": buffer_content
}
# Generate session/conversation UUID and files to simulate agent startup
session_uuid = str(uuid.uuid4())
own_key_map = {
"claude": "claude_session_id_own",
"agy": "agy_conversation_id_own",
"hermes": "hermes_conversation_id_own",
"cline": "cline_conversation_id_own"
}
own_key = own_key_map.get(agent_type)
if own_key:
agents[name][own_key] = session_uuid
# Find isolation directory (e.g. if HOME has agent_homes/<uuid>)
home_dir = os.environ.get("HOME", "TMP_PATH_PLACEHOLDER")
ws_abs = os.path.abspath(cwd or "TMP_PATH_PLACEHOLDER")
ws_key = ws_abs.replace('/', '-').replace('_', '-')
if agent_type == "claude":
ccd = os.environ.get("CLAUDE_CONFIG_DIR")
if ccd:
cp_dir = os.path.join(ccd, "projects")
else:
cp_dir = os.environ.get("CLAUDE_PROJECT_DIR", f"{home_dir}/.claude/projects")
proj_dir = os.path.join(cp_dir, ws_key)
os.makedirs(proj_dir, exist_ok=True)
jsonl_file = os.path.join(proj_dir, f"{session_uuid}.jsonl")
with open(jsonl_file, 'w') as jf:
jf.write(json.dumps({"sessionId": session_uuid}) + "\\\\n")
elif agent_type == "agy":
db_dir = os.path.join(home_dir, ".gemini", "antigravity-cli", "conversations")
os.makedirs(db_dir, exist_ok=True)
db_file = os.path.join(db_dir, f"{session_uuid}.db")
with open(db_file, 'w') as df:
df.write("")
lc_dir = os.path.join(home_dir, ".gemini", "antigravity-cli", "cache")
os.makedirs(lc_dir, exist_ok=True)
lc_file = os.path.join(lc_dir, "last_conversations.json")
lc_data = {}
if os.path.exists(lc_file):
try:
with open(lc_file, 'r') as lcf:
lc_data = json.load(lcf)
except Exception:
pass
lc_data[ws_abs] = session_uuid
with open(lc_file, 'w') as lcf:
json.dump(lc_data, lcf)
elif agent_type == "hermes":
hermes_dir = os.path.join(home_dir, ".hermes")
os.makedirs(hermes_dir, exist_ok=True)
hdb = os.path.join(hermes_dir, "state.db")
conn = sqlite3.connect(hdb)
conn.execute("CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, cwd TEXT, started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)")
conn.execute("INSERT OR REPLACE INTO sessions (id, cwd) VALUES (?, ?)", (session_uuid, ws_abs))
conn.commit()
conn.close()
elif agent_type == "cline":
clin_base = os.path.join(home_dir, ".cline", "data", "sessions")
if "agent_homes" in home_dir:
clin_base = os.path.join(home_dir, "sessions")
session_dir = os.path.join(clin_base, session_uuid)
os.makedirs(session_dir, exist_ok=True)
json_file = os.path.join(session_dir, f"{session_uuid}.json")
with open(json_file, 'w') as jf:
jf.write(json.dumps({"id": session_uuid}))
state["agents"] = agents
save_state()
sys.exit(0)
elif cmd2 == "get":
if len(args) < 3:
sys.exit(1)
name = args[2]
agents = state.get("agents", {})
if name in agents:
agent_data = agents[name]
pane_info = {
"pid": agent_data.get("pid", 9999),
"cwd": agent_data.get("cwd", ""),
"command": agent_data.get("command", ""),
"argv": agent_data.get("command", "")
}
print(json.dumps({
"pane": pane_info,
"result": {
"agent": {
"agent": agent_data.get("agent", "claude"),
"status": agent_data.get("status", "running"),
"cwd": agent_data.get("cwd", ""),
"pane_id": agent_data.get("pane_id", "w1:p1"),
"workspace_id": agent_data.get("workspace_id", "w1")
}
}
}))
sys.exit(0)
else:
sys.stderr.write("Agent " + name + " not found\\\\n")
sys.exit(1)
elif cmd2 == "read":
if len(args) < 3:
sys.exit(1)
name = args[2]
agents = state.get("agents", {})
if name in agents:
buffer_content = agents[name].get("buffer", "Ready")
print(buffer_content)
sys.exit(0)
else:
sys.stderr.write("Agent " + name + " not found\\\\n")
sys.exit(1)
elif cmd2 == "send":
if len(args) < 4:
sys.exit(1)
name = args[2]
text = args[3]
agents = state.get("agents", {})
if name in agents:
agents[name]["sent_text"] = agents[name].get("sent_text", "") + text
if text == "C-m":
agents[name]["buffer"] = agents[name].get("buffer", "") + "\\nesc to interrupt"
else:
agents[name]["buffer"] = agents[name].get("buffer", "") + "\\n" + text
if "/exit" in text or "exit" in text or "Exit" in text:
agents[name]["status"] = "stopped"
state["agents"] = agents
save_state()
sys.exit(0)
else:
sys.stderr.write("Agent " + name + " not found\\\\n")
sys.exit(1)
elif cmd1 == "session":
if len(args) < 3:
sys.exit(1)
cmd2 = args[1]
name = args[2]
agents = state.get("agents", {})
if cmd2 == "stop":
if name in agents:
agents[name]["status"] = "stopped"
state["agents"] = agents
save_state()
sys.exit(0)
else:
sys.exit(1)
elif cmd2 == "delete":
if name in agents:
del agents[name]
state["agents"] = agents
save_state()
sys.exit(0)
else:
sys.exit(1)
elif cmd1 == "pane":
if len(args) < 3:
sys.exit(1)
cmd2 = args[1]
if cmd2 == "send-keys":
if len(args) < 4:
sys.exit(1)
name = args[2]
key = args[3]
agents = state.get("agents", {})
actual_name = name
for a_name, data in agents.items():
if data.get("pane_id") == name:
actual_name = a_name
break
if actual_name in agents:
agents[actual_name]["sent_keys"] = agents[actual_name].get("sent_keys", []) + [key]
if key in ("Enter", "C-m"):
agents[actual_name]["buffer"] = agents[actual_name].get("buffer", "") + "\\nesc to interrupt"
state["agents"] = agents
save_state()
sys.exit(0)
else:
sys.exit(1)
elif cmd2 == "process-info":
pane_id = ""
if "--pane" in args:
pane_id = args[args.index("--pane") + 1]
pid = 9999
for name, data in state.get("agents", {}).items():
if data.get("pane_id") == pane_id or pane_id == "w1:p1":
pid = data.get("pid", 9999)
break
res = {
"process_info": {
"foreground_processes": [{"pid": pid}],
"shell_pid": pid
}
}
print(json.dumps({"result": res}))
sys.exit(0)
elif cmd2 == "close":
pane_id = args[2]
agents = state.get("agents", {})
to_delete = []
for name, data in agents.items():
if data.get("pane_id") == pane_id or pane_id == "w1:p1":
to_delete.append(name)
for name in to_delete:
del agents[name]
state["agents"] = agents
save_state()
sys.exit(0)
elif cmd1 == "ls":
if "-F" in args:
for name, data in state.get("agents", {}).items():
print(f"{name}|999999")
sys.exit(0)
with open("/tmp/debug_mock_herdr.log", "a") as f_debug:
f_debug.write(f"ARGS: {sys.argv[1:]} | AGENTS: {list(state.get('agents', {}).keys())} | PATH: {os.path.exists(state_file)}\\n")
agents_list = []
for name, data in state.get("agents", {}).items():
agents_list.append({
"name": name,
"agent": data.get("agent", "claude"),
"agent_status": data.get("status", "running")
})
print(json.dumps({"result": {"agents": agents_list}}))
sys.exit(0)
sys.exit(0)
""".replace("STATE_FILE_PLACEHOLDER", str(state_file)).replace("TMP_PATH_PLACEHOLDER", str(tmp_path))
herdr_bin.write_text(code)
herdr_bin.chmod(0o755)
# Update PATH using monkeypatch.
# Prepend bin_dir, and append standard Homebrew/system paths so lib.sh won't prepend them
old_path = os.environ.get("PATH", "")
extra_dirs = [
"/home/linuxbrew/.linuxbrew/bin",
"/home/linuxbrew/.linuxbrew/sbin",
f"{tmp_path}/.local/bin",
f"{tmp_path}/.npm-global/bin"
]
new_path = old_path
for d in extra_dirs:
if d not in new_path:
new_path = f"{new_path}:{d}"
monkeypatch.setenv("PATH", f"{bin_dir}:{new_path}")
return state_file
@pytest.fixture
def mock_agents(mam_sandbox, monkeypatch):
"""
Generates mock agent binaries (claude, agy, hermes, cline)
and places them in the sandboxed bin folder to satisfy preflight checks.
"""
tmp_path = mam_sandbox
bin_dir = tmp_path / "bin"
bin_dir.mkdir(parents=True, exist_ok=True)
# 1. claude mock
claude_bin = bin_dir / "claude"
claude_bin.write_text("""#!/usr/bin/env python3
import sys
import json
args = sys.argv[1:]
if len(args) >= 2 and args[0] == "auth" and args[1] == "status":
print(json.dumps({"loggedIn": True}))
sys.exit(0)
sys.exit(0)
""")
claude_bin.chmod(0o755)
# 2. agy mock
agy_bin = bin_dir / "agy"
agy_bin.write_text("""#!/usr/bin/env python3
import sys
args = sys.argv[1:]
if len(args) >= 1 and args[0] == "models":
print("gemini-1.5-pro\\ngemini-1.5-flash")
sys.exit(0)
sys.exit(0)
""")
agy_bin.chmod(0o755)
# 3. hermes mock
hermes_bin = bin_dir / "hermes"
hermes_bin.write_text("""#!/usr/bin/env python3
import sys
args = sys.argv[1:]
if len(args) >= 1 and args[0] == "status":
print("Hermes functional")
sys.exit(0)
sys.exit(0)
""")
hermes_bin.chmod(0o755)
# 4. cline mock
cline_bin = bin_dir / "cline"
cline_bin.write_text("""#!/usr/bin/env python3
import sys
import json
args = sys.argv[1:]
if len(args) >= 1 and args[0] == "history":
print(json.dumps([]))
sys.exit(0)
sys.exit(0)
""")
cline_bin.chmod(0o755)
# 5. uuidgen mock to guarantee isolated creation UUIDs
uuidgen_bin = bin_dir / "uuidgen"
uuidgen_bin.write_text("""#!/usr/bin/env python3
import uuid
print(str(uuid.uuid4()))
""")
uuidgen_bin.chmod(0o755)
# Prepend bin directory to PATH
old_path = os.environ.get("PATH", "")
extra_dirs = [
"/home/linuxbrew/.linuxbrew/bin",
"/home/linuxbrew/.linuxbrew/sbin",
f"{tmp_path}/.local/bin",
f"{tmp_path}/.npm-global/bin"
]
new_path = old_path
for d in extra_dirs:
if d not in new_path:
new_path = f"{new_path}:{d}"
monkeypatch.setenv("PATH", f"{bin_dir}:{new_path}")
return bin_dir
+182
View File
@@ -0,0 +1,182 @@
import os
import subprocess
import json
import pytest
import sys
from pathlib import Path
# Helper to run mutation on agent-sessions.yaml using atomic_dump_yaml in bash
def run_mutation(mam_sandbox, mutation_str, env=None):
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
cmd_str = f"source {lib_path} && atomic_dump_yaml {yaml_path}"
run_env = dict(os.environ)
if env:
run_env.update(env)
res = subprocess.run(["bash", "-c", cmd_str], input=mutation_str, capture_output=True, text=True, env=run_env)
return res
def test_unbound_key_handling(mam_sandbox, mock_herdr):
"""
Verify unbound key handling: herdr has-session/kill-session with trailing parameters
handles it gracefully (returns status 1 with error) instead of crashing with unbound var or shift errors.
"""
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
subprocess.run(["bash", "-c", f"source {lib_path}"], cwd=str(mam_sandbox), env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox)))
shim_path = mam_sandbox / ".mam" / "shim" / "herdr"
assert shim_path.exists(), "Shim herdr was not created"
# Run has-session with trailing -t
res = subprocess.run([str(shim_path), "has-session", "-t"], capture_output=True, text=True, env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr)))
assert res.returncode == 1
assert "Error: -t requires a value" in res.stderr
# Run kill-session with trailing -t
res = subprocess.run([str(shim_path), "kill-session", "-t"], capture_output=True, text=True, env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr)))
assert res.returncode == 1
assert "Error: -t requires a value" in res.stderr
def test_new_session_fallback_shell(mam_sandbox, mock_herdr):
"""
Verify new-session: launches shell when no command is provided.
"""
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
subprocess.run(["bash", "-c", f"source {lib_path}"], cwd=str(mam_sandbox), env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox)))
shim_path = mam_sandbox / ".mam" / "shim" / "herdr"
test_shell = "/bin/custom_sh"
run_env = dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr), SHELL=test_shell)
res = subprocess.run([str(shim_path), "new-session", "-s", "fallback-session"], capture_output=True, text=True, env=run_env)
assert res.returncode == 0, f"Stderr: {res.stderr}"
with open(mock_herdr, 'r') as f:
state = json.load(f)
calls = state.get("calls", [])
start_call = None
for call in calls:
if "agent" in call and "start" in call and "fallback-session" in call:
start_call = call
break
assert start_call is not None, f"No agent start call found in: {calls}"
assert start_call[-1] == test_shell, f"Expected last arg to be {test_shell}, got {start_call[-1]}"
def test_ls_key_error(mam_sandbox):
"""
Verify ls key error: parses agent list without 'name' correctly using fallback keys.
"""
py_code = """
import sys, json
try:
data = json.load(sys.stdin)
res = data.get('result', data)
for a in res.get('agents', []):
try:
name = a.get('name') or a.get('agent') or 'unknown'
print(f"{name}|0")
except Exception:
pass
except Exception:
pass
"""
input_json = json.dumps({
"result": {
"agents": [
{"agent": "claude", "status": "running"},
{"name": "agent-with-name", "agent": "agy"}
]
}
})
res = subprocess.run([sys.executable, "-c", py_code], input=input_json, capture_output=True, text=True)
assert res.returncode == 0
lines = res.stdout.strip().split('\n')
assert "claude|0" in lines
assert "agent-with-name|0" in lines
def test_reconcile_relocatability(mam_sandbox, mock_herdr):
"""
Verify relocatability: reconcile.sh runs when relocated or inside different paths.
"""
reconcile_script = mam_sandbox / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
run_dir = mam_sandbox / "some_other_dir"
run_dir.mkdir()
res = subprocess.run(["bash", str(reconcile_script), "--once", "--emit-diff", "--dry-run"], cwd=str(run_dir), capture_output=True, text=True, env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr)))
assert res.returncode == 0, f"Failed when run from different directory. Stderr: {res.stderr}"
def test_workspace_server_mapping(mam_sandbox, mock_herdr):
"""
Verify workspace/server mapping: status output workspace is correct.
"""
status_script = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
session_name = "test-mapping-sess-creator-claude"
mutation = f"""
d['herdr_sessions'] = [{{
'name': '{session_name}',
'status': 'running',
'role': 'Creator',
'herdr_session': 'my-custom-workspace',
'pane': {{
'cwd': 'WS_PLACEHOLDER',
'pid': 7777,
'cmd': 'claude',
'cmd_full': 'claude'
}}
}}]
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
res_mut = run_mutation(mam_sandbox, mutation)
assert res_mut.returncode == 0
res = subprocess.run(["bash", str(status_script), "--json"], capture_output=True, text=True, cwd=str(mam_sandbox), env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr)))
assert res.returncode == 0, f"Stderr: {res.stderr}"
data = json.loads(res.stdout)
sess_detail = data["sessions_detail"]
target_sess = [s for s in sess_detail if s["name"] == session_name][0]
assert target_sess["server"] == "my-custom-workspace"
def test_variable_splicing_injection_safety(mam_sandbox, mock_herdr):
"""
Verify variable splicing injection: no vulnerabilities or syntax errors remain.
"""
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
subprocess.run(["bash", "-c", f"source {lib_path}"], cwd=str(mam_sandbox), env=dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox)))
shim_path = mam_sandbox / ".mam" / "shim" / "herdr"
adversarial_name = 'my"server; import os; os.system("echo INJECTED")'
run_env = dict(os.environ, HOME=str(mam_sandbox), WORKSPACE_ROOT=str(mam_sandbox), REAL_HERDR=str(mock_herdr), HERDR_SERVER_NAME=adversarial_name)
res = subprocess.run([str(shim_path), "new-session", "-s", "injection-test-session"], capture_output=True, text=True, env=run_env)
assert res.returncode == 0, f"Failed with adversarial HERDR_SERVER_NAME. Stderr: {res.stderr}"
with open(mock_herdr, 'r') as f:
state = json.load(f)
assert any("injection-test-session" in call for call in state["calls"]), "Expected injection-test-session call to succeed"
def test_export_masking_exit_code_preservation():
"""
Verify export masking: exit codes are preserved when assigning and exporting.
"""
# 1. Export masked version exits 0 (silent fail)
cmd_masked = ["bash", "-c", "set -e; export TEST_VAR=$(false); echo 'survived'"]
res_masked = subprocess.run(cmd_masked, capture_output=True, text=True)
assert res_masked.returncode == 0
assert res_masked.stdout.strip() == "survived"
# 2. Fixed split version exits non-zero (preserves failure exit code)
cmd_fixed = ["bash", "-c", "set -e; TEST_VAR=$(false); export TEST_VAR; echo 'survived'"]
res_fixed = subprocess.run(cmd_fixed, capture_output=True, text=True)
assert res_fixed.returncode != 0
assert res_fixed.stdout.strip() != "survived"
+205
View File
@@ -0,0 +1,205 @@
import os
import subprocess
import json
import sqlite3
import pytest
import shutil
import yaml
import sys
from pathlib import Path
# Helper to run mutation on agent-sessions.yaml using atomic_dump_yaml in bash
def run_mutation(mam_sandbox, mutation_str, env=None):
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
cmd_str = f"source {lib_path} && atomic_dump_yaml {yaml_path}"
run_env = dict(os.environ)
if env:
run_env.update(env)
res = subprocess.run(["bash", "-c", cmd_str], input=mutation_str, capture_output=True, text=True, env=run_env)
return res
def test_stop_session_unvalidated_agent(mam_sandbox, mock_herdr, mock_agents):
"""
Test case 1: Unvalidated agent argument in stop_session.sh
Verify that calling stop_session.sh with an invalid agent name returns code 2
and exits with a clear error message to stderr.
"""
tmp_path = mam_sandbox
stop_script = tmp_path / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
# 1. Register a session first
session_name = "invalid-agent-session-creator-claude"
mutation = """
d['herdr_sessions'] = [{
'name': 'invalid-agent-session-creator-claude',
'status': 'running',
'role': 'Creator',
'pane': {
'cwd': 'WS_PLACEHOLDER',
'pid': 8888,
'cmd': 'claude',
'cmd_full': 'claude'
}
}]
""".replace("WS_PLACEHOLDER", str(tmp_path))
res_mut = run_mutation(tmp_path, mutation)
assert res_mut.returncode == 0
# 2. Try stopping with --agent invalid_agent and --purge-conversation
cmd_stop = [
"bash", str(stop_script),
"--session", session_name,
"--agent", "invalid_agent",
"--purge-conversation",
"--yes"
]
res_stop = subprocess.run(cmd_stop, capture_output=True, text=True, cwd=str(tmp_path))
# It returns code 2
assert res_stop.returncode == 2
# And prints invalid agent type error to stderr
assert "ERROR: invalid agent type 'invalid_agent'" in res_stop.stderr
# The registry entry is NOT removed
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
with open(yaml_path, 'r') as f:
reg = yaml.safe_load(f)
assert len(reg.get("herdr_sessions", [])) == 1
def test_special_character_workspace_slug_interpretation(mam_sandbox):
"""
Test case 2: Special character workspace slug interpretation
Verify that workspace paths with only special characters result in derived session names
that do NOT start with dashes, preventing option flags injection.
"""
tmp_path = mam_sandbox
lib_path = tmp_path / ".agents" / "skills" / "lib.sh"
# Workspace consisting of purely special characters
special_ws = tmp_path / "@#$*" / "@#$*"
special_ws.mkdir(parents=True, exist_ok=True)
cmd = f"source {lib_path} && derive_session_name '{special_ws}' 'claude'"
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, cwd=str(tmp_path))
assert res.returncode == 0
derived_name = res.stdout.strip()
# Confirm that the derived name does NOT start with a dash and is default "ws"
assert not derived_name.startswith("-")
assert derived_name == "ws-creator-claude"
def test_monitor_reconcile_race_on_purge(mam_sandbox, mock_herdr):
"""
Test case 3: Concurrency/Race condition during session purge
Verify that if a session is being purged, the presence of the purging lock file
prevents concurrent monitor checks from auto-registering it back.
"""
tmp_path = mam_sandbox
reconcile_script = tmp_path / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
# 1. Register a running session in herdr and YAML
session_name = "purged-race-creator-claude"
with open(mock_herdr, 'r') as f:
state = json.load(f)
state["agents"][session_name] = {
"status": "running",
"agent": "claude",
"cwd": str(tmp_path),
"pid": 9999,
"pane_id": "w1:p1",
"command": "claude"
}
with open(mock_herdr, 'w') as f:
json.dump(state, f, indent=2)
mutation = f"""
d['herdr_sessions'] = [{{
'name': '{session_name}',
'status': 'running',
'role': 'Creator',
'pane': {{
'cwd': 'WS_PLACEHOLDER',
'pid': 9999,
'cmd': 'claude',
'cmd_full': 'claude'
}}
}}]
""".replace("WS_PLACEHOLDER", str(tmp_path))
res_mut = run_mutation(tmp_path, mutation)
assert res_mut.returncode == 0
# 2. Simulate a purge: stop_session.sh deletes the registry row, but herdr session takes a moment to die.
# We remove it from the YAML registry, and create the purging lock file.
mutation_purge = f"""
d['herdr_sessions'] = [s for s in d.get('herdr_sessions', []) if s.get('name') != '{session_name}']
"""
res_purge_mut = run_mutation(tmp_path, mutation_purge)
assert res_purge_mut.returncode == 0
# Create the purging lock file
purging_file = tmp_path / ".mam" / f"purging-{session_name}"
purging_file.parent.mkdir(parents=True, exist_ok=True)
purging_file.touch()
# Confirm YAML registry has no sessions
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
with open(yaml_path, 'r') as f:
reg = yaml.safe_load(f)
assert len(reg.get("herdr_sessions", [])) == 0
# 3. Run reconcile.sh once. It should NOT auto-register it back because the purging lock file exists!
cmd_reconcile = ["bash", str(reconcile_script), "--once", "--emit-diff"]
res_rec = subprocess.run(cmd_reconcile, capture_output=True, text=True, cwd=str(tmp_path))
assert res_rec.returncode == 0
# Verify that the session has NOT been auto-registered back in YAML
with open(yaml_path, 'r') as f:
reg_after = yaml.safe_load(f)
sessions_after = reg_after.get("herdr_sessions", [])
assert len(sessions_after) == 0
def test_mqtt_hmac_and_seq_validation(mam_sandbox):
"""
Test case 4: MQTT HMAC and sequence number validation
Verify that mqtt_common correctly verifies HMAC signatures and drops messages with
invalid signatures or out-of-order sequence numbers.
"""
# Import mqtt_common from sandboxed folder
sys.path.insert(0, str(mam_sandbox / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "scripts"))
import mqtt_common
# 1. verify_hmac behaviour with no auth_token (PoC mode)
payload_poc = {"data": {"val": 123}}
assert mqtt_common.verify_hmac(payload_poc, None) is True
# 2. verify_hmac with auth_token and valid HMAC
auth_token = "mysecrettoken"
payload = {
"job_id": "job123",
"event": "started",
"seq": 1,
"data": {
"val": 123
}
}
# Compute valid signature
msg = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
import hmac
import hashlib
sig = hmac.new(auth_token.encode(), msg, hashlib.sha256).hexdigest()
payload_signed = dict(payload)
payload_signed["data"] = dict(payload["data"])
payload_signed["data"]["hmac_sig"] = sig
assert mqtt_common.verify_hmac(payload_signed, auth_token) is True
# 3. verify_hmac with invalid signature
payload_signed["data"]["hmac_sig"] = "invalidsignature"
assert mqtt_common.verify_hmac(payload_signed, auth_token) is False
+74
View File
@@ -0,0 +1,74 @@
import subprocess
import json
import yaml
def test_create_session_dry_run(mam_sandbox, mock_herdr, mock_agents):
"""
Sanity test verifying that create_session.sh --dry-run parses inputs,
invokes herdr's preflight checks, and exits successfully without side effects.
"""
tmp_path = mam_sandbox
script_path = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
cmd = [
"bash", str(script_path),
"--workspace", str(tmp_path),
"--agent", "claude",
"--role", "Creator",
"--dry-run"
]
res = subprocess.run(cmd, capture_output=True, text=True)
assert res.returncode == 0, f"Stdout: {res.stdout}\nStderr: {res.stderr}"
assert "[dry-run] would provision isolation" in res.stdout
assert "[dry-run] would spawn" in res.stdout
# Verify that mock herdr was called for checking session existence (which gets translated to agent get)
with open(mock_herdr, 'r') as f:
state = json.load(f)
calls = state.get("calls", [])
assert any("agent" in call and "get" in call for call in calls), f"Calls: {calls}"
def test_create_session_full(mam_sandbox, mock_herdr, mock_agents):
"""
Sanity test verifying that a full create_session.sh run intercepts herdr calls,
completes the TUI readiness check using mock responses, and serializes state
correctly to the sandboxed agent-sessions.yaml registry.
"""
tmp_path = mam_sandbox
script_path = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
cmd = [
"bash", str(script_path),
"--workspace", str(tmp_path),
"--agent", "claude",
"--role", "Creator"
]
res = subprocess.run(cmd, capture_output=True, text=True)
assert res.returncode == 0, f"Stdout: {res.stdout}\nStderr: {res.stderr}"
# 1. Verify mock herdr state file registers the agent session
with open(mock_herdr, 'r') as f:
state = json.load(f)
agents = state.get("agents", {})
assert len(agents) == 1, "There should be exactly one registered agent in the mock herdr state."
session_name = list(agents.keys())[0]
assert session_name.endswith("-creator-claude")
assert agents[session_name]["status"] == "running"
assert agents[session_name]["agent"] == "claude"
# 2. Verify that agent-sessions.yaml is successfully written
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
assert yaml_path.exists(), "The agent-sessions.yaml configuration file was not created."
with open(yaml_path, 'r') as f:
reg = yaml.safe_load(f)
sessions = reg.get("herdr_sessions", [])
assert len(sessions) == 1
assert sessions[0]["name"] == session_name
assert sessions[0]["status"] == "running"
assert sessions[0]["role"] == "Creator"
assert sessions[0]["isolation"]["uuid"] is not None
+363
View File
@@ -0,0 +1,363 @@
import os
import subprocess
import json
import hmac
import hashlib
import shlex
import sys
import pytest
# Helper to run bash snippets sourcing lib.sh
def run_lib_func(mam_sandbox, func_name, *args, env=None):
lib_path = mam_sandbox / "skills" / "lib.sh"
cmd_str = f"source {lib_path} && {func_name} " + " ".join(shlex.quote(str(a)) for a in args)
run_env = dict(os.environ)
if env:
run_env.update(env)
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True, env=run_env)
return res
def get_mqtt_common(mam_sandbox):
script_path = str(mam_sandbox / "skills" / "multi-agent-mux-delegate-job" / "scripts")
if script_path not in sys.path:
sys.path.insert(0, script_path)
import mqtt_common
return mqtt_common
# ==============================================================================
# FEATURE 1: Create Session (7 Test Cases)
# ==============================================================================
def test_create_derive_session_name_standard(mam_sandbox):
"""Test standard derive_session_name slug generation."""
res = run_lib_func(mam_sandbox, "derive_session_name", "/home/user/project", "claude")
assert res.returncode == 0
assert res.stdout.strip() == "user-project-creator-claude"
def test_create_derive_session_name_nested(mam_sandbox):
"""Test derive_session_name with nested paths, upper casing, and underscores."""
res = run_lib_func(mam_sandbox, "derive_session_name", "/home/User_Name/My_New_Project", "agy")
assert res.returncode == 0
assert res.stdout.strip() == "user-name-my-new-project-creator-agy"
def test_create_derive_session_name_weird_characters(mam_sandbox):
"""Test derive_session_name with spaces and punctuation in the path."""
res = run_lib_func(mam_sandbox, "derive_session_name", "/a/b c/d-e!f", "hermes")
assert res.returncode == 0
assert res.stdout.strip() == "bc-d-ef-creator-hermes"
def test_create_isolation_lever(mam_sandbox):
"""Test isolation_lever outputs for each supported agent."""
agents = {
"claude": "claude_config_dir",
"cline": "cline_data_dir",
"agy": "home",
"hermes": "home",
"unknown": ""
}
for agent, expected in agents.items():
res = run_lib_func(mam_sandbox, "isolation_lever", agent)
assert res.returncode == 0
assert res.stdout.strip() == expected
def test_create_isolation_env_prefix(mam_sandbox):
"""Test isolation_env_prefix format outputs."""
res = run_lib_func(mam_sandbox, "isolation_env_prefix", "claude", "/tmp/iso")
assert res.returncode == 0
assert res.stdout == "CLAUDE_CONFIG_DIR=/tmp/iso "
res2 = run_lib_func(mam_sandbox, "isolation_env_prefix", "agy", "/tmp/iso")
assert res2.returncode == 0
assert res2.stdout == "HOME=/tmp/iso "
res3 = run_lib_func(mam_sandbox, "isolation_env_prefix", "cline", "/tmp/iso")
assert res3.returncode == 0
assert res3.stdout == ""
def test_create_isolation_cmd_args(mam_sandbox):
"""Test isolation_cmd_args format outputs."""
res = run_lib_func(mam_sandbox, "isolation_cmd_args", "cline", "/tmp/iso")
assert res.returncode == 0
assert res.stdout == "--data-dir /tmp/iso"
res2 = run_lib_func(mam_sandbox, "isolation_cmd_args", "claude", "/tmp/iso")
assert res2.returncode == 0
assert res2.stdout == ""
def test_create_validate_env_key(mam_sandbox):
"""Test _validate_env_key function with valid and blocked environment keys."""
# Valid key
res = run_lib_func(mam_sandbox, "_validate_env_key", "MY_VALID_KEY")
assert res.returncode == 0
# Malformed key (starts with number)
res2 = run_lib_func(mam_sandbox, "_validate_env_key", "123BAD")
assert res2.returncode != 0
# Blocked key (LD_PRELOAD)
res3 = run_lib_func(mam_sandbox, "_validate_env_key", "LD_PRELOAD")
assert res3.returncode != 0
# ==============================================================================
# FEATURE 2: Resume Session (6 Test Cases)
# ==============================================================================
def test_resume_resolve_herdr_session_default(mam_sandbox):
"""Test resolve_herdr_session fallback behavior when session is not in YAML."""
res = run_lib_func(mam_sandbox, "resolve_herdr_session", "non-existent-session")
assert res.returncode == 0
assert res.stdout.strip() == "default"
def test_resume_resolve_herdr_session_env(mam_sandbox):
"""Test resolve_herdr_session fallback to HERDR_SERVER_NAME env var."""
res = run_lib_func(mam_sandbox, "resolve_herdr_session", "non-existent-session", env={"HERDR_SERVER_NAME": "custom_server"})
assert res.returncode == 0
assert res.stdout.strip() == "custom_server"
def test_resume_find_workspace_uuid_empty(mam_sandbox):
"""Test find_workspace_uuid returns empty string for non-existent workspace."""
res = run_lib_func(mam_sandbox, "find_workspace_uuid", "/non/existent/path", "claude")
assert res.returncode == 0
assert res.stdout.strip() == ""
def test_resume_find_workspace_uuid_target_non_existent(mam_sandbox):
"""Test target session query with target that does not exist in YAML."""
res = run_lib_func(mam_sandbox, "find_workspace_uuid", str(mam_sandbox), "claude", "non-existent-session")
assert res.returncode == 0
assert res.stdout.strip() == ""
def test_resume_find_workspace_uuid_invalid_agent(mam_sandbox):
"""Test find_workspace_uuid behavior with an unsupported agent name."""
res = run_lib_func(mam_sandbox, "find_workspace_uuid", str(mam_sandbox), "invalidagent")
assert res.returncode == 0
assert res.stdout.strip() == ""
def test_resume_script_invalid_args(mam_sandbox):
"""Test calling resolve_session_id.sh with missing arguments."""
script_path = mam_sandbox / "skills" / "multi-agent-mux-resume" / "scripts" / "resolve_session_id.sh"
res = subprocess.run(["bash", str(script_path), "--workspace", str(mam_sandbox)], capture_output=True, text=True)
assert res.returncode == 2
assert "ERROR: --agent required" in res.stderr
# ==============================================================================
# FEATURE 3: Stop Session (5 Test Cases)
# ==============================================================================
def test_stop_check_is_nfs_local(mam_sandbox):
"""Test that _check_is_nfs on local temp directory returns non-zero (not NFS)."""
res = run_lib_func(mam_sandbox, "_check_is_nfs", str(mam_sandbox))
# It will exit with 1 if it is not NFS
assert res.returncode == 1
def test_stop_is_already_stopped_not_found(mam_sandbox):
"""Test is_already_stopped exits with 1 when session is not in YAML."""
res = run_lib_func(mam_sandbox, "is_already_stopped", "non-existent-session")
assert res.returncode == 1
def test_stop_session_invalid_agent_suffix(mam_sandbox):
"""Test stop_session.sh fails when agent cannot be inferred from session name."""
script_path = mam_sandbox / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
res = subprocess.run(["bash", str(script_path), "--session", "bad-session-name"], capture_output=True, text=True)
assert res.returncode == 2
assert "ERROR: cannot infer agent" in res.stderr
def test_stop_session_missing_required_args(mam_sandbox):
"""Test stop_session.sh fails when session name is missing."""
script_path = mam_sandbox / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
res = subprocess.run(["bash", str(script_path)], capture_output=True, text=True)
assert res.returncode == 2
assert "ERROR: --session required" in res.stderr
def test_stop_session_purge_no_yes(mam_sandbox):
"""Test stop_session.sh exits with 3 when purge is requested without --yes."""
script_path = mam_sandbox / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
# Seed yaml with the session first to avoid "not in yaml" exit 1
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
yaml_path.write_text("""herdr_sessions:
- name: test-project-creator-claude
status: running
pane:
cwd: /tmp
""")
res = subprocess.run(["bash", str(script_path), "--session", "test-project-creator-claude", "--purge-conversation"], capture_output=True, text=True)
assert res.returncode == 3
assert "DANGER: --purge-conversation will DELETE" in res.stdout
# ==============================================================================
# FEATURE 4: Status Query (5 Test Cases)
# ==============================================================================
def test_status_json_schema_fields(mam_sandbox, mock_herdr, mock_agents):
"""Verify status.sh output JSON contains the expected structure."""
script_path = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
# Run status.sh with --json
res = subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
assert res.returncode == 0
data = json.loads(res.stdout)
assert "timestamp" in data
assert "yaml_path" in data
assert "drifts" in data
assert "sessions_detail" in data
def test_status_text_headers_presence(mam_sandbox):
"""Verify status.sh output in text mode includes header columns."""
script_path = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
res = subprocess.run(["bash", str(script_path)], capture_output=True, text=True)
assert res.returncode == 0
assert "NAME" in res.stdout
assert "WORKSPACE" in res.stdout
assert "YAML" in res.stdout
assert "HERDR" in res.stdout
assert "DRIFT" in res.stdout
def test_status_resume_on_disk_helper_claude(mam_sandbox):
"""Verify resume_on_disk behavior for claude inside status.sh logic via mock YAML queries."""
# We can write a custom yaml and check status
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
yaml_path.write_text("""herdr_sessions:
- name: test-project-creator-claude
status: running
claude_session_id_own: some-uuid
pane:
cwd: /tmp/nonexistent-workspace
""")
script_path = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
res = subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
assert res.returncode == 0
data = json.loads(res.stdout)
detail = data["sessions_detail"][0]
assert detail["name"] == "test-project-creator-claude"
assert detail["resume_state"] == "MISSING"
def test_status_resume_on_disk_helper_agy(mam_sandbox):
"""Verify resume_on_disk behavior for agy inside status.sh logic."""
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
yaml_path.write_text("""herdr_sessions:
- name: test-project-creator-agy
status: running
agy_conversation_id_own: some-uuid
pane:
cwd: /tmp/nonexistent-workspace
""")
script_path = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
res = subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
assert res.returncode == 0
data = json.loads(res.stdout)
detail = data["sessions_detail"][0]
assert detail["name"] == "test-project-creator-agy"
assert detail["resume_state"] == "MISSING"
def test_status_get_job_status_helper(mam_sandbox):
"""Verify get_job_status parses non-existent jobs gracefully."""
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
yaml_path.write_text("""herdr_sessions:
- name: test-project-creator-claude
status: running
delegate_job_id: nonexistent-job-id
pane:
cwd: /tmp
""")
script_path = mam_sandbox / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
res = subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
assert res.returncode == 0
data = json.loads(res.stdout)
detail = data["sessions_detail"][0]
assert detail["job_id"] == "nonexistent-job-id"
assert detail["job_status"] == "unknown"
# ==============================================================================
# FEATURE 5: Monitor/Reconcile (6 Test Cases)
# ==============================================================================
def test_mqtt_topic_prefix_for(mam_sandbox):
"""Test topic prefix helper methods in mqtt_common."""
mqtt_common = get_mqtt_common(mam_sandbox)
prefix = mqtt_common.topic_prefix_for("job123")
assert prefix == "python/mqtt/jobs/job123"
events_topic = mqtt_common.events_topic_for("job123")
assert events_topic == "python/mqtt/jobs/job123/events"
def test_mqtt_verify_hmac_no_token(mam_sandbox):
"""Verify verify_hmac returns True when no token is present."""
mqtt_common = get_mqtt_common(mam_sandbox)
payload = {"data": {"hmac_sig": "somesig"}}
assert mqtt_common.verify_hmac(payload, None) is True
assert mqtt_common.verify_hmac(payload, "") is True
def test_mqtt_verify_hmac_valid_invalid(mam_sandbox):
"""Verify verify_hmac signature validation matching logic."""
mqtt_common = get_mqtt_common(mam_sandbox)
token = "secret_key"
payload = {
"job_id": "job1",
"event": "started",
"seq": 1,
"timestamp": "2026-07-19T00:00:00Z",
"data": {
"some_key": "some_val"
}
}
# Calculate HMAC
msg = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
sig = hmac.new(token.encode(), msg, hashlib.sha256).hexdigest()
# Put HMAC signature inside data block
payload["data"]["hmac_sig"] = sig
assert mqtt_common.verify_hmac(payload, token) is True
# Modifying payload should cause verification to fail
payload["seq"] = 2
assert mqtt_common.verify_hmac(payload, token) is False
def test_mqtt_reason_code_value(mam_sandbox):
"""Verify reason_code_value correctly extracts values from paho reason codes."""
mqtt_common = get_mqtt_common(mam_sandbox)
# Test plain int
assert mqtt_common.reason_code_value(0) == 0
assert mqtt_common.reason_code_value(5) == 5
# Test object with .value attribute
class DummyReasonCode:
def __init__(self, val):
self.value = val
assert mqtt_common.reason_code_value(DummyReasonCode(0)) == 0
assert mqtt_common.reason_code_value(DummyReasonCode(16)) == 16
def test_mqtt_with_retry_success(mam_sandbox):
"""Verify with_retry decorator works on direct success."""
mqtt_common = get_mqtt_common(mam_sandbox)
calls = []
@mqtt_common.with_retry(attempts=3)
def dummy_func(x):
calls.append(x)
return x * 2
res = dummy_func(5)
assert res == 10
assert calls == [5]
def test_mqtt_with_retry_failure(mam_sandbox):
"""Verify with_retry decorator raises error after specified attempts."""
mqtt_common = get_mqtt_common(mam_sandbox)
calls = []
@mqtt_common.with_retry(attempts=3, base_delay=0.01)
def failing_func():
calls.append(1)
raise ValueError("failing")
with pytest.raises(ValueError, match="failing"):
failing_func()
assert len(calls) == 3
+734
View File
@@ -0,0 +1,734 @@
import os
import shutil
import json
import sqlite3
import subprocess
import pytest
import time
import sys
import shlex
from pathlib import Path
# Helper to run mutation on agent-sessions.yaml using atomic_dump_yaml in bash
def run_mutation(mam_sandbox, mutation_str, env=None):
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
cmd_str = f"source {lib_path} && atomic_dump_yaml {yaml_path}"
run_env = dict(os.environ)
if env:
run_env.update(env)
res = subprocess.run(["bash", "-c", cmd_str], input=mutation_str, capture_output=True, text=True, env=run_env)
return res
def get_mqtt_common(mam_sandbox):
script_path = str(mam_sandbox / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "scripts")
if script_path not in sys.path:
sys.path.insert(0, script_path)
import mqtt_common
return mqtt_common
# ==============================================================================
# FEATURE 1: Create Session (5 Test Cases)
# ==============================================================================
def test_comp_create_schema_validation(mam_sandbox):
"""Verify that atomic_dump_yaml validates the schema and rejects malformed formats."""
# Try setting herdr_sessions to a dictionary instead of list
mutation = "d['herdr_sessions'] = {}"
res = run_mutation(mam_sandbox, mutation)
assert "VALIDATE: herdr_sessions is not a list" in res.stderr
# Try setting status to an invalid state
mutation_invalid_status = """
d['herdr_sessions'] = [{
'name': 'session1',
'status': 'invalidstatus',
'pane': {}
}]
"""
res2 = run_mutation(mam_sandbox, mutation_invalid_status)
assert "VALIDATE:" in res2.stderr
def test_comp_create_role_immutability(mam_sandbox):
"""Verify that once a role is defined in the sessions, it cannot be mutated."""
# 1. Setup session with a role
mutation_setup = """
d['herdr_sessions'] = [{
'name': 'session-imm-role',
'status': 'running',
'role': 'Creator',
'pane': {}
}]
"""
res = run_mutation(mam_sandbox, mutation_setup)
assert res.returncode == 0
# 2. Try mutating the role
mutation_bad = """
for s in d.get('herdr_sessions', []):
if s.get('name') == 'session-imm-role':
s['role'] = 'Reviewer'
"""
res_bad = run_mutation(mam_sandbox, mutation_bad)
assert res_bad.returncode != 0
assert "role of session 'session-imm-role' cannot be modified" in res_bad.stderr
def test_comp_create_duplicate_running_ids(mam_sandbox):
"""Verify that duplicate running conversation IDs across sessions are rejected."""
mutation = """
d['herdr_sessions'] = [
{
'name': 'sess1',
'status': 'running',
'claude_session_id_own': 'uuid-1234',
'pane': {}
},
{
'name': 'sess2',
'status': 'running',
'claude_session_id_own': 'uuid-1234',
'pane': {}
}
]
"""
res = run_mutation(mam_sandbox, mutation)
assert res.returncode != 0
assert "Duplicate running conversation ID" in res.stderr
def test_comp_create_isolation_folder_setup(mam_sandbox):
"""Verify that provision_isolation correctly creates directories and symlinks."""
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
iso_root = mam_sandbox / "iso_home_test"
# Mock global claude credentials
claude_cred = mam_sandbox / ".claude"
claude_cred.mkdir(parents=True, exist_ok=True)
(claude_cred / ".credentials.json").write_text('{"token": "xyz"}')
cmd_str = f"source {lib_path} && provision_isolation claude {iso_root}"
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True)
assert res.returncode == 0
# Verify symlink exists and points to the credentials
cred_sym = iso_root / ".credentials.json"
assert cred_sym.is_symlink()
assert cred_sym.read_text() == '{"token": "xyz"}'
def test_comp_create_sqlite_tables_created(mam_sandbox, mock_herdr, mock_agents):
"""Verify that tables exist and contain records after a full create_session.sh run."""
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
cmd = [
"bash", str(script_path),
"--workspace", str(mam_sandbox),
"--agent", "claude",
"--role", "Creator"
]
res = subprocess.run(cmd, capture_output=True, text=True)
assert res.returncode == 0
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
assert db_path.exists()
# Connect directly to the SQLite DB
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
# Check tables
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
tables = [row[0] for row in cursor.fetchall()]
assert "state" in tables
assert "sessions" in tables
# Check entries in sessions table
cursor.execute("SELECT name, status, pane_cwd FROM sessions;")
rows = cursor.fetchall()
assert len(rows) == 1
assert rows[0][0].endswith("-creator-claude")
assert rows[0][1] == "running"
conn.close()
# ==============================================================================
# FEATURE 2: Resume Session (5 Test Cases)
# ==============================================================================
def test_comp_resume_config_restore(mam_sandbox):
"""Verify that isolation details are preserved and can be read from the DB registry."""
# Write a test session with isolation details
mutation = """
d['herdr_sessions'] = [{
'name': 'test-session-iso',
'status': 'stopped',
'pane': {'cwd': '/tmp'},
'isolation': {
'uuid': 'iso-uuid-789',
'root': '/tmp/iso_root',
'lever': 'claude_config_dir',
'seeded': []
}
}]
"""
res = run_mutation(mam_sandbox, mutation)
assert res.returncode == 0
# Read the configuration via resume_session.sh isolation resolution check
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh"
# We run in dry-run/mock mode or just execute python command checking block
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
# Call the python parsing block from resume_session.sh directly
python_block = """
import os, json, sqlite3
name = "test-session-iso"
db_path = "DB_PATH_PLACEHOLDER"
conn = sqlite3.connect(db_path)
row = conn.execute('SELECT data FROM sessions WHERE name=?', (name,)).fetchone()
if row:
import json
s = json.loads(row[0])
print(json.dumps(s.get('isolation') or {}))
""".replace("DB_PATH_PLACEHOLDER", str(db_path))
res_py = subprocess.run(["python3", "-c", python_block], capture_output=True, text=True)
assert res_py.returncode == 0
data = json.loads(res_py.stdout)
assert data["uuid"] == "iso-uuid-789"
assert data["root"] == "/tmp/iso_root"
def test_comp_resume_metadata_read_integrity(mam_sandbox):
"""Verify metadata read integrity from the SQLite DB by changing state outside YAML."""
# Write initial YAML state
mutation = """
d['herdr_sessions'] = [{
'name': 'sess-integrity',
'status': 'stopped',
'pane': {'cwd': '/tmp'}
}]
"""
run_mutation(mam_sandbox, mutation)
# Update SQLite directly to terminated
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
conn = sqlite3.connect(str(db_path))
conn.execute("UPDATE sessions SET status='terminated' WHERE name='sess-integrity'")
conn.commit()
conn.close()
# Read via atomic_dump_yaml check (which pulls from DB sessions table)
# If the DB reading has integrity, d will contain sess-integrity as status 'terminated'
mutation_check = """
for s in d.get('herdr_sessions', []):
if s.get('name') == 'sess-integrity':
print(f"STATUS={s['status']}")
"""
res = run_mutation(mam_sandbox, mutation_check)
assert "STATUS=terminated" in res.stdout
def test_comp_resume_update_yaml(mam_sandbox):
"""Verify that update_yaml_resumed.sh cleans up stop fields and marks status running."""
# Seed a stopped session with stop metadata
mutation = """
d['herdr_sessions'] = [{
'name': 'test-resumed-session-creator-claude',
'status': 'stopped',
'stopped_at': '2026-07-19T00:00:00Z',
'stop_reason': 'manual_stop',
'pane': {'cwd': '/tmp'}
}]
"""
run_mutation(mam_sandbox, mutation)
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-resume" / "scripts" / "update_yaml_resumed.sh"
res = subprocess.run(["bash", str(script_path), "--session", "test-resumed-session-creator-claude", "--uuid", "new-uuid-999"], capture_output=True, text=True)
assert res.returncode == 0
# Verify stopped fields are popped
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
conn = sqlite3.connect(str(db_path))
row = conn.execute("SELECT data FROM sessions WHERE name='test-resumed-session-creator-claude'").fetchone()
s = json.loads(row[0])
assert s["status"] == "running"
assert "stopped_at" not in s
assert "stop_reason" not in s
assert s["claude_session_id_own"] == "new-uuid-999"
conn.close()
def test_comp_resume_find_workspace_uuid_tier1_own_id(mam_sandbox):
"""Verify find_workspace_uuid resolves the per-row own ID properly."""
# Write a running session with a claude_session_id_own
mutation = """
d['herdr_sessions'] = [{
'name': 'ws-own-creator-claude',
'status': 'running',
'claude_session_id_own': 'own-uuid-111',
'pane': {'cwd': 'WS_PLACEHOLDER'}
}]
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
run_mutation(mam_sandbox, mutation)
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
# Target session mode scopes resolution to the row
cmd_str = f"source {lib_path} && find_workspace_uuid {mam_sandbox} claude ws-own-creator-claude"
# Since we are mock-checking, own_exists function mock will query disk format.
# Claude disk format requires a project file: projects/<key>/<uuid>.jsonl
# Let's create it.
key = str(mam_sandbox).replace('/', '-').replace('_', '-')
proj_dir = mam_sandbox / ".claude" / "projects" / key
proj_dir.mkdir(parents=True, exist_ok=True)
(proj_dir / "own-uuid-111.jsonl").write_text('{"sessionId": "own-uuid-111"}')
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True)
assert res.returncode == 0
# Should resolve to own-uuid-111 because it exists in projects folder
assert res.stdout.strip() == "own-uuid-111"
def test_comp_resume_find_workspace_uuid_tier2_disk_scan_claude(mam_sandbox):
"""Verify find_workspace_uuid scans workspace on disk if own ID isn't set in the row."""
# Seed session without own ID
mutation = """
d['herdr_sessions'] = [{
'name': 'ws-scan-creator-claude',
'status': 'stopped',
'pane': {'cwd': 'WS_PLACEHOLDER'}
}]
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
run_mutation(mam_sandbox, mutation)
# Create JSONL file
key = str(mam_sandbox).replace('/', '-').replace('_', '-')
proj_dir = mam_sandbox / ".claude" / "projects" / key
proj_dir.mkdir(parents=True, exist_ok=True)
(proj_dir / "scanned-uuid.jsonl").write_text('{"sessionId": "scanned-uuid"}')
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
cmd_str = f"source {lib_path} && find_workspace_uuid {mam_sandbox} claude"
res = subprocess.run(["bash", "-c", cmd_str], capture_output=True, text=True)
assert res.returncode == 0
assert res.stdout.strip() == "scanned-uuid"
# ==============================================================================
# FEATURE 3: Stop Session (5 Test Cases)
# ==============================================================================
def test_comp_stop_safe_path_checking(mam_sandbox):
"""Verify path guards block directory deletion if isolation path check fails."""
# Mock a terminated session where isolation root is set outside .mam folder
mutation = """
d['herdr_sessions'] = [{
'name': 'test-purge-guard-creator-claude',
'status': 'running',
'pane': {'cwd': 'WS_PLACEHOLDER'},
'isolation': {
'uuid': 'some-uuid',
'root': '/tmp/unauthorized_path_outside_mam'
}
}]
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
run_mutation(mam_sandbox, mutation)
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
# Attempt to purge. The python script should print "WARN: isolated home path check failed" and NOT crash
res = subprocess.run(["bash", str(script_path), "--session", "test-purge-guard-creator-claude", "--purge-conversation", "--yes"], capture_output=True, text=True)
assert res.returncode == 0
assert "WARN: isolated home path check failed" in res.stdout
def test_comp_stop_sqlite_state_update(mam_sandbox):
"""Verify that stop_session.sh updates state in SQLite to stopped."""
mutation = """
d['herdr_sessions'] = [{
'name': 'test-stop-sqlite-creator-claude',
'status': 'running',
'pane': {'cwd': 'WS_PLACEHOLDER'}
}]
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
run_mutation(mam_sandbox, mutation)
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
res = subprocess.run(["bash", str(script_path), "--session", "test-stop-sqlite-creator-claude"], capture_output=True, text=True)
assert res.returncode == 0
# Query database directly
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
conn = sqlite3.connect(str(db_path))
row = conn.execute("SELECT status, data FROM sessions WHERE name='test-stop-sqlite-creator-claude'").fetchone()
assert row[0] == "stopped"
s = json.loads(row[1])
assert s["status"] == "stopped"
assert "stopped_at" in s
assert s["stop_reason"] == "manual_stop"
conn.close()
def test_comp_stop_purge_record_removal(mam_sandbox):
"""Verify that purge completely removes the session record from DB."""
mutation = """
d['herdr_sessions'] = [{
'name': 'test-purge-record-creator-claude',
'status': 'running',
'pane': {'cwd': 'WS_PLACEHOLDER'}
}]
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
run_mutation(mam_sandbox, mutation)
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
res = subprocess.run(["bash", str(script_path), "--session", "test-purge-record-creator-claude", "--purge-conversation", "--yes"], capture_output=True, text=True)
assert res.returncode == 0
# Check SQLite
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
conn = sqlite3.connect(str(db_path))
row = conn.execute("SELECT COUNT(*) FROM sessions WHERE name='test-purge-record-creator-claude'").fetchone()
assert row[0] == 0
conn.close()
def test_comp_stop_lock_release(mam_sandbox):
"""Verify SQLite lock is released after running atomic_dump_yaml."""
mutation = """
d['herdr_sessions'] = [{
'name': 'test-lock-release',
'status': 'stopped',
'pane': {'cwd': '/tmp'}
}]
"""
res = run_mutation(mam_sandbox, mutation)
assert res.returncode == 0
# Since res has returned, the lock should be free.
# We test this by immediately acquiring a direct SQLite write connection
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
conn = sqlite3.connect(str(db_path), timeout=0.1)
try:
conn.execute("BEGIN IMMEDIATE")
conn.execute("UPDATE sessions SET status='archived' WHERE name='test-lock-release'")
conn.commit()
except sqlite3.OperationalError as e:
pytest.fail(f"Could not acquire SQLite lock: {e}")
finally:
conn.close()
def test_comp_stop_graceful_kill_chain(mam_sandbox, mock_herdr, mock_agents):
"""Verify that the graceful stop command fallback chain runs when herdr has-session is alive."""
# Write a running session in YAML
mutation = """
d['herdr_sessions'] = [{
'name': 'test-graceful-chain-creator-claude',
'status': 'running',
'pane': {'cwd': 'WS_PLACEHOLDER', 'pid': 12345}
}]
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
run_mutation(mam_sandbox, mutation)
# Pre-populate herdr mock with the running session so it passes has-session check
with open(mock_herdr, 'r') as f:
state = json.load(f)
state["agents"]["test-graceful-chain-creator-claude"] = {
"status": "running",
"agent": "claude",
"cwd": str(mam_sandbox),
"pid": 12345,
"pane_id": "w1:p1",
"command": "claude",
"buffer": "Anthropic Claude Ready"
}
with open(mock_herdr, 'w') as f:
json.dump(state, f, indent=2)
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
# Run stop_session.sh. This should call send-keys first
res = subprocess.run(["bash", str(script_path), "--session", "test-graceful-chain-creator-claude"], capture_output=True, text=True)
assert res.returncode == 0
with open(mock_herdr, 'r') as f:
state = json.load(f)
# Verify mock herdr calls recorded the keys "/exit" sent
calls = state.get("calls", [])
# Should see send-keys call
assert any("send" in call and "/exit" in call for call in calls)
# ==============================================================================
# FEATURE 4: Status Query (5 Test Cases)
# ==============================================================================
def test_comp_status_read_lock(mam_sandbox):
"""Verify status.sh accesses SQLite database with a read lock, not writing."""
# Seed DB first so it exists
run_mutation(mam_sandbox, "d['herdr_sessions'] = []")
script_path = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
# Run status.sh in a background sub-process
res = subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
assert res.returncode == 0
# Verify no writing occurred by comparing DB file modified time
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
t1 = os.path.getmtime(db_path)
# Run status.sh again
subprocess.run(["bash", str(script_path), "--json"], capture_output=True, text=True)
t2 = os.path.getmtime(db_path)
assert t1 == t2
def test_comp_status_drift_class_parsing(mam_sandbox):
"""Verify status.sh correctly parses drifts returned by reconcile.sh."""
# Write reconcile mock logic that outputs a drift JSON
reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
drift_output = {
"timestamp": "2026-07-19T00:00:00Z",
"yaml_path": str(mam_sandbox / ".mam" / "agent-sessions.yaml"),
"herdr_sessions_alive": ["test-session|default"],
"herdr_confirmed": True,
"drifts": [{"class": "A", "name": "test-session", "msg": "drift detected"}],
"actions": []
}
# Overwrite reconcile.sh to print this JSON directly
reconcile_script.write_text(f"""#!/usr/bin/env bash
echo '{json.dumps(drift_output)}'
""")
# Seed YAML
mutation = """
d['herdr_sessions'] = [{
'name': 'test-session',
'status': 'running',
'pane': {'cwd': '/tmp'}
}]
"""
run_mutation(mam_sandbox, mutation)
status_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
res = subprocess.run(["bash", str(status_script), "--json"], capture_output=True, text=True)
assert res.returncode == 0
data = json.loads(res.stdout)
assert data["sessions_detail"][0]["drift_classes"] == ["A"]
def test_comp_status_job_candidates_parsing(mam_sandbox):
"""Verify status.sh reads job statuses from audit logs or registry JSONs."""
# Write YAML session
mutation = """
d['herdr_sessions'] = [{
'name': 'test-job-session-creator-claude',
'status': 'running',
'delegate_job_id': 'job12345',
'pane': {'cwd': 'WS_PLACEHOLDER'}
}]
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
run_mutation(mam_sandbox, mutation)
# Create the status.json in both candidate folders to cover path resolutions
job_logs_dir1 = mam_sandbox / ".mam" / "delegate_job_logs" / "job12345"
job_logs_dir1.mkdir(parents=True, exist_ok=True)
(job_logs_dir1 / "status.json").write_text('{"status": "completed"}')
job_logs_dir2 = mam_sandbox.parent / ".mam" / "delegate_job_logs" / "job12345"
job_logs_dir2.mkdir(parents=True, exist_ok=True)
(job_logs_dir2 / "status.json").write_text('{"status": "completed"}')
status_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
res = subprocess.run(["bash", str(status_script), "--json"], capture_output=True, text=True)
assert res.returncode == 0
data = json.loads(res.stdout)
assert data["sessions_detail"][0]["job_status"] == "completed"
def test_comp_status_no_side_effects(mam_sandbox):
"""Verify that running status.sh leaves YAML and DB unchanged."""
# Seed DB first so it exists
run_mutation(mam_sandbox, "d['herdr_sessions'] = []")
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
y_content_1 = yaml_path.read_text()
conn = sqlite3.connect(str(db_path))
d_content_1 = conn.execute("SELECT * FROM sessions").fetchall()
conn.close()
status_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
subprocess.run(["bash", str(status_script)], capture_output=True, text=True)
y_content_2 = yaml_path.read_text()
conn = sqlite3.connect(str(db_path))
d_content_2 = conn.execute("SELECT * FROM sessions").fetchall()
conn.close()
assert y_content_1 == y_content_2
assert d_content_1 == d_content_2
def test_comp_status_yaml_to_json_structure(mam_sandbox):
"""Verify YAML-to-JSON structure translation preserves all nested maps."""
mutation = """
d['herdr_sessions'] = [{
'name': 'test-struct-creator-claude',
'status': 'running',
'pane': {
'index': 0,
'pid': 1234,
'cmd': 'claude',
'cwd': '/tmp'
}
}]
"""
run_mutation(mam_sandbox, mutation)
status_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
res = subprocess.run(["bash", str(status_script), "--json"], capture_output=True, text=True)
assert res.returncode == 0
data = json.loads(res.stdout)
session = data["sessions_detail"][0]
assert session["pane_pid"] == 1234
assert session["pane_cwd"] == "/tmp"
# ==============================================================================
# FEATURE 5: Monitor/Reconcile (6 Test Cases)
# ==============================================================================
def test_comp_monitor_concurrency_lock(mam_sandbox):
"""Verify that multiple subscriber reconciles cannot run concurrently."""
# Reconcile subscribe script tries to acquire .mam/monitor.lock
# Let's write a mock subscriber that acquires the lock
workspace_root = mam_sandbox
lock_file_path = workspace_root / ".mam" / "monitor.lock"
# Hold lock in Python
import fcntl
lock_file = open(lock_file_path, 'w')
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
# Now execute reconcile subscribe loop, it should exit immediately
reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
res = subprocess.run(["bash", str(reconcile_script), "--subscribe", "--idle-timeout", "1"], capture_output=True, text=True, cwd=str(mam_sandbox))
assert res.returncode == 0
assert "MQTT Monitor: another subscriber is already running" in res.stdout
lock_file.close()
def test_comp_monitor_sqlite_journal_wal(mam_sandbox):
"""Verify that SQLite database defaults to WAL mode on standard filesystems."""
mutation = """
d['herdr_sessions'] = []
"""
# Runs atomic_dump_yaml which sets journal mode
res = run_mutation(mam_sandbox, mutation)
assert res.returncode == 0
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
conn = sqlite3.connect(str(db_path))
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
conn.close()
assert mode.lower() == "wal"
def test_comp_monitor_sqlite_journal_delete_nfs(mam_sandbox):
"""Verify that SQLite database falls back to DELETE mode on NFS filesystems."""
mutation = """
d['herdr_sessions'] = []
"""
# Set MAM_IS_NFS env var
res = run_mutation(mam_sandbox, mutation, env={"MAM_IS_NFS": "true"})
assert res.returncode == 0
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
conn = sqlite3.connect(str(db_path))
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
conn.close()
assert mode.lower() == "delete"
def test_comp_monitor_db_schema_auto_update(mam_sandbox):
"""Verify that table structure and indexes are automatically created if DB is deleted."""
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
if db_path.exists():
db_path.unlink()
mutation = """
d['herdr_sessions'] = []
"""
res = run_mutation(mam_sandbox, mutation)
assert res.returncode == 0
assert db_path.exists()
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
tables = [row[0] for row in cursor.fetchall()]
assert "state" in tables
assert "sessions" in tables
cursor.execute("SELECT name FROM sqlite_master WHERE type='index';")
indexes = [row[0] for row in cursor.fetchall()]
assert "idx_sessions_pane_cwd" in indexes
conn.close()
def test_comp_monitor_monotonic_seq_updates(mam_sandbox):
"""Verify sequence monotonic updates in mqtt_common."""
mqtt_common = get_mqtt_common(mam_sandbox)
# Setup job JSON in jobs registry dir
registry_dir = mam_sandbox / ".mam" / "jobs"
registry_dir.mkdir(parents=True, exist_ok=True)
job_id = "job-seq-123"
job_data = {
"job_id": job_id,
"status": "running",
"last_seq": 0
}
with open(registry_dir / f"{job_id}.json", "w") as f:
json.dump(job_data, f)
# Get sequence multiple times
s1 = mqtt_common.next_seq(job_id, str(registry_dir))
s2 = mqtt_common.next_seq(job_id, str(registry_dir))
s3 = mqtt_common.next_seq(job_id, str(registry_dir))
assert s1 == 1
assert s2 == 2
assert s3 == 3
# Reload and check
with open(registry_dir / f"{job_id}.json", "r") as f:
loaded = json.load(f)
assert loaded["last_seq"] == 3
def test_comp_monitor_auto_state_recovery(mam_sandbox, mock_herdr):
"""Verify that reconcile.sh auto-terminates a session if herdr is confirmed dead."""
# Write a running session in YAML
mutation = """
d['herdr_sessions'] = [{
'name': 'test-autorecovery-creator-claude',
'status': 'running',
'pane': {'cwd': 'WS_PLACEHOLDER'}
}]
""".replace("WS_PLACEHOLDER", str(mam_sandbox))
run_mutation(mam_sandbox, mutation)
# Run reconcile.sh --once --emit-diff (which runs the dry-run, which does NOT write but reports)
reconcile_script = mam_sandbox / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
res = subprocess.run(["bash", str(reconcile_script), "--once", "--emit-diff", "--dry-run"], capture_output=True, text=True, cwd=str(mam_sandbox))
assert res.returncode == 0
data = json.loads(res.stdout)
drifts = data["drifts"]
# Should detect drift class A: herdr gone
assert any(dr["class"] == "A" and dr["name"] == "test-autorecovery-creator-claude" for dr in drifts)
# Now run reconcile.sh --once --emit-diff (without --dry-run) to trigger write
res_write = subprocess.run(["bash", str(reconcile_script), "--once", "--emit-diff"], capture_output=True, text=True, cwd=str(mam_sandbox))
assert res_write.returncode == 0
# Verify session is marked terminated in the SQLite DB
db_path = mam_sandbox / ".mam" / "agent-sessions.db"
conn = sqlite3.connect(str(db_path))
row = conn.execute("SELECT status FROM sessions WHERE name='test-autorecovery-creator-claude'").fetchone()
assert row[0] == "terminated"
conn.close()
+401
View File
@@ -0,0 +1,401 @@
import os
import subprocess
import json
import sqlite3
import pytest
import shutil
import yaml
from pathlib import Path
# Helper to run mutation on agent-sessions.yaml using atomic_dump_yaml in bash
def run_mutation(mam_sandbox, mutation_str, env=None):
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
cmd_str = f"source {lib_path} && atomic_dump_yaml {yaml_path}"
run_env = dict(os.environ)
if env:
run_env.update(env)
res = subprocess.run(["bash", "-c", cmd_str], input=mutation_str, capture_output=True, text=True, env=run_env)
return res
def test_integration_create_options_combination(mam_sandbox, mock_herdr, mock_agents, monkeypatch):
"""
Tier 3: Create Session Integration
Covers pairwise combination: --onboard + --submit-job + --wrapper + HERDR_SERVER_NAME env override
"""
tmp_path = mam_sandbox
script_path = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
# Set HERDR_SERVER_NAME env var to test environment override combination
monkeypatch.setenv("HERDR_SERVER_NAME", "custom_server")
cmd = [
"bash", str(script_path),
"--workspace", str(tmp_path),
"--agent", "claude",
"--role", "Creator",
"--onboard",
"--submit-job", "Test onboard prompt",
"--wrapper"
]
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(tmp_path))
if res.returncode != 0:
with open(mock_herdr, 'r') as f:
state = json.load(f)
debug_info = (
f"MOCK HERDR CALLS: {state.get('calls', [])}\n"
f"MOCK HERDR AGENTS: {state.get('agents', {})}\n"
)
assert False, f"Stdout: {res.stdout}\nStderr: {res.stderr}\nDebug:\n{debug_info}"
assert res.returncode == 0
# Verify it is registered in yaml
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
assert yaml_path.exists()
with open(yaml_path, 'r') as f:
reg = yaml.safe_load(f)
sessions = reg.get("herdr_sessions", [])
assert len(sessions) == 1
session = sessions[0]
assert session["role"] == "Creator"
assert session.get("herdr_session") == "custom_server"
assert session["delegate_job_id"] is not None
assert session["status"] == "running"
# Verify delegate job JSON file exists and contains correct info
job_id = session["delegate_job_id"]
job_file = tmp_path / ".mam" / "jobs" / f"{job_id}.json"
assert job_file.exists()
with open(job_file, 'r') as jf:
job_data = json.load(jf)
assert job_data["job_id"] == job_id
assert "Test onboard prompt" in job_data["prompt"]
assert job_data["agent_session"] == f"herdr:{session['name']}"
def test_integration_stop_purge_combination(mam_sandbox, mock_herdr, mock_agents):
"""
Tier 3: Stop Session Integration
Covers pairwise combination: --purge-conversation + --yes + HOME override
"""
tmp_path = mam_sandbox
# 1. Run create_session to register a session with isolation enabled
create_script = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
cmd_create = [
"bash", str(create_script),
"--workspace", str(tmp_path),
"--agent", "claude",
"--role", "Creator"
]
res_create = subprocess.run(cmd_create, capture_output=True, text=True, cwd=str(tmp_path))
assert res_create.returncode == 0, f"Stdout: {res_create.stdout}\nStderr: {res_create.stderr}"
# Get session name and isolation uuid
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
with open(yaml_path, 'r') as f:
reg = yaml.safe_load(f)
session = reg["herdr_sessions"][0]
session_name = session["name"]
iso_uuid = session["isolation"]["uuid"]
iso_root = Path(session["isolation"]["root"])
# Ensure isolation home root folder is provisioned
assert iso_root.exists()
# Locate dynamically generated conversation file in claude projects
key = str(tmp_path).replace('/', '-').replace('_', '-')
proj_dir = iso_root / "projects" / key
assert proj_dir.exists(), f"Expected isolated projects directory {proj_dir} to exist"
jsonls = list(proj_dir.glob("*.jsonl"))
assert len(jsonls) == 1, f"Expected exactly 1 jsonl file in {proj_dir}, found {jsonls}"
jsonl_file = jsonls[0]
assert jsonl_file.exists()
# Update mock herdr's state to match the running agent so the stop kill-chain works gracefully
with open(mock_herdr, 'r') as f:
state = json.load(f)
state["agents"][session_name] = {
"status": "running",
"agent": "claude",
"cwd": str(tmp_path),
"pid": 12345,
"pane_id": "w1:p1",
"command": "claude",
"buffer": "Anthropic Claude Ready"
}
with open(mock_herdr, 'w') as f:
json.dump(state, f, indent=2)
# 2. Calling stop_session without --yes fails for --purge-conversation
stop_script = tmp_path / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
cmd_stop_no_yes = [
"bash", str(stop_script),
"--session", session_name,
"--purge-conversation"
]
res_stop_no_yes = subprocess.run(cmd_stop_no_yes, capture_output=True, text=True, cwd=str(tmp_path))
assert res_stop_no_yes.returncode == 3
assert "DANGER: --purge-conversation" in res_stop_no_yes.stdout
# Ensure nothing was deleted yet
assert iso_root.exists()
assert jsonl_file.exists()
# 3. Run stop_session with --yes
cmd_stop_yes = [
"bash", str(stop_script),
"--session", session_name,
"--purge-conversation",
"--yes"
]
res_stop_yes = subprocess.run(cmd_stop_yes, capture_output=True, text=True, cwd=str(tmp_path))
assert res_stop_yes.returncode == 0, f"Stdout: {res_stop_yes.stdout}\nStderr: {res_stop_yes.stderr}"
# Assertions:
# - Isolation directory deleted
assert not iso_root.exists()
# - Conversation files deleted
assert not jsonl_file.exists()
# - Session completely removed from YAML/DB registry
with open(yaml_path, 'r') as f:
reg = yaml.safe_load(f)
assert not any(s["name"] == session_name for s in reg.get("herdr_sessions", []))
db_path = tmp_path / ".mam" / "agent-sessions.db"
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM sessions WHERE name=?", (session_name,))
assert cursor.fetchone()[0] == 0
conn.close()
def test_integration_resume_fallbacks(mam_sandbox, mock_herdr, mock_agents):
"""
Tier 3: Resume Session Fallbacks
Covers boundary checks, invalid / missing UUIDs, and workspace disk scan fallback resolution.
"""
tmp_path = mam_sandbox
# 1. Calling resume_session.sh with missing arguments
resume_script = tmp_path / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh"
cmd_missing = ["bash", str(resume_script), "--session", "test-session"]
res_missing = subprocess.run(cmd_missing, capture_output=True, text=True, cwd=str(tmp_path))
assert res_missing.returncode == 2
assert "ERROR:" in res_missing.stderr
# 2. Calling with non-existent session
cmd_nonexistent = ["bash", str(resume_script), "--workspace", str(tmp_path), "--agent", "claude", "--session", "non-existent"]
res_nonexistent = subprocess.run(cmd_nonexistent, capture_output=True, text=True, cwd=str(tmp_path))
assert res_nonexistent.returncode == 1
assert "ERROR: No saved session for" in res_nonexistent.stderr
# 3. Fallback resolution: set up a stopped session in YAML with NO own ID
session_name = "test-fallback-creator-claude"
mutation = """
d['herdr_sessions'] = [{
'name': 'test-fallback-creator-claude',
'status': 'stopped',
'role': 'Creator',
'pane': {'cwd': 'WS_PLACEHOLDER'}
}]
""".replace("WS_PLACEHOLDER", str(tmp_path))
res_mut = run_mutation(tmp_path, mutation)
assert res_mut.returncode == 0
# Seed a jsonl file representing a conversation under the workspace path
key = str(tmp_path).replace('/', '-').replace('_', '-')
proj_dir = tmp_path / ".claude" / "projects" / key
proj_dir.mkdir(parents=True, exist_ok=True)
scanned_uuid = "scanned-uuid-xyz-123"
(proj_dir / f"{scanned_uuid}.jsonl").write_text(json.dumps({"sessionId": scanned_uuid}) + "\n")
# Run resume_session.sh
cmd_resume = ["bash", str(resume_script), "--workspace", str(tmp_path), "--agent", "claude", "--session", session_name]
res_resume = subprocess.run(cmd_resume, capture_output=True, text=True, cwd=str(tmp_path))
assert res_resume.returncode == 0, f"Stdout: {res_resume.stdout}\nStderr: {res_resume.stderr}"
# Verify the session resumed using the resolved UUID
with open(mock_herdr, 'r') as f:
state = json.load(f)
calls = state.get("calls", [])
# Find the agent start/new-session call
new_sess_call = None
for call in calls:
if "agent" in call and "start" in call and session_name in call:
new_sess_call = call
break
assert new_sess_call is not None, f"Calls: {calls}"
# Verify the argument contains the resolved scanned_uuid
assert any(scanned_uuid in arg for arg in new_sess_call), f"Resume arguments: {new_sess_call}"
def test_integration_reconcile_diff_formats(mam_sandbox, mock_herdr):
"""
Tier 3: Reconcile Drift Detection and Diff Formatting
Covers outputs of reconcile.sh --once --emit-diff under dry-run and actual mutation.
"""
tmp_path = mam_sandbox
reconcile_script = tmp_path / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
# Seed a running session in YAML/DB that is NOT in herdr (Drift class A)
session_name = "drift-a-creator-claude"
mutation = """
d['herdr_sessions'] = [{
'name': 'drift-a-creator-claude',
'status': 'running',
'role': 'Creator',
'pane': {
'cwd': 'WS_PLACEHOLDER',
'pid': 9876,
'cmd': 'claude',
'cmd_full': 'claude'
}
}]
""".replace("WS_PLACEHOLDER", str(tmp_path))
res_mut = run_mutation(tmp_path, mutation)
assert res_mut.returncode == 0
db_path = tmp_path / ".mam" / "agent-sessions.db"
assert db_path.exists()
# Verify DB content
conn = sqlite3.connect(str(db_path))
db_rows = conn.execute("SELECT * FROM sessions").fetchall()
db_state = conn.execute("SELECT * FROM state").fetchall()
conn.close()
# Verify YAML content
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
with open(yaml_path, 'r') as f:
yaml_content = f.read()
# Verify load_state_json output
lib_path = tmp_path / ".agents" / "skills" / "lib.sh"
res_load = subprocess.run(["bash", "-c", f"source {lib_path} && load_state_json"], capture_output=True, text=True)
# Verify herdr ls -F output
res_ls = subprocess.run(["herdr", "ls", "-F", "#{session_name}|#{session_created}"], capture_output=True, text=True)
# 1. Run reconcile with --dry-run
cmd_dry = ["bash", str(reconcile_script), "--once", "--emit-diff", "--dry-run"]
res_dry = subprocess.run(cmd_dry, capture_output=True, text=True, cwd=str(tmp_path))
assert res_dry.returncode == 0, f"Stderr: {res_dry.stderr}"
# Verify stdout is valid JSON and reports class A drift
try:
data_dry = json.loads(res_dry.stdout)
except Exception as e:
assert False, f"Failed to parse JSON. stdout: {res_dry.stdout}, stderr: {res_dry.stderr}, error: {e}"
assert "drifts" in data_dry, f"JSON: {data_dry}"
drifts_dry = data_dry["drifts"]
# Debug print on failure
if not any(d["class"] == "A" and d["name"] == session_name for d in drifts_dry):
debug_info = (
f"DB_ROWS: {db_rows}\n"
f"DB_STATE: {db_state}\n"
f"YAML: {yaml_content}\n"
f"LOAD_STATE_JSON STDOUT: {res_load.stdout}\n"
f"LOAD_STATE_JSON STDERR: {res_load.stderr}\n"
f"HERDR LS STDOUT: {res_ls.stdout}\n"
f"HERDR LS STDERR: {res_ls.stderr}\n"
f"AGENT_SESSIONS_YAML ENV: {os.environ.get('AGENT_SESSIONS_YAML')}\n"
)
# Execute Python sub-process trace
py_code = """
import os, json, glob, subprocess, time, sqlite3
from datetime import datetime, timezone
import yaml
yaml_path = os.environ['YAML_PATH']
home = os.environ['HOME_DIR']
d = {}
ws_root = os.environ.get('WORKSPACE_ROOT')
print("WORKSPACE_ROOT:", ws_root)
script = f"source '{ws_root}/.agents/skills/lib.sh' && load_state_json"
out = subprocess.check_output(['bash', '-c', script])
print("LOAD STATE JSON OUT:", out.decode('utf-8'))
"""
env = {
"YAML_PATH": str(yaml_path),
"HOME_DIR": str(tmp_path),
"CLAUDE_PROJECT_DIR": str(tmp_path / ".claude" / "projects"),
"LOCAL_BIN": str(tmp_path / ".local" / "bin"),
"WORKSPACE_ROOT": str(tmp_path),
"AGENT_SESSIONS_YAML": str(yaml_path),
"PATH": os.environ.get("PATH", "")
}
res_test = subprocess.run(["python3", "-c", py_code], env=env, capture_output=True, text=True)
debug_info += f"TEST_CODE_STDOUT: {res_test.stdout}\nTEST_CODE_STDERR: {res_test.stderr}\n"
assert False, f"Drift class A not found in drifts. Output JSON: {json.dumps(data_dry, indent=2)}\nStderr: {res_dry.stderr}\nDebug:\n{debug_info}"
# Verify dry-run made no state changes in SQLite
conn = sqlite3.connect(str(db_path))
row = conn.execute("SELECT status FROM sessions WHERE name=?", (session_name,)).fetchone()
assert row[0] == "running"
conn.close()
# 2. Run reconcile without --dry-run
cmd_real = ["bash", str(reconcile_script), "--once", "--emit-diff"]
res_real = subprocess.run(cmd_real, capture_output=True, text=True, cwd=str(tmp_path))
assert res_real.returncode == 0, f"Stderr: {res_real.stderr}"
# Verify DB has been updated to terminated
conn = sqlite3.connect(str(db_path))
row = conn.execute("SELECT status FROM sessions WHERE name=?", (session_name,)).fetchone()
assert row[0] == "terminated"
conn.close()
def test_integration_invalid_arguments_exit_statuses(mam_sandbox, mock_herdr, mock_agents):
"""
Tier 3: Invalid Arguments & Exit Status Checks
Verifies that all scripts correctly handle invalid options and combinations with correct exit statuses.
"""
tmp_path = mam_sandbox
# 1. create_session.sh with missing arguments
create_script = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
res = subprocess.run(["bash", str(create_script)], capture_output=True, text=True, cwd=str(tmp_path))
assert res.returncode == 2
assert "ERROR: --workspace required" in res.stderr
res = subprocess.run(["bash", str(create_script), "--workspace", str(tmp_path)], capture_output=True, text=True, cwd=str(tmp_path))
assert res.returncode == 2
assert "ERROR: --agent required" in res.stderr
# 2. stop_session.sh with missing arguments
stop_script = tmp_path / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
res = subprocess.run(["bash", str(stop_script)], capture_output=True, text=True, cwd=str(tmp_path))
assert res.returncode == 2
assert "ERROR: --session required" in res.stderr
# 3. stop_session.sh cannot infer agent from weird session name
res = subprocess.run(["bash", str(stop_script), "--session", "bad-name"], capture_output=True, text=True, cwd=str(tmp_path))
assert res.returncode == 2
assert "ERROR: cannot infer agent" in res.stderr
# 4. stop_session.sh on non-existent session
res = subprocess.run(["bash", str(stop_script), "--session", "nonexistent-creator-claude"], capture_output=True, text=True, cwd=str(tmp_path))
assert res.returncode == 1
assert "ERROR: session 'nonexistent-creator-claude' not in" in res.stderr
# 5. reconcile.sh with invalid flag
reconcile_script = tmp_path / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
res = subprocess.run(["bash", str(reconcile_script), "--invalid-flag"], capture_output=True, text=True, cwd=str(tmp_path))
assert res.returncode == 2
assert "ERROR: unknown arg" in res.stderr
+429
View File
@@ -0,0 +1,429 @@
import os
import subprocess
import json
import sqlite3
import pytest
import shutil
import yaml
import time
import concurrent.futures
import threading
from pathlib import Path
# Helper to run mutation on agent-sessions.yaml using atomic_dump_yaml in bash
def run_mutation(mam_sandbox, mutation_str, env=None):
lib_path = mam_sandbox / ".agents" / "skills" / "lib.sh"
yaml_path = mam_sandbox / ".mam" / "agent-sessions.yaml"
cmd_str = f"source {lib_path} && atomic_dump_yaml {yaml_path}"
run_env = dict(os.environ)
if env:
run_env.update(env)
res = subprocess.run(["bash", "-c", cmd_str], input=mutation_str, capture_output=True, text=True, env=run_env)
return res
def test_e2e_scenario1_standard_lifecycle(mam_sandbox, mock_herdr, mock_agents):
"""
Scenario 1: Standard Agent Session Lifecycle
Spawn session, check registry status, query status via status.sh, and stop session.
"""
tmp_path = mam_sandbox
create_script = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
status_script = tmp_path / "skills" / "multi-agent-mux-status" / "scripts" / "status.sh"
stop_script = tmp_path / "skills" / "multi-agent-mux-stop" / "scripts" / "stop_session.sh"
session_name = "e2e-sess1-creator-claude"
# 1. Spawn session using create_session.sh
cmd_create = [
"bash", str(create_script),
"--workspace", str(tmp_path),
"--agent", "claude",
"--role", "Creator",
"--session", session_name
]
res_create = subprocess.run(cmd_create, capture_output=True, text=True, cwd=str(tmp_path))
assert res_create.returncode == 0, f"Stderr: {res_create.stderr}"
# Verify mock herdr registers it
with open(mock_herdr, 'r') as f:
state = json.load(f)
assert session_name in state["agents"]
assert state["agents"][session_name]["status"] == "running"
# 2. Check status via status.sh --json
cmd_status = ["bash", str(status_script), "--json"]
res_status = subprocess.run(cmd_status, capture_output=True, text=True, cwd=str(tmp_path))
assert res_status.returncode == 0
status_data = json.loads(res_status.stdout)
sessions_detail = status_data["sessions_detail"]
assert any(s["name"] == session_name and s["status"] == "running" for s in sessions_detail)
# 3. Stop it via stop_session.sh
cmd_stop = ["bash", str(stop_script), "--session", session_name]
res_stop = subprocess.run(cmd_stop, capture_output=True, text=True, cwd=str(tmp_path))
assert res_stop.returncode == 0
# Verify status in YAML/DB becomes stopped
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
with open(yaml_path, 'r') as f:
reg = yaml.safe_load(f)
session_entry = [s for s in reg.get("herdr_sessions", []) if s["name"] == session_name][0]
assert session_entry["status"] == "stopped"
def test_e2e_scenario2_disconnect_resume(mam_sandbox, mock_herdr, mock_agents):
"""
Scenario 2: Session Disconnect and Resume
Spawn session, simulate process death in herdr state, run resume_session.sh, and assert resume UUID.
"""
tmp_path = mam_sandbox
create_script = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
resume_script = tmp_path / "skills" / "multi-agent-mux-resume" / "scripts" / "resume_session.sh"
session_name = "e2e-sess2-creator-claude"
# 1. Spawn session
cmd_create = [
"bash", str(create_script),
"--workspace", str(tmp_path),
"--agent", "claude",
"--role", "Creator",
"--session", session_name
]
res_create = subprocess.run(cmd_create, capture_output=True, text=True, cwd=str(tmp_path))
assert res_create.returncode == 0
# Get own UUID from YAML
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
with open(yaml_path, 'r') as f:
reg = yaml.safe_load(f)
orig_session = reg["herdr_sessions"][0]
# Simulate first message creating own ID (materialized own ID)
own_uuid = "e2e-own-uuid-111"
iso_root = Path(orig_session["isolation"]["root"])
key = str(tmp_path).replace('/', '-').replace('_', '-')
proj_dir = iso_root / "projects" / key
proj_dir.mkdir(parents=True, exist_ok=True)
(proj_dir / f"{own_uuid}.jsonl").write_text(json.dumps({"sessionId": own_uuid}) + "\n")
mutation = f"""
for s in d.get('herdr_sessions', []):
if s.get('name') == '{session_name}':
s['status'] = 'stopped'
s['claude_session_id_own'] = '{own_uuid}'
"""
res_mut = run_mutation(tmp_path, mutation)
assert res_mut.returncode == 0
# 2. Simulate process death in mock herdr state
with open(mock_herdr, 'r') as f:
state = json.load(f)
if session_name in state["agents"]:
del state["agents"][session_name]
with open(mock_herdr, 'w') as f:
json.dump(state, f, indent=2)
# Clear herdr calls to isolate assertions
with open(mock_herdr, 'r') as f:
state = json.load(f)
state["calls"] = []
with open(mock_herdr, 'w') as f:
json.dump(state, f, indent=2)
# 3. Run resume_session.sh
cmd_resume = [
"bash", str(resume_script),
"--workspace", str(tmp_path),
"--agent", "claude",
"--session", session_name
]
res_resume = subprocess.run(cmd_resume, capture_output=True, text=True, cwd=str(tmp_path))
assert res_resume.returncode == 0, f"Stderr: {res_resume.stderr}"
# 4. Assert that it resumes using the correct UUID in the start command arguments
with open(mock_herdr, 'r') as f:
state = json.load(f)
calls = state.get("calls", [])
resume_call = None
for call in calls:
if "agent" in call and "start" in call and session_name in call:
resume_call = call
break
assert resume_call is not None, f"Could not find resume agent start call in calls: {calls}"
assert any(own_uuid in arg for arg in resume_call), f"Expected UUID {own_uuid} to be in resume command: {resume_call}"
def test_e2e_scenario3_drift_auto_reconciliation(mam_sandbox, mock_herdr):
"""
Scenario 3: Drift Detection and Auto-Reconciliation
Setup drift states (running in herdr but not in YAML registry, and running in YAML registry but terminated in herdr).
Verify reconcile.sh automatically reconciles both.
"""
tmp_path = mam_sandbox
reconcile_script = tmp_path / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh"
# Drift 1: Running in herdr but not in YAML
drift_herdr_only = "drift-herdr-only-creator-claude"
with open(mock_herdr, 'r') as f:
state = json.load(f)
state["agents"][drift_herdr_only] = {
"status": "running",
"agent": "claude",
"cwd": str(tmp_path),
"pid": 5555,
"pane_id": "w1:p1",
"command": "claude",
"buffer": "Anthropic Claude Ready"
}
with open(mock_herdr, 'w') as f:
json.dump(state, f, indent=2)
# Drift 2: Running in YAML registry but terminated in herdr
drift_yaml_only = "drift-yaml-only-creator-claude"
mutation = f"""
d['herdr_sessions'] = [{{
'name': '{drift_yaml_only}',
'status': 'running',
'role': 'Creator',
'pane': {{
'cwd': 'WS_PLACEHOLDER',
'pid': 6666,
'cmd': 'claude',
'cmd_full': 'claude'
}}
}}]
""".replace("WS_PLACEHOLDER", str(tmp_path))
res_mut = run_mutation(tmp_path, mutation)
assert res_mut.returncode == 0
# Run reconcile.sh --once
cmd_reconcile = ["bash", str(reconcile_script), "--once"]
res_recon = subprocess.run(cmd_reconcile, capture_output=True, text=True, cwd=str(tmp_path))
assert res_recon.returncode == 0, f"Stderr: {res_recon.stderr}"
# Verify YAML/DB states
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
with open(yaml_path, 'r') as f:
reg = yaml.safe_load(f)
sessions = reg.get("herdr_sessions", [])
# drift-herdr-only-creator-claude should have been auto-registered as running
sess_herdr_only = [s for s in sessions if s["name"] == drift_herdr_only]
assert len(sess_herdr_only) == 1
assert sess_herdr_only[0]["status"] == "running"
# drift-yaml-only-creator-claude should have been auto-terminated
sess_yaml_only = [s for s in sessions if s["name"] == drift_yaml_only]
assert len(sess_yaml_only) == 1
assert sess_yaml_only[0]["status"] == "terminated"
def test_e2e_scenario4_parallel_flock_locking(mam_sandbox, mock_herdr, mock_agents):
"""
Scenario 4: Parallel Session Operations with flock Locking
Run multiple concurrent session creation scripts to verify SQLite locking prevents database corruption.
"""
tmp_path = mam_sandbox
create_script = tmp_path / "skills" / "multi-agent-mux-create" / "scripts" / "create_session.sh"
num_sessions = 6
def run_create(i):
session_name = f"parallel-sess-{i}-creator-claude"
cmd = [
"bash", str(create_script),
"--workspace", str(tmp_path),
"--agent", "claude",
"--role", "Creator",
"--session", session_name
]
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(tmp_path))
return res
# Execute in parallel
with concurrent.futures.ThreadPoolExecutor(max_workers=num_sessions) as executor:
futures = [executor.submit(run_create, i) for i in range(num_sessions)]
results = [f.result() for f in futures]
# Verify that all succeeded
for i, res in enumerate(results):
assert res.returncode == 0, f"Session {i} failed. Stdout: {res.stdout}\nStderr: {res.stderr}"
# Verify all 6 sessions are present in YAML registry
yaml_path = tmp_path / ".mam" / "agent-sessions.yaml"
with open(yaml_path, 'r') as f:
reg = yaml.safe_load(f)
sessions = reg.get("herdr_sessions", [])
registered_names = {s["name"] for s in sessions}
for i in range(num_sessions):
assert f"parallel-sess-{i}-creator-claude" in registered_names
def test_e2e_scenario5_multi_agent_review_loop(mam_sandbox, mock_herdr, mock_agents):
"""
Scenario 5: Multi-Agent Review Loop
Run run_loop.sh under simulated conditions where reviewer output is mocked.
Verify loop terminates correctly with expected PASS and NOT PASS verdicts.
"""
tmp_path = mam_sandbox
loop_script = tmp_path / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "run_loop.sh"
# 1. Seed sessions in registry for the worker, reviewer, and planner
worker_name = "test-worker-creator-claude"
reviewer_name = "test-reviewer-creator-claude"
planner_name = "test-planner-creator-claude"
mutation = f"""
d['herdr_sessions'] = [
{{
'name': '{worker_name}',
'status': 'running',
'role': 'worker',
'pane': {{'cwd': 'WS_PLACEHOLDER'}}
}},
{{
'name': '{reviewer_name}',
'status': 'running',
'role': 'reviewer',
'pane': {{'cwd': 'WS_PLACEHOLDER'}}
}},
{{
'name': '{planner_name}',
'status': 'running',
'role': 'planner',
'pane': {{'cwd': 'WS_PLACEHOLDER'}}
}}
]
""".replace("WS_PLACEHOLDER", str(tmp_path))
res_mut = run_mutation(tmp_path, mutation)
assert res_mut.returncode == 0
# Seed mock herdr state with these running sessions to satisfy has-session checks
with open(mock_herdr, 'r') as f:
herdr_state = json.load(f)
pane_ids = {
worker_name: "w1:p1",
reviewer_name: "w1:p2",
planner_name: "w1:p3"
}
for name in [worker_name, reviewer_name, planner_name]:
herdr_state["agents"][name] = {
"status": "running",
"agent": "claude",
"cwd": str(tmp_path),
"pid": 9999,
"pane_id": pane_ids[name],
"command": "claude",
"buffer": "Anthropic Claude Ready"
}
with open(mock_herdr, 'w') as f:
json.dump(herdr_state, f, indent=2)
# Define mock reviewer and planner outputs
# Let's mock a scenario:
# Critique: worker challenge
# Refinement: refined plan
# Review: First try NOT PASS, Second try PASS
job_responses = {
"Planner": "Refined Plan:\n1. Implement X\n2. Verify X",
"critique": "Creator Critique: Plan has 1 edge case.",
"Worker": "Creator Output: Code updated.",
"Reviewer": "Reviewer verdict:\n\n[VERDICT: PASS]" # will override inside simulator to test iteration logic
}
# We will run a background simulator thread to resolve jobs
stop_event = threading.Event()
def simulate_delegate_jobs():
jobs_dir = tmp_path / ".mam" / "jobs"
iteration = 1
while not stop_event.is_set():
if not jobs_dir.exists():
time.sleep(0.1)
continue
for job_file in jobs_dir.glob("*.json"):
try:
with open(job_file, 'r+') as f:
job = json.load(f)
if job.get("status") == "pending":
job_id = job["job_id"]
role = job.get("role", "Worker")
prompt = job.get("prompt", "")
# Determine response text
response_text = ""
if role == "Planner":
response_text = job_responses["Planner"]
elif "Challenge" in prompt or "Critique" in prompt:
response_text = job_responses["critique"]
elif role == "Worker":
response_text = job_responses["Worker"]
elif role == "Reviewer":
# For Reviewer, fail the first time, pass the second time
if iteration == 1:
response_text = "Review report:\nSome lint issues found.\n\n[VERDICT: NOT PASS]"
iteration += 1
else:
response_text = "Review report:\nAll clean.\n\n[VERDICT: PASS]"
# Write final report
job_work_dir = jobs_dir / job_id
job_work_dir.mkdir(parents=True, exist_ok=True)
(job_work_dir / "report-final.md").write_text(response_text)
# Complete job
job["status"] = "completed"
f.seek(0)
json.dump(job, f, indent=2)
f.truncate()
# Publish completed event over MQTT using publish_event.py
pub_script = tmp_path / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "scripts" / "publish_event.py"
cmd_pub = [
sys.executable, str(pub_script),
"--registry-dir", str(jobs_dir),
"--job", job_id,
"--event", "completed",
"--detail", f"{role} finished work"
]
subprocess.run(cmd_pub, capture_output=True, text=True)
except Exception:
pass
time.sleep(0.1)
sim_thread = threading.Thread(target=simulate_delegate_jobs)
sim_thread.daemon = True
sim_thread.start()
try:
# Run run_loop.sh
cmd_loop = [
"bash", str(loop_script),
"--target-agent", worker_name,
"--reviewer", reviewer_name,
"--plan",
"--plan-talk", "1",
"--max-loop", "3",
"--task", "Implement feature X and verify"
]
import sys
run_env = dict(os.environ)
run_env["DELEGATE_JOB_PYTHON"] = sys.executable
res_loop = subprocess.run(cmd_loop, capture_output=True, text=True, cwd=str(tmp_path), env=run_env)
# Verify that it succeeded and executed the corrective loop
assert res_loop.returncode == 0, f"Loop failed. Stdout: {res_loop.stdout}\nStderr: {res_loop.stderr}"
assert "Reviewer 'test-reviewer-creator-claude': NOT PASS" in res_loop.stdout or "Reviewer 'test-reviewer-creator-claude': NOT PASS" in res_loop.stderr or "NOT PASS" in res_loop.stdout
assert "Reviewer 'test-reviewer-creator-claude': PASS" in res_loop.stdout
assert "Mux loop finished with 100% PASS verdicts." in res_loop.stdout
finally:
stop_event.set()
sim_thread.join(timeout=1.0)