sync: to agentic-framework v1.1.188
This commit is contained in:
parent
af35296aaa
commit
0ca8711f2f
27 changed files with 2304 additions and 18146 deletions
|
|
@ -1 +1 @@
|
|||
1.1.187
|
||||
1.1.188
|
||||
|
|
|
|||
|
|
@ -26,6 +26,41 @@ create_project_agent_ops_dirs() {
|
|||
mkdir -p "$agent_ops_dir/skills/private"
|
||||
}
|
||||
|
||||
remove_generated_caches() {
|
||||
local root="$1"
|
||||
|
||||
[ -d "$root" ] || return 0
|
||||
find "$root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete
|
||||
find "$root" -depth -type d \( \
|
||||
-name '__pycache__' -o \
|
||||
-name '.pytest_cache' -o \
|
||||
-name '.mypy_cache' -o \
|
||||
-name '.ruff_cache' \
|
||||
\) -exec rm -rf -- {} +
|
||||
}
|
||||
|
||||
copy_tree_without_caches() {
|
||||
local src="$1"
|
||||
local dst="$2"
|
||||
|
||||
mkdir -p "$dst"
|
||||
(
|
||||
cd "$src"
|
||||
tar \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='*/__pycache__' \
|
||||
--exclude='.pytest_cache' \
|
||||
--exclude='*/.pytest_cache' \
|
||||
--exclude='.mypy_cache' \
|
||||
--exclude='*/.mypy_cache' \
|
||||
--exclude='.ruff_cache' \
|
||||
--exclude='*/.ruff_cache' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='*.pyo' \
|
||||
-cf - .
|
||||
) | tar -C "$dst" -xf -
|
||||
}
|
||||
|
||||
copy_common_agent_ops() {
|
||||
local source_dir="$1"
|
||||
local target_agent_ops_dir="$2"
|
||||
|
|
@ -38,8 +73,10 @@ copy_common_agent_ops() {
|
|||
rm -rf "$target_agent_ops_dir/rules/common"
|
||||
rm -rf "$target_agent_ops_dir/skills/common"
|
||||
cp -r "$source_dir/bin" "$target_agent_ops_dir/"
|
||||
cp -r "$source_dir/rules/common" "$target_agent_ops_dir/rules/"
|
||||
cp -r "$source_dir/skills/common" "$target_agent_ops_dir/skills/"
|
||||
copy_tree_without_caches "$source_dir/rules/common" "$target_agent_ops_dir/rules/common"
|
||||
copy_tree_without_caches "$source_dir/skills/common" "$target_agent_ops_dir/skills/common"
|
||||
remove_generated_caches "$target_agent_ops_dir/rules/common"
|
||||
remove_generated_caches "$target_agent_ops_dir/skills/common"
|
||||
}
|
||||
|
||||
ensure_common_rules_file() {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,38 @@ bump_version() {
|
|||
bash "$SCRIPT_DIR/bump-version.sh" "$1"
|
||||
}
|
||||
|
||||
remove_generated_caches() {
|
||||
local root="$1"
|
||||
[[ -d "$root" ]] || return 0
|
||||
find "$root" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete
|
||||
find "$root" -depth -type d \( \
|
||||
-name '__pycache__' -o \
|
||||
-name '.pytest_cache' -o \
|
||||
-name '.mypy_cache' -o \
|
||||
-name '.ruff_cache' \
|
||||
\) -exec rm -rf -- {} +
|
||||
}
|
||||
|
||||
copy_tree_without_caches() {
|
||||
local src="$1" dst="$2"
|
||||
mkdir -p "$dst"
|
||||
(
|
||||
cd "$src"
|
||||
tar \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='*/__pycache__' \
|
||||
--exclude='.pytest_cache' \
|
||||
--exclude='*/.pytest_cache' \
|
||||
--exclude='.mypy_cache' \
|
||||
--exclude='*/.mypy_cache' \
|
||||
--exclude='.ruff_cache' \
|
||||
--exclude='*/.ruff_cache' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='*.pyo' \
|
||||
-cf - .
|
||||
) | tar -C "$dst" -xf -
|
||||
}
|
||||
|
||||
# ── 폴더 동기화 (삭제된 파일도 반영) ────────────────────────────────────────
|
||||
sync_folder() {
|
||||
local src="$1" dst="$2" exclude="${3:-}"
|
||||
|
|
@ -61,10 +93,14 @@ sync_folder() {
|
|||
name="$(basename "$item")"
|
||||
[[ -n "$exclude" && "$name" == "$exclude" ]] && continue
|
||||
rm -rf "$dst/$name"
|
||||
cp -r "$item" "$dst/"
|
||||
# 검증 실행 중 생긴 Python bytecode는 공통 산출물이 아니므로 전파하지 않는다.
|
||||
find "$dst/$name" -type f \( -name '*.pyc' -o -name '*.pyo' \) -delete
|
||||
find "$dst/$name" -depth -type d -name '__pycache__' -empty -delete
|
||||
if [[ -d "$item" ]]; then
|
||||
mkdir -p "$dst/$name"
|
||||
copy_tree_without_caches "$item" "$dst/$name"
|
||||
else
|
||||
cp "$item" "$dst/"
|
||||
fi
|
||||
# 검증 중 생긴 cache는 공통 산출물이 아니므로 전파하지 않는다.
|
||||
remove_generated_caches "$dst/$name"
|
||||
done
|
||||
}
|
||||
|
||||
|
|
@ -85,7 +121,14 @@ common_differs() {
|
|||
if [[ ! -d "$src/$path" || ! -d "$dst/$path" ]]; then
|
||||
return 0
|
||||
fi
|
||||
if ! diff -qr "$src/$path" "$dst/$path" >/dev/null; then
|
||||
if ! diff -qr \
|
||||
--exclude='__pycache__' \
|
||||
--exclude='.pytest_cache' \
|
||||
--exclude='.mypy_cache' \
|
||||
--exclude='.ruff_cache' \
|
||||
--exclude='*.pyc' \
|
||||
--exclude='*.pyo' \
|
||||
"$src/$path" "$dst/$path" >/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
|
@ -121,8 +164,10 @@ copy_common_scaffold() {
|
|||
cp "$src/.version" "$dst/"
|
||||
rm -rf "$dst/bin" "$dst/rules/common" "$dst/skills/common"
|
||||
cp -r "$src/bin" "$dst/"
|
||||
cp -r "$src/rules/common" "$dst/rules/"
|
||||
cp -r "$src/skills/common" "$dst/skills/"
|
||||
copy_tree_without_caches "$src/rules/common" "$dst/rules/common"
|
||||
copy_tree_without_caches "$src/skills/common" "$dst/skills/common"
|
||||
remove_generated_caches "$dst/rules/common"
|
||||
remove_generated_caches "$dst/skills/common"
|
||||
create_project_agent_ops_dirs "$dst"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ README는 프로젝트 특성에 맞게 필요한 섹션만 사용하되, 기본
|
|||
## 먼저 확인할 것
|
||||
|
||||
- [ ] 루트 `README.md` 존재 여부와 기존 내용 확인
|
||||
- [ ] `package.json`, `pyproject.toml`, `Cargo.toml`, `go.mod`, `Makefile`, `docker-compose.yml` 등 실행/검증 명령 근거 확인
|
||||
- [ ] 프로젝트 manifest, build 설정, container 설정, CI workflow 등에서 실행/검증 명령 근거 확인
|
||||
- [ ] `agent-ops/rules/project/rules.md`가 있으면 프로젝트 개요, 기술 스택, 도메인 매핑 확인
|
||||
- [ ] `agent-roadmap/ROADMAP.md` 또는 로컬 `agent-roadmap/current.md`가 있으면 제품 방향과 활성 Milestone 문서 경로만 확인
|
||||
- [ ] 주요 소스 디렉터리와 테스트 디렉터리를 `rg --files`로 가볍게 확인
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ description: agent-test 환경 rules.md와 도메인/검증 시나리오별 테
|
|||
- [ ] `agent-ops/skills/common/router.md`에 `create-test` 라우팅이 있는지 확인한다.
|
||||
- [ ] `agent-ops/rules/project/rules.md`가 있으면 도메인 매핑 테이블을 확인한다.
|
||||
- [ ] `agent-ops/rules/project/domain/` 하위 domain rule 목록을 확인한다.
|
||||
- [ ] 테스트 명령 확인을 위해 프로젝트의 대표 설정 파일을 가볍게 확인한다. 예: `package.json`, `Makefile`, `pyproject.toml`, `go.mod`, `Cargo.toml`, `docker-compose*.yml`, `.github/workflows/**`.
|
||||
- [ ] 테스트 명령 확인을 위해 프로젝트의 대표 manifest, build 설정, container 설정, CI workflow를 가볍게 확인한다.
|
||||
- [ ] `agent-ops/rules/common/_templates/test-env-rules-template.md`를 읽는다.
|
||||
- [ ] `agent-ops/rules/common/_templates/test-case-rule-template.md`를 읽는다.
|
||||
- [ ] 프로젝트에 `agent-test/_templates/env-rules-template.md` 또는 `agent-test/_templates/test-profile-template.md`가 있으면 해당 프로젝트 템플릿을 공통 템플릿보다 우선한다.
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ description: PLAN/CODE_REVIEW 작성 직전 완성된 build packet을 한 번
|
|||
|
||||
## 목표
|
||||
|
||||
완성된 in-memory PLAN 하나를 한 번 평가해 build/review route를 확정한다. routing 전용 문서나 증거 탐색을 만들지 않는다. Build의 기본값은 local이며 아래 표의 cloud 조건에 일치할 때만 승격한다. 공식 review는 항상 cloud의 Codex `gpt-5.6-sol` xhigh다. 이 스킬은 task 파일을 수정하지 않는다.
|
||||
완성된 in-memory PLAN 하나를 한 번 평가해 build/review route를 확정한다. routing 전용 문서나 증거 탐색을 만들지 않는다. Build의 기본값은 local이며 아래 표의 cloud 조건에 일치할 때만 승격한다. 공식 review는 항상 cloud lane을 사용하되 agent와 model은 런타임 실행 카탈로그가 결정한다. 이 스킬은 task 파일을 수정하지 않는다.
|
||||
|
||||
## 입력
|
||||
|
||||
|
|
@ -129,9 +129,9 @@ finalizer 출력만 사용한다. lane, grade, boundary, filename을 수작업
|
|||
항상 `status`, `evaluation_mode`, `missing_evidence`, `blocked_reason`을 반환한다. `status=routed`이면 다음 필드를 모두 반환한다.
|
||||
|
||||
- 공통: `finalizer=finalize-task-policy.sh`, `finalizer_mode=pair`
|
||||
- target별: `closures`, `closure_basis`, `capability_gap`, `grade_scores`, `route_basis`, `lane`, `grade`, `filename`
|
||||
- target별: `closures`, `closure_basis`, `capability_gap`, `grade_scores`, `route_basis`, `lane`, `grade`, `filename`, `catalog_route`
|
||||
- build 전용: `base_route_basis`, `large_indivisible_context`, `matched_loop_risk_signatures`, `loop_risk_count`, `review_rework_count`, `evidence_integrity_failure`, `risk_boundary_matched`, `recovery_boundary_matched`
|
||||
- review 전용: `route_basis=official-review`, `adapter=codex`, `model=gpt-5.6-sol`, `reasoning_effort=xhigh`
|
||||
- review 전용: `route_basis=official-review`, `catalog_route=review/cloud/GNN`. 구체적인 agent와 model은 이 출력에 포함하지 않는다.
|
||||
|
||||
## 완료 확인
|
||||
|
||||
|
|
|
|||
|
|
@ -98,9 +98,6 @@ finalize_review() {
|
|||
REVIEW_LANE=$(field "$route" lane)
|
||||
REVIEW_GRADE=$(field "$route" grade)
|
||||
REVIEW_FILENAME=$(field "$route" filename)
|
||||
REVIEW_ADAPTER=codex
|
||||
REVIEW_MODEL=gpt-5.6-sol
|
||||
REVIEW_REASONING_EFFORT=xhigh
|
||||
}
|
||||
|
||||
emit_build() {
|
||||
|
|
@ -115,6 +112,7 @@ emit_build() {
|
|||
printf 'build_lane=%s\n' "$BUILD_LANE"
|
||||
printf 'build_grade=%s\n' "$BUILD_GRADE"
|
||||
printf 'build_filename=%s\n' "$BUILD_FILENAME"
|
||||
printf 'build_catalog_route=worker/%s/%s\n' "$BUILD_LANE" "$BUILD_GRADE"
|
||||
}
|
||||
|
||||
emit_review() {
|
||||
|
|
@ -122,9 +120,7 @@ emit_review() {
|
|||
printf 'review_lane=%s\n' "$REVIEW_LANE"
|
||||
printf 'review_grade=%s\n' "$REVIEW_GRADE"
|
||||
printf 'review_filename=%s\n' "$REVIEW_FILENAME"
|
||||
printf 'review_adapter=%s\n' "$REVIEW_ADAPTER"
|
||||
printf 'review_model=%s\n' "$REVIEW_MODEL"
|
||||
printf 'review_reasoning_effort=%s\n' "$REVIEW_REASONING_EFFORT"
|
||||
printf 'review_catalog_route=review/%s/%s\n' "$REVIEW_LANE" "$REVIEW_GRADE"
|
||||
}
|
||||
|
||||
mode=${1:-}
|
||||
|
|
|
|||
|
|
@ -241,7 +241,7 @@ class FinalizeTaskRoutingTests(unittest.TestCase):
|
|||
self.assertEqual(result["finalizer_mode"], "pair")
|
||||
self.assert_route(result, "build", basis, lane, grade)
|
||||
|
||||
def test_official_review_keeps_grade_and_fixes_execution_target(self) -> None:
|
||||
def test_official_review_keeps_grade_without_fixing_execution_target(self) -> None:
|
||||
for grade in range(1, 11):
|
||||
with self.subTest(grade=grade):
|
||||
result = fields(
|
||||
|
|
@ -250,9 +250,11 @@ class FinalizeTaskRoutingTests(unittest.TestCase):
|
|||
self.assert_route(
|
||||
result, "review", "official-review", "cloud", grade
|
||||
)
|
||||
self.assertEqual(result["review_adapter"], "codex")
|
||||
self.assertEqual(result["review_model"], "gpt-5.6-sol")
|
||||
self.assertEqual(result["review_reasoning_effort"], "xhigh")
|
||||
self.assertEqual(
|
||||
result["review_catalog_route"], f"review/cloud/G{grade:02d}"
|
||||
)
|
||||
self.assertNotIn("review_adapter", result)
|
||||
self.assertNotIn("review_model", result)
|
||||
|
||||
def test_low_grade_cloud_requires_capability_gap_basis(self) -> None:
|
||||
rejected = run(
|
||||
|
|
|
|||
|
|
@ -264,6 +264,7 @@ common/rules.md와 내용이 중복되지 않도록 한다.
|
|||
- [ ] `.gitignore`에 `agent-test/local/`과 `agent-test/runs/`가 추가되어 있는가
|
||||
- [ ] `.geminiignore`, `.aiexclude`, `.cursorignore`, `.clineignore`에 Agent-Ops 관리 block이 있고 그 안에 `agent-task/archive/**`와 `agent-roadmap/archive/**`가 포함되어 있는가
|
||||
- [ ] `.claude/settings.json`, `opencode.json`에 `agent-task/archive/**` 또는 `agent-roadmap/archive/**` hard read/glob deny가 남아 있지 않은가
|
||||
- [ ] `rules/common`과 `skills/common`에 `__pycache__`, tool cache, `*.pyc`, `*.pyo`가 없고 초기화 복사에서도 제외됐는가
|
||||
- [ ] 기존 archive hard deny가 있으면 init-agent-ops 표준에 맞게 제거했는가
|
||||
- [ ] `.gitignore`에 `agent-task/archive/**` 또는 `agent-roadmap/archive/**` ignore 항목을 추가하지 않았는가
|
||||
- 검증 실패 시: 누락된 파일/항목을 사용자에게 알리고 해당 부분만 보완한다
|
||||
|
|
|
|||
|
|
@ -1,295 +1,142 @@
|
|||
---
|
||||
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 lane/G-specific Codex, Claude, agy, and Pi workers, adding Pi self-checks, converging official Codex reviews, and escalating cloud context until the task loop finishes.
|
||||
description: Execute dependency-ready PLAN and CODE_REVIEW task loops with workspace write claims, a runtime-injected agent/model catalog, deterministic target failover, and persistent recovery state.
|
||||
---
|
||||
|
||||
# Orchestrate Agent Task Loop
|
||||
|
||||
## 🚨 ABSOLUTE PRIORITY — NEVER SEND `final` EXCEPT IN THE TWO CASES BELOW
|
||||
## Final-channel gate
|
||||
|
||||
> [!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.**
|
||||
Do not end the caller turn through `final` until either:
|
||||
|
||||
### `final` Permission 1 — Verified Successful Completion
|
||||
- every in-scope task has a verified archived `complete.log`, every generated work log is archived, no task or execution remains active, and the dispatcher exits `0`; or
|
||||
- the user explicitly asks to stop the current run.
|
||||
|
||||
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.
|
||||
Use `commentary` for non-terminal status, blockers, questions, recovery notices, and partial completion. If the user asked for a persistent run, successful completion alone does not release this gate.
|
||||
|
||||
## 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.
|
||||
Monitor the file-backed workflow under `agent-task/` and converge ready PLAN implementation, optional self-check, official review, follow-up PLAN, and archive completion. The dispatcher owns deterministic scheduling, recovery, target transitions, and runtime evidence. Child agents own implementation and review judgments within their assigned artifact.
|
||||
|
||||
## 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.
|
||||
- `workspace`: trusted repository root containing `agent-task/`; defaults to the current directory.
|
||||
- `execution_catalog`: required runtime agent/model catalog path, supplied with `--execution-catalog` or `AGENT_TASK_EXECUTION_CATALOG`.
|
||||
- `task_group`: optional `agent-task/<task_group>` scope.
|
||||
- `dry_run`: inspect routes, dependencies, claims, and catalog validity without launching an agent.
|
||||
- `max_parallel`: workspace-wide active task-stage limit; defaults to `3`; `0` means unlimited.
|
||||
- `retry_blocked`: retry eligible blocked tasks without changing their catalog route history.
|
||||
|
||||
`--validate-plan` validates one PLAN without launching orchestration and therefore does not require an execution catalog.
|
||||
|
||||
## 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`, 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.
|
||||
- Read the current plan and code-review contracts routed by `agent-ops/skills/common/router.md`.
|
||||
- Obtain the execution catalog from the runtime or project layer. Common owns no default agent, model, provider, or route catalog.
|
||||
- Run `--dry-run` before the first live execution.
|
||||
- Never bypass the physical-workspace dispatcher lock.
|
||||
- Keep automatic approval inside the current workspace and the PLAN's declared write set.
|
||||
|
||||
## Routing Contract
|
||||
## Runtime catalog contract
|
||||
|
||||
| PLAN route | Worker |
|
||||
|---|---|
|
||||
| `local-G01`–`local-G06` | Pi `iop/ornith:35b`, thinking high |
|
||||
| `local-G07`–`local-G08` | KST `[07:00,23:00)` agy `Gemini 3.6 Flash (Medium)`; `[23:00,07:00)` Pi `iop/laguna-s:2.1` |
|
||||
| `local-G09`–`local-G10` | Claude `claude-opus-4-8`, effort xhigh |
|
||||
| `cloud-G01`–`cloud-G02` | agy `Gemini 3.6 Flash (Low)` |
|
||||
| `cloud-G03`–`cloud-G04` | agy `Gemini 3.6 Flash (Medium)` |
|
||||
| `cloud-G05`–`cloud-G06` | agy `Gemini 3.6 Flash (High)` |
|
||||
| `cloud-G07`–`cloud-G08` | Claude `claude-opus-4-8`, effort xhigh |
|
||||
| `cloud-G09`–`cloud-G10` | Codex `gpt-5.6-sol`, reasoning xhigh |
|
||||
| Every `CODE_REVIEW-*` | Codex `gpt-5.6-sol`, reasoning xhigh |
|
||||
The catalog root contains exactly `schema_version`, `targets`, and `routes`. It must cover `worker` and `review`, and each stage must define every `local-G01` through `local-G10` and `cloud-G01` through `cloud-G10` route.
|
||||
|
||||
Concurrency limits:
|
||||
Each target has:
|
||||
|
||||
- Global physical-workspace limit: omitting `max_parallel` caps execution at `3`; explicit `max_parallel=0` is unlimited. A positive value caps unique active task-stage attempts and is not narrowed by `task_group`. The cap applies across worker, self-check, review, and verified external-active attempts in the same physical workspace.
|
||||
- Pi `ornith:35b`: 3.
|
||||
- agy: 1.
|
||||
- Official Codex 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.
|
||||
- an opaque `agent` identity;
|
||||
- an opaque `model` identity;
|
||||
- `execution_class`: `local_model` or `cloud_model`;
|
||||
- optional `selfcheck_required` boolean;
|
||||
- `runtime.command`: a non-empty argv template executed without a shell;
|
||||
- optional `runtime.resume_command`, `preflight_command`, `environment`, `session_path`, `native_session_monitor`, and `auxiliary_logs`;
|
||||
- optional `runtime.output_format`: `text` or `jsonl`.
|
||||
|
||||
## Prompt Contract
|
||||
Command templates may use only `{agent}`, `{model}`, `{target_id}`, `{workspace}`, `{attempt_dir}`, `{session_id}`, `{resume_session}`, and `{prompt}`. The catalog must not embed repository secrets; environment values should refer only to runtime-provided non-secret configuration.
|
||||
|
||||
Keep control prompts in English, insert absolute paths only, and do not expand these sentences unnecessarily.
|
||||
Each route owns its ordered `candidates` plus optional `rule_id`, `policy_priority`, and `reason_codes`. A route may use catalog-owned `windows` instead of a fixed candidate list; every window supplies an IANA timezone, start/end time, and candidates. Exactly one window must match.
|
||||
|
||||
- A dispatcher child runs only while `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 local self-check prompts short. Start them with: `Think in English. Final in Korean.`
|
||||
Before work starts, the dispatcher:
|
||||
|
||||
- Cloud worker: `Read {PLAN_PATH} and complete the task. Keep artifact content in English. Final in Korean.`
|
||||
- Pi worker: `Think in English. Keep artifact content in English. Final in Korean. Read {PLAN_PATH} and complete the task.`
|
||||
- Pi self-check full pass: `Think in English. Final in Korean. Read {PLAN_PATH}; review all work once, fix omissions, and update {CODE_REVIEW_PATH}. Keep files in English.`
|
||||
- Pi self-check unchecked-item retry: `Think in English. Final in Korean. Read {PLAN_PATH}; complete every unchecked implementation item and update {CODE_REVIEW_PATH}. 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.`
|
||||
1. loads and validates the entire catalog;
|
||||
2. verifies exact route coverage and every target reference;
|
||||
3. verifies each target command is executable;
|
||||
4. runs an optional target `preflight_command` for live execution;
|
||||
5. records the catalog source and SHA-256 revision in the decision.
|
||||
|
||||
Never ask a worker, self-check, or review model to create, edit, or summarize `WORK_LOG.md`.
|
||||
A persisted decision is valid only while the injected catalog revision and selected target snapshot still match. Catalog changes fail closed instead of silently changing an active work unit.
|
||||
|
||||
Do not treat Pi self-check exit code `0` as success by itself. Set `selfcheck_done=true` only when `## Implementation Checklist` (or legacy `## 구현 체크리스트`) in `CODE_REVIEW_PATH` contains at least one Markdown list checkbox and every `[...]` checkbox value has at least one non-whitespace character. If both canonical and legacy checklist headings are present in the same file, fail closed. Accept any non-empty value, including `x`, `v`, and `✅`. Do not inspect `## Implementation Item Completion`, `Deviations from Plan`, `Key Design Decisions`, `Verification Results`, or final CODE_REVIEW synchronization text. Run the full self-check prompt exactly once. If its checklist condition fails, resume that successful pass's Pi native session and run the unchecked-item retry prompt up to 10 times. Each retry must resume the locator returned by the preceding successful pass so the same conversation context is preserved; never repeat the full review prompt or start a fresh retry session. Persist the latest successful context locator for dispatcher restart, and block instead of starting fresh when that context cannot be resumed. Block that task after the 10th unchecked-item retry remains incomplete, and continue draining independent work.
|
||||
## Selection and failover
|
||||
|
||||
After an AGY/Gemini worker exits `0`, apply the same `CODE_REVIEW_PATH` implementation-checklist regex before accepting worker completion. If it is incomplete, run a fresh quota probe: only an `exhausted` target becomes `provider-quota` and enters the existing selector failover/promotion chain; `available` or `unknown` remains a completion-evidence recovery on Gemini.
|
||||
- Initial execution selects the first candidate in the injected route.
|
||||
- Resume pins the persisted target and route revision.
|
||||
- The dispatcher never queries quota before admission and never accepts a quota snapshot as selector input.
|
||||
- Classify actual terminal output after an attempt. `provider-quota`, `context-limit`, `model-unavailable`, `provider-stream-disconnect`, and `provider-connection` may advance to the next unused route candidate.
|
||||
- In particular, a confirmed quota/rate-limit error advances directly to the next candidate. A plain mention of quota in source text, model prose, or non-terminal output is not sufficient evidence.
|
||||
- `generic-error`, process termination, work-log failure, and review-control failure do not imply quota and do not change the selected target.
|
||||
- Never use a hidden promotion table or provider-specific fallback. If no next catalog candidate exists, keep recovery within the stage budget or block the task with evidence.
|
||||
- Transfer logical context using the prior locator, normalized output, raw stream, workspace, and PLAN. Use native resume only when both targets opt into the same catalog-declared native-session mechanism and the session belongs to the current workspace.
|
||||
|
||||
For Pi worker recovery attempts, pass only `Read {PLAN_PATH}. Continue.` without a locator explanation. Pi self-check recovery must preserve the current full-pass or unchecked-item role and use its concise prompt. For other CLI escalation attempts, pass `Continue from {LOCATOR_PATH}. Check the saved context and current workspace. Keep artifact content in English. Final in Korean.` Preserve the collaboration prohibition and next-state-materialization sentence in official-review escalation and recovery prompts. Do not ask the model to write a separate handoff summary.
|
||||
## Scheduling and write claims
|
||||
|
||||
When recovering a KST-night `local-G07`–`local-G08` Laguna locator or a terminal `session-stall` locator left by an earlier dispatcher, first require the locator and native session to belong to the current physical workspace. Do not create a fresh session ID for an owned locator. Resume its native session file with `pi --session` and the existing `--session-dir`. For worker recovery pass `Think in English. Keep artifact content in English. Final in Korean. Continue this session and complete the current task.` For interrupted full self-check recovery pass `Think in English. Final in Korean. Continue. Keep files in English.` For an unchecked-item retry, pass its normal concise prompt while resuming the existing native session. After a dispatcher restart, find the owned locator and resume the same session. Count this same-session restart toward the same stage's 10-consecutive-failure limit.
|
||||
- Admit every dependency-ready task whose canonical PLAN write set does not collide with another active claim.
|
||||
- Require exactly one non-empty `Modified Files Summary` or supported legacy heading. Reject broad, malformed, directory, outside-workspace, or missing paths.
|
||||
- Atomically claim canonical paths before worker, self-check, or review execution. Keep a task's claim across retries and follow-up PLANs; release it only after verified archive completion.
|
||||
- Treat explicit predecessors as unfinished while matching live execution evidence exists, even if a `complete.log` is already visible.
|
||||
- Apply `max_parallel` across the physical workspace, independent of task-group filtering. Do not count internal helper coroutines as agent slots.
|
||||
- A blocker delays only that task and its dependency closure. Continue draining independent work.
|
||||
|
||||
## Work-Log Contract
|
||||
## Prompt and child boundary
|
||||
|
||||
- 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.
|
||||
Prefix worker and review prompts with the dispatcher-child boundary that prohibits starting or monitoring another orchestration loop. A child may use `dispatch.py --validate-plan` only when its plan or review finalization requires it.
|
||||
|
||||
## Caller Lifecycle and Status Display
|
||||
Prompts must include absolute artifact paths and instruct the child to follow the repository's language and output rules. Do not hardcode a programming language, human language, agent, model, or provider in common prompts.
|
||||
|
||||
- **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 the initial Pi full self-check plus 10 same-context unchecked-item retries leave the implementation checklist incomplete, or official review makes no change 10 consecutive times.
|
||||
- While one task recovers or becomes blocked, continue every ready/running task that neither requires it as a predecessor nor collides with its retained workspace claim. Internal recovery or blocking must not trigger an arbitrary complete-candidate rescan.
|
||||
- If review shared-state preflight fails, block only ready review tasks and still start every worker/self-check with a disjoint claim in the same pass. The complete scan after `complete.log` must preserve the existing snapshot rather than reread already running task directories, avoiding races with parallel archive moves that could stop another process.
|
||||
- For KST-night `local-G07`–`local-G08` Laguna locator `context-limit`/`session-stall`, prefer the Prompt Contract's same-session resume and display `Pi세션연속재시작`. Use a fresh session and `세션응답복구재시도` only for other legacy Pi `session-stall` recovery.
|
||||
- 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.
|
||||
Never ask a child to create, edit, or summarize `WORK_LOG.md`; that file is dispatcher-owned.
|
||||
|
||||
## Failure Classification and Reporting Contract
|
||||
## Self-check
|
||||
|
||||
- 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.
|
||||
Run self-check only when the selected catalog target declares `selfcheck_required=true`. The completing decision, not a fixed agent identity or execution class, determines the requirement.
|
||||
|
||||
## Procedure
|
||||
Accept self-check completion only when `## Implementation Checklist` or its supported legacy heading contains at least one checkbox and every checkbox has a non-empty value. Run one full pass, then resume the latest successful native context for at most 10 unchecked-item retries when the target supports native resume. Block instead of silently starting a new context when a required persisted context is unavailable.
|
||||
|
||||
1. **Inspect state.**
|
||||
- Print active tasks, routes, stages, and dependencies:
|
||||
## Runtime evidence and recovery
|
||||
|
||||
```bash
|
||||
python3 agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py --dry-run
|
||||
```
|
||||
- Store each attempt under the dispatcher state directory with `locator.json`, `stream.log`, `normalized-output.log`, and `heartbeat.log`.
|
||||
- Record the target id, opaque agent/model identity, execution class, runtime contract, catalog evidence, process identity, workspace identity, timestamps, result, and exact failure evidence.
|
||||
- Treat stderr as terminal diagnostic evidence. For JSONL, recognize generic terminal event fields such as error/fatal type or severity, rejected/failed status with an error code, and explicit error flags.
|
||||
- Determine liveness from PID/start-token/process-marker evidence and actual stream or native-session progress. Heartbeat mtime is never agent progress.
|
||||
- Never start a duplicate attempt while owned live evidence remains.
|
||||
- Keep a 10-consecutive-failure budget per task stage. Reset only that stage's budget after success.
|
||||
- Preserve failed attempt logs. Delete successful attempt logs only after verified archive completion and no live evidence.
|
||||
|
||||
- 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.
|
||||
## Work log
|
||||
|
||||
2. **Run the dispatcher.**
|
||||
- Run all active tasks with the default physical-workspace cap of `3`:
|
||||
- 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.
|
||||
- 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`.
|
||||
|
||||
```bash
|
||||
python3 agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py
|
||||
```
|
||||
## Invocation
|
||||
|
||||
- Run one task group:
|
||||
|
||||
```bash
|
||||
python3 agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py --task-group <task_group>
|
||||
```
|
||||
|
||||
- Cap total concurrent attempts across the physical workspace:
|
||||
|
||||
```bash
|
||||
python3 agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py --max-parallel 2
|
||||
```
|
||||
|
||||
- Explicitly disable the cap:
|
||||
|
||||
```bash
|
||||
python3 agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py --max-parallel 0
|
||||
```
|
||||
|
||||
- Preview classification without launching CLIs under the same cap:
|
||||
|
||||
```bash
|
||||
python3 agent-ops/skills/common/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 Pi worker success, Pi self-check success, and official review as separate stages. If restart state is `worker_done=true` and `selfcheck_done=false`, resume on the same Pi model, not with worker or review. Run the full pass when `selfcheck_incomplete=0`; otherwise resume the persisted successful self-check context locator with an unchecked-item retry. Never replace a missing or invalid persisted context with a fresh session.
|
||||
- Key persistent state to the first-line `task/plan/tag` generation and, for `m-*`, its `milestone-task` scope. Checklist/body edits to the same PLAN do not reset the stage; a new plan number or changed Milestone Task scope does.
|
||||
- Send an already completed review stub with no dispatcher execution record to review. Never send dispatcher-recorded Pi worker success to review before self-check completes.
|
||||
- Start official review and worker/self-check together when they belong to different dependency-ready tasks with disjoint workspace claims. Wait for a claim owner to reach verified completion before admitting a colliding task.
|
||||
- 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.**
|
||||
- Escalate `agy -> Claude -> Codex` or `Claude -> Codex` only on terminal provider error events or stderr evidence of context/output limits, provider quota/rate limits, unavailable models, or confirmed provider transport errors. 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 Claude, classify a `rate_limit_event` with `rate_limit_info.status=rejected`, an error `result` with `api_error_status=429` or `error=rate_limit`, or a `You've hit your session limit · resets ...` terminal diagnostic as `provider-quota`. Never escalate from an assistant message, source text, tool/test output, or a plain quota-configuration string in an AGY log.
|
||||
- Target Codex `gpt-5.6-terra` with reasoning `high` when escalating from Claude to Codex.
|
||||
- If Codex returns the same error, retry in a fresh Codex session using the locator while preserving the previous Codex model/reasoning and sharing the same stage's 10-consecutive-failure limit. Continue dispatching other tasks during recovery.
|
||||
- When current source reads a locator blocked 10 times as `generic-error` by older dispatcher source, collapse those 10 failures into one terminal error and clear only that task's blocker only if all 10 terminal-evidence records for the same task/plan/role/source/execution target reclassify to the same escalatable error. Include `stream.log` and the attempt's `agy-cli.log` for AGY. Do not adjust automatically when any history is missing or mixed, or when the locator dispatcher source hash equals the current source hash. Dry-run must display this escalation recovery and next model without writing state. Live execution must choose the higher target from the locator's actual failed target, not the initial PLAN route, inherit locator context, and restore the same escalation target and locator from persisted reclassification metadata after immediate restart.
|
||||
- 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 Pi to a cloud model.
|
||||
- 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 Codex one-shot session with no separate numeric limit. Dispatch all ready reviews with disjoint workspace claims in parallel.
|
||||
- For finalization recovery without an active PLAN, recover the review target and write claim from the archived plan log for the same first-line generation metadata, including `milestone-task` when present. Keep the claim until the completed archive is verified.
|
||||
- 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 the routing table.
|
||||
- [ ] Run exactly one full fresh-session self-check only for Pi work, followed by at most 10 unchecked-item retries in that same Pi native session context when its checklist remains incomplete.
|
||||
- [ ] Run every official review with Codex `gpt-5.6-sol` xhigh and dispatch dependency-ready reviews with disjoint workspace claims in parallel, subject to the global `--max-parallel` cap (no separate review-only limit).
|
||||
- [ ] Locate the native session and output log for every attempt locator.
|
||||
- [ ] 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=pi/iop/ornith:35b
|
||||
plan=/absolute/path/PLAN-local-G05.md
|
||||
work_log=/absolute/path/WORK_LOG.md
|
||||
|
||||
------------------------------------------
|
||||
리뷰시작: 03+01_event_contract_unit_tests
|
||||
------------------------------------------
|
||||
model=codex/gpt-5.6-sol xhigh
|
||||
review=/absolute/path/CODE_REVIEW-local-G05.md
|
||||
```bash
|
||||
python3 agent-ops/skills/common/orchestrate-agent-task-loop/scripts/dispatch.py \
|
||||
--workspace /absolute/repository \
|
||||
--execution-catalog /runtime/config/execution-catalog.json \
|
||||
--dry-run
|
||||
```
|
||||
|
||||
Use the same separator format for `작업대기`, `작업수행중`, `자가검증시작`, `로그보완재시도`, `모델승격`, `리뷰결과`, `루프정체경고`, `작업차단`, `작업로그아카이브`, and `작업완료`.
|
||||
Remove `--dry-run` to start execution. Add `--task-group <name>`, `--max-parallel <n>`, or `--retry-blocked` only when requested by the workflow.
|
||||
|
||||
## Prohibitions
|
||||
Launch the live dispatcher as one persistent foreground process. Do not wrap it in an arbitrary timeout and do not start a second dispatcher after a normal tool yield. Wait on the same execution handle until an attention event or terminal exit.
|
||||
|
||||
- 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 Pi self-check 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.
|
||||
## Completion checklist
|
||||
|
||||
- [ ] Catalog was injected, fully validated, preflighted, and revision-pinned.
|
||||
- [ ] No fixed common agent/model/provider route or quota probe was used.
|
||||
- [ ] Runtime quota errors moved only to the next catalog candidate.
|
||||
- [ ] Dependencies, write claims, and workspace concurrency were enforced.
|
||||
- [ ] Required self-check and official review stages completed.
|
||||
- [ ] Every observed task has a verified archived `complete.log`.
|
||||
- [ ] Work logs and successful attempt cleanup were reconciled.
|
||||
- [ ] No active, waiting, pending, or blocked in-scope task remains.
|
||||
- [ ] Dispatcher exited `0` before successful final response.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
interface:
|
||||
display_name: "Agent Task Loop Orchestrator"
|
||||
short_description: "Orchestrate PLAN execution and Codex review loops"
|
||||
short_description: "Orchestrate PLAN and review loops with an injected runtime catalog"
|
||||
default_prompt: "Use $orchestrate-agent-task-loop to execute the active agent-task workflow."
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,125 +1,350 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Pure execution-target policy for Agent Task worker and review stages."""
|
||||
"""Runtime-injected execution-target catalog and route policy.
|
||||
|
||||
This common module intentionally owns no agent or model catalog. A caller
|
||||
supplies a JSON catalog at runtime; this module validates it and resolves one
|
||||
ordered route without interpreting provider-specific identities.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
from zoneinfo import ZoneInfo
|
||||
from datetime import datetime, time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
|
||||
CATALOG_SCHEMA_VERSION = "1.0"
|
||||
VALID_STAGES = {"worker", "review"}
|
||||
VALID_LANES = {"local", "cloud"}
|
||||
VALID_EXECUTION_CLASSES = {"local_model", "cloud_model"}
|
||||
VALID_OUTPUT_FORMATS = {"jsonl", "text"}
|
||||
ALLOWED_TEMPLATE_FIELDS = {
|
||||
"agent",
|
||||
"attempt_dir",
|
||||
"model",
|
||||
"prompt",
|
||||
"resume_session",
|
||||
"session_id",
|
||||
"target_id",
|
||||
"workspace",
|
||||
}
|
||||
|
||||
|
||||
class CatalogError(ValueError):
|
||||
"""The injected execution catalog is missing or malformed."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouteTarget:
|
||||
adapter: str
|
||||
target: str
|
||||
catalog_id: str
|
||||
agent: str
|
||||
model: str
|
||||
execution_class: str
|
||||
selfcheck_required: bool
|
||||
runtime: dict[str, Any]
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionTargetCatalog:
|
||||
source: Path
|
||||
revision: str
|
||||
targets: dict[str, RouteTarget]
|
||||
routes: dict[str, dict[str, dict[str, Any]]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PolicyDecision:
|
||||
route_id: str
|
||||
rule_id: str
|
||||
policy_priority: int
|
||||
reason_codes: tuple[str, ...]
|
||||
time_window: str
|
||||
catalog_revision: str
|
||||
candidates: tuple[RouteTarget, ...]
|
||||
|
||||
|
||||
PI_ORNITH = RouteTarget("pi", "iop/ornith:35b", "local_model", True)
|
||||
AGY_GEMINI_LOW = RouteTarget(
|
||||
"agy", "Gemini 3.6 Flash (Low)", "cloud_model", False
|
||||
)
|
||||
AGY_GEMINI_MEDIUM = RouteTarget(
|
||||
"agy", "Gemini 3.6 Flash (Medium)", "cloud_model", False
|
||||
)
|
||||
AGY_GEMINI_HIGH = RouteTarget(
|
||||
"agy", "Gemini 3.6 Flash (High)", "cloud_model", False
|
||||
)
|
||||
PI_LAGUNA = RouteTarget("pi", "iop/laguna-s:2.1", "local_model", True)
|
||||
CLAUDE_OPUS = RouteTarget("claude", "claude-opus-4-8", "cloud_model", False)
|
||||
CLAUDE_HAIKU_XHIGH = RouteTarget(
|
||||
"claude", "claude-haiku-4-5", "cloud_model", False
|
||||
)
|
||||
CODEX_SPARK_XHIGH = RouteTarget(
|
||||
"codex", "gpt-5.3-codex-spark", "cloud_model", False
|
||||
)
|
||||
CODEX_SOL_XHIGH = RouteTarget("codex", "gpt-5.6-sol", "cloud_model", False)
|
||||
CODEX_TERRA_HIGH = RouteTarget("codex", "gpt-5.6-terra", "cloud_model", False)
|
||||
def _require_string(value: object, label: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise CatalogError(f"{label} must be a non-empty string")
|
||||
return value
|
||||
|
||||
|
||||
CANONICAL_TARGETS = (
|
||||
PI_ORNITH,
|
||||
AGY_GEMINI_LOW,
|
||||
AGY_GEMINI_MEDIUM,
|
||||
AGY_GEMINI_HIGH,
|
||||
PI_LAGUNA,
|
||||
CLAUDE_OPUS,
|
||||
CLAUDE_HAIKU_XHIGH,
|
||||
CODEX_SPARK_XHIGH,
|
||||
CODEX_SOL_XHIGH,
|
||||
CODEX_TERRA_HIGH,
|
||||
)
|
||||
def _validate_template(parts: object, label: str) -> tuple[str, ...]:
|
||||
if not isinstance(parts, list) or not parts:
|
||||
raise CatalogError(f"{label} must be a non-empty string list")
|
||||
if not all(isinstance(part, str) and part for part in parts):
|
||||
raise CatalogError(f"{label} must contain only non-empty strings")
|
||||
for part in parts:
|
||||
offset = 0
|
||||
while True:
|
||||
start = part.find("{", offset)
|
||||
if start < 0:
|
||||
break
|
||||
end = part.find("}", start + 1)
|
||||
if end < 0:
|
||||
raise CatalogError(f"{label} contains an unmatched '{{': {part!r}")
|
||||
field = part[start + 1 : end]
|
||||
if field not in ALLOWED_TEMPLATE_FIELDS:
|
||||
raise CatalogError(
|
||||
f"{label} uses unsupported template field {field!r}"
|
||||
)
|
||||
offset = end + 1
|
||||
return tuple(parts)
|
||||
|
||||
|
||||
def canonical_target(adapter: str, target: str) -> RouteTarget | None:
|
||||
"""Resolve one policy-owned adapter + target identity."""
|
||||
return next(
|
||||
(
|
||||
candidate
|
||||
for candidate in CANONICAL_TARGETS
|
||||
if candidate.adapter == adapter and candidate.target == target
|
||||
),
|
||||
None,
|
||||
def _validate_runtime(value: object, label: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise CatalogError(f"{label} must be an object")
|
||||
unknown = set(value) - {
|
||||
"command",
|
||||
"resume_command",
|
||||
"preflight_command",
|
||||
"environment",
|
||||
"output_format",
|
||||
"session_path",
|
||||
"native_session_monitor",
|
||||
"auxiliary_logs",
|
||||
}
|
||||
if unknown:
|
||||
raise CatalogError(f"{label} has unsupported keys: {sorted(unknown)}")
|
||||
command = list(_validate_template(value.get("command"), f"{label}.command"))
|
||||
if "{" in command[0] or "}" in command[0]:
|
||||
raise CatalogError(f"{label}.command executable must be a literal path or name")
|
||||
runtime: dict[str, Any] = {
|
||||
"command": command,
|
||||
"output_format": value.get("output_format", "text"),
|
||||
}
|
||||
if runtime["output_format"] not in VALID_OUTPUT_FORMATS:
|
||||
raise CatalogError(
|
||||
f"{label}.output_format must be one of {sorted(VALID_OUTPUT_FORMATS)}"
|
||||
)
|
||||
for field in ("resume_command", "preflight_command"):
|
||||
if field in value:
|
||||
template = list(
|
||||
_validate_template(value[field], f"{label}.{field}")
|
||||
)
|
||||
if "{" in template[0] or "}" in template[0]:
|
||||
raise CatalogError(
|
||||
f"{label}.{field} executable must be a literal path or name"
|
||||
)
|
||||
runtime[field] = template
|
||||
environment = value.get("environment", {})
|
||||
if not isinstance(environment, dict) or not all(
|
||||
isinstance(key, str)
|
||||
and key
|
||||
and isinstance(item, str)
|
||||
for key, item in environment.items()
|
||||
):
|
||||
raise CatalogError(f"{label}.environment must be a string map")
|
||||
runtime["environment"] = dict(environment)
|
||||
session_path = value.get("session_path")
|
||||
if session_path is not None:
|
||||
runtime["session_path"] = _require_string(
|
||||
session_path, f"{label}.session_path"
|
||||
)
|
||||
_validate_template([session_path], f"{label}.session_path")
|
||||
monitor = value.get("native_session_monitor", False)
|
||||
if not isinstance(monitor, bool):
|
||||
raise CatalogError(f"{label}.native_session_monitor must be a boolean")
|
||||
runtime["native_session_monitor"] = monitor
|
||||
auxiliary_logs = value.get("auxiliary_logs", [])
|
||||
if not isinstance(auxiliary_logs, list) or not all(
|
||||
isinstance(item, str) and item for item in auxiliary_logs
|
||||
):
|
||||
raise CatalogError(f"{label}.auxiliary_logs must be a string list")
|
||||
for index, item in enumerate(auxiliary_logs):
|
||||
_validate_template([item], f"{label}.auxiliary_logs[{index}]")
|
||||
runtime["auxiliary_logs"] = list(auxiliary_logs)
|
||||
return runtime
|
||||
|
||||
|
||||
def _validate_target(target_id: str, value: object) -> RouteTarget:
|
||||
label = f"targets.{target_id}"
|
||||
if not isinstance(value, dict):
|
||||
raise CatalogError(f"{label} must be an object")
|
||||
unknown = set(value) - {
|
||||
"agent",
|
||||
"model",
|
||||
"execution_class",
|
||||
"selfcheck_required",
|
||||
"runtime",
|
||||
}
|
||||
if unknown:
|
||||
raise CatalogError(f"{label} has unsupported keys: {sorted(unknown)}")
|
||||
execution_class = value.get("execution_class")
|
||||
if execution_class not in VALID_EXECUTION_CLASSES:
|
||||
raise CatalogError(
|
||||
f"{label}.execution_class must be one of "
|
||||
f"{sorted(VALID_EXECUTION_CLASSES)}"
|
||||
)
|
||||
selfcheck_required = value.get("selfcheck_required", False)
|
||||
if not isinstance(selfcheck_required, bool):
|
||||
raise CatalogError(f"{label}.selfcheck_required must be a boolean")
|
||||
return RouteTarget(
|
||||
catalog_id=target_id,
|
||||
agent=_require_string(value.get("agent"), f"{label}.agent"),
|
||||
model=_require_string(value.get("model"), f"{label}.model"),
|
||||
execution_class=execution_class,
|
||||
selfcheck_required=selfcheck_required,
|
||||
runtime=_validate_runtime(value.get("runtime"), f"{label}.runtime"),
|
||||
)
|
||||
|
||||
|
||||
def promotion_target(current: RouteTarget) -> RouteTarget | None:
|
||||
"""Return the next target in the policy-owned cloud promotion chain."""
|
||||
if current.adapter == "agy" and current in {
|
||||
AGY_GEMINI_LOW,
|
||||
AGY_GEMINI_MEDIUM,
|
||||
AGY_GEMINI_HIGH,
|
||||
}:
|
||||
return CLAUDE_OPUS
|
||||
if current == CLAUDE_OPUS:
|
||||
return CODEX_TERRA_HIGH
|
||||
return None
|
||||
def _validate_window(value: object, label: str) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise CatalogError(f"{label} must be an object")
|
||||
required = {"timezone", "start", "end", "candidates"}
|
||||
missing = required - set(value)
|
||||
if missing:
|
||||
raise CatalogError(f"{label} missing keys: {sorted(missing)}")
|
||||
timezone_name = _require_string(value["timezone"], f"{label}.timezone")
|
||||
try:
|
||||
ZoneInfo(timezone_name)
|
||||
except ZoneInfoNotFoundError as exc:
|
||||
raise CatalogError(f"{label}.timezone is unknown: {timezone_name}") from exc
|
||||
for field in ("start", "end"):
|
||||
raw = _require_string(value[field], f"{label}.{field}")
|
||||
try:
|
||||
time.fromisoformat(raw)
|
||||
except ValueError as exc:
|
||||
raise CatalogError(f"{label}.{field} must be HH:MM[:SS]") from exc
|
||||
return dict(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QuotaProbeSpec:
|
||||
command: str
|
||||
target: str
|
||||
required_caps: tuple[str, ...]
|
||||
|
||||
|
||||
def quota_probe_spec(target: RouteTarget) -> QuotaProbeSpec | None:
|
||||
"""Return the policy-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}"),
|
||||
def _validate_route(
|
||||
value: object,
|
||||
label: str,
|
||||
target_ids: set[str],
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(value, dict):
|
||||
raise CatalogError(f"{label} must be an object")
|
||||
unknown = set(value) - {
|
||||
"candidates",
|
||||
"rule_id",
|
||||
"policy_priority",
|
||||
"reason_codes",
|
||||
"windows",
|
||||
}
|
||||
if unknown:
|
||||
raise CatalogError(f"{label} has unsupported keys: {sorted(unknown)}")
|
||||
candidates = value.get("candidates")
|
||||
windows = value.get("windows")
|
||||
if (candidates is None) == (windows is None):
|
||||
raise CatalogError(
|
||||
f"{label} must define exactly one of candidates or windows"
|
||||
)
|
||||
if target.adapter in {"claude", "codex"}:
|
||||
return QuotaProbeSpec(
|
||||
command=target.adapter,
|
||||
target=target.target,
|
||||
required_caps=("overall",),
|
||||
normalized = dict(value)
|
||||
if windows is not None:
|
||||
if not isinstance(windows, list) or not windows:
|
||||
raise CatalogError(f"{label}.windows must be a non-empty list")
|
||||
normalized["windows"] = [
|
||||
_validate_window(item, f"{label}.windows[{index}]")
|
||||
for index, item in enumerate(windows)
|
||||
]
|
||||
candidate_lists = [item["candidates"] for item in normalized["windows"]]
|
||||
else:
|
||||
candidate_lists = [candidates]
|
||||
for index, candidate_list in enumerate(candidate_lists):
|
||||
item_label = f"{label}.candidates[{index}]"
|
||||
if not isinstance(candidate_list, list) or not candidate_list:
|
||||
raise CatalogError(f"{item_label} must be a non-empty list")
|
||||
if len(candidate_list) != len(set(candidate_list)):
|
||||
raise CatalogError(f"{item_label} must not contain duplicates")
|
||||
unknown_targets = [item for item in candidate_list if item not in target_ids]
|
||||
if unknown_targets:
|
||||
raise CatalogError(
|
||||
f"{item_label} references unknown targets: {unknown_targets}"
|
||||
)
|
||||
priority = value.get("policy_priority", 0)
|
||||
if isinstance(priority, bool) or not isinstance(priority, int):
|
||||
raise CatalogError(f"{label}.policy_priority must be an integer")
|
||||
reasons = value.get("reason_codes", [])
|
||||
if not isinstance(reasons, list) or not all(
|
||||
isinstance(item, str) and item for item in reasons
|
||||
):
|
||||
raise CatalogError(f"{label}.reason_codes must be a string list")
|
||||
return normalized
|
||||
|
||||
|
||||
def load_catalog(path: str | Path) -> ExecutionTargetCatalog:
|
||||
source = Path(path).expanduser().resolve()
|
||||
try:
|
||||
raw = source.read_bytes()
|
||||
except OSError as exc:
|
||||
raise CatalogError(f"execution catalog is unreadable: {source}: {exc}") from exc
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise CatalogError(f"execution catalog is not valid UTF-8 JSON: {source}") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise CatalogError("execution catalog root must be an object")
|
||||
if set(value) != {"schema_version", "targets", "routes"}:
|
||||
raise CatalogError(
|
||||
"execution catalog root must contain exactly schema_version, targets, routes"
|
||||
)
|
||||
return None
|
||||
if value["schema_version"] != CATALOG_SCHEMA_VERSION:
|
||||
raise CatalogError(
|
||||
f"execution catalog schema_version must be {CATALOG_SCHEMA_VERSION!r}"
|
||||
)
|
||||
raw_targets = value["targets"]
|
||||
if not isinstance(raw_targets, dict) or not raw_targets:
|
||||
raise CatalogError("execution catalog targets must be a non-empty object")
|
||||
targets = {
|
||||
_require_string(target_id, "target id"): _validate_target(target_id, item)
|
||||
for target_id, item in raw_targets.items()
|
||||
}
|
||||
raw_routes = value["routes"]
|
||||
if not isinstance(raw_routes, dict) or set(raw_routes) != VALID_STAGES:
|
||||
raise CatalogError(
|
||||
f"execution catalog routes must contain exactly {sorted(VALID_STAGES)}"
|
||||
)
|
||||
routes: dict[str, dict[str, dict[str, Any]]] = {}
|
||||
required_route_ids = {
|
||||
f"{lane}-G{grade:02d}"
|
||||
for lane in VALID_LANES
|
||||
for grade in range(1, 11)
|
||||
}
|
||||
for stage in sorted(VALID_STAGES):
|
||||
stage_routes = raw_routes[stage]
|
||||
if not isinstance(stage_routes, dict) or set(stage_routes) != required_route_ids:
|
||||
missing = sorted(required_route_ids - set(stage_routes or {}))
|
||||
extra = sorted(set(stage_routes or {}) - required_route_ids)
|
||||
raise CatalogError(
|
||||
f"routes.{stage} must cover local/cloud G01..G10 exactly; "
|
||||
f"missing={missing}, extra={extra}"
|
||||
)
|
||||
routes[stage] = {
|
||||
route_id: _validate_route(
|
||||
route, f"routes.{stage}.{route_id}", set(targets)
|
||||
)
|
||||
for route_id, route in stage_routes.items()
|
||||
}
|
||||
revision = hashlib.sha256(raw).hexdigest()
|
||||
return ExecutionTargetCatalog(source, revision, targets, routes)
|
||||
|
||||
|
||||
def _validate(stage: str, lane: str, grade: int, evaluated_at: datetime) -> None:
|
||||
def canonical_target(catalog: ExecutionTargetCatalog, target_id: str) -> RouteTarget | None:
|
||||
return catalog.targets.get(target_id)
|
||||
|
||||
|
||||
def _window_matches(window: dict[str, Any], evaluated_at: datetime) -> bool:
|
||||
local_time = evaluated_at.astimezone(ZoneInfo(window["timezone"])).time()
|
||||
start = time.fromisoformat(window["start"])
|
||||
end = time.fromisoformat(window["end"])
|
||||
return start <= local_time < end if start < end else local_time >= start or local_time < end
|
||||
|
||||
|
||||
def select_policy(
|
||||
*,
|
||||
catalog: ExecutionTargetCatalog,
|
||||
stage: str,
|
||||
lane: str,
|
||||
grade: int,
|
||||
evaluated_at: datetime,
|
||||
) -> PolicyDecision:
|
||||
if stage not in VALID_STAGES:
|
||||
raise ValueError(f"unsupported stage: {stage}")
|
||||
if lane not in VALID_LANES:
|
||||
|
|
@ -128,93 +353,27 @@ def _validate(stage: str, lane: str, grade: int, evaluated_at: datetime) -> None
|
|||
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 target policy for one initial route evaluation."""
|
||||
|
||||
_validate(stage, lane, grade, evaluated_at)
|
||||
|
||||
if stage == "review":
|
||||
return PolicyDecision(
|
||||
rule_id="official-review-codex",
|
||||
policy_priority=10,
|
||||
reason_codes=("official_review_fixed",),
|
||||
time_window="not_applicable",
|
||||
candidates=(CODEX_SOL_XHIGH,),
|
||||
)
|
||||
|
||||
if lane == "local":
|
||||
if grade <= 6:
|
||||
return PolicyDecision(
|
||||
rule_id="worker-local-g01-g06",
|
||||
policy_priority=30,
|
||||
reason_codes=("local_low_grade",),
|
||||
time_window="not_applicable",
|
||||
candidates=(PI_ORNITH,),
|
||||
route_id = f"{lane}-G{grade:02d}"
|
||||
route = catalog.routes[stage][route_id]
|
||||
selected_route = route
|
||||
time_window = "not_applicable"
|
||||
if "windows" in route:
|
||||
matches = [item for item in route["windows"] if _window_matches(item, evaluated_at)]
|
||||
if len(matches) != 1:
|
||||
raise CatalogError(
|
||||
f"routes.{stage}.{route_id}.windows must match exactly once; matches={len(matches)}"
|
||||
)
|
||||
if grade <= 8:
|
||||
time_window = _kst_time_window(evaluated_at)
|
||||
if time_window == "kst-day-[07:00,23:00)":
|
||||
rule_id = "worker-local-g07-g08-kst-day"
|
||||
reason_code = "kst_day_gemini_medium"
|
||||
candidates = (AGY_GEMINI_MEDIUM, PI_LAGUNA)
|
||||
else:
|
||||
rule_id = "worker-local-g07-g08-kst-night"
|
||||
reason_code = "kst_night_laguna"
|
||||
candidates = (PI_LAGUNA, AGY_GEMINI_MEDIUM)
|
||||
return PolicyDecision(
|
||||
rule_id=rule_id,
|
||||
policy_priority=20,
|
||||
reason_codes=(reason_code,),
|
||||
time_window=time_window,
|
||||
candidates=candidates,
|
||||
)
|
||||
return PolicyDecision(
|
||||
rule_id="worker-local-g09-g10",
|
||||
policy_priority=30,
|
||||
reason_codes=("local_high_grade_cloud_target",),
|
||||
time_window="not_applicable",
|
||||
candidates=(CLAUDE_OPUS,),
|
||||
selected_route = {**route, **matches[0]}
|
||||
time_window = (
|
||||
f"{matches[0]['timezone']}:{matches[0]['start']}-{matches[0]['end']}"
|
||||
)
|
||||
|
||||
if grade <= 2:
|
||||
candidates = (
|
||||
CODEX_SPARK_XHIGH,
|
||||
AGY_GEMINI_LOW,
|
||||
CLAUDE_HAIKU_XHIGH,
|
||||
)
|
||||
rule_id = "worker-cloud-g01-g02"
|
||||
reason_code = "cloud_spark_priority_grade"
|
||||
elif grade <= 4:
|
||||
candidates = (AGY_GEMINI_MEDIUM,)
|
||||
rule_id = "worker-cloud-g03-g04"
|
||||
reason_code = "cloud_gemini_medium_grade"
|
||||
elif grade <= 6:
|
||||
candidates = (AGY_GEMINI_HIGH,)
|
||||
rule_id = "worker-cloud-g05-g06"
|
||||
reason_code = "cloud_gemini_high_grade"
|
||||
elif grade <= 8:
|
||||
candidates = (CLAUDE_OPUS,)
|
||||
rule_id = "worker-cloud-g07-g08"
|
||||
reason_code = "cloud_opus_grade"
|
||||
else:
|
||||
candidates = (CODEX_SOL_XHIGH,)
|
||||
rule_id = "worker-cloud-g09-g10"
|
||||
reason_code = "cloud_codex_grade"
|
||||
candidate_ids = selected_route["candidates"]
|
||||
return PolicyDecision(
|
||||
rule_id=rule_id,
|
||||
policy_priority=30,
|
||||
reason_codes=(reason_code,),
|
||||
time_window="not_applicable",
|
||||
candidates=candidates,
|
||||
route_id=route_id,
|
||||
rule_id=str(selected_route.get("rule_id") or f"{stage}-{route_id}"),
|
||||
policy_priority=int(selected_route.get("policy_priority", 0)),
|
||||
reason_codes=tuple(selected_route.get("reason_codes", [])),
|
||||
time_window=time_window,
|
||||
catalog_revision=catalog.revision,
|
||||
candidates=tuple(catalog.targets[target_id] for target_id in candidate_ids),
|
||||
)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -130,10 +130,10 @@ class ObservationInvokeIntegrationTest(unittest.IsolatedAsyncioTestCase):
|
|||
cwd,
|
||||
actual_session_id,
|
||||
attempt_dir,
|
||||
pi_resume_session=None,
|
||||
native_resume_session=None,
|
||||
):
|
||||
self.assertEqual(actual_session_id, session_id)
|
||||
native = attempt_dir / "pi-sessions" / f"session_{session_id}.jsonl"
|
||||
native = attempt_dir / "native-sessions" / f"session_{session_id}.jsonl"
|
||||
child = (
|
||||
"from pathlib import Path\n"
|
||||
"import sys,time\n"
|
||||
|
|
@ -148,7 +148,20 @@ class ObservationInvokeIntegrationTest(unittest.IsolatedAsyncioTestCase):
|
|||
)
|
||||
return [sys.executable, "-c", child, str(native)]
|
||||
|
||||
spec = dispatch.AgentSpec("pi", "ornith:35b", "pi", local_pi=True)
|
||||
spec = dispatch.AgentSpec(
|
||||
"runtime-agent",
|
||||
"runtime-model",
|
||||
"runtime-target",
|
||||
native_resume=True,
|
||||
target_id="runtime-target",
|
||||
execution_class="local_model",
|
||||
runtime={
|
||||
"command": ["runtime-command", "{prompt}"],
|
||||
"session_path": "native-sessions/session_{session_id}.jsonl",
|
||||
"native_session_monitor": True,
|
||||
"output_format": "text",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with (
|
||||
mock.patch.object(dispatch, "build_command", side_effect=command_for),
|
||||
|
|
@ -192,102 +205,14 @@ class SkillObservationContractTest(unittest.TestCase):
|
|||
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(
|
||||
"KST-night `local-G07`–`local-G08` Laguna locator `context-limit`/`session-stall`",
|
||||
skill,
|
||||
)
|
||||
self.assertIn(
|
||||
"fresh session and `세션응답복구재시도` only for other legacy Pi `session-stall` recovery",
|
||||
skill,
|
||||
)
|
||||
self.assertIn("The dispatcher owns deterministic scheduling, recovery", skill)
|
||||
self.assertIn("Launch the live dispatcher as one persistent foreground process", skill)
|
||||
self.assertIn("Do not count internal helper coroutines as agent slots", skill)
|
||||
self.assertIn("actual stream or native-session progress", skill)
|
||||
self.assertIn("PID/start-token/process-marker evidence", skill)
|
||||
self.assertIn("never queries quota before admission", skill)
|
||||
self.assertIn("confirmed quota/rate-limit error advances directly", skill)
|
||||
self.assertIn("Common owns no default agent, model, provider, or route catalog", skill)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -1,248 +1,178 @@
|
|||
import importlib.util
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
SCRIPT = (
|
||||
Path(__file__).resolve().parents[1]
|
||||
/ "scripts"
|
||||
/ "execution_target_policy.py"
|
||||
)
|
||||
SPEC = importlib.util.spec_from_file_location("execution_target_policy", SCRIPT)
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "execution_target_policy.py"
|
||||
SPEC = importlib.util.spec_from_file_location("execution_target_policy_test", 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)
|
||||
def catalog_value(*, windows: bool = False) -> dict:
|
||||
targets = {
|
||||
"target-a": {
|
||||
"agent": "runner-a",
|
||||
"model": "model-a",
|
||||
"execution_class": "local_model",
|
||||
"selfcheck_required": True,
|
||||
"runtime": {
|
||||
"command": ["runner-a", "--model", "{model}", "{prompt}"],
|
||||
"resume_command": ["runner-a", "--resume", "{resume_session}", "{prompt}"],
|
||||
"output_format": "jsonl",
|
||||
"native_session_monitor": True,
|
||||
},
|
||||
},
|
||||
"target-b": {
|
||||
"agent": "runner-b",
|
||||
"model": "model-b",
|
||||
"execution_class": "cloud_model",
|
||||
"runtime": {"command": ["runner-b", "{prompt}"]},
|
||||
},
|
||||
}
|
||||
routes = {"worker": {}, "review": {}}
|
||||
for stage in routes:
|
||||
for lane in ("local", "cloud"):
|
||||
for grade in range(1, 11):
|
||||
route = {
|
||||
"candidates": ["target-a", "target-b"],
|
||||
"rule_id": f"{stage}-{lane}-g{grade:02d}",
|
||||
"policy_priority": grade,
|
||||
"reason_codes": ["catalog-route"],
|
||||
}
|
||||
routes[stage][f"{lane}-G{grade:02d}"] = route
|
||||
if windows:
|
||||
routes["worker"]["local-G07"] = {
|
||||
"windows": [
|
||||
{
|
||||
"timezone": "UTC",
|
||||
"start": "00:00",
|
||||
"end": "12:00",
|
||||
"candidates": ["target-a", "target-b"],
|
||||
"rule_id": "day-route",
|
||||
},
|
||||
{
|
||||
"timezone": "UTC",
|
||||
"start": "12:00",
|
||||
"end": "00:00",
|
||||
"candidates": ["target-b", "target-a"],
|
||||
"rule_id": "night-route",
|
||||
},
|
||||
]
|
||||
}
|
||||
return {"schema_version": "1.0", "targets": targets, "routes": routes}
|
||||
|
||||
|
||||
def write_catalog(root: Path, value: dict | None = None) -> Path:
|
||||
path = root / "catalog.json"
|
||||
path.write_text(json.dumps(value or catalog_value()), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
class ExecutionTargetPolicyTests(unittest.TestCase):
|
||||
def test_local_g07_route_uses_kst_boundaries(self):
|
||||
cases = [
|
||||
(at_utc(21, 59, 59), "pi", "iop/laguna-s:2.1", "kst-night-[23:00,07:00)"),
|
||||
(at_utc(22, 0, 0), "agy", "Gemini 3.6 Flash (Medium)", "kst-day-[07:00,23:00)"),
|
||||
(at_utc(13, 59, 59), "agy", "Gemini 3.6 Flash (Medium)", "kst-day-[07:00,23:00)"),
|
||||
(at_utc(14, 0, 0), "pi", "iop/laguna-s:2.1", "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"}):
|
||||
def test_catalog_is_runtime_loaded_and_route_is_complete(self):
|
||||
with TemporaryDirectory() as tmp:
|
||||
catalog = policy.load_catalog(write_catalog(Path(tmp)))
|
||||
decision = policy.select_policy(
|
||||
stage="worker", lane="local", grade=8, evaluated_at=night_time
|
||||
catalog=catalog,
|
||||
stage="worker",
|
||||
lane="cloud",
|
||||
grade=3,
|
||||
evaluated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
)
|
||||
self.assertEqual(decision.rule_id, "worker-local-g07-g08-kst-night")
|
||||
self.assertEqual(decision.candidates, (policy.PI_LAGUNA, policy.AGY_GEMINI_MEDIUM))
|
||||
self.assertEqual(decision.time_window, "kst-night-[23:00,07:00)")
|
||||
self.assertEqual(decision.candidates[0].target, "iop/laguna-s:2.1")
|
||||
self.assertEqual(decision.route_id, "cloud-G03")
|
||||
self.assertEqual([item.catalog_id for item in decision.candidates], ["target-a", "target-b"])
|
||||
self.assertEqual(decision.candidates[0].agent, "runner-a")
|
||||
self.assertEqual(decision.candidates[0].model, "model-a")
|
||||
self.assertEqual(decision.catalog_revision, catalog.revision)
|
||||
|
||||
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 (Medium)", False),
|
||||
8: ("agy", "Gemini 3.6 Flash (Medium)", False),
|
||||
9: ("claude", "claude-opus-4-8", False),
|
||||
10: ("claude", "claude-opus-4-8", False),
|
||||
},
|
||||
"cloud": {
|
||||
**{
|
||||
grade: ("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-4-8", False),
|
||||
8: ("claude", "claude-opus-4-8", False),
|
||||
9: ("codex", "gpt-5.6-sol", False),
|
||||
10: ("codex", "gpt-5.6-sol", False),
|
||||
},
|
||||
}
|
||||
for lane, grades in expected.items():
|
||||
for grade, route in grades.items():
|
||||
with self.subTest(lane=lane, grade=grade):
|
||||
selected = policy.select_policy(
|
||||
stage="worker",
|
||||
lane=lane,
|
||||
grade=grade,
|
||||
evaluated_at=daytime,
|
||||
).candidates[0]
|
||||
self.assertEqual(
|
||||
(
|
||||
selected.adapter,
|
||||
selected.target,
|
||||
selected.selfcheck_required,
|
||||
),
|
||||
route,
|
||||
)
|
||||
def test_common_policy_has_no_built_in_catalog(self):
|
||||
self.assertFalse(hasattr(policy, "CANONICAL_TARGETS"))
|
||||
self.assertFalse(hasattr(policy, "quota_probe_spec"))
|
||||
self.assertFalse(hasattr(policy, "promotion_target"))
|
||||
|
||||
def test_cloud_g01_g02_uses_ordered_spark_gemini_haiku_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.CODEX_SPARK_XHIGH,
|
||||
policy.AGY_GEMINI_LOW,
|
||||
policy.CLAUDE_HAIKU_XHIGH,
|
||||
),
|
||||
)
|
||||
self.assertEqual(
|
||||
decision.reason_codes,
|
||||
("cloud_spark_priority_grade",),
|
||||
)
|
||||
|
||||
def test_review_matrix_is_fixed_to_codex(self):
|
||||
for lane in ("local", "cloud"):
|
||||
for grade in range(1, 11):
|
||||
with self.subTest(lane=lane, grade=grade):
|
||||
decision = policy.select_policy(
|
||||
stage="review",
|
||||
lane=lane,
|
||||
grade=grade,
|
||||
evaluated_at=at_utc(3),
|
||||
)
|
||||
self.assertEqual(decision.rule_id, "official-review-codex")
|
||||
self.assertEqual(decision.candidates, (policy.CODEX_SOL_XHIGH,))
|
||||
|
||||
def test_local_g07_g08_candidate_order_uses_kst_boundaries(self):
|
||||
daytime = policy.select_policy(
|
||||
stage="worker",
|
||||
lane="local",
|
||||
grade=8,
|
||||
evaluated_at=at_utc(3),
|
||||
)
|
||||
nighttime = policy.select_policy(
|
||||
stage="worker",
|
||||
lane="local",
|
||||
grade=8,
|
||||
evaluated_at=at_utc(15),
|
||||
)
|
||||
self.assertEqual(
|
||||
[candidate.adapter for candidate in daytime.candidates],
|
||||
["agy", "pi"],
|
||||
)
|
||||
self.assertEqual(
|
||||
[candidate.adapter for candidate in nighttime.candidates],
|
||||
["pi", "agy"],
|
||||
)
|
||||
|
||||
def test_invalid_inputs_are_rejected(self):
|
||||
cases = [
|
||||
{"stage": "selfcheck", "lane": "local", "grade": 7},
|
||||
{"stage": "worker", "lane": "hybrid", "grade": 7},
|
||||
{"stage": "worker", "lane": "local", "grade": 0},
|
||||
{"stage": "worker", "lane": "local", "grade": 11},
|
||||
]
|
||||
for values in cases:
|
||||
with self.subTest(values=values):
|
||||
with self.assertRaises(ValueError):
|
||||
policy.select_policy(
|
||||
**values,
|
||||
evaluated_at=at_utc(3),
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError, "timezone-aware"):
|
||||
policy.select_policy(
|
||||
def test_optional_windows_are_catalog_owned_and_timezone_generic(self):
|
||||
with TemporaryDirectory() as tmp:
|
||||
catalog = policy.load_catalog(write_catalog(Path(tmp), catalog_value(windows=True)))
|
||||
morning = policy.select_policy(
|
||||
catalog=catalog,
|
||||
stage="worker",
|
||||
lane="local",
|
||||
grade=7,
|
||||
evaluated_at=datetime(2026, 7, 25, 12, 0, 0),
|
||||
evaluated_at=datetime(2026, 1, 1, 6, tzinfo=timezone.utc),
|
||||
)
|
||||
evening = policy.select_policy(
|
||||
catalog=catalog,
|
||||
stage="worker",
|
||||
lane="local",
|
||||
grade=7,
|
||||
evaluated_at=datetime(2026, 1, 1, 18, tzinfo=timezone.utc),
|
||||
)
|
||||
self.assertEqual(morning.candidates[0].catalog_id, "target-a")
|
||||
self.assertEqual(evening.candidates[0].catalog_id, "target-b")
|
||||
self.assertEqual(morning.rule_id, "day-route")
|
||||
self.assertEqual(evening.rule_id, "night-route")
|
||||
|
||||
def test_cloud_promotion_matrix(self):
|
||||
cases = [
|
||||
(policy.AGY_GEMINI_LOW, policy.CLAUDE_OPUS),
|
||||
(policy.AGY_GEMINI_MEDIUM, policy.CLAUDE_OPUS),
|
||||
(policy.AGY_GEMINI_HIGH, policy.CLAUDE_OPUS),
|
||||
(policy.CLAUDE_OPUS, policy.CODEX_TERRA_HIGH),
|
||||
(policy.CLAUDE_HAIKU_XHIGH, None),
|
||||
(policy.CODEX_SPARK_XHIGH, None),
|
||||
(policy.CODEX_SOL_XHIGH, None),
|
||||
(policy.CODEX_TERRA_HIGH, None),
|
||||
(policy.PI_ORNITH, None),
|
||||
(policy.PI_LAGUNA, None),
|
||||
def test_catalog_requires_every_stage_lane_grade_route(self):
|
||||
value = catalog_value()
|
||||
del value["routes"]["review"]["cloud-G10"]
|
||||
with TemporaryDirectory() as tmp:
|
||||
with self.assertRaisesRegex(policy.CatalogError, "cover local/cloud G01..G10 exactly"):
|
||||
policy.load_catalog(write_catalog(Path(tmp), value))
|
||||
|
||||
def test_unknown_target_and_unknown_template_field_are_rejected(self):
|
||||
unknown_target = catalog_value()
|
||||
unknown_target["routes"]["worker"]["local-G01"]["candidates"] = ["missing"]
|
||||
bad_template = catalog_value()
|
||||
bad_template["targets"]["target-a"]["runtime"]["command"] = ["runner", "{provider_secret}"]
|
||||
with TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
with self.assertRaisesRegex(policy.CatalogError, "unknown targets"):
|
||||
policy.load_catalog(write_catalog(root, unknown_target))
|
||||
with self.assertRaisesRegex(policy.CatalogError, "unsupported template field"):
|
||||
policy.load_catalog(write_catalog(root, bad_template))
|
||||
|
||||
def test_command_executable_must_be_literal_for_preflight(self):
|
||||
value = catalog_value()
|
||||
value["targets"]["target-a"]["runtime"]["command"] = [
|
||||
"{workspace}",
|
||||
"{prompt}",
|
||||
]
|
||||
for current, expected in cases:
|
||||
with self.subTest(current=current):
|
||||
self.assertEqual(policy.promotion_target(current), expected)
|
||||
with TemporaryDirectory() as tmp:
|
||||
with self.assertRaisesRegex(policy.CatalogError, "executable must be a literal"):
|
||||
policy.load_catalog(write_catalog(Path(tmp), value))
|
||||
|
||||
for target in policy.CANONICAL_TARGETS:
|
||||
with self.subTest(identity=target.target):
|
||||
self.assertEqual(
|
||||
policy.canonical_target(target.adapter, target.target),
|
||||
target,
|
||||
)
|
||||
self.assertIsNone(policy.canonical_target("codex", "unknown"))
|
||||
def test_catalog_revision_changes_with_content(self):
|
||||
with TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
path = write_catalog(root)
|
||||
first = policy.load_catalog(path)
|
||||
changed = catalog_value()
|
||||
changed["targets"]["target-a"]["model"] = "model-a-next"
|
||||
path.write_text(json.dumps(changed), encoding="utf-8")
|
||||
second = policy.load_catalog(path)
|
||||
self.assertNotEqual(first.revision, second.revision)
|
||||
|
||||
def test_quota_probe_spec_matrix(self):
|
||||
cases = [
|
||||
(policy.PI_ORNITH, None),
|
||||
(policy.PI_LAGUNA, None),
|
||||
(
|
||||
policy.AGY_GEMINI_LOW,
|
||||
policy.QuotaProbeSpec("agy", "Gemini 3.6 Flash (Low)", ("overall", "model:Gemini 3.6 Flash (Low)")),
|
||||
),
|
||||
(
|
||||
policy.AGY_GEMINI_MEDIUM,
|
||||
policy.QuotaProbeSpec("agy", "Gemini 3.6 Flash (Medium)", ("overall", "model:Gemini 3.6 Flash (Medium)")),
|
||||
),
|
||||
(
|
||||
policy.AGY_GEMINI_HIGH,
|
||||
policy.QuotaProbeSpec("agy", "Gemini 3.6 Flash (High)", ("overall", "model:Gemini 3.6 Flash (High)")),
|
||||
),
|
||||
(
|
||||
policy.CLAUDE_OPUS,
|
||||
policy.QuotaProbeSpec("claude", "claude-opus-4-8", ("overall",)),
|
||||
),
|
||||
(
|
||||
policy.CLAUDE_HAIKU_XHIGH,
|
||||
policy.QuotaProbeSpec("claude", "claude-haiku-4-5", ("overall",)),
|
||||
),
|
||||
(
|
||||
policy.CODEX_SPARK_XHIGH,
|
||||
policy.QuotaProbeSpec("codex", "gpt-5.3-codex-spark", ("overall",)),
|
||||
),
|
||||
(
|
||||
policy.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)
|
||||
def test_invalid_route_inputs_are_rejected(self):
|
||||
with TemporaryDirectory() as tmp:
|
||||
catalog = policy.load_catalog(write_catalog(Path(tmp)))
|
||||
for values in (
|
||||
{"stage": "selfcheck", "lane": "local", "grade": 1},
|
||||
{"stage": "worker", "lane": "hybrid", "grade": 1},
|
||||
{"stage": "worker", "lane": "local", "grade": 0},
|
||||
):
|
||||
with self.subTest(values=values), self.assertRaises(ValueError):
|
||||
policy.select_policy(
|
||||
catalog=catalog,
|
||||
evaluated_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||
**values,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -100,7 +100,7 @@ Task directory naming rules:
|
|||
- For predecessor index `PP`, the only valid archive lookup candidates are `agent-task/archive/*/*/{task_group}/PP_*/complete.log` and `agent-task/archive/*/*/{task_group}/PP+*/complete.log`.
|
||||
- Archive lookup matches the predecessor index at the start of the archived subtask directory name, such as `01_...` or `01+...`, under the same `{task_group}`. If multiple candidates match one predecessor index, do not choose by guess; record the ambiguity and require a concrete task path or runtime selection.
|
||||
- Do not treat an archived predecessor as the active task to edit. Archive lookup is only for dependency satisfaction before writing or implementing a dependent split plan.
|
||||
- Example: split a refactoring common core plus two app integrations under `agent-task/refactoring/` as `01_core`, `02+01_edge_integration`, `03+01_node_integration`. Both integrations depend only on `01_core` and may run in parallel after `01_core` has `complete.log`.
|
||||
- Example: split a refactoring common core plus two app integrations under `agent-task/refactoring/` as `01_core`, `02+01_app_a_integration`, `03+01_app_b_integration`. Both integrations depend only on `01_core` and may run in parallel after `01_core` has `complete.log`.
|
||||
- Example: split three sequential tasks under one task group as `01_schema`, `02+01_migration`, `03+02_api`.
|
||||
- Example: split independent docs/UI plus an integration under one task group as `01_core`, `02+01_db`, `03+02_api`, `04_docs`, `05_ui`, `06+05_integration`; `01_core`, `04_docs`, and `05_ui` can start together, and `06+05_integration` waits only for `05_ui`.
|
||||
- After a pair is written, preserve its task group and subtask directory name verbatim. Only an explicit `refine-plans` run may rename eligible unstarted siblings by its dependency-order rules.
|
||||
|
|
@ -204,10 +204,10 @@ Complete all items below before creating active plan/review files. Work through
|
|||
- [ ] **Resolve follow-up findings once** — in `prepare-follow-up`, map every inherited Required/Suggested id. Default repository-fixable work to `direct-fix` with exact root-cause files, overriding stale verification-only exclusions. Allow `verified-dependency` only when an exact active PLAN claims those files and task-protocol ordering applies, or when `complete.log` plus fresh evidence proves the failed precondition is satisfied; vague owners or `complete.log` alone are invalid. Set `ownership_closed=true` only after all mappings are proven. Reject unchanged-precondition verification loops. Reuse the existing analysis; add no model, sub-agent, or routing-only pass.
|
||||
- [ ] **Resolve split predecessor completion** — if the selected or proposed subtask directory has `NN+PP[,QQ...]_...`, resolve each predecessor index under the same task group. Check only the active and archive candidate patterns defined in the task directory naming rules. Record found active/archive paths, missing predecessors, or ambiguous matches in `Analysis > Split Judgment` (legacy: `분석 결과 > 분할 판단`) and, when order matters, `Dependencies and Execution Order` (legacy: `의존 관계 및 구현 순서`).
|
||||
- [ ] **Grep all symbol references** — for any renamed or removed symbol, find every call site and import chain.
|
||||
- [ ] **Check dependency manifests** — before adding any new package, verify its presence in go.mod / package manifest.
|
||||
- [ ] **Check dependency manifests** — before adding any dependency, inspect the repository's relevant dependency manifest and lockfile, confirm whether it already exists, and follow the repository-native version and update policy.
|
||||
- [ ] **Pre-check compile issues** — identify missing interface implementations, type mismatches, and broken imports.
|
||||
- [ ] **Verify verification commands** — confirm that the final verification commands actually run in this repository layout.
|
||||
- [ ] **Stabilize fragile verification** — for search or generated-output checks, choose deterministic commands up front, such as `rg --sort path`, and decide whether cached test output is acceptable or `-count=1` is required.
|
||||
- [ ] **Stabilize fragile verification** — for search or generated-output checks, choose deterministic commands up front, such as `rg --sort path`, and decide whether cached test output is acceptable or the repository's test runner must use its fresh-run or cache-bypass option.
|
||||
- [ ] **Derive routing signals once** — treat each completed in-memory PLAN as the worker packet. From facts already collected, record `large_indivisible_context`, positive matched loop-risk names/count, and recovery signals. Do not reread files, prove unmatched signatures false, or aggregate parent/sibling risk for routing.
|
||||
|
||||
## Step 3 - Finalize Task Routing
|
||||
|
|
@ -248,7 +248,7 @@ Use the second form for every `m-*` task and the first form for every non-milest
|
|||
Example:
|
||||
|
||||
```markdown
|
||||
<!-- task=m-principal-provider-credential-slot-routing/07+01,02,05_secret_material plan=0 tag=API milestone-task=secret-at-rest -->
|
||||
<!-- task=m-sample-capability/03+01,02_storage plan=0 tag=API milestone-task=sample-item -->
|
||||
```
|
||||
|
||||
Required sections:
|
||||
|
|
@ -321,7 +321,7 @@ Verification fidelity rules:
|
|||
- `Verification Results` (legacy: `검증 결과`) must contain actual stdout/stderr, not summarized or reconstructed output. If output is too long, record the saved output file path and the exact command used to create it.
|
||||
- If mobile/UI verification has no progress for 2 minutes or times out, stop blind retries; collect focused stdout plus screenshot/window/UI-tree evidence when available, or record why capture is impossible.
|
||||
- If the plan's pass condition says all leftovers must be intentional exceptions, any `변경 필요` item forces FAIL until resolved or explicitly reclassified with evidence.
|
||||
- Decide in the plan whether Go test cache output is acceptable. If fresh execution matters, use `go test -count=1 ...`.
|
||||
- Decide in the plan whether cached test output is acceptable. If fresh execution matters, use the repository's test runner option that forces a fresh run or bypasses cached results, and record the exact command.
|
||||
|
||||
## Step 6 - Write Review Stub
|
||||
|
||||
|
|
@ -351,7 +351,7 @@ Do not write or return a prepared pair when either routing target is not `routed
|
|||
- The plan skill directly checked the rendered PLAN before the pair was written or returned. Its single non-empty `Modified Files Summary` contains only exact workspace file claims and no glob or directory claim.
|
||||
- In `write` mode, `.gitignore` has the Agent-Ops managed block that unignores `agent-task/**/*.md` and `agent-task/**/*.log` and ignores local `agent-roadmap/current.md`. In `prepare-follow-up` mode, the block was only inspected and any needed repair was returned as `gitignore_repair_needed`.
|
||||
- Single-plan work stores active files directly under `agent-task/{task_group}/`.
|
||||
- Split work, if any, uses one shared `agent-task/{task_group}/` parent and one subtask directory per plan/review pair with names like `01_core`, `02+01_edge_integration`, `03+01_node_integration`; dependency details live in the subtask directory name as `NN+PP[,QQ...]_subtask_name`.
|
||||
- Split work, if any, uses one shared `agent-task/{task_group}/` parent and one subtask directory per plan/review pair with names like `01_core`, `02+01_app_a_integration`, `03+01_app_b_integration`; dependency details live in the subtask directory name as `NN+PP[,QQ...]_subtask_name`.
|
||||
- Split sibling indices follow topological dependency order: every predecessor is lower than its consumer, and every gap is explained by an unchanged existing predecessor or an occupied active/archive index.
|
||||
- Milestone-linked work uses `agent-task/m-<milestone-slug>/` as the task group; non-roadmap task groups do not start with `m-`.
|
||||
- Both first lines are identical. Non-milestone pairs match `<!-- task={task_name} plan={plan_number} tag={TAG} -->`; `m-*` pairs append exactly ` milestone-task=<task-id>[,<task-id>...]` before ` -->`.
|
||||
|
|
|
|||
|
|
@ -14,11 +14,9 @@ description: 현재 또는 지정 Milestone의 정확히 한 Epic을 작은 직
|
|||
- `workspace`: 준비된 feature worktree 절대 경로 (필수)
|
||||
- `target-milestone`: 활성 Milestone slug 또는 경로 (필수)
|
||||
- `target-epic`: 정확한 Epic id 또는 이름 (필수)
|
||||
- `planner-agent`: `codex`, `claude`, `gemini`, `pi` 중 하나 (생략 시 `codex`)
|
||||
- `review-agent`: 생략하면 `planner-agent`와 같다. (선택)
|
||||
- `planner-model`, `review-model`: provider별 model override. Codex 기본 사용 시 `planner-model` 생략 시 `gpt-5.6-sol`, 다른 provider는 해당 CLI 기본 모델을 사용한다. (선택)
|
||||
- `reasoning-effort`: 지원하는 provider의 reasoning/thinking override. 생략 시 `xhigh` (선택)
|
||||
- `pi-provider`: Pi provider override (선택)
|
||||
- `execution-catalog`: 런타임이 주입한 agent-model 실행 카탈로그 경로. `AGENT_TASK_EXECUTION_CATALOG`로 대신 주입할 수 있다. (필수)
|
||||
- `planner-target`: 카탈로그에 선언된 materialize/refine 실행 target id. `AGENT_TASK_PLANNER_TARGET`로 대신 주입할 수 있다. (필수)
|
||||
- `review-target`: 카탈로그에 선언된 initial/final review target id. `AGENT_TASK_REVIEW_TARGET`로 주입하거나 생략하면 `planner-target`과 같다. (선택)
|
||||
- `retry`: terminal failure의 원인을 사용자가 해소한 뒤 같은 Epic 상태를 재개할 때만 사용한다. (선택)
|
||||
- `batch-task-ids`: 상위 `prepare-milestone-workspace`가 고정한 선택 Epic Task id 합집합. 직접 호출에서는 사용하지 않는다. (내부 선택)
|
||||
|
||||
|
|
@ -55,13 +53,16 @@ description: 현재 또는 지정 Milestone의 정확히 한 Epic을 작은 직
|
|||
python3 agent-ops/skills/common/prepare-epic-work-items/scripts/run_epic_cycle.py \
|
||||
--workspace "$WORKSPACE" \
|
||||
--milestone "$MILESTONE" \
|
||||
--epic "$EPIC"
|
||||
--epic "$EPIC" \
|
||||
--execution-catalog "$EXECUTION_CATALOG" \
|
||||
--planner-target "$PLANNER_TARGET" \
|
||||
--review-target "$REVIEW_TARGET"
|
||||
```
|
||||
|
||||
- 기본값은 `codex / gpt-5.6-sol / xhigh`다. 다른 provider를 지정하면 모델을 별도로 주지 않는 한 해당 provider의 CLI 기본 모델을 사용한다.
|
||||
- 다른 agent, model, reasoning, Pi provider override와 `--retry`는 해당 입력이 있을 때만 전달한다.
|
||||
- agent, model, 실행 명령과 provider별 옵션은 스킬이나 스크립트에 고정하지 않고 카탈로그 target의 opaque metadata와 argv template에서 가져온다.
|
||||
- target id와 카탈로그 revision은 실행 evidence에 보존한다. `--retry`는 동일 카탈로그 계약과 target을 사용한다.
|
||||
- 상위 batch에서 호출할 때만 고정된 Task id 합집합을 `--batch-task-ids`로 전달한다.
|
||||
- 스크립트는 각 agent를 새 one-shot session으로 실행한다. Codex, Claude, Gemini(`agy` adapter), Pi를 같은 normalized runner 계약으로 지원한다.
|
||||
- 스크립트는 카탈로그가 지시한 각 target을 새 one-shot session으로 실행한다.
|
||||
- model stdout/stderr는 git common dir의 locator log에만 저장한다. caller stdout에는 lifecycle/attention event만 출력한다.
|
||||
|
||||
3. **상태 전이를 따른다**
|
||||
|
|
|
|||
190
agent-ops/skills/common/prepare-epic-work-items/scripts/run_agent_once.py
Executable file → Normal file
190
agent-ops/skills/common/prepare-epic-work-items/scripts/run_agent_once.py
Executable file → Normal file
|
|
@ -1,25 +1,24 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Run one fresh Codex, Claude, Gemini/agy, or Pi agent without polling."""
|
||||
"""Run one fresh target from a runtime-injected execution catalog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Iterable
|
||||
import uuid
|
||||
|
||||
|
||||
AGENT_COMMAND = {"codex": "codex", "claude": "claude", "gemini": "agy", "pi": "pi"}
|
||||
DEFAULT_AGENT = "codex"
|
||||
DEFAULT_MODEL = "gpt-5.6-sol"
|
||||
DEFAULT_REASONING_EFFORT = "xhigh"
|
||||
CATALOG_ENV = "AGENT_TASK_EXECUTION_CATALOG"
|
||||
LABEL_PATTERN = re.compile(r"^[A-Za-z0-9._-]+$")
|
||||
PROBE_EXPECTED = "MILESTONE_AGENT_READY"
|
||||
|
||||
|
|
@ -28,6 +27,22 @@ class AgentRunError(RuntimeError):
|
|||
"""One-shot runner contract error."""
|
||||
|
||||
|
||||
def load_policy_module():
|
||||
path = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "orchestrate-agent-task-loop"
|
||||
/ "scripts"
|
||||
/ "execution_target_policy.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("epic_execution_target_policy", path)
|
||||
if spec is None or spec.loader is None:
|
||||
raise AgentRunError(f"execution catalog policy not found: {path}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
|
@ -44,7 +59,6 @@ def atomic_json(path: Path, value: dict[str, Any]) -> None:
|
|||
|
||||
|
||||
def process_start_token(pid: int) -> str | None:
|
||||
"""Return a best-effort token that distinguishes PID reuse."""
|
||||
stat = Path(f"/proc/{pid}/stat")
|
||||
try:
|
||||
remainder = stat.read_text(encoding="utf-8").rsplit(")", 1)[1].split()
|
||||
|
|
@ -123,76 +137,40 @@ def prompt_text(args: argparse.Namespace) -> str:
|
|||
return path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def build_command(
|
||||
*,
|
||||
agent: str,
|
||||
prompt: str,
|
||||
workspace: Path,
|
||||
model: str | None,
|
||||
reasoning_effort: str | None,
|
||||
pi_provider: str | None,
|
||||
session_id: str,
|
||||
attempt_dir: Path,
|
||||
probe: bool = False,
|
||||
) -> list[str]:
|
||||
if agent == "codex":
|
||||
command = ["codex", "exec", "--json", "-C", str(workspace)]
|
||||
if model:
|
||||
command.extend(["-m", model])
|
||||
if reasoning_effort:
|
||||
command.extend(["-c", f'model_reasoning_effort="{reasoning_effort}"'])
|
||||
if not probe:
|
||||
command.append("--dangerously-bypass-approvals-and-sandbox")
|
||||
command.append(prompt)
|
||||
return command
|
||||
if agent == "claude":
|
||||
command = [
|
||||
"claude",
|
||||
"-p",
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
"--session-id",
|
||||
session_id,
|
||||
]
|
||||
if model:
|
||||
command.extend(["--model", model])
|
||||
if reasoning_effort:
|
||||
command.extend(["--effort", reasoning_effort])
|
||||
if not probe:
|
||||
command.append("--dangerously-skip-permissions")
|
||||
command.append(prompt)
|
||||
return command
|
||||
if agent == "gemini":
|
||||
command = ["agy", "--print", prompt, "--print-timeout", "8h"]
|
||||
if model:
|
||||
command.extend(["--model", model])
|
||||
if not probe:
|
||||
command.append("--dangerously-skip-permissions")
|
||||
command.extend(["--log-file", str(attempt_dir / "agy-cli.log")])
|
||||
return command
|
||||
if agent == "pi":
|
||||
command = [
|
||||
"pi",
|
||||
"-p",
|
||||
"--mode",
|
||||
"json",
|
||||
"--session-id",
|
||||
session_id,
|
||||
"--session-dir",
|
||||
str(attempt_dir / "pi-sessions"),
|
||||
]
|
||||
if not probe:
|
||||
command.append("--approve")
|
||||
if pi_provider:
|
||||
command.extend(["--provider", pi_provider])
|
||||
if model:
|
||||
command.extend(["--model", model])
|
||||
if reasoning_effort:
|
||||
command.extend(["--thinking", reasoning_effort])
|
||||
command.append(prompt)
|
||||
return command
|
||||
raise AgentRunError(f"unsupported agent: {agent}")
|
||||
def resolve_target(catalog_path: str, target_id: str):
|
||||
policy = load_policy_module()
|
||||
try:
|
||||
catalog = policy.load_catalog(catalog_path)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise AgentRunError(f"invalid execution catalog: {exc}") from exc
|
||||
target = policy.canonical_target(catalog, target_id)
|
||||
if target is None:
|
||||
raise AgentRunError(f"execution catalog target not found: {target_id}")
|
||||
return catalog, target
|
||||
|
||||
|
||||
def template_values(*, target, prompt: str, workspace: Path, session_id: str, attempt_dir: Path) -> dict[str, str]:
|
||||
return {
|
||||
"agent": target.agent,
|
||||
"attempt_dir": str(attempt_dir),
|
||||
"model": target.model,
|
||||
"prompt": prompt,
|
||||
"resume_session": "",
|
||||
"session_id": session_id,
|
||||
"target_id": target.catalog_id,
|
||||
"workspace": str(workspace),
|
||||
}
|
||||
|
||||
|
||||
def build_command(*, target, prompt: str, workspace: Path, session_id: str, attempt_dir: Path) -> list[str]:
|
||||
values = template_values(
|
||||
target=target,
|
||||
prompt=prompt,
|
||||
workspace=workspace,
|
||||
session_id=session_id,
|
||||
attempt_dir=attempt_dir,
|
||||
)
|
||||
return [str(item).format_map(values) for item in target.runtime["command"]]
|
||||
|
||||
|
||||
def sanitized_command(command: list[str], prompt: str) -> list[str]:
|
||||
|
|
@ -201,14 +179,12 @@ def sanitized_command(command: list[str], prompt: str) -> list[str]:
|
|||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
value = argparse.ArgumentParser(description=__doc__)
|
||||
value.add_argument("--agent", choices=sorted(AGENT_COMMAND), default=DEFAULT_AGENT)
|
||||
value.add_argument("--execution-catalog", default=os.environ.get(CATALOG_ENV))
|
||||
value.add_argument("--target-id", required=True)
|
||||
value.add_argument("--workspace", required=True)
|
||||
prompt_group = value.add_mutually_exclusive_group()
|
||||
prompt_group.add_argument("--prompt")
|
||||
prompt_group.add_argument("--prompt-file")
|
||||
value.add_argument("--model")
|
||||
value.add_argument("--reasoning-effort", default=DEFAULT_REASONING_EFFORT)
|
||||
value.add_argument("--pi-provider")
|
||||
value.add_argument("--label", default="one-shot")
|
||||
value.add_argument("--probe", action="store_true")
|
||||
value.add_argument("--result-file")
|
||||
|
|
@ -217,16 +193,20 @@ def parser() -> argparse.ArgumentParser:
|
|||
|
||||
def execute(args: argparse.Namespace) -> int:
|
||||
workspace = workspace_root(args.workspace)
|
||||
if args.model is None and args.agent == DEFAULT_AGENT:
|
||||
args.model = DEFAULT_MODEL
|
||||
if not args.execution_catalog:
|
||||
raise AgentRunError(
|
||||
f"--execution-catalog or {CATALOG_ENV} is required"
|
||||
)
|
||||
catalog, target = resolve_target(args.execution_catalog, args.target_id)
|
||||
if not LABEL_PATTERN.fullmatch(args.label):
|
||||
raise AgentRunError("--label may contain only letters, digits, dot, underscore, and hyphen")
|
||||
prompt = prompt_text(args)
|
||||
result = result_file(workspace, args.result_file)
|
||||
executable = AGENT_COMMAND[args.agent]
|
||||
resolved = shutil.which(executable)
|
||||
if resolved is None:
|
||||
raise AgentRunError(f"agent command not found: agent={args.agent} command={executable}")
|
||||
executable = target.runtime["command"][0]
|
||||
if shutil.which(executable) is None:
|
||||
raise AgentRunError(
|
||||
f"target command not found: target_id={target.catalog_id} command={executable}"
|
||||
)
|
||||
|
||||
execution_id = f"{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:12]}"
|
||||
root = state_root(workspace)
|
||||
|
|
@ -235,25 +215,37 @@ def execute(args: argparse.Namespace) -> int:
|
|||
stream = attempt_dir / "stream.log"
|
||||
locator = attempt_dir / "locator.json"
|
||||
session_id = str(uuid.uuid4())
|
||||
command = build_command(
|
||||
agent=args.agent,
|
||||
values = template_values(
|
||||
target=target,
|
||||
prompt=prompt,
|
||||
workspace=workspace,
|
||||
model=args.model,
|
||||
reasoning_effort=args.reasoning_effort,
|
||||
pi_provider=args.pi_provider,
|
||||
session_id=session_id,
|
||||
attempt_dir=attempt_dir,
|
||||
probe=args.probe,
|
||||
)
|
||||
command = build_command(
|
||||
target=target,
|
||||
prompt=prompt,
|
||||
workspace=workspace,
|
||||
session_id=session_id,
|
||||
attempt_dir=attempt_dir,
|
||||
)
|
||||
environment = {
|
||||
str(key): str(item).format_map(values)
|
||||
for key, item in target.runtime.get("environment", {}).items()
|
||||
}
|
||||
record: dict[str, Any] = {
|
||||
"execution_id": execution_id,
|
||||
"label": args.label,
|
||||
"workspace": str(workspace),
|
||||
"agent": args.agent,
|
||||
"catalog": {
|
||||
"source": str(catalog.source),
|
||||
"revision": catalog.revision,
|
||||
"schema_version": "1.0",
|
||||
},
|
||||
"target_id": target.catalog_id,
|
||||
"agent": target.agent,
|
||||
"model": target.model,
|
||||
"command": sanitized_command(command, prompt),
|
||||
"model": args.model,
|
||||
"reasoning_effort": args.reasoning_effort,
|
||||
"prompt_sha256": hashlib.sha256(prompt.encode()).hexdigest(),
|
||||
"session_id": session_id,
|
||||
"stream_log": str(stream),
|
||||
|
|
@ -264,11 +256,12 @@ def execute(args: argparse.Namespace) -> int:
|
|||
persist(locator, result, record)
|
||||
emit(
|
||||
"AGENT_STARTED",
|
||||
agent=args.agent,
|
||||
agent=target.agent,
|
||||
execution_id=execution_id,
|
||||
label=args.label,
|
||||
locator=str(locator),
|
||||
model=args.model or "default",
|
||||
model=target.model,
|
||||
target_id=target.catalog_id,
|
||||
)
|
||||
with stream.open("wb") as output:
|
||||
try:
|
||||
|
|
@ -278,6 +271,7 @@ def execute(args: argparse.Namespace) -> int:
|
|||
env={
|
||||
**os.environ,
|
||||
"MILESTONE_PREPARATION_EXECUTION_ID": execution_id,
|
||||
**environment,
|
||||
},
|
||||
stdout=output,
|
||||
stderr=subprocess.STDOUT,
|
||||
|
|
@ -332,7 +326,7 @@ def main(argv: Iterable[str] | None = None) -> int:
|
|||
args = parser().parse_args(argv)
|
||||
try:
|
||||
return execute(args)
|
||||
except (AgentRunError, OSError) as exc:
|
||||
except (AgentRunError, OSError, ValueError) as exc:
|
||||
emit("AGENT_FINISHED", label=getattr(args, "label", "one-shot"), result="failed", reason=str(exc))
|
||||
return 2
|
||||
|
||||
|
|
|
|||
|
|
@ -16,9 +16,6 @@ from typing import Any, Iterable
|
|||
|
||||
|
||||
STAGES = ("materialize", "initial-review", "refine", "final-review")
|
||||
DEFAULT_PLANNER_AGENT = "codex"
|
||||
DEFAULT_PLANNER_MODEL = "gpt-5.6-sol"
|
||||
DEFAULT_REASONING_EFFORT = "xhigh"
|
||||
PLAN_PATTERN = "PLAN-*-G??.md"
|
||||
REVIEW_PATTERN = "CODE_REVIEW-*-G??.md"
|
||||
HEADER = re.compile(r"^<!--\s+(?P<body>.*?)\s+-->$")
|
||||
|
|
@ -325,20 +322,9 @@ def validate_pairs(
|
|||
raise CycleError("target Epic Task ids must be inside the selected batch")
|
||||
pairs: list[tuple[Path, Path, dict[str, str]]] = []
|
||||
union: set[str] = set()
|
||||
project_dispatcher = (
|
||||
workspace / "agent-ops" / "skills" / "project" / "orchestrate-agent-task-loop"
|
||||
dispatcher_root = (
|
||||
workspace / "agent-ops" / "skills" / "common" / "orchestrate-agent-task-loop"
|
||||
)
|
||||
private_dispatcher = (
|
||||
workspace / "agent-ops" / "skills" / "private" / "orchestrate-agent-task-loop"
|
||||
)
|
||||
if project_dispatcher.is_dir() and private_dispatcher.is_dir():
|
||||
dispatcher_root = private_dispatcher
|
||||
elif project_dispatcher.is_dir():
|
||||
dispatcher_root = project_dispatcher
|
||||
else:
|
||||
dispatcher_root = (
|
||||
workspace / "agent-ops" / "skills" / "common" / "orchestrate-agent-task-loop"
|
||||
)
|
||||
dispatcher = dispatcher_root / "scripts" / "dispatch.py"
|
||||
for plan, review, header in all_pairs:
|
||||
ids = header["milestone-task"].split(",")
|
||||
|
|
@ -446,10 +432,8 @@ def run_agent_stage(
|
|||
identity: str,
|
||||
stage: str,
|
||||
prompt: str,
|
||||
agent: str,
|
||||
model: str | None,
|
||||
reasoning_effort: str | None,
|
||||
pi_provider: str | None,
|
||||
execution_catalog: str,
|
||||
target_id: str,
|
||||
prior_cycle_status: str,
|
||||
retry: bool,
|
||||
) -> Path:
|
||||
|
|
@ -497,8 +481,10 @@ def run_agent_stage(
|
|||
command = [
|
||||
sys.executable,
|
||||
str(runner),
|
||||
"--agent",
|
||||
agent,
|
||||
"--execution-catalog",
|
||||
execution_catalog,
|
||||
"--target-id",
|
||||
target_id,
|
||||
"--workspace",
|
||||
str(workspace),
|
||||
"--prompt-file",
|
||||
|
|
@ -508,12 +494,6 @@ def run_agent_stage(
|
|||
"--result-file",
|
||||
str(result_path),
|
||||
]
|
||||
if model:
|
||||
command.extend(["--model", model])
|
||||
if reasoning_effort:
|
||||
command.extend(["--reasoning-effort", reasoning_effort])
|
||||
if pi_provider:
|
||||
command.extend(["--pi-provider", pi_provider])
|
||||
result = run(command, cwd=workspace, check=False, capture=False)
|
||||
if result.returncode != 0:
|
||||
if result.returncode == 3:
|
||||
|
|
@ -561,15 +541,15 @@ def parser() -> argparse.ArgumentParser:
|
|||
value.add_argument("--milestone", required=True)
|
||||
value.add_argument("--epic", required=True)
|
||||
value.add_argument(
|
||||
"--planner-agent",
|
||||
choices=("codex", "claude", "gemini", "pi"),
|
||||
default=DEFAULT_PLANNER_AGENT,
|
||||
"--execution-catalog",
|
||||
default=os.environ.get("AGENT_TASK_EXECUTION_CATALOG"),
|
||||
)
|
||||
value.add_argument(
|
||||
"--planner-target", default=os.environ.get("AGENT_TASK_PLANNER_TARGET")
|
||||
)
|
||||
value.add_argument(
|
||||
"--review-target", default=os.environ.get("AGENT_TASK_REVIEW_TARGET")
|
||||
)
|
||||
value.add_argument("--review-agent", choices=("codex", "claude", "gemini", "pi"))
|
||||
value.add_argument("--planner-model")
|
||||
value.add_argument("--review-model")
|
||||
value.add_argument("--reasoning-effort", default=DEFAULT_REASONING_EFFORT)
|
||||
value.add_argument("--pi-provider")
|
||||
value.add_argument(
|
||||
"--batch-task-ids",
|
||||
help="internal selected-Epic Task id union; permits earlier Epic pairs in the same batch",
|
||||
|
|
@ -584,10 +564,16 @@ def parser() -> argparse.ArgumentParser:
|
|||
|
||||
|
||||
def apply_defaults(args: argparse.Namespace) -> argparse.Namespace:
|
||||
if args.planner_model is None and args.planner_agent == DEFAULT_PLANNER_AGENT:
|
||||
args.planner_model = DEFAULT_PLANNER_MODEL
|
||||
if args.reasoning_effort is None:
|
||||
args.reasoning_effort = DEFAULT_REASONING_EFFORT
|
||||
if not args.execution_catalog:
|
||||
raise CycleError(
|
||||
"--execution-catalog or AGENT_TASK_EXECUTION_CATALOG is required"
|
||||
)
|
||||
if not args.planner_target:
|
||||
raise CycleError(
|
||||
"--planner-target or AGENT_TASK_PLANNER_TARGET is required"
|
||||
)
|
||||
if args.review_target is None:
|
||||
args.review_target = args.planner_target
|
||||
return args
|
||||
|
||||
|
||||
|
|
@ -758,17 +744,17 @@ def cycle(args: argparse.Namespace) -> int:
|
|||
elif changed_paths(workspace) and not args.retry:
|
||||
raise CycleError("dirty recovery state requires explicit --retry")
|
||||
|
||||
reviewer_agent = args.review_agent or args.planner_agent
|
||||
reviewer_model = args.review_model or (
|
||||
args.planner_model if reviewer_agent == args.planner_agent else None
|
||||
)
|
||||
reviewer_target = args.review_target or args.planner_target
|
||||
start_index = STAGES.index(str(state.get("next_stage", STAGES[0])))
|
||||
for stage in STAGES[start_index:]:
|
||||
stage_head = git(workspace, "rev-parse", "HEAD")
|
||||
event_prefix = stage.upper().replace("-", "_")
|
||||
emit(f"{event_prefix}_STARTED", identity=identity)
|
||||
agent = args.planner_agent if stage in {"materialize", "refine"} else reviewer_agent
|
||||
model = args.planner_model if stage in {"materialize", "refine"} else reviewer_model
|
||||
target_id = (
|
||||
args.planner_target
|
||||
if stage in {"materialize", "refine"}
|
||||
else reviewer_target
|
||||
)
|
||||
prompt = stage_prompt(
|
||||
stage=stage,
|
||||
workspace=workspace,
|
||||
|
|
@ -792,10 +778,8 @@ def cycle(args: argparse.Namespace) -> int:
|
|||
identity=identity.replace(":", "-"),
|
||||
stage=stage,
|
||||
prompt=prompt,
|
||||
agent=agent,
|
||||
model=model,
|
||||
reasoning_effort=args.reasoning_effort,
|
||||
pi_provider=args.pi_provider,
|
||||
execution_catalog=args.execution_catalog,
|
||||
target_id=target_id,
|
||||
prior_cycle_status=prior_cycle_status,
|
||||
retry=args.retry,
|
||||
)
|
||||
|
|
@ -907,10 +891,11 @@ def cycle(args: argparse.Namespace) -> int:
|
|||
|
||||
|
||||
def main(argv: Iterable[str] | None = None) -> int:
|
||||
args = apply_defaults(parser().parse_args(argv))
|
||||
args = parser().parse_args(argv)
|
||||
state_path: Path | None = None
|
||||
identity = "unknown"
|
||||
try:
|
||||
apply_defaults(args)
|
||||
return cycle(args)
|
||||
except (CycleError, OSError, ValueError) as exc:
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ from pathlib import Path
|
|||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
|
||||
|
|
@ -18,63 +19,63 @@ SPEC.loader.exec_module(MODULE)
|
|||
REPOSITORY = Path(__file__).resolve().parents[5]
|
||||
|
||||
|
||||
class AgentCommandTest(unittest.TestCase):
|
||||
def build(self, agent: str) -> list[str]:
|
||||
def catalog_value(executable: str) -> dict:
|
||||
target = {
|
||||
"agent": "runtime-agent",
|
||||
"model": "runtime-model",
|
||||
"execution_class": "cloud_model",
|
||||
"selfcheck_required": False,
|
||||
"runtime": {
|
||||
"command": [executable, "{prompt}", "{target_id}"],
|
||||
"environment": {"RUN_TARGET": "{target_id}"},
|
||||
},
|
||||
}
|
||||
routes = {"worker": {}, "review": {}}
|
||||
for stage in routes:
|
||||
for lane in ("local", "cloud"):
|
||||
for grade in range(1, 11):
|
||||
routes[stage][f"{lane}-G{grade:02d}"] = {
|
||||
"candidates": ["primary"]
|
||||
}
|
||||
return {"schema_version": "1.0", "targets": {"primary": target}, "routes": routes}
|
||||
|
||||
|
||||
class ExecutionCatalogRunnerTest(unittest.TestCase):
|
||||
def test_build_command_only_expands_injected_template(self) -> None:
|
||||
target = SimpleNamespace(
|
||||
agent="runtime-agent",
|
||||
model="runtime-model",
|
||||
catalog_id="target-a",
|
||||
runtime={"command": ["runner", "--id", "{target_id}", "{prompt}"]},
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
path = Path(raw)
|
||||
return MODULE.build_command(
|
||||
agent=agent,
|
||||
command = MODULE.build_command(
|
||||
target=target,
|
||||
prompt="prompt",
|
||||
workspace=path,
|
||||
model="model-name",
|
||||
reasoning_effort="high",
|
||||
pi_provider="provider-name",
|
||||
session_id="session-id",
|
||||
attempt_dir=path,
|
||||
)
|
||||
self.assertEqual(command, ["runner", "--id", "target-a", "prompt"])
|
||||
|
||||
def test_codex_contract(self) -> None:
|
||||
command = self.build("codex")
|
||||
self.assertEqual(command[:3], ["codex", "exec", "--json"])
|
||||
self.assertIn("--dangerously-bypass-approvals-and-sandbox", command)
|
||||
|
||||
def test_claude_contract(self) -> None:
|
||||
command = self.build("claude")
|
||||
self.assertEqual(command[0], "claude")
|
||||
self.assertIn("--output-format", command)
|
||||
self.assertIn("--session-id", command)
|
||||
|
||||
def test_gemini_maps_to_agy(self) -> None:
|
||||
command = self.build("gemini")
|
||||
self.assertEqual(command[0], "agy")
|
||||
self.assertEqual(command[1:3], ["--print", "prompt"])
|
||||
|
||||
def test_pi_contract(self) -> None:
|
||||
command = self.build("pi")
|
||||
self.assertEqual(command[0], "pi")
|
||||
self.assertIn("--mode", command)
|
||||
self.assertIn("--session-id", command)
|
||||
|
||||
def test_probe_commands_do_not_enable_mutating_permission_bypass(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
path = Path(raw)
|
||||
for agent in MODULE.AGENT_COMMAND:
|
||||
command = MODULE.build_command(
|
||||
agent=agent,
|
||||
prompt="READY",
|
||||
workspace=path,
|
||||
model=None,
|
||||
reasoning_effort=None,
|
||||
pi_provider=None,
|
||||
session_id="session-id",
|
||||
attempt_dir=path,
|
||||
probe=True,
|
||||
def test_missing_catalog_is_rejected(self) -> None:
|
||||
with tempfile.TemporaryDirectory(dir=REPOSITORY) as raw:
|
||||
workspace = Path(raw) / "workspace"
|
||||
workspace.mkdir()
|
||||
subprocess.run(
|
||||
["git", "init", "-b", "main", str(workspace)],
|
||||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
with mock.patch.dict(os.environ, {}, clear=False):
|
||||
os.environ.pop(MODULE.CATALOG_ENV, None)
|
||||
result = MODULE.main(
|
||||
["--target-id", "primary", "--workspace", str(workspace), "--probe"]
|
||||
)
|
||||
self.assertNotIn("--dangerously-bypass-approvals-and-sandbox", command)
|
||||
self.assertNotIn("--dangerously-skip-permissions", command)
|
||||
self.assertNotIn("--approve", command)
|
||||
self.assertEqual(result, 2)
|
||||
|
||||
def test_probe_executes_selected_command_once(self) -> None:
|
||||
def test_probe_executes_catalog_target_once_and_records_revision(self) -> None:
|
||||
with tempfile.TemporaryDirectory(dir=REPOSITORY) as raw:
|
||||
root = Path(raw)
|
||||
workspace = root / "workspace"
|
||||
|
|
@ -86,12 +87,14 @@ class AgentCommandTest(unittest.TestCase):
|
|||
check=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
)
|
||||
fake = binary / "codex"
|
||||
fake = binary / "runtime-runner"
|
||||
fake.write_text(
|
||||
"#!/bin/sh\nprintf '%s\\n' MILESTONE_AGENT_READY\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
fake.chmod(0o755)
|
||||
catalog = root / "catalog.json"
|
||||
catalog.write_text(json.dumps(catalog_value("runtime-runner")), encoding="utf-8")
|
||||
result_file = (
|
||||
workspace
|
||||
/ ".git"
|
||||
|
|
@ -102,8 +105,10 @@ class AgentCommandTest(unittest.TestCase):
|
|||
with mock.patch.dict(os.environ, {"PATH": f"{binary}:{os.environ['PATH']}"}):
|
||||
result = MODULE.main(
|
||||
[
|
||||
"--agent",
|
||||
"codex",
|
||||
"--execution-catalog",
|
||||
str(catalog),
|
||||
"--target-id",
|
||||
"primary",
|
||||
"--workspace",
|
||||
str(workspace),
|
||||
"--probe",
|
||||
|
|
@ -114,10 +119,15 @@ class AgentCommandTest(unittest.TestCase):
|
|||
self.assertEqual(result, 0)
|
||||
recorded = json.loads(result_file.read_text(encoding="utf-8"))
|
||||
self.assertEqual(recorded["status"], "succeeded")
|
||||
self.assertEqual(recorded["model"], "gpt-5.6-sol")
|
||||
self.assertEqual(recorded["reasoning_effort"], "xhigh")
|
||||
self.assertIn("agent_process_start_token", recorded)
|
||||
locators = list((workspace / ".git" / "epic-work-preparation" / "runs").glob("*/locator.json"))
|
||||
self.assertEqual(recorded["target_id"], "primary")
|
||||
self.assertEqual(recorded["agent"], "runtime-agent")
|
||||
self.assertEqual(recorded["model"], "runtime-model")
|
||||
self.assertTrue(recorded["catalog"]["revision"])
|
||||
locators = list(
|
||||
(workspace / ".git" / "epic-work-preparation" / "runs").glob(
|
||||
"*/locator.json"
|
||||
)
|
||||
)
|
||||
self.assertEqual(len(locators), 1)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -31,30 +31,25 @@ def command(cwd: Path, *args: str) -> str:
|
|||
|
||||
|
||||
class EpicCycleContractTest(unittest.TestCase):
|
||||
def test_cycle_defaults_to_codex_top_model_and_reasoning(self) -> None:
|
||||
def test_cycle_requires_runtime_catalog_and_defaults_review_target(self) -> None:
|
||||
args = MODULE.parser().parse_args(
|
||||
["--workspace", "/workspace", "--milestone", "milestone.md", "--epic", "epic"]
|
||||
)
|
||||
MODULE.apply_defaults(args)
|
||||
self.assertEqual(args.planner_agent, "codex")
|
||||
self.assertEqual(args.planner_model, "gpt-5.6-sol")
|
||||
self.assertEqual(args.reasoning_effort, "xhigh")
|
||||
|
||||
other = MODULE.parser().parse_args(
|
||||
[
|
||||
"--workspace",
|
||||
"/workspace",
|
||||
"--milestone",
|
||||
"milestone.md",
|
||||
"--epic",
|
||||
"epic",
|
||||
"--planner-agent",
|
||||
"claude",
|
||||
"--workspace", "/workspace",
|
||||
"--milestone", "milestone.md",
|
||||
"--epic", "epic",
|
||||
"--execution-catalog", "/runtime/catalog.json",
|
||||
"--planner-target", "planner-primary",
|
||||
]
|
||||
)
|
||||
MODULE.apply_defaults(other)
|
||||
self.assertIsNone(other.planner_model)
|
||||
self.assertEqual(other.reasoning_effort, "xhigh")
|
||||
MODULE.apply_defaults(args)
|
||||
self.assertEqual(args.planner_target, "planner-primary")
|
||||
self.assertEqual(args.review_target, "planner-primary")
|
||||
|
||||
missing = MODULE.parser().parse_args(
|
||||
["--workspace", "/workspace", "--milestone", "milestone.md", "--epic", "epic"]
|
||||
)
|
||||
with self.assertRaises(MODULE.CycleError):
|
||||
MODULE.apply_defaults(missing)
|
||||
|
||||
def test_live_stage_result_requires_tracking_without_relaunch(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
|
|
@ -84,10 +79,8 @@ class EpicCycleContractTest(unittest.TestCase):
|
|||
identity="sample-epic",
|
||||
stage="materialize",
|
||||
prompt="prompt",
|
||||
agent="codex",
|
||||
model=None,
|
||||
reasoning_effort=None,
|
||||
pi_provider=None,
|
||||
execution_catalog="/runtime/catalog.json",
|
||||
target_id="planner-primary",
|
||||
prior_cycle_status="tracking",
|
||||
retry=False,
|
||||
)
|
||||
|
|
@ -119,10 +112,8 @@ class EpicCycleContractTest(unittest.TestCase):
|
|||
"identity": "sample-epic",
|
||||
"stage": "materialize",
|
||||
"prompt": "prompt",
|
||||
"agent": "codex",
|
||||
"model": None,
|
||||
"reasoning_effort": None,
|
||||
"pi_provider": None,
|
||||
"execution_catalog": "/runtime/catalog.json",
|
||||
"target_id": "planner-primary",
|
||||
"prior_cycle_status": "tracking",
|
||||
}
|
||||
with self.assertRaises(MODULE.TrackingRecoveryRequired):
|
||||
|
|
@ -209,7 +200,7 @@ class EpicCycleContractTest(unittest.TestCase):
|
|||
{"first-task", "second-task"},
|
||||
)
|
||||
|
||||
def test_full_cycle_with_fresh_fake_codex_passes_and_pushes(self) -> None:
|
||||
def test_full_cycle_with_fresh_injected_target_passes_and_pushes(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
root = Path(raw)
|
||||
remote = root / "remote.git"
|
||||
|
|
@ -274,8 +265,10 @@ class EpicCycleContractTest(unittest.TestCase):
|
|||
str(milestone.relative_to(workspace)),
|
||||
"--epic",
|
||||
"sample-epic",
|
||||
"--planner-agent",
|
||||
"codex",
|
||||
"--execution-catalog",
|
||||
"/runtime/catalog.json",
|
||||
"--planner-target",
|
||||
"planner-primary",
|
||||
]
|
||||
)
|
||||
self.assertEqual(result, 0)
|
||||
|
|
@ -302,8 +295,10 @@ class EpicCycleContractTest(unittest.TestCase):
|
|||
str(milestone.relative_to(workspace)),
|
||||
"--epic",
|
||||
"sample-epic",
|
||||
"--planner-agent",
|
||||
"codex",
|
||||
"--execution-catalog",
|
||||
"/runtime/catalog.json",
|
||||
"--planner-target",
|
||||
"planner-primary",
|
||||
"--batch-task-ids",
|
||||
"large-task,later-task",
|
||||
]
|
||||
|
|
@ -333,8 +328,10 @@ class EpicCycleContractTest(unittest.TestCase):
|
|||
str(milestone.relative_to(workspace)),
|
||||
"--epic",
|
||||
"sample-epic",
|
||||
"--planner-agent",
|
||||
"codex",
|
||||
"--execution-catalog",
|
||||
"/runtime/catalog.json",
|
||||
"--planner-target",
|
||||
"planner-primary",
|
||||
]
|
||||
)
|
||||
self.assertEqual(resumed, 0)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
name: prepare-milestone-workspace
|
||||
description: 계획 상태의 Milestone을 명시 workspace의 Git Flow feature worktree로 준비하거나, 이미 준비된 현재 feature workspace에서 선택한 한 개·범위·남은 모든 Epic을 검토된 작업으로 변환하고 전체 준비 배리어 뒤 dispatcher를 시작할 때 사용한다. "../iop-s1 위치에 X 작업 준비해", "현 마일스톤에 두 번째 에픽 작업 시작해", "X 마일스톤에 1,2번째 에픽까지 작업 시작해", "현 마일스톤에 남은 에픽 작업들 시작해" 요청에서 사용한다.
|
||||
description: 계획 상태의 Milestone을 명시 workspace의 Git Flow feature worktree로 준비하거나, 이미 준비된 현재 feature workspace에서 선택한 한 개·범위·남은 모든 Epic을 검토된 작업으로 변환하고 전체 준비 배리어 뒤 dispatcher를 시작할 때 사용한다. "../sample-feature-worktree 위치에 X 작업 준비해", "현 마일스톤에 두 번째 에픽 작업 시작해", "X 마일스톤에 1,2번째 에픽까지 작업 시작해", "현 마일스톤에 남은 에픽 작업들 시작해" 요청에서 사용한다.
|
||||
---
|
||||
|
||||
# Prepare Milestone Workspace
|
||||
|
|
@ -15,11 +15,9 @@ description: 계획 상태의 Milestone을 명시 workspace의 Git Flow feature
|
|||
|
||||
- `target-milestone`: 활성 Milestone 이름, id, slug 또는 경로 (필수)
|
||||
- `workspace`: 생성 모드에서는 feature worktree 절대 경로 또는 develop repository root 기준 상대 경로가 필수다. 현재 workspace 실행 모드에서는 현재 repository root를 사용한다.
|
||||
- `planner-agent`: `codex`, `claude`, `gemini`, `pi` 중 하나 (생략 시 `codex`)
|
||||
- `review-agent`: 생략하면 `planner-agent`와 같다. (선택)
|
||||
- `planner-model`, `review-model`: provider별 model override. Codex 기본 사용 시 `planner-model` 생략 시 `gpt-5.6-sol`, 다른 provider는 해당 CLI 기본 모델을 사용한다. (선택)
|
||||
- `reasoning-effort`: 지원하는 provider의 reasoning/thinking override. 생략 시 `xhigh` (선택)
|
||||
- `pi-provider`: Pi provider override (선택)
|
||||
- `execution-catalog`: 런타임이 주입한 agent-model 실행 카탈로그 경로. `AGENT_TASK_EXECUTION_CATALOG`로 대신 주입할 수 있다. (필수)
|
||||
- `planner-target`: 카탈로그에 선언된 Epic materialize/refine 실행 target id. `AGENT_TASK_PLANNER_TARGET`로 대신 주입할 수 있다. (필수)
|
||||
- `review-target`: 카탈로그에 선언된 review 실행 target id. `AGENT_TASK_REVIEW_TARGET`로 주입하거나 생략하면 `planner-target`과 같다. (선택)
|
||||
- `target-epics`: `remaining`, `first-incomplete`, 정확한 Epic id/title의 comma list, 또는 문서 순서의 1-based inclusive range `N..M`. 생략하면 `first-incomplete`를 사용한다. (선택)
|
||||
- `retry`: 기록된 attention/recovery 조건을 사용자가 해소한 뒤 batch를 재개할 때만 사용한다. (선택)
|
||||
|
||||
|
|
@ -32,7 +30,7 @@ description: 계획 상태의 Milestone을 명시 workspace의 Git Flow feature
|
|||
- 두 모드 모두 `구현 잠금: 해제`, `결정 필요: 없음`이어야 한다.
|
||||
- `sync-milestone-workstate mode=consistency-check`가 `ready`여야 한다.
|
||||
- remote와 `gitflow.branch.develop`, `gitflow.prefix.feature`를 확인할 수 있어야 한다.
|
||||
- 선택 agent의 비대화식 one-shot capability probe가 branch 생성 전에 성공해야 한다.
|
||||
- 선택 target의 카탈로그 검증과 비대화식 one-shot capability probe가 branch 생성 전에 성공해야 한다.
|
||||
|
||||
## 절차
|
||||
|
||||
|
|
@ -50,12 +48,14 @@ python3 agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_work
|
|||
--repo "$REPO" \
|
||||
--milestone "$MILESTONE" \
|
||||
--workspace "$WORKSPACE" \
|
||||
--epics "$EPICS"
|
||||
--epics "$EPICS" \
|
||||
--execution-catalog "$EXECUTION_CATALOG" \
|
||||
--planner-target "$PLANNER_TARGET" \
|
||||
--review-target "$REVIEW_TARGET"
|
||||
```
|
||||
|
||||
- 기본값은 `codex / gpt-5.6-sol / xhigh`다. 다른 provider를 지정하면 모델을 별도로 주지 않는 한 해당 provider의 CLI 기본 모델을 사용한다.
|
||||
- 다른 agent, model, reasoning, Pi provider override가 있으면 해당 인자를 전달한다.
|
||||
- 스크립트는 develop HEAD와 remote develop의 일치, agent probe, branch 충돌, worktree 소유권을 mutation 전에 검사한다.
|
||||
- agent, model, 실행 명령과 provider별 옵션은 스킬이나 스크립트에 고정하지 않고 주입된 카탈로그 target에서 가져온다.
|
||||
- 스크립트는 develop HEAD와 remote develop의 일치, target probe, branch 충돌, worktree 소유권을 mutation 전에 검사한다.
|
||||
- branch는 Milestone id가 아니라 파일 basename을 사용한 `feature/<milestone-slug>`다.
|
||||
- 기존 branch/worktree는 정확히 같은 branch·경로이고 clean할 때만 재개한다.
|
||||
- remote branch 생성 뒤 후속 단계가 실패해도 branch/worktree를 자동 삭제하지 않는다.
|
||||
|
|
@ -66,7 +66,10 @@ python3 agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_work
|
|||
--existing-workspace \
|
||||
--workspace "$CURRENT_WORKSPACE" \
|
||||
--milestone "$MILESTONE" \
|
||||
--epics "$EPICS"
|
||||
--epics "$EPICS" \
|
||||
--execution-catalog "$EXECUTION_CATALOG" \
|
||||
--planner-target "$PLANNER_TARGET" \
|
||||
--review-target "$REVIEW_TARGET"
|
||||
```
|
||||
|
||||
- 현재 workspace가 target feature branch/current와 다르면 다른 worktree를 탐색하거나 branch를 바꾸지 않고 `FAILED`로 멈춘다.
|
||||
|
|
@ -81,7 +84,7 @@ python3 agent-ops/skills/common/prepare-milestone-workspace/scripts/prepare_work
|
|||
|
||||
4. **전체 준비 배리어 뒤 dispatcher로 전환한다**
|
||||
- 모든 선택 Epic이 `EPIC_WORK_ITEMS_READY` 또는 `EPIC_COMPLETED`이고 deterministic batch validation과 모든 push가 끝난 경우에만 `MILESTONE_WORK_ITEMS_READY`를 낸다.
|
||||
- active plan이 있으면 private/project/common 우선순위로 `orchestrate-agent-task-loop` dispatcher를 선택하고 같은 task group `m-<milestone-slug>`에 `--dry-run`을 먼저 실행한 뒤 live를 정확히 한 번 시작한다.
|
||||
- active plan이 있으면 공통 `orchestrate-agent-task-loop` dispatcher에 런타임 카탈로그를 주입하고 같은 task group `m-<milestone-slug>`에 `--dry-run`을 먼저 실행한 뒤 live를 정확히 한 번 시작한다.
|
||||
- 모든 선택 Epic이 `EPIC_COMPLETED`이면 dispatcher를 생략한다.
|
||||
- foreground dispatcher가 종료될 때까지 caller는 timer polling이나 상태 파일 검사를 하지 않는다. batch/dispatcher PID와 start token은 git common dir 상태에 기록해 재진입 중복 실행을 막는다.
|
||||
|
||||
|
|
|
|||
|
|
@ -9,17 +9,12 @@ import json
|
|||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Iterable, NamedTuple
|
||||
|
||||
|
||||
VALID_AGENTS = {"codex", "claude", "gemini", "pi"}
|
||||
AGENT_COMMAND = {"codex": "codex", "claude": "claude", "gemini": "agy", "pi": "pi"}
|
||||
DEFAULT_PLANNER_AGENT = "codex"
|
||||
DEFAULT_PLANNER_MODEL = "gpt-5.6-sol"
|
||||
DEFAULT_REASONING_EFFORT = "xhigh"
|
||||
CATALOG_ENV = "AGENT_TASK_EXECUTION_CATALOG"
|
||||
MILESTONE_PATTERN = re.compile(
|
||||
r"^agent-roadmap/phase/(?P<phase>[a-z0-9-]+)/milestones/(?P<slug>[a-z0-9-]+)\.md$"
|
||||
)
|
||||
|
|
@ -345,14 +340,11 @@ def worktrees(repo: Path) -> list[dict[str, str]]:
|
|||
return records
|
||||
|
||||
|
||||
def probe_agents(
|
||||
def probe_targets(
|
||||
repo: Path,
|
||||
planner_agent: str,
|
||||
reviewer_agent: str,
|
||||
planner_model: str | None,
|
||||
reviewer_model: str | None,
|
||||
reasoning_effort: str | None,
|
||||
pi_provider: str | None,
|
||||
execution_catalog: str,
|
||||
planner_target: str,
|
||||
review_target: str,
|
||||
) -> None:
|
||||
runner = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
|
|
@ -362,33 +354,27 @@ def probe_agents(
|
|||
)
|
||||
if not runner.is_file():
|
||||
raise PreparationError(f"agent runner not found: {runner}")
|
||||
seen: set[tuple[str, str | None]] = set()
|
||||
for agent, model in (
|
||||
(planner_agent, planner_model),
|
||||
(reviewer_agent, reviewer_model),
|
||||
):
|
||||
identity = (agent, model)
|
||||
if identity in seen:
|
||||
seen: set[str] = set()
|
||||
for target_id in (planner_target, review_target):
|
||||
if target_id in seen:
|
||||
continue
|
||||
seen.add(identity)
|
||||
seen.add(target_id)
|
||||
command = [
|
||||
sys.executable,
|
||||
str(runner),
|
||||
"--agent",
|
||||
agent,
|
||||
"--execution-catalog",
|
||||
execution_catalog,
|
||||
"--target-id",
|
||||
target_id,
|
||||
"--workspace",
|
||||
str(repo),
|
||||
"--probe",
|
||||
]
|
||||
if model:
|
||||
command.extend(["--model", model])
|
||||
if reasoning_effort:
|
||||
command.extend(["--reasoning-effort", reasoning_effort])
|
||||
if agent == "pi" and pi_provider:
|
||||
command.extend(["--pi-provider", pi_provider])
|
||||
result = run(command, cwd=repo, check=False, capture=False)
|
||||
if result.returncode != 0:
|
||||
raise PreparationError(f"agent capability probe failed: agent={agent} model={model or 'default'}")
|
||||
raise PreparationError(
|
||||
f"execution target capability probe failed: target_id={target_id}"
|
||||
)
|
||||
|
||||
|
||||
def render_current(
|
||||
|
|
@ -440,14 +426,7 @@ def epic_cycle_script(workspace: Path) -> Path:
|
|||
|
||||
|
||||
def dispatcher_script(workspace: Path) -> Path:
|
||||
project = workspace / "agent-ops" / "skills" / "project" / "orchestrate-agent-task-loop"
|
||||
private = workspace / "agent-ops" / "skills" / "private" / "orchestrate-agent-task-loop"
|
||||
if project.is_dir() and private.is_dir():
|
||||
root = private
|
||||
elif project.is_dir():
|
||||
root = project
|
||||
else:
|
||||
root = workspace / "agent-ops" / "skills" / "common" / "orchestrate-agent-task-loop"
|
||||
root = workspace / "agent-ops" / "skills" / "common" / "orchestrate-agent-task-loop"
|
||||
path = root / "scripts" / "dispatch.py"
|
||||
if not path.is_file():
|
||||
raise PreparationError(f"dispatcher script not found: {path}")
|
||||
|
|
@ -472,21 +451,15 @@ def epic_cycle_command(
|
|||
str(milestone),
|
||||
"--epic",
|
||||
epic.epic_id,
|
||||
"--planner-agent",
|
||||
args.planner_agent,
|
||||
"--execution-catalog",
|
||||
args.execution_catalog,
|
||||
"--planner-target",
|
||||
args.planner_target,
|
||||
"--batch-task-ids",
|
||||
",".join(batch_ids),
|
||||
]
|
||||
if args.review_agent:
|
||||
command.extend(["--review-agent", args.review_agent])
|
||||
if args.planner_model:
|
||||
command.extend(["--planner-model", args.planner_model])
|
||||
if args.review_model:
|
||||
command.extend(["--review-model", args.review_model])
|
||||
if args.reasoning_effort:
|
||||
command.extend(["--reasoning-effort", args.reasoning_effort])
|
||||
if args.pi_provider:
|
||||
command.extend(["--pi-provider", args.pi_provider])
|
||||
if args.review_target:
|
||||
command.extend(["--review-target", args.review_target])
|
||||
if validate_only:
|
||||
command.append("--validate-only")
|
||||
elif args.retry:
|
||||
|
|
@ -549,10 +522,7 @@ def cross_epic_review(
|
|||
batch_ids: list[str],
|
||||
common: Path,
|
||||
) -> tuple[int, dict[str, Any] | None]:
|
||||
reviewer_agent = args.review_agent or args.planner_agent
|
||||
reviewer_model = args.review_model or (
|
||||
args.planner_model if reviewer_agent == args.planner_agent else None
|
||||
)
|
||||
reviewer_target = args.review_target or args.planner_target
|
||||
state_root = common / "milestone-work-preparation" / milestone_slug
|
||||
prompt_path = state_root / "prompts" / "cross-epic-review.txt"
|
||||
result_path = (
|
||||
|
|
@ -623,8 +593,10 @@ Review the complete prepared artifact union across these Epics from a fresh cont
|
|||
command = [
|
||||
sys.executable,
|
||||
str(runner),
|
||||
"--agent",
|
||||
reviewer_agent,
|
||||
"--execution-catalog",
|
||||
args.execution_catalog,
|
||||
"--target-id",
|
||||
reviewer_target,
|
||||
"--workspace",
|
||||
str(workspace),
|
||||
"--prompt-file",
|
||||
|
|
@ -634,12 +606,6 @@ Review the complete prepared artifact union across these Epics from a fresh cont
|
|||
"--result-file",
|
||||
str(result_path),
|
||||
]
|
||||
if reviewer_model:
|
||||
command.extend(["--model", reviewer_model])
|
||||
if args.reasoning_effort:
|
||||
command.extend(["--reasoning-effort", args.reasoning_effort])
|
||||
if reviewer_agent == "pi" and args.pi_provider:
|
||||
command.extend(["--pi-provider", args.pi_provider])
|
||||
starting_head = git(workspace, "rev-parse", "HEAD")
|
||||
result = run(command, cwd=workspace, check=False, capture=False)
|
||||
if git(workspace, "rev-parse", "HEAD") != starting_head:
|
||||
|
|
@ -696,6 +662,9 @@ def coordinate_batch(
|
|||
"workspace": str(workspace),
|
||||
"selected_epics": [epic.epic_id for epic in selected],
|
||||
"batch_task_ids": batch_ids,
|
||||
"execution_catalog": str(Path(args.execution_catalog).expanduser().resolve()),
|
||||
"planner_target": args.planner_target,
|
||||
"review_target": args.review_target,
|
||||
}
|
||||
state_path = common / "milestone-work-preparation" / milestone_slug / "batch-state.json"
|
||||
state = read_json(state_path)
|
||||
|
|
@ -922,6 +891,8 @@ def coordinate_batch(
|
|||
str(workspace),
|
||||
"--task-group",
|
||||
task_group,
|
||||
"--execution-catalog",
|
||||
args.execution_catalog,
|
||||
"--dry-run",
|
||||
],
|
||||
cwd=workspace,
|
||||
|
|
@ -943,6 +914,8 @@ def coordinate_batch(
|
|||
str(workspace),
|
||||
"--task-group",
|
||||
task_group,
|
||||
"--execution-catalog",
|
||||
args.execution_catalog,
|
||||
]
|
||||
if resume_blocked_dispatcher and args.retry:
|
||||
command.append("--retry-blocked")
|
||||
|
|
@ -1023,16 +996,13 @@ def parser() -> argparse.ArgumentParser:
|
|||
action="store_true",
|
||||
help="start selected Epic work in the current prepared feature workspace",
|
||||
)
|
||||
value.add_argument("--execution-catalog", default=os.environ.get(CATALOG_ENV))
|
||||
value.add_argument(
|
||||
"--planner-agent",
|
||||
choices=sorted(VALID_AGENTS),
|
||||
default=DEFAULT_PLANNER_AGENT,
|
||||
"--planner-target", default=os.environ.get("AGENT_TASK_PLANNER_TARGET")
|
||||
)
|
||||
value.add_argument(
|
||||
"--review-target", default=os.environ.get("AGENT_TASK_REVIEW_TARGET")
|
||||
)
|
||||
value.add_argument("--review-agent", choices=sorted(VALID_AGENTS))
|
||||
value.add_argument("--planner-model")
|
||||
value.add_argument("--review-model")
|
||||
value.add_argument("--reasoning-effort", default=DEFAULT_REASONING_EFFORT)
|
||||
value.add_argument("--pi-provider")
|
||||
value.add_argument(
|
||||
"--epics",
|
||||
help="prepare and dispatch remaining/first-incomplete/one/list/range selector",
|
||||
|
|
@ -1053,10 +1023,17 @@ def parser() -> argparse.ArgumentParser:
|
|||
|
||||
|
||||
def apply_defaults(args: argparse.Namespace) -> argparse.Namespace:
|
||||
if args.planner_model is None and args.planner_agent == DEFAULT_PLANNER_AGENT:
|
||||
args.planner_model = DEFAULT_PLANNER_MODEL
|
||||
if args.reasoning_effort is None:
|
||||
args.reasoning_effort = DEFAULT_REASONING_EFFORT
|
||||
if not args.execution_catalog:
|
||||
raise PreparationError(
|
||||
f"--execution-catalog or {CATALOG_ENV} is required"
|
||||
)
|
||||
if not args.planner_target:
|
||||
raise PreparationError(
|
||||
"--planner-target or AGENT_TASK_PLANNER_TARGET is required"
|
||||
)
|
||||
args.execution_catalog = str(Path(args.execution_catalog).expanduser().resolve())
|
||||
if args.review_target is None:
|
||||
args.review_target = args.planner_target
|
||||
return args
|
||||
|
||||
|
||||
|
|
@ -1103,15 +1080,6 @@ def prepare_existing(args: argparse.Namespace) -> int:
|
|||
raise PreparationError(
|
||||
f"workspace-local current does not select target Milestone: {current_path}"
|
||||
)
|
||||
reviewer_agent = args.review_agent or args.planner_agent
|
||||
reviewer_model = args.review_model or (
|
||||
args.planner_model if reviewer_agent == args.planner_agent else None
|
||||
)
|
||||
for agent in {args.planner_agent, reviewer_agent} if selected else set():
|
||||
command = AGENT_COMMAND[agent]
|
||||
if shutil.which(command) is None and not args.skip_agent_probe:
|
||||
raise PreparationError(f"agent command not found: agent={agent} command={command}")
|
||||
|
||||
common = git_common_dir(workspace)
|
||||
state_root = common / "milestone-work-preparation" / milestone_slug
|
||||
state_root.mkdir(parents=True, exist_ok=True)
|
||||
|
|
@ -1169,14 +1137,11 @@ def prepare_existing(args: argparse.Namespace) -> int:
|
|||
if args.dry_run:
|
||||
return 0
|
||||
if selected and not args.skip_agent_probe and not resuming_batch:
|
||||
probe_agents(
|
||||
probe_targets(
|
||||
workspace,
|
||||
args.planner_agent,
|
||||
reviewer_agent,
|
||||
args.planner_model,
|
||||
reviewer_model,
|
||||
args.reasoning_effort,
|
||||
args.pi_provider,
|
||||
args.execution_catalog,
|
||||
args.planner_target,
|
||||
args.review_target,
|
||||
)
|
||||
ensure_clean(workspace, "feature workspace after agent probe")
|
||||
state = {
|
||||
|
|
@ -1185,9 +1150,9 @@ def prepare_existing(args: argparse.Namespace) -> int:
|
|||
"milestone_slug": milestone_slug,
|
||||
"branch": branch,
|
||||
"workspace": str(workspace),
|
||||
"planner_agent": args.planner_agent,
|
||||
"review_agent": reviewer_agent,
|
||||
"reasoning_effort": args.reasoning_effort,
|
||||
"execution_catalog": args.execution_catalog,
|
||||
"planner_target": args.planner_target,
|
||||
"review_target": args.review_target,
|
||||
"existing_workspace": True,
|
||||
}
|
||||
atomic_json(state_path, state)
|
||||
|
|
@ -1227,15 +1192,6 @@ def prepare(args: argparse.Namespace) -> int:
|
|||
pass
|
||||
else:
|
||||
raise PreparationError("feature workspace must not be nested inside the develop checkout")
|
||||
reviewer_agent = args.review_agent or args.planner_agent
|
||||
reviewer_model = args.review_model or (
|
||||
args.planner_model if reviewer_agent == args.planner_agent else None
|
||||
)
|
||||
for agent in {args.planner_agent, reviewer_agent}:
|
||||
command = AGENT_COMMAND[agent]
|
||||
if shutil.which(command) is None and not args.skip_agent_probe:
|
||||
raise PreparationError(f"agent command not found: agent={agent} command={command}")
|
||||
|
||||
common = git_common_dir(repo)
|
||||
state_root = common / "milestone-work-preparation" / milestone_slug
|
||||
state_path = state_root / "workspace-state.json"
|
||||
|
|
@ -1274,14 +1230,11 @@ def prepare(args: argparse.Namespace) -> int:
|
|||
)
|
||||
|
||||
if not args.skip_agent_probe and not args.dry_run:
|
||||
probe_agents(
|
||||
probe_targets(
|
||||
repo,
|
||||
args.planner_agent,
|
||||
reviewer_agent,
|
||||
args.planner_model,
|
||||
reviewer_model,
|
||||
args.reasoning_effort,
|
||||
args.pi_provider,
|
||||
args.execution_catalog,
|
||||
args.planner_target,
|
||||
args.review_target,
|
||||
)
|
||||
ensure_clean(repo, "develop checkout after agent probe")
|
||||
|
||||
|
|
@ -1369,9 +1322,9 @@ def prepare(args: argparse.Namespace) -> int:
|
|||
"milestone_slug": milestone_slug,
|
||||
"branch": branch,
|
||||
"workspace": str(workspace),
|
||||
"planner_agent": args.planner_agent,
|
||||
"review_agent": reviewer_agent,
|
||||
"reasoning_effort": args.reasoning_effort,
|
||||
"execution_catalog": args.execution_catalog,
|
||||
"planner_target": args.planner_target,
|
||||
"review_target": args.review_target,
|
||||
}
|
||||
atomic_json(state_path, state)
|
||||
emit("WORKSPACE_READY", **state)
|
||||
|
|
@ -1388,8 +1341,9 @@ def prepare(args: argparse.Namespace) -> int:
|
|||
|
||||
|
||||
def main(argv: Iterable[str] | None = None) -> int:
|
||||
args = apply_defaults(parser().parse_args(argv))
|
||||
args = parser().parse_args(argv)
|
||||
try:
|
||||
apply_defaults(args)
|
||||
return prepare_existing(args) if args.existing_workspace else prepare(args)
|
||||
except (OSError, PreparationError) as exc:
|
||||
emit("FAILED", reason=str(exc))
|
||||
|
|
|
|||
|
|
@ -31,11 +31,24 @@ def command(cwd: Path, *args: str) -> str:
|
|||
|
||||
|
||||
class PrepareWorkspaceTest(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.runtime_environment = mock.patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"AGENT_TASK_EXECUTION_CATALOG": "/runtime/catalog.json",
|
||||
"AGENT_TASK_PLANNER_TARGET": "planner-primary",
|
||||
},
|
||||
)
|
||||
self.runtime_environment.start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.runtime_environment.stop()
|
||||
|
||||
def test_relative_workspace_is_resolved_from_repository_root(self) -> None:
|
||||
repo = Path("/tmp/example/iop")
|
||||
repo = Path("/tmp/example/sample-repo")
|
||||
self.assertEqual(
|
||||
MODULE.resolve_workspace(repo, "../iop-s1"),
|
||||
Path("/tmp/example/iop-s1"),
|
||||
MODULE.resolve_workspace(repo, "../sample-feature-worktree"),
|
||||
Path("/tmp/example/sample-feature-worktree"),
|
||||
)
|
||||
|
||||
def test_epic_document_range_is_one_based_and_inclusive(self) -> None:
|
||||
|
|
@ -94,14 +107,14 @@ class PrepareWorkspaceTest(unittest.TestCase):
|
|||
[],
|
||||
)
|
||||
|
||||
def test_workspace_defaults_to_codex_top_model_and_reasoning(self) -> None:
|
||||
def test_workspace_uses_runtime_injected_catalog_and_targets(self) -> None:
|
||||
args = MODULE.parser().parse_args(
|
||||
["--repo", "/repo", "--milestone", "milestone.md", "--workspace", "/workspace"]
|
||||
)
|
||||
MODULE.apply_defaults(args)
|
||||
self.assertEqual(args.planner_agent, "codex")
|
||||
self.assertEqual(args.planner_model, "gpt-5.6-sol")
|
||||
self.assertEqual(args.reasoning_effort, "xhigh")
|
||||
self.assertEqual(args.execution_catalog, "/runtime/catalog.json")
|
||||
self.assertEqual(args.planner_target, "planner-primary")
|
||||
self.assertEqual(args.review_target, "planner-primary")
|
||||
|
||||
other = MODULE.parser().parse_args(
|
||||
[
|
||||
|
|
@ -111,13 +124,15 @@ class PrepareWorkspaceTest(unittest.TestCase):
|
|||
"milestone.md",
|
||||
"--workspace",
|
||||
"/workspace",
|
||||
"--planner-agent",
|
||||
"claude",
|
||||
"--planner-target",
|
||||
"planner-secondary",
|
||||
"--review-target",
|
||||
"review-primary",
|
||||
]
|
||||
)
|
||||
MODULE.apply_defaults(other)
|
||||
self.assertIsNone(other.planner_model)
|
||||
self.assertEqual(other.reasoning_effort, "xhigh")
|
||||
self.assertEqual(other.planner_target, "planner-secondary")
|
||||
self.assertEqual(other.review_target, "review-primary")
|
||||
|
||||
def test_agent_probe_bypass_is_test_only(self) -> None:
|
||||
output = io.StringIO()
|
||||
|
|
@ -132,8 +147,8 @@ class PrepareWorkspaceTest(unittest.TestCase):
|
|||
"missing.md",
|
||||
"--workspace",
|
||||
"/missing-workspace",
|
||||
"--planner-agent",
|
||||
"codex",
|
||||
"--planner-target",
|
||||
"planner-primary",
|
||||
"--skip-agent-probe",
|
||||
]
|
||||
)
|
||||
|
|
@ -183,8 +198,8 @@ class PrepareWorkspaceTest(unittest.TestCase):
|
|||
str(milestone.relative_to(repo)),
|
||||
"--workspace",
|
||||
str(worktree),
|
||||
"--planner-agent",
|
||||
"codex",
|
||||
"--planner-target",
|
||||
"planner-primary",
|
||||
"--skip-agent-probe",
|
||||
]
|
||||
)
|
||||
|
|
@ -532,6 +547,9 @@ class PrepareWorkspaceTest(unittest.TestCase):
|
|||
"workspace": str(workspace),
|
||||
"selected_epics": ["first"],
|
||||
"batch_task_ids": ["first-task"],
|
||||
"execution_catalog": "/runtime/catalog.json",
|
||||
"planner_target": "planner-primary",
|
||||
"review_target": "planner-primary",
|
||||
"status": "dispatching",
|
||||
"epic_events": {"first": "EPIC_WORK_ITEMS_READY"},
|
||||
"dispatcher_pid": os.getpid(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue