code-coordinator 0.5.46__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
coord/config.py
ADDED
|
@@ -0,0 +1,3032 @@
|
|
|
1
|
+
"""Parse and validate coordinator.yml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass, field, fields
|
|
9
|
+
from datetime import time
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
13
|
+
|
|
14
|
+
import yaml
|
|
15
|
+
|
|
16
|
+
from coord.liveness_auditor import (
|
|
17
|
+
DEFAULT_DEBOUNCE_SECONDS as DEFAULT_LIVENESS_DEBOUNCE_SECONDS,
|
|
18
|
+
DEFAULT_MODEL as DEFAULT_LIVENESS_MODEL,
|
|
19
|
+
DEFAULT_STRIKES as DEFAULT_LIVENESS_STRIKES,
|
|
20
|
+
DEFAULT_TIMEOUT_SECONDS as DEFAULT_LIVENESS_TIMEOUT_SECONDS,
|
|
21
|
+
)
|
|
22
|
+
from coord.models import Machine, QuietHours, Repo, WorkerPermissionsConfig
|
|
23
|
+
from coord.platform_paths import default_coord_dir
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
DEFAULT_CONFIG_PATH = Path("coordinator.yml")
|
|
27
|
+
|
|
28
|
+
# Canonical config home — works on a machine that has no repo checkout, mirroring
|
|
29
|
+
# where ``~/.coord/coord.db`` and ``~/.coord/client.toml`` already live. This is
|
|
30
|
+
# the recommended location; ``./coordinator.yml`` stays a development fallback.
|
|
31
|
+
USER_CONFIG_PATH = default_coord_dir() / "coordinator.yml"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def resolve_config_path() -> Path:
|
|
35
|
+
"""Resolve which ``coordinator.yml`` to load when no explicit path is given.
|
|
36
|
+
|
|
37
|
+
Search order (first existing file wins):
|
|
38
|
+
|
|
39
|
+
1. ``$COORD_CONFIG`` (if set) — explicit override.
|
|
40
|
+
2. ``~/.coord/coordinator.yml`` — the canonical home (no repo checkout needed).
|
|
41
|
+
3. ``./coordinator.yml`` — CWD, for development / the repo checkout.
|
|
42
|
+
|
|
43
|
+
When none exist the canonical home path is returned so the "not found" error
|
|
44
|
+
points operators at the recommended location rather than at the CWD.
|
|
45
|
+
"""
|
|
46
|
+
env = os.environ.get("COORD_CONFIG")
|
|
47
|
+
if env:
|
|
48
|
+
return Path(env).expanduser()
|
|
49
|
+
for candidate in (USER_CONFIG_PATH, DEFAULT_CONFIG_PATH):
|
|
50
|
+
if candidate.exists():
|
|
51
|
+
return candidate
|
|
52
|
+
return USER_CONFIG_PATH
|
|
53
|
+
|
|
54
|
+
# Safety-by-default: repos without explicit worker_permissions get this deny-list.
|
|
55
|
+
DEFAULT_DENY_COMMANDS: list[str] = [
|
|
56
|
+
"Bash(gh *)",
|
|
57
|
+
"Bash(git push --force *)",
|
|
58
|
+
"Bash(git push -f *)",
|
|
59
|
+
"Bash(git reset --hard *)",
|
|
60
|
+
"Bash(git branch -D *)",
|
|
61
|
+
"Bash(git checkout -- .)",
|
|
62
|
+
"Bash(git clean -f *)",
|
|
63
|
+
"Bash(rm -rf *)",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ConfigError(Exception):
|
|
68
|
+
"""Raised when coordinator.yml is missing, malformed, or fails validation."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class HooksConfig:
|
|
73
|
+
on_round_complete: list[str] = field(default_factory=list)
|
|
74
|
+
on_session_end: list[str] = field(default_factory=list)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class ReviewsConfig:
|
|
79
|
+
"""Adversarial code review settings.
|
|
80
|
+
|
|
81
|
+
`enabled=True` by default. When enabled, `coord pr` auto-dispatches an
|
|
82
|
+
adversarial review to a different machine after the PR worker is sent.
|
|
83
|
+
Completion of a "work" assignment via reconciliation also triggers review
|
|
84
|
+
dispatch automatically (see coord/review.py). Set `enabled: false` in
|
|
85
|
+
coordinator.yml to opt out.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
enabled: bool = True
|
|
89
|
+
auto_dispatch: bool = True
|
|
90
|
+
require_approval: bool = False
|
|
91
|
+
# #1811: optional provider override for the REVIEW dispatch, independent
|
|
92
|
+
# of the repo's own worker provider (`Repo.provider`). Threaded as
|
|
93
|
+
# `spec_provider` at both `guard_unattended_dispatch` call sites in
|
|
94
|
+
# coord/review.py — same precedence seam `resolve_provider_name` already
|
|
95
|
+
# implements (spec > repo > providers.default), just given a review-only
|
|
96
|
+
# entry point. `None` (the default) inherits `repo.provider` exactly as
|
|
97
|
+
# before this field existed — no existing deployment's behavior changes.
|
|
98
|
+
# Without this, the only way to review a repo pinned to a second backend
|
|
99
|
+
# (e.g. `provider: opencode`) is to let the review silently inherit that
|
|
100
|
+
# same backend — sharing the worker's model family removes the "zero
|
|
101
|
+
# shared context" independence adversarial review depends on. Validated
|
|
102
|
+
# against `providers.definitions` at parse time in `_parse_reviews` (an
|
|
103
|
+
# unknown name is a config error, not a dispatch-time surprise); the
|
|
104
|
+
# `human_attended_only` TOS gate (#437) still applies to whatever this
|
|
105
|
+
# resolves to, same as `repo.provider`.
|
|
106
|
+
provider: str | None = None
|
|
107
|
+
reviewer_prompt: str = ""
|
|
108
|
+
checklist: list[str] = field(default_factory=lambda: [
|
|
109
|
+
"Check for platform-specific code in shared/cross-platform paths",
|
|
110
|
+
])
|
|
111
|
+
repo_overrides: dict[str, list[str]] = field(default_factory=dict)
|
|
112
|
+
# Flood guard (incident 2026-06-08): bound *bulk* review dispatch so a
|
|
113
|
+
# backlog "unmasking" (e.g. removing a gate that had been suppressing
|
|
114
|
+
# reviews) can't fire hundreds of metered `claude -p` reviews in one pass.
|
|
115
|
+
# See coord.review.dispatch_pending_reviews.
|
|
116
|
+
max_auto_dispatch_per_pass: int = 5 # cap reviews dispatched per reconcile/notify pass (0 = unbounded)
|
|
117
|
+
flood_threshold: int = 12 # if more rows than this are pending review in one pass, refuse all (0 = no surge gate)
|
|
118
|
+
allow_review_flood: bool = False # override the surge gate (or set env COORD_ALLOW_REVIEW_FLOOD=1)
|
|
119
|
+
# #1488: sanity bound (additions+deletions) for `coord review-reaffirm` —
|
|
120
|
+
# the audited escape hatch that re-points a stale-but-content-changed
|
|
121
|
+
# approval's `review_head_sha` to the branch's current head instead of
|
|
122
|
+
# requiring a full re-review. A mechanical conflict-resolution delta is
|
|
123
|
+
# tens of lines; anything past this is refused outright (no override
|
|
124
|
+
# flag) so the command can never be used to wave through a genuine
|
|
125
|
+
# rewrite. 0 disables the bound (not recommended).
|
|
126
|
+
reaffirm_max_diff_lines: int = 300
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass
|
|
130
|
+
class ConcurrencyConfig:
|
|
131
|
+
max_workers: int = 2
|
|
132
|
+
stagger_seconds: float = 30.0
|
|
133
|
+
backoff_base: float = 60.0
|
|
134
|
+
max_retries: int = 3
|
|
135
|
+
auto_reassign: bool = False
|
|
136
|
+
stale_threshold: int = 3
|
|
137
|
+
# Spawn `claude -p` through a transient `bash -c 'exec ...'` parent so the
|
|
138
|
+
# immediate parent of claude is a short-lived shell. This is the upstream
|
|
139
|
+
# headline fix for the daemon-spawn freeze (anthropics/claude-code#56268).
|
|
140
|
+
bash_wrap_spawn: bool = True
|
|
141
|
+
# First-output (TTFT) watchdog: if a worker produces zero output within
|
|
142
|
+
# this many seconds, kill its process group and fail the assignment so the
|
|
143
|
+
# auto_reassign path re-dispatches it. 0 disables the watchdog. This only
|
|
144
|
+
# catches truly silent hangs — a rate-limited worker still emits output and
|
|
145
|
+
# therefore passes the check.
|
|
146
|
+
first_output_timeout: float = 600.0
|
|
147
|
+
# Remote interactive-session staleness timeout (#588). After a remote
|
|
148
|
+
# ``claude-pty`` assignment has been running for longer than this many
|
|
149
|
+
# hours, each reconcile pass probes the remote tmux session via SSH. If
|
|
150
|
+
# the session is dead (tmux has-session exits 1) the coordinator calls
|
|
151
|
+
# ``finalize_remote_interactive_exit`` to push any commits and release the
|
|
152
|
+
# machine slot. If SSH is unreachable, a warning is emitted instead.
|
|
153
|
+
# Default is 12 hours — generous enough that a genuinely long session is
|
|
154
|
+
# never interrupted, but tight enough to catch orphaned rows from crashed
|
|
155
|
+
# sessions overnight. Set to 0 to disable the sweep entirely.
|
|
156
|
+
interactive_session_timeout_hours: float = 12.0
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass
|
|
160
|
+
class SmokeRule:
|
|
161
|
+
"""When a worker's diff touches any of `files`, the smoke machine must
|
|
162
|
+
have all capabilities in `requires`.
|
|
163
|
+
|
|
164
|
+
`files` patterns match by prefix against the relative paths returned by
|
|
165
|
+
`gh pr view --json files`. A trailing `/` makes the prefix explicit; bare
|
|
166
|
+
paths match if the touched path starts with the rule path (so `src/gtk`
|
|
167
|
+
catches `src/gtk/foo.c` and `src/gtk_helpers.c`). Use `src/gtk/` to scope
|
|
168
|
+
strictly to the directory.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
files: list[str] = field(default_factory=list)
|
|
172
|
+
requires: list[str] = field(default_factory=list)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@dataclass
|
|
176
|
+
class SmokeTestsConfig:
|
|
177
|
+
"""Smoke-test orchestration. Off by default — opt-in per project.
|
|
178
|
+
|
|
179
|
+
`default_command` is the shell command the smoke agent runs (e.g.
|
|
180
|
+
`make smoke` or `pytest tests/smoke`). Per-repo overrides flow through
|
|
181
|
+
`Repo.test_command` already; this is the fallback when none is set.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
auto_queue: bool = False
|
|
185
|
+
default_command: str | None = None
|
|
186
|
+
timeout_seconds: int = 600
|
|
187
|
+
capability_rules: list[SmokeRule] = field(default_factory=list)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass
|
|
191
|
+
class AcceptanceDriverConfig:
|
|
192
|
+
"""One entry under ``acceptance.drivers.<repo_name>`` in coordinator.yml
|
|
193
|
+
(#944, docs/ORACLE_LOOP.md), OR one entry in that repo's ``routes:`` list
|
|
194
|
+
(#1125, in-repo path routing — see :class:`AcceptanceConfig.driver_for`).
|
|
195
|
+
|
|
196
|
+
``kind`` selects the framework-specific adapter that knows how to launch,
|
|
197
|
+
drive, and parse a repo's sealed acceptance suite (``tui-tuidriver`` and
|
|
198
|
+
``cli-pytest`` are implemented; other kinds are declared here but
|
|
199
|
+
rejected at run time by :mod:`coord.acceptance_drivers` until their
|
|
200
|
+
issues land). ``run`` is the shell command that executes the suite and
|
|
201
|
+
must print structured (JSON) verdicts to stdout — it may reference the
|
|
202
|
+
``{ms}`` template (substituted with the ``ms-NN`` milestone dirname by
|
|
203
|
+
:func:`coord.acceptance_drivers.render_run_command`) to point at a
|
|
204
|
+
milestone-scoped suite dir. ``mock`` is a glob (relative to the
|
|
205
|
+
acceptance dir) for the viewable mock/assertion fixtures — ``*.screen``
|
|
206
|
+
for ``tui-tuidriver``, ``*.out`` (expected CLI stdout) for
|
|
207
|
+
``cli-pytest`` — informational today, consumed by the future mock-author
|
|
208
|
+
(#930). ``capability`` is the machine capability required to run this
|
|
209
|
+
driver, intended to be routed the same way ``smoke_tests.capability_rules``
|
|
210
|
+
routes smoke tests.
|
|
211
|
+
|
|
212
|
+
``setup`` (#1733) is an optional shell command run once, before ``run``,
|
|
213
|
+
to provision whatever a driver needs that a bare ``git checkout``
|
|
214
|
+
doesn't provide — e.g. ``npm ci`` for ``web-playwright``. It exists
|
|
215
|
+
because ``coord acceptance record``'s whole design is a throwaway ``git
|
|
216
|
+
worktree add --detach`` the worker never touches: that worktree has no
|
|
217
|
+
``node_modules`` (gitignored, never checked out) and no way to get one
|
|
218
|
+
short of an explicit install step, so a JS driver's ``run`` failed with
|
|
219
|
+
a bare ``exit 127`` (playwright not found) before ever producing a
|
|
220
|
+
parsed verdict. ``tui-tuidriver`` (cargo fetches its own deps) and
|
|
221
|
+
``cli-pytest`` (runs against the ambient env) happen to self-provision,
|
|
222
|
+
which is exactly why this went unnoticed until the first JS driver ran.
|
|
223
|
+
Left empty (the default), no provisioning step runs — unchanged
|
|
224
|
+
behaviour for every driver that doesn't need one. A non-zero exit from
|
|
225
|
+
``setup`` is reported as a distinct "provisioning failed" error rather
|
|
226
|
+
than being folded into "tests failed"/"wrote no report" (see
|
|
227
|
+
:func:`coord.acceptance_drivers.run_driver`).
|
|
228
|
+
|
|
229
|
+
``entrypoint`` (#1552) is the repo-root-relative file this driver's
|
|
230
|
+
``run`` command links its slices through — the sealed oracle's *crate
|
|
231
|
+
root*, for a driver whose framework discovers tests via an entry point
|
|
232
|
+
rather than by walking a directory. ``tui-tuidriver``'s
|
|
233
|
+
``cargo test --test acceptance`` cannot see
|
|
234
|
+
``tests/acceptance/ms-NN/slice.rs`` at all until
|
|
235
|
+
``tui/tests/acceptance.rs`` ``include!``s it, so that file is part of
|
|
236
|
+
the oracle and must be declared here: it is folded into the sealed set
|
|
237
|
+
(:meth:`AcceptanceConfig.sealed_paths`) so a ``test-author`` registering
|
|
238
|
+
its slice there is expected rather than a scope violation, and a
|
|
239
|
+
``type="work"`` worker touching it still trips oracle tamper. Leave it
|
|
240
|
+
empty for a directory-discovered suite — ``cli-pytest``'s
|
|
241
|
+
``pytest tests/acceptance/{ms}`` legitimately has no entry point, which
|
|
242
|
+
is exactly why #1175's blanket refusal only ever broke the Rust route.
|
|
243
|
+
|
|
244
|
+
``match`` and ``routes`` implement #1125's in-repo path routing: a repo
|
|
245
|
+
entry with a non-empty ``routes`` list is a *router* — its own
|
|
246
|
+
``kind``/``run``/``mock``/``capability``/``setup``/``entrypoint`` are
|
|
247
|
+
unused and each element of ``routes`` is itself an
|
|
248
|
+
``AcceptanceDriverConfig`` with ``match`` set (a repo-root-relative
|
|
249
|
+
glob, e.g. ``"coord/**"``). A route entry's own ``routes`` is always
|
|
250
|
+
empty — nesting one level is the whole feature, not a recursive router.
|
|
251
|
+
See :meth:`AcceptanceConfig.driver_for` for the resolution rule.
|
|
252
|
+
|
|
253
|
+
``capability`` IS consulted (#966): both ``coord acceptance run --all``
|
|
254
|
+
and ``coord acceptance record`` preflight-check it against the invoking
|
|
255
|
+
host via :func:`coord.acceptance.acceptance_capability_gap` and refuse
|
|
256
|
+
loudly, naming a capable machine, rather than silently running on
|
|
257
|
+
hardware that may not support the driver. #966 deliberately stopped
|
|
258
|
+
there rather than building actual remote-exec routing (the way ``coord
|
|
259
|
+
test``'s ``pick_smoke_machine``/``match_rules`` route smoke runs to
|
|
260
|
+
capable hardware) — that's real new plumbing, unjustified until a driver
|
|
261
|
+
with an *unroutable* capability mismatch actually exists; "fail loud
|
|
262
|
+
instead of silently running wrong" was enough to unblock #944's only
|
|
263
|
+
driver at the time. A daemon host must still satisfy every declared
|
|
264
|
+
driver's capability itself, since ``record`` always executes wherever
|
|
265
|
+
the daemon is (see ``coord.commands.acceptance._acceptance_record_via_daemon``)
|
|
266
|
+
— that is a real, operator-facing constraint on which machine can be the
|
|
267
|
+
daemon, not something this preflight check can route around.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
kind: str = ""
|
|
271
|
+
run: str = ""
|
|
272
|
+
mock: str = ""
|
|
273
|
+
capability: str = ""
|
|
274
|
+
setup: str = ""
|
|
275
|
+
entrypoint: str = ""
|
|
276
|
+
match: str = ""
|
|
277
|
+
routes: list["AcceptanceDriverConfig"] = field(default_factory=list)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# #944 sealing v1: the sealed acceptance tree, relative to the repo root.
|
|
281
|
+
# Kept here (rather than imported from `coord.acceptance`) so config parsing
|
|
282
|
+
# stays dependency-free; `coord.acceptance.ACCEPTANCE_DIRNAME` is the same
|
|
283
|
+
# directory without the trailing slash.
|
|
284
|
+
SEALED_ACCEPTANCE_DIR = "tests/acceptance/"
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@dataclass
|
|
288
|
+
class AcceptanceConfig:
|
|
289
|
+
"""``acceptance.drivers`` — repo name -> :class:`AcceptanceDriverConfig`."""
|
|
290
|
+
|
|
291
|
+
drivers: dict[str, AcceptanceDriverConfig] = field(default_factory=dict)
|
|
292
|
+
|
|
293
|
+
def entrypoints(self, repo_name: str) -> list[str]:
|
|
294
|
+
"""Every ``entrypoint:`` declared by *repo_name*'s acceptance driver
|
|
295
|
+
(#1552), deduped, declaration order preserved.
|
|
296
|
+
|
|
297
|
+
Path-independent by design, exactly like :meth:`has_driver` and for
|
|
298
|
+
the same reason: the callers (sealing, the reviewer's scope rule,
|
|
299
|
+
``dispatch``'s forbid list) are deciding what the *whole repo's*
|
|
300
|
+
oracle covers, not which single route a given file resolves to. A
|
|
301
|
+
routed repo contributes one entry per route that declares one; a
|
|
302
|
+
flat repo contributes at most its own. Repos with no driver — and
|
|
303
|
+
drivers whose suite is directory-discovered (``cli-pytest``) —
|
|
304
|
+
return ``[]``.
|
|
305
|
+
"""
|
|
306
|
+
entry = self.drivers.get(repo_name)
|
|
307
|
+
if entry is None:
|
|
308
|
+
return []
|
|
309
|
+
out: list[str] = []
|
|
310
|
+
for cfg in [entry, *entry.routes]:
|
|
311
|
+
ep = cfg.entrypoint.strip()
|
|
312
|
+
if ep and ep not in out:
|
|
313
|
+
out.append(ep)
|
|
314
|
+
return out
|
|
315
|
+
|
|
316
|
+
def sealed_paths(self, repo_name: str) -> list[str]:
|
|
317
|
+
"""The full sealed-oracle path set for *repo_name* (#944 sealing v1,
|
|
318
|
+
#1552) — ``[]`` when the repo has no acceptance driver at all.
|
|
319
|
+
|
|
320
|
+
Two kinds of entry, distinguished by the trailing slash:
|
|
321
|
+
|
|
322
|
+
- ``"tests/acceptance/"`` — a directory prefix; everything under it
|
|
323
|
+
is sealed.
|
|
324
|
+
- each declared driver ``entrypoint`` (e.g.
|
|
325
|
+
``"tui/tests/acceptance.rs"``) — an exact file.
|
|
326
|
+
|
|
327
|
+
#1552: before this was derived, the set was a single hardcoded
|
|
328
|
+
literal in ``coord.review``, which happened to fit ``cli-pytest``
|
|
329
|
+
(pytest walks the directory) and was structurally unsatisfiable for
|
|
330
|
+
``tui-tuidriver`` (cargo needs a crate root that ``include!``s each
|
|
331
|
+
slice). A ``test-author`` on the Rust route could either wire its
|
|
332
|
+
slice in and trip a mandatory ``request-changes``, or leave it
|
|
333
|
+
unwired and ship 476 lines of dead code. Deriving the set from the
|
|
334
|
+
driver definition lets each route declare its own entry point
|
|
335
|
+
instead.
|
|
336
|
+
"""
|
|
337
|
+
if not self.has_driver(repo_name):
|
|
338
|
+
return []
|
|
339
|
+
return [SEALED_ACCEPTANCE_DIR, *self.entrypoints(repo_name)]
|
|
340
|
+
|
|
341
|
+
def driver_for(
|
|
342
|
+
self, repo_name: str, path: str | None = None,
|
|
343
|
+
) -> AcceptanceDriverConfig | None:
|
|
344
|
+
"""Resolve *repo_name*'s acceptance driver, optionally routed by
|
|
345
|
+
*path* (#1125, repo-root-relative — e.g. ``"coord/acceptance.py"``).
|
|
346
|
+
|
|
347
|
+
- Unknown repo -> ``None``.
|
|
348
|
+
- Repo entry has no ``routes`` (today's flat single-driver form,
|
|
349
|
+
back-compat) -> the entry itself, regardless of *path*.
|
|
350
|
+
- Repo entry has ``routes`` -> the **first** route whose ``match``
|
|
351
|
+
glob matches *path* (``fnmatch`` semantics, e.g. ``"coord/**"``
|
|
352
|
+
matches ``"coord/acceptance.py"``); first-match wins when more
|
|
353
|
+
than one route's glob matches. ``path=None`` against a routed
|
|
354
|
+
entry can't select a route, so it returns ``None`` rather than
|
|
355
|
+
guessing one — callers that know they're driving a specific file
|
|
356
|
+
(or an issue's manifest-mapped path) must pass it.
|
|
357
|
+
|
|
358
|
+
Resolution rule when a milestone/issue's slice spans more than one
|
|
359
|
+
route (#1125 review finding 4): this method makes no attempt to
|
|
360
|
+
detect or merge across routes for a single call — it resolves
|
|
361
|
+
exactly one *path* to exactly one route (or ``None``). A caller
|
|
362
|
+
whose work spans multiple routes (e.g. a full-stack issue touching
|
|
363
|
+
both ``coord/**`` and ``tui/**``) must pick ONE representative path
|
|
364
|
+
for the invocation (or invoke once per route) rather than expect
|
|
365
|
+
this method to fan out; callers driving a whole repo/milestone with
|
|
366
|
+
no single path in hand (Gate A, sealing, briefing-injection) should
|
|
367
|
+
use :meth:`has_driver` instead, which is path-independent by design.
|
|
368
|
+
"""
|
|
369
|
+
entry = self.drivers.get(repo_name)
|
|
370
|
+
if entry is None:
|
|
371
|
+
return None
|
|
372
|
+
if not entry.routes:
|
|
373
|
+
return entry
|
|
374
|
+
if path is None:
|
|
375
|
+
return None
|
|
376
|
+
for route in entry.routes:
|
|
377
|
+
if fnmatch.fnmatch(path, route.match):
|
|
378
|
+
return route
|
|
379
|
+
return None
|
|
380
|
+
|
|
381
|
+
def has_driver(self, repo_name: str) -> bool:
|
|
382
|
+
"""Path-independent "does this repo participate in the oracle loop
|
|
383
|
+
at all" predicate (#1125 review finding 1).
|
|
384
|
+
|
|
385
|
+
True when *repo_name* has ANY acceptance driver configured — flat
|
|
386
|
+
or routed — regardless of which route a given path would resolve
|
|
387
|
+
to. Use this for existence-only checks that must not silently flip
|
|
388
|
+
the moment a repo adopts ``routes:`` — Gate A
|
|
389
|
+
(``coord.milestone_dispatch.gate_a_status``), the ``tests/acceptance/``
|
|
390
|
+
sealing/forbid list (``coord.dispatch.dispatch``), and the
|
|
391
|
+
oracle-loop briefing-contract injection (``coord.dispatch.dispatch``)
|
|
392
|
+
all only need "yes/no", never a concrete driver to run — use
|
|
393
|
+
:meth:`driver_for` (with a *path*) for that.
|
|
394
|
+
"""
|
|
395
|
+
return repo_name in self.drivers
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
# #1430: plan-worker ESTIMATE -> escalation rung (see ModelsConfig.model_for_estimate).
|
|
399
|
+
_ESTIMATE_RUNG: dict[str, int] = {"trivial": 0, "small": 0, "medium": 1, "large": 2}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@dataclass
|
|
403
|
+
class ModelsConfig:
|
|
404
|
+
"""Model tier selection and escalation ladder for workers.
|
|
405
|
+
|
|
406
|
+
`default` is the model passed to ``claude -p`` when an assignment doesn't
|
|
407
|
+
specify one. `escalation` is an ordered list of model aliases (low →
|
|
408
|
+
high); when a worker fails or gets stuck, the coordinator escalates to
|
|
409
|
+
the next entry via `next_model`. `labels` is a per-issue-label override
|
|
410
|
+
(e.g. ``documentation: haiku``) resolved via :meth:`model_for_labels` —
|
|
411
|
+
consulted by every ``type="work"`` dispatch site (``coord plan`` /
|
|
412
|
+
``approve``, ``coord assign``, ``coord milestone dispatch``); plan-stage
|
|
413
|
+
and review-stage dispatches deliberately stay on ``default`` (#1430).
|
|
414
|
+
|
|
415
|
+
`versions` pins an alias to an exact model id, e.g.
|
|
416
|
+
``{sonnet: claude-sonnet-4-6, opus: claude-opus-4-7}``. When set, the
|
|
417
|
+
coordinator translates the alias to the exact id before passing it to
|
|
418
|
+
``claude -p --model`` on the worker. Aliases not present in the map
|
|
419
|
+
pass through unchanged, so ``claude -p`` falls back to its CLI default
|
|
420
|
+
(which today is whatever the installed claude-cli treats as latest).
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
default: str = "sonnet"
|
|
424
|
+
escalation: list[str] = field(
|
|
425
|
+
default_factory=lambda: ["haiku", "sonnet", "opus"]
|
|
426
|
+
)
|
|
427
|
+
labels: dict[str, str] = field(default_factory=dict)
|
|
428
|
+
versions: dict[str, str] = field(default_factory=dict)
|
|
429
|
+
|
|
430
|
+
def next_model(self, current: str) -> str:
|
|
431
|
+
"""Return the next model in the escalation ladder.
|
|
432
|
+
|
|
433
|
+
If *current* is already at the top of the ladder, or isn't on the
|
|
434
|
+
ladder at all, return *current* unchanged.
|
|
435
|
+
"""
|
|
436
|
+
try:
|
|
437
|
+
idx = self.escalation.index(current)
|
|
438
|
+
except ValueError:
|
|
439
|
+
return current
|
|
440
|
+
if idx + 1 < len(self.escalation):
|
|
441
|
+
return self.escalation[idx + 1]
|
|
442
|
+
return current
|
|
443
|
+
|
|
444
|
+
def resolve(self, alias: str | None) -> str | None:
|
|
445
|
+
"""Resolve an alias to its pinned exact model id, if configured.
|
|
446
|
+
|
|
447
|
+
Returns *alias* unchanged when no mapping exists, and ``None`` when
|
|
448
|
+
*alias* is ``None`` (preserves the "omit --model" code path).
|
|
449
|
+
"""
|
|
450
|
+
if alias is None:
|
|
451
|
+
return None
|
|
452
|
+
return self.versions.get(alias, alias)
|
|
453
|
+
|
|
454
|
+
def model_for_labels(self, issue_labels: list[str]) -> str | None:
|
|
455
|
+
"""Resolve an issue's GitHub labels to a model alias via ``labels``.
|
|
456
|
+
|
|
457
|
+
#1430: ``labels`` used to be validated at parse time and read by
|
|
458
|
+
nothing — every dispatch ran ``default`` regardless of the issue's
|
|
459
|
+
tier/category label. This is the resolver every dispatch site now
|
|
460
|
+
calls before falling back to ``default`` itself.
|
|
461
|
+
|
|
462
|
+
Precedence when an issue carries several configured labels (e.g.
|
|
463
|
+
both ``bug`` and ``tier:large``) — see
|
|
464
|
+
:meth:`model_for_labels_with_reason` for the full rule (#1633).
|
|
465
|
+
|
|
466
|
+
Returns ``None`` (never ``default``) when no configured label is
|
|
467
|
+
present on the issue, or ``labels`` itself is empty — mirroring the
|
|
468
|
+
None-passthrough style of :meth:`resolve`. Callers are expected to
|
|
469
|
+
fall back to ``default`` themselves, e.g.::
|
|
470
|
+
|
|
471
|
+
model = override or config.models.model_for_labels(issue_labels) or config.models.default
|
|
472
|
+
"""
|
|
473
|
+
return self.model_for_labels_with_reason(issue_labels)[0]
|
|
474
|
+
|
|
475
|
+
def model_for_labels_with_reason(
|
|
476
|
+
self, issue_labels: list[str]
|
|
477
|
+
) -> tuple[str | None, str | None, list[str]]:
|
|
478
|
+
"""Like :meth:`model_for_labels`, but also returns the label that
|
|
479
|
+
matched and any configured labels it shadowed.
|
|
480
|
+
|
|
481
|
+
Returns ``(model, matched_label, shadowed_labels)``, or
|
|
482
|
+
``(None, None, [])`` under the same conditions
|
|
483
|
+
:meth:`model_for_labels` returns ``None``.
|
|
484
|
+
|
|
485
|
+
#1633: precedence used to be decided by *issue-label order* — the
|
|
486
|
+
order GitHub happens to return the issue's own labels in, which
|
|
487
|
+
nothing in this repo controls. That made ``tier:small``/
|
|
488
|
+
``tier:large`` no-ops on any issue that also carried a type label
|
|
489
|
+
(``bug``/``enhancement``/...), and let the same issue re-route
|
|
490
|
+
just by having a label removed and re-added. Precedence is now
|
|
491
|
+
deterministic and config-driven instead:
|
|
492
|
+
|
|
493
|
+
1. ``tier:*`` entries are checked first — they are documented as
|
|
494
|
+
size-tier *overrides* over the type-label entries.
|
|
495
|
+
2. All other entries are checked next.
|
|
496
|
+
|
|
497
|
+
Within each group, ties are broken by ``labels``'s own iteration
|
|
498
|
+
order (insertion order from ``coordinator.yml``), not the issue's
|
|
499
|
+
label order — so the same issue + config always resolves to the
|
|
500
|
+
same model, regardless of what order GitHub reports the issue's
|
|
501
|
+
labels in.
|
|
502
|
+
|
|
503
|
+
#1454: a silent fall-through to ``default`` (stale/missing label)
|
|
504
|
+
looks identical to an intentional default from the CLI output
|
|
505
|
+
alone. Callers use the matched label to print *why* a model was
|
|
506
|
+
chosen, and *shadowed_labels* (every other configured label also
|
|
507
|
+
present on the issue, in resolution order) to print what lost —
|
|
508
|
+
see :func:`describe_model_choice`.
|
|
509
|
+
"""
|
|
510
|
+
if not self.labels:
|
|
511
|
+
return None, None, []
|
|
512
|
+
present_in_config_order = [
|
|
513
|
+
label for label in self.labels if label in issue_labels
|
|
514
|
+
]
|
|
515
|
+
if not present_in_config_order:
|
|
516
|
+
return None, None, []
|
|
517
|
+
tier_candidates = [
|
|
518
|
+
label for label in present_in_config_order if label.startswith("tier:")
|
|
519
|
+
]
|
|
520
|
+
other_candidates = [
|
|
521
|
+
label for label in present_in_config_order if not label.startswith("tier:")
|
|
522
|
+
]
|
|
523
|
+
ordered_candidates = tier_candidates + other_candidates
|
|
524
|
+
matched = ordered_candidates[0]
|
|
525
|
+
shadowed = ordered_candidates[1:]
|
|
526
|
+
return self.labels[matched], matched, shadowed
|
|
527
|
+
|
|
528
|
+
def model_for_estimate(self, estimate: str | None) -> str | None:
|
|
529
|
+
"""Map a plan worker's ``ESTIMATE`` to a model alias via ``escalation``.
|
|
530
|
+
|
|
531
|
+
#1430: once a plan has run, its ``ESTIMATE`` (trivial | small |
|
|
532
|
+
medium | large — derived from actually reading the code) is a
|
|
533
|
+
better-informed signal than the label chosen at issue-creation time,
|
|
534
|
+
so ``approve_plan`` uses this to override the label-derived model
|
|
535
|
+
for the work assignment it dispatches.
|
|
536
|
+
|
|
537
|
+
``trivial``/``small`` resolve to the lowest rung of ``escalation``,
|
|
538
|
+
``medium`` to the middle, ``large`` to the top — clamped to
|
|
539
|
+
``len(escalation) - 1`` so a short/custom ladder doesn't index out
|
|
540
|
+
of range. Returns ``None`` for an empty/unrecognised estimate or an
|
|
541
|
+
empty ``escalation`` list — callers fall back to the label-derived
|
|
542
|
+
or default model themselves.
|
|
543
|
+
"""
|
|
544
|
+
if not estimate or not self.escalation:
|
|
545
|
+
return None
|
|
546
|
+
idx = _ESTIMATE_RUNG.get(estimate.strip().lower())
|
|
547
|
+
if idx is None:
|
|
548
|
+
return None
|
|
549
|
+
idx = min(idx, len(self.escalation) - 1)
|
|
550
|
+
return self.escalation[idx]
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def describe_model_choice(
|
|
554
|
+
*,
|
|
555
|
+
resolved_model: str,
|
|
556
|
+
explicit_reason: str | None = None,
|
|
557
|
+
matched_label: str | None = None,
|
|
558
|
+
shadowed_labels: list[str] | None = None,
|
|
559
|
+
) -> str:
|
|
560
|
+
"""Format a one-line explanation of why *resolved_model* was chosen.
|
|
561
|
+
|
|
562
|
+
#1454: dispatch used to print just the bare model name, so a silent
|
|
563
|
+
mis-route to ``models.default`` (e.g. a tier label that hadn't been
|
|
564
|
+
picked up yet) read identically to an intentional default — the exact
|
|
565
|
+
ambiguity that made the stale-label-cache bug expensive to notice.
|
|
566
|
+
|
|
567
|
+
*explicit_reason*, when set, wins outright (e.g. ``"explicit --model"``
|
|
568
|
+
or ``"resolved at plan time"``) — the caller already knows the model
|
|
569
|
+
didn't come from a fresh label match. Otherwise *matched_label* (from
|
|
570
|
+
:meth:`ModelsConfig.model_for_labels_with_reason`) selects between the
|
|
571
|
+
"via label" and "default; no label match" phrasings.
|
|
572
|
+
|
|
573
|
+
#1633: when the issue carried more than one configured label,
|
|
574
|
+
*shadowed_labels* names the ones that lost, so a route that might look
|
|
575
|
+
surprising (e.g. ``tier:large`` winning over ``enhancement``) is
|
|
576
|
+
self-explaining at dispatch time instead of reading like the older,
|
|
577
|
+
order-dependent bug.
|
|
578
|
+
"""
|
|
579
|
+
if explicit_reason:
|
|
580
|
+
return f"{resolved_model} ({explicit_reason})"
|
|
581
|
+
if matched_label:
|
|
582
|
+
if shadowed_labels:
|
|
583
|
+
shadowed_str = ", ".join(repr(label) for label in shadowed_labels)
|
|
584
|
+
return (
|
|
585
|
+
f"{resolved_model} (via label {matched_label!r}, "
|
|
586
|
+
f"shadowing {shadowed_str})"
|
|
587
|
+
)
|
|
588
|
+
return f"{resolved_model} (via label {matched_label!r})"
|
|
589
|
+
return f"{resolved_model} (default; no label match)"
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
@dataclass
|
|
593
|
+
class DispatchConfig:
|
|
594
|
+
"""Smart task-splitting configuration.
|
|
595
|
+
|
|
596
|
+
When ``auto_split`` is ``True`` (the default), the ``coord approve``
|
|
597
|
+
command analyses each proposal's ``files_likely`` list. If the file
|
|
598
|
+
count exceeds ``max_files_per_worker``, the work is shown to the user
|
|
599
|
+
split into parallel/sequential chunks for confirmation before dispatch.
|
|
600
|
+
|
|
601
|
+
Set ``auto_split: false`` to disable the splitting analysis entirely.
|
|
602
|
+
|
|
603
|
+
When ``require_plan`` is ``True``, ``coord assign`` defaults to
|
|
604
|
+
``--plan-only`` behaviour — the worker reads the codebase and produces a
|
|
605
|
+
structured plan without writing any code. The user then runs
|
|
606
|
+
``coord approve-plan`` or ``coord reject-plan`` to act on the plan.
|
|
607
|
+
Pass ``--no-plan`` to ``coord assign`` to override this default and
|
|
608
|
+
dispatch a work assignment directly. Assignments of type ``review``,
|
|
609
|
+
``smoke``, or ``plan`` are never affected by this setting.
|
|
610
|
+
"""
|
|
611
|
+
|
|
612
|
+
max_files_per_worker: int = 8
|
|
613
|
+
auto_split: bool = True
|
|
614
|
+
require_plan: bool = False
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
@dataclass
|
|
618
|
+
class UsageGateConfig:
|
|
619
|
+
"""Pre-flight gate on the account's Max-plan 5h/weekly usage windows
|
|
620
|
+
(#1466). ``coord drive``'s ``preflight()`` and the ``coord approve``
|
|
621
|
+
batch path probe ``claude -p "/usage"`` (see ``coord.usage_limits``) and
|
|
622
|
+
consult this before dispatching, so a run doesn't start work that's
|
|
623
|
+
certain to run straight into a 5-hour or weekly wall — the first sign of
|
|
624
|
+
which was previously a worker dying mid-task with the branch stranded.
|
|
625
|
+
|
|
626
|
+
``mode`` is both the on/off switch and the enforcement level:
|
|
627
|
+
|
|
628
|
+
- ``"disabled"`` — never probe, never gate. Pre-#1466 behaviour.
|
|
629
|
+
- ``"warn"`` — probe and print a warning above threshold, but never
|
|
630
|
+
refuse a dispatch. **The default** — until the probe's prose parse
|
|
631
|
+
(``.result`` is NOT a stable contract, see ``coord.usage_limits``'s
|
|
632
|
+
docstring) has enough field mileage to trust for blocking real work.
|
|
633
|
+
- ``"block"`` — refuse to dispatch above threshold.
|
|
634
|
+
|
|
635
|
+
A probe that fails or returns "unknown" (no OAuth subscription session,
|
|
636
|
+
unparseable output, timeout, ...) NEVER blocks or warns regardless of
|
|
637
|
+
``mode`` — see ``coord.usage_limits.evaluate_usage_gate``.
|
|
638
|
+
|
|
639
|
+
CAVEAT: Anthropic announced ``claude -p``/Agent SDK usage moving off the
|
|
640
|
+
subscription windows onto a separate monthly credit pool; that rollout
|
|
641
|
+
is paused as of 2026-06-15, so today this gate correctly predicts a
|
|
642
|
+
headless worker running into the same session/weekly walls ``/usage``
|
|
643
|
+
reports. If the rollout resumes, this gate stops being predictive and
|
|
644
|
+
would need to switch to tracking credit balance instead.
|
|
645
|
+
"""
|
|
646
|
+
|
|
647
|
+
mode: str = "warn" # "disabled" | "warn" | "block"
|
|
648
|
+
session_threshold_pct: float = 85.0
|
|
649
|
+
week_threshold_pct: float = 90.0
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
# #846: default wall-clock thresholds (seconds) an assignment of a given
|
|
653
|
+
# `type` may run before `coord.notify.detect_needs_attention` flags it.
|
|
654
|
+
# Deliberately generous — this is a "human should glance at this" signal,
|
|
655
|
+
# not a kill switch (detection + surfacing only, see issue #846).
|
|
656
|
+
#
|
|
657
|
+
# These are all *headless* types — a `claude -p` worker converging toward a
|
|
658
|
+
# result with no one attending it live, so "running way longer than usual"
|
|
659
|
+
# is a meaningful stuck signal. `plan`/`mock-author`/`test-author` are
|
|
660
|
+
# lighter-weight than `work` (no code-writing convergence loop) but still
|
|
661
|
+
# headless, so they get their own explicit (rather than work-fallback)
|
|
662
|
+
# tuning. `conflict-fix` is dual-purpose — the automated #241 worker *and*
|
|
663
|
+
# the interactive `--merge-of` session share this type (see
|
|
664
|
+
# `coord.reconcile.is_interactive_merge_session`) — so it gets a little more
|
|
665
|
+
# headroom than `work` to cover a human resolving a semantic conflict.
|
|
666
|
+
#
|
|
667
|
+
# #1137 audit note: the #1133 follow-up asked whether `merge`/`fix` (the two
|
|
668
|
+
# types named in the original #846 ask but left unhandled by #1133) need
|
|
669
|
+
# their own entry. `merge` does NOT — there is no literal `type="merge"`
|
|
670
|
+
# (a dedicated value was tried and reverted, see
|
|
671
|
+
# `is_interactive_merge_session`'s docstring / tests/test_reap_merged_sessions.py
|
|
672
|
+
# DISCRIMINATOR NOTE); the interactive `--merge-of` session already shares
|
|
673
|
+
# `conflict-fix` above and is covered by its 60m threshold. `fix` (the
|
|
674
|
+
# interactive `--fix-of`/`--rework-of` human-attended session) DOES need
|
|
675
|
+
# handling — it shares `type="work"` with headless coding workers, so it
|
|
676
|
+
# can't get its own entry here either. Instead `attention_threshold_for`
|
|
677
|
+
# recognizes it via the same compound discriminator shape as
|
|
678
|
+
# `is_interactive_merge_session` — `provider_name="claude-pty"` +
|
|
679
|
+
# `review_of_assignment_id` set on a `type="work"` row — and reuses
|
|
680
|
+
# `conflict-fix`'s threshold (the same "human resolving someone else's
|
|
681
|
+
# feedback" scenario).
|
|
682
|
+
#
|
|
683
|
+
# #1144 audit note: the same dual-purpose shape exists for `review` and
|
|
684
|
+
# `smoke`. Headless auto-review (`coord.review`, no `provider_name`) and
|
|
685
|
+
# headless smoke (`coord.smoke`, no `provider_name`) share their types with
|
|
686
|
+
# the interactive `--review-of`/`--smoke-of` sessions
|
|
687
|
+
# (`coord.commands.dispatch_workers`, `provider_name="claude-pty"` +
|
|
688
|
+
# `review_of_assignment_id` set) - plain `review`/`smoke` are only 15m/20m,
|
|
689
|
+
# so a human reading a diff or babysitting a smoke run for that long is
|
|
690
|
+
# normal, not stuck. `attention_threshold_for` extends the same compound
|
|
691
|
+
# discriminator to `assignment_type in ("work", "review", "smoke")` and
|
|
692
|
+
# defers all three to `conflict-fix`'s 60m threshold rather than giving
|
|
693
|
+
# review/smoke their own tuned value - one interactive threshold for every
|
|
694
|
+
# "human attending a claude-pty session tied to an earlier assignment" case
|
|
695
|
+
# is easier to reason about than three, and 60m is already generous enough
|
|
696
|
+
# to cover a human reading a diff or watching a smoke run.
|
|
697
|
+
_DEFAULT_ATTENTION_THRESHOLDS: dict[str, float] = {
|
|
698
|
+
"work": 45 * 60.0,
|
|
699
|
+
"review": 15 * 60.0,
|
|
700
|
+
"smoke": 20 * 60.0,
|
|
701
|
+
"plan": 30 * 60.0,
|
|
702
|
+
"mock-author": 30 * 60.0,
|
|
703
|
+
"test-author": 30 * 60.0,
|
|
704
|
+
"conflict-fix": 60 * 60.0,
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
# #1133: assignment types that are human-attended interactive sessions — a
|
|
708
|
+
# developer reading/thinking/typing at a live `claude` TTY (driven via
|
|
709
|
+
# `POST /inject/{id}` from the TUI), not a headless worker converging toward
|
|
710
|
+
# a result. These have no wall-clock "stuck" concept: a human legitimately
|
|
711
|
+
# spending hours reading an issue, chatting through a plan, or validating a
|
|
712
|
+
# diff is normal, not stalled (the #846 wall-clock check exists to catch a
|
|
713
|
+
# headless worker silently burning budget — see `attention_signal`'s
|
|
714
|
+
# docstring for the #448 motivation, which doesn't apply here). Exempt from
|
|
715
|
+
# the wall-clock signal unconditionally in `attention_threshold_for` —
|
|
716
|
+
# *not* merely by omission from `_DEFAULT_ATTENTION_THRESHOLDS` — so a user
|
|
717
|
+
# who overrides `pipeline.attention_thresholds.work` in `coordinator.yml`
|
|
718
|
+
# can't accidentally re-arm this check for a chat session via the
|
|
719
|
+
# fallback-to-"work" behaviour (see that method's docstring). A user who
|
|
720
|
+
# explicitly configures a threshold for one of these types still wins —
|
|
721
|
+
# this is a default exemption, not an unconditional one.
|
|
722
|
+
INTERACTIVE_SESSION_TYPES: frozenset[str] = frozenset({
|
|
723
|
+
"chat",
|
|
724
|
+
"troubleshoot",
|
|
725
|
+
"audit",
|
|
726
|
+
"milestone-chat",
|
|
727
|
+
"refinement",
|
|
728
|
+
"new-issue-chat",
|
|
729
|
+
"test-chat",
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
@dataclass
|
|
734
|
+
class LivenessAuditorConfig:
|
|
735
|
+
"""#2048: cheap, independent per-turn liveness auditor tunables.
|
|
736
|
+
|
|
737
|
+
``enabled`` (default ``False``) — the auditor ships dark. It costs a
|
|
738
|
+
``claude -p`` subprocess spawn per debounced audit, so it earns trust
|
|
739
|
+
the same way ``auto_dispatch_stalled``/``escalate_semantic_conflicts``
|
|
740
|
+
did before it: off until an operator turns it on. Unlike a
|
|
741
|
+
daemon-side setting, a config change here does NOT need a
|
|
742
|
+
``coord-serve`` restart: ``detect_liveness_stall`` only runs inside
|
|
743
|
+
``coord notify``'s ``run()``, invoked as a fresh CLI process by the
|
|
744
|
+
``coord-notify.timer`` systemd unit (or by ``coord drive``'s stall
|
|
745
|
+
nudge) — each invocation loads this config fresh off disk, so an edit
|
|
746
|
+
takes effect on the next timer tick (≤5 min), independent of any
|
|
747
|
+
long-running daemon.
|
|
748
|
+
|
|
749
|
+
``strikes`` — consecutive ``blocked`` verdicts required before an
|
|
750
|
+
``EVENT_LIVENESS_STALL`` is raised. One bad turn is normal; this many
|
|
751
|
+
in a row is a stall. Default 3 (matches OpenChamber's Session Goals,
|
|
752
|
+
the prior art this borrows from).
|
|
753
|
+
|
|
754
|
+
``debounce_seconds`` — minimum wall-clock gap between audits for the
|
|
755
|
+
same assignment. A stall is a multi-minute phenomenon; auditing every
|
|
756
|
+
turn buys no extra signal and pays for a process spawn each time.
|
|
757
|
+
Default 60s.
|
|
758
|
+
|
|
759
|
+
``model`` — the model the audit subprocess runs. Default
|
|
760
|
+
``"claude-haiku-4-5"``, the cheapest current model — the audit's whole
|
|
761
|
+
design rests on a fixed ~1k-token context, so the cost stays flat
|
|
762
|
+
regardless of session length (see ``coord/liveness_auditor.py``'s
|
|
763
|
+
module docstring for the numbers).
|
|
764
|
+
|
|
765
|
+
``timeout_seconds`` — subprocess timeout per audit call. Default 30s.
|
|
766
|
+
|
|
767
|
+
``claude_bin`` — override the ``claude`` binary path/name, mirroring
|
|
768
|
+
other subprocess-spawning config in this file. ``None`` (default) uses
|
|
769
|
+
the CLI's own resolution of ``claude`` on ``$PATH``.
|
|
770
|
+
"""
|
|
771
|
+
|
|
772
|
+
enabled: bool = False
|
|
773
|
+
strikes: int = DEFAULT_LIVENESS_STRIKES
|
|
774
|
+
debounce_seconds: float = DEFAULT_LIVENESS_DEBOUNCE_SECONDS
|
|
775
|
+
model: str = DEFAULT_LIVENESS_MODEL
|
|
776
|
+
timeout_seconds: float = DEFAULT_LIVENESS_TIMEOUT_SECONDS
|
|
777
|
+
claude_bin: str | None = None
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
@dataclass
|
|
781
|
+
class PipelineConfig:
|
|
782
|
+
"""Assignment lifecycle gate configuration.
|
|
783
|
+
|
|
784
|
+
``default_gates`` is the list of approval steps required for every work
|
|
785
|
+
assignment unless overridden by an issue label. ``labels`` maps GitHub
|
|
786
|
+
issue label names to gate lists, allowing per-label overrides — e.g.
|
|
787
|
+
a ``hotfix`` label could bypass review with ``hotfix: [merge]``.
|
|
788
|
+
|
|
789
|
+
``auto_loop`` enables the automated review → fix → re-review cycle.
|
|
790
|
+
When ``True`` (default), a review that requests changes automatically
|
|
791
|
+
dispatches a fix worker. The fix worker then receives a fresh review,
|
|
792
|
+
and the cycle continues until the review approves or
|
|
793
|
+
``max_review_iterations`` is reached.
|
|
794
|
+
|
|
795
|
+
``max_review_iterations`` is the maximum number of fix rounds before
|
|
796
|
+
the auto-loop stops and posts a notice asking for manual intervention.
|
|
797
|
+
Default is 5.
|
|
798
|
+
|
|
799
|
+
``escalate_fix_model`` controls whether auto-dispatched fix workers
|
|
800
|
+
escalate the model on each bounce iteration. When ``True`` (default),
|
|
801
|
+
the first fix stays on ``models.default`` and each subsequent fix
|
|
802
|
+
iteration climbs one rung up ``models.escalation`` (capped at the top).
|
|
803
|
+
When ``False``, fix dispatches set no model (today's behaviour: the
|
|
804
|
+
agent falls back to ``claude -p``'s default).
|
|
805
|
+
|
|
806
|
+
``attention_thresholds`` (#846) maps assignment ``type`` (``"work"``,
|
|
807
|
+
``"review"``, ``"smoke"``, ...) to a wall-clock duration (seconds) that
|
|
808
|
+
an assignment may sit in ``status="running"`` before
|
|
809
|
+
``coord.notify.detect_needs_attention`` flags it. A type not present in
|
|
810
|
+
the mapping falls back to ``_DEFAULT_ATTENTION_THRESHOLDS``, *unless*
|
|
811
|
+
it's a human-attended interactive type (#1133,
|
|
812
|
+
:data:`INTERACTIVE_SESSION_TYPES` — ``"chat"``, ``"troubleshoot"``,
|
|
813
|
+
``"audit"``, ``"milestone-chat"``, ``"refinement"``,
|
|
814
|
+
``"new-issue-chat"``, ``"test-chat"``), which is exempt from the
|
|
815
|
+
wall-clock check entirely by default — see
|
|
816
|
+
:meth:`attention_threshold_for`. An interactive ``--fix-of``/
|
|
817
|
+
``--rework-of`` session (#1137) is also recognized there, by
|
|
818
|
+
``provider_name``/``review_of_assignment_id`` rather than ``type``
|
|
819
|
+
(it shares ``type="work"`` with headless coding workers), and reuses
|
|
820
|
+
``conflict-fix``'s threshold. The interactive ``--review-of`` and
|
|
821
|
+
``--smoke-of`` sessions (#1144) are recognized the same way for
|
|
822
|
+
``type="review"``/``type="smoke"`` and also reuse ``conflict-fix``'s
|
|
823
|
+
threshold, rather than plain review's 15m or smoke's 20m.
|
|
824
|
+
|
|
825
|
+
``escalate_semantic_conflicts`` (#1291) controls whether a conflict-fix
|
|
826
|
+
worker that gives up on a **semantic** conflict (it emits the
|
|
827
|
+
``coord:conflict=semantic`` marker on its ``STUCK:`` line) gets ONE
|
|
828
|
+
second attempt from a stronger model
|
|
829
|
+
(``semantic_conflict_model``, default ``"fable"``) before the merge
|
|
830
|
+
entry is parked ``HUMAN_REQUIRED``. **Defaults to ``False``** — this
|
|
831
|
+
ships dark until it earns trust on real conflicts. The escalated
|
|
832
|
+
attempt consumes the existing one-per-entry conflict-fix retry cap, so
|
|
833
|
+
a second semantic failure goes to ``HUMAN_REQUIRED`` exactly as today;
|
|
834
|
+
there is no loop. It is still subject to every merge gate (tests,
|
|
835
|
+
``coord verify-merge``, CI, review) — nothing is force-merged.
|
|
836
|
+
|
|
837
|
+
``convergence_rounds`` (#846) is the number of fix/review rounds
|
|
838
|
+
(``Assignment.review_iteration``) an assignment may accumulate without
|
|
839
|
+
reaching a green test verdict + approved review before it is flagged as
|
|
840
|
+
non-converging (thrashing). Default 3.
|
|
841
|
+
|
|
842
|
+
``auto_dispatch_stalled`` (#1478) controls whether
|
|
843
|
+
``coord.notify.detect_stalled_pipeline`` (#1441) gets a dispatch arm in
|
|
844
|
+
addition to its diagnostic GitHub comment. Detection + narration is
|
|
845
|
+
always on (there is no flag for that half — it is cheap and has shipped
|
|
846
|
+
since #1441); this flag gates only the *action*: enqueueing an
|
|
847
|
+
``approved_not_queued`` row for merge, dispatching a conflict-fix for a
|
|
848
|
+
merge entry stuck ``CONFLICT``, or dispatching the fix/review a
|
|
849
|
+
review-completion transition would have. **Defaults to ``False``** —
|
|
850
|
+
ships dark, same posture as ``escalate_semantic_conflicts``, until
|
|
851
|
+
unattended dispatch on a stalled row earns trust. The one-shot
|
|
852
|
+
``notified`` ledger keyed by ``_stalled_notified_key`` (shared with the
|
|
853
|
+
comment) still applies, so a row is acted on once per tick-cycle, not
|
|
854
|
+
every 5 minutes.
|
|
855
|
+
|
|
856
|
+
``liveness_auditor`` (#2048) configures the cheap, independent,
|
|
857
|
+
per-turn liveness auditor — see :class:`LivenessAuditorConfig`. Off by
|
|
858
|
+
default; gates nothing even when enabled (see
|
|
859
|
+
:func:`coord.notify.detect_liveness_stall`).
|
|
860
|
+
"""
|
|
861
|
+
|
|
862
|
+
default_gates: list[str] = field(default_factory=lambda: ["test", "review", "merge"])
|
|
863
|
+
labels: dict[str, list[str]] = field(default_factory=dict)
|
|
864
|
+
auto_loop: bool = True
|
|
865
|
+
max_review_iterations: int = 5
|
|
866
|
+
escalate_fix_model: bool = True
|
|
867
|
+
# #1291 — DEFAULT OFF. See the class docstring.
|
|
868
|
+
escalate_semantic_conflicts: bool = False
|
|
869
|
+
semantic_conflict_model: str = "fable"
|
|
870
|
+
# #1478 — DEFAULT OFF. See the class docstring.
|
|
871
|
+
auto_dispatch_stalled: bool = False
|
|
872
|
+
attention_thresholds: dict[str, float] = field(
|
|
873
|
+
default_factory=lambda: dict(_DEFAULT_ATTENTION_THRESHOLDS)
|
|
874
|
+
)
|
|
875
|
+
convergence_rounds: int = 3
|
|
876
|
+
# #2048 — DEFAULT OFF. See LivenessAuditorConfig's docstring.
|
|
877
|
+
liveness_auditor: LivenessAuditorConfig = field(default_factory=LivenessAuditorConfig)
|
|
878
|
+
|
|
879
|
+
def attention_threshold_for(
|
|
880
|
+
self,
|
|
881
|
+
assignment_type: str,
|
|
882
|
+
*,
|
|
883
|
+
provider_name: str | None = None,
|
|
884
|
+
review_of_assignment_id: str | None = None,
|
|
885
|
+
) -> float:
|
|
886
|
+
"""Wall-clock threshold (seconds) for *assignment_type*.
|
|
887
|
+
|
|
888
|
+
Checked in order:
|
|
889
|
+
|
|
890
|
+
1. **Interactive session sharing a headless type** (#1137/#1144):
|
|
891
|
+
``assignment_type in ("work", "review", "smoke")`` with
|
|
892
|
+
``provider_name == "claude-pty"`` and
|
|
893
|
+
``review_of_assignment_id`` set (the optional keyword-only args,
|
|
894
|
+
passed by callers that have the full assignment record; both
|
|
895
|
+
default to ``None`` so existing callers that only know the type
|
|
896
|
+
are unaffected). This mirrors
|
|
897
|
+
:func:`coord.reconcile.is_interactive_merge_session`'s compound
|
|
898
|
+
discriminator — a dedicated ``type="fix"``/``type="review-of"``/
|
|
899
|
+
``type="smoke-of"`` was deliberately not introduced, for the same
|
|
900
|
+
reason a dedicated ``type="merge"`` was reverted (see that
|
|
901
|
+
function's docstring): each of these three shares its type with
|
|
902
|
+
a headless counterpart (``work`` with headless coding workers,
|
|
903
|
+
``review`` with headless auto-review, ``smoke`` with headless
|
|
904
|
+
smoke). A matching row defers to
|
|
905
|
+
``attention_threshold_for("conflict-fix")`` — the same "human
|
|
906
|
+
attending a live session, not a worker silently converging"
|
|
907
|
+
scenario that earned conflict-fix its extra headroom in #1133 —
|
|
908
|
+
so an explicit user override of ``conflict-fix`` (but *not* of
|
|
909
|
+
plain ``work``/``review``/``smoke``) still applies. Checked
|
|
910
|
+
*before* the plain ``attention_thresholds`` lookup below for the
|
|
911
|
+
same reason :data:`INTERACTIVE_SESSION_TYPES` is:
|
|
912
|
+
``attention_thresholds`` always carries built-in ``"work"``/
|
|
913
|
+
``"review"``/``"smoke"`` entries (the dataclass default copies
|
|
914
|
+
the whole ``_DEFAULT_ATTENTION_THRESHOLDS`` dict), so checking
|
|
915
|
+
that dict first would make this branch unreachable.
|
|
916
|
+
2. **Explicit override** — an ``attention_thresholds`` entry for
|
|
917
|
+
*this exact* ``assignment_type`` (built-in default or
|
|
918
|
+
user-configured) always wins.
|
|
919
|
+
3. **Interactive session type** (#1133,
|
|
920
|
+
:data:`INTERACTIVE_SESSION_TYPES`) — human-attended
|
|
921
|
+
chat/troubleshoot/review-style sessions with no
|
|
922
|
+
headless-convergence concept — exempted (``inf``, never flagged)
|
|
923
|
+
rather than inheriting a headless-worker threshold.
|
|
924
|
+
4. **Fallback to this config's own ``"work"`` entry** (so a user who
|
|
925
|
+
only overrides ``work`` gets that value applied to unlisted
|
|
926
|
+
*headless* types too, not the hardcoded default) — and only
|
|
927
|
+
reaches for the hardcoded default when even ``"work"`` was never
|
|
928
|
+
configured. This fallback is deliberately scoped to headless
|
|
929
|
+
types by the ``INTERACTIVE_SESSION_TYPES`` check above it: unlike
|
|
930
|
+
an unlisted headless type (probably work-like), an unlisted
|
|
931
|
+
interactive type has no wall-clock-stuck concept at all, so
|
|
932
|
+
silently reusing ``"work"``'s threshold for it would be a
|
|
933
|
+
category error, not a reasonable guess.
|
|
934
|
+
"""
|
|
935
|
+
if (
|
|
936
|
+
assignment_type in ("work", "review", "smoke")
|
|
937
|
+
and provider_name == "claude-pty"
|
|
938
|
+
and review_of_assignment_id is not None
|
|
939
|
+
):
|
|
940
|
+
return self.attention_threshold_for("conflict-fix")
|
|
941
|
+
if assignment_type in self.attention_thresholds:
|
|
942
|
+
return self.attention_thresholds[assignment_type]
|
|
943
|
+
if assignment_type in INTERACTIVE_SESSION_TYPES:
|
|
944
|
+
return float("inf")
|
|
945
|
+
return self.attention_thresholds.get(
|
|
946
|
+
"work", _DEFAULT_ATTENTION_THRESHOLDS["work"]
|
|
947
|
+
)
|
|
948
|
+
|
|
949
|
+
def tracked_labels(self) -> list[str]:
|
|
950
|
+
"""Return the GitHub issue labels considered part of the pipeline.
|
|
951
|
+
|
|
952
|
+
Always includes ``'coord'`` so normal coordinator-tagged issues appear
|
|
953
|
+
in the pipeline panel regardless of per-label gate configuration.
|
|
954
|
+
Additional labels come from the ``labels`` dict keys, sorted for
|
|
955
|
+
stable ordering.
|
|
956
|
+
"""
|
|
957
|
+
if not self.labels:
|
|
958
|
+
return ["coord"]
|
|
959
|
+
keys = sorted(self.labels.keys())
|
|
960
|
+
if "coord" not in keys:
|
|
961
|
+
keys = ["coord"] + keys
|
|
962
|
+
return keys
|
|
963
|
+
|
|
964
|
+
def gates_for_label(self, label: str | None) -> list[str]:
|
|
965
|
+
"""Return the gate list for a specific label, falling back to defaults.
|
|
966
|
+
|
|
967
|
+
``label`` may be ``None`` (no matching tracked label found on the
|
|
968
|
+
issue) — in that case the configured ``default_gates`` are returned.
|
|
969
|
+
"""
|
|
970
|
+
if label and label in self.labels:
|
|
971
|
+
return list(self.labels[label])
|
|
972
|
+
return list(self.default_gates)
|
|
973
|
+
|
|
974
|
+
def test_precedes_review(self) -> bool:
|
|
975
|
+
"""True when the ``test`` gate is ordered *before* ``review`` in the
|
|
976
|
+
default gate list — i.e. the smoke/test verdict gates review dispatch
|
|
977
|
+
(Work → Test → Review), rather than gating only the merge.
|
|
978
|
+
|
|
979
|
+
When both gates are present and ``test`` comes first, automatic review
|
|
980
|
+
dispatch waits for a ``passed``/``skipped`` test verdict (see
|
|
981
|
+
``coord.review.dispatch_pending_reviews``); when ``review`` comes first
|
|
982
|
+
(or either gate is absent) review fires on work completion as before.
|
|
983
|
+
Consulted on the *default* policy only — this governs headless
|
|
984
|
+
review *dispatch* timing (``coord.review.dispatch_pending_reviews``),
|
|
985
|
+
which does not consult per-label overrides. This differs from the
|
|
986
|
+
merge gate's ``requires_smoke``/``requires_review`` (`coord/
|
|
987
|
+
merge_queue.py`), which *do* honour a work item's resolved
|
|
988
|
+
``required_gates`` (falling back to this default list) since #1213 —
|
|
989
|
+
so a ``["merge"]``-only label can bypass the merge-time review/test
|
|
990
|
+
gates even though a review may still be auto-dispatched under the
|
|
991
|
+
default policy here.
|
|
992
|
+
"""
|
|
993
|
+
gates = self.default_gates or []
|
|
994
|
+
if "test" not in gates or "review" not in gates:
|
|
995
|
+
return False
|
|
996
|
+
return gates.index("test") < gates.index("review")
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
@dataclass
|
|
1000
|
+
class MergeConfig:
|
|
1001
|
+
"""Merge behaviour configuration.
|
|
1002
|
+
|
|
1003
|
+
``auto_drain`` enables automatic draining of READY merge-queue entries on
|
|
1004
|
+
each daemon passive tick. **Default-off** — with no ``merge:`` block in
|
|
1005
|
+
``coordinator.yml`` the daemon never merges automatically and existing
|
|
1006
|
+
behaviour is unchanged.
|
|
1007
|
+
|
|
1008
|
+
When enabled, after the enqueue step in ``_tick_loop`` the daemon calls
|
|
1009
|
+
:func:`coord.serve_app._auto_drain_tick`, which evaluates the plan
|
|
1010
|
+
(review + smoke + CI gates) and merges exactly the entries marked
|
|
1011
|
+
``READY``, in true ``sequence()`` order. ``BLOCKED`` and terminal
|
|
1012
|
+
entries are never touched. Every auto-merge is logged so the operator
|
|
1013
|
+
can audit what drained (#781).
|
|
1014
|
+
|
|
1015
|
+
Set ``max_per_tick`` to cap how many merges the daemon may perform in a
|
|
1016
|
+
single tick (default ``0`` = unlimited).
|
|
1017
|
+
|
|
1018
|
+
``sibling_overlap_aging_hours`` (#920) gates
|
|
1019
|
+
:func:`coord.merge_queue.find_sibling_overlaps` — the "these approved
|
|
1020
|
+
branches will conflict if merged out of order or late" warning shown by
|
|
1021
|
+
``coord status`` / ``coord merge --plan``. It's the number of hours the
|
|
1022
|
+
oldest entry in a file-overlapping cluster of approved (PENDING) queue
|
|
1023
|
+
entries must have been waiting before the warning fires. ``0`` disables
|
|
1024
|
+
the warning entirely. Default ``24.0``.
|
|
1025
|
+
"""
|
|
1026
|
+
|
|
1027
|
+
auto_drain: bool = False
|
|
1028
|
+
max_per_tick: int = 0
|
|
1029
|
+
auto_reap_merged: bool = True
|
|
1030
|
+
sibling_overlap_aging_hours: float = 24.0
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
@dataclass
|
|
1034
|
+
class MilestoneConfig:
|
|
1035
|
+
"""Milestone-driven-workflow configuration (#767 / #769 Phase 1).
|
|
1036
|
+
|
|
1037
|
+
``auto_dispatch`` enables the daemon's tick loop to keep draining a
|
|
1038
|
+
milestone's declared work order after ``coord milestone dispatch``
|
|
1039
|
+
registers it: as issues reach a merged/terminal state, the newly-
|
|
1040
|
+
unblocked ready frontier is recomputed and dispatched automatically —
|
|
1041
|
+
no further human approval per issue, since the *declared* work order
|
|
1042
|
+
(the `## Work order` block) was the one-time approval unit.
|
|
1043
|
+
**Default-off** — with no ``milestone:`` block in ``coordinator.yml``
|
|
1044
|
+
the daemon never auto-dispatches and existing behaviour is unchanged;
|
|
1045
|
+
`coord milestone dispatch` still works as a one-shot manual drain.
|
|
1046
|
+
|
|
1047
|
+
When enabled, :func:`coord.serve_app._milestone_drain_tick` runs on
|
|
1048
|
+
each daemon tick (after the reconcile step) for every milestone
|
|
1049
|
+
registered via a non-dry-run `coord milestone dispatch` call, and
|
|
1050
|
+
deregisters a milestone once its whole work order reaches a terminal
|
|
1051
|
+
state.
|
|
1052
|
+
|
|
1053
|
+
Editing this wiring requires a **daemon restart** to take effect — the
|
|
1054
|
+
tick loop's closures are captured at ``coord serve`` startup time.
|
|
1055
|
+
"""
|
|
1056
|
+
|
|
1057
|
+
auto_dispatch: bool = False
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
@dataclass
|
|
1061
|
+
class CiStoreConfig:
|
|
1062
|
+
"""Backend selection for CI check visibility (#240).
|
|
1063
|
+
|
|
1064
|
+
``type`` is one of ``github`` (shell out to ``gh pr checks``) or
|
|
1065
|
+
``none`` (always-empty :class:`coord.ci_store.NoOpCi`). When the block
|
|
1066
|
+
is absent we default to ``github`` since it's a no-op upgrade for users
|
|
1067
|
+
who already have ``gh`` configured. Future backends (GitLab, Buildkite)
|
|
1068
|
+
add new ``type`` values without breaking existing configs.
|
|
1069
|
+
"""
|
|
1070
|
+
|
|
1071
|
+
type: str = "github"
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
@dataclass
|
|
1075
|
+
class AuditConfig:
|
|
1076
|
+
"""``audit:`` block (#1036/#1038) — the append-only ``audit_log``
|
|
1077
|
+
table's tunables.
|
|
1078
|
+
|
|
1079
|
+
``max_rows`` is a future retention cap, not a pruning sweep: when set
|
|
1080
|
+
above the default ``0`` (unlimited), :func:`coord.audit.record_audit`
|
|
1081
|
+
opportunistically deletes the oldest rows past that count after every
|
|
1082
|
+
insert. ``0`` means keep everything forever — the default for this
|
|
1083
|
+
milestone, since retention policy is explicitly out of scope (see the
|
|
1084
|
+
issue's "Out of scope" section).
|
|
1085
|
+
|
|
1086
|
+
``level`` (#1038) selects how much of the audit taxonomy is captured:
|
|
1087
|
+
``"business"`` records only real board transitions (dispatch, verdicts,
|
|
1088
|
+
merge, ...); ``"operational"`` (the default) additionally records the
|
|
1089
|
+
daemon-tick's autonomic actions (passive reconcile, merge-queue
|
|
1090
|
+
enqueue/drain, conflict-fix dispatch, housekeeping sweeps) tagged
|
|
1091
|
+
``tier="operational"``, ``actor="daemon"``. Business-tier rows are
|
|
1092
|
+
always recorded regardless of ``level`` — this only gates the
|
|
1093
|
+
operational tier.
|
|
1094
|
+
"""
|
|
1095
|
+
|
|
1096
|
+
max_rows: int = 0
|
|
1097
|
+
level: str = "operational"
|
|
1098
|
+
|
|
1099
|
+
|
|
1100
|
+
@dataclass
|
|
1101
|
+
class ModelRates:
|
|
1102
|
+
"""Per-1M-token USD rates for one canonical model (#1118 ``pricing:`` block).
|
|
1103
|
+
|
|
1104
|
+
Consumed by :mod:`coord.usage_rollup`'s cost estimator for legs that have
|
|
1105
|
+
no captured ``cost_usd``. All four fields default to ``0.0`` so a
|
|
1106
|
+
partially-specified override (e.g. only ``input``) still produces a
|
|
1107
|
+
valid (if incomplete) rate rather than raising.
|
|
1108
|
+
"""
|
|
1109
|
+
|
|
1110
|
+
input: float = 0.0
|
|
1111
|
+
output: float = 0.0
|
|
1112
|
+
cache_read: float = 0.0
|
|
1113
|
+
cache_creation: float = 0.0
|
|
1114
|
+
|
|
1115
|
+
|
|
1116
|
+
def _default_pricing() -> dict[str, ModelRates]:
|
|
1117
|
+
"""Built-in per-1M-token rates for the four canonical model tiers.
|
|
1118
|
+
|
|
1119
|
+
Official Anthropic list pricing at time of writing (Sonnet/Opus/Haiku/
|
|
1120
|
+
Fable input+output list price; cache_read = 0.1x input, cache_creation =
|
|
1121
|
+
1.25x input, the standard 5-minute-TTL cache economics) — pinned exactly
|
|
1122
|
+
by ``test_pricing_absent_defaults_to_builtin_rates`` in
|
|
1123
|
+
``tests/test_config_pricing.py`` so it can't silently drift again. A
|
|
1124
|
+
``pricing:`` block in coordinator.yml overrides or extends any of these.
|
|
1125
|
+
|
|
1126
|
+
#1290: the Opus row previously carried ``15.00/75.00`` (Opus 3 / 4.0 /
|
|
1127
|
+
4.1 era pricing, mistakenly pinned as "verified" at #1118 review).
|
|
1128
|
+
Current Opus (4.6 through 4.8) list price is ``5.00/25.00`` — corrected
|
|
1129
|
+
here. Sonnet and Haiku were already correct.
|
|
1130
|
+
"""
|
|
1131
|
+
return {
|
|
1132
|
+
"sonnet": ModelRates(input=3.00, output=15.00, cache_read=0.30, cache_creation=3.75),
|
|
1133
|
+
"opus": ModelRates(input=5.00, output=25.00, cache_read=0.50, cache_creation=6.25),
|
|
1134
|
+
"haiku": ModelRates(input=1.00, output=5.00, cache_read=0.10, cache_creation=1.25),
|
|
1135
|
+
"fable": ModelRates(input=10.00, output=50.00, cache_read=1.00, cache_creation=12.50),
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
|
|
1139
|
+
@dataclass
|
|
1140
|
+
class PricingConfig:
|
|
1141
|
+
"""``pricing:`` block (#1118) — per-canonical-model per-1M-token USD rates.
|
|
1142
|
+
|
|
1143
|
+
``models`` maps a canonical model key (``"sonnet"``, ``"opus"``,
|
|
1144
|
+
``"haiku"``, or any operator-added key) to its :class:`ModelRates`. An
|
|
1145
|
+
absent ``pricing:`` block in coordinator.yml still yields the built-in
|
|
1146
|
+
defaults via :func:`_default_pricing`. A model key with no entry here
|
|
1147
|
+
(e.g. ``"(unknown)"``, or a genuinely unrecognized model string) has no
|
|
1148
|
+
rate — :mod:`coord.usage_rollup` treats that as "no estimate possible"
|
|
1149
|
+
and flags the group rather than silently reporting $0.
|
|
1150
|
+
"""
|
|
1151
|
+
|
|
1152
|
+
models: dict[str, ModelRates] = field(default_factory=_default_pricing)
|
|
1153
|
+
|
|
1154
|
+
def rates_for(self, canonical_model: str) -> ModelRates | None:
|
|
1155
|
+
"""Look up rates for a canonical model key, or ``None`` if unpriced."""
|
|
1156
|
+
return self.models.get(canonical_model)
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
@dataclass
|
|
1160
|
+
class ProviderDef:
|
|
1161
|
+
"""Definition of a single named worker-command provider.
|
|
1162
|
+
|
|
1163
|
+
Corresponds to one entry under ``providers.definitions`` in
|
|
1164
|
+
``coordinator.yml``. All fields except ``type`` are optional.
|
|
1165
|
+
|
|
1166
|
+
Attributes:
|
|
1167
|
+
type: Provider backend type. Currently supported values are
|
|
1168
|
+
``"claude"`` (legacy ``claude -p`` stream-json worker, the
|
|
1169
|
+
default) and ``"claude-pty"`` (interactive ``claude`` spawned
|
|
1170
|
+
inside a PTY for subscription-billed runs — see #425). The
|
|
1171
|
+
authoritative list of registered backends is built by
|
|
1172
|
+
:func:`coord.providers.build_provider`.
|
|
1173
|
+
binary: Override the worker binary path/name. ``None`` means the
|
|
1174
|
+
provider uses its own default (``"claude"`` for the claude
|
|
1175
|
+
backend).
|
|
1176
|
+
model: Pin this provider to a specific model id or alias. Used as
|
|
1177
|
+
the ``--model`` fallback in the provider's ``build_command``
|
|
1178
|
+
when neither an explicit per-call ``resolved_model`` nor
|
|
1179
|
+
``AssignmentSpec.model`` is set. For definitions whose ``type``
|
|
1180
|
+
is **not** ``"claude"``/``"claude-pty"`` (e.g. ``"opencode"``),
|
|
1181
|
+
``coord/dispatch.py``'s model resolution is provider-aware
|
|
1182
|
+
(#1706 review fix): when a dispatch has no explicit ``--model``
|
|
1183
|
+
and no label-routed model, and the effective provider's
|
|
1184
|
+
definition pins a ``model`` here, ``models.default`` is *not*
|
|
1185
|
+
applied and ``AssignmentSpec.model`` is left unset — so this
|
|
1186
|
+
field wins for the common "pin opencode to a model once" case.
|
|
1187
|
+
For definitions whose ``type`` IS ``claude``/``claude-pty``
|
|
1188
|
+
(regardless of what name they're registered under — see the
|
|
1189
|
+
``fast-claude`` example in ``coordinator.example.yml``),
|
|
1190
|
+
``models.default`` still always wins over this field when no
|
|
1191
|
+
explicit override is given, because ``AssignmentSpec.model``
|
|
1192
|
+
for those backends must go through ``models.resolve()``'s
|
|
1193
|
+
alias -> exact-id translation (``models.versions``), which a
|
|
1194
|
+
raw ``model`` value here would bypass.
|
|
1195
|
+
attach_url: Reserved for future attach-mode providers.
|
|
1196
|
+
env: Extra environment variables for the worker subprocess.
|
|
1197
|
+
Values may contain ``${VAR}`` placeholders which are expanded
|
|
1198
|
+
from :data:`os.environ` at parse time.
|
|
1199
|
+
extra_args: Additional command-line arguments appended to the
|
|
1200
|
+
worker argv.
|
|
1201
|
+
"""
|
|
1202
|
+
|
|
1203
|
+
type: str
|
|
1204
|
+
binary: str | None = None
|
|
1205
|
+
model: str | None = None
|
|
1206
|
+
attach_url: str | None = None
|
|
1207
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
1208
|
+
extra_args: list[str] = field(default_factory=list)
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
@dataclass
|
|
1212
|
+
class ProvidersConfig:
|
|
1213
|
+
"""Global provider registry.
|
|
1214
|
+
|
|
1215
|
+
Parsed from the optional ``providers:`` block in ``coordinator.yml``.
|
|
1216
|
+
When the block is absent, ``default == "claude"`` and an implicit
|
|
1217
|
+
``"claude"`` definition is present in ``definitions``.
|
|
1218
|
+
|
|
1219
|
+
Attributes:
|
|
1220
|
+
default: The provider name used when no per-spec, per-label, or
|
|
1221
|
+
per-repo override is set. Defaults to ``"claude"``.
|
|
1222
|
+
definitions: Named provider definitions keyed by provider name.
|
|
1223
|
+
An implicit ``"claude"`` entry is always materialised if absent.
|
|
1224
|
+
labels: Per-issue-label provider override (#1889), e.g.
|
|
1225
|
+
``{"harness:opencode": "opencode"}`` — mirrors
|
|
1226
|
+
:attr:`ModelsConfig.labels`' shape and precedent, including its
|
|
1227
|
+
provenance reporting (:meth:`model_for_labels_with_reason`
|
|
1228
|
+
here becomes :meth:`provider_for_labels_with_reason`). Resolved
|
|
1229
|
+
via :func:`coord.providers.resolve_provider_name`'s
|
|
1230
|
+
``issue_labels`` param, slotted into the precedence chain
|
|
1231
|
+
between *spec_provider* and *repo_provider* — see that
|
|
1232
|
+
function's docstring for the full chain. Values are validated
|
|
1233
|
+
against ``definitions`` at parse time in ``_parse_providers``
|
|
1234
|
+
(mirrors ``reviews.provider``, #1811): an unknown provider name
|
|
1235
|
+
here is a config-load error, not a dispatch-time surprise
|
|
1236
|
+
discovered at 2am. Every dispatch site gates this to
|
|
1237
|
+
``type="work"`` proposals only, the same restriction
|
|
1238
|
+
``models.labels`` uses (#1430) — plan/review/smoke dispatches
|
|
1239
|
+
must not inherit a harness-eval label meant for the eventual
|
|
1240
|
+
work dispatch.
|
|
1241
|
+
"""
|
|
1242
|
+
|
|
1243
|
+
default: str = "claude"
|
|
1244
|
+
definitions: dict[str, ProviderDef] = field(default_factory=dict)
|
|
1245
|
+
labels: dict[str, str] = field(default_factory=dict)
|
|
1246
|
+
|
|
1247
|
+
def __post_init__(self) -> None:
|
|
1248
|
+
# Always ensure the implicit "claude" definition exists so callers
|
|
1249
|
+
# can look it up by name without checking for its presence.
|
|
1250
|
+
if "claude" not in self.definitions:
|
|
1251
|
+
self.definitions["claude"] = ProviderDef(type="claude")
|
|
1252
|
+
|
|
1253
|
+
def provider_for_labels(self, issue_labels: list[str]) -> str | None:
|
|
1254
|
+
"""Resolve an issue's GitHub labels to a provider name via ``labels``.
|
|
1255
|
+
|
|
1256
|
+
#1889: mirrors :meth:`ModelsConfig.model_for_labels` — see
|
|
1257
|
+
:meth:`provider_for_labels_with_reason` for the full precedence
|
|
1258
|
+
rule. Returns ``None`` (never *default* or *repo_provider*) when no
|
|
1259
|
+
configured label is present on the issue, or ``labels`` itself is
|
|
1260
|
+
empty. Callers are expected to fall back to *repo_provider*/
|
|
1261
|
+
``default`` themselves (:func:`coord.providers.resolve_provider_name`
|
|
1262
|
+
does this).
|
|
1263
|
+
"""
|
|
1264
|
+
return self.provider_for_labels_with_reason(issue_labels)[0]
|
|
1265
|
+
|
|
1266
|
+
def provider_for_labels_with_reason(
|
|
1267
|
+
self, issue_labels: list[str]
|
|
1268
|
+
) -> tuple[str | None, str | None, list[str]]:
|
|
1269
|
+
"""Like :meth:`provider_for_labels`, but also returns the label that
|
|
1270
|
+
matched and any configured labels it shadowed.
|
|
1271
|
+
|
|
1272
|
+
Returns ``(provider, matched_label, shadowed_labels)``, or
|
|
1273
|
+
``(None, None, [])`` under the same conditions
|
|
1274
|
+
:meth:`provider_for_labels` returns ``None``.
|
|
1275
|
+
|
|
1276
|
+
#1889: mirrors :meth:`ModelsConfig.model_for_labels_with_reason`
|
|
1277
|
+
(#1633)'s provenance shape, minus the ``tier:*`` grouping —
|
|
1278
|
+
``providers.labels`` has no size-tier concept, so precedence among
|
|
1279
|
+
several configured labels present on the same issue (e.g. both
|
|
1280
|
+
``harness:opencode`` and ``harness:claude``) is simply ``labels``'s
|
|
1281
|
+
own declaration order in ``coordinator.yml``, exactly like
|
|
1282
|
+
``models.labels``' non-tier group. Ties are NOT broken by the
|
|
1283
|
+
issue's own label order (GitHub-controlled, not config-controlled)
|
|
1284
|
+
for the same reason #1633 fixed that for models: the same issue +
|
|
1285
|
+
config must always resolve to the same provider, regardless of
|
|
1286
|
+
what order GitHub reports the issue's labels in.
|
|
1287
|
+
"""
|
|
1288
|
+
if not self.labels:
|
|
1289
|
+
return None, None, []
|
|
1290
|
+
present_in_config_order = [
|
|
1291
|
+
label for label in self.labels if label in issue_labels
|
|
1292
|
+
]
|
|
1293
|
+
if not present_in_config_order:
|
|
1294
|
+
return None, None, []
|
|
1295
|
+
matched = present_in_config_order[0]
|
|
1296
|
+
shadowed = present_in_config_order[1:]
|
|
1297
|
+
return self.labels[matched], matched, shadowed
|
|
1298
|
+
|
|
1299
|
+
|
|
1300
|
+
# #1711: provider-availability capability vocabulary.
|
|
1301
|
+
#
|
|
1302
|
+
# A machine advertises support for a given provider *backend type* the same
|
|
1303
|
+
# way it advertises "rust"/"gtk"/"browser" — one more string in
|
|
1304
|
+
# `machines[].capabilities`, e.g. `"provider:opencode"`. A dedicated
|
|
1305
|
+
# `Machine.provider` field was considered and rejected: provider and machine
|
|
1306
|
+
# are already orthogonal in this data model (a machine has no provider
|
|
1307
|
+
# opinion of its own — `Repo.provider`/`providers.default` decide that), and
|
|
1308
|
+
# "can this machine run backend X" is exactly the shape `smoke_tests.
|
|
1309
|
+
# capability_rules` and `coord.prereqs` already solve for "rust"/"gtk"/
|
|
1310
|
+
# "browser". Reusing that machinery (rather than inventing a parallel
|
|
1311
|
+
# provider-routing concept) means `coord doctor`'s declared-vs-probed report
|
|
1312
|
+
# and the `coordinator.yml` capability list both fall out for free.
|
|
1313
|
+
#
|
|
1314
|
+
# Keyed off `ProviderDef.type` (not the definition's arbitrary registered
|
|
1315
|
+
# NAME) because type is what actually determines which binary a machine
|
|
1316
|
+
# needs installed. An operator-named alias of a claude-backed provider (e.g.
|
|
1317
|
+
# `fast-claude` in `coordinator.example.yml`, `type: claude`) never needs a
|
|
1318
|
+
# capability declared — it still just runs the `claude` CLI. Only a
|
|
1319
|
+
# genuinely different backend TYPE (today: `opencode`) needs one.
|
|
1320
|
+
#
|
|
1321
|
+
# `claude` and `claude-pty` are the IMPLICIT baseline: every machine is
|
|
1322
|
+
# assumed to already have the `claude` CLI (this predates #1711, and
|
|
1323
|
+
# probing for the `claude` binary itself is out of this issue's scope —
|
|
1324
|
+
# see its non-goals), so neither needs a capability declared. This is what
|
|
1325
|
+
# keeps every existing no-``providers:``-block deployment unaffected.
|
|
1326
|
+
IMPLICIT_PROVIDER_TYPES: frozenset[str] = frozenset({"claude", "claude-pty"})
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
def provider_capability(provider_type: str) -> str:
|
|
1330
|
+
"""The ``capabilities:`` string a machine advertises to declare it can
|
|
1331
|
+
run *provider_type* (#1711).
|
|
1332
|
+
|
|
1333
|
+
``provider_capability("opencode") == "provider:opencode"``. Single
|
|
1334
|
+
source of truth for the naming convention — every caller that declares,
|
|
1335
|
+
checks, or probes provider availability (`coord doctor`'s prereq
|
|
1336
|
+
manifest, `coord.providers.guard_provider_machine_capability`, `coord
|
|
1337
|
+
plan`'s proposal filter) calls this rather than hand-formatting the
|
|
1338
|
+
``"provider:" + name`` string, so the convention can't drift between
|
|
1339
|
+
call sites.
|
|
1340
|
+
"""
|
|
1341
|
+
return f"provider:{provider_type}"
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
def model_plausible_for_provider_type(model: str, provider_type: str) -> bool:
|
|
1345
|
+
"""Namespace-shape sanity check: could *model* plausibly belong to
|
|
1346
|
+
*provider_type* (#1798)?
|
|
1347
|
+
|
|
1348
|
+
A cheap, syntax-only heuristic — NOT a live catalog lookup (no network
|
|
1349
|
+
call, no per-provider model list to keep in sync). Every model
|
|
1350
|
+
identifier this coordinator actually resolves falls into one of two
|
|
1351
|
+
shapes, mirroring the two namespaces ``coord.dispatch.
|
|
1352
|
+
resolve_dispatch_model_alias`` already reasons about:
|
|
1353
|
+
|
|
1354
|
+
* Claude aliases (``sonnet``, ``opus``, ``haiku``) and exact ids
|
|
1355
|
+
resolved via ``models.versions`` (``claude-sonnet-4-6``) — never
|
|
1356
|
+
contain a ``/``.
|
|
1357
|
+
* OpenCode Zen model strings are always ``provider/model``
|
|
1358
|
+
(``opencode/glm-5.2``, ``deepseek/deepseek-chat`` — see
|
|
1359
|
+
``docs/OPENCODE_VERIFICATION.md``) — always contain a ``/``.
|
|
1360
|
+
|
|
1361
|
+
That's enough signal to catch #1798's actual failure mode — a Claude
|
|
1362
|
+
alias handed to an opencode-type backend (or vice versa) — before a
|
|
1363
|
+
dispatch spends a worktree and a network round-trip discovering the
|
|
1364
|
+
backend rejects it mid-run. ``claude``/``claude-pty``
|
|
1365
|
+
(:data:`IMPLICIT_PROVIDER_TYPES`) require NO ``/``; every other
|
|
1366
|
+
(non-implicit) provider type requires one.
|
|
1367
|
+
"""
|
|
1368
|
+
has_namespace = "/" in model
|
|
1369
|
+
if provider_type in IMPLICIT_PROVIDER_TYPES:
|
|
1370
|
+
return not has_namespace
|
|
1371
|
+
return has_namespace
|
|
1372
|
+
|
|
1373
|
+
|
|
1374
|
+
# #1628: default disk mount points the health engine probes. "/" and "/home"
|
|
1375
|
+
# are usually the two that matter (and were the two that mattered on
|
|
1376
|
+
# 2026-07-30 — elitebook's /home hit 0 bytes free); "~/.coord" is separate
|
|
1377
|
+
# because on some layouts the coordinator state dir is its own filesystem.
|
|
1378
|
+
# Probing the same *device* twice is deduped at run time, so a machine with
|
|
1379
|
+
# one big root filesystem still reports one line.
|
|
1380
|
+
_DEFAULT_HEALTH_DISK_PATHS = ("/", "/home", "~/.coord")
|
|
1381
|
+
|
|
1382
|
+
# The systemd *user* units `spawned_coord` (#1834) introspects. Duplicated
|
|
1383
|
+
# from `coord.health.checks.spawned_coord.DEFAULT_UNITS` rather than imported,
|
|
1384
|
+
# same reason as AGENT_PORT in coord/commands/_common.py: config must not
|
|
1385
|
+
# import the check registry (probes import config, not the other way round).
|
|
1386
|
+
# tests/test_release_verify.py pins the two lists together.
|
|
1387
|
+
_DEFAULT_SPAWNED_COORD_UNITS = (
|
|
1388
|
+
"coord-serve",
|
|
1389
|
+
"coord-agent",
|
|
1390
|
+
"coord-web",
|
|
1391
|
+
"coord-drive-queue",
|
|
1392
|
+
"coord-notify",
|
|
1393
|
+
)
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
@dataclass
|
|
1397
|
+
class HealthConfig:
|
|
1398
|
+
"""Thresholds for the fleet-health check engine (#1628, ``coord health``).
|
|
1399
|
+
|
|
1400
|
+
Every number here is a *default that would have caught the 2026-07-30
|
|
1401
|
+
incidents* — see ``tests/test_health_incident_regression.py``, which
|
|
1402
|
+
replays the recorded values against these defaults. Loosening one is
|
|
1403
|
+
therefore a decision to stop catching a class of failure that has
|
|
1404
|
+
already happened once, not a tuning preference; the regression test is
|
|
1405
|
+
there to make that trade explicit rather than accidental.
|
|
1406
|
+
|
|
1407
|
+
Disk thresholds are expressed as **percent free remaining** (headroom),
|
|
1408
|
+
not percent used, because headroom is the question the engine answers.
|
|
1409
|
+
"""
|
|
1410
|
+
|
|
1411
|
+
# Master switch. False makes `coord health` report nothing rather than
|
|
1412
|
+
# being removed from the CLI — a disabled check must still be visible.
|
|
1413
|
+
enabled: bool = True
|
|
1414
|
+
# Check ids to skip, e.g. ["plan_usage"] on a machine with no OAuth login.
|
|
1415
|
+
disabled_checks: list[str] = field(default_factory=list)
|
|
1416
|
+
|
|
1417
|
+
# ── disk free ─────────────────────────────────────────────────────────
|
|
1418
|
+
disk_paths: list[str] = field(
|
|
1419
|
+
default_factory=lambda: list(_DEFAULT_HEALTH_DISK_PATHS)
|
|
1420
|
+
)
|
|
1421
|
+
disk_warn_free_pct: float = 15.0
|
|
1422
|
+
disk_crit_free_pct: float = 7.0
|
|
1423
|
+
|
|
1424
|
+
# ── cargo target/ total across known dirs ─────────────────────────────
|
|
1425
|
+
cargo_target_warn_gb: float = 40.0
|
|
1426
|
+
cargo_target_crit_gb: float = 60.0
|
|
1427
|
+
# Extra target dirs to total beyond the shared per-machine cache and each
|
|
1428
|
+
# known checkout's own target/.
|
|
1429
|
+
cargo_target_extra_dirs: list[str] = field(default_factory=list)
|
|
1430
|
+
# Walking a 78G target dir is not free. The probe totals what it can in
|
|
1431
|
+
# this many seconds and reports a partial scan rather than blowing the
|
|
1432
|
+
# ~2s registry budget; a partial total is a *lower bound*, so a CRIT
|
|
1433
|
+
# derived from one is still trustworthy.
|
|
1434
|
+
cargo_scan_budget_secs: float = 1.5
|
|
1435
|
+
|
|
1436
|
+
# ── stale worktrees under ~/.coord/worktrees ──────────────────────────
|
|
1437
|
+
# "Stale" here is deliberately DB-free (see the probe's docstring): a
|
|
1438
|
+
# worktree directory untouched for this long. Counting live assignments
|
|
1439
|
+
# would need board state, which is H-3's job, not this child's.
|
|
1440
|
+
worktree_stale_hours: float = 48.0
|
|
1441
|
+
worktree_warn_count: int = 3
|
|
1442
|
+
worktree_crit_count: int = 10
|
|
1443
|
+
|
|
1444
|
+
# ── agent install ─────────────────────────────────────────────────────
|
|
1445
|
+
# Absolute path to the agent venv's python. None → autodetect
|
|
1446
|
+
# (~/.coord-venv/bin/python3, else the running interpreter).
|
|
1447
|
+
agent_venv_python: str | None = None
|
|
1448
|
+
agent_version_warn_behind: int = 1
|
|
1449
|
+
agent_version_crit_behind: int = 2
|
|
1450
|
+
# The *simple index*, not the JSON API: they flip independently in both
|
|
1451
|
+
# directions and only the simple index is what pip actually resolves
|
|
1452
|
+
# against, so a JSON-API answer can say "current" while pip disagrees.
|
|
1453
|
+
pypi_index_url: str = "https://pypi.org/simple"
|
|
1454
|
+
network_timeout_secs: float = 3.0
|
|
1455
|
+
|
|
1456
|
+
# ── graphify graph freshness ──────────────────────────────────────────
|
|
1457
|
+
# A stale graph whose checkout has hooks disabled is CRIT regardless of
|
|
1458
|
+
# age: it structurally cannot self-heal, so time only makes it worse.
|
|
1459
|
+
graph_stale_warn_hours: float = 24.0
|
|
1460
|
+
graph_stale_crit_hours: float = 72.0
|
|
1461
|
+
|
|
1462
|
+
# ── Max-plan usage windows (wraps coord.usage_limits) ─────────────────
|
|
1463
|
+
plan_usage_warn_pct: float = 85.0
|
|
1464
|
+
plan_usage_crit_pct: float = 95.0
|
|
1465
|
+
|
|
1466
|
+
# ── fleet deploy lanes, daemon host only (#1630) ──────────────────────
|
|
1467
|
+
# These three name the two deploy lanes that live on the *daemon* host
|
|
1468
|
+
# rather than on an agent: the operator's CLI venv, and the locally-built
|
|
1469
|
+
# coord-tui binary. All three follow `agent_venv_python`'s convention —
|
|
1470
|
+
# ``None`` means "use the documented default location", NOT "disable the
|
|
1471
|
+
# lane" — so the lanes are live on a stock install with no config at all,
|
|
1472
|
+
# and an operator only sets them when their layout differs.
|
|
1473
|
+
#
|
|
1474
|
+
# Absolute path to the operator CLI venv's python. None →
|
|
1475
|
+
# ~/.coord-cli-venv/bin/python3 (what the install docs create). This lane
|
|
1476
|
+
# exists because it was found three releases stale on 2026-07-29.
|
|
1477
|
+
cli_venv_python: str | None = None
|
|
1478
|
+
# Absolute path to the built coord-tui binary. None → ~/.local/bin/coord-tui
|
|
1479
|
+
# (README: `cd tui && cargo build && cp target/debug/coord-tui
|
|
1480
|
+
# ~/.local/bin/coord-tui`).
|
|
1481
|
+
tui_binary_path: str | None = None
|
|
1482
|
+
# Directory holding the tui/ Rust sources the binary was built from. None →
|
|
1483
|
+
# `<checkout>/tui/src` for the first configured local checkout that has one.
|
|
1484
|
+
# Deliberately points at `src/`, not the crate root: rooting the mtime walk
|
|
1485
|
+
# above `target/` would sweep a multi-GB build dir on every refresh.
|
|
1486
|
+
tui_source_dir: str | None = None
|
|
1487
|
+
# Absolute path to the live `coord web --dist` bundle (#1834 lane 5).
|
|
1488
|
+
# None → ~/coord-web-dist — the symlink `deploy/coord-web-dist-build.timer`
|
|
1489
|
+
# atomically repoints at each new release (#1543).
|
|
1490
|
+
webapp_dist_path: str | None = None
|
|
1491
|
+
# Directory holding the coord/dashboard/webapp/ sources the bundle was
|
|
1492
|
+
# built from. None → `<checkout>/coord/dashboard/webapp/src` for the
|
|
1493
|
+
# first configured local checkout that has one. Same `src/`-not-root
|
|
1494
|
+
# reasoning as `tui_source_dir`: rooting at the webapp package root would
|
|
1495
|
+
# sweep `node_modules`/`dist` were they not already skipped by name.
|
|
1496
|
+
webapp_source_dir: str | None = None
|
|
1497
|
+
|
|
1498
|
+
# ── systemd unit-file drift (#1831) ────────────────────────────────────
|
|
1499
|
+
# `deploy/*.service`/`*.timer` is version-controlled and reviewed but
|
|
1500
|
+
# nothing installs it — a unit hand-copied at machine setup drifts
|
|
1501
|
+
# forever from what's checked in. These two follow the same convention
|
|
1502
|
+
# as the deploy-lane paths above: None means "use the documented default
|
|
1503
|
+
# location", not "disable the check".
|
|
1504
|
+
#
|
|
1505
|
+
# The reference directory — a FALLBACK only, since #1927. The check now
|
|
1506
|
+
# diffs against `coord/deploy/` inside the installed distribution (the
|
|
1507
|
+
# released artifact for the running version, which cannot drift with
|
|
1508
|
+
# this host); both this setting and the checkout scan below apply only
|
|
1509
|
+
# when the installed wheel ships no units of its own, and whatever they
|
|
1510
|
+
# point at is reported as an unverified working copy.
|
|
1511
|
+
# None -> `<checkout>/deploy` for the first configured local checkout
|
|
1512
|
+
# that has one (normally the code-coordinator checkout in repo_paths).
|
|
1513
|
+
deploy_dir: str | None = None
|
|
1514
|
+
# Where systemd user units actually live. None -> ~/.config/systemd/user.
|
|
1515
|
+
systemd_user_dir: str | None = None
|
|
1516
|
+
|
|
1517
|
+
# ── what a running service actually spawns (#1834) ────────────────────
|
|
1518
|
+
# The systemd user units whose LIVE process environment `spawned_coord`
|
|
1519
|
+
# reads to predict which `coord` binary their subprocesses will get.
|
|
1520
|
+
# Unlike the path-ish options above, an EMPTY list here really does mean
|
|
1521
|
+
# "off" — the unit names are the check's entire subject, so there is no
|
|
1522
|
+
# documented default to fall back to once they are cleared.
|
|
1523
|
+
spawned_coord_units: list[str] = field(
|
|
1524
|
+
default_factory=lambda: list(_DEFAULT_SPAWNED_COORD_UNITS)
|
|
1525
|
+
)
|
|
1526
|
+
|
|
1527
|
+
|
|
1528
|
+
@dataclass
|
|
1529
|
+
class Config:
|
|
1530
|
+
repos: list[Repo]
|
|
1531
|
+
machines: list[Machine]
|
|
1532
|
+
hooks: HooksConfig = field(default_factory=HooksConfig)
|
|
1533
|
+
reviews: ReviewsConfig = field(default_factory=ReviewsConfig)
|
|
1534
|
+
concurrency: ConcurrencyConfig = field(default_factory=ConcurrencyConfig)
|
|
1535
|
+
smoke_tests: SmokeTestsConfig = field(default_factory=SmokeTestsConfig)
|
|
1536
|
+
acceptance: AcceptanceConfig = field(default_factory=AcceptanceConfig)
|
|
1537
|
+
models: ModelsConfig = field(default_factory=ModelsConfig)
|
|
1538
|
+
pipeline: PipelineConfig = field(default_factory=PipelineConfig)
|
|
1539
|
+
dispatch: DispatchConfig = field(default_factory=DispatchConfig)
|
|
1540
|
+
usage_gate: UsageGateConfig = field(default_factory=UsageGateConfig)
|
|
1541
|
+
ci_store: CiStoreConfig = field(default_factory=CiStoreConfig)
|
|
1542
|
+
merge: MergeConfig = field(default_factory=MergeConfig)
|
|
1543
|
+
milestone: MilestoneConfig = field(default_factory=MilestoneConfig)
|
|
1544
|
+
providers: ProvidersConfig = field(default_factory=ProvidersConfig)
|
|
1545
|
+
audit: AuditConfig = field(default_factory=AuditConfig)
|
|
1546
|
+
pricing: PricingConfig = field(default_factory=PricingConfig)
|
|
1547
|
+
health: HealthConfig = field(default_factory=HealthConfig)
|
|
1548
|
+
path: Path | None = None
|
|
1549
|
+
|
|
1550
|
+
def repo(self, name: str) -> Repo | None:
|
|
1551
|
+
return next((r for r in self.repos if r.name == name), None)
|
|
1552
|
+
|
|
1553
|
+
|
|
1554
|
+
def load(path: str | Path | None = None) -> Config:
|
|
1555
|
+
"""Load and validate a coordinator.yml file.
|
|
1556
|
+
|
|
1557
|
+
When ``path`` is None the location is resolved via
|
|
1558
|
+
:func:`resolve_config_path` (``$COORD_CONFIG`` → ``~/.coord/coordinator.yml``
|
|
1559
|
+
→ ``./coordinator.yml``), so the tool works on a machine without a repo
|
|
1560
|
+
checkout.
|
|
1561
|
+
"""
|
|
1562
|
+
p = Path(path).expanduser() if path is not None else resolve_config_path()
|
|
1563
|
+
if not p.exists():
|
|
1564
|
+
raise ConfigError(
|
|
1565
|
+
f"Config file not found: {p}. Create it at {USER_CONFIG_PATH} "
|
|
1566
|
+
f"(recommended — works without a repo checkout), pass --config <path>, "
|
|
1567
|
+
f"or set $COORD_CONFIG."
|
|
1568
|
+
)
|
|
1569
|
+
|
|
1570
|
+
try:
|
|
1571
|
+
raw = yaml.safe_load(p.read_text())
|
|
1572
|
+
except yaml.YAMLError as e:
|
|
1573
|
+
raise ConfigError(f"Invalid YAML in {p}: {e}") from e
|
|
1574
|
+
|
|
1575
|
+
if raw is None:
|
|
1576
|
+
raise ConfigError(f"Config file is empty: {p}")
|
|
1577
|
+
|
|
1578
|
+
return parse_mapping(raw, path=p)
|
|
1579
|
+
|
|
1580
|
+
|
|
1581
|
+
def parse_mapping(raw: Any, *, path: Path | None = None) -> Config:
|
|
1582
|
+
"""Validate an already-decoded coordinator.yml *mapping* into a :class:`Config`.
|
|
1583
|
+
|
|
1584
|
+
:func:`load` is this plus "read the YAML off disk first". Split out (#1538)
|
|
1585
|
+
so callers that already hold the mapping — notably the ``coord web
|
|
1586
|
+
--fixture`` seeded-board server, whose fixture JSON may carry an inline
|
|
1587
|
+
``config`` block — get the identical parsing/validation instead of a
|
|
1588
|
+
second, drifting hand-rolled Config builder.
|
|
1589
|
+
"""
|
|
1590
|
+
if not isinstance(raw, dict):
|
|
1591
|
+
raise ConfigError(f"Top-level config must be a mapping, got {type(raw).__name__}")
|
|
1592
|
+
|
|
1593
|
+
p = path
|
|
1594
|
+
repos = _parse_repos(raw.get("repos"))
|
|
1595
|
+
machines = _parse_machines(raw.get("machines"), repos)
|
|
1596
|
+
_validate_dependencies(repos)
|
|
1597
|
+
hooks = _parse_hooks(raw.get("hooks"))
|
|
1598
|
+
# #1811: providers parsed before reviews so `reviews.provider` can be
|
|
1599
|
+
# validated against `providers.definitions` at parse time — mirrors how
|
|
1600
|
+
# `reviews.repo_overrides` validates against `repo_names` above.
|
|
1601
|
+
providers = _parse_providers(raw.get("providers"))
|
|
1602
|
+
reviews = _parse_reviews(
|
|
1603
|
+
raw.get("reviews"), {r.name for r in repos}, set(providers.definitions),
|
|
1604
|
+
)
|
|
1605
|
+
concurrency = _parse_concurrency(raw.get("concurrency"))
|
|
1606
|
+
smoke_tests = _parse_smoke_tests(raw.get("smoke_tests"))
|
|
1607
|
+
acceptance = _parse_acceptance(raw.get("acceptance"))
|
|
1608
|
+
models = _parse_models(raw.get("models"))
|
|
1609
|
+
pipeline = _parse_pipeline(raw.get("pipeline"))
|
|
1610
|
+
dispatch = _parse_dispatch(raw.get("dispatch"))
|
|
1611
|
+
usage_gate = _parse_usage_gate(raw.get("usage_gate"))
|
|
1612
|
+
ci_store = _parse_ci_store(raw.get("ci_store"))
|
|
1613
|
+
merge = _parse_merge(raw.get("merge"))
|
|
1614
|
+
milestone = _parse_milestone(raw.get("milestone"))
|
|
1615
|
+
audit = _parse_audit(raw.get("audit"))
|
|
1616
|
+
pricing = _parse_pricing(raw.get("pricing"))
|
|
1617
|
+
health = _parse_health(raw.get("health"))
|
|
1618
|
+
|
|
1619
|
+
return Config(
|
|
1620
|
+
repos=repos,
|
|
1621
|
+
machines=machines,
|
|
1622
|
+
hooks=hooks,
|
|
1623
|
+
reviews=reviews,
|
|
1624
|
+
concurrency=concurrency,
|
|
1625
|
+
smoke_tests=smoke_tests,
|
|
1626
|
+
acceptance=acceptance,
|
|
1627
|
+
models=models,
|
|
1628
|
+
pipeline=pipeline,
|
|
1629
|
+
dispatch=dispatch,
|
|
1630
|
+
usage_gate=usage_gate,
|
|
1631
|
+
ci_store=ci_store,
|
|
1632
|
+
merge=merge,
|
|
1633
|
+
milestone=milestone,
|
|
1634
|
+
providers=providers,
|
|
1635
|
+
audit=audit,
|
|
1636
|
+
pricing=pricing,
|
|
1637
|
+
health=health,
|
|
1638
|
+
path=p,
|
|
1639
|
+
)
|
|
1640
|
+
|
|
1641
|
+
|
|
1642
|
+
def _parse_repos(raw: Any) -> list[Repo]:
|
|
1643
|
+
if raw is None:
|
|
1644
|
+
raise ConfigError("Config must define 'repos'")
|
|
1645
|
+
if not isinstance(raw, list):
|
|
1646
|
+
raise ConfigError("'repos' must be a list")
|
|
1647
|
+
if not raw:
|
|
1648
|
+
raise ConfigError("'repos' must contain at least one repo")
|
|
1649
|
+
|
|
1650
|
+
repos: list[Repo] = []
|
|
1651
|
+
seen: set[str] = set()
|
|
1652
|
+
for i, entry in enumerate(raw):
|
|
1653
|
+
if not isinstance(entry, dict):
|
|
1654
|
+
raise ConfigError(f"repos[{i}] must be a mapping, got {type(entry).__name__}")
|
|
1655
|
+
name = entry.get("name")
|
|
1656
|
+
github = entry.get("github")
|
|
1657
|
+
if not name or not isinstance(name, str):
|
|
1658
|
+
raise ConfigError(f"repos[{i}].name is required (string)")
|
|
1659
|
+
if not github or not isinstance(github, str):
|
|
1660
|
+
raise ConfigError(f"repos[{i}].github is required (string, 'owner/repo')")
|
|
1661
|
+
if "/" not in github:
|
|
1662
|
+
raise ConfigError(
|
|
1663
|
+
f"repos[{i}].github must be 'owner/repo', got {github!r}"
|
|
1664
|
+
)
|
|
1665
|
+
if name in seen:
|
|
1666
|
+
raise ConfigError(f"duplicate repo name: {name!r}")
|
|
1667
|
+
seen.add(name)
|
|
1668
|
+
|
|
1669
|
+
depends_on = entry.get("depends_on", []) or []
|
|
1670
|
+
if not isinstance(depends_on, list) or not all(isinstance(d, str) for d in depends_on):
|
|
1671
|
+
raise ConfigError(f"repos[{i}].depends_on must be a list of repo names")
|
|
1672
|
+
|
|
1673
|
+
default_branch = entry.get("default_branch", "main")
|
|
1674
|
+
if not isinstance(default_branch, str):
|
|
1675
|
+
raise ConfigError(f"repos[{i}].default_branch must be a string")
|
|
1676
|
+
|
|
1677
|
+
# #934: develop_branch — opt-in to the develop + feature-branch-
|
|
1678
|
+
# per-milestone git model (docs/PIPELINE_V2.md "Git model"). Absent
|
|
1679
|
+
# (None) by default so existing repos are unaffected.
|
|
1680
|
+
develop_branch = entry.get("develop_branch")
|
|
1681
|
+
if develop_branch is not None and not isinstance(develop_branch, str):
|
|
1682
|
+
raise ConfigError(f"repos[{i}].develop_branch must be a string")
|
|
1683
|
+
|
|
1684
|
+
build_command = entry.get("build_command")
|
|
1685
|
+
if build_command is not None and not isinstance(build_command, str):
|
|
1686
|
+
raise ConfigError(f"repos[{i}].build_command must be a string")
|
|
1687
|
+
test_command = entry.get("test_command")
|
|
1688
|
+
if test_command is not None and not isinstance(test_command, str):
|
|
1689
|
+
raise ConfigError(f"repos[{i}].test_command must be a string")
|
|
1690
|
+
# #296: run_cmd — optional shell command to launch the app for manual
|
|
1691
|
+
# smoke testing. Surfaced in the TUI Test stage detail panel.
|
|
1692
|
+
run_cmd = entry.get("run_cmd")
|
|
1693
|
+
if run_cmd is not None and not isinstance(run_cmd, str):
|
|
1694
|
+
raise ConfigError(f"repos[{i}].run_cmd must be a string")
|
|
1695
|
+
|
|
1696
|
+
worker_permissions = _parse_worker_permissions(entry.get("worker_permissions"), i)
|
|
1697
|
+
|
|
1698
|
+
housekeeping = entry.get("housekeeping", []) or []
|
|
1699
|
+
if not isinstance(housekeeping, list) or not all(isinstance(h, str) for h in housekeeping):
|
|
1700
|
+
raise ConfigError(f"repos[{i}].housekeeping must be a list of strings")
|
|
1701
|
+
|
|
1702
|
+
coordinator_only_files = entry.get("coordinator_only_files", []) or []
|
|
1703
|
+
if not isinstance(coordinator_only_files, list) or not all(isinstance(f, str) for f in coordinator_only_files):
|
|
1704
|
+
raise ConfigError(f"repos[{i}].coordinator_only_files must be a list of strings")
|
|
1705
|
+
|
|
1706
|
+
# #268: reference_repos — sibling repos a worker may reference
|
|
1707
|
+
# for context but doesn't actually build against.
|
|
1708
|
+
reference_repos = entry.get("reference_repos", []) or []
|
|
1709
|
+
if not isinstance(reference_repos, list) or not all(isinstance(r, str) for r in reference_repos):
|
|
1710
|
+
raise ConfigError(f"repos[{i}].reference_repos must be a list of repo names")
|
|
1711
|
+
|
|
1712
|
+
# #316: new_issue_guidance — inline markdown or repo-relative file path.
|
|
1713
|
+
new_issue_guidance = entry.get("new_issue_guidance")
|
|
1714
|
+
if new_issue_guidance is not None and not isinstance(new_issue_guidance, str):
|
|
1715
|
+
raise ConfigError(f"repos[{i}].new_issue_guidance must be a string")
|
|
1716
|
+
|
|
1717
|
+
# #305: artifact_paths — glob patterns for build artifacts to stash.
|
|
1718
|
+
artifact_paths_raw = entry.get("artifact_paths", []) or []
|
|
1719
|
+
if not isinstance(artifact_paths_raw, list):
|
|
1720
|
+
raise ConfigError(f"repos[{i}].artifact_paths must be a list of strings")
|
|
1721
|
+
for j, p in enumerate(artifact_paths_raw):
|
|
1722
|
+
if not isinstance(p, str):
|
|
1723
|
+
raise ConfigError(
|
|
1724
|
+
f"repos[{i}].artifact_paths[{j}] must be a string, "
|
|
1725
|
+
f"got {type(p).__name__}"
|
|
1726
|
+
)
|
|
1727
|
+
artifact_paths: list[str] = list(artifact_paths_raw)
|
|
1728
|
+
|
|
1729
|
+
# #323: optional per-repo provider override.
|
|
1730
|
+
repo_provider = entry.get("provider")
|
|
1731
|
+
if repo_provider is not None and not isinstance(repo_provider, str):
|
|
1732
|
+
raise ConfigError(f"repos[{i}].provider must be a string")
|
|
1733
|
+
|
|
1734
|
+
repos.append(
|
|
1735
|
+
Repo(
|
|
1736
|
+
name=name,
|
|
1737
|
+
github=github,
|
|
1738
|
+
depends_on=depends_on,
|
|
1739
|
+
default_branch=default_branch,
|
|
1740
|
+
develop_branch=develop_branch,
|
|
1741
|
+
build_command=build_command,
|
|
1742
|
+
test_command=test_command,
|
|
1743
|
+
run_cmd=run_cmd,
|
|
1744
|
+
worker_permissions=worker_permissions,
|
|
1745
|
+
housekeeping=housekeeping,
|
|
1746
|
+
coordinator_only_files=coordinator_only_files,
|
|
1747
|
+
reference_repos=reference_repos,
|
|
1748
|
+
new_issue_guidance=new_issue_guidance,
|
|
1749
|
+
artifact_paths=artifact_paths,
|
|
1750
|
+
provider=repo_provider,
|
|
1751
|
+
)
|
|
1752
|
+
)
|
|
1753
|
+
return repos
|
|
1754
|
+
|
|
1755
|
+
|
|
1756
|
+
def _parse_worker_permissions(raw: Any, repo_index: int) -> WorkerPermissionsConfig:
|
|
1757
|
+
"""Parse the ``worker_permissions`` block for a single repo.
|
|
1758
|
+
|
|
1759
|
+
When *raw* is ``None`` (key absent from YAML), the default deny-list is
|
|
1760
|
+
applied — safety by default. An explicit ``deny: []`` clears restrictions.
|
|
1761
|
+
"""
|
|
1762
|
+
if raw is None:
|
|
1763
|
+
return WorkerPermissionsConfig(deny=list(DEFAULT_DENY_COMMANDS))
|
|
1764
|
+
|
|
1765
|
+
if not isinstance(raw, dict):
|
|
1766
|
+
raise ConfigError(
|
|
1767
|
+
f"repos[{repo_index}].worker_permissions must be a mapping"
|
|
1768
|
+
)
|
|
1769
|
+
|
|
1770
|
+
allow = raw.get("allow", []) or []
|
|
1771
|
+
if not isinstance(allow, list) or not all(isinstance(a, str) for a in allow):
|
|
1772
|
+
raise ConfigError(
|
|
1773
|
+
f"repos[{repo_index}].worker_permissions.allow must be a list of strings"
|
|
1774
|
+
)
|
|
1775
|
+
|
|
1776
|
+
deny = raw.get("deny", []) or []
|
|
1777
|
+
if not isinstance(deny, list) or not all(isinstance(d, str) for d in deny):
|
|
1778
|
+
raise ConfigError(
|
|
1779
|
+
f"repos[{repo_index}].worker_permissions.deny must be a list of strings"
|
|
1780
|
+
)
|
|
1781
|
+
|
|
1782
|
+
return WorkerPermissionsConfig(allow=allow, deny=deny)
|
|
1783
|
+
|
|
1784
|
+
|
|
1785
|
+
# #1862: 24h "HH:MM" — the only shape `quiet_hours.start`/`.end` accept.
|
|
1786
|
+
_HHMM_RE = re.compile(r"^([01]\d|2[0-3]):([0-5]\d)$")
|
|
1787
|
+
|
|
1788
|
+
|
|
1789
|
+
def _parse_hhmm(raw: Any, *, field_path: str) -> time:
|
|
1790
|
+
if not isinstance(raw, str):
|
|
1791
|
+
raise ConfigError(
|
|
1792
|
+
f"{field_path} must be a 24h 'HH:MM' string, got {type(raw).__name__}"
|
|
1793
|
+
)
|
|
1794
|
+
m = _HHMM_RE.match(raw)
|
|
1795
|
+
if not m:
|
|
1796
|
+
raise ConfigError(f"{field_path} must be 24h 'HH:MM' (e.g. '23:00'), got {raw!r}")
|
|
1797
|
+
return time(int(m.group(1)), int(m.group(2)))
|
|
1798
|
+
|
|
1799
|
+
|
|
1800
|
+
def _parse_quiet_hours(raw: Any, *, machine_index: int, machine_name: str) -> QuietHours | None:
|
|
1801
|
+
"""Parse ``machines[i].quiet_hours`` (#1862). ``None`` input → ``None``
|
|
1802
|
+
(no window — the default, unchanged-behaviour case).
|
|
1803
|
+
|
|
1804
|
+
``tz`` is REQUIRED and validated against the IANA database: `coord
|
|
1805
|
+
serve` runs on UTC, so a naive time-of-day compared against the
|
|
1806
|
+
daemon's own clock would silently fire hours off from what a non-UTC
|
|
1807
|
+
operator wrote — a quiet-hours feature that activates early is worse
|
|
1808
|
+
than none, because it silently pulls the machine out of the fleet
|
|
1809
|
+
during the working day. Fail loudly here rather than default quietly.
|
|
1810
|
+
"""
|
|
1811
|
+
if raw is None:
|
|
1812
|
+
return None
|
|
1813
|
+
prefix = f"machines[{machine_index}] ({machine_name!r}).quiet_hours"
|
|
1814
|
+
if not isinstance(raw, dict):
|
|
1815
|
+
raise ConfigError(f"{prefix} must be a mapping")
|
|
1816
|
+
|
|
1817
|
+
start = _parse_hhmm(raw.get("start"), field_path=f"{prefix}.start")
|
|
1818
|
+
end = _parse_hhmm(raw.get("end"), field_path=f"{prefix}.end")
|
|
1819
|
+
|
|
1820
|
+
tz = raw.get("tz")
|
|
1821
|
+
if not tz or not isinstance(tz, str):
|
|
1822
|
+
raise ConfigError(
|
|
1823
|
+
f"{prefix}.tz is required (IANA zone name, e.g. 'America/Chicago') — "
|
|
1824
|
+
"quiet hours never default to the daemon's own UTC clock, since that "
|
|
1825
|
+
"would silently fire at the wrong local hour"
|
|
1826
|
+
)
|
|
1827
|
+
try:
|
|
1828
|
+
ZoneInfo(tz)
|
|
1829
|
+
except (ZoneInfoNotFoundError, ValueError, OSError) as e:
|
|
1830
|
+
raise ConfigError(f"{prefix}.tz {tz!r} is not a known IANA zone name: {e}") from e
|
|
1831
|
+
|
|
1832
|
+
if start == end:
|
|
1833
|
+
raise ConfigError(
|
|
1834
|
+
f"{prefix}: start and end must differ ('always quiet' is ambiguous — "
|
|
1835
|
+
"use `coord pause` to take a machine out of rotation indefinitely instead)"
|
|
1836
|
+
)
|
|
1837
|
+
|
|
1838
|
+
return QuietHours(start=start, end=end, tz=tz)
|
|
1839
|
+
|
|
1840
|
+
|
|
1841
|
+
def _parse_machines(raw: Any, repos: list[Repo]) -> list[Machine]:
|
|
1842
|
+
if raw is None:
|
|
1843
|
+
raise ConfigError("Config must define 'machines'")
|
|
1844
|
+
if not isinstance(raw, list):
|
|
1845
|
+
raise ConfigError("'machines' must be a list")
|
|
1846
|
+
if not raw:
|
|
1847
|
+
raise ConfigError("'machines' must contain at least one machine")
|
|
1848
|
+
|
|
1849
|
+
repo_names = {r.name for r in repos}
|
|
1850
|
+
machines: list[Machine] = []
|
|
1851
|
+
seen: set[str] = set()
|
|
1852
|
+
for i, entry in enumerate(raw):
|
|
1853
|
+
if not isinstance(entry, dict):
|
|
1854
|
+
raise ConfigError(f"machines[{i}] must be a mapping, got {type(entry).__name__}")
|
|
1855
|
+
name = entry.get("name")
|
|
1856
|
+
host = entry.get("host")
|
|
1857
|
+
if not name or not isinstance(name, str):
|
|
1858
|
+
raise ConfigError(f"machines[{i}].name is required (string)")
|
|
1859
|
+
if not host or not isinstance(host, str):
|
|
1860
|
+
raise ConfigError(f"machines[{i}].host is required (string, tailscale hostname)")
|
|
1861
|
+
if name in seen:
|
|
1862
|
+
raise ConfigError(f"duplicate machine name: {name!r}")
|
|
1863
|
+
seen.add(name)
|
|
1864
|
+
|
|
1865
|
+
capabilities = entry.get("capabilities", []) or []
|
|
1866
|
+
if not isinstance(capabilities, list) or not all(isinstance(c, str) for c in capabilities):
|
|
1867
|
+
raise ConfigError(f"machines[{i}].capabilities must be a list of strings")
|
|
1868
|
+
|
|
1869
|
+
machine_repos = entry.get("repos", []) or []
|
|
1870
|
+
if not isinstance(machine_repos, list) or not all(isinstance(r, str) for r in machine_repos):
|
|
1871
|
+
raise ConfigError(f"machines[{i}].repos must be a list of repo names")
|
|
1872
|
+
|
|
1873
|
+
unknown = [r for r in machine_repos if r not in repo_names]
|
|
1874
|
+
if unknown:
|
|
1875
|
+
raise ConfigError(
|
|
1876
|
+
f"machines[{i}] ({name!r}) references unknown repos: {unknown}"
|
|
1877
|
+
)
|
|
1878
|
+
|
|
1879
|
+
repo_paths = entry.get("repo_paths", {}) or {}
|
|
1880
|
+
if not isinstance(repo_paths, dict) or not all(
|
|
1881
|
+
isinstance(k, str) and isinstance(v, str) for k, v in repo_paths.items()
|
|
1882
|
+
):
|
|
1883
|
+
raise ConfigError(f"machines[{i}].repo_paths must be a mapping of repo name → local path")
|
|
1884
|
+
unknown_paths = [r for r in repo_paths if r not in repo_names]
|
|
1885
|
+
if unknown_paths:
|
|
1886
|
+
raise ConfigError(
|
|
1887
|
+
f"machines[{i}] ({name!r}) repo_paths references unknown repos: {unknown_paths}"
|
|
1888
|
+
)
|
|
1889
|
+
|
|
1890
|
+
# #1417: optional per-machine capacity override. `None` (unset)
|
|
1891
|
+
# means "use concurrency.max_workers" — see Machine.max_workers.
|
|
1892
|
+
machine_max_workers = entry.get("max_workers")
|
|
1893
|
+
if machine_max_workers is not None:
|
|
1894
|
+
if isinstance(machine_max_workers, bool) or not isinstance(machine_max_workers, int):
|
|
1895
|
+
raise ConfigError(f"machines[{i}].max_workers must be an integer")
|
|
1896
|
+
if machine_max_workers < 1:
|
|
1897
|
+
raise ConfigError(f"machines[{i}].max_workers must be at least 1")
|
|
1898
|
+
|
|
1899
|
+
quiet_hours = _parse_quiet_hours(
|
|
1900
|
+
entry.get("quiet_hours"), machine_index=i, machine_name=name,
|
|
1901
|
+
)
|
|
1902
|
+
|
|
1903
|
+
machines.append(
|
|
1904
|
+
Machine(
|
|
1905
|
+
name=name,
|
|
1906
|
+
host=host,
|
|
1907
|
+
capabilities=capabilities,
|
|
1908
|
+
repos=machine_repos,
|
|
1909
|
+
repo_paths=repo_paths,
|
|
1910
|
+
max_workers=machine_max_workers,
|
|
1911
|
+
quiet_hours=quiet_hours,
|
|
1912
|
+
)
|
|
1913
|
+
)
|
|
1914
|
+
return machines
|
|
1915
|
+
|
|
1916
|
+
|
|
1917
|
+
KNOWN_HOOKS = {"close_merged_issues", "summary_report"}
|
|
1918
|
+
|
|
1919
|
+
|
|
1920
|
+
def _parse_hooks(raw: Any) -> HooksConfig:
|
|
1921
|
+
if raw is None:
|
|
1922
|
+
return HooksConfig()
|
|
1923
|
+
if not isinstance(raw, dict):
|
|
1924
|
+
raise ConfigError("'hooks' must be a mapping")
|
|
1925
|
+
hooks = HooksConfig()
|
|
1926
|
+
for event_name in ("on_round_complete", "on_session_end"):
|
|
1927
|
+
entries = raw.get(event_name)
|
|
1928
|
+
if entries is None:
|
|
1929
|
+
continue
|
|
1930
|
+
if not isinstance(entries, list) or not all(isinstance(e, str) for e in entries):
|
|
1931
|
+
raise ConfigError(f"hooks.{event_name} must be a list of hook names")
|
|
1932
|
+
unknown = [e for e in entries if e not in KNOWN_HOOKS]
|
|
1933
|
+
if unknown:
|
|
1934
|
+
raise ConfigError(
|
|
1935
|
+
f"hooks.{event_name} references unknown hooks: {unknown}. "
|
|
1936
|
+
f"Known: {sorted(KNOWN_HOOKS)}"
|
|
1937
|
+
)
|
|
1938
|
+
setattr(hooks, event_name, entries)
|
|
1939
|
+
return hooks
|
|
1940
|
+
|
|
1941
|
+
|
|
1942
|
+
def _parse_reviews(
|
|
1943
|
+
raw: Any, repo_names: set[str], provider_names: set[str] | None = None,
|
|
1944
|
+
) -> ReviewsConfig:
|
|
1945
|
+
if raw is None:
|
|
1946
|
+
return ReviewsConfig()
|
|
1947
|
+
if not isinstance(raw, dict):
|
|
1948
|
+
raise ConfigError("'reviews' must be a mapping")
|
|
1949
|
+
|
|
1950
|
+
cfg = ReviewsConfig()
|
|
1951
|
+
|
|
1952
|
+
# #1811: reviews.provider — validated against providers.definitions the
|
|
1953
|
+
# same way reviews.repo_overrides is validated against repo_names below,
|
|
1954
|
+
# so an unknown name is a config error at parse time, not a silent
|
|
1955
|
+
# dispatch-time fallback.
|
|
1956
|
+
if "provider" in raw:
|
|
1957
|
+
value = raw["provider"]
|
|
1958
|
+
if not isinstance(value, str) or not value:
|
|
1959
|
+
raise ConfigError("reviews.provider must be a non-empty string")
|
|
1960
|
+
if provider_names is not None and value not in provider_names:
|
|
1961
|
+
raise ConfigError(
|
|
1962
|
+
f"reviews.provider references unknown provider: {value!r}"
|
|
1963
|
+
)
|
|
1964
|
+
cfg.provider = value
|
|
1965
|
+
|
|
1966
|
+
for bool_field in ("enabled", "auto_dispatch", "require_approval", "allow_review_flood"):
|
|
1967
|
+
if bool_field in raw:
|
|
1968
|
+
value = raw[bool_field]
|
|
1969
|
+
if not isinstance(value, bool):
|
|
1970
|
+
raise ConfigError(f"reviews.{bool_field} must be a boolean")
|
|
1971
|
+
setattr(cfg, bool_field, value)
|
|
1972
|
+
|
|
1973
|
+
for int_field in ("max_auto_dispatch_per_pass", "flood_threshold", "reaffirm_max_diff_lines"):
|
|
1974
|
+
if int_field in raw:
|
|
1975
|
+
value = raw[int_field]
|
|
1976
|
+
# bool is a subclass of int — reject it explicitly so a stray
|
|
1977
|
+
# `flood_threshold: true` doesn't silently become 1.
|
|
1978
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
1979
|
+
raise ConfigError(f"reviews.{int_field} must be a non-negative integer")
|
|
1980
|
+
setattr(cfg, int_field, value)
|
|
1981
|
+
|
|
1982
|
+
if "reviewer_prompt" in raw:
|
|
1983
|
+
value = raw["reviewer_prompt"]
|
|
1984
|
+
if not isinstance(value, str):
|
|
1985
|
+
raise ConfigError("reviews.reviewer_prompt must be a string")
|
|
1986
|
+
cfg.reviewer_prompt = value
|
|
1987
|
+
|
|
1988
|
+
checklist = raw.get("checklist", []) or []
|
|
1989
|
+
if not isinstance(checklist, list) or not all(isinstance(c, str) for c in checklist):
|
|
1990
|
+
raise ConfigError("reviews.checklist must be a list of strings")
|
|
1991
|
+
cfg.checklist = checklist
|
|
1992
|
+
|
|
1993
|
+
overrides = raw.get("repo_overrides", {}) or {}
|
|
1994
|
+
if not isinstance(overrides, dict):
|
|
1995
|
+
raise ConfigError("reviews.repo_overrides must be a mapping of repo → list of strings")
|
|
1996
|
+
for repo_name, items in overrides.items():
|
|
1997
|
+
if not isinstance(repo_name, str):
|
|
1998
|
+
raise ConfigError("reviews.repo_overrides keys must be repo names")
|
|
1999
|
+
if repo_name not in repo_names:
|
|
2000
|
+
raise ConfigError(
|
|
2001
|
+
f"reviews.repo_overrides references unknown repo: {repo_name!r}"
|
|
2002
|
+
)
|
|
2003
|
+
if not isinstance(items, list) or not all(isinstance(i, str) for i in items):
|
|
2004
|
+
raise ConfigError(
|
|
2005
|
+
f"reviews.repo_overrides[{repo_name}] must be a list of strings"
|
|
2006
|
+
)
|
|
2007
|
+
cfg.repo_overrides = overrides
|
|
2008
|
+
return cfg
|
|
2009
|
+
|
|
2010
|
+
|
|
2011
|
+
def _parse_concurrency(raw: Any) -> ConcurrencyConfig:
|
|
2012
|
+
if raw is None:
|
|
2013
|
+
return ConcurrencyConfig()
|
|
2014
|
+
if not isinstance(raw, dict):
|
|
2015
|
+
raise ConfigError("'concurrency' must be a mapping")
|
|
2016
|
+
cfg = ConcurrencyConfig()
|
|
2017
|
+
for key in (
|
|
2018
|
+
"max_workers", "stagger_seconds", "backoff_base", "max_retries",
|
|
2019
|
+
"stale_threshold", "first_output_timeout", "interactive_session_timeout_hours",
|
|
2020
|
+
):
|
|
2021
|
+
val = raw.get(key)
|
|
2022
|
+
if val is None:
|
|
2023
|
+
continue
|
|
2024
|
+
if key in ("max_retries", "max_workers", "stale_threshold"):
|
|
2025
|
+
if not isinstance(val, int) or val < 0:
|
|
2026
|
+
raise ConfigError(f"concurrency.{key} must be a non-negative integer")
|
|
2027
|
+
else:
|
|
2028
|
+
# bool is a subclass of int — reject it explicitly for numeric keys.
|
|
2029
|
+
if isinstance(val, bool) or not isinstance(val, (int, float)) or val < 0:
|
|
2030
|
+
raise ConfigError(f"concurrency.{key} must be a non-negative number")
|
|
2031
|
+
setattr(cfg, key, val)
|
|
2032
|
+
if "auto_reassign" in raw:
|
|
2033
|
+
val = raw["auto_reassign"]
|
|
2034
|
+
if not isinstance(val, bool):
|
|
2035
|
+
raise ConfigError("concurrency.auto_reassign must be a boolean")
|
|
2036
|
+
cfg.auto_reassign = val
|
|
2037
|
+
if "bash_wrap_spawn" in raw:
|
|
2038
|
+
val = raw["bash_wrap_spawn"]
|
|
2039
|
+
if not isinstance(val, bool):
|
|
2040
|
+
raise ConfigError("concurrency.bash_wrap_spawn must be a boolean")
|
|
2041
|
+
cfg.bash_wrap_spawn = val
|
|
2042
|
+
return cfg
|
|
2043
|
+
|
|
2044
|
+
|
|
2045
|
+
def _parse_smoke_tests(raw: Any) -> SmokeTestsConfig:
|
|
2046
|
+
if raw is None:
|
|
2047
|
+
return SmokeTestsConfig()
|
|
2048
|
+
if not isinstance(raw, dict):
|
|
2049
|
+
raise ConfigError("'smoke_tests' must be a mapping")
|
|
2050
|
+
|
|
2051
|
+
cfg = SmokeTestsConfig()
|
|
2052
|
+
if "auto_queue" in raw:
|
|
2053
|
+
value = raw["auto_queue"]
|
|
2054
|
+
if not isinstance(value, bool):
|
|
2055
|
+
raise ConfigError("smoke_tests.auto_queue must be a boolean")
|
|
2056
|
+
cfg.auto_queue = value
|
|
2057
|
+
|
|
2058
|
+
if "default_command" in raw:
|
|
2059
|
+
value = raw["default_command"]
|
|
2060
|
+
if value is not None and not isinstance(value, str):
|
|
2061
|
+
raise ConfigError("smoke_tests.default_command must be a string")
|
|
2062
|
+
cfg.default_command = value
|
|
2063
|
+
|
|
2064
|
+
if "timeout_seconds" in raw:
|
|
2065
|
+
value = raw["timeout_seconds"]
|
|
2066
|
+
if not isinstance(value, int) or value <= 0:
|
|
2067
|
+
raise ConfigError("smoke_tests.timeout_seconds must be a positive integer")
|
|
2068
|
+
cfg.timeout_seconds = value
|
|
2069
|
+
|
|
2070
|
+
rules_raw = raw.get("capability_rules", []) or []
|
|
2071
|
+
if not isinstance(rules_raw, list):
|
|
2072
|
+
raise ConfigError("smoke_tests.capability_rules must be a list")
|
|
2073
|
+
rules: list[SmokeRule] = []
|
|
2074
|
+
for i, entry in enumerate(rules_raw):
|
|
2075
|
+
if not isinstance(entry, dict):
|
|
2076
|
+
raise ConfigError(
|
|
2077
|
+
f"smoke_tests.capability_rules[{i}] must be a mapping"
|
|
2078
|
+
)
|
|
2079
|
+
files = entry.get("files", []) or []
|
|
2080
|
+
requires = entry.get("requires", []) or []
|
|
2081
|
+
if not isinstance(files, list) or not all(isinstance(f, str) for f in files):
|
|
2082
|
+
raise ConfigError(
|
|
2083
|
+
f"smoke_tests.capability_rules[{i}].files must be a list of strings"
|
|
2084
|
+
)
|
|
2085
|
+
if not isinstance(requires, list) or not all(isinstance(r, str) for r in requires):
|
|
2086
|
+
raise ConfigError(
|
|
2087
|
+
f"smoke_tests.capability_rules[{i}].requires must be a list of strings"
|
|
2088
|
+
)
|
|
2089
|
+
if not files:
|
|
2090
|
+
raise ConfigError(
|
|
2091
|
+
f"smoke_tests.capability_rules[{i}].files must be non-empty"
|
|
2092
|
+
)
|
|
2093
|
+
if not requires:
|
|
2094
|
+
raise ConfigError(
|
|
2095
|
+
f"smoke_tests.capability_rules[{i}].requires must be non-empty"
|
|
2096
|
+
)
|
|
2097
|
+
rules.append(SmokeRule(files=files, requires=requires))
|
|
2098
|
+
cfg.capability_rules = rules
|
|
2099
|
+
return cfg
|
|
2100
|
+
|
|
2101
|
+
|
|
2102
|
+
def _parse_acceptance(raw: Any) -> AcceptanceConfig:
|
|
2103
|
+
"""Parse the ``acceptance:`` block (#944, docs/ORACLE_LOOP.md).
|
|
2104
|
+
|
|
2105
|
+
``acceptance.drivers`` maps a local repo name (as declared under
|
|
2106
|
+
``repos:``) to its driver config. Absent entirely -> no repo has a sealed
|
|
2107
|
+
acceptance suite, and ``coord acceptance run/record`` refuses with a
|
|
2108
|
+
clear error rather than guessing a default.
|
|
2109
|
+
"""
|
|
2110
|
+
if raw is None:
|
|
2111
|
+
return AcceptanceConfig()
|
|
2112
|
+
if not isinstance(raw, dict):
|
|
2113
|
+
raise ConfigError("'acceptance' must be a mapping")
|
|
2114
|
+
|
|
2115
|
+
drivers_raw = raw.get("drivers", {}) or {}
|
|
2116
|
+
if not isinstance(drivers_raw, dict):
|
|
2117
|
+
raise ConfigError(
|
|
2118
|
+
"acceptance.drivers must be a mapping of repo name -> driver config"
|
|
2119
|
+
)
|
|
2120
|
+
|
|
2121
|
+
drivers: dict[str, AcceptanceDriverConfig] = {}
|
|
2122
|
+
for repo_name, entry in drivers_raw.items():
|
|
2123
|
+
if not isinstance(entry, dict):
|
|
2124
|
+
raise ConfigError(f"acceptance.drivers[{repo_name!r}] must be a mapping")
|
|
2125
|
+
|
|
2126
|
+
routes_raw = entry.get("routes")
|
|
2127
|
+
if routes_raw is not None:
|
|
2128
|
+
# #1125 review finding 5: a routed entry's flat kind/run/mock/
|
|
2129
|
+
# capability fields are unused (each route carries its own) — an
|
|
2130
|
+
# operator who sets both almost certainly meant one or the
|
|
2131
|
+
# other, so reject it rather than silently discarding the flat
|
|
2132
|
+
# fields.
|
|
2133
|
+
flat_fields = [
|
|
2134
|
+
f for f in ("kind", "run", "mock", "capability", "setup", "entrypoint")
|
|
2135
|
+
if entry.get(f)
|
|
2136
|
+
]
|
|
2137
|
+
if flat_fields:
|
|
2138
|
+
raise ConfigError(
|
|
2139
|
+
f"acceptance.drivers[{repo_name!r}] sets both 'routes' "
|
|
2140
|
+
f"and flat field(s) {flat_fields!r} — a routed entry's "
|
|
2141
|
+
"driver is entirely per-route; remove the flat fields "
|
|
2142
|
+
"(they would otherwise be silently ignored)"
|
|
2143
|
+
)
|
|
2144
|
+
drivers[repo_name] = AcceptanceDriverConfig(
|
|
2145
|
+
routes=_parse_acceptance_routes(repo_name, routes_raw),
|
|
2146
|
+
)
|
|
2147
|
+
continue
|
|
2148
|
+
|
|
2149
|
+
kind = entry.get("kind")
|
|
2150
|
+
if not kind or not isinstance(kind, str):
|
|
2151
|
+
raise ConfigError(f"acceptance.drivers[{repo_name!r}].kind is required")
|
|
2152
|
+
|
|
2153
|
+
run = entry.get("run")
|
|
2154
|
+
if not run or not isinstance(run, str):
|
|
2155
|
+
raise ConfigError(f"acceptance.drivers[{repo_name!r}].run is required")
|
|
2156
|
+
|
|
2157
|
+
mock = entry.get("mock", "") or ""
|
|
2158
|
+
if not isinstance(mock, str):
|
|
2159
|
+
raise ConfigError(f"acceptance.drivers[{repo_name!r}].mock must be a string")
|
|
2160
|
+
|
|
2161
|
+
capability = entry.get("capability", "") or ""
|
|
2162
|
+
if not isinstance(capability, str):
|
|
2163
|
+
raise ConfigError(
|
|
2164
|
+
f"acceptance.drivers[{repo_name!r}].capability must be a string"
|
|
2165
|
+
)
|
|
2166
|
+
|
|
2167
|
+
setup = entry.get("setup", "") or ""
|
|
2168
|
+
if not isinstance(setup, str):
|
|
2169
|
+
raise ConfigError(f"acceptance.drivers[{repo_name!r}].setup must be a string")
|
|
2170
|
+
|
|
2171
|
+
entrypoint = _acceptance_entrypoint(
|
|
2172
|
+
entry, f"acceptance.drivers[{repo_name!r}].entrypoint"
|
|
2173
|
+
)
|
|
2174
|
+
|
|
2175
|
+
drivers[repo_name] = AcceptanceDriverConfig(
|
|
2176
|
+
kind=kind, run=run, mock=mock, capability=capability, setup=setup,
|
|
2177
|
+
entrypoint=entrypoint,
|
|
2178
|
+
)
|
|
2179
|
+
|
|
2180
|
+
return AcceptanceConfig(drivers=drivers)
|
|
2181
|
+
|
|
2182
|
+
|
|
2183
|
+
def _parse_acceptance_routes(
|
|
2184
|
+
repo_name: str, routes_raw: Any,
|
|
2185
|
+
) -> list[AcceptanceDriverConfig]:
|
|
2186
|
+
"""Parse ``acceptance.drivers.<repo_name>.routes`` (#1125) into a list of
|
|
2187
|
+
``AcceptanceDriverConfig`` route entries, each with ``match`` set.
|
|
2188
|
+
|
|
2189
|
+
Each element is validated the same way as a flat driver entry
|
|
2190
|
+
(``kind``/``run`` required, ``mock``/``capability``/``setup`` optional
|
|
2191
|
+
strings), plus a required ``match`` glob.
|
|
2192
|
+
"""
|
|
2193
|
+
if not isinstance(routes_raw, list) or not routes_raw:
|
|
2194
|
+
raise ConfigError(
|
|
2195
|
+
f"acceptance.drivers[{repo_name!r}].routes must be a non-empty list"
|
|
2196
|
+
)
|
|
2197
|
+
|
|
2198
|
+
routes: list[AcceptanceDriverConfig] = []
|
|
2199
|
+
for i, route_entry in enumerate(routes_raw):
|
|
2200
|
+
if not isinstance(route_entry, dict):
|
|
2201
|
+
raise ConfigError(
|
|
2202
|
+
f"acceptance.drivers[{repo_name!r}].routes[{i}] must be a mapping"
|
|
2203
|
+
)
|
|
2204
|
+
|
|
2205
|
+
match = route_entry.get("match")
|
|
2206
|
+
if not match or not isinstance(match, str):
|
|
2207
|
+
raise ConfigError(
|
|
2208
|
+
f"acceptance.drivers[{repo_name!r}].routes[{i}].match is required"
|
|
2209
|
+
)
|
|
2210
|
+
|
|
2211
|
+
kind = route_entry.get("kind")
|
|
2212
|
+
if not kind or not isinstance(kind, str):
|
|
2213
|
+
raise ConfigError(
|
|
2214
|
+
f"acceptance.drivers[{repo_name!r}].routes[{i}].kind is required"
|
|
2215
|
+
)
|
|
2216
|
+
|
|
2217
|
+
run = route_entry.get("run")
|
|
2218
|
+
if not run or not isinstance(run, str):
|
|
2219
|
+
raise ConfigError(
|
|
2220
|
+
f"acceptance.drivers[{repo_name!r}].routes[{i}].run is required"
|
|
2221
|
+
)
|
|
2222
|
+
|
|
2223
|
+
mock = route_entry.get("mock", "") or ""
|
|
2224
|
+
if not isinstance(mock, str):
|
|
2225
|
+
raise ConfigError(
|
|
2226
|
+
f"acceptance.drivers[{repo_name!r}].routes[{i}].mock must be a string"
|
|
2227
|
+
)
|
|
2228
|
+
|
|
2229
|
+
capability = route_entry.get("capability", "") or ""
|
|
2230
|
+
if not isinstance(capability, str):
|
|
2231
|
+
raise ConfigError(
|
|
2232
|
+
f"acceptance.drivers[{repo_name!r}].routes[{i}].capability must be a string"
|
|
2233
|
+
)
|
|
2234
|
+
|
|
2235
|
+
setup = route_entry.get("setup", "") or ""
|
|
2236
|
+
if not isinstance(setup, str):
|
|
2237
|
+
raise ConfigError(
|
|
2238
|
+
f"acceptance.drivers[{repo_name!r}].routes[{i}].setup must be a string"
|
|
2239
|
+
)
|
|
2240
|
+
|
|
2241
|
+
entrypoint = _acceptance_entrypoint(
|
|
2242
|
+
route_entry, f"acceptance.drivers[{repo_name!r}].routes[{i}].entrypoint"
|
|
2243
|
+
)
|
|
2244
|
+
|
|
2245
|
+
routes.append(
|
|
2246
|
+
AcceptanceDriverConfig(
|
|
2247
|
+
kind=kind, run=run, mock=mock, capability=capability, setup=setup,
|
|
2248
|
+
match=match, entrypoint=entrypoint,
|
|
2249
|
+
)
|
|
2250
|
+
)
|
|
2251
|
+
|
|
2252
|
+
return routes
|
|
2253
|
+
|
|
2254
|
+
|
|
2255
|
+
def _acceptance_entrypoint(entry: dict, label: str) -> str:
|
|
2256
|
+
"""Validate an acceptance driver's optional ``entrypoint:`` (#1552).
|
|
2257
|
+
|
|
2258
|
+
Must be a repo-root-relative *file* path — it is folded into the sealed
|
|
2259
|
+
set as an exact-match entry (:meth:`AcceptanceConfig.sealed_paths`), so
|
|
2260
|
+
an absolute path or a trailing-slash directory would silently seal
|
|
2261
|
+
nothing at all. Reject both here rather than at review time, where the
|
|
2262
|
+
only symptom would be a `test-author` bounced for a scope violation it
|
|
2263
|
+
cannot fix.
|
|
2264
|
+
"""
|
|
2265
|
+
raw = entry.get("entrypoint", "") or ""
|
|
2266
|
+
if not isinstance(raw, str):
|
|
2267
|
+
raise ConfigError(f"{label} must be a string")
|
|
2268
|
+
value = raw.strip()
|
|
2269
|
+
if not value:
|
|
2270
|
+
return ""
|
|
2271
|
+
if value.startswith("/") or value.startswith("~"):
|
|
2272
|
+
raise ConfigError(
|
|
2273
|
+
f"{label} must be repo-root-relative, not an absolute path "
|
|
2274
|
+
f"(got {value!r})"
|
|
2275
|
+
)
|
|
2276
|
+
if value.endswith("/"):
|
|
2277
|
+
raise ConfigError(
|
|
2278
|
+
f"{label} must name a FILE (the driver's crate-root/entry point), "
|
|
2279
|
+
f"not a directory (got {value!r})"
|
|
2280
|
+
)
|
|
2281
|
+
return value
|
|
2282
|
+
|
|
2283
|
+
|
|
2284
|
+
def _parse_models(raw: Any) -> ModelsConfig:
|
|
2285
|
+
if raw is None:
|
|
2286
|
+
return ModelsConfig()
|
|
2287
|
+
if not isinstance(raw, dict):
|
|
2288
|
+
raise ConfigError("'models' must be a mapping")
|
|
2289
|
+
|
|
2290
|
+
cfg = ModelsConfig()
|
|
2291
|
+
if "default" in raw:
|
|
2292
|
+
value = raw["default"]
|
|
2293
|
+
if not isinstance(value, str) or not value:
|
|
2294
|
+
raise ConfigError("models.default must be a non-empty string")
|
|
2295
|
+
cfg.default = value
|
|
2296
|
+
|
|
2297
|
+
if "escalation" in raw:
|
|
2298
|
+
value = raw["escalation"]
|
|
2299
|
+
if not isinstance(value, list) or not all(isinstance(v, str) and v for v in value):
|
|
2300
|
+
raise ConfigError("models.escalation must be a list of non-empty strings")
|
|
2301
|
+
cfg.escalation = list(value)
|
|
2302
|
+
|
|
2303
|
+
if "labels" in raw:
|
|
2304
|
+
value = raw["labels"]
|
|
2305
|
+
if not isinstance(value, dict) or not all(
|
|
2306
|
+
isinstance(k, str) and isinstance(v, str) for k, v in value.items()
|
|
2307
|
+
):
|
|
2308
|
+
raise ConfigError(
|
|
2309
|
+
"models.labels must be a mapping of label name → model alias"
|
|
2310
|
+
)
|
|
2311
|
+
cfg.labels = dict(value)
|
|
2312
|
+
|
|
2313
|
+
if "versions" in raw:
|
|
2314
|
+
value = raw["versions"]
|
|
2315
|
+
if not isinstance(value, dict) or not all(
|
|
2316
|
+
isinstance(k, str) and k and isinstance(v, str) and v
|
|
2317
|
+
for k, v in value.items()
|
|
2318
|
+
):
|
|
2319
|
+
raise ConfigError(
|
|
2320
|
+
"models.versions must be a mapping of alias → exact model id"
|
|
2321
|
+
)
|
|
2322
|
+
cfg.versions = dict(value)
|
|
2323
|
+
|
|
2324
|
+
return cfg
|
|
2325
|
+
|
|
2326
|
+
|
|
2327
|
+
def _parse_pipeline(raw: Any) -> PipelineConfig:
|
|
2328
|
+
if raw is None:
|
|
2329
|
+
return PipelineConfig()
|
|
2330
|
+
if not isinstance(raw, dict):
|
|
2331
|
+
raise ConfigError("'pipeline' must be a mapping")
|
|
2332
|
+
|
|
2333
|
+
cfg = PipelineConfig()
|
|
2334
|
+
|
|
2335
|
+
if "default_gates" in raw:
|
|
2336
|
+
value = raw["default_gates"]
|
|
2337
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
2338
|
+
raise ConfigError("pipeline.default_gates must be a list of strings")
|
|
2339
|
+
cfg.default_gates = list(value)
|
|
2340
|
+
|
|
2341
|
+
if "labels" in raw:
|
|
2342
|
+
value = raw["labels"]
|
|
2343
|
+
if not isinstance(value, dict):
|
|
2344
|
+
raise ConfigError("pipeline.labels must be a mapping of label → list of strings")
|
|
2345
|
+
for k, v in value.items():
|
|
2346
|
+
if not isinstance(k, str):
|
|
2347
|
+
raise ConfigError("pipeline.labels keys must be strings")
|
|
2348
|
+
if not isinstance(v, list) or not all(isinstance(g, str) for g in v):
|
|
2349
|
+
raise ConfigError(
|
|
2350
|
+
f"pipeline.labels[{k!r}] must be a list of gate name strings"
|
|
2351
|
+
)
|
|
2352
|
+
cfg.labels = {k: list(v) for k, v in value.items()}
|
|
2353
|
+
|
|
2354
|
+
if "auto_loop" in raw:
|
|
2355
|
+
value = raw["auto_loop"]
|
|
2356
|
+
if not isinstance(value, bool):
|
|
2357
|
+
raise ConfigError("pipeline.auto_loop must be a boolean")
|
|
2358
|
+
cfg.auto_loop = value
|
|
2359
|
+
|
|
2360
|
+
if "max_review_iterations" in raw:
|
|
2361
|
+
value = raw["max_review_iterations"]
|
|
2362
|
+
if not isinstance(value, int) or value < 1:
|
|
2363
|
+
raise ConfigError("pipeline.max_review_iterations must be a positive integer")
|
|
2364
|
+
cfg.max_review_iterations = value
|
|
2365
|
+
|
|
2366
|
+
if "escalate_fix_model" in raw:
|
|
2367
|
+
value = raw["escalate_fix_model"]
|
|
2368
|
+
if not isinstance(value, bool):
|
|
2369
|
+
raise ConfigError("pipeline.escalate_fix_model must be a boolean")
|
|
2370
|
+
cfg.escalate_fix_model = value
|
|
2371
|
+
|
|
2372
|
+
if "escalate_semantic_conflicts" in raw:
|
|
2373
|
+
value = raw["escalate_semantic_conflicts"]
|
|
2374
|
+
if not isinstance(value, bool):
|
|
2375
|
+
raise ConfigError("pipeline.escalate_semantic_conflicts must be a boolean")
|
|
2376
|
+
cfg.escalate_semantic_conflicts = value
|
|
2377
|
+
|
|
2378
|
+
if "semantic_conflict_model" in raw:
|
|
2379
|
+
value = raw["semantic_conflict_model"]
|
|
2380
|
+
if not isinstance(value, str) or not value.strip():
|
|
2381
|
+
raise ConfigError("pipeline.semantic_conflict_model must be a non-empty string")
|
|
2382
|
+
cfg.semantic_conflict_model = value.strip()
|
|
2383
|
+
|
|
2384
|
+
if "auto_dispatch_stalled" in raw:
|
|
2385
|
+
value = raw["auto_dispatch_stalled"]
|
|
2386
|
+
if not isinstance(value, bool):
|
|
2387
|
+
raise ConfigError("pipeline.auto_dispatch_stalled must be a boolean")
|
|
2388
|
+
cfg.auto_dispatch_stalled = value
|
|
2389
|
+
|
|
2390
|
+
if "attention_thresholds" in raw:
|
|
2391
|
+
value = raw["attention_thresholds"]
|
|
2392
|
+
if not isinstance(value, dict):
|
|
2393
|
+
raise ConfigError(
|
|
2394
|
+
"pipeline.attention_thresholds must be a mapping of "
|
|
2395
|
+
"assignment type -> duration (e.g. '45m', '15m', or seconds)"
|
|
2396
|
+
)
|
|
2397
|
+
parsed: dict[str, float] = {}
|
|
2398
|
+
for k, v in value.items():
|
|
2399
|
+
if not isinstance(k, str):
|
|
2400
|
+
raise ConfigError("pipeline.attention_thresholds keys must be strings")
|
|
2401
|
+
parsed[k] = _parse_duration_seconds(
|
|
2402
|
+
v, context=f"pipeline.attention_thresholds[{k!r}]"
|
|
2403
|
+
)
|
|
2404
|
+
cfg.attention_thresholds = parsed
|
|
2405
|
+
|
|
2406
|
+
if "convergence_rounds" in raw:
|
|
2407
|
+
value = raw["convergence_rounds"]
|
|
2408
|
+
if not isinstance(value, int) or value < 1:
|
|
2409
|
+
raise ConfigError("pipeline.convergence_rounds must be a positive integer")
|
|
2410
|
+
cfg.convergence_rounds = value
|
|
2411
|
+
|
|
2412
|
+
if "liveness_auditor" in raw:
|
|
2413
|
+
cfg.liveness_auditor = _parse_liveness_auditor(raw["liveness_auditor"])
|
|
2414
|
+
|
|
2415
|
+
return cfg
|
|
2416
|
+
|
|
2417
|
+
|
|
2418
|
+
def _parse_liveness_auditor(raw: Any) -> LivenessAuditorConfig:
|
|
2419
|
+
if raw is None:
|
|
2420
|
+
return LivenessAuditorConfig()
|
|
2421
|
+
if not isinstance(raw, dict):
|
|
2422
|
+
raise ConfigError("'pipeline.liveness_auditor' must be a mapping")
|
|
2423
|
+
|
|
2424
|
+
cfg = LivenessAuditorConfig()
|
|
2425
|
+
|
|
2426
|
+
if "enabled" in raw:
|
|
2427
|
+
value = raw["enabled"]
|
|
2428
|
+
if not isinstance(value, bool):
|
|
2429
|
+
raise ConfigError("pipeline.liveness_auditor.enabled must be a boolean")
|
|
2430
|
+
cfg.enabled = value
|
|
2431
|
+
|
|
2432
|
+
if "strikes" in raw:
|
|
2433
|
+
value = raw["strikes"]
|
|
2434
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
|
2435
|
+
raise ConfigError("pipeline.liveness_auditor.strikes must be a positive integer")
|
|
2436
|
+
cfg.strikes = value
|
|
2437
|
+
|
|
2438
|
+
if "debounce_seconds" in raw:
|
|
2439
|
+
cfg.debounce_seconds = _parse_duration_seconds(
|
|
2440
|
+
raw["debounce_seconds"], context="pipeline.liveness_auditor.debounce_seconds"
|
|
2441
|
+
)
|
|
2442
|
+
|
|
2443
|
+
if "model" in raw:
|
|
2444
|
+
value = raw["model"]
|
|
2445
|
+
if not isinstance(value, str) or not value.strip():
|
|
2446
|
+
raise ConfigError("pipeline.liveness_auditor.model must be a non-empty string")
|
|
2447
|
+
cfg.model = value.strip()
|
|
2448
|
+
|
|
2449
|
+
if "timeout_seconds" in raw:
|
|
2450
|
+
cfg.timeout_seconds = _parse_duration_seconds(
|
|
2451
|
+
raw["timeout_seconds"], context="pipeline.liveness_auditor.timeout_seconds"
|
|
2452
|
+
)
|
|
2453
|
+
|
|
2454
|
+
if "claude_bin" in raw:
|
|
2455
|
+
value = raw["claude_bin"]
|
|
2456
|
+
if value is not None and (not isinstance(value, str) or not value.strip()):
|
|
2457
|
+
raise ConfigError(
|
|
2458
|
+
"pipeline.liveness_auditor.claude_bin must be a non-empty string or null"
|
|
2459
|
+
)
|
|
2460
|
+
cfg.claude_bin = value.strip() if isinstance(value, str) else None
|
|
2461
|
+
|
|
2462
|
+
return cfg
|
|
2463
|
+
|
|
2464
|
+
|
|
2465
|
+
_DURATION_UNIT_SECONDS: dict[str, float] = {
|
|
2466
|
+
"s": 1.0,
|
|
2467
|
+
"m": 60.0,
|
|
2468
|
+
"h": 3600.0,
|
|
2469
|
+
"d": 86400.0,
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
|
|
2473
|
+
def _parse_duration_seconds(value: Any, *, context: str) -> float:
|
|
2474
|
+
"""Parse a duration into seconds. Accepts a bare number (seconds) or a
|
|
2475
|
+
string like ``"45m"``, ``"15m"``, ``"2h"``, ``"90s"``. Used for
|
|
2476
|
+
``pipeline.attention_thresholds`` (#846)."""
|
|
2477
|
+
if isinstance(value, bool):
|
|
2478
|
+
raise ConfigError(f"{context} must be a number of seconds or a duration string")
|
|
2479
|
+
if isinstance(value, (int, float)):
|
|
2480
|
+
if value <= 0:
|
|
2481
|
+
raise ConfigError(f"{context} must be a positive duration")
|
|
2482
|
+
return float(value)
|
|
2483
|
+
if isinstance(value, str):
|
|
2484
|
+
text = value.strip().lower()
|
|
2485
|
+
if text and text[-1] in _DURATION_UNIT_SECONDS and text[:-1].strip():
|
|
2486
|
+
number_part = text[:-1].strip()
|
|
2487
|
+
try:
|
|
2488
|
+
number = float(number_part)
|
|
2489
|
+
except ValueError:
|
|
2490
|
+
pass
|
|
2491
|
+
else:
|
|
2492
|
+
if number <= 0:
|
|
2493
|
+
raise ConfigError(f"{context} must be a positive duration")
|
|
2494
|
+
return number * _DURATION_UNIT_SECONDS[text[-1]]
|
|
2495
|
+
try:
|
|
2496
|
+
number = float(text)
|
|
2497
|
+
except ValueError:
|
|
2498
|
+
raise ConfigError(
|
|
2499
|
+
f"{context} must be a number of seconds or a duration string "
|
|
2500
|
+
f"like '45m', '15m', '2h' (got {value!r})"
|
|
2501
|
+
) from None
|
|
2502
|
+
if number <= 0:
|
|
2503
|
+
raise ConfigError(f"{context} must be a positive duration")
|
|
2504
|
+
return number
|
|
2505
|
+
raise ConfigError(f"{context} must be a number of seconds or a duration string")
|
|
2506
|
+
|
|
2507
|
+
|
|
2508
|
+
def _parse_dispatch(raw: Any) -> DispatchConfig:
|
|
2509
|
+
if raw is None:
|
|
2510
|
+
return DispatchConfig()
|
|
2511
|
+
if not isinstance(raw, dict):
|
|
2512
|
+
raise ConfigError("'dispatch' must be a mapping")
|
|
2513
|
+
|
|
2514
|
+
cfg = DispatchConfig()
|
|
2515
|
+
|
|
2516
|
+
if "max_files_per_worker" in raw:
|
|
2517
|
+
value = raw["max_files_per_worker"]
|
|
2518
|
+
if not isinstance(value, int) or value < 1:
|
|
2519
|
+
raise ConfigError("dispatch.max_files_per_worker must be a positive integer")
|
|
2520
|
+
cfg.max_files_per_worker = value
|
|
2521
|
+
|
|
2522
|
+
if "auto_split" in raw:
|
|
2523
|
+
value = raw["auto_split"]
|
|
2524
|
+
if not isinstance(value, bool):
|
|
2525
|
+
raise ConfigError("dispatch.auto_split must be a boolean")
|
|
2526
|
+
cfg.auto_split = value
|
|
2527
|
+
|
|
2528
|
+
if "require_plan" in raw:
|
|
2529
|
+
value = raw["require_plan"]
|
|
2530
|
+
if not isinstance(value, bool):
|
|
2531
|
+
raise ConfigError("dispatch.require_plan must be a boolean")
|
|
2532
|
+
cfg.require_plan = value
|
|
2533
|
+
|
|
2534
|
+
return cfg
|
|
2535
|
+
|
|
2536
|
+
|
|
2537
|
+
def _parse_usage_gate(raw: Any) -> UsageGateConfig:
|
|
2538
|
+
if raw is None:
|
|
2539
|
+
return UsageGateConfig()
|
|
2540
|
+
if not isinstance(raw, dict):
|
|
2541
|
+
raise ConfigError("'usage_gate' must be a mapping")
|
|
2542
|
+
|
|
2543
|
+
cfg = UsageGateConfig()
|
|
2544
|
+
|
|
2545
|
+
if "mode" in raw:
|
|
2546
|
+
value = raw["mode"]
|
|
2547
|
+
if value not in ("disabled", "warn", "block"):
|
|
2548
|
+
raise ConfigError("usage_gate.mode must be one of: disabled, warn, block")
|
|
2549
|
+
cfg.mode = value
|
|
2550
|
+
|
|
2551
|
+
for key in ("session_threshold_pct", "week_threshold_pct"):
|
|
2552
|
+
if key in raw:
|
|
2553
|
+
value = raw[key]
|
|
2554
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or not (0 <= value <= 100):
|
|
2555
|
+
raise ConfigError(f"usage_gate.{key} must be a number between 0 and 100")
|
|
2556
|
+
setattr(cfg, key, float(value))
|
|
2557
|
+
|
|
2558
|
+
return cfg
|
|
2559
|
+
|
|
2560
|
+
|
|
2561
|
+
# #1628: (field name, minimum) for every numeric knob in the `health:` block.
|
|
2562
|
+
# A single table rather than a per-field branch, because the whole point of
|
|
2563
|
+
# the block is that adding a check adds a threshold — and if adding one meant
|
|
2564
|
+
# hand-writing another eight-line validator, the "adding a check touches one
|
|
2565
|
+
# file" property would rot on the config side instead.
|
|
2566
|
+
_HEALTH_FLOAT_FIELDS: dict[str, float] = {
|
|
2567
|
+
"disk_warn_free_pct": 0.0,
|
|
2568
|
+
"disk_crit_free_pct": 0.0,
|
|
2569
|
+
"cargo_target_warn_gb": 0.0,
|
|
2570
|
+
"cargo_target_crit_gb": 0.0,
|
|
2571
|
+
"cargo_scan_budget_secs": 0.0,
|
|
2572
|
+
"worktree_stale_hours": 0.0,
|
|
2573
|
+
"network_timeout_secs": 0.0,
|
|
2574
|
+
"graph_stale_warn_hours": 0.0,
|
|
2575
|
+
"graph_stale_crit_hours": 0.0,
|
|
2576
|
+
"plan_usage_warn_pct": 0.0,
|
|
2577
|
+
"plan_usage_crit_pct": 0.0,
|
|
2578
|
+
}
|
|
2579
|
+
_HEALTH_INT_FIELDS: tuple[str, ...] = (
|
|
2580
|
+
"worktree_warn_count",
|
|
2581
|
+
"worktree_crit_count",
|
|
2582
|
+
"agent_version_warn_behind",
|
|
2583
|
+
"agent_version_crit_behind",
|
|
2584
|
+
)
|
|
2585
|
+
_HEALTH_STR_LIST_FIELDS: tuple[str, ...] = (
|
|
2586
|
+
"disabled_checks",
|
|
2587
|
+
"disk_paths",
|
|
2588
|
+
"cargo_target_extra_dirs",
|
|
2589
|
+
"spawned_coord_units",
|
|
2590
|
+
)
|
|
2591
|
+
# Path-ish overrides: a string, or null to mean "use the documented default".
|
|
2592
|
+
# Table-driven for the same reason as the numeric fields above — a new deploy
|
|
2593
|
+
# lane should not need its own hand-written eight-line validator.
|
|
2594
|
+
_HEALTH_OPT_STR_FIELDS: tuple[str, ...] = (
|
|
2595
|
+
"agent_venv_python",
|
|
2596
|
+
"cli_venv_python",
|
|
2597
|
+
"tui_binary_path",
|
|
2598
|
+
"tui_source_dir",
|
|
2599
|
+
"deploy_dir",
|
|
2600
|
+
"systemd_user_dir",
|
|
2601
|
+
"webapp_dist_path",
|
|
2602
|
+
"webapp_source_dir",
|
|
2603
|
+
)
|
|
2604
|
+
# Pairs that must not be inverted. A config where warn is stricter than crit
|
|
2605
|
+
# silently makes the crit level unreachable — the check keeps reporting WARN
|
|
2606
|
+
# for a machine that is actually on fire, which is exactly the failure this
|
|
2607
|
+
# milestone exists to prevent. Reject it at load rather than at 3am.
|
|
2608
|
+
_HEALTH_ORDERED_PAIRS: tuple[tuple[str, str, str], ...] = (
|
|
2609
|
+
# (warn_field, crit_field, direction) — "asc": crit must be >= warn.
|
|
2610
|
+
("cargo_target_warn_gb", "cargo_target_crit_gb", "asc"),
|
|
2611
|
+
("graph_stale_warn_hours", "graph_stale_crit_hours", "asc"),
|
|
2612
|
+
("plan_usage_warn_pct", "plan_usage_crit_pct", "asc"),
|
|
2613
|
+
("worktree_warn_count", "worktree_crit_count", "asc"),
|
|
2614
|
+
("agent_version_warn_behind", "agent_version_crit_behind", "asc"),
|
|
2615
|
+
# Disk thresholds are *headroom* percentages, so crit must be the lower
|
|
2616
|
+
# number: warn at 15% free, crit at 7% free.
|
|
2617
|
+
("disk_warn_free_pct", "disk_crit_free_pct", "desc"),
|
|
2618
|
+
)
|
|
2619
|
+
|
|
2620
|
+
|
|
2621
|
+
def _parse_health(raw: Any) -> HealthConfig:
|
|
2622
|
+
"""Parse the optional ``health:`` block from coordinator.yml (#1628).
|
|
2623
|
+
|
|
2624
|
+
An absent block returns :class:`HealthConfig` defaults — the thresholds
|
|
2625
|
+
that would have fired on the 2026-07-30 incidents.
|
|
2626
|
+
"""
|
|
2627
|
+
if raw is None:
|
|
2628
|
+
return HealthConfig()
|
|
2629
|
+
if not isinstance(raw, dict):
|
|
2630
|
+
raise ConfigError("'health' must be a mapping")
|
|
2631
|
+
|
|
2632
|
+
cfg = HealthConfig()
|
|
2633
|
+
known = {f.name for f in fields(HealthConfig)}
|
|
2634
|
+
unknown = sorted(set(raw) - known)
|
|
2635
|
+
if unknown:
|
|
2636
|
+
raise ConfigError(
|
|
2637
|
+
f"unknown health option(s): {', '.join(unknown)} "
|
|
2638
|
+
f"(valid: {', '.join(sorted(known))})"
|
|
2639
|
+
)
|
|
2640
|
+
|
|
2641
|
+
if "enabled" in raw:
|
|
2642
|
+
if not isinstance(raw["enabled"], bool):
|
|
2643
|
+
raise ConfigError("health.enabled must be a boolean")
|
|
2644
|
+
cfg.enabled = raw["enabled"]
|
|
2645
|
+
|
|
2646
|
+
for key in _HEALTH_OPT_STR_FIELDS:
|
|
2647
|
+
if key in raw:
|
|
2648
|
+
value = raw[key]
|
|
2649
|
+
if value is not None and not isinstance(value, str):
|
|
2650
|
+
raise ConfigError(f"health.{key} must be a string or null")
|
|
2651
|
+
# An empty/whitespace string is an operator typo, not "disabled":
|
|
2652
|
+
# accepting it would silently resolve the lane to the CWD.
|
|
2653
|
+
if isinstance(value, str) and not value.strip():
|
|
2654
|
+
raise ConfigError(f"health.{key} must be a non-empty string or null")
|
|
2655
|
+
setattr(cfg, key, value.strip() if isinstance(value, str) else None)
|
|
2656
|
+
|
|
2657
|
+
if "pypi_index_url" in raw:
|
|
2658
|
+
value = raw["pypi_index_url"]
|
|
2659
|
+
if not isinstance(value, str) or not value.strip():
|
|
2660
|
+
raise ConfigError("health.pypi_index_url must be a non-empty string")
|
|
2661
|
+
# Canonicalise the trailing slash here so the value that ends up in
|
|
2662
|
+
# the check's reported `values["index_url"]` is the same string
|
|
2663
|
+
# whichever way the operator wrote it.
|
|
2664
|
+
cfg.pypi_index_url = value.strip().rstrip("/")
|
|
2665
|
+
|
|
2666
|
+
for key in _HEALTH_STR_LIST_FIELDS:
|
|
2667
|
+
if key in raw:
|
|
2668
|
+
value = raw[key]
|
|
2669
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
2670
|
+
raise ConfigError(f"health.{key} must be a list of strings")
|
|
2671
|
+
setattr(cfg, key, list(value))
|
|
2672
|
+
|
|
2673
|
+
for key, minimum in _HEALTH_FLOAT_FIELDS.items():
|
|
2674
|
+
if key in raw:
|
|
2675
|
+
value = raw[key]
|
|
2676
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
2677
|
+
raise ConfigError(f"health.{key} must be a number")
|
|
2678
|
+
if value < minimum:
|
|
2679
|
+
raise ConfigError(f"health.{key} must be >= {minimum}")
|
|
2680
|
+
if key.endswith("_pct") and value > 100:
|
|
2681
|
+
raise ConfigError(f"health.{key} must be between 0 and 100")
|
|
2682
|
+
setattr(cfg, key, float(value))
|
|
2683
|
+
|
|
2684
|
+
for key in _HEALTH_INT_FIELDS:
|
|
2685
|
+
if key in raw:
|
|
2686
|
+
value = raw[key]
|
|
2687
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
|
2688
|
+
raise ConfigError(f"health.{key} must be a non-negative integer")
|
|
2689
|
+
setattr(cfg, key, value)
|
|
2690
|
+
|
|
2691
|
+
for warn_key, crit_key, direction in _HEALTH_ORDERED_PAIRS:
|
|
2692
|
+
warn_value = getattr(cfg, warn_key)
|
|
2693
|
+
crit_value = getattr(cfg, crit_key)
|
|
2694
|
+
inverted = crit_value < warn_value if direction == "asc" else crit_value > warn_value
|
|
2695
|
+
if inverted:
|
|
2696
|
+
relation = ">=" if direction == "asc" else "<="
|
|
2697
|
+
raise ConfigError(
|
|
2698
|
+
f"health.{crit_key} ({crit_value}) must be {relation} "
|
|
2699
|
+
f"health.{warn_key} ({warn_value}) — otherwise the crit level "
|
|
2700
|
+
f"is unreachable and a failing machine only ever reports WARN"
|
|
2701
|
+
)
|
|
2702
|
+
|
|
2703
|
+
return cfg
|
|
2704
|
+
|
|
2705
|
+
|
|
2706
|
+
def _parse_ci_store(raw: Any) -> CiStoreConfig:
|
|
2707
|
+
if raw is None:
|
|
2708
|
+
return CiStoreConfig()
|
|
2709
|
+
if not isinstance(raw, dict):
|
|
2710
|
+
raise ConfigError("'ci_store' must be a mapping")
|
|
2711
|
+
|
|
2712
|
+
cfg = CiStoreConfig()
|
|
2713
|
+
if "type" in raw:
|
|
2714
|
+
value = raw["type"]
|
|
2715
|
+
if not isinstance(value, str) or value not in ("github", "none"):
|
|
2716
|
+
raise ConfigError("ci_store.type must be one of: github, none")
|
|
2717
|
+
cfg.type = value
|
|
2718
|
+
return cfg
|
|
2719
|
+
|
|
2720
|
+
|
|
2721
|
+
def _parse_merge(raw: Any) -> MergeConfig:
|
|
2722
|
+
"""Parse the optional ``merge:`` block from coordinator.yml.
|
|
2723
|
+
|
|
2724
|
+
An absent block returns ``MergeConfig()`` — ``auto_drain=False`` —
|
|
2725
|
+
preserving existing behaviour: the daemon never merges automatically.
|
|
2726
|
+
"""
|
|
2727
|
+
if raw is None:
|
|
2728
|
+
return MergeConfig()
|
|
2729
|
+
if not isinstance(raw, dict):
|
|
2730
|
+
raise ConfigError("'merge' must be a mapping")
|
|
2731
|
+
|
|
2732
|
+
cfg = MergeConfig()
|
|
2733
|
+
if "auto_drain" in raw:
|
|
2734
|
+
value = raw["auto_drain"]
|
|
2735
|
+
if not isinstance(value, bool):
|
|
2736
|
+
raise ConfigError("merge.auto_drain must be a boolean")
|
|
2737
|
+
cfg.auto_drain = value
|
|
2738
|
+
if "max_per_tick" in raw:
|
|
2739
|
+
value = raw["max_per_tick"]
|
|
2740
|
+
if not isinstance(value, int) or value < 0:
|
|
2741
|
+
raise ConfigError("merge.max_per_tick must be a non-negative integer")
|
|
2742
|
+
cfg.max_per_tick = value
|
|
2743
|
+
if "auto_reap_merged" in raw:
|
|
2744
|
+
value = raw["auto_reap_merged"]
|
|
2745
|
+
if not isinstance(value, bool):
|
|
2746
|
+
raise ConfigError("merge.auto_reap_merged must be a boolean")
|
|
2747
|
+
cfg.auto_reap_merged = value
|
|
2748
|
+
if "sibling_overlap_aging_hours" in raw:
|
|
2749
|
+
value = raw["sibling_overlap_aging_hours"]
|
|
2750
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)) or value < 0:
|
|
2751
|
+
raise ConfigError(
|
|
2752
|
+
"merge.sibling_overlap_aging_hours must be a non-negative number"
|
|
2753
|
+
)
|
|
2754
|
+
cfg.sibling_overlap_aging_hours = float(value)
|
|
2755
|
+
return cfg
|
|
2756
|
+
|
|
2757
|
+
|
|
2758
|
+
def _parse_milestone(raw: Any) -> MilestoneConfig:
|
|
2759
|
+
"""Parse the optional ``milestone:`` block from coordinator.yml.
|
|
2760
|
+
|
|
2761
|
+
An absent block returns ``MilestoneConfig()`` — ``auto_dispatch=False`` —
|
|
2762
|
+
preserving existing behaviour: the daemon never auto-drains a milestone's
|
|
2763
|
+
work order; `coord milestone dispatch` still dispatches the ready
|
|
2764
|
+
frontier once per invocation.
|
|
2765
|
+
"""
|
|
2766
|
+
if raw is None:
|
|
2767
|
+
return MilestoneConfig()
|
|
2768
|
+
if not isinstance(raw, dict):
|
|
2769
|
+
raise ConfigError("'milestone' must be a mapping")
|
|
2770
|
+
|
|
2771
|
+
cfg = MilestoneConfig()
|
|
2772
|
+
if "auto_dispatch" in raw:
|
|
2773
|
+
value = raw["auto_dispatch"]
|
|
2774
|
+
if not isinstance(value, bool):
|
|
2775
|
+
raise ConfigError("milestone.auto_dispatch must be a boolean")
|
|
2776
|
+
cfg.auto_dispatch = value
|
|
2777
|
+
return cfg
|
|
2778
|
+
|
|
2779
|
+
|
|
2780
|
+
_VALID_AUDIT_LEVELS = ("business", "operational")
|
|
2781
|
+
|
|
2782
|
+
|
|
2783
|
+
def _parse_audit(raw: Any) -> AuditConfig:
|
|
2784
|
+
"""Parse the optional ``audit:`` block from coordinator.yml (#1036/#1038).
|
|
2785
|
+
|
|
2786
|
+
An absent block returns ``AuditConfig()`` — ``max_rows=0`` (unlimited)
|
|
2787
|
+
and ``level="operational"`` — preserving existing behaviour:
|
|
2788
|
+
``coord.audit.record_audit`` never trims and captures both tiers.
|
|
2789
|
+
"""
|
|
2790
|
+
if raw is None:
|
|
2791
|
+
return AuditConfig()
|
|
2792
|
+
if not isinstance(raw, dict):
|
|
2793
|
+
raise ConfigError("'audit' must be a mapping")
|
|
2794
|
+
|
|
2795
|
+
cfg = AuditConfig()
|
|
2796
|
+
if "max_rows" in raw:
|
|
2797
|
+
value = raw["max_rows"]
|
|
2798
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
2799
|
+
raise ConfigError("audit.max_rows must be a non-negative integer")
|
|
2800
|
+
cfg.max_rows = value
|
|
2801
|
+
if "level" in raw:
|
|
2802
|
+
value = raw["level"]
|
|
2803
|
+
if not isinstance(value, str) or value not in _VALID_AUDIT_LEVELS:
|
|
2804
|
+
raise ConfigError(
|
|
2805
|
+
f"audit.level must be one of {_VALID_AUDIT_LEVELS!r}, got {value!r}"
|
|
2806
|
+
)
|
|
2807
|
+
cfg.level = value
|
|
2808
|
+
return cfg
|
|
2809
|
+
|
|
2810
|
+
|
|
2811
|
+
_PRICING_RATE_FIELDS = ("input", "output", "cache_read", "cache_creation")
|
|
2812
|
+
|
|
2813
|
+
|
|
2814
|
+
def _parse_pricing(raw: Any) -> PricingConfig:
|
|
2815
|
+
"""Parse the optional ``pricing:`` block from coordinator.yml (#1118).
|
|
2816
|
+
|
|
2817
|
+
An absent block returns ``PricingConfig()`` — the built-in sonnet/opus/
|
|
2818
|
+
haiku defaults from :func:`_default_pricing`. Each entry under
|
|
2819
|
+
``pricing:`` overrides or extends a canonical model key; unspecified
|
|
2820
|
+
rate fields on an *existing* key (e.g. ``opus``) keep the built-in
|
|
2821
|
+
default rather than being zeroed, so an operator can bump just
|
|
2822
|
+
``pricing.opus.output`` without restating the other three rates. A
|
|
2823
|
+
wholly new model key starts from ``ModelRates()`` (all zero) and is
|
|
2824
|
+
filled in from whatever fields are given.
|
|
2825
|
+
"""
|
|
2826
|
+
models = _default_pricing()
|
|
2827
|
+
if raw is None:
|
|
2828
|
+
return PricingConfig(models=models)
|
|
2829
|
+
if not isinstance(raw, dict):
|
|
2830
|
+
raise ConfigError("'pricing' must be a mapping of model name -> rates")
|
|
2831
|
+
|
|
2832
|
+
for model_key, entry in raw.items():
|
|
2833
|
+
if not isinstance(model_key, str) or not model_key:
|
|
2834
|
+
raise ConfigError("pricing keys must be non-empty strings")
|
|
2835
|
+
if not isinstance(entry, dict):
|
|
2836
|
+
raise ConfigError(f"pricing[{model_key!r}] must be a mapping")
|
|
2837
|
+
|
|
2838
|
+
base = models.get(model_key, ModelRates())
|
|
2839
|
+
rates = ModelRates(
|
|
2840
|
+
input=base.input,
|
|
2841
|
+
output=base.output,
|
|
2842
|
+
cache_read=base.cache_read,
|
|
2843
|
+
cache_creation=base.cache_creation,
|
|
2844
|
+
)
|
|
2845
|
+
for rate_field in _PRICING_RATE_FIELDS:
|
|
2846
|
+
if rate_field not in entry:
|
|
2847
|
+
continue
|
|
2848
|
+
value = entry[rate_field]
|
|
2849
|
+
if not isinstance(value, (int, float)) or isinstance(value, bool) or value < 0:
|
|
2850
|
+
raise ConfigError(
|
|
2851
|
+
f"pricing[{model_key!r}].{rate_field} must be a non-negative number"
|
|
2852
|
+
)
|
|
2853
|
+
setattr(rates, rate_field, float(value))
|
|
2854
|
+
models[model_key] = rates
|
|
2855
|
+
|
|
2856
|
+
return PricingConfig(models=models)
|
|
2857
|
+
|
|
2858
|
+
|
|
2859
|
+
_ENV_VAR_RE = re.compile(r"\$\{([^}]+)\}")
|
|
2860
|
+
|
|
2861
|
+
|
|
2862
|
+
def _expand_env_vars(value: str) -> str:
|
|
2863
|
+
"""Expand ``${VAR}`` placeholders in *value* using :data:`os.environ`.
|
|
2864
|
+
|
|
2865
|
+
Unset variables are left as-is (e.g. ``${MISSING}`` stays
|
|
2866
|
+
``"${MISSING}"``). Only ``${VAR}`` syntax is supported — bare ``$VAR``
|
|
2867
|
+
is not expanded.
|
|
2868
|
+
"""
|
|
2869
|
+
|
|
2870
|
+
def _replace(m: re.Match) -> str: # type: ignore[type-arg]
|
|
2871
|
+
var = m.group(1)
|
|
2872
|
+
return os.environ.get(var, m.group(0))
|
|
2873
|
+
|
|
2874
|
+
return _ENV_VAR_RE.sub(_replace, value)
|
|
2875
|
+
|
|
2876
|
+
|
|
2877
|
+
def _parse_providers(raw: Any) -> ProvidersConfig:
|
|
2878
|
+
"""Parse the optional ``providers:`` block from coordinator.yml.
|
|
2879
|
+
|
|
2880
|
+
An absent block returns ``ProvidersConfig()`` — ``default == "claude"``
|
|
2881
|
+
and an implicit ``"claude"`` definition present. An explicit block may
|
|
2882
|
+
override ``default`` and/or add named definitions. Values in
|
|
2883
|
+
``definitions[*].env`` undergo ``${VAR}`` expansion against
|
|
2884
|
+
:data:`os.environ`.
|
|
2885
|
+
"""
|
|
2886
|
+
if raw is None:
|
|
2887
|
+
return ProvidersConfig()
|
|
2888
|
+
if not isinstance(raw, dict):
|
|
2889
|
+
raise ConfigError("'providers' must be a mapping")
|
|
2890
|
+
|
|
2891
|
+
cfg = ProvidersConfig()
|
|
2892
|
+
|
|
2893
|
+
if "default" in raw:
|
|
2894
|
+
value = raw["default"]
|
|
2895
|
+
if not isinstance(value, str) or not value:
|
|
2896
|
+
raise ConfigError("providers.default must be a non-empty string")
|
|
2897
|
+
cfg.default = value
|
|
2898
|
+
|
|
2899
|
+
defs_raw = raw.get("definitions", {}) or {}
|
|
2900
|
+
if not isinstance(defs_raw, dict):
|
|
2901
|
+
raise ConfigError("providers.definitions must be a mapping")
|
|
2902
|
+
|
|
2903
|
+
for def_name, def_raw in defs_raw.items():
|
|
2904
|
+
if not isinstance(def_name, str):
|
|
2905
|
+
raise ConfigError("providers.definitions keys must be strings")
|
|
2906
|
+
if not isinstance(def_raw, dict):
|
|
2907
|
+
raise ConfigError(
|
|
2908
|
+
f"providers.definitions[{def_name!r}] must be a mapping"
|
|
2909
|
+
)
|
|
2910
|
+
|
|
2911
|
+
ptype = def_raw.get("type")
|
|
2912
|
+
if not ptype or not isinstance(ptype, str):
|
|
2913
|
+
raise ConfigError(
|
|
2914
|
+
f"providers.definitions[{def_name!r}].type is required (string)"
|
|
2915
|
+
)
|
|
2916
|
+
|
|
2917
|
+
binary = def_raw.get("binary")
|
|
2918
|
+
if binary is not None and not isinstance(binary, str):
|
|
2919
|
+
raise ConfigError(
|
|
2920
|
+
f"providers.definitions[{def_name!r}].binary must be a string"
|
|
2921
|
+
)
|
|
2922
|
+
|
|
2923
|
+
model = def_raw.get("model")
|
|
2924
|
+
if model is not None and not isinstance(model, str):
|
|
2925
|
+
raise ConfigError(
|
|
2926
|
+
f"providers.definitions[{def_name!r}].model must be a string"
|
|
2927
|
+
)
|
|
2928
|
+
|
|
2929
|
+
attach_url = def_raw.get("attach_url")
|
|
2930
|
+
if attach_url is not None and not isinstance(attach_url, str):
|
|
2931
|
+
raise ConfigError(
|
|
2932
|
+
f"providers.definitions[{def_name!r}].attach_url must be a string"
|
|
2933
|
+
)
|
|
2934
|
+
|
|
2935
|
+
env_raw = def_raw.get("env", {}) or {}
|
|
2936
|
+
if not isinstance(env_raw, dict):
|
|
2937
|
+
raise ConfigError(
|
|
2938
|
+
f"providers.definitions[{def_name!r}].env must be a mapping"
|
|
2939
|
+
)
|
|
2940
|
+
for k, v in env_raw.items():
|
|
2941
|
+
if not isinstance(k, str) or not isinstance(v, str):
|
|
2942
|
+
raise ConfigError(
|
|
2943
|
+
f"providers.definitions[{def_name!r}].env must map strings to strings"
|
|
2944
|
+
)
|
|
2945
|
+
# Expand ${VAR} in env values.
|
|
2946
|
+
env: dict[str, str] = {k: _expand_env_vars(v) for k, v in env_raw.items()}
|
|
2947
|
+
|
|
2948
|
+
extra_args_raw = def_raw.get("extra_args", []) or []
|
|
2949
|
+
if not isinstance(extra_args_raw, list) or not all(
|
|
2950
|
+
isinstance(a, str) for a in extra_args_raw
|
|
2951
|
+
):
|
|
2952
|
+
raise ConfigError(
|
|
2953
|
+
f"providers.definitions[{def_name!r}].extra_args must be a list of strings"
|
|
2954
|
+
)
|
|
2955
|
+
extra_args: list[str] = list(extra_args_raw)
|
|
2956
|
+
|
|
2957
|
+
cfg.definitions[def_name] = ProviderDef(
|
|
2958
|
+
type=ptype,
|
|
2959
|
+
binary=binary,
|
|
2960
|
+
model=model,
|
|
2961
|
+
attach_url=attach_url,
|
|
2962
|
+
env=env,
|
|
2963
|
+
extra_args=extra_args,
|
|
2964
|
+
)
|
|
2965
|
+
|
|
2966
|
+
# Belt-and-suspenders: ProvidersConfig.__post_init__ already
|
|
2967
|
+
# materialises the implicit "claude" entry when ProvidersConfig() is
|
|
2968
|
+
# constructed above (line ~904), so this branch is unreachable under
|
|
2969
|
+
# current code. Kept as a guard against future refactors that might
|
|
2970
|
+
# construct ProvidersConfig differently (e.g. via dict-update or
|
|
2971
|
+
# bypassing __post_init__ with object.__new__) — the invariant
|
|
2972
|
+
# "definitions always contains 'claude'" is load-bearing for
|
|
2973
|
+
# resolve_provider_name() callers that look up the definition
|
|
2974
|
+
# without checking presence first.
|
|
2975
|
+
if "claude" not in cfg.definitions:
|
|
2976
|
+
cfg.definitions["claude"] = ProviderDef(type="claude")
|
|
2977
|
+
|
|
2978
|
+
# #1889: providers.labels — an issue-level lever mirroring
|
|
2979
|
+
# models.labels (label name -> provider name), parsed AFTER
|
|
2980
|
+
# cfg.definitions above so it can be validated against the very
|
|
2981
|
+
# registry it references. Validated at parse time, the same pattern
|
|
2982
|
+
# reviews.provider uses (#1811) — an unknown provider name here is a
|
|
2983
|
+
# config-load error, not a dispatch-time surprise discovered at 2am.
|
|
2984
|
+
labels_raw = raw.get("labels", {}) or {}
|
|
2985
|
+
if not isinstance(labels_raw, dict) or not all(
|
|
2986
|
+
isinstance(k, str) and isinstance(v, str) for k, v in labels_raw.items()
|
|
2987
|
+
):
|
|
2988
|
+
raise ConfigError(
|
|
2989
|
+
"providers.labels must be a mapping of label name → provider name"
|
|
2990
|
+
)
|
|
2991
|
+
unknown_providers = sorted(set(labels_raw.values()) - set(cfg.definitions))
|
|
2992
|
+
if unknown_providers:
|
|
2993
|
+
raise ConfigError(
|
|
2994
|
+
f"providers.labels references unknown provider(s): {unknown_providers}"
|
|
2995
|
+
)
|
|
2996
|
+
cfg.labels = dict(labels_raw)
|
|
2997
|
+
|
|
2998
|
+
return cfg
|
|
2999
|
+
|
|
3000
|
+
|
|
3001
|
+
def _validate_dependencies(repos: list[Repo]) -> None:
|
|
3002
|
+
from coord.deps import detect_cycles
|
|
3003
|
+
|
|
3004
|
+
repo_names = {r.name for r in repos}
|
|
3005
|
+
for r in repos:
|
|
3006
|
+
unknown = [d for d in r.depends_on if d not in repo_names]
|
|
3007
|
+
if unknown:
|
|
3008
|
+
raise ConfigError(
|
|
3009
|
+
f"repo {r.name!r} depends_on unknown repos: {unknown}"
|
|
3010
|
+
)
|
|
3011
|
+
if r.name in r.depends_on:
|
|
3012
|
+
raise ConfigError(f"repo {r.name!r} cannot depend on itself")
|
|
3013
|
+
|
|
3014
|
+
# #268: reference_repos go through the same name-resolution as
|
|
3015
|
+
# depends_on but DO NOT feed into the cycle detector — the
|
|
3016
|
+
# intent is precisely to allow back-references (vimcode →
|
|
3017
|
+
# quadraui in depends_on; quadraui → vimcode in reference_repos)
|
|
3018
|
+
# that would be cycles if treated as build deps.
|
|
3019
|
+
unknown_ref = [r2 for r2 in r.reference_repos if r2 not in repo_names]
|
|
3020
|
+
if unknown_ref:
|
|
3021
|
+
raise ConfigError(
|
|
3022
|
+
f"repo {r.name!r} reference_repos unknown repos: {unknown_ref}"
|
|
3023
|
+
)
|
|
3024
|
+
if r.name in r.reference_repos:
|
|
3025
|
+
raise ConfigError(
|
|
3026
|
+
f"repo {r.name!r} cannot reference itself"
|
|
3027
|
+
)
|
|
3028
|
+
|
|
3029
|
+
cycles = detect_cycles(repos)
|
|
3030
|
+
if cycles:
|
|
3031
|
+
cycle_str = " → ".join(cycles[0])
|
|
3032
|
+
raise ConfigError(f"circular dependency detected: {cycle_str}")
|