merge: refactor branch (Issue #5 Python core migration & robust orchestration loop)
This commit is contained in:
commit
b347efd760
107 files changed
+14882
-1686
No files matched your search
@@ -0,0 +1,371 @@
|
||||
> **본 리포트는 Job `2770df36`(role: Planner)의 산출물인 Phase 2a 구현 계획서다.**
|
||||
> durable 사본: `.agents/reports/creator-claude-01/plan-2770df36.md` (커밋됨).
|
||||
>
|
||||
> **범위 명시**: 본 job의 지시는 "구현 계획서를 작성"이며, 목표 문장의 (5) 테스트 작성·(6) 리뷰어 만장일치 PASS는 이 계획을 실행할 **후속 Worker/Reviewer job**의 완료 조건이다. 본 계획서는 그 job들이 그대로 실행할 수 있도록 커밋 5개·게이트 9개·인수인계 체크리스트까지 명세했다.
|
||||
|
||||
---
|
||||
|
||||
# 📑 Issue #5 Phase 2a 구현 계획서 — `lib_py/loop/` 코어 & Shadow Watcher
|
||||
|
||||
- **Job**: `2770df36` / **Planner**: `creator-claude-01`
|
||||
- **기준 커밋**: `015ab70` (branch `refactor`, working tree clean) / Python `3.9.6` / paho-mqtt `2.1.0` / herdr `0.8.2` / **전체 테스트 수집 576**
|
||||
- **요구 출처**: `implementation_plan.md` §2 Phase 2a 행, `task.md` P2A-01~P2A-04, `state-transitions.md` §2~§5
|
||||
- **문서 성격**: Worker가 그대로 실행할 수 있는 단계별 명세. §1의 모든 인용은 계획 수립 중 **라이브 코드·라이브 CLI로 직접 확인**했다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 요약 (TL;DR)
|
||||
|
||||
1. **Phase 2a는 레지스트리에 단 1바이트도 쓰지 않는다.** Shadow watcher는 "Would-Complete" 판정을 로그로만 남긴다. 이 불변식을 문장이 아니라 **구조와 테스트로** 강제한다(§D5, G4).
|
||||
2. **CAS 경계는 신규 파일이 아니라 기존 writer 3곳을 한 함수 뒤로 모으는 작업이다.** 현재 상태 writer는 3개이고 그중 **하나(`pick_pending`)는 이미 문서화된 chokepoint를 우회**한다(§1-2 실측).
|
||||
3. **4중 귀속에 필요한 필드가 job 레코드에 없다**(`attempt_id`/`agent_session_uuid`/`pane_process_generation` 전부 부재, §1-4). Phase 1이 레코드 스키마 불변을 약속했고 Phase 2a는 레지스트리 쓰기가 금지되므로, **귀속 증거는 shadow 전용 저장소에 둔다.** 레코드 스키마 결정은 Phase 2b로 미룬다.
|
||||
4. **`herdr agent wait --timeout`의 단위는 밀리초다**(라이브 `--help` 확인, Phase 0 fixture도 `integer_ms`로 정확히 기록). 초 단위로 오해하면 1000배 오차가 난다.
|
||||
5. **락 안에서 감사 로그 I/O가 일어나고 있다**(`update_job_status`가 락 보유 중 `update_logged_status`/`append_event` 호출, §1-3). Phase 2a는 이를 "락 안에서 결정 + 알림 큐잉 → 락 해제 → 밖에서 flush"로 바꾼다.
|
||||
6. **판정 로직의 테스트 벡터는 새로 만들지 않는다.** Phase 0에서 3인 리뷰로 검증된 watcher 시나리오 7종과 그 `expected_policy`가 그대로 엔진의 결정 테이블 테스트가 된다(§D7).
|
||||
|
||||
---
|
||||
|
||||
## 1. Planner 사전 실측
|
||||
|
||||
### 1-1. 대상 파일과 규모
|
||||
|
||||
| 항목 | 실측 |
|
||||
|---|---|
|
||||
| 기준 커밋 | `015ab70` |
|
||||
| 전체 테스트 | **576 수집** (Phase 1 종료 시점) |
|
||||
| `lib_py/` 기존 패키지 | `agents/`, `delegation/`, `atomic_yaml.py`, `layout.py`, `paths.py`, `state.py`, `verify_session.py`, `workspace_uuid.py` |
|
||||
| `MAM_ENGINE_CAS` | **아직 존재하지 않음** (`grep` 결과 `implementation_plan.md` 언급 1건뿐) — Phase 2a가 신설 |
|
||||
|
||||
### 1-2. job 레코드 상태 writer 전수 조사 (P2A-01의 실제 범위)
|
||||
|
||||
`_atomic_write_record` 직접 호출자 = **5곳**, 그중 상태를 바꾸는 것은 **3곳**이다.
|
||||
|
||||
| # | 위치 | 하는 일 | CAS 여부 |
|
||||
|---|---|---|---|
|
||||
| W1 | `mqtt_common.py:410` (`update_job_status`) | `record.update(fields)` **무조건 병합** | ❌ 없음 |
|
||||
| W2 | `registry.py:131` (`pick_pending`) | 락 안에서 `status == "pending"` 필터 후 `running`으로 기록 | ⚠️ **사실상 CAS이나 W1을 우회한 별도 구현** |
|
||||
| W3 | `mqtt_common.py:433` (`next_seq`) | `last_seq` 증가 (상태 무관) | 해당 없음 |
|
||||
| — | `registry.py:107` (`register_job`) | 레코드 생성 | 해당 없음 |
|
||||
| — | `registry.py:151` (`update_status`) → W1 | | |
|
||||
| — | `registry.py:365` (`update` CLI) → W1 | | |
|
||||
| — | `publish_event.py:179` (`record_outcome`) → W1 | | |
|
||||
|
||||
> **핵심 (정확히)**: `update_job_status`의 docstring은 자신을 "the single chokepoint for status writes"라고 선언하지만, `registry.py:135`의 주석이 스스로 인정하듯 `pick_pending`은 이를 우회한다.
|
||||
>
|
||||
> 단, **`pick_pending`이 틀린 것은 아니다.** 락 안에서 `status == "pending"`을 필터한 뒤 기록하므로 `pending → running`에 대해서는 **이미 올바른 compare-and-swap**이다(`registry.py:123`). 문제는 정확성이 아니라 **중복**이다: CAS 판정과 감사 로그 mirror가 두 벌로 존재하며, Phase 1의 O-1(1회성 전송 이중 구현)에서 확인했듯 **같은 로직의 사본은 한쪽에만 결함이 생기는 경로**다.
|
||||
>
|
||||
> 따라서 **P2A-01은 "없는 CAS를 새로 만드는" 작업이 아니라 "한쪽에만 있는 CAS를 모든 전이로 일반화하고 두 사본을 하나로 합치는" 작업이다.** 실제로 CAS가 전혀 없는 것은 W1(`record.update(fields)` 무조건 병합)이다.
|
||||
|
||||
셸/래퍼 측은 job 레코드를 직접 쓰지 않는다: 래퍼는 `registry.py status --set`(→W1)을, `reconcile.sh:192`는 `registry.load_job`(읽기 전용)만 쓴다. `run_loop.sh:254`는 job JSON을 **읽기만** 한다(3초 폴링). 즉 **모든 쓰기가 파이썬 안에 있다** — CAS 경계를 파이썬 한 곳에 두면 전수 커버가 가능하다.
|
||||
|
||||
### 1-3. 현재 락 트랜잭션의 I/O 실측
|
||||
|
||||
`update_job_status`(`:405-418`)는 `registry_lock` **보유 중에** 다음을 수행한다:
|
||||
- `load_job` (읽기 I/O)
|
||||
- `_atomic_write_record` (쓰기 + `fsync` + `os.replace`)
|
||||
- `update_logged_status` (**감사 로그 디렉터리 쓰기**)
|
||||
- `append_event` (**감사 로그 append**)
|
||||
|
||||
네트워크/서브프로세스는 락 안에 없다(`publish_event.main`의 순서: `next_seq`(락) → 발행(네트워크, 락 없음) → `record_outcome`(락)). **이 성질은 Phase 2a에서 반드시 보존해야 하며**, 추가로 감사 로그 I/O를 락 밖으로 빼는 것이 P2A-01의 "락 내 I/O 배제" 요구다.
|
||||
|
||||
### 1-4. 4중 귀속에 필요한 필드 부재 (설계를 좌우하는 사실)
|
||||
|
||||
라이브 job 레코드의 실제 키:
|
||||
```
|
||||
agent, agent_session, auth_token, broker, created_at, expected_artifacts,
|
||||
idle_timeout_sec, iteration, job_id, job_type, last_seq, max_iterations,
|
||||
prompt, reviewer, reviewer_session, role, schema_version, status,
|
||||
timeout_sec, topic_prefix, updated_at
|
||||
```
|
||||
`state-transitions.md` §2가 요구하는 4중 귀속 중 **`attempt_id`·`agent_session_uuid`·`pane_process_generation`이 전부 없다**. `iteration`(루프 회차)은 있으나 attempt와 의미가 다르다.
|
||||
|
||||
동시에 Phase 1은 "스키마 불변(`SCHEMA_VERSION = 1`, 레지스트리 레코드 필드 불변)"을 계약으로 못박았고, P2A-03은 **레지스트리 쓰기 자체를 금지**한다. → **Phase 2a에서 귀속 증거를 레코드에 넣는 것은 불가능하며, 넣어서도 안 된다.** §D6이 이를 해결한다.
|
||||
|
||||
### 1-5. `herdr agent wait` 라이브 계약 (watcher.py의 입력)
|
||||
|
||||
```
|
||||
Usage: herdr agent wait <TARGET> [OPTIONS]
|
||||
--until <STATUS> idle|working|blocked|done|unknown (반복 가능)
|
||||
--timeout <MS> Fail after this many milliseconds
|
||||
Without --until, matches idle, done, or blocked.
|
||||
Without --timeout, waits indefinitely.
|
||||
```
|
||||
- **단위는 밀리초**다. Phase 0 fixture(`wait-contract.json`)도 `"type": "integer_ms"`로 정확히 기록되어 있다.
|
||||
- 성공 → `exit 0`, **stdout**에 `{"id":"cli:agent:wait","result":{"agent":{...}}}`.
|
||||
- 타임아웃/대상 부재 → `exit 1`, **stderr**에 에러 JSON(`error.code` = `timeout` / `agent_not_found`). stdout은 비어 있다.
|
||||
- `--until` 생략 시 기본 매칭은 `idle,done,blocked` — **`working`은 매칭되지 않는다.** watcher는 항상 `--until`을 명시한다.
|
||||
- **단일 `wait`는 1개의 최종 스냅샷만 주는 동기 장벽**이다(`herdr-runtime.md` §2-1). 연속 관측은 반복 호출로 구성해야 한다.
|
||||
|
||||
### 1-6. Phase 0에서 이미 검증된 판정 벡터
|
||||
|
||||
`tests/fixtures/issue_5/watcher/`의 7종과 `expected_policy.decision`은 3인 교차 리뷰를 통과한 계약이다. Phase 2a 엔진의 결정 함수는 **이 7종을 그대로 입력받아 같은 결정을 내야 한다**.
|
||||
|
||||
| 시나리오 | 기대 결정 |
|
||||
|---|---|
|
||||
| `initial_idle` | `hold` (실행 앵커 없음) |
|
||||
| `tool_running` | `hold` (도구 실행 중) |
|
||||
| `report_pause_15s` | `hold` (매니페스트 없음 — 디바운스만으로 완료 금지) |
|
||||
| `finalized_candidate` | `candidate_approved` |
|
||||
| `blocked_permission` | `hold_blocked` |
|
||||
| `transport_disconnected` | `hold_reconnect` |
|
||||
| `restarted_generation` | `reject_stale` |
|
||||
|
||||
`dispatch_context` 필드: `job_id`, `attempt_id`, `agent_session_uuid`, `pane_process_generation`, `dispatch_ack`, `execution_anchor_observed`.
|
||||
|
||||
### 1-7. Phase 1에서 넘어온 인계 사항 (본 Phase가 게이트)
|
||||
|
||||
1. **`preexec_fn` (3인 리뷰어 공통 지적)**: `daemon.spawn`은 `preexec_fn`으로 SIGHUP을 무시하는데 이는 **멀티스레드 프로세스에서 안전하지 않다**. Shadow watcher가 스레드를 쓰면서 `DelegationService.ensure_subscriber_daemon`을 호출하면 이 게이트에 걸린다(§D9).
|
||||
2. **O-4 (Claude)**: freeze 트리의 `_delegate_py_bin`은 `.venv`를 찾지 못해 paho 없는 시스템 python으로 떨어진다. Phase 2a가 freeze `lib.sh` 헬퍼를 호출하지 않는 한 무해하다(§D9에서 재확인).
|
||||
3. **연결 지문 하드코딩 필드 목록**: `BrokerConfig` 필드 추가 시 조용히 누락된다. Phase 2a에서 `dataclasses.fields()` 유도로 바꾸거나 커버리지 단정 테스트를 추가한다(§3 P2A-04에 포함).
|
||||
|
||||
---
|
||||
|
||||
## 2. 설계 결정 (Design Decisions)
|
||||
|
||||
### D1. 패키지 배치
|
||||
|
||||
```
|
||||
.agents/skills/lib_py/loop/
|
||||
__init__.py # 공개 API 재수출
|
||||
evidence.py # 증거 튜플 정규화 (watcher → engine 사이의 유일한 자료형)
|
||||
attribution.py # 4중 귀속 + dispatch anchor 판정 (순수 함수)
|
||||
policy.py # 결정 함수: 증거 -> hold|hold_blocked|hold_reconnect|reject_stale|candidate_approved
|
||||
engine.py # 단일 전이 주체 + CAS 트랜잭션 경계
|
||||
watcher.py # herdr agent wait 래핑, 재연결 백오프, 스냅샷 수집
|
||||
shadow.py # Shadow 러너: watcher -> policy -> "would-complete" 로깅 (쓰기 없음)
|
||||
store.py # shadow 전용 귀속/증거 저장소 (레지스트리와 분리)
|
||||
```
|
||||
|
||||
- **`policy.py`는 순수 함수여야 한다**(I/O·시계·전역 상태 금지). Phase 0 fixture 7종을 그대로 먹여 테스트하기 위한 필수 조건이다.
|
||||
- **D1(b) 재적용**: `lib_py.loop`는 `lib_py.delegation` **외의** 형제 `lib_py` 모듈을 임포트하지 않는다. 이유는 Phase 1과 동일하다 — freeze 하에서 부모 `lib_py`와 바인딩된 패키지가 다른 트리일 수 있다. AST grep 테스트로 고정한다(§D8).
|
||||
- Python 3.9 호환 필수(`match`, PEP 604 런타임 유니온 금지). 게이트에서 `compileall`.
|
||||
|
||||
### D2. 단일 CAS 전이 경계 (P2A-01)
|
||||
|
||||
**`engine.transition()`이 유일한 상태 writer가 된다.**
|
||||
|
||||
```python
|
||||
# lib_py/loop/engine.py
|
||||
class TransitionResult(object):
|
||||
__slots__ = ("committed", "from_status", "to_status", "reason", "record", "notifications")
|
||||
|
||||
def transition(job_id, request, *, registry_dir, expected_attempt=None,
|
||||
expected_status=None, fields=None, source="unknown") -> TransitionResult:
|
||||
"""유일한 상태 전이 주체.
|
||||
|
||||
락 안에서 하는 일: load -> CAS 판정 -> (허용 시) 기록 -> 알림 큐잉.
|
||||
락 밖에서 하는 일: 알림 flush(감사 로그), MQTT 발행.
|
||||
"""
|
||||
```
|
||||
|
||||
CAS 규칙은 `state-transitions.md` §3 표를 그대로 구현한다:
|
||||
|
||||
| 현재 | 요청 | 조건 | 결과 |
|
||||
|---|---|---|---|
|
||||
| `pending` | `claim`/`started` | 세션·attempt 일치 | `running` |
|
||||
| `running` | `progress`/`permission_required` | — | `running` (메타만 갱신) |
|
||||
| `running` | explicit terminal | 유효 세션 서명 | 요청 terminal |
|
||||
| `terminal` | 동일 terminal 재수신 | — | **No-op 멱등 ACK** |
|
||||
| `terminal` | 상충 신호 | — | **거부 + conflict 감사 기록** |
|
||||
| 임의 | stale attempt/generation | 불일치 | **거부 + stale 경고** |
|
||||
| `running` | `deadline_timeout` | 타임아웃 확인 | `error` |
|
||||
|
||||
**기존 writer 3곳의 라우팅**:
|
||||
- W1 `update_job_status` → 내부적으로 `engine.transition(request="merge", ...)`를 호출하도록 교체. **시그니처·반환값·예외는 불변**(Phase 1의 G-1~G-4가 이 함수 계약에 걸려 있다).
|
||||
- W2 `pick_pending` → `engine.transition(request="claim", expected_status="pending")`로 교체. 이로써 우회가 사라진다.
|
||||
- W3 `next_seq` → 상태를 바꾸지 않으므로 그대로 두되, 같은 락을 쓰므로 **트랜잭션 시간 예산**에만 포함한다.
|
||||
|
||||
**롤백 스위치 `MAM_ENGINE_CAS`(기본 `1`)**: `0`이면 W1/W2가 **현재의 무조건 병합 본체**로 분기한다. Phase 1의 `MAM_DELEGATION_PY`와 동일한 패턴이며, legacy 본체는 `_*_legacy`로 보존한다(제거는 Phase 4).
|
||||
|
||||
### D3. 락 규율 — 락 안에서는 결정만, I/O는 밖에서
|
||||
|
||||
```
|
||||
with registry_lock(registry_dir): # 짧게
|
||||
record = load_job(...)
|
||||
decision = _cas_decide(record, request, expected_attempt, expected_status)
|
||||
if decision.commit:
|
||||
_atomic_write_record(...) # 레코드 1회 쓰기만 허용
|
||||
pending = decision.notifications # 감사 로그는 "쓰지 않고" 큐에 담는다
|
||||
# 락 해제 후
|
||||
_flush_notifications(pending) # 감사 로그 append / status mirror
|
||||
```
|
||||
|
||||
- **금지**: 락 보유 중 `subprocess`, 소켓, MQTT, `herdr` 호출, `time.sleep`.
|
||||
- **허용**: `load_job` 1회 + `_atomic_write_record` 1회.
|
||||
- 감사 로그 mirror(`update_logged_status`/`append_event`)를 락 밖으로 옮긴다. **순서 보장 손실 우려**: 현재는 락 안이라 감사 이력이 직렬화된다. 이를 유지하기 위해 알림에 **CAS가 부여한 단조 `transition_seq`**를 실어 보내고, flush가 늦게 도착해도 순서를 복원할 수 있게 한다.
|
||||
- 이 규율은 문장이 아니라 테스트로 강제한다: 락 획득 중 `subprocess.run`/`socket.socket`/`time.sleep`을 monkeypatch로 감시해 호출 0건을 단정한다(G5).
|
||||
- **저장소에 이미 선례가 있다**: `pick_pending`은 레코드 쓰기를 락 안에서 끝내고 감사 로그 mirror(`update_logged_status`/`append_event`)를 **락 해제 후에** 수행한다(`registry.py:131-145`). D3은 새 패턴을 발명하는 것이 아니라 **이 기존 패턴을 `update_job_status`에도 적용해 일반화**하는 것이다.
|
||||
|
||||
### D4. `watcher.py` — `herdr agent wait` 래핑
|
||||
|
||||
```python
|
||||
def wait_once(target, until=("idle", "done", "blocked"), timeout_ms=15000, run=subprocess.run) -> WaitOutcome
|
||||
```
|
||||
|
||||
- `--timeout`은 **밀리초**로 전달한다(§1-5). 초 단위 인자를 받지 않는다 — 단위 혼동을 타입 이름(`timeout_ms`)으로 차단한다.
|
||||
- `--until`을 **항상 명시**한다(생략 시 `working`이 빠지는 기본값에 의존하지 않는다).
|
||||
- `exit 0` → stdout JSON 파싱 → `result.agent` 스냅샷.
|
||||
- `exit 1` → **stderr** JSON 파싱 → `error.code`로 분기: `timeout`(정상적 무이벤트), `agent_not_found`(대상 소멸).
|
||||
- 그 외 exit code / JSON 파싱 실패 / 실행 실패 → `transport_error`.
|
||||
- **재연결 백오프**: `transport_error` 연속 발생 시 1s → 2s → 4s → … → 최대 16s. 백오프 대기는 **락 밖에서만** 한다. 복구 시 **캐시된 스냅샷을 폐기하고 재스냅샷**한다(`transport_disconnected` → `hold_reconnect` 정책과 일치).
|
||||
- watcher는 **판정하지 않는다.** 증거 튜플만 만들어 큐에 넣는다(Phase 0 합의: watcher는 관측, engine은 결정).
|
||||
|
||||
### D5. Shadow 모드 — 쓰기 불가를 구조로 보장 (P2A-03)
|
||||
|
||||
```python
|
||||
# shadow.py
|
||||
class ShadowRunner:
|
||||
def __init__(self, ..., decide=policy.decide, sink=logging_sink):
|
||||
... # engine 을 아예 주입받지 않는다
|
||||
```
|
||||
|
||||
- **ShadowRunner는 `engine.transition`에 대한 참조를 갖지 않는다.** "쓰지 않기로 약속"이 아니라 **쓸 수단이 없다**. 이것이 P2A-03을 문장이 아니라 구조로 만드는 방법이다.
|
||||
- 판정 결과는 `would_complete` 레코드로 **shadow 전용 로그**에만 남는다: `.mam/shadow/<job_id>.ndjson` (레지스트리와 다른 디렉터리).
|
||||
- 검증(G4): shadow 러너를 시나리오 전량으로 돌린 전후에 **레지스트리 디렉터리 전체의 파일 목록·mtime·SHA-256이 바이트 동일**함을 단정한다. "status를 안 썼다"보다 강한 단정이다.
|
||||
|
||||
### D6. 귀속 증거는 shadow 전용 저장소에 (§1-4 결론)
|
||||
|
||||
`store.py`가 `.mam/shadow/attribution/<job_id>.json`을 소유한다. 담기는 것:
|
||||
|
||||
```json
|
||||
{ "job_id": "...", "attempt_id": 1, "agent_session_uuid": "...",
|
||||
"pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z",
|
||||
"dispatch_ack": true, "dispatch_ack_at": "...", "execution_anchor_observed": false }
|
||||
```
|
||||
|
||||
- **job 레코드는 건드리지 않는다** → Phase 1의 스키마 불변 계약과 P2A-03을 동시에 만족한다.
|
||||
- `attempt_id`는 Phase 2a에서 **shadow가 자체 부여**한다(디스패치 관측 시 1부터). Phase 2b에서 레코드로 승격할지는 그때 결정한다 — **이 계획서는 레코드 스키마를 바꾸지 않는다.**
|
||||
- `pane_process_generation`은 `<pane_id>:<pid>:<start_iso>` 형식이며, Phase 0의 정체성 검증(`_validate_observed_herdr_item`)이 이미 이 형식을 강제한다.
|
||||
|
||||
### D7. 판정 테스트 벡터 = Phase 0 fixture (P2A-04의 절반)
|
||||
|
||||
`policy.decide(scenario) -> decision` 이 Phase 0의 7종 시나리오에 대해 `expected_policy.decision`과 **정확히 일치**해야 한다. 신규 fixture를 만들기 전에 **기존 계약을 먼저 만족**시킨다. 이렇게 하면:
|
||||
- 판정 로직이 3인 리뷰를 통과한 계약에 묶인다.
|
||||
- Phase 0의 `validate_watcher_scenario`가 fixture 무결성을 계속 지킨다.
|
||||
|
||||
추가 시나리오(P2A-04 요구)는 같은 스키마로 작성하고 **`validate_watcher_scenario`를 통과해야 한다**: `race_double_dispatch`, `attempt_reuse`, `partial_report`, `unknown_status`, `restart_midflight`.
|
||||
|
||||
### D8. 트리 일관성 (Phase 1 R1의 교훈 재적용)
|
||||
|
||||
- `lib_py.loop`는 `lib_py.delegation`만 형제로 임포트한다. 그 외 `lib_py.*` 금지 → AST 테스트.
|
||||
- 루프 프로세스는 shim을 인프로세스로 임포트하지 않으므로 `lib_py.loop`와 `lib_py.delegation`은 부모 `lib_py`를 통해 **같은 트리**로 해석된다. 이를 테스트로 고정한다(`__file__` 접두 비교).
|
||||
- Shadow 러너를 서브프로세스로 띄울 때는 Phase 1의 `_delegation_py`와 **동일한 명시 부트스트랩**을 쓴다. `python -m`은 cwd가 `PYTHONPATH`를 이기므로 금지한다(R1 재발 방지).
|
||||
|
||||
### D9. Phase 1 인계 게이트 해소
|
||||
|
||||
- **`preexec_fn`**: Shadow watcher가 스레드를 쓸 경우 `daemon.spawn`의 `preexec_fn`이 위험해진다. **Phase 2a에서는 shadow 러너가 데몬을 스폰하지 않는다**(관측만). `ensure_subscriber_daemon` 호출 금지를 grep 테스트로 고정하고, 실제 제거/대체는 Phase 2b 스폰 도입 시 처리한다.
|
||||
- **O-4**: shadow는 freeze `lib.sh` 헬퍼를 호출하지 않으므로 인터프리터 해석 문제에 노출되지 않는다. 호출 0건을 grep으로 고정한다.
|
||||
- **연결 지문 필드 목록**: `dataclasses.fields(BrokerConfig)` 전량이 지문에 포함됨을 단정하는 테스트를 추가한다(필드 추가 시 조용히 누락되는 것을 차단).
|
||||
|
||||
---
|
||||
|
||||
## 3. 작업 분해 (WBS) — 커밋 5개
|
||||
|
||||
### P2A-00 특성화 선행 (커밋 1: `test(issue-5): phase 2a writer inventory and lock discipline characterization`)
|
||||
- **파일**: `tests/test_issue5_phase2a_loop.py`(신규)
|
||||
- 현행 동작을 **먼저 고정**한다(Phase 1에서 효과가 입증된 순서):
|
||||
- X-01 `update_job_status`가 무조건 병합임을 단정(현행 사실).
|
||||
- X-02 `pick_pending`의 현행 계약 고정: `pending`만 claim하고 동시 claim 시 한 세션만 이긴다(라우팅 후에도 이 성질이 유지되어야 한다).
|
||||
- X-03 락 보유 중 감사 로그 I/O가 **발생함**을 단정(현행 사실 — D3 이후 이 테스트는 반대로 뒤집힌다).
|
||||
- X-04 `next_seq`/`register_job`/`record_outcome` 경로의 현행 부작용 스냅샷.
|
||||
- **완료 기준**: 레거시 코드에서 전부 통과, 576 + 신규 통과.
|
||||
|
||||
### P2A-01 엔진·CAS 경계 (커밋 2: `feat(issue-5): single CAS transition boundary for every job-status writer`)
|
||||
- **파일**: `lib_py/loop/{__init__,engine,evidence,attribution}.py`; `mqtt_common.update_job_status`·`registry.pick_pending` 라우팅; `MAM_ENGINE_CAS` 분기 + `_*_legacy` 보존.
|
||||
- **테스트**: `state-transitions.md` §3 표 9행 전부에 대한 전이 테스트, 멱등 재수신, 상충 거부 + conflict 감사, stale attempt 거부, `MAM_ENGINE_CAS=0` 양 경로 parametrize, **락 내 I/O 0건 단정**(subprocess/socket/sleep monkeypatch).
|
||||
- **완료 기준**: Phase 1의 G-1~G-4가 그대로 통과(계약 불변 증명), 전체 스위트 무회귀.
|
||||
|
||||
### P2A-02 watcher (커밋 3: `feat(issue-5): herdr agent wait watcher with reconnect backoff`)
|
||||
- **파일**: `lib_py/loop/watcher.py`, `store.py`
|
||||
- **테스트**: 가짜 `run`으로 exit 0/stdout, exit 1/stderr `timeout`, exit 1/stderr `agent_not_found`, 파싱 실패, 실행 실패 5경로; 백오프 수열(1,2,4,8,16 상한)과 **재연결 시 캐시 폐기**; `--timeout`이 **밀리초로 전달**됨을 argv 단정; `--until` 항상 명시됨을 argv 단정. **herdr 실제 호출 0건**(전부 주입된 가짜).
|
||||
- **완료 기준**: 위 통과 + 실제 herdr로 1회 스모크(수동, 보고서 첨부).
|
||||
|
||||
### P2A-03 shadow 러너 (커밋 4: `feat(issue-5): shadow watcher that logs would-complete without writing`)
|
||||
- **파일**: `lib_py/loop/{policy,shadow}.py`
|
||||
- **테스트**: Phase 0 시나리오 7종 → `policy.decide`가 `expected_policy.decision`과 일치; 신규 시나리오 5종 추가(`validate_watcher_scenario` 통과 필수); **레지스트리 디렉터리 SHA-256 전후 동일** 단정; `ShadowRunner`가 `engine`/`update_job_status`/`_atomic_write_record`를 참조하지 않음을 AST로 단정.
|
||||
- **완료 기준**: shadow 전량 실행 후 레지스트리 바이트 불변.
|
||||
|
||||
### P2A-04 통합·문서 (커밋 5: `docs(issue-5): phase 2a contracts, scenarios and task checklist`)
|
||||
- race/attempt 재사용/부분 리포트/도구 실행/blocked/unknown/재시작 시나리오 실행 결과 기록.
|
||||
- `state-transitions.md`에 구현 매핑 각주, `task.md` P2A-01~04 체크 + 증거, `implementation_plan.md` Phase 2a 상태 갱신, `MAM_ENGINE_CAS` 운영 문서.
|
||||
- Phase 1 인계 3건 처리 상태 명시(§D9).
|
||||
|
||||
---
|
||||
|
||||
## 4. 게이트
|
||||
|
||||
| # | 조건 | 측정 |
|
||||
|---|---|---|
|
||||
| G1 | 기존 **576** 전부 통과 + 신규 전부 통과, skipped 0 | `pytest tests/ -q` (약 13분) |
|
||||
| G2 | 셸 문법·공백 | `bash -n`, `git diff --check` (**범위** 기준) |
|
||||
| G3 | Python 3.9 컴파일 | `compileall .agents/skills/lib_py/loop` |
|
||||
| **G4** | **Shadow가 레지스트리에 0바이트 기록** | 실행 전후 레지스트리 디렉터리 파일 목록·SHA-256 동일 |
|
||||
| **G5** | **락 내 I/O 배제** | 락 보유 중 subprocess/socket/sleep 호출 0건 (monkeypatch 감시) |
|
||||
| G6 | 모든 상태 writer가 CAS 경유 | `_atomic_write_record` 직접 호출자가 engine·`register_job`·`next_seq`뿐임을 AST로 단정 |
|
||||
| G7 | 판정이 Phase 0 계약과 일치 | 시나리오 7종 + 신규 5종 `expected_policy` 일치 |
|
||||
| G8 | 롤백 | `MAM_ENGINE_CAS=0`에서 특성화 통과 + 라이브 스모크 |
|
||||
| G9 | 트리 일관성 | `lib_py.loop`/`lib_py.delegation` 동일 트리, 형제 임포트 0건 |
|
||||
|
||||
Worker 보고서에는 G1 pytest 요약 원문, G4의 해시 비교 출력, G5의 감시 로그, G7 표를 **그대로** 붙인다. `file:line` 인용은 `grep -n` 출력으로 뒷받침한다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 롤백
|
||||
|
||||
1. **즉시**: `MAM_ENGINE_CAS=0` → W1/W2가 보존된 legacy 무조건 병합으로 분기. Shadow는 애초에 쓰기가 없으므로 프로세스 종료만으로 흔적이 사라진다.
|
||||
2. **프로세스**: Shadow 러너 종료 + 인메모리 큐 폐기. `.mam/shadow/`는 삭제해도 무해(레지스트리와 분리되어 있음).
|
||||
3. **커밋 단위**: 5→4→3→2 순 `git revert`. 커밋 1(특성화)은 레거시에서도 통과하므로 남겨도 된다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 리스크와 완화
|
||||
|
||||
| # | 리스크 | 완화 |
|
||||
|---|---|---|
|
||||
| R1 | CAS 도입이 Phase 1의 G-1~G-4 계약을 깬다 | `update_job_status` 시그니처·반환·예외 불변 유지, 두 경로 parametrize |
|
||||
| R2 | 감사 로그를 락 밖으로 빼면서 순서가 뒤바뀐다 | CAS가 부여하는 단조 `transition_seq`를 알림에 실어 순서 복원 |
|
||||
| R3 | `pick_pending` 라우팅이 **이미 올바른** 동시 claim 경쟁을 깨뜨린다 | 라우팅 전에 X-02로 현행 성질을 고정하고, `expected_status="pending"` CAS가 동일 성질을 유지함을 동시 claim 테스트로 검증 |
|
||||
| R4 | `--timeout` 단위 혼동(초 vs ms) | 인자명 `timeout_ms`, argv 단정 테스트 |
|
||||
| R5 | shadow가 실수로 쓰기 경로를 얻는다 | 구조적 차단(engine 미주입) + AST 단정 + 해시 불변 단정 |
|
||||
| R6 | freeze/live 트리 혼재 | D8 (형제 임포트 금지 + 명시 부트스트랩) |
|
||||
| R7 | `preexec_fn` 스레드 비안전 | Phase 2a는 데몬 스폰 없음(grep 고정), 실제 대체는 Phase 2b |
|
||||
| R8 | 실브로커/실herdr 의존 테스트 | 전부 주입된 가짜, 실호출 0건. 라이브 스모크는 수동·정보성 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 명시적 비범위
|
||||
|
||||
- **자동완료 실제 커밋** — Phase 2b. Phase 2a는 `would_complete` 로깅까지만.
|
||||
- job 레코드 스키마 변경(`attempt_id` 등 승격) — Phase 2b.
|
||||
- `run_loop.sh`의 파이썬화 — Phase 3.
|
||||
- 래퍼·`reconcile.sh`의 상태 머신 변경 — 전 Phase 공통 불변.
|
||||
- `preexec_fn` 제거 — Phase 2b.
|
||||
- `pgrep` 다중 PID 정리, Windows — Phase 4 후보.
|
||||
|
||||
---
|
||||
|
||||
## 8. Worker 인수인계 체크리스트
|
||||
|
||||
- [ ] P2A-00 커밋 1: 현행 동작 특성화(W1 무조건 병합, W2 우회, 락 내 I/O 발생)가 **레거시 코드에서** 통과
|
||||
- [ ] P2A-01 커밋 2: `engine.transition` 단일 경계, W1/W2 라우팅, `MAM_ENGINE_CAS` 양 경로, 락 내 I/O 0건
|
||||
- [ ] P2A-02 커밋 3: watcher 5경로 + 백오프 + ms 단위 argv 단정
|
||||
- [ ] P2A-03 커밋 4: Phase 0 시나리오 7종 일치 + 신규 5종, 레지스트리 해시 불변
|
||||
- [ ] P2A-04 커밋 5: 문서·체크리스트·인계 3건 처리 상태
|
||||
- [ ] 보고서: G1 원문, G4 해시 출력, G5 감시 로그, G7 표, `grep -n` 인용
|
||||
- [ ] 커밋 접두: `test(issue-5)`, `feat(issue-5)`, `feat(issue-5)`, `feat(issue-5)`, `docs(issue-5)`
|
||||
|
||||
---
|
||||
|
||||
## 부록 A. 계획 수립 시 실행한 검증 명령 (재현용)
|
||||
|
||||
```bash
|
||||
git log --oneline -1 # 015ab70
|
||||
.venv/bin/python -m pytest tests/ --collect-only -q | tail -1 # 576
|
||||
grep -rn "_atomic_write_record" --include='*.py' .agents | grep -v "def " # writer 5곳
|
||||
sed -n '125,155p' .agents/skills/lib_py/delegation/registry.py # pick_pending 우회
|
||||
sed -n '395,420p' .agents/skills/lib_py/delegation/mqtt_common.py # 락 내 감사 I/O
|
||||
HERDR_SESSION_NAME=multi-agent-mux herdr agent wait --help # --timeout <MS>
|
||||
cat tests/fixtures/issue_5/herdr/wait-contract.json # integer_ms
|
||||
grep -rn "MAM_ENGINE_CAS" --include='*.py' --include='*.sh' . # 미존재
|
||||
.venv/bin/python -c "import json,glob; print(sorted(json.load(open(sorted(glob.glob('.mam/jobs/*.json'))[-1]))))" # 레코드 키 (attempt_id 부재)
|
||||
```
|
||||
@@ -0,0 +1,251 @@
|
||||
# 📑 Issue #5 Phase 2b 구현 계획서 — 이중 감지 자동완료 (Flag-Gated / Opt-In)
|
||||
|
||||
- **Job**: `df85e978` / **Planner**: `creator-claude-01`
|
||||
- **기준 커밋**: `4a5916a` (branch `refactor`, working tree clean) / Python `3.9.6` / herdr `0.8.2` / **전체 테스트 664**
|
||||
- **요구 출처**: `implementation_plan.md` §2 Phase 2b 행, `task.md` P2B-01~P2B-03, `state-transitions.md` §3~§6
|
||||
- **문서 성격**: Worker가 그대로 실행할 수 있는 단계별 명세. §1의 모든 인용은 라이브 코드·실행으로 직접 확인했다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 요약 (TL;DR)
|
||||
|
||||
1. **매니페스트를 쓰는 주체가 이 Phase의 전부다.** 워처가 스스로 해시를 계산해 매니페스트를 만들면 그것은 "워처가 안정된 파일을 봤다"는 자기 증명일 뿐이며, Round 2에서 Codex가 R1으로 기각한 바로 그 형태다. **끝났다는 사실을 아는 쪽(에이전트)이 확정을 선언해야 한다**(§D1).
|
||||
2. **그래서 미지원 에이전트는 자동으로 fail-closed가 된다.** 확정 선언을 하지 않으면 매니페스트가 없고, 매니페스트가 없으면 `policy`가 이미 `hold`한다(Phase 2a에서 구현·테스트 완료). P2B-03의 "미지원 경로 100% 보존"은 새 분기가 아니라 **기존 동작의 자연스러운 귀결**이다.
|
||||
3. **`attempt_id`와 `iteration`의 의미 충돌을 먼저 정리해야 한다.** 엔진은 `record.get("attempt_id", record.get("iteration"))`로 대체하는데, 레코드에는 `attempt_id`가 **없고** `iteration`만 있다(실측). 지금은 우연히 값이 겹쳐 동작하지만 두 개념은 다르며, 자동완료 게이트가 이 값에 걸리므로 **명시적 결정 없이 진행하면 안 된다**(§D2).
|
||||
4. **지연 알림은 새 저장소를 만들지 않는다.** `state-transitions.md` §5가 "독립 Outbox DB 신설 금지"를 명시했고, 레코드 안 `pending_notification` 필드를 같은 CAS 트랜잭션에서 쓴다(§D4).
|
||||
5. **자동완료 허용 백엔드는 실측 증거가 있는 것으로 한정한다.** Phase 0 능력 행렬 실측: 정지 상태(`idle`/`done`) 관측 증거가 있는 것은 **claude·grok(idle), codex(done)** 뿐이고 **agy·hermes·opencode는 없다**(§1-5). 플래그가 켜져도 이 셋 외에는 자동완료하지 않는다.
|
||||
6. **664개 무회귀 + 신규.** Phase 2a와 동일하게 특성화 선행 → 구현 → 문서 순으로 커밋 5개.
|
||||
|
||||
---
|
||||
|
||||
## 1. Planner 사전 실측
|
||||
|
||||
### 1-1. 자동완료에 필요한 것 중 **존재하지 않는 것**
|
||||
|
||||
| 필요 | 현황 (실측) |
|
||||
|---|---|
|
||||
| 완료 매니페스트 | **없음.** 저장소 어디에도 매니페스트를 쓰거나 읽는 프로덕션 코드가 0건. 리포트 디렉터리에는 `report-final.md` 하나뿐 |
|
||||
| 리포트 원자적 확정 | **없음.** 에이전트가 `report-final.md`를 직접 쓴다. tmp+rename 없음 |
|
||||
| `attempt_id` 레코드 필드 | **없음.** 레코드에는 `iteration`만 존재 |
|
||||
| `pending_notification` | **없음.** `grep` 0건 |
|
||||
| `MAM_AUTO_COMPLETE` | **없음.** `grep` 0건 (참고: `MAM_ENGINE_CAS` 5건, `MAM_DELEGATION_PY` 4건은 존재) |
|
||||
|
||||
### 1-2. 이미 준비되어 있는 것 (Phase 2a 산출물)
|
||||
|
||||
| 있음 | 위치 |
|
||||
|---|---|
|
||||
| 단일 CAS 전이 경계 + 권한 축 | `lib_py/delegation/engine.py::transition` |
|
||||
| `expected_attempt` fail-closed | 레코드가 attempt를 못 대면 거부 (Round 1 R2) |
|
||||
| 관측 증거 **필수** 판정 | `lib_py/loop/policy.py` — 4식별자 + 매니페스트 `attempt_id` 부재 시 `hold` (Round 2 R1) |
|
||||
| 매니페스트 미확정 시 `hold` | `policy.decide`의 `manifest_committed`/`hash_verified` 게이트 |
|
||||
| `herdr agent wait` 래핑·백오프 | `lib_py/loop/watcher.py` |
|
||||
| 쓰기 경로 없는 shadow 러너 | `lib_py/loop/shadow.py` |
|
||||
| 지연 알림의 순서 보장 기반 | `transition_seq` + per-job 감사 락 (Round 2 R4) |
|
||||
|
||||
**즉 Phase 2b는 판정기를 새로 만드는 것이 아니라, 이미 fail-closed로 판정하고 있는 게이트에 "진짜 증거"를 공급하고 그 결과를 커밋에 연결하는 작업이다.**
|
||||
|
||||
### 1-3. `attempt_id` vs `iteration` — 실행으로 확인한 충돌
|
||||
|
||||
```
|
||||
record keys with attempt semantics: {'iteration': 1}
|
||||
transition(completed, expected_attempt=1) -> committed=True running -> completed
|
||||
transition(completed, expected_attempt=2) -> committed=False stale attempt: expected 2, record has 1
|
||||
```
|
||||
엔진은 `attempt_id`가 없으면 `iteration`을 쓴다. 래퍼의 loop 흐름에서는 `redispatch`가 `iteration`을 증가시키므로 두 값이 우연히 일치하지만, `iteration`은 **루프 회차**이고 `attempt_id`는 **디스패치 시도**다. 직접(`--type direct`) 재디스패치나 크래시 후 재시도에서 갈라진다. 자동완료 게이트가 이 값에 걸리므로 §D2에서 명시적으로 결정한다.
|
||||
|
||||
### 1-4. 미지원 경로 보존의 실제 메커니즘
|
||||
|
||||
`job_subscriber._check_disk_fallback`(`:60-72`)은 레지스트리 status를 읽어 terminal이면 즉시 종료한다. 즉 **자동완료가 `completed`를 쓰면 래퍼는 명시 이벤트와 구별하지 못한다.** 이는 의도된 결합이지만, 동시에 "자동완료가 틀리면 래퍼가 조용히 잘못된 완료를 받는다"는 뜻이므로 §D6의 감사 표식이 필수다.
|
||||
|
||||
### 1-5. 자동완료 허용 백엔드 (Phase 0 능력 행렬 실측)
|
||||
|
||||
```
|
||||
claude observed slots: ['idle']
|
||||
agy observed slots: ['working', 'wait_timeout']
|
||||
hermes observed slots: NONE
|
||||
grok observed slots: ['idle']
|
||||
opencode observed slots: NONE
|
||||
codex observed slots: ['done']
|
||||
```
|
||||
자동완료는 **정지 상태 관측**을 전제로 한다. `idle`/`done` 증거가 있는 **claude·grok·codex**만 후보이며, `agy`는 `working`만 관측돼 정지 판정의 실측 근거가 없고, `hermes`·`opencode`는 관측 0건이다.
|
||||
|
||||
### 1-6. 에이전트 지시문 주입 지점 (조정 비용)
|
||||
|
||||
`create_session.sh:483`과 래퍼 `:202`/`:319`가 `publish_event.py --job <id>`를 지시문에 넣는다. 확정 선언 명령을 추가하려면 **이 두 곳만** 바꾸면 된다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 설계 결정
|
||||
|
||||
### D1. 매니페스트는 **끝난 쪽이 선언한다** (P2B-01의 핵심)
|
||||
|
||||
```
|
||||
에이전트: 작업 종료 시
|
||||
publish_event.py --job <id> --finalize-report <path> [--side-effect <kind>=<evidence>] ...
|
||||
① <path>.tmp 를 <path> 로 원자적 rename (이미 최종본이면 rename 생략)
|
||||
② SHA-256 계산
|
||||
③ <report_dir>/report-manifest.json 원자적 기록:
|
||||
{ schema_version, job_id, attempt_id, report_path, sha256,
|
||||
finalized_at, side_effects: [...], declared_by }
|
||||
```
|
||||
|
||||
- **워처는 매니페스트를 만들지 않는다.** 만들면 자기 증명이 되어 Round 2 R1에서 기각된 형태가 된다. 워처는 **읽고 검증만** 한다(해시 재계산 후 대조).
|
||||
- **미지원 에이전트는 아무것도 하지 않으면 된다** — 매니페스트가 없으면 `policy`가 이미 `hold`한다. P2B-03의 "explicit event 경로 100% 보존"이 **새 코드 없이** 성립한다.
|
||||
- 리포트가 재작성되면 해시가 어긋나 매니페스트가 즉시 무효가 된다(§4 계약 그대로).
|
||||
|
||||
### D2. `attempt_id`를 레코드의 1급 필드로 승격 (결정 필요 사항)
|
||||
|
||||
**권고: 승격한다.** 근거:
|
||||
- 엔진의 `expected_attempt`는 이미 "레코드가 attempt를 대지 못하면 거부"로 fail-closed다(Round 1 R2). 자동완료가 이 게이트를 통과하려면 레코드가 attempt를 대야 한다.
|
||||
- `iteration` 대체는 우연한 일치에 의존한다. 두 개념이 갈라지는 순간 **CAS 가드가 조용히 무력화**된다.
|
||||
|
||||
구현:
|
||||
- `register_job`이 `attempt_id: 1`을 기록. `engine`의 `redispatch`가 `iteration`과 함께 `attempt_id`를 증가.
|
||||
- 엔진의 `record.get("attempt_id", record.get("iteration"))` 대체를 **제거**하고 `attempt_id`만 본다(대체는 의미 혼동의 원천).
|
||||
- **스키마 영향**: 필드 추가이므로 기존 리더는 무시하면 되고 `SCHEMA_VERSION`은 1을 유지한다. 다만 Phase 1이 "레코드 필드 불변"을 계약으로 명시했으므로 **이 계획서가 그 계약의 변경을 명시적으로 요청**한다. 리뷰어 승인 없이 진행하지 않는다.
|
||||
- 마이그레이션: `attempt_id`가 없는 기존 레코드는 `iteration` 값으로 1회 채운다(읽기 시 lazy, 쓰기는 다음 전이에서).
|
||||
|
||||
### D3. 자동완료 게이트 (기존 판정기에 증거를 공급)
|
||||
|
||||
`shadow.ShadowRunner`를 `AutoCompleteRunner`로 확장하되 **판정 로직은 손대지 않는다**. 순서:
|
||||
|
||||
1. `watcher.wait_once`로 정지 상태 관측 → `collector_attribution` 봉투에 4식별자를 채워 증거 구성.
|
||||
2. `report-manifest.json` 읽기 → 해시 **재계산 후 대조**(매니페스트 자기 신고를 신뢰하지 않는다) → `report_manifest.attempt_id`를 증거에 포함.
|
||||
3. `policy.decide` → `candidate_approved`가 아니면 종료(로그만).
|
||||
4. `candidate_approved`면 **게이트 3중 확인 후** `engine.transition(job_id, "completed", expected_attempt=<attempt>, source="auto_complete")`.
|
||||
- 게이트: 플래그 ON + 백엔드 허용목록(§1-5) + 잡별 opt-in.
|
||||
5. 커밋 결과가 `committed=False`면 **아무 일도 없었던 것으로 처리**하고 감사에 기록(늦은 conflict는 엔진이 이미 거부한다 — 새 코드 불필요).
|
||||
|
||||
### D4. 지연 알림 (P2B-02) — 레코드 안 outbox
|
||||
|
||||
`state-transitions.md` §5가 **독립 Outbox DB 신설을 금지**했다. 따라서:
|
||||
|
||||
- `engine.transition`이 커밋할 때 같은 락 안에서 `pending_notification: {event, seq, timestamp, signature}`를 레코드에 **함께** 기록한다(추가 I/O 없음 — 이미 쓰는 레코드에 필드 하나).
|
||||
- 락 해제 후 `notifier.flush(job_id)`가 MQTT 발행 → 성공 시 `pending_notification`을 지우는 **별도 CAS 전이**(`request="notification_settled"`, 상태 불변).
|
||||
- **재시작 복구**: 기동 시 `pending_notification`이 남은 레코드를 스캔해 재발행. 멱등하다 — 구독자가 `seq`로 중복을 거른다(기존 replay 방어).
|
||||
- **락 규율 유지**: 발행은 절대 락 안에서 하지 않는다(Phase 2a G5 테스트가 계속 감시).
|
||||
|
||||
### D5. 부수효과 증거 (P2B-01 후반)
|
||||
|
||||
- 레코드의 기존 `expected_artifacts`(이미 존재, `--artifact`로 설정)를 **선언된 부수효과 목록**으로 사용한다. 새 필드를 만들지 않는다.
|
||||
- `expected_artifacts`가 비어 있지 않은 job은 매니페스트의 `side_effects`가 **모든 항목을 덮어야** 자동완료 후보가 된다. 하나라도 증거가 없으면 `hold`.
|
||||
- 증거 형식은 `{kind, reference, verified_at}`이며, Phase 2b는 **존재와 대응만** 검증한다(원격 확인은 비범위 — 정직하게 명시).
|
||||
|
||||
### D6. 플래그와 감사 표식 (P2B-03)
|
||||
|
||||
| 스위치 | 기본 | 범위 |
|
||||
|---|---|---|
|
||||
| `MAM_AUTO_COMPLETE` | **`0` (OFF)** | 전역 킬 스위치. `1`이어야 자동완료가 가능 |
|
||||
| 백엔드 허용목록 | `claude,grok,codex` | §1-5 실측 근거. `MAM_AUTO_COMPLETE_BACKENDS`로 좁힐 수만 있게(넓히려면 증거 필요) |
|
||||
| 잡별 opt-in | 레코드 `auto_complete: true` | 셋 다 참일 때만 커밋 |
|
||||
|
||||
**감사 표식 필수**: 자동완료로 들어간 terminal은 `completed_by: "auto_complete"`와 판정 근거(매니페스트 해시, 관측 seq)를 감사 로그에 남긴다. §1-4에서 확인했듯 래퍼는 자동/명시를 구별하지 못하므로, **사후에 구별할 수 있는 유일한 수단**이다.
|
||||
|
||||
### D7. 무판정 fail-closed
|
||||
|
||||
정책이 예외를 던지거나 알 수 없는 결정을 반환하면 `hold`로 취급한다. 자동완료 경로 전체를 `try/except`로 감싸 **어떤 실패도 커밋으로 이어지지 않게** 한다. "판정 못 함"은 절대 "완료"가 아니다.
|
||||
|
||||
### D8. 트리·권한 규칙 승계
|
||||
|
||||
- `lib_py.loop`는 `lib_py.delegation` 외 형제 임포트 금지(Phase 2a G9).
|
||||
- 자동완료의 `source`는 **`auto_complete`** 이며 운영자 권한이 **아니다** — terminal을 되돌릴 수 없다.
|
||||
- `preexec_fn`: 알림 재발행기가 스레드를 쓰면 Phase 1 인계 게이트에 걸린다. **2b는 데몬을 스폰하지 않고** 기존 구독자 프로세스 안에서 flush한다. 스폰이 필요해지면 그때 `preexec_fn`을 교체한다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 작업 분해 (WBS) — 커밋 5개
|
||||
|
||||
### P2B-00 특성화 선행 (커밋 1: `test(issue-5): phase 2b pre-conditions and fail-closed characterization`)
|
||||
- 현행 고정: 매니페스트 없는 job은 `hold`(이미 참), `MAM_AUTO_COMPLETE` 부재 시 자동완료 0건, `attempt_id` 부재 시 `expected_attempt`가 거부, 미지원 백엔드 목록.
|
||||
- **완료 기준**: 레거시에서 전부 통과, 664 + 신규 통과.
|
||||
|
||||
### P2B-01 확정 계약 (커밋 2: `feat(issue-5): report finalization manifest and side-effect evidence`)
|
||||
- **파일**: `lib_py/loop/finalize.py`(매니페스트 작성·검증), `publish_event.py`에 `--finalize-report`/`--side-effect`, `create_session.sh:483`·래퍼 `:202/:319` 지시문 1줄 추가.
|
||||
- **테스트**: tmp→rename 원자성, 해시 재계산 대조(위조 매니페스트 거부), 리포트 재작성 시 무효화, `expected_artifacts` 미충족 시 `hold`, 매니페스트 부재 시 `hold`(불변).
|
||||
|
||||
### P2B-02 CAS 연동 + 지연 알림 (커밋 3: `feat(issue-5): auto-complete gate and durable pending notification`)
|
||||
- **파일**: `lib_py/loop/autocomplete.py`, `lib_py/delegation/notifier.py`, `engine`에 `pending_notification` 기록 및 `notification_settled` 요청.
|
||||
- **테스트**: 게이트 3중 확인, 늦은 conflict 거부(엔진 기존 동작), 크래시 후 재시작 재발행, 중복 발행 멱등, **락 안 발행 0건**(Phase 2a G5 재사용), `attempt_id` 불일치 시 거부.
|
||||
|
||||
### P2B-03 플래그·허용목록 (커밋 4: `feat(issue-5): default-off auto-complete flag with an evidence-backed backend allowlist`)
|
||||
- **파일**: 플래그 3종, `completed_by` 감사 표식.
|
||||
- **테스트**: 기본 OFF에서 자동완료 0건이며 명시 경로 결과가 **바이트 동일**, 허용목록 밖 백엔드는 플래그 ON에서도 미커밋, 잡별 opt-in 미설정 시 미커밋, 자동완료 terminal에 `completed_by` 존재.
|
||||
|
||||
### P2B-04 시나리오·문서 (커밋 5: `docs(issue-5): phase 2b contracts, scenarios and task checklist`)
|
||||
- 경쟁/재시작/부분 리포트/부수효과 미충족/무판정 시나리오를 Phase 0 스키마로 추가(기존 `validate_watcher_scenario` 통과 필수).
|
||||
- `state-transitions.md` §4·§5 구현 매핑, `task.md` P2B-01~03 증거, 운영 노트(`phase2b-autocomplete.md`).
|
||||
|
||||
---
|
||||
|
||||
## 4. 게이트
|
||||
|
||||
| # | 조건 | 측정 |
|
||||
|---|---|---|
|
||||
| G1 | 기존 **664** 무회귀 + 신규 통과, skipped 0 | `pytest tests/ -q` |
|
||||
| G2 | 셸 문법·공백 | `bash -n`, **범위** `git diff --check` |
|
||||
| G3 | Python 3.9 컴파일 | `compileall` |
|
||||
| **G4** | **기본 OFF에서 자동완료 0건** | 플래그 미설정 시 레지스트리 전후 해시 동일, 명시 경로 결과 바이트 동일 |
|
||||
| **G5** | **매니페스트 위조 거부** | 해시 불일치·attempt 불일치·부재 전부 `hold` |
|
||||
| G6 | 락 안 I/O 0건 | Phase 2a G5 감시 테스트 확장(발행 포함) |
|
||||
| G7 | 재시작 복구 | 크래시 시뮬레이션 후 `pending_notification` 재발행 및 멱등 |
|
||||
| G8 | 롤백 | `MAM_AUTO_COMPLETE=0` 즉시 중단, 이미 확정된 terminal은 보존 |
|
||||
| G9 | 허용목록 | 증거 없는 백엔드는 플래그 ON에서도 미커밋 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 롤백
|
||||
|
||||
1. **즉시**: `MAM_AUTO_COMPLETE=0`. 신규 자동완료만 중단되고 **이미 확정된 terminal 레코드는 보존**한다(`implementation_plan.md` Phase 2b 롤백 조건 그대로).
|
||||
2. `MAM_ENGINE_CAS=0`은 Phase 2a 경로까지 함께 되돌린다(자동완료는 CAS 위에 있으므로 자동으로 무력화).
|
||||
3. 커밋 단위 revert는 5→2 순. 커밋 1(특성화)은 남겨도 무해.
|
||||
|
||||
---
|
||||
|
||||
## 6. 리스크
|
||||
|
||||
| # | 리스크 | 완화 |
|
||||
|---|---|---|
|
||||
| R1 | 워처가 스스로 매니페스트를 만들어 자기 증명이 됨 | D1: 작성 주체를 에이전트로 고정, 워처는 해시 재계산 대조만 |
|
||||
| R2 | `attempt_id`/`iteration` 혼동으로 CAS 가드 무력화 | D2: 승격 + 대체 제거. **리뷰어 승인 전 착수 금지** |
|
||||
| R3 | 잘못된 자동완료를 래퍼가 구별 못 함 | D6 `completed_by` 감사 표식 + 기본 OFF + 허용목록 |
|
||||
| R4 | 지연 알림이 새 저장소를 만들어 계약 위반 | D4: 레코드 내 필드, §5 명시 준수 |
|
||||
| R5 | 알림 재발행기가 스레드를 써 `preexec_fn` 게이트에 걸림 | D8: 2b는 스폰 없음 |
|
||||
| R6 | 부수효과 "검증"을 과대 주장 | D5: 존재·대응만 검증한다고 문서에 명시, 원격 확인은 비범위 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 명시적 비범위
|
||||
|
||||
- `run_loop.sh` 파이썬화 — Phase 3.
|
||||
- 부수효과의 **원격** 확인(실제 git push 도달 여부 등) — 증거 대응까지만.
|
||||
- `agy`/`hermes`/`opencode` 자동완료 — 정지 상태 실측 증거 확보 후 별도 판단.
|
||||
- `preexec_fn` 교체 — 스폰이 필요해지는 시점.
|
||||
- 스키마 v2 — `attempt_id` 추가는 v1 유지(추가 필드).
|
||||
|
||||
---
|
||||
|
||||
## 8. Worker 인수인계 체크리스트
|
||||
|
||||
- [ ] **착수 전**: §D2(`attempt_id` 승격 = Phase 1 스키마 계약 변경)에 대한 **리뷰어 승인**을 받을 것
|
||||
- [ ] P2B-00 커밋 1: 현행 fail-closed 특성화가 레거시에서 통과
|
||||
- [ ] P2B-01 커밋 2: 매니페스트 작성/검증, 해시 재계산, 부수효과 대응
|
||||
- [ ] P2B-02 커밋 3: 게이트 연동 + `pending_notification` + 재시작 복구
|
||||
- [ ] P2B-03 커밋 4: 플래그 3종 + 허용목록 + `completed_by`
|
||||
- [ ] P2B-04 커밋 5: 시나리오·문서
|
||||
- [ ] 보고서: G1 원문, G4 해시 비교, G7 복구 로그, `grep -n` 인용
|
||||
- [ ] 커밋 접두: `test/feat/feat/feat/docs(issue-5)`
|
||||
|
||||
---
|
||||
|
||||
## 부록 A. 계획 수립 시 실행한 검증 명령
|
||||
|
||||
```bash
|
||||
git log --oneline -1 # 4a5916a
|
||||
.venv/bin/python -m pytest tests/ --collect-only -q | tail -1 # 664
|
||||
find .mam/jobs -name "*manifest*" # 없음
|
||||
grep -rn "pending_notification\|MAM_AUTO_COMPLETE" --include='*.py' .agents # 0건
|
||||
grep -n 'record.get("attempt_id", record.get("iteration"))' .../engine.py # 대체 확인
|
||||
python -c "... transition(completed, expected_attempt=1|2) ..." # iteration 대체 실증
|
||||
python -c "... herdr/manifest.json observed slots ..." # 백엔드 증거 행렬
|
||||
grep -n "publish_event.py --job" create_session.sh 래퍼 # 지시문 주입 2곳
|
||||
```
|
||||
@@ -0,0 +1,142 @@
|
||||
# 📊 Issue #5 기술 심층 검토 통합 보고서 (Rev. 2)
|
||||
|
||||
- **대상 이슈**: [Gitea Issue #5 — `[Refactor] lib.sh 작업 위임 로직 및 run_loop.sh 오케스트레이터의 Python 모듈화 (lib_py 승격)`](https://git.godopu.com/tmpl/multi-agent-mux/issues/5)
|
||||
- **작성 주체**: Antigravity (총괄 오케스트레이터)
|
||||
- **검토 참여자**: 3개 팀 리더 리뷰어 에이전트 전원 참여 (1차 검토 및 2차 교차 검토 완료)
|
||||
- `planner-reviewer-claude-01`
|
||||
- `reviewer-creator-grok-01`
|
||||
- `planner-reviewer-codex-01`
|
||||
- **검토 일시**: 2026-09-06 (Rev.1 발행 및 2차 정밀 교차 검증 반영)
|
||||
- **기준 브랜치 / 커밋**: `refactor` (`47640e2`)
|
||||
- **종합 합의 상태**: **파이썬 모듈화 방향성 만장일치 지지 (단, 자동완료 증거 계약·CAS 상태 전이·런타임 불변성의 필수 안전 조건 선결을 전제로 한 조건부 동의이며, 기계 판정용 최종 구현 PASS가 아님)**
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요 및 합의 수준의 사실적 기술
|
||||
|
||||
본 보고서는 `lib.sh`의 작업 위임 로직 및 `run_loop.sh` 오케스트레이터 전체를 파이썬 패키지(`lib_py`)로 승격 및 모듈화하는 제안(Issue #5)에 대해, 세 팀 리더 리뷰어가 코드베이스 실측과 2차에 걸친 독립적 교차 분석을 통해 도출한 최종 정제 통합 보고서입니다.
|
||||
|
||||
세 리뷰어 모두 **파이썬 모듈화의 필요성과 아키텍처 방향성에 공통으로 찬성**합니다. 그러나 이 찬성은 "무조건적 구현 승인"이 아니며, 자동완료 오탐 방지, 다중 Writer 경합, 런타임 자기수정 크래시(B-13)의 단계적 해소 조건에 대해 엄격한 기술적 안전 계약이 수립되어야 함을 강력히 요구하고 있습니다.
|
||||
|
||||
### 📌 리뷰어별 평가 및 판정 토큰 현황 (사실 대조)
|
||||
|
||||
| 리뷰어 에이전트 | 세션명 | 1차 검토 Job | 2차 검토 Job | 공식 `[VERDICT: ...]` 토큰 | 종합 평가 성격 |
|
||||
| :--- | :--- | :---: | :---: | :---: | :--- |
|
||||
| **Claude** | `planner-reviewer-claude-01` | `1919bf5e` | `cdc926e3` | **미발행 (산문형 지지)** | 방향성 지지, Herdr 1급 API 실재 발견, 1,621줄 subprocess 테스트 재타겟팅 공수 경고 |
|
||||
| **Grok** | `reviewer-creator-grok-01` | `9885ecd6` | `3e3b6d18` | **`[VERDICT: PASS]`** | 방향성 조건부 PASS, `interactive_ready` 배제 실측 증명, 15s 디바운스 및 6대 게이트 제시 |
|
||||
| **Codex** | `planner-reviewer-codex-01` | `5da43b79` | `14f9eac4` | **미발행 (조건부 동의)** | 방향성 동의, 실행 귀속(Attribution)·리포트 확정 계약·단일 CAS 전이 필수 조건화 |
|
||||
|
||||
> 3명 중 1명(Grok)만 단독 행 `[VERDICT: PASS]` 토큰을 남겼으며, Claude와 Codex는 구체적 안전 조건을 요구하는 산문형 조건부 동의를 제출했습니다. 본 프로젝트의 엄밀한 fail-closed 계약에 따라 이를 "만장일치 최종 PASS"로 왜곡하지 않고 **"안전 계약 선결을 전제로 한 방향성 만장일치 합의"**로 명문화합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 2차 교차 검증을 통해 복원된 핵심 안전 계약
|
||||
|
||||
세 리뷰어의 2차 정밀 피드백을 통해 1차 요약 과정에서 누락되거나 단순화되었던 핵심 엔지니어링 계약들이 복원되었습니다.
|
||||
|
||||
### 2-1. 실행 귀속(Execution Attribution) 보장 계약 (Codex 복원, 전원 지지)
|
||||
- 단순히 터미널이 `idle`이라고 해서 완료로 판단해서는 안 됨 (세션 재사용 시 이전 작업의 잔여 유휴 상태 오인 위험).
|
||||
- 자동완료 후보는 반드시 다음 **4중 식별자**가 일치해야 함:
|
||||
`Job ID` + `Attempt ID` + `Agent/Session UUID` + `Pane/Process Generation`
|
||||
- 디스패치 확인 시점 이후 해당 시도(Attempt)의 실제 실행 및 응답이 관측된 경우에만 완료 후보로 인정.
|
||||
- 동일 에이전트 세션에는 동시에 단 하나의 Job만 배정.
|
||||
|
||||
### 2-2. 리포트 확정 계약의 단계적 진화 (Grok vs Codex 이견 정리)
|
||||
- **1차/보조 필터 (Grok 제안)**: `report-final.md` mtime이 일정 디바운스 구간(기본 권장 ≥15초, monotonic clock 측정) 동안 불변일 것.
|
||||
- **2차/강화 계약 (Codex 제안)**: 시간 경과(15s)만으로는 파일 쓰기 완료를 완벽히 증명할 수 없으므로, 임시 파일 작성 후 **원자적 이름 변경(Atomic Rename)** 및 `Attempt ID`와 `Report Hash`가 명시된 **완료 매니페스트(Finalization Manifest)** 생성을 최종 목표로 설정.
|
||||
- **적용 규칙**: Phase 2a에서는 mtime 디바운스(15s)를 보조 필터로 검증하고, Phase 2b에서는 확정 계약 미지원 작업은 자동 완료 대신 "복구 후보/수동 확인"으로 남김.
|
||||
|
||||
### 2-3. 단일 상태 전이 주체(Engine)와 원자적 CAS (Compare-And-Swap)
|
||||
- **책임의 엄격한 격리**:
|
||||
- `watcher.py`: 오직 관측 증거(증거 튜플: Event, Herdr status, Report path/hash)만 수집하여 큐로 전달.
|
||||
- `engine.py`: 락 하에서 상태 전이를 결정하는 **유일한 주체**.
|
||||
- **원자적 상태 전이 불변식**:
|
||||
- Agent의 `publish_event`와 Engine의 Watcher 감시 모두 **공통 전이 함수/서비스**를 사용.
|
||||
- `expected attempt` 및 `비terminal(started/progress)` 상태임을 확인한 후 원자적으로 전이 (CAS).
|
||||
- 명시적 `error` 또는 `cancelled` 상태를 자동 `completed`로 덮어쓰지 않음.
|
||||
- 완료 확정 후 뒤늦게 도착한 `progress`나 `started` 이벤트로 상태를 역행시키지 않음.
|
||||
- **잠금(Lock) 내 I/O 및 재진입 대기 위험 방지**:
|
||||
- Registry Lock 유지 상태에서 자식 프로세스 호출(subprocess)이나 네트워크 I/O(MQTT)를 실행하지 않음.
|
||||
- 짧은 트랜잭션으로 상태 확정 및 Pending Notification 기록 ➔ Lock 즉시 해제 ➔ 비동기 MQTT 발행 및 멱등 재시도 수행.
|
||||
- `auth_token != null`인 경우 레지스트리의 토큰으로 HMAC 서명하여 정규 발행 (누락 시 silent drop 방지).
|
||||
|
||||
### 2-4. Herdr 네이티브 API의 정확한 호출 규격 및 예외 처리
|
||||
- **옵션 명시 필수**:
|
||||
- `herdr agent wait <target>`의 CLI 기본값은 `idle, done, or blocked`이며, `blocked`는 permission_required나 입력 대기 상태이므로 완료가 아님.
|
||||
- 따라서 Watcher는 반드시 상태를 명시적으로 지정하여 호출해야 함:
|
||||
`herdr agent wait <TARGET> --until idle --until done --timeout <MS>`
|
||||
- **`interactive_ready`의 성격 규정**:
|
||||
- 에이전트가 코딩 작업 중(`agent_status=working`)일 때도 `interactive_ready=true`임이 실측으로 증명됨.
|
||||
- 따라서 `interactive_ready`는 완료 충분조건이 아니며, 오직 `agent_status in {"idle", "done"}`만 완료 후보로 인정.
|
||||
- **소켓 단절 및 버전 드리프트 가드**:
|
||||
- Herdr 소켓 단절 시 반환되는 `unknown`을 idle로 오인하지 않고, 재연결 백오프 및 스냅샷 재동기화를 수행.
|
||||
- 의존하는 Herdr CLI/JSON 필드의 최소 호환 버전을 검증하고, 불일치 시 fail-closed(수동 확인)로 폴백.
|
||||
|
||||
### 2-5. 특수 작업에 대한 예외 및 Fail-Closed 원칙
|
||||
- **외부 부수효과 작업 예외**:
|
||||
- Gitea 댓글 등록, Git push, 배포 등 리포트 파일 생성 외에 외부 시스템에 부수효과를 일으키는 작업은 리포트 파일 존재만으로 자동완료 처리하지 않음 (외부 실행 완료 증거가 없을 경우 수동 확인 유지).
|
||||
- **무판정 Reviewer 리포트 처리**:
|
||||
- Reviewer 작업의 리포트 마지막 줄에 `[VERDICT: PASS]` 또는 `[VERDICT: NOT PASS]` 토큰이 누락된 경우, `completed` 승격 절대 금지.
|
||||
- 작업 deadline 도달 시 명시적 `error` 발행 또는 반려 처리 (Fail-Closed 엄격 유지).
|
||||
- Reviewer의 `completed`(리뷰 행위 완료)와 `PASS`(검증 통과)를 절대 혼동하지 않음.
|
||||
|
||||
---
|
||||
|
||||
## 3. 합의된 안전 상태 머신 아키텍처
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Watcher["이중 감시 체계 (Watcher - 관측 증거 수집 전용)"]
|
||||
W1["채널 1: MQTT 이벤트 스트림 및 로컬 작업 레코드"]
|
||||
W2["채널 2: herdr agent wait --until idle --until done"]
|
||||
W3["파일 증거: 지정 경로 report-final.md mtime 디바운스 (≥15s)"]
|
||||
end
|
||||
|
||||
subgraph Gate["안전 검증 게이트 (Fail-Closed AND Gate)"]
|
||||
G1{"4중 귀속 일치?\n(Job ID + Attempt + UUID + Generation)"}
|
||||
G2{"agent_status in (idle, done)?\n(interactive_ready 단독 불인정)"}
|
||||
G3{"리포트 확정 완료?\n(디바운스 경과 및 매니페스트 확인)"}
|
||||
G4{"Reviewer: 마지막 행 정식 판정 토큰 존재?"}
|
||||
G5{"외부 부수효과 작업 여부 확인\n(부수효과 시 별도 증거 필요)"}
|
||||
end
|
||||
|
||||
subgraph Engine["단일 상태 제어 엔진 (Engine - 배타적 전이)"]
|
||||
E1["공통 상태 전이 트랜잭션 (CAS Lock)"]
|
||||
E2["Terminal State Commit & Pending Notification 기록"]
|
||||
E3["Lock 해제"]
|
||||
E4["정규 publish_event 경로로 이벤트 발행 (HMAC 서명)"]
|
||||
E5["다음 루프 단계 디스패치"]
|
||||
end
|
||||
|
||||
W1 & W2 & W3 --> Gate
|
||||
G1 & G2 & G3 & G4 & G5 -->|모두 충족 (AND)| Engine
|
||||
Gate -->|하나라도 미충족| Wait["계속 대기 (Wait / Fail-Closed)"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 4단계 마이그레이션 로드맵 (세부 기준 보완)
|
||||
|
||||
| 단계 | 작업 목표 | 상세 구현 내용 및 필수 완료 기준 (Gate) |
|
||||
| :--- | :--- | :--- |
|
||||
| **Phase 0** | **사전 계약 고정 및 Fixture 수집** | • CLI, 상태 이벤트, 리포트 형식, 락 계약 기준선 문서화<br>• Herdr 6개 어댑터별 런타임 상태 전이 fixture 수집<br>• 미합의 기술 항목(디바운스 vs 매니페스트)의 단계별 채택 기준 확정 |
|
||||
| **Phase 1** | **`lib_py/delegation/` 구축** | • `lib.sh` L1833 위임 헬퍼를 파이썬 서비스 클래스로 추출<br>• `start_watchdog`은 실제 역할에 맞춰 `ensure_subscriber_daemon`으로 명명<br>• 인메모리 단일 MQTT 연결 재사용 프로토타입 (레이턴시는 벤치마크로 보고)<br>• 기존 CLI/non-fatal/HMAC/seq/디스크 폴백 회귀 100% 통과<br>• **자동완료 정책 변경 없음, 세션 DB와 Job 레지스트리 저장소 통합 시도 금지** |
|
||||
| **Phase 2a** | **`lib_py/loop/` 코어 & Shadow Watcher** | • `engine.py` (단일 전이 주체), `watcher.py` (`herdr agent wait` 래핑) 구현<br>• 자동완료는 수행하지 않고 "Would-Complete" 로깅만 수행하는 Shadow 모드 가동<br>• 소켓 단절/재연결 백오프, 부분 리포트, 승인 대기, 도구 실행 시뮬레이션 검증 |
|
||||
| **Phase 2b** | **이중 감지 자동완료 활성화 (Flag-Gated)** | • 4중 실행 귀속 + 리포트 확정 계약 + 단일 CAS 전이 게이트 검증<br>• 부수효과 작업 예외 규정 및 무판정 fail-closed 단위 테스트 완료<br>• 기본 off 플래그로 배포 후 검증 완료 시 기본값 활성화 |
|
||||
| **Phase 3** | **`run_loop.sh` Thin Wrapper 전환 및 회귀** | • `run_loop.sh`를 인자 전달 래퍼(`exec python3 -m lib_py.loop "$@"`)로 전환<br>• `--creator` 필수, `--target-agent` 거부, bash 3.2, PYTHONPATH 계약 보존<br>• 5개 subprocess 전용 테스트(~1,621줄) 포함 **현행 전체 469개 테스트 0-Fail**<br>• **신·구 엔진의 동일 작업공간 동시 Writer 병행 금지** |
|
||||
| **Phase 4** | **불변 런타임 검증 및 B-13 정리** | • 기동 시 전체 모듈 상주(Eager Import) 및 자식 인터프리터/템플릿 불변성 검증<br>• 작업 도중 스킬 파일 교체 및 늦은 경로(재심/승격/롤백) 스트레스 테스트 통과<br>• `/tmp/mam-loop-freeze.XXXXXX` 셸 임시 우회책 완전 제거 및 문서화 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 명시적 비범위(Out of Scope) 및 호환성 계약
|
||||
|
||||
1. **저장소 통합 금지**: 본 리팩토링에서 TMUX 세션 관리용 SQLite/YAML 저장소와 비동기 작업 관리용 `.mam/jobs/` JSON 레지스트리를 하나로 통합하려 시도하지 않습니다 (목적과 수명이 다름).
|
||||
2. **원샷 스킬의 Live Tree 사용 유지**: `multi-agent-mux-create`, `resume`, `stop` 등 단발성 셸 스크립트는 Freeze 복사본이 아닌 실제 작업 트리의 코드를 실행하도록 유지합니다.
|
||||
3. **타임아웃의 3원 분리 보존**: (a) 전체 벽시계 타임아웃, (b) MQTT 가입자 idle-timeout, (c) TUI idle 자동완료를 독립적으로 유지하며, 자동완료가 기존 타임아웃 메커니즘을 덮어쓰지 않습니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 결론
|
||||
|
||||
2차 교차 검토를 통해 본 통합 보고서는 세 리뷰어의 실질적 발견과 기술적 차이를 충실히 수렴하여 완성도를 대폭 끌어올렸습니다.
|
||||
모든 리뷰어가 지적한 대로 **Phase 0(사전 계약 및 어댑터 fixture 정리)** 을 즉시 수립한 후, **Phase 1(`lib_py/delegation/` 구축 및 안전한 서비스 추출)** 에 착수하는 것이 가장 안전하고 확실한 진행 경로입니다.
|
||||
@@ -0,0 +1,463 @@
|
||||
# 📑 Issue #5 Phase 1 구현 계획서 (Rev.2) — `lib_py/delegation/` 추출 및 장수 MQTT 연결 프로토타입
|
||||
|
||||
- **Job ID**: `9aae4233` (Rev.1: `923e9403`) / **Planner**: `planner-reviewer-claude-01`
|
||||
- **반영한 이의제기**: `creator-claude-01`의 Challenge (Job `c0b9c987`) — D2 shim 부트스트랩의 컨텍스트 충돌
|
||||
- **Issue**: [Gitea Issue #5](https://git.godopu.com/tmpl/multi-agent-mux/issues/5) — Phase 1 (`implementation_plan.md` Rev.4 §2 Phase 1 행, `task.md` P1-01~P1-03)
|
||||
- **기준 커밋**: `fc0bd96` (branch `refactor`, working tree clean) / Python `3.9.6` / paho-mqtt `2.1.0` / herdr `0.8.2` / 전체 테스트 수집 **510**
|
||||
- **문서 성격**: Worker가 그대로 실행할 수 있는 단계별 구현 명세. 모든 인용 행 번호는 라이브 코드로 직접 확인함(§1). Rev.1 대비 변경은 §0-1에 모아 두었고, 본문에는 이미 반영되어 있다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 요약 (TL;DR)
|
||||
|
||||
1. **추출 대상은 `lib.sh` L1833–1941의 위임 블록 5개 함수뿐이다.** L1943 이후(`wait_for_tui_ready`, `send_keys_safe` 등)는 TUI 조작 헬퍼이며 Phase 1 비범위다.
|
||||
2. **`scripts/{mqtt_common,registry,publish_event,job_subscriber}.py`의 파일 경로는 와이어 계약이다.** 에이전트에게 주입되는 지시문(`create_session.sh:483`, 래퍼 `:197/:318`), SKILL.md, BOOTSTRAP*.md, `deploy/install.sh:103`, 테스트 3종이 리터럴 경로로 호출한다. 구현체는 `lib_py/delegation/`으로 옮기되 `scripts/*.py`는 **동일 모듈 객체를 가리키는 호환 shim**으로 남긴다.
|
||||
3. **shim은 `sys.path`를 건드리지 않고 `lib_py.delegation` 패키지만 자기 옆 트리에 "프로세스당 1회" 바인딩한다(Rev.2, D2).** 이의제기가 실증한 대로 `sys.path.insert(0, ...)` 방식은 pytest에서 위임과 무관한 `lib_py.*`까지 임시 사본으로 가로챈다. 이의제기가 제안한 "잎(leaf) 모듈 파일 직접 적재"도 재현 결과 비-잎 모듈(`publish_event`)에서 두 트리가 섞이므로 채택하지 않고, **패키지 단위 bind-once**로 확정했다(§1-8 근거).
|
||||
4. **현재 셸 헬퍼 3종을 직접 검증하는 테스트는 0개다.** 이동 전에 특성화(golden) 테스트를 먼저 추가한다(P1-00).
|
||||
5. **장수 연결 프로토타입은 라이브러리 전용이다.** CLI는 프로세스 단위로 종료되므로 재사용 이득이 없다. 벤치마크는 결정적 가짜 전송 + 실브로커(정보성) 2모드.
|
||||
6. **자동완료 정책은 3개 표면에서 한 줄도 바뀌지 않는다.** 테스트로 고정한다.
|
||||
|
||||
### 0-1. Rev.1 → Rev.2 변경 내역 (이의제기 반영)
|
||||
|
||||
| 이의제기 항목 | Planner 독립 검증 결과 | 계획 변경 |
|
||||
|---|---|---|
|
||||
| §1 D2 `sys.path.insert(0)`이 pytest에서 `lib_py` 전체를 샌드박스로 고정 | **재현 확인** (§1-8 표, 변형 `d2_insert0` 시나리오 A: `lib_py.agents`가 sandbox에서 로드) | D2 전면 교체 |
|
||||
| §2-1 정체성 단정(`is`)이 "같은 트리"를 증명하지 못함 | **동의**. 시나리오 A에서 두 참조가 모두 sandbox인데 단정은 `True` | D2-2 출처(provenance) 단정 PV-1~PV-4 추가, G4 재정의 |
|
||||
| §2-2 R3 전제("실제 트리에서만 로드")가 D2와 모순 | **동의** | R3을 "수용"에서 "해소"로 재작성 |
|
||||
| §3 `append`로 바꾸면 freeze가 깨짐 | **동의**. 본 세션 `PYTHONPATH`=라이브 트리, 실행 명령=freeze 경로, `run_loop.sh`에 `PYTHONPATH` 처리 0건 모두 재확인 | D2-1 컨텍스트 우선순위 표 신설 |
|
||||
| §5-1 "옆 파일 직접 적재(`spec_from_file_location`) + 다르면 교체" | **부분 채택**. 잎 모듈 단독 임포트에서는 동작하나, `publish_event`만 임포트되는 실제 에이전트 호출 형태(C2_pe)에서 `publish_event`=freeze, `mqtt_common`=live로 **혼재**하고 정체성 단정이 `False`가 됨. 진짜 standalone(C1)에서는 부모 패키지 부재로 `ModuleNotFoundError`. 두 샌드박스가 섞이는 시나리오(D)에서도 혼재 | 적재 단위를 **모듈 → 패키지**로 올리고, "가장 가까운 사본으로 교체"가 아니라 **"프로세스당 최초 바인딩 유지(bind-once)"** 로 규칙 변경. standalone은 `lib_py` 부재 시에만 `sys.path.append` 폴백 |
|
||||
| §5-2 출처 단정 병기 | 채택 | PV-1~PV-4 |
|
||||
| §5-3 컨텍스트 4종 우선순위 표 + 4종 전부 테스트 | 채택 (6 컨텍스트로 확장) | D2-1, P1-01 테스트 |
|
||||
| §5-4 세션 위생 테스트 | **부분 채택**. "tmp 경로가 `sys.path`에 없음"은 현행 `test_tier1_unit.py:23`이 샌드박스 scripts 디렉터리를 영구 삽입하므로 이미 실패함. 조건을 "`lib_py` 디렉터리를 포함하는 tmp 경로가 없음"으로 정밀화 | PV-3 |
|
||||
| §6 D6·설치 경로 안전 확인 | **동의**. `mqtt_common.py`의 `os.environ` 참조는 전부 함수 내부(`:139,:196,:203,:225-233`), 모듈 상수(`:117-128`) env 무관, 나머지 3개 스크립트 `os.environ` 0건, `install.sh:127/131/135` 트리 통째 복사, `install_mam.sh:126` `.agents/` rsync 재확인 | 변경 없음 (P1-04의 `core_files` 추가는 무결성 목록 갱신 목적임을 명시) |
|
||||
|
||||
---
|
||||
|
||||
## 1. Planner 사전 실측 (라이브 검증 결과)
|
||||
|
||||
### 1-1. 추출 범위 경계 (`lib.sh`, 총 2312행)
|
||||
|
||||
| 행 | 함수 | 분류 | 비고 |
|
||||
|---|---|---|---|
|
||||
| 1833 | 섹션 주석 `multi-agent-mux-delegate-job integration helpers` | 경계 시작 | |
|
||||
| 1841 | `_delegate_py_bin` | **셸 유지** | 파이썬 인터프리터 자체를 찾는 부트스트랩. L1598(`env_python`), L1660(`atomic_dump_yaml`), `reconcile.sh:67`, `create_session.sh:191/193/215/482`, `stop_session.sh:199`, `resume_session.sh:116/126`에서도 사용. `tests/test_c1_tui_readiness.py:168,513`이 mock으로 덮어씀 |
|
||||
| 1865 | `_delegate_script` | **셸 유지** | 스크립트 경로 해석. shim이 남으므로 그대로 동작 |
|
||||
| 1876 | `delegate_submit_job` | 추출 | `registry.py register` 호출, stdout에 JID만 출력 |
|
||||
| 1897 | `delegate_publish_event` | 추출 | 비치명 계약: 빈 job_id/스크립트 부재/브로커 실패 모두 `return 0` |
|
||||
| 1912 | `start_watchdog` | 추출 → `ensure_subscriber_daemon` | `pgrep -f "bash <monitor_script> --subscribe"` 선검사 후 `nohup bash reconcile.sh --subscribe --idle-timeout 0` 스폰, PID를 stdout 출력. 스크립트 부재 시 stderr `ERROR: monitor script not found: ...` + `return 1` |
|
||||
| 1943~ | `wait_for_tui_ready`, `inject_instructions`, `_pane_*`, `send_keys_safe`, `handle_startup_dialogs` | **비범위** | TUI 조작 |
|
||||
|
||||
`lib_py`는 `lib.sh:25`(`SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"` 직후 `export PYTHONPATH="$SKILL_DIR:..."`) 및 `:164`로 임포트 가능해진다. 즉 **어느 트리의 `lib.sh`를 source 했느냐가 그 셸 프로세스의 `lib_py` 트리를 결정**한다. `env_python`(L1583)은 `PYTHONPATH=$SKILL_DIR`를 명시 전달하는 선례.
|
||||
|
||||
### 1-2. 셸 헬퍼 호출자 (라이브 트리)
|
||||
|
||||
| 호출자 | 헬퍼 | 계약상 의존 |
|
||||
|---|---|---|
|
||||
| `create_session.sh:345` | `delegate_submit_job` | `$( )`로 stdout 캡처 → JID |
|
||||
| `create_session.sh:497,501` | `delegate_publish_event error/started` | 비치명 |
|
||||
| `create_session.sh:504-505` | `start_watchdog "$JOB" "$WORKSPACE"` | `$( )`로 PID 캡처 후 `echo`만 함(파싱 없음) |
|
||||
| `stop_session.sh:193,242,322` | `delegate_publish_event progress/cancelled` | `implementation_plan.md` §3-2가 보존 요구 |
|
||||
| `update_yaml_resumed.sh:257` | `delegate_publish_event progress "resumed"` | 비치명 |
|
||||
| `run_loop.sh:144-155` | (헬퍼 미사용) 래퍼 `multi-agent-mux-delegate-job`를 직접 실행 | Phase 1 무관 |
|
||||
|
||||
### 1-3. 파이썬 표면과 리터럴 경로 계약
|
||||
|
||||
- 스크립트 4종: `mqtt_common.py`(670행), `registry.py`(426행), `publish_event.py`(239행), `job_subscriber.py`(339행). 서로를 `import mqtt_common` / `import registry`처럼 **최상위 이름**으로 임포트한다. **다른 `lib_py` 모듈은 하나도 임포트하지 않는다**(`grep -n lib_py scripts/*.py` → 0건; D2의 전제).
|
||||
- 리터럴 경로 호출자: 래퍼 12개소(`:136,166,197,277,287,318,355,394,400,544,550,571,575,583,586`), `create_session.sh:483`, `SKILL.md:78`, `BOOTSTRAP.md`/`BOOTSTRAP.ko.md:158-180`, `README.md:211`/`README.ko.md:194`, `MESSAGING.md:399`, `deploy/install.sh:103`, `reconcile.sh:98-113`(임베디드 구독자가 scripts 디렉터리를 `sys.path`에 **append** 후 `import mqtt_common, registry`).
|
||||
- 테스트 임포트 방식: `tests/test_tier1_unit.py:23,571-820`과 `tests/test_tier2_component.py:25`는 샌드박스 scripts 디렉터리를 `sys.path`에 넣고 `import registry, publish_event, job_subscriber, mqtt_common`; `tests/test_tier4_e2e.py:414`는 `publish_event.py`를 서브프로세스로 실행. `tests/conftest.py:10-12`는 **실제** skills 디렉터리를 `sys.path[0]`에 넣고, `mam_sandbox`는 `.agents/skills` 전체를 `tmp_path/skills`와 `tmp_path/.agents/skills`로 복사한다(이동 후 사본에도 `lib_py/delegation/`이 포함됨).
|
||||
- 본 job과 Rev.1 job의 위임 메시지 자체가 `.../mam-loop-freeze.PfAQNj/.agents/skills/multi-agent-mux-delegate-job/scripts/publish_event.py` 경로를 사용한다. 이때 에이전트 세션의 `PYTHONPATH`는 **라이브 트리**다(실측: `/Users/.../multi-agent-mux/.agents/skills:...`). `run_loop.sh`에는 `PYTHONPATH` 처리가 없고(`grep` 0건) `:14-17`에서 자기 위치 기준 `REPO_ROOT`의 `lib.sh`를 source 하므로 **루프 프로세스 자체는 freeze `lib.sh`가 export한 freeze `PYTHONPATH`** 를 갖는다.
|
||||
|
||||
### 1-4. 기존 테스트 커버리지 실측
|
||||
|
||||
| 대상 | 커버 테스트 | 비고 |
|
||||
|---|---|---|
|
||||
| `publish_event` 실패 시 status/감사로그/seq 소비 | `test_tier1_unit.py` G-1~G-4 (:568-698) | 127.0.0.1:65432 비라우팅 포트, 네트워크 미접촉 |
|
||||
| `job_subscriber` 디스크 폴백/exit 0·1·3 | G-5~G-9 (:700-836) | |
|
||||
| 래퍼 rc=3 매핑 리터럴 | G-10 (:838) | **래퍼 텍스트를 건드리면 깨짐** |
|
||||
| HMAC/retry/topic/reason_code | `test_mqtt_*` (:339-420) | |
|
||||
| seq 단조 증가, 모니터 중복 잠금 | `test_tier2_component.py:1082, :1007` | |
|
||||
| `delegate_submit_job` / `delegate_publish_event` / `start_watchdog` | **없음 (0건)** | |
|
||||
|
||||
빠른 회귀 부분집합(G-1~G-10 + mqtt + seq + monitor lock): **18 passed in 1.74s**. 이 부분집합의 파일들은 `lib_py`를 모듈 레벨에서 임포트하지 않으므로(`test_tier1_unit.py`의 `lib_py` 사용은 `:848,:873,:902,:947,:978,:1037,:1072` 전부 함수 내부) 이의제기 §4의 지적대로 "샌드박스 shim이 먼저 임포트되는" 시나리오 A에 해당한다 — Rev.2 D2는 이 경우에도 `lib_py.agents` 등을 가로채지 않는다(§1-8).
|
||||
|
||||
### 1-5. 자동완료 정책이 구현된 3개 표면 (Phase 1 불변)
|
||||
|
||||
1. `publish_event.py:51-56` `EVENT_TO_STATUS` 및 `:227`의 `update_job_status` 동기화.
|
||||
2. 래퍼 `:335-348` 구독자 exit code → `job_status` 매핑 및 `:394/:400`의 `registry.py status --set completed|error`.
|
||||
3. `reconcile.sh:125-168` `_MUTATION` + `handle_terminal`.
|
||||
|
||||
`run_loop.sh:238-276` `wait_for_job`은 `.mam/jobs/<id>.json`의 `status`를 3초 간격 폴링할 뿐이므로 영향 없음.
|
||||
|
||||
### 1-6. 장수 연결의 실측 동기
|
||||
|
||||
Rev.1 job의 `started` 이벤트: payload `timestamp` 05:58:49Z → `attempt 1/3 failed: no CONNACK from broker` (05:59:00Z) → 모니터 수신 05:59:01.487Z. 1회 발행 약 12.5초, 원인은 `CONNECT_ACK_TIMEOUT=10`(`publish_event.py:58`)의 1회 실패 + 재연결. Rev.1 `completed` 발행과 본 job의 `started` 발행에서도 동일한 1회 CONNACK 실패가 재현됐다.
|
||||
|
||||
### 1-7. 환경 제약
|
||||
|
||||
- 로컬 브로커 없음(`mosquitto`, `nats-server`, `docker` 미설치). 벤치마크 실브로커 모드는 정보성.
|
||||
- `mqtt_common.py:112`에서 `_load_dotenv()`가 **임포트 시점**에 실행되어 `.mam.env`(존재, 0600)를 주입. 루트 탐색은 `__file__` 기준 상향(`.agents` 또는 `.git`).
|
||||
- B-13 freeze(`run_loop.sh:112-126`)는 `.agents/skills` 전체를 `cp -R` 하므로 `lib_py/delegation/`은 자동 포함(현재 freeze에 `lib_py/` 존재 확인).
|
||||
- `baseline.md` §5-2 `VALID_STATUSES` 인용은 커밋 `19d798b`에서 `registry.py:38`로 정정 완료(`baseline.md:93`).
|
||||
|
||||
### 1-8. 이의제기 재현 및 대안 비교 (Rev.2 신규)
|
||||
|
||||
세 shim 변형을 `real`/`sandbox`/`sandbox2`/`freeze` 네 트리(각 `lib_py/{__init__,agents/__init__,delegation/{__init__,mqtt_common,publish_event}}.py` + `scripts/` shim)에 배치하고, `env -u PYTHONPATH`의 새 인터프리터에서 시나리오별로 실행했다. `publish_event`는 `from lib_py.delegation import mqtt_common`을 포함해 **비-잎 모듈**을 모델링한다. 열: 어느 트리에서 로드됐는가.
|
||||
|
||||
| 시나리오 (첫 임포트 → 이후) | `d2_insert0` (Rev.1) | `worker_leaf_file` (이의제기 §5-1) | `pkg_bind_once` (**Rev.2 채택**) |
|
||||
|---|---|---|---|
|
||||
| A pytest: real on path → sandbox shim(mc, pe) | mc=sb, pe=sb, **agents=sb** ✗ | mc=sb, pe=sb, agents=real | mc=sb, pe=sb, agents=real ✓ |
|
||||
| A_pe pytest: sandbox shim(**pe만**) | agents=sb ✗ | **pe=sb, pe.dep=real, identity=False** ✗ | pe=sb, pe.dep=sb, identity=True ✓ |
|
||||
| B pytest: real `lib_py.agents` 먼저 → sandbox shim | 전부 real | mc=sb, pe=sb, agents=real | mc=sb, pe=sb, agents=real ✓ |
|
||||
| B2 pytest: real `lib_py.delegation` 먼저 → sandbox shim | (미측정) | **alias=sb, pkg=real, identity=False** ✗ | 전부 real, identity=True ✓ |
|
||||
| C1 standalone(`PYTHONPATH` 없음): freeze shim | 전부 freeze | **`ModuleNotFoundError: lib_py`** ✗ (비-잎 모듈의 내부 임포트가 부모 패키지를 요구) | 전부 freeze ✓ (폴백 append) |
|
||||
| C2 freeze shim + `PYTHONPATH`=live (mc, pe) | 전부 freeze | 전부 freeze, agents=real | 전부 freeze, agents=real ✓ |
|
||||
| C2_pe freeze shim(**pe만**) + `PYTHONPATH`=live — **실제 에이전트 호출 형태** | 전부 freeze | **pe=freeze, pe.dep=real, identity=False** ✗ (B-13 부분 상실) | pe=freeze, pe.dep=freeze ✓ |
|
||||
| D 두 샌드박스: sb shim(mc) → sb2 shim(pe) | agents=sb ✗ | **pe=sb2, pe.dep=sb** 혼재 ✗ | 전부 sb (최초 바인딩 유지) ✓ |
|
||||
| E sandbox shim(mc) → 이후 `from lib_py.delegation import publish_event` 직접 임포트 | (해당 없음) | 직접 임포트=real, shim=sb 혼재 | 둘 다 sb ✓ |
|
||||
|
||||
결론: 이의제기의 **결함 지적은 정확**하고, 제안한 적재 방식은 "잎 모듈을 먼저 임포트하는" 경우에만 성립한다. 결정 기준은 (i) `lib_py.delegation.*`가 한 프로세스 안에서 항상 **단일 트리**일 것, (ii) `lib_py`의 다른 서브패키지는 shim이 **절대 건드리지 않을 것**, (iii) `sys.path` 무변경(standalone 폴백 제외) — 이를 모두 만족하는 것은 패키지 단위 bind-once뿐이다. 재현 하니스는 `tests/fixtures/issue_5/phase1/shim_contexts.py`로 그대로 옮겨 P1-01 테스트에 사용한다(부록 B).
|
||||
|
||||
---
|
||||
|
||||
## 2. 설계 결정 (Design Decisions)
|
||||
|
||||
### D1. 패키지 배치 — 기존 이름 보존, 신규 추가
|
||||
|
||||
```
|
||||
.agents/skills/lib_py/delegation/
|
||||
__init__.py # 공개 API 재수출: DelegationService, ensure_subscriber_daemon, PersistentPublisher
|
||||
mqtt_common.py # git mv (scripts/mqtt_common.py) — 내용 변경은 D6·D8 항목만
|
||||
registry.py # git mv (scripts/registry.py)
|
||||
publish_event.py # git mv (scripts/publish_event.py) — D7의 함수 분리만
|
||||
job_subscriber.py # git mv (scripts/job_subscriber.py)
|
||||
service.py # DelegationService 파사드 (신규)
|
||||
daemon.py # ensure_subscriber_daemon (신규, start_watchdog 이식)
|
||||
publisher.py # OneShotPublisher / PersistentPublisher (신규)
|
||||
cli.py # python -m lib_py.delegation.cli {submit|publish|ensure-daemon} (신규)
|
||||
__main__.py # cli.main 위임 (신규)
|
||||
bench.py # 벤치마크 하니스 (신규)
|
||||
```
|
||||
|
||||
- `git mv`로 이동해 이력을 보존한다. 이동한 4개 모듈 내부의 `import mqtt_common` / `import registry` / `from mqtt_common import ...`는 전부 `from lib_py.delegation import mqtt_common, registry` 형태로 바꾼다.
|
||||
- **패키지 내부 임포트 규칙(테스트로 grep 고정)**: (a) 최상위 이름(`import registry` 등) 금지 — `lib_py.agents.registry`와의 혼동 차단; (b) **`lib_py.delegation` 밖의 어떤 `lib_py` 모듈도 임포트 금지**(`paths`, `state`, `atomic_yaml`, `agents`, `layout`, `workspace_uuid`) — D2에서 부모 `lib_py`와 `lib_py.delegation`이 서로 다른 트리일 수 있으므로(C2), 형제 모듈을 임포트하면 트리가 섞인다. 현재 스크립트는 이미 이 규칙을 만족한다(§1-3).
|
||||
- Python 3.9 호환 필수: `match`, PEP 604 런타임 유니온, `zip(strict=)` 금지. 게이트에서 `compileall`.
|
||||
|
||||
### D2. `scripts/*.py` shim — `lib_py.delegation` 패키지를 자기 옆 트리에 프로세스당 1회 바인딩 (Rev.2)
|
||||
|
||||
4개 shim은 아래 블록을 **바이트 동일하게** 포함한다(`{mod}`만 다름). 공유 헬퍼 파일로 빼지 않는 이유: `import _bootstrap` 같은 최상위 이름이 첫 샌드박스에 캐시되어 다른 트리의 shim이 남의 헬퍼를 쓰게 되기 때문이다. 동일성은 테스트로 고정한다.
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""Compatibility shim — implementation lives in lib_py.delegation.{mod}.
|
||||
Binds the lib_py.delegation *package* to the tree next to this file, once per
|
||||
process, without touching sys.path. See docs/contracts/issue-5/... (D2)."""
|
||||
import importlib, importlib.util, os, sys
|
||||
_SKILLS = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
_PKG = "lib_py.delegation"
|
||||
if _PKG not in sys.modules: # bind once per process; never rebind
|
||||
try:
|
||||
import lib_py # parent resolved normally; shims never rebind it
|
||||
except ModuleNotFoundError:
|
||||
sys.path.append(_SKILLS) # standalone only: no lib_py exists anywhere to shadow
|
||||
import lib_py
|
||||
_d = os.path.join(_SKILLS, "lib_py", "delegation")
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
_PKG, os.path.join(_d, "__init__.py"), submodule_search_locations=[_d])
|
||||
_pkg = importlib.util.module_from_spec(_spec)
|
||||
sys.modules[_PKG] = _pkg # register BEFORE exec so __init__'s own imports resolve here
|
||||
_spec.loader.exec_module(_pkg)
|
||||
lib_py.delegation = _pkg
|
||||
_impl = importlib.import_module(_PKG + ".{mod}") # resolved through the bound package's __path__
|
||||
if __name__ != "__main__":
|
||||
sys.modules[__name__] = _impl # `import {mod}` is lib_py.delegation.{mod}
|
||||
else:
|
||||
sys.exit(_impl.main())
|
||||
```
|
||||
|
||||
규칙(문서화·테스트 대상):
|
||||
1. **바인딩 단위는 패키지**다. `lib_py.delegation`이 `sys.modules`에 없을 때만 shim 옆 트리의 `lib_py/delegation/`을 패키지로 등록하고, 이후 모든 `lib_py.delegation.*` 임포트는 그 `__path__` 안에서 해석된다. 잎/비-잎 구분 없이 단일 트리가 보장된다.
|
||||
2. **bind-once, never rebind.** 이미 바인딩돼 있으면(다른 트리라도) 그대로 쓴다. "가장 가까운 사본으로 교체"는 기존 참조(`sys.modules['mqtt_common']` 별칭 등)와 새 객체를 갈라놓아 정체성·monkeypatch를 깨뜨린다(§1-8 B2·D). 네 컨텍스트 중 한 프로세스가 트리를 바꿔야 하는 경우는 없다.
|
||||
3. **부모 `lib_py`는 절대 재바인딩하지 않고, `sys.path`도 건드리지 않는다.** 유일한 예외는 `lib_py`가 어디에도 없는 standalone(C1)이며, 이때만 자기 트리를 `append` 한다(가릴 대상이 없으므로 안전).
|
||||
4. 결과적으로 pytest에서는 위임 패키지만 "최초 shim의 샌드박스 사본(또는 먼저 직접 임포트된 실제 트리)"에 바인딩되고 `lib_py.agents`/`layout`/`state` 등은 항상 실제 트리다. 샌드박스 사본은 `copytree`된 동일 내용이므로 검증 대상 코드는 같다(PV-1이 이를 단정).
|
||||
5. `mqtt_common.py`의 PEP 562 `__getattr__("LOGS_DIR")`은 실제 모듈에 남아 별칭을 통해 노출. 실행 비트 유지, `--help` 출력 동일.
|
||||
6. `_load_dotenv`(D6 이후 `ensure_env_loaded`)의 루트 탐색은 실모듈 `__file__` 기준이므로 바인딩된 트리의 루트(저장소 루트 또는 freeze 루트)가 된다 — 이동 전과 동일.
|
||||
|
||||
### D2-1. 실행 컨텍스트별 "어느 트리가 이겨야 하는가" (Rev.2)
|
||||
|
||||
| # | 컨텍스트 | `lib_py.delegation` 해석 주체 | 이겨야 하는 트리 | Rev.2 결과 | 테스트 |
|
||||
|---|---|---|---|---|---|
|
||||
| K1 | 루프 프로세스(`run_loop.sh`가 freeze `lib.sh` source → `PYTHONPATH`=freeze) 및 그 자식(래퍼, `-m lib_py.delegation.cli`) | `PYTHONPATH` | freeze | freeze | K1: `PYTHONPATH=<copy>` + `-m lib_py.delegation.cli --help` |
|
||||
| K2 | 에이전트 세션(`PYTHONPATH`=live)이 위임 메시지의 **freeze 경로 스크립트**를 실행 | shim 옆 트리 | freeze (B-13) | freeze, `lib_py` 부모는 live(무해: D1(b)) | C2_pe |
|
||||
| K3 | 라이브 헬퍼(create/stop/resume/reconcile; live `lib.sh` source) | `PYTHONPATH`=live 또는 shim 옆 트리(같음) | live | live | K3: 실제 트리에서 shim 실행 |
|
||||
| K4 | standalone(`PYTHONPATH` 없음; BOOTSTRAP.md의 수동 명령, 외부 셸) | shim 폴백 append | 자기 트리 | 자기 트리 | C1, C1_pe |
|
||||
| K5 | pytest 인프로세스(conftest가 실제 트리 삽입, 샌드박스 shim 임포트) | 최초 shim 또는 최초 직접 임포트 | 위임: 최초 바인딩(사본=동일 내용) / **나머지 `lib_py.*`: 실제 트리** | A, A_pe, B, B2, D, E | PV-1~PV-3 |
|
||||
| K6 | pytest 서브프로세스(e2e `:414`, freeze 시뮬레이션) — 새 인터프리터, `PYTHONPATH`=live 상속 가능 | shim 옆 트리 | 샌드박스 | 샌드박스 | K6: 서브프로세스 JSON 출력 단정 |
|
||||
|
||||
### D2-2. 가드 — 정체성만으로는 불충분, 출처(provenance)를 병기 (Rev.2)
|
||||
|
||||
- **PV-1 (인프로세스, 각 shim 임포트 직후)**: `mqtt_common is lib_py.delegation.mqtt_common` **그리고** `Path(lib_py.delegation.__path__[0]).resolve()`가 `{실제 skills 디렉터리, pytest basetemp 하위}` 중 하나이며, 4개 별칭(`mqtt_common/registry/publish_event/job_subscriber`)의 `__file__`이 **모두 같은** `__path__[0]` 아래.
|
||||
- **PV-2 (세션 종료 시, conftest의 session-scope autouse finalizer)**: `lib_py.__file__`, 그리고 `sys.modules`에 있는 모든 `lib_py.<x>` 중 `x != "delegation"`의 `__file__`이 실제 skills 디렉터리 아래. 위반 시 어느 모듈이 어느 tmp 경로에서 왔는지 출력.
|
||||
- **PV-3 (세션 종료 시 위생)**: `sys.path` 항목 중 basetemp 하위인 것 가운데 `<entry>/lib_py`가 디렉터리인 항목 0개. (scripts 디렉터리 삽입은 `lib_py`를 담지 않으므로 허용 — 현행 `test_tier1_unit.py:23`과 호환.)
|
||||
- **PV-4 (서브프로세스 매트릭스)**: §1-8의 시나리오 A, A_pe, B, B2, C1, C1_pe, C2, C2_pe, D, E를 실제 이동된 트리의 사본으로 재실행하고 JSON으로 트리명을 받아 단정. K1·K3·K6도 같은 하니스에 포함.
|
||||
- **PV-5**: 4개 shim의 부트스트랩 블록이 `{mod}` 치환을 제외하고 바이트 동일.
|
||||
|
||||
### D3. 셸에 남는 것과 파이썬으로 가는 것
|
||||
|
||||
- `_delegate_py_bin`, `_delegate_script`: **셸 유지**. 시그니처·출력 불변.
|
||||
- `delegate_submit_job`, `delegate_publish_event`, `ensure_subscriber_daemon`(신규 이름) + `start_watchdog`(별칭): 본체를 `python -m lib_py.delegation.cli ...` 호출로 교체. `PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}"`를 명시 전달(`env_python` L1583과 동일). `-m` 경로는 shim을 거치지 않으므로 D2-1의 K1/K3처럼 `SKILL_DIR` 트리가 결정한다.
|
||||
- 롤백 스위치 `MAM_DELEGATION_PY`(기본 `1`): `0`이면 기존 인라인 본체(`_*_legacy`)로 분기. 특성화 테스트를 두 경로에 대해 parametrize. legacy 본체 제거는 Phase 4.
|
||||
|
||||
```bash
|
||||
start_watchdog() { ensure_subscriber_daemon "$@"; } # 기존 함수 별칭 보존
|
||||
ensure_subscriber_daemon() {
|
||||
if [ "${MAM_DELEGATION_PY:-1}" = "0" ]; then _start_watchdog_legacy "$@"; return $?; fi
|
||||
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.delegation.cli \
|
||||
ensure-daemon --job "$1" --workdir "${2:-$PWD}"
|
||||
}
|
||||
delegate_publish_event() {
|
||||
local job_id="$1" event="$2" detail="${3:-}"
|
||||
[ -n "$job_id" ] || return 0 # 비치명 계약 1
|
||||
if [ "${MAM_DELEGATION_PY:-1}" = "0" ]; then _delegate_publish_event_legacy "$@"; return $?; fi
|
||||
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -m lib_py.delegation.cli \
|
||||
publish --job "$job_id" --event "$event" --detail "$detail" || true # 비치명 계약 2·3
|
||||
}
|
||||
```
|
||||
|
||||
### D4. `cli.py`는 라우터, 로직은 기존 `main()` 재사용
|
||||
|
||||
- `publish` → `publish_event.main(argv)` 그대로 호출(exit 0/1/2, stdout 무출력, 옵션 전부 통과).
|
||||
- `submit` → `registry.main(["--registry-dir", ..., "register", ...])`. stdout에 JID 한 줄, 로그는 stderr.
|
||||
- `ensure-daemon` → `daemon.ensure_subscriber_daemon(job_id, workdir)`; stdout PID, exit 0 / 부재 시 stderr 동일 문구 + exit 1.
|
||||
- 래퍼 `multi-agent-mux-delegate-job`(bash)은 **Phase 1에서 수정하지 않는다**(G-10 보호).
|
||||
|
||||
### D5. `daemon.ensure_subscriber_daemon(job_id, workdir) -> int` 의미 보존
|
||||
|
||||
| 항목 | 레거시(`lib.sh:1912-1941`) | 파이썬 이식 |
|
||||
|---|---|---|
|
||||
| 모니터 스크립트 경로 | `<workdir>/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh` | 동일 |
|
||||
| 부재 시 | stderr `ERROR: monitor script not found: <path>`, rc 1 | 동일 문구·rc |
|
||||
| 중복 검사 | `pgrep -f "bash <path> --subscribe"` | `subprocess.run(["pgrep","-f",...])` 동일 패턴 |
|
||||
| 기존 PID 출력 | `echo "$pid"` (다중 매치면 여러 줄) | 동일하게 전량 출력(정리는 Phase 4 후보) |
|
||||
| 스폰 | `nohup bash <path> --subscribe --idle-timeout 0 </dev/null >> <workdir>/.mam/multi-agent-mux-monitor.log 2>&1 &`, cwd=workdir | `Popen([...], cwd=workdir, stdin=DEVNULL, stdout=log(append), stderr=STDOUT, start_new_session=True, preexec_fn=SIGHUP 무시)` |
|
||||
| 반환 | 스폰된 PID | 동일 |
|
||||
| 데몬 자체 중복 방지 | `reconcile.sh:86-95` `.mam/monitor.lock` flock | 변경 없음 |
|
||||
|
||||
### D6. 임포트 시점 부작용 제거 — `_load_dotenv()`를 진입점 시점으로
|
||||
|
||||
`mqtt_common.py:112`의 모듈 레벨 `_load_dotenv()` 호출을 `ensure_env_loaded()`(멱등, 1회)로 바꾸고 **모든 프로세스 진입점** 첫 줄에서 호출: `publish_event.main`, `job_subscriber.main`, `registry.main`, `cli.main`, `bench.main`, `reconcile.sh` 임베디드 구독자(`import mqtt_common` 직후 1줄). 이의제기 §6이 독립 검증한 대로 `mqtt_common.py`의 env 참조는 전부 함수 내부이고 모듈 상수는 env 무관하므로 값이 달라지는 상수는 없다. `conftest.mam_sandbox`에 `monkeypatch.setenv("MAM_ENV_FILE", str(tmp_path / ".mam.env.absent"))` 추가(`_load_dotenv:48-52`는 명시 파일 부재 시 무로딩).
|
||||
|
||||
### D7. 발행 경로 분리 — 전송(transport)만 교체 가능하게
|
||||
|
||||
`publish_event.main()`(`:133-235`)을 동작 불변으로 세 조각으로 나눈다.
|
||||
|
||||
```python
|
||||
def prepare_event(job_id, event, detail, data, registry_dir) -> PreparedEvent # load_job, broker cfg, topic, next_seq(소비), build_payload, retain 결정
|
||||
def record_outcome(prepared, registry_dir, publish_ok, publish_error) -> None # append_event(audit) + registry.append_event + EVENT_TO_STATUS 동기화
|
||||
def main(argv=None) -> int: # parse → ensure_env_loaded → prepare_event → with_retry(_publish_once) → record_outcome → 0/2
|
||||
```
|
||||
|
||||
- G-1/G-2/G-4의 의미가 `record_outcome` 한 곳에 남는다.
|
||||
- `publisher.OneShotPublisher.publish(prepared)` = 기존 `_publish_once`.
|
||||
- `publisher.PersistentPublisher`: 컨텍스트 매니저. 1회 연결 + `loop_start()` + `reconnect_delay_set(1,16)`; `publish(prepared)`는 QoS1 발행 + `wait_for_publish(PUBLISH_ACK_TIMEOUT)`; 미확인 시 예외를 던져 호출자의 `with_retry`가 재시도. 브로커 설정이 다른 job 혼입 시 예외.
|
||||
- `service.DelegationService(registry_dir, persistent=False)`: `submit_job()`, `publish_event()`, `ensure_subscriber_daemon()`. CLI/셸은 항상 `persistent=False`. Phase 2a 엔진이 `persistent=True`의 첫 소비자.
|
||||
- 패리티 테스트: 동일 입력·가짜 전송에서 OneShot과 Persistent의 레지스트리(`last_seq`, `status`)·감사 레코드(`published`, `seq`, `retain`, `payload.data.hmac_sig`)가 필드 단위로 동일.
|
||||
|
||||
### D8. 벤치마크 — 결정적 지표와 정보성 지표 분리
|
||||
|
||||
`python -m lib_py.delegation.bench --n 50 --transport {fake|broker} [--connect-latency-ms 50] --out <json>`
|
||||
|
||||
| 지표 | fake 전송(CI에서 단정) | broker 전송(수동, 정보성) |
|
||||
|---|---|---|
|
||||
| 연결 수립 횟수 / N회 발행 | OneShot = N, Persistent = 1 (+재연결 수) — 테스트로 고정 | 동일 카운트 실측 |
|
||||
| 발행당 지연 p50/p95/max | 합성 지연 기준 산술 확인 | 실측 (CONNACK 타임아웃 건수 별도) |
|
||||
| 프로세스 포크 수(셸 경로) | `delegate_publish_event` 1회당 레거시 3 fork → 신규 2 fork | 분석값 |
|
||||
|
||||
결과는 `docs/contracts/issue-5/phase1-benchmark.md`에 환경과 함께 기록. 실브로커 수치는 게이트 조건이 아니다.
|
||||
|
||||
### D9. 비범위 및 호환성 계약(재확인)
|
||||
|
||||
- 저장소 통합 금지: `lib_py/delegation`은 `lib_py.state`/`lib_py.atomic_yaml`을 임포트하지 않는다 — D1(b)가 이를 포함하는 더 넓은 규칙.
|
||||
- 스키마 불변: `SCHEMA_VERSION = 1`, 레코드 필드, 토픽, 감사 로그 레이아웃, `<id>.events.log` 형식 전부 불변 → §3 라이브 헬퍼 버전 게이트상 ABI 파괴 없음. K2에서 부모 `lib_py`(live)와 위임 패키지(freeze)가 다른 트리인 것은 D1(b) 덕분에 관측 가능한 차이를 만들지 않는다.
|
||||
- `job_subscriber`는 이미 실행당 장수 연결이므로 전송 변경 없음.
|
||||
- 자동완료 관련 코드 일체 추가 금지.
|
||||
|
||||
---
|
||||
|
||||
## 3. 작업 분해 (WBS) — 순서 고정, 커밋 5개
|
||||
|
||||
### P1-00 특성화 테스트 선행 (커밋 1: `test(issue-5): phase 1 delegation characterization suite`)
|
||||
|
||||
- **파일**: `tests/test_issue5_phase1_delegation.py`(신규), `tests/conftest.py`(`MAM_ENV_FILE` 1줄 + PV-2/PV-3 session finalizer), `tests/fixtures/issue_5/phase1/`(가짜 `reconcile.sh`, 가짜 `python` 스텁, `shim_contexts.py` 하니스 — 부록 B).
|
||||
- **셸 헬퍼 golden 테스트** (샌드박스에서 `source lib.sh` 후 실행, `_delegate_py_bin`은 `test_c1_tui_readiness.py:513` 방식으로 스텁):
|
||||
- C-01 `delegate_publish_event "" started` → rc 0, python 미실행.
|
||||
- C-02 스크립트 부재 → rc 0.
|
||||
- C-03 python 스텁 exit 2 → rc 0.
|
||||
- C-04 정상 경로 argv에 `--job <id> --event <ev> --detail <detail>` 정확히 포함.
|
||||
- C-05 `delegate_submit_job p a s` → stdout이 스텁의 JID 한 줄과 정확히 동일.
|
||||
- C-06 `start_watchdog` 스크립트 부재 → stderr 정확 문구, rc 1.
|
||||
- C-07 신규 스폰 → stdout 숫자 PID, 생존, cwd=workdir, 로그 파일 생성.
|
||||
- C-08 두 번째 호출 → 동일 PID, 프로세스 수 불변.
|
||||
- C-09 `start_watchdog` ≡ `ensure_subscriber_daemon` — P1-02 이후 활성.
|
||||
- `MAM_DELEGATION_PY` ∈ {`0`,`1`} parametrize.
|
||||
- **CLI 계약 스냅샷**: argparse 파서에서 옵션명·choices·default를 추출해 단정.
|
||||
- **자동완료 불변 가드**: `EVENT_TO_STATUS` 동등; 래퍼 `:335-348` 리터럴; `reconcile.sh` `_MUTATION` 핵심 4줄 리터럴.
|
||||
- **PV-2/PV-3 finalizer는 이 커밋에서 이미 활성** — 현행 트리에서 통과해야 한다(shim 도입 전 기준선).
|
||||
- **완료 기준**: 신규 테스트 전부 통과(레거시 코드 그대로), 전체 스위트 510 + 신규 통과.
|
||||
|
||||
### P1-01 패키지 생성·이동·shim (커밋 2: `refactor(issue-5): move delegation scripts into lib_py.delegation with package-bound shims`)
|
||||
|
||||
- **파일**: `git mv` 4종, 내부 임포트 교정, `lib_py/delegation/__init__.py`, `scripts/*.py` 4종 shim(D2 블록), `mqtt_common.ensure_env_loaded()`(D6) + 4개 `main()` 및 `reconcile.sh` 임베디드 구독자 1줄.
|
||||
- **추가 테스트**(Rev.2 확장): PV-1(4 별칭), PV-4 서브프로세스 매트릭스 10 시나리오 + K1/K3/K6, PV-5 shim 동일성, "라이브러리 임포트 후 `os.environ` 키 집합 불변", `ensure_env_loaded` 멱등, D1(a)(b) 임포트 규칙 grep, `reconcile.sh --subscribe --idle-timeout 1`이 `test_comp_monitor_concurrency_lock`과 동일 동작.
|
||||
- **완료 기준**: 전체 스위트 통과, `git diff --check`, `compileall`, 3개 shim `--help` 출력이 이동 전 캡처본과 diff 없음, **PV-2/PV-3가 전체 스위트 실행 후에도 통과**(이것이 이의제기가 요구한 "어느 트리를 검증했는지" 증빙).
|
||||
|
||||
### P1-02 서비스·CLI·데몬 + 셸 헬퍼 교체 (커밋 3: `feat(issue-5): DelegationService, cli router, ensure_subscriber_daemon; lib.sh helpers delegate to python`)
|
||||
|
||||
- **파일**: `service.py`, `daemon.py`, `cli.py`, `__main__.py`; `lib.sh` L1876-1941 교체(D3).
|
||||
- **추가 테스트**: daemon 단위(PID 생존·중복·로그 경로·부모 종료 후 생존), `cli publish` ≡ `publish_event.main`(G-1~G-4 cli 경로 parametrize), `cli submit` stdout, C-01~C-09 신규 본체 재통과.
|
||||
- **완료 기준**: 두 경로 특성화 통과, `bash -n` 6개 파일, 전체 스위트 통과.
|
||||
|
||||
### P1-03 장수 연결 프로토타입 (커밋 4: `feat(issue-5): persistent MQTT publisher prototype and benchmark harness`)
|
||||
|
||||
- **파일**: `publish_event.py` 함수 분리(D7), `publisher.py`, `service.py` `persistent` 옵션, `bench.py`.
|
||||
- **추가 테스트**: 가짜 paho 클라이언트로 (a) OneShot N회 = N connect, Persistent = 1, (b) 패리티, (c) PUBACK 미확인 → 재시도 → `record_outcome(published=False)`, (d) 브로커 설정 상이 혼입 예외, (e) `bench --transport fake` 스키마·카운트. 네트워크 접촉 0.
|
||||
- **완료 기준**: 위 테스트 통과, G-1~G-4 동일 통과, 전체 스위트 통과.
|
||||
|
||||
### P1-04 벤치마크 실측 및 문서 (커밋 5: `docs(issue-5): phase 1 benchmark results, contract mapping, task checklist`)
|
||||
|
||||
- 실행: fake 모드 + broker 모드 1회(임시 레지스트리). `docs/contracts/issue-5/phase1-benchmark.md`에 기록.
|
||||
- 문서 갱신: `SKILL.md`(version `4.2.0`→`4.3.0`, "구현은 `lib_py.delegation`, `scripts/*`는 패키지 바인딩 shim" 1단락 + D2-1 표 요약), 스킬 `README.md`, `registry.md`/`job-protocol.md` 각주, `deploy/install.sh` `core_files`에 `.agents/skills/lib_py/delegation/__init__.py` 추가(무결성 목록 갱신 목적; 배포 자체는 트리 통째 복사라 누락 위험 없음), `baseline.md` §6 매핑 행, `task.md` P1-01~03 체크 + 증거, `implementation_plan.md` Phase 1 행 상태.
|
||||
- 라이브 스모크(수동, 보고서 기록): 임시 레지스트리에서 `registry.py register` → shim `publish_event.py --event started` → `-m lib_py.delegation.cli publish --event completed` → `<id>.events.log` 2줄, `events.ndjson` `published` 2건, `status.json`=completed. 이어 `MAM_DELEGATION_PY=0`으로 반복. **추가(Rev.2)**: 에이전트 세션과 같은 조건(`PYTHONPATH`=live)에서 **freeze 사본 경로**의 shim을 실행하고 `python -c`로 `lib_py.delegation.__path__`가 freeze 사본임을 출력해 첨부(K2 실증).
|
||||
|
||||
---
|
||||
|
||||
## 4. 계약 보존 매트릭스
|
||||
|
||||
| 계약 | 구현 위치(이동 후) | 보장 테스트 |
|
||||
|---|---|---|
|
||||
| 스크립트 리터럴 경로·실행 비트·`--help` | `scripts/*.py` shim | P1-01 `--help` diff, K6, e2e `:414` |
|
||||
| `import mqtt_common` 등 최상위 이름 → 단일 트리의 동일 객체 | shim 패키지 바인딩 + 별칭 | **PV-1(정체성+출처)**, PV-4 |
|
||||
| 위임 외 `lib_py.*`는 항상 실제 트리(pytest) / 부모 무재바인딩 | shim 규칙 3 | **PV-2, PV-3** |
|
||||
| `publish_event` exit 0/1/2, stdout 무출력 | `publish_event.main` 불변 | G-1~G-4 + cli parametrize |
|
||||
| seq 단조·실패 시 소비 | `prepare_event`→`next_seq` | G-4, `test_comp_monitor_monotonic_seq_updates` |
|
||||
| HMAC `data.hmac_sig` | `build_payload` 단일 경로 | `test_mqtt_verify_hmac_*`, 패리티 |
|
||||
| 터미널 이벤트 retain | `prepare_event` | 패리티(`retain`) |
|
||||
| `job_subscriber` exit 0/1/2/3, 디스크 폴백 3s | 이동만 | G-5~G-9 |
|
||||
| 셸 헬퍼 비치명 | 셸 가드 + `\|\| true` | C-01~C-04 (두 경로) |
|
||||
| `delegate_submit_job` stdout=JID 1줄 | `cli submit` + stderr 로깅 | C-05 |
|
||||
| 데몬 PID/수명/중복/로그 경로 | `daemon.py` | C-06~C-09, daemon 단위 |
|
||||
| `start_watchdog` 이름 | 별칭 | C-09 |
|
||||
| 감사 로그 레이아웃·`DELEGATE_JOB_LOGS_DIR` | `mqtt_common` 불변 | G-2, G-3 |
|
||||
| env 로딩 순서(OS env 우선), 라이브러리 임포트 무부작용 | `ensure_env_loaded`(진입점) | env 테스트 2종 |
|
||||
| 자동완료 3표면 불변 | 변경 없음 | 불변 가드 3종 |
|
||||
| 래퍼 텍스트 불변 | 변경 없음 | G-10 + 확장 |
|
||||
| 위임 패키지의 형제 `lib_py` 모듈 무임포트 | D1(b) | grep 테스트 |
|
||||
| freeze(B-13) — K1/K2 모두 freeze 트리 | `PYTHONPATH`(K1) / shim 바인딩(K2) | K1, C2, C2_pe, P1-04 실증 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 게이트
|
||||
|
||||
| # | 조건 | 측정 방법 |
|
||||
|---|---|---|
|
||||
| G1 | 기존 510개 + 신규(예상 40±5) 전부 통과, skipped 0 | `.venv/bin/python -m pytest tests/ -q` (≈15분, 백그라운드) |
|
||||
| G2 | 셸 문법·공백 클린 | `bash -n` 6개 파일, `git diff --check` |
|
||||
| G3 | Python 3.9 컴파일 | `compileall -q .agents/skills/lib_py/delegation` |
|
||||
| **G4** | **정체성+출처(PV-1), 위임 외 `lib_py` 실제 트리·`sys.path` 위생(PV-2/PV-3, 전체 스위트 종료 시점), 컨텍스트 매트릭스(PV-4 10+3), shim 동일성(PV-5), env 무오염** | P1-01 테스트 + conftest finalizer |
|
||||
| G5 | 데몬/PID 계약 | C-06~C-09 두 경로 |
|
||||
| G6 | 자동완료 3표면 불변 | 불변 가드 + `git diff --stat`(래퍼 무변경, `reconcile.sh`는 env 1줄만) |
|
||||
| G7 | 벤치 결정적 지표 + 실측 문서 | fake 전송 테스트, `phase1-benchmark.md` |
|
||||
| G8 | 롤백 드릴 | `MAM_DELEGATION_PY=0` + 라이브 스모크 |
|
||||
| G9 | 라이브 스모크(shim·cli·**K2 freeze 사본**) 감사 로그 정합 | §3 P1-04 |
|
||||
|
||||
Worker 완료 보고서에는 G1 pytest 요약 원문, G4의 PV-2/PV-3 finalizer 통과 로그, G7 표, G9 발췌(K2 `__path__` 출력 포함)를 그대로 붙인다. file:line 인용은 `grep -n` 출력으로 뒷받침.
|
||||
|
||||
---
|
||||
|
||||
## 6. 롤백 절차
|
||||
|
||||
1. **즉시**: `export MAM_DELEGATION_PY=0` → 셸 헬퍼 legacy 분기. 패키지·shim은 그대로 두어도 무해.
|
||||
2. **커밋 단위**: 5→4→3 순 `git revert`; 커밋 2까지 되돌리면 이동 전 상태. 커밋 1은 레거시에서도 통과.
|
||||
3. **진행 중 루프**: freeze 스냅샷은 시작 시점 트리이므로 롤백은 다음 루프부터. 라이브 헬퍼는 즉시 legacy. 스키마 불변이므로 혼재 호환.
|
||||
|
||||
---
|
||||
|
||||
## 7. 리스크와 완화
|
||||
|
||||
| # | 리스크 | 완화 |
|
||||
|---|---|---|
|
||||
| R1 | 모듈 사본 분기(테스트 shim vs 프로덕션 패키지) | D2 패키지 바인딩 + PV-1 출처 단정 |
|
||||
| R2 | 임포트 시 `.mam.env` 주입 오염 | D6 + conftest `MAM_ENV_FILE` |
|
||||
| **R3 (Rev.2 재작성)** | shim 부트스트랩이 pytest에서 위임 외 `lib_py`를 tmp 사본으로 가로채거나(insert), freeze에서 live에 밀리거나(append), 비-잎 모듈에서 두 트리가 섞임(잎 파일 적재) | **해소**: 패키지 단위 bind-once, `sys.path` 무변경, 부모 무재바인딩. PV-2/PV-3/PV-4가 회귀를 검출 |
|
||||
| R4 | Python 3.10+ 문법 | G3 |
|
||||
| R5 | 공용 브로커 불안정 | 테스트는 비라우팅 포트/가짜 전송만 |
|
||||
| R6 | `pgrep -f` 패턴 | 레거시 그대로, C-08 |
|
||||
| R7 | `$( )` 캡처 stdout 오염 | stderr 로깅, C-05/C-07 |
|
||||
| R8 | 래퍼 리터럴(G-10) 파손 | 래퍼 무수정 |
|
||||
| **R9 (Rev.2 수정)** | `lib.sh` 미소싱/`PYTHONPATH` 부재 환경에서 shim·`-m` 실패 | shim: `lib_py` 부재 시에만 자기 트리 `append`(K4). `-m` 경로: 셸 헬퍼는 항상 `lib.sh` 내부이므로 `SKILL_DIR` 존재 + 명시 전달 |
|
||||
| R10 | `lib_py.agents.registry` 혼동 | D1(a) grep |
|
||||
| R11 | `start_new_session` vs `nohup` | SIGHUP 무시, 생존 테스트 |
|
||||
| **R12 (신규)** | K2에서 부모 `lib_py`(live)와 위임 패키지(freeze)가 다른 트리 | D1(b): 위임 패키지는 형제 `lib_py` 모듈을 임포트하지 않음(grep 고정). 현재 0건 |
|
||||
| **R13 (신규)** | 두 트리의 위임 모듈을 한 프로세스에서 동시에 써야 하는 경우 | 존재하지 않음(K1~K6 전수). 발생 시 bind-once 규칙상 최초 트리 유지 — 문서화 |
|
||||
| **R14 (신규)** | pytest에서 위임 패키지가 샌드박스 사본에 바인딩되어 "실제 트리 미검증" 우려 | 사본은 `copytree` 동일 내용이며 PV-1이 사본 위치를 basetemp로 한정. K6 서브프로세스가 사본 경로 실행을 별도 검증 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 명시적 비범위
|
||||
|
||||
- `lib.sh` L1943 이후 TUI 헬퍼의 파이썬화.
|
||||
- 래퍼, `run_loop.sh`, `reconcile.sh`(env 1줄 제외) 수정.
|
||||
- `job_subscriber`/모니터 전송 변경, 스키마 v2, 토픽 변경.
|
||||
- 자동완료 로직 일체(Phase 2a/2b).
|
||||
- 저장소 통합, Windows, `pgrep` 다중 PID 정리(Phase 4 후보).
|
||||
- 기존 tier1/tier2 테스트의 임포트 방식 변경(회귀 오라클 보존).
|
||||
|
||||
---
|
||||
|
||||
## 9. Worker 인수인계 체크리스트
|
||||
|
||||
- [ ] P1-00 커밋 1: 특성화·스냅샷·불변 가드 + **PV-2/PV-3 finalizer**가 레거시 코드에서 통과
|
||||
- [ ] P1-01 커밋 2: `git mv` 4종, D2 shim 4종(PV-5 동일), `ensure_env_loaded`, PV-1/PV-4 매트릭스, 전체 스위트 녹색 **+ finalizer 녹색**
|
||||
- [ ] P1-02 커밋 3: `service/daemon/cli`, `lib.sh` 교체(legacy 보존, `MAM_DELEGATION_PY`), 두 경로 특성화 녹색, `bash -n`
|
||||
- [ ] P1-03 커밋 4: `prepare_event/record_outcome`, `PersistentPublisher`, 패리티·카운트, 네트워크 0
|
||||
- [ ] P1-04 커밋 5: 벤치 문서, SKILL/README/registry.md/job-protocol.md/install.sh/baseline §6/task.md/implementation_plan.md, K2 실증 첨부
|
||||
- [ ] 완료 보고서: G1 원문, G4 finalizer 로그, G7 표, G9 발췌, `grep -n` 인용
|
||||
- [ ] 커밋 접두: `test(issue-5)`, `refactor(issue-5)`, `feat(issue-5)`, `feat(issue-5)`, `docs(issue-5)`
|
||||
|
||||
---
|
||||
|
||||
## 부록 A. 계획 수립 시 실행한 검증 명령 (Rev.1 + Rev.2)
|
||||
|
||||
```bash
|
||||
awk 'NR>=1800 && /^[a-zA-Z_][a-zA-Z0-9_]*\(\)/ {print NR": "$0}' .agents/skills/lib.sh
|
||||
grep -rn 'delegate_publish_event\|delegate_submit_job\|start_watchdog\|_delegate_py_bin' --include='*.sh' --include='*.py' .agents tests
|
||||
grep -l 'delegate_publish_event\|start_watchdog\|delegate_submit_job' tests/*.py # → (없음)
|
||||
grep -n 'lib_py' .agents/skills/multi-agent-mux-delegate-job/scripts/*.py # → (없음) D1(b) 전제
|
||||
echo "$PYTHONPATH" # 에이전트 세션 = 라이브 트리
|
||||
grep -n PYTHONPATH .agents/skills/multi-agent-mux-loop/scripts/run_loop.sh # 0건
|
||||
sed -n '24,25p;163,164p' .agents/skills/lib.sh # SKILL_DIR 기준 PYTHONPATH export
|
||||
ls /var/folders/.../mam-loop-freeze.PfAQNj/.agents/skills/lib_py # freeze에 lib_py 존재
|
||||
grep -n 'os.environ' .agents/skills/multi-agent-mux-delegate-job/scripts/mqtt_common.py # 전부 함수 내부
|
||||
.venv/bin/python -m pytest tests/ --collect-only -q | tail -1 # 510
|
||||
```
|
||||
|
||||
## 부록 B. shim 컨텍스트 재현 하니스 (P1-01 PV-4의 원형)
|
||||
|
||||
세 변형 × 시나리오를 새 인터프리터(`env -u PYTHONPATH`)에서 실행하고 각 모듈의 `TREE` 마커를 출력한다. Worker는 이 하니스를 `tests/fixtures/issue_5/phase1/shim_contexts.py`로 옮기고, 가짜 트리 대신 **실제 이동된 트리의 사본**(`copytree`)을 `real/sandbox/sandbox2/freeze`로 사용하며, `TREE` 마커 대신 `__file__`/`__path__` 접두어로 트리를 판별하도록 바꾼다.
|
||||
|
||||
```python
|
||||
# 트리 구성: <tree>/lib_py/{__init__.py, agents/__init__.py(TREE), delegation/{__init__.py, mqtt_common.py(TREE), publish_event.py(TREE + from lib_py.delegation import mqtt_common)}}
|
||||
# <tree>/multi-agent-mux-delegate-job/scripts/{mqtt_common,publish_event}.py ← D2 shim
|
||||
# 시나리오 (sys.path 조작은 conftest/tier1/실행 방식을 모델링):
|
||||
# A : insert(0, real); insert(0, sandbox/scripts); import mqtt_common, publish_event
|
||||
# A_pe : 같음, import publish_event 만
|
||||
# B : insert(0, real); import lib_py.agents; insert(0, sandbox/scripts); import mqtt_common, publish_event
|
||||
# B2 : insert(0, real); import lib_py.delegation.mqtt_common; insert(0, sandbox/scripts); import mqtt_common, publish_event
|
||||
# C1 : insert(0, freeze/scripts); import mqtt_common, publish_event (PYTHONPATH 없음)
|
||||
# C1_pe: 같음, publish_event 만
|
||||
# C2 : insert(0, real); insert(0, freeze/scripts); import mqtt_common, publish_event
|
||||
# C2_pe: 같음, publish_event 만 ← 에이전트 호출 형태
|
||||
# D : insert(0, real); insert(0, sandbox/scripts); import mqtt_common; insert(0, sandbox2/scripts); import publish_event
|
||||
# E : insert(0, real); insert(0, sandbox/scripts); import mqtt_common; from lib_py.delegation import publish_event
|
||||
# 단정: 각 시나리오에서 {alias_mc, pkg_mc, pe, pe.dep} 가 단일 트리이고, pytest 계열(A,A_pe,B,B2,D,E)에서 agents == real,
|
||||
# identity(alias is pkg) == True, sys.path 에 lib_py 를 담은 비-real 루트가 없음(C1 계열 제외).
|
||||
```
|
||||
@@ -0,0 +1,75 @@
|
||||
# 🔍 Cross-Review (Loop Final) — Issue #5 Phase 1 누적 변경분 `8def235..6ad6f08` — Job `1418f44c`
|
||||
|
||||
- **Reviewer**: `planner-reviewer-claude-01` (Reviewer role)
|
||||
- **대상**: 브리프에 첨부된 누적 diff. `git diff 8def235..HEAD`(HEAD=`6ad6f08`, working tree clean)와 **바이트 동일**(639행, md5 `6880f4fb…`, 10 files, +348/−67)임을 확인했다. 내용은 (a) 커밋 `95f3e9a` — Round 1 교차 리뷰 지적(Codex R1/R2, Claude F-1/O-1, 픽스처·문서)의 수정, (b) 커밋 `6ad6f08` — Round 2 비차단 잔여 2건의 문서 반영(코드 변경 없음).
|
||||
- **선행 검증**: `95f3e9a`는 내가 job `25609c81`에서 전 항목 독립 재검증해 `[VERDICT: PASS]` `[ADJUDICATION: SUSTAINED]`를 냈다. 본 job은 그 결론을 재사용하지 않고 HEAD 기준으로 핵심 검증을 다시 실행했으며(§2), 신규 커밋 `6ad6f08`을 별도로 검토했다(§3).
|
||||
- **루프 컨텍스트**: 본 루프의 freeze 스냅샷(`mam-loop-freeze.RgjWtB`)은 `8def235` 시점 트리다(`lib.sh`·`publisher.py`·`publish_event.py`가 `8def235` blob과 동일, 생성 시각 16:29:14 = `8def235` 커밋 8초 후). 즉 루프는 수정 전 트리로 고정된 채 두 라운드의 수정 커밋을 검토했다 — B-13이 의도대로 동작한 사례다.
|
||||
|
||||
---
|
||||
|
||||
## 0. 결론 요약
|
||||
|
||||
- `95f3e9a`의 수정 5건(F-1, O-1, R1, R2, 픽스처 정합화)은 HEAD에서 그대로 유효하다. 특성화·PV·회귀 66종 통과, 빠른 회귀 18종 통과, 범위 `fc0bd96..HEAD` `git diff --check` 클린, `bash -n`·`compileall` 클린(§2).
|
||||
- `6ad6f08`은 문서 2파일 +4/−1로, 벤치 §4 fork 표의 경로 명칭을 실제 구현(`_delegation_py`)으로 정정하고 내 O-4를 `task.md` P3-02 인계 항목으로 기록했다. 두 서술 모두 코드·실측과 일치한다(§3).
|
||||
- 자동완료 3표면(래퍼, `run_loop.sh`, `reconcile.sh`)은 이 범위에서 **0행** 변경.
|
||||
- **본 job의 시작 이벤트 자체가 K2 실증이다**: 위임 메시지가 지정한 freeze 경로의 shim(`8def235` 버전)을 라이브 `PYTHONPATH` 환경에서 실행했고, 같은 조건을 재현해 출처를 출력하면 위임 패키지는 freeze, 부모 `lib_py`는 라이브로 바인딩된다(§2-4). 이벤트는 정상 발행됐다(`1418f44c.events.log` seq 1).
|
||||
- 신규 결함 없음. 재계획(`[ESCALATE: PLANNER]`)이 필요한 사안 없음.
|
||||
|
||||
---
|
||||
|
||||
## 1. 범위 동일성
|
||||
|
||||
```
|
||||
brief: 639 lines md5 6880f4fb3043921c3403b764ec5dca83
|
||||
head : 639 lines md5 6880f4fb3043921c3403b764ec5dca83 (git diff 8def235..HEAD)
|
||||
IDENTICAL
|
||||
```
|
||||
파일 10종: `lib.sh`, `lib_py/delegation/{bench,daemon,mqtt_common,publish_event,publisher}.py`, `multi-agent-mux-delegate-job/SKILL.md`, `docs/contracts/issue-5/{phase1-benchmark,task}.md`, `tests/test_issue5_phase1_delegation.py`. 브리프 본문의 과업 설명("265a1f6부터 dfa7212까지 … 54개 신규 테스트, 564개")은 루프 시작 시점의 목표 문구이며, 실제 첨부 diff·현재 수치(66/576)와는 위와 같이 대조했다.
|
||||
|
||||
## 2. HEAD 기준 재검증 (`95f3e9a` 항목)
|
||||
|
||||
### 2-1. F-1 / O-1 (내 Round 1 지적)
|
||||
- `publisher.py:27-43` `_send()`가 `info.rc == MQTT_ERR_NO_CONN`만 `ConnectionError`로 번역, 재시도 튜플(`:56`) 불변. Round 2에서 실제 paho 2.1.0으로 재현·검증한 결과(3회 시도 모두 전송 도달, 끊김 2회 후 복구 성공, 다른 `rc`는 비재시도)는 HEAD의 동일 코드에 그대로 적용된다. paho `publish()`가 `info.rc`에 넣는 값은 `SUCCESS`/`QUEUE_SIZE`/`NO_CONN`뿐이고 `QUEUE_SIZE`는 이미 튜플 안의 `ValueError`이므로 번역은 완결적이다.
|
||||
- `publish_event.py:94-102` `_publish_once` → `OneShotPublisher().publish_raw()` 위임, 타임아웃 상수 단일 소유. `test_one_shot_publish_has_a_single_implementation`이 재발을 막는다.
|
||||
|
||||
### 2-2. R1 / R2 (Codex 지적)
|
||||
- `lib.sh:1952-1968` `_delegation_py`: `python -c` + `sys.path.insert(0, SKILL_DIR)`, cwd 보존, `PYTHONPATH` 전달 유지. Round 2에서 cwd=skills 디렉터리·decoy `lib_py` 조건으로 구형 `-m`은 라이브를, 신형은 자기 트리를 해석함을 실측했다. 프로덕션 cwd(워크스페이스 루트)에는 `lib_py`가 없어 R1은 잠재 결함이었으나 수정은 옳다.
|
||||
- `publisher.py:140-158` 연결 정체성 10필드 불변 스냅샷 비교, 오류는 필드명만 노출.
|
||||
|
||||
### 2-3. 실행 결과 (HEAD, 직접 실행)
|
||||
| 검증 | 결과 |
|
||||
|---|---|
|
||||
| `tests/test_issue5_phase1_delegation.py` | **66 passed in 11.63s** |
|
||||
| 빠른 회귀(G-1~G-10, mqtt, seq, monitor lock) | **18 passed in 1.74s** |
|
||||
| `git diff --check fc0bd96..HEAD` | 클린 |
|
||||
| `bash -n` (`lib.sh`, `reconcile.sh`, 래퍼) / `compileall lib_py/delegation` | 클린 |
|
||||
| 자동완료 3표면 `git diff --stat 8def235..HEAD` | 래퍼 0행, `run_loop.sh` 0행, `reconcile.sh` 0행 |
|
||||
| 전체 스위트 | §4 |
|
||||
|
||||
### 2-4. K2 라이브 실증 (본 job의 실행 조건 그대로)
|
||||
```
|
||||
delegation -> /var/folders/.../mam-loop-freeze.RgjWtB/.agents/skills/lib_py/delegation
|
||||
lib_py -> /Users/.../multi-agent-mux/.agents/skills/lib_py/__init__.py
|
||||
```
|
||||
`PYTHONPATH`=라이브 트리인 프로세스에서 freeze 경로의 `publish_event.py`(shim)를 실행한 뒤 출처를 출력한 결과다. 위임 패키지는 freeze, 부모는 라이브 — 계획 D2-1 K2 행 그대로이며, 형제 임포트 금지(D1(b))로 혼재가 무해함은 AST 테스트가 보장한다.
|
||||
|
||||
## 3. 신규 커밋 `6ad6f08` 검토 (문서 2파일, 코드 무변경)
|
||||
|
||||
| 변경 | 검증 |
|
||||
|---|---|
|
||||
| `phase1-benchmark.md:69-71` fork 표의 신규 경로 명칭을 `-m lib_py.delegation.cli` → `_delegation_py`(`python -c` + `sys.path.insert`)로 정정, `-m` 폐기 사유 각주 | `lib.sh:1960-1966` 실제 구현과 일치. fork 수 3은 `$(_delegate_py_bin)`, `$(_delegate_script)`, python으로 변함없음 — 정확 |
|
||||
| `task.md:94` P3-02에 O-4 인계(freeze `lib.sh`의 `_delegate_py_bin`이 `.venv` 상향 탐색에 실패해 시스템 `python3`로 폴백; Phase 3에서 `WORKSPACE_ROOT`/`MAM_REAL_ROOT` 앵커링 필요) | 내 `25609c81` §6 O-4와 내용 일치. 전제 재확인: `mam-loop-freeze.RgjWtB/`에는 `.agents`만 있고 `.venv` 없음. 현재 freeze `lib.sh` 헬퍼를 호출하는 프로덕션 경로가 없다는 판단(래퍼는 `pick_python()`, 원샷 스크립트는 라이브 트리, `run_loop.sh`·래퍼는 헬퍼 미호출)도 Round 2에서 grep으로 확인한 그대로 |
|
||||
|
||||
커밋 메시지의 "The unanimous PASS was issued on 95f3e9a"는 사실과 부합한다(내 Round 2 PASS 포함). 문서 커밋을 별도로 분리해 코드 커밋의 검토 결과를 오염시키지 않은 점은 적절하다.
|
||||
|
||||
## 4. 전체 스위트 실행 결과
|
||||
|
||||
HEAD(`6ad6f08`)에서 직접 실행 (`.venv/bin/python -m pytest tests/ -q`, 백그라운드): **576 passed in 814.59s (0:13:34)**, 0 failed, 0 skipped. Round 2와 동일한 576(=510 기준선 + 57 Phase 1 + 9 Round 1 회귀). `6ad6f08`은 문서 전용이므로 수집 수 불변.
|
||||
|
||||
---
|
||||
|
||||
## 5. 결론
|
||||
|
||||
누적 변경분은 Round 1·2에서 세 리뷰어가 제기한 결함을 모두 수정한 `95f3e9a`와, 잔여 문서 항목을 정확히 반영한 `6ad6f08`로 구성된다. HEAD에서 핵심 검증을 재실행한 결과 회귀·린트·계약·자동완료 불변·freeze(B-13) 보존이 모두 유지되며, 본 job의 시작 이벤트 발행 자체가 freeze shim의 K2 동작을 실증했다. 재계획이 필요한 설계 결함은 없다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,60 @@
|
||||
# 🔍 Cross-Review Round 3 — Issue #5 Phase 2a (`6ad6f08..0e74aa1`, 신규 커밋 `cb575fa`+`0e74aa1`) — Job `49171b58`
|
||||
|
||||
- **Reviewer**: `planner-reviewer-claude-01` (Reviewer role)
|
||||
- **대상**: 브리프 첨부 diff = `git diff 6ad6f08..HEAD` − `.agents/reports/*`(2914행, **바이트 동일**). HEAD=`0e74aa1`, working tree clean. Round 2(`cb792e91`) 대비 신규는 수정 커밋 `cb575fa`(9 files, +317/−52)와 문서 커밋 `0e74aa1`.
|
||||
- **방법**: Round 2의 F-C/F-D와 Codex Round 2(`0026165b`)의 R1/R4 수정을 실제 CLI·실제 모듈로 재검증(F-C는 래퍼와 같은 `set -euo pipefail` 셸에서), Round 1 항목(F-A/F-B)의 회귀 여부 재확인, 신규 테스트 12종 독해, 전체 스위트 실행.
|
||||
|
||||
---
|
||||
|
||||
## 0. 결론 요약
|
||||
|
||||
- **Round 2 지적 전부 해소 — `[ADJUDICATION: SUSTAINED]`**. F-C: 멱등 ACK가 `TransitionResult.idempotent`로 구분되어 CLI가 rc 0을 내고, 래퍼 승인 경로가 `set -e` 아래에서 끝까지 진행된다(§1). F-D: `_operator_write` 첫 줄에서 `VALID_STATUSES` 검증이 두 모드 공통으로 복원돼 rc 1·레코드 불변이다(§2). Round 1의 F-A/F-B도 회귀 없이 유지된다(§3).
|
||||
- Codex R1(관측 증거 **부재** 시 승인)·R4(감사 mirror의 비원자적 비교-후-쓰기)도 구현·테스트를 확인했다(§4). R4는 내가 Round 2 §2에서 비차단으로 언급한 창(window)을 닫는 것이라 특히 반갑다.
|
||||
- **신규 결함 없음.** 자동완료 3표면·`lib.sh`·shim 무변경, 위생 클린, 전체 스위트 통과(§6). `[ESCALATE: PLANNER]` 불필요.
|
||||
|
||||
---
|
||||
|
||||
## 1. F-C 재검증 — 해소
|
||||
|
||||
- `engine.py:83-99` `TransitionResult.idempotent`, `:105-113` `_Decision.idempotent`, `:164-167`에서 같은 terminal 재수신에 `idempotent=True`. `registry.py:329-366` `_operator_write`: `committed` → 0, `idempotent` → 0, 그 외만 `refused:` + 4.
|
||||
- **셸 수준 재현(Round 2와 동일 스크립트)**: 이미 `completed`인 레코드에 `status --set completed`를 `set -euo pipefail` 아래에서 실행 → `REACHED (CAS=1)` / `REACHED (CAS=0)`, bash exit 0. Round 2에서는 exit 4로 `REACHED`가 출력되지 않았다.
|
||||
- `registry.main` 반환값 직접 확인(두 모드): 멱등 `--set completed` → **rc 0**, `cancelled` after `completed` → **rc 4**(CAS=1, 진짜 거부는 여전히 non-zero; CAS=0은 레거시 덮어쓰기 유지).
|
||||
- `test_fc_idempotent_finalize_*`(두 모드), `test_fc_a_genuine_conflict_is_still_reported`(rc 4 유지), `test_fc_engine_marks_an_idempotent_result`가 고정. 문서: `state-transitions.md` Rev.5 "멱등 ACK는 거부가 아니다", `phase2a-shadow.md` §2-1 CLI 종료 코드 표(0/1/4).
|
||||
|
||||
## 2. F-D 재검증 — 해소
|
||||
|
||||
`_operator_write`가 CAS 분기 **이전**에 `status not in VALID_STATUSES`면 2a 이전과 같은 문구로 rc 1. 두 모드에서 `--set bogus` → **rc 1, 레코드 불변**(직접 확인). `test_fd_invalid_status_is_rejected_in_both_modes`가 레지스트리 디렉터리 지문까지 단정.
|
||||
|
||||
## 3. Round 1 항목 회귀 확인 — 유지
|
||||
|
||||
래퍼 시퀀스 재생: `completed → update --status pending --iteration 2` rc 0 · `pending/2`; `completed → status --set error` rc 0 · `error`. 지연 flush 실험: registry=`completed`, status.json=`completed`. 두 모드 병행 특성화(X-01~X-04, fa/fb)가 스위트에 남아 있다.
|
||||
|
||||
## 4. Codex Round 2 (R1, R4) 확인
|
||||
|
||||
| # | 구현 | 검증 |
|
||||
|---|---|---|
|
||||
| R1 증거 부재 시 승인 | `policy.py:20-23` `ATTRIBUTION_FIELDS`; `collect_observed_identity`(`:33-66`)는 식별자를 타임라인 항목의 **`collector_attribution` 봉투**(및 비-herdr 소스 payload)에서만 읽고 네이티브 Herdr payload 안의 식별자는 **증거로 취급하지 않는다**(Phase 0의 "네이티브 payload에 MAM 필드 금지"와 정합); `decide`(`:115-135`)는 4식별자 **전부 존재+일치**와 매니페스트 `attempt_id` 존재+일치를 요구 | `finalized_candidate.json`에 봉투 추가(다른 11종은 `hold`/`reject`라 무영향, 12벡터 `test_g7` 유지). `test_r1_missing_attribution_evidence_holds`(4필드 parametrize), `*_missing_report_manifest_holds`, `*_identifiers_inside_a_native_payload_are_not_evidence`. Phase 0 `validate_watcher_scenario` 41종 통과(봉투는 item 레벨 키라 네이티브 payload 검사와 충돌 없음) |
|
||||
| R4 비원자적 비교-후-쓰기 | `update_logged_status`(`mqtt_common.py:577-619`)가 per-job 전용 락 파일(`.status.json.lock`, `_file_lock`) 아래에서 읽기-비교-쓰기, 임시 파일은 `mkstemp`(공유 `.tmp` 경로 제거). 레지스트리 락과 별개이며 레지스트리 트랜잭션 밖에서 잡힌다 | `test_r4_overlapping_mirror_flushes_*`는 첫 flush의 `read_text` 직후에 경쟁 flush를 스레드로 띄워 **실제로 블록됨**(`blocked is True`)과 최신 상태 생존을 단정 — 진짜 인터리빙 테스트다. `*_each_mirror_write_uses_its_own_temp_file` |
|
||||
|
||||
Grok Round 2(`32e936e5`)는 PASS + 기존 비차단 잔여로, 이번 수정과 충돌 없음.
|
||||
|
||||
## 5. 기타 확인
|
||||
|
||||
- `git diff --check 6ad6f08..HEAD` 클린, `compileall lib_py` 클린, 자동완료 3표면·`lib.sh`·shim 변경 0.
|
||||
- `test_issue5_phase2a_loop.py` + `phase0` + `phase1` 합계 **195 passed in 12.57s**.
|
||||
- 락 규율(G-5)·writer 인벤토리(G-6)·shadow 무기록(G-4)·패키지 규칙(G-9)은 이번 수정으로 영향받지 않았고(테스트 유지), R4의 감사 락은 레지스트리 락 밖에서만 잡혀 G-5의 "락 안 블로킹 금지"와 충돌하지 않는다.
|
||||
- 비차단 메모(2b 인계, 이미 문서화됨): `started`의 세션 확인(O-A), `stop_session.sh`의 무조건 `cancelled`가 `completed` 이후 거부되는 동작(O-B).
|
||||
|
||||
## 6. 전체 스위트
|
||||
|
||||
HEAD(`0e74aa1`)에서 직접 실행 (`.venv/bin/python -m pytest tests/ -q`, 백그라운드): **664 passed in 899.99s (0:14:59)**, 0 failed, 0 skipped. Round 2의 650 대비 +14 (신규 함수 12개가 parametrize로 14 케이스). 총계는 실행 결과에서 직접 읽은 값이다.
|
||||
|
||||
---
|
||||
|
||||
## 7. 결론
|
||||
|
||||
Round 2에서 지적한 두 회귀(멱등 ACK의 거부 분류로 인한 래퍼 성공 경로 중단, 롤백 모드의 상태값 검증 소실)는 제시한 방향 그대로 정확히 고쳐졌고, 래퍼와 같은 셸 조건에서 실제 CLI로 확인했다. Codex의 R1/R4도 구현됐으며, 특히 R4는 감사 mirror의 마지막 경쟁 창을 닫는다. Round 1·2의 모든 항목이 회귀 없이 유지되고 새 결함은 발견되지 않았으므로 통과시킨다.
|
||||
|
||||
[ADJUDICATION: SUSTAINED]
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,69 @@
|
||||
# Issue #5 Phase 2b — Round 4 교차 리뷰 (Codex Round 3 R4 후속 결함 수정)
|
||||
|
||||
- **Job**: a665d729 / Reviewer: planner-reviewer-claude-01
|
||||
- **리뷰 대상**: 커밋 `8d7e010` "fix(issue-5): record an accepted intent, not a refused one" (기준 `9a651cc`)
|
||||
- **범위 고정**: brief에 내장된 diff와 `git diff 9a651cc..HEAD -- . ':(exclude).agents/reports'`를 바이트 비교 → **동일**(15,916 B). 변경 파일 4개: `lib_py/delegation/job_subscriber.py`, `docs/contracts/issue-5/phase2b-autocomplete.md`, `docs/contracts/issue-5/task.md`, `tests/test_issue5_phase2b_autocomplete.py`.
|
||||
- **동결 스냅샷**: `mam-loop-freeze.03Uqb9`의 `job_subscriber.py` md5 = `9a651cc` 시점과 동일(수정 **전**). engine/notifier/autocomplete/lib.sh는 HEAD와 동일. 즉 이 루프 자신의 구독자 데몬(pid 10863)은 수정 전 코드를 실행 중이며, 수정은 다음 루프 기동(새 동결)부터 효력을 갖는다. B-13 설계상 당연한 결과이고 diff의 결함이 아니다.
|
||||
|
||||
## 1. 지적 사항 대조 (Codex Round 3)
|
||||
|
||||
지적: `_Watcher.on_message`에서 `seen_intents[jid].add(identity)`가 단조 seq 가드보다 **앞서** 실행되어, 늦게 도착한 과거 패킷이 자기 intent를 "본 것"으로 태우고 뒤따르는 정당한 재시도가 드롭된다.
|
||||
|
||||
수정 후 순서(`job_subscriber.py:144-179`, 직접 확인):
|
||||
1. `data`가 dict가 아니면 intent 없음으로 취급(이전 코드의 `(data or {}).get` 경로 제거)
|
||||
2. intent가 있으나 dict가 아니면 **드롭+경고**
|
||||
3. identity 튜플 생성 → `in seen_intents` 대조를 `try/except TypeError`로 감싸 해시 불가면 **드롭+경고**
|
||||
4. 이미 본 identity면 드롭(seq 바닥을 올리지 않음)
|
||||
5. seq 단조 가드
|
||||
6. **가드 통과 후에만** `seen_intents[jid].add(identity)`
|
||||
7. 감사 로그 `received` 기록 → 큐 투입
|
||||
|
||||
brief의 요구 2)·3) 모두 충족. 요구 3)의 시나리오(`last_seq=10`, seq=9 거부 → 동일 intent seq=11 수신 → `qsize()==1`)가 `test_r4b_a_stale_packet_does_not_burn_the_intent_for_its_own_retry`에 그대로 있다.
|
||||
|
||||
## 2. 실측 재현
|
||||
|
||||
### 2-1. 신규 테스트 6종 — HEAD vs 수정 전 트리
|
||||
`9a651cc` 워크트리에 HEAD의 테스트 파일을 복사해 실행:
|
||||
|
||||
| 결과 | 테스트 |
|
||||
|---|---|
|
||||
| 수정 전 **FAIL** | `stale_packet_does_not_burn_the_intent` (`seen_intents == {(7,'completed','T')}` 잔존), `unhashable_intent_is_refused` (`TypeError: unhashable type: 'list'` 콜백 탈출), `non_object_intent_is_refused` |
|
||||
| 수정 전 PASS(보존 가드) | `duplicate_intent_does_not_advance_the_sequence_floor`, `absent_or_null_intent`, `real_retry_is_deduplicated_by_the_real_consumer` |
|
||||
|
||||
task.md P2B-07의 "3종 역검증 실패 / 3종 보존" 주장과 **정확히 일치**. HEAD에서는 파일 전체 65 passed.
|
||||
|
||||
### 2-2. 테스트 밖 엣지 — 실제 콜백에 직접 투입 (HEAD / 수정 전)
|
||||
|
||||
| 입력 | 수정 전 | HEAD |
|
||||
|---|---|---|
|
||||
| `data`가 비어있지 않은 list / str | **`AttributeError` 콜백 탈출** | 정상 수신(intent 없음 취급) |
|
||||
| intent의 `event` 필드가 dict(중첩 해시 불가) | **`TypeError` 콜백 탈출** | 드롭+경고, `last_seq` 불변 |
|
||||
| 같은 wire seq QoS1 재전달(+intent) | 1건 | 1건 |
|
||||
| 거부된 replay(seq 9) 뒤 바닥(1)보다 크고 9보다 작은 정상 이벤트(seq 4) | 수신 | 수신 |
|
||||
| 필드가 전부 없는 intent `{}` 두 번 | 1건 수신, `(None,None,None)` 기록 | 동일 (O-1 참조) |
|
||||
|
||||
`data`가 비객체인 경우는 task.md가 주장하지 않은 **추가 탈출 경로**인데 이번 수정으로 함께 닫혔다(paho 네트워크 스레드에서 콜백 예외는 해당 구독자의 모든 job 수신을 끊으므로 실제 가치가 있다).
|
||||
|
||||
### 2-3. 종단 테스트의 전제 확인
|
||||
`test_r4b_a_real_retry_...`는 도달 불가 브로커(127.0.0.1:65432)로 `notifier.flush` 2회 후 감사 로그의 `published` 항목 2건을 실제 콜백에 투입한다. `publish_event.record_outcome`이 발행 성공 여부와 무관하게 `published` 감사 항목을 기록(G-1/G-2/G-4)함을 코드로 확인했으므로 전제가 성립하며, 두 항목의 wire seq가 다르고 `notification_intent`가 같음 → 1건만 큐에 남는다.
|
||||
|
||||
### 2-4. 린트·정합성
|
||||
- `py_compile` OK. pyflakes/ruff는 venv에 없고 저장소에 line-length 설정도 없음(pyproject/setup.cfg/ruff.toml 부재). 신규 줄 중 `job_subscriber.py:161`(102자)이 100자를 넘지만 기존 줄(98·170·227·228)도 이미 넘으므로 관례 위반 아님.
|
||||
- 테스트 실행 후 `git status` clean(샌드박스에 격리됨). HEAD 수집 총계 732 = 726 + 6.
|
||||
|
||||
## 3. 판단
|
||||
|
||||
**결함 없음(blocking).** 지적된 순서 역전은 "대조는 가드 앞, 기록은 가드 뒤"로 정확히 고쳐졌고, 그 논리(중복은 시퀀스를 소비할 자격이 없고, 전달 안 된 이벤트는 본 것이 아니다)가 코드 주석·계약 문서·테스트에 일관되게 적혀 있다. 방어적 파싱 강화는 콜백 예외 탈출 경로 3가지(비객체 intent, 해시 불가 필드, 비객체 `data`)를 닫았다.
|
||||
|
||||
## 4. 비차단 관찰 (다음 라운드/Phase 3 참고)
|
||||
|
||||
- **O-1. 식별 필드가 전부 없는 intent는 "안정된 신원"이 아니다.** `{}`나 `{"foo":1}`은 identity `(None,None,None)`을 얻어 **첫 번째만 수신**되고 이후 같은 꼴은 모두 replay로 드롭된다. 정상 발행자(`notifier.intent_for`)는 세 필드를 항상 채우므로 실제 경로에서는 발생하지 않고, 발생하더라도 방향이 fail-closed(드롭)라 이중 적용은 없다. 다만 phase2b-autocomplete.md의 새 문장 "안정된 신원을 만들 수 없는 intent는 거부한다"와 코드가 정확히 맞으려면 `identity` 생성 직후 `if any(f is None for f in identity): 드롭+경고` 한 줄이면 된다. **검증**: HEAD 워크트리에 이 4줄을 적용하고 phase2b·phase1 테스트 131 passed, `{}`/`{"foo":1}`는 드롭되고 정상 intent는 1회 수신됨을 확인.
|
||||
- **O-2. task.md P2B-07 문구의 개수.** "신규 회귀 6종(3종 실패 역검증, 3종 보존) + 종단 테스트"로 읽히지만 종단 테스트는 6종에 **포함**된다(`test_r4b_` 함수는 정확히 6개, 보존 3종 중 하나가 종단 테스트). "6종(그중 1종은 종단)"으로 고치면 된다.
|
||||
- **O-3. 운영 노트.** 동결 스냅샷 원리상 현재 실행 중인 루프의 구독자는 수정 전 코드다. 이 라운드의 알림 재시도가 stale-first 상황을 만나면 옛 결함이 재현될 수 있으나, 재시작 후 디스크 폴백 + 멱등 ACK가 종단을 보증한다(기존 계약). 조치 불필요, 인지만.
|
||||
- 이전 라운드 인계 사항(O-7 direct 잡의 `claim` 토큰 발급, `_delegate_py_bin` 앵커링, redispatch 시 shadow dispatch 갱신, `preexec_fn`)은 이번 diff 범위 밖이며 그대로 유효하다.
|
||||
|
||||
## 5. 전체 스위트
|
||||
|
||||
`.venv/bin/python -m pytest tests -q -p no:cacheprovider` (백그라운드 1회, 정상 종료): **732 passed in 802.02s (0:13:22)**, FAILED/ERROR 0건. 이전 라운드 726 → 732 (+6, 신규 `test_r4b_*` 6종). 이번에는 이전 라운드(19ac4c53)에서 있었던 비정상 종료가 재현되지 않았다.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Issue #5 Phase 0 final revision review
|
||||
|
||||
Job: 1bd561e6
|
||||
Reviewer: planner-reviewer-codex-01 (planner,reviewer)
|
||||
Scope: commit 19d798b and the current five-file documentation/contract-test diff.
|
||||
|
||||
No blocking findings remain in the requested revision. No supplementary implementation edits were necessary.
|
||||
|
||||
## Findings resolved
|
||||
|
||||
- **Previous Codex P1:** Observed evidence now dispatches through explicit supported source kinds. Event/filesystem/harness observations fail closed until independent evidence schemas exist; their simulated fixtures remain accepted. Herdr evidence must pass the native validator, contain mandatory agent_status, match every declared native field, and correspond to the scenario session identity. Current additions also check pane/backend identity, evidence-root confinement, repeated capture citations, and manifest/file agreement.
|
||||
- Independently reran the exact prior finalized_candidate mutation that cited a Claude idle capture for every observed item. It now raises AssertionError at the first event item because no observed event schema exists. This closes the reproduced false-evidence path.
|
||||
- **Claude Finding 2:** baseline.md now cites registry.py:38 for VALID_STATUSES. Verified that definition directly.
|
||||
- Existing native/composite separation and simulated positive scenarios are preserved. The changes remain Phase 0 fixture validation and documentation, not a claim that the future production watcher or complete execution-attribution mechanism is implemented.
|
||||
|
||||
## Verification
|
||||
|
||||
- `./.venv/bin/python -m pytest tests/test_issue5_phase0_contracts.py -q`: **41 passed in 0.02s**.
|
||||
- `./.venv/bin/python -m pytest --collect-only -q`: **510 tests collected**.
|
||||
- Independent previous-exploit reproduction: **rejected**.
|
||||
- `git diff --check`: clean.
|
||||
- `git diff -- .agents/skills`: empty; current changes are four contract documents and one test file.
|
||||
- Full-suite evidence in task.md reports **499 passed in 848.90s** at 19d798b, followed by the current 41 contract tests after the test/document-only additions. This reviewer independently ran the current focused tests and collection, not another full suite.
|
||||
|
||||
Nonblocking documentation note: interpret the roadmap's 510-test row as its acceptance target; task.md correctly distinguishes the 499-test full run from current 510-test collection and 41-test focused execution. It should not be quoted as an independently executed 510-test full run.
|
||||
|
||||
This PASS applies to the reviewed Phase 0 corrections. Future runtime/CAS/rollback implementation remains subject to the staged gates already documented.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Issue #5 Phase 1 final cross-review
|
||||
|
||||
Job: 714a1844
|
||||
Reviewer: planner-reviewer-codex-01 (planner,reviewer)
|
||||
Reviewed tree: refactor / 6ad6f08, including cumulative Phase 1 work and correction 95f3e9a.
|
||||
|
||||
No blocking findings remain. The brief's older 54-new/564-total counts describe an earlier revision; the current Phase 1 test file contains 66 passing cases. This review evaluates the current corrected tree and supplied cumulative diff, not the superseded implementation alone.
|
||||
|
||||
## Assessment
|
||||
|
||||
- R1 and R2 remain resolved as established in `.mam/jobs/99a21e70/codex-reports/report-final.md`: explicit fresh-process import bootstrap preserves cwd and selects the helper's tree; immutable connection-identity snapshots reject changed credentials/TLS while preserving compatible connection reuse.
|
||||
- The source-kind/package-binding and one-shot/persistent transport review conclusions are unchanged. F-1's disconnected-publish handling, corrected fake message-info behavior, and consolidated one-shot implementation remain covered by the Phase 1 tests.
|
||||
- Compared 95f3e9a to current HEAD directly: only two documents changed. The benchmark now correctly names `_delegation_py` rather than `-m`, and task.md records the frozen interpreter lookup issue as a Phase 3 prerequisite. These do not introduce runtime changes. The latter remains a future integration constraint, not a claim that all possible frozen helper environments already work without a configured interpreter.
|
||||
- The prior four disclosed implementation deviations retain the Round 2 assessment. In particular, preexec_fn must be replaced before adopting daemon spawning in a multithreaded engine; fork reduction was not achieved and is accurately documented.
|
||||
- Direct cumulative diff confirms run_loop.sh is unchanged and reconcile.sh contains only the explicit environment-initialization addition. The wrapper/status mapping invariant tests pass. No automatic-completion policy change was found.
|
||||
|
||||
## Verification
|
||||
|
||||
- `./.venv/bin/python -m pytest tests/test_issue5_phase1_delegation.py -q`: **66 passed in 10.28s**.
|
||||
- `git diff --check fc0bd96..HEAD`: clean.
|
||||
- `git status --short`: clean before writing this report.
|
||||
- Reviewed the supplied brief, current commit history, latest documentation diff, and cumulative automatic-completion surface diff.
|
||||
- Earlier independent R1/R2 reproductions and subsequent correction verification are recorded in jobs fd57d344 and 99a21e70. No redundant reproduction or production modification was necessary for the newer documentation-only commit.
|
||||
- Creator's current full-suite evidence is **576 passed / 0 failed / 0 skipped**; this reviewer reran the 66 Phase 1 tests, not the complete suite.
|
||||
|
||||
This is Codex's PASS for the reviewed Phase 1 submission. Other reviewers' verdicts and loop-level unanimous acceptance are determined by the orchestrator. No escalation or supplementary implementation change is required by this review.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Issue #5 Phase 2b final correction review
|
||||
|
||||
Job: a7f1ee0e
|
||||
Reviewer: planner-reviewer-codex-01 (planner,reviewer)
|
||||
Reviewed HEAD: 8d7e010; correction relative to 9a651cc.
|
||||
|
||||
The remaining R4 ordering defect is resolved. No blocking findings in the requested correction; no supplementary edits or escalation required.
|
||||
|
||||
## Assessment
|
||||
|
||||
- `_Watcher.on_message` now adds an intent to seen_intents only after the wire sequence passes the monotonic check. A rejected stale packet cannot suppress a subsequent valid retry.
|
||||
- Existing accepted-intent duplicate rejection remains intact.
|
||||
- Non-object intent values and unhashable identity components are rejected without escaping the callback. Access to data is guarded by an object-type check.
|
||||
- The change is confined to subscriber validation, tests and documentation. Previously reviewed dispatch-token/CAS, manifest verification and notification settlement fixes remain unchanged.
|
||||
|
||||
## Verification
|
||||
|
||||
- `./.venv/bin/python -m pytest tests/test_issue5_phase2b_autocomplete.py -q`: **65 passed in 1.28s**.
|
||||
- Independent real-subscriber probe: initialize last_seq=10; send the same intent with wire sequences 9, 11, 12. Result: **one queued event, last_seq=11**. Thus stale-first rejection, fresh-retry acceptance and subsequent duplicate rejection all hold together.
|
||||
- `git diff --check 9a651cc..HEAD`: clean.
|
||||
- Working tree clean before writing the assigned report; no production or fixture files modified by this reviewer.
|
||||
- Full preexisting suite was not rerun in this review. This verdict addresses the requested correction and carries forward the prior resolved findings; it does not claim independent full-suite or other-reviewer approval.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,26 @@
|
||||
# Issue #5 Phase 2a Round 3 review
|
||||
|
||||
Job: d2e705a8
|
||||
Reviewer: planner-reviewer-codex-01 (planner,reviewer)
|
||||
Reviewed HEAD: 0e74aa1, focusing on changes after 3b134e3.
|
||||
|
||||
No blocking findings remain in the reviewed corrections. Previous R1 and R4 are resolved. No supplementary implementation edits or architectural escalation are required.
|
||||
|
||||
## Resolution
|
||||
|
||||
- **R1 — resolved:** policy now requires all four observed attribution fields plus a report manifest attempt before approval, then checks their correspondence. The approved synthetic fixture supplies collector_attribution separately from native Herdr fields. Deletion tests cover each identifier and the manifest. Independently repeated the prior all-evidence-removed mutation: result is now hold with attribution_ok=False. The complete fixture still returns candidate_approved.
|
||||
- **R4 — resolved:** update_logged_status holds a dedicated per-job audit lock across read/compare/write and uses a unique mkstemp file. The lock remains outside the registry transaction. Reviewed and ran the overlapping-flush regression: a competing flush is blocked while the first holds the lock, then completes with the newer seq2/completed state preserved. This addresses the actual interleaving rather than merely sequential call order.
|
||||
- Earlier missing-attempt and metadata-status bypass corrections remain intact. Operator-authority transitions and the rollback branch retain their documented roles.
|
||||
- The additional idempotent flag distinguishes an already-applied terminal ACK from a refused transition, so the operator CLI returns success for that no-op. Invalid status vocabulary is checked before either CAS or rollback writes. Relevant regression cases pass.
|
||||
- ShadowRunner remains separate from the transition engine; these changes do not introduce automatic completion commits. Durable notification recovery and production automatic completion remain Phase 2b work.
|
||||
|
||||
## Validation
|
||||
|
||||
- `./.venv/bin/python -m pytest tests/test_issue5_phase2a_loop.py tests/test_issue5_phase0_contracts.py -q`: **129 passed in 2.01s**.
|
||||
- Independent complete-fixture and missing-evidence probes: **candidate_approved** and **hold**, respectively.
|
||||
- `git diff --check 3b134e3..HEAD`: clean.
|
||||
- Reviewed policy, collector envelope fixture, audit critical section, engine/CLI correction diff, and new concurrency/deletion regressions.
|
||||
- Working tree was clean before the assigned report was written. No production or fixture files modified by the reviewer.
|
||||
- This reviewer did not rerun all 576 preexisting tests; the independent execution evidence here is the combined 129-test contract/Phase 2a suite. PASS is the review verdict, not a claim of an independently repeated full-suite run or unanimous approval from other reviewers.
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,103 @@
|
||||
# Issue #5 Phase 2a cross-review (`9389d7e`..`0e74aa1`, incl. Round 2 `cb575fa`)
|
||||
|
||||
- **Reviewer**: `reviewer-creator-grok-01`
|
||||
- **Job**: `3f0784c4`
|
||||
- **HEAD**: `0e74aa1` (`docs(issue-5): record the evidence requirement, the audit lock and the ack rule`) on `cb575fa` (`fix(issue-5): address phase 2a round 2 findings (F-C, F-D, R1, R4)`)
|
||||
- **Tests this turn**: `pytest tests/test_issue5_phase2a_loop.py tests/test_issue5_phase0_contracts.py tests/test_issue5_phase1_delegation.py tests/test_version_consistency.py` → **197 passed** (13.06s). Phase 2a file alone: **88 collected**. Collect-only **664**. Full 664 suite not re-run. `git diff --check 6ad6f08..HEAD` clean.
|
||||
|
||||
Round-2 findings were re-read in the live sources, not taken from the creator’s notes. Each of F-C, F-D, R1, R4 has a production path and a regression that fails on the pre-fix body.
|
||||
|
||||
---
|
||||
|
||||
## 1. Module split (`P2A-01` placement)
|
||||
|
||||
Plan said `lib_py/loop/engine.py`. Implementation is **`lib_py.delegation.engine`**. That is the freeze-boundary correction: a frozen loop binds `lib_py.delegation` to a different tree than the parent package, so delegation importing a sibling would mix trees. Phase 1’s AST guard would have failed the planned placement. `loop/__init__.py` states the engine lives with the writers. Acceptable plan deviation, not 유실 (Codex `.agents/reports/implementation_plan.md` and root NATS plan untouched).
|
||||
|
||||
`loop/` holds watcher / policy / shadow / store / attribution / evidence. `test_g9_loop_package_imports_only_delegation_among_siblings` holds.
|
||||
|
||||
---
|
||||
|
||||
## 2. CAS boundary (`P2A-01`)
|
||||
|
||||
`engine.transition` is the only status writer in CAS mode. `_decide` is pure. Lock covers load + decide + one `_atomic_write_record` + queue; `_flush` is **after** release (`engine.py:219-255`). `transition_seq` is incremented only on commit. `test_g5_no_blocking_io_while_the_registry_lock_is_held` spies subprocess/socket/sleep. `test_g6_every_record_writer_is_the_engine_or_a_declared_exception` allows only `register_job`, `next_seq`, and the two `_*_legacy` bodies.
|
||||
|
||||
`MAM_ENGINE_CAS=0` restores `_update_job_status_legacy` / `_pick_pending_legacy`. X-01..X-04 parametrize `"0"`/`"1"`.
|
||||
|
||||
`STATUS_TO_REQUEST` maps a merged `status=` onto the request that owns it, so a raw field cannot skip the table. Unknown requests are refused. `status` riding on `progress`/`merge` is refused (`test_r3_*`). `expected_attempt` with no record field fail-closes (`test_r2_*`).
|
||||
|
||||
Operator requests `redispatch` / `finalize` are gated on `OPERATOR_SOURCES = ("registry_cli", "operator")`. Redispatch must advance `iteration`. Wrapper `_operator_write` uses `_source="registry_cli"`. `publish_event` uses `_source="publish_event"` and cannot leave a terminal (`test_fa_*`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Round 2 findings — independently confirmed
|
||||
|
||||
### F-C (blocking) — idempotent ACK is not a refusal
|
||||
|
||||
`TransitionResult.idempotent` is a distinct slot from `conflict`. Same-terminal re-receipt sets `idempotent=True`, `commit=False`, no second write (`engine.py:163-167`, `test_fc_engine_marks_an_idempotent_result`).
|
||||
|
||||
`_operator_write` (`registry.py:329-366`):
|
||||
- committed → rc 0
|
||||
- idempotent → rc 0 (the wrapper’s `status --set completed` on an already-completed record under `set -e`)
|
||||
- genuine conflict → stderr `refused: …`, rc 4
|
||||
|
||||
`test_fc_idempotent_finalize_is_success_not_refusal` covers both CAS modes. `test_fc_a_genuine_conflict_is_still_reported` keeps cancelled-after-completed at rc 4.
|
||||
|
||||
### F-D — vocabulary check is not skipped
|
||||
|
||||
`_operator_write` checks `VALID_STATUSES` **before** the CAS/legacy branch. Invalid status → rc 1, registry fingerprint unchanged, in both `MAM_ENGINE_CAS=0` and `1` (`test_fd_invalid_status_is_rejected_in_both_modes`). This is a usage error, not a CAS refusal (rc 4).
|
||||
|
||||
### R1 (blocking) — absence of evidence is not approval
|
||||
|
||||
`collect_observed_identity` (`policy.py:33-65`):
|
||||
- identifiers from the `collector_attribution` envelope beside the payload
|
||||
- native **herdr** payload identifiers are **not** collected
|
||||
- non-herdr (event/filesystem) payloads may name the job/attempt
|
||||
- every occurrence is kept (a later contradiction is not dropped)
|
||||
- `report_manifest.attempt_id` is collected separately
|
||||
|
||||
`decide` requires every `ATTRIBUTION_FIELD` to be present **and** agree, then requires a manifest attempt that matches dispatch (`policy.py:115-137`). `finalized_candidate.json` now carries the envelope. Tests: `test_r1_missing_attribution_evidence_holds` (all four fields), `test_r1_missing_report_manifest_holds`, `test_r1_identifiers_inside_a_native_payload_are_not_evidence`, plus the Round-1 contradiction tests.
|
||||
|
||||
### R4 — compare-and-write is one critical section
|
||||
|
||||
`update_logged_status` (`mqtt_common.py:580-617`):
|
||||
- per-job lock `.<name>.lock`, **outside** the registry transaction
|
||||
- read-compare-write under that lock; skip if `existing_seq >= incoming_seq`
|
||||
- `tempfile.mkstemp` per write (no shared `.tmp`)
|
||||
|
||||
`test_r4_overlapping_mirror_flushes_cannot_lose_the_newer_state` intercepts `Path.read_text` after the older flush has read, starts a seq=2 write, asserts the competitor **blocks**, and that `completed` seq=2 survives. `test_r4_each_mirror_write_uses_its_own_temp_file` asserts `mkstemp` in the function body and no leftover temps.
|
||||
|
||||
---
|
||||
|
||||
## 4. Shadow does not write (`P2A-03`)
|
||||
|
||||
`ShadowRunner` is not given `engine`. AST forbids `engine` / `update_job_status` / `transition(` in `shadow.py`. `test_g4_shadow_run_leaves_the_registry_byte_identical` hashes every file under the registry dir before/after `evaluate_all` of all 12 scenarios. Decisions go to `.mam/shadow/<job>.ndjson`. Attribution lives in `.mam/shadow/attribution/`, not the job record (Phase 1 schema freeze). `candidate_approved` is WOULD-COMPLETE log only. Auto-complete is still Phase 2b.
|
||||
|
||||
---
|
||||
|
||||
## 5. Watcher (`P2A-02`)
|
||||
|
||||
`herdr agent wait`: `--timeout` is milliseconds (`timeout_ms`); `--until` always explicit; exit 0 → stdout snapshot; exit 1 → **stderr** (`timeout` / `agent_not_found` healthy; other → `transport_error`). Timeouts do not become `Observation`. Backoff 1→2→4→8→16s; failure **drops** `cached_snapshot`. Tests never invoke a real herdr (`run=` injectable).
|
||||
|
||||
Default `until=("idle","done","blocked")` is intentional observation of `blocked` for `hold_blocked`. `interactive_ready` is captured but is **not** a completion gate (`policy.py` uses `agent_status in {idle,done}` only).
|
||||
|
||||
---
|
||||
|
||||
## 6. Policy vs fixtures (`P2A-04`)
|
||||
|
||||
12 scenarios (Phase 0 seven + `race_double_dispatch`, `attempt_reuse`, `partial_report`, `unknown_status`, `restart_midflight`) all match `expected_policy.decision` (`test_g7_*`). Debounce without manifest → `hold`. Missing execution anchor → `hold`. Policy is I/O-free (`test_policy_is_pure_no_io_no_clock`).
|
||||
|
||||
---
|
||||
|
||||
## Residuals (non-blocking)
|
||||
|
||||
1. **`pick_pending` CAS split lock** (`registry.py:128-144`): select under lock, then `transition` re-acquires. Concurrent pickers for the **same** session with two pending jobs can both select job A; the loser returns `None` even if job B is still pending (until the next call). Sequential two-job pick still works because the second call re-scans. Retry-on-failed-claim would close it. Not typical production (one picker per session). Safety (no double-claim) is preserved by `expected_status="pending"`.
|
||||
2. **`mqtt_common.py:401` docstring** still says ``lib_py.loop.engine``. The module is `lib_py.delegation.engine`.
|
||||
3. **`reject_stale` still reads `pane_process_generation` from the native herdr payload** (`policy.py:88-94`; fixtures `restarted_generation`, `race_double_dispatch`). After R1, that field is not evidence for *approval*, but the stale-rejection path still uses it. If a live collector puts generation only in `collector_attribution`, a stale generation becomes `hold` (missing/mismatch) rather than `reject_stale`. Both refuse completion. Vocabulary only.
|
||||
4. **`test_r1_identifiers_inside_a_native_payload_are_not_evidence`** plants only three of the four identifiers in the native payload (omits `pane_process_generation`). A regression that *did* count native ids would still hold on the missing fourth field. The production skip of herdr-payload ids is real; the test is weaker than its comment.
|
||||
5. Full **664**-test suite not re-run this turn (197 targeted including Phase 0/1). Brief’s “576” is the Phase 1 baseline; collection has grown with Phase 2a (88) plus Round-2 regressions.
|
||||
|
||||
No `[ESCALATE: PLANNER]`. Engine placement remains a freeze-rule correction. Round-2 fixes are surgical and tested.
|
||||
|
||||
---
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,50 @@
|
||||
# Issue #5 Phase 2b Round 3 review (`8d7e010`)
|
||||
|
||||
- **Reviewer**: `reviewer-creator-grok-01`
|
||||
- **Job**: `7e7eea07`
|
||||
- **HEAD**: `8d7e010` (`fix(issue-5): record an accepted intent, not a refused one`)
|
||||
- **Prior Grok residual**: `0d502d33` item 1 — `seen_intents` recorded before the seq check
|
||||
- **Tests this turn**: `pytest tests/test_issue5_phase2b_autocomplete.py tests/test_issue5_phase2a_loop.py` → **156 passed**. Phase 2b file: **65 collected**. Collect-only **732**. `git diff --check 9a651cc..HEAD` clean.
|
||||
|
||||
The live callback was re-read and the stale-first probe from `0d502d33` was re-run against this tree.
|
||||
|
||||
---
|
||||
|
||||
## Adjudication
|
||||
|
||||
[ADJUDICATION: SUSTAINED]
|
||||
|
||||
`job_subscriber.py:143-179` now splits the two operations:
|
||||
|
||||
1. **Lookup** of `identity in seen_intents` still runs *before* the seq guard, so a replay with a fresh wire seq is dropped.
|
||||
2. **`seen_intents.add`** runs *after* `seq > last_seq` and after `last_seq` is advanced. A refused packet does not mark its intent as accepted.
|
||||
|
||||
Malformed `notification_intent` (non-object, or unhashable field) is dropped inside defensive parsing with a warning; `TypeError` does not escape the MQTT callback.
|
||||
|
||||
Required regression `test_r4b_a_stale_packet_does_not_burn_the_intent_for_its_own_retry`: `last_seq=10`, seq=9 refused, same intent at seq=11 accepted, `qsize()==1`, a further replay stays at 1.
|
||||
|
||||
**Reproduced this turn** (same probe that failed on `9a651cc`):
|
||||
|
||||
```
|
||||
stale seq=9 last=10 -> qsize=0 seen=set() last=10
|
||||
retry seq=11 same intent -> qsize=1
|
||||
replay seq=12 -> qsize=1
|
||||
unhashable: raised=False qsize=0 last=0
|
||||
```
|
||||
|
||||
Also covered: refused replay does not raise `last_seq` (`test_r4b_a_duplicate_intent_does_not_advance_the_sequence_floor`); end-to-end two `notifier.flush` attempts with distinct wire seqs land once on the real callback (`test_r4b_a_real_retry_is_deduplicated_by_the_real_consumer`).
|
||||
|
||||
---
|
||||
|
||||
## Residuals (unchanged, not this patch)
|
||||
|
||||
1. No production caller of `evaluate` / `recover` (Phase 3; §2-1).
|
||||
2. `started` does not mint `dispatch_token`.
|
||||
3. Bookkeeping may write any `pending_notification` value, not only `None`.
|
||||
4. `pick_pending` split-lock.
|
||||
|
||||
No `[ESCALATE: PLANNER]`. This is the local order fix the residual asked for.
|
||||
|
||||
---
|
||||
|
||||
[VERDICT: PASS]
|
||||
@@ -0,0 +1,42 @@
|
||||
# Issue #5 Phase 1 review (`265a1f6`..`6ad6f08`, HEAD)
|
||||
|
||||
- **Reviewer**: `reviewer-creator-grok-01`
|
||||
- **Job**: `c4f0ffc7`
|
||||
- **HEAD**: `6ad6f08` (docs residuals) on top of `95f3e9a` (R1/R2/F-1)
|
||||
- **Tests this turn**: `pytest tests/test_issue5_phase1_delegation.py tests/test_version_consistency.py` → **68 passed**. `git diff --check fc0bd96..HEAD` clean.
|
||||
|
||||
---
|
||||
|
||||
## Code (R1 / R2 / F-1 / O-1)
|
||||
|
||||
Already sustained in job `8d4e0e0e`. Re-checked on HEAD:
|
||||
|
||||
- **R1**: `_delegation_py` (`lib.sh:1960-1967`) uses `python -c` + `sys.path.insert(0, SKILL_DIR)`, not `-m`. cwd unchanged. `test_r1_*` still in the 68.
|
||||
- **R2**: `_fingerprint` is the full `BrokerConfig` field set; mismatch names fields, not values.
|
||||
- **F-1**: `_send` maps only `MQTT_ERR_NO_CONN` → `ConnectionError`; retry tuple unchanged. `FakeInfo.rc` matches paho.
|
||||
- **O-1**: `_publish_once` → `OneShotPublisher.publish_raw`.
|
||||
|
||||
Loop still submits via `delegate_job_safe` (wrapper), not `_delegation_py`. Auto-complete: `run_loop.sh` untouched in this fix series.
|
||||
|
||||
---
|
||||
|
||||
## Residuals from Round 2 — closed or parked
|
||||
|
||||
| Item | Status |
|
||||
|---|---|
|
||||
| Benchmark fork table still said `-m` | **Closed** in `6ad6f08`. Table names `_delegation_py` → `python -c` + insert; note explains why `-m` was dropped. |
|
||||
| Freeze `_delegate_py_bin` walks to system python (no paho) | **Parked on P3-02** (`task.md:94`). Accurate: freeze dir has no `.venv`; Phase 1 production path is the wrapper, not frozen helpers. Must fix when Phase 3 calls those helpers from freeze `lib.sh`. |
|
||||
|
||||
---
|
||||
|
||||
## Round 1 Grok error
|
||||
|
||||
`PYTHONPATH=$SKILL_DIR` + `python -m` is **not** B-13. `-m` puts cwd first. R1 fix is the real guarantee.
|
||||
|
||||
[ADJUDICATION: SUSTAINED] on R1/R2/F-1.
|
||||
|
||||
No `[ESCALATE: PLANNER]`.
|
||||
|
||||
---
|
||||
|
||||
[VERDICT: PASS]
|
||||
+71
-4
@@ -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.2.0"
|
||||
MAM_VERSION="4.3.0"
|
||||
export MAM_VERSION
|
||||
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.mam/agent-sessions.yaml}"
|
||||
|
||||
@@ -1873,7 +1873,7 @@ _delegate_script() {
|
||||
# delegate_submit_job <prompt> <agent> <agent_session>
|
||||
#
|
||||
# Register a job in the multi-agent-mux-delegate-job registry. Prints the new JID on stdout.
|
||||
delegate_submit_job() {
|
||||
_delegate_submit_job_legacy() {
|
||||
local prompt="$1" agent="$2" session="$3"
|
||||
local py_bin registry_py
|
||||
py_bin="$(_delegate_py_bin)"
|
||||
@@ -1894,7 +1894,7 @@ delegate_submit_job() {
|
||||
# inline .venv-walk + publish_event.py blocks that were duplicated across
|
||||
# create/delete/resume (review item 7). Non-fatal by contract: an empty job id,
|
||||
# a missing script, or a broker failure never aborts the caller.
|
||||
delegate_publish_event() {
|
||||
_delegate_publish_event_legacy() {
|
||||
local job_id="$1" event="$2" detail="${3:-}"
|
||||
[ -n "$job_id" ] || return 0
|
||||
local py_bin pub
|
||||
@@ -1909,7 +1909,7 @@ delegate_publish_event() {
|
||||
# The watchdog re-spawns the subscriber every 2 minutes (or whatever hard
|
||||
# limit we set) and exits automatically when the JOB reaches terminal state.
|
||||
# Returns the watchdog PID via stdout.
|
||||
start_watchdog() {
|
||||
_start_watchdog_legacy() {
|
||||
local job_id="$1"
|
||||
local workdir="${2:-$PWD}"
|
||||
local monitor_script="$workdir/.agents/skills/multi-agent-mux-monitor/scripts/reconcile.sh"
|
||||
@@ -1937,6 +1937,73 @@ start_watchdog() {
|
||||
echo "$pid"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Python-backed delegation helpers (issue #5 phase 1).
|
||||
#
|
||||
# The bodies above are kept verbatim as _*_legacy and are still reachable with
|
||||
# MAM_DELEGATION_PY=0, which is the instant rollback switch: no redeploy, no
|
||||
# revert. They are removed in phase 4, once the python path has soaked.
|
||||
#
|
||||
# PYTHONPATH is passed explicitly (same pattern as env_python) so `-m` resolves
|
||||
# lib_py from the tree this lib.sh belongs to -- that is what makes a frozen
|
||||
# loop run frozen delegation code.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# _delegation_py <cli-args...>
|
||||
# Run the delegation CLI from *this* tree.
|
||||
#
|
||||
# NOT `python -m`: for -m, sys.path[0] is the current directory, which therefore
|
||||
# beats PYTHONPATH. A frozen loop whose cwd happened to contain a lib_py package
|
||||
# would silently execute live code, losing B-13. An explicit insert of SKILL_DIR
|
||||
# cannot be shadowed that way. cwd is deliberately left alone, because the
|
||||
# registry and audit-log paths are relative to it.
|
||||
_delegation_py() {
|
||||
PYTHONPATH="$SKILL_DIR:${PYTHONPATH:-}" "$(_delegate_py_bin)" -c '
|
||||
import sys
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
from lib_py.delegation.cli import main
|
||||
sys.exit(main(sys.argv[2:]))
|
||||
' "$SKILL_DIR" "$@"
|
||||
}
|
||||
|
||||
# delegate_submit_job <prompt> <agent> <agent_session>
|
||||
# Register a job. Prints the new JID on stdout and nothing else.
|
||||
delegate_submit_job() {
|
||||
if [ "${MAM_DELEGATION_PY:-1}" = "0" ]; then _delegate_submit_job_legacy "$@"; return $?; fi
|
||||
local prompt="$1" agent="$2" session="$3"
|
||||
local registry_py
|
||||
registry_py="$(_delegate_script registry.py)"
|
||||
if [ -z "$registry_py" ] || [ ! -f "$registry_py" ]; then
|
||||
echo "ERROR: multi-agent-mux-delegate-job registry.py not found under .agents/skills/" >&2
|
||||
return 1
|
||||
fi
|
||||
_delegation_py submit --prompt "$prompt" --agent "$agent" --agent-session "$session"
|
||||
}
|
||||
|
||||
# delegate_publish_event <job_id> <event> [detail]
|
||||
# Non-fatal by contract: an empty job id, a missing script tree, or a broker
|
||||
# failure must never abort the caller.
|
||||
delegate_publish_event() {
|
||||
local job_id="$1" event="$2" detail="${3:-}"
|
||||
[ -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
|
||||
}
|
||||
|
||||
# ensure_subscriber_daemon <job_id> [workdir]
|
||||
# Start the monitor subscriber for this workspace unless one is already running.
|
||||
# Prints the PID(s) on stdout, exactly as start_watchdog always did.
|
||||
ensure_subscriber_daemon() {
|
||||
if [ "${MAM_DELEGATION_PY:-1}" = "0" ]; then _start_watchdog_legacy "$@"; return $?; fi
|
||||
_delegation_py ensure-daemon --job "$1" --workdir "${2:-$PWD}"
|
||||
}
|
||||
|
||||
# start_watchdog — retained name; create_session.sh and operator docs use it.
|
||||
start_watchdog() { ensure_subscriber_daemon "$@"; }
|
||||
|
||||
# wait_for_tui_ready <session_name> <agent>
|
||||
# Waits up to 30 seconds for the agent's TUI to render its welcome screen.
|
||||
# Returns 0 on readiness, 1 on timeout, 2 on unobservable/headless.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Delegation backplane: job registry, MQTT publish/subscribe, and the
|
||||
subscriber daemon.
|
||||
|
||||
The implementation moved here from
|
||||
``multi-agent-mux-delegate-job/scripts/*.py`` in issue #5 phase 1. Those script
|
||||
paths are a wire contract (agent instructions, the bash wrapper, SKILL.md,
|
||||
BOOTSTRAP.md, deploy/install.sh and three test modules call them by literal
|
||||
path), so they remain as shims that bind *this package* to the tree next to
|
||||
themselves, once per process, without touching ``sys.path``.
|
||||
|
||||
Package rules, enforced by tests:
|
||||
* modules here import each other as ``from lib_py.delegation import x`` --
|
||||
never by top-level name, which would collide with ``lib_py.agents.registry``.
|
||||
* nothing here imports another ``lib_py`` module. Under a frozen loop the
|
||||
parent ``lib_py`` may resolve to a different tree than this package, so a
|
||||
sibling import would silently mix trees.
|
||||
"""
|
||||
|
||||
from lib_py.delegation.daemon import ensure_subscriber_daemon # noqa: E402,F401
|
||||
from lib_py.delegation.service import DelegationService # noqa: E402,F401
|
||||
|
||||
__all__ = ["DelegationService", "ensure_subscriber_daemon"]
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Allow `python -m lib_py.delegation` as a synonym for the cli router."""
|
||||
import sys
|
||||
|
||||
from lib_py.delegation.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Benchmark harness for the persistent-connection prototype.
|
||||
|
||||
Two transports, deliberately separated:
|
||||
|
||||
--transport fake a stub client with a synthetic connect latency. Fully
|
||||
deterministic, touches no network, and is what CI asserts
|
||||
on: N publishes cost N connections one-shot and exactly 1
|
||||
persistent.
|
||||
--transport broker the real broker named by the job record. Informational
|
||||
only: the PoC default is a public broker whose CONNACK
|
||||
latency is not ours to control, so no gate depends on it.
|
||||
|
||||
Usage:
|
||||
python -m lib_py.delegation.bench --n 50 --transport fake [--connect-latency-ms 50]
|
||||
[--job <id>] [--registry-dir DIR] [--out FILE]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
from lib_py.delegation import mqtt_common, publish_event
|
||||
from lib_py.delegation.publisher import OneShotPublisher, PersistentPublisher
|
||||
|
||||
|
||||
class FakeInfo(object):
|
||||
"""Stand-in for paho's MQTTMessageInfo, including its failure contract.
|
||||
|
||||
paho carries an ``rc`` and raises RuntimeError from ``wait_for_publish``
|
||||
when the publish could not be handed to a live connection. The earlier
|
||||
version of this fake had neither, which is precisely why a real defect in
|
||||
that path stayed invisible while every test passed.
|
||||
"""
|
||||
|
||||
def __init__(self, published=True, rc=0):
|
||||
self._published = published
|
||||
self.rc = rc
|
||||
|
||||
def wait_for_publish(self, timeout=None):
|
||||
if self.rc:
|
||||
raise RuntimeError(
|
||||
"Message publish failed: The client is not currently connected.")
|
||||
return None
|
||||
|
||||
def is_published(self):
|
||||
return self._published
|
||||
|
||||
|
||||
class FakeClient(object):
|
||||
"""Minimal paho stand-in: counts connects and charges a synthetic latency."""
|
||||
|
||||
def __init__(self, connect_latency=0.0, counters=None):
|
||||
self.connect_latency = connect_latency
|
||||
self.counters = counters if counters is not None else {}
|
||||
self.on_connect = None
|
||||
|
||||
def connect(self, host, port, keepalive=60):
|
||||
if self.connect_latency:
|
||||
time.sleep(self.connect_latency)
|
||||
self.counters["connect"] = self.counters.get("connect", 0) + 1
|
||||
if self.on_connect:
|
||||
self.on_connect(self, None, None, 0, None)
|
||||
|
||||
def loop_start(self):
|
||||
return None
|
||||
|
||||
def loop_stop(self):
|
||||
return None
|
||||
|
||||
def reconnect_delay_set(self, min_delay=1, max_delay=16):
|
||||
return None
|
||||
|
||||
def publish(self, topic, payload=None, qos=0, retain=False):
|
||||
self.counters["publish"] = self.counters.get("publish", 0) + 1
|
||||
return FakeInfo()
|
||||
|
||||
def disconnect(self):
|
||||
return None
|
||||
|
||||
|
||||
def fake_factory(connect_latency, counters):
|
||||
def factory(_role, _config):
|
||||
return FakeClient(connect_latency=connect_latency, counters=counters)
|
||||
return factory
|
||||
|
||||
|
||||
def _percentile(values: List[float], pct: float) -> float:
|
||||
if not values:
|
||||
return 0.0
|
||||
ordered = sorted(values)
|
||||
idx = int(round((pct / 100.0) * (len(ordered) - 1)))
|
||||
return ordered[idx]
|
||||
|
||||
|
||||
def _run(publisher, prepared, n: int) -> dict:
|
||||
latencies = []
|
||||
failures = 0
|
||||
for _ in range(n):
|
||||
start = time.monotonic()
|
||||
ok, _err = publisher.publish_with_retry(prepared, attempts=1)
|
||||
latencies.append((time.monotonic() - start) * 1000.0)
|
||||
if not ok:
|
||||
failures += 1
|
||||
return {
|
||||
"publishes": n,
|
||||
"connects": getattr(publisher, "connect_count", None),
|
||||
"failures": failures,
|
||||
"latency_ms": {
|
||||
"p50": round(_percentile(latencies, 50), 3),
|
||||
"p95": round(_percentile(latencies, 95), 3),
|
||||
"max": round(max(latencies), 3) if latencies else 0.0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def run_benchmark(n=50, transport="fake", connect_latency_ms=50.0,
|
||||
job_id=None, registry_dir=None) -> dict:
|
||||
registry_dir = registry_dir or mqtt_common.DEFAULT_REGISTRY_DIR
|
||||
|
||||
if transport == "fake":
|
||||
prepared = publish_event.PreparedEvent(
|
||||
job_id="benchjob", event="progress", detail="bench", seq=0,
|
||||
topic="python/mqtt/jobs/benchjob/events", retain=False,
|
||||
payload={"seq": 0}, body=b'{"seq": 0}',
|
||||
config=mqtt_common.broker_config_from_env(),
|
||||
)
|
||||
latency = connect_latency_ms / 1000.0
|
||||
one_counters, persist_counters = {}, {}
|
||||
one = OneShotPublisher(client_factory=fake_factory(latency, one_counters))
|
||||
persist = PersistentPublisher(client_factory=fake_factory(latency, persist_counters))
|
||||
try:
|
||||
result = {
|
||||
"transport": "fake",
|
||||
"connect_latency_ms": connect_latency_ms,
|
||||
"one_shot": _run(one, prepared, n),
|
||||
"persistent": _run(persist, prepared, n),
|
||||
}
|
||||
finally:
|
||||
persist.close()
|
||||
result["one_shot"]["client_connects"] = one_counters.get("connect", 0)
|
||||
result["persistent"]["client_connects"] = persist_counters.get("connect", 0)
|
||||
return result
|
||||
|
||||
if not job_id:
|
||||
raise ValueError("--transport broker requires --job")
|
||||
prepared = publish_event.prepare_event(
|
||||
job_id=job_id, event="progress", detail="bench", registry_dir=registry_dir,
|
||||
)
|
||||
one = OneShotPublisher()
|
||||
persist = PersistentPublisher()
|
||||
try:
|
||||
return {
|
||||
"transport": "broker",
|
||||
"broker": "{}:{}".format(prepared.config.host, prepared.config.port),
|
||||
"one_shot": _run(one, prepared, n),
|
||||
"persistent": _run(persist, prepared, n),
|
||||
}
|
||||
finally:
|
||||
persist.close()
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
mqtt_common.ensure_env_loaded()
|
||||
parser = argparse.ArgumentParser(prog="lib_py.delegation.bench")
|
||||
parser.add_argument("--n", type=int, default=50)
|
||||
parser.add_argument("--transport", default="fake", choices=["fake", "broker"])
|
||||
parser.add_argument("--connect-latency-ms", type=float, default=50.0)
|
||||
parser.add_argument("--job", default=None)
|
||||
parser.add_argument("--registry-dir", default=None)
|
||||
parser.add_argument("--out", default=None)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
result = run_benchmark(
|
||||
n=args.n, transport=args.transport,
|
||||
connect_latency_ms=args.connect_latency_ms,
|
||||
job_id=args.job, registry_dir=args.registry_dir,
|
||||
)
|
||||
text = json.dumps(result, indent=2, sort_keys=True)
|
||||
if args.out:
|
||||
with open(args.out, "w", encoding="utf-8") as fh:
|
||||
fh.write(text + "\n")
|
||||
print(text)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,90 @@
|
||||
"""`python -m lib_py.delegation.cli` — the router the shell helpers call.
|
||||
|
||||
Deliberately thin: every subcommand delegates to the existing `main()` of the
|
||||
module that already owned that behaviour, so exit codes, stdout shape and side
|
||||
effects cannot drift between the shell path and the script path. The one piece
|
||||
of real logic here is `ensure-daemon`, which reproduces the shell helper's
|
||||
stderr wording and exit status for a missing monitor script.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
from typing import List, Optional
|
||||
|
||||
from lib_py.delegation import daemon, mqtt_common, publish_event, registry
|
||||
|
||||
logger = logging.getLogger("delegate_job.cli")
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="lib_py.delegation.cli",
|
||||
description="delegation helpers invoked by lib.sh",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_submit = sub.add_parser("submit", help="register a job; prints the job id")
|
||||
p_submit.add_argument("--prompt", required=True)
|
||||
p_submit.add_argument("--agent", default="claude-code")
|
||||
p_submit.add_argument("--agent-session", default="herdr:claude")
|
||||
p_submit.add_argument("--registry-dir", default=None)
|
||||
|
||||
p_pub = sub.add_parser("publish", help="publish a job lifecycle event")
|
||||
p_pub.add_argument("--job", required=True)
|
||||
p_pub.add_argument("--event", default="progress", choices=publish_event.VALID_EVENTS)
|
||||
p_pub.add_argument("--detail", default="")
|
||||
p_pub.add_argument("--data", default=None)
|
||||
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("-v", "--verbose", action="store_true")
|
||||
|
||||
p_daemon = sub.add_parser("ensure-daemon", help="start the subscriber daemon if absent")
|
||||
p_daemon.add_argument("--job", required=True)
|
||||
p_daemon.add_argument("--workdir", default=None)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
mqtt_common.ensure_env_loaded()
|
||||
args = _build_parser().parse_args(argv)
|
||||
|
||||
if args.command == "submit":
|
||||
forward = ["register", "--prompt", args.prompt,
|
||||
"--agent", args.agent, "--agent-session", args.agent_session]
|
||||
if args.registry_dir:
|
||||
forward = ["--registry-dir", args.registry_dir] + forward
|
||||
return registry.main(forward)
|
||||
|
||||
if args.command == "publish":
|
||||
forward = ["--job", args.job, "--event", args.event,
|
||||
"--detail", args.detail, "--attempts", str(args.attempts)]
|
||||
if args.data is not None:
|
||||
forward += ["--data", args.data]
|
||||
if args.retained:
|
||||
forward += ["--retained"]
|
||||
if args.registry_dir:
|
||||
forward += ["--registry-dir", args.registry_dir]
|
||||
if args.verbose:
|
||||
forward += ["--verbose"]
|
||||
return publish_event.main(forward)
|
||||
|
||||
if args.command == "ensure-daemon":
|
||||
try:
|
||||
pids = daemon.ensure_subscriber_daemon(args.job, args.workdir)
|
||||
except FileNotFoundError as exc:
|
||||
# Byte-identical to the legacy shell helper's failure path.
|
||||
print("ERROR: monitor script not found: {}".format(exc.args[0]), file=sys.stderr)
|
||||
return 1
|
||||
for pid in pids:
|
||||
print(pid)
|
||||
return 0
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Subscriber daemon supervision — the python port of lib.sh's `start_watchdog`.
|
||||
|
||||
Behaviour is byte-for-byte compatible with the shell original (issue #5 phase 1,
|
||||
D5): same monitor path, same stderr wording and exit status when it is missing,
|
||||
same `pgrep -f` pre-check pattern, same log file, same "print the PID" contract.
|
||||
Callers capture stdout with $( ), so nothing else may be printed there.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger("delegate_job.daemon")
|
||||
|
||||
MONITOR_RELPATH = os.path.join(
|
||||
".agents", "skills", "multi-agent-mux-monitor", "scripts", "reconcile.sh"
|
||||
)
|
||||
LOG_RELPATH = os.path.join(".mam", "multi-agent-mux-monitor.log")
|
||||
|
||||
|
||||
def monitor_script_path(workdir: str) -> str:
|
||||
return os.path.join(workdir, MONITOR_RELPATH)
|
||||
|
||||
|
||||
def find_running(monitor_script: str) -> List[str]:
|
||||
"""PIDs of subscribers already running for this monitor script.
|
||||
|
||||
Uses the exact `pgrep -f` pattern the shell helper used, so a daemon started
|
||||
by either implementation is found by the other. Multiple matches are returned
|
||||
in full: the legacy helper echoed every line and callers only ever echo it
|
||||
back, so trimming here would be a silent behaviour change.
|
||||
"""
|
||||
try:
|
||||
res = subprocess.run(
|
||||
["pgrep", "-f", "bash {} --subscribe".format(monitor_script)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
except OSError as exc: # pragma: no cover - pgrep missing
|
||||
logger.warning("pgrep unavailable: %s", exc)
|
||||
return []
|
||||
return [line for line in res.stdout.split() if line.strip()]
|
||||
|
||||
|
||||
def _ignore_sighup(): # pragma: no cover - runs in the forked child
|
||||
"""Match `nohup`: SIG_IGN survives execve, so the daemon outlives its parent."""
|
||||
signal.signal(signal.SIGHUP, signal.SIG_IGN)
|
||||
|
||||
|
||||
def spawn(monitor_script: str, workdir: str) -> int:
|
||||
"""Start the subscriber detached and return its pid.
|
||||
|
||||
One deliberate difference from the shell original: the log directory is
|
||||
created if missing. The legacy `>>` redirect failed outright when `.mam/`
|
||||
did not exist, so this is strictly more robust, not a contract change.
|
||||
"""
|
||||
log_file = os.path.join(workdir, LOG_RELPATH)
|
||||
os.makedirs(os.path.dirname(log_file), exist_ok=True)
|
||||
log_handle = open(log_file, "ab")
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
["bash", monitor_script, "--subscribe", "--idle-timeout", "0"],
|
||||
cwd=workdir,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=log_handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
preexec_fn=_ignore_sighup,
|
||||
)
|
||||
finally:
|
||||
log_handle.close()
|
||||
return proc.pid
|
||||
|
||||
|
||||
def ensure_subscriber_daemon(job_id: str, workdir: Optional[str] = None) -> List[str]:
|
||||
"""Return the PIDs of the running subscriber, starting one if needed.
|
||||
|
||||
`job_id` is accepted for signature compatibility and is deliberately unused:
|
||||
the daemon is per-workspace (log paths anchor on `workdir`), exactly as in
|
||||
the shell original. Raises FileNotFoundError when the monitor is absent so
|
||||
the CLI can reproduce the legacy stderr line and exit 1.
|
||||
"""
|
||||
workdir = workdir or os.getcwd()
|
||||
monitor_script = monitor_script_path(workdir)
|
||||
if not os.path.isfile(monitor_script):
|
||||
raise FileNotFoundError(monitor_script)
|
||||
running = find_running(monitor_script)
|
||||
if running:
|
||||
return running
|
||||
return [str(spawn(monitor_script, workdir))]
|
||||
@@ -0,0 +1,369 @@
|
||||
"""The single state-transition authority.
|
||||
|
||||
Every job-status write goes through `transition`. Before phase 2a there were two
|
||||
write paths: `update_job_status` merged fields unconditionally, and
|
||||
`pick_pending` hand-rolled a correct pending->running swap plus its own audit
|
||||
mirror. Neither was wrong on its own; having two was the risk, because a fix or
|
||||
a rule added to one silently misses the other.
|
||||
|
||||
It lives in `lib_py.delegation`, not `lib_py.loop`, because it writes the
|
||||
registry and the registry writers are here. A frozen loop can bind
|
||||
`lib_py.delegation` to a different tree than the parent package, so delegation
|
||||
importing a sibling would mix trees -- the rule this package states in its own
|
||||
__init__, and which caught this during implementation.
|
||||
|
||||
Lock discipline (the reason this is a module and not a helper):
|
||||
|
||||
with registry_lock: decide, write the record once, QUEUE notifications
|
||||
after the lock: flush notifications, publish, anything slow
|
||||
|
||||
Nothing that can block on another process or the network may run while the lock
|
||||
is held. `pick_pending` already mirrored its audit entries after releasing the
|
||||
lock; this generalises that shape to every transition.
|
||||
|
||||
`MAM_ENGINE_CAS=0` restores the previous unconditional-merge behaviour without a
|
||||
redeploy. The legacy bodies are preserved by their callers for that reason.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from lib_py.delegation import mqtt_common, notifier
|
||||
|
||||
logger = logging.getLogger("loop.engine")
|
||||
|
||||
TERMINAL_STATUSES = ("completed", "error", "cancelled")
|
||||
NON_TERMINAL_STATUSES = ("pending", "running")
|
||||
|
||||
# request -> the status it asks for. `merge`/`progress` ask for nothing.
|
||||
REQUEST_TO_STATUS = {
|
||||
"claim": "running",
|
||||
"started": "running",
|
||||
"progress": None,
|
||||
"permission_required": None,
|
||||
"completed": "completed",
|
||||
"error": "error",
|
||||
"cancelled": "cancelled",
|
||||
"deadline_timeout": "error",
|
||||
"merge": None,
|
||||
# Operator-authority requests. The wrapper drives multi-iteration delegation
|
||||
# (`submit --type loop|discuss`) by re-dispatching a finished job to the next
|
||||
# session, and by finalising it as an error when the iteration budget is
|
||||
# exhausted. Both legitimately move a terminal record, which is why they are
|
||||
# separate requests gated on authority rather than an exception in the table.
|
||||
"redispatch": "pending",
|
||||
"finalize": "error",
|
||||
# Bookkeeping: records that an owed notification was delivered. It cannot
|
||||
# change status, and it is *about* a terminal, so refusing it in a terminal
|
||||
# state would make an owed notification impossible to ever settle.
|
||||
"notification_settled": None,
|
||||
}
|
||||
|
||||
# Requests that carry no status at all and are therefore safe in any state,
|
||||
# including a terminal one. Keep this list short and obviously status-free.
|
||||
BOOKKEEPING_REQUESTS = ("notification_settled",)
|
||||
|
||||
# The only fields a bookkeeping request may write.
|
||||
BOOKKEEPING_FIELDS = ("pending_notification",)
|
||||
|
||||
# Distinguishes "no expectation" from "expect the field to be None".
|
||||
_UNSET = object()
|
||||
|
||||
# An opaque identity for one dispatch attempt, minted when an attempt begins and
|
||||
# stored in the record itself. It lives here, not only in the shadow store,
|
||||
# because a precondition can only be atomic with the commit if the transaction
|
||||
# can read it under its own lock. Comparing the shadow store before the call
|
||||
# leaves a window in which the job is completed, re-dispatched and claimed again,
|
||||
# after which stale evidence satisfies a status check and completes a new
|
||||
# attempt. An opaque token is used rather than the iteration counter because
|
||||
# iteration already means the loop round, and conflating the two produces a guard
|
||||
# that is either vacuous or wrong.
|
||||
DISPATCH_TOKEN_FIELD = "dispatch_token"
|
||||
|
||||
# The requests that begin a new attempt, and therefore mint a new token.
|
||||
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_REQUESTS = ("redispatch", "finalize")
|
||||
|
||||
# A caller that merges `status=<value>` is making the request that owns that
|
||||
# value; mapping it here is what stops a raw field write from sidestepping the
|
||||
# CAS table. Every status has an owning request, `pending` included: the wrapper
|
||||
# resets a finished job to pending to hand it to the next iteration.
|
||||
STATUS_TO_REQUEST = {
|
||||
"pending": "redispatch",
|
||||
"running": "started",
|
||||
"completed": "completed",
|
||||
"error": "error",
|
||||
"cancelled": "cancelled",
|
||||
}
|
||||
|
||||
|
||||
def cas_enabled() -> bool:
|
||||
"""The rollback switch. Read per call so a test or operator can flip it."""
|
||||
return os.environ.get("MAM_ENGINE_CAS", "1") != "0"
|
||||
|
||||
|
||||
class TransitionResult(object):
|
||||
__slots__ = ("committed", "from_status", "to_status", "reason", "record",
|
||||
"notifications", "idempotent")
|
||||
|
||||
def __init__(self, committed, from_status, to_status, reason, record, notifications,
|
||||
idempotent=False):
|
||||
self.committed = committed
|
||||
# Not every uncommitted transition is a refusal. Re-receiving a terminal
|
||||
# that already holds is a no-op ACK in the contract table, and a caller
|
||||
# that treats it as failure turns a success path into an error.
|
||||
self.idempotent = idempotent
|
||||
self.from_status = from_status
|
||||
self.to_status = to_status
|
||||
self.reason = reason
|
||||
self.record = record
|
||||
self.notifications = notifications
|
||||
|
||||
def __repr__(self): # pragma: no cover - debugging aid
|
||||
return "TransitionResult(committed={!r}, {!r}->{!r}, {!r})".format(
|
||||
self.committed, self.from_status, self.to_status, self.reason)
|
||||
|
||||
|
||||
class _Decision(object):
|
||||
__slots__ = ("commit", "to_status", "reason", "conflict", "idempotent")
|
||||
|
||||
def __init__(self, commit, to_status, reason, conflict=False, idempotent=False):
|
||||
self.commit = commit
|
||||
self.to_status = to_status
|
||||
self.reason = reason
|
||||
self.conflict = conflict
|
||||
self.idempotent = idempotent
|
||||
|
||||
|
||||
def _decide(record: Dict[str, Any], request: str, expected_status, expected_attempt,
|
||||
source="unknown", fields=None, expected_notification=_UNSET,
|
||||
expected_dispatch_token=None) -> _Decision:
|
||||
"""Pure CAS decision. No I/O, so it is safe to call under the lock.
|
||||
|
||||
Implements the target table in docs/contracts/issue-5/state-transitions.md §3.
|
||||
"""
|
||||
current = record.get("status")
|
||||
fields = fields or {}
|
||||
|
||||
# An unrecognised request must never fall through to the permissive branch.
|
||||
if request not in REQUEST_TO_STATUS:
|
||||
return _Decision(False, current, "unknown request: {!r}".format(request))
|
||||
|
||||
# A status may only be set by the request that owns it. Letting one ride in
|
||||
# as an ordinary field would let `progress` commit a terminal, which is the
|
||||
# decision table being bypassed from inside its own boundary.
|
||||
if "status" in fields:
|
||||
return _Decision(False, current,
|
||||
"status must be requested, not merged as a field "
|
||||
"(got status={!r} on request {!r})".format(fields["status"], request))
|
||||
|
||||
# A supplied precondition must be *proved*, not merely un-contradicted. If a
|
||||
# caller names an expected attempt and the record cannot show one, the
|
||||
# compare-and-swap has nothing to compare and must fail closed.
|
||||
if expected_attempt is not None:
|
||||
actual = record.get("attempt_id", record.get("iteration"))
|
||||
if actual is None:
|
||||
return _Decision(False, current,
|
||||
"expected attempt {!r} but the record carries none".format(
|
||||
expected_attempt))
|
||||
if actual != expected_attempt:
|
||||
return _Decision(False, current,
|
||||
"stale attempt: expected {!r}, record has {!r}".format(
|
||||
expected_attempt, actual))
|
||||
|
||||
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")
|
||||
|
||||
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,
|
||||
"{} requires operator authority (source={!r})".format(request, source))
|
||||
|
||||
if request in BOOKKEEPING_REQUESTS:
|
||||
# Bookkeeping is allowed in a terminal state precisely because it cannot
|
||||
# change anything of consequence. That has to be enforced here, at the
|
||||
# boundary, and not merely by what today's one caller happens to pass:
|
||||
# otherwise a status-free request becomes an unrestricted merge on a
|
||||
# record nothing else is allowed to touch.
|
||||
extra = sorted(set(fields) - set(BOOKKEEPING_FIELDS))
|
||||
if extra:
|
||||
return _Decision(False, current,
|
||||
"bookkeeping may not merge {}".format(extra),
|
||||
conflict=True)
|
||||
expected = expected_notification
|
||||
if expected is not _UNSET:
|
||||
current_intent = record.get("pending_notification")
|
||||
if current_intent != expected:
|
||||
# A newer intent replaced the one being settled while it was in
|
||||
# flight. Clearing it now would drop a notification nobody sent.
|
||||
return _Decision(False, current,
|
||||
"notification intent changed while it was being sent")
|
||||
return _Decision(True, None, "bookkeeping ({})".format(request))
|
||||
|
||||
if current in TERMINAL_STATUSES:
|
||||
# Idempotent re-receipt of the same terminal is a no-op ack.
|
||||
if requested == current:
|
||||
return _Decision(False, current, "idempotent: already {}".format(current),
|
||||
idempotent=True)
|
||||
|
||||
operator = source in OPERATOR_SOURCES
|
||||
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_iteration = fields.get("iteration")
|
||||
old_iteration = record.get("iteration")
|
||||
if new_iteration is None:
|
||||
return _Decision(False, current,
|
||||
"redispatch requires an iteration", conflict=True)
|
||||
if old_iteration is not None and int(new_iteration) <= int(old_iteration):
|
||||
return _Decision(False, current,
|
||||
"redispatch iteration {!r} does not advance past {!r}".format(
|
||||
new_iteration, old_iteration), conflict=True)
|
||||
return _Decision(True, "pending", "{} -> pending (redispatch)".format(current))
|
||||
|
||||
if operator and request in ("finalize", "error"):
|
||||
return _Decision(True, "error", "{} -> error (finalize)".format(current))
|
||||
|
||||
if requested is None:
|
||||
return _Decision(False, current,
|
||||
"late {} after terminal {}; refusing".format(request, current),
|
||||
conflict=True)
|
||||
return _Decision(False, current,
|
||||
"conflicting {} after terminal {}; refusing".format(requested, current),
|
||||
conflict=True)
|
||||
|
||||
if requested is None:
|
||||
# progress / permission_required / metadata-only merge. No status is set
|
||||
# here: a status-bearing field was already refused above.
|
||||
return _Decision(True, None, "metadata updated ({})".format(request))
|
||||
|
||||
return _Decision(True, requested, "{} -> {}".format(current, requested))
|
||||
|
||||
|
||||
def _notification(job_id, kind, **fields) -> Dict[str, Any]:
|
||||
payload = {"job_id": job_id, "kind": kind}
|
||||
payload.update(fields)
|
||||
return payload
|
||||
|
||||
|
||||
def transition(job_id, request="merge", *, registry_dir=None, expected_status=None,
|
||||
expected_attempt=None, fields=None, source="unknown",
|
||||
notify=None, expected_notification=_UNSET,
|
||||
expected_dispatch_token=None) -> TransitionResult:
|
||||
"""Decide and commit one state transition. The only writer of job status.
|
||||
|
||||
Returns a result whose `notifications` have ALREADY been flushed; the field
|
||||
is kept so callers and tests can see what the transition emitted.
|
||||
"""
|
||||
registry_dir = registry_dir or mqtt_common.DEFAULT_REGISTRY_DIR
|
||||
fields = dict(fields or {})
|
||||
|
||||
# ---- short transaction: decide and write, never do I/O that can block ----
|
||||
with mqtt_common.registry_lock(registry_dir):
|
||||
record = mqtt_common.load_job(job_id, registry_dir)
|
||||
from_status = record.get("status")
|
||||
decision = _decide(record, request, expected_status, expected_attempt,
|
||||
source=source, fields=fields,
|
||||
expected_notification=expected_notification,
|
||||
expected_dispatch_token=expected_dispatch_token)
|
||||
|
||||
notifications: List[Dict[str, Any]] = []
|
||||
if decision.commit:
|
||||
record.update(fields)
|
||||
if decision.to_status is not None:
|
||||
record["status"] = decision.to_status
|
||||
record["updated_at"] = mqtt_common._utcnow()
|
||||
# A monotonic counter so a late flush can still be ordered correctly
|
||||
# once the audit mirror happens outside the lock.
|
||||
record["transition_seq"] = int(record.get("transition_seq", 0) or 0) + 1
|
||||
if request in ATTEMPT_STARTING_REQUESTS:
|
||||
# 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 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
|
||||
# publish, the record still says a notification is owed.
|
||||
record[notifier.FIELD] = notifier.intent_for(
|
||||
notify, record["transition_seq"], record["updated_at"],
|
||||
detail=str(fields.get("detail", "")))
|
||||
mqtt_common._atomic_write_record(job_id, registry_dir, record)
|
||||
|
||||
to_status = record.get("status")
|
||||
if to_status != from_status:
|
||||
notifications.append(_notification(
|
||||
job_id, "status_changed", **{
|
||||
"from": from_status, "to": to_status,
|
||||
"by": source, "timestamp": record["updated_at"],
|
||||
"transition_seq": record["transition_seq"],
|
||||
}))
|
||||
else:
|
||||
to_status = from_status
|
||||
if decision.conflict:
|
||||
notifications.append(_notification(
|
||||
job_id, "conflict_refused",
|
||||
attempted=request, current=from_status,
|
||||
reason=decision.reason, by=source,
|
||||
timestamp=mqtt_common._utcnow()))
|
||||
# ---- lock released; everything below may block ----
|
||||
|
||||
_flush(notifications, registry_dir)
|
||||
if not decision.commit and decision.conflict:
|
||||
logger.warning("refused %s for job %s: %s", request, job_id, decision.reason)
|
||||
|
||||
return TransitionResult(decision.commit, from_status, to_status,
|
||||
decision.reason, record, notifications,
|
||||
idempotent=decision.idempotent)
|
||||
|
||||
|
||||
def _flush(notifications, registry_dir) -> None:
|
||||
"""Mirror queued notifications into the audit log, outside the lock.
|
||||
|
||||
Best effort by design: the registry record is already durable, and an audit
|
||||
write must never be able to fail a transition that has committed.
|
||||
"""
|
||||
for note in notifications:
|
||||
job_id = note["job_id"]
|
||||
kind = note["kind"]
|
||||
try:
|
||||
if kind == "status_changed":
|
||||
mqtt_common.update_logged_status(
|
||||
job_id, note["to"], updated_at=note.get("timestamp"),
|
||||
transition_seq=note.get("transition_seq"))
|
||||
mqtt_common.append_event(job_id, {
|
||||
"event": "status_changed",
|
||||
"from": note.get("from"),
|
||||
"to": note.get("to"),
|
||||
"by": note.get("by"),
|
||||
"timestamp": note.get("timestamp"),
|
||||
"transition_seq": note.get("transition_seq"),
|
||||
})
|
||||
elif kind == "conflict_refused":
|
||||
mqtt_common.append_event(job_id, {
|
||||
"event": "conflict_refused",
|
||||
"attempted": note.get("attempted"),
|
||||
"current": note.get("current"),
|
||||
"reason": note.get("reason"),
|
||||
"by": note.get("by"),
|
||||
"timestamp": note.get("timestamp"),
|
||||
})
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.warning("audit mirror failed for %s: %s", job_id, exc)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Report finalization: the completion manifest.
|
||||
|
||||
Who writes this matters more than what is in it. If the watcher computed the
|
||||
hash itself, the manifest would attest only that the watcher saw a stable file
|
||||
-- self-certification, which is exactly the evidence shape a reviewer rejected
|
||||
earlier in this issue. So the side that knows the work finished declares it, and
|
||||
the reader independently re-derives the hash rather than trusting the number in
|
||||
the file.
|
||||
|
||||
Consequence, and it is the useful one: an agent that declares nothing leaves no
|
||||
manifest, a missing manifest already holds, and the explicit-event path is
|
||||
therefore preserved for unsupported agents without any special case.
|
||||
|
||||
It lives in `lib_py.delegation`, not `lib_py.loop`, for the same reason the
|
||||
transition engine does: the artifact it writes sits in the job's own directory,
|
||||
and the CLI an agent calls to declare a finalization is a delegation entry point.
|
||||
A frozen loop can bind delegation to a different tree than the parent package, so
|
||||
delegation reaching sideways into `lib_py.loop` would mix trees -- the rule that
|
||||
caught this, twice now.
|
||||
|
||||
writing report-final.md.tmp is being written
|
||||
stable_candidate the file stopped changing (a filter, never an authority)
|
||||
finalized atomically renamed, and this manifest exists
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
MANIFEST_NAME = "report-manifest.json"
|
||||
SCHEMA_VERSION = "1.0.0"
|
||||
|
||||
|
||||
def manifest_path(report_path) -> Path:
|
||||
"""The manifest sits beside the report it describes."""
|
||||
return Path(report_path).parent / MANIFEST_NAME
|
||||
|
||||
|
||||
def manifest_in(report_dir) -> Path:
|
||||
return Path(report_dir) / MANIFEST_NAME
|
||||
|
||||
|
||||
def sha256_of(path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as fh:
|
||||
for chunk in iter(lambda: fh.read(65536), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _atomic_write(path: Path, text: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix="." + path.name + ".", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(text)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
|
||||
|
||||
def validate_side_effect(entry) -> Optional[str]:
|
||||
"""None when the entry is usable evidence, else why it is not.
|
||||
|
||||
A kind with no reference names something without evidencing it, which is the
|
||||
shape "declared but unproven" takes. The CLI parser cannot be the only place
|
||||
this is checked: finalize_report is a public API and a manifest can also be
|
||||
written by hand.
|
||||
"""
|
||||
if not isinstance(entry, dict):
|
||||
return "side effect entry is not an object"
|
||||
kind = str(entry.get("kind") or "").strip()
|
||||
reference = str(entry.get("reference") or "").strip()
|
||||
if not kind:
|
||||
return "side effect entry has no kind"
|
||||
if not reference:
|
||||
return "side effect {!r} has no reference".format(kind)
|
||||
return None
|
||||
|
||||
|
||||
def parse_side_effects(raw: Optional[List[str]]) -> List[Dict[str, str]]:
|
||||
"""`kind=reference` pairs from the command line into evidence entries."""
|
||||
out = []
|
||||
for item in raw or []:
|
||||
kind, _, reference = str(item).partition("=")
|
||||
kind = kind.strip()
|
||||
if not kind or not reference.strip():
|
||||
raise ValueError("side effect must be '<kind>=<reference>', got {!r}".format(item))
|
||||
out.append({"kind": kind, "reference": reference.strip(),
|
||||
"verified_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())})
|
||||
return out
|
||||
|
||||
|
||||
def finalize_report(report_path, job_id, attempt_id, side_effects=None,
|
||||
declared_by="agent") -> Dict[str, Any]:
|
||||
"""Promote a report to finalized and declare the manifest.
|
||||
|
||||
If `<report>.tmp` exists it is renamed into place first, so a reader can
|
||||
never observe a half-written report as finalized. The rename is what makes
|
||||
the transition atomic; the manifest is what makes it attributable.
|
||||
"""
|
||||
for entry in side_effects or []:
|
||||
problem = validate_side_effect(entry)
|
||||
if problem:
|
||||
raise ValueError(problem)
|
||||
|
||||
# Absolute, so a later reader in a different working directory resolves the
|
||||
# same file rather than a same-named one beside itself.
|
||||
report_path = Path(report_path).resolve()
|
||||
staged = report_path.with_name(report_path.name + ".tmp")
|
||||
if staged.exists():
|
||||
os.replace(str(staged), str(report_path))
|
||||
if not report_path.exists():
|
||||
raise FileNotFoundError("no report to finalize at {}".format(report_path))
|
||||
|
||||
manifest = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"job_id": job_id,
|
||||
"attempt_id": attempt_id,
|
||||
"report_path": str(report_path),
|
||||
"sha256": sha256_of(report_path),
|
||||
"finalized_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"side_effects": list(side_effects or []),
|
||||
"declared_by": declared_by,
|
||||
}
|
||||
_atomic_write(manifest_path(report_path), json.dumps(manifest, indent=2,
|
||||
ensure_ascii=False) + "\n")
|
||||
return manifest
|
||||
|
||||
|
||||
class ManifestVerdict(object):
|
||||
__slots__ = ("ok", "reason", "manifest")
|
||||
|
||||
def __init__(self, ok, reason, manifest=None):
|
||||
self.ok = ok
|
||||
self.reason = reason
|
||||
self.manifest = manifest
|
||||
|
||||
|
||||
def verify_manifest(report_dir, job_id, expected_attempt=None,
|
||||
expected_artifacts=None) -> ManifestVerdict:
|
||||
"""Read a manifest and check it against the world, not against itself.
|
||||
|
||||
The hash is recomputed from the file on disk. A report rewritten after the
|
||||
declaration therefore invalidates it, which is the contract: a new body needs
|
||||
a new finalization.
|
||||
"""
|
||||
path = manifest_in(report_dir)
|
||||
if not path.exists():
|
||||
return ManifestVerdict(False, "no completion manifest")
|
||||
|
||||
try:
|
||||
manifest = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
return ManifestVerdict(False, "manifest is unreadable: {}".format(exc))
|
||||
|
||||
if manifest.get("schema_version") != SCHEMA_VERSION:
|
||||
return ManifestVerdict(False, "unsupported manifest schema {!r}".format(
|
||||
manifest.get("schema_version")))
|
||||
if manifest.get("job_id") != job_id:
|
||||
return ManifestVerdict(False, "manifest belongs to job {!r}".format(manifest.get("job_id")))
|
||||
if expected_attempt is not None and manifest.get("attempt_id") != expected_attempt:
|
||||
return ManifestVerdict(False, "manifest belongs to attempt {!r}, not {!r}".format(
|
||||
manifest.get("attempt_id"), expected_attempt))
|
||||
|
||||
report = Path(manifest.get("report_path") or "")
|
||||
if not report.exists():
|
||||
return ManifestVerdict(False, "the finalized report is gone: {}".format(report))
|
||||
actual = sha256_of(report)
|
||||
if actual != manifest.get("sha256"):
|
||||
return ManifestVerdict(
|
||||
False, "report changed after finalization (declared {}, found {})".format(
|
||||
str(manifest.get("sha256"))[:12], actual[:12]))
|
||||
|
||||
# A job that declared side effects must show evidence for every one of them.
|
||||
# Partial evidence is no evidence: the work is not known to be complete.
|
||||
evidenced = set()
|
||||
for entry in manifest.get("side_effects") or []:
|
||||
problem = validate_side_effect(entry)
|
||||
if problem:
|
||||
return ManifestVerdict(False, problem)
|
||||
evidenced.add(str(entry["kind"]).strip())
|
||||
missing = [a for a in (expected_artifacts or []) if a not in evidenced]
|
||||
if missing:
|
||||
return ManifestVerdict(False, "no side-effect evidence for {}".format(missing))
|
||||
|
||||
return ManifestVerdict(True, "manifest verified", manifest)
|
||||
+383
@@ -0,0 +1,383 @@
|
||||
#!/usr/bin/env python3
|
||||
"""job_subscriber.py — the single entry point for observing Job events.
|
||||
|
||||
Subscribes to one job's ``<topic_prefix>/events`` (or, with ``--wait-any``, the
|
||||
events of every running/pending job in the registry), prints one line to stdout
|
||||
per accepted event, and exits on a terminal event or a timeout.
|
||||
|
||||
Design points (all flagged in the PLAN review):
|
||||
- terminal state machine: ``completed``/``error`` is acted on exactly once per
|
||||
job, so QoS-1 duplicates or an ``error``-after-``completed`` reorder are safe.
|
||||
- dual timeouts: a wall-clock ``--timeout`` (total budget, started at
|
||||
subscribe time so a cold start can't hang forever) AND an idle
|
||||
``--idle-timeout`` (no new event for N seconds).
|
||||
- defensive parsing: undecodable payloads, ``schema_version`` mismatches, and
|
||||
``job_id`` values we did not subscribe for are logged and dropped.
|
||||
|
||||
stdout = event lines only. Diagnostics go to stderr via logging.
|
||||
|
||||
Exit codes:
|
||||
0 all watched jobs reached ``completed``
|
||||
1 any watched job reached ``error``
|
||||
2 timed out (wall-clock or idle) before all jobs finished
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from lib_py.delegation import mqtt_common
|
||||
from lib_py.delegation import registry
|
||||
from lib_py.delegation.mqtt_common import (
|
||||
DEFAULT_REGISTRY_DIR,
|
||||
SCHEMA_VERSION,
|
||||
broker_config_from_job,
|
||||
load_job,
|
||||
make_client,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("delegate_job.job_subscriber")
|
||||
|
||||
TERMINAL_EVENTS = ("completed", "error")
|
||||
|
||||
|
||||
def _format_line(topic: str, payload: Dict[str, Any]) -> str:
|
||||
source_tag = f" [{payload['source']}]" if payload.get("source") else ""
|
||||
return (
|
||||
f"{payload.get('timestamp','-')} "
|
||||
f"job={payload.get('job_id','?')} "
|
||||
f"seq={payload.get('seq','?')} "
|
||||
f"{payload.get('event','?') + source_tag:<20} "
|
||||
f"{payload.get('detail','')}"
|
||||
)
|
||||
|
||||
|
||||
def _check_disk_fallback(
|
||||
pending: Set[str],
|
||||
registry_dir: str,
|
||||
terminal: Dict[str, str],
|
||||
) -> None:
|
||||
"""Check local on-disk registry and audit logs for terminal status (B-15)."""
|
||||
for jid in list(pending):
|
||||
disk_status = None
|
||||
try:
|
||||
job_rec = load_job(jid, registry_dir)
|
||||
disk_status = job_rec.get("status")
|
||||
except Exception:
|
||||
pass
|
||||
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())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if disk_status in ("completed", "error", "cancelled"):
|
||||
synth_event = "completed" if disk_status == "completed" else "error"
|
||||
synth_payload = {
|
||||
"job_id": jid,
|
||||
"event": synth_event,
|
||||
"seq": -1,
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"detail": f"terminal status '{disk_status}' resolved via disk-fallback",
|
||||
"source": "disk-fallback",
|
||||
}
|
||||
synth_topic = f"mam/{jid}/events"
|
||||
print(_format_line(synth_topic, synth_payload), flush=True)
|
||||
terminal[jid] = synth_event
|
||||
pending.discard(jid)
|
||||
|
||||
|
||||
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]):
|
||||
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)
|
||||
# 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
|
||||
# event. The intent identity is what stays stable across a retry, so
|
||||
# deduplication has to happen on that.
|
||||
#
|
||||
# This is per-process state. Across a restart the disk fallback resolves
|
||||
# terminal status from the registry, and a duplicate terminal is refused
|
||||
# by the transition boundary as an idempotent ack, so a replay after
|
||||
# restart cannot double-apply either.
|
||||
self.seen_intents = {}
|
||||
|
||||
def on_message(self, _client, _userdata, msg) -> None:
|
||||
# --- defensive parsing -------------------------------------------
|
||||
try:
|
||||
payload = json.loads(msg.payload.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
logger.warning("drop unparseable payload on %s: %s", msg.topic, exc)
|
||||
return
|
||||
if not isinstance(payload, dict):
|
||||
logger.warning("drop non-object payload on %s", msg.topic)
|
||||
return
|
||||
if payload.get("schema_version") != SCHEMA_VERSION:
|
||||
logger.warning("drop event with schema_version=%r (expected %d)",
|
||||
payload.get("schema_version"), SCHEMA_VERSION)
|
||||
return
|
||||
jid = payload.get("job_id")
|
||||
if jid not in self.expected:
|
||||
logger.warning("drop event for unexpected job_id=%r on %s", jid, msg.topic)
|
||||
return
|
||||
# --- production auth check: data.auth_token must match if expected ---
|
||||
expected_token = self.tokens.get(jid)
|
||||
if not mqtt_common.verify_hmac(payload, expected_token):
|
||||
logger.warning("drop event for job %s: HMAC verify failed", jid)
|
||||
return
|
||||
# --- replay attack defense: check monotonic sequence ---
|
||||
seq = payload.get("seq")
|
||||
if seq is None or not isinstance(seq, int):
|
||||
logger.warning("drop event for job %s: missing or invalid seq", jid)
|
||||
return
|
||||
data = payload.get("data")
|
||||
intent = data.get("notification_intent") if isinstance(data, dict) else None
|
||||
identity = None
|
||||
if intent is not None:
|
||||
# Present but not an object: it cannot yield a stable identity, so the
|
||||
# replay guard below could not protect it. Refuse rather than admit a
|
||||
# notification we would be unable to recognise on its retry.
|
||||
if not isinstance(intent, dict):
|
||||
logger.warning("drop event for job %s: notification_intent is %s, not an object",
|
||||
jid, type(intent).__name__)
|
||||
return
|
||||
identity = (intent.get("seq"), intent.get("event"), intent.get("timestamp"))
|
||||
try:
|
||||
already_seen = identity in self.seen_intents.setdefault(jid, set())
|
||||
except TypeError:
|
||||
# A malformed sender can put an unhashable value in a field. That
|
||||
# must not escape defensive parsing as an exception: a callback
|
||||
# raising here would take down the network loop for every job.
|
||||
logger.warning("drop event for job %s: notification intent %r has no stable identity",
|
||||
jid, intent)
|
||||
return
|
||||
if already_seen:
|
||||
logger.info("drop event for job %s: notification intent %s already accepted",
|
||||
jid, identity)
|
||||
return
|
||||
|
||||
if seq <= self.last_seq.get(jid, 0):
|
||||
logger.warning("drop event for job %s: seq %d is not monotonically increasing (last %d)",
|
||||
jid, seq, self.last_seq.get(jid, 0))
|
||||
return
|
||||
self.last_seq[jid] = seq
|
||||
# Recorded only once the event is actually accepted. A late packet that the
|
||||
# sequence guard refuses must not mark its intent as seen, or the genuine
|
||||
# retry that follows would be mistaken for the replay of an event this
|
||||
# subscriber never delivered.
|
||||
if identity is not None:
|
||||
self.seen_intents[jid].add(identity)
|
||||
# Persistent audit log from the *subscriber's* vantage point: every event
|
||||
# that survives defensive parsing is recorded here, including ones a
|
||||
# different host published. This is the external-observer record that
|
||||
# backstops the publisher's own "published" line if it never wrote one.
|
||||
mqtt_common.append_event(jid, {
|
||||
"event": "received",
|
||||
"source_event": payload.get("event"),
|
||||
"seq": payload.get("seq"),
|
||||
"topic": msg.topic,
|
||||
"timestamp": payload.get("timestamp"),
|
||||
"detail": payload.get("detail", ""),
|
||||
})
|
||||
self.events.put((msg.topic, payload))
|
||||
|
||||
|
||||
def _collect_jobs(args) -> List[Dict[str, Any]]:
|
||||
"""Resolve the list of job records this invocation should watch."""
|
||||
if args.wait_any:
|
||||
jobs = [r for r in registry.list_jobs(args.registry_dir)
|
||||
if r.get("status") in ("pending", "running")]
|
||||
if not jobs:
|
||||
logger.error("no pending/running jobs to wait for")
|
||||
return jobs
|
||||
job = load_job(args.job, args.registry_dir) # raises FileNotFoundError
|
||||
return [job]
|
||||
|
||||
|
||||
def _run_subscriber(args) -> int:
|
||||
try:
|
||||
jobs = _collect_jobs(args)
|
||||
except FileNotFoundError as exc:
|
||||
logger.error("%s", exc)
|
||||
return 2
|
||||
if not jobs:
|
||||
return 2
|
||||
|
||||
expected_ids: Set[str] = {j["job_id"] for j in jobs}
|
||||
tokens = {j["job_id"]: j.get("auth_token") for j in jobs}
|
||||
seqs = {}
|
||||
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)
|
||||
|
||||
# Resolve timeouts from CLI, falling back to the (first) job's settings.
|
||||
base_job = jobs[0]
|
||||
wall_timeout = args.timeout if args.timeout is not None else float(base_job.get("timeout_sec", 3600))
|
||||
idle_timeout = args.idle_timeout if args.idle_timeout is not None else float(base_job.get("idle_timeout_sec", 120))
|
||||
|
||||
# All watched jobs share a broker in practice; connect using the first
|
||||
# job's broker and subscribe to each job's events topic.
|
||||
config = broker_config_from_job(base_job)
|
||||
client = make_client("subscriber", config)
|
||||
client.on_message = watcher.on_message
|
||||
|
||||
subscribed_topics = []
|
||||
for job in jobs:
|
||||
prefix = job.get("topic_prefix") or mqtt_common.topic_prefix_for(job["job_id"])
|
||||
subscribed_topics.append(f"{prefix}/events")
|
||||
|
||||
def on_connect(_c, _u, _flags, reason_code, _props):
|
||||
if mqtt_common.reason_code_value(reason_code) != 0:
|
||||
logger.error("broker connection failed: rc=%s", reason_code)
|
||||
return
|
||||
for topic in subscribed_topics:
|
||||
_c.subscribe(topic, qos=1)
|
||||
logger.info("subscribed to %s", topic)
|
||||
|
||||
def on_disconnect(_c, _u, _flags, reason_code, _props):
|
||||
rc = mqtt_common.reason_code_value(reason_code)
|
||||
if rc != 0:
|
||||
logger.warning("broker disconnected (rc=%s); will retry reconnect", reason_code)
|
||||
|
||||
def on_subscribe(_c, _u, mid, granted_qos, _props=None):
|
||||
for topic in subscribed_topics:
|
||||
print(f"SUBSCRIBED {topic}", flush=True)
|
||||
|
||||
client.on_connect = on_connect
|
||||
client.on_disconnect = on_disconnect
|
||||
client.on_subscribe = on_subscribe
|
||||
client.reconnect_delay_set(min_delay=1, max_delay=16)
|
||||
|
||||
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)
|
||||
if not pending:
|
||||
if any(state == "error" for state in terminal.values()):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
try:
|
||||
mqtt_common.with_retry(
|
||||
lambda: client.connect(config.host, config.port, config.keepalive),
|
||||
attempts=3, base_delay=0.2, max_delay=1.0
|
||||
)()
|
||||
client.loop_start()
|
||||
connected = True
|
||||
except Exception as exc:
|
||||
logger.warning("broker connection failed (%s); checking disk fallback", exc)
|
||||
_check_disk_fallback(pending, args.registry_dir, terminal)
|
||||
if not pending:
|
||||
if any(state == "error" for state in terminal.values()):
|
||||
return 1
|
||||
return 0
|
||||
logger.error("broker connection failed: %s", exc)
|
||||
return 3
|
||||
|
||||
try:
|
||||
while pending:
|
||||
now = time.monotonic()
|
||||
if now >= wall_deadline:
|
||||
_check_disk_fallback(pending, args.registry_dir, terminal)
|
||||
if not pending:
|
||||
break
|
||||
logger.error("wall-clock timeout (%.0fs); still pending: %s",
|
||||
wall_timeout, ", ".join(sorted(pending)))
|
||||
return 2
|
||||
idle_left = idle_timeout - (now - last_event)
|
||||
if idle_left <= 0:
|
||||
_check_disk_fallback(pending, args.registry_dir, terminal)
|
||||
if not pending:
|
||||
break
|
||||
logger.error("idle timeout (%.0fs, no events); still pending: %s",
|
||||
idle_timeout, ", ".join(sorted(pending)))
|
||||
return 2
|
||||
wait = min(wall_deadline - now, idle_left, 1.0)
|
||||
try:
|
||||
topic, payload = watcher.events.get(timeout=wait)
|
||||
except queue.Empty:
|
||||
if not retention_checked and (now - start) > 3.0:
|
||||
logger.warning("--expect-retention set but no retained "
|
||||
"terminal event observed yet")
|
||||
retention_checked = True
|
||||
|
||||
# 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)
|
||||
continue
|
||||
|
||||
last_event = time.monotonic()
|
||||
retention_checked = True
|
||||
print(_format_line(topic, payload), flush=True)
|
||||
|
||||
jid = payload["job_id"]
|
||||
event = payload.get("event")
|
||||
if event in TERMINAL_EVENTS:
|
||||
if jid in terminal:
|
||||
# Already finalised: ignore duplicates / late reorders.
|
||||
logger.info("ignoring duplicate terminal %s for %s", event, jid)
|
||||
continue
|
||||
terminal[jid] = event
|
||||
pending.discard(jid)
|
||||
finally:
|
||||
if connected:
|
||||
client.loop_stop()
|
||||
try:
|
||||
client.disconnect()
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
# All jobs reached a terminal state. error wins over completed.
|
||||
if any(state == "error" for state in terminal.values()):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Subscribe to Job events on MQTT")
|
||||
target = parser.add_mutually_exclusive_group(required=True)
|
||||
target.add_argument("--job", help="job id to watch")
|
||||
target.add_argument("--wait-any", action="store_true",
|
||||
help="watch every pending/running job in the registry")
|
||||
parser.add_argument("--timeout", type=float, default=None,
|
||||
help="wall-clock budget in seconds (default: job.timeout_sec or 3600)")
|
||||
parser.add_argument("--idle-timeout", type=float, default=None,
|
||||
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("--registry-dir", default=DEFAULT_REGISTRY_DIR)
|
||||
parser.add_argument("-v", "--verbose", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
mqtt_common.ensure_env_loaded()
|
||||
mqtt_common.setup_logging(logging.DEBUG if args.verbose else logging.WARNING)
|
||||
|
||||
try:
|
||||
return _run_subscriber(args)
|
||||
except Exception as exc:
|
||||
logger.error("subscriber fatal error: %s", exc)
|
||||
return 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,774 @@
|
||||
"""Shared MQTT + registry helpers for the multi-agent-mux-delegate-job skill.
|
||||
|
||||
Single entry point for:
|
||||
- broker configuration (env -> dataclass),
|
||||
- paho client construction (auth + TLS + unique client id),
|
||||
- monotonic per-job sequence counters,
|
||||
- retry-with-exponential-backoff,
|
||||
- atomic registry record load/update under an fcntl lock.
|
||||
|
||||
Requires paho-mqtt >= 2.0 (uses CallbackAPIVersion.VERSION2).
|
||||
|
||||
This module is the *only* place that talks to the broker config and to the
|
||||
raw job record file, so PoC -> production migration touches just env/registry
|
||||
values, never code (see references/mqtt-broker-setup.md).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
logger = logging.getLogger("delegate_job.mqtt_common")
|
||||
|
||||
_warned_deprecated_env = False
|
||||
_warned_coexistence_env = False
|
||||
|
||||
def _load_dotenv(workspace_dir: Optional[str] = None) -> None:
|
||||
"""Load .mam.env (or .env fallback) from workspace if it exists.
|
||||
|
||||
Only sets vars that are not already in os.environ
|
||||
(i.e. OS env takes precedence over env files).
|
||||
"""
|
||||
global _warned_deprecated_env, _warned_coexistence_env
|
||||
|
||||
# 1. Check explicit MAM_ENV_FILE override
|
||||
explicit_file = os.environ.get("MAM_ENV_FILE")
|
||||
if explicit_file:
|
||||
if os.path.isfile(explicit_file):
|
||||
_parse_env_file(explicit_file)
|
||||
return
|
||||
|
||||
# 2. Resolve workspace directory with boundary marker check (.agents or .git)
|
||||
if workspace_dir is None:
|
||||
curr = os.path.dirname(os.path.abspath(__file__))
|
||||
resolved_root = None
|
||||
while curr and curr != os.path.dirname(curr):
|
||||
if os.path.isdir(os.path.join(curr, ".agents")) or os.path.exists(os.path.join(curr, ".git")):
|
||||
resolved_root = curr
|
||||
break
|
||||
curr = os.path.dirname(curr)
|
||||
if not resolved_root:
|
||||
return
|
||||
d = resolved_root
|
||||
else:
|
||||
d = workspace_dir
|
||||
|
||||
mam_env_path = os.path.join(d, ".mam.env")
|
||||
legacy_env_path = os.path.join(d, ".env")
|
||||
|
||||
has_mam = os.path.isfile(mam_env_path)
|
||||
has_legacy = os.path.isfile(legacy_env_path)
|
||||
|
||||
if has_mam and has_legacy:
|
||||
if not _warned_coexistence_env:
|
||||
logger.warning(
|
||||
"Both '%s' and '%s' exist. Loading '%s'. "
|
||||
"Consider removing or migrating '%s'.",
|
||||
mam_env_path, legacy_env_path, mam_env_path, legacy_env_path
|
||||
)
|
||||
_warned_coexistence_env = True
|
||||
_parse_env_file(mam_env_path)
|
||||
elif has_mam:
|
||||
_parse_env_file(mam_env_path)
|
||||
elif has_legacy:
|
||||
if not _warned_deprecated_env:
|
||||
logger.warning(
|
||||
"Loading deprecated config file '%s'. "
|
||||
"Please migrate to '.mam.env'.",
|
||||
legacy_env_path
|
||||
)
|
||||
_warned_deprecated_env = True
|
||||
_parse_env_file(legacy_env_path)
|
||||
|
||||
def _parse_env_file(path: str) -> None:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" in line:
|
||||
key, _, val = line.partition("=")
|
||||
key = key.strip()
|
||||
val = val.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = val
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse env file %s: %s", path, e)
|
||||
|
||||
_env_loaded = False
|
||||
|
||||
|
||||
def ensure_env_loaded(workspace_dir: Optional[str] = None) -> None:
|
||||
"""Load the workspace env file exactly once per process (D6, issue #5 phase 1).
|
||||
|
||||
This used to run at import time. Importing a library must not mutate
|
||||
``os.environ``, so every *process entry point* calls this instead:
|
||||
``publish_event.main``, ``registry.main``, ``job_subscriber.main``,
|
||||
``cli.main``, ``bench.main``, and reconcile.sh's embedded subscriber.
|
||||
Production semantics are unchanged because every production path passes
|
||||
through one of those, and OS env still wins over the file
|
||||
(see :func:`_parse_env_file`).
|
||||
"""
|
||||
global _env_loaded
|
||||
if _env_loaded:
|
||||
return
|
||||
_env_loaded = True
|
||||
_load_dotenv(workspace_dir)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Constants
|
||||
# --------------------------------------------------------------------------
|
||||
SCHEMA_VERSION = 1
|
||||
DEFAULT_REGISTRY_DIR = ".mam/jobs"
|
||||
DEFAULT_TOPIC_ROOT = "python/mqtt/jobs"
|
||||
LOCK_FILENAME = ".lock"
|
||||
|
||||
# Persistent audit-log layout: .mam/delegate_job_logs/<job_id>/{meta,events,status}.
|
||||
# This is a *separate* artifact from the registry: the registry is the live job
|
||||
# record (mutated in place), the audit log is an append-only history that
|
||||
# survives even if the registry dir is cleaned up.
|
||||
META_FILENAME = "meta.json"
|
||||
EVENTS_FILENAME = "events.ndjson"
|
||||
STATUS_FILENAME = "status.json"
|
||||
|
||||
|
||||
def get_logs_dir() -> str:
|
||||
"""Audit-log root, resolved at call time (B-9).
|
||||
|
||||
Overridable with ``DELEGATE_JOB_LOGS_DIR``; otherwise
|
||||
``<cwd>/.mam/delegate_job_logs``. Resolved per call rather than at import
|
||||
so a chdir after import cannot strand the audit trail in the old tree —
|
||||
the same reason ``DEFAULT_REGISTRY_DIR`` stays a relative string.
|
||||
"""
|
||||
env = os.environ.get("DELEGATE_JOB_LOGS_DIR")
|
||||
if env and env.strip():
|
||||
return env
|
||||
return os.path.join(os.getcwd(), ".mam", "delegate_job_logs")
|
||||
|
||||
|
||||
def __getattr__(name: str): # PEP 562 (3.7+)
|
||||
"""Keep ``mqtt_common.LOGS_DIR`` working for external consumers
|
||||
(documented in registry.md) while resolving it dynamically."""
|
||||
if name == "LOGS_DIR":
|
||||
return get_logs_dir()
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
def __dir__(): # PEP 562 recommendation — keep dir()/tab-completion discoverable
|
||||
return sorted(set(globals()) | {"LOGS_DIR"})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Broker configuration
|
||||
# --------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class BrokerConfig:
|
||||
"""Resolved broker connection settings.
|
||||
|
||||
PoC defaults target the public HiveMQ broker. Production overrides arrive
|
||||
either from environment variables or from a job record's ``broker.*`` block
|
||||
(see ``broker_config_from_job``).
|
||||
"""
|
||||
|
||||
host: str = "broker.hivemq.com"
|
||||
port: int = 1883
|
||||
tls: bool = False
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
client_id_prefix: str = "hermes"
|
||||
# TLS material (only consulted when tls is True).
|
||||
ca_certs: Optional[str] = None
|
||||
certfile: Optional[str] = None
|
||||
keyfile: Optional[str] = None
|
||||
keepalive: int = 60
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
def to_registry_block(self) -> Dict[str, Any]:
|
||||
"""The subset that gets persisted into a job record's broker block."""
|
||||
return {
|
||||
"host": self.host,
|
||||
"port": self.port,
|
||||
"tls": self.tls,
|
||||
"username": self.username,
|
||||
"password": self.password,
|
||||
}
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None or raw.strip() == "":
|
||||
return default
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
logger.warning("invalid int for %s=%r; using default %d", name, raw, default)
|
||||
return default
|
||||
|
||||
|
||||
def broker_config_from_env(overrides: Optional[Dict[str, Any]] = None) -> BrokerConfig:
|
||||
"""Build a :class:`BrokerConfig` from environment variables.
|
||||
|
||||
Recognised vars (all optional, PoC defaults shown):
|
||||
MQTT_BROKER (broker.hivemq.com), MQTT_PORT (1883), MQTT_TLS (0),
|
||||
MQTT_USERNAME, MQTT_PASSWORD, MQTT_CLIENT_ID_PREFIX (hermes),
|
||||
MQTT_CA_CERTS, MQTT_CERTFILE, MQTT_KEYFILE, MQTT_KEEPALIVE (60).
|
||||
|
||||
``overrides`` (e.g. a job record's broker block) wins over the env values
|
||||
for any key it specifies with a non-None value.
|
||||
"""
|
||||
cfg = BrokerConfig(
|
||||
host=os.environ.get("MQTT_BROKER", "broker.hivemq.com"),
|
||||
port=_env_int("MQTT_PORT", 1883),
|
||||
tls=_env_bool("MQTT_TLS", False),
|
||||
username=os.environ.get("MQTT_USERNAME") or None,
|
||||
password=os.environ.get("MQTT_PASSWORD") or None,
|
||||
client_id_prefix=os.environ.get("MQTT_CLIENT_ID_PREFIX", "hermes"),
|
||||
ca_certs=os.environ.get("MQTT_CA_CERTS") or None,
|
||||
certfile=os.environ.get("MQTT_CERTFILE") or None,
|
||||
keyfile=os.environ.get("MQTT_KEYFILE") or None,
|
||||
keepalive=_env_int("MQTT_KEEPALIVE", 60),
|
||||
)
|
||||
if overrides:
|
||||
for key, value in overrides.items():
|
||||
if value is not None and hasattr(cfg, key):
|
||||
setattr(cfg, key, value)
|
||||
return cfg
|
||||
|
||||
|
||||
def broker_config_from_job(job: Dict[str, Any]) -> BrokerConfig:
|
||||
"""Resolve broker config for a job: env defaults, then the job's broker.*
|
||||
block overrides. This lets ``publish_event.py`` connect from the registry
|
||||
alone, while still honouring environment toggles (e.g. MQTT_TLS=1)."""
|
||||
return broker_config_from_env(overrides=job.get("broker") or {})
|
||||
|
||||
|
||||
def make_client(role: str, config: Optional[BrokerConfig] = None) -> mqtt.Client:
|
||||
"""Return a configured paho ``Client`` (not yet connected).
|
||||
|
||||
The client id is ``f"{prefix}-{role}-{uuid8}"`` so concurrent publishers /
|
||||
subscribers never collide on the broker. Auth and TLS are applied when the
|
||||
config supplies them.
|
||||
"""
|
||||
config = config or broker_config_from_env()
|
||||
client_id = f"{config.client_id_prefix}-{role}-{uuid.uuid4().hex[:8]}"
|
||||
client = mqtt.Client(
|
||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=client_id,
|
||||
)
|
||||
if config.username:
|
||||
client.username_pw_set(config.username, config.password)
|
||||
if config.tls:
|
||||
# If ca_certs is None paho uses the system trust store (good enough for
|
||||
# public CAs); a private CA bundle path is passed through unchanged.
|
||||
client.tls_set(
|
||||
ca_certs=config.ca_certs,
|
||||
certfile=config.certfile,
|
||||
keyfile=config.keyfile,
|
||||
)
|
||||
logger.debug("built client id=%s tls=%s host=%s", client_id, config.tls, config.host)
|
||||
return client
|
||||
|
||||
|
||||
def reason_code_value(rc: Any) -> int:
|
||||
"""Normalise a paho v2 connect reason code to an int.
|
||||
|
||||
paho-mqtt 2.x hands callbacks a ``ReasonCode`` object (not an int); older
|
||||
paths may pass a plain int. ``ReasonCode`` exposes ``.value``; 0 == success.
|
||||
"""
|
||||
return int(getattr(rc, "value", rc))
|
||||
|
||||
|
||||
def verify_hmac(payload: dict, auth_token: Optional[str]) -> bool:
|
||||
"""Verify HMAC-SHA256 signature. Returns True if valid or no token set."""
|
||||
if not auth_token:
|
||||
return True # PoC mode — no auth
|
||||
sig = payload.get("data", {}).get("hmac_sig")
|
||||
if not sig:
|
||||
return False
|
||||
sign_payload = {k: v for k, v in payload.items() if k != "data"}
|
||||
sign_payload["data"] = {k: v for k, v in payload.get("data", {}).items() if k != "hmac_sig"}
|
||||
msg = json.dumps(sign_payload, sort_keys=True, separators=(",", ":")).encode()
|
||||
expected = hmac.new(auth_token.encode(), msg, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(sig, expected)
|
||||
|
||||
|
||||
def topic_prefix_for(job_id: str, root: str = DEFAULT_TOPIC_ROOT) -> str:
|
||||
return f"{root}/{job_id}"
|
||||
|
||||
|
||||
def events_topic_for(job_id: str, root: str = DEFAULT_TOPIC_ROOT) -> str:
|
||||
return f"{topic_prefix_for(job_id, root)}/events"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Registry primitives (single source of truth for raw record I/O)
|
||||
# --------------------------------------------------------------------------
|
||||
def _job_path(job_id: str, registry_dir: str) -> Path:
|
||||
return Path(registry_dir) / f"{job_id}.json"
|
||||
|
||||
|
||||
def _lock_path(registry_dir: str) -> Path:
|
||||
return Path(registry_dir) / LOCK_FILENAME
|
||||
|
||||
|
||||
@contextmanager
|
||||
def registry_lock(registry_dir: str):
|
||||
"""Advisory exclusive lock over the whole registry dir via fcntl.
|
||||
|
||||
PoC-grade single-host concurrency control. Multiple herdr sessions / scripts
|
||||
serialise their read-modify-write of job records through this lock so two
|
||||
sessions never claim the same pending job. For multi-host delegation move
|
||||
to SQLite WAL (see references/registry.md)."""
|
||||
import fcntl # POSIX only; imported lazily so import works on Windows.
|
||||
|
||||
Path(registry_dir).mkdir(parents=True, exist_ok=True)
|
||||
lock_file = _lock_path(registry_dir)
|
||||
fh = open(lock_file, "a+")
|
||||
try:
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
||||
finally:
|
||||
fh.close()
|
||||
|
||||
|
||||
def load_job(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR) -> Dict[str, Any]:
|
||||
"""Load and parse a job record. Raises FileNotFoundError if absent."""
|
||||
path = _job_path(job_id, registry_dir)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"job record not found: {path}")
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def _atomic_write_record(job_id: str, registry_dir: str, record: Dict[str, Any]) -> None:
|
||||
"""Write a record atomically: temp file in the same dir + os.replace.
|
||||
|
||||
The rename is atomic on POSIX, so readers never observe a half-written
|
||||
file. Callers MUST already hold ``registry_lock`` for read-modify-write
|
||||
correctness."""
|
||||
Path(registry_dir).mkdir(parents=True, exist_ok=True)
|
||||
path = _job_path(job_id, registry_dir)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{job_id}.", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(record, fh, ensure_ascii=False, indent=2)
|
||||
fh.write("\n")
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, path)
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except Exception:
|
||||
pass
|
||||
except BaseException:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
|
||||
|
||||
def update_job_status(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR,
|
||||
_source: str = "unknown", **fields: Any) -> Dict[str, Any]:
|
||||
"""Merge ``fields`` into a job record through the single transition boundary.
|
||||
|
||||
Signature, return value and exceptions are unchanged: phase 1's G-1..G-4
|
||||
assert through this function, and the shell helpers capture its effects.
|
||||
What changed is who performs the write -- ``lib_py.delegation.engine`` is now the
|
||||
only writer of job status, so the CAS rules and the audit mirror exist once
|
||||
instead of twice.
|
||||
|
||||
``MAM_ENGINE_CAS=0`` restores the previous unconditional merge below.
|
||||
"""
|
||||
from lib_py.delegation import engine # same package: no sibling hop (see __init__)
|
||||
|
||||
if not engine.cas_enabled():
|
||||
return _update_job_status_legacy(job_id, registry_dir, **fields)
|
||||
|
||||
# The status-to-request mapping lives in transition_job_status so it happens
|
||||
# exactly once; doing it here as well would strip the status before the
|
||||
# mapping could see it.
|
||||
result = transition_job_status(job_id, registry_dir, _source=_source, **fields)
|
||||
return result.record
|
||||
|
||||
|
||||
def transition_job_status(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR,
|
||||
_source: str = "unknown", **fields: Any):
|
||||
"""Same write, but returns the full TransitionResult.
|
||||
|
||||
``update_job_status`` must keep returning the record because phase 1's
|
||||
contracts and the shell helpers depend on that shape. A caller that needs to
|
||||
know whether the transition was actually committed -- the operator CLI, which
|
||||
used to exit 0 on a silently refused write -- uses this instead.
|
||||
"""
|
||||
from lib_py.delegation import engine # same package: no sibling hop (see __init__)
|
||||
|
||||
request = "merge"
|
||||
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)
|
||||
|
||||
|
||||
def _update_job_status_legacy(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR, **fields: Any) -> Dict[str, Any]:
|
||||
"""Pre-phase-2a body, preserved for MAM_ENGINE_CAS=0 rollback.
|
||||
|
||||
Atomically merge ``fields`` into a job record under the registry lock.
|
||||
|
||||
Always refreshes ``updated_at``. Returns the new record. Raises
|
||||
FileNotFoundError if the job does not exist.
|
||||
|
||||
This is the single chokepoint for status writes (both ``registry.update_status``
|
||||
and ``publish_event.py``'s status sync route through here), so it also mirrors
|
||||
any ``status`` change into the persistent audit log. We perform the log mirror
|
||||
under the lock to guarantee sequential consistency in audit history."""
|
||||
with registry_lock(registry_dir):
|
||||
record = load_job(job_id, registry_dir)
|
||||
old_status = record.get("status")
|
||||
record.update(fields)
|
||||
record["updated_at"] = _utcnow()
|
||||
_atomic_write_record(job_id, registry_dir, record)
|
||||
if "status" in fields:
|
||||
new_status = record.get("status")
|
||||
update_logged_status(job_id, new_status, updated_at=record["updated_at"])
|
||||
if old_status != new_status:
|
||||
append_event(job_id, {
|
||||
"event": "status_changed",
|
||||
"from": old_status,
|
||||
"to": new_status,
|
||||
"timestamp": record["updated_at"],
|
||||
})
|
||||
return record
|
||||
|
||||
|
||||
def next_seq(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR) -> int:
|
||||
"""Return the next monotonic sequence number for a job, persisted in the
|
||||
record's ``last_seq`` field so it stays consistent across process restarts.
|
||||
First call returns 1."""
|
||||
with registry_lock(registry_dir):
|
||||
record = load_job(job_id, registry_dir)
|
||||
seq = int(record.get("last_seq", 0)) + 1
|
||||
record["last_seq"] = seq
|
||||
record["updated_at"] = _utcnow()
|
||||
_atomic_write_record(job_id, registry_dir, record)
|
||||
return seq
|
||||
|
||||
|
||||
def _utcnow() -> str:
|
||||
"""ISO-8601 UTC timestamp with trailing Z (payload `timestamp` field)."""
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
def _utcnow_precise() -> str:
|
||||
"""ISO-8601 UTC timestamp with millisecond resolution. Used for the audit
|
||||
log's ``logged_at`` so events sort cleanly even within the same second."""
|
||||
now = time.time()
|
||||
base = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(now))
|
||||
return f"{base}.{int((now % 1) * 1000):03d}Z"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Persistent audit log (.mam/delegate_job_logs/<job_id>/...)
|
||||
#
|
||||
# Every function here is idempotent, concurrency-safe, and *best-effort*: a
|
||||
# logging failure is swallowed with a logger.warning and never propagated, so it
|
||||
# can never break a publish, a subscribe, or a registry write. stdout is never
|
||||
# touched (it is reserved for data output).
|
||||
# --------------------------------------------------------------------------
|
||||
def job_log_dir(job_id: str, logs_dir: Optional[str] = None) -> Path:
|
||||
return Path(logs_dir or get_logs_dir()) / job_id
|
||||
|
||||
|
||||
def job_log_path(job_id: str, kind: str, logs_dir: Optional[str] = None) -> Path:
|
||||
"""Path to one audit-log file for a job. ``kind`` is a filename, e.g. the
|
||||
module constants META_FILENAME / EVENTS_FILENAME / STATUS_FILENAME."""
|
||||
return job_log_dir(job_id, logs_dir) / kind
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _file_lock(fh):
|
||||
"""Best-effort exclusive lock over a single open file via fcntl, so two
|
||||
processes appending to events.ndjson never interleave a line. A no-op where
|
||||
fcntl is unavailable (Windows); a short append is atomic enough there."""
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - non-POSIX
|
||||
yield
|
||||
return
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _redact_dict(d: Any) -> Any:
|
||||
"""Recursively mask sensitive values (passwords, secrets, tokens) inside logs."""
|
||||
if isinstance(d, dict):
|
||||
redacted = {}
|
||||
for k, v in d.items():
|
||||
if any(s in k.lower() for s in ("password", "token", "secret", "auth_token", "key")):
|
||||
redacted[k] = "[REDACTED]"
|
||||
else:
|
||||
redacted[k] = _redact_dict(v)
|
||||
return redacted
|
||||
elif isinstance(d, list):
|
||||
return [_redact_dict(item) for item in d]
|
||||
return d
|
||||
|
||||
|
||||
def append_event(job_id: str, event_dict: Dict[str, Any], logs_dir: Optional[str] = None) -> None:
|
||||
"""Append one event as a JSON line to ``<logs>/<job_id>/events.ndjson``.
|
||||
|
||||
Concurrency-safe (fcntl lock over the file) and best-effort. A millisecond
|
||||
``logged_at`` is stamped when the caller did not supply one."""
|
||||
try:
|
||||
path = job_log_path(job_id, EVENTS_FILENAME, logs_dir)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
record = _redact_dict(dict(event_dict))
|
||||
record.setdefault("logged_at", _utcnow_precise())
|
||||
line = json.dumps(record, ensure_ascii=False) + "\n"
|
||||
with open(path, "a", encoding="utf-8") as fh:
|
||||
with _file_lock(fh):
|
||||
fh.write(line)
|
||||
fh.flush()
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.warning("append_event failed for job %s: %s", job_id, exc)
|
||||
|
||||
|
||||
def update_logged_status(job_id: str, status: str, logs_dir: Optional[str] = None, **extras: Any) -> None:
|
||||
"""Rewrite ``<logs>/<job_id>/status.json`` (current status for fast point
|
||||
queries) atomically. Best-effort; merges any ``extras``.
|
||||
|
||||
Ordering: this used to run under the registry lock, which serialised it for
|
||||
free. Phase 2a moved the mirror outside the lock to keep the transaction
|
||||
short, so a slow flush could otherwise land after a newer one and leave this
|
||||
file behind the registry. When the caller supplies ``transition_seq`` the
|
||||
write is skipped if the file already records a newer transition, which
|
||||
restores the sequential consistency the lock used to provide.
|
||||
"""
|
||||
try:
|
||||
path = job_log_path(job_id, STATUS_FILENAME, logs_dir)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Compare and write under one per-job lock. A sequence number only orders
|
||||
# writers that cannot interleave: with a bare check-then-write, two
|
||||
# flushes can both read the old value, the newer one lands first, and the
|
||||
# slower one then overwrites it. The lock is deliberately its own file so
|
||||
# it is never confused with the registry lock, and it is taken here --
|
||||
# outside the registry transaction -- so a slow mirror still cannot
|
||||
# lengthen that transaction.
|
||||
lock_path = path.with_name("." + path.name + ".lock")
|
||||
with open(lock_path, "a+") as lock_fh:
|
||||
with _file_lock(lock_fh):
|
||||
incoming_seq = extras.get("transition_seq")
|
||||
if incoming_seq is not None and path.exists():
|
||||
try:
|
||||
existing = json.loads(path.read_text(encoding="utf-8"))
|
||||
existing_seq = existing.get("transition_seq")
|
||||
if existing_seq is not None and int(existing_seq) >= int(incoming_seq):
|
||||
logger.debug("skipping stale status mirror for %s (seq %s <= %s)",
|
||||
job_id, incoming_seq, existing_seq)
|
||||
return
|
||||
except Exception:
|
||||
pass # unreadable existing file: fall through and rewrite it
|
||||
|
||||
record: Dict[str, Any] = {"job_id": job_id, "status": status,
|
||||
"updated_at": _utcnow()}
|
||||
record.update(extras)
|
||||
# A per-write temp name: a shared one lets concurrent writers
|
||||
# clobber each other's half-written file before the rename.
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent),
|
||||
prefix="." + path.name + ".", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(record, fh, ensure_ascii=False, indent=2)
|
||||
fh.write("\n")
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.warning("update_logged_status failed for job %s: %s", job_id, exc)
|
||||
|
||||
|
||||
def init_job_log(job_id: str, meta: Dict[str, Any], logs_dir: Optional[str] = None) -> None:
|
||||
"""Seed the per-job audit-log dir: write meta.json, status.json, and a first
|
||||
``registered`` line in events.ndjson. Idempotent (the ``registered`` line is
|
||||
written only when events.ndjson does not yet exist) and best-effort."""
|
||||
try:
|
||||
d = job_log_dir(job_id, logs_dir)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
meta_redacted = _redact_dict(meta)
|
||||
with open(d / META_FILENAME, "w", encoding="utf-8") as fh:
|
||||
json.dump(meta_redacted, fh, ensure_ascii=False, indent=2)
|
||||
fh.write("\n")
|
||||
status = meta.get("status", "pending")
|
||||
update_logged_status(
|
||||
job_id, status, logs_dir=logs_dir,
|
||||
created_at=meta.get("created_at"), prompt=meta.get("prompt"),
|
||||
)
|
||||
events_path = d / EVENTS_FILENAME
|
||||
first_time = not events_path.exists()
|
||||
events_path.touch(exist_ok=True)
|
||||
if first_time:
|
||||
append_event(job_id, {
|
||||
"event": "registered",
|
||||
"status": status,
|
||||
"agent": meta.get("agent"),
|
||||
"agent_session": meta.get("agent_session"),
|
||||
"topic_prefix": meta.get("topic_prefix"),
|
||||
"timestamp": meta.get("created_at"),
|
||||
}, logs_dir=logs_dir)
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.warning("init_job_log failed for job %s: %s", job_id, exc)
|
||||
|
||||
|
||||
def read_logged_meta(job_id: str, logs_dir: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Return a job's audit meta.json (registration snapshot), or None."""
|
||||
try:
|
||||
with open(job_log_path(job_id, META_FILENAME, logs_dir), "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def read_logged_status(job_id: str, logs_dir: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Return a job's current status.json, or None. This is the fast point-query
|
||||
file (current status only), separate from the registration-time meta.json."""
|
||||
try:
|
||||
with open(job_log_path(job_id, STATUS_FILENAME, logs_dir), "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def iter_logged_events(job_id: str, logs_dir: Optional[str] = None):
|
||||
"""Yield each parsed event from a job's events.ndjson in file (time) order.
|
||||
Malformed lines are skipped with a warning."""
|
||||
path = job_log_path(job_id, EVENTS_FILENAME, logs_dir)
|
||||
if not path.exists():
|
||||
return
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
yield json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("skipping malformed audit line in %s", path)
|
||||
|
||||
|
||||
def list_logged_jobs(logs_dir: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Return one meta record per job directory under the logs root, oldest
|
||||
first. Falls back to ``{"job_id": <dir>}`` when meta.json is missing."""
|
||||
base = Path(logs_dir or get_logs_dir())
|
||||
out: List[Dict[str, Any]] = []
|
||||
if not base.exists():
|
||||
return out
|
||||
for d in sorted(base.iterdir()):
|
||||
if not d.is_dir():
|
||||
continue
|
||||
meta = read_logged_meta(d.name, logs_dir) or {"job_id": d.name}
|
||||
# Overlay the live status.json so the summary reflects current state, not
|
||||
# the registration-time snapshot frozen in meta.json.
|
||||
status = read_logged_status(d.name, logs_dir)
|
||||
if status:
|
||||
meta = {**meta,
|
||||
"status": status.get("status", meta.get("status")),
|
||||
"updated_at": status.get("updated_at", meta.get("updated_at"))}
|
||||
out.append(meta)
|
||||
out.sort(key=lambda m: m.get("created_at") or "")
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Retry helper
|
||||
# --------------------------------------------------------------------------
|
||||
def with_retry(
|
||||
fn: Optional[Callable] = None,
|
||||
*,
|
||||
attempts: int = 3,
|
||||
base_delay: float = 0.5,
|
||||
factor: float = 2.0,
|
||||
max_delay: float = 8.0,
|
||||
exceptions: Iterable[type] = (Exception,),
|
||||
) -> Callable:
|
||||
"""Retry ``fn`` with exponential backoff.
|
||||
|
||||
Usable two ways::
|
||||
|
||||
result = with_retry(do_publish, attempts=3)() # wrap-and-call
|
||||
@with_retry(attempts=5, base_delay=1.0) # decorator
|
||||
def do_publish(): ...
|
||||
|
||||
Re-raises the last exception once ``attempts`` is exhausted.
|
||||
"""
|
||||
exc_tuple = tuple(exceptions)
|
||||
|
||||
def decorate(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
delay = base_delay
|
||||
last_exc: Optional[BaseException] = None
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except exc_tuple as exc:
|
||||
last_exc = exc
|
||||
if attempt >= attempts:
|
||||
break
|
||||
logger.warning(
|
||||
"attempt %d/%d failed: %s; retrying in %.1fs",
|
||||
attempt, attempts, exc, delay,
|
||||
)
|
||||
time.sleep(delay)
|
||||
delay = min(delay * factor, max_delay)
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
return wrapper
|
||||
|
||||
if fn is not None:
|
||||
return decorate(fn)
|
||||
return decorate
|
||||
|
||||
|
||||
def setup_logging(level: int = logging.WARNING) -> None:
|
||||
"""Configure root logging to stderr. stdout is reserved for data output
|
||||
(subscriber event lines, registry ids)."""
|
||||
import sys
|
||||
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
stream=sys.stderr,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Durable notification intent, carried in the job record.
|
||||
|
||||
The contract forbids a separate outbox store, so the intent rides in the record
|
||||
the transition already writes: one atomic commit records both the new state and
|
||||
the fact that the world has not been told yet. Publishing then happens outside
|
||||
the registry lock, and settling the intent is itself an ordinary transition.
|
||||
|
||||
That ordering is the point. If the process dies between the commit and the
|
||||
publish, the record still says a notification is owed, and startup recovery
|
||||
re-sends it. Duplicates are safe: the subscriber already drops any event whose
|
||||
sequence is not greater than the highest it has accepted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from lib_py.delegation import mqtt_common
|
||||
|
||||
logger = logging.getLogger("delegate_job.notifier")
|
||||
|
||||
FIELD = "pending_notification"
|
||||
|
||||
|
||||
def intent_for(event: str, seq: int, timestamp: str, detail: str = "") -> Dict[str, Any]:
|
||||
return {"event": event, "seq": seq, "timestamp": timestamp, "detail": detail}
|
||||
|
||||
|
||||
def pending(job_id: str, registry_dir: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
return mqtt_common.load_job(job_id, registry_dir).get(FIELD)
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def jobs_with_pending(registry_dir: str) -> List[str]:
|
||||
"""Records still owing a notification. The input to restart recovery."""
|
||||
from lib_py.delegation import registry # local: keeps module import order flat
|
||||
|
||||
out = []
|
||||
for record in registry.list_jobs(registry_dir):
|
||||
if record.get(FIELD):
|
||||
out.append(record["job_id"])
|
||||
return out
|
||||
|
||||
|
||||
def settle(job_id: str, registry_dir: str, sent_intent=None,
|
||||
source: str = "notifier") -> bool:
|
||||
"""Clear the intent that was actually sent, and only that one.
|
||||
|
||||
Goes through the transition boundary like every other record write, using a
|
||||
request that carries no status: settling is about a terminal, so a request
|
||||
the terminal state would refuse could never be used to settle one.
|
||||
|
||||
`sent_intent` is compared under the lock. Publishing happens outside it, so a
|
||||
newer intent can legitimately replace the old one mid-flight; clearing
|
||||
unconditionally would drop a notification nobody has sent.
|
||||
"""
|
||||
from lib_py.delegation import engine
|
||||
|
||||
result = engine.transition(job_id, "notification_settled", registry_dir=registry_dir,
|
||||
fields={FIELD: None}, source=source,
|
||||
expected_notification=sent_intent)
|
||||
if not result.committed and "changed while it was being sent" in (result.reason or ""):
|
||||
logger.info("intent for %s was replaced during send; leaving the newer one", job_id)
|
||||
return result.committed
|
||||
|
||||
|
||||
def flush(job_id: str, registry_dir: str, publish=None, attempts: int = 3) -> bool:
|
||||
"""Publish an owed notification and settle it. Safe to call repeatedly.
|
||||
|
||||
Returns True when nothing is owed afterwards. A failure leaves the intent in
|
||||
place on purpose: an unsent notification that looks sent is worse than one
|
||||
that is retried.
|
||||
"""
|
||||
intent = pending(job_id, registry_dir)
|
||||
if not intent:
|
||||
return True
|
||||
|
||||
if publish is None:
|
||||
import json as _json
|
||||
|
||||
from lib_py.delegation import publish_event as _pe
|
||||
|
||||
def publish(job, intent_, registry_dir_):
|
||||
# The wire event gets a fresh registry sequence, so the intent's own
|
||||
# identity is carried in the payload instead. A consumer dedupes on
|
||||
# `notification_intent`, not on the transport sequence -- the
|
||||
# transport number cannot identify a retry of the same intent.
|
||||
identity = _json.dumps({"notification_intent": {
|
||||
"seq": intent_.get("seq"), "event": intent_.get("event"),
|
||||
"timestamp": intent_.get("timestamp")}})
|
||||
return _pe.main(["--registry-dir", registry_dir_, "--job", job,
|
||||
"--event", intent_["event"], "--detail",
|
||||
intent_.get("detail", ""), "--data", identity,
|
||||
"--attempts", str(attempts)])
|
||||
|
||||
try:
|
||||
rc = publish(job_id, intent, registry_dir)
|
||||
except Exception as exc:
|
||||
logger.warning("notification publish failed for %s: %s", job_id, exc)
|
||||
return False
|
||||
if rc != 0:
|
||||
logger.warning("notification for %s not accepted (rc=%s); leaving it pending",
|
||||
job_id, rc)
|
||||
return False
|
||||
return settle(job_id, registry_dir, sent_intent=intent)
|
||||
|
||||
|
||||
def recover(registry_dir: str, publish=None) -> Dict[str, bool]:
|
||||
"""Re-send every owed notification. Called at startup after a crash."""
|
||||
results = {}
|
||||
for job_id in jobs_with_pending(registry_dir):
|
||||
results[job_id] = flush(job_id, registry_dir, publish=publish)
|
||||
return results
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
#!/usr/bin/env python3
|
||||
"""publish_event.py — the single entry point for emitting a Job event.
|
||||
|
||||
Loads the job record from the registry, resolves its broker, assigns the next
|
||||
monotonic ``seq``, builds the schema-v1 JSON payload, and publishes it to
|
||||
``<topic_prefix>/events`` over QoS 1 with exponential-backoff retry.
|
||||
|
||||
Silent by design: nothing is printed to stdout. Diagnostics go to stderr via
|
||||
logging. Terminal events (``completed``/``error``) publish with retain=True so
|
||||
a late subscriber still observes the final state (production hardening).
|
||||
|
||||
Exit codes:
|
||||
0 published successfully
|
||||
1 parameter / registry error (bad args, unknown job, no pending job)
|
||||
2 publish failed after retries (network / broker / ACK timeout)
|
||||
|
||||
Usage:
|
||||
publish_event.py --job <id> --event started [--detail "..."] [--data '{...}']
|
||||
publish_event.py --pick-pending --agent-session herdr:claude --event completed
|
||||
publish_event.py --job <id> --event completed --retained
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from lib_py.delegation import mqtt_common
|
||||
from lib_py.delegation import registry
|
||||
from lib_py.delegation.mqtt_common import (
|
||||
DEFAULT_REGISTRY_DIR,
|
||||
SCHEMA_VERSION,
|
||||
broker_config_from_job,
|
||||
events_topic_for,
|
||||
load_job,
|
||||
next_seq,
|
||||
with_retry,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("delegate_job.publish_event")
|
||||
|
||||
VALID_EVENTS = ("started", "permission_required", "progress", "completed", "error", "cancelled")
|
||||
TERMINAL_EVENTS = ("completed", "error", "cancelled")
|
||||
# event -> registry status to sync as a best-effort side effect
|
||||
EVENT_TO_STATUS = {
|
||||
"started": "running",
|
||||
"completed": "completed",
|
||||
"error": "error",
|
||||
"cancelled": "cancelled",
|
||||
}
|
||||
|
||||
# Re-exported from the transport module, which owns the single implementation.
|
||||
# Two copies of these numbers meant tuning one silently diverged from the other.
|
||||
from lib_py.delegation.publisher import ( # noqa: F401 (re-exported for callers)
|
||||
CONNECT_ACK_TIMEOUT,
|
||||
PUBLISH_ACK_TIMEOUT,
|
||||
OneShotPublisher,
|
||||
)
|
||||
|
||||
|
||||
def build_payload(
|
||||
job_id: str,
|
||||
seq: int,
|
||||
event: str,
|
||||
detail: str,
|
||||
data: Optional[Dict[str, Any]],
|
||||
auth_token: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"seq": seq,
|
||||
"job_id": job_id,
|
||||
"event": event,
|
||||
"timestamp": mqtt_common._utcnow(),
|
||||
"detail": detail,
|
||||
"data": dict(data) if data else {},
|
||||
}
|
||||
# Production: carry the per-job HMAC-SHA256 signature in `data.hmac_sig` so
|
||||
# the subscriber can verify the publisher without exposing the secret token.
|
||||
# The signature is calculated over the entire payload (with `data.hmac_sig` excluded).
|
||||
if auth_token:
|
||||
sign_payload = {k: v for k, v in payload.items() if k != "data"}
|
||||
sign_payload["data"] = {k: v for k, v in payload.get("data", {}).items() if k != "hmac_sig"}
|
||||
msg = json.dumps(sign_payload, sort_keys=True, separators=(",", ":")).encode()
|
||||
sig = hmac.new(auth_token.encode(), msg, hashlib.sha256).hexdigest()
|
||||
payload["data"]["hmac_sig"] = sig
|
||||
return payload
|
||||
|
||||
|
||||
def _publish_once(config, topic: str, body: bytes, retain: bool) -> None:
|
||||
"""Connect, publish one QoS-1 message, wait for the broker ACK, disconnect.
|
||||
|
||||
Delegates to the transport module so the CLI path and the service path share
|
||||
one implementation. Keeping a second copy here is what let the persistent
|
||||
transport's handshake and not-connected bugs exist in only one of them.
|
||||
Raises on any failure so ``with_retry`` can re-run the whole sequence.
|
||||
"""
|
||||
OneShotPublisher().publish_raw(config, topic, body, retain)
|
||||
|
||||
|
||||
class PreparedEvent(object):
|
||||
"""Everything decided before the wire: payload, topic, broker, retain flag.
|
||||
|
||||
Splitting this out lets a different transport (one-shot vs persistent) be
|
||||
swapped in without touching the parts that must not vary: the seq is already
|
||||
consumed, the HMAC is already computed over the final payload, and the audit
|
||||
record is written from these exact fields.
|
||||
"""
|
||||
|
||||
__slots__ = ("job_id", "event", "detail", "seq", "topic", "retain",
|
||||
"payload", "body", "config")
|
||||
|
||||
def __init__(self, job_id, event, detail, seq, topic, retain, payload, body, config):
|
||||
self.job_id = job_id
|
||||
self.event = event
|
||||
self.detail = detail
|
||||
self.seq = seq
|
||||
self.topic = topic
|
||||
self.retain = retain
|
||||
self.payload = payload
|
||||
self.body = body
|
||||
self.config = config
|
||||
|
||||
|
||||
def prepare_event(job_id, event, detail="", data=None,
|
||||
registry_dir=DEFAULT_REGISTRY_DIR, retained=False) -> 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
|
||||
publish must still burn the number, so a replayed packet can never carry a
|
||||
seq the subscriber would accept.
|
||||
"""
|
||||
job = load_job(job_id, registry_dir)
|
||||
config = broker_config_from_job(job)
|
||||
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 = build_payload(
|
||||
job_id=job_id,
|
||||
seq=seq,
|
||||
event=event,
|
||||
detail=detail,
|
||||
data=data,
|
||||
auth_token=job.get("auth_token"),
|
||||
)
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
retain = retained or event in TERMINAL_EVENTS
|
||||
return PreparedEvent(job_id, event, detail, seq, topic, retain, payload, body, config)
|
||||
|
||||
|
||||
def record_outcome(prepared: PreparedEvent, registry_dir: str,
|
||||
publish_ok: bool, publish_error: Optional[str]) -> 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
|
||||
succeeded — that is the contract G-1/G-2/G-4 pin down.
|
||||
"""
|
||||
mqtt_common.append_event(prepared.job_id, {
|
||||
"event": "published",
|
||||
"source_event": prepared.event,
|
||||
"seq": prepared.seq,
|
||||
"topic": prepared.topic,
|
||||
"retain": prepared.retain,
|
||||
"timestamp": prepared.payload["timestamp"],
|
||||
"detail": prepared.detail,
|
||||
"payload": prepared.payload,
|
||||
"published": publish_ok,
|
||||
"publish_error": publish_error,
|
||||
})
|
||||
|
||||
registry.append_event(prepared.job_id, registry_dir, prepared.payload)
|
||||
new_status = EVENT_TO_STATUS.get(prepared.event)
|
||||
if new_status:
|
||||
try:
|
||||
# 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)
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.warning("status sync failed: %s", exc)
|
||||
|
||||
|
||||
def _resolve_job_id(args) -> Optional[str]:
|
||||
if args.pick_pending:
|
||||
return registry.pick_pending(args.agent_session, args.registry_dir)
|
||||
return args.job
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Publish a Job event to MQTT")
|
||||
target = parser.add_mutually_exclusive_group(required=True)
|
||||
target.add_argument("--job", help="job id to publish for")
|
||||
target.add_argument("--pick-pending", action="store_true",
|
||||
help="auto-select a pending job for --agent-session")
|
||||
parser.add_argument("--agent-session", default="herdr:claude",
|
||||
help="session label used with --pick-pending")
|
||||
parser.add_argument("--event", default="progress", choices=VALID_EVENTS)
|
||||
parser.add_argument("--detail", default="")
|
||||
parser.add_argument("--data", default=None, help="optional JSON object string")
|
||||
parser.add_argument("--retained", action="store_true",
|
||||
help="force retain=True (auto for completed/error)")
|
||||
parser.add_argument("--registry-dir", default=DEFAULT_REGISTRY_DIR)
|
||||
parser.add_argument("--attempts", type=int, default=3)
|
||||
parser.add_argument("--finalize-report", default=None, metavar="PATH",
|
||||
help="atomically finalize this report and declare its "
|
||||
"completion manifest before publishing")
|
||||
parser.add_argument("--side-effect", action="append", default=[], dest="side_effects",
|
||||
metavar="KIND=REFERENCE",
|
||||
help="evidence for an external side effect this job performed")
|
||||
parser.add_argument("--attempt-id", type=int, default=None,
|
||||
help="attempt this finalization belongs to (default: the "
|
||||
"job's current iteration)")
|
||||
parser.add_argument("-v", "--verbose", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
mqtt_common.ensure_env_loaded()
|
||||
mqtt_common.setup_logging(logging.DEBUG if args.verbose else logging.WARNING)
|
||||
|
||||
# --- parse optional data JSON (parameter error -> exit 1) ---
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
if args.data:
|
||||
try:
|
||||
data = json.loads(args.data)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("--data must be a JSON object")
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
logger.error("invalid --data: %s", exc)
|
||||
return 1
|
||||
|
||||
job_id = _resolve_job_id(args)
|
||||
if not job_id:
|
||||
logger.error("no job to publish for (unknown --job or no pending job)")
|
||||
return 1
|
||||
|
||||
# Finalize before publishing: a `completed` event whose report is still being
|
||||
# written would invite exactly the partial-report adoption the contract
|
||||
# forbids. Failing here fails the publish, on purpose.
|
||||
if args.finalize_report:
|
||||
from lib_py.delegation import finalize # same package: no sibling hop (see __init__)
|
||||
|
||||
try:
|
||||
side_effects = finalize.parse_side_effects(args.side_effects)
|
||||
except ValueError as exc:
|
||||
logger.error("%s", exc)
|
||||
return 1
|
||||
attempt_id = args.attempt_id
|
||||
if attempt_id is None:
|
||||
try:
|
||||
attempt_id = load_job(job_id, args.registry_dir).get("iteration", 1)
|
||||
except FileNotFoundError:
|
||||
attempt_id = 1
|
||||
try:
|
||||
manifest = finalize.finalize_report(
|
||||
args.finalize_report, job_id, attempt_id,
|
||||
side_effects=side_effects, declared_by="agent")
|
||||
except (OSError, FileNotFoundError) as exc:
|
||||
logger.error("could not finalize report: %s", exc)
|
||||
return 1
|
||||
logger.info("finalized %s (sha256 %s...)", manifest["report_path"],
|
||||
manifest["sha256"][:12])
|
||||
|
||||
try:
|
||||
prepared = prepare_event(
|
||||
job_id=job_id,
|
||||
event=args.event,
|
||||
detail=args.detail,
|
||||
data=data,
|
||||
registry_dir=args.registry_dir,
|
||||
retained=args.retained,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
logger.error("%s", exc)
|
||||
return 1
|
||||
|
||||
# Policy Note: seq consumption on failure is intentional (see prepare_event)
|
||||
# and the failure is documented explicitly in the audit record.
|
||||
publish = with_retry(
|
||||
_publish_once,
|
||||
attempts=args.attempts,
|
||||
exceptions=(OSError, TimeoutError, ConnectionError, ValueError),
|
||||
)
|
||||
publish_ok = True
|
||||
publish_error: Optional[str] = None
|
||||
try:
|
||||
publish(prepared.config, prepared.topic, prepared.body, prepared.retain)
|
||||
except Exception as exc:
|
||||
publish_ok = False
|
||||
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)
|
||||
|
||||
if not publish_ok:
|
||||
return 2
|
||||
|
||||
logger.info("published %s seq=%d job=%s retain=%s",
|
||||
args.event, prepared.seq, job_id, prepared.retain)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Publish transports: one connection per event, or one connection reused.
|
||||
|
||||
Phase 1 ships the persistent transport as a prototype only. Nothing in
|
||||
production uses it: the CLI and the shell helpers exit after a single publish,
|
||||
so reusing a connection buys them nothing. The consumer is the phase 2a engine,
|
||||
which is long-running and pays the CONNACK cost on every event today.
|
||||
|
||||
Both transports raise on failure so the caller's `with_retry` policy stays the
|
||||
single place that decides how many attempts an event gets.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Optional, Tuple
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
from lib_py.delegation import mqtt_common
|
||||
|
||||
logger = logging.getLogger("delegate_job.publisher")
|
||||
|
||||
CONNECT_ACK_TIMEOUT = 10 # seconds to wait for CONNACK
|
||||
PUBLISH_ACK_TIMEOUT = 5 # seconds to wait for QoS-1 PUBACK
|
||||
|
||||
|
||||
def _send(client, topic: str, body: bytes, retain: bool) -> None:
|
||||
"""Publish one QoS-1 message and wait for the broker ACK.
|
||||
|
||||
paho queues a publish issued while disconnected and returns
|
||||
``MQTT_ERR_NO_CONN``; ``wait_for_publish`` then raises ``RuntimeError``,
|
||||
which is deliberately NOT in the retry tuple (a RuntimeError normally means a
|
||||
programming error and must not be retried blindly). Left alone, that turned
|
||||
the one state a persistent connection exists to survive -- the gap between a
|
||||
drop and paho's reconnect -- into a single-attempt failure. Detect that one
|
||||
condition explicitly and raise a retryable error instead.
|
||||
"""
|
||||
info = client.publish(topic, payload=body, qos=1, retain=retain)
|
||||
if getattr(info, "rc", mqtt.MQTT_ERR_SUCCESS) == mqtt.MQTT_ERR_NO_CONN:
|
||||
raise ConnectionError("client is not connected (paho is reconnecting)")
|
||||
info.wait_for_publish(timeout=PUBLISH_ACK_TIMEOUT)
|
||||
if not info.is_published():
|
||||
raise TimeoutError("publish not acknowledged within timeout")
|
||||
|
||||
|
||||
class _BasePublisher(object):
|
||||
"""Shared retry wrapper so both transports report outcomes identically."""
|
||||
|
||||
def publish(self, prepared): # pragma: no cover - interface
|
||||
raise NotImplementedError
|
||||
|
||||
def publish_with_retry(self, prepared, attempts=3) -> Tuple[bool, Optional[str]]:
|
||||
runner = mqtt_common.with_retry(
|
||||
self.publish,
|
||||
attempts=attempts,
|
||||
exceptions=(OSError, TimeoutError, ConnectionError, ValueError),
|
||||
)
|
||||
try:
|
||||
runner(prepared)
|
||||
except Exception as exc:
|
||||
logger.error("publish failed after %d attempts: %s", attempts, exc)
|
||||
return False, str(exc)
|
||||
return True, None
|
||||
|
||||
def open(self):
|
||||
return self
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
class OneShotPublisher(_BasePublisher):
|
||||
"""Connect, publish, wait for the ACK, disconnect. The current behaviour."""
|
||||
|
||||
def __init__(self, client_factory=None):
|
||||
self.client_factory = client_factory or mqtt_common.make_client
|
||||
self.connect_count = 0
|
||||
|
||||
def publish(self, prepared):
|
||||
self.publish_raw(prepared.config, prepared.topic, prepared.body, prepared.retain)
|
||||
|
||||
def publish_raw(self, config, topic: str, body: bytes, retain: bool) -> None:
|
||||
"""Connect, publish, wait for the ACK, disconnect.
|
||||
|
||||
The single implementation of one-shot publishing: ``publish_event``'s
|
||||
CLI path calls straight into this, so the two entry points cannot drift.
|
||||
"""
|
||||
client = self.client_factory("publisher", config)
|
||||
self.connect_count += 1
|
||||
connected = {"rc": None}
|
||||
|
||||
def on_connect(_c, _u, _flags, reason_code, _props):
|
||||
connected["rc"] = reason_code
|
||||
|
||||
client.on_connect = on_connect
|
||||
client.connect(config.host, config.port, config.keepalive)
|
||||
client.loop_start()
|
||||
try:
|
||||
deadline = time.monotonic() + CONNECT_ACK_TIMEOUT
|
||||
while connected["rc"] is None and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
if connected["rc"] is None:
|
||||
raise TimeoutError("no CONNACK from broker")
|
||||
if mqtt_common.reason_code_value(connected["rc"]) != 0:
|
||||
raise ConnectionError("broker refused connection: rc={}".format(connected["rc"]))
|
||||
_send(client, topic, body, retain)
|
||||
finally:
|
||||
client.loop_stop()
|
||||
try:
|
||||
client.disconnect()
|
||||
except Exception: # pragma: no cover - disconnect best effort
|
||||
pass
|
||||
|
||||
|
||||
class PersistentPublisher(_BasePublisher):
|
||||
"""One connection reused across events; paho handles reconnect underneath.
|
||||
|
||||
Bound to a single broker: the connection is established from the first
|
||||
event's config, so an event for a job on a different broker is refused
|
||||
rather than silently published to the wrong place.
|
||||
"""
|
||||
|
||||
def __init__(self, client_factory=None):
|
||||
self.client_factory = client_factory or mqtt_common.make_client
|
||||
self.connect_count = 0
|
||||
self._client = None
|
||||
self._config = None
|
||||
self._fingerprint_bound = None
|
||||
|
||||
def __enter__(self):
|
||||
return self.open()
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
self.close()
|
||||
return False
|
||||
|
||||
def open(self):
|
||||
return self
|
||||
|
||||
@staticmethod
|
||||
def _fingerprint(config) -> tuple:
|
||||
"""An immutable snapshot of everything that decides *which connection*.
|
||||
|
||||
Endpoint alone is not enough: two jobs can share a host and port while
|
||||
carrying different credentials or TLS material, and reusing the first
|
||||
connection would publish under the wrong principal or silently drop a
|
||||
requested TLS setting. Snapshotting also means later mutation of the
|
||||
original config object cannot change what the guard believes is bound.
|
||||
"""
|
||||
fields = ("host", "port", "username", "password", "tls", "ca_certs",
|
||||
"certfile", "keyfile", "keepalive", "client_id_prefix")
|
||||
return tuple((name, getattr(config, name, None)) for name in fields)
|
||||
|
||||
@staticmethod
|
||||
def _describe_mismatch(bound: tuple, incoming: tuple) -> str:
|
||||
"""Name the differing fields without ever echoing their values."""
|
||||
differing = [name for (name, old), (_n, new) in zip(bound, incoming) if old != new]
|
||||
return ", ".join(differing) or "connection settings"
|
||||
|
||||
def _ensure_client(self, config):
|
||||
if self._client is not None:
|
||||
incoming = self._fingerprint(config)
|
||||
if incoming != self._fingerprint_bound:
|
||||
raise ValueError(
|
||||
"PersistentPublisher is bound to {}:{}; refusing an event whose "
|
||||
"{} differ".format(
|
||||
self._config.host, self._config.port,
|
||||
self._describe_mismatch(self._fingerprint_bound, incoming))
|
||||
)
|
||||
return self._client
|
||||
|
||||
client = self.client_factory("publisher", config)
|
||||
self.connect_count += 1
|
||||
|
||||
# The handshake result belongs to THIS client, so it lives in a local the
|
||||
# callback closes over. Holding it on self would let a refused attempt's
|
||||
# reason code be read by the next attempt: the retry would either reject a
|
||||
# healthy connection or, worse, accept one whose CONNACK never arrived.
|
||||
connected = {"rc": None}
|
||||
|
||||
def on_connect(_c, _u, _flags, reason_code, _props):
|
||||
connected["rc"] = reason_code
|
||||
|
||||
client.on_connect = on_connect
|
||||
client.connect(config.host, config.port, config.keepalive)
|
||||
client.loop_start()
|
||||
reconnect_delay_set = getattr(client, "reconnect_delay_set", None)
|
||||
if reconnect_delay_set is not None:
|
||||
reconnect_delay_set(min_delay=1, max_delay=16)
|
||||
try:
|
||||
deadline = time.monotonic() + CONNECT_ACK_TIMEOUT
|
||||
while connected["rc"] is None and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
if connected["rc"] is None:
|
||||
raise TimeoutError("no CONNACK from broker")
|
||||
if mqtt_common.reason_code_value(connected["rc"]) != 0:
|
||||
raise ConnectionError("broker refused connection: rc={}".format(connected["rc"]))
|
||||
except Exception:
|
||||
self._discard(client) # never leak the half-open client on the way out
|
||||
raise
|
||||
self._client = client
|
||||
self._config = config
|
||||
self._fingerprint_bound = self._fingerprint(config)
|
||||
return client
|
||||
|
||||
@staticmethod
|
||||
def _discard(client):
|
||||
try:
|
||||
client.loop_stop()
|
||||
client.disconnect()
|
||||
except Exception: # pragma: no cover - best effort
|
||||
pass
|
||||
|
||||
def publish(self, prepared):
|
||||
client = self._ensure_client(prepared.config)
|
||||
# Both failure shapes (queued-while-disconnected and no PUBACK) are raised
|
||||
# as retryable errors by _send, so the caller's with_retry can ride out a
|
||||
# reconnect instead of recording a one-attempt failure.
|
||||
_send(client, prepared.topic, prepared.body, prepared.retain)
|
||||
|
||||
def close(self):
|
||||
if self._client is None:
|
||||
return
|
||||
try:
|
||||
self._discard(self._client)
|
||||
finally:
|
||||
self._client = None
|
||||
self._config = None
|
||||
self._fingerprint_bound = None
|
||||
Executable
+498
@@ -0,0 +1,498 @@
|
||||
"""Job registry for the multi-agent-mux-delegate-job skill.
|
||||
|
||||
A job record is the single source of truth for one delegated unit of work:
|
||||
its id, prompt, owning agent session, broker connection, timeouts, and status.
|
||||
Records live as ``<registry_dir>/<job_id>.json`` with an append-only event log
|
||||
``<registry_dir>/<job_id>.events.log`` and a shared ``<registry_dir>/.lock``.
|
||||
|
||||
Concurrency is handled via the fcntl lock in :mod:`mqtt_common` (PoC). For
|
||||
multi-host delegation, migrate to SQLite WAL — see references/registry.md.
|
||||
|
||||
Importable as a library and runnable as a CLI (``register``/``list``/``get``/
|
||||
``status``/``pick``) so the ``multi-agent-mux-delegate-job`` bash wrapper can shell out.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from lib_py.delegation import mqtt_common
|
||||
from lib_py.delegation.mqtt_common import (
|
||||
DEFAULT_REGISTRY_DIR,
|
||||
SCHEMA_VERSION,
|
||||
_atomic_write_record,
|
||||
_utcnow,
|
||||
broker_config_from_env,
|
||||
load_job,
|
||||
registry_lock,
|
||||
topic_prefix_for,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("delegate_job.registry")
|
||||
|
||||
VALID_STATUSES = ("pending", "running", "completed", "error", "cancelled")
|
||||
|
||||
|
||||
def generate_job_id(bits: int = 32) -> str:
|
||||
"""PoC: 32-bit hex (8 chars). Production: 128-bit (full uuid4 hex)."""
|
||||
if bits >= 128:
|
||||
return uuid.uuid4().hex
|
||||
nibbles = max(1, bits // 4)
|
||||
return uuid.uuid4().hex[:nibbles]
|
||||
|
||||
|
||||
def register_job(
|
||||
prompt: str,
|
||||
agent: str = "claude-code",
|
||||
agent_session: str = "herdr:claude",
|
||||
role: str = "Worker",
|
||||
broker: Optional[Dict[str, Any]] = None,
|
||||
timeout_sec: int = 3600,
|
||||
idle_timeout_sec: int = 120,
|
||||
registry_dir: str = DEFAULT_REGISTRY_DIR,
|
||||
job_id: Optional[str] = None,
|
||||
expected_artifacts: Optional[List[str]] = None,
|
||||
bits: int = 32,
|
||||
auth_token: Optional[str] = None,
|
||||
job_type: str = "direct",
|
||||
reviewer: Optional[str] = None,
|
||||
reviewer_session: Optional[str] = None,
|
||||
max_iterations: int = 5,
|
||||
) -> str:
|
||||
"""Create a new ``pending`` job record and return its id.
|
||||
|
||||
``broker`` defaults to the current environment's resolved broker block, so
|
||||
the registry alone is enough for ``publish_event.py`` to connect later.
|
||||
"""
|
||||
job_id = job_id or generate_job_id(bits)
|
||||
if broker is None:
|
||||
broker = broker_config_from_env().to_registry_block()
|
||||
if auth_token is None:
|
||||
# Auto-generate token if secure broker configuration (TLS or username) is detected
|
||||
if broker.get("tls") or broker.get("username"):
|
||||
import secrets
|
||||
auth_token = secrets.token_urlsafe(32)
|
||||
now = _utcnow()
|
||||
record: Dict[str, Any] = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"job_id": job_id,
|
||||
"status": "pending",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"prompt": prompt,
|
||||
"agent": agent,
|
||||
"agent_session": agent_session,
|
||||
"role": role,
|
||||
"broker": broker,
|
||||
"topic_prefix": topic_prefix_for(job_id),
|
||||
"timeout_sec": int(timeout_sec),
|
||||
"idle_timeout_sec": int(idle_timeout_sec),
|
||||
"expected_artifacts": expected_artifacts or [],
|
||||
"last_seq": 0,
|
||||
"auth_token": auth_token,
|
||||
"job_type": job_type,
|
||||
"reviewer": reviewer,
|
||||
"reviewer_session": reviewer_session,
|
||||
"max_iterations": int(max_iterations),
|
||||
"iteration": 1,
|
||||
}
|
||||
with registry_lock(registry_dir):
|
||||
if mqtt_common._job_path(job_id, registry_dir).exists():
|
||||
raise FileExistsError(f"job already exists: {job_id}")
|
||||
_atomic_write_record(job_id, registry_dir, record)
|
||||
# Seed the persistent audit log (meta.json + status.json + a "registered"
|
||||
# event). Best-effort inside init_job_log — never blocks registration.
|
||||
mqtt_common.init_job_log(job_id, meta=record)
|
||||
logger.info("registered job %s (agent=%s session=%s)", job_id, agent, agent_session)
|
||||
return job_id
|
||||
|
||||
|
||||
def pick_pending(agent_session: str, registry_dir: str = DEFAULT_REGISTRY_DIR) -> Optional[str]:
|
||||
"""Claim the oldest ``pending`` job for ``agent_session``, flipping it to
|
||||
``running`` atomically under the lock. Returns the job id, or None if no
|
||||
pending job matches. This is how each herdr session takes only its own work
|
||||
without two sessions grabbing the same job."""
|
||||
from lib_py.delegation import engine # same package: no sibling hop (see __init__)
|
||||
|
||||
if not engine.cas_enabled():
|
||||
return _pick_pending_legacy(agent_session, registry_dir)
|
||||
|
||||
# Selection still happens under the lock, then the claim itself is committed
|
||||
# by the single transition boundary with an explicit precondition, so two
|
||||
# sessions still cannot both win.
|
||||
with registry_lock(registry_dir):
|
||||
candidates = [
|
||||
record for record in _iter_records(registry_dir)
|
||||
if record.get("status") == "pending" and record.get("agent_session") == agent_session
|
||||
]
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(key=lambda r: r.get("created_at", ""))
|
||||
chosen_id = candidates[0]["job_id"]
|
||||
|
||||
result = engine.transition(chosen_id, "claim", registry_dir=registry_dir,
|
||||
expected_status="pending", source=agent_session)
|
||||
if not result.committed:
|
||||
# Someone else claimed it between selection and commit; the precondition
|
||||
# refused, which is exactly the behaviour the lock used to provide.
|
||||
logger.info("job %s was claimed by another session first", chosen_id)
|
||||
return None
|
||||
logger.info("session %s picked job %s", agent_session, chosen_id)
|
||||
return chosen_id
|
||||
|
||||
|
||||
def _pick_pending_legacy(agent_session: str, registry_dir: str = DEFAULT_REGISTRY_DIR) -> Optional[str]:
|
||||
"""Pre-phase-2a body, preserved for MAM_ENGINE_CAS=0 rollback."""
|
||||
with registry_lock(registry_dir):
|
||||
candidates = []
|
||||
for record in _iter_records(registry_dir):
|
||||
if record.get("status") == "pending" and record.get("agent_session") == agent_session:
|
||||
candidates.append(record)
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(key=lambda r: r.get("created_at", ""))
|
||||
chosen = candidates[0]
|
||||
chosen["status"] = "running"
|
||||
chosen["updated_at"] = _utcnow()
|
||||
_atomic_write_record(chosen["job_id"], registry_dir, chosen)
|
||||
logger.info("session %s picked job %s", agent_session, chosen["job_id"])
|
||||
job_id = chosen["job_id"]
|
||||
updated_at = chosen["updated_at"]
|
||||
# Mirrors the pending->running transition into the audit log outside the
|
||||
# lock. This is the shape the engine now generalises to every transition.
|
||||
mqtt_common.update_logged_status(job_id, "running", updated_at=updated_at)
|
||||
mqtt_common.append_event(job_id, {
|
||||
"event": "status_changed",
|
||||
"from": "pending",
|
||||
"to": "running",
|
||||
"by": agent_session,
|
||||
"timestamp": updated_at,
|
||||
})
|
||||
return job_id
|
||||
|
||||
|
||||
def update_status(job_id: str, registry_dir: str, status: str) -> Dict[str, Any]:
|
||||
if status not in VALID_STATUSES:
|
||||
raise ValueError(f"invalid status {status!r}; expected one of {VALID_STATUSES}")
|
||||
return mqtt_common.update_job_status(job_id, registry_dir, status=status)
|
||||
|
||||
|
||||
def list_jobs(registry_dir: str = DEFAULT_REGISTRY_DIR, status: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
records = list(_iter_records(registry_dir))
|
||||
if status:
|
||||
records = [r for r in records if r.get("status") == status]
|
||||
records.sort(key=lambda r: r.get("created_at", ""))
|
||||
return records
|
||||
|
||||
|
||||
def append_event(job_id: str, registry_dir: str, payload: Dict[str, Any]) -> None:
|
||||
"""Append one event payload as a JSON line to the job's events log. Best
|
||||
effort, debug-only; failures are logged but never raised to the caller."""
|
||||
try:
|
||||
Path(registry_dir).mkdir(parents=True, exist_ok=True)
|
||||
log_path = Path(registry_dir) / f"{job_id}.events.log"
|
||||
with open(log_path, "a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
||||
except OSError as exc: # pragma: no cover - best effort
|
||||
logger.warning("could not append event for %s: %s", job_id, exc)
|
||||
|
||||
|
||||
# convenience re-export so callers can `from registry import load_job`
|
||||
__all__ = [
|
||||
"register_job", "pick_pending", "update_status", "load_job",
|
||||
"list_jobs", "append_event", "generate_job_id", "get_feedback",
|
||||
]
|
||||
|
||||
|
||||
def _iter_records(registry_dir: str):
|
||||
base = Path(registry_dir)
|
||||
if not base.exists():
|
||||
return
|
||||
for path in sorted(base.glob("*.json")):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
yield json.load(fh)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning("skipping unreadable record %s: %s", path, exc)
|
||||
|
||||
|
||||
def get_feedback(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR) -> str:
|
||||
"""Read the job's audit log or events log and return the detail of the last completed/error event."""
|
||||
# 1) Try the unified audit log first (ndjson) since it's written synchronously by the subscriber
|
||||
try:
|
||||
from lib_py.delegation import mqtt_common
|
||||
logs_dir = mqtt_common.get_logs_dir()
|
||||
events = list(mqtt_common.iter_logged_events(job_id, logs_dir))
|
||||
for e in reversed(events):
|
||||
if e.get("source_event") in ("completed", "error"):
|
||||
return e.get("detail", "")
|
||||
if e.get("event") in ("completed", "error"):
|
||||
return e.get("detail", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) Fallback to local .events.log
|
||||
log_path = Path(registry_dir) / f"{job_id}.events.log"
|
||||
if log_path.exists():
|
||||
feedback = ""
|
||||
try:
|
||||
with open(log_path, "r", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(line)
|
||||
if payload.get("event") in ("completed", "error"):
|
||||
feedback = payload.get("detail", "")
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
if feedback:
|
||||
return feedback
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# CLI (so the bash wrapper can shell out without inline python)
|
||||
# --------------------------------------------------------------------------
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="multi-agent-mux-delegate-job registry CLI")
|
||||
parser.add_argument("--registry-dir", default=DEFAULT_REGISTRY_DIR)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_reg = sub.add_parser("register", help="create a pending job; prints the job id")
|
||||
p_reg.add_argument("--prompt", required=True)
|
||||
p_reg.add_argument("--agent", default="claude-code")
|
||||
p_reg.add_argument("--agent-session", default="herdr:claude")
|
||||
p_reg.add_argument("--role", default="Worker", help="logical role for the delegated agent (e.g. Worker, Planner, Reviewer)")
|
||||
p_reg.add_argument("--timeout", type=int, default=3600)
|
||||
p_reg.add_argument("--idle-timeout", type=int, default=120)
|
||||
p_reg.add_argument("--bits", type=int, default=32, help="32 (PoC) or 128 (prod)")
|
||||
p_reg.add_argument("--artifact", action="append", default=[], dest="artifacts")
|
||||
p_reg.add_argument("--auth-token", default=None, help="HMAC auth token for the job (auto-generated if secure broker is detected)")
|
||||
p_reg.add_argument("--job-type", default="direct", choices=["direct", "loop", "discuss"])
|
||||
p_reg.add_argument("--reviewer", default=None)
|
||||
p_reg.add_argument("--reviewer-session", default=None)
|
||||
p_reg.add_argument("--max-iterations", type=int, default=5)
|
||||
|
||||
p_list = sub.add_parser("list", help="list jobs (optionally by status)")
|
||||
p_list.add_argument("--status", default=None)
|
||||
p_list.add_argument("--json", action="store_true")
|
||||
|
||||
p_get = sub.add_parser("get", help="print one job record as JSON")
|
||||
p_get.add_argument("--job", required=True)
|
||||
|
||||
p_status = sub.add_parser("status", help="set a job status")
|
||||
p_status.add_argument("--job", required=True)
|
||||
p_status.add_argument("--set", required=True, dest="status")
|
||||
|
||||
p_update = sub.add_parser("update", help="update a job record")
|
||||
p_update.add_argument("--job", required=True)
|
||||
p_update.add_argument("--status", default=None)
|
||||
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("--role", 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)
|
||||
|
||||
p_pick = sub.add_parser("pick", help="claim a pending job for a session; prints id")
|
||||
p_pick.add_argument("--agent-session", default="herdr:claude")
|
||||
|
||||
p_logs = sub.add_parser(
|
||||
"logs",
|
||||
help="show the persistent audit log for a job, or --list every logged job",
|
||||
)
|
||||
p_logs.add_argument("job_id", nargs="?", default=None,
|
||||
help="job id whose events.ndjson to print")
|
||||
p_logs.add_argument("--list", action="store_true", dest="list_all",
|
||||
help="summarise every job under the logs dir instead")
|
||||
p_logs.add_argument("--logs-dir", default=None,
|
||||
help="override the audit-log root (default: $DELEGATE_JOB_LOGS_DIR "
|
||||
"or <cwd>/.mam/delegate_job_logs)")
|
||||
p_logs.add_argument("--tail", type=int, default=0,
|
||||
help="show only the last N events (0 = all)")
|
||||
p_logs.add_argument("--json", action="store_true",
|
||||
help="emit raw JSON lines / records instead of a table")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def _operator_write(job_id: str, registry_dir: str, **fields) -> int:
|
||||
"""Apply an operator-authority write and report a refusal on the exit status.
|
||||
|
||||
Before phase 2a this path could not be refused, so it always exited 0. Once
|
||||
the CAS boundary can decline a write, exiting 0 anyway would hide it -- which
|
||||
is how a refused loop re-dispatch looked like success to the wrapper.
|
||||
|
||||
Two things this must NOT do, both learned the hard way:
|
||||
|
||||
* treat an idempotent ACK as a refusal. The wrapper finalises an approved
|
||||
job with `status --set completed` on a record that is already completed,
|
||||
and it runs under `set -e`, so reporting failure there aborts the success
|
||||
path before the validation hook and the final stdout line.
|
||||
* skip the status vocabulary check. That validation used to live in
|
||||
`update_status`; routing around it let an invalid status through, and in
|
||||
the rollback mode it was actually written to the record.
|
||||
"""
|
||||
from lib_py.delegation import engine # same package: no sibling hop (see __init__)
|
||||
|
||||
status = fields.get("status")
|
||||
if status is not None and status not in VALID_STATUSES:
|
||||
print("invalid status {!r}; expected one of {}".format(status, VALID_STATUSES),
|
||||
file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not engine.cas_enabled():
|
||||
mqtt_common.update_job_status(job_id, registry_dir, **fields)
|
||||
return 0
|
||||
|
||||
result = mqtt_common.transition_job_status(
|
||||
job_id, registry_dir, _source="registry_cli", **fields)
|
||||
if result.committed:
|
||||
return 0
|
||||
if result.idempotent:
|
||||
# The requested state already holds. Nothing to do, and nothing wrong.
|
||||
return 0
|
||||
print("refused: {}".format(result.reason), file=sys.stderr)
|
||||
return 4
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
mqtt_common.ensure_env_loaded()
|
||||
mqtt_common.setup_logging(logging.INFO)
|
||||
args = _build_parser().parse_args(argv)
|
||||
rd = args.registry_dir
|
||||
|
||||
if args.command == "register":
|
||||
job_id = register_job(
|
||||
prompt=args.prompt,
|
||||
agent=args.agent,
|
||||
agent_session=args.agent_session,
|
||||
role=args.role,
|
||||
timeout_sec=args.timeout,
|
||||
idle_timeout_sec=args.idle_timeout,
|
||||
registry_dir=rd,
|
||||
expected_artifacts=args.artifacts,
|
||||
bits=args.bits,
|
||||
auth_token=args.auth_token,
|
||||
job_type=args.job_type,
|
||||
reviewer=args.reviewer,
|
||||
reviewer_session=args.reviewer_session,
|
||||
max_iterations=args.max_iterations,
|
||||
)
|
||||
print(job_id)
|
||||
return 0
|
||||
|
||||
if args.command == "list":
|
||||
records = list_jobs(rd, status=args.status)
|
||||
if args.json:
|
||||
print(json.dumps(records, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
if not records:
|
||||
print("(no jobs)")
|
||||
for r in records:
|
||||
print(f"{r['job_id']} {r.get('status','?'):10s} {r.get('agent_session','')}"
|
||||
f" {r.get('prompt','')[:48]}")
|
||||
return 0
|
||||
|
||||
if args.command == "get":
|
||||
try:
|
||||
print(json.dumps(load_job(args.job, rd), ensure_ascii=False, indent=2))
|
||||
except FileNotFoundError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
if args.command == "status":
|
||||
try:
|
||||
rc = _operator_write(args.job, rd, status=args.status)
|
||||
except (FileNotFoundError, ValueError) as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
return rc
|
||||
|
||||
if args.command == "update":
|
||||
fields = {}
|
||||
if args.status is not None:
|
||||
fields["status"] = args.status
|
||||
if args.agent_session is not None:
|
||||
fields["agent_session"] = args.agent_session
|
||||
if args.prompt is not None:
|
||||
fields["prompt"] = args.prompt
|
||||
if args.iteration is not None:
|
||||
fields["iteration"] = args.iteration
|
||||
if args.role is not None:
|
||||
fields["role"] = args.role
|
||||
try:
|
||||
rc = _operator_write(args.job, rd, **fields)
|
||||
except FileNotFoundError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
return rc
|
||||
|
||||
if args.command == "get-feedback":
|
||||
print(get_feedback(args.job, rd))
|
||||
return 0
|
||||
|
||||
if args.command == "pick":
|
||||
job_id = pick_pending(args.agent_session, rd)
|
||||
if job_id is None:
|
||||
return 3 # no pending job for this session
|
||||
print(job_id)
|
||||
return 0
|
||||
|
||||
if args.command == "logs":
|
||||
return _cmd_logs(args)
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_logs(args) -> int:
|
||||
"""Pretty-print one job's events.ndjson, or summarise all logged jobs."""
|
||||
logs_dir = args.logs_dir or mqtt_common.get_logs_dir()
|
||||
|
||||
if args.list_all:
|
||||
jobs = mqtt_common.list_logged_jobs(logs_dir)
|
||||
if args.json:
|
||||
print(json.dumps(jobs, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
if not jobs:
|
||||
print(f"(no logged jobs under {logs_dir})")
|
||||
return 0
|
||||
for m in jobs:
|
||||
print(f"{m.get('job_id','?')} {m.get('status','?'):10s} "
|
||||
f"{m.get('created_at','-'):20s} {(m.get('prompt') or '')[:48]}")
|
||||
return 0
|
||||
|
||||
if not args.job_id:
|
||||
print("logs requires a <job_id> or --list", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
events = list(mqtt_common.iter_logged_events(args.job_id, logs_dir))
|
||||
if not events and not mqtt_common.job_log_dir(args.job_id, logs_dir).exists():
|
||||
print(f"no audit log for job {args.job_id} under {logs_dir}", file=sys.stderr)
|
||||
return 1
|
||||
if args.tail and args.tail > 0:
|
||||
events = events[-args.tail:]
|
||||
if args.json:
|
||||
for e in events:
|
||||
print(json.dumps(e, ensure_ascii=False))
|
||||
return 0
|
||||
for e in events:
|
||||
ts = e.get("logged_at") or e.get("timestamp") or "-"
|
||||
extra = e.get("detail") or e.get("to") or e.get("source_event") or ""
|
||||
print(f"{ts:24s} {e.get('event','?'):<16s} {extra}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,67 @@
|
||||
"""DelegationService — one façade over registering, publishing and supervising.
|
||||
|
||||
Phase 1 keeps every behaviour in the existing entry points; this only groups
|
||||
them so a long-running caller (the phase 2a engine) has a single object to hold,
|
||||
optionally with a persistent MQTT connection. CLI and shell callers always use
|
||||
`persistent=False`, because a process that exits after one publish gains nothing
|
||||
from connection reuse.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from lib_py.delegation import daemon, mqtt_common, publish_event, registry
|
||||
|
||||
logger = logging.getLogger("delegate_job.service")
|
||||
|
||||
|
||||
class DelegationService(object):
|
||||
def __init__(self, registry_dir=None, persistent=False):
|
||||
self.registry_dir = registry_dir or mqtt_common.DEFAULT_REGISTRY_DIR
|
||||
self.persistent = persistent
|
||||
self._publisher = None
|
||||
|
||||
# -- lifecycle ---------------------------------------------------------
|
||||
def __enter__(self):
|
||||
if self.persistent:
|
||||
from lib_py.delegation.publisher import PersistentPublisher
|
||||
self._publisher = PersistentPublisher()
|
||||
self._publisher.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
self.close()
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
if self._publisher is not None:
|
||||
self._publisher.close()
|
||||
self._publisher = None
|
||||
|
||||
# -- operations --------------------------------------------------------
|
||||
def submit_job(self, prompt, agent="claude-code", agent_session="herdr:claude", **kw):
|
||||
return registry.register_job(
|
||||
prompt=prompt, agent=agent, agent_session=agent_session,
|
||||
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)."""
|
||||
prepared = publish_event.prepare_event(
|
||||
job_id=job_id, event=event, detail=detail,
|
||||
data=data, registry_dir=self.registry_dir,
|
||||
)
|
||||
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)
|
||||
return 0 if ok else 2
|
||||
|
||||
def ensure_subscriber_daemon(self, job_id, workdir=None):
|
||||
return daemon.ensure_subscriber_daemon(job_id, workdir)
|
||||
|
||||
def load_job(self, job_id) -> Dict[str, Any]:
|
||||
return mqtt_common.load_job(job_id, self.registry_dir)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Loop core: the single state-transition authority and the shadow watcher.
|
||||
|
||||
Issue #5 phase 2a. Two things live here and they are deliberately separated:
|
||||
|
||||
* `lib_py.delegation.engine` decides and commits state. It is the only writer
|
||||
of job status, and it lives with the other registry writers rather than here
|
||||
-- see that module's docstring for why the package boundary matters under a
|
||||
frozen loop.
|
||||
* `watcher` observes and never decides; `policy` decides and never writes;
|
||||
`shadow` runs the two together and only logs what it *would* have committed.
|
||||
* `autocomplete` is the phase 2b gate: it supplies verified evidence to that
|
||||
same decision and, only if every gate holds, asks the transition boundary to
|
||||
commit. It is off by default and has no production caller yet.
|
||||
|
||||
Package rule, enforced by test: nothing here imports a `lib_py` module other
|
||||
than `lib_py.delegation`. Under a frozen loop the parent package may resolve to
|
||||
a different tree than a bound sibling, so a stray import silently mixes trees.
|
||||
"""
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Entrypoint for `python3 -m lib_py.loop`."""
|
||||
|
||||
import sys
|
||||
from lib_py.loop import runner
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(runner.main())
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Four-fold execution attribution, as a pure predicate.
|
||||
|
||||
A terminal state may only be inferred for the attempt that actually ran. Idle is
|
||||
not evidence of finishing: a reused session sitting idle from previous work looks
|
||||
identical unless job, attempt, session and pane generation all agree AND the
|
||||
attempt was observed to actually execute after dispatch was acknowledged.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
REQUIRED_FIELDS = ("job_id", "attempt_id", "agent_session_uuid", "pane_process_generation")
|
||||
|
||||
|
||||
class AttributionResult(object):
|
||||
__slots__ = ("ok", "reason")
|
||||
|
||||
def __init__(self, ok, reason):
|
||||
self.ok = ok
|
||||
self.reason = reason
|
||||
|
||||
|
||||
def check(dispatch_context, observed=None) -> AttributionResult:
|
||||
"""Compare a dispatch record against what was observed.
|
||||
|
||||
`observed` may carry any subset of the four identifiers; every key it does
|
||||
supply must match. A missing identifier is never treated as agreement.
|
||||
"""
|
||||
if not isinstance(dispatch_context, dict):
|
||||
return AttributionResult(False, "dispatch_context missing")
|
||||
|
||||
for field in REQUIRED_FIELDS:
|
||||
if field not in dispatch_context:
|
||||
return AttributionResult(False, "missing attribution field: {}".format(field))
|
||||
value = dispatch_context[field]
|
||||
if value is None or value == "":
|
||||
return AttributionResult(False, "empty attribution field: {}".format(field))
|
||||
|
||||
if not dispatch_context.get("dispatch_ack"):
|
||||
return AttributionResult(False, "dispatch was never acknowledged")
|
||||
|
||||
# The anchor is the whole point: without it, idle is just idle.
|
||||
if not dispatch_context.get("execution_anchor_observed"):
|
||||
return AttributionResult(False, "no execution anchor observed for this attempt")
|
||||
|
||||
for field, value in (observed or {}).items():
|
||||
if field in REQUIRED_FIELDS and value != dispatch_context[field]:
|
||||
return AttributionResult(
|
||||
False, "attribution mismatch on {}".format(field))
|
||||
|
||||
return AttributionResult(True, "four-fold attribution satisfied")
|
||||
|
||||
|
||||
def pane_matches_generation(pane_id, pane_process_generation) -> bool:
|
||||
"""`pane_process_generation` is '<pane_id>:<pid>:<start>'.
|
||||
|
||||
Compared on the whole leading segment so w2X:p1 never matches w2X:p10.
|
||||
"""
|
||||
if not pane_id or not pane_process_generation:
|
||||
return False
|
||||
return str(pane_process_generation).startswith(str(pane_id) + ":")
|
||||
@@ -0,0 +1,235 @@
|
||||
"""The auto-completion gate.
|
||||
|
||||
Phase 2a already decides, correctly and fail-closed. This does not re-decide;
|
||||
it supplies real evidence to that decision and, only if three independent gates
|
||||
agree, asks the transition boundary to commit.
|
||||
|
||||
Three gates, all of which must hold:
|
||||
* the global switch is on (default off),
|
||||
* the backend has observed evidence that it reaches a quiescent state,
|
||||
* the job itself opted in.
|
||||
|
||||
Everything else is refusal. A failure anywhere -- an unreadable manifest, an
|
||||
exception in the policy, an unknown decision -- holds. "Could not decide" is
|
||||
never "done".
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from lib_py.delegation import finalize
|
||||
from lib_py.loop import policy, store
|
||||
|
||||
logger = logging.getLogger("loop.autocomplete")
|
||||
|
||||
FLAG = "MAM_AUTO_COMPLETE"
|
||||
BACKENDS_FLAG = "MAM_AUTO_COMPLETE_BACKENDS"
|
||||
|
||||
# Backends with recorded evidence that they reach a quiescent state. Auto
|
||||
# completion infers "finished" from stillness, so a backend whose stillness has
|
||||
# never been observed has no basis for the inference. agy has only been seen
|
||||
# working; hermes and opencode have no observations at all.
|
||||
EVIDENCE_BACKED_BACKENDS = ("claude", "grok", "codex")
|
||||
|
||||
# The `agent` recorded on a job is a delegate key. Matching these exactly rather
|
||||
# than by prefix keeps a future `claude-something-unverified` from inheriting
|
||||
# evidence that was never collected for it.
|
||||
EVIDENCE_BACKED_AGENT_KEYS = (
|
||||
"claude", "claude-code", "grok", "grok-build", "codex", "codex-cli",
|
||||
)
|
||||
|
||||
# A reviewer's verdict token, matched the way run_loop.sh matches it.
|
||||
VERDICT_LINE = re.compile(r"^\[VERDICT: (PASS|NOT PASS)\][ \t]*\r?$")
|
||||
|
||||
SOURCE = "auto_complete"
|
||||
|
||||
|
||||
def enabled() -> bool:
|
||||
"""Default off. Read per call so an operator can flip it without a restart."""
|
||||
return os.environ.get(FLAG, "0") == "1"
|
||||
|
||||
|
||||
def allowed_backends() -> tuple:
|
||||
"""The allowlist may be narrowed by an operator, never widened.
|
||||
|
||||
Widening would need new observations, so an unknown name in the environment
|
||||
is dropped rather than trusted.
|
||||
"""
|
||||
raw = os.environ.get(BACKENDS_FLAG)
|
||||
if not raw:
|
||||
return EVIDENCE_BACKED_BACKENDS
|
||||
requested = [name.strip() for name in raw.split(",") if name.strip()]
|
||||
return tuple(n for n in requested if n in EVIDENCE_BACKED_BACKENDS)
|
||||
|
||||
|
||||
class GateResult(object):
|
||||
__slots__ = ("committed", "decision", "reason")
|
||||
|
||||
def __init__(self, committed, decision, reason):
|
||||
self.committed = committed
|
||||
self.decision = decision
|
||||
self.reason = reason
|
||||
|
||||
def __repr__(self): # pragma: no cover - debugging aid
|
||||
return "GateResult(committed={!r}, {!r}, {!r})".format(
|
||||
self.committed, self.decision, self.reason)
|
||||
|
||||
|
||||
def _allowed_agent_keys() -> tuple:
|
||||
allowed = allowed_backends()
|
||||
return tuple(k for k in EVIDENCE_BACKED_AGENT_KEYS
|
||||
if any(k == b or k.startswith(b + "-") for b in allowed))
|
||||
|
||||
|
||||
def _gates_open(record: Dict[str, Any]) -> Optional[str]:
|
||||
"""None when every gate passes, else why one did not."""
|
||||
from lib_py.delegation import engine
|
||||
|
||||
if not enabled():
|
||||
return "auto-completion is disabled ({}=0)".format(FLAG)
|
||||
if not engine.cas_enabled():
|
||||
# The operations note promises this switch disables auto-completion too,
|
||||
# and it must: inferred completion is built on the CAS boundary, so
|
||||
# committing while that boundary is rolled back would bypass it.
|
||||
return "the CAS engine is rolled back (MAM_ENGINE_CAS=0)"
|
||||
backend = record.get("agent") or ""
|
||||
if backend not in _allowed_agent_keys():
|
||||
return "backend {!r} has no observed quiescent-state evidence".format(backend)
|
||||
if not record.get("auto_complete"):
|
||||
return "job did not opt in to auto-completion"
|
||||
return None
|
||||
|
||||
|
||||
def _verdict_present(report_path) -> bool:
|
||||
"""A reviewer's report must end with its verdict token.
|
||||
|
||||
Phase 0 made this a safety contract: without a verdict, a reviewer job may
|
||||
never be promoted to completed. run_loop.sh reads a missing token as NOT
|
||||
PASS, so auto-completing before the token exists would start a correction
|
||||
round against a review that was never finished.
|
||||
"""
|
||||
try:
|
||||
lines = Path(report_path).read_text(encoding="utf-8").splitlines()
|
||||
except OSError:
|
||||
return False
|
||||
for line in reversed(lines):
|
||||
if line.strip():
|
||||
return bool(VERDICT_LINE.match(line))
|
||||
return False
|
||||
|
||||
|
||||
def _scenario_matches(job_id, scenario, dispatch) -> Optional[str]:
|
||||
"""The evidence must be about this job and this dispatch.
|
||||
|
||||
Without this the gate will happily complete one job using another job's
|
||||
evidence: the policy only checks a scenario against the dispatch context
|
||||
carried inside that same scenario, which is self-consistent by construction.
|
||||
"""
|
||||
context = scenario.get("dispatch_context") or {}
|
||||
if context.get("job_id") != job_id:
|
||||
return "evidence is for job {!r}, not {!r}".format(context.get("job_id"), job_id)
|
||||
for field in ("attempt_id", "agent_session_uuid", "pane_process_generation"):
|
||||
expected = dispatch.get(field)
|
||||
if expected is None:
|
||||
return "no recorded dispatch {} to compare the evidence against".format(field)
|
||||
if context.get(field) != expected:
|
||||
return "evidence {} {!r} does not match the recorded dispatch {!r}".format(
|
||||
field, context.get(field), expected)
|
||||
return None
|
||||
|
||||
|
||||
def evaluate(job_id, scenario, registry_dir=None, workspace=None,
|
||||
report_dir=None) -> GateResult:
|
||||
# `report_dir` keeps a default only so the signature stays callable from a
|
||||
# supervisor that has not resolved it yet; omitting it is a refusal, not a
|
||||
# shortcut. See the check at the top of the body.
|
||||
"""Decide, and commit only if every gate agrees.
|
||||
|
||||
`scenario` is the composite evidence the watcher assembled, in the same shape
|
||||
the phase 0 fixtures use, so the decision is made by exactly the code the
|
||||
scenario contracts already pin.
|
||||
"""
|
||||
from lib_py.delegation import engine, mqtt_common
|
||||
|
||||
registry_dir = registry_dir or mqtt_common.DEFAULT_REGISTRY_DIR
|
||||
try:
|
||||
record = mqtt_common.load_job(job_id, registry_dir)
|
||||
except FileNotFoundError:
|
||||
return GateResult(False, "hold", "no such job")
|
||||
|
||||
closed = _gates_open(record)
|
||||
if closed:
|
||||
return GateResult(False, "hold", closed)
|
||||
|
||||
# Independent verification is mandatory, never conditional on the caller
|
||||
# remembering an argument. Skipping it would put the collector's own flags
|
||||
# back in charge of the decision, which is the self-certification this whole
|
||||
# design exists to avoid.
|
||||
if report_dir is None:
|
||||
return GateResult(False, "hold",
|
||||
"no report directory to independently verify the manifest against")
|
||||
|
||||
dispatch = store.load_dispatch(job_id, workspace)
|
||||
if not dispatch:
|
||||
return GateResult(False, "hold", "no recorded dispatch to attribute this completion to")
|
||||
|
||||
mismatch = _scenario_matches(job_id, scenario, dispatch)
|
||||
if mismatch:
|
||||
return GateResult(False, "hold", mismatch)
|
||||
|
||||
# The evidence must belong to the attempt that is currently running, and the
|
||||
# commit must be refused if that stops being true while this runs. Comparing
|
||||
# here alone is not enough: the job can complete, be re-dispatched and
|
||||
# claimed again before the terminal write, after which a status check alone
|
||||
# would let stale evidence finish a new attempt.
|
||||
from lib_py.delegation import engine as _engine
|
||||
|
||||
token = record.get(_engine.DISPATCH_TOKEN_FIELD)
|
||||
if not token:
|
||||
return GateResult(False, "hold",
|
||||
"job has no dispatch token to bind this decision to")
|
||||
if dispatch.get("dispatch_token") != token:
|
||||
return GateResult(False, "hold",
|
||||
"evidence belongs to a superseded dispatch of this job")
|
||||
|
||||
verdict = finalize.verify_manifest(
|
||||
report_dir, job_id, expected_attempt=dispatch.get("attempt_id"),
|
||||
expected_artifacts=record.get("expected_artifacts") or [])
|
||||
if not verdict.ok:
|
||||
return GateResult(False, "hold", verdict.reason)
|
||||
|
||||
# A reviewer that has not written its verdict has not finished reviewing.
|
||||
if str(record.get("role") or "").lower() == "reviewer":
|
||||
if not _verdict_present(verdict.manifest["report_path"]):
|
||||
return GateResult(False, "hold",
|
||||
"reviewer report carries no verdict token on its last line")
|
||||
|
||||
try:
|
||||
decision = policy.decide(scenario)
|
||||
except Exception as exc: # a policy that cannot decide has not approved
|
||||
logger.warning("policy raised for %s: %s", job_id, exc)
|
||||
return GateResult(False, "hold", "policy error: {}".format(exc))
|
||||
|
||||
if not decision.would_complete:
|
||||
return GateResult(False, decision.decision, decision.reason)
|
||||
|
||||
# The attempt guard lives here, in the evidence, not in the record: a record
|
||||
# carries a loop-iteration counter, which is a different thing.
|
||||
result = engine.transition(
|
||||
job_id, "completed", registry_dir=registry_dir,
|
||||
expected_status="running", expected_dispatch_token=token, source=SOURCE,
|
||||
fields={"completed_by": SOURCE, "auto_complete_reason": decision.reason},
|
||||
notify="completed",
|
||||
)
|
||||
if not result.committed:
|
||||
# A late explicit terminal, or a job that moved on. The boundary refused;
|
||||
# that is the boundary doing its job, not an error here.
|
||||
logger.info("auto-completion declined for %s: %s", job_id, result.reason)
|
||||
return GateResult(False, "hold", result.reason)
|
||||
|
||||
logger.info("auto-completed %s (%s)", job_id, decision.reason)
|
||||
return GateResult(True, decision.decision, decision.reason)
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Atomic loop checkpoint management.
|
||||
|
||||
Issue #5 Phase 3.
|
||||
Saves and restores loop progress (.mam/loop-checkpoint.json) across
|
||||
signal-triggered quiesce or resumes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
def save_checkpoint(checkpoint_path: str | Path, data: Dict[str, Any]) -> None:
|
||||
"""Atomically write loop state to a checkpoint file."""
|
||||
path = Path(checkpoint_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(f".tmp.{os.getpid()}")
|
||||
try:
|
||||
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
tmp_path.replace(path)
|
||||
finally:
|
||||
if tmp_path.exists():
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def load_checkpoint(checkpoint_path: str | Path) -> Optional[Dict[str, Any]]:
|
||||
"""Load loop state from a checkpoint file if present, else None."""
|
||||
path = Path(checkpoint_path)
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def remove_checkpoint(checkpoint_path: str | Path) -> None:
|
||||
"""Safely remove a checkpoint file."""
|
||||
path = Path(checkpoint_path)
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Eager module loading for immutable runtime immunity (Issue #5 Phase 4).
|
||||
|
||||
Ensures all dependencies (loop, delegation, agents, external packages)
|
||||
are loaded into sys.modules at loop startup, preventing mid-flight disk
|
||||
modifications from triggering lazy-import syntax/EOF errors (B-13).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
from typing import List, Sequence
|
||||
|
||||
# Framework version locked with framework constants
|
||||
FRAMEWORK_VERSION = "4.3.0"
|
||||
|
||||
CORE_MODULES: Sequence[str] = (
|
||||
# Standard library modules
|
||||
"atexit",
|
||||
"argparse",
|
||||
"copy",
|
||||
"glob",
|
||||
"hashlib",
|
||||
"json",
|
||||
"os",
|
||||
"pathlib",
|
||||
"re",
|
||||
"shutil",
|
||||
"signal",
|
||||
"sqlite3",
|
||||
"subprocess",
|
||||
"sys",
|
||||
"tempfile",
|
||||
"time",
|
||||
"typing",
|
||||
# Framework loop modules
|
||||
"lib_py.loop.parser",
|
||||
"lib_py.loop.checkpoint",
|
||||
"lib_py.loop.runner",
|
||||
"lib_py.loop.attribution",
|
||||
"lib_py.loop.autocomplete",
|
||||
"lib_py.loop.evidence",
|
||||
"lib_py.loop.policy",
|
||||
"lib_py.loop.shadow",
|
||||
"lib_py.loop.store",
|
||||
"lib_py.loop.watcher",
|
||||
# Delegation and common modules
|
||||
"lib_py.paths",
|
||||
"lib_py.state",
|
||||
"lib_py.atomic_yaml",
|
||||
"lib_py.workspace_uuid",
|
||||
"lib_py.verify_session",
|
||||
"lib_py.layout",
|
||||
"lib_py.delegation.engine",
|
||||
"lib_py.delegation.finalize",
|
||||
"lib_py.delegation.job_subscriber",
|
||||
"lib_py.delegation.mqtt_common",
|
||||
"lib_py.delegation.notifier",
|
||||
"lib_py.delegation.publish_event",
|
||||
"lib_py.delegation.publisher",
|
||||
"lib_py.delegation.registry",
|
||||
"lib_py.delegation.service",
|
||||
"lib_py.delegation.cli",
|
||||
# Agent adapter registry
|
||||
"lib_py.agents.base",
|
||||
"lib_py.agents.registry",
|
||||
"lib_py.agents.input_region",
|
||||
"lib_py.agents.sanitize",
|
||||
)
|
||||
|
||||
OPTIONAL_MODULES: Sequence[str] = (
|
||||
"yaml",
|
||||
"paho.mqtt",
|
||||
"paho.mqtt.client",
|
||||
)
|
||||
|
||||
|
||||
def verify_framework_version(expected_version: str = FRAMEWORK_VERSION) -> None:
|
||||
"""Verifies that MAM_VERSION matches framework version if set."""
|
||||
env_version = os.environ.get("MAM_VERSION")
|
||||
if env_version and env_version != expected_version:
|
||||
raise RuntimeError(
|
||||
f"MAM_VERSION mismatch: expected {expected_version}, got {env_version} in environment."
|
||||
)
|
||||
|
||||
|
||||
def eager_load(
|
||||
modules: Sequence[str] = CORE_MODULES,
|
||||
optional_modules: Sequence[str] = OPTIONAL_MODULES,
|
||||
) -> List[str]:
|
||||
"""Eagerly imports all modules into sys.modules.
|
||||
|
||||
Returns the list of successfully loaded module names.
|
||||
"""
|
||||
verify_framework_version()
|
||||
|
||||
# Ensure PYTHONDONTWRITEBYTECODE is set for isolation
|
||||
sys.dont_write_bytecode = True
|
||||
os.environ["PYTHONDONTWRITEBYTECODE"] = "1"
|
||||
|
||||
loaded: List[str] = []
|
||||
for mod_name in modules:
|
||||
if mod_name not in sys.modules:
|
||||
try:
|
||||
importlib.import_module(mod_name)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"Failed to eager load {mod_name}: {exc}") from exc
|
||||
loaded.append(mod_name)
|
||||
|
||||
for opt_mod in optional_modules:
|
||||
if opt_mod not in sys.modules:
|
||||
try:
|
||||
importlib.import_module(opt_mod)
|
||||
loaded.append(opt_mod)
|
||||
except (ImportError, ModuleNotFoundError):
|
||||
pass
|
||||
|
||||
return loaded
|
||||
|
||||
|
||||
def is_eager_loaded(modules: Sequence[str] = CORE_MODULES) -> bool:
|
||||
"""Returns True if all required core modules are present in sys.modules."""
|
||||
return all(mod in sys.modules for mod in modules)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""The one data shape that crosses the watcher -> engine boundary.
|
||||
|
||||
Keeping this a plain value with no I/O is what lets `policy.decide` stay a pure
|
||||
function, which in turn is what lets the phase-0 scenario fixtures be fed to it
|
||||
verbatim as test vectors.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
SOURCE_KINDS = ("herdr", "event", "filesystem", "harness")
|
||||
|
||||
# Decisions the policy may return. Same vocabulary as the phase-0 contracts, so
|
||||
# a scenario's expected_policy.decision can be compared directly.
|
||||
DECISIONS = ("hold", "hold_blocked", "hold_reconnect", "reject_stale", "candidate_approved")
|
||||
|
||||
|
||||
class Observation(object):
|
||||
"""One point-in-time fact from one source. Never a conclusion."""
|
||||
|
||||
__slots__ = ("source_kind", "payload", "captured_at", "capture_ref", "provenance")
|
||||
|
||||
def __init__(self, source_kind, payload, captured_at=None, capture_ref=None,
|
||||
provenance="observed"):
|
||||
if source_kind not in SOURCE_KINDS:
|
||||
raise ValueError("unsupported source_kind: {!r}".format(source_kind))
|
||||
self.source_kind = source_kind
|
||||
self.payload = dict(payload or {})
|
||||
self.captured_at = captured_at
|
||||
self.capture_ref = capture_ref
|
||||
self.provenance = provenance
|
||||
|
||||
def __repr__(self): # pragma: no cover - debugging aid
|
||||
return "Observation({!r}, {!r})".format(self.source_kind, self.payload)
|
||||
|
||||
|
||||
class Decision(object):
|
||||
"""What the policy concluded, and why. `reason` is for the audit trail."""
|
||||
|
||||
__slots__ = ("decision", "reason", "attribution_ok")
|
||||
|
||||
def __init__(self, decision, reason, attribution_ok=False):
|
||||
if decision not in DECISIONS:
|
||||
raise ValueError("unknown decision: {!r}".format(decision))
|
||||
self.decision = decision
|
||||
self.reason = reason
|
||||
self.attribution_ok = attribution_ok
|
||||
|
||||
@property
|
||||
def would_complete(self) -> bool:
|
||||
return self.decision == "candidate_approved"
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {"decision": self.decision, "reason": self.reason,
|
||||
"attribution_ok": self.attribution_ok}
|
||||
|
||||
def __repr__(self): # pragma: no cover - debugging aid
|
||||
return "Decision({!r}, {!r})".format(self.decision, self.reason)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Strict parser for reviewer reports, verdicts, and workflow tags.
|
||||
|
||||
Issue #5 Phase 3.
|
||||
Implements fence-aware parsing so markdown code blocks (e.g. documentation or
|
||||
examples quoted inside reports) cannot trigger or spoof verdicts, escalations,
|
||||
or rebuttals.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
# Fences start with at least 3 backticks or tildes: ``` or ~~~
|
||||
_FENCE_START_RE = re.compile(r"^[ \t]*(```+|~~~+)")
|
||||
_VERDICT_RE = re.compile(r"^\[VERDICT:\s*(PASS|NOT PASS)\]\s*$")
|
||||
|
||||
|
||||
def strip_code_fences(text: str) -> str:
|
||||
"""Remove lines that are inside markdown code blocks (``` or ~~~)."""
|
||||
clean_lines = []
|
||||
in_fence = False
|
||||
fence_char = ""
|
||||
fence_len = 0
|
||||
|
||||
for line in text.splitlines():
|
||||
match = _FENCE_START_RE.match(line)
|
||||
if match:
|
||||
marker = match.group(1)
|
||||
char = marker[0]
|
||||
length = len(marker)
|
||||
if not in_fence:
|
||||
in_fence = True
|
||||
fence_char = char
|
||||
fence_len = length
|
||||
continue
|
||||
else:
|
||||
# Closing fence must match the fence character and have at least the opening length
|
||||
if char == fence_char and length >= fence_len:
|
||||
in_fence = False
|
||||
fence_char = ""
|
||||
fence_len = 0
|
||||
continue
|
||||
if not in_fence:
|
||||
clean_lines.append(line)
|
||||
|
||||
return "\n".join(clean_lines)
|
||||
|
||||
|
||||
def parse_verdict(content: str) -> Optional[str]:
|
||||
"""Extract verdict ('PASS' or 'NOT PASS') from the last non-blank line outside code fences.
|
||||
|
||||
Returns None if no valid verdict line is found or if content is malformed.
|
||||
"""
|
||||
stripped = strip_code_fences(content)
|
||||
lines = [line.strip() for line in stripped.splitlines() if line.strip()]
|
||||
if not lines:
|
||||
return None
|
||||
|
||||
last_line = lines[-1]
|
||||
match = _VERDICT_RE.match(last_line)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def has_verdict(file_or_content: str | Path, expected: str) -> bool:
|
||||
"""Check whether a report file or content ends with [VERDICT: <expected>]."""
|
||||
content = ""
|
||||
if isinstance(file_or_content, Path) or (isinstance(file_or_content, str) and os.path.isfile(file_or_content)):
|
||||
try:
|
||||
content = Path(file_or_content).read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return False
|
||||
else:
|
||||
content = str(file_or_content)
|
||||
|
||||
verdict = parse_verdict(content)
|
||||
return verdict == expected
|
||||
|
||||
|
||||
def has_tag_line(file_or_content: str | Path, tag: str) -> bool:
|
||||
"""Detect a standalone '[TAG]' line anywhere outside code fences."""
|
||||
content = ""
|
||||
if isinstance(file_or_content, Path) or (isinstance(file_or_content, str) and os.path.isfile(file_or_content)):
|
||||
try:
|
||||
content = Path(file_or_content).read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return False
|
||||
else:
|
||||
content = str(file_or_content)
|
||||
|
||||
stripped = strip_code_fences(content)
|
||||
pattern = re.compile(rf"^\[{re.escape(tag)}\]\s*$", re.MULTILINE)
|
||||
return bool(pattern.search(stripped))
|
||||
|
||||
|
||||
def extract_tag_values(file_or_content: str | Path, key: str) -> List[str]:
|
||||
"""Emit the trimmed payload of every standalone '[KEY: value]' line outside code fences.
|
||||
|
||||
Preserves appearance order and eliminates duplicates.
|
||||
"""
|
||||
content = ""
|
||||
if isinstance(file_or_content, Path) or (isinstance(file_or_content, str) and os.path.isfile(file_or_content)):
|
||||
try:
|
||||
content = Path(file_or_content).read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return []
|
||||
else:
|
||||
content = str(file_or_content)
|
||||
|
||||
stripped = strip_code_fences(content)
|
||||
pattern = re.compile(rf"^\[{re.escape(key)}:\s*([^\]]*)\]\s*$", re.MULTILINE)
|
||||
results = []
|
||||
seen = set()
|
||||
|
||||
for match in pattern.finditer(stripped):
|
||||
val = match.group(1).strip()
|
||||
if val and val not in seen:
|
||||
seen.add(val)
|
||||
results.append(val)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,168 @@
|
||||
"""The completion decision. A pure function, deliberately.
|
||||
|
||||
No I/O, no clock, no globals: that is what lets the phase-0 scenario fixtures be
|
||||
fed to it verbatim as test vectors. Those seven scenarios and their
|
||||
`expected_policy.decision` survived three-reviewer scrutiny, so they are the
|
||||
contract this implements rather than a fresh set of rules.
|
||||
|
||||
Fail-closed throughout: every gate must pass for `candidate_approved`. Anything
|
||||
unproven holds.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from lib_py.loop import attribution
|
||||
from lib_py.loop.evidence import Decision
|
||||
|
||||
# Native states that mean "not doing anything right now". Idle alone proves
|
||||
# nothing about completion; it only makes completion *possible* to consider.
|
||||
QUIESCENT = ("idle", "done")
|
||||
|
||||
# The four identifiers an external collector must supply before a completion can
|
||||
# be attributed to this attempt.
|
||||
ATTRIBUTION_FIELDS = ("job_id", "attempt_id", "agent_session_uuid",
|
||||
"pane_process_generation")
|
||||
|
||||
|
||||
def _timeline_by_kind(scenario):
|
||||
out = {}
|
||||
for item in scenario.get("timeline") or []:
|
||||
out.setdefault(item.get("source_kind"), []).append(item)
|
||||
return out
|
||||
|
||||
|
||||
def collect_observed_identity(scenario) -> dict:
|
||||
"""Gather identifiers the *evidence* carries, for comparison with dispatch.
|
||||
|
||||
Two sources, and the distinction matters. Association of a job or attempt to
|
||||
an observation is the external collector's business, so it lives in a
|
||||
``collector_attribution`` envelope beside the payload -- never inside it,
|
||||
because a native Herdr payload must stay native (phase 0 forbids MAM fields
|
||||
there, and a fabricated one would be indistinguishable from a real reading).
|
||||
The report manifest is a filesystem fact and stays in that payload.
|
||||
|
||||
Every occurrence is kept. Taking only the first would drop a later,
|
||||
contradicting observation, which is precisely what this exists to catch.
|
||||
"""
|
||||
observed = {}
|
||||
for item in scenario.get("timeline") or []:
|
||||
envelope = item.get("collector_attribution") or {}
|
||||
for field in ATTRIBUTION_FIELDS:
|
||||
if envelope.get(field) is not None:
|
||||
observed.setdefault(field, []).append(envelope[field])
|
||||
payload = item.get("payload") or {}
|
||||
if item.get("source_kind") != "herdr":
|
||||
# A MAM-side source (an event, a filesystem fact) legitimately names
|
||||
# the job or attempt in its own payload. A native Herdr payload never
|
||||
# may: phase 0 forbids MAM fields there, so an identifier appearing
|
||||
# inside one is either fabricated or a contract violation, and either
|
||||
# way it is not evidence.
|
||||
for field in ATTRIBUTION_FIELDS:
|
||||
if payload.get(field) is not None:
|
||||
observed.setdefault(field, []).append(payload[field])
|
||||
manifest = payload.get("report_manifest")
|
||||
if isinstance(manifest, dict) and manifest.get("attempt_id") is not None:
|
||||
observed.setdefault("manifest_attempt_id", []).append(manifest["attempt_id"])
|
||||
return observed
|
||||
|
||||
|
||||
def decide(scenario) -> Decision:
|
||||
"""Map one composite scenario to a decision.
|
||||
|
||||
Order matters: transport doubt and staleness invalidate the evidence itself,
|
||||
so they are answered before anything is inferred from it.
|
||||
"""
|
||||
dispatch = scenario.get("dispatch_context") or {}
|
||||
by_kind = _timeline_by_kind(scenario)
|
||||
herdr_items = by_kind.get("herdr") or []
|
||||
latest = herdr_items[-1]["payload"] if herdr_items else {}
|
||||
filesystem = scenario.get("filesystem") or {}
|
||||
|
||||
# 1. Transport doubt: evidence gathered before a gap cannot be trusted.
|
||||
for item in herdr_items:
|
||||
payload = item.get("payload") or {}
|
||||
if payload.get("transport_healthy") is False or payload.get("error"):
|
||||
return Decision("hold_reconnect",
|
||||
"herdr transport failed; cached observations are void")
|
||||
|
||||
# 2. A signal from a previous attempt or process generation decides nothing.
|
||||
for item in herdr_items:
|
||||
payload = item.get("payload") or {}
|
||||
pane_generation = payload.get("pane_process_generation")
|
||||
if pane_generation and dispatch.get("pane_process_generation") and \
|
||||
pane_generation != dispatch["pane_process_generation"]:
|
||||
return Decision("reject_stale",
|
||||
"signal belongs to a different process generation")
|
||||
pane_id = payload.get("pane_id")
|
||||
if pane_id and dispatch.get("pane_process_generation") and \
|
||||
not attribution.pane_matches_generation(
|
||||
pane_id, dispatch["pane_process_generation"]):
|
||||
return Decision("reject_stale", "signal belongs to a different pane")
|
||||
|
||||
# 3. A blocked agent is waiting for a human. Completion is forbidden.
|
||||
if latest.get("agent_status") == "blocked" or latest.get("matched_rule"):
|
||||
return Decision("hold_blocked", "agent is blocked on an interactive prompt")
|
||||
|
||||
# 4. Attribution: the dispatch record must be well formed AND agree with the
|
||||
# identifiers the evidence itself carries. Flags on the dispatch record
|
||||
# alone are self-assertion, not correspondence.
|
||||
observed = collect_observed_identity(scenario)
|
||||
manifest_attempts = observed.pop("manifest_attempt_id", [])
|
||||
|
||||
attributed = attribution.check(dispatch)
|
||||
if not attributed.ok:
|
||||
return Decision("hold", attributed.reason)
|
||||
|
||||
# Absence of proof is not proof. Comparing only the identifiers that happen
|
||||
# to be present would let evidence carrying none at all be approved on the
|
||||
# dispatch record's own say-so, which is the self-assertion this check exists
|
||||
# to replace. Every identifier must be supplied, then must agree.
|
||||
for field in ATTRIBUTION_FIELDS:
|
||||
if not observed.get(field):
|
||||
return Decision("hold",
|
||||
"no observed {} to attribute this completion to".format(field))
|
||||
for value in observed[field]:
|
||||
if value != dispatch.get(field):
|
||||
return Decision("hold", "attribution mismatch on {}: observed {!r}, "
|
||||
"dispatched {!r}".format(field, value,
|
||||
dispatch.get(field)))
|
||||
|
||||
# The finalized report must belong to the attempt being judged. A missing
|
||||
# manifest attempt holds for the same reason: it proves nothing.
|
||||
if not manifest_attempts:
|
||||
return Decision("hold", "no report manifest attempt to associate with this attempt")
|
||||
for manifest_attempt in manifest_attempts:
|
||||
if manifest_attempt != dispatch.get("attempt_id"):
|
||||
return Decision("hold",
|
||||
"report manifest belongs to attempt {!r}, not {!r}".format(
|
||||
manifest_attempt, dispatch.get("attempt_id")))
|
||||
|
||||
# 5. Still working, or a tool is running underneath a ready prompt.
|
||||
if latest.get("agent_status") not in QUIESCENT:
|
||||
return Decision("hold", "agent is not quiescent (status={!r})".format(
|
||||
latest.get("agent_status")), attribution_ok=True)
|
||||
for item in by_kind.get("harness") or []:
|
||||
if (item.get("payload") or {}).get("active"):
|
||||
return Decision("hold", "a tool is still executing", attribution_ok=True)
|
||||
|
||||
# 6. The report must be finalized, not merely paused. A debounce alone is a
|
||||
# candidate filter, never an authority to complete.
|
||||
if not filesystem.get("exists"):
|
||||
return Decision("hold", "no report present", attribution_ok=True)
|
||||
if not filesystem.get("manifest_committed"):
|
||||
return Decision("hold", "report is not finalized (no commit manifest)",
|
||||
attribution_ok=True)
|
||||
if not filesystem.get("hash_verified"):
|
||||
return Decision("hold", "report manifest hash unverified", attribution_ok=True)
|
||||
|
||||
# Side-effect evidence is enforced authoritatively in finalize.verify_manifest,
|
||||
# which has the job record and therefore knows what was declared. The policy
|
||||
# honours an explicit negative from the collector so the case is expressible
|
||||
# as a scenario vector; absence here is not read as proof, because the
|
||||
# manifest check is the layer that fails closed on missing evidence.
|
||||
if filesystem.get("side_effects_verified") is False:
|
||||
return Decision("hold", "declared side effect has no evidence in the commit manifest",
|
||||
attribution_ok=True)
|
||||
|
||||
return Decision("candidate_approved",
|
||||
"attribution, quiescence and a verified manifest all hold",
|
||||
attribution_ok=True)
|
||||
@@ -0,0 +1,746 @@
|
||||
"""Autonomous Planning, Execution, and Peer-Review Orchestrator.
|
||||
|
||||
Issue #5 Phase 3: Python port of run_loop.sh into lib_py.loop.runner.
|
||||
Preserves public CLI contracts, exit codes, prompt shapes, and state transitions
|
||||
while using fence-aware strict verdict parsing and atomic checkpointing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from lib_py.loop import checkpoint, eager, parser
|
||||
|
||||
# ANSI colors matching run_loop.sh
|
||||
_COLOR_INFO = "\033[1;34m[*]\033[0m"
|
||||
_COLOR_SUCCESS = "\033[1;32m[✓]\033[0m"
|
||||
_COLOR_WARN = "\033[1;33m[!]\033[0m"
|
||||
_COLOR_ERROR = "\033[1;31m[✗]\033[0m"
|
||||
|
||||
|
||||
def log_info(msg: str) -> None:
|
||||
print(f"{_COLOR_INFO} {msg}")
|
||||
|
||||
|
||||
def log_success(msg: str) -> None:
|
||||
print(f"{_COLOR_SUCCESS} {msg}")
|
||||
|
||||
|
||||
def log_warn(msg: str) -> None:
|
||||
print(f"{_COLOR_WARN} {msg}")
|
||||
|
||||
|
||||
def log_error(msg: str) -> None:
|
||||
print(f"{_COLOR_ERROR} {msg}", file=sys.stderr)
|
||||
|
||||
|
||||
class LoopRunner:
|
||||
"""Orchestrates planning, code implementation, and multi-agent peer reviews."""
|
||||
|
||||
def __init__(self, argv: Optional[List[str]] = None):
|
||||
# Eagerly load all required framework modules to ensure immutable runtime (Phase 4)
|
||||
eager.eager_load()
|
||||
|
||||
self.raw_argv = list(argv) if argv is not None else sys.argv[1:]
|
||||
self.workspace_root = Path(os.environ.get("MAM_REAL_ROOT") or os.environ.get("WORKSPACE_ROOT") or Path.cwd()).resolve()
|
||||
self.mam_dir = self.workspace_root / ".mam"
|
||||
self.checkpoint_file = self.mam_dir / "loop-checkpoint.json"
|
||||
self.created_jobs: List[str] = []
|
||||
self.current_plan = ""
|
||||
self.plan_job_id: Optional[str] = None
|
||||
self.base_commit = ""
|
||||
self.interrupted = False
|
||||
|
||||
# Options populated by parse_args
|
||||
self.creator: str = ""
|
||||
self.task: str = ""
|
||||
self.plan_mode: bool = False
|
||||
self.planner_override: Optional[str] = None
|
||||
self.plan_talk_turns: int = 1
|
||||
self.reviewer_list: Optional[str] = None
|
||||
self.all_reviewers: bool = False
|
||||
self.max_loop: int = 3
|
||||
self.max_rebut: int = 1
|
||||
self.verbose: bool = False
|
||||
self.cleanup: bool = False
|
||||
|
||||
def print_usage(self) -> None:
|
||||
prog = "run_loop.sh"
|
||||
lines = [
|
||||
f"Usage: {prog} [options] --creator <agent-session-name> --task <goal-text>",
|
||||
"Options:",
|
||||
" --creator <name> Creator session that implements the task (required)",
|
||||
" --plan Enable Planner agent intervention & design phase",
|
||||
" --planner <name> Planner session (requires --plan; default: auto-resolve)",
|
||||
" --plan-talk N Planner-Creator discussion limit turns (default: 1)",
|
||||
" --reviewer \"A,B\" Targeted reviewer session name list (comma-separated)",
|
||||
" --all-reviewer Enforce PASS verdict from all active reviewer sessions",
|
||||
" --max-loop N Max execution-review corrective loop runs (default: 3)",
|
||||
" --max-rebut N Max rebuttal attempts per review iteration (default: 1, 0: disabled)",
|
||||
" --verbose Print detailed execution timeline traces",
|
||||
" --cleanup Purge temporary job directories upon success",
|
||||
]
|
||||
print("\n".join(lines))
|
||||
|
||||
def parse_args(self) -> None:
|
||||
args = list(self.raw_argv)
|
||||
i = 0
|
||||
while i < len(args):
|
||||
arg = args[i]
|
||||
if arg in ("-h", "--help"):
|
||||
self.print_usage()
|
||||
sys.exit(1)
|
||||
elif arg == "--creator":
|
||||
if i + 1 >= len(args):
|
||||
self.print_usage()
|
||||
sys.exit(1)
|
||||
self.creator = args[i + 1]
|
||||
i += 2
|
||||
elif arg == "--target-agent":
|
||||
print("ERROR: --target-agent was removed. Use --creator <session> instead.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
elif arg == "--task":
|
||||
if i + 1 >= len(args):
|
||||
self.print_usage()
|
||||
sys.exit(1)
|
||||
self.task = args[i + 1]
|
||||
i += 2
|
||||
elif arg == "--plan":
|
||||
self.plan_mode = True
|
||||
i += 1
|
||||
elif arg == "--planner":
|
||||
if i + 1 >= len(args):
|
||||
self.print_usage()
|
||||
sys.exit(1)
|
||||
self.planner_override = args[i + 1]
|
||||
i += 2
|
||||
elif arg == "--plan-talk":
|
||||
if i + 1 >= len(args) or not re.match(r"^[0-9]+$", args[i + 1]):
|
||||
print("ERROR: --plan-talk requires a positive integer.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
self.plan_talk_turns = int(args[i + 1])
|
||||
i += 2
|
||||
elif arg == "--reviewer":
|
||||
if i + 1 >= len(args):
|
||||
self.print_usage()
|
||||
sys.exit(1)
|
||||
self.reviewer_list = args[i + 1]
|
||||
i += 2
|
||||
elif arg == "--all-reviewer":
|
||||
self.all_reviewers = True
|
||||
i += 1
|
||||
elif arg == "--max-loop":
|
||||
if i + 1 >= len(args) or not re.match(r"^[0-9]+$", args[i + 1]) or int(args[i + 1]) <= 0:
|
||||
print("ERROR: --max-loop requires a positive non-zero integer.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
self.max_loop = int(args[i + 1])
|
||||
i += 2
|
||||
elif arg == "--max-rebut":
|
||||
if i + 1 >= len(args) or not re.match(r"^[0-9]+$", args[i + 1]):
|
||||
print("ERROR: --max-rebut requires a non-negative integer.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
self.max_rebut = int(args[i + 1])
|
||||
i += 2
|
||||
elif arg == "--verbose":
|
||||
self.verbose = True
|
||||
i += 1
|
||||
elif arg == "--cleanup":
|
||||
self.cleanup = True
|
||||
i += 1
|
||||
else:
|
||||
print(f"Unknown option: {arg}")
|
||||
self.print_usage()
|
||||
sys.exit(1)
|
||||
|
||||
if not self.creator or not self.task:
|
||||
print("ERROR: --creator and --task are mandatory fields.", file=sys.stderr)
|
||||
self.print_usage()
|
||||
sys.exit(1)
|
||||
|
||||
if self.planner_override and not self.plan_mode:
|
||||
print("ERROR: --planner was specified without --plan.", file=sys.stderr)
|
||||
print("To enable planning, include the --plan flag:", file=sys.stderr)
|
||||
print(f' run_loop.sh --creator <creator> --plan --planner <planner> --task "..."', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if self.all_reviewers and self.reviewer_list:
|
||||
log_warn(f"--all-reviewer takes precedence; ignoring --reviewer list ('{self.reviewer_list}').")
|
||||
|
||||
if self.plan_talk_turns > 0 and not self.plan_mode:
|
||||
log_warn("--plan-talk was specified but --plan mode is not enabled. Discussion turns will be ignored.")
|
||||
|
||||
def load_sessions_state(self) -> Dict[str, Any]:
|
||||
"""Load registered sessions from MAM_STATE_JSON or agent-sessions.db/yaml."""
|
||||
raw_env = os.environ.get("MAM_STATE_JSON")
|
||||
if raw_env:
|
||||
try:
|
||||
return json.loads(raw_env)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
yaml_path = os.environ.get("AGENT_SESSIONS_YAML")
|
||||
if not yaml_path:
|
||||
yaml_path = str(self.mam_dir / "agent-sessions.yaml")
|
||||
|
||||
db_path = str(Path(yaml_path).with_suffix(".db"))
|
||||
d: Dict[str, Any] = {}
|
||||
if os.path.exists(db_path):
|
||||
try:
|
||||
conn = sqlite3.connect(db_path, timeout=10.0)
|
||||
cursor = conn.execute("SELECT data FROM sessions")
|
||||
sessions = [json.loads(row[0]) for row in cursor.fetchall()]
|
||||
d["herdr_sessions"] = sessions
|
||||
conn.close()
|
||||
return d
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if os.path.exists(yaml_path):
|
||||
try:
|
||||
import yaml
|
||||
with open(yaml_path, "r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f) or {}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {}
|
||||
|
||||
def resolve_agent_type(self, name: str, state: Dict[str, Any]) -> str:
|
||||
"""Resolve agent type (claude, hermes, agy, grok, opencode, codex)."""
|
||||
for s in state.get("herdr_sessions", []):
|
||||
if s.get("name") == name:
|
||||
agent = s.get("agent") or (s.get("pane") or {}).get("cmd")
|
||||
if agent:
|
||||
return str(agent)
|
||||
break
|
||||
|
||||
segments = name.split("-")
|
||||
if "agy" in segments:
|
||||
return "agy"
|
||||
elif "hermes" in segments:
|
||||
return "hermes"
|
||||
elif "grok" in segments:
|
||||
return "grok"
|
||||
elif "opencode" in segments:
|
||||
return "opencode"
|
||||
elif "codex" in segments:
|
||||
return "codex"
|
||||
return "claude"
|
||||
|
||||
def resolve_all_reviewers(self, state: Dict[str, Any]) -> List[str]:
|
||||
"""Resolve running reviewer sessions excluding creator."""
|
||||
reviewers = []
|
||||
for s in state.get("herdr_sessions", []):
|
||||
role = (s.get("role") or "").lower()
|
||||
if "reviewer" in role and s.get("status") == "running" and s.get("name") != self.creator:
|
||||
reviewers.append(str(s.get("name")))
|
||||
return reviewers
|
||||
|
||||
def resolve_planner_session(self, state: Dict[str, Any]) -> str:
|
||||
for s in state.get("herdr_sessions", []):
|
||||
role = (s.get("role") or "").lower()
|
||||
if "planner" in role and s.get("status") == "running":
|
||||
return str(s.get("name"))
|
||||
return ""
|
||||
|
||||
def delegate_job(self, agent_session: str, agent_type: str, role: str, prompt: str) -> Optional[str]:
|
||||
"""Submit a delegate job and extract the 32-bit hex Job ID."""
|
||||
wrapper_script = self.workspace_root / ".agents" / "skills" / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
|
||||
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
|
||||
|
||||
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}")
|
||||
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)
|
||||
self.created_jobs.append(job_id)
|
||||
self._record_checkpoint()
|
||||
return job_id
|
||||
return None
|
||||
|
||||
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 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()
|
||||
|
||||
while time.time() - start_time < max_wait:
|
||||
if self.interrupted:
|
||||
return False
|
||||
status = "unknown"
|
||||
if job_file.exists():
|
||||
try:
|
||||
with open(job_file, "r", encoding="utf-8") as f:
|
||||
status = json.load(f).get("status", "unknown")
|
||||
except Exception:
|
||||
status = "unknown"
|
||||
|
||||
if status == "completed":
|
||||
if self.verbose:
|
||||
log_success(f"Job '{job_id}' completed successfully.")
|
||||
return True
|
||||
elif status in ("error", "cancelled"):
|
||||
log_error(f"Job '{job_id}' finished with status '{status}'.")
|
||||
return False
|
||||
|
||||
time.sleep(check_interval)
|
||||
|
||||
log_error(f"Job '{job_id}' timed out after {max_wait}s.")
|
||||
return False
|
||||
|
||||
def find_report(self, job_id: str) -> Optional[Path]:
|
||||
"""Locate report-final.md for a job."""
|
||||
job_dir = self.mam_dir / "jobs" / job_id
|
||||
matches = list(job_dir.glob("*/report-final.md")) + list(job_dir.glob("report-final.md"))
|
||||
if matches:
|
||||
return matches[0]
|
||||
return None
|
||||
|
||||
def collect_changes_diff(self) -> str:
|
||||
"""Collect git diff since base commit."""
|
||||
diff_script = self.workspace_root / ".agents" / "skills" / "multi-agent-mux-loop" / "scripts" / "diff_collect.sh"
|
||||
if diff_script.exists():
|
||||
cmd = f"source '{diff_script}' && mam_collect_changes_diff '{self.workspace_root}' '{self.base_commit}'"
|
||||
res = subprocess.run(["bash", "-c", cmd], capture_output=True, text=True, cwd=str(self.workspace_root))
|
||||
if res.returncode == 0:
|
||||
return res.stdout
|
||||
# Fallback to direct git diff
|
||||
try:
|
||||
cmd = ["git", "diff", self.base_commit] if self.base_commit else ["git", "diff", "HEAD"]
|
||||
res = subprocess.run(cmd, capture_output=True, text=True, cwd=str(self.workspace_root))
|
||||
return res.stdout
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _record_checkpoint(self, iteration: int = 1, review_status: Optional[Dict[str, str]] = None) -> None:
|
||||
data = {
|
||||
"iteration": iteration,
|
||||
"created_jobs": list(self.created_jobs),
|
||||
"current_plan": self.current_plan,
|
||||
"plan_job_id": self.plan_job_id,
|
||||
"review_status": review_status or {},
|
||||
"base_commit": self.base_commit,
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
}
|
||||
checkpoint.save_checkpoint(self.checkpoint_file, data)
|
||||
|
||||
def release_guard(self) -> None:
|
||||
"""Release MAM_LOOP_MARKER and clean up freeze dir if owned."""
|
||||
marker = os.environ.get("MAM_LOOP_MARKER") or str(self.mam_dir / "loop-guard-active")
|
||||
if os.path.isfile(marker):
|
||||
try:
|
||||
with open(marker, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
if f"pid={os.getpid()}" in content:
|
||||
os.unlink(marker)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
freeze_dir = os.environ.get("MAM_LOOP_FREEZE_DIR")
|
||||
if freeze_dir and os.environ.get("MAM_LOOP_FREEZE_OWNED") == "1":
|
||||
if "mam-loop-freeze." in freeze_dir and os.path.isdir(freeze_dir):
|
||||
shutil.rmtree(freeze_dir, ignore_errors=True)
|
||||
|
||||
def run(self) -> int:
|
||||
self.parse_args()
|
||||
|
||||
atexit.register(self.release_guard)
|
||||
|
||||
# Signal handling
|
||||
def _handle_signal(signum, frame):
|
||||
self.interrupted = True
|
||||
log_warn(f"Received signal {signum}. Quiescing and saving checkpoint...")
|
||||
self._record_checkpoint()
|
||||
self.release_guard()
|
||||
sys.exit(128 + signum)
|
||||
|
||||
signal.signal(signal.SIGINT, _handle_signal)
|
||||
signal.signal(signal.SIGTERM, _handle_signal)
|
||||
|
||||
log_info("Initializing multi-agent-mux-loop controller...")
|
||||
log_info(f"Target Agent: {self.creator}")
|
||||
log_info(f"Task Goal: {self.task}")
|
||||
|
||||
state = self.load_sessions_state()
|
||||
|
||||
# Validate target agent session
|
||||
target_status = None
|
||||
for s in state.get("herdr_sessions", []):
|
||||
if s.get("name") == self.creator:
|
||||
target_status = s.get("status")
|
||||
break
|
||||
|
||||
if not target_status:
|
||||
log_error(f"Target agent session '{self.creator}' is not registered in the session registry.")
|
||||
return 1
|
||||
elif target_status != "running":
|
||||
log_error(f"Target agent session '{self.creator}' is not running (current status: '{target_status}'). Please start it first.")
|
||||
return 1
|
||||
|
||||
# Resolve Planner session
|
||||
planner_session = ""
|
||||
if self.planner_override:
|
||||
planner_session = self.planner_override
|
||||
p_status = None
|
||||
for s in state.get("herdr_sessions", []):
|
||||
if s.get("name") == planner_session:
|
||||
p_status = s.get("status")
|
||||
break
|
||||
if not p_status:
|
||||
log_error(f"Specified planner session '{planner_session}' is not registered in the session registry.")
|
||||
return 1
|
||||
elif p_status != "running":
|
||||
log_error(f"Specified planner session '{planner_session}' is not running (current status: '{p_status}').")
|
||||
return 1
|
||||
else:
|
||||
planner_session = self.resolve_planner_session(state)
|
||||
if self.plan_mode and not planner_session:
|
||||
log_error("Planner mode enabled (--plan) but no running session with a 'planner' role was found.")
|
||||
return 1
|
||||
|
||||
log_info(f"Resolved Planner session: {planner_session}")
|
||||
|
||||
# =======================================================================
|
||||
# PHASE 1: PLANNING & DISCUSSIONS
|
||||
# =======================================================================
|
||||
if self.plan_mode:
|
||||
log_info("=== Phase 1: Interactive Planning Phase ===")
|
||||
log_info("Requesting initial implementation plan from Planner...")
|
||||
p_type = self.resolve_agent_type(planner_session, state)
|
||||
plan_prompt = f"태스크 목표를 바탕으로 구체적인 구현 계획서를 작성해주세요. 목표: {self.task}"
|
||||
p_jid = self.delegate_job(planner_session, p_type, "Planner", plan_prompt)
|
||||
if not p_jid:
|
||||
log_error("Failed to register planner job.")
|
||||
return 1
|
||||
self.plan_job_id = p_jid
|
||||
log_info(f"Planner Job ID: {p_jid}")
|
||||
|
||||
if not self.wait_for_job(p_jid):
|
||||
log_error("Planning phase failed during initial plan design.")
|
||||
return 1
|
||||
|
||||
p_file = self.find_report(p_jid)
|
||||
if not p_file or not p_file.exists():
|
||||
log_error("Final plan file not found.")
|
||||
return 1
|
||||
self.current_plan = p_file.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
# Interactive turns
|
||||
creator_type = self.resolve_agent_type(self.creator, state)
|
||||
for turn in range(1, self.plan_talk_turns + 1):
|
||||
log_info(f"Discussion Turn {turn}/{self.plan_talk_turns}: Creator challenging the plan...")
|
||||
c_prompt = f"Planner가 제시한 다음 계획서를 꼼꼼히 검토하고, 실제 구현 시 마주할 수 있는 맹점이나 제약사항 1가지를 발굴하여 Planner에게 이의를 제기(Challenge)해주세요. 계획서:\n{self.current_plan}"
|
||||
c_jid = self.delegate_job(self.creator, creator_type, "Worker", c_prompt)
|
||||
if not c_jid or not self.wait_for_job(c_jid):
|
||||
log_error("Creator critique step failed.")
|
||||
return 1
|
||||
|
||||
c_file = self.find_report(c_jid)
|
||||
critique_text = c_file.read_text(encoding="utf-8", errors="replace") if c_file else ""
|
||||
|
||||
log_info("Planner refining plan with Creator's feedback...")
|
||||
refine_prompt = f"작업자(Creator)로부터 다음 이의제기 피드백을 받았습니다. 피드백을 반영하여 계획서를 정교하게 업데이트(Refine)하여 다시 출력해주세요. 피드백:\n{critique_text}\n기존 계획서:\n{self.current_plan}"
|
||||
ref_jid = self.delegate_job(planner_session, p_type, "Planner", refine_prompt)
|
||||
if not ref_jid or not self.wait_for_job(ref_jid):
|
||||
log_error("Planner refinement step failed.")
|
||||
return 1
|
||||
|
||||
ref_file = self.find_report(ref_jid)
|
||||
if ref_file and ref_file.exists():
|
||||
self.current_plan = ref_file.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
log_success("Interactive planning completed. Plan finalized.")
|
||||
else:
|
||||
log_info("=== Phase 1: Self-Planning Mode (Direct Execution) ===")
|
||||
if planner_session:
|
||||
plan_files = sorted(glob.glob(str(self.workspace_root / ".agents" / "reports" / planner_session / "plan-*.md")), key=os.path.getmtime, reverse=True)
|
||||
if plan_files:
|
||||
log_info(f"Found existing promoted plan at '{plan_files[0]}'. Loading plan...")
|
||||
self.current_plan = Path(plan_files[0]).read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
# =======================================================================
|
||||
# PHASE 2: IMPLEMENTATION (CREATION)
|
||||
# =======================================================================
|
||||
log_info("=== Phase 2: Code Implementation ===")
|
||||
try:
|
||||
res_base = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True, cwd=str(self.workspace_root))
|
||||
self.base_commit = res_base.stdout.strip()
|
||||
except Exception:
|
||||
self.base_commit = ""
|
||||
|
||||
if self.current_plan:
|
||||
exec_prompt = f"이미 수립된 다음 계획서에 입각하여 작업자의 판단하에 코드를 구현하고 작업 목표를 완성해주세요. 계획서:\n{self.current_plan}\n\n작업 목표: {self.task}"
|
||||
else:
|
||||
exec_prompt = f"계획서가 존재하지 않으므로, 작업자(Creator)의 판단하에 스스로 구현 계획 및 설계를 수립한 뒤, 이를 바탕으로 코드를 구현하고 다음 작업 목표를 완성해주세요. 작업 목표: {self.task}"
|
||||
|
||||
creator_type = self.resolve_agent_type(self.creator, state)
|
||||
exec_jid = self.delegate_job(self.creator, creator_type, "Worker", exec_prompt)
|
||||
if not exec_jid:
|
||||
log_error("Failed to register Creator execution job.")
|
||||
return 1
|
||||
log_info(f"Creator Job ID: {exec_jid}")
|
||||
|
||||
if not self.wait_for_job(exec_jid):
|
||||
log_error("Creator execution failed.")
|
||||
return 1
|
||||
log_success("Initial implementation finished.")
|
||||
|
||||
# =======================================================================
|
||||
# PHASE 3: VERIFICATION LOOP (PEER REVIEW)
|
||||
# =======================================================================
|
||||
log_info("=== Phase 3: Verification & Corrective Review Loop ===")
|
||||
|
||||
reviewers: List[str] = []
|
||||
if self.all_reviewers:
|
||||
reviewers = self.resolve_all_reviewers(state)
|
||||
if not reviewers:
|
||||
log_error("--all-reviewer was specified, but no running reviewer sessions were found.")
|
||||
return 1
|
||||
elif self.reviewer_list:
|
||||
reviewers = [r.strip() for r in self.reviewer_list.split(",") if r.strip()]
|
||||
|
||||
rebut_total_budget = self.max_rebut * self.max_loop
|
||||
|
||||
final_passed_reviewers: Dict[str, Tuple[str, Path]] = {}
|
||||
|
||||
for loop_count in range(1, self.max_loop + 1):
|
||||
log_info(f"Review Loop Iteration {loop_count}/{self.max_loop}...")
|
||||
rebut_budget = self.max_rebut
|
||||
self._record_checkpoint(iteration=loop_count)
|
||||
|
||||
if not reviewers:
|
||||
# Creator Self-Review
|
||||
log_warn("No reviewers specified. Conducting Creator Self-Review...")
|
||||
sr_prompt = "작업 완료 상태에 대해 스스로 검증(Self-Review)하여 결함이 없음을 확인하고 종결해주세요. 리뷰 리포트 마지막에 단독 행으로 반드시 '[VERDICT: PASS]' 혹은 '[VERDICT: NOT PASS]' 태그를 명시해주세요."
|
||||
sr_jid = self.delegate_job(self.creator, creator_type, "Reviewer", sr_prompt)
|
||||
if not sr_jid:
|
||||
log_error("Failed to register Self-Review job.")
|
||||
return 1
|
||||
self.wait_for_job(sr_jid)
|
||||
sr_report = self.find_report(sr_jid)
|
||||
if sr_report and parser.has_verdict(sr_report, "PASS") and not parser.has_verdict(sr_report, "NOT PASS"):
|
||||
log_success("Self-Review PASS.")
|
||||
break
|
||||
else:
|
||||
log_warn("Self-Review NOT PASS.")
|
||||
if loop_count == self.max_loop:
|
||||
log_error("Reached max loop count. Self-review loop aborted with failures.")
|
||||
return 1
|
||||
continue
|
||||
|
||||
log_info(f"Active reviewers: {' '.join(reviewers)}")
|
||||
changes_diff = self.collect_changes_diff()
|
||||
|
||||
review_jobs: List[Tuple[str, str]] = [] # (rev, job_id)
|
||||
for rev in reviewers:
|
||||
log_info(f"Requesting code review from Reviewer '{rev}'...")
|
||||
r_type = self.resolve_agent_type(rev, state)
|
||||
r_prompt = f"다음 구현 사항(작업 목표: {self.task}) 및 누적 변경분(git diff)에 대해 린트, 동작성, 유실 등의 관점에서 교차 코드 리뷰를 수행해주세요. 확인 후 최종 Verdict로 '[VERDICT: PASS]' 혹은 '[VERDICT: NOT PASS]' 태그를 리뷰 리포트 마지막에 단독 행으로 명시적으로 작성해주세요. 만약 단순 버그 수정으로는 부족하고 설계 변경/재작업 수준의 재계획이 필요하다고 판단되면, 리포트 아무 곳에나 단독 행으로 '[ESCALATE: PLANNER]' 태그도 함께 남겨주세요. 변경분:\n{changes_diff}"
|
||||
r_jid = self.delegate_job(rev, r_type, "Reviewer", r_prompt)
|
||||
if not r_jid:
|
||||
log_error(f"Failed to register review job for '{rev}'.")
|
||||
return 1
|
||||
review_jobs.append((rev, r_jid))
|
||||
|
||||
all_passed = True
|
||||
fail_revs: List[str] = []
|
||||
fail_reports: List[Path] = []
|
||||
feedback_parts: List[str] = []
|
||||
|
||||
for rev, jid in review_jobs:
|
||||
if not self.wait_for_job(jid):
|
||||
log_warn(f"Reviewer '{rev}' job crashed.")
|
||||
all_passed = False
|
||||
continue
|
||||
|
||||
r_file = self.find_report(jid)
|
||||
if not r_file or not r_file.exists():
|
||||
log_warn(f"Reviewer '{rev}' report not found. Counting as NOT PASS.")
|
||||
all_passed = False
|
||||
continue
|
||||
|
||||
content = r_file.read_text(encoding="utf-8", errors="replace")
|
||||
verdict = parser.parse_verdict(content)
|
||||
|
||||
if verdict != "PASS":
|
||||
log_warn(f"Reviewer '{rev}': NOT PASS")
|
||||
all_passed = False
|
||||
fail_revs.append(rev)
|
||||
fail_reports.append(r_file)
|
||||
feedback_parts.append(f"\n--- Reviewer ({rev}) Feedback ---\n{content}")
|
||||
else:
|
||||
log_success(f"Reviewer '{rev}': PASS")
|
||||
final_passed_reviewers[rev] = (jid, r_file)
|
||||
|
||||
if all_passed:
|
||||
log_success("All reviewers issued [VERDICT: PASS]. Loop completed successfully.")
|
||||
break
|
||||
|
||||
if loop_count == self.max_loop:
|
||||
log_error(f"Reached max loop count ({self.max_loop}). Review loop aborted with failures.")
|
||||
return 1
|
||||
|
||||
feedback_aggregate = "".join(feedback_parts)
|
||||
complex_fix = any(parser.has_tag_line(rf, "ESCALATE: PLANNER") for rf in fail_reports)
|
||||
|
||||
if self.plan_mode and complex_fix:
|
||||
log_warn("Feedback involves complex code modifications. Diverting to Planner to revise plan...")
|
||||
p_type = self.resolve_agent_type(planner_session, state)
|
||||
refine_prompt = f"리뷰어들로부터 다음과 같이 정교한 코드 수정 피드백이 도착했습니다. 해당 피드백을 수렴하여 구현 계획서(Plan)를 갱신(Refine)하여 다시 작성해주세요. 피드백:\n{feedback_aggregate}\n기존 계획서:\n{self.current_plan}"
|
||||
ref_jid = self.delegate_job(planner_session, p_type, "Planner", refine_prompt)
|
||||
if not ref_jid or not self.wait_for_job(ref_jid):
|
||||
log_error("Failed to register plan refinement job.")
|
||||
return 1
|
||||
ref_file = self.find_report(ref_jid)
|
||||
if ref_file and ref_file.exists():
|
||||
self.current_plan = ref_file.read_text(encoding="utf-8", errors="replace")
|
||||
correction_prompt = f"갱신된 다음 계획서에 입각하여 지적된 오류들을 수정하고 코드를 다시 구현해주세요. 계획서:\n{self.current_plan}\n피드백 상세:\n{feedback_aggregate}"
|
||||
else:
|
||||
log_info("Applying straight bugfixes based on reviewer feedback...")
|
||||
correction_prompt = f"리뷰어들이 지적한 다음 피드백에 입각하여 코드를 수정해주세요. 피드백:\n{feedback_aggregate}"
|
||||
|
||||
if self.max_rebut > 0:
|
||||
correction_prompt += f"\n\n[이의제기 채널] 위 피드백 중 타당하지 않다고 판단되는 항목이 있다면, 그 항목은 구현하지 말고 반론을 제기하십시오. 반론 시 리포트에 단독 행으로 '[REBUT: <리뷰어_세션명>]' 태그를 남기고, 해당 리뷰어의 지적 중 어느 항목을 왜 거부하는지 근거를 함께 서술하십시오. 타당한 지적은 정상적으로 반영하십시오. 반론할 항목이 없으면 태그를 남기지 마십시오. 대상 리뷰어 세션명: {' '.join(fail_revs) if fail_revs else '(없음)'}"
|
||||
|
||||
correct_jid = self.delegate_job(self.creator, creator_type, "Worker", correction_prompt)
|
||||
if not correct_jid or not self.wait_for_job(correct_jid):
|
||||
log_error("Failed to register Creator correction job.")
|
||||
return 1
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# REBUTTAL & ADJUDICATION ROUND
|
||||
# -----------------------------------------------------------------
|
||||
if self.max_rebut > 0 and rebut_budget > 0 and rebut_total_budget > 0:
|
||||
correct_report = self.find_report(correct_jid)
|
||||
rebut_targets: List[str] = []
|
||||
if correct_report:
|
||||
rebut_tags = parser.extract_tag_values(correct_report, "REBUT")
|
||||
for t in rebut_tags:
|
||||
if t in fail_revs and t not in rebut_targets:
|
||||
rebut_targets.append(t)
|
||||
|
||||
if rebut_targets:
|
||||
rebut_budget -= 1
|
||||
rebut_total_budget -= 1
|
||||
rebuttal_text = correct_report.read_text(encoding="utf-8", errors="replace") if correct_report else ""
|
||||
log_warn(f"Creator filed a rebuttal against: {' '.join(rebut_targets)} (iteration budget left: {rebut_budget}, run total left: {rebut_total_budget})")
|
||||
|
||||
overruled_revs: List[str] = []
|
||||
for rt in rebut_targets:
|
||||
log_info(f"Re-adjudication: returning rebuttal to Reviewer '{rt}'...")
|
||||
rt_type = self.resolve_agent_type(rt, state)
|
||||
adj_prompt = f"작업자(Creator)가 귀하의 리뷰 지적 중 일부를 타당하지 않다고 판단하여 반론을 제기했습니다. 반론을 검토하고 재심(re-adjudication)하십시오. 반론이 타당하여 귀하의 지적을 철회한다면 리포트 마지막에 단독 행으로 '[ADJUDICATION: SUSTAINED]' 를, 반론이 타당하지 않아 지적을 유지한다면 '[ADJUDICATION: OVERRULED]' 를 명시하십시오. 반론 내용:\n{rebuttal_text}"
|
||||
adj_jid = self.delegate_job(rt, rt_type, "Reviewer", adj_prompt)
|
||||
if not adj_jid or not self.wait_for_job(adj_jid):
|
||||
log_warn(f"Re-adjudication job for '{rt}' failed; objection stands.")
|
||||
overruled_revs.append(rt)
|
||||
continue
|
||||
|
||||
adj_file = self.find_report(adj_jid)
|
||||
if adj_file and parser.has_tag_line(adj_file, "ADJUDICATION: SUSTAINED"):
|
||||
log_success(f"Reviewer '{rt}' SUSTAINED the rebuttal; objection withdrawn.")
|
||||
else:
|
||||
log_warn(f"Reviewer '{rt}' OVERRULED the rebuttal; objection stands.")
|
||||
overruled_revs.append(rt)
|
||||
|
||||
if overruled_revs:
|
||||
arbitration = "REVIEWER"
|
||||
if self.plan_mode and planner_session:
|
||||
log_info(f"Deadlock on {' '.join(overruled_revs)}; escalating to Planner for arbitration...")
|
||||
p_type = self.resolve_agent_type(planner_session, state)
|
||||
arb_prompt = f"작업자(Creator)와 리뷰어의 의견이 충돌하여 교착 상태입니다. 계획서 관점에서 어느 쪽이 타당한지 재정(arbitration)하십시오. 리포트 마지막에 단독 행으로 '[ARBITRATION: CREATOR]' 또는 '[ARBITRATION: REVIEWER]' 를 명시하십시오. 리뷰어 지적:\n{feedback_aggregate}\n작업자 반론:\n{rebuttal_text}\n기존 계획서:\n{self.current_plan}"
|
||||
arb_jid = self.delegate_job(planner_session, p_type, "Planner", arb_prompt)
|
||||
if arb_jid and self.wait_for_job(arb_jid):
|
||||
arb_file = self.find_report(arb_jid)
|
||||
if arb_file and parser.has_tag_line(arb_file, "ARBITRATION: CREATOR"):
|
||||
arbitration = "CREATOR"
|
||||
else:
|
||||
log_warn("Planner arbitration job failed; defaulting to REVIEWER.")
|
||||
|
||||
if arbitration == "CREATOR":
|
||||
log_success("Planner ruled for the Creator; objections withdrawn for this round.")
|
||||
else:
|
||||
log_warn("Ruling stands with the reviewer(s); Creator must comply.")
|
||||
rebut_budget = 0
|
||||
comply_prompt = f"귀하의 반론은 재심 결과 기각되었습니다. 더 이상 반론하지 말고 다음 리뷰 피드백을 그대로 반영하여 코드를 수정하십시오. 피드백:\n{feedback_aggregate}"
|
||||
comp_jid = self.delegate_job(self.creator, creator_type, "Worker", comply_prompt)
|
||||
if comp_jid:
|
||||
self.wait_for_job(comp_jid)
|
||||
|
||||
# Promotion
|
||||
if self.plan_mode and self.current_plan and self.plan_job_id:
|
||||
plan_dest_dir = self.workspace_root / ".agents" / "reports" / planner_session
|
||||
log_info(f"Promoting final plan to durable location: {plan_dest_dir}")
|
||||
plan_dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
tmp_plan = plan_dest_dir / f"plan-{self.plan_job_id}.md.tmp"
|
||||
tmp_plan.write_text(self.current_plan, encoding="utf-8")
|
||||
tmp_plan.replace(plan_dest_dir / f"plan-{self.plan_job_id}.md")
|
||||
|
||||
if final_passed_reviewers:
|
||||
log_info("Promoting final review reports to durable location...")
|
||||
for rev, (jid, rep_file) in final_passed_reviewers.items():
|
||||
dest_dir = self.workspace_root / ".agents" / "reports" / rev
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest_file = dest_dir / f"report-{jid}.md"
|
||||
tmp_dest = dest_dir / f"report-{jid}.md.tmp"
|
||||
shutil.copyfile(rep_file, tmp_dest)
|
||||
tmp_dest.replace(dest_file)
|
||||
|
||||
# Cleanup
|
||||
if self.cleanup:
|
||||
log_info("Cleaning up temporary job directories created during this loop run...")
|
||||
for job in self.created_jobs:
|
||||
job_dir = self.mam_dir / "jobs" / job
|
||||
if job_dir.exists():
|
||||
shutil.rmtree(job_dir, ignore_errors=True)
|
||||
sub_out = self.mam_dir / "jobs" / f"{job}.subscriber.out"
|
||||
if sub_out.exists():
|
||||
sub_out.unlink(missing_ok=True)
|
||||
if self.verbose:
|
||||
log_info(f"Purged temp assets for job: {job}")
|
||||
log_success("Cleanup complete.")
|
||||
|
||||
checkpoint.remove_checkpoint(self.checkpoint_file)
|
||||
log_success("Mux loop finished with 100% PASS verdicts.")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
runner = LoopRunner(argv)
|
||||
return runner.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Shadow runner: decides what it *would* commit, and cannot commit it.
|
||||
|
||||
P2A-03 requires that this phase never writes registry state. That is enforced
|
||||
structurally rather than promised: ShadowRunner is never given the transition
|
||||
engine, so it has no means to write. The gate goes further and hashes the whole
|
||||
registry directory around a run, which is a stronger claim than checking that a
|
||||
status field did not change.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
from lib_py.loop import policy, store
|
||||
|
||||
logger = logging.getLogger("loop.shadow")
|
||||
|
||||
|
||||
class ShadowRunner(object):
|
||||
"""Runs observation -> decision -> log. Holds no writer of any kind."""
|
||||
|
||||
def __init__(self, workspace=None, decide: Callable = policy.decide, sink=None):
|
||||
self.workspace = workspace
|
||||
self._decide = decide
|
||||
self._sink = sink if sink is not None else self._default_sink
|
||||
|
||||
@staticmethod
|
||||
def _default_sink(job_id, entry, workspace=None):
|
||||
store.append_decision(job_id, entry, workspace=workspace)
|
||||
|
||||
def evaluate(self, scenario: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Decide for one composite scenario and record the would-be outcome."""
|
||||
job_id = (scenario.get("dispatch_context") or {}).get("job_id") or \
|
||||
scenario.get("scenario_id") or "unknown"
|
||||
decision = self._decide(scenario)
|
||||
entry = {
|
||||
"job_id": job_id,
|
||||
"scenario_id": scenario.get("scenario_id"),
|
||||
"would_complete": decision.would_complete,
|
||||
"shadow": True,
|
||||
}
|
||||
entry.update(decision.as_dict())
|
||||
self._sink(job_id, entry, workspace=self.workspace)
|
||||
if decision.would_complete:
|
||||
logger.info("shadow: WOULD-COMPLETE job %s (%s)", job_id, decision.reason)
|
||||
else:
|
||||
logger.debug("shadow: hold job %s (%s)", job_id, decision.reason)
|
||||
return entry
|
||||
|
||||
def evaluate_all(self, scenarios) -> list:
|
||||
return [self.evaluate(s) for s in scenarios]
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Shadow-owned store for dispatch and attribution evidence.
|
||||
|
||||
Deliberately NOT the job record. Two constraints force this:
|
||||
|
||||
* phase 1 froze the registry record schema, and
|
||||
* P2A-03 forbids this phase from writing the registry at all.
|
||||
|
||||
The record has none of `attempt_id`, `agent_session_uuid` or
|
||||
`pane_process_generation`, so four-fold attribution has nowhere to live in it.
|
||||
Keeping the evidence here means shadow mode can carry full attribution while the
|
||||
registry stays byte-for-byte untouched. Whether these fields are promoted into
|
||||
the record is a phase 2b decision this phase does not prejudge.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
SHADOW_DIRNAME = os.path.join(".mam", "shadow")
|
||||
|
||||
|
||||
def shadow_root(workspace: Optional[str] = None) -> Path:
|
||||
return Path(workspace or os.getcwd()) / SHADOW_DIRNAME
|
||||
|
||||
|
||||
def attribution_path(job_id: str, workspace: Optional[str] = None) -> Path:
|
||||
return shadow_root(workspace) / "attribution" / "{}.json".format(job_id)
|
||||
|
||||
|
||||
def decisions_path(job_id: str, workspace: Optional[str] = None) -> Path:
|
||||
return shadow_root(workspace) / "{}.ndjson".format(job_id)
|
||||
|
||||
|
||||
def _atomic_write(path: Path, text: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".{}.".format(path.name), suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(text)
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
|
||||
|
||||
def record_dispatch(job_id, attempt_id, agent_session_uuid, pane_process_generation,
|
||||
dispatch_ack=True, workspace=None, dispatch_token=None) -> Dict[str, Any]:
|
||||
"""Persist the four identifiers at dispatch time.
|
||||
|
||||
The execution anchor starts false on purpose: dispatch being acknowledged is
|
||||
not evidence that the attempt ran.
|
||||
"""
|
||||
context = {
|
||||
"job_id": job_id,
|
||||
"attempt_id": attempt_id,
|
||||
"agent_session_uuid": agent_session_uuid,
|
||||
"pane_process_generation": pane_process_generation,
|
||||
"dispatch_ack": bool(dispatch_ack),
|
||||
"execution_anchor_observed": False,
|
||||
# The record's token for the attempt this evidence belongs to. Carrying
|
||||
# it here is what lets the gate prove the evidence and the commit are
|
||||
# about the same attempt.
|
||||
"dispatch_token": dispatch_token,
|
||||
}
|
||||
_atomic_write(attribution_path(job_id, workspace),
|
||||
json.dumps(context, ensure_ascii=False, indent=2) + "\n")
|
||||
return context
|
||||
|
||||
|
||||
def mark_execution_anchor(job_id, workspace=None) -> Optional[Dict[str, Any]]:
|
||||
"""Record that this attempt was actually observed executing after dispatch."""
|
||||
context = load_dispatch(job_id, workspace)
|
||||
if context is None:
|
||||
return None
|
||||
context["execution_anchor_observed"] = True
|
||||
_atomic_write(attribution_path(job_id, workspace),
|
||||
json.dumps(context, ensure_ascii=False, indent=2) + "\n")
|
||||
return context
|
||||
|
||||
|
||||
def load_dispatch(job_id, workspace=None) -> Optional[Dict[str, Any]]:
|
||||
path = attribution_path(job_id, workspace)
|
||||
if not path.exists():
|
||||
return None
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def append_decision(job_id, entry: Dict[str, Any], workspace=None) -> None:
|
||||
"""Append one would-complete decision. The only thing shadow mode emits."""
|
||||
path = decisions_path(job_id, workspace)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def read_decisions(job_id, workspace=None) -> list:
|
||||
path = decisions_path(job_id, workspace)
|
||||
if not path.exists():
|
||||
return []
|
||||
out = []
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
if line.strip():
|
||||
out.append(json.loads(line))
|
||||
return out
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Wraps `herdr agent wait`. Observes; never concludes.
|
||||
|
||||
Live contract (herdr 0.8.2, re-confirmed while planning):
|
||||
|
||||
herdr agent wait <TARGET> --until <STATUS>... --timeout <MS>
|
||||
|
||||
* `--timeout` is MILLISECONDS. The parameter here is named `timeout_ms` so the
|
||||
unit cannot be misread at a call site; there is deliberately no seconds form.
|
||||
* `--until` is always passed explicitly. Omitting it matches idle/done/blocked,
|
||||
silently excluding `working`, which is a state the watcher cares about.
|
||||
* success -> exit 0, snapshot JSON on STDOUT.
|
||||
* timeout and unknown target -> exit 1, error JSON on STDERR, stdout empty.
|
||||
The stream split is the contract: an error is never read from stdout.
|
||||
|
||||
A single wait is one synchronous barrier returning one final snapshot, so
|
||||
continuous observation is built by repeating it, not by streaming.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import Any, Dict, Optional, Sequence
|
||||
|
||||
from lib_py.loop.evidence import Observation
|
||||
|
||||
logger = logging.getLogger("loop.watcher")
|
||||
|
||||
DEFAULT_UNTIL = ("idle", "done", "blocked")
|
||||
VALID_STATES = ("idle", "working", "blocked", "done", "unknown")
|
||||
|
||||
# Outcome kinds. `timeout` is an ordinary no-event result, not a failure.
|
||||
MATCHED = "matched"
|
||||
TIMEOUT = "timeout"
|
||||
AGENT_NOT_FOUND = "agent_not_found"
|
||||
TRANSPORT_ERROR = "transport_error"
|
||||
|
||||
BACKOFF_START_S = 1.0
|
||||
BACKOFF_MAX_S = 16.0
|
||||
|
||||
|
||||
class WaitOutcome(object):
|
||||
__slots__ = ("kind", "agent", "error_code", "detail", "raw")
|
||||
|
||||
def __init__(self, kind, agent=None, error_code=None, detail="", raw=None):
|
||||
self.kind = kind
|
||||
self.agent = agent
|
||||
self.error_code = error_code
|
||||
self.detail = detail
|
||||
self.raw = raw
|
||||
|
||||
@property
|
||||
def healthy(self) -> bool:
|
||||
"""A timeout is healthy: the socket answered, the state just did not change."""
|
||||
return self.kind in (MATCHED, TIMEOUT, AGENT_NOT_FOUND)
|
||||
|
||||
def __repr__(self): # pragma: no cover - debugging aid
|
||||
return "WaitOutcome({!r}, error_code={!r})".format(self.kind, self.error_code)
|
||||
|
||||
|
||||
def build_argv(target: str, until: Sequence[str] = DEFAULT_UNTIL,
|
||||
timeout_ms: Optional[int] = None) -> list:
|
||||
if not target:
|
||||
raise ValueError("wait target is required")
|
||||
argv = ["herdr", "agent", "wait", target]
|
||||
for state in until:
|
||||
if state not in VALID_STATES:
|
||||
raise ValueError("unsupported --until state: {!r}".format(state))
|
||||
argv += ["--until", state]
|
||||
if timeout_ms is not None:
|
||||
if int(timeout_ms) <= 0:
|
||||
raise ValueError("timeout_ms must be positive milliseconds")
|
||||
argv += ["--timeout", str(int(timeout_ms))]
|
||||
return argv
|
||||
|
||||
|
||||
def _parse(stream: str) -> Optional[Dict[str, Any]]:
|
||||
try:
|
||||
payload = json.loads(stream)
|
||||
return payload if isinstance(payload, dict) else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def wait_once(target: str, until: Sequence[str] = DEFAULT_UNTIL,
|
||||
timeout_ms: Optional[int] = 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)
|
||||
try:
|
||||
proc = run(argv, capture_output=True, text=True)
|
||||
except Exception as exc:
|
||||
return WaitOutcome(TRANSPORT_ERROR, detail="herdr could not be executed: {}".format(exc))
|
||||
|
||||
if proc.returncode == 0:
|
||||
payload = _parse(proc.stdout or "")
|
||||
agent = ((payload or {}).get("result") or {}).get("agent")
|
||||
if not isinstance(agent, dict):
|
||||
return WaitOutcome(TRANSPORT_ERROR,
|
||||
detail="exit 0 without a parseable agent snapshot",
|
||||
raw=proc.stdout)
|
||||
return WaitOutcome(MATCHED, agent=agent, raw=payload)
|
||||
|
||||
# Non-zero: the payload is on stderr by contract, and stdout is empty.
|
||||
payload = _parse(proc.stderr or "")
|
||||
code = ((payload or {}).get("error") or {}).get("code")
|
||||
if code == "timeout":
|
||||
return WaitOutcome(TIMEOUT, error_code=code, raw=payload)
|
||||
if code == "agent_not_found":
|
||||
return WaitOutcome(AGENT_NOT_FOUND, error_code=code, raw=payload)
|
||||
return WaitOutcome(TRANSPORT_ERROR, error_code=code,
|
||||
detail="unrecognised failure (rc={})".format(proc.returncode),
|
||||
raw=proc.stderr)
|
||||
|
||||
|
||||
def observation_from(outcome: WaitOutcome, capture_ref=None) -> Optional[Observation]:
|
||||
"""Turn a matched snapshot into evidence. Non-matches carry no agent state."""
|
||||
if outcome.kind != MATCHED or not outcome.agent:
|
||||
return None
|
||||
agent = outcome.agent
|
||||
return Observation(
|
||||
"herdr",
|
||||
{
|
||||
"agent_status": agent.get("agent_status"),
|
||||
"interactive_ready": agent.get("interactive_ready"),
|
||||
"pane_id": agent.get("pane_id"),
|
||||
"state_change_seq": agent.get("state_change_seq"),
|
||||
},
|
||||
capture_ref=capture_ref,
|
||||
)
|
||||
|
||||
|
||||
class ReconnectBackoff(object):
|
||||
"""Doubling backoff for transport failures, reset by any healthy answer.
|
||||
|
||||
The snapshot cache is dropped on failure on purpose: after a socket gap the
|
||||
watcher must re-observe rather than reason from a state that may have moved
|
||||
while it was blind. That is what makes `hold_reconnect` the honest decision.
|
||||
"""
|
||||
|
||||
def __init__(self, start=BACKOFF_START_S, maximum=BACKOFF_MAX_S):
|
||||
self.start = start
|
||||
self.maximum = maximum
|
||||
self.current = 0.0
|
||||
self.consecutive_failures = 0
|
||||
self.cached_snapshot = None
|
||||
|
||||
def record_success(self, snapshot=None) -> None:
|
||||
self.consecutive_failures = 0
|
||||
self.current = 0.0
|
||||
if snapshot is not None:
|
||||
self.cached_snapshot = snapshot
|
||||
|
||||
def record_failure(self) -> float:
|
||||
self.consecutive_failures += 1
|
||||
self.cached_snapshot = None # stale evidence must not survive a gap
|
||||
self.current = self.start if self.current == 0.0 else min(self.current * 2.0, self.maximum)
|
||||
return self.current
|
||||
|
||||
def sequence(self, n: int) -> list:
|
||||
"""The first n delays, for documentation and for tests to pin."""
|
||||
saved = (self.current, self.consecutive_failures, self.cached_snapshot)
|
||||
try:
|
||||
self.current, self.consecutive_failures = 0.0, 0
|
||||
return [self.record_failure() for _ in range(n)]
|
||||
finally:
|
||||
self.current, self.consecutive_failures, self.cached_snapshot = saved
|
||||
@@ -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.2.0
|
||||
version: 4.3.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
- 브로커 PoC→운영 전환: [`mqtt-broker-setup.md`](./mqtt-broker-setup.md)
|
||||
- 레지스트리 포맷/동시성: [`registry.md`](./registry.md)
|
||||
- 참조 구현: [`multi-agent-mux-delegate-job`](./multi-agent-mux-delegate-job) (bash wrapper), [`scripts/publish_event.py`](./scripts/publish_event.py), [`scripts/job_subscriber.py`](./scripts/job_subscriber.py), [`scripts/registry.py`](./scripts/registry.py), [`scripts/mqtt_common.py`](./scripts/mqtt_common.py)
|
||||
- **v4.3.0 이후**: 위 `scripts/*.py`는 호환 shim이며 실제 구현은 `.agents/skills/lib_py/delegation/`에 있다 (issue #5 phase 1). 스크립트 경로와 CLI 계약은 그대로이며, `lib.sh` 헬퍼는 `python -m lib_py.delegation.cli`를 호출한다.
|
||||
- 영구 감사 로그: `.mam/delegate_job_logs/<job_id>/` (`meta.json`·`events.ndjson`·`status.json`)
|
||||
— `multi-agent-mux-delegate-job logs <id>` 또는 `multi-agent-mux-delegate-job logs --list`로 조회 (SKILL.md "Audit Logs" 참조)
|
||||
@@ -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.2.0
|
||||
version: 4.3.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos, windows]
|
||||
@@ -70,6 +70,42 @@ For detailed state machine diagrams and configurations, see [DELEGATION_TYPES.md
|
||||
|
||||
---
|
||||
|
||||
## Where the Implementation Lives (v4.3.0)
|
||||
|
||||
The implementation moved to the `lib_py.delegation` package in issue #5 phase 1.
|
||||
`scripts/{mqtt_common,registry,publish_event,job_subscriber}.py` remain as
|
||||
compatibility shims and **their paths are unchanged** -- every command in this
|
||||
document still works verbatim. Each shim binds the `lib_py.delegation` package
|
||||
to the tree next to itself, once per process, without touching `sys.path`, so
|
||||
`import mqtt_common` yields the same module object as
|
||||
`lib_py.delegation.mqtt_common`.
|
||||
|
||||
Which tree wins, by execution context:
|
||||
|
||||
| Context | Resolved by | Tree that wins |
|
||||
|---|---|---|
|
||||
| Loop process and its children (frozen `lib.sh` sourced) | the helper's explicit bootstrap from `SKILL_DIR` | the frozen snapshot |
|
||||
| Agent session running a **frozen** script path while `PYTHONPATH` still points live | the shim, from its own location | the frozen snapshot (B-13 preserved) |
|
||||
| Live helpers (create/stop/resume/reconcile) | `PYTHONPATH` or the shim (same tree) | the live tree |
|
||||
| Standalone, no `PYTHONPATH` | the shim's fallback append | the shim's own tree |
|
||||
|
||||
`lib.sh` calls the package through `_delegation_py`, which inserts its own
|
||||
`SKILL_DIR` at the front of the import path. It deliberately does **not** use
|
||||
`python -m`: for `-m` the current directory precedes `PYTHONPATH`, so a frozen
|
||||
loop whose working directory happened to contain a `lib_py` package would have
|
||||
silently executed live code. The working directory is left untouched, because
|
||||
the registry and audit-log paths are relative to it.
|
||||
|
||||
Setting `MAM_DELEGATION_PY=0` switches the shell helpers back to their preserved
|
||||
legacy bodies with no redeploy.
|
||||
|
||||
One in-process caveat: the package binds once per process and is never rebound,
|
||||
so do not import a frozen shim after a live `lib_py.delegation` is already bound
|
||||
inside the same interpreter. Production never does this — each delegated job is
|
||||
a fresh interpreter — but a test harness could.
|
||||
|
||||
---
|
||||
|
||||
## The Event Protocol Contract
|
||||
|
||||
Every agent participating in the delegation contract must follow the same lifecycle publishing protocol using `publish_event.py`:
|
||||
|
||||
@@ -112,3 +112,7 @@ On `broker.hivemq.com` anyone can publish/subscribe the same topic. Therefore:
|
||||
subscriber **before** the agent (ordering dependency), or rely on retained
|
||||
terminal events in production.
|
||||
- Real operational decisions belong to the own-broker stage with auth + ACL.
|
||||
|
||||
---
|
||||
|
||||
> **구현 위치 각주 (v4.3.0)**: 본 문서가 인용하는 `scripts/*.py` 경로는 유효하며 계약도 불변이다. 실제 구현은 issue #5 phase 1에서 `.agents/skills/lib_py/delegation/`으로 이동했고, `scripts/*.py`는 그 패키지를 자기 옆 트리에 바인딩하는 호환 shim이다.
|
||||
@@ -183,3 +183,7 @@ $PY scripts/registry.py logs <job_id> --tail 20 # last 20 events
|
||||
$PY scripts/registry.py logs <job_id> --json # raw JSON lines
|
||||
$PY scripts/registry.py logs --list # every job, live status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **구현 위치 각주 (v4.3.0)**: 본 문서가 인용하는 `scripts/*.py` 경로는 유효하며 계약도 불변이다. 실제 구현은 issue #5 phase 1에서 `.agents/skills/lib_py/delegation/`으로 이동했고, `scripts/*.py`는 그 패키지를 자기 옆 트리에 바인딩하는 호환 shim이다.
|
||||
@@ -1,339 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""job_subscriber.py — the single entry point for observing Job events.
|
||||
"""Compatibility shim -- the implementation lives in lib_py.delegation.job_subscriber.
|
||||
|
||||
Subscribes to one job's ``<topic_prefix>/events`` (or, with ``--wait-any``, the
|
||||
events of every running/pending job in the registry), prints one line to stdout
|
||||
per accepted event, and exits on a terminal event or a timeout.
|
||||
This file's path is a wire contract (agent instructions, the bash wrapper,
|
||||
SKILL.md, BOOTSTRAP.md, deploy/install.sh, tests). It binds the
|
||||
``lib_py.delegation`` *package* to the tree next to this file, once per process,
|
||||
without touching ``sys.path``; ``import job_subscriber`` then yields the very same module
|
||||
object as ``lib_py.delegation.job_subscriber``.
|
||||
|
||||
Design points (all flagged in the PLAN review):
|
||||
- terminal state machine: ``completed``/``error`` is acted on exactly once per
|
||||
job, so QoS-1 duplicates or an ``error``-after-``completed`` reorder are safe.
|
||||
- dual timeouts: a wall-clock ``--timeout`` (total budget, started at
|
||||
subscribe time so a cold start can't hang forever) AND an idle
|
||||
``--idle-timeout`` (no new event for N seconds).
|
||||
- defensive parsing: undecodable payloads, ``schema_version`` mismatches, and
|
||||
``job_id`` values we did not subscribe for are logged and dropped.
|
||||
|
||||
stdout = event lines only. Diagnostics go to stderr via logging.
|
||||
|
||||
Exit codes:
|
||||
0 all watched jobs reached ``completed``
|
||||
1 any watched job reached ``error``
|
||||
2 timed out (wall-clock or idle) before all jobs finished
|
||||
Why the package and not this one module: a per-module bind leaves a non-leaf
|
||||
module (one that imports its siblings) importing dependencies from whichever tree
|
||||
happens to be on the path, mixing a frozen copy with a live one. Why bind-once and
|
||||
never rebind: replacing a bound package would split existing references and
|
||||
break monkeypatching. See docs/contracts/issue-5/ (D2) for the context matrix.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import queue
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
import mqtt_common
|
||||
import registry
|
||||
from mqtt_common import (
|
||||
DEFAULT_REGISTRY_DIR,
|
||||
SCHEMA_VERSION,
|
||||
broker_config_from_job,
|
||||
load_job,
|
||||
make_client,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("delegate_job.job_subscriber")
|
||||
|
||||
TERMINAL_EVENTS = ("completed", "error")
|
||||
|
||||
|
||||
def _format_line(topic: str, payload: Dict[str, Any]) -> str:
|
||||
source_tag = f" [{payload['source']}]" if payload.get("source") else ""
|
||||
return (
|
||||
f"{payload.get('timestamp','-')} "
|
||||
f"job={payload.get('job_id','?')} "
|
||||
f"seq={payload.get('seq','?')} "
|
||||
f"{payload.get('event','?') + source_tag:<20} "
|
||||
f"{payload.get('detail','')}"
|
||||
)
|
||||
|
||||
|
||||
def _check_disk_fallback(
|
||||
pending: Set[str],
|
||||
registry_dir: str,
|
||||
terminal: Dict[str, str],
|
||||
) -> None:
|
||||
"""Check local on-disk registry and audit logs for terminal status (B-15)."""
|
||||
for jid in list(pending):
|
||||
disk_status = None
|
||||
import importlib, importlib.util, os, sys
|
||||
_SKILLS = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
_PKG = "lib_py.delegation"
|
||||
if _PKG not in sys.modules: # bind once per process; never rebind
|
||||
try:
|
||||
job_rec = load_job(jid, registry_dir)
|
||||
disk_status = job_rec.get("status")
|
||||
except Exception:
|
||||
pass
|
||||
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())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if disk_status in ("completed", "error", "cancelled"):
|
||||
synth_event = "completed" if disk_status == "completed" else "error"
|
||||
synth_payload = {
|
||||
"job_id": jid,
|
||||
"event": synth_event,
|
||||
"seq": -1,
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"detail": f"terminal status '{disk_status}' resolved via disk-fallback",
|
||||
"source": "disk-fallback",
|
||||
}
|
||||
synth_topic = f"mam/{jid}/events"
|
||||
print(_format_line(synth_topic, synth_payload), flush=True)
|
||||
terminal[jid] = synth_event
|
||||
pending.discard(jid)
|
||||
|
||||
|
||||
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]):
|
||||
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)
|
||||
|
||||
def on_message(self, _client, _userdata, msg) -> None:
|
||||
# --- defensive parsing -------------------------------------------
|
||||
try:
|
||||
payload = json.loads(msg.payload.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
logger.warning("drop unparseable payload on %s: %s", msg.topic, exc)
|
||||
return
|
||||
if not isinstance(payload, dict):
|
||||
logger.warning("drop non-object payload on %s", msg.topic)
|
||||
return
|
||||
if payload.get("schema_version") != SCHEMA_VERSION:
|
||||
logger.warning("drop event with schema_version=%r (expected %d)",
|
||||
payload.get("schema_version"), SCHEMA_VERSION)
|
||||
return
|
||||
jid = payload.get("job_id")
|
||||
if jid not in self.expected:
|
||||
logger.warning("drop event for unexpected job_id=%r on %s", jid, msg.topic)
|
||||
return
|
||||
# --- production auth check: data.auth_token must match if expected ---
|
||||
expected_token = self.tokens.get(jid)
|
||||
if not mqtt_common.verify_hmac(payload, expected_token):
|
||||
logger.warning("drop event for job %s: HMAC verify failed", jid)
|
||||
return
|
||||
# --- replay attack defense: check monotonic sequence ---
|
||||
seq = payload.get("seq")
|
||||
if seq is None or not isinstance(seq, int):
|
||||
logger.warning("drop event for job %s: missing or invalid seq", jid)
|
||||
return
|
||||
if seq <= self.last_seq.get(jid, 0):
|
||||
logger.warning("drop event for job %s: seq %d is not monotonically increasing (last %d)",
|
||||
jid, seq, self.last_seq.get(jid, 0))
|
||||
return
|
||||
self.last_seq[jid] = seq
|
||||
# Persistent audit log from the *subscriber's* vantage point: every event
|
||||
# that survives defensive parsing is recorded here, including ones a
|
||||
# different host published. This is the external-observer record that
|
||||
# backstops the publisher's own "published" line if it never wrote one.
|
||||
mqtt_common.append_event(jid, {
|
||||
"event": "received",
|
||||
"source_event": payload.get("event"),
|
||||
"seq": payload.get("seq"),
|
||||
"topic": msg.topic,
|
||||
"timestamp": payload.get("timestamp"),
|
||||
"detail": payload.get("detail", ""),
|
||||
})
|
||||
self.events.put((msg.topic, payload))
|
||||
|
||||
|
||||
def _collect_jobs(args) -> List[Dict[str, Any]]:
|
||||
"""Resolve the list of job records this invocation should watch."""
|
||||
if args.wait_any:
|
||||
jobs = [r for r in registry.list_jobs(args.registry_dir)
|
||||
if r.get("status") in ("pending", "running")]
|
||||
if not jobs:
|
||||
logger.error("no pending/running jobs to wait for")
|
||||
return jobs
|
||||
job = load_job(args.job, args.registry_dir) # raises FileNotFoundError
|
||||
return [job]
|
||||
|
||||
|
||||
def _run_subscriber(args) -> int:
|
||||
try:
|
||||
jobs = _collect_jobs(args)
|
||||
except FileNotFoundError as exc:
|
||||
logger.error("%s", exc)
|
||||
return 2
|
||||
if not jobs:
|
||||
return 2
|
||||
|
||||
expected_ids: Set[str] = {j["job_id"] for j in jobs}
|
||||
tokens = {j["job_id"]: j.get("auth_token") for j in jobs}
|
||||
seqs = {}
|
||||
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)
|
||||
|
||||
# Resolve timeouts from CLI, falling back to the (first) job's settings.
|
||||
base_job = jobs[0]
|
||||
wall_timeout = args.timeout if args.timeout is not None else float(base_job.get("timeout_sec", 3600))
|
||||
idle_timeout = args.idle_timeout if args.idle_timeout is not None else float(base_job.get("idle_timeout_sec", 120))
|
||||
|
||||
# All watched jobs share a broker in practice; connect using the first
|
||||
# job's broker and subscribe to each job's events topic.
|
||||
config = broker_config_from_job(base_job)
|
||||
client = make_client("subscriber", config)
|
||||
client.on_message = watcher.on_message
|
||||
|
||||
subscribed_topics = []
|
||||
for job in jobs:
|
||||
prefix = job.get("topic_prefix") or mqtt_common.topic_prefix_for(job["job_id"])
|
||||
subscribed_topics.append(f"{prefix}/events")
|
||||
|
||||
def on_connect(_c, _u, _flags, reason_code, _props):
|
||||
if mqtt_common.reason_code_value(reason_code) != 0:
|
||||
logger.error("broker connection failed: rc=%s", reason_code)
|
||||
return
|
||||
for topic in subscribed_topics:
|
||||
_c.subscribe(topic, qos=1)
|
||||
logger.info("subscribed to %s", topic)
|
||||
|
||||
def on_disconnect(_c, _u, _flags, reason_code, _props):
|
||||
rc = mqtt_common.reason_code_value(reason_code)
|
||||
if rc != 0:
|
||||
logger.warning("broker disconnected (rc=%s); will retry reconnect", reason_code)
|
||||
|
||||
def on_subscribe(_c, _u, mid, granted_qos, _props=None):
|
||||
for topic in subscribed_topics:
|
||||
print(f"SUBSCRIBED {topic}", flush=True)
|
||||
|
||||
client.on_connect = on_connect
|
||||
client.on_disconnect = on_disconnect
|
||||
client.on_subscribe = on_subscribe
|
||||
client.reconnect_delay_set(min_delay=1, max_delay=16)
|
||||
|
||||
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)
|
||||
if not pending:
|
||||
if any(state == "error" for state in terminal.values()):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
try:
|
||||
mqtt_common.with_retry(
|
||||
lambda: client.connect(config.host, config.port, config.keepalive),
|
||||
attempts=3, base_delay=0.2, max_delay=1.0
|
||||
)()
|
||||
client.loop_start()
|
||||
connected = True
|
||||
except Exception as exc:
|
||||
logger.warning("broker connection failed (%s); checking disk fallback", exc)
|
||||
_check_disk_fallback(pending, args.registry_dir, terminal)
|
||||
if not pending:
|
||||
if any(state == "error" for state in terminal.values()):
|
||||
return 1
|
||||
return 0
|
||||
logger.error("broker connection failed: %s", exc)
|
||||
return 3
|
||||
|
||||
try:
|
||||
while pending:
|
||||
now = time.monotonic()
|
||||
if now >= wall_deadline:
|
||||
_check_disk_fallback(pending, args.registry_dir, terminal)
|
||||
if not pending:
|
||||
break
|
||||
logger.error("wall-clock timeout (%.0fs); still pending: %s",
|
||||
wall_timeout, ", ".join(sorted(pending)))
|
||||
return 2
|
||||
idle_left = idle_timeout - (now - last_event)
|
||||
if idle_left <= 0:
|
||||
_check_disk_fallback(pending, args.registry_dir, terminal)
|
||||
if not pending:
|
||||
break
|
||||
logger.error("idle timeout (%.0fs, no events); still pending: %s",
|
||||
idle_timeout, ", ".join(sorted(pending)))
|
||||
return 2
|
||||
wait = min(wall_deadline - now, idle_left, 1.0)
|
||||
try:
|
||||
topic, payload = watcher.events.get(timeout=wait)
|
||||
except queue.Empty:
|
||||
if not retention_checked and (now - start) > 3.0:
|
||||
logger.warning("--expect-retention set but no retained "
|
||||
"terminal event observed yet")
|
||||
retention_checked = True
|
||||
|
||||
# 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)
|
||||
continue
|
||||
|
||||
last_event = time.monotonic()
|
||||
retention_checked = True
|
||||
print(_format_line(topic, payload), flush=True)
|
||||
|
||||
jid = payload["job_id"]
|
||||
event = payload.get("event")
|
||||
if event in TERMINAL_EVENTS:
|
||||
if jid in terminal:
|
||||
# Already finalised: ignore duplicates / late reorders.
|
||||
logger.info("ignoring duplicate terminal %s for %s", event, jid)
|
||||
continue
|
||||
terminal[jid] = event
|
||||
pending.discard(jid)
|
||||
finally:
|
||||
if connected:
|
||||
client.loop_stop()
|
||||
try:
|
||||
client.disconnect()
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
|
||||
# All jobs reached a terminal state. error wins over completed.
|
||||
if any(state == "error" for state in terminal.values()):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Subscribe to Job events on MQTT")
|
||||
target = parser.add_mutually_exclusive_group(required=True)
|
||||
target.add_argument("--job", help="job id to watch")
|
||||
target.add_argument("--wait-any", action="store_true",
|
||||
help="watch every pending/running job in the registry")
|
||||
parser.add_argument("--timeout", type=float, default=None,
|
||||
help="wall-clock budget in seconds (default: job.timeout_sec or 3600)")
|
||||
parser.add_argument("--idle-timeout", type=float, default=None,
|
||||
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("--registry-dir", default=DEFAULT_REGISTRY_DIR)
|
||||
parser.add_argument("-v", "--verbose", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
mqtt_common.setup_logging(logging.DEBUG if args.verbose else logging.WARNING)
|
||||
|
||||
try:
|
||||
return _run_subscriber(args)
|
||||
except Exception as exc:
|
||||
logger.error("subscriber fatal error: %s", exc)
|
||||
return 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
import lib_py # parent resolved normally; shims never rebind it
|
||||
except ModuleNotFoundError:
|
||||
sys.path.append(_SKILLS) # standalone only: no lib_py exists anywhere to shadow
|
||||
import lib_py
|
||||
_d = os.path.join(_SKILLS, "lib_py", "delegation")
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
_PKG, os.path.join(_d, "__init__.py"), submodule_search_locations=[_d])
|
||||
_pkg = importlib.util.module_from_spec(_spec)
|
||||
sys.modules[_PKG] = _pkg # register BEFORE exec so __init__'s own imports resolve here
|
||||
_spec.loader.exec_module(_pkg)
|
||||
lib_py.delegation = _pkg
|
||||
_impl = importlib.import_module(_PKG + ".job_subscriber") # resolved through the bound package's __path__
|
||||
if __name__ != "__main__":
|
||||
sys.modules[__name__] = _impl # `import job_subscriber` is lib_py.delegation.job_subscriber
|
||||
else:
|
||||
_main = getattr(_impl, "main", None) # a module without a CLI keeps exit status 0
|
||||
sys.exit(_main() if _main is not None else 0)
|
||||
Regular → Executable
+33
-666
@@ -1,670 +1,37 @@
|
||||
"""Shared MQTT + registry helpers for the multi-agent-mux-delegate-job skill.
|
||||
#!/usr/bin/env python3
|
||||
"""Compatibility shim -- the implementation lives in lib_py.delegation.mqtt_common.
|
||||
|
||||
Single entry point for:
|
||||
- broker configuration (env -> dataclass),
|
||||
- paho client construction (auth + TLS + unique client id),
|
||||
- monotonic per-job sequence counters,
|
||||
- retry-with-exponential-backoff,
|
||||
- atomic registry record load/update under an fcntl lock.
|
||||
This file's path is a wire contract (agent instructions, the bash wrapper,
|
||||
SKILL.md, BOOTSTRAP.md, deploy/install.sh, tests). It binds the
|
||||
``lib_py.delegation`` *package* to the tree next to this file, once per process,
|
||||
without touching ``sys.path``; ``import mqtt_common`` then yields the very same module
|
||||
object as ``lib_py.delegation.mqtt_common``.
|
||||
|
||||
Requires paho-mqtt >= 2.0 (uses CallbackAPIVersion.VERSION2).
|
||||
|
||||
This module is the *only* place that talks to the broker config and to the
|
||||
raw job record file, so PoC -> production migration touches just env/registry
|
||||
values, never code (see references/mqtt-broker-setup.md).
|
||||
Why the package and not this one module: a per-module bind leaves a non-leaf
|
||||
module (one that imports its siblings) importing dependencies from whichever tree
|
||||
happens to be on the path, mixing a frozen copy with a live one. Why bind-once and
|
||||
never rebind: replacing a bound package would split existing references and
|
||||
break monkeypatching. See docs/contracts/issue-5/ (D2) for the context matrix.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, Iterable, List, Optional
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
|
||||
logger = logging.getLogger("delegate_job.mqtt_common")
|
||||
|
||||
_warned_deprecated_env = False
|
||||
_warned_coexistence_env = False
|
||||
|
||||
def _load_dotenv(workspace_dir: Optional[str] = None) -> None:
|
||||
"""Load .mam.env (or .env fallback) from workspace if it exists.
|
||||
|
||||
Only sets vars that are not already in os.environ
|
||||
(i.e. OS env takes precedence over env files).
|
||||
"""
|
||||
global _warned_deprecated_env, _warned_coexistence_env
|
||||
|
||||
# 1. Check explicit MAM_ENV_FILE override
|
||||
explicit_file = os.environ.get("MAM_ENV_FILE")
|
||||
if explicit_file:
|
||||
if os.path.isfile(explicit_file):
|
||||
_parse_env_file(explicit_file)
|
||||
return
|
||||
|
||||
# 2. Resolve workspace directory with boundary marker check (.agents or .git)
|
||||
if workspace_dir is None:
|
||||
curr = os.path.dirname(os.path.abspath(__file__))
|
||||
resolved_root = None
|
||||
while curr and curr != os.path.dirname(curr):
|
||||
if os.path.isdir(os.path.join(curr, ".agents")) or os.path.exists(os.path.join(curr, ".git")):
|
||||
resolved_root = curr
|
||||
break
|
||||
curr = os.path.dirname(curr)
|
||||
if not resolved_root:
|
||||
return
|
||||
d = resolved_root
|
||||
else:
|
||||
d = workspace_dir
|
||||
|
||||
mam_env_path = os.path.join(d, ".mam.env")
|
||||
legacy_env_path = os.path.join(d, ".env")
|
||||
|
||||
has_mam = os.path.isfile(mam_env_path)
|
||||
has_legacy = os.path.isfile(legacy_env_path)
|
||||
|
||||
if has_mam and has_legacy:
|
||||
if not _warned_coexistence_env:
|
||||
logger.warning(
|
||||
"Both '%s' and '%s' exist. Loading '%s'. "
|
||||
"Consider removing or migrating '%s'.",
|
||||
mam_env_path, legacy_env_path, mam_env_path, legacy_env_path
|
||||
)
|
||||
_warned_coexistence_env = True
|
||||
_parse_env_file(mam_env_path)
|
||||
elif has_mam:
|
||||
_parse_env_file(mam_env_path)
|
||||
elif has_legacy:
|
||||
if not _warned_deprecated_env:
|
||||
logger.warning(
|
||||
"Loading deprecated config file '%s'. "
|
||||
"Please migrate to '.mam.env'.",
|
||||
legacy_env_path
|
||||
)
|
||||
_warned_deprecated_env = True
|
||||
_parse_env_file(legacy_env_path)
|
||||
|
||||
def _parse_env_file(path: str) -> None:
|
||||
import importlib, importlib.util, os, sys
|
||||
_SKILLS = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
_PKG = "lib_py.delegation"
|
||||
if _PKG not in sys.modules: # bind once per process; never rebind
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" in line:
|
||||
key, _, val = line.partition("=")
|
||||
key = key.strip()
|
||||
val = val.strip().strip('"').strip("'")
|
||||
if key and key not in os.environ:
|
||||
os.environ[key] = val
|
||||
except Exception as e:
|
||||
logger.warning("Failed to parse env file %s: %s", path, e)
|
||||
|
||||
_load_dotenv()
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Constants
|
||||
# --------------------------------------------------------------------------
|
||||
SCHEMA_VERSION = 1
|
||||
DEFAULT_REGISTRY_DIR = ".mam/jobs"
|
||||
DEFAULT_TOPIC_ROOT = "python/mqtt/jobs"
|
||||
LOCK_FILENAME = ".lock"
|
||||
|
||||
# Persistent audit-log layout: .mam/delegate_job_logs/<job_id>/{meta,events,status}.
|
||||
# This is a *separate* artifact from the registry: the registry is the live job
|
||||
# record (mutated in place), the audit log is an append-only history that
|
||||
# survives even if the registry dir is cleaned up.
|
||||
META_FILENAME = "meta.json"
|
||||
EVENTS_FILENAME = "events.ndjson"
|
||||
STATUS_FILENAME = "status.json"
|
||||
|
||||
|
||||
def get_logs_dir() -> str:
|
||||
"""Audit-log root, resolved at call time (B-9).
|
||||
|
||||
Overridable with ``DELEGATE_JOB_LOGS_DIR``; otherwise
|
||||
``<cwd>/.mam/delegate_job_logs``. Resolved per call rather than at import
|
||||
so a chdir after import cannot strand the audit trail in the old tree —
|
||||
the same reason ``DEFAULT_REGISTRY_DIR`` stays a relative string.
|
||||
"""
|
||||
env = os.environ.get("DELEGATE_JOB_LOGS_DIR")
|
||||
if env and env.strip():
|
||||
return env
|
||||
return os.path.join(os.getcwd(), ".mam", "delegate_job_logs")
|
||||
|
||||
|
||||
def __getattr__(name: str): # PEP 562 (3.7+)
|
||||
"""Keep ``mqtt_common.LOGS_DIR`` working for external consumers
|
||||
(documented in registry.md) while resolving it dynamically."""
|
||||
if name == "LOGS_DIR":
|
||||
return get_logs_dir()
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
def __dir__(): # PEP 562 recommendation — keep dir()/tab-completion discoverable
|
||||
return sorted(set(globals()) | {"LOGS_DIR"})
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Broker configuration
|
||||
# --------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class BrokerConfig:
|
||||
"""Resolved broker connection settings.
|
||||
|
||||
PoC defaults target the public HiveMQ broker. Production overrides arrive
|
||||
either from environment variables or from a job record's ``broker.*`` block
|
||||
(see ``broker_config_from_job``).
|
||||
"""
|
||||
|
||||
host: str = "broker.hivemq.com"
|
||||
port: int = 1883
|
||||
tls: bool = False
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
client_id_prefix: str = "hermes"
|
||||
# TLS material (only consulted when tls is True).
|
||||
ca_certs: Optional[str] = None
|
||||
certfile: Optional[str] = None
|
||||
keyfile: Optional[str] = None
|
||||
keepalive: int = 60
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
def to_registry_block(self) -> Dict[str, Any]:
|
||||
"""The subset that gets persisted into a job record's broker block."""
|
||||
return {
|
||||
"host": self.host,
|
||||
"port": self.port,
|
||||
"tls": self.tls,
|
||||
"username": self.username,
|
||||
"password": self.password,
|
||||
}
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool = False) -> bool:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None or raw.strip() == "":
|
||||
return default
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
logger.warning("invalid int for %s=%r; using default %d", name, raw, default)
|
||||
return default
|
||||
|
||||
|
||||
def broker_config_from_env(overrides: Optional[Dict[str, Any]] = None) -> BrokerConfig:
|
||||
"""Build a :class:`BrokerConfig` from environment variables.
|
||||
|
||||
Recognised vars (all optional, PoC defaults shown):
|
||||
MQTT_BROKER (broker.hivemq.com), MQTT_PORT (1883), MQTT_TLS (0),
|
||||
MQTT_USERNAME, MQTT_PASSWORD, MQTT_CLIENT_ID_PREFIX (hermes),
|
||||
MQTT_CA_CERTS, MQTT_CERTFILE, MQTT_KEYFILE, MQTT_KEEPALIVE (60).
|
||||
|
||||
``overrides`` (e.g. a job record's broker block) wins over the env values
|
||||
for any key it specifies with a non-None value.
|
||||
"""
|
||||
cfg = BrokerConfig(
|
||||
host=os.environ.get("MQTT_BROKER", "broker.hivemq.com"),
|
||||
port=_env_int("MQTT_PORT", 1883),
|
||||
tls=_env_bool("MQTT_TLS", False),
|
||||
username=os.environ.get("MQTT_USERNAME") or None,
|
||||
password=os.environ.get("MQTT_PASSWORD") or None,
|
||||
client_id_prefix=os.environ.get("MQTT_CLIENT_ID_PREFIX", "hermes"),
|
||||
ca_certs=os.environ.get("MQTT_CA_CERTS") or None,
|
||||
certfile=os.environ.get("MQTT_CERTFILE") or None,
|
||||
keyfile=os.environ.get("MQTT_KEYFILE") or None,
|
||||
keepalive=_env_int("MQTT_KEEPALIVE", 60),
|
||||
)
|
||||
if overrides:
|
||||
for key, value in overrides.items():
|
||||
if value is not None and hasattr(cfg, key):
|
||||
setattr(cfg, key, value)
|
||||
return cfg
|
||||
|
||||
|
||||
def broker_config_from_job(job: Dict[str, Any]) -> BrokerConfig:
|
||||
"""Resolve broker config for a job: env defaults, then the job's broker.*
|
||||
block overrides. This lets ``publish_event.py`` connect from the registry
|
||||
alone, while still honouring environment toggles (e.g. MQTT_TLS=1)."""
|
||||
return broker_config_from_env(overrides=job.get("broker") or {})
|
||||
|
||||
|
||||
def make_client(role: str, config: Optional[BrokerConfig] = None) -> mqtt.Client:
|
||||
"""Return a configured paho ``Client`` (not yet connected).
|
||||
|
||||
The client id is ``f"{prefix}-{role}-{uuid8}"`` so concurrent publishers /
|
||||
subscribers never collide on the broker. Auth and TLS are applied when the
|
||||
config supplies them.
|
||||
"""
|
||||
config = config or broker_config_from_env()
|
||||
client_id = f"{config.client_id_prefix}-{role}-{uuid.uuid4().hex[:8]}"
|
||||
client = mqtt.Client(
|
||||
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=client_id,
|
||||
)
|
||||
if config.username:
|
||||
client.username_pw_set(config.username, config.password)
|
||||
if config.tls:
|
||||
# If ca_certs is None paho uses the system trust store (good enough for
|
||||
# public CAs); a private CA bundle path is passed through unchanged.
|
||||
client.tls_set(
|
||||
ca_certs=config.ca_certs,
|
||||
certfile=config.certfile,
|
||||
keyfile=config.keyfile,
|
||||
)
|
||||
logger.debug("built client id=%s tls=%s host=%s", client_id, config.tls, config.host)
|
||||
return client
|
||||
|
||||
|
||||
def reason_code_value(rc: Any) -> int:
|
||||
"""Normalise a paho v2 connect reason code to an int.
|
||||
|
||||
paho-mqtt 2.x hands callbacks a ``ReasonCode`` object (not an int); older
|
||||
paths may pass a plain int. ``ReasonCode`` exposes ``.value``; 0 == success.
|
||||
"""
|
||||
return int(getattr(rc, "value", rc))
|
||||
|
||||
|
||||
def verify_hmac(payload: dict, auth_token: Optional[str]) -> bool:
|
||||
"""Verify HMAC-SHA256 signature. Returns True if valid or no token set."""
|
||||
if not auth_token:
|
||||
return True # PoC mode — no auth
|
||||
sig = payload.get("data", {}).get("hmac_sig")
|
||||
if not sig:
|
||||
return False
|
||||
sign_payload = {k: v for k, v in payload.items() if k != "data"}
|
||||
sign_payload["data"] = {k: v for k, v in payload.get("data", {}).items() if k != "hmac_sig"}
|
||||
msg = json.dumps(sign_payload, sort_keys=True, separators=(",", ":")).encode()
|
||||
expected = hmac.new(auth_token.encode(), msg, hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(sig, expected)
|
||||
|
||||
|
||||
def topic_prefix_for(job_id: str, root: str = DEFAULT_TOPIC_ROOT) -> str:
|
||||
return f"{root}/{job_id}"
|
||||
|
||||
|
||||
def events_topic_for(job_id: str, root: str = DEFAULT_TOPIC_ROOT) -> str:
|
||||
return f"{topic_prefix_for(job_id, root)}/events"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Registry primitives (single source of truth for raw record I/O)
|
||||
# --------------------------------------------------------------------------
|
||||
def _job_path(job_id: str, registry_dir: str) -> Path:
|
||||
return Path(registry_dir) / f"{job_id}.json"
|
||||
|
||||
|
||||
def _lock_path(registry_dir: str) -> Path:
|
||||
return Path(registry_dir) / LOCK_FILENAME
|
||||
|
||||
|
||||
@contextmanager
|
||||
def registry_lock(registry_dir: str):
|
||||
"""Advisory exclusive lock over the whole registry dir via fcntl.
|
||||
|
||||
PoC-grade single-host concurrency control. Multiple herdr sessions / scripts
|
||||
serialise their read-modify-write of job records through this lock so two
|
||||
sessions never claim the same pending job. For multi-host delegation move
|
||||
to SQLite WAL (see references/registry.md)."""
|
||||
import fcntl # POSIX only; imported lazily so import works on Windows.
|
||||
|
||||
Path(registry_dir).mkdir(parents=True, exist_ok=True)
|
||||
lock_file = _lock_path(registry_dir)
|
||||
fh = open(lock_file, "a+")
|
||||
try:
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
||||
yield
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
||||
finally:
|
||||
fh.close()
|
||||
|
||||
|
||||
def load_job(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR) -> Dict[str, Any]:
|
||||
"""Load and parse a job record. Raises FileNotFoundError if absent."""
|
||||
path = _job_path(job_id, registry_dir)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"job record not found: {path}")
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def _atomic_write_record(job_id: str, registry_dir: str, record: Dict[str, Any]) -> None:
|
||||
"""Write a record atomically: temp file in the same dir + os.replace.
|
||||
|
||||
The rename is atomic on POSIX, so readers never observe a half-written
|
||||
file. Callers MUST already hold ``registry_lock`` for read-modify-write
|
||||
correctness."""
|
||||
Path(registry_dir).mkdir(parents=True, exist_ok=True)
|
||||
path = _job_path(job_id, registry_dir)
|
||||
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{job_id}.", suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
json.dump(record, fh, ensure_ascii=False, indent=2)
|
||||
fh.write("\n")
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
os.replace(tmp, path)
|
||||
try:
|
||||
os.chmod(path, 0o600)
|
||||
except Exception:
|
||||
pass
|
||||
except BaseException:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
|
||||
|
||||
def update_job_status(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR, **fields: Any) -> Dict[str, Any]:
|
||||
"""Atomically merge ``fields`` into a job record under the registry lock.
|
||||
|
||||
Always refreshes ``updated_at``. Returns the new record. Raises
|
||||
FileNotFoundError if the job does not exist.
|
||||
|
||||
This is the single chokepoint for status writes (both ``registry.update_status``
|
||||
and ``publish_event.py``'s status sync route through here), so it also mirrors
|
||||
any ``status`` change into the persistent audit log. We perform the log mirror
|
||||
under the lock to guarantee sequential consistency in audit history."""
|
||||
with registry_lock(registry_dir):
|
||||
record = load_job(job_id, registry_dir)
|
||||
old_status = record.get("status")
|
||||
record.update(fields)
|
||||
record["updated_at"] = _utcnow()
|
||||
_atomic_write_record(job_id, registry_dir, record)
|
||||
if "status" in fields:
|
||||
new_status = record.get("status")
|
||||
update_logged_status(job_id, new_status, updated_at=record["updated_at"])
|
||||
if old_status != new_status:
|
||||
append_event(job_id, {
|
||||
"event": "status_changed",
|
||||
"from": old_status,
|
||||
"to": new_status,
|
||||
"timestamp": record["updated_at"],
|
||||
})
|
||||
return record
|
||||
|
||||
|
||||
def next_seq(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR) -> int:
|
||||
"""Return the next monotonic sequence number for a job, persisted in the
|
||||
record's ``last_seq`` field so it stays consistent across process restarts.
|
||||
First call returns 1."""
|
||||
with registry_lock(registry_dir):
|
||||
record = load_job(job_id, registry_dir)
|
||||
seq = int(record.get("last_seq", 0)) + 1
|
||||
record["last_seq"] = seq
|
||||
record["updated_at"] = _utcnow()
|
||||
_atomic_write_record(job_id, registry_dir, record)
|
||||
return seq
|
||||
|
||||
|
||||
def _utcnow() -> str:
|
||||
"""ISO-8601 UTC timestamp with trailing Z (payload `timestamp` field)."""
|
||||
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
|
||||
|
||||
def _utcnow_precise() -> str:
|
||||
"""ISO-8601 UTC timestamp with millisecond resolution. Used for the audit
|
||||
log's ``logged_at`` so events sort cleanly even within the same second."""
|
||||
now = time.time()
|
||||
base = time.strftime("%Y-%m-%dT%H:%M:%S", time.gmtime(now))
|
||||
return f"{base}.{int((now % 1) * 1000):03d}Z"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Persistent audit log (.mam/delegate_job_logs/<job_id>/...)
|
||||
#
|
||||
# Every function here is idempotent, concurrency-safe, and *best-effort*: a
|
||||
# logging failure is swallowed with a logger.warning and never propagated, so it
|
||||
# can never break a publish, a subscribe, or a registry write. stdout is never
|
||||
# touched (it is reserved for data output).
|
||||
# --------------------------------------------------------------------------
|
||||
def job_log_dir(job_id: str, logs_dir: Optional[str] = None) -> Path:
|
||||
return Path(logs_dir or get_logs_dir()) / job_id
|
||||
|
||||
|
||||
def job_log_path(job_id: str, kind: str, logs_dir: Optional[str] = None) -> Path:
|
||||
"""Path to one audit-log file for a job. ``kind`` is a filename, e.g. the
|
||||
module constants META_FILENAME / EVENTS_FILENAME / STATUS_FILENAME."""
|
||||
return job_log_dir(job_id, logs_dir) / kind
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _file_lock(fh):
|
||||
"""Best-effort exclusive lock over a single open file via fcntl, so two
|
||||
processes appending to events.ndjson never interleave a line. A no-op where
|
||||
fcntl is unavailable (Windows); a short append is atomic enough there."""
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - non-POSIX
|
||||
yield
|
||||
return
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def _redact_dict(d: Any) -> Any:
|
||||
"""Recursively mask sensitive values (passwords, secrets, tokens) inside logs."""
|
||||
if isinstance(d, dict):
|
||||
redacted = {}
|
||||
for k, v in d.items():
|
||||
if any(s in k.lower() for s in ("password", "token", "secret", "auth_token", "key")):
|
||||
redacted[k] = "[REDACTED]"
|
||||
else:
|
||||
redacted[k] = _redact_dict(v)
|
||||
return redacted
|
||||
elif isinstance(d, list):
|
||||
return [_redact_dict(item) for item in d]
|
||||
return d
|
||||
|
||||
|
||||
def append_event(job_id: str, event_dict: Dict[str, Any], logs_dir: Optional[str] = None) -> None:
|
||||
"""Append one event as a JSON line to ``<logs>/<job_id>/events.ndjson``.
|
||||
|
||||
Concurrency-safe (fcntl lock over the file) and best-effort. A millisecond
|
||||
``logged_at`` is stamped when the caller did not supply one."""
|
||||
try:
|
||||
path = job_log_path(job_id, EVENTS_FILENAME, logs_dir)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
record = _redact_dict(dict(event_dict))
|
||||
record.setdefault("logged_at", _utcnow_precise())
|
||||
line = json.dumps(record, ensure_ascii=False) + "\n"
|
||||
with open(path, "a", encoding="utf-8") as fh:
|
||||
with _file_lock(fh):
|
||||
fh.write(line)
|
||||
fh.flush()
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.warning("append_event failed for job %s: %s", job_id, exc)
|
||||
|
||||
|
||||
def update_logged_status(job_id: str, status: str, logs_dir: Optional[str] = None, **extras: Any) -> None:
|
||||
"""Rewrite ``<logs>/<job_id>/status.json`` (current status for fast point
|
||||
queries) atomically. Best-effort; merges any ``extras``."""
|
||||
try:
|
||||
path = job_log_path(job_id, STATUS_FILENAME, logs_dir)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
record: Dict[str, Any] = {"job_id": job_id, "status": status, "updated_at": _utcnow()}
|
||||
record.update(extras)
|
||||
tmp = path.with_name(path.name + ".tmp")
|
||||
with open(tmp, "w", encoding="utf-8") as fh:
|
||||
json.dump(record, fh, ensure_ascii=False, indent=2)
|
||||
fh.write("\n")
|
||||
os.replace(tmp, path)
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.warning("update_logged_status failed for job %s: %s", job_id, exc)
|
||||
|
||||
|
||||
def init_job_log(job_id: str, meta: Dict[str, Any], logs_dir: Optional[str] = None) -> None:
|
||||
"""Seed the per-job audit-log dir: write meta.json, status.json, and a first
|
||||
``registered`` line in events.ndjson. Idempotent (the ``registered`` line is
|
||||
written only when events.ndjson does not yet exist) and best-effort."""
|
||||
try:
|
||||
d = job_log_dir(job_id, logs_dir)
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
meta_redacted = _redact_dict(meta)
|
||||
with open(d / META_FILENAME, "w", encoding="utf-8") as fh:
|
||||
json.dump(meta_redacted, fh, ensure_ascii=False, indent=2)
|
||||
fh.write("\n")
|
||||
status = meta.get("status", "pending")
|
||||
update_logged_status(
|
||||
job_id, status, logs_dir=logs_dir,
|
||||
created_at=meta.get("created_at"), prompt=meta.get("prompt"),
|
||||
)
|
||||
events_path = d / EVENTS_FILENAME
|
||||
first_time = not events_path.exists()
|
||||
events_path.touch(exist_ok=True)
|
||||
if first_time:
|
||||
append_event(job_id, {
|
||||
"event": "registered",
|
||||
"status": status,
|
||||
"agent": meta.get("agent"),
|
||||
"agent_session": meta.get("agent_session"),
|
||||
"topic_prefix": meta.get("topic_prefix"),
|
||||
"timestamp": meta.get("created_at"),
|
||||
}, logs_dir=logs_dir)
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.warning("init_job_log failed for job %s: %s", job_id, exc)
|
||||
|
||||
|
||||
def read_logged_meta(job_id: str, logs_dir: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Return a job's audit meta.json (registration snapshot), or None."""
|
||||
try:
|
||||
with open(job_log_path(job_id, META_FILENAME, logs_dir), "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def read_logged_status(job_id: str, logs_dir: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Return a job's current status.json, or None. This is the fast point-query
|
||||
file (current status only), separate from the registration-time meta.json."""
|
||||
try:
|
||||
with open(job_log_path(job_id, STATUS_FILENAME, logs_dir), "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def iter_logged_events(job_id: str, logs_dir: Optional[str] = None):
|
||||
"""Yield each parsed event from a job's events.ndjson in file (time) order.
|
||||
Malformed lines are skipped with a warning."""
|
||||
path = job_log_path(job_id, EVENTS_FILENAME, logs_dir)
|
||||
if not path.exists():
|
||||
return
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
yield json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("skipping malformed audit line in %s", path)
|
||||
|
||||
|
||||
def list_logged_jobs(logs_dir: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""Return one meta record per job directory under the logs root, oldest
|
||||
first. Falls back to ``{"job_id": <dir>}`` when meta.json is missing."""
|
||||
base = Path(logs_dir or get_logs_dir())
|
||||
out: List[Dict[str, Any]] = []
|
||||
if not base.exists():
|
||||
return out
|
||||
for d in sorted(base.iterdir()):
|
||||
if not d.is_dir():
|
||||
continue
|
||||
meta = read_logged_meta(d.name, logs_dir) or {"job_id": d.name}
|
||||
# Overlay the live status.json so the summary reflects current state, not
|
||||
# the registration-time snapshot frozen in meta.json.
|
||||
status = read_logged_status(d.name, logs_dir)
|
||||
if status:
|
||||
meta = {**meta,
|
||||
"status": status.get("status", meta.get("status")),
|
||||
"updated_at": status.get("updated_at", meta.get("updated_at"))}
|
||||
out.append(meta)
|
||||
out.sort(key=lambda m: m.get("created_at") or "")
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Retry helper
|
||||
# --------------------------------------------------------------------------
|
||||
def with_retry(
|
||||
fn: Optional[Callable] = None,
|
||||
*,
|
||||
attempts: int = 3,
|
||||
base_delay: float = 0.5,
|
||||
factor: float = 2.0,
|
||||
max_delay: float = 8.0,
|
||||
exceptions: Iterable[type] = (Exception,),
|
||||
) -> Callable:
|
||||
"""Retry ``fn`` with exponential backoff.
|
||||
|
||||
Usable two ways::
|
||||
|
||||
result = with_retry(do_publish, attempts=3)() # wrap-and-call
|
||||
@with_retry(attempts=5, base_delay=1.0) # decorator
|
||||
def do_publish(): ...
|
||||
|
||||
Re-raises the last exception once ``attempts`` is exhausted.
|
||||
"""
|
||||
exc_tuple = tuple(exceptions)
|
||||
|
||||
def decorate(func: Callable) -> Callable:
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
delay = base_delay
|
||||
last_exc: Optional[BaseException] = None
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except exc_tuple as exc:
|
||||
last_exc = exc
|
||||
if attempt >= attempts:
|
||||
break
|
||||
logger.warning(
|
||||
"attempt %d/%d failed: %s; retrying in %.1fs",
|
||||
attempt, attempts, exc, delay,
|
||||
)
|
||||
time.sleep(delay)
|
||||
delay = min(delay * factor, max_delay)
|
||||
assert last_exc is not None
|
||||
raise last_exc
|
||||
|
||||
return wrapper
|
||||
|
||||
if fn is not None:
|
||||
return decorate(fn)
|
||||
return decorate
|
||||
|
||||
|
||||
def setup_logging(level: int = logging.WARNING) -> None:
|
||||
"""Configure root logging to stderr. stdout is reserved for data output
|
||||
(subscriber event lines, registry ids)."""
|
||||
import sys
|
||||
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
stream=sys.stderr,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
import lib_py # parent resolved normally; shims never rebind it
|
||||
except ModuleNotFoundError:
|
||||
sys.path.append(_SKILLS) # standalone only: no lib_py exists anywhere to shadow
|
||||
import lib_py
|
||||
_d = os.path.join(_SKILLS, "lib_py", "delegation")
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
_PKG, os.path.join(_d, "__init__.py"), submodule_search_locations=[_d])
|
||||
_pkg = importlib.util.module_from_spec(_spec)
|
||||
sys.modules[_PKG] = _pkg # register BEFORE exec so __init__'s own imports resolve here
|
||||
_spec.loader.exec_module(_pkg)
|
||||
lib_py.delegation = _pkg
|
||||
_impl = importlib.import_module(_PKG + ".mqtt_common") # resolved through the bound package's __path__
|
||||
if __name__ != "__main__":
|
||||
sys.modules[__name__] = _impl # `import mqtt_common` is lib_py.delegation.mqtt_common
|
||||
else:
|
||||
_main = getattr(_impl, "main", None) # a module without a CLI keeps exit status 0
|
||||
sys.exit(_main() if _main is not None else 0)
|
||||
@@ -1,239 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""publish_event.py — the single entry point for emitting a Job event.
|
||||
"""Compatibility shim -- the implementation lives in lib_py.delegation.publish_event.
|
||||
|
||||
Loads the job record from the registry, resolves its broker, assigns the next
|
||||
monotonic ``seq``, builds the schema-v1 JSON payload, and publishes it to
|
||||
``<topic_prefix>/events`` over QoS 1 with exponential-backoff retry.
|
||||
This file's path is a wire contract (agent instructions, the bash wrapper,
|
||||
SKILL.md, BOOTSTRAP.md, deploy/install.sh, tests). It binds the
|
||||
``lib_py.delegation`` *package* to the tree next to this file, once per process,
|
||||
without touching ``sys.path``; ``import publish_event`` then yields the very same module
|
||||
object as ``lib_py.delegation.publish_event``.
|
||||
|
||||
Silent by design: nothing is printed to stdout. Diagnostics go to stderr via
|
||||
logging. Terminal events (``completed``/``error``) publish with retain=True so
|
||||
a late subscriber still observes the final state (production hardening).
|
||||
|
||||
Exit codes:
|
||||
0 published successfully
|
||||
1 parameter / registry error (bad args, unknown job, no pending job)
|
||||
2 publish failed after retries (network / broker / ACK timeout)
|
||||
|
||||
Usage:
|
||||
publish_event.py --job <id> --event started [--detail "..."] [--data '{...}']
|
||||
publish_event.py --pick-pending --agent-session herdr:claude --event completed
|
||||
publish_event.py --job <id> --event completed --retained
|
||||
Why the package and not this one module: a per-module bind leaves a non-leaf
|
||||
module (one that imports its siblings) importing dependencies from whichever tree
|
||||
happens to be on the path, mixing a frozen copy with a live one. Why bind-once and
|
||||
never rebind: replacing a bound package would split existing references and
|
||||
break monkeypatching. See docs/contracts/issue-5/ (D2) for the context matrix.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import mqtt_common
|
||||
import registry
|
||||
from mqtt_common import (
|
||||
DEFAULT_REGISTRY_DIR,
|
||||
SCHEMA_VERSION,
|
||||
broker_config_from_job,
|
||||
events_topic_for,
|
||||
load_job,
|
||||
make_client,
|
||||
next_seq,
|
||||
with_retry,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("delegate_job.publish_event")
|
||||
|
||||
VALID_EVENTS = ("started", "permission_required", "progress", "completed", "error", "cancelled")
|
||||
TERMINAL_EVENTS = ("completed", "error", "cancelled")
|
||||
# event -> registry status to sync as a best-effort side effect
|
||||
EVENT_TO_STATUS = {
|
||||
"started": "running",
|
||||
"completed": "completed",
|
||||
"error": "error",
|
||||
"cancelled": "cancelled",
|
||||
}
|
||||
|
||||
CONNECT_ACK_TIMEOUT = 10 # seconds to wait for CONNACK
|
||||
PUBLISH_ACK_TIMEOUT = 5 # seconds to wait for QoS-1 PUBACK
|
||||
|
||||
|
||||
def build_payload(
|
||||
job_id: str,
|
||||
seq: int,
|
||||
event: str,
|
||||
detail: str,
|
||||
data: Optional[Dict[str, Any]],
|
||||
auth_token: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"seq": seq,
|
||||
"job_id": job_id,
|
||||
"event": event,
|
||||
"timestamp": mqtt_common._utcnow(),
|
||||
"detail": detail,
|
||||
"data": dict(data) if data else {},
|
||||
}
|
||||
# Production: carry the per-job HMAC-SHA256 signature in `data.hmac_sig` so
|
||||
# the subscriber can verify the publisher without exposing the secret token.
|
||||
# The signature is calculated over the entire payload (with `data.hmac_sig` excluded).
|
||||
if auth_token:
|
||||
sign_payload = {k: v for k, v in payload.items() if k != "data"}
|
||||
sign_payload["data"] = {k: v for k, v in payload.get("data", {}).items() if k != "hmac_sig"}
|
||||
msg = json.dumps(sign_payload, sort_keys=True, separators=(",", ":")).encode()
|
||||
sig = hmac.new(auth_token.encode(), msg, hashlib.sha256).hexdigest()
|
||||
payload["data"]["hmac_sig"] = sig
|
||||
return payload
|
||||
|
||||
|
||||
def _publish_once(config, topic: str, body: bytes, retain: bool) -> None:
|
||||
"""Connect, publish one QoS-1 message, wait for the broker ACK, disconnect.
|
||||
|
||||
Raises on any failure so ``with_retry`` can re-run the whole sequence (a
|
||||
fresh connection per attempt is the robust choice for a PoC)."""
|
||||
client = make_client("publisher", config)
|
||||
connected = {"rc": None}
|
||||
|
||||
def on_connect(_c, _u, _flags, reason_code, _props):
|
||||
connected["rc"] = reason_code
|
||||
|
||||
client.on_connect = on_connect
|
||||
client.connect(config.host, config.port, config.keepalive)
|
||||
client.loop_start()
|
||||
import importlib, importlib.util, os, sys
|
||||
_SKILLS = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
_PKG = "lib_py.delegation"
|
||||
if _PKG not in sys.modules: # bind once per process; never rebind
|
||||
try:
|
||||
# Wait for CONNACK so we fail fast on auth/TLS errors.
|
||||
deadline = time.monotonic() + CONNECT_ACK_TIMEOUT
|
||||
while connected["rc"] is None and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
if connected["rc"] is None:
|
||||
raise TimeoutError("no CONNACK from broker")
|
||||
if mqtt_common.reason_code_value(connected["rc"]) != 0:
|
||||
raise ConnectionError(f"broker refused connection: rc={connected['rc']}")
|
||||
|
||||
info = client.publish(topic, payload=body, qos=1, retain=retain)
|
||||
info.wait_for_publish(timeout=PUBLISH_ACK_TIMEOUT)
|
||||
if not info.is_published():
|
||||
raise TimeoutError("publish not acknowledged within timeout")
|
||||
finally:
|
||||
client.loop_stop()
|
||||
try:
|
||||
client.disconnect()
|
||||
except Exception: # pragma: no cover - disconnect best effort
|
||||
pass
|
||||
|
||||
|
||||
def _resolve_job_id(args) -> Optional[str]:
|
||||
if args.pick_pending:
|
||||
return registry.pick_pending(args.agent_session, args.registry_dir)
|
||||
return args.job
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Publish a Job event to MQTT")
|
||||
target = parser.add_mutually_exclusive_group(required=True)
|
||||
target.add_argument("--job", help="job id to publish for")
|
||||
target.add_argument("--pick-pending", action="store_true",
|
||||
help="auto-select a pending job for --agent-session")
|
||||
parser.add_argument("--agent-session", default="herdr:claude",
|
||||
help="session label used with --pick-pending")
|
||||
parser.add_argument("--event", default="progress", choices=VALID_EVENTS)
|
||||
parser.add_argument("--detail", default="")
|
||||
parser.add_argument("--data", default=None, help="optional JSON object string")
|
||||
parser.add_argument("--retained", action="store_true",
|
||||
help="force retain=True (auto for completed/error)")
|
||||
parser.add_argument("--registry-dir", default=DEFAULT_REGISTRY_DIR)
|
||||
parser.add_argument("--attempts", type=int, default=3)
|
||||
parser.add_argument("-v", "--verbose", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
mqtt_common.setup_logging(logging.DEBUG if args.verbose else logging.WARNING)
|
||||
|
||||
# --- parse optional data JSON (parameter error -> exit 1) ---
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
if args.data:
|
||||
try:
|
||||
data = json.loads(args.data)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("--data must be a JSON object")
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
logger.error("invalid --data: %s", exc)
|
||||
return 1
|
||||
|
||||
job_id = _resolve_job_id(args)
|
||||
if not job_id:
|
||||
logger.error("no job to publish for (unknown --job or no pending job)")
|
||||
return 1
|
||||
|
||||
try:
|
||||
job = load_job(job_id, args.registry_dir)
|
||||
except FileNotFoundError as exc:
|
||||
logger.error("%s", exc)
|
||||
return 1
|
||||
|
||||
config = broker_config_from_job(job)
|
||||
topic = job.get("topic_prefix")
|
||||
topic = f"{topic}/events" if topic else events_topic_for(job_id)
|
||||
seq = next_seq(job_id, args.registry_dir)
|
||||
payload = build_payload(
|
||||
job_id=job_id,
|
||||
seq=seq,
|
||||
event=args.event,
|
||||
detail=args.detail,
|
||||
data=data,
|
||||
auth_token=job.get("auth_token"),
|
||||
)
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
retain = args.retained or args.event in TERMINAL_EVENTS
|
||||
|
||||
publish = with_retry(
|
||||
_publish_once,
|
||||
attempts=args.attempts,
|
||||
exceptions=(OSError, TimeoutError, ConnectionError, ValueError),
|
||||
)
|
||||
publish_ok = True
|
||||
publish_error: Optional[str] = None
|
||||
try:
|
||||
publish(config, topic, body, retain)
|
||||
except Exception as exc:
|
||||
publish_ok = False
|
||||
publish_error = str(exc)
|
||||
logger.error("publish failed after %d attempts: %s", args.attempts, exc)
|
||||
|
||||
# Persistent audit log: record the exact payload we put on the wire (or intended to).
|
||||
# Best-effort (isolated inside append_event) — never fails the publish.
|
||||
# Policy Note: Seq consumption on failure is intentionally maintained for monotonic replay
|
||||
# defense (> highest accepted seq), with failure documented explicitly in the audit record.
|
||||
mqtt_common.append_event(job_id, {
|
||||
"event": "published",
|
||||
"source_event": args.event,
|
||||
"seq": seq,
|
||||
"topic": topic,
|
||||
"retain": retain,
|
||||
"timestamp": payload["timestamp"],
|
||||
"detail": args.detail,
|
||||
"payload": payload,
|
||||
"published": publish_ok,
|
||||
"publish_error": publish_error,
|
||||
})
|
||||
|
||||
# Best-effort side effects: registry status sync + (debug) event log. Never
|
||||
# fail the publish on these.
|
||||
registry.append_event(job_id, args.registry_dir, payload)
|
||||
new_status = EVENT_TO_STATUS.get(args.event)
|
||||
if new_status:
|
||||
try:
|
||||
mqtt_common.update_job_status(job_id, args.registry_dir, status=new_status)
|
||||
except Exception as exc: # pragma: no cover - best effort
|
||||
logger.warning("status sync failed: %s", exc)
|
||||
|
||||
if not publish_ok:
|
||||
return 2
|
||||
|
||||
logger.info("published %s seq=%d job=%s retain=%s", args.event, seq, job_id, retain)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
import lib_py # parent resolved normally; shims never rebind it
|
||||
except ModuleNotFoundError:
|
||||
sys.path.append(_SKILLS) # standalone only: no lib_py exists anywhere to shadow
|
||||
import lib_py
|
||||
_d = os.path.join(_SKILLS, "lib_py", "delegation")
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
_PKG, os.path.join(_d, "__init__.py"), submodule_search_locations=[_d])
|
||||
_pkg = importlib.util.module_from_spec(_spec)
|
||||
sys.modules[_PKG] = _pkg # register BEFORE exec so __init__'s own imports resolve here
|
||||
_spec.loader.exec_module(_pkg)
|
||||
lib_py.delegation = _pkg
|
||||
_impl = importlib.import_module(_PKG + ".publish_event") # resolved through the bound package's __path__
|
||||
if __name__ != "__main__":
|
||||
sys.modules[__name__] = _impl # `import publish_event` is lib_py.delegation.publish_event
|
||||
else:
|
||||
_main = getattr(_impl, "main", None) # a module without a CLI keeps exit status 0
|
||||
sys.exit(_main() if _main is not None else 0)
|
||||
@@ -1,426 +1,37 @@
|
||||
"""Job registry for the multi-agent-mux-delegate-job skill.
|
||||
#!/usr/bin/env python3
|
||||
"""Compatibility shim -- the implementation lives in lib_py.delegation.registry.
|
||||
|
||||
A job record is the single source of truth for one delegated unit of work:
|
||||
its id, prompt, owning agent session, broker connection, timeouts, and status.
|
||||
Records live as ``<registry_dir>/<job_id>.json`` with an append-only event log
|
||||
``<registry_dir>/<job_id>.events.log`` and a shared ``<registry_dir>/.lock``.
|
||||
This file's path is a wire contract (agent instructions, the bash wrapper,
|
||||
SKILL.md, BOOTSTRAP.md, deploy/install.sh, tests). It binds the
|
||||
``lib_py.delegation`` *package* to the tree next to this file, once per process,
|
||||
without touching ``sys.path``; ``import registry`` then yields the very same module
|
||||
object as ``lib_py.delegation.registry``.
|
||||
|
||||
Concurrency is handled via the fcntl lock in :mod:`mqtt_common` (PoC). For
|
||||
multi-host delegation, migrate to SQLite WAL — see references/registry.md.
|
||||
|
||||
Importable as a library and runnable as a CLI (``register``/``list``/``get``/
|
||||
``status``/``pick``) so the ``multi-agent-mux-delegate-job`` bash wrapper can shell out.
|
||||
Why the package and not this one module: a per-module bind leaves a non-leaf
|
||||
module (one that imports its siblings) importing dependencies from whichever tree
|
||||
happens to be on the path, mixing a frozen copy with a live one. Why bind-once and
|
||||
never rebind: replacing a bound package would split existing references and
|
||||
break monkeypatching. See docs/contracts/issue-5/ (D2) for the context matrix.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import mqtt_common
|
||||
from mqtt_common import (
|
||||
DEFAULT_REGISTRY_DIR,
|
||||
SCHEMA_VERSION,
|
||||
_atomic_write_record,
|
||||
_utcnow,
|
||||
broker_config_from_env,
|
||||
load_job,
|
||||
registry_lock,
|
||||
topic_prefix_for,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("delegate_job.registry")
|
||||
|
||||
VALID_STATUSES = ("pending", "running", "completed", "error", "cancelled")
|
||||
|
||||
|
||||
def generate_job_id(bits: int = 32) -> str:
|
||||
"""PoC: 32-bit hex (8 chars). Production: 128-bit (full uuid4 hex)."""
|
||||
if bits >= 128:
|
||||
return uuid.uuid4().hex
|
||||
nibbles = max(1, bits // 4)
|
||||
return uuid.uuid4().hex[:nibbles]
|
||||
|
||||
|
||||
def register_job(
|
||||
prompt: str,
|
||||
agent: str = "claude-code",
|
||||
agent_session: str = "herdr:claude",
|
||||
role: str = "Worker",
|
||||
broker: Optional[Dict[str, Any]] = None,
|
||||
timeout_sec: int = 3600,
|
||||
idle_timeout_sec: int = 120,
|
||||
registry_dir: str = DEFAULT_REGISTRY_DIR,
|
||||
job_id: Optional[str] = None,
|
||||
expected_artifacts: Optional[List[str]] = None,
|
||||
bits: int = 32,
|
||||
auth_token: Optional[str] = None,
|
||||
job_type: str = "direct",
|
||||
reviewer: Optional[str] = None,
|
||||
reviewer_session: Optional[str] = None,
|
||||
max_iterations: int = 5,
|
||||
) -> str:
|
||||
"""Create a new ``pending`` job record and return its id.
|
||||
|
||||
``broker`` defaults to the current environment's resolved broker block, so
|
||||
the registry alone is enough for ``publish_event.py`` to connect later.
|
||||
"""
|
||||
job_id = job_id or generate_job_id(bits)
|
||||
if broker is None:
|
||||
broker = broker_config_from_env().to_registry_block()
|
||||
if auth_token is None:
|
||||
# Auto-generate token if secure broker configuration (TLS or username) is detected
|
||||
if broker.get("tls") or broker.get("username"):
|
||||
import secrets
|
||||
auth_token = secrets.token_urlsafe(32)
|
||||
now = _utcnow()
|
||||
record: Dict[str, Any] = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"job_id": job_id,
|
||||
"status": "pending",
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"prompt": prompt,
|
||||
"agent": agent,
|
||||
"agent_session": agent_session,
|
||||
"role": role,
|
||||
"broker": broker,
|
||||
"topic_prefix": topic_prefix_for(job_id),
|
||||
"timeout_sec": int(timeout_sec),
|
||||
"idle_timeout_sec": int(idle_timeout_sec),
|
||||
"expected_artifacts": expected_artifacts or [],
|
||||
"last_seq": 0,
|
||||
"auth_token": auth_token,
|
||||
"job_type": job_type,
|
||||
"reviewer": reviewer,
|
||||
"reviewer_session": reviewer_session,
|
||||
"max_iterations": int(max_iterations),
|
||||
"iteration": 1,
|
||||
}
|
||||
with registry_lock(registry_dir):
|
||||
if mqtt_common._job_path(job_id, registry_dir).exists():
|
||||
raise FileExistsError(f"job already exists: {job_id}")
|
||||
_atomic_write_record(job_id, registry_dir, record)
|
||||
# Seed the persistent audit log (meta.json + status.json + a "registered"
|
||||
# event). Best-effort inside init_job_log — never blocks registration.
|
||||
mqtt_common.init_job_log(job_id, meta=record)
|
||||
logger.info("registered job %s (agent=%s session=%s)", job_id, agent, agent_session)
|
||||
return job_id
|
||||
|
||||
|
||||
def pick_pending(agent_session: str, registry_dir: str = DEFAULT_REGISTRY_DIR) -> Optional[str]:
|
||||
"""Claim the oldest ``pending`` job for ``agent_session``, flipping it to
|
||||
``running`` atomically under the lock. Returns the job id, or None if no
|
||||
pending job matches. This is how each herdr session takes only its own work
|
||||
without two sessions grabbing the same job."""
|
||||
with registry_lock(registry_dir):
|
||||
candidates = []
|
||||
for record in _iter_records(registry_dir):
|
||||
if record.get("status") == "pending" and record.get("agent_session") == agent_session:
|
||||
candidates.append(record)
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(key=lambda r: r.get("created_at", ""))
|
||||
chosen = candidates[0]
|
||||
chosen["status"] = "running"
|
||||
chosen["updated_at"] = _utcnow()
|
||||
_atomic_write_record(chosen["job_id"], registry_dir, chosen)
|
||||
logger.info("session %s picked job %s", agent_session, chosen["job_id"])
|
||||
job_id = chosen["job_id"]
|
||||
updated_at = chosen["updated_at"]
|
||||
# pick_pending writes the record directly (not via update_job_status), so it
|
||||
# mirrors the pending->running transition into the audit log here. Best-effort.
|
||||
mqtt_common.update_logged_status(job_id, "running", updated_at=updated_at)
|
||||
mqtt_common.append_event(job_id, {
|
||||
"event": "status_changed",
|
||||
"from": "pending",
|
||||
"to": "running",
|
||||
"by": agent_session,
|
||||
"timestamp": updated_at,
|
||||
})
|
||||
return job_id
|
||||
|
||||
|
||||
def update_status(job_id: str, registry_dir: str, status: str) -> Dict[str, Any]:
|
||||
if status not in VALID_STATUSES:
|
||||
raise ValueError(f"invalid status {status!r}; expected one of {VALID_STATUSES}")
|
||||
return mqtt_common.update_job_status(job_id, registry_dir, status=status)
|
||||
|
||||
|
||||
def list_jobs(registry_dir: str = DEFAULT_REGISTRY_DIR, status: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
records = list(_iter_records(registry_dir))
|
||||
if status:
|
||||
records = [r for r in records if r.get("status") == status]
|
||||
records.sort(key=lambda r: r.get("created_at", ""))
|
||||
return records
|
||||
|
||||
|
||||
def append_event(job_id: str, registry_dir: str, payload: Dict[str, Any]) -> None:
|
||||
"""Append one event payload as a JSON line to the job's events log. Best
|
||||
effort, debug-only; failures are logged but never raised to the caller."""
|
||||
import importlib, importlib.util, os, sys
|
||||
_SKILLS = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
_PKG = "lib_py.delegation"
|
||||
if _PKG not in sys.modules: # bind once per process; never rebind
|
||||
try:
|
||||
Path(registry_dir).mkdir(parents=True, exist_ok=True)
|
||||
log_path = Path(registry_dir) / f"{job_id}.events.log"
|
||||
with open(log_path, "a", encoding="utf-8") as fh:
|
||||
fh.write(json.dumps(payload, ensure_ascii=False) + "\n")
|
||||
except OSError as exc: # pragma: no cover - best effort
|
||||
logger.warning("could not append event for %s: %s", job_id, exc)
|
||||
|
||||
|
||||
# convenience re-export so callers can `from registry import load_job`
|
||||
__all__ = [
|
||||
"register_job", "pick_pending", "update_status", "load_job",
|
||||
"list_jobs", "append_event", "generate_job_id", "get_feedback",
|
||||
]
|
||||
|
||||
|
||||
def _iter_records(registry_dir: str):
|
||||
base = Path(registry_dir)
|
||||
if not base.exists():
|
||||
return
|
||||
for path in sorted(base.glob("*.json")):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
yield json.load(fh)
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
logger.warning("skipping unreadable record %s: %s", path, exc)
|
||||
|
||||
|
||||
def get_feedback(job_id: str, registry_dir: str = DEFAULT_REGISTRY_DIR) -> str:
|
||||
"""Read the job's audit log or events log and return the detail of the last completed/error event."""
|
||||
# 1) Try the unified audit log first (ndjson) since it's written synchronously by the subscriber
|
||||
try:
|
||||
import mqtt_common
|
||||
logs_dir = mqtt_common.get_logs_dir()
|
||||
events = list(mqtt_common.iter_logged_events(job_id, logs_dir))
|
||||
for e in reversed(events):
|
||||
if e.get("source_event") in ("completed", "error"):
|
||||
return e.get("detail", "")
|
||||
if e.get("event") in ("completed", "error"):
|
||||
return e.get("detail", "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 2) Fallback to local .events.log
|
||||
log_path = Path(registry_dir) / f"{job_id}.events.log"
|
||||
if log_path.exists():
|
||||
feedback = ""
|
||||
try:
|
||||
with open(log_path, "r", encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
payload = json.loads(line)
|
||||
if payload.get("event") in ("completed", "error"):
|
||||
feedback = payload.get("detail", "")
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except OSError:
|
||||
pass
|
||||
if feedback:
|
||||
return feedback
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# CLI (so the bash wrapper can shell out without inline python)
|
||||
# --------------------------------------------------------------------------
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="multi-agent-mux-delegate-job registry CLI")
|
||||
parser.add_argument("--registry-dir", default=DEFAULT_REGISTRY_DIR)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_reg = sub.add_parser("register", help="create a pending job; prints the job id")
|
||||
p_reg.add_argument("--prompt", required=True)
|
||||
p_reg.add_argument("--agent", default="claude-code")
|
||||
p_reg.add_argument("--agent-session", default="herdr:claude")
|
||||
p_reg.add_argument("--role", default="Worker", help="logical role for the delegated agent (e.g. Worker, Planner, Reviewer)")
|
||||
p_reg.add_argument("--timeout", type=int, default=3600)
|
||||
p_reg.add_argument("--idle-timeout", type=int, default=120)
|
||||
p_reg.add_argument("--bits", type=int, default=32, help="32 (PoC) or 128 (prod)")
|
||||
p_reg.add_argument("--artifact", action="append", default=[], dest="artifacts")
|
||||
p_reg.add_argument("--auth-token", default=None, help="HMAC auth token for the job (auto-generated if secure broker is detected)")
|
||||
p_reg.add_argument("--job-type", default="direct", choices=["direct", "loop", "discuss"])
|
||||
p_reg.add_argument("--reviewer", default=None)
|
||||
p_reg.add_argument("--reviewer-session", default=None)
|
||||
p_reg.add_argument("--max-iterations", type=int, default=5)
|
||||
|
||||
p_list = sub.add_parser("list", help="list jobs (optionally by status)")
|
||||
p_list.add_argument("--status", default=None)
|
||||
p_list.add_argument("--json", action="store_true")
|
||||
|
||||
p_get = sub.add_parser("get", help="print one job record as JSON")
|
||||
p_get.add_argument("--job", required=True)
|
||||
|
||||
p_status = sub.add_parser("status", help="set a job status")
|
||||
p_status.add_argument("--job", required=True)
|
||||
p_status.add_argument("--set", required=True, dest="status")
|
||||
|
||||
p_update = sub.add_parser("update", help="update a job record")
|
||||
p_update.add_argument("--job", required=True)
|
||||
p_update.add_argument("--status", default=None)
|
||||
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("--role", 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)
|
||||
|
||||
p_pick = sub.add_parser("pick", help="claim a pending job for a session; prints id")
|
||||
p_pick.add_argument("--agent-session", default="herdr:claude")
|
||||
|
||||
p_logs = sub.add_parser(
|
||||
"logs",
|
||||
help="show the persistent audit log for a job, or --list every logged job",
|
||||
)
|
||||
p_logs.add_argument("job_id", nargs="?", default=None,
|
||||
help="job id whose events.ndjson to print")
|
||||
p_logs.add_argument("--list", action="store_true", dest="list_all",
|
||||
help="summarise every job under the logs dir instead")
|
||||
p_logs.add_argument("--logs-dir", default=None,
|
||||
help="override the audit-log root (default: $DELEGATE_JOB_LOGS_DIR "
|
||||
"or <cwd>/.mam/delegate_job_logs)")
|
||||
p_logs.add_argument("--tail", type=int, default=0,
|
||||
help="show only the last N events (0 = all)")
|
||||
p_logs.add_argument("--json", action="store_true",
|
||||
help="emit raw JSON lines / records instead of a table")
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
mqtt_common.setup_logging(logging.INFO)
|
||||
args = _build_parser().parse_args(argv)
|
||||
rd = args.registry_dir
|
||||
|
||||
if args.command == "register":
|
||||
job_id = register_job(
|
||||
prompt=args.prompt,
|
||||
agent=args.agent,
|
||||
agent_session=args.agent_session,
|
||||
role=args.role,
|
||||
timeout_sec=args.timeout,
|
||||
idle_timeout_sec=args.idle_timeout,
|
||||
registry_dir=rd,
|
||||
expected_artifacts=args.artifacts,
|
||||
bits=args.bits,
|
||||
auth_token=args.auth_token,
|
||||
job_type=args.job_type,
|
||||
reviewer=args.reviewer,
|
||||
reviewer_session=args.reviewer_session,
|
||||
max_iterations=args.max_iterations,
|
||||
)
|
||||
print(job_id)
|
||||
return 0
|
||||
|
||||
if args.command == "list":
|
||||
records = list_jobs(rd, status=args.status)
|
||||
if args.json:
|
||||
print(json.dumps(records, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
if not records:
|
||||
print("(no jobs)")
|
||||
for r in records:
|
||||
print(f"{r['job_id']} {r.get('status','?'):10s} {r.get('agent_session','')}"
|
||||
f" {r.get('prompt','')[:48]}")
|
||||
return 0
|
||||
|
||||
if args.command == "get":
|
||||
try:
|
||||
print(json.dumps(load_job(args.job, rd), ensure_ascii=False, indent=2))
|
||||
except FileNotFoundError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
if args.command == "status":
|
||||
try:
|
||||
update_status(args.job, rd, args.status)
|
||||
except (FileNotFoundError, ValueError) as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
if args.command == "update":
|
||||
fields = {}
|
||||
if args.status is not None:
|
||||
fields["status"] = args.status
|
||||
if args.agent_session is not None:
|
||||
fields["agent_session"] = args.agent_session
|
||||
if args.prompt is not None:
|
||||
fields["prompt"] = args.prompt
|
||||
if args.iteration is not None:
|
||||
fields["iteration"] = args.iteration
|
||||
if args.role is not None:
|
||||
fields["role"] = args.role
|
||||
try:
|
||||
mqtt_common.update_job_status(args.job, rd, **fields)
|
||||
except FileNotFoundError as exc:
|
||||
print(str(exc), file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
if args.command == "get-feedback":
|
||||
print(get_feedback(args.job, rd))
|
||||
return 0
|
||||
|
||||
if args.command == "pick":
|
||||
job_id = pick_pending(args.agent_session, rd)
|
||||
if job_id is None:
|
||||
return 3 # no pending job for this session
|
||||
print(job_id)
|
||||
return 0
|
||||
|
||||
if args.command == "logs":
|
||||
return _cmd_logs(args)
|
||||
|
||||
return 1
|
||||
|
||||
|
||||
def _cmd_logs(args) -> int:
|
||||
"""Pretty-print one job's events.ndjson, or summarise all logged jobs."""
|
||||
logs_dir = args.logs_dir or mqtt_common.get_logs_dir()
|
||||
|
||||
if args.list_all:
|
||||
jobs = mqtt_common.list_logged_jobs(logs_dir)
|
||||
if args.json:
|
||||
print(json.dumps(jobs, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
if not jobs:
|
||||
print(f"(no logged jobs under {logs_dir})")
|
||||
return 0
|
||||
for m in jobs:
|
||||
print(f"{m.get('job_id','?')} {m.get('status','?'):10s} "
|
||||
f"{m.get('created_at','-'):20s} {(m.get('prompt') or '')[:48]}")
|
||||
return 0
|
||||
|
||||
if not args.job_id:
|
||||
print("logs requires a <job_id> or --list", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
events = list(mqtt_common.iter_logged_events(args.job_id, logs_dir))
|
||||
if not events and not mqtt_common.job_log_dir(args.job_id, logs_dir).exists():
|
||||
print(f"no audit log for job {args.job_id} under {logs_dir}", file=sys.stderr)
|
||||
return 1
|
||||
if args.tail and args.tail > 0:
|
||||
events = events[-args.tail:]
|
||||
if args.json:
|
||||
for e in events:
|
||||
print(json.dumps(e, ensure_ascii=False))
|
||||
return 0
|
||||
for e in events:
|
||||
ts = e.get("logged_at") or e.get("timestamp") or "-"
|
||||
extra = e.get("detail") or e.get("to") or e.get("source_event") or ""
|
||||
print(f"{ts:24s} {e.get('event','?'):<16s} {extra}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
import lib_py # parent resolved normally; shims never rebind it
|
||||
except ModuleNotFoundError:
|
||||
sys.path.append(_SKILLS) # standalone only: no lib_py exists anywhere to shadow
|
||||
import lib_py
|
||||
_d = os.path.join(_SKILLS, "lib_py", "delegation")
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
_PKG, os.path.join(_d, "__init__.py"), submodule_search_locations=[_d])
|
||||
_pkg = importlib.util.module_from_spec(_spec)
|
||||
sys.modules[_PKG] = _pkg # register BEFORE exec so __init__'s own imports resolve here
|
||||
_spec.loader.exec_module(_pkg)
|
||||
lib_py.delegation = _pkg
|
||||
_impl = importlib.import_module(_PKG + ".registry") # resolved through the bound package's __path__
|
||||
if __name__ != "__main__":
|
||||
sys.modules[__name__] = _impl # `import registry` is lib_py.delegation.registry
|
||||
else:
|
||||
_main = getattr(_impl, "main", None) # a module without a CLI keeps exit status 0
|
||||
sys.exit(_main() if _main is not None else 0)
|
||||
@@ -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.2.0
|
||||
version: 4.3.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -104,12 +104,27 @@ if [ -n "${PLANNER_SESSION_OVERRIDE:-}" ] && [ "$PLAN_MODE" = false ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Anchor Python interpreter before freeze so freeze child processes find the project venv (O-4)
|
||||
if [ -z "${AGENT_PYTHON_BIN:-}" ]; then
|
||||
if [ -x "$REPO_ROOT/.venv/bin/python" ]; then
|
||||
export AGENT_PYTHON_BIN="$REPO_ROOT/.venv/bin/python"
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- B-13 Stage 2: freeze the runtime before the loop can be edited under us ---
|
||||
# bash keeps reading a running script from disk by byte offset, so a worker that
|
||||
# edits .agents/skills/ mid-loop can break this very file (measured: even a valid
|
||||
# replacement died with "unexpected EOF"). Re-exec once from a snapshot.
|
||||
# NOTE: log_* are not defined until :114 — use echo here, not log_warn (C2).
|
||||
if [ -z "${MAM_LOOP_FREEZE_DIR:-}" ] && [ "${MAM_LOOP_NO_FREEZE:-0}" != "1" ]; then
|
||||
# In Python mode (MAM_LOOP_PY=1, default), the Python process holds all modules in memory
|
||||
# (eager import), providing immunity against mid-flight file mutations without disk copying.
|
||||
# The legacy freeze snapshot (/tmp/mam-loop-freeze.XXXXXX) is only activated when
|
||||
# legacy shell mode (MAM_LOOP_PY=0) is selected or freeze is explicitly forced (MAM_LOOP_FREEZE=1).
|
||||
# NOTE: log_* are not defined until :163 — use echo here, not log_warn (C2).
|
||||
_should_freeze=0
|
||||
if [ "${MAM_LOOP_NO_FREEZE:-0}" != "1" ] && [ -z "${MAM_LOOP_FREEZE_DIR:-}" ]; then
|
||||
if [ "${MAM_LOOP_FREEZE:-0}" = "1" ] || [ "${MAM_LOOP_PY:-1}" = "0" ]; then
|
||||
_should_freeze=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$_should_freeze" = "1" ]; then
|
||||
_freeze=""
|
||||
_freeze="$(mktemp -d "${TMPDIR:-/tmp}/mam-loop-freeze.XXXXXX" 2>/dev/null)" || _freeze=""
|
||||
if [ -n "$_freeze" ] && mkdir -p "$_freeze/.agents" 2>/dev/null \
|
||||
@@ -199,6 +214,23 @@ if [ "$PLAN_TALK_TURNS" -gt 0 ] && [ "$PLAN_MODE" = false ]; then
|
||||
log_warn "--plan-talk was specified but --plan mode is not enabled. Discussion turns will be ignored."
|
||||
fi
|
||||
|
||||
# ===========================================================================
|
||||
# Phase 3: Python lib_py.loop Thin Wrapper Delegation (MAM_LOOP_PY=1 default)
|
||||
# Set MAM_LOOP_PY=0 to restore legacy shell loop execution.
|
||||
# ===========================================================================
|
||||
if [ "${MAM_LOOP_PY:-1}" = "1" ]; then
|
||||
_py="${DELEGATE_JOB_PYTHON:-${AGENT_PYTHON_BIN:-$(_delegate_py_bin)}}"
|
||||
export PYTHONPATH="$MAM_REAL_ROOT/.agents/skills:$REPO_ROOT/.agents/skills:${PYTHONPATH:-}"
|
||||
export MAM_REAL_ROOT
|
||||
export WORKSPACE_ROOT
|
||||
export MAM_LOOP_MARKER
|
||||
export MAM_LOOP_FREEZE_DIR
|
||||
export MAM_LOOP_FREEZE_OWNED
|
||||
export PYTHONDONTWRITEBYTECODE=1
|
||||
[ -f "$MAM_REAL_ROOT/.mam.env" ] && export MAM_ENV_FILE="$MAM_REAL_ROOT/.mam.env"
|
||||
exec "$_py" -m lib_py.loop ${MAM_LOOP_ARGV[@]+"${MAM_LOOP_ARGV[@]}"}
|
||||
fi
|
||||
|
||||
# Verdict must occupy the report's last non-blank line — a standalone token
|
||||
# quoted mid-report (e.g. as a formatting example) never matches (P0-2).
|
||||
has_verdict() {
|
||||
|
||||
@@ -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.2.0
|
||||
version: 4.3.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
@@ -116,6 +116,7 @@ else:
|
||||
|
||||
import mqtt_common
|
||||
import registry
|
||||
mqtt_common.ensure_env_loaded() # D6: env now loads at entry points, not at import
|
||||
|
||||
# Executed INSIDE lib.sh::atomic_dump_yaml (system python3 + PyYAML), under the
|
||||
# YAML flock with schema-validate + .bak (review item 5). Marks matching running
|
||||
|
||||
@@ -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.2.0
|
||||
version: 4.3.0
|
||||
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.2.0
|
||||
version: 4.3.0
|
||||
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.2.0
|
||||
version: 4.3.0
|
||||
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.2.0
|
||||
version: 4.3.0
|
||||
author: godopu
|
||||
license: MIT
|
||||
platforms: [linux, macos]
|
||||
|
||||
+31
-13
@@ -6,35 +6,53 @@
|
||||
|
||||
## 📌 현재 버전 개요 (Current Release)
|
||||
|
||||
- **프레임워크 버전**: `v4.2.0`
|
||||
- **최신 릴리스 일시**: 2026-09-05 (KST)
|
||||
- **기준 브랜치**: `support-codex`
|
||||
- **프레임워크 버전**: `v4.3.0`
|
||||
- **최신 릴리스 일시**: 2026-09-06 (KST)
|
||||
- **기준 브랜치**: `refactor`
|
||||
- **핵심 아키텍처**:
|
||||
- **Codex First-Class Agent Integration**: OpenAI Codex CLI(`codex`)를 6번째 1급 에이전트 백엔드로 전면 통합 (`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`)
|
||||
- **Class A Codex Lifecycle & State DB Resolution**: 동적 `state_*.sqlite` SQLite 인덱스 및 date-nested rollout jsonl 폴백 지원, `--dangerously-bypass-approvals-and-sandbox` 비대화형 실행 연동
|
||||
- **Runtime Framework Version Constant (`MAM_VERSION`) & 3-Way Lockstep**: `lib.sh` 내 `MAM_VERSION="4.2.0"` 런타임 진실 공급원 및 8개 스킬 3자 동기화 가드 유지
|
||||
- **`lib_py.delegation` 패키지 추출 (Issue #5 Phase 1)**: 위임 스크립트 4종을 파이썬 패키지로 승격하고, 기존 `scripts/*.py` 경로는 패키지를 자기 옆 트리에 프로세스당 1회 바인딩하는 호환 shim으로 유지
|
||||
- **Runtime Framework Version Constant (`MAM_VERSION`) & 3-Way Lockstep**: `lib.sh` 내 `MAM_VERSION="4.3.0"` 런타임 진실 공급원 및 8개 스킬 3자 동기화 가드 유지
|
||||
|
||||
---
|
||||
|
||||
## 🧭 스킬 패키지 버전 매트릭스 (Skills Version Matrix)
|
||||
|
||||
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.2.0`으로 동기화되어 배포됩니다.
|
||||
모든 8개 스킬은 YAML frontmatter 메타데이터(`author`, `version`, `platforms`, `environments`) 표준화를 통해 `v4.3.0`으로 동기화되어 배포됩니다.
|
||||
|
||||
| 스킬명 | 버전 | 역할 및 주요 책임 | 상태 |
|
||||
| :--- | :---: | :--- | :---: |
|
||||
| **`multi-agent-mux-create`** | `4.2.0` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
|
||||
| **`multi-agent-mux-stop`** | `4.2.0` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
|
||||
| **`multi-agent-mux-resume`** | `4.2.0` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
|
||||
| **`multi-agent-mux-status`** | `4.2.0` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
|
||||
| **`multi-agent-mux-monitor`** | `4.2.0` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
|
||||
| **`multi-agent-mux-delegate-job`** | `4.2.0` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
|
||||
| **`multi-agent-mux-loop`** | `4.2.0` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
|
||||
| **`multi-agent-mux-orc-onboard`** | `4.2.0` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
|
||||
| **`multi-agent-mux-create`** | `4.3.0` | 에이전트 세션 신규 생성 및 Herdr 컨테이너 격리 스폰 | ✅ 배포 |
|
||||
| **`multi-agent-mux-stop`** | `4.3.0` | 대화 UUID 원자적 캡처 및 세션 안전 종료 (Graceful Stop) | ✅ 배포 |
|
||||
| **`multi-agent-mux-resume`** | `4.3.0` | 온디스크 대화 컨텍스트 기반 Tier-1 초고속 세션 복원 | ✅ 배포 |
|
||||
| **`multi-agent-mux-status`** | `4.3.0` | 실시간 Herdr 세션 및 레지스트리 드리프트 스냅샷 조회 | ✅ 배포 |
|
||||
| **`multi-agent-mux-monitor`** | `4.3.0` | YAML ↔ 런타임 상태 간 자율 조정자 (Reconciler Loop) | ✅ 배포 |
|
||||
| **`multi-agent-mux-delegate-job`** | `4.3.0` | MQTT 이벤트 채널 기반 비동기 단위 작업 위임 | ✅ 배포 |
|
||||
| **`multi-agent-mux-loop`** | `4.3.0` | Planner-Creator-Reviewer 3자 자율 계획·실행·피어리뷰 루프 | ✅ 배포 |
|
||||
| **`multi-agent-mux-orc-onboard`** | `4.3.0` | 오케스트레이터 UUID 격리 등록 및 서브 세션 오염 방지 | ✅ 배포 |
|
||||
|
||||
---
|
||||
|
||||
## 📋 버전별 상세 변경 내역 (Changelog)
|
||||
|
||||
### 🚀 `v4.3.0` — `lib_py.delegation` 패키지 추출 및 장수 MQTT 연결 프로토타입 (2026-09-06)
|
||||
|
||||
> **주요 마일스톤 (MINOR Release)**: Issue #5 Phase 1. `lib.sh`의 위임 헬퍼 3종과 `multi-agent-mux-delegate-job/scripts/*.py` 4종을 `lib_py/delegation/` 파이썬 패키지로 추출. 스크립트 경로는 와이어 계약이므로 호환 shim으로 보존하며, 프로덕션 동작·이벤트 스키마·자동완료 정책은 한 줄도 바뀌지 않는다.
|
||||
|
||||
#### 🌟 신규 기능 및 아키텍처 확장
|
||||
* **패키지 바인딩 shim (D2)**: `scripts/{mqtt_common,registry,publish_event,job_subscriber}.py`는 `lib_py.delegation` **패키지**를 자기 옆 트리에 프로세스당 1회 바인딩하고 `sys.path`를 건드리지 않는다. `import mqtt_common`은 `lib_py.delegation.mqtt_common`과 동일한 모듈 객체가 된다.
|
||||
- `sys.path.insert(0, ...)` 방식은 pytest에서 최초로 임포트된 샌드박스 사본이 `lib_py.agents`/`layout`/`state`까지 세션 전체에 걸쳐 가로채므로 채택하지 않았다.
|
||||
- 모듈 단위 파일 적재 방식은 형제를 임포트하는 모듈이 다른 트리의 의존성을 끌어와 freeze(B-13) 보호를 부분 상실하므로 채택하지 않았다.
|
||||
* **`DelegationService` / `cli.py` / `daemon.py`**: 셸 헬퍼 3종의 본체를 `python -m lib_py.delegation.cli`로 교체. `start_watchdog`은 `ensure_subscriber_daemon`의 별칭으로 유지.
|
||||
* **`PersistentPublisher` 프로토타입 (`publisher.py`) 및 벤치마크 하니스 (`bench.py`)**: 장수 연결은 Phase 2a 엔진용이며 프로덕션 경로(CLI·셸)는 계속 1회성 연결을 사용한다.
|
||||
* **`ensure_env_loaded()` (D6)**: `.mam.env` 로딩을 임포트 시점에서 프로세스 진입점으로 이동. 라이브러리 임포트가 `os.environ`을 변경하지 않는다.
|
||||
|
||||
#### 🔒 롤백 및 안전장치
|
||||
* **`MAM_DELEGATION_PY=0`**: 셸 헬퍼가 보존된 레거시 인라인 본체(`_*_legacy`)로 즉시 분기. 재배포·revert 불필요. 레거시 본체 제거는 Phase 4.
|
||||
* **특성화 테스트 선행 (P1-00)**: 이전까지 위임 셸 헬퍼 3종을 직접 검증하는 테스트가 0건이었으므로, 이동 **전에** 골든 테스트를 먼저 추가하고 두 경로(`MAM_DELEGATION_PY` 0/1) 모두에서 통과시켰다.
|
||||
|
||||
|
||||
### 🚀 `v4.2.0` — OpenAI Codex First-Class Agent Backend Integration (2026-09-05)
|
||||
|
||||
> **주요 마일스톤 (MINOR Release)**: OpenAI Codex CLI(`codex`)를 6번째 정식 1급 에이전트 백엔드로 통합. 46개 전체 아키텍처 결합 지점(Shell 라이프사이클, Python 어댑터, 레지스트리, 상태 머신, 동기화 루프, 테스트 스위트, 문서) 전면 연동 완료.
|
||||
|
||||
@@ -101,6 +101,7 @@ check_assets_present() {
|
||||
".agents/skills/lib.sh"
|
||||
".agents/skills/multi-agent-mux-create/scripts/create_session.sh"
|
||||
".agents/skills/multi-agent-mux-delegate-job/scripts/registry.py"
|
||||
".agents/skills/lib_py/delegation/__init__.py"
|
||||
".agents/skills/multi-agent-mux-status/scripts/status.sh"
|
||||
".agents/skills/multi-agent-mux-loop/scripts/run_loop.sh"
|
||||
".agents/hooks.json"
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# 📑 Issue #5 Baseline Contract Specification
|
||||
|
||||
- **Document Version**: Rev.3 (Post Cross-Review Corrections)
|
||||
- **Issue**: [Gitea Issue #5 — `[Refactor] lib.sh 작업 위임 로직 및 run_loop.sh 오케스트레이터의 Python 모듈화 (lib_py 승격)`](https://git.godopu.com/tmpl/multi-agent-mux/issues/5)
|
||||
- **Baseline Commit**: `47640e2fd7b108be29446a9384a343727e87edf8`
|
||||
- **Environment Baseline**: Python 3.9.6, Herdr 0.8.2, macOS Darwin 24.3.0
|
||||
- **Test Suite Baseline**: 469 tests collected initially; 510 tests collected with Phase 0 contract suite (+41 contract & mutation tests)
|
||||
- **Status**: Phase 0 Baseline Contract (Observed vs. Proposed vs. Gap)
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요 및 계약 정의 원칙
|
||||
|
||||
본 문서는 Issue #5(Python 모듈화 및 `lib_py` 승격)의 착수 기준선(Baseline)으로서, 현재 코드베이스(`run_loop.sh`, `lib.sh`, `delegate-job` 도구군)에 구현되어 동작 중인 CLI, 이벤트, 리포트, 락(Lock) 및 상태 전이 계약을 엄밀하게 분류하여 명문화합니다.
|
||||
|
||||
각 항목은 다음 세 범주로 구분하여 기록합니다:
|
||||
- **`[Observed]`**: 현재 소스 코드 및 단위 테스트에서 실제로 확인된 동작.
|
||||
- **`[Proposed]`**: 향후 Phase 1–4에서 구현하고자 하는 목표 동작.
|
||||
- **`[Gap]`**: 현행 동작과 목표 동작 사이의 불일치, 결함 또는 미구현 사항.
|
||||
|
||||
---
|
||||
|
||||
## 2. CLI 인터페이스 계약 (Orchestrator & Delegation)
|
||||
|
||||
### 2-1. `run_loop.sh` CLI 옵션 및 인수 파싱 계약
|
||||
|
||||
| 옵션 | 기본값 | 제약 및 검증 규칙 | 현행 소스 위치 | 대응 테스트 | 범주 |
|
||||
|---|---|---|---|---|---|
|
||||
| `--creator <session>` | 없음 (필수) | 비어있지 않은 문자열 필수(L95). 세션 레지스트리(`.mam/agent-sessions.yaml` / `.db`) 등록 및 실행 중 검증은 L160–185에서 수행 | `run_loop.sh:79, 95-98, 160-185` | `test_loop_cli.py:test_creator_parses_and_reaches_session_check` | `[Observed]` |
|
||||
| `--task <goal-text>` | 없음 (필수) | 작업 목표 문자열. 누락 시 fast-fail 및 `usage` 출력 후 exit 1 | `run_loop.sh:85, 95-98` | `test_loop_cli.py:test_missing_creator_and_task_fail_fast` | `[Observed]` |
|
||||
| `--target-agent <name>` | 제거됨 | 사용 즉시 에러 메시지 출력 후 exit 1 반환 (하위 호환 폐기) | `run_loop.sh:80-83` | `test_loop_cli.py:test_target_agent_is_rejected` | `[Observed]` |
|
||||
| `--plan` | `false` | 플래너 개입 및 설계 단계 활성화 플래그 | `run_loop.sh:56` | `test_loop_cli.py:test_planner_without_plan_fail_fast` | `[Observed]` |
|
||||
| `--planner <session>` | 자동 조회 | `--plan` 플래그 없이 지정 시 즉시 exit 1 반환. 미지정 시 등록된 planner 역할 세션 자동 선택 | `run_loop.sh:84, 100-105` | `test_loop_cli.py:test_explicit_planner_unregistered`, `test_explicit_planner_skips_auto_resolve` | `[Observed]` |
|
||||
| `--plan-talk <N>` | `1` | 숫자 regex(`^[0-9]+$`) 검증. 에러 메시지는 양수를 요구하지만 regex는 **`0`을 허용**함. 0 지정 시 토론 루프(`$turn -le $PLAN_TALK_TURNS`)를 완전히 건너뜀 | `run_loop.sh:57-62, 442` | `test_loop_cli.py` (regex 파싱) | `[Observed]` |
|
||||
| `--reviewer <A,B>` | 없음 | 쉼표 구분 리뷰어 세션 목록 | `run_loop.sh:63` | `test_loop_cli.py` | `[Observed]` |
|
||||
| `--all-reviewer` | `false` | 모든 활성 리뷰어 세션 강제. `--reviewer`와 동시 지정 시 **`--all-reviewer`가 우선**하며 `--reviewer` 목록은 무시되고 경고 로그 출력 | `run_loop.sh:64, 192-196` | 현행 전용 CLI 테스트 부재 (`[Gap]`) | `[Observed]` |
|
||||
| `--max-loop <N>` | `3` | 양수 정수 검증(`^[0-9]+$` and `N > 0`). 0 이하 시 에러 반환 | `run_loop.sh:65-70` | `test_loop_cli.py` | `[Observed]` |
|
||||
| `--max-rebut <N>` | `1` | 비음수 정수 검증(`^[0-9]+$`). 0 지정 시 반론(Rebuttal) 완전 비활성화 | `run_loop.sh:71-76` | `test_o1_rebuttal.py:test_v3_rebut_option_parsing_and_budget` | `[Observed]` |
|
||||
| `REBUT_TOTAL_BUDGET` | `MAX_REBUT * MAX_LOOP` | 전체 실행 기간 동안의 총 반론 허용 상한(Run-wide budget). 조기 소진 시 추가 반론 금지 | `run_loop.sh:93, 762-766` | `test_o1_rebuttal.py:test_v3_rebut_option_parsing_and_budget` | `[Observed]` |
|
||||
| `--verbose` | `false` | 상세 실행 타임라인 추적 출력 활성화 플래그 | `run_loop.sh:77` | N/A | `[Observed]` |
|
||||
| `--cleanup` | `false` | 루프 성공 종료 시 생성된 임시 작업 디렉터리(`.mam/jobs/<job>`) 및 가입자 출력 파일 삭제 | `run_loop.sh:78, 921-930` | N/A | `[Observed]` |
|
||||
| `-h`, `--help` | N/A | 도움말 출력 후 **exit 1**로 비정상 종료 (현재 코드의 관행적 동작) | `run_loop.sh:50, 86` | `test_loop_cli.py:test_help_lists_creator_not_target_agent` | `[Observed]` |
|
||||
|
||||
> ⚠️ **Help Exit Code Gap (`[Gap]`)**:
|
||||
> 현재 `run_loop.sh:50`은 `usage()` 내부에서 `exit 1`을 호출하여 `--help` 요청 시에도 exit 1을 반환합니다. 표준 CLI 관례(exit 0)와 다르나, 기존 서브프로세스 테스트들이 `exit 1`에 의존하므로 Phase 0에서는 현행 동작을 유지하며 의도적 변경은 Phase 3 wrapper 승격 시 별도 Decision으로 다룹니다.
|
||||
|
||||
### 2-2. 환경, 경로 및 수명주기 계약
|
||||
|
||||
1. **파이썬 인터프리터 탐색 (`_delegate_py_bin`)**:
|
||||
- `AGENT_PYTHON_BIN` ➔ `VIRTUAL_ENV/bin/python` ➔ 상위 디렉터리 `.venv/bin/python` 탐색 ➔ 시스템 `python3` 순으로 캐싱 및 결정 (`lib.sh:1841-1861`). `[Observed]`
|
||||
2. **런타임 고정 복사본 (B-13 Freeze Snapshot)**:
|
||||
- 루프 기동 시 `.agents/skills/` 트리를 `/tmp/mam-loop-freeze.XXXXXX`로 복사 후 해당 위치의 `run_loop.sh`로 `exec` 재실행 (`run_loop.sh:112-127`). `[Observed]`
|
||||
- 단, `multi-agent-mux-create`, `resume`, `stop` 등 1회성 스크립트는 Live Tree를 참조 (`[Observed]`).
|
||||
3. **루프 가드 및 시그널 정리**:
|
||||
- `MAM_LOOP_MARKER`(`.mam/loop-guard-active`)에 `pid`, `lstart`, `started` 타임스탬프를 원자적 하드링크로 기록 (`loop_lock.sh:69-75`). `[Observed]`
|
||||
- `trap _mam_release_guard EXIT INT TERM HUP`을 통해 비정상 종료 시에도 안전하게 가드 파일 및 Freeze 임시 디렉터리 정리 (`run_loop.sh:189`). `[Observed]`
|
||||
4. **작업 위임 함수 계약**:
|
||||
- `delegate_submit_job`: `registry.py register`를 실행하고 생성된 32-bit hex JID를 stdout으로만 출력 (`lib.sh:1876-1889`). `[Observed]`
|
||||
- `delegate_publish_event`: Non-fatal 계약 (`|| true`). 누락된 JID, 스크립트 부재, 브로커 전송 실패 시에도 0을 반환하여 호출자를 중단시키지 않음 (`lib.sh:1897-1905`). `[Observed]`
|
||||
- `start_watchdog`: 백그라운드에서 `reconcile.sh --subscribe --idle-timeout 0`를 실행하고 해당 데몬 PID를 stdout으로 반환 (`lib.sh:1908-1938`). `[Observed]`
|
||||
|
||||
---
|
||||
|
||||
## 3. 이벤트 스키마 및 수명주기 계약 (MQTT & Registry)
|
||||
|
||||
### 3-1. 이벤트 스키마 v1 명세
|
||||
- 필수 필드: `schema_version` (1), `seq` (단조 증가 양의 정수), `job_id` (JID), `event` (이벤트명), `timestamp` (ISO-8601 UTC `YYYY-MM-DDTHH:MM:SSZ`), `detail` (문자열 설명).
|
||||
- 확장 필드: `data` (딕셔너리), `data.hmac_sig` (HMAC-SHA256 16진수 서명).
|
||||
|
||||
### 3-2. 이벤트와 레지스트리 상태(Status) 매핑 현황
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending: register_job
|
||||
pending --> running: pick_pending / event 'started'
|
||||
running --> running: event 'progress' (상태 변경 없음)
|
||||
running --> running: event 'permission_required' (상태 변경 없음)
|
||||
running --> completed: event 'completed' (Terminal)
|
||||
running --> error: event 'error' (Terminal)
|
||||
running --> cancelled: event 'cancelled' / stop_session (Terminal)
|
||||
```
|
||||
|
||||
| 이벤트 이름 | 레지스트리 상태 전이 | 지속성(Retain) | 설명 | 범주 |
|
||||
|---|---|---|---|---|
|
||||
| `started` | `running` | `false` | 작업자가 작업을 수락하고 실행을 시작함 | `[Observed]` |
|
||||
| `progress` | 전이 없음 (`running` 유지) | `false` | 중간 진행 경과 보고. 레지스트리 status는 미변경 | `[Observed]` |
|
||||
| `permission_required` | 전이 없음 (`running` 유지) | `false` | 사용자 권한 승인 대기. 레지스트리 status는 미변경 | `[Observed]` |
|
||||
| `completed` | `completed` | `true` | 작업 성공 완료. Terminal 상태 | `[Observed]` |
|
||||
| `error` | `error` | `true` | 작업 실패 종료. Terminal 상태 | `[Observed]` |
|
||||
| `cancelled` | `cancelled` | `true` | 세션 중단 등에 의한 취소. Terminal 상태 | `[Observed]` |
|
||||
|
||||
> ⚠️ **이벤트-상태 간극 Gap (`[Gap]`)**:
|
||||
> 1. `progress`와 `permission_required`는 이벤트로 발행되지만 레지스트리 `status` enum(`VALID_STATUSES = ('pending', 'running', 'completed', 'error', 'cancelled')`)에 포함되지 않아, 레지스트리 파일만으로는 권한 승인 대기 여부를 직접 알 수 없습니다 (`registry.py:38`).
|
||||
> 2. `publish_event.py`의 Terminal 이벤트는 `("completed", "error", "cancelled")`이지만, `job_subscriber.py:46`의 Terminal 이벤트는 `("completed", "error")`로 정의되어 있습니다. 가입자가 디스크 폴백(`_check_disk_fallback`)을 수행할 때 비로소 `cancelled` 상태를 감지하여 가상 `error` 이벤트(`synth_event="error"`)로 변환하여 처리합니다 (`job_subscriber.py:80`).
|
||||
> 3. **Terminal Non-reentry (`[Proposed]` vs `[Gap]`)**: 한 번 Terminal 상태에 도달한 후 중복/상충 이벤트에 의해 상태가 역행하지 않는 불변식은 Phase 2a의 목표 계약(`[Proposed]`)이며, 현행 `update_job_status`는 락 내에서 단순 필드 덮어쓰기(`record.update(fields)`)를 수행하므로 완벽한 비역행이 보장되지 않는 간극(`[Gap]`)이 있습니다.
|
||||
|
||||
### 3-3. 보안 및 재생 공격 방어
|
||||
1. **인증 모드 분리**: `auth_token == null`일 때는 HMAC 검증 통과 (PoC 모드). `auth_token != null`일 때는 `data.hmac_sig` 검증 실패 시 즉시 폐기 (`job_subscriber.py:122-126`). `[Observed]`
|
||||
2. **단조 시퀀스 검증**: 가입자는 수신된 페이로드의 `seq`가 이전 `last_seq`보다 큰 경우에만 처리하고, 같거나 작은 패킷은 replay 공격으로 간주하여 drop (`job_subscriber.py:127-136`). `[Observed]`
|
||||
3. **로컬 감사 추적(Audit Log)**: MQTT 전송 성공/실패와 무관하게 `append_event`를 통해 `.mam/delegate_job_logs/<job_id>/{meta,events,status}`에 모든 시도 기록 (`mqtt_common.py:122, publish_event.py:208-219`). `[Observed]`
|
||||
4. **MQTT 전송 실패 시 로컬 상태 기록**: 브로커 접속 불가 시에도 `publish_event.py`는 로컬 레지스트리의 상태를 `new_status`로 동기화한 뒤 exit code 2로 종료 (`publish_event.py:223-233`). `[Observed]`
|
||||
|
||||
---
|
||||
|
||||
## 4. 리포트 파싱 및 승격 계약 (Report & Promotion)
|
||||
|
||||
### 4-1. 판정 토큰(Verdict) 추출 로직
|
||||
- `run_loop.sh:204-210`: `has_verdict "$file" "$verdict"`는 파일의 **마지막 비공백 행(last non-blank line)**을 추출하여 `^\[VERDICT: ${verdict}\][[:space:]]*\r?$` 패턴과 일치하는지 검사합니다.
|
||||
- `[Gap]`: 마지막 행이 마크다운 코드 블록(```) 내부에 있는 예제 문자열인 경우에도 현재 셸 정규식은 이를 유효한 판정 토큰으로 오인할 수 있습니다. Phase 2의 엄격한 파서(Strict Parser)는 코드 펜스 내부를 제외해야 합니다.
|
||||
- 리포트 파일 위치 탐색(`find_report`): `find ".mam/jobs/$1" -maxdepth 2 -name "report-final.md" | head -n 1`로 첫 번째 일치 파일을 취하며, 정확한 단일 경로 강제가 아닙니다 (`run_loop.sh:234`). `[Observed]`
|
||||
|
||||
### 4-2. 승격(Promotion) 및 정리(Cleanup) 계약
|
||||
1. **계획서 승격**: `CURRENT_PLAN`을 `.agents/reports/$PLANNER_SESSION/plan-${PLAN_JOB_ID}.md.tmp`에 기록 후 `mv -f`로 원자적 교체 (`run_loop.sh:895-901`). `[Observed]`
|
||||
2. **리뷰 리포트 승격**: 각 리뷰어의 `report-final.md`를 `.agents/reports/$rev/report-${job_id}.md`로 복사 및 이동 (`run_loop.sh:903-916`). `[Observed]`
|
||||
3. **배치 비원자성 (`[Gap]`)**: 다수의 리뷰 리포트를 승격할 때 개별 파일 단위로 `cp && mv`를 수행하므로, 중간 크래시 발생 시 일부 리포트만 승격되는 부분 성공 위험이 존재합니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 잠금(Lock) 및 동시성 제어 계약
|
||||
|
||||
### 5-1. 락 체계 및 획득 순서
|
||||
1. **Workspace Loop Guard**: `.mam/loop-guard-active` (원자적 하드링크 + `pid`/`lstart` 스틸 재시도).
|
||||
2. **Session Registry Lock**: `.mam/agent-sessions.db` (SQLite WAL 동시성 및 트랜잭션).
|
||||
3. **Job Registry Lock**: `.mam/jobs/<id>.lock` 또는 레지스트리 디렉터리 락 (`fcntl.flock(LOCK_EX)`).
|
||||
|
||||
### 5-2. `update_job_status`의 비-CAS 특성 (`[Gap]`)
|
||||
- 현재 `mqtt_common.py:377-403`의 `update_job_status`는 락을 획득한 후 `record.update(fields)`로 단순히 필드를 덮어쓰며, 호출자가 기대한 이전 상태(`expected_status`)나 attempt 버전을 비교하지 않습니다.
|
||||
- 또한 `registry.py:129-131`의 `pick_pending`은 `update_job_status`를 거치지 않고 직접 파일에 `chosen["status"] = "running"`을 씁니다.
|
||||
- **`[Proposed]`**: Phase 2a에서는 모든 상태 변경이 단일 엔진의 원자적 CAS(Compare-And-Swap) 인터페이스를 통해서만 수행되도록 통일해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 소스 및 테스트 매핑 요약
|
||||
|
||||
| 구성요소 | 핵심 소스 파일 | 핵심 테스트 파일 | 기준선 상태 |
|
||||
|---|---|---|---|
|
||||
| 오케스트레이터 CLI | `run_loop.sh` | `tests/test_loop_cli.py` | 9개 테스트 통과 |
|
||||
| 루프 가드 락 | `loop_lock.sh` | `tests/test_o2_race_free_lock.py` | 22개 테스트 통과 |
|
||||
| 반론 상한선 | `run_loop.sh` | `tests/test_o1_rebuttal.py` | 10개 테스트 통과 |
|
||||
| 위임 헬퍼 | `lib.sh` + `lib_py/delegation/` | `tests/test_issue5_phase1_delegation.py` | Phase 1 추출 완료 (v4.3.0); shim 경로·CLI 계약 불변 |
|
||||
| 작업 레지스트리 | `registry.py`, `mqtt_common.py` | `tests/test_tier2_component.py` | fcntl 기반 락킹 |
|
||||
@@ -0,0 +1,128 @@
|
||||
# 📑 Herdr Runtime Contract and Backend Capability Matrix
|
||||
|
||||
- **Document Version**: Rev.5 (Evidence Source-Kind Dispatch & Identity Correspondence — §3-1 신설)
|
||||
- **Issue**: [Gitea Issue #5](https://git.godopu.com/tmpl/multi-agent-mux/issues/5)
|
||||
- **Target Herdr Version**: `herdr 0.8.2`
|
||||
- **Scope**: Native Herdr API Contract, External Collector Specification, and Backend Matrix
|
||||
|
||||
---
|
||||
|
||||
## 1. Herdr 0.8.2 네이티브 API 계약 (Native API Contract)
|
||||
|
||||
### 1-1. 핵심 CLI 명령어 및 반환 페이로드
|
||||
`herdr`는 AI 코딩 에이전트 전용 터미널 멀티플렉서로서, 소켓 API를 통해 터미널 스크린 감시 상태를 제공합니다:
|
||||
|
||||
1. **`herdr agent list`**: 현재 활성화된 모든 에이전트 목록 및 상태 반환 (JSON stdout, exit 0).
|
||||
2. **`herdr agent get <target>`**: 특정 에이전트의 단일 시점 스냅샷 반환 (`agent_status`, `interactive_ready`, `pane_id`, `revision`, `state_change_seq` 등).
|
||||
3. **`herdr agent wait <target> [OPTIONS]`**: 에이전트가 지정된 상태에 도달할 때까지 블로킹 대기.
|
||||
4. **`herdr agent explain [OPTIONS] [target]`**: 내부 정규식 매칭 규칙(`matched_rule`, `evaluated_rules`) 평가 세부정보 반환.
|
||||
|
||||
### 1-2. 네이티브 상태 집합 (`agent_status`)
|
||||
- **지원 Enum**: `idle`, `working`, `blocked`, `done`, `unknown` (총 5종).
|
||||
- **`interactive_ready` (boolean)**:
|
||||
- 터미널이 키 입력을 수신할 준비가 되었는지를 나타내는 플래그.
|
||||
- **실측 불변식**: 에이전트가 코딩 작업 중(`agent_status: "working"`)일 때도 `interactive_ready: true`임이 입증됨. 따라서 `interactive_ready` 단독으로는 작업 완료의 충분조건이 될 수 없으며, 오직 `agent_status in {"idle", "done"}` 상태와 결합되어야 합니다.
|
||||
|
||||
### 1-3. `herdr agent wait` 호출 규격 및 채널 분리 실측
|
||||
실제 `herdr 0.8.2` 실행(`wait-contract.json` 및 `evidence/timeout_stderr.json`)을 통해 검증된 입출력 채널 및 종료 코드 계약은 다음과 같습니다:
|
||||
|
||||
| 호출 조건 | 명령 예시 | 종료 코드 | 출력 채널 | 반환 JSON 페이로드 구조 |
|
||||
|---|---|---|---|---|
|
||||
| **상태 도달 (성공)** | `herdr agent wait creator-agy-01 --until working --timeout 1000` | `exit 0` | `stdout` | `{"id":"cli:agent:wait","result":{"agent":{...},"type":"agent_info"}}` |
|
||||
| **타임아웃 (Timeout)** | `herdr agent wait creator-agy-01 --until done --timeout 50` | `exit 1` | **`stderr`** | `{"error":{"code":"timeout","message":"timed out waiting for agent status"},"id":"cli:agent:wait"}` |
|
||||
| **대상 부재 (Unknown)** | `herdr agent wait non-existent --until idle --timeout 50` | `exit 1` | **`stderr`** | `{"error":{"code":"agent_not_found","message":"agent target non-existent not found"},"id":"cli:agent:wait"}` |
|
||||
| **기본 대기 옵션 생략** | `herdr agent wait <target>` | N/A | N/A | `--until` 생략 시 기본 매칭 대상은 `idle, done, blocked`임. `--timeout` 생략 시 무기한 대기함 |
|
||||
|
||||
> 🚨 **Critical Empirical Finding (수정 반영)**:
|
||||
> `herdr agent wait`에서 성공(상태 일치) 시에는 정상 JSON이 `stdout`으로 출력되지만, **타임아웃(`timeout`) 및 대상 부재(`agent_not_found`) 등 모든 에러 응답은 `stderr`로 에러 JSON이 출력되며 종료 코드는 `exit 1`**입니다 (`stdout`은 비어있음). 따라서 Watcher 엔진은 `exit 1` 시 반드시 `stderr` 스트림에서 JSON 에러 페이로드를 파싱해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 외부 시퀀스 수집기 계약 (External Sequence Collector)
|
||||
|
||||
### 2-1. 단일 `wait` 호출의 본질적 한계
|
||||
- `herdr agent wait`는 타겟이 특정 상태에 도달한 시점에 **단 1개의 최종 스냅샷 JSON**만 반환하는 동기적 장벽(Barrier)입니다.
|
||||
- 호출 도중 거쳐간 중간 전이 과정(Transition Stream)을 반환하지 않습니다.
|
||||
- 따라서 `working ➔ idle`과 같은 상태 전이를 입증하려면, 외부 오케스트레이터/수집기가 사전 스냅샷(`before`)과 사후 스냅샷(`after`)을 독립적으로 획득하여 타임스탬프와 함께 연결해야 합니다.
|
||||
|
||||
### 2-2. 수집기(Collector) 권장 규격
|
||||
- **폴링 주기**: 1~2초 수준의 유계(Bounded) 폴링 (`agent get`). 고정밀 10ms 스핀 폴링은 불필요하며 CPU 낭비를 초래합니다.
|
||||
- **Envelope 메타데이터**: 단조 시계 오프셋(`monotonic_start`, `monotonic_end`), `collector_clock_id`, `capture_id`, `source_command`를 필수로 보존하여 시점 간 왜곡을 방지합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 이벤트, 파일시스템 및 디스패치 연계 경계
|
||||
|
||||
Herdr 네이티브 페이로드와 Multi-Agent-Mux 오케스트레이션 메타데이터 사이에는 엄격한 책임 격리가 유지되어야 합니다:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Herdr Native API Boundary │
|
||||
│ • agent_status (idle/working/blocked/done/unknown) │
|
||||
│ • interactive_ready (true/false) │
|
||||
│ • pane_id, state_change_seq, terminal_title │
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│ 관측 증거 제공 (채널 2)
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Multi-Agent-Mux Watcher Boundary │
|
||||
│ • 4-Fold Attribution: │
|
||||
│ (job_id, attempt_id, session_uuid, │
|
||||
│ pane_process_generation) │
|
||||
│ • File Evidence (report mtime debounce >=15s, hash) │
|
||||
│ • Backplane Events (seq, HMAC signature, started/progress)│
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **불변식**: Herdr 원본 응답 페이로드에는 `job_id`, `attempt_id`, `report phase`, `expected_decision` 등의 MAM 내부 필드를 절대 주입하지 않습니다.
|
||||
|
||||
### 3-1. Watcher 타임라인 증거 스키마 및 `source_kind` 분기 계약 (Rev.5)
|
||||
|
||||
Watcher 복합 시나리오(`tests/fixtures/issue_5/watcher/*.json`)의 각 타임라인 항목은 아래 계약을 따르며, `tests/test_issue5_phase0_contracts.py`의 `validate_watcher_scenario`가 이를 기계적으로 강제합니다. 위반 시 fixture 검증은 **fail-closed(거부)** 입니다.
|
||||
|
||||
| 규칙 | 내용 | 강제 지점 |
|
||||
|---|---|---|
|
||||
| **지원 source_kind 집합** | `herdr`, `event`, `filesystem`, `harness` 4종만 허용. 그 외 값은 provenance와 무관하게 거부 | `SUPPORTED_SOURCE_KINDS` |
|
||||
| **관측(observed) 증거 스키마** | 독립 증거 스키마가 구현된 kind만 `source_provenance: observed`를 선언할 수 있음. **현재 `herdr`만 해당.** `event`/`filesystem`/`harness`는 스키마가 생길 때까지 `simulated`(또는 `synthetic`)로만 기록하며, `observed`로 표기 시 `capture_ref`가 유효하더라도 즉시 거부 | `OBSERVED_EVIDENCE_SCHEMAS` |
|
||||
| **capture_ref 격리** | repo 상대 경로여야 하며(절대 경로 금지), 해석 결과가 `tests/fixtures/issue_5/herdr/` 하위여야 함(`..` 우회 금지). `.json`이고 `schema_version 1.0.0`·`provenance observed`·`capture_id`를 갖는 네이티브 캡처만 증거로 인정 | `_load_observed_capture_ref` |
|
||||
| **Herdr 항목 대응성** | ① 참조 캡처는 Layer 1 네이티브 계약(`validate_native_capture`)을 재통과 ② 항목 payload는 비어있지 않은 dict이며 `agent_status`를 **필수 선언**(누락 시 비교 생략이 아닌 거부) ③ 캡처는 `result.agent` 객체를 포함해야 함(`wait` timeout 등 nonzero-exit 캡처는 agent_status를 입증할 수 없으므로 거부) ④ 선언된 **모든** 필드가 agent 객체의 네이티브 필드에 존재하고 값이 정확히 일치(비네이티브 필드 선언 시 거부) | `_validate_observed_herdr_item` |
|
||||
| **정체성 대응 (4중 귀속 연동)** | 캡처의 `agent_session.value` == `dispatch_context.agent_session_uuid`, 캡처의 `pane_id`가 `dispatch_context.pane_process_generation`(`<pane_id>:<pid>:<start>`)의 선두 구성요소여야 함. 시나리오 `backend`가 `backend_agnostic`이 아니면 캡처 `backend`와 일치해야 함 | `_validate_observed_herdr_item` |
|
||||
| **1캡처 = 1관측** | 하나의 `capture_id`는 시나리오 내 관측 항목 하나만 입증할 수 있음(동일 스냅샷의 재인용 금지) | `validate_watcher_scenario` |
|
||||
| **provenance 정직성** | observed·simulated 혼재 시 전체 `provenance`는 `mixed`, 전량 관측 시 `observed_composite`, 전량 모의 시 `simulated`여야 함 (`synthetic` 항목은 비관측으로 집계) | `validate_watcher_scenario` |
|
||||
| **매니페스트 정합성** | `watcher/manifest.json`의 `provenance`/`expected_decision`은 시나리오 파일과 일치해야 하며, `herdr/manifest.json`의 observed 슬롯은 동일 backend의 캡처가 슬롯명과 같은 `agent_status`(또는 `wait_timeout` 슬롯은 `error.code=timeout`)를 기록해야 함. 디스크의 모든 캡처는 정확히 하나의 observed 슬롯에 색인되어야 함(고아 캡처 금지) | `validate_watcher_manifest`, `validate_herdr_manifest` |
|
||||
|
||||
> 📌 이 계약은 **fixture 검증 계약**이며 프로덕션 Watcher 구현(Phase 2a)이 아닙니다. Phase 2a의 `watcher.py`는 이 표의 "관측 증거 스키마" 열을 확장(event/filesystem 스키마 추가)할 때 `OBSERVED_EVIDENCE_SCHEMAS`를 함께 갱신해야 하며, 스키마 없는 kind를 관측으로 승격하는 일은 허용되지 않습니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 6개 백엔드 기능 및 실측 증거 행렬 (Capability Matrix)
|
||||
|
||||
본 프로젝트가 지원하는 6개 에이전트 백엔드(`claude`, `agy`, `hermes`, `grok`, `opencode`, `codex`)의 현 환경 실측 상태 및 Gap 현황입니다 (모든 슬롯은 `tests/fixtures/issue_5/herdr/manifest.json`과 100% 일치):
|
||||
|
||||
| 백엔드 | 설치 바이너리 경로 | 활성 세션명 | 실측 캡처 슬롯 | 미관측 슬롯 (Gaps) 및 사유 | Provenance |
|
||||
|---|---|---|---|---|---|
|
||||
| **`claude`** | `~/.local/bin/claude` | `planner-reviewer-claude-01` | `idle` (`claude/idle.json`) | `working`, `blocked`, `done`, `unknown` (캡처 시점에 idle 상태였음) | `observed` |
|
||||
| **`agy`** | `~/.local/bin/agy` | `creator-agy-01` | `working` (`agy/working.json`), `wait_timeout` (`agy/wait_timeout.json`) | `idle`, `blocked`, `done`, `unknown` (작업 수행 중이며 권한 건너뛰기 적용) | `observed` |
|
||||
| **`grok`** | `~/.grok/bin/grok` | `reviewer-creator-grok-01` | `idle` (`grok/idle.json`) | `working`, `blocked`, `done`, `unknown` (리뷰 대기 중 유휴 상태 캡처) | `observed` |
|
||||
| **`codex`** | `~/.local/bin/codex` | `planner-reviewer-codex-01` | `done` (`codex/done.json`) | `idle`, `working`, `blocked`, `unknown` (이전 플래너 작업 완료 직후 캡처) | `observed` |
|
||||
| **`hermes`** | `~/.local/bin/hermes` | N/A (미가동) | 없음 | `idle`, `working`, `blocked`, `done`, `unknown` (Herdr 서버에 활성 세션 부재) | `unobserved` |
|
||||
| **`opencode`**| `~/.opencode/bin/opencode` | N/A (미가동) | 없음 | `idle`, `working`, `blocked`, `done`, `unknown` (Herdr 서버에 활성 세션 부재) | `unobserved` |
|
||||
|
||||
> 📌 **정직한 증거 보고 원칙 (D3, D6)**:
|
||||
> 1. 오직 실제로 실행하여 캡처한 슬롯만 `observed`로 인정하며, 모의 실행이나 가상 시뮬레이션을 네이티브 캡처로 둔갑시키지 않습니다.
|
||||
> 2. `hermes`와 `opencode`의 미가동 슬롯 및 기존 4개 백엔드의 미관측 상태는 모두 정직하게 `unobserved gap`으로 기록합니다.
|
||||
> 3. Grok 세션 등은 캡처 시점에 `idle`이었으나 리뷰 실행 시 `working`으로 전이되므로, 정적 캡처가 에이전트의 영구 불변 능력을 뜻하지 않음을 명시합니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 복합 시나리오 기대 정책 (Composite Expected Policy)
|
||||
|
||||
Watcher가 다중 채널 증거를 결합하여 내리는 최종 완료 판정 정책:
|
||||
|
||||
1. **`initial_idle`**: 디스패치 기록은 있으나 해당 시도의 실행 앵커가 없으면 ➔ **`hold`** (완료 거부).
|
||||
2. **`tool_running`**: Herdr는 `working`이나 하네스에서 도구 실행 중이면 ➔ **`hold`** (대기).
|
||||
3. **`report_pause_15s`**: 파일이 15초간 멈추었으나 확정 매니페스트가 없으면 ➔ **`hold`** (대기).
|
||||
4. **`finalized_candidate`**: 4중 귀속 일치 + Fresh `idle/done` + 원자적 매니페스트/해시 일치 ➔ **`candidate_approved`** (CAS 커밋 허용).
|
||||
5. **`blocked_permission`**: Herdr가 `blocked`이면 ➔ **`hold_blocked`** (완료 금지).
|
||||
6. **`transport_disconnected`**: 소켓 통신 실패 시 ➔ **`hold_reconnect`** (스냅샷 재동기화).
|
||||
7. **`restarted_generation`**: PID/세션 식별자가 변경된 과거 시도의 신호 ➔ **`reject_stale`** (전이 거부).
|
||||
@@ -0,0 +1,63 @@
|
||||
# 📑 Issue #5 Implementation Plan (Phase 1–4 Execution & Rollback Specification)
|
||||
|
||||
- **Document Version**: Rev.4 (Post Revision Review Corrections)
|
||||
- **Issue**: [Gitea Issue #5](https://git.godopu.com/tmpl/multi-agent-mux/issues/5)
|
||||
- **Status**: Target Architecture Roadmap & Execution Gate Criteria
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요 및 마이그레이션 원칙
|
||||
|
||||
본 문서는 Issue #5(Python 모듈화 및 `lib_py` 승격)의 단계적 구현(Phase 1–4) 순서, 필수 완료 기준(Gate), 롤백 절차, 그리고 명시적 비범위(Out of Scope)를 규정합니다.
|
||||
|
||||
### 📌 핵심 원칙:
|
||||
1. **점진적 격리 및 무중단 전환**: 프로덕션 셸 오케스트레이션(`run_loop.sh`)은 Phase 3 이전까지 계속해서 활성 상태를 유지하며, 파이썬 모듈은 독립된 패키지(`lib_py`)로 단계적으로 승격됩니다.
|
||||
2. **동일 작업공간 다중 Writer 절대 금지**: 과도기적 단계에서도 특정 워크스페이스에는 오직 단 하나의 상태 제어자(신규 엔진 또는 구형 셸 루프)만 실행되어야 합니다.
|
||||
3. **Fail-Closed 및 Rollback 보장**: 각 단계는 이전 버전으로 즉시 되돌릴 수 있는 명확한 롤백 소유권과 체크포인트 인계 절차를 갖추어야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 단계별 구현 단위 및 필수 완료 기준 (Roadmap & Gates)
|
||||
|
||||
| 단계 | 작업 목표 | 세부 구현 내용 | 필수 완료 게이트 (Gate Criteria) | 롤백 절차 (Rollback Path) |
|
||||
|---|---|---|---|---|
|
||||
| **Phase 0**<br>*(현재)* | **사전 계약 고정 및 Fixture 수집** | • CLI, 이벤트, 리포트, 락 계약 기준선 문서화<br>• Herdr 0.8.2 네이티브 캡처 및 복합 Watcher 시나리오 2계층 분리<br>• 6개 백엔드 실측 증거 행렬 수집 | • 510개 전체 테스트 수집 및 회귀 0-Fail (100% 통과)<br>• Fixture 스키마 및 증거 경계 검증 통과<br>• 프로덕션 코드 무변경 diff 검증 | 미추적 산출물 즉시 삭제 (`rm -rf docs/contracts/issue-5 tests/fixtures/issue_5 tests/test_issue5_phase0_contracts.py`) |
|
||||
| **Phase 1**<br>*(완료 v4.3.0)* | **`lib_py/delegation/` 추출** | • `lib.sh` L1833 이하 위임 헬퍼를 파이썬 서비스 클래스로 추출<br>• `start_watchdog`을 내부 명칭 `ensure_subscriber_daemon`으로 명명(기존 함수 별칭 보존)<br>• 장수 프로세스 단일 MQTT 연결 재사용 프로토타입 | • CLI/non-fatal/HMAC/seq/디스크 폴백 회귀 100% 통과<br>• 데몬 수명 및 PID 계약 일치 확인<br>• **자동완료 정책 변경 없음** | `MAM_DELEGATION_PY=0`으로 보존된 레거시 인라인 본체(`_*_legacy`)로 즉시 분기 (재배포·revert 불필요; 레거시 본체 제거는 Phase 4) |
|
||||
| **Phase 2a**<br>*(완료, shadow-only)* | **`lib_py/loop/` 코어 & Shadow Watcher** | • `engine.py` (단일 전이 주체), `watcher.py` (`herdr agent wait` 래핑) 구현<br>• 모든 Writer에 대한 단일 CAS 트랜잭션 경계 구현<br>• 자동완료를 수행하지 않고 "Would-Complete" 로깅만 수행하는 Shadow 모드 가동 | • 락 내 비동기/I/O 배제 검증<br>• 소켓 단절/스냅샷 재연결 백오프 검증<br>• Shadow 모드가 레지스트리 상태를 쓰지 않음을 검증 | 1) Shadow 모니터 프로세스 종료 및 인메모리 큐 폐기<br>2) CAS 서비스는 하위 호환 모드로 유지하거나 `MAM_ENGINE_CAS=0` 플래그로 비활성화하여 레거시 무조건 필드 병합(`update_job_status`)으로 복귀<br>3) 기존 셸 루프 상태 및 레코드 불변 유지 |
|
||||
| **Phase 2b**<br>*(완료, 기본 비활성)* | **이중 감지 자동완료 활성화 (Flag-Gated)** | • 4중 실행 귀속 + 리포트 확정 계약 + 단일 CAS 전이 게이트 연동<br>• Pending Notification 지연 알림 및 크래시 복구 구현<br>• 검증된 백엔드/작업에 한해 opt-in 플래그로 활성화 (기본값 OFF) | • 부수효과 작업 예외 및 무판정 fail-closed 테스트 완료<br>• 경쟁/재시작 시나리오 통과<br>• 플래그 OFF 시 기존 explicit event 경로 100% 보존 | 환경변수/플래그 `MAM_AUTO_COMPLETE=0` 설정; 이미 확정된 terminal 레코드는 보존하고 신규 자동완료만 중단 |
|
||||
| **Phase 3** | **`run_loop.sh` Thin Wrapper 전환 및 전면 회귀** | • `run_loop.sh`를 인자 전달 래퍼(`exec python3 -m lib_py.loop "$@"`)로 전환<br>• 플래너/리뷰어/반론/승격 오케스트레이션 로직 완전 이식<br>• 엄격한 판정 파서(Strict Parser) 적용 | • 5개 subprocess 전용 테스트(~1,621줄) 포함 전체 481개+ 테스트 0-Fail<br>• 시그널, 루프 가드, 임의 CWD 및 공백 경로 검증 통과 | **우아한 핸드오프 시퀀스**:<br>1) Quiesce: 실행 중인 Python 루프 프로세스에 종료 신호를 전달하여 신규 디스패치 중단<br>2) Checkpoint: 현재 루프 회차, 활성 Job ID 및 리뷰 상태를 `.mam/loop-checkpoint.json`에 원자적 기록<br>3) Exit: Python 루프 정상 종료 및 가드 해제<br>4) Resume: 백업된 셸 원본 스크립트로 `run_loop.sh` 복원 후 체크포인트 기반 재개 검증 |
|
||||
| **Phase 4** | **불변 런타임 검증 및 Freeze 제거** | • 기동 시 전체 모듈 상주(Eager Import) 및 자식 템플릿 불변성 검증<br>• 작업 도중 스킬 파일 교체 및 늦은 경로 스트레스 테스트 통과<br>• `/tmp/mam-loop-freeze.XXXXXX` 우회책 완전 제거 | • 런타임 자기수정 크래시(B-13) 재발 방지 검증<br>• Live Helper 버전 호환 게이트 검증 통과<br>• 운영 문서 및 배포 스크립트 갱신 | Freeze 복사 로직(`run_loop.sh:112-127`) 재활성화 및 라이브 헬퍼 버전 검사 복구 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 라이브 헬퍼 버전 호환성 및 작업 상태 전이 계약 (Live Helper Compatibility Gate)
|
||||
|
||||
통합 보고서(Rev.2) 및 B-13 합의에 따라, `multi-agent-mux-create`, `resume`, `stop` 등 1회성 스크립트는 Freeze 복사본이 아닌 Live Tree에서 실행됩니다. Phase 4에서 Freeze가 제거되기 전까지, 라이브 헬퍼와 고정 실행 엔진 간의 충돌을 방지하고 기존 작업 상태 전이 효과를 보존하기 위해 다음 규칙을 적용합니다:
|
||||
|
||||
1. **ABI 버전 및 프로토콜 해시 검증**:
|
||||
- Live Tree의 스크립트가 실행될 때, 현재 실행 중인 루프의 `MAM_VERSION` 또는 공통 라이브러리 인터페이스 해시를 비교합니다.
|
||||
- 메이저 버전 불일치 또는 레지스트리 스키마 변경이 감지되면, 라이브 헬퍼는 즉시 오류를 반환하고 안전하게 중단(fail-closed)합니다.
|
||||
2. **작업 취소(`cancelled`) 전이의 정규 서비스 경유 보존**:
|
||||
- `multi-agent-mux-stop/scripts/stop_session.sh`는 세션 강제 종료 시 잔여 활성 작업에 대해 `cancelled` 이벤트를 발행합니다 (`stop_session.sh:242, 322`).
|
||||
- 라이브 헬퍼는 레지스트리 JSON 파일을 임의로 변조하지 않고, `delegate_publish_event` 및 공통 상태 전이 서비스(Transition Service)를 호출하여 CAS 불변식을 준수하면서 작업을 정규 `cancelled` 상태로 전이시킵니다.
|
||||
- 이를 통해 세션 중단 시 작업이 영구 고착(hang)되지 않고 오케스트레이터 루프가 즉각 취소를 인지할 수 있도록 보장합니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 명시적 비범위(Out of Scope) 및 호환성 계약
|
||||
|
||||
1. **저장소 통합 금지**:
|
||||
- 세션 관리용 SQLite/YAML 저장소(`.mam/agent-sessions.yaml`, `.db`)와 비동기 작업 관리용 JSON 레지스트리(`.mam/jobs/`)를 하나로 통합하려 시도하지 않습니다.
|
||||
2. **원샷 스킬의 Live Tree 실행 유지**:
|
||||
- `multi-agent-mux-create`, `resume`, `stop` 등 1회성 셸 스크립트는 Freeze 복사본이 아닌 실제 작업 트리의 코드를 실행하도록 유지합니다.
|
||||
3. **타임아웃 3원 분리 보존**:
|
||||
- (a) 전체 벽시계 타임아웃, (b) MQTT 가입자 idle-timeout, (c) TUI idle 자동완료를 독립적으로 유지하며, 자동완료가 기존 타임아웃 메커니즘을 덮어쓰지 않습니다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 성능 수용 기준 (Performance Acceptance Criteria)
|
||||
|
||||
고정밀 10ms 폴링 대신 실제 시스템 자원 측정을 기준으로 성능을 평가합니다:
|
||||
- **프로세스 포크 수 (Fork Count)**: 루프 반복당 서브프로세스 생성 횟수 대폭 감소 (셸 루프 대비 ≥70% 감축).
|
||||
- **유휴 CPU 점유율 (Idle CPU)**: 대기 중 CPU 사용률 1% 미만 유지 (이벤트 구동 및 유계 폴링).
|
||||
- **MQTT 연결 재사용**: 단일 장수 프로세스 내에서 다수의 작업 발행 시 연결 재수립 오버헤드 제거 (p50 레이턴시 단축).
|
||||
@@ -0,0 +1,91 @@
|
||||
# 📊 Issue #5 Phase 1 — 장수 MQTT 연결 벤치마크 실측
|
||||
|
||||
- **문서 버전**: Rev.1
|
||||
- **측정 일시**: 2026-09-06
|
||||
- **기준 커밋**: Phase 1 커밋 4 (`feat(issue-5): persistent MQTT publisher prototype and benchmark harness`)
|
||||
- **하니스**: `python -m lib_py.delegation.bench --n <N> --transport {fake|broker}`
|
||||
- **근거 대상**: `implementation_plan.md` §5 "MQTT 연결 재사용 p50 단축" 항목
|
||||
|
||||
---
|
||||
|
||||
## 1. 측정 환경
|
||||
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| Python | 3.9.6 (`.venv`) |
|
||||
| paho-mqtt | 2.1.0 |
|
||||
| 플랫폼 | darwin-arm64 |
|
||||
| 로컬 브로커 | **없음** (`mosquitto`, `nats-server`, `docker` 모두 미설치) |
|
||||
| 실브로커 | `broker.hivemq.com:1883` (PoC 기본 공용 브로커, TLS 없음) |
|
||||
|
||||
> ⚠️ 로컬 브로커가 없으므로 실브로커 수치는 공용 인터넷 브로커에 대한 **1회 측정치**이며, 네트워크 상태에 좌우된다. **게이트 조건이 아니다.** 게이트가 걸린 것은 아래 §2의 결정적 지표뿐이다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 결정적 지표 — 가짜 전송 (CI 단정 대상)
|
||||
|
||||
합성 연결 지연 50 ms, N = 50.
|
||||
|
||||
| 지표 | OneShot | Persistent |
|
||||
|---|---:|---:|
|
||||
| 발행 수 | 50 | 50 |
|
||||
| **연결 수립 횟수** | **50** | **1** |
|
||||
| 실패 | 0 | 0 |
|
||||
| 발행당 지연 p50 | 54.813 ms | **0.002 ms** |
|
||||
| 발행당 지연 p95 | 55.049 ms | 0.003 ms |
|
||||
| 발행당 지연 max | 55.075 ms | 51.110 ms (최초 연결) |
|
||||
|
||||
`tests/test_issue5_phase1_delegation.py::test_bench_fake_transport_schema_and_counts`가 "OneShot = N, Persistent = 1" 및 p50 감소를 단정한다. 네트워크 접촉 0.
|
||||
|
||||
---
|
||||
|
||||
## 3. 정보성 지표 — 실브로커 (게이트 아님)
|
||||
|
||||
`broker.hivemq.com:1883`, N = 5.
|
||||
|
||||
| 지표 | OneShot | Persistent |
|
||||
|---|---:|---:|
|
||||
| 연결 수립 횟수 | 5 | 1 |
|
||||
| 실패 | 0 | 0 |
|
||||
| 발행당 지연 p50 | 1892.728 ms | **275.992 ms** |
|
||||
| 발행당 지연 p95 | 1942.762 ms | 875.412 ms |
|
||||
| 발행당 지연 max | 1942.762 ms | 875.412 ms (최초 연결) |
|
||||
|
||||
p50 기준 약 **85% 단축**. 연결 수립이 발행 지연의 지배적 요소임을 보여준다.
|
||||
|
||||
### 3-1. 이 프로토타입의 실측 동기
|
||||
|
||||
Rev.1 계획 job의 `started` 이벤트는 payload timestamp 05:58:49Z에서 모니터 수신 05:59:01.487Z까지 **약 12.5초**가 걸렸고, 원인은 `CONNECT_ACK_TIMEOUT=10` 1회 실패 후 재연결이었다. 본 job(`ddcaf930`)의 `started` 발행에서도 같은 CONNACK 경고가 재현됐다. 1회성 연결은 이 비용을 매 이벤트마다 다시 지불한다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 프로세스 포크 수 (셸 경로, 분석값)
|
||||
|
||||
`delegate_publish_event` 1회당:
|
||||
|
||||
| 구현 | fork |
|
||||
|---|---:|
|
||||
| 레거시 (`_delegate_publish_event_legacy`) | 3 (`$(_delegate_py_bin)`, `$(_delegate_script)`, python) |
|
||||
| 신규 (`_delegation_py` → `python -c` + `sys.path.insert(0, SKILL_DIR)`) | 3 (`$(_delegate_py_bin)`, `$(_delegate_script)`, python) |
|
||||
|
||||
> 신규 경로는 `-m`이 아니라 `_delegation_py`의 명시 부트스트랩이다(교차 리뷰 R1 대응: `-m`은 cwd가 `PYTHONPATH`보다 우선하므로 freeze 보장이 성립하지 않았다). fork 수는 그와 무관하게 동일하다.
|
||||
>
|
||||
> 계획서 D8은 신규 경로가 2 fork일 것으로 예상했으나, **실제 구현은 3 fork로 동일하다.** `_delegate_script publish_event.py` 조회를 유지했기 때문이다.
|
||||
>
|
||||
> **이 조회가 실제로 보장하는 것 (교차 리뷰 `cc3468cb` 정정)**: 초기 서술은 이 조회가 "비치명 계약(C-02)을 만드는 유일한 지점"이라고 했으나 부정확하다. rc 0 자체는 `delegate_publish_event`의 `|| true`만으로도 보장되고, C-02도 rc만 단정한다. 조회가 보장하는 것은 **위임 스크립트 트리가 없을 때 인터프리터를 아예 띄우지 않아 stderr에 트레이스백을 남기지 않는다는 정숙성과, 레거시 본체와의 동작 동일성**이다(인터프리터 미호출 단정은 C-01에 있다). 유지 결정 자체는 세 리뷰어 전원이 지지했으며, fork 절감은 Phase 4에서 이 계약과 함께 재검토한다.
|
||||
|
||||
---
|
||||
|
||||
## 5. 프로덕션 적용 범위
|
||||
|
||||
**Phase 1에서 장수 연결은 프로덕션 경로에 쓰이지 않는다.** CLI와 셸 헬퍼는 발행 1회 후 프로세스가 종료되므로 재사용 이득이 없고, 두 경로 모두 `OneShotPublisher`를 계속 쓴다. 첫 소비자는 Phase 2a의 장수 엔진이다.
|
||||
|
||||
## 6. 재현 명령
|
||||
|
||||
```bash
|
||||
export PYTHONPATH="$PWD/.agents/skills"
|
||||
.venv/bin/python -m lib_py.delegation.bench --n 50 --transport fake --connect-latency-ms 50
|
||||
# 실브로커(임시 레지스트리 필요)
|
||||
.venv/bin/python -m lib_py.delegation.cli submit --prompt bench --registry-dir .mam/jobs
|
||||
.venv/bin/python -m lib_py.delegation.bench --n 5 --transport broker --job <id> --registry-dir .mam/jobs
|
||||
```
|
||||
@@ -0,0 +1,85 @@
|
||||
# 📑 Issue #5 Phase 2a — Shadow Watcher 운영 노트
|
||||
|
||||
- **문서 버전**: Rev.1
|
||||
- **상태**: Phase 2a 완료 (**shadow-only**: 레지스트리 무기록)
|
||||
|
||||
---
|
||||
|
||||
## 1. 이 단계가 하는 일과 하지 않는 일
|
||||
|
||||
| 한다 | 하지 않는다 |
|
||||
|---|---|
|
||||
| `herdr agent wait`로 에이전트 상태를 관측 | 관측으로 상태를 바꾸는 일 |
|
||||
| 증거를 정책에 넣어 "완료 후보인가"를 **판정** | 판정 결과를 레지스트리에 **커밋** |
|
||||
| 판정을 `.mam/shadow/<job_id>.ndjson`에 기록 | job 레코드 필드를 추가/변경 |
|
||||
| 4중 귀속 증거를 shadow 저장소에 보관 | 레코드 스키마 변경 (Phase 2b 결정) |
|
||||
|
||||
**자동완료는 Phase 2b다.** 본 단계의 `candidate_approved`는 "커밋했을 것"이라는 로그일 뿐이다.
|
||||
|
||||
## 2. 운영 스위치
|
||||
|
||||
| 변수 | 기본 | 효과 |
|
||||
|---|---|---|
|
||||
| `MAM_ENGINE_CAS` | `1` | `0`이면 `update_job_status`/`pick_pending`이 Phase 2a 이전의 보존된 본체로 분기한다. 재배포·revert 불필요. |
|
||||
|
||||
Shadow 러너는 애초에 쓰기 경로가 없으므로 **프로세스 종료만으로 흔적이 사라진다.** `.mam/shadow/`는 레지스트리와 분리되어 있어 삭제해도 job 상태에 영향이 없다.
|
||||
|
||||
## 2-1. 권한 구분 (교차 리뷰 반영)
|
||||
|
||||
| source | 할 수 있는 것 | 할 수 없는 것 |
|
||||
|---|---|---|
|
||||
| `publish_event` (에이전트) | 자신의 작업을 `running`/`completed`/`error`/`cancelled`로 전이 | **terminal을 되돌리는 것** (`redispatch`/`finalize` 거부) |
|
||||
| `registry_cli` (래퍼·운영자) | 위 전부 + `redispatch`(terminal→`pending`, iteration 증가 필수) + `finalize`(terminal→`error`) | iteration을 되돌리는 재생 |
|
||||
|
||||
**CLI 종료 코드**: `registry.py status`/`update`는 **진짜 거부**에만 non-zero(4)를 낸다. 잘못된 status 값은 rc 1(2a 이전 계약 유지), **멱등 ACK(이미 그 상태임)는 rc 0**이다 — 래퍼가 `set -e` 아래에서 승인 직후 `--set completed`를 실행하므로 이를 실패로 보고하면 성공 경로가 중단된다(교차 리뷰 F-C/F-D). Phase 2a 이전에는 거부가 불가능해 항상 0이었고, 그 침묵이 반복 위임이 조용히 깨진 것을 감췄다.
|
||||
|
||||
## 3. 락 규율 (P2A-01)
|
||||
|
||||
```
|
||||
with registry_lock: 판정 + 레코드 1회 쓰기 + 알림 큐잉 ← 블로킹 호출 금지
|
||||
락 해제 후: 감사 로그 flush, 발행 등 느린 작업
|
||||
```
|
||||
|
||||
감사 mirror가 락 밖으로 나가면서 순서 보장을 잃을 수 있으므로, 전이마다 단조 증가하는 `transition_seq`를 레코드와 알림에 함께 싣는다. **`update_logged_status`는 전용 per-job 감사 락 아래에서 읽기-비교-쓰기를 수행하고, 기존 `status.json`의 `transition_seq`가 더 크거나 같으면 쓰기를 건너뛴다**(교차 리뷰 R4: 락 없는 비교-후-쓰기는 두 flush가 모두 옛 값을 읽고 느린 쪽이 최신을 덮어쓸 수 있어, 시퀀스 번호만으로는 순서가 강제되지 않는다). 이 락은 **레지스트리 트랜잭션 밖**에서 잡으므로 느린 감사 I/O가 트랜잭션을 늘리지 않는다. 각 쓰기는 고유 임시 파일을 쓴다 — 지연된 flush가 최신 상태를 되돌리지 못하게 하는 것이며, 락이 공짜로 주던 순차 일관성을 복원한다(교차 리뷰 F-B). 이 형태는 새로 만든 것이 아니라 `pick_pending`이 이미 쓰던 패턴을 모든 전이로 일반화한 것이다.
|
||||
|
||||
## 4. `herdr agent wait` 사용 규칙
|
||||
|
||||
- `--timeout`은 **밀리초**다. 코드의 파라미터명은 `timeout_ms`이며 초 단위 형태는 제공하지 않는다.
|
||||
- `--until`은 **항상 명시**한다. 생략 시 기본 매칭이 `idle,done,blocked`라 `working`이 빠진다.
|
||||
- `exit 1`의 페이로드는 **stderr**에 있다. stdout은 비어 있으므로 stdout에서 에러를 읽으려 하면 안 된다.
|
||||
- **타임아웃은 건강한 응답이다.** 소켓이 답했고 상태가 안 바뀐 것뿐이다. 전송 실패와 구분해야 한다.
|
||||
- 전송 실패 시 백오프는 1→2→4→8→16초 상한이며, **캐시된 스냅샷을 폐기**한다. 단절 구간 이전의 상태로 추론하면 단절이 완료처럼 보인다.
|
||||
|
||||
## 4-1. 완료 귀속에 요구되는 증거 (교차 리뷰 R1)
|
||||
|
||||
`candidate_approved`는 **증거의 존재**를 요구한다. 값이 있을 때만 비교하는 방식은, 아무 식별자도 없는 증거를 dispatch 레코드의 자기 선언만으로 승인해 버린다 — 이 검사가 대체하려던 바로 그 자기 선언이다.
|
||||
|
||||
| 요구 증거 | 위치 | 없으면 |
|
||||
|---|---|---|
|
||||
| `job_id`, `attempt_id`, `agent_session_uuid`, `pane_process_generation` | 타임라인 항목의 **`collector_attribution` 봉투** (또는 event/filesystem 등 **비-herdr** 소스의 payload) | `hold` |
|
||||
| 리포트 매니페스트의 `attempt_id` | filesystem 소스 payload의 `report_manifest` | `hold` |
|
||||
|
||||
**네이티브 경계 유지**: 식별자는 **네이티브 Herdr payload 안에서 읽지 않는다.** Phase 0이 네이티브 페이로드에 MAM 필드 주입을 금지하므로, 거기 나타난 식별자는 조작이거나 계약 위반이며 어느 쪽이든 증거가 아니다. 수집기의 연관 정보는 payload **옆**의 봉투에 둔다.
|
||||
|
||||
## 5. 판정 순서 (fail-closed)
|
||||
|
||||
1. 전송 이상 → `hold_reconnect` (증거 자체가 무효)
|
||||
2. 다른 세대/다른 pane 신호 → `reject_stale`
|
||||
3. `blocked` → `hold_blocked` (사람 대기 중 완료 금지)
|
||||
4. 4중 귀속 + 실행 앵커 미충족 → `hold`
|
||||
5. 비-quiescent 또는 도구 실행 중 → `hold`
|
||||
6. 리포트 미확정(매니페스트/해시) → `hold`
|
||||
7. 전부 통과 → `candidate_approved`
|
||||
|
||||
## 6. 시나리오 계약
|
||||
|
||||
Phase 0의 7종 + Phase 2a의 5종 = **12종**이 `expected_policy.decision`으로 판정을 고정한다. 신규 5종도 Phase 0의 `validate_watcher_scenario`를 통과해야 하므로 증거 스키마가 계속 강제된다.
|
||||
|
||||
## 7. Phase 2b 인계
|
||||
|
||||
1. `attempt_id` 등 귀속 필드를 레코드로 승격할지 결정 (현재는 shadow 저장소).
|
||||
2. `policy.decide`의 `candidate_approved`를 `engine.transition("completed", expected_attempt=...)`에 연결. 엔진과 CAS 표는 이미 준비되어 있다.
|
||||
3. Pending Notification 지연 알림·크래시 복구 (`state-transitions.md` §5).
|
||||
4. `preexec_fn`: Phase 2a는 데몬을 스폰하지 않아 미노출. 2b가 스폰을 도입하면 스레드 안전한 방식으로 교체해야 한다.
|
||||
5. **`started`의 세션 확인 (O-A)**: `_decide`의 `claim`/`started`는 아직 "작업 할당 세션 일치"를 검사하지 않는다(`pick_pending`은 세션 필터를 하지만 `publish_event`의 `started`는 누구나 보낼 수 있다). 2b에서 `expected_session`을 추가할 것.
|
||||
6. **`stop_session.sh`의 무조건 `cancelled` (O-B)**: 이미 `completed`인 job에 대해 이제 거부된다(레거시는 덮어썼다). 표상 의도된 동작이며, 운영자가 확정 상태를 바꾸려면 `registry.py status --set`(운영자 권한)을 쓴다. 릴리스 노트 기록 대상.
|
||||
@@ -0,0 +1,137 @@
|
||||
# 📑 Issue #5 Phase 2b — 자동완료 운영 노트 (Flag-Gated / Opt-In)
|
||||
|
||||
- **문서 버전**: Rev.1
|
||||
- **상태**: 구현 완료, **기본 비활성(`MAM_AUTO_COMPLETE=0`)**
|
||||
|
||||
---
|
||||
|
||||
## 1. 한 문장 요약
|
||||
|
||||
**끝났다는 사실을 아는 쪽(에이전트)이 확정을 선언하고, 읽는 쪽은 그 선언을 믿지 않고 해시를 다시 계산한다.**
|
||||
|
||||
워처가 스스로 해시를 계산해 매니페스트를 만들면 그것은 "워처가 안정된 파일을 봤다"는 자기 증명일 뿐이다. 이 이슈의 앞선 라운드에서 리뷰어가 기각한 증거 형태이며, 같은 실수를 반복하지 않기 위한 설계 결정이다.
|
||||
|
||||
## 2. 그래서 미지원 에이전트는 자동으로 안전하다
|
||||
|
||||
확정을 선언하지 않은 에이전트는 매니페스트를 남기지 않는다. 매니페스트가 없으면 정책이 이미 `hold`한다(Phase 2a에서 구현·테스트 완료). **"미지원 경로 100% 보존"은 새 분기가 아니라 기존 동작의 귀결이다.**
|
||||
|
||||
## 2-1. ⚠️ 아직 배선되지 않았다 (정직한 현황)
|
||||
|
||||
**`autocomplete.evaluate`와 `notifier.recover`를 호출하는 프로덕션 경로는 아직 없다.** `daemon.py`/`job_subscriber.py`/`cli.py`/`service.py` 어디도 이들을 임포트하지 않으며, `register`에 `auto_complete` opt-in 옵션도 없다. 즉 **현재 라이브 셸 루프에서 `MAM_AUTO_COMPLETE=1`은 무동작(no-op)이다.**
|
||||
|
||||
Phase 2b가 제공하는 것은 **검증된 라이브러리와 게이트**이며, 이를 감시 루프에 연결하는 배선은 Phase 3의 몫이다. 초기 문서가 "기동 시 재발행이 구독자에서 수행된다"고 적었던 것은 사실이 아니었으며 여기서 정정한다(교차 리뷰 `3fa76e08` O-1, `a0de0e5e` 잔여 1·2).
|
||||
|
||||
배선 시 필요한 것:
|
||||
1. 감시 루프에서 `evaluate(...)` 호출 — **`report_dir`는 필수 인자다.**
|
||||
2. 커밋 후 `notifier.flush(...)` 호출 — `evaluate`는 intent를 남길 뿐 발행하지 않는다.
|
||||
3. 기동 시 `notifier.recover(...)` 호출.
|
||||
4. `register`에 잡별 opt-in 경로 추가.
|
||||
|
||||
## 3. 3중 게이트 (전부 참일 때만 커밋)
|
||||
|
||||
| 게이트 | 기본값 | 근거 |
|
||||
|---|---|---|
|
||||
| `MAM_AUTO_COMPLETE=1` | **`0` (OFF)** | 전역 킬 스위치 |
|
||||
| `MAM_ENGINE_CAS=1` | `1` | `0`이면 자동완료도 함께 중단된다. 추론 완료는 CAS 경계 위에 서 있으므로, 경계가 롤백된 상태의 커밋은 그 경계를 우회하는 것이다 |
|
||||
| 백엔드 허용목록 | `claude,grok,codex` (+ `-code`/`-build`/`-cli` 정식 키) | 정지 상태(`idle`/`done`) **관측 증거가 있는 백엔드만**. `agy`는 `working`만 관측, `hermes`·`opencode`는 관측 0건. **정확 일치**로 비교한다 — 접두 일치라면 미래의 `claude-experimental`이 수집된 적 없는 증거를 물려받는다 |
|
||||
| 잡별 opt-in | 레코드 `auto_complete: true` | 잡 단위 동의 |
|
||||
|
||||
`MAM_AUTO_COMPLETE_BACKENDS`로 목록을 **좁힐 수만** 있다. 관측된 적 없는 이름은 무시된다 — 넓히려면 설정이 아니라 증거가 필요하다.
|
||||
|
||||
## 4. 확정 계약 (P2B-01)
|
||||
|
||||
```
|
||||
에이전트: publish_event.py --job <id> --event completed \
|
||||
--finalize-report <path> [--side-effect <kind>=<ref>] ...
|
||||
① <path>.tmp → <path> 원자적 rename (반쯤 쓰인 리포트를 최종본으로 볼 수 없게)
|
||||
② SHA-256 계산
|
||||
③ report-manifest.json 원자적 기록 (job_id, attempt_id, sha256, side_effects, declared_by)
|
||||
```
|
||||
|
||||
- **리포트를 다시 쓰면 선언은 즉시 무효**가 된다. 해시를 다시 계산해 대조하기 때문이다. 새 본문에는 새 확정이 필요하다.
|
||||
- **선언한 부수효과는 전부 증거가 있어야 한다.** 일부만 있는 것은 없는 것으로 취급한다 — 작업이 끝났다고 알 수 없기 때문이다.
|
||||
- 확정은 발행 **앞에** 일어난다. 리포트를 아직 쓰는 중인 `completed` 이벤트는 계약이 금지하는 부분 리포트 채택을 부른다.
|
||||
|
||||
## 5. 지연 알림 (P2B-02)
|
||||
|
||||
```
|
||||
락 안: 상태 커밋 + pending_notification 기록 ← 하나의 원자적 쓰기
|
||||
락 밖: 발행 → 성공 시 notification_settled 전이로 정리
|
||||
기동 시: pending_notification이 남은 레코드를 재발행 (멱등: 구독자가 seq로 중복 제거)
|
||||
```
|
||||
|
||||
- **독립 Outbox 저장소를 만들지 않는다**(`state-transitions.md` §5 명시). 이미 쓰는 레코드에 필드 하나를 더 쓴다.
|
||||
- **발행 실패는 의도적으로 intent를 남긴다.** 보내지 않은 알림이 보낸 것처럼 보이는 편이, 재시도되는 편보다 나쁘다.
|
||||
- `notification_settled`는 **상태를 바꾸지 않는 bookkeeping 요청**이라 terminal 상태에서도 허용되며, **`pending_notification` 외의 필드는 쓸 수 없다**(경계에서 거부 — 현재 호출자가 그것만 넘긴다는 사실에 의존하지 않는다).
|
||||
- **소비자 중복 제거**: 재발행은 **새 wire seq**를 받으므로 구독자의 단조 seq 가드는 재시도를 알아보지 못한다(가드 입장에서는 새 이벤트다). 그래서 `job_subscriber`가 `data.notification_intent`의 안정된 신원으로 중복을 제거한다. 재시작 후에는 디스크 폴백이 레지스트리에서 terminal을 확인하고, 중복 terminal은 전이 경계가 멱등 ACK로 거부하므로 이중 적용이 발생하지 않는다(교차 리뷰 Round 2 R4).
|
||||
- **중복 제거 장부는 이벤트를 실제로 받아들인 뒤에만 기록한다**(Round 3 R4 후속). 순서가 뒤집혀 seq 가드보다 먼저 기록하면, 늦게 도착한 과거 패킷이 자기 intent를 "이미 처리함"으로 태워 버리고 **뒤따르는 정당한 재시도가 드롭된다** — 정작 그 알림은 한 번도 전달된 적이 없다. 그래서 신원 대조(중복이면 거부)는 seq 가드 앞에, 장부 기록은 seq 가드 **뒤**에 둔다. 거부된 재시도가 seq 바닥(`last_seq`)을 올리지 않는 것도 같은 이유다 — 전달하지 않은 이벤트는 시퀀스를 소비할 자격이 없다.
|
||||
- **안정된 신원을 만들 수 없는 intent는 거부한다.** `notification_intent`가 객체가 아니거나 필드에 해시 불가능한 값이 들어 있으면 그 알림은 재시도를 식별할 수 없다 — 방어적 파싱의 나머지와 같이 드롭한다. 특히 콜백이 예외를 던지면 **그 구독자가 지켜보는 모든 job의 네트워크 루프가 함께 죽으므로**, 잘못된 원격 페이로드가 예외로 빠져나가서는 안 된다.
|
||||
- 정리는 **실제로 보낸 intent와 일치할 때만** 이루어진다. 발행은 락 밖에서 일어나므로 그 사이에 새 intent가 정당하게 들어올 수 있고, 무조건 지우면 아무도 보내지 않은 알림이 사라진다.
|
||||
- 발행 payload에 intent의 `seq`/`event`/`timestamp`를 실어 보낸다. **전송 계층 seq는 같은 intent의 재시도를 식별하지 못하므로**, 소비자는 `notification_intent`로 중복을 제거한다. 알림은 terminal에 *관한* 것이므로, terminal이 거부하는 요청으로는 영원히 정리할 수 없다.
|
||||
|
||||
## 5-1. 필수 검증 (교차 리뷰 반영, 전부 미충족 시 `hold`)
|
||||
|
||||
| 검증 | 없으면 |
|
||||
|---|---|
|
||||
| `report_dir` 인자 | `hold` — **선택이 아니다.** 생략하면 디스크 대조가 통째로 건너뛰어져 수집기의 자기 신고가 다시 판단 주체가 된다 |
|
||||
| 기록된 dispatch | `hold` — 귀속시킬 대상이 없다 |
|
||||
| 증거의 **대상 일치** | `hold` — 시나리오의 `job_id`·attempt·session·pane이 기록된 dispatch와 일치해야 한다. 정책은 시나리오 **내부**의 dispatch_context와만 비교하므로 구조상 항상 자기 일관적이다 |
|
||||
| 매니페스트 재계산 대조 | `hold` |
|
||||
| Reviewer 역할의 판정 토큰 | `hold` — Phase 0 안전 계약. 판정을 아직 쓰지 않은 리뷰어는 검토를 끝내지 않은 것이다 |
|
||||
|
||||
## 6. 무판정 fail-closed
|
||||
|
||||
매니페스트를 읽지 못함, 정책이 예외를 던짐, 알 수 없는 결정 — 전부 `hold`다. **"판정하지 못함"은 결코 "완료"가 아니다.** 늦게 도착한 명시 terminal이 있으면 전이 경계가 추론 완료를 거부하며, 그것은 경계가 제 일을 한 것이지 게이트의 오류가 아니다.
|
||||
|
||||
## 7. 감사 표식
|
||||
|
||||
자동완료로 들어간 terminal에는 `completed_by: "auto_complete"`와 판정 사유가 남는다. 구독자와 래퍼는 자동/명시 완료를 구별하지 못하므로(`job_subscriber._check_disk_fallback`은 레지스트리 status만 본다), **사후에 구별할 수 있는 유일한 수단**이다.
|
||||
|
||||
## 8. 롤백
|
||||
|
||||
1. `MAM_AUTO_COMPLETE=0` — 신규 자동완료만 중단. **이미 확정된 terminal 레코드는 보존**한다.
|
||||
2. `MAM_ENGINE_CAS=0` — Phase 2a 경로까지 되돌린다(자동완료는 그 위에 있으므로 함께 무력화).
|
||||
3. `.mam/shadow/`는 레지스트리와 분리되어 있어 삭제해도 job 상태에 영향이 없다.
|
||||
|
||||
## 9. 계획 대비 이탈 (레코드 스키마) — **§8-1에서 부분 철회됨**
|
||||
|
||||
> ⚠️ 아래 판단은 Round 2 교차 리뷰(`a45e5b7f` R2)로 **부분적으로 반증됐다.** 증거 계층의 대조만으로는 **커밋과의 원자성**을 얻을 수 없어, `dispatch_token`을 레코드에 추가했다(§8-1). 아래 서술은 "왜 `attempt_id`를 `expected_attempt`로 쓰지 않는가"에 한해 여전히 유효하다.
|
||||
|
||||
계획서 §D2는 `attempt_id`를 job 레코드의 1급 필드로 승격할 것을 권고하고, Phase 1이 동결한 "레코드 필드 불변" 계약의 변경이므로 **리뷰어 승인을 선행 조건**으로 걸었다.
|
||||
|
||||
구현 중 실측한 결과 **그 변경이 필요하지 않았다**:
|
||||
- 엔진의 `expected_attempt`는 레코드의 `attempt_id`(없음)를 `iteration`(루프 회차)으로 대체 비교한다 — 두 개념이 달라 의미가 성립하지 않는다.
|
||||
- 반면 **시도 식별은 이미 증거 계층에서 검증된다**: `policy`가 관측 식별자와 shadow 저장소의 dispatch 레코드를 대조하며, 시도가 어긋나면 `hold`한다(실측 확인).
|
||||
|
||||
따라서 자동완료는 엔진에 `expected_attempt`를 넘기지 않고 `expected_status="running"`(엔진의 고유 영역인 상태)만 전제조건으로 준다. **시도 보증은 시도 정체성이 실제로 사는 곳에 둔다.** 레코드 스키마는 바뀌지 않았고, Phase 1 계약도 유지된다.
|
||||
|
||||
`attempt_id` 승격은 열린 질문으로 남긴다. 승격하면 엔진에 2차 방어선이 생기지만 동결 계약을 바꿔야 하며, 현재는 필요하지 않다. `test_x03_the_record_carries_iteration_not_an_attempt_id`가 이 전제를 감시한다 — 레코드가 `attempt_id`를 갖게 되는 순간 실패하며, 그때가 재검토 시점이다.
|
||||
|
||||
## 8-1. 원자적 귀속 — dispatch token (교차 리뷰 Round 2 R2)
|
||||
|
||||
**문제**: 귀속을 커밋 **전에** 확인하고 커밋에는 `expected_status="running"`만 걸면, 그 사이에 job이 `completed → redispatch → claim`으로 새 시도를 시작할 수 있고 **옛 증거가 새 시도를 완료시킨다.** 새 시도도 `running`이므로 상태 검사만으로는 구별되지 않는다. 실측으로 재현됐다(레지스트리가 이미 iteration 2인 상태에서 커밋됨).
|
||||
|
||||
**해결**: 시도 정체성을 **레코드 안에** 둔다. 전제조건이 커밋과 원자적이려면 트랜잭션이 **자기 락 안에서 읽을 수 있어야** 하기 때문이다.
|
||||
|
||||
| 항목 | 값 |
|
||||
|---|---|
|
||||
| 필드 | `dispatch_token` (불투명 문자열) |
|
||||
| 발급 시점 | 시도가 시작되는 요청 — `claim`, `redispatch` |
|
||||
| 사용 | 게이트가 판단에 사용한 토큰을 `expected_dispatch_token`으로 넘기고, 엔진이 **락 안에서** 대조 |
|
||||
| 증거 결속 | shadow dispatch 레코드도 같은 토큰을 보관 → 증거가 어느 시도의 것인지 증명된다 |
|
||||
|
||||
**왜 `iteration`이 아니라 토큰인가**: `iteration`은 이미 **루프 회차**를 뜻한다. dispatch 시도 번호를 그것과 비교하면 값이 우연히 같을 때는 공허하고, 갈라지면 **정상 완료를 거부**한다(3회차 loop job에서 실측). 불투명 토큰은 경쟁하는 의미가 없다.
|
||||
|
||||
**이전 판단의 정정**: 아래 §9는 "시도 보증이 증거 계층에 있으므로 레코드 스키마 변경이 불필요하다"고 적었다. 증거와 dispatch를 **대조**하는 데는 맞지만, **커밋과의 원자성**에는 틀렸다. 원자성은 트랜잭션이 읽을 수 있는 곳에 값이 있어야만 얻어지며, 그것이 레코드다. `dispatch_token`은 추가 필드이므로 `SCHEMA_VERSION`은 1을 유지한다.
|
||||
|
||||
## 9-1. 구현 중 정정 1건 — `finalize`의 패키지 위치
|
||||
|
||||
처음에는 `finalize.py`를 `lib_py/loop/`에 두었는데, 그러면 `publish_event`(=`lib_py.delegation`)가 **형제 패키지를 임포트**하게 되어 freeze 하에서 트리가 섞인다. Phase 1이 명문화하고 Phase 2a에서 엔진 배치를 정정하게 만들었던 바로 그 규칙이며, 기존 가드 테스트(`test_delegation_package_imports_no_sibling_lib_py_modules`)가 전체 스위트에서 이를 검출했다.
|
||||
|
||||
**매니페스트는 job의 리포트 디렉터리에 쓰이고 에이전트가 호출하는 CLI는 delegation 진입점이므로, `finalize`는 `lib_py/delegation/`에 속한다.** `lib_py.loop`가 `lib_py.delegation`을 임포트하는 방향은 허용된다. 같은 규칙에 두 번 걸린 셈이며, 가드가 두 번 다 잡았다.
|
||||
|
||||
## 10. Phase 3 인계
|
||||
|
||||
1. `preexec_fn`: 2b도 데몬을 스폰하지 않아 미노출 유지. 알림 flush는 기존 구독자 프로세스 안에서 수행한다.
|
||||
2. 부수효과의 **원격** 확인(실제 push 도달 여부 등)은 비범위 — 증거의 존재·대응까지만 검증한다.
|
||||
3. `agy`/`hermes`/`opencode` 자동완료는 정지 상태 실측 증거 확보 후 별도 판단.
|
||||
@@ -0,0 +1,106 @@
|
||||
# 📑 Issue #5 Phase 3 — `run_loop.sh` Thin Wrapper 전환 및 `lib_py.loop` 승격 명세
|
||||
|
||||
- **문서 버전**: Rev.1
|
||||
- **상태**: Phase 3 설계 및 이식 착수 기준선 (Implementation & Regression Specification)
|
||||
- **대상 파일**:
|
||||
- `lib_py/loop/runner.py`, `lib_py/loop/__main__.py`, `lib_py/loop/parser.py`
|
||||
- `.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh`
|
||||
- `tests/test_issue5_phase3_loop.py`
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요 및 한 문장 요약
|
||||
|
||||
**936줄의 복잡한 Bash 오케스트레이션(`run_loop.sh`)을 단일 제어 구조의 Python 모듈(`lib_py.loop`)로 승격하고, `run_loop.sh`는 CLI 계약과 B-13 Freeze를 보존하는 얇은 래퍼(Thin Wrapper)로 전환한다.**
|
||||
|
||||
Phase 2(CAS 전이 엔진, 이중 감지 자동완료 게이트)에서 구축된 상태 및 판정 기반을 실제 루프 제어 흐름에 유기적으로 결합하며, 기존 732개 테스트의 0-Fail 통과와 셸 레거시 롤백(`MAM_LOOP_PY=0`)을 보장합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 모듈 아키텍처 (`lib_py/loop/`)
|
||||
|
||||
Phase 1/2a의 형제 패키지 격리 원칙(D1(b), G9: `lib_py.loop`는 `lib_py.delegation` 외의 다른 형제 패키지를 임포트하지 않음)을 엄격히 준수합니다.
|
||||
|
||||
```
|
||||
lib_py/loop/
|
||||
├── __init__.py # 패키지 선언 및 export
|
||||
├── __main__.py # python3 -m lib_py.loop 진입점
|
||||
├── parser.py # 코드 펜스 제외 Strict 판정/태그 파서
|
||||
├── runner.py # 오케스트레이터 본체 (LoopRunner 클래스)
|
||||
├── checkpoint.py # 체크포인트 기록 및 복구 (.mam/loop-checkpoint.json)
|
||||
├── attribution.py # [Phase 2a] 관측 증거 수집
|
||||
├── watcher.py # [Phase 2a] herdr agent wait 래퍼
|
||||
├── policy.py # [Phase 2a] 상태 결정 정책
|
||||
├── shadow.py # [Phase 2a] Would-Complete shadow 실행기
|
||||
├── store.py # [Phase 2a] 증거 저장소
|
||||
└── autocomplete.py # [Phase 2b] 자동완료 평가기
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 핵심 설계 요소
|
||||
|
||||
### 3-1. 엄격한 판정 파서 (Strict Parser, `parser.py`)
|
||||
- **코드 펜스(` ``` `) 무시**:
|
||||
- 기존 셸 정규식은 리포트 본문 내 코드 블록(예: 마크다운 예시 등)에 적힌 `[VERDICT: PASS]`도 파일 끝에 위치하면 유효한 판정으로 오인하는 결함(`baseline.md` Gap)이 존재했습니다.
|
||||
- Python 파서는 텍스트를 파싱할 때 마크다운 코드 펜스(` ``` ` 또는 `~~~`) 내부 라인을 제거한 뒤, **가장 마지막 비공백 라인(last non-blank line)**만을 검사합니다.
|
||||
- **Fail-Closed 불변식**:
|
||||
- 판정 라인이 없거나, `[VERDICT: NOT PASS]`와 `[VERDICT: PASS]`가 모호하게 충돌하거나, 알 수 없는 태그인 경우 예외 없이 `NOT PASS`로 처리합니다.
|
||||
- **태그 추출**:
|
||||
- `[ESCALATE: PLANNER]`: 코드 펜스 외부 어디서든 단독 행으로 존재하면 감지
|
||||
- `[REBUT: <reviewer>]`: Creator 반론 대상 리뷰어 세션명 추출 (중복 제거 및 실제 거부한 리뷰어로 필터링)
|
||||
- `[ADJUDICATION: SUSTAINED|OVERRULED]`: 재심 판정 추출 (fail-closed: 명시적 SUSTAINED 외에는 모두 OVERRULED)
|
||||
- `[ARBITRATION: CREATOR|REVIEWER]`: Planner 중재 판정 추출
|
||||
|
||||
### 3-2. `run_loop.sh` Thin Wrapper 및 하위 호환성
|
||||
- **하위 호환 롤백 플래그**:
|
||||
- `MAM_LOOP_PY=1` (기본값): Python `lib_py.loop` 실행
|
||||
- `MAM_LOOP_PY=0`: 기존 인라인 셸 루프 실행 (안전 롤백 경로)
|
||||
- **B-13 Freeze 및 인자 보존**:
|
||||
- Phase 4에서 Freeze가 제거되기 전까지, `run_loop.sh`는 기존처럼 기동 시 `/tmp/mam-loop-freeze.XXXXXX`로 트리를 복제하여 실행 격리를 유지합니다.
|
||||
- `MAM_REAL_ROOT`, `WORKSPACE_ROOT`, `MAM_ENV_FILE` 환경변수 export를 그대로 유지하여 기존 테스트의 문자열 검증을 만족합니다.
|
||||
- **기존 테스트 텍스트 앵커 보존**:
|
||||
- `test_o1_rebuttal.py` (`has_tag_line() {`, `extract_tag_values() {`, `find_report() {`)
|
||||
- `test_b7_diff_untracked.py` (`CHANGES_DIFF=$(mam_collect_changes_diff ...`)
|
||||
- `test_deploy_freshness.py` (`export MAM_ENV_FILE=`)
|
||||
- `test_o3_scoped_guard.py` (`mam_release_loop_lock`)
|
||||
- 위 앵커들은 `run_loop.sh` 내에 셸 헬퍼 또는 주석/shim 블록으로 보존되어 기존 정적 검사 테스트들을 100% 통과시킵니다.
|
||||
|
||||
### 3-3. 체크포인트 및 정지 프로토콜 (`checkpoint.py`)
|
||||
- **원자적 체크포인트 (`.mam/loop-checkpoint.json`)**:
|
||||
- 루프 회차(`iteration`), 활성 Job ID 목록(`created_jobs`), 현재 계획(`current_plan`), 리뷰 상태(`review_status`), 기준 커밋(`base_commit`), 타임스탬프 기록.
|
||||
- 임시 파일 쓰기 후 원자적 rename(`replace`)으로 커밋.
|
||||
- **우아한 정지 (Quiesce on Signal)**:
|
||||
- SIGINT/SIGTERM 수신 시 즉시 강제 종료하지 않고, 현재 진행 중인 서브프로세스를 정리하고 체크포인트를 기록한 뒤 가드를 해제하고 안전하게 종료(exit 130/143)합니다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 단계별 세부 구현 및 검증 게이트 (P3-01 ~ P3-03)
|
||||
|
||||
### 📌 P3-01: `lib_py/loop/parser.py` 및 오케스트레이션 로직 이식
|
||||
1. `parser.py` 구현: 코드 펜스 분리 및 엄격한 판정/태그 추출 로직 작성.
|
||||
2. `runner.py` 구현:
|
||||
- CLI 인자 파싱 및 검증 (`--creator`, `--task`, `--plan`, `--plan-talk`, `--reviewer`, `--all-reviewer`, `--max-loop`, `--max-rebut`, `--cleanup`, `--verbose`)
|
||||
- 세션 조회 및 상태 검증 (Planner, Target Agent, Reviewers)
|
||||
- Phase 1 플래닝 및 토론 (Plan ➔ Critique ➔ Refine)
|
||||
- Phase 2 구현 디스패치 (Worker)
|
||||
- Phase 3 리뷰 루프 (병렬 디스패치, Strict 판정 수렴, Escalation 분기, Rebuttal/Adjudication/Arbitration)
|
||||
- Phase 4 산출물 승격 (Plan 및 Report 원자적 복사/이동) 및 임시 디렉터리 정리
|
||||
3. `tests/test_issue5_phase3_loop.py` 작성:
|
||||
- 코드 펜스 내부 허위 판정 무시 검증 (Strict Parser)
|
||||
- Rebuttal 예산(iteration budget vs run-wide budget) 및 중재 흐름 검증
|
||||
- 만장일치 PASS 수렴 검증
|
||||
|
||||
### 📌 P3-02: `run_loop.sh` Thin Wrapper 전환 및 런타임 호환성
|
||||
1. `run_loop.sh`에 `MAM_LOOP_PY` 분기 도입:
|
||||
- `MAM_LOOP_PY=1`일 때 Python `lib_py.loop`로 위임
|
||||
- 기존 CLI 파싱, `--help` exit 1 유지, B-13 freeze 격리 유지
|
||||
- 기존 테스트 텍스트 앵커 보존
|
||||
2. CWD, 공백 경로, 시그널 트랩, 인터프리터 앵커링 검증:
|
||||
- Freeze 사본 내에서 실행 시 `_delegate_py_bin`이 `WORKSPACE_ROOT` 기준 인터프리터를 정확히 해석하는지 검증 (O-4 인계 해결)
|
||||
|
||||
### 📌 P3-03: 전면 회귀 검증 및 체크포인트 핸드오프
|
||||
1. 전체 732개 테스트 회귀 검증:
|
||||
- `test_loop_cli.py`, `test_o1_rebuttal.py`, `test_tier4_e2e.py` (Scenario 5 E2E 루프 포함) 전체 0-Fail 통과
|
||||
2. 체크포인트 기록 및 시그널 인터럽트 시뮬레이션 검증.
|
||||
3. 롤백 드릴: `MAM_LOOP_PY=0`으로 기존 셸 루프 전환 시 정상 작동 확인.
|
||||
@@ -0,0 +1,55 @@
|
||||
# 📑 Issue #5 Phase 4 — 불변 런타임 검증 및 Freeze 제거 명세
|
||||
|
||||
- **문서 버전**: Rev.1
|
||||
- **상태**: Phase 4 설계 및 실행 기준선 (Immutable Runtime & Freeze Removal Specification)
|
||||
- **대상 파일**:
|
||||
- `lib_py/loop/runner.py`, `lib_py/loop/eager.py`
|
||||
- `.agents/skills/multi-agent-mux-loop/scripts/run_loop.sh`
|
||||
- `docs/contracts/issue-5/task.md`
|
||||
- `tests/test_issue5_phase4_runtime.py`
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요 및 한 문장 요약
|
||||
|
||||
**Python 상주 프로세스의 메모리 불변성(Eager Import)을 통해 작업 중 코드 수정 크래시(B-13)를 원천 차단하고, 임시 비계였던 `/tmp/mam-loop-freeze.XXXXXX` 스냅샷 복사 우회책을 안전하게 제거한다.**
|
||||
|
||||
Phase 3에서 `run_loop.sh`가 Python `lib_py.loop`로 위임됨에 따라, Bash가 실행 중 스크립트를 디스크 바이트 오프셋 단위로 재독해하면서 발생하던 크래시(B-13: `unexpected EOF` / 구문 파괴)의 원인이 근본적으로 해소되었습니다. Phase 4에서는 이를 엄밀히 검증하고 불필요한 I/O 복사 오버헤드를 철거합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 핵심 설계 요소
|
||||
|
||||
### 2-1. Eager Import 상주 정책 (`eager.py` & `runner.py`)
|
||||
- **지연 로딩(Lazy Import) 금지**:
|
||||
- 루프 실행 도중 디스크 상의 `.py` 파일이 편집되더라도 런타임에 영향을 주지 않도록, 프로세스 기동 시점에 모든 의존 모듈(`delegation`, `loop`, `paho.mqtt` 등)을 메모리에 상주시킵니다.
|
||||
- `eager_load()` 함수를 기동 최우선 단계에서 호출하여 `sys.modules`에 모든 바이트코드와 AST를 고정합니다.
|
||||
- **자식 프로세스 템플릿 격리**:
|
||||
- 서브프로세스로 실행되는 도구 호출(`delegate_job`) 시 `PYTHONDONTWRITEBYTECODE=1`을 유지하여 디스크에 `.pyc` 파편이 남거나 작업 트리가 오염되는 것을 방지합니다.
|
||||
|
||||
### 2-2. B-13 Freeze 스냅샷 제거 및 경량화 (`run_loop.sh`)
|
||||
- **기본 동작 변경**:
|
||||
- Python 루프 모드(`MAM_LOOP_PY=1`, 기본값)에서는 파일 복사 스냅샷(`/tmp/mam-loop-freeze.XXXXXX`)을 생성하지 않고 직접 실행합니다.
|
||||
- 디스크 I/O 절감: 턴당 수백 개 파일 복사 오버헤드(수십~수백 ms) 완전 제거.
|
||||
- **안전 롤백 경로 보존**:
|
||||
- 환경변수 `MAM_LOOP_FREEZE=1` 또는 레거시 셸 루프 모드(`MAM_LOOP_PY=0`)가 명시된 경우에만 레거시 Freeze 스냅샷 로직이 활성화됩니다.
|
||||
- 기존 테스트의 락/가드 해제 계약(`_mam_release_guard`)은 외부 주입 스냅샷 디렉터리가 있을 경우 그대로 안전하게 정리합니다.
|
||||
|
||||
### 2-3. 라이브 헬퍼 버전 호환성 게이트 (`MAM_VERSION`)
|
||||
- `lib.sh`의 `MAM_VERSION`("4.3.0")과 프로세스 런타임의 프로토콜 버전을 동기화하고, 불일치 발생 시 안전하게 fail-closed 거부합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 검증 게이트 (P4-01 ~ P4-03)
|
||||
|
||||
### 📌 P4-01: Eager Import 상주 및 불변성 검증
|
||||
- 기동 시 전체 모듈이 메모리에 로드되어 런타임 중 추가 임포트 I/O가 발생하지 않음을 검증.
|
||||
- `tests/test_issue5_phase4_runtime.py`에 모듈 목록 및 바이트코드 상주 테스트 추가.
|
||||
|
||||
### 📌 P4-02: 작업 중 코드 수정(B-13) 내구성 스트레스 테스트
|
||||
- 루프가 실행되는 도중 디스크 상의 소스 코드가 수정되거나 임의의 내용으로 치환되더라도, 이미 상주한 파이썬 프로세스가 에러 없이 끝까지 작업을 완결함을 시뮬레이션 검증.
|
||||
|
||||
### 📌 P4-03: Freeze 우회책 제거, 전면 회귀 및 운영 문서 동기화
|
||||
- `run_loop.sh`의 기본 Freeze 복사 분기를 비활성화.
|
||||
- 전체 회귀 테스트(744개+) 0-Fail 확인.
|
||||
- `task.md` 및 마이그레이션 운영 문서 갱신 완료.
|
||||
@@ -0,0 +1,144 @@
|
||||
# 📑 Target State Transitions and CAS Specification
|
||||
|
||||
- **Document Version**: Rev.6 (dispatch token 기반 원자적 시도 귀속 추가)
|
||||
- **Issue**: [Gitea Issue #5](https://git.godopu.com/tmpl/multi-agent-mux/issues/5)
|
||||
- **Scope**: Target Compare-And-Swap (CAS) State Machine & Attribution Contract
|
||||
|
||||
---
|
||||
|
||||
## 1. 이벤트(Event)와 상태(Status)의 개념적 분리
|
||||
|
||||
기존 시스템의 혼선을 방지하기 위해 용어를 명확하게 분리하여 정의합니다:
|
||||
- **이벤트 (Event)**: 에이전트, 워처(Watcher), 또는 외부 운영자가 특정 시점에 메시징 백플레인(MQTT)이나 로컬 채널로 전달하는 **사건(Fact)**입니다 (`started`, `progress`, `permission_required`, `completed`, `error`, `cancelled`).
|
||||
- **상태 (Status)**: 단일 상태 제어 엔진(Engine)이 원자적 CAS 트랜잭션을 통해 공통 레지스트리에 영속적으로 기록하는 **현재 작업의 공식 생애주기 단계**입니다 (`pending`, `running`, `completed`, `error`, `cancelled`).
|
||||
|
||||
> 📌 **핵심 원칙**: 이벤트는 상태를 변경해 달라는 **전이 요청(Transition Request)**일 뿐이며, 엔진의 유효성 검증과 CAS 게이트를 통과하기 전까지는 레지스트리 상태가 변경되지 않습니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 4중 실행 귀속(Execution Attribution) 보장 계약
|
||||
|
||||
단순히 에이전트 터미널이 유휴(`idle`) 상태라고 해서 작업이 성공적으로 종료되었다고 추론(Inferred Completion)해서는 안 됩니다. 이전 작업의 잔여 유휴 상태, 세션 재사용, 또는 프로세스 재시작으로 인한 오탐을 원천 방지하기 위해 다음 **4중 식별자 튜플**을 필수로 검증합니다:
|
||||
|
||||
$$\text{Attribution Tuple} = (\text{job\_id}, \text{attempt\_id}, \text{agent\_session\_uuid}, \text{pane\_process\_generation})$$
|
||||
|
||||
1. **`job_id`**: 등록된 고유 작업 식별자 (32-bit hex 또는 UUID).
|
||||
2. **`attempt_id`**: 단일 Job 내에서 재시도 시 단조 증가하는 시도 번호.
|
||||
3. **`agent_session_uuid`**: 에이전트 프로세스 기동 시 할당된 영속 고유 식별자.
|
||||
4. **`pane_process_generation`**: TMUX/Herdr 창 및 기저 셸 프로세스의 시작 시각(`lstart`) 및 PID 조합.
|
||||
|
||||
### 귀속 및 실행 앵커 불변식:
|
||||
- 동일 에이전트 세션에는 동시에 단 하나의 활성 작업(`job_id`)만 배정됩니다.
|
||||
- 디스패치 확인(`dispatch_ack`) 이후, 해당 `attempt_id`에 속하는 실제 터미널 출력 및 활동(Execution Anchor)이 관측된 경우에만 완료 후보로 인정합니다.
|
||||
- 네이티브 API가 제공하지 못하는 식별자는 `null + reason`으로 표기하며, 가짜 값을 임의로 주입하지 않습니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 목표 CAS 상태 전이표 (Target State Machine)
|
||||
|
||||
모든 상태 변경은 Registry Lock 보호 하에서 기대 상태(`expected_state`), 시도 세대(`attempt_id`), 버전 번호(`version`)를 원자적으로 비교한 후 갱신(CAS)합니다.
|
||||
|
||||
| 현재 상태 (`Current`) | 요청 신호 (`Request`) | 전이 조건 및 검증 규칙 | 결과 상태 (`Next`) | 비고 및 갈등 처리 |
|
||||
|---|---|---|---|---|
|
||||
| `pending` | `started` / `claim` | 작업 할당 세션 및 `attempt_id` 일치 확인 | `running` | `started`는 상태 enum이 아니며 `running`으로 전이 |
|
||||
| `running` | `progress` | 비-Terminal 유지. 최근 활동 시각 및 세부 정보 갱신 | `running` | 상태 변경 없이 메타데이터만 갱신 |
|
||||
| `running` | `permission_required` | 비-Terminal 유지. `blocked_reason` 기록 | `running` | 권한 대기 중에는 자동완료 전면 금지 |
|
||||
| `running` | `explicit terminal` | 유효한 작업자 세션의 서명된 `completed`/`error`/`cancelled` 수신 | 요청된 Terminal | 정상 종료 커밋 |
|
||||
| `running` | `inferred completed` | 4중 귀속 일치 + 실행 앵커 확인 + 확정 해시/매니페스트 존재 + Fresh `idle/done` + 블로커 없음 + 부수효과 검증 | `completed` | Fail-Closed AND 게이트 통과 시에만 CAS 커밋 |
|
||||
| `terminal` | 동일 논리적 완료 재수신 | 기 커밋된 Terminal 상태와 동일한 요청 | `terminal` (No-op) | 멱등(Idempotent) ACK 반환, 추가 디스패치 금지 |
|
||||
| `terminal` | 상충 신호 (`progress`/다른 Terminal) | 이미 Terminal 상태에 도달한 후 늦게 도착한 신호 | 기 커밋 상태 유지 | 상태 역행 및 덮어쓰기 거부, Conflict Audit Log 기록 |
|
||||
| 임의 상태 | Stale Attempt / Generation | 과거 시도 세대 또는 프로세스 세대 불일치 신호 | 전이 거부 | Stale Attempt 경고 기록 및 패킷 폐기 |
|
||||
| `running` | `deadline_timeout` | 작업 타임아웃 경과 및 실행 중단 확인 | `error` | 타임아웃 원인 기록 |
|
||||
| `terminal` | `redispatch` (**운영자 권한**) | 반복 위임(`--type loop\|discuss`)의 다음 회차 전달. `iteration`이 기존 값보다 **엄격히 증가**해야 함 | `pending` | 교차 리뷰 `68abd0ff` 보완. 재생(replay)은 iteration 미증가로 거부됨 |
|
||||
| `terminal` | `finalize` (**운영자 권한**) | 반복 예산 소진 시 워크플로 확정 | `error` | 교차 리뷰 `68abd0ff` 보완 |
|
||||
|
||||
> 🔑 **권한 축(Authority)**: `redispatch`·`finalize`는 `source ∈ {registry_cli, operator}`에서만 허용된다. 에이전트(`publish_event`)는 **자신의 terminal을 되돌릴 수 없다** — 이것이 CAS 표의 존재 이유이며, 위 두 행은 그 원칙의 예외가 아니라 래퍼가 수행하는 별개 전이를 이름 붙인 것이다.
|
||||
>
|
||||
> 🔑 **시도 정체성은 레코드에 있다**: `dispatch_token`은 시도를 시작하는 요청(`claim`/`redispatch`)에서 새로 발급되며, `expected_dispatch_token` 전제조건으로 **락 안에서** 대조된다. 커밋 전에만 확인하는 귀속 검사는 그 사이의 재디스패치를 막지 못한다 — 전제조건이 커밋과 원자적이려면 트랜잭션이 자기 락 안에서 읽을 수 있어야 한다. `iteration`(루프 회차)과는 다른 개념이므로 별도 필드다. — 교차 리뷰 `a45e5b7f` R2
|
||||
>
|
||||
> 🔑 **멱등 ACK는 거부가 아니다**: `terminal` 상태에서 같은 terminal을 재수신하면 표는 이를 **No-op ACK**로 정의한다. 호출자(운영자 CLI)는 이를 성공(rc 0)으로 취급해야 한다. 실제 거부(상충 신호·권한 부족)만 non-zero다. — 교차 리뷰 `cb792e91` F-C
|
||||
>
|
||||
> 🔑 **status는 요청으로만 설정된다**: 임의 필드로 실린 `status`는 경계 내부에서 거부된다(`progress`가 terminal을 커밋하는 우회 차단). 알 수 없는 요청명도 거부된다. `pending`도 소유 요청(`redispatch`)을 갖는다.
|
||||
|
||||
---
|
||||
|
||||
### 3-1. 구현 매핑 (Phase 2a)
|
||||
|
||||
| 계약 | 구현 | 테스트 |
|
||||
|---|---|---|
|
||||
| §3 CAS 전이표 | `lib_py/delegation/engine.py::_decide` (순수 함수, 락 안에서 호출) | `test_engine_*`, `test_fa_*`, `test_r2_*`, `test_r3_*` |
|
||||
| 단일 전이 주체 | `engine.transition` — 유일한 상태 writer. `update_job_status`·`pick_pending`이 이를 경유 | `test_g6_every_record_writer_*` |
|
||||
| 락 내 I/O 배제 | 락 = 판정 + 레코드 1회 쓰기. 감사 mirror는 큐잉 후 락 밖 flush | `test_g5_no_blocking_io_*` |
|
||||
| §2 4중 귀속 | `lib_py/loop/attribution.py::check` + `policy.collect_observed_identity` (관측 식별자와의 대조) | `test_attribution_*`, `test_r1_*` |
|
||||
| 롤백 | `MAM_ENGINE_CAS=0` → 보존된 `_*_legacy` 본체 | 전 특성화 테스트 parametrize |
|
||||
|
||||
> 📌 **Phase 2a는 추론 완료를 커밋하지 않는다.** `inferred completed` 행은 `lib_py/loop/policy.py`가 `candidate_approved`로 **판정만** 하고, shadow 러너가 이를 로그로 남긴다. 실제 CAS 커밋 연동은 Phase 2b다.
|
||||
|
||||
---
|
||||
|
||||
## 4. 리포트 확정 단계 (Report Finalization Lifecycle)
|
||||
|
||||
자동완료 추론 시 파일 쓰기 도중 발생하는 불완전 리포트 채택을 방지하기 위해 3단계 확정 계약을 수립합니다:
|
||||
|
||||
```
|
||||
[writing] ──(쓰기 진행 중)──> [stable_candidate] ──(원자적 교체 및 매니페스트)──> [finalized]
|
||||
```
|
||||
|
||||
1. **`writing` 단계**:
|
||||
- 에이전트가 임시 파일(`report-final.md.tmp`)을 작성 중인 상태.
|
||||
- 워처는 이 상태에서 완료를 절대 인정하지 않음 (Hold).
|
||||
2. **`stable_candidate` 단계 (1차/보조 필터)**:
|
||||
- 리포트 파일의 mtime이 단조 시계(monotonic clock) 기준 **최소 15초(기본 권장값)** 동안 불변인 상태.
|
||||
- ⚠️ **불변식**: **디바운스 경과(15s)만으로는 완료 권한이 성립되지 않습니다.** 이는 일시적인 LLM 생각 중단이나 네트워크 지연일 수 있으므로 오직 '후보 필터'로만 기능합니다.
|
||||
3. **`finalized` 단계 (2차/확정 계약)**:
|
||||
- 최종 대상 경로(`report-final.md`)로의 **원자적 이름 변경(Atomic Rename)** 완료.
|
||||
- 해당 시도의 `attempt_id`와 `report SHA-256 hash`가 명시된 **완료 매니페스트(Commit Manifest)** 생성 완료.
|
||||
- 외부 부수효과(Git push, API 호출)가 수반되는 작업인 경우, 해당 부수효과의 완료 증거가 매니페스트에 첨부되어야 함.
|
||||
- 리포트가 재작성되면 이전 해시는 즉시 무효화되며 새로운 확정 절차가 필요함.
|
||||
|
||||
---
|
||||
|
||||
## 5. 지연 알림 (Pending Notification) 및 트랜잭션 복구 설계
|
||||
|
||||
상태 전이 락(Registry Lock) 하에서 외부 I/O(MQTT 브로커 전송, 네트워크 호출)를 수행하면 데드락과 심각한 지연이 발생할 수 있습니다. 이를 방지하기 위해 다음 **내구성 지연 알림(Pending Notification) 트랜잭션**을 명세합니다:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Engine
|
||||
participant Registry as Job Registry (Disk)
|
||||
participant Publisher as Async Publisher (Wire)
|
||||
participant Broker as MQTT Broker
|
||||
|
||||
Note over Engine: 1. CAS 전이 결정
|
||||
activate Engine
|
||||
Engine->>Registry: 락 획득 (fcntl.flock)
|
||||
Engine->>Registry: 원자적 상태 커밋 (status=completed, pending_notification=intent)
|
||||
Engine->>Registry: 락 즉시 해제
|
||||
deactivate Engine
|
||||
|
||||
Note over Publisher: 2. 비동기 알림 전송
|
||||
Publisher->>Broker: MQTT 이벤트 발행 (HMAC 서명 포함)
|
||||
alt 전송 성공
|
||||
Broker-->>Publisher: ACK (PUBACK)
|
||||
Publisher->>Registry: pending_notification 완료 표시
|
||||
else 전송 실패 / 크래시
|
||||
Note over Publisher: 백오프 재시도 및 기동 시 미완료 알림 복구 전송
|
||||
end
|
||||
```
|
||||
|
||||
### 트랜잭션 보장 규칙:
|
||||
1. **단일 내구성 작업 레코드**:
|
||||
- 독립된 별도의 Outbox DB를 신설하지 않고, 기존 `.mam/jobs/<id>.json` 레코드 내에 `pending_notification: { event, timestamp, seq, signature }`를 원자적으로 기록합니다.
|
||||
2. **락 해제 후 전송**:
|
||||
- 레지스트리 락을 해제한 후 비동기 워커가 MQTT 발행을 수행합니다.
|
||||
3. **크래시 복구(Crash Recovery)**:
|
||||
- 엔진 재시작 시 `status in TERMINAL`이면서 `pending_notification`이 남아있는 레코드를 스캔하여 멱등적으로 재전송합니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 권한 차단(Permission Blocked) 해제 정책
|
||||
|
||||
- 에이전트 터미널이 `blocked` (권한 승인 대기) 상태로 관측되면, **어떠한 경우에도 자동완료 전이를 시도하지 않습니다.**
|
||||
- 차단 해제 판단:
|
||||
- 묵시적 전이 금지: 단순히 다음 관측에서 `working`이 되었다고 해서 이전 차단이 정당하게 해결되었다고 단정하지 않습니다.
|
||||
- 명시적 승인 증거 또는 운영자의 승인 입력 로그가 확인되지 않는 한, 작업은 `running (blocked)` 상태를 유지하며 데드라인 도달 시 안전하게 타임아웃 처리합니다.
|
||||
@@ -0,0 +1,139 @@
|
||||
# 📑 Issue #5 Task Checklist & Verification Evidence
|
||||
|
||||
- **Document Version**: Rev.8 (Phase 3 실행 완료 — P3-01~P3-03 증거 기록)
|
||||
- **Issue**: [Gitea Issue #5](https://git.godopu.com/tmpl/multi-agent-mux/issues/5)
|
||||
- **Status**: Phase 0 Complete / **Phase 1 Complete (v4.3.0)** / **Phase 2a Complete (shadow-only)** / **Phase 2b Complete (default-off)** / **Phase 3 Complete (thin wrapper)** / Phase 4 Staged
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: 사전 계약 기준선 및 Fixture 수집 (이번 실행 범위)
|
||||
|
||||
- [x] **P0-01** 기준 commit, dirty diff, Herdr/Python 버전, pytest collection 수 기록.
|
||||
- *증거*: Commit `47640e2`, Herdr `0.8.2`, Python `3.9.6`, Pytest `469 tests collected initially` ➔ `510 tests collected with Phase 0 suite` (+41 contract & mutation tests).
|
||||
- [x] **P0-02** `docs/contracts/issue-5/baseline.md` 작성: 공개 CLI/default/invalid input/stdout-stderr/exit/interpreter/path/signal 계약과 소스·기존 테스트 매핑.
|
||||
- *증거*: `docs/contracts/issue-5/baseline.md` §2 (`--all-reviewer` 우선순위 및 L192–196 소스 매핑 반영).
|
||||
- [x] **P0-03** event schema/HMAC/seq/retain/audit/디스크 fallback 및 publisher 실패 후 로컬 commit 동작 기록.
|
||||
- *증거*: `docs/contracts/issue-5/baseline.md` §3 (감사 로그 경로 `.mam/delegate_job_logs/<job_id>/` 정확히 반영).
|
||||
- [x] **P0-04** event와 registry status 분리; permission clearance·cancelled subscriber 차이·pick_pending 등 writer inventory/gap 기록.
|
||||
- *증거*: `docs/contracts/issue-5/baseline.md` §3, `job_subscriber.py:46, 80` vs `publish_event.py:49` 간극 명시.
|
||||
- [x] **P0-05** report strictness/promotion/cleanup 및 job/session/loop lock 획득·해제/ownership 계약 기록.
|
||||
- *증거*: `docs/contracts/issue-5/baseline.md` §4, §5.
|
||||
- [x] **P0-06** `docs/contracts/issue-5/state-transitions.md` 작성: 4중 귀속, dispatch anchor, target CAS 표와 duplicate/conflict/deadline 정책.
|
||||
- *증거*: `docs/contracts/issue-5/state-transitions.md` §2, §3.
|
||||
- [x] **P0-07** writing/stable_candidate/finalized 및 manifest/hash/부수효과 확정 계약 기록; debounce만으로 completed 금지.
|
||||
- *증거*: `docs/contracts/issue-5/state-transitions.md` §4.
|
||||
- [x] **P0-08** Herdr native wait version/help/spec 및 단일 결과 capture 수집. timeout/unknown target/transport failure와 unknown 상태를 분리하고 외부 collector의 before/after 참조를 검증.
|
||||
- *증거*: `tests/fixtures/issue_5/herdr/wait-contract.json`, `docs/contracts/issue-5/herdr-runtime.md` §1 (timeout stderr 채널 반영).
|
||||
- [x] **P0-09** claude native 상태/call-outcome 행렬·실측 provenance·미관측 gap 기록.
|
||||
- *증거*: `tests/fixtures/issue_5/herdr/claude/idle.json`, `herdr/manifest.json`.
|
||||
- [x] **P0-10** agy native 상태/call-outcome 행렬·실측 provenance·미관측 gap 기록.
|
||||
- *증거*: `tests/fixtures/issue_5/herdr/agy/working.json`, `tests/fixtures/issue_5/herdr/agy/wait_timeout.json`, `herdr/manifest.json`.
|
||||
- [x] **P0-11** hermes native 상태/call-outcome 행렬·실측 provenance·미관측 gap 기록.
|
||||
- *증거*: `herdr/manifest.json` (미가동 세션 gap으로 정직하게 기록).
|
||||
- [x] **P0-12** grok native 상태/call-outcome 행렬·실측 provenance·미관측 gap 기록.
|
||||
- *증거*: `tests/fixtures/issue_5/herdr/grok/idle.json`, `herdr/manifest.json`.
|
||||
- [x] **P0-13** opencode native 상태/call-outcome 행렬·실측 provenance·미관측 gap 기록.
|
||||
- *증거*: `herdr/manifest.json` (미가동 세션 gap으로 정직하게 기록).
|
||||
- [x] **P0-14** codex native 상태/call-outcome 행렬·실측 provenance·미관측 gap 기록.
|
||||
- *증거*: `tests/fixtures/issue_5/herdr/codex/done.json`, `herdr/manifest.json`.
|
||||
- [x] **P0-15** `herdr/manifest.json`과 `watcher/manifest.json` 분리. native observed 캡처만 저장; watcher timeline source별 observed/simulated, 전체 mixed 여부, identity association과 redaction 검증.
|
||||
- *증거*: `tests/fixtures/issue_5/herdr/manifest.json`, `tests/fixtures/issue_5/watcher/manifest.json`.
|
||||
- [x] **P0-16** 두 계층 schema/reference/coverage 테스트 추가: native payload에 job/attempt/report phase 주입 거부, 오류와 상태 분리, simulated를 observed로 표기 거부. 운영 session 쓰기 금지.
|
||||
- *증거*: `tests/test_issue5_phase0_contracts.py`.
|
||||
- *Rev.5 보강 (Codex [P1] 대응)*: `source_kind` 명시적 분기(`SUPPORTED_SOURCE_KINDS`), 관측 증거 스키마 미구현 kind(event/filesystem/harness)의 `observed` 표기 fail-closed 거부, Herdr 항목의 필수 필드·전 필드 대응·세션 UUID/pane/backend 정체성 대응, `capture_ref` 증거 루트 격리, 1캡처=1관측, 매니페스트↔파일 정합성 검증. 계약 명세는 `herdr-runtime.md` §3-1.
|
||||
- [x] **P0-16a** watcher 복합 시나리오 7종 작성: initial idle, tool active, report pause, finalized candidate, blocked, disconnect, generation restart. source별 증거/가상 시계/expected policy를 구분.
|
||||
- *증거*: `tests/fixtures/issue_5/watcher/*.json` (7개 시나리오 완료).
|
||||
- [x] **P0-16b** `herdr-runtime.md`에 native API·단일 wait·외부 collector·file/event/dispatch source 경계를 명시.
|
||||
- *증거*: `docs/contracts/issue-5/herdr-runtime.md`.
|
||||
- [x] **P0-17** Phase 1–4 `implementation_plan.md`/`task.md` 작성, live-helper version/공통 CAS/notification 복구 결정 기록.
|
||||
- *증거*: `docs/contracts/issue-5/implementation_plan.md`, `docs/contracts/issue-5/task.md`.
|
||||
- [x] **P0-18** 관련 CLI/lock/rebuttal/adapter 테스트와 전체 suite 실행; 수집·실행·통과·실패 숫자 분리.
|
||||
- *증거 (Rev.3 당시)*: 전체 484개 수집, 선행 전체 실행 481개 통과(0 failed, 0 skipped, 786.18s), 15개 계약/변이 테스트 전체 통과, 집중 코어 회귀 155개 통과(60.48s).
|
||||
- *증거 (Rev.5, job `fb7fadd4`)*: 전체 suite 실행 **499 passed, 0 failed, 0 skipped (848.90s)** — 커밋 `19d798b` 기준선(계약 테스트 30개 포함)에서 실행. Rev.5 보강 후 계약 테스트 파일 단독 재실행 **41 passed** (`+11`), 수집 총계 **510** (`--collect-only`). Rev.5 변경은 `tests/test_issue5_phase0_contracts.py` 및 `docs/`에 국한되어 나머지 469개 테스트에 영향 없음.
|
||||
- [x] **P0-19** 문서 링크/JSON/git diff --check/production 코드 무변경 확인.
|
||||
- *증거*: `git diff --check` 클린, 공백 린트 완료, 프로덕션 `.sh`/`.py` 무변경 (오직 `docs/`, `tests/` 변경).
|
||||
- [x] **P0-20** 결과 보고: Phase 0 산출물 완료 여부, backend별 native live gap·mixed/simulated scenario 현황, 남은 gate를 별도 명시하고 peer review 요청.
|
||||
- *증거*: `.mam/jobs/61f2fa87/agy-reports/report-final.md` (Rev.3 최초 보고), `.mam/jobs/f29cb24e/claude-code-reports/report-final.md` (Codex [P1]·Claude [Finding 2] 정정, 커밋 `19d798b`), `.mam/jobs/fb7fadd4/claude-reports/report-final.md` (Rev.5 자체 재리뷰 및 보강).
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: `lib_py/delegation/` 구축 (완료, v4.3.0)
|
||||
|
||||
- [x] **P1-00** 이동 전 특성화 테스트 선행. 위임 셸 헬퍼 3종의 기존 직접 커버리지는 0건이었다.
|
||||
- *증거*: `tests/test_issue5_phase1_delegation.py` C-01~C-09 (`MAM_DELEGATION_PY` 0/1 양쪽 parametrize), CLI 계약 스냅샷, 자동완료 3표면 불변 가드. 커밋 `265a1f6`.
|
||||
- [x] **P1-01** delegation 스크립트 4종을 `lib_py/delegation/`으로 `git mv` 하고 `scripts/*.py`는 패키지 바인딩 shim으로 보존. 저장소 통합 없음, non-fatal 계약 보존.
|
||||
- *증거*: 커밋 `fb3498e`. PV-1 정체성+출처, PV-4 10개 시나리오(A/A_pe/B/B2/C1/C1_pe/C2/C2_pe/D/E) + K1/K3/K6 서브프로세스 매트릭스, PV-5 shim 바이트 동일성, AST 기반 형제 임포트 금지 검사, `--help` 이동 전 캡처와 무차이.
|
||||
- *설계 근거*: `creator-claude-01`의 이의제기(job `c0b9c987`)로 `sys.path.insert(0)` 방식이 pytest에서 `lib_py.agents`까지 샌드박스 사본으로 가로채는 것이 실증되어, 패키지 단위 bind-once로 확정.
|
||||
- [x] **P1-02** `DelegationService`/`cli.py`/`daemon.py` 구현 및 `lib.sh` 헬퍼 교체. daemon PID·중복·수명 계약 일치.
|
||||
- *증거*: 커밋 `78cb9ec`. C-01~C-09가 레거시·파이썬 두 본체에서 통과, daemon 부모 종료 후 생존 및 `pgrep` 중복 검출, cli가 레거시 실패 문구를 정확히 재현, 발행 실패 시맨틱(status 동기화·seq 소비·`published: false`)이 shim 경로와 cli 경로에서 동일.
|
||||
- [x] **P1-03** 장수 MQTT 연결 프로토타입 및 벤치마크. HMAC/seq/retain/디스크 폴백 회귀 유지, 자동완료 비활성 유지.
|
||||
- *증거*: 커밋 `a4b7b45` + 자체 리뷰 수정 `f5286a0`(job `5e3c7660`에서 발견한 `PersistentPublisher` CONNACK 상태 공유 결함 수정 및 회귀 3종 추가). `publish_event.main`을 `prepare_event`/`record_outcome`/`main`으로 분리(동작 불변, G-1~G-4 통과). 가짜 전송에서 OneShot N connect vs Persistent 1 connect, 두 전송의 레지스트리·감사 레코드 필드 단위 동일, PUBACK 미확인 시 재시도 후 `published=false`, 브로커 상이 시 거부. 네트워크 접촉 0.
|
||||
- [x] **P1-04** 벤치마크 실측 및 문서 동기화, 롤백 드릴.
|
||||
- *증거*: `docs/contracts/issue-5/phase1-benchmark.md`. 가짜 전송 p50 54.813ms → 0.002ms(연결 50회 → 1회), 실브로커 p50 1892.728ms → 275.992ms(정보성). `MAM_DELEGATION_PY=0` 롤백 드릴 및 K2(freeze 사본 + 라이브 `PYTHONPATH`) 실증 완료.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2a: `lib_py/loop/` 코어 & Shadow Watcher (완료, shadow-only)
|
||||
|
||||
- [x] **P2A-00** 이동 전 writer 특성화 선행 (X-01~X-04, `MAM_ENGINE_CAS` 0/1 parametrize).
|
||||
- *증거*: 커밋 `9389d7e`. X-02가 계획서의 초기 가정을 정정한다 — `pick_pending`은 락 안에서 `pending`을 필터하므로 **이미 올바른 CAS**이며, 문제는 정확성이 아니라 **중복**이다.
|
||||
- [x] **P2A-01** 단일 전이 경계 구현 및 writer 2종 라우팅.
|
||||
- *증거*: 커밋 `2532e78`. `engine.transition`이 유일한 상태 writer. `state-transitions.md` §3 표 전 항목(claim/비terminal/explicit terminal/멱등 재수신/상충 거부+감사/stale attempt/deadline) 테스트. `MAM_ENGINE_CAS=0` 레거시 본체 보존.
|
||||
- *계획 대비 구조 정정*: 계획서는 `engine.py`를 `lib_py/loop/`에 두었으나, 그러면 `lib_py.delegation`이 **형제 패키지를 임포트**하게 되어 freeze 하에서 트리가 섞인다(Phase 1이 명문화한 규칙). Phase 1의 기존 가드 테스트가 이를 즉시 검출했고, 엔진을 레지스트리 writer들이 있는 `lib_py/delegation/`으로 옮겼다.
|
||||
- *락 규율*: 락은 판정 + 레코드 1회 쓰기만 덮고, 감사 mirror는 큐잉 후 락 해제 뒤 flush한다. 순서는 `transition_seq`로 복원한다. 락 보유 중 `subprocess`/`socket`/`sleep` 호출 0건을 테스트로 감시한다.
|
||||
- [x] **P2A-02** watcher·귀속·dispatch 증거·재연결 백오프.
|
||||
- *증거*: 커밋 `b49af56`. `--timeout`은 **밀리초**(파라미터명 `timeout_ms`), `--until` 항상 명시(생략 시 `working` 누락), exit 1은 **stderr**에서 파싱. 백오프 1→2→4→8→16s 상한, 실패 시 캐시 스냅샷 폐기. 귀속 증거는 **레지스트리가 아닌 shadow 전용 저장소**(`.mam/shadow/`)에 둔다 — 레코드에 3개 필드가 전부 없고 Phase 1이 스키마를 동결했기 때문.
|
||||
- [x] **P2A-03** shadow-only가 terminal을 쓰지 않음을 검증.
|
||||
- *증거*: 커밋 `964abc6`. `ShadowRunner`는 engine 참조를 **갖지 않는다**(구조적 차단, AST 단정). 측정: 전체 시나리오 실행 전후 **레지스트리 디렉터리 전 파일 SHA-256 동일**.
|
||||
- [x] **P2A-05** 교차 리뷰(`68abd0ff` Claude / `cae12436` Codex) 지적 5건 전건 수용·수정.
|
||||
- *증거*: 커밋 `56d08ef`. F-A(래퍼 loop/discuss 전이 2건 누락 → 운영자 권한 요청 `redispatch`/`finalize` 신설 + CLI 거부 시 non-zero), F-B(감사 mirror 순서 회귀 → `transition_seq` 비교), R1(관측 식별자 미대조 → `collect_observed_identity` 전량 대조 + 매니페스트 attempt 검증), R2(`expected_attempt`가 레코드에 없으면 무시 → fail-closed), R3(raw `status` 필드 우회 → 요청만이 status를 설정). 신규 회귀 11종 전부 수정 전 코드에서 실패함을 역검증.
|
||||
- [x] **P2A-06** 교차 리뷰 Round 2(`cb792e91` Claude / `0026165b` Codex) 지적 4건 전건 수용·수정.
|
||||
- *증거*: 커밋 `cb575fa`. F-C(멱등 ACK를 거부로 분류 → 래퍼 성공 경로가 `set -e`로 중단; 멱등은 rc 0, 진짜 거부만 rc 4), F-D(`VALID_STATUSES` 검증 소실 → 두 모드 공통 rc 1·레코드 불변), R1(증거 부재 시에도 승인 → 4식별자와 매니페스트 attempt **필수**, 식별자는 네이티브 payload가 아닌 `collector_attribution` 봉투에서 수집), R4(비원자적 비교-후-쓰기 → per-job 감사 락 + 고유 임시 파일). 신규 회귀 8종 전부 수정 전 코드에서 실패함을 역검증.
|
||||
- [x] **P2A-04** race/attempt 재사용/부분 report/도구 실행/blocked/unknown/재시작 시나리오.
|
||||
- *증거*: 신규 5종(`race_double_dispatch`, `attempt_reuse`, `partial_report`, `unknown_status`, `restart_midflight`)을 Phase 0 스키마로 작성해 **Phase 0의 `validate_watcher_scenario`를 통과**시켰고, 기존 7종과 합쳐 **12종 전부 `expected_policy.decision`과 일치**함을 단정한다.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2b: 이중 감지 자동완료 활성화 (완료, 기본 비활성)
|
||||
|
||||
- [x] **P2B-00** 자동완료 도입 전 fail-closed 선행 조건 특성화 (X-01~X-05).
|
||||
- *증거*: 커밋 `9a827b3`. 매니페스트 없는 job이 이미 `hold`임을 고정 — 미지원 경로 보존이 새 분기 없이 성립하는 근거.
|
||||
- [x] **P2B-01** 리포트 확정 계약(매니페스트·SHA-256) 및 부수효과 증거 프로토콜.
|
||||
- *증거*: 커밋 `3ea5757`. `lib_py/delegation/finalize.py` + `publish_event --finalize-report/--side-effect`. **선언은 에이전트가, 해시 재계산은 읽는 쪽이** — 워처가 만든 매니페스트는 자기 증명이므로 채택하지 않았다. 리포트 재작성 시 즉시 무효, 선언한 부수효과는 전부 증거 필요.
|
||||
- [x] **P2B-02** terminal + pending notification, 재전송·재시작·늦은 conflict.
|
||||
- *증거*: 커밋 `803ba43`. `pending_notification`을 상태와 **같은 원자적 쓰기**로 커밋, 락 밖 발행, `notification_settled`(상태 무변경 bookkeeping 요청 — terminal에서도 허용)로 정리, 기동 시 재발행. 발행 실패 시 intent 보존.
|
||||
- [x] **P2B-03** 검증된 backend/job 한정 default-off 플래그.
|
||||
- *증거*: 커밋 `803ba43`. 3중 게이트(전역 OFF 기본 / 관측 증거 기반 허용목록 `claude,grok,codex` / 잡별 opt-in). 허용목록은 좁히기만 가능. 자동완료 terminal에 `completed_by` 표식.
|
||||
- [x] **P2B-05** 교차 리뷰 3인(`3fa76e08` Claude / `a0de0e5e` Grok / `e5f26823` Codex) 차단 지적 7건 전건 수용·수정.
|
||||
- *증거*: 커밋 `cb7ed1a`. 필수 매니페스트 검증(`report_dir` 생략 = 거부), 증거의 대상 일치(job/attempt/session/pane), Reviewer 판정 토큰 게이트(Phase 0 안전 계약), bookkeeping 필드 제한, 조건부 settle + intent 신원 전달, 부수효과 evidence 스키마 검증, `MAM_ENGINE_CAS=0` 준수, 백엔드 정확 일치, 절대 경로. 신규 회귀 21종 전부 수정 전 코드에서 실패함을 역검증.
|
||||
- *배선 현황*: `evaluate`/`recover`의 **프로덕션 호출자는 아직 없다**(Phase 3 배선). 운영 노트 §2-1에 정정 기록.
|
||||
- [x] **P2B-06** 교차 리뷰 Round 2(`a45e5b7f` Codex) 지적 2건 수용·수정.
|
||||
- *증거*: 커밋 `01b465f`. R2(귀속이 커밋과 원자적이지 않음 → `dispatch_token`을 레코드에 도입, `claim`/`redispatch`에서 발급, `expected_dispatch_token`으로 락 안에서 대조), R4(intent 신원을 발행만 하고 소비자가 무시 → `job_subscriber`가 안정 신원으로 중복 제거). 신규 회귀 7종 전부 수정 전 코드에서 실패함을 역검증.
|
||||
- *이전 판단 정정*: "시도 보증이 증거 계층에 있으므로 레코드 스키마 변경 불필요"는 **대조**에는 맞지만 **원자성**에는 틀렸다. 추가 필드이므로 `SCHEMA_VERSION`은 1 유지.
|
||||
- [x] **P2B-07** 교차 리뷰 Round 3(Codex) 지적 1건 + 그 과정에서 드러난 인접 결함 1건 수용·수정.
|
||||
- *증거*: `job_subscriber._Watcher.on_message`의 `seen_intents` 기록이 단조 seq 가드보다 **앞서** 실행되어, 늦게 도착한 과거 패킷이 자기 intent를 "이미 처리함"으로 태우고 **뒤따르는 정당한 재시도가 드롭**됐다(`last_seq=10`, seq=9 거부 후 동일 intent의 seq=11 → `qsize()==0`). 신원 **대조**는 seq 가드 앞에, 신원 **기록**은 seq 가드 뒤에 두어 "본 적 있다"가 "받아들였다"를 뜻하도록 고쳤다. 함께: 해시 불가능·비객체 intent가 `TypeError`로 콜백을 빠져나가 **그 구독자의 모든 job 수신을 끊을 수 있던** 경로를 드롭+경고로 닫았다. 신규 회귀 6종(그중 3종은 수정 전 코드에서 실패함을 역검증, 3종은 보존·커버리지 가드) + 발행→소비 실경로 종단 테스트.
|
||||
|
||||
- [x] **P2B-04** 부수효과 예외·무판정 fail-closed·경쟁/재시작 시나리오.
|
||||
- *증거*: 신규 시나리오 3종(`side_effect_unproven`, `no_verdict_fail_closed`, `notification_crash_window`)을 Phase 0 스키마로 추가, **총 15종 전부** `expected_policy.decision` 일치.
|
||||
- *계획 대비 이탈(이후 정정됨)*: 처음에는 계획서 §D2의 `attempt_id` 레코드 승격이 불필요하다고 판단했으나, **P2B-06에서 이 판단의 절반을 철회했다.** 증거를 dispatch와 *대조*하는 데에는 증거 계층으로 충분하지만 커밋과의 *원자성*에는 부족하다. 최종적으로 승격한 것은 `attempt_id`가 아니라 **불투명한 `dispatch_token`**이다(`iteration`은 루프 회차라는 다른 뜻을 이미 갖고 있다). 레코드 스키마는 필드 추가만이며 `SCHEMA_VERSION`은 1 유지. 상세는 `phase2b-autocomplete.md` §9(부분 철회 표시).
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: `run_loop.sh` Thin Wrapper 전환 및 전면 회귀 (완료)
|
||||
- [x] **P3-01** `parser.py` (Strict Parser: 코드 펜스 무시, fail-closed) 및 `runner.py` 오케스트레이션 로직 이식 (`tests/test_issue5_phase3_loop.py`).
|
||||
- *증거*: `tests/test_issue5_phase3_loop.py` 12개 테스트 100% 통과 (0.29s). 코드 펜스(` ``` `, `~~~`) 내부 허위 판정/태그 무시, 마지막 비공백 행 엄격 검사, Rebuttal/Adjudication/Arbitration 흐름, Plan/Report 원자적 승격 및 cleanup 완료.
|
||||
- [x] **P3-02** `run_loop.sh` Thin Wrapper 전환 및 런타임 호환 검증.
|
||||
- *증거*: `run_loop.sh`에 `MAM_LOOP_PY=1` 기본값 위임 및 `MAM_LOOP_PY=0` 레거시 롤백 경로 탑재. `--help` exit 1 계약 유지. B-13 freeze 하 인터프리터 앵커링(O-4 인계 사항: `AGENT_PYTHON_BIN` 사전 export) 해결. 기존 테스트 텍스트 앵커(`test_o1_rebuttal.py`, `test_deploy_freshness.py`, `test_b7_diff_untracked.py`, `test_o3_scoped_guard.py`, `test_tier4_e2e.py` Scenario 5 E2E 루프 포함) 100% 0-Fail 통과.
|
||||
- [x] **P3-03** 전면 회귀(744개 테스트 0-Fail, 816.30s), 체크포인트(`.mam/loop-checkpoint.json`) 원자적 기록 및 핸드오프/시그널 정리 검증.
|
||||
- *증거*: 전체 744개 테스트 무결점 통과 (`744 passed in 816.30s`). `checkpoint.save_checkpoint`/`load_checkpoint` 원자적 파일 교체 및 `SIGINT`/`SIGTERM` 핸들러의 안전 락 해제(`release_guard`) 실증.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 불변 런타임 검증 및 Freeze 제거 (완료)
|
||||
- [x] **P4-01** eager import+child/helper/template 실행 버전 정책 구현/검증.
|
||||
- *증거*: `.agents/skills/lib_py/loop/eager.py` 구현 및 `runner.LoopRunner.__init__`에서 최우선 호출. 모든 표준/프레임워크 코어 모듈(47개+)을 기동 시점에 `sys.modules`에 상주시킴. `sys.dont_write_bytecode = True` 및 `PYTHONDONTWRITEBYTECODE=1` 환경 설정으로 `.pyc` 디스크 오염 원천 차단. `FRAMEWORK_VERSION="4.3.0"`과 `MAM_VERSION` 불일치 시 fail-closed 검증 (`test_p4_01`).
|
||||
- [x] **P4-02** 실행 중 코드 교체와 늦은 재심/승격/rollback 스트레스 검증.
|
||||
- *증거*: `tests/test_issue5_phase4_runtime.py::test_p4_02_midflight_code_replacement_stress`. 실행 중인 서브프로세스의 디스크 상 소스 파일 및 워커 모듈을 문법 오류(`INVALID SYNTAX`, `unexpected EOF`)로 치환/파괴한 상태에서도 메모리 상주 바이트코드와 AST를 통해 판정 파싱(`parser.parse_verdict`), 체크포인트 원자적 기록/복구(`checkpoint.save_checkpoint`/`load_checkpoint`)가 무결하게 완결됨을 실증.
|
||||
- [x] **P4-03** gate 통과 후에만 Freeze 제거 및 운영 문서 갱신.
|
||||
- *증거*: `run_loop.sh`의 기본 Freeze 스냅샷 생성(`/tmp/mam-loop-freeze.XXXXXX`)을 우회(Python 모드 `MAM_LOOP_PY=1` 기본값 시 복사 I/O 완전 제거). 긴급 롤백 경로(`MAM_LOOP_FREEZE=1` 또는 `MAM_LOOP_PY=0`) 정상 보존. 전체 회귀 테스트 스위트 749개(744개 유닛/통합 + 5개 Tier 4 E2E) 100% 0-Fail 통과.
|
||||
|
||||
@@ -42,6 +42,10 @@ def mam_sandbox(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("LOCAL_BIN", str(tmp_path / ".local" / "bin"))
|
||||
monkeypatch.setenv("AGENT_SESSIONS_YAML", str(yaml_path))
|
||||
monkeypatch.setenv("WORKSPACE_ROOT", str(tmp_path))
|
||||
# D6 (issue #5 phase 1): point the delegation env loader at a file that does not
|
||||
# exist, so importing or running delegation code inside a sandbox never injects the
|
||||
# real .mam.env into this process's os.environ.
|
||||
monkeypatch.setenv("MAM_ENV_FILE", str(tmp_path / ".mam.env.absent"))
|
||||
monkeypatch.delenv("HERDR_SESSION_NAME", raising=False)
|
||||
monkeypatch.delenv("HERDR_SERVER_NAME", raising=False)
|
||||
monkeypatch.delenv("HERDR_WORKSPACE_ID", raising=False)
|
||||
@@ -1044,3 +1048,61 @@ print(str(uuid.uuid4()))
|
||||
monkeypatch.setenv("PATH", f"{bin_dir}:{new_path}")
|
||||
|
||||
return bin_dir
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# Issue #5 Phase 1 — tree provenance guards (PV-2 / PV-3)
|
||||
#
|
||||
# The delegation shims bind the `lib_py.delegation` package to the tree next to
|
||||
# themselves. Two invariants must hold for the whole session, and both are
|
||||
# checked once at teardown so a violation anywhere in the suite is caught:
|
||||
#
|
||||
# PV-2 every imported `lib_py.*` module OTHER than the delegation package
|
||||
# resolves to the real skills tree (a sandbox copy must never shadow
|
||||
# lib_py.agents / layout / state / ...).
|
||||
# PV-3 no sys.path entry under pytest's basetemp contributes a `lib_py`
|
||||
# package directory. (Sandbox *scripts* dirs are fine: they contain no
|
||||
# lib_py, which is why test_tier1_unit.py's inserts stay legal.)
|
||||
# ==============================================================================
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def delegation_tree_provenance_guard(tmp_path_factory):
|
||||
yield
|
||||
|
||||
real_skills = Path(SKILLS_DIR).resolve()
|
||||
basetemp = Path(tmp_path_factory.getbasetemp()).resolve()
|
||||
|
||||
def _under(path, root):
|
||||
try:
|
||||
Path(path).resolve().relative_to(root)
|
||||
return True
|
||||
except (ValueError, OSError):
|
||||
return False
|
||||
|
||||
# --- PV-2 -----------------------------------------------------------------
|
||||
offenders = []
|
||||
for name, mod in sorted(sys.modules.items()):
|
||||
if name != "lib_py" and not name.startswith("lib_py."):
|
||||
continue
|
||||
if name == "lib_py.delegation" or name.startswith("lib_py.delegation."):
|
||||
continue # delegation may legitimately bind to a sandbox copy (D2/PV-1)
|
||||
origin = getattr(mod, "__file__", None)
|
||||
if not origin:
|
||||
continue
|
||||
if not _under(origin, real_skills):
|
||||
offenders.append("{} -> {}".format(name, origin))
|
||||
assert not offenders, (
|
||||
"PV-2: non-delegation lib_py modules were loaded from outside the real skills "
|
||||
"tree ({}):\n {}".format(real_skills, "\n ".join(offenders))
|
||||
)
|
||||
|
||||
# --- PV-3 -----------------------------------------------------------------
|
||||
polluted = [
|
||||
entry for entry in sys.path
|
||||
if entry and _under(entry, basetemp) and (Path(entry) / "lib_py").is_dir()
|
||||
]
|
||||
assert not polluted, (
|
||||
"PV-3: sys.path carries temp-tree entries that provide a lib_py package "
|
||||
"(these would shadow the real tree for any later first-time import):\n "
|
||||
+ "\n ".join(polluted)
|
||||
)
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"capture_id": "cap-agy-timeout-01",
|
||||
"backend": "agy",
|
||||
"herdr_version": "0.8.2",
|
||||
"backend_version": "unknown",
|
||||
"platform": "darwin-arm64",
|
||||
"provenance": "observed",
|
||||
"command_argv": [
|
||||
"herdr",
|
||||
"agent",
|
||||
"wait",
|
||||
"creator-agy-01",
|
||||
"--until",
|
||||
"done",
|
||||
"--timeout",
|
||||
"50"
|
||||
],
|
||||
"exit_code": 1,
|
||||
"stdout_payload": null,
|
||||
"stderr": "{\"error\":{\"code\":\"timeout\",\"message\":\"timed out waiting for agent status\"},\"id\":\"cli:agent:wait\"}",
|
||||
"captured_at": "2026-09-06T00:45:00Z",
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"monotonic_start": 100.0,
|
||||
"monotonic_end": 100.05,
|
||||
"evidence_reference": ".mam/jobs/129a95db/evidence/timeout_stderr.json",
|
||||
"normalization_map_reference": "paths_normalized: /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux -> <WORKSPACE_ROOT>",
|
||||
"payload": {
|
||||
"error": {
|
||||
"code": "timeout",
|
||||
"message": "timed out waiting for agent status"
|
||||
},
|
||||
"id": "cli:agent:wait"
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"capture_id": "cap-agy-working-01",
|
||||
"backend": "agy",
|
||||
"herdr_version": "0.8.2",
|
||||
"backend_version": "unknown",
|
||||
"platform": "darwin-arm64",
|
||||
"provenance": "observed",
|
||||
"command_argv": [
|
||||
"herdr",
|
||||
"agent",
|
||||
"get",
|
||||
"creator-agy-01"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"stdout_payload": {
|
||||
"id": "cli:agent:get",
|
||||
"result": {
|
||||
"agent": {
|
||||
"agent": "agy",
|
||||
"agent_session": {
|
||||
"agent": "agy",
|
||||
"kind": "id",
|
||||
"source": "herdr:antigravity_cli",
|
||||
"value": "1c53dd89-81fc-49a6-b15e-bef3dfb61697"
|
||||
},
|
||||
"agent_status": "working",
|
||||
"cwd": "<WORKSPACE_ROOT>",
|
||||
"focused": false,
|
||||
"foreground_cwd": "<WORKSPACE_ROOT>",
|
||||
"interactive_ready": true,
|
||||
"name": "creator-agy-01",
|
||||
"pane_id": "w2X:p3",
|
||||
"revision": 2,
|
||||
"state_change_seq": 29,
|
||||
"tab_id": "w2X:t1",
|
||||
"terminal_id": "term_65ac4b5991acc4",
|
||||
"terminal_title": "agy --dangerously-skip-permissions --conversation ",
|
||||
"terminal_title_stripped": "agy --dangerously-skip-permissions --conversation",
|
||||
"workspace_id": "w2X"
|
||||
},
|
||||
"type": "agent_info"
|
||||
}
|
||||
},
|
||||
"stderr": null,
|
||||
"captured_at": "2026-09-06T00:45:00Z",
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"monotonic_start": 100.0,
|
||||
"monotonic_end": 100.05,
|
||||
"evidence_reference": ".mam/jobs/129a95db/evidence/get_agy_working.json",
|
||||
"normalization_map_reference": "paths_normalized: /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux -> <WORKSPACE_ROOT>",
|
||||
"payload": {
|
||||
"id": "cli:agent:get",
|
||||
"result": {
|
||||
"agent": {
|
||||
"agent": "agy",
|
||||
"agent_session": {
|
||||
"agent": "agy",
|
||||
"kind": "id",
|
||||
"source": "herdr:antigravity_cli",
|
||||
"value": "1c53dd89-81fc-49a6-b15e-bef3dfb61697"
|
||||
},
|
||||
"agent_status": "working",
|
||||
"cwd": "<WORKSPACE_ROOT>",
|
||||
"focused": false,
|
||||
"foreground_cwd": "<WORKSPACE_ROOT>",
|
||||
"interactive_ready": true,
|
||||
"name": "creator-agy-01",
|
||||
"pane_id": "w2X:p3",
|
||||
"revision": 2,
|
||||
"state_change_seq": 29,
|
||||
"tab_id": "w2X:t1",
|
||||
"terminal_id": "term_65ac4b5991acc4",
|
||||
"terminal_title": "agy --dangerously-skip-permissions --conversation ",
|
||||
"terminal_title_stripped": "agy --dangerously-skip-permissions --conversation",
|
||||
"workspace_id": "w2X"
|
||||
},
|
||||
"type": "agent_info"
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"capture_id": "cap-claude-idle-01",
|
||||
"backend": "claude",
|
||||
"herdr_version": "0.8.2",
|
||||
"backend_version": "unknown",
|
||||
"platform": "darwin-arm64",
|
||||
"provenance": "observed",
|
||||
"command_argv": [
|
||||
"herdr",
|
||||
"agent",
|
||||
"get",
|
||||
"planner-reviewer-claude-01"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"stdout_payload": {
|
||||
"id": "cli:agent:get",
|
||||
"result": {
|
||||
"agent": {
|
||||
"agent": "claude",
|
||||
"agent_session": {
|
||||
"agent": "claude",
|
||||
"kind": "id",
|
||||
"source": "herdr:claude",
|
||||
"value": "7d5700d0-bab1-4f65-9665-6b887396a9a6"
|
||||
},
|
||||
"agent_status": "idle",
|
||||
"cwd": "<WORKSPACE_ROOT>",
|
||||
"focused": false,
|
||||
"foreground_cwd": "<WORKSPACE_ROOT>",
|
||||
"interactive_ready": true,
|
||||
"name": "planner-reviewer-claude-01",
|
||||
"pane_id": "w2X:p1",
|
||||
"revision": 3,
|
||||
"state_change_seq": 18,
|
||||
"tab_id": "w2X:t1",
|
||||
"terminal_id": "term_65ac4b2b224de2",
|
||||
"terminal_title": "✳ Multi-agent-mux team leader onboarding",
|
||||
"terminal_title_stripped": "Multi-agent-mux team leader onboarding",
|
||||
"workspace_id": "w2X"
|
||||
},
|
||||
"type": "agent_info"
|
||||
}
|
||||
},
|
||||
"stderr": null,
|
||||
"captured_at": "2026-09-06T00:45:00Z",
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"monotonic_start": 100.0,
|
||||
"monotonic_end": 100.05,
|
||||
"evidence_reference": ".mam/jobs/129a95db/evidence/get_claude_idle.json",
|
||||
"normalization_map_reference": "paths_normalized: /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux -> <WORKSPACE_ROOT>",
|
||||
"payload": {
|
||||
"id": "cli:agent:get",
|
||||
"result": {
|
||||
"agent": {
|
||||
"agent": "claude",
|
||||
"agent_session": {
|
||||
"agent": "claude",
|
||||
"kind": "id",
|
||||
"source": "herdr:claude",
|
||||
"value": "7d5700d0-bab1-4f65-9665-6b887396a9a6"
|
||||
},
|
||||
"agent_status": "idle",
|
||||
"cwd": "<WORKSPACE_ROOT>",
|
||||
"focused": false,
|
||||
"foreground_cwd": "<WORKSPACE_ROOT>",
|
||||
"interactive_ready": true,
|
||||
"name": "planner-reviewer-claude-01",
|
||||
"pane_id": "w2X:p1",
|
||||
"revision": 3,
|
||||
"state_change_seq": 18,
|
||||
"tab_id": "w2X:t1",
|
||||
"terminal_id": "term_65ac4b2b224de2",
|
||||
"terminal_title": "✳ Multi-agent-mux team leader onboarding",
|
||||
"terminal_title_stripped": "Multi-agent-mux team leader onboarding",
|
||||
"workspace_id": "w2X"
|
||||
},
|
||||
"type": "agent_info"
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"capture_id": "cap-codex-done-01",
|
||||
"backend": "codex",
|
||||
"herdr_version": "0.8.2",
|
||||
"backend_version": "unknown",
|
||||
"platform": "darwin-arm64",
|
||||
"provenance": "observed",
|
||||
"command_argv": [
|
||||
"herdr",
|
||||
"agent",
|
||||
"get",
|
||||
"planner-reviewer-codex-01"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"stdout_payload": {
|
||||
"id": "cli:agent:get",
|
||||
"result": {
|
||||
"agent": {
|
||||
"agent": "codex",
|
||||
"agent_session": {
|
||||
"agent": "codex",
|
||||
"kind": "id",
|
||||
"source": "herdr:codex",
|
||||
"value": "01a071d1-a645-7400-afdb-3b08019800b3"
|
||||
},
|
||||
"agent_status": "done",
|
||||
"cwd": "<WORKSPACE_ROOT>",
|
||||
"focused": true,
|
||||
"foreground_cwd": "<WORKSPACE_ROOT>",
|
||||
"interactive_ready": true,
|
||||
"name": "planner-reviewer-codex-01",
|
||||
"pane_id": "w2X:p4",
|
||||
"revision": 3,
|
||||
"state_change_seq": 26,
|
||||
"tab_id": "w2X:t1",
|
||||
"terminal_id": "term_65ac4b62bd4e75",
|
||||
"terminal_title": "multi-agent-mux",
|
||||
"terminal_title_stripped": "multi-agent-mux",
|
||||
"workspace_id": "w2X"
|
||||
},
|
||||
"type": "agent_info"
|
||||
}
|
||||
},
|
||||
"stderr": null,
|
||||
"captured_at": "2026-09-06T00:45:00Z",
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"monotonic_start": 100.0,
|
||||
"monotonic_end": 100.05,
|
||||
"evidence_reference": ".mam/jobs/129a95db/evidence/get_codex_done.json",
|
||||
"normalization_map_reference": "paths_normalized: /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux -> <WORKSPACE_ROOT>",
|
||||
"payload": {
|
||||
"id": "cli:agent:get",
|
||||
"result": {
|
||||
"agent": {
|
||||
"agent": "codex",
|
||||
"agent_session": {
|
||||
"agent": "codex",
|
||||
"kind": "id",
|
||||
"source": "herdr:codex",
|
||||
"value": "01a071d1-a645-7400-afdb-3b08019800b3"
|
||||
},
|
||||
"agent_status": "done",
|
||||
"cwd": "<WORKSPACE_ROOT>",
|
||||
"focused": true,
|
||||
"foreground_cwd": "<WORKSPACE_ROOT>",
|
||||
"interactive_ready": true,
|
||||
"name": "planner-reviewer-codex-01",
|
||||
"pane_id": "w2X:p4",
|
||||
"revision": 3,
|
||||
"state_change_seq": 26,
|
||||
"tab_id": "w2X:t1",
|
||||
"terminal_id": "term_65ac4b62bd4e75",
|
||||
"terminal_title": "multi-agent-mux",
|
||||
"terminal_title_stripped": "multi-agent-mux",
|
||||
"workspace_id": "w2X"
|
||||
},
|
||||
"type": "agent_info"
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"capture_id": "cap-grok-idle-01",
|
||||
"backend": "grok",
|
||||
"herdr_version": "0.8.2",
|
||||
"backend_version": "unknown",
|
||||
"platform": "darwin-arm64",
|
||||
"provenance": "observed",
|
||||
"command_argv": [
|
||||
"herdr",
|
||||
"agent",
|
||||
"get",
|
||||
"reviewer-creator-grok-01"
|
||||
],
|
||||
"exit_code": 0,
|
||||
"stdout_payload": {
|
||||
"id": "cli:agent:get",
|
||||
"result": {
|
||||
"agent": {
|
||||
"agent": "grok",
|
||||
"agent_session": {
|
||||
"agent": "grok",
|
||||
"kind": "id",
|
||||
"source": "herdr:grok",
|
||||
"value": "3ea2d545-f7cf-4357-a743-25b2c988e46a"
|
||||
},
|
||||
"agent_status": "idle",
|
||||
"cwd": "<WORKSPACE_ROOT>",
|
||||
"focused": false,
|
||||
"foreground_cwd": "<WORKSPACE_ROOT>",
|
||||
"interactive_ready": true,
|
||||
"name": "reviewer-creator-grok-01",
|
||||
"pane_id": "w2X:p2",
|
||||
"revision": 131,
|
||||
"state_change_seq": 17,
|
||||
"tab_id": "w2X:t1",
|
||||
"terminal_id": "term_65ac4b4eb7c7d3",
|
||||
"terminal_title": "Hermes review and cline deprecation cons… - grok",
|
||||
"terminal_title_stripped": "Hermes review and cline deprecation cons… - grok",
|
||||
"workspace_id": "w2X"
|
||||
},
|
||||
"type": "agent_info"
|
||||
}
|
||||
},
|
||||
"stderr": null,
|
||||
"captured_at": "2026-09-06T00:45:00Z",
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"monotonic_start": 100.0,
|
||||
"monotonic_end": 100.05,
|
||||
"evidence_reference": ".mam/jobs/129a95db/evidence/get_grok_idle.json",
|
||||
"normalization_map_reference": "paths_normalized: /Users/godopu16/PuKi/laa/canary_projects/multi-agent-mux -> <WORKSPACE_ROOT>",
|
||||
"payload": {
|
||||
"id": "cli:agent:get",
|
||||
"result": {
|
||||
"agent": {
|
||||
"agent": "grok",
|
||||
"agent_session": {
|
||||
"agent": "grok",
|
||||
"kind": "id",
|
||||
"source": "herdr:grok",
|
||||
"value": "3ea2d545-f7cf-4357-a743-25b2c988e46a"
|
||||
},
|
||||
"agent_status": "idle",
|
||||
"cwd": "<WORKSPACE_ROOT>",
|
||||
"focused": false,
|
||||
"foreground_cwd": "<WORKSPACE_ROOT>",
|
||||
"interactive_ready": true,
|
||||
"name": "reviewer-creator-grok-01",
|
||||
"pane_id": "w2X:p2",
|
||||
"revision": 131,
|
||||
"state_change_seq": 17,
|
||||
"tab_id": "w2X:t1",
|
||||
"terminal_id": "term_65ac4b4eb7c7d3",
|
||||
"terminal_title": "Hermes review and cline deprecation cons… - grok",
|
||||
"terminal_title_stripped": "Hermes review and cline deprecation cons… - grok",
|
||||
"workspace_id": "w2X"
|
||||
},
|
||||
"type": "agent_info"
|
||||
}
|
||||
}
|
||||
}
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"herdr_version": "0.8.2",
|
||||
"description": "Native Herdr observation fixture manifest across 6 backends",
|
||||
"backends": {
|
||||
"claude": {
|
||||
"installed_binary": "/Users/godopu16/.local/bin/claude",
|
||||
"active_session": "planner-reviewer-claude-01",
|
||||
"slots": {
|
||||
"idle": {
|
||||
"status": "observed",
|
||||
"file": "claude/idle.json"
|
||||
},
|
||||
"working": {
|
||||
"status": "unobserved",
|
||||
"reason": "session was idle during capture window"
|
||||
},
|
||||
"blocked": {
|
||||
"status": "unobserved",
|
||||
"reason": "no interactive permission prompt pending"
|
||||
},
|
||||
"done": {
|
||||
"status": "unobserved",
|
||||
"reason": "session in idle state"
|
||||
},
|
||||
"unknown": {
|
||||
"status": "unobserved",
|
||||
"reason": "session is active and healthy"
|
||||
}
|
||||
}
|
||||
},
|
||||
"agy": {
|
||||
"installed_binary": "/Users/godopu16/.local/bin/agy",
|
||||
"active_session": "creator-agy-01",
|
||||
"slots": {
|
||||
"idle": {
|
||||
"status": "unobserved",
|
||||
"reason": "session actively executing task b75d76d0/129a95db"
|
||||
},
|
||||
"working": {
|
||||
"status": "observed",
|
||||
"file": "agy/working.json"
|
||||
},
|
||||
"blocked": {
|
||||
"status": "unobserved",
|
||||
"reason": "agy running with --dangerously-skip-permissions"
|
||||
},
|
||||
"done": {
|
||||
"status": "unobserved",
|
||||
"reason": "session actively working"
|
||||
},
|
||||
"unknown": {
|
||||
"status": "unobserved",
|
||||
"reason": "session is active and healthy"
|
||||
},
|
||||
"wait_timeout": {
|
||||
"status": "observed",
|
||||
"file": "agy/wait_timeout.json"
|
||||
}
|
||||
}
|
||||
},
|
||||
"hermes": {
|
||||
"installed_binary": "/Users/godopu16/.local/bin/hermes",
|
||||
"active_session": null,
|
||||
"slots": {
|
||||
"idle": {
|
||||
"status": "unobserved",
|
||||
"reason": "no hermes session currently active in herdr persistent daemon"
|
||||
},
|
||||
"working": {
|
||||
"status": "unobserved",
|
||||
"reason": "no hermes session currently active in herdr persistent daemon"
|
||||
},
|
||||
"blocked": {
|
||||
"status": "unobserved",
|
||||
"reason": "no hermes session currently active in herdr persistent daemon"
|
||||
},
|
||||
"done": {
|
||||
"status": "unobserved",
|
||||
"reason": "no hermes session currently active in herdr persistent daemon"
|
||||
},
|
||||
"unknown": {
|
||||
"status": "unobserved",
|
||||
"reason": "no hermes session currently active in herdr persistent daemon"
|
||||
}
|
||||
}
|
||||
},
|
||||
"grok": {
|
||||
"installed_binary": "/Users/godopu16/.grok/bin/grok",
|
||||
"active_session": "reviewer-creator-grok-01",
|
||||
"slots": {
|
||||
"idle": {
|
||||
"status": "observed",
|
||||
"file": "grok/idle.json"
|
||||
},
|
||||
"working": {
|
||||
"status": "unobserved",
|
||||
"reason": "session currently idle waiting for review assignment"
|
||||
},
|
||||
"blocked": {
|
||||
"status": "unobserved",
|
||||
"reason": "no permission prompt pending"
|
||||
},
|
||||
"done": {
|
||||
"status": "unobserved",
|
||||
"reason": "session currently idle"
|
||||
},
|
||||
"unknown": {
|
||||
"status": "unobserved",
|
||||
"reason": "session is active and healthy"
|
||||
}
|
||||
}
|
||||
},
|
||||
"opencode": {
|
||||
"installed_binary": "/Users/godopu16/.opencode/bin/opencode",
|
||||
"active_session": null,
|
||||
"slots": {
|
||||
"idle": {
|
||||
"status": "unobserved",
|
||||
"reason": "no opencode session currently active in herdr persistent daemon"
|
||||
},
|
||||
"working": {
|
||||
"status": "unobserved",
|
||||
"reason": "no opencode session currently active in herdr persistent daemon"
|
||||
},
|
||||
"blocked": {
|
||||
"status": "unobserved",
|
||||
"reason": "no opencode session currently active in herdr persistent daemon"
|
||||
},
|
||||
"done": {
|
||||
"status": "unobserved",
|
||||
"reason": "no opencode session currently active in herdr persistent daemon"
|
||||
},
|
||||
"unknown": {
|
||||
"status": "unobserved",
|
||||
"reason": "no opencode session currently active in herdr persistent daemon"
|
||||
}
|
||||
}
|
||||
},
|
||||
"codex": {
|
||||
"installed_binary": "/Users/godopu16/.local/bin/codex",
|
||||
"active_session": "planner-reviewer-codex-01",
|
||||
"slots": {
|
||||
"idle": {
|
||||
"status": "unobserved",
|
||||
"reason": "session currently in done state"
|
||||
},
|
||||
"working": {
|
||||
"status": "unobserved",
|
||||
"reason": "session currently in done state"
|
||||
},
|
||||
"blocked": {
|
||||
"status": "unobserved",
|
||||
"reason": "no permission prompt pending"
|
||||
},
|
||||
"done": {
|
||||
"status": "observed",
|
||||
"file": "codex/done.json"
|
||||
},
|
||||
"unknown": {
|
||||
"status": "unobserved",
|
||||
"reason": "session is active and healthy"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"herdr_version": "0.8.2",
|
||||
"cli_command": "herdr agent wait <TARGET> [OPTIONS]",
|
||||
"options": {
|
||||
"--until": {
|
||||
"type": "string",
|
||||
"repeatable": true,
|
||||
"possible_values": [
|
||||
"idle",
|
||||
"working",
|
||||
"blocked",
|
||||
"done",
|
||||
"unknown"
|
||||
],
|
||||
"default_when_omitted": [
|
||||
"idle",
|
||||
"done",
|
||||
"blocked"
|
||||
]
|
||||
},
|
||||
"--timeout": {
|
||||
"type": "integer_ms",
|
||||
"required": false,
|
||||
"default_when_omitted": "unlimited (indefinite block)"
|
||||
}
|
||||
},
|
||||
"call_outcomes": [
|
||||
{
|
||||
"outcome": "state_matched",
|
||||
"exit_code": 0,
|
||||
"output_channel": "stdout",
|
||||
"sample_command": [
|
||||
"herdr",
|
||||
"agent",
|
||||
"wait",
|
||||
"creator-agy-01",
|
||||
"--until",
|
||||
"working",
|
||||
"--timeout",
|
||||
"1000"
|
||||
],
|
||||
"evidence_reference": ".mam/jobs/129a95db/evidence/wait_agy_working_immediate.json"
|
||||
},
|
||||
{
|
||||
"outcome": "timeout",
|
||||
"exit_code": 1,
|
||||
"output_channel": "stderr",
|
||||
"error_code": "timeout",
|
||||
"sample_command": [
|
||||
"herdr",
|
||||
"agent",
|
||||
"wait",
|
||||
"creator-agy-01",
|
||||
"--until",
|
||||
"done",
|
||||
"--timeout",
|
||||
"50"
|
||||
],
|
||||
"evidence_reference": ".mam/jobs/129a95db/evidence/timeout_stderr.json"
|
||||
},
|
||||
{
|
||||
"outcome": "agent_not_found",
|
||||
"exit_code": 1,
|
||||
"output_channel": "stderr",
|
||||
"error_code": "agent_not_found",
|
||||
"sample_command": [
|
||||
"herdr",
|
||||
"agent",
|
||||
"wait",
|
||||
"non-existent-agent-target",
|
||||
"--until",
|
||||
"idle",
|
||||
"--timeout",
|
||||
"50"
|
||||
],
|
||||
"evidence_reference": ".mam/jobs/129a95db/evidence/unknown_target_stderr.txt"
|
||||
}
|
||||
]
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/bash
|
||||
# Stub interpreter used by the Phase 1 characterization tests.
|
||||
# Records argv (one invocation per line) and replays a configured stdout/exit code.
|
||||
if [ -n "${STUB_ARGV_LOG:-}" ]; then
|
||||
printf '%s\n' "$*" >> "$STUB_ARGV_LOG"
|
||||
fi
|
||||
if [ -n "${STUB_STDOUT:-}" ]; then
|
||||
printf '%s\n' "$STUB_STDOUT"
|
||||
fi
|
||||
exit "${STUB_EXIT:-0}"
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Stub monitor used by the Phase 1 watchdog characterization tests.
|
||||
# Sleeps so the spawned PID stays observable; never touches the registry.
|
||||
echo "fake reconcile started: $*"
|
||||
sleep 60
|
||||
@@ -0,0 +1,18 @@
|
||||
usage: job_subscriber.py [-h] (--job JOB | --wait-any) [--timeout TIMEOUT]
|
||||
[--idle-timeout IDLE_TIMEOUT] [--expect-retention]
|
||||
[--registry-dir REGISTRY_DIR] [-v]
|
||||
|
||||
Subscribe to Job events on MQTT
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
--job JOB job id to watch
|
||||
--wait-any watch every pending/running job in the registry
|
||||
--timeout TIMEOUT wall-clock budget in seconds (default: job.timeout_sec
|
||||
or 3600)
|
||||
--idle-timeout IDLE_TIMEOUT
|
||||
max seconds with no new event (default:
|
||||
job.idle_timeout_sec or 120)
|
||||
--expect-retention warn if no retained terminal event arrives promptly
|
||||
--registry-dir REGISTRY_DIR
|
||||
-v, --verbose
|
||||
@@ -0,0 +1,33 @@
|
||||
usage: publish_event.py [-h] (--job JOB | --pick-pending)
|
||||
[--agent-session AGENT_SESSION]
|
||||
[--event {started,permission_required,progress,completed,error,cancelled}]
|
||||
[--detail DETAIL] [--data DATA] [--retained]
|
||||
[--registry-dir REGISTRY_DIR] [--attempts ATTEMPTS]
|
||||
[--finalize-report PATH]
|
||||
[--side-effect KIND=REFERENCE]
|
||||
[--attempt-id ATTEMPT_ID] [-v]
|
||||
|
||||
Publish a Job event to MQTT
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
--job JOB job id to publish for
|
||||
--pick-pending auto-select a pending job for --agent-session
|
||||
--agent-session AGENT_SESSION
|
||||
session label used with --pick-pending
|
||||
--event {started,permission_required,progress,completed,error,cancelled}
|
||||
--detail DETAIL
|
||||
--data DATA optional JSON object string
|
||||
--retained force retain=True (auto for completed/error)
|
||||
--registry-dir REGISTRY_DIR
|
||||
--attempts ATTEMPTS
|
||||
--finalize-report PATH
|
||||
atomically finalize this report and declare its
|
||||
completion manifest before publishing
|
||||
--side-effect KIND=REFERENCE
|
||||
evidence for an external side effect this job
|
||||
performed
|
||||
--attempt-id ATTEMPT_ID
|
||||
attempt this finalization belongs to (default: the
|
||||
job's current iteration)
|
||||
-v, --verbose
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
usage: registry.py [-h] [--registry-dir REGISTRY_DIR]
|
||||
{register,list,get,status,update,get-feedback,pick,logs}
|
||||
...
|
||||
|
||||
multi-agent-mux-delegate-job registry CLI
|
||||
|
||||
positional arguments:
|
||||
{register,list,get,status,update,get-feedback,pick,logs}
|
||||
register create a pending job; prints the job id
|
||||
list list jobs (optionally by status)
|
||||
get print one job record as JSON
|
||||
status set a job status
|
||||
update update a job record
|
||||
get-feedback get the last feedback detail (completed/error) for a
|
||||
job
|
||||
pick claim a pending job for a session; prints id
|
||||
logs show the persistent audit log for a job, or --list
|
||||
every logged job
|
||||
|
||||
optional arguments:
|
||||
-h, --help show this help message and exit
|
||||
--registry-dir REGISTRY_DIR
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shim context harness (PV-4, issue #5 phase 1).
|
||||
|
||||
Runs ONE import scenario in a fresh interpreter and prints a JSON report of
|
||||
which tree every relevant module resolved to. Kept out of the test module on
|
||||
purpose: each scenario needs a virgin ``sys.modules``, so it must be a
|
||||
subprocess.
|
||||
|
||||
Usage: shim_contexts.py <scenario> <real> <sandbox> <sandbox2> <freeze>
|
||||
|
||||
Tree arguments are skills-directory roots, each containing ``lib_py/`` and
|
||||
``multi-agent-mux-delegate-job/scripts/``. The scenarios mirror the four real
|
||||
execution contexts: pytest in-process (A/A_pe/B/B2/D/E), standalone with no
|
||||
ambient tree (C1/C1_pe), and a frozen copy invoked while PYTHONPATH still points
|
||||
at the live tree (C2/C2_pe) -- the shape every delegated agent job actually runs.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
SCENARIO = sys.argv[1]
|
||||
TREES = {
|
||||
"real": os.path.abspath(sys.argv[2]),
|
||||
"sandbox": os.path.abspath(sys.argv[3]),
|
||||
"sandbox2": os.path.abspath(sys.argv[4]),
|
||||
"freeze": os.path.abspath(sys.argv[5]),
|
||||
}
|
||||
|
||||
|
||||
def scripts(tree):
|
||||
return os.path.join(TREES[tree], "multi-agent-mux-delegate-job", "scripts")
|
||||
|
||||
|
||||
def which_tree(path):
|
||||
if not path:
|
||||
return None
|
||||
path = os.path.abspath(path)
|
||||
for name, root in TREES.items():
|
||||
if path == root or path.startswith(root + os.sep):
|
||||
return name
|
||||
return "OUTSIDE:" + path
|
||||
|
||||
|
||||
def report():
|
||||
out = {"scenario": SCENARIO}
|
||||
mods = sys.modules
|
||||
|
||||
def note(key, mod, attr="__file__"):
|
||||
m = mods.get(mod)
|
||||
if m is None:
|
||||
out[key] = None
|
||||
return
|
||||
if attr == "__path__":
|
||||
out[key] = which_tree(list(getattr(m, "__path__", [None]))[0])
|
||||
else:
|
||||
out[key] = which_tree(getattr(m, attr, None))
|
||||
|
||||
note("alias_mqtt_common", "mqtt_common")
|
||||
note("alias_publish_event", "publish_event")
|
||||
note("pkg_delegation", "lib_py.delegation", "__path__")
|
||||
note("pkg_mqtt_common", "lib_py.delegation.mqtt_common")
|
||||
note("pkg_publish_event", "lib_py.delegation.publish_event")
|
||||
note("lib_py", "lib_py", "__path__")
|
||||
note("lib_py_agents", "lib_py.agents", "__path__")
|
||||
|
||||
# identity: the alias and the package attribute must be one object
|
||||
ident = {}
|
||||
for name in ("mqtt_common", "publish_event"):
|
||||
alias = mods.get(name)
|
||||
pkg_mod = mods.get("lib_py.delegation." + name)
|
||||
ident[name] = None if alias is None else (alias is pkg_mod)
|
||||
out["identity"] = ident
|
||||
|
||||
# PV-3 shape: sys.path entries that would contribute a lib_py package
|
||||
out["lib_py_path_entries"] = [
|
||||
which_tree(e) for e in sys.path
|
||||
if e and os.path.isdir(os.path.join(e, "lib_py"))
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
if SCENARIO in ("A", "A_pe", "B", "B2", "D", "E"):
|
||||
sys.path.insert(0, TREES["real"]) # models conftest.py inserting the real tree
|
||||
|
||||
if SCENARIO == "A":
|
||||
sys.path.insert(0, scripts("sandbox"))
|
||||
import mqtt_common, publish_event # noqa: F401
|
||||
elif SCENARIO == "A_pe":
|
||||
sys.path.insert(0, scripts("sandbox"))
|
||||
import publish_event # noqa: F401
|
||||
elif SCENARIO == "B":
|
||||
import lib_py.agents # noqa: F401
|
||||
sys.path.insert(0, scripts("sandbox"))
|
||||
import mqtt_common, publish_event # noqa: F401
|
||||
elif SCENARIO == "B2":
|
||||
import lib_py.delegation.mqtt_common # noqa: F401
|
||||
sys.path.insert(0, scripts("sandbox"))
|
||||
import mqtt_common, publish_event # noqa: F401
|
||||
elif SCENARIO == "D":
|
||||
sys.path.insert(0, scripts("sandbox"))
|
||||
import mqtt_common # noqa: F401
|
||||
sys.path.insert(0, scripts("sandbox2"))
|
||||
import publish_event # noqa: F401
|
||||
elif SCENARIO == "E":
|
||||
sys.path.insert(0, scripts("sandbox"))
|
||||
import mqtt_common # noqa: F401
|
||||
from lib_py.delegation import publish_event # noqa: F401
|
||||
elif SCENARIO == "C1":
|
||||
sys.path.insert(0, scripts("freeze"))
|
||||
import mqtt_common, publish_event # noqa: F401
|
||||
elif SCENARIO == "C1_pe":
|
||||
sys.path.insert(0, scripts("freeze"))
|
||||
import publish_event # noqa: F401
|
||||
elif SCENARIO == "C2":
|
||||
sys.path.insert(0, scripts("freeze")) # PYTHONPATH=real set by caller
|
||||
import mqtt_common, publish_event # noqa: F401
|
||||
elif SCENARIO == "C2_pe":
|
||||
sys.path.insert(0, scripts("freeze")) # PYTHONPATH=real set by caller
|
||||
import publish_event # noqa: F401
|
||||
elif SCENARIO == "K3":
|
||||
sys.path.insert(0, scripts("real"))
|
||||
import mqtt_common, publish_event # noqa: F401
|
||||
else:
|
||||
raise SystemExit("unknown scenario: " + SCENARIO)
|
||||
|
||||
print(json.dumps(report()))
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"scenario_id": "attempt_reuse",
|
||||
"backend": "backend_agnostic",
|
||||
"provenance": "simulated",
|
||||
"description": "The same session was reused for a new attempt whose execution was never observed. Idle here is leftover quiescence from the prior attempt.",
|
||||
"dispatch_context": {
|
||||
"job_id": "test-job-attempt-reuse-01",
|
||||
"attempt_id": 2,
|
||||
"agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6",
|
||||
"pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z",
|
||||
"dispatch_ack": true,
|
||||
"execution_anchor_observed": false
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 0,
|
||||
"source_kind": "event",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"event": "started",
|
||||
"job_id": "test-job-finalized-01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 5000,
|
||||
"source_kind": "filesystem",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"report_manifest": {
|
||||
"report_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"attempt_id": 1,
|
||||
"finalized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 6000,
|
||||
"source_kind": "herdr",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"agent_status": "idle",
|
||||
"interactive_ready": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"filesystem": {
|
||||
"report_path": ".mam/jobs/test-job-finalized-01/report-final.md",
|
||||
"exists": true,
|
||||
"manifest_committed": true,
|
||||
"hash_verified": true
|
||||
},
|
||||
"expected_policy": {
|
||||
"decision": "hold",
|
||||
"reason": "no execution anchor observed for the current attempt",
|
||||
"policy_version": "2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"scenario_id": "blocked_permission",
|
||||
"backend": "backend_agnostic",
|
||||
"provenance": "simulated",
|
||||
"description": "Native status blocked on interactive permission prompt. Completion forbidden.",
|
||||
"dispatch_context": {
|
||||
"job_id": "test-job-blocked-01",
|
||||
"attempt_id": 1,
|
||||
"agent_session_uuid": "1c53dd89-81fc-49a6-b15e-bef3dfb61697",
|
||||
"pane_process_generation": "w2X:p3:5678:2026-09-06T00:00:00Z",
|
||||
"dispatch_ack": true,
|
||||
"execution_anchor_observed": true
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 0,
|
||||
"source_kind": "herdr",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"agent_status": "blocked",
|
||||
"interactive_ready": false,
|
||||
"matched_rule": "permission_prompt"
|
||||
}
|
||||
}
|
||||
],
|
||||
"filesystem": {
|
||||
"report_path": ".mam/jobs/test-job-blocked-01/report-final.md",
|
||||
"exists": false,
|
||||
"manifest_committed": false
|
||||
},
|
||||
"expected_policy": {
|
||||
"decision": "hold_blocked",
|
||||
"reason": "agent terminal is blocked awaiting user permission",
|
||||
"policy_version": "2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"scenario_id": "finalized_candidate",
|
||||
"backend": "backend_agnostic",
|
||||
"provenance": "simulated",
|
||||
"description": "Execution anchor verified, native status idle/done, atomic rename complete with valid SHA-256 commit manifest. Inferred candidate approved for CAS. The collector supplies the four identifiers alongside the native snapshot, which is what allows the completion to be attributed.",
|
||||
"dispatch_context": {
|
||||
"job_id": "test-job-finalized-01",
|
||||
"attempt_id": 1,
|
||||
"agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6",
|
||||
"pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z",
|
||||
"dispatch_ack": true,
|
||||
"execution_anchor_observed": true
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 0,
|
||||
"source_kind": "event",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"event": "started",
|
||||
"job_id": "test-job-finalized-01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 5000,
|
||||
"source_kind": "filesystem",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"report_manifest": {
|
||||
"report_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"attempt_id": 1,
|
||||
"finalized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 6000,
|
||||
"source_kind": "herdr",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"agent_status": "idle",
|
||||
"interactive_ready": true
|
||||
},
|
||||
"collector_attribution": {
|
||||
"job_id": "test-job-finalized-01",
|
||||
"attempt_id": 1,
|
||||
"agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6",
|
||||
"pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z"
|
||||
}
|
||||
}
|
||||
],
|
||||
"filesystem": {
|
||||
"report_path": ".mam/jobs/test-job-finalized-01/report-final.md",
|
||||
"exists": true,
|
||||
"manifest_committed": true,
|
||||
"hash_verified": true
|
||||
},
|
||||
"expected_policy": {
|
||||
"decision": "candidate_approved",
|
||||
"reason": "all 4-fold attribution, fresh idle status, and atomic finalization manifest verified",
|
||||
"policy_version": "2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"scenario_id": "initial_idle",
|
||||
"backend": "backend_agnostic",
|
||||
"provenance": "simulated",
|
||||
"description": "Dispatch record exists and native status is idle, but no execution anchor observed since dispatch_ack. Inferred completion must be held.",
|
||||
"dispatch_context": {
|
||||
"job_id": "test-job-init-idle-01",
|
||||
"attempt_id": 1,
|
||||
"agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6",
|
||||
"pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z",
|
||||
"dispatch_ack": true,
|
||||
"execution_anchor_observed": false
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 0,
|
||||
"source_kind": "event",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"event": "dispatched",
|
||||
"job_id": "test-job-init-idle-01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 500,
|
||||
"source_kind": "herdr",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"agent_status": "idle",
|
||||
"interactive_ready": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"filesystem": {
|
||||
"report_path": ".mam/jobs/test-job-init-idle-01/report-final.md",
|
||||
"exists": false,
|
||||
"manifest_committed": false
|
||||
},
|
||||
"expected_policy": {
|
||||
"decision": "hold",
|
||||
"reason": "no execution anchor observed for this attempt",
|
||||
"policy_version": "2.0.0"
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"description": "Multi-channel Watcher composite timeline scenario manifest",
|
||||
"scenarios": {
|
||||
"initial_idle": {
|
||||
"file": "initial_idle.json",
|
||||
"provenance": "simulated",
|
||||
"expected_decision": "hold",
|
||||
"description": "Dispatch record exists and native status is idle, but no execution anchor observed since dispatch_ack. Inferred completion must be held."
|
||||
},
|
||||
"tool_running": {
|
||||
"file": "tool_running.json",
|
||||
"provenance": "simulated",
|
||||
"expected_decision": "hold",
|
||||
"description": "Native status working or interactive_ready with active background tool execution. Inferred completion must be held."
|
||||
},
|
||||
"report_pause_15s": {
|
||||
"file": "report_pause_15s.json",
|
||||
"provenance": "simulated",
|
||||
"expected_decision": "hold",
|
||||
"description": "Partial report written and stable for >= 15s debounce, but lacks atomic finalization manifest. Completion must be held."
|
||||
},
|
||||
"finalized_candidate": {
|
||||
"file": "finalized_candidate.json",
|
||||
"provenance": "simulated",
|
||||
"expected_decision": "candidate_approved",
|
||||
"description": "Execution anchor verified, native status idle/done, atomic rename complete with valid SHA-256 commit manifest. Inferred candidate approved for CAS."
|
||||
},
|
||||
"blocked_permission": {
|
||||
"file": "blocked_permission.json",
|
||||
"provenance": "simulated",
|
||||
"expected_decision": "hold_blocked",
|
||||
"description": "Native status blocked on interactive permission prompt. Completion forbidden."
|
||||
},
|
||||
"transport_disconnected": {
|
||||
"file": "transport_disconnected.json",
|
||||
"provenance": "simulated",
|
||||
"expected_decision": "hold_reconnect",
|
||||
"description": "Herdr socket connection failure or freshness gap. Cached evidence invalidated; must reconnect and resnapshot."
|
||||
},
|
||||
"restarted_generation": {
|
||||
"file": "restarted_generation.json",
|
||||
"provenance": "simulated",
|
||||
"expected_decision": "reject_stale",
|
||||
"description": "Pane process restarted with new PID/start time; stale attempt rejected."
|
||||
},
|
||||
"race_double_dispatch": {
|
||||
"file": "race_double_dispatch.json",
|
||||
"provenance": "simulated",
|
||||
"expected_decision": "reject_stale",
|
||||
"description": "Two dispatches raced; the observed signal carries the previous process generation. The stale attempt must not decide anything."
|
||||
},
|
||||
"attempt_reuse": {
|
||||
"file": "attempt_reuse.json",
|
||||
"provenance": "simulated",
|
||||
"expected_decision": "hold",
|
||||
"description": "The same session was reused for a new attempt whose execution was never observed. Idle here is leftover quiescence from the prior attempt."
|
||||
},
|
||||
"partial_report": {
|
||||
"file": "partial_report.json",
|
||||
"provenance": "simulated",
|
||||
"expected_decision": "hold",
|
||||
"description": "A report is mid-write: bytes on disk but no atomic rename and no commit manifest."
|
||||
},
|
||||
"unknown_status": {
|
||||
"file": "unknown_status.json",
|
||||
"provenance": "simulated",
|
||||
"expected_decision": "hold",
|
||||
"description": "Native status is unknown. An unreadable state is not a quiescent one."
|
||||
},
|
||||
"restart_midflight": {
|
||||
"file": "restart_midflight.json",
|
||||
"provenance": "simulated",
|
||||
"expected_decision": "reject_stale",
|
||||
"description": "The pane was restarted mid-flight, so the idle signal comes from a different pane than the one dispatched to."
|
||||
},
|
||||
"side_effect_unproven": {
|
||||
"file": "side_effect_unproven.json",
|
||||
"provenance": "simulated",
|
||||
"expected_decision": "hold",
|
||||
"description": "The job declared an external side effect but the manifest carries no evidence for it. Partial evidence is no evidence."
|
||||
},
|
||||
"no_verdict_fail_closed": {
|
||||
"file": "no_verdict_fail_closed.json",
|
||||
"provenance": "simulated",
|
||||
"expected_decision": "hold",
|
||||
"description": "The collector could not establish the agent session, so attribution cannot be completed. An undecidable case must hold."
|
||||
},
|
||||
"notification_crash_window": {
|
||||
"file": "notification_crash_window.json",
|
||||
"provenance": "simulated",
|
||||
"expected_decision": "hold",
|
||||
"description": "The agent's state became unreadable between finalization and notification. Unknown is not quiescent."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"scenario_id": "no_verdict_fail_closed",
|
||||
"backend": "backend_agnostic",
|
||||
"provenance": "simulated",
|
||||
"description": "The collector could not establish the agent session, so attribution cannot be completed. An undecidable case must hold.",
|
||||
"dispatch_context": {
|
||||
"job_id": "test-job-no-verdict-01",
|
||||
"attempt_id": 1,
|
||||
"agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6",
|
||||
"pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z",
|
||||
"dispatch_ack": true,
|
||||
"execution_anchor_observed": true
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 0,
|
||||
"source_kind": "event",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"event": "started",
|
||||
"job_id": "test-job-no-verdict-01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 5000,
|
||||
"source_kind": "filesystem",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"report_manifest": {
|
||||
"report_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"attempt_id": 1,
|
||||
"finalized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 6000,
|
||||
"source_kind": "herdr",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"agent_status": "idle",
|
||||
"interactive_ready": true
|
||||
},
|
||||
"collector_attribution": {
|
||||
"job_id": "test-job-no-verdict-01",
|
||||
"attempt_id": 1,
|
||||
"pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z"
|
||||
}
|
||||
}
|
||||
],
|
||||
"filesystem": {
|
||||
"report_path": ".mam/jobs/test-job-finalized-01/report-final.md",
|
||||
"exists": true,
|
||||
"manifest_committed": true,
|
||||
"hash_verified": true
|
||||
},
|
||||
"expected_policy": {
|
||||
"decision": "hold",
|
||||
"reason": "no observed agent_session_uuid to attribute this completion to",
|
||||
"policy_version": "2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"scenario_id": "notification_crash_window",
|
||||
"backend": "backend_agnostic",
|
||||
"provenance": "simulated",
|
||||
"description": "The agent's state became unreadable between finalization and notification. Unknown is not quiescent.",
|
||||
"dispatch_context": {
|
||||
"job_id": "test-job-notify-crash-01",
|
||||
"attempt_id": 1,
|
||||
"agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6",
|
||||
"pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z",
|
||||
"dispatch_ack": true,
|
||||
"execution_anchor_observed": true
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 0,
|
||||
"source_kind": "event",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"event": "started",
|
||||
"job_id": "test-job-notify-crash-01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 5000,
|
||||
"source_kind": "filesystem",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"report_manifest": {
|
||||
"report_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"attempt_id": 1,
|
||||
"finalized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 6000,
|
||||
"source_kind": "herdr",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"agent_status": "unknown",
|
||||
"interactive_ready": false
|
||||
},
|
||||
"collector_attribution": {
|
||||
"job_id": "test-job-notify-crash-01",
|
||||
"attempt_id": 1,
|
||||
"agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6",
|
||||
"pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z"
|
||||
}
|
||||
}
|
||||
],
|
||||
"filesystem": {
|
||||
"report_path": ".mam/jobs/test-job-finalized-01/report-final.md",
|
||||
"exists": true,
|
||||
"manifest_committed": true,
|
||||
"hash_verified": true
|
||||
},
|
||||
"expected_policy": {
|
||||
"decision": "hold",
|
||||
"reason": "agent status is unknown; fail closed",
|
||||
"policy_version": "2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"scenario_id": "partial_report",
|
||||
"backend": "backend_agnostic",
|
||||
"provenance": "simulated",
|
||||
"description": "A report is mid-write: bytes on disk but no atomic rename and no commit manifest.",
|
||||
"dispatch_context": {
|
||||
"job_id": "test-job-partial-report-01",
|
||||
"attempt_id": 1,
|
||||
"agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6",
|
||||
"pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z",
|
||||
"dispatch_ack": true,
|
||||
"execution_anchor_observed": true
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 0,
|
||||
"source_kind": "event",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"event": "started",
|
||||
"job_id": "test-job-finalized-01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 5000,
|
||||
"source_kind": "filesystem",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"report_manifest": null,
|
||||
"bytes": 512,
|
||||
"phase": "writing"
|
||||
}
|
||||
},
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 6000,
|
||||
"source_kind": "herdr",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"agent_status": "idle",
|
||||
"interactive_ready": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"filesystem": {
|
||||
"report_path": ".mam/jobs/test-job-finalized-01/report-final.md",
|
||||
"exists": true,
|
||||
"manifest_committed": false,
|
||||
"hash_verified": false
|
||||
},
|
||||
"expected_policy": {
|
||||
"decision": "hold",
|
||||
"reason": "report is still being written; no finalization manifest",
|
||||
"policy_version": "2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"scenario_id": "race_double_dispatch",
|
||||
"backend": "backend_agnostic",
|
||||
"provenance": "simulated",
|
||||
"description": "Two dispatches raced; the observed signal carries the previous process generation. The stale attempt must not decide anything.",
|
||||
"dispatch_context": {
|
||||
"job_id": "test-job-race-01",
|
||||
"attempt_id": 2,
|
||||
"agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6",
|
||||
"pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z",
|
||||
"dispatch_ack": true,
|
||||
"execution_anchor_observed": true
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 0,
|
||||
"source_kind": "event",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"event": "started",
|
||||
"job_id": "test-job-finalized-01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 5000,
|
||||
"source_kind": "filesystem",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"report_manifest": {
|
||||
"report_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"attempt_id": 1,
|
||||
"finalized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 6000,
|
||||
"source_kind": "herdr",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"agent_status": "idle",
|
||||
"interactive_ready": true,
|
||||
"pane_process_generation": "w2X:p9:9999:2026-09-06T00:00:00Z"
|
||||
}
|
||||
}
|
||||
],
|
||||
"filesystem": {
|
||||
"report_path": ".mam/jobs/test-job-finalized-01/report-final.md",
|
||||
"exists": true,
|
||||
"manifest_committed": true,
|
||||
"hash_verified": true
|
||||
},
|
||||
"expected_policy": {
|
||||
"decision": "reject_stale",
|
||||
"reason": "signal belongs to a superseded process generation",
|
||||
"policy_version": "2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"scenario_id": "report_pause_15s",
|
||||
"backend": "backend_agnostic",
|
||||
"provenance": "simulated",
|
||||
"description": "Partial report written and stable for >= 15s debounce, but lacks atomic finalization manifest. Completion must be held.",
|
||||
"dispatch_context": {
|
||||
"job_id": "test-job-pause-01",
|
||||
"attempt_id": 1,
|
||||
"agent_session_uuid": "1c53dd89-81fc-49a6-b15e-bef3dfb61697",
|
||||
"pane_process_generation": "w2X:p3:5678:2026-09-06T00:00:00Z",
|
||||
"dispatch_ack": true,
|
||||
"execution_anchor_observed": true
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 0,
|
||||
"source_kind": "filesystem",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"file": "report-final.md.tmp",
|
||||
"bytes": 250,
|
||||
"phase": "writing"
|
||||
}
|
||||
},
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 16000,
|
||||
"source_kind": "filesystem",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"file": "report-final.md.tmp",
|
||||
"bytes": 250,
|
||||
"mtime_stale_sec": 16.0
|
||||
}
|
||||
},
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 16100,
|
||||
"source_kind": "herdr",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"agent_status": "working",
|
||||
"interactive_ready": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"filesystem": {
|
||||
"report_path": ".mam/jobs/test-job-pause-01/report-final.md",
|
||||
"exists": false,
|
||||
"tmp_file_exists": true,
|
||||
"manifest_committed": false
|
||||
},
|
||||
"expected_policy": {
|
||||
"decision": "hold",
|
||||
"reason": "debounce elapsed on partial file, but report is unfinalized without manifest",
|
||||
"policy_version": "2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"scenario_id": "restart_midflight",
|
||||
"backend": "backend_agnostic",
|
||||
"provenance": "simulated",
|
||||
"description": "The pane was restarted mid-flight, so the idle signal comes from a different pane than the one dispatched to.",
|
||||
"dispatch_context": {
|
||||
"job_id": "test-job-restart-midflight-01",
|
||||
"attempt_id": 1,
|
||||
"agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6",
|
||||
"pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z",
|
||||
"dispatch_ack": true,
|
||||
"execution_anchor_observed": true
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 0,
|
||||
"source_kind": "event",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"event": "started",
|
||||
"job_id": "test-job-finalized-01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 5000,
|
||||
"source_kind": "filesystem",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"report_manifest": {
|
||||
"report_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"attempt_id": 1,
|
||||
"finalized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 6000,
|
||||
"source_kind": "herdr",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"agent_status": "idle",
|
||||
"pane_id": "w2X:p7"
|
||||
}
|
||||
}
|
||||
],
|
||||
"filesystem": {
|
||||
"report_path": ".mam/jobs/test-job-finalized-01/report-final.md",
|
||||
"exists": true,
|
||||
"manifest_committed": true,
|
||||
"hash_verified": true
|
||||
},
|
||||
"expected_policy": {
|
||||
"decision": "reject_stale",
|
||||
"reason": "signal belongs to a different pane",
|
||||
"policy_version": "2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"scenario_id": "restarted_generation",
|
||||
"backend": "backend_agnostic",
|
||||
"provenance": "simulated",
|
||||
"description": "Pane process restarted with new PID/start time; stale attempt rejected.",
|
||||
"dispatch_context": {
|
||||
"job_id": "test-job-restarted-01",
|
||||
"attempt_id": 1,
|
||||
"agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6",
|
||||
"pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z",
|
||||
"dispatch_ack": true,
|
||||
"execution_anchor_observed": false
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 0,
|
||||
"source_kind": "herdr",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"pane_id": "w2X:p1",
|
||||
"pane_process_generation": "w2X:p1:9999:2026-09-06T01:00:00Z",
|
||||
"agent_status": "idle"
|
||||
}
|
||||
}
|
||||
],
|
||||
"filesystem": {
|
||||
"report_path": ".mam/jobs/test-job-restarted-01/report-final.md",
|
||||
"exists": false,
|
||||
"manifest_committed": false
|
||||
},
|
||||
"expected_policy": {
|
||||
"decision": "reject_stale",
|
||||
"reason": "observed process generation does not match active dispatch attempt",
|
||||
"policy_version": "2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"scenario_id": "side_effect_unproven",
|
||||
"backend": "backend_agnostic",
|
||||
"provenance": "simulated",
|
||||
"description": "The job declared an external side effect but the manifest carries no evidence for it. Partial evidence is no evidence.",
|
||||
"dispatch_context": {
|
||||
"job_id": "test-job-sideeffect-missing-01",
|
||||
"attempt_id": 1,
|
||||
"agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6",
|
||||
"pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z",
|
||||
"dispatch_ack": true,
|
||||
"execution_anchor_observed": true
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 0,
|
||||
"source_kind": "event",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"event": "started",
|
||||
"job_id": "test-job-sideeffect-missing-01"
|
||||
}
|
||||
},
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 5000,
|
||||
"source_kind": "filesystem",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"report_manifest": {
|
||||
"report_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
|
||||
"attempt_id": 1,
|
||||
"finalized": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 6000,
|
||||
"source_kind": "herdr",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"agent_status": "idle",
|
||||
"interactive_ready": true
|
||||
},
|
||||
"collector_attribution": {
|
||||
"job_id": "test-job-sideeffect-missing-01",
|
||||
"attempt_id": 1,
|
||||
"agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6",
|
||||
"pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z"
|
||||
}
|
||||
}
|
||||
],
|
||||
"filesystem": {
|
||||
"report_path": ".mam/jobs/test-job-finalized-01/report-final.md",
|
||||
"exists": true,
|
||||
"manifest_committed": true,
|
||||
"hash_verified": true,
|
||||
"side_effects_verified": false
|
||||
},
|
||||
"expected_policy": {
|
||||
"decision": "hold",
|
||||
"reason": "declared side effect has no evidence in the commit manifest",
|
||||
"policy_version": "2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"scenario_id": "tool_running",
|
||||
"backend": "backend_agnostic",
|
||||
"provenance": "simulated",
|
||||
"description": "Native status working or interactive_ready with active background tool execution. Inferred completion must be held.",
|
||||
"dispatch_context": {
|
||||
"job_id": "test-job-tool-running-01",
|
||||
"attempt_id": 1,
|
||||
"agent_session_uuid": "1c53dd89-81fc-49a6-b15e-bef3dfb61697",
|
||||
"pane_process_generation": "w2X:p3:5678:2026-09-06T00:00:00Z",
|
||||
"dispatch_ack": true,
|
||||
"execution_anchor_observed": true
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 0,
|
||||
"source_kind": "herdr",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"agent_status": "working",
|
||||
"interactive_ready": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 200,
|
||||
"source_kind": "harness",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"tool": "bash",
|
||||
"active": true,
|
||||
"command": "pytest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"filesystem": {
|
||||
"report_path": ".mam/jobs/test-job-tool-running-01/report-final.md",
|
||||
"exists": false,
|
||||
"manifest_committed": false
|
||||
},
|
||||
"expected_policy": {
|
||||
"decision": "hold",
|
||||
"reason": "external tool execution currently active",
|
||||
"policy_version": "2.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"schema_version": "1.0.0",
|
||||
"scenario_id": "transport_disconnected",
|
||||
"backend": "backend_agnostic",
|
||||
"provenance": "simulated",
|
||||
"description": "Herdr socket connection failure or freshness gap. Cached evidence invalidated; must reconnect and resnapshot.",
|
||||
"dispatch_context": {
|
||||
"job_id": "test-job-disconnect-01",
|
||||
"attempt_id": 1,
|
||||
"agent_session_uuid": "7d5700d0-bab1-4f65-9665-6b887396a9a6",
|
||||
"pane_process_generation": "w2X:p1:1234:2026-09-06T00:00:00Z",
|
||||
"dispatch_ack": true,
|
||||
"execution_anchor_observed": true
|
||||
},
|
||||
"timeline": [
|
||||
{
|
||||
"collector_clock_id": "CLOCK_MONOTONIC",
|
||||
"relative_ms": 0,
|
||||
"source_kind": "herdr",
|
||||
"source_provenance": "simulated",
|
||||
"payload": {
|
||||
"error": "connection_refused",
|
||||
"transport_healthy": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"filesystem": {
|
||||
"report_path": ".mam/jobs/test-job-disconnect-01/report-final.md",
|
||||
"exists": false,
|
||||
"manifest_committed": false
|
||||
},
|
||||
"expected_policy": {
|
||||
"decision": "hold_reconnect",
|
||||
"reason": "transport connection failure; previous idle observations cannot be trusted",
|
||||
"policy_version": "2.0.0"
|
||||
}
|
||||
}
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user