Files
multi-agent-mux/docs/contracts/issue-5/phase2b-autocomplete.md
T
GodopuandClaude Opus 5 9a651ccc71 docs(issue-5): record atomic attribution and correct my earlier claim
The state-transition spec gains the rule that attempt identity lives in the
record, because a precondition can only be atomic with the commit if the
transaction can read it under its own lock, and checking attribution beforehand
leaves a window a re-dispatch fits through.

The operations note gets the same, plus the reason it is an opaque token rather
than the iteration counter: iteration already means the loop round, so comparing
a dispatch attempt against it is vacuous when they agree and wrong when they
diverge.

It also marks my earlier section as partly withdrawn rather than quietly editing
it. That section argued a record schema change was unnecessary because the
attempt guard lives in the evidence layer. That was right about comparing
evidence to a dispatch and wrong about atomicity, and the review proved the
difference by execution. The narrower point it made -- why not to reuse
iteration -- still holds and is kept.

Also records why the subscriber now deduplicates on the intent identity: a
replay arrives with a fresh wire sequence, so the monotonic guard cannot see it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd
2026-09-06 23:22:12 +09:00

13 KiB

📑 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).
  • 정리는 실제로 보낸 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 자동완료는 정지 상태 실측 증거 확보 후 별도 판단.