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

# 단방향 failover와 실제 stage budget lifecycle 증거 보완

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

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

## 배경

직전 구현은 canonical route evidence와 대부분의 failover/budget 검증을 보완했지만, 후순위 candidate로 최초 admission된 뒤 앞 rank의 quota가 회복되면 selector가 역방향 failover를 허용한다. S07 테스트도 재오픈 이후 실제 8회 invocation과 production 성공 분기의 stage-local reset을 끝까지 증명하지 않는다. 이 후속은 SDD S06의 단방향 no-bounce와 S07의 실제 lifecycle evidence만 닫는다.

## Archive Evidence Snapshot

- 직전 계획: `agent-task/m-agent-task-runtime-target-selector/04+03_failover_budget/plan_local_G08_17.log`.
- 직전 리뷰: `agent-task/m-agent-task-runtime-target-selector/04+03_failover_budget/code_review_cloud_G08_17.log` — FAIL, Required 2 / Suggested 0 / Nit 0.
- 영향 파일: `select_execution_target.py`, `test_select_execution_target.py`, `test_dispatch.py`.
- 검증 evidence: focused selector 26, S07 7, S10/dispatcher 22, 전체 192 tests와 `py_compile`/금지 심볼 검색/`git diff --check`는 PASS했다. 별도 reproducer는 주간 initial이 Gemini quota exhausted로 Laguna를 선택한 뒤 Gemini quota가 회복되면 Laguna의 qualified failure가 Laguna→Gemini 역방향 전환을 만드는 것을 확인했다.
- 남은 carryover: S02/S06/S07/S10과 `time-route`, `context-failover`, `failure-budget`, `selfcheck-policy`의 최종 PASS evidence다.

## 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`: ordered candidate의 단방향 logical failover와 no-bounce
  - `failure-budget`: target 전환 전후 동일 stage 10회 실패 예산과 성공 reset
  - `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` — 이 agent-task runtime에 매칭되는 별도 계약/spec 문서 없음.
- `agent-task/m-agent-task-runtime-target-selector/04+03_failover_budget/plan_local_G08_17.log`
- `agent-task/m-agent-task-runtime-target-selector/04+03_failover_budget/code_review_cloud_G08_17.log`

### SDD 기준

- SDD: `agent-roadmap/sdd/automation-runtime-bridge/agent-task-runtime-target-selector/SDD.md`, 상태 `[승인됨]`, SDD 잠금 `해제`.
- S02/`time-route`: KST 네 경계 matrix와 canonical 최초 target evidence를 유지한다.
- S06/`context-failover`: ordered candidate의 현재 선택 이후 unused target만 한 번 사용하고 이전 rank로 bounce하지 않아야 한다. 이 기준이 항목 1의 production fix와 selector/dispatcher regression을 결정한다.
- S07/`failure-budget`: 동일 stage target 전환, 재오픈, stage 분리, 실제 성공 초기화를 포함한 10회 evidence가 필요하다. 이 기준이 항목 2의 invocation/reset assertion을 결정한다.
- S10/`selfcheck-policy`: completing target 기반 selfcheck evidence를 전체 회귀에서 유지한다.
- Evidence Map의 S02/S06/S07/S10 행을 최종 focused test와 전체 suite 출력으로 검증한다.

### 테스트 환경 규칙

- `test_env=local`; `agent-test/local/rules.md`를 읽었다.
- agent-ops Python runtime에 매칭되는 local smoke profile은 없다. 외부 runner, service, credential, device를 사용하지 않으므로 별도 프리플라이트는 필요 없다.
- fallback 검증 source는 repository의 Python `unittest`, `py_compile`, 결정적 `rg --sort path`, `git diff --check`다. 이 범위에는 test-rule 유지보수가 필요하지 않다.

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

- 기존 selector failover tests는 primary→alternate와 used target 반복만 확인하고, quota 때문에 alternate가 initial selected가 된 뒤 primary quota가 회복되는 reverse-rank variant를 검출하지 못한다.
- 기존 budget reopen test는 최종 count만 확인하고 재오픈 invocation 8회, 전체 target sequence 1+9, 최종 locator를 assertion하지 않는다.
- 기존 success reset test는 `StageFailureBudget.reset_on_success()` helper를 직접 호출해 `run_escalating()` 성공 분기가 worker budget만 지우는 production behavior를 증명하지 않는다.

### 심볼 참조

- rename/remove symbol 없음.
- 변경 call chain은 selector `_failover()` → dispatcher `select_execution_decision()`/`run_escalating()`과 `SelectorFailoverContractTests`, `DynamicFailoverBudgetTest`다.

### 분할 판단

- split policy를 먼저 평가했다. reverse-rank selection과 1+9/reset evidence는 같은 ordered candidate, `execution_decisions[stage]`, `stage_failure_budgets` lifecycle을 닫는다.
- production/API rollout 경계나 별도 ownership/deployment 경계가 없고, test-only evidence를 분리하면 selector fix 없이 의미 있는 PASS를 만들 수 없다.
- 기존 dependent subtask `04+03_failover_budget` identity와 predecessor 계약을 보존해야 하므로 단일 follow-up pair가 가장 작은 안전 단위다.

### 범위 결정 근거

- `dispatch.py` production reset 경로는 이미 `run_escalating()` 성공에서 current `stage_budget`만 제거하므로 변경하지 않고 실제 entry point test로 검증한다.
- execution target policy matrix, hidden-input/canonical evidence fix, completing-target selfcheck implementation은 회귀 대상으로만 유지한다.
- roadmap/SDD, agent-ops 공통 규칙/스킬, 계약/spec, unrelated dispatcher tests는 수정하지 않는다.

### 최종 라우팅

- `evaluation_mode=isolated-reassessment`, `finalizer=finalize-task-policy.sh`, `finalizer_mode=pair`.
- Build closures: scope/context/verification/evidence/ownership/decision 모두 `true`. 두 Required와 영향 call chain, deterministic commands, 현재 worktree ownership 및 승인 SDD로 닫혔다.
- 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: ordered transitions는 alternate initial→qualified failure→no candidate 및 primary→alternate→reopen→success이고 adverse path는 reverse-rank/budget exhaustion이다. concurrent consistency는 actor 1로 false다. selector/dispatcher persisted boundary와 JSON candidate ordering을 검증하며 structured interpretation=true, target-rank×quota×transition×stage variants로 variant product=true, `triggered=true`; unknown 없음.
- Review closures: scope/context/verification/evidence/ownership/decision 모두 `true`. Review scores는 2/2/1/2/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, 반복 횟수와 filename은 격리 라우팅 입력에서 제외했다.

## 구현 체크리스트

- [ ] REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_API-1 failover candidate를 현재 selected rank 이후로 제한하고 reverse-rank no-bounce regression을 추가한다.
- [ ] REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_API-2 실제 재오픈 1+9 invocation과 production 성공 분기의 stage-local reset을 검증한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.

### [REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_API-1] Ordered failover no-bounce

#### 문제

`select_execution_target.py:768-782`는 모든 canonical candidate에서 첫 unused eligible target을 고른다. quota 때문에 rank 2 Laguna가 initial selected인 상태에서 rank 1 Gemini quota가 회복되면 Laguna failure가 Gemini로 역행해 SDD S06을 위반한다.

#### 해결 방법

현재 selected와 일치하는 candidate index를 찾고 quota/eligibility refresh는 전체 candidate에 유지하되, `selected_candidate`는 그 index 이후의 unused eligible candidate에서만 고른다.

```python
# Before: select_execution_target.py:768
for candidate in prior["candidates"]:
    ...
    if key not in used_set and current["eligibility"] == "eligible":
        selected_candidate = current

# After
previous_index = next(index for index, candidate in enumerate(prior["candidates"]) if _target_ref(candidate) == previous)
for index, candidate in enumerate(prior["candidates"]):
    ...
    if index > previous_index and key not in used_set and current["eligibility"] == "eligible":
        selected_candidate = current
```

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

- [ ] `scripts/select_execution_target.py`: 현재 selected rank 이전 candidate를 failover 대상에서 제외한다.
- [ ] `tests/test_select_execution_target.py`: exhausted Gemini 때문에 Laguna initial, Gemini 회복 후 qualified Laguna failure가 `no_failover_candidate`로 닫히는 regression을 추가한다.
- [ ] `tests/test_dispatch.py`: 같은 reverse-rank 상태가 dispatcher에서 task-local selector blocker가 되고 Gemini를 invoke하지 않는 integration assertion을 기존 canonical failover test에 보강한다.

#### 테스트 작성

- `SelectorFailoverContractTests.test_failover_never_returns_to_an_earlier_candidate_rank`: 주간 exhausted Gemini snapshot으로 Laguna initial을 만들고 available snapshot 이후 Laguna transport failure가 Gemini로 되돌아가지 않는지 확인한다.
- `DispatcherCanonicalFailoverIntegrationTest.test_recovered_primary_quota_does_not_reverse_failover`: persisted Laguna initial에서 Gemini quota 회복 후 qualified failure가 task-local `no_failover_candidate` blocker가 되고 invocation history에 Gemini가 추가되지 않는지 확인한다.

#### 중간 검증

```bash
python3 -m unittest agent-ops.skills.project.orchestrate-agent-task-loop.tests.test_select_execution_target.SelectorFailoverContractTests agent-ops.skills.project.orchestrate-agent-task-loop.tests.test_dispatch.DispatcherCanonicalFailoverIntegrationTest -v
```

예상 결과: ordered primary→alternate는 유지되고 alternate→primary reverse-rank failover는 selector/dispatcher 모두 fail closed한다.

### [REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_REVIEW_API-2] S07 actual invocation과 success reset

#### 문제

`test_dispatch.py:5400-5431`은 재오픈 mock invocation 수·spec·최종 locator를 확인하지 않고, `test_dispatch.py:5456`은 helper를 직접 호출해 production `run_escalating()` 성공 분기를 우회한다.

#### 해결 방법

재오픈 mock의 await count 8, 모든 `AgentSpec`이 Laguna, 최종 locator가 8번째 failure인지 확인해 첫 실행과 합친 Gemini 1 + Laguna 9를 증명한다. worker/review budget을 각각 1로 preload한 뒤 `run_escalating()`에 worker 성공 invocation을 주입하고 worker count만 0, review count는 1인지 확인한다.

```python
# Before: test_dispatch.py:5415,5456
success, final_loc = asyncio.run(dispatch.run_escalating(...))
worker_budget.reset_on_success()

# After
success, final_loc = asyncio.run(dispatch.run_escalating(...))
self.assertEqual(invoke.await_count, 8)
self.assertTrue(all(call.args[4] == laguna_spec for call in invoke.await_args_list))
self.assertEqual(final_loc, locators[-1])
asyncio.run(dispatch.run_escalating(workspace, store, task, "worker", gemini_spec))
```

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

- [ ] `tests/test_dispatch.py`: reopen invocation count/spec/sequence와 final locator를 assertion한다.
- [ ] `tests/test_dispatch.py`: worker/review budget preload 뒤 실제 `run_escalating()` success가 worker만 reset하는지 검증한다.

#### 테스트 작성

- 기존 `DynamicFailoverBudgetTest.test_primary_and_alternate_share_budget_across_reopen`을 강화한다.
- 기존 `DynamicFailoverBudgetTest.test_success_resets_only_current_stage_budget`을 production 성공 entry point 기반으로 바꾼다.

#### 중간 검증

```bash
python3 -m unittest agent-ops.skills.project.orchestrate-agent-task-loop.tests.test_dispatch.DynamicFailoverBudgetTest agent-ops.skills.project.orchestrate-agent-task-loop.tests.test_dispatch.RouteDecisionPersistenceTest -v
```

예상 결과: 실제 Gemini 1 + Laguna 9, reopen state/audit, worker 성공 reset과 review stage 보존이 PASS한다.

## 의존 관계 및 구현 순서

1. 항목 1에서 selector의 단방향 candidate invariant와 regression을 고정한다.
2. 항목 2에서 같은 persisted decision/stage budget lifecycle의 actual invocation evidence를 닫는다.

## 수정 파일 요약

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

## 최종 검증

```bash
python3 -m unittest agent-ops.skills.project.orchestrate-agent-task-loop.tests.test_select_execution_target.SelectorContractTests 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.DynamicFailoverBudgetTest agent-ops.skills.project.orchestrate-agent-task-loop.tests.test_dispatch.RouteDecisionPersistenceTest -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.TaskStageTest -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/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
rg --sort path -n "selected_candidate|no_failover_candidate|await_count|reset_on_success" agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.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/budget/selfcheck와 전체 suite가 PASS하고 compile·deterministic symbol inventory·diff check가 성공한다. Python `unittest`에는 cache reuse가 없다.

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