feat(agent-ops): GLM 백업 경로를 OpenCode로 전환한다
Gemini 쿼터 소진 시 reasoning 단계가 한 단계 높은 GLM-5.2를 선택하고 실행 상태에도 effort를 보존하기 위해 변경한다.
This commit is contained in:
parent
61016d5bd0
commit
a89b42ed14
8 changed files with 488 additions and 121 deletions
|
|
@ -59,7 +59,7 @@ Treat Korean text inside code spans or fenced examples as exact runtime or file-
|
|||
## 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.
|
||||
- [ ] Verify that `codex`, `claude`, `agy`, `opencode`, and `pi` are on PATH and their login/provider configuration is valid.
|
||||
- [ ] Limit automatic approval to PLAN execution inside the current workspace; do not expand scope to external-system changes or destructive work.
|
||||
- [ ] Verify that no other dispatcher is running in the same workspace. Never bypass a workspace-lock failure.
|
||||
- [ ] Run `--dry-run` before the first live run to inspect active-task classification and dependency state.
|
||||
|
|
@ -69,11 +69,11 @@ Treat Korean text inside code spans or fenced examples as exact runtime or file-
|
|||
| PLAN route | Worker |
|
||||
|---|---|
|
||||
| `local-G01`–`local-G06` | Pi `iop/ornith:35b`, thinking high |
|
||||
| `local-G07`–`local-G08` | KST day/night agy `Gemini 3.6 Flash (High)` → `claude-glm` `glm-5.2` → Codex `gpt-5.6-terra`, reasoning high |
|
||||
| `local-G07`–`local-G08` | KST day/night agy `Gemini 3.6 Flash (High)` → OpenCode `iop-glm/glm-5.2`, variant max → Codex `gpt-5.6-terra`, reasoning high |
|
||||
| `local-G09`–`local-G10` | Claude `claude-opus-4-8`, effort xhigh |
|
||||
| `cloud-G01`–`cloud-G02` | Codex `gpt-5.3-codex-spark` → agy `Gemini 3.6 Flash (Low)` → `claude-glm` `glm-5.2` → Codex `gpt-5.6-terra`, reasoning high |
|
||||
| `cloud-G03`–`cloud-G04` | agy `Gemini 3.6 Flash (Medium)` → `claude-glm` `glm-5.2` → Codex `gpt-5.6-terra`, reasoning high |
|
||||
| `cloud-G05`–`cloud-G06` | agy `Gemini 3.6 Flash (High)` → `claude-glm` `glm-5.2` → Codex `gpt-5.6-terra`, reasoning high |
|
||||
| `cloud-G01`–`cloud-G02` | Codex `gpt-5.3-codex-spark` → agy `Gemini 3.6 Flash (Low)` → OpenCode `iop-glm/glm-5.2`, variant medium → Codex `gpt-5.6-terra`, reasoning high |
|
||||
| `cloud-G03`–`cloud-G04` | agy `Gemini 3.6 Flash (Medium)` → OpenCode `iop-glm/glm-5.2`, variant high → Codex `gpt-5.6-terra`, reasoning high |
|
||||
| `cloud-G05`–`cloud-G06` | agy `Gemini 3.6 Flash (High)` → OpenCode `iop-glm/glm-5.2`, variant max → Codex `gpt-5.6-terra`, reasoning high |
|
||||
| `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 |
|
||||
|
|
@ -111,7 +111,7 @@ Keep control prompts in English, insert absolute paths only, and do not expand t
|
|||
|
||||
Never ask a worker, self-check, or review model to create, edit, or summarize `WORK_LOG.md`.
|
||||
|
||||
Run Pi self-check only for policy targets marked `local_model` and `selfcheck_required=true`; `claude-glm` `glm-5.2` is a cloud target and skips self-check. Do not treat a local Pi self-check exit code `0` as success by itself. Set `selfcheck_done=true` only when `## Implementation Checklist` (or legacy `## 구현 체크리스트`) in `CODE_REVIEW_PATH` contains at least one Markdown list checkbox and every `[...]` checkbox value has at least one non-whitespace character. If both canonical and legacy checklist headings are present in the same file, fail closed. Accept any non-empty value, including `x`, `v`, and `✅`. Do not inspect `## Implementation Item Completion`, `Deviations from Plan`, `Key Design Decisions`, `Verification Results`, or final CODE_REVIEW synchronization text. Run the full self-check prompt exactly once. If its checklist condition fails, resume that successful pass's Pi native session and run the unchecked-item retry prompt up to 10 times. Each retry must resume the locator returned by the preceding successful pass so the same conversation context is preserved; never repeat the full review prompt or start a fresh retry session. Persist the latest successful context locator for dispatcher restart, and block instead of starting fresh when that context cannot be resumed. Block that task after the 10th unchecked-item retry remains incomplete, and continue draining independent work.
|
||||
Run Pi self-check only for policy targets marked `local_model` and `selfcheck_required=true`; OpenCode `iop-glm/glm-5.2` is a cloud target and skips self-check. Do not treat a local Pi self-check exit code `0` as success by itself. Set `selfcheck_done=true` only when `## Implementation Checklist` (or legacy `## 구현 체크리스트`) in `CODE_REVIEW_PATH` contains at least one Markdown list checkbox and every `[...]` checkbox value has at least one non-whitespace character. If both canonical and legacy checklist headings are present in the same file, fail closed. Accept any non-empty value, including `x`, `v`, and `✅`. Do not inspect `## Implementation Item Completion`, `Deviations from Plan`, `Key Design Decisions`, `Verification Results`, or final CODE_REVIEW synchronization text. Run the full self-check prompt exactly once. If its checklist condition fails, resume that successful pass's Pi native session and run the unchecked-item retry prompt up to 10 times. Each retry must resume the locator returned by the preceding successful pass so the same conversation context is preserved; never repeat the full review prompt or start a fresh retry session. Persist the latest successful context locator for dispatcher restart, and block instead of starting fresh when that context cannot be resumed. Block that task after the 10th unchecked-item retry remains incomplete, and continue draining independent work.
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -229,13 +229,13 @@ When recovering a KST-night `local-G07`–`local-G08` Laguna locator or a termin
|
|||
- Archive `WORK_LOG.md` as `work_log_N.log` only after the final task review process exits, the dispatcher appends `FINISH`, and a complete scan finds no active/running task in that group. Accept the log at either the active group path or the verified completed single-task archive; do not impose either location contract on common plan/code-review.
|
||||
|
||||
3. **Escalate and recover context.**
|
||||
- For every route that lists Gemini followed by `claude-glm` and Codex Terra, classify terminal provider errors or stderr evidence of context/output limits, provider quota/rate limits, unavailable models, or confirmed provider transport errors as a qualified failover to the next candidate. For AGY, accept top-level `error`, `fatal`, `request.failed`, or `turn.failed` events; failed/rejected status with a top-level error/code; stderr; or strong `RESOURCE_EXHAUSTED`, HTTP 429, quota, or rate-limit evidence in `agy-cli.log`. For Claude and `claude-glm`, 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`. Cloud-only escalation remains `Claude/claude-glm -> Codex`; never escalate from an assistant message, source text, tool/test output, or a plain quota-configuration string in an AGY log.
|
||||
- For every route that lists Gemini followed by OpenCode GLM and Codex Terra, classify terminal provider errors or stderr evidence of context/output limits, provider quota/rate limits, unavailable models, or confirmed provider transport errors as a qualified failover to the next candidate. For AGY, accept top-level `error`, `fatal`, `request.failed`, or `turn.failed` events; failed/rejected status with a top-level error/code; stderr; or strong `RESOURCE_EXHAUSTED`, HTTP 429, quota, or rate-limit evidence in `agy-cli.log`. For OpenCode, accept stderr or structured error events as terminal diagnostics. Cloud-only escalation remains OpenCode GLM → Codex Terra; never escalate from an assistant message, source text, tool/test output, or a plain quota-configuration string in an AGY log. Legacy `claude-glm` locators retain their typed quota parsing for recovery only.
|
||||
- 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 local Pi models to a cloud model. Cloud `claude-glm` may fail over to the policy-owned Codex Terra backup.
|
||||
- Never escalate local Pi models to a cloud model. OpenCode GLM may fail over to the policy-owned Codex Terra backup; legacy `claude-glm` recovery keeps the same terminal fallback.
|
||||
- Use attempt identity `<task-name>__p<plan>__<role>__aNN` and namespace the process marker with the physical workspace id. Record canonical workspace root/id, CLI/model/reasoning effort, PLAN/review, `WORK_LOG.md`, session ID, native session path, and raw output log in the locator.
|
||||
- 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -508,6 +508,8 @@ class AgentSpec:
|
|||
def effective_reasoning_effort(spec: AgentSpec) -> str | None:
|
||||
if spec.cli in {"codex", "claude", "claude-glm"}:
|
||||
return spec.reasoning_effort or "xhigh"
|
||||
if spec.cli == "opencode":
|
||||
return spec.reasoning_effort or "max"
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -538,9 +540,11 @@ def agent_spec_from_record(record: dict[str, Any]) -> AgentSpec | None:
|
|||
command_model = str(command_model)
|
||||
if cli == "claude-glm" and model == "glm-5.2" and not command_model:
|
||||
command_model = "sonnet"
|
||||
if cli == "opencode" and model == "glm-5.2" and not command_model:
|
||||
command_model = "iop-glm/glm-5.2"
|
||||
local_pi = cli == "pi"
|
||||
if cli in {"codex", "claude", "claude-glm"}:
|
||||
effort = reasoning_effort or "xhigh"
|
||||
if cli in {"codex", "claude", "claude-glm", "opencode"}:
|
||||
effort = reasoning_effort or ("max" if cli == "opencode" else "xhigh")
|
||||
display = f"{cli}/{model} {effort}"
|
||||
elif cli == "pi":
|
||||
display = pi_display(model, thinking_level)
|
||||
|
|
@ -1609,7 +1613,16 @@ class StageFailureBudget:
|
|||
count = int(entry.get("count", 0)) + 1
|
||||
entry.update(
|
||||
work_unit_id=self.work_unit_id, stage=self.stage, count=count,
|
||||
last_target={"adapter": target.get("adapter"), "target": target.get("target")},
|
||||
last_target={
|
||||
key: target.get(key)
|
||||
for key in (
|
||||
"adapter",
|
||||
"target",
|
||||
"thinking_level",
|
||||
"reasoning_effort",
|
||||
)
|
||||
if target.get(key) is not None
|
||||
},
|
||||
last_transition=transition,
|
||||
)
|
||||
budgets[self.key] = entry
|
||||
|
|
@ -1754,6 +1767,7 @@ def agent_spec_from_decision(decision: dict[str, Any]) -> AgentSpec:
|
|||
raise ExecutionDecisionError("selector selected가 object가 아니다")
|
||||
adapter, target = selected.get("adapter"), selected.get("target")
|
||||
thinking_level = selected.get("thinking_level")
|
||||
reasoning_effort = selected.get("reasoning_effort")
|
||||
local_pi = selected.get("selfcheck_required")
|
||||
execution_class = selected.get("execution_class")
|
||||
if (not isinstance(adapter, str) or not isinstance(target, str) or not target
|
||||
|
|
@ -1775,7 +1789,9 @@ def agent_spec_from_decision(decision: dict[str, Any]) -> AgentSpec:
|
|||
lane=decision["lane"],
|
||||
grade=decision["grade"],
|
||||
)
|
||||
canonical = selector.policy.canonical_target(adapter, target, thinking_level)
|
||||
canonical = selector.policy.canonical_target(
|
||||
adapter, target, thinking_level, reasoning_effort
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ExecutionDecisionError(f"selector policy validation 실패: {exc}") from exc
|
||||
if canonical is None or (
|
||||
|
|
@ -1784,10 +1800,15 @@ def agent_spec_from_decision(decision: dict[str, Any]) -> AgentSpec:
|
|||
):
|
||||
raise ExecutionDecisionError("selector selected가 canonical policy target이 아니다")
|
||||
initial_keys = {
|
||||
(item.adapter, item.target, item.thinking_level)
|
||||
(
|
||||
item.adapter,
|
||||
item.target,
|
||||
item.thinking_level,
|
||||
item.reasoning_effort,
|
||||
)
|
||||
for item in policy_targets
|
||||
}
|
||||
if (adapter, target, thinking_level) not in initial_keys:
|
||||
if (adapter, target, thinking_level, reasoning_effort) not in initial_keys:
|
||||
promotion_path = decision.get("promotion_path")
|
||||
if not isinstance(promotion_path, list) or len(promotion_path) < 2:
|
||||
raise ExecutionDecisionError("selector promotion path가 없다")
|
||||
|
|
@ -1801,6 +1822,7 @@ def agent_spec_from_decision(decision: dict[str, Any]) -> AgentSpec:
|
|||
entry.get("adapter"),
|
||||
entry.get("target"),
|
||||
entry.get("thinking_level"),
|
||||
entry.get("reasoning_effort"),
|
||||
)
|
||||
if resolved is None:
|
||||
raise ExecutionDecisionError(
|
||||
|
|
@ -1811,6 +1833,7 @@ def agent_spec_from_decision(decision: dict[str, Any]) -> AgentSpec:
|
|||
resolved_path[0].adapter,
|
||||
resolved_path[0].target,
|
||||
resolved_path[0].thinking_level,
|
||||
resolved_path[0].reasoning_effort,
|
||||
) not in initial_keys:
|
||||
raise ExecutionDecisionError("selector promotion path 시작 target이 잘못됐다")
|
||||
if any(
|
||||
|
|
@ -1831,11 +1854,13 @@ def agent_spec_from_decision(decision: dict[str, Any]) -> AgentSpec:
|
|||
local_pi=True,
|
||||
thinking_level=canonical.thinking_level,
|
||||
)
|
||||
if adapter not in {"agy", "claude", "claude-glm", "codex"} or local_pi:
|
||||
if adapter not in {"agy", "claude", "claude-glm", "codex", "opencode"} or local_pi:
|
||||
raise ExecutionDecisionError(f"selector adapter/schema가 유효하지 않다: {adapter!r}")
|
||||
model = target
|
||||
reasoning_effort = "high" if canonical == selector.policy.CODEX_TERRA_HIGH else None
|
||||
suffix = " high" if reasoning_effort == "high" else (
|
||||
reasoning_effort = canonical.reasoning_effort or (
|
||||
"high" if canonical == selector.policy.CODEX_TERRA_HIGH else None
|
||||
)
|
||||
suffix = f" {reasoning_effort}" if reasoning_effort is not None else (
|
||||
" xhigh" if adapter in {"claude", "claude-glm", "codex"} else ""
|
||||
)
|
||||
display = f"{adapter}/{target}{suffix}"
|
||||
|
|
@ -1868,6 +1893,7 @@ def _spec_from_completing_decision(decision: dict[str, Any]) -> AgentSpec:
|
|||
execution_class = selected.get("execution_class")
|
||||
selfcheck_required = selected.get("selfcheck_required")
|
||||
thinking_level = selected.get("thinking_level")
|
||||
reasoning_effort = selected.get("reasoning_effort")
|
||||
if not all(isinstance(value, str) and value for value in (adapter, target, execution_class)):
|
||||
raise ExecutionDecisionError(
|
||||
"completing decision selected의 adapter/target/execution_class는 빈 문자열이 아닌 string이어야 한다"
|
||||
|
|
@ -1915,7 +1941,7 @@ def _spec_from_completing_decision(decision: dict[str, Any]) -> AgentSpec:
|
|||
local_pi=True,
|
||||
thinking_level=thinking_level,
|
||||
)
|
||||
if adapter not in {"agy", "claude", "claude-glm", "codex"}:
|
||||
if adapter not in {"agy", "claude", "claude-glm", "codex", "opencode"}:
|
||||
raise ExecutionDecisionError(
|
||||
f"completing decision adapter가 유효하지 않다: {adapter!r}"
|
||||
)
|
||||
|
|
@ -1939,6 +1965,24 @@ def _spec_from_completing_decision(decision: dict[str, Any]) -> AgentSpec:
|
|||
local_pi=False,
|
||||
command_model="sonnet",
|
||||
)
|
||||
if adapter == "opencode":
|
||||
if target != "glm-5.2":
|
||||
raise ExecutionDecisionError(
|
||||
f"opencode completing decision target이 유효하지 않다: {target}"
|
||||
)
|
||||
if reasoning_effort not in {"medium", "high", "max"}:
|
||||
raise ExecutionDecisionError(
|
||||
"opencode completing decision reasoning_effort가 유효하지 않다: "
|
||||
f"{reasoning_effort!r}"
|
||||
)
|
||||
return AgentSpec(
|
||||
adapter,
|
||||
target,
|
||||
f"{adapter}/{target} {reasoning_effort}",
|
||||
local_pi=False,
|
||||
reasoning_effort=reasoning_effort,
|
||||
command_model="iop-glm/glm-5.2",
|
||||
)
|
||||
display = f"{adapter}/{target}"
|
||||
return AgentSpec(adapter, target, display, local_pi=False)
|
||||
|
||||
|
|
@ -2178,8 +2222,12 @@ def selector_evidence_lines(decision: dict[str, Any] | None) -> list[str]:
|
|||
rank = c.get("candidate_rank", "?")
|
||||
adapter = c.get("adapter", "?")
|
||||
target = c.get("target", "?")
|
||||
effort = c.get("reasoning_effort")
|
||||
elig = c.get("eligibility", "?")
|
||||
cand_strs.append(f"#{rank}:{adapter}/{target}({elig})")
|
||||
effort_suffix = f" {effort}" if effort else ""
|
||||
cand_strs.append(
|
||||
f"#{rank}:{adapter}/{target}{effort_suffix}({elig})"
|
||||
)
|
||||
|
||||
reasons = decision_info.get(
|
||||
"reason_codes", selected.get("reason_codes", [])
|
||||
|
|
@ -2843,6 +2891,13 @@ def terminal_diagnostic(cli: str, channel: str, line: str) -> str | None:
|
|||
return json.dumps(value, ensure_ascii=False)
|
||||
if event_type == "system" and subtype.startswith("error"):
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
if cli == "opencode" and event_type.lower() in {
|
||||
"error",
|
||||
"session.error",
|
||||
"request.failed",
|
||||
"turn.failed",
|
||||
}:
|
||||
return json.dumps(value.get("error", value), ensure_ascii=False)
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -3186,7 +3241,7 @@ def attempt_terminal_diagnostics(
|
|||
def promoted_spec(spec: AgentSpec, recovery_count: int) -> AgentSpec | None:
|
||||
if spec.cli == "agy":
|
||||
return AgentSpec("claude", "claude-opus-4-8", "claude/claude-opus-4-8 xhigh")
|
||||
if spec.cli in {"claude", "claude-glm"}:
|
||||
if spec.cli in {"claude", "claude-glm", "opencode"}:
|
||||
return AgentSpec(
|
||||
"codex",
|
||||
"gpt-5.6-terra",
|
||||
|
|
@ -3228,6 +3283,18 @@ def render_json_line(cli: str, line: str) -> tuple[list[str], str | None]:
|
|||
if value.get("type") == "result" and value.get("result"):
|
||||
rendered.extend(str(value["result"]).splitlines())
|
||||
session_id = session_id or value.get("session_id")
|
||||
elif cli == "opencode":
|
||||
part = value.get("part")
|
||||
if isinstance(part, dict):
|
||||
if part.get("type") == "text" and part.get("text"):
|
||||
rendered.extend(str(part["text"]).splitlines())
|
||||
elif part.get("type") == "tool" and part.get("tool"):
|
||||
rendered.append(f"tool={part['tool']}")
|
||||
session_id = (
|
||||
value.get("sessionID")
|
||||
or value.get("sessionId")
|
||||
or value.get("session_id")
|
||||
)
|
||||
return rendered, str(session_id) if session_id else None
|
||||
|
||||
|
||||
|
|
@ -3835,6 +3902,23 @@ def build_command(
|
|||
"--effort", str(effective_reasoning_effort(spec)),
|
||||
"--dangerously-skip-permissions", prompt,
|
||||
]
|
||||
if spec.cli == "opencode":
|
||||
return [
|
||||
"opencode",
|
||||
"run",
|
||||
"--format",
|
||||
"json",
|
||||
"--dir",
|
||||
str(workspace),
|
||||
"--agent",
|
||||
"build",
|
||||
"--model",
|
||||
spec.command_model or spec.model,
|
||||
"--variant",
|
||||
str(effective_reasoning_effort(spec)),
|
||||
"--auto",
|
||||
prompt,
|
||||
]
|
||||
if spec.cli == "agy":
|
||||
return [
|
||||
# `--print` consumes its immediately following argument as the prompt.
|
||||
|
|
@ -3956,7 +4040,11 @@ async def invoke(
|
|||
"agent_process_marker": process_marker,
|
||||
"plan_path": str(task.plan) if task.plan else None,
|
||||
"review_path": str(task.review) if task.review else None,
|
||||
"session_id": session_id if spec.cli in {"claude", "claude-glm", "pi"} else None,
|
||||
"session_id": (
|
||||
session_id
|
||||
if spec.cli in {"claude", "claude-glm", "opencode", "pi"}
|
||||
else None
|
||||
),
|
||||
"native_session_path": (
|
||||
str(pi_resume_session)
|
||||
if pi_resume_session is not None
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ KST = ZoneInfo("Asia/Seoul")
|
|||
VALID_STAGES = {"worker", "review"}
|
||||
VALID_LANES = {"local", "cloud"}
|
||||
VALID_PI_THINKING_LEVELS = frozenset({"low", "medium", "high"})
|
||||
VALID_REASONING_EFFORTS = frozenset({"medium", "high", "max", "xhigh"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
@ -23,6 +24,7 @@ class RouteTarget:
|
|||
execution_class: str
|
||||
selfcheck_required: bool
|
||||
thinking_level: str | None = None
|
||||
reasoning_effort: str | None = None
|
||||
command_model: str | None = None
|
||||
|
||||
|
||||
|
|
@ -46,6 +48,34 @@ AGY_GEMINI_HIGH = RouteTarget(
|
|||
"agy", "Gemini 3.6 Flash (High)", "cloud_model", False
|
||||
)
|
||||
PI_LAGUNA = RouteTarget("pi", "iop/laguna-s:2.1", "local_model", True)
|
||||
# OpenCode's GLM fallback always runs one reasoning step above the Gemini
|
||||
# variant it follows: Low -> medium, Medium -> high, High -> max.
|
||||
OPENCODE_GLM_MEDIUM = RouteTarget(
|
||||
"opencode",
|
||||
"glm-5.2",
|
||||
"cloud_model",
|
||||
False,
|
||||
reasoning_effort="medium",
|
||||
command_model="iop-glm/glm-5.2",
|
||||
)
|
||||
OPENCODE_GLM_HIGH = RouteTarget(
|
||||
"opencode",
|
||||
"glm-5.2",
|
||||
"cloud_model",
|
||||
False,
|
||||
reasoning_effort="high",
|
||||
command_model="iop-glm/glm-5.2",
|
||||
)
|
||||
OPENCODE_GLM_MAX = RouteTarget(
|
||||
"opencode",
|
||||
"glm-5.2",
|
||||
"cloud_model",
|
||||
False,
|
||||
reasoning_effort="max",
|
||||
command_model="iop-glm/glm-5.2",
|
||||
)
|
||||
# Retain the old Claude compatibility target only for persisted locator
|
||||
# recovery. New policy decisions never select it.
|
||||
CLAUDE_GLM = RouteTarget(
|
||||
"claude-glm",
|
||||
"glm-5.2",
|
||||
|
|
@ -73,6 +103,9 @@ CANONICAL_TARGETS = (
|
|||
AGY_GEMINI_MEDIUM,
|
||||
AGY_GEMINI_HIGH,
|
||||
PI_LAGUNA,
|
||||
OPENCODE_GLM_MEDIUM,
|
||||
OPENCODE_GLM_HIGH,
|
||||
OPENCODE_GLM_MAX,
|
||||
CLAUDE_GLM,
|
||||
CLAUDE_OPUS,
|
||||
CLAUDE_HAIKU_XHIGH,
|
||||
|
|
@ -83,7 +116,10 @@ CANONICAL_TARGETS = (
|
|||
|
||||
|
||||
def canonical_target(
|
||||
adapter: str, target: str, thinking_level: str | None = None,
|
||||
adapter: str,
|
||||
target: str,
|
||||
thinking_level: str | None = None,
|
||||
reasoning_effort: str | None = None,
|
||||
) -> RouteTarget | None:
|
||||
"""Resolve one policy-owned adapter + target + thinking identity."""
|
||||
return next(
|
||||
|
|
@ -94,6 +130,7 @@ def canonical_target(
|
|||
candidate.adapter == adapter
|
||||
and candidate.target == target
|
||||
and candidate.thinking_level == thinking_level
|
||||
and candidate.reasoning_effort == reasoning_effort
|
||||
)
|
||||
),
|
||||
None,
|
||||
|
|
@ -181,11 +218,19 @@ def select_policy(
|
|||
if time_window == "kst-day-[07:00,23:00)":
|
||||
rule_id = "worker-local-g07-g08-kst-day"
|
||||
reason_code = "kst_day_gemini_high"
|
||||
candidates = (AGY_GEMINI_HIGH, CLAUDE_GLM, CODEX_TERRA_HIGH)
|
||||
candidates = (
|
||||
AGY_GEMINI_HIGH,
|
||||
OPENCODE_GLM_MAX,
|
||||
CODEX_TERRA_HIGH,
|
||||
)
|
||||
else:
|
||||
rule_id = "worker-local-g07-g08-kst-night"
|
||||
reason_code = "kst_night_gemini_high"
|
||||
candidates = (AGY_GEMINI_HIGH, CLAUDE_GLM, CODEX_TERRA_HIGH)
|
||||
candidates = (
|
||||
AGY_GEMINI_HIGH,
|
||||
OPENCODE_GLM_MAX,
|
||||
CODEX_TERRA_HIGH,
|
||||
)
|
||||
return PolicyDecision(
|
||||
rule_id=rule_id,
|
||||
policy_priority=20,
|
||||
|
|
@ -205,17 +250,25 @@ def select_policy(
|
|||
candidates = (
|
||||
CODEX_SPARK_XHIGH,
|
||||
AGY_GEMINI_LOW,
|
||||
CLAUDE_GLM,
|
||||
OPENCODE_GLM_MEDIUM,
|
||||
CODEX_TERRA_HIGH,
|
||||
)
|
||||
rule_id = "worker-cloud-g01-g02"
|
||||
reason_code = "cloud_spark_priority_grade"
|
||||
elif grade <= 4:
|
||||
candidates = (AGY_GEMINI_MEDIUM, CLAUDE_GLM, CODEX_TERRA_HIGH)
|
||||
candidates = (
|
||||
AGY_GEMINI_MEDIUM,
|
||||
OPENCODE_GLM_HIGH,
|
||||
CODEX_TERRA_HIGH,
|
||||
)
|
||||
rule_id = "worker-cloud-g03-g04"
|
||||
reason_code = "cloud_gemini_medium_grade"
|
||||
elif grade <= 6:
|
||||
candidates = (AGY_GEMINI_HIGH, CLAUDE_GLM, CODEX_TERRA_HIGH)
|
||||
candidates = (
|
||||
AGY_GEMINI_HIGH,
|
||||
OPENCODE_GLM_MAX,
|
||||
CODEX_TERRA_HIGH,
|
||||
)
|
||||
rule_id = "worker-cloud-g05-g06"
|
||||
reason_code = "cloud_gemini_high_grade"
|
||||
elif grade <= 8:
|
||||
|
|
|
|||
|
|
@ -191,6 +191,16 @@ def _validate_prior_selected(selected: object) -> None:
|
|||
"prior_decision.selected.thinking_level must be null or one of "
|
||||
f"{sorted(policy.VALID_PI_THINKING_LEVELS)}",
|
||||
)
|
||||
reasoning_effort = selected.get("reasoning_effort")
|
||||
if reasoning_effort is not None and (
|
||||
not isinstance(reasoning_effort, str)
|
||||
or reasoning_effort not in policy.VALID_REASONING_EFFORTS
|
||||
):
|
||||
raise SelectorInputError(
|
||||
code,
|
||||
"prior_decision.selected.reasoning_effort must be null or one of "
|
||||
f"{sorted(policy.VALID_REASONING_EFFORTS)}",
|
||||
)
|
||||
|
||||
|
||||
def _require_non_empty_string(
|
||||
|
|
@ -272,6 +282,16 @@ def _validate_prior_candidates(candidates: object) -> None:
|
|||
f"{prefix}.thinking_level must be null or one of "
|
||||
f"{sorted(policy.VALID_PI_THINKING_LEVELS)}",
|
||||
)
|
||||
reasoning_effort = entry.get("reasoning_effort")
|
||||
if reasoning_effort is not None and (
|
||||
not isinstance(reasoning_effort, str)
|
||||
or reasoning_effort not in policy.VALID_REASONING_EFFORTS
|
||||
):
|
||||
raise SelectorInputError(
|
||||
code,
|
||||
f"{prefix}.reasoning_effort must be null or one of "
|
||||
f"{sorted(policy.VALID_REASONING_EFFORTS)}",
|
||||
)
|
||||
_require_string_enum(entry, "quota_mode", _VALID_QUOTA_MODES, prefix, code)
|
||||
_require_string_enum(
|
||||
entry, "quota_status", _VALID_QUOTA_STATUSES, prefix, code
|
||||
|
|
@ -856,6 +876,8 @@ def _initial(
|
|||
}
|
||||
if target.thinking_level is not None:
|
||||
candidate["thinking_level"] = target.thinking_level
|
||||
if target.reasoning_effort is not None:
|
||||
candidate["reasoning_effort"] = target.reasoning_effort
|
||||
candidates.append(candidate)
|
||||
if eligible and selected is None:
|
||||
selected = target
|
||||
|
|
@ -873,6 +895,8 @@ def _initial(
|
|||
}
|
||||
if selected.thinking_level is not None:
|
||||
selected_fields["thinking_level"] = selected.thinking_level
|
||||
if selected.reasoning_effort is not None:
|
||||
selected_fields["reasoning_effort"] = selected.reasoning_effort
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"work_unit_id": work_unit_id,
|
||||
|
|
@ -916,9 +940,11 @@ def _validate_selected_and_used_history(
|
|||
selected.get("adapter"),
|
||||
selected.get("target"),
|
||||
selected.get("thinking_level"),
|
||||
selected.get("reasoning_effort"),
|
||||
)
|
||||
canon_keys_list = [
|
||||
(c.adapter, c.target, c.thinking_level) for c in canonical_targets
|
||||
(c.adapter, c.target, c.thinking_level, c.reasoning_effort)
|
||||
for c in canonical_targets
|
||||
]
|
||||
canon_keys_set = set(canon_keys_list)
|
||||
|
||||
|
|
@ -954,6 +980,7 @@ def _validate_selected_and_used_history(
|
|||
entry.get("adapter"),
|
||||
entry.get("target"),
|
||||
entry.get("thinking_level"),
|
||||
entry.get("reasoning_effort"),
|
||||
)
|
||||
if target is None:
|
||||
raise SelectorInputError(
|
||||
|
|
@ -964,6 +991,7 @@ def _validate_selected_and_used_history(
|
|||
path_targets[0].adapter,
|
||||
path_targets[0].target,
|
||||
path_targets[0].thinking_level,
|
||||
path_targets[0].reasoning_effort,
|
||||
) not in canon_keys_set:
|
||||
raise SelectorInputError(
|
||||
code, "promotion_path must begin at the initial policy target"
|
||||
|
|
@ -998,6 +1026,7 @@ def _validate_selected_and_used_history(
|
|||
entry.get("adapter"),
|
||||
entry.get("target"),
|
||||
entry.get("thinking_level"),
|
||||
entry.get("reasoning_effort"),
|
||||
)
|
||||
if u_key not in canon_keys_set:
|
||||
raise SelectorInputError(
|
||||
|
|
@ -1029,6 +1058,7 @@ def _validate_selected_and_used_history(
|
|||
c.get("adapter"),
|
||||
c.get("target"),
|
||||
c.get("thinking_level"),
|
||||
c.get("reasoning_effort"),
|
||||
)
|
||||
for c in prior_cands
|
||||
if isinstance(c, dict) and c.get("eligibility") == "eligible"
|
||||
|
|
@ -1118,6 +1148,7 @@ def _validate_prior_candidate_identity(
|
|||
or p_cand.get("execution_class") != c_target.execution_class
|
||||
or p_cand.get("selfcheck_required") != c_target.selfcheck_required
|
||||
or p_cand.get("thinking_level") != c_target.thinking_level
|
||||
or p_cand.get("reasoning_effort") != c_target.reasoning_effort
|
||||
):
|
||||
raise SelectorInputError(
|
||||
code,
|
||||
|
|
@ -1157,7 +1188,7 @@ def _resume(
|
|||
selected = prior_decision["selected"]
|
||||
decision = dict(prior_decision["decision"])
|
||||
decision["pinned"] = True
|
||||
target_ref = {"adapter": selected["adapter"], "target": selected["target"]}
|
||||
target_ref = _target_ref(selected)
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"work_unit_id": work_unit_id,
|
||||
|
|
@ -1183,6 +1214,8 @@ def _target_ref(candidate: dict) -> dict:
|
|||
ref = {"adapter": candidate["adapter"], "target": candidate["target"]}
|
||||
if candidate.get("thinking_level") is not None:
|
||||
ref["thinking_level"] = candidate["thinking_level"]
|
||||
if candidate.get("reasoning_effort") is not None:
|
||||
ref["reasoning_effort"] = candidate["reasoning_effort"]
|
||||
return ref
|
||||
|
||||
|
||||
|
|
@ -1209,9 +1242,22 @@ def _validate_used_candidates(value: object) -> list[dict]:
|
|||
f"{index}].thinking_level must be null or one of "
|
||||
f"{sorted(policy.VALID_PI_THINKING_LEVELS)}",
|
||||
)
|
||||
reasoning_effort = entry.get("reasoning_effort")
|
||||
if reasoning_effort is not None and (
|
||||
not isinstance(reasoning_effort, str)
|
||||
or reasoning_effort not in policy.VALID_REASONING_EFFORTS
|
||||
):
|
||||
raise SelectorInputError(
|
||||
"malformed_prior_decision",
|
||||
"used_candidates["
|
||||
f"{index}].reasoning_effort must be null or one of "
|
||||
f"{sorted(policy.VALID_REASONING_EFFORTS)}",
|
||||
)
|
||||
ref = {"adapter": adapter, "target": target}
|
||||
if thinking_level is not None:
|
||||
ref["thinking_level"] = thinking_level
|
||||
if reasoning_effort is not None:
|
||||
ref["reasoning_effort"] = reasoning_effort
|
||||
refs.append(ref)
|
||||
return refs
|
||||
|
||||
|
|
@ -1238,7 +1284,15 @@ def _failover(
|
|||
used = _validate_used_candidates(prior.get("used_candidates"))
|
||||
if previous not in used:
|
||||
used.append(previous)
|
||||
used_set = {(entry["adapter"], entry["target"]) for entry in used}
|
||||
used_set = {
|
||||
(
|
||||
entry["adapter"],
|
||||
entry["target"],
|
||||
entry.get("thinking_level"),
|
||||
entry.get("reasoning_effort"),
|
||||
)
|
||||
for entry in used
|
||||
}
|
||||
selected_candidate = None
|
||||
selected_probed_snapshot = None
|
||||
candidates = []
|
||||
|
|
@ -1271,7 +1325,12 @@ def _failover(
|
|||
current["eligibility"] = "ineligible" if status == "exhausted" else "eligible"
|
||||
current["rejection_reason"] = "quota_exhausted" if status == "exhausted" else None
|
||||
candidates.append(current)
|
||||
key = (current["adapter"], current["target"])
|
||||
key = (
|
||||
current["adapter"],
|
||||
current["target"],
|
||||
current.get("thinking_level"),
|
||||
current.get("reasoning_effort"),
|
||||
)
|
||||
if index > previous_index and key not in used_set and current["eligibility"] == "eligible" and selected_candidate is None:
|
||||
selected_candidate = current
|
||||
selected_probed_snapshot = current_snapshot
|
||||
|
|
@ -1288,6 +1347,8 @@ def _failover(
|
|||
}
|
||||
if selected_candidate.get("thinking_level") is not None:
|
||||
selected["thinking_level"] = selected_candidate["thinking_level"]
|
||||
if selected_candidate.get("reasoning_effort") is not None:
|
||||
selected["reasoning_effort"] = selected_candidate["reasoning_effort"]
|
||||
next_target = _target_ref(selected)
|
||||
used.append(next_target)
|
||||
decision = dict(prior["decision"])
|
||||
|
|
@ -1359,6 +1420,7 @@ def _promotion(
|
|||
prior["selected"]["adapter"],
|
||||
prior["selected"]["target"],
|
||||
prior["selected"].get("thinking_level"),
|
||||
prior["selected"].get("reasoning_effort"),
|
||||
)
|
||||
promoted = policy.promotion_target(current) if current is not None else None
|
||||
if promoted is None:
|
||||
|
|
@ -1371,6 +1433,7 @@ def _promotion(
|
|||
"adapter": current.adapter,
|
||||
"target": current.target,
|
||||
"thinking_level": current.thinking_level,
|
||||
"reasoning_effort": current.reasoning_effort,
|
||||
}
|
||||
)
|
||||
next_target = _target_ref(
|
||||
|
|
@ -1378,6 +1441,7 @@ def _promotion(
|
|||
"adapter": promoted.adapter,
|
||||
"target": promoted.target,
|
||||
"thinking_level": promoted.thinking_level,
|
||||
"reasoning_effort": promoted.reasoning_effort,
|
||||
}
|
||||
)
|
||||
promotion_path = list(prior.get("promotion_path", [previous_target]))
|
||||
|
|
@ -1405,6 +1469,11 @@ def _promotion(
|
|||
if promoted.thinking_level is not None
|
||||
else {}
|
||||
),
|
||||
**(
|
||||
{"reasoning_effort": promoted.reasoning_effort}
|
||||
if promoted.reasoning_effort is not None
|
||||
else {}
|
||||
),
|
||||
},
|
||||
"candidates": prior["candidates"],
|
||||
"decision": decision,
|
||||
|
|
|
|||
|
|
@ -142,6 +142,36 @@ class CommandConstructionTest(unittest.TestCase):
|
|||
effort_index = command.index("--effort")
|
||||
self.assertEqual(command[effort_index + 1], "xhigh")
|
||||
|
||||
def test_opencode_glm_uses_provider_model_and_requested_variant(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
for effort in ("medium", "high", "max"):
|
||||
with self.subTest(effort=effort):
|
||||
command = dispatch.build_command(
|
||||
dispatch.AgentSpec(
|
||||
"opencode",
|
||||
"glm-5.2",
|
||||
f"opencode/glm-5.2 {effort}",
|
||||
reasoning_effort=effort,
|
||||
command_model="iop-glm/glm-5.2",
|
||||
),
|
||||
"Implement the active plan.",
|
||||
workspace,
|
||||
"test-session",
|
||||
workspace / "attempt",
|
||||
)
|
||||
|
||||
self.assertEqual(command[:2], ["opencode", "run"])
|
||||
self.assertEqual(command[command.index("--format") + 1], "json")
|
||||
self.assertEqual(command[command.index("--dir") + 1], str(workspace))
|
||||
self.assertEqual(command[command.index("--agent") + 1], "build")
|
||||
self.assertEqual(
|
||||
command[command.index("--model") + 1],
|
||||
"iop-glm/glm-5.2",
|
||||
)
|
||||
self.assertEqual(command[command.index("--variant") + 1], effort)
|
||||
self.assertIn("--auto", command)
|
||||
|
||||
|
||||
class TaskStageTest(unittest.TestCase):
|
||||
def make_task(self, root: Path, review_text: str = ""):
|
||||
|
|
@ -3346,6 +3376,30 @@ class ReviewControlTest(unittest.TestCase):
|
|||
dispatch.terminal_diagnostic("agy", "stdout", event)
|
||||
)
|
||||
|
||||
def test_opencode_error_event_is_terminal_diagnostic(self):
|
||||
event = json.dumps(
|
||||
{
|
||||
"type": "error",
|
||||
"sessionID": "session-opencode",
|
||||
"error": {"statusCode": 429, "message": "quota exhausted"},
|
||||
}
|
||||
)
|
||||
diagnostic = dispatch.terminal_diagnostic("opencode", "stdout", event)
|
||||
self.assertIsNotNone(diagnostic)
|
||||
self.assertIn("quota exhausted", diagnostic)
|
||||
|
||||
def test_opencode_json_event_preserves_text_and_session(self):
|
||||
event = json.dumps(
|
||||
{
|
||||
"type": "text",
|
||||
"sessionID": "session-opencode",
|
||||
"part": {"type": "text", "text": "first\nsecond"},
|
||||
}
|
||||
)
|
||||
rendered, session_id = dispatch.render_json_line("opencode", event)
|
||||
self.assertEqual(rendered, ["first", "second"])
|
||||
self.assertEqual(session_id, "session-opencode")
|
||||
|
||||
def test_agy_log_diagnostic_requires_strong_quota_evidence(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
log = Path(temporary) / "agy-cli.log"
|
||||
|
|
@ -8503,10 +8557,16 @@ class DynamicFailoverBudgetTest(unittest.TestCase):
|
|||
workspace = Path(temporary)
|
||||
(workspace / ".git").mkdir()
|
||||
task = self.make_task(workspace)
|
||||
gemini_spec = dispatch.AgentSpec("agy", "Gemini 3.6 Flash (Medium)", "agy/Gemini 3.6 Flash (Medium)")
|
||||
gemini_spec = dispatch.AgentSpec("agy", "Gemini 3.6 Flash (High)", "agy/Gemini 3.6 Flash (High)")
|
||||
locator_gemini = self.make_attempt_locator(workspace, task, gemini_spec)
|
||||
laguna_spec = dispatch.AgentSpec("pi", "laguna-s:2.1", "pi/iop/laguna-s:2.1", local_pi=True)
|
||||
locator_laguna = self.make_attempt_locator(workspace, task, laguna_spec)
|
||||
glm_spec = dispatch.AgentSpec(
|
||||
"opencode",
|
||||
"glm-5.2",
|
||||
"opencode/glm-5.2 max",
|
||||
reasoning_effort="max",
|
||||
command_model="iop-glm/glm-5.2",
|
||||
)
|
||||
locator_glm = self.make_attempt_locator(workspace, task, glm_spec)
|
||||
|
||||
initial_store = dispatch.StateStore(workspace)
|
||||
try:
|
||||
|
|
@ -8523,7 +8583,7 @@ class DynamicFailoverBudgetTest(unittest.TestCase):
|
|||
if spec.cli == "agy":
|
||||
return (1, "provider-quota", locator_gemini)
|
||||
if len(invoked_specs) == 2:
|
||||
return (1, "generic-error", locator_laguna)
|
||||
return (1, "generic-error", locator_glm)
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
with (
|
||||
|
|
@ -8539,7 +8599,7 @@ class DynamicFailoverBudgetTest(unittest.TestCase):
|
|||
decisions1 = state1["execution_decisions"]
|
||||
history1 = state1["route_transition_history"]
|
||||
worker_budget1 = dispatch.StageFailureBudget.from_decision(store, task, decisions1["worker"])
|
||||
self.assertEqual([s.cli for s in invoked_specs[:2]], ["agy", "claude-glm"])
|
||||
self.assertEqual([s.cli for s in invoked_specs[:2]], ["agy", "opencode"])
|
||||
self.assertEqual([h["transition"] for h in history1], ["initial", "provider-quota"])
|
||||
self.assertEqual(worker_budget1.count(), 2)
|
||||
finally:
|
||||
|
|
@ -8563,11 +8623,11 @@ class DynamicFailoverBudgetTest(unittest.TestCase):
|
|||
mock.patch.object(dispatch.asyncio, "sleep", new=mock.AsyncMock()),
|
||||
):
|
||||
success, final_loc = asyncio.run(
|
||||
dispatch.run_escalating(workspace, reopened, task, "worker", laguna_spec)
|
||||
dispatch.run_escalating(workspace, reopened, task, "worker", glm_spec)
|
||||
)
|
||||
self.assertFalse(success)
|
||||
self.assertEqual(invoke.await_count, 8)
|
||||
self.assertTrue(all(call.args[4] == laguna_spec for call in invoke.await_args_list))
|
||||
self.assertTrue(all(call.args[4] == glm_spec for call in invoke.await_args_list))
|
||||
self.assertEqual(final_loc, locators[-1])
|
||||
self.assertIn("recovery failure limit exhausted", reopened.task_state(task)["blocked"])
|
||||
|
||||
|
|
@ -8578,7 +8638,14 @@ class DynamicFailoverBudgetTest(unittest.TestCase):
|
|||
self.assertEqual(worker_budget2.count(), 10)
|
||||
self.assertEqual(review_budget2.count(), 0)
|
||||
raw_entry = state2.get("stage_failure_budgets", {}).get(worker_budget2.key, {})
|
||||
self.assertEqual(raw_entry.get("last_target"), {"adapter": "claude-glm", "target": "glm-5.2"})
|
||||
self.assertEqual(
|
||||
raw_entry.get("last_target"),
|
||||
{
|
||||
"adapter": "opencode",
|
||||
"target": "glm-5.2",
|
||||
"reasoning_effort": "max",
|
||||
},
|
||||
)
|
||||
self.assertEqual(raw_entry.get("last_transition"), "provider-quota")
|
||||
self.assertEqual(state2["execution_decisions"], decisions1)
|
||||
self.assertEqual([h["transition"] for h in state2["route_transition_history"]], ["initial", "provider-quota"])
|
||||
|
|
@ -8684,7 +8751,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
locator.write_text(json.dumps(record), encoding="utf-8")
|
||||
return locator
|
||||
|
||||
async def test_cloud_g01_g02_quota_failover_runs_spark_gemini_glm_low(self):
|
||||
async def test_cloud_g01_g02_quota_failover_runs_spark_gemini_glm_medium(self):
|
||||
daytime = datetime(
|
||||
2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9))
|
||||
)
|
||||
|
|
@ -8733,11 +8800,12 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
"Gemini 3.6 Flash (Low)",
|
||||
"agy/Gemini 3.6 Flash (Low)",
|
||||
),
|
||||
"claude-glm": dispatch.AgentSpec(
|
||||
"claude-glm",
|
||||
"opencode": dispatch.AgentSpec(
|
||||
"opencode",
|
||||
"glm-5.2",
|
||||
"claude-glm/glm-5.2 xhigh",
|
||||
command_model="sonnet",
|
||||
"opencode/glm-5.2 medium",
|
||||
reasoning_effort="medium",
|
||||
command_model="iop-glm/glm-5.2",
|
||||
),
|
||||
}
|
||||
locators = {
|
||||
|
|
@ -8749,7 +8817,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
async def mock_invoke(*args, **kwargs):
|
||||
spec = args[4]
|
||||
invoked_specs.append(spec)
|
||||
if spec.cli == "claude-glm":
|
||||
if spec.cli == "opencode":
|
||||
return 0, None, locators[spec.cli]
|
||||
return 1, "provider-quota", locators[spec.cli]
|
||||
|
||||
|
|
@ -8770,13 +8838,13 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(final_locator, locators["claude-glm"])
|
||||
self.assertEqual(final_locator, locators["opencode"])
|
||||
self.assertEqual(
|
||||
[(spec.cli, spec.model) for spec in invoked_specs],
|
||||
[
|
||||
("codex", "gpt-5.3-codex-spark"),
|
||||
("agy", "Gemini 3.6 Flash (Low)"),
|
||||
("claude-glm", "glm-5.2"),
|
||||
("opencode", "glm-5.2"),
|
||||
],
|
||||
)
|
||||
decision = store.task_state(task)["execution_decisions"]["worker"]
|
||||
|
|
@ -8785,7 +8853,11 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
[
|
||||
{"adapter": "codex", "target": "gpt-5.3-codex-spark"},
|
||||
{"adapter": "agy", "target": "Gemini 3.6 Flash (Low)"},
|
||||
{"adapter": "claude-glm", "target": "glm-5.2"},
|
||||
{
|
||||
"adapter": "opencode",
|
||||
"target": "glm-5.2",
|
||||
"reasoning_effort": "medium",
|
||||
},
|
||||
],
|
||||
)
|
||||
finally:
|
||||
|
|
@ -8864,7 +8936,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
finally:
|
||||
store.close()
|
||||
|
||||
async def test_day_gemini_zero_exit_quota_continues_on_claude_glm_with_logical_context(self):
|
||||
async def test_day_gemini_zero_exit_quota_continues_on_opencode_glm_with_logical_context(self):
|
||||
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
|
|
@ -8872,7 +8944,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
task = self.make_task(workspace)
|
||||
store = dispatch.StateStore(workspace)
|
||||
try:
|
||||
gemini_spec = dispatch.AgentSpec("agy", "Gemini 3.6 Flash (Medium)", "agy/Gemini 3.6 Flash (Medium)")
|
||||
gemini_spec = dispatch.AgentSpec("agy", "Gemini 3.6 Flash (High)", "agy/Gemini 3.6 Flash (High)")
|
||||
locator = self.make_attempt_locator(workspace, task, gemini_spec)
|
||||
invoked_specs = []
|
||||
invoked_prompts = []
|
||||
|
|
@ -8896,7 +8968,8 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
self.assertTrue(success)
|
||||
self.assertEqual(len(invoked_specs), 2)
|
||||
self.assertEqual(invoked_specs[0].cli, "agy")
|
||||
self.assertEqual(invoked_specs[1].cli, "claude-glm")
|
||||
self.assertEqual(invoked_specs[1].cli, "opencode")
|
||||
self.assertEqual(invoked_specs[1].reasoning_effort, "max")
|
||||
self.assertFalse(invoked_specs[1].local_pi)
|
||||
continuation_prompt = invoked_prompts[1]
|
||||
self.assertIn(str(task.plan.resolve()), continuation_prompt)
|
||||
|
|
@ -8906,7 +8979,8 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
self.assertIn(str((locator.parent / "normalized-output.log").resolve()), continuation_prompt)
|
||||
state = store.task_state(task)
|
||||
decisions = state["execution_decisions"]["worker"]
|
||||
self.assertEqual(decisions["selected"]["adapter"], "claude-glm")
|
||||
self.assertEqual(decisions["selected"]["adapter"], "opencode")
|
||||
self.assertEqual(decisions["selected"]["reasoning_effort"], "max")
|
||||
self.assertEqual(decisions["transition"]["trigger"], "provider-quota")
|
||||
finally:
|
||||
store.close()
|
||||
|
|
@ -8996,7 +9070,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
finally:
|
||||
store.close()
|
||||
|
||||
async def test_cloud_agy_quota_failover_commits_glm_high(self):
|
||||
async def test_cloud_agy_quota_failover_commits_glm_max(self):
|
||||
daytime = datetime(
|
||||
2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9))
|
||||
)
|
||||
|
|
@ -9012,10 +9086,11 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
"agy/Gemini 3.6 Flash (High)",
|
||||
)
|
||||
glm_spec = dispatch.AgentSpec(
|
||||
"claude-glm",
|
||||
"opencode",
|
||||
"glm-5.2",
|
||||
"claude-glm/glm-5.2 xhigh",
|
||||
command_model="sonnet",
|
||||
"opencode/glm-5.2 max",
|
||||
reasoning_effort="max",
|
||||
command_model="iop-glm/glm-5.2",
|
||||
)
|
||||
locators = {
|
||||
spec.cli: self.make_attempt_locator(workspace, task, spec)
|
||||
|
|
@ -9037,7 +9112,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
store, task, decision
|
||||
)
|
||||
transition_budget_counts.append(budget.count())
|
||||
return (0, None, locators["claude-glm"])
|
||||
return (0, None, locators["opencode"])
|
||||
|
||||
with (
|
||||
mock.patch.object(dispatch, "invoke", new=mock_invoke),
|
||||
|
|
@ -9053,7 +9128,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
)
|
||||
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(final_locator, locators["claude-glm"])
|
||||
self.assertEqual(final_locator, locators["opencode"])
|
||||
self.assertEqual(invoked_specs, [agy_spec, glm_spec])
|
||||
self.assertEqual(transition_budget_counts, [1])
|
||||
self.assertIn(
|
||||
|
|
@ -9069,8 +9144,9 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
"target": "Gemini 3.6 Flash (High)",
|
||||
},
|
||||
{
|
||||
"adapter": "claude-glm",
|
||||
"adapter": "opencode",
|
||||
"target": "glm-5.2",
|
||||
"reasoning_effort": "max",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
|
@ -9087,7 +9163,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
finally:
|
||||
store.close()
|
||||
|
||||
async def test_night_gemini_quota_fails_over_to_glm_high(self):
|
||||
async def test_night_gemini_quota_fails_over_to_glm_max(self):
|
||||
nighttime = datetime(2026, 7, 26, 1, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
|
|
@ -9097,8 +9173,9 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
try:
|
||||
gemini_spec = dispatch.AgentSpec("agy", "Gemini 3.6 Flash (High)", "agy/Gemini 3.6 Flash (High)")
|
||||
glm_spec = dispatch.AgentSpec(
|
||||
"claude-glm", "glm-5.2", "claude-glm/glm-5.2 xhigh",
|
||||
command_model="sonnet",
|
||||
"opencode", "glm-5.2", "opencode/glm-5.2 max",
|
||||
reasoning_effort="max",
|
||||
command_model="iop-glm/glm-5.2",
|
||||
)
|
||||
locator = self.make_attempt_locator(workspace, task, gemini_spec)
|
||||
invoked_specs = []
|
||||
|
|
@ -9122,14 +9199,15 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
self.assertEqual(invoked_specs, [gemini_spec, glm_spec])
|
||||
state = store.task_state(task)
|
||||
decisions = state["execution_decisions"]["worker"]
|
||||
self.assertEqual(decisions["selected"]["adapter"], "claude-glm")
|
||||
self.assertEqual(decisions["selected"]["adapter"], "opencode")
|
||||
self.assertEqual(decisions["selected"]["target"], "glm-5.2")
|
||||
self.assertEqual(decisions["selected"]["reasoning_effort"], "max")
|
||||
self.assertNotIn("thinking_level", decisions["selected"])
|
||||
self.assertEqual(decisions["transition"]["trigger"], "provider-quota")
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
async def test_night_gemini_quota_initially_exhausted_selects_glm_high(self):
|
||||
async def test_night_gemini_quota_initially_exhausted_selects_glm_max(self):
|
||||
nighttime = datetime(2026, 7, 26, 1, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
|
|
@ -9138,8 +9216,9 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
store = dispatch.StateStore(workspace)
|
||||
try:
|
||||
glm_spec = dispatch.AgentSpec(
|
||||
"claude-glm", "glm-5.2", "claude-glm/glm-5.2 xhigh",
|
||||
command_model="sonnet",
|
||||
"opencode", "glm-5.2", "opencode/glm-5.2 max",
|
||||
reasoning_effort="max",
|
||||
command_model="iop-glm/glm-5.2",
|
||||
)
|
||||
locator = self.make_attempt_locator(workspace, task, glm_spec)
|
||||
invoked_specs = []
|
||||
|
|
@ -9195,8 +9274,9 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
)
|
||||
|
||||
glm_spec = dispatch.AgentSpec(
|
||||
"claude-glm", "glm-5.2", "claude-glm/glm-5.2 xhigh",
|
||||
command_model="sonnet",
|
||||
"opencode", "glm-5.2", "opencode/glm-5.2 max",
|
||||
reasoning_effort="max",
|
||||
command_model="iop-glm/glm-5.2",
|
||||
)
|
||||
decision, spec = dispatch.persisted_execution_decision(store, task, stage="worker", evaluated_at=daytime)
|
||||
self.assertEqual(spec, glm_spec)
|
||||
|
|
@ -9219,7 +9299,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
async def mock_invoke(*args, **kwargs):
|
||||
spec = args[4]
|
||||
invoked_specs.append(spec)
|
||||
if spec.cli == "claude-glm":
|
||||
if spec.cli == "opencode":
|
||||
return (1, "provider-quota", locator)
|
||||
return (0, None, locator)
|
||||
|
||||
|
|
@ -9231,7 +9311,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
|
||||
self.assertTrue(success)
|
||||
self.assertEqual(len(invoked_specs), 2)
|
||||
self.assertEqual(invoked_specs[0].cli, "claude-glm")
|
||||
self.assertEqual(invoked_specs[0].cli, "opencode")
|
||||
self.assertEqual(invoked_specs[1].cli, "codex")
|
||||
self.assertEqual(invoked_specs[1].model, "gpt-5.6-terra")
|
||||
|
||||
|
|
@ -9360,8 +9440,8 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
finally:
|
||||
store.close()
|
||||
|
||||
async def test_cloud_g05_g06_gemini_quota_fails_over_to_glm_high(self):
|
||||
"""Cloud G05–G06 sends qualified Gemini failures to Claude GLM 5.2."""
|
||||
async def test_cloud_g05_g06_gemini_quota_fails_over_to_glm_max(self):
|
||||
"""Cloud G05–G06 sends qualified Gemini failures to OpenCode GLM max."""
|
||||
daytime = datetime(
|
||||
2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9))
|
||||
)
|
||||
|
|
@ -9375,8 +9455,9 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
"agy", "Gemini 3.6 Flash (High)", "agy/Gemini 3.6 Flash (High)"
|
||||
)
|
||||
glm_spec = dispatch.AgentSpec(
|
||||
"claude-glm", "glm-5.2", "claude-glm/glm-5.2 xhigh",
|
||||
command_model="sonnet",
|
||||
"opencode", "glm-5.2", "opencode/glm-5.2 max",
|
||||
reasoning_effort="max",
|
||||
command_model="iop-glm/glm-5.2",
|
||||
)
|
||||
loc_agy = self.make_attempt_locator(workspace, task, agy_spec)
|
||||
loc_glm = self.make_attempt_locator(workspace, task, glm_spec)
|
||||
|
|
@ -9403,7 +9484,7 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
self.assertTrue(success)
|
||||
self.assertEqual(
|
||||
[s.cli for s in invoked_specs],
|
||||
["agy", "claude-glm"],
|
||||
["agy", "opencode"],
|
||||
)
|
||||
self.assertEqual(invoked_specs[1], glm_spec)
|
||||
|
||||
|
|
@ -9413,12 +9494,16 @@ class DispatcherCanonicalFailoverIntegrationTest(unittest.IsolatedAsyncioTestCas
|
|||
decision["used_candidates"],
|
||||
[
|
||||
{"adapter": "agy", "target": "Gemini 3.6 Flash (High)"},
|
||||
{"adapter": "claude-glm", "target": "glm-5.2"},
|
||||
{
|
||||
"adapter": "opencode",
|
||||
"target": "glm-5.2",
|
||||
"reasoning_effort": "max",
|
||||
},
|
||||
],
|
||||
)
|
||||
transitions = [h["transition"] for h in state["route_transition_history"]]
|
||||
self.assertIn("provider-quota", transitions)
|
||||
self.assertEqual(decision["selected"]["adapter"], "claude-glm")
|
||||
self.assertEqual(decision["selected"]["adapter"], "opencode")
|
||||
self.assertEqual(decision["selected"]["target"], "glm-5.2")
|
||||
self.assertNotIn("thinking_level", decision["selected"])
|
||||
finally:
|
||||
|
|
@ -9734,20 +9819,21 @@ class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase):
|
|||
task = self.make_task(workspace, lane="local", grade=8)
|
||||
store = dispatch.StateStore(workspace)
|
||||
try:
|
||||
# 1. Initial decision local G08 -> Gemini High, Claude GLM 5.2, Terra High.
|
||||
# 1. Initial decision local G08 -> Gemini High, OpenCode GLM max, Terra High.
|
||||
dec1, spec1 = dispatch.persisted_execution_decision(
|
||||
store, task, stage="worker", evaluated_at=daytime
|
||||
)
|
||||
self.assertEqual(spec1.cli, "agy")
|
||||
|
||||
# 2. Qualified failover (provider-quota) -> transitions to Claude GLM.
|
||||
# 2. Qualified failover (provider-quota) -> transitions to OpenCode GLM.
|
||||
dec2 = dispatch.select_execution_decision(
|
||||
task, stage="worker", prior_decision=dec1,
|
||||
evaluated_at=daytime, transition="failover", failure_class="provider-quota"
|
||||
)
|
||||
self.assertEqual(dec2["transition"]["trigger"], "provider-quota")
|
||||
self.assertEqual(dec2["selected"]["adapter"], "claude-glm")
|
||||
self.assertEqual(dec2["selected"]["adapter"], "opencode")
|
||||
self.assertEqual(dec2["selected"]["target"], "glm-5.2")
|
||||
self.assertEqual(dec2["selected"]["reasoning_effort"], "max")
|
||||
|
||||
terra_available = {
|
||||
"schema_version": "1.0",
|
||||
|
|
@ -9816,7 +9902,7 @@ class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase):
|
|||
)
|
||||
self.assertEqual(spec1.cli, "agy")
|
||||
|
||||
# 2. Record primary failure (count=1) -> failover to Claude GLM.
|
||||
# 2. Record primary failure (count=1) -> failover to OpenCode GLM.
|
||||
budget = dispatch.StageFailureBudget.from_decision(store, task, dec1)
|
||||
count1 = budget.record_failure(target=dec1["selected"], transition="provider-quota")
|
||||
self.assertEqual(count1, 1)
|
||||
|
|
@ -9826,7 +9912,8 @@ class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase):
|
|||
evaluated_at=daytime, transition="failover", failure_class="provider-quota"
|
||||
)
|
||||
dispatch.commit_execution_decision(store, task, "worker", dec2)
|
||||
self.assertEqual(dec2["selected"]["adapter"], "claude-glm")
|
||||
self.assertEqual(dec2["selected"]["adapter"], "opencode")
|
||||
self.assertEqual(dec2["selected"]["reasoning_effort"], "max")
|
||||
|
||||
# 3. Alternate fails 9 times -> budget count reaches 10, task is blocked
|
||||
budget2 = dispatch.StageFailureBudget.from_decision(store, task, dec2)
|
||||
|
|
@ -10230,7 +10317,7 @@ class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase):
|
|||
daytime = datetime(2026, 7, 26, 14, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||||
nighttime = datetime(2026, 7, 26, 1, 0, 0, tzinfo=timezone(timedelta(hours=9)))
|
||||
|
||||
# Case 1: Day local G08 Gemini quota failover completes on pinned Claude GLM.
|
||||
# Case 1: Day local G08 Gemini quota failover completes on pinned OpenCode GLM.
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
(workspace / ".git").mkdir()
|
||||
|
|
@ -10239,8 +10326,9 @@ class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase):
|
|||
try:
|
||||
gemini_spec = dispatch.AgentSpec("agy", "Gemini 3.6 Flash (High)", "agy/Gemini 3.6 Flash (High)")
|
||||
glm_spec = dispatch.AgentSpec(
|
||||
"claude-glm", "glm-5.2", "claude-glm/glm-5.2 xhigh",
|
||||
command_model="sonnet",
|
||||
"opencode", "glm-5.2", "opencode/glm-5.2 max",
|
||||
reasoning_effort="max",
|
||||
command_model="iop-glm/glm-5.2",
|
||||
)
|
||||
loc_gemini = self.make_attempt_locator(workspace, task, gemini_spec)
|
||||
loc_glm = self.make_attempt_locator(workspace, task, glm_spec)
|
||||
|
|
@ -10260,12 +10348,16 @@ class SelectorDispatcherIntegrationTest(unittest.IsolatedAsyncioTestCase):
|
|||
dispatch.persisted_execution_decision(store, task, stage="worker", evaluated_at=daytime)
|
||||
await dispatch.run_worker(workspace, store, task)
|
||||
|
||||
self.assertEqual([s.cli for s in invoked_specs], ["agy", "claude-glm"])
|
||||
self.assertEqual([s.cli for s in invoked_specs], ["agy", "opencode"])
|
||||
state = store.task_state(task)
|
||||
self.assertEqual(state["execution_class"], "cloud_model")
|
||||
self.assertTrue(state["selfcheck_done"])
|
||||
self.assertEqual(dispatch.task_stage(task, state), "review")
|
||||
self.assertEqual(state["execution_decisions"]["worker"]["selected"]["adapter"], "claude-glm")
|
||||
self.assertEqual(state["execution_decisions"]["worker"]["selected"]["adapter"], "opencode")
|
||||
self.assertEqual(
|
||||
state["execution_decisions"]["worker"]["selected"]["reasoning_effort"],
|
||||
"max",
|
||||
)
|
||||
self.assertEqual(
|
||||
state["completing_decision"]["selected"]["execution_class"], "cloud_model"
|
||||
)
|
||||
|
|
@ -10539,7 +10631,7 @@ class ThroughputQuotaBatchTest(unittest.TestCase):
|
|||
batch_snap = dispatch.build_admission_batch_snapshot(store, ready, now)
|
||||
|
||||
# Night local-G08 probes Gemini High plus its Codex Terra backup.
|
||||
# claude-glm has no dedicated quota probe; review remains excluded.
|
||||
# OpenCode GLM has no dedicated quota probe; review remains excluded.
|
||||
self.assertIsNotNone(batch_snap)
|
||||
self.assertEqual(len(probe_calls), 2)
|
||||
self.assertEqual(probe_calls[0]["adapter"], "agy")
|
||||
|
|
@ -11079,15 +11171,16 @@ class ThroughputQuotaBatchTest(unittest.TestCase):
|
|||
self.assertIsNone(st_blocked_after.get("blocked"))
|
||||
self.assertFalse(st_blocked_after.get("retry_quota_refresh_pending"))
|
||||
dec_after = st_blocked_after["execution_decisions"]["worker"]
|
||||
self.assertEqual(dec_after["selected"]["adapter"], "claude-glm")
|
||||
self.assertEqual(dec_after["selected"]["adapter"], "opencode")
|
||||
self.assertEqual(dec_after["selected"]["target"], "glm-5.2")
|
||||
self.assertEqual(dec_after["selected"]["reasoning_effort"], "max")
|
||||
self.assertNotIn("thinking_level", dec_after["selected"])
|
||||
self.assertEqual(dec_after["transition"]["trigger"], "provider-quota")
|
||||
self.assertEqual(dec_after["work_unit_id"], d_blocked["work_unit_id"])
|
||||
|
||||
used = dec_after.get("used_candidates", [])
|
||||
used_adapters = [u.get("adapter") for u in used]
|
||||
self.assertIn("claude-glm", used_adapters)
|
||||
self.assertIn("opencode", used_adapters)
|
||||
self.assertIn("agy", used_adapters)
|
||||
self.assertTrue(len(st_blocked_after.get("route_transition_history", [])) >= 2)
|
||||
blocked_invocations = [call for call in invoke_calls if call[0] == t_blocked.name]
|
||||
|
|
|
|||
|
|
@ -51,7 +51,11 @@ class ExecutionTargetPolicyTests(unittest.TestCase):
|
|||
self.assertEqual(decision.rule_id, "worker-local-g07-g08-kst-night")
|
||||
self.assertEqual(
|
||||
decision.candidates,
|
||||
(policy.AGY_GEMINI_HIGH, policy.CLAUDE_GLM, policy.CODEX_TERRA_HIGH),
|
||||
(
|
||||
policy.AGY_GEMINI_HIGH,
|
||||
policy.OPENCODE_GLM_MAX,
|
||||
policy.CODEX_TERRA_HIGH,
|
||||
),
|
||||
)
|
||||
self.assertEqual(decision.time_window, "kst-night-[23:00,07:00)")
|
||||
self.assertEqual(decision.candidates[0].target, "Gemini 3.6 Flash (High)")
|
||||
|
|
@ -120,7 +124,7 @@ class ExecutionTargetPolicyTests(unittest.TestCase):
|
|||
(
|
||||
policy.CODEX_SPARK_XHIGH,
|
||||
policy.AGY_GEMINI_LOW,
|
||||
policy.CLAUDE_GLM,
|
||||
policy.OPENCODE_GLM_MEDIUM,
|
||||
policy.CODEX_TERRA_HIGH,
|
||||
),
|
||||
)
|
||||
|
|
@ -142,7 +146,7 @@ class ExecutionTargetPolicyTests(unittest.TestCase):
|
|||
self.assertEqual(decision.rule_id, "official-review-codex")
|
||||
self.assertEqual(decision.candidates, (policy.CODEX_SOL_XHIGH,))
|
||||
|
||||
def test_local_g07_g08_candidate_order_uses_gemini_high_then_glm_high(self):
|
||||
def test_local_g07_g08_candidate_order_uses_gemini_high_then_glm_max(self):
|
||||
daytime = policy.select_policy(
|
||||
stage="worker",
|
||||
lane="local",
|
||||
|
|
@ -157,19 +161,26 @@ class ExecutionTargetPolicyTests(unittest.TestCase):
|
|||
)
|
||||
expected = (
|
||||
policy.AGY_GEMINI_HIGH,
|
||||
policy.CLAUDE_GLM,
|
||||
policy.OPENCODE_GLM_MAX,
|
||||
policy.CODEX_TERRA_HIGH,
|
||||
)
|
||||
self.assertEqual(daytime.candidates, expected)
|
||||
self.assertEqual(nighttime.candidates, expected)
|
||||
|
||||
def test_claude_glm_is_cloud_without_selfcheck(self):
|
||||
target = policy.CLAUDE_GLM
|
||||
self.assertEqual(target.adapter, "claude-glm")
|
||||
self.assertEqual(target.target, "glm-5.2")
|
||||
self.assertEqual(target.command_model, "sonnet")
|
||||
self.assertEqual(target.execution_class, "cloud_model")
|
||||
self.assertFalse(target.selfcheck_required)
|
||||
def test_opencode_glm_effort_is_one_step_above_gemini(self):
|
||||
cases = (
|
||||
(policy.AGY_GEMINI_LOW, policy.OPENCODE_GLM_MEDIUM, "medium"),
|
||||
(policy.AGY_GEMINI_MEDIUM, policy.OPENCODE_GLM_HIGH, "high"),
|
||||
(policy.AGY_GEMINI_HIGH, policy.OPENCODE_GLM_MAX, "max"),
|
||||
)
|
||||
for gemini, target, effort in cases:
|
||||
with self.subTest(gemini=gemini.target):
|
||||
self.assertEqual(target.adapter, "opencode")
|
||||
self.assertEqual(target.target, "glm-5.2")
|
||||
self.assertEqual(target.command_model, "iop-glm/glm-5.2")
|
||||
self.assertEqual(target.reasoning_effort, effort)
|
||||
self.assertEqual(target.execution_class, "cloud_model")
|
||||
self.assertFalse(target.selfcheck_required)
|
||||
|
||||
def test_invalid_inputs_are_rejected(self):
|
||||
cases = [
|
||||
|
|
@ -202,6 +213,9 @@ class ExecutionTargetPolicyTests(unittest.TestCase):
|
|||
(policy.CODEX_TERRA_HIGH, None),
|
||||
(policy.PI_ORNITH, None),
|
||||
(policy.PI_LAGUNA, None),
|
||||
(policy.OPENCODE_GLM_MEDIUM, None),
|
||||
(policy.OPENCODE_GLM_HIGH, None),
|
||||
(policy.OPENCODE_GLM_MAX, None),
|
||||
(policy.CLAUDE_GLM, None),
|
||||
]
|
||||
for current, expected in cases:
|
||||
|
|
@ -212,7 +226,10 @@ class ExecutionTargetPolicyTests(unittest.TestCase):
|
|||
with self.subTest(identity=target.target):
|
||||
self.assertEqual(
|
||||
policy.canonical_target(
|
||||
target.adapter, target.target, target.thinking_level
|
||||
target.adapter,
|
||||
target.target,
|
||||
target.thinking_level,
|
||||
target.reasoning_effort,
|
||||
),
|
||||
target,
|
||||
)
|
||||
|
|
@ -222,6 +239,9 @@ class ExecutionTargetPolicyTests(unittest.TestCase):
|
|||
cases = [
|
||||
(policy.PI_ORNITH, None),
|
||||
(policy.PI_LAGUNA, None),
|
||||
(policy.OPENCODE_GLM_MEDIUM, None),
|
||||
(policy.OPENCODE_GLM_HIGH, None),
|
||||
(policy.OPENCODE_GLM_MAX, None),
|
||||
(policy.CLAUDE_GLM, None),
|
||||
(
|
||||
policy.AGY_GEMINI_LOW,
|
||||
|
|
|
|||
|
|
@ -527,10 +527,10 @@ class SelectorRouteMatrixTests(unittest.TestCase):
|
|||
[c["candidate_rank"] for c in daytime], [1, 2, 3]
|
||||
)
|
||||
self.assertEqual(
|
||||
[c["adapter"] for c in daytime], ["agy", "claude-glm", "codex"]
|
||||
[c["adapter"] for c in daytime], ["agy", "opencode", "codex"]
|
||||
)
|
||||
self.assertEqual(
|
||||
[c["adapter"] for c in nighttime], ["agy", "claude-glm", "codex"]
|
||||
[c["adapter"] for c in nighttime], ["agy", "opencode", "codex"]
|
||||
)
|
||||
single = write_task_file(Path(tmp), "PLAN", "cloud", 5)
|
||||
candidates = selector.select_execution_target(
|
||||
|
|
@ -538,6 +538,34 @@ class SelectorRouteMatrixTests(unittest.TestCase):
|
|||
)["candidates"]
|
||||
self.assertEqual([c["candidate_rank"] for c in candidates], [1, 2, 3])
|
||||
|
||||
def test_each_gemini_candidate_is_immediately_followed_by_higher_effort_glm(self):
|
||||
cases = (
|
||||
("cloud", 1, "Low", "medium"),
|
||||
("cloud", 3, "Medium", "high"),
|
||||
("cloud", 5, "High", "max"),
|
||||
("local", 7, "High", "max"),
|
||||
)
|
||||
with TemporaryDirectory() as tmp:
|
||||
for lane, grade, gemini_effort, glm_effort in cases:
|
||||
with self.subTest(lane=lane, grade=grade):
|
||||
task_file = write_task_file(Path(tmp), "PLAN", lane, grade)
|
||||
candidates = selector.select_execution_target(
|
||||
task_file, evaluated_at=kst(12)
|
||||
)["candidates"]
|
||||
gemini_index = next(
|
||||
index
|
||||
for index, candidate in enumerate(candidates)
|
||||
if candidate["adapter"] == "agy"
|
||||
)
|
||||
self.assertEqual(
|
||||
candidates[gemini_index]["target"],
|
||||
f"Gemini 3.6 Flash ({gemini_effort})",
|
||||
)
|
||||
glm = candidates[gemini_index + 1]
|
||||
self.assertEqual(glm["adapter"], "opencode")
|
||||
self.assertEqual(glm["target"], "glm-5.2")
|
||||
self.assertEqual(glm["reasoning_effort"], glm_effort)
|
||||
|
||||
|
||||
class SelectorQuotaRepresentationTests(unittest.TestCase):
|
||||
def test_quota_probe_tri_state(self):
|
||||
|
|
@ -641,7 +669,7 @@ class SelectorQuotaRepresentationTests(unittest.TestCase):
|
|||
result["quota"]["targets"], snapshot["targets"]
|
||||
)
|
||||
|
||||
def test_exhausted_gemini_falls_back_to_glm_high(self):
|
||||
def test_exhausted_gemini_high_falls_back_to_glm_max(self):
|
||||
snapshot = {
|
||||
"snapshot_id": "gemini-exhausted",
|
||||
"source": "iop-node quota-probe",
|
||||
|
|
@ -659,8 +687,9 @@ class SelectorQuotaRepresentationTests(unittest.TestCase):
|
|||
result = selector.select_execution_target(
|
||||
task_file, evaluated_at=kst(12), quota_snapshot=snapshot
|
||||
)
|
||||
self.assertEqual(result["selected"]["adapter"], "claude-glm")
|
||||
self.assertEqual(result["selected"]["adapter"], "opencode")
|
||||
self.assertEqual(result["selected"]["target"], "glm-5.2")
|
||||
self.assertEqual(result["selected"]["reasoning_effort"], "max")
|
||||
self.assertNotIn("thinking_level", result["selected"])
|
||||
|
||||
def test_all_candidates_exhausted_returns_no_eligible_target(self):
|
||||
|
|
@ -788,7 +817,7 @@ class SelectorQuotaRepresentationTests(unittest.TestCase):
|
|||
self.assertEqual(local_result["quota"]["mode"], "unbounded")
|
||||
self.assertEqual(local_result["quota"]["status"], "not_applicable")
|
||||
|
||||
# Local G07 has Gemini, Claude GLM, and Terra candidates.
|
||||
# Local G07 has Gemini, OpenCode GLM, and Terra candidates.
|
||||
dynamic = write_task_file(Path(tmp), "PLAN", "local", 7)
|
||||
candidates = selector.select_execution_target(
|
||||
dynamic, evaluated_at=kst(12)
|
||||
|
|
@ -796,8 +825,9 @@ class SelectorQuotaRepresentationTests(unittest.TestCase):
|
|||
self.assertEqual(len(candidates), 3)
|
||||
self.assertEqual(candidates[0]["adapter"], "agy")
|
||||
self.assertEqual(candidates[0]["quota_status"], "unknown")
|
||||
self.assertEqual(candidates[1]["adapter"], "claude-glm")
|
||||
self.assertEqual(candidates[1]["adapter"], "opencode")
|
||||
self.assertEqual(candidates[1]["target"], "glm-5.2")
|
||||
self.assertEqual(candidates[1]["reasoning_effort"], "max")
|
||||
self.assertEqual(candidates[1]["quota_status"], "unknown")
|
||||
self.assertEqual(candidates[1]["execution_class"], "cloud_model")
|
||||
self.assertFalse(candidates[1]["selfcheck_required"])
|
||||
|
|
@ -1154,7 +1184,7 @@ class SelectorIdentityAndQuotaRoundtripTests(unittest.TestCase):
|
|||
|
||||
|
||||
class SelectorFailoverContractTests(unittest.TestCase):
|
||||
def test_cloud_g01_g02_quota_failover_follows_spark_gemini_glm_low_order(self):
|
||||
def test_cloud_g01_g02_quota_failover_follows_spark_gemini_glm_medium_order(self):
|
||||
with TemporaryDirectory() as tmp:
|
||||
task_file = write_task_file(Path(tmp), "PLAN", "cloud", 1)
|
||||
initial = selector.select_execution_target(
|
||||
|
|
@ -1187,7 +1217,7 @@ class SelectorFailoverContractTests(unittest.TestCase):
|
|||
[
|
||||
("codex", "gpt-5.3-codex-spark"),
|
||||
("agy", "Gemini 3.6 Flash (Low)"),
|
||||
("claude-glm", "glm-5.2"),
|
||||
("opencode", "glm-5.2"),
|
||||
("codex", "gpt-5.6-terra"),
|
||||
],
|
||||
)
|
||||
|
|
@ -1197,7 +1227,7 @@ class SelectorFailoverContractTests(unittest.TestCase):
|
|||
)
|
||||
self.assertEqual(
|
||||
(glm["selected"]["adapter"], glm["selected"]["target"]),
|
||||
("claude-glm", "glm-5.2"),
|
||||
("opencode", "glm-5.2"),
|
||||
)
|
||||
self.assertNotIn("thinking_level", glm["selected"])
|
||||
self.assertEqual(
|
||||
|
|
@ -1205,7 +1235,11 @@ class SelectorFailoverContractTests(unittest.TestCase):
|
|||
[
|
||||
{"adapter": "codex", "target": "gpt-5.3-codex-spark"},
|
||||
{"adapter": "agy", "target": "Gemini 3.6 Flash (Low)"},
|
||||
{"adapter": "claude-glm", "target": "glm-5.2"},
|
||||
{
|
||||
"adapter": "opencode",
|
||||
"target": "glm-5.2",
|
||||
"reasoning_effort": "medium",
|
||||
},
|
||||
],
|
||||
)
|
||||
terra = selector.select_execution_target(
|
||||
|
|
@ -1240,8 +1274,9 @@ class SelectorFailoverContractTests(unittest.TestCase):
|
|||
task_file, evaluated_at=kst(12), transition="failover",
|
||||
prior_decision=prior, failure_class="provider-quota",
|
||||
)
|
||||
self.assertEqual(result["selected"]["adapter"], "claude-glm")
|
||||
self.assertEqual(result["selected"]["adapter"], "opencode")
|
||||
self.assertEqual(result["selected"]["target"], "glm-5.2")
|
||||
self.assertEqual(result["selected"]["reasoning_effort"], "max")
|
||||
self.assertEqual(result["transition"]["context_transfer"], "logical")
|
||||
self.assertEqual(result["transition"]["trigger"], "provider-quota")
|
||||
self.assertEqual(len(result["used_candidates"]), 2)
|
||||
|
|
@ -1319,8 +1354,9 @@ class SelectorFailoverContractTests(unittest.TestCase):
|
|||
prior = selector.select_execution_target(
|
||||
task_file, evaluated_at=kst(12), quota_snapshot=gemini_exhausted_snapshot
|
||||
)
|
||||
self.assertEqual(prior["selected"]["adapter"], "claude-glm")
|
||||
self.assertEqual(prior["selected"]["adapter"], "opencode")
|
||||
self.assertEqual(prior["selected"]["target"], "glm-5.2")
|
||||
self.assertEqual(prior["selected"]["reasoning_effort"], "max")
|
||||
self.assertNotIn("thinking_level", prior["selected"])
|
||||
|
||||
result = selector.select_execution_target(
|
||||
|
|
@ -1449,7 +1485,7 @@ class SelectorFailoverContractTests(unittest.TestCase):
|
|||
)
|
||||
self.assertEqual(day_initial["selected"]["adapter"], "agy")
|
||||
|
||||
# 23:00 KST is nighttime -> failover to claude-glm
|
||||
# 23:00 KST is nighttime -> failover to OpenCode GLM max.
|
||||
night_failover = selector.select_execution_target(
|
||||
task_file,
|
||||
evaluated_at=kst(23, 0, 0),
|
||||
|
|
@ -1457,23 +1493,27 @@ class SelectorFailoverContractTests(unittest.TestCase):
|
|||
prior_decision=day_initial,
|
||||
failure_class="provider-quota",
|
||||
)
|
||||
self.assertEqual(night_failover["selected"]["adapter"], "claude-glm")
|
||||
self.assertEqual(night_failover["selected"]["adapter"], "opencode")
|
||||
self.assertEqual(
|
||||
night_failover["used_candidates"],
|
||||
[
|
||||
{"adapter": "agy", "target": "Gemini 3.6 Flash (High)"},
|
||||
{"adapter": "claude-glm", "target": "glm-5.2"},
|
||||
{
|
||||
"adapter": "opencode",
|
||||
"target": "glm-5.2",
|
||||
"reasoning_effort": "max",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
# 23:01 KST nighttime resume -> preserved pinned claude-glm decision
|
||||
# 23:01 KST nighttime resume -> preserved pinned OpenCode decision.
|
||||
night_resume = selector.select_execution_target(
|
||||
task_file,
|
||||
evaluated_at=kst(23, 1, 0),
|
||||
transition="resume",
|
||||
prior_decision=night_failover,
|
||||
)
|
||||
self.assertEqual(night_resume["selected"]["adapter"], "claude-glm")
|
||||
self.assertEqual(night_resume["selected"]["adapter"], "opencode")
|
||||
self.assertIs(night_resume["decision"]["pinned"], True)
|
||||
self.assertEqual(night_resume["used_candidates"], night_failover["used_candidates"])
|
||||
|
||||
|
|
@ -1501,8 +1541,9 @@ class SelectorFailoverContractTests(unittest.TestCase):
|
|||
failure_class="provider-stream-disconnect",
|
||||
)
|
||||
|
||||
self.assertEqual(result["selected"]["adapter"], "claude-glm")
|
||||
self.assertEqual(result["selected"]["adapter"], "opencode")
|
||||
self.assertEqual(result["selected"]["target"], "glm-5.2")
|
||||
self.assertEqual(result["selected"]["reasoning_effort"], "max")
|
||||
self.assertNotIn("thinking_level", result["selected"])
|
||||
self.assertEqual(result["quota"]["status"], "unknown")
|
||||
self.assertEqual(run_mock.call_count, 2)
|
||||
|
|
|
|||
|
|
@ -262,6 +262,9 @@ OpenCode의 custom OpenAI-compatible provider 형식은 공식 [Providers 문서
|
|||
"output": 131072
|
||||
},
|
||||
"variants": {
|
||||
"medium": {
|
||||
"reasoningEffort": "medium"
|
||||
},
|
||||
"high": {
|
||||
"reasoningEffort": "high"
|
||||
},
|
||||
|
|
@ -276,7 +279,7 @@ OpenCode의 custom OpenAI-compatible provider 형식은 공식 [Providers 문서
|
|||
}
|
||||
```
|
||||
|
||||
GLM-5.2의 `1M` context, 최대 `131072` output, reasoning effort, sampling parameter는 [Z.AI parameter 문서](https://docs.z.ai/guides/overview/concept-param)와 [GLM-5.2 안내](https://z.ai/blog/glm-5.2)를 기준으로 한다. OpenCode의 OpenAI-compatible GLM-5.2 변형은 현재 [provider transform](https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/provider/transform.ts)에서 `high`와 `max`를 제공한다.
|
||||
GLM-5.2의 `1M` context, 최대 `131072` output, reasoning effort, sampling parameter는 [Z.AI parameter 문서](https://docs.z.ai/guides/overview/concept-param)와 [GLM-5.2 안내](https://z.ai/blog/glm-5.2)를 기준으로 한다. OpenCode의 OpenAI-compatible GLM-5.2 변형은 현재 [provider transform](https://github.com/anomalyco/opencode/blob/dev/packages/opencode/src/provider/transform.ts)에서 `high`와 `max`를 제공하며, dispatcher의 Gemini Low fallback은 custom provider 설정에 명시한 `medium` variant를 사용한다.
|
||||
|
||||
일반 coding/agent 작업은 `temperature=1`, `top_p=1`, `variant=max`를 기본으로 한다. 지연이나 quota 비용을 줄여야 할 때만 agent별로 `variant=high`를 선택한다.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue