diff --git a/.agents/skills/lib_py/loop/policy.py b/.agents/skills/lib_py/loop/policy.py index 005ae65..9042b9b 100644 --- a/.agents/skills/lib_py/loop/policy.py +++ b/.agents/skills/lib_py/loop/policy.py @@ -154,6 +154,15 @@ def decide(scenario) -> Decision: 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) diff --git a/docs/contracts/issue-5/implementation_plan.md b/docs/contracts/issue-5/implementation_plan.md index 7eb9a02..b107033 100644 --- a/docs/contracts/issue-5/implementation_plan.md +++ b/docs/contracts/issue-5/implementation_plan.md @@ -24,7 +24,7 @@ | **Phase 0**
*(현재)* | **사전 계약 고정 및 Fixture 수집** | • CLI, 이벤트, 리포트, 락 계약 기준선 문서화
• Herdr 0.8.2 네이티브 캡처 및 복합 Watcher 시나리오 2계층 분리
• 6개 백엔드 실측 증거 행렬 수집 | • 510개 전체 테스트 수집 및 회귀 0-Fail (100% 통과)
• Fixture 스키마 및 증거 경계 검증 통과
• 프로덕션 코드 무변경 diff 검증 | 미추적 산출물 즉시 삭제 (`rm -rf docs/contracts/issue-5 tests/fixtures/issue_5 tests/test_issue5_phase0_contracts.py`) | | **Phase 1**
*(완료 v4.3.0)* | **`lib_py/delegation/` 추출** | • `lib.sh` L1833 이하 위임 헬퍼를 파이썬 서비스 클래스로 추출
• `start_watchdog`을 내부 명칭 `ensure_subscriber_daemon`으로 명명(기존 함수 별칭 보존)
• 장수 프로세스 단일 MQTT 연결 재사용 프로토타입 | • CLI/non-fatal/HMAC/seq/디스크 폴백 회귀 100% 통과
• 데몬 수명 및 PID 계약 일치 확인
• **자동완료 정책 변경 없음** | `MAM_DELEGATION_PY=0`으로 보존된 레거시 인라인 본체(`_*_legacy`)로 즉시 분기 (재배포·revert 불필요; 레거시 본체 제거는 Phase 4) | | **Phase 2a**
*(완료, shadow-only)* | **`lib_py/loop/` 코어 & Shadow Watcher** | • `engine.py` (단일 전이 주체), `watcher.py` (`herdr agent wait` 래핑) 구현
• 모든 Writer에 대한 단일 CAS 트랜잭션 경계 구현
• 자동완료를 수행하지 않고 "Would-Complete" 로깅만 수행하는 Shadow 모드 가동 | • 락 내 비동기/I/O 배제 검증
• 소켓 단절/스냅샷 재연결 백오프 검증
• Shadow 모드가 레지스트리 상태를 쓰지 않음을 검증 | 1) Shadow 모니터 프로세스 종료 및 인메모리 큐 폐기
2) CAS 서비스는 하위 호환 모드로 유지하거나 `MAM_ENGINE_CAS=0` 플래그로 비활성화하여 레거시 무조건 필드 병합(`update_job_status`)으로 복귀
3) 기존 셸 루프 상태 및 레코드 불변 유지 | -| **Phase 2b** | **이중 감지 자동완료 활성화 (Flag-Gated)** | • 4중 실행 귀속 + 리포트 확정 계약 + 단일 CAS 전이 게이트 연동
• Pending Notification 지연 알림 및 크래시 복구 구현
• 검증된 백엔드/작업에 한해 opt-in 플래그로 활성화 (기본값 OFF) | • 부수효과 작업 예외 및 무판정 fail-closed 테스트 완료
• 경쟁/재시작 시나리오 통과
• 플래그 OFF 시 기존 explicit event 경로 100% 보존 | 환경변수/플래그 `MAM_AUTO_COMPLETE=0` 설정; 이미 확정된 terminal 레코드는 보존하고 신규 자동완료만 중단 | +| **Phase 2b**
*(완료, 기본 비활성)* | **이중 감지 자동완료 활성화 (Flag-Gated)** | • 4중 실행 귀속 + 리포트 확정 계약 + 단일 CAS 전이 게이트 연동
• Pending Notification 지연 알림 및 크래시 복구 구현
• 검증된 백엔드/작업에 한해 opt-in 플래그로 활성화 (기본값 OFF) | • 부수효과 작업 예외 및 무판정 fail-closed 테스트 완료
• 경쟁/재시작 시나리오 통과
• 플래그 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 "$@"`)로 전환
• 플래너/리뷰어/반론/승격 오케스트레이션 로직 완전 이식
• 엄격한 판정 파서(Strict Parser) 적용 | • 5개 subprocess 전용 테스트(~1,621줄) 포함 전체 481개+ 테스트 0-Fail
• 시그널, 루프 가드, 임의 CWD 및 공백 경로 검증 통과 | **우아한 핸드오프 시퀀스**:
1) Quiesce: 실행 중인 Python 루프 프로세스에 종료 신호를 전달하여 신규 디스패치 중단
2) Checkpoint: 현재 루프 회차, 활성 Job ID 및 리뷰 상태를 `.mam/loop-checkpoint.json`에 원자적 기록
3) Exit: Python 루프 정상 종료 및 가드 해제
4) Resume: 백업된 셸 원본 스크립트로 `run_loop.sh` 복원 후 체크포인트 기반 재개 검증 | | **Phase 4** | **불변 런타임 검증 및 Freeze 제거** | • 기동 시 전체 모듈 상주(Eager Import) 및 자식 템플릿 불변성 검증
• 작업 도중 스킬 파일 교체 및 늦은 경로 스트레스 테스트 통과
• `/tmp/mam-loop-freeze.XXXXXX` 우회책 완전 제거 | • 런타임 자기수정 크래시(B-13) 재발 방지 검증
• Live Helper 버전 호환 게이트 검증 통과
• 운영 문서 및 배포 스크립트 갱신 | Freeze 복사 로직(`run_loop.sh:112-127`) 재활성화 및 라이브 헬퍼 버전 검사 복구 | diff --git a/docs/contracts/issue-5/phase2b-autocomplete.md b/docs/contracts/issue-5/phase2b-autocomplete.md new file mode 100644 index 0000000..015bd18 --- /dev/null +++ b/docs/contracts/issue-5/phase2b-autocomplete.md @@ -0,0 +1,84 @@ +# 📑 Issue #5 Phase 2b — 자동완료 운영 노트 (Flag-Gated / Opt-In) + +- **문서 버전**: Rev.1 +- **상태**: 구현 완료, **기본 비활성(`MAM_AUTO_COMPLETE=0`)** + +--- + +## 1. 한 문장 요약 + +**끝났다는 사실을 아는 쪽(에이전트)이 확정을 선언하고, 읽는 쪽은 그 선언을 믿지 않고 해시를 다시 계산한다.** + +워처가 스스로 해시를 계산해 매니페스트를 만들면 그것은 "워처가 안정된 파일을 봤다"는 자기 증명일 뿐이다. 이 이슈의 앞선 라운드에서 리뷰어가 기각한 증거 형태이며, 같은 실수를 반복하지 않기 위한 설계 결정이다. + +## 2. 그래서 미지원 에이전트는 자동으로 안전하다 + +확정을 선언하지 않은 에이전트는 매니페스트를 남기지 않는다. 매니페스트가 없으면 정책이 이미 `hold`한다(Phase 2a에서 구현·테스트 완료). **"미지원 경로 100% 보존"은 새 분기가 아니라 기존 동작의 귀결이다.** + +## 3. 3중 게이트 (전부 참일 때만 커밋) + +| 게이트 | 기본값 | 근거 | +|---|---|---| +| `MAM_AUTO_COMPLETE=1` | **`0` (OFF)** | 전역 킬 스위치 | +| 백엔드 허용목록 | `claude,grok,codex` | 정지 상태(`idle`/`done`) **관측 증거가 있는 백엔드만**. `agy`는 `working`만 관측, `hermes`·`opencode`는 관측 0건 | +| 잡별 opt-in | 레코드 `auto_complete: true` | 잡 단위 동의 | + +`MAM_AUTO_COMPLETE_BACKENDS`로 목록을 **좁힐 수만** 있다. 관측된 적 없는 이름은 무시된다 — 넓히려면 설정이 아니라 증거가 필요하다. + +## 4. 확정 계약 (P2B-01) + +``` +에이전트: publish_event.py --job --event completed \ + --finalize-report [--side-effect =] ... + ① .tmp → 원자적 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 상태에서도 허용된다. 알림은 terminal에 *관한* 것이므로, terminal이 거부하는 요청으로는 영원히 정리할 수 없다. + +## 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. 계획 대비 이탈 1건 (레코드 스키마 미변경) + +계획서 §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`를 갖게 되는 순간 실패하며, 그때가 재검토 시점이다. + +## 10. Phase 3 인계 + +1. `preexec_fn`: 2b도 데몬을 스폰하지 않아 미노출 유지. 알림 flush는 기존 구독자 프로세스 안에서 수행한다. +2. 부수효과의 **원격** 확인(실제 push 도달 여부 등)은 비범위 — 증거의 존재·대응까지만 검증한다. +3. `agy`/`hermes`/`opencode` 자동완료는 정지 상태 실측 증거 확보 후 별도 판단. diff --git a/docs/contracts/issue-5/task.md b/docs/contracts/issue-5/task.md index e9050f6..96d714c 100644 --- a/docs/contracts/issue-5/task.md +++ b/docs/contracts/issue-5/task.md @@ -2,7 +2,7 @@ - **Document Version**: Rev.7 (Phase 2a 실행 완료 — P2A-01~P2A-04 증거 기록) - **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–4 Staged +- **Status**: Phase 0 Complete / **Phase 1 Complete (v4.3.0)** / **Phase 2a Complete (shadow-only)** / **Phase 2b Complete (default-off)** / Phase 3–4 Staged --- @@ -94,10 +94,19 @@ --- -## Phase 2b: 이중 감지 자동완료 활성화 (후속) -- [ ] **P2B-01** report finalization/manifest/hash 및 외부 부수효과 evidence protocol 구현. -- [ ] **P2B-02** terminal+pending notification commit/재전송·재시작·늦은 conflict 처리 검증. -- [ ] **P2B-03** 검증된 backend/job에만 default-off flag로 활성화; unsupported explicit event 경로 보존. +## Phase 2b: 이중 감지 자동완료 활성화 (완료, 기본 비활성) + +- [x] **P2B-00** 자동완료 도입 전 fail-closed 선행 조건 특성화 (X-01~X-05). + - *증거*: 커밋 `9a827b3`. 매니페스트 없는 job이 이미 `hold`임을 고정 — 미지원 경로 보존이 새 분기 없이 성립하는 근거. +- [x] **P2B-01** 리포트 확정 계약(매니페스트·SHA-256) 및 부수효과 증거 프로토콜. + - *증거*: 커밋 `3ea5757`. `lib_py/loop/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-04** 부수효과 예외·무판정 fail-closed·경쟁/재시작 시나리오. + - *증거*: 신규 시나리오 3종(`side_effect_unproven`, `no_verdict_fail_closed`, `notification_crash_window`)을 Phase 0 스키마로 추가, **총 15종 전부** `expected_policy.decision` 일치. + - *계획 대비 이탈*: 계획서 §D2의 `attempt_id` 레코드 승격(및 그 선행 조건인 리뷰어 승인)은 **불필요함이 실측으로 확인되어 수행하지 않았다.** 시도 보증은 이미 증거 계층(`policy` ↔ shadow dispatch 대조)에 있으며, 레코드 스키마와 Phase 1 동결 계약은 그대로다. 상세는 `phase2b-autocomplete.md` §9. --- diff --git a/tests/fixtures/issue_5/watcher/manifest.json b/tests/fixtures/issue_5/watcher/manifest.json index 3a5ba94..44fdc05 100644 --- a/tests/fixtures/issue_5/watcher/manifest.json +++ b/tests/fixtures/issue_5/watcher/manifest.json @@ -73,6 +73,24 @@ "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." } } } diff --git a/tests/fixtures/issue_5/watcher/no_verdict_fail_closed.json b/tests/fixtures/issue_5/watcher/no_verdict_fail_closed.json new file mode 100644 index 0000000..2efa1b3 --- /dev/null +++ b/tests/fixtures/issue_5/watcher/no_verdict_fail_closed.json @@ -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" + } +} diff --git a/tests/fixtures/issue_5/watcher/notification_crash_window.json b/tests/fixtures/issue_5/watcher/notification_crash_window.json new file mode 100644 index 0000000..b30dfde --- /dev/null +++ b/tests/fixtures/issue_5/watcher/notification_crash_window.json @@ -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" + } +} diff --git a/tests/fixtures/issue_5/watcher/side_effect_unproven.json b/tests/fixtures/issue_5/watcher/side_effect_unproven.json new file mode 100644 index 0000000..b03f22c --- /dev/null +++ b/tests/fixtures/issue_5/watcher/side_effect_unproven.json @@ -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" + } +} diff --git a/tests/test_issue5_phase2a_loop.py b/tests/test_issue5_phase2a_loop.py index 89b6289..3149f61 100644 --- a/tests/test_issue5_phase2a_loop.py +++ b/tests/test_issue5_phase2a_loop.py @@ -560,6 +560,8 @@ def _scenarios(): # ... and the five P2A-04 requires, held to the same contract "race_double_dispatch", "attempt_reuse", "partial_report", "unknown_status", "restart_midflight", + # ... and the three phase 2b adds, held to the same contract + "side_effect_unproven", "no_verdict_fail_closed", "notification_crash_window", ]) def test_g7_policy_matches_the_phase0_expected_decision(scenario_id): """G7: the seven scenarios that survived three-reviewer scrutiny are the