diff --git a/.gitignore b/.gitignore index 7b2fd05..7cfabb2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,8 @@ test-sessions.yaml.lock .hermes/ .venv/ __pycache__/ -*.pyc \ No newline at end of file +*.pyc + +# 빌드/배포 HTML 산출물 +skills/delegate-job/USER_MANUAL.html +skills/delegate-job/mqtt-broker-setup.html \ No newline at end of file diff --git a/skills/agent-sessions-monitor/SKILL.md b/skills/agent-sessions-monitor/SKILL.md index 1dfa4c0..97bb5f5 100644 --- a/skills/agent-sessions-monitor/SKILL.md +++ b/skills/agent-sessions-monitor/SKILL.md @@ -98,11 +98,12 @@ bash ~/PuKi/lab/agent_sessions/skills/agent-sessions-monitor/scripts/reconcile.s # Read-only: compute drift WITHOUT writing the YAML (use for "what's running?" checks). bash ~/PuKi/lab/agent_sessions/skills/agent-sessions-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. +bash ~/PuKi/lab/agent_sessions/skills/agent-sessions-monitor/scripts/reconcile.sh --subscribe ``` -Flags: `--once` (single pass), `--emit-diff` (print JSON), `--dry-run` (P1-E — no -mutation). There are **no** `--workspace` / `--agent` / `--comment-card` flags; the -worker turns the emitted JSON `drifts[]` into `kanban_comment` calls itself. +Flags: `--once` (single pass), `--emit-diff` (print JSON), `--dry-run` (P1-E — no mutation), `--subscribe` (persistent push-based MQTT subscription monitoring; falls back to polling if connection fails). 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) diff --git a/skills/agent-sessions-monitor/scripts/reconcile.sh b/skills/agent-sessions-monitor/scripts/reconcile.sh index 734b9de..a1682de 100755 --- a/skills/agent-sessions-monitor/scripts/reconcile.sh +++ b/skills/agent-sessions-monitor/scripts/reconcile.sh @@ -21,18 +21,141 @@ STATE_DIR="${AGENT_SESSIONS_STATE_DIR:-$HOME/.cache/agent-sessions-monitor}" ONCE=0 EMIT_DIFF=0 DRY_RUN=0 +SUBSCRIBE=0 while [ $# -gt 0 ]; do case "$1" in --once) ONCE=1; shift ;; --emit-diff) EMIT_DIFF=1; shift ;; --dry-run) DRY_RUN=1; shift ;; - -h|--help) echo "Usage: $0 [--once] [--emit-diff] [--dry-run]"; exit 0 ;; + --subscribe) SUBSCRIBE=1; shift ;; + -h|--help) echo "Usage: $0 [--once] [--emit-diff] [--dry-run] [--subscribe]"; 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 + SUBSCRIBE_MODE=1 env_python "$AGENT_SESSIONS_YAML" <<'PYEOF' +import os, sys, json, fcntl, tempfile, subprocess +from datetime import datetime, timezone +import yaml + +yaml_path = os.environ['YAML_PATH'] +home = os.environ['HOME_DIR'] + +# Add skills/delegate-job/scripts to path to import mqtt_common +script_dir = os.path.dirname(os.path.abspath(__file__)) if '__file__' in globals() else os.getcwd() +path_candidate = os.path.join('/home/godopu16/PuKi/laa/canary_projects/advanced_multi_agent', 'skills', 'delegate-job', 'scripts') +if os.path.isdir(path_candidate): + sys.path.append(path_candidate) +else: + d = script_dir + found = False + while d != '/' and d: + p = os.path.join(d, 'skills', 'delegate-job', 'scripts') + if os.path.isdir(p): + sys.path.append(p) + found = True + break + p2 = os.path.join(d, 'delegate-job', 'scripts') + if os.path.isdir(p2): + sys.path.append(p2) + found = True + break + d = os.path.dirname(d) + +import mqtt_common + +cfg = mqtt_common.broker_config_from_env() +client = mqtt_common.make_client("monitor_sub", cfg) + +def on_message(client, userdata, msg): + 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 event in ("completed", "error"): + print(f"MQTT Monitor: received terminal event {event} for job {jid}", flush=True) + update_session_by_job(jid, event) + except Exception as e: + print(f"MQTT Monitor error parsing message: {e}", flush=True) + +def update_session_by_job(jid, event): + lock_path = yaml_path + '.lock' + lock_fh = open(lock_path, 'w') + fcntl.flock(lock_fh, fcntl.LOCK_EX) + try: + if os.path.exists(yaml_path): + with open(yaml_path) as f: + d_local = yaml.safe_load(f) or {} + else: + d_local = {} + + sessions = d_local.setdefault('tmux_sessions', []) + updated = False + for s in sessions: + if s.get('delegate_job_id') == jid and s.get('status') == 'running': + s['status'] = 'terminated' + now_iso = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') + s['terminated_at'] = now_iso + s['terminated_at_epoch'] = int(datetime.now(timezone.utc).timestamp()) + s['termination_mode'] = f"auto-detected (MQTT {event})" + name = s.get('name') + srv = s.get('tmux_server') or 'default' + kill_tmux_session(name, srv) + updated = True + + if updated: + 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_local, f, default_flow_style=False, sort_keys=False, + allow_unicode=True, width=4096) + os.replace(tmp, yaml_path) + print(f"MQTT Monitor: updated YAML for job {jid} to terminated", flush=True) + except Exception as e: + if os.path.exists(tmp): + os.remove(tmp) + print(f"MQTT Monitor error writing YAML: {e}", flush=True) + finally: + fcntl.flock(lock_fh, fcntl.LOCK_UN) + lock_fh.close() + +def kill_tmux_session(name, srv): + try: + cmd = ['tmux'] + if srv != 'default': + cmd += ['-L', srv] + cmd += ['kill-session', '-t', name] + subprocess.run(cmd, capture_output=True) + print(f"MQTT Monitor: killed tmux session {name} on server {srv}", flush=True) + except Exception as e: + print(f"MQTT Monitor error killing tmux: {e}", flush=True) + +client.on_message = on_message + +def on_connect(_c, _u, _flags, reason_code, _props): + rc = mqtt_common.reason_code_value(reason_code) + if rc == 0: + _c.subscribe("python/mqtt/jobs/+/events", qos=1) + print("MQTT Monitor: subscribed to python/mqtt/jobs/+/events", flush=True) + else: + print(f"MQTT Monitor connection failed: {rc}", flush=True) + +client.on_connect = on_connect +print(f"MQTT Monitor: connecting to {cfg.host}:{cfg.port} (TLS={cfg.tls})...", flush=True) +client.connect(cfg.host, cfg.port, cfg.keepalive) +client.loop_forever() +PYEOF + exit 0 +fi + mkdir -p "$STATE_DIR" # 모든 비교 로직을 단일 소스로 둔다. dry-run 은 env_python(읽기전용), 그 외엔 diff --git a/skills/delegate-job/USER_MANUAL.html b/skills/delegate-job/USER_MANUAL.html deleted file mode 100644 index 02c34fe..0000000 --- a/skills/delegate-job/USER_MANUAL.html +++ /dev/null @@ -1,2195 +0,0 @@ - - - - - - delegate-job — User Manual & Architecture Guide - - - - - - - - - - - - -
- - - - - -
- - -
- -

delegate-job

-

자율 에이전트 비동기 위임 및 MQTT 이벤트 관찰 아키텍처 명세서

-
- - -
-
- - - - -
-
-

MQTT 브로커 셋업 가이드 (PoC → Production)

-

공개 테스트 브로커에서 내부 TLS 암호화 및 ACL 인가 제어를 적용한 사설 프로덕션 브로커로 안전하게 전환하기 위한 단계별 구축 가이드입니다.

-
- 가이드 열기 → -
- - -
-

시스템 아키텍처 명세

-

자율 에이전트 위임 모델은 동시성 간섭이 배제된 단방향 이벤트 스트림 구조를 띱니다. 아래 탭을 전환하며 상호작용 가능한 다이어그램들을 확인하실 수 있습니다.

- -
-
- - - -
- - -
-
- - - - - - - - - - - - - - - User/Delegator - - - - delegate-job submit - Wrapper CLI - - - - Job Registry - .hermes/jobs/<id>.json - - - - job_subscriber.py - Background Process - - - - Agent (tmux/print) - claude-code / codex - - - - publish_event.py - Worker Utility - - - - MQTT Broker - HiveMQ / Mosquitto - jobs/<id>/events - - - - Audit Log - events.ndjson - - - - - - - - - - - - - - - - - - - - - -
-
- - -
-
- - - - - - - - - - - - - - - - - - CLI Wrapper - - - - Registry - - - - Subscriber - - - - AI Agent (tmux) - - - - MQTT Broker - - - - Audit Log - - - - - - 1. register_job (pending) - - - - 2. spawn subscriber (bg) - - - - 3. connect & subscribe QoS 1 - - - - 4. tmux launch (instructions with JID) - - - - 5. publish event="started" - - - - 6. deliver event="started" - - - - 7. write logs (meta snapshot + events & status = "running") - - - - 8. publish event="completed" - - - - 9. deliver event="completed" - - - - 10. write logs (status = "completed") - - - - 11. exit process (exit 0) - -
-
- - -
-
- - - - - - - - - - - - delegate-job - Bash orchestrator CLI - - - - registry.py - Registry CRUD CLI/Lib - - - - job_subscriber.py - MQTT event monitor - - - - publish_event.py - Status event emitter - - - - mqtt_common.py - Shared library: Broker configuration, - Advisory fcntl lock, logging, files IO - - - - - - - - - - - - - - -
-
-
-
- - -
-

1. 이 스킬이 하는 일

-

delegate-job 스킬은 복잡한 자율 에이전트 오케스트레이션(작업 위임) 흐름을 단 한 줄의 명령어로 단순화합니다.

- -
-
- -
-
-

자동화 범위

-

1. Job ID 신규 발급 및 독자적인 샌드박스 레지스트리 레코드 생성
- 2. 이벤트 전송 유실 방지를 위해 에이전트 시작 전에 백그라운드 수신기를 선(先) 실행
- 3. 격리된 tmux 인터랙티브 터미널 세션을 열어 작업 위임
- 4. 에이전트가 발행한 실시간 상태 이벤트를 감지하여 NDJSON 포맷 감사 로그(Audit Log) 축적
- 5. 최종 성공(completed) 또는 실패(error) 감지 시 자동으로 자원 반환 및 모니터링 해제

-
-
-
- - -
-

2. 설치 & 사전조건

-

이 스킬은 POSIX 표준 개발 규격을 준수하며, 파이썬 기반의 백그라운드 구독 유틸리티와 통신 드라이버가 필요합니다.

- -

사전 시스템 사양

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
인프라 항목기본 요건활용 세부 영역
OSmacOS / Linux (Windows 환경인 경우 WSL 가상 환경 권장)런타임 커널 지원
RuntimePython 3.9+Registry / Publisher / Subscriber 연동
Multiplexertmux (미설치 시 wrapper 기동 시 경고)인터랙티브 모드 세션 격리 기동
Librarypaho-mqtt ≥ 2.0 (VERSION2 콜백 API 지원 사양)MQTT 브로커 통신을 위한 로컬 파이썬 모듈
-
- -

스킬 저장소 파일 디렉터리 구성

-
-
- Directory layout -
-
~/.hermes/skills/autonomous-ai-agents/delegate-job/
-├── SKILL.md              # Claude 에이전트가 해석하는 프롬프트 가이드 지침
-├── USER_MANUAL.html      # 사용자 분석용 HTML 지침서 (본 문서)
-├── README.md             # 간략한 프로젝트 정보
-├── job-protocol.md       # MQTT JSON 페이로드 규격 및 스키마 명세
-├── registry.md           # 레지스트리 레코드의 속성 값 정의 및 락 매커니즘
-├── mqtt-broker-setup.html # PoC → TLS 운영 브로커 컷오버 가이드 (HTML)
-├── delegate-job          # 오케스트레이션 역할을 하는 Bash wrapper script
-└── scripts/
-    ├── mqtt_common.py    # 브로커 연결 빌드, 파일 락, 감사 로그 공통 모듈
-    ├── registry.py       # Job 등록/수정을 관장하는 CLI 헬퍼 라이브러리
-    ├── publish_event.py  # 구동 중인 에이전트가 이벤트를 쏠 때 쓰는 송신기
-    └── job_subscriber.py # 위임 작업 종료 시점까지 백그라운드에서 감시하는 수신기
-
-
- - -
-

3. Quick Start (작업 위임)

-

단일 통합 명령어를 활용하여 즉시 위임 환경을 기동하거나, 빌드 파이프라인 연동 시 단계별 조작이 용이하도록 파이썬 직접 구동을 지원합니다.

- -
-
- - -
- - -
-

delegate-job submit 명령을 통하여 백그라운드 수신기를 구동하고 에이전트를 안전하게 격리 기동합니다.

- -
-
- bash - -
-
delegate-job submit \
-  --agent claude-code \
-  --prompt "정렬 문제 10개를 만들어 sort_problems.md로 저장" \
-  --workdir /path/to/project \
-  --agent-session tmux:demo \
-  --timeout 600 --idle-timeout 120
-
- -

위임 시작 시 콘솔 실시간 로그 (stdout 예시)

-
-
registered job: 2971fbf8
-subscriber pid: 34311 (log: .hermes/jobs/2971fbf8.subscriber.out)
-agent launched in tmux session: demo (attach with: tmux attach -t demo)
-subscriber output:
-2026-06-19T13:16:00Z  job=2971fbf8  seq=1  started               Job 2971fbf8 started
-2026-06-19T13:18:20Z  job=2971fbf8  seq=2  completed             saved sort_problems.md
-subscriber exit code: 0
-/path/to/project/.hermes/delegate_job_logs/2971fbf8   # Audit log 디렉터리 경로 반환
-
-
- - -
-

의존 관계 설정 및 디버깅을 위해 단계를 세분화하여 파이썬 런타임 스크립트를 기동할 수 있습니다.

-
-
- bash - -
-
PY=.venv/bin/python
-SKILL=~/.hermes/skills/autonomous-ai-agents/delegate-job/scripts
-
-# 1) 작업 정보 레코드 생성
-JID=$($PY "$SKILL/registry.py" register \
-        --prompt "정렬 파일 구성" --agent claude-code --agent-session tmux:demo \
-        --timeout 600 --idle-timeout 120)
-
-# 2) 백그라운드 수신기 우선 실행 (순서 의존성 주의)
-$PY "$SKILL/job_subscriber.py" --job "$JID" --timeout 600 --idle-timeout 120 &
-
-# 3) 시작(started) 상태 신호 수동 전송
-$PY "$SKILL/publish_event.py" --job "$JID" --event started
-
-# 4) 에이전트 구동 과정... (에이전트가 직접 completed/error 신호를 쏘도록 지시)
-
-# 5) 조회 및 로그 상태 검증
-$PY "$SKILL/registry.py" get --job "$JID"
-$PY "$SKILL/registry.py" logs "$JID"
-$PY "$SKILL/registry.py" logs --list
-
-
-
-
- - -
-

4. Job Lifecycle

-

각 위임 작업은 독립적인 분기가 적용되어 라이프사이클을 돌며 수신기의 exit code를 결정 짓습니다.

- -
-
-
- pending - 최초 등록 상태 -
-
- -
-
- running - 시작 신호 접수 -
-
- -
-
- completed - 성공 종료 -
- -
-
- error - 실패 종료 -
- -
-
- cancelled - 취소 상태 -
- -
-
- timeout - 제한시간 초과 -
-
- -

프로세스 반환 코드(Exit Code) 사양

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
기동 스크립트Exit Code반환 의미 및 트리거 사유
job_subscriber.py0에이전트로부터 작업을 성공적으로 마쳤다는 completed 이벤트 수신 완료
job_subscriber.py1작업 수행 실패 또는 처리 예외로 에이전트가 error 이벤트를 송출한 경우
job_subscriber.py2설정한 허용 제한 시간(timeout_sec) 또는 정체 제한(idle_timeout_sec) 임계치 도달
publish_event.py0MQTT 브로커 대상 QoS 1 발행 성공 및 PUBACK 응답 수신 완료
publish_event.py1필수 파라미터 누락, 존재하지 않는 JID 참조 등 호출 설정 오류
publish_event.py2브로커 접속 실패 또는 네트워크 결함으로 지수 백오프 기반 3회 재시도 유실
-
-
- - -
-

5. 이벤트 프로토콜

-

메시지 전송은 지연 및 간섭을 피하기 위해 1 Job당 1 전용 토픽을 준수합니다.

- -

MQTT 통신 토픽 규격

-
-
python/mqtt/jobs/<job_id>/events
-
- -

메시지 페이로드 스키마 명세

-

전송하는 모든 데이터는 UTF-8 규격의 JSON 오브젝트 포맷을 따릅니다.

- -
-
- JSON (schema_version = 1) - -
-
{
-  "schema_version": 1,
-  "seq": 7,                              // 동일 JID 내 단조 증가하는 정수형 카운터
-  "job_id": "abc12345",
-  "event": "started",                    // started | permission_required | progress | completed | error
-  "timestamp": "2026-06-19T09:32:00Z",   // ISO-8601 UTC 규격 시각
-  "detail": "데이터베이스 쿼리 아티팩트 보관 완료",
-  "data": {                              // 확장 요구사항 반영을 위한 옵션 메타 딕셔너리
-    "auth_token": "a1b2c3d4..." 
-  }
-}
-
- -

Event Catalogue

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
이벤트명의무 여부발행 유발 시점detail 기술 방식
started필수 (seq=1)에이전트가 작업을 할당받아 수행에 착수할 때"Job <id> started"
permission_required옵션에이전트가 시스템 파일 수정 등 사용자 승인이 필요할 때"needs to write sort_problems.md"
progress옵션중간 단계 수행 결과 알림"creating problem 5/10"
completed종단 필수위임 요구 명세를 온전히 이행하고 정상 종료할 때"saved sort_problems.md"
error종단 필수수행 도중 예외가 발생하거나 에이전트가 실패 종료될 때"internal exception occurred"
-
- -
-
- -
-
-

방어적 메시지 파싱 규칙 (Defensive Parsing)

-

공개 인터넷 브로커(HiveMQ 등)는 제3자의 데이터 변조가 손쉽게 가능하므로, job_subscriber.py는 아래 검증을 거쳐 맞지 않는 유해 메시지를 무시하도록 설계되었습니다.

-
    -
  • JSON 구문 포맷 에러 감지 시 무시 및 폐기
  • -
  • schema_version 속성이 구독자 지원 범위(1)와 불일치 시 기각
  • -
  • 수집 대기 중인 대상 JID가 아닌 엉뚱한 job_id를 가진 페이로드 차단
  • -
-
-
-
- - -
-

6. Audit Log 자동 기록

-

작업 실행 주기 동안 실시간 상태 흐름을 추적할 수 있도록 .hermes/delegate_job_logs/<job_id>/ 폴더에 3종의 감사 추적 파일이 자동 축적됩니다.

- -

감사 기록 파일 정의

-
- - - - - - - - - - - - - - - - - - - - - - - - - -
감사 로그 파일포맷 규격기록 내용 및 정보 범위
meta.jsonJSON Object최초 작업 할당 시점의 프롬프트 전문, 타임아웃 구성, 브로커 접속 스냅샷
events.ndjsonNDJSON생성, 송신, 수신, 상태 전이의 단계별 세부 이력이 발생 시간순으로 한 줄씩 적재
status.jsonJSON Object현재 시점의 최종 런타임 결과값 저장 (속도 개선을 위한 포인트 필터용)
-
- -

시간 흐름별 감지 포인트

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
기록 액션 시점events.ndjson 저장 포맷쓰기 명령의 수행 주체
Job 등록 완료 시registered (메타 데이터 구성 및 status.json 상태 pending 선 생성)registry.py
작업 상태 변경 시status_changed (이전 상태 → 변경 상태 흐름)job_subscriber.py / registry.py
에이전트 송신 완료published (송출 타임스탬프와 페이로드 스냅샷 포함)publish_event.py
수신기 수신 완료received (수신 감지 성공 시각 및 세부 속성)job_subscriber.py
-
- -

- 💡 운영 전환 검증 가이드: 위 종단 검증 시나리오는 공개 PoC 브로커를 활용한 테스트입니다. - 사설 운영 브로커로 전환하여 이를 검증하고자 한다면, MQTT 브로커 셋업 가이드 →의 연동 검증 시나리오 절차를 확인하십시오. -

- -
-
- -
-
-

로그 쓰기 트랜잭션 격리

-

감사 로그 아티팩트 파일 쓰기는 메인 에이전트 동작 및 MQTT 발행 스레드를 보호하기 위해 철저한 Best-effort 방식으로 감싸져 실행됩니다. 파일 쓰기 중 동시성 경합 시 try/except 처리 및 OS fcntl 락, logger.warning 백업 처리를 통해 메인 통신 채널을 보장합니다.

-
-
-
- - -
-

7. 명령어 Reference

-

통합 오케스트레이션 및 상태 점검을 지원하는 CLI 명령어입니다.

- -
- - -
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
서브 커맨드상세 역할 설명기본 사용 기법
submit작업 등록, 수신기 실행, 에이전트 tmux 기동 단계를 일괄 지시합니다.delegate-job submit --agent claude-code --prompt "..."
status레지스트리 레코드를 질의하여 작업의 세부 구성 정보를 조회합니다.delegate-job status --job <id>
list저장소에 보관된 작업 내역을 목록 형태로 일괄 조회합니다.delegate-job list
verify에이전트가 추출한 작업 산출물 아티팩트의 무결성 검증 스크립트를 작동시킵니다.delegate-job verify --job <id> --validate ./validate.sh
wait위임된 작업이 종단(completed/error) 상태를 맞이할 때까지 동기 대기합니다.delegate-job wait --job <id>
logs감사 로그 이력을 판독하여 타임라인 순으로 가공된 수행 기록을 출력합니다.delegate-job logs <id> 또는 delegate-job logs --list
-
-
- - -
-

8. 종단 Smoke 검증 결과

-

실제 로컬 호스트 및 공개 HiveMQ 브로커 상에서 정상 구동된 종단 검증 데이터(job cb32569f)의 정합성 리포트입니다.

- -

검증 재현 시나리오 명령어

-
-
- bash - -
-
# 1) human 에이전트 용 임시 smoke 작업 생성
-JID=$(.venv/bin/python skills/delegate-job/scripts/registry.py \
-        --registry-dir .hermes/jobs register \
-        --prompt "smoke: flatten+resplit" --agent human --agent-session tmux:flatten-smoke \
-        --timeout 60 --idle-timeout 30 | tail -1)
-
-# 2) 백그라운드 이벤트 감시 모니터 기동
-.venv/bin/python skills/delegate-job/scripts/job_subscriber.py \
-  --registry-dir .hermes/jobs --job "$JID" --timeout 60 --idle-timeout 30 &
-
-# 3)started 라이프사이클 이벤트 강제 발행
-.venv/bin/python skills/delegate-job/scripts/publish_event.py \
-  --registry-dir .hermes/jobs --job "$JID" --event started \
-  --detail "flatten smoke started"
-
-# 4) completed 종단 이벤트 강제 발행
-.venv/bin/python skills/delegate-job/scripts/publish_event.py \
-  --registry-dir .hermes/jobs --job "$JID" --event completed \
-  --detail "flatten smoke done"
-
-# 5) 모니터 프로세스가 completed 접수 후 자동으로 자원 회수 종료 확인
-wait %1
-
- -

실제 생성된 로그 아티팩트 명세 (job cb32569f)

- -

events.ndjson 감사 기록 전문:

-
-
{"event": "registered", "status": "pending", "agent": "human", "agent_session": "tmux:flatten-smoke", "topic_prefix": "python/mqtt/jobs/cb32569f", "timestamp": "2026-06-19T04:09:50Z", "logged_at": "2026-06-19T04:09:50.846Z"}
-{"event": "received", "source_event": "started", "seq": 1, "topic": "python/mqtt/jobs/cb32569f/events", "timestamp": "2026-06-19T04:10:03Z", "detail": "flatten smoke started", "logged_at": "2026-06-19T04:10:11.548Z"}
-{"event": "published", "source_event": "started", "seq": 1, "topic": "python/mqtt/jobs/cb32569f/events", "retain": false, "timestamp": "2026-06-19T04:10:03Z", "detail": "flatten smoke started", "payload": {"schema_version": 1, "seq": 1, "job_id": "cb32569f", "event": "started", "timestamp": "2026-06-19T04:10:03Z", "detail": "flatten smoke started", "data": {}}, "logged_at": "2026-06-19T04:10:12.555Z"}
-{"event": "status_changed", "from": "pending", "to": "running", "timestamp": "2026-06-19T04:10:12Z", "logged_at": "2026-06-19T04:10:12.558Z"}
-{"event": "received", "source_event": "completed", "seq": 2, "topic": "python/mqtt/jobs/cb32569f/events", "timestamp": "2026-06-19T04:10:13Z", "detail": "flatten smoke done", "logged_at": "2026-06-19T04:10:16.927Z"}
-{"event": "published", "source_event": "completed", "seq": 2, "topic": "python/mqtt/jobs/cb32569f/events", "retain": true, "timestamp": "2026-06-19T04:10:13Z", "detail": "flatten smoke done", "payload": {"schema_version": 1, "seq": 2, "job_id": "cb32569f", "event": "completed", "timestamp": "2026-06-19T04:10:13Z", "detail": "flatten smoke done", "data": {}}, "logged_at": "2026-06-19T04:10:17.932Z"}
-{"event": "status_changed", "from": "running", "to": "completed", "timestamp": "2026-06-19T04:10:17Z", "logged_at": "2026-06-19T04:10:17.935Z"}
-
- -

status.json 실시간 결과 캐시 스냅샷:

-
-
{
-  "job_id": "cb32569f",
-  "status": "completed",
-  "updated_at": "2026-06-19T04:10:17Z"
-}
-
- -
    -
  • 이벤트 정합성 검증 완료: 등록 → 구독 → 2회 발행 후 수신기가 정상 복귀 코드 0을 반환하며 종료
  • -
  • 감사 로그 누락 방지 확인: events.ndjson 파일 내 시간순 정합 기록 확인 완료
  • -
  • 데이터 캐싱 정합성 검증: 레지스트리의 status와 감사 로그 내 status.json 캐시 값이 완성 동기화됨
  • -
-
- - -
-

9. 자주 빠지는 함정

-

자율 에이전트 비동기 위임 환경 구성 및 운영 셋업 중에 봉착하기 쉬운 결함 시나리오입니다.

- -
-
-

① 오래된 Job ID의 에이전트 프롬프트 하드코딩 에러

-

사유: 에이전트 기동 시 무작위 ID가 새로이 발급됩니다. 과거 세션의 ID를 에이전트 기동 프롬프트에 고정 지정해 주면, 에이전트가 다른 Job ID 채널로 이벤트를 쏘고, 감시 수신기는 무반응 타임아웃(exit 2)에 처하게 됩니다.

-

→ 해결책: 래퍼 실행 프롬프트에 --job "$JOB_ID" 옵션을 넘겨 신규 JID 값을 주입 하십시오.

-
- -
-

② 수신기 실행 전 이벤트 선 송출 유실 결함

-

사유: MQTT 브로커 통신의 일반 비영속화 메시지는 구독 대기 중인 채널이 없으면 소멸합니다. 수신기를 백그라운드에 올리지 않은 상태에서 에이전트가 이벤트를 쏘면 데이터가 소실되어 프로세스가 정지합니다.

-

→ 해결책: 래퍼 submit 동작을 쓰거나, completed 발행 시 MQTT retain=true 처리를 병행하십시오.

-
- -
-

③ tmux 인터랙티브 모드에서의 세션 명칭 중복 간섭

-

사유: --agent-session에 고정 문자열(예: tmux:claude)을 기입한 채 복수 작업을 동시 실행하면, tmux 세션이 이미 점유 상태여서 프로세스가 기동 실패를 반환합니다.

-

→ 해결책: 위임 건수마다 UUID 또는 JID 기반 고유 세션명을 매핑 기동하십시오.

-
- -
-

④ 공개 테스트 브로커 보안 위협 요소

-

사유: HiveMQ 공개 브로커는 누구든지 메시지를 구독/발행 가능하므로 스니핑 및 인젝션 해킹 위협에 완전 노출되어 있습니다.

-

→ 해결책: 내부망에 전용 Mosquitto 브로커를 올리고 TLS 8883 및 Bearer auth_token 인증 로직을 전개하십시오.

-
- -
-

⑤ detail / data 속성 내 보안 자격 키 노출 위험

-

사유: MQTT 전송 페이로드의 detail 및 data에 로컬 절대 경로, 액세스 토큰 등을 여과 없이 주입하면, 타인에게 시스템 정보가 노출됩니다.

-

→ 해결책: 전송 값은 추상화된 이력 텍스트만 싣고, 기밀 파라미터는 로컬 Registry 레코드를 통해서만 참조 하십시오.

-
-
-
- - -
-

10. 운영 Broker 전환

-

보안 통제 및 격리가 요구되는 비즈니스 인프라로 이관하기 위한 독립 브로커 구성 기법입니다. 스크립트 수정 없이 환경변수 조율만으로 이행됩니다.

- - -
-
- - - - - 자세한 단계별 가이드: MQTT 브로커 셋업 (Mosquitto/EMQX, TLS, ACL, Cut-over) -
- 가이드 읽기 → -
- -

환경 주입 파일 (mqtt.env 예시)

-
-
- bash - -
-
export MQTT_BROKER=mqtt.internal.example.com
-export MQTT_PORT=8883
-export MQTT_TLS=1
-export MQTT_CA_CERTS=/etc/ssl/certs/internal-ca.pem
-export MQTT_USERNAME=hermes-operator
-export MQTT_PASSWORD=secure_token_key
-
-# 환경 주입
-source mqtt.env
-
-# 위임 submit 스크립트 재시동 (스크립트 소스코드 수정 무)
-delegate-job submit --agent claude-code --prompt "운영 서버 이관 점검"
-
- -
-
- -
-
-

우선순위 역전 장애 (Broker Precedence Pitfall)

-

공통 모듈인 broker_config_from_job()는 환경 변수 로드 후 레지스트리 JSON 파일의 broker.* 속성을 덮어씌워 합성(Merge)합니다.

-

즉, 쉘 터미널 상에 새로운 MQTT_BROKER 호스트 환경변수를 주입하더라도, 이미 등록되어 구동 대기 중이던 과거 레지스트리 레코드 파일 내부에 HiveMQ PoC 브로커 주소가 하드코딩 되어 있다면, 변경된 주소가 무시되어 과거 브로커로 접속하게 됩니다. 반드시 신규 작업을 다시 등록(register)하거나, 레코드 JSON 파일(.hermes/jobs/<id>.json)을 수동 편집하여 broker.host 항목을 수동 업데이트한 후 구동 하십시오.

-
-
-
- - - - - - - -
-
최종 검증 및 정합 완료일: 2026-06-19
-
참조 데이터 Job ID: cb32569f
-
인프라: broker.hivemq.com:1883
-
- -
- - - - -
- - - - - diff --git a/skills/delegate-job/mqtt-broker-setup.html b/skills/delegate-job/mqtt-broker-setup.html deleted file mode 100644 index 073999d..0000000 --- a/skills/delegate-job/mqtt-broker-setup.html +++ /dev/null @@ -1,1415 +0,0 @@ - - - - - - delegate-job — MQTT Broker Setup Guide - - - - - - - - - - - - -
- - - - - -
- - -
- -

MQTT Broker Setup Guide

-

공개 PoC 브로커에서 내부 TLS 및 ACL 통제 브로커로의 컷오버 셋업 가이드

-
- - -
-

1. PoC vs Production broker

-

자율 에이전트 위임 스킬(delegate-job)의 모든 연결 설정은 환경변수 및 레지스트리 레코드의 broker 블록 정보로부터 유도됩니다. 코드를 일체 변경하지 않고 설정 조율만으로 자체 사내망 브로커로 안전하게 컷오버(Cut-over)할 수 있도록 추상화되어 있습니다.

- -
-
- - - - - - - - - - - - - - - - - - - - - - - - - 1. PoC (TEST ENVIRONMENT) - - - Publisher (Worker) - - Subscriber (Hermes) - - - - broker.hivemq.com - Port: 1883 (Plaintext) - No Auth, Public Access - - - - - - 2. PRODUCTION (SECURE INTRANET) - - - Claude Worker - - Hermes Client - - - - Mosquitto / EMQX - Port: 8883 (TLS Encrypted) - ACL Rules & Password Auth - Bearer auth_token Verification - - - - -
-
- -
-
-

PoC 환경 (Public HiveMQ)

-

- 장점: 제로 셋업, 외부 접근성 완비, 빠른 프로토타입 작성 및 timeout/state-machine 로직 결선용에 최적화.

- 단점 및 수용 제약: 암호화 전송 없음(Plaintext), 임의의 제3자가 동일 토픽 메시지를 엿보거나 주입 가능, 민감 정보(패스워드, 소스 경로 등) 적재 절대 금지. 또한 비영속(Non-retained) 모드 구동 시 신호 수신기(Subscriber) 기동 전에 발행된 이벤트 및 재구독(Re-subscribing) 클라이언트의 과거 이벤트 유실을 수용해야 함. -

-
-
-

Production 환경 (사설 Mosquitto)

-

- 장점: TLS 전송 암호화, ID/Password 기반 클라이언트 인증, 토픽 레벨의 세분화된 ACL 인가 통제(Publish/Subscribe 권한 분리), 영속성 보장(QoS 1 + Retained).

- 단점 및 관리 요소: 전용 사설 인프라 및 자체 CA/인증서 키 체인 수명 관리 셋업 비용 발생. -

-
-
-
- - -
-

2. 환경변수로 broker config

-

공통 모듈 mqtt_common.py는 다음 환경변수를 우선 로드하여 접속 설정을 결정합니다. 코드를 일체 변경하지 않고 매개변수 설정만으로도 브로커를 즉각 대체할 수 있도록 추상화되어 있습니다.

- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
환경변수의미PoC 기본 설정값운영(Production) 구성 예시
MQTT_BROKER브로커 서버 IP / 도메인 호스트명"broker.hivemq.com""mqtt.internal.example.com"
MQTT_PORT브로커 접속 포트 번호1883 (Plaintext TCP)8883 (TLS TCP)
MQTT_TLSTLS 암호화 채널 기동 옵션 (1/0)0 (미기동)1 (기동 활성화)
MQTT_USERNAME접속 인증 계정 사용자명None"hermes-delegator"
MQTT_PASSWORD접속 인증 계정 비밀번호None"secure_broker_password"
MQTT_CA_CERTS검증용 사설 CA 인증서 묶음 파일 경로None"/etc/ssl/certs/internal-ca.pem"
MQTT_CERTFILEmTLS 대응용 클라이언트 공개 인증서 경로None"/path/to/client.crt"
MQTT_KEYFILEmTLS 대응용 클라이언트 개인 키 경로None"/path/to/client.key"
MQTT_CLIENT_ID_PREFIX식별 접두사 (충돌 방지용 임의 접미사 결합)"hermes""hermes-prod"
-
-
- - -
-

3. 운영 broker 설정 체크리스트 (Mosquitto/EMQX)

-

영속성 구성 및 TLS 암호화 채널, ACL 인가 제어를 위해 구성 정보를 셋업합니다. 다음 체크리스트를 준수하십시오.

- -
    -
  • 영속성(Persistence) 보장: 브로커 재기동 시에도 이전 retained 메시지가 유지되도록 설정
  • -
  • 익명 접속 금지: allow_anonymous false 설정으로 허가된 계정만 연결 허용
  • -
  • 전용 포트 매핑: TLS 전용 8883 포트 리스너 바인딩
  • -
  • 인증 및 인가 분리: 비밀번호 파일 및 ACL 규칙 파일 경로 정의
  • -
- -

3.1 Mosquitto 패키지 설치

-
-
- shell - -
-
# macOS 환경인 경우 Homebrew로 설치
-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
-
- -

3.2 `mosquitto.conf` 주요 설정 명세

-
-
- 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
-
- -

3.3 사용자 크레덴셜 생성 (`mosquitto_passwd`)

-

인증된 클라이언트만 통신할 수 있도록 사용자 해시 패스워드 파일을 생성합니다.

-
-
- shell - -
-
# 최초 사용자(hermes) 등록 (-c 옵션은 기존 파일 초기화 및 재생성하므로 최초 1회만 사용)
-mosquitto_passwd -c /mosquitto/auth/passwd hermes
-
-# 추가 에이전트 전용 계정 등록 (-c를 붙이면 기존 패스워드 목록이 휘발되므로 제외)
-mosquitto_passwd /mosquitto/auth/passwd claude-worker
-
-
- - -
-

4. TLS / 인증서 설정

-

전송 구간 암호화 및 도청 방지를 위해 TLS 인증체계를 갖춥니다. 사설 환경 구축 시 다음 명령어를 참조하여 인증서 세트를 준비하십시오.

- -

4.1 간이 검증용 자체 서명 인증서 세트 (단일 호스트 테스트용)

-
-
- shell - -
-
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   # 클라이언트는 동일 인증서를 루트 CA 신뢰용 인증서로 복사하여 참조
-
- -

4.2 사내 전용 루트 CA 기반 발급 구조 (권장 체계)

-
-
- shell - -
-
# 1) 사내 최상위 루트 CA 키 및 인증서 생성 (10년 보존 기한)
-openssl genrsa -out ca.key 4096
-openssl req -x509 -new -nodes -key ca.key -days 3650 -out ca.crt -subj "/CN=Hermes-CA"
-
-# 2) 브로커용 개인키 및 CSR 발급 신청 파일 생성
-openssl genrsa -out server.key 2048
-openssl req -new -key server.key -out server.csr -subj "/CN=mqtt.internal"
-
-# 3) 루트 CA 개인키 서명을 통해 최종 브로커용 인증서 생성 (825일 유효)
-openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-  -out server.crt -days 825
-
-

클라이언트는 환경변수 MQTT_CA_CERTS=/path/to/ca.crt를 설정함으로써 해당 사설 CA를 신뢰하게 됩니다.

-
- - -
-

5. ACL (claude worker / Hermes reader 권한 분리)

-

최소 권한의 법칙(Least Privilege)에 입각하여 퍼블리셔(에이전트 Worker)와 서브스크라이버(Hermes 모니터)의 토픽 읽기/쓰기 권한을 명확히 분리합니다. 이를 통해 타사 또는 다른 권한 영역의 에이전트가 허가되지 않은 작업 이벤트를 스푸핑하거나 발행하는 것을 완벽히 방지합니다.

- -
-
- - - - - - - - - - - - - - - - - - user: claude-worker - Agent Publisher - - - topic: python/mqtt/jobs/+/events - ACL Rules Enforcement - Wildcard '+' maps to Job ID - - - user: hermes - Observer/Subscriber - - - topic write [OK] - - - topic read [DENIED] - - - topic read [OK] - -
-
- -

5.1 ACL 규칙 파일 명세 (`/mosquitto/auth/acl`)

-
-
- acl - -
-
# claude-worker: 지정한 JID의 하위 이벤트 토픽에 메시지를 '발행'할 권한만 소유
-user claude-worker
-topic write python/mqtt/jobs/+/events
-
-# hermes: 모든 작업 식별자 토픽으로부터 이벤트를 '구독'할 권한만 소유
-user hermes
-topic read python/mqtt/jobs/+/events
-
-# 호환성 검증용 샘플 토픽 권한 공유
-pattern readwrite python/mqtt/sample
-
-
- - -
-

6. Cut-over 절차 + precedence 함정 + 검증

-

환경 구성 이관 준비가 완료되었다면, 오직 주입 설정 정보만으로 올바르게 컷오버가 체결되는지 점검합니다.

- -
-
- -
-
-

우선순위 역전 장애 (Broker Precedence Pitfall) 주의

-

- 공통 모듈 broker_config_from_job()는 환경 변수보다 Job 레지스트리 레코드 내부의 broker.* 설정값을 최종 우선(Overriding)하여 병합합니다.

- 쉘 터미널에 신규 프로덕션 브로커 환경변수(MQTT_BROKER=mqtt.internal 등)를 정상 주입하였더라도, 과거에 이미 생성되어 대기 중이던 레지스트리 JSON 파일이 있다면 기존 PoC 주소(broker.hivemq.com)가 우선 적용되게 됩니다. - 반드시 신규 작업을 다시 등록(register)하거나, 레코드 JSON 파일(.hermes/jobs/<id>.json)을 수동 편집하여 broker.host 항목을 수동 업데이트한 후 실행하십시오. -

-
-
- -

6.1 컷오버 통합 검증 명령어 시나리오

-
-
- bash - -
-
# 1) 쉘 환경에 신규 브로커 접속 매개변수 바인딩
-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=secure_hermes_password
-
-# 2) mosquitto CLI 유틸리티를 활용한 선 구독 점검
-mosquitto_sub -h "$MQTT_BROKER" -p 8883 --cafile "$MQTT_CA_CERTS" \
-  -u hermes -P "$MQTT_PASSWORD" -t 'python/mqtt/jobs/+/events' -v &
-
-# 3) 작업 위임 스크립트를 변경 없이 실행
-PY=.venv/bin/python
-JID=$($PY scripts/registry.py register --prompt "broker cutover smoke test")
-$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
-
- -

6.2 성공 판정 체크리스트

-
    -
  • job_subscriber.pycompleted 최종 수신 감지 즉시 exit code 0으로 반환 종료
  • -
  • mosquitto_sub 백그라운드 수신 터미널 창에 발행된 JSON 이벤트 페이로드가 정상 검출됨
  • -
  • 인가되지 않은(허가 받지 않은) 크레덴셜 계정으로 publish 시도 시 브로커 ACL에 의해 즉각 거절 차단
  • -
  • 작업 종료 시그널 발송 완료 후 늦게 접속한(late joined) 신규 구독자도 retained terminal 이벤트 정상 수신
  • -
- -

- 검증이 완료되면, publish_event.py 및 에이전트 구동 시 로컬 Registry 정보를 통해 신규 브로커 정보로 자동 접속할 수 있도록 각 작업 레코드 JSON의 broker 블록을 다음과 같이 지속(Persist) 정의해 주십시오. -

- -
-
- json - -
-
"broker": {
-  "host": "mqtt.internal",
-  "port": 8883,
-  "tls": true,
-  "username": "claude-worker",
-  "password": "…"
-}
-
-
- - - - - - - -
-
최종 검증 완료일: 2026-06-19
-
작업 사양: PoC to Production Cut-over
-
인프라: Eclipse Mosquitto v2.x
-
- -
- - - - -
- - - - - diff --git a/skills/delegate-job/scripts/mqtt_common.py b/skills/delegate-job/scripts/mqtt_common.py index 91b7ff6..72b3181 100644 --- a/skills/delegate-job/scripts/mqtt_common.py +++ b/skills/delegate-job/scripts/mqtt_common.py @@ -260,6 +260,10 @@ def _atomic_write_record(job_id: str, registry_dir: str, record: Dict[str, Any]) 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) diff --git a/skills/lib.sh b/skills/lib.sh index da03309..da0a5ed 100644 --- a/skills/lib.sh +++ b/skills/lib.sh @@ -7,7 +7,6 @@ # - atomic_dump_yaml : flock + 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) -# - validate_yaml : schema check (P1-G) # # Source it from each script with a path computed from the script location: # source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh" @@ -256,46 +255,6 @@ finally: PYEOF } -# --------------------------------------------------------------------------- -# validate_yaml [yaml_path] -# -# Schema check (P1-G). Exits non-zero with an actionable message on failure. -# Safe to call as a preflight in any mutator. -# --------------------------------------------------------------------------- -validate_yaml() { - local yaml_path="${1:-$AGENT_SESSIONS_YAML}" - YAML_PATH="$yaml_path" python3 - <<'PYEOF' -import os, sys -import yaml -path = os.environ['YAML_PATH'] -try: - with open(path) as f: - d = yaml.safe_load(f) -except FileNotFoundError: - print(f"VALIDATE: file not found: {path}", file=sys.stderr); sys.exit(1) -except yaml.YAMLError as e: - print(f"VALIDATE: YAML parse error: {e}", file=sys.stderr); sys.exit(1) -d = d or {} -if not isinstance(d, dict): - print("VALIDATE: top-level is not a mapping", file=sys.stderr); sys.exit(1) -sessions = d.get('tmux_sessions', []) -if not isinstance(sessions, list): - print("VALIDATE: tmux_sessions is not a list", file=sys.stderr); sys.exit(1) -valid = {'running', 'terminated', 'archived'} -for i, s in enumerate(sessions): - if not isinstance(s, dict): - print(f"VALIDATE: tmux_sessions[{i}] not a mapping", file=sys.stderr); sys.exit(1) - for k in ('name', 'status'): - if not s.get(k): - print(f"VALIDATE: tmux_sessions[{i}] missing '{k}'", file=sys.stderr); sys.exit(1) - if s['status'] not in valid: - print(f"VALIDATE: tmux_sessions[{i}] {s.get('name')!r} bad status {s['status']!r}", - file=sys.stderr); sys.exit(1) - if not isinstance(s.get('pane'), dict): - print(f"VALIDATE: tmux_sessions[{i}] {s.get('name')!r} missing pane", file=sys.stderr); sys.exit(1) -print(f"VALIDATE OK: {len(sessions)} session(s)") -PYEOF -} # --------------------------------------------------------------------------- # find_workspace_uuid @@ -401,3 +360,39 @@ if ai.get('project_cwd') == ws: print('') PYEOF } + +# --------------------------------------------------------------------------- +# delegate_submit_job +# +# Register a job in the delegate-job registry. Auto-detects the virtualenv python +# and prints the new JID on stdout. +# --------------------------------------------------------------------------- +delegate_submit_job() { + local prompt="$1" agent="$2" session="$3" + local skill_dir + skill_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + + local py_bin="python3" + local d="$skill_dir" + while [ "$d" != "/" ] && [ -n "$d" ]; do + if [ -x "$d/.venv/bin/python" ]; then + py_bin="$d/.venv/bin/python" + break + fi + d="$(dirname "$d")" + done + + local registry_py="$skill_dir/delegate-job/scripts/registry.py" + if [ ! -f "$registry_py" ]; then + registry_py="$(find "$skill_dir" -name "registry.py" | head -n 1 || echo "")" + fi + if [ -z "$registry_py" ] || [ ! -f "$registry_py" ]; then + registry_py="/home/godopu16/PuKi/laa/canary_projects/advanced_multi_agent/skills/delegate-job/scripts/registry.py" + fi + + "$py_bin" "$registry_py" register \ + --prompt "$prompt" \ + --agent "$agent" \ + --agent-session "$session" +} + diff --git a/skills/multi-agent-create/SKILL.md b/skills/multi-agent-create/SKILL.md index 8263296..02bc337 100644 --- a/skills/multi-agent-create/SKILL.md +++ b/skills/multi-agent-create/SKILL.md @@ -76,6 +76,11 @@ To prevent this, you can run this skill inside an **isolated tmux server** using ```bash bash scripts/create_session.sh --workspace /path/to/project --agent claude --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 --submit-job "Task prompt here" + ``` ### Recommended Alias You can set an alias in your shell to easily query sessions on the isolated server: diff --git a/skills/multi-agent-create/scripts/create_session.sh b/skills/multi-agent-create/scripts/create_session.sh index 88be5d5..11a444a 100755 --- a/skills/multi-agent-create/scripts/create_session.sh +++ b/skills/multi-agent-create/scripts/create_session.sh @@ -32,6 +32,7 @@ Options: --wrapper force use of ~/.local/bin/ 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 delegate-job registry with the given prompt -h, --help this help EOF } @@ -42,6 +43,7 @@ SESSION_NAME="" USE_WRAPPER=0 DRY_RUN=0 TMUX_SERVER_OPT="" +SUBMIT_JOB_PROMPT="" while [ $# -gt 0 ]; do case "$1" in @@ -51,6 +53,7 @@ while [ $# -gt 0 ]; do --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 @@ -154,6 +157,19 @@ case "$AGENT" in esac # agent-sessions.yaml 에 append +DELEGATE_JOB_ID="" +if [ -n "$SUBMIT_JOB_PROMPT" ]; then + delegate_agent="" + if [ "$AGENT" = "claude" ]; then + delegate_agent="claude-code" + 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 echo "ERROR: $AGENT_SESSIONS_YAML not found. Run init first." >&2 exit 4 @@ -172,7 +188,8 @@ 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}" <<'PYEOF' + TMUX_SERVER_NAME="${TMUX_SERVER_NAME:-default}" \ + DELEGATE_JOB_ID="$DELEGATE_JOB_ID" <<'PYEOF' name = os.environ['SESSION_NAME'] agent = os.environ['AGENT'] pid = os.environ.get('PANE_PID', '') @@ -196,6 +213,7 @@ entry = { '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, @@ -242,6 +260,20 @@ 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" + py_bin="python3" + d_dir="$(dirname "${BASH_SOURCE[0]}")" + while [ "$d_dir" != "/" ] && [ -n "$d_dir" ]; do + if [ -x "$d_dir/.venv/bin/python" ]; then + py_bin="$d_dir/.venv/bin/python" + break + fi + d_dir="$(dirname "$d_dir")" + done + pub_script="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/delegate-job/scripts/publish_event.py" + "$py_bin" "$pub_script" --job "$DELEGATE_JOB_ID" --event started --detail "canary session created" || true +fi echo "agent-sessions.yaml updated" echo if [ -n "${TMUX_SERVER_NAME:-}" ] && [ "$TMUX_SERVER_NAME" != "default" ]; then diff --git a/skills/multi-agent-delete/SKILL.md b/skills/multi-agent-delete/SKILL.md index 5d461a0..a0b8ac2 100644 --- a/skills/multi-agent-delete/SKILL.md +++ b/skills/multi-agent-delete/SKILL.md @@ -80,10 +80,13 @@ bash ~/PuKi/lab/agent_sessions/skills/multi-agent-delete/scripts/delete_session. The script: 1. Verifies the session is in agent-sessions.yaml -2. Captures the `last_visible_status` from `tmux capture-pane` (so we have a final TUI snapshot for audit) -3. For `hard` mode: `tmux kill-session -t ` (which auto-SIGTERMs children including the agent) -4. For `purge-conversation`: deletes `~/.claude/projects/.../jsonl` (claude) or `~/.gemini/antigravity-cli/conversations/...db` + `brain/...` (agy) -5. Updates the YAML entry: +2. If `delegate_job_id` is set, automatically publishes a `progress --detail "terminating"` event to the delegate-job registry +3. Captures the `last_visible_status` from `tmux capture-pane` (so we have a final TUI snapshot for audit) +4. For `hard` mode: `tmux kill-session -t ` (which auto-SIGTERMs children including the agent) +5. For `purge-conversation`: deletes `~/.claude/projects/.../jsonl` (claude) or `~/.gemini/antigravity-cli/conversations/...db` + `brain/...` (agy) +6. Updates the YAML entry +7. If `delegate_job_id` is set, publishes a `completed` event to the delegate-job registry +8. Updates the YAML entry: ```yaml - name: status: terminated diff --git a/skills/multi-agent-delete/scripts/delete_session.sh b/skills/multi-agent-delete/scripts/delete_session.sh index 91ed194..0b32014 100755 --- a/skills/multi-agent-delete/scripts/delete_session.sh +++ b/skills/multi-agent-delete/scripts/delete_session.sh @@ -61,15 +61,17 @@ if [ -z "$AGENT" ]; then esac fi -# 세션이 YAML 에 있는지 + 해당 row 의 워크스페이스 cwd 추출 -TARGET_CWD=$(env_python "$AGENT_SESSIONS_YAML" SESSION_NAME="$SESSION_NAME" <<'PYEOF' +# 세션이 YAML 에 있는지 + 해당 row 의 워크스페이스 cwd 및 delegate_job_id 추출 +MAPPED_DATA=$(env_python "$AGENT_SESSIONS_YAML" SESSION_NAME="$SESSION_NAME" <<'PYEOF' import os, yaml name = os.environ['SESSION_NAME'] with open(os.environ['YAML_PATH']) as f: d = yaml.safe_load(f) or {} for s in d.get('tmux_sessions', []): if s.get('name') == name: - print((s.get('pane') or {}).get('cwd', '')) + cwd = (s.get('pane') or {}).get('cwd', '') + jid = s.get('delegate_job_id', '') or '' + print(f"{cwd}|{jid}") raise SystemExit(0) raise SystemExit(7) PYEOF @@ -78,6 +80,9 @@ PYEOF exit 1 } +TARGET_CWD="${MAPPED_DATA%|*}" +DELEGATE_JOB_ID="${MAPPED_DATA#*|}" + # purge 확인 if [ "$PURGE" = "1" ] && [ "$YES" != "1" ]; then echo "DANGER: --purge-conversation will DELETE this workspace's on-disk conversation." @@ -104,6 +109,20 @@ if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then LAST_STATUS=$(tmux capture-pane -t "$SESSION_NAME" -p -S -10 2>/dev/null | tr '\n' ' ' | head -c 500 || true) fi +if [ -n "$DELEGATE_JOB_ID" ]; then + py_bin="python3" + d_dir="$(dirname "${BASH_SOURCE[0]}")" + while [ "$d_dir" != "/" ] && [ -n "$d_dir" ]; do + if [ -x "$d_dir/.venv/bin/python" ]; then + py_bin="$d_dir/.venv/bin/python" + break + fi + d_dir="$(dirname "$d_dir")" + done + pub_script="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/delegate-job/scripts/publish_event.py" + "$py_bin" "$pub_script" --job "$DELEGATE_JOB_ID" --event progress --detail "terminating" || true +fi + # hard 모드면 tmux 죽임 if [ "$MODE" = "hard" ] && [ "$TMUX_ALIVE" = "1" ]; then tmux kill-session -t "$SESSION_NAME" @@ -187,6 +206,20 @@ elif purge and not purge_uuid: print(f"updated: {name} status={target['status']}", flush=True) PYEOF +if [ -n "$DELEGATE_JOB_ID" ]; then + py_bin="python3" + d_dir="$(dirname "${BASH_SOURCE[0]}")" + while [ "$d_dir" != "/" ] && [ -n "$d_dir" ]; do + if [ -x "$d_dir/.venv/bin/python" ]; then + py_bin="$d_dir/.venv/bin/python" + break + fi + d_dir="$(dirname "$d_dir")" + done + pub_script="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/delegate-job/scripts/publish_event.py" + "$py_bin" "$pub_script" --job "$DELEGATE_JOB_ID" --event completed --detail "session terminated" || true +fi + echo echo "=== delete complete ===" echo " session: $SESSION_NAME" diff --git a/skills/multi-agent-resume/SKILL.md b/skills/multi-agent-resume/SKILL.md index 9699be0..68c37f9 100644 --- a/skills/multi-agent-resume/SKILL.md +++ b/skills/multi-agent-resume/SKILL.md @@ -87,6 +87,7 @@ case "$AGENT" in esac # 4. Update agent-sessions.yaml: status running, last_visible_status +# (Also automatically publishes a `progress --detail "resumed"` event to the delegate-job registry if a delegate_job_id exists) bash ~/PuKi/lab/agent_sessions/skills/multi-agent-resume/scripts/update_yaml_resumed.sh \ --session "$SESSION_NAME" --uuid "$UUID" diff --git a/skills/multi-agent-resume/scripts/update_yaml_resumed.sh b/skills/multi-agent-resume/scripts/update_yaml_resumed.sh index 8928420..b2394b0 100755 --- a/skills/multi-agent-resume/scripts/update_yaml_resumed.sh +++ b/skills/multi-agent-resume/scripts/update_yaml_resumed.sh @@ -55,6 +55,19 @@ if [ "$AGENT" = "agy" ] && [ -n "$PANE_PID" ]; then CHILD_PID="${CHILD_PID:-0}" fi +DELEGATE_JOB_ID=$(env_python "$AGENT_SESSIONS_YAML" SESSION_NAME="$SESSION_NAME" <<'PYEOF' +import os, yaml +name = os.environ['SESSION_NAME'] +with open(os.environ['YAML_PATH']) as f: + d = yaml.safe_load(f) or {} +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' @@ -104,3 +117,17 @@ snap.pop('terminated_at_epoch', None) print(f"updated: {name} status=running (resume id -> per-row own id)", flush=True) PYEOF + +if [ -n "$DELEGATE_JOB_ID" ]; then + py_bin="python3" + d_dir="$(dirname "${BASH_SOURCE[0]}")" + while [ "$d_dir" != "/" ] && [ -n "$d_dir" ]; do + if [ -x "$d_dir/.venv/bin/python" ]; then + py_bin="$d_dir/.venv/bin/python" + break + fi + d_dir="$(dirname "$d_dir")" + done + pub_script="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/delegate-job/scripts/publish_event.py" + "$py_bin" "$pub_script" --job "$DELEGATE_JOB_ID" --event progress --detail "resumed" || true +fi diff --git a/skills/multi-agent-status/SKILL.md b/skills/multi-agent-status/SKILL.md index a0c05b2..0b8c48d 100644 --- a/skills/multi-agent-status/SKILL.md +++ b/skills/multi-agent-status/SKILL.md @@ -55,24 +55,14 @@ The script: ## Output format (default = aligned table) ``` -AGENT SESSIONS STATUS -yaml_path: ~/PuKi/lab/agent_sessions/agent-sessions.yaml -tmux_sessions_alive: 2 -yaml_entries: 3 -unregistered: 0 -drifts: 0 - -NAME | YAML | TMUX | PANE CMD | PANE CWD | RESUME UUID ON DISK ---------------------------------------------------|----------|-------|-------------------|---------------------------------------------------|-------------------- -lab-landing-page-creator-claude | running | ✓ | claude | /home/.../refer_landing_page | 87dc548e-... ✓ -lab-landing-page-creator-agy | terminated| ✗ | - | - | 22255a9a-... ✓ (orphan) -lab-paper-pdf2md-creator-claude | running | ✓ | claude | /home/.../paper-pdf2md | - - -DRIFTS -(none) - -UNREGISTERED TMUX SESSIONS -(none) +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`) diff --git a/skills/multi-agent-status/scripts/status.sh b/skills/multi-agent-status/scripts/status.sh index e42a144..9c94cb8 100755 --- a/skills/multi-agent-status/scripts/status.sh +++ b/skills/multi-agent-status/scripts/status.sh @@ -60,11 +60,49 @@ def resume_on_disk(s): return '?' +def get_job_status(s): + jid = s.get('delegate_job_id') + if not jid: + return ('-', '-') + + # Try workspace relative + path = os.path.join('.hermes', 'jobs', f"{jid}.json") + 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 + + # Try fixed absolute path of registry + path_fixed = os.path.join('/home/godopu16/PuKi/laa/canary_projects/advanced_multi_agent', '.hermes', 'jobs', f"{jid}.json") + if os.path.exists(path_fixed): + try: + with open(path_fixed) as jf: + job_data = json.load(jf) + return (jid, job_data.get('status', 'unknown')) + except Exception: + pass + + # Try audit log status.json + path_audit = os.path.join('/home/godopu16/PuKi/laa/canary_projects/advanced_multi_agent', '.hermes', 'delegate_job_logs', jid, 'status.json') + if os.path.exists(path_audit): + try: + with open(path_audit) 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("=" * 116) -print(f"{'NAME':<44} {'SERVER':<12} {'YAML':<10} {'TMUX':<6} {'CMD':<6} {'RESUME':<8} DRIFT") -print("-" * 116) +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: @@ -74,15 +112,16 @@ for s in sessions: 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} {drs}") + 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("-" * 116) + print("-" * 136) for dr in extra: print(f" [{dr['class']}] {dr['msg']}") -print("=" * 116) +print("=" * 136) print(f"alive tmux: {sorted(alive)}") PYEOF