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:
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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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,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 ==="
|
||||
|
||||
Reference in new issue
Block a user