Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4e1246138 | ||
|
|
39daf013cd | ||
|
|
9620de9ae6 | ||
|
|
a027cf8885 | ||
|
|
ed97171959 | ||
|
|
83ce47c470 | ||
|
|
ad07168c80 | ||
|
|
5cbac7e87f | ||
|
|
3e4f5fe5f7 | ||
|
|
49073a67c3 | ||
|
|
7e1d2304ad | ||
|
|
cc2b2257c0 | ||
|
|
c969d436e3 | ||
|
|
3ddfd07fcd | ||
|
|
7095da4c9e | ||
|
|
13496ce922 | ||
|
|
4d6423e92f | ||
|
|
9e4f286b43 | ||
|
|
d885aa52ae | ||
|
|
2030721155 | ||
|
|
f2a1611390 | ||
|
|
c6dab0a901 | ||
|
|
f6fb8a038f | ||
|
|
5fed318883 | ||
|
|
a9ff3d56ee | ||
|
|
2d48dc5323 |
@@ -1,169 +0,0 @@
|
||||
# AGENT.md
|
||||
|
||||
본 문서는 새로운 프로젝트에 **MQTT 메시징 백플레인 및 Tmux 기반 멀티 에이전트 오케스트레이션 워크플로우**를 도입하고, 협업하는 에이전트들이 일관된 규칙과 아키텍처에 따라 안전하고 견고하게 작업을 수행할 수 있도록 정의한 공통 지침 및 규약입니다.
|
||||
|
||||
새로운 프로젝트에서 작업하는 모든 에이전트는 작업을 시작하기 전 이 문서를 반드시 정독하고 규약을 준수해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 에이전트의 역할 정의 (Agent Roles)
|
||||
|
||||
역할군 간의 책임 및 권한을 명확히 분리하여 병목을 줄이고 작업의 완성도를 높입니다.
|
||||
|
||||
### 👑 General Manager (총괄 매니저)
|
||||
- **주요 책무**: 사용자와 직접 소통하여 요구사항 접수, 상세 작업 계획 수립, 팀장 에이전트 할당 및 작업 위임, 전체 워크플로우 통제 및 최종 완료 보고.
|
||||
- **모호성 제거**: 사용자의 요구사항에 모호한 부분이 있다면 작업을 추측하여 진행하지 말고, 즉시 사용자에게 질문하여 명확히 해야 합니다 (`/grill-me` 슬래시 명령어 권장).
|
||||
|
||||
### 👥 Team Leaders (팀장)
|
||||
새롭게 생성되는 에이전트(`antigravity`, `claude`, `cline`, `hermes` 등)는 각 팀의 **팀장** 역할을 수행합니다. 총괄 매니저로부터 작업을 위임받아 개발 또는 리뷰 워크플로우를 주도합니다.
|
||||
- **Developer Team Leader (개발 팀장)**:
|
||||
- 총괄 매니저로부터 작업을 위임받습니다.
|
||||
- **작업 분석 및 계획**: 주어진 작업을 철저히 분석하고, 작은 단위로 문제를 나누어 세부 계획을 수립합니다.
|
||||
- **내부 병렬 처리**: 내부적으로 subagent를 활용해 위임받은 작업을 병렬적으로 처리할 수 있습니다.
|
||||
- **리뷰 타당성 검증 및 거부**: 리뷰어가 지적한 피드백을 면밀히 검토합니다. 타당한 제안은 수렴하여 코드를 수정하지만, 타당하지 않다고 판단되는 안건은 반영하지 않고 **그 명확한 이유를 작성하여 리뷰어에게 되돌려 보냅니다**.
|
||||
- **완료 신호 송신**: 모든 리뷰어들로부터 `PASS`를 획득하고 변경 사항이 검증되면, 최초 작업을 위임받았던 개발 팀장이 총괄 매니저에게 최종 작업 완료 신호를 송신합니다.
|
||||
- **Reviewer Team Leader (리뷰어 팀장)**:
|
||||
- 개발 팀장으로부터 리뷰 요청을 접수합니다.
|
||||
- **문제 제시에 대한 이유와 개선 방향 포함**: 단순한 반려(`NOT PASS`) 통보는 금지됩니다. 이슈를 제기할 때는 **반드시 해당 문제가 발생하는 구체적인 이유와 확실한 개선 방향(코드 대안 포함)을 함께 작성**해야 합니다.
|
||||
- **합의 루프**: 모든 지적 사항이 해결되고 최종 `PASS`를 발행할 때까지 리뷰 루프에 동참합니다.
|
||||
|
||||
### 🛡️ 역할 범위 준수 원칙 (Role Suitability Check)
|
||||
- 모든 에이전트는 자신에게 부여된 역할에 부합하는 작업만을 수행해야 합니다. (예: 개발 팀장은 최종 PASS 여부를 결정하지 않으며, 리뷰어 팀장은 직접 프로젝트 소스코드를 작성하지 않습니다.)
|
||||
- **자신의 역할에 맞지 않는 작업이 지시된 경우**, 에이전트는 반드시:
|
||||
1. 해당 작업을 수행하기에 가장 적합한 에이전트 세션을 추천하여 위임을 유도하거나,
|
||||
2. 프로젝트 연속성을 위해 극히 필요한 경우 직접 작업을 수행합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 메시징 백플레인 & 레지스트리 규약
|
||||
|
||||
에이전트 간의 비동기 소통과 상태 관리는 분산 이벤트 채널 및 파일/DB 레지스트리를 통해 제어됩니다.
|
||||
|
||||
### 📡 MQTT 백플레인 (MQTT Backplane)
|
||||
- **이벤트 라이프사이클**:
|
||||
- `started` (작업 개시) ➡️ `progress`/`permission_required` (진행 상황 공유) ➡️ `completed` (성공 종료) 또는 `error` (실패 종료)
|
||||
- `completed` 및 `error`는 단 한 번만 발행되는 단말(Terminal) 이벤트입니다.
|
||||
- **메시지 발행/구독 규칙**:
|
||||
- MQTT는 영속 큐를 보장하지 않으므로, 에이전트 구동 전 **반드시 구독자(`job_subscriber.py`)가 먼저 백그라운드에서 대기**해야 합니다 (Subscribe-before-Publish 원칙).
|
||||
- 단말 이벤트 발행 시 브로커에 `retain=True`로 영속화하여 늦게 합류한 구독자도 최종 상태를 읽을 수 있도록 조치합니다.
|
||||
- 전송 데이터에는 비밀번호, 개인키 등의 중요 비밀 정보나 절대 경로가 포함되지 않도록 보편화(Generalised)해야 합니다.
|
||||
|
||||
### 🗃️ 레지스트리 및 상태 관리
|
||||
- 본 아키텍처는 목적에 따라 두 가지 레지스트리를 분리하여 운영합니다:
|
||||
- **잡 레지스트리 (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(네트워크 파일 시스템) 환경에서는 완전한 파일 락이 보장되지 않으므로 로컬 파일 시스템 사용을 권장합니다.
|
||||
|
||||
### 🛡️ 보안 프로토콜 (HMAC-SHA256)
|
||||
- **무인증 PoC 모드**: 잡 레지스트리 생성 시 `auth_token`이 `null`로 지정된 경우(PoC 기본 모드), 별도의 서명 검증을 생략하고 모든 이벤트를 수용합니다 (`verify_hmac`이 항상 `True`를 반환).
|
||||
- **인증 Production 모드**: 실배포 환경이나 인증이 필요한 연동 단계에서는 각 잡마다 고유 암호화 토큰(`auth_token`)을 발급합니다. 퍼블리셔는 이 토큰을 키로 삼아 `hmac_sig` 서명을 페이로드에 동반해야 하며, 수신단(`verify_hmac`)에서 서명이 없거나 일치하지 않는 메시지는 즉시 드랍하여 다운그레이드 공격을 원천 차단합니다.
|
||||
- **롤아웃 전략**: 보안 스킴 갱신 시 송수신 노드 간 불일치로 인한 이벤트 드랍을 피하기 위해, 과도기적 하이브리드 포맷 전송(평문 유출 위험 있음)을 배제하고 **모든 노드를 일제히 업데이트하는 "동시 롤아웃(Simultaneous Rollout)"**을 채택해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 협업 워크플로우 실행 절차 (Workflow Loop)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor User as 사용자
|
||||
participant GM as General Manager
|
||||
participant DTL as Developer Team Leader
|
||||
participant RTL as Reviewer Team Leaders
|
||||
participant M as MQTT Backplane
|
||||
|
||||
User->>GM: 요구사항 전달
|
||||
GM->>DTL: 작업 위임 (예: 랜딩 페이지 제작)
|
||||
Note over DTL: 작업 분석, 세분화 및 subagent 병렬 구동
|
||||
DTL->>M: 'started' 이벤트 발행
|
||||
Note over DTL: 코드 변경 및 구현
|
||||
DTL->>M: 'completed' 발행
|
||||
DTL->>RTL: 리뷰 요청 (랜딩 페이지를 제작했습니다. 리뷰를 진행해주세요)
|
||||
Note over RTL: 교차 분석 & 검증
|
||||
alt 결함 발견 (리뷰어 피드백)
|
||||
RTL->>DTL: NOT PASS / 피드백 (반드시 이유와 확실한 개선 방향 포함)
|
||||
Note over DTL: DTL이 피드백의 타당성 검증
|
||||
alt 타당한 피드백
|
||||
Note over DTL: DTL이 수용하여 코드 수정
|
||||
else 타당하지 않은 피드백
|
||||
DTL->>RTL: 반론 및 거부 이유 전달 (부적절한 항목 미반영)
|
||||
end
|
||||
DTL->>RTL: 재리뷰 요청 (리뷰 안건 수정 완료)
|
||||
else 검증 통과
|
||||
RTL->>DTL: PASS
|
||||
end
|
||||
DTL->>GM: 최종 완료 신호 송신
|
||||
GM->>User: 사용자에게 작업 완료 통보
|
||||
```
|
||||
|
||||
1. **계획 수립 및 할당**: 총괄 매니저는 개발 팀장에게 작업을 인가합니다.
|
||||
2. **분석 및 내부 실행**: 개발 팀장은 작업을 분석하고 세분화하여 계획을 세운 뒤 내부 subagent를 가동하여 구현을 완료합니다. 이후 `started`를 거쳐 `completed` 이벤트를 발행하고 리뷰어에게 검수를 요청합니다.
|
||||
3. **이의 제기 및 정제 루프**:
|
||||
- 리뷰어 팀장은 상세 피드백 시 반드시 이유와 보완 방향을 제시해야 합니다.
|
||||
- 개발 팀장은 의견을 검토해 타당하면 수정하고, 타당하지 않으면 반론과 근거를 회신합니다.
|
||||
- 리뷰어 전원이 `PASS`를 인가할 때까지 이 과정이 반복됩니다.
|
||||
4. **최종 보고**: 개발 팀장이 총괄 매니저에게 완료 신호를 보내면 총괄 매니저가 사용자에게 완료를 알립니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 분석 인프라 패턴 & 실무 가이드 (Infra Patterns)
|
||||
|
||||
장기 실행 에이전트 분석 중 발생하는 유실 및 인프라적 장애를 예방하기 위한 중요 지침입니다.
|
||||
|
||||
### 📸 TUI 뷰포트 절단 방지 (Pane Snapshotting 3대 규칙)
|
||||
TMUX 환경에서 실행되는 에이전트가 화면 스크롤 한계로 인해 이전 출력이나 장문의 디버깅 로그를 잃지 않도록 아래의 **스냅샷 패턴을 의무적으로 수행**합니다.
|
||||
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 상태를 마지막으로 캡처하여 전체 작업 궤적을 보존합니다.
|
||||
|
||||
### 📄 장문 브리핑 전달 방식
|
||||
- TMUX `send-keys`나 입력 버퍼를 통해 수백 줄의 장문 지시나 프롬프트를 직렬로 입력하면, 에이전트의 TUI가 이를 모두 온전히 소화하지 못하고 일부 문자나 문단이 탈취/누락될 수 있습니다.
|
||||
- **해결 지침**: 지시 사항이 긴 경우, 반드시 `/tmp/brief-<job_id>.md` 등의 파일 경로로 지시문을 별도 작성해 전달하고, 에이전트에는 `"Read /tmp/brief-... and execute"` 라는 단순화된 실행 명령만 전달하십시오.
|
||||
|
||||
### ⏱️ 타임아웃 구성 및 정렬 규칙
|
||||
- **잡 실행 제한 (`timeout_sec` & `idle_timeout_sec`)**: 각 잡은 전체 실행 만료 시간(`timeout_sec`, 기본 3600s)과 메세지 미수신 유휴 시간(`idle_timeout_sec`, 기본 120s)을 독립적으로 가집니다.
|
||||
- **모니터 유휴 대기 (`SUB_IDLE_TIMEOUT`)**: 모니터 스크립트(`reconcile.sh`)의 유휴 대기 시간(`SUB_IDLE_TIMEOUT`) 기본값은 잡 최대 예산에 맞춰 `3600s`(1시간) 이상으로 항상 넉넉히 설정해야 합니다. 모니터가 작업 완료 전에 유휴 감지로 조기 자동 종료되어 백그라운드 태스크 관리를 소실하는 문제를 방지하기 위함입니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 새 프로젝트 적용 체크리스트 (Setup Checklist)
|
||||
|
||||
새 프로젝트에 이 에이전트 오케스트레이션 모델을 구축할 때의 체크리스트입니다.
|
||||
|
||||
- [ ] **가상환경 의존성**: `pyyaml`, `paho-mqtt` 등 필요한 Python 패키지가 `.venv` 또는 `requirements.txt`에 포함되었는가?
|
||||
- [ ] **환경 설정 파일**: MQTT 브로커 주소 및 보안 Credential이 `.env` 파일에 안전하게 로드되고 공유되는가?
|
||||
- [ ] **디렉토리 규약**: 레지스트리 경로(`.mam/jobs/`) 및 로깅 경로(`.mam/delegate_job_logs/`)가 `.gitignore`에 등록되었는가?
|
||||
- [ ] **스크립트 구비**: `mqtt_common.py`, `publish_event.py`, `job_subscriber.py`, `registry.py` 등의 핵심 모듈이 배치되었는가?
|
||||
- [ ] **HMAC 활성화**: 새로운 레지스트리 잡 발급 시 난수 기반의 `auth_token`이 정상적으로 주입되고, 서명 기반의 상호 인증이 활성화되는가?
|
||||
- [ ] **운영 헌장 배치**: 본 규약 파일(`AGENT.md`)이 새 프로젝트의 **.agents/ 디렉터리**에 배치되었는가? (프로젝트 루트를 깔끔하게 유지하면서도 온보딩하는 에이전트들이 규칙을 이해할 수 있도록 `.agents/` 경로 배치가 권장됩니다.)
|
||||
|
||||
---
|
||||
|
||||
*본 가이드는 협업 효율성과 코드 보안의 엄격한 균형을 유지하기 위한 규범입니다. 변경 사항이 필요한 경우 총괄 매니저 및 전체 팀장의 합의를 거쳐 본 문서를 업데이트해야 합니다.*
|
||||
|
||||
---
|
||||
|
||||
## 6. AIoT 멀티 에이전트 인터페이스 모듈 설계 지침 (A2A 및 Agent Card)
|
||||
|
||||
향후 연구 및 개발 단계에서 구현할 통합 인터페이스 모듈(Unified Interface Module)은 이종 에이전트 간의 자율 협업 및 상호운용성 확보를 위해 **A2A(Agent-to-Agent)** 표준을 포함하며, 다음의 설계 지침을 철저히 준수해야 합니다.
|
||||
|
||||
### 📇 1. 에이전트 카드(Agent Card)의 표준 지원
|
||||
- **규격화된 JSON 스키마**: 인터페이스 모듈은 에이전트의 메타데이터 및 입출력 인터페이스를 정의한 에이전트 카드를 JSON 형태로 자동 생성 및 서빙해야 합니다.
|
||||
- **필수 명세 항목**:
|
||||
- 에이전트의 명칭 및 페르소나/역할 (Name, Persona, Role)
|
||||
- 호출 가능한 엔드포인트 정보 (Endpoint URI 및 프로토콜 규격)
|
||||
- 입력 데이터 스키마 (Tool Schema 및 입출력 제약사항)
|
||||
- 보안 인증 및 인가 사양 (Authentication & Security Credentials)
|
||||
- **배포 및 탐색 규격**: 시스템 내의 다른 이종 에이전트들이 실시간으로 탐색할 수 있도록 `/.well-known/agent-card.json` 표준 경로를 통한 에이전트 카드 서빙 인프라를 내장해야 합니다.
|
||||
|
||||
### 📡 2. A2A 및 ACP 프로토콜 커넥터 설계
|
||||
- **프로토콜 상호운용성**: IBM ACP(Agent Communication Protocol)와 Linux Foundation A2A 표준의 병합 사양에 기반하여, 에이전트 간 비동기 위임(Task Delegation), 제어권 이관, 상태 동기화가 가능한 프로토콜 커넥터를 인터페이스 모듈 내에 구현해야 합니다.
|
||||
- **이벤트 기반 자율 제어**: 상시 센서 모니터링은 경량 물리 노드 및 모니터 프로그램이 수행하도록 분리하고, 감지된 물리 이벤트는 A2A 프로토콜 메시지를 통해 비동기적으로 특정 AI 에이전트에 작업을 자율 위임하고 처리 후 즉시 생명주기 및 제어권을 회수하는 비동기 인터페이스를 탑재해야 합니다.
|
||||
|
||||
### 🛠️ 3. 워크플로우 정합성 및 이슈 추적
|
||||
- **실행 일관성 보장**: 이종 에이전트가 동일한 작업을 재수행할 때 동일한 결과를 얻을 수 있도록 워크플로우 실행을 원자적(Atomic)으로 통제해야 합니다.
|
||||
- **표준 이슈 트래킹**: 실행 예외나 네트워크 단절 등의 장애가 발생한 경우, 복구 및 추적이 용이하도록 표준 규격화된 이슈 추적 인터페이스(Issue Tracking Interface)를 연동 및 마련해야 합니다.
|
||||
|
||||
### 🛡️ 4. 멱등성(Idempotency) 보장 스키마
|
||||
- **물리 제어 명령의 멱등성 분류**: 인터페이스 모듈은 전달되는 제어 명령을 멱등성 보장 여부에 따라 분류해야 합니다. 정보 조회·상태 설정 등 멱등성이 자연 보장되는 명령과, 농약 투포·급수·밸브 개폐 등 부작용이 누적되어 재실행 시 치명적 안전 사고로 이어질 수 있는 비멱등성(non-idempotent) 물리 제어 명령을 명시적으로 구분해야 합니다.
|
||||
- **중복 발행 억제 메커니즘**: 비동기 재시도 루프에서 동일한 제어 의도가 새로운 작업 ID로 중복 발행되는 것을 방지하기 위해, 제어 의도 식별자(Control Intent Key) 기반의 중복 억제(Deduplication) 스키마를 인터페이스 계층에 내장해야 합니다. 재시도 시 신규 작업 ID가 아닌 원본 제어 의도에 바인딩된 멱등성 키를 재사용하여 동일 제어의 재실행 가능성을 시스템 차원에서 통제해야 합니다.
|
||||
- **물리 장치측 사전 검증**: 중복 제어가 물리 장치에 도달하기 전에 인터페이스 모듈이 사전 검증(pre-flight check)을 수행하여, 이미 실행된 제어 의도에 대한 재실행 요청을 사전 차단하거나 장치측 상태와 대조 후 승인하는 안전망을 제공해야 합니다.
|
||||
@@ -1,169 +0,0 @@
|
||||
# AGENT.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.
|
||||
|
||||
All agents working on a new project must read this document thoroughly and comply with the defined protocols before starting any tasks.
|
||||
|
||||
---
|
||||
|
||||
## 1. Agent Roles Definition (Agent Roles)
|
||||
|
||||
We clearly separate responsibilities and permissions between roles to reduce bottlenecks and enhance the quality of execution.
|
||||
|
||||
### 👑 General Manager (Orchestrator)
|
||||
- **Core Responsibility**: Interact directly with the user, receive high-level requirements, establish task plans, delegate tasks to Team Leaders, control the overall workflow, and report completion back to the user.
|
||||
- **Ambiguity Resolution**: If a user's requirements contain ambiguous details, do not guess. Immediately ask the user for clarification (we recommend using the `/grill-me` slash command).
|
||||
|
||||
### 👥 Team Leaders (팀장)
|
||||
Newly spawned agents (e.g., `antigravity`, `claude`, `cline`, `hermes`) act as **Team Leaders** of their respective groups. They receive delegated tasks from the General Manager and manage implementation or review workflows.
|
||||
- **Developer Team Leader (개발 팀장)**:
|
||||
- Receives tasks from the General Manager.
|
||||
- **Task Breakdown & Planning**: Thoroughly analyzes the task, breaks it down into small units, and creates a plan.
|
||||
- **Internal Parallelism**: Can run subagents in parallel internally to handle the delegated work.
|
||||
- **Review Integrity & Refusal**: Thoroughly reviews feedback from Reviewers. Adopts/implements recommendations if valid. If any recommendation is judged invalid, the Developer Team Leader must **not** implement it, but instead return the refutation along with detailed reasons to the Reviewer.
|
||||
- **Completion Signal**: Once all reviewers yield a `PASS` and changes are verified, the Developer Team Leader who first received the task sends a completion signal back to the General Manager.
|
||||
- **Reviewer Team Leader (리뷰어 팀장)**:
|
||||
- Receives review requests from the Developer Team Leader.
|
||||
- **Detailed Feedback with Directions**: Simply rejecting changes (`NOT PASS`) is forbidden. Reviewers **must** specify the exact reason for the issue and provide a concrete, stable, and verified alternative direction for improvement.
|
||||
- **Consensus Loop**: Engages in the review cycle until all objections are resolved and a final `PASS` is issued.
|
||||
|
||||
### 🛡️ Role Suitability Check Principle (자신의 역할 범위 수행 원칙)
|
||||
- Every agent must only perform tasks suitable for its designated role (e.g., Developer Team Leaders do not issue final reviews, and Reviewer Team Leaders do not write project code).
|
||||
- **If an agent receives a task that does not fit its role**, it must either:
|
||||
1. Recommend the optimal agent session to delegate the task to, or
|
||||
2. Perform the task directly if strictly necessary for project continuity.
|
||||
|
||||
---
|
||||
|
||||
## 2. Messaging Backplane & Registry Protocol
|
||||
|
||||
Asynchronous communication and state management between agents are controlled via distributed event channels and file/DB registries.
|
||||
|
||||
### 📡 MQTT Backplane
|
||||
- **Event Lifecycle**:
|
||||
- `started` (Job execution starts) ➡️ `progress`/`permission_required` (Share intermediate progress) ➡️ `completed` (Successful termination) or `error` (Failed termination)
|
||||
- `completed` and `error` are terminal events that are published exactly once.
|
||||
- **Publish/Subscribe Rules**:
|
||||
- Since MQTT does not guarantee persistent queues, the subscriber (`job_subscriber.py`) **must be running in the background before the agent starts** (the Subscribe-before-Publish principle).
|
||||
- When publishing terminal events, publish with `retain=True` on the broker so that subscribers joining late can still read the final state.
|
||||
- Generalize all transmitted data to ensure that sensitive secrets like passwords, private keys, or absolute system paths are not included.
|
||||
|
||||
### 🗃️ 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.
|
||||
|
||||
### 🛡️ 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`).
|
||||
- **Authenticated Production Mode**: In production environments or integrations requiring authentication, a unique cryptographic token (`auth_token`) is issued for each job. The publisher must include an `hmac_sig` signature in the payload keyed by this token, and the receiving end (`verify_hmac`) will immediately drop messages that lack a signature or have mismatching signatures to prevent downgrade attacks.
|
||||
- **Rollout Strategy**: To avoid event drops caused by inconsistencies between publishing and receiving nodes when updating security schemes, hybrid transition formats (which risk leaking plaintext tokens) must not be used. Instead, adopt a **"Simultaneous Rollout"** where all nodes are updated at once.
|
||||
|
||||
---
|
||||
|
||||
## 3. Collaborative Workflow Execution Loop (Workflow Loop)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
actor User as User
|
||||
participant GM as General Manager
|
||||
participant DTL as Developer Team Leader
|
||||
participant RTL as Reviewer Team Leaders
|
||||
participant M as MQTT Backplane
|
||||
|
||||
User->>GM: Hand over requirements
|
||||
GM->>DTL: Delegate task (e.g., create landing page)
|
||||
Note over DTL: Analyze, breakdown & spawn parallel subagents
|
||||
DTL->>M: Publish 'started' event
|
||||
Note over DTL: Modify code & implement
|
||||
DTL->>M: Publish 'completed'
|
||||
DTL->>RTL: Request review (I created landing page. Please review it)
|
||||
Note over RTL: Cross-analysis & verification
|
||||
alt Defect Found (Reviewer feedback)
|
||||
RTL->>DTL: NOT PASS / Feedback (Must include reason & improvement direction)
|
||||
Note over DTL: DTL checks validity of suggestions
|
||||
alt Valid feedback
|
||||
Note over DTL: DTL adopts and modifies code
|
||||
else Invalid feedback
|
||||
DTL->>RTL: Send refutation & reasons (Did not reflect inappropriate parts)
|
||||
end
|
||||
DTL->>RTL: Request review again (Modified review items)
|
||||
else Verification Pass
|
||||
RTL->>DTL: PASS
|
||||
end
|
||||
DTL->>GM: Send completion signal
|
||||
GM->>User: Notify task completion
|
||||
```
|
||||
|
||||
1. **Planning and Allocation**: The General Manager delegates the task to the Developer Team Leader.
|
||||
2. **Analysis and Internal Execution**: The Developer Team Leader analyzes the task, breaks it down, plans execution, and optionally spawns parallel subagents. It publishes `started`, completes the task, and requests review from the Reviewer Team Leader.
|
||||
3. **Objection & Refinement Loop**:
|
||||
- The Reviewer Team Leader must provide clear reasons and improvement directions for any issues.
|
||||
- The Developer Team Leader validates the feedback. Valid suggestions are implemented; invalid ones are refuted with reasons and returned to the reviewer.
|
||||
- This cycle repeats until all reviewers issue a `PASS`.
|
||||
4. **Completion and Report**: The Developer Team Leader sends the final completion signal to the General Manager, who notifies the user.
|
||||
|
||||
---
|
||||
|
||||
## 4. Analysis Infrastructure Patterns & Practical Guide (Infra Patterns)
|
||||
|
||||
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**:
|
||||
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.
|
||||
|
||||
### 📄 Handling Long Briefing Instructions
|
||||
- Sending long instructions or prompts (hundreds of lines) sequentially via TMUX `send-keys` or input buffers can overwhelm the agent's TUI, leading to lost characters or truncated paragraphs.
|
||||
- **Resolution**: If instructions are long, write them separately to a file path (e.g., `/tmp/brief-<job_id>.md`) and send a simplified execution command to the agent: `"Read /tmp/brief-... and execute"`.
|
||||
|
||||
### ⏱️ 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).
|
||||
- **Monitor Idle Waiting (`SUB_IDLE_TIMEOUT`)**: The idle timeout for the monitor script (`reconcile.sh`), `SUB_IDLE_TIMEOUT`, must always be set generously to `3600s` (1 hour) or more to align with the maximum job budget. This prevents the monitor from terminating early due to idle detection, which would lose control over background tasks before they finish.
|
||||
|
||||
---
|
||||
|
||||
## 5. Setup Checklist for New Projects (Setup Checklist)
|
||||
|
||||
Use this checklist when deploying this agent orchestration model to a new project:
|
||||
|
||||
- [ ] **Virtualenv Dependencies**: Are required Python packages like `pyyaml` and `paho-mqtt` included in `.venv` or `requirements.txt`?
|
||||
- [ ] **Configuration File**: Are the MQTT broker address and security credentials safely loaded and shared via the `.env` file?
|
||||
- [ ] **Directory Convention**: Are the registry path (`.mam/jobs/`) and logging path (`.mam/delegate_job_logs/`) added to `.gitignore`?
|
||||
- [ ] **Core Scripts**: Are the core scripts (`mqtt_common.py`, `publish_event.py`, `job_subscriber.py`, and `registry.py`) in place?
|
||||
- [ ] **HMAC Enablement**: When a new registry job is created, is a random `auth_token` correctly injected, and is signature-based mutual authentication active?
|
||||
- [ ] **Charter Placement**: Is this protocol file (`AGENT.md`) placed in the **.agents/ directory** of the new project? (Placing it in `.agents/` is essential to keep the project root clean while allowing onboarding agents to align on the rules.)
|
||||
|
||||
---
|
||||
|
||||
*This guide balances collaboration efficiency with strict code security. Any required changes must be discussed and agreed upon by the General Manager and all Team Leaders before updating this document.*
|
||||
|
||||
---
|
||||
|
||||
## 6. AIoT Multi-Agent Interface Module Design Guidelines (A2A and Agent Card)
|
||||
|
||||
The Unified Interface Module to be implemented in future research and development stages must incorporate the **A2A (Agent-to-Agent)** standard to achieve autonomous collaboration and interoperability among heterogeneous agents, and must comply with the following design guidelines:
|
||||
|
||||
### 📇 1. Standard Support for Agent Cards
|
||||
- **Structured JSON Schema**: The interface module must automatically generate and serve Agent Cards defining the agent's metadata and input/output interfaces in JSON format.
|
||||
- **Mandatory Specifications**:
|
||||
- Agent Name and Persona/Role (Name, Persona, Role)
|
||||
- Callable Endpoint Information (Endpoint URI and Protocol Specifications)
|
||||
- Input Data Schema (Tool Schema and I/O constraints)
|
||||
- Authentication and Authorization Specifications (Authentication & Security Credentials)
|
||||
- **Deployment and Discovery Specification**: An Agent Card serving infrastructure must be built-in to serve the metadata via the standard path `/.well-known/agent-card.json`, allowing other heterogeneous agents in the system to discover it in real-time.
|
||||
|
||||
### 📡 2. A2A and ACP Protocol Connector Design
|
||||
- **Protocol Interoperability**: Implement a protocol connector inside the interface module based on the merged specifications of IBM ACP (Agent Communication Protocol) and Linux Foundation A2A standards to handle task delegation, control transfer, and state synchronization.
|
||||
- **Event-Driven Autonomous Control**: Decouple sensor monitoring to be executed by lightweight physical nodes and monitoring programs, and implement an asynchronous interface that delegates tasks to specific AI agents upon detecting physical events via A2A protocol messages, reclaiming lifecycles and control rights immediately upon task completion.
|
||||
|
||||
### 🛠️ 3. Workflow Coherence and Issue Tracking
|
||||
- **Execution Consistency**: Control workflow execution atomically to guarantee identical outputs when heterogeneous agents re-execute the same tasks.
|
||||
- **Standardized Issue Tracking**: Provide a standardized Issue Tracking Interface to support debugging, tracking, and recovery in case of execution exceptions or network disconnections.
|
||||
|
||||
### 🛡️ 4. Idempotency Assurance Schema
|
||||
- **Idempotency Classification of Physical Control Commands**: The interface module must classify control commands by their idempotency guarantees. It must explicitly distinguish between commands where idempotency is naturally guaranteed (e.g., information queries, state settings) and non-idempotent physical control commands where side effects accumulate and re-execution may lead to critical safety incidents (e.g., pesticide spraying, irrigation, valve actuation).
|
||||
- **Duplicate Dispatch Suppression Mechanism**: To prevent the same control intent from being dispatched under a new job ID during asynchronous retry loops, a deduplication schema based on a Control Intent Key must be embedded in the interface layer. Upon retry, the system must reuse the idempotency key bound to the original control intent rather than issuing a new job ID, thereby systemically controlling the possibility of duplicate execution of the same control.
|
||||
- **Pre-flight Verification at the Interface Layer**: Before a duplicate control reaches the physical device, the interface module must perform a pre-flight check that pre-blocks re-execution requests for already-executed control intents, or approves them only after cross-checking against device-side state, providing a safety net against physical double actuation.
|
||||
@@ -1,829 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# lib.sh — shared library for the multi-agent-mux-* skills.
|
||||
#
|
||||
# Single source of truth for the four things that were inconsistently
|
||||
# re-implemented across create/resume/delete/monitor (REVIEW.md §4.1):
|
||||
# - derive_session_name : the tmux session slug (P0-A)
|
||||
# - atomic_dump_yaml : SQLite db transaction + temp+rename + .bak + validate (P0-B)
|
||||
# - env_python : env-safe Python (no heredoc injection) (P0-B / P1-B)
|
||||
# - find_workspace_uuid : workspace-SCOPED resume id lookup (P0-C)
|
||||
#
|
||||
# Source it from each script with a path computed from the script location:
|
||||
# source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
|
||||
#
|
||||
# HARD RULE: the agent-sessions.yaml file is only ever written through
|
||||
# atomic_dump_yaml. Never `open(yaml_path, 'w')` anywhere else.
|
||||
|
||||
SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
WORKSPACE_ROOT="$(cd "$SKILL_DIR/../.." && pwd)"
|
||||
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
|
||||
|
||||
# Workspace-relative defaults with environment overrides (Phase Z)
|
||||
HOME_DIR="${HOME_DIR:-$HOME}"
|
||||
CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$HOME/.claude/projects}"
|
||||
LOCAL_BIN="${LOCAL_BIN:-$HOME/.local/bin}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tmux Server Isolation support
|
||||
# ---------------------------------------------------------------------------
|
||||
# Paths to exclude when resolving the real tmux binary (shim/wrapper dirs).
|
||||
_TMUX_SHIM_DIR_PATTERN="${_TMUX_SHIM_DIR_PATTERN:-/multi-agent-tmux-shim/}"
|
||||
_TMUX_SKILLS_BIN_PATTERN="${_TMUX_SKILLS_BIN_PATTERN:-/.agents/skills/.bin}"
|
||||
|
||||
TMUX_SERVER_NAME="${TMUX_SERVER_NAME:-default}"
|
||||
|
||||
_resolve_real_tmux_path() {
|
||||
if [ -z "${_REAL_TMUX_PATH:-}" ] || [[ "$_REAL_TMUX_PATH" == *"${_TMUX_SHIM_DIR_PATTERN}"* ]] || [[ "$_REAL_TMUX_PATH" == *"${_TMUX_SKILLS_BIN_PATTERN}"* ]]; then
|
||||
local dir save_ifs="$IFS"
|
||||
_REAL_TMUX_PATH=""
|
||||
IFS=:
|
||||
for dir in $PATH; do
|
||||
if [[ "$dir" != *"${_TMUX_SHIM_DIR_PATTERN}"* ]] && [[ "$dir" != *"${_TMUX_SKILLS_BIN_PATTERN}"* ]] && [ -x "$dir/tmux" ]; then
|
||||
_REAL_TMUX_PATH="$dir/tmux"
|
||||
break
|
||||
fi
|
||||
done
|
||||
IFS="$save_ifs"
|
||||
if [ -z "$_REAL_TMUX_PATH" ]; then
|
||||
_REAL_TMUX_PATH="tmux"
|
||||
fi
|
||||
export _REAL_TMUX_PATH
|
||||
fi
|
||||
}
|
||||
|
||||
_init_tmux_isolation() {
|
||||
_resolve_real_tmux_path
|
||||
if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then
|
||||
local wrapper_dir="${TMPDIR:-/tmp}${_TMUX_SHIM_DIR_PATTERN}${TMUX_SERVER_NAME}"
|
||||
if [[ ":$PATH:" != *":$wrapper_dir:"* ]]; then
|
||||
mkdir -p "$wrapper_dir"
|
||||
cat <<EOF > "$wrapper_dir/tmux"
|
||||
#!/usr/bin/env bash
|
||||
if [ -z "\${TMUX_SERVER_NAME:-}" ] || [ "\$TMUX_SERVER_NAME" = "default" ]; then
|
||||
exec "$_REAL_TMUX_PATH" "\$@"
|
||||
else
|
||||
exec "$_REAL_TMUX_PATH" -L "\$TMUX_SERVER_NAME" "\$@"
|
||||
fi
|
||||
EOF
|
||||
chmod +x "$wrapper_dir/tmux"
|
||||
export PATH="$wrapper_dir:$PATH"
|
||||
fi
|
||||
else
|
||||
# 격리 비활성화 시 shim 자동 cleanup (PATH에서 제거)
|
||||
local new_path="" dir save_ifs="$IFS"
|
||||
IFS=:
|
||||
for dir in $PATH; do
|
||||
if [[ "$dir" != *"${_TMUX_SHIM_DIR_PATTERN}"* ]] && [[ "$dir" != *"${_TMUX_SKILLS_BIN_PATTERN}"* ]]; then
|
||||
if [ -z "$new_path" ]; then
|
||||
new_path="$dir"
|
||||
else
|
||||
new_path="$new_path:$dir"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
IFS="$save_ifs"
|
||||
export PATH="$new_path"
|
||||
fi
|
||||
}
|
||||
|
||||
_tmux() {
|
||||
_init_tmux_isolation
|
||||
if [ -z "${TMUX_SERVER_NAME:-}" ] || [ "$TMUX_SERVER_NAME" = "default" ]; then
|
||||
"$_REAL_TMUX_PATH" "$@"
|
||||
else
|
||||
"$_REAL_TMUX_PATH" -L "$TMUX_SERVER_NAME" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
tmux() {
|
||||
_tmux "$@"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_tmux_server <session_name>
|
||||
#
|
||||
# Query agent-sessions.yaml to find the tmux_server associated with a session.
|
||||
# Fallback to TMUX_SERVER_NAME or 'default' if not registered or field is missing.
|
||||
# Prints the resolved server name on stdout.
|
||||
# ---------------------------------------------------------------------------
|
||||
resolve_tmux_server() {
|
||||
local session_name="$1"
|
||||
SESSION_NAME="$session_name" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
||||
import os, sys, sqlite3, json, yaml
|
||||
name = os.environ['SESSION_NAME']
|
||||
yaml_path = os.environ['YAML_PATH']
|
||||
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
||||
try:
|
||||
if os.path.exists(db_path):
|
||||
conn = sqlite3.connect(db_path, timeout=10.0)
|
||||
try:
|
||||
row = conn.execute('SELECT data FROM sessions WHERE name=?', (name,)).fetchone()
|
||||
if row:
|
||||
s = json.loads(row[0])
|
||||
server = s.get('tmux_server')
|
||||
if server:
|
||||
print(server)
|
||||
sys.exit(0)
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
||||
if row:
|
||||
d = json.loads(row[0])
|
||||
for s in d.get('tmux_sessions', []):
|
||||
if s.get('name') == name:
|
||||
server = s.get('tmux_server')
|
||||
if server:
|
||||
print(server)
|
||||
sys.exit(0)
|
||||
conn.close()
|
||||
elif os.path.exists(yaml_path):
|
||||
with open(yaml_path) as f:
|
||||
d = yaml.safe_load(f) or {}
|
||||
for s in d.get('tmux_sessions', []):
|
||||
if s.get('name') == name:
|
||||
server = s.get('tmux_server')
|
||||
if server:
|
||||
print(server)
|
||||
sys.exit(0)
|
||||
except Exception:
|
||||
pass
|
||||
# Fallback
|
||||
print(os.environ.get('TMUX_SERVER_NAME', 'default'))
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# derive_session_name <workspace> <agent>
|
||||
#
|
||||
# THE single source of truth for the tmux session name. Rule:
|
||||
# slug = the two trailing path components of the absolute workspace,
|
||||
# '_' -> '-', lowercased, joined with '-'
|
||||
# name = "<slug>-creator-<agent>"
|
||||
#
|
||||
# Workspace root 기준 상대 해석. 예:
|
||||
# $WORKSPACE_ROOT/landing_page/refer_landing_page + claude
|
||||
# -> landing-page-refer-landing-page-creator-claude
|
||||
#
|
||||
# Decision (REVIEW P0-A): the actual workspace basename (refer_landing_page)
|
||||
# IS included. The hand-written historical entry that dropped it
|
||||
# (lab-landing-page-creator-claude) was the bug, not the convention.
|
||||
# Every script and SKILL.md must use exactly this rule.
|
||||
# ---------------------------------------------------------------------------
|
||||
derive_session_name() {
|
||||
local workspace="$1" agent="$2"
|
||||
local abs parent work slug
|
||||
abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace"
|
||||
parent="$(basename "$(dirname "$abs")" 2>/dev/null || echo "")"
|
||||
work="$(basename "$abs" 2>/dev/null || echo "root")"
|
||||
if [ -z "$parent" ] || [ "$parent" = "/" ] || [ "$parent" = "." ]; then
|
||||
parent="workspace"
|
||||
fi
|
||||
if [ -z "$work" ] || [ "$work" = "/" ] || [ "$work" = "." ]; then
|
||||
work="root"
|
||||
fi
|
||||
slug="$(printf '%s-%s' "$parent" "$work" | tr '[:upper:]' '[:lower:]' | tr '_' '-')"
|
||||
slug="$(printf '%s' "$slug" | tr -cd 'a-zA-Z0-9-')"
|
||||
printf '%s-creator-%s' "$slug" "$agent"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# env_python <yaml_path> [KEY=VALUE ...] (Python source read from stdin)
|
||||
#
|
||||
# Run python3 with the source supplied on stdin via a *quoted* heredoc, so the
|
||||
# shell never interpolates the source. All values are passed through the
|
||||
# environment (YAML_PATH plus any KEY=VALUE pairs). Untrusted data (workspace
|
||||
# paths, capture-pane text) must travel as env vars and be read via os.environ
|
||||
# inside the script — never spliced into the source. Read-only by convention;
|
||||
# use atomic_dump_yaml when you need to write the YAML.
|
||||
# ---------------------------------------------------------------------------
|
||||
_validate_env_key() {
|
||||
local key="$1"
|
||||
if [[ ! "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
|
||||
echo "ERROR: Invalid environment variable name: $key" >&2
|
||||
return 1
|
||||
fi
|
||||
case "$key" in
|
||||
LD_PRELOAD|LD_LIBRARY_PATH|PYTHONPATH|PYTHONHOME|PYTHONINSPECT|PYTHONSTARTUP)
|
||||
echo "ERROR: Blocked environment variable: $key" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
env_python() {
|
||||
local yaml_path="$1"; shift
|
||||
local -a envs=("YAML_PATH=$yaml_path" "HOME_DIR=$HOME_DIR" "CLAUDE_PROJECT_DIR=$CLAUDE_PROJECT_DIR" "LOCAL_BIN=$LOCAL_BIN")
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
*=*)
|
||||
local key="${1%%=*}"
|
||||
_validate_env_key "$key" || return 1
|
||||
envs+=("$1")
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
env "${envs[@]}" python3 - "$@"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# atomic_dump_yaml <yaml_path> [KEY=VALUE ...] (mutation source from stdin)
|
||||
#
|
||||
# The ONLY sanctioned way to write agent-sessions.yaml. It:
|
||||
# 1. takes an exclusive SQLite BEGIN IMMEDIATE transaction lock on
|
||||
# agent-sessions.db (serialises all writers)
|
||||
# 2. loads the current state into `d` (seeds from YAML if DB is empty)
|
||||
# 3. exec()s the caller's mutation source (sees d, yaml, os, datetime,
|
||||
# timezone, glob, subprocess; reads values via os.environ). The mutation
|
||||
# may print and may `raise SystemExit(n)` to abort *without* writing.
|
||||
# 4. validates the resulting schema
|
||||
# 5. backs up to <yaml_path>.bak, then writes YAML atomically (temp + os.replace)
|
||||
# when a session transitions to a finished state.
|
||||
#
|
||||
# The mutation source is passed via env and exec()'d — it is never string
|
||||
# spliced and untrusted data never lands in Python source (P0-B / P1-B).
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check if the workspace is on NFS — locking behaves differently on NFS
|
||||
_check_is_nfs() {
|
||||
local f="$1"
|
||||
local mountpoint
|
||||
mountpoint="$(df --output=target "$f" 2>/dev/null | tail -1)" || return 1
|
||||
if mount | grep -q "$mountpoint.*nfs\|$mountpoint.*cifs\|$mountpoint.*fuse.sshfs"; then
|
||||
return 0 # is NFS
|
||||
fi
|
||||
return 1 # not NFS
|
||||
}
|
||||
|
||||
atomic_dump_yaml() {
|
||||
local yaml_path="$1"; shift
|
||||
if [ -z "${MAM_IS_NFS:-}" ]; then
|
||||
if _check_is_nfs "$(dirname "$yaml_path")"; then
|
||||
export MAM_IS_NFS="true"
|
||||
echo "WARNING: $(dirname "$yaml_path") appears to be a network filesystem (NFS/CIFS/SSHFS)." >&2
|
||||
echo "WARNING: SQLite journal_mode automatically falls back to DELETE." >&2
|
||||
else
|
||||
export MAM_IS_NFS="false"
|
||||
fi
|
||||
fi
|
||||
|
||||
local -a envs=("YAML_PATH=$yaml_path" "HOME_DIR=$HOME_DIR" "CLAUDE_PROJECT_DIR=$CLAUDE_PROJECT_DIR" "LOCAL_BIN=$LOCAL_BIN" "MAM_IS_NFS=$MAM_IS_NFS")
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
*=*)
|
||||
local key="${1%%=*}"
|
||||
_validate_env_key "$key" || return 1
|
||||
envs+=("$1")
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
break
|
||||
;;
|
||||
esac
|
||||
done
|
||||
local mutation; mutation="$(cat)"
|
||||
env "${envs[@]}" AGENT_SESSIONS_MUTATION="$mutation" python3 - <<'PYEOF'
|
||||
import os, sys, tempfile, shutil, glob, subprocess, json, sqlite3
|
||||
from datetime import datetime, timezone
|
||||
import yaml
|
||||
|
||||
yaml_path = os.environ['YAML_PATH']
|
||||
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
||||
|
||||
def _validate(d):
|
||||
if not isinstance(d, dict):
|
||||
raise SystemExit("VALIDATE: top-level is not a mapping")
|
||||
sessions = d.get('tmux_sessions', [])
|
||||
if not isinstance(sessions, list):
|
||||
raise SystemExit("VALIDATE: tmux_sessions is not a list")
|
||||
valid = {'running', 'terminated', 'archived', 'stopped'}
|
||||
for i, s in enumerate(sessions):
|
||||
if not isinstance(s, dict):
|
||||
raise SystemExit(f"VALIDATE: tmux_sessions[{i}] not a mapping")
|
||||
if not s.get('name') or not s.get('status'):
|
||||
raise SystemExit(f"VALIDATE: tmux_sessions[{i}] missing name/status")
|
||||
if s.get('role') is not None and (not isinstance(s['role'], str) or not s['role'].strip()):
|
||||
raise SystemExit(f"VALIDATE: tmux_sessions[{i}] {s.get('name')!r} role must be a non-empty string")
|
||||
if s['status'] not in valid:
|
||||
raise SystemExit(f"VALIDATE: tmux_sessions[{i}] {s.get('name')!r} bad status {s['status']!r}")
|
||||
if not isinstance(s.get('pane'), dict):
|
||||
raise SystemExit(f"VALIDATE: tmux_sessions[{i}] {s.get('name')!r} missing pane")
|
||||
|
||||
def get_terminal_set(d):
|
||||
return {s.get('name'): s.get('status') for s in d.get('tmux_sessions', []) if s.get('status') in ('stopped', 'terminated', 'archived')}
|
||||
|
||||
os.makedirs(os.path.dirname(db_path) or '.', exist_ok=True)
|
||||
conn = sqlite3.connect(db_path, timeout=60.0)
|
||||
|
||||
for f in [db_path, db_path + '-wal', db_path + '-shm']:
|
||||
if os.path.exists(f):
|
||||
try:
|
||||
os.chmod(f, 0o600)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
is_nfs = os.environ.get('MAM_IS_NFS') == 'true'
|
||||
if is_nfs:
|
||||
conn.execute('PRAGMA journal_mode=DELETE')
|
||||
else:
|
||||
conn.execute('PRAGMA journal_mode=WAL')
|
||||
|
||||
try:
|
||||
# Disable auto-commit by explicitly starting a transaction with BEGIN IMMEDIATE
|
||||
# This prevents the read-modify-write lost update race condition.
|
||||
conn.execute('BEGIN IMMEDIATE')
|
||||
conn.execute('CREATE TABLE IF NOT EXISTS state (id INTEGER PRIMARY KEY, data TEXT)')
|
||||
conn.execute('CREATE TABLE IF NOT EXISTS sessions (name TEXT PRIMARY KEY, status TEXT, pane_cwd TEXT, data JSON)')
|
||||
conn.execute('CREATE INDEX IF NOT EXISTS idx_sessions_pane_cwd ON sessions(pane_cwd)')
|
||||
|
||||
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
||||
if row:
|
||||
d = json.loads(row[0])
|
||||
else:
|
||||
# Seed from YAML
|
||||
if os.path.exists(yaml_path):
|
||||
with open(yaml_path) as f:
|
||||
d = yaml.safe_load(f) or {}
|
||||
else:
|
||||
d = {}
|
||||
|
||||
# Assemble d['tmux_sessions'] from sessions table if table contains data
|
||||
db_sessions = []
|
||||
cursor = conn.execute('SELECT name, status, pane_cwd, data FROM sessions')
|
||||
for s_row in cursor.fetchall():
|
||||
s_data = json.loads(s_row[3])
|
||||
s_data['name'] = s_row[0]
|
||||
s_data['status'] = s_row[1]
|
||||
if 'pane' not in s_data:
|
||||
s_data['pane'] = {}
|
||||
s_data['pane']['cwd'] = s_row[2]
|
||||
db_sessions.append(s_data)
|
||||
|
||||
if db_sessions:
|
||||
d['tmux_sessions'] = db_sessions
|
||||
elif 'tmux_sessions' not in d:
|
||||
d['tmux_sessions'] = []
|
||||
|
||||
old_terminals = get_terminal_set(d)
|
||||
old_roles = {s.get('name'): s.get('role') for s in db_sessions if s.get('role')}
|
||||
|
||||
# --- caller mutation (module scope: sees d, yaml, os, glob, subprocess) ---
|
||||
exec(compile(os.environ['AGENT_SESSIONS_MUTATION'], '<mutation>', 'exec'), globals())
|
||||
|
||||
# Role immutability check
|
||||
for s in d.get('tmux_sessions', []):
|
||||
name = s.get('name')
|
||||
if name in old_roles and s.get('role') != old_roles[name]:
|
||||
raise SystemExit(f"VALIDATE: role of session {name!r} cannot be modified from {old_roles[name]!r} to {s.get('role')!r}")
|
||||
|
||||
_validate(d)
|
||||
|
||||
# Separate globals and sessions for normalization
|
||||
d_state = {k: v for k, v in d.items() if k != 'tmux_sessions'}
|
||||
conn.execute('REPLACE INTO state (id, data) VALUES (1, ?)', (json.dumps(d_state),))
|
||||
|
||||
current_names = []
|
||||
for s in d.get('tmux_sessions', []):
|
||||
name = s.get('name')
|
||||
status = s.get('status')
|
||||
pane_cwd = (s.get('pane') or {}).get('cwd', '')
|
||||
conn.execute('REPLACE INTO sessions (name, status, pane_cwd, data) VALUES (?, ?, ?, ?)',
|
||||
(name, status, pane_cwd, json.dumps(s)))
|
||||
current_names.append(name)
|
||||
|
||||
if current_names:
|
||||
placeholders = ','.join('?' for _ in current_names)
|
||||
conn.execute(f'DELETE FROM sessions WHERE name NOT IN ({placeholders})', current_names)
|
||||
else:
|
||||
conn.execute('DELETE FROM sessions')
|
||||
|
||||
new_terminals = get_terminal_set(d)
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Write to YAML ONLY when a session transitions to a finished state
|
||||
# (Moved after conn.commit() per Claude's feedback)
|
||||
if new_terminals != old_terminals:
|
||||
if os.path.exists(yaml_path):
|
||||
try:
|
||||
shutil.copy2(yaml_path, yaml_path + '.bak')
|
||||
except Exception:
|
||||
pass
|
||||
dir_ = os.path.dirname(yaml_path) or '.'
|
||||
fd, tmp = tempfile.mkstemp(dir=dir_, prefix='.agent-sessions.', suffix='.tmp')
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as f:
|
||||
yaml.safe_dump(d, f, default_flow_style=False, sort_keys=False,
|
||||
allow_unicode=True, width=4096)
|
||||
os.replace(tmp, yaml_path)
|
||||
except Exception:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
raise
|
||||
|
||||
try:
|
||||
conn.execute('PRAGMA wal_checkpoint(TRUNCATE)')
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# H3: Re-apply chmod 0600 after close to cover newly created -wal / -shm files
|
||||
try:
|
||||
os.chmod(db_path, 0o600)
|
||||
wal = db_path + '-wal'
|
||||
if os.path.exists(wal): os.chmod(wal, 0o600)
|
||||
shm = db_path + '-shm'
|
||||
if os.path.exists(shm): os.chmod(shm, 0o600)
|
||||
except Exception:
|
||||
pass
|
||||
PYEOF
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# find_workspace_uuid <workspace> <agent>
|
||||
#
|
||||
# Workspace-SCOPED resolution of the resume UUID (P0-C). It NEVER returns a
|
||||
# global agent_identities id unless that id's project_cwd matches THIS
|
||||
# workspace. Resolution order:
|
||||
# 1) tmux_sessions[] row whose pane.cwd == this workspace -> per-row own id
|
||||
# (claude_session_id_own / agy_conversation_id_own)
|
||||
# 2) on-disk scan scoped to this workspace
|
||||
# (claude: ~/.claude/projects/<key>/*.jsonl ; agy: last_conversations.json[cwd])
|
||||
# 3) agent_identities cache, ONLY when its project_cwd == this workspace
|
||||
# Prints the UUID on stdout (empty line if none). Always exits 0.
|
||||
# ---------------------------------------------------------------------------
|
||||
find_workspace_uuid() {
|
||||
local workspace="$1" agent="$2"
|
||||
local abs; abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace"
|
||||
WS_ABS="$abs" AGENT="$agent" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
||||
import os, json, glob, sqlite3
|
||||
import yaml
|
||||
|
||||
ws = os.environ['WS_ABS']
|
||||
agent = os.environ['AGENT']
|
||||
home = os.environ['HOME_DIR']
|
||||
yaml_path = os.environ['YAML_PATH']
|
||||
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
||||
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
|
||||
|
||||
def jsonl_exists(uuid):
|
||||
key = ws.replace('/', '-').replace('_', '-')
|
||||
return os.path.exists(f"{claude_project_dir}/{key}/{uuid}.jsonl")
|
||||
|
||||
|
||||
def db_exists(uuid):
|
||||
return os.path.exists(f"{home}/.gemini/antigravity-cli/conversations/{uuid}.db")
|
||||
|
||||
|
||||
def hermes_exists(uuid):
|
||||
hdb = f"{home}/.hermes/state.db"
|
||||
if not os.path.exists(hdb):
|
||||
return False
|
||||
try:
|
||||
conn = sqlite3.connect(hdb)
|
||||
r = conn.execute("SELECT 1 FROM sessions WHERE id=?", (uuid,)).fetchone()
|
||||
conn.close()
|
||||
return r is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def cline_exists(uuid):
|
||||
return os.path.exists(f"{home}/.cline/data/sessions/{uuid}/{uuid}.json")
|
||||
|
||||
|
||||
def emit(u):
|
||||
print(u)
|
||||
raise SystemExit(0)
|
||||
|
||||
|
||||
# 1) per-row own id for THIS workspace (optimized with direct sqlite query if db exists)
|
||||
sessions = []
|
||||
try:
|
||||
if os.path.exists(db_path):
|
||||
conn = sqlite3.connect(db_path, timeout=10.0)
|
||||
has_sessions_table = False
|
||||
try:
|
||||
cursor = conn.execute('SELECT data FROM sessions WHERE pane_cwd=?', (ws,))
|
||||
for row in cursor.fetchall():
|
||||
sessions.append(json.loads(row[0]))
|
||||
has_sessions_table = True
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
if not has_sessions_table or not sessions:
|
||||
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
||||
if row:
|
||||
d = json.loads(row[0])
|
||||
for s in d.get('tmux_sessions', []):
|
||||
if isinstance(s, dict) and (s.get('pane') or {}).get('cwd') == ws:
|
||||
sessions.append(s)
|
||||
conn.close()
|
||||
elif os.path.exists(yaml_path):
|
||||
with open(yaml_path) as f:
|
||||
d = yaml.safe_load(f) or {}
|
||||
for s in d.get('tmux_sessions', []):
|
||||
if isinstance(s, dict) and (s.get('pane') or {}).get('cwd') == ws:
|
||||
sessions.append(s)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for s in sessions:
|
||||
name = s.get('name', '')
|
||||
if agent == 'claude' and name.endswith('-creator-claude'):
|
||||
cand = s.get('claude_session_id_own')
|
||||
if cand and jsonl_exists(cand):
|
||||
emit(cand)
|
||||
if agent == 'agy' and name.endswith('-creator-agy'):
|
||||
cand = s.get('agy_conversation_id_own')
|
||||
if cand and db_exists(cand):
|
||||
emit(cand)
|
||||
if agent == 'hermes' and name.endswith('-creator-hermes'):
|
||||
cand = s.get('hermes_conversation_id_own')
|
||||
if cand and hermes_exists(cand):
|
||||
emit(cand)
|
||||
if agent == 'cline' and name.endswith('-creator-cline'):
|
||||
cand = s.get('cline_conversation_id_own')
|
||||
if cand and cline_exists(cand):
|
||||
emit(cand)
|
||||
|
||||
# 2) disk scan scoped to THIS workspace
|
||||
if agent == 'claude':
|
||||
key = ws.replace('/', '-').replace('_', '-')
|
||||
proj = f"{claude_project_dir}/{key}"
|
||||
if os.path.isdir(proj):
|
||||
for j in sorted(glob.glob(f"{proj}/*.jsonl"), key=os.path.getmtime, reverse=True):
|
||||
sid = None
|
||||
try:
|
||||
with open(j) as f:
|
||||
first = f.readline().strip()
|
||||
if first:
|
||||
sid = json.loads(first).get('sessionId')
|
||||
except Exception:
|
||||
sid = None
|
||||
cand = sid or os.path.basename(j)[:-6]
|
||||
if cand and jsonl_exists(cand):
|
||||
emit(cand)
|
||||
elif agent == 'agy':
|
||||
lc = f"{home}/.gemini/antigravity-cli/cache/last_conversations.json"
|
||||
if os.path.exists(lc):
|
||||
cand = None
|
||||
try:
|
||||
cand = json.load(open(lc)).get(ws)
|
||||
except Exception:
|
||||
cand = None
|
||||
if cand and db_exists(cand):
|
||||
emit(cand)
|
||||
elif agent == 'hermes':
|
||||
hdb = f"{home}/.hermes/state.db"
|
||||
if os.path.exists(hdb):
|
||||
cand = None
|
||||
try:
|
||||
conn = sqlite3.connect(hdb)
|
||||
r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (ws,)).fetchone()
|
||||
conn.close()
|
||||
if r:
|
||||
cand = r[0]
|
||||
except Exception:
|
||||
cand = None
|
||||
if cand:
|
||||
emit(cand)
|
||||
elif agent == 'cline':
|
||||
sessions_dir = f"{home}/.cline/data/sessions"
|
||||
if os.path.isdir(sessions_dir):
|
||||
candidates = []
|
||||
for session_folder in glob.glob(f"{sessions_dir}/*"):
|
||||
if os.path.isdir(session_folder):
|
||||
folder_name = os.path.basename(session_folder)
|
||||
json_file = f"{session_folder}/{folder_name}.json"
|
||||
if os.path.exists(json_file):
|
||||
candidates.append(json_file)
|
||||
candidates.sort(key=os.path.getmtime, reverse=True)
|
||||
for j in candidates:
|
||||
try:
|
||||
with open(j) as f:
|
||||
sdata = json.load(f)
|
||||
if sdata.get('cwd') == ws or sdata.get('workspace_root') == ws:
|
||||
sid = sdata.get('session_id')
|
||||
if sid:
|
||||
emit(sid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3) agent_identities cache, ONLY when its project_cwd == this workspace
|
||||
ai = {}
|
||||
try:
|
||||
if os.path.exists(db_path):
|
||||
conn = sqlite3.connect(db_path, timeout=10.0)
|
||||
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
||||
if row:
|
||||
ai = json.loads(row[0]).get('agent_identities', {})
|
||||
conn.close()
|
||||
elif os.path.exists(yaml_path):
|
||||
with open(yaml_path) as f:
|
||||
d = yaml.safe_load(f) or {}
|
||||
ai = d.get('agent_identities', {})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
ai_agent = ai.get(agent) or {}
|
||||
if ai_agent.get('project_cwd') == ws:
|
||||
if agent == 'claude':
|
||||
cand = ai_agent.get('session_id')
|
||||
if cand and jsonl_exists(cand):
|
||||
emit(cand)
|
||||
elif agent == 'agy':
|
||||
cand = ai.get('conversation_id')
|
||||
if cand and db_exists(cand):
|
||||
emit(cand)
|
||||
elif agent == 'hermes':
|
||||
cand = ai_agent.get('session_id') or ai.get('conversation_id')
|
||||
if cand and hermes_exists(cand):
|
||||
emit(cand)
|
||||
elif agent == 'cline':
|
||||
cand = ai_agent.get('session_id') or ai.get('conversation_id')
|
||||
if cand and cline_exists(cand):
|
||||
emit(cand)
|
||||
|
||||
print('')
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# capture_conversation_id <agent> <workdir>
|
||||
#
|
||||
# Thin wrapper over find_workspace_uuid: resolves THIS workspace's conversation
|
||||
# id (claude jsonl sessionId / agy db uuid) and prints it on stdout (empty line
|
||||
# if none). find_workspace_uuid is already a workspace-scoped, 3-tier, race-free
|
||||
# resolver (per-row own id -> workspace-scoped disk scan -> cwd-matched cache),
|
||||
# so recording its result into the row before kill guarantees tier-1 on the next
|
||||
# resume. Always exits 0.
|
||||
# ---------------------------------------------------------------------------
|
||||
capture_conversation_id() {
|
||||
local agent="$1" workdir="$2"
|
||||
find_workspace_uuid "$workdir" "$agent"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_already_stopped <session_name>
|
||||
#
|
||||
# Exits 0 if the row's status is 'stopped' (printing "stopped_at=<ts>" on
|
||||
# stdout), 1 otherwise (including not-found). Used for idempotency: a second
|
||||
# stop on an already-stopped session is a no-op.
|
||||
# ---------------------------------------------------------------------------
|
||||
is_already_stopped() {
|
||||
local session_name="$1"
|
||||
SESSION_NAME="$session_name" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF'
|
||||
import os, yaml, sqlite3, json
|
||||
name = os.environ['SESSION_NAME']
|
||||
yaml_path = os.environ['YAML_PATH']
|
||||
db_path = os.path.splitext(yaml_path)[0] + '.db'
|
||||
try:
|
||||
if os.path.exists(db_path):
|
||||
conn = sqlite3.connect(db_path, timeout=10.0)
|
||||
has_sessions_table = False
|
||||
try:
|
||||
row = conn.execute('SELECT status, data FROM sessions WHERE name=?', (name,)).fetchone()
|
||||
if row:
|
||||
status, s_data_str = row[0], row[1]
|
||||
if status == 'stopped':
|
||||
s = json.loads(s_data_str)
|
||||
print(f"stopped_at={s.get('stopped_at', '?')}")
|
||||
raise SystemExit(0)
|
||||
has_sessions_table = True
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
if not has_sessions_table:
|
||||
row = conn.execute('SELECT data FROM state WHERE id=1').fetchone()
|
||||
if row:
|
||||
d = json.loads(row[0])
|
||||
for s in d.get('tmux_sessions', []):
|
||||
if s.get('name') == name and s.get('status') == 'stopped':
|
||||
print(f"stopped_at={s.get('stopped_at', '?')}")
|
||||
raise SystemExit(0)
|
||||
conn.close()
|
||||
raise SystemExit(1)
|
||||
elif os.path.exists(yaml_path):
|
||||
with open(yaml_path) as f:
|
||||
d = yaml.safe_load(f) or {}
|
||||
for s in d.get('tmux_sessions', []):
|
||||
if s.get('name') == name and s.get('status') == 'stopped':
|
||||
print(f"stopped_at={s.get('stopped_at', '?')}")
|
||||
raise SystemExit(0)
|
||||
except Exception:
|
||||
pass
|
||||
raise SystemExit(1)
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# multi-agent-mux-delegate-job integration helpers
|
||||
#
|
||||
# All paths are resolved relative to lib.sh's own location (BASH_SOURCE), so the
|
||||
# skill tree is relocatable — no hardcoded absolute paths (review item 6).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# _delegate_py_bin — echo the virtualenv python (walk up from .agents/skills/), else python3.
|
||||
_delegate_py_bin() {
|
||||
# Return cached result if available (shell variable, not exported — avoids cross-workspace pollution)
|
||||
if [ -n "${AGENT_PYTHON_BIN:-}" ] && [ -x "$AGENT_PYTHON_BIN" ]; then
|
||||
printf '%s\n' "$AGENT_PYTHON_BIN"; return 0
|
||||
fi
|
||||
local d
|
||||
d="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
while [ "$d" != "/" ] && [ -n "$d" ]; do
|
||||
if [ -x "$d/.venv/bin/python" ]; then
|
||||
AGENT_PYTHON_BIN="$d/.venv/bin/python"
|
||||
printf '%s\n' "$AGENT_PYTHON_BIN"; return 0
|
||||
fi
|
||||
d="$(dirname "$d")"
|
||||
done
|
||||
AGENT_PYTHON_BIN="$(command -v python3 || echo python3)"
|
||||
printf '%s\n' "$AGENT_PYTHON_BIN"
|
||||
}
|
||||
|
||||
# _delegate_script <name> — echo the path to a multi-agent-mux-delegate-job script, resolved
|
||||
# relative to .agents/skills/ (lib.sh dir). Empty if not found.
|
||||
_delegate_script() {
|
||||
local name="$1" skill_dir cand
|
||||
skill_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cand="$skill_dir/multi-agent-mux-delegate-job/scripts/$name"
|
||||
if [ -f "$cand" ]; then printf '%s\n' "$cand"; return 0; fi
|
||||
printf '%s\n' "$(find "$skill_dir" -name "$name" 2>/dev/null | head -n 1 || true)"
|
||||
}
|
||||
|
||||
# delegate_submit_job <prompt> <agent> <agent_session>
|
||||
#
|
||||
# Register a job in the multi-agent-mux-delegate-job registry. Prints the new JID on stdout.
|
||||
delegate_submit_job() {
|
||||
local prompt="$1" agent="$2" session="$3"
|
||||
local py_bin registry_py
|
||||
py_bin="$(_delegate_py_bin)"
|
||||
registry_py="$(_delegate_script registry.py)"
|
||||
if [ -z "$registry_py" ] || [ ! -f "$registry_py" ]; then
|
||||
echo "ERROR: multi-agent-mux-delegate-job registry.py not found under .agents/skills/" >&2
|
||||
return 1
|
||||
fi
|
||||
"$py_bin" "$registry_py" register \
|
||||
--prompt "$prompt" \
|
||||
--agent "$agent" \
|
||||
--agent-session "$session"
|
||||
}
|
||||
|
||||
# delegate_publish_event <job_id> <event> [detail]
|
||||
#
|
||||
# Publish a lifecycle event to the multi-agent-mux-delegate-job registry. Consolidates the
|
||||
# inline .venv-walk + publish_event.py blocks that were duplicated across
|
||||
# create/delete/resume (review item 7). Non-fatal by contract: an empty job id,
|
||||
# a missing script, or a broker failure never aborts the caller.
|
||||
delegate_publish_event() {
|
||||
local job_id="$1" event="$2" detail="${3:-}"
|
||||
[ -n "$job_id" ] || return 0
|
||||
local py_bin pub
|
||||
py_bin="$(_delegate_py_bin)"
|
||||
pub="$(_delegate_script publish_event.py)"
|
||||
[ -n "$pub" ] && [ -f "$pub" ] || return 0
|
||||
"$py_bin" "$pub" --job "$job_id" --event "$event" --detail "$detail" || true
|
||||
}
|
||||
|
||||
# start_watchdog <job_id> [workdir]
|
||||
# Spawns a watchdog process to monitor a delegate-job JOB in the background.
|
||||
# The watchdog re-spawns the subscriber every 2 minutes (or whatever hard
|
||||
# limit we set) and exits automatically when the JOB reaches terminal state.
|
||||
# Returns the watchdog PID via stdout.
|
||||
start_watchdog() {
|
||||
local job_id="$1"
|
||||
local workdir="${2:-$PWD}"
|
||||
local monitor_script="$workdir/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh"
|
||||
local log_file="$workdir/.mam/multi-agent-mux-monitor.log"
|
||||
|
||||
if [ ! -f "$monitor_script" ]; then
|
||||
echo "ERROR: monitor script not found: $monitor_script" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Check if reconcile.sh --subscribe is already running on this workspace
|
||||
local pid
|
||||
pid=$(pgrep -f "bash $monitor_script --subscribe" || true)
|
||||
|
||||
if [ -z "$pid" ]; then
|
||||
# Start the wildcard monitor subscriber daemon with --idle-timeout 0 (never idle out)
|
||||
# and ensure it runs with $workdir as cwd to anchor relative log paths.
|
||||
local orig_pwd="$PWD"
|
||||
cd "$workdir"
|
||||
nohup bash "$monitor_script" --subscribe --idle-timeout 0 >> "$log_file" 2>&1 &
|
||||
pid=$!
|
||||
cd "$orig_pwd"
|
||||
fi
|
||||
|
||||
echo "$pid"
|
||||
}
|
||||
|
||||
|
||||
@@ -1,220 +0,0 @@
|
||||
---
|
||||
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."
|
||||
version: 1.0.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
environments: [terminal, tmux]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [agent, tmux, 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
|
||||
|
||||
> **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).
|
||||
|
||||
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):
|
||||
|
||||
> slug = the **two trailing path components** of the absolute workspace, `_`→`-`, lowercased, joined with `-`; name = `<slug>-creator-<agent>`.
|
||||
|
||||
So `$WORKSPACE_ROOT/landing_page/refer_landing_page` + `claude` → `landing-page-refer-landing-page-creator-claude`. The workspace basename (`refer_landing_page`) **is** included; the hand-written historical entry that dropped it (`lab-landing-page-creator-claude`) was the bug, not the convention.
|
||||
|
||||
## Pre-flight checks
|
||||
|
||||
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}"
|
||||
|
||||
# 2) claude / agy available
|
||||
command -v claude # required for --agent claude
|
||||
command -v agy # required for --agent agy
|
||||
|
||||
# 3) claude auth (if --agent claude)
|
||||
claude auth status 2>&1 | python3 -c "import json,sys; d=json.load(sys.stdin); assert d.get('loggedIn'), 'claude not logged in'"
|
||||
|
||||
# 4) target workspace exists
|
||||
test -d "$WORKSPACE" || { echo "ERROR: workspace $WORKSPACE not a directory"; exit 1; }
|
||||
```
|
||||
|
||||
If any check fails → `kanban_block(reason="...")` (worker path) or report to user (interactive path). Do not proceed with a half-broken setup.
|
||||
|
||||
## Standard names
|
||||
|
||||
- **tmux 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
|
||||
- see `<workdir>/agent_sessions.md` for the canonical wrapper template
|
||||
|
||||
## Tmux 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.
|
||||
|
||||
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).
|
||||
|
||||
### 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.
|
||||
```
|
||||
2. **Via Option Flag**:
|
||||
```bash
|
||||
bash scripts/create_session.sh --workspace /path/to/project --agent claude --role developer --tmux-server multi-agent-canary
|
||||
```
|
||||
3. **Submit Job Integration**:
|
||||
You can automatically register a delegated job with a prompt when creating a session:
|
||||
```bash
|
||||
bash scripts/create_session.sh --workspace /path/to/project --agent claude --role developer --submit-job "Task prompt here"
|
||||
```
|
||||
|
||||
### 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'
|
||||
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.
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
WORKSPACE=/path/to/project
|
||||
AGENT=claude # or agy
|
||||
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."
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 2. Spawn the tmux 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 (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"
|
||||
fi
|
||||
;;
|
||||
agy)
|
||||
tmux 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
|
||||
|
||||
# 3. Wait for agent TUI to be ready (varies: claude ~5s, agy ~3s)
|
||||
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)
|
||||
```
|
||||
|
||||
## Registering the session in agent-sessions.yaml
|
||||
|
||||
After spawn, append a new `tmux_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')
|
||||
pane:
|
||||
index: 0
|
||||
pid: <PANE_PID>
|
||||
cmd: <AGENT> # 'claude' or 'agy'
|
||||
cmd_full: <full command line, see table below>
|
||||
cwd: <PANE_CWD>
|
||||
tui: # only for claude
|
||||
model: <from TUI status>
|
||||
provider: <from TUI status>
|
||||
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>"
|
||||
```
|
||||
|
||||
`cmd_full` per agent (this is the actual command line in the pane, not the resume command):
|
||||
|
||||
| agent | cmd_full |
|
||||
|---|---|
|
||||
| claude (interactive) | `claude` |
|
||||
| agy (interactive) | `agy --dangerously-skip-permissions` |
|
||||
|
||||
Use the `agent-sessions-yaml-edit` script in `scripts/` to safely append (preserves comments + format):
|
||||
|
||||
```bash
|
||||
bash .agents/skills/multi-agent-mux-create/scripts/create_session.sh \
|
||||
--workspace "$WORKSPACE" --agent "$AGENT" --role "$ROLE" --session "$SESSION_NAME"
|
||||
```
|
||||
|
||||
The script handles the YAML append, pane capture, and the `last_visible_status` placeholder.
|
||||
|
||||
## 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 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.
|
||||
- **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
|
||||
|
||||
After spawn + YAML append:
|
||||
|
||||
```bash
|
||||
# 1. tmux session is alive
|
||||
tmux has-session -t "$SESSION_NAME" && 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}'
|
||||
|
||||
# 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']]
|
||||
assert '$SESSION_NAME' in names, 'session not registered'
|
||||
print('OK:', names)
|
||||
"
|
||||
|
||||
# 4. Optional: send a probe via tmux send-keys and capture-pane
|
||||
tmux send-keys -t "$SESSION_NAME" "" Enter
|
||||
sleep 2
|
||||
tmux capture-pane -t "$SESSION_NAME" -p -S -20
|
||||
```
|
||||
|
||||
## 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
|
||||
@@ -1,316 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# create_session.sh — multi-agent-mux-create 의 부속 스크립트
|
||||
# Usage:
|
||||
# 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 는 인라인)
|
||||
# 4) pane 메타 캡처 (pid, cmd, cwd)
|
||||
# 5) agent-sessions.yaml 에 tmux_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)
|
||||
# 4 = agent-sessions.yaml append failure
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 --workspace <path> --agent <claude|agy|hermes|cline> --role <role> [options]
|
||||
|
||||
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)
|
||||
--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
|
||||
--submit-job PROMPT submit a job to multi-agent-mux-delegate-job registry with the given prompt
|
||||
-h, --help this help
|
||||
EOF
|
||||
}
|
||||
|
||||
WORKSPACE=""
|
||||
AGENT=""
|
||||
ROLE=""
|
||||
SESSION_NAME=""
|
||||
USE_WRAPPER=0
|
||||
DRY_RUN=0
|
||||
TMUX_SERVER_OPT=""
|
||||
SUBMIT_JOB_PROMPT=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--workspace) WORKSPACE="$2"; shift 2 ;;
|
||||
--agent) AGENT="$2"; shift 2 ;;
|
||||
--role) ROLE="$2"; shift 2 ;;
|
||||
--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 ;;
|
||||
--submit-job) SUBMIT_JOB_PROMPT="$2"; shift 2 ;;
|
||||
-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"
|
||||
fi
|
||||
|
||||
# Preflight
|
||||
[ -n "$WORKSPACE" ] || { echo "ERROR: --workspace required" >&2; usage; exit 2; }
|
||||
[ -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 "$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)
|
||||
if [ "$AGENT" = "claude" ]; then
|
||||
if ! claude auth status 2>/dev/null | grep -q '"loggedIn":\s*true'; then
|
||||
echo "ERROR: claude not logged in. Run 'claude auth login' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
elif [ "$AGENT" = "agy" ]; then
|
||||
if ! agy models >/dev/null 2>&1; then
|
||||
echo "ERROR: agy is not authenticated. Please log in first." >&2
|
||||
exit 1
|
||||
fi
|
||||
elif [ "$AGENT" = "hermes" ]; then
|
||||
if ! hermes status >/dev/null 2>&1; then
|
||||
echo "ERROR: hermes is not functional. Run 'hermes setup' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
elif [ "$AGENT" = "cline" ]; then
|
||||
if ! cline history --json >/dev/null 2>&1; then
|
||||
echo "ERROR: cline is not functional or configured." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# 세션 이름 — lib.sh::derive_session_name 이 단일 소스 (P0-A)
|
||||
if [ -z "$SESSION_NAME" ]; then
|
||||
SESSION_NAME="$(derive_session_name "$WORKSPACE" "$AGENT")"
|
||||
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
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# tmux 세션 띄우기
|
||||
LOCAL_BIN="${LOCAL_BIN:-$HOME/.local/bin}"
|
||||
WRAPPER="$LOCAL_BIN/$SESSION_NAME"
|
||||
|
||||
spawn() {
|
||||
case "$AGENT" in
|
||||
claude)
|
||||
if { [ -x "$WRAPPER" ] && [ "$(basename "$WRAPPER")" != "claude" ]; } || [ "$USE_WRAPPER" = "1" ]; then
|
||||
nohup "$WRAPPER" >/dev/null 2>&1 &
|
||||
disown
|
||||
else
|
||||
_tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "claude --dangerously-skip-permissions"
|
||||
fi
|
||||
;;
|
||||
agy)
|
||||
_tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "agy --dangerously-skip-permissions"
|
||||
;;
|
||||
hermes)
|
||||
_tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "hermes"
|
||||
;;
|
||||
cline)
|
||||
_tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "cline -i"
|
||||
;;
|
||||
*) 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)"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
spawn
|
||||
|
||||
# TUI 준비 대기
|
||||
sleep 6
|
||||
|
||||
# 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)
|
||||
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
# cmd_full 결정
|
||||
case "$AGENT" in
|
||||
claude) CMD_FULL='claude --dangerously-skip-permissions' ;;
|
||||
agy) CMD_FULL='agy --dangerously-skip-permissions' ;;
|
||||
hermes) CMD_FULL='hermes' ;;
|
||||
cline) CMD_FULL='cline -i' ;;
|
||||
esac
|
||||
|
||||
# 시작 명령
|
||||
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 [ -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\" \"claude --dangerously-skip-permissions\""
|
||||
fi
|
||||
;;
|
||||
agy|hermes|cline)
|
||||
START_CMD="$local_tmux new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
|
||||
;;
|
||||
esac
|
||||
|
||||
# agent-sessions.yaml 에 append
|
||||
DELEGATE_JOB_ID=""
|
||||
if [ -n "$SUBMIT_JOB_PROMPT" ]; then
|
||||
delegate_agent=""
|
||||
if [ "$AGENT" = "claude" ]; then
|
||||
delegate_agent="claude-code"
|
||||
elif [ "$AGENT" = "hermes" ]; then
|
||||
delegate_agent="hermes-agent"
|
||||
elif [ "$AGENT" = "cline" ]; then
|
||||
delegate_agent="cline-agent"
|
||||
else
|
||||
delegate_agent="antigravity-cli"
|
||||
fi
|
||||
agent_session="tmux:$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"
|
||||
fi
|
||||
|
||||
# atomic_dump_yaml: flock + temp+rename + .bak + schema validate (P0-B).
|
||||
# 모든 값은 환경변수로 전달 — heredoc interpolation 없음 (P1-B).
|
||||
# 자식 pid 는 bash 에서 pgrep 으로 미리 구함 (P2: 도구명 필터).
|
||||
CHILD_PID=0
|
||||
if { [ "$AGENT" = "agy" ] || [ "$AGENT" = "hermes" ] || [ "$AGENT" = "cline" ]; } && [ -n "$PANE_PID" ]; then
|
||||
CHILD_PID=$(pgrep -P "$PANE_PID" -x "$AGENT" 2>/dev/null | head -1 || true)
|
||||
CHILD_PID="${CHILD_PID:-0}"
|
||||
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" \
|
||||
CMD_FULL="$CMD_FULL" START_CMD="$START_CMD" CHILD_PID="$CHILD_PID" \
|
||||
TMUX_SERVER_NAME="${TMUX_SERVER_NAME:-default}" \
|
||||
DELEGATE_JOB_ID="$DELEGATE_JOB_ID" ROLE="$ROLE" <<'PYEOF'
|
||||
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')
|
||||
server_opt = f"-L {server_name} " if server_name and server_name != 'default' else ""
|
||||
|
||||
sessions = d.setdefault('tmux_sessions', [])
|
||||
|
||||
# P0-D: 같은 이름 엔트리가 status=running 이면만 거부. terminated/archived 는
|
||||
# 재사용 가능 — 낡은 엔트리를 제거하고 새로 append (create -> delete -> create).
|
||||
running_same = [s for s in sessions if s.get('name') == name and s.get('status') == 'running']
|
||||
if running_same:
|
||||
print(f"ERROR: {name} already running in agent-sessions.yaml", flush=True)
|
||||
raise SystemExit(4)
|
||||
sessions[:] = [s for s in sessions if s.get('name') != name]
|
||||
|
||||
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,
|
||||
'delegate_job_id': os.environ.get('DELEGATE_JOB_ID', '') or None,
|
||||
'pane': {
|
||||
'index': 0,
|
||||
'pid': int(pid) if pid.isdigit() else 0,
|
||||
'cmd': agent,
|
||||
'cmd_full': os.environ['CMD_FULL'],
|
||||
'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}',
|
||||
}
|
||||
|
||||
if agent == 'claude':
|
||||
entry['tui'] = {
|
||||
'model': '(unknown — capture after first message)',
|
||||
'provider': 'anthropic',
|
||||
'plan': '(unknown)',
|
||||
'account': '(unknown — read from claude auth status)',
|
||||
'version': '(unknown — read from TUI)',
|
||||
}
|
||||
entry['claude_session_id_own'] = None
|
||||
entry['last_visible_status'] = "TUI started; awaiting first user message"
|
||||
elif agent == 'agy':
|
||||
cp = os.environ.get('CHILD_PID', '0')
|
||||
entry['child_pid'] = int(cp) if cp.isdigit() else 0
|
||||
entry['agy_conversation_id_own'] = None
|
||||
entry['mcp_attachments'] = [
|
||||
{
|
||||
'name': 'stitch',
|
||||
'transport': 'mcp-remote',
|
||||
'endpoint': 'https://stitch.googleapis.com/mcp'
|
||||
}
|
||||
]
|
||||
entry['last_visible_status'] = "TUI started; awaiting first user message"
|
||||
elif agent == 'hermes':
|
||||
cp = os.environ.get('CHILD_PID', '0')
|
||||
entry['child_pid'] = int(cp) if cp.isdigit() else 0
|
||||
entry['hermes_conversation_id_own'] = None
|
||||
entry['last_visible_status'] = "TUI started; awaiting first user message"
|
||||
elif agent == 'cline':
|
||||
cp = os.environ.get('CHILD_PID', '0')
|
||||
entry['child_pid'] = int(cp) if cp.isdigit() else 0
|
||||
entry['cline_conversation_id_own'] = None
|
||||
entry['last_visible_status'] = "TUI started; awaiting first user message"
|
||||
|
||||
sessions.append(entry)
|
||||
|
||||
snap = d.setdefault('snapshot', {})
|
||||
snap['taken_at'] = os.environ['NOW_ISO']
|
||||
snap['cwd'] = os.environ['PANE_CWD']
|
||||
print(f"appended: {name}", flush=True)
|
||||
PYEOF
|
||||
|
||||
echo
|
||||
echo "=== created ==="
|
||||
echo "tmux 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"
|
||||
delegate_publish_event "$DELEGATE_JOB_ID" started "multi-agent-mux session created"
|
||||
WD_PID=$(start_watchdog "$DELEGATE_JOB_ID" "$WORKSPACE")
|
||||
echo "watchdog PID: $WD_PID"
|
||||
fi
|
||||
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 "Delete: use multi-agent-mux-stop skill"
|
||||
echo "Resume: use multi-agent-mux-resume skill (after first message creates a session id)"
|
||||
@@ -1,11 +0,0 @@
|
||||
# multi-agent-mux-delegate-job 스킬
|
||||
|
||||
작업(Job)을 자율 에이전트(claude-code/hermes/agy/cline/codex/opencode/human)에게 위임하고 MQTT
|
||||
이벤트 채널로 비동기 관찰하는 범용 에이전트 협업 스킬. **시작점은 [`SKILL.md`](./SKILL.md).**
|
||||
|
||||
- 프로토콜/스키마: [`job-protocol.md`](./job-protocol.md)
|
||||
- 브로커 PoC→운영 전환: [`mqtt-broker-setup.md`](./mqtt-broker-setup.md)
|
||||
- 레지스트리 포맷/동시성: [`registry.md`](./registry.md)
|
||||
- 참조 구현: [`multi-agent-mux-delegate-job`](./multi-agent-mux-delegate-job) (bash wrapper), [`scripts/publish_event.py`](./scripts/publish_event.py), [`scripts/job_subscriber.py`](./scripts/job_subscriber.py), [`scripts/registry.py`](./scripts/registry.py), [`scripts/mqtt_common.py`](./scripts/mqtt_common.py)
|
||||
- 영구 감사 로그: `.mam/delegate_job_logs/<job_id>/` (`meta.json`·`events.ndjson`·`status.json`)
|
||||
— `multi-agent-mux-delegate-job logs <id>` 또는 `multi-agent-mux-delegate-job logs --list`로 조회 (SKILL.md "Audit Logs" 참조)
|
||||
@@ -1,94 +0,0 @@
|
||||
---
|
||||
name: multi-agent-mux-delegate-job
|
||||
description: "Delegate a unit of work to any autonomous agent (claude-code, hermes, agy, cline, codex, or a human) and observe it asynchronously over an MQTT event channel. Supported roles include orchestrator, worker, and reviewer."
|
||||
version: 1.1.0
|
||||
author: Multi-Agent System
|
||||
license: MIT
|
||||
platforms: [linux, macos, windows]
|
||||
---
|
||||
|
||||
# multi-agent-mux-delegate-job — Async Job Delegation over MQTT
|
||||
|
||||
Delegate a unit of work to any autonomous agent, then **observe** it asynchronously instead of blocking. Every job gets a unique ID and a registry record. The worker agent publishes lifecycle events (`started`, `permission_required`, `progress`, `completed`, `error`) to a per-job MQTT topic, and the delegator/orchestrator subscribes to verify the final state.
|
||||
|
||||
This skill allows any agent (`claude-code`, `hermes`, `agy`, `cline`, etc.) to play any role: **Orchestrator/Delegator**, **Worker/Implementer**, or **Reviewer**.
|
||||
|
||||
---
|
||||
|
||||
## Roles in Multi-Agent Mux
|
||||
|
||||
- **Orchestrator (Delegator)**: Initiates the job, coordinates other agents, handles loops and reviews, and commits final changes.
|
||||
- **Worker (Implementer)**: Receives the brief file or task prompt, performs the implementation, and emits started/completed/error events.
|
||||
- **Reviewer**: Evaluates git diffs or artifacts produced by the worker, and responds with a `completed` event containing `"PASS"` or feedback.
|
||||
|
||||
---
|
||||
|
||||
## Core Commands (CLI)
|
||||
|
||||
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')
|
||||
multi-agent-mux-delegate-job submit \
|
||||
--agent <claude-code|hermes-agent|agy-agent|cline-agent|human> \
|
||||
--agent-session tmux:<session_name> \
|
||||
--prompt "Task description or instructions here" \
|
||||
--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> \
|
||||
--prompt "Task description"
|
||||
|
||||
# 3) Check job status and audit logs
|
||||
multi-agent-mux-delegate-job status --job <JOB_ID>
|
||||
multi-agent-mux-delegate-job logs <JOB_ID> # Chronological log of events
|
||||
multi-agent-mux-delegate-job list # Summary of all registered jobs
|
||||
|
||||
# 4) Verify job artifacts with a validation script
|
||||
multi-agent-mux-delegate-job verify --job <JOB_ID> --validate ./validate.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task Delegation Types
|
||||
|
||||
Supported job types include:
|
||||
- `direct` (default): Single agent execution (direct tasking).
|
||||
- `loop` (Worker-Reviewer Loop): Alternates worker execution and reviewer evaluation until reviewer approves (`PASS`) or iterations run out.
|
||||
- `discuss` (Research & Discussion): Collaboration between two agents to reach a consensus (e.g., agreeing on a design or plan).
|
||||
|
||||
For detailed state machine diagrams and configurations, see [DELEGATION_TYPES.md](./DELEGATION_TYPES.md).
|
||||
|
||||
---
|
||||
|
||||
## The Event Protocol Contract
|
||||
|
||||
Every agent participating in the delegation contract must follow the same lifecycle publishing protocol using `publish_event.py`:
|
||||
|
||||
1. **On Start**: Publish `started` event.
|
||||
`python3 .agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py --job "$JOB_ID" --event started`
|
||||
2. **On Tool/Permission Prompt**: Publish `permission_required` event.
|
||||
`python3 ... --job "$JOB_ID" --event permission_required --detail "<tool>:<reason>"`
|
||||
3. **On Progress Update (Optional)**: Publish `progress` event.
|
||||
`python3 ... --job "$JOB_ID" --event progress --detail "<status_update>"`
|
||||
4. **On Success**: Publish `completed` event.
|
||||
`python3 ... --job "$JOB_ID" --event completed --detail "<summary>"` (Reviewer should include `"PASS"` in the detail to approve).
|
||||
5. **On Failure/Feedback**: Publish `error` event.
|
||||
`python3 ... --job "$JOB_ID" --event error --detail "<reason_or_feedback>"`
|
||||
|
||||
---
|
||||
|
||||
## Audit Logs
|
||||
|
||||
Job lifecycle execution events are persistently mirrored to an append-only log under `.mam/delegate_job_logs/<job_id>/` (containing `meta.json`, `events.ndjson`, and `status.json`). Use `multi-agent-mux-delegate-job logs <job_id>` to view the timeline.
|
||||
|
||||
---
|
||||
|
||||
## Best Practices and Pitfalls
|
||||
|
||||
- **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.
|
||||
- **Batch Grouping**: Group non-overlapping tasks into batches to parallelize execution across multiple agent sessions, reducing overhead.
|
||||
@@ -1,114 +0,0 @@
|
||||
# Job Event Protocol
|
||||
|
||||
The wire contract every multi-agent-mux-delegate-job agent (claude-code, codex, opencode,
|
||||
human, …) speaks. One job → one MQTT topic → JSON event payloads. Stable across
|
||||
the PoC (public broker) and production (own broker) stages; only transport
|
||||
hardening changes, never the payload shape.
|
||||
|
||||
Reference implementation: [`./scripts/publish_event.py`](./scripts/publish_event.py)
|
||||
(emit) and [`./scripts/job_subscriber.py`](./scripts/job_subscriber.py) (observe).
|
||||
|
||||
---
|
||||
|
||||
## 1. Topic design
|
||||
|
||||
| Topic | Purpose |
|
||||
|-------|---------|
|
||||
| `python/mqtt/sample` | Legacy demo topic — **never changed** (README compat). |
|
||||
| `python/mqtt/jobs/<job_id>/events` | Per-job event stream (this protocol). |
|
||||
|
||||
- One topic per job, JSON payload, `event` field discriminates the type.
|
||||
- Single-direction publish only (worker → observer). No request/response.
|
||||
- Future split is reserved but not required:
|
||||
`<job_id>/events`, `<job_id>/logs`, `<job_id>/artifacts`.
|
||||
- `topic_prefix` is stored in the job record so publishers resolve the topic
|
||||
from the registry alone (`<topic_prefix>/events`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Payload schema (JSON, UTF-8, `schema_version = 1`)
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"seq": 7,
|
||||
"job_id": "abc12345",
|
||||
"event": "started | permission_required | progress | completed | error",
|
||||
"timestamp": "2026-06-19T09:32:00Z",
|
||||
"detail": "generalised, whitelisted human-readable string",
|
||||
"data": { "optional": "metadata" }
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Rule |
|
||||
|-------|------|
|
||||
| `schema_version` | If publisher/subscriber disagree, the subscriber **drops** the event with a warning (defensive parsing). |
|
||||
| `seq` | Monotonic **per `job_id`**, first publish = 1. Lets the subscriber detect reorder/duplication. Persisted in the registry (`last_seq`) so it survives restarts. |
|
||||
| `job_id` | Subscriber drops any event whose `job_id` it did not subscribe for. |
|
||||
| `timestamp` | Publisher host clock, **advisory only**. The delegator's timeout is measured from *receive* time, not this field. |
|
||||
| `detail` | Generalised text only. **No absolute paths, keys, or tokens.** |
|
||||
| `data` | Optional metadata. Production may add `hmac_sig`, `build_id`, etc. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Event catalogue
|
||||
|
||||
| event | When emitted | `detail` example | seq |
|
||||
|-------|--------------|------------------|-----|
|
||||
| `started` | Agent first picks up the job | `"Job a1b2c3d4 started"` | 1 |
|
||||
| `permission_required` | Agent needs a tool/permission grant | `"needs to write sort_problems.md"` | as it happens |
|
||||
| `progress` | Optional intermediate checkpoint | `"creating problem 5/10"` | as it happens |
|
||||
| `completed` | Successful terminal state | `"saved to sort_problems.md"` | last |
|
||||
| `error` | Failure / exception terminal state | `"internal error, see logs"` | last |
|
||||
|
||||
`started` and `completed`/`error` are mandatory bookends; `permission_required`
|
||||
and `progress` are optional. `detail` must stay on the whitelist of generalised
|
||||
phrasings — never leak secrets through it.
|
||||
|
||||
### Terminal semantics
|
||||
|
||||
- `completed` → subscriber exits 0; `error` → exits 1.
|
||||
- The subscriber runs a **terminal state machine**: it finalises a job on the
|
||||
first `completed`/`error` it sees and ignores any later terminal event for
|
||||
that job (QoS-1 duplicate, or an `error`-after-`completed` reorder). When all
|
||||
watched jobs are finalised it exits.
|
||||
- Wall-clock timeout *or* idle timeout before a terminal event → exit 2.
|
||||
|
||||
---
|
||||
|
||||
## 4. Production hardening (own broker stage)
|
||||
|
||||
The payload shape is unchanged; the transport and trust model tighten. See
|
||||
[`mqtt-broker-setup.md`](./mqtt-broker-setup.md) for the broker side.
|
||||
|
||||
- **Auth / ACL** — username/password + per-topic ACL. `jobs/+/events` publish is
|
||||
granted to the worker credential, subscribe to the Hermes credential.
|
||||
- **HMAC Signature Verification (`data.hmac_sig`)** — to authenticate the publisher and verify message integrity without exposing the raw secret token over the wire, each job record contains a per-job `auth_token` (`secrets.token_urlsafe(32)`). The publisher computes an HMAC-SHA256 signature over the serialized payload (excluding `data.hmac_sig` itself) using the `auth_token` as the key, and appends it to **`data.hmac_sig`**. The subscriber reconstructs this signature and **drops any message that does not match or lacks a valid signature**.
|
||||
|
||||
```json
|
||||
{ "...": "...", "data": { "hmac_sig": "d2f3...", "build_id": "42" } }
|
||||
```
|
||||
|
||||
- **TLS** — port 8883 + private CA. Toggled with `MQTT_TLS=1` (+ `MQTT_CA_CERTS`);
|
||||
no code change.
|
||||
- **Retained terminal events** — `completed`/`error` publish with `retain=True`
|
||||
so a subscriber that joins late immediately receives the last terminal state
|
||||
instead of a stale view. The reference publisher auto-retains terminal events;
|
||||
`--retained` forces it for any event.
|
||||
- **Dual timeouts** — total wall-clock budget + last-activity idle detection,
|
||||
both measured from receive time.
|
||||
- **Clock trust** — never trust the payload `timestamp` for timeout decisions.
|
||||
|
||||
---
|
||||
|
||||
## 5. Why a public broker is PoC-only
|
||||
|
||||
On `broker.hivemq.com` anyone can publish/subscribe the same topic. Therefore:
|
||||
|
||||
- No secret data in payloads.
|
||||
- `started`/`completed`/`error` are *signals*, never a basis for a security
|
||||
decision.
|
||||
- Non-retained messages are **not queued** for absent subscribers — start the
|
||||
subscriber **before** the agent (ordering dependency), or rely on retained
|
||||
terminal events in production.
|
||||
- Real operational decisions belong to the own-broker stage with auth + ACL.
|
||||
@@ -1,176 +0,0 @@
|
||||
# MQTT Broker Setup — PoC → Production
|
||||
|
||||
The multi-agent-mux-delegate-job scripts read **all** broker settings from environment
|
||||
variables (or a job record's `broker.*` block) through a single helper,
|
||||
`broker_config_from_env()` in
|
||||
[`./scripts/mqtt_common.py`](./scripts/mqtt_common.py). The design goal:
|
||||
**switch from the public PoC broker to your own broker with config only — no
|
||||
code change.**
|
||||
|
||||
| Env var | Meaning | PoC default | Production |
|
||||
|---------|---------|-------------|-----------|
|
||||
| `MQTT_BROKER` | host | `broker.hivemq.com` | internal hostname/IP |
|
||||
| `MQTT_PORT` | port | `1883` | `8883` (TLS) |
|
||||
| `MQTT_TLS` | TLS on/off (`1`/`0`) | `0` | `1` |
|
||||
| `MQTT_USERNAME` / `MQTT_PASSWORD` | auth | (none) | broker-issued |
|
||||
| `MQTT_CA_CERTS` | CA bundle path | (none) | private CA path |
|
||||
| `MQTT_CERTFILE` / `MQTT_KEYFILE` | client cert (optional mTLS) | (none) | per-client |
|
||||
| `MQTT_CLIENT_ID_PREFIX` | client id prefix | `hermes` | per-environment |
|
||||
|
||||
---
|
||||
|
||||
## 1. PoC: public broker (`broker.hivemq.com`)
|
||||
|
||||
**Pros** — zero setup, reachable from anywhere, perfect for wiring up the
|
||||
publish/subscribe loop and the timeout/state-machine logic.
|
||||
|
||||
**Cons / accepted assumptions** — no auth, no integrity, shared with the world:
|
||||
|
||||
- no secrets in payloads;
|
||||
- `started`/`completed`/`error` are advisory signals only;
|
||||
- non-retained messages are **not queued** for absent subscribers, so the
|
||||
subscriber must start before the agent;
|
||||
- a re-subscribing client cannot recover past (non-retained) events.
|
||||
|
||||
Use it only to validate the protocol, never for real decisions.
|
||||
|
||||
---
|
||||
|
||||
## 2. Production: self-hosted Mosquitto (or EMQX)
|
||||
|
||||
Both support MQTT 5 + ACL + TLS. Mosquitto shown below; EMQX is a drop-in for
|
||||
the same env vars.
|
||||
|
||||
### 2.1 Install
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install mosquitto
|
||||
|
||||
# Debian/Ubuntu
|
||||
sudo apt-get update && sudo apt-get install -y mosquitto mosquitto-clients
|
||||
|
||||
# Docker
|
||||
docker run -d --name mosquitto -p 8883:8883 \
|
||||
-v "$PWD/mosquitto.conf:/mosquitto/config/mosquitto.conf" \
|
||||
-v "$PWD/certs:/mosquitto/certs" \
|
||||
-v "$PWD/auth:/mosquitto/auth" \
|
||||
eclipse-mosquitto:2
|
||||
```
|
||||
|
||||
### 2.2 `mosquitto.conf` (key lines)
|
||||
|
||||
```conf
|
||||
persistence true
|
||||
persistence_location /mosquitto/data/
|
||||
|
||||
password_file /mosquitto/auth/passwd
|
||||
acl_file /mosquitto/auth/acl
|
||||
allow_anonymous false
|
||||
|
||||
listener 8883
|
||||
cafile /mosquitto/certs/ca.crt
|
||||
certfile /mosquitto/certs/server.crt
|
||||
keyfile /mosquitto/certs/server.key
|
||||
```
|
||||
|
||||
`persistence true` + QoS 1 + retained terminal events means a subscriber that
|
||||
joins after a job finished still sees the final `completed`/`error`.
|
||||
|
||||
### 2.3 Users (username/password)
|
||||
|
||||
```bash
|
||||
# create the file with the first user, then add more with -b
|
||||
mosquitto_passwd -c /mosquitto/auth/passwd hermes # subscriber/delegator
|
||||
mosquitto_passwd /mosquitto/auth/passwd claude-worker # publisher/agent
|
||||
# (omit -c after the first; -c truncates the file)
|
||||
```
|
||||
|
||||
### 2.4 ACL — least privilege
|
||||
|
||||
The worker only **publishes** events; Hermes only **subscribes**:
|
||||
|
||||
```conf
|
||||
# /mosquitto/auth/acl
|
||||
|
||||
# claude-worker: may publish job events, may not read others' streams
|
||||
user claude-worker
|
||||
topic write python/mqtt/jobs/+/events
|
||||
|
||||
# hermes: observes every job's events
|
||||
user hermes
|
||||
topic read python/mqtt/jobs/+/events
|
||||
|
||||
# keep the legacy demo topic usable for both, if desired
|
||||
pattern readwrite python/mqtt/sample
|
||||
```
|
||||
|
||||
### 2.5 TLS certificates
|
||||
|
||||
**Quick self-signed (single host, internal only):**
|
||||
|
||||
```bash
|
||||
mkdir -p certs && cd certs
|
||||
openssl req -x509 -newkey rsa:2048 -nodes -days 825 \
|
||||
-keyout server.key -out server.crt \
|
||||
-subj "/CN=mqtt.internal"
|
||||
cp server.crt ca.crt # clients trust this as the CA bundle
|
||||
```
|
||||
|
||||
**Private CA (recommended — separate CA from server cert):**
|
||||
|
||||
```bash
|
||||
# 1) CA
|
||||
openssl genrsa -out ca.key 4096
|
||||
openssl req -x509 -new -nodes -key ca.key -days 3650 -out ca.crt -subj "/CN=Hermes-CA"
|
||||
# 2) server cert signed by the CA
|
||||
openssl genrsa -out server.key 2048
|
||||
openssl req -new -key server.key -out server.csr -subj "/CN=mqtt.internal"
|
||||
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
|
||||
-out server.crt -days 825
|
||||
```
|
||||
|
||||
Clients trust `ca.crt` via `MQTT_CA_CERTS=/path/to/ca.crt`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Cut-over verification (config-only, no code change)
|
||||
|
||||
Goal: prove the **same scripts** talk to your broker by changing only env/registry.
|
||||
|
||||
```bash
|
||||
# 1) point the env at the new broker
|
||||
export MQTT_BROKER=mqtt.internal
|
||||
export MQTT_PORT=8883
|
||||
export MQTT_TLS=1
|
||||
export MQTT_CA_CERTS=$PWD/certs/ca.crt
|
||||
export MQTT_USERNAME=hermes
|
||||
export MQTT_PASSWORD=… # subscriber side
|
||||
# (publisher side uses claude-worker creds via the job record's broker block)
|
||||
|
||||
# 2) sanity-check with the mosquitto CLI first
|
||||
mosquitto_sub -h "$MQTT_BROKER" -p 8883 --cafile "$MQTT_CA_CERTS" \
|
||||
-u hermes -P "$MQTT_PASSWORD" -t 'python/mqtt/jobs/+/events' -v &
|
||||
|
||||
# 3) run the unchanged multi-agent-mux-delegate-job loop
|
||||
PY=.venv/bin/python
|
||||
JID=$($PY scripts/registry.py register --prompt "broker cutover smoke")
|
||||
$PY scripts/job_subscriber.py --job "$JID" --timeout 30 &
|
||||
sleep 3
|
||||
$PY scripts/publish_event.py --job "$JID" --event started
|
||||
$PY scripts/publish_event.py --job "$JID" --event completed # auto-retained
|
||||
```
|
||||
|
||||
Expected:
|
||||
- subscriber prints the `started` and `completed` lines and exits 0;
|
||||
- `mosquitto_sub` shows the same events (ACL allows `hermes` to read);
|
||||
- publishing as a credential **without** write ACL is rejected by the broker;
|
||||
- a subscriber started *after* `completed` still receives it (retained).
|
||||
|
||||
If all four hold, the migration is config-only. Persist the broker block into
|
||||
each job record so `publish_event.py` connects from the registry alone:
|
||||
|
||||
```json
|
||||
"broker": { "host": "mqtt.internal", "port": 8883, "tls": true,
|
||||
"username": "claude-worker", "password": "…" }
|
||||
```
|
||||
@@ -1,440 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# multi-agent-mux-delegate-job — user-facing orchestrator for the multi-agent-mux-delegate-job skill.
|
||||
#
|
||||
# Subcommands:
|
||||
# submit register a job, start the subscriber FIRST, then run the agent,
|
||||
# then (optionally) run a validation script.
|
||||
# status show one job record.
|
||||
# list list all jobs.
|
||||
# verify run a user-supplied --validate script against a job's artifacts.
|
||||
# wait block until all running/pending jobs reach a terminal state.
|
||||
#
|
||||
# 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.
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Load local .env if it exists in current dir or workspace root
|
||||
if [[ -f .env ]]; then
|
||||
set -a; source .env; set +a
|
||||
elif [[ -f "$SCRIPT_DIR/../../.env" ]]; then
|
||||
set -a; source "$SCRIPT_DIR/../../.env"; set +a
|
||||
fi
|
||||
|
||||
# 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 [[ -x "${WORKDIR:-.}/.venv/bin/python" ]]; then
|
||||
py_bin="${WORKDIR}/.venv/bin/python"
|
||||
elif [[ -x ".venv/bin/python" ]]; then
|
||||
py_bin="$(pwd)/.venv/bin/python"
|
||||
else
|
||||
py_bin="python3"
|
||||
fi
|
||||
if ! "$py_bin" -c "import paho.mqtt" 2>/dev/null; then
|
||||
echo "ERROR: paho-mqtt package is missing for $py_bin." >&2
|
||||
echo " Please create a virtual environment and install it:" >&2
|
||||
echo " python3 -m venv .venv && .venv/bin/pip install -r \"$SCRIPT_DIR/requirements.txt\"" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$py_bin"
|
||||
}
|
||||
|
||||
REGISTRY_DIR_DEFAULT=".mam/jobs"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
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]
|
||||
[--type <direct|loop|discuss>] [--reviewer <reviewer_agent>]
|
||||
[--reviewer-session <reviewer_session>] [--max-iterations <count>]
|
||||
# The skill is tmux-interactive only; --mode print was removed.
|
||||
status --job <id> [--registry-dir <dir>]
|
||||
list [--registry-dir <dir>]
|
||||
verify --job <id> --validate <script> [--registry-dir <dir>]
|
||||
wait [--job <id>] [--timeout <sec>] [--registry-dir <dir>]
|
||||
logs <job_id> | --list # persistent audit log (delegate_job_logs/)
|
||||
EOF
|
||||
}
|
||||
|
||||
# ---- arg parsing helpers --------------------------------------------------
|
||||
AGENT="claude-code"; PROMPT=""; WORKDIR="$(pwd)"; AGENT_SESSION="tmux: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
|
||||
|
||||
parse_opts() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--agent) AGENT="$2"; shift 2;;
|
||||
--prompt) PROMPT="$2"; shift 2;;
|
||||
--workdir) WORKDIR="$2"; shift 2;;
|
||||
--agent-session) AGENT_SESSION="$2"; shift 2;;
|
||||
--timeout) TIMEOUT="$2"; shift 2;;
|
||||
--idle-timeout) IDLE_TIMEOUT="$2"; shift 2;;
|
||||
--validate) VALIDATE="$2"; shift 2;;
|
||||
--job) JOB_ID="$2"; shift 2;;
|
||||
--registry-dir) REGISTRY_DIR="$2"; shift 2;;
|
||||
--dry-run) DRY_RUN=1; shift;;
|
||||
--type) TYPE="$2"; shift 2;;
|
||||
--reviewer) REVIEWER="$2"; shift 2;;
|
||||
--reviewer-session) REVIEWER_SESSION="$2"; shift 2;;
|
||||
--max-iterations) MAX_ITERATIONS="$2"; shift 2;;
|
||||
*) echo "unknown option: $1" >&2; usage; exit 1;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
cmd_submit() {
|
||||
parse_opts "$@"
|
||||
[[ -n "$PROMPT" ]] || { echo "submit requires --prompt" >&2; exit 1; }
|
||||
PY="$(pick_python)"
|
||||
cd "$WORKDIR"
|
||||
mkdir -p "$REGISTRY_DIR"
|
||||
|
||||
# 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" \
|
||||
--timeout "$TIMEOUT" --idle-timeout "$IDLE_TIMEOUT" \
|
||||
--job-type "$TYPE" --reviewer "$REVIEWER" --reviewer-session "$REVIEWER_SESSION" \
|
||||
--max-iterations "$MAX_ITERATIONS")"
|
||||
echo "registered job: $JOB_ID"
|
||||
|
||||
if [[ "$TYPE" == "direct" ]]; then
|
||||
# 2) START THE SUBSCRIBER FIRST (ordering dependency — MQTT does not queue
|
||||
# non-retained messages for absent subscribers).
|
||||
local logf="$REGISTRY_DIR/$JOB_ID.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 &
|
||||
local sub_pid=$!
|
||||
echo "subscriber pid: $sub_pid (log: $logf)"
|
||||
sleep 1 # give the subscriber time to CONNACK + SUBSCRIBE before the agent runs
|
||||
|
||||
# 3) run the agent (or print the command for dry-run / missing binary)
|
||||
local pub="$PY $SCRIPT_DIR/scripts/publish_event.py --registry-dir $REGISTRY_DIR --job $JOB_ID"
|
||||
# NOTE: the agent MUST use --job "$JOB_ID" (the one we just minted). Hard-coding
|
||||
# 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"
|
||||
|
||||
run_agent "$JOB_ID" "$instructions"
|
||||
|
||||
# 4) optional validation hook
|
||||
if [[ -n "$VALIDATE" ]]; then
|
||||
echo "running validation: $VALIDATE"
|
||||
if JOB_ID="$JOB_ID" REGISTRY_DIR="$REGISTRY_DIR" bash "$VALIDATE"; then
|
||||
echo "validation: PASS"
|
||||
else
|
||||
local rc=$?
|
||||
echo "validation: FAIL (exit $rc)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
# In dry-run we never started a real subscriber (the wrapper short-circuits
|
||||
# before launching one), but the wait below would still try to join the
|
||||
# background sub_pid from cmd_submit. Skip both the wait and the subscriber
|
||||
# log dump; the user just wants to see the instruction that would have run.
|
||||
local logs_root_dry="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
||||
echo "$logs_root_dry/$JOB_ID"
|
||||
return 0
|
||||
fi
|
||||
|
||||
wait "$sub_pid" || true
|
||||
echo "subscriber output:"; cat "$logf" || true
|
||||
|
||||
# Last stdout line: the persistent audit-log dir for this job (see SKILL.md
|
||||
# "Audit Logs"). Callers can scrape `tail -n1` to find it.
|
||||
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
||||
echo "$logs_root/$JOB_ID"
|
||||
else
|
||||
# Implement loop/discuss orchestrator
|
||||
local iteration=1
|
||||
local current_prompt="$PROMPT"
|
||||
local current_session="$AGENT_SESSION"
|
||||
local current_role="worker"
|
||||
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
echo "[dry-run] orchestrator loop would start for job: $JOB_ID type: $TYPE"
|
||||
echo "worker session: $AGENT_SESSION, reviewer session: $REVIEWER_SESSION"
|
||||
local logs_root_dry="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
||||
echo "$logs_root_dry/$JOB_ID"
|
||||
return 0
|
||||
fi
|
||||
|
||||
while true; do
|
||||
echo "=================================================="
|
||||
echo "Iteration $iteration - Role: $current_role"
|
||||
echo "Session: $current_session"
|
||||
echo "=================================================="
|
||||
|
||||
# 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" \
|
||||
--status "pending"
|
||||
|
||||
# Start subscriber
|
||||
local logf="$REGISTRY_DIR/${JOB_ID}.iter_${iteration}_${current_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 &
|
||||
local sub_pid=$!
|
||||
echo "subscriber pid: $sub_pid (log: $logf)"
|
||||
sleep 1
|
||||
|
||||
# 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"
|
||||
|
||||
# Trigger agent
|
||||
run_agent "$JOB_ID" "$instructions" "$current_session"
|
||||
|
||||
# Wait for subscriber
|
||||
local sub_rc=0
|
||||
wait "$sub_pid" || sub_rc=$?
|
||||
echo "subscriber output:"; cat "$logf" || true
|
||||
|
||||
# Check job status based on subscriber exit code
|
||||
local job_status="running"
|
||||
if [[ $sub_rc -eq 0 ]]; then
|
||||
job_status="completed"
|
||||
elif [[ $sub_rc -eq 1 ]]; then
|
||||
job_status="error"
|
||||
else
|
||||
job_status="timeout"
|
||||
fi
|
||||
|
||||
echo "Job role $current_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 [[ "$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"
|
||||
current_session="$REVIEWER_SESSION"
|
||||
# Build reviewer prompt based on type
|
||||
if [[ "$TYPE" == "loop" ]]; then
|
||||
current_prompt="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. CRITICAL: When raising issues or giving a review, you MUST include the exact reason for the issue and a clear direction for improvement (문제 제시에 대한 이유와 확실한 개선 방향을 반드시 포함해야 합니다)."
|
||||
elif [[ "$TYPE" == "discuss" ]]; then
|
||||
current_prompt="Read draft/documents generated for job $JOB_ID. Review the feasibility and content. Write your feedback/objections. If you agree with the plan, reply with 'AGREE'."
|
||||
fi
|
||||
else
|
||||
if [[ "$job_status" != "completed" ]]; then
|
||||
echo "Reviewer did not complete successfully (status: $job_status). Terminating workflow."
|
||||
break
|
||||
fi
|
||||
|
||||
# Reviewer finished. Check if pass/agree
|
||||
local success=0
|
||||
if [[ "$TYPE" == "loop" ]]; then
|
||||
if [[ "${feedback,,}" == *"pass"* ]]; then
|
||||
success=1
|
||||
fi
|
||||
elif [[ "$TYPE" == "discuss" ]]; then
|
||||
if [[ "${feedback,,}" == *"agree"* ]]; then
|
||||
success=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$success" == "1" ]]; then
|
||||
echo "Reviewer approved the work. Finalizing job as completed."
|
||||
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" status --job "$JOB_ID" --set "completed"
|
||||
break
|
||||
else
|
||||
# Reviewer rejected/provided feedback. Increment & check max iterations
|
||||
if [[ $iteration -ge $MAX_ITERATIONS ]]; then
|
||||
echo "Max iterations ($MAX_ITERATIONS) reached without approval. Terminating workflow."
|
||||
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" status --job "$JOB_ID" --set "error"
|
||||
break
|
||||
fi
|
||||
|
||||
iteration=$((iteration + 1))
|
||||
current_role="worker"
|
||||
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
|
||||
fi
|
||||
done
|
||||
|
||||
# 4) optional validation hook
|
||||
if [[ -n "$VALIDATE" ]]; then
|
||||
echo "running validation: $VALIDATE"
|
||||
if JOB_ID="$JOB_ID" REGISTRY_DIR="$REGISTRY_DIR" bash "$VALIDATE"; then
|
||||
echo "validation: PASS"
|
||||
else
|
||||
local rc=$?
|
||||
echo "validation: FAIL (exit $rc)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Last stdout line: the persistent audit-log dir
|
||||
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
|
||||
echo "$logs_root/$JOB_ID"
|
||||
fi
|
||||
}
|
||||
|
||||
run_agent() {
|
||||
local job_id="$1"; local instructions="$2"; local target_session="${3:-$AGENT_SESSION}"
|
||||
# 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.
|
||||
# - 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
|
||||
# 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:}"
|
||||
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
echo "[dry-run] would delegate task to running agent '$AGENT' in tmux 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
|
||||
return 1
|
||||
fi
|
||||
|
||||
local _tmux="tmux"
|
||||
if [ -n "${TMUX_SERVER_NAME:-}" ]; then
|
||||
_tmux="tmux -L $TMUX_SERVER_NAME"
|
||||
fi
|
||||
|
||||
if ! $_tmux 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
|
||||
|
||||
# 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
|
||||
fi
|
||||
|
||||
echo "살아있는 에이전트 세션 '$sess'에 작업을 위임합니다..."
|
||||
$_tmux set-buffer -b "job_buf_$job_id" "$instructions"
|
||||
$_tmux paste-buffer -b "job_buf_$job_id" -t "$sess"
|
||||
sleep 0.5
|
||||
$_tmux send-keys -t "$sess" C-m
|
||||
$_tmux delete-buffer -b "job_buf_$job_id"
|
||||
|
||||
echo "작업이 세션 '$sess'에 전송되었습니다. (연결하려면: $_tmux attach -t $sess)"
|
||||
trap - EXIT
|
||||
}
|
||||
|
||||
cmd_status() {
|
||||
parse_opts "$@"
|
||||
[[ -n "$JOB_ID" ]] || { echo "status requires --job" >&2; exit 1; }
|
||||
PY="$(pick_python)"
|
||||
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" get --job "$JOB_ID"
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
parse_opts "$@"
|
||||
PY="$(pick_python)"
|
||||
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" list
|
||||
}
|
||||
|
||||
cmd_verify() {
|
||||
parse_opts "$@"
|
||||
[[ -n "$JOB_ID" ]] || { echo "verify requires --job" >&2; exit 1; }
|
||||
[[ -n "$VALIDATE" ]] || { echo "verify requires --validate <script>" >&2; exit 1; }
|
||||
echo "verifying job $JOB_ID with $VALIDATE"
|
||||
if JOB_ID="$JOB_ID" REGISTRY_DIR="$REGISTRY_DIR" bash "$VALIDATE"; then
|
||||
echo "verify: PASS (exit 0)"; exit 0
|
||||
else
|
||||
rc=$?; echo "verify: FAIL (exit $rc)"; exit "$rc"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_logs() {
|
||||
# logs <job_id> | logs --list — delegates to registry.py's logs CLI, which
|
||||
# reads the persistent audit log under $DELEGATE_JOB_LOGS_DIR (or
|
||||
# <cwd>/delegate_job_logs). Run from your project dir so the default resolves.
|
||||
PY="$(pick_python)"
|
||||
if [[ "${1:-}" == "--list" ]]; then
|
||||
"$PY" "$SCRIPT_DIR/scripts/registry.py" logs --list
|
||||
else
|
||||
local jid="${1:-}"
|
||||
[[ -n "$jid" ]] || { echo "logs requires <job_id> or --list" >&2; exit 1; }
|
||||
"$PY" "$SCRIPT_DIR/scripts/registry.py" logs "$jid"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_wait() {
|
||||
parse_opts "$@"
|
||||
PY="$(pick_python)"
|
||||
if [[ -n "$JOB_ID" ]]; then
|
||||
"$PY" "$SCRIPT_DIR/scripts/job_subscriber.py" --registry-dir "$REGISTRY_DIR" \
|
||||
--job "$JOB_ID" --timeout "$TIMEOUT"
|
||||
else
|
||||
"$PY" "$SCRIPT_DIR/scripts/job_subscriber.py" --registry-dir "$REGISTRY_DIR" \
|
||||
--wait-any --timeout "$TIMEOUT"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
local sub="${1:-}"; shift || true
|
||||
case "$sub" in
|
||||
submit) cmd_submit "$@";;
|
||||
status) cmd_status "$@";;
|
||||
list) cmd_list "$@";;
|
||||
verify) cmd_verify "$@";;
|
||||
wait) cmd_wait "$@";;
|
||||
logs) cmd_logs "$@";;
|
||||
""|-h|--help|help) usage;;
|
||||
*) echo "unknown command: $sub" >&2; usage; exit 1;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,183 +0,0 @@
|
||||
# Job Registry
|
||||
|
||||
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
|
||||
parallel without collisions, and `publish_event.py` / `job_subscriber.py` can
|
||||
reconstruct everything they need from the registry alone.
|
||||
|
||||
Reference implementation: [`./scripts/registry.py`](./scripts/registry.py)
|
||||
(library + CLI) over the primitives in
|
||||
[`./scripts/mqtt_common.py`](./scripts/mqtt_common.py).
|
||||
|
||||
---
|
||||
|
||||
## 1. Directory layout
|
||||
|
||||
```
|
||||
.mam/jobs/
|
||||
<job_id>.json # job metadata record (schema below)
|
||||
<job_id>.events.log # append-only JSON-lines event log (debug, optional)
|
||||
.lock # shared advisory lock (fcntl) for the whole registry
|
||||
```
|
||||
|
||||
`registry_dir` defaults to `.mam/jobs` and is overridable everywhere via
|
||||
`--registry-dir`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Job record schema
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"job_id": "abc12345",
|
||||
"status": "pending | running | completed | error | cancelled",
|
||||
"created_at": "2026-06-19T09:30:00Z",
|
||||
"updated_at": "2026-06-19T09:32:00Z",
|
||||
"prompt": "정렬 문제 10개를 만들어 sort_problems.md로 저장…",
|
||||
"agent": "claude-code",
|
||||
"agent_session": "tmux:claude",
|
||||
"broker": {
|
||||
"host": "broker.hivemq.com",
|
||||
"port": 1883,
|
||||
"tls": false,
|
||||
"username": null,
|
||||
"password": null
|
||||
},
|
||||
"topic_prefix": "python/mqtt/jobs/abc12345",
|
||||
"timeout_sec": 3600,
|
||||
"idle_timeout_sec": 120,
|
||||
"expected_artifacts": ["sort_problems.md"],
|
||||
"last_seq": 0,
|
||||
"auth_token": null
|
||||
}
|
||||
```
|
||||
|
||||
- `broker` lets `publish_event.py` connect from the record alone (env still
|
||||
overrides toggles like `MQTT_TLS`).
|
||||
- `topic_prefix` → the events topic is `<topic_prefix>/events`.
|
||||
- `last_seq` backs the monotonic `seq` counter so it survives process restarts.
|
||||
- `expected_artifacts` is the hook a user `validate.sh` checks (existence/content).
|
||||
- `auth_token` is `null` in PoC; production sets `secrets.token_urlsafe(32)`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Concurrency rules
|
||||
|
||||
### PoC — fcntl advisory lock
|
||||
|
||||
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
|
||||
one machine.
|
||||
|
||||
### Production — SQLite WAL
|
||||
|
||||
When delegation spans **multiple hosts**, the file lock no longer serialises
|
||||
across machines. Migrate the same operations to a SQLite database in WAL mode
|
||||
(`PRAGMA journal_mode=WAL`) with a transaction per claim. The function
|
||||
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)`:
|
||||
|
||||
1. acquires the registry lock,
|
||||
2. scans for the **oldest** record with `status == "pending"` **and**
|
||||
matching `agent_session`,
|
||||
3. flips it to `running` and writes it back **atomically**,
|
||||
4. releases the lock and returns the `job_id` (or `None`).
|
||||
|
||||
Because the scan + flip happen under one lock, two sessions can never claim the
|
||||
same job. Sessions with distinct labels naturally partition the work; sessions
|
||||
sharing a label compete safely — first to acquire the lock wins, the other sees
|
||||
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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Atomic status updates
|
||||
|
||||
All writes use a temp-file + `os.replace` rename, which is atomic on POSIX:
|
||||
|
||||
1. take the registry lock,
|
||||
2. load the current record,
|
||||
3. mutate fields + refresh `updated_at` (and `last_seq` for `next_seq`),
|
||||
4. write to `.<job_id>.<rand>.tmp` in the **same directory**, `fsync`,
|
||||
5. `os.replace(tmp, <job_id>.json)`,
|
||||
6. release the lock.
|
||||
|
||||
A reader therefore always sees either the old or the new complete record, never
|
||||
a half-written file. This is the file-based equivalent of the rename trick
|
||||
(`pending.<session>` → `running.<session>`) and maps cleanly onto a single
|
||||
SQLite transaction when you migrate.
|
||||
|
||||
---
|
||||
|
||||
## 6. CLI quick reference
|
||||
|
||||
```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
|
||||
$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
|
||||
```
|
||||
|
||||
Exit codes: `0` ok, `1` not found / bad status, `3` (`pick`) no pending job for
|
||||
that session.
|
||||
|
||||
---
|
||||
|
||||
## 7. Persistent audit log
|
||||
|
||||
Separate from the registry, every job is also mirrored to a durable append-only
|
||||
audit log at `.mam/delegate_job_logs/<job_id>/` (override with
|
||||
`DELEGATE_JOB_LOGS_DIR`, default `<cwd>/.mam/delegate_job_logs`). The registry
|
||||
is **live state** mutated in place; the audit log is **history** that survives
|
||||
even after the registry dir is cleaned up. It is git-ignored.
|
||||
|
||||
```
|
||||
.mam/delegate_job_logs/<job_id>/
|
||||
meta.json # registration snapshot (the full job record at register time)
|
||||
events.ndjson # append-only, one JSON event per line, time-ordered
|
||||
status.json # current status only (fast point-query)
|
||||
```
|
||||
|
||||
`events.ndjson` lines are written automatically at four points:
|
||||
|
||||
| Trigger | line `event` | Source |
|
||||
|---------|-------------|--------|
|
||||
| `register_job` | `registered` | `registry.register_job` → `mqtt_common.init_job_log` |
|
||||
| status change (`update_status`, `pick`, publish status sync) | `status_changed` (`from`/`to`) | `mqtt_common.update_job_status` / `pick_pending` |
|
||||
| event published | `published` (embeds the exact payload) | `publish_event.py` |
|
||||
| event received | `received` | `job_subscriber.py` |
|
||||
|
||||
Helpers live in [`./scripts/mqtt_common.py`](./scripts/mqtt_common.py):
|
||||
`LOGS_DIR`, `job_log_path`, `init_job_log`, `append_event` (fcntl-locked,
|
||||
concurrent-append safe), `update_logged_status`, and the readers
|
||||
`read_logged_meta` / `read_logged_status` / `iter_logged_events` /
|
||||
`list_logged_jobs`. Every writer is **best-effort and isolated** — wrapped in
|
||||
`try/except` with a `logger.warning`, so an audit-log failure never breaks the
|
||||
registry write, the publish, or the subscribe it shadows.
|
||||
|
||||
Read them via the CLI:
|
||||
|
||||
```bash
|
||||
PY=.venv/bin/python
|
||||
$PY scripts/registry.py logs <job_id> # pretty timeline
|
||||
$PY scripts/registry.py logs <job_id> --tail 20 # last 20 events
|
||||
$PY scripts/registry.py logs <job_id> --json # raw JSON lines
|
||||
$PY scripts/registry.py logs --list # every job, live status
|
||||
```
|
||||
@@ -1,253 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""job_subscriber.py — the single entry point for observing Job events.
|
||||
|
||||
Subscribes to one job's ``<topic_prefix>/events`` (or, with ``--wait-any``, the
|
||||
events of every running/pending job in the registry), prints one line to stdout
|
||||
per accepted event, and exits on a terminal event or a timeout.
|
||||
|
||||
Design points (all flagged in the PLAN review):
|
||||
- terminal state machine: ``completed``/``error`` is acted on exactly once per
|
||||
job, so QoS-1 duplicates or an ``error``-after-``completed`` reorder are safe.
|
||||
- dual timeouts: a wall-clock ``--timeout`` (total budget, started at
|
||||
subscribe time so a cold start can't hang forever) AND an idle
|
||||
``--idle-timeout`` (no new event for N seconds).
|
||||
- defensive parsing: undecodable payloads, ``schema_version`` mismatches, and
|
||||
``job_id`` values we did not subscribe for are logged and dropped.
|
||||
|
||||
stdout = event lines only. Diagnostics go to stderr via logging.
|
||||
|
||||
Exit codes:
|
||||
0 all watched jobs reached ``completed``
|
||||
1 any watched job reached ``error``
|
||||
2 timed out (wall-clock or idle) before all jobs finished
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
import mqtt_common
|
||||
import registry
|
||||
from mqtt_common import (
|
||||
DEFAULT_REGISTRY_DIR,
|
||||
SCHEMA_VERSION,
|
||||
broker_config_from_job,
|
||||
load_job,
|
||||
make_client,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("delegate_job.job_subscriber")
|
||||
|
||||
TERMINAL_EVENTS = ("completed", "error")
|
||||
|
||||
|
||||
def _format_line(topic: str, payload: Dict[str, Any]) -> str:
|
||||
return (
|
||||
f"{payload.get('timestamp','-')} "
|
||||
f"job={payload.get('job_id','?')} "
|
||||
f"seq={payload.get('seq','?')} "
|
||||
f"{payload.get('event','?'):<20} "
|
||||
f"{payload.get('detail','')}"
|
||||
)
|
||||
|
||||
|
||||
class _Watcher:
|
||||
"""Holds the shared queue + the set of job_ids we accept events for."""
|
||||
|
||||
def __init__(self, expected_job_ids: Set[str], expected_tokens: Dict[str, Optional[str]], expected_seqs: Dict[str, int]):
|
||||
self.events: "queue.Queue[Tuple[str, Dict[str, Any]]]" = queue.Queue()
|
||||
self.expected = set(expected_job_ids)
|
||||
self.tokens = expected_tokens # job_id -> expected auth_token (or None)
|
||||
self.last_seq = dict(expected_seqs)
|
||||
|
||||
def on_message(self, _client, _userdata, msg) -> None:
|
||||
# --- defensive parsing -------------------------------------------
|
||||
try:
|
||||
payload = json.loads(msg.payload.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
logger.warning("drop unparseable payload on %s: %s", msg.topic, exc)
|
||||
return
|
||||
if not isinstance(payload, dict):
|
||||
logger.warning("drop non-object payload on %s", msg.topic)
|
||||
return
|
||||
if payload.get("schema_version") != SCHEMA_VERSION:
|
||||
logger.warning("drop event with schema_version=%r (expected %d)",
|
||||
payload.get("schema_version"), SCHEMA_VERSION)
|
||||
return
|
||||
jid = payload.get("job_id")
|
||||
if jid not in self.expected:
|
||||
logger.warning("drop event for unexpected job_id=%r on %s", jid, msg.topic)
|
||||
return
|
||||
# --- production auth check: data.auth_token must match if expected ---
|
||||
expected_token = self.tokens.get(jid)
|
||||
if not mqtt_common.verify_hmac(payload, expected_token):
|
||||
logger.warning("drop event for job %s: HMAC verify failed", jid)
|
||||
return
|
||||
# --- replay attack defense: check monotonic sequence ---
|
||||
seq = payload.get("seq")
|
||||
if seq is None or not isinstance(seq, int):
|
||||
logger.warning("drop event for job %s: missing or invalid seq", jid)
|
||||
return
|
||||
if seq <= self.last_seq.get(jid, 0):
|
||||
logger.warning("drop event for job %s: seq %d is not monotonically increasing (last %d)",
|
||||
jid, seq, self.last_seq.get(jid, 0))
|
||||
return
|
||||
self.last_seq[jid] = seq
|
||||
# Persistent audit log from the *subscriber's* vantage point: every event
|
||||
# that survives defensive parsing is recorded here, including ones a
|
||||
# different host published. This is the external-observer record that
|
||||
# backstops the publisher's own "published" line if it never wrote one.
|
||||
mqtt_common.append_event(jid, {
|
||||
"event": "received",
|
||||
"source_event": payload.get("event"),
|
||||
"seq": payload.get("seq"),
|
||||
"topic": msg.topic,
|
||||
"timestamp": payload.get("timestamp"),
|
||||
"detail": payload.get("detail", ""),
|
||||
})
|
||||
self.events.put((msg.topic, payload))
|
||||
|
||||
|
||||
def _collect_jobs(args) -> List[Dict[str, Any]]:
|
||||
"""Resolve the list of job records this invocation should watch."""
|
||||
if args.wait_any:
|
||||
jobs = [r for r in registry.list_jobs(args.registry_dir)
|
||||
if r.get("status") in ("pending", "running")]
|
||||
if not jobs:
|
||||
logger.error("no pending/running jobs to wait for")
|
||||
return jobs
|
||||
job = load_job(args.job, args.registry_dir) # raises FileNotFoundError
|
||||
return [job]
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Subscribe to Job events on MQTT")
|
||||
target = parser.add_mutually_exclusive_group(required=True)
|
||||
target.add_argument("--job", help="job id to watch")
|
||||
target.add_argument("--wait-any", action="store_true",
|
||||
help="watch every pending/running job in the registry")
|
||||
parser.add_argument("--timeout", type=float, default=None,
|
||||
help="wall-clock budget in seconds (default: job.timeout_sec or 3600)")
|
||||
parser.add_argument("--idle-timeout", type=float, default=None,
|
||||
help="max seconds with no new event (default: job.idle_timeout_sec or 120)")
|
||||
parser.add_argument("--expect-retention", action="store_true",
|
||||
help="warn if no retained terminal event arrives promptly")
|
||||
parser.add_argument("--registry-dir", default=DEFAULT_REGISTRY_DIR)
|
||||
parser.add_argument("-v", "--verbose", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
mqtt_common.setup_logging(logging.DEBUG if args.verbose else logging.WARNING)
|
||||
|
||||
try:
|
||||
jobs = _collect_jobs(args)
|
||||
except FileNotFoundError as exc:
|
||||
logger.error("%s", exc)
|
||||
return 2
|
||||
if not jobs:
|
||||
return 2
|
||||
|
||||
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}
|
||||
watcher = _Watcher(expected_ids, tokens, seqs)
|
||||
|
||||
# Resolve timeouts from CLI, falling back to the (first) job's settings.
|
||||
base_job = jobs[0]
|
||||
wall_timeout = args.timeout if args.timeout is not None else float(base_job.get("timeout_sec", 3600))
|
||||
idle_timeout = args.idle_timeout if args.idle_timeout is not None else float(base_job.get("idle_timeout_sec", 120))
|
||||
|
||||
# All watched jobs share a broker in practice; connect using the first
|
||||
# job's broker and subscribe to each job's events topic.
|
||||
config = broker_config_from_job(base_job)
|
||||
client = make_client("subscriber", config)
|
||||
client.on_message = watcher.on_message
|
||||
|
||||
subscribed_topics = []
|
||||
for job in jobs:
|
||||
prefix = job.get("topic_prefix") or mqtt_common.topic_prefix_for(job["job_id"])
|
||||
subscribed_topics.append(f"{prefix}/events")
|
||||
|
||||
def on_connect(_c, _u, _flags, reason_code, _props):
|
||||
if mqtt_common.reason_code_value(reason_code) != 0:
|
||||
logger.error("broker connection failed: rc=%s", reason_code)
|
||||
return
|
||||
for topic in subscribed_topics:
|
||||
_c.subscribe(topic, qos=1)
|
||||
logger.info("subscribed to %s", topic)
|
||||
|
||||
def on_disconnect(_c, _u, _flags, reason_code, _props):
|
||||
rc = mqtt_common.reason_code_value(reason_code)
|
||||
if rc != 0:
|
||||
logger.warning("broker disconnected (rc=%s); will retry reconnect", reason_code)
|
||||
|
||||
client.on_connect = on_connect
|
||||
client.on_disconnect = on_disconnect
|
||||
client.reconnect_delay_set(min_delay=1, max_delay=16)
|
||||
mqtt_common.with_retry(
|
||||
lambda: client.connect(config.host, config.port, config.keepalive),
|
||||
attempts=5, base_delay=1.0, max_delay=16.0
|
||||
)()
|
||||
client.loop_start()
|
||||
|
||||
terminal: Dict[str, str] = {} # job_id -> "completed"/"error"
|
||||
pending: Set[str] = set(expected_ids)
|
||||
start = time.monotonic()
|
||||
wall_deadline = start + wall_timeout
|
||||
last_event = start
|
||||
retention_checked = not args.expect_retention
|
||||
|
||||
try:
|
||||
while pending:
|
||||
now = time.monotonic()
|
||||
if now >= wall_deadline:
|
||||
logger.error("wall-clock timeout (%.0fs); still pending: %s",
|
||||
wall_timeout, ", ".join(sorted(pending)))
|
||||
return 2
|
||||
idle_left = idle_timeout - (now - last_event)
|
||||
if idle_left <= 0:
|
||||
logger.error("idle timeout (%.0fs, no events); still pending: %s",
|
||||
idle_timeout, ", ".join(sorted(pending)))
|
||||
return 2
|
||||
wait = min(wall_deadline - now, idle_left, 1.0)
|
||||
try:
|
||||
topic, payload = watcher.events.get(timeout=wait)
|
||||
except queue.Empty:
|
||||
if not retention_checked and (now - start) > 3.0:
|
||||
logger.warning("--expect-retention set but no retained "
|
||||
"terminal event observed yet")
|
||||
retention_checked = True
|
||||
continue
|
||||
|
||||
last_event = time.monotonic()
|
||||
retention_checked = True
|
||||
print(_format_line(topic, payload), flush=True)
|
||||
|
||||
jid = payload["job_id"]
|
||||
event = payload.get("event")
|
||||
if event in TERMINAL_EVENTS:
|
||||
if jid in terminal:
|
||||
# Already finalised: ignore duplicates / late reorders.
|
||||
logger.info("ignoring duplicate terminal %s for %s", event, jid)
|
||||
continue
|
||||
terminal[jid] = event
|
||||
pending.discard(jid)
|
||||
finally:
|
||||
client.loop_stop()
|
||||
try:
|
||||
client.disconnect()
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
# All jobs reached a terminal state. error wins over completed.
|
||||
if any(state == "error" for state in terminal.values()):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,616 +0,0 @@
|
||||
"""Shared MQTT + registry helpers for the multi-agent-mux-delegate-job skill.
|
||||
|
||||
Single entry point for:
|
||||
- broker configuration (env -> dataclass),
|
||||
- paho client construction (auth + TLS + unique client id),
|
||||
- monotonic per-job sequence counters,
|
||||
- retry-with-exponential-backoff,
|
||||
- atomic registry record load/update under an fcntl lock.
|
||||
|
||||
Requires paho-mqtt >= 2.0 (uses CallbackAPIVersion.VERSION2).
|
||||
|
||||
This module is the *only* place that talks to the broker config and to the
|
||||
raw job record file, so PoC -> production migration touches just env/registry
|
||||
values, never code (see references/mqtt-broker-setup.md).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
logger = logging.getLogger("delegate_job.mqtt_common")
|
||||
|
||||
def _load_dotenv(workspace_dir: str = None) -> None:
|
||||
"""Load .env file from workspace if it exists and env var not already set.
|
||||
|
||||
This ensures Python scripts get the same env vars as the shell wrapper
|
||||
scripts that source .env. Only sets vars that are not already in os.environ
|
||||
(i.e. OS env takes precedence over .env file).
|
||||
"""
|
||||
import os
|
||||
if workspace_dir is None:
|
||||
# Walk up from this script to find workspace root
|
||||
d = os.path.dirname(os.path.abspath(__file__))
|
||||
for _ in range(5):
|
||||
if os.path.isfile(os.path.join(d, ".env")):
|
||||
break
|
||||
d = os.path.dirname(d)
|
||||
else:
|
||||
d = workspace_dir
|
||||
env_path = os.path.join(d, ".env")
|
||||
if not os.path.isfile(env_path):
|
||||
return
|
||||
with open(env_path, "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" in line:
|
||||
key, _, val = line.partition("=")
|
||||
key = key.strip()
|
||||
val = val.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = val
|
||||
|
||||
_load_dotenv()
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Constants
|
||||
# --------------------------------------------------------------------------
|
||||
SCHEMA_VERSION = 1
|
||||
DEFAULT_REGISTRY_DIR = ".mam/jobs"
|
||||
DEFAULT_TOPIC_ROOT = "python/mqtt/jobs"
|
||||
LOCK_FILENAME = ".lock"
|
||||
|
||||
# Persistent audit-log layout: .mam/delegate_job_logs/<job_id>/{meta,events,status}.
|
||||
# This is a *separate* artifact from the registry: the registry is the live job
|
||||
# record (mutated in place), the audit log is an append-only history that
|
||||
# survives even if the registry dir is cleaned up.
|
||||
META_FILENAME = "meta.json"
|
||||
EVENTS_FILENAME = "events.ndjson"
|
||||
STATUS_FILENAME = "status.json"
|
||||
|
||||
|
||||
def _default_logs_dir() -> str:
|
||||
"""Audit-log root. Overridable with ``DELEGATE_JOB_LOGS_DIR``; otherwise
|
||||
``<cwd>/.mam/delegate_job_logs`` — we keep audit logs next to the
|
||||
live registry (``.mam/jobs/``) so the two runtime artifacts sit
|
||||
under the same parent dir and follow the same ``.gitignore`` rule.
|
||||
The cwd of whichever process emits events (the bash wrapper and
|
||||
scripts) is used as the anchor."""
|
||||
env = os.environ.get("DELEGATE_JOB_LOGS_DIR")
|
||||
if env and env.strip():
|
||||
return env
|
||||
return os.path.join(os.getcwd(), ".mam", "delegate_job_logs")
|
||||
|
||||
|
||||
LOGS_DIR = _default_logs_dir()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Broker configuration
|
||||
# --------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class BrokerConfig:
|
||||
"""Resolved broker connection settings.
|
||||
|
||||
PoC defaults target the public HiveMQ broker. Production overrides arrive
|
||||
either from environment variables or from a job record's ``broker.*`` block
|
||||
(see ``broker_config_from_job``).
|
||||
"""
|
||||
|
||||
host: str = "broker.hivemq.com"
|
||||
port: int = 1883
|
||||
tls: bool = False
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
client_id_prefix: str = "hermes"
|
||||
# TLS material (only consulted when tls is True).
|
||||
ca_certs: Optional[str] = None
|
||||
certfile: Optional[str] = None
|
||||
keyfile: Optional[str] = None
|
||||
keepalive: int = 60
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
def to_registry_block(self) -> Dict[str, Any]:
|
||||
"""The subset that gets persisted into a job record's broker block."""
|
||||
return {
|
||||
"host": self.host,
|
||||
"port": self.port,
|
||||
"tls": self.tls,
|
||||
"username": self.username,
|
||||
"password": self.password,
|
||||
}
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None or raw.strip() == "":
|
||||
return default
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
logger.warning("invalid int for %s=%r; using default %d", name, raw, default)
|
||||
return default
|
||||
|
||||
|
||||
def broker_config_from_env(overrides: Optional[Dict[str, Any]] = None) -> BrokerConfig:
|
||||
"""Build a :class:`BrokerConfig` from environment variables.
|
||||
|
||||
Recognised vars (all optional, PoC defaults shown):
|
||||
MQTT_BROKER (broker.hivemq.com), MQTT_PORT (1883), MQTT_TLS (0),
|
||||
MQTT_USERNAME, MQTT_PASSWORD, MQTT_CLIENT_ID_PREFIX (hermes),
|
||||
MQTT_CA_CERTS, MQTT_CERTFILE, MQTT_KEYFILE, MQTT_KEEPALIVE (60).
|
||||
|
||||
``overrides`` (e.g. a job record's broker block) wins over the env values
|
||||
for any key it specifies with a non-None value.
|
||||
"""
|
||||
cfg = BrokerConfig(
|
||||
host=os.environ.get("MQTT_BROKER", "broker.hivemq.com"),
|
||||
port=_env_int("MQTT_PORT", 1883),
|
||||
tls=_env_bool("MQTT_TLS", False),
|
||||
username=os.environ.get("MQTT_USERNAME") or None,
|
||||
password=os.environ.get("MQTT_PASSWORD") or None,
|
||||
client_id_prefix=os.environ.get("MQTT_CLIENT_ID_PREFIX", "hermes"),
|
||||
ca_certs=os.environ.get("MQTT_CA_CERTS") or None,
|
||||
certfile=os.environ.get("MQTT_CERTFILE") or None,
|
||||
keyfile=os.environ.get("MQTT_KEYFILE") or None,
|
||||
keepalive=_env_int("MQTT_KEEPALIVE", 60),
|
||||
)
|
||||
if overrides:
|
||||
for key, value in overrides.items():
|
||||
if value is not None and hasattr(cfg, key):
|
||||
setattr(cfg, key, value)
|
||||
return cfg
|
||||
|
||||
|
||||
def broker_config_from_job(job: Dict[str, Any]) -> BrokerConfig:
|
||||
"""Resolve broker config for a job: env defaults, then the job's broker.*
|
||||
block overrides. This lets ``publish_event.py`` connect from the registry
|
||||
alone, while still honouring environment toggles (e.g. MQTT_TLS=1)."""
|
||||
return broker_config_from_env(overrides=job.get("broker") or {})
|
||||
|
||||
|
||||
def make_client(role: str, config: Optional[BrokerConfig] = None) -> mqtt.Client:
|
||||
"""Return a configured paho ``Client`` (not yet connected).
|
||||
|
||||
The client id is ``f"{prefix}-{role}-{uuid8}"`` so concurrent publishers /
|
||||
subscribers never collide on the broker. Auth and TLS are applied when the
|
||||
config supplies them.
|
||||
"""
|
||||
config = config or broker_config_from_env()
|
||||
client_id = f"{config.client_id_prefix}-{role}-{uuid.uuid4().hex[:8]}"
|
||||
client = mqtt.Client(
|
||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=client_id,
|
||||
)
|
||||
if config.username:
|
||||
client.username_pw_set(config.username, config.password)
|
||||
if config.tls:
|
||||
# If ca_certs is None paho uses the system trust store (good enough for
|
||||
# public CAs); a private CA bundle path is passed through unchanged.
|
||||
client.tls_set(
|
||||
ca_certs=config.ca_certs,
|
||||
certfile=config.certfile,
|
||||
keyfile=config.keyfile,
|
||||
)
|
||||
logger.debug("built client id=%s tls=%s host=%s", client_id, config.tls, config.host)
|
||||
return client
|
||||
|
||||
|
||||
def reason_code_value(rc: Any) -> int:
|
||||
"""Normalise a paho v2 connect reason code to an int.
|
||||
|
||||
paho-mqtt 2.x hands callbacks a ``ReasonCode`` object (not an int); older
|
||||
paths may pass a plain int. ``ReasonCode`` exposes ``.value``; 0 == success.
|
||||
"""
|
||||
return int(getattr(rc, "value", rc))
|
||||
|
||||
|
||||
def verify_hmac(payload: dict, auth_token: Optional[str]) -> bool:
|
||||
"""Verify HMAC-SHA256 signature. Returns True if valid or no token set."""
|
||||
if not auth_token:
|
||||
return True # PoC mode — no auth
|
||||
sig = payload.get("data", {}).get("hmac_sig")
|
||||
if not sig:
|
||||
return False
|
||||
sign_payload = {k: v for k, v in payload.items() if k != "data"}
|
||||
sign_payload["data"] = {k: v for k, v in payload.get("data", {}).items() if k != "hmac_sig"}
|
||||
msg = json.dumps(sign_payload, sort_keys=True, separators=(",", ":")).encode()
|
||||
expected = hmac.new(auth_token.encode(), msg, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(sig, expected)
|
||||
|
||||
|
||||
def topic_prefix_for(job_id: str, root: str = DEFAULT_TOPIC_ROOT) -> str:
|
||||
return f"{root}/{job_id}"
|
||||
|
||||
|
||||
def events_topic_for(job_id: str, root: str = DEFAULT_TOPIC_ROOT) -> str:
|
||||
return f"{topic_prefix_for(job_id, root)}/events"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Registry primitives (single source of truth for raw record I/O)
|
||||
# --------------------------------------------------------------------------
|
||||
def _job_path(job_id: str, registry_dir: str) -> Path:
|
||||
return Path(registry_dir) / f"{job_id}.json"
|
||||
|
||||
|
||||
def _lock_path(registry_dir: str) -> Path:
|
||||
return Path(registry_dir) / LOCK_FILENAME
|
||||
|
||||
|
||||
@contextmanager
|
||||
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
|
||||
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)."""
|
||||
import fcntl # POSIX only; imported lazily so import works on Windows.
|
||||
|
||||
Path(registry_dir).mkdir(parents=True, exist_ok=True)
|
||||
lock_file = _lock_path(registry_dir)
|
||||
fh = open(lock_file, "a+")
|
||||
try:
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
||||
finally:
|
||||
fh.close()
|
||||
|
||||
|
||||
def load_job(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR) -> Dict[str, Any]:
|
||||
"""Load and parse a job record. Raises FileNotFoundError if absent."""
|
||||
path = _job_path(job_id, registry_dir)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"job record not found: {path}")
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def _atomic_write_record(job_id: str, registry_dir: str, record: Dict[str, Any]) -> None:
|
||||
"""Write a record atomically: temp file in the same dir + os.replace.
|
||||
|
||||
The rename is atomic on POSIX, so readers never observe a half-written
|
||||
file. Callers MUST already hold ``registry_lock`` for read-modify-write
|
||||
correctness."""
|
||||
Path(registry_dir).mkdir(parents=True, exist_ok=True)
|
||||
path = _job_path(job_id, registry_dir)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{job_id}.", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(record, fh, ensure_ascii=False, indent=2)
|
||||
fh.write("\n")
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, path)
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except Exception:
|
||||
pass
|
||||
except BaseException:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
|
||||
|
||||
def update_job_status(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR, **fields: Any) -> Dict[str, Any]:
|
||||
"""Atomically merge ``fields`` into a job record under the registry lock.
|
||||
|
||||
Always refreshes ``updated_at``. Returns the new record. Raises
|
||||
FileNotFoundError if the job does not exist.
|
||||
|
||||
This is the single chokepoint for status writes (both ``registry.update_status``
|
||||
and ``publish_event.py``'s status sync route through here), so it also mirrors
|
||||
any ``status`` change into the persistent audit log. We perform the log mirror
|
||||
under the lock to guarantee sequential consistency in audit history."""
|
||||
with registry_lock(registry_dir):
|
||||
record = load_job(job_id, registry_dir)
|
||||
old_status = record.get("status")
|
||||
record.update(fields)
|
||||
record["updated_at"] = _utcnow()
|
||||
_atomic_write_record(job_id, registry_dir, record)
|
||||
if "status" in fields:
|
||||
new_status = record.get("status")
|
||||
update_logged_status(job_id, new_status, updated_at=record["updated_at"])
|
||||
if old_status != new_status:
|
||||
append_event(job_id, {
|
||||
"event": "status_changed",
|
||||
"from": old_status,
|
||||
"to": new_status,
|
||||
"timestamp": record["updated_at"],
|
||||
})
|
||||
return record
|
||||
|
||||
|
||||
def next_seq(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR) -> int:
|
||||
"""Return the next monotonic sequence number for a job, persisted in the
|
||||
record's ``last_seq`` field so it stays consistent across process restarts.
|
||||
First call returns 1."""
|
||||
with registry_lock(registry_dir):
|
||||
record = load_job(job_id, registry_dir)
|
||||
seq = int(record.get("last_seq", 0)) + 1
|
||||
record["last_seq"] = seq
|
||||
record["updated_at"] = _utcnow()
|
||||
_atomic_write_record(job_id, registry_dir, record)
|
||||
return seq
|
||||
|
||||
|
||||
def _utcnow() -> str:
|
||||
"""ISO-8601 UTC timestamp with trailing Z (payload `timestamp` field)."""
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
def _utcnow_precise() -> str:
|
||||
"""ISO-8601 UTC timestamp with millisecond resolution. Used for the audit
|
||||
log's ``logged_at`` so events sort cleanly even within the same second."""
|
||||
now = time.time()
|
||||
base = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(now))
|
||||
return f"{base}.{int((now % 1) * 1000):03d}Z"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Persistent audit log (.mam/delegate_job_logs/<job_id>/...)
|
||||
#
|
||||
# Every function here is idempotent, concurrency-safe, and *best-effort*: a
|
||||
# logging failure is swallowed with a logger.warning and never propagated, so it
|
||||
# can never break a publish, a subscribe, or a registry write. stdout is never
|
||||
# touched (it is reserved for data output).
|
||||
# --------------------------------------------------------------------------
|
||||
def job_log_dir(job_id: str, logs_dir: Optional[str] = None) -> Path:
|
||||
return Path(logs_dir or LOGS_DIR) / job_id
|
||||
|
||||
|
||||
def job_log_path(job_id: str, kind: str, logs_dir: Optional[str] = None) -> Path:
|
||||
"""Path to one audit-log file for a job. ``kind`` is a filename, e.g. the
|
||||
module constants META_FILENAME / EVENTS_FILENAME / STATUS_FILENAME."""
|
||||
return job_log_dir(job_id, logs_dir) / kind
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _file_lock(fh):
|
||||
"""Best-effort exclusive lock over a single open file via fcntl, so two
|
||||
processes appending to events.ndjson never interleave a line. A no-op where
|
||||
fcntl is unavailable (Windows); a short append is atomic enough there."""
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - non-POSIX
|
||||
yield
|
||||
return
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _redact_dict(d: Any) -> Any:
|
||||
"""Recursively mask sensitive values (passwords, secrets, tokens) inside logs."""
|
||||
if isinstance(d, dict):
|
||||
redacted = {}
|
||||
for k, v in d.items():
|
||||
if any(s in k.lower() for s in ("password", "token", "secret", "auth_token", "key")):
|
||||
redacted[k] = "[REDACTED]"
|
||||
else:
|
||||
redacted[k] = _redact_dict(v)
|
||||
return redacted
|
||||
elif isinstance(d, list):
|
||||
return [_redact_dict(item) for item in d]
|
||||
return d
|
||||
|
||||
|
||||
def append_event(job_id: str, event_dict: Dict[str, Any], logs_dir: Optional[str] = None) -> None:
|
||||
"""Append one event as a JSON line to ``<logs>/<job_id>/events.ndjson``.
|
||||
|
||||
Concurrency-safe (fcntl lock over the file) and best-effort. A millisecond
|
||||
``logged_at`` is stamped when the caller did not supply one."""
|
||||
try:
|
||||
path = job_log_path(job_id, EVENTS_FILENAME, logs_dir)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
record = _redact_dict(dict(event_dict))
|
||||
record.setdefault("logged_at", _utcnow_precise())
|
||||
line = json.dumps(record, ensure_ascii=False) + "\n"
|
||||
with open(path, "a", encoding="utf-8") as fh:
|
||||
with _file_lock(fh):
|
||||
fh.write(line)
|
||||
fh.flush()
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.warning("append_event failed for job %s: %s", job_id, exc)
|
||||
|
||||
|
||||
def update_logged_status(job_id: str, status: str, logs_dir: Optional[str] = None, **extras: Any) -> None:
|
||||
"""Rewrite ``<logs>/<job_id>/status.json`` (current status for fast point
|
||||
queries) atomically. Best-effort; merges any ``extras``."""
|
||||
try:
|
||||
path = job_log_path(job_id, STATUS_FILENAME, logs_dir)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
record: Dict[str, Any] = {"job_id": job_id, "status": status, "updated_at": _utcnow()}
|
||||
record.update(extras)
|
||||
tmp = path.with_name(path.name + ".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump(record, fh, ensure_ascii=False, indent=2)
|
||||
fh.write("\n")
|
||||
os.replace(tmp, path)
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.warning("update_logged_status failed for job %s: %s", job_id, exc)
|
||||
|
||||
|
||||
def init_job_log(job_id: str, meta: Dict[str, Any], logs_dir: Optional[str] = None) -> None:
|
||||
"""Seed the per-job audit-log dir: write meta.json, status.json, and a first
|
||||
``registered`` line in events.ndjson. Idempotent (the ``registered`` line is
|
||||
written only when events.ndjson does not yet exist) and best-effort."""
|
||||
try:
|
||||
d = job_log_dir(job_id, logs_dir)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
meta_redacted = _redact_dict(meta)
|
||||
with open(d / META_FILENAME, "w", encoding="utf-8") as fh:
|
||||
json.dump(meta_redacted, fh, ensure_ascii=False, indent=2)
|
||||
fh.write("\n")
|
||||
status = meta.get("status", "pending")
|
||||
update_logged_status(
|
||||
job_id, status, logs_dir=logs_dir,
|
||||
created_at=meta.get("created_at"), prompt=meta.get("prompt"),
|
||||
)
|
||||
events_path = d / EVENTS_FILENAME
|
||||
first_time = not events_path.exists()
|
||||
events_path.touch(exist_ok=True)
|
||||
if first_time:
|
||||
append_event(job_id, {
|
||||
"event": "registered",
|
||||
"status": status,
|
||||
"agent": meta.get("agent"),
|
||||
"agent_session": meta.get("agent_session"),
|
||||
"topic_prefix": meta.get("topic_prefix"),
|
||||
"timestamp": meta.get("created_at"),
|
||||
}, logs_dir=logs_dir)
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.warning("init_job_log failed for job %s: %s", job_id, exc)
|
||||
|
||||
|
||||
def read_logged_meta(job_id: str, logs_dir: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Return a job's audit meta.json (registration snapshot), or None."""
|
||||
try:
|
||||
with open(job_log_path(job_id, META_FILENAME, logs_dir), "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def read_logged_status(job_id: str, logs_dir: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Return a job's current status.json, or None. This is the fast point-query
|
||||
file (current status only), separate from the registration-time meta.json."""
|
||||
try:
|
||||
with open(job_log_path(job_id, STATUS_FILENAME, logs_dir), "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def iter_logged_events(job_id: str, logs_dir: Optional[str] = None):
|
||||
"""Yield each parsed event from a job's events.ndjson in file (time) order.
|
||||
Malformed lines are skipped with a warning."""
|
||||
path = job_log_path(job_id, EVENTS_FILENAME, logs_dir)
|
||||
if not path.exists():
|
||||
return
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
yield json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("skipping malformed audit line in %s", path)
|
||||
|
||||
|
||||
def list_logged_jobs(logs_dir: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Return one meta record per job directory under the logs root, oldest
|
||||
first. Falls back to ``{"job_id": <dir>}`` when meta.json is missing."""
|
||||
base = Path(logs_dir or LOGS_DIR)
|
||||
out: List[Dict[str, Any]] = []
|
||||
if not base.exists():
|
||||
return out
|
||||
for d in sorted(base.iterdir()):
|
||||
if not d.is_dir():
|
||||
continue
|
||||
meta = read_logged_meta(d.name, logs_dir) or {"job_id": d.name}
|
||||
# Overlay the live status.json so the summary reflects current state, not
|
||||
# the registration-time snapshot frozen in meta.json.
|
||||
status = read_logged_status(d.name, logs_dir)
|
||||
if status:
|
||||
meta = {**meta,
|
||||
"status": status.get("status", meta.get("status")),
|
||||
"updated_at": status.get("updated_at", meta.get("updated_at"))}
|
||||
out.append(meta)
|
||||
out.sort(key=lambda m: m.get("created_at") or "")
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Retry helper
|
||||
# --------------------------------------------------------------------------
|
||||
def with_retry(
|
||||
fn: Optional[Callable] = None,
|
||||
*,
|
||||
attempts: int = 3,
|
||||
base_delay: float = 0.5,
|
||||
factor: float = 2.0,
|
||||
max_delay: float = 8.0,
|
||||
exceptions: Iterable[type] = (Exception,),
|
||||
) -> Callable:
|
||||
"""Retry ``fn`` with exponential backoff.
|
||||
|
||||
Usable two ways::
|
||||
|
||||
result = with_retry(do_publish, attempts=3)() # wrap-and-call
|
||||
@with_retry(attempts=5, base_delay=1.0) # decorator
|
||||
def do_publish(): ...
|
||||
|
||||
Re-raises the last exception once ``attempts`` is exhausted.
|
||||
"""
|
||||
exc_tuple = tuple(exceptions)
|
||||
|
||||
def decorate(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
delay = base_delay
|
||||
last_exc: Optional[BaseException] = None
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except exc_tuple as exc:
|
||||
last_exc = exc
|
||||
if attempt >= attempts:
|
||||
break
|
||||
logger.warning(
|
||||
"attempt %d/%d failed: %s; retrying in %.1fs",
|
||||
attempt, attempts, exc, delay,
|
||||
)
|
||||
time.sleep(delay)
|
||||
delay = min(delay * factor, max_delay)
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
return wrapper
|
||||
|
||||
if fn is not None:
|
||||
return decorate(fn)
|
||||
return decorate
|
||||
|
||||
|
||||
def setup_logging(level: int = logging.WARNING) -> None:
|
||||
"""Configure root logging to stderr. stdout is reserved for data output
|
||||
(subscriber event lines, registry ids)."""
|
||||
import sys
|
||||
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
stream=sys.stderr,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
@@ -1,229 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""publish_event.py — the single entry point for emitting a Job event.
|
||||
|
||||
Loads the job record from the registry, resolves its broker, assigns the next
|
||||
monotonic ``seq``, builds the schema-v1 JSON payload, and publishes it to
|
||||
``<topic_prefix>/events`` over QoS 1 with exponential-backoff retry.
|
||||
|
||||
Silent by design: nothing is printed to stdout. Diagnostics go to stderr via
|
||||
logging. Terminal events (``completed``/``error``) publish with retain=True so
|
||||
a late subscriber still observes the final state (production hardening).
|
||||
|
||||
Exit codes:
|
||||
0 published successfully
|
||||
1 parameter / registry error (bad args, unknown job, no pending job)
|
||||
2 publish failed after retries (network / broker / ACK timeout)
|
||||
|
||||
Usage:
|
||||
publish_event.py --job <id> --event started [--detail "..."] [--data '{...}']
|
||||
publish_event.py --pick-pending --agent-session tmux:claude --event completed
|
||||
publish_event.py --job <id> --event completed --retained
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import mqtt_common
|
||||
import registry
|
||||
from mqtt_common import (
|
||||
DEFAULT_REGISTRY_DIR,
|
||||
SCHEMA_VERSION,
|
||||
broker_config_from_job,
|
||||
events_topic_for,
|
||||
load_job,
|
||||
make_client,
|
||||
next_seq,
|
||||
with_retry,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("delegate_job.publish_event")
|
||||
|
||||
VALID_EVENTS = ("started", "permission_required", "progress", "completed", "error")
|
||||
TERMINAL_EVENTS = ("completed", "error")
|
||||
# event -> registry status to sync as a best-effort side effect
|
||||
EVENT_TO_STATUS = {
|
||||
"started": "running",
|
||||
"completed": "completed",
|
||||
"error": "error",
|
||||
}
|
||||
|
||||
CONNECT_ACK_TIMEOUT = 10 # seconds to wait for CONNACK
|
||||
PUBLISH_ACK_TIMEOUT = 5 # seconds to wait for QoS-1 PUBACK
|
||||
|
||||
|
||||
def build_payload(
|
||||
job_id: str,
|
||||
seq: int,
|
||||
event: str,
|
||||
detail: str,
|
||||
data: Optional[Dict[str, Any]],
|
||||
auth_token: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"seq": seq,
|
||||
"job_id": job_id,
|
||||
"event": event,
|
||||
"timestamp": mqtt_common._utcnow(),
|
||||
"detail": detail,
|
||||
"data": dict(data) if data else {},
|
||||
}
|
||||
# Production: carry the per-job HMAC-SHA256 signature in `data.hmac_sig` so
|
||||
# the subscriber can verify the publisher without exposing the secret token.
|
||||
# The signature is calculated over the entire payload (with `data.hmac_sig` excluded).
|
||||
if auth_token:
|
||||
sign_payload = {k: v for k, v in payload.items() if k != "data"}
|
||||
sign_payload["data"] = {k: v for k, v in payload.get("data", {}).items() if k != "hmac_sig"}
|
||||
msg = json.dumps(sign_payload, sort_keys=True, separators=(",", ":")).encode()
|
||||
sig = hmac.new(auth_token.encode(), msg, hashlib.sha256).hexdigest()
|
||||
payload["data"]["hmac_sig"] = sig
|
||||
return payload
|
||||
|
||||
|
||||
def _publish_once(config, topic: str, body: bytes, retain: bool) -> None:
|
||||
"""Connect, publish one QoS-1 message, wait for the broker ACK, disconnect.
|
||||
|
||||
Raises on any failure so ``with_retry`` can re-run the whole sequence (a
|
||||
fresh connection per attempt is the robust choice for a PoC)."""
|
||||
client = make_client("publisher", config)
|
||||
connected = {"rc": None}
|
||||
|
||||
def on_connect(_c, _u, _flags, reason_code, _props):
|
||||
connected["rc"] = reason_code
|
||||
|
||||
client.on_connect = on_connect
|
||||
client.connect(config.host, config.port, config.keepalive)
|
||||
client.loop_start()
|
||||
try:
|
||||
# Wait for CONNACK so we fail fast on auth/TLS errors.
|
||||
deadline = time.monotonic() + CONNECT_ACK_TIMEOUT
|
||||
while connected["rc"] is None and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
if connected["rc"] is None:
|
||||
raise TimeoutError("no CONNACK from broker")
|
||||
if mqtt_common.reason_code_value(connected["rc"]) != 0:
|
||||
raise ConnectionError(f"broker refused connection: rc={connected['rc']}")
|
||||
|
||||
info = client.publish(topic, payload=body, qos=1, retain=retain)
|
||||
info.wait_for_publish(timeout=PUBLISH_ACK_TIMEOUT)
|
||||
if not info.is_published():
|
||||
raise TimeoutError("publish not acknowledged within timeout")
|
||||
finally:
|
||||
client.loop_stop()
|
||||
try:
|
||||
client.disconnect()
|
||||
except Exception: # pragma: no cover - disconnect best effort
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_job_id(args) -> Optional[str]:
|
||||
if args.pick_pending:
|
||||
return registry.pick_pending(args.agent_session, args.registry_dir)
|
||||
return args.job
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Publish a Job event to MQTT")
|
||||
target = parser.add_mutually_exclusive_group(required=True)
|
||||
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",
|
||||
help="session label used with --pick-pending")
|
||||
parser.add_argument("--event", default="progress", choices=VALID_EVENTS)
|
||||
parser.add_argument("--detail", default="")
|
||||
parser.add_argument("--data", default=None, help="optional JSON object string")
|
||||
parser.add_argument("--retained", action="store_true",
|
||||
help="force retain=True (auto for completed/error)")
|
||||
parser.add_argument("--registry-dir", default=DEFAULT_REGISTRY_DIR)
|
||||
parser.add_argument("--attempts", type=int, default=3)
|
||||
parser.add_argument("-v", "--verbose", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
mqtt_common.setup_logging(logging.DEBUG if args.verbose else logging.WARNING)
|
||||
|
||||
# --- parse optional data JSON (parameter error -> exit 1) ---
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
if args.data:
|
||||
try:
|
||||
data = json.loads(args.data)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("--data must be a JSON object")
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
logger.error("invalid --data: %s", exc)
|
||||
return 1
|
||||
|
||||
job_id = _resolve_job_id(args)
|
||||
if not job_id:
|
||||
logger.error("no job to publish for (unknown --job or no pending job)")
|
||||
return 1
|
||||
|
||||
try:
|
||||
job = load_job(job_id, args.registry_dir)
|
||||
except FileNotFoundError as exc:
|
||||
logger.error("%s", exc)
|
||||
return 1
|
||||
|
||||
config = broker_config_from_job(job)
|
||||
topic = job.get("topic_prefix")
|
||||
topic = f"{topic}/events" if topic else events_topic_for(job_id)
|
||||
seq = next_seq(job_id, args.registry_dir)
|
||||
payload = build_payload(
|
||||
job_id=job_id,
|
||||
seq=seq,
|
||||
event=args.event,
|
||||
detail=args.detail,
|
||||
data=data,
|
||||
auth_token=job.get("auth_token"),
|
||||
)
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
retain = args.retained or args.event in TERMINAL_EVENTS
|
||||
|
||||
publish = with_retry(
|
||||
_publish_once,
|
||||
attempts=args.attempts,
|
||||
exceptions=(OSError, TimeoutError, ConnectionError, ValueError),
|
||||
)
|
||||
try:
|
||||
publish(config, topic, body, retain)
|
||||
except Exception as exc:
|
||||
logger.error("publish failed after %d attempts: %s", args.attempts, exc)
|
||||
return 2
|
||||
|
||||
# Persistent audit log: record the exact payload we put on the wire so the
|
||||
# publish is reproducible from the log alone. Best-effort (isolated inside
|
||||
# append_event) — never fails the publish.
|
||||
mqtt_common.append_event(job_id, {
|
||||
"event": "published",
|
||||
"source_event": args.event,
|
||||
"seq": seq,
|
||||
"topic": topic,
|
||||
"retain": retain,
|
||||
"timestamp": payload["timestamp"],
|
||||
"detail": args.detail,
|
||||
"payload": payload,
|
||||
})
|
||||
|
||||
# Best-effort side effects: registry status sync + (debug) event log. Never
|
||||
# fail the publish on these.
|
||||
registry.append_event(job_id, args.registry_dir, payload)
|
||||
new_status = EVENT_TO_STATUS.get(args.event)
|
||||
if new_status:
|
||||
try:
|
||||
mqtt_common.update_job_status(job_id, args.registry_dir, status=new_status)
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.warning("status sync failed: %s", exc)
|
||||
|
||||
logger.info("published %s seq=%d job=%s retain=%s", args.event, seq, job_id, retain)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,420 +0,0 @@
|
||||
"""Job registry for the multi-agent-mux-delegate-job skill.
|
||||
|
||||
A job record is the single source of truth for one delegated unit of work:
|
||||
its id, prompt, owning agent session, broker connection, timeouts, and status.
|
||||
Records live as ``<registry_dir>/<job_id>.json`` with an append-only event log
|
||||
``<registry_dir>/<job_id>.events.log`` and a shared ``<registry_dir>/.lock``.
|
||||
|
||||
Concurrency is handled via the fcntl lock in :mod:`mqtt_common` (PoC). For
|
||||
multi-host delegation, migrate to SQLite WAL — see references/registry.md.
|
||||
|
||||
Importable as a library and runnable as a CLI (``register``/``list``/``get``/
|
||||
``status``/``pick``) so the ``multi-agent-mux-delegate-job`` bash wrapper can shell out.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import mqtt_common
|
||||
from mqtt_common import (
|
||||
DEFAULT_REGISTRY_DIR,
|
||||
SCHEMA_VERSION,
|
||||
_atomic_write_record,
|
||||
_utcnow,
|
||||
broker_config_from_env,
|
||||
load_job,
|
||||
registry_lock,
|
||||
topic_prefix_for,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("delegate_job.registry")
|
||||
|
||||
TERMINAL_STATUSES = ("completed", "error", "cancelled")
|
||||
VALID_STATUSES = ("pending", "running", "completed", "error", "cancelled")
|
||||
|
||||
|
||||
def generate_job_id(bits: int = 32) -> str:
|
||||
"""PoC: 32-bit hex (8 chars). Production: 128-bit (full uuid4 hex)."""
|
||||
if bits >= 128:
|
||||
return uuid.uuid4().hex
|
||||
nibbles = max(1, bits // 4)
|
||||
return uuid.uuid4().hex[:nibbles]
|
||||
|
||||
|
||||
def register_job(
|
||||
prompt: str,
|
||||
agent: str = "claude-code",
|
||||
agent_session: str = "tmux:claude",
|
||||
broker: Optional[Dict[str, Any]] = None,
|
||||
timeout_sec: int = 3600,
|
||||
idle_timeout_sec: int = 120,
|
||||
registry_dir: str = DEFAULT_REGISTRY_DIR,
|
||||
job_id: Optional[str] = None,
|
||||
expected_artifacts: Optional[List[str]] = None,
|
||||
bits: int = 32,
|
||||
auth_token: Optional[str] = None,
|
||||
job_type: str = "direct",
|
||||
reviewer: Optional[str] = None,
|
||||
reviewer_session: Optional[str] = None,
|
||||
max_iterations: int = 5,
|
||||
) -> str:
|
||||
"""Create a new ``pending`` job record and return its id.
|
||||
|
||||
``broker`` defaults to the current environment's resolved broker block, so
|
||||
the registry alone is enough for ``publish_event.py`` to connect later.
|
||||
"""
|
||||
job_id = job_id or generate_job_id(bits)
|
||||
if broker is None:
|
||||
broker = broker_config_from_env().to_registry_block()
|
||||
if auth_token is None:
|
||||
# Auto-generate token if secure broker configuration (TLS or username) is detected
|
||||
if broker.get("tls") or broker.get("username"):
|
||||
import secrets
|
||||
auth_token = secrets.token_urlsafe(32)
|
||||
now = _utcnow()
|
||||
record: Dict[str, Any] = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"job_id": job_id,
|
||||
"status": "pending",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"prompt": prompt,
|
||||
"agent": agent,
|
||||
"agent_session": agent_session,
|
||||
"broker": broker,
|
||||
"topic_prefix": topic_prefix_for(job_id),
|
||||
"timeout_sec": int(timeout_sec),
|
||||
"idle_timeout_sec": int(idle_timeout_sec),
|
||||
"expected_artifacts": expected_artifacts or [],
|
||||
"last_seq": 0,
|
||||
"auth_token": auth_token,
|
||||
"job_type": job_type,
|
||||
"reviewer": reviewer,
|
||||
"reviewer_session": reviewer_session,
|
||||
"max_iterations": int(max_iterations),
|
||||
"iteration": 1,
|
||||
}
|
||||
with registry_lock(registry_dir):
|
||||
if mqtt_common._job_path(job_id, registry_dir).exists():
|
||||
raise FileExistsError(f"job already exists: {job_id}")
|
||||
_atomic_write_record(job_id, registry_dir, record)
|
||||
# Seed the persistent audit log (meta.json + status.json + a "registered"
|
||||
# event). Best-effort inside init_job_log — never blocks registration.
|
||||
mqtt_common.init_job_log(job_id, meta=record)
|
||||
logger.info("registered job %s (agent=%s session=%s)", job_id, agent, agent_session)
|
||||
return job_id
|
||||
|
||||
|
||||
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
|
||||
without two sessions grabbing the same job."""
|
||||
with registry_lock(registry_dir):
|
||||
candidates = []
|
||||
for record in _iter_records(registry_dir):
|
||||
if record.get("status") == "pending" and record.get("agent_session") == agent_session:
|
||||
candidates.append(record)
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(key=lambda r: r.get("created_at", ""))
|
||||
chosen = candidates[0]
|
||||
chosen["status"] = "running"
|
||||
chosen["updated_at"] = _utcnow()
|
||||
_atomic_write_record(chosen["job_id"], registry_dir, chosen)
|
||||
logger.info("session %s picked job %s", agent_session, chosen["job_id"])
|
||||
job_id = chosen["job_id"]
|
||||
updated_at = chosen["updated_at"]
|
||||
# pick_pending writes the record directly (not via update_job_status), so it
|
||||
# mirrors the pending->running transition into the audit log here. Best-effort.
|
||||
mqtt_common.update_logged_status(job_id, "running", updated_at=updated_at)
|
||||
mqtt_common.append_event(job_id, {
|
||||
"event": "status_changed",
|
||||
"from": "pending",
|
||||
"to": "running",
|
||||
"by": agent_session,
|
||||
"timestamp": updated_at,
|
||||
})
|
||||
return job_id
|
||||
|
||||
|
||||
def update_status(job_id: str, registry_dir: str, status: str) -> Dict[str, Any]:
|
||||
if status not in VALID_STATUSES:
|
||||
raise ValueError(f"invalid status {status!r}; expected one of {VALID_STATUSES}")
|
||||
return mqtt_common.update_job_status(job_id, registry_dir, status=status)
|
||||
|
||||
|
||||
def list_jobs(registry_dir: str = DEFAULT_REGISTRY_DIR, status: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
records = list(_iter_records(registry_dir))
|
||||
if status:
|
||||
records = [r for r in records if r.get("status") == status]
|
||||
records.sort(key=lambda r: r.get("created_at", ""))
|
||||
return records
|
||||
|
||||
|
||||
def append_event(job_id: str, registry_dir: str, payload: Dict[str, Any]) -> None:
|
||||
"""Append one event payload as a JSON line to the job's events log. Best
|
||||
effort, debug-only; failures are logged but never raised to the caller."""
|
||||
try:
|
||||
Path(registry_dir).mkdir(parents=True, exist_ok=True)
|
||||
log_path = Path(registry_dir) / f"{job_id}.events.log"
|
||||
with open(log_path, "a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
||||
except OSError as exc: # pragma: no cover - best effort
|
||||
logger.warning("could not append event for %s: %s", job_id, exc)
|
||||
|
||||
|
||||
# convenience re-export so callers can `from registry import load_job`
|
||||
__all__ = [
|
||||
"register_job", "pick_pending", "update_status", "load_job",
|
||||
"list_jobs", "append_event", "generate_job_id", "get_feedback",
|
||||
]
|
||||
|
||||
|
||||
def _iter_records(registry_dir: str):
|
||||
base = Path(registry_dir)
|
||||
if not base.exists():
|
||||
return
|
||||
for path in sorted(base.glob("*.json")):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
yield json.load(fh)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning("skipping unreadable record %s: %s", path, exc)
|
||||
|
||||
|
||||
def get_feedback(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR) -> str:
|
||||
"""Read the job's audit log or events log and return the detail of the last completed/error event."""
|
||||
# 1) Try the unified audit log first (ndjson) since it's written synchronously by the subscriber
|
||||
try:
|
||||
import mqtt_common
|
||||
logs_dir = mqtt_common.LOGS_DIR
|
||||
events = list(mqtt_common.iter_logged_events(job_id, logs_dir))
|
||||
for e in reversed(events):
|
||||
if e.get("source_event") in ("completed", "error"):
|
||||
return e.get("detail", "")
|
||||
if e.get("event") in ("completed", "error"):
|
||||
return e.get("detail", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) Fallback to local .events.log
|
||||
log_path = Path(registry_dir) / f"{job_id}.events.log"
|
||||
if log_path.exists():
|
||||
feedback = ""
|
||||
try:
|
||||
with open(log_path, "r", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(line)
|
||||
if payload.get("event") in ("completed", "error"):
|
||||
feedback = payload.get("detail", "")
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
if feedback:
|
||||
return feedback
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# CLI (so the bash wrapper can shell out without inline python)
|
||||
# --------------------------------------------------------------------------
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="multi-agent-mux-delegate-job registry CLI")
|
||||
parser.add_argument("--registry-dir", default=DEFAULT_REGISTRY_DIR)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
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("--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)")
|
||||
p_reg.add_argument("--artifact", action="append", default=[], dest="artifacts")
|
||||
p_reg.add_argument("--auth-token", default=None, help="HMAC auth token for the job (auto-generated if secure broker is detected)")
|
||||
p_reg.add_argument("--job-type", default="direct", choices=["direct", "loop", "discuss"])
|
||||
p_reg.add_argument("--reviewer", default=None)
|
||||
p_reg.add_argument("--reviewer-session", default=None)
|
||||
p_reg.add_argument("--max-iterations", type=int, default=5)
|
||||
|
||||
p_list = sub.add_parser("list", help="list jobs (optionally by status)")
|
||||
p_list.add_argument("--status", default=None)
|
||||
p_list.add_argument("--json", action="store_true")
|
||||
|
||||
p_get = sub.add_parser("get", help="print one job record as JSON")
|
||||
p_get.add_argument("--job", required=True)
|
||||
|
||||
p_status = sub.add_parser("status", help="set a job status")
|
||||
p_status.add_argument("--job", required=True)
|
||||
p_status.add_argument("--set", required=True, dest="status")
|
||||
|
||||
p_update = sub.add_parser("update", help="update a job record")
|
||||
p_update.add_argument("--job", required=True)
|
||||
p_update.add_argument("--status", default=None)
|
||||
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_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_logs = sub.add_parser(
|
||||
"logs",
|
||||
help="show the persistent audit log for a job, or --list every logged job",
|
||||
)
|
||||
p_logs.add_argument("job_id", nargs="?", default=None,
|
||||
help="job id whose events.ndjson to print")
|
||||
p_logs.add_argument("--list", action="store_true", dest="list_all",
|
||||
help="summarise every job under the logs dir instead")
|
||||
p_logs.add_argument("--logs-dir", default=None,
|
||||
help="override the audit-log root (default: $DELEGATE_JOB_LOGS_DIR "
|
||||
"or <cwd>/.mam/delegate_job_logs)")
|
||||
p_logs.add_argument("--tail", type=int, default=0,
|
||||
help="show only the last N events (0 = all)")
|
||||
p_logs.add_argument("--json", action="store_true",
|
||||
help="emit raw JSON lines / records instead of a table")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
mqtt_common.setup_logging(logging.INFO)
|
||||
args = _build_parser().parse_args(argv)
|
||||
rd = args.registry_dir
|
||||
|
||||
if args.command == "register":
|
||||
job_id = register_job(
|
||||
prompt=args.prompt,
|
||||
agent=args.agent,
|
||||
agent_session=args.agent_session,
|
||||
timeout_sec=args.timeout,
|
||||
idle_timeout_sec=args.idle_timeout,
|
||||
registry_dir=rd,
|
||||
expected_artifacts=args.artifacts,
|
||||
bits=args.bits,
|
||||
auth_token=args.auth_token,
|
||||
job_type=args.job_type,
|
||||
reviewer=args.reviewer,
|
||||
reviewer_session=args.reviewer_session,
|
||||
max_iterations=args.max_iterations,
|
||||
)
|
||||
print(job_id)
|
||||
return 0
|
||||
|
||||
if args.command == "list":
|
||||
records = list_jobs(rd, status=args.status)
|
||||
if args.json:
|
||||
print(json.dumps(records, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
if not records:
|
||||
print("(no jobs)")
|
||||
for r in records:
|
||||
print(f"{r['job_id']} {r.get('status','?'):10s} {r.get('agent_session','')}"
|
||||
f" {r.get('prompt','')[:48]}")
|
||||
return 0
|
||||
|
||||
if args.command == "get":
|
||||
try:
|
||||
print(json.dumps(load_job(args.job, rd), ensure_ascii=False, indent=2))
|
||||
except FileNotFoundError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
if args.command == "status":
|
||||
try:
|
||||
update_status(args.job, rd, args.status)
|
||||
except (FileNotFoundError, ValueError) as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
if args.command == "update":
|
||||
fields = {}
|
||||
if args.status is not None:
|
||||
fields["status"] = args.status
|
||||
if args.agent_session is not None:
|
||||
fields["agent_session"] = args.agent_session
|
||||
if args.prompt is not None:
|
||||
fields["prompt"] = args.prompt
|
||||
if args.iteration is not None:
|
||||
fields["iteration"] = args.iteration
|
||||
try:
|
||||
mqtt_common.update_job_status(args.job, rd, **fields)
|
||||
except FileNotFoundError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
if args.command == "get-feedback":
|
||||
print(get_feedback(args.job, rd))
|
||||
return 0
|
||||
|
||||
if args.command == "pick":
|
||||
job_id = pick_pending(args.agent_session, rd)
|
||||
if job_id is None:
|
||||
return 3 # no pending job for this session
|
||||
print(job_id)
|
||||
return 0
|
||||
|
||||
if args.command == "logs":
|
||||
return _cmd_logs(args)
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_logs(args) -> int:
|
||||
"""Pretty-print one job's events.ndjson, or summarise all logged jobs."""
|
||||
logs_dir = args.logs_dir or mqtt_common.LOGS_DIR
|
||||
|
||||
if args.list_all:
|
||||
jobs = mqtt_common.list_logged_jobs(logs_dir)
|
||||
if args.json:
|
||||
print(json.dumps(jobs, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
if not jobs:
|
||||
print(f"(no logged jobs under {logs_dir})")
|
||||
return 0
|
||||
for m in jobs:
|
||||
print(f"{m.get('job_id','?')} {m.get('status','?'):10s} "
|
||||
f"{m.get('created_at','-'):20s} {(m.get('prompt') or '')[:48]}")
|
||||
return 0
|
||||
|
||||
if not args.job_id:
|
||||
print("logs requires a <job_id> or --list", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
events = list(mqtt_common.iter_logged_events(args.job_id, logs_dir))
|
||||
if not events and not mqtt_common.job_log_dir(args.job_id, logs_dir).exists():
|
||||
print(f"no audit log for job {args.job_id} under {logs_dir}", file=sys.stderr)
|
||||
return 1
|
||||
if args.tail and args.tail > 0:
|
||||
events = events[-args.tail:]
|
||||
if args.json:
|
||||
for e in events:
|
||||
print(json.dumps(e, ensure_ascii=False))
|
||||
return 0
|
||||
for e in events:
|
||||
ts = e.get("logged_at") or e.get("timestamp") or "-"
|
||||
extra = e.get("detail") or e.get("to") or e.get("source_event") or ""
|
||||
print(f"{ts:24s} {e.get('event','?'):<16s} {extra}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,236 +0,0 @@
|
||||
---
|
||||
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."
|
||||
version: 1.0.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
environments: [kanban, terminal, tmux]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [agent, tmux, 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]
|
||||
---
|
||||
|
||||
# Agent Sessions Monitor — Live Reconciliation via Kanban Worker
|
||||
|
||||
> **Companion skills**: `multi-agent-mux-create` / `multi-agent-mux-resume` / `multi-agent-mux-stop` (mutators); this skill is the **observer**.
|
||||
> **Single source of truth**: `./.mam/agent-sessions.yaml`.
|
||||
|
||||
## What this skill does
|
||||
|
||||
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)
|
||||
- `~/.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
|
||||
- **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
|
||||
6. **Goal loop**: judge (auxiliary model) re-checks the card after each turn against the body to decide "is monitoring still wanted?". When the user says "stop monitoring" via comment, the worker blocks with `reason=stop-requested`.
|
||||
|
||||
## When to use
|
||||
|
||||
- You have multiple workspaces with tmux 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
|
||||
- A single, short session — overhead > benefit
|
||||
- You don't have a Kanban dispatcher running
|
||||
|
||||
## Dispatching the monitor
|
||||
|
||||
```bash
|
||||
# Goal-mode task: keeps running until the user signals stop
|
||||
hermes kanban create \
|
||||
--title "agent-sessions monitor (live reconcile)" \
|
||||
--assignee default \
|
||||
--workspace worktree \
|
||||
--branch wt/multi-agent-mux-monitor \
|
||||
--goal \
|
||||
--goal-max-turns 100 \
|
||||
--max-runtime 8h \
|
||||
--max-retries 1 \
|
||||
--skill multi-agent-mux-monitor \
|
||||
--body "$(cat <<'EOF'
|
||||
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
|
||||
5. For any drift, call `kanban_comment` with the diff
|
||||
6. Sleep 30 seconds, then repeat
|
||||
|
||||
If the user comments `stop` or `stop monitoring` on this card, call `kanban_block(reason="stop-requested by user")`.
|
||||
|
||||
If you find that a Claude session's `claude_session_id_own` is null but there's a new *.jsonl in the project dir, read the sessionId from the first line and update the YAML.
|
||||
|
||||
Use the helper script at .agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh for the YAML updates — it handles all the merge logic and writes a structured comment to this card.
|
||||
EOF
|
||||
)"
|
||||
```
|
||||
|
||||
## Helper script: `reconcile.sh`
|
||||
|
||||
The worker calls this script every 30s. It:
|
||||
|
||||
1. Diffs YAML ↔ tmux ↔ 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`
|
||||
|
||||
```bash
|
||||
# Reconcile + auto-update YAML (atomic, flock-guarded). Emits JSON drift to stdout.
|
||||
bash .agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh --once --emit-diff
|
||||
|
||||
# Read-only: compute drift WITHOUT writing the YAML (use for "what's running?" checks).
|
||||
bash .agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh --once --emit-diff --dry-run
|
||||
|
||||
# Push-based MQTT Monitor: listen to delegated job events on the broker and update the YAML instantly.
|
||||
# Bounded run that exits after 5 min idle, or 1 h wall-clock; falls back to polling if the broker is down.
|
||||
bash .agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh --subscribe --idle-timeout 300 --timeout 3600
|
||||
|
||||
# Persistent monitor (no timeouts): runs until interrupted; still polls if the broker is unreachable.
|
||||
bash .agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh --subscribe --idle-timeout 0
|
||||
```
|
||||
|
||||
Flags: `--once` (single pass), `--emit-diff` (print JSON), `--dry-run` (P1-E — no mutation), `--subscribe` (push-based MQTT subscription monitoring). `--subscribe` sub-flags: `--timeout N` (exit after N seconds of wall-clock; `0` = no limit, default), `--idle-timeout N` (exit after N seconds with no message; default `3600`, `0` = never idle-out). On a broker connection failure (connect error **or** non-zero CONNACK), `--subscribe` falls back to a polling loop that re-runs `--once --emit-diff` every `RECONCILE_POLL_INTERVAL` (default 15) seconds until `--timeout`. Terminal-event YAML updates are written through `lib.sh::atomic_dump_yaml` (flock + schema-validate + `.bak`). There are **no** `--workspace` / `--agent` / `--comment-card` flags; the worker turns the emitted JSON `drifts[]` into `kanban_comment` calls itself.
|
||||
|
||||
## Drift classes (what the script handles)
|
||||
|
||||
### Status Enum
|
||||
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
|
||||
|
||||
```
|
||||
YAML: status=running, pane.pid=201132, cmd=claude
|
||||
tmux: 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."
|
||||
```
|
||||
|
||||
**Skip-set**: the auto-terminate only fires for sessions whose status is `running`.
|
||||
Rows already in a deliberate end state — `terminated`, `archived`, or **`stopped`**
|
||||
(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.
|
||||
|
||||
### B. tmux alive, not in YAML → auto-register
|
||||
|
||||
```
|
||||
tmux: 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."
|
||||
```
|
||||
|
||||
### C. New session id materializes (claude first message sent)
|
||||
|
||||
```
|
||||
YAML: claude_session_id_own=null (placeholder)
|
||||
disk: ~/.claude/projects/.../b3a7...c2f.jsonl exists, mtime=now,
|
||||
first line sessionId=b3a7...c2f
|
||||
→ update claude_session_id_own=b3a7...c2f
|
||||
→ comment: "lab-landing-page-creator-claude: session id materialized b3a7...c2f"
|
||||
```
|
||||
|
||||
### D. Stale UUID (artifact gone)
|
||||
|
||||
```
|
||||
YAML: agent_identities.claude.session_id=87dc548e-...
|
||||
disk: ~/.claude/projects/.../87dc548e-...jsonl: missing
|
||||
→ flag in comment, but DO NOT delete from YAML
|
||||
(the user may have moved the file or the disk may be temporarily unavailable;
|
||||
only `--purge-conversation` should remove the id)
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## Worker body template (for `hermes kanban create --body`)
|
||||
|
||||
The `--body` of the dispatched task IS the worker's behavior spec. Here's a tested template:
|
||||
|
||||
```markdown
|
||||
# agent-sessions monitor
|
||||
|
||||
## Loop (every 30s)
|
||||
|
||||
1. Read agent-sessions.yaml
|
||||
2. Bash: `bash .agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh --emit-diff`
|
||||
3. Parse the JSON diff from stdout
|
||||
4. If `drifts` is non-empty:
|
||||
- For each drift, call `kanban_comment` with the diff message
|
||||
5. Bash: `sleep 30`
|
||||
6. Heartbeat every 5 min: `kanban_heartbeat(progress="alive, N drifts detected, last at <time>")`
|
||||
|
||||
## Stop condition
|
||||
|
||||
If `$HERMES_KANBAN_TASK` card has any comment containing "stop" or "stop monitoring" from a user:
|
||||
- Call `kanban_block(reason="stop-requested by user at <timestamp>")`
|
||||
|
||||
## Drift responses
|
||||
|
||||
- A. tmux dead + YAML running: auto-terminate YAML, comment
|
||||
- B. tmux 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 call multi-agent-mux-create or multi-agent-mux-stop — only the user initiates those
|
||||
- Do NOT call `git commit` / `git push`
|
||||
```
|
||||
|
||||
## Security: --subscribe on Public Brokers
|
||||
|
||||
When using `--subscribe` with the default PoC public broker
|
||||
(`broker.hivemq.com:1883`), be aware that:
|
||||
|
||||
1. **Wildcard subscription** means anyone can publish events to your job topics.
|
||||
2. **Auto-kill on terminal events** means a spoofed `completed` or `error`
|
||||
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.
|
||||
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.
|
||||
|
||||
## Verification (one-shot)
|
||||
|
||||
```bash
|
||||
# Run reconcile once and inspect output
|
||||
bash .agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh --emit-diff --once \
|
||||
| python3 -m json.tool
|
||||
```
|
||||
|
||||
## Related skills
|
||||
|
||||
- `kanban-worker` — base lifecycle for the dispatched worker
|
||||
- `kanban-orchestrator` — if you want to dispatch this monitor *from* an orchestrator, use this to know how to phrase the body
|
||||
@@ -1,644 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# reconcile.sh — multi-agent-mux-monitor 의 부속 스크립트
|
||||
# YAML ↔ tmux ↔ 디스크 artifact 간 drift 감지 (+ YAML 자동 갱신).
|
||||
#
|
||||
# Usage:
|
||||
# bash reconcile.sh --once --emit-diff # drift 감지 + 갱신
|
||||
# bash reconcile.sh --once --emit-diff --dry-run # drift 만 계산, 쓰기 안 함 (P1-E)
|
||||
#
|
||||
# --dry-run: 부수효과 없는 read-only. "지금 뭐 돌고 있지?" 질문에 안전.
|
||||
# multi-agent-mux-status 스킬이 이걸 재사용.
|
||||
#
|
||||
# 출력 (JSON): {timestamp, yaml_path, tmux_sessions_alive, tmux_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"
|
||||
|
||||
STATE_DIR="${AGENT_SESSIONS_STATE_DIR:-$WORKSPACE_ROOT/.cache/multi-agent-mux-monitor}"
|
||||
|
||||
ONCE=0
|
||||
EMIT_DIFF=0
|
||||
DRY_RUN=0
|
||||
SUBSCRIBE=0
|
||||
# --subscribe controls (review item 4): 0 = no overall timeout; idle default 3600s
|
||||
# (raised from 600s to align with job timeout defaults); idle 0 = never idle-out.
|
||||
SUB_TIMEOUT=0
|
||||
SUB_IDLE_TIMEOUT=3600
|
||||
POLL_INTERVAL="${RECONCILE_POLL_INTERVAL:-15}"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--once) ONCE=1; shift ;;
|
||||
--emit-diff) EMIT_DIFF=1; shift ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
--subscribe) SUBSCRIBE=1; shift ;;
|
||||
--timeout) SUB_TIMEOUT="$2"; shift 2 ;;
|
||||
--idle-timeout) SUB_IDLE_TIMEOUT="$2"; shift 2 ;;
|
||||
-h|--help) echo "Usage: $0 [--once] [--emit-diff] [--dry-run] [--subscribe [--timeout N] [--idle-timeout N]]"; exit 0 ;;
|
||||
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found" >&2; exit 1; }
|
||||
|
||||
if [ "$SUBSCRIBE" = "1" ]; then
|
||||
# Paths resolved relative to this script (review item 6): skills/ dir + lib.sh.
|
||||
SKILLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
LIB_SH="$SKILLS_DIR/lib.sh"
|
||||
# MQTT client lives in the project venv (has paho). All YAML work is delegated
|
||||
# to lib.sh::atomic_dump_yaml, which runs the system python3 (has PyYAML) — so
|
||||
# no single interpreter needs both paho and PyYAML (review items 4/5/6).
|
||||
PYBIN="$(_delegate_py_bin)"
|
||||
|
||||
# The MQTT subscribe loop exits 3 to signal "broker unavailable → poll instead".
|
||||
set +e
|
||||
YAML_PATH="$AGENT_SESSIONS_YAML" HOME_DIR="$HOME_DIR" CLAUDE_PROJECT_DIR="$CLAUDE_PROJECT_DIR" LOCAL_BIN="$LOCAL_BIN" \
|
||||
WORKSPACE_ROOT="$WORKSPACE_ROOT" SUB_TIMEOUT="$SUB_TIMEOUT" SUB_IDLE_TIMEOUT="$SUB_IDLE_TIMEOUT" \
|
||||
SKILLS_DIR="$SKILLS_DIR" LIB_SH="$LIB_SH" \
|
||||
"$PYBIN" - <<'PYEOF'
|
||||
import os, sys, json, time, subprocess
|
||||
|
||||
lib_sh = os.environ.get('LIB_SH', '')
|
||||
skills_dir = os.environ.get('SKILLS_DIR', '')
|
||||
yaml_path = os.environ.get('YAML_PATH', '')
|
||||
workspace_root = os.environ.get('WORKSPACE_ROOT', '')
|
||||
timeout = int(os.environ.get('SUB_TIMEOUT', '0') or '0') # 0 = no overall timeout
|
||||
idle_timeout = int(os.environ.get('SUB_IDLE_TIMEOUT', '3600') or '0') # 0 = no idle timeout
|
||||
|
||||
# Prevent duplicate wildcard subscribers for this workspace (concurrency race)
|
||||
import fcntl
|
||||
lock_file_path = os.path.join(workspace_root or '.', '.mam', 'monitor.lock')
|
||||
try:
|
||||
os.makedirs(os.path.dirname(lock_file_path), exist_ok=True)
|
||||
lock_file = open(lock_file_path, 'w')
|
||||
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
print("MQTT Monitor: another subscriber is already running for this workspace. Exiting.", flush=True)
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"MQTT Monitor: failed to acquire monitor lock ({e}). Exiting.", flush=True)
|
||||
sys.exit(1)
|
||||
|
||||
# Locate skills/multi-agent-mux-delegate-job/scripts to import mqtt_common — relative first, then
|
||||
# an upward walk from cwd. No hardcoded absolute path (review item 6).
|
||||
cand = os.path.join(skills_dir, 'multi-agent-mux-delegate-job', 'scripts') if skills_dir else ''
|
||||
if cand and os.path.isdir(cand):
|
||||
sys.path.append(cand)
|
||||
else:
|
||||
d = os.getcwd()
|
||||
while d and d != '/':
|
||||
hit = None
|
||||
for sub in (('.agents', 'skills', 'multi-agent-mux-delegate-job', 'scripts'), ('skills', 'multi-agent-mux-delegate-job', 'scripts'), ('multi-agent-mux-delegate-job', 'scripts')):
|
||||
p = os.path.join(d, *sub)
|
||||
if os.path.isdir(p):
|
||||
hit = p
|
||||
break
|
||||
if hit:
|
||||
sys.path.append(hit)
|
||||
break
|
||||
d = os.path.dirname(d)
|
||||
|
||||
import mqtt_common
|
||||
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),
|
||||
# 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'''
|
||||
import os, subprocess
|
||||
from datetime import datetime, timezone
|
||||
_jid = os.environ['MQTT_JID']
|
||||
_event = os.environ['MQTT_EVENT']
|
||||
_now = datetime.now(timezone.utc)
|
||||
_changed = False
|
||||
for s in d.get('tmux_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
|
||||
if not _changed:
|
||||
raise SystemExit(0) # nothing matched — skip the write entirely
|
||||
'''
|
||||
|
||||
|
||||
def handle_terminal(jid, event):
|
||||
if not lib_sh or not os.path.isfile(lib_sh):
|
||||
print('MQTT Monitor: lib.sh not found, cannot update YAML', flush=True)
|
||||
return
|
||||
env = dict(os.environ)
|
||||
env['MQTT_JID'] = jid
|
||||
env['MQTT_EVENT'] = event
|
||||
cmd = ['bash', '-c',
|
||||
'source "$LIB_SH"; atomic_dump_yaml "$YAML_PATH" MQTT_JID="$MQTT_JID" MQTT_EVENT="$MQTT_EVENT"']
|
||||
r = subprocess.run(cmd, input=_MUTATION, text=True, env=env, capture_output=True)
|
||||
if (r.stdout or '').strip():
|
||||
print(r.stdout.strip(), flush=True)
|
||||
if r.returncode != 0 and (r.stderr or '').strip():
|
||||
print('MQTT Monitor: atomic_dump_yaml stderr: ' + r.stderr.strip(), flush=True)
|
||||
|
||||
|
||||
state = {'last_msg': time.time(), 'connected': False, 'failed': False}
|
||||
last_seqs = {}
|
||||
|
||||
|
||||
def on_message(_client, _userdata, msg):
|
||||
state['last_msg'] = time.time()
|
||||
try:
|
||||
payload = json.loads(msg.payload.decode("utf-8"))
|
||||
jid = payload.get("job_id")
|
||||
event = payload.get("event")
|
||||
if not jid or not event:
|
||||
return
|
||||
|
||||
if workspace_root:
|
||||
registry_dir = os.path.join(workspace_root, '.mam', 'jobs')
|
||||
else:
|
||||
yaml_dir = os.path.dirname(yaml_path) if yaml_path else ""
|
||||
registry_dir = os.path.join(yaml_dir, 'jobs') if yaml_dir else '.mam/jobs'
|
||||
|
||||
try:
|
||||
job = registry.load_job(jid, registry_dir)
|
||||
except FileNotFoundError:
|
||||
# Silently ignore events for jobs not in the local registry
|
||||
return
|
||||
|
||||
expected_token = job.get("auth_token")
|
||||
if not mqtt_common.verify_hmac(payload, expected_token):
|
||||
print(f"MQTT Monitor: drop event for job {jid}: HMAC verify failed", flush=True)
|
||||
return
|
||||
|
||||
seq = payload.get("seq")
|
||||
if seq is None or not isinstance(seq, int):
|
||||
print(f"MQTT Monitor: drop event for job {jid}: missing or invalid seq", flush=True)
|
||||
return
|
||||
if seq <= last_seqs.get(jid, 0):
|
||||
print(f"MQTT Monitor: drop event for job {jid}: seq {seq} not monotonic (last {last_seqs.get(jid, 0)})", flush=True)
|
||||
return
|
||||
last_seqs[jid] = seq
|
||||
|
||||
# Append the event to events.ndjson audit trail
|
||||
mqtt_common.append_event(jid, {
|
||||
"event": "received",
|
||||
"source_event": event,
|
||||
"seq": seq,
|
||||
"topic": msg.topic,
|
||||
"timestamp": payload.get("timestamp"),
|
||||
"detail": payload.get("detail", ""),
|
||||
})
|
||||
|
||||
print(f"MQTT Monitor: recorded event {event} for job {jid} (seq={seq})", flush=True)
|
||||
|
||||
if event in ("completed", "error"):
|
||||
print(f"MQTT Monitor: received terminal event {event} for job {jid}", flush=True)
|
||||
handle_terminal(jid, event)
|
||||
except Exception as e:
|
||||
print(f"MQTT Monitor error parsing message: {e}", flush=True)
|
||||
|
||||
|
||||
def on_connect(_c, _u, _flags, reason_code, _props):
|
||||
rc = mqtt_common.reason_code_value(reason_code)
|
||||
if rc == 0:
|
||||
state['connected'] = True
|
||||
_c.subscribe("python/mqtt/jobs/+/events", qos=1)
|
||||
print("MQTT Monitor: subscribed to python/mqtt/jobs/+/events", flush=True)
|
||||
else:
|
||||
state['failed'] = True
|
||||
print(f"MQTT Monitor connection failed: rc={rc}", flush=True)
|
||||
|
||||
|
||||
cfg = mqtt_common.broker_config_from_env()
|
||||
client = mqtt_common.make_client("monitor_sub", cfg)
|
||||
client.on_message = on_message
|
||||
client.on_connect = on_connect
|
||||
print(f"MQTT Monitor: connecting to {cfg.host}:{cfg.port} (TLS={cfg.tls})...", flush=True)
|
||||
|
||||
# Connection failure → fall back to polling (review item 4).
|
||||
try:
|
||||
client.connect(cfg.host, cfg.port, cfg.keepalive)
|
||||
except Exception as e:
|
||||
print(f"MQTT Monitor: connect failed ({e}); falling back to polling", flush=True)
|
||||
sys.exit(3)
|
||||
|
||||
client.loop_start()
|
||||
_wait = time.time()
|
||||
while time.time() - _wait < 5 and not state['connected'] and not state['failed']:
|
||||
time.sleep(0.1)
|
||||
if not state['connected']:
|
||||
print("MQTT Monitor: broker did not accept connection; falling back to polling", flush=True)
|
||||
client.loop_stop()
|
||||
sys.exit(3)
|
||||
|
||||
start = time.time()
|
||||
try:
|
||||
while True:
|
||||
now = time.time()
|
||||
if timeout and (now - start) >= timeout:
|
||||
print(f"MQTT Monitor: --timeout {timeout}s reached, exiting", flush=True)
|
||||
break
|
||||
if idle_timeout and (now - state['last_msg']) >= idle_timeout:
|
||||
print(f"MQTT Monitor: --idle-timeout {idle_timeout}s reached, exiting", flush=True)
|
||||
break
|
||||
time.sleep(0.5)
|
||||
finally:
|
||||
client.loop_stop()
|
||||
try:
|
||||
client.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(0)
|
||||
PYEOF
|
||||
sub_rc=$?
|
||||
set -e
|
||||
|
||||
if [ "$sub_rc" = "3" ]; then
|
||||
echo "MQTT Monitor: broker unavailable — falling back to polling (interval ${POLL_INTERVAL}s)" >&2
|
||||
_self="$SKILLS_DIR/multi-agent-mux-monitor/scripts/reconcile.sh"
|
||||
_start=$(date +%s)
|
||||
while :; do
|
||||
bash "$_self" --once --emit-diff >/dev/null 2>&1 || true
|
||||
if [ "$SUB_TIMEOUT" != "0" ] && [ "$(( $(date +%s) - _start ))" -ge "$SUB_TIMEOUT" ]; then
|
||||
break
|
||||
fi
|
||||
sleep "$POLL_INTERVAL"
|
||||
done
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
|
||||
# 모든 비교 로직을 단일 소스로 둔다. dry-run 은 env_python(읽기전용), 그 외엔
|
||||
# atomic_dump_yaml(flock + temp+rename) 로 같은 소스를 돌린다. atomic 래퍼에서는
|
||||
# 'actions' 가 없으면 SystemExit(0) 으로 쓰기를 건너뛴다 (불필요한 재포맷 방지).
|
||||
read -r -d '' RECON_SRC <<'PYEOF' || true
|
||||
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']
|
||||
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
|
||||
|
||||
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'
|
||||
d = {}
|
||||
try:
|
||||
if os.path.exists(db_path):
|
||||
conn = sqlite3.connect(db_path, timeout=10.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 {}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
drifts = []
|
||||
actions = []
|
||||
|
||||
# === 현재 tmux 상태 — transient 실패를 'no sessions' 와 구분 (P1-E) ===
|
||||
tmux_sessions = []
|
||||
tmux_confirmed = True
|
||||
|
||||
# YAML 에 등록된 고유한 tmux_server 목록 수집 + 환경변수 TMUX_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'
|
||||
unique_servers.add(srv)
|
||||
|
||||
try:
|
||||
for srv in sorted(unique_servers):
|
||||
cmd = ['tmux']
|
||||
if srv != 'default':
|
||||
cmd += ['-L', srv]
|
||||
cmd += ['ls', '-F', '#{session_name}|#{session_created}']
|
||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if r.returncode == 0:
|
||||
for line in r.stdout.strip().split('\n'):
|
||||
if not line:
|
||||
continue
|
||||
name, created = line.split('|', 1)
|
||||
tmux_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
|
||||
except Exception:
|
||||
tmux_confirmed = False
|
||||
|
||||
|
||||
def pane_meta(session, srv):
|
||||
try:
|
||||
cmd = ['tmux']
|
||||
if srv != 'default':
|
||||
cmd += ['-L', srv]
|
||||
cmd += ['list-panes', '-t', session, '-F',
|
||||
'#{pane_pid}|#{pane_current_path}|#{pane_current_command}']
|
||||
out = subprocess.check_output(cmd, text=True)
|
||||
parts = out.strip().split('\n')[0].split('|')
|
||||
return {'pid': int(parts[0]), 'cwd': parts[1], 'cmd': parts[2]}
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
yaml_sessions = d.get('tmux_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}
|
||||
|
||||
# === drift A: tmux dead + YAML running → auto-terminate ===
|
||||
# tmux 응답을 확정했을 때만. transient 실패 시 모두 terminated 로 마크하지 않음 (P1-E)
|
||||
if tmux_confirmed:
|
||||
for s in yaml_sessions:
|
||||
name = s.get('name')
|
||||
if not name:
|
||||
continue
|
||||
# 'stopped' 도 deliberate한 종료 상태 — drift 로 보지 않고 그대로 둔다.
|
||||
# (없으면 tmux-dead stopped 세션을 'terminated' 로 덮어써 resumable 플래그가 소실됨)
|
||||
if s.get('status') in ('terminated', 'archived', 'stopped'):
|
||||
continue
|
||||
srv = s.get('tmux_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)'
|
||||
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."})
|
||||
actions.append(f"terminated: {name}")
|
||||
|
||||
# === drift B: tmux alive + not in YAML → auto-register ===
|
||||
if tmux_confirmed:
|
||||
for t in tmux_sessions:
|
||||
name = t['name']
|
||||
if name in yaml_session_names:
|
||||
continue
|
||||
if name.endswith('-creator-claude'):
|
||||
agent = 'claude'
|
||||
elif name.endswith('-creator-agy'):
|
||||
agent = 'agy'
|
||||
elif name.endswith('-creator-hermes'):
|
||||
agent = 'hermes'
|
||||
elif name.endswith('-creator-cline'):
|
||||
agent = 'cline'
|
||||
else:
|
||||
continue
|
||||
srv = t.get('server', 'default')
|
||||
pm = pane_meta(name, srv)
|
||||
if not pm:
|
||||
continue
|
||||
if agent == 'claude':
|
||||
cmd_full = 'claude --dangerously-skip-permissions'
|
||||
elif agent == 'agy':
|
||||
cmd_full = 'agy --dangerously-skip-permissions'
|
||||
elif agent == 'hermes':
|
||||
cmd_full = 'hermes'
|
||||
elif agent == 'cline':
|
||||
cmd_full = 'cline -i'
|
||||
server_opt = f"-L {srv} " if srv != 'default' else ""
|
||||
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,
|
||||
'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}',
|
||||
'last_visible_status': 'running',
|
||||
'last_visible_note': 'auto-registered by monitor',
|
||||
}
|
||||
if agent == 'claude':
|
||||
entry['tui'] = {'model': '(unknown — capture after first message)', 'provider': 'anthropic',
|
||||
'plan': '(unknown)', 'account': '(unknown)', 'version': '(unknown)'}
|
||||
entry['claude_session_id_own'] = None
|
||||
elif agent == 'agy':
|
||||
entry['child_pid'] = 0
|
||||
entry['agy_conversation_id_own'] = None
|
||||
entry['mcp_attachments'] = [
|
||||
{
|
||||
'name': 'stitch',
|
||||
'transport': 'mcp-remote',
|
||||
'endpoint': 'https://stitch.googleapis.com/mcp'
|
||||
}
|
||||
]
|
||||
elif agent == 'hermes':
|
||||
entry['child_pid'] = 0
|
||||
entry['hermes_conversation_id_own'] = None
|
||||
elif agent == 'cline':
|
||||
entry['child_pid'] = 0
|
||||
entry['cline_conversation_id_own'] = None
|
||||
d.setdefault('tmux_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']})."})
|
||||
actions.append(f"registered: {name}")
|
||||
|
||||
# === drift C: claude 새 session id materialize (per-row own id) ===
|
||||
for s in d.get('tmux_sessions', []):
|
||||
if not s.get('name', '').endswith('-creator-claude'):
|
||||
continue
|
||||
if s.get('status') != 'running':
|
||||
continue
|
||||
if s.get('claude_session_id_own'):
|
||||
continue
|
||||
cwd = (s.get('pane') or {}).get('cwd', '')
|
||||
if not cwd:
|
||||
continue
|
||||
proj_key = cwd.replace('/', '-').replace('_', '-')
|
||||
proj_dir = f"{claude_project_dir}/{proj_key}"
|
||||
if not os.path.isdir(proj_dir):
|
||||
continue
|
||||
jsonls = sorted(glob.glob(f"{proj_dir}/*.jsonl"), key=os.path.getmtime, reverse=True)
|
||||
if not jsonls:
|
||||
continue
|
||||
latest = jsonls[0]
|
||||
if time.time() - os.path.getmtime(latest) > 300:
|
||||
continue
|
||||
try:
|
||||
with open(latest) as f:
|
||||
first = f.readline().strip()
|
||||
if not first:
|
||||
continue
|
||||
sid = json.loads(first).get('sessionId')
|
||||
if not sid:
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
s['claude_session_id_own'] = sid
|
||||
drifts.append({'class': 'C', 'name': s['name'], 'msg': f"{s['name']}: session id materialized: {sid}"})
|
||||
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', []):
|
||||
if not s.get('name', '').endswith('-creator-agy'):
|
||||
continue
|
||||
if s.get('status') != 'running':
|
||||
continue
|
||||
if s.get('agy_conversation_id_own'):
|
||||
continue
|
||||
cwd = (s.get('pane') or {}).get('cwd', '')
|
||||
if not cwd:
|
||||
continue
|
||||
lc = f"{home}/.gemini/antigravity-cli/cache/last_conversations.json"
|
||||
if os.path.exists(lc):
|
||||
try:
|
||||
with open(lc) as f:
|
||||
lc_data = json.load(f)
|
||||
cid = lc_data.get(cwd)
|
||||
if cid and os.path.exists(f"{home}/.gemini/antigravity-cli/conversations/{cid}.db"):
|
||||
s['agy_conversation_id_own'] = cid
|
||||
drifts.append({'class': 'C', 'name': s['name'], 'msg': f"{s['name']}: conversation id materialized: {cid}"})
|
||||
actions.append(f"updated conversation id: {cid}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# === drift C (hermes): hermes 새 session id materialize (per-row own id) ===
|
||||
for s in d.get('tmux_sessions', []):
|
||||
if not s.get('name', '').endswith('-creator-hermes'):
|
||||
continue
|
||||
if s.get('status') != 'running':
|
||||
continue
|
||||
if s.get('hermes_conversation_id_own'):
|
||||
continue
|
||||
cwd = (s.get('pane') or {}).get('cwd', '')
|
||||
if not cwd:
|
||||
continue
|
||||
hdb = f"{home}/.hermes/state.db"
|
||||
if os.path.exists(hdb):
|
||||
try:
|
||||
conn = sqlite3.connect(hdb)
|
||||
r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (cwd,)).fetchone()
|
||||
conn.close()
|
||||
if r:
|
||||
cid = r[0]
|
||||
s['hermes_conversation_id_own'] = cid
|
||||
drifts.append({'class': 'C', 'name': s['name'], 'msg': f"{s['name']}: conversation id materialized: {cid}"})
|
||||
actions.append(f"updated conversation id: {cid}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# === drift C (cline): cline 새 session id materialize (per-row own id) ===
|
||||
for s in d.get('tmux_sessions', []):
|
||||
if not s.get('name', '').endswith('-creator-cline'):
|
||||
continue
|
||||
if s.get('status') != 'running':
|
||||
continue
|
||||
if s.get('cline_conversation_id_own'):
|
||||
continue
|
||||
cwd = (s.get('pane') or {}).get('cwd', '')
|
||||
if not cwd:
|
||||
continue
|
||||
sessions_dir = f"{home}/.cline/data/sessions"
|
||||
if os.path.isdir(sessions_dir):
|
||||
candidates = []
|
||||
for session_folder in glob.glob(f"{sessions_dir}/*"):
|
||||
if os.path.isdir(session_folder):
|
||||
folder_name = os.path.basename(session_folder)
|
||||
json_file = f"{session_folder}/{folder_name}.json"
|
||||
if os.path.exists(json_file):
|
||||
candidates.append(json_file)
|
||||
candidates.sort(key=os.path.getmtime, reverse=True)
|
||||
for j in candidates:
|
||||
try:
|
||||
with open(j) as f:
|
||||
sdata = json.load(f)
|
||||
if sdata.get('cwd') == cwd or sdata.get('workspace_root') == cwd:
|
||||
cid = sdata.get('session_id')
|
||||
if cid:
|
||||
s['cline_conversation_id_own'] = cid
|
||||
drifts.append({'class': 'C', 'name': s['name'], 'msg': f"{s['name']}: session id materialized: {cid}"})
|
||||
actions.append(f"updated session id: {cid}")
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# === drift D: stale UUID (cache 의 artifact 가 사라짐) — 보고만, 변경 없음 ===
|
||||
ai = d.get('agent_identities', {}) or {}
|
||||
cl = (ai.get('claude') or {})
|
||||
if cl.get('session_id'):
|
||||
sid = cl['session_id']
|
||||
if not glob.glob(f"{claude_project_dir}/*/{sid}.jsonl"):
|
||||
drifts.append({'class': 'D', 'name': '(claude identity cache)',
|
||||
'msg': f"stale UUID in agent_identities.claude.session_id: {sid} (jsonl missing)"})
|
||||
ag = (ai.get('agy') or {})
|
||||
if ag.get('conversation_id'):
|
||||
cid = ag['conversation_id']
|
||||
if not os.path.exists(f"{home}/.gemini/antigravity-cli/conversations/{cid}.db"):
|
||||
drifts.append({'class': 'D', 'name': '(agy identity cache)',
|
||||
'msg': f"stale UUID in agent_identities.agy.conversation_id: {cid} (.db missing)"})
|
||||
hr = (ai.get('hermes') or {})
|
||||
if hr.get('session_id'):
|
||||
sid = hr['session_id']
|
||||
hdb = f"{home}/.hermes/state.db"
|
||||
has_session = False
|
||||
if os.path.exists(hdb):
|
||||
try:
|
||||
conn = sqlite3.connect(hdb)
|
||||
r = conn.execute("SELECT 1 FROM sessions WHERE id=?", (sid,)).fetchone()
|
||||
conn.close()
|
||||
has_session = r is not None
|
||||
except Exception:
|
||||
pass
|
||||
if not has_session:
|
||||
drifts.append({'class': 'D', 'name': '(hermes identity cache)',
|
||||
'msg': f"stale UUID in agent_identities.hermes.session_id: {sid} (session missing from db)"})
|
||||
cn = (ai.get('cline') or {})
|
||||
if cn.get('session_id'):
|
||||
sid = cn['session_id']
|
||||
if not os.path.exists(f"{home}/.cline/data/sessions/{sid}/{sid}.json"):
|
||||
drifts.append({'class': 'D', 'name': '(cline identity cache)',
|
||||
'msg': f"stale UUID in agent_identities.cline.session_id: {sid} (session file missing)"})
|
||||
|
||||
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,
|
||||
'drifts': drifts,
|
||||
'actions': actions,
|
||||
}
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
|
||||
# atomic 래퍼: actions 가 없으면 쓰기를 건너뛴다. env_python(dry-run)에선 무해.
|
||||
if not actions:
|
||||
raise SystemExit(0)
|
||||
PYEOF
|
||||
|
||||
if [ "$DRY_RUN" = "1" ]; then
|
||||
printf '%s' "$RECON_SRC" | env_python "$AGENT_SESSIONS_YAML"
|
||||
else
|
||||
printf '%s' "$RECON_SRC" | atomic_dump_yaml "$AGENT_SESSIONS_YAML"
|
||||
fi
|
||||
@@ -1,151 +0,0 @@
|
||||
---
|
||||
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."
|
||||
version: 1.0.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
environments: [terminal, tmux]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [agent, tmux, 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]
|
||||
---
|
||||
|
||||
# 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) 참조.
|
||||
> **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.
|
||||
|
||||
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.
|
||||
|
||||
### Resuming a `stopped` session (`stopped → running`)
|
||||
|
||||
When a session was ended via `multi-agent-mux-stop` (which captures the ID and gracefully stops by default),
|
||||
its row is `status: stopped` with `resumable: true` and the conversation id
|
||||
already recorded in `claude_session_id_own` / `agy_conversation_id_own`. This is the
|
||||
ideal resume path:
|
||||
|
||||
- **tier-1, race-free**: because the stop command wrote the id into the row at stop
|
||||
time, `resolve_session_id.sh` resolves it via `find_workspace_uuid` tier-1 (the
|
||||
per-row own id) — no reliance on the mtime-based disk scan, so a concurrent
|
||||
session in another workspace can never shadow it.
|
||||
- On resume, `update_yaml_resumed.sh` transitions `stopped → running` and **clears
|
||||
the stop metadata** (`stopped_at`, `stopped_at_epoch`, `stop_reason`, `resumable`)
|
||||
along with the usual `terminated_at*` / `termination_mode` / `archived_at`, so the
|
||||
row reflects a clean running state with no stale end-of-session fields.
|
||||
|
||||
## UUID resolution order
|
||||
|
||||
`agent-sessions.yaml` is the *primary* source. The skill reads in this order:
|
||||
|
||||
1. **`agent-sessions.yaml` → `agent_identities.<agent>.session_id` (claude) / `conversation_id` (agy)** — explicit saved value
|
||||
2. **`agent-sessions.yaml` → `agent_identities.<agent>.session_jsonl` (claude) / `conversation_db` (agy)** — the on-disk artifact
|
||||
3. **Fallback: scan disk for the workspace's most recent conversation** (Note: `CLAUDE_PROJECT_DIR` overrides the default `~/.claude/projects/` path, and `HOME_DIR` overrides the `~` path) —
|
||||
- claude: `ls -t $CLAUDE_PROJECT_DIR/<workspace-key>/*.jsonl | head -1` and parse the `sessionId` from the first line
|
||||
- agy: `jq -r '."<workspace>"' $HOME_DIR/.gemini/antigravity-cli/cache/last_conversations.json`
|
||||
|
||||
If all three are empty → the workspace has no conversation yet. Fall back to `multi-agent-mux-create`.
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
WORKSPACE=/path/to/project
|
||||
AGENT=claude # or agy or hermes
|
||||
SESSION_NAME=<workspace>-creator-<agent> # same convention as multi-agent-mux-create
|
||||
|
||||
# 1. Resolve the session id
|
||||
UUID=$(bash .agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh \
|
||||
--workspace "$WORKSPACE" --agent "$AGENT")
|
||||
|
||||
if [ -z "$UUID" ]; then
|
||||
echo "No saved session for $WORKSPACE ($AGENT). Use multi-agent-mux-create first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Resolve the isolated tmux server name
|
||||
source .agents/skills/lib.sh
|
||||
export TMUX_SERVER_NAME="$(resolve_tmux_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"
|
||||
fi
|
||||
|
||||
# 3. Spawn new tmux session + run agent with the saved id
|
||||
case "$AGENT" in
|
||||
claude)
|
||||
tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" \
|
||||
"claude --dangerously-skip-permissions -r $UUID"
|
||||
# auto-handle trust / bypass dialogs
|
||||
sleep 5
|
||||
tmux send-keys -t "$SESSION_NAME" Enter 2>/dev/null || true
|
||||
sleep 3
|
||||
tmux send-keys -t "$SESSION_NAME" Down 2>/dev/null || true
|
||||
sleep 0.3
|
||||
tmux send-keys -t "$SESSION_NAME" Enter 2>/dev/null || true
|
||||
;;
|
||||
agy)
|
||||
tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" \
|
||||
"agy --dangerously-skip-permissions --conversation $UUID"
|
||||
;;
|
||||
hermes)
|
||||
tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" \
|
||||
"hermes --resume $UUID"
|
||||
;;
|
||||
esac
|
||||
|
||||
# 4. Update agent-sessions.yaml: status running, last_visible_status
|
||||
# (Also automatically publishes a `progress --detail "resumed"` event to the multi-agent-mux-delegate-job registry if a delegate_job_id exists)
|
||||
bash .agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh \
|
||||
--session "$SESSION_NAME" --uuid "$UUID"
|
||||
|
||||
# 5. Attach
|
||||
tmux attach -t "$SESSION_NAME"
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **`claude -r` requires the SAME project directory** — if the workspace path differs from when the session was created, claude will create a new project dir key (`-home-...-different-name`) and put the resume in a different location. Always `-c` (cd to workspace) before running.
|
||||
- **agy's `--conversation` flag name varies by version** — older versions used `--resume` or `-r`. Check `agy --help | grep -E "conversation|resume"` and use the right flag. v1.0.x: `--conversation`.
|
||||
- **The first message after resume might re-trigger TUI dialogs** — if the original session was created with `--dangerously-skip-permissions`, those flags are NOT persisted; you must re-apply them on resume. The script above re-passes them.
|
||||
- **Don't resume if the session is brand new and empty** — `multi-agent-mux-create` already set up an empty container; sending a probe message ("init") is the right way to materialize a session id, NOT `claude -r` with a placeholder.
|
||||
- **`agy --conversation <id>` will fail if the conversation was deleted from disk** — check `~/.gemini/antigravity-cli/conversations/<uuid>.db` exists before attempting resume. If missing, the conversation is gone; you need a fresh session via `multi-agent-mux-create`.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# 1. tmux alive with the right cmd
|
||||
tmux list-panes -t "$SESSION_NAME" -F 'cmd=#{pane_current_command} cwd=#{pane_current_path}'
|
||||
|
||||
# 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]
|
||||
print(f' status: {s[\"status\"]}')
|
||||
print(f' pane.cmd_full: {s[\"pane\"][\"cmd_full\"]}')
|
||||
"
|
||||
|
||||
# 3. TUI shows resumed conversation (capture-pane to verify)
|
||||
sleep 5
|
||||
tmux capture-pane -t "$SESSION_NAME" -p -S -30
|
||||
# look for the previous message at top of the buffer (claude) or last_visible_status set (agy)
|
||||
```
|
||||
|
||||
## 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)
|
||||
@@ -1,40 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# resolve_session_id.sh — multi-agent-mux-resume 의 부속 스크립트
|
||||
# Usage:
|
||||
# bash resolve_session_id.sh --workspace <path> --agent <claude|agy>
|
||||
# 출력: stdout 으로 UUID 한 줄 (없으면 빈 줄 + exit 0)
|
||||
#
|
||||
# P0-C: 전역 agent_identities 를 즉시 반환하지 않는다. lib.sh::find_workspace_uuid
|
||||
# 가 워크스페이스 격리된 해결 경로(per-row own id -> 디스크 스캔 -> cwd 일치하는
|
||||
# cache)만 사용. 다른 워크스페이스의 UUID 를 절대 반환하지 않음.
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 --workspace <path> --agent <claude|agy>
|
||||
Outputs the resolved UUID on stdout (empty if not found).
|
||||
EOF
|
||||
}
|
||||
|
||||
WORKSPACE=""
|
||||
AGENT=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--workspace) WORKSPACE="$2"; shift 2 ;;
|
||||
--agent) AGENT="$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; }
|
||||
case "$AGENT" in
|
||||
claude|agy|hermes|cline) ;;
|
||||
*) echo "ERROR: --agent must be claude, agy, hermes, or cline" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
find_workspace_uuid "$WORKSPACE" "$AGENT"
|
||||
@@ -1,164 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# update_yaml_resumed.sh — multi-agent-mux-resume 의 부속 스크립트
|
||||
# Resume 한 세션의 agent-sessions.yaml 엔트리를 status=running + resume 메타로 갱신.
|
||||
# resume UUID 를 per-row own id (claude_session_id_own / agy_conversation_id_own)
|
||||
# 에 박는다 — agent_identities 전역은 더 이상 primary 아님 (cache 로 강등, P0-C/단계 e).
|
||||
#
|
||||
# Usage: bash update_yaml_resumed.sh --session <name> --uuid <id> [--agent claude|agy]
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 --session <name> --uuid <id> [--agent claude|agy]
|
||||
EOF
|
||||
}
|
||||
|
||||
SESSION_NAME=""
|
||||
UUID=""
|
||||
AGENT=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--session) SESSION_NAME="$2"; shift 2 ;;
|
||||
--uuid) UUID="$2"; shift 2 ;;
|
||||
--agent) AGENT="$2"; shift 2 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; exit 2; }
|
||||
[ -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")"
|
||||
|
||||
# --agent 미지정 시 이름 suffix 로 fallback (P1-F: 가능하면 --agent 명시)
|
||||
if [ -z "$AGENT" ]; then
|
||||
case "$SESSION_NAME" in
|
||||
*-creator-claude) AGENT=claude ;;
|
||||
*-creator-agy) AGENT=agy ;;
|
||||
*-creator-hermes) AGENT=hermes ;;
|
||||
*-creator-cline) AGENT=cline ;;
|
||||
*) echo "ERROR: cannot infer agent from '$SESSION_NAME'; pass --agent" >&2; exit 2 ;;
|
||||
esac
|
||||
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)
|
||||
PANE_PID="${PANE_PID:-}"
|
||||
CHILD_PID=0
|
||||
if { [ "$AGENT" = "agy" ] || [ "$AGENT" = "hermes" ] || [ "$AGENT" = "cline" ]; } && [ -n "$PANE_PID" ]; then
|
||||
CHILD_PID=$(pgrep -P "$PANE_PID" -x "$AGENT" 2>/dev/null | head -1 || true)
|
||||
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
|
||||
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=10.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', []):
|
||||
if s.get('name') == name:
|
||||
print(s.get('delegate_job_id', '') or '')
|
||||
raise SystemExit(0)
|
||||
raise SystemExit(0)
|
||||
PYEOF
|
||||
)
|
||||
|
||||
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
|
||||
SESSION_NAME="$SESSION_NAME" UUID="$UUID" AGENT="$AGENT" NOW_ISO="$NOW_ISO" \
|
||||
PANE_PID="$PANE_PID" CHILD_PID="$CHILD_PID" <<'PYEOF'
|
||||
name = os.environ['SESSION_NAME']
|
||||
uuid = os.environ['UUID']
|
||||
agent = os.environ['AGENT']
|
||||
now = os.environ['NOW_ISO']
|
||||
pane_pid = os.environ.get('PANE_PID', '')
|
||||
|
||||
target = None
|
||||
for s in d.get('tmux_sessions', []):
|
||||
if s.get('name') == name:
|
||||
target = s
|
||||
break
|
||||
|
||||
if target is None:
|
||||
print(f"ERROR: session not in YAML: {name}", flush=True)
|
||||
raise SystemExit(1)
|
||||
|
||||
target['status'] = 'running'
|
||||
target.pop('terminated_at', None)
|
||||
target.pop('terminated_at_epoch', None)
|
||||
target.pop('termination_mode', None)
|
||||
target.pop('archived_at', None)
|
||||
# stop 메타도 정리 — resume 하면 더 이상 stopped 상태가 아니므로 잔존 필드를 제거.
|
||||
target.pop('stopped_at', None)
|
||||
target.pop('stopped_at_epoch', None)
|
||||
target.pop('stop_reason', None)
|
||||
target.pop('resumable', None)
|
||||
target['last_visible_status'] = f'resumed conversation {uuid} at {now}'
|
||||
|
||||
target.setdefault('pane', {})
|
||||
if pane_pid.isdigit():
|
||||
target['pane']['pid'] = int(pane_pid)
|
||||
|
||||
if agent == 'claude':
|
||||
target['pane']['cmd'] = 'claude'
|
||||
target['pane']['cmd_full'] = f'claude --dangerously-skip-permissions -r {uuid}'
|
||||
target['claude_session_id_own'] = uuid
|
||||
elif agent == 'agy':
|
||||
target['pane']['cmd'] = 'agy'
|
||||
target['pane']['cmd_full'] = f'agy --dangerously-skip-permissions --conversation {uuid}'
|
||||
target['agy_conversation_id_own'] = uuid
|
||||
cp = os.environ.get('CHILD_PID', '0')
|
||||
if cp.isdigit() and int(cp) > 0:
|
||||
target['child_pid'] = int(cp)
|
||||
elif agent == 'hermes':
|
||||
target['pane']['cmd'] = 'hermes'
|
||||
target['pane']['cmd_full'] = f'hermes --resume {uuid}'
|
||||
target['hermes_conversation_id_own'] = uuid
|
||||
cp = os.environ.get('CHILD_PID', '0')
|
||||
if cp.isdigit() and int(cp) > 0:
|
||||
target['child_pid'] = int(cp)
|
||||
elif agent == 'cline':
|
||||
target['pane']['cmd'] = 'cline'
|
||||
target['pane']['cmd_full'] = f'cline -i --id {uuid}'
|
||||
target['cline_conversation_id_own'] = uuid
|
||||
cp = os.environ.get('CHILD_PID', '0')
|
||||
if cp.isdigit() and int(cp) > 0:
|
||||
target['child_pid'] = int(cp)
|
||||
|
||||
snap = d.setdefault('snapshot', {})
|
||||
snap['taken_at'] = now
|
||||
snap.pop('terminated_at', None)
|
||||
snap.pop('terminated_at_epoch', None)
|
||||
|
||||
print(f"updated: {name} status=running (resume id -> per-row own id)", flush=True)
|
||||
PYEOF
|
||||
|
||||
delegate_publish_event "$DELEGATE_JOB_ID" progress "resumed"
|
||||
@@ -1,124 +0,0 @@
|
||||
---
|
||||
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."
|
||||
version: 1.0.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
environments: [terminal, tmux]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [agent, tmux, 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]
|
||||
---
|
||||
|
||||
# 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` 환경변수를 수동으로 지정하지 않아도 모든 격리 서버의 세션 상태를 통합 조회합니다.
|
||||
> **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.**
|
||||
|
||||
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 python3
|
||||
test -f .mam/agent-sessions.yaml
|
||||
```
|
||||
|
||||
If `agent-sessions.yaml` doesn't exist or is malformed → print clear error, exit 1. **Do not create it.** (Use `multi-agent-mux-create` first.)
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
bash .agents/skills/multi-agent-mux-status/scripts/status.sh [--json]
|
||||
```
|
||||
|
||||
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`)
|
||||
- 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"
|
||||
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)
|
||||
========================================================================================================================================
|
||||
NAME SERVER YAML TMUX 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 -
|
||||
lab-paper-pdf2md-creator-claude default running alive claude scan - - -
|
||||
========================================================================================================================================
|
||||
```
|
||||
|
||||
## Output format (`--json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"yaml_path": "...",
|
||||
"tmux_sessions_alive": ["..."],
|
||||
"yaml_entries": [...],
|
||||
"rows": [
|
||||
{
|
||||
"name": "lab-landing-page-creator-claude",
|
||||
"yaml_status": "running",
|
||||
"tmux_alive": true,
|
||||
"pane_cmd": "claude",
|
||||
"pane_cwd": "/home/.../refer_landing_page",
|
||||
"resume_uuid_on_disk": true,
|
||||
"drift": null
|
||||
},
|
||||
{
|
||||
"name": "lab-landing-page-creator-agy",
|
||||
"yaml_status": "terminated",
|
||||
"tmux_alive": false,
|
||||
"drift": "yaml-says-terminated-but-disk-uuid-still-present"
|
||||
}
|
||||
],
|
||||
"unregistered": [],
|
||||
"drifts": []
|
||||
}
|
||||
```
|
||||
|
||||
## Drift classes (read-only — never mutates)
|
||||
|
||||
| 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." |
|
||||
| `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.
|
||||
- **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.
|
||||
|
||||
## When to use
|
||||
|
||||
- "Is the claude session still running?" → this skill, not the monitor
|
||||
- "What UUID does this workspace have?" → this skill
|
||||
- "Is there drift between YAML and reality?" → this skill, then dispatch monitor or fix manually
|
||||
- Quick sanity check before dispatching a long Kanban task
|
||||
|
||||
## When NOT to use
|
||||
|
||||
- Continuous live tracking → `multi-agent-mux-monitor` (Kanban worker)
|
||||
- Recovering from corruption → manual edit + `.bak` restore
|
||||
- Polling more than once a minute → `multi-agent-mux-monitor` (it dedupes)
|
||||
@@ -1,140 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# status.sh — multi-agent-mux-status 의 부속 스크립트 (READ-ONLY)
|
||||
# 한 번 호출로 현재 agent 세션 상태표를 출력. 부수효과 없음.
|
||||
# reconcile.sh --dry-run 을 재사용해 drift 를 계산하고 (P1-E), YAML/디스크에서
|
||||
# 보강한 표를 그린다. YAML 을 절대 수정하지 않는다.
|
||||
#
|
||||
# Usage: bash status.sh [--json]
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
|
||||
|
||||
RECONCILE="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/multi-agent-mux-monitor/scripts/reconcile.sh"
|
||||
|
||||
JSON=0
|
||||
[ "${1:-}" = "--json" ] && JSON=1
|
||||
|
||||
[ -f "$AGENT_SESSIONS_YAML" ] || { echo "ERROR: $AGENT_SESSIONS_YAML not found. Run multi-agent-mux-create first." >&2; exit 1; }
|
||||
|
||||
# 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'
|
||||
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=10.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 {}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
alive = set(drift.get('tmux_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', '.')
|
||||
# Candidate locations (review item 6: project-root-relative, no hardcoded abs paths):
|
||||
# 1) cwd-relative registry 2) project-root registry 3) project-root audit log
|
||||
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 = d.get('tmux_sessions', [])
|
||||
print(f"agent-sessions status — {drift['timestamp']} (tmux_confirmed={drift['tmux_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("-" * 136)
|
||||
if not sessions:
|
||||
print("(no sessions registered)")
|
||||
for s in sessions:
|
||||
name = s.get('name', '?')
|
||||
server = s.get('tmux_server') or 'default'
|
||||
status = s.get('status', '?')
|
||||
tmux = '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}")
|
||||
# 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]
|
||||
if extra:
|
||||
print("-" * 136)
|
||||
for dr in extra:
|
||||
print(f" [{dr['class']}] {dr['msg']}")
|
||||
print("=" * 136)
|
||||
print(f"alive tmux: {sorted(alive)}")
|
||||
PYEOF
|
||||
@@ -1,136 +0,0 @@
|
||||
---
|
||||
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."
|
||||
version: 1.0.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
environments: [terminal, tmux]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [agent, tmux, 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
|
||||
|
||||
> **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` 환경변수를 수동으로 지정할 필요가 없습니다.
|
||||
> **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:
|
||||
|
||||
- The tmux 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 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.
|
||||
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.
|
||||
|
||||
## Pre-flight
|
||||
|
||||
```bash
|
||||
SESSION_NAME=<workspace>-creator-<agent> # convention
|
||||
AGENT_SESSIONS_YAML=.mam/agent-sessions.yaml
|
||||
|
||||
# 1) Session is registered?
|
||||
python3 -c "
|
||||
import yaml
|
||||
d = yaml.safe_load(open('$AGENT_SESSIONS_YAML'))
|
||||
names = [s['name'] for s in d.get('tmux_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)
|
||||
"
|
||||
|
||||
# 2) Already stopped?
|
||||
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]
|
||||
print(s.get('status', 'unknown'))
|
||||
")
|
||||
if [ "$ALREADY" = "stopped" ]; then
|
||||
echo "Already stopped."
|
||||
fi
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
```bash
|
||||
# 1. Stop gracefully (default — captures ID, shuts down safely, status=stopped)
|
||||
bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
|
||||
--session "$SESSION_NAME"
|
||||
|
||||
# 2. Stop gracefully + record a custom stop reason
|
||||
bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
|
||||
--session "$SESSION_NAME" --reason api_error
|
||||
|
||||
# 3. Stop gracefully + clean up on-disk conversation (DANGEROUS)
|
||||
# — this prevents any future resume (status=terminated, resumable=false).
|
||||
bash .agents/skills/multi-agent-mux-stop/scripts/stop_session.sh \
|
||||
--session "$SESSION_NAME" --purge-conversation
|
||||
```
|
||||
|
||||
**Idempotency**: if the row is already `status: stopped`, the script prints `already stopped (...)` and exits 0 — re-running is a safe no-op.
|
||||
|
||||
### State machine
|
||||
|
||||
```
|
||||
running ──(stop default / --reason)────────► stopped (resumable:true, conv preserved)
|
||||
running ──(stop --purge-conversation --yes)► terminated (resumable:false, conv deleted)
|
||||
stopped ──(stop default … again)───────────► stopped (idempotent no-op)
|
||||
```
|
||||
|
||||
Fields written in STOP mode: `status: stopped`, `stopped_at`, `stopped_at_epoch`, `stop_reason`, `termination_mode: graceful`, `claude_session_id_own`/`agy_conversation_id_own` and `resumable: true`.
|
||||
|
||||
If `--purge-conversation` is used: `status: terminated`, `terminated_at`, `terminated_at_epoch`, `termination_mode: purge` and `resumable: false`.
|
||||
|
||||
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)
|
||||
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
|
||||
7. If `delegate_job_id` is set, publishes a `completed` event to the multi-agent-mux-delegate-job registry
|
||||
|
||||
## 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.
|
||||
- **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.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
# 1. tmux gone
|
||||
tmux has-session -t "$SESSION_NAME" 2>/dev/null && echo "STILL ALIVE" || echo "OK: tmux 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]
|
||||
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\"]}')
|
||||
print(f' preserved: pane.pid={s[\"pane\"][\"pid\"]}, cmd={s[\"pane\"][\"cmd\"]}, cwd={s[\"pane\"][\"cwd\"]}')
|
||||
"
|
||||
|
||||
# 3. (if --purge-conversation) disk artifacts gone
|
||||
[ -f "${CLAUDE_PROJECT_DIR:-$HOME/.claude/projects}/<projkey>/<uuid>.jsonl" ] && echo "WARN: jsonl still exists" || echo "OK: jsonl purged"
|
||||
```
|
||||
|
||||
## 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.
|
||||
- **Replacing an existing session with a new one** → `multi-agent-mux-stop` first, then `multi-agent-mux-create`.
|
||||
@@ -1,353 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# stop_session.sh — multi-agent-mux-stop 의 부속 스크립트
|
||||
# Usage:
|
||||
# bash stop_session.sh --session <name> [--agent claude|agy] \
|
||||
# [--mode soft|hard] [--purge-conversation] [--yes]
|
||||
#
|
||||
# mode:
|
||||
# soft — YAML 을 status=archived 로 마크, tmux 세션은 그대로 둠 (P1-A:
|
||||
# terminated 는 tmux 가 실제로 죽은 상태에만 사용)
|
||||
# hard — tmux kill-session + YAML status=terminated
|
||||
# --purge-conversation: --mode hard 일 때만. 삭제 대상 세션의 *워크스페이스에
|
||||
# 격리된* conversation artifact 만 삭제 (P0-C). 전역
|
||||
# agent_identities 를 참조하지 않음. resume 불가.
|
||||
#
|
||||
# Stop extension (Option A — stop 확장, 새 6번째 스킬 없이 stop 의미론 흡수):
|
||||
# --capture-id — kill 직전에 이 워크스페이스의 conversation id 를 row 에 확정
|
||||
# 기록 (claude_session_id_own / agy_conversation_id_own) →
|
||||
# 다음 resume 이 tier-1(race-free) 로 복원. find_workspace_uuid
|
||||
# 재사용 (per-row -> workspace-scoped disk scan -> cache).
|
||||
# --reason R — 상태 전이 사유 (stop_reason). 기본값 manual_stop.
|
||||
# --graceful — kill-session 즉시 종료 대신 send-keys 로 정상 종료 유도 →
|
||||
# 3초 대기 → 미종료 시 kill-session(SIGTERM) → 5초 → SIGKILL.
|
||||
# 위 세 옵션 중 하나라도 주면 STOP 모드: status 가 terminated 가 아니라 stopped
|
||||
# 로 전이 (running -> stopped). 멱등: 이미 stopped 면 no-op + exit 0.
|
||||
# 옵션 미지정 시 기존 hard/soft 동작 그대로 (backward compatible).
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 = success (or already-stopped no-op) | 1 = YAML not found / not registered
|
||||
# 2 = invalid args | 3 = interactive confirmation required (--yes 누락)
|
||||
set -euo pipefail
|
||||
|
||||
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $0 --session <name> [--agent claude|agy] [--purge-conversation] [--yes] [--reason <reason>]
|
||||
|
||||
Stop arguments:
|
||||
--reason <reason> — stop_reason field (default: manual_stop)
|
||||
(idempotent: stopping an already-stopped session is a no-op with exit 0)
|
||||
EOF
|
||||
}
|
||||
|
||||
SESSION_NAME=""
|
||||
AGENT=""
|
||||
PURGE=0
|
||||
YES=0
|
||||
CAPTURE_ID=1
|
||||
GRACEFUL=1
|
||||
REASON="manual_stop"
|
||||
STOP_MODE=1
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--session) SESSION_NAME="$2"; shift 2 ;;
|
||||
--agent) AGENT="$2"; shift 2 ;;
|
||||
--purge-conversation) PURGE=1; shift ;;
|
||||
--yes) YES=1; shift ;;
|
||||
--reason) REASON="$2"; shift 2 ;;
|
||||
--mode|--capture-id|--graceful)
|
||||
echo "ERROR: $1 option is deprecated. Stop now always stops gracefully and captures IDs." >&2
|
||||
exit 2
|
||||
;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "ERROR: unknown arg: $1" >&2; usage; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
[ -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")"
|
||||
|
||||
# --agent 미지정 시 이름 suffix 로 fallback (P1-F)
|
||||
if [ -z "$AGENT" ]; then
|
||||
case "$SESSION_NAME" in
|
||||
*-creator-claude) AGENT=claude ;;
|
||||
*-creator-agy) AGENT=agy ;;
|
||||
*-creator-hermes) AGENT=hermes ;;
|
||||
*-creator-cline) AGENT=cline ;;
|
||||
*) echo "ERROR: cannot infer agent from '$SESSION_NAME'; pass --agent" >&2; exit 2 ;;
|
||||
esac
|
||||
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
|
||||
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=10.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', []):
|
||||
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
|
||||
) || {
|
||||
echo "ERROR: session '$SESSION_NAME' not in $AGENT_SESSIONS_YAML" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
TARGET_CWD=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("cwd",""))')
|
||||
DELEGATE_JOB_ID=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("job_id",""))')
|
||||
|
||||
# 멱등성: STOP 모드에서 이미 stopped 인 세션이면 no-op + exit 0
|
||||
if [ "$STOP_MODE" = "1" ]; then
|
||||
if STOPPED_INFO=$(is_already_stopped "$SESSION_NAME"); then
|
||||
echo "already stopped (status=stopped, $STOPPED_INFO) — no-op"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# purge 확인
|
||||
if [ "$PURGE" = "1" ] && [ "$YES" != "1" ]; then
|
||||
echo "DANGER: --purge-conversation will DELETE this workspace's on-disk conversation."
|
||||
echo " workspace: ${TARGET_CWD:-<unknown>}"
|
||||
echo " This means: no future multi-agent-mux-resume for this session."
|
||||
echo " Re-run with --yes to confirm."
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# purge 대상 UUID 를 워크스페이스 격리해서 해결 (P0-C — 전역 참조 금지)
|
||||
PURGE_UUID=""
|
||||
if [ "$PURGE" = "1" ] && [ -n "$TARGET_CWD" ]; then
|
||||
PURGE_UUID=$(find_workspace_uuid "$TARGET_CWD" "$AGENT" || true)
|
||||
fi
|
||||
|
||||
NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
NOW_EPOCH=$(date +%s)
|
||||
|
||||
# tmux 상태 + 마지막 TUI 스냅샷 (살아있을 때만; capture-pane 내용은 env 로만 전달)
|
||||
TMUX_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)
|
||||
fi
|
||||
|
||||
# --capture-id: kill 직전에 conversation id 를 해결 (process/jsonl 이 아직 살아있을 때).
|
||||
# find_workspace_uuid 가 tier-1(row) -> tier-2(workspace-scoped disk scan) -> tier-3(cache)
|
||||
# 를 알아서 시도하므로 tmux 생사와 무관하게 동작.
|
||||
CAPTURED_UUID=""
|
||||
if [ "$CAPTURE_ID" = "1" ] && [ -n "$TARGET_CWD" ]; then
|
||||
CAPTURED_UUID=$(capture_conversation_id "$AGENT" "$TARGET_CWD" || true)
|
||||
if [ -n "$CAPTURED_UUID" ]; then
|
||||
echo "captured conversation id: $CAPTURED_UUID"
|
||||
else
|
||||
echo "WARN: --capture-id requested but no conversation id resolved (nothing on disk yet)"
|
||||
fi
|
||||
fi
|
||||
|
||||
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)
|
||||
case "$AGENT" in
|
||||
claude) exitkey="/exit" ;;
|
||||
agy) exitkey="Exit" ;;
|
||||
hermes) exitkey="/exit" ;;
|
||||
cline) exitkey="/exit" ;;
|
||||
*) exitkey="/exit" ;;
|
||||
esac
|
||||
echo "graceful: send-keys '$exitkey' to $SESSION_NAME"
|
||||
tmux send-keys -t "$SESSION_NAME" "$exitkey" Enter 2>/dev/null || true
|
||||
sleep 3
|
||||
if ! tmux 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
|
||||
sleep 5
|
||||
if ! tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
|
||||
echo "graceful: terminated after kill-session"
|
||||
return 0
|
||||
fi
|
||||
echo "graceful: STILL alive → SIGKILL fallback (pane pid $pane_pid)"
|
||||
[ -n "$pane_pid" ] && kill -9 "$pane_pid" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# tmux 종료: graceful 이면 폴백 체인, 아니면 기존 hard kill.
|
||||
if [ "$GRACEFUL" = "1" ] && [ "$TMUX_ALIVE" = "1" ]; then
|
||||
graceful_stop
|
||||
elif [ "$TMUX_ALIVE" = "1" ]; then
|
||||
tmux kill-session -t "$SESSION_NAME"
|
||||
echo "killed tmux: $SESSION_NAME"
|
||||
else
|
||||
echo "tmux already dead, just updating YAML"
|
||||
fi
|
||||
|
||||
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
|
||||
SESSION_NAME="$SESSION_NAME" AGENT="$AGENT" PURGE="$PURGE" \
|
||||
NOW_ISO="$NOW_ISO" NOW_EPOCH="$NOW_EPOCH" LAST_STATUS="$LAST_STATUS" \
|
||||
PURGE_UUID="$PURGE_UUID" TARGET_CWD="$TARGET_CWD" \
|
||||
REASON="$REASON" CAPTURED_UUID="$CAPTURED_UUID" <<'PYEOF'
|
||||
import shutil
|
||||
name = os.environ['SESSION_NAME']
|
||||
agent = os.environ['AGENT']
|
||||
purge = os.environ['PURGE'] == '1'
|
||||
now = os.environ['NOW_ISO']
|
||||
home = os.environ['HOME_DIR']
|
||||
last_status = os.environ.get('LAST_STATUS', '')
|
||||
purge_uuid = os.environ.get('PURGE_UUID', '').strip()
|
||||
ws = os.environ.get('TARGET_CWD', '')
|
||||
reason = os.environ.get('REASON', '') or 'manual_stop'
|
||||
captured = os.environ.get('CAPTURED_UUID', '').strip()
|
||||
|
||||
target = None
|
||||
for s in d.get('tmux_sessions', []):
|
||||
if s.get('name') == name:
|
||||
target = s
|
||||
break
|
||||
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:
|
||||
target['status'] = 'stopped'
|
||||
target['stopped_at'] = now
|
||||
target['stopped_at_epoch'] = int(os.environ['NOW_EPOCH'])
|
||||
target['stop_reason'] = reason
|
||||
target['termination_mode'] = 'graceful'
|
||||
|
||||
if last_status:
|
||||
target['last_visible_status_at_termination'] = last_status
|
||||
|
||||
# --capture-id: 항상 captured UUID 기록 (purge가 아닐 때만)
|
||||
if captured and not purge:
|
||||
if agent == 'claude':
|
||||
target['claude_session_id_own'] = captured
|
||||
elif agent == 'agy':
|
||||
target['agy_conversation_id_own'] = captured
|
||||
elif agent == 'hermes':
|
||||
target['hermes_conversation_id_own'] = captured
|
||||
elif agent == 'cline':
|
||||
target['cline_conversation_id_own'] = captured
|
||||
target['resumable'] = True
|
||||
|
||||
# --purge-conversation: 워크스페이스 격리된 UUID 의 디스크 artifact 만 삭제 (P0-C)
|
||||
if purge and purge_uuid:
|
||||
if agent == 'claude':
|
||||
key = ws.replace('/', '-').replace('_', '-')
|
||||
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
|
||||
jsonl = f"{claude_project_dir}/{key}/{purge_uuid}.jsonl"
|
||||
if os.path.exists(jsonl):
|
||||
os.remove(jsonl)
|
||||
print(f"purged: {jsonl}", flush=True)
|
||||
target['claude_session_id_own'] = None
|
||||
elif agent == 'agy':
|
||||
db = f"{home}/.gemini/antigravity-cli/conversations/{purge_uuid}.db"
|
||||
if os.path.exists(db):
|
||||
os.remove(db)
|
||||
print(f"purged: {db}", flush=True)
|
||||
brain = f"{home}/.gemini/antigravity-cli/brain/{purge_uuid}"
|
||||
if os.path.isdir(brain):
|
||||
shutil.rmtree(brain)
|
||||
print(f"purged: {brain}", flush=True)
|
||||
target['agy_conversation_id_own'] = None
|
||||
elif agent == 'hermes':
|
||||
json_file = f"{home}/.hermes/sessions/session_{purge_uuid}.json"
|
||||
if os.path.exists(json_file):
|
||||
os.remove(json_file)
|
||||
print(f"purged: {json_file}", flush=True)
|
||||
hdb = f"{home}/.hermes/state.db"
|
||||
if os.path.exists(hdb):
|
||||
try:
|
||||
import sqlite3
|
||||
hconn = sqlite3.connect(hdb)
|
||||
hconn.execute("DELETE FROM sessions WHERE id=?", (purge_uuid,))
|
||||
hconn.execute("DELETE FROM messages WHERE session_id=?", (purge_uuid,))
|
||||
hconn.commit()
|
||||
hconn.close()
|
||||
print(f"purged db records for session: {purge_uuid}", flush=True)
|
||||
except Exception as e:
|
||||
print(f"WARN: purge hermes db records failed: {e}", flush=True)
|
||||
target['hermes_conversation_id_own'] = None
|
||||
elif agent == 'cline':
|
||||
sessions_dir = f"{home}/.cline/data/sessions/{purge_uuid}"
|
||||
if os.path.isdir(sessions_dir):
|
||||
shutil.rmtree(sessions_dir)
|
||||
print(f"purged: {sessions_dir}", flush=True)
|
||||
target['cline_conversation_id_own'] = None
|
||||
# agent_identities 는 cache — 이 워크스페이스 것일 때만 비운다
|
||||
ai = (d.get('agent_identities') or {}).get(agent) or {}
|
||||
if ai.get('project_cwd') == ws:
|
||||
if agent == 'claude' and ai.get('session_id') == purge_uuid:
|
||||
ai['session_id'] = None
|
||||
ai['session_jsonl'] = None
|
||||
ai.pop('session_size_bytes', None)
|
||||
ai.pop('session_lines', None)
|
||||
elif agent == 'agy' and ai.get('conversation_id') == purge_uuid:
|
||||
ai['conversation_id'] = None
|
||||
ai['conversation_db'] = None
|
||||
ai['conversation_brain_dir'] = None
|
||||
elif agent == 'hermes' and ai.get('session_id') == purge_uuid:
|
||||
ai['session_id'] = None
|
||||
elif agent == 'cline' and ai.get('session_id') == purge_uuid:
|
||||
ai['session_id'] = None
|
||||
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)
|
||||
PYEOF
|
||||
|
||||
delegate_publish_event "$DELEGATE_JOB_ID" completed "session terminated"
|
||||
|
||||
echo
|
||||
echo "=== stop complete ==="
|
||||
echo " session: $SESSION_NAME"
|
||||
echo " agent: $AGENT"
|
||||
echo " reason: $REASON"
|
||||
echo " captured: ${CAPTURED_UUID:-<none>}"
|
||||
echo " purge: $PURGE${PURGE_UUID:+ (uuid $PURGE_UUID)}"
|
||||
echo " time: $NOW_ISO"
|
||||
echo
|
||||
echo "Recovery: multi-agent-mux-create + multi-agent-mux-resume 로 동일 컨텍스트 복원 가능"
|
||||
echo " (단 --purge-conversation 사용 시 복원 불가)"
|
||||
@@ -75,3 +75,23 @@
|
||||
# Directory for delegate-job audit logs (sits beside .mam/jobs/).
|
||||
#default: <cwd>/.mam/delegate_job_logs
|
||||
# DELEGATE_JOB_LOGS_DIR=/path/to/workspace/.mam/delegate_job_logs
|
||||
|
||||
# ==============================================================================
|
||||
# deploy / distribution source (for forks/mirrors)
|
||||
# ==============================================================================
|
||||
# Note: These variables are read from the execution environment by deployment scripts.
|
||||
# Since deploy/install.sh runs before .env exists, you must pass them via export
|
||||
# or prepended variables (e.g. MAM_REPO_URL=... bash deploy/install.sh).
|
||||
# If you run a private mirror, we strongly recommend configuring all three variables.
|
||||
|
||||
# Distribution repository URL (cloned during recovery steps).
|
||||
#default: https://git.godopu.com/tmpl/multi-agent-mux.git
|
||||
# MAM_REPO_URL=https://git.godopu.com/tmpl/multi-agent-mux.git
|
||||
|
||||
# Distribution archive download URL (used for bootstrap extraction).
|
||||
#default: https://git.godopu.com/tmpl/multi-agent-mux/archive/main.tar.gz
|
||||
# MAM_ARCHIVE_URL=https://git.godopu.com/tmpl/multi-agent-mux/archive/main.tar.gz
|
||||
|
||||
# Distribution update/installer script URL.
|
||||
#default: https://git.godopu.com/tmpl/multi-agent-mux/raw/branch/main/deploy/install.sh
|
||||
# MAM_INSTALLER_URL=https://git.godopu.com/tmpl/multi-agent-mux/raw/branch/main/deploy/install.sh
|
||||
|
||||
@@ -6,3 +6,6 @@
|
||||
.mam/*.bak
|
||||
.agents/
|
||||
|
||||
|
||||
# Multi-Agent Mux (MAM) runtime databases and isolation cache
|
||||
/.mam/
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
snapshot:
|
||||
taken_at: '2026-07-03T01:49:10Z'
|
||||
cwd: /Users/godopu16/PuKi/lab/canary_projects/multi-agent-paper
|
||||
tmux_sessions:
|
||||
- name: canary-projects-multi-agent-paper-creator-claude
|
||||
status: stopped
|
||||
role: researcher-reviewer
|
||||
tmux_session_created_at: '2026-06-30T02:14:27Z'
|
||||
tmux_session_epoch: 1782785667
|
||||
tmux_server: multi-agent-paper
|
||||
delegate_job_id: null
|
||||
pane:
|
||||
index: 0
|
||||
pid: 13504
|
||||
cmd: claude
|
||||
cmd_full: claude --dangerously-skip-permissions -r d6eb5e41-a7a6-4c92-b6f2-4133da3a292e
|
||||
cwd: /Users/godopu16/PuKi/lab/canary_projects/multi-agent-paper
|
||||
start_command: tmux -L multi-agent-paper new-session -d -s "canary-projects-multi-agent-paper-creator-claude" -x 140 -y 40 -c "/Users/godopu16/PuKi/lab/canary_projects/multi-agent-paper" "claude --dangerously-skip-permissions"
|
||||
attach_command: tmux -L multi-agent-paper attach -t canary-projects-multi-agent-paper-creator-claude
|
||||
kill_command: tmux -L multi-agent-paper kill-session -t canary-projects-multi-agent-paper-creator-claude
|
||||
tui:
|
||||
model: (unknown — capture after first message)
|
||||
provider: anthropic
|
||||
plan: (unknown)
|
||||
account: (unknown — read from claude auth status)
|
||||
version: (unknown — read from TUI)
|
||||
claude_session_id_own: d6eb5e41-a7a6-4c92-b6f2-4133da3a292e
|
||||
last_visible_status: resumed conversation d6eb5e41-a7a6-4c92-b6f2-4133da3a292e at 2026-07-03T01:49:07Z
|
||||
last_visible_status_at_termination: ' 32 33 -셋째, **구조화된 직렬화 기반의 태스크 생명주기 관리 및 부하 분산 효율**이다. Protobuf(Protocol Buffers)의 스키마 정의형 바이너리 직렬화는 텍스트 기반 JSON 파싱 대비 직렬화·역직렬화 연 -산 비용과 페이로드 크기를 현저히 절감하여, 동적으로 생성·소멸하는 다수의 서브에이전트와 오케스트레이터 간에 폭증하는 제어 메시지를 메모리 효율적으로 처리할'
|
||||
stopped_at: '2026-07-03T14:17:34Z'
|
||||
stopped_at_epoch: 1783088254
|
||||
stop_reason: manual_stop
|
||||
termination_mode: graceful
|
||||
resumable: true
|
||||
- name: canary-projects-multi-agent-paper-creator-cline
|
||||
status: stopped
|
||||
role: researcher-reviewer
|
||||
tmux_session_created_at: '2026-06-30T02:23:09Z'
|
||||
tmux_session_epoch: 1782786189
|
||||
tmux_server: multi-agent-paper
|
||||
delegate_job_id: null
|
||||
pane:
|
||||
index: 0
|
||||
pid: 13609
|
||||
cmd: cline
|
||||
cmd_full: cline -i --id 1782786184987_wcllh
|
||||
cwd: /Users/godopu16/PuKi/lab/canary_projects/multi-agent-paper
|
||||
start_command: tmux -L multi-agent-paper new-session -d -s "canary-projects-multi-agent-paper-creator-cline" -x 140 -y 40 -c "/Users/godopu16/PuKi/lab/canary_projects/multi-agent-paper" "cline -i"
|
||||
attach_command: tmux -L multi-agent-paper attach -t canary-projects-multi-agent-paper-creator-cline
|
||||
kill_command: tmux -L multi-agent-paper kill-session -t canary-projects-multi-agent-paper-creator-cline
|
||||
child_pid: 0
|
||||
cline_conversation_id_own: 1782786184987_wcllh
|
||||
last_visible_status: resumed conversation 1782786184987_wcllh at 2026-07-03T01:49:10Z
|
||||
last_visible_status_at_termination: ' │IDL을 "명확한 계약서"에 비유 │33 │"사전에 문서로 못박아 두는 ''명확한 계약서'' 역할 ... 계약서에 서명한 양측이 조항을 재협상할 필요 없이 곧바로 거래할 수 있듯이" │✅ 정확 반영 │ ├────────────────────────────────┼───┼────────────────────────────────────'
|
||||
stopped_at: '2026-07-03T14:17:37Z'
|
||||
stopped_at_epoch: 1783088257
|
||||
stop_reason: manual_stop
|
||||
termination_mode: graceful
|
||||
resumable: true
|
||||
@@ -1,34 +0,0 @@
|
||||
.agents/AGENT.md
|
||||
.agents/AGENT.ko.md
|
||||
.agents/skills/multi-agent-mux-stop/scripts/stop_session.sh
|
||||
.agents/skills/multi-agent-mux-stop/SKILL.md
|
||||
.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh
|
||||
.agents/skills/multi-agent-mux-monitor/SKILL.md
|
||||
.agents/skills/multi-agent-mux-delegate-job/mqtt-broker-setup.md
|
||||
.agents/skills/multi-agent-mux-delegate-job/requirements.txt
|
||||
.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job
|
||||
.agents/skills/multi-agent-mux-delegate-job/DELEGATION_TYPES.md
|
||||
.agents/skills/multi-agent-mux-delegate-job/README.md
|
||||
.agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py
|
||||
.agents/skills/multi-agent-mux-delegate-job/scripts/registry.py
|
||||
.agents/skills/multi-agent-mux-delegate-job/scripts/mqtt_common.py
|
||||
.agents/skills/multi-agent-mux-delegate-job/scripts/job_subscriber.py
|
||||
.agents/skills/multi-agent-mux-delegate-job/job-protocol.md
|
||||
.agents/skills/multi-agent-mux-delegate-job/SKILL.md
|
||||
.agents/skills/multi-agent-mux-delegate-job/registry.md
|
||||
.agents/skills/multi-agent-mux-create/scripts/create_session.sh
|
||||
.agents/skills/multi-agent-mux-create/SKILL.md
|
||||
.agents/skills/lib.sh
|
||||
.agents/skills/multi-agent-mux-resume/scripts/resolve_session_id.sh
|
||||
.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh
|
||||
.agents/skills/multi-agent-mux-resume/SKILL.md
|
||||
.agents/skills/multi-agent-mux-status/scripts/status.sh
|
||||
.agents/skills/multi-agent-mux-status/SKILL.md
|
||||
MESSAGING.md
|
||||
BOOTSTRAP.md
|
||||
BOOTSTRAP.ko.md
|
||||
INSTRUCTION.md
|
||||
remove.sh
|
||||
update.sh
|
||||
.env.example
|
||||
.env
|
||||
@@ -1,7 +1,10 @@
|
||||
# INSTRUCTION.md
|
||||
# AGENTS.md
|
||||
|
||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||
|
||||
> [!NOTE]
|
||||
> This repository uses two separate guides: the general LLM behavioral guidelines ([AGENTS.md](AGENTS.md)) and the project-specific multi-agent orchestration guidelines ([.agents/MULTI_AGENT_RULES.md](.agents/MULTI_AGENT_RULES.md)).
|
||||
|
||||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||
|
||||
## 1. Think Before Coding
|
||||
@@ -64,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 .agents/AGENT.md first before working and follow the instructions for orchestration.
|
||||
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.
|
||||
@@ -1,185 +0,0 @@
|
||||
# BOOTSTRAP.md
|
||||
|
||||
본 문서는 `tmux_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다.
|
||||
|
||||
새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 프로젝트 구조 이해 (Scaffolding Overview)
|
||||
|
||||
본 프로젝트를 새로운 환경에 복제(Clone)한 후, 핵심 구성 요소들의 위치와 역할을 먼저 파악해야 합니다.
|
||||
|
||||
* `.agents/`: 오케스트레이션 및 에이전트 커스텀 스킬 디렉터리
|
||||
* `AGENT.md`: 에이전트 간의 역할 분담(PM, Worker, Reviewer) 및 이벤트 발행 규약 정의
|
||||
* `AGENT.ko.md`: 에이전트 간의 역할 분담(PM, Worker, Reviewer) 및 이벤트 발행 규약 정의 (한국어)
|
||||
* `skills/`: 멀티 에이전트 구동 및 비동기 잡 처리를 수행하는 셸 스크립트 모음
|
||||
* `lib.sh`: 오케스트레이션의 핵심 셸 함수 및 가상환경(venv) 자동 연동 라이브러리
|
||||
* `multi-agent-mux-create/`: 격리된 tmux 에이전트 세션을 시작하는 스크립트
|
||||
* `multi-agent-mux-stop/`: 세션을 정상적으로 중지하고 상태를 업데이트하는 스크립트
|
||||
* `multi-agent-mux-resume/`: 중지된 에이전트 세션을 이전 대화 상태 그대로 복원하는 스크립트
|
||||
* `multi-agent-mux-status/`: 전체 에이전트 세션의 현재 구동 상태를 조회하는 스크립트
|
||||
* `multi-agent-mux-monitor/`: tmux 상태와 레지스트리 상태를 동기화하는 모니터 스크립트
|
||||
* `multi-agent-mux-delegate-job/`: 비동기 잡 분할 실행 모듈
|
||||
* `requirements.txt`: Python 의존성 목록 (paho-mqtt, pyyaml)
|
||||
* `scripts/`: 핵심 비즈니스 로직을 구동하는 Python 스크립트 디렉터리
|
||||
* `registry.py`: 잡의 등록, 클레임 및 원자적 파일 락 제어 (CLI 지원)
|
||||
* `job_subscriber.py`: 백그라운드 이벤트 구독기 및 오디팅 로그 생성기
|
||||
* `publish_event.py`: 실행 상태 및 에러 트랩 시 이벤트 퍼블리셔
|
||||
* `mqtt_common.py`: 공통 MQTT 브로커 연결 유틸리티
|
||||
* `MESSAGING.md`: 에이전트 간 주고받는 MQTT 메시지 와이어 스킴 가이드라인
|
||||
|
||||
---
|
||||
|
||||
## 2. 워크스페이스 프로비저닝 (단계 0 — 참고용)
|
||||
|
||||
에이전트가 이 안내서를 읽기 전에 워크스페이스 설치가 선행되어야 합니다. 이 단계는 이미 개발자(사람)가 설치기를 통해 수행 완료한 상태입니다. 에이전트가 환경 검증 작업을 수행할 때 파일들이 어떤 경로로 복사되고 설치되었는지에 대한 기초 사실(Reference)을 제공하기 위해 여기에 기록해 둡니다.
|
||||
|
||||
### 단계 0.1: 설치 스크립트 실행 (이미 기수행됨)
|
||||
인스톨러를 통해 워크스페이스를 기동하기 위해 사람이 다음 명령을 구동하였습니다:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.godopu.com/tmpl/multi-agent-mux/raw/branch/main/deploy/install.sh | bash
|
||||
```
|
||||
|
||||
인스톨러는 시스템 의존성 유효성을 검증하고 리포지토리를 불러와 오케스트레이션을 위한 `.agents/` 스켈레톤 및 관련 문서 파일(본 `BOOTSTRAP.ko.md` 포함)을 복사하고 `.env` 및 `.venv` 가상환경 설정을 자동으로 수행했습니다.
|
||||
|
||||
> [!NOTE]
|
||||
> **검증 전용 경고**: 본 가이드를 읽고 있는 AI 에이전트는 인스톨러 스크립트를 스스로 **다시 실행해서는 안 됩니다.** 즉시 환경 변수 설정 및 검증 테스트 단계로 넘어가십시오.
|
||||
|
||||
---
|
||||
|
||||
## 3. 환경 설정 파일 생성 (.env)
|
||||
|
||||
메시징 브로커 설정 및 실행 경로를 설정하기 위해 로컬 환경 설정 파일(`.env`)을 생성하고 수정해야 합니다.
|
||||
|
||||
### 단계 3.1: 자동 생성 스크립트 실행
|
||||
프로젝트 루트에서 제공되는 환경 설정 템플릿 복사 스크립트를 실행합니다.
|
||||
|
||||
```bash
|
||||
# .env.example를 .env로 자동 복제 (이미 존재하면 덮어쓰지 않고 보호됨)
|
||||
./scripts/generate-env.sh
|
||||
|
||||
# 만약 강제로 덮어쓰고 백업을 생성하고 싶은 경우:
|
||||
./scripts/generate-env.sh --force
|
||||
```
|
||||
|
||||
### 단계 3.2: 환경 변수 수정 및 설정
|
||||
생성된 `.env` 파일을 열어 설정을 필요에 따라 구성합니다.
|
||||
|
||||
> [!NOTE]
|
||||
> `generate-env.sh`로 생성된 기본 `.env` 파일은 모든 환경 변수 항목이 주석 처리되어 있습니다. 주석 처리된 상태로 둘 경우 로컬 프로젝트 루트를 기준으로 한 상대 경로(`.mam/` 등) 및 기본 공개 브로커 주소가 자동 지정되므로 그대로 사용하셔도 무방합니다.
|
||||
|
||||
1. **MQTT Broker 설정 (`MQTT_BROKER`)**:
|
||||
* 기본값은 HiveMQ 공개 브로커(`broker.hivemq.com`)로 잡혀 있으나, 보안 및 프라이버시가 중요한 프로덕션 작업 시에는 개인/사설 브로커 주소로 변경할 것을 강력히 권장합니다.
|
||||
2. **인증 정보 (`MQTT_USERNAME`, `MQTT_PASSWORD`)**:
|
||||
* 보안 브로커를 사용하는 경우 `replace_me`로 명시된 곳을 실제 브로커 계정 정보로 변경해 주십시오.
|
||||
3. **경로 관련 변수 (선택 사항)**:
|
||||
* 특정 빌드 시스템이나 호스트 폴더 구조에 맞추어 `AGENT_SESSIONS_YAML` 및 `DELEGATE_JOB_LOGS_DIR` 등의 경로 값을 절대 경로로 오버라이드해야 하는 경우에만 주석을 풀고 기입하십시오.
|
||||
|
||||
> [!WARNING]
|
||||
> **보안 모드 기본값 안내**:
|
||||
> 시스템의 기본 설정은 **무인증 PoC 모드**입니다. 잡 등록 시 `auth_token`이 명시적으로 주입되지 않으면(또는 `null`인 경우) HMAC 서명 검증이 생략됩니다.
|
||||
> 공개 브로커 사용 환경이나 실제 프로덕션 단계에서는 잡 등록 시 `auth_token`을 고유 난수값으로 생성 및 주입하여 HMAC 보안 서명을 활성화해야 합니다. (자세한 보안 규약은 [MESSAGING.md](./MESSAGING.md) 및 [AGENT.ko.md](.agents/AGENT.ko.md)의 `2.3 보안 프로토콜` 섹션을 참조하십시오. 현재 CLI를 통한 자동 토큰 생성/주입 기능 지원은 향후 로드맵의 `FW-N6` 과제로 처리 예정입니다.)
|
||||
|
||||
---
|
||||
|
||||
## 4. 의존성 및 가상환경 설정 (Venv Setup)
|
||||
|
||||
오케스트레이션 및 MQTT 메시징을 구동하기 위한 Python 3 의존성을 설정합니다.
|
||||
|
||||
### 단계 4.1: Python 가상환경 구축
|
||||
프로젝트 루트에서 `.venv` 가상환경을 생성하고 활성화합니다.
|
||||
|
||||
```bash
|
||||
# 가상환경 생성
|
||||
python3 -m venv .venv
|
||||
|
||||
# 가상환경 활성화
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
### 단계 4.2: 의존성 패키지 설치
|
||||
`multi-agent-mux-delegate-job` 디렉터리에 기재된 `requirements.txt` 의존성 목록을 가상환경에 설치합니다.
|
||||
|
||||
```bash
|
||||
# 의존성 패키지(pyyaml, paho-mqtt 등) 설치
|
||||
pip install -r .agents/skills/multi-agent-mux-delegate-job/requirements.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 디렉터리 준비 및 보안 감시 가이드
|
||||
|
||||
에이전트 제어 상태 및 잡 기록을 위해 로컬 레지스트리 디렉터리가 정상적으로 생성되었는지 확인합니다.
|
||||
|
||||
1. **필수 로컬 디렉터리 구조**:
|
||||
* `.mam/jobs/`: 등록된 비동기 잡의 세부 메타데이터가 파일 형태로 저장되는 디렉터리
|
||||
* `.mam/delegate_job_logs/`: 에이전트가 발행하는 모든 백플레인 이벤트 흐름이 기록되는 audit log (`events.ndjson`) 보존 디렉터리
|
||||
2. **Git 커밋 제어 (.gitignore)**:
|
||||
* 새 프로젝트 초기화 시 아래 파일들이 절대 리포지토리에 커밋되지 않도록 `.gitignore` 상태를 점검합니다. `!.env.example` 예외 처리가 유지되어야 템플릿이 보존됩니다:
|
||||
```text
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.mam/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 실행 환경 검증 및 부트스트랩 테스트
|
||||
|
||||
환경 구축이 오작동 없이 안전하게 완료되었는지 아래의 체크리스트를 실행해 검증합니다.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 아래의 모든 검증 명령은 반드시 **프로젝트 루트 디렉터리**(`.mam/` 디렉터리가 직접 보이는 위치)에서 실행해야 합니다. 잡 레지스트리 디렉터리 기본 경로가 프로젝트 루트 하위의 `./.mam/jobs` 상대 경로를 기준으로 탐색되기 때문입니다.
|
||||
|
||||
### 검증 테스트 1: 잡 레지스트리 정상 구동 여부
|
||||
Python 스크립트 및 venv 라이브러리가 올바르게 로드되는지 확인하기 위해 잡 목록을 조회합니다.
|
||||
|
||||
```bash
|
||||
# 가상환경(.venv) 파이썬 인터프리터를 사용하여 실행
|
||||
.venv/bin/python3 .agents/skills/multi-agent-mux-delegate-job/scripts/registry.py list
|
||||
```
|
||||
* **출력 기대 결과**: 에러 메시지 없이 빈 JSON 배열 `[]` 또는 현재 등록된 pending/running 잡 목록이 성공적으로 출력되어야 합니다.
|
||||
|
||||
### 검증 테스트 2: MQTT 연결 브로커 핸드셰이크 테스트
|
||||
브로커와의 송수신 통신망 상태를 확인하고, 이벤트 생명주기 및 멱등성이 온전히 작동하는지 실측 검증합니다.
|
||||
|
||||
```bash
|
||||
# 1. 테스트용 임시 잡 등록 및 발급된 8자리 Hex 잡 ID 획득
|
||||
JID=$(.venv/bin/python3 .agents/skills/multi-agent-mux-delegate-job/scripts/registry.py register \
|
||||
--agent "test-agent" \
|
||||
--prompt "Bootstrap check command" \
|
||||
--timeout 120)
|
||||
echo "Generated Job ID: $JID"
|
||||
|
||||
# 2. 획득한 잡 ID에 대해 백그라운드 이벤트 구독기(Subscriber) 구동
|
||||
.venv/bin/python3 .agents/skills/multi-agent-mux-delegate-job/scripts/job_subscriber.py --job "$JID" &
|
||||
|
||||
# 3. 구독자의 MQTT Broker 소켓 연결 및 수신부 초기화 완료를 보장하기 위해 2초 대기
|
||||
sleep 2
|
||||
|
||||
# 4. 테스트 시작 이벤트 발행 (Subscribe-before-Publish 원칙 준수)
|
||||
.venv/bin/python3 .agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py \
|
||||
--job "$JID" \
|
||||
--event started \
|
||||
--detail "Bootstrap MQTT verification connection check"
|
||||
|
||||
# 5. 이벤트 수신이 터미널(stdout) 및 .mam/delegate_job_logs/events.ndjson 로그 파일에 정상 기록되는지 확인
|
||||
|
||||
# 6. 검증 완료 후 백그라운드 프로세스 종료 및 테스트 잡 레코드 수동 정리
|
||||
kill %1
|
||||
rm -f ".mam/jobs/$JID.json" ".mam/jobs/$JID.lock"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 에이전트 온보딩 가이드 (New Agent Onboarding)
|
||||
|
||||
본 환경 구축을 무사히 마쳤다면, 협업하는 에이전트는 즉시 .agents/ 디렉터리에 있는 **[AGENT.ko.md](.agents/AGENT.ko.md)** 문서를 읽어야 합니다.
|
||||
|
||||
해당 문서에는 에이전트가 각 역할(PM, Worker, Reviewer)로 구동될 때 지켜야 할 **수술적 변경 규칙, 교차 검증 통과 규약, Tmux 뷰포트 유실 방지를 위한 스냅샷 패턴** 등이 서술되어 있어 안정적인 멀티 에이전트 워크플로우에 즉시 기여할 수 있도록 돕습니다.
|
||||
@@ -1,186 +0,0 @@
|
||||
# BOOTSTRAP.md
|
||||
|
||||
This document guides you through the setup and initialization procedures required to adopt the `tmux_agent_orchestration` orchestration and messaging backplane workflow in a new project, enabling a new developer or agent to get up and running quickly.
|
||||
|
||||
A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scaffolding Overview (Project Structure)
|
||||
|
||||
Before cloning this project into a new environment, you must first understand the locations and roles of its core components:
|
||||
|
||||
* `.agents/`: Orchestration and custom agent skills root.
|
||||
* `AGENT.md`: Definition of agent roles (PM, Worker, Reviewer) and event publication rules.
|
||||
* `AGENT.ko.md`: Definition of agent roles (PM, Worker, Reviewer) and event publication rules (Korean).
|
||||
* `skills/`: A collection of shell scripts that execute multi-agent coordination and asynchronous job processing.
|
||||
* `lib.sh`: The core orchestration shell functions and virtual environment (venv) auto-loading library.
|
||||
* `multi-agent-mux-create/`: Script to launch isolated tmux agent sessions.
|
||||
* `multi-agent-mux-stop/`: Script to gracefully stop agent sessions and update states.
|
||||
* `multi-agent-mux-resume/`: Script to restore stopped agent sessions back to their previous conversation state.
|
||||
* `multi-agent-mux-status/`: Script to query the current running state of all agent sessions.
|
||||
* `multi-agent-mux-monitor/`: Monitor script to sync tmux states with the registry.
|
||||
* `multi-agent-mux-delegate-job/`: Asynchronous job splitting and delegation module.
|
||||
* `requirements.txt`: Python dependency list (`paho-mqtt`, `pyyaml`).
|
||||
* `scripts/`: Python scripts running the core business logic.
|
||||
* `registry.py`: Job registration, claiming, and atomic file lock control (CLI supported).
|
||||
* `job_subscriber.py`: Background event subscriber and audit log generator.
|
||||
* `publish_event.py`: Event publisher for runtime states and error traps.
|
||||
* `mqtt_common.py`: Common utility for connecting to the MQTT broker.
|
||||
* `MESSAGING.md`: Messaging scheme and wire protocol guidelines for MQTT communication between agents.
|
||||
|
||||
---
|
||||
|
||||
## 2. Workspace Provisioning (Step 0 — Reference Only)
|
||||
|
||||
Before any agent can read this guide, the workspace must be provisioned. This step has already been performed by the human developer using the installer. It is documented here strictly as a reference for verifying agents to understand how the assets arrived.
|
||||
|
||||
### Step 0.1: Run the Installer One-Liner (Pre-Cloned/Provisioned)
|
||||
The human developer bootstrapped the workspace by running:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://git.godopu.com/tmpl/multi-agent-mux/raw/branch/main/deploy/install.sh | bash
|
||||
```
|
||||
|
||||
The installer verified system dependencies, staged the repository, copied the runtime `.agents/` scaffolding and documentation files (including this `BOOTSTRAP.md`), and set up the default `.env` and `.venv` environments.
|
||||
|
||||
> [!NOTE]
|
||||
> **Verify Only**: AI agents reading this guide must **not** attempt to run the installation script again. Proceed directly to configuration and verification steps.
|
||||
|
||||
---
|
||||
|
||||
## 3. Environment Configuration (.env)
|
||||
|
||||
To set up the messaging broker and execution paths, you must create and modify a local environment configuration file (`.env`).
|
||||
|
||||
### Step 3.1: Run the Generation Script
|
||||
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
|
||||
|
||||
# To force overwrite and create a backup of the existing .env:
|
||||
./scripts/generate-env.sh --force
|
||||
```
|
||||
|
||||
### Step 3.2: Modify Environment Variables
|
||||
Open the generated `.env` file to configure settings as needed.
|
||||
|
||||
> [!NOTE]
|
||||
> The default `.env` file generated by `generate-env.sh` has all environment variables commented out. If left commented out, the system defaults to using relative paths (`.mam/`, etc.) relative to the local project root, and the public MQTT broker. You can use it as-is without uncommenting anything.
|
||||
|
||||
1. **MQTT Broker Setup (`MQTT_BROKER`)**:
|
||||
* The default broker is HiveMQ's public sandbox broker (`broker.hivemq.com`). However, for production work where security and privacy are critical, we strongly recommend changing this to a private broker address.
|
||||
2. **Authentication Credentials (`MQTT_USERNAME`, `MQTT_PASSWORD`)**:
|
||||
* If using a secured broker, change the placeholders marked `replace_me` to your actual broker credentials.
|
||||
3. **Path Variables (Optional)**:
|
||||
* Uncomment and specify absolute paths for variables like `AGENT_SESSIONS_YAML` and `DELEGATE_JOB_LOGS_DIR` only if you need to override the default relative paths to align with specific build systems or host directories.
|
||||
|
||||
> [!WARNING]
|
||||
> **Security Mode Default Warning**:
|
||||
> The system's default setting is the **unauthenticated PoC mode**. If an `auth_token` is not explicitly provided (or is `null`) during job registration, HMAC signature verification is skipped.
|
||||
> In a public broker environment or production phase, you must generate and inject a unique random `auth_token` during job registration to enable HMAC signature security. (For detailed security protocols, refer to section `2.3 Security Protocol` in [MESSAGING.md](./MESSAGING.md) and [AGENT.md](.agents/AGENT.md). Automated token generation and injection via CLI is on the roadmap under task `FW-N6`.)
|
||||
|
||||
---
|
||||
|
||||
## 4. Dependency and Virtualenv Setup
|
||||
|
||||
Set up the Python 3 dependencies required to run the orchestration and MQTT messaging backplane.
|
||||
|
||||
### Step 4.1: Build Python Virtual Environment
|
||||
Create and activate a `.venv` virtual environment in the project root:
|
||||
|
||||
```bash
|
||||
# Create virtual environment
|
||||
python3 -m venv .venv
|
||||
|
||||
# Activate virtual environment
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
### Step 4.2: Install Dependency Packages
|
||||
Install the required packages listed in `requirements.txt` under `multi-agent-mux-delegate-job`:
|
||||
|
||||
```bash
|
||||
# Install dependencies (pyyaml, paho-mqtt, etc.)
|
||||
pip install -r .agents/skills/multi-agent-mux-delegate-job/requirements.txt
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Directory Structure and Security Audit Guide
|
||||
|
||||
Ensure that the local registry directories required to track agent states and jobs are successfully created:
|
||||
|
||||
1. **Required Directory Structure**:
|
||||
* `.mam/jobs/`: Holds detailed metadata files for registered asynchronous jobs.
|
||||
* `.mam/delegate_job_logs/`: Holds the audit logs (`events.ndjson`) for all backplane events published by agents.
|
||||
2. **Git Ignore Configuration (`.gitignore`)**:
|
||||
* When initializing a new project, verify that the following entries are configured in `.gitignore` to prevent committing local runtimes to the repository. The exception `!.env.example` must be kept to preserve the template:
|
||||
```text
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.mam/
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Execution Verification and Bootstrap Tests
|
||||
|
||||
To verify that the environment has been successfully built without runtime errors, run the following verification checklist.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> All verification commands below must be executed from the **project root directory** (where the `.mam/` directory is directly visible). This is because the default job registry path resolved by scripts is relative to the current working directory under `./.mam/jobs`.
|
||||
|
||||
### Verification Test 1: Registry Script Load Check
|
||||
Verify that the Python scripts and virtual environment libraries load correctly by listing jobs:
|
||||
|
||||
```bash
|
||||
# Run using the python interpreter in the virtual environment
|
||||
.venv/bin/python3 .agents/skills/multi-agent-mux-delegate-job/scripts/registry.py list
|
||||
```
|
||||
* **Expected Output**: The command should exit successfully and print an empty JSON array `[]` (or a list of pending/running jobs if any exist) without any python traceback errors.
|
||||
|
||||
### Verification Test 2: MQTT Broker Connection Handshake Test
|
||||
Test the end-to-end communication through the broker to verify that events are published and received correctly:
|
||||
|
||||
```bash
|
||||
# 1. Register a temporary test job and capture its 8-character Hex Job ID
|
||||
JID=$(.venv/bin/python3 .agents/skills/multi-agent-mux-delegate-job/scripts/registry.py register \
|
||||
--agent "test-agent" \
|
||||
--prompt "Bootstrap check command" \
|
||||
--timeout 120)
|
||||
echo "Generated Job ID: $JID"
|
||||
|
||||
# 2. Run the background event subscriber (Subscriber) for this Job ID
|
||||
.venv/bin/python3 .agents/skills/multi-agent-mux-delegate-job/scripts/job_subscriber.py --job "$JID" &
|
||||
|
||||
# 3. Wait 2 seconds to allow the Subscriber to establish its MQTT socket connection
|
||||
sleep 2
|
||||
|
||||
# 4. Publish a start event (adhering to the Subscribe-before-Publish rule)
|
||||
.venv/bin/python3 .agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py \
|
||||
--job "$JID" \
|
||||
--event started \
|
||||
--detail "Bootstrap MQTT verification connection check"
|
||||
|
||||
# 5. Verify that the event is printed to stdout and written to the audit log:
|
||||
# .mam/delegate_job_logs/events.ndjson
|
||||
|
||||
# 6. Stop the background subscriber and clean up the test job records
|
||||
kill %1
|
||||
rm -f ".mam/jobs/$JID.json" ".mam/jobs/$JID.lock"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Onboarding Collaborating Agents (New Agent Onboarding)
|
||||
|
||||
Once the setup is verified, onboarding agents should immediately read the **[AGENT.md](.agents/AGENT.md)** guidelines in the .agents/ directory.
|
||||
|
||||
The guidelines describe essential workflows—such as **surgical change constraints, cross-verification review loops, and pane snapshotting to prevent viewport truncation**—allowing new agents to quickly and safely integrate with the multi-agent workflow.
|
||||
@@ -1,364 +0,0 @@
|
||||
# Messaging System Technical Analysis & Architecture Report
|
||||
|
||||
This report provides a comprehensive, deep-dive analysis of the messaging system implemented in the `multi-agent-mux-delegate-job` skill. It covers the MQTT broker architecture, event protocols, job lifecycles, codebase internals, cross-system integration, and a list of known limitations along with production recommendations.
|
||||
|
||||
---
|
||||
|
||||
## 1. MQTT Broker Architecture: PoC vs. TLS Production
|
||||
|
||||
The messaging system is designed with a clear, decoupled transition pathway from a Proof of Concept (PoC) public broker setup to a secured, authenticated, and encrypted private production cluster. All configurations are resolved dynamically from the environment or overridden at the job level, requiring zero code modifications during deployment cut-over.
|
||||
|
||||
### 1.1 PoC Architecture (Public Sandbox)
|
||||
In the initial development/testing phase, the system defaults to the public broker hosted by HiveMQ:
|
||||
* **Host/IP**: `broker.hivemq.com`
|
||||
* **Protocol/Port**: Plaintext MQTT over TCP on port `1883`.
|
||||
* **Security & Auth**: None. No username, password, TLS encryption, or access control list (ACL) constraints are applied.
|
||||
* **QoS Level**: QoS 1 (At Least Once) is requested for publishes and subscriptions, ensuring acknowledgement at the network layer.
|
||||
|
||||
#### Risks and Limitations of the PoC Setup:
|
||||
1. **Zero Eavesdropping Protection**: Because the broker is public and unencrypted, any internet user can subscribe to the root topic (`python/mqtt/jobs/#`) and read the exact prompt, agent sessions, and intermediate progress events.
|
||||
2. **Event Spoofing & Injection**: Anyone can publish messages to any job topic. An attacker could publish a malicious `completed` or `error` event, prematurely terminating a running subscriber or causing the delegator to execute unauthorized post-validation hooks.
|
||||
3. **No Message Persistence**: Public brokers do not guarantee queue persistence or durable sessions for disconnected clients. If a subscriber briefly drops offline, QoS 1 messages published during the disconnect window may be discarded.
|
||||
4. **Rate Limiting & Reliability**: Public sandboxes are subject to arbitrary rate limits, traffic spikes, and transient connection resets, leading to network-level timeouts.
|
||||
|
||||
---
|
||||
|
||||
### 1.2 Production Architecture (Secure Private Broker)
|
||||
For production deployments, the system is designed to run on a private, self-hosted MQTT 5.0 broker such as **Mosquitto** or **EMQX**.
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "Secure Corporate Network"
|
||||
Broker["Private MQTT Broker (Mosquitto/EMQX) <br> Ports: 8883 (TLS)"]
|
||||
|
||||
subgraph "Hermes (Delegator/Orchestrator)"
|
||||
SubClient["job_subscriber.py <br> (Role: subscriber)"]
|
||||
end
|
||||
|
||||
subgraph "Tmux Workspace (Agent Host)"
|
||||
PubClient["publish_event.py <br> (Role: publisher)"]
|
||||
end
|
||||
|
||||
SubClient -- "Subscribe (QoS 1) <br> Auth: hermes <br> ACL: Read jobs/+/events" --> Broker
|
||||
PubClient -- "Publish (QoS 1 + Retain Terminal) <br> Auth: claude-worker <br> ACL: Write jobs/+/events" --> Broker
|
||||
end
|
||||
```
|
||||
|
||||
#### Production Security & Hardening Controls:
|
||||
1. **Transport Layer Security (TLS v1.3)**: Traffic is encrypted over port `8883` using a private Certification Authority (CA). The orchestrator validates the broker using `MQTT_CA_CERTS` (CA bundle path). Optionally, Mutual TLS (mTLS) is supported via client-side certificate keys (`MQTT_CERTFILE`/`MQTT_KEYFILE`) for cryptographic device identities.
|
||||
2. **Strict Client Authentication**: All clients must supply credentials (`MQTT_USERNAME` / `MQTT_PASSWORD`) to establish a connection. Anonymous logins are explicitly disabled (`allow_anonymous false`).
|
||||
3. **Role-Based Topic Access Control Lists (ACLs)**:
|
||||
* **Orchestrator/Hermes (Subscriber)**: Authenticates as user `hermes` with read-only access to all event streams:
|
||||
```conf
|
||||
user hermes
|
||||
topic read python/mqtt/jobs/+/events
|
||||
```
|
||||
* **Agent/Worker (Publisher)**: Authenticates as user `claude-worker` with write-only access restricted to the job event sub-topics:
|
||||
```conf
|
||||
user claude-worker
|
||||
topic write python/mqtt/jobs/+/events
|
||||
```
|
||||
This prevents workers from eavesdropping on sister agents or intercepting commands on other jobs.
|
||||
4. **Durable Message Queues & Session State**:
|
||||
* The broker is configured with `persistence true` and a dedicated disk storage path.
|
||||
* Subscribers connect with persistent session flags to ensure the broker buffers QoS 1 messages during temporary network drops.
|
||||
5. **Retained Terminal Events**: Terminal events (`completed`/`error`) are published with the `retain=True` flag. This allows a late-joining or recovering subscriber to instantly retrieve the final job status without waiting for active transmissions.
|
||||
|
||||
---
|
||||
|
||||
### 1.3 Production Mosquitto Configuration Reference
|
||||
A hardened `/etc/mosquitto/mosquitto.conf` production configuration includes:
|
||||
```conf
|
||||
# Persistence settings
|
||||
persistence true
|
||||
persistence_location /var/lib/mosquitto/
|
||||
|
||||
# Authentication and Authorization
|
||||
password_file /etc/mosquitto/auth/passwd
|
||||
acl_file /etc/mosquitto/auth/acl
|
||||
allow_anonymous false
|
||||
|
||||
# Listener and TLS Configuration
|
||||
listener 8883
|
||||
cafile /etc/mosquitto/certs/ca.crt
|
||||
certfile /etc/mosquitto/certs/server.crt
|
||||
keyfile /etc/mosquitto/certs/server.key
|
||||
tls_version tlsv1.3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Event Protocol Specification
|
||||
|
||||
The event protocol defines a strict, single-direction JSON wire schema. It acts as the contract between the worker agent (the publisher) and the delegator/orchestrator (the subscriber).
|
||||
|
||||
### 2.1 Wire Schema (JSON UTF-8, `schema_version = 1`)
|
||||
Every event payload must adhere to the following schema structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"seq": 2,
|
||||
"job_id": "918b0612",
|
||||
"event": "progress",
|
||||
"timestamp": "2026-06-20T14:48:58Z",
|
||||
"detail": "Section 1: MQTT Broker Architecture completed",
|
||||
"data": {
|
||||
"auth_token": "URL-safe-base64-random-token-here",
|
||||
"custom_metric": 42
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Wire Schema Field Dictionary
|
||||
|
||||
| Field | Type | Required | Description & Validation Rules |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `schema_version` | Integer | **Yes** | Must be exactly `1`. Subscribers discard payloads with mismatched version numbers to prevent parser crashes on schema drift. |
|
||||
| `seq` | Integer | **Yes** | Monotonic sequence counter starting at `1` for the first publish. Incremented and stored in the job's registry file (`last_seq`) to survive agent pane crashes. |
|
||||
| `job_id` | String | **Yes** | The 8-character hex string identifying the target job. Subscribers discard any messages whose `job_id` is unexpected or unrequested. |
|
||||
| `event` | String | **Yes** | The event classification: `started`, `progress`, `permission_required`, `completed`, or `error`. |
|
||||
| `timestamp` | String | **Yes** | ISO-8601 UTC timestamp with a trailing `Z` suffix. (Advisory only; never trusted for timeouts). |
|
||||
| `detail` | String | **Yes** | Generalized, safe text description. Strict rules prohibit absolute paths, workspace paths, passwords, or raw environment variables. |
|
||||
| `data` | Object | **Yes** | Metadata dictionary. Used in production to pass `auth_token` or structured execution metrics. |
|
||||
|
||||
---
|
||||
|
||||
### 2.3 Event Type Dictionary and Schemas
|
||||
|
||||
#### 1. `started`
|
||||
* **Emit Trigger**: Emitted by the worker agent immediately upon boot inside the tmux session, indicating it has parsed the instructions and started execution.
|
||||
* **Payload Constraints**: `seq` must be `1`. Status in registry is transitioned to `running`.
|
||||
* **Example Detail**: `"Job 918b0612 started"`
|
||||
|
||||
#### 2. `progress`
|
||||
* **Emit Trigger**: Optional. Emitted at major check-points, long loops, or sub-task boundaries.
|
||||
* **Payload Constraints**: None.
|
||||
* **Example Detail**: `"Section 1: MQTT Broker Architecture completed"`
|
||||
|
||||
#### 3. `permission_required`
|
||||
* **Emit Trigger**: Emitted when the agent needs human confirmation (e.g. to run a destructive command or read/write critical system files).
|
||||
* **Payload Constraints**: `detail` contains the resource/action requested.
|
||||
* **Example Detail**: `"needs write permission to MESSAGING.md"`
|
||||
|
||||
#### 4. `completed` (Terminal)
|
||||
* **Emit Trigger**: Successful job completion. The agent has generated all expected artifacts and verified correctness.
|
||||
* **Payload Constraints**: Must be the final event. Published with `retain=True`.
|
||||
* **Example Detail**: `"deep report written and committed to git"`
|
||||
|
||||
#### 5. `error` (Terminal)
|
||||
* **Emit Trigger**: Terminal failure. Agent encountered an unhandled exception, syntax error, or validation script fail.
|
||||
* **Payload Constraints**: Must be the final event. Published with `retain=True`.
|
||||
* **Example Detail**: `"validation fail: missing files"`
|
||||
|
||||
---
|
||||
|
||||
### 2.4 Integrity and Authentication Verification (HMAC-SHA256 Signatures)
|
||||
To prevent unauthorized users from hijacking or spoofing events on public brokers:
|
||||
1. When a job is registered, a cryptographic token (`auth_token`) is generated (`secrets.token_urlsafe(32)`).
|
||||
2. The publisher reads this token and signs the JSON payload. Specifically, the publisher calculates an HMAC-SHA256 signature using the `auth_token` as the secret key over the serialized payload (with the `hmac_sig` field excluded).
|
||||
3. The signature is attached as `data.hmac_sig` on the wire.
|
||||
4. The subscriber (`job_subscriber.py`) reads the expected `auth_token` from the local registry and verifies the HMAC signature. Any message with a missing, invalid, or mismatched signature is discarded immediately with an "HMAC verify failed" log.
|
||||
5. To prevent event drops, all publishers and subscribers must be updated simultaneously during deployment rollout, since the plaintext `auth_token` is never transmitted on the wire to prevent token interception.
|
||||
|
||||
---
|
||||
|
||||
## 3. Job Lifecycle & State Transitions
|
||||
|
||||
The lifecycle of a delegated job progresses through a highly coordinated state machine, involving file-based registry claiming, asynchronous message subscription, and multi-faceted event publishing.
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending : register_job()
|
||||
pending --> running : pick_pending()
|
||||
running --> completed : publish_event(--event completed)
|
||||
running --> error : publish_event(--event error)
|
||||
running --> cancelled : update_status(..., cancelled)
|
||||
pending --> cancelled : update_status(..., cancelled)
|
||||
completed --> [*]
|
||||
error --> [*]
|
||||
cancelled --> [*]
|
||||
```
|
||||
|
||||
### 3.1 Step-by-Step Lifecycle Phase Details
|
||||
|
||||
#### Phase 1: Registration (`register`)
|
||||
* **Trigger**: A delegator triggers `registry.py register` (or the `multi-agent-mux-delegate-job submit` command).
|
||||
* **Registry State**: Flips from non-existent to `pending` inside `.mam/jobs/<job_id>.json`. A `last_seq` counter is initialized to `0`.
|
||||
* **Locking**: Exclusive fcntl file lock acquired over `.lock` during write.
|
||||
* **Durable Audit Log**: Writes `<logs>/<job_id>/meta.json`, sets status to `pending` in `status.json`, and appends a `registered` event line to `events.ndjson`.
|
||||
|
||||
#### Phase 2: Claiming (`pick_pending`)
|
||||
* **Trigger**: An agent session starts up and calls `registry.py pick --agent-session <session_label>`.
|
||||
* **Registry State**: Oldest matching `pending` record is scanned. Status is atomically updated to `running`. `updated_at` is stamped.
|
||||
* **Locking**: Reads and writes occur inside the exclusive fcntl lock block.
|
||||
* **Durable Audit Log**: Status is synced to `running` in `status.json` and a `status_changed` event is appended to `events.ndjson`.
|
||||
|
||||
#### Phase 3: Listening (`subscribe`)
|
||||
* **Trigger**: The wrapper command launches `job_subscriber.py --job <job_id>` in the background **before** launching the agent.
|
||||
* **Broker Connection**: Connects to the resolved host, issues a QoS 1 subscription to `python/mqtt/jobs/<job_id>/events`, and blocks on an event queue.
|
||||
* **Timeout Initialization**: Dual timeouts (wall-clock budget and activity idle timer) are calculated and start ticking.
|
||||
|
||||
#### Phase 4: Execution & Progress Events (`publish`)
|
||||
* **Trigger**: The agent executes prompts within tmux and runs `publish_event.py` at boot and checkpoint stages.
|
||||
* **Network Handshake**: Publisher opens a fresh TCP/TLS socket to the broker, awaits CONNACK, publishes a single QoS 1 message, waits for PUBACK, and gracefully disconnects to avoid socket resource leaks.
|
||||
* **State Updates**: Updates `last_seq` monotonically, updates `status` to `running` (if not already), and mirrors the published payload into the local audit logs (`events.ndjson`).
|
||||
* **Subscriber Capture**: The subscriber captures the payload, performs bearer token checks, prints the formatted line to stdout, and resets its idle timer.
|
||||
|
||||
#### Phase 5: Terminal Finalization
|
||||
* **Trigger**: Agent publishes `--event completed` or `--event error`.
|
||||
* **Registry Transition**: State becomes `completed` or `error`.
|
||||
* **Retained Messaging**: The terminal event is published with `retain=True` on the broker.
|
||||
* **Subscriber Exit**: The subscriber processes the terminal event exactly once, terminates its background loops, and exits (code `0` for completed, `1` for error).
|
||||
|
||||
---
|
||||
|
||||
## 4. Code Internals Analysis
|
||||
|
||||
### 4.1 `registry.py` & `lib.sh` (Locking & Atomicity)
|
||||
Two concurrency control schemes co-exist in this workspace to coordinate state modification:
|
||||
|
||||
1. **`lib.sh::atomic_dump_yaml()`**: Used for workspace-wide tmux session inventory (`agent-sessions.yaml`).
|
||||
* **Locking**: Uses SQLite database transaction serialization via `BEGIN IMMEDIATE` on `agent-sessions.db`.
|
||||
* **Safe Mutation**: The mutation source code is passed in an environment variable `AGENT_SESSIONS_MUTATION` and executed dynamically using `exec(compile(..., 'exec'), globals())`. This isolates the execution and avoids command-injection vectors.
|
||||
* **Atomicity**: Updates the SQLite tables and then, if a session transitions to a finished state, writes to a temp file in the same directory using `tempfile.mkstemp()` and performs an `os.replace()` rename. POSIX guarantees the replacement is atomic, preventing half-written YAML reads. A `.bak` backup copy is also preserved.
|
||||
2. **`registry.py::register_job() / pick_pending() / _atomic_write_record()`**: Used for job-level metadata JSON files (`<job_id>.json`).
|
||||
* **Locking**: Wraps operations in a `registry_lock(registry_dir)` context manager, implementing an advisory exclusive lock on `.lock` via `fcntl.flock`.
|
||||
* **Atomicity**: In `_atomic_write_record()`, it uses `tempfile.mkstemp` inside the parent registry folder, serializes the updated job record to the temp file, flushes it, triggers a physical disk sync via `os.fsync(fh.fileno())`, and executes `os.replace` to replace the main JSON record file. The file permission is restricted to `0o600` immediately.
|
||||
|
||||
---
|
||||
|
||||
### 4.2 `publish_event.py` (Retries and Handshakes)
|
||||
The publisher script enforces robust error handling when sending status updates:
|
||||
* **Fresh Connection Pattern**: Instead of maintaining a persistent socket connection (which is susceptible to socket timeouts or channel leaks), `publish_event.py` opens a fresh socket, completes the authentication/TLS handshake, publishes a single QoS 1 event, waits for `PUBACK`, and closes the connection.
|
||||
* **Exponential Backoff**: Wrapped in the `with_retry()` decorator from `mqtt_common.py`. In case of socket errors (`OSError`, `TimeoutError`, `ConnectionError`), it retries up to 3 times (configurable via `--attempts`) with backoff:
|
||||
$$\text{delay} = \min(\text{base\_delay} \times \text{factor}^{\text{attempt}-1}, \text{max\_delay})$$
|
||||
Default parameters: `base_delay = 0.5s`, `factor = 2.0`, `max_delay = 8.0s`.
|
||||
* **ACK Handshake Deadlines**:
|
||||
* `CONNECT_ACK_TIMEOUT = 10s` (stops hangs during broker downtime).
|
||||
* `PUBLISH_ACK_TIMEOUT = 5s` (guarantees QoS 1 message acknowledgment before marking as published).
|
||||
|
||||
---
|
||||
|
||||
### 4.3 `job_subscriber.py` (Timers and Queue Semantics)
|
||||
The subscriber acts as the central execution watchdog:
|
||||
* **Queue Serialization**: Uses a thread-safe `queue.Queue` internally. The Paho MQTT callback thread adds messages to the queue, and the main thread processes them sequentially. This separates network I/O from state machine validation.
|
||||
* **State Machine Protection**: To safeguard against QoS 1 duplicate delivery or out-of-order broker retries, the subscriber runs a terminal state machine. It records job completion in an internal `terminal` dictionary. Once a job is marked `completed` or `error`, any subsequent events for that `job_id` are ignored:
|
||||
```python
|
||||
if event in TERMINAL_EVENTS:
|
||||
if jid in terminal:
|
||||
logger.info("ignoring duplicate terminal %s for %s", event, jid)
|
||||
continue
|
||||
terminal[jid] = event
|
||||
pending.discard(jid)
|
||||
```
|
||||
* **Dual Timeout Semantics**:
|
||||
1. **Wall-Clock Timeout**: Calculated relative to absolute startup time (`wall_deadline = start + wall_timeout`). It acts as a hard budget limit, guarding against an agent hanging indefinitely.
|
||||
2. **Activity Idle Timeout**: Measured as the difference between the current monotonic time and the last packet arrival time (`idle_left = idle_timeout - (now - last_event)`). If the agent fails to print logs or publish progress updates for the duration of the idle window, the subscriber aborts and exits with code 2.
|
||||
|
||||
---
|
||||
|
||||
### 4.4 `mqtt_common.py` (Logging & Config Resolution)
|
||||
* **Log Routing isolation**: Configured via `setup_logging()`. The root logger is bound to `sys.stderr`. This preserves the standard output stream (`stdout`) exclusively for clean JSON-lines payloads, enabling downstream bash tools to pipeline event feeds cleanly (e.g., `job_subscriber.py ... | jq`).
|
||||
* **Broker Config Resolution**: Configured in `broker_config_from_job()`. Resolves credentials hierarchically:
|
||||
1. Defaults to environment configurations (e.g. `MQTT_BROKER`, `MQTT_PORT`, `MQTT_TLS`, `MQTT_CA_CERTS`).
|
||||
2. Overlays credentials specified inside the job record JSON block (`broker.*`). This allows the agent to fetch its dedicated target broker credentials on a per-job basis.
|
||||
|
||||
---
|
||||
|
||||
## 5. Cross-System Integration
|
||||
|
||||
The delegated messaging system functions as a critical control backplane, binding shell wrappers and monitoring loops across the orchestration stack.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
User["User/Cron Client"] -->|submit| Wrap["multi-agent-mux-delegate-job (Bash)"]
|
||||
Wrap -->|registers| Reg["registry.py (Live Registry)"]
|
||||
Wrap -->|spawns background| Sub["job_subscriber.py"]
|
||||
Wrap -->|spawns tmux pane| Tmux["tmux Session (Agent Pane)"]
|
||||
|
||||
Tmux -->|executes agent| Agent["Claude / Codex Agent"]
|
||||
Agent -->|publish_event.py| Broker["MQTT Broker"]
|
||||
Broker -->|delivers events| Sub
|
||||
Broker -->|delivers events| Mon["reconcile.sh (Monitor Loop)"]
|
||||
|
||||
Mon -->|updates| Inv["agent-sessions.yaml <br> (lib.sh::atomic_dump_yaml)"]
|
||||
```
|
||||
|
||||
### 5.1 Orchestration Wrappers (`multi-agent-mux-*`)
|
||||
1. **`multi-agent-mux-delegate-job (submit)`**:
|
||||
* Registers a job, spawns `job_subscriber.py` to capture standard output streams to `.mam/jobs/<job_id>.subscriber.out`, and sleeps for `1` second.
|
||||
* Boots the agent pane in tmux:
|
||||
```bash
|
||||
tmux new-session -d -s "$sess" -c "$WORKDIR" \
|
||||
"printf '%s' \"$instructions\" | $bin --dangerously-skip-permissions; echo; read"
|
||||
```
|
||||
* Pre-seeds agent instruction headers via stdin to enforce that the agent runs `publish_event.py` for its transitions.
|
||||
* Blocks on `wait $sub_pid`, and finally prints the audit log directory.
|
||||
2. **`multi-agent-mux-monitor` (`reconcile.sh`)**:
|
||||
* **Wildcard Monitor Integration**: Runs a unified background subscriber loop (`reconcile.sh --subscribe`) to capture progress, verify security tokens (HMAC) and sequences, write audit logs, and automatically clean up tmux sessions upon terminal events.
|
||||
* **Reconciliation loop**: Subscribes to the global job topic. On terminal events, it invokes `lib.sh::atomic_dump_yaml` to sync status drifts (e.g. setting tmux sessions to `terminated` in `agent-sessions.yaml` once the agent exits).
|
||||
3. **`multi-agent-mux-create / stop / resume`**:
|
||||
* Integrates the job life status into session metadata updates, ensuring standard tmux cleanup triggers state updates in the registry and audit logs.
|
||||
|
||||
---
|
||||
|
||||
## 6. Known Limitations & Recommendations
|
||||
|
||||
### 6.1 Limitations
|
||||
|
||||
1. **Single-Host File Locking Vulnerability**:
|
||||
The advisory locking system previously relied heavily on `fcntl.flock`. While `agent-sessions.yaml` has been migrated to SQLite WAL to solve concurrent writes, the job metadata in `.mam/jobs/` still relies on `fcntl.flock` which may behave non-atomically on NFS.
|
||||
2. **Bearer Token Leakage over Plaintext (Public Broker)**:
|
||||
The `auth_token` mechanism is a simple plaintext bearer comparison. If the transport layer is unencrypted (e.g., using `broker.hivemq.com` on port `1883`), any eavesdropper on the network can steal the token and spoof legitimate events.
|
||||
3. **Subscriber Network Drop Orphanage**:
|
||||
`job_subscriber.py` does not implement automatic reconnection loops. If the subscriber loses connection to the broker, it exits, leaving the running tmux agent orphaned and without a validation/collection hook.
|
||||
4. **Lack of Ordering Guarantees in QoS 1**:
|
||||
QoS 1 guarantees delivery but not strict ordering. Under heavy backoff retries, a late-delivered progress event could land after a terminal event, causing state inconsistencies.
|
||||
|
||||
---
|
||||
|
||||
### 6.2 Recommendations
|
||||
|
||||
1. **[Implemented] Migrate to SQLite WAL Backend**:
|
||||
The `agent-sessions.yaml` locking mechanism in `lib.sh` has been upgraded to use a SQLite database (`agent-sessions.db`) configured with Write-Ahead Logging (`PRAGMA journal_mode=WAL`). The YAML file is now only updated as a finalized archive when a session reaches a terminal state (`stopped`, `terminated`, `archived`), eliminating `flock` contention during active session updates.
|
||||
**Architecture Decision Note**: This means `agent-sessions.yaml` is **no longer a real-time view** of currently `running` sessions. We have explicitly accepted the trade-off of giving up real-time text readability of running sessions in favor of robust concurrency and solving NFS flock limits. Tooling and status checks must now query the SQLite DB to observe live `running` states.
|
||||
2. **Implement Signature-Based Payload Verification**:
|
||||
Rather than sending a plaintext token, utilize HMAC signatures. The delegator and worker share a secret key; the worker publishes a signature of the payload (e.g. `HMAC-SHA256(secret_key, payload_bytes)`). The subscriber validates the signature, preventing token interception.
|
||||
3. **Enforce Mandatory Broker-Side TLS and ACLs**:
|
||||
De-prioritize plaintext support. Enforce connection over port `8883` with verified TLS certificates. Implement client certificates (mTLS) for agent authentication.
|
||||
4. **Build Auto-Reconnecting Subscriber Loops**:
|
||||
Upgrade `job_subscriber.py` to handle disconnect callbacks. Maintain a persistent queue in memory and allow the client to reconnect with exponential backoff, preventing socket dropout from terminating the orchestration flow.
|
||||
|
||||
---
|
||||
|
||||
## Glossary: Session States vs Job States
|
||||
|
||||
This project manages **two distinct state domains** that are often confused:
|
||||
|
||||
### Session States (YAML — `.mam/agent-sessions.yaml`)
|
||||
Managed by `.agents/skills/lib.sh` and the 6 `multi-agent-mux-*` skills.
|
||||
Valid values (see `lib.sh` valid-status set):
|
||||
|
||||
| State | Meaning | Set by |
|
||||
|---|---|---|
|
||||
| `running` | tmux session active, agent running | `create`, `resume` |
|
||||
| `stopped` | deliberately stopped via `--capture-id`/`--reason`/`--graceful`; conversation preserved for resume | `stop` (STOP mode) |
|
||||
| `terminated` | hard-killed via `--mode hard`; tmux session destroyed | `stop` (hard mode), `monitor` reconcile |
|
||||
| `archived` | soft-stopped via `--mode soft`; tmux left alive, YAML-only update | `stop` (soft mode) |
|
||||
|
||||
### Job States (Registry — `.mam/jobs/<id>.json`)
|
||||
Managed by `.agents/skills/multi-agent-mux-delegate-job/scripts/registry.py`.
|
||||
Valid values:
|
||||
|
||||
| State | Meaning | Set by |
|
||||
|---|---|---|
|
||||
| `pending` | job registered, agent not yet started | `registry.py register` |
|
||||
| `running` | agent picked up the job, publishing events | `publish_event.py --event started` |
|
||||
| `completed` | terminal event — agent finished successfully | `publish_event.py --event completed` |
|
||||
| `error` | terminal event — agent failed | `publish_event.py --event error` |
|
||||
| `cancelled` | job cancelled by orchestrator | `registry.py cancel` |
|
||||
|
||||
**Key distinction**: Session states track the **tmux container lifecycle** (create→stop→resume).
|
||||
Job states track the **delegated work lifecycle** (submit→run→complete/error).
|
||||
A single session can host multiple sequential jobs; a job runs within exactly one session.
|
||||
@@ -1,14 +0,0 @@
|
||||
# gRPC 기반 Multi-Agent 인터페이스 연구 사용사례
|
||||
|
||||
## 목적
|
||||
이기종 환경에서 동작하는 Multi-agent 서비스를 위한 gRPC 기반 인터페이스 모듈 설계/개발 연구 논문 작성용.
|
||||
- 연구의 필요성 및 연구 개발 내용의 방향을 설립하기 위함
|
||||
|
||||
## 요구사항
|
||||
- agy와 claude 각각 1개의 agent를 생성함.
|
||||
- 각 agent는 3개의 sub-agent를 spawn 함 (개발자, AI 에이전트 관리자, 자료분석가)
|
||||
- 각 에이전트는 자료분석 sub-agent를 통해 현재 multi-agent 가 사용되는 사례, 각 에이전트 간 통신 기술의 background를 조사하고, 개발자와 AI 에이전트 관리자 sub-agent를 통해 각자의 입장에서 필요한 요구사항을 도출함.
|
||||
- 도출된 요구사항을 만족시키기 위해 gRPC를 어떻게 활용할 수 있을지 깊이 생각한 후 보고서를 작성
|
||||
- agy와 claude는 sub-agent를 통해 작성된 보고서를 최종 검토한 후 각자의 보고 내용을 서로 피드백함.
|
||||
- agy가 claude에게 먼저 피드백 받은 후 claude는 자신의 보고서 내용을 반영한 수정방안을 agy에게 전달, agy가 최종 보고서를 작성한 후 claude가 한 번 더 검토를 거쳐 최종보고서를 작성함
|
||||
- 작성된 보고서를 너가 다시 한 번 더 검토하여 A4 6장 분량의 보고서를 작성해줘.
|
||||
@@ -1,175 +0,0 @@
|
||||
---
|
||||
title: 멀티 에이전트란
|
||||
description: 멀티에이전트의 개념 설명, 장점, 실제 구축을 통한 경험 공유를 세미나에서 발표하기 위한 발표 스크립트 및 발표자료 작성용 문서입니다.
|
||||
marp: true
|
||||
---
|
||||
# AI Agent란?
|
||||
|
||||
## General
|
||||
AI 에이전트(AI Agent)는 단순히 사용자의 텍스트 입력을 받아 응답을 생성하는 대화형 인터페이스(Chatbot)를 넘어, 주어진 최종 목표(Goal)를 자율적으로 달성하기 위해 환경을 인식(Perceive)하고, 스스로 계획(Planning) 및 추론(Reasoning)하며, 도구를 사용해 물리적/디지털적 행동(Action)을 수행하는 LLM 기반 소프트웨어 시스템입니다.
|
||||
|
||||
LLM 기반 에이전트 시스템 아키텍처는 크게 다음 3가지 핵심 요소로 구성됩니다:
|
||||
1. **Planning (계획 및 추론)**
|
||||
- **Task Decomposition (작업 분해)**: 거대하고 복잡한 목표를 실행 가능한 작은 단위의 세부 태스크로 쪼갭니다. (예: Chain of Thought, Tree of Thoughts 등)
|
||||
- **Self-Reflection (자기 성찰 및 피드백)**: 행동의 결과를 스스로 분석하고 실수를 교정하여 향후 계획을 실시간으로 수정합니다. (예: ReAct, Reflexion 프레임워크)
|
||||
2. **Memory (기억 장치)**
|
||||
- **Short-term Memory (단기 기억)**: 현재 대화나 컨텍스트 윈도우 내에서 실시간으로 유지되는 즉각적인 대화 맥락 정보입니다.
|
||||
- **Long-term Memory (장기 기억)**: 외부 데이터베이스나 벡터 DB(Vector DB) 등을 활용하여 과거 대화 기록, 대량의 지식을 인덱싱하고 필요시 RAG(검색 증강 생성) 기법으로 다시 불러오는 정보 보존 공간입니다.
|
||||
3. **Tool Use (도구 활용)**
|
||||
- 에이전트가 학습되지 않은 외부 정보에 접근하거나 외부 시스템에 영향을 미치기 위해 계산기, 코드 실행용 샌드박스, 웹 브라우저, 외부 API 등을 주도적으로 호출하는 능력입니다.
|
||||
|
||||

|
||||
|
||||
## AI Agent를 더욱 강력하게 만드는 Skills
|
||||
Skills(기술/도구 패키지)는 AI 에이전트가 외부 환경과 동적으로 상호작용할 수 있도록 결합하는 기능적 실행 모듈입니다. 단순히 LLM에게 행동 지침을 주는 프롬프트 엔지니어링 수준을 넘어, 에이전트가 특정 목표를 위해 직접 실행할 수 있는 코드, 도구의 명세 스키마(Tool Schema), 사용 설명 및 예시(Few-shot Examples) 등이 하나의 단위로 패키징된 자율적 확장 도구 모음입니다.
|
||||
- **동적 모듈화**: 에이전트는 상황에 따라 특정 기술을 필요할 때 로드하여 사용하고 완료 후 반환합니다.
|
||||
- **작업 수행 한계 돌파**: 텍스트 생성이라는 언어 모델 자체의 한계를 넘어, 시스템 레벨의 파일 제어, 서버 배포, 물리 데이터 수집 등의 적극적인 업무 대행 능력을 에이전트에 제공합니다.
|
||||
|
||||
## Claude cowork와 code
|
||||
- AI Agent의 대표 주자인 Claude는 Claude desktop 이라는 프로그램으로 자신들의 뛰어난 모델을 서비스하고 있으며, Claude Desktop App에서 우리는 Claude Cowork와 Claude Code라는 다른 서비스를 사용할 수 있음
|
||||
- Claude Cowork와 Claude Code는 같은 모델을 사용하지만 작동하는 방식이 완전히 다름. Claude Cowork의 경우 사무 작업 등 일상적인 업무의 비서 역할을 수행하며, Claude Code는 Coding 작업에 특화된 Coding Agent라고 소개함
|
||||
- 두 서비스는 동일하게 내부 컴퓨터의 특정 디렉터리에서 작업을 수행하지만 내부적인 작동 절차를 확인해보면 Claude Cowork는 Cloud에서 모델이 필요한 기능을 제공하여 pdf, word, excel, ppt 등 다양한 파일을 읽고 쓸 수 있도록 AI 모델의 기능을 확장한 서비스임
|
||||
- 반면에 Claude Code는 필요한 모듈, 소프트웨어가 있다면 Local Computer에 설치해서 사용한다는 차이점이 있음.
|
||||
|
||||

|
||||
|
||||
- Claude Cowork와 Claude Code를 비교하면 Claude Cowork가 더 좋아보일 수 있지만 Claude Code를 잘 조련하면 Claude Cowork 보다 훨씬 좋은 결과를 기대할 수 있음.
|
||||
|
||||
|
||||
|
||||
## Skills 사례 소개
|
||||
에이전트가 업무 현장에서 유용하게 사용하는 대표적인 Skills 사례들은 다음과 같습니다:
|
||||
1. **코드베이스 분석 및 관리 Skill**
|
||||
- 프로젝트 소스코드를 탐색(Grep), 특정 코드 조각을 치환(File replace), 변경 사항 검증(Linting), 최종 커밋 및 푸시 등을 처리하는 개발 자동화 Skill입니다.
|
||||
2. **브라우저 자동화 및 스크래핑 Skill**
|
||||
- 헤드리스 브라우저(Playwright, Puppeteer 등)를 기동하여 실시간 웹 트렌드 조사, 경쟁사 데이터 수집, 웹 UI에 대한 QA 테스트를 자율적으로 수행하는 Skill입니다.
|
||||
3. **인프라스트럭처 제어 및 DevOps Skill**
|
||||
- 클라우드 환경(AWS, GCP 등)이나 Firebase Hosting, Cloud Firestore 등과 같은 서버리스 백엔드 서비스의 배포 및 데이터베이스 규칙 수정을 지원하는 시스템 운영용 Skill입니다.
|
||||
4. **학술/도메인 특화 API Skill**
|
||||
- 생화학 데이터베이스(ChEMBL), 의학 학술 논문(PubMed, arXiv), 유전학 정보(dbSNP, ClinVar) 등 전문 영역의 연구용 OpenAPI와 연동하여 자율 연구원(Researcher) 역할을 돕는 조사용 Skill입니다.
|
||||
|
||||
### Github를 점령한 Skills
|
||||

|
||||
|
||||
### Understand skill
|
||||
Turn any codebase, knowledge base, or docs into an interactive knowledge graph you can explore, search, and ask questions about.
|
||||

|
||||
|
||||
### 유행이라 개발해본 multi-agent-mux skill
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
|
||||
|
||||
# Multi-Agent란
|
||||
|
||||
## General
|
||||
멀티 에이전트(Multi-Agent) 시스템은 단일 에이전트(Single Agent)의 한계를 극복하기 위해, 서로 다른 페르소나와 전문 도구(Skills)를 갖춘 여러 개의 에이전트들이 협력 네트워크를 형성하여 복잡한 목표를 조율(Orchestration)하고 분할 해결하는 구조입니다.
|
||||
|
||||
단일 에이전트는 대규모 컨텍스트를 처리할 때 정보 누락(Lost in the middle) 현상이 발생하기 쉽고, 여러 도구를 한꺼번에 다루어야 할 때 환각(Hallucination)율이 증가하는 문제가 있습니다. 멀티 에이전트의 필요성은 다음과 같습니다:
|
||||
1. **분할 정복 (Divide and Conquer)**: 하나의 거대한 프로젝트를 설계, 구현, 검증, 연구 등의 독립적인 세부 태스크로 분산시켜 병렬 및 정밀 처리를 지원합니다.
|
||||
2. **역할 정의 및 노이즈 최소화 (Role Specialization)**: 각 에이전트에게 한정된 역할(예: PM, Developer, QA, Researcher)과 타겟 도구만 부여하므로, 불필요한 프롬프트 노이즈를 제어하고 추론 정확도를 극대화할 수 있습니다.
|
||||
3. **이종 모델 융합 및 교차 검증**: 서로 다른 LLM을 각 역할에 최적화하여 융합 배치하고, 상호 비평 및 검토 루프를 구현함으로써 최종 결과물의 신뢰성을 극대화합니다.
|
||||
|
||||
## Multi Agent의 예시
|
||||
|
||||
### Subagent란
|
||||
Subagent는 부모 에이전트(Parent Agent 또는 Orchestrator)에 의해 동적으로 생성되어 특정 국소적이고 독립적인 태스크를 대행한 뒤, 결과를 상위로 반환하고 소멸하는 종속형 에이전트입니다.
|
||||
- **컨텍스트 격리**: 상위 에이전트의 전체 대화 컨텍스트를 오염시키지 않기 위해 하위 작업의 맥락만을 분기(Branch)하여 처리함으로써, 에이전트 실행 도중 누적되는 토큰 소모량을 최적화하고 속도를 개선합니다.
|
||||
- **예시**: 메인 에이전트가 "전체 코드 리팩토링"을 수행하면서, 특정 모듈에 대한 에러 복구 작업만을 subagent에게 위임하여 독립적으로 문제를 해결하게 하는 경우입니다.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### Team agent란
|
||||
Team agent는 단일 계층적인 수직 구조를 넘어, 수평적이고 다양한 역할을 맡은 여러 독립 에이전트가 협의체(Crew/Team)를 구성하여 대화형 협력(Multi-agent Debate) 및 협상을 통해 목표를 완수하는 협동형 에이전트 구성 방식입니다.
|
||||
- **의견 충돌 및 합의**: 특정 설계안에 대해 아키텍트 에이전트와 보안 담당 에이전트가 서로의 입장에서 논쟁(Debate)을 벌이고, 최종적으로 합의된 결과를 PM 에이전트가 도출하는 식의 인간 워크플로우 모사가 가능합니다.
|
||||
- **협력적 의사결정**: 복잡한 비선형적 문제 해결에 있어 각 에이전트가 피드백을 실시간으로 주고받으며 점진적으로 답을 고도화합니다.
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
### Context Engineering (Lang Graph)란
|
||||
컨텍스트 엔지니어링(Context Engineering)은 다중 에이전트 시스템에서 에이전트들 간의 대화 흐름, 전달되는 컨텍스트(State), 복잡한 제어 루프를 효율적으로 설계하고 유지하는 방법론적 학문입니다. 이를 구현하는 대표적인 상태 보존형 프레임워크가 LangGraph입니다.
|
||||
- **상태 보존 및 순환 제어(Stateful & Cyclic Control)**: 단순 선형적 체인 구조를 탈피하여, 에이전트 간의 루프(반복 검증), 조건부 분기(Conditional branching), 실패 시 롤백 등을 순환형 그래프(Cyclic Graph) 구조로 관리합니다.
|
||||
- **영속적 상태 관리**: 협업 과정에서 축적되는 다양한 상태 변화(State)를 중앙 저장소에서 추적 및 동기화하므로, 대형 워크플로우 도중 특정 노드가 실패하더라도 이전 상태부터 안전하게 재시작할 수 있습니다.
|
||||
|
||||
### CrewAI, Sana 등 Multi agent orchestration을 지원하기 위한 소프트웨어
|
||||
|
||||

|
||||
1. **CrewAI**
|
||||
- 역할 기반(Role-based) 협업을 설계하는 데 특화된 프레임워크입니다. 각 에이전트에게 명확한 역할(Role), 목표(Goal), 배경 설명(Backstory)을 부여하고, 이들을 업무 프로세스(Sequential 또는 Hierarchical)에 따라 배치해 '크루(Crew)' 단위로 조율합니다.
|
||||
1. **Microsoft AutoGen**
|
||||
- 대중 에이전트 간 대화(Conversational Agentic Design)에 중점을 둔 프레임워크입니다. 다양한 LLM과 도구(Tools)가 연결된 여러 에이전트가 서로 대화를 주고받으며 코드 실행 및 피드백을 처리할 수 있으며, 동적 워크플로우 및 인간의 개입(Human-in-the-loop)을 풍부하게 지원합니다.
|
||||
1. **BeeAI**
|
||||
- BeeAI는 프레임워크 전반에서 [AI 에이전트](https://www.ibm.com/kr-ko/think/topics/ai-agents)를 탐색, 실행 및 공유할 수 있는 중앙 집중형 환경을 제공하는 [오픈소스](https://www.ibm.com/kr-ko/think/topics/open-source) 플랫폼입니다. IBM이 개발한 BeeAI는 에이전트 통신 프로토콜(ACP)을 기반으로 구축되었으며 Linux Foundation에서 호스팅됩니다. 팀은 BeeAI 프레임워크를 사용하여 사일로화된 에코시스템 외부에 에이전트를 배포할 수 있습니다.
|
||||
|
||||
## Multi Agent 환경 구성을 위한 Protocols
|
||||
|
||||
### MCP/ACP
|
||||
다양한 서비스와 이종 에이전트들이 상호 작용하기 위해 필요한 개방형 표준 통신 프로토콜입니다.
|
||||
- **Model Context Protocol (MCP)**: Anthropic이 2024년 11월 발표한 오픈 소스 프로토콜로, AI 애플리케이션(Host) 내부의 클라이언트(Client)가 로컬/원격의 도구(Tools), 데이터 소스(Resources), 그리고 컨텍스트 템플릿(Prompts)을 통일된 규격으로 안전하게 호출할 수 있는 공통 인터페이스입니다.
|
||||
- **Agent Communication Protocol (ACP)**: IBM Research가 BeeAI 프레임워크 구동을 지원하기 위해 2025년 3월 발표했던 오픈 표준 에이전트 통신 인터페이스입니다. 이종 에이전트 간의 서비스 발견(Service Discovery), 상호작용 방법 협상, 태스크 위임 프로세스를 규격화하였으며, 이후 2025년 8월 27일 공식 깃허브 저장소가 아카이빙되며 Linux Foundation AI & Data 하위의 A2A(Agent-to-Agent) 프로토콜로 공식 통합 및 병합되었습니다.
|
||||
|
||||
### A2A
|
||||
- **A2A (Agent-to-Agent) 프로토콜**: Google이 2025년 4월에 50여 파트너사들과 공동 발표하고 2025년 6월 Linux Foundation에 기증한 이종 에이전트 간 통합 통신 오픈 표준입니다. 서로 다른 벤더가 구현한 에이전트나 다양한 아키텍처 기반의 에이전트가 복잡한 결합 코드(Glue code) 없이도 협업 메시지를 교환하고 상태를 추적할 수 있습니다.
|
||||
- **Agent Card (에이전트 카드)**: A2A 생태계에서 에이전트의 기능 명세서 역할을 하는 JSON 포맷의 표준 규격서입니다. 에이전트 카드에는 해당 에이전트의 명칭, 제공 가능한 역량(Capabilities), 호출 엔드포인트 정보, 요청 시 통과해야 하는 보안 인증 사양(Authentication) 등이 명시되어 있어 서비스 검색 시스템(Service Discovery)이 에이전트의 역할을 실시간으로 탐색할 수 있도록 돕습니다. 주로 `/.well-known/agent-card.json` 경로에서 탐색 가능하도록 배포됩니다.
|
||||
|
||||
# 실제 Multi Agent Orchestration을 구현하며 겪은 문제점들
|
||||
|
||||

|
||||
1. **Agent들과 각 Agent들의 세션 관리**
|
||||
- 다수의 에이전트와 그 아래에 동적으로 생성되는 subagent들의 생명주기(Lifecycle) 및 고유 식별자(UUID)를 동기화하고 상태를 지속적으로 보존하는 일관된 세션 관리 시스템이 필요합니다.
|
||||
2. **에이전트 상호 탐색(Service Discovery)과 역할 식별**
|
||||
- 새로운 에이전트가 네트워크에 진입했을 때 어떤 에이전트가 어떤 과업을 처리할 수 있는지 동적으로 파악해야 합니다.
|
||||
- **마스터 - 슬레이브(Master-Slave) 방식**: 중앙 오케스트레이터가 전권을 쥐고 세션을 직접 관리 및 명령하므로 통제는 쉬우나, 오케스트레이터의 에러가 시스템 전체의 단일 실패점(SPOF)이 될 위험이 있습니다.
|
||||
- **P2P 및 보고(P2P and Report) 방식**: 에이전트들이 동등한 위치에서 협상하며 자율적으로 탐색하고, 작업 결과를 동기적으로 혹은 비동기적으로 기록 보관소에 보고하는 방식이지만 네트워크 관리 비용이 큽니다.
|
||||
3. **실시간 메시징과 이벤트 예외 처리의 복잡성**
|
||||
- 태스크 위임 완료 후, 작업 결과와 성공/실패 여부를 교환하는 통신 채널이 중단되거나 유실될 수 있습니다.
|
||||
- **송신/수신 주체의 돌발 종료**: A 에이전트가 작업을 위임한 후 예상치 못하게 다운되었다가 재가동(Restart)된다면, B 에이전트가 작업을 완료한 뒤 발송한 비동기식 실시간 알람이 허공으로 날아가 결국 전체 협업 루프가 끊어집니다.
|
||||
- **무한 대기 및 리소스 누수(Deadlock & Resource Leak)**: 작업을 전달받은 subagent가 아무런 예외 통보(Timeout 또는 Error Event) 없이 갑자기 고사(Silent death)하는 경우, 작업을 위임하고 대기하던 부모 에이전트는 무한 대기(Blocking) 상태에 빠져 리소스를 계속 낭비하게 되며 시스템 모니터링에서도 이를 감지하기 어렵습니다.
|
||||
|
||||
# Multi Agent Orchestration 의 장점
|
||||
|
||||
1. **프롬프트의 간소화 및 루프 엔진의 진화**
|
||||
- 기존의 길고 장황한 단일 "Super Prompt" 엔지니어링 시대에서 벗어나, 에이전트를 구동하고 제어하는 자율 루프(Loop Architecture) 설계 중심으로 패러다임이 이동하고 있습니다.
|
||||
- *PSPDFKit 창업자이자 오픈소스 AI 에이전트 프로젝트인 OpenClaw의 크리에이터인 페터 슈타인베르거(Peter Steinberger, 2026년 초 OpenAI 합류)는 **"코딩 에이전트에 프롬프트를 더 넣지 말고, 에이전트를 구동하는 루프를 설계하라"**(Stop prompting your coding agents; start designing loops that prompt your agents)고 강조한 바 있습니다.*
|
||||
- 멀티 에이전트 구조에서는 작업 지시 PM 에이전트, 작업 수행 Worker 에이전트, 유효성 검증 Reviewer 에이전트로 나뉨으로써 프롬프트가 단편적이고 명료해집니다.
|
||||
2. **컨텍스트 설명 불필요 (Context-Free Sharing)**
|
||||
- 공유 작업 환경(Shared Workspaces)과 Git 같은 버전 관리 시스템을 에이전트들이 공유하므로, 새로 합류한 에이전트에게 변경 이력이나 현재 맥락을 다시 텍스트로 설명하느라 불필요한 토큰과 대기 시간을 낭비할 필요가 없습니다.
|
||||
3. **이종 모델 피드백을 통한 고품질 산출물 교차 검증**
|
||||
- 특정 한 모델 계열만으로 결과물을 짜고 동일한 계열에 검토를 시키는 것보다, 서로 다른 아키텍처와 특징을 지닌 이종 모델(예: Gemini로 작성한 내용을 Claude 기반 에이전트에게 피드백받음) 간에 상호 보완하도록 할 때 결과물의 신뢰성이 극적으로 향상됩니다. 이종 모델들이 세상을 바라보는 가중치와 강점이 다르기 때문에 보이지 않는 맹점(Blind spot)을 상호 보완해 줍니다.
|
||||
4. **비용 효율적인 토큰 분배 (Cost Optimization)**
|
||||
- 모든 에이전트가 값비싼 최상위 프론티어 LLM 모델만을 사용할 필요가 없습니다. 작업의 요구 난이도와 속도, 정확성을 저울질하여 쉬운 코드 생성이나 정보 검색은 경량화된 저비용 모델(예: Gemini Flash 세대)을 탑재한 에이전트에 분산 위임하고, 고난도의 논리적 추론이 필요한 부분에만 최상위 고비용 모델을 탑재한 에이전트를 적절히 매칭함으로써 종합적인 API 비용을 효율적으로 제어할 수 있습니다.
|
||||
|
||||
# What We Need?
|
||||
성공적인 Multi-Agent Orchestration 구현을 위해 갖추어야 할 기본 필수 구성요소들입니다:
|
||||
1. **Agent 간 작업 환경 공유 (A2A의 Agent Card 기반)**
|
||||
- 특정 에이전트에게 태스크를 안전하게 위임하고 위임받기 위해선, 각 에이전트 카드를 통해 서로의 역할, 엔드포인트, 입력 명세 스키마를 신뢰할 수 있는 방식으로 확인하고 동기화할 수 있어야 합니다.
|
||||
2. **에이전트 작동 인프라스트럭처 (Agent Working Infrastructure)**
|
||||
- 에이전트들이 통신에 사용할 서비스 주소와 포트를 동적으로 조회할 수 있는 서비스 디스커버리(Service Discovery) 기능 및 자동 라우팅 체계가 내재되어야 합니다.
|
||||
3. **일관성 있는 워크플로우 추적 및 공유 (Issue & Workflow Tracking)**
|
||||
- 서로 다른 에이전트가 동일한 작업을 재수행할 때 동일한 프로세스와 출력을 일관되게 얻을 수 있도록 워크플로우 사양이 제어되어야 합니다. 또한 에러나 중간 정지가 일어났을 때 복구 및 추적이 용이하도록 규격화된 이슈 추적 인터페이스(Issue Tracking Interface)를 마련해야 합니다.
|
||||
|
||||

|
||||
|
||||
4. **고성능 양방향 메시징 시스템 (Advanced Messaging System)**
|
||||
- 고용량의 코드 블록, 이미지 및 멀티모달 센서 데이터 등을 대량으로 빠르고 정확하게 공유하기 위해서는 기존의 MQTT나 단순 메시지 큐(MQ) 방식은 토픽 세분화와 페이로드 크기 한계로 인해 통신 병목을 겪을 수밖에 없습니다.
|
||||
- 단편적인 이벤트 알림만 전송하는 구조에서 탈피하여 상세 에러 트레이스, 상태 구조체, 이미지 바이너리 등을 한꺼번에 담을 수 있는 풍부한 페이로드 지원 메시징 규격이 필수적입니다. 나아가 대용량 멀티모달 푸시(Push)와 양방향 스트리밍을 지원하기 위해 HTTP/3의 멀티 스트리밍 등 고성능 통신이 수반되어야 합니다.
|
||||
5. **작업 및 태스크 관리 시스템 (Job & Task Lifecycle Controller)**
|
||||
- 하나의 부모 작업을 하위 스크럼(Scrum) 단위로 쪼개어 서브에이전트들에게 뿌려주는 라이프사이클 관리 기능이 있어야 합니다. 각 서브에이전트의 생성부터 소멸까지의 메시징 토픽과 임시 토큰을 정리해야 하며, 이 과정에서 부하를 고르게 배분하고 실력 있는 에이전트를 매칭하기 위한 로드 밸런싱(Load Balancing) 기법이 병행되어야 합니다.
|
||||
|
||||
## Why we need gRPC?
|
||||
이러한 해결 과제와 인프라 요구사항 속에서 **gRPC (Google Remote Procedure Call)** 프로토콜은 멀티 에이전트 인프라의 핵심 백본으로 기능합니다.
|
||||
1. **A2A 프로토콜과의 높은 호환성 및 스마트 팩토리/팜 최적화**
|
||||
- 개발 인터페이스 모듈은 스마트 팜, 스마트 팩토리와 같은 AIoT 서비스를 주요 타겟으로 개발되고 있습니다. 대역폭 및 성능 제약이 극심한 초경량 센서 디바이스 계층에서는 MQTT/CoAP 등을 보완적으로 혼용할 수 있으나, 상위의 데이터 수집 게이트웨이 및 엣지 연산 계층에서는 gRPC가 자율 통합 제어를 위한 강력하고 최적의 통신 프로토콜이 됩니다.
|
||||
2. **고성능 멀티 모달 바이너리 스트리밍 및 Pub/Sub 네이티브 지원**
|
||||
- AIoT나 지능형 에이전트 시스템은 텍스트뿐만 아니라 고해상도 이미지, 소리 주파수 등 대량의 멀티모달 데이터를 처리합니다. HTTP/2 또는 HTTP/3 기반으로 작동하는 gRPC는 양방향 스트리밍(Bidirectional Streaming)과 Request/Response, Pub/Sub 통신 스타일을 단일 포트에서 완벽히 소화하여, 복잡한 네트워크 요구사항을 단숨에 통합시킵니다.
|
||||
- 마이크로서비스 아키텍처(MSA)에서 메인 인터페이스로 gRPC가 통용되고 있으므로, 현업 시스템으로의 이식 및 확장 속도가 독보적으로 빠릅니다.
|
||||
3. **구조화된 직렬화 기반의 효율적 태스크 생명주기 및 부하 분산**
|
||||
- Protobuf(Protocol Buffers)를 통한 강력한 스키마 정의는 대량의 동적 서브에이전트들과 오케스트레이터 간에 쏟아지는 통신 메시지를 JSON 문자열 파싱 대비 수배 이상 빠르고 메모리 효율적으로 처리할 수 있게 합니다.
|
||||
- 이미 검증된 수많은 gRPC 기반 로드 밸런싱 및 이슈 트래킹 도구(예: Envoy, Kubernetes gRPC liveness probe 등)를 적극 도입함으로써, 에이전트 라이프사이클 감시 체계를 커스텀 빌드할 필요 없이 기성 인프라로 손쉽게 구축할 수 있습니다.
|
||||
@@ -0,0 +1,106 @@
|
||||
# 연구 협업 및 가이드라인 문서
|
||||
|
||||
본 문서는 **"GAIA (gRPC-based Agent Interface module for AIoT)"** 연구 논문 작성 및 모듈 구현을 진행하는 3인 연구팀(환웅, 광선, 사용자)의 GitHub 협업 규칙, Mattermost 연동 방식, Obsidian 초안 ↔ Overleaf 형식화 파이프라인, 그리고 논문 기술 시 지켜야 할 학술 용어 가이드라인을 정의합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. GitHub 협업 및 브랜치 전략
|
||||
|
||||
원활한 코드 개발과 논문 초안 작성을 위해 저장소를 성격에 따라 분리하고, 부모-자식 간의 Git Submodule 연동 구조를 채택합니다.
|
||||
|
||||
### 1.1 저장소 구조 (GAIA Ecosystem)
|
||||
본 프로젝트는 관심사 격리 및 마크다운 링크 유지 관리를 위해 아래의 3개 저장소 분리 및 서브모듈 구조를 따릅니다.
|
||||
|
||||
* **`gaia-paper` (Parent 저장소)**
|
||||
- *역할*: 메인 논문 초안 마크다운, LaTeX 파일, 피규어 이미지 및 참조 문서 관리.
|
||||
- *동작*: 로컬 상대 경로 링크 유지를 위해 아래 두 저장소를 Submodule로 포함합니다.
|
||||
* **`gaia-interface` (Submodule 1)**
|
||||
- *역할*: Go 기반 T2 게이트웨이, Python T1 에이전트 어댑터, `.proto` 스키마 및 실제 구현 모듈 코드.
|
||||
* **`gaia-samples` (Submodule 2)**
|
||||
- *역할*: `quic-go` 샌드박스, gRPC 기본 스트리밍 예제, MQTT 및 CoAP 프로토타이핑 등 학습 전용 샘플 코드.
|
||||
|
||||
#### 로컬 클론 및 초기화 방법
|
||||
연구원은 `gaia-paper` 저장소 하나만 클론하면 하부 서브모듈까지 일정한 디렉터리 경로로 즉시 연동됩니다:
|
||||
```bash
|
||||
# 서브모듈을 포함하여 재귀적으로 클론
|
||||
git clone --recursive https://github.com/your-org/gaia-paper.git
|
||||
|
||||
# 이미 클론받은 경우 서브모듈 초기화 및 업데이트
|
||||
git submodule update --init --recursive
|
||||
```
|
||||
|
||||
### 1.2 브랜치 구조 및 명명 규칙
|
||||
각 저장소의 핵심 개발 브랜치는 다음과 같이 운영됩니다:
|
||||
- **`main`**: 최종 릴리즈 및 제출용 빌드가 동작하는 프로덕션 브랜치.
|
||||
- **`paper/draft`**: `gaia-paper` 저장소에서 논문 마크다운 초안을 공동 편집하는 브랜치.
|
||||
- **`feature/gate-go`**: `gaia-interface` 내에서 Go 기반 T2 게이트웨이 및 `quic-go` 커스텀 어댑터를 작업하는 브랜치. (환웅 담당)
|
||||
- **`feature/agent-py`**: `gaia-interface` 내에서 Python 기반 T1 에이전트 및 A2A 바인딩을 작업하는 브랜치. (광선 담당)
|
||||
|
||||
### 1.3 Pull Request (PR) 및 코드 리뷰 규칙
|
||||
- 모든 코드 및 문서 수정 사항은 개발 브랜치에서 작업 후 `paper/draft` 또는 `main`으로 PR을 생성하여 병합해야 합니다.
|
||||
- **최소 승인 조건**: PR 병합을 위해서는 담당자 외에 **최소 1명 이상의 팀원으로부터의 사전 승인(Approve)**이 필수적입니다.
|
||||
- **리뷰 피드백**: 단순 반려(`NOT PASS`)는 금지되며, 반려 시에는 반드시 구체적인 버그/논리적 오류 원인과 대안 코드를 함께 제시해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. Mattermost 메신저 연동 가이드
|
||||
|
||||
팀 내에서 진행 상황을 실시간으로 감지하고 투명하게 공유하기 위해 Mattermost 연동을 다음과 같이 활성화합니다.
|
||||
|
||||
### 2.1 GitHub Webhook 연동
|
||||
- GitHub 저장소 설정(Settings) ➡️ Webhooks ➡️ Add Webhook
|
||||
- **Payload URL**: Mattermost GitHub Integration 서비스가 제공하는 URL 또는 수신용 웹훅 주소 입력.
|
||||
- **Content type**: `application/json`
|
||||
- **Events**: Pull Requests, Pushes, Issue Comments 활성화.
|
||||
- 수신 채널 `#git-alert`로 실시간 커밋 및 토론 흐름이 공유됩니다.
|
||||
|
||||
### 2.2 테스트베드 알림 연동
|
||||
- 에뮬레이션 테스트베드 가동 및 벤치마크 실험 완료 시 실행 결과 데이터가 Mattermost 특정 채널(`#experiment-results`)로 전송되도록 API 알림 봇 연동 스크립트를 빌드 프로세스에 포함합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. Obsidian (초안) ➡️ Overleaf (포맷팅) 워크플로우
|
||||
|
||||
논문 작성 시 마크다운의 유연함과 LaTeX의 정교한 타이포그래피를 결합하여 작성 효율성을 최대화합니다.
|
||||
|
||||
### 3.1 Obsidian 작성 규칙
|
||||
- 모든 초안은 프로젝트 내 `paper_draft/` 디렉토리에 마크다운 형식으로 분할 작성합니다.
|
||||
- **이미지 및 미디어 파일**: `assets/images/` 폴더 내에 배치하며, 문서 내에서는 **상대 경로**로 참조합니다:
|
||||
```markdown
|
||||

|
||||
```
|
||||
- 절대 경로를 사용하거나 Obsidian 전용 내부 위키 링크(`[[image]]`)를 쓰는 것은 Overleaf 변환 시 문서를 손상시키므로 금지합니다.
|
||||
|
||||
### 3.2 Overleaf 형식화 및 동기화 절차
|
||||
1. **마크다운 초안 완성**: `paper_draft/` 아래의 장별 마크다운 문서 검토를 완료합니다.
|
||||
2. **LaTeX 변환**: `pandoc` 컴파일러를 이용해 마크다운 파일을 LaTeX 포맷(`.tex`)으로 변환합니다:
|
||||
```bash
|
||||
pandoc paper_draft/chapter3_design.md -f markdown -t latex -o build/chapter3_design.tex
|
||||
```
|
||||
3. **Overleaf 업로드**:
|
||||
- Overleaf 프로젝트 내에 생성된 `.tex` 파일과 이미지 에셋(`assets/images/`)을 복사/업로드합니다.
|
||||
- Overleaf Git 연동(Pro 계정)이 활성화되어 있다면, `main` 브랜치에 변환된 `.tex` 파일을 직접 푸시하여 원격 컴파일할 수 있습니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 학술 용어 및 서술 일관성 통일 지침
|
||||
|
||||
논문 작성 및 설계 문서화 시, 구어체나 비형식적인 묘사를 배제하고 엄격한 학술 용어를 일관되게 사용합니다.
|
||||
|
||||
### 4.1 핵심 계층 정의
|
||||
- **센싱 계층 (Sensing Layer)**:
|
||||
- 단순히 데이터를 실시간 업로드하는 것에 그치지 않고, "온도·습도 등의 물리 센서 값을 정량적으로 측정 및 필터링하여 Edge 게이트웨이 또는 Cloud 서비스로 고속 전송"하는 구조적 계층으로 명확히 정의합니다.
|
||||
- **제어 계층 (Control Layer)**:
|
||||
- 에이전트의 판단에 따라 물리 환경에 개입하는 행위 계층입니다. "농약 살포, 밸브 개폐 등 물리 액추에이터에 직접 명령을 전달하고, 해당 동작이 정상 완료되었는지 루프백(Loopback) 검증하는 계층"으로 정의합니다.
|
||||
|
||||
### 4.2 용어 통일 표
|
||||
논문 전반에 걸쳐 아래의 용어로 번역 및 표현을 통일합니다:
|
||||
|
||||
| 지양할 표현 (구어체/혼용) | 권장할 학술 용어 | 영문 표기 |
|
||||
| :--- | :--- | :--- |
|
||||
| 값을 나르는 레이어, 전달층 | 센싱 계층 | Sensing Layer |
|
||||
| 일 시키고 확인하는 층, 명령층 | 제어 계층 | Control Layer |
|
||||
| 엣지박스, 중간 서버 | 엣지 게이트웨이 | Edge Gateway |
|
||||
| 중복 작동 방지 키, 의도 키 | 제어 의도 식별자 | Control Intent Key |
|
||||
| 똑같은 결과 보장 | 멱등성 보장 | Idempotency Assurance |
|
||||
| 끊겼을 때 다시 잇기 | 단절 내성 / 단절 복구 | Disconnection Tolerance / Resilience |
|
||||
@@ -74,8 +74,8 @@ MAS 분야는 1960년대 Austin과 Searle의 **화행 이론(Speech Act Theory)*
|
||||
| **MQTT 5.0** | 과도한 복잡성 | 적합 | 낮음(헤더 2B~수십 B) | Pub/Sub QoS | QoS 1/2(세션 유지) | 없음 | 가능 |
|
||||
| **CoAP** | 부적합 | 적합(저전력) | 매우 낮음(4B 헤더) | Observe(단방향) | Confirmable(제한적) | 없음 | 가능(수 KB) |
|
||||
| **WebSocket** | 보통 | 불가 | 중간(HTTP 업그레이드) | 양방향 | 없음 | 없음 | 불가 |
|
||||
| **gRPC/HTTP2** | 매우 적합 | 부분적 | 낮음(Protobuf 이진) | 4방향(Unary/Server/Client/Bidi) | Retry Policy(제한적) | 강함(Proto IDL) | 무거움(수 MB) |
|
||||
| **QUIC/HTTP3** | 매우 적합 | 부분적 | 낮음 | 양방향 | connection migration | 없음(독립) | 무거움 |
|
||||
| **gRPC/HTTP/2** | 매우 적합 | 부분적 | 낮음(Protobuf 이진) | 4방향(Unary/Server/Client/Bidi) | Retry Policy(제한적) | 강함(Proto IDL) | 무거움(수 MB) |
|
||||
| **QUIC/HTTP/3** | 매우 적합 | 부분적 | 낮음 | 양방향 | connection migration | 없음(독립) | 무거움 |
|
||||
|
||||
### 3.2 REST/HTTP·JSON의 구조적 한계
|
||||
|
||||
@@ -85,7 +85,7 @@ REST는 웹 서비스 통합에서 지배적 패러다임이지만 엣지 AIoT
|
||||
|
||||
**단방향 폴링 문제**: REST는 근본적으로 요청-응답 모델이다. T1이 다수 T2/T3 에이전트의 상태를 실시간으로 수신하려면 폴링(polling) 또는 웹훅(webhook) 구성이 필요하며, 폴링은 불필요한 네트워크 트래픽을 유발하고 웹훅은 T3 MCU에서 HTTP 서버를 동작시켜야 하는 불가능한 요구 사항을 가진다.
|
||||
|
||||
**HTTP/1.1 HoL Blocking**: Head-of-Line Blocking으로 인해 T1↔T2 간 다수 에이전트의 동시 RPC가 직렬화되어 처리 지연이 누적된다. HTTP/2 기반인 gRPC는 이 문제를 단일 연결 내 스트림 다중화로 해결한다.
|
||||
**HTTP/1.1 및 TCP HoL Blocking**: HTTP/1.1은 전송 시 Head-of-Line Blocking으로 인해 RPC가 직렬화된다. HTTP/2 기반인 gRPC는 단일 연결 내 스트림 다중화로 이를 개선하지만, 여전히 TCP 계층의 HOLB(단일 패킷 유실 시 전체 스트림 정체)는 해소하지 못한다. 본 연구에서 제안하는 gRPC over QUIC(HTTP/3)은 각 스트림의 전송을 전송 레벨에서 독립적으로 제어함으로써 이 문제를 근본적으로 극복한다.
|
||||
|
||||
**스키마 부재**: JSON은 타입 안전성을 보장하지 않는다. T3 디바이스가 장기간 배포되면 T1과 T3의 메시지 스키마가 점진적으로 불일치해도 파싱 오류로 뒤늦게 발견된다.
|
||||
|
||||
@@ -240,7 +240,7 @@ Gaba et al. [2023]은 Holochain 분산 해시 테이블(DHT)을 IoT 디바이스
|
||||
|
||||
Iyengar & Thomson [2021]의 QUIC RFC 9000은 UDP 위에서 멀티플렉싱, 암호화, connection migration을 제공하는 전송 프로토콜을 정의한다. Connection migration은 IP 주소나 포트가 변경되어도 연결 ID(Connection ID)를 유지하여 핸드오버 시 전송 계층 연결이 끊어지지 않도록 한다.
|
||||
|
||||
QUIC은 V2X와 모바일 헬스케어 시나리오에서 전송 계층 핸드오버 문제를 근본적으로 해결한다. 그러나 QUIC/HTTP3 위에서 gRPC를 실행하는 표준(gRPC over HTTP3)은 아직 실험적 단계이며, 더 중요하게는 QUIC의 connection migration이 전송 연결을 유지해도 **그 위의 gRPC 스트리밍 세션의 에이전트 태스크 상태**는 애플리케이션 계층에서 별도로 관리해야 한다. Resume Token은 QUIC과 독립적으로 필요한 에이전트 수준 메커니즘이다.
|
||||
QUIC은 V2X와 모바일 헬스케어 시나리오에서 전송 계층 핸드오버 문제를 근본적으로 해결한다. 비록 공식 gRPC 스택의 HTTP/3 표준 지원은 부분적이거나 실험적 단계에 있으나, 본 연구는 `quic-go`를 활용한 커스텀 전송 계층 어댑터(gRPC over QUIC)를 직접 구현·실증하여 이 한계를 돌파한다. 더 중요하게는 QUIC의 connection migration이 전송 연결을 유지해도 **그 위의 gRPC 스트리밍 세션의 에이전트 태스크 상태**는 애플리케이션 계층에서 별도로 관리해야 하며, Resume Token은 QUIC과 독립적이면서도 계층적으로 정합되는 핵심 단절 내성 메커니즘이다.
|
||||
|
||||
### 7.6 SPIFFE/SPIRE: 제로 트러스트 워크로드 신원
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
본 시스템의 모든 아키텍처 결정은 다음 7가지 guiding principle에 근거한다. 각 원칙은 엣지 AIoT의 물리적 이질성과 다중 에이전트 협업의 복잡성에서 도출되었다.
|
||||
|
||||
### P1. Tier-Appropriate Protocol (계층 적합 프로토콜)
|
||||
각 tier는 자신의 자원·연결성·전력 제약에 최적화된 프로토콜을 사용한다. T1 클라우드에는 gRPC/HTTP-2, T2 엣지에는 gRPC over QUIC, T3 현장 디바이스에는 MQTT/CoAP/BLE를 적용한다. **단일 프로토콜로 3-tier 전체를 포괄하려는 설계는 거부한다.** 이는 임베디드 디바이스에 gRPC 풀스택을 강제할 때 발생하는 RAM·Flash 초과 문제와, 클라우드 서버에 MQTT를 사용할 때 발생하는 스키마 안전성 부재 문제를 동시에 해결하는 근거다.
|
||||
각 tier는 자신의 자원·연결성·전력 제약에 최적화된 프로토콜을 사용한다. T1 클라우드와 T2 엣지 통신에는 gRPC over QUIC(HTTP/3)를 기본(Primary) 프로토콜로 채택하며, 방화벽 UDP 차단이나 레거시 인프라 제약 시 차선책으로 gRPC over HTTP/2를 활용한 전송 폴백(Fallback)을 적용한다. T3 현장 디바이스에는 MQTT/CoAP/BLE를 적용한다. **단일 프로토콜로 3-tier 전체를 포괄하려는 설계는 거부한다.** 이는 임베디드 디바이스에 gRPC 풀스택을 강제할 때 발생하는 RAM·Flash 초과 문제와, 클라우드 서버에 MQTT를 사용할 때 발생하는 스키마 안전성 부재 문제를 동시에 해결하는 근거다.
|
||||
|
||||
### P2. Semantic-Transport Separation (시맨틱-전송 분리)
|
||||
에이전트 간 "무엇을 할 것인가(Task 위임·역량 광고)"와 "어떻게 전달할 것인가(Byte 전송)"를 명확히 분리한다. A2A/MCP는 시맨틱 계층에서 동작하고, gRPC/MQTT/CoAP는 전송 계층에서 동작한다. 이 분리는 전송 프로토콜을 교체해도 에이전트 협업 로직이 영향받지 않도록 보장한다.
|
||||
@@ -70,7 +70,7 @@ T3 디바이스와 T2 게이트웨이 사이의 단절은 예외가 아닌 정
|
||||
║ └──────────────────────────────────────────────────────────────────────┘ ║
|
||||
╚══════════════════════════════════════════╦═══════════════════════════════════╝
|
||||
║
|
||||
gRPC over QUIC (HTTP/3) / gRPC over HTTP/2
|
||||
gRPC over QUIC (HTTP/3) [기본] / gRPC over HTTP/2 [폴백]
|
||||
W3C traceparent in gRPC Metadata
|
||||
mTLS + SPIFFE SVID
|
||||
║
|
||||
@@ -125,7 +125,7 @@ T3 디바이스와 T2 게이트웨이 사이의 단절은 예외가 아닌 정
|
||||
│ - SSE / Webhook: 비동기 Task 상태 업데이트 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ Layer 2: Transport Layer (전송 계층) │
|
||||
│ - T1↔T2: gRPC over QUIC (HTTP/3) + Bidi Streaming │
|
||||
│ - T1↔T2: gRPC over QUIC (HTTP/3) [기본] / HTTP/2 [폴백] │
|
||||
│ - T2↔T2: gRPC Bidi Streaming (엣지 합의) │
|
||||
│ - T2↔T3: MQTT 5.0 / CoAP Confirmable / gRPC-Lite │
|
||||
│ - Retry Policy: Exponential Backoff + Jitter │
|
||||
@@ -142,7 +142,7 @@ T3 디바이스와 T2 게이트웨이 사이의 단절은 예외가 아닌 정
|
||||
|
||||
| 구간 | 전송 프로토콜 | 보안 | 지연 목표 | 단절 내성 |
|
||||
|------|------------|------|----------|----------|
|
||||
| T1 ↔ T2 | gRPC over HTTP/2 or QUIC | mTLS + SPIFFE SVID | < 100ms | Resume Token + QUIC conn-migration |
|
||||
| T1 ↔ T2 | gRPC over QUIC [기본] / HTTP/2 [폴백] | mTLS + SPIFFE SVID | < 100ms | Resume Token + QUIC conn-migration |
|
||||
| T2 ↔ T2 | gRPC Bidi Streaming | mTLS + SPIFFE SVID | < 10ms | 로컬 재연결 + Bidi 재확립 |
|
||||
| T2 ↔ T3 (임베디드) | MQTT 5.0 / CoAP | X.509 SVID + TLS-PSK | < 1s | MQTT Last Will + QoS 1/2 |
|
||||
| T2 ↔ T3 (고성능) | gRPC-Lite / nanopb | mTLS Lite | < 50ms | Resume Token |
|
||||
@@ -220,9 +220,9 @@ MCP 도구 호출은 T2 게이트웨이의 gRPC 인터셉터를 통해 tracepare
|
||||
|
||||
**Rationale**: Bidi Streaming을 T2↔T2 엣지 합의에 사용하는 이유는, 다수 엣지 노드가 단일 소켓을 통해 ms 단위의 상호 메시지 교환을 진행할 수 있어 소켓 수를 O(n²)에서 O(n)으로 줄이기 때문이다.
|
||||
|
||||
#### 3.2.2 gRPC over QUIC (T1↔T2)
|
||||
#### 3.2.2 gRPC over QUIC (T1↔T2) [기본] 및 다이얼 실패 기반 전송 폴백
|
||||
|
||||
T1↔T2 광역 무선 구간에서는 gRPC를 QUIC(HTTP/3) 위에 실어 다음 이점을 얻는다.
|
||||
T1↔T2 광역 구간은 gRPC over QUIC(HTTP/3) 프로토콜을 기본(Primary) 전송 채널로 사용하며, 다음의 이점을 제공한다:
|
||||
|
||||
- **Connection Migration**: 모바일 IP 변경 시(5G→Wi-Fi) TCP 연결 단절 없이 마이그레이션
|
||||
- **0-RTT Handshake**: 재연결 시 0-RTT로 즉시 재개 (Resume Token과 시너지)
|
||||
@@ -236,6 +236,8 @@ gRPC Frame
|
||||
└── 5G NR / LTE / Wi-Fi Physical
|
||||
```
|
||||
|
||||
QUIC 연결 내부에서는 **개별 RPC 호출(Request/Response)과 네트워크 수준의 QUIC 스트림을 1:1로 매핑**하여 개별 호출의 독립 전송을 보장하고 스트림 수준의 Head-of-Line Blocking(HOLB)을 완전히 차단한다. 또한, 엣지 게이트웨이(T2)와 클라우드(T1) 사이의 전송 신뢰성을 보장하기 위해 **다이얼 실패 기반 전송 폴백(Dial-Failure-Based Transport Fallback)** 메커니즘을 내장한다. 클라이언트(게이트웨이)는 기본적으로 UDP/QUIC 다이얼러를 통해 연결을 시도하며, 방화벽 UDP 차단이나 패킷 영구 손실 등 UDP 연결 실패가 감지되는 경우 즉시 표준 TCP 기반의 `h2`(gRPC over HTTP/2) 다이얼러로 폴백하여 연결 연속성을 유지한다.
|
||||
|
||||
#### 3.2.3 T2↔T3 프로토콜 선택 기준
|
||||
|
||||
```
|
||||
@@ -321,7 +323,7 @@ T2 엣지 게이트웨이는 본 아키텍처의 핵심 허브다. 이 컴포넌
|
||||
### 4.1 컴포넌트 다이어그램
|
||||
|
||||
```
|
||||
T1 방향 (gRPC over QUIC / HTTP-2)
|
||||
T1 방향 (gRPC over QUIC [기본] / HTTP/2 [폴백])
|
||||
│
|
||||
┌───────────────▼───────────────────────────────┐
|
||||
│ gRPC Server (T1 facing) │
|
||||
@@ -774,6 +776,26 @@ Envoy Circuit Breaker 설정:
|
||||
|
||||
T2 게이트웨이 자체 Circuit Breaker는 T3 디바이스 클래스별로 독립 인스턴스를 운용하여, 특정 디바이스 클래스의 대량 실패가 다른 클래스에 영향주지 않도록 격리한다.
|
||||
|
||||
### 7.5 제어 멱등성 및 중복 제어 방지 (Control Idempotency)
|
||||
|
||||
비동기 에이전트의 재시도 루프 시 네트워크 단절 또는 지연으로 인해 동일한 제어 명령이 중복 전달되는 안전 위험을 예방하기 위해, 제어 멱등성 보장 메커니즘을 내장한다.
|
||||
|
||||
#### 1) 명령 성격 분류 (Command Classification)
|
||||
- **멱등성(Idempotent) 명령**: 동일한 인자값으로 여러 번 실행해도 물리 및 논리 상태가 일치하는 안전 명령. (예: `TelemetryService.GetLatestData()`, `DeviceService.SetState(status=SLEEP)`)
|
||||
- **비멱등성(Non-idempotent) 명령**: 실행 시마다 기기의 동작이나 화학적/물리적 상태가 누적 가산되어 중복 실행 시 위험을 초래하는 위험 명령. (예: `SprinklerService.SprayPesticide(volume=500ml)`, `PowerService.ToggleSwitch()`)
|
||||
|
||||
#### 2) Control Intent Key (CIK) 스키마
|
||||
- 비멱등 명령 호출 시 클라이언트(T1 Orchestrator 등)는 헤더(`grpc-metadata-control-intent-key`) 및 페이로드 메타데이터 내에 UUIDv4 기반의 `Control Intent Key (CIK)`를 필수로 동반해야 한다.
|
||||
- 네트워크 문제로 RPC가 실패하여 재시도할 때, 호출자는 매번 새로운 `Job ID`를 발급하더라도 최초 생성한 `CIK`를 고정하여 전송한다.
|
||||
|
||||
#### 3) T2 Gateway-side Pre-flight Check & Deduplication Cache
|
||||
- T2 게이트웨이의 gRPC 인터셉터(`IdempotencyInterceptor`)는 비멱등 명령에 대해 CIK를 캐시(`LRU Cache` 및 Redis/SQLite 등의 로컬 영속 DB 조합)와 대조한다.
|
||||
- **캐시 미스 (최초 요청)**: CIK를 'RUNNING' 상태로 캐시에 등록하고, 하부 T3 디바이스로 명령을 라우팅한 뒤 그 결과를 수신하여 'COMPLETED' 상태와 실행 결과 페이로드(ResultPayload)를 캐시에 저장하고 클라이언트에 응답한다.
|
||||
- **캐시 히트 (중복 요청)**:
|
||||
- 상태가 'RUNNING'일 경우: 진행 중인 작업으로 인지하여 클라이언트에 `codes.Aborted` ("Operation in progress")를 반환하거나 대기 스트리밍 상태를 유지한다.
|
||||
- 상태가 'COMPLETED'일 경우: 하부 T3 디바이스에 명령을 재전송하지 않고, 캐시된 `ResultPayload` 및 상태를 즉시 반환하여 중복 기기 작동을 방지한다.
|
||||
- **캐시 만료**: CIK의 유효 기간(TTL)은 각 사용 사례의 안전 마진에 따라 동적으로 지정된다(예: 스마트 팜 자동 방제 시나리오의 경우 최소 1시간 보존).
|
||||
|
||||
---
|
||||
|
||||
## 8. 멀티테넌시 설계
|
||||
@@ -995,7 +1017,7 @@ Jaeger (트레이스 저장) ← Grafana Tempo
|
||||
|
||||
| 설계 항목 | 스마트 팩토리 | 스마트 빌딩 | V2X | 헬스케어 |
|
||||
|---------|------------|-----------|-----|--------|
|
||||
| T1↔T2 백본 | gRPC + HTTP/2 | gRPC + 유선/4G | gRPC + QUIC (5G) | gRPC + 유선 |
|
||||
| T1↔T2 백본 | gRPC + QUIC (HTTP/2 폴백)* | gRPC + QUIC (HTTP/2 폴백)* | gRPC + QUIC (5G, HTTP/2 폴백) | gRPC + QUIC (HTTP/2 폴백)* |
|
||||
| T2↔T3 현장 | MQTT 5.0 / gRPC-Lite | MQTT + LoRa 브릿지 | C-V2X / ITS-G5 | BLE + MQTT |
|
||||
| 저지연 합의 | gRPC Bidi (AGV) | gRPC Unary (HVAC) | QUIC datagram | gRPC Unary (이벤트) |
|
||||
| 핸드오버 | Resume Token | Resume Token | QUIC conn-migration | Resume Token |
|
||||
@@ -1004,6 +1026,8 @@ Jaeger (트레이스 저장) ← Grafana Tempo
|
||||
| 긴급 이벤트 | gRPC Stream 알림 | gRPC Unary 알림 | C-V2X CAM/DENM | gRPC Unary < 1s |
|
||||
| A2A 위임 방향 | T1→T2→T3 계층 | T1→T2 목표 기반 | T1 교통→T2 RSU | T2 감지→T1 임상 |
|
||||
|
||||
* 주: T1↔T2 백본의 기본 전송 프로토콜은 gRPC over QUIC(HTTP/3)를 원칙으로 하나, 유선망 중심의 안정적인 고정 링크(스마트 팩토리, 헬스케어 유선망 등)에서는 인프라 호환성을 위해 gRPC over HTTP/2 폴백(Fallback)을 적극적으로 허용한다.
|
||||
|
||||
---
|
||||
|
||||
## 부록: Proto 설계 가이드라인
|
||||
@@ -39,7 +39,7 @@
|
||||
- `net/http` 인터페이스 호환 → HTTP/3 서버를 표준 핸들러로 운영 가능
|
||||
- Connection migration 내장: 핸드오버(IP 변경) 시 세션 유지
|
||||
- QUIC Datagram 확장(RFC 9221) 지원 → 저지연 V2X 메시지에 활용 가능
|
||||
- gRPC-Go 트랜스포트 레이어 교체 방식으로 통합 (`WithContextDialer` + `quic.DialAddr`)
|
||||
- TransportSelector 기반의 사전 프로브 및 분기 방식으로 통합 (UDP 성공 시 connect-go/HTTP3 RoundTripper, 실패 시 grpc-go/HTTP2 폴백)
|
||||
|
||||
#### gRPC-Go (`google.golang.org/grpc`)
|
||||
- 공식 Google Go gRPC 구현 — Interceptor Chain, Health Checking, Service Config(retry policy) 내장
|
||||
@@ -195,6 +195,7 @@ message AgentTask {
|
||||
bytes payload = 5; // Protobuf Any 직렬화
|
||||
map<string, string> metadata = 6; // X-Tenant-ID, X-Device-Class 등
|
||||
google.protobuf.Timestamp deadline = 7;
|
||||
string control_intent_key = 8; // 비멱등 제어 명령의 중복 필터링을 위한 CIK
|
||||
}
|
||||
|
||||
// 에이전트 태스크 응답
|
||||
@@ -422,14 +423,16 @@ func New(ctx context.Context, cfg GatewayConfig) (*Gateway, error) {
|
||||
return nil, fmt.Errorf("SPIFFE credentials: %w", err)
|
||||
}
|
||||
|
||||
// 2. Resume Token 관리자 초기화
|
||||
// 2. Resume Token 및 Idempotency 관리자 초기화
|
||||
tokenMgr := NewResumeTokenManager()
|
||||
idempotencyMgr := NewIdempotencyManager()
|
||||
|
||||
// 3. gRPC 서버 인터셉터 체인 구성
|
||||
srv := grpc.NewServer(
|
||||
grpc.Creds(creds),
|
||||
grpc.ChainUnaryInterceptor(
|
||||
interceptors.DeadlineEnforcer(defaultDeadlines),
|
||||
interceptors.IdempotencyUnary(idempotencyMgr),
|
||||
interceptors.ResumeTokenUnary(tokenMgr),
|
||||
interceptors.OTelUnary(),
|
||||
),
|
||||
@@ -548,7 +551,165 @@ var defaultDeadlines = map[string]int64{
|
||||
}
|
||||
```
|
||||
|
||||
> **참고**: 실제 quic-go와 gRPC-Go 통합 시 `grpc.WithContextDialer`를 사용하여 클라이언트 측에서 QUIC 다이얼러를 주입하는 방식이 더 실용적이다. 위 코드는 서버 측 리스너 래핑 패턴을 보여준다. 프로덕션에서는 `quic-go/http3` 패키지의 `ServeListener` 방식 또는 별도 gRPC-QUIC 어댑터 라이브러리(`github.com/open-telemetry/opentelemetry-collector-contrib` 참조)를 활용한다.
|
||||
> **참고**: 실제 quic-go와 gRPC-Go 통합 시 공식 gRPC 스택의 HTTP/3 지원이 실험적 단계임을 감안하여, 본 논문은 전송 계층의 독립 스트림 멀티플렉싱(HOLB 해소)을 실질적으로 확보하기 위한 두 가지 커스텀 어댑터 설계 및 다이얼 실패 기반 폴백 메커니즘을 정의한다.
|
||||
|
||||
#### 다이얼 실패 기반 전송 폴백(Dial-Failure-Based Transport Fallback) 명세
|
||||
|
||||
T1 클라우드와 T2 엣지 게이트웨이 간 무선 링크의 신뢰성을 보장하기 위해, 기본 UDP/QUIC 연결 수립 실패 시 TCP/HTTP/2 경로로 자동 전환하는 전송 폴백 메커니즘을 구현한다. 본 구조는 포트나 전송 프로토콜이 상이하여 단일 핸드셰이크 내 ALPN 강등 협상이 불가하므로, 클라이언트 단의 다이얼러에서 명시적인 연결 실패 감지 후 폴백을 수행한다.
|
||||
|
||||
##### 1. 클라이언트 측 전송 어댑터 및 트랜스포트 셀렉터 (`internal/gateway/fallback_dialer.go`)
|
||||
|
||||
공식 gRPC-Go의 단일 커넥션 다이얼러 방식은 하나의 QUIC 스트림으로 HTTP/2 전체 프레임을 터널링할 경우 QUIC 세션 내부에서 TCP 수준의 HOLB를 그대로 재현하는 설계 결함(naive 구현)을 갖는다. 이를 방지하기 위해 각 RPC 호출을 독립된 QUIC 스트림에 1:1로 매핑하는 구조를 채택하며, 다음의 두 가지 대안을 명세한다:
|
||||
|
||||
- **(a) 권장 구현 — HTTP/3 의미론 기반 매핑 (connect-go 활용)**:
|
||||
`quic-go/http3` 패키지의 `http3.RoundTripper`를 기반으로 동작하는 HTTP/3 클라이언트를 활용하여 gRPC와 와이어 프로토콜 수준에서 호환되도록 구성한다. 이 방식은 HTTP/3 명세에 따라 **개별 RPC 호출(Request/Response)이 네트워크 수준의 독립된 QUIC 스트림으로 자동 매핑**되므로 패킷 유실 시에도 다른 RPC 스트림이 정체되지 않는다. 이 권장 구현의 Go 코드 세부 사양은 부록 [internal/cloud/grpc_quic_client.go](file:///Users/godopu16/PuKi/lab/canary_projects/multi-agent-paper/gRPC_Based_Interface/IMPL_DESIGN.md#L1498)의 `NewT1Client` QUIC 분기 구현(RoundTripper 및 http.Client 연계)을 통해 구체화된다.
|
||||
- **(b) 대안 구현 — per-RPC 커스텀 ClientTransport**:
|
||||
gRPC-Go의 커스텀 `ClientTransport` 인터페이스를 직접 구현하여, 클라이언트가 RPC를 개시할 때마다 `quic.Connection.OpenStreamSync`를 호출하여 새 QUIC 스트림을 동적으로 개방하고, 해당 스트림에 length-prefixed gRPC 바이트 프레임을 직접 매핑하여 송수신한다. 단, 이 대안은 gRPC-Go 내부의 private transport API 변동에 따른 유지보수 리스크가 있음을 명시한다.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **구조적 제약 규정**: QUIC 경로 클라이언트 기동 시, 기존의 naive 터널링 회귀를 원천적으로 방지하기 위해 `grpc.DialContext` 및 `WithContextDialer` API의 사용을 구조적으로 금지한다. QUIC 전송은 오직 `http3.RoundTripper`와 와이어 수준에서 호환되는 HTTP/3 기반 클라이언트(예: `connect-go`)를 통해서만 독립 QUIC 스트림으로 개별 RPC를 매핑해야 한다.
|
||||
|
||||
아래는 다이얼 실패 감지 및 `tls.Config` 분리 복제(`Clone`)가 적용된 트랜스포트 셀렉터 구현 명세이다:
|
||||
|
||||
```go
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/quic-go/quic-go"
|
||||
)
|
||||
|
||||
// TransportKind는 연결에 사용될 최적의 전송 방식을 정의한다.
|
||||
type TransportKind int
|
||||
|
||||
const (
|
||||
TransportQUIC TransportKind = iota // UDP/QUIC (HTTP/3)
|
||||
TransportH2 // TCP/HTTP2 (폴백)
|
||||
)
|
||||
|
||||
// TransportSelector는 연결 수립 전 UDP/QUIC의 가용성을 사전에 프로브하는 셀렉터이다.
|
||||
type TransportSelector struct {
|
||||
quicTLSConfig *tls.Config // QUIC 전용 ALPN (h3)
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func NewTransportSelector(baseConfig *tls.Config, timeout time.Duration) *TransportSelector {
|
||||
// 호출자 tls.Config의 사이드 이펙트 방지를 위해 복제(Clone) 수행
|
||||
quicTLS := baseConfig.Clone()
|
||||
quicTLS.NextProtos = []string{"h3"} // QUIC은 h3 프로토콜에 한정
|
||||
|
||||
return &TransportSelector{
|
||||
quicTLSConfig: quicTLS,
|
||||
timeout: timeout,
|
||||
}
|
||||
}
|
||||
|
||||
// Probe는 UDP/QUIC 다이얼을 시도하여 가용성을 판정하고, 즉시 연결을 해제한 뒤 적절한 TransportKind를 반환한다.
|
||||
// (성공 시 커넥션을 즉시 닫고 TransportQUIC 반환. 0-RTT/세션 재개 각주 및 프로브 커넥션 캐싱 변형 병기 가능)
|
||||
func (s *TransportSelector) Probe(ctx context.Context, addr string) (TransportKind, error) {
|
||||
probeCtx, cancel := context.WithTimeout(ctx, s.timeout)
|
||||
defer cancel()
|
||||
|
||||
// 1. QUIC(HTTP/3) Dial 프로브 시도
|
||||
conn, err := quic.DialAddr(probeCtx, addr, s.quicTLSConfig, nil)
|
||||
if err == nil {
|
||||
// 프로브 성공 후 커넥션 종료 (HTTP/3 RoundTripper에서 정식으로 독립 연결을 맺고 세션을 관리하도록 함)
|
||||
conn.CloseWithError(0, "probe finished")
|
||||
return TransportQUIC, nil
|
||||
}
|
||||
|
||||
// 2. 다이얼 실패 시 h2 폴백 지시
|
||||
return TransportH2, nil
|
||||
}
|
||||
```
|
||||
|
||||
##### 2. 서버 측 전송 어댑터 명세 (`internal/gateway/server.go`)
|
||||
|
||||
서버 측 역시 단일 연결을 무조건 수락(Accept)하여 gRPC 서버에 전달할 경우 동적 다중 스트림 매핑이 손실된다. 따라서 서버 측은 다음과 같이 HTTP/3 서버 위에 `connect-go` 등의 gRPC 호환 서비스 핸들러를 마운트하여 **단일 비즈니스 로직(Handler)이 HTTP/3와 HTTP/2 폴백 연결에서 공유**되도록 구성한다.
|
||||
|
||||
```go
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/quic-go/quic-go/http3"
|
||||
)
|
||||
|
||||
type GatewayServer struct {
|
||||
h3Server *http3.Server
|
||||
h2Server *http.Server
|
||||
handler http.Handler // HTTP/3 및 HTTP/2 공용 gRPC-compatible 핸들러
|
||||
}
|
||||
|
||||
func NewGatewayServer(addr string, tlsConfig *tls.Config, handler http.Handler) *GatewayServer {
|
||||
h3TLS := tlsConfig.Clone()
|
||||
h3TLS.NextProtos = []string{"h3"}
|
||||
|
||||
h2TLS := tlsConfig.Clone()
|
||||
h2TLS.NextProtos = []string{"h2"}
|
||||
|
||||
return &GatewayServer{
|
||||
h3Server: &http3.Server{
|
||||
Addr: addr,
|
||||
TLSConfig: h3TLS,
|
||||
Handler: handler,
|
||||
},
|
||||
h2Server: &http.Server{
|
||||
Addr: addr,
|
||||
TLSConfig: h2TLS,
|
||||
Handler: handler, // 동일 비즈니스 핸들러 공유
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GatewayServer) Start() error {
|
||||
errChan := make(chan error, 2)
|
||||
|
||||
// HTTP/3 (UDP) 서버 기동
|
||||
go func() {
|
||||
errChan <- s.h3Server.ListenAndServeTLS("", "")
|
||||
}()
|
||||
|
||||
// HTTP/2 (TCP) 서버 기동 (폴백 대비 수신 채널)
|
||||
go func() {
|
||||
errChan <- s.h2Server.ListenAndServeTLS("", "")
|
||||
}()
|
||||
|
||||
return <-errChan
|
||||
}
|
||||
```
|
||||
|
||||
##### 2.1. 전송 계층 단일화 대안 및 런타임 강등 정책
|
||||
|
||||
- **M3-alt 대안: connect-go + http2.Transport 기반 폴백 단일화**:
|
||||
기본 구현은 QUIC 경로에는 `connect-go`를, 폴백 TCP 경로에는 표준 `grpc-go`를 분리 적용하여 구현하나, 설계 대안으로 **폴백 TCP 경로마저 connect-go + http2.Transport 스택으로 단일화**하는 방안을 고려할 수 있다. 이 경우 클라이언트 스택은 `http.Client` 하나로 완전히 단일화되어 `connectT1Client` 1종만 구현하면 되며, 서버 측의 단일 공용 HTTP 핸들러 공유 구조와 구조적인 대칭성을 완벽히 만족하게 된다.
|
||||
- **M4 정책: 런타임 강등 및 복구 정책**:
|
||||
T1 클라이언트 기동 이후 연속 3회 이상 네트워크 타임아웃 또는 전송 계층 에러가 검출될 경우, 클라이언트는 현재 커넥션을 파기하고 즉시 `TransportSelector.Probe`를 재수수행하여 네트워크 가용성을 다시 판정한 후 T1Client 인스턴스를 재생성한다. 단, 이 과정에서의 기존 실행 중인 RPC 세션 정보 복구 및 상태 동기화의 상세 알고리즘은 향후 연구 범위로 위임한다.
|
||||
|
||||
##### 3. 소거 실험용 비교군 명세 (Naive Single-Stream Tunneling)
|
||||
|
||||
스트림 매핑 유무가 HOLB 해소에 미치는 실질적인 영향을 입증하는 소거 실험(Ablation Study) 비교군 구성을 위해, 기존에 논의된 단일 QUIC 스트림 고정 터널링(Naive) 코드를 아래와 같이 별도로 명세한다. 이 명세는 평가 단계(Evaluation)에서 패킷 손실 시 TCP와 동등한 수준의 지연시간 병목이 유발됨을 입증하는 대조군으로 기능한다.
|
||||
|
||||
```go
|
||||
// naiveStreamConn은 하나의 QUIC 커넥션에서 단 하나의 스트림만을 점유하여
|
||||
// gRPC-Go에 net.Conn 인터페이스로 노출함으로써 강제로 단일 채널 터널링을 수행한다.
|
||||
type naiveStreamConn struct {
|
||||
quic.Stream
|
||||
conn quic.Connection
|
||||
}
|
||||
|
||||
func (c *naiveStreamConn) LocalAddr() net.Addr { return c.conn.LocalAddr() }
|
||||
func (c *naiveStreamConn) RemoteAddr() net.Addr { return c.conn.RemoteAddr() }
|
||||
// (본 naive 구현은 개별 RPC의 독립 스트림 권한을 박탈하여 세션 내 HOLB를 유발함)
|
||||
```
|
||||
|
||||
### 4.2 MQTT→gRPC 변환 엔진
|
||||
|
||||
@@ -928,6 +1089,154 @@ type resumeServerStream struct {
|
||||
func (s *resumeServerStream) Context() context.Context { return s.ctx }
|
||||
```
|
||||
|
||||
#### Idempotency gRPC Interceptor (`internal/gateway/interceptors/idempotency.go`)
|
||||
|
||||
```go
|
||||
package interceptors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
|
||||
agentv1 "github.com/your-org/edge-aiot-mas/gen/go/agent/v1"
|
||||
)
|
||||
|
||||
const controlIntentKeyHeader = "grpc-metadata-control-intent-key"
|
||||
|
||||
// CacheEntry는 CIK 캐시의 레코드를 정의한다.
|
||||
type CacheEntry struct {
|
||||
State string // "RUNNING", "COMPLETED"
|
||||
ResultPayload interface{} // 캐시된 결과 페이로드 (AgentResult)
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// IdempotencyManager는 CIK 기반 중복 제거 필터를 총괄한다.
|
||||
type IdempotencyManager struct {
|
||||
mu sync.RWMutex
|
||||
cache map[string]*CacheEntry
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// NewIdempotencyManager는 IdempotencyManager를 초기화한다.
|
||||
func NewIdempotencyManager() *IdempotencyManager {
|
||||
mgr := &IdempotencyManager{
|
||||
cache: make(map[string]*CacheEntry),
|
||||
ttl: 1 * time.Hour, // 기본 TTL 1시간
|
||||
}
|
||||
// 백그라운드에서 캐시 만료 정리 고루틴 기동
|
||||
go mgr.cleanupLoop()
|
||||
return mgr
|
||||
}
|
||||
|
||||
func (m *IdempotencyManager) cleanupLoop() {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
for range ticker.C {
|
||||
m.mu.Lock()
|
||||
now := time.Now()
|
||||
for k, v := range m.cache {
|
||||
if now.Sub(v.CreatedAt) > m.ttl {
|
||||
delete(m.cache, k)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Get은 CIK에 해당하는 캐시 데이터를 조회한다.
|
||||
func (m *IdempotencyManager) Get(cik string) (*CacheEntry, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
entry, ok := m.cache[cik]
|
||||
return entry, ok
|
||||
}
|
||||
|
||||
// Set은 CIK에 대한 캐시 레코드를 등록하거나 갱신한다.
|
||||
func (m *IdempotencyManager) Set(cik string, entry *CacheEntry) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
entry.CreatedAt = time.Now()
|
||||
m.cache[cik] = entry
|
||||
}
|
||||
|
||||
// IdempotencyUnary는 비멱등 Unary 명령의 중복 실행을 방지하는 인터셉터다.
|
||||
func IdempotencyUnary(mgr *IdempotencyManager) grpc.UnaryServerInterceptor {
|
||||
return func(
|
||||
ctx context.Context,
|
||||
req interface{},
|
||||
info *grpc.UnaryServerInfo,
|
||||
handler grpc.UnaryHandler,
|
||||
) (interface{}, error) {
|
||||
// 1. 요청 메시지가 AgentTask인지 타입 단언
|
||||
task, ok := req.(*agentv1.AgentTask)
|
||||
if !ok {
|
||||
return handler(ctx, req)
|
||||
}
|
||||
|
||||
// 2. 비멱등(Non-idempotent) 제어 명령 유형인지 검증 (infer, control, ota 등)
|
||||
if task.TaskType != "control" && task.TaskType != "ota" {
|
||||
return handler(ctx, req)
|
||||
}
|
||||
|
||||
// 3. Control Intent Key (CIK) 추출
|
||||
cik := task.ControlIntentKey
|
||||
if cik == "" {
|
||||
// 들어오는 메타데이터 헤더에서 추출 시도
|
||||
if md, ok := metadata.FromIncomingContext(ctx); ok {
|
||||
keys := md.Get(controlIntentKeyHeader)
|
||||
if len(keys) > 0 {
|
||||
cik = keys[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CIK가 비어 있으면 사전 검증을 통과시킬 수 없으므로 거부하거나 바이패스
|
||||
if cik == "" {
|
||||
return nil, status.Error(codes.InvalidArgument, "Control Intent Key (CIK) is required for non-idempotent tasks")
|
||||
}
|
||||
|
||||
// 4. 캐시 조회 및 사전 검증(Pre-flight Check)
|
||||
if entry, hit := mgr.Get(cik); hit {
|
||||
switch entry.State {
|
||||
case "RUNNING":
|
||||
return nil, status.Error(codes.Aborted, "Operation is already in progress under this Control Intent Key")
|
||||
case "COMPLETED":
|
||||
// 중복 동작 방지: 캐시된 결과 즉시 반환
|
||||
return entry.ResultPayload, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 캐시에 'RUNNING' 상태로 임시 선점
|
||||
mgr.Set(cik, &CacheEntry{State: "RUNNING"})
|
||||
|
||||
// 6. 핸들러 실행 (하부 물리 계층 명령 전달)
|
||||
resp, err := handler(ctx, req)
|
||||
if err != nil {
|
||||
// 실패 시 캐시 레코드 삭제하여 재시도 허용
|
||||
m := mgr
|
||||
m.mu.Lock()
|
||||
delete(m.cache, cik)
|
||||
m.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 7. 성공 결과 캐싱 완료 처리
|
||||
mgr.Set(cik, &CacheEntry{
|
||||
State: "COMPLETED",
|
||||
ResultPayload: resp,
|
||||
})
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### 4.4 A2A HTTP/3 엔드포인트
|
||||
|
||||
#### `internal/gateway/a2a_handler.go`
|
||||
@@ -1279,7 +1588,7 @@ import (
|
||||
telemetryv1 "github.com/your-org/edge-aiot-mas/gen/go/telemetry/v1"
|
||||
)
|
||||
|
||||
// QUICDialer는 quic-go를 사용하여 gRPC 연결을 수립하는 다이얼러다.
|
||||
// QUICDialer는 소거 실험(Ablation Study) 비교용 naive 단일 스트림 터널링 다이얼러다. (HOLB 재현 구조)
|
||||
type QUICDialer struct {
|
||||
tlsConfig *tls.Config
|
||||
}
|
||||
@@ -1289,7 +1598,7 @@ func NewQUICDialer(tlsConfig *tls.Config) *QUICDialer {
|
||||
return &QUICDialer{tlsConfig: tlsConfig}
|
||||
}
|
||||
|
||||
// DialContext는 gRPC의 WithContextDialer에 전달될 다이얼 함수다.
|
||||
// DialContext는 소거 실험군(naive) gRPC 다이얼러 함수다.
|
||||
func (d *QUICDialer) DialContext(ctx context.Context, addr string) (net.Conn, error) {
|
||||
// QUIC connection 수립
|
||||
conn, err := quic.DialAddr(ctx, addr, d.tlsConfig, &quic.Config{
|
||||
@@ -1331,23 +1640,70 @@ func (c *quicStreamConn) SetDeadline(t interface{}) error { return nil }
|
||||
func (c *quicStreamConn) SetReadDeadline(t interface{}) error { return nil }
|
||||
func (c *quicStreamConn) SetWriteDeadline(t interface{}) error { return nil }
|
||||
|
||||
// T1Client는 T1 클라우드에서 T2 게이트웨이로 연결하는 gRPC 클라이언트다.
|
||||
type T1Client struct {
|
||||
conn *grpc.ClientConn
|
||||
agentClient agentv1.AgentServiceClient
|
||||
telemetryClient telemetryv1.TelemetryServiceClient
|
||||
// TransportKind는 감지된 전송 프로토콜 종류를 나타낸다.
|
||||
type TransportKind int
|
||||
|
||||
const (
|
||||
TransportQUIC TransportKind = iota
|
||||
TransportH2
|
||||
)
|
||||
|
||||
// T1Client는 T1 클라우드에서 T2 게이트웨이로 연결하는 gRPC 클라이언트 인터페이스이다.
|
||||
type T1Client interface {
|
||||
ExecuteTask(ctx context.Context, task *agentv1.AgentTask) (*agentv1.AgentResult, error)
|
||||
StreamTelemetry(ctx context.Context, query *telemetryv1.TelemetryQuery, handler func(*telemetryv1.DataPoint) error) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
// NewT1Client는 T2 게이트웨이에 gRPC over QUIC 연결을 수립한다.
|
||||
func NewT1Client(ctx context.Context, gatewayAddr string, tlsConfig *tls.Config) (*T1Client, error) {
|
||||
dialer := NewQUICDialer(tlsConfig)
|
||||
// NewT1Client는 TransportSelector를 통해 QUIC 가용성을 우선 프로브하고 최적의 클라이언트 스택을 구성한다.
|
||||
func NewT1Client(ctx context.Context, gatewayAddr string, tlsConfig *tls.Config) (T1Client, error) {
|
||||
// T1 클라이언트 수립 시, QUIC 경로에서는 grpc.Dial 및 WithContextDialer 사용이 구조적으로 금지된다.
|
||||
selector := NewTransportSelector(tlsConfig, 3*time.Second)
|
||||
kind, err := selector.Probe(ctx, gatewayAddr)
|
||||
if err != nil {
|
||||
kind = TransportH2
|
||||
}
|
||||
|
||||
conn, err := grpc.DialContext(
|
||||
ctx,
|
||||
gatewayAddr,
|
||||
grpc.WithContextDialer(dialer.DialContext),
|
||||
grpc.WithTransportCredentials(credentials.NewTLS(tlsConfig)),
|
||||
// Retry policy: 무선 손실 시 지수 백오프
|
||||
if kind == TransportQUIC {
|
||||
// (a) 권장 구현 — connect-go + HTTP/3 RoundTripper 구성 (grpc.ClientConn 미사용)
|
||||
quicTLS := tlsConfig.Clone()
|
||||
quicTLS.NextProtos = []string{"h3"}
|
||||
h3RoundTripper := &http3.RoundTripper{
|
||||
TLSClientConfig: quicTLS,
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Transport: h3RoundTripper,
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
return &connectT1Client{
|
||||
httpClient: httpClient,
|
||||
agentClient: agentv1.NewAgentServiceClient(httpClient),
|
||||
telemetryClient: telemetryv1.NewTelemetryServiceClient(httpClient),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// (b) 폴백 구현 — grpc-go over HTTP/2 구성 (TCP 전용 다이얼러 적용)
|
||||
tcpTLS := tlsConfig.Clone()
|
||||
tcpTLS.NextProtos = []string{"h2"}
|
||||
|
||||
dialer := func(ctx context.Context, addr string) (net.Conn, error) {
|
||||
d := &net.Dialer{Timeout: 3 * time.Second}
|
||||
conn, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tlsConn := tls.Client(conn, tcpTLS)
|
||||
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||
conn.Close()
|
||||
return nil, err
|
||||
}
|
||||
return tlsConn, nil
|
||||
}
|
||||
|
||||
conn, err := grpc.DialContext(ctx, gatewayAddr,
|
||||
grpc.WithContextDialer(dialer),
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()), // dialer단에서 tls 핸드셰이크 처리 완료
|
||||
grpc.WithDefaultServiceConfig(`{
|
||||
"methodConfig": [{
|
||||
"name": [{}],
|
||||
@@ -1362,23 +1718,28 @@ func NewT1Client(ctx context.Context, gatewayAddr string, tlsConfig *tls.Config)
|
||||
}`),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("grpc dial: %w", err)
|
||||
return nil, fmt.Errorf("tcp fallback dial failed: %w", err)
|
||||
}
|
||||
|
||||
return &T1Client{
|
||||
return &grpcT1Client{
|
||||
conn: conn,
|
||||
agentClient: agentv1.NewAgentServiceClient(conn),
|
||||
telemetryClient: telemetryv1.NewTelemetryServiceClient(conn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ExecuteTask는 T2 게이트웨이에 AgentTask를 전송한다.
|
||||
func (c *T1Client) ExecuteTask(ctx context.Context, task *agentv1.AgentTask) (*agentv1.AgentResult, error) {
|
||||
// connectT1Client는 connect-go/HTTP3 기반의 T1Client 구현체이다. (RPC 단위 독립 QUIC 스트림 매핑)
|
||||
type connectT1Client struct {
|
||||
httpClient *http.Client
|
||||
agentClient agentv1.AgentServiceClient
|
||||
telemetryClient telemetryv1.TelemetryServiceClient
|
||||
}
|
||||
|
||||
func (c *connectT1Client) ExecuteTask(ctx context.Context, task *agentv1.AgentTask) (*agentv1.AgentResult, error) {
|
||||
return c.agentClient.Execute(ctx, task)
|
||||
}
|
||||
|
||||
// StreamTelemetry는 T2 게이트웨이에서 텔레메트리 스트림을 구독한다.
|
||||
func (c *T1Client) StreamTelemetry(
|
||||
func (c *connectT1Client) StreamTelemetry(
|
||||
ctx context.Context,
|
||||
query *telemetryv1.TelemetryQuery,
|
||||
handler func(*telemetryv1.DataPoint) error,
|
||||
@@ -1387,7 +1748,6 @@ func (c *T1Client) StreamTelemetry(
|
||||
if err != nil {
|
||||
return fmt.Errorf("stream start: %w", err)
|
||||
}
|
||||
|
||||
for {
|
||||
dp, err := stream.Recv()
|
||||
if err != nil {
|
||||
@@ -1399,8 +1759,47 @@ func (c *T1Client) StreamTelemetry(
|
||||
}
|
||||
}
|
||||
|
||||
// Close는 gRPC 연결을 종료한다.
|
||||
func (c *T1Client) Close() error { return c.conn.Close() }
|
||||
func (c *connectT1Client) Close() error {
|
||||
if transport, ok := c.httpClient.Transport.(*http3.RoundTripper); ok {
|
||||
return transport.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// grpcT1Client는 gRPC-Go/HTTP2 기반의 T1Client 폴백 구현체이다.
|
||||
type grpcT1Client struct {
|
||||
conn *grpc.ClientConn
|
||||
agentClient agentv1.AgentServiceClient
|
||||
telemetryClient telemetryv1.TelemetryServiceClient
|
||||
}
|
||||
|
||||
func (c *grpcT1Client) ExecuteTask(ctx context.Context, task *agentv1.AgentTask) (*agentv1.AgentResult, error) {
|
||||
return c.agentClient.Execute(ctx, task)
|
||||
}
|
||||
|
||||
func (c *grpcT1Client) StreamTelemetry(
|
||||
ctx context.Context,
|
||||
query *telemetryv1.TelemetryQuery,
|
||||
handler func(*telemetryv1.DataPoint) error,
|
||||
) error {
|
||||
stream, err := c.telemetryClient.Stream(ctx, query)
|
||||
if err != nil {
|
||||
return fmt.Errorf("stream start: %w", err)
|
||||
}
|
||||
for {
|
||||
dp, err := stream.Recv()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stream recv: %w", err)
|
||||
}
|
||||
if err := handler(dp); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *grpcT1Client) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -25,7 +25,7 @@
|
||||
|
||||
이 3-tier 사이를 관통하는 에이전트 간 통신은 같은 머신 위의 함수 호출이 아니라 **상이한 운영체제·언어·전력·연결성을 가진 원격 디바이스들 사이의 다중 홉 프로토콜** 이다. 본 연구는 엣지 AIoT 시나리오에서 gRPC가 어느 tier 사이에서 가장 효과적이고, 어느 tier에서는 별도 보완이 필요한지를 4대 사용 사례와 함께 분석한다.
|
||||
|
||||

|
||||

|
||||
|
||||
---
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
|
||||
엣지 AIoT의 3-tier 분산 구조 위에서 동작하는 4대 대표 사용 사례는 다음과 같다. 각 사례는 **자신만의 디바이스 조합·통신 요구·제약**을 갖는다.
|
||||
|
||||

|
||||

|
||||
|
||||
### 사례 ① 스마트 팩토리 (Industry 4.0)
|
||||
**배치**: T1(중앙 PM·품질분석·예지정비 에이전트) ↔ T2(엣지 컨트롤러, 라인별) ↔ T3(다수 AGV·협동로봇·CCTV·진동 센서).
|
||||
@@ -60,7 +60,7 @@
|
||||
| T3 센서 ↔ T2 | 텔레메트리 다수 팬인 | 동시 10k 노드, 1Hz/노드 |
|
||||
| T3 로봇 ↔ T1 (직접) | OTA 펌웨어, 원격 진단 | 결함 내성 다운로드 |
|
||||
|
||||

|
||||

|
||||
|
||||
### 사례 ② 스마트 빌딩 / 스마트 그리드 / 에너지
|
||||
**배치**: T1(빌딩 에너지 최적화·DR 에이전트) ↔ T2(빌딩별 또는 변전소별 엣지) ↔ T3(수만 개의 HVAC·조명·PV 인버터·스마트 미터).
|
||||
@@ -69,7 +69,7 @@
|
||||
- **T2 ↔ T1**: 비-실시간 분석·명령下发, 4G/유선
|
||||
- **엣지-로컬 합의**: 빌딩 내 HVAC 협업(피크 절감), < 100ms 응답
|
||||
|
||||

|
||||

|
||||
|
||||
### 사례 ③ 커넥티드 차량 / V2X
|
||||
**배치**: T1(클라우드 텔레매틱스·HD맵·원격 진단) ↔ T2(RSU·로드사이드 유닛·5G MEC) ↔ T3(차량 내 ECU·레이더·카메라).
|
||||
@@ -85,7 +85,7 @@
|
||||
- **T2 ↔ T1**: 일간 업로드, 비실시간
|
||||
- **긴급 이벤트**: 부정맥·낙상 감지 시 < 1s T1 알림
|
||||
|
||||

|
||||

|
||||
|
||||
**공통 통신 요구 5가지**: ① 타입 안전 ② 단방향·양방향 스트리밍 ③ 단절·핸드오버 내성 ④ 디바이스 attestation ⑤ 광역 관측성. 단 4가지 사례는 **각각 다른 우선순위**를 가진다.
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
|
||||
**직렬화 포맷 정량 비교** (예시적 추정치)¹:
|
||||
|
||||

|
||||

|
||||
|
||||
¹ Illustrative Estimates. 페이로드 복잡도·라이브러리 버전·런타임 구현에 따라 변동.
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
| **Client Streaming** | T3 → T2 다수 센서 배치 업로드, OTA 펌웨어 청크 |
|
||||
| **Bidi Streaming** | T2↔T2 엣지 합의, T3↔T2 차량/로봇 실시간 협업 |
|
||||
|
||||

|
||||

|
||||
|
||||
### 5.3 엣지 AIoT 환경에서의 약점과 보완 패턴
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
|
||||
본 절은 본 연구의 핵심 제안인 **2-tier 프로토콜 아키텍처** 를 제시한다. 단일 프로토콜이 아닌 **tier별 최적 프로토콜을 혼용**하고, T2 엣지 게이트웨이가 변환·집계·인증을 책임지는 구조다.
|
||||
|
||||

|
||||

|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
@@ -168,7 +168,7 @@
|
||||
│ - 전송: gRPC(Unary / Server-streaming) │
|
||||
│ - 거버넌스: Istio Service Mesh, OTel Collector │
|
||||
└─────────────────────────────┬────────────────────────────────────┘
|
||||
│ gRPC / HTTP-2 (유선·4G·안정)
|
||||
│ gRPC over QUIC [기본] / HTTP/2 [폴백]
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ T2: 엣지 (Jetson / 산업용 PC / 5G MEC) │
|
||||
@@ -222,7 +222,7 @@ T1↔T2 무선 구간에서 송신 측 버퍼가 차면 `WINDOW_SIZE=0` 프레
|
||||
|
||||
T3↔T2↔T1의 텔레메트리 스트리밍(사례 ①②④)이 중간에 끊기면, gRPC 서버는 **Resume Token**(마지막 전송 위치)을 발급한다. 클라이언트는 재연결 시 `Resume-Token` Metadata를 첨부해 이어받는다. 이 패턴은 gRPC Interceptor에 캡슐화되어 모바일·엣지·MCU의 일시 단절을 흡수한다.
|
||||
|
||||

|
||||

|
||||
|
||||
### 7.4 명시적 Deadline
|
||||
T3 디바이스는 네트워크 품질을 신뢰할 수 없다. `ClientContext`에 `Deadline`을 인터셉터에서 강제 주입해 단절 시 무한 대기를 방지한다. 디바이스 클래스별 기본 deadline 정책을 다르게 적용한다(예: AGV V2V 50ms, IoT 텔레메트리 5s).
|
||||
@@ -241,7 +241,7 @@ gRPC TLS 핸드셰이크 시 SAN의 SPIFFE ID를 즉시 확인해 비인가 디
|
||||
|
||||
### 8.2 2계층 통신 제어: Service Mesh + Interceptor
|
||||
|
||||

|
||||

|
||||
|
||||
| 계층 | 통제 항목 | 엣지 AIoT 적용 |
|
||||
|------|----------|----------------|
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "AgentCard",
|
||||
"description": "A2A 표준 기반 이종 에이전트 및 물리 장치 발견용 Agent Card 스키마 템플릿",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"agent_id": {
|
||||
"type": "string",
|
||||
"description": "에이전트 또는 물리 노드의 고유 UUID 또는 식별자"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "에이전트/장치 명칭 (예: smart-farm-sprinkler-01)"
|
||||
},
|
||||
"role": {
|
||||
"type": "string",
|
||||
"description": "시스템 내에서의 역할 (예: sensing-layer, control-layer, planner, developer, reviewer)"
|
||||
},
|
||||
"version": {
|
||||
"type": "string",
|
||||
"description": "에이전트 모듈 소프트웨어 버전"
|
||||
},
|
||||
"endpoint": {
|
||||
"type": "object",
|
||||
"description": "연동 엔드포인트 정보",
|
||||
"properties": {
|
||||
"uri": {
|
||||
"type": "string",
|
||||
"description": "접속 Endpoint URI (예: dns:///edge-gateway.local:50051 또는 mqtt://broker.local:1883)"
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string",
|
||||
"enum": ["grpc", "grpc-over-quic", "mqtt-v5", "coap", "http2"],
|
||||
"description": "통신 전송 프로토콜 규격"
|
||||
},
|
||||
"transport_fallback": {
|
||||
"type": "string",
|
||||
"enum": ["grpc-over-http2", "mqtt-v3.1.1", "none"],
|
||||
"description": "네트워크 제한 시 fallback할 전송 계층"
|
||||
}
|
||||
},
|
||||
"required": ["uri", "protocol"]
|
||||
},
|
||||
"security": {
|
||||
"type": "object",
|
||||
"description": "보안 및 신원 정보",
|
||||
"properties": {
|
||||
"auth_type": {
|
||||
"type": "string",
|
||||
"enum": ["spiffe-svid", "hmac-sha256", "mtls", "none"],
|
||||
"description": "인증/인가 보안 프로토콜"
|
||||
},
|
||||
"spiffe_id": {
|
||||
"type": "string",
|
||||
"description": "SPIFFE 신원 식별자 (예: spiffe://example.org/ns/smartfarm/sa/sprinkler)"
|
||||
},
|
||||
"hmac_token_ref": {
|
||||
"type": "string",
|
||||
"description": "HMAC 토큰 서명을 위한 환경변수명"
|
||||
}
|
||||
},
|
||||
"required": ["auth_type"]
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "array",
|
||||
"description": "에이전트가 제공하는 과업/제어 기능 및 RPC 메소드 매핑",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "기능/명령 이름 (예: spray_pesticide)"
|
||||
},
|
||||
"grpc_method": {
|
||||
"type": "string",
|
||||
"description": "gRPC 패키지/메서드 매핑 경로 (예: /smartfarm.ControlService/SprayPesticide)"
|
||||
},
|
||||
"mqtt_topic": {
|
||||
"type": "string",
|
||||
"description": "하부 센싱/제어 매핑 MQTT 토픽 (예: farm/device/sprinkler/control)"
|
||||
},
|
||||
"idempotency": {
|
||||
"type": "string",
|
||||
"enum": ["idempotent", "non-idempotent"],
|
||||
"description": "제어 명령의 멱등성 여부 분류 (비멱등 시 CIK 적용 필수)"
|
||||
},
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"description": "입력 파라미터 Protobuf/JSON 스키마 정의"
|
||||
}
|
||||
},
|
||||
"required": ["name", "idempotency"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["agent_id", "name", "role", "endpoint", "security", "capabilities"]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 575 KiB After Width: | Height: | Size: 575 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 618 KiB After Width: | Height: | Size: 618 KiB |
|
Before Width: | Height: | Size: 439 KiB After Width: | Height: | Size: 439 KiB |
|
Before Width: | Height: | Size: 614 KiB After Width: | Height: | Size: 614 KiB |
@@ -0,0 +1,127 @@
|
||||
# Herdr 사용 가이드
|
||||
|
||||
## 소개
|
||||
|
||||
Herdr는 "코딩 에이전트를 위한 tmux"라고 볼 수 있는 터미널 워크스페이스 매니저(agent multiplexer)입니다. 여러 AI 코딩 에이전트(Claude Code, Codex 등)를 각각 실제 터미널 pane에서 실행하면서, 어떤 에이전트가 작업 중인지 / 입력을 기다리는지(blocked) / 끝났는지를 사이드바에서 한눈에 확인할 수 있고, detach해도 백그라운드에서 계속 실행됩니다. Rust로 작성된 로컬 바이너리이며 별도의 GUI 앱이나 클라우드 계정이 필요 없습니다.
|
||||
|
||||
공식 사이트: [herdr.dev](https://herdr.dev)
|
||||
|
||||
## 주요 사용사례
|
||||
|
||||
- 여러 에이전트를 동시에 병렬로 실행하며 상태만 사이드바에서 훑어보기
|
||||
- SSH나 휴대폰으로 원격 접속해 백그라운드 세션에 이어 붙기 (detach/reattach)
|
||||
- 소켓 API/CLI로 스크립트나 다른 에이전트(오케스트레이터)가 에이전트에 명령을 넣고 결과를 읽어오는 자동화
|
||||
- 서버 재시작 후에도 에이전트 네이티브 세션을 복원해서 이어가기 (Claude Code, Codex 등 통합 시)
|
||||
- 하나의 워크스페이스(프로젝트) 안에서 여러 개의 서로 다른 workspace/session으로 완전히 격리된 작업 공간 운용
|
||||
|
||||
## 개념 정리
|
||||
|
||||
| 개념 | 설명 |
|
||||
|---|---|
|
||||
| Session | 지속되는 Herdr 서버 하나(런타임 인스턴스). `herdr`는 기본 세션에 붙고, `herdr --session <name>`으로 named session을 launch-or-attach 할 수 있음 |
|
||||
| Workspace | 세션 안의 최상위 프로젝트 컨테이너. 보통 repo/작업 단위로 하나씩 |
|
||||
| Tab | workspace 안의 레이아웃 |
|
||||
| Pane | 실제 터미널 하나 |
|
||||
| Agent | Herdr가 pane 안에서 인식하는 프로세스 (Claude Code, Codex 등). 상태: blocked/working/done/idle/unknown |
|
||||
| Channel | Herdr 바이너리 자체의 업데이트 트랙 (stable/preview) — session과 무관한 별개 개념 |
|
||||
|
||||
## 주요 단축키
|
||||
|
||||
prefix 키 기본값은 `ctrl+b`. `prefix+?`로 언제든 전체 목록 확인 가능.
|
||||
|
||||
가장 먼저 배울 5가지:
|
||||
|
||||
| 동작 | 키 |
|
||||
|---|---|
|
||||
| 새 탭 | `prefix+c` |
|
||||
| 좌우/상하 분할 | `prefix+v` / `prefix+minus` |
|
||||
| 패널 이동 | `prefix+h/j/k/l` |
|
||||
| 워크스페이스 탐색 | `prefix+w` |
|
||||
| detach (세션 유지) | `prefix+q` |
|
||||
|
||||
추가 자주 쓰는 것:
|
||||
|
||||
| 동작 | 키 |
|
||||
|---|---|
|
||||
| 패널 확대(zoom) | `prefix+z` |
|
||||
| 패널 닫기 | `prefix+x` |
|
||||
| 카피 모드 | `prefix+[` |
|
||||
| 다음/이전 탭 | `prefix+n` / `prefix+p` |
|
||||
| 새 워크스페이스 | `prefix+shift+n` |
|
||||
| 사이드바 토글 | `prefix+b` |
|
||||
|
||||
마우스만으로도 클릭/드래그/우클릭 메뉴로 대부분 조작 가능 (mouse-native).
|
||||
|
||||
## 실습: 세션 생성 → Claude 실행 → 소켓으로 프롬프트 전달 → 결과 확인
|
||||
|
||||
### 1. 현재 디렉터리를 cwd로 하는 새 세션 `new_session` 생성
|
||||
|
||||
```bash
|
||||
cd <현재 작업 디렉터리>
|
||||
herdr --session new_session
|
||||
```
|
||||
|
||||
Herdr의 세션은 launch-or-attach 모델이라 이 한 줄로 세션이 없으면 생성하고 동시에 attach까지 됩니다.
|
||||
|
||||
### 2. 생성된 세션에서 Claude 실행
|
||||
|
||||
attach된 pane 안에서:
|
||||
|
||||
```bash
|
||||
claude
|
||||
```
|
||||
|
||||
(테스트용으로 권한 프롬프트를 건너뛰려면 `claude --dangerously-skip-permissions`. 신뢰된 환경에서만 사용 권장.)
|
||||
|
||||
### 3. 다른 터미널에서 herdr 명령으로 프롬프트 전달 (직접 타이핑 아님)
|
||||
|
||||
새 터미널에서 에이전트 목록과 pane_id 확인:
|
||||
|
||||
```bash
|
||||
herdr --session new_session agent list
|
||||
herdr --session new_session agent get claude
|
||||
```
|
||||
|
||||
텍스트 입력 + Enter 제출을 원자적으로 수행:
|
||||
|
||||
```bash
|
||||
herdr --session new_session pane run <pane_id> "정렬 프로그램을 작성해줘"
|
||||
```
|
||||
|
||||
### 4. Claude의 결과 화면 출력
|
||||
|
||||
완료될 때까지 기다렸다가 읽기:
|
||||
|
||||
```bash
|
||||
herdr --session new_session wait agent-status <pane_id> --status idle
|
||||
herdr --session new_session pane read <pane_id> --source recent --lines 150
|
||||
```
|
||||
|
||||
현재 화면만 바로 보기:
|
||||
|
||||
```bash
|
||||
herdr --session new_session pane read <pane_id> --source visible --lines 80
|
||||
```
|
||||
|
||||
`--session new_session`을 매번 붙이는 대신 `export HERDR_SESSION=new_session`으로 환경변수를 설정하면 이후 명령에서 생략 가능합니다.
|
||||
|
||||
## 참고: 다중 workspace 간 에이전트 제어
|
||||
|
||||
Herdr의 소켓 API/CLI는 workspace 단위로 격리되지 않고 세션 전체가 하나의 소켓을 공유합니다. `pane_id`가 `w1:p1` 같은 전역 ID라서, 지금 어느 workspace에 있든 다른 workspace의 에이전트를 `agent send`, `agent read`, `wait agent-status`, `pane run` 등으로 그대로 제어할 수 있습니다.
|
||||
|
||||
```bash
|
||||
herdr workspace create --cwd ~/project --label claude-test
|
||||
herdr workspace list # 방금 만든 workspace_id 확인
|
||||
herdr agent start claude-test --workspace <workspace_id> -- claude
|
||||
herdr agent send claude-test "테스트"
|
||||
herdr wait agent-status claude-test --status idle
|
||||
```
|
||||
|
||||
## 출처
|
||||
|
||||
- [herdr.dev/docs](https://herdr.dev/docs/)
|
||||
- [herdr.dev/docs/cli-reference](https://herdr.dev/docs/cli-reference/)
|
||||
- [herdr.dev/docs/socket-api](https://herdr.dev/docs/socket-api/)
|
||||
- [herdr.dev/docs/keyboard](https://herdr.dev/docs/keyboard/)
|
||||
- [herdr.dev/docs/agents](https://herdr.dev/docs/agents/)
|
||||
- [herdr.dev/docs/persistence-remote](https://herdr.dev/docs/persistence-remote/)
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,409 @@
|
||||
---
|
||||
title: AI Multi-Agents 오케스트레이션 기술 동향 및 사례
|
||||
description: AI 에이전트의 핵심 기법부터 MCP/SKILL 확장, TMUX/crewAI/LangGraph 오케스트레이션 인프라, ACP/A2A 상호운용성 표준, AIoT/gRPC 동향까지 다루는 특강 발표자료용 문서입니다.
|
||||
marp: true
|
||||
---
|
||||
<!-- NOTE: 이 문서의 동기화 사본이 paper_draft/SLIDE.md에 존재합니다. 이미지 상대경로는 루트 기준이므로 렌더링은 루트 SLIDE.md로 수행하세요. -->
|
||||
|
||||
# AI Multi-Agents 오케스트레이션 기술 동향 및 사례
|
||||
### 정적 AI 모델에서 자율적 AI 에이전트로의 패러다임 전환
|
||||
|
||||
---
|
||||
|
||||
## 🔍 이 발표에서 다루고 싶은 핵심 화두
|
||||
|
||||
최근 AI 분야에서 가장 뜨거운 화두는, 단순히 사용자의 질문에 답을 하는 **정적 AI 모델(LLM)**을 넘어 스스로 목적을 가지고 움직이는 **자율형 AI 에이전트(AI Agent)**로의 패러다임 전환입니다.
|
||||
|
||||
우리가 흔히 쓰는 AI 모델이 똑똑하게 생각하고 답변을 생성하는 '지능 엔진'이라면, AI 에이전트는 그 지능에 자율성과 행동력, 그리고 다른 에이전트와의 협업 능력까지 결합한 실제 '행동의 주체'라고 볼 수 있습니다.
|
||||
|
||||
오늘 발표에서는 단순한 AI 모델을 자율적인 에이전트로 업그레이드하는 구체적인 엔지니어링 방법론부터 시작하여, 여러 에이전트들이 유기적으로 협업하는 오케스트레이션 인프라와 산업 표준 기술 동향을 아주 쉽게 풀어보려 합니다.
|
||||
|
||||
---
|
||||
|
||||
### 💡 구체적 사례 비교: "멀티 에이전트 관련 연구를 조사해줘"
|
||||
|
||||
사용자가 **"멀티 에이전트 관련 최신 연구 동향을 조사하고 보고서로 저장해줘"**라는 명령을 내렸을 때, 두 시스템은 완전히 다르게 움직입니다.
|
||||
|
||||
먼저 **AI 모델(LLM)**은 머릿속(학습된 데이터)에 있는 지식만을 꺼내서 기존 연구 목록을 말로 친절하게 설명해 줍니다. 겉보기에는 똑똑해 보이지만, 실시간으로 최신 논문을 인터넷에서 검색해 오거나 보고서 파일을 디스크에 직접 저장하는 '행동'은 수행하지 못합니다.
|
||||
|
||||
반면 **AI 에이전트(Agent)**는 명령을 받자마자 실제 행동을 개시합니다. 학술 API에 접속해 최신 논문을 검색해 오고, PDF 파일을 직접 다운로드해서 그 내용을 파싱한 다음, 최종 결과물을 마크다운 형태의 파일로 사용자의 컴퓨터 폴더에 실제로 만들고 저장해 줍니다.
|
||||
|
||||
---
|
||||
|
||||
### 📊 역할과 작동 방식의 결정적 차이
|
||||
|
||||
이 두 개념의 차이는 다음과 같이 쉽게 풀어볼 수 있습니다.
|
||||
|
||||
- **생각(Thinking)과 행동(Doing)의 차이**
|
||||
AI 모델은 질문을 던지면 이에 부합하는 지식이나 코드를 화면에 '답변'해 주는 똑똑한 뇌 역할을 합니다. 반면 AI 에이전트는 최종 목적지에 도달하기 위해 스스로 세부 계획을 세우고 실제 컴퓨터 환경에서 발로 뛰는 손과 발 역할을 함께 수행합니다.
|
||||
- **조언(Advise)과 실행(Execution)의 차이**
|
||||
모델은 "이 스크립트를 이렇게 실행해서 저장하세요"라고 텍스트로 가이드라인만 알려줍니다. 하지만 에이전트는 운영체제나 파일 시스템에 직접 들어가서 스크립트를 실제로 실행하고 그 결과물을 직접 파일로 구체화합니다.
|
||||
- **오류를 마주했을 때의 대처 능력**
|
||||
모델은 실행 중 오류가 났을 때 사람이 에러 로그를 다시 복사해서 붙여넣어 주기 전까지는 대기할 수밖에 없습니다. 반면 에이전트는 오류가 나면 스스로 로그를 해석하고, 성찰(Reflection) 과정을 거쳐 계획을 수정해 다시 시도합니다.
|
||||
|
||||
---
|
||||
|
||||
### 🚗 아주 쉬운 비유: "운전자"와 "자율주행 차량"
|
||||
|
||||
그렇다면 에이전트는 모델이 시키는 대로만 움직이는 수동적인 존재일까요? 결코 그렇지 않습니다.
|
||||
|
||||
- **AI 모델(LLM)은 '운전자(뇌)' 입니다.**
|
||||
"여기서 우회전하고 다음에서 멈추자"라는 인지적 판단과 주행 경로(추론)를 수립합니다.
|
||||
- **AI 에이전트(Agent)는 '자율주행 차량 시스템 전체' 입니다.**
|
||||
운전자의 판단을 가속 페달과 바퀴 회전(도구 실행)으로 바꾸며, 다음과 같은 독자적인 **시스템 수준의 판단**을 수행합니다:
|
||||
- **안전 가드레일**: 운전자가 실수로 시스템을 파괴하는 명령과 같이 위험한 길로 가려 할 때 비상 제동을 걸어 이를 원천 차단합니다.
|
||||
- **예외 복구 및 통제**: 네트워크 연결이 끊기거나 무한 루프에 빠지는 상황이 발생하면 스스로 판단하여 실행을 중단하고 우회 경로를 수립합니다.
|
||||
- **합의 형성(Consensus)**: 멀티 에이전트 환경에서 각 에이전트들의 교차 검증 결과를 수집하여 통과나 반려 여부를 결정하고 최종 집행을 판정합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ AI 에이전트가 갖춰야 할 4대 필수 기능
|
||||
|
||||
단순히 대화만 나누는 챗봇이 아니라, 진짜 제 역할을 하는 **자율형 AI 에이전트**가 되기 위해 소프트웨어 시스템 차원에서 반드시 제공해야 하는 4가지 핵심 기능입니다.
|
||||
|
||||
1. **계획 및 추론 능력 (Planning)**
|
||||
2. **기억 및 상태 관리 능력 (Memory)**
|
||||
3. **도구 활용 및 실행 능력 (Tool Use & Action)**
|
||||
4. **상호 통신 및 협업 능력 (Collaboration)**
|
||||
|
||||
---
|
||||
|
||||
### 1. 계획 및 추론 능력 (Planning)
|
||||
|
||||
사용자가 최종 목표만 주면 스스로 실행 가능한 단계별 세부 태스크를 설계하고, 진행 과정에서 문제가 생기면 계획을 유연하게 수정하는 능력입니다.
|
||||
|
||||
- **Task Decomposition (작업 분해)**: 복잡한 목표를 실행 가능한 작은 단위의 세부 태스크로 분할 (예: Chain of Thought, Tree of Thoughts 등)
|
||||
- **Self-Reflection (자기 성찰 및 피드백)**: 행동 결과를 스스로 분석하고 실수를 교정하여 향후 계획을 실시간으로 수정 (예: ReAct, Reflexion 프레임워크)
|
||||
|
||||
* **실제 예시**:
|
||||
에이전트에게 "블로그 기사 작성"을 요청하면, 스스로 **'키워드 조사 ➡️ 개요 작성 ➡️ 본문 집필 ➡️ 오탈자 검사'** 순으로 계획을 세웁니다. 만약 맞춤법 검사 도중 치명적인 논리 오류를 발견하면, 본문 작성 단계로 스스로 되돌아가 계획을 수정하고 다시 쓰는 자가 성찰(Self-Reflection)을 거칩니다.
|
||||
|
||||
---
|
||||
|
||||
### 2. 기억 및 상태 관리 능력 (Memory)
|
||||
|
||||
현재 나누는 대화의 즉각적인 흐름(단기 기억)뿐만 아니라, 과거의 경험과 누적된 지식(장기 기억)을 필요할 때마다 영속적으로 꺼내어 쓰는 능력입니다.
|
||||
|
||||
- **Short-term Memory (단기 기억)**: 현재 대화나 컨텍스트 윈도우 내에서 실시간으로 유지되는 즉각적인 맥락 정보
|
||||
- **Long-term Memory (장기 기억)**: 외부 데이터베이스나 벡터 DB를 활용하여 과거 대화 기록 및 지식을 RAG 기법으로 바인딩하는 정보 보존 공간
|
||||
|
||||
* **실제 예시**:
|
||||
에이전트에게 "어제 작업하던 파이썬 소스코드의 오류를 이어서 수정해줘"라고 요청하는 경우입니다. 에이전트는 데이터베이스에 누적된 과거 대화 히스토리와, 벡터 DB에 저장되어 있는 소스코드의 예전 상태 정보를 동적으로 인출(Retrieval)해 와 대화 맥락을 끊김 없이 이어갑니다.
|
||||
|
||||
---
|
||||
|
||||
### 3. 도구 활용 및 실행 능력 (Tool Use & Action)
|
||||
|
||||
인터넷 검색, 외부 API 호출, 파일 시스템 접근, 코드 실행 등 컴퓨터 세상의 다양한 소프트웨어를 직접 연결하고 작동시키는 손과 발 역할을 의미합니다.
|
||||
|
||||
* **실제 예시**:
|
||||
복잡한 나눗셈 연산을 해야 할 때 직접 계산기 도구를 호출해 오차 없이 연산하고, 최신 주식 시세를 알기 위해 증권사 API를 호출하며, 작성한 코드가 잘 돌아가는지 검증하기 위해 격리된 샌드박스 컴퓨터 환경(Docker)을 구동해 스크립트를 직접 실행합니다.
|
||||
|
||||
---
|
||||
|
||||
### 4. 상호 통신 및 협업 능력 (Collaboration)
|
||||
|
||||
혼자서 모든 일을 처리하는 것이 아니라, 다른 역할을 가진 전문 에이전트나 사용자 시스템과 표준 규격으로 메시지를 주고받으며 큰 작업을 분산 처리하는 능력입니다.
|
||||
|
||||
* **실제 예시**:
|
||||
"이 프로젝트의 보안 취약점 보고서를 작성해줘"라는 명령을 내렸을 때의 상황입니다. 보안 에이전트가 소스코드를 스캔해 취약점을 나열하면, 인프라 에이전트가 가상 머신 설정을 검토하고, 최종적으로 리뷰어 에이전트들이 보고서의 신뢰성을 상호 검증하여 하나의 완성된 산출물을 합작해 냅니다.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 패러다임 전환: Model eats the Scaffolding (모델이 외부 시스템을 흡수하다)
|
||||
|
||||
현재 에이전트 기술에서 가장 중요한 변화 중 하나는, 과거에 에이전트 제어 시스템(Scaffolding, 에이전트의 구동을 돕는 외부 뼈대 구조)이 수동으로 제어하던 기능들이 점점 AI 모델 내부로 흡수되고 있는 현상입니다.
|
||||
|
||||
- https://arca.live/b/characterai/174977057?category=%EB%89%B4%EC%8A%A42&p=1
|
||||
|
||||
- **스스로 생각하는 모델의 등장 (Test-time Compute, 추론 시점 추가 연산)**
|
||||
최근 출시된 Gemini Thinking이나 OpenAI o1/o3 같은 모델들은 외부 시스템이 루프를 돌려주지 않아도, 모델 스스로 출력을 내보내기 전에 내부적으로 계획을 세우고(Planning) 스스로 오류를 성찰(Reflection)하는 과정을 완료합니다.
|
||||
- **인지와 집행의 명확한 역할 분담**
|
||||
이에 따라 고차원적인 논리 설계와 계획(Planning)은 **AI 모델** 내부로 빠르게 넘어가고 있습니다. 반면 외부의 **에이전트 인프라(Harness, 에이전트 실행 및 도구 제어 장치)**는 안전한 실행 환경(Sandboxing, 격리 환경 실행), 권한 통제, 상태 관리처럼 모델이 직접 수행하기 어려운 물리적 보호막 역할을 담당하는 방향으로 진화하고 있습니다.
|
||||
- **차별화 요소(Alpha)의 이동**
|
||||
단순히 계획을 짜는 흐름을 코드로 구현하는 것의 가치는 점차 줄어들고 있습니다. 이제는 복잡한 인프라를 실시간으로 제어하고, 서로 다른 규격을 가진 다양한 에이전트들을 표준 프로토콜로 유기적으로 엮어내는 기술이 핵심 경쟁력으로 부상하고 있습니다.
|
||||
|
||||
---
|
||||
|
||||
# 1. AI 에이전트 구현을 위한 3대 핵심 엔지니어링 기법
|
||||
|
||||
성공적인 AI 에이전트 시스템을 구현하기 위해서는 단순히 모델에게 프롬프트를 입력하는 것을 넘어, **모델 제어**, **정보 연동**, **물리적 환경 연결**을 유기적으로 엮어내는 3가지 핵심 엔지니어링 기법이 필요합니다.
|
||||
|
||||
1. **프롬프트 엔지니어링 (Prompt Engineering)**: 에이전트의 페르소나와 사고 방식(추론 가이드라인)을 규정하는 작업입니다.
|
||||
2. **컨텍스트 엔지니어링 (Context Engineering)**: 대화 흐름을 끊김 없이 보존하고 관련 데이터를 적시에 제공하는 정보 정리 작업입니다.
|
||||
3. **하네스 엔지니어링 (Harness Engineering)**: 격리된 환경에서 다양한 소프트웨어 도구를 안전하게 조작할 수 있는 물리적 손발을 달아주는 작업입니다.
|
||||
|
||||
---
|
||||
|
||||
### 1️⃣ 프롬프트 엔지니어링 (Prompt Engineering)
|
||||
|
||||
모델의 발전과 자체 추론 기능의 향상으로 예전만큼 미시적인 프롬프트 트릭에 집착할 필요는 줄어들고 있습니다. 하지만 프롬프트는 여전히 **AI 에이전트 활용의 시작점이자 뼈대**입니다. 구체적이고 명확한 작동 지침을 만들기 위해 프롬프트 작성 시 반드시 반영해야 할 3대 핵심 고려사항입니다.
|
||||
|
||||
- **역할 및 페르소나 지시 (Role)**
|
||||
에이전트에게 전문 도메인 지식과 행동 경계를 지정해 줍니다. (예: *"너는 주니어 개발자를 코칭하는 꼼꼼한 테크리더 에이전트다. 직접 고치지 말고 가이드라인만 제공해라."*)
|
||||
- **구체적인 작업 예시 제공 (Few-shot)**
|
||||
원하는 출력 형식이나 중간 추론 과정의 모범 예시를 제공하여, 에이전트의 답변 일관성과 가독성을 극대화합니다.
|
||||
- **자가 검증 방법 제시 (Verification)**
|
||||
에이전트 스스로 작업의 무결성을 점검하게 하거나, 외부 시스템이 실행 결과를 확인 및 통제할 수 있도록 정형화된 출력 규격(예: 최종 통과 시 `[VERDICT: PASS]` 명시 요구)을 정의해 줍니다.
|
||||
|
||||
---
|
||||
|
||||
### 💡 실제 프롬프트 구조 예시 (ReAct 사고 방식)
|
||||
|
||||
에이전트가 단번에 대답하지 않고 단계별로 계획을 세워 도구를 사용하도록 프롬프트 구조를 강제하는 기법입니다.
|
||||
|
||||
```text
|
||||
[System Prompt]
|
||||
너는 복잡한 수식을 계산하는 수학 에이전트다. 다음 형식으로 사고해라:
|
||||
- Thought: 문제 해결을 위한 다음 행동 계획을 작성해라.
|
||||
- Action: 호출할 도구 이름과 인자값을 JSON으로 적어라. (예: Calculator)
|
||||
- Observation: 도구 실행 결과가 여기에 채워질 것이다.
|
||||
- Thought: 실행 결과를 바탕으로 성찰하고, 필요하면 다음 Action을 설계해라.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ 컨텍스트 엔지니어링 (Context Engineering)
|
||||
|
||||
에이전트가 다루는 대화 맥락과 작업 상태(State)를 효율적으로 정제하고, 수많은 외부 정보 중 **지금 꼭 필요한 관련 데이터(RAG)**만을 선별하여 제한된 AI의 기억 공간(컨텍스트 윈도우)에 밀도 높게 채워 넣는 기술입니다.
|
||||
|
||||
* **핵심 설계 요소**:
|
||||
- **대화 상태 보존**: 이전 턴의 작업 결과를 유실 없이 보존
|
||||
- **동적 바인딩**: 외부 RAG 검색기에서 관련도 높은 중요 데이터만 적시에 필터링하여 공급
|
||||
- **메모리 압축**: 불필요한 과거 로그는 요약하고, 핵심 현재 상태 구조체만 유지해 토큰 낭비 방지
|
||||
* **에이전트 상태 보존 예시 (State JSON)**:
|
||||
```json
|
||||
{
|
||||
"task_id": "job_10294",
|
||||
"current_working_directory": "/workspace/src",
|
||||
"error_logs": ["SyntaxError: unexpected EOF while parsing at line 14"],
|
||||
"completed_subtasks": ["1. 소스코드 로드 완료", "2. 오류 라인 식별"],
|
||||
"next_action_required": "오류 라인 14의 괄호 닫힘 확인 및 수정 스크립트 작성"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ 하네스 엔지니어링 (Harness Engineering)
|
||||
|
||||
에이전트가 파일 시스템 제어, 브라우저 조작, 외부 API 호출 등 컴퓨터 세상의 다양한 도구들을 안전하게 가동할 수 있도록 **물리적 인터페이스(연결 고리)**를 구성하는 기술입니다.
|
||||
|
||||
* **핵심 설계 요소**:
|
||||
- **도구 호출 가로채기(Intercepting)**: 모델이 내놓은 도구 호출 의도(JSON 등)를 감지하고, 실제 터미널이나 프로그램의 함수로 전달해 구동함
|
||||
- **보안 샌드박싱 (Sandboxing)**: 에이전트가 악성 코드나 파괴적인 명령어를 무단 실행하지 않도록 격리된 가상 환경을 구축하고 권한을 통제함
|
||||
* **물리 인터페이스 도구 결합 예시**:
|
||||
```
|
||||
[LLM의 출력] ──> "Action: execute_command, args: { cmd: 'ls -la' }"
|
||||
│ (하네스가 이를 가로챔)
|
||||
▼
|
||||
[하네스 제어기] ──> 격리된 Docker 샌드박스 내부에서 'ls -la' 실제 실행
|
||||
│ (실행 결과 가로챔)
|
||||
▼
|
||||
[LLM의 입력] <── "Observation: total 12\ndrwxr-xr-x 3 user..." (모델에 반환)
|
||||
```
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
# 2. AI Agent 기능 확장: MCP & SKILL
|
||||
|
||||
## MCP (Model Context Protocol)
|
||||
Model Context Protocol(MCP)은 Anthropic이 2024년 11월 발표한 오픈 소스 프로토콜로, AI 애플리케이션(Host) 내부의 클라이언트(Client)가 로컬/원격의 도구(Tools), 데이터 소스(Resources), 그리고 컨텍스트 템플릿(Prompts)을 통일된 규격으로 안전하게 호출할 수 있는 공통 인터페이스입니다.
|
||||
- **Host-Client-Server 구조**: 에이전트 플랫폼(Host) 내 클라이언트가 표준화된 MCP 프로토콜을 통해 외부의 다양한 MCP 서버들과 통신하여 기능을 동적으로 확장하는 형태
|
||||
- **3대 제공 요소**:
|
||||
- **Tools**: 에이전트가 실행할 수 있는 실행형 기능(계산기, 파일 제어, API 호출 등)을 명세하고 호출 처리
|
||||
- **Resources**: 에이전트가 읽을 수 있는 데이터 소스, 파일, 컨텍스트 상태 정보를 노출
|
||||
- **Prompts**: 자주 사용되는 프롬프트 템플릿과 실행 지침을 표준 규격으로 제공
|
||||
|
||||
## CLI 코딩 에이전트: Claude Code vs Cowork
|
||||
AI 에이전트의 대표 주자인 Claude는 Claude Desktop App에서 Claude Cowork와 Claude Code라는 두 가지 상이한 서비스를 제공합니다.
|
||||
- **작동 방식의 차이**: Claude Cowork는 일상적인 사무 비서 역할로 Cloud 상에서 필요한 파일(pdf, word, excel 등)을 읽고 쓰는 방식을 사용하며, Claude Code는 Coding 작업에 특화되어 Local Computer에 필요한 모듈과 소프트웨어를 설치하여 직접 파일 수정 및 실행을 수행
|
||||
- **환경적 이점**: Claude Code는 터미널에서 로컬 컴퓨터의 필요한 모듈/소프트웨어를 바탕으로 작업 디렉터리 내 파일을 직접 제어하여 개발 업무에 고도화된 성능 발휘
|
||||
|
||||

|
||||
|
||||
- **생산성 극대화**: Claude Code를 프로젝트에 알맞게 조련하여 사용할 경우 단순 Cloud 비서 환경 대비 월등히 높은 결과물 기대 가능
|
||||
|
||||
## SKILL이란
|
||||
Skills(기술/도구 패키지)는 AI 에이전트가 외부 환경과 동적으로 상호작용할 수 있도록 결합하는 기능적 실행 모듈입니다. 단순히 LLM에게 단순 프롬프트 지시를 하는 프롬프트 엔지니어링 수준을 넘어, 에이전트가 특정 목표를 위해 직접 실행할 수 있는 실행 코드, 도구의 명세 스키마(Tool Schema), 사용 설명 및 예시(Few-shot) 등이 하나로 패키징된 자율 확장 도구 모음입니다.
|
||||
- **동적 모듈화 및 모듈화 방식**: 상황에 따라 필요한 기술을 동적으로 로드해 사용하고, 완료 후 반환하는 독립 모듈화 형태 채택
|
||||
- **작업 수행 한계 돌파**: 텍스트 생성이라는 언어 모델의 한계를 넘어 파일 제어, 서버 배포, 물리 데이터 수집 등 실질적인 업무 대행 능력을 에이전트에 부여
|
||||
|
||||
## Skills 사례 소개
|
||||
에이전트가 업무 현장에서 유용하게 사용하는 대표적인 Skills 사례들은 다음과 같습니다:
|
||||
1. **코드베이스 분석 및 관리 Skill**
|
||||
- 프로젝트 소스코드를 탐색(Grep), 특정 코드 조각을 치환(File replace), 변경 사항 검증(Linting), 최종 커밋 및 푸시 등을 처리하는 개발 자동화 Skill
|
||||
2. **브라우저 자동화 및 스크래핑 Skill**
|
||||
- 헤드리스 브라우저(Playwright, Puppeteer 등)를 기동하여 실시간 웹 트렌드 조사, 경쟁사 데이터 수집, 웹 UI에 대한 QA 테스트를 자율 수행하는 Skill
|
||||
3. **인프라스트럭처 제어 및 DevOps Skill**
|
||||
- 클라우드 환경(AWS, GCP 등)이나 Firebase Hosting, Cloud Firestore 등과 같은 서버리스 백엔드 서비스의 배포 및 데이터베이스 규칙 수정을 지원하는 시스템 운영용 Skill
|
||||
4. **학술/도메인 특화 API Skill**
|
||||
- 생화학 데이터베이스(ChEMBL), 의학 학술 논문(PubMed, arXiv), 유전학 정보(dbSNP, ClinVar) 등 전문 영역의 연구용 OpenAPI와 연동하여 자율 연구원(Researcher) 역할을 돕는 조사용 Skill
|
||||
|
||||
### Github를 점령한 Skills
|
||||

|
||||
|
||||
### Understand skill
|
||||
Turn any codebase, knowledge base, or docs into an interactive knowledge graph you can explore, search, and ask questions about.
|
||||

|
||||
|
||||
### 유행이라 개발해본 multi-agent-mux skill
|
||||

|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
# 3. 멀티 에이전트 협업 체계 및 오케스트레이션 인프라
|
||||
|
||||
## 싱글 에이전트의 한계
|
||||
단일 에이전트는 대규모 컨텍스트를 처리할 때 정보 누락(Lost in the middle) 현상이 발생하기 쉽고, 여러 도구를 한꺼번에 다루어야 할 때 환각(Hallucination)율이 증가하는 문제가 있습니다. 또한 역할 집중으로 인한 프롬프트의 장황화, 멀티태스킹 오류 및 컨텍스트 인지 한계 등의 명확한 한계를 가집니다.
|
||||
|
||||
## 멀티 에이전트란: 역할 세분화와 교차 검증
|
||||
멀티 에이전트(Multi-Agent) 시스템은 단일 에이전트(Single Agent)의 한계를 극복하기 위해, 서로 다른 페르소나와 전문 도구(Skills)를 갖춘 여러 개의 에이전트들이 협력 네트워크를 형성하여 복잡한 목표를 조율(Orchestration)하고 분할 해결하는 구조입니다.
|
||||
- **역할 세분화 (Role Specialization)**: 에이전트별로 한정된 역할(PM, Developer, QA, Researcher 등)과 도구만을 부여하여 프롬프트 노이즈를 억제하고 추론 정확도 향상
|
||||
- **교차 검증**: 서로 다른 이종 모델(Gemini 작성 ➡️ Claude 검토 등) 간의 상호 비평 및 검증 루프를 통해 결과물의 정합성을 교차 검증하고 결과 신뢰성 극대화
|
||||
- **분할 정복 (Divide and Conquer)**: 하나의 거대한 프로젝트를 독립된 세부 태스크로 쪼개어 다수의 에이전트가 병렬적으로 해결함으로써 복잡성 분산
|
||||
|
||||
## Subagent / Team agent
|
||||
|
||||
- ### Subagent란
|
||||
Subagent는 부모 에이전트(Parent Agent 또는 Orchestrator)에 의해 동적으로 생성되어 특정 국소적이고 독립적인 태스크를 대행한 뒤, 결과를 상위로 반환하고 소멸하는 종속형 에이전트입니다.
|
||||
- **컨텍스트 격리**: 하위 작업의 맥락만을 분기(Branch)하여 처리함으로써 상위 대화의 컨텍스트 오염을 막고 토큰 소모량 최적화 및 속도 개선
|
||||
- **예시**: 메인 에이전트가 리팩토링 중 특정 모듈 에러 복구 작업만을 subagent에 위임하여 처리하는 독립적인 문제 해결 기법
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
- ### Team agent란
|
||||
Team agent는 단일 계층적인 수직 구조를 넘어, 수평적이고 다양한 역할을 맡은 여러 독립 에이전트가 협의체(Crew/Team)를 구성하여 대화형 협력(Multi-agent Debate) 및 협상을 통해 목표를 완수하는 협동형 에이전트 구성 방식입니다.
|
||||
- **의견 충돌 및 합의**: 특정 설계안에 대해 서로 다른 관점의 에이전트들이 논쟁(Debate)을 벌이고, 최종적으로 조율된 결과를 PM 에이전트가 도출하는 식의 협력 모델
|
||||
- **협력적 의사결정**: 복잡한 문제에 대해 실시간으로 의견과 피드백을 교환하여 점진적으로 결과물의 품질 고도화
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 구성 기술: TMUX · crewAI · LangGraph
|
||||
|
||||
- ### TMUX
|
||||
TMUX는 에이전트들의 독립적인 작업 공간을 제공하는 가상 터미널 관리 도구로, 백그라운드 내 장기 작업(Long-running task) 세션을 안정적으로 유지하고 AI 에이전트의 동작을 TUI로 실시간 모니터링 및 제어할 수 있도록 도와주는 소프트웨어입니다.
|
||||
- **에이전트별 독립 작업 공간 제공**: 각 에이전트 세션을 독립적인 tmux 윈도우나 패널에 격리하여 병렬로 실행할 수 있는 물리적 격리막 형성
|
||||
- **장기 실행 작업의 백그라운드 세션 유지**: SSH 연결이 끊어지거나 브라우저 세션이 끊겨도 tmux 세션 내부에서 구동되는 에이전트 작업은 유실 없이 백그라운드에서 계속 유지
|
||||
- **TUI 실시간 모니터링 및 제어**: CLI 코딩 에이전트나 멀티 에이전트들의 실행 과정을 사람이 TUI를 통해 실시간으로 관측(observe)하고 필요시 입력을 제공할 수 있도록 실시간 관측 및 제어 지원
|
||||
- *실제로 앞서 소개한 `multi-agent-mux` 스킬 데모가 이러한 TMUX 세션 관리 기능을 활용하여 설계된 멀티 에이전트 오케스트레이션 사례임*
|
||||
|
||||
- ### crewAI
|
||||
역할 기반(Role-based) 협업을 설계하는 데 특화된 프레임워크입니다. 각 에이전트에게 명확한 역할(Role), 목표(Goal), 배경 설명(Backstory)을 부여하고, 이들을 업무 프로세스(Sequential 또는 Hierarchical)에 따라 배치해 '크루(Crew)' 단위로 조율합니다.
|
||||
|
||||
- ### LangGraph
|
||||
컨텍스트 엔지니어링(Context Engineering)은 다중 에이전트 시스템에서 에이전트들 간의 대화 흐름, 전달되는 컨텍스트(State), 복잡한 제어 루프를 효율적으로 설계하고 유지하는 방법론적 학문입니다. 이를 구현하는 대표적인 상태 보존형 프레임워크가 LangGraph입니다.
|
||||
- **상태 보존 및 순환 제어**: 단순 선형적 체인 구조를 탈피하여, 에이전트 간의 루프(반복 검증), 조건부 분기(Conditional branching), 실패 시 롤백 등을 순환형 그래프(Cyclic Graph) 구조로 제어
|
||||
- **영속적 상태 관리**: 협업 과정에서 축적되는 다양한 상태 변화(State)를 중앙 저장소에서 추적 및 동기화하여 특정 노드가 실패하더라도 이전 상태부터 복구 및 재시작할 수 있는 환경 제공
|
||||
|
||||
- ### (참고) AutoGen, BeeAI
|
||||
Microsoft AutoGen과 IBM의 BeeAI도 멀티 에이전트 오케스트레이션을 지원하는 주요 프레임워크입니다.
|
||||
- **Microsoft AutoGen**: 대화형 에이전트 설계(Conversational Agentic Design)에 중점을 둔 프레임워크로, 에이전트 간 대화를 통해 코드를 실행하고 피드백을 주고받는 풍부한 동적 워크플로우 제공
|
||||
- **BeeAI**: 에이전트를 탐색, 실행, 공유할 수 있는 중앙 집중형 오픈소스 플랫폼으로 리눅스 재단(LF) 하위에서 ACP 표준 프로토콜을 백본으로 구축
|
||||
|
||||

|
||||
|
||||
## [사례] 실제 구현하며 겪은 문제점들
|
||||

|
||||
1. **Agent들과 각 Agent들의 세션 관리**
|
||||
- 다수의 에이전트와 그 아래에 동적으로 생성되는 subagent들의 생명주기(Lifecycle) 및 고유 식별자(UUID)를 동기화하고 상태를 지속적으로 보존하는 일관된 세션 관리 시스템 요구
|
||||
2. **에이전트 상호 탐색(Service Discovery)과 역할 식별**
|
||||
- 새로운 에이전트가 네트워크에 진입했을 때 어떤 에이전트가 어떤 과업을 처리할 수 있는지 동적으로 파악하는 기능 요구
|
||||
- **마스터 - 슬레이브(Master-Slave) 방식**: 중앙 오케스트레이터가 전권을 쥐고 세션을 직접 관리 및 명령하므로 통제는 쉬우나, 오케스트레이터의 에러가 시스템 전체의 단일 실패점(SPOF)이 될 위험 존재
|
||||
- **P2P 및 보고(P2P and Report) 방식**: 에이전트들이 동등한 위치에서 협상하며 자율적으로 탐색하고, 작업 결과를 기록 보관소에 보고하는 방식이나 네트워크 관리 비용 상승 우려
|
||||
3. **실시간 메시징과 이벤트 예외 처리의 복잡성**
|
||||
- 태스크 위임 완료 후, 작업 결과와 성공/실패 여부를 교환하는 통신 채널이 중단되거나 유실될 수 있는 위험성 대두
|
||||
- **송신/수신 주체의 돌발 종료**: 위임 후 송신 주체가 다운되었다가 재가동(Restart)되는 경우, 비동기 알람이 공중분해되어 전체 협업 루프 중단
|
||||
- **무한 대기 및 리소스 누수(Deadlock & Resource Leak)**: 작업을 위임받은 서브에이전트가 예외 이벤트 없이 비정상 종료(Silent death)할 경우 부모 에이전트가 무한 대기(Blocking) 상태에 빠지는 문제 발생
|
||||
|
||||
## [사례] Multi Agent Orchestration의 장점
|
||||
1. **프롬프트의 간소화 및 루프 엔진의 진화**
|
||||
- 기존의 길고 장황한 단일 "Super Prompt" 엔지니어링 시대에서 벗어나, 에이전트를 구동하고 제어하는 자율 루프(Loop Architecture) 설계 중심으로 패러다임 이동
|
||||
- *PSPDFKit 창업자이자 오픈소스 AI 에이전트 프로젝트인 OpenClaw의 크리에이터인 페터 슈타인베르거(Peter Steinberger, 2026년 초 OpenAI 합류)는 **"코딩 에이전트에 프롬프트를 더 넣지 말고, 에이전트를 구동하는 루프를 설계하라"**(Stop prompting your coding agents; start designing loops that prompt your agents)고 강조한 바 있음*
|
||||
- 멀티 에이전트 구조에서는 작업 지시 PM 에이전트, 작업 수행 Worker 에이전트, 유효성 검증 Reviewer 에이전트로 나뉨으로써 프롬프트가 단편적이고 명료해지는 이점 확보
|
||||
2. **컨텍스트 설명 불필요 (Context-Free Sharing)**
|
||||
- 공유 작업 환경(Shared Workspaces)과 Git 같은 버전 관리 시스템을 에이전트들이 공유하므로, 새로 합류한 에이전트에게 변경 이력이나 현재 맥락을 다시 텍스트로 설명하느라 불필요한 토큰과 대기 시간 낭비 방지
|
||||
3. **이종 모델 피드백을 통한 고품질 산출물 교차 검증**
|
||||
- 특정 한 모델 계열만으로 결과물을 짜고 동일한 계열에 검토를 시키는 것보다, 서로 다른 아키텍처와 특징을 지닌 이종 모델(Gemini 작성 ➡️ Claude 검토 등) 간 상호 보완할 때 결과물의 신뢰성 극대화 및 보이지 않는 맹점 상호 보완
|
||||
4. **비용 효율적인 토큰 분배 (Cost Optimization)**
|
||||
- 쉬운 코드 생성이나 정보 검색은 경량화된 저비용 모델(예: Gemini Flash 세대)을 탑재한 에이전트에 분산 위임하고, 고난도의 논리적 추론이 필요한 부분에만 최상위 고비용 모델을 탑재한 에이전트를 적절히 매칭함으로써 종합적인 API 비용 효율적 제어
|
||||
|
||||
## [요구사항] 성공적인 구현을 위해 필요한 것들 (What We Need?)
|
||||
앞의 문제점과 장점을 종합하면, 성공적인 멀티 에이전트 오케스트레이션 구현을 위해서는 다음 5가지 구성요소가 필수적이며, 이 요구사항들이 4장(표준)과 5장(인프라)에서 소개할 솔루션의 선정 기준이 됩니다.
|
||||
|
||||
1. **Agent 간 작업 환경 공유 (A2A의 Agent Card 기반)**
|
||||
- 특정 에이전트에게 태스크를 안전하게 위임하고 위임받기 위해선, 각 에이전트 카드를 통해 서로의 역할, 엔드포인트, 입력 명세 스키마를 신뢰성 있는 방식으로 동기화
|
||||
2. **에이전트 작동 인프라스트럭처 (Agent Working Infrastructure)**
|
||||
- 에이전트들이 통신에 사용할 서비스 주소와 포트를 동적으로 조회할 수 있는 서비스 디스커버리 기능 및 자동 라우팅 체계 내재
|
||||
3. **일관성 있는 워크플로우 추적 및 공유 (Issue & Workflow Tracking)**
|
||||
- 서로 다른 에이전트가 동일한 작업을 재수행할 때 동일한 프로세스와 출력을 일관되게 얻을 수 있도록 워크플로우 사양이 제어되어야 함. 또한 에러나 중간 정지가 일어났을 때 복구 및 추적이 용이하도록 규격화된 이슈 추적 인터페이스 마련
|
||||
4. **고성능 양방향 메시징 시스템 (Advanced Messaging System)**
|
||||
- 고용량의 코드 블록, 이미지 및 멀티모달 센서 데이터 등을 대량으로 빠르고 정확하게 공유하기 위해서는 기존의 MQTT나 단순 메시지 큐(MQ) 방식은 토픽 세분화와 페이로드 크기 한계로 인해 통신 병목을 겪을 수밖에 없음
|
||||
- 단편적인 이벤트 알림만 전송하는 구조에서 탈피하여 상세 에러 트레이스, 상태 구조체, 이미지 바이너리 등을 한꺼번에 담을 수 있는 풍부한 페이로드 지원 메시징 규격이 필수적임. 나아가 대용량 멀티모달 푸시(Push)와 양방향 스트리밍을 지원하기 위해 HTTP/3의 멀티 스트리밍 등 고성능 통신 수반
|
||||
5. **작업 및 태스크 관리 시스템 (Job & Task Lifecycle Controller)**
|
||||
- 하나의 부모 작업을 하위 스크럼(Scrum) 단위로 쪼개어 서브에이전트들에게 뿌려주는 라이프사이클 관리 기능이 있어야 함. 각 서브에이전트의 생성부터 소멸까지의 메시징 토픽과 임시 토큰을 정리해야 하며, 이 과정에서 부하를 고르게 배분하고 실력 있는 에이전트를 매칭하기 위한 로드 밸런싱 기법 병행
|
||||
|
||||

|
||||
|
||||
### 🔗 요구사항과 기술 솔루션 매핑 (Bridge Mapping)
|
||||
- **요구사항 1(작업 환경 공유)** 및 **요구사항 2(서비스 디스커버리)**는 상호 연동 장벽을 허물기 위해 **§4 에이전트 상호운용성 표준(ACP/A2A)**을 통해 규격화된 표준 명세로 제공
|
||||
- **요구사항 4(고성능 양방향 메시징)** 및 **요구사항 5(태스크 라이프사이클/로드밸런싱)**는 고부하 대규모 실시간 분산 환경에서 물리적 네트워크 및 런타임 최적화를 실현하는 **§5 gRPC 백본 인프라**를 통해 해결
|
||||
- **요구사항 3(워크플로우 추적)**은 표준 명세(Agent Card)의 추적 스키마와 물리 인프라(gRPC liveness probe 및 트레이싱 도구) 양대 측면에 모두 걸쳐 상호 보완적 제어
|
||||
|
||||
---
|
||||
|
||||
# 4. 에이전트 상호운용성 확보를 위한 표준: ACP, A2A
|
||||
|
||||
## 왜 이종 에이전트 연동 표준이 필요한가
|
||||
3장에서 정의한 요구사항 중 **'Agent 간 작업 환경 공유'(요구사항 1)**와 **'서비스 디스커버리'(요구사항 2)**는 특정 벤더의 독점적 구현이 아닌 개방형 표준 규격을 통해 해결되어야 합니다. 그 핵심 해답이 되는 오픈 규격 표준이 바로 ACP와 A2A입니다.
|
||||
|
||||
서로 다른 벤더가 제작하고 각기 다른 아키텍처로 설계된 이종 에이전트들이 복잡한 맞춤형 결합 코드(Glue code) 없이도 서비스 검색, 역할 전파 및 태스크 위임을 유연하게 수행하려면 통일된 오픈 통신 규약이 필수적입니다. 표준 규격을 통하면 에이전트 생태계의 결합도가 낮아져 이종 에이전트 간의 동적 협업 네트워크 구축 속도가 비약적으로 향상됩니다.
|
||||
|
||||
## ACP (Agent Communication Protocol)
|
||||
IBM Research가 자사의 BeeAI 프레임워크 연동을 지원하기 위해 **2025년 3월** 발표했던 오픈 표준 에이전트 통신 인터페이스입니다. 이종 에이전트 간의 서비스 발견(Service Discovery), 상호작용 방법 협상, 태스크 위임 프로세스를 최초로 규격화하였습니다. 이후 **2025년 8월 27일** 공식 깃허브 저장소가 아카이빙되며 Linux Foundation AI & Data 하위의 A2A(Agent-to-Agent) 프로토콜로 공식 통합 및 병합되어 단일 연합 생태계를 형성하였습니다.
|
||||
|
||||
## A2A와 Agent Card
|
||||
- ### A2A (Agent-to-Agent) 프로토콜
|
||||
Google과 50여 개 파트너사들이 **2025년 4월** 공동 발표하고 **2025년 6월** Linux Foundation에 기증한 이종 에이전트 간 표준 메시지 교환 및 상태 추적용 통합 통신 오픈 표준입니다. 서로 다른 벤더가 구현한 에이전트나 다양한 아키텍처 기반의 에이전트가 복잡한 결합 코드 없이도 협업 메시지를 교환하고 상태 추적 지원
|
||||
- ### Agent Card (에이전트 카드)
|
||||
A2A 생태계에서 에이전트의 기능 명세서 역할을 하는 JSON 포맷의 표준 규격서입니다. 이는 **요구사항 1(작업 환경 공유)**을 물리적 파일 수준에서 구체화하는 핵심 메커니즘으로 기능함. 에이전트 카드에는 해당 에이전트의 명칭, 제공 가능한 역량(Capabilities), 호출 엔드포인트 정보, 요청 시 통과해야 하는 보안 인증 사양(Authentication) 등이 명시되어 있어 서비스 검색 시스템(Service Discovery)이 에이전트의 역할을 실시간으로 탐색할 수 있도록 보장. 주로 `/.well-known/agent-card.json` 경로에 배포
|
||||
|
||||
---
|
||||
|
||||
# 5. 지능형 사물인터넷(AIoT) 및 gRPC 기술 동향
|
||||
|
||||
## AIoT 환경에서 멀티 에이전트가 유용한 이유
|
||||
스마트 팩토리, 스마트 팜 등 다수의 물리 디바이스로 구성된 Physical 계층과 엣지 연산 계층이 복잡하게 혼재하는 AIoT 환경에서는 단일 클라우드 에이전트만으로 지연 시간 제어와 로컬 예외 상황 대처를 감당하기 어렵습니다. 따라서 센서 수집, 이상 탐지, 자율 제어 등의 고유 역할을 여러 에이전트에 자율 분산 배치하여 협업 기반의 로컬 제어를 실현해야 합니다. 이를 통해 네트워크 연결 차단 시에도 독립 제어가 가능하며, 컴퓨팅 자원이 극도로 제한된 엣지 인프라 하에서도 실시간 협업 자율 제어를 달성할 수 있습니다.
|
||||
|
||||
## Why we need gRPC?
|
||||
3장 요구사항 중 **'고성능 양방향 메시징'(요구사항 4)**과 **'태스크 라이프사이클 관리'(요구사항 5)**를 물리적 인프라 수준에서 충족하는 강력한 통신 백본이 gRPC (Google Remote Procedure Call) 프로토콜입니다.
|
||||
|
||||
1. **A2A 프로토콜과의 높은 호환성 및 스마트 팩토리/팜 최적화**
|
||||
- 스마트 팜, 스마트 팩토리와 같은 AIoT 서비스를 타겟으로 하는 개발 단계에서 대역폭이 극단적으로 제약되는 센서 디바이스는 MQTT/CoAP 등을 혼용하되, 상위 게이트웨이 및 연산 계층에서는 gRPC가 강력하고 최적의 제어 프로토콜로 작동
|
||||
2. **고성능 멀티 모달 바이너리 스트리밍 및 Pub/Sub 네이티브 지원**
|
||||
- HTTP/2 또는 HTTP/3 기반으로 작동하여 양방향 스트리밍, Request/Response, Pub/Sub 통신 방식을 단일 포트에서 완벽히 수행하며 대용량 멀티모달 바이너리를 고속으로 처리
|
||||
- 마이크로서비스 아키텍처(MSA)에서 메인 통신 규격으로 활용되므로 타 시스템으로의 이식 및 연동 성능 탁월
|
||||
3. **구조화된 직렬화 기반의 효율적 태스크 생명주기 및 부하 분산**
|
||||
- Protobuf를 통한 강력한 스키마 기반 직렬화로 통신 메시지 처리 효율을 JSON 파싱 대비 수배 이상 향상하고 메모리 오버헤드 극소화
|
||||
- 이미 검증된 Envoy, Kubernetes gRPC liveness probe 등의 기성 인프라를 활용하여 에이전트 라이프사이클 감시 체계를 최소한의 공수로 구축 가능
|
||||
|
||||
## 기술 동향 요약
|
||||
최근 AIoT 에이전트 인프라 환경은 물리적 센서 단말에서 대역폭을 극단적으로 아끼기 위해 경량 프로토콜(MQTT/CoAP)을 사용하고, 상위 연산 및 오케스트레이터 게이트웨이 계층에서는 고성능 프로토콜(gRPC)을 사용하여 계층별 완충 혼용을 유도하는 설계가 주류 트렌드로 자리잡고 있습니다. 이러한 하이브리드 프로토콜 아키텍처는 마이크로서비스 아키텍처(MSA)에 뛰어난 이식 및 확장 성능을 보장합니다.
|
||||
|
||||
---
|
||||
|
||||
# 특강 최종 요약
|
||||
|
||||
## AI Multi-Agents 오케스트레이션 기술 동향 및 사례
|
||||
- **AI 에이전트 구현을 위한 3대 핵심 엔지니어링 기법**: 프롬프트 엔지니어링, 컨텍스트 엔지니어링, 하네스 엔지니어링을 통하여 단일 에이전트 설계 및 도구 연동 제어
|
||||
- **AI Agent 기능 확장**: MCP(Model Context Protocol) 및 SKILL 프레임워크를 기반으로 클라이언트-서버 구조로 동적인 도구(Tools) 및 데이터(Resources) 호출 인터페이스 확장
|
||||
- **멀티 에이전트 협업 체계 및 오케스트레이션 인프라**: 싱글 에이전트의 한계(Lost in the middle, 환각)를 극복하기 위해 TMUX 세션 관리 TUI, crewAI 역할 협업, LangGraph 상태 보존 그래프를 결합한 분산 협업 구조 구축
|
||||
- **에이전트 상호운용성 확보를 위한 표준**: 벤더 종속 없이 협업 위임을 실현하기 위해 ACP와 A2A 프로토콜 표준 규격 및 Agent Card 기반의 상호 탐색 환경 통일
|
||||
- **지능형 사물인터넷 (AIoT) 및 gRPC 기술 동향**: 물리 및 엣지 단말 자원 한계를 보완하기 위한 경량 프로토콜(MQTT/CoAP)과 고성능 양방향 메시징 gRPC를 계층별 완충 혼용하여 MSA 기반의 실시간 자율 분산 제어망 완성
|
||||