Compare commits
5 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9262266bb | |||
| d113125e1c | |||
| ca6d7dfa59 | |||
| b59baa2139 | |||
| 0db86135fd |
22 changed files with 1223 additions and 3665 deletions
|
|
@ -21,7 +21,7 @@
|
||||||
- 긴 배경 설명은 README나 별도 참조 문서로 보내고, 실행 문서에는 실행 규칙만 남긴다.
|
- 긴 배경 설명은 README나 별도 참조 문서로 보내고, 실행 문서에는 실행 규칙만 남긴다.
|
||||||
- 룰 문서는 협업자가 직접 읽는 계약 문서이므로 한국어 `한다`체로 작성한다.
|
- 룰 문서는 협업자가 직접 읽는 계약 문서이므로 한국어 `한다`체로 작성한다.
|
||||||
- README, GUIDE, roadmap 문서는 사람이 함께 검토하는 협업 문서이므로 한국어 설명체 또는 존댓말을 사용할 수 있다.
|
- README, GUIDE, roadmap 문서는 사람이 함께 검토하는 협업 문서이므로 한국어 설명체 또는 존댓말을 사용할 수 있다.
|
||||||
- 스킬 문서의 frontmatter `description`과 Markdown 본문은 영어로 작성한다. 기존 스킬은 언어 통일만을 위해 수정하지 않되, 새로 만들거나 실질적으로 갱신하는 스킬은 영어를 사용한다. 프로젝트가 요구하는 사용자-facing 출력 literal은 해당 언어를 유지할 수 있다.
|
- 스킬 문서는 실행 안정성을 우선한다. 한국어 또는 영어를 사용할 수 있고, 이미 잘 동작하는 절차 계약은 언어 통일만을 위해 수정하지 않는다.
|
||||||
- path, filename, 상태값, id, regex, command, frontmatter key, runtime protocol token은 원문 ASCII 식별자를 유지한다.
|
- path, filename, 상태값, id, regex, command, frontmatter key, runtime protocol token은 원문 ASCII 식별자를 유지한다.
|
||||||
|
|
||||||
## 라우팅
|
## 라우팅
|
||||||
|
|
|
||||||
|
|
@ -1,51 +1,56 @@
|
||||||
---
|
---
|
||||||
name: <skill-name>
|
name: <skill-name>
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
description: <Describe in one sentence what this skill does and when to use it. Include trigger keywords when useful.>
|
description: <이 skill이 하는 일을 한 줄로 설명. 트리거 키워드 포함 권장>
|
||||||
---
|
---
|
||||||
|
|
||||||
# <skill-name>
|
# <skill-name>
|
||||||
|
|
||||||
## Purpose
|
## 목적
|
||||||
|
|
||||||
<Explain the problem this skill solves in one or two sentences.>
|
<이 skill이 해결하는 문제를 1~2문장으로 설명>
|
||||||
|
|
||||||
## When to use
|
## 언제 호출할지
|
||||||
|
|
||||||
- <Invocation case 1>
|
- <이 skill을 호출해야 하는 상황 1>
|
||||||
- <Invocation case 2>
|
- <이 skill을 호출해야 하는 상황 2>
|
||||||
|
- <이 skill을 호출해야 하는 상황 3>
|
||||||
|
|
||||||
<!-- Optional: remove this comment and the entire Inputs section when the skill has no explicit parameters. -->
|
## 입력
|
||||||
## Inputs
|
|
||||||
|
|
||||||
- `<param-name>`: <Description.> (required)
|
- `<param-name>`: <설명> (필수)
|
||||||
|
- `<param-name>`: <설명> (선택)
|
||||||
|
|
||||||
<!-- Optional: remove this comment and the entire Preflight section when no check must precede execution. -->
|
## 먼저 확인할 것
|
||||||
## Preflight
|
|
||||||
|
|
||||||
- [ ] <Condition that must be checked before execution.>
|
- [ ] <실행 전 반드시 확인해야 할 조건 1>
|
||||||
|
- [ ] <실행 전 반드시 확인해야 할 조건 2>
|
||||||
|
|
||||||
## Procedure
|
## 실행 절차
|
||||||
|
|
||||||
1. **<Step name>**
|
1. **<단계명>**
|
||||||
- <Required action.>
|
- <세부 행동>
|
||||||
|
- <세부 행동>
|
||||||
|
|
||||||
2. **Report the result**
|
2. **<단계명>**
|
||||||
- <Result to report.>
|
- <세부 행동>
|
||||||
|
|
||||||
## Validation
|
3. **결과 보고**
|
||||||
|
- <출력할 내용>
|
||||||
|
|
||||||
- [ ] <Verifiable success condition.>
|
## 실행 결과 검증
|
||||||
- If validation fails, <define the retry, rollback, or reporting action.>
|
|
||||||
|
|
||||||
<!-- Optional: remove this comment and the entire Output format section unless an exact shape is required. -->
|
- [ ] <실행 후 확인해야 할 성공 조건 1>
|
||||||
## Output format
|
- [ ] <실행 후 확인해야 할 성공 조건 2>
|
||||||
|
- 검증 실패 시: <실패 시 취할 행동 — 롤백, 사용자 알림, 재시도 등>
|
||||||
|
|
||||||
|
## 출력 형식
|
||||||
|
|
||||||
```
|
```
|
||||||
<Output example.>
|
<출력 예시>
|
||||||
```
|
```
|
||||||
|
|
||||||
<!-- Optional: remove this comment and the entire Prohibitions section unless a specific likely or unsafe action must be forbidden. -->
|
## 금지 사항
|
||||||
## Prohibitions
|
|
||||||
|
|
||||||
- <Forbidden action.>
|
- <절대 하면 안 되는 것>
|
||||||
|
- <절대 하면 안 되는 것>
|
||||||
|
|
|
||||||
|
|
@ -20,12 +20,12 @@ Implementation agents never decide or request user review. They record implement
|
||||||
## Core Loop Rules
|
## Core Loop Rules
|
||||||
|
|
||||||
- Trigger: Korean or English active-task review requests, including `리뷰 진행해` and `리뷰해줘`, must use this skill when an active `CODE_REVIEW-*-G??.md` or `USER_REVIEW.md` exists under `agent-task/*/` or `agent-task/*/*/`, excluding `agent-task/archive/**`.
|
- Trigger: Korean or English active-task review requests, including `리뷰 진행해` and `리뷰해줘`, must use this skill when an active `CODE_REVIEW-*-G??.md` or `USER_REVIEW.md` exists under `agent-task/*/` or `agent-task/*/*/`, excluding `agent-task/archive/**`.
|
||||||
- Finalize every selected state unless Step 5 returns blocked. Normally append one verdict, prepare one next state, archive the pair, and materialize that state. For `dependency-wait`, keep the pair and persist `next-task`/resume condition without another verdict. A resolved `USER_REVIEW.md` writes `complete.log` and archives the task.
|
- Finalize every selected active state: for `CODE_REVIEW-*-G??.md`, append one verdict, prepare the required next state, archive the active review and plan files, then materialize exactly one next state; for `USER_REVIEW.md` completion, update the stop state, write `complete.log`, and archive the task.
|
||||||
- Next state: `PASS` writes `complete.log` and moves the task under `agent-task/archive/YYYY/MM/`; if the task group is `m-<milestone-slug>`, report completion metadata for the runtime event. `WARN` or `FAIL` normally invokes `agent-ops/skills/common/plan/SKILL.md`, which must run `finalize-task-routing` before writing the next active pair; if the user-review gate triggers, write `USER_REVIEW.md` instead. A completed `USER_REVIEW.md` uses the same terminal `complete.log` and archive path as `PASS`.
|
- Next state: `PASS` writes `complete.log` and moves the task under `agent-task/archive/YYYY/MM/`; if the task group is `m-<milestone-slug>`, report completion metadata for the runtime event. `WARN` or `FAIL` normally invokes `agent-ops/skills/common/plan/SKILL.md`, which must run `finalize-task-routing` before writing the next active pair; if the user-review gate triggers, write `USER_REVIEW.md` instead. A completed `USER_REVIEW.md` uses the same terminal `complete.log` and archive path as `PASS`.
|
||||||
- The user-review gate is review-agent-owned and triggers only when current evidence proves either that a concrete selected Milestone `구현 잠금 > 결정 필요` item blocks the next safe implementation step or that required external verification cannot proceed without a user-controlled capability or authorization. Generic status fields or blocker text written by implementation are never a user-review request.
|
- The user-review gate is review-agent-owned and triggers only when current evidence proves either that a concrete selected Milestone `구현 잠금 > 결정 필요` item blocks the next safe implementation step or that required external verification cannot proceed without a user-controlled capability or authorization. Generic status fields or blocker text written by implementation are never a user-review request.
|
||||||
- Do not replace `USER_REVIEW.md` with an inline user question. When the user-review gate triggers, write the file-based stop state and report its path.
|
- Do not replace `USER_REVIEW.md` with an inline user question. When the user-review gate triggers, write the file-based stop state and report its path.
|
||||||
- Do not ask for confirmation before WARN/FAIL follow-up files. If the user-review gate triggers, write `USER_REVIEW.md`; otherwise invoke the plan skill with the current raw findings and let it write the smallest concrete follow-up after fresh routing.
|
- Do not ask for confirmation before WARN/FAIL follow-up files. If the user-review gate triggers, write `USER_REVIEW.md`; otherwise invoke the plan skill with the current raw findings and let it write the smallest concrete follow-up after fresh routing.
|
||||||
- Recovery: if a prior turn appended a verdict without archive or next-state files, do not append another verdict; resume Step 5 preparation/archive from that verdict. If a pre-existing verdict predates stable finding ids, assign `R1..` and `S1..` once in displayed order in the recovery handoff and record that mapping without appending a second verdict. If exactly one member of the pair was archived after both archive destinations had been preflighted, verify the archived member and remaining source/destination, finish that archive, then use the post-archive recovery below. If both logs exist with a verdict but the required next state is absent, reconstruct it from those exact logs: PASS resumes `complete.log`; WARN/FAIL reruns the plan skill in `write` mode with raw archived findings and `isolated-reassessment`; a valid user-review gate rerenders `USER_REVIEW.md`. If a prior turn resolved `USER_REVIEW.md` without `complete.log`, resume at the matching finalization step.
|
- Recovery: if a prior turn appended a verdict without archive or next-state files, do not append another verdict; resume Step 5 preparation/archive from that verdict. If exactly one member of the pair was archived after both archive destinations had been preflighted, verify the archived member and remaining source/destination, finish that archive, then use the post-archive recovery below. If both logs exist with a verdict but the required next state is absent, reconstruct it from those exact logs: PASS resumes `complete.log`; WARN/FAIL reruns the plan skill in `write` mode with raw archived findings and `isolated-reassessment`; a valid user-review gate rerenders `USER_REVIEW.md`. If a prior turn resolved `USER_REVIEW.md` without `complete.log`, resume at the matching finalization step.
|
||||||
|
|
||||||
## User Review Gate
|
## User Review Gate
|
||||||
|
|
||||||
|
|
@ -98,8 +98,6 @@ Milestone task group contract:
|
||||||
Follow-up routing boundary:
|
Follow-up routing boundary:
|
||||||
|
|
||||||
- This skill records current source, actual verification output, and findings, but it must not estimate or recommend the next lane/G.
|
- This skill records current source, actual verification output, and findings, but it must not estimate or recommend the next lane/G.
|
||||||
- Decide each Required/Suggested disposition here and validate it directly; dispatcher use is optional. Default repository-fixable work to `direct-fix`, expanding stale exclusions when required by original acceptance. Allow `verified-dependency` only with the exact PLAN/task ordering proof, or `complete.log` plus fresh proof that the precondition is satisfied; vague owners and `complete.log` alone are invalid. Set `ownership_closed=true` only after every id has that proof.
|
|
||||||
- Never send an unchanged-precondition verification packet. For an unordered dependency, keep the verdict pair and return `status=blocked`, `blocked_reason=dependency-wait`, `next-task`, and resume condition; do not redispatch it or request user review.
|
|
||||||
- On WARN/FAIL, invoke the plan skill in `prepare-follow-up` mode with the selected task path and raw current evidence before archiving the current pair.
|
- On WARN/FAIL, invoke the plan skill in `prepare-follow-up` mode with the selected task path and raw current evidence before archiving the current pair.
|
||||||
- Do not pass the archived lane, grade, routing score, rationale, or filename as plan-routing input. Archive paths remain evidence pointers, and actual logs/findings remain raw evidence.
|
- Do not pass the archived lane, grade, routing score, rationale, or filename as plan-routing input. Archive paths remain evidence pointers, and actual logs/findings remain raw evidence.
|
||||||
- The plan skill must complete its full analysis and mandatory `finalize-task-routing` step before it writes the next pair. Code-review must not create a routed follow-up pair directly.
|
- The plan skill must complete its full analysis and mandatory `finalize-task-routing` step before it writes the next pair. Code-review must not create a routed follow-up pair directly.
|
||||||
|
|
@ -111,7 +109,7 @@ Directory states:
|
||||||
|-------|---------|
|
|-------|---------|
|
||||||
| `PLAN-*-G??.md` + unfilled `CODE_REVIEW-*-G??.md` stub/placeholders | Implementation is not judgeable; review should fail completeness if invoked |
|
| `PLAN-*-G??.md` + unfilled `CODE_REVIEW-*-G??.md` stub/placeholders | Implementation is not judgeable; review should fail completeness if invoked |
|
||||||
| `PLAN-*-G??.md` + filled `CODE_REVIEW-*-G??.md` without verdict | Ready for code-review skill |
|
| `PLAN-*-G??.md` + filled `CODE_REVIEW-*-G??.md` without verdict | Ready for code-review skill |
|
||||||
| `PLAN-*-G??.md` + `CODE_REVIEW-*-G??.md` with appended verdict | Review finalization pending or `dependency-wait`; do not append another verdict. Resume Step 5 immediately for unfinished finalization, or only after the recorded dependency changes for a wait. |
|
| `PLAN-*-G??.md` + `CODE_REVIEW-*-G??.md` with appended verdict | Review finalization pending; do not append another verdict, resume Step 5 preparation/archive |
|
||||||
| Exactly one active pair member + its newly archived counterpart | Partial archive after a preflighted finalization; verify both identities, finish the remaining archive, then resume post-archive recovery |
|
| Exactly one active pair member + its newly archived counterpart | Partial archive after a preflighted finalization; verify both identities, finish the remaining archive, then resume post-archive recovery |
|
||||||
| `complete.log` + `*.log` files | Task complete (PASS or user-review-resolved PASS), before final task-directory archive move |
|
| `complete.log` + `*.log` files | Task complete (PASS or user-review-resolved PASS), before final task-directory archive move |
|
||||||
| `USER_REVIEW.md` + `*.log` files | Automatic loop stopped; its recorded Milestone decision or external-execution user action must be resolved before creating another plan |
|
| `USER_REVIEW.md` + `*.log` files | Automatic loop stopped; its recorded Milestone decision or external-execution user action must be resolved before creating another plan |
|
||||||
|
|
@ -192,7 +190,7 @@ Required fields for canonical English active pairs:
|
||||||
|
|
||||||
- `Overall Verdict`: exactly `PASS`, `WARN`, or `FAIL`.
|
- `Overall Verdict`: exactly `PASS`, `WARN`, or `FAIL`.
|
||||||
- `Dimension Assessment`: Pass/Warn/Fail for correctness, completeness, test coverage, API contract, code quality, implementation deviation, verification trust. If SDD Evidence Map applies through `milestone-task`, also include spec conformance.
|
- `Dimension Assessment`: Pass/Warn/Fail for correctness, completeness, test coverage, API contract, code quality, implementation deviation, verification trust. If SDD Evidence Map applies through `milestone-task`, also include spec conformance.
|
||||||
- `Findings`: `None`, or bullets using stable `Required R1`, `Required R2`, `Suggested S1`, or `Suggested S2` ids with `file:line` and a concrete fix; Nit findings do not need ids. Keep every Required/Suggested id unchanged in the follow-up handoff and plan.
|
- `Findings`: `None`, or bullets using `Required`, `Suggested`, or `Nit` with `file:line` and a concrete fix.
|
||||||
- `Routing Signals`: calculate once and append `review_rework_count=<N>` and `evidence_integrity_failure=true|false`. Set rework count to archived same-task `WARN|FAIL` verdicts plus one only when the current verdict is non-PASS. Set integrity failure to true only when a claimed test, command, exit code, or production path is absent, unexecuted, or contradicted by fresh reviewer evidence.
|
- `Routing Signals`: calculate once and append `review_rework_count=<N>` and `evidence_integrity_failure=true|false`. Set rework count to archived same-task `WARN|FAIL` verdicts plus one only when the current verdict is non-PASS. Set integrity failure to true only when a claimed test, command, exit code, or production path is absent, unexecuted, or contradicted by fresh reviewer evidence.
|
||||||
- `Next Step`: keep only the matching PASS, WARN/FAIL follow-up, or USER_REVIEW line.
|
- `Next Step`: keep only the matching PASS, WARN/FAIL follow-up, or USER_REVIEW line.
|
||||||
|
|
||||||
|
|
@ -231,14 +229,13 @@ Do not archive WARN/FAIL files until the next-state content is fully prepared in
|
||||||
|
|
||||||
Reuse the routing signals appended in Step 4; do not recount verdict history for routing. Separately count the existing logs once for archive identity: set `current_review_archive_number=count(code_review_*.log)` and `current_plan_archive_number=count(plan_*.log)`, then derive both archive names from the current active files' own lane/grade. These archive values describe the pair being closed, not the next route.
|
Reuse the routing signals appended in Step 4; do not recount verdict history for routing. Separately count the existing logs once for archive identity: set `current_review_archive_number=count(code_review_*.log)` and `current_plan_archive_number=count(plan_*.log)`, then derive both archive names from the current active files' own lane/grade. These archive values describe the pair being closed, not the next route.
|
||||||
|
|
||||||
The follow-up handoff contains the selected `{task_name}`, revalidated outcome/acceptance/exclusions, current verdict and stable Required/Suggested ids, affected files, actual verification output, each id's `direct-fix` or `verified-dependency` disposition and exact evidence, roadmap carryover, routing signals, `REVIEW_<PARENT_TAG>`, and predicted current-pair archive names. Keep current active paths only as evidence pointers. Omit prior lane, grade, routing score, rationale, filename, and preferred next route from routing input. The plan may use current archive names only after routing to render `Archive Evidence Snapshot`.
|
The follow-up handoff contains the selected `{task_name}`, the current plan's requested outcome/acceptance/exclusions revalidated against current evidence, current verdict, Required/Suggested/Nit findings, affected files, actual verification output, current ownership/dependency facts, roadmap carryover, `review_rework_count`, `evidence_integrity_failure`, `REVIEW_<PARENT_TAG>`, and those predicted current-pair archive names. Keep current active paths only as evidence pointers. Do not add the prior lane, grade, routing score, rationale, or a preferred next route to the neutral routing snapshot, and require plan to omit route-bearing basenames from the isolated routing input. The plan may use current archive names only after routing to render `Archive Evidence Snapshot`.
|
||||||
|
|
||||||
- `prepare-follow-up` must return `status: routed`, the exact routed basenames, `prepared_plan`, `prepared_review`, `plan_number`, `current_plan_archive_name`, `current_plan_archive_number`, `current_review_archive_name`, `current_review_archive_number`, `plan_log_number`, `review_log_number`, and `gitignore_repair_needed`. It must have executed `finalize-task-routing` in `isolated-reassessment` mode.
|
- `prepare-follow-up` must return `status: routed`, the exact routed basenames, `prepared_plan`, `prepared_review`, `plan_number`, `current_plan_archive_name`, `current_plan_archive_number`, `current_review_archive_name`, `current_review_archive_number`, `plan_log_number`, `review_log_number`, and `gitignore_repair_needed`. It must have executed `finalize-task-routing` in `isolated-reassessment` mode.
|
||||||
- Verify that the returned current archive names/numbers equal the values derived before preparation, and that `plan_log_number` / `review_log_number` are the post-archive counts embedded in the new review stub for its future archive.
|
- Verify that the returned current archive names/numbers equal the values derived before preparation, and that `plan_log_number` / `review_log_number` are the post-archive counts embedded in the new review stub for its future archive.
|
||||||
- Before archiving either active file, inspect `prepared_plan` directly. Require one non-empty `Finding Resolution Map` that maps every Required/Suggested id exactly once; every `direct-fix` target appears in `Modified Files Summary`; every `verified-dependency` has the exact task-protocol/current-state proof above; and a planned change or satisfied dependency makes repeated verification meaningful. Reuse the existing review/plan analysis; do not add a separate model pass.
|
- Materialize `prepared_plan` only as a temporary candidate outside the repository and run `python3 agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py --workspace <workspace> --validate-plan <candidate-plan>`. Require exit code `0` before archiving either active file. The candidate must contain exactly one non-empty `Modified Files Summary` with only exact workspace files. Globs, directories, workspace root, URLs, outside-workspace paths, malformed paths, and placeholders are invalid. Remove the temporary candidate after validation.
|
||||||
- If preparation returns `needs_evidence`, collect all named new evidence and rerun after the input changes; never rerun with unchanged evidence. If the evidence cannot be obtained in the current scope, leave the verdict-appended pair in place and report the exact finalization blocker.
|
- If preparation returns `needs_evidence`, collect all named new evidence and rerun after the input changes; never rerun with unchanged evidence. If the evidence cannot be obtained in the current scope, leave the verdict-appended pair in place and report the exact finalization blocker.
|
||||||
- If preparation returns `blocked` or the direct prepared-PLAN check fails, correct the handoff/plan from already collected evidence in the same review pass. If exact new evidence is genuinely required, leave the verdict-appended active PLAN/CODE_REVIEW pair in place, do not check archive/next-state items, and report a resumable internal finalization blocker. A later code-review invocation resumes this step without appending another verdict; this condition is not user review by itself.
|
- If preparation returns `blocked` or prepared PLAN validation fails, leave the verdict-appended active PLAN/CODE_REVIEW pair in place, do not check archive/next-state items, and report a resumable finalization blocker. A later code-review invocation resumes this step without appending another verdict.
|
||||||
- For `dependency-wait`, keep one `### Finalization State` under the verdict with status, next task, resume condition, and checked state. Do no work while unchanged; when satisfied, mark it resolved with evidence and resume without another verdict.
|
|
||||||
|
|
||||||
After the required next state is prepared, archive is mandatory for `PASS`, `WARN`, and `FAIL`. Ensure `.gitignore` has the Agent-Ops managed gitignore block for task artifacts before writing `*.log` outputs. Prefer `source agent-ops/bin/ai-ignore.sh && agent_ops_ensure_gitignore_task_artifact_block .gitignore`; if the helper is unavailable, add or update a block containing `!agent-task/`, `!agent-task/**/`, `!agent-task/**/*.md`, `!agent-task/**/*.log`, and `agent-roadmap/current.md`. Apply the repair here when `prepare-follow-up` returned `gitignore_repair_needed: true`.
|
After the required next state is prepared, archive is mandatory for `PASS`, `WARN`, and `FAIL`. Ensure `.gitignore` has the Agent-Ops managed gitignore block for task artifacts before writing `*.log` outputs. Prefer `source agent-ops/bin/ai-ignore.sh && agent_ops_ensure_gitignore_task_artifact_block .gitignore`; if the helper is unavailable, add or update a block containing `!agent-task/`, `!agent-task/**/`, `!agent-task/**/*.md`, `!agent-task/**/*.log`, and `agent-roadmap/current.md`. Apply the repair here when `prepare-follow-up` returned `gitignore_repair_needed: true`.
|
||||||
|
|
||||||
|
|
@ -271,7 +268,7 @@ For `WARN` or `FAIL`, materialize the next state prepared in Step 5 immediately
|
||||||
- If the user-review gate triggered, write the prepared body to `agent-task/{task_name}/USER_REVIEW.md`. It must use exactly one supported type, `milestone-lock` or `external-execution`, contain every archived loop entry plus the exact required user action or decision, and contain no placeholder. Do not write active PLAN/CODE_REVIEW files or `complete.log`.
|
- If the user-review gate triggered, write the prepared body to `agent-task/{task_name}/USER_REVIEW.md`. It must use exactly one supported type, `milestone-lock` or `external-execution`, contain every archived loop entry plus the exact required user action or decision, and contain no placeholder. Do not write active PLAN/CODE_REVIEW files or `complete.log`.
|
||||||
- Otherwise write `prepared_plan` and `prepared_review` byte-for-byte to their routed basenames. Do not rerun, adjust, compare, or upgrade their lane/G after archive.
|
- Otherwise write `prepared_plan` and `prepared_review` byte-for-byte to their routed basenames. Do not rerun, adjust, compare, or upgrade their lane/G after archive.
|
||||||
- Verify the written follow-up pair contains the predicted archived plan/review paths in identical `Archive Evidence Snapshot` sections and contains no unresolved token from the review-stub template inventory. Unrelated braces in commands or code are allowed.
|
- Verify the written follow-up pair contains the predicted archived plan/review paths in identical `Archive Evidence Snapshot` sections and contains no unresolved token from the review-stub template inventory. Unrelated braces in commands or code are allowed.
|
||||||
- Read the written PLAN once and confirm byte-for-byte materialization retained the checked `Finding Resolution Map` and `Modified Files Summary` invariants.
|
- Re-run `python3 agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py --workspace <workspace> --validate-plan <written-plan>` and require exit code `0` to confirm that the byte-for-byte materialized PLAN retained the validated write claim.
|
||||||
- Do not adjust the prepared route after finalization. For a `local-fit` base, `review_rework_count >= 2` or `evidence_integrity_failure=true` must produce `recovery-boundary`; `capability-gap` and `grade-boundary` keep their own basis.
|
- Do not adjust the prepared route after finalization. For a `local-fit` base, `review_rework_count >= 2` or `evidence_integrity_failure=true` must produce `recovery-boundary`; `capability-gap` and `grade-boundary` keep their own basis.
|
||||||
|
|
||||||
If the task group is `m-<milestone-slug>` and the user-review gate triggered, report that the milestone task is blocked on user review; do not emit PASS completion metadata and do not call `update-roadmap`.
|
If the task group is `m-<milestone-slug>` and the user-review gate triggered, report that the milestone task is blocked on user review; do not emit PASS completion metadata and do not call `update-roadmap`.
|
||||||
|
|
@ -322,8 +319,6 @@ Report Required/Suggested counts, archive names, the final task archive path for
|
||||||
|
|
||||||
## Final Checklist
|
## Final Checklist
|
||||||
|
|
||||||
For `status=blocked`, keep the verdict pair and persist/report blocker, next task, and resume condition. Archive/next-state items below wait until it changes; unchanged dependency state triggers no review work.
|
|
||||||
|
|
||||||
- `{current_review_archive_name}` exists with the verdict appended and was derived from the archived active review's own route.
|
- `{current_review_archive_name}` exists with the verdict appended and was derived from the archived active review's own route.
|
||||||
- `{current_plan_archive_name}` exists and was derived from the archived active plan's own route.
|
- `{current_plan_archive_name}` exists and was derived from the archived active plan's own route.
|
||||||
- `.gitignore` unignores `agent-task/**/*.md` and `agent-task/**/*.log` and ignores local `agent-roadmap/current.md`; generated task artifacts are not ignored by `git check-ignore`.
|
- `.gitignore` unignores `agent-task/**/*.md` and `agent-task/**/*.log` and ignores local `agent-roadmap/current.md`; generated task artifacts are not ignored by `git check-ignore`.
|
||||||
|
|
@ -333,8 +328,8 @@ For `status=blocked`, keep the verdict pair and persist/report blocker, next tas
|
||||||
- PASS `complete.log` first line is byte-for-byte identical to the archived PLAN header. An `m-*` log contains non-empty `milestone-task` ids and reports them in completion metadata; a non-milestone log omits the field.
|
- PASS `complete.log` first line is byte-for-byte identical to the archived PLAN header. An `m-*` log contains non-empty `milestone-task` ids and reports them in completion metadata; a non-milestone log omits the field.
|
||||||
- PASS does not create `Roadmap Completion` or directly check a Milestone Task. Aggregated evaluation is deferred to `sync-milestone-workstate`.
|
- PASS does not create `Roadmap Completion` or directly check a Milestone Task. Aggregated evaluation is deferred to `sync-milestone-workstate`.
|
||||||
- WARN/FAIL without user-review gate: the plan skill was invoked for the exact task path with verified `review_rework_count` and `evidence_integrity_failure`, completed `finalize-task-routing`, and created new active `PLAN-{build_lane}-GNN.md` and `CODE_REVIEW-{review_lane}-GNN.md` files matching the fresh routed output; no `complete.log`.
|
- WARN/FAIL without user-review gate: the plan skill was invoked for the exact task path with verified `review_rework_count` and `evidence_integrity_failure`, completed `finalize-task-routing`, and created new active `PLAN-{build_lane}-GNN.md` and `CODE_REVIEW-{review_lane}-GNN.md` files matching the fresh routed output; no `complete.log`.
|
||||||
- WARN/FAIL prepared PLAN passed the intrinsic finding-resolution and write-boundary checks before active-pair archive and retained them after byte-for-byte materialization. Invalid resolution or write claims leave the verdict-appended prior pair active.
|
- WARN/FAIL prepared PLAN passed `python3 agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py --workspace <workspace> --validate-plan <candidate-plan>` before active-pair archive and again after byte-for-byte materialization. Invalid write claims leave the verdict-appended prior pair active.
|
||||||
- WARN/FAIL follow-up: the plan input omitted prior route fields, revalidated outcome/acceptance/exclusions from current evidence, mapped every stable Required/Suggested id to a direct fix or exact verified dependency, used the completed in-memory PLAN as the packet, and copied identical `Archive Evidence Snapshot` sections into the new plan/review pair.
|
- WARN/FAIL follow-up: the plan input omitted prior route fields, revalidated outcome/acceptance/exclusions from current evidence, used the completed in-memory PLAN as the packet, and copied identical `Archive Evidence Snapshot` sections into the new plan/review pair.
|
||||||
- Follow-up plans and review stubs keep implementation agents limited to implementation/test/evidence and contain no implementation-owned user-review request section.
|
- Follow-up plans and review stubs keep implementation agents limited to implementation/test/evidence and contain no implementation-owned user-review request section.
|
||||||
- USER_REVIEW: `USER_REVIEW.md` exists from template, no active `PLAN-*.md` or `CODE_REVIEW-*.md` remains, and no `complete.log` was written.
|
- USER_REVIEW: `USER_REVIEW.md` exists from template, no active `PLAN-*.md` or `CODE_REVIEW-*.md` remains, and no `complete.log` was written.
|
||||||
- Review-agent-owned USER_REVIEW: the generated `USER_REVIEW.md` records one supported gate type, the exact Milestone decision or external-execution user action, and evidence that made automatic continuation unsafe.
|
- Review-agent-owned USER_REVIEW: the generated `USER_REVIEW.md` records one supported gate type, the exact Milestone decision or external-execution user action, and evidence that made automatic continuation unsafe.
|
||||||
|
|
|
||||||
|
|
@ -1,116 +1,109 @@
|
||||||
---
|
---
|
||||||
name: create-skill
|
name: create-skill
|
||||||
version: 1.1.0
|
version: 1.0.1
|
||||||
description: Create an agent-ops skill with one responsibility, a minimal execution contract, unambiguous English instructions, and correct routing. Use when adding a new common, project, or private SKILL.md.
|
description: 새로운 SKILL.md 파일을 생성하기 위한 범용 스킬
|
||||||
---
|
---
|
||||||
|
|
||||||
# Create Skill
|
# Create Skill
|
||||||
|
|
||||||
## Purpose
|
## 목적
|
||||||
|
|
||||||
Create a correctly structured `SKILL.md` under `agent-ops/skills/` from the current skill template, then register its routing entry.
|
`agent-ops/skills/` 하위에 올바른 형식의 SKILL.md 파일을 생성한다.
|
||||||
|
기존 skill-template.md 를 기반으로, 요청 목적에 맞는 내용을 채워 넣는다.
|
||||||
|
생성 후 라우팅 항목을 추가한다.
|
||||||
|
|
||||||
This skill creates skills read by the project-local `agent-ops` router. When creating a Codex-discoverable skill under `$CODEX_HOME/skills`, follow the system `skill-creator` rules instead and use only `name` and `description` in the frontmatter.
|
이 스킬은 프로젝트 내부 `agent-ops` 라우터가 읽는 스킬을 만든다.
|
||||||
|
`$CODEX_HOME/skills`에 설치되어 Codex가 직접 discover하는 스킬을 만들 때는 시스템 `skill-creator` 규칙을 우선하고, frontmatter는 `name`과 `description`만 사용한다.
|
||||||
|
|
||||||
### Select the creation location
|
### 생성 위치 결정
|
||||||
|
- `.agent-ops-source` 파일이 **있으면** (공통 관리 레포): `agent-ops/skills/common/<skill-name>/SKILL.md`
|
||||||
|
- `.agent-ops-source` 파일이 **없고** 사용자가 private 또는 operator-local을 명시하면 (타겟 프로젝트): `agent-ops/skills/private/<skill-name>/SKILL.md`
|
||||||
|
- `.agent-ops-source` 파일이 **없고** private 요청이 없으면 (타겟 프로젝트): `agent-ops/skills/project/<skill-name>/SKILL.md`
|
||||||
|
|
||||||
- If `.agent-ops-source` exists, create `agent-ops/skills/common/<skill-name>/SKILL.md`.
|
## 언제 호출할지
|
||||||
- If `.agent-ops-source` does not exist and the user explicitly requests private or operator-local visibility, create `agent-ops/skills/private/<skill-name>/SKILL.md`.
|
|
||||||
- If `.agent-ops-source` does not exist and the user does not request private visibility, create `agent-ops/skills/project/<skill-name>/SKILL.md`.
|
|
||||||
|
|
||||||
## When to use
|
- 새로운 반복 작업 패턴이 생겨 skill로 정의해야 할 때
|
||||||
|
- 기존 skill이 없는 작업 유형을 처음 수행하기 전에
|
||||||
|
- 사용자가 특정 작업을 skill로 만들어 달라고 요청할 때
|
||||||
|
|
||||||
- A repeatable task pattern is not covered by an existing skill.
|
## 입력
|
||||||
- The user asks to create a specific skill.
|
|
||||||
|
|
||||||
## Inputs
|
- `skill-name`: 생성할 skill 이름, kebab-case (필수)
|
||||||
|
- `purpose`: 이 skill이 해결하는 문제 한 줄 요약 (필수)
|
||||||
|
- `visibility`: `common`, `project`, `private` 중 하나. 사용자가 private 또는 operator-local을 명시했을 때만 `private`을 선택한다. (선택)
|
||||||
|
- `trigger-cases`: 이 skill을 호출해야 하는 상황 목록 (선택)
|
||||||
|
|
||||||
- `skill-name`: Kebab-case skill name. (required)
|
## 먼저 확인할 것
|
||||||
- `purpose`: One-sentence summary of the problem the skill solves. (required)
|
|
||||||
- `visibility`: `common`, `project`, or `private`. Select `private` only when the user explicitly requests private or operator-local visibility. (optional)
|
|
||||||
- `trigger-cases`: Situations that should invoke the skill. (optional)
|
|
||||||
|
|
||||||
## Preflight
|
- [ ] `agent-ops/skills/common/`, `agent-ops/skills/project/`, `agent-ops/skills/private/` 하위에 동일 이름의 디렉터리가 이미 존재하는지 확인
|
||||||
|
- [ ] `agent-ops/skills/common/router.md` 및 `agent-ops/rules/project/rules.md` 에 이미 유사한 라우팅 항목이 있는지 확인
|
||||||
|
- [ ] `agent-ops/skills/common/_templates/skill-template.md` 를 읽어 최신 템플릿 형식 파악
|
||||||
|
|
||||||
- [ ] Check `agent-ops/skills/common/`, `agent-ops/skills/project/`, and `agent-ops/skills/private/` for an existing directory with the same name.
|
## 실행 절차
|
||||||
- [ ] Check `agent-ops/skills/common/router.md` and `agent-ops/rules/project/rules.md` for equivalent routing or functionality.
|
|
||||||
- [ ] Read `agent-ops/skills/common/_templates/skill-template.md` for the current structure.
|
|
||||||
|
|
||||||
## Procedure
|
1. **중복 확인**
|
||||||
|
- 같은 visibility 경로에 이미 있는 skill은 덮어쓰지 않고 중단한다.
|
||||||
|
- private 요청에서 같은 이름의 project skill은 의도된 override 후보이므로 중복으로 중단하지 않는다. common skill과의 같은 이름 또는 다른 기능의 중복은 사용자에게 알리고 중단한다.
|
||||||
|
- private override가 아닌 기능 중복은 사용자에게 알리고 중단한다.
|
||||||
|
- project skill과 같은 이름의 private override는 해당 project skill의 책임을 완전히 대체하는지 확인한다.
|
||||||
|
|
||||||
1. **Reject unintended duplication**
|
2. **목적 분석**
|
||||||
- Stop instead of overwriting a skill in the same visibility path.
|
- `purpose` 와 `trigger-cases` 를 바탕으로 아래 항목을 도출한다
|
||||||
- For a private request, treat a same-name project skill as a possible intentional override rather than an automatic duplicate. Stop and report a same-name common skill or a functional duplicate.
|
- 언제 호출할지 (2~4개)
|
||||||
- Confirm that a private override fully replaces the responsibility of its same-name project skill.
|
- 필요한 입력 파라미터
|
||||||
|
- 사전 확인 항목
|
||||||
|
- 실행 절차 (3~7단계)
|
||||||
|
- 출력 형식
|
||||||
|
- 금지 사항
|
||||||
|
|
||||||
2. **Define one responsibility and its minimum contract**
|
3. **SKILL.md 생성**
|
||||||
- Select only the representative trigger cases needed to distinguish this skill from existing routes. Do not pad the list to reach a target count.
|
- 경로: 생성 위치 결정 규칙에 따라 `common/`, `project/`, 또는 `private/` 하위에 생성
|
||||||
- Define the required procedure and success or failure criteria. Add inputs, preflight checks, an exact output format, or prohibitions only when they change correct execution or verdict determination.
|
- `skill-template.md` 형식을 따른다
|
||||||
- Use the fewest procedure steps that preserve the workflow. Three to seven steps are a guideline for a genuinely multi-stage workflow, not a required count.
|
- agent-ops 내부 스킬은 기존 로컬 관례에 맞춰 `version`을 둘 수 있다. Codex 설치형 스킬로 배포할 목적이면 `version`이나 `depends` 같은 비표준 frontmatter를 넣지 않는다.
|
||||||
- Include only contracts required to execute the repeated task and determine success or failure.
|
- 프로젝트 특화 내용보다 범용 절차를 우선한다
|
||||||
- Do not add speculative inputs, states, branches, output fields, validation rules, or extension points for unsupported future cases.
|
- 절차는 구체적이되 지나치게 세부 구현을 기술하지 않는다
|
||||||
- Omit a rule that does not change an action, selection, validation result, or failure response. Link to an existing owning rule instead of restating its contract.
|
|
||||||
- Keep exactly one independent responsibility in the skill.
|
|
||||||
|
|
||||||
3. **Write `SKILL.md`**
|
4. **라우팅 업데이트**
|
||||||
- Create the file under the selected `common/`, `project/`, or `private/` path and follow `skill-template.md`.
|
- `.agent-ops-source` 마커가 **있으면** (공통 관리 레포): `agent-ops/skills/common/router.md`에 라우팅 항목 추가
|
||||||
- Write the frontmatter `description` and Markdown instructions in English.
|
- private skill이 같은 이름의 project skill을 override하면 별도 라우팅 항목을 추가하지 않는다. 공통 규칙의 private 우선순위를 사용한다.
|
||||||
- Use short imperative sentences with one meaning each. State the condition, required action, and verifiable success or failure criterion when they affect execution.
|
- project skill과 짝이 없는 private skill은 `agent-ops/rules/private/rules.md`에만 라우팅 항목을 추가한다. 파일이 없으면 private route만 담은 ignored local rule을 생성한다.
|
||||||
- Do not use discretionary terms such as `appropriately`, `if needed`, or `when possible` without a decision criterion.
|
- private rule의 trigger는 project router와 중복 등록하지 않는다.
|
||||||
- Keep the procedure specific without encoding unnecessary implementation detail.
|
- `.agent-ops-source` 마커가 **없고** private skill이 아니면 (타겟 프로젝트): `agent-ops/rules/project/rules.md`의 프로젝트 스킬 라우터 섹션에 라우팅 항목 추가
|
||||||
- Preserve paths, filenames, IDs, commands, regexes, status values, protocol tokens, and other exact literals. User-facing output literals may use the language required by the project.
|
- 기존 공통 스킬을 수정해 trigger가 달라졌다면 새 skill을 만들지 말고 `agent-ops/skills/common/router.md`의 기존 행을 갱신한다
|
||||||
- Agent-ops internal skills may retain the local `version` convention. For Codex-installed skills, do not add nonstandard frontmatter such as `version` or `depends`.
|
- 이 skill이 속할 라우팅 축(구조 분석/코드 변경/흐름 추적 등)을 판단한다
|
||||||
- Prefer reusable procedures over project-specific implementation details.
|
- 기존 라우팅 구조를 깨지 않는다
|
||||||
- Remove unused optional template sections, authoring comments, and placeholders from the completed skill.
|
|
||||||
|
|
||||||
4. **Update routing**
|
5. **결과 보고**
|
||||||
- If `.agent-ops-source` exists, add the routing entry to `agent-ops/skills/common/router.md`.
|
- 생성한 파일 경로
|
||||||
- Do not add a separate route when a private skill overrides a same-name project skill; use the common private-precedence rule.
|
- 라우팅 항목을 추가한 파일과 내용
|
||||||
- Route a private skill with no project counterpart only from `agent-ops/rules/private/rules.md`. If the file does not exist, create an ignored local rule containing only the private route.
|
- 이 skill이 다루지 않는 범위(필요 시)
|
||||||
- Do not duplicate a private trigger in the project router.
|
|
||||||
- If `.agent-ops-source` does not exist and the skill is not private, add the route to the project skill router section in `agent-ops/rules/project/rules.md`.
|
|
||||||
- If an existing common skill only needs different triggers, update its existing row in `agent-ops/skills/common/router.md` instead of creating another skill.
|
|
||||||
- Select the existing routing axis that matches the skill and preserve the current routing structure.
|
|
||||||
|
|
||||||
5. **Report the result**
|
## 출력 형식
|
||||||
- Report the created file path.
|
|
||||||
- Report the routing file and added entry.
|
|
||||||
- Report excluded scope only when it prevents a likely misunderstanding.
|
|
||||||
|
|
||||||
## Validation
|
```
|
||||||
|
|
||||||
- [ ] `agent-ops/skills/{common|project|private}/<skill-name>/SKILL.md` exists.
|
|
||||||
- [ ] The skill contains the required purpose, invocation cases, procedure, and validation sections.
|
|
||||||
- [ ] Inputs, preflight, output format, and prohibitions are present only when they define a necessary contract.
|
|
||||||
- [ ] Every instruction is necessary for execution or verdict determination; no speculative contract remains.
|
|
||||||
- [ ] Instructions are concise, single-meaning, and free of discretionary wording without decision criteria.
|
|
||||||
- [ ] The frontmatter description and Markdown instructions are in English, except exact literals that must retain another language.
|
|
||||||
- [ ] No template authoring comment or unfilled placeholder remains.
|
|
||||||
- [ ] The frontmatter has a valid `name` and `description`.
|
|
||||||
- [ ] An agent-ops skill follows local frontmatter conventions, while a Codex-installed skill follows the system `skill-creator` frontmatter rules.
|
|
||||||
- [ ] A private override takes precedence over its same-name project skill, and only an unmatched private skill is routed from the private rule.
|
|
||||||
- If validation fails, report the missing or conflicting item and change only that item.
|
|
||||||
|
|
||||||
## Output format
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## 생성 완료
|
## 생성 완료
|
||||||
|
|
||||||
- SKILL 경로: agent-ops/skills/{common|project|private}/<skill-name>/SKILL.md
|
- SKILL 경로: agent-ops/skills/{common|project|private}/<skill-name>/SKILL.md
|
||||||
- 라우팅 추가: <대상 파일> → <라우팅 축> → <skill-name>
|
- 라우팅 추가: <대상 파일> → <라우팅 축> → <skill-name>
|
||||||
|
|
||||||
## 주의사항 (해당 시)
|
## 주의사항 (해당 시)
|
||||||
|
|
||||||
- <이 skill이 다루지 않는 범위 또는 주의할 점>
|
- <이 skill이 다루지 않는 범위 또는 주의할 점>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Prohibitions
|
## 실행 결과 검증
|
||||||
|
|
||||||
- Do not copy private skill or private rule content into tracked common or project paths.
|
- [ ] `agent-ops/skills/{common|project|private}/<skill-name>/SKILL.md` 파일이 생성되었는가
|
||||||
- Do not overwrite an existing skill.
|
- [ ] 생성된 파일이 `skill-template.md`의 필수 섹션(목적, 언제 호출할지, 실행 절차, 실행 결과 검증, 출력 형식, 금지 사항)을 포함하는가
|
||||||
- Do not hardcode project-specific paths such as `app/screens/` in a reusable skill.
|
- [ ] frontmatter에 name, description이 올바르게 기재되었는가
|
||||||
- Do not modify code unrelated to skill creation.
|
- [ ] agent-ops 내부 스킬이면 version 등 로컬 관례를 따르고, Codex 설치형 스킬이면 시스템 `skill-creator` frontmatter 규칙을 따르는가
|
||||||
- Do not delete or reorder existing routing entries.
|
- [ ] private override는 동일 이름의 project skill보다 우선되고, 짝이 없는 private skill만 private rule에 라우팅되었는가
|
||||||
- Do not combine multiple independent responsibilities in one skill.
|
- 검증 실패 시: 누락된 섹션 또는 라우팅 항목을 사용자에게 알리고 해당 부분만 보완한다
|
||||||
- Do not add contracts for hypothetical future requirements.
|
|
||||||
- Do not use ambiguous instructions without executable decision criteria.
|
## 금지 사항
|
||||||
|
|
||||||
|
- private skill 또는 private rule의 내용을 tracked common·project 경로에 복사하지 않는다
|
||||||
|
- 이미 존재하는 skill 을 덮어쓰지 않는다
|
||||||
|
- 프로젝트 특화 경로(예: `app/screens/`)를 skill 본문에 하드코딩하지 않는다
|
||||||
|
- skill 생성과 무관한 코드 파일을 수정하지 않는다
|
||||||
|
- 라우팅 대상 파일의 기존 항목을 삭제하거나 재정렬하지 않는다
|
||||||
|
- 하나의 skill 에 여러 독립적인 책임을 묶지 않는다
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,6 @@ Filename rules:
|
||||||
|
|
||||||
Role boundary rules:
|
Role boundary rules:
|
||||||
|
|
||||||
- Keep root cause, scope, ownership, and next-state decisions under plan/review, and validate their artifacts directly; dispatcher use is optional. Let implementing agents execute the packet without reinterpreting findings or changing its owner/write boundary.
|
|
||||||
- Implementing agents fill implementation-owned `CODE_REVIEW-*-G??.md` sections, keep active files in place, and report ready for review.
|
- Implementing agents fill implementation-owned `CODE_REVIEW-*-G??.md` sections, keep active files in place, and report ready for review.
|
||||||
- If implementation cannot continue, implementing agents record the exact blocker, attempted commands/output, and resume condition only in `Verification Results` or `Deviations from Plan` (legacy: `검증 결과` or `계획 대비 변경 사항`), then leave the active files in place for official review.
|
- If implementation cannot continue, implementing agents record the exact blocker, attempted commands/output, and resume condition only in `Verification Results` or `Deviations from Plan` (legacy: `검증 결과` or `계획 대비 변경 사항`), then leave the active files in place for official review.
|
||||||
- During implementation, do not ask the user directly, present choices, call user-input tools, or create control-plane stop files. The official reviewer owns all next-state classification.
|
- During implementation, do not ask the user directly, present choices, call user-input tools, or create control-plane stop files. The official reviewer owns all next-state classification.
|
||||||
|
|
@ -201,7 +200,6 @@ Complete all items below before creating active plan/review files. Work through
|
||||||
- [ ] **Assess test coverage** — for each behavior change, explicitly record whether existing tests cover it.
|
- [ ] **Assess test coverage** — for each behavior change, explicitly record whether existing tests cover it.
|
||||||
- [ ] **Assess split boundaries once** — reconcile request acceptance with source/tests, then split only where every child has a stable contract and independent PASS verification. Otherwise keep the invariant together; do not gather extra evidence solely to lower routing risk.
|
- [ ] **Assess split boundaries once** — reconcile request acceptance with source/tests, then split only where every child has a stable contract and independent PASS verification. Otherwise keep the invariant together; do not gather extra evidence solely to lower routing risk.
|
||||||
- [ ] **Capture recovery signals once** — first-pass uses `review_rework_count=0` and `evidence_integrity_failure=false`. In `prepare-follow-up`, reuse the values already validated and appended by code-review; do not recount verdict history. For another isolated replan, derive them once from the same-task state already loaded for planning, without a routing-only log pass.
|
- [ ] **Capture recovery signals once** — first-pass uses `review_rework_count=0` and `evidence_integrity_failure=false`. In `prepare-follow-up`, reuse the values already validated and appended by code-review; do not recount verdict history. For another isolated replan, derive them once from the same-task state already loaded for planning, without a routing-only log pass.
|
||||||
- [ ] **Resolve follow-up findings once** — in `prepare-follow-up`, map every inherited Required/Suggested id. Default repository-fixable work to `direct-fix` with exact root-cause files, overriding stale verification-only exclusions. Allow `verified-dependency` only when an exact active PLAN claims those files and task-protocol ordering applies, or when `complete.log` plus fresh evidence proves the failed precondition is satisfied; vague owners or `complete.log` alone are invalid. Set `ownership_closed=true` only after all mappings are proven. Reject unchanged-precondition verification loops. Reuse the existing analysis; add no model, sub-agent, or routing-only pass.
|
|
||||||
- [ ] **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 `Analysis > Split Judgment` (legacy: `분석 결과 > 분할 판단`) and, when order matters, `Dependencies and Execution Order` (legacy: `의존 관계 및 구현 순서`).
|
- [ ] **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 `Analysis > Split Judgment` (legacy: `분석 결과 > 분할 판단`) and, when order matters, `Dependencies and Execution Order` (legacy: `의존 관계 및 구현 순서`).
|
||||||
- [ ] **Grep all symbol references** — for any renamed or removed symbol, find every call site and import chain.
|
- [ ] **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.
|
- [ ] **Check dependency manifests** — before adding any new package, verify its presence in go.mod / package manifest.
|
||||||
|
|
@ -257,7 +255,6 @@ Required sections:
|
||||||
- `For the Implementing Agent`: warn that filling implementation-owned `CODE_REVIEW-*-G??.md` sections is mandatory. Tell the implementer to run verification, fill actual notes/output, keep active files in place, and report ready for review; finalization is code-review-skill only. If blocked, the implementer records only exact blocker evidence, attempted commands/output, and resume conditions in implementation-owned evidence fields. It must not ask the user, call user-input tools, create control-plane stop files, classify the next state, archive logs, or write `complete.log`.
|
- `For the Implementing Agent`: warn that filling implementation-owned `CODE_REVIEW-*-G??.md` sections is mandatory. Tell the implementer to run verification, fill actual notes/output, keep active files in place, and report ready for review; finalization is code-review-skill only. If blocked, the implementer records only exact blocker evidence, attempted commands/output, and resume conditions in implementation-owned evidence fields. It must not ask the user, call user-input tools, create control-plane stop files, classify the next state, archive logs, or write `complete.log`.
|
||||||
- `Background`: 2-4 sentences explaining why the work is needed.
|
- `Background`: 2-4 sentences explaining why the work is needed.
|
||||||
- `Archive Evidence Snapshot`: include this section only when the plan resumes from `USER_REVIEW.md`, a prior archived review, or any archive evidence. Omit it for first-pass plans with no archive evidence. The section must contain only the archive facts needed to implement without rereading archive by default: prior task/archive paths, verdict, Required/Suggested/Nit summary, affected files, verification evidence, and any roadmap carryover. If exact prior context is still required, cite the specific archive file paths allowed to read; do not ask the implementer to search `agent-task/archive/**` broadly.
|
- `Archive Evidence Snapshot`: include this section only when the plan resumes from `USER_REVIEW.md`, a prior archived review, or any archive evidence. Omit it for first-pass plans with no archive evidence. The section must contain only the archive facts needed to implement without rereading archive by default: prior task/archive paths, verdict, Required/Suggested/Nit summary, affected files, verification evidence, and any roadmap carryover. If exact prior context is still required, cite the specific archive file paths allowed to read; do not ask the implementer to search `agent-task/archive/**` broadly.
|
||||||
- `Finding Resolution Map`: for WARN/FAIL follow-ups only, use one row per Required/Suggested id: mode, exact fix/dependency evidence, and changed/satisfied precondition. Put every `direct-fix` file in `Modified Files Summary`. For an unordered dependency, emit no worker packet; return `status=blocked`, `blocked_reason=dependency-wait`, `next-task`, and resume condition.
|
|
||||||
- `Analysis`: record the findings from Step 2 and the final routed output from Step 3. This section is the written output of the analysis — not a summary, but the actual findings that justify the plan's scope and decisions. Must include all of the following subsections:
|
- `Analysis`: record the findings from Step 2 and the final routed output from Step 3. This section is the written output of the analysis — not a summary, but the actual findings that justify the plan's scope and decisions. Must include all of the following subsections:
|
||||||
- `Files Read`: list every source and test file read during analysis, with path. List verification-context source files only when they were actually present and read.
|
- `Files Read`: list every source and test file read during analysis, with path. List verification-context source files only when they were actually present and read.
|
||||||
- `SDD Criteria`: for `SDD: 필요` Milestones, list the SDD path, status, first-line `milestone-task` ids, targeted Acceptance Scenario ids, and the Evidence Map rows that drive the plan. State explicitly how those rows shaped the implementation checklist and final verification. If the selected Milestone has `SDD: 불필요`, state the recorded reason. If the work is not Milestone-linked, state "not applicable".
|
- `SDD Criteria`: for `SDD: 필요` Milestones, list the SDD path, status, first-line `milestone-task` ids, targeted Acceptance Scenario ids, and the Evidence Map rows that drive the plan. State explicitly how those rows shaped the implementation checklist and final verification. If the selected Milestone has `SDD: 불필요`, state the recorded reason. If the work is not Milestone-linked, state "not applicable".
|
||||||
|
|
@ -269,13 +266,13 @@ Required sections:
|
||||||
- `Final Routing`: record `evaluation_mode`, finalizer, both targets' closure/grade/route, `large_indivisible_context`, positive loop-risk names/count, recovery signals, capability-gap evidence, and canonical filenames. Do not include or compare a previous loop's lane/G.
|
- `Final Routing`: record `evaluation_mode`, finalizer, both targets' closure/grade/route, `large_indivisible_context`, positive loop-risk names/count, recovery signals, capability-gap evidence, and canonical filenames. Do not include or compare a previous loop's lane/G.
|
||||||
- `Implementation Checklist`: 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 `- [ ] Fill implementation-owned sections in CODE_REVIEW-*-G??.md with actual implementation notes and verification output.` Copy this checklist into the review stub's `Implementation Checklist` section with the same item text and order.
|
- `Implementation Checklist`: 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 `- [ ] Fill implementation-owned sections in CODE_REVIEW-*-G??.md with actual implementation notes and verification output.` Copy this checklist into the review stub's `Implementation Checklist` section with the same item text and order.
|
||||||
- One item per change: `### [TAG-1] Title`, `TAG-2`, etc.
|
- One item per change: `### [TAG-1] Title`, `TAG-2`, etc.
|
||||||
- `Modified Files Summary`: table mapping files to item ids. This is the skill-owned implementation write boundary.
|
- `Modified Files Summary`: table mapping files to item ids. This section is the dispatcher's workspace write-claim source of truth.
|
||||||
- Include exactly one `## Modified Files Summary` section and at least one exact workspace file path.
|
- Include exactly one `## Modified Files Summary` section and at least one exact workspace file path.
|
||||||
- Wrap every claimed file path in backticks. A bare path cell is invalid.
|
- Wrap every claimed file path in backticks. A bare path cell is invalid.
|
||||||
- Use repository-relative or canonical absolute file paths. Never use a glob (`*`, `?`, `[]`), directory path, workspace root, URL, path outside the workspace, malformed path, or prose placeholder as a claim.
|
- Use repository-relative or canonical absolute file paths. Never use a glob (`*`, `?`, `[]`), directory path, workspace root, URL, path outside the workspace, malformed path, or prose placeholder as a claim.
|
||||||
- Enumerate only implementer- or reviewer-owned workspace files, including the active review evidence file and deterministic workspace evidence artifacts.
|
- Enumerate only implementer- or reviewer-owned workspace files, including the active review evidence file and deterministic workspace evidence artifacts.
|
||||||
- For generated verification artifacts, choose deterministic exact workspace filenames or write them under a task-specific temporary directory outside the repository. Never substitute a directory or glob claim for dynamic filenames.
|
- For generated verification artifacts, choose deterministic exact workspace filenames or write them under a task-specific temporary directory outside the repository. Never substitute a directory or glob claim for dynamic filenames.
|
||||||
- Before writing or returning a prepared pair, inspect the rendered PLAN itself and confirm this section occurs exactly once, is non-empty, and satisfies every path rule above. For a follow-up, also confirm every `direct-fix` target in `Finding Resolution Map` is present here.
|
- Before writing or returning a prepared pair, validate the rendered PLAN with `python3 agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py --workspace <workspace> --validate-plan <candidate-plan>`. A prepared in-memory PLAN may be materialized only as a temporary candidate outside the repository for this validation. Require exit code `0`. On failure, do not write or return the pair.
|
||||||
- `Final Verification`: runnable commands and expected outcome. Prefer commands from verified handoff facts when supplied; fill missing coverage from repository manifests, scripts, workflows, domain rules, and related tests, and record the source in `Analysis > Verification Context`. Commands must be exact and deterministic enough for the reviewer to rerun; use stable ordering for searches and state whether cached test output is acceptable. End this section with **"After completing all code changes, fill implementation-owned sections in `CODE_REVIEW-*-G??.md`."**
|
- `Final Verification`: runnable commands and expected outcome. Prefer commands from verified handoff facts when supplied; fill missing coverage from repository manifests, scripts, workflows, domain rules, and related tests, and record the source in `Analysis > Verification Context`. Commands must be exact and deterministic enough for the reviewer to rerun; use stable ordering for searches and state whether cached test output is acceptable. End this section with **"After completing all code changes, fill implementation-owned sections in `CODE_REVIEW-*-G??.md`."**
|
||||||
|
|
||||||
Each plan item must include:
|
Each plan item must include:
|
||||||
|
|
@ -348,7 +345,7 @@ Do not write or return a prepared pair when either routing target is not `routed
|
||||||
## Final Checklist
|
## Final Checklist
|
||||||
|
|
||||||
- In `write` mode, the routed `PLAN-{build_lane}-GNN.md` and `CODE_REVIEW-{review_lane}-GNN.md` both exist under `agent-task/{task_name}/`. In `prepare-follow-up` mode, neither routed file was written; both exact bodies and basenames were returned while the verdict-appended current pair remained active.
|
- In `write` mode, the routed `PLAN-{build_lane}-GNN.md` and `CODE_REVIEW-{review_lane}-GNN.md` both exist under `agent-task/{task_name}/`. In `prepare-follow-up` mode, neither routed file was written; both exact bodies and basenames were returned while the verdict-appended current pair remained active.
|
||||||
- The plan skill directly checked the rendered PLAN before the pair was written or returned. Its single non-empty `Modified Files Summary` contains only exact workspace file claims and no glob or directory claim.
|
- The rendered PLAN passed `python3 agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py --workspace <workspace> --validate-plan <candidate-plan>` before the pair was written or returned. Its single non-empty `Modified Files Summary` contains only exact workspace file claims and no glob or directory claim.
|
||||||
- In `write` mode, `.gitignore` has the Agent-Ops managed block that unignores `agent-task/**/*.md` and `agent-task/**/*.log` and ignores local `agent-roadmap/current.md`. In `prepare-follow-up` mode, the block was only inspected and any needed repair was returned as `gitignore_repair_needed`.
|
- In `write` mode, `.gitignore` has the Agent-Ops managed block that unignores `agent-task/**/*.md` and `agent-task/**/*.log` and ignores local `agent-roadmap/current.md`. In `prepare-follow-up` mode, the block was only inspected and any needed repair was returned as `gitignore_repair_needed`.
|
||||||
- Single-plan work stores active files directly under `agent-task/{task_group}/`.
|
- Single-plan work stores active files directly under `agent-task/{task_group}/`.
|
||||||
- Split work, if any, uses one shared `agent-task/{task_group}/` parent and one subtask directory per plan/review pair with names like `01_core`, `02+01_edge_integration`, `03+01_node_integration`; dependency details live in the subtask directory name as `NN+PP[,QQ...]_subtask_name`.
|
- Split work, if any, uses one shared `agent-task/{task_group}/` parent and one subtask directory per plan/review pair with names like `01_core`, `02+01_edge_integration`, `03+01_node_integration`; dependency details live in the subtask directory name as `NN+PP[,QQ...]_subtask_name`.
|
||||||
|
|
@ -368,7 +365,6 @@ Do not write or return a prepared pair when either routing target is not `routed
|
||||||
- The plan and review stub have matching `Implementation Checklist` (legacy: `구현 체크리스트`) item text/order; their final checkbox is the mandatory `CODE_REVIEW-*-G??.md` evidence item.
|
- The plan and review stub have matching `Implementation Checklist` (legacy: `구현 체크리스트`) item text/order; their final checkbox is the mandatory `CODE_REVIEW-*-G??.md` evidence item.
|
||||||
- `finalize-task-routing` ran once after the PLAN body was complete, used no routing-only evidence pass, counted only positive packet-local risk, kept capability/grade basis from being relabeled by escalation signals, and produced matching filenames.
|
- `finalize-task-routing` ran once after the PLAN body was complete, used no routing-only evidence pass, counted only positive packet-local risk, kept capability/grade basis from being relabeled by escalation signals, and produced matching filenames.
|
||||||
- Review WARN/FAIL follow-ups entered through this plan skill and did not inherit or compare the archived lane/G.
|
- Review WARN/FAIL follow-ups entered through this plan skill and did not inherit or compare the archived lane/G.
|
||||||
- Every WARN/FAIL finding has one proven direct fix or ordered/satisfied dependency; only then is ownership closed, and verification does not repeat against an unchanged precondition.
|
|
||||||
- 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 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 `Review-Only Checklist` (legacy: `코드리뷰 전용 체크리스트`) owned only by the review agent.
|
- The review stub has a clearly marked `Review-Only Checklist` (legacy: `코드리뷰 전용 체크리스트`) owned only by the review agent.
|
||||||
- Routed review file completion table lists every plan item.
|
- Routed review file completion table lists every plan item.
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@
|
||||||
> The task is NOT complete until every implementation-owned section below is filled in.
|
> The task is NOT complete until every implementation-owned section below is filled in.
|
||||||
> Complete the `Implementation Checklist`; the final checklist item is mandatory before saving.
|
> Complete the `Implementation Checklist`; the final checklist item is mandatory before saving.
|
||||||
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
|
> Fill implementation-owned sections, then stop with active files in place and report ready for review.
|
||||||
> Execute the plan's selected root cause, scope, files, and dependency decisions as written. Do not choose another owner, narrow/expand the write boundary, or replace a fix with another verification attempt.
|
|
||||||
> If implementation is blocked, record the exact blocker, attempted commands/output, and resume condition only in implementation-owned evidence fields.
|
> 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.
|
> Do not ask the user directly, present choices, call user-input tools, create control-plane stop files, or classify the next state.
|
||||||
> Finalization (`Code Review Result`, log rename, `complete.log`, archive moves, `Review-Only Checklist`) is review-agent-only, even after compaction/resume.
|
> Finalization (`Code Review Result`, log rename, `complete.log`, archive moves, `Review-Only Checklist`) is review-agent-only, even after compaction/resume.
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,6 @@ python3 agent-ops/skills/project/openai-usage-token-issue/scripts/issue_token.py
|
||||||
- raw token은 remote process argument에 넣지 않고 SSH stdin payload로만 전달한다.
|
- raw token은 remote process argument에 넣지 않고 SSH stdin payload로만 전달한다.
|
||||||
- Edge config에는 `token_ref`, SHA-256 hash, `principal_ref`, `principal_alias`만 기록한다.
|
- Edge config에는 `token_ref`, SHA-256 hash, `principal_ref`, `principal_alias`만 기록한다.
|
||||||
- `openai.principal_tokens[]` 변경은 restart-required로 처리한다. candidate check, cutover, exact listener identity 확인, restart, rollback을 생략하지 않는다.
|
- `openai.principal_tokens[]` 변경은 restart-required로 처리한다. candidate check, cutover, exact listener identity 확인, restart, rollback을 생략하지 않는다.
|
||||||
- Edge 재시작 직후 Node 재연결 유예를 위해 chat smoke의 HTTP `502`/`503`/`504`만 총 32초 이내의 제한된 backoff로 재시도한다. 다른 HTTP 오류는 재시도하지 않고 기존 rollback 경계를 유지한다.
|
|
||||||
- dev-corp Confluence 표에는 사용자, alias, token ref, 상태, 동기화 시각만 기록한다. raw token, token hash, Authorization, provider credential을 넣지 않는다.
|
- dev-corp Confluence 표에는 사용자, alias, token ref, 상태, 동기화 시각만 기록한다. raw token, token hash, Authorization, provider credential을 넣지 않는다.
|
||||||
- Confluence write는 최신 version에 한 번만 수행하고 409를 포함한 실패를 자동 재시도하지 않는다.
|
- Confluence write는 최신 version에 한 번만 수행하고 409를 포함한 실패를 자동 재시도하지 않는다.
|
||||||
- Confluence 실패는 활성화된 Edge/store를 되돌리지 않고 clipboard 전달을 막아 동일 command로 재개한다.
|
- Confluence 실패는 활성화된 Edge/store를 되돌리지 않고 clipboard 전달을 막아 동일 command로 재개한다.
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ import urllib.request
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from html.parser import HTMLParser
|
from html.parser import HTMLParser
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable, NoReturn, cast
|
from typing import Any, NoReturn, cast
|
||||||
|
|
||||||
ATLASSIAN_BASE_URL = "https://lgucorp.atlassian.net"
|
ATLASSIAN_BASE_URL = "https://lgucorp.atlassian.net"
|
||||||
CONFLUENCE_FOLDER_ID = "650886407"
|
CONFLUENCE_FOLDER_ID = "650886407"
|
||||||
|
|
@ -37,8 +37,6 @@ CONFLUENCE_TITLE = "IOP 계정 발급 현황"
|
||||||
MANAGED_HEADING = "IOP 사용자 토큰 발급 현황"
|
MANAGED_HEADING = "IOP 사용자 토큰 발급 현황"
|
||||||
TABLE_HEADERS = ("사용자", "principal alias", "token ref", "상태", "동기화 시각")
|
TABLE_HEADERS = ("사용자", "principal alias", "token ref", "상태", "동기화 시각")
|
||||||
SUPPORTED_ENVIRONMENTS = ("dev", "dev-corp")
|
SUPPORTED_ENVIRONMENTS = ("dev", "dev-corp")
|
||||||
OPENAI_RECONNECT_RETRY_STATUSES = frozenset({502, 503, 504})
|
|
||||||
OPENAI_RECONNECT_RETRY_DELAYS_SECONDS = (1, 2, 3, 5, 8, 13)
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowFailure(RuntimeError):
|
class WorkflowFailure(RuntimeError):
|
||||||
|
|
@ -53,12 +51,6 @@ class ConfluenceHTTPFailure(WorkflowFailure):
|
||||||
super().__init__(f"confluence_http_{status_code}")
|
super().__init__(f"confluence_http_{status_code}")
|
||||||
|
|
||||||
|
|
||||||
class OpenAIHTTPFailure(WorkflowFailure):
|
|
||||||
def __init__(self, status_code: int):
|
|
||||||
self.status_code = status_code
|
|
||||||
super().__init__("openai_smoke_http_failed")
|
|
||||||
|
|
||||||
|
|
||||||
class CurlTransportFailure(WorkflowFailure):
|
class CurlTransportFailure(WorkflowFailure):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__("curl_transport_failed")
|
super().__init__("curl_transport_failed")
|
||||||
|
|
@ -846,7 +838,7 @@ def api_json(
|
||||||
except CurlTransportFailure:
|
except CurlTransportFailure:
|
||||||
fail("openai_smoke_network_failed")
|
fail("openai_smoke_network_failed")
|
||||||
if status_code < 200 or status_code >= 300:
|
if status_code < 200 or status_code >= 300:
|
||||||
raise OpenAIHTTPFailure(status_code)
|
fail("openai_smoke_http_failed")
|
||||||
try:
|
try:
|
||||||
value = json.loads(data)
|
value = json.loads(data)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
|
|
@ -856,25 +848,6 @@ def api_json(
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def with_openai_reconnect_retry(
|
|
||||||
request: Callable[[], dict[str, Any]],
|
|
||||||
*,
|
|
||||||
retry_delays: tuple[int, ...] = OPENAI_RECONNECT_RETRY_DELAYS_SECONDS,
|
|
||||||
sleep: Callable[[float], None] = time.sleep,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
for attempt in range(len(retry_delays) + 1):
|
|
||||||
try:
|
|
||||||
return request()
|
|
||||||
except OpenAIHTTPFailure as error:
|
|
||||||
if (
|
|
||||||
error.status_code not in OPENAI_RECONNECT_RETRY_STATUSES
|
|
||||||
or attempt == len(retry_delays)
|
|
||||||
):
|
|
||||||
raise
|
|
||||||
sleep(retry_delays[attempt])
|
|
||||||
fail("openai_smoke_retry_invalid")
|
|
||||||
|
|
||||||
|
|
||||||
def api_smoke(root: Path, profile: dict[str, Any], raw_token: str) -> None:
|
def api_smoke(root: Path, profile: dict[str, Any], raw_token: str) -> None:
|
||||||
if profile["openai_smoke_transport"] == "ssh-loopback":
|
if profile["openai_smoke_transport"] == "ssh-loopback":
|
||||||
remote_call(
|
remote_call(
|
||||||
|
|
@ -889,22 +862,18 @@ def api_smoke(root: Path, profile: dict[str, Any], raw_token: str) -> None:
|
||||||
models = api_json(root, profile, "/models", raw_token, timeout=15)
|
models = api_json(root, profile, "/models", raw_token, timeout=15)
|
||||||
if not isinstance(models.get("data"), list) or not models["data"]:
|
if not isinstance(models.get("data"), list) or not models["data"]:
|
||||||
fail("openai_models_invalid")
|
fail("openai_models_invalid")
|
||||||
response = with_openai_reconnect_retry(
|
response = api_json(
|
||||||
lambda: api_json(
|
root,
|
||||||
root,
|
profile,
|
||||||
profile,
|
"/chat/completions",
|
||||||
"/chat/completions",
|
raw_token,
|
||||||
raw_token,
|
{
|
||||||
{
|
"model": profile["smoke_model"],
|
||||||
"model": profile["smoke_model"],
|
"messages": [{"role": "user", "content": "Reply with the single word OK."}],
|
||||||
"messages": [
|
"max_tokens": 2048,
|
||||||
{"role": "user", "content": "Reply with the single word OK."}
|
"temperature": 0,
|
||||||
],
|
},
|
||||||
"max_tokens": 2048,
|
timeout=120,
|
||||||
"temperature": 0,
|
|
||||||
},
|
|
||||||
timeout=120,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
choices = response.get("choices")
|
choices = response.get("choices")
|
||||||
if not isinstance(choices, list) or not choices or not isinstance(choices[0], dict):
|
if not isinstance(choices, list) or not choices or not isinstance(choices[0], dict):
|
||||||
|
|
@ -1247,45 +1216,6 @@ def selftest() -> dict[str, Any]:
|
||||||
fail("selftest_alias_failed")
|
fail("selftest_alias_failed")
|
||||||
if normalize_alias("a@example.invalid", None) != "a":
|
if normalize_alias("a@example.invalid", None) != "a":
|
||||||
fail("selftest_short_alias_failed")
|
fail("selftest_short_alias_failed")
|
||||||
retry_attempts = 0
|
|
||||||
retry_sleeps: list[float] = []
|
|
||||||
|
|
||||||
def transient_request() -> dict[str, Any]:
|
|
||||||
nonlocal retry_attempts
|
|
||||||
retry_attempts += 1
|
|
||||||
if retry_attempts < 3:
|
|
||||||
raise OpenAIHTTPFailure(502)
|
|
||||||
return {"status": "ok"}
|
|
||||||
|
|
||||||
retry_result = with_openai_reconnect_retry(
|
|
||||||
transient_request,
|
|
||||||
retry_delays=(1, 2),
|
|
||||||
sleep=retry_sleeps.append,
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
retry_result.get("status") != "ok"
|
|
||||||
or retry_attempts != 3
|
|
||||||
or retry_sleeps != [1, 2]
|
|
||||||
):
|
|
||||||
fail("selftest_openai_retry_failed")
|
|
||||||
non_retryable_attempts = 0
|
|
||||||
|
|
||||||
def non_retryable_request() -> dict[str, Any]:
|
|
||||||
nonlocal non_retryable_attempts
|
|
||||||
non_retryable_attempts += 1
|
|
||||||
raise OpenAIHTTPFailure(401)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with_openai_reconnect_retry(
|
|
||||||
non_retryable_request,
|
|
||||||
retry_delays=(1,),
|
|
||||||
sleep=lambda _delay: fail("selftest_openai_non_retryable_slept"),
|
|
||||||
)
|
|
||||||
except OpenAIHTTPFailure as error:
|
|
||||||
if error.status_code != 401 or non_retryable_attempts != 1:
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
fail("selftest_openai_non_retryable_failed")
|
|
||||||
try:
|
try:
|
||||||
parse_request('{"env":"dev","principal_ref":null}')
|
parse_request('{"env":"dev","principal_ref":null}')
|
||||||
except WorkflowFailure as error:
|
except WorkflowFailure as error:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: orchestrate-agent-task-loop
|
name: orchestrate-agent-task-loop
|
||||||
description: Run agent-task work and autonomously execute active PLAN/CODE_REVIEW loops on request. Use when dispatching dependency-ready work in parallel by predecessor completion and workspace write claims, running catalog-selected lane/G workers and reviewers, applying target-configured self-check stages, converging official reviews, and escalating cloud context until the task loop finishes.
|
description: Run agent-task work and autonomously execute active PLAN/CODE_REVIEW loops on request. Use when dispatching dependency-ready work in parallel by predecessor completion and workspace write claims, running lane/G-specific Codex, Claude, agy, and Pi workers, adding local-Pi self-checks, converging official Codex reviews, and escalating cloud context until the task loop finishes.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Orchestrate Agent Task Loop
|
# Orchestrate Agent Task Loop
|
||||||
|
|
@ -66,34 +66,24 @@ Treat Korean text inside code spans or fenced examples as exact runtime or file-
|
||||||
|
|
||||||
## Routing Contract
|
## Routing Contract
|
||||||
|
|
||||||
`scripts/execution_target_catalog.json` is the only assignment source. Do not
|
| PLAN route | Worker |
|
||||||
reinterpret a target from task prose or environment variables.
|
|---|---|
|
||||||
|
| `local-G01`–`local-G06` | Pi `iop/ornith:35b`, thinking high |
|
||||||
- `lanes.worker` and `lanes.review` each define every `local-G01` through
|
| `local-G07`–`local-G08` | KST day/night agy `Gemini 3.6 Flash (High)` → OpenCode `iop-glm/glm-5.2`, variant max → Codex `gpt-5.6-terra`, reasoning high |
|
||||||
`local-G10` and `cloud-G01` through `cloud-G10` independently.
|
| `local-G09`–`local-G10` | Claude `claude-opus-4-8`, effort xhigh |
|
||||||
- A lane's `candidates` array is its complete ordered execution chain. The first
|
| `cloud-G01`–`cloud-G02` | Codex `gpt-5.3-codex-spark` → agy `Gemini 3.6 Flash (Low)` → OpenCode `iop-glm/glm-5.2`, variant medium → Codex `gpt-5.6-terra`, reasoning high |
|
||||||
eligible target is the default and qualified terminal failures advance to the
|
| `cloud-G03`–`cloud-G04` | agy `Gemini 3.6 Flash (Medium)` → OpenCode `iop-glm/glm-5.2`, variant high → Codex `gpt-5.6-terra`, reasoning high |
|
||||||
next unused eligible target without returning to an earlier rank.
|
| `cloud-G05`–`cloud-G06` | agy `Gemini 3.6 Flash (High)` → OpenCode `iop-glm/glm-5.2`, variant max → Codex `gpt-5.6-terra`, reasoning high |
|
||||||
- `targets` owns adapter, model, command model, execution class, self-check,
|
| `cloud-G07`–`cloud-G08` | Claude `claude-opus-4-8`, effort xhigh |
|
||||||
thinking, and reasoning options. `selfcheck.full_review` and
|
| `cloud-G09`–`cloud-G10` | Codex `gpt-5.6-sol`, reasoning xhigh |
|
||||||
`selfcheck.checklist_review` independently enable the full-work review and
|
| Every `CODE_REVIEW-*` | Codex `gpt-5.6-sol`, reasoning xhigh |
|
||||||
implementation-checklist-only review for that exact worker target. Reorder or
|
|
||||||
replace existing target ids by editing only the lane array. Add a model for an
|
|
||||||
existing adapter by adding one target entry and referencing its id. Only a new
|
|
||||||
CLI/driver requires Python dispatcher support.
|
|
||||||
- The dispatcher reloads and validates the catalog before each scheduler
|
|
||||||
admission and again immediately before a self-check starts. A running model
|
|
||||||
invocation keeps its pinned decision, while the next task or self-check stage
|
|
||||||
uses the latest switches without a dispatcher source change or restart.
|
|
||||||
- Missing grade lanes, unknown target ids, duplicate candidates or runtime
|
|
||||||
identities, invalid options, and incomplete time-window metadata fail closed.
|
|
||||||
|
|
||||||
Concurrency limits:
|
Concurrency limits:
|
||||||
|
|
||||||
- Global physical-workspace limit: omitting `max_parallel` caps execution at `3`; explicit `max_parallel=0` is unlimited. A positive value caps unique active task-stage attempts and is not narrowed by `task_group`. The cap applies across worker, self-check, review, and verified external-active attempts in the same physical workspace.
|
- Global physical-workspace limit: omitting `max_parallel` caps execution at `3`; explicit `max_parallel=0` is unlimited. A positive value caps unique active task-stage attempts and is not narrowed by `task_group`. The cap applies across worker, self-check, review, and verified external-active attempts in the same physical workspace.
|
||||||
- Pi `ornith:35b`: 3.
|
- Pi `ornith:35b`: 3.
|
||||||
- agy: 1.
|
- agy: 1.
|
||||||
- Official review: no separate review-only limit; subject to the global
|
- Official Codex review: no separate review-only limit; subject to the global
|
||||||
cap.
|
cap.
|
||||||
- Run worker/self-check and official review in parallel only when they belong to different dependency-ready tasks and their canonical PLAN write sets do not collide in the current physical workspace. Prevent duplicate execution of the same task.
|
- Run worker/self-check and official review in parallel only when they belong to different dependency-ready tasks and their canonical PLAN write sets do not collide in the current physical workspace. Prevent duplicate execution of the same task.
|
||||||
- Even with `complete.log`, treat an explicit predecessor as unfinished while live model/review execution evidence for that task remains. Delay only its consumers; do not propagate the delay to dependency-free siblings or other task groups.
|
- Even with `complete.log`, treat an explicit predecessor as unfinished while live model/review execution evidence for that task remains. Delay only its consumers; do not propagate the delay to dependency-free siblings or other task groups.
|
||||||
|
|
@ -109,29 +99,25 @@ Keep control prompts in English, insert absolute paths only, and do not expand t
|
||||||
|
|
||||||
- A dispatcher child runs only while `IOP_AGENT_TASK_EXECUTION_ID` is present.
|
- A dispatcher child runs only while `IOP_AGENT_TASK_EXECUTION_ID` is present.
|
||||||
- Prefix every worker and review prompt with: `You are a child agent already launched by the dispatcher, not the orchestration caller. Execute only the assigned role directly. Do not start, monitor, or wait for orchestration through dispatch.py or orchestrate-agent-task-loop. You may run dispatch.py --validate-plan only when required by plan or code-review finalization because that mode validates one candidate PLAN without starting or monitoring orchestration.`
|
- Prefix every worker and review prompt with: `You are a child agent already launched by the dispatcher, not the orchestration caller. Execute only the assigned role directly. Do not start, monitor, or wait for orchestration through dispatch.py or orchestrate-agent-task-loop. You may run dispatch.py --validate-plan only when required by plan or code-review finalization because that mode validates one candidate PLAN without starting or monitoring orchestration.`
|
||||||
- Keep self-check prompts short. Start full-review, checklist-review, and recovery prompts with: `Think in English. Final in Korean.`
|
- Keep local self-check prompts short. Start fresh self-check and recovery prompts with: `Think in English. Final in Korean.` The same-session unchecked-item retry uses the exact terse prompt below.
|
||||||
|
|
||||||
- Cloud worker: `Read {PLAN_PATH} and complete the task. Keep artifact content in English. Final in Korean.`
|
- Cloud worker: `Read {PLAN_PATH} and complete the task. Keep artifact content in English. Final in Korean.`
|
||||||
- Pi worker: `Think in English. Keep artifact content in English. Final in Korean. Read {PLAN_PATH} and complete the task.`
|
- Pi worker: `Think in English. Keep artifact content in English. Final in Korean. Read {PLAN_PATH} and complete the task.`
|
||||||
- Self-check full review: `Think in English. Final in Korean. Read {PLAN_PATH}; review all work once, fix omissions, and update {CODE_REVIEW_PATH}. Keep files in English.`
|
- Pi self-check full pass: `Think in English. Final in Korean. Read {PLAN_PATH}; review all work once, fix omissions, and update {CODE_REVIEW_PATH}. Keep files in English.`
|
||||||
- Self-check checklist review: `Think in English. Final in Korean. Read {CODE_REVIEW_PATH}. Review only its Implementation Checklist section. Mark every completed item, finish any missing implementation or evidence required by those items, and leave all official-review-only sections untouched. Keep files in English.`
|
- Pi self-check unchecked-item retry: `The code review file is incomplete! Complete it now!`
|
||||||
- Official review: `Read {CODE_REVIEW_PATH} and start the review. Keep artifact content in English. Final in Korean.`
|
- Official review: `Read {CODE_REVIEW_PATH} and start the review. Keep artifact content in English. Final in Korean.`
|
||||||
- Review-exit recovery: `Continue the review for {TASK_PATH}. Keep artifact content in English. Final in Korean.`
|
- Review-exit recovery: `Continue the review for {TASK_PATH}. Keep artifact content in English. Final in Korean.`
|
||||||
- Context escalation: `Continue from {LOCATOR_PATH}. Check the saved context and current workspace. Keep artifact content in English. Final in Korean.`
|
- Context escalation: `Continue from {LOCATOR_PATH}. Check the saved context and current workspace. Keep artifact content in English. Final in Korean.`
|
||||||
|
|
||||||
Never ask a worker, self-check, or review model to create, edit, or summarize `WORK_LOG.md`.
|
Never ask a worker, self-check, or review model to create, edit, or summarize `WORK_LOG.md`.
|
||||||
|
|
||||||
Resolve self-check stages from the completing worker target's live catalog entry; do not rerun target selection or substitute another model. Treat `selfcheck.full_review` and `selfcheck.checklist_review` as separate scheduler stages and persist `selfcheck_full_review_done` and `selfcheck_checklist_review_done` independently. A disabled stage is skipped. A newly enabled unfinished stage runs before official review on the next scheduler entry.
|
Run Pi self-check only for policy targets marked `local_model` and `selfcheck_required=true`; OpenCode `iop-glm/glm-5.2` is a cloud target and skips self-check. Do not treat a local Pi self-check exit code `0` as success by itself. Set `selfcheck_done=true` only when `## Implementation Checklist` (or legacy `## 구현 체크리스트`) in `CODE_REVIEW_PATH` contains at least one Markdown list checkbox and every `[...]` checkbox value has at least one non-whitespace character. If both canonical and legacy checklist headings are present in the same file, fail closed. Accept any non-empty value, including `x`, `v`, and `✅`. Do not inspect `## Implementation Item Completion`, `Deviations from Plan`, `Key Design Decisions`, `Verification Results`, or final CODE_REVIEW synchronization text. Run the full self-check prompt exactly once. If its checklist condition fails, resume that successful pass's Pi native session and run the unchecked-item retry prompt up to 10 times. Each retry must resume the locator returned by the preceding successful pass so the same conversation context is preserved; never repeat the full review prompt or start a fresh retry session. Persist the latest successful context locator for dispatcher restart, and block instead of starting fresh when that context cannot be resumed. Block that task after the 10th unchecked-item retry remains incomplete, and continue draining independent work.
|
||||||
|
|
||||||
Treat catalog `selfcheck_required` as a legacy persisted-decision compatibility field only; operators configure the two runtime stages through the nested `selfcheck` object. When migrating old Pi state, `selfcheck_done=true` means both stages completed, while `selfcheck_done=false` with `selfcheck_incomplete > 0` means full review completed and checklist review remains unfinished. Legacy cloud `selfcheck_done=true` means the old dispatcher skipped self-check and does not satisfy a newly enabled stage.
|
After an AGY/Gemini worker exits `0`, apply the same `CODE_REVIEW_PATH` implementation-checklist regex before accepting worker completion. If it is incomplete, run a fresh quota probe: only an `exhausted` target becomes `provider-quota` and enters the existing selector failover/promotion chain; `available` or `unknown` remains a completion-evidence recovery on Gemini.
|
||||||
|
|
||||||
The full-review stage runs its prompt exactly once. The checklist-review stage first evaluates `## Implementation Checklist` (or legacy `## 구현 체크리스트`) in `CODE_REVIEW_PATH`; if it already contains at least one Markdown list checkbox and every `[...]` checkbox value has at least one non-whitespace character, complete the stage without invoking a model. If both canonical and legacy checklist headings are present in the same file, fail closed. Accept any non-empty value, including `x`, `v`, and `✅`. Do not inspect `## Implementation Item Completion`, `Deviations from Plan`, `Key Design Decisions`, `Verification Results`, or final CODE_REVIEW synchronization text. When incomplete, run one checklist-only pass plus up to 10 checklist-only retries. For Pi, each retry resumes the locator returned by the preceding successful pass; persist the latest successful context locator across dispatcher restart and block instead of starting fresh when it cannot be resumed. For cloud targets, each checklist-only retry starts fresh on the same completing target. Never promote or substitute a different self-check target after a provider failure. Keep the two stages' process-recovery budgets independent. Block that task after the 10th checklist retry remains incomplete, and continue draining independent work.
|
For Pi worker recovery attempts, pass only `Read {PLAN_PATH}. Continue.` without a locator explanation. Pi self-check recovery must preserve the current full-pass or unchecked-item role and use its concise prompt. For other CLI escalation attempts, pass `Continue from {LOCATOR_PATH}. Check the saved context and current workspace. Keep artifact content in English. Final in Korean.` Preserve the collaboration prohibition and next-state-materialization sentence in official-review escalation and recovery prompts. Do not ask the model to write a separate handoff summary.
|
||||||
|
|
||||||
After an AGY/Gemini worker exits `0`, apply the same `CODE_REVIEW_PATH` implementation-checklist regex before accepting worker completion. If it is incomplete, run a fresh quota probe: only an `exhausted` target becomes `provider-quota` and enters the ordered lane failover chain; `available` or `unknown` remains a completion-evidence recovery on Gemini.
|
When recovering a KST-night `local-G07`–`local-G08` Laguna locator or a terminal `session-stall` locator left by an earlier dispatcher, first require the locator and native session to belong to the current physical workspace. Do not create a fresh session ID for an owned locator. Resume its native session file with `pi --session` and the existing `--session-dir`. For worker recovery pass `Think in English. Keep artifact content in English. Final in Korean. Continue this session and complete the current task.` For interrupted full self-check recovery pass `Think in English. Final in Korean. Continue. Keep files in English.` For an unchecked-item retry, pass its normal concise prompt while resuming the existing native session. After a dispatcher restart, find the owned locator and resume the same session. Count this same-session restart toward the same stage's 10-consecutive-failure limit.
|
||||||
|
|
||||||
For Pi worker recovery attempts, pass only `Read {PLAN_PATH}. Continue.` without a locator explanation. Pi self-check recovery must preserve the current full-review or checklist-review role and use its concise prompt. For other CLI escalation attempts, pass `Continue from {LOCATOR_PATH}. Check the saved context and current workspace. Keep artifact content in English. Final in Korean.` Preserve the collaboration prohibition and next-state-materialization sentence in official-review escalation and recovery prompts. Do not ask the model to write a separate handoff summary.
|
|
||||||
|
|
||||||
When recovering a KST-night `local-G07`–`local-G08` Laguna locator or a terminal `session-stall` locator left by an earlier dispatcher, first require the locator and native session to belong to the current physical workspace. Do not create a fresh session ID for an owned locator. Resume its native session file with `pi --session` and the existing `--session-dir`. For worker recovery pass `Think in English. Keep artifact content in English. Final in Korean. Continue this session and complete the current task.` For interrupted full-review recovery pass `Think in English. Final in Korean. Continue. Keep files in English.` For a checklist-review retry, pass its normal concise prompt while resuming the existing native session. After a dispatcher restart, find the owned locator and resume the same session. Count this same-session restart toward the same stage's 10-consecutive-failure limit.
|
|
||||||
|
|
||||||
## Work-Log Contract
|
## Work-Log Contract
|
||||||
|
|
||||||
|
|
@ -169,7 +155,7 @@ When recovering a KST-night `local-G07`–`local-G08` Laguna locator or a termin
|
||||||
- Determine every CLI's health/progress primarily from actual stdout/stderr in `stream.log`, plus native session events when available. Before accepting PID, marker, native-session, or stream evidence, require the locator path and recorded workspace identity to belong to the current physical workspace; accept an identity-less legacy locator only under the current store's `runs` root. Never use heartbeat mtime as progress evidence. Record workspace id, dispatcher PID, agent PID, each process start token, and the per-attempt process environment marker in the locator; namespace that marker by workspace. Another dispatcher must not start a duplicate attempt merely because the stream is quiet when the PID/start token or marker shows the same process is alive. For a locator without an agent PID, never infer stale state or duplicate recovery from elapsed time while any stream/native progress evidence exists; use only an actual terminal error or confirmed process exit as recovery evidence for every model. Run Pi with `--mode json` so `thinking_delta`, `text_delta`, and tool streams reach stdout. End an **exact** Pi toolCall-to-all-toolResult interval only when every `toolCall.id` in the preceding assistant event matches a later `toolResult.toolCallId`; never terminate the process on a time limit. If the locator lacks an agent PID during this interval, never classify it as stale or duplicate recovery based on log age; require recorded process evidence to show termination. Do not infer tool execution from `starting`, `unknown`, model reasoning, or post-toolResult state. Outside this interval, use only `stream.log` updates for Pi liveness; toolResult alone does not reset the model-response silence clock. If the stream stops for three minutes outside tool execution, store the final stream excerpt as `pi_silence_inspection` for Pi or `stream_silence_inspection` for another CLI, emit `모델응답점검`, and do not terminate the model process. Recover only from an actual terminal error or process exit.
|
- Determine every CLI's health/progress primarily from actual stdout/stderr in `stream.log`, plus native session events when available. Before accepting PID, marker, native-session, or stream evidence, require the locator path and recorded workspace identity to belong to the current physical workspace; accept an identity-less legacy locator only under the current store's `runs` root. Never use heartbeat mtime as progress evidence. Record workspace id, dispatcher PID, agent PID, each process start token, and the per-attempt process environment marker in the locator; namespace that marker by workspace. Another dispatcher must not start a duplicate attempt merely because the stream is quiet when the PID/start token or marker shows the same process is alive. For a locator without an agent PID, never infer stale state or duplicate recovery from elapsed time while any stream/native progress evidence exists; use only an actual terminal error or confirmed process exit as recovery evidence for every model. Run Pi with `--mode json` so `thinking_delta`, `text_delta`, and tool streams reach stdout. End an **exact** Pi toolCall-to-all-toolResult interval only when every `toolCall.id` in the preceding assistant event matches a later `toolResult.toolCallId`; never terminate the process on a time limit. If the locator lacks an agent PID during this interval, never classify it as stale or duplicate recovery based on log age; require recorded process evidence to show termination. Do not infer tool execution from `starting`, `unknown`, model reasoning, or post-toolResult state. Outside this interval, use only `stream.log` updates for Pi liveness; toolResult alone does not reset the model-response silence clock. If the stream stops for three minutes outside tool execution, store the final stream excerpt as `pi_silence_inspection` for Pi or `stream_silence_inspection` for another CLI, emit `모델응답점검`, and do not terminate the model process. Recover only from an actual terminal error or process exit.
|
||||||
- Detect a local-model `repetition-loop` only when the same normalized chunk repeats three consecutive times with no new tool event or file/state change. Do not infer it from similarity or semantic duplication in `thinking_delta`/`text_delta`. This signal alone must not terminate the process, block the task, trigger recovery/retry, or escalate the model; keep observing for substantive progress or an actual terminal error.
|
- Detect a local-model `repetition-loop` only when the same normalized chunk repeats three consecutive times with no new tool event or file/state change. Do not infer it from similarity or semantic duplication in `thinking_delta`/`text_delta`. This signal alone must not terminate the process, block the task, trigger recovery/retry, or escalate the model; keep observing for substantive progress or an actual terminal error.
|
||||||
- Keep `provider-connection`, `provider-stream-disconnect`, `session-stall`, `generic-error`, `process-terminated`, context/quota/model errors, and review-control violations distinct, but make them share a budget of 10 consecutive automatic recovery failures for the same task stage. On the 10th failure, block that task and do not auto-resume after cooldown. Reset the stage counter after success.
|
- Keep `provider-connection`, `provider-stream-disconnect`, `session-stall`, `generic-error`, `process-terminated`, context/quota/model errors, and review-control violations distinct, but make them share a budget of 10 consecutive automatic recovery failures for the same task stage. On the 10th failure, block that task and do not auto-resume after cooldown. Reset the stage counter after success.
|
||||||
- Record an explicit terminal blocker when a checklist-review initial pass plus 10 retries leaves the implementation checklist incomplete, or official review makes no change 10 consecutive times.
|
- Record an explicit terminal blocker when the initial Pi full self-check plus 10 same-context unchecked-item retries leave the implementation checklist incomplete, or official review makes no change 10 consecutive times.
|
||||||
- While one task recovers or becomes blocked, continue every ready/running task that neither requires it as a predecessor nor collides with its retained workspace claim. Internal recovery or blocking must not trigger an arbitrary complete-candidate rescan.
|
- While one task recovers or becomes blocked, continue every ready/running task that neither requires it as a predecessor nor collides with its retained workspace claim. Internal recovery or blocking must not trigger an arbitrary complete-candidate rescan.
|
||||||
- If review shared-state preflight fails, block only ready review tasks and still start every worker/self-check with a disjoint claim in the same pass. The complete scan after `complete.log` must preserve the existing snapshot rather than reread already running task directories, avoiding races with parallel archive moves that could stop another process.
|
- If review shared-state preflight fails, block only ready review tasks and still start every worker/self-check with a disjoint claim in the same pass. The complete scan after `complete.log` must preserve the existing snapshot rather than reread already running task directories, avoiding races with parallel archive moves that could stop another process.
|
||||||
- For KST-night `local-G07`–`local-G08` Laguna locator `context-limit`/`session-stall`, prefer the Prompt Contract's same-session resume and display `Pi세션연속재시작`. Use a fresh session and `세션응답복구재시도` only for other legacy Pi `session-stall` recovery.
|
- For KST-night `local-G07`–`local-G08` Laguna locator `context-limit`/`session-stall`, prefer the Prompt Contract's same-session resume and display `Pi세션연속재시작`. Use a fresh session and `세션응답복구재시도` only for other legacy Pi `session-stall` recovery.
|
||||||
|
|
@ -235,25 +221,26 @@ When recovering a KST-night `local-G07`–`local-G08` Laguna locator or a termin
|
||||||
- If a worker/self-check/review future ends without `complete.log`, reread only that task and run its next stage. Do not rescan the complete candidate set.
|
- If a worker/self-check/review future ends without `complete.log`, reread only that task and run its next stage. Do not rescan the complete candidate set.
|
||||||
- Persist `active_stage` for a running task. After dispatcher restart, exclude that task from candidates, restore or conservatively adopt its workspace write claim, and immediately dispatch every other dependency-ready task whose claim does not collide.
|
- Persist `active_stage` for a running task. After dispatcher restart, exclude that task from candidates, restore or conservatively adopt its workspace write claim, and immediately dispatch every other dependency-ready task whose claim does not collide.
|
||||||
- **ABSOLUTE RULE:** Scan the complete candidate set only at initial entry and immediately after creating a verified `complete.log`. In that scan, exclude tasks shown as running by current-workspace state and native session/locator evidence, then atomically admit every dependency-ready task with a non-colliding write claim. An unmet dependency or write collision excludes only that task. Exit instead of polling when no candidate remains.
|
- **ABSOLUTE RULE:** Scan the complete candidate set only at initial entry and immediately after creating a verified `complete.log`. In that scan, exclude tasks shown as running by current-workspace state and native session/locator evidence, then atomically admit every dependency-ready task with a non-colliding write claim. An unmet dependency or write collision excludes only that task. Exit instead of polling when no candidate remains.
|
||||||
- Persist worker success, full-review self-check, checklist-review self-check, and official review as separate stages. If restart state has an enabled unfinished self-check stage, use the completing worker target rather than rerunning worker selection or advancing to official review. For Pi checklist-review retries, resume the persisted successful context locator; never replace a missing or invalid persisted context with a fresh session.
|
- Persist Pi worker success, Pi self-check success, and official review as separate stages. If restart state is `worker_done=true` and `selfcheck_done=false`, resume on the same Pi model, not with worker or review. Run the full pass when `selfcheck_incomplete=0`; otherwise resume the persisted successful self-check context locator with an unchecked-item retry. Never replace a missing or invalid persisted context with a fresh session.
|
||||||
- Key persistent state to the first-line `task/plan/tag` generation and, for `m-*`, its `milestone-task` scope. Checklist/body edits to the same PLAN do not reset the stage; a new plan number or changed Milestone Task scope does.
|
- Key persistent state to the first-line `task/plan/tag` generation and, for `m-*`, its `milestone-task` scope. Checklist/body edits to the same PLAN do not reset the stage; a new plan number or changed Milestone Task scope does.
|
||||||
- Send an already completed review stub with no dispatcher execution record to review. Never send dispatcher-recorded worker success to review while a configured self-check stage remains unfinished.
|
- Send an already completed review stub with no dispatcher execution record to review. Never send dispatcher-recorded Pi worker success to review before self-check completes.
|
||||||
- Start official review and worker/self-check together when they belong to different dependency-ready tasks with disjoint workspace claims. Wait for a claim owner to reach verified completion before admitting a colliding task.
|
- Start official review and worker/self-check together when they belong to different dependency-ready tasks with disjoint workspace claims. Wait for a claim owner to reach verified completion before admitting a colliding task.
|
||||||
- Let the dispatcher record every worker/self-check/review attempt start and finish in the task-group `WORK_LOG.md`.
|
- Let the dispatcher record every worker/self-check/review attempt start and finish in the task-group `WORK_LOG.md`.
|
||||||
- Archive `WORK_LOG.md` as `work_log_N.log` only after the final task review process exits, the dispatcher appends `FINISH`, and a complete scan finds no active/running task in that group. Accept the log at either the active group path or the verified completed single-task archive; do not impose either location contract on common plan/code-review.
|
- Archive `WORK_LOG.md` as `work_log_N.log` only after the final task review process exits, the dispatcher appends `FINISH`, and a complete scan finds no active/running task in that group. Accept the log at either the active group path or the verified completed single-task archive; do not impose either location contract on common plan/code-review.
|
||||||
|
|
||||||
3. **Escalate and recover context.**
|
3. **Escalate and recover context.**
|
||||||
- For every multi-candidate catalog lane, classify terminal provider errors or stderr evidence of context/output limits, provider quota/rate limits, unavailable models, or confirmed provider transport errors as a qualified failover to the next catalog candidate. For AGY, accept top-level `error`, `fatal`, `request.failed`, or `turn.failed` events; failed/rejected status with a top-level error/code; stderr; or strong `RESOURCE_EXHAUSTED`, HTTP 429, quota, or rate-limit evidence in `agy-cli.log`. For OpenCode, accept stderr or structured error events as terminal diagnostics. Never fail over from an assistant message, source text, tool/test output, or a plain quota-configuration string in an AGY log. Legacy locators retain their compatibility promotion only for recovery.
|
- For every route that lists Gemini followed by OpenCode GLM and Codex Terra, classify terminal provider errors or stderr evidence of context/output limits, provider quota/rate limits, unavailable models, or confirmed provider transport errors as a qualified failover to the next candidate. For AGY, accept top-level `error`, `fatal`, `request.failed`, or `turn.failed` events; failed/rejected status with a top-level error/code; stderr; or strong `RESOURCE_EXHAUSTED`, HTTP 429, quota, or rate-limit evidence in `agy-cli.log`. For OpenCode, accept stderr or structured error events as terminal diagnostics. Cloud-only escalation remains OpenCode GLM → Codex Terra; never escalate from an assistant message, source text, tool/test output, or a plain quota-configuration string in an AGY log. Legacy `claude-glm` locators retain their typed quota parsing for recovery only.
|
||||||
- If a selected target has no unused eligible catalog fallback, retry in a fresh session using the locator while preserving that target's model options and sharing the same stage's 10-consecutive-failure limit. Continue dispatching other tasks during recovery.
|
- Target Codex `gpt-5.6-terra` with reasoning `high` when escalating from Claude to Codex.
|
||||||
|
- If Codex returns the same error, retry in a fresh Codex session using the locator while preserving the previous Codex model/reasoning and sharing the same stage's 10-consecutive-failure limit. Continue dispatching other tasks during recovery.
|
||||||
- When current source reads a locator blocked 10 times as `generic-error` by older dispatcher source, collapse those 10 failures into one terminal error and clear only that task's blocker only if all 10 terminal-evidence records for the same task/plan/role/source/execution target reclassify to the same escalatable error. Include `stream.log` and the attempt's `agy-cli.log` for AGY. Do not adjust automatically when any history is missing or mixed, or when the locator dispatcher source hash equals the current source hash. Dry-run must display this escalation recovery and next model without writing state. Live execution must choose the higher target from the locator's actual failed target, not the initial PLAN route, inherit locator context, and restore the same escalation target and locator from persisted reclassification metadata after immediate restart.
|
- When current source reads a locator blocked 10 times as `generic-error` by older dispatcher source, collapse those 10 failures into one terminal error and clear only that task's blocker only if all 10 terminal-evidence records for the same task/plan/role/source/execution target reclassify to the same escalatable error. Include `stream.log` and the attempt's `agy-cli.log` for AGY. Do not adjust automatically when any history is missing or mixed, or when the locator dispatcher source hash equals the current source hash. Dry-run must display this escalation recovery and next model without writing state. Live execution must choose the higher target from the locator's actual failed target, not the initial PLAN route, inherit locator context, and restore the same escalation target and locator from persisted reclassification metadata after immediate restart.
|
||||||
- Recover timeout, crash, process termination, permission, and ordinary implementation errors on the same target within the same stage's 10-consecutive-failure limit, preserving the actual failure class and locator. At exhaustion, block only that task and keep dispatching independent work.
|
- Recover timeout, crash, process termination, permission, and ordinary implementation errors on the same target within the same stage's 10-consecutive-failure limit, preserving the actual failure class and locator. At exhaustion, block only that task and keep dispatching independent work.
|
||||||
- On success after escalation, record `worker_cli` and `worker_model` from the successful locator's actual target, not the initial PLAN route.
|
- On success after escalation, record `worker_cli` and `worker_model` from the successful locator's actual target, not the initial PLAN route.
|
||||||
- Never escalate a `local_model` target to a cloud target. Cloud fallback follows only the current lane's ordered candidates; legacy locators may use their persisted compatibility promotion.
|
- Never escalate local Pi models to a cloud model. OpenCode GLM may fail over to the policy-owned Codex Terra backup; legacy `claude-glm` recovery keeps the same terminal fallback.
|
||||||
- Use attempt identity `<task-name>__p<plan>__<role>__aNN` and namespace the process marker with the physical workspace id. Record canonical workspace root/id, CLI/model/reasoning effort, PLAN/review, `WORK_LOG.md`, session ID, native session path, and raw output log in the locator.
|
- Use attempt identity `<task-name>__p<plan>__<role>__aNN` and namespace the process marker with the physical workspace id. Record canonical workspace root/id, CLI/model/reasoning effort, PLAN/review, `WORK_LOG.md`, session ID, native session path, and raw output log in the locator.
|
||||||
- Store locators under repository `.git/agent-task-dispatcher/runs/`. Fall back to `${XDG_STATE_HOME}/agent-task-dispatcher/<workspace-id>/runs/` only when `.git` state is unwritable.
|
- Store locators under repository `.git/agent-task-dispatcher/runs/`. Fall back to `${XDG_STATE_HOME}/agent-task-dispatcher/<workspace-id>/runs/` only when `.git` state is unwritable.
|
||||||
|
|
||||||
4. **Converge review.**
|
4. **Converge review.**
|
||||||
- Run every official review in an independent session on the selected review-lane target with no separate numeric limit. Dispatch all ready reviews with disjoint workspace claims in parallel.
|
- Run every official review in an independent Codex one-shot session with no separate numeric limit. Dispatch all ready reviews with disjoint workspace claims in parallel.
|
||||||
- For finalization recovery without an active PLAN, recover the review target and write claim from the archived plan log for the same first-line generation metadata, including `milestone-task` when present. Keep the claim until the completed archive is verified.
|
- For finalization recovery without an active PLAN, recover the review target and write claim from the archived plan log for the same first-line generation metadata, including `milestone-task` when present. Keep the claim until the completed archive is verified.
|
||||||
- Forbid collaboration/sub-agent tools in official review and finish inside the current one-shot session. If such a tool call appears, clean up that attempt's independent subprocess group and retry in a fresh review session. Count the failure toward the same stage's 10-consecutive-failure limit.
|
- Forbid collaboration/sub-agent tools in official review and finish inside the current one-shot session. If such a tool call appears, clean up that attempt's independent subprocess group and retry in a fresh review session. Count the failure toward the same stage's 10-consecutive-failure limit.
|
||||||
- Delegate PASS archive, WARN/FAIL follow-up pairs, and review-finalization recovery to the `code-review` file contract.
|
- Delegate PASS archive, WARN/FAIL follow-up pairs, and review-finalization recovery to the `code-review` file contract.
|
||||||
|
|
@ -264,9 +251,9 @@ When recovering a KST-night `local-G07`–`local-G08` Laguna locator or a termin
|
||||||
## Verification Checklist
|
## Verification Checklist
|
||||||
|
|
||||||
- [ ] Scan the complete candidate set only on initial entry and immediately after verified `complete.log`; atomically claim and start every non-running, dependency-ready, non-colliding candidate in the same pass.
|
- [ ] Scan the complete candidate set only on initial entry and immediately after verified `complete.log`; atomically claim and start every non-running, dependency-ready, non-colliding candidate in the same pass.
|
||||||
- [ ] Confirm the actual CLI/model for each route matches its catalog lane array.
|
- [ ] Confirm the actual CLI/model for each route matches the routing table.
|
||||||
- [ ] Reload the catalog before admission, then run only the completing target's enabled full-review/checklist-review stages; allow one checklist-only pass plus at most 10 retries, preserving Pi native context between retries.
|
- [ ] Run exactly one full fresh-session self-check only for Pi work, followed by at most 10 unchecked-item retries in that same Pi native session context when its checklist remains incomplete.
|
||||||
- [ ] Resolve every official review from its explicit `lanes.review` grade entry and dispatch dependency-ready reviews with disjoint workspace claims in parallel, subject to the global `--max-parallel` cap (no separate review-only limit).
|
- [ ] Run every official review with Codex `gpt-5.6-sol` xhigh and dispatch dependency-ready reviews with disjoint workspace claims in parallel, subject to the global `--max-parallel` cap (no separate review-only limit).
|
||||||
- [ ] Locate the native session and output log for every attempt locator.
|
- [ ] Locate the native session and output log for every attempt locator.
|
||||||
- [ ] Record every worker/self-check/review attempt `START`/`FINISH` in one task-group `WORK_LOG.md`.
|
- [ ] Record every worker/self-check/review attempt `START`/`FINISH` in one task-group `WORK_LOG.md`.
|
||||||
- [ ] For every completed task group that generated `WORK_LOG.md`, archive a `work_log_N.log` containing the final review `FINISH` and leave no active `WORK_LOG.md`.
|
- [ ] For every completed task group that generated `WORK_LOG.md`, archive a `work_log_N.log` containing the final review `FINISH` and leave no active `WORK_LOG.md`.
|
||||||
|
|
@ -302,7 +289,7 @@ Use the same separator format for `작업대기`, `작업수행중`, `자가검
|
||||||
- Never infer dependency from numeric order when no predecessor index is present.
|
- Never infer dependency from numeric order when no predecessor index is present.
|
||||||
- Never scan the complete archive or read archive files outside dependency candidates.
|
- Never scan the complete archive or read archive files outside dependency candidates.
|
||||||
- Never ask a worker to perform official review, archive work, or create `complete.log`.
|
- Never ask a worker to perform official review, archive work, or create `complete.log`.
|
||||||
- Never treat either self-check stage as official review.
|
- Never treat Pi self-check as official review.
|
||||||
- Never depend on a model-authored handoff summary for context recovery.
|
- Never depend on a model-authored handoff summary for context recovery.
|
||||||
- Never treat a generic failure as token/quota failure and escalate it to a higher model.
|
- Never treat a generic failure as token/quota failure and escalate it to a higher model.
|
||||||
- Never resolve `USER_REVIEW.md` automatically or guess a user decision.
|
- Never resolve `USER_REVIEW.md` automatically or guess a user decision.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
interface:
|
interface:
|
||||||
display_name: "Agent Task Loop Orchestrator"
|
display_name: "Agent Task Loop Orchestrator"
|
||||||
short_description: "Orchestrate catalog-routed PLAN and review loops"
|
short_description: "Orchestrate PLAN execution and Codex review loops"
|
||||||
default_prompt: "Use $orchestrate-agent-task-loop to execute the active agent-task workflow."
|
default_prompt: "Use $orchestrate-agent-task-loop to execute the active agent-task workflow."
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,597 +0,0 @@
|
||||||
{
|
|
||||||
"schema_version": 1,
|
|
||||||
"targets": {
|
|
||||||
"pi-ornith-high": {
|
|
||||||
"adapter": "pi",
|
|
||||||
"target": "iop/ornith:35b",
|
|
||||||
"execution_class": "local_model",
|
|
||||||
"selfcheck": {
|
|
||||||
"full_review": true,
|
|
||||||
"checklist_review": true
|
|
||||||
},
|
|
||||||
"thinking_level": "high"
|
|
||||||
},
|
|
||||||
"pi-laguna-high": {
|
|
||||||
"adapter": "pi",
|
|
||||||
"target": "iop/laguna-s:2.1",
|
|
||||||
"execution_class": "local_model",
|
|
||||||
"selfcheck": {
|
|
||||||
"full_review": true,
|
|
||||||
"checklist_review": true
|
|
||||||
},
|
|
||||||
"thinking_level": "high"
|
|
||||||
},
|
|
||||||
"agy-gemini-low": {
|
|
||||||
"adapter": "agy",
|
|
||||||
"target": "Gemini 3.6 Flash (Low)",
|
|
||||||
"execution_class": "cloud_model",
|
|
||||||
"selfcheck": {
|
|
||||||
"full_review": false,
|
|
||||||
"checklist_review": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agy-gemini-medium": {
|
|
||||||
"adapter": "agy",
|
|
||||||
"target": "Gemini 3.6 Flash (Medium)",
|
|
||||||
"execution_class": "cloud_model",
|
|
||||||
"selfcheck": {
|
|
||||||
"full_review": false,
|
|
||||||
"checklist_review": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agy-gemini-high": {
|
|
||||||
"adapter": "agy",
|
|
||||||
"target": "Gemini 3.6 Flash (High)",
|
|
||||||
"execution_class": "cloud_model",
|
|
||||||
"selfcheck": {
|
|
||||||
"full_review": false,
|
|
||||||
"checklist_review": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"opencode-glm-medium": {
|
|
||||||
"adapter": "opencode",
|
|
||||||
"target": "glm-5.2",
|
|
||||||
"command_model": "iop-glm/glm-5.2",
|
|
||||||
"execution_class": "cloud_model",
|
|
||||||
"selfcheck": {
|
|
||||||
"full_review": false,
|
|
||||||
"checklist_review": true
|
|
||||||
},
|
|
||||||
"reasoning_effort": "medium"
|
|
||||||
},
|
|
||||||
"opencode-glm-high": {
|
|
||||||
"adapter": "opencode",
|
|
||||||
"target": "glm-5.2",
|
|
||||||
"command_model": "iop-glm/glm-5.2",
|
|
||||||
"execution_class": "cloud_model",
|
|
||||||
"selfcheck": {
|
|
||||||
"full_review": false,
|
|
||||||
"checklist_review": true
|
|
||||||
},
|
|
||||||
"reasoning_effort": "high"
|
|
||||||
},
|
|
||||||
"opencode-glm-max": {
|
|
||||||
"adapter": "opencode",
|
|
||||||
"target": "glm-5.2",
|
|
||||||
"command_model": "iop-glm/glm-5.2",
|
|
||||||
"execution_class": "cloud_model",
|
|
||||||
"selfcheck": {
|
|
||||||
"full_review": false,
|
|
||||||
"checklist_review": true
|
|
||||||
},
|
|
||||||
"reasoning_effort": "max"
|
|
||||||
},
|
|
||||||
"legacy-claude-glm": {
|
|
||||||
"adapter": "claude-glm",
|
|
||||||
"target": "glm-5.2",
|
|
||||||
"command_model": "sonnet",
|
|
||||||
"execution_class": "cloud_model",
|
|
||||||
"selfcheck": {
|
|
||||||
"full_review": false,
|
|
||||||
"checklist_review": true
|
|
||||||
},
|
|
||||||
"reasoning_effort": "xhigh"
|
|
||||||
},
|
|
||||||
"claude-opus-xhigh": {
|
|
||||||
"adapter": "claude",
|
|
||||||
"target": "claude-opus-4-8",
|
|
||||||
"execution_class": "cloud_model",
|
|
||||||
"selfcheck": {
|
|
||||||
"full_review": false,
|
|
||||||
"checklist_review": false
|
|
||||||
},
|
|
||||||
"reasoning_effort": "xhigh"
|
|
||||||
},
|
|
||||||
"claude-haiku-xhigh": {
|
|
||||||
"adapter": "claude",
|
|
||||||
"target": "claude-haiku-4-5",
|
|
||||||
"execution_class": "cloud_model",
|
|
||||||
"selfcheck": {
|
|
||||||
"full_review": false,
|
|
||||||
"checklist_review": false
|
|
||||||
},
|
|
||||||
"reasoning_effort": "xhigh"
|
|
||||||
},
|
|
||||||
"codex-spark-xhigh": {
|
|
||||||
"adapter": "codex",
|
|
||||||
"target": "gpt-5.3-codex-spark",
|
|
||||||
"execution_class": "cloud_model",
|
|
||||||
"selfcheck": {
|
|
||||||
"full_review": false,
|
|
||||||
"checklist_review": false
|
|
||||||
},
|
|
||||||
"reasoning_effort": "xhigh"
|
|
||||||
},
|
|
||||||
"codex-sol-xhigh": {
|
|
||||||
"adapter": "codex",
|
|
||||||
"target": "gpt-5.6-sol",
|
|
||||||
"execution_class": "cloud_model",
|
|
||||||
"selfcheck": {
|
|
||||||
"full_review": false,
|
|
||||||
"checklist_review": false
|
|
||||||
},
|
|
||||||
"reasoning_effort": "xhigh"
|
|
||||||
},
|
|
||||||
"codex-terra-high": {
|
|
||||||
"adapter": "codex",
|
|
||||||
"target": "gpt-5.6-terra",
|
|
||||||
"execution_class": "cloud_model",
|
|
||||||
"selfcheck": {
|
|
||||||
"full_review": false,
|
|
||||||
"checklist_review": false
|
|
||||||
},
|
|
||||||
"reasoning_effort": "high"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"lanes": {
|
|
||||||
"worker": {
|
|
||||||
"local-G01": {
|
|
||||||
"candidates": [
|
|
||||||
"pi-ornith-high"
|
|
||||||
],
|
|
||||||
"rule_id": "worker-local-g01-catalog",
|
|
||||||
"policy_priority": 30,
|
|
||||||
"reason_codes": [
|
|
||||||
"worker_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"local-G02": {
|
|
||||||
"candidates": [
|
|
||||||
"pi-ornith-high"
|
|
||||||
],
|
|
||||||
"rule_id": "worker-local-g02-catalog",
|
|
||||||
"policy_priority": 30,
|
|
||||||
"reason_codes": [
|
|
||||||
"worker_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"local-G03": {
|
|
||||||
"candidates": [
|
|
||||||
"pi-ornith-high"
|
|
||||||
],
|
|
||||||
"rule_id": "worker-local-g03-catalog",
|
|
||||||
"policy_priority": 30,
|
|
||||||
"reason_codes": [
|
|
||||||
"worker_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"local-G04": {
|
|
||||||
"candidates": [
|
|
||||||
"pi-ornith-high"
|
|
||||||
],
|
|
||||||
"rule_id": "worker-local-g04-catalog",
|
|
||||||
"policy_priority": 30,
|
|
||||||
"reason_codes": [
|
|
||||||
"worker_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"local-G05": {
|
|
||||||
"candidates": [
|
|
||||||
"pi-ornith-high"
|
|
||||||
],
|
|
||||||
"rule_id": "worker-local-g05-catalog",
|
|
||||||
"policy_priority": 30,
|
|
||||||
"reason_codes": [
|
|
||||||
"worker_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"local-G06": {
|
|
||||||
"candidates": [
|
|
||||||
"pi-ornith-high"
|
|
||||||
],
|
|
||||||
"rule_id": "worker-local-g06-catalog",
|
|
||||||
"policy_priority": 30,
|
|
||||||
"reason_codes": [
|
|
||||||
"worker_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"local-G07": {
|
|
||||||
"candidates": [
|
|
||||||
"agy-gemini-high",
|
|
||||||
"opencode-glm-max",
|
|
||||||
"codex-terra-high"
|
|
||||||
],
|
|
||||||
"policy_priority": 20,
|
|
||||||
"time_windows": {
|
|
||||||
"kst-day-[07:00,23:00)": {
|
|
||||||
"rule_id": "worker-local-g07-kst-day-catalog",
|
|
||||||
"reason_codes": [
|
|
||||||
"worker_catalog_lane_kst_day"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"kst-night-[23:00,07:00)": {
|
|
||||||
"rule_id": "worker-local-g07-kst-night-catalog",
|
|
||||||
"reason_codes": [
|
|
||||||
"worker_catalog_lane_kst_night"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"local-G08": {
|
|
||||||
"candidates": [
|
|
||||||
"agy-gemini-high",
|
|
||||||
"opencode-glm-max",
|
|
||||||
"codex-terra-high"
|
|
||||||
],
|
|
||||||
"policy_priority": 20,
|
|
||||||
"time_windows": {
|
|
||||||
"kst-day-[07:00,23:00)": {
|
|
||||||
"rule_id": "worker-local-g08-kst-day-catalog",
|
|
||||||
"reason_codes": [
|
|
||||||
"worker_catalog_lane_kst_day"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"kst-night-[23:00,07:00)": {
|
|
||||||
"rule_id": "worker-local-g08-kst-night-catalog",
|
|
||||||
"reason_codes": [
|
|
||||||
"worker_catalog_lane_kst_night"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"local-G09": {
|
|
||||||
"candidates": [
|
|
||||||
"claude-opus-xhigh",
|
|
||||||
"codex-terra-high"
|
|
||||||
],
|
|
||||||
"rule_id": "worker-local-g09-catalog",
|
|
||||||
"policy_priority": 30,
|
|
||||||
"reason_codes": [
|
|
||||||
"worker_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"local-G10": {
|
|
||||||
"candidates": [
|
|
||||||
"claude-opus-xhigh",
|
|
||||||
"codex-terra-high"
|
|
||||||
],
|
|
||||||
"rule_id": "worker-local-g10-catalog",
|
|
||||||
"policy_priority": 30,
|
|
||||||
"reason_codes": [
|
|
||||||
"worker_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"cloud-G01": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-spark-xhigh",
|
|
||||||
"agy-gemini-low",
|
|
||||||
"opencode-glm-medium",
|
|
||||||
"codex-terra-high"
|
|
||||||
],
|
|
||||||
"rule_id": "worker-cloud-g01-catalog",
|
|
||||||
"policy_priority": 30,
|
|
||||||
"reason_codes": [
|
|
||||||
"worker_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"cloud-G02": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-spark-xhigh",
|
|
||||||
"agy-gemini-low",
|
|
||||||
"opencode-glm-medium",
|
|
||||||
"codex-terra-high"
|
|
||||||
],
|
|
||||||
"rule_id": "worker-cloud-g02-catalog",
|
|
||||||
"policy_priority": 30,
|
|
||||||
"reason_codes": [
|
|
||||||
"worker_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"cloud-G03": {
|
|
||||||
"candidates": [
|
|
||||||
"agy-gemini-medium",
|
|
||||||
"opencode-glm-high",
|
|
||||||
"codex-terra-high"
|
|
||||||
],
|
|
||||||
"rule_id": "worker-cloud-g03-catalog",
|
|
||||||
"policy_priority": 30,
|
|
||||||
"reason_codes": [
|
|
||||||
"worker_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"cloud-G04": {
|
|
||||||
"candidates": [
|
|
||||||
"agy-gemini-medium",
|
|
||||||
"opencode-glm-high",
|
|
||||||
"codex-terra-high"
|
|
||||||
],
|
|
||||||
"rule_id": "worker-cloud-g04-catalog",
|
|
||||||
"policy_priority": 30,
|
|
||||||
"reason_codes": [
|
|
||||||
"worker_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"cloud-G05": {
|
|
||||||
"candidates": [
|
|
||||||
"agy-gemini-high",
|
|
||||||
"opencode-glm-max",
|
|
||||||
"codex-terra-high"
|
|
||||||
],
|
|
||||||
"rule_id": "worker-cloud-g05-catalog",
|
|
||||||
"policy_priority": 30,
|
|
||||||
"reason_codes": [
|
|
||||||
"worker_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"cloud-G06": {
|
|
||||||
"candidates": [
|
|
||||||
"agy-gemini-high",
|
|
||||||
"opencode-glm-max",
|
|
||||||
"codex-terra-high"
|
|
||||||
],
|
|
||||||
"rule_id": "worker-cloud-g06-catalog",
|
|
||||||
"policy_priority": 30,
|
|
||||||
"reason_codes": [
|
|
||||||
"worker_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"cloud-G07": {
|
|
||||||
"candidates": [
|
|
||||||
"claude-opus-xhigh",
|
|
||||||
"codex-terra-high"
|
|
||||||
],
|
|
||||||
"rule_id": "worker-cloud-g07-catalog",
|
|
||||||
"policy_priority": 30,
|
|
||||||
"reason_codes": [
|
|
||||||
"worker_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"cloud-G08": {
|
|
||||||
"candidates": [
|
|
||||||
"claude-opus-xhigh",
|
|
||||||
"codex-terra-high"
|
|
||||||
],
|
|
||||||
"rule_id": "worker-cloud-g08-catalog",
|
|
||||||
"policy_priority": 30,
|
|
||||||
"reason_codes": [
|
|
||||||
"worker_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"cloud-G09": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-sol-xhigh"
|
|
||||||
],
|
|
||||||
"rule_id": "worker-cloud-g09-catalog",
|
|
||||||
"policy_priority": 30,
|
|
||||||
"reason_codes": [
|
|
||||||
"worker_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"cloud-G10": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-sol-xhigh"
|
|
||||||
],
|
|
||||||
"rule_id": "worker-cloud-g10-catalog",
|
|
||||||
"policy_priority": 30,
|
|
||||||
"reason_codes": [
|
|
||||||
"worker_catalog_lane"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"review": {
|
|
||||||
"local-G01": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-sol-xhigh"
|
|
||||||
],
|
|
||||||
"rule_id": "review-local-g01-catalog",
|
|
||||||
"policy_priority": 10,
|
|
||||||
"reason_codes": [
|
|
||||||
"review_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"local-G02": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-sol-xhigh"
|
|
||||||
],
|
|
||||||
"rule_id": "review-local-g02-catalog",
|
|
||||||
"policy_priority": 10,
|
|
||||||
"reason_codes": [
|
|
||||||
"review_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"local-G03": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-sol-xhigh"
|
|
||||||
],
|
|
||||||
"rule_id": "review-local-g03-catalog",
|
|
||||||
"policy_priority": 10,
|
|
||||||
"reason_codes": [
|
|
||||||
"review_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"local-G04": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-sol-xhigh"
|
|
||||||
],
|
|
||||||
"rule_id": "review-local-g04-catalog",
|
|
||||||
"policy_priority": 10,
|
|
||||||
"reason_codes": [
|
|
||||||
"review_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"local-G05": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-sol-xhigh"
|
|
||||||
],
|
|
||||||
"rule_id": "review-local-g05-catalog",
|
|
||||||
"policy_priority": 10,
|
|
||||||
"reason_codes": [
|
|
||||||
"review_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"local-G06": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-sol-xhigh"
|
|
||||||
],
|
|
||||||
"rule_id": "review-local-g06-catalog",
|
|
||||||
"policy_priority": 10,
|
|
||||||
"reason_codes": [
|
|
||||||
"review_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"local-G07": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-sol-xhigh"
|
|
||||||
],
|
|
||||||
"rule_id": "review-local-g07-catalog",
|
|
||||||
"policy_priority": 10,
|
|
||||||
"reason_codes": [
|
|
||||||
"review_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"local-G08": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-sol-xhigh"
|
|
||||||
],
|
|
||||||
"rule_id": "review-local-g08-catalog",
|
|
||||||
"policy_priority": 10,
|
|
||||||
"reason_codes": [
|
|
||||||
"review_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"local-G09": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-sol-xhigh"
|
|
||||||
],
|
|
||||||
"rule_id": "review-local-g09-catalog",
|
|
||||||
"policy_priority": 10,
|
|
||||||
"reason_codes": [
|
|
||||||
"review_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"local-G10": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-sol-xhigh"
|
|
||||||
],
|
|
||||||
"rule_id": "review-local-g10-catalog",
|
|
||||||
"policy_priority": 10,
|
|
||||||
"reason_codes": [
|
|
||||||
"review_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"cloud-G01": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-sol-xhigh"
|
|
||||||
],
|
|
||||||
"rule_id": "review-cloud-g01-catalog",
|
|
||||||
"policy_priority": 10,
|
|
||||||
"reason_codes": [
|
|
||||||
"review_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"cloud-G02": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-sol-xhigh"
|
|
||||||
],
|
|
||||||
"rule_id": "review-cloud-g02-catalog",
|
|
||||||
"policy_priority": 10,
|
|
||||||
"reason_codes": [
|
|
||||||
"review_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"cloud-G03": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-sol-xhigh"
|
|
||||||
],
|
|
||||||
"rule_id": "review-cloud-g03-catalog",
|
|
||||||
"policy_priority": 10,
|
|
||||||
"reason_codes": [
|
|
||||||
"review_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"cloud-G04": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-sol-xhigh"
|
|
||||||
],
|
|
||||||
"rule_id": "review-cloud-g04-catalog",
|
|
||||||
"policy_priority": 10,
|
|
||||||
"reason_codes": [
|
|
||||||
"review_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"cloud-G05": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-sol-xhigh"
|
|
||||||
],
|
|
||||||
"rule_id": "review-cloud-g05-catalog",
|
|
||||||
"policy_priority": 10,
|
|
||||||
"reason_codes": [
|
|
||||||
"review_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"cloud-G06": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-sol-xhigh"
|
|
||||||
],
|
|
||||||
"rule_id": "review-cloud-g06-catalog",
|
|
||||||
"policy_priority": 10,
|
|
||||||
"reason_codes": [
|
|
||||||
"review_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"cloud-G07": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-sol-xhigh"
|
|
||||||
],
|
|
||||||
"rule_id": "review-cloud-g07-catalog",
|
|
||||||
"policy_priority": 10,
|
|
||||||
"reason_codes": [
|
|
||||||
"review_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"cloud-G08": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-sol-xhigh"
|
|
||||||
],
|
|
||||||
"rule_id": "review-cloud-g08-catalog",
|
|
||||||
"policy_priority": 10,
|
|
||||||
"reason_codes": [
|
|
||||||
"review_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"cloud-G09": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-sol-xhigh"
|
|
||||||
],
|
|
||||||
"rule_id": "review-cloud-g09-catalog",
|
|
||||||
"policy_priority": 10,
|
|
||||||
"reason_codes": [
|
|
||||||
"review_catalog_lane"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"cloud-G10": {
|
|
||||||
"candidates": [
|
|
||||||
"codex-sol-xhigh"
|
|
||||||
],
|
|
||||||
"rule_id": "review-cloud-g10-catalog",
|
|
||||||
"policy_priority": 10,
|
|
||||||
"reason_codes": [
|
|
||||||
"review_catalog_lane"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"promotions": {
|
|
||||||
"claude-opus-xhigh": "codex-terra-high"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Driver-level validation for operator-defined execution targets."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
|
|
||||||
VALID_ADAPTERS = frozenset(
|
|
||||||
{"pi", "agy", "opencode", "claude-glm", "claude", "codex"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_target_contract(target, path: str, error_type) -> None:
|
|
||||||
if target.adapter not in VALID_ADAPTERS:
|
|
||||||
raise error_type(
|
|
||||||
f"{path}.adapter must be one of {sorted(VALID_ADAPTERS)}; "
|
|
||||||
"a new adapter requires dispatcher driver support"
|
|
||||||
)
|
|
||||||
local = target.adapter == "pi"
|
|
||||||
expected_class = "local_model" if local else "cloud_model"
|
|
||||||
if target.execution_class != expected_class:
|
|
||||||
raise error_type(
|
|
||||||
f"{path}: {target.adapter} targets must use {expected_class}"
|
|
||||||
)
|
|
||||||
if target.selfcheck_required != local:
|
|
||||||
raise error_type(
|
|
||||||
f"{path}: selfcheck_required must be {str(local).lower()} "
|
|
||||||
f"for {target.adapter} as the persisted-decision compatibility field"
|
|
||||||
)
|
|
||||||
if not isinstance(target.selfcheck_full_review, bool):
|
|
||||||
raise error_type(f"{path}: selfcheck.full_review must be a boolean")
|
|
||||||
if not isinstance(target.selfcheck_checklist_review, bool):
|
|
||||||
raise error_type(f"{path}: selfcheck.checklist_review must be a boolean")
|
|
||||||
if target.adapter == "pi":
|
|
||||||
if not target.target.startswith("iop/"):
|
|
||||||
raise error_type(f"{path}: pi target must start with iop/")
|
|
||||||
if target.thinking_level is None:
|
|
||||||
raise error_type(f"{path}: pi target requires thinking_level")
|
|
||||||
if target.reasoning_effort is not None or target.command_model is not None:
|
|
||||||
raise error_type(
|
|
||||||
f"{path}: pi target cannot set reasoning_effort or command_model"
|
|
||||||
)
|
|
||||||
elif target.thinking_level is not None:
|
|
||||||
raise error_type(f"{path}: thinking_level is only valid for pi")
|
|
||||||
if target.adapter == "agy" and (
|
|
||||||
target.reasoning_effort is not None or target.command_model is not None
|
|
||||||
):
|
|
||||||
raise error_type(
|
|
||||||
f"{path}: agy target cannot set reasoning_effort or command_model"
|
|
||||||
)
|
|
||||||
if target.adapter == "opencode" and (
|
|
||||||
target.reasoning_effort not in {"medium", "high", "max"}
|
|
||||||
or target.command_model is None
|
|
||||||
):
|
|
||||||
raise error_type(
|
|
||||||
f"{path}: opencode target requires command_model and "
|
|
||||||
"medium|high|max reasoning_effort"
|
|
||||||
)
|
|
||||||
if target.adapter == "claude-glm" and (
|
|
||||||
target.command_model is None or target.reasoning_effort != "xhigh"
|
|
||||||
):
|
|
||||||
raise error_type(
|
|
||||||
f"{path}: claude-glm target requires command_model and "
|
|
||||||
"xhigh reasoning_effort"
|
|
||||||
)
|
|
||||||
if target.adapter in {"claude", "codex"} and target.reasoning_effort is None:
|
|
||||||
raise error_type(
|
|
||||||
f"{path}: {target.adapter} target requires reasoning_effort"
|
|
||||||
)
|
|
||||||
|
|
@ -1,16 +1,10 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Catalog-backed execution-target policy for Agent Task stages."""
|
"""Pure execution-target policy for Agent Task worker and review stages."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import hashlib
|
|
||||||
import importlib.util
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
|
@ -21,12 +15,6 @@ VALID_STAGES = {"worker", "review"}
|
||||||
VALID_LANES = {"local", "cloud"}
|
VALID_LANES = {"local", "cloud"}
|
||||||
VALID_PI_THINKING_LEVELS = frozenset({"low", "medium", "high"})
|
VALID_PI_THINKING_LEVELS = frozenset({"low", "medium", "high"})
|
||||||
VALID_REASONING_EFFORTS = frozenset({"medium", "high", "max", "xhigh"})
|
VALID_REASONING_EFFORTS = frozenset({"medium", "high", "max", "xhigh"})
|
||||||
VALID_EXECUTION_CLASSES = frozenset({"local_model", "cloud_model"})
|
|
||||||
CATALOG_SCHEMA_VERSION = 1
|
|
||||||
CATALOG_PATH = Path(__file__).with_name("execution_target_catalog.json")
|
|
||||||
TIME_WINDOWS = frozenset(
|
|
||||||
{"kst-day-[07:00,23:00)", "kst-night-[23:00,07:00)"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -35,30 +23,9 @@ class RouteTarget:
|
||||||
target: str
|
target: str
|
||||||
execution_class: str
|
execution_class: str
|
||||||
selfcheck_required: bool
|
selfcheck_required: bool
|
||||||
selfcheck_full_review: bool = False
|
|
||||||
selfcheck_checklist_review: bool = False
|
|
||||||
thinking_level: str | None = None
|
thinking_level: str | None = None
|
||||||
reasoning_effort: str | None = None
|
reasoning_effort: str | None = None
|
||||||
command_model: str | None = None
|
command_model: str | None = None
|
||||||
catalog_id: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class LanePolicy:
|
|
||||||
candidates: tuple[str, ...]
|
|
||||||
policy_priority: int
|
|
||||||
rule_id: str | None = None
|
|
||||||
reason_codes: tuple[str, ...] = ()
|
|
||||||
time_windows: dict[str, tuple[str, tuple[str, ...]]] | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ExecutionTargetCatalog:
|
|
||||||
schema_version: int
|
|
||||||
revision: str
|
|
||||||
targets: dict[str, RouteTarget]
|
|
||||||
lanes: dict[str, dict[str, LanePolicy]]
|
|
||||||
promotions: dict[str, str]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -68,428 +35,84 @@ class PolicyDecision:
|
||||||
reason_codes: tuple[str, ...]
|
reason_codes: tuple[str, ...]
|
||||||
time_window: str
|
time_window: str
|
||||||
candidates: tuple[RouteTarget, ...]
|
candidates: tuple[RouteTarget, ...]
|
||||||
route_id: str
|
|
||||||
catalog_revision: str
|
|
||||||
|
|
||||||
|
|
||||||
class CatalogError(ValueError):
|
PI_ORNITH = RouteTarget("pi", "iop/ornith:35b", "local_model", True)
|
||||||
"""Raised when the operator-owned model catalog is malformed."""
|
AGY_GEMINI_LOW = RouteTarget(
|
||||||
|
"agy", "Gemini 3.6 Flash (Low)", "cloud_model", False
|
||||||
|
)
|
||||||
|
AGY_GEMINI_MEDIUM = RouteTarget(
|
||||||
|
"agy", "Gemini 3.6 Flash (Medium)", "cloud_model", False
|
||||||
|
)
|
||||||
|
AGY_GEMINI_HIGH = RouteTarget(
|
||||||
|
"agy", "Gemini 3.6 Flash (High)", "cloud_model", False
|
||||||
|
)
|
||||||
|
PI_LAGUNA = RouteTarget("pi", "iop/laguna-s:2.1", "local_model", True)
|
||||||
|
# OpenCode's GLM fallback always runs one reasoning step above the Gemini
|
||||||
|
# variant it follows: Low -> medium, Medium -> high, High -> max.
|
||||||
|
OPENCODE_GLM_MEDIUM = RouteTarget(
|
||||||
|
"opencode",
|
||||||
|
"glm-5.2",
|
||||||
|
"cloud_model",
|
||||||
|
False,
|
||||||
|
reasoning_effort="medium",
|
||||||
|
command_model="iop-glm/glm-5.2",
|
||||||
|
)
|
||||||
|
OPENCODE_GLM_HIGH = RouteTarget(
|
||||||
|
"opencode",
|
||||||
|
"glm-5.2",
|
||||||
|
"cloud_model",
|
||||||
|
False,
|
||||||
|
reasoning_effort="high",
|
||||||
|
command_model="iop-glm/glm-5.2",
|
||||||
|
)
|
||||||
|
OPENCODE_GLM_MAX = RouteTarget(
|
||||||
|
"opencode",
|
||||||
|
"glm-5.2",
|
||||||
|
"cloud_model",
|
||||||
|
False,
|
||||||
|
reasoning_effort="max",
|
||||||
|
command_model="iop-glm/glm-5.2",
|
||||||
|
)
|
||||||
|
# Retain the old Claude compatibility target only for persisted locator
|
||||||
|
# recovery. New policy decisions never select it.
|
||||||
|
CLAUDE_GLM = RouteTarget(
|
||||||
|
"claude-glm",
|
||||||
|
"glm-5.2",
|
||||||
|
"cloud_model",
|
||||||
|
False,
|
||||||
|
# `claude-glm` maps the Claude Code Sonnet alias to the GLM provider.
|
||||||
|
# Keep the policy/log identity as the actual GLM model while invoking the
|
||||||
|
# compatibility alias required by Claude Code.
|
||||||
|
command_model="sonnet",
|
||||||
|
)
|
||||||
|
CLAUDE_OPUS = RouteTarget("claude", "claude-opus-4-8", "cloud_model", False)
|
||||||
|
CLAUDE_HAIKU_XHIGH = RouteTarget(
|
||||||
|
"claude", "claude-haiku-4-5", "cloud_model", False
|
||||||
|
)
|
||||||
|
CODEX_SPARK_XHIGH = RouteTarget(
|
||||||
|
"codex", "gpt-5.3-codex-spark", "cloud_model", False
|
||||||
|
)
|
||||||
|
CODEX_SOL_XHIGH = RouteTarget("codex", "gpt-5.6-sol", "cloud_model", False)
|
||||||
|
CODEX_TERRA_HIGH = RouteTarget("codex", "gpt-5.6-terra", "cloud_model", False)
|
||||||
|
|
||||||
|
|
||||||
def _load_target_contract():
|
CANONICAL_TARGETS = (
|
||||||
module_name = "execution_target_contract"
|
PI_ORNITH,
|
||||||
loaded = sys.modules.get(module_name)
|
AGY_GEMINI_LOW,
|
||||||
if loaded is not None:
|
AGY_GEMINI_MEDIUM,
|
||||||
return loaded
|
AGY_GEMINI_HIGH,
|
||||||
path = Path(__file__).with_name("execution_target_contract.py")
|
PI_LAGUNA,
|
||||||
spec = importlib.util.spec_from_file_location(module_name, path)
|
OPENCODE_GLM_MEDIUM,
|
||||||
if spec is None or spec.loader is None:
|
OPENCODE_GLM_HIGH,
|
||||||
raise CatalogError(f"target contract load failed: {path}")
|
OPENCODE_GLM_MAX,
|
||||||
module = importlib.util.module_from_spec(spec)
|
CLAUDE_GLM,
|
||||||
sys.modules[spec.name] = module
|
CLAUDE_OPUS,
|
||||||
spec.loader.exec_module(module)
|
CLAUDE_HAIKU_XHIGH,
|
||||||
return module
|
CODEX_SPARK_XHIGH,
|
||||||
|
CODEX_SOL_XHIGH,
|
||||||
|
CODEX_TERRA_HIGH,
|
||||||
target_contract = _load_target_contract()
|
)
|
||||||
VALID_ADAPTERS = target_contract.VALID_ADAPTERS
|
|
||||||
|
|
||||||
|
|
||||||
def _object(value: object, path: str) -> dict[str, Any]:
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise CatalogError(f"{path} must be an object")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _non_empty_string(value: object, path: str) -> str:
|
|
||||||
if not isinstance(value, str) or not value:
|
|
||||||
raise CatalogError(f"{path} must be a non-empty string")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _optional_enum(
|
|
||||||
value: object, allowed: frozenset[str], path: str
|
|
||||||
) -> str | None:
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
if not isinstance(value, str) or value not in allowed:
|
|
||||||
raise CatalogError(f"{path} must be null or one of {sorted(allowed)}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _target_from_config(target_id: str, value: object) -> RouteTarget:
|
|
||||||
path = f"targets.{target_id}"
|
|
||||||
item = _object(value, path)
|
|
||||||
allowed = {
|
|
||||||
"adapter",
|
|
||||||
"target",
|
|
||||||
"execution_class",
|
|
||||||
"selfcheck_required",
|
|
||||||
"selfcheck",
|
|
||||||
"thinking_level",
|
|
||||||
"reasoning_effort",
|
|
||||||
"command_model",
|
|
||||||
}
|
|
||||||
unknown = sorted(set(item) - allowed)
|
|
||||||
if unknown:
|
|
||||||
raise CatalogError(f"{path} has unknown fields: {unknown}")
|
|
||||||
execution_class = _non_empty_string(
|
|
||||||
item.get("execution_class"), f"{path}.execution_class"
|
|
||||||
)
|
|
||||||
if execution_class not in VALID_EXECUTION_CLASSES:
|
|
||||||
raise CatalogError(
|
|
||||||
f"{path}.execution_class must be one of {sorted(VALID_EXECUTION_CLASSES)}"
|
|
||||||
)
|
|
||||||
legacy_selfcheck = item.get("selfcheck_required")
|
|
||||||
raw_selfcheck = item.get("selfcheck")
|
|
||||||
if raw_selfcheck is not None and legacy_selfcheck is not None:
|
|
||||||
raise CatalogError(
|
|
||||||
f"{path} cannot combine selfcheck and selfcheck_required"
|
|
||||||
)
|
|
||||||
if raw_selfcheck is not None:
|
|
||||||
selfcheck_config = _object(raw_selfcheck, f"{path}.selfcheck")
|
|
||||||
expected_fields = {"full_review", "checklist_review"}
|
|
||||||
if set(selfcheck_config) != expected_fields:
|
|
||||||
raise CatalogError(
|
|
||||||
f"{path}.selfcheck must contain exactly {sorted(expected_fields)}"
|
|
||||||
)
|
|
||||||
full_review = selfcheck_config.get("full_review")
|
|
||||||
checklist_review = selfcheck_config.get("checklist_review")
|
|
||||||
if not isinstance(full_review, bool):
|
|
||||||
raise CatalogError(
|
|
||||||
f"{path}.selfcheck.full_review must be a boolean"
|
|
||||||
)
|
|
||||||
if not isinstance(checklist_review, bool):
|
|
||||||
raise CatalogError(
|
|
||||||
f"{path}.selfcheck.checklist_review must be a boolean"
|
|
||||||
)
|
|
||||||
# Keep the old decision field stable for persisted-state compatibility.
|
|
||||||
# Runtime self-check admission uses the two explicit stage flags below.
|
|
||||||
selfcheck_required = item.get("adapter") == "pi"
|
|
||||||
else:
|
|
||||||
if not isinstance(legacy_selfcheck, bool):
|
|
||||||
raise CatalogError(
|
|
||||||
f"{path}.selfcheck must be an object with boolean stages"
|
|
||||||
)
|
|
||||||
full_review = legacy_selfcheck
|
|
||||||
checklist_review = legacy_selfcheck
|
|
||||||
selfcheck_required = legacy_selfcheck
|
|
||||||
command_model = item.get("command_model")
|
|
||||||
if command_model is not None:
|
|
||||||
command_model = _non_empty_string(command_model, f"{path}.command_model")
|
|
||||||
target = RouteTarget(
|
|
||||||
adapter=_non_empty_string(item.get("adapter"), f"{path}.adapter"),
|
|
||||||
target=_non_empty_string(item.get("target"), f"{path}.target"),
|
|
||||||
execution_class=execution_class,
|
|
||||||
selfcheck_required=selfcheck_required,
|
|
||||||
selfcheck_full_review=full_review,
|
|
||||||
selfcheck_checklist_review=checklist_review,
|
|
||||||
thinking_level=_optional_enum(
|
|
||||||
item.get("thinking_level"),
|
|
||||||
VALID_PI_THINKING_LEVELS,
|
|
||||||
f"{path}.thinking_level",
|
|
||||||
),
|
|
||||||
reasoning_effort=_optional_enum(
|
|
||||||
item.get("reasoning_effort"),
|
|
||||||
VALID_REASONING_EFFORTS,
|
|
||||||
f"{path}.reasoning_effort",
|
|
||||||
),
|
|
||||||
command_model=command_model,
|
|
||||||
catalog_id=target_id,
|
|
||||||
)
|
|
||||||
target_contract.validate_target_contract(target, path, CatalogError)
|
|
||||||
return target
|
|
||||||
|
|
||||||
|
|
||||||
def _string_list(value: object, path: str) -> tuple[str, ...]:
|
|
||||||
if not isinstance(value, list) or not value:
|
|
||||||
raise CatalogError(f"{path} must be a non-empty array")
|
|
||||||
values = tuple(
|
|
||||||
_non_empty_string(entry, f"{path}[{index}]")
|
|
||||||
for index, entry in enumerate(value)
|
|
||||||
)
|
|
||||||
if len(values) != len(set(values)):
|
|
||||||
raise CatalogError(f"{path} must not contain duplicate ids")
|
|
||||||
return values
|
|
||||||
|
|
||||||
|
|
||||||
def _lane_from_config(
|
|
||||||
stage: str,
|
|
||||||
lane_id: str,
|
|
||||||
value: object,
|
|
||||||
targets: dict[str, RouteTarget],
|
|
||||||
) -> LanePolicy:
|
|
||||||
path = f"lanes.{stage}.{lane_id}"
|
|
||||||
item = _object(value, path)
|
|
||||||
allowed = {
|
|
||||||
"candidates",
|
|
||||||
"rule_id",
|
|
||||||
"policy_priority",
|
|
||||||
"reason_codes",
|
|
||||||
"time_windows",
|
|
||||||
}
|
|
||||||
unknown = sorted(set(item) - allowed)
|
|
||||||
if unknown:
|
|
||||||
raise CatalogError(f"{path} has unknown fields: {unknown}")
|
|
||||||
candidates = _string_list(item.get("candidates"), f"{path}.candidates")
|
|
||||||
missing_targets = [target_id for target_id in candidates if target_id not in targets]
|
|
||||||
if missing_targets:
|
|
||||||
raise CatalogError(f"{path} references unknown targets: {missing_targets}")
|
|
||||||
execution_classes = {targets[target_id].execution_class for target_id in candidates}
|
|
||||||
if len(execution_classes) != 1:
|
|
||||||
raise CatalogError(
|
|
||||||
f"{path}.candidates cannot mix local_model and cloud_model targets"
|
|
||||||
)
|
|
||||||
priority = item.get("policy_priority")
|
|
||||||
if isinstance(priority, bool) or not isinstance(priority, int) or priority < 0:
|
|
||||||
raise CatalogError(f"{path}.policy_priority must be a non-negative integer")
|
|
||||||
raw_reasons = item.get("reason_codes", [])
|
|
||||||
if not isinstance(raw_reasons, list) or any(
|
|
||||||
not isinstance(reason, str) or not reason for reason in raw_reasons
|
|
||||||
):
|
|
||||||
raise CatalogError(f"{path}.reason_codes must be an array of strings")
|
|
||||||
rule_id = item.get("rule_id")
|
|
||||||
if rule_id is not None:
|
|
||||||
rule_id = _non_empty_string(rule_id, f"{path}.rule_id")
|
|
||||||
raw_windows = item.get("time_windows")
|
|
||||||
windows: dict[str, tuple[str, tuple[str, ...]]] | None = None
|
|
||||||
if raw_windows is not None:
|
|
||||||
if rule_id is not None or raw_reasons:
|
|
||||||
raise CatalogError(
|
|
||||||
f"{path}: time_windows cannot be combined with base rule metadata"
|
|
||||||
)
|
|
||||||
windows_obj = _object(raw_windows, f"{path}.time_windows")
|
|
||||||
if set(windows_obj) != TIME_WINDOWS:
|
|
||||||
raise CatalogError(
|
|
||||||
f"{path}.time_windows must define exactly {sorted(TIME_WINDOWS)}"
|
|
||||||
)
|
|
||||||
windows = {}
|
|
||||||
for window_name, raw_window in windows_obj.items():
|
|
||||||
window = _object(raw_window, f"{path}.time_windows.{window_name}")
|
|
||||||
if set(window) != {"rule_id", "reason_codes"}:
|
|
||||||
raise CatalogError(
|
|
||||||
f"{path}.time_windows.{window_name} must contain rule_id and reason_codes"
|
|
||||||
)
|
|
||||||
reasons = window.get("reason_codes")
|
|
||||||
if not isinstance(reasons, list) or not reasons or any(
|
|
||||||
not isinstance(reason, str) or not reason for reason in reasons
|
|
||||||
):
|
|
||||||
raise CatalogError(
|
|
||||||
f"{path}.time_windows.{window_name}.reason_codes must be a non-empty string array"
|
|
||||||
)
|
|
||||||
windows[window_name] = (
|
|
||||||
_non_empty_string(
|
|
||||||
window.get("rule_id"),
|
|
||||||
f"{path}.time_windows.{window_name}.rule_id",
|
|
||||||
),
|
|
||||||
tuple(reasons),
|
|
||||||
)
|
|
||||||
elif rule_id is None:
|
|
||||||
raise CatalogError(f"{path}.rule_id is required without time_windows")
|
|
||||||
return LanePolicy(
|
|
||||||
candidates=candidates,
|
|
||||||
policy_priority=priority,
|
|
||||||
rule_id=rule_id,
|
|
||||||
reason_codes=tuple(raw_reasons),
|
|
||||||
time_windows=windows,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _read_catalog_root(path: Path) -> dict[str, Any]:
|
|
||||||
try:
|
|
||||||
raw = path.read_text(encoding="utf-8")
|
|
||||||
except OSError as exc:
|
|
||||||
raise CatalogError(f"cannot read execution target catalog {path}: {exc}") from exc
|
|
||||||
try:
|
|
||||||
data = json.loads(raw)
|
|
||||||
except json.JSONDecodeError as exc:
|
|
||||||
raise CatalogError(f"invalid JSON in execution target catalog {path}: {exc}") from exc
|
|
||||||
root = _object(data, "catalog")
|
|
||||||
if set(root) != {"schema_version", "targets", "lanes", "promotions"}:
|
|
||||||
raise CatalogError(
|
|
||||||
"catalog must contain exactly schema_version, targets, lanes, promotions"
|
|
||||||
)
|
|
||||||
if root.get("schema_version") != CATALOG_SCHEMA_VERSION:
|
|
||||||
raise CatalogError(
|
|
||||||
f"catalog.schema_version must be {CATALOG_SCHEMA_VERSION}"
|
|
||||||
)
|
|
||||||
return root
|
|
||||||
|
|
||||||
|
|
||||||
def load_catalog(path: Path = CATALOG_PATH) -> ExecutionTargetCatalog:
|
|
||||||
root = _read_catalog_root(path)
|
|
||||||
raw_targets = _object(root.get("targets"), "targets")
|
|
||||||
if not raw_targets:
|
|
||||||
raise CatalogError("targets must not be empty")
|
|
||||||
targets = {
|
|
||||||
_non_empty_string(target_id, "targets key"): _target_from_config(
|
|
||||||
target_id, value
|
|
||||||
)
|
|
||||||
for target_id, value in raw_targets.items()
|
|
||||||
}
|
|
||||||
identities: dict[tuple[object, ...], str] = {}
|
|
||||||
for target_id, target in targets.items():
|
|
||||||
identity = (
|
|
||||||
target.adapter,
|
|
||||||
target.target,
|
|
||||||
target.thinking_level,
|
|
||||||
target.reasoning_effort,
|
|
||||||
)
|
|
||||||
if identity in identities:
|
|
||||||
raise CatalogError(
|
|
||||||
f"targets {identities[identity]!r} and {target_id!r} have duplicate runtime identity"
|
|
||||||
)
|
|
||||||
identities[identity] = target_id
|
|
||||||
|
|
||||||
raw_lanes = _object(root.get("lanes"), "lanes")
|
|
||||||
if set(raw_lanes) != VALID_STAGES:
|
|
||||||
raise CatalogError(f"lanes must define exactly {sorted(VALID_STAGES)}")
|
|
||||||
expected_lane_ids = {
|
|
||||||
f"{lane}-G{grade:02d}" for lane in VALID_LANES for grade in range(1, 11)
|
|
||||||
}
|
|
||||||
lanes: dict[str, dict[str, LanePolicy]] = {}
|
|
||||||
for stage in sorted(VALID_STAGES):
|
|
||||||
stage_lanes = _object(raw_lanes.get(stage), f"lanes.{stage}")
|
|
||||||
if set(stage_lanes) != expected_lane_ids:
|
|
||||||
missing = sorted(expected_lane_ids - set(stage_lanes))
|
|
||||||
extra = sorted(set(stage_lanes) - expected_lane_ids)
|
|
||||||
raise CatalogError(
|
|
||||||
f"lanes.{stage} must define every grade independently; missing={missing}, extra={extra}"
|
|
||||||
)
|
|
||||||
lanes[stage] = {
|
|
||||||
lane_id: _lane_from_config(stage, lane_id, value, targets)
|
|
||||||
for lane_id, value in stage_lanes.items()
|
|
||||||
}
|
|
||||||
|
|
||||||
raw_promotions = _object(root.get("promotions"), "promotions")
|
|
||||||
promotions: dict[str, str] = {}
|
|
||||||
for source, destination in raw_promotions.items():
|
|
||||||
source_id = _non_empty_string(source, "promotions key")
|
|
||||||
destination_id = _non_empty_string(
|
|
||||||
destination, f"promotions.{source_id}"
|
|
||||||
)
|
|
||||||
if source_id not in targets or destination_id not in targets:
|
|
||||||
raise CatalogError(
|
|
||||||
f"promotions.{source_id} references an unknown target"
|
|
||||||
)
|
|
||||||
if source_id == destination_id:
|
|
||||||
raise CatalogError(f"promotions.{source_id} cannot point to itself")
|
|
||||||
if (
|
|
||||||
targets[source_id].execution_class != "cloud_model"
|
|
||||||
or targets[destination_id].execution_class != "cloud_model"
|
|
||||||
):
|
|
||||||
raise CatalogError("promotions may contain only cloud_model targets")
|
|
||||||
promotions[source_id] = destination_id
|
|
||||||
for source_id in promotions:
|
|
||||||
seen: set[str] = set()
|
|
||||||
current = source_id
|
|
||||||
while current in promotions:
|
|
||||||
if current in seen:
|
|
||||||
raise CatalogError(f"promotions contain a cycle at {current!r}")
|
|
||||||
seen.add(current)
|
|
||||||
current = promotions[current]
|
|
||||||
normalized = json.dumps(root, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
||||||
return ExecutionTargetCatalog(
|
|
||||||
schema_version=CATALOG_SCHEMA_VERSION,
|
|
||||||
revision=hashlib.sha256(normalized.encode("utf-8")).hexdigest(),
|
|
||||||
targets=targets,
|
|
||||||
lanes=lanes,
|
|
||||||
promotions=promotions,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
CATALOG = load_catalog()
|
|
||||||
CATALOG_REVISION = CATALOG.revision
|
|
||||||
CATALOG_TARGETS_BY_ID = CATALOG.targets
|
|
||||||
CANONICAL_TARGETS = tuple(CATALOG.targets.values())
|
|
||||||
|
|
||||||
|
|
||||||
def reload_catalog(path: Path = CATALOG_PATH) -> ExecutionTargetCatalog:
|
|
||||||
"""Atomically publish the latest operator-owned catalog.
|
|
||||||
|
|
||||||
The dispatcher calls this before each scheduler admission and immediately
|
|
||||||
before a self-check stage starts. Existing model invocations keep their
|
|
||||||
pinned decision; the next stage observes the newest self-check switches.
|
|
||||||
"""
|
|
||||||
catalog = load_catalog(path)
|
|
||||||
compatibility_ids = (
|
|
||||||
"pi-ornith-high",
|
|
||||||
"pi-laguna-high",
|
|
||||||
"agy-gemini-low",
|
|
||||||
"agy-gemini-medium",
|
|
||||||
"agy-gemini-high",
|
|
||||||
"opencode-glm-medium",
|
|
||||||
"opencode-glm-high",
|
|
||||||
"opencode-glm-max",
|
|
||||||
"legacy-claude-glm",
|
|
||||||
"claude-opus-xhigh",
|
|
||||||
"claude-haiku-xhigh",
|
|
||||||
"codex-spark-xhigh",
|
|
||||||
"codex-sol-xhigh",
|
|
||||||
"codex-terra-high",
|
|
||||||
)
|
|
||||||
missing = [
|
|
||||||
target_id
|
|
||||||
for target_id in compatibility_ids
|
|
||||||
if target_id not in catalog.targets
|
|
||||||
]
|
|
||||||
if missing:
|
|
||||||
raise CatalogError(
|
|
||||||
"catalog is missing compatibility targets required by the "
|
|
||||||
f"dispatcher: {missing}"
|
|
||||||
)
|
|
||||||
compatibility_targets = {
|
|
||||||
target_id: catalog.targets[target_id] for target_id in compatibility_ids
|
|
||||||
}
|
|
||||||
global CATALOG, CATALOG_REVISION, CATALOG_TARGETS_BY_ID, CANONICAL_TARGETS
|
|
||||||
global PI_ORNITH, PI_LAGUNA, AGY_GEMINI_LOW, AGY_GEMINI_MEDIUM
|
|
||||||
global AGY_GEMINI_HIGH, OPENCODE_GLM_MEDIUM, OPENCODE_GLM_HIGH
|
|
||||||
global OPENCODE_GLM_MAX, CLAUDE_GLM, CLAUDE_OPUS, CLAUDE_HAIKU_XHIGH
|
|
||||||
global CODEX_SPARK_XHIGH, CODEX_SOL_XHIGH, CODEX_TERRA_HIGH
|
|
||||||
CATALOG = catalog
|
|
||||||
CATALOG_REVISION = catalog.revision
|
|
||||||
CATALOG_TARGETS_BY_ID = catalog.targets
|
|
||||||
CANONICAL_TARGETS = tuple(catalog.targets.values())
|
|
||||||
PI_ORNITH = compatibility_targets["pi-ornith-high"]
|
|
||||||
PI_LAGUNA = compatibility_targets["pi-laguna-high"]
|
|
||||||
AGY_GEMINI_LOW = compatibility_targets["agy-gemini-low"]
|
|
||||||
AGY_GEMINI_MEDIUM = compatibility_targets["agy-gemini-medium"]
|
|
||||||
AGY_GEMINI_HIGH = compatibility_targets["agy-gemini-high"]
|
|
||||||
OPENCODE_GLM_MEDIUM = compatibility_targets["opencode-glm-medium"]
|
|
||||||
OPENCODE_GLM_HIGH = compatibility_targets["opencode-glm-high"]
|
|
||||||
OPENCODE_GLM_MAX = compatibility_targets["opencode-glm-max"]
|
|
||||||
CLAUDE_GLM = compatibility_targets["legacy-claude-glm"]
|
|
||||||
CLAUDE_OPUS = compatibility_targets["claude-opus-xhigh"]
|
|
||||||
CLAUDE_HAIKU_XHIGH = compatibility_targets["claude-haiku-xhigh"]
|
|
||||||
CODEX_SPARK_XHIGH = compatibility_targets["codex-spark-xhigh"]
|
|
||||||
CODEX_SOL_XHIGH = compatibility_targets["codex-sol-xhigh"]
|
|
||||||
CODEX_TERRA_HIGH = compatibility_targets["codex-terra-high"]
|
|
||||||
return catalog
|
|
||||||
|
|
||||||
|
|
||||||
def catalog_target(target_id: str) -> RouteTarget:
|
|
||||||
try:
|
|
||||||
return CATALOG.targets[target_id]
|
|
||||||
except KeyError as exc:
|
|
||||||
raise CatalogError(f"unknown catalog target: {target_id}") from exc
|
|
||||||
|
|
||||||
|
|
||||||
# Compatibility names remain for persisted-state recovery and focused driver tests.
|
|
||||||
# Lane membership and order live only in execution_target_catalog.json.
|
|
||||||
PI_ORNITH = catalog_target("pi-ornith-high")
|
|
||||||
PI_LAGUNA = catalog_target("pi-laguna-high")
|
|
||||||
AGY_GEMINI_LOW = catalog_target("agy-gemini-low")
|
|
||||||
AGY_GEMINI_MEDIUM = catalog_target("agy-gemini-medium")
|
|
||||||
AGY_GEMINI_HIGH = catalog_target("agy-gemini-high")
|
|
||||||
OPENCODE_GLM_MEDIUM = catalog_target("opencode-glm-medium")
|
|
||||||
OPENCODE_GLM_HIGH = catalog_target("opencode-glm-high")
|
|
||||||
OPENCODE_GLM_MAX = catalog_target("opencode-glm-max")
|
|
||||||
CLAUDE_GLM = catalog_target("legacy-claude-glm")
|
|
||||||
CLAUDE_OPUS = catalog_target("claude-opus-xhigh")
|
|
||||||
CLAUDE_HAIKU_XHIGH = catalog_target("claude-haiku-xhigh")
|
|
||||||
CODEX_SPARK_XHIGH = catalog_target("codex-spark-xhigh")
|
|
||||||
CODEX_SOL_XHIGH = catalog_target("codex-sol-xhigh")
|
|
||||||
CODEX_TERRA_HIGH = catalog_target("codex-terra-high")
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_target(
|
def canonical_target(
|
||||||
|
|
@ -498,11 +121,7 @@ def canonical_target(
|
||||||
thinking_level: str | None = None,
|
thinking_level: str | None = None,
|
||||||
reasoning_effort: str | None = None,
|
reasoning_effort: str | None = None,
|
||||||
) -> RouteTarget | None:
|
) -> RouteTarget | None:
|
||||||
"""Resolve one catalog target, accepting pre-catalog implicit defaults."""
|
"""Resolve one policy-owned adapter + target + thinking identity."""
|
||||||
if adapter == "pi" and thinking_level is None:
|
|
||||||
thinking_level = "high"
|
|
||||||
if adapter in {"claude", "claude-glm", "codex"} and reasoning_effort is None:
|
|
||||||
reasoning_effort = "xhigh"
|
|
||||||
return next(
|
return next(
|
||||||
(
|
(
|
||||||
candidate
|
candidate
|
||||||
|
|
@ -519,11 +138,10 @@ def canonical_target(
|
||||||
|
|
||||||
|
|
||||||
def promotion_target(current: RouteTarget) -> RouteTarget | None:
|
def promotion_target(current: RouteTarget) -> RouteTarget | None:
|
||||||
"""Return a legacy promotion target declared by the catalog."""
|
"""Return the next target in the policy-owned cloud promotion chain."""
|
||||||
if current.catalog_id is None:
|
if current == CLAUDE_OPUS:
|
||||||
return None
|
return CODEX_TERRA_HIGH
|
||||||
destination = CATALOG.promotions.get(current.catalog_id)
|
return None
|
||||||
return CATALOG.targets.get(destination) if destination else None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -534,7 +152,7 @@ class QuotaProbeSpec:
|
||||||
|
|
||||||
|
|
||||||
def quota_probe_spec(target: RouteTarget) -> QuotaProbeSpec | None:
|
def quota_probe_spec(target: RouteTarget) -> QuotaProbeSpec | None:
|
||||||
"""Return the driver-owned quota probe spec for a route target."""
|
"""Return the policy-owned quota probe spec for a route target."""
|
||||||
if target.execution_class == "local_model":
|
if target.execution_class == "local_model":
|
||||||
return None
|
return None
|
||||||
if target.adapter == "agy":
|
if target.adapter == "agy":
|
||||||
|
|
@ -573,25 +191,98 @@ def _kst_time_window(evaluated_at: datetime) -> str:
|
||||||
def select_policy(
|
def select_policy(
|
||||||
*, stage: str, lane: str, grade: int, evaluated_at: datetime
|
*, stage: str, lane: str, grade: int, evaluated_at: datetime
|
||||||
) -> PolicyDecision:
|
) -> PolicyDecision:
|
||||||
"""Return the ordered targets for one explicit stage/lane/grade entry."""
|
"""Return the ordered target policy for one initial route evaluation."""
|
||||||
|
|
||||||
_validate(stage, lane, grade, evaluated_at)
|
_validate(stage, lane, grade, evaluated_at)
|
||||||
lane_id = f"{lane}-G{grade:02d}"
|
|
||||||
lane_policy = CATALOG.lanes[stage][lane_id]
|
if stage == "review":
|
||||||
if lane_policy.time_windows is not None:
|
return PolicyDecision(
|
||||||
time_window = _kst_time_window(evaluated_at)
|
rule_id="official-review-codex",
|
||||||
rule_id, reason_codes = lane_policy.time_windows[time_window]
|
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:
|
||||||
|
time_window = _kst_time_window(evaluated_at)
|
||||||
|
if time_window == "kst-day-[07:00,23:00)":
|
||||||
|
rule_id = "worker-local-g07-g08-kst-day"
|
||||||
|
reason_code = "kst_day_gemini_high"
|
||||||
|
candidates = (
|
||||||
|
AGY_GEMINI_HIGH,
|
||||||
|
OPENCODE_GLM_MAX,
|
||||||
|
CODEX_TERRA_HIGH,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rule_id = "worker-local-g07-g08-kst-night"
|
||||||
|
reason_code = "kst_night_gemini_high"
|
||||||
|
candidates = (
|
||||||
|
AGY_GEMINI_HIGH,
|
||||||
|
OPENCODE_GLM_MAX,
|
||||||
|
CODEX_TERRA_HIGH,
|
||||||
|
)
|
||||||
|
return PolicyDecision(
|
||||||
|
rule_id=rule_id,
|
||||||
|
policy_priority=20,
|
||||||
|
reason_codes=(reason_code,),
|
||||||
|
time_window=time_window,
|
||||||
|
candidates=candidates,
|
||||||
|
)
|
||||||
|
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 <= 2:
|
||||||
|
candidates = (
|
||||||
|
CODEX_SPARK_XHIGH,
|
||||||
|
AGY_GEMINI_LOW,
|
||||||
|
OPENCODE_GLM_MEDIUM,
|
||||||
|
CODEX_TERRA_HIGH,
|
||||||
|
)
|
||||||
|
rule_id = "worker-cloud-g01-g02"
|
||||||
|
reason_code = "cloud_spark_priority_grade"
|
||||||
|
elif grade <= 4:
|
||||||
|
candidates = (
|
||||||
|
AGY_GEMINI_MEDIUM,
|
||||||
|
OPENCODE_GLM_HIGH,
|
||||||
|
CODEX_TERRA_HIGH,
|
||||||
|
)
|
||||||
|
rule_id = "worker-cloud-g03-g04"
|
||||||
|
reason_code = "cloud_gemini_medium_grade"
|
||||||
|
elif grade <= 6:
|
||||||
|
candidates = (
|
||||||
|
AGY_GEMINI_HIGH,
|
||||||
|
OPENCODE_GLM_MAX,
|
||||||
|
CODEX_TERRA_HIGH,
|
||||||
|
)
|
||||||
|
rule_id = "worker-cloud-g05-g06"
|
||||||
|
reason_code = "cloud_gemini_high_grade"
|
||||||
|
elif grade <= 8:
|
||||||
|
candidates = (CLAUDE_OPUS,)
|
||||||
|
rule_id = "worker-cloud-g07-g08"
|
||||||
|
reason_code = "cloud_opus_grade"
|
||||||
else:
|
else:
|
||||||
time_window = "not_applicable"
|
candidates = (CODEX_SOL_XHIGH,)
|
||||||
assert lane_policy.rule_id is not None
|
rule_id = "worker-cloud-g09-g10"
|
||||||
rule_id, reason_codes = lane_policy.rule_id, lane_policy.reason_codes
|
reason_code = "cloud_codex_grade"
|
||||||
return PolicyDecision(
|
return PolicyDecision(
|
||||||
rule_id=rule_id,
|
rule_id=rule_id,
|
||||||
policy_priority=lane_policy.policy_priority,
|
policy_priority=30,
|
||||||
reason_codes=reason_codes,
|
reason_codes=(reason_code,),
|
||||||
time_window=time_window,
|
time_window="not_applicable",
|
||||||
candidates=tuple(
|
candidates=candidates,
|
||||||
CATALOG.targets[target_id] for target_id in lane_policy.candidates
|
|
||||||
),
|
|
||||||
route_id=f"{stage}:{lane_id}",
|
|
||||||
catalog_revision=CATALOG.revision,
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,308 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Convert persisted execution-target decisions into dispatcher agent specs."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class AgentSpec:
|
|
||||||
cli: str
|
|
||||||
model: str
|
|
||||||
display: str
|
|
||||||
local_pi: bool = False
|
|
||||||
reasoning_effort: str | None = None
|
|
||||||
thinking_level: str | None = None
|
|
||||||
command_model: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def effective_reasoning_effort(spec: AgentSpec) -> str | None:
|
|
||||||
if spec.cli in {"codex", "claude", "claude-glm"}:
|
|
||||||
return spec.reasoning_effort or "xhigh"
|
|
||||||
if spec.cli == "opencode":
|
|
||||||
return spec.reasoning_effort or "max"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def effective_pi_thinking_level(spec: AgentSpec) -> str | None:
|
|
||||||
return spec.thinking_level or "high" if spec.cli == "pi" else None
|
|
||||||
|
|
||||||
|
|
||||||
def pi_display(model: str, thinking_level: str | None) -> str:
|
|
||||||
suffix = f" {thinking_level}" if thinking_level is not None else ""
|
|
||||||
return f"pi/iop/{model}{suffix}"
|
|
||||||
|
|
||||||
|
|
||||||
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")
|
|
||||||
thinking_level = record.get("thinking_level")
|
|
||||||
command_model = record.get("command_model")
|
|
||||||
reasoning_effort = (
|
|
||||||
str(reasoning_effort) if reasoning_effort is not None else None
|
|
||||||
)
|
|
||||||
thinking_level = str(thinking_level) if thinking_level is not None else None
|
|
||||||
command_model = str(command_model) if command_model is not None else None
|
|
||||||
if cli == "claude-glm" and model == "glm-5.2" and not command_model:
|
|
||||||
command_model = "sonnet"
|
|
||||||
if cli == "opencode" and model == "glm-5.2" and not command_model:
|
|
||||||
command_model = "iop-glm/glm-5.2"
|
|
||||||
if cli in {"codex", "claude", "claude-glm", "opencode"}:
|
|
||||||
effort = reasoning_effort or ("max" if cli == "opencode" else "xhigh")
|
|
||||||
display = f"{cli}/{model} {effort}"
|
|
||||||
elif cli == "pi":
|
|
||||||
display = pi_display(model, thinking_level)
|
|
||||||
else:
|
|
||||||
display = f"{cli}/{model}"
|
|
||||||
return AgentSpec(
|
|
||||||
cli,
|
|
||||||
model,
|
|
||||||
display,
|
|
||||||
local_pi=cli == "pi",
|
|
||||||
reasoning_effort=reasoning_effort,
|
|
||||||
thinking_level=thinking_level,
|
|
||||||
command_model=command_model,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
return agent_spec_from_record(record) if isinstance(record, dict) else None
|
|
||||||
|
|
||||||
|
|
||||||
def _selected_schema(decision: dict[str, Any], error_type):
|
|
||||||
selected = decision.get("selected")
|
|
||||||
if not isinstance(selected, dict):
|
|
||||||
raise error_type("selector selected가 object가 아니다")
|
|
||||||
adapter = selected.get("adapter")
|
|
||||||
target = selected.get("target")
|
|
||||||
execution_class = selected.get("execution_class")
|
|
||||||
selfcheck = selected.get("selfcheck_required")
|
|
||||||
if (
|
|
||||||
not isinstance(adapter, str)
|
|
||||||
or not isinstance(target, str)
|
|
||||||
or not target
|
|
||||||
or not isinstance(selfcheck, bool)
|
|
||||||
or execution_class not in {"local_model", "cloud_model"}
|
|
||||||
):
|
|
||||||
raise error_type("selector selected schema가 유효하지 않다")
|
|
||||||
return selected, adapter, target, execution_class, selfcheck
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_promotion_path(
|
|
||||||
decision: dict[str, Any], canonical, initial_keys: set[tuple], selector, error_type
|
|
||||||
) -> None:
|
|
||||||
promotion_path = decision.get("promotion_path")
|
|
||||||
if not isinstance(promotion_path, list) or len(promotion_path) < 2:
|
|
||||||
raise error_type("selector promotion path가 없다")
|
|
||||||
resolved_path = []
|
|
||||||
for index, entry in enumerate(promotion_path):
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
raise error_type(f"selector promotion path[{index}]가 object가 아니다")
|
|
||||||
resolved = selector.policy.canonical_target(
|
|
||||||
entry.get("adapter"),
|
|
||||||
entry.get("target"),
|
|
||||||
entry.get("thinking_level"),
|
|
||||||
entry.get("reasoning_effort"),
|
|
||||||
)
|
|
||||||
if resolved is None:
|
|
||||||
raise error_type(
|
|
||||||
f"selector promotion path[{index}] target이 canonical이 아니다"
|
|
||||||
)
|
|
||||||
resolved_path.append(resolved)
|
|
||||||
if _target_key(resolved_path[0]) not in initial_keys:
|
|
||||||
raise error_type("selector promotion path 시작 target이 잘못됐다")
|
|
||||||
if any(
|
|
||||||
selector.policy.promotion_target(previous) != current
|
|
||||||
for previous, current in zip(resolved_path, resolved_path[1:])
|
|
||||||
):
|
|
||||||
raise error_type("selector promotion path 순서가 잘못됐다")
|
|
||||||
if resolved_path[-1] != canonical:
|
|
||||||
raise error_type("selector promotion path tail이 selected와 다르다")
|
|
||||||
|
|
||||||
|
|
||||||
def _spec_from_canonical(canonical, error_type) -> AgentSpec:
|
|
||||||
adapter = canonical.adapter
|
|
||||||
target = canonical.target
|
|
||||||
if adapter == "pi":
|
|
||||||
if not target.startswith("iop/"):
|
|
||||||
raise error_type("Pi selector target/schema가 유효하지 않다")
|
|
||||||
model = target.removeprefix("iop/")
|
|
||||||
return AgentSpec(
|
|
||||||
"pi",
|
|
||||||
model,
|
|
||||||
pi_display(model, canonical.thinking_level),
|
|
||||||
local_pi=True,
|
|
||||||
thinking_level=canonical.thinking_level,
|
|
||||||
)
|
|
||||||
if adapter not in {"agy", "claude", "claude-glm", "codex", "opencode"}:
|
|
||||||
raise error_type(f"selector adapter/schema가 유효하지 않다: {adapter!r}")
|
|
||||||
effort = canonical.reasoning_effort
|
|
||||||
suffix = f" {effort}" if effort is not None else ""
|
|
||||||
return AgentSpec(
|
|
||||||
adapter,
|
|
||||||
target,
|
|
||||||
f"{adapter}/{target}{suffix}",
|
|
||||||
reasoning_effort=effort,
|
|
||||||
command_model=canonical.command_model,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _target_key(target) -> tuple:
|
|
||||||
return (
|
|
||||||
target.adapter,
|
|
||||||
target.target,
|
|
||||||
target.thinking_level,
|
|
||||||
target.reasoning_effort,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def agent_spec_from_decision(
|
|
||||||
decision: dict[str, Any], selector, error_type
|
|
||||||
) -> AgentSpec:
|
|
||||||
selected, adapter, target, execution_class, selfcheck = _selected_schema(
|
|
||||||
decision, error_type
|
|
||||||
)
|
|
||||||
thinking = selected.get("thinking_level")
|
|
||||||
reasoning = selected.get("reasoning_effort")
|
|
||||||
try:
|
|
||||||
selector._validate_prior_decision(decision)
|
|
||||||
selector._validate_prior_candidate_identity(
|
|
||||||
decision,
|
|
||||||
stage=decision["stage"],
|
|
||||||
lane=decision["lane"],
|
|
||||||
grade=decision["grade"],
|
|
||||||
)
|
|
||||||
catalog = decision.get("catalog")
|
|
||||||
if (
|
|
||||||
isinstance(catalog, dict)
|
|
||||||
and catalog.get("revision") != selector.policy.CATALOG.revision
|
|
||||||
):
|
|
||||||
return spec_from_snapshot(decision, error_type)
|
|
||||||
evaluated_at = selector.datetime.fromisoformat(
|
|
||||||
decision["decision"]["evaluated_at"]
|
|
||||||
)
|
|
||||||
policy_targets = selector.policy.select_policy(
|
|
||||||
stage=decision["stage"],
|
|
||||||
lane=decision["lane"],
|
|
||||||
grade=decision["grade"],
|
|
||||||
evaluated_at=evaluated_at,
|
|
||||||
).candidates
|
|
||||||
canonical = selector.policy.canonical_target(
|
|
||||||
adapter, target, thinking, reasoning
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
raise error_type(f"selector policy validation 실패: {exc}") from exc
|
|
||||||
if canonical is None or (
|
|
||||||
canonical.execution_class != execution_class
|
|
||||||
or canonical.selfcheck_required != selfcheck
|
|
||||||
):
|
|
||||||
raise error_type("selector selected가 canonical policy target이 아니다")
|
|
||||||
initial_keys = {_target_key(item) for item in policy_targets}
|
|
||||||
if _target_key(canonical) not in initial_keys:
|
|
||||||
_validate_promotion_path(
|
|
||||||
decision, canonical, initial_keys, selector, error_type
|
|
||||||
)
|
|
||||||
return _spec_from_canonical(canonical, error_type)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_snapshot_contract(
|
|
||||||
adapter: str,
|
|
||||||
target: str,
|
|
||||||
execution_class: str,
|
|
||||||
selfcheck: bool,
|
|
||||||
error_type,
|
|
||||||
) -> None:
|
|
||||||
if adapter == "pi":
|
|
||||||
if not target.startswith("iop/"):
|
|
||||||
raise error_type(
|
|
||||||
f"Pi completing decision target이 iop/ prefix가 아니다: {target}"
|
|
||||||
)
|
|
||||||
model = target.removeprefix("iop/")
|
|
||||||
glm_cloud = model == "glm-5.2"
|
|
||||||
legacy_glm = glm_cloud and execution_class == "local_model" and selfcheck
|
|
||||||
expected = ("cloud_model", False) if glm_cloud else ("local_model", True)
|
|
||||||
if not legacy_glm and (execution_class, selfcheck) != expected:
|
|
||||||
raise error_type(
|
|
||||||
"Pi completing decision execution/selfcheck 계약이 유효하지 않다: "
|
|
||||||
f"target={target} execution_class={execution_class} "
|
|
||||||
f"selfcheck_required={selfcheck}"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if adapter not in {"agy", "claude", "claude-glm", "codex", "opencode"}:
|
|
||||||
raise error_type(
|
|
||||||
f"completing decision adapter가 유효하지 않다: {adapter!r}"
|
|
||||||
)
|
|
||||||
if execution_class != "cloud_model" or selfcheck:
|
|
||||||
raise error_type(
|
|
||||||
"cloud completing decision execution/selfcheck 계약이 유효하지 않다: "
|
|
||||||
f"{adapter}/{execution_class}/{selfcheck}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def spec_from_snapshot(decision: dict[str, Any], error_type) -> AgentSpec:
|
|
||||||
"""Build a spec from the target snapshot pinned in a persisted decision."""
|
|
||||||
selected, adapter, target, execution_class, selfcheck = _selected_schema(
|
|
||||||
decision, error_type
|
|
||||||
)
|
|
||||||
thinking = selected.get("thinking_level")
|
|
||||||
reasoning = selected.get("reasoning_effort")
|
|
||||||
command_model = selected.get("command_model")
|
|
||||||
_validate_snapshot_contract(
|
|
||||||
adapter, target, execution_class, selfcheck, error_type
|
|
||||||
)
|
|
||||||
if adapter == "pi":
|
|
||||||
if thinking is not None and thinking not in {"low", "medium", "high"}:
|
|
||||||
raise error_type(
|
|
||||||
f"Pi completing decision thinking_level이 유효하지 않다: {thinking!r}"
|
|
||||||
)
|
|
||||||
model = target.removeprefix("iop/")
|
|
||||||
return AgentSpec(
|
|
||||||
adapter,
|
|
||||||
model,
|
|
||||||
pi_display(model, thinking),
|
|
||||||
local_pi=True,
|
|
||||||
thinking_level=thinking,
|
|
||||||
)
|
|
||||||
if adapter == "claude-glm":
|
|
||||||
return AgentSpec(
|
|
||||||
adapter,
|
|
||||||
target,
|
|
||||||
f"{adapter}/{target} xhigh",
|
|
||||||
command_model=str(command_model or "sonnet"),
|
|
||||||
)
|
|
||||||
if adapter == "opencode":
|
|
||||||
if reasoning is not None and reasoning not in {"medium", "high", "max"}:
|
|
||||||
raise error_type(
|
|
||||||
"opencode completing decision reasoning_effort가 유효하지 않다: "
|
|
||||||
f"{reasoning!r}"
|
|
||||||
)
|
|
||||||
effort = str(reasoning or "max")
|
|
||||||
return AgentSpec(
|
|
||||||
adapter,
|
|
||||||
target,
|
|
||||||
f"{adapter}/{target} {effort}",
|
|
||||||
reasoning_effort=effort,
|
|
||||||
command_model=str(command_model or target),
|
|
||||||
)
|
|
||||||
effort = reasoning or ("xhigh" if adapter in {"claude", "codex"} else None)
|
|
||||||
suffix = f" {effort}" if effort else ""
|
|
||||||
return AgentSpec(
|
|
||||||
adapter,
|
|
||||||
target,
|
|
||||||
f"{adapter}/{target}{suffix}",
|
|
||||||
reasoning_effort=effort,
|
|
||||||
command_model=str(command_model) if command_model is not None else None,
|
|
||||||
)
|
|
||||||
|
|
@ -1,347 +0,0 @@
|
||||||
#!/usr/bin/env python3
|
|
||||||
"""Persisted execution-target snapshot validation helpers."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
|
|
||||||
ERROR_CODE = "malformed_prior_decision"
|
|
||||||
|
|
||||||
|
|
||||||
def runtime_key(entry: dict) -> tuple[object, ...]:
|
|
||||||
return (
|
|
||||||
entry.get("adapter"),
|
|
||||||
entry.get("target"),
|
|
||||||
entry.get("thinking_level"),
|
|
||||||
entry.get("reasoning_effort"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def route_key(target) -> tuple[object, ...]:
|
|
||||||
return (
|
|
||||||
target.adapter,
|
|
||||||
target.target,
|
|
||||||
target.thinking_level,
|
|
||||||
target.reasoning_effort,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_selected_target(prior_decision, canonical_targets, policy, error_type):
|
|
||||||
selected = prior_decision.get("selected")
|
|
||||||
if not isinstance(selected, dict):
|
|
||||||
raise error_type(ERROR_CODE, "prior_decision.selected must be an object")
|
|
||||||
selected_key = runtime_key(selected)
|
|
||||||
matching = policy.canonical_target(*selected_key)
|
|
||||||
if matching is None:
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE,
|
|
||||||
f"prior_decision.selected {selected_key} is not a policy-owned target",
|
|
||||||
)
|
|
||||||
if (
|
|
||||||
selected.get("execution_class") != matching.execution_class
|
|
||||||
or selected.get("selfcheck_required") != matching.selfcheck_required
|
|
||||||
):
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE,
|
|
||||||
f"prior_decision.selected attributes do not match canonical target for {selected_key}",
|
|
||||||
)
|
|
||||||
if isinstance(prior_decision.get("catalog"), dict) and (
|
|
||||||
selected.get("target_id") != matching.catalog_id
|
|
||||||
or selected.get("command_model") != matching.command_model
|
|
||||||
):
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE,
|
|
||||||
f"prior_decision.selected catalog attributes do not match canonical target for {selected_key}",
|
|
||||||
)
|
|
||||||
return selected_key, matching, [route_key(target) for target in canonical_targets]
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_promotion_history(
|
|
||||||
prior_decision, matching, canonical_keys, policy, error_type
|
|
||||||
):
|
|
||||||
promotion_path = prior_decision.get("promotion_path")
|
|
||||||
if not isinstance(promotion_path, list) or len(promotion_path) < 2:
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE, "promoted prior_decision requires promotion_path evidence"
|
|
||||||
)
|
|
||||||
path_targets = []
|
|
||||||
for index, entry in enumerate(promotion_path):
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
raise error_type(ERROR_CODE, f"promotion_path[{index}] must be an object")
|
|
||||||
target = policy.canonical_target(*runtime_key(entry))
|
|
||||||
if target is None:
|
|
||||||
raise error_type(ERROR_CODE, f"promotion_path[{index}] is not policy-owned")
|
|
||||||
path_targets.append(target)
|
|
||||||
if route_key(path_targets[0]) not in set(canonical_keys):
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE, "promotion_path must begin at the initial policy target"
|
|
||||||
)
|
|
||||||
for previous, current in zip(path_targets, path_targets[1:]):
|
|
||||||
if policy.promotion_target(previous) != current:
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE, "promotion_path contains a non-adjacent transition"
|
|
||||||
)
|
|
||||||
if path_targets[-1] != matching:
|
|
||||||
raise error_type(ERROR_CODE, "promotion_path tail does not match selected target")
|
|
||||||
if "used_candidates" in prior_decision:
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE, "promotion decision must not carry failover used_candidates"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_used_history(prior_decision, selected_key, canonical_keys, error_type):
|
|
||||||
used = prior_decision["used_candidates"]
|
|
||||||
if not isinstance(used, list):
|
|
||||||
raise error_type(ERROR_CODE, "prior_decision.used_candidates must be a list")
|
|
||||||
used_keys = []
|
|
||||||
for index, entry in enumerate(used):
|
|
||||||
if not isinstance(entry, dict):
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE,
|
|
||||||
f"prior_decision.used_candidates[{index}] must be an object",
|
|
||||||
)
|
|
||||||
key = runtime_key(entry)
|
|
||||||
if key not in set(canonical_keys):
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE,
|
|
||||||
f"prior_decision.used_candidates[{index}] {key} is not in canonical policy targets {set(canonical_keys)}",
|
|
||||||
)
|
|
||||||
used_keys.append(key)
|
|
||||||
if len(used_keys) != len(set(used_keys)):
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE, "prior_decision.used_candidates contains duplicate targets"
|
|
||||||
)
|
|
||||||
if [canonical_keys.index(key) for key in used_keys] != sorted(
|
|
||||||
canonical_keys.index(key) for key in used_keys
|
|
||||||
):
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE,
|
|
||||||
"prior_decision.used_candidates order does not match candidate rank order",
|
|
||||||
)
|
|
||||||
if used_keys and selected_key != used_keys[-1]:
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE,
|
|
||||||
f"prior_decision.selected {selected_key} does not match tail of used_candidates {used_keys[-1]}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_selected_and_history(
|
|
||||||
prior_decision, canonical_targets, policy, error_type
|
|
||||||
):
|
|
||||||
selected_key, matching, canonical_keys = _validate_selected_target(
|
|
||||||
prior_decision, canonical_targets, policy, error_type
|
|
||||||
)
|
|
||||||
if selected_key not in set(canonical_keys):
|
|
||||||
_validate_promotion_history(
|
|
||||||
prior_decision, matching, canonical_keys, policy, error_type
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if "used_candidates" in prior_decision:
|
|
||||||
_validate_used_history(
|
|
||||||
prior_decision, selected_key, canonical_keys, error_type
|
|
||||||
)
|
|
||||||
return
|
|
||||||
eligible = [
|
|
||||||
runtime_key(candidate)
|
|
||||||
for candidate in prior_decision.get("candidates", [])
|
|
||||||
if isinstance(candidate, dict) and candidate.get("eligibility") == "eligible"
|
|
||||||
]
|
|
||||||
if eligible and selected_key != eligible[0]:
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE,
|
|
||||||
f"prior_decision.selected {selected_key} does not match first eligible candidate {eligible[0]} when used_candidates is absent",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_pinned_catalog_snapshot(
|
|
||||||
prior_decision, error_type, validate_used_candidates
|
|
||||||
):
|
|
||||||
candidates = prior_decision.get("candidates")
|
|
||||||
selected = prior_decision.get("selected")
|
|
||||||
if not isinstance(candidates, list) or not isinstance(selected, dict):
|
|
||||||
raise error_type(ERROR_CODE, "pinned catalog snapshot is incomplete")
|
|
||||||
keys = [runtime_key(candidate) for candidate in candidates]
|
|
||||||
if len(keys) != len(set(keys)):
|
|
||||||
raise error_type(ERROR_CODE, "pinned catalog snapshot has duplicate targets")
|
|
||||||
selected_key = runtime_key(selected)
|
|
||||||
if selected_key not in keys:
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE, "pinned selected target is not present in the candidate snapshot"
|
|
||||||
)
|
|
||||||
selected_candidate = candidates[keys.index(selected_key)]
|
|
||||||
identity_fields = (
|
|
||||||
"target_id", "adapter", "target", "execution_class",
|
|
||||||
"selfcheck_required", "thinking_level", "reasoning_effort", "command_model",
|
|
||||||
)
|
|
||||||
for field in identity_fields:
|
|
||||||
if selected.get(field) != selected_candidate.get(field):
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE,
|
|
||||||
f"pinned selected.{field} does not match its candidate snapshot",
|
|
||||||
)
|
|
||||||
if "promotion_path" in prior_decision:
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE,
|
|
||||||
"catalog-backed decisions must express fallback in the lane candidate array",
|
|
||||||
)
|
|
||||||
if "used_candidates" not in prior_decision:
|
|
||||||
eligible = [
|
|
||||||
runtime_key(candidate)
|
|
||||||
for candidate in candidates
|
|
||||||
if candidate.get("eligibility") == "eligible"
|
|
||||||
]
|
|
||||||
if not eligible or selected_key != eligible[0]:
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE, "selected target is not the first eligible pinned candidate"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
used = validate_used_candidates(prior_decision.get("used_candidates"))
|
|
||||||
used_keys = [runtime_key(entry) for entry in used]
|
|
||||||
if len(used_keys) != len(set(used_keys)):
|
|
||||||
raise error_type(ERROR_CODE, "used_candidates contains duplicate targets")
|
|
||||||
if any(key not in keys for key in used_keys):
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE, "used_candidates contains a target outside the pinned snapshot"
|
|
||||||
)
|
|
||||||
if [keys.index(key) for key in used_keys] != sorted(
|
|
||||||
keys.index(key) for key in used_keys
|
|
||||||
):
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE, "used_candidates order does not match the pinned candidate order"
|
|
||||||
)
|
|
||||||
if not used_keys or used_keys[-1] != selected_key:
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE, "selected target does not match used_candidates tail"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_catalog(catalog, expected_route_id, policy, error_type):
|
|
||||||
if not isinstance(catalog, dict):
|
|
||||||
return False
|
|
||||||
if (
|
|
||||||
catalog.get("schema_version") != policy.CATALOG_SCHEMA_VERSION
|
|
||||||
or not isinstance(catalog.get("revision"), str)
|
|
||||||
or not catalog.get("revision")
|
|
||||||
):
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE,
|
|
||||||
"prior_decision.catalog must contain the current schema_version and a non-empty revision",
|
|
||||||
)
|
|
||||||
if catalog.get("route_id") != expected_route_id:
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE,
|
|
||||||
f"prior_decision.catalog.route_id ({catalog.get('route_id')!r}) does not match {expected_route_id!r}",
|
|
||||||
)
|
|
||||||
return catalog.get("revision") != policy.CATALOG.revision
|
|
||||||
|
|
||||||
|
|
||||||
def _canonical_decision(decision_info, stage, lane, grade, policy, error_type):
|
|
||||||
evaluated_at = decision_info.get("evaluated_at")
|
|
||||||
if not isinstance(evaluated_at, str):
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE, "prior_decision.decision.evaluated_at must be a string"
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
parsed = datetime.fromisoformat(evaluated_at)
|
|
||||||
except (ValueError, TypeError) as exc:
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE,
|
|
||||||
f"prior_decision.decision.evaluated_at is not a valid ISO datetime: {evaluated_at!r}",
|
|
||||||
) from exc
|
|
||||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE,
|
|
||||||
f"prior_decision.decision.evaluated_at must be timezone-aware: {evaluated_at!r}",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
return policy.select_policy(
|
|
||||||
stage=stage, lane=lane, grade=grade, evaluated_at=parsed
|
|
||||||
)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise error_type(ERROR_CODE, str(exc)) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_decision_metadata(decision_info, canonical, error_type):
|
|
||||||
expected = {
|
|
||||||
"rule_id": canonical.rule_id,
|
|
||||||
"policy_priority": canonical.policy_priority,
|
|
||||||
"reason_codes": list(canonical.reason_codes),
|
|
||||||
"time_window": canonical.time_window,
|
|
||||||
}
|
|
||||||
actual = {
|
|
||||||
"rule_id": decision_info.get("rule_id"),
|
|
||||||
"policy_priority": decision_info.get("policy_priority"),
|
|
||||||
"reason_codes": list(decision_info.get("reason_codes", [])),
|
|
||||||
"time_window": decision_info.get("time_window"),
|
|
||||||
}
|
|
||||||
for field, expected_value in expected.items():
|
|
||||||
if actual[field] != expected_value:
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE,
|
|
||||||
f"prior_decision.decision.{field} ({actual[field]!r}) does not match canonical policy ({expected_value!r})",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_candidates(prior_decision, canonical_targets, catalog, error_type):
|
|
||||||
candidates = prior_decision.get("candidates")
|
|
||||||
if not isinstance(candidates, list) or len(candidates) != len(canonical_targets):
|
|
||||||
actual_length = len(candidates) if isinstance(candidates, list) else 0
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE,
|
|
||||||
f"prior_decision.candidates length ({actual_length}) does not match canonical policy candidates length ({len(canonical_targets)})",
|
|
||||||
)
|
|
||||||
for index, (candidate, target) in enumerate(zip(candidates, canonical_targets)):
|
|
||||||
if not isinstance(candidate, dict):
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE, f"prior_decision.candidates[{index}] must be an object"
|
|
||||||
)
|
|
||||||
expected = {
|
|
||||||
"adapter": target.adapter,
|
|
||||||
"target": target.target,
|
|
||||||
"execution_class": target.execution_class,
|
|
||||||
"selfcheck_required": target.selfcheck_required,
|
|
||||||
"thinking_level": target.thinking_level,
|
|
||||||
"reasoning_effort": target.reasoning_effort,
|
|
||||||
}
|
|
||||||
if isinstance(catalog, dict):
|
|
||||||
expected.update(
|
|
||||||
target_id=target.catalog_id, command_model=target.command_model
|
|
||||||
)
|
|
||||||
if any(candidate.get(field) != value for field, value in expected.items()):
|
|
||||||
raise error_type(
|
|
||||||
ERROR_CODE,
|
|
||||||
f"prior_decision.candidates[{index}] identity ({candidate.get('adapter')}, {candidate.get('target')}) does not match canonical policy candidate ({target.adapter}, {target.target})",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_prior_candidate_identity(
|
|
||||||
prior_decision,
|
|
||||||
*,
|
|
||||||
stage,
|
|
||||||
lane,
|
|
||||||
grade,
|
|
||||||
policy,
|
|
||||||
error_type,
|
|
||||||
validate_used_candidates,
|
|
||||||
):
|
|
||||||
decision_info = prior_decision.get("decision")
|
|
||||||
if not isinstance(decision_info, dict):
|
|
||||||
raise error_type(ERROR_CODE, "prior_decision.decision must be an object")
|
|
||||||
catalog = prior_decision.get("catalog")
|
|
||||||
changed = _validate_catalog(
|
|
||||||
catalog, f"{stage}:{lane}-G{grade:02d}", policy, error_type
|
|
||||||
)
|
|
||||||
if changed:
|
|
||||||
_validate_pinned_catalog_snapshot(
|
|
||||||
prior_decision, error_type, validate_used_candidates
|
|
||||||
)
|
|
||||||
return
|
|
||||||
canonical = _canonical_decision(
|
|
||||||
decision_info, stage, lane, grade, policy, error_type
|
|
||||||
)
|
|
||||||
_validate_decision_metadata(decision_info, canonical, error_type)
|
|
||||||
_validate_candidates(prior_decision, canonical.candidates, catalog, error_type)
|
|
||||||
_validate_selected_and_history(
|
|
||||||
prior_decision, canonical.candidates, policy, error_type
|
|
||||||
)
|
|
||||||
|
|
@ -73,19 +73,6 @@ def _load_policy():
|
||||||
policy = _load_policy()
|
policy = _load_policy()
|
||||||
|
|
||||||
|
|
||||||
def _load_state_validation():
|
|
||||||
path = Path(__file__).resolve().parent / "execution_target_state.py"
|
|
||||||
spec = importlib.util.spec_from_file_location("execution_target_state", 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
|
|
||||||
|
|
||||||
|
|
||||||
state_validation = _load_state_validation()
|
|
||||||
|
|
||||||
|
|
||||||
class SelectorInputError(Exception):
|
class SelectorInputError(Exception):
|
||||||
"""Input contract violation returned as stderr JSON with a non-zero exit."""
|
"""Input contract violation returned as stderr JSON with a non-zero exit."""
|
||||||
|
|
||||||
|
|
@ -189,13 +176,6 @@ def _validate_prior_selected(selected: object) -> None:
|
||||||
"prior_decision.selected.execution_class must be one of "
|
"prior_decision.selected.execution_class must be one of "
|
||||||
f"{sorted(_VALID_EXECUTION_CLASSES)}",
|
f"{sorted(_VALID_EXECUTION_CLASSES)}",
|
||||||
)
|
)
|
||||||
for field in ("target_id", "command_model"):
|
|
||||||
value = selected.get(field)
|
|
||||||
if value is not None and (not isinstance(value, str) or not value):
|
|
||||||
raise SelectorInputError(
|
|
||||||
code,
|
|
||||||
f"prior_decision.selected.{field} must be null or a non-empty string",
|
|
||||||
)
|
|
||||||
if not isinstance(selected.get("selfcheck_required"), bool):
|
if not isinstance(selected.get("selfcheck_required"), bool):
|
||||||
raise SelectorInputError(
|
raise SelectorInputError(
|
||||||
code,
|
code,
|
||||||
|
|
@ -255,16 +235,6 @@ def _require_nullable_string(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _require_optional_non_empty_string(
|
|
||||||
container: dict, field: str, prefix: str, code: str
|
|
||||||
) -> None:
|
|
||||||
value = container.get(field)
|
|
||||||
if value is not None and (not isinstance(value, str) or not value):
|
|
||||||
raise SelectorInputError(
|
|
||||||
code, f"{prefix}.{field} must be null or a non-empty string"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_prior_candidates(candidates: object) -> None:
|
def _validate_prior_candidates(candidates: object) -> None:
|
||||||
"""Validate every reused candidate against the initial output schema.
|
"""Validate every reused candidate against the initial output schema.
|
||||||
|
|
||||||
|
|
@ -295,8 +265,6 @@ def _validate_prior_candidates(candidates: object) -> None:
|
||||||
)
|
)
|
||||||
_require_non_empty_string(entry, "adapter", prefix, code)
|
_require_non_empty_string(entry, "adapter", prefix, code)
|
||||||
_require_non_empty_string(entry, "target", prefix, code)
|
_require_non_empty_string(entry, "target", prefix, code)
|
||||||
for field in ("target_id", "command_model"):
|
|
||||||
_require_optional_non_empty_string(entry, field, prefix, code)
|
|
||||||
_require_string_enum(
|
_require_string_enum(
|
||||||
entry, "execution_class", _VALID_EXECUTION_CLASSES, prefix, code
|
entry, "execution_class", _VALID_EXECUTION_CLASSES, prefix, code
|
||||||
)
|
)
|
||||||
|
|
@ -446,21 +414,6 @@ def _validate_prior_decision(value: object) -> dict:
|
||||||
_validate_prior_candidates(value["candidates"])
|
_validate_prior_candidates(value["candidates"])
|
||||||
_validate_prior_decision_evidence(value["decision"])
|
_validate_prior_decision_evidence(value["decision"])
|
||||||
_validate_prior_quota(value["quota"])
|
_validate_prior_quota(value["quota"])
|
||||||
catalog = value.get("catalog")
|
|
||||||
if catalog is not None:
|
|
||||||
if not isinstance(catalog, dict):
|
|
||||||
raise SelectorInputError(code, "prior_decision.catalog must be an object")
|
|
||||||
if catalog.get("schema_version") != policy.CATALOG_SCHEMA_VERSION:
|
|
||||||
raise SelectorInputError(
|
|
||||||
code,
|
|
||||||
"prior_decision.catalog.schema_version does not match the supported catalog schema",
|
|
||||||
)
|
|
||||||
for field in ("revision", "route_id"):
|
|
||||||
item = catalog.get(field)
|
|
||||||
if not isinstance(item, str) or not item:
|
|
||||||
raise SelectorInputError(
|
|
||||||
code, f"prior_decision.catalog.{field} must be a non-empty string"
|
|
||||||
)
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -886,24 +839,6 @@ def _selected_quota(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _selected_fields(target) -> dict:
|
|
||||||
def value(field: str):
|
|
||||||
return target.get(field) if isinstance(target, dict) else getattr(target, field)
|
|
||||||
|
|
||||||
selected = {
|
|
||||||
field: value(field)
|
|
||||||
for field in ("adapter", "target", "execution_class", "selfcheck_required")
|
|
||||||
}
|
|
||||||
for field in ("target_id", "command_model", "thinking_level", "reasoning_effort"):
|
|
||||||
source_field = (
|
|
||||||
"catalog_id" if field == "target_id" and not isinstance(target, dict) else field
|
|
||||||
)
|
|
||||||
field_value = value(source_field)
|
|
||||||
if field_value is not None:
|
|
||||||
selected[field] = field_value
|
|
||||||
return selected
|
|
||||||
|
|
||||||
|
|
||||||
def _initial(
|
def _initial(
|
||||||
*,
|
*,
|
||||||
work_unit_id: str,
|
work_unit_id: str,
|
||||||
|
|
@ -929,13 +864,20 @@ def _initial(
|
||||||
)
|
)
|
||||||
eligible = status != "exhausted"
|
eligible = status != "exhausted"
|
||||||
candidate = {
|
candidate = {
|
||||||
**_selected_fields(target),
|
|
||||||
"candidate_rank": rank,
|
"candidate_rank": rank,
|
||||||
|
"adapter": target.adapter,
|
||||||
|
"target": target.target,
|
||||||
|
"execution_class": target.execution_class,
|
||||||
|
"selfcheck_required": target.selfcheck_required,
|
||||||
"quota_mode": mode,
|
"quota_mode": mode,
|
||||||
"quota_status": status,
|
"quota_status": status,
|
||||||
"eligibility": "eligible" if eligible else "ineligible",
|
"eligibility": "eligible" if eligible else "ineligible",
|
||||||
"rejection_reason": None if eligible else "quota_exhausted",
|
"rejection_reason": None if eligible else "quota_exhausted",
|
||||||
}
|
}
|
||||||
|
if target.thinking_level is not None:
|
||||||
|
candidate["thinking_level"] = target.thinking_level
|
||||||
|
if target.reasoning_effort is not None:
|
||||||
|
candidate["reasoning_effort"] = target.reasoning_effort
|
||||||
candidates.append(candidate)
|
candidates.append(candidate)
|
||||||
if eligible and selected is None:
|
if eligible and selected is None:
|
||||||
selected = target
|
selected = target
|
||||||
|
|
@ -945,18 +887,22 @@ def _initial(
|
||||||
"no_eligible_target",
|
"no_eligible_target",
|
||||||
"all policy candidates are exhausted according to the quota snapshot",
|
"all policy candidates are exhausted according to the quota snapshot",
|
||||||
)
|
)
|
||||||
selected_fields = _selected_fields(selected)
|
selected_fields = {
|
||||||
|
"adapter": selected.adapter,
|
||||||
|
"target": selected.target,
|
||||||
|
"execution_class": selected.execution_class,
|
||||||
|
"selfcheck_required": selected.selfcheck_required,
|
||||||
|
}
|
||||||
|
if selected.thinking_level is not None:
|
||||||
|
selected_fields["thinking_level"] = selected.thinking_level
|
||||||
|
if selected.reasoning_effort is not None:
|
||||||
|
selected_fields["reasoning_effort"] = selected.reasoning_effort
|
||||||
return {
|
return {
|
||||||
"schema_version": SCHEMA_VERSION,
|
"schema_version": SCHEMA_VERSION,
|
||||||
"work_unit_id": work_unit_id,
|
"work_unit_id": work_unit_id,
|
||||||
"stage": stage,
|
"stage": stage,
|
||||||
"lane": lane,
|
"lane": lane,
|
||||||
"grade": grade,
|
"grade": grade,
|
||||||
"catalog": {
|
|
||||||
"schema_version": policy.CATALOG_SCHEMA_VERSION,
|
|
||||||
"revision": decision.catalog_revision,
|
|
||||||
"route_id": decision.route_id,
|
|
||||||
},
|
|
||||||
"selected": selected_fields,
|
"selected": selected_fields,
|
||||||
"candidates": candidates,
|
"candidates": candidates,
|
||||||
"decision": {
|
"decision": {
|
||||||
|
|
@ -981,8 +927,147 @@ def _initial(
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _runtime_key(entry: dict) -> tuple[object, ...]:
|
def _validate_selected_and_used_history(
|
||||||
return state_validation.runtime_key(entry)
|
prior_decision: dict,
|
||||||
|
canonical_targets: list,
|
||||||
|
) -> None:
|
||||||
|
code = "malformed_prior_decision"
|
||||||
|
selected = prior_decision.get("selected")
|
||||||
|
if not isinstance(selected, dict):
|
||||||
|
raise SelectorInputError(code, "prior_decision.selected must be an object")
|
||||||
|
|
||||||
|
sel_key = (
|
||||||
|
selected.get("adapter"),
|
||||||
|
selected.get("target"),
|
||||||
|
selected.get("thinking_level"),
|
||||||
|
selected.get("reasoning_effort"),
|
||||||
|
)
|
||||||
|
canon_keys_list = [
|
||||||
|
(c.adapter, c.target, c.thinking_level, c.reasoning_effort)
|
||||||
|
for c in canonical_targets
|
||||||
|
]
|
||||||
|
canon_keys_set = set(canon_keys_list)
|
||||||
|
|
||||||
|
matching_cand = policy.canonical_target(*sel_key)
|
||||||
|
if matching_cand is None:
|
||||||
|
raise SelectorInputError(
|
||||||
|
code,
|
||||||
|
f"prior_decision.selected {sel_key} is not a policy-owned target",
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
selected.get("execution_class") != matching_cand.execution_class
|
||||||
|
or selected.get("selfcheck_required") != matching_cand.selfcheck_required
|
||||||
|
):
|
||||||
|
raise SelectorInputError(
|
||||||
|
code,
|
||||||
|
f"prior_decision.selected attributes do not match canonical target for {sel_key}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if sel_key not in canon_keys_set:
|
||||||
|
promotion_path = prior_decision.get("promotion_path")
|
||||||
|
if not isinstance(promotion_path, list) or len(promotion_path) < 2:
|
||||||
|
raise SelectorInputError(
|
||||||
|
code,
|
||||||
|
"promoted prior_decision requires promotion_path evidence",
|
||||||
|
)
|
||||||
|
path_targets = []
|
||||||
|
for index, entry in enumerate(promotion_path):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise SelectorInputError(
|
||||||
|
code, f"promotion_path[{index}] must be an object"
|
||||||
|
)
|
||||||
|
target = policy.canonical_target(
|
||||||
|
entry.get("adapter"),
|
||||||
|
entry.get("target"),
|
||||||
|
entry.get("thinking_level"),
|
||||||
|
entry.get("reasoning_effort"),
|
||||||
|
)
|
||||||
|
if target is None:
|
||||||
|
raise SelectorInputError(
|
||||||
|
code, f"promotion_path[{index}] is not policy-owned"
|
||||||
|
)
|
||||||
|
path_targets.append(target)
|
||||||
|
if (
|
||||||
|
path_targets[0].adapter,
|
||||||
|
path_targets[0].target,
|
||||||
|
path_targets[0].thinking_level,
|
||||||
|
path_targets[0].reasoning_effort,
|
||||||
|
) not in canon_keys_set:
|
||||||
|
raise SelectorInputError(
|
||||||
|
code, "promotion_path must begin at the initial policy target"
|
||||||
|
)
|
||||||
|
for previous, current in zip(path_targets, path_targets[1:]):
|
||||||
|
if policy.promotion_target(previous) != current:
|
||||||
|
raise SelectorInputError(
|
||||||
|
code, "promotion_path contains a non-adjacent transition"
|
||||||
|
)
|
||||||
|
if path_targets[-1] != matching_cand:
|
||||||
|
raise SelectorInputError(
|
||||||
|
code, "promotion_path tail does not match selected target"
|
||||||
|
)
|
||||||
|
if "used_candidates" in prior_decision:
|
||||||
|
raise SelectorInputError(
|
||||||
|
code, "promotion decision must not carry failover used_candidates"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if "used_candidates" in prior_decision:
|
||||||
|
used = prior_decision["used_candidates"]
|
||||||
|
if not isinstance(used, list):
|
||||||
|
raise SelectorInputError(code, "prior_decision.used_candidates must be a list")
|
||||||
|
|
||||||
|
used_keys = []
|
||||||
|
for idx, entry in enumerate(used):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise SelectorInputError(
|
||||||
|
code, f"prior_decision.used_candidates[{idx}] must be an object"
|
||||||
|
)
|
||||||
|
u_key = (
|
||||||
|
entry.get("adapter"),
|
||||||
|
entry.get("target"),
|
||||||
|
entry.get("thinking_level"),
|
||||||
|
entry.get("reasoning_effort"),
|
||||||
|
)
|
||||||
|
if u_key not in canon_keys_set:
|
||||||
|
raise SelectorInputError(
|
||||||
|
code,
|
||||||
|
f"prior_decision.used_candidates[{idx}] {u_key} is not in canonical policy targets {canon_keys_set}",
|
||||||
|
)
|
||||||
|
used_keys.append(u_key)
|
||||||
|
|
||||||
|
if len(used_keys) != len(set(used_keys)):
|
||||||
|
raise SelectorInputError(
|
||||||
|
code, "prior_decision.used_candidates contains duplicate targets"
|
||||||
|
)
|
||||||
|
|
||||||
|
indices = [canon_keys_list.index(k) for k in used_keys]
|
||||||
|
if indices != sorted(indices):
|
||||||
|
raise SelectorInputError(
|
||||||
|
code, "prior_decision.used_candidates order does not match candidate rank order"
|
||||||
|
)
|
||||||
|
|
||||||
|
if used_keys and sel_key != used_keys[-1]:
|
||||||
|
raise SelectorInputError(
|
||||||
|
code,
|
||||||
|
f"prior_decision.selected {sel_key} does not match tail of used_candidates {used_keys[-1]}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
prior_cands = prior_decision.get("candidates", [])
|
||||||
|
eligible_cands = [
|
||||||
|
(
|
||||||
|
c.get("adapter"),
|
||||||
|
c.get("target"),
|
||||||
|
c.get("thinking_level"),
|
||||||
|
c.get("reasoning_effort"),
|
||||||
|
)
|
||||||
|
for c in prior_cands
|
||||||
|
if isinstance(c, dict) and c.get("eligibility") == "eligible"
|
||||||
|
]
|
||||||
|
if eligible_cands and sel_key != eligible_cands[0]:
|
||||||
|
raise SelectorInputError(
|
||||||
|
code,
|
||||||
|
f"prior_decision.selected {sel_key} does not match first eligible candidate {eligible_cands[0]} when used_candidates is absent",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_prior_candidate_identity(
|
def _validate_prior_candidate_identity(
|
||||||
|
|
@ -992,15 +1077,86 @@ def _validate_prior_candidate_identity(
|
||||||
lane: str,
|
lane: str,
|
||||||
grade: int,
|
grade: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
state_validation.validate_prior_candidate_identity(
|
code = "malformed_prior_decision"
|
||||||
prior_decision,
|
decision_info = prior_decision.get("decision")
|
||||||
stage=stage,
|
if not isinstance(decision_info, dict):
|
||||||
lane=lane,
|
raise SelectorInputError(code, "prior_decision.decision must be an object")
|
||||||
grade=grade,
|
|
||||||
policy=policy,
|
eval_str = decision_info.get("evaluated_at")
|
||||||
error_type=SelectorInputError,
|
if not isinstance(eval_str, str):
|
||||||
validate_used_candidates=_validate_used_candidates,
|
raise SelectorInputError(code, "prior_decision.decision.evaluated_at must be a string")
|
||||||
)
|
|
||||||
|
try:
|
||||||
|
prior_eval_at = datetime.fromisoformat(eval_str)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise SelectorInputError(
|
||||||
|
code, f"prior_decision.decision.evaluated_at is not a valid ISO datetime: {eval_str!r}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if prior_eval_at.tzinfo is None or prior_eval_at.utcoffset() is None:
|
||||||
|
raise SelectorInputError(
|
||||||
|
code, f"prior_decision.decision.evaluated_at must be timezone-aware: {eval_str!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
canonical_decision = policy.select_policy(
|
||||||
|
stage=stage, lane=lane, grade=grade, evaluated_at=prior_eval_at
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SelectorInputError(code, str(exc)) from exc
|
||||||
|
|
||||||
|
if decision_info.get("rule_id") != canonical_decision.rule_id:
|
||||||
|
raise SelectorInputError(
|
||||||
|
code,
|
||||||
|
f"prior_decision.decision.rule_id ({decision_info.get('rule_id')!r}) "
|
||||||
|
f"does not match canonical policy ({canonical_decision.rule_id!r})",
|
||||||
|
)
|
||||||
|
if decision_info.get("policy_priority") != canonical_decision.policy_priority:
|
||||||
|
raise SelectorInputError(
|
||||||
|
code,
|
||||||
|
f"prior_decision.decision.policy_priority ({decision_info.get('policy_priority')!r}) "
|
||||||
|
f"does not match canonical policy ({canonical_decision.policy_priority!r})",
|
||||||
|
)
|
||||||
|
if list(decision_info.get("reason_codes", [])) != list(canonical_decision.reason_codes):
|
||||||
|
raise SelectorInputError(
|
||||||
|
code,
|
||||||
|
f"prior_decision.decision.reason_codes ({decision_info.get('reason_codes')!r}) "
|
||||||
|
f"does not match canonical policy ({list(canonical_decision.reason_codes)!r})",
|
||||||
|
)
|
||||||
|
if decision_info.get("time_window") != canonical_decision.time_window:
|
||||||
|
raise SelectorInputError(
|
||||||
|
code,
|
||||||
|
f"prior_decision.decision.time_window ({decision_info.get('time_window')!r}) "
|
||||||
|
f"does not match canonical policy ({canonical_decision.time_window!r})",
|
||||||
|
)
|
||||||
|
|
||||||
|
canonical_targets = canonical_decision.candidates
|
||||||
|
prior_candidates = prior_decision.get("candidates")
|
||||||
|
if not isinstance(prior_candidates, list) or len(prior_candidates) != len(canonical_targets):
|
||||||
|
raise SelectorInputError(
|
||||||
|
code,
|
||||||
|
f"prior_decision.candidates length ({len(prior_candidates) if isinstance(prior_candidates, list) else 0}) "
|
||||||
|
f"does not match canonical policy candidates length ({len(canonical_targets)})",
|
||||||
|
)
|
||||||
|
|
||||||
|
for idx, (p_cand, c_target) in enumerate(zip(prior_candidates, canonical_targets)):
|
||||||
|
if not isinstance(p_cand, dict):
|
||||||
|
raise SelectorInputError(code, f"prior_decision.candidates[{idx}] must be an object")
|
||||||
|
if (
|
||||||
|
p_cand.get("adapter") != c_target.adapter
|
||||||
|
or p_cand.get("target") != c_target.target
|
||||||
|
or p_cand.get("execution_class") != c_target.execution_class
|
||||||
|
or p_cand.get("selfcheck_required") != c_target.selfcheck_required
|
||||||
|
or p_cand.get("thinking_level") != c_target.thinking_level
|
||||||
|
or p_cand.get("reasoning_effort") != c_target.reasoning_effort
|
||||||
|
):
|
||||||
|
raise SelectorInputError(
|
||||||
|
code,
|
||||||
|
f"prior_decision.candidates[{idx}] identity ({p_cand.get('adapter')}, {p_cand.get('target')}) "
|
||||||
|
f"does not match canonical policy candidate ({c_target.adapter}, {c_target.target})",
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate_selected_and_used_history(prior_decision, canonical_targets)
|
||||||
|
|
||||||
|
|
||||||
def _resume(
|
def _resume(
|
||||||
|
|
@ -1039,11 +1195,6 @@ def _resume(
|
||||||
"stage": stage,
|
"stage": stage,
|
||||||
"lane": lane,
|
"lane": lane,
|
||||||
"grade": grade,
|
"grade": grade,
|
||||||
**(
|
|
||||||
{"catalog": prior_decision["catalog"]}
|
|
||||||
if "catalog" in prior_decision
|
|
||||||
else {}
|
|
||||||
),
|
|
||||||
"selected": selected,
|
"selected": selected,
|
||||||
"candidates": prior_decision["candidates"],
|
"candidates": prior_decision["candidates"],
|
||||||
"decision": decision,
|
"decision": decision,
|
||||||
|
|
@ -1133,7 +1284,15 @@ def _failover(
|
||||||
used = _validate_used_candidates(prior.get("used_candidates"))
|
used = _validate_used_candidates(prior.get("used_candidates"))
|
||||||
if previous not in used:
|
if previous not in used:
|
||||||
used.append(previous)
|
used.append(previous)
|
||||||
used_set = {_runtime_key(entry) for entry in used}
|
used_set = {
|
||||||
|
(
|
||||||
|
entry["adapter"],
|
||||||
|
entry["target"],
|
||||||
|
entry.get("thinking_level"),
|
||||||
|
entry.get("reasoning_effort"),
|
||||||
|
)
|
||||||
|
for entry in used
|
||||||
|
}
|
||||||
selected_candidate = None
|
selected_candidate = None
|
||||||
selected_probed_snapshot = None
|
selected_probed_snapshot = None
|
||||||
candidates = []
|
candidates = []
|
||||||
|
|
@ -1166,13 +1325,30 @@ def _failover(
|
||||||
current["eligibility"] = "ineligible" if status == "exhausted" else "eligible"
|
current["eligibility"] = "ineligible" if status == "exhausted" else "eligible"
|
||||||
current["rejection_reason"] = "quota_exhausted" if status == "exhausted" else None
|
current["rejection_reason"] = "quota_exhausted" if status == "exhausted" else None
|
||||||
candidates.append(current)
|
candidates.append(current)
|
||||||
key = _runtime_key(current)
|
key = (
|
||||||
|
current["adapter"],
|
||||||
|
current["target"],
|
||||||
|
current.get("thinking_level"),
|
||||||
|
current.get("reasoning_effort"),
|
||||||
|
)
|
||||||
if index > previous_index and key not in used_set and current["eligibility"] == "eligible" and selected_candidate is None:
|
if index > previous_index and key not in used_set and current["eligibility"] == "eligible" and selected_candidate is None:
|
||||||
selected_candidate = current
|
selected_candidate = current
|
||||||
selected_probed_snapshot = current_snapshot
|
selected_probed_snapshot = current_snapshot
|
||||||
if selected_candidate is None:
|
if selected_candidate is None:
|
||||||
raise SelectorInputError("no_failover_candidate", "no unused eligible candidate remains for this work unit")
|
raise SelectorInputError("no_failover_candidate", "no unused eligible candidate remains for this work unit")
|
||||||
selected = _selected_fields(selected_candidate)
|
selected = {
|
||||||
|
field: selected_candidate[field]
|
||||||
|
for field in (
|
||||||
|
"adapter",
|
||||||
|
"target",
|
||||||
|
"execution_class",
|
||||||
|
"selfcheck_required",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if selected_candidate.get("thinking_level") is not None:
|
||||||
|
selected["thinking_level"] = selected_candidate["thinking_level"]
|
||||||
|
if selected_candidate.get("reasoning_effort") is not None:
|
||||||
|
selected["reasoning_effort"] = selected_candidate["reasoning_effort"]
|
||||||
next_target = _target_ref(selected)
|
next_target = _target_ref(selected)
|
||||||
used.append(next_target)
|
used.append(next_target)
|
||||||
decision = dict(prior["decision"])
|
decision = dict(prior["decision"])
|
||||||
|
|
@ -1181,7 +1357,6 @@ def _failover(
|
||||||
return {
|
return {
|
||||||
"schema_version": SCHEMA_VERSION, "work_unit_id": work_unit_id, "stage": stage,
|
"schema_version": SCHEMA_VERSION, "work_unit_id": work_unit_id, "stage": stage,
|
||||||
"lane": lane, "grade": grade, "selected": selected, "candidates": candidates,
|
"lane": lane, "grade": grade, "selected": selected, "candidates": candidates,
|
||||||
**({"catalog": prior["catalog"]} if "catalog" in prior else {}),
|
|
||||||
"decision": decision,
|
"decision": decision,
|
||||||
"quota": _selected_quota(
|
"quota": _selected_quota(
|
||||||
selected_target,
|
selected_target,
|
||||||
|
|
@ -1284,8 +1459,22 @@ def _promotion(
|
||||||
"stage": stage,
|
"stage": stage,
|
||||||
"lane": lane,
|
"lane": lane,
|
||||||
"grade": grade,
|
"grade": grade,
|
||||||
**({"catalog": prior["catalog"]} if "catalog" in prior else {}),
|
"selected": {
|
||||||
"selected": _selected_fields(promoted),
|
"adapter": promoted.adapter,
|
||||||
|
"target": promoted.target,
|
||||||
|
"execution_class": promoted.execution_class,
|
||||||
|
"selfcheck_required": promoted.selfcheck_required,
|
||||||
|
**(
|
||||||
|
{"thinking_level": promoted.thinking_level}
|
||||||
|
if promoted.thinking_level is not None
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
**(
|
||||||
|
{"reasoning_effort": promoted.reasoning_effort}
|
||||||
|
if promoted.reasoning_effort is not None
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
},
|
||||||
"candidates": prior["candidates"],
|
"candidates": prior["candidates"],
|
||||||
"decision": decision,
|
"decision": decision,
|
||||||
"promotion_path": promotion_path,
|
"promotion_path": promotion_path,
|
||||||
|
|
@ -1303,74 +1492,6 @@ def _promotion(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def select_execution_target_for_route(
|
|
||||||
*,
|
|
||||||
work_unit_id: str,
|
|
||||||
stage: str,
|
|
||||||
lane: str,
|
|
||||||
grade: int,
|
|
||||||
evaluated_at: datetime,
|
|
||||||
transition: str = "initial",
|
|
||||||
prior_decision: dict | None = None,
|
|
||||||
quota_snapshot: dict | None = None,
|
|
||||||
quota_probe_command: str = DEFAULT_QUOTA_PROBE_COMMAND,
|
|
||||||
failure_class: str | None = None,
|
|
||||||
) -> dict:
|
|
||||||
"""Select a target from an already validated task generation identity."""
|
|
||||||
if not isinstance(work_unit_id, str) or not work_unit_id:
|
|
||||||
raise SelectorInputError(
|
|
||||||
"invalid_work_unit_id", "work_unit_id must be a non-empty string"
|
|
||||||
)
|
|
||||||
_validate_evaluated_at(evaluated_at)
|
|
||||||
try:
|
|
||||||
policy._validate(stage, lane, grade, evaluated_at)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise SelectorInputError("invalid_route", str(exc)) from exc
|
|
||||||
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=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=stage,
|
|
||||||
lane=lane,
|
|
||||||
grade=grade,
|
|
||||||
)
|
|
||||||
if transition == "failover":
|
|
||||||
return _failover(
|
|
||||||
prior_decision, work_unit_id=work_unit_id, stage=stage,
|
|
||||||
lane=lane, grade=grade, evaluated_at=evaluated_at,
|
|
||||||
quota_snapshot=quota_snapshot, quota_probe_command=quota_probe_command,
|
|
||||||
failure_class=failure_class,
|
|
||||||
)
|
|
||||||
if transition == "promotion":
|
|
||||||
return _promotion(
|
|
||||||
prior_decision, work_unit_id=work_unit_id, stage=stage,
|
|
||||||
lane=lane, grade=grade, evaluated_at=evaluated_at,
|
|
||||||
quota_snapshot=quota_snapshot,
|
|
||||||
quota_probe_command=quota_probe_command,
|
|
||||||
failure_class=failure_class,
|
|
||||||
)
|
|
||||||
raise SelectorInputError(
|
|
||||||
"invalid_transition", f"unknown transition: {transition!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def select_execution_target(
|
def select_execution_target(
|
||||||
task_file: Path,
|
task_file: Path,
|
||||||
*,
|
*,
|
||||||
|
|
@ -1382,7 +1503,8 @@ def select_execution_target(
|
||||||
quota_probe_command: str = DEFAULT_QUOTA_PROBE_COMMAND,
|
quota_probe_command: str = DEFAULT_QUOTA_PROBE_COMMAND,
|
||||||
failure_class: str | None = None,
|
failure_class: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Parse one task file and return a stable selector decision."""
|
"""Return the stable JSON-serializable selector decision for one call."""
|
||||||
|
|
||||||
kind, lane, grade = _parse_filename(task_file)
|
kind, lane, grade = _parse_filename(task_file)
|
||||||
prefix_stage = _STAGE_BY_KIND[kind]
|
prefix_stage = _STAGE_BY_KIND[kind]
|
||||||
if stage is not None and stage != prefix_stage:
|
if stage is not None and stage != prefix_stage:
|
||||||
|
|
@ -1390,17 +1512,53 @@ def select_execution_target(
|
||||||
"stage_mismatch",
|
"stage_mismatch",
|
||||||
f"explicit stage {stage!r} conflicts with filename stage {prefix_stage!r}",
|
f"explicit stage {stage!r} conflicts with filename stage {prefix_stage!r}",
|
||||||
)
|
)
|
||||||
return select_execution_target_for_route(
|
resolved_stage = stage or prefix_stage
|
||||||
work_unit_id=_work_unit_id(_parse_header(task_file)),
|
|
||||||
stage=stage or prefix_stage,
|
header = _parse_header(task_file)
|
||||||
lane=lane,
|
work_unit_id = _work_unit_id(header)
|
||||||
grade=grade,
|
_validate_evaluated_at(evaluated_at)
|
||||||
evaluated_at=evaluated_at,
|
quota_snapshot = _validate_quota_snapshot(quota_snapshot)
|
||||||
transition=transition,
|
if not isinstance(quota_probe_command, str) or not quota_probe_command:
|
||||||
prior_decision=prior_decision,
|
raise SelectorInputError(
|
||||||
quota_snapshot=quota_snapshot,
|
"invalid_quota_probe_command",
|
||||||
quota_probe_command=quota_probe_command,
|
"quota_probe_command must be a non-empty string",
|
||||||
failure_class=failure_class,
|
)
|
||||||
|
|
||||||
|
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":
|
||||||
|
return _failover(
|
||||||
|
prior_decision, 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,
|
||||||
|
failure_class=failure_class,
|
||||||
|
)
|
||||||
|
if transition == "promotion":
|
||||||
|
return _promotion(
|
||||||
|
prior_decision, 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,
|
||||||
|
failure_class=failure_class,
|
||||||
|
)
|
||||||
|
raise SelectorInputError(
|
||||||
|
"invalid_transition", f"unknown transition: {transition!r}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -558,7 +558,7 @@ class CompletingTargetSelfcheckTest(unittest.IsolatedAsyncioTestCase):
|
||||||
"""Verify selfcheck is determined by the completing decision's execution_class.
|
"""Verify selfcheck is determined by the completing decision's execution_class.
|
||||||
|
|
||||||
- Worker success persists the actual completing decision with execution_class.
|
- Worker success persists the actual completing decision with execution_class.
|
||||||
- target-configured selfcheck stages schedule independently.
|
- selfcheck schedules exactly once when execution_class=local_model.
|
||||||
- local selfcheck reuses the completing target without re-evaluating selector.
|
- local selfcheck reuses the completing target without re-evaluating selector.
|
||||||
- Gemini→Laguna, Laguna→Gemini, cloud completions follow the policy.
|
- Gemini→Laguna, Laguna→Gemini, cloud completions follow the policy.
|
||||||
- Restart does not duplicate selfcheck execution.
|
- Restart does not duplicate selfcheck execution.
|
||||||
|
|
@ -745,8 +745,8 @@ class CompletingTargetSelfcheckTest(unittest.IsolatedAsyncioTestCase):
|
||||||
finally:
|
finally:
|
||||||
store.close()
|
store.close()
|
||||||
|
|
||||||
async def test_local_completing_decision_runs_two_selfcheck_stages(self):
|
async def test_local_completing_decision_triggers_selfcheck(self):
|
||||||
"""Pi runs full-review and checklist-review as separate stages."""
|
"""execution_class=local_model schedules exactly one selfcheck."""
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
workspace = Path(temporary)
|
workspace = Path(temporary)
|
||||||
(workspace / ".git").mkdir()
|
(workspace / ".git").mkdir()
|
||||||
|
|
@ -783,7 +783,7 @@ class CompletingTargetSelfcheckTest(unittest.IsolatedAsyncioTestCase):
|
||||||
dispatch.completing_decision_requires_selfcheck(state)
|
dispatch.completing_decision_requires_selfcheck(state)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Full review and checklist review are separate scheduler entries.
|
# Run selfcheck once
|
||||||
with (
|
with (
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
dispatch, "run_escalating",
|
dispatch, "run_escalating",
|
||||||
|
|
@ -797,20 +797,14 @@ class CompletingTargetSelfcheckTest(unittest.IsolatedAsyncioTestCase):
|
||||||
await dispatch.run_selfcheck(
|
await dispatch.run_selfcheck(
|
||||||
workspace, store, task
|
workspace, store, task
|
||||||
)
|
)
|
||||||
self.assertTrue(
|
|
||||||
store.task_state(task)["selfcheck_full_review_done"]
|
|
||||||
)
|
|
||||||
self.assertFalse(store.task_state(task)["selfcheck_done"])
|
|
||||||
await dispatch.run_selfcheck(workspace, store, task)
|
|
||||||
|
|
||||||
state2 = store.task_state(task)
|
state2 = store.task_state(task)
|
||||||
self.assertTrue(state2["selfcheck_done"])
|
self.assertTrue(state2["selfcheck_done"])
|
||||||
self.assertTrue(state2["selfcheck_checklist_review_done"])
|
|
||||||
self.assertEqual(dispatch.task_stage(task, state2), "review")
|
self.assertEqual(dispatch.task_stage(task, state2), "review")
|
||||||
finally:
|
finally:
|
||||||
store.close()
|
store.close()
|
||||||
|
|
||||||
async def test_claude_glm_completion_runs_checklist_only(self):
|
async def test_claude_glm_completion_skips_selfcheck(self):
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
workspace = Path(temporary)
|
workspace = Path(temporary)
|
||||||
(workspace / ".git").mkdir()
|
(workspace / ".git").mkdir()
|
||||||
|
|
@ -838,194 +832,14 @@ class CompletingTargetSelfcheckTest(unittest.IsolatedAsyncioTestCase):
|
||||||
blocked=None,
|
blocked=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertTrue(
|
self.assertFalse(
|
||||||
dispatch.completing_decision_requires_selfcheck(
|
dispatch.completing_decision_requires_selfcheck(
|
||||||
store.task_state(task)
|
store.task_state(task)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
dispatch.task_stage(task, store.task_state(task)),
|
dispatch.task_stage(task, store.task_state(task)),
|
||||||
"selfcheck",
|
"review",
|
||||||
)
|
|
||||||
stages = dispatch.completing_decision_selfcheck_stages(
|
|
||||||
store.task_state(task)
|
|
||||||
)
|
|
||||||
self.assertFalse(stages.full_review)
|
|
||||||
self.assertTrue(stages.checklist_review)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
async def test_opencode_glm_runs_checklist_with_completing_worker_target(self):
|
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
|
||||||
workspace = Path(temporary)
|
|
||||||
(workspace / ".git").mkdir()
|
|
||||||
task = self.make_task(workspace, lane="cloud", grade=3)
|
|
||||||
store = dispatch.StateStore(workspace)
|
|
||||||
decision = {
|
|
||||||
"work_unit_id": self._WORK_UNIT_ID,
|
|
||||||
"stage": "worker",
|
|
||||||
"selected": {
|
|
||||||
"target_id": "opencode-glm-high",
|
|
||||||
"adapter": "opencode",
|
|
||||||
"target": "glm-5.2",
|
|
||||||
"command_model": "iop-glm/glm-5.2",
|
|
||||||
"reasoning_effort": "high",
|
|
||||||
"execution_class": "cloud_model",
|
|
||||||
"selfcheck_required": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
store.update_task(
|
|
||||||
task,
|
|
||||||
worker_done=True,
|
|
||||||
worker_cli="opencode",
|
|
||||||
worker_model="glm-5.2",
|
|
||||||
completing_decision=decision,
|
|
||||||
execution_class="cloud_model",
|
|
||||||
selfcheck_done=False,
|
|
||||||
blocked=None,
|
|
||||||
)
|
|
||||||
locator = self.make_locator(workspace, "opencode", "glm-5.2")
|
|
||||||
try:
|
|
||||||
with (
|
|
||||||
mock.patch.object(
|
|
||||||
dispatch,
|
|
||||||
"run_escalating",
|
|
||||||
new=mock.AsyncMock(return_value=(True, locator)),
|
|
||||||
) as run_escalating,
|
|
||||||
mock.patch.object(
|
|
||||||
dispatch,
|
|
||||||
"implementation_review_errors",
|
|
||||||
side_effect=[["구현 체크리스트 미완료"], []],
|
|
||||||
),
|
|
||||||
):
|
|
||||||
await dispatch.run_selfcheck(workspace, store, task)
|
|
||||||
|
|
||||||
self.assertEqual(run_escalating.await_count, 1)
|
|
||||||
self.assertEqual(run_escalating.await_args.args[4].cli, "opencode")
|
|
||||||
self.assertTrue(run_escalating.await_args.kwargs["unchecked_items"])
|
|
||||||
state = store.task_state(task)
|
|
||||||
self.assertFalse(state["selfcheck_full_review_done"])
|
|
||||||
self.assertTrue(state["selfcheck_checklist_review_done"])
|
|
||||||
self.assertTrue(state["selfcheck_done"])
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
async def test_cloud_selfcheck_retries_without_promoting_completing_target(self):
|
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
|
||||||
workspace = Path(temporary)
|
|
||||||
(workspace / ".git").mkdir()
|
|
||||||
task = self.make_task(workspace, lane="cloud", grade=3)
|
|
||||||
store = dispatch.StateStore(workspace)
|
|
||||||
spec = dispatch.AgentSpec(
|
|
||||||
"opencode",
|
|
||||||
"glm-5.2",
|
|
||||||
"opencode/glm-5.2 high",
|
|
||||||
reasoning_effort="high",
|
|
||||||
command_model="iop-glm/glm-5.2",
|
|
||||||
)
|
|
||||||
first_locator = self.make_locator(
|
|
||||||
workspace,
|
|
||||||
"opencode-first",
|
|
||||||
"glm-5.2",
|
|
||||||
)
|
|
||||||
second_locator = self.make_locator(
|
|
||||||
workspace,
|
|
||||||
"opencode-second",
|
|
||||||
"glm-5.2",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with (
|
|
||||||
mock.patch.object(
|
|
||||||
dispatch,
|
|
||||||
"invoke",
|
|
||||||
new=mock.AsyncMock(
|
|
||||||
side_effect=[
|
|
||||||
(1, "provider-quota", first_locator),
|
|
||||||
(0, None, second_locator),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
) as invoke,
|
|
||||||
mock.patch.object(
|
|
||||||
dispatch.asyncio,
|
|
||||||
"sleep",
|
|
||||||
new=mock.AsyncMock(),
|
|
||||||
),
|
|
||||||
mock.patch.object(
|
|
||||||
dispatch,
|
|
||||||
"promoted_spec",
|
|
||||||
side_effect=AssertionError(
|
|
||||||
"selfcheck must not promote its completing target"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
):
|
|
||||||
success, locator = await dispatch.run_escalating(
|
|
||||||
workspace,
|
|
||||||
store,
|
|
||||||
task,
|
|
||||||
"selfcheck",
|
|
||||||
spec,
|
|
||||||
unchecked_items=True,
|
|
||||||
recovery_state_key=(
|
|
||||||
dispatch.SELF_CHECK_CHECKLIST_REVIEW_FAILURE_KEY
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertTrue(success)
|
|
||||||
self.assertEqual(locator, second_locator)
|
|
||||||
self.assertEqual(invoke.await_count, 2)
|
|
||||||
self.assertEqual(
|
|
||||||
[call.args[4].cli for call in invoke.await_args_list],
|
|
||||||
["opencode", "opencode"],
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
async def test_selfcheck_steps_have_independent_recovery_budgets(self):
|
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
|
||||||
workspace = Path(temporary)
|
|
||||||
(workspace / ".git").mkdir()
|
|
||||||
task = self.make_task(workspace)
|
|
||||||
store = dispatch.StateStore(workspace)
|
|
||||||
store.update_task(
|
|
||||||
task,
|
|
||||||
recovery_failures={
|
|
||||||
dispatch.SELF_CHECK_FULL_REVIEW_FAILURE_KEY:
|
|
||||||
dispatch.RECOVERY_FAILURE_LIMIT,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
locator = self.make_locator(workspace, "pi", "laguna-s:2.1")
|
|
||||||
spec = dispatch.AgentSpec(
|
|
||||||
"pi",
|
|
||||||
"laguna-s:2.1",
|
|
||||||
"pi/iop/laguna-s:2.1",
|
|
||||||
local_pi=True,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
with mock.patch.object(
|
|
||||||
dispatch,
|
|
||||||
"invoke",
|
|
||||||
new=mock.AsyncMock(return_value=(0, None, locator)),
|
|
||||||
) as invoke:
|
|
||||||
success, _ = await dispatch.run_escalating(
|
|
||||||
workspace,
|
|
||||||
store,
|
|
||||||
task,
|
|
||||||
"selfcheck",
|
|
||||||
spec,
|
|
||||||
unchecked_items=True,
|
|
||||||
recovery_state_key=(
|
|
||||||
dispatch.SELF_CHECK_CHECKLIST_REVIEW_FAILURE_KEY
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertTrue(success)
|
|
||||||
self.assertEqual(invoke.await_count, 1)
|
|
||||||
self.assertEqual(
|
|
||||||
store.task_state(task)["recovery_failures"],
|
|
||||||
{
|
|
||||||
dispatch.SELF_CHECK_FULL_REVIEW_FAILURE_KEY:
|
|
||||||
dispatch.RECOVERY_FAILURE_LIMIT,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
store.close()
|
store.close()
|
||||||
|
|
@ -1155,7 +969,6 @@ class CompletingTargetSelfcheckTest(unittest.IsolatedAsyncioTestCase):
|
||||||
await dispatch.run_selfcheck(
|
await dispatch.run_selfcheck(
|
||||||
workspace, store, task
|
workspace, store, task
|
||||||
)
|
)
|
||||||
await dispatch.run_selfcheck(workspace, store, task)
|
|
||||||
|
|
||||||
# persisted_execution_decision must NOT be called during selfcheck
|
# persisted_execution_decision must NOT be called during selfcheck
|
||||||
self.assertEqual(len(selector_calls), 0)
|
self.assertEqual(len(selector_calls), 0)
|
||||||
|
|
@ -1190,8 +1003,6 @@ class CompletingTargetSelfcheckTest(unittest.IsolatedAsyncioTestCase):
|
||||||
completing_decision=local_decision,
|
completing_decision=local_decision,
|
||||||
execution_class="local_model",
|
execution_class="local_model",
|
||||||
selfcheck_done=True,
|
selfcheck_done=True,
|
||||||
selfcheck_full_review_done=True,
|
|
||||||
selfcheck_checklist_review_done=True,
|
|
||||||
blocked=None,
|
blocked=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1205,127 +1016,6 @@ class CompletingTargetSelfcheckTest(unittest.IsolatedAsyncioTestCase):
|
||||||
finally:
|
finally:
|
||||||
store.close()
|
store.close()
|
||||||
|
|
||||||
async def test_legacy_partial_selfcheck_resumes_at_checklist_stage(self):
|
|
||||||
"""Old incomplete state must not repeat its already-successful full pass."""
|
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
|
||||||
workspace = Path(temporary)
|
|
||||||
(workspace / ".git").mkdir()
|
|
||||||
task = self.make_task(workspace)
|
|
||||||
store = dispatch.StateStore(workspace)
|
|
||||||
decision = {
|
|
||||||
"work_unit_id": self._WORK_UNIT_ID,
|
|
||||||
"stage": "worker",
|
|
||||||
"selected": {
|
|
||||||
"adapter": "pi",
|
|
||||||
"target": "iop/laguna-s:2.1",
|
|
||||||
"execution_class": "local_model",
|
|
||||||
"selfcheck_required": True,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
locator = self.make_locator(workspace, "pi", "laguna-s:2.1")
|
|
||||||
store.update_task(
|
|
||||||
task,
|
|
||||||
worker_done=True,
|
|
||||||
worker_cli="pi",
|
|
||||||
worker_model="laguna-s:2.1",
|
|
||||||
completing_decision=decision,
|
|
||||||
execution_class="local_model",
|
|
||||||
selfcheck_done=False,
|
|
||||||
selfcheck_incomplete=1,
|
|
||||||
selfcheck_context_locator=str(locator),
|
|
||||||
blocked=None,
|
|
||||||
)
|
|
||||||
legacy_state = store.task_state(task)
|
|
||||||
legacy_state.pop("selfcheck_full_review_done")
|
|
||||||
legacy_state.pop("selfcheck_checklist_review_done")
|
|
||||||
store.save()
|
|
||||||
try:
|
|
||||||
with (
|
|
||||||
mock.patch.object(
|
|
||||||
dispatch,
|
|
||||||
"run_escalating",
|
|
||||||
new=mock.AsyncMock(return_value=(True, locator)),
|
|
||||||
) as run_escalating,
|
|
||||||
mock.patch.object(
|
|
||||||
dispatch,
|
|
||||||
"implementation_review_errors",
|
|
||||||
side_effect=[["구현 체크리스트 미완료"], []],
|
|
||||||
),
|
|
||||||
):
|
|
||||||
await dispatch.run_selfcheck(
|
|
||||||
workspace,
|
|
||||||
store,
|
|
||||||
task,
|
|
||||||
resume_locator=locator,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(run_escalating.await_count, 1)
|
|
||||||
self.assertTrue(
|
|
||||||
run_escalating.await_args.kwargs["unchecked_items"]
|
|
||||||
)
|
|
||||||
state = store.task_state(task)
|
|
||||||
self.assertTrue(state["selfcheck_checklist_review_done"])
|
|
||||||
self.assertTrue(state["selfcheck_done"])
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
async def test_live_catalog_toggle_changes_the_next_stage(self):
|
|
||||||
"""A catalog-only switch applies to persisted work without source reload."""
|
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
|
||||||
workspace = Path(temporary)
|
|
||||||
(workspace / ".git").mkdir()
|
|
||||||
task = self.make_task(workspace, lane="cloud", grade=7)
|
|
||||||
store = dispatch.StateStore(workspace)
|
|
||||||
decision = {
|
|
||||||
"work_unit_id": self._WORK_UNIT_ID,
|
|
||||||
"stage": "worker",
|
|
||||||
"selected": {
|
|
||||||
"target_id": "codex-sol-xhigh",
|
|
||||||
"adapter": "codex",
|
|
||||||
"target": "gpt-5.6-sol",
|
|
||||||
"reasoning_effort": "xhigh",
|
|
||||||
"execution_class": "cloud_model",
|
|
||||||
"selfcheck_required": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
store.update_task(
|
|
||||||
task,
|
|
||||||
worker_done=True,
|
|
||||||
worker_cli="codex",
|
|
||||||
worker_model="gpt-5.6-sol",
|
|
||||||
completing_decision=decision,
|
|
||||||
execution_class="cloud_model",
|
|
||||||
selfcheck_done=True,
|
|
||||||
blocked=None,
|
|
||||||
)
|
|
||||||
policy = dispatch._selector_module().policy
|
|
||||||
data = json.loads(policy.CATALOG_PATH.read_text(encoding="utf-8"))
|
|
||||||
data["targets"]["codex-sol-xhigh"]["selfcheck"] = {
|
|
||||||
"full_review": True,
|
|
||||||
"checklist_review": False,
|
|
||||||
}
|
|
||||||
catalog_path = workspace / "execution-target-catalog.json"
|
|
||||||
catalog_path.write_text(json.dumps(data), encoding="utf-8")
|
|
||||||
try:
|
|
||||||
self.assertEqual(
|
|
||||||
dispatch.task_stage(task, store.task_state(task)),
|
|
||||||
"review",
|
|
||||||
)
|
|
||||||
reloaded = policy.reload_catalog(catalog_path)
|
|
||||||
stages = dispatch.completing_decision_selfcheck_stages(
|
|
||||||
store.task_state(task)
|
|
||||||
)
|
|
||||||
self.assertTrue(stages.full_review)
|
|
||||||
self.assertFalse(stages.checklist_review)
|
|
||||||
self.assertEqual(stages.catalog_revision, reloaded.revision)
|
|
||||||
self.assertEqual(
|
|
||||||
dispatch.task_stage(task, store.task_state(task)),
|
|
||||||
"selfcheck",
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
policy.reload_catalog()
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
async def test_identity_mismatch_fails_closed(self):
|
async def test_identity_mismatch_fails_closed(self):
|
||||||
"""Missing or malformed completing decision blocks selfcheck."""
|
"""Missing or malformed completing decision blocks selfcheck."""
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
|
|
@ -1350,7 +1040,6 @@ class CompletingTargetSelfcheckTest(unittest.IsolatedAsyncioTestCase):
|
||||||
await dispatch.run_selfcheck(
|
await dispatch.run_selfcheck(
|
||||||
workspace, store, task
|
workspace, store, task
|
||||||
)
|
)
|
||||||
await dispatch.run_selfcheck(workspace, store, task)
|
|
||||||
|
|
||||||
self.assertEqual(run_escalating.await_count, 0)
|
self.assertEqual(run_escalating.await_count, 0)
|
||||||
state = store.task_state(task)
|
state = store.task_state(task)
|
||||||
|
|
@ -1526,7 +1215,6 @@ class CompletingTargetSelfcheckTest(unittest.IsolatedAsyncioTestCase):
|
||||||
await dispatch.run_selfcheck(
|
await dispatch.run_selfcheck(
|
||||||
workspace, store, task
|
workspace, store, task
|
||||||
)
|
)
|
||||||
await dispatch.run_selfcheck(workspace, store, task)
|
|
||||||
|
|
||||||
self.assertEqual(len(selector_select_calls), 0)
|
self.assertEqual(len(selector_select_calls), 0)
|
||||||
self.assertEqual(len(quota_probe_calls), 0)
|
self.assertEqual(len(quota_probe_calls), 0)
|
||||||
|
|
@ -4162,15 +3850,10 @@ class ReviewControlTest(unittest.TestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
unchecked_retry,
|
unchecked_retry,
|
||||||
f"{dispatch.SELF_CHECK_PROMPT_PREFIX} Read "
|
"The code review file is incomplete! Complete it now!",
|
||||||
f"{task.review.resolve()}. Review only its Implementation "
|
|
||||||
"Checklist section. Mark every completed item, finish any "
|
|
||||||
"missing implementation or evidence required by those items, "
|
|
||||||
"and leave all official-review-only sections untouched. Keep "
|
|
||||||
"files in English.",
|
|
||||||
)
|
)
|
||||||
self.assertNotIn(str(task.plan.resolve()), unchecked_retry)
|
self.assertNotIn(str(task.plan.resolve()), unchecked_retry)
|
||||||
self.assertIn(str(task.review.resolve()), unchecked_retry)
|
self.assertNotIn(str(task.review.resolve()), unchecked_retry)
|
||||||
self.assertNotIn("dispatcher child", selfcheck.lower())
|
self.assertNotIn("dispatcher child", selfcheck.lower())
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
dispatch.continuation_prompt(
|
dispatch.continuation_prompt(
|
||||||
|
|
@ -5029,7 +4712,6 @@ class RepetitionLimitTest(unittest.IsolatedAsyncioTestCase):
|
||||||
}
|
}
|
||||||
store.update_task(
|
store.update_task(
|
||||||
task,
|
task,
|
||||||
selfcheck_full_review_done=True,
|
|
||||||
selfcheck_incomplete=(
|
selfcheck_incomplete=(
|
||||||
dispatch.SELF_CHECK_UNCHECKED_RETRY_LIMIT + 1
|
dispatch.SELF_CHECK_UNCHECKED_RETRY_LIMIT + 1
|
||||||
),
|
),
|
||||||
|
|
@ -5087,7 +4769,6 @@ class RepetitionLimitTest(unittest.IsolatedAsyncioTestCase):
|
||||||
}
|
}
|
||||||
store.update_task(
|
store.update_task(
|
||||||
task,
|
task,
|
||||||
selfcheck_full_review_done=True,
|
|
||||||
selfcheck_incomplete=dispatch.SELF_CHECK_UNCHECKED_RETRY_LIMIT,
|
selfcheck_incomplete=dispatch.SELF_CHECK_UNCHECKED_RETRY_LIMIT,
|
||||||
completing_decision=completing_decision,
|
completing_decision=completing_decision,
|
||||||
)
|
)
|
||||||
|
|
@ -5156,7 +4837,6 @@ class RepetitionLimitTest(unittest.IsolatedAsyncioTestCase):
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
await dispatch.run_selfcheck(root, store, task)
|
await dispatch.run_selfcheck(root, store, task)
|
||||||
await dispatch.run_selfcheck(root, store, task)
|
|
||||||
|
|
||||||
self.assertEqual(run_escalating.await_count, 2)
|
self.assertEqual(run_escalating.await_count, 2)
|
||||||
self.assertFalse(
|
self.assertFalse(
|
||||||
|
|
@ -5174,7 +4854,7 @@ class RepetitionLimitTest(unittest.IsolatedAsyncioTestCase):
|
||||||
run_escalating.await_args_list[1].kwargs[
|
run_escalating.await_args_list[1].kwargs[
|
||||||
"initial_resume_locator"
|
"initial_resume_locator"
|
||||||
],
|
],
|
||||||
None,
|
locators[0],
|
||||||
)
|
)
|
||||||
state = store.task_state(task)
|
state = store.task_state(task)
|
||||||
self.assertTrue(state["selfcheck_done"])
|
self.assertTrue(state["selfcheck_done"])
|
||||||
|
|
@ -5221,7 +4901,6 @@ class RepetitionLimitTest(unittest.IsolatedAsyncioTestCase):
|
||||||
store.update_task(
|
store.update_task(
|
||||||
task,
|
task,
|
||||||
completing_decision=completing_decision,
|
completing_decision=completing_decision,
|
||||||
selfcheck_full_review_done=True,
|
|
||||||
selfcheck_incomplete=1,
|
selfcheck_incomplete=1,
|
||||||
selfcheck_context_locator=str(locator),
|
selfcheck_context_locator=str(locator),
|
||||||
)
|
)
|
||||||
|
|
@ -5234,9 +4913,7 @@ class RepetitionLimitTest(unittest.IsolatedAsyncioTestCase):
|
||||||
new=mock.AsyncMock(return_value=(True, retry_locator)),
|
new=mock.AsyncMock(return_value=(True, retry_locator)),
|
||||||
) as run_escalating,
|
) as run_escalating,
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
dispatch,
|
dispatch, "implementation_review_errors", return_value=[]
|
||||||
"implementation_review_errors",
|
|
||||||
side_effect=[["구현 체크리스트 미완료"], []],
|
|
||||||
),
|
),
|
||||||
):
|
):
|
||||||
await dispatch.run_selfcheck(root, store, task)
|
await dispatch.run_selfcheck(root, store, task)
|
||||||
|
|
@ -5271,7 +4948,6 @@ class RepetitionLimitTest(unittest.IsolatedAsyncioTestCase):
|
||||||
store.update_task(
|
store.update_task(
|
||||||
task,
|
task,
|
||||||
completing_decision=completing_decision,
|
completing_decision=completing_decision,
|
||||||
selfcheck_full_review_done=True,
|
|
||||||
selfcheck_incomplete=1,
|
selfcheck_incomplete=1,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
|
|
@ -5304,11 +4980,7 @@ class RepetitionLimitTest(unittest.IsolatedAsyncioTestCase):
|
||||||
"selfcheck_required": True,
|
"selfcheck_required": True,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
store.update_task(
|
store.update_task(task, completing_decision=completing_decision)
|
||||||
task,
|
|
||||||
completing_decision=completing_decision,
|
|
||||||
selfcheck_full_review_done=True,
|
|
||||||
)
|
|
||||||
locator = root / "locator.json"
|
locator = root / "locator.json"
|
||||||
try:
|
try:
|
||||||
with (
|
with (
|
||||||
|
|
@ -5329,10 +5001,13 @@ class RepetitionLimitTest(unittest.IsolatedAsyncioTestCase):
|
||||||
run_escalating.await_count,
|
run_escalating.await_count,
|
||||||
1 + dispatch.SELF_CHECK_UNCHECKED_RETRY_LIMIT,
|
1 + dispatch.SELF_CHECK_UNCHECKED_RETRY_LIMIT,
|
||||||
)
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
run_escalating.await_args_list[0].kwargs["unchecked_items"]
|
||||||
|
)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
all(
|
all(
|
||||||
call.kwargs["unchecked_items"]
|
call.kwargs["unchecked_items"]
|
||||||
for call in run_escalating.await_args_list
|
for call in run_escalating.await_args_list[1:]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
|
|
@ -8393,52 +8068,6 @@ class RouteDecisionPersistenceTest(unittest.TestCase):
|
||||||
)
|
)
|
||||||
return next(task for task in dispatch.scan_tasks(workspace, None) if task.name == name)
|
return next(task for task in dispatch.scan_tasks(workspace, None) if task.name == name)
|
||||||
|
|
||||||
def test_catalog_target_edit_keeps_running_task_spec_snapshot(self):
|
|
||||||
evaluated = datetime(2026, 7, 26, 14, 0, tzinfo=dispatch.KST)
|
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
|
||||||
workspace = Path(temporary)
|
|
||||||
(workspace / ".git").mkdir()
|
|
||||||
task = self.make_task(workspace, lane="cloud", grade=3)
|
|
||||||
selector = dispatch._selector_module()
|
|
||||||
quota = {
|
|
||||||
"schema_version": "1.0",
|
|
||||||
"snapshot_id": "catalog-pin-test",
|
|
||||||
"source": "test",
|
|
||||||
"checked_at": evaluated.isoformat(),
|
|
||||||
"targets": [],
|
|
||||||
"required_caps": [],
|
|
||||||
"reason_codes": [],
|
|
||||||
}
|
|
||||||
initial = dispatch.select_execution_decision(
|
|
||||||
task,
|
|
||||||
stage="worker",
|
|
||||||
evaluated_at=evaluated,
|
|
||||||
quota_snapshot=quota,
|
|
||||||
)
|
|
||||||
data = json.loads(
|
|
||||||
selector.policy.CATALOG_PATH.read_text(encoding="utf-8")
|
|
||||||
)
|
|
||||||
data["targets"]["agy-gemini-medium"]["target"] = (
|
|
||||||
"Gemini replacement model"
|
|
||||||
)
|
|
||||||
catalog_path = workspace / "changed-catalog.json"
|
|
||||||
catalog_path.write_text(json.dumps(data), encoding="utf-8")
|
|
||||||
changed = selector.policy.load_catalog(catalog_path)
|
|
||||||
|
|
||||||
with mock.patch.object(selector.policy, "CATALOG", changed):
|
|
||||||
resumed = dispatch.select_execution_decision(
|
|
||||||
task,
|
|
||||||
stage="worker",
|
|
||||||
evaluated_at=evaluated,
|
|
||||||
transition="resume",
|
|
||||||
prior_decision=initial,
|
|
||||||
)
|
|
||||||
spec = dispatch.agent_spec_from_decision(resumed)
|
|
||||||
|
|
||||||
self.assertTrue(resumed["decision"]["pinned"])
|
|
||||||
self.assertEqual(spec.cli, "agy")
|
|
||||||
self.assertEqual(spec.model, "Gemini 3.6 Flash (Medium)")
|
|
||||||
|
|
||||||
def test_reopen_body_edit_and_generation_reset_preserve_or_reset_pin(self):
|
def test_reopen_body_edit_and_generation_reset_preserve_or_reset_pin(self):
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
workspace = Path(temporary)
|
workspace = Path(temporary)
|
||||||
|
|
@ -8470,7 +8099,7 @@ class RouteDecisionPersistenceTest(unittest.TestCase):
|
||||||
reopened, reopened_task, stage="worker"
|
reopened, reopened_task, stage="worker"
|
||||||
)
|
)
|
||||||
self.assertEqual(resumed["transition"]["trigger"], "resume")
|
self.assertEqual(resumed["transition"]["trigger"], "resume")
|
||||||
self.assertEqual(resumed_spec.display, "pi/iop/ornith:35b high")
|
self.assertEqual(resumed_spec.display, "pi/iop/ornith:35b")
|
||||||
|
|
||||||
assert reopened_task.plan is not None
|
assert reopened_task.plan is not None
|
||||||
reopened_task.plan.write_text(
|
reopened_task.plan.write_text(
|
||||||
|
|
@ -8566,10 +8195,6 @@ class RouteDecisionPersistenceTest(unittest.TestCase):
|
||||||
"adapter": "claude",
|
"adapter": "claude",
|
||||||
"target": "claude-opus-4-8",
|
"target": "claude-opus-4-8",
|
||||||
"status": "exhausted",
|
"status": "exhausted",
|
||||||
}, {
|
|
||||||
"adapter": "codex",
|
|
||||||
"target": "gpt-5.6-terra",
|
|
||||||
"status": "exhausted",
|
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
@ -8670,13 +8295,7 @@ class DispatcherConvergenceSimulationTest(unittest.IsolatedAsyncioTestCase):
|
||||||
enter("selfcheck", task.name)
|
enter("selfcheck", task.name)
|
||||||
try:
|
try:
|
||||||
await asyncio.sleep(0.005)
|
await asyncio.sleep(0.005)
|
||||||
store.update_task(
|
store.update_task(task, selfcheck_done=True, blocked=None)
|
||||||
task,
|
|
||||||
selfcheck_done=True,
|
|
||||||
selfcheck_full_review_done=True,
|
|
||||||
selfcheck_checklist_review_done=True,
|
|
||||||
blocked=None,
|
|
||||||
)
|
|
||||||
finally:
|
finally:
|
||||||
leave("selfcheck", task.name)
|
leave("selfcheck", task.name)
|
||||||
|
|
||||||
|
|
@ -9232,11 +8851,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
decision["used_candidates"],
|
decision["used_candidates"],
|
||||||
[
|
[
|
||||||
{
|
{"adapter": "codex", "target": "gpt-5.3-codex-spark"},
|
||||||
"adapter": "codex",
|
|
||||||
"target": "gpt-5.3-codex-spark",
|
|
||||||
"reasoning_effort": "xhigh",
|
|
||||||
},
|
|
||||||
{"adapter": "agy", "target": "Gemini 3.6 Flash (Low)"},
|
{"adapter": "agy", "target": "Gemini 3.6 Flash (Low)"},
|
||||||
{
|
{
|
||||||
"adapter": "opencode",
|
"adapter": "opencode",
|
||||||
|
|
@ -9248,106 +8863,6 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
||||||
finally:
|
finally:
|
||||||
store.close()
|
store.close()
|
||||||
|
|
||||||
async def test_archived_review_recovery_uses_review_lane_fallback(self):
|
|
||||||
evaluated = datetime(2026, 7, 26, 14, 0, tzinfo=dispatch.KST)
|
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
|
||||||
workspace = Path(temporary)
|
|
||||||
(workspace / ".git").mkdir()
|
|
||||||
active = self.make_task(workspace, lane="local", grade=8)
|
|
||||||
assert active.plan is not None and active.review is not None
|
|
||||||
active.review.write_text(
|
|
||||||
active.review.read_text(encoding="utf-8")
|
|
||||||
+ "\n## 코드리뷰 결과\n- 종합 판정: FAIL\n",
|
|
||||||
encoding="utf-8",
|
|
||||||
)
|
|
||||||
active.plan.rename(active.directory / "plan_local_G08_0.log")
|
|
||||||
active.review.rename(
|
|
||||||
active.directory / "code_review_local_G08_0.log"
|
|
||||||
)
|
|
||||||
task = next(
|
|
||||||
item
|
|
||||||
for item in dispatch.scan_tasks(workspace, None)
|
|
||||||
if item.name == active.name
|
|
||||||
)
|
|
||||||
self.assertTrue(task.recovery)
|
|
||||||
self.assertIsNone(task.plan)
|
|
||||||
self.assertIsNone(task.review)
|
|
||||||
|
|
||||||
selector = dispatch._selector_module()
|
|
||||||
data = json.loads(
|
|
||||||
selector.policy.CATALOG_PATH.read_text(encoding="utf-8")
|
|
||||||
)
|
|
||||||
data["lanes"]["review"]["local-G08"]["candidates"] = [
|
|
||||||
"codex-sol-xhigh",
|
|
||||||
"claude-haiku-xhigh",
|
|
||||||
]
|
|
||||||
catalog_path = workspace / "review-catalog.json"
|
|
||||||
catalog_path.write_text(json.dumps(data), encoding="utf-8")
|
|
||||||
changed = selector.policy.load_catalog(catalog_path)
|
|
||||||
store = dispatch.StateStore(workspace)
|
|
||||||
try:
|
|
||||||
with mock.patch.object(selector.policy, "CATALOG", changed):
|
|
||||||
decision, initial_spec = dispatch.persisted_execution_decision(
|
|
||||||
store,
|
|
||||||
task,
|
|
||||||
stage="review",
|
|
||||||
evaluated_at=evaluated,
|
|
||||||
)
|
|
||||||
self.assertEqual(len(decision["candidates"]), 2)
|
|
||||||
locators = {
|
|
||||||
"codex": self.make_attempt_locator(
|
|
||||||
workspace, task, initial_spec
|
|
||||||
),
|
|
||||||
"claude": self.make_attempt_locator(
|
|
||||||
workspace,
|
|
||||||
task,
|
|
||||||
dispatch.AgentSpec(
|
|
||||||
"claude",
|
|
||||||
"claude-haiku-4-5",
|
|
||||||
"claude/claude-haiku-4-5 xhigh",
|
|
||||||
reasoning_effort="xhigh",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
invoked = []
|
|
||||||
|
|
||||||
async def fake_invoke(*args, **kwargs):
|
|
||||||
spec = args[4]
|
|
||||||
invoked.append(spec)
|
|
||||||
if spec.cli == "codex":
|
|
||||||
return 1, "provider-quota", locators["codex"]
|
|
||||||
return 0, None, locators["claude"]
|
|
||||||
|
|
||||||
with (
|
|
||||||
mock.patch.object(dispatch, "invoke", new=fake_invoke),
|
|
||||||
mock.patch.object(
|
|
||||||
dispatch,
|
|
||||||
"build_context_package",
|
|
||||||
side_effect=AssertionError(
|
|
||||||
"review fallback must restart from review artifacts"
|
|
||||||
),
|
|
||||||
),
|
|
||||||
mock.patch.object(
|
|
||||||
dispatch.asyncio, "sleep", new=mock.AsyncMock()
|
|
||||||
),
|
|
||||||
):
|
|
||||||
success, locator = await dispatch.run_escalating(
|
|
||||||
workspace,
|
|
||||||
store,
|
|
||||||
task,
|
|
||||||
"review",
|
|
||||||
initial_spec,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertTrue(success)
|
|
||||||
self.assertEqual(locator, locators["claude"])
|
|
||||||
self.assertEqual([spec.cli for spec in invoked], ["codex", "claude"])
|
|
||||||
selected = store.task_state(task)["execution_decisions"]["review"]
|
|
||||||
self.assertEqual(selected["selected"]["target_id"], "claude-haiku-xhigh")
|
|
||||||
self.assertEqual(selected["transition"]["trigger"], "provider-quota")
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
async def test_invalid_logical_context_does_not_commit_or_promote(self):
|
async def test_invalid_logical_context_does_not_commit_or_promote(self):
|
||||||
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||||||
|
|
||||||
|
|
@ -9470,7 +8985,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
||||||
finally:
|
finally:
|
||||||
store.close()
|
store.close()
|
||||||
|
|
||||||
async def test_cloud_g07_provider_quota_follows_lane_array_to_codex(self):
|
async def test_cloud_g07_provider_quota_promotes_claude_to_codex_without_no_failover_block(self):
|
||||||
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
workspace = Path(temporary)
|
workspace = Path(temporary)
|
||||||
|
|
@ -9539,7 +9054,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
||||||
decision = state["execution_decisions"]["worker"]
|
decision = state["execution_decisions"]["worker"]
|
||||||
self.assertEqual(decision["selected"]["adapter"], "codex")
|
self.assertEqual(decision["selected"]["adapter"], "codex")
|
||||||
self.assertEqual(decision["selected"]["target"], "gpt-5.6-terra")
|
self.assertEqual(decision["selected"]["target"], "gpt-5.6-terra")
|
||||||
self.assertNotIn("kind", decision["transition"])
|
self.assertEqual(decision["transition"]["kind"], "promotion")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
decision["transition"]["trigger"], "provider-quota"
|
decision["transition"]["trigger"], "provider-quota"
|
||||||
)
|
)
|
||||||
|
|
@ -10484,8 +9999,8 @@ class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase):
|
||||||
self.assertTrue(task.recovery)
|
self.assertTrue(task.recovery)
|
||||||
store = dispatch.StateStore(workspace)
|
store = dispatch.StateStore(workspace)
|
||||||
try:
|
try:
|
||||||
# 1. Active review uses the PLAN generation/route and that
|
# 1. Active review uses the PLAN generation/route but the fixed
|
||||||
# review lane's catalog candidates in a canonical schema.
|
# official-review policy and a complete canonical schema.
|
||||||
dec_rev, spec_rev = dispatch.persisted_execution_decision(
|
dec_rev, spec_rev = dispatch.persisted_execution_decision(
|
||||||
store, task, stage="review", evaluated_at=daytime
|
store, task, stage="review", evaluated_at=daytime
|
||||||
)
|
)
|
||||||
|
|
@ -10494,13 +10009,12 @@ class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase):
|
||||||
self.assertEqual(dec_rev["lane"], "local")
|
self.assertEqual(dec_rev["lane"], "local")
|
||||||
self.assertEqual(dec_rev["grade"], 8)
|
self.assertEqual(dec_rev["grade"], 8)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
dec_rev["decision"]["rule_id"],
|
dec_rev["decision"]["rule_id"], "official-review-codex"
|
||||||
"review-local-g08-catalog",
|
|
||||||
)
|
)
|
||||||
self.assertEqual(dec_rev["decision"]["policy_priority"], 10)
|
self.assertEqual(dec_rev["decision"]["policy_priority"], 10)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
dec_rev["decision"]["reason_codes"],
|
dec_rev["decision"]["reason_codes"],
|
||||||
["review_catalog_lane"],
|
["official_review_fixed"],
|
||||||
)
|
)
|
||||||
self.assertEqual(dec_rev["decision"]["timezone"], "Asia/Seoul")
|
self.assertEqual(dec_rev["decision"]["timezone"], "Asia/Seoul")
|
||||||
self.assertFalse(dec_rev["decision"]["pinned"])
|
self.assertFalse(dec_rev["decision"]["pinned"])
|
||||||
|
|
@ -10510,7 +10024,7 @@ class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase):
|
||||||
"snapshot_id": None,
|
"snapshot_id": None,
|
||||||
"mode": "bounded",
|
"mode": "bounded",
|
||||||
"status": "unknown",
|
"status": "unknown",
|
||||||
"source": "official_review_catalog_policy",
|
"source": "official_review_fixed_policy",
|
||||||
"checked_at": None,
|
"checked_at": None,
|
||||||
"targets": [],
|
"targets": [],
|
||||||
},
|
},
|
||||||
|
|
@ -10542,8 +10056,8 @@ class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase):
|
||||||
self.assertEqual(reused, dec_rev)
|
self.assertEqual(reused, dec_rev)
|
||||||
self.assertEqual(reused_spec, spec_rev)
|
self.assertEqual(reused_spec, spec_rev)
|
||||||
|
|
||||||
# With one configured candidate, a qualified cloud failure
|
# A qualified cloud failure restarts the same fixed Codex target
|
||||||
# retries that candidate without selecting an unavailable next one.
|
# without selector failover, promotion, quota probe, or local CLI.
|
||||||
retry_locator = self.make_attempt_locator(
|
retry_locator = self.make_attempt_locator(
|
||||||
workspace, task, spec_rev
|
workspace, task, spec_rev
|
||||||
)
|
)
|
||||||
|
|
@ -10563,7 +10077,7 @@ class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase):
|
||||||
dispatch,
|
dispatch,
|
||||||
"select_execution_decision",
|
"select_execution_decision",
|
||||||
side_effect=AssertionError(
|
side_effect=AssertionError(
|
||||||
"single-candidate review recovery must not reselect"
|
"fixed review recovery must not reselect"
|
||||||
),
|
),
|
||||||
) as selector_mock,
|
) as selector_mock,
|
||||||
mock.patch.object(
|
mock.patch.object(
|
||||||
|
|
@ -10605,14 +10119,14 @@ class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase):
|
||||||
# 4. Audit consumers read canonical nested decision/quota and
|
# 4. Audit consumers read canonical nested decision/quota and
|
||||||
# only expose legacy flat fields through read-only fallback.
|
# only expose legacy flat fields through read-only fallback.
|
||||||
evidence = dispatch.selector_evidence_lines(dec_rev)
|
evidence = dispatch.selector_evidence_lines(dec_rev)
|
||||||
self.assertIn("rule_id=review-local-g08-catalog", evidence)
|
self.assertIn("rule_id=official-review-codex", evidence)
|
||||||
self.assertIn("priority=10", evidence)
|
self.assertIn("priority=10", evidence)
|
||||||
self.assertIn("transition=initial", evidence)
|
self.assertIn("transition=initial", evidence)
|
||||||
self.assertIn("quota_status=unknown", evidence)
|
self.assertIn("quota_status=unknown", evidence)
|
||||||
status = dispatch.status_lines(
|
status = dispatch.status_lines(
|
||||||
task, "review", "ready", decision=dec_rev
|
task, "review", "ready", decision=dec_rev
|
||||||
)
|
)
|
||||||
self.assertIn("rule_id=review-local-g08-catalog", status)
|
self.assertIn("rule_id=official-review-codex", status)
|
||||||
runtime_evidence = dispatch.selector_runtime_evidence(dec_rev)
|
runtime_evidence = dispatch.selector_runtime_evidence(dec_rev)
|
||||||
self.assertIn("decision", runtime_evidence)
|
self.assertIn("decision", runtime_evidence)
|
||||||
self.assertIn("quota", runtime_evidence)
|
self.assertIn("quota", runtime_evidence)
|
||||||
|
|
@ -10837,14 +10351,8 @@ class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase):
|
||||||
self.assertEqual([s.cli for s in invoked_specs], ["agy", "opencode"])
|
self.assertEqual([s.cli for s in invoked_specs], ["agy", "opencode"])
|
||||||
state = store.task_state(task)
|
state = store.task_state(task)
|
||||||
self.assertEqual(state["execution_class"], "cloud_model")
|
self.assertEqual(state["execution_class"], "cloud_model")
|
||||||
self.assertFalse(state["selfcheck_done"])
|
self.assertTrue(state["selfcheck_done"])
|
||||||
self.assertEqual(dispatch.task_stage(task, state), "selfcheck")
|
self.assertEqual(dispatch.task_stage(task, state), "review")
|
||||||
self.assertEqual(
|
|
||||||
state["selfcheck_config"]["checklist_review"], True
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
state["selfcheck_config"]["full_review"], False
|
|
||||||
)
|
|
||||||
self.assertEqual(state["execution_decisions"]["worker"]["selected"]["adapter"], "opencode")
|
self.assertEqual(state["execution_decisions"]["worker"]["selected"]["adapter"], "opencode")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
state["execution_decisions"]["worker"]["selected"]["reasoning_effort"],
|
state["execution_decisions"]["worker"]["selected"]["reasoning_effort"],
|
||||||
|
|
@ -10981,12 +10489,9 @@ class ThroughputQuotaBatchTest(unittest.TestCase):
|
||||||
batch_snap = dispatch.build_admission_batch_snapshot(store, ready, now)
|
batch_snap = dispatch.build_admission_batch_snapshot(store, ready, now)
|
||||||
|
|
||||||
self.assertIsNotNone(batch_snap)
|
self.assertIsNotNone(batch_snap)
|
||||||
# Every unique target in the shared lane is probed once across all tasks.
|
# Cloud G7 candidate target: claude/claude-opus-4-8
|
||||||
self.assertEqual(len(probe_calls), 2)
|
# Total unique probe keys = 1. Probed EXACTLY 1 time across all 3 tasks!
|
||||||
self.assertEqual(
|
self.assertEqual(len(probe_calls), 1)
|
||||||
len({(call["adapter"], call["target"]) for call in probe_calls}),
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Evaluate decisions for all tasks using batch_snap
|
# Evaluate decisions for all tasks using batch_snap
|
||||||
d1, _ = dispatch.persisted_execution_decision(store, t1, stage="worker", quota_snapshot=batch_snap)
|
d1, _ = dispatch.persisted_execution_decision(store, t1, stage="worker", quota_snapshot=batch_snap)
|
||||||
|
|
@ -11041,8 +10546,8 @@ class ThroughputQuotaBatchTest(unittest.TestCase):
|
||||||
batch_snap = dispatch.build_admission_batch_snapshot(store, ready, now)
|
batch_snap = dispatch.build_admission_batch_snapshot(store, ready, now)
|
||||||
|
|
||||||
self.assertIsNotNone(batch_snap)
|
self.assertIsNotNone(batch_snap)
|
||||||
# cloud-G07 contributes Claude/Terra and cloud-G09 adds Sol.
|
# Ensure no duplicate probes were called and exactly 2 unique targets were probed
|
||||||
self.assertEqual(len(probed_keys), 3)
|
self.assertEqual(len(probed_keys), 2)
|
||||||
self.assertEqual(len(probed_keys), len(set(probed_keys)))
|
self.assertEqual(len(probed_keys), len(set(probed_keys)))
|
||||||
|
|
||||||
d1, _ = dispatch.persisted_execution_decision(store, t1, stage="worker", quota_snapshot=batch_snap)
|
d1, _ = dispatch.persisted_execution_decision(store, t1, stage="worker", quota_snapshot=batch_snap)
|
||||||
|
|
@ -11125,77 +10630,14 @@ class ThroughputQuotaBatchTest(unittest.TestCase):
|
||||||
ready = [(t_night, "worker"), (t_review, "review")]
|
ready = [(t_night, "worker"), (t_review, "review")]
|
||||||
batch_snap = dispatch.build_admission_batch_snapshot(store, ready, now)
|
batch_snap = dispatch.build_admission_batch_snapshot(store, ready, now)
|
||||||
|
|
||||||
# Night local-G08 probes Gemini High plus Terra; the independent
|
# Night local-G08 probes Gemini High plus its Codex Terra backup.
|
||||||
# review lane contributes its configured target once.
|
# OpenCode GLM has no dedicated quota probe; review remains excluded.
|
||||||
self.assertIsNotNone(batch_snap)
|
self.assertIsNotNone(batch_snap)
|
||||||
self.assertEqual(len(probe_calls), 3)
|
self.assertEqual(len(probe_calls), 2)
|
||||||
self.assertEqual(probe_calls[0]["adapter"], "agy")
|
self.assertEqual(probe_calls[0]["adapter"], "agy")
|
||||||
self.assertEqual(probe_calls[0]["target"], "Gemini 3.6 Flash (High)")
|
self.assertEqual(probe_calls[0]["target"], "Gemini 3.6 Flash (High)")
|
||||||
self.assertEqual(probe_calls[1]["adapter"], "codex")
|
self.assertEqual(probe_calls[1]["adapter"], "codex")
|
||||||
self.assertEqual(probe_calls[1]["target"], "gpt-5.6-terra")
|
self.assertEqual(probe_calls[1]["target"], "gpt-5.6-terra")
|
||||||
self.assertEqual(probe_calls[2]["adapter"], "codex")
|
|
||||||
self.assertEqual(probe_calls[2]["target"], "gpt-5.6-sol")
|
|
||||||
finally:
|
|
||||||
store.close()
|
|
||||||
|
|
||||||
def test_review_batch_quota_selects_next_catalog_candidate(self):
|
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
|
||||||
workspace = Path(temporary)
|
|
||||||
(workspace / ".git").mkdir()
|
|
||||||
task = self.make_task(
|
|
||||||
workspace, "route/01_review", lane="local", grade=8
|
|
||||||
)
|
|
||||||
selector = dispatch._selector_module()
|
|
||||||
data = json.loads(
|
|
||||||
selector.policy.CATALOG_PATH.read_text(encoding="utf-8")
|
|
||||||
)
|
|
||||||
data["lanes"]["review"]["local-G08"]["candidates"] = [
|
|
||||||
"codex-sol-xhigh",
|
|
||||||
"claude-haiku-xhigh",
|
|
||||||
]
|
|
||||||
catalog_path = workspace / "review-catalog.json"
|
|
||||||
catalog_path.write_text(json.dumps(data), encoding="utf-8")
|
|
||||||
changed = selector.policy.load_catalog(catalog_path)
|
|
||||||
store = dispatch.StateStore(workspace)
|
|
||||||
|
|
||||||
def probe(*args, **kwargs):
|
|
||||||
adapter, target = kwargs["adapter"], kwargs["target"]
|
|
||||||
status = "exhausted" if adapter == "codex" else "available"
|
|
||||||
return {
|
|
||||||
"schema_version": "1.0",
|
|
||||||
"snapshot_id": f"review-{adapter}",
|
|
||||||
"source": "test",
|
|
||||||
"checked_at": kwargs["checked_at"].isoformat(),
|
|
||||||
"targets": [
|
|
||||||
{"adapter": adapter, "target": target, "status": status}
|
|
||||||
],
|
|
||||||
"required_caps": [],
|
|
||||||
"reason_codes": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
with (
|
|
||||||
mock.patch.object(selector.policy, "CATALOG", changed),
|
|
||||||
mock.patch.object(
|
|
||||||
selector, "probe_candidate_quota", side_effect=probe
|
|
||||||
) as probe_mock,
|
|
||||||
):
|
|
||||||
evaluated = datetime(2026, 7, 26, 14, 0, tzinfo=dispatch.KST)
|
|
||||||
snapshot = dispatch.build_admission_batch_snapshot(
|
|
||||||
store, [(task, "review")], evaluated
|
|
||||||
)
|
|
||||||
decision, spec = dispatch.persisted_execution_decision(
|
|
||||||
store,
|
|
||||||
task,
|
|
||||||
stage="review",
|
|
||||||
evaluated_at=evaluated,
|
|
||||||
quota_snapshot=snapshot,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(probe_mock.call_count, 2)
|
|
||||||
self.assertEqual(decision["selected"]["target_id"], "claude-haiku-xhigh")
|
|
||||||
self.assertEqual(decision["quota"]["status"], "available")
|
|
||||||
self.assertEqual(spec.cli, "claude")
|
|
||||||
finally:
|
finally:
|
||||||
store.close()
|
store.close()
|
||||||
|
|
||||||
|
|
@ -11579,23 +11021,16 @@ class ThroughputQuotaBatchTest(unittest.TestCase):
|
||||||
ready = [(t1, "worker")]
|
ready = [(t1, "worker")]
|
||||||
retry_batch_snap = dispatch.build_admission_batch_snapshot(store, ready, now)
|
retry_batch_snap = dispatch.build_admission_batch_snapshot(store, ready, now)
|
||||||
|
|
||||||
self.assertIsNotNone(retry_batch_snap)
|
self.assertIsNone(retry_batch_snap)
|
||||||
self.assertEqual(len(probe_calls), 1)
|
self.assertEqual(len(probe_calls), 0)
|
||||||
self.assertEqual(
|
|
||||||
(probe_calls[0]["adapter"], probe_calls[0]["target"]),
|
|
||||||
("codex", "gpt-5.6-terra"),
|
|
||||||
)
|
|
||||||
|
|
||||||
# The retry refresh observes the persisted unused lane alternate.
|
# With no persisted unused alternate, retry consumes no quota snapshot and resumes.
|
||||||
with mock.patch("subprocess.run", side_effect=AssertionError) as run:
|
with mock.patch("subprocess.run", side_effect=AssertionError) as run:
|
||||||
d, spec = dispatch.persisted_execution_decision(
|
d, spec = dispatch.persisted_execution_decision(
|
||||||
store, t1, stage="worker", quota_snapshot=retry_batch_snap
|
store, t1, stage="worker", quota_snapshot=retry_batch_snap
|
||||||
)
|
)
|
||||||
run.assert_not_called()
|
run.assert_not_called()
|
||||||
self.assertEqual(
|
self.assertEqual(store.task_state(t1).get("quota_snapshot")["snapshot_id"], "snap-initial")
|
||||||
store.task_state(t1).get("quota_snapshot")["snapshot_id"],
|
|
||||||
retry_batch_snap["snapshot_id"],
|
|
||||||
)
|
|
||||||
# retry context is preserved through decision commit so that
|
# retry context is preserved through decision commit so that
|
||||||
# invoke() can read handoff_id and atomically consume it.
|
# invoke() can read handoff_id and atomically consume it.
|
||||||
# In production run_worker() always calls invoke() after this.
|
# In production run_worker() always calls invoke() after this.
|
||||||
|
|
@ -11728,13 +11163,9 @@ class ThroughputQuotaBatchTest(unittest.TestCase):
|
||||||
res = await dispatch.dispatch_with_store(args, workspace, store)
|
res = await dispatch.dispatch_with_store(args, workspace, store)
|
||||||
|
|
||||||
run_sub.assert_not_called()
|
run_sub.assert_not_called()
|
||||||
self.assertEqual(
|
self.assertEqual(len(probe_calls), 1)
|
||||||
{(call["adapter"], call["target"]) for call in probe_calls},
|
self.assertEqual(probe_calls[0]["adapter"], "codex")
|
||||||
{
|
self.assertEqual(probe_calls[0]["target"], "gpt-5.6-terra")
|
||||||
("codex", "gpt-5.6-terra"),
|
|
||||||
("codex", "gpt-5.6-sol"),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
st_blocked_after = store.task_state(t_blocked)
|
st_blocked_after = store.task_state(t_blocked)
|
||||||
self.assertIsNone(st_blocked_after.get("blocked"))
|
self.assertIsNone(st_blocked_after.get("blocked"))
|
||||||
|
|
@ -13095,22 +12526,16 @@ class ArtifactLanguageContractTest(unittest.TestCase):
|
||||||
"pi_selfcheck_continuation",
|
"pi_selfcheck_continuation",
|
||||||
"pi_selfcheck_native_continuation",
|
"pi_selfcheck_native_continuation",
|
||||||
}
|
}
|
||||||
checklist_only_prompts = {
|
terse_unchecked_prompts = {
|
||||||
"selfcheck_unchecked",
|
"selfcheck_unchecked",
|
||||||
"pi_selfcheck_unchecked_continuation",
|
"pi_selfcheck_unchecked_continuation",
|
||||||
}
|
}
|
||||||
for name, prompt in prompts.items():
|
for name, prompt in prompts.items():
|
||||||
with self.subTest(prompt=name):
|
with self.subTest(prompt=name):
|
||||||
if name in checklist_only_prompts:
|
if name in terse_unchecked_prompts:
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
prompt,
|
prompt,
|
||||||
f"{dispatch.SELF_CHECK_PROMPT_PREFIX} Read "
|
"The code review file is incomplete! Complete it now!",
|
||||||
f"{task.review.resolve()}. Review only its "
|
|
||||||
"Implementation Checklist section. Mark every "
|
|
||||||
"completed item, finish any missing implementation "
|
|
||||||
"or evidence required by those items, and leave all "
|
|
||||||
"official-review-only sections untouched. Keep "
|
|
||||||
"files in English.",
|
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
self.assertTrue(
|
self.assertTrue(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
|
||||||
import sys
|
import sys
|
||||||
from tempfile import TemporaryDirectory
|
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
@ -25,139 +23,6 @@ def at_utc(hour: int, minute: int = 0, second: int = 0) -> datetime:
|
||||||
|
|
||||||
|
|
||||||
class ExecutionTargetPolicyTests(unittest.TestCase):
|
class ExecutionTargetPolicyTests(unittest.TestCase):
|
||||||
def test_catalog_defines_every_stage_lane_grade_independently(self):
|
|
||||||
expected = {
|
|
||||||
f"{lane}-G{grade:02d}"
|
|
||||||
for lane in policy.VALID_LANES
|
|
||||||
for grade in range(1, 11)
|
|
||||||
}
|
|
||||||
self.assertEqual(set(policy.CATALOG.lanes), policy.VALID_STAGES)
|
|
||||||
for stage in policy.VALID_STAGES:
|
|
||||||
with self.subTest(stage=stage):
|
|
||||||
self.assertEqual(set(policy.CATALOG.lanes[stage]), expected)
|
|
||||||
|
|
||||||
def test_changing_one_lane_candidate_array_requires_no_python_change(self):
|
|
||||||
data = json.loads(policy.CATALOG_PATH.read_text(encoding="utf-8"))
|
|
||||||
data["lanes"]["worker"]["cloud-G03"]["candidates"] = [
|
|
||||||
"codex-sol-xhigh",
|
|
||||||
"agy-gemini-medium",
|
|
||||||
]
|
|
||||||
with TemporaryDirectory() as tmp:
|
|
||||||
path = Path(tmp) / "catalog.json"
|
|
||||||
path.write_text(json.dumps(data), encoding="utf-8")
|
|
||||||
catalog = policy.load_catalog(path)
|
|
||||||
with mock.patch.object(policy, "CATALOG", catalog):
|
|
||||||
decision = policy.select_policy(
|
|
||||||
stage="worker", lane="cloud", grade=3, evaluated_at=at_utc(3)
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
[target.catalog_id for target in decision.candidates],
|
|
||||||
["codex-sol-xhigh", "agy-gemini-medium"],
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_selfcheck_stages_reload_from_catalog_without_python_change(self):
|
|
||||||
data = json.loads(policy.CATALOG_PATH.read_text(encoding="utf-8"))
|
|
||||||
data["targets"]["codex-sol-xhigh"]["selfcheck"] = {
|
|
||||||
"full_review": True,
|
|
||||||
"checklist_review": False,
|
|
||||||
}
|
|
||||||
with TemporaryDirectory() as tmp:
|
|
||||||
path = Path(tmp) / "catalog.json"
|
|
||||||
path.write_text(json.dumps(data), encoding="utf-8")
|
|
||||||
try:
|
|
||||||
reloaded = policy.reload_catalog(path)
|
|
||||||
target = reloaded.targets["codex-sol-xhigh"]
|
|
||||||
self.assertTrue(target.selfcheck_full_review)
|
|
||||||
self.assertFalse(target.selfcheck_checklist_review)
|
|
||||||
self.assertEqual(policy.CATALOG.revision, reloaded.revision)
|
|
||||||
finally:
|
|
||||||
policy.reload_catalog()
|
|
||||||
|
|
||||||
def test_failed_reload_preserves_the_published_catalog(self):
|
|
||||||
data = json.loads(policy.CATALOG_PATH.read_text(encoding="utf-8"))
|
|
||||||
del data["targets"]["legacy-claude-glm"]
|
|
||||||
published = policy.CATALOG
|
|
||||||
with TemporaryDirectory() as tmp:
|
|
||||||
path = Path(tmp) / "catalog.json"
|
|
||||||
path.write_text(json.dumps(data), encoding="utf-8")
|
|
||||||
with self.assertRaisesRegex(
|
|
||||||
policy.CatalogError,
|
|
||||||
"missing compatibility targets",
|
|
||||||
):
|
|
||||||
policy.reload_catalog(path)
|
|
||||||
self.assertIs(policy.CATALOG, published)
|
|
||||||
|
|
||||||
def test_catalog_rejects_a_missing_grade_lane(self):
|
|
||||||
data = json.loads(policy.CATALOG_PATH.read_text(encoding="utf-8"))
|
|
||||||
del data["lanes"]["worker"]["cloud-G03"]
|
|
||||||
with TemporaryDirectory() as tmp:
|
|
||||||
path = Path(tmp) / "catalog.json"
|
|
||||||
path.write_text(json.dumps(data), encoding="utf-8")
|
|
||||||
with self.assertRaisesRegex(
|
|
||||||
policy.CatalogError, "define every grade independently"
|
|
||||||
):
|
|
||||||
policy.load_catalog(path)
|
|
||||||
|
|
||||||
def test_catalog_owns_explicit_driver_options(self):
|
|
||||||
self.assertEqual(policy.PI_ORNITH.thinking_level, "high")
|
|
||||||
self.assertEqual(policy.PI_LAGUNA.thinking_level, "high")
|
|
||||||
for target in (
|
|
||||||
policy.CLAUDE_GLM,
|
|
||||||
policy.CLAUDE_OPUS,
|
|
||||||
policy.CLAUDE_HAIKU_XHIGH,
|
|
||||||
policy.CODEX_SPARK_XHIGH,
|
|
||||||
policy.CODEX_SOL_XHIGH,
|
|
||||||
):
|
|
||||||
with self.subTest(target=target.catalog_id):
|
|
||||||
self.assertEqual(target.reasoning_effort, "xhigh")
|
|
||||||
|
|
||||||
def test_catalog_rejects_unsupported_runtime_combinations(self):
|
|
||||||
cases = (
|
|
||||||
(
|
|
||||||
"unknown adapter",
|
|
||||||
lambda data: data["targets"]["agy-gemini-low"].update(
|
|
||||||
adapter="unknown-cli"
|
|
||||||
),
|
|
||||||
"new adapter requires dispatcher driver support",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"implicit pi thinking",
|
|
||||||
lambda data: data["targets"]["pi-ornith-high"].pop(
|
|
||||||
"thinking_level"
|
|
||||||
),
|
|
||||||
"requires thinking_level",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"mixed execution classes",
|
|
||||||
lambda data: data["lanes"]["worker"]["local-G01"].update(
|
|
||||||
candidates=["pi-ornith-high", "codex-sol-xhigh"]
|
|
||||||
),
|
|
||||||
"cannot mix local_model and cloud_model",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"promotion cycle",
|
|
||||||
lambda data: data["promotions"].update(
|
|
||||||
{"codex-terra-high": "claude-opus-xhigh"}
|
|
||||||
),
|
|
||||||
"contain a cycle",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"incomplete selfcheck stages",
|
|
||||||
lambda data: data["targets"]["opencode-glm-high"][
|
|
||||||
"selfcheck"
|
|
||||||
].pop("checklist_review"),
|
|
||||||
"must contain exactly",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
for name, mutate, message in cases:
|
|
||||||
with self.subTest(name=name), TemporaryDirectory() as tmp:
|
|
||||||
data = json.loads(policy.CATALOG_PATH.read_text(encoding="utf-8"))
|
|
||||||
mutate(data)
|
|
||||||
path = Path(tmp) / "catalog.json"
|
|
||||||
path.write_text(json.dumps(data), encoding="utf-8")
|
|
||||||
with self.assertRaisesRegex(policy.CatalogError, message):
|
|
||||||
policy.load_catalog(path)
|
|
||||||
|
|
||||||
def test_local_g07_route_uses_kst_boundaries(self):
|
def test_local_g07_route_uses_kst_boundaries(self):
|
||||||
cases = [
|
cases = [
|
||||||
(at_utc(21, 59, 59), "agy", "Gemini 3.6 Flash (High)", "kst-night-[23:00,07:00)"),
|
(at_utc(21, 59, 59), "agy", "Gemini 3.6 Flash (High)", "kst-night-[23:00,07:00)"),
|
||||||
|
|
@ -183,7 +48,7 @@ class ExecutionTargetPolicyTests(unittest.TestCase):
|
||||||
decision = policy.select_policy(
|
decision = policy.select_policy(
|
||||||
stage="worker", lane="local", grade=8, evaluated_at=night_time
|
stage="worker", lane="local", grade=8, evaluated_at=night_time
|
||||||
)
|
)
|
||||||
self.assertEqual(decision.rule_id, "worker-local-g08-kst-night-catalog")
|
self.assertEqual(decision.rule_id, "worker-local-g07-g08-kst-night")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
decision.candidates,
|
decision.candidates,
|
||||||
(
|
(
|
||||||
|
|
@ -265,10 +130,10 @@ class ExecutionTargetPolicyTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
decision.reason_codes,
|
decision.reason_codes,
|
||||||
("worker_catalog_lane",),
|
("cloud_spark_priority_grade",),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_review_catalog_defines_every_lane(self):
|
def test_review_matrix_is_fixed_to_codex(self):
|
||||||
for lane in ("local", "cloud"):
|
for lane in ("local", "cloud"):
|
||||||
for grade in range(1, 11):
|
for grade in range(1, 11):
|
||||||
with self.subTest(lane=lane, grade=grade):
|
with self.subTest(lane=lane, grade=grade):
|
||||||
|
|
@ -278,14 +143,7 @@ class ExecutionTargetPolicyTests(unittest.TestCase):
|
||||||
grade=grade,
|
grade=grade,
|
||||||
evaluated_at=at_utc(3),
|
evaluated_at=at_utc(3),
|
||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(decision.rule_id, "official-review-codex")
|
||||||
decision.rule_id,
|
|
||||||
f"review-{lane}-g{grade:02d}-catalog",
|
|
||||||
)
|
|
||||||
self.assertEqual(
|
|
||||||
decision.reason_codes,
|
|
||||||
("review_catalog_lane",),
|
|
||||||
)
|
|
||||||
self.assertEqual(decision.candidates, (policy.CODEX_SOL_XHIGH,))
|
self.assertEqual(decision.candidates, (policy.CODEX_SOL_XHIGH,))
|
||||||
|
|
||||||
def test_local_g07_g08_candidate_order_uses_gemini_high_then_glm_max(self):
|
def test_local_g07_g08_candidate_order_uses_gemini_high_then_glm_max(self):
|
||||||
|
|
@ -323,8 +181,6 @@ class ExecutionTargetPolicyTests(unittest.TestCase):
|
||||||
self.assertEqual(target.reasoning_effort, effort)
|
self.assertEqual(target.reasoning_effort, effort)
|
||||||
self.assertEqual(target.execution_class, "cloud_model")
|
self.assertEqual(target.execution_class, "cloud_model")
|
||||||
self.assertFalse(target.selfcheck_required)
|
self.assertFalse(target.selfcheck_required)
|
||||||
self.assertFalse(target.selfcheck_full_review)
|
|
||||||
self.assertTrue(target.selfcheck_checklist_review)
|
|
||||||
|
|
||||||
def test_invalid_inputs_are_rejected(self):
|
def test_invalid_inputs_are_rejected(self):
|
||||||
cases = [
|
cases = [
|
||||||
|
|
|
||||||
|
|
@ -122,39 +122,6 @@ MALFORMED_NESTED_VARIANTS = [
|
||||||
|
|
||||||
|
|
||||||
class SelectorContractTests(unittest.TestCase):
|
class SelectorContractTests(unittest.TestCase):
|
||||||
def test_catalog_edit_applies_to_new_work_and_keeps_prior_route_pinned(self):
|
|
||||||
with TemporaryDirectory() as tmp:
|
|
||||||
root = Path(tmp)
|
|
||||||
task_file = write_task_file(root, "PLAN", "cloud", 3)
|
|
||||||
prior = selector.select_execution_target(
|
|
||||||
task_file, evaluated_at=kst(12)
|
|
||||||
)
|
|
||||||
data = json.loads(
|
|
||||||
selector.policy.CATALOG_PATH.read_text(encoding="utf-8")
|
|
||||||
)
|
|
||||||
data["lanes"]["worker"]["cloud-G03"]["candidates"] = [
|
|
||||||
"codex-sol-xhigh",
|
|
||||||
"agy-gemini-medium",
|
|
||||||
]
|
|
||||||
catalog_path = root / "catalog.json"
|
|
||||||
catalog_path.write_text(json.dumps(data), encoding="utf-8")
|
|
||||||
changed = selector.policy.load_catalog(catalog_path)
|
|
||||||
with mock.patch.object(selector.policy, "CATALOG", changed), mock.patch.object(
|
|
||||||
selector.policy, "CATALOG_REVISION", changed.revision
|
|
||||||
):
|
|
||||||
resumed = selector.select_execution_target(
|
|
||||||
task_file,
|
|
||||||
evaluated_at=kst(12),
|
|
||||||
transition="resume",
|
|
||||||
prior_decision=prior,
|
|
||||||
)
|
|
||||||
new_work = selector.select_execution_target(
|
|
||||||
task_file, evaluated_at=kst(12)
|
|
||||||
)
|
|
||||||
self.assertEqual(resumed["selected"], prior["selected"])
|
|
||||||
self.assertEqual(resumed["candidates"], prior["candidates"])
|
|
||||||
self.assertEqual(new_work["selected"]["target_id"], "codex-sol-xhigh")
|
|
||||||
|
|
||||||
def test_worker_contract_shape_and_types(self):
|
def test_worker_contract_shape_and_types(self):
|
||||||
with TemporaryDirectory() as tmp:
|
with TemporaryDirectory() as tmp:
|
||||||
task_file = write_task_file(Path(tmp), "PLAN", "cloud", 7)
|
task_file = write_task_file(Path(tmp), "PLAN", "cloud", 7)
|
||||||
|
|
@ -176,15 +143,8 @@ class SelectorContractTests(unittest.TestCase):
|
||||||
"target": "claude-opus-4-8",
|
"target": "claude-opus-4-8",
|
||||||
"execution_class": "cloud_model",
|
"execution_class": "cloud_model",
|
||||||
"selfcheck_required": False,
|
"selfcheck_required": False,
|
||||||
"target_id": "claude-opus-xhigh",
|
|
||||||
"reasoning_effort": "xhigh",
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(result["catalog"]["route_id"], "worker:cloud-G07")
|
|
||||||
self.assertEqual(
|
|
||||||
result["catalog"]["schema_version"],
|
|
||||||
selector.policy.CATALOG_SCHEMA_VERSION,
|
|
||||||
)
|
|
||||||
for key in ("rule_id", "policy_priority", "reason_codes", "pinned"):
|
for key in ("rule_id", "policy_priority", "reason_codes", "pinned"):
|
||||||
self.assertIn(key, result["decision"])
|
self.assertIn(key, result["decision"])
|
||||||
self.assertIs(result["decision"]["pinned"], False)
|
self.assertIs(result["decision"]["pinned"], False)
|
||||||
|
|
@ -619,26 +579,24 @@ class SelectorQuotaRepresentationTests(unittest.TestCase):
|
||||||
for name, status in snapshots.items():
|
for name, status in snapshots.items():
|
||||||
with self.subTest(status=name):
|
with self.subTest(status=name):
|
||||||
if status == "exhausted":
|
if status == "exhausted":
|
||||||
result = selector.select_execution_target(
|
with self.assertRaises(selector.SelectorInputError) as ctx:
|
||||||
cloud,
|
selector.select_execution_target(
|
||||||
evaluated_at=kst(12),
|
cloud,
|
||||||
quota_snapshot={
|
evaluated_at=kst(12),
|
||||||
"snapshot_id": f"probe-{name}",
|
quota_snapshot={
|
||||||
"source": "iop-node quota-probe",
|
"snapshot_id": f"probe-{name}",
|
||||||
"checked_at": "2026-07-25T03:00:00+09:00",
|
"source": "iop-node quota-probe",
|
||||||
"targets": [
|
"checked_at": "2026-07-25T03:00:00+09:00",
|
||||||
{
|
"targets": [
|
||||||
"adapter": "claude",
|
{
|
||||||
"target": "claude-opus-4-8",
|
"adapter": "claude",
|
||||||
"status": status,
|
"target": "claude-opus-4-8",
|
||||||
}
|
"status": status,
|
||||||
],
|
}
|
||||||
},
|
],
|
||||||
)
|
},
|
||||||
self.assertEqual(result["selected"]["adapter"], "codex")
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(ctx.exception.code, "no_eligible_target")
|
||||||
result["selected"]["target"], "gpt-5.6-terra"
|
|
||||||
)
|
|
||||||
continue
|
continue
|
||||||
result = selector.select_execution_target(
|
result = selector.select_execution_target(
|
||||||
cloud,
|
cloud,
|
||||||
|
|
@ -681,11 +639,14 @@ class SelectorQuotaRepresentationTests(unittest.TestCase):
|
||||||
"subprocess.run", return_value=completed
|
"subprocess.run", return_value=completed
|
||||||
):
|
):
|
||||||
if status == "exhausted":
|
if status == "exhausted":
|
||||||
result = selector.select_execution_target(
|
with self.assertRaises(
|
||||||
cloud, evaluated_at=kst(12)
|
selector.SelectorInputError
|
||||||
)
|
) as ctx:
|
||||||
|
selector.select_execution_target(
|
||||||
|
cloud, evaluated_at=kst(12)
|
||||||
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
result["selected"]["target"], "gpt-5.6-terra"
|
ctx.exception.code, "no_eligible_target"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
result = selector.select_execution_target(
|
result = selector.select_execution_target(
|
||||||
|
|
@ -741,12 +702,7 @@ class SelectorQuotaRepresentationTests(unittest.TestCase):
|
||||||
"adapter": "claude",
|
"adapter": "claude",
|
||||||
"target": "claude-opus-4-8",
|
"target": "claude-opus-4-8",
|
||||||
"status": "exhausted",
|
"status": "exhausted",
|
||||||
},
|
}
|
||||||
{
|
|
||||||
"adapter": "codex",
|
|
||||||
"target": "gpt-5.6-terra",
|
|
||||||
"status": "exhausted",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
with TemporaryDirectory() as tmp:
|
with TemporaryDirectory() as tmp:
|
||||||
|
|
@ -1277,11 +1233,7 @@ class SelectorFailoverContractTests(unittest.TestCase):
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
glm["used_candidates"],
|
glm["used_candidates"],
|
||||||
[
|
[
|
||||||
{
|
{"adapter": "codex", "target": "gpt-5.3-codex-spark"},
|
||||||
"adapter": "codex",
|
|
||||||
"target": "gpt-5.3-codex-spark",
|
|
||||||
"reasoning_effort": "xhigh",
|
|
||||||
},
|
|
||||||
{"adapter": "agy", "target": "Gemini 3.6 Flash (Low)"},
|
{"adapter": "agy", "target": "Gemini 3.6 Flash (Low)"},
|
||||||
{
|
{
|
||||||
"adapter": "opencode",
|
"adapter": "opencode",
|
||||||
|
|
@ -1596,7 +1548,7 @@ class SelectorFailoverContractTests(unittest.TestCase):
|
||||||
self.assertEqual(result["quota"]["status"], "unknown")
|
self.assertEqual(result["quota"]["status"], "unknown")
|
||||||
self.assertEqual(run_mock.call_count, 2)
|
self.assertEqual(run_mock.call_count, 2)
|
||||||
|
|
||||||
def test_policy_owned_cloud_lane_fallback_chain_and_no_bounce(self):
|
def test_policy_owned_cloud_promotion_chain_and_no_bounce(self):
|
||||||
with TemporaryDirectory() as tmp:
|
with TemporaryDirectory() as tmp:
|
||||||
task_file = write_task_file(Path(tmp), "PLAN", "cloud", 7)
|
task_file = write_task_file(Path(tmp), "PLAN", "cloud", 7)
|
||||||
initial = selector.select_execution_target(
|
initial = selector.select_execution_target(
|
||||||
|
|
@ -1607,7 +1559,7 @@ class SelectorFailoverContractTests(unittest.TestCase):
|
||||||
terra = selector.select_execution_target(
|
terra = selector.select_execution_target(
|
||||||
task_file,
|
task_file,
|
||||||
evaluated_at=kst(12),
|
evaluated_at=kst(12),
|
||||||
transition="failover",
|
transition="promotion",
|
||||||
prior_decision=initial,
|
prior_decision=initial,
|
||||||
failure_class="provider-quota",
|
failure_class="provider-quota",
|
||||||
)
|
)
|
||||||
|
|
@ -1621,29 +1573,39 @@ class SelectorFailoverContractTests(unittest.TestCase):
|
||||||
(initial["selected"]["adapter"], initial["selected"]["target"]),
|
(initial["selected"]["adapter"], initial["selected"]["target"]),
|
||||||
("claude", "claude-opus-4-8"),
|
("claude", "claude-opus-4-8"),
|
||||||
)
|
)
|
||||||
|
self.assertEqual(terra["transition"]["kind"], "promotion")
|
||||||
self.assertEqual(terra["transition"]["trigger"], "provider-quota")
|
self.assertEqual(terra["transition"]["trigger"], "provider-quota")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
(terra["selected"]["adapter"], terra["selected"]["target"]),
|
(terra["selected"]["adapter"], terra["selected"]["target"]),
|
||||||
("codex", "gpt-5.6-terra"),
|
("codex", "gpt-5.6-terra"),
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
terra["promotion_path"],
|
||||||
|
[
|
||||||
|
{"adapter": "claude", "target": "claude-opus-4-8"},
|
||||||
|
{"adapter": "codex", "target": "gpt-5.6-terra"},
|
||||||
|
],
|
||||||
|
)
|
||||||
with self.assertRaises(selector.SelectorInputError) as exhausted:
|
with self.assertRaises(selector.SelectorInputError) as exhausted:
|
||||||
selector.select_execution_target(
|
selector.select_execution_target(
|
||||||
task_file,
|
task_file,
|
||||||
evaluated_at=kst(23),
|
evaluated_at=kst(23),
|
||||||
transition="failover",
|
transition="promotion",
|
||||||
prior_decision=resumed,
|
prior_decision=resumed,
|
||||||
failure_class="provider-quota",
|
failure_class="provider-quota",
|
||||||
)
|
)
|
||||||
self.assertEqual(exhausted.exception.code, "no_failover_candidate")
|
self.assertEqual(exhausted.exception.code, "no_promotion_target")
|
||||||
with self.assertRaises(selector.SelectorInputError) as generic:
|
with self.assertRaises(selector.SelectorInputError) as generic:
|
||||||
selector.select_execution_target(
|
selector.select_execution_target(
|
||||||
task_file,
|
task_file,
|
||||||
evaluated_at=kst(12),
|
evaluated_at=kst(12),
|
||||||
transition="failover",
|
transition="promotion",
|
||||||
prior_decision=initial,
|
prior_decision=initial,
|
||||||
failure_class="generic-error",
|
failure_class="generic-error",
|
||||||
)
|
)
|
||||||
self.assertEqual(generic.exception.code, "unqualified_failover_trigger")
|
self.assertEqual(
|
||||||
|
generic.exception.code, "unqualified_promotion_trigger"
|
||||||
|
)
|
||||||
|
|
||||||
def test_probe_candidate_quota_argv_and_normalization(self):
|
def test_probe_candidate_quota_argv_and_normalization(self):
|
||||||
eval_time = kst(14, 0, 0)
|
eval_time = kst(14, 0, 0)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ test_env: dev
|
||||||
test_profile: edge-smoke
|
test_profile: edge-smoke
|
||||||
domain: edge
|
domain: edge
|
||||||
verification_type: smoke
|
verification_type: smoke
|
||||||
last_rule_updated_at: 2026-08-05
|
last_rule_updated_at: 2026-08-02
|
||||||
---
|
---
|
||||||
|
|
||||||
# edge-smoke dev 테스트
|
# edge-smoke dev 테스트
|
||||||
|
|
@ -52,7 +52,7 @@ dev-runtime provider pool과 4-node 연결 상태를 점검할 때는 `agent-tes
|
||||||
- Edge OpenAI-compatible base URL: `http://toki-labs.com:18083/v1`
|
- Edge OpenAI-compatible base URL: `http://toki-labs.com:18083/v1`
|
||||||
- Edge-Node TCP transport: `toki-labs.com:18084`
|
- Edge-Node TCP transport: `toki-labs.com:18084`
|
||||||
- active model aliases: `laguna-s:2.1`(GX10), `ornith:35b`/`ornith-fast`(OneXPlayer/RTX5090), `qwen3.6:35b`(mac-mlx-vllm)
|
- active model aliases: `laguna-s:2.1`(GX10), `ornith:35b`/`ornith-fast`(OneXPlayer/RTX5090), `qwen3.6:35b`(mac-mlx-vllm)
|
||||||
- host Pi dispatcher profile: `agent-test/inventory-agent.yaml`의 `environments.dev.agents.pi` 기준. 현재 기본 provider/model/thinking level은 `iop` / `glm-5.2` / `high`이고, dispatcher local-model route는 `iop/ornith:35b` / `high`다. 두 모델 모두 IOP Edge `http://toki-labs.com:18083/v1`을 사용하며 Pi local direct providers는 제거된 상태다. credential 원문 대신 같은 inventory의 SOPS `token_ref`만 기준으로 삼는다.
|
- host Pi default profile: `agent-test/inventory-agent.yaml`의 `environments.dev.agents.pi` 기준. 현재 기본 provider/model/thinking level은 `iop` / `laguna-s:2.1` / `high`이며, Pi는 IOP Edge `http://toki-labs.com:18083/v1`의 Laguna model group을 호출한다. Pi local direct providers는 제거된 상태다.
|
||||||
- provider/model separation: Laguna는 GX10 `poolside_v1`, Qwen은 mac `qwen`/`qwen3`, Ornith는 Lemonade runtime profile을 사용한다. dev-corp `gemma4:26b`와 stopped DiffusionGemma 설정을 이들 profile에 섞지 않는다.
|
- provider/model separation: Laguna는 GX10 `poolside_v1`, Qwen은 mac `qwen`/`qwen3`, Ornith는 Lemonade runtime profile을 사용한다. dev-corp `gemma4:26b`와 stopped DiffusionGemma 설정을 이들 profile에 섞지 않는다.
|
||||||
|
|
||||||
노드 후보:
|
노드 후보:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
inventory_id: inventory-agent
|
inventory_id: inventory-agent
|
||||||
schema_version: 1
|
schema_version: 1
|
||||||
last_updated_at: "2026-08-05"
|
last_updated_at: "2026-08-02"
|
||||||
|
|
||||||
scope:
|
scope:
|
||||||
type: shared_agent_host_profiles
|
type: shared_agent_host_profiles
|
||||||
|
|
@ -13,15 +13,6 @@ policy:
|
||||||
agent_provider_compatibility_observations: allowed
|
agent_provider_compatibility_observations: allowed
|
||||||
node_reference_policy: reference_only
|
node_reference_policy: reference_only
|
||||||
|
|
||||||
dispatcher_scope:
|
|
||||||
included_agents:
|
|
||||||
- codex
|
|
||||||
- claude
|
|
||||||
- agy
|
|
||||||
- pi
|
|
||||||
excluded_agents:
|
|
||||||
- opencode
|
|
||||||
|
|
||||||
source_inventories:
|
source_inventories:
|
||||||
common: agent-test/inventory.yaml
|
common: agent-test/inventory.yaml
|
||||||
dev: agent-test/inventory-dev.yaml
|
dev: agent-test/inventory-dev.yaml
|
||||||
|
|
@ -32,96 +23,39 @@ environments:
|
||||||
source_inventory: agent-test/inventory-dev.yaml
|
source_inventory: agent-test/inventory-dev.yaml
|
||||||
agents:
|
agents:
|
||||||
claude:
|
claude:
|
||||||
status: configured
|
status: not_configured
|
||||||
host_binding: not_recorded
|
note: No Claude host profile is recorded in the dev inventory.
|
||||||
observed_at: "2026-08-05"
|
|
||||||
config_scope: host_local_cli
|
|
||||||
cli_version: 2.1.221
|
|
||||||
authentication:
|
|
||||||
status: authenticated
|
|
||||||
method: claude.ai
|
|
||||||
secret_value_tracked: false
|
|
||||||
dispatcher_models:
|
|
||||||
- model: claude-opus-4-8
|
|
||||||
effort: xhigh
|
|
||||||
live_validation:
|
|
||||||
claude_opus_4_8_xhigh: passed
|
|
||||||
codex:
|
codex:
|
||||||
status: configured
|
status: node_reference_only
|
||||||
node_ref: mac-codex-node
|
node_ref: mac-codex-node
|
||||||
host_binding: not_recorded
|
note: The CLI adapter and MLX provider runtime remain owned by the referenced dev node inventory.
|
||||||
observed_at: "2026-08-05"
|
|
||||||
config_scope: host_local_cli
|
|
||||||
cli_version: 0.146.0
|
|
||||||
config_file: /config/.codex/config.toml
|
|
||||||
config_file_mode: "0600"
|
|
||||||
current_default_model: gpt-5.6-sol
|
|
||||||
current_default_reasoning_effort: xhigh
|
|
||||||
dispatcher_models:
|
|
||||||
- model: gpt-5.3-codex-spark
|
|
||||||
reasoning_effort: high
|
|
||||||
- model: gpt-5.6-terra
|
|
||||||
reasoning_effort: high
|
|
||||||
- model: gpt-5.6-sol
|
|
||||||
reasoning_effort: xhigh
|
|
||||||
sqlite_state:
|
|
||||||
active_home: /config/.codex/sqlite-repaired-20260804T191357Z
|
|
||||||
repair_backup: /config/.codex/db-repair-backups/20260804T191357Z
|
|
||||||
repair_reason: The previous state database contained orphan pages and physically duplicated thread IDs behind a corrupt unique index.
|
|
||||||
integrity_check: passed
|
|
||||||
foreign_key_check: passed
|
|
||||||
duplicate_thread_ids: 0
|
|
||||||
thread_rows: 1600
|
|
||||||
unique_thread_ids: 1600
|
|
||||||
doctor_summary: passed
|
|
||||||
live_validation:
|
|
||||||
gpt_5_3_codex_spark_high: passed
|
|
||||||
gpt_5_6_terra_high: passed
|
|
||||||
gpt_5_6_sol_xhigh: passed
|
|
||||||
note: The CLI host profile is recorded here. The referenced dev node inventory remains the source of truth for the MLX provider runtime.
|
|
||||||
pi:
|
pi:
|
||||||
status: configured
|
status: configured
|
||||||
host_binding: not_recorded
|
host_binding: not_recorded
|
||||||
observed_at: "2026-08-05"
|
observed_at: "2026-07-24"
|
||||||
config_scope: host_local_pi_agent
|
config_scope: host_local_pi_agent
|
||||||
cli_version: 0.81.1
|
|
||||||
config_dir: /config/.pi/agent
|
config_dir: /config/.pi/agent
|
||||||
config_files:
|
|
||||||
models:
|
|
||||||
path: /config/.pi/agent/models.json
|
|
||||||
mode: "0600"
|
|
||||||
settings:
|
|
||||||
path: /config/.pi/agent/settings.json
|
|
||||||
mode: "0600"
|
|
||||||
current_default_provider: iop
|
current_default_provider: iop
|
||||||
current_default_model: glm-5.2
|
current_default_model: laguna-s:2.1
|
||||||
current_default_thinking_level: high
|
current_default_thinking_level: high
|
||||||
current_default_api: openai-completions
|
current_default_api: openai-completions
|
||||||
current_default_base_url: http://toki-labs.com:18083/v1
|
current_default_base_url: http://toki-labs.com:18083/v1
|
||||||
current_default_auth_header: true
|
current_default_auth_header: true
|
||||||
dispatcher_enabled_models:
|
|
||||||
- iop/ornith:35b
|
|
||||||
- iop/glm-5.2
|
|
||||||
credential_binding:
|
|
||||||
source_host: toki@toki-labs.com
|
|
||||||
sops_file: /Users/toki/.config/iop/secrets/dev-openai-toki.sops.yaml
|
|
||||||
sops_age_key_file: /Users/toki/.config/sops/age/keys.txt
|
|
||||||
token_ref: tokens.toki-dev-pi
|
|
||||||
applied_to:
|
|
||||||
- providers.iop.apiKey
|
|
||||||
- providers.iop.models[id=ornith:35b].headers.X-IOP-Provider-Authorization
|
|
||||||
secret_value_tracked: false
|
|
||||||
sampling_policy:
|
sampling_policy:
|
||||||
explicit_parameters: false
|
explicit_parameters: false
|
||||||
source: selected_iop_model_profile
|
source: dev_laguna_provider_defaults
|
||||||
|
provider_default:
|
||||||
|
temperature: 0.7
|
||||||
|
top_p: 0.95
|
||||||
|
top_k: 20
|
||||||
omitted_fields:
|
omitted_fields:
|
||||||
- temperature
|
- temperature
|
||||||
- top_p
|
- top_p
|
||||||
- top_k
|
- top_k
|
||||||
- repeat_penalty
|
- repeat_penalty
|
||||||
note: Pi does not define global sampling overrides for dispatcher models; the selected IOP model/provider profile applies its baseline.
|
note: Pi omits these fields and the selected GX10 Laguna provider applies the model baseline.
|
||||||
api_key_value_tracked: false
|
api_key_value_tracked: false
|
||||||
endpoint_policy: The current default and dispatcher-enabled Pi models use the IOP Edge provider. Pi does not bypass Edge to a direct provider endpoint.
|
endpoint_policy: current default Pi route is IOP Edge provider iop with model laguna-s:2.1. Pi does not bypass Edge to the GX10 vLLM endpoint.
|
||||||
local_pi_providers_at_observation:
|
local_pi_providers_at_observation:
|
||||||
- iop
|
- iop
|
||||||
direct_providers_removed_at_observation:
|
direct_providers_removed_at_observation:
|
||||||
|
|
@ -133,37 +67,18 @@ environments:
|
||||||
- ornith:35b
|
- ornith:35b
|
||||||
- ornith-fast
|
- ornith-fast
|
||||||
- laguna-s:2.1
|
- laguna-s:2.1
|
||||||
- glm-5.2
|
pi_model_parameters:
|
||||||
dispatcher_model_parameters:
|
provider: iop
|
||||||
ornith_35b:
|
model_id: laguna-s:2.1
|
||||||
provider: iop
|
display_name: IOP Poolside Laguna S 2.1
|
||||||
model_id: ornith:35b
|
api: openai-completions
|
||||||
display_name: IOP Ornith 1.0 35B
|
context_window: 262144
|
||||||
api: openai-completions
|
max_tokens: 65536
|
||||||
context_window: 262144
|
reasoning: true
|
||||||
max_tokens: 32768
|
thinking_level: high
|
||||||
reasoning: true
|
thinking_format: chat-template
|
||||||
thinking_level: high
|
enable_thinking_source: thinking.enabled
|
||||||
thinking_format: chat-template
|
preserve_thinking: true
|
||||||
enable_thinking_source: thinking.enabled
|
|
||||||
glm_5_2:
|
|
||||||
provider: iop
|
|
||||||
model_id: glm-5.2
|
|
||||||
display_name: IOP GLM-5.2
|
|
||||||
api: openai-completions
|
|
||||||
context_window: 1000000
|
|
||||||
max_tokens: 131072
|
|
||||||
reasoning: true
|
|
||||||
thinking_level: high
|
|
||||||
thinking_level_mapping: low_medium_high_max_to_high
|
|
||||||
thinking_format: zai
|
|
||||||
supports_reasoning_effort: true
|
|
||||||
zai_tool_stream: true
|
|
||||||
live_validation:
|
|
||||||
dev_models_endpoint: passed
|
|
||||||
sops_token_ingress_and_provider_header_preflight: passed
|
|
||||||
pi_ornith_35b_high: passed
|
|
||||||
pi_glm_5_2_high: passed
|
|
||||||
current_laguna_parameter_alignment:
|
current_laguna_parameter_alignment:
|
||||||
model_alias: laguna-s:2.1
|
model_alias: laguna-s:2.1
|
||||||
pi_context_window: 262144
|
pi_context_window: 262144
|
||||||
|
|
@ -536,23 +451,10 @@ environments:
|
||||||
finish_reason: stop
|
finish_reason: stop
|
||||||
content: "4"
|
content: "4"
|
||||||
completion_tokens: 2
|
completion_tokens: 2
|
||||||
separation_note: The host Pi default is GLM-5.2 and the dispatcher local-model route is Ornith 35B, both through IOP Edge. Keep GLM, Laguna, Qwen, Ornith, and dev-corp Gemma parser/template/runtime settings separate.
|
separation_note: The host Pi default is Laguna S 2.1 through IOP Edge. Keep Laguna, Qwen, Ornith, and dev-corp Gemma parser/template/runtime settings separate.
|
||||||
agy:
|
agy:
|
||||||
status: configured_quota_blocked
|
status: not_configured
|
||||||
host_binding: not_recorded
|
note: No agy host profile is recorded in the dev inventory.
|
||||||
observed_at: "2026-08-05"
|
|
||||||
config_scope: host_local_cli
|
|
||||||
cli_version: 1.1.10
|
|
||||||
dispatcher_models:
|
|
||||||
- Gemini 3.6 Flash (Low)
|
|
||||||
- Gemini 3.6 Flash (Medium)
|
|
||||||
- Gemini 3.6 Flash (High)
|
|
||||||
configuration_validation: passed
|
|
||||||
live_validation:
|
|
||||||
status: blocked
|
|
||||||
failure_class: provider_quota
|
|
||||||
provider_message: Individual quota reached
|
|
||||||
note: The CLI recognizes every dispatcher model and its host authentication/configuration is usable, but live requests remain unavailable until the individual quota recovers.
|
|
||||||
dev-corp:
|
dev-corp:
|
||||||
source_inventory: agent-test/inventory-dev-corp.yaml
|
source_inventory: agent-test/inventory-dev-corp.yaml
|
||||||
agents:
|
agents:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue