merge: fix/issue-23-24 (Issues #23, #24: cross-server herdr scope, agent-agnostic start-up dialogs, started-based onboarding verdict, verified rollback; v4.7.2)
Reviews: authoring workspace 5 reviewers x 5 rounds PASS; merge workspace round 1 @f4ba205 claude/agy/grok PASS (codex report lost to host sleep; its two probes fixed in05b0ea4), round 2 @05b0ea4 all four PASS (identity: basef4ba205-> head05b0ea4, evidence review-tree). Full suite @f4ba205: 1111 passed, 1 skipped; affected suites @05b0ea4: 123 passed. Reports under .agents/reports/. Closes #23, #24. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
This commit was merged in pull request #35.
This commit is contained in:
commit
1debc7d924
46 files changed
+2992
-141
No files matched your search
@@ -0,0 +1,176 @@
|
||||
# 🔍 Review Report — PR #35 병합 전 독립 리뷰 (job `adc0ff3c`)
|
||||
|
||||
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-23
|
||||
- **대상**: PR #35 `fix/issue-23-24` **@ `f4ba205`**, base `main 1e78dae` (v4.7.1). 13커밋, 22파일 `+2197/−118`, `git diff --check` 클린.
|
||||
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260923T142448Z-609954/1`. **identity 확인**: `HEAD = f4ba205c…fd2f`, `HEAD^{tree} = 88cb4f34…f181` — 브리프의 `head`·`tree` 와 일치.
|
||||
- **안전 규칙 준수**: 검증 경로·소스 워크트리(`multi-agent-mux-wt/pr-35`)·본 체크아웃(`main 1e78dae`) 모두 수정·커밋·checkout 없음. 실험은 scratchpad 임시 디렉터리(uuid4 이름)에서만, pytest 는 `AGENT_PYTHON_BIN=<제어 루트 .venv>` + `--basetemp=<검증 경로 밖>` 으로 실행. 실제 herdr 서버·에이전트 세션·`.mam`·데몬 조작 없음(이 pane 의 herdr 환경변수를 **읽기만** 했다), push·merge·PR 상태 변경 없음, purge·scrub 실행 없음.
|
||||
|
||||
## 결론 요약
|
||||
|
||||
**병합을 막을 결함은 없다.** 세 핵심 규칙 — 교차 서버 판정, "이번 dispatch 의 started 만 증거", kill 결과 확인 롤백 — 을 각각 독립 실험으로 재현·확인했고, 새 테스트는 실제 `registry.py`/`publish_event.py` 를 쓰며 홈·브로커·실제 herdr 에 닿지 않는다. 비차단 지적 6건(F1–F6)을 남긴다. 그중 F1 은 **브리프의 codex 수락 키 서술과 코드가 다르다**는 점이라 오케스트레이터가 확인할 필요가 있다.
|
||||
|
||||
## 1. #24 시작 다이얼로그 (항목 1)
|
||||
|
||||
### 1-1. 어댑터 시그니처·수락 키
|
||||
|
||||
`python -m lib_py.agents facts <agent>` 가 실제로 내놓는 값(검증 경로에서 실행):
|
||||
|
||||
| 에이전트 | `MAM_STARTUP_DIALOGS` | 강한 준비 토큰 |
|
||||
|---|---|---|
|
||||
| claude | 기존 사다리 5개(trust Enter / `Yes, try it` Escape / `Yes, proceed` Down+Enter / `Resuming…` Enter / `Press Enter…` Enter)가 그대로 옮겨짐 | `Anthropic\|Assistant\|…\|Haiku` |
|
||||
| agy | `Do you trust the contents of this project\|Yes, I trust this folder` → `Enter`, trust | `Antigravity` |
|
||||
| grok | `Do you trust the contents of this directory` → `y`, trust | `Grok\|xAI\|Assistant\|❯\|>>>` |
|
||||
| codex | `Do you trust…\|1\. Yes, continue` → **`Enter`**, trust · `1\. Update now\|Skip until next version` → **`2`**, notice | `Codex\|Ask Codex to do anything` |
|
||||
| opencode·hermes | `[]` (다이얼로그 없음) | 있음 → 핸들러가 배너에서 즉시 반환 |
|
||||
|
||||
**F1 (확인 요망, 비차단)**: 브리프는 codex 수락 키를 "`1`+Enter, 업데이트 안내 `3`+Enter" 로 적었으나 코드는 **Enter 단독**(1번 항목이 미리 선택돼 있다는 주석)과 **`2`**("2 = Skip" 이라는 관찰 기록, 2026-09-22) 다. 어느 쪽이 실제 화면인지는 이 리뷰에서 재현할 수 없다(실제 codex 를 띄우는 것은 금지). PR 본문은 codex 를 포함한 4개 에이전트의 실제 herdr 시험이 사람 개입 없이 `started`(토큰 포함)→`completed` 까지 갔다고 기록하므로 **코드 쪽이 실측**으로 보이지만, 브리프 작성자의 관찰과 다르므로 병합 전에 한 번 맞춰 볼 것을 권한다. `_MAM_READY_TOKENS_CLAUDE` 상수는 제거되고 어댑터의 `strong_ready_tokens` 로 대체됐다(값 동일).
|
||||
|
||||
### 1-2. 준비 토큰으로 수락을 판정하는 이유
|
||||
|
||||
`handle_startup_dialogs`(`lib.sh:2979-`)는 시그니처가 화면에 남아 있어도 같은 인덱스에 대해 `MAM_DIALOG_RESEND_S`(기본 5 s) 안에는 재전송하지 않고(`_mam_dialog_pick` 의 cooldown), 반환 조건은 **오직 준비 토큰**이다. `--source visible` 스냅샷이 수락된 다이얼로그를 한동안 계속 보여 준다는 #24 의 관찰과 맞물려 타당하다 — 옛 코드는 매 폴링마다 Enter 를 다시 보내 이미 넘어간 TUI 에 입력을 흘렸다. `test_c1_tui_readiness.py::test_c1b` 의 단언이 `==30` 에서 `1 ≤ n ≤ 30` 으로 바뀐 것도 같은 이유이며, "한 프레임에 두 번 보내지 않는다"는 원래 가드는 그대로다.
|
||||
|
||||
### 1-3. `--no-auto-trust` / `MAM_AUTO_TRUST=0`
|
||||
|
||||
`_mam_dialog_pick` 은 `kind == "trust"` 이고 auto_trust 가 꺼져 있으면 `skip|idx|` 를 내고, 핸들러는 경고만 남긴다(cooldown 마다 1회). `notice` 는 항상 답한다. create(`create_session.sh:86-94`)와 resume(`resume_session.sh:43-57`)이 **같은 스위치·같은 env 변수(`MAM_AUTO_TRUST`)·같은 호출(`handle_startup_dialogs "$SESSION" N "$AGENT"`)** 을 쓴다. S6/R3 가 고정.
|
||||
|
||||
### 1-4. 자동 수락의 적용 범위(보안)
|
||||
|
||||
pane 은 `new-session -c "$WORKSPACE"` 로 뜨고 신뢰 창은 그 cwd 에 대한 것이므로 실질적으로 `--workspace` 에만 적용된다. 다만 시그니처 정규식은 **디렉터리 이름을 검사하지 않는다** — 화면에 어떤 경로가 적혀 있든 매치되면 수락한다(F5). 시작 직후의 신뢰 창이 다른 디렉터리를 가리킬 경로는 없어 실무 위험은 낮다.
|
||||
|
||||
## 2. #23 교차 서버 스코프 (항목 2)
|
||||
|
||||
`_herdr_caller_server` / `_herdr_call_is_cross_server`(`lib.sh:1403-1447`)를 lib.sh 에서 함수만 추출해 `env -i` 로 9가지 조합을 돌렸다(전부 기대와 일치):
|
||||
|
||||
| `HERDR_SOCKET_PATH` | `HERDR_SESSION` | 대상 | 판정 |
|
||||
|---|---|---|---|
|
||||
| `~/.config/herdr/herdr.sock` | – | `mam` | caller=`default`, **CROSS** (보고된 #23 시나리오) |
|
||||
| 〃 | – | (없음) / `default` | same (앰비언트 sentinel) |
|
||||
| `~/.config/herdr/sessions/mam/herdr.sock` | – | `mam` | caller=`mam`, same → **pane 배치·workspace 패킹 유지** |
|
||||
| 〃 | – | `other` | CROSS |
|
||||
| 〃 | `orch` | `mam` | caller=`orch`(HERDR_SESSION 우선), CROSS |
|
||||
| `/other/layout/herdr.sock` / 비어 있음 | – | `mam` | caller="" → same (pane 밖: 명시 스코프 존중) |
|
||||
| `…/sessions/a b/herdr.sock` | – | `mam` | caller=`a b`, CROSS (공백 안전) |
|
||||
|
||||
- 제거는 `mam_herdr` 안에서 **교차일 때만** `env -u HERDR_WORKSPACE_ID -u HERDR_PANE_ID -u HERDR_TAB_ID` 로 이루어지고(`lib.sh:1449-1461`), argv 배열 + `if` 로 판정하므로 `set -e` 호출자를 중단시키지 않는다(S12).
|
||||
- `lib_py/loop/watcher.py::caller_server/scoped_env` 는 같은 규칙의 파이썬 쌍둥이다. 같은 5가지 입력으로 돌려 bash 와 **동일 결과**를 확인했다(같은 서버·pane 밖·`default`·`None` 대상은 ids 유지, 교차만 제거).
|
||||
- **실제 레이아웃 근거**: 이 pane(herdr **0.9.1**)의 환경은 `HERDR_SOCKET_PATH=~/.config/herdr/sessions/multi-agent-mux/herdr.sock`, `HERDR_SESSION=multi-agent-mux` 였다 — 규칙이 전제하는 `sessions/<name>/herdr.sock` 형태와 일치하고, `HERDR_SESSION` 도 export 된다. 0.8.2 의 "기본 서버 pane 에는 `HERDR_SESSION` 이 없다"는 전제는 PR 의 실측 기록(코드 주석)에 의존하며 여기서 재현할 수 없다. 두 경우 모두 소켓 경로 규칙이 뒷받침하므로 어느 버전이든 판정이 성립한다.
|
||||
- 인식되지 않는 레이아웃은 ""(= pane 밖) 으로 떨어져 **오늘의 동작을 유지**한다. 잘못된 제거보다 안전한 쪽이다.
|
||||
|
||||
## 3. #23 온보딩 전달 판정 (항목 3)
|
||||
|
||||
### 3-1. 공유
|
||||
|
||||
`delegate_publish_cmd`·`delegate_job_status`·`delegate_started_recorded`·`delegate_wait_started` 가 `lib.sh:2189-2313` 에 하나씩 있고, create 온보딩(`create_session.sh:622-680`)과 래퍼 `_wait_started`(`multi-agent-mux-delegate-job:647-668`)가 **같은 `delegate_started_recorded`** 를 부른다. 래퍼는 `MAM_JOBS_DIR="$REGISTRY_DIR"` 와 `LOGS_DIR_ABS` 를 넘겨 자기 레지스트리·감사 루트를 그대로 쓴다(`:191`,`:659`). 옛 `_disk_status != pending → 성공` 우회와 구독자 콘솔 grep 은 **삭제**됐다(diff 로 확인).
|
||||
|
||||
### 3-2. 증거 규칙 실측
|
||||
|
||||
`delegate_started_recorded` 만 추출해 가짜 `events.log`/`events.ndjson` 으로 10가지를 돌렸다(전부 기대와 일치):
|
||||
|
||||
| # | 상태 | 토큰 요구 | 결과 |
|
||||
|---|---|---|---|
|
||||
| 1 | 파일 없음 | 있음 | NOT |
|
||||
| 2 | 이전 dispatch 토큰의 `started` | 있음 | **NOT** (R1: 이전 시도의 증거 거부) |
|
||||
| 3 | 〃 | 없음 | recorded (토큰 없으면 종전대로) |
|
||||
| 4 | 이번 토큰의 `started` | 있음 | recorded |
|
||||
| 5 | 감사 기록 `dispatch_verified:true`, **fingerprint 없음** | 있음 | **NOT** (신원 없는 과거 기록 fail closed) |
|
||||
| 6 | 〃 | 없음 | recorded |
|
||||
| 7 | 이전 시도의 fingerprint | 있음 | NOT |
|
||||
| 8 | 이번 토큰의 fingerprint | 있음 | recorded |
|
||||
| 9 | fingerprint 맞지만 `dispatch_verified:false` | 있음 | NOT |
|
||||
| 10 | 깨진 줄 + `progress` 만 | 있음 | NOT (JSON 파싱, 패턴 매칭 아님) |
|
||||
|
||||
fingerprint 는 `sha256(token)` 으로 구독자(`job_subscriber.py:303-306`)와 **같은 해시**임을 확인했다. 레지스트리 `status` 는 어느 경로에서도 증거로 쓰이지 않는다.
|
||||
|
||||
### 3-3. 종료된 잡·#18 정합
|
||||
|
||||
- create 는 더 이상 에이전트 대신 `started` 를 발행하지 않는다(`:649-657` 주석과 코드; S7). 미확인 시 `delegate_job_status` 가 `completed|error|cancelled` 면 **덮어쓰지 않고** 경고만 한다(`:674-679`; S7m).
|
||||
- 대기 시간은 `MAM_ONBOARD_ACK_S` → `MAM_INJECT_ACK_GRACE_S` → 잡의 `ack_timeout_sec`(기본 180) 순이다 — #18 의 T=180 과 같은 값이고, heartbeat 계약은 `started --heartbeat 30` 명령을 `delegate_publish_cmd` 가 래퍼와 **인자 단위로 동일**하게 만든다(S7l).
|
||||
- `inject_instructions`(= `send_keys_safe`) 의 rc 4 는 세 자리 모두 "전달 미상"(`lib.sh:2746` busy 초과, `:2810` delivery unknown, `:2883` Enter 미수락)이라 `started` 대기로 판정을 넘기는 것이 맞고, rc 1(quiesce 실패)·2·3(다이얼로그 차단)은 즉시 실패로 남는다(S8/S8b).
|
||||
|
||||
## 4. #23 실패 처리 (항목 4)
|
||||
|
||||
- `cleanup_herdr_on_error`(`create_session.sh:318-346`): `kill-session` 의 rc 를 버리지 않고 받은 뒤 `has-session` 으로 재확인, 살아 있으면 행을 `running` + `last_visible_status: rollback-failed` + `delegate_job_id: null` 로 표시하고 **exit 43**. bash 3.2 에서 EXIT trap 안의 `exit 43` 이 원래 종료 코드(1)를 덮어씀을 직접 확인했다(`rc=43`). 상태값은 `atomic_yaml.py:18` 의 어휘(`running/terminated/archived/stopped`) 안이고 `last_visible_status` 는 reconcile 이 이미 쓰는 필드다.
|
||||
- 온보딩 미확인: 세션 보존 + 잡 `error`(이미 종료된 잡은 제외) + 경고 2줄, `ROW_APPENDED` 뒤라 YAML 행은 유효한 `running` 이다. 실패한 잡은 `JOB_TERMINALIZED=1` 로 표시돼 트랩이 `cancelled` 를 겹쳐 발행하지 않는다.
|
||||
- create SKILL.md(`:101-131`)가 이 규칙(started 기반 판정, rc 4 경고, 세션 보존, `MAM_ONBOARD_ACK_S`, exit 42/43 과 행 상태)을 정확히 서술한다.
|
||||
|
||||
## 5. 기존 테스트 조정 6파일 (항목 5)
|
||||
|
||||
| 파일 | 변경 | 판정 |
|
||||
|---|---|---|
|
||||
| `conftest.py` | `mam_sandbox` 에 `MAM_ONBOARD_ACK_S=3` 추가 | 타당. 샌드박스에는 `started` 를 발행할 실제 에이전트가 없어 이것이 없으면 create 테스트마다 180 s 를 소모한다. create 전용 변수라 래퍼의 `MAM_INJECT_ACK_GRACE_S` 를 단언하는 테스트와 간섭하지 않는다 |
|
||||
| `test_b19_*` | `grep -q 'Yes, try it'` 가 lib.sh 에 있는지 → claude 어댑터의 `('Yes, try it', ['Escape'], 'notice')` 로 위치 이동 | 토큰 목록 두 단언은 그대로. 검증 대상이 이사한 것뿐 |
|
||||
| `test_c1_tui_readiness.py` | `KEYS_COUNT==30` → `1 ≤ n ≤ 30`, `custom` 에이전트 → `claude` | §1-2 참조. cooldown 도입으로 "매 반복 1회" 가 "프레임당 최대 1회" 로 바뀌었고 원래 가드(2회 금지)는 유지·강화. `custom` 은 어댑터가 없어 다이얼로그 목록이 비므로 이 시험에는 `claude` 가 맞다 |
|
||||
| `test_issue14_delivery_ack.py` | 레지스트리 상태만 `running` 으로 flip 하던 "ack" 를 **실제 `publish_event.py` + dispatch 토큰의 `started`** 로 교체 | **강화**. 옛 시험은 PR 이 없앤 "상태만 보고 성공" 우회를 통과 조건으로 쓰고 있었다. 새 시험은 로컬 `events.log` 에 토큰이 실린 줄까지 단언한다 |
|
||||
| `test_issue18_liveness_contract.py` | acceptance5 의 배달 단언을 `recorded 'started'` 하나로 좁힘 | 계약상 옳다(레지스트리 상태는 증거가 아님). 다만 "브로커가 retained `completed` 만 배달" 한 경우가 이제 **실패**한다 — `_skip_if_broker_did_not_deliver` 는 그 잡의 어떤 이벤트도 못 받았을 때만 skip 하므로. 공개 브로커에서 flake 가 늘 수 있는 자리(F4) |
|
||||
| `test_issue5_phase2a_loop.py` | G9 가드가 코드만 읽도록(`ast` 로 docstring **노드**만 제거, 주석은 unparse 에서 사라짐) + `g9b` 자체 검증 | 약화 아님. 호출 형태(`subprocess` argv 의 `lib.sh` 경로·`source`, 식별자)는 여전히 코드 텍스트에 있어 잡히고, `g9b` 가 "같은 줄의 코드"(R2) 네 가지까지 고정한다. `ast.unparse` 는 3.9+ — venv 3.9.6 이라 통과 |
|
||||
|
||||
## 7. 문서 (항목 7)
|
||||
|
||||
- create SKILL.md `:101-131`: started 기반 판정·rc 4 경고·세션 보존·`MAM_ONBOARD_ACK_S`·`--no-auto-trust`(codex 의 로컬 설정 로드 위험 명시)·`MAM_DIALOG_RESEND_S`·exit 42/43 과 행 상태가 코드와 일치한다. resume SKILL.md 는 에이전트 분기 `case` 를 없애고 같은 호출로 바꿨다.
|
||||
- **F2 (비차단, 문서 누락)**: 감사 기록의 새 필드 `dispatch_verified`·`dispatch_fingerprint`·`attempt_id` 는 `MESSAGING.md`·delegate-job `SKILL.md` **어디에도 없다**(전수 grep 0건). 두 문서는 `events.ndjson` 을 단계 수준으로만 서술해 필드 목록 자체가 없으므로 모순은 없지만, "래퍼와 create 가 무엇을 증거로 읽는가"가 이번 PR 의 핵심이라 한 문단은 있어야 한다.
|
||||
- **F3 (비차단, 문구 정밀도)**: SKILL.md 는 `MAM_ONBOARD_ACK_S` 가 래퍼의 `MAM_INJECT_ACK_GRACE_S` 와 "별개" 라고만 적었지만 코드의 우선순위는 `MAM_ONBOARD_ACK_S` → `MAM_INJECT_ACK_GRACE_S` → 잡의 `ack_timeout_sec` 이다(`create_session.sh:658`). 즉 래퍼 변수도 create 의 대기에 **2순위로 영향**을 준다.
|
||||
- `.claude/skills`: 본 체크아웃의 것은 git 에 추적되지 않는 로컬 심링크(`../../.agents/skills/*`, 10개)라 자동 동기화된다. 검증 경로(워크트리)에는 존재하지 않는다 — 배포 사본 정합 문제 없음.
|
||||
|
||||
## 6. 테스트 (항목 6)
|
||||
|
||||
### 6-1. 검증 경로에서의 실행
|
||||
|
||||
```
|
||||
cd <검증 경로> && AGENT_PYTHON_BIN=<제어 루트 .venv/bin/python> PYTHONDONTWRITEBYTECODE=1 <같은 인터프리터> -m pytest \
|
||||
-p no:cacheprovider -q -rfEs --basetemp=<검증 경로 밖> \
|
||||
tests/test_issue23_24_startup_path.py tests/test_issue24_resume_dialogs.py tests/test_b19_headless_reconcile_fixes.py \
|
||||
tests/test_c1_tui_readiness.py tests/test_issue14_delivery_ack.py tests/test_issue18_liveness_contract.py \
|
||||
tests/test_issue5_phase2a_loop.py tests/test_herdr_shim_contract.py tests/test_loop_cli.py
|
||||
→ 273 passed in 700.58s (0:11:40) failed 0 · errors 0 · skipped 0
|
||||
```
|
||||
|
||||
새 파일 2개(73건)와 조정된 6파일, 그리고 셸 shim 계약·loop CLI 까지 모두 통과. 브로커 의존 시험(issue14·issue18)도 이번 실행에서는 flake 없이 통과했다.
|
||||
|
||||
### 6-2. 수정 없이는 실패하는가 — 확인함
|
||||
|
||||
검증 경로 사본에서 PR 이 바꾼 소스 12파일(lib.sh·어댑터 5·`__main__.py`·`job_subscriber.py`·`watcher.py`·create/resume 스크립트·래퍼)만 base `1e78dae` 판으로 되돌리고 새 테스트 2파일을 돌렸다:
|
||||
|
||||
```
|
||||
60 failed, 12 passed, 1 skipped in 275.50s
|
||||
```
|
||||
|
||||
실패 60건은 S1(교차 서버)·S4~S6(다이얼로그)·S7/W(started 증거)·S8(rc 4)·S9/S10(롤백)·R1~R4(resume) 전 영역에 걸친다. 통과한 12건은 "같은 서버는 스코프 유지"(S2/S2b)·"shim 은 바뀌지 않음"(S11)·"호출자가 준 토큰은 덮어쓰지 않음"(S5e) 같은 **불변 조건**들이라 수정과 무관한 것이 맞다. 즉 새 테스트는 이 변경을 정확히 겨냥한다.
|
||||
|
||||
### 6-3. 격리·픽스처·시간
|
||||
|
||||
| 항목 | 결과 |
|
||||
|---|---|
|
||||
| 실제 `.mam/jobs` / `.mam/delegate_job_logs` 전후 | **차이 0 / 차이 0** |
|
||||
| 실제 herdr 서버·홈 | `mam_sandbox` 가 `HOME`/`HOME_DIR` 를 `tmp_path` 로 바꾸고(`conftest.py:210-211`) herdr 는 mock. 새 파일에서 `hivemq`·실제 소켓 참조 0건. 이 pane 의 herdr 환경변수는 S1d 가 **읽기만** 한다 |
|
||||
| 실제 생산자 사용 | S7i~S7n·W 계열은 `REGISTRY_PY`/`PUBLISH_PY`(실제 `registry.py`·`publish_event.py`)로 잡을 등록하고 `started` 를 발행한다(`test_issue23_24_startup_path.py:620-660`). "손으로 만든 픽스처가 결함을 가렸다"는 PR 의 교훈이 테스트에 반영돼 있다 |
|
||||
| 검증 경로 | tracked 파일 변화 없음(HEAD `f4ba205` 유지). 내 실행이 무시되는 `.mam/` 을 만들었다(#32 리뷰 때와 같은 현상, 그대로 둠) |
|
||||
| 실행 시간 | 새 파일 2개는 대체로 빠르지만 **`test_issue24_resume_dialogs.py` 4건이 각 39~43 s** 다(resume 의 `handle_startup_dialogs … 20` 타임아웃과 mock 준비 대기가 겹친다). 스위트 전체 시간에 +2.7 분을 더한다(F6) |
|
||||
|
||||
## 8. 병합 차단 여부 (항목 8)
|
||||
|
||||
버전 락스텝 bump 부재(`lib.sh:32` `4.7.1`, 오케스트레이터가 4.7.2 로 처리 예정) 외에 **병합을 막을 결함은 없다**.
|
||||
|
||||
| # | 비차단 지적 | 근거 / 권고 |
|
||||
|---|---|---|
|
||||
| F1 | **codex 수락 키가 브리프 서술과 다르다.** 코드: 신뢰 창 `Enter`(1번 미리 선택), 업데이트 안내 **`2`**(Skip, 이번만). 브리프: `1`+Enter, `3`+Enter(Skip until next version). S4 의 codex 화면 픽스처(`Update available / 1. Update now / 2. Skip / 3. Skip until next version`)를 보면 3개 항목이 실제로 있고, 코드는 그중 **2** 를 고른다 — "아무것도 바꾸지 않는" 선택이라는 주석과 일치하나 다음 시작 때 다시 뜬다. 실제 시험은 PR 본문대로 통과했으므로 동작은 성립 | 병합 전 브리프 작성자와 한 번 맞출 것; `3` 이 의도라면 어댑터 한 줄 |
|
||||
| F2 | 감사 기록의 새 필드(`dispatch_verified`·`dispatch_fingerprint`·`attempt_id`)가 MESSAGING.md·delegate-job SKILL.md 에 없다 | 한 문단 추가 권고 |
|
||||
| F3 | `MAM_INJECT_ACK_GRACE_S` 가 create 의 대기에도 2순위로 영향을 준다는 점이 SKILL.md 에 없다(`create_session.sh:658`) | 문구 보강 |
|
||||
| F4 | acceptance5 가 "retained `completed` 만 배달" 된 경우를 이제 실패로 본다(계약상 옳음). 공개 브로커에서 flake 가 늘 수 있는 자리 | 관찰되면 skip 조건 재검토 |
|
||||
| F5 | 다이얼로그 시그니처는 화면의 디렉터리 경로를 검사하지 않는다 — pane 이 `-c $WORKSPACE` 로 뜨므로 실무 위험은 낮다 | 정보 |
|
||||
| F6 | `test_issue24_resume_dialogs.py` 4건이 각 ~40 s | 타임아웃을 env 로 줄일 수 있으면 개선 |
|
||||
|
||||
## 9. 교차검증 가이드
|
||||
|
||||
```bash
|
||||
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T142448Z-609954/1
|
||||
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
|
||||
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # f4ba205… / 88cb4f3…
|
||||
# 교차 서버 규칙 (함수만 추출, env -i)
|
||||
eval "$(sed -n '/^_herdr_caller_server() {/,/^}/p; /^_herdr_call_is_cross_server() {/,/^}/p' "$RP/.agents/skills/lib.sh")"
|
||||
env -i HERDR_SOCKET_PATH=/x/.config/herdr/herdr.sock HERDR_SESSION_NAME=mam bash -c "$(declare -f _herdr_caller_server _herdr_call_is_cross_server); _herdr_call_is_cross_server && echo CROSS"
|
||||
env -i HERDR_SOCKET_PATH=/x/.config/herdr/sessions/mam/herdr.sock HERDR_SESSION_NAME=mam bash -c "$(declare -f _herdr_caller_server _herdr_call_is_cross_server); _herdr_call_is_cross_server || echo same"
|
||||
# 증거 규칙: 이전 토큰의 started / fingerprint 없는 감사 기록은 토큰 요구 시 거부 (§3-2 의 10 케이스)
|
||||
# 수정 없이는 실패하는지 (사본에서만): 소스 12파일을 1e78dae 판으로 교체 후 새 테스트 2파일 → 60 failed / 12 passed
|
||||
```
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20260923T220355Z-76dd1d",
|
||||
"base_sha": "f4ba205c9f755d817e2206c7a9cb390a3e61fd2f",
|
||||
"head_sha": "05b0ea4762de3fad013e8986d45db21c4e6aa465",
|
||||
"tree_sha": "8f5e8b0402017d2cc809364e60400c4c25b922f3",
|
||||
"review_commit": "05b0ea4762de3fad013e8986d45db21c4e6aa465",
|
||||
"reviewer": "planner-reviewer-claude-01",
|
||||
"job_id": "df626811",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-09-23T23:35:19Z"
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
# 🔍 Review Report — PR #35 델타 리뷰 2회차 (job `df626811`)
|
||||
|
||||
- **Reviewer**: `planner-reviewer-claude-01` (role: planner,reviewer) · 2026-09-24
|
||||
- **대상 델타**: `f4ba205..05b0ea4` (1커밋 `05b0ea4`, 6파일 `+76/−5`, `git diff --check` 클린), base `main 1e78dae` (v4.7.1).
|
||||
- **검증 경로**: `/var/folders/.../T/mam-review/loop-20260923T220355Z-76dd1d/1`. **identity 확인**: `HEAD = 05b0ea47…a465`, `HEAD^{tree} = 8f5e8b04…22f3` — 브리프와 일치.
|
||||
- **1회차 내 판정**: PASS (job `adc0ff3c`). 이번에는 codex 탐침 2건의 정정이 실제로 닫혔는지와 부작용을 본다.
|
||||
- **안전 규칙 준수**: 검증 경로·소스 워크트리·본 체크아웃(`main 1e78dae`) 모두 수정·커밋·checkout 없음. 함수 추출 실험과 되돌린 사본 실행은 scratchpad 에서만, pytest 는 `AGENT_PYTHON_BIN` + `--basetemp=<검증 경로 밖>`. 실제 herdr·세션·`.mam`·데몬 조작 없음, push·merge·PR 상태 변경 없음.
|
||||
|
||||
## 결론 요약
|
||||
|
||||
**두 정정은 탐침을 닫고, 1회차 판정을 뒤집을 요소는 없다.** P2 의 `continue` 는 S5/S5b 의 쿨다운 계약을 그대로 유지하면서 같은 화면의 다음 다이얼로그를 보게 하고, P1 의 `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"` 는 "플래그 > 환경 > 기본 1" 의 우선순위를 문서대로 만든다. 비차단 관찰 2건(G1·G2).
|
||||
|
||||
## 1. P2 — `_mam_dialog_pick` 의 `continue` (항목 1)
|
||||
|
||||
`lib.sh:2954-2958`. 함수만 추출해 codex 어댑터의 다이얼로그 2개(신뢰 창 idx0 → `Enter`, 업데이트 안내 idx1 → `2`)와 **두 창이 함께 있는 화면**으로 7가지 상태를 돌렸다(cooldown 5 s):
|
||||
|
||||
| # | 상태 | 결과 | 의미 |
|
||||
|---|---|---|---|
|
||||
| 1 | 기록 없음, 자동 신뢰 on | `send\|0\|Enter` | 신뢰 창이 먼저(어댑터 순서) |
|
||||
| 2 | 신뢰 창을 1 s 전에 보냄(쿨다운 중), on | **`send\|1\|2`** | **P2 정정**: 쿨다운 중인 창을 건너뛰고 안내에 답한다. 옛 코드는 여기서 `exit 1` 로 아무것도 안 했다 |
|
||||
| 3 | 기록 없음, 자동 신뢰 off | `skip\|0\|` | 신뢰 창은 사람 몫, skip 기록 |
|
||||
| 4 | skip 1 s 뒤, off | **`send\|1\|2`** | **P2 정정**: skip 의 쿨다운이 안내를 가리지 않는다 |
|
||||
| 5 | 두 창 모두 쿨다운 중 | 없음(rc 1) | **S5 유지**: 쿨다운 안에서는 재전송 없음 |
|
||||
| 6 | 두 창 모두 쿨다운 만료(+5 s) | `send\|0\|Enter` | **S5b 유지**: 만료 후 1회 재시도, 다시 신뢰 창부터 |
|
||||
| 7 | off, 만료 | `skip\|0\|` | 경고가 쿨다운마다 1회 |
|
||||
|
||||
**키 순서·횟수**: 핸들러는 한 폴링에 하나만 고르므로(`_mam_dialog_pick` 은 첫 actionable 항목에서 종료), 두 창이 한 화면에 있으면 **1회차 폴링에 `Enter`, 다음 폴링(1 s 뒤)에 `2`** 순으로 각 1회 간다. S5f 가 `["Enter", "2"]` 를, S6c 가 `["2"]` + 경고를 그대로 고정한다. 쿨다운은 **인덱스별**(`state[idx]`)이라 서로 간섭하지 않는다.
|
||||
|
||||
## 2. P1 — `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"` (항목 2)
|
||||
|
||||
`create_session.sh:72,87-88,96` 과 `resume_session.sh:33,42-43,57` 이 같은 3단 구조(초기값 ← 환경, 플래그 arm 이 덮어씀, 마지막에 `export`)다. 세 줄을 그대로 옮긴 하네스로 8가지를 돌렸다:
|
||||
|
||||
| 환경 `MAM_AUTO_TRUST` | 플래그 | 핸들러가 받는 값 | 판정 |
|
||||
|---|---|---|---|
|
||||
| 없음 | – | `1` (on) | 기본 |
|
||||
| `0` | – | `0` (**off**) | **P1 정정**: 문서의 환경변수 형식이 이제 닿는다(옛 코드는 `1` 로 덮어썼다) |
|
||||
| `0` | `--auto-trust` | `1` (on) | 플래그 > 환경 |
|
||||
| `1` | `--no-auto-trust` | `0` (off) | 플래그 > 환경 |
|
||||
| 없음 | `--no-auto-trust` | `0` (off) | |
|
||||
| `yes` / `false` | – | 그대로 전달 → 핸들러 `!= "0"` 규칙으로 **on** | `_mam_dialog_pick` 도 같은 규칙(`MAM_DLG_TRUST != "0"`)이라 두 층이 일치한다 |
|
||||
| `''`(빈 문자열) | – | `:-1` 이 빈 값을 미설정으로 봐 `1` (on) | 핸들러의 `${MAM_AUTO_TRUST:-1}` 와 같은 해석 |
|
||||
|
||||
즉 "0 만 off, 나머지는 전부 on" 이 스크립트·핸들러·picker 세 층에서 동일하다(`lib.sh:2986`, picker 의 `!= "0"`). create SKILL.md `:119-120` 이 환경변수 형식과 "플래그 우선"을 명시한다. **G1 (비차단)**: `false`/`no` 가 on 으로 해석되는 것은 일관되지만 직관에 반한다 — 문서에 "`0` 만 끈다" 를 한 마디 적어 두면 좋다. resume SKILL.md 는 `:107` 에서 `MAM_AUTO_TRUST=0` 형식은 적지만 "플래그가 환경보다 우선" 은 create 쪽에만 있다(G2, 사소).
|
||||
|
||||
## 3. 테스트 (항목 1·3)
|
||||
|
||||
### 3-1. 수정 없이는 실패하는가 — 확인함
|
||||
|
||||
검증 경로 사본에서 `lib.sh`·`create_session.sh`·`resume_session.sh` 세 파일만 `f4ba205` 판으로 되돌리고 새 회귀와 이웃 시험을 `-k "s6c or s5f or r3b or c3 or test_s5_ or s5b or test_s6_"` 로 돌렸다:
|
||||
|
||||
```
|
||||
4 failed, 7 passed, 70 deselected in 153.65s
|
||||
FAILED test_s6c_a_skipped_trust_dialog_does_not_hide_the_notice_below_it ← P2
|
||||
FAILED test_s5f_a_dialog_inside_its_cooldown_does_not_block_the_next_one ← P2
|
||||
FAILED test_r3b_…_on_resume[0-flag0-0] (환경변수만, 플래그 없음) ← P1
|
||||
FAILED test_c3_…_on_create[0-flag0-0] (환경변수만, 플래그 없음) ← P1
|
||||
```
|
||||
|
||||
실패 4건이 정확히 두 탐침의 재현 사례다. 통과한 7건 — R3b/C3 의 "플래그가 환경을 이긴다" 4사례(플래그는 옛 코드에서도 동작했다), S5·S5b(쿨다운 계약), S6(신뢰 창 skip + 안내 응답, 한 화면에 하나씩) — 은 정정 전후로 **변하지 않아야 하는** 계약이고, 실제로 변하지 않았다. 즉 `continue` 가 S5/S5b 를 깨지 않았다는 것이 되돌린 사본과 PR 트리 양쪽에서 성립한다(PR 트리 쪽은 §3-2).
|
||||
|
||||
### 3-2. PR 트리(검증 경로)에서의 실행
|
||||
|
||||
```
|
||||
tests/test_issue23_24_startup_path.py tests/test_issue24_resume_dialogs.py tests/test_c1_tui_readiness.py tests/test_b19_headless_reconcile_fixes.py
|
||||
→ 120 passed in 535.40s (0:08:55) failed 0 · skipped 0
|
||||
같은 -k 부분집합(새 회귀 4 + 이웃 7) 단독 → 11 passed in 148.21s
|
||||
```
|
||||
|
||||
- 다이얼로그 핸들러를 건드리는 4파일 전부 통과. `c1`·`b19` 의 "프레임당 1회"·`Yes, try it` 가드도 유지된다.
|
||||
- 실제 `.mam/jobs` 전후 **차이 0**. 검증 경로 tracked 파일 변화 없음(HEAD `05b0ea4`), 본 체크아웃 `main 1e78dae`.
|
||||
- 시간: 이 호스트에서 다른 스위트 2개가 동시에 돌고 있어 resume 시험 6건이 각 37~41 s 였다(1회차 F6 그대로; R3b 3사례가 더해져 +2 분).
|
||||
|
||||
## 4. 병합 차단 여부 (항목 3)
|
||||
|
||||
1회차 PASS 를 뒤집을 요소는 없다. 델타는 세 줄의 소스 변경(`lib.sh:2958` `continue`, `create_session.sh:72`·`resume_session.sh:33` 의 환경 기본값)과 문서 한 줄, 회귀 4건이며, 두 탐침의 재현 사례만 정확히 실패→통과로 바뀌었다. 버전 락스텝 bump 부재(`4.7.1`, 오케스트레이터가 4.7.2 로 처리 예정) 외에 **병합을 막을 결함은 없다**.
|
||||
|
||||
| # | 비차단 관찰 | 근거 |
|
||||
|---|---|---|
|
||||
| G1 | `MAM_AUTO_TRUST` 는 `0` 만 끈다(`false`/`no` 는 on). 세 층(스크립트·핸들러·picker)이 일치하므로 결함은 아니지만 문서에 한 마디 있으면 좋다 | `create_session.sh:72`, `lib.sh:2986`, `_mam_dialog_pick` 의 `!= "0"` |
|
||||
| G2 | "플래그가 환경보다 우선" 은 create SKILL.md(`:119-120`)에만 있고 resume SKILL.md(`:107`)에는 환경변수 형식만 있다 | 사소 |
|
||||
|
||||
1회차 F1(codex 수락 키와 브리프 서술의 차이)은 이 델타의 범위 밖이며 그대로 열려 있다.
|
||||
|
||||
## 5. 교차검증 가이드
|
||||
|
||||
```bash
|
||||
RP=/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T220355Z-76dd1d/1
|
||||
PY=/Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux/.venv/bin/python
|
||||
git -C "$RP" rev-parse HEAD 'HEAD^{tree}' # 05b0ea4… / 8f5e8b0…
|
||||
# picker 단독: 신뢰 창 쿨다운 중에도 안내에 답하는지
|
||||
eval "$(sed -n '/^_mam_dialog_pick() {/,/^}/p' "$RP/.agents/skills/lib.sh")"
|
||||
D='[["Do you trust the contents of this directory|1\\. Yes, continue",["Enter"],"trust"],["1\\. Update now|Skip until next version",["2"],"notice"]]'
|
||||
S=$'Do you trust the contents of this directory?\n > 1. Yes, continue\nUpdate available\n 1. Update now\n 2. Skip\n 3. Skip until next version'
|
||||
_mam_dialog_pick "$S" "$D" 1 "0:1000" 1001 5 # send|1|2 (정정 전: 출력 없음)
|
||||
_mam_dialog_pick "$S" "$D" 1 "0:1000 1:1000" 1002 5 || echo nothing # S5 유지
|
||||
# 되돌린 사본에서 회귀가 실패하는지: lib.sh/create/resume 를 f4ba205 판으로 교체 → -k "s6c or s5f or r3b or c3 ..." → 4 failed / 7 passed
|
||||
```
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20260923T220355Z-76dd1d",
|
||||
"base_sha": "f4ba205c9f755d817e2206c7a9cb390a3e61fd2f",
|
||||
"head_sha": "05b0ea4762de3fad013e8986d45db21c4e6aa465",
|
||||
"tree_sha": "8f5e8b0402017d2cc809364e60400c4c25b922f3",
|
||||
"review_commit": "05b0ea4762de3fad013e8986d45db21c4e6aa465",
|
||||
"reviewer": "planner-reviewer-codex-01",
|
||||
"job_id": "81cd4006",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-09-23T23:35:19Z"
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# PR #35 델타 리뷰 — 두 탐침 지적 해소
|
||||
|
||||
- Job: `81cd4006`; reviewer: `planner-reviewer-codex-01`
|
||||
- Loop: `loop-20260923T220355Z-76dd1d`
|
||||
- Base: `f4ba205c9f755d817e2206c7a9cb390a3e61fd2f`
|
||||
- Head: `05b0ea4762de3fad013e8986d45db21c4e6aa465`
|
||||
- Tree: `8f5e8b0402017d2cc809364e60400c4c25b922f3`
|
||||
- 검증 경로: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T220355Z-76dd1d/1`
|
||||
|
||||
두 기존 지적이 해결되었다. 이번 델타에서 추가 병합 차단 결함을 발견하지 않았다. 브리프에 명시된 4.7.2 버전 락스텝 변경은 오케스트레이터의 후속 작업으로 남는다.
|
||||
|
||||
## P1: 환경변수 자동 신뢰 거부가 create/resume에서 보존됨
|
||||
|
||||
근거: `.agents/skills/multi-agent-mux-create/scripts/create_session.sh:72`, `:87`, `:88`, `:96`; `.agents/skills/multi-agent-mux-resume/scripts/resume_session.sh:33`, `:42`, `:43`, `:57`.
|
||||
|
||||
초기값이 `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"`로 바뀌어 환경의 `0`을 보존한다. 이후 명시적 플래그가 값을 덮어쓰므로 문서에 추가된 우선순위와 일치한다. 실제 create/resume 스크립트를 샌드박스의 mock herdr와 실행하는 C3/R3b 회귀가 다음 여섯 사례를 검증했다.
|
||||
|
||||
| 환경값 | 명시 플래그 | create/resume 핸들러에 전달되는 값 |
|
||||
|---|---|---|
|
||||
| `0` | 없음 | `0` |
|
||||
| `0` | `--auto-trust` | `1` |
|
||||
| `1` | `--no-auto-trust` | `0` |
|
||||
|
||||
추가로 두 스크립트에서 추출한 초기화·인자 처리 코드와 실제 `_mam_dialog_pick` 함수를 연결하여, 미설정·빈 문자열·`0`·`1`·`false`·`2`를 각각 검사했다(총 12사례). 미설정/빈 문자열은 `1`로 기본화되며, 정확히 `0`일 때만 신뢰 창을 건너뛴다. `false`와 `2`는 그대로 전달되고 자동 신뢰가 켜진다. 이는 핸들러의 기존 `!= "0"` 규칙과 일치하며 일반적인 boolean 문자열 파서로 바뀐 것은 아니다.
|
||||
|
||||
역검증: 별도 임시 샌드박스에서 해당 스크립트의 초기화 한 줄만 `AUTO_TRUST=1`로 되돌린 뒤, C3/R3b의 환경값 `0`, 플래그 없음 사례를 호출했다. create와 resume 모두 새 회귀의 assertion이 실패했다. 수정된 HEAD에서는 같은 사례가 통과했다. 기존 P1 지적을 해소한다.
|
||||
|
||||
## P2: 쿨다운 중인 신뢰 창이 뒤의 안내를 가리지 않음
|
||||
|
||||
근거: `.agents/skills/lib.sh:2953` 및 `:2958`. 쿨다운에 걸린 항목에서 검색 전체를 끝내던 `sys.exit(1)`이 다음 항목을 검사하는 `continue`로 바뀌었다. 항목별 쿨다운 판정과 전송 기록은 유지된다.
|
||||
|
||||
재현 화면은 신뢰 문구와 업데이트 안내를 함께 포함한다.
|
||||
|
||||
```text
|
||||
Do you trust the contents of this directory?
|
||||
> 1. Yes, continue
|
||||
Update available
|
||||
1. Update now
|
||||
2. Skip
|
||||
3. Skip until next version
|
||||
```
|
||||
|
||||
S6c는 `MAM_AUTO_TRUST=0`, 쿨다운 30초, 핸들러 실행 3회 조건에서 신뢰 키 없이 `2`만 한 번 전송됨을 검증했다. S5f는 자동 신뢰가 켜진 상태와 쿨다운 60초에서 `Enter`, `2`가 이 순서로 각각 한 번 전송됨을 검증했다. 둘 다 통과했다. 기존 S5/S5b를 포함한 선택 테스트도 통과하여, 쿨다운 중 재전송 억제와 만료 후 재시도 동작이 보존됨을 확인했다.
|
||||
|
||||
역검증: 임시 사본에서 이 `continue`만 이전 `sys.exit(1)`로 되돌렸다. S6c는 실제 전송 키가 `[]`여서 실패했고, S5f는 실제 전송 키가 `[Enter]`뿐이어서 실패했다. 즉 새 테스트는 안내 전송 누락을 실제로 검출한다. 기존 P2 지적을 해소한다.
|
||||
|
||||
## 검증 결과와 범위
|
||||
|
||||
검증 트리의 HEAD/tree가 브리프와 일치하고 작업 트리가 깨끗함을 확인했다. `git diff f4ba205...HEAD`의 6개 파일을 검토했다. 제품 코드·소스 워크트리·실제 에이전트 세션은 수정하지 않았으며, 역검증 변경은 `/tmp/mam-81cd-revert-*`의 테스트 사본에만 적용했다.
|
||||
|
||||
실행한 선택 테스트:
|
||||
|
||||
```text
|
||||
tests/test_issue23_24_startup_path.py
|
||||
tests/test_issue24_resume_dialogs.py
|
||||
-k 's5 or s6 or environment_form or no_auto_trust_reaches'
|
||||
```
|
||||
|
||||
- 결과: **20 passed, 61 deselected in 191.05s**.
|
||||
- 브리프의 Python 인터프리터를 `AGENT_PYTHON_BIN`과 실행 경로에 함께 지정했다. `PYTHONDONTWRITEBYTECODE=1`, `-p no:cacheprovider`, 검증 경로 밖의 고유 `--basetemp=/tmp/mam-review-81cd4006.*`를 사용했다.
|
||||
- 수정 역검증 4건 모두 새 회귀 assertion이 예상대로 실패했다: 신뢰 거부+안내, 신뢰 수락+안내, create 환경값, resume 환경값.
|
||||
- 환경값 경계 검사 12건 통과.
|
||||
- `git diff --check f4ba205 HEAD`, 변경된 셸 파일 3개의 개별 `bash -n`, 변경된 Python 테스트 파일 2개의 AST 구문 검사 통과.
|
||||
|
||||
직전 `f4ba205` 리뷰에서 기록한 247건 통과는 이전 트리의 결과다. 이번 트리에서 247건 전체를 재실행했다고 주장하지 않으며, 이번 검증은 두 정정과 관련 회귀에 집중했다. 델타는 교차 서버 스코프, dispatch 증거, 롤백 구현을 변경하지 않는다. 새 회귀는 샌드박스의 mock herdr/화면 문자열을 사용하므로 실제 CLI 화면 수용 시험을 새로 수행했다는 의미도 아니다.
|
||||
|
||||
단순 수정으로 두 지적이 닫혔으므로 재설계 에스컬레이션은 필요하지 않다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20260923T220355Z-76dd1d",
|
||||
"base_sha": "f4ba205c9f755d817e2206c7a9cb390a3e61fd2f",
|
||||
"head_sha": "05b0ea4762de3fad013e8986d45db21c4e6aa465",
|
||||
"tree_sha": "8f5e8b0402017d2cc809364e60400c4c25b922f3",
|
||||
"review_commit": "05b0ea4762de3fad013e8986d45db21c4e6aa465",
|
||||
"reviewer": "reviewer-agy-01",
|
||||
"job_id": "471806f8",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-09-23T23:35:19Z"
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# Pre-Merge Independent Delta Review Report: PR #35 (`fix/issue-23-24` Round 2)
|
||||
|
||||
- **Job ID**: `471806f8`
|
||||
- **Target Agent**: `agy` (Session: `herdr:reviewer-agy-01`)
|
||||
- **Role**: Reviewer
|
||||
- **Target Worktree Context**:
|
||||
- **Loop ID**: `loop-20260923T220355Z-76dd1d`
|
||||
- **Base Commit**: `f4ba205c9f755d817e2206c7a9cb390a3e61fd2f` (Round 1 target)
|
||||
- **Head Commit**: `05b0ea4762de3fad013e8986d45db21c4e6aa465` (1 delta commit: +76 / −5)
|
||||
- **Tree SHA**: `8f5e8b0402017d2cc809364e60400c4c25b922f3`
|
||||
- **Verification Path**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T220355Z-76dd1d/1`
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
This second review round examines delta commit `05b0ea4` ("fix(issue-23-24): merge review - the environment form of the auto-trust switch reaches the handler; a rate-limited dialog does not hide the next one"). The commit addresses two specific edge-case probes identified during the merge review of PR #35:
|
||||
1. **Probe P1**: In `create_session.sh` and `resume_session.sh`, hardcoded `AUTO_TRUST=1` unconditionally overwrote the documented environment switch `MAM_AUTO_TRUST=0`.
|
||||
2. **Probe P2**: In `lib.sh::_mam_dialog_pick`, hitting a dialog within its resend cooldown triggered `sys.exit(1)`, preventing subsequent modal notices (e.g. codex update skip) on the same screen from being answered.
|
||||
|
||||
Both fixes, their regression test coverage (`test_issue23_24_startup_path.py` and `test_issue24_resume_dialogs.py`), and documentation updates in `multi-agent-mux-create/SKILL.md` were evaluated. All 18 targeted tests and git diff validation passed with zero regressions.
|
||||
|
||||
---
|
||||
|
||||
## 2. Detailed Verification Against Checklist Items
|
||||
|
||||
### (1) Resolution of Codex Probes P1 & P2 and Side-Effect Analysis
|
||||
- **P1: Environment Variable Inheritance (`AUTO_TRUST="${MAM_AUTO_TRUST:-1}"`)**:
|
||||
- Previously, `create_session.sh` and `resume_session.sh` set `AUTO_TRUST=1` and subsequently exported `MAM_AUTO_TRUST="$AUTO_TRUST"`, clobbering caller-provided `MAM_AUTO_TRUST=0` when no CLI flag was supplied.
|
||||
- Initializing `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"` allows `MAM_AUTO_TRUST=0` in the environment to take effect naturally.
|
||||
- An explicit CLI flag (`--auto-trust` or `--no-auto-trust`) re-assigns `AUTO_TRUST` to `1` or `0`, correctly asserting command-line precedence over environment variables.
|
||||
- Regression verified by `test_r3b` (resume) and `test_c3` (create) across all combinations (env=0 alone, env=0 with `--auto-trust`, and env=1 with `--no-auto-trust`).
|
||||
- **P2: Dialog Inspection Fallthrough via `continue`**:
|
||||
- In `lib.sh::_mam_dialog_pick`, when a dialog matched on screen is within its cooldown window (`0 <= now - last < cooldown`), changing the behavior from `sys.exit(1)` to `continue` permits evaluation of subsequent dialog signatures in the adapter's list.
|
||||
- If a trust dialog was skipped via `--no-auto-trust` (recorded in `_MAM_DIALOG_SENT`), or if it was answered on the preceding frame and is now cooling, a co-located notice dialog (such as the codex update prompt) is properly evaluated and answered on subsequent iterations.
|
||||
- Verified by new test `test_s6c` (skipped trust dialog does not hide notice below it) and `test_s5f` (dialog inside cooldown does not block answering the next dialog).
|
||||
- **Preservation of Cooldown Protection**:
|
||||
- `test_s5` and `test_s5b` continue to verify that an individual dialog cannot be resent while its cooldown is active, and can only be retried once the cooldown window expires.
|
||||
- Multi-dialog ordering and execution count remain deterministic: trust dialog answered first (`Enter`), followed by notice dialog (`2`), each sent exactly once (`test_s5f`).
|
||||
|
||||
### (2) Environment Precedence & Value Semantics
|
||||
- In shell scripts (`create_session.sh` / `resume_session.sh`):
|
||||
- `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"` defaults to `1` when unset or empty.
|
||||
- Any user-specified value (e.g. `0`) is preserved and exported.
|
||||
- Command-line flags `--auto-trust` and `--no-auto-trust` overwrite `AUTO_TRUST` to `1` and `0` respectively, ensuring CLI options win over environment variables.
|
||||
- In Python (`_mam_dialog_pick`):
|
||||
- `auto_trust = os.environ.get("MAM_DLG_TRUST", "1") != "0"` cleanly maps string `"0"` to `False`, while any other non-"0" value defaults to `True`.
|
||||
- Behavior between shell scripts and Python dialog picker is fully aligned.
|
||||
- Documentation in `multi-agent-mux-create/SKILL.md` explicitly specifies `MAM_AUTO_TRUST=0` and documents that explicit flags take precedence over the environment.
|
||||
|
||||
### (3) Overall Merge Readiness
|
||||
- Commit `05b0ea4` reinforces the robustness and reliability of PR #35 without altering any public interfaces or state machine rules verified in Round 1.
|
||||
- Formatting and git diff checks (`git diff f4ba205...HEAD --check`) reported 0 whitespace or formatting issues.
|
||||
- Version bump to `v4.7.2` remains scheduled for the orchestrator release commit.
|
||||
- No blockers or regressions detected.
|
||||
|
||||
---
|
||||
|
||||
## 3. Test Execution Summary
|
||||
|
||||
| Test Suite / Target | Result | Duration | Notes |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `test_issue23_24_startup_path.py` (`s6c`, `s5f`, `s5`, `s5b`) | **11 / 11 PASS** | 182s | Probe P2 cooldown fallthrough & multi-dialog handling |
|
||||
| `test_issue24_resume_dialogs.py` (`r3b`, `c3`, `r3`) | **7 / 7 PASS** | 8s | Probe P1 environment variable inheritance & CLI flag precedence |
|
||||
| `git diff f4ba205...HEAD --check` | **0 errors** | 0.05s | Clean formatting and no whitespace errors |
|
||||
|
||||
---
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,123 @@
|
||||
# Pre-Merge Independent Review Report: PR #35 (`fix/issue-23-24`)
|
||||
|
||||
- **Job ID**: `c786c83f`
|
||||
- **Target Agent**: `agy` (Session: `herdr:reviewer-agy-01`)
|
||||
- **Role**: Reviewer
|
||||
- **Target Worktree Context**:
|
||||
- **Loop ID**: `loop-20260923T142448Z-609954`
|
||||
- **Base Commit**: `1e78dae6ebcb01773df7c4715c98b1786965a24a` (v4.7.1)
|
||||
- **Head Commit**: `f4ba205c9f755d817e2206c7a9cb390a3e61fd2f`
|
||||
- **Tree SHA**: `88cb4f34f1e9f8220a373350cfc600a53735f181`
|
||||
- **Verification Path**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T142448Z-609954/1`
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
PR #35 addresses Issue #23 and Issue #24 ("교차 서버 herdr 스코프와 에이전트 무관 시작 다이얼로그, 실제 에이전트가 충족하는 온보딩 전달 판정") across 13 commits and 22 files (+2197 / −118).
|
||||
|
||||
The branch eliminates cross-server scope leaks (`HERDR_WORKSPACE_ID` / `HERDR_PANE_ID` / `HERDR_TAB_ID`), externalizes agent start-up modal handling into declarative adapter facts, standardizes protocol-level `started` acknowledgement for agent onboarding, and implements robust fail-closed rollback handling (exit code 43 on unkillable sessions).
|
||||
|
||||
All verification tasks were performed in the isolated verification path. All 73 tests in the new test suites (`test_issue23_24_startup_path.py`, `test_issue24_resume_dialogs.py`) and all 177 regression tests in the adjusted suites passed without error.
|
||||
|
||||
---
|
||||
|
||||
## 2. Detailed Verification Against Checklist Items
|
||||
|
||||
### (1) Issue #24 Startup Dialogs
|
||||
- **Adapter-Driven Declarative Modals**:
|
||||
- Signatures, response keystrokes, and dialog classifications (`trust` vs. `notice`) are defined per agent in `lib_py/agents/adapters/*.py` (`startup_dialogs` property) and exposed via `lib_py.agents facts` (`MAM_STARTUP_DIALOGS`).
|
||||
- Supported keys match target agent TUI specifications:
|
||||
* `agy`: `'Do you trust the contents of this project|Yes, I trust this folder'` -> `['Enter']` (`trust`).
|
||||
* `grok`: `'Do you trust the contents of this directory'` -> `['y']` (`trust`).
|
||||
* `codex`: `'Do you trust the contents of this directory|1\\. Yes, continue'` -> `['Enter']` (`trust`); `'1\\. Update now|Skip until next version'` -> `['2']` (`notice`, selects "2. Skip").
|
||||
* `claude`: Full legacy ladder (`Enter` for trust/proceed, `Escape` for fullscreen upsell modal to preserve execution flags, `Down`+`Enter` for proceed).
|
||||
- **Cooldown & Ready Token Gating**:
|
||||
- Because herdr `--source visible` capture retains modal text on-screen temporarily after acceptance, re-sending keystrokes is rate-limited by `MAM_DIALOG_RESEND_S` (default 5s cooldown).
|
||||
- TUI readiness is strictly judged by adapter ready tokens (`MAM_STRONG_READY_TOKENS` / `MAM_READY_TOKENS`), never by modal disappearance (`test_s5c`, `test_s5d`).
|
||||
- **`--no-auto-trust` Flag & Workspace Scope**:
|
||||
- Setting `--no-auto-trust` (`MAM_AUTO_TRUST=0`) leaves dialogs of kind `trust` unanswered (warning printed to stderr for operator confirmation) while continuing to auto-handle `notice` dialogs (`test_s6`).
|
||||
- Crucial for `codex` security, where directory trust activates project-local configuration, hooks, and execution policies.
|
||||
- Automatic trust is only sent to sessions launched inside the designated `--workspace`.
|
||||
- **Contract Parity across Create and Resume**:
|
||||
- `create_session.sh` and `resume_session.sh` both invoke `handle_startup_dialogs "$SESSION_NAME" <timeout> "$AGENT"`, parse `--no-auto-trust`, and share `_mam_load_dialog_facts` (`test_r1`, `test_r2`, `test_r3`, `test_r4`).
|
||||
|
||||
### (2) Issue #23 Cross-Server Herdr Scoping
|
||||
- **Targeted Scope Variable Stripping**:
|
||||
- In `lib.sh` (`mam_herdr`), `_herdr_call_is_cross_server` strips `HERDR_WORKSPACE_ID`, `HERDR_PANE_ID`, and `HERDR_TAB_ID` via `env -u ...` **only** when targeting a different server (`caller != target`).
|
||||
- Same-server calls retain workspace/pane scoping, protecting workspace packing and pane isolation (`test_s2`, `test_s1e`). Calls outside panes retain explicit scopes (`test_s2b`).
|
||||
- **Caller Server Identification**:
|
||||
- `_herdr_caller_server` checks `HERDR_SESSION` first. If unset (e.g. herdr 0.8.2 on the default server), it parses `HERDR_SOCKET_PATH`:
|
||||
* `*/.config/herdr/herdr.sock` -> `default`
|
||||
* `*/.config/herdr/sessions/<name>/herdr.sock` -> `<name>`
|
||||
* Unknown formats -> `""` (treated safely as non-pane).
|
||||
- Verified across both default and named session socket layouts on herdr 0.8.2 and 0.9.0 (`test_s1c`, `test_s1d`, `test_s1f`, `test_s1g`, `test_s1h`).
|
||||
- **Python Watcher Parity**:
|
||||
- `lib_py/loop/watcher.py` implements identical logic (`caller_server()` and `scoped_env()`) for direct subprocess executions of herdr (`test_s13`).
|
||||
|
||||
### (3) Issue #23 Onboarding Delivery Verification
|
||||
- **Unified Command Builder & Predicate**:
|
||||
- `delegate_publish_cmd` in `lib.sh` generates the canonical publish command with `--expect-dispatch-token <token> --attempt-id <attempt>`.
|
||||
- `test_s7l` confirms `delegate_publish_cmd` produces identical arguments to the delegate-job wrapper's `_pub_cmd`.
|
||||
- Both `create_session.sh` and the wrapper (`_wait_started`) share `delegate_started_recorded` from `lib.sh`.
|
||||
- **Dispatch Attribution & Single-Injection Contract**:
|
||||
- Local events: `<registry>/<job_id>.events.log` requires `event == "started"` and `data.dispatch_token == <token>`.
|
||||
- Remote events: `<logs_dir>/<job_id>/events.ndjson` requires `source_event == "started"`, `dispatch_verified is True`, and `dispatch_fingerprint == sha256(<token>)`.
|
||||
- Prevents a prior attempt's started record from acknowledging a subsequent re-dispatch (`test_w2`, `test_w7`).
|
||||
- Legacy records lacking a fingerprint fail closed when a token is demanded (`test_w8`).
|
||||
- **Elimination of Status Bypass**:
|
||||
- The previous wrapper fallback (`disk_status != pending -> success`) was completely removed. Both create and wrapper now strictly verify `started` event attribution (`test_w1`, `test_w4`).
|
||||
- **Terminal State Protection**:
|
||||
- If a job has already transitioned to a terminal status (`completed`, `error`, `cancelled`), `create_session.sh` leaves the job untouched rather than issuing a conflicting state change (`test_s7f`, `test_s7m`).
|
||||
- **Liveness Parity**:
|
||||
- Respects Issue #18 liveness contract (T=180s timeout, configurable via `MAM_ONBOARD_ACK_S`).
|
||||
|
||||
### (4) Issue #23 Rollback & Error Handling
|
||||
- **Kill Confirmation & Exit Code 43**:
|
||||
- `cleanup_herdr_on_error` checks the returncode of `kill-session` and affirmatively verifies session termination via `has-session`.
|
||||
- If the session remains alive, the script updates `agent-sessions.yaml` to `status: running` with `last_visible_status: rollback-failed`, clears `delegate_job_id`, prints manual cleanup instructions, and exits with code **43** (`test_s9`).
|
||||
- If cleanly killed, it marks `status: terminated`, `last_visible_status: rolled-back`, publishes `cancelled` for the onboarding job, and exits (`test_s10`).
|
||||
- **Session Preservation on Handshake Timeout**:
|
||||
- If an onboarding `started` event is not observed within `ONBOARD_ACK_S`, the onboarding job is marked `error`, but the session and its pane are **preserved** (`test_s7d`). Operators can re-submit onboarding via `multi-agent-mux-delegate-job submit` once the agent finishes initializing.
|
||||
|
||||
### (5) Impact on Existing Test Suites (6 Files)
|
||||
- `tests/conftest.py`: Injects `MAM_ONBOARD_ACK_S=3` into the `mam_sandbox` fixture to prevent sandbox runs without real agents from waiting 180s.
|
||||
- `tests/test_b19_headless_reconcile_fixes.py`: Relocated modal assertion to inspect `claude.py` adapter facts.
|
||||
- `tests/test_c1_tui_readiness.py`: Updated `test_c1b_hint_press_enter_sends_enter` to reflect time-based cooldown semantics (`MAM_DIALOG_RESEND_S`), verifying `1 <= count <= 30`.
|
||||
- `tests/test_issue14_delivery_ack.py`: Updated out-of-band agent acknowledgement to publish a genuine `started` event with token via `publish_event.py`.
|
||||
- `tests/test_issue18_liveness_contract.py`: Assertions match the protocol requirement (`recorded 'started'`).
|
||||
- `tests/test_issue5_phase2a_loop.py`: Refined G9 guard using Python AST parsing (`_code_only`) to strip only docstring expression nodes while preserving same-line code statements. Rigorously tested by `test_g9b_the_guard_still_catches_an_invocation_and_ignores_prose`.
|
||||
|
||||
### (6) Test Isolation & Reliability
|
||||
- New suites (`test_issue23_24_startup_path.py` [1268 lines] and `test_issue24_resume_dialogs.py` [127 lines]) operate completely within isolated sandboxes.
|
||||
- Real `registry.py` and `publish_event.py` utilities are exercised against isolated directories.
|
||||
- Zero mutation of control plane `.mam`, live agent sessions, or daemon processes.
|
||||
|
||||
### (7) Documentation Integrity
|
||||
- `multi-agent-mux-create/SKILL.md` and `multi-agent-mux-resume/SKILL.md` accurately document `--no-auto-trust`, exit codes 42/43, and `MAM_ONBOARD_ACK_S`.
|
||||
- Event and audit log schemas (`dispatch_fingerprint`, `dispatch_verified`) align with `MESSAGING.md`.
|
||||
|
||||
### (8) Merge Readiness
|
||||
- Version bump to `v4.7.2` is scheduled for the orchestrator release commit.
|
||||
- Code style, formatting, and diff integrity checks (`git diff 1e78dae...HEAD --check`) reported 0 issues.
|
||||
- Zero architectural, functional, or regression defects found.
|
||||
|
||||
---
|
||||
|
||||
## 3. Test Execution Summary
|
||||
|
||||
| Test Suite | Result | Duration | Notes |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `tests/test_issue23_24_startup_path.py` | **68 / 68 PASS** | 358s | S1–S13, S4–S6, S7–S10, W1–W8 test matrix |
|
||||
| `tests/test_issue24_resume_dialogs.py` | **5 / 5 PASS** | 8s | R1–R4 resume dialog parity |
|
||||
| `tests/test_issue5_phase2a_loop.py` | **91 / 91 PASS** | 3.1s | Loop orchestration & updated G9 AST guard |
|
||||
| `tests/test_issue18_liveness_contract.py` | **31 / 31 PASS** | 112s | Protocol liveness, subscriber audit & broker fallback |
|
||||
| `tests/test_c1_tui_readiness.py` | **23 / 23 PASS** | 45s | TUI readiness & rate-limited dialog responses |
|
||||
| `tests/test_b19_headless_reconcile_fixes.py` | **16 / 16 PASS** | 1.8s | Reconcile fixes & dialog exclusion assertions |
|
||||
| `tests/test_issue14_delivery_ack.py` | **12 / 12 PASS** | 98s | Delivery acknowledgement contracts |
|
||||
| `tests/test_version_consistency.py` | **3 / 3 PASS** | 0.2s | Version consistency checks |
|
||||
| `git diff 1e78dae...HEAD --check` | **0 errors** | 0.05s | Clean whitespace and formatting |
|
||||
|
||||
---
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,52 @@
|
||||
# 🔍 Review Report — Job 59e62ce6 (PR #35 @ `f4ba205`)
|
||||
|
||||
- **Reviewer**: `reviewer-grok-01` · 2026-09-22
|
||||
- **검증 경로**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T142448Z-609954/1`
|
||||
- **identity**: base `1e78dae` head `f4ba205` tree `88cb4f3` (일치)
|
||||
- **본 체크아웃**: `main` `1e78dae` 유지. 소스 워크트리·herdr 미조작.
|
||||
|
||||
`git diff --check 1e78dae...HEAD` 클린. 13커밋.
|
||||
|
||||
## (1) #24 다이얼로그
|
||||
|
||||
어댑터 `startup_dialogs` (`facts` 로 `MAM_STARTUP_DIALOGS`):
|
||||
|
||||
| 에이전트 | 키 | kind |
|
||||
|---|---|---|
|
||||
| agy | Enter | trust |
|
||||
| grok | `y` | trust |
|
||||
| codex trust | Enter (`1. Yes, continue` 선선택) | trust |
|
||||
| codex 업데이트 | **`2`** (Skip) | notice |
|
||||
| claude | Enter / Escape / Down+Enter | 기존 사다리 |
|
||||
|
||||
브리프의 “`3`+Enter” 와 다름. 코드·테스트(`test_s4`, `test_s6`)는 **`2`** 를 잠근다. 주석: 0.155.1 화면에서 2 가 Skip. `--source visible` 잔상 때문에 재전송은 `MAM_DIALOG_RESEND_S`(기본 5s) 이고 수락은 ready 토큰만 (`lib.sh` `handle_startup_dialogs`). `--no-auto-trust` / `MAM_AUTO_TRUST=0` 은 trust 만 건너뛰고 notice 는 답함. 신뢰는 `--workspace` 로 연 그 세션의 창. create·resume 둘 다 `handle_startup_dialogs "$SESSION" … "$AGENT"` 와 `MAM_AUTO_TRUST`.
|
||||
|
||||
## (2) #23 교차 서버
|
||||
|
||||
`_herdr_call_is_cross_server` (`lib.sh:1437-1446`): 대상이 비거나 `default` 이면 스트립 안 함(같은 서버·#7 패킹의 `HERDR_WORKSPACE_ID` 유지). 호출자 서버 없음(pane 밖)도 스트립 안 함. `HERDR_SESSION` 이 있으면 소켓보다 우선. 없으면 `HERDR_SOCKET_PATH` 모양: `…/.config/herdr/herdr.sock` → `default`, `…/.config/herdr/sessions/<name>/herdr.sock` → `<name>`. 0.8.2 기본 서버 pane 은 `HERDR_SESSION` 이 없어도 소켓으로 판별. 0.9 는 소켓 규약이 같고, 주석의 0.9 차이는 agent status 미보고(`lib.sh:2792`)이지 소켓 형태가 아님. `watcher.scoped_env` 가 같은 규칙.
|
||||
|
||||
## (3) 온보딩 전달
|
||||
|
||||
`delegate_publish_cmd` / `delegate_started_recorded` 를 create 와 래퍼 `_wait_started` 가 공유. 증거는 **이번 `dispatch_token`** 의 `events.log` `started`, 또는 감사 `events.ndjson` 의 `dispatch_verified` **그리고** `dispatch_fingerprint == sha256(token)`. 지문 없는 옛 기록은 토큰이 있을 때 거부. 레지스트리 status 는 증거가 아님(래퍼 `:641-646`). 이미 터미널인 잡은 `JOB_TERMINALIZED` 로 두 번째 close 를 안 함. T 기본 180, heartbeat 계약 유지. create 는 더 이상 대리 `started` 를 안 찍음.
|
||||
|
||||
## (4) 실패 처리
|
||||
|
||||
롤백: `kill-session` rc 후 `has-session`. 살아 있으면 행 `status: running`, `last_visible_status: rollback-failed`, `delegate_job_id` 비움, **exit 43**. 죽었으면 `terminated` / `rolled-back`. 온보딩 미확인은 세션 유지, 잡 `error`, `MAM_ONBOARD_ACK_S` 가 대기. create SKILL.md 와 맞음.
|
||||
|
||||
## (5) 기존 테스트
|
||||
|
||||
G9 는 AST 에서 docstring 노드만 빼고 나머지 코드를 unparse (`test_issue5_phase2a_loop.py:710-729`). `test_g9b` 가 호출·경로 문자열은 여전히 잡고 주석/독스트링은 놓음을 잠금. 의도 약화 아님. issue14 는 started 증거 계약에 맞춰 조정.
|
||||
|
||||
## (6) 새 테스트
|
||||
|
||||
`test_issue23_24_startup_path.py`·`test_issue24_resume_dialogs.py` 는 mock herdr / tmp. 실제 herdr·홈 미접촉. 검증 경로에서 `AGENT_PYTHON_BIN` 으로 **177 passed in 461.74s** (위 두 파일 + issue5 phase2a + issue14).
|
||||
|
||||
## (7)–(8) 문서·락스텝
|
||||
|
||||
create SKILL: `--no-auto-trust`, exit 43, `MAM_ONBOARD_ACK_S`. resume SKILL: `--no-auto-trust` 와 같은 핸들러. **비차단**: `MESSAGING.md` 와 delegate-job SKILL.md 본문에 `dispatch_fingerprint` 단어가 없음. 필드는 `job_subscriber.py` 주석과 감사 레코드에 있다. 락스텝은 4.7.1; 4.7.2 는 오케스트레이터 예정. 병합 차단 아님.
|
||||
|
||||
## 종합
|
||||
|
||||
#23 교차 서버 스코프와 #24 에이전트 무관 다이얼로그, 실제 `started` 로만 온보딩을 판정하는 계약이 코드·테스트와 맞다. 설계 재작업 불필요.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"loop_id": "loop-20260923T220355Z-76dd1d",
|
||||
"base_sha": "f4ba205c9f755d817e2206c7a9cb390a3e61fd2f",
|
||||
"head_sha": "05b0ea4762de3fad013e8986d45db21c4e6aa465",
|
||||
"tree_sha": "8f5e8b0402017d2cc809364e60400c4c25b922f3",
|
||||
"review_commit": "05b0ea4762de3fad013e8986d45db21c4e6aa465",
|
||||
"reviewer": "reviewer-grok-01",
|
||||
"job_id": "8352b00e",
|
||||
"verdict": "PASS",
|
||||
"transport": "herdr",
|
||||
"evidence": "review-tree",
|
||||
"isolation": "worktree",
|
||||
"verified_at": "2026-09-23T23:35:19Z"
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# 🔍 Review Report — Job 8352b00e (PR #35 델타 `f4ba205...05b0ea4`)
|
||||
|
||||
- **Reviewer**: `reviewer-grok-01` · 2026-09-24
|
||||
- **검증 경로**: `/var/folders/q_/68sxgw3j073dgjg8wl9s08vw0000gn/T/mam-review/loop-20260923T220355Z-76dd1d/1`
|
||||
- **identity**: head `05b0ea4` tree `8f5e8b0` (일치). 1회차 grok PASS @ `f4ba205` 를 뒤집지 않음.
|
||||
- **본 체크아웃**: `main` `1e78dae` 유지.
|
||||
|
||||
`git diff --check f4ba205...HEAD` 클린. 커밋 1개.
|
||||
|
||||
## (1) 탐침 두 건 — 닫힘
|
||||
|
||||
**P1.** `create_session.sh:72` 와 `resume_session.sh:33` 이 `AUTO_TRUST="${MAM_AUTO_TRUST:-1}"` 후 플래그 case 가 덮어쓰고, 그 다음 `export MAM_AUTO_TRUST`. 환경만 `0` 이면 핸들러에 `0`. `--auto-trust` / `--no-auto-trust` 가 이김. R3b·C3 각 3사례 통과.
|
||||
|
||||
**P2.** `_mam_dialog_pick` (`lib.sh:2954-2958`) 이 쿨다운 중이면 `sys.exit(1)` 대신 `continue`. 같은 화면의 다음 다이얼로그를 본다.
|
||||
|
||||
- S6c: `--no-auto-trust` + 신뢰 창과 업데이트 안내가 같이 있으면 키는 `2` 한 번, stderr 에 no-auto-trust.
|
||||
- S5f: 자동 신뢰면 순서 `Enter` 다음 `2`, 각 1회.
|
||||
|
||||
임시 사본에서 `continue` 를 `sys.exit(1)` 로 되돌리면 S6c·S5f **2 failed** (안내는 안 나감). 정정 트리에서는 통과.
|
||||
|
||||
**부작용.** S5(수락된 창은 쿨다운 안 재전송)·S5b(만료 후 1회) 그대로 통과. 한 다이얼로그만 있으면 루프 끝이 여전히 `sys.exit(1)` 이라 “할 일 없음”.
|
||||
|
||||
## (2) `0`/`1` 외 값
|
||||
|
||||
핸들러는 `MAM_DLG_TRUST != "0"` (`lib.sh` pick 의 `auto_trust`). 리터럴 `"0"` 만 끔. `"false"`/`"no"`/`"00"` 은 켜짐. 빈 환경은 `${MAM_AUTO_TRUST:-1}` 이 `1`. 문서가 약속한 형식은 `MAM_AUTO_TRUST=0` 이라 일치. create SKILL.md 에 환경 형식과 “명시 플래그가 이김”이 있음.
|
||||
|
||||
## (3) 1회차·락스텝
|
||||
|
||||
교차 서버 스코프, started-only 온보딩, exit 43 은 이 커밋이 건드리지 않음. 4.7.2 bump 는 오케스트레이터 예정. 병합 차단 아님.
|
||||
|
||||
## 테스트 (검증 경로)
|
||||
|
||||
S5, S5b, S5f, S6, S6c, R3b×3, C3×3: **11 passed in 152.03s**.
|
||||
|
||||
## 종합
|
||||
|
||||
두 탐침이 코드·회귀와 맞다. 쿨다운은 같은 다이얼로그의 재전송만 막고, 다음 다이얼로그는 막지 않는다. 설계 재작업 불필요.
|
||||
|
||||
[VERDICT: PASS]
|
||||
+347
-24
@@ -29,7 +29,7 @@ export WORKSPACE_ROOT
|
||||
# Framework semantic version. Single runtime source of truth; kept in lockstep
|
||||
# with VERSIONS.md and the 8 SKILL.md frontmatters by tests/test_version_consistency.py.
|
||||
# NOTE: unlike other MAM_* variables this one is intentionally NOT env-overridable.
|
||||
MAM_VERSION="4.7.1"
|
||||
MAM_VERSION="4.7.2"
|
||||
export MAM_VERSION
|
||||
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
|
||||
|
||||
@@ -71,7 +71,6 @@ done
|
||||
_MAM_MODAL_TOKENS='Do you trust the files|Yes, proceed|No, exit|Allow this|browser to authenticate|Resuming the full session|Resume from summary|Try the new fullscreen renderer\?'
|
||||
_MAM_HINT_TOKENS='Use arrow keys|Esc to cancel|Press Enter to continue'
|
||||
_MAM_DIALOG_TOKENS="${_MAM_MODAL_TOKENS}|${_MAM_HINT_TOKENS}"
|
||||
_MAM_READY_TOKENS_CLAUDE='Anthropic|Assistant|Chat|Welcome|Claude Code|Opus|Sonnet|Haiku'
|
||||
|
||||
# Workspace-relative defaults with environment overrides (Phase Z)
|
||||
HOME_DIR="${HOME_DIR:-$HOME}"
|
||||
@@ -1373,9 +1372,93 @@ EOF
|
||||
fi
|
||||
}
|
||||
|
||||
# _herdr_call_is_cross_server
|
||||
#
|
||||
# ISSUE-23: a herdr pane exports HERDR_WORKSPACE_ID / HERDR_PANE_ID /
|
||||
# HERDR_TAB_ID, and those ids only mean something on the server that issued
|
||||
# them. When a MAM script runs inside one server's pane (the orchestrator's)
|
||||
# but targets another (the isolated MAM server named by HERDR_SESSION_NAME),
|
||||
# the inherited ids reach the scope filters (_herdr_ws_scope,
|
||||
# _herdr_agent_get_scoped, _resolve_herdr_pane_id) as a foreign workspace, and
|
||||
# their fail-closed miss (lib.sh explicit-env miss, ISSUE-3 / F-2b) reports a
|
||||
# live session as absent: has-session, kill-session, agent prompt and the
|
||||
# delegate-job preflight all answer "no such session".
|
||||
#
|
||||
# _herdr_caller_server — the name of the server whose pane this process runs
|
||||
# in; empty when it is not in a pane at all.
|
||||
#
|
||||
# HERDR_SESSION is used when present, but herdr 0.8.2 does not export it to a
|
||||
# pane of the *default* server (the orchestrator's usual home), which is
|
||||
# exactly the pane the #23 leak comes from - the live trial of this branch
|
||||
# found every call judged "not crossing" for that reason. HERDR_SOCKET_PATH is
|
||||
# exported to every pane, and its layout names the server:
|
||||
#
|
||||
# $HOME/.config/herdr/herdr.sock -> default
|
||||
# $HOME/.config/herdr/sessions/<name>/herdr.sock -> <name>
|
||||
#
|
||||
# (the same convention lib.sh's shim already relies on to find a named
|
||||
# server's socket). Matched by shape, not by $HOME, so a redirected HOME does
|
||||
# not break it. An unrecognised layout yields "" - treated as "not in a pane",
|
||||
# which keeps today's behaviour rather than guessing.
|
||||
_herdr_caller_server() {
|
||||
if [ -n "${HERDR_SESSION:-}" ]; then
|
||||
printf '%s\n' "$HERDR_SESSION"
|
||||
return 0
|
||||
fi
|
||||
local sock="${HERDR_SOCKET_PATH:-}"
|
||||
case "$sock" in
|
||||
*/.config/herdr/herdr.sock)
|
||||
printf 'default\n'
|
||||
;;
|
||||
*/.config/herdr/sessions/*/herdr.sock)
|
||||
local d="${sock%/herdr.sock}"
|
||||
printf '%s\n' "${d##*/}"
|
||||
;;
|
||||
*)
|
||||
printf '\n'
|
||||
;;
|
||||
esac
|
||||
return 0
|
||||
}
|
||||
|
||||
# True only for a call aimed at a *named* server other than the caller's own:
|
||||
#
|
||||
# * target unset or `default` (the lib.sh:97 fallback) is the shim's sentinel
|
||||
# for "pass no session flag", so herdr answers on the ambient server, which
|
||||
# for a caller inside a pane is that same caller's server - not a crossing;
|
||||
# * no caller server (_herdr_caller_server empty) means the caller is not in
|
||||
# a pane at all, so an exported HERDR_WORKSPACE_ID is a deliberate scope,
|
||||
# not a leak (H-18/H-21/H-23);
|
||||
# * caller == target is the same server.
|
||||
#
|
||||
# A caller in the default server's pane targeting a named server IS a crossing
|
||||
# (the reported #23 scenario): `default` is a real server name on the caller's
|
||||
# side, only as a *target* is it the "ambient" sentinel.
|
||||
_herdr_call_is_cross_server() {
|
||||
local caller target="${HERDR_SESSION_NAME:-}"
|
||||
case "$target" in
|
||||
""|default) return 1 ;;
|
||||
esac
|
||||
caller="$(_herdr_caller_server)"
|
||||
if [ -n "$caller" ] && [ "$caller" != "$target" ]; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
mam_herdr() {
|
||||
_init_herdr_isolation
|
||||
"$WORKSPACE_ROOT/.mam/shim/herdr" "$@"
|
||||
# Built as an argv array and decided inside `if`: this runs under
|
||||
# `set -euo pipefail` in most callers, where a bare failing test would abort
|
||||
# the whole script instead of this one call.
|
||||
local -a _scope_strip=()
|
||||
if _herdr_call_is_cross_server; then
|
||||
_scope_strip=(env -u HERDR_WORKSPACE_ID -u HERDR_PANE_ID -u HERDR_TAB_ID)
|
||||
if [ "${MAM_SHIM_DEBUG:-0}" = "1" ]; then
|
||||
echo "mam_herdr: dropping caller pane scope ids (caller server '$(_herdr_caller_server)' != target '${HERDR_SESSION_NAME:-}')" >&2
|
||||
fi
|
||||
fi
|
||||
${_scope_strip[@]+"${_scope_strip[@]}"} "$WORKSPACE_ROOT/.mam/shim/herdr" "$@"
|
||||
}
|
||||
|
||||
_herdr() {
|
||||
@@ -2090,6 +2173,145 @@ delegate_publish_event() {
|
||||
_delegation_py publish --job "$job_id" --event "$event" --detail "$detail" ${extra[@]+"${extra[@]}"} || true
|
||||
}
|
||||
|
||||
# delegate_publish_cmd <job_id> [dispatch_token] [attempt_id]
|
||||
# The publish command an agent is told to run, shell-quoted, with absolute
|
||||
# script/registry/audit-log paths (Issue #21: an agent whose cwd is a worktree
|
||||
# runs it as written). create_session's onboarding uses this; the delegate-job
|
||||
# wrapper still builds its own (_pub_cmd), and S7l pins the two equal
|
||||
# argument-for-argument, so both hand the agent the same shape:
|
||||
# `--expect-dispatch-token T --attempt-id A` make publish_event write
|
||||
# data.dispatch_token / data.attempt_id into the agent's own events, which is
|
||||
# the only way a `started` can name the dispatch it answers (the trial-2
|
||||
# finding: create_session's hand-rolled command omitted them, the agent's
|
||||
# `started` carried no token, and the token-checking waiter could never be
|
||||
# satisfied by a real agent). publish_event also CAS-checks that token against
|
||||
# the record, so a stale dispatch's event is refused at the source.
|
||||
delegate_publish_cmd() {
|
||||
local job_id="$1" tok="${2:-}" att="${3:-}"
|
||||
local py_bin pub_script pub_reg pub_logs
|
||||
py_bin="$(_delegate_py_bin)"
|
||||
pub_script="$WORKSPACE_ROOT/.agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py"
|
||||
[ -f "$pub_script" ] || pub_script="$(_delegate_script publish_event.py)"
|
||||
pub_script="$(cd -P "$(dirname "$pub_script")" && pwd -P)/$(basename "$pub_script")"
|
||||
pub_reg="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/jobs}}"
|
||||
pub_reg="${pub_reg:-.mam/jobs}"
|
||||
mkdir -p "$pub_reg" && pub_reg="$(cd -P "$pub_reg" && pwd -P)"
|
||||
pub_logs="$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$py_bin" -c 'import os; from lib_py.delegation.mqtt_common import get_logs_dir; print(os.path.abspath(get_logs_dir()))' 2>/dev/null || true)"
|
||||
local -a argv=("$py_bin" "$pub_script" --registry-dir "$pub_reg" --job "$job_id")
|
||||
[ -n "$tok" ] && argv+=(--expect-dispatch-token "$tok")
|
||||
[ -n "$att" ] && argv+=(--attempt-id "$att")
|
||||
local out
|
||||
out="$(
|
||||
if [ -n "$pub_logs" ]; then printf 'DELEGATE_JOB_LOGS_DIR=%q ' "$pub_logs"; fi
|
||||
printf '%q ' "${argv[@]}"
|
||||
)"
|
||||
printf '%s' "${out% }"
|
||||
}
|
||||
|
||||
# delegate_job_status <job_id> — the record's status, empty if unreadable.
|
||||
delegate_job_status() {
|
||||
local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}"
|
||||
REC="$reg_dir/$1.json" python3 -c '
|
||||
import json, os
|
||||
try:
|
||||
print(json.load(open(os.environ["REC"])).get("status", "") or "")
|
||||
except Exception:
|
||||
print("")
|
||||
' 2>/dev/null || true
|
||||
}
|
||||
|
||||
# delegate_started_recorded <job_id> [dispatch_token] [logs_dir]
|
||||
# THE definition of "this dispatch's started is on disk", shared by
|
||||
# delegate_wait_started (create_session's onboarding ack) and the delegate-job
|
||||
# wrapper's _wait_started. Two sources, one rule:
|
||||
#
|
||||
# * <registry>/<job>.events.log - the local publisher's own line, whose
|
||||
# data.dispatch_token must equal <dispatch_token> when one is given (a loop
|
||||
# job keeps one events.log across attempts, and a stale attempt's started
|
||||
# must not vouch for this one - review R1);
|
||||
# * <logs_dir>/<job>/events.ndjson - the subscriber's audit record of a
|
||||
# `started` it received, the only evidence a REMOTE agent (no shared disk)
|
||||
# can leave. The subscriber verifies the HMAC and drops any event whose
|
||||
# data.dispatch_token differs from the job's expected token before it
|
||||
# writes this record (dispatch_verified), and writes dispatch_fingerprint
|
||||
# = SHA-256 of that token. The file is appended to across a job's
|
||||
# attempts, so the flag alone would let an earlier attempt's record
|
||||
# acknowledge a later dispatch (review round 4): with <dispatch_token>
|
||||
# given, a record counts only if it is dispatch_verified AND its
|
||||
# fingerprint equals SHA-256(<dispatch_token>). A record with no
|
||||
# fingerprint (written before this field existed) names no dispatch and
|
||||
# is rejected on that path - fail closed. Without a token, any received
|
||||
# started counts, as before.
|
||||
#
|
||||
# Never the registry status: a record leaves `pending` when another writer
|
||||
# closes it or another dispatch claims it. Lines are parsed as JSON, never
|
||||
# pattern-matched. rc 0 = recorded, 1 = not (yet).
|
||||
delegate_started_recorded() {
|
||||
local job_id="$1" tok="${2:-}" logs_dir="${3:-}"
|
||||
local reg_dir="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/jobs}"
|
||||
[ -n "$logs_dir" ] || logs_dir="${DELEGATE_JOB_LOGS_DIR:-${WORKSPACE_ROOT:-$PWD}/.mam/delegate_job_logs}"
|
||||
local ev_log="$reg_dir/$job_id.events.log"
|
||||
local audit="$logs_dir/$job_id/events.ndjson"
|
||||
[ -f "$ev_log" ] || [ -f "$audit" ] || return 1
|
||||
EV_LOG="$ev_log" EV_AUDIT="$audit" EV_TOK="$tok" python3 -c '
|
||||
import hashlib, json, os, sys
|
||||
tok = os.environ.get("EV_TOK", "")
|
||||
fp = hashlib.sha256(tok.encode("utf-8")).hexdigest() if tok else ""
|
||||
|
||||
def lines(path):
|
||||
if not os.path.isfile(path):
|
||||
return
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
try:
|
||||
e = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(e, dict):
|
||||
yield e
|
||||
|
||||
for e in lines(os.environ["EV_LOG"]):
|
||||
if e.get("event") != "started":
|
||||
continue
|
||||
data = e.get("data") if isinstance(e.get("data"), dict) else {}
|
||||
if not tok or data.get("dispatch_token") == tok:
|
||||
sys.exit(0)
|
||||
for e in lines(os.environ["EV_AUDIT"]):
|
||||
if e.get("event") != "received" or e.get("source_event") != "started":
|
||||
continue
|
||||
if not tok:
|
||||
sys.exit(0)
|
||||
if e.get("dispatch_verified") is True and e.get("dispatch_fingerprint") == fp:
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
' 2>/dev/null
|
||||
}
|
||||
|
||||
# delegate_wait_started <job_id> [ack_timeout_s] [dispatch_token]
|
||||
#
|
||||
# ISSUE-23 / ISSUE-18: delivery is confirmed by the protocol, not by the
|
||||
# screen. The evidence is delegate_started_recorded (above); the registry's
|
||||
# status is deliberately NOT evidence - a record leaves `pending` when another
|
||||
# writer closes it or another dispatch claims it (review R1). The local
|
||||
# publisher writes the log line before it syncs the status, so nothing that
|
||||
# really started is missed. Returns 0 on the evidence, 1 if it did not appear
|
||||
# within T. Never re-injects: the single-injection contract is fail-closed.
|
||||
delegate_wait_started() {
|
||||
local job_id="$1" ack_s="${2:-180}" tok="${3:-}"
|
||||
[ -n "$job_id" ] || return 1
|
||||
local deadline
|
||||
deadline=$(( $(date +%s) + ack_s ))
|
||||
while :; do
|
||||
if delegate_started_recorded "$job_id" "$tok"; then
|
||||
return 0
|
||||
fi
|
||||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||
return 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
# ensure_subscriber_daemon <job_id> [workdir]
|
||||
# Start the monitor subscriber for this workspace unless one is already running.
|
||||
# Prints the PID(s) on stdout, exactly as start_watchdog always did.
|
||||
@@ -2163,7 +2385,7 @@ wait_for_tui_ready() {
|
||||
# handle it and do NOT declare ready on this iteration.
|
||||
tail_lines=$(printf '%s\n' "$content" | grep -v '^[[:space:]]*$' | tail -n 20)
|
||||
if printf '%s\n' "$tail_lines" | grep -E -q "$modal_pat" 2>/dev/null; then
|
||||
handle_startup_dialogs "$sess" 1 || true
|
||||
handle_startup_dialogs "$sess" 1 "$agent" || true
|
||||
sleep 1
|
||||
continue
|
||||
fi
|
||||
@@ -2213,7 +2435,7 @@ wait_for_tui_ready() {
|
||||
fi
|
||||
|
||||
# 4. Startup dialog & hint resolution if not ready
|
||||
handle_startup_dialogs "$sess" 1 || true
|
||||
handle_startup_dialogs "$sess" 1 "$agent" || true
|
||||
sleep 1
|
||||
done
|
||||
echo "⚠️ TUI readiness check timed out for '$sess'." >&2
|
||||
@@ -2661,29 +2883,130 @@ send_keys_safe() {
|
||||
return 4
|
||||
}
|
||||
|
||||
# handle_startup_dialogs <sess> [timeout_sec=20]
|
||||
# Post-start/resume dialog policy for claude: accept the trust / bypass
|
||||
# dialogs ONLY when their signature is positively on screen; return as soon
|
||||
# as the TUI banner is ready. Replaces the blind Enter/Down/Enter sequence.
|
||||
# _mam_load_dialog_facts <agent>
|
||||
# Populate MAM_STARTUP_DIALOGS for <agent>, once per agent per shell, and the
|
||||
# ready tokens (MAM_STRONG_READY_TOKENS / MAM_READY_TOKENS) only when the caller
|
||||
# has not set them. `facts` is an eval contract (F8); evaluating all of it here
|
||||
# would clobber tokens a caller deliberately set, so only these are taken, and
|
||||
# the tokens only fill a gap. Without them the handler could never recognise a
|
||||
# ready screen when called on its own - resume_session.sh does exactly that,
|
||||
# with no wait_for_tui_ready before it - and idled through its whole timeout on
|
||||
# every resume (review B-1). An agent the adapters do not know keeps an empty
|
||||
# list and no tokens.
|
||||
_mam_load_dialog_facts() {
|
||||
local agent="$1" _f="" _d="" _s="" _r=""
|
||||
if [ "${_MAM_DIALOGS_AGENT:-}" = "$agent" ]; then
|
||||
return 0
|
||||
fi
|
||||
_f="$("$(_delegate_py_bin)" -m lib_py.agents facts "$agent" 2>/dev/null)" || _f=""
|
||||
_d="$(printf '%s\n' "$_f" | sed -n 's/^MAM_STARTUP_DIALOGS=//p')"
|
||||
_s="$(printf '%s\n' "$_f" | sed -n 's/^MAM_STRONG_READY_TOKENS=//p')"
|
||||
_r="$(printf '%s\n' "$_f" | sed -n 's/^MAM_READY_TOKENS=//p')"
|
||||
if [ -n "$_d" ]; then
|
||||
eval "MAM_STARTUP_DIALOGS=$_d"
|
||||
else
|
||||
MAM_STARTUP_DIALOGS='[]'
|
||||
fi
|
||||
if [ -z "${MAM_STRONG_READY_TOKENS:-}" ] && [ -n "$_s" ]; then
|
||||
eval "MAM_STRONG_READY_TOKENS=$_s"
|
||||
fi
|
||||
if [ -z "${MAM_READY_TOKENS:-}" ] && [ -n "$_r" ]; then
|
||||
eval "MAM_READY_TOKENS=$_r"
|
||||
fi
|
||||
_MAM_DIALOGS_AGENT="$agent"
|
||||
return 0
|
||||
}
|
||||
|
||||
# _mam_dialog_pick — first actionable dialog on this frame.
|
||||
# stdout: "send|<idx>|<key> <key>..." or "skip|<idx>|", nothing when there is
|
||||
# none to act on. rc 1 means "nothing to do" (matched-but-cooling counts).
|
||||
_mam_dialog_pick() {
|
||||
MAM_DLG_PANE="$1" MAM_DLG_JSON="$2" MAM_DLG_TRUST="$3" \
|
||||
MAM_DLG_STATE="$4" MAM_DLG_NOW="$5" MAM_DLG_COOLDOWN="$6" \
|
||||
python3 -c '
|
||||
import os, re, sys, json
|
||||
pane = os.environ.get("MAM_DLG_PANE", "")
|
||||
try:
|
||||
dialogs = json.loads(os.environ.get("MAM_DLG_JSON") or "[]")
|
||||
except Exception:
|
||||
dialogs = []
|
||||
auto_trust = os.environ.get("MAM_DLG_TRUST", "1") != "0"
|
||||
try:
|
||||
now = int(os.environ.get("MAM_DLG_NOW") or 0)
|
||||
cooldown = int(os.environ.get("MAM_DLG_COOLDOWN") or 5)
|
||||
except ValueError:
|
||||
sys.exit(1)
|
||||
state = {}
|
||||
for tok in (os.environ.get("MAM_DLG_STATE") or "").split():
|
||||
k, _, v = tok.partition(":")
|
||||
try:
|
||||
state[int(k)] = int(v)
|
||||
except ValueError:
|
||||
pass
|
||||
for idx, entry in enumerate(dialogs):
|
||||
try:
|
||||
pattern, keys, kind = entry[0], entry[1], entry[2]
|
||||
if not re.search(pattern, pane):
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
last = state.get(idx)
|
||||
if last is not None and 0 <= now - last < cooldown:
|
||||
# rate-limited: look at the other dialogs on this screen instead of
|
||||
# giving up (merge review, codex probe: a skipped trust prompt or a
|
||||
# freshly answered one used to hide the notice below it)
|
||||
continue
|
||||
if kind == "trust" and not auto_trust:
|
||||
print("skip|%d|" % idx)
|
||||
sys.exit(0)
|
||||
print("send|%d|%s" % (idx, " ".join(keys)))
|
||||
sys.exit(0)
|
||||
sys.exit(1)
|
||||
'
|
||||
}
|
||||
|
||||
# handle_startup_dialogs <sess> [timeout_sec=20] [agent=claude]
|
||||
# Answer the blocking modals an agent shows before its TUI is usable. The
|
||||
# signatures and accept keys come from the agent adapter (MAM_STARTUP_DIALOGS),
|
||||
# so this is agent-agnostic - issue #24: it used to be a claude-only ladder
|
||||
# called only for claude, and agy/grok/codex sat on their trust prompt until
|
||||
# the readiness wait timed out and onboarding was skipped.
|
||||
#
|
||||
# `--source visible` keeps showing an accepted dialog for a while (#24
|
||||
# comment), so a signature still on screen is NOT evidence that the keys were
|
||||
# missed: re-sending for the same signature is gated by MAM_DIALOG_RESEND_S
|
||||
# (default 5s) and acceptance is judged only by the ready tokens.
|
||||
#
|
||||
# MAM_AUTO_TRUST=0 leaves `trust` dialogs unanswered (warned once per
|
||||
# cooldown); `notice` dialogs are always answered.
|
||||
handle_startup_dialogs() {
|
||||
local sess="$1" timeout="${2:-20}" waited=0 pane
|
||||
local sess="$1" timeout="${2:-20}" agent="${3:-claude}"
|
||||
local waited=0 pane pick action rest idx keys key now
|
||||
local cooldown="${MAM_DIALOG_RESEND_S:-5}"
|
||||
local auto_trust="${MAM_AUTO_TRUST:-1}"
|
||||
_mam_load_dialog_facts "$agent"
|
||||
local dialogs="${MAM_STARTUP_DIALOGS:-[]}"
|
||||
local ready_re="${MAM_STRONG_READY_TOKENS:-${MAM_READY_TOKENS:-}}"
|
||||
while [ "$waited" -lt "$timeout" ]; do
|
||||
pane=$(_pane_tail "$sess" 20)
|
||||
if printf '%s\n' "$pane" | grep -Eq 'Do you trust the files|Yes, I trust this folder|Quick safety check'; then
|
||||
_sks_herdr send-keys -t "$sess" Enter
|
||||
elif printf '%s\n' "$pane" | grep -q 'Yes, try it'; then
|
||||
# Fullscreen-renderer upsell modal (not the idle /tui tip). Enter would
|
||||
# accept and restart the session without permission flags — reject.
|
||||
_sks_herdr send-keys -t "$sess" Escape
|
||||
elif printf '%s\n' "$pane" | grep -q 'Yes, proceed'; then
|
||||
_sks_herdr send-keys -t "$sess" Down
|
||||
now=$(date +%s)
|
||||
pick=""
|
||||
pick=$(_mam_dialog_pick "$pane" "$dialogs" "$auto_trust" "${_MAM_DIALOG_SENT:-}" "$now" "$cooldown" 2>/dev/null || true)
|
||||
if [ -n "$pick" ]; then
|
||||
action="${pick%%|*}"
|
||||
rest="${pick#*|}"
|
||||
idx="${rest%%|*}"
|
||||
keys="${rest#*|}"
|
||||
if [ "$action" = "send" ]; then
|
||||
for key in $keys; do
|
||||
_sks_herdr send-keys -t "$sess" "$key" || true
|
||||
sleep 0.3
|
||||
_sks_herdr send-keys -t "$sess" Enter
|
||||
elif printf '%s\n' "$pane" | grep -q 'Resuming the full session'; then
|
||||
_sks_herdr send-keys -t "$sess" Enter
|
||||
elif printf '%s\n' "$pane" | grep -q 'Press Enter to continue'; then
|
||||
_sks_herdr send-keys -t "$sess" Enter
|
||||
elif printf '%s\n' "$pane" | grep -Eq "${_MAM_READY_TOKENS_CLAUDE:-Anthropic|Assistant|Chat|Welcome}"; then
|
||||
done
|
||||
else
|
||||
echo "handle_startup_dialogs: a trust dialog is open on '$sess' and --no-auto-trust was given; accept it manually" >&2
|
||||
fi
|
||||
_MAM_DIALOG_SENT="${_MAM_DIALOG_SENT:-} $idx:$now"
|
||||
elif [ -n "$ready_re" ] && printf '%s\n' "$pane" | grep -Eq "$ready_re" 2>/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
|
||||
@@ -63,6 +63,7 @@ def main():
|
||||
print(f"MAM_STRONG_READY_TOKENS={q(adapter.strong_ready_tokens)}")
|
||||
print(f"MAM_WEAK_READY_TOKENS={q(adapter.weak_ready_tokens)}")
|
||||
print(f"MAM_MODAL_TOKENS={q(adapter.modal_tokens or '')}")
|
||||
print(f"MAM_STARTUP_DIALOGS={q(json.dumps(adapter.startup_dialogs))}")
|
||||
print(f"MAM_EXIT_KEY={q(adapter.exit_key)}")
|
||||
print(f"MAM_DELEGATE_AGENT_KEY={q(adapter.delegate_agent_key)}")
|
||||
|
||||
|
||||
@@ -23,6 +23,15 @@ class AgyAgentAdapter(BaseAgentAdapter):
|
||||
def delegate_agent_key(self) -> str:
|
||||
return 'antigravity-cli'
|
||||
|
||||
@property
|
||||
def startup_dialogs(self) -> list:
|
||||
# #24: "Do you trust the contents of this project?" with
|
||||
# "> Yes, I trust this folder" preselected - Enter accepts.
|
||||
return [
|
||||
('Do you trust the contents of this project|Yes, I trust this folder',
|
||||
['Enter'], 'trust'),
|
||||
]
|
||||
|
||||
@property
|
||||
def identity_cache_fields(self) -> tuple:
|
||||
return ('conversation_id', 'conversation_db', 'conversation_brain_dir')
|
||||
|
||||
@@ -44,6 +44,19 @@ class ClaudeAgentAdapter(BaseAgentAdapter):
|
||||
def modal_tokens(self) -> Optional[str]:
|
||||
return 'Try the new fullscreen renderer\\?'
|
||||
|
||||
@property
|
||||
def startup_dialogs(self) -> list:
|
||||
# The ladder handle_startup_dialogs used to hardcode, unchanged.
|
||||
return [
|
||||
('Do you trust the files|Yes, I trust this folder|Quick safety check', ['Enter'], 'trust'),
|
||||
# Fullscreen-renderer upsell, not the idle /tui tip: Enter would
|
||||
# accept and restart the session without the permission flags.
|
||||
('Yes, try it', ['Escape'], 'notice'),
|
||||
('Yes, proceed', ['Down', 'Enter'], 'trust'),
|
||||
('Resuming the full session', ['Enter'], 'notice'),
|
||||
('Press Enter to continue', ['Enter'], 'notice'),
|
||||
]
|
||||
|
||||
def artifact_path(self, uuid: str, ctx: DiscoveryContext) -> str:
|
||||
return f"{ctx.claude_dir}/{ctx.ws_key}/{uuid}.jsonl"
|
||||
|
||||
|
||||
@@ -24,6 +24,20 @@ class CodexAgentAdapter(BaseAgentAdapter):
|
||||
def delegate_agent_key(self) -> str:
|
||||
return 'codex-cli'
|
||||
|
||||
@property
|
||||
def startup_dialogs(self) -> list:
|
||||
# #24: the trust prompt lists "1. Yes, continue" preselected, so Enter
|
||||
# accepts. Trust here also loads this project's local config, hooks and
|
||||
# exec policy, which is why --no-auto-trust exists.
|
||||
#
|
||||
# The 0.155.1 update notice (observed 2026-09-22) blocks the session the
|
||||
# same way; "2" is Skip, the choice that changes nothing.
|
||||
return [
|
||||
('Do you trust the contents of this directory|1\\. Yes, continue',
|
||||
['Enter'], 'trust'),
|
||||
('1\\. Update now|Skip until next version', ['2'], 'notice'),
|
||||
]
|
||||
|
||||
@property
|
||||
def identity_cache_fields(self) -> tuple:
|
||||
return ('session_id',)
|
||||
|
||||
@@ -26,6 +26,13 @@ class GrokAgentAdapter(BaseAgentAdapter):
|
||||
def delegate_agent_key(self) -> str:
|
||||
return 'grok-build'
|
||||
|
||||
@property
|
||||
def startup_dialogs(self) -> list:
|
||||
# #24: "Do you trust the contents of this directory?" answered with y.
|
||||
return [
|
||||
('Do you trust the contents of this directory', ['y'], 'trust'),
|
||||
]
|
||||
|
||||
@property
|
||||
def identity_cache_fields(self) -> tuple:
|
||||
return ('session_id', 'session_jsonl')
|
||||
|
||||
@@ -156,6 +156,21 @@ class BaseAgentAdapter:
|
||||
def modal_tokens(self) -> Optional[str]:
|
||||
return None
|
||||
|
||||
@property
|
||||
def startup_dialogs(self) -> List[tuple]:
|
||||
"""Blocking modals this CLI can show before its TUI is usable, most
|
||||
specific first: (pattern, keys, kind).
|
||||
|
||||
`pattern` is an extended-regex signature matched against the pane tail,
|
||||
`keys` are herdr send-keys arguments sent in order, and `kind` is
|
||||
'trust' (a directory-trust prompt, gated by --auto-trust) or 'notice'
|
||||
(anything else that blocks the start, always answered).
|
||||
|
||||
Issue #24: without this the start-up trust prompts of agy, grok and
|
||||
codex burn the whole readiness timeout and onboarding is skipped.
|
||||
"""
|
||||
return []
|
||||
|
||||
@property
|
||||
def strong_ready_tokens(self) -> str:
|
||||
return self.ready_tokens
|
||||
|
||||
@@ -24,6 +24,7 @@ Exit codes:
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
@@ -279,6 +280,19 @@ class _Watcher:
|
||||
# that survives defensive parsing is recorded here, including ones a
|
||||
# different host published. This is the external-observer record that
|
||||
# backstops the publisher's own "published" line if it never wrote one.
|
||||
# Which dispatch this record vouches for. `dispatch_verified` says the
|
||||
# token check above ran (the record had a dispatch_token and the
|
||||
# event's data.dispatch_token matched it) after HMAC verification; on
|
||||
# its own it names no dispatch, and this file is appended to across a
|
||||
# job's attempts, so a reader waiting for a later dispatch would take
|
||||
# an earlier attempt's record as its acknowledgement (review round 4).
|
||||
# `dispatch_fingerprint` is SHA-256 of the token that was verified -
|
||||
# the same identity the local path compares in events.log, minus the
|
||||
# token itself, which the audit redactor masks by key name and which a
|
||||
# 128-bit uuid4 nonce is not recoverable from. `attempt_id` is kept for
|
||||
# readers; it is not the identity, since a token can be re-minted
|
||||
# within an attempt. A reader that demands a token must match the
|
||||
# fingerprint; a record without one proves nothing about identity.
|
||||
mqtt_common.append_event(jid, {
|
||||
"event": "received",
|
||||
"source_event": payload.get("event"),
|
||||
@@ -286,6 +300,11 @@ class _Watcher:
|
||||
"topic": msg.topic,
|
||||
"timestamp": payload.get("timestamp"),
|
||||
"detail": payload.get("detail", ""),
|
||||
"dispatch_verified": expected_disp_tok is not None,
|
||||
"dispatch_fingerprint": (hashlib.sha256(expected_disp_tok.encode("utf-8")).hexdigest()
|
||||
if expected_disp_tok else None),
|
||||
"attempt_id": (data.get("attempt_id") if isinstance(data, dict) and data.get("attempt_id") is not None
|
||||
else self.attempts.get(jid)),
|
||||
})
|
||||
self.events.put((msg.topic, payload))
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import sys
|
||||
from typing import List, Sequence
|
||||
|
||||
# Framework version locked with framework constants
|
||||
FRAMEWORK_VERSION = "4.7.1"
|
||||
FRAMEWORK_VERSION = "4.7.2"
|
||||
|
||||
CORE_MODULES: Sequence[str] = (
|
||||
# Standard library modules
|
||||
|
||||
@@ -19,6 +19,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from typing import Any, Dict, Optional, Sequence
|
||||
|
||||
@@ -38,6 +39,57 @@ TRANSPORT_ERROR = "transport_error"
|
||||
BACKOFF_START_S = 1.0
|
||||
BACKOFF_MAX_S = 16.0
|
||||
|
||||
# ISSUE-23: ids a herdr pane exports about ITS OWN server. Passing them to a
|
||||
# call aimed at a different server makes herdr scope the lookup to a workspace
|
||||
# that does not exist there, and the target is reported as missing. The shell
|
||||
# side drops them in mam_herdr; this is the one python path that runs herdr
|
||||
# without going through it.
|
||||
CALLER_SCOPE_VARS = ("HERDR_WORKSPACE_ID", "HERDR_PANE_ID", "HERDR_TAB_ID")
|
||||
|
||||
|
||||
def caller_server(environ=None):
|
||||
"""The server whose pane this process runs in, "" when not in a pane.
|
||||
|
||||
HERDR_SESSION when present; otherwise the shape of HERDR_SOCKET_PATH, which
|
||||
herdr exports to every pane (HERDR_SESSION is absent in a default-server
|
||||
pane on herdr 0.8.2 - the orchestrator's usual home, and where the #23 leak
|
||||
comes from). Same convention as lib.sh's _herdr_caller_server:
|
||||
|
||||
.../.config/herdr/herdr.sock -> "default"
|
||||
.../.config/herdr/sessions/<name>/herdr.sock -> <name>
|
||||
|
||||
Matched by shape, not by $HOME. Anything else is "" (not in a pane).
|
||||
"""
|
||||
src = os.environ if environ is None else environ
|
||||
sess = (src.get("HERDR_SESSION") or "").strip()
|
||||
if sess:
|
||||
return sess
|
||||
sock = (src.get("HERDR_SOCKET_PATH") or "").strip()
|
||||
parts = sock.split("/")
|
||||
if len(parts) >= 4 and parts[-1] == "herdr.sock":
|
||||
if parts[-3:-1] == [".config", "herdr"]:
|
||||
return "default"
|
||||
if len(parts) >= 5 and parts[-4:-2] == ["herdr", "sessions"] and parts[-5] == ".config":
|
||||
return parts[-2]
|
||||
return ""
|
||||
|
||||
|
||||
def scoped_env(session=None, environ=None):
|
||||
"""The environment for a wait aimed at `session`.
|
||||
|
||||
Unchanged unless this process runs inside a pane of another server: only
|
||||
then are the caller's own workspace/pane/tab ids meaningless for the call
|
||||
and dropped. No pane and same-server keep every value.
|
||||
"""
|
||||
src = os.environ if environ is None else environ
|
||||
caller = caller_server(src)
|
||||
target = (session or "").strip()
|
||||
# `default` is the same sentinel build_argv reads: no --session flag, so
|
||||
# herdr answers on the ambient server - the caller's own.
|
||||
if not caller or not target or target == "default" or caller == target:
|
||||
return dict(src)
|
||||
return dict((k, v) for k, v in src.items() if k not in CALLER_SCOPE_VARS)
|
||||
|
||||
|
||||
class WaitOutcome(object):
|
||||
__slots__ = ("kind", "agent", "error_code", "detail", "raw")
|
||||
@@ -92,7 +144,7 @@ def wait_once(target: str, until: Sequence[str] = DEFAULT_UNTIL,
|
||||
"""One barrier call. `run` is injectable so tests never touch a real herdr."""
|
||||
argv = build_argv(target, until, timeout_ms, session=session)
|
||||
try:
|
||||
proc = run(argv, capture_output=True, text=True)
|
||||
proc = run(argv, capture_output=True, text=True, env=scoped_env(session))
|
||||
except Exception as exc:
|
||||
return WaitOutcome(TRANSPORT_ERROR, detail="herdr could not be executed: {}".format(exc))
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-create
|
||||
description: "Create a new agent session (claude, antigravity/agy) in a dedicated herdr session for context-preserving long-running work. Always creates a herdr session — never backgrounds with nohup/disown. Writes the new session to .mam/agent-sessions.yaml. Use when you want to start a fresh agent (no prior UUID) for a new project workspace."
|
||||
version: 4.7.1
|
||||
version: 4.7.2
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
@@ -60,7 +60,8 @@ If any check fails → abort with a non-zero exit and report the reason (automat
|
||||
- examples: `landing-page-refer-landing-page-creator-claude`, `paper-pdf2md-creator-agy`
|
||||
- never re-derive this by hand — source lib.sh and call the function
|
||||
- **wrapper script** (claude only): `~/.local/bin/<workspace-slug>-creator-claude`
|
||||
- contents: herdr new-session with `claude` inside, auto-handles trust/bypass dialogs
|
||||
- contents: herdr new-session with `claude` inside; trust/bypass dialogs are
|
||||
handled by the skill for every agent (see `--no-auto-trust` below)
|
||||
- see `<workdir>/agent_sessions.md` for the canonical wrapper template
|
||||
|
||||
## Herdr Session Isolation (격리 세션)
|
||||
@@ -97,6 +98,39 @@ The bootstrapped server receives a **sanitized copy of the caller's environment*
|
||||
bash scripts/create_session.sh --workspace /path/to/project --agent claude --role developer --onboard
|
||||
```
|
||||
|
||||
**Delivery is judged by the agent's own `started` event** (#23), within the
|
||||
job's `ack_timeout_sec` (default 180 s) — not by what the pane looked like two
|
||||
seconds after the keys were sent. The script no longer publishes `started` on
|
||||
the agent's behalf, so a job that shows `started` really was picked up. A
|
||||
send_keys rc 4 ("Enter not seen accepted", where a still-starting TUI lands) is
|
||||
a warning; rc 1/2/3 still abort at once. No `started` within the ack timeout
|
||||
marks the job `error` and warns, but **keeps the session**: the pane is up and
|
||||
the row is valid, only the handshake never landed, and killing a live agent
|
||||
over a slow onboarding would destroy more than it protects (the same rule
|
||||
this script applies to a TUI that never signalled ready). Re-send with
|
||||
`multi-agent-mux-delegate-job submit` once the agent is up; the prompt is
|
||||
never re-injected automatically. `MAM_ONBOARD_ACK_S` overrides how long that
|
||||
wait is — distinct from the wrapper's `MAM_INJECT_ACK_GRACE_S`.
|
||||
|
||||
**Start-up dialogs** are answered for every agent, not only claude (#24). The
|
||||
blocking-modal signatures and their accept keys live in each agent adapter
|
||||
(`lib_py/agents/adapters/*.py`, published as `MAM_STARTUP_DIALOGS`), and they
|
||||
are answered while waiting for the TUI rather than after it. `--no-auto-trust`
|
||||
(or `MAM_AUTO_TRUST=0` in the environment; an explicit flag wins over the
|
||||
environment) leaves directory-trust dialogs for a human to accept and still answers other
|
||||
blocking notices (for example codex's update prompt, where Skip changes
|
||||
nothing). Trust matters most for **codex**: trusting a directory makes it load
|
||||
that project's local config, hooks and exec policy, so `--no-auto-trust` is the
|
||||
switch to reach for when the workspace is not yours. Re-sending is rate-limited
|
||||
by `MAM_DIALOG_RESEND_S` (default 5 s) because a `--source visible` snapshot
|
||||
keeps showing a dialog for a while after it was accepted.
|
||||
|
||||
**Exit codes**: 42 = the TUI never became ready and the process is gone;
|
||||
43 = the rollback could not kill the herdr session and it is still running.
|
||||
On 43 the message names the session and the manual cleanup command; the YAML
|
||||
row keeps `status: running` with `last_visible_status: rollback-failed` and no
|
||||
`delegate_job_id`, and the onboarding job is closed as `cancelled`.
|
||||
|
||||
Why use `--herdr-session`?
|
||||
|
||||
- By default, all skills target `default` herdr session socket — fine for single-workspace use.
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
# 2 = invalid args
|
||||
# 3 = herdr session already exists (use multi-agent-mux-resume or delete first)
|
||||
# 4 = agent-sessions.yaml append failure
|
||||
# 42 = agent TUI never became ready and the process is gone
|
||||
# 43 = rollback could not kill the herdr session; it is still running (#23)
|
||||
set -euo pipefail
|
||||
|
||||
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -43,6 +45,7 @@ Options:
|
||||
use --herdr-session for that.
|
||||
--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-auto-trust do not answer a start-up directory-trust dialog (default: answer it)
|
||||
--no-onboard disable automatic onboarding job submission
|
||||
--no-isolate legacy flag (no-op; all sessions use global configuration)
|
||||
--isolate legacy flag (no-op; all sessions use global configuration)
|
||||
@@ -60,6 +63,13 @@ HERDR_SERVER_OPT=""
|
||||
HERDR_WORKSPACE_OPT=""
|
||||
SUBMIT_JOB_PROMPT=""
|
||||
ONBOARD=1
|
||||
# Issue #24: answer a start-up directory-trust dialog for the --workspace the
|
||||
# operator named. --no-auto-trust leaves those unanswered (notices are always
|
||||
# answered); codex in particular loads project-local config, hooks and exec
|
||||
# policy once trusted. MAM_AUTO_TRUST=0 in the environment is the same switch
|
||||
# (the skill docs say so); an explicit --auto-trust/--no-auto-trust wins over it
|
||||
# (merge review, codex probe: the export below used to overwrite the caller's 0).
|
||||
AUTO_TRUST="${MAM_AUTO_TRUST:-1}"
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
@@ -74,6 +84,8 @@ while [ $# -gt 0 ]; do
|
||||
--submit-job) SUBMIT_JOB_PROMPT="$2"; shift 2 ;;
|
||||
--onboard) ONBOARD=1; shift ;;
|
||||
--no-onboard) ONBOARD=0; shift ;;
|
||||
--auto-trust) AUTO_TRUST=1; shift ;;
|
||||
--no-auto-trust) AUTO_TRUST=0; shift ;;
|
||||
--isolate) echo "NOTE: --isolate/--no-isolate is a no-op — config-home isolation was removed; sessions always use global config." >&2; shift ;; # legacy compatibility
|
||||
--no-isolate) echo "NOTE: --isolate/--no-isolate is a no-op — config-home isolation was removed; sessions always use global config." >&2; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
@@ -81,6 +93,8 @@ while [ $# -gt 0 ]; do
|
||||
esac
|
||||
done
|
||||
|
||||
export MAM_AUTO_TRUST="$AUTO_TRUST"
|
||||
|
||||
if [ -n "$HERDR_SERVER_OPT" ]; then
|
||||
export HERDR_SESSION_NAME="$HERDR_SERVER_OPT"
|
||||
fi
|
||||
@@ -273,13 +287,67 @@ NOW_ISO=$(date -u +'%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
spawn
|
||||
|
||||
# _mark_row_rolled_back <status> <note>
|
||||
# ISSUE-23: the YAML row and the job registration are both written before the
|
||||
# prompt is injected, so a rollback that only kills the pane leaves a row that
|
||||
# says `running` pointing at a failed job - which is what status.sh keeps
|
||||
# reporting (symptom 5). Mark the row instead of deleting it: a deleted row
|
||||
# takes the evidence with it.
|
||||
#
|
||||
# <status> stays inside the schema's vocabulary (running/terminated/archived/
|
||||
# stopped - atomic_yaml.py rejects anything else): `terminated` when the
|
||||
# session is really gone, and `running` when the kill failed, because it IS
|
||||
# still running and saying otherwise would be the same lie in reverse. What
|
||||
# makes the failed case legible is the note, the cleared job id and exit 43.
|
||||
_mark_row_rolled_back() {
|
||||
local status="$1" note="$2"
|
||||
[ "${ROW_APPENDED:-0}" = "1" ] || return 0
|
||||
atomic_dump_yaml "$AGENT_SESSIONS_YAML" \
|
||||
SESSION_NAME="$SESSION_NAME" ROLLBACK_STATUS="$status" ROLLBACK_NOTE="$note" <<'PYEOF' || true
|
||||
name = os.environ['SESSION_NAME']
|
||||
status = os.environ['ROLLBACK_STATUS']
|
||||
note = os.environ['ROLLBACK_NOTE']
|
||||
for s in d.get('herdr_sessions', []):
|
||||
if s.get('name') == name:
|
||||
s['status'] = status
|
||||
s['last_visible_status'] = note
|
||||
s['delegate_job_id'] = None
|
||||
break
|
||||
PYEOF
|
||||
}
|
||||
|
||||
# Trap for rolling back/cleaning up herdr session if script exits due to error
|
||||
cleanup_herdr_on_error() {
|
||||
local exit_code=$?
|
||||
if [ $exit_code -ne 0 ]; then
|
||||
[ $exit_code -eq 0 ] && return 0
|
||||
echo "⚠️ Error occurred during initialization (exit code $exit_code). Rolling back and killing herdr session '$SESSION_NAME'..." >&2
|
||||
_herdr kill-session -t "$SESSION_NAME" 2>/dev/null || true
|
||||
|
||||
# ISSUE-23: the kill result used to be discarded (`2>/dev/null || true`), so
|
||||
# a kill that closed nothing still printed "rolled back" while the session
|
||||
# kept running. Check it, then confirm with has-session.
|
||||
local kill_rc=0
|
||||
_herdr kill-session -t "$SESSION_NAME" || kill_rc=$?
|
||||
local alive=0
|
||||
_herdr has-session -t "$SESSION_NAME" >/dev/null 2>&1 && alive=1
|
||||
|
||||
# Release the onboarding job: `cancelled` is a terminal transition the
|
||||
# registry knows, and it says what happened - the job was never really
|
||||
# dispatched. Best effort; a failure here must not stop the rollback.
|
||||
# `completed`/`error`/`cancelled` are terminal and published exactly once, so
|
||||
# a job this run already closed is left alone - a second one is refused by
|
||||
# the registry anyway ("conflicting cancelled after terminal error").
|
||||
if [ -n "${DELEGATE_JOB_ID:-}" ] && [ "${JOB_TERMINALIZED:-0}" != "1" ]; then
|
||||
delegate_publish_event "$DELEGATE_JOB_ID" cancelled "rolled back by create_session" "${DELEGATE_JOB_TOKEN:-}" "${DELEGATE_JOB_ATTEMPT:-}" || true
|
||||
JOB_TERMINALIZED=1
|
||||
fi
|
||||
|
||||
if [ "$alive" = "1" ]; then
|
||||
_mark_row_rolled_back "running" "rollback-failed"
|
||||
echo "ERROR: rollback could not kill herdr session '$SESSION_NAME' (kill rc=$kill_rc) - it is still running." >&2
|
||||
echo " Clean it up with: HERDR_SESSION_NAME=${HERDR_SESSION_NAME:-default} herdr kill-session -t $SESSION_NAME" >&2
|
||||
exit 43
|
||||
fi
|
||||
_mark_row_rolled_back "terminated" "rolled-back"
|
||||
}
|
||||
trap cleanup_herdr_on_error EXIT
|
||||
|
||||
@@ -331,8 +399,11 @@ if [ "$TUI_READY_RC" -ne 0 ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$AGENT" = "claude" ] && [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then
|
||||
handle_startup_dialogs "$SESSION_NAME" 15
|
||||
# Issue #24: every agent, not only claude. The signatures and accept keys come
|
||||
# from the agent adapter, so an agy/grok/codex trust prompt is answered here
|
||||
# too instead of sitting until the readiness wait gives up.
|
||||
if [ -z "$LAST_VISIBLE_STATUS_OVERRIDE" ]; then
|
||||
handle_startup_dialogs "$SESSION_NAME" 15 "$AGENT"
|
||||
fi
|
||||
|
||||
# pane 메타 캡처
|
||||
@@ -377,6 +448,7 @@ if [ -n "$SUBMIT_JOB_PROMPT" ]; then
|
||||
echo "Submitted delegated job: $DELEGATE_JOB_ID"
|
||||
DELEGATE_JOB_TOKEN=""
|
||||
DELEGATE_JOB_ATTEMPT=""
|
||||
DELEGATE_JOB_ACK_S=""
|
||||
if [ -n "$DELEGATE_JOB_ID" ]; then
|
||||
local_info=$(python3 -c "
|
||||
import sys, json, os
|
||||
@@ -389,13 +461,15 @@ if os.path.exists(p):
|
||||
d = json.load(f)
|
||||
tok = d.get('dispatch_token', '')
|
||||
att = d.get('attempt_id', d.get('iteration', 1))
|
||||
print(f'{tok} {att}')
|
||||
ack = d.get('ack_timeout_sec', 180)
|
||||
print(f'{tok} {att} {ack}')
|
||||
except Exception:
|
||||
pass
|
||||
" 2>/dev/null || true)
|
||||
if [ -n "$local_info" ]; then
|
||||
DELEGATE_JOB_TOKEN=$(echo "$local_info" | awk '{print $1}')
|
||||
DELEGATE_JOB_ATTEMPT=$(echo "$local_info" | awk '{print $2}')
|
||||
DELEGATE_JOB_ACK_S=$(echo "$local_info" | awk '{print $3}')
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
@@ -534,6 +608,7 @@ snap['taken_at'] = os.environ['NOW_ISO']
|
||||
snap['cwd'] = os.environ['PANE_CWD']
|
||||
print(f"appended: {name}", flush=True)
|
||||
PYEOF
|
||||
ROW_APPENDED=1
|
||||
|
||||
echo
|
||||
echo "=== created ==="
|
||||
@@ -541,22 +616,12 @@ echo "herdr session: $SESSION_NAME (pane pid $PANE_PID, cmd $PANE_CMD, cwd $PANE
|
||||
if [ -n "$DELEGATE_JOB_ID" ]; then
|
||||
echo "delegate job: $DELEGATE_JOB_ID"
|
||||
|
||||
# Construct instructions matching multi-agent-mux-delegate-job format
|
||||
py_bin="$(_delegate_py_bin)"
|
||||
# Issue #21: absolute script, registry and audit-log paths (shell-quoted), so
|
||||
# an agent whose cwd is a git worktree can run the command as written.
|
||||
pub_script="$WORKSPACE_ROOT/.agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py"
|
||||
[ -f "$pub_script" ] || pub_script="$(_delegate_script publish_event.py)"
|
||||
pub_script="$(cd -P "$(dirname "$pub_script")" && pwd -P)/$(basename "$pub_script")"
|
||||
pub_reg="${MAM_JOBS_DIR:-${WORKSPACE_ROOT:+$WORKSPACE_ROOT/.mam/jobs}}"
|
||||
pub_reg="${pub_reg:-.mam/jobs}"
|
||||
mkdir -p "$pub_reg" && pub_reg="$(cd -P "$pub_reg" && pwd -P)"
|
||||
pub_logs="$(PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$py_bin" -c 'import os; from lib_py.delegation.mqtt_common import get_logs_dir; print(os.path.abspath(get_logs_dir()))' 2>/dev/null || true)"
|
||||
pub="$(
|
||||
if [ -n "$pub_logs" ]; then printf 'DELEGATE_JOB_LOGS_DIR=%q ' "$pub_logs"; fi
|
||||
printf '%q ' "$py_bin" "$pub_script" --registry-dir "$pub_reg" --job "$DELEGATE_JOB_ID"
|
||||
)"
|
||||
pub="${pub% }"
|
||||
# The publish command the agent will run: the same shape the delegate-job
|
||||
# wrapper hands its agents, from the one builder in lib.sh. The dispatch
|
||||
# token and attempt id are what make the agent's own `started` name THIS
|
||||
# dispatch (trial-2 finding: without them the token-checking ack below could
|
||||
# never be satisfied by a real agent, and every onboarding sat out 180 s).
|
||||
pub="$(delegate_publish_cmd "$DELEGATE_JOB_ID" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT")"
|
||||
instructions="Your job_id is \"$DELEGATE_JOB_ID\" (the one just registered for THIS delegation).
|
||||
|
||||
On start run: $pub --event started.
|
||||
@@ -569,12 +634,56 @@ Task: $SUBMIT_JOB_PROMPT"
|
||||
# Inject instructions into the herdr pane
|
||||
rc=0
|
||||
inject_instructions "$SESSION_NAME" "$instructions" "$DELEGATE_JOB_ID" || rc=$?
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
# ISSUE-23: rc 4 is "Enter was not seen to be accepted within the send_keys
|
||||
# window", which is where a TUI that is still finishing its start-up lands.
|
||||
# It is a warning now; the agent's own `started` decides. rc 1/2/3 are still
|
||||
# failures: they say the pane never quiesced, a dialog was blocking, or the
|
||||
# text never appeared - none of which a later `started` can repair.
|
||||
if [ "$rc" -ne 0 ] && [ "$rc" -ne 4 ]; then
|
||||
delegate_publish_event "$DELEGATE_JOB_ID" error "instruction injection failed (prompt-lock, rc=$rc)" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
|
||||
JOB_TERMINALIZED=1
|
||||
exit 1
|
||||
fi
|
||||
if [ "$rc" -eq 4 ]; then
|
||||
echo "WARNING: Enter was not observed to be accepted for '$SESSION_NAME' (rc=4); waiting for the agent's own started event instead." >&2
|
||||
fi
|
||||
|
||||
delegate_publish_event "$DELEGATE_JOB_ID" started "multi-agent-mux session created and instructions injected" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
|
||||
# ISSUE-23: this script no longer publishes `started` on the agent's behalf.
|
||||
# It used to, which made the registry show a started job even when the agent
|
||||
# never saw the prompt, and left any started-based verdict meaningless.
|
||||
#
|
||||
# How long to wait for the handshake: MAM_ONBOARD_ACK_S, else the wrapper's
|
||||
# MAM_INJECT_ACK_GRACE_S, else the job's own ack timeout. The create-specific
|
||||
# variable comes first so this wait can be shortened - in a sandbox with no
|
||||
# real agent, for instance - without also retuning the delegate-job
|
||||
# wrapper's injection ack, which is a different wait with its own tests.
|
||||
ONBOARD_ACK_S="${MAM_ONBOARD_ACK_S:-${MAM_INJECT_ACK_GRACE_S:-${DELEGATE_JOB_ACK_S:-180}}}"
|
||||
if ! delegate_wait_started "$DELEGATE_JOB_ID" "$ONBOARD_ACK_S" "$DELEGATE_JOB_TOKEN"; then
|
||||
# The job failed, the session did not: the pane is up and the row is valid,
|
||||
# only the handshake never landed. Killing a live agent over a slow or
|
||||
# silent onboarding would destroy more than it protects, and it would
|
||||
# contradict this script's own rule for the same situation one step
|
||||
# earlier - a TUI that never signalled ready is preserved for diagnosis,
|
||||
# not rolled back. So: mark the job, warn loudly, keep the session.
|
||||
# MULTI_AGENT_RULES §6 has the GM gate on this job's `completed` anyway,
|
||||
# so an unacknowledged onboarding already blocks real work.
|
||||
# A job that is already terminal is not marked again: `error` over a
|
||||
# `completed` is refused by the registry anyway (trial-2 log: "conflicting
|
||||
# error after terminal completed"), and a job the agent has finished is
|
||||
# not an undelivered one - only the evidence was missing.
|
||||
ONBOARD_JOB_ST="$(delegate_job_status "$DELEGATE_JOB_ID")"
|
||||
case "$ONBOARD_JOB_ST" in
|
||||
completed|error|cancelled)
|
||||
echo "WARNING: no started event naming this dispatch for '$SESSION_NAME' within ${ONBOARD_ACK_S}s, but job $DELEGATE_JOB_ID is already '$ONBOARD_JOB_ST'; leaving it as it is." >&2
|
||||
;;
|
||||
*)
|
||||
delegate_publish_event "$DELEGATE_JOB_ID" error "onboarding delivery unconfirmed: no started within ${ONBOARD_ACK_S}s" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
|
||||
echo "WARNING: '$SESSION_NAME' did not acknowledge the onboarding prompt within ${ONBOARD_ACK_S}s." >&2
|
||||
echo " The session is preserved and job $DELEGATE_JOB_ID is marked error; re-send the onboarding with multi-agent-mux-delegate-job submit once the agent is up." >&2
|
||||
;;
|
||||
esac
|
||||
JOB_TERMINALIZED=1
|
||||
fi
|
||||
# Trigger immediate priority reconcile cycle asynchronously
|
||||
# Issue #21 phase 2 (A8): the control root's reconcile/watchdog, never the
|
||||
# (unreviewed) copy in a worktree --workspace; identical paths by default.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-delegate-job
|
||||
description: "Delegate a unit of work to any autonomous agent (claude-code, hermes, agy, grok-build, codex, or a human) and observe it asynchronously over an MQTT event channel. Supported roles include orchestrator, worker, and reviewer."
|
||||
version: 4.7.1
|
||||
version: 4.7.2
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos, windows]
|
||||
|
||||
@@ -633,41 +633,33 @@ _disk_status() {
|
||||
# ISSUE-18: delivery is confirmed by the protocol, not the screen. After the
|
||||
# prompt was handed over (herdr accepted the submission, or the dispatch was
|
||||
# published for a remote session) the job's own `started` must arrive within
|
||||
# the ack timeout T: the registry leaves `pending` (the agent's publish_event
|
||||
# syncs it on this host), a started line lands in events.log, or the
|
||||
# subscriber prints a started line (a remote agent - no shared disk). No
|
||||
# `started` within T is a delivery failure: fail closed, never re-inject
|
||||
# (single-injection contract). The status is not pinned to this dispatch and
|
||||
# may come from an external writer; the terminal handling after this still
|
||||
# keeps such an error/cancelled as it is rather than turning it into success.
|
||||
# the ack timeout T. The evidence is lib.sh's delegate_started_recorded - the
|
||||
# same predicate create_session's onboarding ack uses, so the two waits share
|
||||
# one contract (ack-finding req. 2, review R1): the local publisher's line in
|
||||
# events.log, or the subscriber's HMAC-verified, dispatch-attributed audit
|
||||
# record of a `started` it received (a remote agent has no shared disk). The
|
||||
# registry status is not evidence - a record leaves `pending` when another
|
||||
# writer closes it (error, cancelled) or another dispatch claims it, none of
|
||||
# which says this agent saw this prompt. No `started` within T is a delivery
|
||||
# failure: fail closed, never re-inject (single-injection contract). A job
|
||||
# that is already terminal is handled by the terminal logic after this, not
|
||||
# turned into a success here.
|
||||
_wait_started() {
|
||||
local job_id="$1" ack_s="${2:-$ACK_TIMEOUT}" sub_log="${3:-}" deadline wall st
|
||||
local job_id="$1" ack_s="${2:-$ACK_TIMEOUT}" sub_log="${3:-}" deadline wall
|
||||
# sub_log is retained for callers' signatures; the subscriber's evidence is
|
||||
# read from its audit record, which is attributable, not from its console.
|
||||
deadline=$(( $(date +%s) + ack_s ))
|
||||
# The ack wait never outlives this dispatch's wall clock (START_TIME + TIMEOUT).
|
||||
wall=$(( ${START_TIME:-$(date +%s)} + ${TIMEOUT:-3600} ))
|
||||
[ "$deadline" -gt "$wall" ] && deadline=$wall
|
||||
local ev_log="$REGISTRY_DIR/$job_id.events.log"
|
||||
# A loop job keeps one events.log across iterations: only a `started` that
|
||||
# carries THIS attempt's dispatch token counts (review R1).
|
||||
local started_re='"event": *"started"'
|
||||
[ -n "${_disp_tok:-}" ] && started_re='"event": *"started".*"dispatch_token": *"'"$_disp_tok"'"'
|
||||
local budget=$(( deadline - $(date +%s) ))
|
||||
while :; do
|
||||
st="$(_disk_status "$job_id")"
|
||||
if [ -n "$st" ] && [ "$st" != "pending" ]; then
|
||||
echo "inject: job $job_id advanced to '$st' in the registry; treating the prompt as delivered and continuing." >&2
|
||||
return 0
|
||||
fi
|
||||
# publish_event records `started` here before its wire publish and before
|
||||
# the status sync, so a slow broker cannot delay the acknowledgement.
|
||||
if [ -f "$ev_log" ] && grep -Eq "$started_re" "$ev_log" 2>/dev/null; then
|
||||
# publish_event records `started` locally before its wire publish and
|
||||
# before the status sync, so a slow broker cannot delay the acknowledgement.
|
||||
if MAM_JOBS_DIR="$REGISTRY_DIR" delegate_started_recorded "$job_id" "${_disp_tok:-}" "${LOGS_DIR_ABS:-}"; then
|
||||
echo "inject: job $job_id recorded 'started' in events.log; treating the prompt as delivered and continuing." >&2
|
||||
return 0
|
||||
fi
|
||||
if [ -n "$sub_log" ] && [ -f "$sub_log" ] && grep -Eq '^[^ ]+ job=[^ ]+ seq=[^ ]+ started( |$)' "$sub_log" 2>/dev/null; then
|
||||
echo "inject: subscriber received started for job $job_id; treating the prompt as delivered and continuing." >&2
|
||||
return 0
|
||||
fi
|
||||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||
ACK_WAITED_S="$budget"
|
||||
return 1
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-loop
|
||||
description: "Run an autonomous planning-execution-review loop using multiple agents (Planner, Creator, Reviewers) in the workspace. Automatically orchestrates plan discussion, code changes, and peer reviews until a unanimous PASS is achieved or the maximum iteration limit is reached."
|
||||
version: 4.7.1
|
||||
version: 4.7.2
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-monitor
|
||||
description: "Run a long-lived reconciler that watches .mam/agent-sessions.yaml against the actual herdr/agent runtime state and reconciles them. Use when you want live visibility into which agent sessions are running, which are dead, which have stale YAML entries, and which have new session ids that haven't been recorded yet. Runs as a persistent loop (`reconcile.sh --subscribe`) that keeps going until it times out, idles out, or is interrupted."
|
||||
version: 4.7.1
|
||||
version: 4.7.2
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-orc-onboard
|
||||
description: "Register current or specified orchestrator session UUID into agent-sessions.yaml orchestrator_uuids list to prevent sub-agent discovery capture."
|
||||
version: 4.7.1
|
||||
version: 4.7.2
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-resume
|
||||
description: "Resume an existing agent (claude, antigravity/agy) conversation by UUID into a herdr session. Reads .mam/agent-sessions.yaml for the saved session/conversation id, spawns (or reuses) a herdr session of the matching name, and runs `claude -r <id>` or `agy --conversation <id>` inside. Use when you want to reattach to a previous session's context, or revive a session whose herdr died but the agent's conversation is still on disk."
|
||||
version: 4.7.1
|
||||
version: 4.7.2
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
@@ -101,17 +101,11 @@ case "$AGENT" in
|
||||
esac
|
||||
|
||||
# 4. Spawn new herdr session + run agent with the saved id
|
||||
case "$AGENT" in
|
||||
claude)
|
||||
START_CMD="herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
|
||||
eval "$START_CMD"
|
||||
# auto-handle trust / bypass dialogs
|
||||
handle_startup_dialogs "$SESSION_NAME" 20
|
||||
;;
|
||||
agy|hermes|grok|opencode|codex)
|
||||
eval "herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
|
||||
;;
|
||||
esac
|
||||
eval "herdr new-session -d -s \"$SESSION_NAME\" -x 140 -y 40 -c \"$WORKSPACE\" \"$CMD_FULL\""
|
||||
# Issue #24: start-up dialogs (trust prompts, blocking notices) are answered for
|
||||
# every agent from the adapter's signatures - the same call create makes.
|
||||
# --no-auto-trust leaves a directory-trust dialog to a human (MAM_AUTO_TRUST=0).
|
||||
handle_startup_dialogs "$SESSION_NAME" 20 "$AGENT"
|
||||
|
||||
# 4. Update agent-sessions.yaml: status running, last_visible_status
|
||||
# (Also automatically publishes a `progress --detail "resumed"` event to the multi-agent-mux-delegate-job registry if a delegate_job_id exists)
|
||||
|
||||
@@ -13,6 +13,8 @@ Usage: $0 --workspace <path> --agent <claude|agy|hermes|grok|opencode|codex> --s
|
||||
|
||||
Options:
|
||||
--herdr-session NAME specify isolated herdr session name (alias: --herdr-server)
|
||||
--no-auto-trust do not answer a start-up directory-trust dialog (default: answer it;
|
||||
other blocking notices are always answered) - same switch as create
|
||||
--dry-run Simulates resume flow (resolves binary, environment) without writing
|
||||
any updates to YAML or DB. Safe to execute inside active write transactions.
|
||||
EOF
|
||||
@@ -25,6 +27,10 @@ HERDR_SERVER_OPT=""
|
||||
HERDR_WORKSPACE_OPT=""
|
||||
|
||||
DRY_RUN=0
|
||||
# Issue #24: a resumed CLI shows the same directory-trust dialog as a fresh
|
||||
# one, so resume takes the same switch as create (MAM_AUTO_TRUST is what
|
||||
# handle_startup_dialogs reads).
|
||||
AUTO_TRUST="${MAM_AUTO_TRUST:-1}" # the environment form of the same switch; a flag wins
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
@@ -33,6 +39,8 @@ while [ $# -gt 0 ]; do
|
||||
--session) SESSION_NAME="$2"; shift 2 ;;
|
||||
--herdr-session|--herdr-server) HERDR_SERVER_OPT="$2"; shift 2 ;;
|
||||
--herdr-workspace) HERDR_WORKSPACE_OPT="$2"; shift 2 ;;
|
||||
--auto-trust) AUTO_TRUST=1; shift ;;
|
||||
--no-auto-trust) AUTO_TRUST=0; shift ;;
|
||||
--dry-run) DRY_RUN=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "ERROR: unknown arg: $1" >&2; exit 2 ;;
|
||||
@@ -46,6 +54,7 @@ case "$AGENT" in
|
||||
*) echo "ERROR: unsupported agent: $AGENT" >&2; exit 2 ;;
|
||||
esac
|
||||
[ -n "$SESSION_NAME" ] || { echo "ERROR: --session required" >&2; exit 2; }
|
||||
export MAM_AUTO_TRUST="$AUTO_TRUST"
|
||||
|
||||
# Issue #21 phase 2 (A11): a session resumes only in the directory it ran in
|
||||
# (its conversation is keyed by that cwd; a class A agent would otherwise start
|
||||
@@ -226,10 +235,10 @@ fi
|
||||
|
||||
# 4. Spawn new agent session (delegates to herdr translation shim)
|
||||
_herdr new-session -d -s "$SESSION_NAME" -c "$WORKSPACE" "$CP_PREFIX$CMD_FULL"
|
||||
if [ "$AGENT" = "claude" ]; then
|
||||
# auto-handle trust / bypass dialogs
|
||||
handle_startup_dialogs "$SESSION_NAME" 20
|
||||
fi
|
||||
# Issue #24: every agent, the same call create makes. The signatures and accept
|
||||
# keys come from the agent adapter; this used to run only for claude, so a
|
||||
# resumed agy/grok/codex sat on its trust prompt.
|
||||
handle_startup_dialogs "$SESSION_NAME" 20 "$AGENT"
|
||||
|
||||
# Wait for TUI readiness or let it settle
|
||||
sleep 2
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-status
|
||||
description: "Read-only instant snapshot of all agent herdr sessions — name, YAML status, herdr alive, pane cmd/cwd, resume UUID on disk, and any drift. No mutation. Reuses reconcile.sh --dry-run for the diff logic. Use when you want to know 'what's running RIGHT NOW' without spinning up the monitor loop."
|
||||
version: 4.7.1
|
||||
version: 4.7.2
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: multi-agent-mux-stop
|
||||
description: "Stop an agent herdr session (claude, antigravity/agy) and update .mam/agent-sessions.yaml. Default stops gracefully and marks status=stopped with conversation preserved for resume. Does NOT delete on-disk conversation artifacts (jsonl/db) — those are preserved unless --purge-conversation is passed. Use when ending a work session, switching to a different one, or cleaning up before a fresh start."
|
||||
version: 4.7.1
|
||||
version: 4.7.2
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
본 문서는 `herdr_agent_orchestration` 오케스트레이션 및 메시징 백플레인 워크플로우를 새로운 프로젝트에 도입하여 이식하고, 새로운 개발자/에이전트가 초기 가동을 시작할 때 수행해야 하는 환경 설정 및 구축 절차를 안내합니다.
|
||||
|
||||
[](VERSIONS.md)
|
||||
[](VERSIONS.md)
|
||||
|
||||
새로운 에이전트는 이 안내서에 기술된 절차를 순차적으로 실행하여 초기 환경을 안정적으로 설정할 수 있습니다.
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
This document guides you through the setup and initialization procedures required to adopt the `herdr_agent_orchestration` orchestration and messaging backplane workflow in a new project, enabling a new developer or agent to get up and running quickly.
|
||||
|
||||
[](VERSIONS.md)
|
||||
[](VERSIONS.md)
|
||||
|
||||
A new agent can follow the steps in this guide sequentially to establish a stable and reliable initial environment.
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Messaging System Technical Analysis & Architecture Report
|
||||
|
||||
Applies to: MAM v4.7.1
|
||||
Applies to: MAM v4.7.2
|
||||
|
||||
This report provides a comprehensive, deep-dive analysis of the messaging system implemented in the `multi-agent-mux-delegate-job` skill. It covers the MQTT broker architecture, event protocols, job lifecycles, codebase internals, cross-system integration, and a list of known limitations along with production recommendations.
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
Herdr와 MQTT 브로커를 기반으로 구축된 고신뢰성 **다중 에이전트 오케스트레이션 및 메시징 백플레인** 프레임워크입니다. `claude`, `agy`, `hermes`, `grok`, `opencode`, `codex` 등 다중 LLM 백엔드 에이전트 전반에 걸쳐 장시간 수행되는 작업(코드 생성, 리팩토링, 보안 검토 등)을 조정, 격리 및 감사할 수 있도록 설계되었습니다.
|
||||
|
||||
[](VERSIONS.md)
|
||||
[](VERSIONS.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
An advanced, high-reliability **Multi-Agent Orchestration & Messaging Backplane** framework built on Herdr and MQTT. It is designed to coordinate, isolate, and audit long-running agent tasks (such as code generation, refactoring, and security reviews) across multiple LLM backend clients (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`).
|
||||
|
||||
[](VERSIONS.md)
|
||||
[](VERSIONS.md)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+29
-11
@@ -6,8 +6,8 @@
|
||||
|
||||
## 📌 현재 버전 개요 (Current Release)
|
||||
|
||||
- **프레임워크 버전**: `v4.7.1`
|
||||
- **최신 릴리스 일시**: 2026-09-22 (KST)
|
||||
- **프레임워크 버전**: `v4.7.2`
|
||||
- **최신 릴리스 일시**: 2026-09-23 (KST)
|
||||
- **기준 브랜치**: `main`
|
||||
- **핵심 아키텍처**:
|
||||
- **Terminal 상태 단방향 불변성 (`engine.py`, Issue #6 Phase 1)**: `completed`, `error`, `cancelled` 터미널 상태 진입 후 후속 이벤트나 업데이트에 의한 덮어쓰기를 엄격히 차단(Terminal Immutability). `registry.py close` 서브커맨드 및 fail-closed 조기 종료 도입.
|
||||
@@ -22,23 +22,41 @@
|
||||
|
||||
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
|
||||
|
||||
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.7.1`로 동기화되어 배포됩니다.
|
||||
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.7.2`로 동기화되어 배포됩니다.
|
||||
|
||||
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
|
||||
| :--- | :---: | :--- | :---: |
|
||||
| **`multi-agent-mux-create`** | `4.7.1` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
|
||||
| **`multi-agent-mux-stop`** | `4.7.1` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
|
||||
| **`multi-agent-mux-resume`** | `4.7.1` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
|
||||
| **`multi-agent-mux-status`** | `4.7.1` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
|
||||
| **`multi-agent-mux-monitor`** | `4.7.1` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
|
||||
| **`multi-agent-mux-delegate-job`** | `4.7.1` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
|
||||
| **`multi-agent-mux-loop`** | `4.7.1` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
|
||||
| **`multi-agent-mux-orc-onboard`** | `4.7.1` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
|
||||
| **`multi-agent-mux-create`** | `4.7.2` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
|
||||
| **`multi-agent-mux-stop`** | `4.7.2` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
|
||||
| **`multi-agent-mux-resume`** | `4.7.2` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
|
||||
| **`multi-agent-mux-status`** | `4.7.2` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
|
||||
| **`multi-agent-mux-monitor`** | `4.7.2` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
|
||||
| **`multi-agent-mux-delegate-job`** | `4.7.2` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
|
||||
| **`multi-agent-mux-loop`** | `4.7.2` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
|
||||
| **`multi-agent-mux-orc-onboard`** | `4.7.2` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
|
||||
|
||||
---
|
||||
|
||||
## 📋 버전별 상세 변경 내역 (Changelog)
|
||||
|
||||
### 🚀 `v4.7.2` — Issue #23·#24 세션 생성 경로: 교차 서버 herdr 스코프, 에이전트 무관 시작 다이얼로그, 실제 에이전트가 충족하는 온보딩 전달 판정 (2026-09-23, PR #35)
|
||||
|
||||
> **패치 마일스톤 (PATCH Release)**: `create_session.sh` 로 새 세션을 만드는 첫 1분 안의 결함 둘을 고쳤다. 오케스트레이터 pane 이 물려주는 herdr 환경(`HERDR_WORKSPACE_ID`·`HERDR_PANE_ID`·`HERDR_TAB_ID`)이 격리 서버 조회를 오염시켜 온보딩 프롬프트가 전달되지 않고, 전달 판정이 화면 기준이라 살아 있는 에이전트를 실패로 판정한 뒤 롤백까지 실패하던 문제(#23)와, 시작 신뢰(trust) 다이얼로그를 claude 에게만 처리해 agy·grok·codex 가 준비 대기 초과로 온보딩을 건너뛰던 문제(#24)다. 옵션 하나(`--no-auto-trust`)와 종료 코드 둘(42·43)이 늘지만 잘못된 기본 동작을 고치는 수정이라 PATCH 로 올린다.
|
||||
|
||||
#### 🌟 주요 개선 사항
|
||||
1. **시작 다이얼로그가 에이전트 무관 (#24, `lib_py/agents/base.py: startup_dialogs`, 어댑터 4종, `lib.sh: handle_startup_dialogs`)**: 모달의 시그니처·수락 키·종류(`trust`/`notice`)를 어댑터가 제공하고(`MAM_STARTUP_DIALOGS`), TUI 준비 대기 중 감지되면 수락한다. agy(Enter)·grok(`y`)·codex(`1. Yes, continue` Enter)의 신뢰 창과 codex 0.155.1 업데이트 안내(Skip)를 처리한다. 수락 판정은 다이얼로그 시그니처가 아니라 준비 토큰으로 한다(`--source visible` 이 수락된 창을 한동안 계속 보여준다). `--no-auto-trust` / `MAM_AUTO_TRUST=0` 은 신뢰 창만 사람에게 남기고 안내는 계속 처리한다(codex 의 trust 는 프로젝트 로컬 설정·훅·exec 정책을 로드한다). resume 도 create 와 같은 스위치·계약이다.
|
||||
2. **교차 서버 호출의 스코프 제거 (#23, `lib.sh`)**: herdr pane 이 내보내는 세 id 는 발급한 서버에서만 뜻이 있으므로 **다른 서버를 겨냥한 호출에서만** `env -u` 로 제거한다. 호출자 서버는 `HERDR_SESSION` 이 있으면 그것, 없으면 `HERDR_SOCKET_PATH` 의 모양에서 구한다(herdr 0.8.2 는 기본 서버 pane 에 `HERDR_SESSION` 을 내보내지 않는다).
|
||||
3. **온보딩 전달 판정 = 에이전트 자신의 `started` (#23, `lib.sh: delegate_publish_cmd / delegate_started_recorded / delegate_wait_started`, `create_session.sh`, delegate-job 래퍼)**: create 는 더 이상 에이전트 대신 `started` 를 발행하지 않는다. 발행 명령 빌더 하나가 create 온보딩과 래퍼에 같은 모양(dispatch_token·attempt 포함)을 주고, `started` 판정 하나를 두 대기 지점이 공유한다. 이번 dispatch 의 `started` 만 증거이며(로컬: events.log 의 dispatch_token, 원격: 구독자가 HMAC·토큰 검증 뒤 감사 기록에 남기는 `dispatch_fingerprint`(검증한 토큰의 SHA-256)+attempt), 신원 없는 과거 기록은 fail closed 다. 래퍼의 "레코드 상태만 보고 먼저 성공" 우회는 사라졌다. send_keys 의 rc 4(Enter 수락 미관측)는 경고로 낮추고 에이전트의 `started` 가 결정한다.
|
||||
4. **실패 처리 (#23, `create_session.sh`)**: 롤백이 kill 결과를 확인하고 `has-session` 으로 재확인한다. 못 죽였으면 행에 `rollback-failed` 를 적고 exit 43. 온보딩이 `MAM_ONBOARD_ACK_S`(기본: 래퍼의 주입 ack 유예 → 잡의 ack timeout 180) 안에 확인되지 않아도 세션은 보존하고 잡만 error 로 표시한다(살아 있는 에이전트를 느린 온보딩 때문에 죽이지 않는다; 이미 종료된 잡은 덮어쓰지 않는다). 롤백 시 온보딩 잡은 `cancelled`, YAML 행은 `terminated`/`rollback-failed` 로 표시한다. TUI 가 준비되지 않고 프로세스도 없으면 exit 42.
|
||||
5. **문서**: create/resume SKILL.md(`--no-auto-trust`, `started` 기반 판정, exit 42·43, `MAM_ONBOARD_ACK_S`, 미확인 온보딩 시 세션 보존 규칙).
|
||||
|
||||
#### ✅ 품질 검증
|
||||
- **신규 테스트** (`tests/test_issue23_24_startup_path.py` 1,268줄, `tests/test_issue24_resume_dialogs.py`): 어댑터 다이얼로그 facts, 준비 토큰 기반 수락, `--no-auto-trust`, 교차 서버 스코프 제거(같은 서버는 유지), 공유 `started` 판정(이전 시도·신원 없는 기록 거부, 종료된 잡 보존), 롤백의 kill 확인·exit 43, 미확인 온보딩 시 세션 보존. 픽스처는 실제 `registry.py`·`publish_event.py` 를 쓴다(작성 워크스페이스 리뷰에서 손으로 만든 픽스처가 결함 3건을 가렸던 교훈). 기존 테스트 6파일 조정(G9 가드는 코드만 읽음).
|
||||
- **실제 herdr 수용 시험 (작성 워크스페이스)**: 오케스트레이터 pane 의 상속 환경을 그대로 둔 채 격리 서버에 agy·grok·codex·opencode 를 순차 생성 — 4건 모두 사람 개입 없이 TUI 준비, 온보딩 전달, `started`(dispatch_token 포함) → `completed`.
|
||||
- **리뷰**: 작성 워크스페이스 5인 5라운드(차단 지적 3건 — `HERDR_SESSION` 에만 의존한 교차 서버 판정, `data.dispatch_token` 을 요구했으나 실제 발행 명령엔 없던 온보딩 ack, 래퍼의 레코드 상태 우회와 신원 없는 원격 감사 기록 — 모두 해소) 전원 PASS. 병합 워크스페이스에서 PR head 를 detached 워크트리에 받아 재검토: @f4ba205 claude·agy·grok PASS; codex 는 리뷰 도중 호스트가 반복 절전(pmset 로그)에 들어가 API 스트림이 끊기는 바람에 리포트를 남기지 못했고, 대화 기록에서 복원한 탐침 2건(환경변수 `MAM_AUTO_TRUST=0` 이 스크립트의 기본값 export 에 덮여 핸들러에 1 로 도달, 쿨다운에 걸린 다이얼로그가 같은 화면의 다음 다이얼로그를 가림)을 05b0ea4 로 정정. 델타 리뷰 @05b0ea4 claude·agy·grok·codex 4인 전원 PASS(identity 4건 모두 base f4ba205 → head 05b0ea4, evidence review-tree; codex 는 두 정정을 되돌린 사본에서 새 회귀가 실패함을 역검증). 리포트와 identity 사이드카는 `.agents/reports/<session>/`.
|
||||
- **전체 스위트 (병합 워크스페이스, 자체 venv 가 있는 워크트리 @f4ba205)**: 1111 통과 / 1 skip / 실패 0 (51분 21초, 리뷰 루프와 병행); 05b0ea4 에서 startup-path·resume-dialogs·TUI readiness·headless reconcile·version 스위트 123 통과.
|
||||
- **별건**: 리뷰 루프가 diff 를 단일 argv 로 넘겨 Linux 의 단일 인자 상한(131,072 바이트)을 넘는 브랜치 diff 에서 `OSError: [Errno 7]` 로 죽고 rc 0 으로 끝난다(수집기 상한 `MAM_DIFF_MAX_BYTES` 기본 200000 이 더 높다). 작성 측이 별도 이슈로 등록. 비차단: 감사 기록의 새 필드(`dispatch_verified`·`dispatch_fingerprint`·`attempt_id`)가 MESSAGING.md·delegate-job SKILL.md 에 없음, `MAM_INJECT_ACK_GRACE_S` 가 create 의 대기에도 2순위로 작용한다는 점이 문서에 없음, `MAM_AUTO_TRUST` 는 `0` 만 끈다는 점과 플래그 우선 규칙이 resume SKILL.md 에 없음, codex 업데이트 안내에 `2`(이번만 건너뛰기)를 고르므로 다음 시작 때 다시 뜸(`3` 이면 다음 버전까지 조용함), 다이얼로그 시그니처는 화면의 디렉터리 경로를 검사하지 않음(pane 이 `-c $WORKSPACE` 로 뜨므로 실무 위험 낮음), `test_issue24_resume_dialogs.py` 4건이 각 ~40 s.
|
||||
|
||||
### 🚀 `v4.7.1` — Issue #25 승격된 계획서 재사용은 `--plan-file` 로만: 암묵적 자동 로드 제거, 빈 계획 리포트는 조용히 넘어가지 않음 (2026-09-22, PR #32)
|
||||
|
||||
> **패치 마일스톤 (PATCH Release)**: `run_loop.sh` 를 `--plan` 없이 실행하면 러너가 planner 를 자동으로 고르고 `.agents/reports/<planner>/plan-*.md` 중 mtime 이 가장 최근인 파일을 "이미 수립된 계획서"로 creator 에게 넘기던 문제(Issue #25)를 고쳤다. 저장소에는 다른 이슈의 계획서가 수십 개 커밋돼 있고 체크아웃 직후의 mtime 은 의미가 없어, 무관한 계획이 구현 프롬프트에 실렸다(v4.7.0 병합 루프에서도 Issue #6 계획서가 실린 것을 관측). 옵션이 하나 늘지만 잘못된 기본 동작을 없애는 수정이라 PATCH 로 올린다.
|
||||
|
||||
@@ -227,6 +227,12 @@ def mam_sandbox(tmp_path, monkeypatch, request):
|
||||
# (`reconcile.sh --subscribe --idle-timeout 0`) from a sandbox. Tests that
|
||||
# exercise the daemon itself override this with "0" or delenv.
|
||||
monkeypatch.setenv("MAM_NO_SUBSCRIBER_DAEMON", "1")
|
||||
# Issue #23: onboarding delivery now waits for the agent's own `started`.
|
||||
# No sandbox has a real agent to publish one, so without a short grace
|
||||
# every create_session test would sit out the full 180 s ack timeout.
|
||||
# Create-specific on purpose: MAM_INJECT_ACK_GRACE_S is the delegate-job
|
||||
# wrapper's knob and its own tests assert on the value they pass.
|
||||
monkeypatch.setenv("MAM_ONBOARD_ACK_S", "3")
|
||||
monkeypatch.delenv("HERDR_SESSION_NAME", raising=False)
|
||||
monkeypatch.delenv("HERDR_SERVER_NAME", raising=False)
|
||||
monkeypatch.delenv("HERDR_WORKSPACE_ID", raising=False)
|
||||
|
||||
@@ -282,13 +282,19 @@ if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED";
|
||||
|
||||
|
||||
def test_mam_dialog_tokens_exclude_yes_try_it():
|
||||
"""N-1 / F-1: token list must not contain Yes, try it; Escape branch stays."""
|
||||
"""N-1 / F-1: token list must not contain Yes, try it; Escape branch stays.
|
||||
|
||||
Issue #24 moved the per-agent ladder out of lib.sh into the agent adapter,
|
||||
so the Escape branch is now claude's own `startup_dialogs` entry. The two
|
||||
token-list assertions are unchanged; only where the branch lives moved.
|
||||
"""
|
||||
content = open(_LIB_SH, encoding="utf-8").read()
|
||||
modal_line = next(l for l in content.splitlines() if l.startswith("_MAM_MODAL_TOKENS="))
|
||||
hint_line = next(l for l in content.splitlines() if l.startswith("_MAM_HINT_TOKENS="))
|
||||
assert "Yes, try it" not in modal_line
|
||||
assert "Yes, try it" not in hint_line
|
||||
assert "grep -q 'Yes, try it'" in content
|
||||
adapter = os.path.join(os.path.dirname(_LIB_SH), "lib_py", "agents", "adapters", "claude.py")
|
||||
assert "('Yes, try it', ['Escape'], 'notice')" in open(adapter, encoding="utf-8").read()
|
||||
|
||||
|
||||
def test_wait_for_tui_ready_succeeds_on_fullscreen_tip():
|
||||
|
||||
@@ -55,7 +55,16 @@ if _pane_dialog_open dummy; then echo "DIALOG_OPEN"; else echo "DIALOG_CLOSED";
|
||||
|
||||
|
||||
def test_c1b_hint_press_enter_sends_enter():
|
||||
"""C-1b (B-3): 'Press Enter to continue' alone must return dialog CLOSED, and wait_for_tui_ready must send Enter exactly once per iteration (30 total across 30 iterations, not 60)."""
|
||||
"""C-1b (B-3): 'Press Enter to continue' alone must return dialog CLOSED,
|
||||
and wait_for_tui_ready must never send Enter twice for one frame.
|
||||
|
||||
Issue #24 replaced "once per iteration" with a time cooldown
|
||||
(MAM_DIALOG_RESEND_S): a `--source visible` snapshot keeps showing an
|
||||
accepted dialog for a while, so re-sending on every poll types into a TUI
|
||||
that already moved on. This test stubs `sleep`, so the wall clock barely
|
||||
advances and one cooldown window covers the whole run - hence 1, not 30.
|
||||
The guard the case was written for (never 2 per iteration) is unchanged
|
||||
and strictly stronger now."""
|
||||
screen = """Some startup banner
|
||||
Press Enter to continue
|
||||
"""
|
||||
@@ -73,7 +82,7 @@ export MAM_STRONG_READY_TOKENS='NonExistentStrong'
|
||||
export MAM_WEAK_READY_TOKENS=''
|
||||
export MAM_INPUT_PLACEHOLDER=''
|
||||
rc=0
|
||||
wait_for_tui_ready dummy-sess custom || rc=$?
|
||||
wait_for_tui_ready dummy-sess claude || rc=$?
|
||||
echo "RC=$rc"
|
||||
echo "KEYS_COUNT=$(grep -c "send-keys -t dummy-sess Enter" "$KEYS" || echo 0)"
|
||||
rm -f "$KEYS"
|
||||
@@ -81,7 +90,9 @@ rm -f "$KEYS"
|
||||
res = _run_lib_helpers(script)
|
||||
assert res.returncode == 0, res.stderr + res.stdout
|
||||
assert "DIALOG_CLOSED" in res.stdout
|
||||
assert "KEYS_COUNT=30" in res.stdout
|
||||
count = int(res.stdout.split("KEYS_COUNT=")[1].split()[0])
|
||||
assert count >= 1, "the dialog was never answered: " + res.stdout
|
||||
assert count <= 30, "one frame must never be answered twice: " + res.stdout
|
||||
|
||||
|
||||
def test_c1c_transition_to_upsell_modal_sends_escape_not_enter():
|
||||
|
||||
@@ -261,13 +261,21 @@ def test_wrapper_honours_registry_ack_over_unconfirmed_inject(tmp_path, mock_her
|
||||
time.sleep(0.1)
|
||||
assert jid, "job was not registered"
|
||||
|
||||
# The agent acknowledges the job out of band, as codex does via publish_event.
|
||||
ack = subprocess.run(
|
||||
[sys.executable, str(REGISTRY_PY), "--registry-dir", str(reg_dir),
|
||||
"update", "--job", jid, "--status", "running"],
|
||||
capture_output=True, text=True, cwd=str(tmp_path), env=env,
|
||||
# The agent acknowledges the job out of band, as codex does via
|
||||
# publish_event - the real producer, with the dispatch token the wrapper
|
||||
# handed it. (Issue #23 review R1: a bare registry status flip is not an
|
||||
# acknowledgement any more; only the agent's own `started` naming this
|
||||
# dispatch is. publish_event writes that line locally before its wire
|
||||
# publish, so an unreachable broker does not hide it.)
|
||||
tok = json.loads((reg_dir / f"{jid}.json").read_text()).get("dispatch_token") or ""
|
||||
subprocess.run(
|
||||
[sys.executable, str(PUBLISH_PY), "--registry-dir", str(reg_dir), "--job", jid,
|
||||
"--event", "started", "--expect-dispatch-token", tok, "--attempt-id", "1"],
|
||||
capture_output=True, text=True, cwd=str(tmp_path), env=env, timeout=60,
|
||||
)
|
||||
assert ack.returncode == 0, ack.stderr
|
||||
started_lines = [l for l in (reg_dir / f"{jid}.events.log").read_text().splitlines()
|
||||
if '"started"' in l]
|
||||
assert started_lines and tok in started_lines[-1], started_lines
|
||||
|
||||
stdout, stderr = proc.communicate(timeout=90)
|
||||
assert "treating the prompt as delivered and continuing" in stderr, f"ack guard did not fire:\n{stderr}\n{stdout}"
|
||||
|
||||
@@ -1225,11 +1225,11 @@ def test_acceptance5_remote_runner_completes_a_job_over_the_broker_only(tmp_path
|
||||
pytest.skip("broker unreachable when publishing the dispatch (no CONNACK); the runner never saw the job")
|
||||
_skip_if_broker_did_not_deliver(reg, runner_log)
|
||||
assert proc.returncode == 0, f"rc={proc.returncode}\n{stdout}\n{stderr}\nrunner:\n{runner_log.read_text()}"
|
||||
# delivery is confirmed by the protocol: the subscriber saw `started`, or
|
||||
# the registry left pending - to running, or straight to completed when
|
||||
# the broker delivered only the retained terminal event
|
||||
assert ("subscriber received started" in stderr or "advanced to 'running'" in stderr
|
||||
or "advanced to 'completed'" in stderr), stderr
|
||||
# delivery is confirmed by the protocol: the subscriber's attributed
|
||||
# audit record of the remote `started` (review R1: the registry status
|
||||
# is no longer evidence - it can leave pending for reasons that say
|
||||
# nothing about this dispatch)
|
||||
assert "recorded 'started'" in stderr, stderr
|
||||
assert _job(reg, jid)["status"] == "completed", \
|
||||
f"the subscriber mirrors the remote terminal into the registry\n{stdout}\n{stderr}\nrunner:\n{runner_log.read_text()}"
|
||||
assert _job(reg, jid)["transport"] == "remote"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,166 @@
|
||||
"""Issue #24, resume path: start-up dialogs are answered for every agent.
|
||||
|
||||
resume_session.sh called handle_startup_dialogs only for claude and passed no
|
||||
agent, so a resumed agy/grok/codex sat on its directory-trust prompt exactly as
|
||||
the create path did before this branch. These fix the call contract resume
|
||||
now shares with create.
|
||||
|
||||
What is real: the shipped create/stop/resume scripts run end to end against
|
||||
the mock herdr in a sandbox. What is not: the mock's pane always shows a
|
||||
ready banner, so no test here drives a real trust dialog - the create-side
|
||||
tests (test_issue23_24_startup_path.py, S4-S6) fix which keys each dialog
|
||||
gets, and R4 shows the same shipped function is now reached from resume.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
SCRIPTS = ("multi-agent-mux-create/scripts/create_session.sh",
|
||||
"multi-agent-mux-stop/scripts/stop_session.sh",
|
||||
"multi-agent-mux-resume/scripts/resume_session.sh")
|
||||
|
||||
|
||||
def _script(tmp_path, rel):
|
||||
return str(tmp_path / ".agents" / "skills" / rel)
|
||||
|
||||
|
||||
def _create_and_stop(tmp_path, agent, name):
|
||||
ws = str(tmp_path / ("ws_" + name))
|
||||
os.makedirs(ws, exist_ok=True)
|
||||
create, stop, _ = (_script(tmp_path, s) for s in SCRIPTS)
|
||||
subprocess.run(["bash", create, "--workspace", ws, "--agent", agent,
|
||||
"--role", "creator", "--session", name, "--no-onboard"],
|
||||
check=True, cwd=str(tmp_path), capture_output=True, text=True)
|
||||
subprocess.run(["bash", stop, "--session", name, "--agent", agent],
|
||||
check=True, cwd=str(tmp_path), capture_output=True, text=True)
|
||||
return ws
|
||||
|
||||
|
||||
def _resume(tmp_path, agent, name, ws, extra=()):
|
||||
_, _, resume = (_script(tmp_path, s) for s in SCRIPTS)
|
||||
return subprocess.run(["bash", resume, "--workspace", ws, "--agent", agent,
|
||||
"--session", name, *extra],
|
||||
cwd=str(tmp_path), capture_output=True, text=True)
|
||||
|
||||
|
||||
def _record_dialog_calls(tmp_path):
|
||||
"""Override handle_startup_dialogs in the SANDBOX copy of lib.sh (never the
|
||||
repo's) so the call resume makes - session, timeout, agent, MAM_AUTO_TRUST
|
||||
- is written down. Bash keeps the last definition, so appending wins."""
|
||||
log = tmp_path / "dialog_calls.log"
|
||||
lib = tmp_path / ".agents" / "skills" / "lib.sh"
|
||||
with open(lib, "a") as f:
|
||||
f.write('\nhandle_startup_dialogs() {\n'
|
||||
' printf \'%%s\\n\' "sess=$1 timeout=${2:-} agent=${3:-<none>} '
|
||||
'auto_trust=${MAM_AUTO_TRUST:-<unset>}" >> "%s"\n'
|
||||
'}\n' % log)
|
||||
return log
|
||||
|
||||
|
||||
def _calls(log):
|
||||
return log.read_text().splitlines() if log.exists() else []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("agent", ["agy", "codex"])
|
||||
def test_r1_resume_answers_dialogs_for_a_non_claude_agent_and_names_it(
|
||||
mam_sandbox, mock_herdr, mock_agents, agent):
|
||||
"""R1: the gate is gone and the agent is passed - the two halves of the
|
||||
create contract resume was missing."""
|
||||
tmp_path = mam_sandbox
|
||||
name = "test-r1-" + agent
|
||||
ws = _create_and_stop(tmp_path, agent, name)
|
||||
log = _record_dialog_calls(tmp_path) # armed after create, so only resume is recorded
|
||||
res = _resume(tmp_path, agent, name, ws)
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
assert _calls(log) == ["sess=%s timeout=20 agent=%s auto_trust=1" % (name, agent)]
|
||||
|
||||
|
||||
def test_r2_claude_keeps_its_dialog_handling(mam_sandbox, mock_herdr, mock_agents):
|
||||
"""R2: claude is still handled, now by the same call as everyone else."""
|
||||
tmp_path = mam_sandbox
|
||||
ws = _create_and_stop(tmp_path, "claude", "test-r2-claude")
|
||||
log = _record_dialog_calls(tmp_path)
|
||||
res = _resume(tmp_path, "claude", "test-r2-claude", ws)
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
assert _calls(log) == ["sess=test-r2-claude timeout=20 agent=claude auto_trust=1"]
|
||||
|
||||
|
||||
def test_r3_no_auto_trust_reaches_the_dialog_handler(mam_sandbox, mock_herdr, mock_agents):
|
||||
"""R3: --no-auto-trust on resume sets MAM_AUTO_TRUST=0 for the handler,
|
||||
the same switch with the same meaning as create's."""
|
||||
tmp_path = mam_sandbox
|
||||
ws = _create_and_stop(tmp_path, "codex", "test-r3-codex")
|
||||
log = _record_dialog_calls(tmp_path)
|
||||
res = _resume(tmp_path, "codex", "test-r3-codex", ws, extra=("--no-auto-trust",))
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
assert _calls(log) == ["sess=test-r3-codex timeout=20 agent=codex auto_trust=0"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("env_value,flag,expected", [
|
||||
("0", (), "0"), # the documented environment form alone
|
||||
("0", ("--auto-trust",), "1"), # an explicit flag wins over the environment
|
||||
("1", ("--no-auto-trust",), "0"),
|
||||
])
|
||||
def test_r3b_the_environment_form_of_the_switch_reaches_the_handler_on_resume(
|
||||
mam_sandbox, mock_herdr, mock_agents, monkeypatch, env_value, flag, expected):
|
||||
"""R3b (merge review, codex probe): MAM_AUTO_TRUST=0 without a flag used to
|
||||
be overwritten by the script's own export of its default."""
|
||||
tmp_path = mam_sandbox
|
||||
ws = _create_and_stop(tmp_path, "codex", "test-r3b-codex")
|
||||
log = _record_dialog_calls(tmp_path)
|
||||
monkeypatch.setenv("MAM_AUTO_TRUST", env_value)
|
||||
res = _resume(tmp_path, "codex", "test-r3b-codex", ws, extra=flag)
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
assert _calls(log) == ["sess=test-r3b-codex timeout=20 agent=codex auto_trust=%s" % expected]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("env_value,flag,expected", [
|
||||
("0", (), "0"),
|
||||
("0", ("--auto-trust",), "1"),
|
||||
("1", ("--no-auto-trust",), "0"),
|
||||
])
|
||||
def test_c3_the_environment_form_of_the_switch_reaches_the_handler_on_create(
|
||||
mam_sandbox, mock_herdr, mock_agents, monkeypatch, env_value, flag, expected):
|
||||
"""C3 (merge review, codex probe): the same contract on create_session."""
|
||||
tmp_path = mam_sandbox
|
||||
log = _record_dialog_calls(tmp_path)
|
||||
ws = str(tmp_path / "ws_c3")
|
||||
os.makedirs(ws, exist_ok=True)
|
||||
create = _script(tmp_path, SCRIPTS[0])
|
||||
monkeypatch.setenv("MAM_AUTO_TRUST", env_value)
|
||||
res = subprocess.run(["bash", create, "--workspace", ws, "--agent", "codex",
|
||||
"--role", "creator", "--session", "test-c3-codex", "--no-onboard", *flag],
|
||||
cwd=str(tmp_path), capture_output=True, text=True)
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
assert _calls(log) == ["sess=test-c3-codex timeout=15 agent=codex auto_trust=%s" % expected]
|
||||
|
||||
|
||||
def test_r4_the_shipped_handler_really_runs_for_a_non_claude_resume(
|
||||
mam_sandbox, mock_herdr, mock_agents):
|
||||
"""R4 (no override): with the real handle_startup_dialogs, a codex resume
|
||||
completes and the pane is read after the spawn - the handler was reached
|
||||
and returned on the ready banner. Before this change no pane read happened
|
||||
between new-session and the YAML update for a non-claude agent."""
|
||||
tmp_path = mam_sandbox
|
||||
ws = _create_and_stop(tmp_path, "codex", "test-r4-codex")
|
||||
before = len(json.load(open(mock_herdr)).get("calls", []))
|
||||
t0 = time.monotonic()
|
||||
res = _resume(tmp_path, "codex", "test-r4-codex", ws)
|
||||
elapsed = time.monotonic() - t0
|
||||
assert res.returncode == 0, res.stdout + res.stderr
|
||||
# Review B-1: the handler used to idle its whole 20 s timeout here because
|
||||
# it had no ready tokens on the resume path. rc 0 alone cannot tell early
|
||||
# return from timeout; the clock can.
|
||||
assert elapsed < 12, "resume took %.1fs: the dialog handler idled to its timeout" % elapsed
|
||||
calls = json.load(open(mock_herdr)).get("calls", [])[before:]
|
||||
|
||||
def _has(c, *verbs): # the mock records argv with a leading `--session <name>`
|
||||
return any(tuple(c[i:i + len(verbs)]) == verbs for i in range(len(c)))
|
||||
|
||||
spawn = next(i for i, c in enumerate(calls) if _has(c, "agent", "start"))
|
||||
reads = [c for c in calls[spawn + 1:] if _has(c, "agent", "read") or _has(c, "pane", "read")]
|
||||
assert reads, "no pane read after the spawn: handle_startup_dialogs never ran\n%s" % calls
|
||||
@@ -692,22 +692,90 @@ def test_g9_loop_package_imports_only_delegation_among_siblings():
|
||||
assert not offenders, offenders
|
||||
|
||||
|
||||
# The G9 guard reads CODE, not prose. Its claim is that no frozen lib.sh
|
||||
# helper is *invoked* from lib_py/loop; a comment or docstring that names
|
||||
# lib.sh (say, to point at the shell twin of a function so the two stay in
|
||||
# sync) invokes nothing. Every way Python could actually reach a lib.sh helper
|
||||
# - a path or `source` inside a subprocess argv, an identifier - lives in code
|
||||
# text, which is still scanned in full, so the guard loses none of its reach.
|
||||
# History: it first fired on `hashlib.sha256` (a word boundary fixed that),
|
||||
# then on a docstring cross-reference in watcher.py; both were mentions.
|
||||
_G9_PATTERNS = {
|
||||
"ensure_subscriber_daemon": r"ensure_subscriber_daemon",
|
||||
"preexec_fn": r"preexec_fn",
|
||||
"lib.sh": r"\blib\.sh\b", # word boundary: `hashlib.sha256` is not lib.sh
|
||||
}
|
||||
|
||||
|
||||
def _code_only(src):
|
||||
"""`src` as code only: the docstring expression statements are removed from
|
||||
the AST and the rest is unparsed, which also drops comments (they are not
|
||||
in the AST). Only the docstring *node* goes, so a statement that shares
|
||||
its line - a docstring followed by `; call()` - is kept, as are string literals in code
|
||||
(a path or `source` in a subprocess argv is exactly what must be caught).
|
||||
Review R2: blanking the docstring's whole line range hid same-line code."""
|
||||
import ast
|
||||
|
||||
tree = ast.parse(src)
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.Module, ast.ClassDef, ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
body = getattr(node, "body", None) or []
|
||||
first = body[0] if body else None
|
||||
if (isinstance(first, ast.Expr) and isinstance(first.value, ast.Constant)
|
||||
and isinstance(first.value.value, str)):
|
||||
del body[0]
|
||||
if not body:
|
||||
body.append(ast.Pass())
|
||||
return ast.unparse(tree)
|
||||
|
||||
|
||||
def _g9_offenders(src):
|
||||
import re
|
||||
code = _code_only(src)
|
||||
return [name for name, pat in _G9_PATTERNS.items() if re.search(pat, code)]
|
||||
|
||||
|
||||
def test_g9_phase2a_spawns_no_daemon_and_calls_no_frozen_helper():
|
||||
"""The two phase 1 handoffs stay dormant: preexec_fn is unreachable because
|
||||
nothing here spawns, and no frozen lib.sh helper is invoked."""
|
||||
import re
|
||||
|
||||
# `lib.sh` needs a word boundary: a bare substring search also matches
|
||||
# hashlib.sha256, which is how this guard first fired on innocent code.
|
||||
patterns = {
|
||||
"ensure_subscriber_daemon": re.compile(r"ensure_subscriber_daemon"),
|
||||
"preexec_fn": re.compile(r"preexec_fn"),
|
||||
"lib.sh": re.compile(r"\blib\.sh\b"),
|
||||
}
|
||||
for path in sorted((SKILLS / "lib_py" / "loop").glob("*.py")):
|
||||
src = path.read_text(encoding="utf-8")
|
||||
for name, pattern in patterns.items():
|
||||
assert not pattern.search(src), "{} references {}".format(path.name, name)
|
||||
hits = _g9_offenders(src)
|
||||
assert not hits, "{} references {}".format(path.name, ", ".join(hits))
|
||||
|
||||
|
||||
def test_g9b_the_guard_still_catches_an_invocation_and_ignores_prose():
|
||||
"""The narrowing must not cost the guard its purpose: an invocation-shaped
|
||||
reference in code text still trips it; the same words in a docstring or a
|
||||
comment do not."""
|
||||
invoked = (
|
||||
'import subprocess\n'
|
||||
'def go():\n'
|
||||
' return subprocess.run(["bash", "-c", "source .agents/skills/lib.sh; ensure_subscriber_daemon x"])\n'
|
||||
)
|
||||
assert set(_g9_offenders(invoked)) == {"lib.sh", "ensure_subscriber_daemon"}
|
||||
path_only = 'CMD = ["bash", "/x/.agents/skills/lib.sh"]\n'
|
||||
assert _g9_offenders(path_only) == ["lib.sh"]
|
||||
prose = (
|
||||
'"""Module doc: same convention as lib.sh\'s _herdr_caller_server."""\n'
|
||||
'import hashlib\n'
|
||||
'def f():\n'
|
||||
' """See lib.sh; never call ensure_subscriber_daemon from here."""\n'
|
||||
' # preexec_fn is documented in lib.sh\n'
|
||||
' return hashlib.sha256(b"x").hexdigest()\n'
|
||||
)
|
||||
assert _g9_offenders(prose) == []
|
||||
# Review R2: code sharing a line with a docstring must still be seen -
|
||||
# module, one-line body, one-line def, and the last line of a multi-line
|
||||
# docstring. Only the docstring node is removed, never its line.
|
||||
same_line = [
|
||||
'"""module doc"""; ensure_subscriber_daemon()\n',
|
||||
'def f():\n """function doc"""; ensure_subscriber_daemon()\n',
|
||||
'def f(): """function doc"""; ensure_subscriber_daemon()\n',
|
||||
'def f():\n """multi\n line"""; ensure_subscriber_daemon()\n',
|
||||
]
|
||||
for snippet in same_line:
|
||||
assert _g9_offenders(snippet) == ["ensure_subscriber_daemon"], snippet
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
|
||||
Reference in new issue
Block a user