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

# Plan - Selector failover와 공통 실패 예산

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

선행 `03+01,02_route_pin_state`의 `complete.log`가 확인된 뒤 구현한다. 구현과 검증을 끝낸 뒤 `CODE_REVIEW-cloud-G09.md`의 구현 에이전트 소유 섹션을 실제 내용과 원문 출력으로 채운다. active 파일은 그대로 두고 review 준비 완료만 보고한다. 종결은 code-review skill 전용이다. 차단되면 정확한 원인, 시도한 명령/출력, 재개 조건만 구현 에이전트 소유 evidence에 기록한다. 사용자에게 질문하거나 user-input 도구를 호출하지 않고, control-plane stop file 생성, 다음 상태 분류, archive/log 이동, `complete.log` 작성은 하지 않는다.

## 배경

selector의 `failover` transition은 현재 명시적으로 거부되고 dispatcher의 복구 예산은 정적 target 흐름에 묶여 있다. 이 작업은 live invocation을 바꾸기 전에 failover 결정 계약, 논리적 context package, 동일 work unit/stage의 공유 실패 예산을 독립적으로 완성한다.

## 분석 결과

### 읽은 파일

- `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-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py`
- `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py`
- `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py`
- `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py`
- `agent-test/local/rules.md`
- `agent-ops/rules/common/rules-agent-spec.md`
- `agent-spec/index.md`
- `agent-contract/index.md`

### SDD 기준

- SDD: `agent-roadmap/sdd/automation-runtime-bridge/agent-task-runtime-target-selector/SDD.md`, 상태 `[승인됨]`, 잠금 해제.
- 대상 Acceptance Scenario: S06 → `context-failover`, S07 → `failure-budget`.
- Evidence Map S06 행의 주야간 failover/logical context transition evidence와 S07 행의 target 전환·stage 분리·성공 초기화를 포함한 10회 counter evidence를 구현 체크리스트와 두 집중 테스트에 반영했다.
- live dispatcher 전환 없이 기반만 구현하므로 `Roadmap Targets`는 두지 않는다. 두 Task의 완료 소유자는 `05+04_dispatch_integration`이다.

### 테스트 환경 규칙

- `test_env=local`; `agent-test/local/rules.md`가 존재하며 끝까지 읽었다.
- agent-ops Python dispatcher에 매칭되는 `agent-test/local/*.md` profile은 없다. 현재 checkout의 기존 Python test manifest를 fallback 근거로 삼아 fresh-process `unittest`, `py_compile`, `git diff --check`를 사용하며 test-rule 유지보수는 필요하지 않다.
- 외부 provider와 실 quota 호출 없이 고정 quota snapshot과 임시 locator/state를 사용한다.
- 계획 시점 기준 전체 Python suite 169 tests가 PASS다.

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

- `select_execution_target.py:592-597`은 `failover`를 `unsupported_transition`으로 종료한다.
- day Gemini→Laguna, night Laguna→Gemini, night quota `available|unknown|exhausted`의 단방향 결과 테스트가 없다.
- target 전환 전후 누적 실패 10회, stage 성공 시 reset, restart 보존을 한 흐름으로 검증하지 않는다.
- cross-adapter continuation payload에 PLAN/locator, normalized/raw output, workspace가 모두 포함되는지 검증하지 않는다.

### 심볼 참조

- rename/remove 없음.
- 신규 failover 계약은 selector CLI와 후속 dispatcher integration의 경계이므로 JSON 필드를 먼저 고정한다.

### 분할 판단

- split 정책을 먼저 평가했다. shared task group은 `m-agent-task-runtime-target-selector`이고 topology는 `03 → 04 → 05 → 06 → 07 → 08`이다.
- 현재 `04+03_failover_budget`의 predecessor `03+01,02_route_pin_state`는 active plan만 있고 `complete.log`가 없어 미충족이다.
- 후속 `05+04_dispatch_integration`이 이 계약을 live invocation에 연결하고 selfcheck, throughput, audit는 각각 `06`, `07`, `08`로 분리한다.

### 범위 결정 근거

- dispatcher의 정적 `route_agent()` call-site 교체는 제외한다.
- provider 오류 분류 전체 재설계와 quota producer 변경은 제외한다.
- selector는 아직 사용하지 않은 ordered candidate만 반환한다. `05`가 확정된 provider target 불가 trigger만 failover로 연결하며, 일반 실패·반복 횟수·시간 변화는 same-target recovery 또는 pin 유지로 처리한다.
- `agent-spec/index.md`와 `agent-contract/index.md`에 매칭되는 활성 selector/dispatcher spec/contract가 없으므로 승인 SDD, 코드와 테스트를 기준으로 삼는다.

### 최종 라우팅

- evaluation_mode: `isolated-reassessment`
- finalizer=`finalize-task-policy.sh`, finalizer_mode=`pair`; build/review status=`routed`.
- build closures: `scope_closed=true`(승인 SDD와 이 subtask 경계가 고정됨), `context_closed=true`(명시된 source/test 범위를 한 local 작업에서 유지 가능), `verification_closed=true`, `evidence_trusted=true`, `ownership_closed=true`, `decision_closed=true`.
- review closures: `scope_closed=true`, `context_closed=true`(동일 source/test 및 구현 evidence로 판정 가능), `verification_closed=true`, `evidence_trusted=true`, `ownership_closed=true`, `decision_closed=true`.
- build route_basis=`local-fit`; capability_gap=`none`.
- build loop-risk audit: matched signatures=`temporal_state`, `boundary_contract`, `variant_product`; 이 기록은 lane/G를 바꾸지 않는다.
- build scores(scope_coupling/state_concurrency/blast_irreversibility/evidence_diagnosis/verification_complexity)=`2/2/1/1/2`, final=`local-G08`, filename=`PLAN-local-G08.md`.
- review route_basis=`official-review`; official execution=`codex/gpt-5.6-sol xhigh`; capability_gap=`none`.
- review scores(scope_coupling/state_concurrency/blast_irreversibility/evidence_diagnosis/verification_complexity)=`2/2/1/2/2`, final=`cloud-G09`, filename=`CODE_REVIEW-cloud-G09.md`.

## 구현 체크리스트

- [ ] selector가 확정된 provider target 불가에만 주야간 initial target별 단방향 failover를 반환하고, 야간 Gemini `available|unknown`은 1회 허용하며 confirmed `exhausted`만 terminal로 분류한다.
- [ ] adapter 전환용 logical context package가 PLAN/locator, persisted normalized output, raw log, workspace를 보존하고 same-Pi만 native resume으로 구분한다.
- [ ] 동일 work unit/stage 실패 예산 10회를 target 전환과 restart 전후에 공유하고 성공 때만 reset하며 `retry_blocked`는 budget/blocker만 초기화하고 decision/history는 보존한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.

## 구현 항목

### [API-1] Failover selector transition

- 문제: `select_execution_target.py:592-597`은 모든 failover를 거부해 S06의 단방향 target 전환을 표현할 수 없다.
- 해결 방법:

```python
# Before: agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py:592
if transition == "failover":
    raise SelectorInputError("unsupported_transition", ...)
```

```python
# After
if transition == "failover":
    return _failover(
        prior_decision,
        evaluated_at=evaluated_at,
        quota_snapshot=quota_snapshot,
    )
```

  prior identity/stage와 이미 사용한 candidate를 재검증하고 `previous_target`, `next_target`, qualified trigger, `context_transfer=logical`, reason code를 반환한다. quota/rate-limit, context/output limit, model unavailable 또는 transport disconnect만 qualified trigger다. 주간 Gemini는 그때만 Laguna로 이동한다. 야간 Laguna는 Gemini Medium quota가 `available` 또는 `unknown`이면 아직 사용하지 않은 alternate로 1회 이동하고, `unknown` candidate의 runtime failure가 `provider-quota`로 확인될 때만 snapshot을 `exhausted`로 갱신한다. 일반 실패, confirmed `exhausted`, candidate 소진 또는 primary bounce는 failover가 아니라 same-target recovery 또는 명시적 terminal reason으로 끝낸다.
- 수정 파일 및 체크리스트:
  - [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py`: failover validator와 결정 함수.
  - [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py`: `SelectorFailoverContractTests`.
- 테스트 작성: `test_select_execution_target.py`의 `SelectorFailoverContractTests`에서 prior initial route, available/unknown/exhausted snapshot, unknown 1회 후 confirmed provider-quota의 task-local exhausted 전환, generic stderr의 unknown 유지, used-candidate history와 no-bounce를 table fixture로 검증한다.
- 중간 검증: `python3 agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py SelectorFailoverContractTests -v` — 정상·경계 case가 모두 PASS해야 한다.

### [API-2] 논리적 context package

- 문제: `dispatch.py:2318-2493`은 raw `stream.log`를 쓰지만 `render_json_line()`의 normalized output은 콘솔에만 출력하고, `dispatch.py:2658-2687`의 continuation은 cross-adapter 입력을 검증된 객체로 묶지 않는다.
- 해결 방법:

```python
# Before: agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py:2460
stream_log.write(f"[{channel}] {line}\n")
rendered, discovered = render_json_line(spec.cli, line)
```

```python
# After
context_package = {
    "plan": ...,
    "locator": ...,
    "normalized_output": ...,
    "raw_log": ...,
    "workspace": ...,
    "resume_mode": "native" | "logical",
}
```

  `invoke()`가 rendered normalized lines를 attempt-local `normalized-output.log`에 순서대로 저장하고 locator에 그 경로를 기록한다. package builder는 plan/work unit/workspace, locator, normalized artifact, raw stream을 검증하며 cross-adapter에서는 native session을 전달하지 않는다. 이 subtask에서는 artifact 생성과 builder/validator까지 만들고 invocation 소비는 후속으로 남긴다.
- 수정 파일 및 체크리스트:
  - [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py`: normalized output artifact와 locator field, context package builder/validator.
  - [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py`: logical/native 경계 회귀.
- 테스트 작성: `test_dispatch.py`의 `DynamicFailoverBudgetTest`에서 fake provider JSON stream으로 normalized artifact를 만들고 누락 locator/log, workspace mismatch, cross-adapter native-session 유출을 fail-closed 검증한다.
- 중간 검증: `python3 agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py DynamicFailoverBudgetTest -v` — artifact/context/budget case가 모두 PASS해야 한다.

### [API-3] Stage 공통 실패 예산

- 문제: `dispatch.py:2708-2713`은 role별 counter를 읽고 `dispatch.py:2823-2828`은 성공 시 지우지만 selector target 전환 및 `clear_blocked()`(`dispatch.py:541-554`)의 decision/history 보존 계약이 없다.
- 해결 방법:

```python
# Before: agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py:2710
persisted = store.task_state(task).get("recovery_failures", {})
recovery_failures = int(persisted.get(role, 0))
```

```python
# After
budget = stage_budget(store, work_unit_id=decision["work_unit_id"], stage=stage)
budget.record_failure(target=decision["selected"], transition=transition)
```

  qualified primary failure만 count 1과 failover history를 만들고 alternate의 추가 9번째 실패가 총 10 blocker가 된다. 일반 primary 실패는 same-target recovery count만 늘린다. 성공에서만 해당 stage count를 0으로 만든다. `retry_blocked`는 blocker와 stage counters만 초기화하고 persisted decision/history/used candidates는 유지한다.
- 수정 파일 및 체크리스트:
  - [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py`: budget read/increment/reset와 transition evidence helper.
  - [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py`: `DynamicFailoverBudgetTest`.
- 테스트 작성: `test_dispatch.py`의 `DynamicFailoverBudgetTest`에서 qualified primary failure→alternate→추가 9회=10 blocker, generic failure same-target recovery, restart 보존, worker 성공 reset, review stage 격리, retry-blocked decision/history 보존을 temp state fixture로 검증한다.
- 중간 검증: `python3 agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py DynamicFailoverBudgetTest -v` — 모든 counter/reset fixture가 PASS해야 한다.

## 의존 관계 및 구현 순서

1. `03+01,02_route_pin_state`가 archive되고 `complete.log`가 PASS일 때 시작한다. 계획 작성 시점에는 active plan만 있어 미충족이다.
2. API-1과 API-2를 구현한 뒤 API-3이 동일 identity/transition evidence를 소비하게 한다.
3. 이 task PASS 후에만 `05+04_dispatch_integration`을 실행한다.

## 수정 파일 요약

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

## 최종 검증

- `python3 agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py SelectorFailoverContractTests -v` — 정상·quota·no-bounce 회귀 PASS.
- `python3 agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py DynamicFailoverBudgetTest -v` — context artifact·budget·retry-blocked 회귀 PASS.
- `python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*.py'` — fresh process 전체 suite PASS; cache output은 허용하지 않는다.
- `python3 -m py_compile agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py 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/tests/test_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` — exit 0.
- `git diff --check` — 출력 없음.

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