<!-- task=m-agent-task-runtime-target-selector/04+03_failover_budget plan=16 tag=REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_API -->

# Canonical failover 원자성과 production lifecycle evidence 보완

## 이 파일을 읽는 구현 에이전트에게

구현과 테스트를 완료한 뒤 모든 검증 명령을 실행하고 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션에 실제 변경 내용과 stdout/stderr, exit code를 채운다. active PLAN/CODE_REVIEW 파일은 그대로 두고 리뷰 준비 완료만 보고한다. 최종 판정, 로그 아카이브, `complete.log`, 다음 상태 분류는 code-review skill 소유다. 차단되면 구현 소유 evidence 필드에 정확한 blocker, 시도한 명령과 출력, 재개 조건만 기록하며 사용자에게 질문하거나 user-input 도구·control-plane stop 파일을 만들지 않는다.

## 배경

Logical prompt와 cloud legacy promotion 회귀는 통과했지만 persisted selector decision의 canonical epoch/history 검증이 서로 모순된다. failover가 context package 검증 전에 alternate를 저장해 검증 실패 시 부분 상태를 남길 수 있고, S07/S10 테스트는 production primary→alternate/reopen 및 양방향 completing-target lifecycle을 아직 관찰하지 않는다. 이 후속 작업은 selector와 dispatcher가 공유하는 persisted transition을 원자적으로 만들고 승인 SDD evidence를 실제 entry point에서 닫는다.

## Archive Evidence Snapshot

- 직전 계획: `agent-task/m-agent-task-runtime-target-selector/04+03_failover_budget/plan_local_G08_15.log`.
- 직전 리뷰: `agent-task/m-agent-task-runtime-target-selector/04+03_failover_budget/code_review_cloud_G08_15.log` — FAIL, Required 4 / Suggested 0 / Nit 0.
- 영향 파일: `select_execution_target.py`, `dispatch.py`, `test_select_execution_target.py`, `test_dispatch.py`.
- 검증 evidence: policy 6, selector 8, dispatcher logical/legacy 22, dispatcher lifecycle 28, 전체 191 tests와 `py_compile`/`git diff --check`는 PASS했다. focused 재현은 정상 cross-boundary resume의 `malformed_prior_decision` 거부와 tampered selected/used history 수용을 확인했다.
- 로드맵 carryover: S06/S07/S10과 `context-failover`, `failure-budget`, `selfcheck-policy`가 미완료다. S02 `time-route`의 최초 주야간 후보는 회귀 대상으로 유지한다.

## Roadmap Targets

- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/agent-task-runtime-target-selector.md`
- Milestone link: [Milestone 문서](agent-roadmap/phase/automation-runtime-bridge/milestones/agent-task-runtime-target-selector.md)
- Task ids:
  - `time-route`: local-G07~G08 KST 주야간 최초 target
  - `context-failover`: Gemini↔Laguna 단방향 logical context failover
  - `failure-budget`: target 전환 전후 동일 stage 10회 실패 예산
  - `selfcheck-policy`: 실제 worker 완료 target 기반 selfcheck
- Completion mode: check-on-pass

## 분석 결과

### 읽은 파일

- `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py`
- `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py`
- `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py`
- `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py`
- `agent-test/local/rules.md`
- `agent-roadmap/phase/automation-runtime-bridge/PHASE.md`
- `agent-roadmap/phase/automation-runtime-bridge/milestones/agent-task-runtime-target-selector.md`
- `agent-roadmap/sdd/automation-runtime-bridge/agent-task-runtime-target-selector/SDD.md`
- `agent-contract/index.md`, `agent-spec/index.md` — 이 task runtime에 매칭되는 별도 계약/spec 문서 없음.
- `agent-task/archive/2026/07/m-agent-task-runtime-target-selector/03+01,02_route_pin_state/complete.log` — predecessor completion 확인.

### SDD 기준

- SDD: `agent-roadmap/sdd/automation-runtime-bridge/agent-task-runtime-target-selector/SDD.md`, 상태 `[승인됨]`, 잠금 해제.
- S06 → `context-failover`: canonical route epoch/history, logical artifact 검증과 failover commit 원자성을 항목 1·2 및 transition evidence에 반영한다.
- S07 → `failure-budget`: primary 1회와 alternate 9회가 reopen 전후 같은 worker stage counter를 쓰고 success에서만 reset되는지를 항목 3에서 검증한다.
- S10 → `selfcheck-policy`: Gemini→Laguna만 pinned Laguna selfcheck, Laguna→Gemini와 cloud completion은 생략하는 양방향 stage evidence를 항목 3에서 검증한다.
- Evidence Map의 transition, stage counter, completing-target stage evidence가 최종 집중 테스트 assertion과 PASS 조건이다.

### 테스트 환경 규칙

- `test_env=local`; `agent-test/local/rules.md`를 읽었다. 이 Python agent-task runtime 범위에 매칭되는 별도 profile route는 없다.
- 현재 checkout에서 repository `unittest`, `py_compile`, `git diff --check`를 사용한다. 외부 provider, remote runner, Docker, Go profile은 사용하지 않는다.
- 테스트 규칙 생성/갱신은 요청 범위가 아니며 현재 fallback 명령이 결정적이므로 필요하지 않다.

### 테스트 커버리지 공백

- candidate 추가 변조만 거부하며 `selected`, `used_candidates` 순서·중복·현재 selected 일치와 failover 후 KST 경계 resume을 검증하지 않는다.
- context package 단위 검증은 있지만 `run_escalating()`에서 검증 실패 시 persisted decision/history가 불변이고 alternate/legacy promotion이 호출되지 않는지 검증하지 않는다.
- budget 테스트는 helper에 임의 target을 기록하거나 same-target generic failure만 반복해 canonical primary→alternate 1+9와 reopen audit을 검증하지 않는다.
- selfcheck 테스트는 failover decision과 성공 locator를 직접 주입하며 Laguna→Gemini completion, cloud completion, no-new-initial history를 검증하지 않는다.

### 심볼 참조

- rename/remove 없음.
- 변경 call sites: `_validate_prior_candidate_identity()`의 `_resume()`/`_failover()`, `persisted_execution_decision()`의 `run_worker()`/`run_selfcheck()`/`run_escalating()`, `build_context_package()`의 failover branch다.

### 분할 판단

- split policy를 재평가했다. 선택 task는 기존 dependent subtask `04+03_failover_budget`이며 predecessor `03`은 `agent-task/archive/2026/07/m-agent-task-runtime-target-selector/03+01,02_route_pin_state/complete.log`로 충족된다.
- canonical history, context commit, budget과 selfcheck evidence는 하나의 `execution_decisions[stage]`와 `route_transition_history` 원자성을 공유한다. API와 lifecycle 테스트를 분리하면 양쪽 모두 동일 selector/dispatcher fixtures와 transition contract를 중복 수정하므로 현재 task 안의 단일 follow-up pair가 더 안전하다.
- 외부 ownership, 배포, UI, 별도 검증 환경 경계는 없다.

### 범위 결정 근거

- KST 정책 matrix와 quota tri-state 계산 자체, cloud promotion chain, dispatcher drain/throughput, 공통 agent-ops 규칙과 roadmap/SDD 문서는 변경하지 않는다.
- 신규 dependency, 외부 provider 호출, contract/spec 문서 변경은 필요 없다.
- production 수정은 selector history validation과 dispatcher failover commit 경계로 제한하고 나머지는 회귀 테스트 evidence로 닫는다.

### 최종 라우팅

- `evaluation_mode=isolated-reassessment`, `finalizer=finalize-task-policy.sh`, `finalizer_mode=pair`.
- Build closures: scope/context/verification/evidence/ownership/decision 모두 `true`. 근거는 Required 4건의 파일·불변조건, focused 재현, 승인 SDD S06/S07/S10, 로컬 결정적 테스트, 단일 project-owned runtime 경계다.
- Build scores: scope_coupling=2, state_concurrency=2, blast_irreversibility=1, evidence_diagnosis=2, verification_complexity=1. `route_basis=local-fit`, capability gap=none, lane=`local`, grade=`G08`, filename=`PLAN-local-G08.md`.
- Build loop-risk: temporal_state=true(초기/failover/resume/reopen/success/block), concurrent_consistency=false(동시에 쓰는 actor 없이 StateStore 직렬 commit), boundary_contract=true(selector/dispatcher/state와 worker/selfcheck consumers), structured_interpretation=true(JSON candidate/history와 locator artifact 검증), variant_product=true(주야간×전환×completion target), `triggered=true`; unknown 없음.
- Review closures: scope/context/verification/evidence/ownership/decision 모두 `true`. Review scores: scope_coupling=2, state_concurrency=2, blast_irreversibility=1, evidence_diagnosis=2, verification_complexity=1. `route_basis=official-review`, lane=`cloud`, grade=`G08`, filename=`CODE_REVIEW-cloud-G08.md`, target=`codex/gpt-5.6-sol xhigh`.
- capability-gap과 grade floor는 none이다. 반복 횟수, 이전 route, archive filename은 격리 라우팅 입력에서 제외했다.

## 구현 체크리스트

- [ ] REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_API-1 최초 canonical route epoch와 selected/used transition history를 일관되게 검증하고 cross-boundary resume 및 tampered prior 회귀를 통과한다.
- [ ] REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_API-2 logical context package 검증과 failover decision/history commit을 원자화하고 artifact 오류에서 기존 route를 보존한 task-local blocker를 검증한다.
- [ ] REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_API-3 실제 primary→alternate 1+9 budget/reopen과 양방향 completing-target worker→selfcheck lifecycle을 production entry point로 검증한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.

### [REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_API-1] Canonical route epoch와 transition history 검증

#### 문제

`select_execution_target.py:508-590`은 candidate identity를 order대로 확인하지만 `selected`와 `used_candidates`는 canonical 집합 membership만 확인한다. `select_execution_target.py:707-709`는 failover에서 initial policy 검증 기준으로 재사용되는 `decision.evaluated_at`을 전환 시각으로 덮어써, 22:59 주간 initial→23:00 failover의 다음 resume이 야간 candidate order와 비교되어 malformed로 거부된다.

```python
# Before: select_execution_target.py:571-590, 707-709
if sel_key not in canon_keys:
    raise SelectorInputError(...)
for entry in used:
    if u_key not in canon_keys:
        raise SelectorInputError(...)
decision = dict(prior["decision"])
decision["evaluated_at"] = evaluated_at.astimezone(policy.KST).isoformat()
```

#### 해결 방법

initial decision의 canonical 평가 epoch/rule/time-window를 failover 뒤에도 보존한다. `selected`와 `used_candidates`는 selector가 실제 생성할 수 있는 unique ordered transition history인지, 현재 selected가 history tail과 일치하는지, initial/no-used 상태가 candidate eligibility와 모순되지 않는지 검증한다. 전환 관측 시각이 필요하면 initial policy epoch와 분리된 transition evidence로 기록하고 기존 decision identity를 덮어쓰지 않는다.

```python
# After
_validate_canonical_route_identity(prior, canonical_targets)
_validate_selected_used_history(prior)
decision = dict(prior["decision"])  # initial evaluated_at/time_window preserved
transition = {..., "evaluated_at": evaluated_at.astimezone(policy.KST).isoformat()}
```

#### 수정 파일 및 체크리스트

- [ ] `scripts/select_execution_target.py`: immutable canonical epoch와 selected/used ordered-history 검증을 구현한다.
- [ ] `tests/test_select_execution_target.py`: candidate/selected/used 변조 variant, duplicate/reordered history와 22:59→23:00 failover→resume을 검증한다.

#### 테스트 작성

- `SelectorFailoverContractTests.test_tampered_prior_decision_rejected`: candidate, selected, used order/tail/duplicate를 table-driven subtest로 모두 `malformed_prior_decision` 처리한다.
- `SelectorFailoverContractTests.test_cross_boundary_failover_resumes_pinned_decision`: 주간 initial과 야간 전환 후 resume이 동일 selected/history로 성공하는지 검증한다.

#### 중간 검증

```bash
export IOP_FORCE_GEMINI_TODAY=2026-07-26
python3 -m unittest agent-ops.skills.project.orchestrate-agent-task-loop.tests.test_select_execution_target.SelectorRouteMatrixTests agent-ops.skills.project.orchestrate-agent-task-loop.tests.test_select_execution_target.SelectorFailoverContractTests -v
```

예상 결과: canonical 주야간 matrix, valid cross-boundary resume과 모든 tampered history 거부가 PASS한다.

### [REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_API-2] Logical context 검증과 failover commit 원자화

#### 문제

`dispatch.py:3156-3176`은 `persisted_execution_decision(..., transition="failover")`로 alternate와 history를 저장한 뒤 `build_context_package()`를 호출한다. locator가 없으면 package를 만들지 않고 전환하며, locator/raw/normalized 검증이 `ExecutionDecisionError`를 내도 `no_failover_candidate` 외 오류는 삼켜져 persisted alternate와 실제 primary spec이 갈라지고 legacy promotion 경로까지 실행될 수 있다.

```python
# Before: dispatch.py:3156-3177
next_decision, next_spec = persisted_execution_decision(..., transition="failover")
if next_spec != spec:
    if locator:
        context = build_context_package(...)
except ExecutionDecisionError as exc:
    if "no_failover_candidate" in str(exc):
        return False, locator
```

#### 해결 방법

failover candidate를 pure evaluation하고 locator/context package를 먼저 검증한 뒤 decision과 history를 한 번에 commit한다. logical context가 필요한 cross-adapter 전환에서 locator 또는 필수 artifact가 없거나 identity가 다르면 기존 selected/history를 유지하고 구체적인 task-local blocker로 종료한다. 그 오류에서 alternate invocation과 legacy `promoted_spec()`을 실행하지 않는다.

```python
# After
next_decision = select_execution_decision(..., transition="failover")
next_spec = agent_spec_from_decision(next_decision)
context = build_context_package(..., required_locator=locator)
commit_execution_decision(store, task, role, next_decision)
```

#### 수정 파일 및 체크리스트

- [ ] `scripts/dispatch.py`: selector evaluation과 persisted commit 경계를 분리하고 logical context 성공 뒤에만 transition/history를 저장한다.
- [ ] `tests/test_dispatch.py`: locator 없음, raw/normalized 누락과 identity mismatch에서 기존 decision/history 불변, blocker, invoke count와 no-promotion을 검증한다.

#### 테스트 작성

- `DispatcherCanonicalFailoverIntegrationTest.test_invalid_logical_context_does_not_commit_or_promote`: artifact 오류 variant를 table-driven으로 실행해 첫 primary invoke 뒤 종료하고 alternate/legacy invoke가 없음을 검증한다.
- 기존 정상 주간 Gemini→Laguna prompt와 cloud G07 legacy promotion 회귀는 함께 유지한다.

#### 중간 검증

```bash
export IOP_FORCE_GEMINI_TODAY=2026-07-26
python3 -m unittest agent-ops.skills.project.orchestrate-agent-task-loop.tests.test_dispatch.DispatcherCanonicalFailoverIntegrationTest agent-ops.skills.project.orchestrate-agent-task-loop.tests.test_dispatch.ReviewRetryTest -v
```

예상 결과: 정상 logical failover, invalid-context fail-closed와 cloud legacy recovery가 모두 PASS한다.

### [REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_API-3] S07/S10 production lifecycle evidence

#### 문제

`test_dispatch.py:5346-5437`은 budget helper에 임의 Codex target을 직접 기록하거나 same-target Pi generic failure만 반복한다. `test_dispatch.py:5666-5708`은 failover decision과 성공 locator를 직접 주입하고 Laguna 완료만 확인해 actual target 전환 counter, Laguna→Gemini completion, cloud completion과 worker/selfcheck 사이 transition history를 증명하지 않는다.

```python
# Before: test_dispatch.py:5353-5356, 5685-5688
decision, _ = persisted_execution_decision(...)
worker.record_failure(target={"adapter": "codex", ...}, transition="failover")
persisted_execution_decision(..., transition="failover", failure_class="provider-quota")
with patch.object(dispatch, "run_escalating", return_value=(True, locator_laguna)):
    await dispatch.run_worker(...)
```

#### 해결 방법

실제 `run_escalating()`에서 primary 1회 실패로 selector failover를 만들고 alternate 실패가 총 budget 10회까지 누적되게 한다. 중간 cancellation/reopen 뒤 persisted count, `last_target`, `last_transition`, decision/history 보존과 review-stage 분리를 확인하고 success case만 reset을 확인한다. `run_worker()`/`run_selfcheck()`는 정상 failover invocation locator를 사용해 Gemini→Laguna만 selfcheck하고 Laguna→Gemini 및 cloud completion은 생략하며 worker decision resume 외 새 `initial` history가 없음을 검증한다.

```python
# After
await run_escalating(...)  # primary failure -> canonical alternate
reopened = StateStore(workspace)
await run_escalating(...)  # alternate failures through shared count 10
await run_worker(...)
await run_selfcheck(...)
self.assertEqual(initial_history_count, ...)
```

#### 수정 파일 및 체크리스트

- [ ] `tests/test_dispatch.py`: canonical primary 1 + alternate 9, reopen, stage separation, success reset과 audit fields를 production entry point로 검증한다.
- [ ] `tests/test_dispatch.py`: Gemini→Laguna, Laguna→Gemini, cloud completion의 worker/selfcheck stage와 pinned history를 검증한다.
- [ ] `scripts/dispatch.py`: 위 production lifecycle 테스트가 드러내는 counter/selfcheck 오류가 있을 때만 최소 수정한다.

#### 테스트 작성

- `DynamicFailoverBudgetTest.test_primary_and_alternate_share_budget_across_reopen`: actual invocation spec sequence, 10회 counter, last target/transition, review count 0과 reopen을 검증한다.
- `DynamicFailoverBudgetTest.test_success_resets_only_current_stage_budget`: worker success reset과 review 분리를 검증한다.
- `DispatcherCanonicalFailoverIntegrationTest.test_completing_target_controls_selfcheck_and_reuses_pin`: 세 completion variant와 transition history를 검증한다.

#### 중간 검증

```bash
export IOP_FORCE_GEMINI_TODAY=2026-07-26
python3 -m unittest agent-ops.skills.project.orchestrate-agent-task-loop.tests.test_dispatch.TaskStageTest agent-ops.skills.project.orchestrate-agent-task-loop.tests.test_dispatch.RouteDecisionPersistenceTest agent-ops.skills.project.orchestrate-agent-task-loop.tests.test_dispatch.DynamicFailoverBudgetTest agent-ops.skills.project.orchestrate-agent-task-loop.tests.test_dispatch.DispatcherCanonicalFailoverIntegrationTest -v
```

예상 결과: S07 counter/audit와 S10 completing-target stage evidence가 실제 entry point에서 PASS한다.

## 의존 관계 및 구현 순서

1. predecessor `03+01,02_route_pin_state`는 `agent-task/archive/2026/07/m-agent-task-runtime-target-selector/03+01,02_route_pin_state/complete.log`로 충족됐다.
2. 항목 1에서 persisted decision identity/history invariant를 고정한다.
3. 항목 2에서 그 decision을 context 검증 뒤 원자 commit한다.
4. 항목 3에서 완성된 production transition으로 S07/S10 evidence를 검증한다.

## 수정 파일 요약

| 파일 | 항목 |
|------|------|
| `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py` | REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_API-1 |
| `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py` | REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_API-2, REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_API-3 |
| `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py` | REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_API-1 |
| `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py` | REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_API-2, REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_API-3 |

## 최종 검증

```bash
export IOP_FORCE_GEMINI_TODAY=2026-07-26
python3 -m unittest agent-ops.skills.project.orchestrate-agent-task-loop.tests.test_select_execution_target.SelectorRouteMatrixTests agent-ops.skills.project.orchestrate-agent-task-loop.tests.test_select_execution_target.SelectorFailoverContractTests -v
python3 -m unittest agent-ops.skills.project.orchestrate-agent-task-loop.tests.test_dispatch.DispatcherCanonicalFailoverIntegrationTest agent-ops.skills.project.orchestrate-agent-task-loop.tests.test_dispatch.ReviewRetryTest -v
python3 -m unittest agent-ops.skills.project.orchestrate-agent-task-loop.tests.test_dispatch.TaskStageTest agent-ops.skills.project.orchestrate-agent-task-loop.tests.test_dispatch.RouteDecisionPersistenceTest agent-ops.skills.project.orchestrate-agent-task-loop.tests.test_dispatch.DynamicFailoverBudgetTest agent-ops.skills.project.orchestrate-agent-task-loop.tests.test_dispatch.DispatcherCanonicalFailoverIntegrationTest -v
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*.py' -v
python3 -m py_compile agent-ops/skills/project/orchestrate-agent-task-loop/scripts/execution_target_policy.py agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_execution_target_policy.py agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py
git diff --check
```

예상 결과: focused selector/dispatcher/lifecycle와 전체 suite가 모두 PASS하고 compile/diff 오류가 없다. 테스트 cache는 Python `unittest`에 해당하지 않는다.

모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다.
