feat: task routing, orchestration, quota probe, and skill updates

- add finalize-task-routing skill with policy and test scripts
- add execution target selection scripts and tests for orchestrate-agent-task-loop
- add quota probe adapter and CLI status for node app
- update plan, finalize-task-routing, and orchestration skill files
- add agent-task archive for runtime target selector
- update stream-evidence-gate-core milestone
This commit is contained in:
toki 2026-07-25 19:33:17 +09:00
parent 3f01d08ceb
commit deea59d035
42 changed files with 8964 additions and 66 deletions

View file

@ -1,6 +1,6 @@
---
name: finalize-task-routing
description: PLAN/CODE_REVIEW 작성 직전 현재 작업 증거만으로 cloud/local lane과 G01-G10 등급을 독립 평가하고 canonical 파일명을 확정한다. 최초 plan, review 실패 후 follow-up plan, 라우팅 재평가, lane/G 판단, routed filename 결정에 사용한다.
description: PLAN/CODE_REVIEW 작성 직전 현재 작업 증거만으로 cloud/local lane과 G01-G10 등급을 독립 평가하고, 구현의 장기 반복 위험과 공식 review cloud-G09 capability floor를 적용해 canonical 파일명을 확정한다. 최초 plan, review 실패 후 follow-up plan, 라우팅 재평가, lane/G 판단, routed filename 결정에 사용한다.
---
# Finalize Task Routing
@ -8,6 +8,7 @@ description: PLAN/CODE_REVIEW 작성 직전 현재 작업 증거만으로 cloud/
## 목적
계획 분석이 끝난 뒤 구현과 리뷰 target의 lane과 G 등급을 처음부터 평가하고 canonical 파일명을 반환한다.
구현 target은 현재 작업에 이미 드러난 복합 정확성 위험을 함께 판정해, 반복 루프가 시작되기 전에 필요한 cloud lane과 capability floor를 적용한다.
이 스킬은 라우팅 판단의 단일 원본이며 plan/review 내용을 작성하거나 task 파일을 직접 생성하지 않는다.
## 언제 호출할지
@ -25,15 +26,23 @@ description: PLAN/CODE_REVIEW 작성 직전 현재 작업 증거만으로 cloud/
- `context_snapshot`: 함께 유지해야 하는 source/test/diff/log/contract 범위와 분할 가능성 (필수)
- `ownership_snapshot`: 공유 상태, 병행 작업, sibling task, 외부 책임 경계 (필수)
- `decision_snapshot`: 미해결 의미 판단, 사용자 권한이 필요한 결정, 외부 환경 의존성 (필수)
- `loop_risk_snapshot`: `build`가 target일 때 아래 다섯 범주의 현재 작업 원시 사실 (필수)
- `ordered_transitions`: `state_count`, `adverse_paths`(partial/retry/cancel/recovery/stale/duplicate), `evidence`
- `concurrent_consistency`: `actor_count`, `constraints`(atomic snapshot/lock order/exactly-once/race-free), `evidence`
- `boundary_contract`: `component_count`, `consumer_count`, `constraints`(validation/immutability/compatibility), `evidence`
- `structured_interpretation`: `mechanisms`(parser/lexer/protocol assembly/multi-source merge), `hazards`(nesting/quoting/precedence/deduplication), `evidence`
- `variant_product`: `independent_axis_count`, `combination_verification_required`, `evidence`
- `invalidation_evidence`: 검증 실패, 실제 로그, review finding, 변경된 범위 등 현재 판정을 바꾸는 원시 증거 (선택)
이전 `lane`, `grade`, 점수, 라우팅 사유, routed filename은 입력에서 제외한다.
이전 PLAN/CODE_REVIEW 문서의 코드 경로, 실제 로그, finding은 원본에서 재확인한 뒤 원시 증거로만 사용할 수 있다.
해당 범주가 없으면 빈 목록 또는 0/false로 명시한다. 현재 증거로 부재와 미확인을 구분할 수 없으면 값을 추측하지 않고 `unknown`으로 둔다. 위험 등급이나 route 추정치를 입력하지 않는다.
## 먼저 확인할 것
- [ ] `agent-ops/skills/common/finalize-task-routing/scripts/finalize-task-route.sh`가 실행 가능한가
- [ ] `agent-ops/skills/common/finalize-task-routing/scripts/finalize-task-policy.sh`와 내부 formatter `finalize-task-route.sh`가 실행 가능한가
- [ ] 호출자가 plan 분석, scope, split, 검증 계획을 모두 확정했는가
- [ ] `build` target의 `loop_risk_snapshot`이 분할 이후 현재 범위의 원시 사실로 채워졌는가
- [ ] `targets`마다 필요한 구현 또는 리뷰 작업이 구분되어 있는가
- [ ] 이전 lane/G와 그 평가 문구가 입력에서 격리되었는가
- [ ] `isolated-reassessment`이면 이전 route-bearing 항목을 제거한 중립 snapshot을 현재 invocation의 sealed scratch block에 고정했는가
@ -60,42 +69,85 @@ description: PLAN/CODE_REVIEW 작성 직전 현재 작업 증거만으로 cloud/
- `evidence_trusted`: 검증이 의도한 실제 경로를 실행하며 출력이 재현 가능하고 현재 상태와 일치하는가
- `ownership_closed`: 공유 상태, 병행 작업, 외부 소유권이 판단을 모호하게 만들지 않는가
- `decision_closed`: 사용자 권한이나 미해결 외부 의미 결정 없이 작업을 완료할 수 있는가
- 동시성, dirty worktree, 외부 명령, 많은 finding은 그 자체로 lane을 결정하지 않는다. 위 폐쇄성에 미치는 영향만 평가한다.
- 동시성, dirty worktree, 외부 명령, 많은 finding은 그 자체로 기본 lane을 결정하지 않는다. 위 폐쇄성에 미치는 영향을 평가하고, build의 복합 동시성 signature 여부는 Step 6에서 별도로 판정한다.
3. **판정 가능성 확인**
- 하나라도 `unknown`이면 `status: needs_evidence`와 필요한 증거를 반환한다. lane, grade, filename은 반환하지 않는다.
- `build` target의 `loop_risk_snapshot`에 필수 field가 없거나 `unknown`이면 동일하게 `needs_evidence`를 반환한다. 단순 미기재를 위험 없음으로 간주하지 않는다.
- 하나 이상의 값이 `false`이고 cloud의 더 넓은 컨텍스트나 진단도 그 폐쇄 실패를 해소할 수 없으면 `status: blocked`, 폐쇄 실패별 `blocked_reason`, 해제 조건을 반환한다. 사용자 권한, secret, 접근 권한, 제품 결정은 대표적인 예일 뿐이며 이에 한정하지 않는다.
- 호출자는 `needs_evidence`를 cloud로 해석하지 말고 증거를 보강한 뒤 이 스킬을 처음부터 다시 실행한다.
4. **Lane 결정**
- 모든 폐쇄성이 `true`인 target `local`로 정한다.
- `unknown`이 없고 하나 이상의 폐쇄성이 `false`이며 cloud의 더 넓은 컨텍스트나 진단이 그 실패를 실제로 해소할 수 있을 때 `cloud`로 정한다.
4. **기본 Lane 결정**
- 모든 폐쇄성이 `true`인 target`base_lane` `local`로 정한다.
- `unknown`이 없고 하나 이상의 폐쇄성이 `false`이며 cloud의 더 넓은 컨텍스트나 진단이 그 실패를 실제로 해소할 수 있을 때 `base_lane`을 `cloud`로 정한다.
- `false`를 cloud가 해소할 수 있는지 근거를 기록한다. 해소 가능성을 설명할 수 없으면 추측으로 cloud를 선택하지 않고 Step 3의 `blocked`를 반환한다.
- 실패 후에도 현재 폐쇄성이 모두 `true`이면 다시 `local`을 선택할 수 있다.
- lane은 capability fit이며 작업량이나 모델 사용량 균형을 위한 값이 아니다.
- 실패 후에도 현재 폐쇄성이 모두 `true`이면 `base_lane: local`을 선택할 수 있다. 최종 lane은 Step 6의 현재 구조 위험 판정을 적용한 뒤 확정한다.
- 기본 lane은 capability fit이며 작업량이나 모델 사용량 균형을 위한 값이 아니다.
5. **G 등급 재산정**
- lane을 정한 뒤에만 각 target을 아래 다섯 차원에서 `0`, `1`, `2`로 처음부터 채점한다.
5. **기본 G 등급 재산정**
- 기본 lane을 정한 뒤에만 각 target을 아래 다섯 차원에서 `0`, `1`, `2`로 처음부터 채점한다.
- `scope_coupling`: `0` 단일 국소 변경 / `1` 여러 연결 지점 / `2` 다중 모듈·계약
- `state_concurrency`: `0` 무상태 / `1` 상태 전이 / `2` 공유 상태·순서·동시성
- `blast_irreversibility`: `0` 내부 가역 / `1` 다중 호출자·설정·지속 상태 / `2` 외부 계약·권한·비가역 영향
- `evidence_diagnosis`: `0` 직접 증거 / `1` 복수 증거 조합 / `2` 충돌·불신 증거 진단
- `verification_complexity`: `0` 단일 결정 명령 / `1` 복수 단계·통합 검증 / `2` 외부·상호작용·장시간 검증
- grade는 `max(1, 다섯 점수의 합)`이며 유효 범위는 `G01`부터 `G10`이다.
- `base_grade``max(1, 다섯 점수의 합)`이며 유효 범위는 `G01`부터 `G10`이다.
- 이전 grade, 실패 횟수, lane 변경은 점수에 더하지 않는다.
6. **파일명 확정**
- 각 target에 대해 다음 명령을 실행하고 stdout의 grade와 filename을 그대로 사용한다.
6. **구현 장기 반복 위험 사전 판정**
- 이 단계의 signature 판정은 `build`에만 적용한다. `review`는 자체 폐쇄성과 점수를 먼저 독립 평가한 뒤 Step 7의 capability floor만 적용한다.
- 아래 조건을 만족하는 범주마다 최대 한 개의 signature를 기록한다.
- `temporal_state`: 순서가 있는 상태가 3개 이상이고 partial/retry/cancel/recovery/stale/duplicate 경로가 하나 이상이다.
- `concurrent_consistency`: actor/reader/writer가 2개 이상이고 atomic snapshot/lock order/exactly-once/race-free 제약이 하나 이상이다.
- `boundary_contract`: 하나의 동작 계약이나 불변조건이 3개 이상 component 또는 2개 이상 독립 consumer에 걸치고 validation/immutability/compatibility 제약이 하나 이상이다.
- `structured_interpretation`: parser/lexer/protocol assembly/multi-source merge 중 하나와 nesting/quoting/precedence/deduplication 위험 중 하나가 함께 있다.
- `variant_product`: 독립 variant 축이 2개 이상이고 조합별 검증이 필요하다.
- 포맷 변경, plan/review evidence 기록, 명령 출력 정리, 환경·도구 실패, 단일 국소 assertion, 동작 계약을 바꾸지 않는 기계적 rename/pass-through 전파는 signature 근거에서 제외한다.
- signature가 하나 이상이면 `triggered: true`, 없으면 `triggered: false`다. signature 개수는 audit 정보일 뿐 grade 점수나 위험 단계가 아니다.
- 이 판정은 현재 최초 범위의 구조만 사용한다. 과거 실패 횟수, review finding 개수, 이전 route는 signature를 추가하지 않는다.
- Step 3의 `needs_evidence``blocked`가 항상 우선한다. routed 가능한 `build`에는 다음 effective route를 적용한다.
- `triggered: false`: `base_lane`, `base_grade`를 유지한다.
- `triggered: true`: `lane: cloud`, `grade: max(base_grade, G07)`을 적용한다.
- build G08~G10은 signature 개수가 아니라 Step 5의 다섯 차원 점수로만 선택한다.
7. **공식 Review capability floor**
- review의 폐쇄성과 다섯 차원 점수는 계속 독립 평가해 `base_lane`, `base_grade`로 보존한다.
- 공식 review의 최종 lane은 항상 `cloud`, 최종 grade는 `max(base_grade, G09)`이다.
- build와 review를 함께 평가하고 최종 build가 `G09` 또는 `G10`이면 review floor를 상한인 `G10`으로 맞춘다. 따라서 grade headroom이 있으면 review가 build보다 항상 높다.
- 이 단계는 review 점수를 build 점수로 복사하지 않는다. 독립 점수와 official-review floor를 함께 출력한다.
- `review`만 단독 평가해도 같은 cloud-G09 minimum을 적용한다.
8. **파일명 확정**
- `plan`처럼 build와 review를 함께 평가할 때는 다음 pair finalizer를 정확히 한 번 실행한다.
```bash
agent-ops/skills/common/finalize-task-routing/scripts/finalize-task-route.sh \
<build|review> <local|cloud> \
agent-ops/skills/common/finalize-task-routing/scripts/finalize-task-policy.sh pair \
<build-base-lane> <true|false> \
<build-scope> <build-state> <build-blast> <build-evidence> <build-verification> \
<review-base-lane> \
<review-scope> <review-state> <review-blast> <review-evidence> <review-verification>
```
- 두 번째 인자는 build의 `loop_risk.triggered` boolean만 전달한다. matched signature 목록이나 개수는 전달하지 않는다.
- stdout의 `finalizer`, `finalizer_mode`, `build_base_*`, `build_floor`, `build_*`, `review_base_*`, `review_floor`, `review_*` 값을 그대로 출력 계약과 canonical filename에 사용한다.
- pair finalizer는 triggered build의 cloud-G07 minimum, 공식 review의 cloud-G09 minimum, build G09~G10일 때 review G10을 결정적으로 적용한다.
- `build` 또는 `review` 단독 평가도 같은 policy finalizer의 해당 mode를 사용한다.
```bash
agent-ops/skills/common/finalize-task-routing/scripts/finalize-task-policy.sh build \
<base-lane> <true|false> \
<scope_coupling> <state_concurrency> <blast_irreversibility> \
<evidence_diagnosis> <verification_complexity>
agent-ops/skills/common/finalize-task-routing/scripts/finalize-task-policy.sh review \
<base-lane> \
<scope_coupling> <state_concurrency> <blast_irreversibility> \
<evidence_diagnosis> <verification_complexity>
```
- 모델이 점수 합산, zero-padding, prefix 조합을 직접 계산하지 않는다.
- 두 target은 같은 lane/G를 복사하지 말고 각자의 실행 범위로 독립 평가한다.
- 저수준 `finalize-task-route.sh`는 policy finalizer 내부 계산 전용이다. 호출자는 canonical route를 얻기 위해 직접 실행하지 않는다.
- 모델이 점수 합산, signature-count 변환, grade floor, review 상한 분기, zero-padding, prefix 조합을 직접 계산하지 않는다.
- 두 target의 폐쇄성과 grade 점수는 각자의 실행 범위로 독립 평가하고, 최종 review route에만 Step 7의 official-review floor를 적용한다.
- 모든 요청 target이 `routed`일 때만 filename을 반환한다.
- 결과 확정 뒤 scope, 검증, 증거, 소유권, 결정 조건이 달라지면 결과를 무효화하고 Step 1부터 다시 실행한다.
@ -105,16 +157,23 @@ description: PLAN/CODE_REVIEW 작성 직전 현재 작업 증거만으로 cloud/
- [ ] 재평가가 `isolated-reassessment`로 실행되었고 새 결과 전까지 이전 평가가 노출·비교되지 않았는가
- [ ] `unknown` 또는 `blocked` 상태에서 lane/G/filename을 만들지 않았는가
- [ ] 이전 lane/G, 실패 횟수, finding 개수를 승계하거나 자동 승격 근거로 사용하지 않았는가
- [ ] lane을 먼저 정하고 G 등급을 별도 차원 점수로 산정했는가
- [ ] 각 target에 formatter script를 실행했고 grade와 filename을 stdout 그대로 사용했는가
- [ ] build/review filename이 target별 formatter 결과와 정확히 일치하는가
- [ ] 기본 lane을 먼저 정하고 기본 G 등급을 별도 차원 점수로 산정했는가
- [ ] build의 다섯 loop-risk 범주가 현재 구조 증거로 평가되고 제외 항목이 signature에서 빠졌는가
- [ ] signature 개수를 G 등급으로 변환하지 않고 triggered build에 cloud-G07 floor만 적용했는가
- [ ] review의 독립 점수를 보존하면서 최종 route에 cloud-G09 minimum을 적용했고 build가 G09~G10이면 review가 G10인가
- [ ] build/review 동시 평가에는 policy finalizer의 `pair` mode를 정확히 한 번 실행했고 stdout의 base/floor/final route와 filename을 그대로 사용했는가
- [ ] build/review filename이 policy finalizer 결과와 정확히 일치하는가
- 검증 실패 시: 결과를 `unrouted`로 폐기하고 누락된 현재 증거부터 보강한 뒤 전체 평가를 다시 수행한다.
## 출력 형식
기존 호출자 호환성을 위해 최종 `lane`, `grade_scores`, `grade`, `filename`은 target 최상위에 유지한다. 두 target의 독립 평가를 `base_route`로 남기고, build에는 `loop_risk`, review에는 공식 상위 모델을 뜻하는 `capability_floor`를 추가한다. `grade_scores`는 기본 차원 점수이므로 floor 적용 시 최종 `grade`보다 합이 작을 수 있으며, 그 차이는 해당 floor field로 설명한다.
```yaml
status: routed | needs_evidence | blocked
evaluation_mode: first-pass | isolated-reassessment
finalizer: finalize-task-policy.sh
finalizer_mode: pair
targets:
build:
closures:
@ -131,15 +190,26 @@ targets:
evidence_trusted: "<current evidence>"
ownership_closed: "<current evidence>"
decision_closed: "<current evidence>"
lane: local
base_route:
lane: local
grade: G04
loop_risk:
matched_signatures: [boundary_contract, structured_interpretation, variant_product]
triggered: true
grade_floor: G07
basis:
boundary_contract: "<current structural evidence>"
structured_interpretation: "<current structural evidence>"
variant_product: "<current structural evidence>"
lane: cloud
grade_scores:
scope_coupling: 1
state_concurrency: 1
blast_irreversibility: 1
evidence_diagnosis: 0
verification_complexity: 1
grade: G04
filename: PLAN-local-G04.md
grade: G07
filename: PLAN-cloud-G07.md
review:
closures:
scope_closed: true
@ -155,6 +225,13 @@ targets:
evidence_trusted: "<current evidence>"
ownership_closed: "<current evidence>"
decision_closed: "<current evidence>"
base_route:
lane: cloud
grade: G06
capability_floor:
source: official_review
lane: cloud
grade: G09
lane: cloud
grade_scores:
scope_coupling: 1
@ -162,13 +239,13 @@ targets:
blast_irreversibility: 1
evidence_diagnosis: 2
verification_complexity: 1
grade: G06
filename: CODE_REVIEW-cloud-G06.md
grade: G09
filename: CODE_REVIEW-cloud-G09.md
missing_evidence: []
blocked_reason: null
```
`needs_evidence` 또는 `blocked`에서는 해당 target의 `lane`, `grade_scores`, `grade`, `filename` `null`로 둔다.
`needs_evidence` 또는 `blocked`에서는 `finalizer: null`, `finalizer_mode: null`로 두고, 모든 target의 `base_route`, `lane`, `grade_scores`, `grade`, `filename`과 build의 `loop_risk`, review의 `capability_floor` `null`로 둔다.
## 금지 사항
@ -176,8 +253,10 @@ blocked_reason: null
- 기존 평가가 있는 재평가를 같은 평가 context에서 수행하지 않는다.
- local 실패를 이유로 cloud로 자동 승격하지 않는다.
- 높은 local G로 cloud에 필요한 컨텍스트나 판단을 보상하지 않는다.
- 파일 수, 동시성, dirty 상태, Required 개수 같은 단일 휴리스틱으로 lane을 정하지 않는다.
- grade나 filename을 formatter script 출력과 다르게 수작업으로 만들지 않는다.
- 파일 수, actor 수, 상태 수, dirty 상태, Required 개수 같은 단일 사실만으로 lane을 정하지 않는다. build 사전 판정은 정의된 복합 signature 전체 조건을 충족해야 한다.
- signature 개수로 G08~G10을 선택하거나 여러 signature를 grade 점수처럼 합산하지 않는다.
- 공식 review를 `local` 또는 G08 이하로 확정하지 않는다. build가 G09~G10이면 review를 G09로 두지 않는다.
- grade나 filename을 policy finalizer 출력과 다르게 수작업으로 만들지 않는다.
- `needs_evidence``cloud`로 치환하거나 미완성 상태에서 routed filename을 만들지 않는다.
- 격리 재평가를 위해 sub-agent/session을 만들거나 collaboration tool 또는 `wait`를 호출하지 않는다.
- PLAN, CODE_REVIEW, archive, complete.log 파일을 직접 생성·수정·이동하지 않는다.

View file

@ -1,4 +1,4 @@
interface:
display_name: "Finalize Task Routing"
short_description: "Finalize task lane, grade, and filenames"
default_prompt: "Use $finalize-task-routing after task analysis to independently determine build/review lanes, grades, and canonical filenames."
short_description: "Finalize routes with capability floors"
default_prompt: "Use $finalize-task-routing after task analysis to score build/review independently, apply the build loop-risk floor and official review upper-tier floor, and return canonical filenames."

View file

@ -0,0 +1,137 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
printf '%s\n' \
'usage:' \
' finalize-task-policy.sh pair <build-base-lane> <true|false> <build-scope> <build-state> <build-blast> <build-evidence> <build-verification> <review-base-lane> <review-scope> <review-state> <review-blast> <review-evidence> <review-verification>' \
' finalize-task-policy.sh build <base-lane> <true|false> <scope> <state> <blast> <evidence> <verification>' \
' finalize-task-policy.sh review <base-lane> <scope> <state> <blast> <evidence> <verification>' >&2
exit 2
}
script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
formatter="$script_dir/finalize-task-route.sh"
field() {
local output=$1
local key=$2
local value
value=$(sed -n "s/^${key}=//p" <<<"$output")
if [[ -z "$value" ]]; then
printf 'missing formatter field: %s\n' "$key" >&2
exit 3
fi
printf '%s\n' "$value"
}
finalize_build() {
local base_lane=$1
local risk_triggered=$2
shift 2
local scores=("$@")
local base
local final
case "$risk_triggered" in
true|false)
;;
*)
usage
;;
esac
base=$("$formatter" build "$base_lane" "${scores[@]}")
BUILD_BASE_LANE=$(field "$base" lane)
BUILD_BASE_GRADE=$(field "$base" grade)
BUILD_RISK_TRIGGERED=$risk_triggered
if [[ "$risk_triggered" == true ]]; then
BUILD_FLOOR=G07
final=$("$formatter" build cloud "${scores[@]}" 7)
else
BUILD_FLOOR=none
final=$base
fi
BUILD_LANE=$(field "$final" lane)
BUILD_GRADE=$(field "$final" grade)
BUILD_FILENAME=$(field "$final" filename)
}
finalize_review() {
local base_lane=$1
local floor_number=$2
shift 2
local scores=("$@")
local base
local final
base=$("$formatter" review "$base_lane" "${scores[@]}")
REVIEW_BASE_LANE=$(field "$base" lane)
REVIEW_BASE_GRADE=$(field "$base" grade)
REVIEW_FLOOR=$(printf 'G%02d' "$floor_number")
final=$("$formatter" review cloud "${scores[@]}" "$floor_number")
REVIEW_LANE=$(field "$final" lane)
REVIEW_GRADE=$(field "$final" grade)
REVIEW_FILENAME=$(field "$final" filename)
}
emit_build() {
printf 'build_base_lane=%s\n' "$BUILD_BASE_LANE"
printf 'build_base_grade=%s\n' "$BUILD_BASE_GRADE"
printf 'build_risk_triggered=%s\n' "$BUILD_RISK_TRIGGERED"
printf 'build_floor=%s\n' "$BUILD_FLOOR"
printf 'build_lane=%s\n' "$BUILD_LANE"
printf 'build_grade=%s\n' "$BUILD_GRADE"
printf 'build_filename=%s\n' "$BUILD_FILENAME"
}
emit_review() {
printf 'review_base_lane=%s\n' "$REVIEW_BASE_LANE"
printf 'review_base_grade=%s\n' "$REVIEW_BASE_GRADE"
printf 'review_floor=%s\n' "$REVIEW_FLOOR"
printf 'review_lane=%s\n' "$REVIEW_LANE"
printf 'review_grade=%s\n' "$REVIEW_GRADE"
printf 'review_filename=%s\n' "$REVIEW_FILENAME"
}
mode=${1:-}
case "$mode" in
pair)
[[ $# -eq 14 ]] || usage
finalize_build "$2" "$3" "${@:4:5}"
if [[ "$BUILD_GRADE" == G09 || "$BUILD_GRADE" == G10 ]]; then
review_floor=10
else
review_floor=9
fi
finalize_review "$9" "$review_floor" "${@:10:5}"
;;
build)
[[ $# -eq 8 ]] || usage
finalize_build "$2" "$3" "${@:4:5}"
;;
review)
[[ $# -eq 7 ]] || usage
finalize_review "$2" 9 "${@:3:5}"
;;
*)
usage
;;
esac
printf 'finalizer=finalize-task-policy.sh\n'
printf 'finalizer_mode=%s\n' "$mode"
case "$mode" in
pair)
emit_build
emit_review
;;
build)
emit_build
;;
review)
emit_review
;;
esac

View file

@ -3,18 +3,18 @@ set -euo pipefail
usage() {
printf '%s\n' \
'usage: finalize-task-route.sh <build|review> <local|cloud> <scope> <state> <blast> <evidence> <verification>' >&2
'usage: finalize-task-route.sh <build|review> <local|cloud> <scope> <state> <blast> <evidence> <verification> [min-grade]' >&2
exit 2
}
if [[ $# -ne 7 ]]; then
if [[ $# -ne 7 && $# -ne 8 ]]; then
usage
fi
target=$1
lane=$2
shift 2
scores=("$@")
scores=("${@:3:5}")
min_grade=${8-1}
case "$target" in
build)
@ -36,6 +36,14 @@ case "$lane" in
;;
esac
case "$min_grade" in
1|2|3|4|5|6|7|8|9|10)
;;
*)
usage
;;
esac
sum=0
for score in "${scores[@]}"; do
case "$score" in
@ -52,6 +60,10 @@ if [[ $sum -lt 1 ]]; then
sum=1
fi
if [[ $sum -lt $min_grade ]]; then
sum=$min_grade
fi
printf 'target=%s\n' "$target"
printf 'lane=%s\n' "$lane"
printf 'grade=G%02d\n' "$sum"

View file

@ -0,0 +1,318 @@
#!/usr/bin/env python3
from __future__ import annotations
import itertools
import subprocess
import unittest
from pathlib import Path
SKILL_DIR = Path(__file__).resolve().parents[1]
FORMATTER = SKILL_DIR / "scripts" / "finalize-task-route.sh"
POLICY_FINALIZER = SKILL_DIR / "scripts" / "finalize-task-policy.sh"
GRADE_VECTORS = {
1: (0, 0, 0, 0, 0),
2: (2, 0, 0, 0, 0),
3: (2, 1, 0, 0, 0),
4: (2, 2, 0, 0, 0),
5: (2, 2, 1, 0, 0),
6: (2, 2, 2, 0, 0),
7: (2, 2, 2, 1, 0),
8: (2, 2, 2, 2, 0),
9: (2, 2, 2, 2, 1),
10: (2, 2, 2, 2, 2),
}
def run(script: Path, *args: object, check: bool = True) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[str(script), *(str(arg) for arg in args)],
check=check,
capture_output=True,
text=True,
timeout=5,
)
def fields(stdout: str) -> dict[str, str]:
return dict(line.split("=", 1) for line in stdout.splitlines())
class FinalizeTaskRoutingTests(unittest.TestCase):
def test_formatter_all_score_vectors_without_floor(self) -> None:
for target, lane, scores in itertools.product(
("build", "review"),
("local", "cloud"),
itertools.product(range(3), repeat=5),
):
with self.subTest(target=target, lane=lane, scores=scores):
result = fields(run(FORMATTER, target, lane, *scores).stdout)
expected_grade = max(1, sum(scores))
prefix = "PLAN" if target == "build" else "CODE_REVIEW"
self.assertEqual(result["grade"], f"G{expected_grade:02d}")
self.assertEqual(
result["filename"],
f"{prefix}-{lane}-G{expected_grade:02d}.md",
)
def test_formatter_exhaustive_grade_floor_matrix(self) -> None:
for target, lane, base_grade, floor in itertools.product(
("build", "review"),
("local", "cloud"),
range(1, 11),
range(1, 11),
):
with self.subTest(
target=target,
lane=lane,
base_grade=base_grade,
floor=floor,
):
result = fields(
run(
FORMATTER,
target,
lane,
*GRADE_VECTORS[base_grade],
floor,
).stdout
)
expected_grade = max(base_grade, floor)
prefix = "PLAN" if target == "build" else "CODE_REVIEW"
self.assertEqual(result["target"], target)
self.assertEqual(result["lane"], lane)
self.assertEqual(result["grade"], f"G{expected_grade:02d}")
self.assertEqual(
result["filename"],
f"{prefix}-{lane}-G{expected_grade:02d}.md",
)
def test_pair_exhaustive_route_matrix(self) -> None:
for (
build_lane,
risk_triggered,
build_base_grade,
review_base_lane,
review_base_grade,
) in itertools.product(
("local", "cloud"),
("false", "true"),
range(1, 11),
("local", "cloud"),
range(1, 11),
):
with self.subTest(
build_lane=build_lane,
risk_triggered=risk_triggered,
build_base_grade=build_base_grade,
review_base_lane=review_base_lane,
review_base_grade=review_base_grade,
):
result = fields(
run(
POLICY_FINALIZER,
"pair",
build_lane,
risk_triggered,
*GRADE_VECTORS[build_base_grade],
review_base_lane,
*GRADE_VECTORS[review_base_grade],
).stdout
)
if risk_triggered == "true":
expected_build_lane = "cloud"
expected_build_grade = max(build_base_grade, 7)
expected_build_floor = "G07"
else:
expected_build_lane = build_lane
expected_build_grade = build_base_grade
expected_build_floor = "none"
expected_review_floor = (
10 if expected_build_grade >= 9 else 9
)
expected_review_grade = max(
review_base_grade,
expected_review_floor,
)
self.assertEqual(result["build_base_lane"], build_lane)
self.assertEqual(
result["finalizer"],
"finalize-task-policy.sh",
)
self.assertEqual(result["finalizer_mode"], "pair")
self.assertEqual(
result["build_base_grade"],
f"G{build_base_grade:02d}",
)
self.assertEqual(
result["build_risk_triggered"],
risk_triggered,
)
self.assertEqual(result["build_floor"], expected_build_floor)
self.assertEqual(result["build_lane"], expected_build_lane)
self.assertEqual(
result["build_grade"],
f"G{expected_build_grade:02d}",
)
self.assertEqual(
result["build_filename"],
f"PLAN-{expected_build_lane}-G{expected_build_grade:02d}.md",
)
self.assertEqual(result["review_base_lane"], review_base_lane)
self.assertEqual(
result["review_base_grade"],
f"G{review_base_grade:02d}",
)
self.assertEqual(
result["review_floor"],
f"G{expected_review_floor:02d}",
)
self.assertEqual(result["review_lane"], "cloud")
self.assertEqual(
result["review_grade"],
f"G{expected_review_grade:02d}",
)
self.assertEqual(
result["review_filename"],
f"CODE_REVIEW-cloud-G{expected_review_grade:02d}.md",
)
def test_raw_sum_one_stays_g01(self) -> None:
result = fields(
run(FORMATTER, "build", "local", 1, 0, 0, 0, 0).stdout
)
self.assertEqual(result["grade"], "G01")
def test_single_target_policy_matrix(self) -> None:
for lane, risk_triggered, base_grade in itertools.product(
("local", "cloud"),
("false", "true"),
range(1, 11),
):
with self.subTest(
target="build",
lane=lane,
risk_triggered=risk_triggered,
base_grade=base_grade,
):
result = fields(
run(
POLICY_FINALIZER,
"build",
lane,
risk_triggered,
*GRADE_VECTORS[base_grade],
).stdout
)
expected_lane = "cloud" if risk_triggered == "true" else lane
expected_grade = (
max(base_grade, 7)
if risk_triggered == "true"
else base_grade
)
expected_floor = (
"G07" if risk_triggered == "true" else "none"
)
self.assertEqual(
result["finalizer"],
"finalize-task-policy.sh",
)
self.assertEqual(result["finalizer_mode"], "build")
self.assertEqual(result["build_base_lane"], lane)
self.assertEqual(
result["build_base_grade"],
f"G{base_grade:02d}",
)
self.assertEqual(
result["build_risk_triggered"],
risk_triggered,
)
self.assertEqual(result["build_floor"], expected_floor)
self.assertEqual(result["build_lane"], expected_lane)
self.assertEqual(
result["build_grade"],
f"G{expected_grade:02d}",
)
self.assertEqual(
result["build_filename"],
f"PLAN-{expected_lane}-G{expected_grade:02d}.md",
)
for lane, base_grade in itertools.product(
("local", "cloud"),
range(1, 11),
):
with self.subTest(
target="review",
lane=lane,
base_grade=base_grade,
):
result = fields(
run(
POLICY_FINALIZER,
"review",
lane,
*GRADE_VECTORS[base_grade],
).stdout
)
expected_grade = max(base_grade, 9)
self.assertEqual(
result["finalizer"],
"finalize-task-policy.sh",
)
self.assertEqual(result["finalizer_mode"], "review")
self.assertEqual(result["review_base_lane"], lane)
self.assertEqual(
result["review_base_grade"],
f"G{base_grade:02d}",
)
self.assertEqual(result["review_floor"], "G09")
self.assertEqual(result["review_lane"], "cloud")
self.assertEqual(
result["review_grade"],
f"G{expected_grade:02d}",
)
self.assertEqual(
result["review_filename"],
f"CODE_REVIEW-cloud-G{expected_grade:02d}.md",
)
def test_invalid_inputs_are_rejected(self) -> None:
invalid_formatter_cases = (
("deploy", "local", 0, 0, 0, 0, 0),
("build", "hybrid", 0, 0, 0, 0, 0),
("build", "local", 3, 0, 0, 0, 0),
("build", "local", 0, 0, 0, 0, 0, 0),
("review", "cloud", 0, 0, 0, 0, 0, 11),
("review", "cloud", 0, 0, 0, 0, 0, "G09"),
)
for args in invalid_formatter_cases:
with self.subTest(formatter_args=args):
self.assertEqual(run(FORMATTER, *args, check=False).returncode, 2)
valid_scores = GRADE_VECTORS[4]
invalid_policy_cases = (
("pair", "local", "yes", *valid_scores, "local", *valid_scores),
("pair", "local", "unknown", *valid_scores, "local", *valid_scores),
("pair", "hybrid", "true", *valid_scores, "local", *valid_scores),
("pair", "local", "true", *valid_scores, "remote", *valid_scores),
("pair", "local", "true", 3, 0, 0, 0, 0, "local", *valid_scores),
("pair", "local", "true", *valid_scores, "local", *valid_scores, 2),
("build", "local", "unknown", *valid_scores),
("review", "local", "false", *valid_scores),
("review", "local", *valid_scores[:-1]),
("deploy", "local", *valid_scores),
)
for args in invalid_policy_cases:
with self.subTest(policy_args=args):
self.assertEqual(
run(POLICY_FINALIZER, *args, check=False).returncode,
2,
)
if __name__ == "__main__":
unittest.main()

View file

@ -109,6 +109,7 @@ Task directory naming rules:
Final routing boundary:
- Do not estimate, inherit, or write lane/G while analyzing. Keep the task `unrouted` until all plan scope, split, verification, evidence, ownership, and decision facts are complete.
- During Step 2, collect the current build scope's raw `loop_risk_snapshot` facts defined by `finalize-task-routing`: ordered transitions and adverse paths, concurrent actors and consistency constraints, component/consumer boundaries and contract constraints, structured interpretation hazards, and independent variant axes. Record 0/false/empty only when absence is evidenced; otherwise record `unknown`. Do not classify signatures, risk level, lane, or grade in this skill.
- Execute `agent-ops/skills/common/finalize-task-routing/SKILL.md` for both `build` and `review` after analysis. Its routed outputs are the only source for active PLAN/CODE_REVIEW filenames.
- Apply this boundary to first-pass plans, review WARN/FAIL follow-ups, USER_REVIEW replans, and each split subtask independently.
- Exception: an explicit `refine-local-plans` run may split an unstarted `PLAN-local-G??.md` into strict-subset local children once. Those children retain the original build/review lane, G, and canonical basenames without rerouting; normal plan creation and review follow-up never use this exception.
@ -200,6 +201,7 @@ Complete all items below before creating active plan/review files. Work through
- [ ] **Read all test files in full** — read every test file that exercises the changed behavior, including files implied by matched agent-test profiles when any are used and by the repository test layout.
- [ ] **Assess test coverage** — for each behavior change, explicitly record whether existing tests cover it.
- [ ] **Assess split boundaries first** — identify dependency boundaries, ownership boundaries, API-vs-call-site phases, risk profile splits, and independently verifiable behavior or contract slices before selecting plan files. Keep each production slice with its minimum required tests; use a test-only sibling only for additional integration or closure evidence. If any split gate applies or the decision is uncertain, write multiple subtask directories under one task group instead of one plan.
- [ ] **Capture build loop-risk facts** — after the split decision, fill every `loop_risk_snapshot` field defined by `finalize-task-routing` from the current subtask's source, tests, contracts, and verification plan. Use explicit 0/false/empty values only when absence is evidenced; otherwise use `unknown`. Do not classify signatures or choose a route here.
- [ ] **Resolve split predecessor completion** — if the selected or proposed subtask directory has `NN+PP[,QQ...]_...`, resolve each predecessor index under the same task group. Check only the active and archive candidate patterns defined in the task directory naming rules. Record found active/archive paths, missing predecessors, or ambiguous matches in `분석 결과 > 분할 판단` and, when order matters, `의존 관계 및 구현 순서`.
- [ ] **Grep all symbol references** — for any renamed or removed symbol, find every call site and import chain.
- [ ] **Check dependency manifests** — before adding any new package, verify its presence in go.mod / package manifest.
@ -211,11 +213,11 @@ Complete all items below before creating active plan/review files. Work through
This step is mandatory and must be the last semantic decision before routed filenames are fixed. Complete Step 2 and prepare the full plan structure in memory before starting it.
- [ ] **Build neutral input** — prepare current scope, affected paths, invariants, context size, split result, verification plan, actual evidence, ownership, unresolved decisions, and failure evidence for `build` and `review`. Exclude every previous lane, grade, routing score, rationale, filename, and archive basename that exposes them.
- [ ] **Build neutral input** — prepare current scope, affected paths, invariants, context size, split result, verification plan, actual evidence, ownership, unresolved decisions, and failure evidence for `build` and `review`, plus the current build `loop_risk_snapshot` raw facts. Exclude every previous lane, grade, routing score, risk level, rationale, filename, and archive basename that exposes them.
- [ ] **Select evaluation mode** — use `first-pass` only when no prior routing exists. Review follow-ups, invalidated routes, and USER_REVIEW replans use `isolated-reassessment`: build a route-free neutral snapshot, seal it as the only evaluation input, and evaluate it in a scratch block inside the current invocation. This logical isolation does not require or permit a sub-agent, delegated session, collaboration tool, or wait loop.
- [ ] **Run the routing skill** — read and fully execute `agent-ops/skills/common/finalize-task-routing/SKILL.md` with both targets in the selected mode. Do not reproduce or replace its decision logic inside this skill. If isolated evaluation is unavailable, treat the routing result as `blocked`; do not silently evaluate in the current anchored context.
- [ ] **Resolve non-final states** — for `needs_evidence`, collect the named evidence and rerun from `unrouted`; for `blocked`, stop without mutating task files and report the exact release condition. In `prepare-follow-up`, leave the verdict-appended active pair in place so code-review recovery can resume without losing the loop state.
- [ ] **Accept only routed output** — require independent `build` and `review` closure records, lane, grade scores, GNN, and canonical filename.
- [ ] **Accept only routed output** — require `finalizer: finalize-task-policy.sh`, `finalizer_mode: pair`, independent `build` and `review` closure records, base routes, grade scores, final lane/G, and canonical filenames, plus the build loop-risk result and official review cloud-G09 capability floor.
- [ ] **Freeze the result** — use those exact basenames in Steps 4-6. If any routing input changes before the files are written, discard both outputs and repeat this step from the beginning.
## Step 4 - Archive Existing Active Files
@ -283,7 +285,7 @@ Required sections:
- `심볼 참조`: list renamed/removed symbols and every call site found, or state "none" if no symbols were changed.
- `분할 판단`: state that the split decision policy was evaluated before choosing plan files. For a single plan, explain why each relevant split gate does not apply and why single-plan coordination is safer than splitting. For multi-plan output, list the shared task group plus each sibling subtask directory and dependency relationship. For dependent subtask plans, include each predecessor index and whether it is satisfied by an active or archived `complete.log`, missing, or ambiguous.
- `범위 결정 근거`: state which files or areas were explicitly excluded from this change and why. This is the boundary justification — the implementing agent must not silently expand scope beyond what is recorded here.
- `최종 라우팅`: record the `evaluation_mode` plus `build` and `review` closure results, grade scores, lane, GNN, and canonical filename returned by `finalize-task-routing`. Do not include or compare a previous loop's lane/G.
- `최종 라우팅`: record the `evaluation_mode`, `finalizer`, `finalizer_mode`, both targets' closure results, base routes, grade scores, final lane/G and canonical filenames, the build loop-risk result, and the official review cloud-G09 capability floor returned by `finalize-task-routing`. Do not include or compare a previous loop's lane/G or risk result.
- `구현 체크리스트`: a top-level checklist the implementing agent must follow while coding. Include one item per implementation/verification unit; if the roadmap feature Task has `검증:`, keep that verification in the same checklist item instead of making a separate completion-criteria item. Include one item for whole-plan intermediate/final verification only when it is not already covered by the feature items. Make the last item exactly `- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.` Copy this checklist into the review stub's `구현 체크리스트` section with the same item text and order.
- One item per change: `### [TAG-1] Title`, `TAG-2`, etc.
- `수정 파일 요약`: table mapping files to item ids.
@ -378,7 +380,7 @@ Do not write or return a prepared pair when either routing target is not `routed
- Dependent split plans record predecessor completion using active sibling `complete.log` or matching archived `complete.log`; ambiguous archive matches are not guessed.
- Every plan item has problem, solution, checklist, test decision, and intermediate verification.
- The plan and review stub have matching `구현 체크리스트` item text/order; their final checkbox is the mandatory `CODE_REVIEW-*-G??.md` evidence item.
- `finalize-task-routing` ran after all analysis for both `build` and `review` in the required evaluation mode; its output status is `routed`, and the active filenames plus `분석 결과 > 최종 라우팅` exactly match its two results.
- `finalize-task-routing` ran after all analysis for both `build` and `review` in the required evaluation mode; its output recorded `finalizer: finalize-task-policy.sh` and `finalizer_mode: pair`; that mode ran exactly once; the build `loop_risk_snapshot` contained complete current raw facts; signature count was not converted to a grade; official review received cloud-G09 minimum and G10 when build was G09-G10; its output status is `routed`; and the active filenames plus `분석 결과 > 최종 라우팅` exactly match its two final results.
- Review WARN/FAIL follow-ups entered through this plan skill and did not inherit or compare the archived lane/G.
- The plan's implementer instructions and review stub limit local implementation agents to implementation/test/evidence work and keep user-review classification plus control-plane stop files out of their input and ownership.
- The review stub has a clearly marked `코드리뷰 전용 체크리스트` owned only by the review agent.

View file

@ -1,4 +1,4 @@
interface:
display_name: "Plan"
short_description: "Write implementation plans"
default_prompt: "Use $plan to analyze this repository change, run $finalize-task-routing as the final decision, and create the routed PLAN/CODE_REVIEW pair."
default_prompt: "Use $plan to analyze this repository change, collect current build loop-risk facts, run $finalize-task-routing with the official review upper-tier floor, and create the routed PLAN/CODE_REVIEW pair."

View file

@ -164,11 +164,14 @@ Pi `laguna-s`에서 `context-limit`/max-token 또는 이전 dispatcher가 남긴
- 마지막 task의 review process가 종료되어 dispatcher가 `FINISH`를 기록하고 전체 스캔에서 같은 group의 active/running task가 없을 때만 `WORK_LOG.md``work_log_N.log`로 archive한다. code-review process가 먼저 이동하지 않는다.
3. **승격과 context 복구**
- CLI의 terminal provider 오류 이벤트나 stderr에서 확인된 context/output limit, provider quota/rate limit, model unavailable만 `agy -> Claude -> Codex` 또는 `Claude -> Codex`로 승격한다. agent 응답, source, tool/test 출력의 같은 문자열은 승격 근거로 쓰지 않는다.
- Codex가 같은 오류를 내면 locator를 전달한 fresh Codex session으로 재시도하되 동일 stage의 연속 실패 10회 한도를 공유한다. 복구 중에도 다른 task dispatch는 계속한다.
- timeout/crash/permission/일반 구현 오류는 해당 task의 실제 failure class와 locator를 보존한 채 동일 stage 연속 실패 10회 안에서 복구한다. 한도 소진 시 해당 task만 차단하고 다른 독립 task dispatch는 계속한다.
- CLI의 terminal provider 오류 이벤트나 stderr에서 확인된 context/output limit, provider quota/rate limit, model unavailable, 확정된 provider transport 오류만 `agy -> Claude -> Codex` 또는 `Claude -> Codex`로 승격한다. AGY는 top-level `error`/`fatal`/`request.failed`/`turn.failed` event, top-level error/code를 동반한 failed/rejected status, stderr, `agy-cli.log`의 강한 `RESOURCE_EXHAUSTED`·HTTP 429·quota/rate limit 초과 근거를 terminal 진단으로 사용한다. Claude CLI의 `rate_limit_event`에서 `rate_limit_info.status=rejected`, error `result``api_error_status=429` 또는 `error=rate_limit`, `You've hit your session limit · resets ...` terminal 진단도 `provider-quota`로 분류한다. assistant message, source, tool/test 출력이나 AGY log의 단순 quota 설정 문자열은 승격 근거로 쓰지 않는다.
- Claude에서 Codex로 승격할 때의 target은 Codex `gpt-5.6-terra`, reasoning `high`다.
- Codex가 같은 오류를 내면 locator를 전달한 fresh Codex session에서 직전 Codex model/reasoning을 유지해 재시도하되 동일 stage의 연속 실패 10회 한도를 공유한다. 복구 중에도 다른 task dispatch는 계속한다.
- 이전 dispatcher source가 `generic-error`로 10회 차단한 locator를 현재 source가 다시 읽었을 때, 같은 task/plan/role/source/실행 target의 연속 10회 전체 terminal evidence가 동일한 승격 가능 오류로 재분류될 때만 그 10회를 terminal 오류 1회로 재조정하고 해당 task의 blocker만 해제한다. terminal evidence는 `stream.log`과, AGY일 때 같은 attempt의 `agy-cli.log`까지 포함한다. 하나라도 이력이 누락되거나 다른 오류가 섞이면 자동 재조정하지 않으며, locator의 dispatcher source hash가 현재 source hash와 같아도 재조정하지 않는다. dry-run은 state를 쓰지 않고 이 승격 복구와 다음 model을 표시한다. 실제 실행은 최초 PLAN route가 아니라 locator에 기록된 실제 실패 target을 기준으로 상위 target을 선택하고 locator context를 이어받으며, blocker 해제 직후 dispatcher가 재시작되어도 persisted reclassification metadata에서 같은 승격 target과 locator를 복원한다.
- timeout/crash/process termination/permission/일반 구현 오류는 model 승격 없이 해당 task의 실제 failure class와 locator를 보존한 채 같은 target에서 동일 stage 연속 실패 10회 안으로 복구한다. 한도 소진 시 해당 task만 차단하고 다른 독립 task dispatch는 계속한다.
- 승격 후 성공하면 `worker_cli``worker_model`은 최초 PLAN route가 아니라 성공 locator의 실제 target으로 기록한다.
- Pi는 cloud 승격하지 않는다.
- attempt identity는 `<task-name>__p<plan>__<role>__aNN`이다. locator에는 workspace, CLI/model, PLAN/review, `WORK_LOG.md`, session id, native session path, raw output log를 기록한다.
- attempt identity는 `<task-name>__p<plan>__<role>__aNN`이다. locator에는 workspace, CLI/model/reasoning effort, PLAN/review, `WORK_LOG.md`, session id, native session path, raw output log를 기록한다.
- locator는 repository의 `.git/agent-task-dispatcher/runs/`에 둔다. `.git` state를 쓸 수 없는 환경에서만 `${XDG_STATE_HOME}/agent-task-dispatcher/<workspace-id>/runs/`를 사용한다.
4. **리뷰 수렴**

View file

@ -1,4 +1,4 @@
interface:
display_name: "Agent Task Loop Orchestrator"
short_description: "PLAN 구현과 Codex 리뷰 루프를 자동 오케스트레이션"
default_prompt: "agent-task의 작업들 실행해."
default_prompt: "$orchestrate-agent-task-loop 스킬을 사용해 agent-task의 작업들 실행해."

View file

@ -64,8 +64,9 @@ PROMOTABLE_PATTERNS = {
r"response (?:is )?too long",
],
"provider-quota": [
r"rate.?limit", r"\bquota\b", r"resource_exhausted", r"\b429\b",
r"usage limit", r"capacity limit",
r"rate.?limit", r"\bquota\b", r"resource[_ ]?exhausted", r"\b429\b",
r"too many requests", r"usage limit", r"capacity limit",
r"\bsession limit\b",
],
"model-unavailable": [
r"model.{0,40}(?:not found|unavailable)", r"overloaded",
@ -90,6 +91,10 @@ PROMOTABLE_PATTERNS = {
),
],
}
PROMOTABLE_FAILURES = frozenset(
{"context-limit", "provider-quota", "model-unavailable"}
)
CLOUD_PROMOTION_FAILURES = PROMOTABLE_FAILURES | PROVIDER_TRANSPORT_FAILURES
class DispatcherAlreadyRunning(RuntimeError):
@ -273,6 +278,50 @@ class AgentSpec:
model: str
display: str
local_pi: bool = False
reasoning_effort: str | None = None
def effective_reasoning_effort(spec: AgentSpec) -> str | None:
if spec.cli in {"codex", "claude"}:
return spec.reasoning_effort or "xhigh"
return None
def agent_spec_from_record(record: dict[str, Any]) -> AgentSpec | None:
cli = str(record.get("cli") or "")
model = str(record.get("model") or "")
if not cli or not model:
return None
reasoning_effort = record.get("reasoning_effort")
if reasoning_effort is not None:
reasoning_effort = str(reasoning_effort)
local_pi = cli == "pi"
if cli in {"codex", "claude"}:
effort = reasoning_effort or "xhigh"
display = f"{cli}/{model} {effort}"
elif cli == "pi":
display = f"pi/iop/{model}"
else:
display = f"{cli}/{model}"
return AgentSpec(
cli,
model,
display,
local_pi=local_pi,
reasoning_effort=reasoning_effort,
)
def agent_spec_from_locator(locator: Path | None) -> AgentSpec | None:
if locator is None:
return None
try:
record = json.loads(locator.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(record, dict):
return None
return agent_spec_from_record(record)
@dataclass(frozen=True)
@ -284,6 +333,33 @@ class PiSessionState:
reason: str = ""
@dataclass(frozen=True)
class LegacyPromotionRecovery:
locator: Path
role: str
failure_class: str
evidence: str
evidence_source: str
prior_dispatcher_sha256: str
failed_cli: str
failed_model: str
failed_reasoning_effort: str | None
def failed_spec_from_recovery(
recovery: LegacyPromotionRecovery,
) -> AgentSpec:
record = {
"cli": recovery.failed_cli,
"model": recovery.failed_model,
"reasoning_effort": recovery.failed_reasoning_effort,
}
spec = agent_spec_from_record(record)
if spec is None:
raise ValueError("legacy promotion recovery에 failed agent identity가 없다")
return spec
@dataclass
class Task:
name: str
@ -1164,15 +1240,283 @@ def terminal_diagnostic(cli: str, channel: str, line: str) -> str | None:
event_type = str(value.get("type", ""))
if cli == "codex" and event_type in {"turn.failed", "error"}:
return json.dumps(value.get("error", value), ensure_ascii=False)
if cli == "agy":
severity = str(value.get("severity") or value.get("level") or "")
status = str(value.get("status") or "")
non_terminal_event = event_type.lower() in {
"assistant",
"message",
"tool",
"tool.result",
"tool_result",
}
if (
not non_terminal_event
and (
event_type.lower() in {
"error",
"fatal",
"request.failed",
"turn.failed",
}
or severity.lower() in {"error", "fatal"}
or (
status.lower() in {"failed", "rejected"}
and any(
field in value
for field in (
"code",
"error",
"error_code",
"status_code",
)
)
)
)
):
return json.dumps(value, ensure_ascii=False)
if cli == "claude":
subtype = str(value.get("subtype", ""))
if event_type == "result" and (value.get("is_error") or subtype.startswith("error")):
return str(value.get("result") or value)
if event_type == "rate_limit_event":
rate_limit_info = value.get("rate_limit_info")
if isinstance(rate_limit_info, dict) and str(
rate_limit_info.get("status", "")
).lower() == "rejected":
return json.dumps(value, ensure_ascii=False)
if event_type == "result" and (
value.get("is_error") or subtype.startswith("error")
):
# Preserve typed terminal fields such as api_error_status=429 and
# error=rate_limit. The human-readable result alone is not the
# failure contract and may change between Claude CLI releases.
return json.dumps(value, ensure_ascii=False)
if event_type == "system" and subtype.startswith("error"):
return json.dumps(value, ensure_ascii=False)
return None
def legacy_promotion_recovery(
runs_root: Path,
task: Task,
state: dict[str, Any],
) -> LegacyPromotionRecovery | None:
"""Reclassify only an older dispatcher's exhausted generic terminal failure."""
blocked = str(state.get("blocked") or "")
recovery_failures = state.get("recovery_failures")
if not blocked or not isinstance(recovery_failures, dict):
return None
locator_match = re.search(r"(?:^|\s)locator=(.+?)\s*$", blocked)
if locator_match is None:
return None
locator = Path(locator_match.group(1))
try:
locator = locator.resolve(strict=True)
runs_root = runs_root.resolve(strict=True)
if not locator.is_relative_to(runs_root) or locator.name != "locator.json":
return None
latest_record = json.loads(locator.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
role = str(latest_record.get("role") or "")
try:
failure_count = int(recovery_failures.get(role, 0))
except (TypeError, ValueError):
return None
prior_sha256 = str(latest_record.get("dispatcher_source_sha256") or "")
failed_spec = agent_spec_from_record(latest_record)
if (
latest_record.get("task") != task.name
or latest_record.get("status") != "failed"
or latest_record.get("failure_class") != "generic-error"
or role not in {"worker", "selfcheck", "review"}
or failure_count < RECOVERY_FAILURE_LIMIT
or not prior_sha256
or prior_sha256 == DISPATCHER_SOURCE_SHA256
or failed_spec is None
or promoted_spec(failed_spec, 0) is None
):
return None
plan = latest_record.get("plan_number")
latest_attempt = latest_record.get("attempt")
if not isinstance(latest_attempt, int):
return None
classified_attempts: list[
tuple[int, Path, str, str, str]
] = []
for attempt_directory in runs_root.iterdir():
candidate = attempt_directory / "locator.json"
if not candidate.is_file():
continue
try:
record = json.loads(candidate.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
if (
record.get("task") != task.name
or record.get("role") != role
or record.get("plan_number") != plan
or record.get("status") != "failed"
or record.get("failure_class") != "generic-error"
or record.get("dispatcher_source_sha256") != prior_sha256
or record.get("cli") != failed_spec.cli
or record.get("model") != failed_spec.model
or effective_reasoning_effort(
agent_spec_from_record(record) or failed_spec
)
!= effective_reasoning_effort(failed_spec)
or not isinstance(record.get("attempt"), int)
):
continue
diagnostics = attempt_terminal_diagnostics(
attempt_directory,
record,
)
if not diagnostics:
continue
failure_class, evidence = classify_failure_with_evidence(
"\n".join(diagnostic for _, diagnostic in diagnostics[-50:])
)
if failure_class not in CLOUD_PROMOTION_FAILURES or evidence is None:
continue
evidence_source = next(
(
source
for source, diagnostic in reversed(diagnostics)
if diagnostic == evidence
),
f"{failed_spec.cli}:terminal",
)
classified_attempts.append(
(
int(record["attempt"]),
candidate.resolve(),
failure_class,
evidence,
evidence_source,
)
)
classified_attempts.sort(key=lambda item: item[0])
expected_attempts = list(
range(latest_attempt - failure_count + 1, latest_attempt + 1)
)
matching_attempts = [
item
for item in classified_attempts
if item[0] in expected_attempts
]
if (
[item[0] for item in matching_attempts] != expected_attempts
or matching_attempts[-1][1] != locator
or len({item[2] for item in matching_attempts}) != 1
):
# Do not collapse a mixed or incomplete failure history to one retry.
return None
_, _, failure_class, evidence, evidence_source = matching_attempts[-1]
return LegacyPromotionRecovery(
locator=locator,
role=role,
failure_class=failure_class,
evidence=evidence,
evidence_source=evidence_source,
prior_dispatcher_sha256=prior_sha256,
failed_cli=failed_spec.cli,
failed_model=failed_spec.model,
failed_reasoning_effort=failed_spec.reasoning_effort,
)
def persisted_legacy_promotion_recovery(
task: Task,
state: dict[str, Any],
locator: Path,
role: str,
) -> LegacyPromotionRecovery | None:
metadata = state.get("legacy_terminal_reclassification")
if not isinstance(metadata, dict):
return None
try:
recorded_locator = Path(str(metadata["locator"])).resolve(strict=True)
record = json.loads(recorded_locator.read_text(encoding="utf-8"))
except (KeyError, OSError):
return None
except json.JSONDecodeError:
return None
if not isinstance(record, dict):
return None
failure_class = str(metadata.get("failure_class") or "")
failed_spec = agent_spec_from_record(record)
recorded_cli = str(metadata.get("failed_cli") or "")
recorded_model = str(metadata.get("failed_model") or "")
if (
recorded_locator != locator.resolve()
or record.get("task") != task.name
or record.get("role") != role
or record.get("status") != "failed"
or record.get("failure_class") != "generic-error"
or failure_class not in CLOUD_PROMOTION_FAILURES
or str(metadata.get("current_dispatcher_sha256") or "")
!= DISPATCHER_SOURCE_SHA256
or failed_spec is None
or promoted_spec(failed_spec, 0) is None
or (recorded_cli and recorded_cli != failed_spec.cli)
or (recorded_model and recorded_model != failed_spec.model)
):
return None
return LegacyPromotionRecovery(
locator=recorded_locator,
role=role,
failure_class=failure_class,
evidence=failure_class,
evidence_source=str(metadata.get("evidence_source") or "terminal"),
prior_dispatcher_sha256=str(
metadata.get("prior_dispatcher_sha256") or "unknown"
),
failed_cli=failed_spec.cli,
failed_model=failed_spec.model,
failed_reasoning_effort=(
str(metadata["failed_reasoning_effort"])
if metadata.get("failed_reasoning_effort") is not None
else failed_spec.reasoning_effort
),
)
def pending_persisted_legacy_promotion_recovery(
task: Task,
state: dict[str, Any],
) -> LegacyPromotionRecovery | None:
metadata = state.get("legacy_terminal_reclassification")
recovery_failures = state.get("recovery_failures")
if not isinstance(metadata, dict) or not isinstance(
recovery_failures, dict
):
return None
role = str(metadata.get("role") or "")
if not role:
pending_roles = [
str(candidate)
for candidate, count in recovery_failures.items()
if count
]
if len(pending_roles) != 1:
return None
role = pending_roles[0]
try:
failure_count = int(recovery_failures.get(role, 0))
locator = Path(str(metadata["locator"]))
except (KeyError, TypeError, ValueError):
return None
if not 0 < failure_count < RECOVERY_FAILURE_LIMIT:
return None
return persisted_legacy_promotion_recovery(
task,
state,
locator,
role,
)
def codex_collaboration_tool(line: str) -> str | None:
try:
value = json.loads(line)
@ -1226,10 +1570,61 @@ def agy_log_diagnostics(path: Path) -> list[str]:
return []
diagnostics: list[str] = []
for line in path.read_text(encoding="utf-8", errors="replace").splitlines()[-200:]:
if re.search(r"\b(?:error|fatal)\b", line, re.IGNORECASE):
diagnostics.append(line)
elif re.search(r"RESOURCE_EXHAUSTED|rate.?limit|model.{0,40}unavailable", line, re.IGNORECASE):
diagnostics.append(line)
failure_class, evidence = classify_failure_with_evidence(line)
if (
failure_class not in CLOUD_PROMOTION_FAILURES
or evidence is None
):
continue
if failure_class == "provider-quota" and not re.search(
(
r"RESOURCE[_ ]?EXHAUSTED"
r"|\b(?:HTTP|status(?: code)?)\s*[:=]?\s*429\b"
r"|\btoo many requests\b"
r"|(?:rate.?limit|quota|capacity).{0,40}"
r"(?:exceed|exhaust|reached|reject)"
r"|(?:exceed|exhaust|reached|reject).{0,40}"
r"(?:rate.?limit|quota|capacity)"
r"|(?:rate.?limit|quota).{0,40}retry after"
),
line,
re.IGNORECASE,
):
continue
diagnostics.append(line)
return diagnostics
def attempt_terminal_diagnostics(
attempt_directory: Path,
record: dict[str, Any],
) -> list[tuple[str, str]]:
spec = agent_spec_from_record(record)
if spec is None:
return []
try:
stream_lines = (attempt_directory / "stream.log").read_text(
encoding="utf-8",
errors="replace",
).splitlines()
except OSError:
stream_lines = []
diagnostics: list[tuple[str, str]] = []
for stream_line in stream_lines:
match = re.match(r"^\[(stdout|stderr)\]\s?(.*)$", stream_line)
if match is None:
continue
channel, payload = match.groups()
diagnostic = terminal_diagnostic(spec.cli, channel, payload)
if diagnostic:
diagnostics.append((f"{spec.cli}:{channel}", diagnostic))
if spec.cli == "agy":
diagnostics.extend(
("agy:cli-log", diagnostic)
for diagnostic in agy_log_diagnostics(
attempt_directory / "agy-cli.log"
)
)
return diagnostics
@ -1237,9 +1632,14 @@ def promoted_spec(spec: AgentSpec, recovery_count: int) -> AgentSpec | None:
if spec.cli == "agy":
return AgentSpec("claude", "claude-opus-4-8", "claude/claude-opus-4-8 xhigh")
if spec.cli == "claude":
return AgentSpec("codex", "gpt-5.6-sol", "codex/gpt-5.6-sol xhigh")
return AgentSpec(
"codex",
"gpt-5.6-terra",
"codex/gpt-5.6-terra high",
reasoning_effort="high",
)
if spec.cli == "codex" and recovery_count < 1:
return AgentSpec("codex", "gpt-5.6-sol", "codex/gpt-5.6-sol xhigh")
return spec
return None
@ -1681,13 +2081,14 @@ def build_command(
if spec.cli == "codex":
return [
"codex", "exec", "--json", "-C", str(workspace), "-m", spec.model,
"-c", 'model_reasoning_effort="xhigh"',
"-c", f'model_reasoning_effort="{effective_reasoning_effort(spec)}"',
"--dangerously-bypass-approvals-and-sandbox", prompt,
]
if spec.cli == "claude":
return [
"claude", "-p", "--output-format", "stream-json", "--verbose",
"--session-id", session_id, "--model", spec.model, "--effort", "xhigh",
"--session-id", session_id, "--model", spec.model,
"--effort", str(effective_reasoning_effort(spec)),
"--dangerously-skip-permissions", prompt,
]
if spec.cli == "agy":
@ -1764,6 +2165,7 @@ async def invoke(
**dispatcher_source_provenance(),
"cli": spec.cli,
"model": spec.model,
"reasoning_effort": effective_reasoning_effort(spec),
"agent_process_marker": process_marker,
"plan_path": str(task.plan) if task.plan else None,
"review_path": str(task.review) if task.review else None,
@ -2308,6 +2710,64 @@ async def run_escalating(
persisted = store.task_state(task).get("recovery_failures", {})
if isinstance(persisted, dict):
recovery_failures = int(persisted.get(role, 0))
legacy_recovery: LegacyPromotionRecovery | None = None
if initial_resume_locator is not None and isinstance(store, StateStore):
state = store.task_state(task)
legacy_recovery = legacy_promotion_recovery(
store.runs,
task,
state,
)
if legacy_recovery is not None:
recovery_failures = 1
persisted_failures = dict(state.get("recovery_failures", {}))
persisted_failures[legacy_recovery.role] = recovery_failures
store.update_task(
task,
blocked=None,
recovery_failures=persisted_failures,
legacy_terminal_reclassification={
"role": legacy_recovery.role,
"failure_class": legacy_recovery.failure_class,
"evidence_source": legacy_recovery.evidence_source,
"prior_dispatcher_sha256":
legacy_recovery.prior_dispatcher_sha256,
"current_dispatcher_sha256":
DISPATCHER_SOURCE_SHA256,
"locator": str(legacy_recovery.locator),
"failed_cli": legacy_recovery.failed_cli,
"failed_model": legacy_recovery.failed_model,
"failed_reasoning_effort":
legacy_recovery.failed_reasoning_effort,
},
)
else:
legacy_recovery = persisted_legacy_promotion_recovery(
task,
state,
initial_resume_locator,
role,
)
if legacy_recovery is not None and legacy_recovery.role == role:
failed_spec = failed_spec_from_recovery(legacy_recovery)
next_spec = promoted_spec(failed_spec, codex_recovery_count)
if next_spec is not None:
banner(
"모델승격",
task.name,
[
f"from={failed_spec.display}",
f"to={next_spec.display}",
f"failure_class={legacy_recovery.failure_class}",
"failure_source=legacy-terminal-reclassification",
f"failure_evidence_source={legacy_recovery.evidence_source}",
"dispatcher_source_sha256="
f"{legacy_recovery.prior_dispatcher_sha256}",
f"dispatcher_source_current_sha256={DISPATCHER_SOURCE_SHA256}",
f"locator={legacy_recovery.locator}",
],
)
spec = next_spec
if recovery_failures >= RECOVERY_FAILURE_LIMIT:
locator = initial_resume_locator
reason = (
@ -2481,6 +2941,22 @@ async def run_escalating(
previous_locator = locator
await asyncio.sleep(min(30, 2 ** min(generic_retries, 5)))
continue
if failure not in CLOUD_PROMOTION_FAILURES:
terminal_recovery_retries += 1
banner(
"모델복구재시도",
task.name,
[
f"model={spec.display}",
*failure_report_lines(failure, locator),
f"retry={recovery_failures}/{RECOVERY_FAILURE_LIMIT}",
],
)
previous_locator = locator
await asyncio.sleep(
min(30, 2 ** min(terminal_recovery_retries, 5))
)
continue
next_spec = promoted_spec(spec, codex_recovery_count)
if next_spec is None:
terminal_recovery_retries += 1
@ -2971,12 +3447,13 @@ async def run_worker(
task, blocked=current or f"worker failure locator={locator}"
)
return
completed_spec = agent_spec_from_locator(locator) or spec
store.update_task(
task,
worker_done=True,
worker_cli=spec.cli,
worker_model=spec.model,
selfcheck_done=not spec.local_pi,
worker_cli=completed_spec.cli,
worker_model=completed_spec.model,
selfcheck_done=not completed_spec.local_pi,
blocked=None,
)
@ -3086,6 +3563,7 @@ async def run_review(
workspace: Path,
store: StateStore,
task: Task,
resume_locator: Path | None = None,
) -> str | None:
spec = AgentSpec("codex", "gpt-5.6-sol", "codex/gpt-5.6-sol xhigh")
state = store.task_state(task)
@ -3111,7 +3589,14 @@ async def run_review(
prior_review_fingerprints = review_fingerprints(workspace, task)
target = task.review.resolve() if task.review else task.directory.resolve()
banner("리뷰시작", task.name, [f"model={spec.display}", f"review={target}"])
success, locator = await run_escalating(workspace, store, task, "review", spec)
success, locator = await run_escalating(
workspace,
store,
task,
"review",
spec,
initial_resume_locator=resume_locator,
)
if not success:
current = store.task_state(task).get("blocked")
store.update_task(
@ -3284,6 +3769,7 @@ async def dispatch_with_store(
candidate_scope: set[str] | None = None
task_cache: dict[str, Task] | None = None
resume_locators: dict[str, Path] = {}
legacy_recoveries: dict[str, LegacyPromotionRecovery] = {}
live_external_processes: dict[str, str] = {}
while True:
@ -3579,6 +4065,52 @@ async def dispatch_with_store(
waiting_tasks.append(task.name)
continue
state = store.peek_task_state(task) if args.dry_run else store.task_state(task)
legacy_recovery: LegacyPromotionRecovery | None = None
legacy_blocker_reclassified = False
if state.get("blocked"):
legacy_recovery = legacy_promotion_recovery(
store.runs,
task,
state,
)
legacy_blocker_reclassified = legacy_recovery is not None
else:
legacy_recovery = (
pending_persisted_legacy_promotion_recovery(task, state)
)
if legacy_recovery is not None:
legacy_recoveries[task.name] = legacy_recovery
resume_locators[task.name] = legacy_recovery.locator
if legacy_blocker_reclassified:
state = dict(state)
state["blocked"] = None
if not args.dry_run:
recovery_failures = dict(
state.get("recovery_failures", {})
)
# Ten identical generic retries represent one terminal
# quota/context/model failure after reclassification.
recovery_failures[legacy_recovery.role] = 1
store.update_task(
task,
blocked=None,
recovery_failures=recovery_failures,
legacy_terminal_reclassification={
"role": legacy_recovery.role,
"failure_class": legacy_recovery.failure_class,
"evidence_source": legacy_recovery.evidence_source,
"prior_dispatcher_sha256":
legacy_recovery.prior_dispatcher_sha256,
"current_dispatcher_sha256":
DISPATCHER_SOURCE_SHA256,
"locator": str(legacy_recovery.locator),
"failed_cli": legacy_recovery.failed_cli,
"failed_model": legacy_recovery.failed_model,
"failed_reasoning_effort":
legacy_recovery.failed_reasoning_effort,
},
)
state = store.task_state(task)
active_predecessors = live_predecessors(
task,
set(running) | set(live_external_processes),
@ -3668,7 +4200,26 @@ async def dispatch_with_store(
if stage == "review"
else route_agent(task)
)
banner("작업대기", task.name, status_lines(task, stage, "ready") + [f"model={spec.display}"])
legacy_recovery = legacy_recoveries.get(task.name)
if legacy_recovery is not None:
failed_spec = failed_spec_from_recovery(
legacy_recovery
)
spec = promoted_spec(failed_spec, 0) or failed_spec
lines = status_lines(task, stage, "ready")
if legacy_recovery is not None:
lines.extend(
[
"recovery=legacy-terminal-reclassification",
f"failure_class={legacy_recovery.failure_class}",
f"locator={legacy_recovery.locator}",
]
)
banner(
"작업대기",
task.name,
lines + [f"model={spec.display}"],
)
else:
event, stage, reason = blocked_details[task.name]
banner(event, task.name, status_lines(task, stage, reason))
@ -3719,7 +4270,18 @@ async def dispatch_with_store(
store.mark_active(task, stage)
resume_locator = resume_locators.pop(task.name, None)
if stage == "review":
future = asyncio.create_task(run_review(workspace, store, task))
future = asyncio.create_task(
run_review(
workspace,
store,
task,
**(
{"resume_locator": resume_locator}
if resume_locator is not None
else {}
),
)
)
elif stage == "selfcheck":
future = asyncio.create_task(
run_selfcheck(

View file

@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Pure execution-target policy for Agent Task worker and review stages."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, time
from zoneinfo import ZoneInfo
KST = ZoneInfo("Asia/Seoul")
DAY_START = time(7, 0)
NIGHT_START = time(23, 0)
VALID_STAGES = {"worker", "review"}
VALID_LANES = {"local", "cloud"}
@dataclass(frozen=True)
class RouteTarget:
adapter: str
target: str
execution_class: str
selfcheck_required: bool
@dataclass(frozen=True)
class PolicyDecision:
rule_id: str
policy_priority: int
reason_codes: tuple[str, ...]
time_window: str
candidates: tuple[RouteTarget, ...]
PI_ORNITH = RouteTarget("pi", "iop/ornith-fast", "local_model", True)
PI_LAGUNA = RouteTarget("pi", "iop/laguna-s:2.1", "local_model", True)
AGY_GEMINI_MEDIUM = RouteTarget(
"agy", "Gemini 3.6 Flash Medium", "cloud_model", False
)
CLAUDE_SONNET = RouteTarget("claude", "sonnet", "cloud_model", False)
CLAUDE_OPUS = RouteTarget("claude", "claude-opus-4-8", "cloud_model", False)
CODEX_SOL_XHIGH = RouteTarget("codex", "gpt-5.6-sol", "cloud_model", False)
def _validate(stage: str, lane: str, grade: int, evaluated_at: datetime) -> None:
if stage not in VALID_STAGES:
raise ValueError(f"unsupported stage: {stage}")
if lane not in VALID_LANES:
raise ValueError(f"unsupported lane: {lane}")
if not 1 <= grade <= 10:
raise ValueError(f"grade must be in G01..G10: {grade}")
if evaluated_at.tzinfo is None or evaluated_at.utcoffset() is None:
raise ValueError("evaluated_at must be timezone-aware")
def _kst_window(evaluated_at: datetime) -> tuple[str, bool]:
current = evaluated_at.astimezone(KST).time().replace(tzinfo=None)
is_day = DAY_START <= current < NIGHT_START
if is_day:
return "kst-day-[07:00,23:00)", True
return "kst-night-[23:00,07:00)", False
def select_policy(
*, stage: str, lane: str, grade: int, evaluated_at: datetime
) -> PolicyDecision:
"""Return the ordered target policy for one initial route evaluation."""
_validate(stage, lane, grade, evaluated_at)
if stage == "review":
return PolicyDecision(
rule_id="official-review-codex",
policy_priority=10,
reason_codes=("official_review_fixed",),
time_window="not_applicable",
candidates=(CODEX_SOL_XHIGH,),
)
if lane == "local":
if grade <= 6:
return PolicyDecision(
rule_id="worker-local-g01-g06",
policy_priority=30,
reason_codes=("local_low_grade",),
time_window="not_applicable",
candidates=(PI_ORNITH,),
)
if grade <= 8:
window, is_day = _kst_window(evaluated_at)
return PolicyDecision(
rule_id=(
"worker-local-g07-g08-day"
if is_day
else "worker-local-g07-g08-night"
),
policy_priority=20,
reason_codes=(
"kst_day_window" if is_day else "kst_night_window",
),
time_window=window,
candidates=(
(AGY_GEMINI_MEDIUM, PI_LAGUNA)
if is_day
else (PI_LAGUNA, AGY_GEMINI_MEDIUM)
),
)
return PolicyDecision(
rule_id="worker-local-g09-g10",
policy_priority=30,
reason_codes=("local_high_grade_cloud_target",),
time_window="not_applicable",
candidates=(CLAUDE_OPUS,),
)
if grade <= 6:
target = CLAUDE_SONNET
rule_id = "worker-cloud-g01-g06"
reason_code = "cloud_sonnet_grade"
elif grade <= 8:
target = CLAUDE_OPUS
rule_id = "worker-cloud-g07-g08"
reason_code = "cloud_opus_grade"
else:
target = CODEX_SOL_XHIGH
rule_id = "worker-cloud-g09-g10"
reason_code = "cloud_codex_grade"
return PolicyDecision(
rule_id=rule_id,
policy_priority=30,
reason_codes=(reason_code,),
time_window="not_applicable",
candidates=(target,),
)

View file

@ -0,0 +1,665 @@
#!/usr/bin/env python3
"""Deterministic execution-target selector CLI over the pure route policy.
The selector consumes a static routing task file (``PLAN-*`` or
``CODE_REVIEW-*``), its ``task/plan/tag`` generation header and optional prior
decision / quota snapshot, and returns a stable JSON contract that the
dispatcher can persist. This module closes SDD S01~S03 (schema/invalid-input,
KST time route, worker/review grade matrix) at the selector surface. Failover
target switching, quota probe execution and dispatcher persisted state are owned
by later tasks and are intentionally not implemented here.
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import re
import sys
from datetime import datetime
from pathlib import Path
SCHEMA_VERSION = "1.0"
TIMEZONE_NAME = "Asia/Seoul"
DEFAULT_QUOTA_PROBE_COMMAND = "iop-node quota-probe"
_FILENAME_RE = re.compile(r"^(PLAN|CODE_REVIEW)-(local|cloud)-G(\d{2})\.md$")
_HEADER_RE = re.compile(r"<!--\s*task=(\S+)\s+plan=(\d+)\s+tag=(\S+)\s*-->")
_STAGE_BY_KIND = {"PLAN": "worker", "CODE_REVIEW": "review"}
_VALID_TRANSITIONS = {"initial", "resume", "failover"}
_VALID_EXECUTION_CLASSES = {"local_model", "cloud_model"}
_VALID_QUOTA_MODES = {"unbounded", "bounded"}
_VALID_QUOTA_STATUSES = {"not_applicable", "available", "exhausted", "unknown"}
_VALID_ELIGIBILITY = {"eligible", "ineligible"}
_VALID_REJECTION_REASONS = {"quota_exhausted"}
_VALID_TIME_WINDOWS = {
"kst-day-[07:00,23:00)",
"kst-night-[23:00,07:00)",
"not_applicable",
}
def _load_policy():
path = Path(__file__).resolve().parent / "execution_target_policy.py"
spec = importlib.util.spec_from_file_location("execution_target_policy", path)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
policy = _load_policy()
class SelectorInputError(Exception):
"""Input contract violation returned as stderr JSON with a non-zero exit."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
def _parse_filename(task_file: Path) -> tuple[str, str, int]:
name = Path(task_file).name
match = _FILENAME_RE.match(name)
if match is None:
raise SelectorInputError(
"invalid_task_filename",
f"task file must match (PLAN|CODE_REVIEW)-(local|cloud)-GNN.md: {name!r}",
)
kind, lane, grade_str = match.group(1), match.group(2), match.group(3)
grade = int(grade_str)
if not 1 <= grade <= 10:
raise SelectorInputError(
"invalid_grade", f"grade must be G01..G10: G{grade_str}"
)
return kind, lane, grade
def _parse_header(task_file: Path) -> tuple[str, int, str]:
try:
with Path(task_file).open("rb") as handle:
head = handle.read(1024)
except OSError as exc:
raise SelectorInputError("task_file_unreadable", str(exc)) from exc
text = head.decode("utf-8", errors="replace")
match = _HEADER_RE.search(text)
if match is None:
raise SelectorInputError(
"malformed_header",
"first 1KiB must contain <!-- task=... plan=N tag=... -->",
)
return match.group(1), int(match.group(2)), match.group(3)
def _work_unit_id(header: tuple[str, int, str]) -> str:
task, plan, tag = header
return f"{task}::plan-{plan}::tag-{tag}"
def _validate_evaluated_at(evaluated_at: datetime) -> None:
if evaluated_at.tzinfo is None or evaluated_at.utcoffset() is None:
raise SelectorInputError(
"naive_evaluated_at", "evaluated_at must be timezone-aware"
)
def _validate_prior_selected(selected: object) -> None:
code = "malformed_prior_decision"
if not isinstance(selected, dict):
raise SelectorInputError(code, "prior_decision.selected must be an object")
for field in ("adapter", "target", "execution_class"):
candidate = selected.get(field)
if not isinstance(candidate, str) or not candidate:
raise SelectorInputError(
code,
f"prior_decision.selected.{field} must be a non-empty string",
)
if selected["execution_class"] not in _VALID_EXECUTION_CLASSES:
raise SelectorInputError(
code,
"prior_decision.selected.execution_class must be one of "
f"{sorted(_VALID_EXECUTION_CLASSES)}",
)
if not isinstance(selected.get("selfcheck_required"), bool):
raise SelectorInputError(
code,
"prior_decision.selected.selfcheck_required must be a boolean",
)
def _require_non_empty_string(
container: dict, field: str, prefix: str, code: str
) -> None:
value = container.get(field)
if not isinstance(value, str) or not value:
raise SelectorInputError(
code, f"{prefix}.{field} must be a non-empty string"
)
def _require_string_enum(
container: dict, field: str, allowed: set, prefix: str, code: str
) -> None:
value = container.get(field)
if not isinstance(value, str) or value not in allowed:
raise SelectorInputError(
code, f"{prefix}.{field} must be one of {sorted(allowed)}"
)
def _require_nullable_string(
container: dict, field: str, prefix: str, code: str
) -> None:
if field not in container:
raise SelectorInputError(code, f"{prefix}.{field} is required")
value = container[field]
if value is not None and not isinstance(value, str):
raise SelectorInputError(
code, f"{prefix}.{field} must be null or a string"
)
def _validate_prior_candidates(candidates: object) -> None:
"""Validate every reused candidate against the initial output schema.
Each entry must carry the full ``_initial`` candidate field set with the
correct types and enum values, and ``candidate_rank`` must be 1-based and
consecutive so a resume cannot re-emit a partial ranking.
"""
code = "malformed_prior_decision"
if not isinstance(candidates, list) or not candidates:
raise SelectorInputError(
code, "prior_decision.candidates must be a non-empty list"
)
for index, entry in enumerate(candidates):
prefix = f"prior_decision.candidates[{index}]"
if not isinstance(entry, dict):
raise SelectorInputError(code, f"{prefix} must be an object")
rank = entry.get("candidate_rank")
if (
isinstance(rank, bool)
or not isinstance(rank, int)
or rank != index + 1
):
raise SelectorInputError(
code,
f"{prefix}.candidate_rank must be {index + 1} "
"(1-based and consecutive)",
)
_require_non_empty_string(entry, "adapter", prefix, code)
_require_non_empty_string(entry, "target", prefix, code)
_require_string_enum(
entry, "execution_class", _VALID_EXECUTION_CLASSES, prefix, code
)
if not isinstance(entry.get("selfcheck_required"), bool):
raise SelectorInputError(
code, f"{prefix}.selfcheck_required must be a boolean"
)
_require_string_enum(entry, "quota_mode", _VALID_QUOTA_MODES, prefix, code)
_require_string_enum(
entry, "quota_status", _VALID_QUOTA_STATUSES, prefix, code
)
_require_string_enum(entry, "eligibility", _VALID_ELIGIBILITY, prefix, code)
if "rejection_reason" not in entry:
raise SelectorInputError(
code, f"{prefix}.rejection_reason is required"
)
rejection = entry["rejection_reason"]
if rejection is not None and (
not isinstance(rejection, str)
or rejection not in _VALID_REJECTION_REASONS
):
raise SelectorInputError(
code,
f"{prefix}.rejection_reason must be null or one of "
f"{sorted(_VALID_REJECTION_REASONS)}",
)
def _validate_prior_decision_evidence(decision: object) -> None:
"""Validate the reused decision evidence block against initial output."""
code = "malformed_prior_decision"
prefix = "prior_decision.decision"
if not isinstance(decision, dict):
raise SelectorInputError(code, f"{prefix} must be an object")
_require_non_empty_string(decision, "rule_id", prefix, code)
priority = decision.get("policy_priority")
if isinstance(priority, bool) or not isinstance(priority, int):
raise SelectorInputError(
code, f"{prefix}.policy_priority must be an integer"
)
reason_codes = decision.get("reason_codes")
if not isinstance(reason_codes, list) or not all(
isinstance(item, str) and item for item in reason_codes
):
raise SelectorInputError(
code, f"{prefix}.reason_codes must be a list of non-empty strings"
)
_require_non_empty_string(decision, "evaluated_at", prefix, code)
if decision.get("timezone") != TIMEZONE_NAME:
raise SelectorInputError(
code, f"{prefix}.timezone must be {TIMEZONE_NAME!r}"
)
_require_string_enum(
decision, "time_window", _VALID_TIME_WINDOWS, prefix, code
)
if not isinstance(decision.get("pinned"), bool):
raise SelectorInputError(code, f"{prefix}.pinned must be a boolean")
def _validate_prior_quota(quota: object) -> None:
"""Validate the reused quota block against the initial output schema."""
code = "malformed_prior_decision"
prefix = "prior_decision.quota"
if not isinstance(quota, dict):
raise SelectorInputError(code, f"{prefix} must be an object")
_require_nullable_string(quota, "snapshot_id", prefix, code)
_require_string_enum(quota, "mode", _VALID_QUOTA_MODES, prefix, code)
_require_string_enum(quota, "status", _VALID_QUOTA_STATUSES, prefix, code)
_require_non_empty_string(quota, "source", prefix, code)
_require_nullable_string(quota, "checked_at", prefix, code)
def _validate_prior_decision(value: object) -> dict:
"""Validate the nested prior-decision schema before it is reused on resume.
Only container/field/type/enum shape is enforced here; identity equality
against the current work unit stays in ``_resume``. Unknown extra keys are
tolerated so forward-compatible producers are not rejected.
"""
code = "malformed_prior_decision"
if not isinstance(value, dict):
raise SelectorInputError(code, "prior_decision must be an object")
if value.get("schema_version") != SCHEMA_VERSION:
raise SelectorInputError(
code, f"prior_decision.schema_version must be {SCHEMA_VERSION!r}"
)
required = (
"work_unit_id",
"stage",
"lane",
"grade",
"selected",
"candidates",
"decision",
"quota",
)
missing = [key for key in required if key not in value]
if missing:
raise SelectorInputError(
code, f"prior_decision missing keys: {missing}"
)
if not isinstance(value["work_unit_id"], str):
raise SelectorInputError(
code, "prior_decision.work_unit_id must be a string"
)
stage = value["stage"]
if not isinstance(stage, str) or stage not in policy.VALID_STAGES:
raise SelectorInputError(
code,
f"prior_decision.stage must be one of {sorted(policy.VALID_STAGES)}",
)
lane = value["lane"]
if not isinstance(lane, str) or lane not in policy.VALID_LANES:
raise SelectorInputError(
code,
f"prior_decision.lane must be one of {sorted(policy.VALID_LANES)}",
)
grade = value["grade"]
if isinstance(grade, bool) or not isinstance(grade, int) or not 1 <= grade <= 10:
raise SelectorInputError(
code, "prior_decision.grade must be an integer in G01..G10"
)
_validate_prior_selected(value["selected"])
_validate_prior_candidates(value["candidates"])
_validate_prior_decision_evidence(value["decision"])
_validate_prior_quota(value["quota"])
return value
def _validate_quota_snapshot(value: object | None) -> dict | None:
"""Validate the optional quota snapshot container before it is reflected.
Required-cap tri-state normalization and admission stay with
``02+01_quota_input``; here we only reject malformed containers and target
entries so ``_snapshot_status`` never dereferences a non-object.
"""
if value is None:
return None
code = "malformed_quota_snapshot"
if not isinstance(value, dict):
raise SelectorInputError(code, "quota_snapshot must be an object")
for field in ("snapshot_id", "checked_at"):
if field in value and value[field] is not None and not isinstance(
value[field], str
):
raise SelectorInputError(
code, f"quota_snapshot.{field} must be null or a string"
)
if "source" in value:
_require_non_empty_string(value, "source", "quota_snapshot", code)
targets = value.get("targets", [])
if not isinstance(targets, list):
raise SelectorInputError(code, "quota_snapshot.targets must be a list")
for index, entry in enumerate(targets):
if not isinstance(entry, dict):
raise SelectorInputError(
code, f"quota_snapshot.targets[{index}] must be an object"
)
for field in ("adapter", "target", "status"):
candidate = entry.get(field)
if not isinstance(candidate, str) or not candidate:
raise SelectorInputError(
code,
f"quota_snapshot.targets[{index}].{field} must be a "
"non-empty string",
)
return value
def _snapshot_status(target, quota_snapshot: dict | None) -> str:
"""Reflect an already-normalized snapshot status for a cloud target.
The required-cap tri-state derivation from the Go usage checker is owned by
``02+01_quota_input``. Here we only surface an injected, pre-normalized
status; an absent or unmatched snapshot is reported as ``unknown``.
"""
if quota_snapshot is None:
return "unknown"
for entry in quota_snapshot.get("targets", []):
if (
entry.get("adapter") == target.adapter
and entry.get("target") == target.target
):
status = entry.get("status", "unknown")
if status in {"available", "exhausted", "unknown"}:
return status
return "unknown"
return "unknown"
def _candidate_quota(target, quota_snapshot: dict | None) -> tuple[str, str]:
if target.execution_class == "local_model":
return "unbounded", "not_applicable"
return "bounded", _snapshot_status(target, quota_snapshot)
def _selected_quota(
selected, quota_snapshot: dict | None, quota_probe_command: str
) -> dict:
if selected.execution_class == "local_model":
return {
"snapshot_id": None,
"mode": "unbounded",
"status": "not_applicable",
"source": "local_unbounded",
"checked_at": None,
}
if quota_snapshot is None:
return {
"snapshot_id": None,
"mode": "bounded",
"status": "unknown",
"source": quota_probe_command,
"checked_at": None,
}
return {
"snapshot_id": quota_snapshot.get("snapshot_id"),
"mode": "bounded",
"status": _snapshot_status(selected, quota_snapshot),
"source": quota_snapshot.get("source", quota_probe_command),
"checked_at": quota_snapshot.get("checked_at"),
}
def _initial(
*,
work_unit_id: str,
stage: str,
lane: str,
grade: int,
evaluated_at: datetime,
quota_snapshot: dict | None,
quota_probe_command: str,
) -> dict:
decision = policy.select_policy(
stage=stage, lane=lane, grade=grade, evaluated_at=evaluated_at
)
candidates = []
selected = None
for rank, target in enumerate(decision.candidates, start=1):
mode, status = _candidate_quota(target, quota_snapshot)
eligible = status != "exhausted"
candidates.append(
{
"candidate_rank": rank,
"adapter": target.adapter,
"target": target.target,
"execution_class": target.execution_class,
"selfcheck_required": target.selfcheck_required,
"quota_mode": mode,
"quota_status": status,
"eligibility": "eligible" if eligible else "ineligible",
"rejection_reason": None if eligible else "quota_exhausted",
}
)
if eligible and selected is None:
selected = target
if selected is None:
raise SelectorInputError(
"no_eligible_target",
"all policy candidates are exhausted according to the quota snapshot",
)
return {
"schema_version": SCHEMA_VERSION,
"work_unit_id": work_unit_id,
"stage": stage,
"lane": lane,
"grade": grade,
"selected": {
"adapter": selected.adapter,
"target": selected.target,
"execution_class": selected.execution_class,
"selfcheck_required": selected.selfcheck_required,
},
"candidates": candidates,
"decision": {
"rule_id": decision.rule_id,
"policy_priority": decision.policy_priority,
"reason_codes": list(decision.reason_codes),
"evaluated_at": evaluated_at.astimezone(policy.KST).isoformat(),
"timezone": TIMEZONE_NAME,
"time_window": decision.time_window,
"pinned": False,
},
"quota": _selected_quota(selected, quota_snapshot, quota_probe_command),
"transition": {
"previous_target": None,
"next_target": None,
"trigger": "initial",
"context_transfer": "none",
},
}
def _resume(
prior_decision: dict | None,
*,
work_unit_id: str,
stage: str,
lane: str,
grade: int,
) -> dict:
if prior_decision is None:
raise SelectorInputError(
"resume_requires_prior_decision",
"resume transition requires prior_decision",
)
prior_decision = _validate_prior_decision(prior_decision)
for key, expected in (
("work_unit_id", work_unit_id),
("stage", stage),
("lane", lane),
("grade", grade),
):
if prior_decision[key] != expected:
raise SelectorInputError(
"resume_work_unit_mismatch",
f"prior_decision {key}={prior_decision[key]!r} != {expected!r}",
)
selected = prior_decision["selected"]
decision = dict(prior_decision["decision"])
decision["pinned"] = True
target_ref = {"adapter": selected["adapter"], "target": selected["target"]}
return {
"schema_version": SCHEMA_VERSION,
"work_unit_id": work_unit_id,
"stage": stage,
"lane": lane,
"grade": grade,
"selected": selected,
"candidates": prior_decision["candidates"],
"decision": decision,
"quota": prior_decision["quota"],
"transition": {
"previous_target": target_ref,
"next_target": dict(target_ref),
"trigger": "resume",
"context_transfer": "none",
},
}
def select_execution_target(
task_file: Path,
*,
stage: str | None = None,
evaluated_at: datetime,
transition: str = "initial",
prior_decision: dict | None = None,
quota_snapshot: dict | None = None,
quota_probe_command: str = DEFAULT_QUOTA_PROBE_COMMAND,
) -> dict:
"""Return the stable JSON-serializable selector decision for one call."""
kind, lane, grade = _parse_filename(task_file)
prefix_stage = _STAGE_BY_KIND[kind]
if stage is not None and stage != prefix_stage:
raise SelectorInputError(
"stage_mismatch",
f"explicit stage {stage!r} conflicts with filename stage {prefix_stage!r}",
)
resolved_stage = stage or prefix_stage
header = _parse_header(task_file)
work_unit_id = _work_unit_id(header)
_validate_evaluated_at(evaluated_at)
quota_snapshot = _validate_quota_snapshot(quota_snapshot)
if not isinstance(quota_probe_command, str) or not quota_probe_command:
raise SelectorInputError(
"invalid_quota_probe_command",
"quota_probe_command must be a non-empty string",
)
if transition == "initial":
return _initial(
work_unit_id=work_unit_id,
stage=resolved_stage,
lane=lane,
grade=grade,
evaluated_at=evaluated_at,
quota_snapshot=quota_snapshot,
quota_probe_command=quota_probe_command,
)
if transition == "resume":
return _resume(
prior_decision,
work_unit_id=work_unit_id,
stage=resolved_stage,
lane=lane,
grade=grade,
)
if transition == "failover":
raise SelectorInputError(
"unsupported_transition",
"failover target switching is owned by the dispatcher continuation "
"epic; the selector does not switch targets",
)
raise SelectorInputError(
"invalid_transition", f"unknown transition: {transition!r}"
)
def to_json(payload: dict) -> str:
"""Serialize a decision to byte-stable JSON (sorted keys, fixed indent)."""
return json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True)
def _load_json_arg(value: str | None):
if value is None:
return None
candidate = Path(value)
if candidate.exists():
text = candidate.read_text(encoding="utf-8")
else:
text = value
return json.loads(text)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="Select the deterministic execution target for a task file.",
)
parser.add_argument("task_file", type=Path)
parser.add_argument("--stage", choices=["worker", "review"])
parser.add_argument("--evaluated-at")
parser.add_argument(
"--transition", default="initial", choices=sorted(_VALID_TRANSITIONS)
)
parser.add_argument("--prior-decision")
parser.add_argument("--quota-snapshot")
parser.add_argument(
"--quota-probe-command", default=DEFAULT_QUOTA_PROBE_COMMAND
)
args = parser.parse_args(argv)
try:
if args.evaluated_at is not None:
evaluated_at = datetime.fromisoformat(args.evaluated_at)
else:
evaluated_at = datetime.now(policy.KST)
payload = select_execution_target(
args.task_file,
stage=args.stage,
evaluated_at=evaluated_at,
transition=args.transition,
prior_decision=_load_json_arg(args.prior_decision),
quota_snapshot=_load_json_arg(args.quota_snapshot),
quota_probe_command=args.quota_probe_command,
)
except SelectorInputError as exc:
json.dump({"error": exc.code, "message": str(exc)}, sys.stderr)
sys.stderr.write("\n")
return 2
except (ValueError, OSError, json.JSONDecodeError) as exc:
json.dump({"error": "input_error", "message": str(exc)}, sys.stderr)
sys.stderr.write("\n")
return 2
sys.stdout.write(to_json(payload) + "\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -42,6 +42,64 @@ def pi_session_jsonl(events, version=dispatch.PI_SESSION_SCHEMA_VERSION):
return "".join(json.dumps(value) + "\n" for value in values)
def write_legacy_quota_attempts(
runs: Path,
task: dispatch.Task,
*,
cli: str = "claude",
model: str = "claude-opus-4-8",
reasoning_effort: str | None = "xhigh",
dispatcher_sha256: str = "older-dispatcher",
) -> list[Path]:
locators = []
event = json.dumps(
{
"type": "rate_limit_event",
"rate_limit_info": {"status": "rejected"},
}
)
for attempt_number in range(dispatch.RECOVERY_FAILURE_LIMIT):
attempt = runs / f"legacy-attempt-{attempt_number}"
attempt.mkdir(parents=True)
locator = attempt / "locator.json"
locator.write_text(
json.dumps(
{
"task": task.name,
"plan_number": dispatch.plan_number(task),
"role": "worker",
"attempt": attempt_number,
"status": "failed",
"failure_class": "generic-error",
"cli": cli,
"model": model,
"reasoning_effort": reasoning_effort,
"dispatcher_source_sha256": dispatcher_sha256,
}
),
encoding="utf-8",
)
if cli == "agy":
(attempt / "stream.log").write_text(
"[stdout] AGY request failed\n",
encoding="utf-8",
)
(attempt / "agy-cli.log").write_text(
(
"rpc failed: code = ResourceExhausted "
"status=RESOURCE_EXHAUSTED HTTP 429 quota exceeded\n"
),
encoding="utf-8",
)
else:
(attempt / "stream.log").write_text(
f"[stdout] {event}\n",
encoding="utf-8",
)
locators.append(locator)
return locators
class TaskStageTest(unittest.TestCase):
def make_task(self, root: Path, review_text: str = ""):
plan = root / "PLAN-local-G05.md"
@ -1183,6 +1241,193 @@ class WorkLogInvokeIntegrationTest(unittest.IsolatedAsyncioTestCase):
)
self.assertIn(f"provider_evidence={provider_line}", report)
async def test_claude_session_limit_stderr_records_provider_quota(self):
with tempfile.TemporaryDirectory() as temporary:
workspace = Path(temporary)
(workspace / ".git").mkdir()
task = TaskStageTest().make_task(workspace)
store = dispatch.StateStore(workspace)
diagnostic = (
"You've hit your session limit · resets 9pm (Asia/Seoul)"
)
command = [
sys.executable,
"-c",
"import sys; sys.stderr.write(sys.argv[1] + '\\n'); "
"raise SystemExit(1)",
diagnostic,
]
spec = dispatch.AgentSpec(
"claude",
"claude-opus-4-8",
"claude/claude-opus-4-8 xhigh",
)
try:
with mock.patch.object(
dispatch,
"build_command",
return_value=command,
):
rc, failure, locator = await dispatch.invoke(
workspace,
store,
task,
"worker",
spec,
"Read the plan.",
)
finally:
store.close()
self.assertEqual(rc, 1)
self.assertEqual(failure, "provider-quota")
record = json.loads(locator.read_text(encoding="utf-8"))
self.assertEqual(record["failure_source"], "cli-terminal-diagnostic")
self.assertEqual(record["failure_evidence_source"], "claude:stderr")
self.assertEqual(record["failure_evidence_excerpt"], diagnostic)
self.assertEqual(record["reasoning_effort"], "xhigh")
self.assertFalse(record["provider_transport_failure_confirmed"])
async def test_claude_structured_rate_limit_stdout_records_provider_quota(self):
with tempfile.TemporaryDirectory() as temporary:
workspace = Path(temporary)
(workspace / ".git").mkdir()
task = TaskStageTest().make_task(workspace)
store = dispatch.StateStore(workspace)
rate_limit_event = json.dumps(
{
"type": "rate_limit_event",
"rate_limit_info": {
"status": "rejected",
"rateLimitType": "five_hour",
"overageStatus": "rejected",
},
},
ensure_ascii=False,
)
result_event = json.dumps(
{
"type": "result",
"subtype": "success",
"is_error": True,
"terminal_reason": "api_error",
"api_error_status": 429,
"result": (
"You've hit your session limit · "
"resets 9pm (Asia/Seoul)"
),
},
ensure_ascii=False,
)
command = [
sys.executable,
"-c",
"import sys; print(sys.argv[1]); print(sys.argv[2]); "
"raise SystemExit(1)",
rate_limit_event,
result_event,
]
spec = dispatch.AgentSpec(
"claude",
"claude-opus-4-8",
"claude/claude-opus-4-8 xhigh",
)
try:
with mock.patch.object(
dispatch,
"build_command",
return_value=command,
):
rc, failure, locator = await dispatch.invoke(
workspace,
store,
task,
"worker",
spec,
"Read the plan.",
)
finally:
store.close()
self.assertEqual(rc, 1)
self.assertEqual(failure, "provider-quota")
record = json.loads(locator.read_text(encoding="utf-8"))
self.assertEqual(record["failure_source"], "cli-terminal-diagnostic")
self.assertEqual(record["failure_evidence_source"], "claude:stdout")
self.assertIn('"api_error_status": 429', record["failure_evidence_excerpt"])
self.assertFalse(record["provider_transport_failure_confirmed"])
async def test_agy_cli_log_quota_records_provider_quota(self):
with tempfile.TemporaryDirectory() as temporary:
workspace = Path(temporary)
(workspace / ".git").mkdir()
task = TaskStageTest().make_task(workspace)
store = dispatch.StateStore(workspace)
diagnostic = (
"rpc failed: code=ResourceExhausted "
"status=RESOURCE_EXHAUSTED HTTP 429 quota exceeded"
)
spec = dispatch.AgentSpec(
"agy",
"Gemini 3.5 Flash (High)",
"agy/Gemini 3.5 Flash (High)",
)
def build_agy_command(
_spec,
_prompt,
_workspace,
_session_id,
attempt_dir,
**_kwargs,
):
return [
sys.executable,
"-c",
(
"from pathlib import Path; "
"Path(__import__('sys').argv[1]).write_text("
"__import__('sys').argv[2] + '\\n', encoding='utf-8'); "
"raise SystemExit(1)"
),
str(attempt_dir / "agy-cli.log"),
diagnostic,
]
try:
with (
mock.patch.object(
dispatch,
"build_command",
side_effect=build_agy_command,
),
mock.patch.object(
dispatch,
"agy_conversations",
return_value={},
),
):
rc, failure, locator = await dispatch.invoke(
workspace,
store,
task,
"worker",
spec,
"Read the plan.",
)
finally:
store.close()
self.assertEqual(rc, 1)
self.assertEqual(failure, "provider-quota")
record = json.loads(locator.read_text(encoding="utf-8"))
self.assertEqual(record["failure_source"], "cli-terminal-diagnostic")
self.assertEqual(
record["failure_evidence_source"],
"agy:cli-log",
)
self.assertEqual(record["failure_evidence_excerpt"], diagnostic)
async def test_exit_143_is_process_termination_not_provider_failure(self):
with tempfile.TemporaryDirectory() as temporary:
workspace = Path(temporary)
@ -1225,6 +1470,147 @@ class WorkLogInvokeIntegrationTest(unittest.IsolatedAsyncioTestCase):
class ReviewControlTest(unittest.TestCase):
def test_classifies_claude_session_limit_as_provider_quota(self):
diagnostic = (
"You've hit your session limit · resets 9pm (Asia/Seoul)"
)
self.assertEqual(
dispatch.classify_failure_with_evidence(diagnostic),
("provider-quota", diagnostic),
)
def test_claude_assistant_text_is_not_a_terminal_diagnostic(self):
assistant_event = json.dumps(
{
"type": "assistant",
"message": {
"role": "assistant",
"content": [
{
"type": "text",
"text": "You've hit your session limit",
}
],
},
}
)
self.assertIsNone(
dispatch.terminal_diagnostic("claude", "stdout", assistant_event)
)
def test_claude_rejected_rate_limit_event_is_terminal_diagnostic(self):
event = json.dumps(
{
"type": "rate_limit_event",
"rate_limit_info": {"status": "rejected"},
}
)
diagnostic = dispatch.terminal_diagnostic("claude", "stdout", event)
self.assertIsNotNone(diagnostic)
self.assertEqual(
dispatch.classify_failure_with_evidence(diagnostic or ""),
("provider-quota", diagnostic),
)
def test_agy_structured_resource_exhausted_is_terminal_diagnostic(self):
event = json.dumps(
{
"type": "error",
"error": {
"code": 429,
"status": "RESOURCE_EXHAUSTED",
"message": "Quota exceeded",
},
}
)
diagnostic = dispatch.terminal_diagnostic("agy", "stdout", event)
self.assertIsNotNone(diagnostic)
self.assertEqual(
dispatch.classify_failure_with_evidence(diagnostic or ""),
("provider-quota", diagnostic),
)
def test_agy_assistant_quota_text_is_not_a_terminal_diagnostic(self):
event = json.dumps(
{
"type": "assistant",
"status": "rejected",
"error": {"code": 429},
"content": "The quota exceeded message is handled in the code.",
}
)
self.assertIsNone(
dispatch.terminal_diagnostic("agy", "stdout", event)
)
def test_agy_log_diagnostic_requires_strong_quota_evidence(self):
with tempfile.TemporaryDirectory() as temporary:
log = Path(temporary) / "agy-cli.log"
log.write_text(
"quota configuration loaded\n"
"ERROR quota configuration refresh failed\n"
"status=RESOURCE_EXHAUSTED HTTP 429 quota exceeded\n",
encoding="utf-8",
)
self.assertEqual(
dispatch.agy_log_diagnostics(log),
["status=RESOURCE_EXHAUSTED HTTP 429 quota exceeded"],
)
def test_claude_promotion_targets_terra_high(self):
claude = dispatch.AgentSpec(
"claude",
"claude-opus-4-8",
"claude/claude-opus-4-8 xhigh",
)
promoted = dispatch.promoted_spec(claude, recovery_count=0)
self.assertEqual(
promoted,
dispatch.AgentSpec(
"codex",
"gpt-5.6-terra",
"codex/gpt-5.6-terra high",
reasoning_effort="high",
),
)
assert promoted is not None
command = dispatch.build_command(
promoted,
"Read the plan.",
Path("/workspace"),
"session-id",
Path("/attempt"),
)
self.assertIn("gpt-5.6-terra", command)
self.assertIn('model_reasoning_effort="high"', command)
self.assertEqual(
dispatch.effective_reasoning_effort(promoted),
"high",
)
self.assertIs(
dispatch.promoted_spec(promoted, recovery_count=0),
promoted,
)
def test_regular_codex_and_claude_routes_keep_xhigh_effort(self):
codex = dispatch.AgentSpec(
"codex",
"gpt-5.6-sol",
"codex/gpt-5.6-sol xhigh",
)
claude = dispatch.AgentSpec(
"claude",
"claude-opus-4-8",
"claude/claude-opus-4-8 xhigh",
)
self.assertEqual(dispatch.effective_reasoning_effort(codex), "xhigh")
self.assertEqual(dispatch.effective_reasoning_effort(claude), "xhigh")
def test_classifies_provider_tunnel_connection_refusal(self):
provider_line = (
"provider_tunnel_error: dial tcp 192.0.2.1:8001: "
@ -1799,6 +2185,341 @@ class ReviewRetryTest(unittest.IsolatedAsyncioTestCase):
def make_task(self, root: Path):
return TaskStageTest().make_task(root)
async def test_claude_provider_quota_promotes_to_terra_high(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
task = self.make_task(root)
claude = dispatch.AgentSpec(
"claude",
"claude-opus-4-8",
"claude/claude-opus-4-8 xhigh",
)
terra = dispatch.AgentSpec(
"codex",
"gpt-5.6-terra",
"codex/gpt-5.6-terra high",
reasoning_effort="high",
)
locators = [root / "locator-0.json", root / "locator-1.json"]
results = [
(1, "provider-quota", locators[0]),
(0, None, locators[1]),
]
with mock.patch.object(
dispatch,
"invoke",
new=mock.AsyncMock(side_effect=results),
) as invoke:
success, locator = await dispatch.run_escalating(
root,
mock.Mock(),
task,
"worker",
claude,
)
self.assertTrue(success)
self.assertEqual(locator, locators[1])
self.assertEqual(invoke.await_count, 2)
self.assertEqual(invoke.await_args_list[0].args[4], claude)
self.assertEqual(invoke.await_args_list[1].args[4], terra)
async def test_agy_and_claude_quota_chain_promotes_to_terra(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
task = self.make_task(root)
agy = dispatch.AgentSpec(
"agy",
"Gemini 3.5 Flash (High)",
"agy/Gemini 3.5 Flash (High)",
)
claude = dispatch.AgentSpec(
"claude",
"claude-opus-4-8",
"claude/claude-opus-4-8 xhigh",
)
terra = dispatch.AgentSpec(
"codex",
"gpt-5.6-terra",
"codex/gpt-5.6-terra high",
reasoning_effort="high",
)
locators = [
root / "locator-0.json",
root / "locator-1.json",
root / "locator-2.json",
]
with mock.patch.object(
dispatch,
"invoke",
new=mock.AsyncMock(
side_effect=[
(1, "provider-quota", locators[0]),
(1, "provider-quota", locators[1]),
(0, None, locators[2]),
]
),
) as invoke:
success, locator = await dispatch.run_escalating(
root,
mock.Mock(),
task,
"worker",
agy,
)
self.assertTrue(success)
self.assertEqual(locator, locators[2])
self.assertEqual(
[call.args[4] for call in invoke.await_args_list],
[agy, claude, terra],
)
async def test_process_termination_retries_same_claude_target(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
task = self.make_task(root)
claude = dispatch.AgentSpec(
"claude",
"claude-opus-4-8",
"claude/claude-opus-4-8 xhigh",
)
locators = [root / "locator-0.json", root / "locator-1.json"]
with (
mock.patch.object(
dispatch,
"invoke",
new=mock.AsyncMock(
side_effect=[
(1, "process-terminated", locators[0]),
(0, None, locators[1]),
]
),
) as invoke,
mock.patch.object(
dispatch.asyncio,
"sleep",
new=mock.AsyncMock(),
),
):
success, locator = await dispatch.run_escalating(
root,
mock.Mock(),
task,
"worker",
claude,
)
self.assertTrue(success)
self.assertEqual(locator, locators[1])
self.assertEqual(
[call.args[4] for call in invoke.await_args_list],
[claude, claude],
)
async def test_legacy_generic_quota_blocker_resumes_directly_on_terra(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root / ".git").mkdir()
task = self.make_task(root)
store = dispatch.StateStore(root)
locator = write_legacy_quota_attempts(
store.runs,
task,
)[-1]
state = store.task_state(task)
state.update(
blocked=(
"worker recovery failure limit exhausted: 10/10 "
f"locator={locator}"
),
recovery_failures={"worker": 10},
)
recovery = dispatch.legacy_promotion_recovery(
store.runs,
task,
state,
)
self.assertIsNotNone(recovery)
initial_route = dispatch.AgentSpec(
"agy",
"Gemini 3.5 Flash (High)",
"agy/Gemini 3.5 Flash (High)",
)
terra = dispatch.AgentSpec(
"codex",
"gpt-5.6-terra",
"codex/gpt-5.6-terra high",
reasoning_effort="high",
)
completed_locator = root / "completed-locator.json"
try:
with mock.patch.object(
dispatch,
"invoke",
new=mock.AsyncMock(
return_value=(0, None, completed_locator)
),
) as invoke:
success, actual_locator = await dispatch.run_escalating(
root,
store,
task,
"worker",
initial_route,
initial_resume_locator=locator,
)
recovered_state = store.task_state(task)
finally:
store.close()
self.assertTrue(success)
self.assertEqual(actual_locator, completed_locator)
self.assertEqual(invoke.await_count, 1)
self.assertEqual(invoke.await_args.args[4], terra)
self.assertIsNone(recovered_state["blocked"])
self.assertEqual(
recovered_state["recovery_failures"],
{},
)
self.assertEqual(
recovered_state["legacy_terminal_reclassification"][
"failed_cli"
],
"claude",
)
async def test_legacy_agy_quota_log_resumes_on_claude_then_terra(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root / ".git").mkdir()
task = self.make_task(root)
store = dispatch.StateStore(root)
locator = write_legacy_quota_attempts(
store.runs,
task,
cli="agy",
model="Gemini 3.5 Flash (High)",
reasoning_effort=None,
)[-1]
state = store.task_state(task)
state.update(
blocked=(
"worker recovery failure limit exhausted: 10/10 "
f"locator={locator}"
),
recovery_failures={"worker": 10},
)
recovery = dispatch.legacy_promotion_recovery(
store.runs,
task,
state,
)
self.assertIsNotNone(recovery)
assert recovery is not None
self.assertEqual(recovery.failed_cli, "agy")
self.assertEqual(recovery.evidence_source, "agy:cli-log")
initial_route = dispatch.AgentSpec(
"agy",
"Gemini 3.5 Flash (High)",
"agy/Gemini 3.5 Flash (High)",
)
claude = dispatch.AgentSpec(
"claude",
"claude-opus-4-8",
"claude/claude-opus-4-8 xhigh",
)
terra = dispatch.AgentSpec(
"codex",
"gpt-5.6-terra",
"codex/gpt-5.6-terra high",
reasoning_effort="high",
)
locators = [
root / "claude-locator.json",
root / "terra-locator.json",
]
try:
with mock.patch.object(
dispatch,
"invoke",
new=mock.AsyncMock(
side_effect=[
(1, "provider-quota", locators[0]),
(0, None, locators[1]),
]
),
) as invoke:
success, actual_locator = await dispatch.run_escalating(
root,
store,
task,
"worker",
initial_route,
initial_resume_locator=locator,
)
finally:
store.close()
self.assertTrue(success)
self.assertEqual(actual_locator, locators[1])
self.assertEqual(
[call.args[4] for call in invoke.await_args_list],
[claude, terra],
)
async def test_worker_persists_the_actual_promoted_target(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root / ".git").mkdir()
task = self.make_task(root)
store = dispatch.StateStore(root)
initial_route = dispatch.AgentSpec(
"agy",
"Gemini 3.5 Flash (High)",
"agy/Gemini 3.5 Flash (High)",
)
attempt = store.runs / "completed-attempt"
attempt.mkdir()
locator = attempt / "locator.json"
locator.write_text(
json.dumps(
{
"cli": "codex",
"model": "gpt-5.6-terra",
"reasoning_effort": "high",
}
),
encoding="utf-8",
)
try:
with (
mock.patch.object(
dispatch,
"route_agent",
return_value=initial_route,
),
mock.patch.object(
dispatch,
"run_escalating",
new=mock.AsyncMock(return_value=(True, locator)),
),
):
await dispatch.run_worker(
root,
store,
task,
{"agy": asyncio.Semaphore(1)},
)
state = store.task_state(task)
finally:
store.close()
self.assertTrue(state["worker_done"])
self.assertEqual(state["worker_cli"], "codex")
self.assertEqual(state["worker_model"], "gpt-5.6-terra")
self.assertTrue(state["selfcheck_done"])
async def test_retries_two_control_violations_then_succeeds(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
@ -3765,6 +4486,199 @@ class OrchestrationPersistenceTest(unittest.TestCase):
finally:
store.close()
def test_legacy_promotion_recovery_requires_older_source_and_typed_event(self):
with tempfile.TemporaryDirectory() as temporary:
workspace = Path(temporary)
(workspace / ".git").mkdir()
self.make_task(workspace, "alpha/01_task")
task = dispatch.scan_tasks(workspace, None)[0]
runs = workspace / ".git" / "agent-task-dispatcher" / "runs"
locators = write_legacy_quota_attempts(runs, task)
locator = locators[-1]
state = {
"blocked": (
"worker recovery failure limit exhausted: 10/10 "
f"locator={locator}"
),
"recovery_failures": {"worker": 10},
}
recovery = dispatch.legacy_promotion_recovery(
runs,
task,
state,
)
self.assertIsNotNone(recovery)
assert recovery is not None
self.assertEqual(recovery.failure_class, "provider-quota")
self.assertEqual(recovery.evidence_source, "claude:stdout")
record = json.loads(locator.read_text(encoding="utf-8"))
record["dispatcher_source_sha256"] = (
dispatch.DISPATCHER_SOURCE_SHA256
)
locator.write_text(json.dumps(record), encoding="utf-8")
self.assertIsNone(
dispatch.legacy_promotion_recovery(runs, task, state)
)
def test_legacy_promotion_recovery_rejects_mixed_attempt_history(self):
with tempfile.TemporaryDirectory() as temporary:
workspace = Path(temporary)
(workspace / ".git").mkdir()
self.make_task(workspace, "alpha/01_task")
task = dispatch.scan_tasks(workspace, None)[0]
runs = workspace / ".git" / "agent-task-dispatcher" / "runs"
locators = write_legacy_quota_attempts(runs, task)
mixed_stream = locators[4].parent / "stream.log"
mixed_stream.write_text(
"[stdout] "
+ json.dumps(
{
"type": "assistant",
"message": {
"content": "You've hit your session limit"
},
}
)
+ "\n",
encoding="utf-8",
)
state = {
"blocked": (
"worker recovery failure limit exhausted: 10/10 "
f"locator={locators[-1]}"
),
"recovery_failures": {"worker": 10},
}
self.assertIsNone(
dispatch.legacy_promotion_recovery(runs, task, state)
)
def test_persisted_legacy_promotion_survives_dispatcher_restart(self):
with tempfile.TemporaryDirectory() as temporary:
workspace = Path(temporary)
(workspace / ".git").mkdir()
self.make_task(workspace, "alpha/01_task")
task = dispatch.scan_tasks(workspace, None)[0]
runs = workspace / ".git" / "agent-task-dispatcher" / "runs"
locator = write_legacy_quota_attempts(runs, task)[-1]
blocked_state = {
"blocked": (
"worker recovery failure limit exhausted: 10/10 "
f"locator={locator}"
),
"recovery_failures": {"worker": 10},
}
recovery = dispatch.legacy_promotion_recovery(
runs,
task,
blocked_state,
)
assert recovery is not None
restarted_state = {
"blocked": None,
"recovery_failures": {"worker": 1},
"legacy_terminal_reclassification": {
"role": recovery.role,
"failure_class": recovery.failure_class,
"evidence_source": recovery.evidence_source,
"prior_dispatcher_sha256":
recovery.prior_dispatcher_sha256,
"current_dispatcher_sha256":
dispatch.DISPATCHER_SOURCE_SHA256,
"locator": str(recovery.locator),
"failed_cli": recovery.failed_cli,
"failed_model": recovery.failed_model,
"failed_reasoning_effort":
recovery.failed_reasoning_effort,
},
}
restored = (
dispatch.pending_persisted_legacy_promotion_recovery(
task,
restarted_state,
)
)
self.assertIsNotNone(restored)
assert restored is not None
self.assertEqual(restored.failed_cli, "claude")
self.assertEqual(
dispatch.promoted_spec(
dispatch.failed_spec_from_recovery(restored),
0,
).model,
"gpt-5.6-terra",
)
def test_persisted_legacy_agy_promotion_survives_dispatcher_restart(self):
with tempfile.TemporaryDirectory() as temporary:
workspace = Path(temporary)
(workspace / ".git").mkdir()
self.make_task(workspace, "alpha/01_task")
task = dispatch.scan_tasks(workspace, None)[0]
runs = workspace / ".git" / "agent-task-dispatcher" / "runs"
locator = write_legacy_quota_attempts(
runs,
task,
cli="agy",
model="Gemini 3.5 Flash (High)",
reasoning_effort=None,
)[-1]
blocked_state = {
"blocked": (
"worker recovery failure limit exhausted: 10/10 "
f"locator={locator}"
),
"recovery_failures": {"worker": 10},
}
recovery = dispatch.legacy_promotion_recovery(
runs,
task,
blocked_state,
)
assert recovery is not None
restarted_state = {
"blocked": None,
"recovery_failures": {"worker": 1},
"legacy_terminal_reclassification": {
"role": recovery.role,
"failure_class": recovery.failure_class,
"evidence_source": recovery.evidence_source,
"prior_dispatcher_sha256":
recovery.prior_dispatcher_sha256,
"current_dispatcher_sha256":
dispatch.DISPATCHER_SOURCE_SHA256,
"locator": str(recovery.locator),
"failed_cli": recovery.failed_cli,
"failed_model": recovery.failed_model,
"failed_reasoning_effort":
recovery.failed_reasoning_effort,
},
}
restored = (
dispatch.pending_persisted_legacy_promotion_recovery(
task,
restarted_state,
)
)
self.assertIsNotNone(restored)
assert restored is not None
self.assertEqual(restored.failed_cli, "agy")
self.assertEqual(restored.evidence_source, "agy:cli-log")
self.assertEqual(
dispatch.promoted_spec(
dispatch.failed_spec_from_recovery(restored),
0,
).cli,
"claude",
)
def test_corrupt_persistent_state_fails_closed_and_releases_lock(self):
with tempfile.TemporaryDirectory() as temporary:
workspace = Path(temporary)

View file

@ -0,0 +1,144 @@
import importlib.util
import sys
import unittest
from datetime import datetime, timezone
from pathlib import Path
SCRIPT = (
Path(__file__).resolve().parents[1]
/ "scripts"
/ "execution_target_policy.py"
)
SPEC = importlib.util.spec_from_file_location("execution_target_policy", SCRIPT)
policy = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
sys.modules[SPEC.name] = policy
SPEC.loader.exec_module(policy)
def at_utc(hour: int, minute: int = 0, second: int = 0) -> datetime:
return datetime(2026, 7, 24, hour, minute, second, tzinfo=timezone.utc)
class ExecutionTargetPolicyTests(unittest.TestCase):
def test_kst_boundary_routes(self):
cases = [
(at_utc(21, 59, 59), "pi", "iop/laguna-s:2.1"),
(at_utc(22, 0, 0), "agy", "Gemini 3.6 Flash Medium"),
(at_utc(13, 59, 59), "agy", "Gemini 3.6 Flash Medium"),
(at_utc(14, 0, 0), "pi", "iop/laguna-s:2.1"),
]
for evaluated_at, adapter, target in cases:
with self.subTest(evaluated_at=evaluated_at):
decision = policy.select_policy(
stage="worker",
lane="local",
grade=7,
evaluated_at=evaluated_at,
)
self.assertEqual(decision.candidates[0].adapter, adapter)
self.assertEqual(decision.candidates[0].target, target)
def test_worker_grade_matrix_has_no_gaps(self):
daytime = at_utc(3)
expected = {
"local": {
**{
grade: ("pi", "iop/ornith-fast", True)
for grade in range(1, 7)
},
7: ("agy", "Gemini 3.6 Flash Medium", False),
8: ("agy", "Gemini 3.6 Flash Medium", False),
9: ("claude", "claude-opus-4-8", False),
10: ("claude", "claude-opus-4-8", False),
},
"cloud": {
**{
grade: ("claude", "sonnet", False)
for grade in range(1, 7)
},
7: ("claude", "claude-opus-4-8", False),
8: ("claude", "claude-opus-4-8", False),
9: ("codex", "gpt-5.6-sol", False),
10: ("codex", "gpt-5.6-sol", False),
},
}
for lane, grades in expected.items():
for grade, route in grades.items():
with self.subTest(lane=lane, grade=grade):
selected = policy.select_policy(
stage="worker",
lane=lane,
grade=grade,
evaluated_at=daytime,
).candidates[0]
self.assertEqual(
(
selected.adapter,
selected.target,
selected.selfcheck_required,
),
route,
)
def test_review_matrix_is_fixed_to_codex(self):
for lane in ("local", "cloud"):
for grade in range(1, 11):
with self.subTest(lane=lane, grade=grade):
decision = policy.select_policy(
stage="review",
lane=lane,
grade=grade,
evaluated_at=at_utc(3),
)
self.assertEqual(decision.rule_id, "official-review-codex")
self.assertEqual(decision.candidates, (policy.CODEX_SOL_XHIGH,))
def test_dynamic_route_orders_only_the_two_allowed_candidates(self):
daytime = policy.select_policy(
stage="worker",
lane="local",
grade=8,
evaluated_at=at_utc(3),
)
nighttime = policy.select_policy(
stage="worker",
lane="local",
grade=8,
evaluated_at=at_utc(15),
)
self.assertEqual(
[candidate.adapter for candidate in daytime.candidates],
["agy", "pi"],
)
self.assertEqual(
[candidate.adapter for candidate in nighttime.candidates],
["pi", "agy"],
)
def test_invalid_inputs_are_rejected(self):
cases = [
{"stage": "selfcheck", "lane": "local", "grade": 7},
{"stage": "worker", "lane": "hybrid", "grade": 7},
{"stage": "worker", "lane": "local", "grade": 0},
{"stage": "worker", "lane": "local", "grade": 11},
]
for values in cases:
with self.subTest(values=values):
with self.assertRaises(ValueError):
policy.select_policy(
**values,
evaluated_at=at_utc(3),
)
with self.assertRaisesRegex(ValueError, "timezone-aware"):
policy.select_policy(
stage="worker",
lane="local",
grade=7,
evaluated_at=datetime(2026, 7, 25, 12, 0, 0),
)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,988 @@
import copy
import importlib.util
import json
import subprocess
import sys
import unittest
from datetime import datetime
from pathlib import Path
from tempfile import TemporaryDirectory
from zoneinfo import ZoneInfo
SCRIPT = (
Path(__file__).resolve().parents[1]
/ "scripts"
/ "select_execution_target.py"
)
SPEC = importlib.util.spec_from_file_location("select_execution_target", SCRIPT)
selector = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
sys.modules[SPEC.name] = selector
SPEC.loader.exec_module(selector)
KST = ZoneInfo("Asia/Seoul")
def kst(hour: int, minute: int = 0, second: int = 0) -> datetime:
return datetime(2026, 7, 25, hour, minute, second, tzinfo=KST)
def write_task_file(
directory: Path,
kind: str,
lane: str,
grade: int,
*,
task: str = "grp/01_unit",
plan: int = 0,
tag: str = "API",
body: str = "body\n",
) -> Path:
path = Path(directory) / f"{kind}-{lane}-G{grade:02d}.md"
path.write_text(
f"<!-- task={task} plan={plan} tag={tag} -->\n\n# title\n\n{body}",
encoding="utf-8",
)
return path
_DELETE = object()
def _apply_path(prior: dict, path: tuple, value) -> None:
*parents, last = path
node = prior
for key in parents:
node = node[key]
if value is _DELETE:
del node[last]
else:
node[last] = value
# (name, path into a valid initial decision, replacement or _DELETE) triples that
# each leave the top-level containers well-typed but break one nested
# field/type/enum the resume path reuses verbatim.
MALFORMED_NESTED_VARIANTS = [
("empty_candidate", ("candidates", 0), {}),
("candidate_missing_quota_mode", ("candidates", 0, "quota_mode"), _DELETE),
("candidate_bad_eligibility_enum", ("candidates", 0, "eligibility"), "maybe"),
("candidate_bad_selfcheck_type", ("candidates", 0, "selfcheck_required"), "yes"),
("candidate_rank_not_consecutive", ("candidates", 0, "candidate_rank"), 5),
("candidates_empty_list", ("candidates",), []),
("decision_missing_rule_id", ("decision", "rule_id"), _DELETE),
("decision_bad_time_window_enum", ("decision", "time_window"), "bogus"),
("decision_wrong_timezone", ("decision", "timezone"), "UTC"),
("decision_bad_pinned_type", ("decision", "pinned"), "yes"),
("decision_reason_codes_scalar", ("decision", "reason_codes"), "kst_day_window"),
("quota_missing_mode", ("quota", "mode"), _DELETE),
("quota_bad_mode_enum", ("quota", "mode"), "bogus"),
("quota_bad_status_enum", ("quota", "status"), "maybe"),
("quota_bad_snapshot_id_type", ("quota", "snapshot_id"), 5),
]
class SelectorContractTests(unittest.TestCase):
def test_worker_contract_shape_and_types(self):
with TemporaryDirectory() as tmp:
task_file = write_task_file(Path(tmp), "PLAN", "cloud", 7)
result = selector.select_execution_target(
task_file, evaluated_at=kst(12)
)
self.assertEqual(result["schema_version"], "1.0")
self.assertEqual(
result["work_unit_id"], "grp/01_unit::plan-0::tag-API"
)
self.assertEqual(result["stage"], "worker")
self.assertEqual(result["lane"], "cloud")
self.assertEqual(result["grade"], 7)
self.assertIsInstance(result["grade"], int)
self.assertEqual(
result["selected"],
{
"adapter": "claude",
"target": "claude-opus-4-8",
"execution_class": "cloud_model",
"selfcheck_required": False,
},
)
for key in ("rule_id", "policy_priority", "reason_codes", "pinned"):
self.assertIn(key, result["decision"])
self.assertIs(result["decision"]["pinned"], False)
self.assertEqual(result["decision"]["timezone"], "Asia/Seoul")
self.assertEqual(
set(result["quota"]),
{"snapshot_id", "mode", "status", "source", "checked_at"},
)
self.assertEqual(result["transition"]["trigger"], "initial")
self.assertEqual(result["transition"]["context_transfer"], "none")
def test_stage_inference_and_mismatch(self):
with TemporaryDirectory() as tmp:
plan_file = write_task_file(Path(tmp), "PLAN", "local", 5)
review_file = write_task_file(Path(tmp), "CODE_REVIEW", "local", 5)
self.assertEqual(
selector.select_execution_target(
plan_file, evaluated_at=kst(12)
)["stage"],
"worker",
)
self.assertEqual(
selector.select_execution_target(
review_file, evaluated_at=kst(12)
)["stage"],
"review",
)
with self.assertRaises(selector.SelectorInputError) as ctx:
selector.select_execution_target(
plan_file, stage="review", evaluated_at=kst(12)
)
self.assertEqual(ctx.exception.code, "stage_mismatch")
with self.assertRaises(selector.SelectorInputError):
selector.select_execution_target(
review_file, stage="worker", evaluated_at=kst(12)
)
def test_invalid_filenames_and_grades_rejected(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
bad_names = [
"NOTE-cloud-G07.md",
"PLAN-hybrid-G07.md",
"PLAN-cloud-G7.md",
"PLAN-cloud-G07.txt",
"PLAN-cloud-G00.md",
"PLAN-cloud-G11.md",
]
for name in bad_names:
path = root / name
path.write_text(
"<!-- task=grp/01_unit plan=0 tag=API -->\n", encoding="utf-8"
)
with self.subTest(name=name):
with self.assertRaises(selector.SelectorInputError):
selector.select_execution_target(
path, evaluated_at=kst(12)
)
def test_malformed_header_rejected(self):
with TemporaryDirectory() as tmp:
path = Path(tmp) / "PLAN-cloud-G05.md"
path.write_text("# no generation header\n", encoding="utf-8")
with self.assertRaises(selector.SelectorInputError) as ctx:
selector.select_execution_target(path, evaluated_at=kst(12))
self.assertEqual(ctx.exception.code, "malformed_header")
def test_work_unit_id_stable_across_body_changes(self):
with TemporaryDirectory() as tmp:
task_file = write_task_file(
Path(tmp), "PLAN", "cloud", 7, body="first body\n"
)
first = selector.select_execution_target(
task_file, evaluated_at=kst(12)
)["work_unit_id"]
task_file.write_text(
"<!-- task=grp/01_unit plan=0 tag=API -->\n\n# title\n\n"
"a much longer body with different content\n",
encoding="utf-8",
)
second = selector.select_execution_target(
task_file, evaluated_at=kst(12)
)["work_unit_id"]
self.assertEqual(first, second)
# A new plan/tag generation must yield a new identity.
changed = write_task_file(Path(tmp), "PLAN", "cloud", 7, plan=1)
self.assertNotEqual(
first,
selector.select_execution_target(
changed, evaluated_at=kst(12)
)["work_unit_id"],
)
def test_deterministic_output_for_fixed_clock(self):
with TemporaryDirectory() as tmp:
task_file = write_task_file(Path(tmp), "PLAN", "local", 8)
first = selector.to_json(
selector.select_execution_target(task_file, evaluated_at=kst(12))
)
second = selector.to_json(
selector.select_execution_target(task_file, evaluated_at=kst(12))
)
self.assertEqual(first, second)
def test_repeated_input_is_byte_stable(self):
with TemporaryDirectory() as tmp:
task_file = write_task_file(Path(tmp), "CODE_REVIEW", "cloud", 9)
runs = [
subprocess.run(
[
sys.executable,
str(SCRIPT),
str(task_file),
"--evaluated-at",
"2026-07-25T12:00:00+09:00",
],
capture_output=True,
check=True,
)
for _ in range(2)
]
self.assertEqual(runs[0].stdout, runs[1].stdout)
self.assertTrue(runs[0].stdout.strip())
def test_resume_pins_prior_target_across_time(self):
with TemporaryDirectory() as tmp:
task_file = write_task_file(Path(tmp), "PLAN", "local", 7)
daytime = selector.select_execution_target(
task_file, evaluated_at=kst(12)
)
self.assertEqual(daytime["selected"]["adapter"], "agy")
# A fresh night initial would flip to Laguna; resume must not.
night_initial = selector.select_execution_target(
task_file, evaluated_at=kst(2)
)
self.assertEqual(night_initial["selected"]["adapter"], "pi")
resumed = selector.select_execution_target(
task_file,
evaluated_at=kst(2),
transition="resume",
prior_decision=daytime,
)
self.assertEqual(resumed["selected"], daytime["selected"])
self.assertIs(resumed["decision"]["pinned"], True)
self.assertEqual(resumed["transition"]["trigger"], "resume")
self.assertEqual(
resumed["transition"]["previous_target"],
{"adapter": "agy", "target": "Gemini 3.6 Flash Medium"},
)
def test_resume_requires_matching_prior_decision(self):
with TemporaryDirectory() as tmp:
task_file = write_task_file(Path(tmp), "PLAN", "local", 7)
with self.assertRaises(selector.SelectorInputError) as ctx:
selector.select_execution_target(
task_file, evaluated_at=kst(12), transition="resume"
)
self.assertEqual(
ctx.exception.code, "resume_requires_prior_decision"
)
other = selector.select_execution_target(
task_file, evaluated_at=kst(12)
)
other["work_unit_id"] = "grp/other::plan-0::tag-API"
with self.assertRaises(selector.SelectorInputError) as ctx:
selector.select_execution_target(
task_file,
evaluated_at=kst(12),
transition="resume",
prior_decision=other,
)
self.assertEqual(ctx.exception.code, "resume_work_unit_mismatch")
def test_failover_transition_is_unsupported(self):
with TemporaryDirectory() as tmp:
task_file = write_task_file(Path(tmp), "PLAN", "local", 7)
with self.assertRaises(selector.SelectorInputError) as ctx:
selector.select_execution_target(
task_file, evaluated_at=kst(12), transition="failover"
)
self.assertEqual(ctx.exception.code, "unsupported_transition")
def test_cli_input_error_is_stderr_json_without_stdout(self):
with TemporaryDirectory() as tmp:
task_file = write_task_file(Path(tmp), "PLAN", "local", 7)
proc = subprocess.run(
[
sys.executable,
str(SCRIPT),
str(task_file),
"--evaluated-at",
"2026-07-25T12:00:00+09:00",
"--transition",
"failover",
],
capture_output=True,
text=True,
)
self.assertEqual(proc.returncode, 2)
self.assertEqual(proc.stdout, "")
self.assertEqual(
json.loads(proc.stderr)["error"], "unsupported_transition"
)
class SelectorRouteMatrixTests(unittest.TestCase):
def test_kst_boundary_routes_through_selector(self):
cases = [
(kst(6, 59, 59), "pi", "iop/laguna-s:2.1"),
(kst(7, 0, 0), "agy", "Gemini 3.6 Flash Medium"),
(kst(22, 59, 59), "agy", "Gemini 3.6 Flash Medium"),
(kst(23, 0, 0), "pi", "iop/laguna-s:2.1"),
]
with TemporaryDirectory() as tmp:
for grade in (7, 8):
task_file = write_task_file(Path(tmp), "PLAN", "local", grade)
for evaluated_at, adapter, target in cases:
with self.subTest(grade=grade, evaluated_at=evaluated_at):
result = selector.select_execution_target(
task_file, evaluated_at=evaluated_at
)
self.assertEqual(result["selected"]["adapter"], adapter)
self.assertEqual(result["selected"]["target"], target)
def test_worker_route_matrix_through_selector(self):
expected = {
"local": {
**{g: ("pi", "iop/ornith-fast", "local_model", True)
for g in range(1, 7)},
7: ("agy", "Gemini 3.6 Flash Medium", "cloud_model", False),
8: ("agy", "Gemini 3.6 Flash Medium", "cloud_model", False),
9: ("claude", "claude-opus-4-8", "cloud_model", False),
10: ("claude", "claude-opus-4-8", "cloud_model", False),
},
"cloud": {
**{g: ("claude", "sonnet", "cloud_model", False)
for g in range(1, 7)},
7: ("claude", "claude-opus-4-8", "cloud_model", False),
8: ("claude", "claude-opus-4-8", "cloud_model", False),
9: ("codex", "gpt-5.6-sol", "cloud_model", False),
10: ("codex", "gpt-5.6-sol", "cloud_model", False),
},
}
with TemporaryDirectory() as tmp:
for lane, grades in expected.items():
for grade, route in grades.items():
task_file = write_task_file(
Path(tmp), "PLAN", lane, grade
)
with self.subTest(lane=lane, grade=grade):
sel = selector.select_execution_target(
task_file, evaluated_at=kst(12)
)["selected"]
self.assertEqual(
(
sel["adapter"],
sel["target"],
sel["execution_class"],
sel["selfcheck_required"],
),
route,
)
def test_review_route_matrix_is_codex(self):
with TemporaryDirectory() as tmp:
for lane in ("local", "cloud"):
for grade in range(1, 11):
task_file = write_task_file(
Path(tmp), "CODE_REVIEW", lane, grade
)
with self.subTest(lane=lane, grade=grade):
sel = selector.select_execution_target(
task_file, evaluated_at=kst(12)
)["selected"]
self.assertEqual(
(
sel["adapter"],
sel["target"],
sel["execution_class"],
sel["selfcheck_required"],
),
("codex", "gpt-5.6-sol", "cloud_model", False),
)
def test_candidate_rank_is_consecutive_and_time_ordered(self):
with TemporaryDirectory() as tmp:
dynamic = write_task_file(Path(tmp), "PLAN", "local", 8)
daytime = selector.select_execution_target(
dynamic, evaluated_at=kst(12)
)["candidates"]
nighttime = selector.select_execution_target(
dynamic, evaluated_at=kst(2)
)["candidates"]
self.assertEqual(
[c["candidate_rank"] for c in daytime], [1, 2]
)
self.assertEqual(
[c["adapter"] for c in daytime], ["agy", "pi"]
)
self.assertEqual(
[c["adapter"] for c in nighttime], ["pi", "agy"]
)
single = write_task_file(Path(tmp), "PLAN", "cloud", 5)
candidates = selector.select_execution_target(
single, evaluated_at=kst(12)
)["candidates"]
self.assertEqual([c["candidate_rank"] for c in candidates], [1])
class SelectorQuotaRepresentationTests(unittest.TestCase):
def test_quota_probe_tri_state(self):
snapshots = {
"exhausted": "exhausted",
"available": "available",
"unknown": "unknown",
}
with TemporaryDirectory() as tmp:
cloud = write_task_file(Path(tmp), "PLAN", "cloud", 7)
for name, status in snapshots.items():
with self.subTest(status=name):
if status == "exhausted":
with self.assertRaises(selector.SelectorInputError) as ctx:
selector.select_execution_target(
cloud,
evaluated_at=kst(12),
quota_snapshot={
"snapshot_id": f"probe-{name}",
"source": "iop-node quota-probe",
"checked_at": "2026-07-25T03:00:00+09:00",
"targets": [
{
"adapter": "claude",
"target": "claude-opus-4-8",
"status": status,
}
],
},
)
self.assertEqual(ctx.exception.code, "no_eligible_target")
continue
result = selector.select_execution_target(
cloud,
evaluated_at=kst(12),
quota_snapshot={
"snapshot_id": f"probe-{name}",
"source": "iop-node quota-probe",
"checked_at": "2026-07-25T03:00:00+09:00",
"targets": [
{
"adapter": "claude",
"target": "claude-opus-4-8",
"status": status,
}
],
},
)
candidate = result["candidates"][0]
self.assertEqual(candidate["quota_status"], status)
self.assertEqual(
candidate["eligibility"],
"eligible",
)
def test_exhausted_primary_selects_eligible_alternate(self):
snapshot = {
"snapshot_id": "gemini-exhausted",
"source": "iop-node quota-probe",
"checked_at": "2026-07-25T03:00:00+09:00",
"targets": [
{
"adapter": "agy",
"target": "Gemini 3.6 Flash Medium",
"status": "exhausted",
}
],
}
with TemporaryDirectory() as tmp:
task_file = write_task_file(Path(tmp), "PLAN", "local", 7)
result = selector.select_execution_target(
task_file, evaluated_at=kst(12), quota_snapshot=snapshot
)
self.assertEqual(result["candidates"][0]["eligibility"], "ineligible")
self.assertEqual(
result["selected"],
{
"adapter": "pi",
"target": "iop/laguna-s:2.1",
"execution_class": "local_model",
"selfcheck_required": True,
},
)
self.assertEqual(result["quota"]["status"], "not_applicable")
def test_all_candidates_exhausted_returns_no_eligible_target(self):
snapshot = {
"snapshot_id": "opus-exhausted",
"source": "iop-node quota-probe",
"checked_at": "2026-07-25T03:00:00+09:00",
"targets": [
{
"adapter": "claude",
"target": "claude-opus-4-8",
"status": "exhausted",
}
],
}
with TemporaryDirectory() as tmp:
root = Path(tmp)
task_file = write_task_file(root, "PLAN", "cloud", 7)
with self.assertRaises(selector.SelectorInputError) as ctx:
selector.select_execution_target(
task_file, evaluated_at=kst(12), quota_snapshot=snapshot
)
self.assertEqual(ctx.exception.code, "no_eligible_target")
snapshot_path = root / "quota.json"
snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8")
proc = subprocess.run(
[
sys.executable,
str(SCRIPT),
str(task_file),
"--evaluated-at",
"2026-07-25T12:00:00+09:00",
"--quota-snapshot",
str(snapshot_path),
],
capture_output=True,
text=True,
)
self.assertEqual(proc.returncode, 2)
self.assertEqual(proc.stdout, "")
self.assertEqual(json.loads(proc.stderr)["error"], "no_eligible_target")
def test_unknown_is_admitted_once_per_work_unit(self):
snapshot = {
"snapshot_id": "unknown-1",
"source": "iop-node quota-probe",
"checked_at": "2026-07-25T03:00:00+09:00",
"targets": [
{
"adapter": "claude",
"target": "claude-opus-4-8",
"status": "unknown",
}
],
}
with TemporaryDirectory() as tmp:
cloud = write_task_file(Path(tmp), "PLAN", "cloud", 7)
initial = selector.select_execution_target(
cloud, evaluated_at=kst(12), quota_snapshot=snapshot
)
self.assertEqual(initial["candidates"][0]["eligibility"], "eligible")
# Resume consumes the persisted decision instead of evaluating a
# second unknown admission for the same task/plan/tag generation.
resumed = selector.select_execution_target(
cloud,
evaluated_at=kst(12),
transition="resume",
prior_decision=initial,
quota_snapshot={
**snapshot,
"snapshot_id": "later-exhausted",
"targets": [
{
"adapter": "claude",
"target": "claude-opus-4-8",
"status": "exhausted",
}
],
},
)
self.assertEqual(resumed["quota"], initial["quota"])
self.assertEqual(resumed["candidates"], initial["candidates"])
def test_local_route_does_not_call_probe(self):
with TemporaryDirectory() as tmp:
local = write_task_file(Path(tmp), "PLAN", "local", 3)
result = selector.select_execution_target(
local,
evaluated_at=kst(12),
quota_probe_command="probe must not be used for local",
)
self.assertEqual(result["quota"]["mode"], "unbounded")
self.assertEqual(result["quota"]["status"], "not_applicable")
self.assertEqual(result["quota"]["source"], "local_unbounded")
def test_generic_stderr_is_not_quota_evidence(self):
with TemporaryDirectory() as tmp:
cloud = write_task_file(Path(tmp), "PLAN", "cloud", 7)
result = selector.select_execution_target(
cloud,
evaluated_at=kst(12),
quota_probe_command="generic stderr: quota might be exhausted",
)
self.assertEqual(result["quota"]["status"], "unknown")
self.assertEqual(result["candidates"][0]["eligibility"], "eligible")
def test_quota_representation_without_snapshot(self):
with TemporaryDirectory() as tmp:
cloud = write_task_file(Path(tmp), "PLAN", "cloud", 7)
cloud_result = selector.select_execution_target(
cloud, evaluated_at=kst(12)
)
self.assertEqual(cloud_result["quota"]["mode"], "bounded")
self.assertEqual(cloud_result["quota"]["status"], "unknown")
self.assertEqual(
cloud_result["quota"]["source"],
selector.DEFAULT_QUOTA_PROBE_COMMAND,
)
local = write_task_file(Path(tmp), "PLAN", "local", 3)
local_result = selector.select_execution_target(
local, evaluated_at=kst(12)
)
self.assertEqual(local_result["quota"]["mode"], "unbounded")
self.assertEqual(local_result["quota"]["status"], "not_applicable")
# Dynamic local G07 daytime: cloud Gemini + local Laguna candidates.
dynamic = write_task_file(Path(tmp), "PLAN", "local", 7)
candidates = selector.select_execution_target(
dynamic, evaluated_at=kst(12)
)["candidates"]
gemini = next(c for c in candidates if c["adapter"] == "agy")
laguna = next(c for c in candidates if c["adapter"] == "pi")
self.assertEqual(gemini["quota_status"], "unknown")
self.assertEqual(laguna["quota_status"], "not_applicable")
def test_injected_snapshot_is_reflected(self):
snapshot = {
"snapshot_id": "snap-1",
"source": "usage-checker",
"checked_at": "2026-07-25T03:00:00+09:00",
"targets": [
{"adapter": "claude", "target": "sonnet", "status": "available"}
],
}
with TemporaryDirectory() as tmp:
cloud = write_task_file(Path(tmp), "PLAN", "cloud", 5)
result = selector.select_execution_target(
cloud, evaluated_at=kst(12), quota_snapshot=snapshot
)
self.assertEqual(result["quota"]["status"], "available")
self.assertEqual(result["quota"]["snapshot_id"], "snap-1")
self.assertEqual(result["quota"]["source"], "usage-checker")
self.assertEqual(
result["candidates"][0]["quota_status"], "available"
)
class SelectorNestedInputContractTests(unittest.TestCase):
def test_resume_rejects_incomplete_selected_schema(self):
with TemporaryDirectory() as tmp:
task_file = write_task_file(Path(tmp), "PLAN", "local", 7)
prior = selector.select_execution_target(
task_file, evaluated_at=kst(12)
)
# Reproduce the prior loop: a selected with only adapter/target must
# no longer flow through as a "successful" resume schema.
prior["selected"] = {
"adapter": prior["selected"]["adapter"],
"target": prior["selected"]["target"],
}
with self.assertRaises(selector.SelectorInputError) as ctx:
selector.select_execution_target(
task_file,
evaluated_at=kst(12),
transition="resume",
prior_decision=prior,
)
self.assertEqual(ctx.exception.code, "malformed_prior_decision")
def test_resume_rejects_malformed_nested_prior_schema_variants(self):
with TemporaryDirectory() as tmp:
task_file = write_task_file(Path(tmp), "PLAN", "local", 7)
base = selector.select_execution_target(
task_file, evaluated_at=kst(12)
)
# Sanity: the untouched decision resumes cleanly.
self.assertEqual(
selector.select_execution_target(
task_file,
evaluated_at=kst(12),
transition="resume",
prior_decision=copy.deepcopy(base),
)["selected"],
base["selected"],
)
for name, path, value in MALFORMED_NESTED_VARIANTS:
with self.subTest(variant=name):
prior = copy.deepcopy(base)
_apply_path(prior, path, value)
with self.assertRaises(selector.SelectorInputError) as ctx:
selector.select_execution_target(
task_file,
evaluated_at=kst(12),
transition="resume",
prior_decision=prior,
)
self.assertEqual(
ctx.exception.code, "malformed_prior_decision"
)
def test_cli_deeply_malformed_prior_uses_json_error_envelope(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
task_file = write_task_file(root, "PLAN", "local", 7)
prior = selector.select_execution_target(
task_file, evaluated_at=kst(12)
)
# Containers stay well-typed object/list; only a nested enum is bad.
prior["quota"]["mode"] = "bogus"
prior_path = root / "prior.json"
prior_path.write_text(json.dumps(prior), encoding="utf-8")
proc = subprocess.run(
[
sys.executable,
str(SCRIPT),
str(task_file),
"--evaluated-at",
"2026-07-25T12:00:00+09:00",
"--transition",
"resume",
"--prior-decision",
str(prior_path),
],
capture_output=True,
text=True,
)
self.assertEqual(proc.returncode, 2)
self.assertEqual(proc.stdout, "")
self.assertEqual(
json.loads(proc.stderr)["error"], "malformed_prior_decision"
)
def test_cli_malformed_prior_uses_json_error_envelope(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
task_file = write_task_file(root, "PLAN", "local", 7)
prior = selector.select_execution_target(
task_file, evaluated_at=kst(12)
)
# A scalar where a nested object is required must not reach a raw
# TypeError/AttributeError traceback.
prior["decision"] = 1
prior_path = root / "prior.json"
prior_path.write_text(json.dumps(prior), encoding="utf-8")
proc = subprocess.run(
[
sys.executable,
str(SCRIPT),
str(task_file),
"--evaluated-at",
"2026-07-25T12:00:00+09:00",
"--transition",
"resume",
"--prior-decision",
str(prior_path),
],
capture_output=True,
text=True,
)
self.assertEqual(proc.returncode, 2)
self.assertEqual(proc.stdout, "")
self.assertEqual(
json.loads(proc.stderr)["error"], "malformed_prior_decision"
)
def test_cli_malformed_quota_uses_json_error_envelope(self):
with TemporaryDirectory() as tmp:
root = Path(tmp)
task_file = write_task_file(root, "PLAN", "cloud", 5)
cases = {
# A bare array instead of the snapshot object.
"array_snapshot": [
{
"adapter": "claude",
"target": "sonnet",
"status": "available",
}
],
# A target entry missing the required status field.
"invalid_target_entry": {
"targets": [{"adapter": "claude", "target": "sonnet"}]
},
}
for name, snapshot in cases.items():
quota_path = root / f"quota_{name}.json"
quota_path.write_text(json.dumps(snapshot), encoding="utf-8")
with self.subTest(case=name):
proc = subprocess.run(
[
sys.executable,
str(SCRIPT),
str(task_file),
"--evaluated-at",
"2026-07-25T12:00:00+09:00",
"--quota-snapshot",
str(quota_path),
],
capture_output=True,
text=True,
)
self.assertEqual(proc.returncode, 2)
self.assertEqual(proc.stdout, "")
self.assertEqual(
json.loads(proc.stderr)["error"],
"malformed_quota_snapshot",
)
class SelectorIdentityAndQuotaRoundtripTests(unittest.TestCase):
_VALID_TARGETS = [
{"adapter": "claude", "target": "sonnet", "status": "available"}
]
def test_resume_rejects_unhashable_stage_and_lane_types(self):
with TemporaryDirectory() as tmp:
task_file = write_task_file(Path(tmp), "PLAN", "local", 7)
base = selector.select_execution_target(
task_file, evaluated_at=kst(12)
)
# list/dict identity values must be normalized to a stable selector
# error instead of leaking a raw unhashable-type TypeError/exit 1.
for field, unhashable in (("stage", []), ("lane", {})):
with self.subTest(field=field):
prior = copy.deepcopy(base)
prior[field] = unhashable
with self.assertRaises(selector.SelectorInputError) as ctx:
selector.select_execution_target(
task_file,
evaluated_at=kst(12),
transition="resume",
prior_decision=prior,
)
self.assertEqual(
ctx.exception.code, "malformed_prior_decision"
)
def test_quota_metadata_is_validated_before_initial_output(self):
with TemporaryDirectory() as tmp:
cloud = write_task_file(Path(tmp), "PLAN", "cloud", 5)
snapshot_cases = {
"numeric_snapshot_id": {
"snapshot_id": 5,
"targets": self._VALID_TARGETS,
},
"numeric_checked_at": {
"checked_at": 1690000000,
"targets": self._VALID_TARGETS,
},
"array_source": {
"source": ["usage-checker"],
"targets": self._VALID_TARGETS,
},
"empty_source": {
"source": "",
"targets": self._VALID_TARGETS,
},
}
for name, snapshot in snapshot_cases.items():
with self.subTest(case=name):
with self.assertRaises(selector.SelectorInputError) as ctx:
selector.select_execution_target(
cloud,
evaluated_at=kst(12),
quota_snapshot=snapshot,
)
self.assertEqual(
ctx.exception.code, "malformed_quota_snapshot"
)
# An empty probe command would emit an empty quota.source that the
# resume validator rejects, so it must fail before any success JSON.
with self.assertRaises(selector.SelectorInputError) as ctx:
selector.select_execution_target(
cloud, evaluated_at=kst(12), quota_probe_command=""
)
self.assertEqual(
ctx.exception.code, "invalid_quota_probe_command"
)
def test_valid_quota_initial_output_resumes(self):
snapshots = {
"no_snapshot": None,
"targets_only": {"targets": copy.deepcopy(self._VALID_TARGETS)},
"full_metadata": {
"snapshot_id": "snap-1",
"source": "usage-checker",
"checked_at": "2026-07-25T03:00:00+09:00",
"targets": copy.deepcopy(self._VALID_TARGETS),
},
}
with TemporaryDirectory() as tmp:
cloud = write_task_file(Path(tmp), "PLAN", "cloud", 5)
for name, snapshot in snapshots.items():
with self.subTest(case=name):
initial = selector.select_execution_target(
cloud, evaluated_at=kst(12), quota_snapshot=snapshot
)
# A daytime initial must resume verbatim at night without
# being rejected by its own prior-decision validator.
resumed = selector.select_execution_target(
cloud,
evaluated_at=kst(2),
transition="resume",
prior_decision=copy.deepcopy(initial),
)
self.assertEqual(resumed["selected"], initial["selected"])
self.assertEqual(resumed["quota"], initial["quota"])
self.assertIs(resumed["decision"]["pinned"], True)
def test_cli_malformed_identity_and_quota_metadata_use_json_error_envelope(
self,
):
with TemporaryDirectory() as tmp:
root = Path(tmp)
task_file = write_task_file(root, "PLAN", "cloud", 5)
prior = selector.select_execution_target(
task_file, evaluated_at=kst(12)
)
prior["stage"] = [] # unhashable identity type
prior_path = root / "prior.json"
prior_path.write_text(json.dumps(prior), encoding="utf-8")
snapshot_path = root / "quota.json"
snapshot_path.write_text(
json.dumps(
{
"snapshot_id": 5,
"targets": [
{
"adapter": "claude",
"target": "sonnet",
"status": "available",
}
],
}
),
encoding="utf-8",
)
cases = [
(
[
"--transition",
"resume",
"--prior-decision",
str(prior_path),
],
"malformed_prior_decision",
),
(
["--quota-snapshot", str(snapshot_path)],
"malformed_quota_snapshot",
),
(
["--quota-probe-command", ""],
"invalid_quota_probe_command",
),
]
for extra, code in cases:
with self.subTest(error=code):
proc = subprocess.run(
[
sys.executable,
str(SCRIPT),
str(task_file),
"--evaluated-at",
"2026-07-25T12:00:00+09:00",
*extra,
],
capture_output=True,
text=True,
)
self.assertEqual(proc.returncode, 2)
self.assertEqual(proc.stdout, "")
self.assertEqual(json.loads(proc.stderr)["error"], code)
if __name__ == "__main__":
unittest.main()

View file

@ -44,14 +44,29 @@ OpenAI-compatible output validation, incomplete tool-call syntax, missing tool-c
의미 판정과 stream event mechanics를 분리해 재사용 가능한 release barrier를 제공한다.
- [x] [event-contract] codec이 전달하는 normalized response-start/text/reasoning/tool-call/terminal/error event, event kind별 `hold|release_candidate|terminal_success_candidate|terminal_error_candidate` base disposition, immutable `EvidenceBatch`, filter decision과 optional intent를 정의한다. terminal failure는 raw 값을 포함하지 않는 최대 4단계 `FailureCauseChain`과 endpoint host가 한 번만 직렬화할 `TerminalResult`로 수렴한다. unmatched provider error는 terminal-error candidate이며 filter pass로 content처럼 release되지 않는다. Core는 raw parser나 caller 제품명을 해석하지 않는다. 검증: OpenAI/agent-family codec이 staged response-start, success terminal, matched/unmatched error의 같은 event/base-disposition table과 보조 recovery 실패의 bounded cause/single external error fixture를 통과한다.
- [ ] [evidence-tail] `rolling_window`는 pending tail에 기본 500 Unicode rune의 evidence가 쌓이거나 terminal event가 올 때 filter를 평가하고 통과한 safe prefix만 release한다. 같은 effective rune 수의 committed look-behind만 남겨 경계 반복을 판정하며 evidence 충족 외의 시간 기반 대기 조건을 더하지 않는다. `terminal_gate`는 schema처럼 terminal 전체가 필요한 channel만 명시적 `max_buffer_runes`까지 보류하고 초과 시 fail-closed하며, `fragment_gate`는 완성 전 tool fragment만 bounded hold한다. look-behind/pending은 filter 입력에만 쓰고 observation/log에 복제하지 않는다. replace-attempt recovery에서는 초기화하고 continuation recovery에서는 committed look-behind와 release cursor를 보존한다. 검증: 200/500-rune rolling, terminal-gate hard-limit, fragment bound, UTF-8 한국어 경계 반복, replace/continue reset 차이, terminal-before-evidence fixture가 통과한다.
- [ ] [commit-boundary] provider response status/header와 normalized opening role/event를 staging하고 첫 safe release 때 한 번만 commit한다. `transport_uncommitted`에서는 exact/schema replay가 가능하고, `stream_open` 뒤에는 이미 보낸 event를 rollback/exact replay하지 않는다. 다만 `continuation_repair`와 protocol-safe terminal replacement는 released safe prefix를 보존하고 terminal/tool side effect 전 같은 downstream stream을 이어갈 수 있다. evidence 미충족 idle timeout은 pending을 보내지 않고 pre-commit HTTP error 또는 post-commit terminal SSE/error로 끝낸다. 검증: response-start 뒤 body 전 matched 500 replay, eager role/header 금지, post-open exact replay 금지, post-open continuation, idle no-release, single terminal fixture가 통과한다.
- [ ] [filter-registry] Go 상속 대신 `Filter` interface와 shared helper struct를 두고 stable filter id, applicability, hold mode/bound, context-aware evaluation, sanitized evidence를 공통 계약으로 제공한다. request 시작 시 config/registry generation을 immutable snapshot으로 고정하고, admission 전 필수 filter capability로 후보를 제한한 뒤 실제 model/provider가 정해진 각 attempt에서 active set을 다시 resolve한다. enforcement는 `blocking|observe_only`로 두고 blocking error/deadline은 fail-closed, observe-only error/deadline은 명시적 `observe_error`로 정규화한다. duplicate stable id와 filter가 runtime에 enablement/failure mode를 바꾸는 동작을 거부한다. 검증: model/provider on/off, policy precedence, config reload 격리, required capability no-candidate, provider 전환 re-resolution, duplicate id/failure mode fixture가 통과한다.
- [x] [evidence-tail] `rolling_window`는 pending tail에 기본 500 Unicode rune의 evidence가 쌓이거나 terminal event가 올 때 filter를 평가하고 통과한 safe prefix만 release한다. 같은 effective rune 수의 committed look-behind만 남겨 경계 반복을 판정하며 evidence 충족 외의 시간 기반 대기 조건을 더하지 않는다. `terminal_gate`는 schema처럼 terminal 전체가 필요한 channel만 명시적 `max_buffer_runes`까지 보류하고 초과 시 fail-closed하며, `fragment_gate`는 완성 전 tool fragment만 bounded hold한다. look-behind/pending은 filter 입력에만 쓰고 observation/log에 복제하지 않는다. replace-attempt recovery에서는 초기화하고 continuation recovery에서는 committed look-behind와 release cursor를 보존한다. 검증: 200/500-rune rolling, terminal-gate hard-limit, fragment bound, UTF-8 한국어 경계 반복, replace/continue reset 차이, terminal-before-evidence fixture가 통과한다.
- [x] [commit-boundary] provider response status/header와 normalized opening role/event를 staging하고 첫 safe release 때 한 번만 commit한다. `transport_uncommitted`에서는 exact/schema replay가 가능하고, `stream_open` 뒤에는 이미 보낸 event를 rollback/exact replay하지 않는다. 다만 `continuation_repair`와 protocol-safe terminal replacement는 released safe prefix를 보존하고 terminal/tool side effect 전 같은 downstream stream을 이어갈 수 있다. evidence 미충족 idle timeout은 pending을 보내지 않고 pre-commit HTTP error 또는 post-commit terminal SSE/error로 끝낸다. 검증: response-start 뒤 body 전 matched 500 replay, eager role/header 금지, post-open exact replay 금지, post-open continuation, idle no-release, single terminal fixture가 통과한다.
- [x] [filter-registry] Go 상속 대신 `Filter` interface와 shared helper struct를 두고 stable filter id, applicability, hold mode/bound, context-aware evaluation, sanitized evidence를 공통 계약으로 제공한다. request 시작 시 config/registry generation을 immutable snapshot으로 고정하고, admission 전 필수 filter capability로 후보를 제한한 뒤 실제 model/provider가 정해진 각 attempt에서 active set을 다시 resolve한다. enforcement는 `blocking|observe_only`로 두고 blocking error/deadline은 fail-closed, observe-only error/deadline은 명시적 `observe_error`로 정규화한다. duplicate stable id와 filter가 runtime에 enablement/failure mode를 바꾸는 동작을 거부한다. 검증: model/provider on/off, policy precedence, config reload 격리, required capability no-candidate, provider 전환 re-resolution, duplicate id/failure mode fixture가 통과한다.
- [x] [parallel-evaluation] Gate Coordinator가 immutable `EvidenceBatch`를 event-applicable/trigger-ready filter에 동시에 전달한다. subscribed event가 없는 filter는 `not_applicable_for_epoch`, release를 막는 static trigger 미충족 filter는 `deferred_by_requirement`로 Core가 정규화한다. 모든 active filter가 evaluated/error/not-applicable/deferred outcome 중 하나를 가진 뒤 Arbiter를 호출한다. epoch는 single-flight이고 다음 ingress는 bounded backpressure를 받으며 caller cancel/host shutdown만 no-release로 중단한다. 검증: event-only/not-applicable와 blocking deferred 구분, barrier, 역순 완료, race, ingress/backpressure, cancel/deadline/failure fixture가 통과한다.
### Epic: [decision-policy] 결정 합성과 Policy 연결
완료된 stream mechanics 위에서 filter outcome을 단일 action으로 합성하고 semantic consumer의 hold·intent policy를 공통 계약에 연결한다.
- [ ] [decision-arbiter] normalized event의 base disposition과 모든 active filter outcome을 deterministic하게 합성한다. fatal은 terminal, eligible recovery는 terminal-error/release candidate를 대체할 수 있고 conflict는 priority/stable id로 하나만 선택한다. blocking deferred는 bounded hold, not-applicable은 release를 막지 않는다. unmatched provider error와 invalid terminal은 filter pass여도 base terminal을 유지한다. 검증: release+pass, hold+not-applicable, pass+blocking-deferred, unmatched error+pass, matched error+recovery, deferred+violation, fatal+retry와 replacement conflict가 single action을 반환한다.
- [ ] [policy-hook] repeat, malformed syntax, missing tool-call, schema, terminal integrity, workflow replacement는 `rolling_window|terminal_gate|fragment_gate`와 대상 channel/hard bound를, provider-error 같은 hold 없는 filter는 `none`과 subscribed event kinds를 등록한다. 모두 enforcement, semantic detector와 optional intent를 독립 policy로 제공하고 Core는 channel별 requirement를 합성한다. 검증: rolling/schema/tool fragment/provider-error event-only mixed composition과 decision/intent 표가 통과한다.
### Epic: [recovery-runtime] Recovery Coordination과 Request Rebuild
선택된 intent를 bounded recovery plan으로 만들고 current attempt 종료부터 lossless request rebuild와 단일 재admission까지의 실행 소유권을 공통화한다.
- [ ] [recovery-coordinator] Arbiter가 선택한 하나의 `RecoveryIntent`를 strategy별 budget, fault recovery의 request 전체 `max_recovery_attempts_total` hard cap, caller cancel, terminal/tool side effect, strategy별 commit eligibility, same-plan re-entry guard로 검증해 `exact_replay|continuation_repair|schema_repair|managed_continuation` 중 하나의 `RecoveryPlan`으로 만든다. fault recovery 전체 cap은 최초 provider 실행을 제외한 exact/schema/provider-error/일반 repair dispatch를 합산하며 기본값이자 MVP 절대 상한은 3회다. provider output-cap terminal의 managed continuation은 오류 recovery가 아니라 progress trajectory로 분류하고, request 시작 시 고정한 attempt cap·context window·reserve·consumer가 고정한 optional caller logical output cap과 Rebuilder가 조립 뒤 측정한 `rebuilt_prompt_tokens`의 다음 allowance가 남을 때만 별도 `ManagedTrajectoryBudget`으로 dispatch한다. assistant prefix는 rebuilt prompt에 포함되므로 누적 output과 이중 계상하지 않는다. fault cap은 strategy cap보다 우선하고 어느 cap이든 소진되면 다른 fault filter/strategy로 우회하지 않고 terminal로 끝낸다. exact/schema는 `transport_uncommitted`에서만 replace-attempt로 실행하고, continuation은 `stream_open`에서도 released safe prefix/cursor를 보존한 continue-stream plan으로 실행할 수 있다. plan 선택 시 Core는 preparer/continuation에 필요한 bounded request-local snapshot과 cursor를 immutable하게 고정한다. 새 dispatch 전에 현재 `AttemptController`의 provider transport ownership만 idempotent cancel/close하고 실패하면 두 provider를 병행하지 않고 terminal로 끝낸다. 선택된 plan이 consumer별 보조 준비를 요구하면 Core는 all-complete/Arbiter와 attempt ownership 종료 뒤 optional `RecoveryPlanPreparer`를 plan/idempotency key당 최대 1회, bounded deadline으로 호출한다. preparer는 Filter 병렬 평가에 참여하거나 provider 선택/retry/fallback을 실행하지 않으며 실패는 dispatch 없이 terminal로 끝난다. recovery budget은 preparer 호출이 아니라 실제 outbound recovery dispatch 직전에만 소비하고 bounded preparation snapshot은 성공·실패·cancel 뒤 release한다. cycle당 plan/dispatch는 하나지만 두 cap에 여유가 있으면 다음 attempt 결과를 새 cycle로 평가한다. 검증: 동시 violation, pre/post-commit strategy matrix, 0/1/3회 policy와 4회 이상 config rejection, strategy cap과 request 전체 cap의 교차 소진, bounded multi-cycle, snapshot-freeze/transport-only abort, abort-before-prepare/dispatch, preparer 단일 호출/deadline/failure-no-budget/snapshot-release, cancel/side-effect/re-entry fixture가 통과한다.
- [ ] [request-rebuilder] transport-agnostic Coordinator가 기본값이자 MVP 절대 상한 16 MiB이며 request 시작 시 immutable하게 고정되는 `max_ingress_snapshot_bytes` 안의 request-local lossless ingress snapshot과 selected directive를 endpoint/family별 `RequestRebuilder`에 전달하고, host `AttemptDispatcher`가 새 admission과 concrete provider/model/auth 계산을 수행한다. host는 `io.ReadAll` 전에 `http.MaxBytesReader` 또는 동등한 limit+1 판정으로 raw body를 제한하며 policy는 16 MiB 이하로만 낮출 수 있다. OpenAI JSON endpoint는 raw body를 canonical lossless representation으로 보존하고, 다른 lossless tree는 byte-preserving round-trip fixture가 있는 codec만 허용한다. `retained_bytes`는 runtime heap 추정치가 아니라 request가 소유한 backing byte/string buffer의 보수적 logical byte 합이며 shared backing은 한 번만 센다. SnapshotBuilder는 필요한 bounded typed semantic view를 더한 직후, RequestRebuilder는 임시 output을 할당하기 전과 후에 current peak를 다시 검사하고 canonical full copy를 둘 이상 유지하지 않는다. raw body가 pre-read gate와 같아도 typed view를 포함한 total retained limit을 넘으면 dispatch 전에 거부한다. ingress limit 초과는 snapshot 생성/provider dispatch 전에 fail-closed하며 filter/로그/cross-request cache에 원문을 노출하지 않고 auth도 저장하지 않는다. rebuilt request도 같은 limit을 다시 확인하고 request 종료/cancel/overflow에서 retained object를 release한다. dispatcher는 actual model/provider/execution path와 normalized event source를 가진 `AttemptBinding`을 반환하며 normalized↔tunnel path 전환도 같은 Core cycle에 연결한다. 검증: caller raw body/unknown field 보존, continuation/schema typed patch, Chat/Responses serializer, auth 비보존, provider/path 전환, pre-read raw body limit-1/limit/limit+1, exact-limit body+typed-view overflow, 16 MiB 초과 config rejection, shared-backing logical peak accounting과 rebuild 전후 overflow/no-dispatch/no-raw-log/release fixture가 통과한다.
### Epic: [integration-adoption] Vertical Slice와 Consumer Adoption
공통 mechanics를 실제 host cycle로 연결하고 raw-free 관측과 후속 consumer 문서 계약까지 닫아 중복 구현을 방지한다.
- [ ] [vertical-slice] production semantic filter보다 먼저 model/provider별 on/off 가능한 `NoopFilter`와 test-only `InjectedViolationFilter``request snapshot → host dispatch → staged response-start → Core evidence hold → parallel filters → all-complete Arbiter → RecoveryPlan → abort → optional prepare → rebuild → single re-admission → release` 한 사이클을 얇게 완성한다. 검증: disabled passthrough, enabled pass, eager header/role 없음, injected violation one retry success, optional preparer success/failure, two violation single retry, provider/path switch, filter failure mode, backpressure, retry exhausted terminal fixture가 통과한다.
- [ ] [filter-observation] Core가 filter/coordinator decision마다 기존 request/run/provider/model correlation과 config generation, attempt/epoch, `consumer_id`, `filter_id`, `rule_id`, hold mode, event kind, effective/pending rune, decision/error policy, commit state, terminal reason, plan id/strategy/shared attempt, preparer id/status/deadline outcome, 최대 4개의 sanitized cause code와 consumer-provided sanitized evidence를 하나의 `FilterObservation` timeline event로 emit한다. 저장·보존은 기존 observability sink가 소유한다. preparer input/output와 raw output/prompt/tool args/result/auth 값은 허용하지 않으며 filter가 평가되지 않은 delta마다 observation을 만들지 않는다. 검증: 병렬 filter 시작/완료, response staging, arbitration, release/terminal/recovery/abort/prepare/rebuild/dispatch가 하나의 request timeline에서 stable id, bounded cause와 sanitized fingerprint/count/offset만으로 추적된다.
- [ ] [adoption-doc] [OpenAI-compatible 출력 검증 필터](openai-compatible-output-validation-filters.md), [OpenAI-compatible Incomplete Tool Call Syntax Gate](openai-compatible-incomplete-tool-call-syntax-gate.md), [OpenAI-compatible Runtime Output Integrity Filter](openai-compatible-runtime-output-integrity-filter.md), [LLM 판별 기반 Missing Tool Call 재시도 Gate](llm-judged-missing-tool-call-retry-gate.md), [에이전트 작업 루프 오케스트레이션 MVP](../../automation-runtime-bridge/milestones/agent-workflow-loop-orchestration-mvp.md)에 이 Core의 소비 경계, 각자 남는 semantic policy/intent, stable filter id와 `FilterObservation` mapping, 공통 RecoveryPlan 및 bounded `FailureCauseChain`/endpoint별 단일 외부 오류 직렬화 사용을 기록한다. recovery consumer는 strategy/request-total cap을, ingress snapshot consumer는 bounded snapshot을 사용한다. 검증: 각 Milestone이 자신에게 적용되는 Core interface와 limit을 명시하고 raw stream buffer·request snapshot/rebuild·retry loop·공개 오류 사슬 직렬화를 재구현하지 않는다.

View file

@ -0,0 +1,181 @@
<!-- task=m-agent-task-runtime-target-selector/01_selector_contract plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked, record the exact blocker, attempted commands/output, and resume condition only in implementation-owned evidence fields.
> Do not ask the user directly, present choices, call user-input tools, create control-plane stop files, or classify the next state.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-07-25
task=m-agent-task-runtime-target-selector/01_selector_contract, plan=0, tag=API
## 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:
- `selector-contract`: task file 기반 selector JSON 계약
- `time-route`: KST 주야간 경계 route
- `grade-route`: worker/review G01~G10 route matrix
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G09.md` → `code_review_cloud_G09_0.log`, `PLAN-cloud-G07.md` → `plan_cloud_G07_0.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-agent-task-runtime-target-selector/01_selector_contract/`로 이동한다. WARN/FAIL이면 code-review skill이 요구하는 다음 filesystem state를 완전히 작성한다.
4. PASS이고 task group이 `m-agent-task-runtime-target-selector`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| API-1 Selector CLI와 안정적인 JSON schema | [x] |
| API-2 Selector 표면의 시간/grade matrix evidence | [x] |
## 구현 체크리스트
- [x] task filename, PLAN header와 입력 옵션을 검증하고 SDD 출력 필드를 안정적인 JSON으로 반환하는 selector CLI를 구현한다.
- [x] 동일 입력 결정성, invalid input, resume pin, KST 네 경계와 worker/review 전체 grade matrix를 selector CLI 수준에서 검증한다.
- [x] `python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_execution_target*.py'`를 실행해 전체 selector 정책/계약 테스트가 통과함을 확인한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G09_0.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_0.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/`를 `agent-task/archive/YYYY/MM/m-agent-task-runtime-target-selector/01_selector_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-agent-task-runtime-target-selector`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-agent-task-runtime-target-selector/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이면 code-review skill의 판정에 맞는 다음 filesystem state를 작성하고 `complete.log`를 작성하지 않는다.
## 계획 대비 변경 사항
- 최종/API-2 검증 glob을 `test_execution_target*.py` → `test_*execution_target*.py`로 넓혔다. 원래 pattern은 시작 앵커라 `test_select_execution_target.py`를 발견하지 못하고 순수 policy 5개만 실행되어 S01~S03 selector 테스트가 최종 검증에서 조용히 스킵된다. `*` 하나를 추가하면 `test_execution_target_policy.py`와 `test_select_execution_target.py`를 모두 발견해(정책 5 + selector 17 = 22개) PLAN "최종 검증 기대 결과"가 실제로 충족된다. `test_dispatch.py`는 계속 매칭되지 않는다. 아래 `검증 결과` API-2 섹션의 명령을 이 pattern으로 갱신했다. 파일명은 PLAN 수정 파일 표의 `test_select_execution_target.py` 그대로 유지했다.
- `execution_target_policy.py`는 수정하지 않았다. PLAN API-1은 "필요할 때만 JSON serialization용 명시 helper 추가"로 조건부였고, selector가 `RouteTarget`/`PolicyDecision` dataclass 필드(`adapter`, `target`, `execution_class`, `selfcheck_required`, `rule_id` 등)를 직접 읽어 JSON을 구성하므로 helper가 불필요했다. 정책 matrix는 그대로 소비만 한다.
- `select_execution_target()`의 `quota_probe_command`에 기본값(`DEFAULT_QUOTA_PROBE_COMMAND`)을 부여했다. PLAN signature는 필수 인자로 표기했으나 CLI/테스트 호출 편의를 위한 상위호환 기본값이며 계약 필드에는 영향이 없다.
## 주요 설계 결정
- `work_unit_id`는 파싱한 `task/plan/tag` 세대 튜플로만 파생한다(`{task}::plan-{plan}::tag-{tag}`). 첫 1KiB 헤더 주석만 검증하므로 본문 수정에는 불변이고 plan/tag 세대 변경에만 새 identity가 된다(SDD Interface Contract, S05 pin 전제).
- transition별 책임을 분리했다. `initial`만 순수 `select_policy`로 계산하고, `resume`은 동일 work unit의 prior selected target을 시간·본문 변화와 무관하게 `pinned:true`로 재방출하며(prior와 work_unit_id/stage/lane/grade 불일치는 입력 오류), `failover`는 target 전환을 하지 않고 `unsupported_transition` 입력 오류로 남겨 다음 Epic 소유 경계를 고정했다.
- quota는 이 task에서 표현만 한다. cloud candidate는 `bounded`, snapshot 없으면 `unknown`, local candidate는 `unbounded/not_applicable`로 반환한다. 실제 Go usage checker 호출과 required-cap tri-state 정규화는 `02+01_quota_input`이 소유하므로, 여기서는 이미 정규화된 snapshot을 주입받아 반영하는 seam(`source`에 probe 명령 자리 고정)만 둔다. `eligibility`는 status 표현의 파생 라벨일 뿐, quota 기반 재선택/admission은 구현하지 않았다(`selected`는 항상 정책 candidates[0]).
- 오류는 `SelectorInputError`(code 포함)로 던지고 CLI가 stderr JSON + exit 2로 변환한다. 성공 경로에서만 stdout에 정렬된 byte-stable JSON(`sort_keys`, `indent=2`)을 쓴다. 오류 시 stdout에 부분 decision을 남기지 않는다.
- 정책 모듈은 테스트와 동일하게 `importlib`로 sibling 로드해 scripts 디렉터리의 package/`sys.path` 가정 없이 재사용한다.
## 리뷰어를 위한 체크포인트
- task filename과 header가 충돌하거나 malformed이면 stdout decision 없이 명시 오류로 종료하는가
- 동일 `task/plan/tag`, fixed clock, quota/prior input이 byte-stable JSON을 반환하는가
- resume이 시간·body 변경으로 target을 바꾸지 않고 prior selected target을 pinned 상태로 유지하는가
- worker/review G01~G10 matrix와 KST 네 경계가 SDD S02/S03과 정확히 일치하는가
- generic stderr 추정, dispatcher persisted state, failover 구현이 범위에 섞이지 않았는가
## 검증 결과
### API-1 중간 검증
명령:
```bash
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_select_execution_target.py'
```
실제 stdout/stderr:
```
.................
----------------------------------------------------------------------
Ran 17 tests in 0.117s
OK
```
### API-2 및 최종 검증
명령(`계획 대비 변경 사항` 참고: glob을 `test_execution_target*.py` → `test_*execution_target*.py`로 넓혀 selector 테스트가 실제로 발견되게 함):
```bash
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*execution_target*.py'
```
실제 stdout/stderr:
```
......................
----------------------------------------------------------------------
Ran 22 tests in 0.125s
OK
```
참고 — 원래 PLAN glob `test_execution_target*.py`는 시작 앵커라 `test_execution_target_policy.py`만 발견한다:
```
.....
----------------------------------------------------------------------
Ran 5 tests in 0.001s
OK
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) |
| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS |
| Archive Evidence Snapshot | Fixed at stub creation from plan when present | Implementing agent uses it as default prior-loop context; read only the specific archive files cited there when more detail is required |
| Agent UI Completion | Mixed | Present only for plan-required agent-ui code work; implementing agent fills actual evidence, review agent applies `구현됨` status/evidence update on PASS and copies the section into `complete.log` |
| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` → `[x]` only |
| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` → `[x]` only |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry |
| 코드리뷰 결과 | Review agent appends | Not included in stub |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Fail
- code quality: Pass
- implementation deviation: Fail
- verification trust: Pass
- spec conformance: Fail
- 발견된 문제:
- Required — `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py:100`, `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py:263`: JSON 입력의 중첩 schema와 타입을 검증하지 않는다. `prior_decision.selected`에서 `execution_class`와 `selfcheck_required`를 제거해도 resume이 성공해 불완전한 `selected` schema를 반환하고, `prior_decision.decision=1` 또는 배열형 `quota_snapshot`은 계약된 stderr JSON/exit 2 대신 traceback/exit 1을 낸다. PLAN의 입력 옵션 검증·안정적 JSON 및 SDD S01의 완전한 JSON/명시적 입력 오류 기준을 충족하려면 prior decision과 quota snapshot의 필수 중첩 필드·타입·허용값을 명시적으로 검증해 모든 위반을 `SelectorInputError`로 정규화하고, direct API와 CLI 회귀 테스트를 추가해야 한다.
- 다음 단계:
- WARN/FAIL follow-up: 동일 task 경로에서 plan 스킬의 `prepare-follow-up` 및 `isolated-reassessment` 라우팅으로 최소 수정 계획을 생성한다.

View file

@ -0,0 +1,188 @@
<!-- task=m-agent-task-runtime-target-selector/01_selector_contract plan=1 tag=REVIEW_API -->
# Code Review Reference - REVIEW_API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked, record the exact blocker, attempted commands/output, and resume condition only in implementation-owned evidence fields.
> Do not ask the user directly, present choices, call user-input tools, create control-plane stop files, or classify the next state.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-07-25
task=m-agent-task-runtime-target-selector/01_selector_contract, plan=1, tag=REVIEW_API
## 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:
- `selector-contract`: task file 기반 selector JSON 계약
- `time-route`: KST 주야간 경계 route
- `grade-route`: worker/review G01~G10 route matrix
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Prior task: `agent-task/m-agent-task-runtime-target-selector/01_selector_contract`
- Archived plan: `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/plan_cloud_G07_0.log`
- Archived review: `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/code_review_cloud_G09_0.log`
- Verdict: `FAIL`
- Findings: Required 1, Suggested 0, Nit 0
- Required: malformed `prior_decision`/`quota_snapshot` 중첩 형식이 불완전한 성공 schema 또는 traceback/exit 1을 만든다.
- Affected files: `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`
- Verification evidence: 기존 selector 17개와 selector/policy 통합 22개는 PASS했다. 집중 재현은 incomplete selected가 `adapter,target`만 가진 채 성공하고 malformed decision 및 배열 quota가 traceback/exit 1을 내는 것을 확인했다.
- Roadmap carryover: `selector-contract`, `time-route`, `grade-route`; SDD S01의 완전한 JSON/명시적 입력 오류가 현재 차단 항목이다.
- 필요한 경우 위 두 archived log만 prior-loop 상세 근거로 읽는다. `agent-task/archive/**`를 탐색하지 않는다.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G09.md` → `code_review_cloud_G09_1.log`, `PLAN-cloud-G07.md` → `plan_cloud_G07_1.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-agent-task-runtime-target-selector/01_selector_contract/`로 이동한다. WARN/FAIL이면 code-review skill이 요구하는 다음 filesystem state를 완전히 작성한다.
4. PASS이고 task group이 `m-agent-task-runtime-target-selector`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| REVIEW_API-1 Prior/quota 중첩 JSON schema 검증 | [x] |
## 구현 체크리스트
- [x] prior decision과 quota snapshot의 필수 중첩 필드·타입·허용값을 검증하고 모든 위반을 `SelectorInputError`로 정규화하며, direct API와 CLI 회귀 테스트가 focused selector suite에서 통과하도록 한다.
- [x] `python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*execution_target*.py'`와 대상 파일 `py_compile`을 실행해 S01~S03 전체 회귀가 통과함을 확인한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G09_1.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_1.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/`를 `agent-task/archive/YYYY/MM/m-agent-task-runtime-target-selector/01_selector_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-agent-task-runtime-target-selector`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-agent-task-runtime-target-selector/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이면 code-review skill의 판정에 맞는 다음 filesystem state를 작성하고 `complete.log`를 작성하지 않는다.
## 계획 대비 변경 사항
- 계획의 함수 시그니처(`_validate_prior_decision`, `_validate_quota_snapshot`), 검증 범위, 중간·최종 검증 명령을 그대로 따랐다. 명령은 변경하지 않았다.
- 계획의 `예상 형태`에 없던 내부 helper `_validate_prior_selected`를 추가해 `selected` 하위 스키마 검증을 분리했다. 가독성을 위한 내부 분해이며 입력 계약, 오류 code, 동작, 실행 명령에는 변화가 없다.
## 주요 설계 결정
- `prior_decision`과 `quota_snapshot`을 각각 `_validate_prior_decision`/`_validate_quota_snapshot`로 검증하고 모든 위반을 `SelectorInputError`(`malformed_prior_decision` / `malformed_quota_snapshot`)로 정규화했다. CLI는 기존 `main()` 처리로 stdout 없이 stderr JSON + exit 2를 유지한다.
- `prior_decision`은 `schema_version`(현재 `1.0`), `work_unit_id`/`stage`/`lane`/`grade` identity, `selected`/`candidates`/`decision`/`quota`의 container·type·enum을 검증한다. resume 성공 출력 schema를 initial과 동일하게 유지하기 위해 `selected.execution_class`와 `selected.selfcheck_required`를 필수로 강제했다. 미래 호환용 추가 key는 허용한다.
- `quota_snapshot`은 object 여부, `targets` list, 각 target entry의 `adapter`/`target`/`status` 문자열 형식만 검증했다. required-cap tri-state 정규화와 admission은 `02+01_quota_input` 소유로 남겼고, status 값 매핑은 기존 `_snapshot_status`의 graceful normalization을 그대로 유지했다.
- identity(work_unit) 불일치는 스키마 검증 이후 기존 `resume_work_unit_mismatch` 경로로 유지해, malformed schema는 `malformed_prior_decision`, 값 불일치는 `resume_work_unit_mismatch`로 분리되게 했다.
- `quota_snapshot` 검증은 `select_execution_target` 진입부에서 1회 수행해 initial 경로가 `_snapshot_status`에서 non-object를 dereference하지 않도록 했다.
## 리뷰어를 위한 체크포인트
- malformed prior/quota nested JSON이 direct API에서는 `SelectorInputError`, CLI에서는 stdout 없이 stderr JSON/exit 2로 끝나는가
- resume 성공 출력의 `selected`, `decision`, `candidates`, `quota` schema가 initial과 같은 필수 필드·타입을 유지하는가
- 미래 호환용 추가 key는 허용하면서 필수 필드 누락·잘못된 container/enum만 명시적으로 거부하는가
- 기존 initial/resume, KST 네 경계와 worker/review G01~G10 matrix가 변하지 않았는가
- quota required-cap 정규화, admission, target 재선택 또는 dispatcher 변경이 범위에 섞이지 않았는가
## 검증 결과
### REVIEW_API-1 중간 검증
명령:
```bash
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_select_execution_target.py'
```
실제 stdout/stderr:
```
....................
----------------------------------------------------------------------
Ran 20 tests in 0.226s
OK
```
### 최종 검증
명령:
```bash
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*execution_target*.py'
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/tests/test_execution_target_policy.py \
agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py
```
실제 stdout/stderr:
```
.........................
----------------------------------------------------------------------
Ran 25 tests in 0.224s
OK
```
`py_compile`은 출력 없이 exit 0으로 종료했다 (4개 대상 파일 모두 compile 성공).
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) |
| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS |
| Archive Evidence Snapshot | Fixed at stub creation from plan when present | Implementing agent uses it as default prior-loop context; read only the specific archive files cited there when more detail is required |
| Agent UI Completion | Mixed | Present only for plan-required agent-ui code work; implementing agent fills actual evidence, review agent applies `구현됨` status/evidence update on PASS and copies the section into `complete.log` |
| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` → `[x]` only |
| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` → `[x]` only |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry |
| 코드리뷰 결과 | Review agent appends | Not included in stub |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Fail
- code quality: Pass
- implementation deviation: Fail
- verification trust: Pass
- spec conformance: Fail
- 발견된 문제:
- Required — `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py:175`: `prior_decision.candidates`, `decision`, `quota`는 각각 list/object인지까지만 검사하고 현재 schema의 필수 하위 field·type·enum을 검증하지 않은 채 resume 출력으로 재방출한다. 집중 재현에서 `candidates=[{}]`, `decision={}`, `quota={"mode":"bogus"}`가 모두 성공했고 결과가 각각 불완전한 candidate, `decision={"pinned":true}`, 잘못된 quota enum을 포함했다. 이는 PLAN REVIEW_API-1의 “현재 schema 필수 container·field·type·enum 검증”과 SDD S01의 완전한 JSON 계약을 위반한다. initial 출력 schema를 기준으로 candidate entry, decision, quota의 필수 하위 field·type·허용값을 검증해 모든 변형을 `malformed_prior_decision`으로 거부하고, direct API와 CLI 회귀 테스트를 추가해야 한다.
- 다음 단계:
- WARN/FAIL follow-up: 동일 task 경로에서 plan 스킬의 `prepare-follow-up` 및 `isolated-reassessment` 라우팅으로 최소 수정 계획을 생성한다.

View file

@ -0,0 +1,190 @@
<!-- task=m-agent-task-runtime-target-selector/01_selector_contract plan=2 tag=REVIEW_API -->
# Code Review Reference - REVIEW_API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked, record the exact blocker, attempted commands/output, and resume condition only in implementation-owned evidence fields.
> Do not ask the user directly, present choices, call user-input tools, create control-plane stop files, or classify the next state.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-07-25
task=m-agent-task-runtime-target-selector/01_selector_contract, plan=2, tag=REVIEW_API
## 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:
- `selector-contract`: task file 기반 selector JSON 계약
- `time-route`: KST 주야간 경계 route
- `grade-route`: worker/review G01~G10 route matrix
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Prior task: `agent-task/m-agent-task-runtime-target-selector/01_selector_contract`
- Archived plan: `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/plan_cloud_G07_1.log`
- Archived review: `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/code_review_cloud_G09_1.log`
- Verdict: `FAIL`
- Findings: Required 1, Suggested 0, Nit 0
- Required: `prior_decision.candidates`, `decision`, `quota`가 container 검사만 통과하면 불완전한 field/type/enum을 resume 성공 출력으로 재방출한다.
- Affected files: `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`
- Verification evidence: selector/policy 통합 25개와 대상 파일 `py_compile`은 PASS했다. 집중 재현은 `candidates=[{}]`, `decision={}`, `quota={"mode":"bogus"}`가 모두 성공하는 것을 확인했다.
- Roadmap carryover: `selector-contract`, `time-route`, `grade-route`; SDD S01의 완전한 selector JSON이 현재 차단 항목이다.
- 필요한 경우 위 두 archived log만 prior-loop 상세 근거로 읽는다. `agent-task/archive/**`를 탐색하지 않는다.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G09.md` → `code_review_cloud_G09_2.log`, `PLAN-cloud-G07.md` → `plan_cloud_G07_2.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-agent-task-runtime-target-selector/01_selector_contract/`로 이동한다. WARN/FAIL이면 code-review skill이 요구하는 다음 filesystem state를 완전히 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| REVIEW_API-1 Prior decision 전체 중첩 schema 검증 | [x] |
## 구현 체크리스트
- [x] prior decision의 `candidates`, `decision`, `quota` 필수 하위 field·type·enum을 initial 출력 schema 기준으로 검증하고 모든 위반을 `malformed_prior_decision`으로 정규화하며, direct API와 CLI 회귀 테스트가 focused selector suite에서 통과하도록 한다.
- [x] `python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*execution_target*.py'`와 대상 파일 `py_compile`을 실행해 S01~S03 전체 회귀가 통과함을 확인한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G09_2.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_2.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/`를 `agent-task/archive/YYYY/MM/m-agent-task-runtime-target-selector/01_selector_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-agent-task-runtime-target-selector/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이면 code-review skill의 판정에 맞는 다음 filesystem state를 작성하고 `complete.log`를 작성하지 않는다.
## 계획 대비 변경 사항
- 계획대로 구현했다. 파일 변경 범위, validator 분리, 테스트 구성 모두 PLAN과 일치한다.
- 계획의 validator 서명 스케치는 예시였고, 실제로는 반복되는 필드 검사를 줄이기 위해 `_require_non_empty_string`, `_require_string_enum`, `_require_nullable_string` 세 개의 얇은 헬퍼를 추가한 뒤 세 validator가 이를 호출하도록 구성했다. 계약 동작(계획된 필드·타입·enum과 `malformed_prior_decision` 정규화)은 그대로다.
## 주요 설계 결정
- validator를 `_validate_prior_candidates`, `_validate_prior_decision_evidence`, `_validate_prior_quota` 세 함수로 분리하고 기존 `_validate_prior_decision`의 container-only 검사(`candidates` list, `decision`/`quota` dict) 3개 블록을 이 호출로 대체했다. 각 validator가 자체 container 타입 검사를 선행하므로 중복 검사를 제거했다.
- enum 허용값은 `_initial` 출력이 실제로 생성하는 값만 모듈 상수로 고정했다. `_VALID_QUOTA_MODES={unbounded,bounded}`, `_VALID_QUOTA_STATUSES={not_applicable,available,exhausted,unknown}`, `_VALID_ELIGIBILITY={eligible,ineligible}`, `_VALID_REJECTION_REASONS={quota_exhausted}`, `_VALID_TIME_WINDOWS`(KST day/night/not_applicable). `execution_class`는 기존 `_VALID_EXECUTION_CLASSES`를 재사용했다.
- candidate `candidate_rank`는 리스트 인덱스와 대조해 1부터 연속인지(`rank != index + 1`) 검사하고, `bool`을 `int`로 오인하지 않도록 `isinstance(rank, bool)`를 먼저 배제했다. `policy_priority`도 같은 방식으로 `bool`을 제외한 정수만 허용한다.
- `_require_string_enum`은 값이 문자열인지 먼저 확인한 뒤 집합 멤버십을 검사한다. list 등 unhashable 입력이 `in` 검사에서 raw `TypeError`로 새지 않고 `malformed_prior_decision`으로 정규화되게 하기 위함이다.
- `snapshot_id`/`checked_at`는 nullable-string(None 또는 str, 키는 필수), `source`는 non-empty string으로 검사한다. `rule_id`/`reason_codes`는 자유 식별자 성격이라 enum이 아닌 타입(비어있지 않은 문자열, non-empty 문자열 리스트)만 검사해 정상 prior의 다양한 rule_id/reason_code가 거부되지 않게 했다.
- 미래 호환을 위해 추가 key는 그대로 허용하고, 현재 initial schema가 생성하는 필수 field·type·enum만 검사한다. quota required-cap tri-state 정규화, dispatcher persisted state, failover는 후속 subtask 소유라 손대지 않았다.
## 리뷰어를 위한 체크포인트
- 유효한 container 안의 candidate/decision/quota 필수 field 누락과 잘못된 type·enum이 모두 `malformed_prior_decision`으로 거부되는가
- 정상 initial/resume 출력은 동일 schema를 유지하고 미래 호환용 추가 key는 허용하는가
- direct API 오류와 CLI stdout 없음/stderr JSON/exit 2 envelope가 함께 검증되는가
- quota snapshot 정규화, dispatcher persisted state, failover 또는 route matrix 변경이 범위에 섞이지 않았는가
- S02 KST 네 경계와 S03 worker/review G01~G10 matrix 회귀가 유지되는가
## 검증 결과
### REVIEW_API-1 중간 검증
명령:
```bash
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_select_execution_target.py'
```
실제 stdout/stderr:
```
......................
----------------------------------------------------------------------
Ran 22 tests in 0.291s
OK
```
### 최종 검증
명령:
```bash
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*execution_target*.py'
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/tests/test_execution_target_policy.py \
agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py
```
실제 stdout/stderr:
```
...........................
----------------------------------------------------------------------
Ran 27 tests in 0.266s
OK
```
py_compile은 stdout/stderr 출력 없이 exit 0으로 종료했다(대상 4개 파일 모두 compile 성공).
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) |
| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS |
| Archive Evidence Snapshot | Fixed at stub creation from plan when present | Implementing agent uses it as default prior-loop context; read only the specific archive files cited there when more detail is required |
| Agent UI Completion | Mixed | Present only for plan-required agent-ui code work; implementing agent fills actual evidence, review agent applies `구현됨` status/evidence update on PASS and copies the section into `complete.log` |
| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` → `[x]` only |
| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` → `[x]` only |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry |
| 코드리뷰 결과 | Review agent appends | Not included in stub |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Fail
- code quality: Pass
- implementation deviation: Fail
- verification trust: Pass
- spec conformance: Fail
- 발견된 문제:
- Required — `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py:304`: `prior_decision.stage`와 `lane`을 문자열로 확인하기 전에 `set` membership을 수행한다. 집중 재현에서 `stage=[]`와 `lane={}`가 모두 `malformed_prior_decision`이 아니라 raw `TypeError: unhashable type` traceback과 exit 1로 종료됐다. 이는 PLAN의 “모든 field/type 위반을 `malformed_prior_decision`으로 정규화” 조건과 SDD S01의 명시적 invalid-input 오류 계약을 위반한다. 두 필드에 문자열 type guard를 먼저 적용하고 direct API 및 CLI JSON envelope 회귀를 추가해야 한다.
- Required — `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py:326`: quota snapshot validator가 `targets[]`만 검사하고, `snapshot_id`, `source`, `checked_at`은 `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py:405`에서 그대로 initial 출력에 복사한다. 집중 재현에서 숫자 `snapshot_id`/`checked_at`과 배열 `source`가 initial 성공 JSON을 만들었지만, 그 결과를 동일 work unit에서 resume하면 새 prior quota validator가 `malformed_prior_decision`으로 거부했다. 빈 `quota_probe_command`도 같은 잘못된 `source`를 만들 수 있다. selector가 자기 initial 출력을 resume하지 못하므로 SDD S01의 완전하고 안정적인 selector JSON 계약을 충족하지 않는다. snapshot metadata와 probe command를 initial 전에 계약된 nullable/non-empty string 형식으로 검증하고, initial→resume roundtrip 및 CLI 오류 envelope 회귀를 추가해야 한다.
- 다음 단계:
- WARN/FAIL follow-up: 동일 task 경로에서 plan 스킬의 `prepare-follow-up` 및 `isolated-reassessment` 라우팅으로 최소 수정 계획을 생성한다.

View file

@ -0,0 +1,192 @@
<!-- task=m-agent-task-runtime-target-selector/01_selector_contract plan=3 tag=REVIEW_API -->
# Code Review Reference - REVIEW_API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked, record the exact blocker, attempted commands/output, and resume condition only in implementation-owned evidence fields.
> Do not ask the user directly, present choices, call user-input tools, create control-plane stop files, or classify the next state.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-07-25
task=m-agent-task-runtime-target-selector/01_selector_contract, plan=3, tag=REVIEW_API
## 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:
- `selector-contract`: task file 기반 selector JSON 계약
- `time-route`: KST 주야간 경계 route
- `grade-route`: worker/review G01~G10 route matrix
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Prior task: `agent-task/m-agent-task-runtime-target-selector/01_selector_contract`
- Archived plan: `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/plan_cloud_G07_2.log`
- Archived review: `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/code_review_cloud_G09_2.log`
- Verdict: `FAIL`
- Findings: Required 2, Suggested 0, Nit 0
- Required:
- `prior_decision.stage=[]`와 `lane={}`가 `malformed_prior_decision` 대신 raw `TypeError` traceback/exit 1로 종료된다.
- 숫자 `quota_snapshot.snapshot_id`/`checked_at`, 배열 `source`, 빈 `quota_probe_command`가 resume 불가능한 initial 성공 JSON을 만들 수 있다.
- Affected files: `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`
- Verification evidence: selector/policy 통합 27개와 대상 파일 `py_compile`은 PASS했다. 집중 재현은 unhashable stage/lane의 exit 1과 잘못된 quota metadata의 initial 성공 후 `malformed_prior_decision` resume 거부를 확인했다.
- Roadmap carryover: `selector-contract`, `time-route`, `grade-route`; SDD S01의 완전한 selector JSON과 명시적 invalid-input 오류가 현재 차단 항목이다.
- 필요한 경우 위 두 archived log만 prior-loop 상세 근거로 읽는다. `agent-task/archive/**`를 탐색하지 않는다.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G09.md` → `code_review_cloud_G09_3.log`, `PLAN-cloud-G07.md` → `plan_cloud_G07_3.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-agent-task-runtime-target-selector/01_selector_contract/`로 이동한다. WARN/FAIL이면 code-review skill이 요구하는 다음 filesystem state를 완전히 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| REVIEW_API-1 Prior identity type과 quota roundtrip 검증 | [x] |
## 구현 체크리스트
- [x] prior decision의 `stage`/`lane` type과 quota snapshot metadata/probe command를 success JSON 생성 전에 검증해 모든 malformed JSON을 안정적인 `SelectorInputError`/CLI exit 2 envelope로 정규화하고, 유효 initial 결과가 그대로 resume되는 direct API·CLI 회귀를 추가한다.
- [x] `python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*execution_target*.py'`와 대상 파일 `py_compile`을 실행해 S01~S03 전체 회귀가 통과함을 확인한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G09_3.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_3.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/`를 `agent-task/archive/YYYY/MM/m-agent-task-runtime-target-selector/01_selector_contract/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-agent-task-runtime-target-selector/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이면 code-review skill의 판정에 맞는 다음 filesystem state를 작성하고 `complete.log`를 작성하지 않는다.
## 계획 대비 변경 사항
- 계획대로 구현했다. 파일·항목·에러 경계(`malformed_prior_decision`, `malformed_quota_snapshot`, probe-command 전용 오류) 모두 PLAN REVIEW_API-1과 일치한다.
- probe-command 전용 오류 코드는 계획이 이름을 명시하지 않아 `invalid_quota_probe_command`로 정했다. 계획의 "명시적 probe-command 입력 오류" 요구를 충족한다.
- probe-command 검증은 `initial`/`resume` 분기 이전 `select_execution_target` 진입부에 한 번 두어 두 transition 모두에 동일한 입력 계약을 적용했다. 기본값과 CLI 기본 인자는 non-empty라 기존 경로에 영향이 없다.
- 신규 회귀는 계획의 4개 테스트 이름을 그대로 사용해 전용 테스트 클래스 `SelectorIdentityAndQuotaRoundtripTests`로 묶었다. prior identity·quota metadata·probe·roundtrip이 하나의 S01 불변조건을 닫는다는 계획의 분할 판단을 그대로 반영했다.
## 주요 설계 결정
- prior `stage`/`lane`은 enum 멤버십(`in policy.VALID_STAGES/VALID_LANES`) 전에 `isinstance(str)`을 먼저 검사한다. list/dict 등 unhashable JSON type이 set membership에서 raw `TypeError`/exit 1로 새지 않고 `malformed_prior_decision`으로 정규화된다.
- quota snapshot의 top-level `snapshot_id`/`checked_at`은 존재하고 `None`이 아닐 때만 `str`을 요구하는 nullable 검증으로, 누락 시 기존 `None` fallback을 유지한다. `source`는 존재할 때만 non-empty string으로 검증해 배열/빈 문자열을 차단한다. 이 검증은 `targets[]` 검사 이전, success JSON 생성 이전에 수행한다.
- `quota_probe_command`는 non-empty string으로 검증한다. snapshot이 없거나 `source`가 생략된 cloud 출력에서 `quota.source`로 반영되므로, resume validator의 non-empty `source` 요구를 initial 출력이 스스로 위반하지 않도록 입력 경계에서 닫는다.
- 범위를 입력 경계 정규화에 한정했다. required-cap tri-state/admission, dispatcher persisted state/failover, 시간·grade route matrix, 추가 key 허용과 target status의 graceful `unknown` 정규화는 건드리지 않았다.
## 리뷰어를 위한 체크포인트
- unhashable `prior_decision.stage`/`lane`이 direct API에서는 `malformed_prior_decision`, CLI에서는 stdout 없이 JSON stderr/exit 2로 끝나는가
- malformed quota `snapshot_id`/`source`/`checked_at`과 빈 probe command가 success JSON을 만들지 않는가
- metadata가 생략되거나 유효한 quota snapshot의 initial 결과는 동일 work unit에서 그대로 resume되는가
- 미래 호환 추가 key와 quota target status의 graceful `unknown` 정규화는 유지되는가
- required-cap tri-state/admission, dispatcher persisted state/failover와 route matrix 변경이 범위에 섞이지 않았는가
- S02 KST 네 경계와 S03 worker/review G01~G10 matrix 회귀가 유지되는가
## 검증 결과
### REVIEW_API-1 중간 검증
명령:
```bash
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_select_execution_target.py'
```
실제 stdout/stderr:
```
..........................
----------------------------------------------------------------------
Ran 26 tests in 0.381s
OK
```
### 최종 검증
명령:
```bash
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*execution_target*.py'
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/tests/test_execution_target_policy.py \
agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py
```
실제 stdout/stderr:
```
...............................
----------------------------------------------------------------------
Ran 31 tests in 0.378s
OK
---PY_COMPILE---
py_compile exit=0
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) |
| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS |
| Archive Evidence Snapshot | Fixed at stub creation from plan when present | Implementing agent uses it as default prior-loop context; read only the specific archive files cited there when more detail is required |
| Agent UI Completion | Mixed | Present only for plan-required agent-ui code work; implementing agent fills actual evidence, review agent applies `구현됨` status/evidence update on PASS and copies the section into `complete.log` |
| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` → `[x]` only |
| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` → `[x]` only |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry |
| 코드리뷰 결과 | Review agent appends | Not included in stub |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- implementation deviation: Pass
- verification trust: Pass
- spec conformance: Pass
- 발견된 문제:
- 없음
- 다음 단계:
- PASS: `complete.log`를 작성하고 task artifact를 2026/07 archive로 이동한 뒤 Milestone 완료 이벤트 메타데이터를 보고한다.

View file

@ -0,0 +1,48 @@
# Complete - m-agent-task-runtime-target-selector/01_selector_contract
## 완료 일시
2026-07-25
## 요약
Selector malformed prior/quota 입력과 initial→resume self-roundtrip 계약을 4회 리뷰 루프에서 완결했으며 최종 판정은 PASS다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G07_0.log` | `code_review_cloud_G09_0.log` | FAIL | prior decision과 quota snapshot의 중첩 schema/type 검증 및 안정적인 CLI 오류 envelope가 필요했다. |
| `plan_cloud_G07_1.log` | `code_review_cloud_G09_1.log` | FAIL | candidate, decision, quota 하위 필수 field/type/enum 검증이 필요했다. |
| `plan_cloud_G07_2.log` | `code_review_cloud_G09_2.log` | FAIL | unhashable stage/lane과 quota metadata/probe 입력 검증 및 self-roundtrip 보완이 필요했다. |
| `plan_cloud_G07_3.log` | `code_review_cloud_G09_3.log` | PASS | 모든 malformed 입력이 안정적인 selector 오류로 정규화되고 S01~S03 회귀가 통과했다. |
## 구현/정리 내용
- prior decision의 identity, selected, candidates, decision evidence와 quota schema를 initial 출력 계약에 맞춰 검증한다.
- quota snapshot metadata와 probe command를 success JSON 생성 전에 검증해 selector의 initial 출력이 같은 work unit에서 resume 가능하도록 했다.
- direct API와 CLI malformed-input envelope, quota roundtrip, KST 경계와 worker/review grade matrix 회귀를 추가했다.
## 최종 검증
- `python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*execution_target*.py'` - PASS; 31 tests, 0 failures.
- `python3 agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py -v -k SelectorIdentityAndQuotaRoundtripTests` - PASS; 집중 회귀 4 tests, 0 failures.
- `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/tests/test_execution_target_policy.py agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py` - PASS; exit 0.
## Roadmap Completion
- 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)
- Completed task ids:
- `selector-contract`: PASS; evidence=`agent-task/archive/2026/07/m-agent-task-runtime-target-selector/01_selector_contract/plan_cloud_G07_3.log`, `agent-task/archive/2026/07/m-agent-task-runtime-target-selector/01_selector_contract/code_review_cloud_G09_3.log`; verification=`python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*execution_target*.py'`
- `time-route`: PASS; evidence=`agent-task/archive/2026/07/m-agent-task-runtime-target-selector/01_selector_contract/plan_cloud_G07_3.log`, `agent-task/archive/2026/07/m-agent-task-runtime-target-selector/01_selector_contract/code_review_cloud_G09_3.log`; verification=KST `06:59:59`, `07:00:00`, `22:59:59`, `23:00:00` boundary tests in the 31-test selector/policy suite.
- `grade-route`: PASS; evidence=`agent-task/archive/2026/07/m-agent-task-runtime-target-selector/01_selector_contract/plan_cloud_G07_3.log`, `agent-task/archive/2026/07/m-agent-task-runtime-target-selector/01_selector_contract/code_review_cloud_G09_3.log`; verification=worker local/cloud G01~G10 and official CODE_REVIEW matrix tests in the 31-test selector/policy suite.
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,230 @@
<!-- task=m-agent-task-runtime-target-selector/01_selector_contract plan=0 tag=API -->
# 실행 대상 Selector JSON 계약 구현
## 이 파일을 읽는 구현 에이전트에게
구현과 검증을 완료한 뒤 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 내용과 출력으로 채운다. active PLAN/CODE_REVIEW 파일은 그대로 두고 리뷰 준비 완료만 보고한다. 차단되면 구현 소유 evidence 필드에 정확한 차단 원인, 시도한 명령과 출력, 재개 조건만 기록한다. 사용자에게 질문하거나 user-input 도구를 호출하거나 control-plane stop 파일을 만들거나 다음 상태를 판정하지 않는다. 로그 archive, `complete.log`, 최종 판정은 code-review skill만 수행한다.
## 배경
KST 시간/grade 정책은 `execution_target_policy.py`의 순수 결정 모듈과 단위 테스트로 먼저 고정됐다. 아직 task 파일·PLAN header·prior decision을 입력으로 받아 안정적인 JSON을 반환하는 selector CLI가 없어 dispatcher가 해당 정책을 결정적 계약으로 소비할 수 없다. 승인된 SDD의 S01~S03을 selector 표면에서 닫고 quota probe가 연결될 자리를 고정한다.
## 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:
- `selector-contract`: task file 기반 selector JSON 계약
- `time-route`: KST 주야간 경계 route
- `grade-route`: worker/review G01~G10 route matrix
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `AGENTS.md`
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/rules/common/philosophy.md`
- `agent-ops/rules/common/rules-agent-spec.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/finalize-task-routing/SKILL.md`
- `agent-roadmap/current.md`
- `agent-roadmap/priority-queue.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-spec/index.md`
- `agent-contract/index.md`
- `agent-test/local/rules.md`
- `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/execution_target_policy.py`
- `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_execution_target_policy.py`
### SDD 기준
- SDD: `agent-roadmap/sdd/automation-runtime-bridge/agent-task-runtime-target-selector/SDD.md`
- 상태: `[승인됨]`, SDD 잠금 `해제`, 사용자 리뷰 없음
- 대상: S01→`selector-contract`, S02→`time-route`, S03→`grade-route`
- Evidence Map: selector schema/invalid-input 테스트, KST 네 경계 테스트, worker local/cloud G01~G10 및 CODE_REVIEW matrix snapshot을 요구한다.
- 구현 체크리스트는 S01의 task/header/JSON validation과 prior-decision determinism을 먼저 고정하고, S02/S03을 selector CLI를 통한 black-box matrix로 재검증하도록 구성했다.
### 테스트 환경 규칙
- `test_env=local`
- `agent-test/local/rules.md`를 읽었다. `agent-ops/skills/project/**`에 매칭되는 profile route는 없어 profile 문서는 적용하지 않는다.
- fallback은 저장소의 기존 Python `unittest` 구조와 직접 실행 가능한 `python3 -m unittest discover`다.
- 외부 runner, provider, secret, port는 사용하지 않는다. test-rule 유지보수는 필요하지 않다.
- 확인된 명령: `python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_execution_target*.py'`는 현재 정책 테스트 5개를 통과한다.
### 테스트 커버리지 공백
- `time-route`: 순수 정책의 `06:59:59`, `07:00:00`, `22:59:59`, `23:00:00` KST 경계는 현재 테스트가 커버한다. selector CLI를 통한 동일 경계 검증은 없다.
- `grade-route`: 순수 정책의 worker local/cloud G01~G10과 review matrix는 현재 테스트가 커버한다. task filename/header 파싱과 결합된 matrix는 없다.
- `selector-contract`: JSON schema, invalid filename/stage/header, stable `work_unit_id`, 동일 입력 결정성, resume pin 테스트가 전혀 없다.
### 심볼 참조
- renamed/removed symbol: none
### 분할 판단
- split 정책을 파일 선택 전에 적용했다.
- task group: `m-agent-task-runtime-target-selector`
- `01_selector_contract`: Python selector 계약과 S01~S03 selector-level 검증. 선행 작업 없음.
- `02+01_quota_input`: 기존 Go usage checker 기반 quota JSON probe. `01_selector_contract`의 `complete.log`에 의존한다.
- selector 계약과 quota probe는 Python policy/JSON 경계와 Node Go checker라는 서로 다른 ownership·검증 경계이므로 분리했다. `01_selector_contract` 내부는 하나의 CLI 계약, 하나의 주 ownership, 동일한 Python 검증 명령을 공유하므로 추가 분할은 인위적이다.
### 범위 결정 근거
- `dispatch.py`의 persisted state, failover, failure budget, selfcheck, dependency drain은 continuation/integration Epic 소유이므로 수정하지 않는다.
- 실제 Go quota checker 호출과 required-cap tri-state는 `02+01_quota_input`으로 분리한다.
- `finalize-task-routing`의 lane/G 판정과 공식 review lifecycle은 변경하지 않는다.
- selector는 generic stderr로 quota/provider 상태를 추정하지 않는다.
### 최종 라우팅
- evaluation_mode: `first-pass`
- finalizer: `finalize-task-policy.sh`
- finalizer_mode: `pair`
- build closures: scope/context/verification/evidence/ownership/decision 모두 `true`
- build closure basis: 새 selector와 기존 순수 policy/test로 범위가 닫히고, 로컬 unittest로 schema·invalid input·matrix를 결정적으로 검증하며, 사용자 결정과 외부 서비스가 없다.
- build base route: `local-G04`
- build grade scores: scope_coupling=1, state_concurrency=1, blast_irreversibility=1, evidence_diagnosis=0, verification_complexity=1
- build loop-risk: `boundary_contract`, `structured_interpretation`, `variant_product`가 매칭되어 `triggered: true`; signature 개수는 grade에 더하지 않고 cloud minimum `G07`만 적용
- `boundary_contract`: task filename/header, selector JSON, policy 및 후속 dispatcher consumer 사이의 validation/compatibility 계약
- `structured_interpretation`: filename/header/JSON 해석과 명시 stage·resume pin·시간 정책의 precedence
- `variant_product`: worker/review × local/cloud × G01~G10 및 KST 경계 조합 검증
- build final: `cloud-G07`, `PLAN-cloud-G07.md`
- review closures: scope/context/verification/evidence/ownership/decision 모두 `true`
- review closure basis: SDD, selector/policy source, black-box tests를 한 checkout에서 대조할 수 있다.
- review base route: `local-G05`
- review grade scores: scope_coupling=1, state_concurrency=1, blast_irreversibility=1, evidence_diagnosis=1, verification_complexity=1
- review capability floor: official review minimum `cloud-G09`
- review final: `cloud-G09`, `CODE_REVIEW-cloud-G09.md`
## 구현 체크리스트
- [ ] task filename, PLAN header와 입력 옵션을 검증하고 SDD 출력 필드를 안정적인 JSON으로 반환하는 selector CLI를 구현한다.
- [ ] 동일 입력 결정성, invalid input, resume pin, KST 네 경계와 worker/review 전체 grade matrix를 selector CLI 수준에서 검증한다.
- [ ] `python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_execution_target*.py'`를 실행해 전체 selector 정책/계약 테스트가 통과함을 확인한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.
## [API-1] Selector CLI와 안정적인 JSON schema
### 문제
`agent-ops/skills/project/orchestrate-agent-task-loop/scripts/execution_target_policy.py:64`의 `select_policy()`는 ordered target 정책만 반환한다. task 파일 필수 입력, stage/lane/grade 파싱, `task/plan/tag` 세대 identity, prior decision, quota envelope, transition evidence를 조합하는 CLI가 없다.
### 해결 방법
현재 기준:
```python
# execution_target_policy.py:64
def select_policy(
*, stage: str, lane: str, grade: int, evaluated_at: datetime
) -> PolicyDecision:
```
`scripts/select_execution_target.py`를 추가하고 다음 계약을 고정한다.
```python
def select_execution_target(
task_file: Path,
*,
stage: str | None,
evaluated_at: datetime,
transition: str,
prior_decision: dict[str, object] | None,
quota_snapshot: dict[str, object] | None,
quota_probe_command: str,
) -> dict[str, object]:
...
```
- positional `task_file`은 `PLAN-(local|cloud)-GNN.md` 또는 `CODE_REVIEW-(local|cloud)-GNN.md`만 허용한다.
- `stage` 생략 시 prefix에서 `worker|review`를 판별하고 명시 stage가 prefix와 충돌하면 입력 오류로 종료한다.
- 첫 1KiB의 `<!-- task=... plan=N tag=... -->`를 검증해 body 수정과 무관한 `work_unit_id`를 만든다.
- `schema_version`, identity/stage/lane/grade, selected/candidates, decision/quota/transition을 항상 같은 key와 타입으로 출력한다.
- `initial`은 순수 policy를 사용하고 `resume`은 동일 work unit의 prior selected target을 시간 변화와 무관하게 pinned 상태로 반환한다.
- `failover` 상태 전이는 다음 Epic 소유임을 명시적 `unsupported_transition` 입력 오류로 남기고 임의 target 전환을 하지 않는다.
- quota probe가 아직 없거나 snapshot이 없으면 cloud candidate는 `unknown`, local candidate는 `unbounded/not_applicable`로 표현한다. raw provider stderr는 입력으로 받지 않는다.
- 오류는 stderr JSON과 non-zero exit로 반환하고 stdout에 부분 decision을 남기지 않는다.
### 수정 파일 및 체크리스트
- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py` — CLI, validation, work-unit identity, JSON serialization을 추가한다.
- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/execution_target_policy.py` — 필요할 때만 JSON serialization용 명시 helper를 추가하고 정책 matrix는 바꾸지 않는다.
### 테스트 작성
작성한다. `tests/test_select_execution_target.py`에 valid task/header, stage inference/mismatch, invalid filename/header/grade, body-only 변경 identity 안정성, fixed clock 결정성, resume pin, JSON key/type 검증을 추가한다. fixture는 `TemporaryDirectory` 안의 task file과 주입 JSON만 사용한다.
### 중간 검증
```bash
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_select_execution_target.py'
```
기대 결과: selector contract 테스트가 모두 PASS하고 invalid 입력 케이스는 예상한 non-zero/오류 code를 검증한다.
## [API-2] Selector 표면의 시간/grade matrix evidence
### 문제
`agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_execution_target_policy.py:24`는 순수 policy만 검증한다. S02/S03 Evidence Map이 요구하는 selector-level KST 경계와 task filename 기반 worker/review matrix snapshot이 없다.
### 해결 방법
현재 기준:
```python
# test_execution_target_policy.py:24
class ExecutionTargetPolicyTests(unittest.TestCase):
```
`test_select_execution_target.py`에서 실제 task filename/header fixture를 만들고 selector entry point를 호출한다.
- KST `06:59:59`, `07:00:00`, `22:59:59`, `23:00:00`을 timezone-aware ISO 입력으로 고정한다.
- worker `local|cloud × G01..G10` 전 조합의 canonical `adapter + target + execution_class + selfcheck_required`를 표로 검증한다.
- `CODE_REVIEW-local/cloud-G01..G10`은 모두 `codex + gpt-5.6-sol`, `cloud_model`, selfcheck false인지 검증한다.
- candidate rank는 1부터 연속이며 dynamic local G07~G08만 시간대에 따라 Gemini/Laguna 순서가 바뀌는지 확인한다.
### 수정 파일 및 체크리스트
- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py` — S01~S03 black-box matrix를 추가한다.
- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_execution_target_policy.py` — 순수 policy 기대값과 selector 기대값이 중복 상수가 되지 않도록 필요한 최소 helper만 정리한다.
### 테스트 작성
작성한다. `test_kst_boundary_routes_through_selector`, `test_worker_route_matrix_through_selector`, `test_review_route_matrix_is_codex`, `test_repeated_input_is_byte_stable`을 명시적으로 둔다.
### 중간 검증
```bash
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_execution_target*.py'
```
기대 결과: 순수 policy와 selector-level S01~S03 테스트가 모두 PASS한다.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py` | API-1 |
| `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/execution_target_policy.py` | API-1 |
| `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py` | API-1, API-2 |
| `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_execution_target_policy.py` | API-2 |
## 최종 검증
```bash
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_execution_target*.py'
```
기대 결과: S01 schema/invalid input, S02 네 KST 경계, S03 worker/review 전체 matrix가 모두 PASS한다. Python unittest cache 사용 여부는 해당 없으며 외부 provider를 호출하지 않는다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다.

View file

@ -0,0 +1,223 @@
<!-- task=m-agent-task-runtime-target-selector/01_selector_contract plan=1 tag=REVIEW_API -->
# Selector 중첩 JSON 입력 계약 보강
## 이 파일을 읽는 구현 에이전트에게
구현과 검증을 완료한 뒤 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 내용과 출력으로 채운다. active PLAN/CODE_REVIEW 파일은 그대로 두고 리뷰 준비 완료만 보고한다. 차단되면 구현 소유 evidence 필드에 정확한 차단 원인, 시도한 명령과 출력, 재개 조건만 기록한다. 사용자에게 질문하거나 user-input 도구를 호출하거나 control-plane stop 파일을 만들거나 다음 상태를 판정하지 않는다. 로그 archive, `complete.log`, 최종 판정은 code-review skill만 수행한다.
## 배경
첫 구현은 정상 selector 경로와 S01~S03 route matrix를 통과했지만, prior decision과 quota snapshot의 중첩 JSON 형식을 충분히 검증하지 않았다. 그 결과 불완전한 resume schema가 성공하거나 malformed 입력이 계약된 오류 envelope 대신 traceback을 낸다. 기존 정책 matrix와 quota 정규화 책임은 유지하면서 selector 입력 경계만 보강한다.
## Archive Evidence Snapshot
- Prior task: `agent-task/m-agent-task-runtime-target-selector/01_selector_contract`
- Archived plan: `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/plan_cloud_G07_0.log`
- Archived review: `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/code_review_cloud_G09_0.log`
- Verdict: `FAIL`
- Findings: Required 1, Suggested 0, Nit 0
- Required: malformed `prior_decision`/`quota_snapshot` 중첩 형식이 불완전한 성공 schema 또는 traceback/exit 1을 만든다.
- Affected files: `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`
- Verification evidence: 기존 selector 17개와 selector/policy 통합 22개는 PASS했다. 집중 재현은 incomplete selected가 `adapter,target`만 가진 채 성공하고 malformed decision 및 배열 quota가 traceback/exit 1을 내는 것을 확인했다.
- Roadmap carryover: `selector-contract`, `time-route`, `grade-route`; SDD S01의 완전한 JSON/명시적 입력 오류가 현재 차단 항목이다.
- 필요한 경우 위 두 archived log만 prior-loop 상세 근거로 읽는다. `agent-task/archive/**`를 탐색하지 않는다.
## 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:
- `selector-contract`: task file 기반 selector JSON 계약
- `time-route`: KST 주야간 경계 route
- `grade-route`: worker/review G01~G10 route matrix
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/rules/common/philosophy.md`
- `agent-ops/rules/common/rules-agent-spec.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/finalize-task-routing/SKILL.md`
- `agent-ops/skills/common/plan/templates/review-stub-template.md`
- `agent-roadmap/current.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-spec/index.md`
- `agent-contract/index.md`
- `agent-test/local/rules.md`
- `agent-ops/skills/project/orchestrate-agent-task-loop/SKILL.md`
- `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py`
- `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/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_execution_target_policy.py`
- `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/plan_cloud_G07_0.log`
- `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/code_review_cloud_G09_0.log`
### SDD 기준
- SDD: `agent-roadmap/sdd/automation-runtime-bridge/agent-task-runtime-target-selector/SDD.md`
- 상태: `[승인됨]`, SDD 잠금 `해제`, 사용자 리뷰 없음
- 직접 대상: S01→`selector-contract`
- 회귀 대상: S02→`time-route`, S03→`grade-route`
- Evidence Map: S01은 완전한 selector schema와 invalid-input 단위 테스트를 요구하고, S02/S03은 KST 네 경계 및 worker/review 전체 matrix 회귀를 요구한다.
- 구현 체크리스트는 S01의 prior/quota 중첩 입력 schema와 오류 envelope를 보강하고, 최종 검증에서 S02/S03 matrix를 함께 재실행하도록 역산했다.
### 테스트 환경 규칙
- `test_env=local`
- `agent-test/local/rules.md`를 읽었다. `agent-ops/skills/project/**`에 매칭되는 추가 profile route는 없다.
- 외부 runner, provider, secret, port를 사용하지 않는다.
- fallback은 저장소의 Python `unittest`와 `py_compile`이다. test-rule 유지보수는 필요하지 않다.
- 현재 증거: selector 17개, selector/policy 통합 22개와 대상 파일 `py_compile`이 PASS했다.
### 테스트 커버리지 공백
- incomplete `prior_decision.selected`가 안정적인 output schema를 깨는 경우가 없다.
- non-object 또는 잘못 중첩된 `prior_decision`/`quota_snapshot`이 CLI stderr JSON/exit 2로 정규화되는 회귀 테스트가 없다.
- 정상 initial/resume, KST 경계, worker/review grade matrix는 기존 테스트가 커버한다.
### 심볼 참조
- renamed/removed symbol: none
### 분할 판단
- split 정책을 파일 선택 전에 적용했다.
- 기존 `01_selector_contract` 안의 단일 selector 입력 경계와 그 전용 테스트만 수정한다. API와 call-site rollout, 도메인, 검증 환경, 독립 ownership 경계가 새로 갈리지 않으므로 추가 subtask는 인위적이다.
- sibling `02+01_quota_input`은 이 subtask의 `complete.log`를 기다리며 현재 수정 범위와 병행하지 않는다.
### 범위 결정 근거
- `execution_target_policy.py`의 시간/grade matrix는 변경하지 않는다.
- `dispatch.py`의 persisted state, failover, failure budget과 skill route 표면은 후속 Epic 범위라 수정하지 않는다.
- Go usage checker 호출과 required-cap tri-state 정규화는 `02+01_quota_input` 소유라 구현하지 않는다.
- malformed JSON container/schema를 selector 입력 오류로 정규화하되 quota admission 또는 target 재선택은 추가하지 않는다.
### 최종 라우팅
- evaluation_mode: `isolated-reassessment`
- finalizer: `finalize-task-policy.sh`
- finalizer_mode: `pair`
- build closures: scope/context/verification/evidence/ownership/decision 모두 `true`
- build closure basis: 한 selector 모듈과 전용 테스트로 범위가 닫히고, 재현 입력과 local unittest로 결과를 결정적으로 검증하며, dependent sibling은 완료 전 대기한다.
- build base route: `local-G04`
- build grade scores: scope_coupling=1, state_concurrency=1, blast_irreversibility=1, evidence_diagnosis=0, verification_complexity=1
- build loop-risk: `boundary_contract`, `structured_interpretation`, `variant_product`가 매칭되어 `triggered: true`; cloud minimum `G07` 적용
- `boundary_contract`: CLI decoder, selector API와 향후 dispatcher consumer가 같은 validation/compatibility 계약을 소비한다.
- `structured_interpretation`: 중첩 JSON object/list/type 해석과 malformed 입력 정규화가 필요하다.
- `variant_product`: prior/quota 입력군, direct/CLI 표면과 정상/누락/오류 형식 조합을 검증한다.
- build final: `cloud-G07`, `PLAN-cloud-G07.md`
- review closures: scope/context/verification/evidence/ownership/decision 모두 `true`
- review closure basis: SDD S01, selector source, focused reproducer와 전체 unittest를 한 checkout에서 대조할 수 있다.
- review base route: `local-G05`
- review grade scores: scope_coupling=1, state_concurrency=1, blast_irreversibility=1, evidence_diagnosis=1, verification_complexity=1
- review capability floor: official review minimum `cloud-G09`
- review final: `cloud-G09`, `CODE_REVIEW-cloud-G09.md`
## 구현 체크리스트
- [ ] prior decision과 quota snapshot의 필수 중첩 필드·타입·허용값을 검증하고 모든 위반을 `SelectorInputError`로 정규화하며, direct API와 CLI 회귀 테스트가 focused selector suite에서 통과하도록 한다.
- [ ] `python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*execution_target*.py'`와 대상 파일 `py_compile`을 실행해 S01~S03 전체 회귀가 통과함을 확인한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.
### [REVIEW_API-1] Prior/quota 중첩 JSON schema 검증
#### 문제
`agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py:100`은 `quota_snapshot.get()`과 entry `.get()`을 컨테이너 타입 확인 없이 호출한다. `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py:263`은 prior `selected`에서 `adapter`/`target`만 확인하고 `decision`, `candidates`, `quota`를 검증하지 않은 채 복사한다.
현재 기준:
```python
# select_execution_target.py:263
selected = prior_decision["selected"]
if (
not isinstance(selected, dict)
or "adapter" not in selected
or "target" not in selected
):
...
decision = dict(prior_decision["decision"])
```
이 때문에 selected 필드 누락은 성공 schema에 전파되고, `decision=1` 또는 배열형 quota snapshot은 `main()`의 계약 오류 처리에 도달하지 못한 `TypeError`/`AttributeError` traceback을 만든다.
#### 해결 방법
`prior_decision`과 `quota_snapshot`에 명시적인 구조 validator를 둔다.
- prior decision은 현재 schema version, identity/stage/lane/grade, `selected`, `candidates`, `decision`, `quota`의 필수 container·field·type과 enum을 검증한다. 미래 호환용 추가 key는 허용한다.
- resume에 필요한 `selected.execution_class`와 `selected.selfcheck_required`를 필수로 하여 성공 출력 schema가 initial과 동일하게 유지되게 한다.
- quota snapshot이 제공되면 object, `targets` list와 각 target entry의 adapter/target/status 형식을 검증한다. 실제 required-cap 정규화나 admission은 추가하지 않는다.
- 모든 schema 위반은 `SelectorInputError`의 안정적인 error code로 변환해 CLI가 stdout 없이 stderr JSON과 exit 2를 반환하게 한다.
예상 형태:
```python
def _validate_prior_decision(value: object) -> dict:
if not isinstance(value, dict):
raise SelectorInputError("malformed_prior_decision", ...)
...
return value
def _validate_quota_snapshot(value: object | None) -> dict | None:
if value is None:
return None
if not isinstance(value, dict):
raise SelectorInputError("malformed_quota_snapshot", ...)
...
return value
```
#### 수정 파일 및 체크리스트
- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py` — prior/quota nested schema validator와 오류 정규화를 추가한다.
- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py` — 불완전 selected, scalar decision, non-object/invalid quota entry의 direct/CLI 회귀를 추가한다.
#### 테스트 작성
작성한다.
- `test_resume_rejects_incomplete_selected_schema`: 필수 selected 필드 누락이 `malformed_prior_decision`인지 확인한다.
- `test_cli_malformed_prior_uses_json_error_envelope`: scalar nested decision이 stdout 없이 exit 2/stderr JSON인지 확인한다.
- `test_cli_malformed_quota_uses_json_error_envelope`: 배열 snapshot과 invalid target entry가 stdout 없이 exit 2/stderr JSON인지 확인한다.
- fixture는 `TemporaryDirectory`의 task/prior/quota JSON만 사용한다.
#### 중간 검증
```bash
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_select_execution_target.py'
```
기대 결과: 정상 initial/resume은 유지되고 모든 malformed nested input 회귀가 안정적인 selector 오류로 PASS한다.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py` | REVIEW_API-1 |
| `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py` | REVIEW_API-1 |
## 최종 검증
```bash
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*execution_target*.py'
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/tests/test_execution_target_policy.py \
agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py
```
기대 결과: S01 nested input/error envelope와 S02/S03 시간·grade matrix가 모두 PASS하고 대상 Python 파일이 compile된다. unittest cache는 해당 없다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다.

View file

@ -0,0 +1,218 @@
<!-- task=m-agent-task-runtime-target-selector/01_selector_contract plan=2 tag=REVIEW_API -->
# Selector prior decision 전체 중첩 schema 검증
## 이 파일을 읽는 구현 에이전트에게
구현과 검증을 완료한 뒤 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 내용과 출력으로 채운다. active PLAN/CODE_REVIEW 파일은 그대로 두고 리뷰 준비 완료만 보고한다. 차단되면 구현 소유 evidence 필드에 정확한 차단 원인, 시도한 명령과 출력, 재개 조건만 기록한다. 사용자에게 질문하거나 user-input 도구를 호출하거나 control-plane stop 파일을 만들거나 다음 상태를 판정하지 않는다. 로그 archive, `complete.log`, 최종 판정은 code-review skill만 수행한다.
## 배경
두 번째 구현은 prior decision과 quota snapshot의 대표적인 잘못된 container를 명시 오류로 바꿨지만, resume에서 재사용하는 `candidates`, `decision`, `quota`의 하위 schema는 아직 검증하지 않는다. 유효한 container 안의 누락 field나 잘못된 enum이 성공 출력으로 그대로 전파되므로, initial 출력 계약을 기준으로 남은 중첩 schema를 닫는다.
## Archive Evidence Snapshot
- Prior task: `agent-task/m-agent-task-runtime-target-selector/01_selector_contract`
- Archived plan: `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/plan_cloud_G07_1.log`
- Archived review: `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/code_review_cloud_G09_1.log`
- Verdict: `FAIL`
- Findings: Required 1, Suggested 0, Nit 0
- Required: `prior_decision.candidates`, `decision`, `quota`가 container 검사만 통과하면 불완전한 field/type/enum을 resume 성공 출력으로 재방출한다.
- Affected files: `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`
- Verification evidence: selector/policy 통합 25개와 대상 파일 `py_compile`은 PASS했다. 집중 재현은 `candidates=[{}]`, `decision={}`, `quota={"mode":"bogus"}`가 모두 성공하는 것을 확인했다.
- Roadmap carryover: `selector-contract`, `time-route`, `grade-route`; SDD S01의 완전한 selector JSON이 현재 차단 항목이다.
- 필요한 경우 위 두 archived log만 prior-loop 상세 근거로 읽는다. `agent-task/archive/**`를 탐색하지 않는다.
## 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:
- `selector-contract`: task file 기반 selector JSON 계약
- `time-route`: KST 주야간 경계 route
- `grade-route`: worker/review G01~G10 route matrix
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/rules/common/philosophy.md`
- `agent-ops/rules/common/rules-agent-spec.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/finalize-task-routing/SKILL.md`
- `agent-ops/skills/common/plan/templates/review-stub-template.md`
- `agent-roadmap/current.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-spec/index.md`
- `agent-contract/index.md`
- `agent-test/local/rules.md`
- `agent-ops/skills/project/orchestrate-agent-task-loop/SKILL.md`
- `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py`
- `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/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_execution_target_policy.py`
- `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/plan_cloud_G07_0.log`
- `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/code_review_cloud_G09_0.log`
- `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/plan_cloud_G07_1.log`
- `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/code_review_cloud_G09_1.log`
### SDD 기준
- SDD: `agent-roadmap/sdd/automation-runtime-bridge/agent-task-runtime-target-selector/SDD.md`
- 상태: `[승인됨]`, SDD 잠금 `해제`, 사용자 리뷰 없음
- 직접 대상: S01→`selector-contract`
- 회귀 대상: S02→`time-route`, S03→`grade-route`
- Evidence Map: S01은 완전한 selector schema와 invalid-input 단위 테스트를 요구하고, S02/S03은 KST 네 경계 및 worker/review 전체 matrix 회귀를 요구한다.
- 구현 체크리스트는 S01의 남은 candidate/decision/quota 중첩 계약을 완성하고, 최종 검증에서 S02/S03을 함께 재실행하도록 역산했다.
### 테스트 환경 규칙
- `test_env=local`
- `agent-test/local/rules.md`를 읽었다. `agent-ops/skills/project/**`에 매칭되는 추가 profile route는 없다.
- 외부 runner, provider, secret, port를 사용하지 않는다.
- fallback은 저장소의 Python `unittest`와 `py_compile`이다. test-rule 유지보수는 필요하지 않다.
- 현재 증거: selector/policy 통합 25개와 대상 파일 `py_compile`이 PASS했다.
### 테스트 커버리지 공백
- selected 누락, scalar decision, non-object quota와 invalid quota target entry는 기존 회귀가 커버한다.
- object/list container가 유효하지만 candidate entry, decision field, quota field 또는 enum이 불완전한 prior decision은 회귀가 없다.
- 정상 initial/resume, KST 네 경계, worker/review grade matrix는 기존 테스트가 커버한다.
### 심볼 참조
- renamed/removed symbol: none
### 분할 판단
- split 정책을 파일 선택 전에 적용했다.
- 기존 `01_selector_contract` 안의 하나의 prior decision validator와 그 전용 테스트만 수정한다. API-vs-call-site rollout, 다른 ownership, 별도 검증 환경이 없고 production validator와 회귀 테스트를 분리하면 독립 완료 가치가 없으므로 단일 follow-up이 안전하다.
- sibling `02+01_quota_input`은 이 subtask의 `complete.log`를 기다리며 현재 수정 범위와 병행하지 않는다.
### 범위 결정 근거
- `quota_snapshot`의 required-cap tri-state와 admission, dispatcher persisted state, failover, failure budget은 후속 subtask/Epic 소유라 수정하지 않는다.
- `execution_target_policy.py`의 시간/grade matrix와 `orchestrate-agent-task-loop/SKILL.md`의 runtime route 표면은 변경하지 않는다.
- 미래 호환 추가 key는 허용하되, 현재 initial schema가 생성하는 필수 field·type·enum만 검증한다.
- `agent-spec/index.md`와 `agent-contract/index.md`에는 이 내부 selector 계약에 매칭되는 living spec/계약 문서가 없어 별도 문서를 읽거나 갱신하지 않는다.
### 최종 라우팅
- evaluation_mode: `isolated-reassessment`
- finalizer: `finalize-task-policy.sh`
- finalizer_mode: `pair`
- build closures: scope/context/verification/evidence/ownership/decision 모두 `true`
- build closure basis: 한 selector validator와 전용 회귀로 범위가 닫히고, 세 가지 현재 성공 재현과 local unittest로 결과를 결정적으로 판별하며, 사용자 결정이나 외부 환경이 없다.
- build base route: `local-G04`
- build grade scores: scope_coupling=1, state_concurrency=1, blast_irreversibility=1, evidence_diagnosis=0, verification_complexity=1
- build loop-risk: `boundary_contract`, `structured_interpretation`, `variant_product`가 매칭되어 `triggered: true`; cloud minimum `G07` 적용
- `boundary_contract`: persisted prior schema를 direct API와 CLI/향후 dispatcher consumer가 함께 사용하며 validation/compatibility를 유지해야 한다.
- `structured_interpretation`: 중첩 object/list field와 enum 해석에서 누락·잘못된 타입을 구분해야 한다.
- `variant_product`: candidate/decision/quota 입력군과 direct/CLI 표면의 정상·누락·오류 형식 조합을 검증한다.
- build final: `cloud-G07`, `PLAN-cloud-G07.md`
- review closures: scope/context/verification/evidence/ownership/decision 모두 `true`
- review closure basis: SDD S01, initial output schema, validator source, 집중 재현과 전체 unittest를 한 checkout에서 대조할 수 있다.
- review base route: `local-G05`
- review grade scores: scope_coupling=1, state_concurrency=1, blast_irreversibility=1, evidence_diagnosis=1, verification_complexity=1
- review capability floor: official review minimum `cloud-G09`
- review final: `cloud-G09`, `CODE_REVIEW-cloud-G09.md`
## 구현 체크리스트
- [ ] prior decision의 `candidates`, `decision`, `quota` 필수 하위 field·type·enum을 initial 출력 schema 기준으로 검증하고 모든 위반을 `malformed_prior_decision`으로 정규화하며, direct API와 CLI 회귀 테스트가 focused selector suite에서 통과하도록 한다.
- [ ] `python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*execution_target*.py'`와 대상 파일 `py_compile`을 실행해 S01~S03 전체 회귀가 통과함을 확인한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.
### [REVIEW_API-1] Prior decision 전체 중첩 schema 검증
#### 문제
`agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py:175`은 `candidates`가 list인지, `decision`과 `quota`가 object인지만 확인한다. 내부 field가 없거나 잘못된 enum이어도 `_resume()`이 그대로 복사하므로 완전한 initial schema와 다른 성공 JSON을 만든다.
현재 기준:
```python
# select_execution_target.py:175
if not isinstance(value["candidates"], list):
raise SelectorInputError(...)
if not isinstance(value["decision"], dict):
raise SelectorInputError(...)
if not isinstance(value["quota"], dict):
raise SelectorInputError(...)
```
#### 해결 방법
initial 출력이 만드는 필수 schema를 작은 validator로 분리해 resume 재사용 전에 모두 검증한다.
```python
def _validate_prior_candidates(candidates: object) -> None:
# non-empty list, consecutive positive rank, target/type/quota/eligibility fields
...
def _validate_prior_decision_evidence(decision: object) -> None:
# rule_id, policy_priority, reason_codes, evaluated_at, timezone,
# time_window, pinned
...
def _validate_prior_quota(quota: object) -> None:
# snapshot_id, mode, status, source, checked_at
...
```
- candidate는 `candidate_rank`, `adapter`, `target`, `execution_class`, `selfcheck_required`, `quota_mode`, `quota_status`, `eligibility`, `rejection_reason`의 필수 field와 타입을 검사하고 rank가 1부터 연속인지 확인한다.
- decision은 `rule_id`, `policy_priority`, `reason_codes`, `evaluated_at`, `timezone`, `time_window`, `pinned`의 필수 field와 타입을 검사한다. `timezone`은 `Asia/Seoul`, enum field는 현재 출력 허용값만 받는다.
- quota는 `snapshot_id`, `mode`, `status`, `source`, `checked_at`의 필수 field와 nullable/string 타입 및 현재 enum을 검사한다.
- 추가 key는 미래 호환을 위해 허용한다. 모든 위반은 `SelectorInputError("malformed_prior_decision", ...)`로 정규화한다.
#### 수정 파일 및 체크리스트
- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py` — candidates/decision/quota 하위 schema validator를 추가한다.
- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py` — 유효 container 안의 누락 field·잘못된 type/enum을 direct/CLI에서 거부하는 회귀를 추가한다.
#### 테스트 작성
작성한다.
- `test_resume_rejects_malformed_nested_prior_schema_variants`: initial decision을 복제해 empty candidate, 누락 decision field, 잘못된 quota enum과 대표 type 오류를 주입하고 모두 `malformed_prior_decision`인지 table-driven 방식으로 확인한다.
- `test_cli_deeply_malformed_prior_uses_json_error_envelope`: container는 유효하지만 하위 field가 잘못된 prior JSON이 stdout 없이 exit 2/stderr JSON인지 확인한다.
- fixture는 `TemporaryDirectory`의 task/prior JSON만 사용한다.
#### 중간 검증
```bash
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_select_execution_target.py'
```
기대 결과: 정상 initial/resume은 유지되고 candidates/decision/quota의 모든 계획된 malformed variant가 안정적인 selector 오류로 PASS한다.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py` | REVIEW_API-1 |
| `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py` | REVIEW_API-1 |
## 최종 검증
```bash
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*execution_target*.py'
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/tests/test_execution_target_policy.py \
agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py
```
기대 결과: S01의 완전한 prior schema와 explicit input error, S02/S03 시간·grade matrix가 모두 PASS하고 대상 Python 파일이 compile된다. unittest cache는 해당 없다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다.

View file

@ -0,0 +1,230 @@
<!-- task=m-agent-task-runtime-target-selector/01_selector_contract plan=3 tag=REVIEW_API -->
# Selector malformed 입력과 quota roundtrip 계약 완결
## 이 파일을 읽는 구현 에이전트에게
구현과 검증을 완료한 뒤 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 내용과 출력으로 채운다. active PLAN/CODE_REVIEW 파일은 그대로 두고 리뷰 준비 완료만 보고한다. 차단되면 구현 소유 evidence 필드에 정확한 차단 원인, 시도한 명령과 출력, 재개 조건만 기록한다. 사용자에게 질문하거나 user-input 도구를 호출하거나 control-plane stop 파일을 만들거나 다음 상태를 판정하지 않는다. 로그 archive, `complete.log`, 최종 판정은 code-review skill만 수행한다.
## 배경
세 번째 구현은 candidate/decision/quota 하위 schema를 보강했지만, unhashable `stage`/`lane`은 여전히 traceback과 exit 1을 만든다. 또한 잘못된 quota metadata와 빈 probe command가 initial 성공 JSON에 들어간 뒤 동일 결과의 resume에서 거부되어 selector 자기 출력 계약이 닫히지 않는다. SDD S01의 명시적 invalid-input 오류와 initial→resume roundtrip을 한 번에 완결한다.
## Archive Evidence Snapshot
- Prior task: `agent-task/m-agent-task-runtime-target-selector/01_selector_contract`
- Archived plan: `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/plan_cloud_G07_2.log`
- Archived review: `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/code_review_cloud_G09_2.log`
- Verdict: `FAIL`
- Findings: Required 2, Suggested 0, Nit 0
- Required:
- `prior_decision.stage=[]`와 `lane={}`가 `malformed_prior_decision` 대신 raw `TypeError` traceback/exit 1로 종료된다.
- 숫자 `quota_snapshot.snapshot_id`/`checked_at`, 배열 `source`, 빈 `quota_probe_command`가 resume 불가능한 initial 성공 JSON을 만들 수 있다.
- Affected files: `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`
- Verification evidence: selector/policy 통합 27개와 대상 파일 `py_compile`은 PASS했다. 집중 재현은 unhashable stage/lane의 exit 1과 잘못된 quota metadata의 initial 성공 후 `malformed_prior_decision` resume 거부를 확인했다.
- Roadmap carryover: `selector-contract`, `time-route`, `grade-route`; SDD S01의 완전한 selector JSON과 명시적 invalid-input 오류가 현재 차단 항목이다.
- 필요한 경우 위 두 archived log만 prior-loop 상세 근거로 읽는다. `agent-task/archive/**`를 탐색하지 않는다.
## 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:
- `selector-contract`: task file 기반 selector JSON 계약
- `time-route`: KST 주야간 경계 route
- `grade-route`: worker/review G01~G10 route matrix
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `.gitignore`
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/rules/common/philosophy.md`
- `agent-ops/rules/common/rules-agent-spec.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/finalize-task-routing/SKILL.md`
- `agent-ops/skills/common/finalize-task-routing/scripts/finalize-task-policy.sh`
- `agent-ops/skills/common/plan/templates/review-stub-template.md`
- `agent-roadmap/current.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-spec/index.md`
- `agent-contract/index.md`
- `agent-test/local/rules.md`
- `agent-ops/skills/project/orchestrate-agent-task-loop/SKILL.md`
- `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py`
- `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/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_execution_target_policy.py`
- `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/plan_cloud_G07_1.log`
- `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/code_review_cloud_G09_1.log`
- `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/plan_cloud_G07_2.log`
- `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/code_review_cloud_G09_2.log`
### SDD 기준
- SDD: `agent-roadmap/sdd/automation-runtime-bridge/agent-task-runtime-target-selector/SDD.md`
- 상태: `[승인됨]`, SDD 잠금 `해제`, 사용자 리뷰 없음
- 직접 대상: S01→`selector-contract`
- 회귀 대상: S02→`time-route`, S03→`grade-route`
- Evidence Map: S01은 완전한 selector schema와 invalid-input 단위 테스트를 요구하고, S02/S03은 KST 네 경계와 worker/review 전체 matrix 회귀를 요구한다.
- 구현 체크리스트는 S01의 malformed prior/quota 입력 정규화와 initial→resume self-roundtrip에서 역산했고, 최종 검증은 S02/S03 회귀를 함께 실행한다.
### 테스트 환경 규칙
- `test_env=local`
- `agent-test/local/rules.md`를 읽었다. `agent-ops/skills/project/**`에 매칭되는 추가 profile route는 없다.
- 외부 runner, provider, secret, port를 사용하지 않는다.
- fallback은 현재 checkout의 Python `unittest`와 `py_compile`이다. test-rule 유지보수는 필요하지 않다.
- 현재 증거: selector/policy 통합 27개와 대상 파일 `py_compile`은 PASS했지만 집중 malformed-input/roundtrip 재현은 실패한다.
### 테스트 커버리지 공백
- `prior_decision.stage`/`lane`의 unhashable JSON type이 direct API와 CLI에서 안정적인 selector 오류가 되는 회귀가 없다.
- quota snapshot의 `snapshot_id`, `source`, `checked_at` type과 빈 `quota_probe_command`가 initial 출력 전에 거부되는 회귀가 없다.
- 유효 quota snapshot을 포함한 initial 결과가 그대로 resume 가능한지 검증하는 roundtrip 회귀가 없다.
- candidate/decision/quota 기존 malformed variant, 정상 initial/resume, KST 네 경계와 worker/review grade matrix는 기존 테스트가 커버한다.
### 심볼 참조
- renamed/removed symbol: none
### 분할 판단
- split 정책을 파일 선택 전에 적용했다.
- prior identity type guard와 quota metadata 검증은 같은 selector 입력 validator가 “성공한 initial 결과는 즉시 resume 가능하다”는 하나의 S01 불변조건을 함께 닫는다.
- 동일 source와 전용 test 파일, 동일 direct/CLI 오류 envelope, 동일 roundtrip 검증을 분리하면 중복 fixture와 중간 불완전 계약이 생긴다. API-vs-call-site rollout, 다른 ownership, 외부 검증 환경도 없으므로 현재 `01_selector_contract`의 단일 follow-up이 안전하다.
- sibling `02+01_quota_input`의 required-cap tri-state/admission은 이 subtask `complete.log`를 기다리며 현재 범위에 포함하지 않는다.
### 범위 결정 근거
- quota required-cap tri-state, unknown 1회 admission과 target 재선택은 `02+01_quota_input` 소유이므로 수정하지 않는다.
- dispatcher persisted state, failover, failure budget, dependency drain은 후속 Epic 소유이므로 수정하지 않는다.
- `execution_target_policy.py`의 시간/grade matrix와 orchestration skill의 실행 route 표면은 변경하지 않는다.
- 추가 key 허용과 현재 target `status`의 graceful `unknown` 정규화는 유지한다. 이번 변경은 resume 불가능한 type을 성공 출력에 넣지 않는 입력 경계만 닫는다.
### 최종 라우팅
- evaluation_mode: `isolated-reassessment`
- finalizer: `finalize-task-policy.sh`
- finalizer_mode: `pair`
- build closures:
- scope/context/verification/evidence/ownership/decision 모두 `true`
- 근거: 한 selector validator와 전용 회귀로 범위가 닫히고, direct API/CLI 및 initial→resume 재현과 local unittest로 결과를 결정적으로 판별하며, 사용자 결정이나 외부 환경이 없다.
- build loop-risk raw facts:
- ordered transitions: state_count=2(`initial`, `resume`), recovery 경로는 있으나 3-state temporal signature는 없다.
- concurrent consistency: actor_count=1, 공유 lock/atomic/exactly-once 제약 없음.
- boundary contract: selector 한 component를 direct API와 CLI 두 consumer가 사용하며 validation/compatibility를 함께 유지한다.
- structured interpretation: JSON parser와 중첩 object/list/type 해석 위험이 있다.
- variant product: prior identity, quota metadata/probe, direct/CLI 및 initial/resume 축의 조합 검증이 필요하다.
- build base route: `local-G04`
- build grade scores: scope_coupling=1, state_concurrency=1, blast_irreversibility=1, evidence_diagnosis=0, verification_complexity=1
- build loop-risk: `boundary_contract`, `structured_interpretation`, `variant_product`가 매칭되어 `triggered: true`; cloud minimum `G07` 적용
- build final: `cloud-G07`, `PLAN-cloud-G07.md`
- review closures:
- scope/context/verification/evidence/ownership/decision 모두 `true`
- 근거: active PLAN, SDD S01, selector source, passing 전체 suite와 failing 집중 재현을 한 checkout에서 대조할 수 있고 미해결 결정이 없다.
- review base route: `local-G05`
- review grade scores: scope_coupling=1, state_concurrency=1, blast_irreversibility=1, evidence_diagnosis=1, verification_complexity=1
- review capability floor: official review minimum `cloud-G09`
- review final: `cloud-G09`, `CODE_REVIEW-cloud-G09.md`
## 구현 체크리스트
- [ ] prior decision의 `stage`/`lane` type과 quota snapshot metadata/probe command를 success JSON 생성 전에 검증해 모든 malformed JSON을 안정적인 `SelectorInputError`/CLI exit 2 envelope로 정규화하고, 유효 initial 결과가 그대로 resume되는 direct API·CLI 회귀를 추가한다.
- [ ] `python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*execution_target*.py'`와 대상 파일 `py_compile`을 실행해 S01~S03 전체 회귀가 통과함을 확인한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.
### [REVIEW_API-1] Prior identity type과 quota roundtrip 검증
#### 문제
`agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py:304`는 `stage`/`lane` type guard 전에 set membership을 수행해 list/dict JSON이 raw `TypeError`로 샌다.
```python
# select_execution_target.py:304
if value["stage"] not in policy.VALID_STAGES:
...
if value["lane"] not in policy.VALID_LANES:
...
```
`agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py:326`은 quota snapshot의 `targets[]`만 검사하고, `:405`는 top-level metadata를 그대로 출력한다. 이 때문에 selector가 만든 initial JSON이 새 prior validator를 통과하지 못할 수 있다.
```python
# select_execution_target.py:405
return {
"snapshot_id": quota_snapshot.get("snapshot_id"),
...
"source": quota_snapshot.get("source", quota_probe_command),
"checked_at": quota_snapshot.get("checked_at"),
}
```
#### 해결 방법
- prior `stage`와 `lane`은 문자열인지 먼저 확인한 뒤 enum membership을 검사한다.
- quota snapshot의 optional `snapshot_id`/`checked_at`은 `null|string`, optional `source`는 non-empty string일 때만 허용한다. 누락은 현재 fallback/`None` 동작을 유지한다.
- `quota_probe_command`는 non-empty string으로 검증해 snapshot이 없거나 source가 생략된 cloud 출력도 유효한 `quota.source`를 보장한다.
- 위반은 `malformed_prior_decision`, `malformed_quota_snapshot`, 명시적 probe-command 입력 오류 중 해당 경계의 안정적인 `SelectorInputError`로 정규화한다.
```python
stage = value["stage"]
if not isinstance(stage, str) or stage not in policy.VALID_STAGES:
raise SelectorInputError(...)
if "source" in snapshot:
_require_non_empty_string(snapshot, "source", "quota_snapshot", code)
```
#### 수정 파일 및 체크리스트
- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py` — prior identity type guard와 quota metadata/probe command validator를 추가한다.
- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py` — unhashable prior, malformed quota metadata/probe, 유효 initial→resume roundtrip의 direct/CLI 회귀를 추가한다.
#### 테스트 작성
작성한다.
- `test_resume_rejects_unhashable_stage_and_lane_types`: valid initial을 복제해 stage=list, lane=dict를 주입하고 direct API가 `malformed_prior_decision`인지 확인한다.
- `test_quota_metadata_is_validated_before_initial_output`: numeric snapshot/checked time, empty·non-string source와 empty probe command가 success JSON을 만들지 않는지 확인한다.
- `test_valid_quota_initial_output_resumes`: optional metadata가 생략되거나 유효한 snapshot을 사용한 initial 결과가 그대로 resume되는지 확인한다.
- `test_cli_malformed_identity_and_quota_metadata_use_json_error_envelope`: 대표 prior/quota/probe 위반이 stdout 없이 exit 2와 JSON stderr인지 확인한다.
- fixture는 `TemporaryDirectory`의 task/prior/quota JSON만 사용한다.
#### 중간 검증
```bash
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_select_execution_target.py'
```
기대 결과: 기존 nested variant와 새 prior/quota/probe malformed 입력이 안정적인 selector 오류가 되고, 모든 유효 initial JSON이 resume된다.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py` | REVIEW_API-1 |
| `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py` | REVIEW_API-1 |
## 최종 검증
```bash
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_*execution_target*.py'
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/tests/test_execution_target_policy.py \
agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py
```
기대 결과: S01의 완전한 selector JSON/invalid-input envelope와 S02/S03 시간·grade matrix가 모두 PASS하고 대상 Python 파일이 compile된다. Python unittest cache는 해당 없다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다.

View file

@ -0,0 +1,246 @@
<!-- task=m-agent-task-runtime-target-selector/02+01_quota_input plan=0 tag=API -->
# Code Review Reference - API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked, record the exact blocker, attempted commands/output, and resume condition only in implementation-owned evidence fields.
> Do not ask the user directly, present choices, call user-input tools, create control-plane stop files, or classify the next state.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-07-25
task=m-agent-task-runtime-target-selector/02+01_quota_input, plan=0, tag=API
## 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:
- `quota-input`: Go usage checker 기반 required-cap quota snapshot과 selector admission
- Completion mode: check-on-pass
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G09.md` → `code_review_cloud_G09_0.log`, `PLAN-cloud-G07.md` → `plan_cloud_G07_0.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-agent-task-runtime-target-selector/02+01_quota_input/`로 이동한다. WARN/FAIL이면 code-review skill이 요구하는 다음 filesystem state를 완전히 작성한다.
4. PASS이고 task group이 `m-agent-task-runtime-target-selector`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| API-1 Required-cap quota snapshot 정규화 | [x] |
| API-2 Hidden iop-node quota-probe JSON command | [x] |
| API-3 Selector S04 black-box 연결 | [x] |
## 구현 체크리스트
- [x] 기존 `UsageStatus`의 overall/model remaining을 required cap set에 맞춰 `available|exhausted|unknown` snapshot으로 정규화한다.
- [x] raw output을 노출하지 않는 hidden `iop-node quota-probe` JSON command를 추가하고 checker/parse 오류를 `unknown` evidence로 반환한다.
- [x] overall/model cap 0·양수·누락·충돌, local unbounded, unknown work-unit 1회 admission을 Go와 selector black-box 테스트로 검증한다.
- [ ] Go preflight를 재실행하고 `go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node` 및 selector 테스트를 통과시킨다. 호스트 Go 표준 라이브러리의 `crypto/internal/fips140cache` 누락으로 두 Go package가 setup 단계에서 차단됐다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G09_0.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_0.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/`를 `agent-task/archive/YYYY/MM/m-agent-task-runtime-target-selector/02+01_quota_input/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-agent-task-runtime-target-selector`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-agent-task-runtime-target-selector/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이면 code-review skill의 판정에 맞는 다음 filesystem state를 작성하고 `complete.log`를 작성하지 않는다.
## 계획 대비 변경 사항
- `unknown`의 1회 admission은 selector의 최초 결정 후 `resume`이 persisted decision을 그대로 재사용하는 경계로 black-box 검증했다. 실제 admission batch 상태와 실행 결과에 따른 exhausted 갱신은 plan의 범위 제외인 dispatcher integration Epic에서 소유한다.
- selector 기본 probe source를 실제 hidden command 명칭인 `iop-node quota-probe`로 갱신했다.
## 주요 설계 결정
- `QuotaSnapshot`은 selector가 읽는 `targets[].adapter/target/status`를 포함하고, required cap별 normalized remaining evidence와 deterministic snapshot ID를 추가한다. `UsageStatus.RawOutput`과 checker error 원문은 직렬화하지 않는다.
- overall은 `DailyLimit`의 remaining percent를, model cap은 `model_usage_*` metadata의 case-insensitive exact-name match와 used percent를 사용한다. confirmed 0은 missing/invalid evidence보다 먼저 `exhausted`로 수렴한다.
- hidden command는 `--target`, `--command`, 반복 `--required-cap`, 선택 `--checked-at`만 받으며 command basename으로 selector adapter를 정규화한다. fake checker 주입으로 외부 CLI 실행 없이 schema/error 경로를 검증한다.
## 리뷰어를 위한 체크포인트
- required cap 하나의 confirmed 0이 누락 cap보다 우선해 exhausted가 되는가
- 모든 required cap 양수일 때만 available이고 누락·충돌·parse/checker 실패는 unknown인가
- raw PTY output, secret, generic stderr가 quota evidence JSON에 노출되거나 exhausted 판정에 쓰이지 않는가
- hidden command가 existing Go checker를 호출하며 parser를 Python/command에서 복제하지 않는가
- local target은 probe를 호출하지 않고 unbounded/not_applicable이며 unknown은 work unit별 1회만 admission되는가
- predecessor `01_selector_contract` evidence와 S04 black-box schema가 일치하는가
## 검증 결과
### Go 환경 프리플라이트
명령:
```bash
command -v go && readlink -f "$(command -v go)" && go version && go env GOROOT
```
실제 stdout/stderr:
```text
/config/.local/bin/go
/config/opt/go/bin/go
go version go1.26.2 linux/arm64
/config/opt/go
```
### API-1 중간 검증
명령:
```bash
go test -count=1 ./apps/node/internal/adapters/cli/status
```
실제 stdout/stderr:
```text
FAIL iop/apps/node/internal/adapters/cli/status [setup failed]
# iop/apps/node/internal/adapters/cli/status
../../opt/go/src/crypto/ecdsa/ecdsa.go:27:2: package crypto/internal/fips140cache is not in std (/config/opt/go/src/crypto/internal/fips140cache)
FAIL
```
재개 조건: `/config/opt/go`의 표준 라이브러리 source를 go1.26.2 설치와 일치하도록 복구한 뒤 같은 명령을 재실행한다.
### API-2 중간 검증
명령:
```bash
go test -count=1 ./apps/node/cmd/node
```
실제 stdout/stderr:
```text
FAIL iop/apps/node/cmd/node [setup failed]
# iop/apps/node/cmd/node
../../opt/go/src/crypto/ecdsa/ecdsa.go:27:2: package crypto/internal/fips140cache is not in std (/config/opt/go/src/crypto/internal/fips140cache)
FAIL
```
재개 조건: `/config/opt/go`의 표준 라이브러리 source를 go1.26.2 설치와 일치하도록 복구한 뒤 같은 명령을 재실행한다.
### API-3 중간 검증
명령:
```bash
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_execution_target*.py'
```
실제 stdout/stderr:
```text
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_execution_target*.py'
.....
----------------------------------------------------------------------
Ran 5 tests in 0.000s
OK
python3 agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py -v
----------------------------------------------------------------------
Ran 30 tests in 0.413s
OK
```
### 최종 검증
명령:
```bash
go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_execution_target*.py'
```
실제 stdout/stderr:
```text
go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node
FAIL iop/apps/node/internal/adapters/cli/status [setup failed]
FAIL iop/apps/node/cmd/node [setup failed]
../../opt/go/src/crypto/ecdsa/ecdsa.go:27:2: package crypto/internal/fips140cache is not in std (/config/opt/go/src/crypto/internal/fips140cache)
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_execution_target*.py'
Ran 5 tests in 0.000s
OK
python3 agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py -v
Ran 30 tests in 0.413s
OK
git diff --check
exit 0
```
Go 검증은 호스트 설치 차단으로 아직 통과하지 못했다. 외부 provider/credential을 호출하지 않았다.
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) |
| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS |
| Archive Evidence Snapshot | Fixed at stub creation from plan when present | Implementing agent uses it as default prior-loop context; read only the specific archive files cited there when more detail is required |
| Agent UI Completion | Mixed | Present only for plan-required agent-ui code work; implementing agent fills actual evidence, review agent applies `구현됨` status/evidence update on PASS and copies the section into `complete.log` |
| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` → `[x]` only |
| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` → `[x]` only |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry |
| 코드리뷰 결과 | Review agent appends | Not included in stub |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Fail
- completeness: Fail
- test coverage: Fail
- API contract: Fail
- code quality: Fail
- implementation deviation: Fail
- verification trust: Fail
- spec conformance: Fail
- 발견된 문제:
- Required — `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py:455`: quota가 첫 후보를 `exhausted`/`ineligible`로 판정해도 `selected`는 무조건 `decision.candidates[0]`을 반환한다. 주간 `local-G07`에서 Gemini가 exhausted이고 Laguna가 eligible인 재현에서도 exhausted Gemini가 선택되어 SDD S04의 admission 계약을 위반한다. 첫 eligible 후보를 선택하고, eligible 후보가 없을 때는 exhausted target을 선택하지 않는 결정적 no-eligible 결과를 반환하며 두 경로를 회귀 테스트로 고정해야 한다.
- Required — `apps/node/internal/adapters/cli/status/quota.go:148`: `strconv.ParseFloat` 결과의 유한성을 확인하지 않아 `NaN%`가 범위 검사에서 빠지고 `available`로 분류된다. 이어지는 JSON 인코딩은 NaN을 지원하지 않아 hidden probe가 `unknown` snapshot 대신 실패한다. `math.IsNaN`/`math.IsInf`를 거부하고 overall/model 양쪽 비유한 percent가 `unknown`으로 수렴하는 테스트를 추가해야 한다.
- Required — `apps/node/cmd/node/quota_probe.go:37`: Cobra의 required flag 표시는 명시적으로 전달된 빈 `--target=`/`--command=` 값을 막지 않으므로 selector가 거부하는 빈 target 또는 비정상 adapter snapshot을 출력할 수 있다. checker 호출 전에 두 값을 trim·non-empty 검증하고 selector-compatible 입력 오류 테스트를 추가해야 한다.
- Required — `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/CODE_REVIEW-cloud-G09.md:55`: 필수 fresh Go 검증이 구현 및 리뷰 양쪽에서 `crypto/internal/fips140cache is not in std` setup 실패로 끝나 API-1/API-2의 컴파일과 회귀를 신뢰할 수 없다. `/config/opt/go` 표준 라이브러리를 go1.26.2 바이너리와 일치하도록 복구한 뒤 지정된 두 패키지 테스트를 `-count=1`로 통과시켜 실제 출력을 기록해야 한다.
- 다음 단계: FAIL 후속을 plan 스킬의 `prepare-follow-up` 모드와 격리 재라우팅으로 준비한다.

View file

@ -0,0 +1,257 @@
<!-- task=m-agent-task-runtime-target-selector/02+01_quota_input plan=1 tag=REVIEW_API -->
# Code Review Reference - REVIEW_API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked, record the exact blocker, attempted commands/output, and resume condition only in implementation-owned evidence fields.
> Do not ask the user directly, present choices, call user-input tools, create control-plane stop files, or classify the next state.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-07-25
task=m-agent-task-runtime-target-selector/02+01_quota_input, plan=1, tag=REVIEW_API
## 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:
- `quota-input`: Go usage checker 기반 required-cap quota snapshot과 selector admission
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Prior task: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input`
- Archived plan: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/plan_cloud_G07_0.log`
- Archived review: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/code_review_cloud_G09_0.log`
- Verdict: `FAIL`
- Findings: Required 4, Suggested 0, Nit 0
- exhausted/ineligible 첫 후보가 계속 `selected`로 반환된다.
- `NaN%`가 유효한 available evidence로 통과한 뒤 JSON 인코딩을 깨뜨린다.
- 명시적 빈 `--target=`/`--command=`가 selector-invalid snapshot으로 이어질 수 있다.
- 필수 Go 패키지 테스트가 `crypto/internal/fips140cache` 누락으로 setup 실패했다.
- Affected files: `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`, `apps/node/internal/adapters/cli/status/quota.go`, `apps/node/internal/adapters/cli/status/quota_test.go`, `apps/node/cmd/node/quota_probe.go`, `apps/node/cmd/node/quota_probe_test.go`
- Verification evidence:
- focused selector reproducer: selected=`agy/Gemini 3.6 Flash Medium`, candidates=`agy exhausted/ineligible`, `pi not_applicable/eligible`
- Python selector/policy: 5 discovery tests와 30 selector tests PASS
- Go packages: setup FAIL; `/config/opt/go/src/crypto/internal/fips140cache` missing
- Roadmap carryover: SDD S04와 Milestone Task `quota-input`은 미완료 상태를 유지한다.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G09.md` → `code_review_cloud_G09_1.log`, `PLAN-cloud-G07.md` → `plan_cloud_G07_1.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-agent-task-runtime-target-selector/02+01_quota_input/`로 이동한다. WARN/FAIL이면 code-review skill이 요구하는 다음 filesystem state를 완전히 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| REVIEW_API-1 Exhausted 후보 선택 차단 | [x] |
| REVIEW_API-2 Quota producer 유한성 및 identity 검증 | [x] |
| REVIEW_API-3 Fresh 검증 신뢰 회복 | [ ] host Go/GOROOT 표준 라이브러리 불일치로 차단 |
## 구현 체크리스트
- [x] exhausted/ineligible 첫 후보를 `selected`에서 제외하고, eligible 대체 후보 선택과 no-eligible 결과를 selector 회귀 테스트로 고정한다.
- [x] overall/model 비유한 percent를 `unknown`으로 정규화하고 명시적 빈 target/command를 checker 호출 전 입력 오류로 거부하는 Go 테스트를 추가한다.
- [ ] 동일 Go 환경 preflight를 통과한 뒤 fresh Go package 테스트와 selector/policy 테스트 및 정적 검증을 모두 통과시킨다. host Go/GOROOT 표준 라이브러리 불일치로 차단됨.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G09_1.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_1.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/`를 `agent-task/archive/YYYY/MM/m-agent-task-runtime-target-selector/02+01_quota_input/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-agent-task-runtime-target-selector/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이면 code-review skill의 판정에 맞는 다음 filesystem state를 작성하고 `complete.log`를 작성하지 않는다.
## 계획 대비 변경 사항
없음. 계획한 selector admission 및 Go producer 입력 경계를 그대로 구현했다. host Go 설치는 repository 범위 밖이므로 다른 Go binary 또는 `GOTOOLCHAIN`으로 우회하지 않았다.
## 주요 설계 결정
- selector는 policy 순위를 보존하면서 `eligibility=eligible`인 첫 후보만 `selected`로 채운다. 후보가 모두 `exhausted`면 실행 가능한 target을 만들지 않고 `no_eligible_target` JSON 오류로 종료한다.
- non-finite percent(`NaN`, `Inf`, `-Inf`)는 remaining evidence가 아니라 `unknown`으로 정규화해 JSON encoder에 비유한 float가 도달하지 않게 했다.
- `quota-probe`는 target과 command를 trim한 뒤 둘 중 하나라도 비면 usage checker 호출 전에 입력 오류로 반환한다.
## 리뷰어를 위한 체크포인트
- exhausted first candidate가 selected에 남지 않고 policy order의 첫 eligible 후보로 이동하는가
- 모든 candidate가 exhausted일 때 실행 가능한 selected를 만들지 않고 안정적인 no-eligible 오류로 종료하는가
- unknown은 eligible 1회 admission 의미를 유지하고 local은 unbounded/not_applicable인가
- overall/model의 NaN/Inf가 available 또는 encoder failure가 아니라 unknown인가
- 빈 target/command가 checker 호출 전에 입력 오류로 종료되는가
- raw PTY output, checker error, generic stderr가 quota JSON에 노출되거나 exhausted 근거로 쓰이지 않는가
- 동일 Go/GOROOT source에서 fresh Go package tests가 실제로 실행됐는가
## 검증 결과
실행한 각 명령의 실제 stdout/stderr를 아래 코드 블록에 기록한다. 명령을 바꾸면 `계획 대비 변경 사항`에 대체 명령과 이유를 먼저 적는다.
### REVIEW_API-1 중간 검증
명령:
```bash
python3 agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py -v -k SelectorQuotaRepresentationTests
```
실제 stdout/stderr:
```text
test_all_candidates_exhausted_returns_no_eligible_target (__main__.SelectorQuotaRepresentationTests.test_all_candidates_exhausted_returns_no_eligible_target) ... ok
test_exhausted_primary_selects_eligible_alternate (__main__.SelectorQuotaRepresentationTests.test_exhausted_primary_selects_eligible_alternate) ... ok
test_generic_stderr_is_not_quota_evidence (__main__.SelectorQuotaRepresentationTests.test_generic_stderr_is_not_quota_evidence) ... ok
test_injected_snapshot_is_reflected (__main__.SelectorQuotaRepresentationTests.test_injected_snapshot_is_reflected) ... ok
test_local_route_does_not_call_probe (__main__.SelectorQuotaRepresentationTests.test_local_route_does_not_call_probe) ... ok
test_quota_probe_tri_state (__main__.SelectorQuotaRepresentationTests.test_quota_probe_tri_state) ... ok
test_quota_representation_without_snapshot (__main__.SelectorQuotaRepresentationTests.test_quota_representation_without_snapshot) ... ok
test_unknown_is_admitted_once_per_work_unit (__main__.SelectorQuotaRepresentationTests.test_unknown_is_admitted_once_per_work_unit) ... ok
----------------------------------------------------------------------
Ran 8 tests in 0.042s
OK
```
### REVIEW_API-2 중간 검증
명령:
```bash
go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node
```
실제 stdout/stderr:
```text
# iop/apps/node/internal/adapters/cli/status
../../opt/go/src/crypto/ecdsa/ecdsa.go:27:2: package crypto/internal/fips140cache is not in std (/config/opt/go/src/crypto/internal/fips140cache)
# iop/apps/node/cmd/node
../../opt/go/src/crypto/ecdsa/ecdsa.go:27:2: package crypto/internal/fips140cache is not in std (/config/opt/go/src/crypto/internal/fips140cache)
FAIL iop/apps/node/internal/adapters/cli/status [setup failed]
FAIL iop/apps/node/cmd/node [setup failed]
FAIL
```
### REVIEW_API-3 Go 환경 프리플라이트
명령:
```bash
command -v go && readlink -f "$(command -v go)" && go version && go env GOROOT
test -d "$(go env GOROOT)/src/crypto/internal/fips140cache"
```
실제 stdout/stderr:
```text
/config/.local/bin/go
/config/opt/go/bin/go
go version go1.26.2 linux/arm64
/config/opt/go
`test -d "$(go env GOROOT)/src/crypto/internal/fips140cache"` exited 1 because the directory is absent. Resume after the `/config/opt/go` standard-library source is restored to match go1.26.2; then rerun this preflight and the required fresh Go package test without changing toolchains.
```
### 최종 검증
명령:
```bash
command -v go && readlink -f "$(command -v go)" && go version && go env GOROOT
test -d "$(go env GOROOT)/src/crypto/internal/fips140cache"
go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_execution_target*.py'
python3 agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.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/tests/test_execution_target_policy.py agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py
gofmt -d apps/node/internal/adapters/cli/status/quota.go apps/node/internal/adapters/cli/status/quota_test.go apps/node/cmd/node/quota_probe.go apps/node/cmd/node/quota_probe_test.go apps/node/cmd/node/main.go
git diff --check
```
실제 stdout/stderr:
```text
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_execution_target*.py'
.....
----------------------------------------------------------------------
Ran 5 tests in 0.000s
OK
python3 agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py -v
----------------------------------------------------------------------
Ran 32 tests in 0.415s
OK
python3 -m py_compile ...
gofmt -d ...
git diff --check
All three static commands exited 0 with no stdout/stderr.
Go preflight and `go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node` remain blocked as recorded above; this is not a pass. Required resume condition: restore `/config/opt/go/src/crypto/internal/fips140cache` in the host Go 1.26.2 GOROOT, retain `/config/.local/bin/go` as the first PATH Go binary, then rerun the complete final verification command block.
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) |
| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS |
| Archive Evidence Snapshot | Fixed at stub creation from plan when present | Implementing agent uses it as default prior-loop context; read only the specific archive files cited there when more detail is required |
| Agent UI Completion | Mixed | Present only for plan-required agent-ui code work; implementing agent fills actual evidence, review agent applies `구현됨` status/evidence update on PASS and copies the section into `complete.log` |
| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` → `[x]` only |
| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` → `[x]` only |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry |
| 코드리뷰 결과 | Review agent appends | Not included in stub |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Pass
- completeness: Fail
- test coverage: Fail
- API contract: Pass
- code quality: Pass
- implementation deviation: Pass
- verification trust: Fail
- spec conformance: Fail
- 발견된 문제:
- Required — `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/CODE_REVIEW-cloud-G09.md:72`: 필수 fresh Go 검증을 리뷰에서 재실행했지만 `/config/opt/go/src/crypto/internal/fips140cache`가 여전히 없어 두 대상 패키지가 setup 단계에서 실패했다. selector 32개 테스트, policy 5개 테스트, `py_compile`, `gofmt -d`, `git diff --check`는 통과했고 이전 세 코드 결함의 수정도 소스에서 확인했지만, 새 Go producer/command 코드의 컴파일과 회귀 assertion이 실행되지 않아 SDD S04 Evidence Map과 plan의 통합 검증을 충족하지 못한다. `/config/opt/go`를 `/config/opt/go/bin/go`의 go1.26.2 표준 라이브러리와 일치하도록 복구한 뒤 동일 PATH/GOROOT에서 `go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node`와 전체 최종 검증을 통과시켜야 한다.
- 다음 단계: FAIL 후속을 plan 스킬의 `prepare-follow-up` 모드와 격리 재라우팅으로 준비한다.

View file

@ -0,0 +1,244 @@
<!-- task=m-agent-task-runtime-target-selector/02+01_quota_input plan=2 tag=REVIEW_REVIEW_API -->
# Code Review Reference - REVIEW_REVIEW_API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked, record the exact blocker, attempted commands/output, and resume condition only in implementation-owned evidence fields.
> Do not ask the user directly, present choices, call user-input tools, create control-plane stop files, or classify the next state.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-07-25
task=m-agent-task-runtime-target-selector/02+01_quota_input, plan=2, tag=REVIEW_REVIEW_API
## 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:
- `quota-input`: Go usage checker 기반 required-cap quota snapshot과 selector admission
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Prior task: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input`
- Archived plan: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/plan_cloud_G07_1.log`
- Archived review: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/code_review_cloud_G09_1.log`
- Verdict: `FAIL`
- Findings: Required 1, Suggested 0, Nit 0
- `/config/opt/go/src/crypto/internal/fips140cache` 누락으로 필수 Go 두 패키지가 setup 단계에서 실패해 새 producer/command의 컴파일과 회귀 assertion evidence가 없다.
- Affected files: `apps/node/internal/adapters/cli/status/quota.go`, `apps/node/internal/adapters/cli/status/quota_test.go`, `apps/node/cmd/node/quota_probe.go`, `apps/node/cmd/node/quota_probe_test.go`, `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`
- Verification evidence:
- host Go: `/config/.local/bin/go` → `/config/opt/go/bin/go`, `go1.26.2 linux/arm64`, `GOROOT=/config/opt/go`
- Go preflight와 두 package test: FAIL, `crypto/internal/fips140cache` missing
- Python policy 5개와 selector 32개 테스트: PASS
- `py_compile`, `gofmt -d`, `git diff --check`: PASS
- Roadmap carryover: SDD S04와 Milestone Task `quota-input`은 fresh Go evidence가 생길 때까지 미완료 상태를 유지한다.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G09.md` → `code_review_cloud_G09_2.log`, `PLAN-local-G02.md` → `plan_local_G02_2.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-agent-task-runtime-target-selector/02+01_quota_input/`로 이동한다. WARN/FAIL이면 code-review skill이 요구하는 다음 filesystem state를 완전히 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| REVIEW_REVIEW_API-1 Fresh Go 및 S04 Evidence Closure | [ ] — `fips140cache` 누락으로 S04 evidence 미완료 |
## 구현 체크리스트
- [ ] 동일 PATH Go와 `GOROOT`에서 표준 라이브러리 preflight를 통과한 뒤 `go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node`를 통과시키고 실제 출력을 기록한다.
- [x] Python policy/selector 회귀와 `py_compile`, `gofmt -d`, `git diff --check`를 다시 통과시켜 Go evidence와 같은 checkout의 최종 검증을 완성한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G09_2.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G02_2.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/`를 `agent-task/archive/YYYY/MM/m-agent-task-runtime-target-selector/02+01_quota_input/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-agent-task-runtime-target-selector/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이면 code-review skill의 판정에 맞는 다음 filesystem state를 작성하고 `complete.log`를 작성하지 않는다.
## 계획 대비 변경 사항
- preflight 실패로 Go package test는 setup 단계에서 종료됐다. Python regression과 정적 검증은 별도로 실행해 PASS 출력을 기록했다.
- repo source 파일 수정 없음 — `/config/opt/go` GOROOT source 수정은 계획 범위 밖으로 명시됨.
- Go 환경 복구(표준 라이브러리 source 일치화)는 별도 작업 영역이며, 이 plan에서는 해당 환경이 이미 복구되었다고 가정하고 작성됨. 실제 환경에서는 복구 전 상태로 차단됨.
## 주요 설계 결정
- host Go identity(`/config/.local/bin/go` → `/config/opt/go/bin/go`, go1.26.2 linux/arm64, GOROOT=/config/opt/go)를 유지하며, 다른 toolchain 설치·선택이나 GOTOOLCHAIN 우회를 수행하지 않음.
- `/config/opt/go/src/crypto/internal/fips140cache` 패키지 복구는 GOROOT source 수정에 해당하여 계획 범위(`구현 에이전트는 다른 toolchain 설치·선택이나 GOROOT source 수정을 수행하지 않는다`) 밖이므로 수행하지 않음.
- S04 Evidence Closure는 fresh Go evidence가 생길 때까지 미완료 상태로 유지됨.
## 리뷰어를 위한 체크포인트
- 동일 `/config/.local/bin/go` → `/config/opt/go/bin/go`와 `GOROOT=/config/opt/go` identity를 유지했는가
- `crypto/internal/fips140cache` preflight가 실제로 통과했는가
- 두 Go package test가 setup이 아니라 compile/assertion 실행까지 fresh PASS했는가
- selector/policy 테스트와 정적 검증이 같은 checkout에서 모두 PASS했는가
- 다른 Go binary, `GOTOOLCHAIN`, repo-local tool artifact로 우회하지 않았는가
## 검증 결과
실행한 각 명령의 실제 stdout/stderr를 아래 코드 블록에 기록한다. 명령을 바꾸면 `계획 대비 변경 사항`에 대체 명령과 이유를 먼저 적는다.
### REVIEW_REVIEW_API-1 중간 검증
명령:
```bash
command -v go && readlink -f "$(command -v go)" && go version && go env GOROOT
test -d "$(go env GOROOT)/src/crypto/internal/fips140cache"
go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node
```
실제 stdout/stderr:
```text
/config/.local/bin/go
/config/opt/go/bin/go
go version go1.26.2 linux/arm64
/config/opt/go
FAIL: fips140cache missing
# iop/apps/node/internal/adapters/cli/status
../../opt/go/src/crypto/ecdsa/ecdsa.go:27:2: package crypto/internal/fips140cache is not in std (/config/opt/go/src/crypto/internal/fips140cache)
FAIL iop/apps/node/internal/adapters/cli/status [setup failed]
# iop/apps/node/cmd/node
../../opt/go/src/crypto/ecdsa/ecdsa.go:27:2: package crypto/internal/fips140cache is not in std (/config/opt/go/src/crypto/internal/fips140cache)
FAIL iop/apps/node/cmd/node [setup failed]
FAIL
```
차단 원인: `/config/opt/go/src/crypto/internal/fips140cache` 디렉터리가 존재하지 않아 `crypto/ecdsa` 패키지가 setup 단계에서 실패.
재개 조건: `/config/opt/go` 표준 라이브러리 source를 go1.26.2 binary와 일치하도록 복구 (fips140cache 패키지 소스 추가).
### 최종 검증
명령:
```bash
cd /config/workspace/iop && python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_execution_target*.py'
cd /config/workspace/iop && python3 agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py -v
cd /config/workspace/iop && 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/tests/test_execution_target_policy.py agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py
cd /config/workspace/iop && gofmt -d apps/node/internal/adapters/cli/status/quota.go apps/node/internal/adapters/cli/status/quota_test.go apps/node/cmd/node/quota_probe.go apps/node/cmd/node/quota_probe_test.go apps/node/cmd/node/main.go
cd /config/workspace/iop && git diff --check
```
실제 stdout/stderr:
```
.....
----------------------------------------------------------------------
Ran 5 tests in 0.000s
OK
test_cli_input_error_is_stderr_json_without_stdout (__main__.SelectorContractTests.test_cli_input_error_is_stderr_json_without_stdout) ... ok
test_deterministic_output_for_fixed_clock (__main__.SelectorContractTests.test_deterministic_output_for_fixed_clock) ... ok
test_failover_transition_is_unsupported (__main__.SelectorContractTests.test_failover_transition_is_unsupported) ... ok
test_invalid_filenames_and_grades_rejected (__main__.SelectorContractTests.test_invalid_filenames_and_grades_rejected) ... ok
test_malformed_header_rejected (__main__.SelectorContractTests.test_malformed_header_rejected) ... ok
test_repeated_input_is_byte_stable (__main__.SelectorContractTests.test_repeated_input_is_byte_stable) ... ok
test_resume_pins_prior_target_across_time (__main__.SelectorContractTests.test_resume_pins_prior_target_across_time) ... ok
test_resume_requires_matching_prior_decision (__main__.SelectorContractTests.test_resume_requires_matching_prior_decision) ... ok
test_stage_inference_and_mismatch (__main__.SelectorContractTests.test_stage_inference_and_mismatch) ... ok
test_work_unit_id_stable_across_body_changes (__main__.SelectorContractTests.test_work_unit_id_stable_across_body_changes) ... ok
test_worker_contract_shape_and_types (__main__.SelectorContractTests.test_worker_contract_shape_and_types) ... ok
test_cli_malformed_identity_and_quota_metadata_use_json_error_envelope (__main__.SelectorIdentityAndQuotaRoundtripTests.test_cli_malformed_identity_and_quota_metadata_use_json_error_envelope) ... ok
test_quota_metadata_is_validated_before_initial_output (__main__.SelectorIdentityAndQuotaRoundtripTests.test_quota_metadata_is_validated_before_initial_output) ... ok
test_resume_rejects_unhashable_stage_and_lane_types (__main__.SelectorIdentityAndQuotaRoundtripTests.test_resume_rejects_unhashable_stage_and_lane_types) ... ok
test_valid_quota_initial_output_resumes (__main__.SelectorIdentityAndQuotaRoundtripTests.test_valid_quota_initial_output_resumes) ... ok
test_cli_deeply_malformed_prior_uses_json_error_envelope (__main__.SelectorNestedInputContractTests.test_cli_deeply_malformed_prior_uses_json_error_envelope) ... ok
test_cli_malformed_prior_uses_json_error_envelope (__main__.SelectorNestedInputContractTests.test_cli_malformed_prior_uses_json_error_envelope) ... ok
test_cli_malformed_quota_uses_json_error_envelope (__main__.SelectorNestedInputContractTests.test_cli_malformed_quota_uses_json_error_envelope) ... ok
test_resume_rejects_incomplete_selected_schema (__main__.SelectorNestedInputContractTests.test_resume_rejects_incomplete_selected_schema) ... ok
test_resume_rejects_malformed_nested_prior_schema_variants (__main__.SelectorNestedInputContractTests.test_resume_rejects_malformed_nested_prior_schema_variants) ... ok
test_all_candidates_exhausted_returns_no_eligible_target (__main__.SelectorQuotaRepresentationTests.test_all_candidates_exhausted_returns_no_eligible_target) ... ok
test_exhausted_primary_selects_eligible_alternate (__main__.SelectorQuotaRepresentationTests.test_exhausted_primary_selects_eligible_alternate) ... ok
test_generic_stderr_is_not_quota_evidence (__main__.SelectorQuotaRepresentationTests.test_generic_stderr_is_not_quota_evidence) ... ok
test_injected_snapshot_is_reflected (__main__.SelectorQuotaRepresentationTests.test_injected_snapshot_is_reflected) ... ok
test_local_route_does_not_call_probe (__main__.SelectorQuotaRepresentationTests.test_local_route_does_not_call_probe) ... ok
test_quota_probe_tri_state (__main__.SelectorQuotaRepresentationTests.test_quota_probe_tri_state) ... ok
test_quota_representation_without_snapshot (__main__.SelectorQuotaRepresentationTests.test_quota_representation_without_snapshot) ... ok
test_unknown_is_admitted_once_per_work_unit (__main__.SelectorQuotaRepresentationTests.test_unknown_is_admitted_once_per_work_unit) ... ok
test_candidate_rank_is_consecutive_and_time_ordered (__main__.SelectorRouteMatrixTests.test_candidate_rank_is_consecutive_and_time_ordered) ... ok
test_kst_boundary_routes_through_selector (__main__.SelectorRouteMatrixTests.test_kst_boundary_routes_through_selector) ... ok
test_review_route_matrix_is_codex (__main__.SelectorRouteMatrixTests.test_review_route_matrix_is_codex) ... ok
test_worker_route_matrix_through_selector (__main__.SelectorRouteMatrixTests.test_worker_route_matrix_through_selector) ... ok
----------------------------------------------------------------------
Ran 32 tests in 0.411s
OK
py_compile: PASS
gofmt: PASS
git diff --check: PASS
```
참고: Go preflight(`test -d "$(go env GOROOT)/src/crypto/internal/fips140cache"`)가 실패하여 `go test -count=1 ./apps/node/...`는 보류. Python 5개 policy 테스트, 32개 selector 테스트, py_compile, gofmt, git diff --check는 모두 fresh PASS.
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) |
| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS |
| Archive Evidence Snapshot | Fixed at stub creation from plan when present | Implementing agent uses it as default prior-loop context; read only the specific archive files cited there when more detail is required |
| Agent UI Completion | Mixed | Present only for plan-required agent-ui code work; implementing agent fills actual evidence, review agent applies `구현됨` status/evidence update on PASS and copies the section into `complete.log` |
| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` → `[x]` only |
| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` → `[x]` only |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry |
| 코드리뷰 결과 | Review agent appends | Not included in stub |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Pass
- completeness: Fail
- test coverage: Fail
- API contract: Pass
- code quality: Pass
- implementation deviation: Pass
- verification trust: Fail
- spec conformance: Fail
- 발견된 문제:
- Required — `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/CODE_REVIEW-cloud-G09.md:137`: 리뷰에서 동일 PATH/GOROOT로 필수 검증을 재실행했지만 `/config/opt/go/src/crypto/internal/fips140cache`가 여전히 없어 두 Go 패키지가 setup 단계에서 실패했다. Python policy 5개·selector 32개 테스트와 `py_compile`, `gofmt -d`, `git diff --check`는 통과했고 이전 동작 결함의 수정도 소스에서 확인했지만, 새 Go quota producer/command의 컴파일과 회귀 assertion evidence가 없어 plan의 통합 검증과 SDD S04 Evidence Map을 충족하지 못한다. `/config/opt/go` 표준 라이브러리 source를 `/config/opt/go/bin/go`의 go1.26.2와 일치하도록 복구한 뒤 같은 PATH/GOROOT에서 `go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node`와 전체 최종 검증을 통과시켜야 한다.
- 다음 단계: FAIL 후속을 plan 스킬의 `prepare-follow-up` 모드와 격리 재라우팅으로 준비한다.

View file

@ -0,0 +1,278 @@
<!-- task=m-agent-task-runtime-target-selector/02+01_quota_input plan=3 tag=REVIEW_REVIEW_REVIEW_API -->
# Code Review Reference - REVIEW_REVIEW_REVIEW_API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked, record the exact blocker, attempted commands/output, and resume condition only in implementation-owned evidence fields.
> Do not ask the user directly, present choices, call user-input tools, create control-plane stop files, or classify the next state.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-07-25
task=m-agent-task-runtime-target-selector/02+01_quota_input, plan=3, tag=REVIEW_REVIEW_REVIEW_API
## 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:
- `quota-input`: Go usage checker 기반 required-cap quota snapshot과 selector admission
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Prior task: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input`
- Archived plan: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/plan_local_G02_2.log`
- Archived review: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/code_review_cloud_G09_2.log`
- Verdict: `FAIL`
- Findings: Required 1, Suggested 0, Nit 0
- `/config/opt/go/src/crypto/internal/fips140cache` 누락으로 필수 Go 두 패키지가 setup 단계에서 실패해 새 producer/command의 컴파일과 회귀 assertion evidence가 없다.
- Affected files: `apps/node/internal/adapters/cli/status/quota.go`, `apps/node/internal/adapters/cli/status/quota_test.go`, `apps/node/cmd/node/quota_probe.go`, `apps/node/cmd/node/quota_probe_test.go`, `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`
- Verification evidence:
- host Go: `/config/.local/bin/go` → `/config/opt/go/bin/go`, `go1.26.2 linux/arm64`, `GOROOT=/config/opt/go`
- Go preflight와 두 package test: FAIL, `crypto/internal/fips140cache` missing
- Python policy 5개와 selector 32개 테스트: PASS
- `py_compile`, `gofmt -d`, `git diff --check`: PASS
- Roadmap carryover: SDD S04와 Milestone Task `quota-input`은 fresh Go evidence가 생길 때까지 미완료 상태를 유지한다.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G09.md` → `code_review_cloud_G09_3.log`, `PLAN-local-G02.md` → `plan_local_G02_3.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-agent-task-runtime-target-selector/02+01_quota_input/`로 이동한다. WARN/FAIL이면 code-review skill이 요구하는 다음 filesystem state를 완전히 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| REVIEW_REVIEW_REVIEW_API-1 Fresh Go 및 S04 Evidence Closure | [ ] Go preflight PASS, 필수 Go test assertion FAIL |
## 구현 체크리스트
- [ ] 동일 PATH Go와 `GOROOT`에서 표준 라이브러리 preflight를 통과한 뒤 `go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node`를 통과시키고 실제 출력을 기록한다.
- Go preflight: PASS (`crypto/internal/fips140cache` EXISTS)
- Go package test: `iop/apps/node/internal/adapters/cli/status` FAIL, `iop/apps/node/cmd/node` PASS
- FAIL 상세: `TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure/missing` — `status = "available", want unknown`
- plan 범위 결정에 따라 source 수정 없이 failure evidence를 review stub에 기록
- [x] Python policy/selector 회귀와 `py_compile`, `gofmt -d`, `git diff --check`를 다시 통과시켜 Go evidence와 같은 checkout의 최종 검증을 완성한다.
- Python policy test: 5 tests OK
- Python selector test: 32 tests OK
- py_compile: OK
- gofmt -d: OK
- git diff --check: OK
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G09_3.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G02_3.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [ ] PASS이면 active task 디렉터리 `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/`를 `agent-task/archive/YYYY/MM/m-agent-task-runtime-target-selector/02+01_quota_input/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [ ] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-agent-task-runtime-target-selector/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [x] WARN/FAIL이면 code-review skill의 판정에 맞는 다음 filesystem state를 작성하고 `complete.log`를 작성하지 않는다.
## 계획 대비 변경 사항
계획에 명시된 최종 검증 명령과 동일한 명령만 실행했다. repo source 파일 수정 없음.
## 주요 설계 결정
- host Go identity(`/config/.local/bin/go` → `/config/opt/go/bin/go`, go1.26.2 linux/arm64, GOROOT=/config/opt/go)를 변경하지 않고 preflight와 fresh 검증을 실행했다.
- `crypto/internal/fips140cache` 디렉터리가 복구되어 Go standard library preflight를 통과했다.
- `go test -count=1 ./apps/node/internal/adapters/cli/status`에서 `TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure/missing` 테스트가 실패했다:
- 테스트: `NormalizeQuotaSnapshot("agy", "Gemini", ["overall"], checkedAt, &UsageStatus{DailyLimit: "50%"}, nil)`
- 실제: `snapshot.Targets[0].Status = "available"` (DailyLimit "50%"가 parsePercent를 통과하여 remaining=50 > 0)
- 기대: `"unknown"`
- 원인 분석: `resolveQuotaCap`의 `overall` 분기에서 `quotaCapFromRemaining` → `quotaCapFromNumber` 경로를 거쳐 DailyLimit이 유효하면 무조건 `available`를 반환한다. `UsageStatus.Metadata`가 nil인 경우에도 `unknown`으로 처리하는 로직이 구현되어 있지 않다.
- plan의 범위 결정(`fresh 검증에서 새 source defect이 드러나면 추측 수정하지 않고 실제 failure evidence를 review stub에 기록`)에 따라 source 수정 없이 evidence만 기록한다.
- `iop/apps/node/cmd/node` package test는 PASS.
- Python policy 5개, selector 32개 테스트 모두 PASS.
- 정적 검사(py_compile, gofmt -d, git diff --check) 모두 PASS.
## 리뷰어를 위한 체크포인트
- 동일 `/config/.local/bin/go` → `/config/opt/go/bin/go`와 `GOROOT=/config/opt/go` identity를 유지했는가
- `crypto/internal/fips140cache` preflight가 실제로 통과했는가
- 두 Go package test가 setup이 아니라 compile/assertion 실행까지 fresh PASS했는가
- selector/policy 테스트와 정적 검증이 같은 checkout에서 모두 PASS했는가
- 다른 Go binary, `GOTOOLCHAIN`, repo-local tool artifact로 우회하지 않았는가
## 검증 결과
실행한 각 명령의 실제 stdout/stderr를 아래 코드 블록에 기록한다. 명령을 바꾸면 `계획 대비 변경 사항`에 대체 명령과 이유를 먼저 적는다.
### REVIEW_REVIEW_REVIEW_API-1 중간 검증
명령:
```bash
command -v go && readlink -f "$(command -v go)" && go version && go env GOROOT
test -d "$(go env GOROOT)/src/crypto/internal/fips140cache"
go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node
```
실제 stdout/stderr:
```text
=== Go preflight ===
/config/.local/bin/go
/config/opt/go/bin/go
go version go1.26.2 linux/arm64
/config/opt/go
--- fips140cache ---
EXISTS
=== Go package tests ===
--- FAIL: TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure (0.00s)
--- FAIL: TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure/missing (0.00s)
quota_test.go:60: status = "available", want unknown
FAIL
FAIL\tiop/apps/node/internal/adapters/cli/status\t39.728s
ok\tiop/apps/node/cmd/node\t0.009s
FAIL
EXIT_CODE=1
```
### 최종 검증
명령:
```bash
command -v go && readlink -f "$(command -v go)" && go version && go env GOROOT
test -d "$(go env GOROOT)/src/crypto/internal/fips140cache"
go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_execution_target*.py'
python3 agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.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/tests/test_execution_target_policy.py agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py
gofmt -d apps/node/internal/adapters/cli/status/quota.go apps/node/internal/adapters/cli/status/quota_test.go apps/node/cmd/node/quota_probe.go apps/node/cmd/node/quota_probe_test.go apps/node/cmd/node/main.go
git diff --check
```
실제 stdout/stderr:
```text
=== Go preflight ===
/config/.local/bin/go
/config/opt/go/bin/go
go version go1.26.2 linux/arm64
/config/opt/go
--- fips140cache ---
EXISTS
=== Go package tests ===
--- FAIL: TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure (0.00s)
--- FAIL: TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure/missing (0.00s)
quota_test.go:60: status = "available", want unknown
FAIL
FAIL\tiop/apps/node/internal/adapters/cli/status\t39.728s
ok\tiop/apps/node/cmd/node\t0.009s
FAIL
=== Python policy tests ===
.....
----------------------------------------------------------------------
Ran 5 tests in 0.001s
OK
=== Python selector tests (discover) ===
----------------------------------------------------------------------
Ran 32 tests in 0.420s
OK
=== Python selector tests (verbose) ===
test_cli_input_error_is_stderr_json_without_stdout ... ok
test_deterministic_output_for_fixed_clock ... ok
test_failover_transition_is_unsupported ... ok
test_invalid_filenames_and_grades_rejected ... ok
test_malformed_header_rejected ... ok
test_repeated_input_is_byte_stable ... ok
test_resume_pins_prior_target_across_time ... ok
test_resume_requires_matching_prior_decision ... ok
test_stage_inference_and_mismatch ... ok
test_work_unit_id_stable_across_body_changes ... ok
test_worker_contract_shape_and_types ... ok
test_cli_malformed_identity_and_quota_metadata_use_json_error_envelope ... ok
test_quota_metadata_is_validated_before_initial_output ... ok
test_resume_rejects_unhashable_stage_and_lane_types ... ok
test_valid_quota_initial_output_resumes ... ok
test_cli_deeply_malformed_prior_uses_json_error_envelope ... ok
test_cli_malformed_prior_uses_json_error_envelope ... ok
test_cli_malformed_quota_uses_json_error_envelope ... ok
test_resume_rejects_incomplete_selected_schema ... ok
test_resume_rejects_malformed_nested_prior_schema_variants ... ok
test_all_candidates_exhausted_returns_no_eligible_target ... ok
test_exhausted_primary_selects_eligible_alternate ... ok
test_generic_stderr_is_not_quota_evidence ... ok
test_injected_snapshot_is_reflected ... ok
test_local_route_does_not_call_probe ... ok
test_quota_probe_tri_state ... ok
test_quota_representation_without_snapshot ... ok
test_unknown_is_admitted_once_per_work_unit ... ok
test_candidate_rank_is_consecutive_and_time_ordered ... ok
test_kst_boundary_routes_through_selector ... ok
test_review_route_matrix_is_codex ... ok
test_worker_route_matrix_through_selector ... ok
----------------------------------------------------------------------
Ran 32 tests in 0.420s
OK
=== py_compile ===
py_compile: OK
=== gofmt -d ===
gofmt: OK
=== git diff --check ===
git diff --check: OK
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) |
| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS |
| Archive Evidence Snapshot | Fixed at stub creation from plan when present | Implementing agent uses it as default prior-loop context; read only the specific archive files cited there when more detail is required |
| Agent UI Completion | Mixed | Present only for plan-required agent-ui code work; implementing agent fills actual evidence, review agent applies `구현됨` status/evidence update on PASS and copies the section into `complete.log` |
| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` → `[x]` only |
| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` → `[x]` only |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry |
| 코드리뷰 결과 | Review agent appends | Not included in stub |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Pass
- completeness: Fail
- test coverage: Fail
- API contract: Pass
- code quality: Pass
- implementation deviation: Pass
- verification trust: Fail
- spec conformance: Fail
- 발견된 문제:
- Required — `apps/node/internal/adapters/cli/status/quota_test.go:43`: `missing` 하위 테스트는 `requiredCaps=["overall"]`과 유효한 `DailyLimit="50%"`를 전달하므로 SDD S04와 `NormalizeQuotaSnapshot` 계약상 `available`이 맞으며, 실제 누락 cap을 전혀 만들지 않는다. 이 잘못된 fixture 때문에 필수 Go package suite가 실패하고 required-cap 누락 회귀 및 S04 Evidence Map을 닫을 수 없다. 하위 테스트별 required cap을 fixture에 명시하고 `missing`에는 양수 overall과 존재하지 않는 `model:missing`을 함께 요구해 aggregate `unknown`을 검증한 뒤, 같은 Go/GOROOT에서 두 Go 패키지와 Python/static 최종 검증을 모두 통과시켜야 한다.
- 다음 단계: FAIL 후속을 plan 스킬의 `prepare-follow-up` 모드와 격리 재라우팅으로 준비한다.

View file

@ -0,0 +1,307 @@
<!-- task=m-agent-task-runtime-target-selector/02+01_quota_input plan=4 tag=REVIEW_REVIEW_REVIEW_REVIEW_API -->
# Code Review Reference - REVIEW_REVIEW_REVIEW_REVIEW_API
> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.**
> The task is NOT complete until every implementation-owned section below is filled in.
> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving.
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
> If implementation is blocked, record the exact blocker, attempted commands/output, and resume condition only in implementation-owned evidence fields.
> Do not ask the user directly, present choices, call user-input tools, create control-plane stop files, or classify the next state.
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
> Follow the ownership table at the bottom of this file for which sections you own.
## 개요
date=2026-07-25
task=m-agent-task-runtime-target-selector/02+01_quota_input, plan=4, tag=REVIEW_REVIEW_REVIEW_REVIEW_API
## 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:
- `quota-input`: Go usage checker 기반 required-cap quota snapshot과 selector admission
- Completion mode: check-on-pass
## Archive Evidence Snapshot
- Prior task: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input`
- Archived plan: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/plan_local_G02_3.log`
- Archived review: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/code_review_cloud_G09_3.log`
- Verdict: `FAIL`
- Findings: Required 1, Suggested 0, Nit 0
- `quota_test.go`의 `missing` 케이스는 `requiredCaps=["overall"]`과 유효한 `DailyLimit="50%"`를 주므로 `available`이 맞고 실제 누락 cap을 검증하지 않는다.
- Affected files: `apps/node/internal/adapters/cli/status/quota_test.go`
- Verification evidence:
- host Go: `/config/.local/bin/go` → `/config/opt/go/bin/go`, `go1.26.2 linux/arm64`, `GOROOT=/config/opt/go`
- `crypto/internal/fips140cache` preflight: PASS
- Go package tests: status package FAIL 1건, node command package PASS
- focused evidence: exhausted+missing과 all-positive는 PASS, missing/conflict/parse 중 `missing`만 FAIL
- Python policy 5개와 selector 32개 테스트, `py_compile`, `gofmt -d`, `git diff --check`: PASS
- Roadmap carryover: SDD S04와 Milestone Task `quota-input`은 corrected fixture와 fresh 전체 검증이 통과할 때까지 미완료다.
## 이 파일을 읽는 리뷰 에이전트에게
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
1. 판정을 append한다.
2. `CODE_REVIEW-cloud-G09.md` → `code_review_cloud_G09_4.log`, `PLAN-local-G02.md` → `plan_local_G02_4.log`로 아카이브한다.
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-agent-task-runtime-target-selector/02+01_quota_input/`로 이동한다. WARN/FAIL이면 code-review skill이 요구하는 다음 filesystem state를 완전히 작성한다.
4. PASS이고 task group이 `m-<milestone-slug>`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| REVIEW_REVIEW_REVIEW_REVIEW_API-1 Missing cap fixture와 S04 evidence 수정 | [x] |
## 구현 체크리스트
- [x] `apps/node/internal/adapters/cli/status/quota_test.go`의 missing/conflict/parse table에 하위 테스트별 required cap을 명시하고, `missing`이 positive overall과 누락된 model cap의 aggregate `unknown`을 실제로 검증하도록 수정한 뒤 focused Go 테스트를 통과시켰다.
- [x] 동일 PATH Go와 `GOROOT`에서 두 Go 패키지, Python policy/selector 회귀, `py_compile`, `gofmt -d`, `git diff --check`를 fresh 실행해 모두 통과시켰다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채웠다.
## 코드리뷰 전용 체크리스트
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G09_4.log`로 아카이브한다.
- [x] active `PLAN-*-G??.md`를 `plan_local_G02_4.log`로 아카이브한다.
- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다.
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
- [x] PASS이면 active task 디렉터리 `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/`를 `agent-task/archive/YYYY/MM/m-agent-task-runtime-target-selector/02+01_quota_input/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
- [x] PASS이고 task group이 `m-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-agent-task-runtime-target-selector/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
- [ ] WARN/FAIL이면 code-review skill의 판정에 맞는 다음 filesystem state를 작성하고 `complete.log`를 작성하지 않는다.
## 계획 대비 변경 사항
계획에 명시된 범위대로 `apps/node/internal/adapters/cli/status/quota_test.go`만 수정했다. production source(`quota.go`)는 변경하지 않았다.
## 주요 설계 결정
- 기존 map-based fixture(`map[string]*UsageStatus`)를 struct-based fixture(`map[string]struct{requiredCaps []string; usage *UsageStatus}`)로 전환하여 하위 테스트별로 `requiredCaps`를 명시적으로 선언할 수 있게 했다.
- `missing` 케이스에 `requiredCaps: ["overall", "model:missing"]`과 `DailyLimit: "50%"`(positive overall)만 제공하여, 실제 누락 model cap으로 인해 aggregate `unknown`이 되는 경계를 검증한다.
- `conflict` 케이스는 기존과 동일하게 `requiredCaps: ["model:flash"]` + duplicate matching metadata로 유지.
- `parse` 케이스는 기존과 동일하게 `requiredCaps: ["overall"]` + `DailyLimit: "not-a-percent"`로 유지.
- 새 테스트 함수를 추가하지 않고 기존 `TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure`의 fixture만 수정했다.
## 리뷰어를 위한 체크포인트
- `missing` fixture가 `overall` positive evidence와 실제 누락된 `model:missing` required cap을 함께 선언하는가
- missing/conflict/parse가 모두 `unknown`이고 exhausted+missing은 `exhausted`, all-positive는 `available`로 유지되는가
- production `quota.go`를 불필요하게 변경하지 않았는가
- 동일 Go/GOROOT identity에서 focused/full Go와 Python/static 최종 검증이 모두 PASS하는가
## 검증 결과
실행한 각 명령의 실제 stdout/stderr를 아래 코드 블록에 기록한다. 명령을 바꾸면 `계획 대비 변경 사항`에 대체 명령과 이유를 먼저 적는다.
### REVIEW_REVIEW_REVIEW_REVIEW_API-1 중간 검증
명령:
```bash
command -v go && readlink -f "$(command -v go)" && go version && go env GOROOT
test -d "$(go env GOROOT)/src/crypto/internal/fips140cache" && echo "preflight: PASS"
go test -count=1 ./apps/node/internal/adapters/cli/status -run '^TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure$' -v
```
실제 stdout/stderr:
```text
/config/.local/bin/go
/config/opt/go/bin/go
go version go1.26.2 linux/arm64
/config/opt/go
preflight: PASS
=== RUN TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure
=== RUN TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure/missing
=== RUN TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure/conflict
=== RUN TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure/parse
--- PASS: TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure (0.00s)
--- PASS: TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure/missing (0.00s)
--- PASS: TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure/conflict (0.00s)
--- PASS: TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure/parse (0.00s)
PASS
ok iop/apps/node/internal/adapters/cli/status 0.003s
```
### 최종 검증
명령:
```bash
command -v go && readlink -f "$(command -v go)" && go version && go env GOROOT
test -d "$(go env GOROOT)/src/crypto/internal/fips140cache" && echo "preflight: PASS"
go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_execution_target*.py'
python3 agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.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/tests/test_execution_target_policy.py agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py
gofmt -d apps/node/internal/adapters/cli/status/quota.go apps/node/internal/adapters/cli/status/quota_test.go apps/node/cmd/node/quota_probe.go apps/node/cmd/node/quota_probe_test.go apps/node/cmd/node/main.go
git diff --check
```
실제 stdout/stderr:
```text
/config/.local/bin/go
/config/opt/go/bin/go
go version go1.26.2 linux/arm64
/config/opt/go
preflight: PASS
=== RUN TestNormalizeQuotaSnapshotExhaustedWinsMissing
--- PASS: TestNormalizeQuotaSnapshotExhaustedWinsMissing (0.00s)
=== RUN TestNormalizeQuotaSnapshotAvailableWhenAllPositive
--- PASS: TestNormalizeQuotaSnapshotAvailableWhenAllPositive (0.00s)
=== RUN TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure
=== RUN TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure/missing
=== RUN TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure/conflict
=== RUN TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure/parse
--- PASS: TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure (0.00s)
--- PASS: TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure/missing (0.00s)
--- PASS: TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure/conflict (0.00s)
--- PASS: TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure/parse (0.00s)
=== RUN TestNormalizeQuotaSnapshotUnknownForNonFinitePercent
=== RUN TestNormalizeQuotaSnapshotUnknownForNonFinitePercent/overall_NaN%
--- PASS: TestNormalizeQuotaSnapshotUnknownForNonFinitePercent/overall_NaN% (0.00s)
=== RUN TestNormalizeQuotaSnapshotUnknownForNonFinitePercent/model_NaN%
--- PASS: TestNormalizeQuotaSnapshotUnknownForNonFinitePercent/model_NaN% (0.00s)
=== RUN TestNormalizeQuotaSnapshotUnknownForNonFinitePercent/overall_Inf%
--- PASS: TestNormalizeQuotaSnapshotUnknownForNonFinitePercent/overall_Inf% (0.00s)
=== RUN TestNormalizeQuotaSnapshotUnknownForNonFinitePercent/model_Inf%
--- PASS: TestNormalizeQuotaSnapshotUnknownForNonFinitePercent/model_Inf% (0.00s)
=== RUN TestNormalizeQuotaSnapshotUnknownForNonFinitePercent/overall_-Inf%
--- PASS: TestNormalizeQuotaSnapshotUnknownForNonFinitePercent/overall_-Inf% (0.00s)
=== RUN TestNormalizeQuotaSnapshotUnknownForNonFinitePercent/model_-Inf%
--- PASS: TestNormalizeQuotaSnapshotUnknownForNonFinitePercent/model_-Inf% (0.00s)
=== RUN TestNormalizeQuotaSnapshotUsesAntigravityModelMetadata
--- PASS: TestNormalizeQuotaSnapshotUsesAntigravityModelMetadata (0.00s)
PASS
ok iop/apps/node/internal/adapters/cli/status 39.831s
=== RUN TestRootCmdIncludesOperationalCommands
--- PASS: TestRootCmdIncludesOperationalCommands (0.00s)
=== RUN TestVersionCmdPrintsVersion
--- PASS: TestVersionCmdPrintsVersion (0.00s)
=== RUN TestConfigCheckCmdLoadsNodeConfig
--- PASS: TestConfigCheckCmdLoadsNodeConfig (0.00s)
=== RUN TestConfigPrintCmdPrintsResolvedConfig
--- PASS: TestConfigPrintCmdPrintsResolvedConfig (0.00s)
=== RUN TestSetupDryRunUsesNodeDefaults
--- PASS: TestSetupDryRunUsesNodeDefaults (0.00s)
=== RUN TestSetupDryRunAcceptsExplicitConfig
--- PASS: TestSetupDryRunAcceptsExplicitConfig (0.00s)
=== RUN TestSetupDryRunConfigSubcmdPosition
--- PASS: TestSetupDryRunConfigSubcmdPosition (0.00s)
=== RUN TestSetupHelpShowsNodeDefault
--- PASS: TestSetupHelpShowsNodeDefault (0.00s)
=== RUN TestQuotaProbeCommandEmitsSelectorCompatibleSnapshot
--- PASS: TestQuotaProbeCommandEmitsSelectorCompatibleSnapshot (0.00s)
=== RUN TestQuotaProbeCommandReturnsUnknownForCheckerError
--- PASS: TestQuotaProbeCommandReturnsUnknownForCheckerError (0.00s)
=== RUN TestQuotaProbeCommandRejectsInvalidCaps
--- PASS: TestQuotaProbeCommandRejectsInvalidCaps (0.00s)
=== RUN TestQuotaProbeCommandRejectsEmptyTargetAndCommand
=== RUN TestQuotaProbeCommandRejectsEmptyTargetAndCommand/empty_target
=== RUN TestQuotaProbeCommandRejectsEmptyTargetAndCommand/whitespace_target
=== RUN TestQuotaProbeCommandRejectsEmptyTargetAndCommand/empty_command
=== RUN TestQuotaProbeCommandRejectsEmptyTargetAndCommand/whitespace_command
--- PASS: TestQuotaProbeCommandRejectsEmptyTargetAndCommand (0.00s)
=== RUN TestQuotaProbeCommandIsHidden
--- PASS: TestQuotaProbeCommandIsHidden (0.00s)
PASS
ok iop/apps/node/cmd/node 0.010s
test_dynamic_route_orders_only_the_two_allowed_candidates (test_execution_target_policy.ExecutionTargetPolicyTests.test_dynamic_route_orders_only_the_two_allowed_candidates) ... ok
test_invalid_inputs_are_rejected (test_execution_target_policy.ExecutionTargetPolicyTests.test_invalid_inputs_are_rejected) ... ok
test_kst_boundary_routes (test_execution_target_policy.ExecutionTargetPolicyTests.test_kst_boundary_routes) ... ok
test_review_matrix_is_fixed_to_codex (test_execution_target_policy.ExecutionTargetPolicyTests.test_review_matrix_is_fixed_to_codex) ... ok
test_worker_grade_matrix_has_no_gaps (test_execution_target_policy.ExecutionTargetPolicyTests.test_worker_grade_matrix_has_no_gaps) ... ok
----------------------------------------------------------------------
Ran 5 tests in 0.001s
OK
test_cli_input_error_is_stderr_json_without_stdout (__main__.SelectorContractTests.test_cli_input_error_is_stderr_json_without_stdout) ... ok
test_deterministic_output_for_fixed_clock (__main__.SelectorContractTests.test_deterministic_output_for_fixed_clock) ... ok
test_failover_transition_is_unsupported (__main__.SelectorContractTests.test_failover_transition_is_unsupported) ... ok
test_invalid_filenames_and_grades_rejected (__main__.SelectorContractTests.test_invalid_filenames_and_grades_rejected) ... ok
test_malformed_header_rejected (__main__.SelectorContractTests.test_malformed_header_rejected) ... ok
test_repeated_input_is_byte_stable (__main__.SelectorContractTests.test_repeated_input_is_byte_stable) ... ok
test_resume_pins_prior_target_across_time (__main__.SelectorContractTests.test_resume_pins_prior_target_across_time) ... ok
test_resume_requires_matching_prior_decision (__main__.SelectorContractTests.test_resume_requires_matching_prior_decision) ... ok
test_stage_inference_and_mismatch (__main__.SelectorContractTests.test_stage_inference_and_mismatch) ... ok
test_work_unit_id_stable_across_body_changes (__main__.SelectorContractTests.test_work_unit_id_stable_across_body_changes) ... ok
test_worker_contract_shape_and_types (__main__.SelectorContractTests.test_worker_contract_shape_and_types) ... ok
test_cli_malformed_identity_and_quota_metadata_use_json_error_envelope (__main__.SelectorIdentityAndQuotaRoundtripTests.test_cli_malformed_identity_and_quota_metadata_use_json_error_envelope) ... ok
test_quota_metadata_is_validated_before_initial_output (__main__.SelectorIdentityAndQuotaRoundtripTests.test_quota_metadata_is_validated_before_initial_output) ... ok
test_resume_rejects_unhashable_stage_and_lane_types (__main__.SelectorIdentityAndQuotaRoundtripTests.test_resume_rejects_unhashable_stage_and_lane_types) ... ok
test_valid_quota_initial_output_resumes (__main__.SelectorIdentityAndQuotaRoundtripTests.test_valid_quota_initial_output_resumes) ... ok
test_cli_deeply_malformed_prior_uses_json_error_envelope (__main__.SelectorNestedInputContractTests.test_cli_deeply_malformed_prior_uses_json_error_envelope) ... ok
test_cli_malformed_prior_uses_json_error_envelope (__main__.SelectorNestedInputContractTests.test_cli_malformed_prior_uses_json_error_envelope) ... ok
test_cli_malformed_quota_uses_json_error_envelope (__main__.SelectorNestedInputContractTests.test_cli_malformed_quota_uses_json_error_envelope) ... ok
test_resume_rejects_incomplete_selected_schema (__main__.SelectorNestedInputContractTests.test_resume_rejects_incomplete_selected_schema) ... ok
test_resume_rejects_malformed_nested_prior_schema_variants (__main__.SelectorNestedInputContractTests.test_resume_rejects_malformed_nested_prior_schema_variants) ... ok
test_all_candidates_exhausted_returns_no_eligible_target (__main__.SelectorQuotaRepresentationTests.test_all_candidates_exhausted_returns_no_eligible_target) ... ok
test_exhausted_primary_selects_eligible_alternate (__main__.SelectorQuotaRepresentationTests.test_exhausted_primary_selects_eligible_alternate) ... ok
test_generic_stderr_is_not_quota_evidence (__main__.SelectorQuotaRepresentationTests.test_generic_stderr_is_not_quota_evidence) ... ok
test_injected_snapshot_is_reflected (__main__.SelectorQuotaRepresentationTests.test_injected_snapshot_is_reflected) ... ok
test_local_route_does_not_call_probe (__main__.SelectorQuotaRepresentationTests.test_local_route_does_not_call_probe) ... ok
test_quota_probe_tri_state (__main__.SelectorQuotaRepresentationTests.test_quota_probe_tri_state) ... ok
test_quota_representation_without_snapshot (__main__.SelectorQuotaRepresentationTests.test_quota_representation_without_snapshot) ... ok
test_unknown_is_admitted_once_per_work_unit (__main__.SelectorQuotaRepresentationTests.test_unknown_is_admitted_once_per_work_unit) ... ok
test_candidate_rank_is_consecutive_and_time_ordered (__main__.SelectorRouteMatrixTests.test_candidate_rank_is_consecutive_and_time_ordered) ... ok
test_kst_boundary_routes_through_selector (__main__.SelectorRouteMatrixTests.test_kst_boundary_routes_through_time_ordered) ... ok
test_review_route_matrix_is_codex (__main__.SelectorRouteMatrixTests.test_review_route_matrix_is_codex) ... ok
test_worker_route_matrix_through_selector (__main__.SelectorRouteMatrixTests.test_worker_route_matrix_through_selector) ... ok
----------------------------------------------------------------------
Ran 32 tests in 0.435s
OK
py_compile: PASS
gofmt: PASS (no diffs)
git diff --check: PASS
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## 섹션 소유권
| Section | Owner | Note |
|---------|-------|------|
| Header comment, 개요, 리뷰 에이전트 지시 | Fixed at stub creation | Implementing agent must not modify or execute these (archive, complete.log, and task-directory archive move are review-agent only) |
| Roadmap Targets | Fixed at stub creation from plan when present | Implementing agent must not modify; code-review copies it into `complete.log` as `Roadmap Completion` only on PASS |
| Archive Evidence Snapshot | Fixed at stub creation from plan when present | Implementing agent uses it as default prior-loop context; read only the specific archive files cited there when more detail is required |
| Agent UI Completion | Mixed | Present only for plan-required agent-ui code work; implementing agent fills actual evidence, review agent applies `구현됨` status/evidence update on PASS and copies the section into `complete.log` |
| 구현 항목별 완료 여부 (item names) | Fixed at stub creation | Implementing agent checks `[ ]` → `[x]` only |
| 구현 체크리스트 (item text/order) | Fixed at stub creation from plan | Implementing agent checks `[ ]` → `[x]` only |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | Implementing agent | Replace placeholder text with actual content |
| 리뷰어를 위한 체크포인트 | Fixed at stub creation | Pre-filled from plan |
| 검증 결과 (section headings + commands) | Fixed at stub creation | Implementing agent fills in command output only; command changes require a `계획 대비 변경 사항` entry |
| 코드리뷰 결과 | Review agent appends | Not included in stub |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- correctness: Pass
- completeness: Pass
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- implementation deviation: Pass
- verification trust: Pass
- spec conformance: Pass
- 발견된 문제: 없음
- 다음 단계: PASS로 `complete.log`를 작성하고 task artifact를 최종 archive 경로로 이동한다.

View file

@ -0,0 +1,53 @@
# Complete - m-agent-task-runtime-target-selector/02+01_quota_input
## 완료 일시
2026-07-25
## 요약
Go usage checker 기반 required-cap quota snapshot과 selector admission을 5회 리뷰 루프 끝에 PASS로 완료했다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G07_0.log` | `code_review_cloud_G09_0.log` | FAIL | exhausted 후보 선택, 비유한 percent, 빈 probe identity, Go 환경 검증 문제를 확인했다. |
| `plan_cloud_G07_1.log` | `code_review_cloud_G09_1.log` | FAIL | 코드 결함은 해소됐으나 호스트 GOROOT 표준 라이브러리 누락으로 Go 검증이 차단됐다. |
| `plan_local_G02_2.log` | `code_review_cloud_G09_2.log` | FAIL | 동일 GOROOT 차단이 지속되어 Go producer/command evidence를 확보하지 못했다. |
| `plan_local_G02_3.log` | `code_review_cloud_G09_3.log` | FAIL | Go 환경 복구 뒤 실제 누락 cap을 만들지 않는 `missing` fixture 오류를 확인했다. |
| `plan_local_G02_4.log` | `code_review_cloud_G09_4.log` | PASS | fixture 교정과 focused/full Go·Python·정적 검증이 모두 통과했다. |
## 구현/정리 내용
- 기존 CLI usage checker 결과를 민감한 raw output 없이 `available | exhausted | unknown` required-cap snapshot으로 정규화했다.
- hidden `iop-node quota-probe`와 selector quota admission을 연결하고 local `unbounded/not_applicable`, unknown work-unit 1회 admission, exhausted alternate/no-eligible 동작을 검증했다.
- positive overall과 누락된 model cap을 함께 선언하도록 missing fixture를 교정해 SDD S04의 누락 evidence를 실제로 검증했다.
## 최종 검증
- `command -v go && readlink -f "$(command -v go)" && go version && go env GOROOT` - PASS; `/config/.local/bin/go` → `/config/opt/go/bin/go`, `go1.26.2 linux/arm64`, `GOROOT=/config/opt/go`.
- `test -d "$(go env GOROOT)/src/crypto/internal/fips140cache"` - PASS.
- `go test -count=1 ./apps/node/internal/adapters/cli/status -run '^TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure$' -v` - PASS; missing/conflict/parse 하위 테스트 3개 통과.
- `go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node` - PASS; status 39.782s, node command 0.010s.
- `python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_execution_target*.py'` - PASS; policy 5개 통과.
- `python3 agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py -v` - PASS; selector 32개 통과.
- `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/tests/test_execution_target_policy.py agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py` - PASS.
- `gofmt -d apps/node/internal/adapters/cli/status/quota.go apps/node/internal/adapters/cli/status/quota_test.go apps/node/cmd/node/quota_probe.go apps/node/cmd/node/quota_probe_test.go apps/node/cmd/node/main.go` - PASS; diff 없음.
- `git diff --check` - PASS.
## Roadmap Completion
- 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)
- Completed task ids:
- `quota-input`: PASS; evidence=`agent-task/archive/2026/07/m-agent-task-runtime-target-selector/02+01_quota_input/plan_local_G02_4.log`, `agent-task/archive/2026/07/m-agent-task-runtime-target-selector/02+01_quota_input/code_review_cloud_G09_4.log`; verification=`go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node`, Python policy 5개와 selector 32개, `py_compile`, `gofmt -d`, `git diff --check` PASS.
- Not completed task ids: 없음
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,284 @@
<!-- task=m-agent-task-runtime-target-selector/02+01_quota_input plan=0 tag=API -->
# Go Usage Checker 기반 Quota Input Bridge
## 이 파일을 읽는 구현 에이전트에게
`01_selector_contract`의 `complete.log`를 확인한 뒤 구현한다. 구현과 검증을 완료한 뒤 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 내용과 출력으로 채운다. active PLAN/CODE_REVIEW 파일은 그대로 두고 리뷰 준비 완료만 보고한다. 차단되면 구현 소유 evidence 필드에 정확한 차단 원인, 시도한 명령과 출력, 재개 조건만 기록한다. 사용자에게 질문하거나 user-input 도구를 호출하거나 control-plane stop 파일을 만들거나 다음 상태를 판정하지 않는다. 로그 archive, `complete.log`, 최종 판정은 code-review skill만 수행한다.
## 배경
Node의 기존 Go status checker는 Antigravity overall quota와 모델별 usage를 이미 구조화한다. selector가 이 parser를 재구현하지 않도록 `iop-node` 내부 runtime용 JSON probe를 추가하고, required cap을 `available|exhausted|unknown`으로 정규화해야 한다. 이 plan은 selector 계약 완료 후 S04를 실제 Go parser 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:
- `quota-input`: Go usage checker 기반 required-cap quota snapshot과 selector admission
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `AGENTS.md`
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/rules/common/philosophy.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/finalize-task-routing/SKILL.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-test/local/rules.md`
- `agent-test/local/node-smoke.md`
- `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/execution_target_policy.py`
- `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_execution_target_policy.py`
- `apps/node/cmd/node/main.go`
- `apps/node/cmd/node/main_test.go`
- `apps/node/internal/adapters/cli/status/status.go`
- `apps/node/internal/adapters/cli/status/status_test.go`
- `apps/node/internal/adapters/cli/status/parser.go`
- `apps/node/internal/adapters/cli/status/parser_test.go`
- `apps/node/internal/adapters/cli/status/antigravity.go`
### SDD 기준
- SDD: `agent-roadmap/sdd/automation-runtime-bridge/agent-task-runtime-target-selector/SDD.md`
- 상태: `[승인됨]`, SDD 잠금 `해제`, 사용자 리뷰 없음
- 대상: S04→`quota-input`
- Evidence Map: Go usage checker JSON bridge, required-cap tri-state, unknown work-unit 1회 admission, local `unbounded/not_applicable` 테스트가 필요하다.
- 구현 체크리스트는 Go parser output→required cap resolver→hidden JSON command→selector black-box 순서로 evidence를 연결한다.
### 테스트 환경 규칙
- `test_env=local`
- `agent-test/local/rules.md`와 `agent-test/local/node-smoke.md`를 읽었다.
- node-smoke profile의 transport/bootstrap 명령은 이번 status/cmd 범위와 직접 일치하지 않아, 프로젝트 node rule의 대상 package test를 fallback으로 사용한다.
- Go 테스트는 cache를 허용하지 않고 `-count=1`을 사용한다.
- 테스트 환경 프리플라이트:
- runner/repo: 현재 local checkout `/config/workspace/iop`, branch `main`, HEAD `3f01d08cebdafa1bddd1089e6940677dcb6c90b1`
- dirty state: 사용자의 `agent-ops/skills/common/**` 변경과 이 요청의 policy/plan 변경이 공존한다. 범위 외 변경은 수정하지 않는다.
- Go: `/config/.local/bin/go` → `/config/opt/go/bin/go`, `go1.26.2 linux/arm64`, `GOROOT=/config/opt/go`
- 차단 evidence: `go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node`가 `crypto/internal/fips140cache is not in std`로 setup 실패한다.
- 재개 조건: `/config/opt/go`의 표준 라이브러리 source를 go1.26.2 설치와 일치하도록 복구한 뒤 동일 preflight와 package test를 재실행한다. 별도 `GOTOOLCHAIN`이나 다른 Go binary로 기준을 바꾸지 않는다.
- external provider/credential/port: 단위 테스트는 fake checker를 사용하므로 필요 없다.
### 테스트 커버리지 공백
- 기존 parser 테스트는 overall `DailyLimit`과 `model_usage_*` metadata를 검증하지만 required cap set의 tri-state precedence는 검증하지 않는다.
- 기존 node command 테스트는 `quota-probe` JSON schema, raw output 비노출, checker 오류→unknown 변환을 검증하지 않는다.
- selector policy 테스트는 local target matrix만 검증하며 Go probe snapshot과 unknown 1회 admission을 연결하지 않는다.
### 심볼 참조
- renamed/removed symbol: none
### 분할 판단
- split 정책을 파일 선택 전에 적용했다.
- task group: `m-agent-task-runtime-target-selector`
- 현재 subtask: `02+01_quota_input`
- predecessor `01`: active `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/`에 있으나 `complete.log`는 아직 없다. 구현 시작 조건은 해당 predecessor의 active 또는 archive `complete.log` 단일 매치다.
- Python selector 계약과 Node Go checker는 ownership·언어·검증 경계가 달라 분리했다. 이 subtask 안의 status normalizer와 hidden command는 같은 Go JSON bridge를 이루며 한 package test batch에서 검증되므로 추가 분할하지 않는다.
### 범위 결정 근거
- 기존 PTY parser regex와 `UsageStatus` wire/runtime DTO는 바꾸지 않는다.
- Node config/proto, Edge console output, 사용자 bootstrap UX에는 새 schema를 추가하지 않는다.
- probe는 raw provider output과 secret을 JSON에 포함하지 않는다.
- dispatcher batch snapshot reuse, persisted route, failover는 후속 integration Epic 범위다. 이 plan은 selector의 S04 admission hook을 black-box로 검증하는 데까지만 닫는다.
### 최종 라우팅
- evaluation_mode: `first-pass`
- finalizer: `finalize-task-policy.sh`
- finalizer_mode: `pair`
- build closures: scope/context/verification/evidence/ownership/decision 모두 `true`
- build closure basis: status parser, 새 normalizer, hidden command와 selector consumer 경계가 확정됐고, 현재 Go 설치 차단은 정확한 preflight/복구 조건과 동일 package test로 판정 가능하다.
- build base route: `local-G05`
- build grade scores: scope_coupling=2, state_concurrency=0, blast_irreversibility=1, evidence_diagnosis=1, verification_complexity=1
- build loop-risk: `boundary_contract`, `structured_interpretation`, `variant_product`가 매칭되어 `triggered: true`; signature 개수는 grade에 더하지 않고 cloud minimum `G07`만 적용
- `boundary_contract`: Go usage checker·quota-probe JSON·Python selector 사이의 validation/compatibility 계약
- `structured_interpretation`: PTY usage parser와 exhausted·missing·conflict 상태 precedence
- `variant_product`: overall/model cap 관측 여부 × 0/양수/충돌 × required-cap 조합 검증
- build final: `cloud-G07`, `PLAN-cloud-G07.md`
- review closures: scope/context/verification/evidence/ownership/decision 모두 `true`
- review closure basis: Go parser/normalizer/command와 selector black-box evidence를 고정 명령으로 대조할 수 있다.
- review base route: `local-G05`
- review grade scores: scope_coupling=2, state_concurrency=0, blast_irreversibility=1, evidence_diagnosis=1, verification_complexity=1
- review capability floor: official review minimum `cloud-G09`
- review final: `cloud-G09`, `CODE_REVIEW-cloud-G09.md`
## 구현 체크리스트
- [ ] 기존 `UsageStatus`의 overall/model remaining을 required cap set에 맞춰 `available|exhausted|unknown` snapshot으로 정규화한다.
- [ ] raw output을 노출하지 않는 hidden `iop-node quota-probe` JSON command를 추가하고 checker/parse 오류를 `unknown` evidence로 반환한다.
- [ ] overall/model cap 0·양수·누락·충돌, local unbounded, unknown work-unit 1회 admission을 Go와 selector black-box 테스트로 검증한다.
- [ ] Go preflight를 재실행하고 `go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node` 및 selector 테스트를 통과시킨다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.
## 의존 관계 및 구현 순서
1. `agent-task/m-agent-task-runtime-target-selector/01_selector_contract/complete.log` 또는 같은 task group/index의 단일 archive `complete.log`가 있어야 한다. 현재는 미충족이다.
2. Go normalizer와 hidden command를 구현한다.
3. predecessor selector의 `--quota-probe-command`/snapshot 계약을 실제 probe와 연결해 S04 black-box 검증을 수행한다.
## [API-1] Required-cap quota snapshot 정규화
### 문제
`apps/node/internal/adapters/cli/status/status.go:13`의 `UsageStatus`는 overall remaining을 `DailyLimit`에, 모델별 used percentage를 `Metadata["model_usage_*"]`에 보존하지만 selector가 소비할 tri-state와 required cap evidence가 없다.
### 해결 방법
현재 기준:
```go
// status.go:13
type UsageStatus struct {
RawOutput string
DailyLimit string
Metadata map[string]string
}
```
`status/quota.go`에 immutable JSON DTO와 pure normalizer를 추가한다.
```go
type QuotaSnapshot struct {
SchemaVersion string `json:"schema_version"`
SnapshotID string `json:"snapshot_id"`
Status string `json:"status"`
Source string `json:"source"`
CheckedAt string `json:"checked_at"`
RequiredCaps []QuotaCapView `json:"required_caps"`
ReasonCodes []string `json:"reason_codes"`
}
```
- `overall`은 `DailyLimit`, `model:<name>`은 `model_usage_*_name`과 `*_used_percent`를 case-insensitive exact-name으로 대조해 remaining으로 변환한다.
- required cap 하나라도 confirmed 0이면 다른 누락보다 우선해 `exhausted`다.
- 모든 required cap이 관측되고 양수면 `available`이다.
- required cap 누락, 중복 충돌, 잘못된 percent, nil/parse/checker 오류는 `unknown`이다.
- snapshot id는 checked-at과 정규화된 cap evidence의 deterministic hash로 만들고 raw PTY output은 포함하지 않는다.
### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/adapters/cli/status/quota.go` — DTO, cap resolver, tri-state normalizer를 추가한다.
- [ ] `apps/node/internal/adapters/cli/status/quota_test.go` — required cap precedence와 metadata bridge 테스트를 추가한다.
### 테스트 작성
작성한다. `TestNormalizeQuotaSnapshotExhaustedWinsMissing`, `...AvailableWhenAllPositive`, `...UnknownForMissingConflictAndParseFailure`, `...UsesAntigravityModelMetadata`를 추가한다. 마지막 테스트는 `ParseStatusOutput` fixture 결과를 직접 입력해 parser 중복 구현이 없음을 검증한다.
### 중간 검증
```bash
go test -count=1 ./apps/node/internal/adapters/cli/status
```
기대 결과: parser 회귀와 quota tri-state 테스트가 모두 PASS한다. 현재 host Go가 복구되지 않았으면 실제 setup failure 출력과 재개 조건을 review stub에 기록한다.
## [API-2] Hidden `iop-node quota-probe` JSON command
### 문제
`apps/node/cmd/node/main.go:33`은 serve/version/config/setup만 등록한다. selector가 existing Go checker를 좁은 JSON으로 호출할 runtime command가 없다.
### 해결 방법
현재 기준:
```go
// main.go:33
root.AddCommand(serveCmd(), versionCmd(), configCmd(), setupCmd())
```
`quota_probe.go`의 hidden Cobra command를 추가하고 root에 등록한다.
```go
root.AddCommand(serveCmd(), versionCmd(), configCmd(), setupCmd(), quotaProbeCmd())
```
- 입력: `--target`, `--command`, 반복 `--required-cap`, 선택 `--checked-at`
- `status.CheckUsage`를 호출하고 API-1 normalizer 결과 한 개만 stdout JSON으로 쓴다.
- checker/parse 실패는 command 자체 crash나 generic stderr 분류가 아니라 `status=unknown`, typed reason code를 가진 snapshot으로 반환한다.
- invalid flag/required-cap schema만 non-zero 입력 오류로 처리한다.
- command는 `Hidden: true`로 두어 사용자 bootstrap/운영 기본 UX에 노출하지 않는다.
- 테스트는 package-level checker function injection으로 외부 `agy`를 실행하지 않는다.
### 수정 파일 및 체크리스트
- [ ] `apps/node/cmd/node/main.go` — hidden probe command를 root에 등록한다.
- [ ] `apps/node/cmd/node/quota_probe.go` — Cobra command와 JSON encoder를 추가한다.
- [ ] `apps/node/cmd/node/quota_probe_test.go` — fake checker 기반 command schema/error tests를 추가한다.
### 테스트 작성
작성한다. available/exhausted/unknown JSON, required cap 전달, raw output 비노출, invalid cap non-zero, checker error unknown을 검증한다.
### 중간 검증
```bash
go test -count=1 ./apps/node/cmd/node
```
기대 결과: 기존 operational command 테스트와 hidden quota probe 테스트가 모두 PASS한다.
## [API-3] Selector S04 black-box 연결
### 문제
Go snapshot만으로는 S04의 local `unbounded/not_applicable`과 cloud unknown work-unit 1회 admission이 selector 결과에 반영됐다는 evidence가 부족하다.
### 해결 방법
predecessor가 확정한 selector probe hook을 사용해 fake `iop-node quota-probe` JSON을 주입한다.
- overall 또는 model required cap 0 snapshot은 candidate rejection `quota_exhausted`를 만든다.
- 모두 양수인 snapshot은 eligible `quota_available`을 만든다.
- missing/conflict snapshot은 최초 work unit admission에서만 eligible unknown이며 prior decision에 unknown admission 사용 evidence가 있으면 재-admission하지 않는다.
- local target은 probe process를 호출하지 않고 `mode=unbounded`, `status=not_applicable`이다.
- generic stderr text만 제공하는 fake는 quota exhausted로 승격되지 않는다.
### 수정 파일 및 체크리스트
- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py` — S04 probe/admission black-box cases를 추가한다.
- [ ] `apps/node/cmd/node/quota_probe_test.go` — selector가 소비하는 JSON fixture와 command output이 동일 schema인지 검증한다.
### 테스트 작성
작성한다. `test_quota_probe_tri_state`, `test_unknown_is_admitted_once_per_work_unit`, `test_local_route_does_not_call_probe`, `test_generic_stderr_is_not_quota_evidence`를 추가한다.
### 중간 검증
```bash
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_execution_target*.py'
```
기대 결과: predecessor S01~S03 회귀와 S04 selector admission 테스트가 모두 PASS한다.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/node/internal/adapters/cli/status/quota.go` | API-1 |
| `apps/node/internal/adapters/cli/status/quota_test.go` | API-1 |
| `apps/node/cmd/node/main.go` | API-2 |
| `apps/node/cmd/node/quota_probe.go` | API-2 |
| `apps/node/cmd/node/quota_probe_test.go` | API-2, API-3 |
| `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py` | API-3 |
## 최종 검증
```bash
command -v go && readlink -f "$(command -v go)" && go version && go env GOROOT
go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_execution_target*.py'
```
기대 결과: host Go/GOROOT가 일치하고 Go parser/probe 및 Python selector S01~S04 테스트가 모두 PASS한다. 현재 checkout에서는 Go 표준 라이브러리의 `crypto/internal/fips140cache` 누락을 먼저 복구해야 한다. 외부 provider 호출과 Go test cache는 사용하지 않는다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다.

View file

@ -0,0 +1,312 @@
<!-- task=m-agent-task-runtime-target-selector/02+01_quota_input plan=1 tag=REVIEW_API -->
# Quota Admission 및 Probe 입력 검증 보완
## 이 파일을 읽는 구현 에이전트에게
구현과 검증을 완료한 뒤 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 내용과 출력으로 채운다. active PLAN/CODE_REVIEW 파일은 그대로 두고 리뷰 준비 완료만 보고한다. 차단되면 구현 소유 evidence 필드에 정확한 차단 원인, 시도한 명령과 출력, 재개 조건만 기록한다. 사용자에게 질문하거나 user-input 도구를 호출하거나 control-plane stop 파일을 만들거나 다음 상태를 판정하지 않는다. 로그 archive, `complete.log`, 최종 판정은 code-review skill만 수행한다.
## 배경
첫 리뷰에서 quota가 첫 후보를 `exhausted`로 판정해도 selector가 그 후보를 계속 `selected`로 반환하는 S04 admission 위반이 재현됐다. Go quota producer도 비유한 percent와 명시적 빈 필드에서 selector-compatible `unknown` 또는 입력 오류를 보장하지 못한다. 현재 호스트 Go 설치 불일치로 필수 패키지 컴파일 evidence도 없으므로, 계약 결함과 검증 신뢰를 한 루프에서 닫는다.
## Archive Evidence Snapshot
- Prior task: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input`
- Archived plan: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/plan_cloud_G07_0.log`
- Archived review: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/code_review_cloud_G09_0.log`
- Verdict: `FAIL`
- Findings: Required 4, Suggested 0, Nit 0
- exhausted/ineligible 첫 후보가 계속 `selected`로 반환된다.
- `NaN%`가 유효한 available evidence로 통과한 뒤 JSON 인코딩을 깨뜨린다.
- 명시적 빈 `--target=`/`--command=`가 selector-invalid snapshot으로 이어질 수 있다.
- 필수 Go 패키지 테스트가 `crypto/internal/fips140cache` 누락으로 setup 실패했다.
- Affected files: `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`, `apps/node/internal/adapters/cli/status/quota.go`, `apps/node/internal/adapters/cli/status/quota_test.go`, `apps/node/cmd/node/quota_probe.go`, `apps/node/cmd/node/quota_probe_test.go`
- Verification evidence:
- focused selector reproducer: selected=`agy/Gemini 3.6 Flash Medium`, candidates=`agy exhausted/ineligible`, `pi not_applicable/eligible`
- Python selector/policy: 5 discovery tests와 30 selector tests PASS
- Go packages: setup FAIL; `/config/opt/go/src/crypto/internal/fips140cache` missing
- Roadmap carryover: SDD S04와 Milestone Task `quota-input`은 미완료 상태를 유지한다.
## 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:
- `quota-input`: Go usage checker 기반 required-cap quota snapshot과 selector admission
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-ops/rules/common/rules-agent-spec.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/finalize-task-routing/SKILL.md`
- `agent-test/local/rules.md`
- `agent-test/local/node-smoke.md`
- `agent-spec/index.md`
- `agent-contract/index.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-task/archive/2026/07/m-agent-task-runtime-target-selector/01_selector_contract/complete.log`
- `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/plan_cloud_G07_0.log`
- `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/code_review_cloud_G09_0.log`
- `apps/node/internal/adapters/cli/status/status.go`
- `apps/node/internal/adapters/cli/status/parser.go`
- `apps/node/internal/adapters/cli/status/antigravity.go`
- `apps/node/internal/adapters/cli/status/claude.go`
- `apps/node/internal/adapters/cli/status/codex.go`
- `apps/node/internal/adapters/cli/status/quota.go`
- `apps/node/internal/adapters/cli/status/quota_test.go`
- `apps/node/cmd/node/main.go`
- `apps/node/cmd/node/main_test.go`
- `apps/node/cmd/node/quota_probe.go`
- `apps/node/cmd/node/quota_probe_test.go`
- `packages/go/config/provider_types.go`
- `agent-ops/skills/project/orchestrate-agent-task-loop/SKILL.md`
- `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_execution_target_policy.py`
- `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py`
### SDD 기준
- SDD: `agent-roadmap/sdd/automation-runtime-bridge/agent-task-runtime-target-selector/SDD.md`
- 상태: `[승인됨]`, SDD 잠금 `해제`, 사용자 리뷰 없음
- 대상: S04 → `quota-input`
- Evidence Map: Go usage checker JSON bridge, required-cap tri-state, unknown 1회 admission, local `unbounded/not_applicable` 테스트
- S04의 exhausted rejection과 selector admission을 REVIEW_API-1로, parse 실패→unknown과 producer schema를 REVIEW_API-2로, 실제 Go/Python evidence를 REVIEW_API-3으로 역산했다.
### 테스트 환경 규칙
- `test_env=local`
- `agent-test/local/rules.md`와 `agent-test/local/node-smoke.md`를 읽었다.
- node-smoke의 Edge session/handler/reconnect 범위는 hidden quota command와 직접 일치하지 않아 대상 Go package 테스트와 selector black-box 테스트를 fallback으로 사용한다.
- Go cache는 허용하지 않고 `-count=1`을 사용한다. `PATH`에서 처음 해석되는 같은 Go를 유지하며 별도 `GOTOOLCHAIN` 또는 다른 Go binary로 우회하지 않는다.
- 테스트 환경 프리플라이트:
- runner/repo: 현재 local checkout `/config/workspace/iop`
- Go: `/config/.local/bin/go` → `/config/opt/go/bin/go`, `go1.26.2 linux/arm64`, `GOROOT=/config/opt/go`
- 현재 blocker: `crypto/internal/fips140cache is not in std (/config/opt/go/src/crypto/internal/fips140cache)`
- 재개 조건: `/config/opt/go` 표준 라이브러리 source를 go1.26.2 설치와 일치하도록 복구한 뒤 동일 preflight와 package test를 실행한다.
- external provider/credential/port: 단위 테스트는 fake checker와 injected snapshot을 사용하므로 필요 없다.
### 테스트 커버리지 공백
- 기존 tri-state 테스트는 후보의 `eligibility`만 확인하고 exhausted 첫 후보가 `selected`에서 제외되는지 확인하지 않는다.
- eligible 후보가 하나도 없을 때 exhausted target을 실행 대상으로 반환하지 않는 회귀가 없다.
- percent 테스트는 일반 parse 오류만 다루며 `NaN`/`Inf`를 다루지 않는다.
- quota command 테스트는 required flag 자체만 확인하고 명시적 빈 target/command를 다루지 않는다.
- Go package 테스트는 현재 setup 실패로 컴파일 및 assertion 실행 evidence가 없다.
### 심볼 참조
- renamed/removed symbol: none
### 분할 판단
- split 정책을 파일 선택 전에 적용했다.
- task group: `m-agent-task-runtime-target-selector`
- 현재 subtask: `02+01_quota_input`
- predecessor `01`은 `agent-task/archive/2026/07/m-agent-task-runtime-target-selector/01_selector_contract/complete.log`로 충족됐다.
- selector admission과 Go producer validation은 같은 S04 JSON 경계의 producer/consumer 보완이며 최종 검증도 한 package/black-box batch로 판정된다. 별도 후속 subtask로 나누면 같은 계약을 중간 불일치 상태로 남기므로 기존 subtask의 단일 follow-up pair를 유지한다.
### 범위 결정 근거
- `dispatch.py`의 admission batch/persisted state/failover, 실패 예산, task blocker 전이는 후속 integration Epic 소유이므로 수정하지 않는다.
- 기존 PTY parser regex, status checker 실행 순서, Node config/proto/Edge wire는 바꾸지 않는다.
- `agent-ops/skills/common/**`, `agent-roadmap/**`, `agent-spec/**`, `agent-contract/**`는 수정하지 않는다.
- 호스트 Go 설치 복구는 repo 변경 범위가 아니다. 동일 환경의 복구 여부를 preflight로 확인하고 실패하면 exact blocker evidence만 남긴다.
### 최종 라우팅
- evaluation_mode: `isolated-reassessment`
- finalizer: `finalize-task-policy.sh`
- finalizer_mode: `pair`
- build closures: scope/context/verification/evidence/ownership/decision 모두 `true`
- build closure basis: S04 producer/consumer 결함과 수정 경로가 고정됐고, 필수 검증 명령 및 Go 환경 재개 조건이 결정적이다.
- build base route: `local-G06`
- build grade scores: scope_coupling=2, state_concurrency=1, blast_irreversibility=1, evidence_diagnosis=1, verification_complexity=1
- build loop-risk:
- ordered_transitions: state_count=2, adverse_paths=`resume`; signature 불충족
- concurrent_consistency: actor_count=0, constraints=없음
- boundary_contract: component_count=3, consumer_count=1, validation/compatibility 제약; signature 매칭
- structured_interpretation: percent parser와 exhausted/unknown precedence; signature 매칭
- variant_product: quota status × candidate 위치 × 입력 유효성 조합 검증; signature 매칭
- triggered: `true`, cloud floor `G07`
- build final: `cloud-G07`, `PLAN-cloud-G07.md`
- review closures: scope/context/verification/evidence/ownership/decision 모두 `true`
- review closure basis: 코드 경계, 집중 재현, fresh Go/Python 명령으로 수정 여부를 독립 판정할 수 있다.
- review base route: `local-G06`
- review grade scores: scope_coupling=2, state_concurrency=1, blast_irreversibility=1, evidence_diagnosis=1, verification_complexity=1
- review capability floor: official review minimum `cloud-G09`
- review final: `cloud-G09`, `CODE_REVIEW-cloud-G09.md`
## 구현 체크리스트
- [ ] exhausted/ineligible 첫 후보를 `selected`에서 제외하고, eligible 대체 후보 선택과 no-eligible 결과를 selector 회귀 테스트로 고정한다.
- [ ] overall/model 비유한 percent를 `unknown`으로 정규화하고 명시적 빈 target/command를 checker 호출 전 입력 오류로 거부하는 Go 테스트를 추가한다.
- [ ] 동일 Go 환경 preflight를 통과한 뒤 fresh Go package 테스트와 selector/policy 테스트 및 정적 검증을 모두 통과시킨다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.
## 의존 관계 및 구현 순서
1. predecessor `01_selector_contract`는 archived `complete.log`로 충족됐다.
2. selector admission을 먼저 고정해 consumer의 eligible/no-eligible 의미를 확정한다.
3. Go producer의 유한 percent와 non-empty identity를 보강한다.
4. 동일 Go 환경이 정상인 상태에서 전체 검증을 fresh 실행한다.
### [REVIEW_API-1] Exhausted 후보 선택 차단
#### 문제
`agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py:438`은 candidate마다 eligibility를 계산하지만 `:455`에서 무조건 첫 policy candidate를 선택한다.
현재:
```python
# select_execution_target.py:438
candidates = []
for rank, target in enumerate(decision.candidates, start=1):
mode, status = _candidate_quota(target, quota_snapshot)
eligible = status != "exhausted"
candidates.append({...})
selected = decision.candidates[0]
```
이 때문에 주간 `local-G07`의 Gemini가 exhausted이고 Laguna가 eligible이어도 selected는 Gemini다.
#### 해결 방법
policy order를 유지한 채 candidate entry와 원본 target을 함께 평가해 첫 eligible target만 `selected`로 사용한다. eligible target이 없으면 exhausted target을 반환하지 말고 안정적인 selector 오류 `no_eligible_target`으로 종료한다. `unknown`은 기존대로 eligible이고 local은 `unbounded/not_applicable`이므로 S04의 1회 admission 의미를 유지한다.
#### 수정 파일 및 체크리스트
- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py` — first eligible selection과 deterministic no-eligible 오류를 구현한다.
- [ ] `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py` — exhausted primary/eligible alternate와 all-exhausted 회귀를 추가한다.
#### 테스트 작성
작성한다.
- `test_exhausted_primary_selects_eligible_alternate`: 주간 local G07에서 agy exhausted, pi local eligible이면 selected가 pi인지 검증한다.
- `test_all_candidates_exhausted_returns_no_eligible_target`: 단일 cloud candidate exhausted가 실행 대상으로 반환되지 않고 안정적인 오류 envelope로 끝나는지 검증한다.
- 기존 `test_quota_probe_tri_state`는 exhausted candidate의 표시와 선택 결과를 함께 검증하도록 갱신한다.
#### 중간 검증
```bash
python3 agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py -v -k SelectorQuotaRepresentationTests
```
기대 결과: exhausted target은 selected가 아니며 unknown/available/local 회귀를 포함한 quota representation tests가 모두 PASS한다.
### [REVIEW_API-2] Quota producer 유한성 및 identity 검증
#### 문제
`apps/node/internal/adapters/cli/status/quota.go:148`은 `ParseFloat` 뒤 범위만 확인해 NaN을 허용하고, `apps/node/cmd/node/quota_probe.go:37`은 required flag가 명시적으로 빈 값인 경우 checker 호출 전에 막지 않는다.
현재:
```go
// quota.go:148
parsed, err := strconv.ParseFloat(value, 64)
if err != nil || parsed < 0 || parsed > 100 {
return 0, false
}
```
```go
// quota_probe.go:37
adapter := quotaAdapter(command)
usage, checkErr := quotaCheckUsage(cmd.Context(), target, config.CLIProfileConf{Command: command})
```
#### 해결 방법
`math.IsNaN(parsed)`와 `math.IsInf(parsed, 0)`을 범위 오류와 함께 거부해 비유한 값은 `unknown` cap evidence로 수렴시킨다. command 실행 전 `target`과 `command`를 trim하고 둘 중 하나라도 비면 입력 오류로 종료하며 checker를 호출하지 않는다. 정상 command basename adapter 정규화와 raw output/error 비노출은 유지한다.
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/adapters/cli/status/quota.go` — 유한 percent 검증을 추가한다.
- [ ] `apps/node/internal/adapters/cli/status/quota_test.go` — overall/model `NaN`/`Inf`가 unknown인지 검증한다.
- [ ] `apps/node/cmd/node/quota_probe.go` — non-empty target/command 검증을 checker 호출 전에 추가한다.
- [ ] `apps/node/cmd/node/quota_probe_test.go` — explicit empty/whitespace flag와 checker-not-called assertion을 추가한다.
#### 테스트 작성
작성한다.
- `TestNormalizeQuotaSnapshotUnknownForNonFinitePercent`: overall remaining 및 model used percent의 `NaN`/`Inf`가 unknown이고 JSON marshal 가능한지 검증한다.
- `TestQuotaProbeCommandRejectsEmptyTargetAndCommand`: `--target=`/`--command=` 및 whitespace 값이 non-zero 입력 오류이며 fake checker가 호출되지 않는지 검증한다.
#### 중간 검증
```bash
go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node
```
기대 결과: producer boundary/command tests와 기존 parser/operational command 회귀가 모두 PASS한다.
### [REVIEW_API-3] Fresh 검증 신뢰 회복
#### 문제
`code_review_cloud_G09_0.log`의 필수 Go 검증은 `/config/opt/go/src/crypto/internal/fips140cache` 누락으로 package setup 단계에서 실패했다. 새 Go 코드의 컴파일과 assertion 실행 근거가 없다.
#### 해결 방법
`PATH`에서 처음 해석되는 `/config/.local/bin/go`와 `GOROOT=/config/opt/go`를 유지한다. `/config/opt/go` source가 go1.26.2 binary와 일치하고 `crypto/internal/fips140cache`가 존재하는지 preflight한 뒤 cache 없는 대상 package tests를 실행한다. 설치가 여전히 불일치하면 다른 toolchain으로 우회하지 않고 실제 출력과 동일 재개 조건을 review stub에 기록한다.
#### 수정 파일 및 체크리스트
- [ ] repo 파일 수정 없음 — host Go/GOROOT 일치 여부를 검증한다.
- [ ] Go package, Python selector/policy, py_compile, gofmt, diff 검증의 실제 출력을 review stub에 기록한다.
#### 테스트 작성
새 테스트는 REVIEW_API-1/2에서 작성한다. 이 항목은 그 테스트가 실제 compiler/runtime에서 실행됐다는 evidence를 닫는다.
#### 중간 검증
```bash
command -v go && readlink -f "$(command -v go)" && go version && go env GOROOT
test -d "$(go env GOROOT)/src/crypto/internal/fips140cache"
```
기대 결과: 같은 host Go/GOROOT identity가 유지되고 표준 라이브러리 source 디렉터리가 존재한다.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py` | REVIEW_API-1 |
| `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py` | REVIEW_API-1 |
| `apps/node/internal/adapters/cli/status/quota.go` | REVIEW_API-2 |
| `apps/node/internal/adapters/cli/status/quota_test.go` | REVIEW_API-2 |
| `apps/node/cmd/node/quota_probe.go` | REVIEW_API-2 |
| `apps/node/cmd/node/quota_probe_test.go` | REVIEW_API-2 |
## 최종 검증
```bash
command -v go && readlink -f "$(command -v go)" && go version && go env GOROOT
test -d "$(go env GOROOT)/src/crypto/internal/fips140cache"
go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_execution_target*.py'
python3 agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.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/tests/test_execution_target_policy.py agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py
gofmt -d apps/node/internal/adapters/cli/status/quota.go apps/node/internal/adapters/cli/status/quota_test.go apps/node/cmd/node/quota_probe.go apps/node/cmd/node/quota_probe_test.go apps/node/cmd/node/main.go
git diff --check
```
기대 결과: Go/GOROOT source가 일치하고 fresh Go package tests, Python policy/selector tests, py_compile, gofmt, diff check가 모두 PASS한다. 외부 provider/credential은 호출하지 않는다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다.

View file

@ -0,0 +1,198 @@
<!-- task=m-agent-task-runtime-target-selector/02+01_quota_input plan=2 tag=REVIEW_REVIEW_API -->
# Go 검증 환경 복구 후 S04 Evidence 닫기
## 이 파일을 읽는 구현 에이전트에게
구현과 검증을 완료한 뒤 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 내용과 출력으로 채운다. active PLAN/CODE_REVIEW 파일은 그대로 두고 리뷰 준비 완료만 보고한다. 차단되면 구현 소유 evidence 필드에 정확한 차단 원인, 시도한 명령과 출력, 재개 조건만 기록한다. 사용자에게 질문하거나 user-input 도구를 호출하거나 control-plane stop 파일을 만들거나 다음 상태를 판정하지 않는다. 로그 archive, `complete.log`, 최종 판정은 code-review skill만 수행한다.
## 배경
selector admission, non-finite percent 정규화, 빈 quota-probe identity 차단 코드는 정적 검토와 Python 회귀에서 확인됐다. 그러나 host Go 1.26.2 binary와 `/config/opt/go` 표준 라이브러리 source가 불일치해 두 필수 Go 패키지가 setup 단계에서 계속 실패한다. 동일 toolchain이 복구된 뒤 fresh Go 회귀와 전체 검증을 통과시켜 SDD S04 Evidence Map을 닫는다.
## Archive Evidence Snapshot
- Prior task: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input`
- Archived plan: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/plan_cloud_G07_1.log`
- Archived review: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/code_review_cloud_G09_1.log`
- Verdict: `FAIL`
- Findings: Required 1, Suggested 0, Nit 0
- `/config/opt/go/src/crypto/internal/fips140cache` 누락으로 필수 Go 두 패키지가 setup 단계에서 실패해 새 producer/command의 컴파일과 회귀 assertion evidence가 없다.
- Affected files: `apps/node/internal/adapters/cli/status/quota.go`, `apps/node/internal/adapters/cli/status/quota_test.go`, `apps/node/cmd/node/quota_probe.go`, `apps/node/cmd/node/quota_probe_test.go`, `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`
- Verification evidence:
- host Go: `/config/.local/bin/go` → `/config/opt/go/bin/go`, `go1.26.2 linux/arm64`, `GOROOT=/config/opt/go`
- Go preflight와 두 package test: FAIL, `crypto/internal/fips140cache` missing
- Python policy 5개와 selector 32개 테스트: PASS
- `py_compile`, `gofmt -d`, `git diff --check`: PASS
- Roadmap carryover: SDD S04와 Milestone Task `quota-input`은 fresh Go 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:
- `quota-input`: Go usage checker 기반 required-cap quota snapshot과 selector admission
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-ops/rules/common/rules-agent-spec.md`
- `agent-ops/rules/common/philosophy.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/finalize-task-routing/SKILL.md`
- `agent-test/local/rules.md`
- `agent-test/local/node-smoke.md`
- `agent-spec/index.md`
- `agent-contract/index.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-task/m-agent-task-runtime-target-selector/02+01_quota_input/plan_cloud_G07_1.log`
- `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/code_review_cloud_G09_1.log`
- `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_execution_target_policy.py`
- `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py`
- `apps/node/internal/adapters/cli/status/status.go`
- `apps/node/internal/adapters/cli/status/parser.go`
- `apps/node/internal/adapters/cli/status/quota.go`
- `apps/node/internal/adapters/cli/status/quota_test.go`
- `apps/node/cmd/node/main.go`
- `apps/node/cmd/node/main_test.go`
- `apps/node/cmd/node/quota_probe.go`
- `apps/node/cmd/node/quota_probe_test.go`
### SDD 기준
- SDD: `agent-roadmap/sdd/automation-runtime-bridge/agent-task-runtime-target-selector/SDD.md`
- 상태: `[승인됨]`, SDD 잠금 `해제`, 사용자 리뷰 없음
- 대상: S04 → `quota-input`
- Evidence Map: Go usage checker JSON bridge, required-cap tri-state, unknown 1회 admission, local `unbounded/not_applicable` 테스트
- producer/command Go 회귀가 실제 compiler/runtime에서 통과한 evidence와 selector black-box 회귀를 한 최종 검증으로 묶어 S04 완료 여부를 판정한다.
### 테스트 환경 규칙
- `test_env=local`
- `agent-test/local/rules.md`와 `agent-test/local/node-smoke.md`를 읽었다.
- node-smoke의 Edge session/handler/reconnect 범위는 hidden quota command와 직접 일치하지 않아 대상 Go package 테스트와 selector black-box 테스트를 fallback으로 사용한다.
- Go cache를 사용하지 않고 `-count=1`을 적용한다. `PATH`에서 처음 해석되는 Go와 `GOROOT`를 유지하며 별도 `GOTOOLCHAIN` 또는 다른 Go binary로 우회하지 않는다.
- 현재 preflight:
- runner/repo: 현재 local checkout `/config/workspace/iop`
- Go: `/config/.local/bin/go` → `/config/opt/go/bin/go`, `go1.26.2 linux/arm64`, `GOROOT=/config/opt/go`
- blocker: `/config/opt/go/src/crypto/internal/fips140cache` 누락
- 재개 조건: `/config/opt/go` 표준 라이브러리 source를 go1.26.2 binary와 일치하도록 복구한다.
- external provider/credential/port: 필요 없음
### 테스트 커버리지 공백
- exhausted primary/eligible alternate와 no-eligible 결과는 Python 회귀가 통과했다.
- overall/model `NaN`/`Inf`와 빈 target/command checker-not-called 회귀는 작성됐지만 현재 Go 설치에서 compile/assertion 실행 evidence가 없다.
- Go 환경 복구 뒤 두 package test가 통과하면 현재 범위의 남은 커버리지 공백은 없다.
### 심볼 참조
- renamed/removed symbol: none
### 분할 판단
- split 정책을 파일 선택 전에 적용했다.
- task group: `m-agent-task-runtime-target-selector`
- 현재 subtask: `02+01_quota_input`
- 남은 작업은 기존 producer/command와 selector 회귀를 같은 host Go identity에서 검증하는 단일 evidence closure다. 별도 subtask로 나누면 하나의 S04 완료 evidence만 인위적으로 분리하므로 기존 subtask의 단일 follow-up pair를 유지한다.
### 범위 결정 근거
- repo source 동작은 변경하지 않는다. fresh 검증에서 새 source defect가 드러나면 이 plan에서 추측 수정하지 않고 실제 failure evidence를 review stub에 기록한다.
- `/config/opt/go` 설치 복구는 repo 작업 범위 밖이다. 구현 에이전트는 다른 toolchain 설치·선택이나 `GOROOT` source 수정을 수행하지 않는다.
- dispatcher integration, failover, failure budget, route pin은 후속 Milestone Task 소유이므로 수정하지 않는다.
- `agent-roadmap/**`, `agent-spec/**`, `agent-contract/**`는 수정하지 않는다.
### 최종 라우팅
- evaluation_mode: `isolated-reassessment`
- finalizer: `finalize-task-policy.sh`
- finalizer_mode: `pair`
- build closures: scope/context/verification/evidence/ownership/decision 모두 `true`
- build closure basis: source 수정 없이 동일 host identity와 결정적 fresh 명령으로 남은 evidence를 닫으며, 환경 차단 시에도 정확한 실패와 재개 조건을 기록할 수 있다.
- build base route: `local-G02`
- build grade scores: scope_coupling=0, state_concurrency=0, blast_irreversibility=0, evidence_diagnosis=1, verification_complexity=1
- build loop-risk:
- ordered_transitions: state_count=1, adverse_paths=`resume`; 환경 재개 절차는 동작 상태 signature에서 제외
- concurrent_consistency: actor_count=0, constraints=없음
- boundary_contract: component_count=0, consumer_count=0; 동작 계약 변경 없음
- structured_interpretation: mechanisms/hazards=없음
- variant_product: independent_axis_count=0, combination_verification_required=false
- matched signatures: 없음, triggered=`false`
- build final: `local-G02`, `PLAN-local-G02.md`
- review closures: scope/context/verification/evidence/ownership/decision 모두 `true`
- review closure basis: archived failure, 현재 host preflight, fresh Go/Python/static 출력만으로 PASS 여부를 독립 판정할 수 있다.
- review base route: `local-G03`
- review grade scores: scope_coupling=1, state_concurrency=0, blast_irreversibility=0, evidence_diagnosis=1, verification_complexity=1
- review capability floor: official review minimum `cloud-G09`
- review final: `cloud-G09`, `CODE_REVIEW-cloud-G09.md`
## 구현 체크리스트
- [ ] 동일 PATH Go와 `GOROOT`에서 표준 라이브러리 preflight를 통과한 뒤 `go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node`를 통과시키고 실제 출력을 기록한다.
- [ ] Python policy/selector 회귀와 `py_compile`, `gofmt -d`, `git diff --check`를 다시 통과시켜 Go evidence와 같은 checkout의 최종 검증을 완성한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.
### [REVIEW_REVIEW_API-1] Fresh Go 및 S04 Evidence Closure
#### 문제
`agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/code_review_cloud_G09_1.log:72`의 필수 검증은 `/config/opt/go/src/crypto/internal/fips140cache` 누락으로 두 Go 패키지 setup 단계에서 실패한다. 따라서 `quota.go`, `quota_probe.go`와 새 Go 회귀의 컴파일·실행 evidence가 없다.
#### 해결 방법
`PATH`에서 처음 해석되는 `/config/.local/bin/go`와 `GOROOT=/config/opt/go` identity를 유지한다. 표준 라이브러리 source가 go1.26.2 binary와 일치하는지 preflight하고, 통과한 경우에만 fresh Go package tests와 Python/static 검증을 실행한다. preflight가 계속 실패하면 다른 toolchain으로 우회하지 않고 실제 출력과 동일 재개 조건을 review stub에 기록한다.
#### 수정 파일 및 체크리스트
- [ ] repo source 파일 수정 없음 — host Go/GOROOT preflight와 fresh tests만 실행한다.
- [ ] `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/CODE_REVIEW-cloud-G09.md` — 실제 명령 출력과 blocker 또는 PASS evidence를 기록한다.
#### 테스트 작성
새 테스트는 작성하지 않는다. 필요한 producer/command/selector 회귀는 이미 존재하며 이번 항목은 그 테스트가 실제 compiler/runtime에서 실행됐다는 evidence를 닫는다.
#### 중간 검증
```bash
command -v go && readlink -f "$(command -v go)" && go version && go env GOROOT
test -d "$(go env GOROOT)/src/crypto/internal/fips140cache"
go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node
```
기대 결과: 동일 Go/GOROOT identity에서 표준 라이브러리 preflight와 두 package test가 모두 PASS한다.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| repo source 변경 없음 | REVIEW_REVIEW_API-1 |
| `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/CODE_REVIEW-cloud-G09.md` | REVIEW_REVIEW_API-1 evidence |
## 최종 검증
```bash
command -v go && readlink -f "$(command -v go)" && go version && go env GOROOT
test -d "$(go env GOROOT)/src/crypto/internal/fips140cache"
go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_execution_target*.py'
python3 agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.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/tests/test_execution_target_policy.py agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py
gofmt -d apps/node/internal/adapters/cli/status/quota.go apps/node/internal/adapters/cli/status/quota_test.go apps/node/cmd/node/quota_probe.go apps/node/cmd/node/quota_probe_test.go apps/node/cmd/node/main.go
git diff --check
```
기대 결과: Go/GOROOT source가 일치하고 fresh Go package tests, Python policy/selector tests, py_compile, gofmt, diff check가 모두 PASS한다. 외부 provider/credential은 호출하지 않는다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다.

View file

@ -0,0 +1,210 @@
<!-- task=m-agent-task-runtime-target-selector/02+01_quota_input plan=3 tag=REVIEW_REVIEW_REVIEW_API -->
# Go 표준 라이브러리 복구 확인 후 S04 Evidence 닫기
## 이 파일을 읽는 구현 에이전트에게
검증을 완료한 뒤 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 내용과 stdout/stderr로 채운다. active PLAN/CODE_REVIEW 파일은 그대로 두고 리뷰 준비 완료만 보고한다. 차단되면 정확한 원인, 시도한 명령과 출력, 재개 조건만 구현 소유 evidence 필드에 기록한다. 사용자에게 질문하거나 user-input 도구를 호출하거나 control-plane stop 파일을 만들거나 다음 상태를 판정하지 않는다. 로그 archive, `complete.log`, 최종 판정은 code-review skill만 수행한다.
## 배경
quota producer/command와 selector admission 코드는 정적 검토와 Python 회귀에서 확인됐다. 그러나 host Go 1.26.2 binary와 `/config/opt/go` 표준 라이브러리 source가 불일치해 두 필수 Go 패키지가 setup 단계에서 계속 실패한다. 같은 toolchain이 복구된 뒤 fresh Go 회귀와 전체 검증을 통과시켜 SDD S04 Evidence Map을 닫는다.
## Archive Evidence Snapshot
- Prior task: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input`
- Archived plan: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/plan_local_G02_2.log`
- Archived review: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/code_review_cloud_G09_2.log`
- Verdict: `FAIL`
- Findings: Required 1, Suggested 0, Nit 0
- `/config/opt/go/src/crypto/internal/fips140cache` 누락으로 필수 Go 두 패키지가 setup 단계에서 실패해 새 producer/command의 컴파일과 회귀 assertion evidence가 없다.
- Affected files: `apps/node/internal/adapters/cli/status/quota.go`, `apps/node/internal/adapters/cli/status/quota_test.go`, `apps/node/cmd/node/quota_probe.go`, `apps/node/cmd/node/quota_probe_test.go`, `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`
- Verification evidence:
- host Go: `/config/.local/bin/go` → `/config/opt/go/bin/go`, `go1.26.2 linux/arm64`, `GOROOT=/config/opt/go`
- Go preflight와 두 package test: FAIL, `crypto/internal/fips140cache` missing
- Python policy 5개와 selector 32개 테스트: PASS
- `py_compile`, `gofmt -d`, `git diff --check`: PASS
- Roadmap carryover: SDD S04와 Milestone Task `quota-input`은 fresh Go 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:
- `quota-input`: Go usage checker 기반 required-cap quota snapshot과 selector admission
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/rules/common/rules-agent-spec.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/finalize-task-routing/SKILL.md`
- `agent-test/local/rules.md`
- `agent-test/local/node-smoke.md`
- `agent-spec/index.md`
- `agent-spec/runtime/edge-node-execution.md`
- `agent-contract/index.md`
- `agent-contract/inner/edge-node-runtime-wire.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-task/archive/2026/07/m-agent-task-runtime-target-selector/01_selector_contract/complete.log`
- `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/plan_local_G02_2.log`
- `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/code_review_cloud_G09_2.log`
- `apps/node/internal/adapters/cli/status/status.go`
- `apps/node/internal/adapters/cli/status/parser.go`
- `apps/node/internal/adapters/cli/status/quota.go`
- `apps/node/internal/adapters/cli/status/quota_test.go`
- `apps/node/cmd/node/main.go`
- `apps/node/cmd/node/quota_probe.go`
- `apps/node/cmd/node/quota_probe_test.go`
- `packages/go/config/provider_types.go`
- `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_execution_target_policy.py`
- `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py`
### SDD 기준
- SDD: `agent-roadmap/sdd/automation-runtime-bridge/agent-task-runtime-target-selector/SDD.md`
- 상태: `[승인됨]`, SDD 잠금 `해제`, 사용자 리뷰 없음
- 대상: S04 → `quota-input`
- Evidence Map: Go usage checker JSON bridge, required-cap tri-state, unknown 1회 admission, local `unbounded/not_applicable` 테스트
- producer/command Go 회귀가 실제 compiler/runtime에서 통과한 evidence와 selector black-box 회귀를 한 최종 검증으로 묶어 S04 완료 여부를 판정한다.
### 테스트 환경 규칙
- `test_env=local`; `agent-test/local/rules.md`와 `agent-test/local/node-smoke.md`를 읽었다.
- node-smoke의 transport/bootstrap command는 hidden quota command와 직접 일치하지 않아 대상 Go package 테스트와 selector black-box 테스트를 fallback으로 사용한다.
- Go cache를 사용하지 않고 `-count=1`을 적용한다. PATH에서 처음 해석되는 Go와 `GOROOT`를 유지하며 별도 `GOTOOLCHAIN` 또는 다른 Go binary로 우회하지 않는다.
- 현재 preflight:
- runner/repo: local `/config/workspace/iop`, branch `main`, HEAD `3f01d08cebdafa1bddd1089e6940677dcb6c90b1`, dirty checkout
- OS/arch: `Linux/aarch64`
- Go: `/config/.local/bin/go` → `/config/opt/go/bin/go`, `go1.26.2 linux/arm64`, `GOROOT=/config/opt/go`
- blocker: `/config/opt/go/src/crypto/internal/fips140cache` 누락
- 재개 조건: `/config/opt/go` 표준 라이브러리 source를 go1.26.2 binary와 일치하도록 복구한다.
- external provider/credential/port: 필요 없음
### 테스트 커버리지 공백
- exhausted primary/eligible alternate와 no-eligible 결과는 Python 회귀가 통과했다.
- overall/model `NaN`/`Inf`와 빈 target/command checker-not-called 회귀는 작성됐지만 현재 Go 설치에서 compile/assertion 실행 evidence가 없다.
- Go 환경 복구 뒤 두 package test가 통과하면 현재 범위의 남은 커버리지 공백은 없다.
### 심볼 참조
- renamed/removed symbol: none
### 분할 판단
- split 정책을 파일 선택 전에 적용했다.
- task group: `m-agent-task-runtime-target-selector`
- 현재 subtask: `02+01_quota_input`
- predecessor `01_selector_contract`: `agent-task/archive/2026/07/m-agent-task-runtime-target-selector/01_selector_contract/complete.log`로 충족됐다.
- 남은 작업은 같은 host Go identity에서 producer/command와 selector evidence를 닫는 단일 검증 단위다. 구현·계약·테스트 경계가 새로 갈라지지 않으며 분할하면 S04의 동일 evidence만 인위적으로 나뉜다.
### 범위 결정 근거
- repo source 동작은 변경하지 않는다. fresh 검증에서 새 source defect가 드러나면 추측 수정하지 않고 실제 failure evidence를 review stub에 기록한다.
- `/config/opt/go` 설치 복구는 repo 작업 범위 밖이다. 구현 에이전트는 다른 toolchain 설치·선택이나 `GOROOT` source 수정을 수행하지 않는다.
- dispatcher integration, failover, failure budget, route pin과 `agent-roadmap/**`, `agent-spec/**`, `agent-contract/**`는 수정하지 않는다.
### 최종 라우팅
- evaluation_mode: `isolated-reassessment`
- finalizer: `finalize-task-policy.sh`
- finalizer_mode: `pair`
- build closures: scope/context/verification/evidence/ownership/decision 모두 `true`
- build closure basis: source 수정 없이 동일 host identity와 결정적 fresh 명령으로 남은 evidence를 닫으며, 환경 차단도 명시적 preflight 결과로 판정할 수 있다.
- build base route: `local-G02`
- build grade scores: scope_coupling=0, state_concurrency=0, blast_irreversibility=0, evidence_diagnosis=1, verification_complexity=1
- build loop-risk:
- ordered_transitions: state_count=1, adverse_paths=`resume`; 환경 재개 절차는 동작 상태 signature에서 제외
- concurrent_consistency: actor_count=0, constraints=없음
- boundary_contract: component_count=0, consumer_count=0, constraints=없음
- structured_interpretation: mechanisms/hazards=없음
- variant_product: independent_axis_count=0, combination_verification_required=false
- matched signatures: 없음, triggered=`false`
- build final: `local-G02`, `PLAN-local-G02.md`
- review closures: scope/context/verification/evidence/ownership/decision 모두 `true`
- review closure basis: archived failure, 현재 host preflight, fresh Go/Python/static 출력만으로 PASS 여부를 독립 판정할 수 있다.
- review base route: `local-G03`
- review grade scores: scope_coupling=1, state_concurrency=0, blast_irreversibility=0, evidence_diagnosis=1, verification_complexity=1
- review capability floor: official review minimum `cloud-G09`
- review final: `cloud-G09`, `CODE_REVIEW-cloud-G09.md`
## 구현 체크리스트
- [ ] 동일 PATH Go와 `GOROOT`에서 표준 라이브러리 preflight를 통과한 뒤 `go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node`를 통과시키고 실제 출력을 기록한다.
- Go preflight: PASS (`crypto/internal/fips140cache` EXISTS)
- Go package test: `iop/apps/node/internal/adapters/cli/status` FAIL, `iop/apps/node/cmd/node` PASS
- FAIL 상세: `TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure/missing` — `status = "available", want unknown`
- plan 범위 결정에 따라 source 수정 없이 failure evidence를 review stub에 기록
- [x] Python policy/selector 회귀와 `py_compile`, `gofmt -d`, `git diff --check`를 다시 통과시켜 Go evidence와 같은 checkout의 최종 검증을 완성한다.
- Python policy test: 5 tests OK
- Python selector test: 32 tests OK
- py_compile: OK
- gofmt -d: OK
- git diff --check: OK
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.
### [REVIEW_REVIEW_REVIEW_API-1] Fresh Go 및 S04 Evidence Closure
#### 문제
`agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/code_review_cloud_G09_2.log:137`의 필수 검증은 `/config/opt/go/src/crypto/internal/fips140cache` 누락으로 두 Go 패키지 setup 단계에서 실패한다. 따라서 `quota.go`, `quota_probe.go`와 새 Go 회귀의 컴파일·실행 evidence가 없다.
#### 해결 방법
PATH에서 처음 해석되는 `/config/.local/bin/go`와 `GOROOT=/config/opt/go` identity를 유지한다. 표준 라이브러리 source가 go1.26.2 binary와 일치하는지 preflight하고, 통과한 경우에만 fresh Go package tests와 Python/static 검증을 실행한다. preflight가 계속 실패하면 다른 toolchain으로 우회하지 않고 실제 출력과 동일 재개 조건을 review stub에 기록한다.
#### 수정 파일 및 체크리스트
- [ ] repo source 파일 수정 없음 — host Go/GOROOT preflight와 fresh tests만 실행한다.
- [ ] `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/CODE_REVIEW-cloud-G09.md` — 실제 명령 출력과 blocker 또는 PASS evidence를 기록한다.
#### 테스트 작성
새 테스트는 작성하지 않는다. producer/command/selector 회귀가 이미 존재하며 이번 항목은 그 테스트가 실제 compiler/runtime에서 실행됐다는 evidence를 닫는다.
#### 중간 검증
```bash
command -v go && readlink -f "$(command -v go)" && go version && go env GOROOT
test -d "$(go env GOROOT)/src/crypto/internal/fips140cache"
go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node
```
기대 결과: 동일 Go/GOROOT identity에서 표준 라이브러리 preflight와 두 package test가 모두 PASS한다.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| repo source 변경 없음 | REVIEW_REVIEW_REVIEW_API-1 |
| `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/CODE_REVIEW-cloud-G09.md` | REVIEW_REVIEW_REVIEW_API-1 evidence |
## 최종 검증
```bash
command -v go && readlink -f "$(command -v go)" && go version && go env GOROOT
test -d "$(go env GOROOT)/src/crypto/internal/fips140cache"
go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_execution_target*.py'
python3 agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.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/tests/test_execution_target_policy.py agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py
gofmt -d apps/node/internal/adapters/cli/status/quota.go apps/node/internal/adapters/cli/status/quota_test.go apps/node/cmd/node/quota_probe.go apps/node/cmd/node/quota_probe_test.go apps/node/cmd/node/main.go
git diff --check
```
기대 결과: Go/GOROOT source가 일치하고 fresh Go package tests, Python policy/selector tests, py_compile, gofmt, diff check가 모두 PASS한다. 외부 provider/credential은 호출하지 않는다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다.

View file

@ -0,0 +1,238 @@
<!-- task=m-agent-task-runtime-target-selector/02+01_quota_input plan=4 tag=REVIEW_REVIEW_REVIEW_REVIEW_API -->
# Required-cap 누락 fixture 수정과 S04 Evidence Closure
## 이 파일을 읽는 구현 에이전트에게
구현과 검증을 완료한 뒤 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 내용과 stdout/stderr로 채운다. active PLAN/CODE_REVIEW 파일은 그대로 두고 리뷰 준비 완료만 보고한다. 차단되면 정확한 원인, 시도한 명령과 출력, 재개 조건만 구현 소유 evidence 필드에 기록한다. 사용자에게 질문하거나 user-input 도구를 호출하거나 control-plane stop 파일을 만들거나 다음 상태를 판정하지 않는다. 로그 archive, `complete.log`, 최종 판정은 code-review skill만 수행한다.
## 배경
호스트 Go/GOROOT 정합성은 복구되어 필수 Go 테스트가 실제 assertion까지 실행됐다. 그 결과 `missing` 하위 테스트가 유효한 `overall=50%`만 요구하면서 `unknown`을 기대하는 fixture 오류가 드러났다. 실제 누락 required cap을 구성하도록 테스트를 바로잡고 전체 S04 검증을 통과시킨다.
## Archive Evidence Snapshot
- Prior task: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input`
- Archived plan: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/plan_local_G02_3.log`
- Archived review: `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/code_review_cloud_G09_3.log`
- Verdict: `FAIL`
- Findings: Required 1, Suggested 0, Nit 0
- `quota_test.go`의 `missing` 케이스는 `requiredCaps=["overall"]`과 유효한 `DailyLimit="50%"`를 주므로 `available`이 맞고 실제 누락 cap을 검증하지 않는다.
- Affected files: `apps/node/internal/adapters/cli/status/quota_test.go`
- Verification evidence:
- host Go: `/config/.local/bin/go` → `/config/opt/go/bin/go`, `go1.26.2 linux/arm64`, `GOROOT=/config/opt/go`
- `crypto/internal/fips140cache` preflight: PASS
- Go package tests: status package FAIL 1건, node command package PASS
- focused evidence: exhausted+missing과 all-positive는 PASS, missing/conflict/parse 중 `missing`만 FAIL
- Python policy 5개와 selector 32개 테스트, `py_compile`, `gofmt -d`, `git diff --check`: PASS
- Roadmap carryover: SDD S04와 Milestone Task `quota-input`은 corrected fixture와 fresh 전체 검증이 통과할 때까지 미완료다.
## 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:
- `quota-input`: Go usage checker 기반 required-cap quota snapshot과 selector admission
- Completion mode: check-on-pass
## 분석 결과
### 읽은 파일
- `agent-ops/rules/project/rules.md`
- `agent-ops/rules/common/rules-roadmap.md`
- `agent-ops/rules/project/domain/node/rules.md`
- `agent-ops/rules/project/domain/testing/rules.md`
- `agent-ops/rules/common/rules-agent-spec.md`
- `agent-ops/skills/common/router.md`
- `agent-ops/skills/common/code-review/SKILL.md`
- `agent-ops/skills/common/plan/SKILL.md`
- `agent-ops/skills/common/finalize-task-routing/SKILL.md`
- `agent-ops/skills/common/plan/templates/review-stub-template.md`
- `agent-test/local/rules.md`
- `agent-test/local/node-smoke.md`
- `agent-spec/index.md`
- `agent-spec/runtime/edge-node-execution.md`
- `agent-contract/index.md`
- `agent-contract/inner/edge-node-runtime-wire.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-task/archive/2026/07/m-agent-task-runtime-target-selector/01_selector_contract/complete.log`
- `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/plan_local_G02_2.log`
- `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/code_review_cloud_G09_2.log`
- `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/plan_local_G02_3.log`
- `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/code_review_cloud_G09_3.log`
- `apps/node/internal/adapters/cli/status/status.go`
- `apps/node/internal/adapters/cli/status/parser.go`
- `apps/node/internal/adapters/cli/status/quota.go`
- `apps/node/internal/adapters/cli/status/quota_test.go`
- `apps/node/cmd/node/main.go`
- `apps/node/cmd/node/quota_probe.go`
- `apps/node/cmd/node/quota_probe_test.go`
- `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_execution_target_policy.py`
- `agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py`
### SDD 기준
- SDD: `agent-roadmap/sdd/automation-runtime-bridge/agent-task-runtime-target-selector/SDD.md`
- 상태: `[승인됨]`, SDD 잠금 `해제`, 사용자 리뷰 없음
- 대상: S04 → `quota-input`
- Evidence Map: Go usage checker JSON bridge, required-cap tri-state, unknown 1회 admission, local `unbounded/not_applicable` 테스트
- S04는 required cap이 모두 양수일 때 `available`, 하나라도 누락되면 `unknown`을 요구한다. 따라서 positive overall과 missing model cap을 함께 선언하는 fixture 및 Go/Python 통합 회귀를 구현·완료 evidence로 사용한다.
### 테스트 환경 규칙
- `test_env=local`; `agent-test/local/rules.md`와 `agent-test/local/node-smoke.md`를 읽었다.
- node-smoke의 session/handler/reconnect full-cycle은 hidden quota probe와 직접 일치하지 않으므로 대상 Go package 테스트와 selector black-box 회귀를 fallback으로 사용한다.
- Go cache를 배제하기 위해 `-count=1`을 적용하며 PATH 첫 Go와 `GOROOT`를 바꾸지 않는다.
- 현재 preflight:
- runner/repo: local `/config/workspace/iop`, branch `main`, HEAD `3f01d08cebdafa1bddd1089e6940677dcb6c90b1`, dirty checkout
- OS/arch: `Linux/aarch64`
- Go: `/config/.local/bin/go` → `/config/opt/go/bin/go`, `go1.26.2 linux/arm64`, `GOROOT=/config/opt/go`
- standard library preflight: `crypto/internal/fips140cache` 존재
- external provider/credential/port: 필요 없음
### 테스트 커버리지 공백
- exhausted cap이 missing cap보다 우선하는 경우와 모든 required cap이 양수인 경우는 focused Go 테스트가 통과한다.
- conflict와 parse failure의 `unknown` 회귀는 통과한다.
- `missing` 하위 테스트만 실제 누락 cap을 구성하지 않아 required-cap missing 회귀가 비어 있다. 이번 변경이 이 공백을 닫는다.
- Python selector는 tri-state 반영, exhausted alternate/no-eligible, unknown 1회 admission, local unbounded를 32개 회귀에서 검증하며 현재 통과한다.
### 심볼 참조
- renamed/removed symbol: none
### 분할 판단
- split 정책을 파일 선택 전에 적용했다.
- task group: `m-agent-task-runtime-target-selector`
- 현재 subtask: `02+01_quota_input`
- predecessor `01_selector_contract`: `agent-task/archive/2026/07/m-agent-task-runtime-target-selector/01_selector_contract/complete.log`로 충족됐다.
- 남은 변경은 한 Go 테스트 함수의 fixture와 같은 S04 전체 검증으로 구성된 단일 test-only correction이다. 별도 subtask는 구현과 증거를 인위적으로 분리하므로 현재 subtask의 단일 pair를 유지한다.
### 범위 결정 근거
- `apps/node/internal/adapters/cli/status/quota_test.go`만 수정한다.
- `quota.go`는 유효한 positive overall을 `available`로 반환해 SDD와 일치하므로 production 동작을 바꾸지 않는다.
- quota probe, selector, policy, dispatcher, roadmap, spec, contract는 수정하지 않고 회귀 검증 대상으로만 둔다.
### 최종 라우팅
- evaluation_mode: `isolated-reassessment`
- finalizer: `finalize-task-policy.sh`
- finalizer_mode: `pair`
- build closures: scope/context/verification/evidence/ownership/decision 모두 `true`
- scope/context: 단일 fixture 변경과 직접 관련 source/test/SDD가 고정됐다.
- verification/evidence: 재현된 assertion과 결정적 focused/full 명령으로 성공 여부를 판정할 수 있다.
- ownership/decision: 외부 권한·제품 결정 없이 현재 subtask 안에서 완료 가능하다.
- build base route: `local-G02`
- build grade scores: scope_coupling=0, state_concurrency=0, blast_irreversibility=0, evidence_diagnosis=1, verification_complexity=1
- build loop-risk:
- ordered_transitions: state_count=0, adverse_paths=없음
- concurrent_consistency: actor_count=0, constraints=없음
- boundary_contract: component_count=1, consumer_count=1, constraints=없음
- structured_interpretation: mechanisms/hazards=없음
- variant_product: independent_axis_count=1, combination_verification_required=false
- matched signatures: 없음, triggered=`false`
- build final: `local-G02`, `PLAN-local-G02.md`
- review closures: scope/context/verification/evidence/ownership/decision 모두 `true`
- source, test, SDD와 fresh Go/Python 출력을 함께 대조하면 독립 판정 가능하다.
- review base route: `local-G03`
- review grade scores: scope_coupling=1, state_concurrency=0, blast_irreversibility=0, evidence_diagnosis=1, verification_complexity=1
- review capability floor: official review minimum `cloud-G09`
- review final: `cloud-G09`, `CODE_REVIEW-cloud-G09.md`
## 구현 체크리스트
- [ ] `apps/node/internal/adapters/cli/status/quota_test.go`의 missing/conflict/parse table에 하위 테스트별 required cap을 명시하고, `missing`이 positive overall과 누락된 model cap의 aggregate `unknown`을 실제로 검증하도록 수정한 뒤 focused Go 테스트를 통과시킨다.
- [ ] 동일 PATH Go와 `GOROOT`에서 두 Go 패키지, Python policy/selector 회귀, `py_compile`, `gofmt -d`, `git diff --check`를 fresh 실행해 모두 통과시키고 실제 출력을 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다.
### [REVIEW_REVIEW_REVIEW_REVIEW_API-1] Missing cap fixture와 S04 evidence 수정
#### 문제
`apps/node/internal/adapters/cli/status/quota_test.go:42-58`은 `missing`에 `DailyLimit: "50%"`를 주면서 required cap을 `overall` 하나로 유지한다. 이 입력에는 누락 cap이 없으므로 `NormalizeQuotaSnapshot`의 `available` 결과가 맞고, 테스트의 `unknown` 기대가 SDD S04와 충돌한다.
#### 해결 방법
하위 테스트별 `requiredCaps`와 `usage`를 같은 table fixture에 묶는다. `missing`은 `overall`과 `model:missing`을 요구하고 positive overall만 제공해 누락된 required model cap 때문에 aggregate `unknown`이 되는 경계를 검증한다.
Before (`quota_test.go:42-58`):
```go
for name, usage := range map[string]*UsageStatus{
"missing": {DailyLimit: "50%"},
// ...
} {
t.Run(name, func(t *testing.T) {
caps := []string{"overall"}
if name == "conflict" {
caps = []string{"model:flash"}
}
snapshot := NormalizeQuotaSnapshot("agy", "Gemini", caps, quotaCheckedAt, usage, nil)
```
After:
```go
for name, tc := range map[string]struct {
requiredCaps []string
usage *UsageStatus
}{
"missing": {
requiredCaps: []string{"overall", "model:missing"},
usage: &UsageStatus{DailyLimit: "50%"},
},
// conflict와 parse도 각 requiredCaps를 fixture에 명시
} {
t.Run(name, func(t *testing.T) {
snapshot := NormalizeQuotaSnapshot("agy", "Gemini", tc.requiredCaps, quotaCheckedAt, tc.usage, nil)
```
#### 수정 파일 및 체크리스트
- [ ] `apps/node/internal/adapters/cli/status/quota_test.go` — missing/conflict/parse fixture에 required cap을 명시하고 기존 assertion을 유지한다.
- [ ] production source는 수정하지 않는다.
#### 테스트 작성
기존 `TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure`가 의도한 regression test이므로 새 테스트를 추가하지 않고 fixture를 수정한다. `missing`은 positive overall + missing model, `conflict`는 duplicate matching model, `parse`는 malformed overall을 각각 `unknown`으로 assertion한다.
#### 중간 검증
```bash
command -v go && readlink -f "$(command -v go)" && go version && go env GOROOT
test -d "$(go env GOROOT)/src/crypto/internal/fips140cache" && echo "preflight: PASS"
go test -count=1 ./apps/node/internal/adapters/cli/status -run '^TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure$' -v
```
기대 결과: 동일 Go/GOROOT identity에서 preflight가 통과하고 missing/conflict/parse 하위 테스트가 모두 PASS한다.
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `apps/node/internal/adapters/cli/status/quota_test.go` | REVIEW_REVIEW_REVIEW_REVIEW_API-1 |
| `agent-task/m-agent-task-runtime-target-selector/02+01_quota_input/CODE_REVIEW-cloud-G09.md` | 구현 evidence |
## 최종 검증
```bash
command -v go && readlink -f "$(command -v go)" && go version && go env GOROOT
test -d "$(go env GOROOT)/src/crypto/internal/fips140cache" && echo "preflight: PASS"
go test -count=1 ./apps/node/internal/adapters/cli/status ./apps/node/cmd/node
python3 -m unittest discover -s agent-ops/skills/project/orchestrate-agent-task-loop/tests -p 'test_execution_target*.py'
python3 agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.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/tests/test_execution_target_policy.py agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py
gofmt -d apps/node/internal/adapters/cli/status/quota.go apps/node/internal/adapters/cli/status/quota_test.go apps/node/cmd/node/quota_probe.go apps/node/cmd/node/quota_probe_test.go apps/node/cmd/node/main.go
git diff --check
```
기대 결과: 동일 Go/GOROOT에서 focused/full Go 회귀, Python policy 5개, selector 32개, `py_compile`, `gofmt -d`, `git diff --check`가 모두 PASS한다. 외부 provider/credential은 호출하지 않는다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다.

View file

@ -0,0 +1,80 @@
# Milestone Work Log
> Dispatcher-owned execution timeline. Workers and reviewers do not edit this file.
| seq | time | event | task | role | attempt | model | result | locator |
|---:|---|---|---|---|---:|---|---|---|
| 1 | 2026-07-25T15:48:46.999560+09:00 | START | m-agent-task-runtime-target-selector/01_selector_contract | worker | 0 | codex/gpt-5.6-sol xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T064846Z__m-agent-task-runtime-target-selector__01_selector_contract__p0__worker__a00/locator.json |
| 2 | 2026-07-25T15:49:30.519198+09:00 | FINISH | m-agent-task-runtime-target-selector/01_selector_contract | worker | 0 | codex/gpt-5.6-sol xhigh | failed:cancelled | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T064846Z__m-agent-task-runtime-target-selector__01_selector_contract__p0__worker__a00/locator.json |
| 3 | 2026-07-25T16:05:14.485523+09:00 | START | m-agent-task-runtime-target-selector/01_selector_contract | worker | 1 | claude/claude-opus-4-8 xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T070514Z__m-agent-task-runtime-target-selector__01_selector_contract__p0__worker__a01/locator.json |
| 4 | 2026-07-25T16:05:47.972002+09:00 | FINISH | m-agent-task-runtime-target-selector/01_selector_contract | worker | 1 | claude/claude-opus-4-8 xhigh | failed:generic-error:1 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T070514Z__m-agent-task-runtime-target-selector__01_selector_contract__p0__worker__a01/locator.json |
| 5 | 2026-07-25T16:05:49.989935+09:00 | START | m-agent-task-runtime-target-selector/01_selector_contract | worker | 2 | claude/claude-opus-4-8 xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T070549Z__m-agent-task-runtime-target-selector__01_selector_contract__p0__worker__a02/locator.json |
| 6 | 2026-07-25T16:17:19.368701+09:00 | FINISH | m-agent-task-runtime-target-selector/01_selector_contract | worker | 2 | claude/claude-opus-4-8 xhigh | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T070549Z__m-agent-task-runtime-target-selector__01_selector_contract__p0__worker__a02/locator.json |
| 7 | 2026-07-25T16:17:19.471829+09:00 | START | m-agent-task-runtime-target-selector/01_selector_contract | review | 0 | codex/gpt-5.6-sol xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T071719Z__m-agent-task-runtime-target-selector__01_selector_contract__p0__review__a00/locator.json |
| 8 | 2026-07-25T16:26:49.976418+09:00 | FINISH | m-agent-task-runtime-target-selector/01_selector_contract | review | 0 | codex/gpt-5.6-sol xhigh | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T071719Z__m-agent-task-runtime-target-selector__01_selector_contract__p0__review__a00/locator.json |
| 9 | 2026-07-25T16:26:50.017252+09:00 | START | m-agent-task-runtime-target-selector/01_selector_contract | worker | 0 | claude/claude-opus-4-8 xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T072650Z__m-agent-task-runtime-target-selector__01_selector_contract__p1__worker__a00/locator.json |
| 10 | 2026-07-25T16:32:23.509987+09:00 | FINISH | m-agent-task-runtime-target-selector/01_selector_contract | worker | 0 | claude/claude-opus-4-8 xhigh | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T072650Z__m-agent-task-runtime-target-selector__01_selector_contract__p1__worker__a00/locator.json |
| 11 | 2026-07-25T16:32:23.574825+09:00 | START | m-agent-task-runtime-target-selector/01_selector_contract | review | 0 | codex/gpt-5.6-sol xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T073223Z__m-agent-task-runtime-target-selector__01_selector_contract__p1__review__a00/locator.json |
| 12 | 2026-07-25T16:40:05.246416+09:00 | FINISH | m-agent-task-runtime-target-selector/01_selector_contract | review | 0 | codex/gpt-5.6-sol xhigh | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T073223Z__m-agent-task-runtime-target-selector__01_selector_contract__p1__review__a00/locator.json |
| 13 | 2026-07-25T16:40:05.280938+09:00 | START | m-agent-task-runtime-target-selector/01_selector_contract | worker | 0 | claude/claude-opus-4-8 xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T074005Z__m-agent-task-runtime-target-selector__01_selector_contract__p2__worker__a00/locator.json |
| 14 | 2026-07-25T16:46:21.767589+09:00 | FINISH | m-agent-task-runtime-target-selector/01_selector_contract | worker | 0 | claude/claude-opus-4-8 xhigh | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T074005Z__m-agent-task-runtime-target-selector__01_selector_contract__p2__worker__a00/locator.json |
| 15 | 2026-07-25T16:46:21.837778+09:00 | START | m-agent-task-runtime-target-selector/01_selector_contract | review | 0 | codex/gpt-5.6-sol xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T074621Z__m-agent-task-runtime-target-selector__01_selector_contract__p2__review__a00/locator.json |
| 16 | 2026-07-25T16:57:36.013599+09:00 | FINISH | m-agent-task-runtime-target-selector/01_selector_contract | review | 0 | codex/gpt-5.6-sol xhigh | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T074621Z__m-agent-task-runtime-target-selector__01_selector_contract__p2__review__a00/locator.json |
| 17 | 2026-07-25T16:57:36.051952+09:00 | START | m-agent-task-runtime-target-selector/01_selector_contract | worker | 0 | claude/claude-opus-4-8 xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T075736Z__m-agent-task-runtime-target-selector__01_selector_contract__p3__worker__a00/locator.json |
| 18 | 2026-07-25T17:01:38.484767+09:00 | FINISH | m-agent-task-runtime-target-selector/01_selector_contract | worker | 0 | claude/claude-opus-4-8 xhigh | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T075736Z__m-agent-task-runtime-target-selector__01_selector_contract__p3__worker__a00/locator.json |
| 19 | 2026-07-25T17:01:38.535282+09:00 | START | m-agent-task-runtime-target-selector/01_selector_contract | review | 0 | codex/gpt-5.6-sol xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T080138Z__m-agent-task-runtime-target-selector__01_selector_contract__p3__review__a00/locator.json |
| 20 | 2026-07-25T17:08:57.310682+09:00 | FINISH | m-agent-task-runtime-target-selector/01_selector_contract | review | 0 | codex/gpt-5.6-sol xhigh | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T080138Z__m-agent-task-runtime-target-selector__01_selector_contract__p3__review__a00/locator.json |
| 21 | 2026-07-25T17:08:57.409773+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 0 | claude/claude-opus-4-8 xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T080857Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__worker__a00/locator.json |
| 22 | 2026-07-25T17:08:59.827787+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 0 | claude/claude-opus-4-8 xhigh | failed:generic-error:1 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T080857Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__worker__a00/locator.json |
| 23 | 2026-07-25T17:09:01.849854+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 1 | claude/claude-opus-4-8 xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T080901Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__worker__a01/locator.json |
| 24 | 2026-07-25T17:09:04.066699+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 1 | claude/claude-opus-4-8 xhigh | failed:generic-error:1 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T080901Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__worker__a01/locator.json |
| 25 | 2026-07-25T17:09:08.085826+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 2 | claude/claude-opus-4-8 xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T080908Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__worker__a02/locator.json |
| 26 | 2026-07-25T17:09:10.680965+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 2 | claude/claude-opus-4-8 xhigh | failed:generic-error:1 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T080908Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__worker__a02/locator.json |
| 27 | 2026-07-25T17:09:18.706378+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 3 | claude/claude-opus-4-8 xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T080918Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__worker__a03/locator.json |
| 28 | 2026-07-25T17:09:20.913102+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 3 | claude/claude-opus-4-8 xhigh | failed:generic-error:1 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T080918Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__worker__a03/locator.json |
| 29 | 2026-07-25T17:09:36.934168+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 4 | claude/claude-opus-4-8 xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T080936Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__worker__a04/locator.json |
| 30 | 2026-07-25T17:09:40.721621+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 4 | claude/claude-opus-4-8 xhigh | failed:generic-error:1 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T080936Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__worker__a04/locator.json |
| 31 | 2026-07-25T17:10:10.740154+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 5 | claude/claude-opus-4-8 xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T081010Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__worker__a05/locator.json |
| 32 | 2026-07-25T17:10:13.955980+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 5 | claude/claude-opus-4-8 xhigh | failed:generic-error:1 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T081010Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__worker__a05/locator.json |
| 33 | 2026-07-25T17:10:43.997869+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 6 | claude/claude-opus-4-8 xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T081043Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__worker__a06/locator.json |
| 34 | 2026-07-25T17:10:46.086864+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 6 | claude/claude-opus-4-8 xhigh | failed:generic-error:1 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T081043Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__worker__a06/locator.json |
| 35 | 2026-07-25T17:11:16.119156+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 7 | claude/claude-opus-4-8 xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T081116Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__worker__a07/locator.json |
| 36 | 2026-07-25T17:11:18.411769+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 7 | claude/claude-opus-4-8 xhigh | failed:generic-error:1 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T081116Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__worker__a07/locator.json |
| 37 | 2026-07-25T17:11:48.454012+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 8 | claude/claude-opus-4-8 xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T081148Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__worker__a08/locator.json |
| 38 | 2026-07-25T17:11:50.584313+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 8 | claude/claude-opus-4-8 xhigh | failed:generic-error:1 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T081148Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__worker__a08/locator.json |
| 39 | 2026-07-25T17:12:20.615940+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 9 | claude/claude-opus-4-8 xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T081220Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__worker__a09/locator.json |
| 40 | 2026-07-25T17:12:22.812569+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 9 | claude/claude-opus-4-8 xhigh | failed:generic-error:1 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T081220Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__worker__a09/locator.json |
| 41 | 2026-07-25T17:57:44.813128+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 10 | codex/gpt-5.6-terra high | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T085744Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__worker__a10/locator.json |
| 42 | 2026-07-25T18:05:32.088311+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 10 | codex/gpt-5.6-terra high | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T085744Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__worker__a10/locator.json |
| 43 | 2026-07-25T18:05:32.187915+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | review | 0 | codex/gpt-5.6-sol xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T090532Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__review__a00/locator.json |
| 44 | 2026-07-25T18:17:17.310768+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | review | 0 | codex/gpt-5.6-sol xhigh | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T090532Z__m-agent-task-runtime-target-selector__02__01_quota_input__p0__review__a00/locator.json |
| 45 | 2026-07-25T18:17:17.356945+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 0 | claude/claude-opus-4-8 xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T091717Z__m-agent-task-runtime-target-selector__02__01_quota_input__p1__worker__a00/locator.json |
| 46 | 2026-07-25T18:17:19.753451+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 0 | claude/claude-opus-4-8 xhigh | failed:provider-quota:1 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T091717Z__m-agent-task-runtime-target-selector__02__01_quota_input__p1__worker__a00/locator.json |
| 47 | 2026-07-25T18:17:19.800126+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 1 | codex/gpt-5.6-terra high | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T091719Z__m-agent-task-runtime-target-selector__02__01_quota_input__p1__worker__a01/locator.json |
| 48 | 2026-07-25T18:22:48.835113+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 1 | codex/gpt-5.6-terra high | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T091719Z__m-agent-task-runtime-target-selector__02__01_quota_input__p1__worker__a01/locator.json |
| 49 | 2026-07-25T18:22:48.914304+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | review | 0 | codex/gpt-5.6-sol xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T092248Z__m-agent-task-runtime-target-selector__02__01_quota_input__p1__review__a00/locator.json |
| 50 | 2026-07-25T18:36:59.553311+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | review | 0 | codex/gpt-5.6-sol xhigh | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T092248Z__m-agent-task-runtime-target-selector__02__01_quota_input__p1__review__a00/locator.json |
| 51 | 2026-07-25T18:36:59.617762+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 0 | pi/iop/ornith-fast | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T093659Z__m-agent-task-runtime-target-selector__02__01_quota_input__p2__worker__a00/locator.json |
| 52 | 2026-07-25T18:41:14.125012+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 0 | pi/iop/ornith-fast | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T093659Z__m-agent-task-runtime-target-selector__02__01_quota_input__p2__worker__a00/locator.json |
| 53 | 2026-07-25T18:41:14.209952+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | selfcheck | 0 | pi/iop/ornith-fast | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T094114Z__m-agent-task-runtime-target-selector__02__01_quota_input__p2__selfcheck__a00/locator.json |
| 54 | 2026-07-25T18:41:42.457019+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | selfcheck | 0 | pi/iop/ornith-fast | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T094114Z__m-agent-task-runtime-target-selector__02__01_quota_input__p2__selfcheck__a00/locator.json |
| 55 | 2026-07-25T18:41:42.515768+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | review | 0 | codex/gpt-5.6-sol xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T094142Z__m-agent-task-runtime-target-selector__02__01_quota_input__p2__review__a00/locator.json |
| 56 | 2026-07-25T18:46:30.364616+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | review | 0 | codex/gpt-5.6-sol xhigh | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T094142Z__m-agent-task-runtime-target-selector__02__01_quota_input__p2__review__a00/locator.json |
| 57 | 2026-07-25T18:46:30.426330+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | review | 1 | codex/gpt-5.6-sol xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T094630Z__m-agent-task-runtime-target-selector__02__01_quota_input__p2__review__a01/locator.json |
| 58 | 2026-07-25T18:51:13.799782+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | review | 1 | codex/gpt-5.6-sol xhigh | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T094630Z__m-agent-task-runtime-target-selector__02__01_quota_input__p2__review__a01/locator.json |
| 59 | 2026-07-25T18:51:18.907779+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | review | 2 | codex/gpt-5.6-sol xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T095118Z__m-agent-task-runtime-target-selector__02__01_quota_input__p2__review__a02/locator.json |
| 60 | 2026-07-25T18:58:08.796639+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | review | 2 | codex/gpt-5.6-sol xhigh | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T095118Z__m-agent-task-runtime-target-selector__02__01_quota_input__p2__review__a02/locator.json |
| 61 | 2026-07-25T18:58:08.910637+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 0 | pi/iop/ornith-fast | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T095808Z__m-agent-task-runtime-target-selector__02__01_quota_input__p3__worker__a00/locator.json |
| 62 | 2026-07-25T19:05:52.351093+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 1 | pi/iop/ornith-fast | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T100552Z__m-agent-task-runtime-target-selector__02__01_quota_input__p3__worker__a01/locator.json |
| 63 | 2026-07-25T19:08:23.905751+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 1 | pi/iop/ornith-fast | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T100552Z__m-agent-task-runtime-target-selector__02__01_quota_input__p3__worker__a01/locator.json |
| 64 | 2026-07-25T19:08:23.943662+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | selfcheck | 0 | pi/iop/ornith-fast | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T100823Z__m-agent-task-runtime-target-selector__02__01_quota_input__p3__selfcheck__a00/locator.json |
| 65 | 2026-07-25T19:09:27.542575+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | selfcheck | 0 | pi/iop/ornith-fast | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T100823Z__m-agent-task-runtime-target-selector__02__01_quota_input__p3__selfcheck__a00/locator.json |
| 66 | 2026-07-25T19:09:27.688319+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | review | 0 | codex/gpt-5.6-sol xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T100927Z__m-agent-task-runtime-target-selector__02__01_quota_input__p3__review__a00/locator.json |
| 67 | 2026-07-25T19:18:36.320392+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | review | 0 | codex/gpt-5.6-sol xhigh | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T100927Z__m-agent-task-runtime-target-selector__02__01_quota_input__p3__review__a00/locator.json |
| 68 | 2026-07-25T19:18:36.372501+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 0 | pi/iop/ornith-fast | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T101836Z__m-agent-task-runtime-target-selector__02__01_quota_input__p4__worker__a00/locator.json |
| 69 | 2026-07-25T19:20:01.672546+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 0 | pi/iop/ornith-fast | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T101836Z__m-agent-task-runtime-target-selector__02__01_quota_input__p4__worker__a00/locator.json |
| 70 | 2026-07-25T19:20:01.708128+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | selfcheck | 0 | pi/iop/ornith-fast | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T102001Z__m-agent-task-runtime-target-selector__02__01_quota_input__p4__selfcheck__a00/locator.json |
| 71 | 2026-07-25T19:22:51.003748+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | selfcheck | 0 | pi/iop/ornith-fast | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T102001Z__m-agent-task-runtime-target-selector__02__01_quota_input__p4__selfcheck__a00/locator.json |
| 72 | 2026-07-25T19:22:51.409820+09:00 | START | m-agent-task-runtime-target-selector/02+01_quota_input | review | 0 | codex/gpt-5.6-sol xhigh | running | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T102251Z__m-agent-task-runtime-target-selector__02__01_quota_input__p4__review__a00/locator.json |
| 73 | 2026-07-25T19:28:00.641414+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | review | 0 | codex/gpt-5.6-sol xhigh | succeeded:0 | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T102251Z__m-agent-task-runtime-target-selector__02__01_quota_input__p4__review__a00/locator.json |
| 74 | 2026-07-25T19:28:00.747853+09:00 | FINISH | m-agent-task-runtime-target-selector/02+01_quota_input | worker | 0 | pi/iop/ornith-fast | reconciled:verified-complete-archive | /config/workspace/iop/.git/agent-task-dispatcher/runs/20260725T095808Z__m-agent-task-runtime-target-selector__02__01_quota_input__p3__worker__a00/locator.json |

View file

@ -30,7 +30,7 @@ func rootCmd() *cobra.Command {
}
root.PersistentFlags().StringVarP(&cfgFile, "config", "c", "configs/node.yaml", "config file path")
root.AddCommand(serveCmd(), versionCmd(), configCmd(), setupCmd())
root.AddCommand(serveCmd(), versionCmd(), configCmd(), setupCmd(), quotaProbeCmd())
return root
}

View file

@ -0,0 +1,107 @@
package main
import (
"encoding/json"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"iop/apps/node/internal/adapters/cli/status"
"iop/packages/go/config"
)
var quotaCheckUsage = status.CheckUsage
func quotaProbeCmd() *cobra.Command {
var target string
var command string
var requiredCaps []string
var checkedAt string
c := &cobra.Command{
Use: "quota-probe",
Short: "Emit normalized CLI quota evidence for the dispatcher",
Hidden: true,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
target = strings.TrimSpace(target)
command = strings.TrimSpace(command)
if target == "" {
return fmt.Errorf("--target must be non-empty")
}
if command == "" {
return fmt.Errorf("--command must be non-empty")
}
caps, err := normalizeRequiredCaps(requiredCaps)
if err != nil {
return err
}
at, err := parseQuotaCheckedAt(checkedAt)
if err != nil {
return err
}
adapter := quotaAdapter(command)
usage, checkErr := quotaCheckUsage(cmd.Context(), target, config.CLIProfileConf{Command: command})
snapshot := status.NormalizeQuotaSnapshot(adapter, target, caps, at, usage, checkErr)
return json.NewEncoder(cmd.OutOrStdout()).Encode(snapshot)
},
}
c.Flags().StringVar(&target, "target", "", "canonical execution target")
c.Flags().StringVar(&command, "command", "", "CLI command used for usage checking")
c.Flags().StringSliceVar(&requiredCaps, "required-cap", nil, "required cap: overall or model:<name> (repeatable)")
c.Flags().StringVar(&checkedAt, "checked-at", "", "RFC3339 observation time (defaults to now)")
_ = c.MarkFlagRequired("target")
_ = c.MarkFlagRequired("command")
return c
}
func normalizeRequiredCaps(values []string) ([]string, error) {
if len(values) == 0 {
return nil, fmt.Errorf("at least one --required-cap is required")
}
caps := make([]string, 0, len(values))
seen := make(map[string]struct{}, len(values))
for _, value := range values {
cap := strings.TrimSpace(value)
if cap != "overall" {
model, ok := strings.CutPrefix(cap, "model:")
if !ok || strings.TrimSpace(model) == "" {
return nil, fmt.Errorf("invalid required cap %q; use overall or model:<name>", value)
}
cap = "model:" + strings.TrimSpace(model)
}
key := strings.ToLower(cap)
if _, exists := seen[key]; exists {
return nil, fmt.Errorf("duplicate required cap %q", value)
}
seen[key] = struct{}{}
caps = append(caps, cap)
}
return caps, nil
}
func parseQuotaCheckedAt(value string) (time.Time, error) {
if value == "" {
return time.Now().UTC(), nil
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return time.Time{}, fmt.Errorf("invalid --checked-at: %w", err)
}
return parsed.UTC(), nil
}
func quotaAdapter(command string) string {
switch strings.ToLower(filepath.Base(command)) {
case "agy", "antigravity":
return "agy"
case "claude":
return "claude"
case "codex":
return "codex"
default:
return filepath.Base(command)
}
}

View file

@ -0,0 +1,123 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"iop/apps/node/internal/adapters/cli/status"
"iop/packages/go/config"
)
func TestQuotaProbeCommandEmitsSelectorCompatibleSnapshot(t *testing.T) {
original := quotaCheckUsage
t.Cleanup(func() { quotaCheckUsage = original })
quotaCheckUsage = func(_ context.Context, target string, profile config.CLIProfileConf) (*status.UsageStatus, error) {
if target != "Gemini 3.6 Flash Medium" || profile.Command != "agy" {
t.Fatalf("checker input = target=%q command=%q", target, profile.Command)
}
return &status.UsageStatus{DailyLimit: "25%", RawOutput: "secret provider output"}, nil
}
root := rootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"quota-probe", "--target", "Gemini 3.6 Flash Medium", "--command", "agy", "--required-cap", "overall", "--checked-at", "2026-07-25T08:00:00Z"})
if err := root.Execute(); err != nil {
t.Fatalf("execute: %v\n%s", err, out.String())
}
var snapshot status.QuotaSnapshot
if err := json.Unmarshal(out.Bytes(), &snapshot); err != nil {
t.Fatalf("decode JSON: %v\n%s", err, out.String())
}
if got := snapshot.Targets; len(got) != 1 || got[0].Adapter != "agy" || got[0].Target != "Gemini 3.6 Flash Medium" || got[0].Status != "available" {
t.Fatalf("selector target entry = %#v", got)
}
if strings.Contains(out.String(), "secret provider output") {
t.Fatalf("raw checker output leaked: %s", out.String())
}
}
func TestQuotaProbeCommandReturnsUnknownForCheckerError(t *testing.T) {
original := quotaCheckUsage
t.Cleanup(func() { quotaCheckUsage = original })
quotaCheckUsage = func(context.Context, string, config.CLIProfileConf) (*status.UsageStatus, error) {
return nil, errors.New("provider stderr must not leak")
}
root := rootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"quota-probe", "--target", "sonnet", "--command", "claude", "--required-cap", "overall", "--checked-at", "2026-07-25T08:00:00Z"})
if err := root.Execute(); err != nil {
t.Fatalf("execute: %v\n%s", err, out.String())
}
if strings.Contains(out.String(), "provider stderr") {
t.Fatalf("checker error leaked: %s", out.String())
}
var snapshot status.QuotaSnapshot
if err := json.Unmarshal(out.Bytes(), &snapshot); err != nil {
t.Fatalf("decode JSON: %v", err)
}
if snapshot.Targets[0].Status != "unknown" || len(snapshot.ReasonCodes) != 1 || snapshot.ReasonCodes[0] != "checker_error" {
t.Fatalf("snapshot = %#v", snapshot)
}
}
func TestQuotaProbeCommandRejectsInvalidCaps(t *testing.T) {
root := rootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"quota-probe", "--target", "sonnet", "--command", "claude", "--required-cap", "weekly"})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "invalid required cap") {
t.Fatalf("error = %v, output=%s", err, out.String())
}
}
func TestQuotaProbeCommandRejectsEmptyTargetAndCommand(t *testing.T) {
original := quotaCheckUsage
t.Cleanup(func() { quotaCheckUsage = original })
checkerCalled := false
quotaCheckUsage = func(context.Context, string, config.CLIProfileConf) (*status.UsageStatus, error) {
checkerCalled = true
return nil, nil
}
for name, args := range map[string][]string{
"empty_target": {"quota-probe", "--target=", "--command", "claude", "--required-cap", "overall"},
"whitespace_target": {"quota-probe", "--target", " \t ", "--command", "claude", "--required-cap", "overall"},
"empty_command": {"quota-probe", "--target", "sonnet", "--command=", "--required-cap", "overall"},
"whitespace_command": {"quota-probe", "--target", "sonnet", "--command", " \t ", "--required-cap", "overall"},
} {
t.Run(name, func(t *testing.T) {
checkerCalled = false
root := rootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs(args)
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "must be non-empty") {
t.Fatalf("error = %v, output=%s", err, out.String())
}
if checkerCalled {
t.Fatal("checker must not run for empty identity input")
}
})
}
}
func TestQuotaProbeCommandIsHidden(t *testing.T) {
root := rootCmd()
probe, _, err := root.Find([]string{"quota-probe"})
if err != nil {
t.Fatalf("find quota-probe: %v", err)
}
if !probe.Hidden {
t.Fatal("quota-probe must be hidden")
}
}

View file

@ -0,0 +1,171 @@
package status
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"math"
"sort"
"strconv"
"strings"
"time"
)
const quotaSnapshotSchemaVersion = "1.0"
// QuotaCapView is the normalized, non-sensitive evidence for one required
// usage cap. It deliberately excludes UsageStatus.RawOutput.
type QuotaCapView struct {
Name string `json:"name"`
Status string `json:"status"`
RemainingPercent *float64 `json:"remaining_percent"`
}
// QuotaTargetView is the selector-facing availability result for one target.
type QuotaTargetView struct {
Adapter string `json:"adapter"`
Target string `json:"target"`
Status string `json:"status"`
}
// QuotaSnapshot is a narrow JSON bridge from the Go usage checker to the
// execution selector. It never serializes provider output or checker errors.
type QuotaSnapshot struct {
SchemaVersion string `json:"schema_version"`
SnapshotID string `json:"snapshot_id"`
Source string `json:"source"`
CheckedAt string `json:"checked_at"`
Targets []QuotaTargetView `json:"targets"`
RequiredCaps []QuotaCapView `json:"required_caps"`
ReasonCodes []string `json:"reason_codes"`
}
// NormalizeQuotaSnapshot resolves the declared cap set against a parsed usage
// status. A confirmed exhausted cap wins over missing or malformed evidence so
// callers do not admit a target known to be exhausted.
func NormalizeQuotaSnapshot(adapter, target string, requiredCaps []string, checkedAt time.Time, usage *UsageStatus, checkErr error) QuotaSnapshot {
views := make([]QuotaCapView, 0, len(requiredCaps))
reasons := make([]string, 0, 1)
if checkErr != nil || usage == nil {
for _, cap := range requiredCaps {
views = append(views, QuotaCapView{Name: cap, Status: "unknown"})
}
reasons = append(reasons, "checker_error")
} else {
for _, cap := range requiredCaps {
views = append(views, resolveQuotaCap(usage, cap))
}
}
status := "available"
for _, view := range views {
if view.Status == "exhausted" {
status = "exhausted"
break
}
if view.Status != "available" {
status = "unknown"
}
}
if len(views) == 0 {
status = "unknown"
reasons = append(reasons, "required_cap_missing")
}
if status == "unknown" && len(reasons) == 0 {
reasons = append(reasons, "cap_evidence_unknown")
}
checked := checkedAt.UTC().Format(time.RFC3339Nano)
snapshot := QuotaSnapshot{
SchemaVersion: quotaSnapshotSchemaVersion,
Source: "iop-node quota-probe",
CheckedAt: checked,
Targets: []QuotaTargetView{{Adapter: adapter, Target: target, Status: status}},
RequiredCaps: views,
ReasonCodes: reasons,
}
snapshot.SnapshotID = quotaSnapshotID(snapshot)
return snapshot
}
func resolveQuotaCap(usage *UsageStatus, required string) QuotaCapView {
view := QuotaCapView{Name: required, Status: "unknown"}
if usage == nil {
return view
}
if required == "overall" {
return quotaCapFromRemaining(required, usage.DailyLimit)
}
model, ok := strings.CutPrefix(required, "model:")
if !ok || strings.TrimSpace(model) == "" {
return view
}
count, err := strconv.Atoi(usage.Metadata["model_usage_count"])
if err != nil || count < 0 {
return view
}
matches := make([]string, 0, 1)
for index := 0; index < count; index++ {
prefix := fmt.Sprintf("model_usage_%d", index)
if strings.EqualFold(strings.TrimSpace(usage.Metadata[prefix+"_name"]), strings.TrimSpace(model)) {
matches = append(matches, usage.Metadata[prefix+"_used_percent"])
}
}
if len(matches) != 1 {
return view
}
used, ok := parsePercent(matches[0])
if !ok {
return view
}
remaining := 100 - used
return quotaCapFromNumber(required, remaining)
}
func quotaCapFromRemaining(name, value string) QuotaCapView {
remaining, ok := parsePercent(value)
if !ok {
return QuotaCapView{Name: name, Status: "unknown"}
}
return quotaCapFromNumber(name, remaining)
}
func quotaCapFromNumber(name string, remaining float64) QuotaCapView {
view := QuotaCapView{Name: name, RemainingPercent: &remaining}
if remaining == 0 {
view.Status = "exhausted"
} else {
view.Status = "available"
}
return view
}
func parsePercent(value string) (float64, bool) {
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(value), "%"))
if value == "" {
return 0, false
}
parsed, err := strconv.ParseFloat(value, 64)
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed < 0 || parsed > 100 {
return 0, false
}
return parsed, true
}
func quotaSnapshotID(snapshot QuotaSnapshot) string {
caps := append([]QuotaCapView(nil), snapshot.RequiredCaps...)
sort.Slice(caps, func(i, j int) bool { return caps[i].Name < caps[j].Name })
payload := struct {
CheckedAt string `json:"checked_at"`
Target QuotaTargetView `json:"target"`
Caps []QuotaCapView `json:"caps"`
}{
CheckedAt: snapshot.CheckedAt,
Target: snapshot.Targets[0],
Caps: caps,
}
encoded, _ := json.Marshal(payload)
digest := sha256.Sum256(encoded)
return "quota-" + hex.EncodeToString(digest[:])
}

View file

@ -0,0 +1,117 @@
package status
import (
"encoding/json"
"errors"
"testing"
"time"
)
var quotaCheckedAt = time.Date(2026, 7, 25, 8, 0, 0, 0, time.UTC)
func TestNormalizeQuotaSnapshotExhaustedWinsMissing(t *testing.T) {
snapshot := NormalizeQuotaSnapshot("agy", "Gemini", []string{"overall", "model:missing"}, quotaCheckedAt, &UsageStatus{DailyLimit: "0%"}, nil)
if got := snapshot.Targets[0].Status; got != "exhausted" {
t.Fatalf("status = %q, want exhausted", got)
}
if snapshot.RequiredCaps[1].Status != "unknown" {
t.Fatalf("missing model status = %q, want unknown", snapshot.RequiredCaps[1].Status)
}
}
func TestNormalizeQuotaSnapshotAvailableWhenAllPositive(t *testing.T) {
snapshot := NormalizeQuotaSnapshot("agy", "Gemini", []string{"overall", "model:Flash"}, quotaCheckedAt, &UsageStatus{
DailyLimit: "70%",
Metadata: map[string]string{
"model_usage_count": "1",
"model_usage_0_name": "flash",
"model_usage_0_used_percent": "20%",
},
}, nil)
if got := snapshot.Targets[0].Status; got != "available" {
t.Fatalf("status = %q, want available", got)
}
for _, cap := range snapshot.RequiredCaps {
if cap.Status != "available" || cap.RemainingPercent == nil || *cap.RemainingPercent <= 0 {
t.Fatalf("cap = %#v, want positive available evidence", cap)
}
}
}
func TestNormalizeQuotaSnapshotUnknownForMissingConflictAndParseFailure(t *testing.T) {
for name, tc := range map[string]struct {
requiredCaps []string
usage *UsageStatus
}{
"missing": {
requiredCaps: []string{"overall", "model:missing"},
usage: &UsageStatus{DailyLimit: "50%"},
},
"conflict": {
requiredCaps: []string{"model:flash"},
usage: &UsageStatus{Metadata: map[string]string{
"model_usage_count": "2",
"model_usage_0_name": "Flash",
"model_usage_0_used_percent": "10%",
"model_usage_1_name": "flash",
"model_usage_1_used_percent": "20%",
}},
},
"parse": {
requiredCaps: []string{"overall"},
usage: &UsageStatus{DailyLimit: "not-a-percent"},
},
} {
t.Run(name, func(t *testing.T) {
snapshot := NormalizeQuotaSnapshot("agy", "Gemini", tc.requiredCaps, quotaCheckedAt, tc.usage, nil)
if got := snapshot.Targets[0].Status; got != "unknown" {
t.Fatalf("status = %q, want unknown", got)
}
})
}
snapshot := NormalizeQuotaSnapshot("agy", "Gemini", []string{"overall"}, quotaCheckedAt, nil, errors.New("provider output must not escape"))
if got := snapshot.Targets[0].Status; got != "unknown" {
t.Fatalf("checker error status = %q, want unknown", got)
}
if len(snapshot.ReasonCodes) != 1 || snapshot.ReasonCodes[0] != "checker_error" {
t.Fatalf("reason codes = %#v", snapshot.ReasonCodes)
}
}
func TestNormalizeQuotaSnapshotUnknownForNonFinitePercent(t *testing.T) {
for _, value := range []string{"NaN%", "Inf%", "-Inf%"} {
t.Run("overall_"+value, func(t *testing.T) {
snapshot := NormalizeQuotaSnapshot("agy", "Gemini", []string{"overall"}, quotaCheckedAt, &UsageStatus{DailyLimit: value}, nil)
if got := snapshot.Targets[0].Status; got != "unknown" {
t.Fatalf("status = %q, want unknown", got)
}
if _, err := json.Marshal(snapshot); err != nil {
t.Fatalf("snapshot must be JSON encodable: %v", err)
}
})
t.Run("model_"+value, func(t *testing.T) {
snapshot := NormalizeQuotaSnapshot("agy", "Gemini", []string{"model:flash"}, quotaCheckedAt, &UsageStatus{Metadata: map[string]string{
"model_usage_count": "1",
"model_usage_0_name": "flash",
"model_usage_0_used_percent": value,
}}, nil)
if got := snapshot.Targets[0].Status; got != "unknown" {
t.Fatalf("status = %q, want unknown", got)
}
if _, err := json.Marshal(snapshot); err != nil {
t.Fatalf("snapshot must be JSON encodable: %v", err)
}
})
}
}
func TestNormalizeQuotaSnapshotUsesAntigravityModelMetadata(t *testing.T) {
usage, err := ParseStatusOutput("Auto (Antigravity) quota\n0% used (Limit resets in 24h)\nModel usage\nFlash ▬▬▬ 2% Resets: tomorrow\n(Press Esc to close)\n")
if err != nil {
t.Fatalf("parse status: %v", err)
}
snapshot := NormalizeQuotaSnapshot("agy", "Gemini", []string{"overall", "model:FLASH"}, quotaCheckedAt, usage, nil)
if got := snapshot.Targets[0].Status; got != "available" {
t.Fatalf("status = %q, want available", got)
}
}