- Introduce finalize_unfinished in multi-agent-mux-delegate-job for fail-closed atomic transitions - Enforce CAS dispatch_token and attempt_id pinning across engine, CLI, and lifecycle scripts - Integrate Herdr wait_for_agent_idle turn handshake to prevent prompt loss and race conditions - Resolve bash 3.2 set -u empty array expansion compatibility in lib.sh - Preserve Issue #5 R2 contract in CAS transition engine (engine.py, publish_event.py, service.py) - Add comprehensive regression suites (test_issue6_fail_closed.py, test_issue6_turn_handshake.py) - Bump lockstep framework and skills to v4.4.1 across lib.sh, eager.py, VERSIONS.md, 8 SKILL.md - Synchronize documentation (IMPROVEMENTS.md, MESSAGING.md, state-transitions.md, task.md, phase5-e2e.md) - Unanimous PASS achieved across 4 reviewer team leaders (Claude, Codex, OpenCode, Grok) Closes #6
140 lines
14 KiB
Markdown
140 lines
14 KiB
Markdown
# 📑 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`를 갖게 되는 순간 실패하며, 그때가 재검토 시점이다.
|
|
|
|
> **Issue #6 업데이트 (v4.4.1)**: Issue #6(작업 분기 및 장애 방지)에서 `attempt_id`가 job 레코드의 1급 필드로 정식 승격되었습니다. `register_job` 시 1로 기본 초기화되고 `redispatch` 시 단조 증가하여 워커 실행 토큰과 원자적으로 바인딩됩니다. 이에 따라 `test_x03`도 `attempt_id == 1`을 검증하도록 갱신되었습니다.
|
|
|
|
## 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` 자동완료는 정지 상태 실측 증거 확보 후 별도 판단.
|