fix(agent-ops): 공통 디스패처 경계를 바로잡는다

중복된 project 디스패처가 공통 런타임과 다른 경로·시간대를 사용하지 않도록 common 구현으로 단일화하고, 작업 로그를 KST로 기록하기 위해 변경한다.
This commit is contained in:
toki 2026-08-08 08:28:48 +09:00
parent 70d22850d0
commit f6e65b31c0
22 changed files with 26 additions and 28148 deletions

View file

@ -1,7 +1,7 @@
---
domain: testing
last_rule_review_commit: 495996fee4b55eabef58505f73ab23848794eeef
last_rule_updated_at: 2026-08-06
last_rule_updated_at: 2026-08-08
---
# testing
@ -33,9 +33,6 @@ last_rule_updated_at: 2026-08-06
- `scripts/readability_read_sets.json` — task별 ordered read-set budget 정의이다.
- `cmd/iop-provider-smoke/` — redacted provider catalog readiness와 status/run/resume/cancel lifecycle을 실제 CLI로 검증하는 smoke command이다.
- `docker-compose.yml` — local dev용 Control Plane, datastore, Flutter Web client stack 조립 표면이다.
- `agent-ops/skills/project/orchestrate-agent-task-loop/SKILL.md` — Agent Task 무인 실행과 provider 격리 검증 절차의 project entrypoint이다.
- `agent-ops/skills/project/orchestrate-agent-task-loop/agents/` — orchestrator 실행에 사용하는 agent metadata이다.
- `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/` — task plan을 CLI invocation으로 연결하는 dispatcher, execution-target policy/selector와 observation helper 경계이다.
## 제외 경로

View file

@ -58,7 +58,7 @@
- Edge/Node 앱 설정 구조 변경 시 `packages/go/config`의 struct/default와 `configs/*.yaml` 예시를 함께 확인한다. Control Plane 로컬 설정 구조 변경 시 `apps/control-plane`의 config loader와 `configs/control-plane.yaml` 예시를 함께 확인한다.
- 테스트는 변경 범위에 맞춰 `go test ./...` 또는 대상 패키지 테스트를 실행한다.
- 사용자 실행 파이프라인에 닿는 작업을 한 경우, 작업 완료 후 `agent-ops/rules/project/domain/testing/rules.md`의 검증 기준을 따른다.
- 활성 `agent-task`의 dry-run, worker/review 실행, blocked retry와 상태 관찰은 사용자의 명시적 실행 요청이 있을 때만 `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py` dispatcher로 수행한다. 이 dispatcher는 Agent-Ops 작업 진행 전용이며 IOP 제품 runtime/API orchestration 경로가 아니다. execution preset, `/v1/messages` 단일 요청, provider stage와 workspace tool loop의 설계·구현·검증에서 dispatcher를 architecture component, caller continuation 또는 test harness로 사용하거나 참조하지 않는다.
- 활성 `agent-task`의 dry-run, worker/review 실행, blocked retry와 상태 관찰은 사용자의 명시적 실행 요청이 있을 때만 `agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py` dispatcher로 수행한다. 이 dispatcher는 Agent-Ops 작업 진행 전용이며 IOP 제품 runtime/API orchestration 경로가 아니다. execution preset, `/v1/messages` 단일 요청, provider stage와 workspace tool loop의 설계·구현·검증에서 dispatcher를 architecture component, caller continuation 또는 test harness로 사용하거나 참조하지 않는다.
- 이 프로젝트에서는 `agent-ops/rules/common/rules-roadmap.md`의 기존 task-group-only 및 `Roadmap Completion` 단건 반영 문구를 legacy 호환 규칙으로 한정한다. 새 `m-*` PLAN/CODE_REVIEW/complete.log는 첫 줄의 `milestone-task=<id>[,<id>...]`로 Milestone Task 기여 범위를 보존한다. 이 metadata나 단건 PASS는 완료 선언이 아니며, `sync-milestone-workstate`가 같은 Milestone task group의 완료 로그를 id별로 집계해 현재 Task 설명·검증·SDD evidence가 모두 충족된 경우에만 체크한다. 기존 `Roadmap Completion`은 first-line metadata가 없는 archive 로그의 호환 evidence로만 취급한다.
- field/bootstrap 작업은 `testing` domain rule을 따르고, 실제 local 환경값이 필요하면 `agent-test/local/rules.md`를 따른다.
- Node, specialized agent, domain agent, Control Plane enrollment 등 사용자가 대상 host에서 실행하는 bootstrap/install command 작업은 `agent-ops/rules/project/domain/testing/rules.md`의 one-line bootstrap UX 기준을 따른다.
@ -87,10 +87,6 @@
| `scripts/fixtures/**` | testing | `agent-ops/rules/project/domain/testing/rules.md` |
| `Makefile` | testing | `agent-ops/rules/project/domain/testing/rules.md` |
| `docker-compose.yml` | testing | `agent-ops/rules/project/domain/testing/rules.md` |
| `agent-ops/skills/project/orchestrate-agent-task-loop/scripts/**` | testing | `agent-ops/rules/project/domain/testing/rules.md` |
| `agent-ops/skills/project/orchestrate-agent-task-loop/tests/**` | testing | `agent-ops/rules/project/domain/testing/rules.md` |
| `agent-ops/skills/project/orchestrate-agent-task-loop/SKILL.md` | testing | `agent-ops/rules/project/domain/testing/rules.md` |
| `agent-ops/skills/project/orchestrate-agent-task-loop/agents/**` | testing | `agent-ops/rules/project/domain/testing/rules.md` |
| `cmd/iop-provider-smoke/**` | testing | `agent-ops/rules/project/domain/testing/rules.md` |
| `scripts/inventory-query/**` | testing | `agent-ops/rules/project/domain/testing/rules.md` |
| `scripts/readability_*` | testing | `agent-ops/rules/project/domain/testing/rules.md` |
@ -111,7 +107,7 @@
- dev-corp 배포, dev-corp runtime 배포, 회사망 mac-mini Edge/Node dev-corp 환경 배포, dev-corp provider pool 배포, dev-corp OpenAI-compatible capacity smoke 검증: `agent-ops/skills/project/dev-corp-runtime-deploy/SKILL.md`
- dev 배포, dev-runtime 배포, Edge/Node dev 환경 배포, provider pool 배포, OpenAI-compatible capacity smoke 검증: `agent-ops/skills/project/dev-runtime-deploy/SKILL.md`
- 사용자 실행 파이프라인 검증, repo 내부 edge-node 진단, 메시지 2회 왕복, edge command 응답, 보조 E2E smoke, full-cycle 실제 구동, `scripts/dev/edge.sh`/`scripts/dev/node.sh` 진단 테스트: `agent-ops/skills/project/e2e-smoke/SKILL.md`
- agent-task의 작업들 실행해, agent-task 무인 실행, task-group dry-run/live pass, blocked retry, Go parity/disposal 확인: `agent-ops/skills/project/orchestrate-agent-task-loop/SKILL.md`
- agent-task의 작업들 실행해, agent-task 무인 실행, task-group dry-run/live pass, blocked retry, Go parity/disposal 확인: `agent-ops/skills/common/orchestrate-agent-task-loop/SKILL.md`
- field 테스트 포트, artifact/bootstrap HTTP, 외부 테스트 환경: `agent-test/local/rules.md`를 따른다.
- bootstrap/install UX, Agent Bootstrap, specialized agent 등록, Control Plane enrollment: `testing` domain rule과 `agent-test/local/rules.md`를 따른다.
- 반복 작업이 확인되면 `agent-ops/skills/project/<skill-name>/SKILL.md`를 생성하고 이 표에 등록한다.

View file

@ -112,7 +112,7 @@ Accept self-check completion only when `## Implementation Checklist` or its supp
## Work log
- Keep one dispatcher-owned `WORK_LOG.md` per task group.
- Append chronological `START` and `FINISH` rows with UTC time, task artifact, plan loop, role, attempt, selected agent/model display, result, and locator.
- Append chronological `START` and `FINISH` rows with KST time, task artifact, plan loop, role, attempt, selected agent/model display, result, and locator.
- Archive the group log as the next `work_log_N.log` only after every observed task in the group is verified complete and idle.
- Work-log write or archive failure is a retryable control-plane failure and prevents exit `0`.

View file

@ -17,7 +17,7 @@ import subprocess
import sys
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
@ -153,6 +153,7 @@ DISPATCHER_CHILD_BOUNDARY_PROMPT = (
REPOSITORY_LANGUAGE_PROMPT = "Follow the repository's language and output rules."
SELF_CHECK_PROMPT_PREFIX = REPOSITORY_LANGUAGE_PROMPT
UTC = timezone.utc
KST = timezone(timedelta(hours=9))
DEFAULT_MAX_PARALLEL = 3
@ -248,8 +249,8 @@ def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def work_log_now_utc() -> str:
return datetime.now(UTC).strftime("%y-%m-%d %H:%M:%SZ")
def work_log_now_kst() -> str:
return datetime.now(KST).strftime("%y-%m-%d %H:%M:%S")
def sha256_file(path: Path | None) -> str:
@ -452,7 +453,7 @@ def append_work_log_event(
return str(value).replace("|", r"\|").replace("\n", " ")
stream.write(
f"| {sequence} | {work_log_now_utc()} | {cell(event)} | "
f"| {sequence} | {work_log_now_kst()} | {cell(event)} | "
f"{cell(task_name)} | "
f"{loop} | {cell(role)} | {attempt} | {cell(model)} | {cell(result)} | "
f"{cell(locator.resolve())} |\n"

View file

@ -261,6 +261,13 @@ class RuntimeCatalogDispatcherTests(unittest.TestCase):
class GenericDispatcherContractTests(unittest.TestCase):
def test_work_log_timestamp_uses_compact_kst_format(self):
fixed_kst = datetime(2026, 7, 26, 7, 40, 15, tzinfo=dispatch.KST)
with mock.patch.object(dispatch, "datetime") as datetime_mock:
datetime_mock.now.return_value = fixed_kst
self.assertEqual(dispatch.work_log_now_kst(), "26-07-26 07:40:15")
datetime_mock.now.assert_called_once_with(dispatch.KST)
def test_parallel_limit_contract(self):
self.assertEqual(dispatch.validated_max_parallel(0), 0)
self.assertEqual(dispatch.validated_max_parallel(3), 3)

View file

@ -426,17 +426,14 @@ def epic_cycle_script(workspace: Path) -> Path:
def dispatcher_script(workspace: Path) -> Path:
skills_root = workspace / "agent-ops" / "skills"
project_root = skills_root / "project" / "orchestrate-agent-task-loop"
project_dispatcher = project_root / "scripts" / "dispatch.py"
if project_dispatcher.is_file():
private_root = skills_root / "private" / "orchestrate-agent-task-loop"
private_dispatcher = private_root / "scripts" / "dispatch.py"
if (private_root / "SKILL.md").is_file() and private_dispatcher.is_file():
return private_dispatcher
return project_dispatcher
common_dispatcher = (
skills_root / "common" / "orchestrate-agent-task-loop" / "scripts" / "dispatch.py"
workspace
/ "agent-ops"
/ "skills"
/ "common"
/ "orchestrate-agent-task-loop"
/ "scripts"
/ "dispatch.py"
)
if not common_dispatcher.is_file():
raise PreparationError(f"dispatcher script not found: {common_dispatcher}")
@ -458,17 +455,7 @@ def dispatcher_command(
"--task-group",
task_group,
]
common_dispatcher = (
workspace
/ "agent-ops"
/ "skills"
/ "common"
/ "orchestrate-agent-task-loop"
/ "scripts"
/ "dispatch.py"
)
if dispatcher.resolve() == common_dispatcher.resolve():
command.extend(["--execution-catalog", execution_catalog])
command.extend(["--execution-catalog", execution_catalog])
return command

View file

@ -51,7 +51,7 @@ class PrepareWorkspaceTest(unittest.TestCase):
Path("/tmp/example/sample-feature-worktree"),
)
def test_dispatcher_prefers_project_override_and_private_pair(self) -> None:
def test_dispatcher_uses_common_runtime_only(self) -> None:
with tempfile.TemporaryDirectory() as raw:
workspace = Path(raw)
common = (
@ -70,24 +70,15 @@ class PrepareWorkspaceTest(unittest.TestCase):
path.parent.mkdir(parents=True, exist_ok=True)
path.touch()
self.assertEqual(MODULE.dispatcher_script(workspace), project)
(private_root / "SKILL.md").touch()
self.assertEqual(MODULE.dispatcher_script(workspace), private)
project.unlink()
self.assertEqual(MODULE.dispatcher_script(workspace), common)
def test_dispatcher_command_injects_catalog_only_for_common_runtime(self) -> None:
def test_dispatcher_command_always_injects_catalog(self) -> None:
workspace = Path("/repo")
common = (
workspace
/ "agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py"
)
project = (
workspace
/ "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py"
)
common_command = MODULE.dispatcher_command(
workspace=workspace,
@ -95,15 +86,7 @@ class PrepareWorkspaceTest(unittest.TestCase):
task_group="m-sample",
execution_catalog="/runtime/catalog.json",
)
project_command = MODULE.dispatcher_command(
workspace=workspace,
dispatcher=project,
task_group="m-sample",
execution_catalog="/runtime/catalog.json",
)
self.assertIn("--execution-catalog", common_command)
self.assertNotIn("--execution-catalog", project_command)
def test_epic_document_range_is_one_based_and_inclusive(self) -> None:
epics = MODULE.parse_epics(

View file

@ -1,308 +0,0 @@
---
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.
---
# Orchestrate Agent Task Loop
## 🚨 ABSOLUTE PRIORITY — NEVER SEND `final` EXCEPT IN THE TWO CASES BELOW
> [!CAUTION]
> **This section overrides every success, blocker, exit-code, error-handling, and termination rule below.**
>
> **Never send on the `final` channel or end the caller turn unless at least one of the two titled permissions below applies. Never infer another exception from a lower section or runtime condition.**
### `final` Permission 1 — Verified Successful Completion
Allow `final` only after every condition below is true:
- Every user-defined completion condition is satisfied.
- Every observed task in every in-scope task group has a verified archived `complete.log`.
- Every generated `WORK_LOG.md` is archived as `work_log_N.log`.
- No active pair or running, pending, or blocked task remains.
- The final dispatcher exit code is `0`.
### `final` Permission 2 — Explicit User Instruction to Stop This Run
Allow `final` when the user explicitly instructs the caller to stop the current run and return through `final`.
### Persistent-Run Instructions Revoke Successful-Completion Permission
If the user says “do not stop,” “never send final,” “keep going,” or gives an equivalent persistent-run instruction, verified success alone does not permit `final`. Only an explicit user instruction to stop the current run or return through `final` releases this restriction.
### Every Other User-Visible Message Must Use `commentary`
Use only the `commentary` channel for every user-visible message before `final` is permitted. This includes status, partial success, completion candidates, blockers, failures, questions, apologies, waits, retries, and recovery guidance.
Partial success, FAIL/WARN, USER_REVIEW, a blocker, retry exhaustion, timeout, a tool error, plan-generation failure, dispatcher exit code `2` or `3`, child exit, loss of a session/cell, and context compaction never permit `final`.
Dispatcher stdout streamed directly by the execution layer is tool output, not a caller-authored message. Never spend an LLM turn restating, summarizing, or relaying a routine dispatcher event.
### Child Prompt Text Never Grants Caller `final` Permission
The prompt-contract phrase `Final in Korean.` controls only the child model response language. It never authorizes the caller to use the `final` channel.
## Purpose
Monitor the file-based state contract under `agent-task/` and converge the workflow from ready PLAN implementation through official code review and follow-up PLANs. Let the script determine filenames, dependencies, slots, and session locators; let each CLI agent make semantic implementation and review decisions.
Treat Korean text inside code spans or fenced examples as exact runtime or file-contract literals. Keep all surrounding instructions in English, and never translate those literals unless the runtime contract changes.
## Inputs
- `workspace`: Trusted repository root containing `agent-task/` (optional; defaults to the current directory).
- `task_group`: Name of a specific `agent-task/<task_group>` to run (optional).
- `dry_run`: Inspect state, routes, and dependencies without starting a CLI (optional).
- `max_parallel`: Non-negative integer cap on unique active task-stage attempts across the physical workspace. Omission defaults to `3`; explicit `0` is unlimited. `--task-group` does not narrow occupancy, adopted external attempts count, internal helper coroutines do not count separately, and an override must be supplied again after restart.
- `retry_blocked`: Explicitly retry the same PLAN blocked by a previous dispatcher run in non-dry-run mode (optional). With `task_group`, reset only that group's blockers and 10-attempt counters while preserving other group state.
## Preconditions
- [ ] Read the current state contracts in `agent-ops/skills/common/plan/SKILL.md` and `agent-ops/skills/common/code-review/SKILL.md`.
- [ ] Verify that `codex`, `claude`, `agy`, `opencode`, and `pi` are on PATH and their login/provider configuration is valid.
- [ ] Limit automatic approval to PLAN execution inside the current workspace; do not expand scope to external-system changes or destructive work.
- [ ] Verify that no other dispatcher is running in the same workspace. Never bypass a workspace-lock failure.
- [ ] Run `--dry-run` before the first live run to inspect active-task classification and dependency state.
## Routing Contract
`scripts/execution_target_catalog.json` is the only assignment source. Do not
reinterpret a target from task prose or environment variables.
- `lanes.worker` and `lanes.review` each define every `local-G01` through
`local-G10` and `cloud-G01` through `cloud-G10` independently.
- A lane's `candidates` array is its complete ordered execution chain. The first
eligible target is the default and qualified terminal failures advance to the
next unused eligible target without returning to an earlier rank.
- `targets` owns adapter, model, command model, execution class, self-check,
thinking, and reasoning options. `selfcheck.full_review` and
`selfcheck.checklist_review` independently enable the full-work review and
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:
- 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 execution has no model-specific dispatcher limit; the global physical-workspace cap applies.
- agy: 1.
- Official review: no separate review-only limit; subject to the global
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.
- 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.
- Run official reviews for different dependency-ready tasks with disjoint workspace claims in parallel.
- Before the first review batch, normalize the Agent-Ops-managed `.gitignore` block once so reviews do not concurrently modify the same shared control file.
- Require exactly one valid, non-empty `Modified Files Summary` (and legacy `수정 파일 요약`) in the active or recovery PLAN. Fail the task closed when any path is broad, outside the workspace, a directory, malformed, or missing.
- Atomically claim every canonical modified-file path before admitting worker, self-check, or review. A collision is a runtime wait, not a predecessor dependency. Retain the task's claim through every stage, retry, dispatcher restart, and follow-up PLAN; replace or expand its own claim only when the new set does not collide, and release it only after verifying the completed archive.
- Scope write claims to the canonical physical workspace. Separate worktrees and clones use independent state and may run in parallel; task-group filtering never narrows the claim ledger inside one workspace.
## Prompt Contract
Keep control prompts in English, insert absolute paths only, and do not expand these sentences unnecessarily.
- 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.`
- Keep self-check prompts short. Start full-review, checklist-review, and recovery prompts with: `Think 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.`
- 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.`
- 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.`
- 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.`
- 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`.
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.
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.
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.
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.
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 Pi locator whose catalog target enables `runtime.native_session_resume`, 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
- Keep exactly one `agent-task/{task_group}/WORK_LOG.md` per task group. Do not create one in a split-subtask directory.
- Allow only the dispatcher to modify this file. Worker/self-check/review models need not read or update it, and success must not depend on its prose.
- Append chronological `START`/`FINISH` rows with time, task, loop, role, attempt, model, result, and locator. In `task`, record the active role artifact relative to `agent-task/`: the PLAN path for a worker and the CODE_REVIEW path for self-check/review. In `loop`, record the PLAN identity's zero-based `plan` number (`0` is the initial plan). Record time in KST (`UTC+09:00`) as `YY-MM-DD HH:MM:SS`, for example `26-07-26 07:40:15`. Use this single timeline to inspect parallel execution order.
- Do not require the common code-review skill to preserve `WORK_LOG.md`. For split work the group log normally remains in the parent because review moves only the selected subtask. For a single task review may move the log with the task archive; after review exits, resolve exactly one source from the active group path or verified completed archive and normalize it to `work_log_N.log`.
- After every observed task in a task group has a verified complete archive and no active/running task remains, append the final `FINISH` and move the generated `WORK_LOG.md` under the final completed archive's group root as `work_log_N.log`. If an archive exists after restart but the last `START` lacks `FINISH`, do not terminate or archive while any PID/start token, per-attempt process marker, or pidless stream/native evidence remains live. Track it until execution evidence has ended and the complete archive is verified, then append `FINISH` with `reconciled:verified-complete-archive` and move the log. Use `agent-task/archive/YYYY/MM/{task_group}/` for split tasks and the actual suffix-bearing archive destination for a single task. Set `N` to one more than the maximum suffix for the same task group across all months, starting at `0`.
- If `WORK_LOG.md` archiving fails or multiple active/archive sources exist, drain other independent work and return non-terminal exit `3` for retry. Return successful exit `0` only after a completed group that generated a log has no active `WORK_LOG.md` and its `work_log_N.log` is verified. Keep an incomplete group's `WORK_LOG.md` active for blocker or exit `3` recovery.
- Split each attempt locator into `stream.log` for model stdout/stderr and `heartbeat.log` for dispatcher state. Determine health only from the newest progress in `stream.log` and native session events; never use heartbeat mtime as progress evidence. Do not copy either log into `WORK_LOG.md`.
- Keep child stdout/stderr, normalized model output, and periodic heartbeat records in locator-owned logs only. The dispatcher's user-visible stdout is an event stream and must never mirror model stream lines or heartbeat ticks.
- If locator refresh temporarily fails after an attempt starts, do not terminate a live model process or start a duplicate task. Record a warning, keep monitoring, and preserve error evidence at the next successful refresh.
- After verifying a PASS archive's `complete.log` and confirming no live execution evidence for that task, delete all of its attempt directories, including locators, native sessions, `stream.log`, `heartbeat.log`, and CLI auxiliary logs. Do not delete them while a model process or conservatively active pidless stream/native evidence remains. Treat transient deletion failure as non-terminal exit `3` for the next reconciliation without blocking the completed task or other tasks; do not return successful exit `0` while any attempt directory remains. Preserve failed or blocked attempt logs as recovery evidence.
- Record log-creation or append failure in the locator as `work-log-setup` or `work-log-runtime-write` and block the task.
- Exclude dispatcher-authored `WORK_LOG.md` changes from official-review progress/stagnation signatures. Count only real changes in PLAN/CODE_REVIEW, review logs, and the write-set.
## Caller Lifecycle and Status Display
- **ABSOLUTE RULE — Do not stop the whole task group when a task-local blocker appears.** Delay only the blocked task and consumers that require its incomplete result as a predecessor. Keep the caller turn active until every independent ready/running task finishes.
- **ABSOLUTE RULE — Scan the complete new-task candidate set only on initial dispatcher entry and immediately after creating a verified `complete.log`.** After a worker/self-check/review attempt ends or a task changes stage, reclassify only that task. After `complete.log` is created, immediately start every runnable task except currently running tasks in the same pass. Another task's execution, wait, dependency, review, or recovery state must not block a candidate. If no candidate or running task remains and only blockers and their dependent waits remain, exit with code `2`.
- Treat the dispatcher as the execution lifecycle and observation owner. It performs deterministic health checks, recovery, retries, routing, and state transitions without caller-LLM supervision. The caller owns only launch authorization, intervention after an attention event, and the `final` gate.
- Keep the caller turn suspended and launch the dispatcher as one persistent foreground execution. Use execution-layer event waiting or direct stdout streaming; never use an LLM-generated polling turn as a keepalive. Never start a duplicate dispatcher while the child is live.
- Never wrap the dispatcher in `timeout`, a short `wait_for`, or an arbitrary cancel/terminate wrapper. Tool yield or expiration of a response window is not process termination. Resume the same execution-layer wait without commentary, analysis, or inspection.
- **ABSOLUTE RULE — The caller never monitors.** During normal execution or event silence, do not run a timer loop, periodically poll through the model, or inspect `ps`, dispatcher `--dry-run`, `state.json`, locator files, `stream.log`, `heartbeat.log`, or `WORK_LOG.md`. A tool yield, empty wait, routine lifecycle event, or response-window expiration does not permit caller-LLM involvement.
- Stream routine lifecycle banners directly from dispatcher stdout to the user without routing them through the caller LLM. Routine events include starts, deterministic retries/recovery, waits, per-task review results, per-task completion while other work remains, and any event for which the dispatcher has already selected the next action.
- Wake the caller LLM only for an attention event that the dispatcher cannot resolve autonomously: a verified `USER_REVIEW` decision, an exhausted terminal blocker, an unrecoverable state/log contract error, loss of the execution handle that requires targeted recovery, or terminal dispatcher exit. A warning or automatic retry is not an attention event merely because it reports an error.
- No dispatcher output, an empty wait, or a wait-window expiration is normal event silence. It never permits `final`, caller termination, a duplicate dispatcher, a state inspection, or a model wake-up. Keep the execution-layer wait attached with the longest supported window.
- A lost session/cell exists only when the execution layer reports the tracked identifier unavailable or aborted, or reports the child process exited; a normal wait return alone is insufficient. Then perform exactly one reinspection of active tasks, locators, PIDs, and state. If that snapshot proves a live dispatcher owner, do not inspect it again until an attention event is observed. Resume event waiting from the same session/cell when available; otherwise subscribe from EOF to only newly appended START/FINISH rows in the task-group WORK_LOG.md. If the fallback observer itself ends without an event while the dispatcher remains live, reattach the same EOF-only observer without reading any prior row or inspecting state. A routine START/FINISH row or direct output only confirms the subscription and does not permit model wake-up or state inspection. Only a dispatcher exit, explicit attention event, fallback-observer error, or explicit user request permits the next targeted inspection. Exit code `0` is successful terminal state. Exit code `2` is a drained blocker or explicit persistent-state-error terminal state. Exit code `3` is a non-terminal tracking state, including another dispatcher workspace lock, a live external agent, or an unexpected dispatcher interruption; inspect PID, locator, and state only after that event.
- On a scheduler/control-plane exception or unexpected exception in an individual agent coroutine, do not immediately freeze it as a task blocker or let the dispatcher event loop cancel other running agents and child processes. Monitor every independent running agent until natural completion, return non-terminal exit `3`, and let the next dispatcher reconcile file and state results. Even when the original exception is a persistent-state error, do not convert it to exit `2` if any agent was running.
- In drained-blocker terminal state, persist the orchestration group as `blocked`, directly blocked tasks as `blocked`, consumers waiting on their predecessors as `waiting`, and verified independent completed tasks as `complete` in `.git/agent-task-dispatcher/state.json`. On re-entry, set incomplete observed tasks back to orchestration state `active`, then reevaluate actual task-local blockers and dependencies.
- Persist observed tasks and the complete same-name archive baseline present at startup, regardless of `complete.log`, in `.git/agent-task-dispatcher/state.json`. If an active task disappears after child restart, recover completion only when exactly one new `complete.log` archive absent from the baseline exists; block when none or multiple exist. Do not count a late `complete.log` added to an incomplete archive that existed before execution as current-run completion.
- If existing `state.json` cannot be read or validated as a JSON object, block the dispatcher. Never replace it with empty state or reset the 10-attempt budget. Repair or explicitly handle it before rerunning.
- When a new user turn arrives, continue tracking the same overall request unless it explicitly cancels the previous request.
- Let the execution layer display `작업시작`, `자가검증시작`, `리뷰시작`, `리뷰재시도`, `Pi복구재시도`, `세션응답복구재시도`, `세션연결재시도`, `리뷰결과`, `작업대기`, `작업차단`, `디스패치추적대기`, and `작업완료` directly from dispatcher stdout. Never duplicate them in model-authored `commentary`. Use `commentary` only when an attention event actually requires caller reasoning or a user decision. Event silence never grants `final`; only the two permissions in the absolute-priority section do.
- 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.
- 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.
- 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.
- For a Pi locator whose target enables `runtime.native_session_resume`, prefer the Prompt Contract's same-session resume for `context-limit`/`session-stall` and display `Pi세션연속재시작`. Use a fresh session and `세션응답복구재시도` for Pi targets without that capability.
- Do not stop for user review based on filename alone. Recognize a `user-review` terminal blocker only when the active task's `USER_REVIEW.md` contains `상태: USER_REVIEW`, exactly one supported type, a concrete target, non-`없음`/`미정` blocker rationale, unresolved user actions or decisions, and resume conditions that prevent the next safe implementation step. For `milestone-lock`, require a real `agent-roadmap/**/milestones/*.md` target. For `external-execution`, require an exact runner/device/service/access target and evidence that no authorized automatic executor can perform the required verification. If the form is incomplete or conflicts with active PLAN/CODE_REVIEW, block it as a task-state contract error instead.
- Recognize `## Code Review Result` (with `Overall Verdict: PASS|WARN|FAIL`) or legacy `## 코드리뷰 결과` (with `종합 판정: PASS|WARN|FAIL`) as the review verdict. If both canonical and legacy headings are present in the same file, fail closed. Never parse the same string in implementation evidence, command output, or example text as the runtime verdict.
- Locator/raw logs under `.git/agent-task-dispatcher/runs/` are internal recovery state and may not appear in the normal project tree. Include the `locator=` path emitted when the dispatcher starts an attempt and the task-group `WORK_LOG.md` path in status updates.
- If a specified `task_group` has neither an observed active task nor a persisted completed task, return state error `unobserved-task-group` with exit code `2`; never treat it as empty completion.
- If child failure is recoverable inside the repository, continue within the 10-attempt budget. After draining independent work, report a blocker that the caller cannot clear in the current turn—such as exhausted budget, required user decision, or external permission—with its path, evidence, and resume condition.
## Failure Classification and Reporting Contract
- Record dispatcher PID, actual agent PID, import time, source path, import-time SHA-256, attempt-start current SHA-256, and `dispatcher_source_matches_loaded` in every attempt locator. Every failure banner and subsequent status must present the locator's exact `failure_class`, `failure_source`, `provider_transport_failure_confirmed`, `dispatcher_pid`, `agent_pid`, `dispatcher_source_sha256`, source-match state, and `locator`; never summarize them into a broader cause.
- A running Python dispatcher does not hot-reload source edits. If `dispatcher_source_matches_loaded=false`, do not claim that new rules are active. Report the loaded/current hashes and execution-version difference until the process-owning session can safely exit and restart.
- Use `provider-connection` or `provider-stream-disconnect` only when original CLI terminal diagnostics contain a strong provider pattern in provider/backend/SSE context. Do not infer provider failure from `connection refused`, `dial tcp`, or `curl` peer failure in ordinary tool/test stderr. For a confirmed attempt, preserve `failure_source=provider-terminal-diagnostic`, `provider_transport_failure_confirmed=true`, `failure_evidence_source`, and `failure_evidence_excerpt` in the locator.
- Treat legacy locator `session-stall` as a record of an earlier dispatcher timeout policy, not as provider failure. During recovery, report `failure_source=dispatcher-timeout`, `provider_transport_failure_confirmed=false`, `termination_initiator=dispatcher`, and the original timeout phase/seconds. Never let the current dispatcher create a new silence timeout.
- Record a SIGTERM-family termination not initiated by the dispatcher as `process-terminated`, with `failure_source=process-termination` and `termination_initiator=unknown`. Never classify exit code `143` as provider failure without actual provider terminal evidence.
- Do not generalize one `pi -p` fresh/isolated session attempt to a Pi TUI or system-wide provider outage. Describe a system-level provider outage only with additional controlled reproduction using the same command, model, and prompt, or backend-health evidence.
- Count `process-terminated` in the same per-stage consecutive-failure budget as other automatic-recovery classes. On the 10th consecutive failure, block that task; never reset the budget after cooldown or auto-resume. A shared budget does not imply common causation or establish provider-failure evidence.
## Procedure
1. **Inspect state.**
- Print active tasks, routes, stages, and dependencies:
```bash
python3 agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py --dry-run
```
- Treat `NN_...` as immediately eligible. Treat `NN+PP[,QQ...]_...` as eligible only after each predecessor's `complete.log` is found once in the active or narrow archive lookup for the same task group and predecessor execution evidence has ended.
- Never infer an implicit dependency from numeric order alone.
2. **Run the dispatcher.**
- Run all active tasks with the default physical-workspace cap of `3`:
```bash
python3 agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py
```
- Run one task group:
```bash
python3 agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py --task-group <task_group>
```
- Cap total concurrent attempts across the physical workspace:
```bash
python3 agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py --max-parallel 2
```
- Explicitly disable the cap:
```bash
python3 agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py --max-parallel 0
```
- Preview classification without launching CLIs under the same cap:
```bash
python3 agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py --dry-run --max-parallel 2
```
- 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.
- **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.
- 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.
- 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`.
- 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.**
- 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.
- 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.
- 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.
- 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.
- 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.
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.
- 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.
- Delegate PASS archive, WARN/FAIL follow-up pairs, and review-finalization recovery to the `code-review` file contract.
- Reclassify any remaining active pair and send it to worker or review.
- Declare stagnation only when the plan write-set source snapshot and review/finding artifacts are all unchanged. Display `루프정체경고` and retry with backoff; on the 10th unchanged attempt, block that task as `review-no-progress-limit`.
- Record a verified `USER_REVIEW.md`, dependency ambiguity, 10 repeated failures, or work-log setup/runtime-write failure only as that task's blocker. Delay only the blocker and consumers that depend on it; continue every independent ready/running task. Return drained terminal blocker exit code `2` only when no independent work remains.
## 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.
- [ ] Confirm the actual CLI/model for each route matches its catalog lane array.
- [ ] 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.
- [ ] 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).
- [ ] 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`.
- [ ] 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`.
- [ ] Verify that a PASS task is archived and each newly released dependent task starts.
- [ ] For success, verify every task's `complete.log`. For blocker exit, verify that no ready/running task remains and only task-local blockers and their dependent waits remain.
- [ ] Verify dispatcher stdout contains lifecycle/attention events only; raw child output and heartbeat ticks remain in locator-owned logs and never require caller-LLM relay.
- [ ] On blocking, output the task, reason, and locator.
- If verification fails, stop the dispatcher and report only the cause without manually moving or overwriting active PLAN/CODE_REVIEW files.
## Output Format
```text
------------------------------------------
작업시작: 03+01_event_contract_unit_tests
------------------------------------------
model=<adapter>/<target> <options>
plan=/absolute/path/PLAN-local-G05.md
work_log=/absolute/path/WORK_LOG.md
------------------------------------------
리뷰시작: 03+01_event_contract_unit_tests
------------------------------------------
model=<adapter>/<target> <options>
review=/absolute/path/CODE_REVIEW-local-G05.md
```
Use the same separator format for `작업대기`, `작업수행중`, `자가검증시작`, `로그보완재시도`, `모델승격`, `리뷰결과`, `루프정체경고`, `작업차단`, `작업로그아카이브`, and `작업완료`.
## Prohibitions
- Never print periodic heartbeat ticks or child model stdout/stderr to dispatcher stdout. Preserve them only in locator-owned logs.
- Never reevaluate PLAN/CODE_REVIEW lane or G in the dispatcher or rename those files.
- 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 ask a worker to perform official review, archive work, or create `complete.log`.
- Never treat either self-check stage as official review.
- 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 resolve `USER_REVIEW.md` automatically or guess a user decision.

View file

@ -1,4 +0,0 @@
interface:
display_name: "Agent Task Loop Orchestrator"
short_description: "Orchestrate catalog-routed PLAN and review loops"
default_prompt: "Use $orchestrate-agent-task-loop to execute the active agent-task workflow."

View file

@ -1,26 +0,0 @@
#!/usr/bin/env python3
"""Observation output emitter and formatting utilities for agent-task dispatcher."""
from __future__ import annotations
SEP = "-" * 42
def banner(event: str, task: str, lines: list[str] | None = None) -> None:
display_task = task.rsplit("/", 1)[-1]
print(SEP, flush=True)
print(f"{event}: {display_task}", flush=True)
print(SEP, flush=True)
if display_task != task:
print(f"task={task}", flush=True)
for line in lines or []:
print(line, flush=True)
def attempt_event(prefix: str, message: str) -> None:
print(f"{prefix} {message}", flush=True)
def validation_claim(path: str) -> None:
"""Emit one canonical write claim for standalone PLAN validation."""
print(path, flush=True)

View file

@ -1,627 +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",
"runtime": {
"native_session_resume": true
}
},
"legacy-pi-selfcheck-disabled": {
"adapter": "pi",
"target": "iop/glm-5.2",
"execution_class": "local_model",
"selfcheck": {
"full_review": false,
"checklist_review": false
},
"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-5",
"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",
"runtime": {
"same_target_retry_limit": 1
}
},
"codex-sol-xhigh": {
"adapter": "codex",
"target": "gpt-5.6-sol",
"execution_class": "cloud_model",
"selfcheck": {
"full_review": false,
"checklist_review": false
},
"reasoning_effort": "xhigh",
"runtime": {
"same_target_retry_limit": 1
}
},
"codex-terra-high": {
"adapter": "codex",
"target": "gpt-5.6-terra",
"execution_class": "cloud_model",
"selfcheck": {
"full_review": false,
"checklist_review": false
},
"reasoning_effort": "high",
"runtime": {
"same_target_retry_limit": 1
}
}
},
"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": {
"agy-gemini-low": "claude-opus-xhigh",
"agy-gemini-medium": "claude-opus-xhigh",
"agy-gemini-high": "claude-opus-xhigh",
"opencode-glm-medium": "codex-terra-high",
"opencode-glm-high": "codex-terra-high",
"opencode-glm-max": "codex-terra-high",
"legacy-claude-glm": "codex-terra-high",
"claude-opus-xhigh": "codex-terra-high",
"claude-haiku-xhigh": "codex-terra-high"
}
}

View file

@ -1,72 +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.native_session_resume:
raise error_type(
f"{path}: native_session_resume is only valid for pi"
)
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"
)

View file

@ -1,558 +0,0 @@
#!/usr/bin/env python3
"""Catalog-backed execution-target policy for Agent Task stages."""
from __future__ import annotations
from dataclasses import dataclass
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
KST = ZoneInfo("Asia/Seoul")
VALID_STAGES = {"worker", "review"}
VALID_LANES = {"local", "cloud"}
VALID_PI_THINKING_LEVELS = frozenset({"low", "medium", "high"})
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)
class RouteTarget:
adapter: str
target: str
execution_class: str
selfcheck_required: bool
selfcheck_full_review: bool = False
selfcheck_checklist_review: bool = False
thinking_level: str | None = None
reasoning_effort: str | None = None
command_model: str | None = None
native_session_resume: bool = False
same_target_retry_limit: int = 0
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)
class PolicyDecision:
rule_id: str
policy_priority: int
reason_codes: tuple[str, ...]
time_window: str
candidates: tuple[RouteTarget, ...]
route_id: str
catalog_revision: str
class CatalogError(ValueError):
"""Raised when the operator-owned model catalog is malformed."""
def _load_target_contract():
module_name = "execution_target_contract"
loaded = sys.modules.get(module_name)
if loaded is not None:
return loaded
path = Path(__file__).with_name("execution_target_contract.py")
spec = importlib.util.spec_from_file_location(module_name, path)
if spec is None or spec.loader is None:
raise CatalogError(f"target contract load failed: {path}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
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",
"runtime",
}
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")
runtime = _object(item.get("runtime", {}), f"{path}.runtime")
runtime_fields = {"native_session_resume", "same_target_retry_limit"}
unknown_runtime = sorted(set(runtime) - runtime_fields)
if unknown_runtime:
raise CatalogError(
f"{path}.runtime has unknown fields: {unknown_runtime}"
)
native_session_resume = runtime.get("native_session_resume", False)
if not isinstance(native_session_resume, bool):
raise CatalogError(
f"{path}.runtime.native_session_resume must be a boolean"
)
same_target_retry_limit = runtime.get("same_target_retry_limit", 0)
if (
isinstance(same_target_retry_limit, bool)
or not isinstance(same_target_retry_limit, int)
or same_target_retry_limit < 0
):
raise CatalogError(
f"{path}.runtime.same_target_retry_limit must be a non-negative integer"
)
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,
native_session_resume=native_session_resume,
same_target_retry_limit=same_target_retry_limit,
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)
global CATALOG, CATALOG_REVISION, CATALOG_TARGETS_BY_ID, CANONICAL_TARGETS
CATALOG = catalog
CATALOG_REVISION = catalog.revision
CATALOG_TARGETS_BY_ID = catalog.targets
CANONICAL_TARGETS = tuple(catalog.targets.values())
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
def canonical_target(
adapter: str,
target: str,
thinking_level: str | None = None,
reasoning_effort: str | None = None,
) -> RouteTarget | None:
"""Resolve one unambiguous catalog target from its runtime identity."""
matches = tuple(
candidate
for candidate in CANONICAL_TARGETS
if (
candidate.adapter == adapter
and candidate.target == target
and (
thinking_level is None
or candidate.thinking_level == thinking_level
)
and (
reasoning_effort is None
or candidate.reasoning_effort == reasoning_effort
)
)
)
return matches[0] if len(matches) == 1 else None
def promotion_target(current: RouteTarget) -> RouteTarget | None:
"""Return a legacy promotion target declared by the catalog."""
if current.catalog_id is None:
return None
destination = CATALOG.promotions.get(current.catalog_id)
return CATALOG.targets.get(destination) if destination else None
@dataclass(frozen=True)
class QuotaProbeSpec:
command: str
target: str
required_caps: tuple[str, ...]
def quota_probe_spec(target: RouteTarget) -> QuotaProbeSpec | None:
"""Return the driver-owned quota probe spec for a route target."""
if target.execution_class == "local_model":
return None
if target.adapter == "agy":
return QuotaProbeSpec(
command="agy",
target=target.target,
required_caps=("overall", f"model:{target.target}"),
)
if target.adapter in {"claude", "codex"}:
return QuotaProbeSpec(
command=target.adapter,
target=target.target,
required_caps=("overall",),
)
return None
def _validate(stage: str, lane: str, grade: int, evaluated_at: datetime) -> None:
if stage not in VALID_STAGES:
raise ValueError(f"unsupported stage: {stage}")
if lane not in VALID_LANES:
raise ValueError(f"unsupported lane: {lane}")
if not 1 <= grade <= 10:
raise ValueError(f"grade must be in G01..G10: {grade}")
if evaluated_at.tzinfo is None or evaluated_at.utcoffset() is None:
raise ValueError("evaluated_at must be timezone-aware")
def _kst_time_window(evaluated_at: datetime) -> str:
kst_time = evaluated_at.astimezone(KST).time()
if 7 <= kst_time.hour < 23:
return "kst-day-[07:00,23:00)"
return "kst-night-[23:00,07:00)"
def select_policy(
*, stage: str, lane: str, grade: int, evaluated_at: datetime
) -> PolicyDecision:
"""Return the ordered targets for one explicit stage/lane/grade entry."""
_validate(stage, lane, grade, evaluated_at)
lane_id = f"{lane}-G{grade:02d}"
lane_policy = CATALOG.lanes[stage][lane_id]
if lane_policy.time_windows is not None:
time_window = _kst_time_window(evaluated_at)
rule_id, reason_codes = lane_policy.time_windows[time_window]
else:
time_window = "not_applicable"
assert lane_policy.rule_id is not None
rule_id, reason_codes = lane_policy.rule_id, lane_policy.reason_codes
return PolicyDecision(
rule_id=rule_id,
policy_priority=lane_policy.policy_priority,
reason_codes=reason_codes,
time_window=time_window,
candidates=tuple(
CATALOG.targets[target_id] for target_id in lane_policy.candidates
),
route_id=f"{stage}:{lane_id}",
catalog_revision=CATALOG.revision,
)

View file

@ -1,363 +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],
target_resolver=None,
) -> 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")
selected = record.get("selected")
selected = selected if isinstance(selected, dict) else {}
command_model = record.get("command_model") or selected.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
canonical = None
if target_resolver is not None:
resolver_reasoning = reasoning_effort or (
"max" if cli == "opencode" else None
)
try:
canonical = target_resolver(
cli,
model,
thinking_level,
resolver_reasoning,
)
except (AttributeError, TypeError, ValueError):
canonical = None
if canonical is not None:
if command_model is None:
command_model = canonical.command_model
if reasoning_effort is None:
reasoning_effort = canonical.reasoning_effort
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, target_resolver=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, target_resolver)
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_route_target(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,
selector.policy.canonical_target,
)
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_route_target(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}"
)
if (execution_class, selfcheck) != ("local_model", True):
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,
target_resolver=None,
) -> 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")
canonical = None
if target_resolver is not None:
resolver_reasoning = reasoning or (
"max" if adapter == "opencode" else None
)
try:
canonical = target_resolver(
adapter,
target,
thinking,
resolver_reasoning,
)
except (AttributeError, TypeError, ValueError):
canonical = None
if canonical is not None:
if command_model is None:
command_model = canonical.command_model
if reasoning is None:
reasoning = canonical.reasoning_effort
_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":
if command_model is None:
raise error_type(
"claude-glm completing decision에 command_model이 없다"
)
return AgentSpec(
adapter,
target,
f"{adapter}/{target} xhigh",
command_model=str(command_model),
)
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}"
)
if command_model is None:
raise error_type(
"opencode completing decision에 command_model이 없다"
)
effort = str(reasoning or "max")
return AgentSpec(
adapter,
target,
f"{adapter}/{target} {effort}",
reasoning_effort=effort,
command_model=str(command_model),
)
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,
)

View file

@ -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
)

View file

@ -1,294 +0,0 @@
import ast
import asyncio
import importlib.util
import io
import json
import os
import re
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
SCRIPT = Path(__file__).parents[1] / "scripts" / "dispatch.py"
loaded = sys.modules.get("agent_task_dispatch")
if loaded is not None:
dispatch = loaded
else:
SPEC = importlib.util.spec_from_file_location("agent_task_dispatch", SCRIPT)
assert SPEC and SPEC.loader
dispatch = importlib.util.module_from_spec(SPEC)
sys.modules[SPEC.name] = dispatch
SPEC.loader.exec_module(dispatch)
def make_test_task(root: Path) -> dispatch.Task:
plan = root / "PLAN-local-G05.md"
review = root / "CODE_REVIEW-local-G05.md"
plan.write_text("<!-- task=test plan=0 tag=TEST -->\n", encoding="utf-8")
review.write_text("<!-- task=test plan=0 tag=TEST -->\n", encoding="utf-8")
return dispatch.Task(
name="test",
directory=root,
plan=plan,
review=review,
user_review=None,
recovery=False,
lane="local",
grade=5,
)
class ObservationOutputTest(unittest.TestCase):
def test_banner_preserves_existing_format_and_nested_task_identity(self):
buffer = io.StringIO()
with mock.patch("sys.stdout", buffer):
dispatch.banner("START", "group/subtask/task_name", ["line 1", "line 2"])
output = buffer.getvalue()
expected = (
"------------------------------------------\n"
"START: task_name\n"
"------------------------------------------\n"
"task=group/subtask/task_name\n"
"line 1\n"
"line 2\n"
)
self.assertEqual(output, expected)
buffer_flat = io.StringIO()
with mock.patch("sys.stdout", buffer_flat):
dispatch.banner("START", "task_name")
output_flat = buffer_flat.getvalue()
expected_flat = (
"------------------------------------------\n"
"START: task_name\n"
"------------------------------------------\n"
)
self.assertEqual(output_flat, expected_flat)
def test_attempt_event_is_one_flushed_stdout_line(self):
buffer = io.StringIO()
with mock.patch("sys.stdout", buffer):
dispatch.attempt_event("[test-prefix]", "event message detail")
output = buffer.getvalue()
self.assertEqual(output, "[test-prefix] event message detail\n")
def test_dispatch_compatibility_aliases_point_to_observation_module(self):
self.assertEqual(dispatch.SEP, dispatch.observation.SEP)
self.assertIs(dispatch.banner, dispatch.observation.banner)
self.assertIs(dispatch.attempt_event, dispatch.observation.attempt_event)
def test_observation_module_identity_is_reused(self):
module1 = dispatch.load_sibling_observation_module()
module2 = dispatch.load_sibling_observation_module()
self.assertIs(module1, module2)
self.assertIs(module1, sys.modules["agent_task_dispatcher_observation"])
def test_dispatch_has_no_direct_stdout_print_calls(self):
source = SCRIPT.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(SCRIPT))
stdout_prints = []
for node in ast.walk(tree):
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id == "print":
is_stderr = False
for kw in node.keywords:
if kw.arg == "file":
val = kw.value
if (
isinstance(val, ast.Attribute)
and isinstance(val.value, ast.Name)
and val.value.id == "sys"
and val.attr == "stderr"
):
is_stderr = True
break
if not is_stderr:
stdout_prints.append(node.lineno)
self.assertEqual(
stdout_prints,
[],
f"found direct stdout print() calls on lines: {stdout_prints}",
)
class ObservationInvokeIntegrationTest(unittest.IsolatedAsyncioTestCase):
async def test_heartbeat_and_child_output_stay_in_logs_not_user_event_stream(self):
with tempfile.TemporaryDirectory() as temporary:
workspace = Path(temporary)
(workspace / ".git").mkdir()
task = make_test_task(workspace)
store = dispatch.StateStore(workspace)
session_id = "11111111-1111-1111-1111-111111111111"
def command_for(
spec,
prompt,
cwd,
actual_session_id,
attempt_dir,
pi_resume_session=None,
):
self.assertEqual(actual_session_id, session_id)
native = attempt_dir / "pi-sessions" / f"session_{session_id}.jsonl"
child = (
"from pathlib import Path\n"
"import sys,time\n"
"path = Path(sys.argv[1])\n"
"path.parent.mkdir(parents=True, exist_ok=True)\n"
"path.write_text("
"'{\"type\":\"session\",\"version\":3,\"id\":\"test\","
"\"timestamp\":\"2026-07-25T00:00:00.000Z\","
"\"cwd\":\"/tmp/test\"}\\n', encoding='utf-8')\n"
"time.sleep(0.05)\n"
"print('done', flush=True)\n"
)
return [sys.executable, "-c", child, str(native)]
spec = dispatch.AgentSpec("pi", "ornith:35b", "pi", local_pi=True)
try:
with (
mock.patch.object(dispatch, "build_command", side_effect=command_for),
mock.patch.object(dispatch.uuid, "uuid4", return_value=session_id),
mock.patch.object(dispatch, "STREAM_HEARTBEAT_SECONDS", 0.01),
mock.patch("builtins.print") as print_mock,
):
rc, failure, locator = await dispatch.invoke(
workspace, store, task, "review", spec, "Reply briefly."
)
finally:
store.close()
self.assertEqual(rc, 0)
self.assertIsNone(failure)
record = json.loads(locator.read_text(encoding="utf-8"))
self.assertTrue(record["native_session_path"].endswith(f"{session_id}.jsonl"))
self.assertIsInstance(record["native_session_mtime_ns"], int)
heartbeat = Path(record["heartbeat_log"]).read_text(encoding="utf-8")
self.assertIn("[heartbeat] 작업중...", heartbeat)
self.assertIn("native_session=", heartbeat)
self.assertIn("native_mtime_ns=", heartbeat)
stream = Path(record["stream_log"]).read_text(encoding="utf-8")
self.assertIn("[stdout] done", stream)
self.assertNotIn("[heartbeat]", stream)
normalized = Path(record["normalized_output_log"]).read_text(
encoding="utf-8"
)
self.assertIn("done", normalized)
visible_output = "\n".join(
" ".join(str(value) for value in call.args)
for call in print_mock.call_args_list
)
self.assertIn("locator=", visible_output)
self.assertNotIn("작업중...", visible_output)
self.assertNotIn("done", visible_output)
class SkillObservationContractTest(unittest.TestCase):
def test_dispatcher_owns_observation_and_caller_wakes_only_for_attention(self):
skill = (
Path(__file__).parents[1] / "SKILL.md"
).read_text(encoding="utf-8")
self.assertIn(
"dispatcher as the execution lifecycle and observation owner",
skill,
)
self.assertIn(
"without caller-LLM supervision",
skill,
)
self.assertIn(
"The caller never monitors",
skill,
)
self.assertIn(
"Wake the caller LLM only for an attention event that the dispatcher cannot resolve autonomously",
skill,
)
self.assertIn(
"Exit code `3` is a non-terminal tracking state, including another dispatcher workspace lock, "
"a live external agent, or an unexpected dispatcher interruption",
skill,
)
self.assertIn(
"every CLI's health/progress primarily from actual stdout/stderr in `stream.log`, "
"plus native session events when available",
skill,
)
self.assertIn(
"dispatcher PID, agent PID, each process start token, and the per-attempt "
"process environment marker",
skill,
)
self.assertIn(
"use only an actual terminal error or confirmed process exit as recovery "
"evidence for every model",
skill,
)
self.assertIn(
"every `toolCall.id` in the preceding assistant event matches a later `toolResult.toolCallId`",
skill,
)
self.assertIn(
"stream stops for three minutes outside tool execution",
skill,
)
self.assertIn(
"locator lacks an agent PID during this interval, never classify it as stale or "
"duplicate recovery based on log age",
skill,
)
self.assertIn(
"original exception is a persistent-state error, do not convert it to exit `2` if any agent was running",
skill,
)
self.assertIn(
"do not return successful exit `0` while any attempt directory remains",
skill,
)
self.assertIn(
"share a budget of 10 consecutive automatic recovery failures for the same task stage",
skill,
)
self.assertIn(
"On the 10th failure, block that task and do not auto-resume after cooldown",
skill,
)
self.assertIn(
"legacy locator `session-stall` as a record of an earlier dispatcher timeout policy, not as provider failure",
skill,
)
self.assertIn(
"Never classify exit code `143` as provider failure without actual provider terminal evidence",
skill,
)
self.assertIn(
"Do not generalize one `pi -p` fresh/isolated session attempt to a Pi TUI or system-wide provider outage",
skill,
)
self.assertIn("provider_transport_failure_confirmed", skill)
self.assertIn(
"Do not infer provider failure from `connection refused`, `dial tcp`, or `curl` peer failure in ordinary tool/test stderr",
skill,
)
self.assertIn(
"A running Python dispatcher does not hot-reload source edits",
skill,
)
self.assertIn("dispatcher_source_sha256", skill)
self.assertIn("`dispatcher_source_matches_loaded=false`", skill)
self.assertIn(
"Pi locator whose target enables `runtime.native_session_resume`",
skill,
)
self.assertIn(
"fresh session and `세션응답복구재시도` for Pi targets without that capability",
skill,
)
if __name__ == "__main__":
unittest.main()

View file

@ -1,496 +0,0 @@
import importlib.util
import json
import sys
from tempfile import TemporaryDirectory
import unittest
from unittest import mock
from datetime import datetime, timezone
from pathlib import Path
SCRIPT = (
Path(__file__).resolve().parents[1]
/ "scripts"
/ "execution_target_policy.py"
)
SPEC = importlib.util.spec_from_file_location("execution_target_policy", SCRIPT)
policy = importlib.util.module_from_spec(SPEC)
assert SPEC.loader is not None
sys.modules[SPEC.name] = policy
SPEC.loader.exec_module(policy)
def at_utc(hour: int, minute: int = 0, second: int = 0) -> datetime:
return datetime(2026, 7, 24, hour, minute, second, tzinfo=timezone.utc)
class ExecutionTargetPolicyTests(unittest.TestCase):
def test_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,
"references an unknown target",
):
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.catalog_target("pi-ornith-high").thinking_level, "high")
self.assertEqual(policy.catalog_target("pi-laguna-high").thinking_level, "high")
for target in (
policy.catalog_target("legacy-claude-glm"),
policy.catalog_target("claude-opus-xhigh"),
policy.catalog_target("claude-haiku-xhigh"),
policy.catalog_target("codex-spark-xhigh"),
policy.catalog_target("codex-sol-xhigh"),
):
with self.subTest(target=target.catalog_id):
self.assertEqual(target.reasoning_effort, "xhigh")
def test_catalog_owns_model_specific_runtime_capabilities(self):
self.assertTrue(
policy.catalog_target("pi-laguna-high").native_session_resume
)
self.assertFalse(
policy.catalog_target("pi-ornith-high").native_session_resume
)
for target_id in (
"codex-spark-xhigh",
"codex-sol-xhigh",
"codex-terra-high",
):
with self.subTest(target_id=target_id):
self.assertEqual(
policy.catalog_target(target_id).same_target_retry_limit,
1,
)
def test_canonical_target_uses_unique_catalog_identity_when_options_are_absent(self):
target = policy.catalog_target("codex-terra-high")
self.assertEqual(
policy.canonical_target(target.adapter, target.target),
target,
)
ambiguous = policy.catalog_target("opencode-glm-medium")
self.assertIsNone(
policy.canonical_target(ambiguous.adapter, ambiguous.target)
)
def test_dispatcher_python_does_not_embed_catalog_model_identities(self):
data = json.loads(policy.CATALOG_PATH.read_text(encoding="utf-8"))
model_identities = set(data["targets"])
for target in data["targets"].values():
model_identities.add(target["target"])
command_model = target.get("command_model")
if command_model:
model_identities.add(command_model)
for runtime_path in sorted(policy.CATALOG_PATH.parent.glob("*.py")):
source = runtime_path.read_text(encoding="utf-8")
for identity in sorted(model_identities):
with self.subTest(path=runtime_path.name, identity=identity):
self.assertNotIn(identity, source)
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",
),
(
"invalid native resume capability",
lambda data: data["targets"]["pi-laguna-high"][
"runtime"
].update(native_session_resume="yes"),
"native_session_resume must be a boolean",
),
(
"invalid same-target retry limit",
lambda data: data["targets"]["codex-sol-xhigh"][
"runtime"
].update(same_target_retry_limit=True),
"same_target_retry_limit must be a non-negative integer",
),
)
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):
cases = [
(at_utc(21, 59, 59), "agy", "Gemini 3.6 Flash (High)", "kst-night-[23:00,07:00)"),
(at_utc(22, 0, 0), "agy", "Gemini 3.6 Flash (High)", "kst-day-[07:00,23:00)"),
(at_utc(13, 59, 59), "agy", "Gemini 3.6 Flash (High)", "kst-day-[07:00,23:00)"),
(at_utc(14, 0, 0), "agy", "Gemini 3.6 Flash (High)", "kst-night-[23:00,07:00)"),
]
for evaluated_at, adapter, target, time_window in cases:
with self.subTest(evaluated_at=evaluated_at):
decision = policy.select_policy(
stage="worker",
lane="local",
grade=7,
evaluated_at=evaluated_at,
)
self.assertEqual(decision.candidates[0].adapter, adapter)
self.assertEqual(decision.candidates[0].target, target)
self.assertEqual(decision.time_window, time_window)
def test_policy_is_unaffected_by_process_environment_variables(self):
night_time = datetime(2026, 7, 25, 17, 0, tzinfo=timezone.utc) # 02:00 KST
with mock.patch.dict("os.environ", {"OTHER_UNRELATED_ENV": "2026-07-26", "ANY_UNRELATED_ENV": "1"}):
decision = policy.select_policy(
stage="worker", lane="local", grade=8, evaluated_at=night_time
)
self.assertEqual(decision.rule_id, "worker-local-g08-kst-night-catalog")
self.assertEqual(
decision.candidates,
(
policy.catalog_target("agy-gemini-high"),
policy.catalog_target("opencode-glm-max"),
policy.catalog_target("codex-terra-high"),
),
)
self.assertEqual(decision.time_window, "kst-night-[23:00,07:00)")
self.assertEqual(decision.candidates[0].target, "Gemini 3.6 Flash (High)")
def test_worker_grade_matrix_has_no_gaps(self):
daytime = at_utc(3)
expected = {
"local": {
**{
grade: ("pi", "iop/ornith:35b", True)
for grade in range(1, 7)
},
7: ("agy", "Gemini 3.6 Flash (High)", False),
8: ("agy", "Gemini 3.6 Flash (High)", False),
9: ("claude", "claude-opus-5", False),
10: ("claude", "claude-opus-5", False),
},
"cloud": {
**{
grade: ("codex", "gpt-5.3-codex-spark", False)
for grade in range(1, 3)
},
**{
grade: ("agy", "Gemini 3.6 Flash (Medium)", False)
for grade in range(3, 5)
},
**{
grade: ("agy", "Gemini 3.6 Flash (High)", False)
for grade in range(5, 7)
},
7: ("claude", "claude-opus-5", False),
8: ("claude", "claude-opus-5", False),
9: ("codex", "gpt-5.6-sol", False),
10: ("codex", "gpt-5.6-sol", False),
},
}
for lane, grades in expected.items():
for grade, route in grades.items():
with self.subTest(lane=lane, grade=grade):
selected = policy.select_policy(
stage="worker",
lane=lane,
grade=grade,
evaluated_at=daytime,
).candidates[0]
self.assertEqual(
(
selected.adapter,
selected.target,
selected.selfcheck_required,
),
route,
)
def test_cloud_g01_g02_uses_ordered_spark_gemini_glm_candidates(self):
for grade in (1, 2):
with self.subTest(grade=grade):
decision = policy.select_policy(
stage="worker",
lane="cloud",
grade=grade,
evaluated_at=at_utc(3),
)
self.assertEqual(
decision.candidates,
(
policy.catalog_target("codex-spark-xhigh"),
policy.catalog_target("agy-gemini-low"),
policy.catalog_target("opencode-glm-medium"),
policy.catalog_target("codex-terra-high"),
),
)
self.assertEqual(
decision.reason_codes,
("worker_catalog_lane",),
)
def test_review_catalog_defines_every_lane(self):
for lane in ("local", "cloud"):
for grade in range(1, 11):
with self.subTest(lane=lane, grade=grade):
decision = policy.select_policy(
stage="review",
lane=lane,
grade=grade,
evaluated_at=at_utc(3),
)
self.assertEqual(
decision.rule_id,
f"review-{lane}-g{grade:02d}-catalog",
)
self.assertEqual(
decision.reason_codes,
("review_catalog_lane",),
)
self.assertEqual(decision.candidates, (policy.catalog_target("codex-sol-xhigh"),))
def test_local_g07_g08_candidate_order_uses_gemini_high_then_glm_max(self):
daytime = policy.select_policy(
stage="worker",
lane="local",
grade=8,
evaluated_at=at_utc(3),
)
nighttime = policy.select_policy(
stage="worker",
lane="local",
grade=8,
evaluated_at=at_utc(15),
)
expected = (
policy.catalog_target("agy-gemini-high"),
policy.catalog_target("opencode-glm-max"),
policy.catalog_target("codex-terra-high"),
)
self.assertEqual(daytime.candidates, expected)
self.assertEqual(nighttime.candidates, expected)
def test_opencode_glm_effort_is_one_step_above_gemini(self):
cases = (
(policy.catalog_target("agy-gemini-low"), policy.catalog_target("opencode-glm-medium"), "medium"),
(policy.catalog_target("agy-gemini-medium"), policy.catalog_target("opencode-glm-high"), "high"),
(policy.catalog_target("agy-gemini-high"), policy.catalog_target("opencode-glm-max"), "max"),
)
for gemini, target, effort in cases:
with self.subTest(gemini=gemini.target):
self.assertEqual(target.adapter, "opencode")
self.assertEqual(target.target, "glm-5.2")
self.assertEqual(target.command_model, "iop-glm/glm-5.2")
self.assertEqual(target.reasoning_effort, effort)
self.assertEqual(target.execution_class, "cloud_model")
self.assertFalse(target.selfcheck_required)
self.assertFalse(target.selfcheck_full_review)
self.assertTrue(target.selfcheck_checklist_review)
def test_invalid_inputs_are_rejected(self):
cases = [
{"stage": "selfcheck", "lane": "local", "grade": 7},
{"stage": "worker", "lane": "hybrid", "grade": 7},
{"stage": "worker", "lane": "local", "grade": 0},
{"stage": "worker", "lane": "local", "grade": 11},
]
for values in cases:
with self.subTest(values=values):
with self.assertRaises(ValueError):
policy.select_policy(
**values,
evaluated_at=at_utc(3),
)
with self.assertRaisesRegex(ValueError, "timezone-aware"):
policy.select_policy(
stage="worker",
lane="local",
grade=7,
evaluated_at=datetime(2026, 7, 25, 12, 0, 0),
)
def test_cloud_promotion_matrix(self):
cases = [
(
policy.catalog_target("agy-gemini-low"),
policy.catalog_target("claude-opus-xhigh"),
),
(
policy.catalog_target("agy-gemini-medium"),
policy.catalog_target("claude-opus-xhigh"),
),
(
policy.catalog_target("agy-gemini-high"),
policy.catalog_target("claude-opus-xhigh"),
),
(policy.catalog_target("claude-opus-xhigh"), policy.catalog_target("codex-terra-high")),
(policy.catalog_target("claude-haiku-xhigh"), policy.catalog_target("codex-terra-high")),
(policy.catalog_target("codex-spark-xhigh"), None),
(policy.catalog_target("codex-sol-xhigh"), None),
(policy.catalog_target("codex-terra-high"), None),
(policy.catalog_target("pi-ornith-high"), None),
(policy.catalog_target("pi-laguna-high"), None),
(policy.catalog_target("legacy-pi-selfcheck-disabled"), None),
(policy.catalog_target("opencode-glm-medium"), policy.catalog_target("codex-terra-high")),
(policy.catalog_target("opencode-glm-high"), policy.catalog_target("codex-terra-high")),
(policy.catalog_target("opencode-glm-max"), policy.catalog_target("codex-terra-high")),
(policy.catalog_target("legacy-claude-glm"), policy.catalog_target("codex-terra-high")),
]
for current, expected in cases:
with self.subTest(current=current):
self.assertEqual(policy.promotion_target(current), expected)
for target in policy.CANONICAL_TARGETS:
with self.subTest(identity=target.target):
self.assertEqual(
policy.canonical_target(
target.adapter,
target.target,
target.thinking_level,
target.reasoning_effort,
),
target,
)
self.assertIsNone(policy.canonical_target("codex", "unknown"))
def test_quota_probe_spec_matrix(self):
cases = [
(policy.catalog_target("pi-ornith-high"), None),
(policy.catalog_target("pi-laguna-high"), None),
(policy.catalog_target("opencode-glm-medium"), None),
(policy.catalog_target("opencode-glm-high"), None),
(policy.catalog_target("opencode-glm-max"), None),
(policy.catalog_target("legacy-claude-glm"), None),
(
policy.catalog_target("agy-gemini-low"),
policy.QuotaProbeSpec("agy", "Gemini 3.6 Flash (Low)", ("overall", "model:Gemini 3.6 Flash (Low)")),
),
(
policy.catalog_target("agy-gemini-medium"),
policy.QuotaProbeSpec("agy", "Gemini 3.6 Flash (Medium)", ("overall", "model:Gemini 3.6 Flash (Medium)")),
),
(
policy.catalog_target("agy-gemini-high"),
policy.QuotaProbeSpec("agy", "Gemini 3.6 Flash (High)", ("overall", "model:Gemini 3.6 Flash (High)")),
),
(
policy.catalog_target("claude-opus-xhigh"),
policy.QuotaProbeSpec("claude", "claude-opus-5", ("overall",)),
),
(
policy.catalog_target("claude-haiku-xhigh"),
policy.QuotaProbeSpec("claude", "claude-haiku-4-5", ("overall",)),
),
(
policy.catalog_target("codex-spark-xhigh"),
policy.QuotaProbeSpec("codex", "gpt-5.3-codex-spark", ("overall",)),
),
(
policy.catalog_target("codex-sol-xhigh"),
policy.QuotaProbeSpec("codex", "gpt-5.6-sol", ("overall",)),
),
]
for target, expected in cases:
with self.subTest(target=target.target):
self.assertEqual(policy.quota_probe_spec(target), expected)
if __name__ == "__main__":
unittest.main()

View file

@ -9,34 +9,6 @@
"value": 660,
"reason": "file production exceeds warning threshold (660 > 500)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py",
"metric": "file_loc",
"level": "exception",
"value": 7215,
"reason": "file production exceeds exception threshold (7215 > 1000)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py",
"metric": "file_loc",
"level": "exception",
"value": 1560,
"reason": "file production exceeds exception threshold (1560 > 1000)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "file_loc",
"level": "split_review",
"value": 12738,
"reason": "file test exceeds split_review threshold (12738 > 1000)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py",
"metric": "file_loc",
"level": "split_review",
"value": 1684,
"reason": "file test exceeds split_review threshold (1684 > 1000)"
},
{
"path": "agent-task/archive/2026/08/m-iop-agent-chronos-extraction-decoupling/13+09,10_receipt_lock_audit/verify-pre-deletion-receipt-v1.py",
"metric": "file_loc",
@ -642,462 +614,6 @@
"function": "ensure_agent_test_profile",
"reason": "function ensure_agent_test_profile exceeds warning threshold (101 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 84,
"function": "StateStore.__init__",
"reason": "function StateStore.__init__ exceeds warning threshold (84 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 98,
"function": "agent_spec_from_decision",
"reason": "function agent_spec_from_decision exceeds warning threshold (98 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 106,
"function": "archive_completed_group_work_logs",
"reason": "function archive_completed_group_work_logs exceeds warning threshold (106 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 843,
"function": "dispatch_with_store",
"reason": "function dispatch_with_store exceeds split_review threshold (843 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 131,
"function": "external_active_is_live",
"reason": "function external_active_is_live exceeds split_review threshold (131 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 102,
"function": "inspect_write_set",
"reason": "function inspect_write_set exceeds warning threshold (102 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 594,
"function": "invoke",
"reason": "function invoke exceeds split_review threshold (594 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 133,
"function": "legacy_promotion_recovery",
"reason": "function legacy_promotion_recovery exceeds split_review threshold (133 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 146,
"function": "pi_native_session_state",
"reason": "function pi_native_session_state exceeds split_review threshold (146 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 93,
"function": "read_task_directory",
"reason": "function read_task_directory exceeds warning threshold (93 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 492,
"function": "run_escalating",
"reason": "function run_escalating exceeds split_review threshold (492 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 122,
"function": "run_review",
"reason": "function run_review exceeds split_review threshold (122 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 154,
"function": "run_selfcheck",
"reason": "function run_selfcheck exceeds split_review threshold (154 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 90,
"function": "run_worker",
"reason": "function run_worker exceeds warning threshold (90 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 118,
"function": "select_dispatch_candidates",
"reason": "function select_dispatch_candidates exceeds warning threshold (118 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/execution_target_policy.py",
"metric": "function_loc",
"level": "warning",
"value": 81,
"function": "select_policy",
"reason": "function select_policy exceeds warning threshold (81 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py",
"metric": "function_loc",
"level": "warning",
"value": 90,
"function": "QuotaBatchProvider.aggregate",
"reason": "function QuotaBatchProvider.aggregate exceeds warning threshold (90 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py",
"metric": "function_loc",
"level": "warning",
"value": 94,
"function": "_failover",
"reason": "function _failover exceeds warning threshold (94 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py",
"metric": "function_loc",
"level": "warning",
"value": 81,
"function": "_initial",
"reason": "function _initial exceeds warning threshold (81 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py",
"metric": "function_loc",
"level": "warning",
"value": 109,
"function": "_promotion",
"reason": "function _promotion exceeds warning threshold (109 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py",
"metric": "function_loc",
"level": "warning",
"value": 86,
"function": "_validate_prior_candidate_identity",
"reason": "function _validate_prior_candidate_identity exceeds warning threshold (86 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py",
"metric": "function_loc",
"level": "warning",
"value": 102,
"function": "_validate_quota_snapshot",
"reason": "function _validate_quota_snapshot exceeds warning threshold (102 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/scripts/select_execution_target.py",
"metric": "function_loc",
"level": "split_review",
"value": 135,
"function": "_validate_selected_and_used_history",
"reason": "function _validate_selected_and_used_history exceeds split_review threshold (135 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 184,
"function": "ArtifactLanguageContractTest.test_plan_and_review_share_dispatch_write_set_contract",
"reason": "function ArtifactLanguageContractTest.test_plan_and_review_share_dispatch_write_set_contract exceeds split_review threshold (184 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 110,
"function": "BlockerDrainTest.test_user_review_only_holds_its_dependency_closure",
"reason": "function BlockerDrainTest.test_user_review_only_holds_its_dependency_closure exceeds warning threshold (110 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 177,
"function": "CompletingTargetSelfcheckTest.test_completing_decision_validation_matrix",
"reason": "function CompletingTargetSelfcheckTest.test_completing_decision_validation_matrix exceeds split_review threshold (177 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 109,
"function": "CompletingTargetSelfcheckTest.test_worker_persists_actual_completing_decision_and_execution_class",
"reason": "function CompletingTargetSelfcheckTest.test_worker_persists_actual_completing_decision_and_execution_class exceeds warning threshold (109 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 92,
"function": "DispatcherCanonicalFailoverIntegrationTest.test_cloud_agy_quota_failover_commits_glm_high",
"reason": "function DispatcherCanonicalFailoverIntegrationTest.test_cloud_agy_quota_failover_commits_glm_high exceeds warning threshold (92 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 107,
"function": "DispatcherCanonicalFailoverIntegrationTest.test_cloud_g01_g02_quota_failover_runs_spark_gemini_glm_low",
"reason": "function DispatcherCanonicalFailoverIntegrationTest.test_cloud_g01_g02_quota_failover_runs_spark_gemini_glm_low exceeds warning threshold (107 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 84,
"function": "DispatcherCanonicalFailoverIntegrationTest.test_cloud_g07_provider_quota_promotes_claude_to_codex_without_no_failover_block",
"reason": "function DispatcherCanonicalFailoverIntegrationTest.test_cloud_g07_provider_quota_promotes_claude_to_codex_without_no_failover_block exceeds warning threshold (84 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 82,
"function": "DispatcherCanonicalFailoverIntegrationTest.test_no_promotion_target_keeps_same_target_and_persists_state",
"reason": "function DispatcherCanonicalFailoverIntegrationTest.test_no_promotion_target_keeps_same_target_and_persists_state exceeds warning threshold (82 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 167,
"function": "DispatcherConvergenceSimulationTest.test_parallel_multi_task_followup_dependency_and_terminal_completion",
"reason": "function DispatcherConvergenceSimulationTest.test_parallel_multi_task_followup_dependency_and_terminal_completion exceeds split_review threshold (167 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 87,
"function": "DynamicFailoverBudgetTest.test_primary_and_alternate_share_budget_across_reopen",
"reason": "function DynamicFailoverBudgetTest.test_primary_and_alternate_share_budget_across_reopen exceeds warning threshold (87 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 88,
"function": "ParallelLimitSchedulingTest.test_limit_two_selects_reviews_before_worker_and_caps_total",
"reason": "function ParallelLimitSchedulingTest.test_limit_two_selects_reviews_before_worker_and_caps_total exceeds warning threshold (88 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 131,
"function": "SelectorDispatcherIntegrationTest.test_completing_target_controls_selfcheck_and_reuses_pin",
"reason": "function SelectorDispatcherIntegrationTest.test_completing_target_controls_selfcheck_and_reuses_pin exceeds split_review threshold (131 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 92,
"function": "SelectorDispatcherIntegrationTest.test_context_budget_and_retry_blocked_lifecycle",
"reason": "function SelectorDispatcherIntegrationTest.test_context_budget_and_retry_blocked_lifecycle exceeds warning threshold (92 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 346,
"function": "SelectorDispatcherIntegrationTest.test_review_recovery_and_runtime_audit_evidence",
"reason": "function SelectorDispatcherIntegrationTest.test_review_recovery_and_runtime_audit_evidence exceeds split_review threshold (346 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 102,
"function": "ThroughputQuotaBatchTest.test_retry_blocked_quota_refresh_lifecycle",
"reason": "function ThroughputQuotaBatchTest.test_retry_blocked_quota_refresh_lifecycle exceeds warning threshold (102 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 168,
"function": "ThroughputQuotaBatchTest.test_retry_blocked_scopes_to_blocked_worker_and_selects_glm_fallback",
"reason": "function ThroughputQuotaBatchTest.test_retry_blocked_scopes_to_blocked_worker_and_selects_glm_fallback exceeds split_review threshold (168 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 165,
"function": "ThroughputQuotaBatchTest.test_retry_blocked_scopes_to_blocked_worker_and_selects_glm_fallback._async_run",
"reason": "function ThroughputQuotaBatchTest.test_retry_blocked_scopes_to_blocked_worker_and_selects_glm_fallback._async_run exceeds split_review threshold (165 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 137,
"function": "ThroughputQuotaBatchTest.test_retry_evidence_artifact_identity_variants",
"reason": "function ThroughputQuotaBatchTest.test_retry_evidence_artifact_identity_variants exceeds split_review threshold (137 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 148,
"function": "ThroughputQuotaBatchTest.test_retry_handoff_first_locator_record_and_commit_guard",
"reason": "function ThroughputQuotaBatchTest.test_retry_handoff_first_locator_record_and_commit_guard exceeds split_review threshold (148 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 135,
"function": "ThroughputQuotaBatchTest.test_retry_handoff_first_locator_record_and_commit_guard._async_run",
"reason": "function ThroughputQuotaBatchTest.test_retry_handoff_first_locator_record_and_commit_guard._async_run exceeds split_review threshold (135 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 134,
"function": "ThroughputQuotaBatchTest.test_retry_handoff_locator_consume_restart_windows",
"reason": "function ThroughputQuotaBatchTest.test_retry_handoff_locator_consume_restart_windows exceeds split_review threshold (134 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 143,
"function": "ThroughputQuotaBatchTest.test_retry_handoff_production_save_fault_preserves_pending",
"reason": "function ThroughputQuotaBatchTest.test_retry_handoff_production_save_fault_preserves_pending exceeds split_review threshold (143 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 129,
"function": "ThroughputQuotaBatchTest.test_retry_handoff_production_save_fault_preserves_pending._async_run",
"reason": "function ThroughputQuotaBatchTest.test_retry_handoff_production_save_fault_preserves_pending._async_run exceeds split_review threshold (129 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 325,
"function": "ThroughputQuotaBatchTest.test_retry_restart_does_not_duplicate_provider_or_mutate_sibling",
"reason": "function ThroughputQuotaBatchTest.test_retry_restart_does_not_duplicate_provider_or_mutate_sibling exceeds split_review threshold (325 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "split_review",
"value": 313,
"function": "ThroughputQuotaBatchTest.test_retry_restart_does_not_duplicate_provider_or_mutate_sibling._async_run",
"reason": "function ThroughputQuotaBatchTest.test_retry_restart_does_not_duplicate_provider_or_mutate_sibling._async_run exceeds split_review threshold (313 > 120)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 84,
"function": "ThroughputQuotaBatchTest.test_same_provider_target_tasks_with_disjoint_write_sets_admit_without_cap",
"reason": "function ThroughputQuotaBatchTest.test_same_provider_target_tasks_with_disjoint_write_sets_admit_without_cap exceeds warning threshold (84 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 81,
"function": "ThroughputQuotaBatchTest.test_same_provider_target_tasks_with_disjoint_write_sets_admit_without_cap.run",
"reason": "function ThroughputQuotaBatchTest.test_same_provider_target_tasks_with_disjoint_write_sets_admit_without_cap.run exceeds warning threshold (81 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 110,
"function": "WorkLogArchiveTest.test_restart_waits_for_live_writer_then_reconciles_and_archives",
"reason": "function WorkLogArchiveTest.test_restart_waits_for_live_writer_then_reconciles_and_archives exceeds warning threshold (110 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 110,
"function": "WorkLogArchiveTest.test_workspace_bound_liveness_rejects_foreign_and_accepts_current_locators",
"reason": "function WorkLogArchiveTest.test_workspace_bound_liveness_rejects_foreign_and_accepts_current_locators exceeds warning threshold (110 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 82,
"function": "WorkLogInvokeIntegrationTest.test_milestone_timeline_uses_active_artifact_and_plan_loop",
"reason": "function WorkLogInvokeIntegrationTest.test_milestone_timeline_uses_active_artifact_and_plan_loop exceeds warning threshold (82 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 84,
"function": "WriteSetTest.test_validate_plan_requires_known_milestone_task_scope",
"reason": "function WriteSetTest.test_validate_plan_requires_known_milestone_task_scope exceeds warning threshold (84 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatch.py",
"metric": "function_loc",
"level": "warning",
"value": 118,
"function": "WriteSetTest.test_workspace_claims_persist_replace_wait_and_release_on_completion",
"reason": "function WriteSetTest.test_workspace_claims_persist_replace_wait_and_release_on_completion exceeds warning threshold (118 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_dispatcher_observation.py",
"metric": "function_loc",
"level": "warning",
"value": 100,
"function": "SkillObservationContractTest.test_dispatcher_owns_observation_and_caller_wakes_only_for_attention",
"reason": "function SkillObservationContractTest.test_dispatcher_owns_observation_and_caller_wakes_only_for_attention exceeds warning threshold (100 > 80)"
},
{
"path": "agent-ops/skills/project/orchestrate-agent-task-loop/tests/test_select_execution_target.py",
"metric": "function_loc",
"level": "warning",
"value": 101,
"function": "SelectorFailoverContractTests.test_tampered_prior_decision_rejected",
"reason": "function SelectorFailoverContractTests.test_tampered_prior_decision_rejected exceeds warning threshold (101 > 80)"
},
{
"path": "agent-task/archive/2026/08/m-iop-agent-chronos-extraction-decoupling/13+09,10_receipt_lock_audit/verify-pre-deletion-receipt-v1.py",
"metric": "function_loc",