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/brain.py
ADDED
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
"""Coordinator brain — gathers context and calls the configured provider to propose assignments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import httpx
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from coord.config import Config
|
|
13
|
+
from coord.models import Proposal, SplitChunk, SplitProposal
|
|
14
|
+
from coord import github_ops
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from coord.providers.base import Provider
|
|
18
|
+
|
|
19
|
+
AGENT_PORT = 7433
|
|
20
|
+
|
|
21
|
+
_log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
SYSTEM_PROMPT = """\
|
|
24
|
+
You are the coordinator brain for a multi-repo, multi-machine Claude Code system.
|
|
25
|
+
|
|
26
|
+
Your job: given a set of open issues and available machines, propose which machine
|
|
27
|
+
should work on which issue. Each machine runs one assignment at a time.
|
|
28
|
+
|
|
29
|
+
Rules:
|
|
30
|
+
- Only assign a machine to a repo it has in its repo list.
|
|
31
|
+
- Prefer to spread work across machines rather than queue on one.
|
|
32
|
+
- Respect repo dependencies: if repo A depends on repo B, and repo B has an open
|
|
33
|
+
issue that blocks A's issue, assign B's issue first or flag the dependency.
|
|
34
|
+
- If two issues would touch overlapping files in the same repo, do NOT assign them
|
|
35
|
+
simultaneously — flag the conflict and pick the higher-priority one.
|
|
36
|
+
- If a machine is already busy (has a running assignment), skip it.
|
|
37
|
+
- Write a concise briefing for each assignment: what the worker should do, which
|
|
38
|
+
files are likely involved, and any constraints.
|
|
39
|
+
|
|
40
|
+
Split detection — if an issue is too large for a single worker session, propose
|
|
41
|
+
a split instead of an assignment. Signs an issue is too large:
|
|
42
|
+
- Issue body has a numbered/bulleted list with 5+ independent items
|
|
43
|
+
- Multiple independent files/surfaces/endpoints to change
|
|
44
|
+
- Title contains "migrate all", "replace remaining", "deduplicate all", etc.
|
|
45
|
+
Do NOT split issues that are naturally sequential or tightly coupled.
|
|
46
|
+
|
|
47
|
+
Respond with a JSON array. Each element is EITHER an assignment:
|
|
48
|
+
{
|
|
49
|
+
"type": "assignment",
|
|
50
|
+
"machine_name": "...",
|
|
51
|
+
"repo_name": "...",
|
|
52
|
+
"issue_number": 123,
|
|
53
|
+
"issue_title": "...",
|
|
54
|
+
"rationale": "why this machine for this issue",
|
|
55
|
+
"files_likely": ["path/to/file.py", ...],
|
|
56
|
+
"briefing": "worker instructions"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
OR a split proposal:
|
|
60
|
+
{
|
|
61
|
+
"type": "split",
|
|
62
|
+
"repo_name": "...",
|
|
63
|
+
"issue_number": 123,
|
|
64
|
+
"issue_title": "...",
|
|
65
|
+
"rationale": "why this issue should be split",
|
|
66
|
+
"chunks": [
|
|
67
|
+
{"title": "chunk title", "scope": "what this chunk covers", "files_likely": [...]},
|
|
68
|
+
...
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
If there is nothing to assign (no idle machines, no open issues, or all issues
|
|
73
|
+
are blocked), return an empty array: []
|
|
74
|
+
|
|
75
|
+
Respond with ONLY the JSON array — no markdown fences, no commentary.\
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def gather_context(config: Config) -> dict:
|
|
80
|
+
"""Fetch open issues per repo and agent status per machine."""
|
|
81
|
+
from coord.state import upsert_open_issues
|
|
82
|
+
|
|
83
|
+
issues_by_repo: dict[str, list[dict]] = {}
|
|
84
|
+
for repo in config.repos:
|
|
85
|
+
try:
|
|
86
|
+
issues = github_ops.get_open_issues(repo.github)
|
|
87
|
+
issues_by_repo[repo.name] = issues
|
|
88
|
+
upsert_open_issues(repo.name, issues)
|
|
89
|
+
except RuntimeError:
|
|
90
|
+
issues_by_repo[repo.name] = []
|
|
91
|
+
|
|
92
|
+
machine_status: dict[str, dict] = {}
|
|
93
|
+
for machine in config.machines:
|
|
94
|
+
try:
|
|
95
|
+
resp = httpx.get(
|
|
96
|
+
f"http://{machine.host}:{AGENT_PORT}/status",
|
|
97
|
+
timeout=5,
|
|
98
|
+
)
|
|
99
|
+
machine_status[machine.name] = resp.json()
|
|
100
|
+
except (httpx.HTTPError, httpx.TimeoutException):
|
|
101
|
+
machine_status[machine.name] = {"status": "offline"}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
"issues_by_repo": issues_by_repo,
|
|
105
|
+
"machine_status": machine_status,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def build_prompt(config: Config, context: dict) -> str:
|
|
110
|
+
"""Assemble the user prompt from config and gathered context."""
|
|
111
|
+
from coord.config import IMPLICIT_PROVIDER_TYPES
|
|
112
|
+
from coord.deps import blocked_repos
|
|
113
|
+
from coord.models import Assignment
|
|
114
|
+
from coord.providers import (
|
|
115
|
+
machines_supporting_provider,
|
|
116
|
+
provider_type_for,
|
|
117
|
+
resolve_provider_name,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
lines: list[str] = []
|
|
121
|
+
|
|
122
|
+
lines.append("## Repos")
|
|
123
|
+
for repo in config.repos:
|
|
124
|
+
deps = f" (depends on: {', '.join(repo.depends_on)})" if repo.depends_on else ""
|
|
125
|
+
# #1711: name the repo's resolved provider and which machines can
|
|
126
|
+
# actually run it whenever that's a non-implicit backend (today:
|
|
127
|
+
# opencode) — steers the brain away from proposing a
|
|
128
|
+
# machine/provider pairing coord.dispatch.dispatch() (and
|
|
129
|
+
# coord.brain.filter_unroutable_provider_proposals, the
|
|
130
|
+
# deterministic backstop applied to whatever the brain returns)
|
|
131
|
+
# would refuse anyway.
|
|
132
|
+
provider_hint = ""
|
|
133
|
+
effective_provider_name = resolve_provider_name(
|
|
134
|
+
None, repo.provider, config.providers,
|
|
135
|
+
)
|
|
136
|
+
ptype = provider_type_for(effective_provider_name, config.providers)
|
|
137
|
+
if ptype not in IMPLICIT_PROVIDER_TYPES:
|
|
138
|
+
eligible = machines_supporting_provider(
|
|
139
|
+
config.machines, effective_provider_name, config.providers,
|
|
140
|
+
)
|
|
141
|
+
if eligible:
|
|
142
|
+
provider_hint = (
|
|
143
|
+
f" [provider={effective_provider_name} — ONLY propose "
|
|
144
|
+
f"machines: {', '.join(eligible)}]"
|
|
145
|
+
)
|
|
146
|
+
else:
|
|
147
|
+
provider_hint = (
|
|
148
|
+
f" [provider={effective_provider_name} — NO machine can "
|
|
149
|
+
"run this yet; do not propose an assignment here]"
|
|
150
|
+
)
|
|
151
|
+
lines.append(f"- {repo.name} ({repo.github}){deps}{provider_hint}")
|
|
152
|
+
|
|
153
|
+
lines.append("")
|
|
154
|
+
from coord.machine_pause import paused_set
|
|
155
|
+
paused = paused_set(config.machines)
|
|
156
|
+
lines.append("## Machines")
|
|
157
|
+
for machine in config.machines:
|
|
158
|
+
caps = ", ".join(machine.capabilities) if machine.capabilities else "none"
|
|
159
|
+
repos = ", ".join(machine.repos) if machine.repos else "none"
|
|
160
|
+
status = context["machine_status"].get(machine.name, {})
|
|
161
|
+
if machine.name in paused:
|
|
162
|
+
# Routing-pause: do not propose work for this machine until
|
|
163
|
+
# the user runs `coord unpause`. Reachability is unchanged.
|
|
164
|
+
state = "paused (do not propose work)"
|
|
165
|
+
elif status.get("status") == "offline":
|
|
166
|
+
state = "offline"
|
|
167
|
+
elif status.get("assignment"):
|
|
168
|
+
state = f"busy (working on: {status['assignment'].get('issue_title', '?')})"
|
|
169
|
+
else:
|
|
170
|
+
state = "idle"
|
|
171
|
+
lines.append(f"- {machine.name} @ {machine.host} [{state}]")
|
|
172
|
+
lines.append(f" capabilities: {caps}")
|
|
173
|
+
lines.append(f" repos: {repos}")
|
|
174
|
+
|
|
175
|
+
lines.append("")
|
|
176
|
+
lines.append("## Open Issues")
|
|
177
|
+
for repo_name, issues in context["issues_by_repo"].items():
|
|
178
|
+
if not issues:
|
|
179
|
+
lines.append(f"### {repo_name}: (no open issues)")
|
|
180
|
+
continue
|
|
181
|
+
lines.append(f"### {repo_name}")
|
|
182
|
+
for issue in issues:
|
|
183
|
+
labels = ", ".join(l.get("name", "") for l in issue.get("labels", []))
|
|
184
|
+
label_str = f" [{labels}]" if labels else ""
|
|
185
|
+
lines.append(f"- #{issue['number']}: {issue['title']}{label_str}")
|
|
186
|
+
body = (issue.get("body") or "").strip()
|
|
187
|
+
if body:
|
|
188
|
+
preview = body[:150]
|
|
189
|
+
if len(body) > 150:
|
|
190
|
+
preview += "..."
|
|
191
|
+
lines.append(f" {preview}")
|
|
192
|
+
|
|
193
|
+
# Build active assignments from machine status to compute blocked repos
|
|
194
|
+
active_assignments: list[Assignment] = []
|
|
195
|
+
for machine_name, status in context["machine_status"].items():
|
|
196
|
+
for entry in status.get("active", []):
|
|
197
|
+
spec = entry.get("spec", {})
|
|
198
|
+
active_assignments.append(Assignment(
|
|
199
|
+
machine_name=machine_name,
|
|
200
|
+
repo_name=spec.get("repo_name", ""),
|
|
201
|
+
issue_number=spec.get("issue_number", 0),
|
|
202
|
+
issue_title=spec.get("issue_title", ""),
|
|
203
|
+
status="running",
|
|
204
|
+
))
|
|
205
|
+
|
|
206
|
+
blocked = blocked_repos(config.repos, active_assignments)
|
|
207
|
+
if blocked:
|
|
208
|
+
lines.append("")
|
|
209
|
+
lines.append("## Blocked Repos (DO NOT assign work here)")
|
|
210
|
+
for repo_name, reasons in blocked.items():
|
|
211
|
+
lines.append(f"### {repo_name} — BLOCKED")
|
|
212
|
+
for reason in reasons:
|
|
213
|
+
lines.append(f" - {reason}")
|
|
214
|
+
|
|
215
|
+
return "\n".join(lines)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _resolve_default_provider(config: Config) -> "Provider":
|
|
219
|
+
"""Instantiate the coordinator's default provider from *config*.
|
|
220
|
+
|
|
221
|
+
Thin wrapper around :func:`coord.providers.resolve_default_provider` —
|
|
222
|
+
all logic (precedence chain, ``human_attended_only`` guard, fallback to
|
|
223
|
+
:class:`~coord.providers.claude.ClaudeProvider`) lives there so that brain
|
|
224
|
+
planning and the dashboard assistant share a single implementation.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
config: The coordinator config.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
A ready-to-use :class:`~coord.providers.base.Provider` instance whose
|
|
231
|
+
``capabilities().human_attended_only`` is ``False``.
|
|
232
|
+
|
|
233
|
+
Raises:
|
|
234
|
+
ValueError: When the configured default provider reports
|
|
235
|
+
``capabilities().human_attended_only=True``. Brain planning is
|
|
236
|
+
an unattended path and must not route through a human-attended
|
|
237
|
+
backend such as :class:`~coord.providers.claude_pty.ClaudePtyProvider`.
|
|
238
|
+
"""
|
|
239
|
+
from coord.providers import resolve_default_provider # noqa: PLC0415
|
|
240
|
+
|
|
241
|
+
return resolve_default_provider(config.providers, config.models)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def call_claude(system: str, user: str, *, provider: "Provider | None" = None) -> str:
|
|
245
|
+
"""Run the configured provider in one-shot mode and return the text response.
|
|
246
|
+
|
|
247
|
+
Builds the subprocess argv via ``provider.oneshot_command()`` so that
|
|
248
|
+
brain planning honours the coordinator's configured backend rather than
|
|
249
|
+
hard-coding ``claude``.
|
|
250
|
+
|
|
251
|
+
When *provider* is ``None`` a :class:`~coord.providers.claude.ClaudeProvider`
|
|
252
|
+
is used — matching the historical behaviour (``claude -p``). Callers
|
|
253
|
+
that want to honour the coordinator's configured backend should pass
|
|
254
|
+
the result of :func:`_resolve_default_provider`.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
system: The system prompt for the brain's planning call.
|
|
258
|
+
user: The user message (piped to the subprocess via stdin).
|
|
259
|
+
provider: Provider whose ``oneshot_command()`` is called to build
|
|
260
|
+
the argv. ``None`` falls back to
|
|
261
|
+
:class:`~coord.providers.claude.ClaudeProvider`.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
The text response string.
|
|
265
|
+
|
|
266
|
+
Raises:
|
|
267
|
+
RuntimeError: When the subprocess exits with a non-zero return code.
|
|
268
|
+
"""
|
|
269
|
+
if provider is None:
|
|
270
|
+
from coord.providers.claude import ClaudeProvider # noqa: PLC0415
|
|
271
|
+
provider = ClaudeProvider()
|
|
272
|
+
|
|
273
|
+
cmd = provider.oneshot_command(system_prompt=system, output_format="json")
|
|
274
|
+
result = subprocess.run(
|
|
275
|
+
cmd,
|
|
276
|
+
input=user,
|
|
277
|
+
capture_output=True,
|
|
278
|
+
text=True,
|
|
279
|
+
timeout=300,
|
|
280
|
+
)
|
|
281
|
+
if result.returncode != 0:
|
|
282
|
+
raise RuntimeError(
|
|
283
|
+
f"brain provider call failed (exit {result.returncode}): {result.stderr.strip()}"
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
# Try to extract the "result" field from a claude -p JSON envelope.
|
|
287
|
+
# Falls back to raw stdout for providers that don't emit this shape
|
|
288
|
+
# (e.g. OpenCodeProvider, whose output is unstructured).
|
|
289
|
+
try:
|
|
290
|
+
outer = json.loads(result.stdout)
|
|
291
|
+
if isinstance(outer, dict) and "result" in outer:
|
|
292
|
+
return outer["result"]
|
|
293
|
+
except (json.JSONDecodeError, ValueError):
|
|
294
|
+
pass
|
|
295
|
+
return result.stdout
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _strip_fences(text: str) -> str:
|
|
299
|
+
cleaned = text.strip()
|
|
300
|
+
fence = re.match(r"^```(?:json)?\s*\n(.*?)```\s*$", cleaned, re.DOTALL)
|
|
301
|
+
return fence.group(1).strip() if fence else cleaned
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def parse_proposals(text: str) -> list[Proposal]:
|
|
305
|
+
"""Parse the JSON response from Claude into Proposal objects."""
|
|
306
|
+
data = json.loads(_strip_fences(text))
|
|
307
|
+
if not isinstance(data, list):
|
|
308
|
+
raise ValueError(f"Expected JSON array of proposals, got {type(data).__name__}")
|
|
309
|
+
|
|
310
|
+
proposals = []
|
|
311
|
+
for i, item in enumerate(data):
|
|
312
|
+
if item.get("type") == "split":
|
|
313
|
+
continue
|
|
314
|
+
proposals.append(Proposal(
|
|
315
|
+
id=i + 1,
|
|
316
|
+
machine_name=item["machine_name"],
|
|
317
|
+
repo_name=item["repo_name"],
|
|
318
|
+
issue_number=item["issue_number"],
|
|
319
|
+
issue_title=item["issue_title"],
|
|
320
|
+
rationale=item.get("rationale", ""),
|
|
321
|
+
files_likely=item.get("files_likely", []),
|
|
322
|
+
briefing=item.get("briefing", ""),
|
|
323
|
+
))
|
|
324
|
+
return proposals
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def resolve_required_gates(
|
|
328
|
+
proposals: list[Proposal],
|
|
329
|
+
config: Config,
|
|
330
|
+
issues_by_repo: dict[str, list[dict]],
|
|
331
|
+
) -> None:
|
|
332
|
+
"""Resolve required_gates for each proposal from config.pipeline.labels.
|
|
333
|
+
|
|
334
|
+
Mutates proposals in place: for each proposal, looks up the issue in
|
|
335
|
+
``issues_by_repo`` to get its GitHub labels, then checks each label against
|
|
336
|
+
``config.pipeline.labels``. The first matching label wins. If no label
|
|
337
|
+
matches, ``required_gates`` is left unchanged (empty → caller falls back to
|
|
338
|
+
``config.pipeline.default_gates`` at dispatch/pipeline-view time).
|
|
339
|
+
"""
|
|
340
|
+
if not config.pipeline.labels:
|
|
341
|
+
return # no label overrides configured, nothing to do
|
|
342
|
+
|
|
343
|
+
for proposal in proposals:
|
|
344
|
+
repo_issues = issues_by_repo.get(proposal.repo_name, [])
|
|
345
|
+
issue = next(
|
|
346
|
+
(iss for iss in repo_issues if iss.get("number") == proposal.issue_number),
|
|
347
|
+
None,
|
|
348
|
+
)
|
|
349
|
+
if issue is None:
|
|
350
|
+
continue
|
|
351
|
+
issue_labels: list[str] = [
|
|
352
|
+
lbl.get("name", "") for lbl in (issue.get("labels") or [])
|
|
353
|
+
]
|
|
354
|
+
for lbl in issue_labels:
|
|
355
|
+
if lbl in config.pipeline.labels:
|
|
356
|
+
proposal.required_gates = list(config.pipeline.labels[lbl])
|
|
357
|
+
break
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def resolve_models(
|
|
361
|
+
proposals: list[Proposal],
|
|
362
|
+
config: Config,
|
|
363
|
+
issues_by_repo: dict[str, list[dict]],
|
|
364
|
+
) -> None:
|
|
365
|
+
"""Resolve proposal.model for each work proposal from config.models.labels.
|
|
366
|
+
|
|
367
|
+
#1430: mirrors :func:`resolve_required_gates`'s shape (walk each
|
|
368
|
+
proposal's issue, resolve its labels, set a field). Precedence no
|
|
369
|
+
longer matches, though: #1633 found that issue-label order (which
|
|
370
|
+
GitHub controls, not this repo) made ``tier:small``/``tier:large``
|
|
371
|
+
silent no-ops on any issue that also carried a type label, so
|
|
372
|
+
:meth:`coord.config.ModelsConfig.model_for_labels_with_reason` now
|
|
373
|
+
resolves deterministically instead — ``tier:*`` entries first, then
|
|
374
|
+
all other entries, ties within each group broken by ``models.labels``'s
|
|
375
|
+
own declaration order in ``coordinator.yml``. ``resolve_required_gates``
|
|
376
|
+
below still uses the old issue-label-order convention for
|
|
377
|
+
``pipeline.labels``; that's a separate, still-open instance of the same
|
|
378
|
+
bug class (tracked outside #1633's scope) and not something callers of
|
|
379
|
+
this function should assume matches.
|
|
380
|
+
|
|
381
|
+
Only ``type="work"`` proposals are touched. ``_apply_require_plan``
|
|
382
|
+
(called before this, in :func:`propose`) may already have upgraded a
|
|
383
|
+
proposal to ``type="plan"`` — plan workers are read-only/cheap and must
|
|
384
|
+
not inherit a ``tier:large`` -> opus routing meant for the eventual work
|
|
385
|
+
dispatch, so plan proposals are left on ``models.default`` here.
|
|
386
|
+
|
|
387
|
+
Leaves ``proposal.model`` unset (``None``) when no label matches, so the
|
|
388
|
+
existing ``if not p.model: p.model = cfg.models.default`` fallback in
|
|
389
|
+
``coord approve`` (``coord/commands/dispatch.py``) keeps working
|
|
390
|
+
unchanged. Never overrides a model already set on the proposal (the
|
|
391
|
+
brain's own JSON output, or a human editing saved proposals before
|
|
392
|
+
approving).
|
|
393
|
+
|
|
394
|
+
#1889: also stamps ``proposal.issue_labels`` when only
|
|
395
|
+
``config.providers.labels`` (not ``config.models.labels``) is
|
|
396
|
+
configured, so :func:`filter_unroutable_provider_proposals` (below) can
|
|
397
|
+
resolve ``providers.labels`` too — a provider-only label eval must not
|
|
398
|
+
require ``models.labels`` to also be configured just to get its issue's
|
|
399
|
+
labels threaded through. No extra GitHub call either way: both concerns
|
|
400
|
+
read the same already-fetched ``issues_by_repo``.
|
|
401
|
+
"""
|
|
402
|
+
if not config.models.labels and not config.providers.labels:
|
|
403
|
+
return # no label overrides of either kind configured, nothing to do
|
|
404
|
+
|
|
405
|
+
for proposal in proposals:
|
|
406
|
+
if proposal.type != "work":
|
|
407
|
+
continue
|
|
408
|
+
repo_issues = issues_by_repo.get(proposal.repo_name, [])
|
|
409
|
+
issue = next(
|
|
410
|
+
(iss for iss in repo_issues if iss.get("number") == proposal.issue_number),
|
|
411
|
+
None,
|
|
412
|
+
)
|
|
413
|
+
if issue is None:
|
|
414
|
+
continue
|
|
415
|
+
issue_labels: list[str] = [
|
|
416
|
+
lbl.get("name", "") for lbl in (issue.get("labels") or [])
|
|
417
|
+
]
|
|
418
|
+
proposal.issue_labels = issue_labels
|
|
419
|
+
if proposal.model:
|
|
420
|
+
continue
|
|
421
|
+
resolved = config.models.model_for_labels(issue_labels)
|
|
422
|
+
if resolved:
|
|
423
|
+
proposal.model = resolved
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def parse_split_proposals(text: str) -> list[SplitProposal]:
|
|
427
|
+
"""Parse split proposals from the brain's JSON response."""
|
|
428
|
+
data = json.loads(_strip_fences(text))
|
|
429
|
+
if not isinstance(data, list):
|
|
430
|
+
return []
|
|
431
|
+
|
|
432
|
+
splits = []
|
|
433
|
+
for i, item in enumerate(data):
|
|
434
|
+
if item.get("type") != "split":
|
|
435
|
+
continue
|
|
436
|
+
chunks = [
|
|
437
|
+
SplitChunk(
|
|
438
|
+
title=c["title"],
|
|
439
|
+
scope=c.get("scope", ""),
|
|
440
|
+
files_likely=c.get("files_likely", []),
|
|
441
|
+
)
|
|
442
|
+
for c in item.get("chunks", [])
|
|
443
|
+
]
|
|
444
|
+
splits.append(SplitProposal(
|
|
445
|
+
id=i + 1,
|
|
446
|
+
repo_name=item["repo_name"],
|
|
447
|
+
issue_number=item["issue_number"],
|
|
448
|
+
issue_title=item["issue_title"],
|
|
449
|
+
rationale=item.get("rationale", ""),
|
|
450
|
+
chunks=chunks,
|
|
451
|
+
))
|
|
452
|
+
return splits
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _annotate_large_proposals(proposals: list[Proposal], config: Config) -> None:
|
|
456
|
+
"""Append a split-suggestion note to proposals that exceed the file threshold.
|
|
457
|
+
|
|
458
|
+
Mutates proposals in place. The note is appended to ``rationale`` so it
|
|
459
|
+
surfaces in ``coord plan`` output without cluttering the briefing itself.
|
|
460
|
+
"""
|
|
461
|
+
threshold = config.dispatch.max_files_per_worker
|
|
462
|
+
for p in proposals:
|
|
463
|
+
if len(p.files_likely) > threshold:
|
|
464
|
+
note = (
|
|
465
|
+
f" [⚠ {len(p.files_likely)} files > threshold {threshold} — "
|
|
466
|
+
"consider splitting via coord split]"
|
|
467
|
+
)
|
|
468
|
+
if note not in p.rationale:
|
|
469
|
+
p.rationale += note
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _apply_require_plan(proposals: list[Proposal], config: Config) -> None:
|
|
473
|
+
"""When dispatch.require_plan is true, upgrade all work proposals to plan type.
|
|
474
|
+
|
|
475
|
+
Mutates proposals in place. Only work proposals are affected — review, smoke,
|
|
476
|
+
and already-typed plan proposals are left unchanged.
|
|
477
|
+
"""
|
|
478
|
+
if not config.dispatch.require_plan:
|
|
479
|
+
return
|
|
480
|
+
for p in proposals:
|
|
481
|
+
if p.type == "work":
|
|
482
|
+
p.type = "plan"
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def filter_unroutable_provider_proposals(
|
|
486
|
+
proposals: list[Proposal], config: Config,
|
|
487
|
+
) -> tuple[list[Proposal], list[tuple[Proposal, str]]]:
|
|
488
|
+
"""#1711: drop any brain-proposed assignment whose target machine
|
|
489
|
+
cannot run the repo's resolved provider.
|
|
490
|
+
|
|
491
|
+
The brain is a free-text LLM planner (see ``SYSTEM_PROMPT``) — nothing
|
|
492
|
+
stops it proposing ``machine_name="laptop"`` for a repo whose resolved
|
|
493
|
+
provider is ``opencode`` when ``laptop`` never declared
|
|
494
|
+
``provider:opencode`` in ``coordinator.yml``.
|
|
495
|
+
:func:`coord.dispatch.dispatch` would refuse that exact combination at
|
|
496
|
+
dispatch time anyway (:func:`coord.providers.
|
|
497
|
+
guard_provider_machine_capability` — the same #1711 gate), but only
|
|
498
|
+
once the proposal has already been shown to the operator and approved.
|
|
499
|
+
This runs the identical deterministic check at planning time instead,
|
|
500
|
+
so ``coord plan`` never shows a proposal ``coord approve`` would
|
|
501
|
+
immediately refuse.
|
|
502
|
+
|
|
503
|
+
A brain-authored :class:`~coord.models.Proposal` never carries a
|
|
504
|
+
per-proposal ``provider`` override — the JSON schema documented in
|
|
505
|
+
``SYSTEM_PROMPT`` has no such field, only ``machine_name``/
|
|
506
|
+
``repo_name``/etc — so the effective provider for each proposal comes
|
|
507
|
+
from ``Repo.provider`` / ``providers.default`` alone, exactly like any
|
|
508
|
+
other dispatch with no explicit ``--provider``.
|
|
509
|
+
|
|
510
|
+
An unresolvable ``machine_name`` (typo, or a machine removed from
|
|
511
|
+
config after the brain call started) is left in ``kept`` — that's a
|
|
512
|
+
different, pre-existing failure mode (``coord approve``'s own "Unknown
|
|
513
|
+
machine" error), not this filter's concern.
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
``(kept, dropped)`` — ``dropped`` pairs each rejected proposal with
|
|
517
|
+
the human-readable refusal reason (the same message
|
|
518
|
+
:func:`~coord.providers.guard_provider_machine_capability` would
|
|
519
|
+
raise at dispatch time), so a caller can report exactly why a
|
|
520
|
+
proposal disappeared instead of it just silently not showing up.
|
|
521
|
+
"""
|
|
522
|
+
from coord.providers import ( # noqa: PLC0415
|
|
523
|
+
guard_provider_machine_capability,
|
|
524
|
+
resolve_provider_name,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
kept: list[Proposal] = []
|
|
528
|
+
dropped: list[tuple[Proposal, str]] = []
|
|
529
|
+
for p in proposals:
|
|
530
|
+
machine = next((m for m in config.machines if m.name == p.machine_name), None)
|
|
531
|
+
if machine is None:
|
|
532
|
+
kept.append(p)
|
|
533
|
+
continue
|
|
534
|
+
repo = config.repo(p.repo_name)
|
|
535
|
+
# #1889: providers.labels, gated to type="work" like every other
|
|
536
|
+
# dispatch site — `p.issue_labels` is stamped by `resolve_models`
|
|
537
|
+
# above (in the same `propose()` call, before this filter runs) for
|
|
538
|
+
# both models.labels and providers.labels, so this reflects the
|
|
539
|
+
# same labels `coord approve`/`dispatch()` will resolve against.
|
|
540
|
+
effective_provider_name = resolve_provider_name(
|
|
541
|
+
getattr(p, "provider", None),
|
|
542
|
+
repo.provider if repo is not None else None,
|
|
543
|
+
config.providers,
|
|
544
|
+
issue_labels=p.issue_labels if p.type == "work" else None,
|
|
545
|
+
)
|
|
546
|
+
try:
|
|
547
|
+
guard_provider_machine_capability(
|
|
548
|
+
provider_name=effective_provider_name,
|
|
549
|
+
machine=machine,
|
|
550
|
+
all_machines=config.machines,
|
|
551
|
+
providers_cfg=config.providers,
|
|
552
|
+
where="coord plan",
|
|
553
|
+
)
|
|
554
|
+
except ValueError as e:
|
|
555
|
+
_log.warning("coord plan: dropping unroutable proposal: %s", e)
|
|
556
|
+
dropped.append((p, str(e)))
|
|
557
|
+
continue
|
|
558
|
+
kept.append(p)
|
|
559
|
+
return kept, dropped
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def propose(config: Config) -> tuple[list[Proposal], list[SplitProposal]]:
|
|
563
|
+
"""Full brain cycle: gather context, call the provider, return proposals and splits."""
|
|
564
|
+
provider = _resolve_default_provider(config)
|
|
565
|
+
context = gather_context(config)
|
|
566
|
+
prompt = build_prompt(config, context)
|
|
567
|
+
response = call_claude(SYSTEM_PROMPT, prompt, provider=provider)
|
|
568
|
+
proposals = parse_proposals(response)
|
|
569
|
+
_apply_require_plan(proposals, config)
|
|
570
|
+
resolve_required_gates(proposals, config, context["issues_by_repo"])
|
|
571
|
+
resolve_models(proposals, config, context["issues_by_repo"])
|
|
572
|
+
_annotate_large_proposals(proposals, config)
|
|
573
|
+
# #1711: never return a proposal `coord approve` would immediately
|
|
574
|
+
# refuse for lacking the resolved provider's capability — see
|
|
575
|
+
# filter_unroutable_provider_proposals's docstring. Dropped proposals
|
|
576
|
+
# are logged (above) rather than surfaced here; `propose()` is used by
|
|
577
|
+
# both `coord plan`'s CLI wrapper (which reports drops itself, see
|
|
578
|
+
# coord.commands.dispatch.plan) and non-CLI callers (e.g. the
|
|
579
|
+
# dashboard) that have no click.echo to report through.
|
|
580
|
+
proposals, _dropped = filter_unroutable_provider_proposals(proposals, config)
|
|
581
|
+
return proposals, parse_split_proposals(response)
|