Two coupled changes responding to the 2026-05-22 batch's tier-0 hit
rate of 0% on PRs the estimator judged "simple", plus three spurious
worker-internal-errors caused by the estimator's MCP-finalize wall
clock exceeding the previous 30s budget.
agent_runner.py — finalize timeout
----------------------------------
`production_agent_runner` previously hardcoded `finalize_timeout_s=30.0`.
On the 8-PR batch the worker recorded three `worker-internal-error`
attempts (PRs 47, 50, 54) all with the same message:
role='estimator' did not emit canonical output within 30.0s ...
The retries succeeded with estimator self-time of 41.5s, 25.9s, 21.5s —
all within plausible bounds for a multi-subsystem PR (PR 54 needed
41.5s of reasoning + MCP IPC + finalize write). Each spurious timeout
cost ~90s of worker wallclock + a re-dispatch.
New module-level constant `_DEFAULT_FINALIZE_TIMEOUT_S` reads
`CONTROLLER_FINALIZE_TIMEOUT_S` (default 90s), matching the
`CONTROLLER_*_TIMEOUT_S` convention used by `opencode_session.py`.
Operators can tune via `.devcontainer/.env.{fork,prod}`.
estimator-implementation.md — TIER MAP + CALIBRATION
----------------------------------------------------
The estimator was wrong on 4 of 8 PRs in the May-22 batch, all in
the same direction (recommended tier 0, escalated to tier 1).
Three of the four were high-confidence picks — including a PR
described as "4 string constant replacements, isolated scope, no
logic changes" that still got bounced at tier 0.
Three prompt changes:
- TIER MAP rewritten: tier 1 is now the default for non-trivial
work; tier 0 requires positive evidence the change is mechanical
(single file, ≤50 LOC, no new logic, no test changes).
- CONFIDENCE RULES "When uncertain, prefer" flipped tier 0 → tier 1.
- New CALIBRATION section bakes in the empirical observation (0/4
hit rate, why Haiku struggles in this codebase, cost calculus:
wrong tier-1 < wrong tier-0 by an order of magnitude when measured
per merged PR).
tiers.yaml — clarify scope
--------------------------
The previous "Used by estimator-implementation.md to make tier
choices model-agnostic" comment on the `capability` field misled
readers into thinking tiers.yaml drove classification. It doesn't —
the estimator agent reads its TIER MAP from its own prompt and
emits an integer; tiers.yaml only answers "for tier-N, which model
runs?". Updated header + field comments to flag this clearly so
future operators don't change descriptors here expecting the
estimator to honor them.
Tests
-----
+ test_default_finalize_timeout_reads_env: covers the env-var
resolution for `CONTROLLER_FINALIZE_TIMEOUT_S` (default, override,
float values). Restores the default at the end so subsequent
tests in the session see the stock constant.
Existing tier-model registry + 27 agent_runner tests pass unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
.opencode/models/ — Model registry
Single source of truth: models.yaml
models.yaml is the ONE file you edit to assign an LLM model to an
agent. Every other model-bearing surface is generated from it by
tools/sync_models.py — do not hand-edit the generated artifacts; the
next sync overwrites them.
# models.yaml
default: local-claude/claude-haiku-4-5
agents:
conflict-resolver-worker: local-claude/claude-opus-4-6
estimator-implementation: local-claude/claude-sonnet-4-6
# ... only agents needing a NON-default model appear here
tools/sync_models.py regenerates, from models.yaml:
| Generated artifact | Consumed by |
|---|---|
.opencode/models/<agent>.txt, default.txt |
the dispatcher's _opencode_worker._resolve_role_model |
opencode.json → agent.<name>.model |
OpenCode's static agent registry |
model: frontmatter in .opencode/agents/<agent>.md |
OpenCode's per-agent config |
Both OpenCode-facing surfaces (the opencode.json block and the
.md frontmatter) are written, because which one OpenCode honours has
varied across versions — writing both makes the assignment correct
regardless. Since all of them derive from the one manifest, they cannot
disagree.
Swapping a model
- Edit
models.yaml. - Apply it — either:
- relaunch the controller pipeline —
tools/run-controller-state-machine-pipeline.shrunssync_models.pyautomatically at startup (before OpenCode boots); or - run
python3 tools/sync_models.pyby hand.
- relaunch the controller pipeline —
- Restart OpenCode — it caches its config at process start, so a running server keeps the old model until restarted. (The pipeline launcher restarts OpenCode for you.)
Verify with no writes — python3 tools/sync_models.py --check exits 1
on any drift between models.yaml and the generated artifacts (suitable
for CI or a pre-launch gate).
Tier-ladder agents — tiers.yaml
The task-implementor-tier-* agents are governed separately by
tiers.yaml + tools/sync_tier_models.py (the escalation ladder has
its own manifest, generator, and drift test). sync_models.py passes
their opencode.json entries through untouched and never edits their
.md files. To change a tier model, edit tiers.yaml and run
tools/sync_tier_models.py.
Inheriting agents
task-implementor and agent-evolution-pool-supervisor have no
opencode.json agent entry and no model: line — they deliberately
inherit their caller's model. sync_models.py does not touch them.
File reference
| File | Role |
|---|---|
models.yaml |
Source of truth — per-agent model assignment (hand-edited) |
tiers.yaml |
Source of truth for the tier ladder (hand-edited) |
<agent>.txt, default.txt |
Generated by sync_models.py |
README.md |
This file |
Every model id is providerID/modelID; the provider must be declared
in opencode.json's provider block — sync_models.py validates this
and aborts on an unknown provider.
See also
../../tools/sync_models.py— the generator.../../tools/sync_tier_models.py— the tier generator.../../docs/development/models.md— design history and the planned Stage 2 fallback layer.