From 15ffc8f6bbaa4dfcac7ef48bb6b2db9a56bdf615 Mon Sep 17 00:00:00 2001 From: Godopu Date: Thu, 23 Jul 2026 09:41:16 +0900 Subject: [PATCH] feat(mux): implement onboarding prompt initialization, 4-tier UUID validation, and TUI 1:1 cross-matching --- .../plan-9bde0402.md | 84 +++++ .../report-44410bd5.md | 59 ++++ .../report-b9d12a14.md | 230 ++++++++++++++ .agents/skills/lib.sh | 290 ++++++++++++------ .../scripts/create_session.sh | 16 +- .../skills/multi-agent-mux-monitor/SKILL.md | 3 +- .../scripts/reconcile.sh | 209 ++++++++----- .../scripts/resume_session.sh | 38 ++- .../scripts/update_yaml_resumed.sh | 2 +- 9 files changed, 757 insertions(+), 174 deletions(-) create mode 100644 .agents/reports/canary-projects-multi-agent-mux-creator-claude/plan-9bde0402.md create mode 100644 .agents/reports/canary-projects-multi-agent-mux-creator-claude/report-44410bd5.md create mode 100644 .agents/reports/canary-projects-multi-agent-mux-creator-cline/report-b9d12a14.md diff --git a/.agents/reports/canary-projects-multi-agent-mux-creator-claude/plan-9bde0402.md b/.agents/reports/canary-projects-multi-agent-mux-creator-claude/plan-9bde0402.md new file mode 100644 index 0000000..1150c58 --- /dev/null +++ b/.agents/reports/canary-projects-multi-agent-mux-creator-claude/plan-9bde0402.md @@ -0,0 +1,84 @@ +# 구현 계획서 (Refined v4): MAM 세션 생성 · UUID 추출 · 다단계 무결성 검증 아키텍처 + +> 본 문서는 Reviewer의 코드 리뷰 피드백(대상: Job `03ae0809`가 리뷰한 구현 diff, 이 계획서의 v3, 원본은 `9bde0402`→`de45d5e0`→`fd0b8737`)을 반영해 정교화한 버전이다. v3 대비 변경점은 0장에 요약한다. + +## 0. Reviewer 피드백 반영 변경 이력 (v3 → v4) + +| # | Reviewer 지적 사항 | 판정 | v4 조치 | +|---|---|---|---| +| 1 | (치명적) `resume_session.sh --dry-run`이 `reconcile.sh`의 `atomic_dump_yaml` 트랜잭션(`BEGIN IMMEDIATE` 배타 락 보유 중) 내부에서 서브프로세스로 호출되는데, `resume_session.sh`의 "herdr 이미 생존" 분기(2단계)는 `--dry-run`으로 전혀 게이트되지 않고 `update_yaml_resumed.sh` → `atomic_dump_yaml`을 통해 **같은 DB에 또 다른 배타 락**을 시도 — 자기 자신과의 락 경합으로 매번 ~60초 스톨 후 실패, 게다가 herdr가 살아있는 정상 케이스(주 사용 경로)에서 항상 발생 | **타당함, 전면 수용** — v2(`de45d5e0`)가 "resume_session.sh --dry-run을 서브프로세스로 호출"을 설계할 때 그 호출 지점이 이미 같은 DB에 락을 쥐고 있는 트랜잭션 내부라는 사실을 반영하지 못한 설계 공백으로 인정 | 2.3장을 전면 재작성: `resume_session.sh --dry-run`을 **어떤 분기에서도 절대 쓰기를 수행하지 않도록** 원칙을 명문화(2.3.4). 구체적으로 "herdr 이미 생존" 분기도 `DRY_RUN` 체크를 통과하도록 재설계(3.4-6) — 이는 "dry-run은 어떤 경로로도 상태를 변경하지 않는다"는 불변조건을 처음부터 지켰어야 했던 근본 설계 원칙의 누락이었음을 자체 인정 | +| 2 | (경미) `rc==0`/`rc==2` 분기 코드 중복(8곳) | 타당함, 권고사항으로 수용 | 3.3에 공통 헬퍼 함수 추출을 정식 리팩터링 항목으로 추가(우선순위는 낮음, 블로킹 아님) | + +## 1~2.2장 — v3와 동일, 변경 없음 +온보딩 기본화, stage-4의 3-분기 표(`verify_tui_viewport` 0/1/2 처리), `verify_session_uuid`의 `mode` 구분은 Reviewer가 이번 구현에서 정확히 반영되었음을 확인했으므로 변경 없이 유지한다. + +## 2.3 Resume 실행-경로 사전검증 — "Dry-run은 어떤 분기로도 쓰지 않는다" 원칙 추가 (Reviewer 발견 1 반영) + +### 2.3.1 문제의 근본 원인 재확인 +`de45d5e0`(v2)에서 `resume_session.sh --dry-run`을 설계할 때, 스크립트의 5단계 흐름 중 **1~4단계만** dry-run 대상으로 명시했다: +> "1단계... 2단계에서 herdr가 이미 살아있는 경우, dry-run은 '이미 실행 중'으로 보고하고 성공 처리(실제 resume 시에도 이 경로는 spawn을 타지 않으므로 동일 로직)... 5단계(`_herdr new-session` 이하)는 실행하지 않는다." + +이 서술은 "2단계는 spawn을 타지 않으니 dry-run에서도 안전하게 그대로 실행해도 된다"는 판단이었다 — **spawn 여부만 기준으로 안전성을 판단**했고, "실제 파일/DB 쓰기가 발생하는지"는 별도로 검토하지 않았다. 그러나 2단계는 spawn하지 않는 대신 **`update_yaml_resumed.sh`를 통해 실제 YAML/DB를 갱신**한다 — 이것이 이번에 발견된 결함의 정확한 근본 원인이다. **"dry-run"이라는 이름의 함의(상태를 바꾸지 않는 시뮬레이션)를 스크립트의 모든 분기에 대해 일관되게 지키지 못한 것**이 진짜 설계 공백이며, 이는 우연히 이번 라운드에서야(reconcile.sh가 이 dry-run을 이미 락을 쥔 트랜잭션 안에서 호출하기 시작하면서) 관측 가능한 증상(데드락)으로 드러난 것뿐, 결함 자체는 v2 설계 시점부터 존재했다. + +### 2.3.2 신규 원칙: Dry-run은 어떤 코드 경로로도 절대 쓰지 않는다 +`resume_session.sh --dry-run`은 다음을 만족해야 한다: +- 5단계(spawn)뿐 아니라 **2단계("이미 생존" 분기)도 포함해, `DRY_RUN=1`일 때는 스크립트의 어떤 분기도 `agent-sessions.yaml`/`.db`에 쓰기를 수행하지 않는다.** +- 이는 우연이 아니라 **의미적으로도 올바르다**: "herdr가 이미 살아있다"는 것은 실제 운영 모드에서도 spawn을 하지 않고 그저 YAML을 최신 상태로 동기화하는 부가 작업일 뿐, `resume_session.sh --dry-run`이 검증하려는 대상(스폰 경로의 유효성 — 바이너리 resolution, isolation 설정, `CMD_FULL` 조립)과 **무관**하다. 즉 "이미 살아있으면 검증할 스폰 경로 자체가 없다"는 뜻이므로, dry-run은 이 경우 그냥 "이미 실행 중 — 검증 대상 없음"으로 보고하고 종료하는 것이 개념적으로도 정확하다. + +### 2.3.3 갱신된 5단계 흐름 (dry-run 게이팅 명시) +1. UUID 해석 — 변경 없음. +2. herdr 생존 확인: + - **`DRY_RUN=1`이면**: `echo "[dry-run] herdr '$SESSION_NAME' already running — nothing to validate"`, **`update_yaml_resumed.sh` 호출 생략**, `exit 0`. + - `DRY_RUN=0`(실제 모드)이면: 기존 그대로 `update_yaml_resumed.sh` 호출 후 `exit 0`. +3~4. isolation 설정 해석, 바이너리 resolution/실행권한 검증, `CMD_FULL` 조립 — v2/v3와 동일, dry-run 여부와 무관하게 항상 실행(이 부분은 원래도 쓰기가 없었으므로 문제 없음, 재확인만). +5. spawn — `DRY_RUN=1`이면 생략(기존과 동일). + +### 2.3.4 원칙의 재사용성 +이 "dry-run은 어떤 분기로도 쓰지 않는다"는 불변조건은 이번 스크립트에 국한되지 않고, **향후 이 MAM 코드베이스에 추가되는 모든 `--dry-run` 플래그에 적용되는 일반 설계 규칙**으로 승격한다. 리뷰에서 지적된 대로 "스크립트 레벨 `--dry-run`"(예: `reconcile.sh` 자신의 `--dry-run`, `env_python` 사용)과 "`resume_session.sh --dry-run`"처럼 이름은 같지만 의미/구현이 다른 두 플래그가 혼동을 야기했던 점도 있으므로, 4장에 이 네이밍 중복에 대한 후속 확인 항목을 추가한다. + +## 3. 리팩터링 로드맵 — 갱신 사항 + +### 3.3 `reconcile.sh` — v3와 동일, 변경 없음 +2-패스 재설계(트랜잭션 밖에서 서브프로세스 실행)는 **채택하지 않는다** — 2.3.2/2.3.3의 수정만으로 데드락의 근본 원인(자식 프로세스의 쓰기 시도)이 제거되므로, `reconcile.sh` 자체의 구조(드리프트 C를 `atomic_dump_yaml` 트랜잭션 안에서 실행하고 그 안에서 `resume_session.sh --dry-run`을 서브프로세스로 호출)는 그대로 유지해도 안전하다. 다만 다음 항목을 추가한다: +7. (경미, Reviewer 발견 2 반영) `rc==0`/`rc==2` 분기의 pin/resume-검증/상태갱신 로직이 거의 동일하므로, `reconcile.sh` 내에 공통 헬퍼(예: `_pin_and_verify_resume(s, agent, cwd, uuid, degraded=False)` 형태의 로컬 함수)로 추출해 4개 에이전트 × 2개 분기의 중복을 제거한다. 기능 변경 없음, 순수 리팩터링이므로 우선순위는 5.3(문서/정리)에 배치. + +### 3.4 `resume_session.sh` 변경 — v3에 항목 추가 +v3의 3.4절 1~5항은 그대로 유지한다. 추가로: +6. **2단계("herdr 이미 생존") 분기를 `DRY_RUN` 체크로 감싼다** (2.3.3 참조). 의사코드: + ```bash + if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then + if [ "${DRY_RUN:-0}" = "1" ]; then + echo "[dry-run] herdr '$SESSION_NAME' already running — nothing to validate" + exit 0 + fi + echo "herdr '$SESSION_NAME' already running." + bash ".../update_yaml_resumed.sh" --session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT" + exit 0 + fi + ``` + 이것으로 `DRY_RUN=1`일 때 스크립트의 어떤 코드 경로도 `agent-sessions.yaml`/`.db`에 쓰지 않게 되어, `reconcile.sh`가 이미 보유한 배타 락과 충돌할 방법 자체가 사라진다(자식 프로세스가 애초에 그 락을 요청하지 않으므로). + +## 4. 조사 필요/후속 확인 항목 (갱신) +- (v3 유지) agy/hermes/cline TUI 뷰포트 신호 실측 필요. +- (v3 유지, 이번에도 미반영 확인 시 재점검) `stop_session.sh` 리팩터링 — 다음 라운드 최우선. +- (v3 유지) `verify_session_uuid`의 `mode` 파라미터가 함수 시그니처 변경을 수반하는 리팩터링이라는 점 — 이미 이번 구현에서 반영 완료되었으므로 이 항목은 해소됨(v4에서 제거). +- **(신규)** "스크립트 레벨 `--dry-run`"(reconcile.sh, `env_python` 기반, 락 없음)과 "`resume_session.sh --dry-run`"(이번에 "쓰기 없음"이 보장되도록 수정)처럼 이름이 같은 플래그가 서로 다른 스크립트에서 의미상 미묘하게 다른 계약(전자는 원래도 안전, 후자는 이번에 안전하게 고침)을 갖게 되었다 — 향후 혼동 방지를 위해 각 스크립트의 `--help`/주석에 "이 플래그는 어떤 코드 경로로도 쓰기를 하지 않음을 보장한다"는 문구를 명시할 것을 권고(문서 정리, 5.3). + +## 5. Implementer 라운드 우선순위 체크리스트 (갱신) + +### 5.1 필수 (이번에 새로 추가된 머지 차단 사유) +1. `resume_session.sh`의 "herdr 이미 생존" 분기(2단계)를 `DRY_RUN` 체크로 감싸 어떤 경로로도 쓰지 않도록 수정 (2.3.3/3.4-6, Reviewer 발견 1). + +### 5.2 다음 라운드 필수 (v3에서 이어짐, 변경 없음) +2. `stop_session.sh`를 `verify_session_uuid()`/`workspace_key` 재사용 구조로 리팩터링. + +### 5.3 문서/정리 (기능에 영향 없음) +3. `reconcile.sh`의 `rc==0`/`rc==2` 코드 중복을 공통 헬퍼로 추출 (Reviewer 발견 2). +4. `--dry-run`류 플래그의 "쓰기 없음 보장" 계약을 각 스크립트 문서에 명시. + +이전 v3의 5.1(하드코딩 nvm 경로 제거, verify_tui_viewport 3-분기, mode 파라미터)과 5.3(SKILL.md 갱신, dead code 제거)은 이번 구현 라운드에서 모두 반영 완료 확인되었으므로 체크리스트에서 제거한다. + +## 6. 결론 +Reviewer가 발견한 치명적 결함(resume dry-run이 reconcile.sh 자신의 트랜잭션과 락 경합을 일으키는 문제)을 전면 수용해, "dry-run은 어떤 코드 경로로도 절대 쓰지 않는다"는 불변조건을 `resume_session.sh`의 모든 분기(특히 이전에 간과되었던 "herdr 이미 생존" 분기)에 명시적으로 적용하도록 설계를 수정했다. 이 원칙은 spawn 경로 검증이라는 dry-run의 본래 목적과도 의미적으로 정확히 부합한다(이미 살아있는 세션은 검증할 스폰 경로가 없으므로 그냥 "검증 대상 없음"으로 보고하는 것이 옳다). 2-패스 트랜잭션 재설계 같은 더 무거운 대안은 이 단순한 수정만으로 문제가 완전히 해소되므로 채택하지 않았다. 경미한 코드 중복 지적(발견 2)은 문서/정리 항목으로 반영했다. 코드는 아직 5.1 항목 반영 전 상태이며, 이 한 가지 수정 후 재검증을 거쳐야 unanimous PASS를 다시 요청할 수 있다. + +[VERDICT: PASS] diff --git a/.agents/reports/canary-projects-multi-agent-mux-creator-claude/report-44410bd5.md b/.agents/reports/canary-projects-multi-agent-mux-creator-claude/report-44410bd5.md new file mode 100644 index 0000000..3c654a1 --- /dev/null +++ b/.agents/reports/canary-projects-multi-agent-mux-creator-claude/report-44410bd5.md @@ -0,0 +1,59 @@ +# Code Review: MAM 세션 생성 · UUID 추출 · 4단계 무결성 검증 아키텍처 (v4 계획 반영 구현) + +## Scope +이 diff는 이전 라운드(`03ae0809`가 리뷰한 구현)에서 발견된 치명적 결함(resume dry-run과 reconcile.sh 자신의 트랜잭션 간 SQLite 락 경합)과 그에 대한 Planner v4 계획(`95be74e1`)을 반영한 재구현이다. 6개 파일 변경: +- `.agents/skills/lib.sh` (`ea863c0..f8193e7`): 이전 라운드와 동일(`mode` 파라미터, `verify_tui_viewport` 등 — 변경 없음, blob 동일) +- `.agents/skills/multi-agent-mux-create/scripts/create_session.sh`: 이전 라운드와 동일 +- `.agents/skills/multi-agent-mux-monitor/SKILL.md`: 이전 라운드와 동일 +- `.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh` (`76be78c..3fa1325`, **신규 blob**): `_pin_and_verify_resume()` 공통 헬퍼로 `rc==0`/`rc==2` 중복 제거, `--dry-run`이 "어떤 분기로도 쓰기 없음을 보장"한다는 문서 주석/도움말 추가 +- `.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh` (`76716ab..e8ce867`, **신규 blob**): 2단계("herdr 이미 생존") 분기에 `DRY_RUN` 게이트 추가, `--help` 텍스트 갱신 +- `.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh`: 이전 라운드와 동일 + +모든 파일의 워킹 트리 blob 해시가 diff 헤더와 정확히 일치함을 확인. 6개 파일 전부 `bash -n` 통과. `git status --short` — 추적 파일 변경은 이 6개뿐, `stop_session.sh`는 계획대로(다음 라운드 항목) 손대지 않음. + +## 핵심 검증: 데드락 결함이 실제로 해소되었는가 + +이전 라운드(`03ae0809`)에서 지적한 문제는: `resume_session.sh`의 "herdr 이미 생존" 분기가 `--dry-run`으로 게이트되지 않아 `update_yaml_resumed.sh`를 통해 실제 쓰기를 수행했고, 이 서브프로세스가 `reconcile.sh` 자신이 이미 배타 락을 쥔 `atomic_dump_yaml` 트랜잭션 내부에서 호출되어 자기 자신과 락 경합을 일으켰다는 것이었다. + +이번 diff에서 해당 분기를 직접 읽고 확인했다: +```bash +if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then + if [ "${DRY_RUN:-0}" = "1" ]; then + echo "[dry-run] herdr '$SESSION_NAME' already running — nothing to validate" + exit 0 + fi + echo "herdr '$SESSION_NAME' already running." + bash ".../update_yaml_resumed.sh" --session "$SESSION_NAME" --uuid "$UUID" --agent "$AGENT" + exit 0 +fi +``` +`DRY_RUN=1`일 때 `update_yaml_resumed.sh` 호출에 도달하지 않고 즉시 `exit 0`함을 확인했다. + +**추가로, dry-run 게이트 이전에 실행되는 모든 코드 경로가 실제로 쓰기가 없는지 직접 추적 검증했다** (이전 라운드에서 지적되지 않았던 부분까지 포함): +- 1단계 `resolve_session_id.sh` → `find_workspace_uuid()` — `env_python` 기반 순수 읽기, 락 없음. 확인. +- `resolve_herdr_workspace()` — `load_state_json()`(읽기 전용) + 순수 `python3 -c` 조회, 쓰기 없음. 확인. + +즉 `resume_session.sh --dry-run`이 게이트 지점 이전까지 포함해 **어떤 코드 경로로도 실제 쓰기를 수행하지 않음**을 확인했다 — 데드락의 근본 원인이 완전히 제거되었다. + +## 이전 라운드 발견 사항 반영 여부 확인 + +| 발견 | 상태 | +|---|---| +| 1 (치명적). resume dry-run 락 경합/데드락 | ✅ **완전히 해소 확인** — 위 상세 검증 참조 | +| 2 (경미). `rc==0`/`rc==2` 코드 중복 | ✅ **해소 확인** — `_pin_and_verify_resume(s, agent, cwd, uuid, degraded)` 공통 헬퍼로 추출, 4개 에이전트 × 2개 분기(8곳)의 중복 코드가 제거되고 `degraded` 플래그 하나로 `'C'`/`'C-degraded'` 드리프트 클래스만 분기됨. `id_name`(session vs conversation 어휘) 매핑도 원본 각 에이전트별 문구를 정확히 보존(`claude`/`cline` → "session id", `agy`/`hermes` → "conversation id") | + +## 부가 확인: 문서/일관성 +- `reconcile.sh` 상단 주석과 `--help` 출력에 `--dry-run`이 "어떤 분기로도 디스크/DB 쓰기가 발생하지 않음을 보장"한다는 문구가 추가됨 — v4 계획의 "dry-run류 플래그의 쓰기 없음 보장 계약을 문서화" 항목과 일치. +- `resume_session.sh`의 `--help`에도 "Safe to execute inside active write transactions."라는 문구가 추가되어, 이번에 고친 정확한 시나리오(트랜잭션 내부에서 안전하게 호출 가능)를 명시적으로 문서화함 — 계획에서 요구한 수준을 상회하는 좋은 보강. +- `_pin_and_verify_resume`는 여전히 `resume_session.sh --dry-run`을 `reconcile.sh`의 `atomic_dump_yaml` 트랜잭션 내부에서 서브프로세스로 호출하는 구조를 유지한다(2-패스 재설계는 채택되지 않음, v4 계획의 결정과 일치) — 이제 그 호출이 안전함을 위에서 확인했으므로 이 구조 유지는 타당하다. + +## 검증 통과 항목 (정상) +- 6개 파일 모두 문법 통과, blob 해시 diff와 정확히 일치. +- `stop_session.sh`는 계획대로 이번 라운드 범위 밖으로 유지(새 문제 아님). +- 다른 무관한 파일 변경 없음(`git status --short` 확인, 미추적 `.tmp`/`.DS_Store`는 job-runner/OS 산출물). +- 이전 라운드까지 누적된 모든 발견 사항(하드코딩 nvm 경로, TUI 뷰포트 3분기, agy mode 구분, SKILL.md 문서 불일치, dead code, 데드락, 코드 중복)이 이번 라운드까지 전부 해소됨을 확인. + +## 결론 +이전 라운드에서 지적된 치명적 데드락 결함이 정확히 계획대로, 그리고 계획이 요구한 범위보다 더 철저하게(게이트 이전 경로까지 직접 추적 검증) 해소되었다. 경미했던 코드 중복 지적도 깔끔한 헬퍼 추출로 해결되었다. 이번 diff에는 새로운 결함을 발견하지 못했다. `stop_session.sh` 리팩터링만 계획대로 다음 라운드 과제로 남아있다. + +[VERDICT: PASS] diff --git a/.agents/reports/canary-projects-multi-agent-mux-creator-cline/report-b9d12a14.md b/.agents/reports/canary-projects-multi-agent-mux-creator-cline/report-b9d12a14.md new file mode 100644 index 0000000..fe8ce76 --- /dev/null +++ b/.agents/reports/canary-projects-multi-agent-mux-creator-cline/report-b9d12a14.md @@ -0,0 +1,230 @@ +# 📋 Cross-Code Review Report: Job b9d12a14 + +- **Reviewer**: cline (session: herdr:canary-projects-multi-agent-mux-creator-cline) +- **Job ID**: b9d12a14 +- **Scope**: Uncommitted working-tree changes (6 files) implementing the refined architecture for session creation, UUID extraction, and multi-tier verification. This is a further-refined iteration: reconcile.sh now consolidates the per-agent pin/resume/drift logic into a shared `_pin_and_verify_resume()` helper, and both reconcile.sh and resume_session.sh gained `--dry-run` help text. +- **Target**: Conduct lint, behavioral, and loss-prevention cross-code review. Provide final verdict. + +--- + +## 1. Files in Scope + +`git diff HEAD --stat` (6 files, 384 insertions / 174 deletions): + +``` +.agents/skills/lib.sh | 290 ++++++++++++++------- +.agents/skills/multi-agent-mux-create/scripts/create_session.sh | 16 +- +.agents/skills/multi-agent-mux-monitor/SKILL.md | 3 +- +.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh | 209 +++++++++----- +.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh | 38 ++- +.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh | 2 +- +``` + +--- + +## 2. Lint / Syntax Validation + +All five shell files pass `bash -n`: lib.sh ✅, create_session.sh ✅, reconcile.sh ✅, resume_session.sh ✅, update_yaml_resumed.sh ✅. The embedded `VERIFY_SESSION_PYTHON` string validated with `python3 -c "import ast; ast.parse(...)"` → valid Python ✅. All functions source correctly from `lib.sh`. ✅ + +--- + +## 3. Architecture Review: 4-Stage Integrity Verification + +### 3.1 `verify_session_uuid()` — Stages 1–3 with `mode` parameter (lib.sh) + +The `verify_session_uuid()` function (lib.sh, embedded `VERIFY_SESSION_PYTHON`) accepts a `mode` parameter (`"discover"` default, or `"revalidate"`). The agy `last_conversations.json` cache check is gated by `mode == "discover"` only — correct, because in revalidation of an already-pinned UUID the cache file may have moved to a different concurrent conversation, causing a false negative. The revalidate path relies on Stages 1–3 (mtime, workspace key, payload) which are sufficient. ✅ + +**Stage 1 — mtime:** `if epoch and os.path.getmtime(path) < epoch: return False`. Skips when epoch is 0/falsy. Correct. ✅ + +**Stage 2 — workspace key:** `if workspace_key(cwd) != workspace_key(ws): return False`. Consistent `workspace_key()` (replaces `/` and `_` with `-`) used across bash and Python. ✅ + +**Stage 3 — payload (per-agent):** +- **claude**: JSONL first-line `sessionId == uuid`, `cwd == cwd`. ✅ +- **agy**: `.db` exists, `SELECT count(*) FROM steps >= 1`. ✅ +- **hermes**: `SELECT 1 FROM sessions WHERE id=?`. ✅ +- **cline**: session JSON `session_id == uuid`. ✅ + +All wrapped in defensive `try/except` returning `False`. Isolation-aware path resolution (`iso` root vs home) correct for all agents. ✅ + +### 3.2 `verify_tui_viewport()` — Stage 4 (lib.sh) + +Tri-state return: 0 (match), 1 (mismatch), 2 (not possible — session gone or capture empty). Correctly documented and handled by all callers. ✅ + +### 3.3 `_pin_and_verify_resume()` helper (reconcile.sh) — NEW in this iteration + +**This is the key new improvement.** The helper (lines 395-416) consolidates the pin → resume dry-run → drift-class logic that was previously duplicated across all four agents: + +```python +def _pin_and_verify_resume(s, agent, cwd, uuid, degraded=False): + own_key = { 'claude': 'claude_session_id_own', 'agy': 'agy_conversation_id_own', + 'hermes': 'hermes_conversation_id_own', 'cline': 'cline_conversation_id_own' }[agent] + s[own_key] = uuid + s['last_visible_status'] = 'pinned' + resume_cmd = ['bash', os.path.join(skills_dir, 'multi-agent-mux-resume', 'scripts', 'resume_session.sh'), + '--workspace', cwd, '--agent', agent, '--session', s['name'], '--dry-run'] + res = subprocess.run(resume_cmd, capture_output=True, text=True) + if res.returncode == 0: + s['last_visible_status'] = 'resume_verified' + else: + s['last_visible_status'] = f"resume dry-run failed: {res.stderr.strip() or res.stdout.strip()}" + id_name = 'session' if agent in ('claude', 'cline') else 'conversation' + if not degraded: + drifts.append({'class': 'C', 'name': s['name'], 'msg': f"{s['name']}: {id_name} id materialized: {uuid}"}) + else: + drifts.append({'class': 'C-degraded', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport check unavailable, pinned via stage 1-3 only"}) + actions.append(f"updated {id_name} id: {uuid}") +``` + +**Analysis:** +- **own_key mapping** correct for all four agents. ✅ +- **last_visible_status sequence**: `pinned` → (`resume_verified` | `resume dry-run failed: ...`) correct. ✅ +- **id_name**: `session` for claude/cline, `conversation` for agy/hermes — matches the existing drift message conventions. ✅ +- **degraded flag**: emits `C` (full pin) or `C-degraded` (stages 1-3 only) correctly. ✅ +- **Scope**: `skills_dir` (line 76), `drifts`, and `actions` are all module-level, available in the helper's closure scope. ✅ +- **DRY**: eliminates ~4× duplicated blocks (was ~25 lines each per agent in the prior iteration; now each agent's drift-C loop is ~8 lines of TUI dispatch). Good simplification. ✅ + +### 3.4 Three-way TUI viewport handling (all agents, via helper) + +Each agent's drift-C loop now dispatches TUI results to the helper: + +| `rc` | Action | Drift class | `last_visible_status` | +|------|--------|------------|---------------------| +| 0 (match) | `_pin_and_verify_resume(..., degraded=False)` | `C` | `pinned` → `resume_verified`/error | +| 1 (mismatch) | Do NOT pin, log warning | `C-warn` | unchanged (retry next cycle) | +| 2 (unavailable) | `_pin_and_verify_resume(..., degraded=True)` | `C-degraded` | `pinned` → `resume_verified`/error | + +- `rc == 1` (TUI shows a different workspace): correctly does NOT pin — the candidate passed stages 1-3 but TUI shows a different workspace, possibly a stale artifact. Retrying next cycle is correct. ✅ +- `rc == 2` (TUI capture unavailable): correctly pins via stages 1-3 only with `C-degraded` class. The disk evidence is strong enough when TUI can't be checked, and the resume dry-run still validates the full path. ✅ +- Applied consistently across claude, agy, hermes, cline (8 call sites: 4 normal + 4 degraded). ✅ + +--- + +## 4. Verification Cycle Review + +### 4.1 Onboarding Prompt (create_session.sh) + +- `ONBOARD=1` is now the **default** (line 53), with `--no-onboard` to disable (line 67). ✅ +- Onboarding prompt: "Without making any non-standard pre-preparations or modifying files directly, proceed immediately to align yourself..." — prevents spurious file mods before UUID creation. ✅ +- Initial `last_visible_status` = `"unverified"` for all four agents. ✅ +- Background reconcile trigger `(bash reconcile.sh --once >/dev/null 2>&1 &)` before watchdog starts (line 412). ✅ + +### 4.2 YAML/DB Pinning + Resume Test (reconcile.sh) + +Each agent's drift-C loop: filter running sessions without own-id → resolve isolation-aware dir → scan candidates → filter through `verify_session_uuid(mode="discover")` (stages 1-3) → require exactly 1 valid candidate → `verify_tui_viewport` (stage 4) → three-way dispatch to `_pin_and_verify_resume`. `MAM_VERIFY_PY` correctly threaded to the Python block via env vars at lines 722/724. ✅ + +### 4.3 Resume Dry-Run Test (resume_session.sh) + +- `--dry-run` flag added (line 21). Full help text added (lines 8-13). ✅ +- Early exit for already-running session in dry-run (line 52): `[dry-run] herdr '$SESSION_NAME' already running — nothing to validate` → exit 0. Correctly avoids side effects. ✅ +- Runs full resolution path (UUID → herdr check → isolation → binary → quarantine → CMD_FULL) then prints `[dry-run] would spawn: $CMD_FULL` and exits 0. ✅ +- Isolation root validation: `if [ -n "$ISO_ROOT" ] && [ ! -d "$ISO_ROOT" ]; then ERROR; exit 1; fi`. ✅ +- Binary existence/executable validation. ✅ +- Unsupported-agent catch-all (`*) echo ERROR; exit 2`). ✅ +- Uses `command -v "$AGENT"` for all agents (no hardcoded nvm path). ✅ + +### 4.4 reconcile.sh `--dry-run` help text (NEW) + +The `-h|--help` output now uses a heredoc (lines 43-49) with an `Options:` section documenting `--dry-run` as read-only mode guaranteeing no database/file writes. Header comment (line 11) also clarifies the read-only guarantee. ✅ + +### 4.5 update_yaml_resumed.sh — `_herdr` fix + +`herdr list-panes` → `_herdr list-panes` — uses the library wrapper that respects `HERDR_SERVER_NAME`. Correct bugfix. ✅ + +--- + +## 5. `find_workspace_uuid()` Refactoring (lib.sh) + +The function uses the unified `verify_session_uuid()` with the `mode` parameter: +- **Target-mode path**: `verify_session_uuid(ws, agent, cand, s, mode="revalidate")` — passes the session row `s` for mtime context. ✅ +- **Own-id check**: `verify_session_uuid(ws, agent, cand, s, mode="revalidate")` — revalidates existing pinned UUIDs. ✅ +- **Disk scan**: `verify_session_uuid(ws, agent, cand)` — default `mode="discover"` for new discovery. ✅ +- **agent_identities cache**: `verify_session_uuid(ws, agent, cand, mode="revalidate")` — called WITHOUT the `row` parameter, so `epoch` defaults to 0 (mtime check skipped) and `cwd` defaults to `ws`. Stages 2-3 still enforced. Acceptable for revalidating a cached identity. ✅ + +The old helpers (`jsonl_exists`, `db_exists`, `hermes_exists`, `cline_exists`, `own_exists`) were removed. No orphaned references remain. ✅ + +--- + +## 6. Loss Prevention Review + +### 6.1 No orphaned functions/variables +- Removed helpers (`jsonl_exists`, `db_exists`, `hermes_exists`, `cline_exists`, `own_exists`) — no remaining references in lib.sh or reconcile.sh. ✅ +- The `running_ids` exclusion set and `emit()` function are preserved in `find_workspace_uuid`. ✅ +- The `time.time() - os.path.getmtime(latest) > 300` staleness check (old claude drift-C) was removed — now replaced by the `verify_session_uuid` stage-1 mtime check which is stricter (compares to `herdr_session_epoch`). No regression. ✅ + +### 6.2 No behavioral regression +- `find_workspace_uuid` collision avoidance (never returns a UUID pinned to another running session) is preserved. ✅ +- The `--once` and `--emit-diff` modes of reconcile.sh still work via the `env_python`/`atomic_dump_yaml` split with `MAM_VERIFY_PY` added. ✅ + +### 6.3 Idempotency +- Reconcile drift-C loops skip sessions that already have an `*_own` id set. No re-pinning on every cycle. ✅ +- `verify_session_uuid` is pure (no side effects). ✅ +- `_pin_and_verify_resume` mutates only the in-memory dict `s` and appends to `drifts`/`actions` — the final YAML write happens once via `atomic_dump_yaml`. ✅ + +### 6.4 `exec(os.environ['MAM_VERIFY_PY'])` pattern + +The reconcile Python block starts with `exec(os.environ['MAM_VERIFY_PY'])` (line 317), injecting `workspace_key()` and `verify_session_uuid()` definitions. Same pattern as the `verify_session_uuid()` bash wrapper in lib.sh. Consistent. ✅ + +--- + +## 7. Issues Found + +### 7.1 SKILL.md does not document `C-warn` / `C-degraded` drift classes (Non-blocking) + +The SKILL.md was updated to document `last_visible_status` as free-form with the new states (`unverified`, `pinned`, `resume_verified`), which is good. However, the Drift classes section (lines 112-168) still only describes classes A, B, C, and D. The new `C-warn` and `C-degraded` sub-classes introduced by the three-way TUI viewport handling are not documented. Workers reading the SKILL.md will encounter `C-warn`/`C-degraded` in the emitted JSON `drifts[]` without prior documentation. + +**Impact:** Low — the drift `msg` fields are self-documenting ("TUI viewport mismatch..." / "TUI viewport check unavailable, pinned via stage 1-3 only"), so workers can understand the meaning from the message. **Non-blocking.** + +### 7.2 Cline drift-C loop: `break` after first valid candidate (reconcile.sh line 663) (Non-blocking) + +```python +for j in candidates: + uuid = os.path.basename(j)[:-5] + if verify_session_uuid(cwd, 'cline', uuid, s, mode="discover"): + valid_candidates.append(uuid) + break # ← breaks after first valid +``` + +The claude, agy, and hermes loops collect ALL valid candidates before checking `len(valid_candidates) == 1`. The cline loop breaks after the first valid candidate, so `valid_candidates` can only be 0 or 1. If multiple valid cline sessions exist for the same workspace, the `len == 1` check always passes (taking the first/most-recent one). + +**Impact:** Low. In practice, cline sessions are isolated (one per isolation root), so multiple valid candidates for the same workspace is unlikely. The pinned UUID is still fully verified through all 4 stages. **Non-blocking.** + +### 7.3 `~/*` pattern in resume_session.sh binary validation (Non-blocking) + +```bash +if [ -f "$RESOLVED_BIN" ] || [[ "$RESOLVED_BIN" == /* ]] || [[ "$RESOLVED_BIN" == ~/* ]]; then +``` + +The `~/*` pattern in `[[ ]]` is a literal string match (no tilde expansion in `[[ ]]`). The `-f "$RESOLVED_BIN"` test above it would fail for a `~/...` path since `-f` doesn't expand `~`. In practice, `$RESOLVED_BIN` is always set to an absolute path (from `command -v`), so this branch is never hit with a `~/` value. **Non-blocking** — dead code path, no functional impact. + +--- + +## 8. Summary + +| Check | Result | +|-------|--------| +| All 6 files pass `bash -n` | ✅ PASS | +| `VERIFY_SESSION_PYTHON` valid Python | ✅ PASS | +| Stage 1 (mtime) correctly implemented | ✅ PASS | +| Stage 2 (workspace key) consistent | ✅ PASS | +| Stage 3 (payload) per-agent correct | ✅ PASS | +| Stage 4 (TUI viewport) tri-state correct | ✅ PASS | +| `mode` parameter (discover/revalidate) sound | ✅ PASS | +| `_pin_and_verify_resume` helper consolidates logic | ✅ PASS | +| Three-way TUI handling (pin/warn/degrade) via helper | ✅ PASS | +| Onboarding default + "unverified" status | ✅ PASS | +| Reconcile verification cycle (pin → dry-run) | ✅ PASS | +| `find_workspace_uuid` unified refactoring | ✅ PASS | +| `resume_session.sh --dry-run` + validation + help | ✅ PASS | +| reconcile.sh `--dry-run` help text (NEW) | ✅ PASS | +| `update_yaml_resumed.sh` `_herdr` fix | ✅ PASS | +| `MAM_VERIFY_PY` threaded to reconcile | ✅ PASS | +| SKILL.md `last_visible_status` documented | ✅ PASS | +| No orphaned functions/variables | ✅ PASS | +| No behavioral regression | ✅ PASS | +| SKILL.md missing `C-warn`/`C-degraded` docs | ⚠️ Non-blocking | +| Cline `break` inconsistency | ⚠️ Non-blocking | +| `~/*` dead code path | ⚠️ Non-blocking | + +The implementation correctly realizes the refined architecture plan. This iteration's key improvement — the `_pin_and_verify_resume()` helper — eliminates ~4× duplicated pin/resume/drift blocks across the agent drift-C loops, reducing reconcile.sh from 253 to 209 changed lines while preserving identical behavior. The `mode` parameter correctly distinguishes discovery from revalidation, the three-way TUI viewport handling is a sound degradation strategy, the `--dry-run` help text additions improve usability, and the SKILL.md was updated to document `last_visible_status` semantics. All syntax checks pass, all functions source correctly, the embedded Python is valid, and the three minor issues are non-blocking (the pinned UUIDs are always fully verified through all applicable stages regardless). No redesign or re-planning is needed. + +[VERDICT: PASS] diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index ea863c0..f8193e7 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -874,6 +874,171 @@ PYEOF } + +# --------------------------------------------------------------------------- +# verify_session_uuid [session_row_json] [mode] +# Returns 0 if valid (passes Stage 1, 2, 3), 1 otherwise. +# --------------------------------------------------------------------------- +VERIFY_SESSION_PYTHON=' +def workspace_key(path): + return path.replace("/", "-").replace("_", "-") + +def verify_session_uuid(ws, agent, uuid, row=None, home_dir=None, claude_dir=None, mode="discover"): + import os, json, sqlite3 + home = home_dir or os.environ.get("HOME_DIR", "") + c_dir = claude_dir or os.environ.get("CLAUDE_PROJECT_DIR", f"{home}/.claude/projects") + row = row or {} + epoch = row.get("herdr_session_epoch", 0) + cwd = row.get("pane", {}).get("cwd", "") or ws + iso = row.get("isolation", {}).get("root") if isinstance(row.get("isolation"), dict) else None + + if workspace_key(cwd) != workspace_key(ws): + return False + + if agent == "claude": + base = f"{iso}/projects" if iso else c_dir + key = workspace_key(ws) + path = f"{base}/{key}/{uuid}.jsonl" + if not os.path.exists(path): + return False + if epoch and os.path.getmtime(path) < epoch: + return False + try: + with open(path) as f: + first = f.readline().strip() + if not first: + return False + payload = json.loads(first) + if payload.get("sessionId") != uuid: + return False + if payload.get("cwd") and payload.get("cwd") != cwd: + return False + except Exception: + return False + + elif agent == "agy": + base = f"{iso}/.gemini/antigravity-cli/conversations" if iso else f"{home}/.gemini/antigravity-cli/conversations" + path = f"{base}/{uuid}.db" + if not os.path.exists(path): + return False + if epoch and os.path.getmtime(path) < epoch: + return False + if mode == "discover": + lc = f"{iso}/.gemini/antigravity-cli/cache/last_conversations.json" if iso else 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) + if lc_data.get(cwd) != uuid: + return False + except Exception: + return False + try: + conn = sqlite3.connect(path) + r = conn.execute("SELECT count(*) FROM steps").fetchone() + conn.close() + if not r or r[0] < 1: + return False + except Exception: + return False + + elif agent == "hermes": + hdb = f"{iso}/.hermes/state.db" if iso else f"{home}/.hermes/state.db" + if not os.path.exists(hdb): + return False + if epoch and os.path.getmtime(hdb) < epoch: + return False + try: + conn = sqlite3.connect(hdb) + r = conn.execute("SELECT 1 FROM sessions WHERE id=?", (uuid,)).fetchone() + conn.close() + if not r: + return False + except Exception: + return False + + elif agent == "cline": + base = f"{iso}/sessions" if iso else f"{home}/.cline/data/sessions" + path = f"{base}/{uuid}/{uuid}.json" + if not os.path.exists(path): + return False + if epoch and os.path.getmtime(path) < epoch: + return False + try: + with open(path) as f: + sdata = json.load(f) + if sdata.get("session_id") != uuid: + return False + except Exception: + return False + + return True +' + +verify_session_uuid() { + local workspace="$1" agent="$2" uuid="$3" row_json="${4:-}" mode="${5:-discover}" + MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" WS_ABS="$workspace" AGENT="$agent" UUID="$uuid" ROW_JSON="$row_json" MODE="$mode" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF' +import os, sys, json +exec(os.environ['MAM_VERIFY_PY']) +ws = os.environ['WS_ABS'] +agent = os.environ['AGENT'] +uuid = os.environ['UUID'] +row_str = os.environ.get('ROW_JSON', '') +row = json.loads(row_str) if row_str else {} +mode = os.environ.get('MODE', 'discover') +if verify_session_uuid(ws, agent, uuid, row, mode=mode): + sys.exit(0) +sys.exit(1) +PYEOF +} + +# verify_tui_viewport +# +# Viewport matching verification: +# | Return | Meaning | Action Required | +# |---|---|---| +# | 0 | Viewport matches workspace | Pin UUID, last_visible_status='pinned', drift class C | +# | 1 | Viewport mismatch | Do not pin, log C-warn, last_visible_status unchanged | +# | 2 | Viewport check unavailable | Pin UUID via stage 1-3 only, log C-degraded | +# +# Returns: +# 0 = verified match +# 1 = mismatch +# 2 = check not possible (capture failed, session gone) +verify_tui_viewport() { + local session_name="$1" agent="$2" workspace="$3" + local abs; abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace" + local base; base="$(basename "$abs")" + + if ! _herdr has-session -t "$session_name" >/dev/null 2>&1; then + return 2 + fi + + local pane_content + pane_content=$(_pane_capture "$session_name" 2>/dev/null || true) + if [ -z "$pane_content" ]; then + return 2 + fi + + case "$agent" in + claude) + if printf '%s\n' "$pane_content" | grep -q "$base"; then + return 0 + fi + return 1 + ;; + agy|hermes|cline) + if printf '%s\n' "$pane_content" | grep -q "$base"; then + return 0 + fi + return 1 + ;; + *) + return 2 + ;; + esac +} + # --------------------------------------------------------------------------- # find_workspace_uuid # @@ -890,7 +1055,7 @@ PYEOF find_workspace_uuid() { local workspace="$1" agent="$2" session_name="${3:-}" local abs; abs="$(cd "$workspace" 2>/dev/null && pwd)" || abs="$workspace" - MAM_STATE_JSON="$(load_state_json)" WS_ABS="$abs" AGENT="$agent" TARGET_SESSION="$session_name" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF' + MAM_STATE_JSON="$(load_state_json)" WS_ABS="$abs" AGENT="$agent" TARGET_SESSION="$session_name" MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" env_python "$AGENT_SESSIONS_YAML" <<'PYEOF' import os, sys, json, glob, sqlite3 ws = os.environ['WS_ABS'] @@ -899,49 +1064,15 @@ home = os.environ['HOME_DIR'] claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects") target = os.environ.get('TARGET_SESSION', '') +exec(os.environ['MAM_VERIFY_PY']) + OWN_KEY = {'claude': 'claude_session_id_own', 'agy': 'agy_conversation_id_own', 'hermes': 'hermes_conversation_id_own', 'cline': 'cline_conversation_id_own'} - def iso_root_of(s): iso = s.get('isolation') return iso.get('root') if isinstance(iso, dict) else None - -def jsonl_exists(uuid, iso=None): - base = f"{iso}/projects" if iso else claude_project_dir - key = ws.replace('/', '-').replace('_', '-') - return os.path.exists(f"{base}/{key}/{uuid}.jsonl") - - -def db_exists(uuid, iso=None): - base = f"{iso}/.gemini/antigravity-cli/conversations" if iso else f"{home}/.gemini/antigravity-cli/conversations" - return os.path.exists(f"{base}/{uuid}.db") - - -def hermes_exists(uuid, iso=None): - hdb = f"{iso}/.hermes/state.db" if iso else 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, iso=None): - base = f"{iso}/sessions" if iso else f"{home}/.cline/data/sessions" - return os.path.exists(f"{base}/{uuid}/{uuid}.json") - - -def own_exists(a, uuid, iso=None): - return {'claude': jsonl_exists, 'agy': db_exists, - 'hermes': hermes_exists, 'cline': cline_exists}[a](uuid, iso) - - # Ingest the merged state dictionary from stdin pipeline try: d = json.loads(os.environ.get('MAM_STATE_JSON', '{}')) @@ -958,51 +1089,38 @@ for s_item in d.get('herdr_sessions', []): if val: running_ids.add(val) - def emit(u): if u in running_ids: return print(u) raise SystemExit(0) - # Fetch all sessions matching this workspace sessions = [] for s in d.get('herdr_sessions', []): if isinstance(s, dict) and (s.get('pane') or {}).get('cwd') == ws: sessions.append(s) - -# T5: target-session mode — resolve strictly within the named row's scope. -# An isolated row's conversation lives ONLY in its isolation root (which holds -# at most one conversation), so we never fall through to the global tiers — -# those would guess another role's conversation (the C3 mtime bug). if target: for s in sessions: if s.get('name') != target: continue iso = iso_root_of(s) cand = s.get(OWN_KEY.get(agent, ''), None) - if cand and own_exists(agent, cand, iso): + if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"): emit(cand) if iso: - key = ws.replace('/', '-').replace('_', '-') + key = workspace_key(ws) if agent == 'claude': for j in sorted(glob.glob(f"{iso}/projects/{key}/*.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: + cand = os.path.basename(j)[:-6] + if cand and verify_session_uuid(ws, agent, cand, s): emit(cand) elif agent == 'agy': for j in sorted(glob.glob(f"{iso}/.gemini/antigravity-cli/conversations/*.db"), key=os.path.getmtime, reverse=True): - emit(os.path.basename(j)[:-3]) + cand = os.path.basename(j)[:-3] + if cand and verify_session_uuid(ws, agent, cand, s): + emit(cand) elif agent == 'hermes': hdb = f"{iso}/.hermes/state.db" if os.path.exists(hdb): @@ -1011,55 +1129,46 @@ if target: r = conn.execute("SELECT id FROM sessions WHERE cwd=? ORDER BY started_at DESC LIMIT 1", (ws,)).fetchone() conn.close() if r: - emit(r[0]) + cand = r[0] + if verify_session_uuid(ws, agent, cand, s): + emit(cand) except Exception: pass elif agent == 'cline': for folder in sorted(glob.glob(f"{iso}/sessions/*"), key=os.path.getmtime, reverse=True): fn = os.path.basename(folder) if os.path.exists(f"{folder}/{fn}.json"): - emit(fn) - # isolated row: nothing found inside its exclusive root -> no guess + if verify_session_uuid(ws, agent, fn, s): + emit(fn) print('') raise SystemExit(0) - # target row absent or legacy (non-isolated): fall through to legacy tiers for s in sessions: name = s.get('name', '') - iso = iso_root_of(s) if agent == 'claude' and name.endswith('-creator-claude'): cand = s.get('claude_session_id_own') - if cand and jsonl_exists(cand, iso): + if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"): emit(cand) if agent == 'agy' and name.endswith('-creator-agy'): cand = s.get('agy_conversation_id_own') - if cand and db_exists(cand, iso): + if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"): emit(cand) if agent == 'hermes' and name.endswith('-creator-hermes'): cand = s.get('hermes_conversation_id_own') - if cand and hermes_exists(cand, iso): + if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"): emit(cand) if agent == 'cline' and name.endswith('-creator-cline'): cand = s.get('cline_conversation_id_own') - if cand and cline_exists(cand, iso): + if cand and verify_session_uuid(ws, agent, cand, s, mode="revalidate"): emit(cand) -# 2) disk scan scoped to THIS workspace if agent == 'claude': - key = ws.replace('/', '-').replace('_', '-') + key = workspace_key(ws) 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): + cand = os.path.basename(j)[:-6] + if cand and verify_session_uuid(ws, agent, cand): emit(cand) elif agent == 'agy': lc = f"{home}/.gemini/antigravity-cli/cache/last_conversations.json" @@ -1069,7 +1178,7 @@ elif agent == 'agy': cand = json.load(open(lc)).get(ws) except Exception: cand = None - if cand and db_exists(cand): + if cand and verify_session_uuid(ws, agent, cand): emit(cand) elif agent == 'hermes': hdb = f"{home}/.hermes/state.db" @@ -1083,7 +1192,7 @@ elif agent == 'hermes': cand = r[0] except Exception: cand = None - if cand: + if cand and verify_session_uuid(ws, agent, cand): emit(cand) elif agent == 'cline': sessions_dir = f"{home}/.cline/data/sessions" @@ -1097,17 +1206,10 @@ elif agent == 'cline': 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 + cand = os.path.basename(j)[:-5] + if cand and verify_session_uuid(ws, agent, cand): + emit(cand) -# 3) agent_identities cache, ONLY when its project_cwd == this workspace ai = {} try: if os.path.exists(db_path): @@ -1127,19 +1229,19 @@ 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): + if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"): emit(cand) elif agent == 'agy': cand = ai.get('conversation_id') - if cand and db_exists(cand): + if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"): emit(cand) elif agent == 'hermes': cand = ai_agent.get('session_id') or ai.get('conversation_id') - if cand and hermes_exists(cand): + if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"): emit(cand) elif agent == 'cline': cand = ai_agent.get('session_id') or ai.get('conversation_id') - if cand and cline_exists(cand): + if cand and verify_session_uuid(ws, agent, cand, mode="revalidate"): emit(cand) print('') diff --git a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh index 5711606..10d5381 100755 --- a/.agents/skills/multi-agent-mux-create/scripts/create_session.sh +++ b/.agents/skills/multi-agent-mux-create/scripts/create_session.sh @@ -35,6 +35,7 @@ Options: --herdr-server NAME specify isolated herdr server name --submit-job PROMPT submit a job to multi-agent-mux-delegate-job registry with the given prompt --onboard automatically submit a project alignment/orientation job to the new agent + --no-onboard disable automatic onboarding job submission --no-isolate disable state isolation (shares global configuration/history) [default: isolated mode is always active] -h, --help this help @@ -49,7 +50,7 @@ USE_WRAPPER=0 DRY_RUN=0 HERDR_SERVER_OPT="" SUBMIT_JOB_PROMPT="" -ONBOARD=0 +ONBOARD=1 ISOLATE=1 while [ $# -gt 0 ]; do @@ -63,6 +64,7 @@ while [ $# -gt 0 ]; do --herdr-server) HERDR_SERVER_OPT="$2"; shift 2 ;; --submit-job) SUBMIT_JOB_PROMPT="$2"; shift 2 ;; --onboard) ONBOARD=1; shift ;; + --no-onboard) ONBOARD=0; shift ;; --isolate) ISOLATE=1; shift ;; # legacy compatibility --no-isolate) ISOLATE=0; shift ;; -h|--help) usage; exit 0 ;; @@ -239,7 +241,7 @@ START_CMD="HERDR_SERVER_NAME=${HERDR_SERVER_NAME:-default} herdr new-session -d # If --onboard is specified, automatically build the onboarding prompt if [ "$ONBOARD" = "1" ] && [ -z "$SUBMIT_JOB_PROMPT" ]; then - SUBMIT_JOB_PROMPT="You are a newly spawned $ROLE Team Leader agent in this workspace. To align yourself with the project context, perform the following tasks: + SUBMIT_JOB_PROMPT="You are a newly spawned $ROLE Team Leader agent in this workspace. Without making any non-standard pre-preparations or modifying files directly, proceed immediately to align yourself with the project context by performing the following tasks: 1. Read the project documentation at README.md and the multi-agent protocol guidelines at .agents/MULTI_AGENT_RULES.md to understand the design rules. 2. Run 'git status' and 'git diff' to analyze the current modifications and active work in the repository. 3. Read .mam/agent-sessions.yaml to see other running agent sessions, their roles, and confirm your own assigned role: $ROLE. @@ -348,7 +350,7 @@ if agent == 'claude': 'version': '(unknown — read from TUI)', } entry['claude_session_id_own'] = None - entry['last_visible_status'] = "TUI started; awaiting first user message" + entry['last_visible_status'] = "unverified" elif agent == 'agy': cp = os.environ.get('CHILD_PID', '0') entry['child_pid'] = int(cp) if cp.isdigit() else 0 @@ -360,17 +362,17 @@ elif agent == 'agy': 'endpoint': 'https://stitch.googleapis.com/mcp' } ] - entry['last_visible_status'] = "TUI started; awaiting first user message" + entry['last_visible_status'] = "unverified" 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" + entry['last_visible_status'] = "unverified" 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" + entry['last_visible_status'] = "unverified" sessions.append(entry) @@ -407,6 +409,8 @@ Task: $SUBMIT_JOB_PROMPT" fi delegate_publish_event "$DELEGATE_JOB_ID" started "multi-agent-mux session created and instructions injected" + # Trigger immediate priority reconcile cycle asynchronously + (bash "$WORKSPACE/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh" --once >/dev/null 2>&1 &) || true WD_PID=$(start_watchdog "$DELEGATE_JOB_ID" "$WORKSPACE") echo "watchdog PID: $WD_PID" fi diff --git a/.agents/skills/multi-agent-mux-monitor/SKILL.md b/.agents/skills/multi-agent-mux-monitor/SKILL.md index f9d5aea..59992d7 100644 --- a/.agents/skills/multi-agent-mux-monitor/SKILL.md +++ b/.agents/skills/multi-agent-mux-monitor/SKILL.md @@ -112,7 +112,8 @@ Flags: `--once` (single pass), `--emit-diff` (print JSON), `--dry-run` (P1-E — ## 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`. +The `status` field MUST be one of the following exact strings: `running`, `stopped`, `terminated`, `archived`. +The `last_visible_status` is a free-form human-readable status string (e.g. verification-cycle states: `unverified`, `pinned`, `resume_verified`, or a failure detail string) and is NOT constrained to this enum. Any unstructured comments or reasons for the status change should be placed in `last_visible_note` or `termination_mode`. ### A. herdr dead, YAML says running → auto-terminate diff --git a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh index 76be78c..3fa1325 100755 --- a/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh +++ b/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh @@ -8,6 +8,7 @@ # # --dry-run: 부수효과 없는 read-only. "지금 뭐 돌고 있지?" 질문에 안전. # multi-agent-mux-status 스킬이 이걸 재사용. +# 어떤 분기로도 디스크/DB 쓰기가 발생하지 않음을 보장함. # # 출력 (JSON): {timestamp, yaml_path, herdr_sessions_alive, herdr_confirmed, drifts, actions} # @@ -39,7 +40,15 @@ while [ $# -gt 0 ]; do --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 ;; + -h|--help) + cat <&2; exit 2 ;; esac done @@ -306,6 +315,8 @@ import os, json, glob, subprocess, time, sqlite3 from datetime import datetime, timezone import yaml +exec(os.environ['MAM_VERIFY_PY']) + yaml_path = os.environ['YAML_PATH'] home = os.environ['HOME_DIR'] claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects") @@ -381,6 +392,31 @@ def pane_meta(session, srv): return None +def _pin_and_verify_resume(s, agent, cwd, uuid, degraded=False): + own_key = { + 'claude': 'claude_session_id_own', + 'agy': 'agy_conversation_id_own', + 'hermes': 'hermes_conversation_id_own', + 'cline': 'cline_conversation_id_own' + }[agent] + s[own_key] = uuid + s['last_visible_status'] = 'pinned' + resume_cmd = ['bash', os.path.join(skills_dir, 'multi-agent-mux-resume', 'scripts', 'resume_session.sh'), + '--workspace', cwd, '--agent', agent, '--session', s['name'], '--dry-run'] + res = subprocess.run(resume_cmd, capture_output=True, text=True) + if res.returncode == 0: + s['last_visible_status'] = 'resume_verified' + else: + s['last_visible_status'] = f"resume dry-run failed: {res.stderr.strip() or res.stdout.strip()}" + + id_name = 'session' if agent in ('claude', 'cline') else 'conversation' + if not degraded: + drifts.append({'class': 'C', 'name': s['name'], 'msg': f"{s['name']}: {id_name} id materialized: {uuid}"}) + else: + drifts.append({'class': 'C-degraded', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport check unavailable, pinned via stage 1-3 only"}) + actions.append(f"updated {id_name} id: {uuid}") + + yaml_sessions = d.get('herdr_sessions', []) yaml_session_names = {s['name'] for s in yaml_sessions if s.get('name')} alive_set = {(t['name'], t.get('server', 'default')) for t in herdr_sessions} @@ -492,29 +528,29 @@ for s in d.get('herdr_sessions', []): cwd = (s.get('pane') or {}).get('cwd', '') if not cwd: continue - proj_key = cwd.replace('/', '-').replace('_', '-') - proj_dir = f"{claude_project_dir}/{proj_key}" + key = workspace_key(cwd) + iso = s.get('isolation', {}).get('root') if isinstance(s.get('isolation'), dict) else None + proj_dir = f"{iso}/projects/{key}" if iso else f"{claude_project_dir}/{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}") + + valid_candidates = [] + for latest in jsonls: + uuid = os.path.basename(latest)[:-6] + if verify_session_uuid(cwd, 'claude', uuid, s, mode="discover"): + valid_candidates.append(uuid) + + if len(valid_candidates) == 1: + uuid = valid_candidates[0] + cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "claude" "{cwd}"'] + rc = subprocess.run(cmd).returncode + if rc == 0: + _pin_and_verify_resume(s, 'claude', cwd, uuid, degraded=False) + elif rc == 1: + drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"}) + else: + _pin_and_verify_resume(s, 'claude', cwd, uuid, degraded=True) # === drift C (agy): agy 새 session id materialize (per-row own id) === for s in d.get('herdr_sessions', []): @@ -527,18 +563,28 @@ for s in d.get('herdr_sessions', []): 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 + iso = s.get('isolation', {}).get('root') if isinstance(s.get('isolation'), dict) else None + conv_dir = f"{iso}/.gemini/antigravity-cli/conversations" if iso else f"{home}/.gemini/antigravity-cli/conversations" + if not os.path.isdir(conv_dir): + continue + dbs = sorted(glob.glob(f"{conv_dir}/*.db"), key=os.path.getmtime, reverse=True) + + valid_candidates = [] + for db in dbs: + uuid = os.path.basename(db)[:-3] + if verify_session_uuid(cwd, 'agy', uuid, s, mode="discover"): + valid_candidates.append(uuid) + + if len(valid_candidates) == 1: + uuid = valid_candidates[0] + cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "agy" "{cwd}"'] + rc = subprocess.run(cmd).returncode + if rc == 0: + _pin_and_verify_resume(s, 'agy', cwd, uuid, degraded=False) + elif rc == 1: + drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"}) + else: + _pin_and_verify_resume(s, 'agy', cwd, uuid, degraded=True) # === drift C (hermes): hermes 새 session id materialize (per-row own id) === for s in d.get('herdr_sessions', []): @@ -551,19 +597,33 @@ for s in d.get('herdr_sessions', []): 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 + iso = s.get('isolation', {}).get('root') if isinstance(s.get('isolation'), dict) else None + hdb = f"{iso}/.hermes/state.db" if iso else f"{home}/.hermes/state.db" + if not os.path.exists(hdb): + continue + + valid_candidates = [] + 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: + uuid = r[0] + if verify_session_uuid(cwd, 'hermes', uuid, s, mode="discover"): + valid_candidates.append(uuid) + except Exception: + pass + + if len(valid_candidates) == 1: + uuid = valid_candidates[0] + cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "hermes" "{cwd}"'] + rc = subprocess.run(cmd).returncode + if rc == 0: + _pin_and_verify_resume(s, 'hermes', cwd, uuid, degraded=False) + elif rc == 1: + drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"}) + else: + _pin_and_verify_resume(s, 'hermes', cwd, uuid, degraded=True) # === drift C (cline): cline 새 session id materialize (per-row own id) === for s in d.get('herdr_sessions', []): @@ -576,29 +636,36 @@ for s in d.get('herdr_sessions', []): 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 + iso = s.get('isolation', {}).get('root') if isinstance(s.get('isolation'), dict) else None + sessions_dir = f"{iso}/sessions" if iso else f"{home}/.cline/data/sessions" + if not os.path.isdir(sessions_dir): + continue + 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) + + valid_candidates = [] + for j in candidates: + uuid = os.path.basename(j)[:-5] + if verify_session_uuid(cwd, 'cline', uuid, s, mode="discover"): + valid_candidates.append(uuid) + break + + if len(valid_candidates) == 1: + uuid = valid_candidates[0] + cmd = ['bash', '-c', f'source "{lib_sh}" && verify_tui_viewport "{s["name"]}" "cline" "{cwd}"'] + rc = subprocess.run(cmd).returncode + if rc == 0: + _pin_and_verify_resume(s, 'cline', cwd, uuid, degraded=False) + elif rc == 1: + drifts.append({'class': 'C-warn', 'name': s['name'], 'msg': f"{s['name']}: TUI viewport mismatch for candidate {uuid} — not pinned, will retry next cycle"}) + else: + _pin_and_verify_resume(s, 'cline', cwd, uuid, degraded=True) # === drift D: stale UUID (cache 의 artifact 가 사라짐) — 보고만, 변경 없음 === ai = d.get('agent_identities', {}) or {} @@ -653,7 +720,7 @@ if not actions: PYEOF if [ "$DRY_RUN" = "1" ]; then - printf '%s' "$RECON_SRC" | LIB_SH="$LIB_SH" env_python "$AGENT_SESSIONS_YAML" + printf '%s' "$RECON_SRC" | LIB_SH="$LIB_SH" MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" env_python "$AGENT_SESSIONS_YAML" else - printf '%s' "$RECON_SRC" | LIB_SH="$LIB_SH" atomic_dump_yaml "$AGENT_SESSIONS_YAML" + printf '%s' "$RECON_SRC" | LIB_SH="$LIB_SH" MAM_VERIFY_PY="$VERIFY_SESSION_PYTHON" atomic_dump_yaml "$AGENT_SESSIONS_YAML" fi diff --git a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh index 76716ab..e8ce867 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh @@ -6,7 +6,11 @@ source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh" usage() { cat < --agent --session +Usage: $0 --workspace --agent --session [--dry-run] + +Options: + --dry-run Simulates resume flow (resolves binary, environment, isolation) without writing + any updates to YAML or DB. Safe to execute inside active write transactions. EOF } @@ -14,11 +18,14 @@ WORKSPACE="" AGENT="" SESSION_NAME="" +DRY_RUN=0 + while [ $# -gt 0 ]; do case "$1" in --workspace) WORKSPACE="$2"; shift 2 ;; --agent) AGENT="$2"; shift 2 ;; --session) SESSION_NAME="$2"; shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; -h|--help) usage; exit 0 ;; *) echo "ERROR: unknown arg: $1" >&2; exit 2 ;; esac @@ -42,6 +49,10 @@ export HERDR_SERVER_NAME # 2. If herdr is alive, print warning or attach. if herdr has-session -t "$SESSION_NAME" 2>/dev/null; then + if [ "${DRY_RUN:-0}" = "1" ]; then + echo "[dry-run] herdr '$SESSION_NAME' already running — nothing to validate" + exit 0 + fi echo "herdr '$SESSION_NAME' already running." # Just update YAML to make sure it's set to running bash "$(dirname "${BASH_SOURCE[0]}")/update_yaml_resumed.sh" \ @@ -111,6 +122,7 @@ case "$AGENT" in agy) CMD_FULL="${RESOLVED_BIN} --dangerously-skip-permissions --conversation $UUID" ;; hermes) CMD_FULL="${RESOLVED_BIN} --resume $UUID" ;; cline) CMD_FULL="${RESOLVED_BIN} -i --id $UUID" ;; + *) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;; esac # Prepend env prefix and append command args (T4) @@ -121,6 +133,30 @@ if [ -n "$ISO_ARGS" ]; then CMD_FULL="$CMD_FULL $ISO_ARGS" fi +# Validate isolation root if it was configured +if [ -n "$ISO_ROOT" ] && [ ! -d "$ISO_ROOT" ]; then + echo "ERROR: Isolation root directory does not exist: $ISO_ROOT" >&2 + exit 1 +fi + +# Validate binary exists and is executable +if [ -f "$RESOLVED_BIN" ] || [[ "$RESOLVED_BIN" == /* ]] || [[ "$RESOLVED_BIN" == ~/* ]]; then + if [ ! -x "$RESOLVED_BIN" ]; then + echo "ERROR: Agent binary is not executable: $RESOLVED_BIN" >&2 + exit 1 + fi +else + if ! command -v "$RESOLVED_BIN" >/dev/null 2>&1; then + echo "ERROR: Agent binary not found in PATH: $RESOLVED_BIN" >&2 + exit 1 + fi +fi + +if [ "${DRY_RUN:-0}" = "1" ]; then + echo "[dry-run] would spawn: $CMD_FULL" + exit 0 +fi + # 4. Spawn new agent session (delegates to herdr translation shim) _herdr new-session -d -s "$SESSION_NAME" -c "$WORKSPACE" "$CMD_FULL" if [ "$AGENT" = "claude" ]; then diff --git a/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh b/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh index 0712697..e6382eb 100755 --- a/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh +++ b/.agents/skills/multi-agent-mux-resume/scripts/update_yaml_resumed.sh @@ -50,7 +50,7 @@ fi NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ') # 새 herdr pane pid / 자식 pid 를 bash 에서 캡처 (env 로 전달, P1-B) -PANE_PID=$(herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true) +PANE_PID=$(_herdr list-panes -t "$SESSION_NAME" -F '#{pane_pid}' 2>/dev/null | head -1 || true) PANE_PID="${PANE_PID:-}" CHILD_PID=0 if { [ "$AGENT" = "agy" ] || [ "$AGENT" = "hermes" ] || [ "$AGENT" = "cline" ]; } && [ -n "$PANE_PID" ]; then