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
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
"""Provider registry: construct and resolve worker-command providers.
|
|
2
|
+
|
|
3
|
+
Public API
|
|
4
|
+
----------
|
|
5
|
+
``build_provider(name, definition, models_cfg) -> Provider``
|
|
6
|
+
Instantiate the correct concrete :class:`~.base.Provider` subclass from a
|
|
7
|
+
:class:`~coord.config.ProviderDef`'s ``type`` field. Raises
|
|
8
|
+
:class:`ValueError` for unknown types.
|
|
9
|
+
|
|
10
|
+
``resolve_provider_name(spec_provider, repo_provider, providers_cfg, issue_labels=None) -> str``
|
|
11
|
+
Apply the precedence chain
|
|
12
|
+
``spec → providers.labels (issue_labels) → repo → providers.default → "claude"``
|
|
13
|
+
(#1889 inserted the label link) and return the winning provider name.
|
|
14
|
+
|
|
15
|
+
``get_provider(provider_name, cfg=None) -> Provider``
|
|
16
|
+
#1710: the single coordinator-side helper that turns a bare
|
|
17
|
+
``provider_name`` string (e.g. ``Assignment.provider_name``, persisted at
|
|
18
|
+
dispatch time — see ``coord/models.py``) into a ready-to-use
|
|
19
|
+
:class:`~.base.Provider` instance, so log consumers (``coord.progress``,
|
|
20
|
+
``coord.usage``, ``coord.failure_class``, ...) can call
|
|
21
|
+
``provider.parse_log()`` instead of importing ``coord.worker_events``
|
|
22
|
+
directly and assuming every worker log is claude-shaped.
|
|
23
|
+
|
|
24
|
+
``provider_def_to_wire(definition) -> dict`` / ``build_provider_from_wire(name, wire_def) -> Provider``
|
|
25
|
+
#1796: the dispatch-payload counterpart to ``build_provider``. A
|
|
26
|
+
config-free agent (no local ``coordinator.yml``, no board service —
|
|
27
|
+
docs/EPHEMERAL_WORKERS.md) has no ``providers.definitions`` registry to
|
|
28
|
+
resolve ``AssignmentSpec.provider`` against. ``provider_def_to_wire``
|
|
29
|
+
(coordinator-side, called from ``coord/dispatch.py``) serializes the
|
|
30
|
+
resolved :class:`~coord.config.ProviderDef` into the JSON-safe dict
|
|
31
|
+
carried as ``AssignmentSpec.provider_def``; ``build_provider_from_wire``
|
|
32
|
+
(agent-side, called from ``coord.agent.AgentServer``) reconstructs the
|
|
33
|
+
same :class:`Provider` instance from it, with no local config needed.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import logging
|
|
39
|
+
from typing import TYPE_CHECKING, Any, Iterable
|
|
40
|
+
|
|
41
|
+
from coord.config import IMPLICIT_PROVIDER_TYPES, ProviderDef, provider_capability
|
|
42
|
+
from coord.providers.base import Capabilities, Provider, WorkerSummary
|
|
43
|
+
from coord.providers.claude import ClaudeProvider
|
|
44
|
+
from coord.providers.claude_pty import ClaudePtyProvider
|
|
45
|
+
from coord.providers.opencode import OpenCodeProvider
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from coord.config import Config, ModelsConfig, ProvidersConfig
|
|
49
|
+
from coord.models import Machine
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"Capabilities",
|
|
53
|
+
"ClaudeProvider",
|
|
54
|
+
"ClaudePtyProvider",
|
|
55
|
+
"OpenCodeProvider",
|
|
56
|
+
"Provider",
|
|
57
|
+
"WorkerSummary",
|
|
58
|
+
"build_provider",
|
|
59
|
+
"build_provider_from_wire",
|
|
60
|
+
"describe_provider_choice",
|
|
61
|
+
"get_provider",
|
|
62
|
+
"guard_provider_machine_capability",
|
|
63
|
+
"guard_unattended_dispatch",
|
|
64
|
+
"machine_supports_provider",
|
|
65
|
+
"machines_supporting_provider",
|
|
66
|
+
"provider_def_to_wire",
|
|
67
|
+
"provider_type_for",
|
|
68
|
+
"resolve_default_provider",
|
|
69
|
+
"resolve_provider_name",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
_log = logging.getLogger(__name__)
|
|
73
|
+
|
|
74
|
+
# Built-in provider types constructible with no ``ProviderDef`` at all (no
|
|
75
|
+
# model/env/extra_args threading — see #1706 comments on ``build_provider``).
|
|
76
|
+
# Used by :func:`get_provider` as the fallback when no ``Config`` is passed
|
|
77
|
+
# (many coordinator-side log consumers only ever had a bare provider_name
|
|
78
|
+
# string in scope — see #1710) or when the name isn't in
|
|
79
|
+
# ``cfg.providers.definitions`` (predates the definition, or a config-free
|
|
80
|
+
# test/CLI context).
|
|
81
|
+
_BUILTIN_PROVIDER_TYPES: dict[str, type[Provider]] = {
|
|
82
|
+
"claude": ClaudeProvider,
|
|
83
|
+
"claude-pty": ClaudePtyProvider,
|
|
84
|
+
"opencode": OpenCodeProvider,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_provider(
|
|
89
|
+
name: str,
|
|
90
|
+
definition: "ProviderDef",
|
|
91
|
+
models_cfg: "ModelsConfig | None",
|
|
92
|
+
) -> Provider:
|
|
93
|
+
"""Construct a :class:`Provider` from *definition*.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
name: The provider's logical name (key in ``providers.definitions``).
|
|
97
|
+
Used only for error messages.
|
|
98
|
+
definition: The parsed :class:`~coord.config.ProviderDef` for this
|
|
99
|
+
provider.
|
|
100
|
+
models_cfg: The coordinator's :class:`~coord.config.ModelsConfig` (may
|
|
101
|
+
be ``None`` when called outside a full config context, e.g. tests).
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
A ready-to-use :class:`Provider` instance.
|
|
105
|
+
|
|
106
|
+
Raises:
|
|
107
|
+
ValueError: When ``definition.type`` is not a known provider type.
|
|
108
|
+
"""
|
|
109
|
+
ptype = definition.type
|
|
110
|
+
if ptype == "claude":
|
|
111
|
+
# #1706: thread model / env / extra_args from the provider
|
|
112
|
+
# definition into the instance. build_command() / env() apply
|
|
113
|
+
# them with the documented precedence (explicit resolved_model >
|
|
114
|
+
# spec.model > definition.model for the model fallback; env and
|
|
115
|
+
# extra_args are additive).
|
|
116
|
+
return ClaudeProvider(
|
|
117
|
+
binary=definition.binary,
|
|
118
|
+
model=definition.model,
|
|
119
|
+
env=definition.env,
|
|
120
|
+
extra_args=definition.extra_args,
|
|
121
|
+
)
|
|
122
|
+
if ptype == "claude-pty":
|
|
123
|
+
# #425: interactive `claude` driven through a PTY — the
|
|
124
|
+
# subscription-billed escape hatch from the 2026-06-15 metering
|
|
125
|
+
# change. #1706: same model / env / extra_args threading as the
|
|
126
|
+
# "claude" branch above.
|
|
127
|
+
return ClaudePtyProvider(
|
|
128
|
+
binary=definition.binary,
|
|
129
|
+
model=definition.model,
|
|
130
|
+
env=definition.env,
|
|
131
|
+
extra_args=definition.extra_args,
|
|
132
|
+
)
|
|
133
|
+
if ptype == "opencode":
|
|
134
|
+
# #325: OpenCode (sst/opencode) worker backend — uses the operator's
|
|
135
|
+
# own API keys, runs `opencode run BRIEFING`. `attach_url` is wired
|
|
136
|
+
# here because it is OpenCode-specific (not a cross-provider concern).
|
|
137
|
+
# #1706: model / env / extra_args threaded the same way as the other
|
|
138
|
+
# provider types.
|
|
139
|
+
return OpenCodeProvider(
|
|
140
|
+
binary=definition.binary,
|
|
141
|
+
attach_url=definition.attach_url,
|
|
142
|
+
model=definition.model,
|
|
143
|
+
env=definition.env,
|
|
144
|
+
extra_args=definition.extra_args,
|
|
145
|
+
)
|
|
146
|
+
raise ValueError(
|
|
147
|
+
f"Unknown provider type {ptype!r} (provider name: {name!r}). "
|
|
148
|
+
f"Supported types: ['claude', 'claude-pty', 'opencode']"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def provider_def_to_wire(definition: "ProviderDef") -> "dict[str, Any]":
|
|
153
|
+
"""Serialize *definition* to the JSON-safe dict carried on the wire as
|
|
154
|
+
``AssignmentSpec.provider_def`` (#1796).
|
|
155
|
+
|
|
156
|
+
Called from ``coord/dispatch.py`` alongside the existing ``"provider"``
|
|
157
|
+
name field, so a config-free agent (no local ``coordinator.yml``, no
|
|
158
|
+
board service — docs/EPHEMERAL_WORKERS.md) receives everything
|
|
159
|
+
:func:`build_provider` needs to construct the SAME provider instance the
|
|
160
|
+
coordinator resolved, without a local ``providers.definitions`` registry
|
|
161
|
+
to look the name up in. See :func:`build_provider_from_wire` for the
|
|
162
|
+
agent-side reconstruction.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
definition: The resolved :class:`~coord.config.ProviderDef`.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
A plain ``dict`` with JSON-safe values only (``env``/``extra_args``
|
|
169
|
+
copied, not aliased, so the caller's definition is never mutated
|
|
170
|
+
through the returned dict).
|
|
171
|
+
"""
|
|
172
|
+
return {
|
|
173
|
+
"type": definition.type,
|
|
174
|
+
"binary": definition.binary,
|
|
175
|
+
"model": definition.model,
|
|
176
|
+
"attach_url": definition.attach_url,
|
|
177
|
+
"env": dict(definition.env),
|
|
178
|
+
"extra_args": list(definition.extra_args),
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def build_provider_from_wire(name: str, wire_def: "dict[str, Any]") -> Provider:
|
|
183
|
+
"""Agent-side counterpart to :func:`provider_def_to_wire` (#1796).
|
|
184
|
+
|
|
185
|
+
Reconstructs the :class:`~coord.config.ProviderDef` the coordinator
|
|
186
|
+
resolved at dispatch time from the plain dict carried in
|
|
187
|
+
``AssignmentSpec.provider_def``, then builds a live :class:`Provider` via
|
|
188
|
+
:func:`build_provider` — the SAME dispatch table used coordinator-side —
|
|
189
|
+
so a config-free agent (no local ``providers.definitions`` registry to
|
|
190
|
+
look *name* up in — docs/EPHEMERAL_WORKERS.md) gets a provider instance
|
|
191
|
+
equivalent to the one the coordinator resolved, instead of being unable
|
|
192
|
+
to honour an explicitly requested ``spec.provider`` at all.
|
|
193
|
+
|
|
194
|
+
This is the fix for #1796: before it existed, a config-free agent that
|
|
195
|
+
could not resolve ``spec.provider`` locally silently fell back to the
|
|
196
|
+
legacy ``claude -p`` spawn path with no error — an explicitly requested
|
|
197
|
+
provider (e.g. ``opencode``) never ran, and every surface (coordinator
|
|
198
|
+
log, board, assignment record) still reported the requested name. See
|
|
199
|
+
``coord.agent.AgentServer._resolve_provider``, which calls this function
|
|
200
|
+
and REFUSES the assignment instead when neither the local registry nor
|
|
201
|
+
``wire_def`` can resolve the name.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
name: The provider's logical name (``AssignmentSpec.provider``).
|
|
205
|
+
Used only for error messages, mirroring :func:`build_provider`.
|
|
206
|
+
wire_def: The dict from ``AssignmentSpec.provider_def`` — see
|
|
207
|
+
:func:`provider_def_to_wire` for its shape.
|
|
208
|
+
|
|
209
|
+
Raises:
|
|
210
|
+
ValueError: *wire_def* is malformed in any way this function can
|
|
211
|
+
detect — not a dict, missing/empty ``"type"``, an ``"env"``/
|
|
212
|
+
``"extra_args"`` whose shape can't be coerced to a dict/list
|
|
213
|
+
(e.g. a string or int — raises :class:`TypeError` from the
|
|
214
|
+
plain ``dict()``/``list()`` calls below, caught and re-raised
|
|
215
|
+
as :class:`ValueError` here) — or *wire_def* names an unknown
|
|
216
|
+
provider type — the last case mirrors :func:`build_provider`'s
|
|
217
|
+
own error for that case. Every case is a :class:`ValueError` so
|
|
218
|
+
:meth:`coord.agent.AgentServer._resolve_provider` (this
|
|
219
|
+
function's only caller) can turn ANY malformed wire payload
|
|
220
|
+
into a clean refused-assignment 400 rather than an uncaught 500
|
|
221
|
+
— the whole point of #1796 is "never silently misbehave", and a
|
|
222
|
+
raw 500 from a coordinator-generated (trusted) but malformed
|
|
223
|
+
payload would be exactly that for the operator watching it fail.
|
|
224
|
+
"""
|
|
225
|
+
if not isinstance(wire_def, dict) or not wire_def.get("type"):
|
|
226
|
+
raise ValueError(
|
|
227
|
+
f"malformed provider_def for provider {name!r}: expected a dict "
|
|
228
|
+
f"with a non-empty 'type' key, got {wire_def!r}"
|
|
229
|
+
)
|
|
230
|
+
try:
|
|
231
|
+
definition = ProviderDef(
|
|
232
|
+
type=wire_def["type"],
|
|
233
|
+
binary=wire_def.get("binary"),
|
|
234
|
+
model=wire_def.get("model"),
|
|
235
|
+
attach_url=wire_def.get("attach_url"),
|
|
236
|
+
env=dict(wire_def.get("env") or {}),
|
|
237
|
+
extra_args=list(wire_def.get("extra_args") or []),
|
|
238
|
+
)
|
|
239
|
+
except (TypeError, ValueError) as e:
|
|
240
|
+
# #1796 review (non-blocking): a wire "env" that isn't dict-shaped
|
|
241
|
+
# (e.g. a string or int) or an "extra_args" that isn't list-shaped
|
|
242
|
+
# raises TypeError from dict()/list() above, not ValueError — catch
|
|
243
|
+
# both here so every malformed shape becomes the same clean refusal
|
|
244
|
+
# instead of a handful of them propagating as an uncaught 500 out of
|
|
245
|
+
# AgentServer.assign() (agent_app.py only catches ValueError).
|
|
246
|
+
raise ValueError(
|
|
247
|
+
f"malformed provider_def for provider {name!r}: {e}"
|
|
248
|
+
) from e
|
|
249
|
+
return build_provider(name, definition, models_cfg=None)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def resolve_provider_name(
|
|
253
|
+
spec_provider: str | None,
|
|
254
|
+
repo_provider: str | None,
|
|
255
|
+
providers_cfg: "ProvidersConfig",
|
|
256
|
+
issue_labels: "list[str] | None" = None,
|
|
257
|
+
) -> str:
|
|
258
|
+
"""Return the effective provider name using the precedence chain.
|
|
259
|
+
|
|
260
|
+
Precedence (highest to lowest):
|
|
261
|
+
1. *spec_provider* — per-assignment override (``AssignmentSpec.provider``).
|
|
262
|
+
2. A ``providers.labels`` match against *issue_labels* (#1889) — e.g.
|
|
263
|
+
``harness:opencode`` -> ``opencode``. See
|
|
264
|
+
:meth:`coord.config.ProvidersConfig.provider_for_labels` for the
|
|
265
|
+
match rule. ``None``/empty *issue_labels* (the default) skips this
|
|
266
|
+
link entirely, reproducing pre-#1889 behavior exactly.
|
|
267
|
+
3. *repo_provider* — per-repo default (``Repo.provider`` in config).
|
|
268
|
+
4. ``providers_cfg.default`` — global default (defaults to ``"claude"``).
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
spec_provider: Provider name from the assignment spec, or ``None``.
|
|
272
|
+
repo_provider: Provider name from the repo config, or ``None``.
|
|
273
|
+
providers_cfg: The parsed :class:`~coord.config.ProvidersConfig`.
|
|
274
|
+
issue_labels: The target issue's GitHub label names, or ``None``.
|
|
275
|
+
#1889: every caller gates this to ``type="work"`` proposals only
|
|
276
|
+
(pass ``None`` for plan/review/smoke dispatches) — the same
|
|
277
|
+
restriction ``models.labels`` uses (#1430), so a harness-eval
|
|
278
|
+
label meant for the eventual work dispatch never leaks into a
|
|
279
|
+
cheap/read-only stage.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
The winning provider name (always a non-empty string).
|
|
283
|
+
"""
|
|
284
|
+
if spec_provider is not None:
|
|
285
|
+
return spec_provider
|
|
286
|
+
if issue_labels:
|
|
287
|
+
label_provider = providers_cfg.provider_for_labels(issue_labels)
|
|
288
|
+
if label_provider is not None:
|
|
289
|
+
return label_provider
|
|
290
|
+
if repo_provider is not None:
|
|
291
|
+
return repo_provider
|
|
292
|
+
return providers_cfg.default
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def describe_provider_choice(
|
|
296
|
+
spec_provider: str | None,
|
|
297
|
+
repo_provider: str | None,
|
|
298
|
+
providers_cfg: "ProvidersConfig",
|
|
299
|
+
issue_labels: "list[str] | None" = None,
|
|
300
|
+
) -> str:
|
|
301
|
+
"""Format a one-line explanation of why the effective provider was chosen.
|
|
302
|
+
|
|
303
|
+
#1707: mirrors ``coord.config.describe_model_choice``'s shape — state the
|
|
304
|
+
winning name AND which link of the
|
|
305
|
+
``spec → label → repo → providers.default`` precedence chain
|
|
306
|
+
(:func:`resolve_provider_name`) supplied it, so
|
|
307
|
+
``coord assign --dry-run --provider ...`` (and any other dry-run/status
|
|
308
|
+
caller) never leaves an operator guessing whether a provider came from an
|
|
309
|
+
explicit ``--provider``, a ``providers.labels`` match, a repo default, or
|
|
310
|
+
the global fallback — the exact ambiguity #1454 fixed for models.
|
|
311
|
+
|
|
312
|
+
#1889: when a ``providers.labels`` match won, names the matched label
|
|
313
|
+
(and any other configured label present on the issue that it shadowed —
|
|
314
|
+
mirrors :func:`coord.config.describe_model_choice`'s
|
|
315
|
+
*shadowed_labels* phrasing, #1633), so a route that might look
|
|
316
|
+
surprising is self-explaining at dispatch time instead of read from
|
|
317
|
+
source — the exact transparency gap #1798 called out for the sibling
|
|
318
|
+
model-label lever.
|
|
319
|
+
|
|
320
|
+
Args:
|
|
321
|
+
spec_provider: Provider name from the assignment spec / CLI flag, or
|
|
322
|
+
``None``.
|
|
323
|
+
repo_provider: Provider name from the repo config, or ``None``.
|
|
324
|
+
providers_cfg: The parsed :class:`~coord.config.ProvidersConfig`.
|
|
325
|
+
issue_labels: The target issue's GitHub label names, or ``None`` —
|
|
326
|
+
see :func:`resolve_provider_name`'s docstring for the
|
|
327
|
+
``type="work"``-only gating every caller applies.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
E.g. ``"opencode (explicit --provider)"``,
|
|
331
|
+
``"opencode (via label 'harness:opencode')"``, or
|
|
332
|
+
``"claude (providers.default)"``.
|
|
333
|
+
"""
|
|
334
|
+
name = resolve_provider_name(spec_provider, repo_provider, providers_cfg, issue_labels)
|
|
335
|
+
if spec_provider is not None:
|
|
336
|
+
return f"{name} (explicit --provider)"
|
|
337
|
+
if issue_labels:
|
|
338
|
+
_, matched_label, shadowed_labels = providers_cfg.provider_for_labels_with_reason(
|
|
339
|
+
issue_labels
|
|
340
|
+
)
|
|
341
|
+
if matched_label:
|
|
342
|
+
if shadowed_labels:
|
|
343
|
+
shadowed_str = ", ".join(repr(label) for label in shadowed_labels)
|
|
344
|
+
return (
|
|
345
|
+
f"{name} (via label {matched_label!r}, "
|
|
346
|
+
f"shadowing {shadowed_str})"
|
|
347
|
+
)
|
|
348
|
+
return f"{name} (via label {matched_label!r})"
|
|
349
|
+
if repo_provider is not None:
|
|
350
|
+
return f"{name} (repo default: Repo.provider)"
|
|
351
|
+
return f"{name} (providers.default)"
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def provider_type_for(provider_name: str, providers_cfg: "ProvidersConfig") -> str:
|
|
355
|
+
"""Resolve *provider_name* to its backend ``type`` (#1711).
|
|
356
|
+
|
|
357
|
+
Falls back to *provider_name* itself when the name isn't registered in
|
|
358
|
+
``providers_cfg.definitions`` (a typo'd name, or one removed from config
|
|
359
|
+
after an assignment was dispatched with it) — mirrors
|
|
360
|
+
:func:`guard_unattended_dispatch`'s posture of not fabricating a refusal
|
|
361
|
+
for an already-broken reference and letting the existing "unknown
|
|
362
|
+
provider" error path surface it instead.
|
|
363
|
+
"""
|
|
364
|
+
definition = providers_cfg.definitions.get(provider_name)
|
|
365
|
+
return definition.type if definition is not None else provider_name
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def machine_supports_provider(
|
|
369
|
+
machine: "Machine", provider_name: str, providers_cfg: "ProvidersConfig",
|
|
370
|
+
) -> bool:
|
|
371
|
+
"""Whether *machine* can run *provider_name* (#1711).
|
|
372
|
+
|
|
373
|
+
``claude``/``claude-pty`` — by resolved TYPE, not registered name, see
|
|
374
|
+
:data:`coord.config.IMPLICIT_PROVIDER_TYPES` — are the implicit
|
|
375
|
+
baseline every machine is assumed to support (unchanged from before
|
|
376
|
+
#1711). Any other backend type requires ``machine.capabilities`` to
|
|
377
|
+
include ``coord.config.provider_capability(type)``, e.g.
|
|
378
|
+
``"provider:opencode"``.
|
|
379
|
+
"""
|
|
380
|
+
ptype = provider_type_for(provider_name, providers_cfg)
|
|
381
|
+
if ptype in IMPLICIT_PROVIDER_TYPES:
|
|
382
|
+
return True
|
|
383
|
+
return provider_capability(ptype) in machine.capabilities
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def machines_supporting_provider(
|
|
387
|
+
machines: Iterable["Machine"], provider_name: str, providers_cfg: "ProvidersConfig",
|
|
388
|
+
) -> list[str]:
|
|
389
|
+
"""Names of every machine in *machines* that can run *provider_name*,
|
|
390
|
+
sorted (#1711). Used to compose the "here's where you CAN dispatch
|
|
391
|
+
this" half of :func:`guard_provider_machine_capability`'s refusal.
|
|
392
|
+
"""
|
|
393
|
+
return sorted(
|
|
394
|
+
m.name for m in machines
|
|
395
|
+
if machine_supports_provider(m, provider_name, providers_cfg)
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def guard_provider_machine_capability(
|
|
400
|
+
*,
|
|
401
|
+
provider_name: str,
|
|
402
|
+
machine: "Machine",
|
|
403
|
+
all_machines: Iterable["Machine"],
|
|
404
|
+
providers_cfg: "ProvidersConfig",
|
|
405
|
+
where: str = "dispatch",
|
|
406
|
+
) -> None:
|
|
407
|
+
"""STRUCTURAL PROVIDER-AVAILABILITY GATE (#1711).
|
|
408
|
+
|
|
409
|
+
Refuses to route *provider_name* to *machine* when *machine* can't run
|
|
410
|
+
it (see :func:`machine_supports_provider`) — e.g. an ``opencode``
|
|
411
|
+
assignment routed to a machine that never declared
|
|
412
|
+
``provider:opencode`` in ``coordinator.yml``. Before this gate, that
|
|
413
|
+
combination failed only at spawn time, deep inside the agent process,
|
|
414
|
+
with an ENOENT-shaped subprocess error discovered minutes into a
|
|
415
|
+
worker — after the assignment row was created and the worktree built.
|
|
416
|
+
This turns it into a clean refusal at the same dispatch chokepoint as
|
|
417
|
+
the #437 TOS gate (:func:`guard_unattended_dispatch`), before any of
|
|
418
|
+
that happens.
|
|
419
|
+
|
|
420
|
+
A machine's own repo/capabilities are matched exactly as
|
|
421
|
+
:func:`machine_supports_provider` defines it — this function adds only
|
|
422
|
+
the refusal message, naming *machine*, the requested provider and its
|
|
423
|
+
resolved type, and every OTHER configured machine that DOES advertise
|
|
424
|
+
the required capability (so an operator can immediately re-target the
|
|
425
|
+
dispatch), or states plainly that no machine advertises it yet.
|
|
426
|
+
|
|
427
|
+
This is a **declaration** check only — whether *machine* actually has
|
|
428
|
+
the backing binary installed is a separate, best-effort concern
|
|
429
|
+
(:mod:`coord.prereqs`, surfaced via ``coord doctor``); a machine that
|
|
430
|
+
lies about a capability it doesn't actually have is caught there, not
|
|
431
|
+
here (mirrors the existing ``rust``/``gtk``/``browser`` split between
|
|
432
|
+
"declared" and "probed-and-met").
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
provider_name: The already-resolved effective provider name (spec →
|
|
436
|
+
repo → ``providers.default``), as returned by
|
|
437
|
+
:func:`resolve_provider_name` or :func:`guard_unattended_dispatch`.
|
|
438
|
+
machine: The machine the dispatch is being routed to.
|
|
439
|
+
all_machines: Every configured machine (``config.machines``) —
|
|
440
|
+
scanned to compose the "these machines DO support it" hint.
|
|
441
|
+
providers_cfg: The coordinator's :class:`~coord.config.ProvidersConfig`.
|
|
442
|
+
where: Short description of the calling site, interpolated into the
|
|
443
|
+
error message (mirrors :func:`guard_unattended_dispatch`).
|
|
444
|
+
|
|
445
|
+
Raises:
|
|
446
|
+
ValueError: When *machine* cannot run *provider_name*.
|
|
447
|
+
"""
|
|
448
|
+
if machine_supports_provider(machine, provider_name, providers_cfg):
|
|
449
|
+
return
|
|
450
|
+
ptype = provider_type_for(provider_name, providers_cfg)
|
|
451
|
+
cap = provider_capability(ptype)
|
|
452
|
+
candidates = [
|
|
453
|
+
name for name in machines_supporting_provider(all_machines, provider_name, providers_cfg)
|
|
454
|
+
if name != machine.name
|
|
455
|
+
]
|
|
456
|
+
if candidates:
|
|
457
|
+
hint = f"machines that DO advertise {cap!r}: {', '.join(candidates)}"
|
|
458
|
+
else:
|
|
459
|
+
hint = f"no configured machine advertises {cap!r} yet"
|
|
460
|
+
raise ValueError(
|
|
461
|
+
f"refusing {where}: machine {machine.name!r} cannot run provider "
|
|
462
|
+
f"{provider_name!r} (type {ptype!r}) — it does not advertise "
|
|
463
|
+
f"{cap!r} in coordinator.yml machines[].capabilities; {hint}. Add "
|
|
464
|
+
f"{cap!r} to a machine that has the {ptype} binary installed, or "
|
|
465
|
+
f"dispatch to one of the machines named above."
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def get_provider(
|
|
470
|
+
provider_name: str | None,
|
|
471
|
+
cfg: "Config | None" = None,
|
|
472
|
+
) -> Provider:
|
|
473
|
+
"""Resolve a bare *provider_name* string to a :class:`Provider` instance.
|
|
474
|
+
|
|
475
|
+
#1710: this is the seam coordinator-side log consumers (``coord.progress``,
|
|
476
|
+
``coord.usage``, ``coord.failure_class``, ...) use to turn the
|
|
477
|
+
already-*resolved* provider name persisted on an assignment
|
|
478
|
+
(``Assignment.provider_name`` — see ``coord/models.py``, set via
|
|
479
|
+
:func:`resolve_provider_name` at dispatch time) into a live
|
|
480
|
+
:class:`Provider` so they can call ``provider.parse_log()`` instead of
|
|
481
|
+
reaching into :mod:`coord.worker_events` directly and assuming every log
|
|
482
|
+
is claude-shaped.
|
|
483
|
+
|
|
484
|
+
Unlike :func:`build_provider`, *cfg* is optional: many call sites (e.g.
|
|
485
|
+
``coord/notify.py``'s completion-capture helpers) only ever had a bare
|
|
486
|
+
``provider_name`` string in scope, never a loaded
|
|
487
|
+
:class:`~coord.config.Config` — see the #1710 inventory. Log parsing
|
|
488
|
+
itself doesn't depend on ``ProviderDef.{model,env,extra_args}`` (none of
|
|
489
|
+
the concrete providers' ``parse_log()`` reads those instance attributes),
|
|
490
|
+
so a config-free construction is always correct for this purpose, even
|
|
491
|
+
though it can't honour a custom ``binary`` override.
|
|
492
|
+
|
|
493
|
+
Resolution order:
|
|
494
|
+
|
|
495
|
+
1. ``provider_name is None`` → ``"claude"`` (the documented implicit
|
|
496
|
+
default for assignments dispatched before #324, or via a path that
|
|
497
|
+
never set the field).
|
|
498
|
+
2. *cfg* given and *provider_name* found in ``cfg.providers.definitions``
|
|
499
|
+
→ :func:`build_provider` (honours ``binary``/``model``/``env``/
|
|
500
|
+
``extra_args`` from the definition).
|
|
501
|
+
3. *provider_name* is one of the built-in type names (``"claude"``,
|
|
502
|
+
``"claude-pty"``, ``"opencode"``) → a bare, no-``ProviderDef``
|
|
503
|
+
instance of that type.
|
|
504
|
+
4. Anything else (a name that is neither configured nor a built-in type —
|
|
505
|
+
a typo, or a provider removed from config after dispatch) →
|
|
506
|
+
:class:`ClaudeProvider`, with a **logged warning**. #1710's whole point
|
|
507
|
+
is that a silent fallback here is the bug; the warning names the
|
|
508
|
+
assignment's actual provider so an operator can tell "misparsed
|
|
509
|
+
because we guessed wrong" apart from "genuinely claude."
|
|
510
|
+
|
|
511
|
+
Args:
|
|
512
|
+
provider_name: The *resolved* provider name, or ``None``.
|
|
513
|
+
cfg: The coordinator's :class:`~coord.config.Config`, when available.
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
A ready-to-use :class:`Provider` instance. Never raises — an unknown
|
|
517
|
+
name degrades to :class:`ClaudeProvider` (loudly) rather than
|
|
518
|
+
blocking whatever best-effort log parsing called this.
|
|
519
|
+
"""
|
|
520
|
+
name = provider_name or "claude"
|
|
521
|
+
if cfg is not None:
|
|
522
|
+
definition = cfg.providers.definitions.get(name)
|
|
523
|
+
if definition is not None:
|
|
524
|
+
try:
|
|
525
|
+
return build_provider(name, definition, cfg.models)
|
|
526
|
+
except ValueError:
|
|
527
|
+
# Unknown ProviderDef.type — fall through to the built-in /
|
|
528
|
+
# warning path below rather than raising out of a best-effort
|
|
529
|
+
# log-parsing caller.
|
|
530
|
+
pass
|
|
531
|
+
ctor = _BUILTIN_PROVIDER_TYPES.get(name)
|
|
532
|
+
if ctor is not None:
|
|
533
|
+
return ctor()
|
|
534
|
+
_log.warning(
|
|
535
|
+
"get_provider: unknown provider_name %r — no matching entry in "
|
|
536
|
+
"providers.definitions and not a built-in provider type. Falling "
|
|
537
|
+
"back to ClaudeProvider for log parsing; if this assignment's "
|
|
538
|
+
"worker is NOT claude, its log will misparse silently downstream "
|
|
539
|
+
"of this warning (#1710).",
|
|
540
|
+
name,
|
|
541
|
+
)
|
|
542
|
+
return ClaudeProvider()
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def resolve_default_provider(
|
|
546
|
+
providers_cfg: "ProvidersConfig",
|
|
547
|
+
models_cfg: "ModelsConfig | None" = None,
|
|
548
|
+
) -> Provider:
|
|
549
|
+
"""Instantiate the coordinator's default provider for unattended oneshot use.
|
|
550
|
+
|
|
551
|
+
Resolves the effective provider name from ``providers_cfg.default``,
|
|
552
|
+
instantiates it via :func:`build_provider`, then checks
|
|
553
|
+
``capabilities().human_attended_only``. Raises :class:`ValueError` if
|
|
554
|
+
the resolved provider is human-attended-only (e.g.
|
|
555
|
+
:class:`~coord.providers.claude_pty.ClaudePtyProvider`) — oneshot callers
|
|
556
|
+
(brain planning, dashboard assistant) are unattended and must never route
|
|
557
|
+
through such a provider.
|
|
558
|
+
|
|
559
|
+
Falls back to a plain :class:`ClaudeProvider` when the resolved name is
|
|
560
|
+
not found in ``providers_cfg.definitions`` (which shouldn't happen in
|
|
561
|
+
practice because :class:`~coord.config.ProvidersConfig` always materialises
|
|
562
|
+
the implicit ``"claude"`` entry).
|
|
563
|
+
|
|
564
|
+
This is the **shared** implementation used by both :mod:`coord.brain` and
|
|
565
|
+
:mod:`coord.dashboard.server`. Any change to default-provider resolution
|
|
566
|
+
or the human-attended guard belongs here — not duplicated in callers.
|
|
567
|
+
|
|
568
|
+
Args:
|
|
569
|
+
providers_cfg: The coordinator's
|
|
570
|
+
:class:`~coord.config.ProvidersConfig`.
|
|
571
|
+
models_cfg: Optional :class:`~coord.config.ModelsConfig`, forwarded
|
|
572
|
+
to :func:`build_provider`.
|
|
573
|
+
|
|
574
|
+
Returns:
|
|
575
|
+
A ready-to-use :class:`Provider` instance whose
|
|
576
|
+
``capabilities().human_attended_only`` is ``False``.
|
|
577
|
+
|
|
578
|
+
Raises:
|
|
579
|
+
ValueError: When the resolved provider reports
|
|
580
|
+
``capabilities().human_attended_only=True``. The error message
|
|
581
|
+
names the provider and points the operator at
|
|
582
|
+
``coord assign --interactive``.
|
|
583
|
+
"""
|
|
584
|
+
name = providers_cfg.default
|
|
585
|
+
definition = providers_cfg.definitions.get(name)
|
|
586
|
+
if definition is None:
|
|
587
|
+
return ClaudeProvider()
|
|
588
|
+
provider = build_provider(name, definition, models_cfg)
|
|
589
|
+
caps = provider.capabilities()
|
|
590
|
+
if caps.human_attended_only:
|
|
591
|
+
raise ValueError(
|
|
592
|
+
f"refusing unattended oneshot call: provider {name!r} reports "
|
|
593
|
+
f"capabilities().human_attended_only=True — this backend is "
|
|
594
|
+
f"licensed only for human-attended interactive use (Anthropic "
|
|
595
|
+
f"ToS §3.7) and must NEVER be selected for unattended "
|
|
596
|
+
f"automation (brain planning, dashboard assistant). Configure "
|
|
597
|
+
f"a non-human-attended provider (e.g. 'claude') as "
|
|
598
|
+
f"providers.default, or launch a human-attended session with "
|
|
599
|
+
f"`coord assign --interactive`."
|
|
600
|
+
)
|
|
601
|
+
return provider
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def guard_unattended_dispatch(
|
|
605
|
+
*,
|
|
606
|
+
spec_provider: str | None,
|
|
607
|
+
repo_provider: str | None,
|
|
608
|
+
providers_cfg: "ProvidersConfig",
|
|
609
|
+
models_cfg: "ModelsConfig | None" = None,
|
|
610
|
+
where: str = "unattended dispatch",
|
|
611
|
+
issue_labels: "list[str] | None" = None,
|
|
612
|
+
) -> str:
|
|
613
|
+
"""STRUCTURAL TOS-COMPLIANCE GATE for unattended dispatch (#437).
|
|
614
|
+
|
|
615
|
+
Resolves the effective provider name with :func:`resolve_provider_name`
|
|
616
|
+
(precedence: spec → label → repo → providers.default), then
|
|
617
|
+
instantiates the provider via :func:`build_provider` and inspects its
|
|
618
|
+
:class:`~coord.providers.base.Capabilities`. Raises :class:`ValueError`
|
|
619
|
+
if ``capabilities().human_attended_only`` is ``True`` — that flag means
|
|
620
|
+
the backend (currently :class:`~coord.providers.claude_pty.ClaudePtyProvider`,
|
|
621
|
+
interactive subscription-billed Claude Code) is only licensed for
|
|
622
|
+
human-attended use under Anthropic ToS §3.7 and must NEVER be selected
|
|
623
|
+
for autonomous routing.
|
|
624
|
+
|
|
625
|
+
This gate is called from every unattended dispatch path
|
|
626
|
+
(``coord.dispatch.dispatch``, ``coord.review.dispatch_review``,
|
|
627
|
+
``coord.reconcile._reassign``). The human-attended escape hatch
|
|
628
|
+
(``coord assign --interactive``) deliberately skips this gate.
|
|
629
|
+
|
|
630
|
+
Args:
|
|
631
|
+
spec_provider: Per-spec/per-proposal provider override, or ``None``.
|
|
632
|
+
repo_provider: Per-repo provider override (``Repo.provider``), or
|
|
633
|
+
``None``.
|
|
634
|
+
providers_cfg: The coordinator's
|
|
635
|
+
:class:`~coord.config.ProvidersConfig`.
|
|
636
|
+
models_cfg: Optional :class:`~coord.config.ModelsConfig`, forwarded
|
|
637
|
+
to :func:`build_provider`.
|
|
638
|
+
where: Short description of the calling site (e.g.
|
|
639
|
+
``"coord approve / dispatch"``) — interpolated into the error
|
|
640
|
+
message so the human knows which path refused.
|
|
641
|
+
issue_labels: The target issue's GitHub label names, or ``None``
|
|
642
|
+
(#1889) — forwarded to :func:`resolve_provider_name` for
|
|
643
|
+
``providers.labels`` resolution. Callers gate this to
|
|
644
|
+
``type="work"`` proposals only, mirroring ``models.labels``
|
|
645
|
+
(#1430); ``None`` reproduces pre-#1889 behavior exactly.
|
|
646
|
+
|
|
647
|
+
Returns:
|
|
648
|
+
The effective provider name (also returned for callers that want to
|
|
649
|
+
thread it onward to the wire payload).
|
|
650
|
+
|
|
651
|
+
Raises:
|
|
652
|
+
ValueError: When the effective provider opts out of unattended use.
|
|
653
|
+
The message names the provider, explains why, and points the
|
|
654
|
+
user at ``coord assign --interactive``.
|
|
655
|
+
"""
|
|
656
|
+
name = resolve_provider_name(spec_provider, repo_provider, providers_cfg, issue_labels)
|
|
657
|
+
definition = providers_cfg.definitions.get(name)
|
|
658
|
+
if definition is None:
|
|
659
|
+
# Unknown name (not in registry) → fall through; the agent's own
|
|
660
|
+
# unknown-provider handling kicks in. Don't fabricate a refusal
|
|
661
|
+
# for a typo'd provider name; let the existing error path surface
|
|
662
|
+
# it as a validation failure at the agent.
|
|
663
|
+
return name
|
|
664
|
+
try:
|
|
665
|
+
provider = build_provider(name, definition, models_cfg)
|
|
666
|
+
except ValueError:
|
|
667
|
+
# build_provider raises on unknown TYPE; the caller will hit the
|
|
668
|
+
# same error path on dispatch. Don't shadow it here.
|
|
669
|
+
return name
|
|
670
|
+
caps = provider.capabilities()
|
|
671
|
+
if caps.human_attended_only:
|
|
672
|
+
raise ValueError(
|
|
673
|
+
f"refusing {where}: provider {name!r} reports "
|
|
674
|
+
f"capabilities().human_attended_only=True — this backend is "
|
|
675
|
+
f"licensed only for human-attended interactive use (Anthropic "
|
|
676
|
+
f"ToS §3.7) and must NEVER be selected for unattended "
|
|
677
|
+
f"automation. To launch a human-attended session, run "
|
|
678
|
+
f"`coord assign --interactive <machine> <repo> <issue>` from "
|
|
679
|
+
f"the operator's terminal; the human drives and closes the "
|
|
680
|
+
f"session. To dispatch unattended, configure a non-human-"
|
|
681
|
+
f"attended provider (e.g. `claude`)."
|
|
682
|
+
)
|
|
683
|
+
return name
|