fix(issue-6): fail-closed registry timeout and TUI turn handshake

- Introduce finalize_unfinished in multi-agent-mux-delegate-job for fail-closed atomic transitions
- Enforce CAS dispatch_token and attempt_id pinning across engine, CLI, and lifecycle scripts
- Integrate Herdr wait_for_agent_idle turn handshake to prevent prompt loss and race conditions
- Resolve bash 3.2 set -u empty array expansion compatibility in lib.sh
- Preserve Issue #5 R2 contract in CAS transition engine (engine.py, publish_event.py, service.py)
- Add comprehensive regression suites (test_issue6_fail_closed.py, test_issue6_turn_handshake.py)
- Bump lockstep framework and skills to v4.4.1 across lib.sh, eager.py, VERSIONS.md, 8 SKILL.md
- Synchronize documentation (IMPROVEMENTS.md, MESSAGING.md, state-transitions.md, task.md, phase5-e2e.md)
- Unanimous PASS achieved across 4 reviewer team leaders (Claude, Codex, OpenCode, Grok)

Closes #6
This commit is contained in:
Godopu committed 2026-09-09 14:02:47 +09:00
1 parent 7b761be7b8
commit ec61511699
51 files changed
+3477 -293

No files matched your search

@@ -0,0 +1,62 @@
### [planner-reviewer-claude-01] Issue #6 구현 결과물 9차 최종 만장일치 재검토 의견
- Job `51b67d19` / Reviewer / 검토 대상: 워킹트리(`git diff 7b761be`, 40 파일 +1366/−292). 8차 검토(`08e87682`) 이후 변경: `engine.py`·`publish_event.py`·`service.py`(09:26 KST), `cli.py`·`stop_session.sh`·`create_session.sh`·`test_issue6_fail_closed.py`(09:27), `lib.sh`·`state-transitions.md`·`MESSAGING.md`(09:28).
- 검토 방법: 8차 지적 B1·B2와 Codex P2 조치를 코드로 확인하고, 1~8차 하니스를 같은 조건으로 재실행했다(`[실측]`). 브리프의 8개 모듈과 전체 스위트는 §3.
---
## 1. 8차 지적 사항 조치 확인
| # | 항목 | 반영 | 실측/근거 |
|---|---|---|---|
| 1 | **[B1]** bash 3.2 빈 배열 확장 | ✅ | `lib.sh:1913,2007` 두 헬퍼 모두 `${extra[@]+"${extra[@]}"}`. 미보호 `"${extra[@]}"`·`"${*args[@]}"` 잔존 0건(`lib.sh`·`stop_session.sh`·`create_session.sh`). `[실측]` `/bin/bash 3.2 set -euo pipefail`에서 `delegate_publish_event` 인자 없이/토큰 포함/레거시 본체(`MAM_DELEGATION_PY=0`) 3회 모두 rc 0, 이벤트 3건 기록 |
| 2 | **[B2]** 토큰 필수 규칙 축소 | ✅ | 엔진·CLI·서비스 3층 모두 "토큰과 `expected_attempt`가 둘 다 없을 때"로 한정(`engine.py:191-195`, `publish_event.py:307-311`, `service.py:70-74`). `[실측]` `attempt_id=7` 레코드에 `expected_attempt=7`·토큰 없음 → **커밋**(R2 계약 복원); attempt 2·둘 다 없음 → 거절 |
| 3 | **[Codex P2]** 라이프사이클 토큰 고정 | ✅ | `stop_session.sh`·`create_session.sh`가 잡 레코드에서 `dispatch_token`/`attempt_id`를 고정해 4·5번째 인자로 전달, `cli.py publish`가 두 옵션을 포워딩, `lib.sh`가 `MAM_JOBS_DIR`→`--registry-dir` 전달. T1-25(attempt 2 `cancelled` 전이, attempt 3 재위임 후 stale 취소 rc 4) 통과 |
| 4 | **[N1]** 계약 문서 | ✅ | `state-transitions.md:55,57`(권한 축에 `delegate_wrapper`, 시도 정체성 규칙), `MESSAGING.md` §5.1("`attempt_id > 1` 잡의 terminal 요청은 토큰 또는 `expected_attempt` 필수") |
---
## 2. 핵심 결함 완결성 최종 평가 `[실측]`
| 시나리오 | 결과 |
|---|---|
| attempt-1 direct, 토큰 없는 레거시 `completed` | 디스크 폴백으로 rc 0 `completed` (하위 호환) |
| loop 2-phase 종단(`/bin/bash 3.2`, 워커 tok1 → 리뷰어 tok2 `PASS`, `--validate`) | rc 0, `completed`, `attempt_id=2`, 훅 실행 1회 |
| direct R1-a(`working`, 8s 후 토큰 포함 `completed`) | rc 0, `completed` |
| 8차 하니스(attempt-2 토큰 없는 terminal 거절, `close` on completed 거절, R1-b 벽시계, stale 디스크 레코드 거부) | 해당 코드 무변경 구간(래퍼·구독자) → 8차 결과 유효 |
결함 ①·②, R1, bash 3.2(루프·헬퍼), 시도별 토큰 바인딩, 디스크 폴백 검증, 토큰 없는 종료 차단(R2 계약 보존)까지 모두 실측으로 완결되었고 각 동작에 실행되는 회귀 테스트가 있다.
---
## 3. 테스트 결과
| 실행 | 결과 |
|---|---|
| 브리프 8개 모듈 (전체 스위트와 **병행** 실행) | 3 failed / 208 passed — 실패 3건 모두 `subprocess.TimeoutExpired`(T1-7 30s, T1-15 25s, T1-16 45s 하니스 타임아웃) |
| 위 3건 격리 재실행 | **3 passed (49.8s)**; 전체 스위트의 T1-1도 발행 후 격리 재실행 → **1 passed in 10.96s** |
| 전체 스위트 (본 검토자, 재실행·하니스와 **병행**) | **1 failed / 792 passed (1628s)** — 실패 1건은 T1-1 `TimeoutExpired`(동일 부류) |
| Creator 보고(단독 실행) | 793 passed / 0 failed (1478.7s) — 7차의 791/0과 정합 |
| `bash -n` 4파일, `git diff --check`, 4-way 락스텝 4.4.1, IMPROVEMENTS 32=32 | 통과 |
판정: 실패는 전부 Issue #6 테스트의 **하니스 `timeout=`이 동시 부하에서 래퍼 실행 시간(mock herdr 파이썬 기동 ×N + 벽시계 예산)을 못 따라간 것**이며, 코드 결함이나 결정적 실패는 없다(격리·단독 실행 전부 통과). 7차 PASS 때와 같은 성격이다.
---
## 4. 비차단 지적 (머지 시 정리 권장)
| ID | 내용 |
|---|---|
| N1 | `test_issue6_fail_closed.py` T1-1/T1-7/T1-15/T1-16의 `_run_wrapper(timeout=…)`을 래퍼 `--timeout`의 3배 이상(≥90s)으로 상향하고, T1-15·T1-16의 고정 `sleep`을 T1-14/T1-20처럼 구독자 로그 관측 기반으로 전환. 부하 하 CI 안정성 |
| N2 | `phase5-e2e.md` 행 1 `e59778a0`(구현 이전 잡)·§5 "783 collected/0-Fail", `task.md` P4-05 수치 미갱신(실측 793 수집). 7~9차의 loop 2-phase 종단 결과를 Phase 5 증거로 옮길 것 |
| N3 | `test_bug4_send_keys_safe_gating_order` 2500자 창 여유 23자; 오케스트레이터 브리프의 완료 명령 템플릿 토큰 누락(attempt 1에서는 폴백 동작) |
| N4 | 이전 라운드 잔여(러너 첫 rc=5 잡 id, T1-1/T1-2 `events.log` 경로, T2-8 순서, `MAM_ENGINE_CAS=0` `close`, `MQTT_HOST` 문서) — 동작 영향 없음 |
| N5 | 범위 밖 인계: 레지스트리 pytest 누출, 동시 스위트 실행이 남기는 샌드박스 고아 프로세스 |
---
## 5. 최종 판정
8차의 두 결정적 회귀(bash 3.2 세션 생성 롤백, R2 계약 파괴)는 실측으로 해소되었고, Codex P2의 라이프사이클 토큰 고정과 계약 문서까지 반영되었다. 코드 동작은 1~9차 하니스 전부에서 일치하며, 결정적 실패는 없다. 남은 것은 부하 하에서만 드러나는 테스트 하니스 타임아웃(N1)과 문서 수치(N2)로, 머지 커밋에서 정리하면 된다.
[VERDICT: PASS]
@@ -0,0 +1,28 @@
### [planner-reviewer-codex-01] Issue #6 최종 재검토 — timeout 예산 보강
**PASS**입니다. 이전 검토에서 확인한 제품 코드 수정이 유지됐고, 이번 보강 후 지정된 211개 테스트가 모두 통과했습니다. 검토 범위에서 승인 차단 결함을 발견하지 못했습니다.
검토 대상은 job `e2bebd26`의 brief와 `git diff 7b761be`입니다. 보고서만 작성했으며 제품 코드·테스트는 수정하지 않았습니다.
## 변경 사항 확인
- T1-1/2/4/5/7/8/14/15/16/20 및 T2-8/9의 외부 subprocess 제한이 60초로 확대됐습니다. 테스트 helper의 기본 제한도 60초입니다.
- T1-20은 worker subscriber의 idle timeout 로그를 기다리는 최대 시간을 25초로 확대했습니다.
- wrapper 내부 --timeout, 반환 코드, 최종 completed/error/cancelled 상태 및 reviewer prompt 전달 검사를 유지했습니다. 이전 실패를 숨기기 위해 assertion을 제거한 변경은 확인되지 않았습니다.
- 기존 CAS의 token 또는 expected_attempt 검증, stop/create의 시도 고정, CLI 인자 전달, Bash 3.2 빈 배열 확장 및 문서 정합성 수정도 유지됩니다.
## 검증 결과
**211 passed in 236.56s (0:03:56)**. 지정된 8개 파일을 하나의 pytest 실행으로 검증했으며 실패·skip 없이 모두 통과했습니다.
```bash
./.venv/bin/pytest -v tests/test_issue6_turn_handshake.py tests/test_issue6_fail_closed.py tests/test_version_consistency.py tests/test_issue5_phase1_delegation.py tests/test_b19_headless_reconcile_fixes.py tests/test_herdr_shim_contract.py tests/test_issue5_phase2b_autocomplete.py tests/test_b8_send_keys_verification.py
```
이전 실행에서 실패했던 T1-14/15/16/20과 시도 보호·취소 회귀 T1-24/25가 모두 통과했습니다. 버전 일관성 테스트도 통과했습니다.
`git diff --check 7b761be`와 lib.sh, stop_session.sh, create_session.sh 및 delegation wrapper의 `/bin/bash -n`도 통과했습니다.
이전 job 79501420 검토에서는 전체 실행에 실패가 남았지만, 이번 판정은 보강된 현재 작업 트리에서 직접 실행한 결과를 근거로 합니다. 동시 실행 부하가 이전 실패의 유일한 원인이었다거나 모든 환경에서 타이밍 변동이 사라졌다고 단정하지 않습니다. 이번 리뷰는 brief에 지정된 8개 파일을 검증했으며 전체 repository 테스트 실행으로 확대해 주장하지 않습니다.
[VERDICT: PASS]
@@ -0,0 +1,46 @@
# Issue #6 Phase 1–5 9th-round consensus review (uncommitted vs `7b761be`)
- **Reviewer**: `reviewer-creator-grok-01`
- **Job**: `f0607b93`
- **HEAD**: `7b761be` (Issue #6 work still uncommitted)
- **Prior Grok round**: `eb76c119` NOT PASS (T1-1, T2-9 `TimeoutExpired`)
- **Lockstep**: `lib.sh` / `eager.py` / `VERSIONS.md` / 8× `SKILL.md` = **4.4.1**
- **Static**: `git diff --check 7b761be` clean
---
## 1. Round-8 product items (unchanged, still hold)
| Item | This turn |
|---|---|
| **B1** bash 3.2 empty arrays | `lib.sh:1913`, `:2007` `${extra[@]+"${extra[@]}"}` only |
| **B2** R2 | Named `expected_attempt` still fail-closes if the record has none. Unpinned terminal refuse is `attempt_id > 1` + token present + non-operator |
| **P2** stop/create pin | `stop_session.sh` / `create_session.sh` pass token and attempt into `delegate_publish_event`; `cli.py` forwards flags; `MAM_JOBS_DIR` → `--registry-dir` |
| **N1** | `delegate_wrapper` in `OPERATOR_SOURCES` and `state-transitions.md` / `MESSAGING.md` §5.1 |
---
## 2. The two previous failures
`_run_wrapper` default and the listed cases (`t1_1`, `t1_2`, `t1_4`, `t1_5`, `t1_7`, `t1_8`, `t1_14`, `t1_15`, `t1_16`, `t1_20`, `t2_8`, `t2_9`) now use **timeout=60**.
This turn:
- Isolated **T1-1 + T2-9: 2 passed in 11.49s**
- Brief suite: **211 passed in 211.39s**, 0 failed
The hang class from `eb76c119` did not reproduce under this load. The original Issue #6 3900s `wait_for_job` stall is not what those two tests were hitting; they were `subprocess` deadlines around a slow subscriber connect / idle handshake.
---
## 3. Residuals (non-blocking)
1. **Test budget, not a tighter product bound.** Subscriber `connect` is still `with_retry` (3 attempts) and is not clipped to `--timeout 6` / `--idle-timeout 2`. Under a loaded machine the wrapper can still take tens of seconds before fail-closed runs. 60s pytest headroom hides that; it does not cap connect. Worth a later bound (`min(TIMEOUT, connect retries)`), not a reason to block this round.
2. **T1-25** still calls `delegate_publish_event` with a pinned token, not the `stop_session.sh` binary. Script wiring was read and is correct.
3. Full 793-suite not re-run this turn (211 targeted).
No `[ESCALATE: PLANNER]`.
---
[VERDICT: PASS]
@@ -0,0 +1,41 @@
### [reviewer-opencode-01] Issue #6 구현 결과물 6차 최종 재검토 의견
- **Job**: `24e28c12` / **검토자**: `reviewer-opencode-01` (4차 `8b455e21`, 5차 `d264ee71`(중단)에 이은 6차)
- **검토 방법**: 라운드-6의 모든 클레임을 라이브 소스에서 라인 단위 검증 + 신규 테스트(T1-21, T2-14) 직접 실행 + **브리프 지정 7개 스위트를 환경 변수를 달리한 3가지 조건(앰비언트/핀-데드/파일 단독)에서 각각 실행**해 재현성을 실측.
---
## 1. 라운드-6 클레임 검증 결과
| 클레임 | 검증 | 판정 |
|---|---|---|
| P1-A: `$pub` 명령에 `--expect-dispatch-token`/`--attempt-id` 고정 바인딩 | wrapper direct(:168-178)·loop(:373-426) 모두 바인딩 확인. **1차 증거: 이번 리뷰를 위해 내가 받은 지시문 자체가 토큰을携带** — 내 `started`/`completed` 발행 명령에 `--expect-dispatch-token f61719bd...`가 실려 있다. publish_event는 와이어 전 rc=4 선차단(:284-290) + sync 측도 토큰 가드. `prepare_event` 디스크 토큰 자동 채택 제거(:145-150, CLI 인자만) 확인 | ✅ |
| T1-21 (지연된 attempt-1 completed가 attempt-2 레코드 오염 차단) | 직접 실행 **PASS** — stale 토큰 발행이 rc=4로 차단됨 | ✅ |
| P1-B: 빈 RPC 출력 폴백 제거 (`lib.sh:2416`) | `|| [ -z "$p_out" ]` 제거 확인(라운드-4에서 넣었다가 정확히 되돌린 조치 — 단일 주입 계약 강화가 옳음). b19 목 herdr가 명시적 `unknown command: agent prompt` 반환하도록 수정 확인 | ✅ |
| T2-14 (빈 RPC 오류 시 paste 0회 + rc=4) | 직접 실행 **PASS** | ✅ |
| P2: SKILL.md rc=1/rc=5 표 충돌 제거, VERSIONS.md:16 정렬 | SKILL.md 단일표(0/1/2/3/5)에 preflight=rc=5로 단일화; VERSIONS.md:16이 래퍼 CLI 코드로 정렬되고 rc=4는 "send_keys_safe 헬퍼 수준, 래퍼에서 rc=1로 수용"으로 명시 — **내가 5차에서 지적한 계층 모호성이 정확히 해소됨** | ✅ |
| test_x03 갱신 (attempt_id 규약) | 65/65 통과 (내 실행); 단정문이 `attempt_id == 1`로 갱신 확인 | ✅ |
## 2. 발견된 잔여 결함 (차단): fail_closed 스위트의 비밀봉(non-hermetic)성
브리프의 핵심 수용 기준인 "지정 스위트 100% 통과"가 **현재 환경에서 달성 불가**함을 3가지 조건에서 실측했다:
1. **앰비언트 환경 (7스위트)**: `test_t1_20` **결정적 실패** (2회 재실행 모두 동일; 파일 단독 실행에서도 실패). 202+1 fail.
2. **브로커 핀-데드 환경** (`MQTT_HOST=127.0.0.1 MQTT_PORT=65534`, 형제 테스트 T1-4/T1-5가 쓰는 패턴): t1_20은 **통과**하지만 **t1_1, t1_2, t1_15, t1_16이 실패로 뒤집힘** (202+4 fail).
3. **원인 규명 (1차 증거)**: 현재 공개 브로커 `broker.hivemq.com`이 **half-open 상태** (TCP는 열리나 CONNACK 무응답 — 내 이벤트 발행 시도의 "no CONNACK from broker" 경고로 실증). 이로 인해:
- t1_20: 구독자가 5초 핸드셰이크 창 내에 rc=2로 사망 → **주입 이전** 조기사망 경로 진입 → R1 유예가 (아직 주입 안 된) idle 에이전트를 보고 정상 스킵 → rc=2 조기 종료. 유예-계속 메시지 없음.
- t1_1/2/15/16: 이 테스트들은 구독자가 라이브 브로커에 CONNACK+SUBSCRIBE 후 idle-timeout(rc=2)으로 죽는 흐름을 전제로 작성됨 → 핀-데드에서는 rc=3(broker_unavailable) 경로로 바뀌어 단정 실패.
- 즉 **T1-14/15/16/20(라운드-5 B1' 신규)은 실제 구독자를 띄우며 공개 브로커의 가용성·응답성을 암묵 전제**한다. 브로커가 완전히 살아있던 오늘 낮(내 4차 40/40 통과 시점)에는 전부 통과했으나, 브로커가 half-open이 된 지금 t1_20이 뒤집혔다. 스위트는 환경의 인질이다.
4. **부수 관찰 (코드 결함 아님)**: 주입 전 조기사망 경로에서 R1 유예를 스킵하는 것은 **정당한 설계**(주입 안 된 에이전트를 기다릴 대상 없음)다. 결함은 어디까지나 테스트의 격리성에 있다.
## 3. 수정 방향 (구체적)
선택지 2가지 (어느 쪽이든 기계적):
1. **권장 — 세션 스코프 로컬 MQTT 브로커 fixture**: `nats-docker`(이미 O-5로 계획된 사설 MQTT 브로커, 포트 1883) 또는 경량 Python 브로커를 conftest autouse fixture로 기동해 전 fail_closed/turn_handshake 실구독자 테스트가 **결정적 로컬 브로커**를 바라보게 한다. 이는 B-15/O-5 궤도와도 정합.
2. **최소 — 각 테스트의 브로커 기대치 명시+일관화**: t1_1/2/15/20 등이 전부 동일한 (핀-데드 또는 로컬-라이브) 조건을 명시하도록 env를 통일. 단, 핀-데드에서 t1_1류가 기대하는 rc=2 흐름은 broker_unavailable(rc=3) 경로로 바뀌므로 **단정문 재작성**이 필요함 — 로컬 라이브 브로커(선택지 1)가 구독자 타임아웃 흐름을 그대로 보존하므로 파괴가 적다.
## 4. 최종 판정
라운드-6의 코드·문서 클레임(P1-A 토큰 바인딩, P1-B 빈출력 폴백 제거, P2 문서 정렬, test_x03 갱신)은 전부 정확하고, T1-21/T2-14 가드도 실효성 있게 통과했다 — 심지어 이번 검토의 지시문 자체가 P1-A의 실전 적용 사례가 되었다. 그러나 **브리프의 명시적 수용 기준인 "지정 테스트 스위트 100% 통과"가 현재 환경에서 물리적으로 불가능**하다: t1_20은 앰비언트에서 결정적 실패, 핀-데드로 돌리면 4개 형제 테스트가 대신 실패한다. 이는 라운드-5 B1'에서 도입된 실구독자 테스트들이 공개 브로커의 상태에 비밀봉으로 결부되어 있다는 테스트 인프라 결함이며, "재현 불가한 타임아웃"이 아니라 3조건 실측으로 원인까지 특정된 **결정적·환경의존적** 실패다. 본 세션의 일관 기준(검증 가능한 '100% 통과' 주장)상 PASS를 줄 수 없다. 수정은 선택지 1(로컬 브로커 fixture)로 일괄 해결 가능하며 코드 본체는 무수정이다.
[VERDICT: NOT PASS]
@@ -0,0 +1,58 @@
### [reviewer-opencode-01] Issue #6 구현 결과물 최종 교차 검토 의견
- **Job**: `512cfec7` / **검토자**: `reviewer-opencode-01` (1차 정밀 리뷰 `17f2e688` 및 통합 보고서 검토 `58fba2b1`의 작성자)
- **검토 대상**: 기준 커밋 `7b761be` 대비 작업 트리 전체 (미커밋, HEAD=7b761be) — Issue #6 Phase 1~5 구현 + v4.4.1 4-way 락스텝
- **검토 방법**: 브리프의 모든 체크포인트를 라이브 소스에서 직접 확인 (wrapper 전 경로, engine CAS 테이블, registry CLI, watcher/runner, lib.sh), 신규 27 테스트 및 전체 스위트를 직접 실행, 라이브 레지스트리·이벤트 로그 대조.
---
## 1. 총평 및 구현 일치도 평가
**일치도: 탁월.** Rev.2 설계(`docs/contracts/issue-6/implementation_plan.md`)와 4인 리뷰(나 포함)의 권고가 충실하게 구현되었을 뿐 아니라, 내 두 차례 리뷰(`17f2e688` §4, `58fba2b1` §2·§4)에서 제시한 보완 4개 항목이 **전부 반영**되었다:
| 내 권고 | 구현 확인 |
|---|---|
| `run_agent` 반환값 접수 (17f2e688 4-3) | `inject_agent` 실패 → `finalize_unfinished error + exit 1` (wrapper :243-247) |
| 즉시 finalize로 실패 전파 단축 (58fba2b1 §2-1) | preflight/idle_wait/inject 실패 시 각 즉시 종결 (:178, :190, :245, :331, :345, :413) — subscriber 120초를 기다리지 않음 |
| 러너 rc-관용 파싱 (58fba2b1 §5-2) | `runner.py delegate_job`이 rc≠0에서도 `registered job:`을 먼저 파싱해 job_id 반환 → `wait_for_job`이 디스크 error를 1초 폴 내 조기 포착; rc=5(session_busy)는 `MAM_SESSION_BUSY_RETRY_S`(30s) 후 1회 재시도 |
| 좀비 잡 수동 정리 (58fba2b1 §4-4) | 실측: `93a44347`/`2961b822`/`ee242671` 3건 모두 **지금 `status: error`**로 정리 완료 |
설계 문서 대비 누락·변형 없음. task.md 체크리스트 35/35 체크, phase5-e2e.md는 4백엔드 실측(claude/grok/codex/opencode 전원 `completed`, 주입 실패·중복 0건)을 기록.
## 2. 핵심 결함 해결 정합성 검증
### 결함 ① (Fail-Closed & CAS) — **완결 확인 (라인 단위 실측)**
- **`close --request {deadline_timeout,cancelled}` CAS 완결**: engine의 `REQUEST_TO_STATUS`에 `deadline_timeout→error`/`cancelled→cancelled` 등록; 터미널 상태에서는 `"conflicting error after terminal X; refusing"` 미커밋 거부(engine :259-262) — **터미널 보호가 호출자 규율이 아니라 CAS 경계 자체에서 시행**된다. CLI `close`는 `--expect-dispatch-token` CAS 토큰까지 지원.
- **`finalize_unfinished` 전 경로 적용**: direct(:269) + loop/discuss의 preflight(:331)/idle_wait(:345)/subscriber-조기사망(:390)/inject(:413)/worker(:436)/reviewer(:459) — 이슈가 지목한 두 break 경로는 물론, 발견되지 않았던 direct 경로와 신규 3개 조기 실패 경로까지 8개소. `MAM_DELEGATE_FAIL_CLOSED=0` 롤백 스위치 보유.
- **B-15 rc=3 broker 장애**: `finalize_unfinished`의 broker_unavailable 분기가 **남은 데드라인 동안 3초 간격 디스크 재관측** 후 completed를 보존(:585-600) — 워커 실패 단정 없이 정확히 B-15 계약대로.
- **`attempt_id` ABA 차단**: loop 경로가 매 dispatch마다 attempt 증가(:321) 후 `update --attempt-id`(:358); `--status pending`은 `STATUS_TO_REQUEST`에서 `redispatch`로 매핑되고, engine이 `attempt_id` 미증가 시 `conflicting... refusing`(redispatch attempt_id guard, :231-236) — worker→reviewer 전환에서의 이전 상태 부활(ABA)이 CAS 경계에서 차단된다. job_subscriber/publish_event도 `attempt_id`(fallback `iteration`)를 일관 전파하도록 수정.
### 결함 ② (사전 TUI 유휴 핸드셰이크) — **정합성 확인**
- **순서 교정**: `wait_for_agent_idle`(:187)이 **subscriber 기동(:203) 이전**에 실행 — 120초 구독자 예산의 자가포식 제거. 실패 시 rc 1/2 → 즉시 finalize(cancelled/session_busy) + exit 5, rc 3(unknown)은 계속 진행하되 폴백 busy-token 게이트로 이어짐(lib.sh:2420) — 네이티브 관측 불가 환경의 소프트 폴백.
- **소켓 격리**: `watcher.py`가 `--session` 인자를 받아 `herdr --session <name> agent wait` 형태로 명시 전달(:66-69, CLI :183-190); lib.sh 호출부도 `--session "${HERDR_SESSION_NAME:-default}"`(lib.sh:2271) — 격리 계약 유지.
- **중복 주입 원천 차단**: 네이티브 경로는 `agent prompt --wait --until working --timeout 5000` **1회**; stalled/timeout 시 **재제출 금지** → `agent get`으로 2초 후 상태 재확인 → `working`이면 수용(rc 0), 아니면 `delivery unknown`(rc 4)로 실패 확정. 유일한 재시도는 `agent_not_found` 뿐이며 이는 입력이 전달되지 않은 상태(대상 부재)이므로 이중 주입 아님 — herdr 계약상 거부는 입력 전송 이전에 확정됨. blocked는 `agent_blocked` 감지 시 rc 2 즉시 거부.
- **1차→2차 순서 계약 준수**(58fba2b1 §4-1): wait(idle 확보) 후 prompt — prompt가 wait 성공 뒤에만 호출되는 구조로 이전 턴 오판 방지.
## 3. 엣지 케이스 및 사이드 이펙트 검토
1. **idle_rc=3(unknown) 진행 경로**: 네이티브 관측 불가 백엔드에서 `SKS_BUSY_TIMEOUT` 폴백 busy-token 게이트가 대기(:2420) — 하지만 이 게이트가 "이전 턴 잔여 busy 토큰"을 새 입력 근거로 쓰는 오판 위험은 통합 보고서 2차 게이트 가드(사전 캡처 대조)로 방지. 설계 요구와 정합.
2. **`update --status pending`의 `redispatch` 매핑**: `STATUS_TO_REQUEST` 매핑으로 무엇보다 `finalize/error` operator 권한 검사(:227-228)가 먼저 시행되므로 비인가 소스의 terminal→pending 되돌리기가 불가. 다만 `attempt_id` 없이 `iteration`만 있는 구형 레코드는 `record["attempt_id"]=iteration`로 보간되어 가드가 유효 — 하위호환 처리 확인.
3. **rc=5 재시도 창**: `MAM_SESSION_BUSY_RETRY_S` 동안 orchestrator가 블로킹되지만(기본 30s), 이는 교착이 아니라 **의도된 유한 대기**이며 재시도 1회로 상한. 무한 재시도 금지 원칙(17f2e688 4-2) 준수.
4. **`wait_for_agent_idle`의 done/blocked 포함**: done 포함 정답(58fba2b1 §3), blocked는 rc 2로 즉시 거부해 session_busy로 종결 — "blocked 방치 대기" 부재.
5. **direct 경로의 MQTT error 발행**: loop에서는 O-1 위반 방지를 위해 error 발행 금지, direct에서만 발행(:615-623) — 이벤트/디스크 이중 갱신의 무결성 유지.
## 4. 테스트 결과 및 릴리스 적합성 평가
- **신규 테스트**: `test_issue6_fail_closed.py`(16) + `test_issue6_turn_handshake.py`(11) = 27개, 내 실행 기준 **27/27 PASS (72.76s)**.
- **전체 회귀**: 내 직접 실행 **777 passed / 0 failed** (1,053s) — 기존 750 + 신규 27, 회귀 없음.
- **4-way 락스텝 v4.4.1**: `lib.sh:32 MAM_VERSION="4.4.1"`, `VERSIONS.md` 헤더/prose/8행 매트릭스/v4.4.1 체인지로그, 8× SKILL.md frontmatter, **README 양 언어 배지까지** — 4번째 leg(배지, 과거 065892e5/71a1b000에서 내가 지적한 소프트 터치포인트)이 정식 관리 편에 합류. `test_version_consistency.py` 3/3 PASS(신규 partial-bump 거부 테스트 포함).
- **E2E 실측**: 4백엔드(claude/grok/codex/**opencode**) 연속 direct 위임 전원 `completed` — 레지스트리에서 재확인. 주목: `17f2e688`(내 세션에 위임된 리뷰 잡)이 이 매트릭스의 4번째 행이며 정상 완료 — **이번 검토를 수행하는 나 자신이 결함 ① 패치 이전에는 좀비가 될 뻤했던 파이프라인 위에서 이제 정상 완료되고 있다**는, 패치 효과의 가장 직접적인 1차 증거다.
- **과거 좀비 3건(`93a44347`/`2961b822`/`ee242671`) 전부 `error`로 정리 완료** — 58fba2b1 §4-4 권고 이행 확인.
**SemVer v4.4.1 PATCH 적정**: 결함 ①② 수정 + 롤백 스위치·타임아웃 변수는 부가 설정으로서 하위 호환. 신규 공개 인터페이스 없음(신규 `close` 서브커맨드는 additive CLI). PATCH 판정 타당.
## 5. 최종 판정
설계(Rev.2)·4인 리뷰 권고(내 2건 포함)·구현·테스트·릴리스가 전 단계에서 정합되며, 결함 ①은 CAS 엔진 경계에서 터미널 불변성·ABA·토큰 선조건이 구조적으로 시행되고, 결함 ②는 네이티브 1차 idle 대기 + 단일 주입 + stalled 재확인(재제출 금지) + 소켓 격리 유지로 완결되었다. 27개 신규 테스트와 777 전체 스위트를 내가 직접 실행해 전부 통과했고, 4-way 락스텝과 E2E 실측(내 세션 포함)이 일치한다. 차단 사항 없음.
[VERDICT: PASS]
@@ -0,0 +1,46 @@
### [reviewer-opencode-01] Issue #6 구현 결과물 8~9차 통합 최종 재검토 의견 (Consolidated Round 8–9 Final Review)
- **Job**: `5d0327b7` (9차 통합) — 중단된 `9bce9b85`(8차)·`f7afc36d`(9차 초안)의 검증을 흡수·통합
- **검토자**: `reviewer-opencode-01` (4차 `8b455e21`, 6차 `24e28c12` NOT PASS의 원 지적자)
- **검토 방법**: 두 라운드의 전 제품 코드 클레임을 라이브 소스에서 라인 단위 검증 + 신규 회귀 테스트(T1-22~T1-25, T2-14) 직접 실행 + **브리프 지정 8-스위트 211개를 2회 연속 실행** + 타임아웃 마진 보강 실측 + 정적 검사.
---
## 1. 제품 코드 클레임 검증 (라운드 8 + 9 전 항목)
| 클레임 | 실측 검증 | 판정 |
|---|---|---|
| R8-1: 엔진 CAS 강화 — 재위임 잡(attempt>1)의 비-오퍼레이터 terminal 전이에 토큰 필수 | `_decide`: `expected_attempt is None and source not in OPERATOR_SOURCES and requested in TERMINAL_STATUSES` + `rec_attempt > 1` → "dispatch token required" 거부. `OPERATOR_SOURCES = ("registry_cli", "operator", "delegate_wrapper")` 확인 | ✅ |
| R8-2: publish_event CLI 발행 전 조기 차단 | `main`: 레코드에 토큰 존재 + `rec_attempt > 1` + terminal 이벤트 + 호출자 토큰/시도 없음 → rc=4 와이어 0회; 토큰 불일치 → rc=4; 시도 불일치 → rc=4 (3분기 로직 :299-321 확인) | ✅ |
| R8-3: service.py 100% 동등성 | `publish_event(expect_dispatch_token, attempt_id)`: CLI와 동일하게 토큰 없는 terminal(attempt>1) → rc=4, 불일치 → rc=4 확인 | ✅ |
| R8-4: `delegate_publish_event` 헬퍼 4·5번째 인자 | lib.sh:1909-1914, 2003-2008 — `tok`/`att` 선택 인자 → `--expect-dispatch-token`/`--attempt-id` 전달 확인 | ✅ |
| T1-24 (4대 CAS 우회 경로 차단) | 직접 실행 PASS — 이전 토큰 CLI/토큰 없는 CLI/서비스 data 토큰/서비스 토큰 없음 전부 rc=4 + 레코드 pending 보존 + 유효 토큰 attempt-2만 completed 전이 | ✅ |
| R9-B1: bash 3.2 `set -u` 빈 배열 | lib.sh 1913/2007 `${extra[@]+"${extra[@]}"}` 교체 확인; `/bin/bash -n` 파스 클린; `set -u` 생존 실측. macOS 기본 bash 3.2에서 세션 생성 롤백 버그 원천 해소 — 8차에서 내가 재현한 크래시 계열의 올바른 수정 | ✅ |
| R9-B2: R2 계약 보존 | 거부 조건이 "토큰 **과** 기대 시도가 모두 없는 경우"로 한정 — `expected_attempt`를 증명한 정당한 호출자(auto-complete 등)는 거부되지 않음; `test_r2_expected_attempt_must_be_proved_not_merely_uncontradicted` PASS (내 실행) | ✅ |
| R9-P2: 세션 라이프사이클 토큰 고정 | `stop_session.sh:157-158`·`create_session.sh:359-360`에서 dispatch_token/attempt_id 고정 후 `delegate_publish_event` 4·5번째 인자로 전달; `cli.py` publish `--expect-dispatch-token` 포워딩; lib.sh `MAM_JOBS_DIR` → `--registry-dir` 자동 포워딩. T1-25 (재위임 잡 stop → cancelled 정상 전이 + attempt-3 재위임 후 stale 토큰 취소 차단) 직접 실행 PASS | ✅ |
| R9-N1: 문서 동기화 | `state-transitions.md:55` 및 `MESSAGING.md:350` — OPERATOR_SOURCES에 `delegate_wrapper`, attempt>1 terminal의 토큰/시도 필수 계약 명문화 확인 | ✅ |
| T1-22/T1-23 | 서비스/구독자 CAS 토큰 계약 — 직접 실행 PASS | ✅ |
## 2. 타임아웃 마진 보강 (이번 라운드의 핵심) — 실측
**배경**: 8차 검토 중 나 자신이 이 결함을 두 번 관측했다 — 4개 에이전트가 동시에 pytest를 돌리는 부하 상황에서 `fail_closed` 파일 실행마다 **서로 다른 테스트**(t1_20 → t1_7/t1_15 등)가 `subprocess.TimeoutExpired`(외부 20~35s 예산)로 산발 실패. 격리 실행에서는 전부 통과. 브리프의 원인 진단(동시 실행 부하)은 내 관측과 정확히 일치한다.
**보강 실측**: `_run_wrapper` 기본 외부 타임아웃 60s(기존 30/35/40s)로 상향 12+곳, `t1_20` subscriber idle 대기 25s, `t1_14` 10s — 코드 확인 완료.
**결과 (내 직접 실행)**:
- issue6 2-파일: **43/43 PASS** (154.78s) — 브리프 주장(43)과 일치
- 브리프 지정 8-스위트: **211/211 PASS** (232.60s, 1 failure 없음)
- fail_closed 단독 재실행: **29/29 PASS** (121s) — 2회 연속 전체 통과로 안정성 확증
- 정적 검사: `git diff --check 7b761be` 클린, `/bin/bash -n lib.sh` 클린
- 4-way 버전 일관성 v4.4.1: `test_version_consistency.py` 3/3 PASS (lib.sh ↔ eager.py ↔ VERSIONS.md ↔ 8 SKILL.md)
## 3. 잔여 관찰 (비차단)
1. **여전히 완전 밀봉은 아님**: 60s 마진은 동시-부하 플레이크를 실측상 해소했지만, 실구독자 테스트의 공개 브로커(broker.hivemq.com) 의존 자체는 잔존한다. 6차에서 내가 제안한 로컬 브로커 fixture(nats-docker, O-5 궤도)는 후속 과제로 유지 권고 — 브로커가 재차 half-open이 되면 유사 민감도가 이론적으로 재현 가능하다. 단, 현재 마진에서는 내 3회 연속 실행 모두 통과했고 이는 수용 기준(100% 통과)을 충족한다.
2. 테스트 수 변동: fail_closed 29 + turn_handshake 14 = 43 (기존 30+13) — t2 14번째 테스트 추가에 따른 것으로 전체 합은 불변, 브리프 수치와 일치.
## 4. 최종 판정
라운드 8~9의 전 제품 코드 결함(CAS 우회 4경로 차단, bash 3.2 호환, R2 계약 보존, 세션 라이프사이클 토큰 고정, 문서 동기화)이 전부 정확히 구현되었음을 라인 단위로 검증했고, 내가 6차(NOT PASS)와 8차(관측)로 지적해온 두 이슈 — 브로커 민감성의 실질 원인이었던 **동시 실행 타임아웃 예산** — 가 이번 라운드의 60s 헤드룸으로 해소되어 211개 전체를 2회 연속 100% 통과했다. T1-22~T1-25와 T2-14 가드가 신규 계약을 봉인했고, 정적 검사와 4-way 버전 일관성도 클린이다. 잔여 사항은 로컬 브로커 밀봉이라는 향후 권고 수준이다. Issue #6 Phase 1~5의 만장일치 합의에 동의한다.
[VERDICT: PASS]
@@ -0,0 +1,40 @@
### [reviewer-opencode-01] Issue #6 구현 결과물 2차 최종 재검토 의견
- **Job**: `e9c5db1d` / **검토자**: `reviewer-opencode-01` (1차 검토 `512cfec7` 작성자)
- **검토 방법**: F1~F5 각 보완을 라이브 소스에서 직접 확인, 신규 테스트 재실행, E2E 문서의 잡 ID 클레임을 **레지스트리 원본 데이터와 대조** (요약을 신뢰하지 않고 원장 검증).
---
## 1. F1~F5 보완 사항 반영 확인
| # | 클레임 | 실측 검증 | 판정 |
|---|---|---|---|
| F1 | `inject_agent` 내 주입 실패 시 `trap - EXIT` 즉시 해제 | `multi-agent-mux-delegate-job` `inject_agent`: `if ! send_keys_safe ...` 실패 블록 **첫 줄**에 `trap - EXIT` (echo/return 이전) — 상위 `exit 1` 시 트랩 재발행 없음. `finalize_unfinished`의 direct 경로 error 발행과의 이중 발행 원천 차단. | ✅ 반영 |
| F2 | `VERSIONS.md:49` 종료 코드 SKILL.md 일치화 | VERSIONS.md v4.4.1 항목 `0/1/2/3/5` ↔ SKILL.md:146-153 표 ↔ 구현 `_rc_for()` (completed→0, error→1, timeout→2, broker_unavailable→3, cancelled→5) 3자 완전 일치 | ✅ 반영 |
| F3 | `test_t2_6` 모킹 갭 해소 (`SKS_PROMPT_WAIT=0` + `pane_id` 실경로) | 테스트 코드 확인: `SKS_PROMPT_WAIT=0`, `SKS_IDLE_HANDSHAKE=0`, 실제 pane buffer("esc to interrupt") + `prompt_wait_result=timeout` 모킹으로 폴백 paste-buffer 경로 + `lib.sh:2483-2489` pre_submit busy-token strip을 실경유. 내 재실행 **11/11 PASS** (t2_6 포함, rc=4 단언). | ✅ 반영 |
| F4 | e2e/task.md 실측 Job ID 갱신 (`e59778a0`, `2286634c`, `f657a7fe`, `512cfec7`) | Job ID 치환 자체는 완료. **그러나 갱신된 표의 내용이 레지스트리 원본과 불일치 — 아래 §2 상세.** | ⚠️ 불완전 |
| F5 | 가상 경로 정정 (`turn_handshake.py`→`watcher.py` 등) | 전체 그레프: 잔여 매칭은 실재 파일명/심볼(`tests/test_issue6_turn_handshake.py` 테스트 파일명, `lib_py/loop/watcher.py`, `lib.sh: wait_for_agent_idle`)뿐 — 가상 경로 0건 | ✅ 반영 |
## 2. 핵심 결함 완결성 최종 평가 (F4 상세 포함)
**코드 완결성 — 1차 검토(`512cfec7`) 결론 유지, 전부 재확인**: `finalize_unfinished` 8경로, `close --request` CAS 터미널 불변성("conflicting ... after terminal; refusing"), `attempt_id` 단조 증가 ABA 방지, `wait_for_agent_idle` 구독자 선행 실행, watcher `--session` 격리, 네이티브 단일 주입 + stalled 재제출 금지(재확인 후 `working`→수용 / rc=4). 이번 라운드 재실행: `test_issue6_fail_closed.py` + `test_issue6_turn_handshake.py` + `test_version_consistency.py` = **30/30 PASS**.
**그러나 F4가 생성한 E2E 문서에 사실 부정확이 있다** — 레지스트리 원본 대조 결과:
- `docs/contracts/issue-6/phase5-e2e.md:35` (3행, `f657a7fe`/codex): 최종 상태 `completed`로 기재 — **실제 레지스트리는 `error`** (subscriber rc=2 timeout → `finalize_unfinished` 종결, 08:30:48 error 이벤트). 게다가 해당 잡의 산출물은 `[VERDICT: NOT PASS]`(F1~F5를 낳은 1차 리뷰)였다 — 어떤 의미로 읽어도 `completed`는 사실이 아니다.
- `:36` (4행, `512cfec7`/opencode — 나의 1차 검토 잡): 최종 상태 `completed`로 기재 — **실제 레지스트리는 `error`** (내 풀스위트 17분 실행이 구독자 idle 예산을 초과, timeout 종결; 리포트는 정상 전달되었고 completed 발행은 CAS가 정당히 거부).
- `:52-53` "subscriber가 completed 이벤트 수신 즉시 rc=0으로 클린 종료됨" — 2행(3·4번)에 대해서는 사실이 아님: 실제 시퀀스는 timeout → fail-closed error 확정 → 늦은 completed CAS 거부였다.
**아이러니**: 이 두 행의 실제 시퀀스는 결함 ① 패치가 **의도한 대로 작동했음을 보여주는 최적의 실측 증거다** (fail-closed 종결 + 터미널 불변성). 문서가 이를 `completed`로 미화하는 바람에, 오히려 진실(fail-closed 실전 검증)이 가려졌다. 이는 본 세션이 일관되게 기각해온 '합의/상태 왜곡' 계열(`aca0b7e8`, `50cb5ac0` 계열)과 같은 유형의 문서 무결성 결함이다.
## 3. 엣지 케이스 및 회귀 위험도 평가
- **코드 측 신규 위험 없음**: 이번 라운드 변경은 F1(트랩 해제, 3줄)·F2(문서)·F3(테스트 보강)·F5(문서)로, 코드 가치에는 무영향. 30/30 재통과 확인.
- **900초 잡 예산 vs 장시간 검토 작업**: 512cfec7/f657a7fe 사례가 드러낸 운영 특성 — 풀스위트(17분)를 돌리는 리뷰어는 구독자 idle 타임아웃(300s)을 초과한다. 이는 버그가 아니라 **문서화할 운영 파라미터**다: 장시간 검토 잡에는 상위 `--timeout/--idle-timeout` 상향 위임이 필요. e2e 문서 수정 시 이 권고를 함께 기입할 것.
- **나머지 2행(e59778a0, 2286634c)과 하단 주석 4건(6f091091 등)은 레지스트리와 일치** (전부 completed 실측 확인) — 오염은 2행 + 클린 종료 서술뿐.
## 4. 최종 판정
F1·F2·F3·F5는 정확히 반영되었고, 코드 수준의 완결성은 1차와 동일하게 훌륭하 (30/30 재검증). 그러나 **F4의 "실측 데이터 동기화"가 오히려 2개 행의 최종 상태를 레지스트리 원본과 반대로 기재**했고, 이는 검증 근거 문서(evidence document)의 사실 정확성 결함으로서 본 세션의 문서 무결성 기준상 차단 대상이다. 수정은 자기조직적이고 경미하다: `docs/contracts/issue-6/phase5-e2e.md:35-36` 두 셀을 `error (subscriber timeout; fail-closed 종결 — 리포트 산출물 정상 전달, completed는 터미널 불변성에 의해 거부됨)` 계열의 사실 서술로 바로잡고, `:52-53`의 클린 종료 서술에 타임아웃-종결 시나리오를 명시하라. 코드 커밋은 이 수정과 함께 진행 가능하다 — 코드 자체는 PASS 수준이며, 본 NOT PASS는 문서 2셀+1서술의 정확화 요구다.
[VERDICT: NOT PASS]
@@ -0,0 +1,40 @@
### [reviewer-opencode-01] Issue #6 구현 결과물 3차 최종 재검토 의견
- **Job**: `8b455e21` / **검토자**: `reviewer-opencode-01` (1차 `512cfec7`, 2차 `e9c5db1d`의 작성자)
- **검토 방법**: 7개 조치 항목을 라이브 소스에서 직접 읽어 라인 단위 확인, 27개 Issue #6 테스트 재실행, **전체 회귀 스위트 직접 실행 (777/777, 19분)**, E2E 문서 진실성 재대조.
---
## 1. 7개 조치 항목 반영 확인 (라인 단위 실측)
| # | 항목 | 실측 검증 | 판정 |
|---|---|---|---|
| F4 | E2E 문서 진실성 | `phase5-e2e.md` 3·4행이 이제 `error` + "fail-closed 확정 실증 (리포트 정상 전달, 후속 completed CAS 정상 거부)"로 기재 — 레지스트리 원본과 100% 일치. :46 서술도 timeout→finalize→CAS-거부의 실제 시퀀스로 정정. 내 2차 지적을 정확히, 그리고 **과하게 미화하지 않게** 반영 | ✅ |
| R1 | Working-agent timeout 재관측 | `finalize_unfinished` timeout 분기(:600-655): subscriber 타임아웃 시 `herdr agent get`으로 `working` 확인 → 남은 데드라인 동안 3초 간격 디스크 재관측, 늦은 completed 수용, agent 정지 시 1초 유예+최종 디스크 확인 후 종료 | ✅ |
| P1 | CAS 토큰 불일치 우회 차단 | close rc=4(토큰 불일치/stale attempt) → "suppressing event emission" 후 이벤트 발행 스킵(:681-685); direct 발행 전 디스크 completed 재확인 → 이중 error 발행 억제 | ✅ |
| P1 | busy-token 만료 안전 반환 | `lib.sh:2420-2435`: `SKS_BUSY_TIMEOUT` 후에도 `still_busy`면 강제 주입 없이 `rc=4` 반환 | ✅ |
| P1 | 불확실 RPC 재시도 방지 | `lib.sh:2395`: 패턴에 `transport_error\|connection` 추가 → paste-buffer 폴백으로 떨어지지 않고 `agent get` 상태 확인 후 `working`이면 수용, 아니면 `rc=4` | ✅ |
| P1 | Runner absent-record hang 방지 | `runner.py:318-333`: 레코드 부재 5회 연속 + 최소 5초 경과 → "aborting wait" 로그 후 `return False` (3,900초 블로킹 제거) | ✅ |
| P2 | Validate hook 도달성 | reviewer 승인/최대 반복 경로가 `final_loop_rc` 설정 후 `break` → 루프 탈출 후 `--validate` 훅 수행 후 `exit "$final_loop_rc"` (:486-520). (worker/reviewer *실패* 경로는 기존대로 즉시 `exit` — 실패 시 검증할 산출물이 없어 의도적이며 SKILL.md 종료 코드 계약과 정합) | ✅ |
## 2. 테스트 검증 (직접 실행)
- `test_issue6_fail_closed.py`(16) + `test_issue6_turn_handshake.py`(11) = **27/27 PASS** (내 실행, -v 확인). t2_6/t2_7/t2_9/t2_11 등 2차 보완 항목의 회귀 가드 전부 통과.
- **전체 회귀**: `pytest tests/ -q` 내 직접 실행 — **777 passed / 0 failed (1,142s)**. 기존 750 + 신규 27, 회귀 없음.
- `test_version_consistency.py` 3/3 (v4.4.1 락스텝 유지).
## 3. 엣지 케이스 및 회귀 위험도 평가
- **R1의 무한 대기 여지**: 재관측 루프는 남은 데드라인(`TIMEOUT - elapsed`)으로 상한이 걸려 있어 유한 — `--timeout` 예산 내에서만 늦은 완료를 수용한다. `remaining=0` 즉시 종결. 안전.
- **R1의 상태 조회 실패**: `agent get` 실패 시 `agent_st=""` → working 아님 → 즉시 finalize 경로로 복귀. fail-open 없음.
- **P2의 의도적 비대칭**: 실패 경로가 validate를 우회하는 것은 산출물 부재 상황에서 정당하며, 종료 코드 계약(`_rc_for`)을 지키기 위한 필수. 승인 경로만 break하는 설계는 옳다.
- **잔여 관찰 (비차단)**:
1. R1의 working-agent 재관측 루프(~50줄)와 P1의 wrapper-level rc=4 발행 억제, P1-3의 transport_error 분기에 **전용 회귀 테스트가 없다** (기존 t1_3/t1_5/t1_11은 인접 계약을 커버하지만 이 신규 분기 자체는 무커버). 후속 패치에서 가드 추가 권고.
2. 브리프가 "총 30개 테스트"로 기재했으나 실제 16+11=27개 — 브리프 측 계수 오류 (코드 결함 아님).
3. 900초 잡 예산 vs 장시간 검토(풀스위트 19분)의 운영 특성은 R1로 완화되었으나, 근본적으로는 상위 위임 시 `--timeout` 상향 위임이 권고됨 (이미 e2e 문서 :46에 기재).
## 4. 최종 판정
2차에서 내가 지적한 문서 진실성 결함(F4)이 사실 그대로 정정되었고, 2차에서 다른 리뷰어들이 지적한 6개 코드 보강(R1, P1×4, P2)이 전부 정확히 구현되었다. 이번 라운드의 신규 변경도 포함해 **27/27 대상 테스트와 777 전체 스위트를 내가 직접 실행해 전부 통과**했으며, v4.4.1 4-way 락스텝도 유지된다. 잔여 사항은 신규 분기의 테스트 가드 부재라는 권고 수준(비차단)과 브리프의 테스트 계수 오류(문서 오탈자 수준)뿐이다. Issue #6은 결함 ①(fail-closed/CAS/ABA)과 결함 ②(턴 핸드셰이크/단일 주입) 모두에서 구조적으로 완결되었고, E2E 실측 문서는 이제 레지스트리 원본과 정확히 일치한다.
[VERDICT: PASS]
+187 -12
View File
@@ -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.4.0"
MAM_VERSION="4.4.1"
export MAM_VERSION
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
@@ -237,7 +237,11 @@ _real_herdr() {
session=""
fi
if [ -n "$session" ]; then
"$REAL_HERDR" --session "$session" "$@"
if [ "${1:-}" = "--session" ] || [ "${1:-}" = "-s" ]; then
"$REAL_HERDR" "$@"
else
"$REAL_HERDR" --session "$session" "$@"
fi
else
"$REAL_HERDR" "$@"
fi
@@ -1896,12 +1900,17 @@ _delegate_submit_job_legacy() {
# a missing script, or a broker failure never aborts the caller.
_delegate_publish_event_legacy() {
local job_id="$1" event="$2" detail="${3:-}"
local tok="${4:-}" att="${5:-}"
[ -n "$job_id" ] || return 0
local py_bin pub
py_bin="$(_delegate_py_bin)"
pub="$(_delegate_script publish_event.py)"
[ -n "$pub" ] && [ -f "$pub" ] || return 0
"$py_bin" "$pub" --job "$job_id" --event "$event" --detail "$detail" || true
local -a extra=()
[ -n "${MAM_JOBS_DIR:-}" ] && extra+=(--registry-dir "$MAM_JOBS_DIR")
[ -n "$tok" ] && extra+=(--expect-dispatch-token "$tok")
[ -n "$att" ] && extra+=(--attempt-id "$att")
"$py_bin" "$pub" --job "$job_id" --event "$event" --detail "$detail" ${extra[@]+"${extra[@]}"} || true
}
# start_watchdog <job_id> [workdir]
@@ -1985,12 +1994,17 @@ delegate_submit_job() {
# failure must never abort the caller.
delegate_publish_event() {
local job_id="$1" event="$2" detail="${3:-}"
local tok="${4:-}" att="${5:-}"
[ -n "$job_id" ] || return 0
if [ "${MAM_DELEGATION_PY:-1}" = "0" ]; then _delegate_publish_event_legacy "$@"; return $?; fi
local pub
pub="$(_delegate_script publish_event.py)"
[ -n "$pub" ] && [ -f "$pub" ] || return 0
_delegation_py publish --job "$job_id" --event "$event" --detail "$detail" || true
local -a extra=()
[ -n "${MAM_JOBS_DIR:-}" ] && extra+=(--registry-dir "$MAM_JOBS_DIR")
[ -n "$tok" ] && extra+=(--expect-dispatch-token "$tok")
[ -n "$att" ] && extra+=(--attempt-id "$att")
_delegation_py publish --job "$job_id" --event "$event" --detail "$detail" ${extra[@]+"${extra[@]}"} || true
}
# ensure_subscriber_daemon <job_id> [workdir]
@@ -2219,6 +2233,91 @@ _pane_dialog_open() {
_pane_tail "$1" "${MAM_DIALOG_TAIL_LINES:-8}" | grep -Eq "$pat"
}
# wait_for_agent_idle <sess> [timeout_ms]
# Phase 2 사전 TUI 유휴 핸드셰이크:
# 0: idle / done (성공)
# 1: agent still working (최종 타임아웃)
# 2: blocked (대화창/모달 차단)
# 3: unknown (휴리스틱 폴백 신호)
wait_for_agent_idle() {
local sess="${1#herdr:}"
local timeout_ms="${2:-${SKS_IDLE_TIMEOUT_MS:-120000}}"
if [ "${SKS_IDLE_HANDSHAKE:-1}" = "0" ]; then
return 0
fi
local target
target=$(_sanitize_herdr_agent_name "$sess")
local raw_status="" agent_st=""
raw_status="$(_sks_herdr agent get "$target" 2>/dev/null || _sks_herdr agent get "$sess" 2>/dev/null || true)"
if [ -n "$raw_status" ]; then
agent_st="$("$(_delegate_py_bin)" -c '
import sys, json
try:
d = json.loads(sys.stdin.read())
print((d.get("result", {}).get("agent", {}) or {}).get("agent_status", "") or "")
except Exception:
print("")
' <<< "$raw_status" 2>/dev/null || true)"
fi
if [ -z "$agent_st" ] || [ "$agent_st" = "unknown" ]; then
return 3
fi
if [ "$agent_st" = "blocked" ]; then
return 2
fi
if [ "$agent_st" = "idle" ] || [ "$agent_st" = "done" ]; then
return 0
fi
local max_retries="${SKS_IDLE_RETRIES:-2}"
local backoff_s="${SKS_IDLE_RETRY_BACKOFF_S:-10}"
local try=0
while [ "$try" -le "$max_retries" ]; do
local watcher_out="" watcher_rc=0
watcher_out="$("$(_delegate_py_bin)" -m lib_py.loop.watcher wait "$target" \
--session "${HERDR_SESSION_NAME:-default}" \
--until idle --until done --until blocked \
--timeout-ms "$timeout_ms" 2>/dev/null)" || watcher_rc=$?
local parsed_st=""
if [ -n "$watcher_out" ]; then
parsed_st="$("$(_delegate_py_bin)" -c '
import sys, json
try:
d = json.loads(sys.stdin.read())
print(d.get("agent_status", "") or "")
except Exception:
print("")
' <<< "$watcher_out" 2>/dev/null || true)"
fi
if [ $watcher_rc -eq 0 ]; then
if [ "$parsed_st" = "blocked" ]; then
return 2
fi
return 0
elif [ $watcher_rc -eq 2 ]; then
if [ "$parsed_st" = "blocked" ]; then
return 2
fi
if [ "$try" -lt "$max_retries" ]; then
sleep "$backoff_s"
try=$((try + 1))
continue
else
return 1
fi
else
return 3
fi
done
return 1
}
# send_keys_safe <sess> <text> [job_id]
# 1. Wait for renderer quiescence (RC-A). If unobservable (headless), bypass visual checks.
# 2. Refuse to paste while a dialog is open (RC-B/RC-C): wait up to
@@ -2232,8 +2331,7 @@ send_keys_safe() {
local sess="$1" text="$2" job_id="${3:-adhoc}"
local pre_submit deadline try
local MAM_MODAL_TOKENS="${MAM_MODAL_TOKENS:-}"
local MAM_AGENT_NAME MAM_OWN_KEY MAM_INPUT_PROMPT MAM_INPUT_PLACEHOLDER MAM_INPUT_RULE_PATTERN
local MAM_READY_TOKENS MAM_STRONG_READY_TOKENS MAM_WEAK_READY_TOKENS MAM_EXIT_KEY MAM_DELEGATE_AGENT_KEY
local MAM_AGENT_NAME MAM_OWN_KEY MAM_INPUT_PROMPT MAM_INPUT_PLACEHOLDER MAM_INPUT_RULE_PATTERN MAM_READY_TOKENS MAM_STRONG_READY_TOKENS MAM_WEAK_READY_TOKENS MAM_EXIT_KEY MAM_DELEGATE_AGENT_KEY
if [ -z "${MAM_MODAL_TOKENS:-}" ]; then
local _sks_agent=""
@@ -2274,10 +2372,83 @@ send_keys_safe() {
local agent_target
agent_target=$(_sanitize_herdr_agent_name "$sess")
# Native herdr 0.8+ fast path: agent prompt handles atomic text + enter submission
# Gated behind quiescence and dialog checks; returns 0 on RPC success to prevent duplicate input
if _sks_herdr agent prompt "$agent_target" "$text" >/dev/null 2>&1 || _sks_herdr agent prompt "$sess" "$text" >/dev/null 2>&1; then
return 0
# Busy gate: wait up to SKS_BUSY_TIMEOUT only if wait_for_agent_idle returned 3 (unknown)
if [ "${_SKS_IDLE_RC:-0}" = "3" ]; then
local b_deadline=$(( $(date +%s) + ${SKS_BUSY_TIMEOUT:-120} ))
local still_busy=1
while [ "$(date +%s)" -lt "$b_deadline" ]; do
local tail_pane
tail_pane=$(_pane_tail "$sess" 8)
if ! printf '%s\n' "$tail_pane" | grep -Fq "esc to interrupt" && ! printf '%s\n' "$tail_pane" | grep -Eq "● |✽ |[A-Za-z]+ing"; then
still_busy=0
break
fi
sleep 1
done
if [ "$still_busy" -eq 1 ]; then
echo "send_keys_safe: pane still busy after SKS_BUSY_TIMEOUT ($sess)" >&2
return 4
fi
fi
# Native herdr 0.8+ fast path: agent prompt handles atomic submission
if [ "${SKS_PROMPT_WAIT:-1}" = "1" ]; then
local p_out="" p_rc=0
local _p_tmp
_p_tmp=$(mktemp 2>/dev/null || echo "/tmp/sks_p_${sess}_$$")
if _sks_herdr agent prompt "$agent_target" "$text" --wait --until working --timeout 5000 >"$_p_tmp" 2>&1; then
rm -f "$_p_tmp" 2>/dev/null || true
return 0
fi
p_rc=$?
p_out=$(cat "$_p_tmp" 2>/dev/null || true)
rm -f "$_p_tmp" 2>/dev/null || true
if printf '%s\n' "$p_out" | grep -Fq "agent_not_found"; then
local _p_tmp2
_p_tmp2=$(mktemp 2>/dev/null || echo "/tmp/sks_p2_${sess}_$$")
if _sks_herdr agent prompt "$sess" "$text" --wait --until working --timeout 5000 >"$_p_tmp2" 2>&1; then
rm -f "$_p_tmp2" 2>/dev/null || true
return 0
fi
p_rc=$?
p_out=$(cat "$_p_tmp2" 2>/dev/null || true)
rm -f "$_p_tmp2" 2>/dev/null || true
fi
if printf '%s\n' "$p_out" | grep -Fq "agent_blocked"; then
echo "send_keys_safe: agent blocked ($sess)" >&2
return 2
fi
# Only fall through to legacy paste-buffer if agent prompt command itself is not supported
# or if target agent is not found (generic pane / terminal)
if printf '%s\n' "$p_out" | grep -Eq "unknown command|unrecognized command|no such command|unexpected argument|unrecognized option|agent_not_found"; then
: # Fall through to legacy paste buffer
else
# Prompt was dispatched via RPC; on any error (empty output, timeout, stalled, transport error, etc.),
# do NOT resubmit via paste buffer (single injection contract). Check agent get after 2s.
sleep 2
local check_raw check_st
check_raw="$(_sks_herdr agent get "$agent_target" 2>/dev/null || _sks_herdr agent get "$sess" 2>/dev/null || true)"
check_st="$("$(_delegate_py_bin)" -c '
import sys, json
try:
d = json.loads(sys.stdin.read())
print((d.get("result", {}).get("agent", {}) or {}).get("agent_status", "") or "")
except Exception:
print("")
' <<< "$check_raw" 2>/dev/null || true)"
if [ "$check_st" = "working" ]; then
return 0
fi
echo "send_keys_safe: delivery unknown/failed ($sess)" >&2
return 4
fi
else
if _sks_herdr agent prompt "$agent_target" "$text" >/dev/null 2>&1 || _sks_herdr agent prompt "$sess" "$text" >/dev/null 2>&1; then
return 0
fi
fi
# Fallback: paste buffer submission. Compute verification markers on demand.
@@ -2328,8 +2499,12 @@ send_keys_safe() {
sleep "$try"
local cur_content
cur_content=$(_pane_capture "$sess")
# Hardened Submission Checks
if printf '%s\n' "$cur_content" | grep -Fq "esc to interrupt" || printf '%s\n' "$cur_content" | grep -Eq "● |✽ |[A-Za-z]+ing"; then
# Hardened Submission Checks: exclude busy tokens if already present in pre_submit
local pre_busy=0
if printf '%s\n' "$pre_submit" | grep -Fq "esc to interrupt" || printf '%s\n' "$pre_submit" | grep -Eq "● |✽ |[A-Za-z]+ing"; then
pre_busy=1
fi
if [ "$pre_busy" -eq 0 ] && ( printf '%s\n' "$cur_content" | grep -Fq "esc to interrupt" || printf '%s\n' "$cur_content" | grep -Eq "● |✽ |[A-Za-z]+ing" ); then
return 0
fi
if [ "$was_popup" = "0" ] && ! _pane_tail "$sess" 3 | tr -d '[:space:]' | grep -Fq "$marker_norm" && [ "$cur_content" != "$pre_submit" ]; then
+6
View File
@@ -39,6 +39,8 @@ def _build_parser() -> argparse.ArgumentParser:
p_pub.add_argument("--retained", action="store_true")
p_pub.add_argument("--registry-dir", default=None)
p_pub.add_argument("--attempts", type=int, default=3)
p_pub.add_argument("--expect-dispatch-token", default=None)
p_pub.add_argument("--attempt-id", type=int, default=None)
p_pub.add_argument("-v", "--verbose", action="store_true")
p_daemon = sub.add_parser("ensure-daemon", help="start the subscriber daemon if absent")
@@ -68,6 +70,10 @@ def main(argv: Optional[List[str]] = None) -> int:
forward += ["--retained"]
if args.registry_dir:
forward += ["--registry-dir", args.registry_dir]
if args.expect_dispatch_token:
forward += ["--expect-dispatch-token", args.expect_dispatch_token]
if args.attempt_id is not None:
forward += ["--attempt-id", str(args.attempt_id)]
if args.verbose:
forward += ["--verbose"]
return publish_event.main(forward)
+28 -3
View File
@@ -88,7 +88,7 @@ ATTEMPT_STARTING_REQUESTS = ("claim", "redispatch")
# Only these sources may move a record out of a terminal state. An agent can
# never walk back its own terminal; that is the whole point of the CAS table.
OPERATOR_SOURCES = ("registry_cli", "operator")
OPERATOR_SOURCES = ("registry_cli", "operator", "delegate_wrapper")
OPERATOR_REQUESTS = ("redispatch", "finalize")
# A caller that merges `status=<value>` is making the request that owns that
@@ -178,20 +178,27 @@ def _decide(record: Dict[str, Any], request: str, expected_status, expected_atte
"stale attempt: expected {!r}, record has {!r}".format(
expected_attempt, actual))
requested = REQUEST_TO_STATUS.get(request, None)
if expected_dispatch_token is not None:
actual_token = record.get(DISPATCH_TOKEN_FIELD)
if actual_token != expected_dispatch_token:
return _Decision(False, current,
"dispatch token changed: the attempt this decision was "
"made for is no longer the current one")
elif expected_attempt is None and source not in OPERATOR_SOURCES and requested in TERMINAL_STATUSES:
actual_token = record.get(DISPATCH_TOKEN_FIELD)
rec_attempt = record.get("attempt_id", record.get("iteration", 1))
if actual_token is not None and rec_attempt > 1:
return _Decision(False, current,
"dispatch token required: redispatched job carries a dispatch token "
"but none was provided")
if expected_status is not None and current != expected_status:
return _Decision(False, current,
"precondition failed: expected status {!r}, found {!r}".format(
expected_status, current))
requested = REQUEST_TO_STATUS.get(request, None)
# Authority first, so a refusal says *why* rather than blaming the state.
if request in OPERATOR_REQUESTS and source not in OPERATOR_SOURCES:
return _Decision(False, current,
@@ -228,6 +235,15 @@ def _decide(record: Dict[str, Any], request: str, expected_status, expected_atte
if operator and request == "redispatch":
# Re-dispatching a finished job to the next iteration. The attempt
# must move forward, so a replayed redispatch cannot resurrect it.
new_attempt = fields.get("attempt_id")
if new_attempt is not None:
old_attempt = record.get("attempt_id", record.get("iteration"))
if old_attempt is not None and int(new_attempt) <= int(old_attempt):
return _Decision(False, current,
"redispatch attempt_id {!r} does not advance past {!r}".format(
new_attempt, old_attempt), conflict=True)
return _Decision(True, "pending", "{} -> pending (redispatch)".format(current))
new_iteration = fields.get("iteration")
old_iteration = record.get("iteration")
if new_iteration is None:
@@ -298,6 +314,15 @@ def transition(job_id, request="merge", *, registry_dir=None, expected_status=No
# A new attempt begins here, so anything decided about the
# previous one stops being committable from this moment on.
record[DISPATCH_TOKEN_FIELD] = uuid.uuid4().hex
if "attempt_id" in fields:
record["attempt_id"] = fields["attempt_id"]
elif "iteration" in fields:
record["attempt_id"] = fields["iteration"]
else:
record.setdefault("attempt_id", record.get("iteration", 1))
elif record.get(DISPATCH_TOKEN_FIELD) is None:
record[DISPATCH_TOKEN_FIELD] = uuid.uuid4().hex
record.setdefault("attempt_id", record.get("iteration", 1))
if notify is not None:
# One atomic commit records both the new state and the fact that
# the world has not been told yet. If the process dies before the
@@ -61,15 +61,21 @@ def _check_disk_fallback(
pending: Set[str],
registry_dir: str,
terminal: Dict[str, str],
expected_dispatch_tokens: Optional[Dict[str, Optional[str]]] = None,
expected_attempts: Optional[Dict[str, Optional[int]]] = None,
) -> None:
"""Check local on-disk registry and audit logs for terminal status (B-15)."""
for jid in list(pending):
disk_status = None
rec_token = None
rec_attempt = None
try:
job_rec = load_job(jid, registry_dir)
disk_status = job_rec.get("status")
rec_token = job_rec.get("dispatch_token")
rec_attempt = job_rec.get("attempt_id", job_rec.get("iteration"))
except Exception:
pass
job_rec = {}
if not disk_status or disk_status not in ("completed", "error", "cancelled"):
try:
disk_status = mqtt_common.read_logged_status(jid, mqtt_common.get_logs_dir())
@@ -77,6 +83,18 @@ def _check_disk_fallback(
pass
if disk_status in ("completed", "error", "cancelled"):
if expected_dispatch_tokens:
exp_token = expected_dispatch_tokens.get(jid)
if exp_token is not None and rec_token != exp_token:
logger.warning("disk fallback ignored terminal status for %s due to dispatch_token mismatch (%r != %r)",
jid, rec_token, exp_token)
continue
if expected_attempts:
exp_att = expected_attempts.get(jid)
if exp_att is not None and rec_attempt is not None and rec_attempt < exp_att:
logger.warning("disk fallback ignored terminal status for %s due to attempt_id older than expected (%r < %r)",
jid, rec_attempt, exp_att)
continue
synth_event = "completed" if disk_status == "completed" else "error"
synth_payload = {
"job_id": jid,
@@ -95,11 +113,15 @@ def _check_disk_fallback(
class _Watcher:
"""Holds the shared queue + the set of job_ids we accept events for."""
def __init__(self, expected_job_ids: Set[str], expected_tokens: Dict[str, Optional[str]], expected_seqs: Dict[str, int]):
def __init__(self, expected_job_ids: Set[str], expected_tokens: Dict[str, Optional[str]], expected_seqs: Dict[str, int],
expected_dispatch_tokens: Optional[Dict[str, Optional[str]]] = None,
expected_attempts: Optional[Dict[str, Optional[int]]] = None):
self.events: "queue.Queue[Tuple[str, Dict[str, Any]]]" = queue.Queue()
self.expected = set(expected_job_ids)
self.tokens = expected_tokens # job_id -> expected auth_token (or None)
self.last_seq = dict(expected_seqs)
self.dispatch_tokens = dict(expected_dispatch_tokens or {})
self.attempts = dict(expected_attempts or {})
# Intent identities already accepted, per job. A replayed notification is
# re-published with a *fresh* wire sequence, so the monotonic sequence
# guard below cannot recognise it: to that guard a retry looks like a new
@@ -135,6 +157,24 @@ class _Watcher:
if not mqtt_common.verify_hmac(payload, expected_token):
logger.warning("drop event for job %s: HMAC verify failed", jid)
return
# --- CAS dispatch token & attempt check: drop stale attempt events ---
expected_disp_tok = self.dispatch_tokens.get(jid)
if expected_disp_tok is not None:
data = payload.get("data")
ev_tok = data.get("dispatch_token") if isinstance(data, dict) else None
if ev_tok != expected_disp_tok:
logger.warning("drop event for job %s: dispatch_token %r does not match expected %r",
jid, ev_tok, expected_disp_tok)
return
expected_att = self.attempts.get(jid)
if expected_att is not None:
data = payload.get("data")
ev_att = data.get("attempt_id") if isinstance(data, dict) else None
if ev_att is not None and isinstance(ev_att, int) and ev_att < expected_att:
logger.warning("drop event for job %s: attempt_id %r is older than expected %r",
jid, ev_att, expected_att)
return
# --- replay attack defense: check monotonic sequence ---
seq = payload.get("seq")
if seq is None or not isinstance(seq, int):
@@ -216,11 +256,21 @@ def _run_subscriber(args) -> int:
expected_ids: Set[str] = {j["job_id"] for j in jobs}
tokens = {j["job_id"]: j.get("auth_token") for j in jobs}
seqs = {}
dispatch_tokens = {}
attempts = {}
for j in jobs:
jid = j["job_id"]
last_seq = int(j.get("last_seq", 0))
seqs[jid] = max(0, last_seq - 1)
watcher = _Watcher(expected_ids, tokens, seqs)
dispatch_tokens[jid] = j.get("dispatch_token")
attempts[jid] = j.get("attempt_id", j.get("iteration", 1))
if args.expect_dispatch_token is not None and args.job:
dispatch_tokens[args.job] = args.expect_dispatch_token
if args.attempt_id is not None and args.job:
attempts[args.job] = args.attempt_id
watcher = _Watcher(expected_ids, tokens, seqs, dispatch_tokens, attempts)
# Resolve timeouts from CLI, falling back to the (first) job's settings.
base_job = jobs[0]
@@ -251,7 +301,18 @@ def _run_subscriber(args) -> int:
if rc != 0:
logger.warning("broker disconnected (rc=%s); will retry reconnect", reason_code)
start = time.monotonic()
wall_deadline = start + wall_timeout
last_event = start
last_disk_check = 0.0
retention_checked = not args.expect_retention
connected = False
subscribed = False
def on_subscribe(_c, _u, mid, granted_qos, _props=None):
nonlocal last_event, subscribed
subscribed = True
last_event = time.monotonic()
for topic in subscribed_topics:
print(f"SUBSCRIBED {topic}", flush=True)
@@ -262,15 +323,9 @@ def _run_subscriber(args) -> int:
terminal: Dict[str, str] = {} # job_id -> "completed"/"error"
pending: Set[str] = set(expected_ids)
start = time.monotonic()
wall_deadline = start + wall_timeout
last_event = start
last_disk_check = 0.0
retention_checked = not args.expect_retention
connected = False
# Check if all jobs are already in terminal state on disk before connecting
_check_disk_fallback(pending, args.registry_dir, terminal)
_check_disk_fallback(pending, args.registry_dir, terminal, dispatch_tokens, attempts)
if not pending:
if any(state == "error" for state in terminal.values()):
return 1
@@ -283,9 +338,10 @@ def _run_subscriber(args) -> int:
)()
client.loop_start()
connected = True
last_event = time.monotonic()
except Exception as exc:
logger.warning("broker connection failed (%s); checking disk fallback", exc)
_check_disk_fallback(pending, args.registry_dir, terminal)
_check_disk_fallback(pending, args.registry_dir, terminal, dispatch_tokens, attempts)
if not pending:
if any(state == "error" for state in terminal.values()):
return 1
@@ -297,15 +353,17 @@ def _run_subscriber(args) -> int:
while pending:
now = time.monotonic()
if now >= wall_deadline:
_check_disk_fallback(pending, args.registry_dir, terminal)
_check_disk_fallback(pending, args.registry_dir, terminal, dispatch_tokens, attempts)
if not pending:
break
logger.error("wall-clock timeout (%.0fs); still pending: %s",
wall_timeout, ", ".join(sorted(pending)))
return 2
if not subscribed:
last_event = now
idle_left = idle_timeout - (now - last_event)
if idle_left <= 0:
_check_disk_fallback(pending, args.registry_dir, terminal)
_check_disk_fallback(pending, args.registry_dir, terminal, dispatch_tokens, attempts)
if not pending:
break
logger.error("idle timeout (%.0fs, no events); still pending: %s",
@@ -323,7 +381,7 @@ def _run_subscriber(args) -> int:
# Step 2 (B-15): Local disk fallback throttled to every 3.0s
if (now - last_disk_check) >= 3.0:
last_disk_check = now
_check_disk_fallback(pending, args.registry_dir, terminal)
_check_disk_fallback(pending, args.registry_dir, terminal, dispatch_tokens, attempts)
continue
last_event = time.monotonic()
@@ -365,6 +423,10 @@ def main(argv=None) -> int:
help="max seconds with no new event (default: job.idle_timeout_sec or 120)")
parser.add_argument("--expect-retention", action="store_true",
help="warn if no retained terminal event arrives promptly")
parser.add_argument("--expect-dispatch-token", default=None,
help="expected dispatch token for CAS verification")
parser.add_argument("--attempt-id", type=int, default=None,
help="minimum attempt id to accept events for")
parser.add_argument("--registry-dir", default=DEFAULT_REGISTRY_DIR)
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args(argv)
@@ -240,7 +240,7 @@ def broker_config_from_env(overrides: Optional[Dict[str, Any]] = None) -> Broker
for any key it specifies with a non-None value.
"""
cfg = BrokerConfig(
host=os.environ.get("MQTT_BROKER", "broker.hivemq.com"),
host=os.environ.get("MQTT_BROKER") or os.environ.get("MQTT_HOST", "broker.hivemq.com"),
port=_env_int("MQTT_PORT", 1883),
tls=_env_bool("MQTT_TLS", False),
username=os.environ.get("MQTT_USERNAME") or None,
@@ -428,12 +428,16 @@ def transition_job_status(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR,
from lib_py.delegation import engine # same package: no sibling hop (see __init__)
request = "merge"
expected_dispatch_token = fields.pop("expected_dispatch_token", None)
expected_attempt = fields.pop("expected_attempt", None)
if "status" in fields:
request = engine.STATUS_TO_REQUEST.get(fields["status"], "merge")
if request != "merge":
fields = {k: v for k, v in fields.items() if k != "status"}
return engine.transition(job_id, request, registry_dir=registry_dir,
fields=fields, source=_source)
fields=fields, source=_source,
expected_dispatch_token=expected_dispatch_token,
expected_attempt=expected_attempt)
def _update_job_status_legacy(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR, **fields: Any) -> Dict[str, Any]:
@@ -127,7 +127,8 @@ class PreparedEvent(object):
def prepare_event(job_id, event, detail="", data=None,
registry_dir=DEFAULT_REGISTRY_DIR, retained=False) -> PreparedEvent:
registry_dir=DEFAULT_REGISTRY_DIR, retained=False,
dispatch_token=None, attempt_id=None) -> PreparedEvent:
"""Load the job, consume the next seq, and build the signed payload.
Consuming the seq here (before any transport runs) is deliberate: a failed
@@ -139,12 +140,21 @@ def prepare_event(job_id, event, detail="", data=None,
topic = job.get("topic_prefix")
topic = f"{topic}/events" if topic else events_topic_for(job_id)
seq = next_seq(job_id, registry_dir)
payload_data = dict(data) if data else {}
eff_dispatch_token = dispatch_token
if eff_dispatch_token is not None and "dispatch_token" not in payload_data:
payload_data["dispatch_token"] = eff_dispatch_token
eff_attempt_id = attempt_id
if eff_attempt_id is not None and "attempt_id" not in payload_data:
payload_data["attempt_id"] = eff_attempt_id
payload = build_payload(
job_id=job_id,
seq=seq,
event=event,
detail=detail,
data=data,
data=payload_data,
auth_token=job.get("auth_token"),
)
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
@@ -153,7 +163,9 @@ def prepare_event(job_id, event, detail="", data=None,
def record_outcome(prepared: PreparedEvent, registry_dir: str,
publish_ok: bool, publish_error: Optional[str]) -> None:
publish_ok: bool, publish_error: Optional[str],
expected_dispatch_token: Optional[str] = None,
expected_attempt: Optional[int] = None) -> None:
"""Persist the audit record, the registry event log and the status sync.
All three are best effort and all three happen whether or not the publish
@@ -176,10 +188,19 @@ def record_outcome(prepared: PreparedEvent, registry_dir: str,
new_status = EVENT_TO_STATUS.get(prepared.event)
if new_status:
try:
exp_token = expected_dispatch_token
if exp_token is None and isinstance(prepared.payload.get("data"), dict):
exp_token = prepared.payload["data"].get("dispatch_token")
exp_att = expected_attempt
if exp_att is None and isinstance(prepared.payload.get("data"), dict):
exp_att = prepared.payload["data"].get("attempt_id")
# Agent authority on purpose: an agent may never move a terminal
# record, which is what the operator-only requests are gated for.
mqtt_common.update_job_status(prepared.job_id, registry_dir,
_source="publish_event", status=new_status)
_source="publish_event", status=new_status,
expected_dispatch_token=exp_token,
expected_attempt=exp_att)
except Exception as exc: # pragma: no cover - best effort
logger.warning("status sync failed: %s", exc)
@@ -214,6 +235,8 @@ def main(argv=None) -> int:
parser.add_argument("--attempt-id", type=int, default=None,
help="attempt this finalization belongs to (default: the "
"job's current iteration)")
parser.add_argument("--expect-dispatch-token", default=None,
help="expected dispatch token for CAS verification")
parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args(argv)
@@ -250,7 +273,8 @@ def main(argv=None) -> int:
attempt_id = args.attempt_id
if attempt_id is None:
try:
attempt_id = load_job(job_id, args.registry_dir).get("iteration", 1)
rec = load_job(job_id, args.registry_dir)
attempt_id = rec.get("attempt_id", rec.get("iteration", 1))
except FileNotFoundError:
attempt_id = 1
try:
@@ -263,6 +287,41 @@ def main(argv=None) -> int:
logger.info("finalized %s (sha256 %s...)", manifest["report_path"],
manifest["sha256"][:12])
check_token = args.expect_dispatch_token
if check_token is None and isinstance(data, dict) and "dispatch_token" in data:
check_token = data.get("dispatch_token")
try:
rec = load_job(job_id, args.registry_dir)
actual_tok = rec.get("dispatch_token")
rec_attempt = rec.get("attempt_id", rec.get("iteration", 1))
except FileNotFoundError:
rec = None
actual_tok = None
rec_attempt = 1
check_attempt = args.attempt_id
if check_attempt is None and isinstance(data, dict) and "attempt_id" in data:
check_attempt = data.get("attempt_id")
if rec is not None and actual_tok is not None:
if check_token is None and check_attempt is None:
if args.event in ("completed", "error", "cancelled") and rec_attempt > 1:
logger.error("dispatch token required: redispatched job %s carries a dispatch token "
"but none was provided (aborting terminal publish)", job_id)
return 4
elif check_token is not None and actual_tok != check_token:
logger.error("dispatch token mismatch: expected %s, got %s (aborting publish)",
check_token, actual_tok)
return 4
elif check_attempt is not None and rec_attempt != check_attempt:
logger.error("attempt mismatch: expected %s, got %s (aborting publish)",
check_attempt, rec_attempt)
return 4
elif check_token is not None and actual_tok != check_token:
logger.error("dispatch token mismatch: expected %s, got %s (aborting publish)",
check_token, actual_tok)
return 4
try:
prepared = prepare_event(
job_id=job_id,
@@ -271,6 +330,8 @@ def main(argv=None) -> int:
data=data,
registry_dir=args.registry_dir,
retained=args.retained,
dispatch_token=args.expect_dispatch_token,
attempt_id=args.attempt_id,
)
except FileNotFoundError as exc:
logger.error("%s", exc)
@@ -292,7 +353,16 @@ def main(argv=None) -> int:
publish_error = str(exc)
logger.error("publish failed after %d attempts: %s", args.attempts, exc)
record_outcome(prepared, args.registry_dir, publish_ok, publish_error)
sync_expected_token = args.expect_dispatch_token
if sync_expected_token is None and isinstance(data, dict):
sync_expected_token = data.get("dispatch_token")
sync_expected_attempt = args.attempt_id
if sync_expected_attempt is None and isinstance(data, dict):
sync_expected_attempt = data.get("attempt_id")
record_outcome(prepared, args.registry_dir, publish_ok, publish_error,
expected_dispatch_token=sync_expected_token,
expected_attempt=sync_expected_attempt)
if not publish_ok:
return 2
@@ -100,6 +100,8 @@ def register_job(
"reviewer_session": reviewer_session,
"max_iterations": int(max_iterations),
"iteration": 1,
"dispatch_token": uuid.uuid4().hex,
"attempt_id": 1,
}
with registry_lock(registry_dir):
if mqtt_common._job_path(job_id, registry_dir).exists():
@@ -299,8 +301,15 @@ def _build_parser() -> argparse.ArgumentParser:
p_update.add_argument("--agent-session", default=None)
p_update.add_argument("--prompt", default=None)
p_update.add_argument("--iteration", type=int, default=None)
p_update.add_argument("--attempt-id", type=int, default=None)
p_update.add_argument("--role", default=None)
p_close = sub.add_parser("close", help="close a job with deadline_timeout or cancelled")
p_close.add_argument("--job", required=True)
p_close.add_argument("--request", required=True, choices=["deadline_timeout", "cancelled"])
p_close.add_argument("--detail", default=None)
p_close.add_argument("--expect-dispatch-token", default=None)
p_feedback = sub.add_parser("get-feedback", help="get the last feedback detail (completed/error) for a job")
p_feedback.add_argument("--job", required=True)
@@ -430,6 +439,8 @@ def main(argv: Optional[List[str]] = None) -> int:
fields["prompt"] = args.prompt
if args.iteration is not None:
fields["iteration"] = args.iteration
if args.attempt_id is not None:
fields["attempt_id"] = args.attempt_id
if args.role is not None:
fields["role"] = args.role
try:
@@ -439,6 +450,47 @@ def main(argv: Optional[List[str]] = None) -> int:
return 1
return rc
if args.command == "close":
from lib_py.delegation import engine
fields = {}
if args.detail is not None:
fields["detail"] = args.detail
if not engine.cas_enabled():
req_status = engine.REQUEST_TO_STATUS.get(args.request, "error")
fields["status"] = req_status
try:
mqtt_common.update_job_status(args.job, rd, **fields)
except FileNotFoundError as exc:
print(str(exc), file=sys.stderr)
return 1
return 0
try:
res = engine.transition(
args.job,
args.request,
registry_dir=rd,
source="delegate_wrapper",
fields=fields,
expected_dispatch_token=args.expect_dispatch_token,
)
except FileNotFoundError as exc:
print(str(exc), file=sys.stderr)
return 1
if res.committed:
return 0
if res.idempotent:
if res.reason:
print(res.reason, file=sys.stderr)
return 0
if res.reason and "conflicting" in res.reason and "after terminal" in res.reason:
print(res.reason, file=sys.stderr)
return 0
print(f"refused: {res.reason}", file=sys.stderr)
return 4
if args.command == "get-feedback":
print(get_feedback(args.job, rd))
return 0
+39 -3
View File
@@ -46,18 +46,54 @@ class DelegationService(object):
registry_dir=self.registry_dir, **kw
)
def publish_event(self, job_id, event, detail="", data=None, attempts=3):
"""Publish one event. Returns the same exit status as the CLI (0 / 2)."""
def publish_event(self, job_id, event, detail="", data=None, attempts=3,
expect_dispatch_token=None, attempt_id=None):
"""Publish one event. Returns the same exit status as the CLI (0 / 2 / 4)."""
eff_dispatch_token = expect_dispatch_token
eff_attempt_id = attempt_id
if data and isinstance(data, dict):
if eff_dispatch_token is None:
eff_dispatch_token = data.get("dispatch_token")
if eff_attempt_id is None:
eff_attempt_id = data.get("attempt_id")
try:
rec = self.load_job(job_id)
actual_tok = rec.get("dispatch_token")
rec_attempt = rec.get("attempt_id", rec.get("iteration", 1))
except FileNotFoundError:
rec = None
actual_tok = None
rec_attempt = 1
if rec is not None and actual_tok is not None:
if eff_dispatch_token is None and eff_attempt_id is None:
if event in ("completed", "error", "cancelled") and rec_attempt > 1:
logger.error("dispatch token required: redispatched job %s carries a dispatch token "
"but none was provided", job_id)
return 4
elif eff_dispatch_token is not None and actual_tok != eff_dispatch_token:
return 4
elif eff_attempt_id is not None and rec_attempt != eff_attempt_id:
return 4
elif eff_dispatch_token is not None and actual_tok != eff_dispatch_token:
return 4
prepared = publish_event.prepare_event(
job_id=job_id, event=event, detail=detail,
data=data, registry_dir=self.registry_dir,
dispatch_token=eff_dispatch_token, attempt_id=eff_attempt_id,
)
publisher = self._publisher
if publisher is None:
from lib_py.delegation.publisher import OneShotPublisher
publisher = OneShotPublisher()
ok, error = publisher.publish_with_retry(prepared, attempts=attempts)
publish_event.record_outcome(prepared, self.registry_dir, ok, error)
publish_event.record_outcome(
prepared, self.registry_dir, ok, error,
expected_dispatch_token=eff_dispatch_token,
expected_attempt=eff_attempt_id,
)
return 0 if ok else 2
def ensure_subscriber_daemon(self, job_id, workdir=None):
+1 -1
View File
@@ -13,7 +13,7 @@ import sys
from typing import List, Sequence
# Framework version locked with framework constants
FRAMEWORK_VERSION = "4.4.0"
FRAMEWORK_VERSION = "4.4.1"
CORE_MODULES: Sequence[str] = (
# Standard library modules
+56 -16
View File
@@ -59,6 +59,7 @@ class LoopRunner:
self.mam_dir = self.workspace_root / ".mam"
self.checkpoint_file = self.mam_dir / "loop-checkpoint.json"
self.created_jobs: List[str] = []
self.failed_submit_jobs: Set[str] = set()
self.current_plan = ""
self.plan_job_id: Optional[str] = None
self.base_commit = ""
@@ -262,27 +263,49 @@ class LoopRunner:
if not wrapper_script.exists():
wrapper_script = Path(__file__).resolve().parent.parent / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
cmd = [
"bash", str(wrapper_script), "submit",
"--agent-session", f"herdr:{agent_session}",
"--agent", agent_type,
"--type", "direct",
"--role", role,
"--prompt", prompt,
]
env = dict(os.environ)
if "DELEGATE_JOB_PYTHON" not in env:
env["DELEGATE_JOB_PYTHON"] = sys.executable
def _run_cmd(p_text: str):
cmd = [
"bash", str(wrapper_script), "submit",
"--agent-session", f"herdr:{agent_session}",
"--agent", agent_type,
"--type", "direct",
"--role", role,
"--prompt", p_text,
]
env = dict(os.environ)
if "DELEGATE_JOB_PYTHON" not in env:
env["DELEGATE_JOB_PYTHON"] = sys.executable
return subprocess.run(cmd, capture_output=True, text=True, cwd=str(self.workspace_root), env=env)
res = _run_cmd(prompt)
match = re.search(r"registered job:\s*([A-Za-z0-9_-]+)", res.stdout)
job_id = match.group(1) if match else None
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(self.workspace_root), env=env)
if res.returncode != 0:
log_error(f"delegate_job submit failed (exit {res.returncode}):\n{res.stderr or res.stdout}")
if res.returncode == 5:
# rc=5 (session_busy): MAM_SESSION_BUSY_RETRY_S wait, then 1 retry
retry_s = int(os.environ.get("MAM_SESSION_BUSY_RETRY_S", "30"))
if retry_s > 0:
time.sleep(retry_s)
res_retry = _run_cmd(prompt)
match_retry = re.search(r"registered job:\s*([A-Za-z0-9_-]+)", res_retry.stdout)
if match_retry:
job_id_retry = match_retry.group(1)
self.created_jobs.append(job_id_retry)
self._record_checkpoint()
if res_retry.returncode != 0:
self.failed_submit_jobs.add(job_id_retry)
log_error(f"delegate_job retry failed (exit {res_retry.returncode}):\n{res_retry.stderr or res_retry.stdout}")
return job_id_retry
if job_id:
self.created_jobs.append(job_id)
self.failed_submit_jobs.add(job_id)
self._record_checkpoint()
return job_id
return None
# Extract Job ID (pattern: 'registered job: [A-Za-z0-9]*')
match = re.search(r"registered job:\s*([A-Za-z0-9]+)", res.stdout)
if match:
job_id = match.group(1)
if job_id:
self.created_jobs.append(job_id)
self._record_checkpoint()
return job_id
@@ -290,11 +313,17 @@ class LoopRunner:
def wait_for_job(self, job_id: str, max_wait: int = 3900, check_interval: float = 1.0) -> bool:
"""Blocking wait for job completion or error with safety timeout."""
if job_id in self.failed_submit_jobs:
# Submit command for this job failed (rc!=0). Cap wait to 5s to avoid 3900s hang
max_wait = min(max_wait, 5.0)
if self.verbose:
log_info(f"Monitoring job '{job_id}' for status changes (timeout: {max_wait}s)...")
job_file = self.mam_dir / "jobs" / f"{job_id}.json"
start_time = time.time()
missing_count = 0
unknown_count = 0
while time.time() - start_time < max_wait:
if self.interrupted:
@@ -306,6 +335,17 @@ class LoopRunner:
status = json.load(f).get("status", "unknown")
except Exception:
status = "unknown"
else:
missing_count += 1
if missing_count >= 5 and (time.time() - start_time) >= min(max_wait, 5.0):
log_error(f"Job record '{job_file}' not found on disk; aborting wait.")
return False
if status == "unknown":
unknown_count += 1
if unknown_count >= 5 and (time.time() - start_time) >= min(max_wait, 5.0):
log_error(f"Job '{job_id}' record unreadable / status unknown; aborting wait.")
return False
if status == "completed":
if self.verbose:
+48 -4
View File
@@ -59,10 +59,14 @@ class WaitOutcome(object):
def build_argv(target: str, until: Sequence[str] = DEFAULT_UNTIL,
timeout_ms: Optional[int] = None) -> list:
timeout_ms: Optional[int] = None,
session: Optional[str] = None) -> list:
if not target:
raise ValueError("wait target is required")
argv = ["herdr", "agent", "wait", target]
if session and session != "default":
argv = ["herdr", "--session", session, "agent", "wait", target]
else:
argv = ["herdr", "agent", "wait", target]
for state in until:
if state not in VALID_STATES:
raise ValueError("unsupported --until state: {!r}".format(state))
@@ -83,9 +87,10 @@ def _parse(stream: str) -> Optional[Dict[str, Any]]:
def wait_once(target: str, until: Sequence[str] = DEFAULT_UNTIL,
timeout_ms: Optional[int] = None, run=subprocess.run) -> WaitOutcome:
timeout_ms: Optional[int] = None, session: Optional[str] = None,
run=subprocess.run) -> WaitOutcome:
"""One barrier call. `run` is injectable so tests never touch a real herdr."""
argv = build_argv(target, until, timeout_ms)
argv = build_argv(target, until, timeout_ms, session=session)
try:
proc = run(argv, capture_output=True, text=True)
except Exception as exc:
@@ -164,3 +169,42 @@ class ReconnectBackoff(object):
return [self.record_failure() for _ in range(n)]
finally:
self.current, self.consecutive_failures, self.cached_snapshot = saved
def main(argv: Optional[Sequence[str]] = None) -> int:
import argparse
import os
import sys
parser = argparse.ArgumentParser(description="watcher CLI for herdr agent wait")
sub = parser.add_subparsers(dest="command", required=True)
p_wait = sub.add_parser("wait", help="wait for agent state")
p_wait.add_argument("target", help="target agent name")
p_wait.add_argument("--session", default=None, help="herdr session name")
p_wait.add_argument("--until", action="append", default=[], help="until state(s)")
p_wait.add_argument("--timeout-ms", type=int, default=None, help="timeout in ms")
args = parser.parse_args(argv)
session = args.session or os.environ.get("HERDR_SESSION_NAME") or None
until = args.until or DEFAULT_UNTIL
outcome = wait_once(args.target, until=until, timeout_ms=args.timeout_ms, session=session)
agent_st = None
if outcome.agent:
agent_st = outcome.agent.get("agent_status")
print(json.dumps({
"kind": outcome.kind,
"agent_status": agent_st,
"error_code": outcome.error_code,
}))
rc_map = {
MATCHED: 0,
TIMEOUT: 2,
AGENT_NOT_FOUND: 3,
TRANSPORT_ERROR: 4,
}
return rc_map.get(outcome.kind, 4)
if __name__ == "__main__":
import sys
sys.exit(main())
@@ -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.4.0
version: 4.4.1
author: godopu
license: MIT
platforms: [linux, macos]
@@ -344,6 +344,29 @@ if [ -n "$SUBMIT_JOB_PROMPT" ]; then
agent_session="herdr:$SESSION_NAME"
DELEGATE_JOB_ID=$(delegate_submit_job "$SUBMIT_JOB_PROMPT" "$delegate_agent" "$agent_session")
echo "Submitted delegated job: $DELEGATE_JOB_ID"
DELEGATE_JOB_TOKEN=""
DELEGATE_JOB_ATTEMPT=""
if [ -n "$DELEGATE_JOB_ID" ]; then
local_info=$(python3 -c "
import sys, json, os
jid = '$DELEGATE_JOB_ID'
reg_dir = os.environ.get('MAM_JOBS_DIR', '.mam/jobs')
p = os.path.join(reg_dir, f'{jid}.json')
if os.path.exists(p):
try:
with open(p) as f:
d = json.load(f)
tok = d.get('dispatch_token', '')
att = d.get('attempt_id', d.get('iteration', 1))
print(f'{tok} {att}')
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}')
fi
fi
fi
if [ ! -f "$AGENT_SESSIONS_YAML" ]; then
@@ -494,11 +517,11 @@ Task: $SUBMIT_JOB_PROMPT"
rc=0
inject_instructions "$SESSION_NAME" "$instructions" "$DELEGATE_JOB_ID" || rc=$?
if [ "$rc" -ne 0 ]; then
delegate_publish_event "$DELEGATE_JOB_ID" error "instruction injection failed (prompt-lock, rc=$rc)"
delegate_publish_event "$DELEGATE_JOB_ID" error "instruction injection failed (prompt-lock, rc=$rc)" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
exit 1
fi
delegate_publish_event "$DELEGATE_JOB_ID" started "multi-agent-mux session created and instructions injected"
delegate_publish_event "$DELEGATE_JOB_ID" started "multi-agent-mux session created and instructions injected" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
# Trigger immediate priority reconcile cycle asynchronously
(bash "$WORKSPACE/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh" --once >/dev/null 2>&1 &) || true
WD_PID=$(start_watchdog "$DELEGATE_JOB_ID" "$WORKSPACE")
@@ -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.4.0
version: 4.4.1
author: godopu
license: MIT
platforms: [linux, macos, windows]
@@ -70,6 +70,22 @@ For detailed state machine diagrams and configurations, see [DELEGATION_TYPES.md
---
## Exit Codes & Failure Handling
Under fail-closed operation (`MAM_DELEGATE_FAIL_CLOSED=1`, default), the CLI returns deterministic exit codes:
| Exit Code | Meaning | Final Registry Status | Failure Behavior & Resilience |
|:---:|:---:|:---:|---|
| `0` | Success | `completed` | Job succeeded and completed event received or verified on disk with approval (`PASS`/`AGREE`). |
| `1` | Failure | `error` | Execution failure / Agent injection error (`inject_agent`). |
| `2` | Timeout | `error` (`deadline_timeout`) | Subscriber timeout (total budget or idle timeout reached without terminal event). **R1 Working-Agent Resilience**: If the agent in Herdr is still actively `working`, the wrapper does not immediately fail-close; it re-observes disk status at intervals up to the wall-clock `--timeout` deadline before fail-closing. |
| `3` | Broker Error | `error` (`deadline_timeout`) | Broker unavailable (`MQTT_BROKER_UNAVAILABLE`) and terminal status not found on disk during deadline. |
| `5` | Session Busy | `cancelled` (`session_busy`) | Precondition failed / `session_busy`: Preflight check (`preflight_agent`) or TUI idle handshake wait failed (prompt never delivered, retryable). |
In all execution paths, the final line of standard output prints the persistent audit log directory path (`<logs_root>/<job_id>`), fulfilling the parsing contract for orchestrator runners.
---
## Where the Implementation Lives (v4.3.0)
The implementation moved to the `lib_py.delegation` package in issue #5 phase 1.
@@ -125,6 +125,10 @@ parse_opts() {
done
}
_to_lower() {
printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
}
cmd_submit() {
parse_opts "$@"
[[ -n "$PROMPT" ]] || { echo "submit requires --prompt" >&2; exit 1; }
@@ -159,12 +163,56 @@ EOF
echo "provisioned job directory: $job_dir"
fi
local START_TIME
START_TIME=$(date +%s)
if [[ "$TYPE" == "direct" ]]; then
# 2) START THE SUBSCRIBER FIRST (ordering dependency — MQTT does not queue
# non-retained messages for absent subscribers).
# 1) Read dispatch token and attempt id for this dispatch
local _disp_tok=""
local _attempt_id=""
_disp_tok="$("$PY" -c "import json, os; p=os.path.join('$REGISTRY_DIR', '$JOB_ID.json'); print(json.load(open(p)).get('dispatch_token','') or '') if os.path.exists(p) else print('')" 2>/dev/null || true)"
_attempt_id="$("$PY" -c "import json, os; p=os.path.join('$REGISTRY_DIR', '$JOB_ID.json'); print(json.load(open(p)).get('attempt_id','') or '') if os.path.exists(p) else print('')" 2>/dev/null || true)"
local pub_extra=()
[ -n "$_disp_tok" ] && pub_extra+=(--expect-dispatch-token "$_disp_tok")
[ -n "$_attempt_id" ] && pub_extra+=(--attempt-id "$_attempt_id")
local pub="$PY $SCRIPT_DIR/scripts/publish_event.py --registry-dir $REGISTRY_DIR --job $JOB_ID ${pub_extra[*]}"
local instructions="Your job_id is \"$JOB_ID\". Detailed task requirements, instructions, and target output paths are documented in the task brief file at: .mam/jobs/$JOB_ID/brief.md. Please READ and follow .mam/jobs/$JOB_ID/brief.md to complete your work. Commands: start='$pub --event started', success='$pub --event completed --detail <summary>', error='$pub --event error --detail <reason>'."
if [[ "$DRY_RUN" == "1" ]]; then
run_agent "$JOB_ID" "$instructions"
local logs_root_dry="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root_dry/$JOB_ID"
return 0
fi
# 1) Preflight
if ! preflight_agent "$JOB_ID" "$AGENT_SESSION" 0; then
finalize_unfinished "$JOB_ID" "error" 1 "preflight"
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
exit 5
fi
# 2) TUI idle handshake
if [ "$AGENT" != "human" ]; then
local idle_rc=0
wait_for_agent_idle "$AGENT_SESSION" || idle_rc=$?
export _SKS_IDLE_RC="$idle_rc"
if [ $idle_rc -eq 1 ] || [ $idle_rc -eq 2 ]; then
finalize_unfinished "$JOB_ID" "error" "$idle_rc" "idle_wait"
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
exit 5
fi
fi
# 3) START THE SUBSCRIBER
local logf="$REGISTRY_DIR/$JOB_ID.subscriber.out"
local sub_extra_args=()
[ -n "$_disp_tok" ] && sub_extra_args+=(--expect-dispatch-token "$_disp_tok")
"$PY" "$SCRIPT_DIR/scripts/job_subscriber.py" --registry-dir "$REGISTRY_DIR" \
--job "$JOB_ID" --timeout "$TIMEOUT" --idle-timeout "$IDLE_TIMEOUT" \
${sub_extra_args[@]+"${sub_extra_args[@]}"} \
>"$logf" 2>&1 &
local sub_pid=$!
echo "subscriber pid: $sub_pid (log: $logf)"
@@ -177,14 +225,23 @@ EOF
break
fi
else
wait "$sub_pid" 2>/dev/null
local sub_exit=$?
local sub_exit=0
wait "$sub_pid" 2>/dev/null || sub_exit=$?
if [ $sub_exit -eq 0 ] || [ $sub_exit -eq 3 ]; then
sub_ready=1
break
else
echo "ERROR: subscriber died early (pid=$sub_pid, exit=$sub_exit, check $logf)" >&2
exit 1
local mapped_st
mapped_st="$(_map_sub_rc "$sub_exit" "$JOB_ID")"
finalize_unfinished "$JOB_ID" "$mapped_st" "$sub_exit" "subscriber"
mapped_st="${FINAL_JOB_STATUS:-$mapped_st}"
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
if [ "${MAM_DELEGATE_FAIL_CLOSED:-1}" = "0" ]; then
exit 0
fi
exit "$(_rc_for "$mapped_st")"
fi
fi
sleep 0.2
@@ -193,17 +250,16 @@ EOF
echo "WARNING: subscriber subscribe handshake timed out — falling back to proceed" >&2
fi
# 3) run the agent (or print the command for dry-run / missing binary)
local pub="$PY $SCRIPT_DIR/scripts/publish_event.py --registry-dir $REGISTRY_DIR --job $JOB_ID"
# NOTE: the agent MUST use --job "$JOB_ID" (the one we just minted). Hard-coding
# an id from an earlier session is the #1 reason a delegated job sits idle and
# times out (see SKILL.md "Wrong job_id propagated to the agent"). We make the
# freshness explicit in the instruction header.
local instructions="Your job_id is \"$JOB_ID\". Detailed task requirements, instructions, and target output paths are documented in the task brief file at: .mam/jobs/$JOB_ID/brief.md. Please READ and follow .mam/jobs/$JOB_ID/brief.md to complete your work. Commands: start='$pub --event started', success='$pub --event completed --detail <summary>', error='$pub --event error --detail <reason>'."
# 5) Inject agent
if ! inject_agent "$JOB_ID" "$instructions" "$AGENT_SESSION"; then
reap_subscriber "$sub_pid"
finalize_unfinished "$JOB_ID" "error" 1 "inject"
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
exit 1
fi
run_agent "$JOB_ID" "$instructions"
# 4) optional validation hook
# 6) optional validation hook
if [[ -n "$VALIDATE" ]]; then
echo "running validation: $VALIDATE"
if JOB_ID="$JOB_ID" REGISTRY_DIR="$REGISTRY_DIR" bash "$VALIDATE"; then
@@ -214,23 +270,24 @@ EOF
fi
fi
if [[ "$DRY_RUN" == "1" ]]; then
# In dry-run we never started a real subscriber (the wrapper short-circuits
# before launching one), but the wait below would still try to join the
# background sub_pid from cmd_submit. Skip both the wait and the subscriber
# log dump; the user just wants to see the instruction that would have run.
local logs_root_dry="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root_dry/$JOB_ID"
return 0
fi
wait "$sub_pid" || true
local sub_rc=0
wait "$sub_pid" || sub_rc=$?
echo "subscriber output:"; cat "$logf" || true
local job_status
job_status="$(_map_sub_rc "$sub_rc" "$JOB_ID")"
finalize_unfinished "$JOB_ID" "$job_status" "$sub_rc" "direct"
job_status="${FINAL_JOB_STATUS:-$job_status}"
# Last stdout line: the persistent audit-log dir for this job (see SKILL.md
# "Audit Logs"). Callers can scrape `tail -n1` to find it.
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
if [ "${MAM_DELEGATE_FAIL_CLOSED:-1}" = "0" ]; then
exit 0
fi
exit "$(_rc_for "$job_status")"
else
# Implement loop/discuss orchestrator
local iteration=1
@@ -238,6 +295,8 @@ EOF
local current_session="$AGENT_SESSION"
local _phase="worker"
local display_role="$DELEGATE_ROLE"
local attempt=0
local final_loop_rc=0
if [[ "$DRY_RUN" == "1" ]]; then
echo "[dry-run] orchestrator loop would start for job: $JOB_ID type: $TYPE"
@@ -256,9 +315,8 @@ EOF
# 1-1) Provision job directory and write iteration brief.md (MAM Job Restructuring)
local job_dir="$REGISTRY_DIR/$JOB_ID"
local clean_session="${current_session#herdr:}"
if [[ "$DRY_RUN" != "1" ]]; then
mkdir -p "$job_dir"
cat <<EOF > "$job_dir/brief.md"
mkdir -p "$job_dir"
cat <<EOF > "$job_dir/brief.md"
# 📋 Brief: Job $JOB_ID Delegation (Iteration $iteration)
- **Job ID**: $JOB_ID
@@ -270,7 +328,40 @@ EOF
## 🔎 Task Description
$current_prompt
EOF
echo "provisioned iteration brief: $job_dir/brief.md"
echo "provisioned iteration brief: $job_dir/brief.md"
attempt=$((attempt + 1))
local force_warn_only=0
local cp_lower def_lower
cp_lower=$(_to_lower "$COUNTERPART_ROLE")
def_lower=$(_to_lower "$DEFAULT_COUNTERPART_ROLE")
if [[ "$_phase" == "reviewer" && "$COUNTERPART_ROLE_EXPLICIT" -eq 1 \
&& "$cp_lower" != "$def_lower" ]]; then
force_warn_only=1
fi
# Preflight
if ! preflight_agent "$JOB_ID" "$current_session" "$force_warn_only"; then
finalize_unfinished "$JOB_ID" "error" 1 "preflight"
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
exit 5
fi
# Idle wait
local cur_agent="$AGENT"
[ "$_phase" = "reviewer" ] && cur_agent="$REVIEWER"
if [ "$cur_agent" != "human" ]; then
local idle_rc=0
wait_for_agent_idle "$current_session" || idle_rc=$?
export _SKS_IDLE_RC="$idle_rc"
if [ $idle_rc -eq 1 ] || [ $idle_rc -eq 2 ]; then
finalize_unfinished "$JOB_ID" "error" "$idle_rc" "idle_wait"
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
exit 5
fi
fi
# Update job details in registry
@@ -279,13 +370,20 @@ EOF
--agent-session "$current_session" \
--prompt "$current_prompt" \
--iteration "$iteration" \
--attempt-id "$attempt" \
--role "$display_role" \
--status "pending"
local _disp_tok=""
_disp_tok="$("$PY" -c "import json, os; p=os.path.join('$REGISTRY_DIR', '$JOB_ID.json'); print(json.load(open(p)).get('dispatch_token','') or '') if os.path.exists(p) else print('')" 2>/dev/null || true)"
# Start subscriber
local logf="$REGISTRY_DIR/${JOB_ID}.iter_${iteration}_${display_role}.subscriber.out"
local loop_sub_args=(--attempt-id "$attempt")
[ -n "$_disp_tok" ] && loop_sub_args+=(--expect-dispatch-token "$_disp_tok")
"$PY" "$SCRIPT_DIR/scripts/job_subscriber.py" --registry-dir "$REGISTRY_DIR" \
--job "$JOB_ID" --timeout "$TIMEOUT" --idle-timeout "$IDLE_TIMEOUT" \
"${loop_sub_args[@]}" \
>"$logf" 2>&1 &
local sub_pid=$!
echo "subscriber pid: $sub_pid (log: $logf)"
@@ -298,14 +396,23 @@ EOF
break
fi
else
wait "$sub_pid" 2>/dev/null
local sub_exit=$?
if [ $sub_exit -eq 0 ]; then
local sub_exit=0
wait "$sub_pid" 2>/dev/null || sub_exit=$?
if [ $sub_exit -eq 0 ] || [ $sub_exit -eq 3 ]; then
sub_ready=1
break
else
echo "ERROR: subscriber died early (pid=$sub_pid, exit=$sub_exit, check $logf)" >&2
exit 1
local mapped_st
mapped_st="$(_map_sub_rc "$sub_exit" "$JOB_ID")"
finalize_unfinished "$JOB_ID" "$mapped_st" "$sub_exit" "subscriber"
mapped_st="${FINAL_JOB_STATUS:-$mapped_st}"
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
if [ "${MAM_DELEGATE_FAIL_CLOSED:-1}" = "0" ]; then
exit 0
fi
exit "$(_rc_for "$mapped_st")"
fi
fi
sleep 0.2
@@ -315,38 +422,27 @@ EOF
fi
# Format instruction block
local pub="$PY $SCRIPT_DIR/scripts/publish_event.py --registry-dir $REGISTRY_DIR --job $JOB_ID"
local loop_pub_extra=(--attempt-id "$attempt")
[ -n "$_disp_tok" ] && loop_pub_extra+=(--expect-dispatch-token "$_disp_tok")
local pub="$PY $SCRIPT_DIR/scripts/publish_event.py --registry-dir $REGISTRY_DIR --job $JOB_ID ${loop_pub_extra[*]}"
local instructions="Your job_id is \"$JOB_ID\". Detailed task requirements, instructions, and target output paths for iteration $iteration are documented in the task brief file at: .mam/jobs/$JOB_ID/brief.md. Please READ and follow .mam/jobs/$JOB_ID/brief.md to complete your work. Commands: start='$pub --event started', success='$pub --event completed --detail <summary>', error='$pub --event error --detail <reason>'."
# Trigger agent
local force_warn_only=0
if [[ "$_phase" == "reviewer" && "$COUNTERPART_ROLE_EXPLICIT" -eq 1 \
&& "${COUNTERPART_ROLE,,}" != "${DEFAULT_COUNTERPART_ROLE,,}" ]]; then
force_warn_only=1
if ! inject_agent "$JOB_ID" "$instructions" "$current_session"; then
reap_subscriber "$sub_pid"
finalize_unfinished "$JOB_ID" "error" 1 "inject"
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
exit 1
fi
run_agent "$JOB_ID" "$instructions" "$current_session" "$force_warn_only"
# Wait for subscriber
local sub_rc=0
wait "$sub_pid" || sub_rc=$?
echo "subscriber output:"; cat "$logf" || true
# Check job status based on subscriber exit code
local job_status="running"
if [[ $sub_rc -eq 0 ]]; then
job_status="completed"
elif [[ $sub_rc -eq 1 ]]; then
job_status="error"
elif [[ $sub_rc -eq 3 ]]; then
job_status="broker_unavailable"
local disk_st
disk_st="$("$PY" -c "import json, os; p=os.path.join('$REGISTRY_DIR', '$JOB_ID.json'); print(json.load(open(p)).get('status','')) if os.path.exists(p) else print('')" 2>/dev/null || true)"
if [[ "$disk_st" == "completed" || "$disk_st" == "error" ]]; then
job_status="$disk_st"
fi
else
job_status="timeout"
fi
local job_status
job_status="$(_map_sub_rc "$sub_rc" "$JOB_ID")"
echo "Job role $display_role finished with status: $job_status"
@@ -354,11 +450,22 @@ EOF
local feedback
feedback="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" get-feedback --job "$JOB_ID")"
echo "Feedback/Detail: $feedback"
if [[ "$_phase" == "worker" ]]; then
if [[ "$job_status" != "completed" ]]; then
echo "Worker did not complete successfully (status: $job_status). Terminating workflow."
break
finalize_unfinished "$JOB_ID" "$job_status" "$sub_rc" "worker" "$current_session"
job_status="${FINAL_JOB_STATUS:-$job_status}"
if [[ "$job_status" == "completed" ]]; then
echo "Worker completed during grace period. Continuing to reviewer."
feedback="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" get-feedback --job "$JOB_ID")"
else
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
if [ "${MAM_DELEGATE_FAIL_CLOSED:-1}" = "0" ]; then
exit 0
fi
exit "$(_rc_for "$job_status")"
fi
fi
# Worker completed successfully, now switch to reviewer
@@ -374,30 +481,46 @@ EOF
else
if [[ "$job_status" != "completed" ]]; then
echo "Reviewer did not complete successfully (status: $job_status). Terminating workflow."
break
finalize_unfinished "$JOB_ID" "$job_status" "$sub_rc" "reviewer" "$current_session"
job_status="${FINAL_JOB_STATUS:-$job_status}"
if [[ "$job_status" == "completed" ]]; then
echo "Reviewer completed during grace period. Continuing evaluation."
feedback="$("$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" get-feedback --job "$JOB_ID")"
else
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
if [ "${MAM_DELEGATE_FAIL_CLOSED:-1}" = "0" ]; then
exit 0
fi
exit "$(_rc_for "$job_status")"
fi
fi
# Reviewer finished. Check if pass/agree
local success=0
local fb_lower
fb_lower=$(_to_lower "$feedback")
if [[ "$TYPE" == "loop" ]]; then
if [[ "${feedback,,}" == *"pass"* ]]; then
if [[ "$fb_lower" == *"pass"* ]]; then
success=1
fi
elif [[ "$TYPE" == "discuss" ]]; then
if [[ "${feedback,,}" == *"agree"* ]]; then
if [[ "$fb_lower" == *"agree"* ]]; then
success=1
fi
fi
if [[ "$success" == "1" ]]; then
echo "Reviewer approved the work. Finalizing job as completed."
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" status --job "$JOB_ID" --set "completed"
final_loop_rc=0
break
else
# Reviewer rejected/provided feedback. Increment & check max iterations
if [[ $iteration -ge $MAX_ITERATIONS ]]; then
echo "Max iterations ($MAX_ITERATIONS) reached without approval. Terminating workflow."
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" status --job "$JOB_ID" --set "error"
final_loop_rc=1
break
fi
@@ -418,40 +541,216 @@ EOF
else
local rc=$?
echo "validation: FAIL (exit $rc)"
final_loop_rc=$rc
fi
fi
# Last stdout line: the persistent audit-log dir
local logs_root="${DELEGATE_JOB_LOGS_DIR:-$WORKDIR/delegate_job_logs}"
echo "$logs_root/$JOB_ID"
exit "$final_loop_rc"
fi
}
run_agent() {
local job_id="$1"; local instructions="$2"; local target_session="${3:-$AGENT_SESSION}"; local force_warn_only="${4:-0}"
# The skill is INTERACTIVE-ONLY. We never invoke `claude -p` or any other
# one-shot print mode, because:
# - claude -p exits the moment stdin is drained, so there's nothing to
# `herdr session attach` to afterwards.
# - fire-and-forget via wrapper defeats the whole point of the audit log
# (you can't tell what happened if the agent crashes mid-turn).
# - the job registry already gives us an authoritative completion signal,
# so we don't need a wrapper-side exit code to know "done".
# The user attaches with `herdr session attach <session>` and types follow-up
# prompts themselves. We pre-load the first prompt via stdin and `read`
# keeps the pane open after the agent exits so the user can review.
reap_subscriber() {
if [ -n "${1:-}" ]; then
kill "$1" 2>/dev/null || true
wait "$1" 2>/dev/null || true
fi
}
_disk_status() {
local jid="$1"
"$PY" -c "import json, os; p=os.path.join('$REGISTRY_DIR', '$jid.json'); print(json.load(open(p)).get('status','')) if os.path.exists(p) else print('')" 2>/dev/null || true
}
_map_sub_rc() {
local sub_rc="$1" job_id="$2"
local job_status="running"
if [[ $sub_rc -eq 0 ]]; then
job_status="completed"
elif [[ $sub_rc -eq 1 ]]; then
job_status="error"
elif [[ $sub_rc -eq 3 ]]; then
job_status="broker_unavailable"
local disk_st
disk_st="$(_disk_status "$job_id")"
if [[ "$disk_st" == "completed" || "$disk_st" == "error" || "$disk_st" == "cancelled" ]]; then
job_status="$disk_st"
fi
else
job_status="timeout"
fi
echo "$job_status"
}
_rc_for() {
case "$1" in
completed) echo 0 ;;
error) echo 1 ;;
timeout) echo 2 ;;
broker_unavailable) echo 3 ;;
cancelled) echo 5 ;;
*) echo 1 ;;
esac
}
finalize_unfinished() {
local job_id="$1" job_status="$2" sub_rc="$3" phase="${4:-}"
if [ "${MAM_DELEGATE_FAIL_CLOSED:-1}" = "0" ]; then
return 0
fi
if [ "$job_status" = "completed" ]; then
return 0
fi
local cur_disk_st="$(_disk_status "$job_id")"
if [ "$cur_disk_st" = "completed" ] || [ "$cur_disk_st" = "error" ] || [ "$cur_disk_st" = "cancelled" ]; then
FINAL_JOB_STATUS="$cur_disk_st"
[ "$cur_disk_st" = "completed" ] && return 0
else
FINAL_JOB_STATUS="$job_status"
fi
# broker_unavailable: re-observe disk for remaining deadline at 3s intervals
if [ "$job_status" = "broker_unavailable" ]; then
local now elapsed remaining
now=$(date +%s)
elapsed=$(( now - ${START_TIME:-now} ))
remaining=$(( TIMEOUT - elapsed ))
[ $remaining -lt 0 ] && remaining=0
while [ $remaining -gt 0 ]; do
local wait_interval=3
[ $remaining -lt $wait_interval ] && wait_interval=$remaining
sleep "$wait_interval"
remaining=$(( remaining - wait_interval ))
local disk_st
disk_st="$(_disk_status "$job_id")"
if [ "$disk_st" = "completed" ] || [ "$disk_st" = "error" ] || [ "$disk_st" = "cancelled" ]; then
job_status="$disk_st"
FINAL_JOB_STATUS="$disk_st"
[ "$disk_st" = "completed" ] && return 0
break
fi
done
fi
# timeout: if subscriber timed out, check if agent is still actively working in herdr
if [ "$job_status" = "timeout" ]; then
local target_sess="${5:-${current_session:-${target_session:-$AGENT_SESSION}}}"
local sess="${target_sess#herdr:}"
local target_agent
target_agent=$(_sanitize_herdr_agent_name "$sess")
local agent_st=""
if [ "$sess" != "human" ] && [ -n "$sess" ]; then
local raw_st
raw_st="$(_sks_herdr agent get "$target_agent" 2>/dev/null || _sks_herdr agent get "$sess" 2>/dev/null || true)"
if [ -n "$raw_st" ]; then
agent_st="$("$(_delegate_py_bin)" -c '
import sys, json
try:
d = json.loads(sys.stdin.read())
print((d.get("result", {}).get("agent", {}) or {}).get("agent_status", "") or "")
except Exception:
print("")
' <<< "$raw_st" 2>/dev/null || true)"
fi
fi
if [ "$agent_st" = "working" ]; then
local now elapsed remaining
now=$(date +%s)
elapsed=$(( now - ${START_TIME:-now} ))
remaining=$(( TIMEOUT - elapsed ))
[ $remaining -lt 0 ] && remaining=0
while [ $remaining -gt 0 ]; do
local disk_st
disk_st="$(_disk_status "$job_id")"
if [ "$disk_st" = "completed" ] || [ "$disk_st" = "error" ] || [ "$disk_st" = "cancelled" ]; then
job_status="$disk_st"
FINAL_JOB_STATUS="$disk_st"
[ "$disk_st" = "completed" ] && return 0
break
fi
local cur_raw cur_st=""
cur_raw="$(_sks_herdr agent get "$target_agent" 2>/dev/null || _sks_herdr agent get "$sess" 2>/dev/null || true)"
if [ -n "$cur_raw" ]; then
cur_st="$("$(_delegate_py_bin)" -c '
import sys, json
try:
d = json.loads(sys.stdin.read())
print((d.get("result", {}).get("agent", {}) or {}).get("agent_status", "") or "")
except Exception:
print("")
' <<< "$cur_raw" 2>/dev/null || true)"
fi
if [ "$cur_st" != "working" ]; then
sleep 1
disk_st="$(_disk_status "$job_id")"
if [ "$disk_st" = "completed" ] || [ "$disk_st" = "error" ] || [ "$disk_st" = "cancelled" ]; then
job_status="$disk_st"
FINAL_JOB_STATUS="$disk_st"
[ "$disk_st" = "completed" ] && return 0
fi
break
fi
local wait_interval=1
[ $remaining -lt $wait_interval ] && wait_interval=$remaining
sleep "$wait_interval"
remaining=$(( remaining - wait_interval ))
done
fi
fi
local close_req="deadline_timeout"
local detail=""
if [ "$phase" = "preflight" ] || [ "$phase" = "idle_wait" ]; then
close_req="cancelled"
detail="session_busy"
fi
local close_args=(--job "$job_id" --request "$close_req")
[ -n "$detail" ] && close_args+=(--detail "$detail")
[ -n "${_disp_tok:-}" ] && close_args+=(--expect-dispatch-token "$_disp_tok")
local close_rc=0
"$PY" "$SCRIPT_DIR/scripts/registry.py" --registry-dir "$REGISTRY_DIR" close "${close_args[@]}" 2>&1 || close_rc=$?
# If close failed due to dispatch token mismatch (rc=4), suppress secondary event emission
# to prevent CAS/ABA bypass
if [ $close_rc -eq 4 ]; then
echo "finalize_unfinished: dispatch token mismatch (stale attempt); suppressing event emission" >&2
return 0
fi
# Check if job completed on disk (universal, for all TYPEs)
local cur_disk_st
cur_disk_st="$(_disk_status "$job_id")"
if [ "$cur_disk_st" = "completed" ]; then
FINAL_JOB_STATUS="completed"
return 0
fi
# direct path only: publish MQTT error/cancelled event (loop/discuss forbidden: O-1)
if [ "$TYPE" = "direct" ]; then
local disp_arg=()
[ -n "${_disp_tok:-}" ] && disp_arg=(--expect-dispatch-token "$_disp_tok")
if [ "$close_req" = "cancelled" ]; then
"$PY" "$SCRIPT_DIR/scripts/publish_event.py" --registry-dir "$REGISTRY_DIR" --job "$job_id" --event cancelled --detail "session_busy" ${disp_arg[@]+"${disp_arg[@]}"} 2>/dev/null || true
elif [ "$sub_rc" -ne 1 ] || [ "$phase" = "inject" ]; then
local err_detail="deadline_timeout"
[ "$phase" = "inject" ] && err_detail="agent_blocked"
"$PY" "$SCRIPT_DIR/scripts/publish_event.py" --registry-dir "$REGISTRY_DIR" --job "$job_id" --event error --detail "$err_detail" ${disp_arg[@]+"${disp_arg[@]}"} 2>/dev/null || true
fi
fi
}
preflight_agent() {
local job_id="$1" target_session="${2:-$AGENT_SESSION}" force_warn_only="${3:-0}"
if [ "$AGENT" = "human" ]; then
echo "[human agent] complete the task, then run publish_event.py --event completed"
return
return 0
fi
local sess="${target_session#herdr:}"
if [[ "$DRY_RUN" == "1" ]]; then
echo "[dry-run] would delegate task to running agent '$AGENT' in herdr session '$sess' with instructions:"
echo "----"; echo "$instructions"; echo "----"
return
fi
# B-3: must probe for the real binary, not the herdr() function / shim wrapper.
if ! has_real_herdr; then
echo "ERROR: this skill requires herdr (interactive agent sessions)." >&2
@@ -459,10 +758,6 @@ run_agent() {
return 1
fi
# Auto-resolve isolation the same way resume/stop/create do — don't rely on
# the caller having exported HERDR_SERVER_NAME by hand. This is what lets
# delegation reach an agent living in an isolated herdr session (e.g. one
# created with --herdr-server) instead of silently looking in "default".
export HERDR_SESSION_NAME="$(resolve_herdr_session "$sess" "$WORKDIR")"
if ! herdr has-session -t "$sess" 2>/dev/null; then
@@ -516,25 +811,45 @@ else:
fi
fi
fi
return 0
}
inject_agent() {
local job_id="$1" instructions="$2" target_session="${3:-$AGENT_SESSION}"
if [ "$AGENT" = "human" ]; then
echo "[human agent] complete the task, then run publish_event.py --event completed"
return 0
fi
local sess="${target_session#herdr:}"
# Before launching the agent, set up error trap to publish error event
if [ -n "${job_id:-}" ] && [ -n "${PY:-}" ]; then
pub_script="$SCRIPT_DIR/scripts/publish_event.py"
trap "rc=\$?; if [ \$rc -ne 0 ]; then \"$PY\" \"$pub_script\" --job '$job_id' --event error --detail 'agent bootstrap failed (exit '\$rc')'; fi" EXIT
trap "rc=\$?; if [ \$rc -ne 0 ]; then \"$PY\" \"$pub_script\" --registry-dir '$REGISTRY_DIR' --job '$job_id' --event error --detail 'agent bootstrap failed (exit '\$rc')'; fi" EXIT
fi
echo "살아있는 에이전트 세션 '$sess'에 작업을 위임합니다..."
if ! send_keys_safe "$sess" "$instructions" "$job_id"; then
trap - EXIT
echo "ERROR: 프롬프트 주입 실패 — 세션 '$sess' (프롬프트 잠금 의심)" >&2
return 1
fi
# NOTE: `herdr session attach` operates on whole herdr *sessions* (server
# instances), not an individual agent by its MAM name — `agent attach` is
# the real command for that. HERDR_SESSION_NAME is inlined so the printed
# command is copy-pasteable in a fresh shell that hasn't sourced lib.sh.
echo "작업이 세션 '$sess'에 전송되었습니다. (연결하려면: HERDR_SESSION_NAME=$HERDR_SESSION_NAME herdr agent attach $sess — lib.sh를 source한 셸에서 실행)"
trap - EXIT
return 0
}
run_agent() {
local job_id="$1" instructions="$2" target_session="${3:-$AGENT_SESSION}" force_warn_only="${4:-0}"
local sess="${target_session#herdr:}"
if [[ "$DRY_RUN" == "1" ]]; then
echo "[dry-run] would delegate task to running agent '$AGENT' in herdr session '$sess' with instructions:"
echo "----"; echo "$instructions"; echo "----"
return 0
fi
preflight_agent "$job_id" "$target_session" "$force_warn_only" || return $?
inject_agent "$job_id" "$instructions" "$target_session" || return $?
}
cmd_status() {
+1 -1
View File
@@ -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.4.0
version: 4.4.1
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.4.0
version: 4.4.1
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.4.0
version: 4.4.1
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.4.0
version: 4.4.1
author: godopu
license: MIT
platforms: [linux, macos]
@@ -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.4.0
version: 4.4.1
author: godopu
license: MIT
platforms: [linux, macos]
+1 -1
View File
@@ -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.4.0
version: 4.4.1
author: godopu
license: MIT
platforms: [linux, macos]
@@ -142,6 +142,29 @@ sys.exit(7)
TARGET_CWD=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("cwd",""))')
DELEGATE_JOB_ID=$(printf '%s' "$MAPPED_DATA" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("job_id",""))')
DELEGATE_JOB_TOKEN=""
DELEGATE_JOB_ATTEMPT=""
if [ -n "$DELEGATE_JOB_ID" ]; then
local_info=$(python3 -c "
import sys, json, os
jid = '$DELEGATE_JOB_ID'
reg_dir = os.environ.get('MAM_JOBS_DIR', '.mam/jobs')
p = os.path.join(reg_dir, f'{jid}.json')
if os.path.exists(p):
try:
with open(p) as f:
d = json.load(f)
tok = d.get('dispatch_token', '')
att = d.get('attempt_id', d.get('iteration', 1))
print(f'{tok} {att}')
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}')
fi
fi
# 멱등성: STOP 모드에서 이미 stopped 인 세션이고 purge 가 아닐 때만 no-op + exit 0
if [ "$STOP_MODE" = "1" ] && [ "$PURGE" != "1" ]; then
@@ -190,7 +213,7 @@ if [ "$CAPTURE_ID" = "1" ] && [ -n "$TARGET_CWD" ]; then
fi
fi
delegate_publish_event "$DELEGATE_JOB_ID" progress "terminating"
delegate_publish_event "$DELEGATE_JOB_ID" progress "terminating" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
# graceful 종료: send-keys 로 정상 종료 유도 → 폴백 체인 (SIGTERM → SIGKILL).
graceful_stop() {
@@ -239,7 +262,7 @@ if [ "$PURGE" = "1" ] && [ "$HERDR_ALIVE" = "1" ]; then
echo " Refusing registry removal — records preserved (no state was modified)." >&2
echo " Diagnose the stuck TUI (herdr session attach '$SESSION_NAME'), then re-run" >&2
echo " stop_session.sh --purge-conversation --yes (retry is safe/idempotent)." >&2
delegate_publish_event "$DELEGATE_JOB_ID" cancelled "purge aborted: session still alive; no state was modified"
delegate_publish_event "$DELEGATE_JOB_ID" cancelled "purge aborted: session still alive; no state was modified" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
exit 4
fi
fi
@@ -319,7 +342,7 @@ else:
print(f"updated: {name} status={target['status']}", flush=True)
PYEOF
delegate_publish_event "$DELEGATE_JOB_ID" cancelled "session stopped by operator before job completion"
delegate_publish_event "$DELEGATE_JOB_ID" cancelled "session stopped by operator before job completion" "$DELEGATE_JOB_TOKEN" "$DELEGATE_JOB_ATTEMPT"
echo
echo "=== stop complete ==="