#!/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