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.
Files changed (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. coord/worker_events.py +954 -0
@@ -0,0 +1,943 @@
1
+ """Milestone dispatch — Phase 1 of #767 (milestone-driven workflow, #769).
2
+
3
+ Turns Phase 0's pure DAG/frontier (:mod:`coord.milestone_order`) into actual
4
+ dispatches: fetch a milestone's tracking-issue context from GitHub, compute
5
+ the ready frontier, pick an idle/capable machine for each ready issue, and
6
+ dispatch it through the same primitives ``coord assign`` uses
7
+ (:func:`coord.dispatch.dispatch` + ``record_dispatched`` + ``post_briefing``)
8
+ — no new dispatch mechanism.
9
+
10
+ Deliberately mechanical / ~zero-Claude-per-decision, matching Phase 0's
11
+ design note: machine selection is a plain deterministic filter (idle,
12
+ ``Machine.can_work_on(repo)``, not routing-paused — the same candidate filter
13
+ ``coord.reconcile._reassign`` and ``coord.review.pick_reviewer_machine`` use),
14
+ not an LLM judgment call like ``coord.brain.propose``.
15
+
16
+ Three call sites share this module:
17
+
18
+ - ``coord milestone dispatch`` (``coord/commands/milestone.py``) — the
19
+ one-shot CLI dispatch (bulk or ``--next`` single-pick).
20
+ - The daemon's auto-drain tick (``coord.serve_app._milestone_drain_tick``,
21
+ opt-in via ``coordinator.yml`` ``milestone.auto_dispatch``) — re-runs the
22
+ same fetch → plan → dispatch sequence for milestones registered via a
23
+ non-dry-run ``coord milestone dispatch`` call, so newly-unblocked frontier
24
+ entries dispatch automatically as dependencies complete.
25
+ - Tests exercise the pure ``plan_dispatch``/``pick_machine`` functions
26
+ directly with a seeded :class:`~coord.models.Board`, no GitHub or HTTP.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass, field
32
+ from typing import TYPE_CHECKING, Callable, Iterable
33
+
34
+ import httpx
35
+
36
+ from coord.milestone_order import (
37
+ BlockedNode,
38
+ FrontierEntry,
39
+ WorkOrder,
40
+ WorkOrderError,
41
+ parse_work_order,
42
+ ready_frontier,
43
+ validate_milestone_membership,
44
+ )
45
+ from coord.models import Assignment, Board, Machine, Proposal, Repo
46
+
47
+ if TYPE_CHECKING:
48
+ from coord.config import AcceptanceDriverConfig, Config
49
+
50
+ __all__ = [
51
+ "MilestoneDispatchError",
52
+ "MilestoneContext",
53
+ "fetch_milestone_context",
54
+ "GateAFileExists",
55
+ "gate_a_status",
56
+ "ManifestFetch",
57
+ "GateAApprovalFetch",
58
+ "gate_a_signoff",
59
+ "gate_a_signoff_status",
60
+ "OracleReadiness",
61
+ "issue_oracle_ready",
62
+ "pick_machine",
63
+ "MachinePick",
64
+ "NoMachineAvailable",
65
+ "MilestonePlan",
66
+ "plan_dispatch",
67
+ "DispatchOutcome",
68
+ "dispatch_entry",
69
+ "is_milestone_complete",
70
+ ]
71
+
72
+
73
+ class MilestoneDispatchError(Exception):
74
+ """A milestone's tracking-issue context could not be fetched or is invalid.
75
+
76
+ Covers the same failure modes as ``coord milestone order``'s inline
77
+ error handling (GitHub fetch failure, no milestone on the tracking
78
+ issue, a malformed ``## Work order`` block, or a node that isn't a
79
+ member of the milestone) but as a plain exception rather than
80
+ ``click.echo`` + ``sys.exit`` — so both the CLI and the daemon tick can
81
+ catch it and decide how to report it themselves.
82
+ """
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class MilestoneContext:
87
+ """The fetched + validated inputs to :func:`plan_dispatch`."""
88
+
89
+ tracking_issue: int
90
+ milestone_number: int
91
+ work_order: WorkOrder
92
+ terminal_issues: frozenset[int] = field(default_factory=frozenset)
93
+ #: The tracking issue's own GitHub state (``"OPEN"``/``"CLOSED"``).
94
+ #: #1929: the milestone gate machine's Gate D observes "has this shipped"
95
+ #: from here — closing the epic is the observable end of the walk. Comes
96
+ #: free from the ``get_issue`` call this function already makes; defaulted
97
+ #: so every existing construction site (tests included) is unaffected.
98
+ tracking_issue_state: str = "OPEN"
99
+
100
+
101
+ def fetch_milestone_context(repo_cfg: Repo, tracking_issue: int) -> MilestoneContext:
102
+ """Fetch the tracking issue, parse its work order, and resolve terminal state.
103
+
104
+ Shared by ``coord milestone order`` and ``coord milestone dispatch`` (and
105
+ the daemon's auto-drain tick) so all three compute the frontier from
106
+ identical inputs. Raises :class:`MilestoneDispatchError` on any fetch,
107
+ parse, or membership-validation failure.
108
+ """
109
+ from coord import github_ops # noqa: PLC0415
110
+
111
+ try:
112
+ issue_data = github_ops.get_issue(repo_cfg.github, tracking_issue)
113
+ except RuntimeError as e:
114
+ raise MilestoneDispatchError(f"could not fetch #{tracking_issue}: {e}") from e
115
+
116
+ milestone = issue_data.get("milestone") or {}
117
+ milestone_number = milestone.get("number")
118
+ if milestone_number is None:
119
+ raise MilestoneDispatchError(f"#{tracking_issue} has no milestone")
120
+
121
+ body = issue_data.get("body") or ""
122
+ try:
123
+ work_order = parse_work_order(body)
124
+ except WorkOrderError as e:
125
+ raise MilestoneDispatchError(str(e)) from e
126
+
127
+ tracking_issue_state = str(issue_data.get("state") or "OPEN").upper()
128
+
129
+ if not work_order.nodes:
130
+ return MilestoneContext(
131
+ tracking_issue=tracking_issue,
132
+ milestone_number=milestone_number,
133
+ work_order=work_order,
134
+ terminal_issues=frozenset(),
135
+ tracking_issue_state=tracking_issue_state,
136
+ )
137
+
138
+ # Membership + terminal state — mirrors coord/commands/milestone.py's
139
+ # original inline logic (Phase 0): issues currently open under the
140
+ # milestone come free from one `get_open_issues` call; anything a node
141
+ # references that isn't in that set gets an individual lookup (closed,
142
+ # or foreign).
143
+ open_issues = github_ops.get_open_issues(repo_cfg.github)
144
+ milestone_issue_numbers = {
145
+ i["number"]
146
+ for i in open_issues
147
+ if (i.get("milestone") or {}).get("number") == milestone_number
148
+ }
149
+ terminal_issues: set[int] = set()
150
+ for node in work_order.nodes:
151
+ if node.issue_number in milestone_issue_numbers:
152
+ continue
153
+ try:
154
+ node_data = github_ops.get_issue(repo_cfg.github, node.issue_number)
155
+ except RuntimeError as e:
156
+ raise MilestoneDispatchError(
157
+ f"could not fetch #{node.issue_number}: {e}"
158
+ ) from e
159
+ node_milestone_number = (node_data.get("milestone") or {}).get("number")
160
+ if node_milestone_number == milestone_number:
161
+ milestone_issue_numbers.add(node.issue_number)
162
+ if node_data.get("state", "").upper() == "CLOSED":
163
+ terminal_issues.add(node.issue_number)
164
+
165
+ try:
166
+ validate_milestone_membership(work_order, milestone_issue_numbers)
167
+ except WorkOrderError as e:
168
+ raise MilestoneDispatchError(str(e)) from e
169
+
170
+ return MilestoneContext(
171
+ tracking_issue=tracking_issue,
172
+ milestone_number=milestone_number,
173
+ work_order=work_order,
174
+ terminal_issues=frozenset(terminal_issues),
175
+ tracking_issue_state=tracking_issue_state,
176
+ )
177
+
178
+
179
+ def is_milestone_complete(ctx: MilestoneContext) -> bool:
180
+ """Whether every node in the work order has reached a terminal state."""
181
+ return all(
182
+ n.issue_number in ctx.terminal_issues for n in ctx.work_order.nodes
183
+ )
184
+
185
+
186
+ # (repo_github, path, branch) -> True if the file exists at that ref.
187
+ # Injected so tests never hit `gh` — mirrors ``coord.claim``'s BranchLookup.
188
+ GateAFileExists = Callable[[str, str, str], bool]
189
+
190
+
191
+ def _default_gate_a_file_exists(repo_github: str, path: str, branch: str) -> bool:
192
+ from coord import github_ops # noqa: PLC0415
193
+
194
+ try:
195
+ github_ops.get_repo_file(repo_github, path, branch=branch)
196
+ return True
197
+ except RuntimeError:
198
+ return False
199
+
200
+
201
+ def gate_a_status(
202
+ repo_cfg: Repo,
203
+ config: "Config",
204
+ milestone_number: int,
205
+ *,
206
+ file_exists: GateAFileExists | None = None,
207
+ ) -> str | None:
208
+ """Gate A (docs/ORACLE_LOOP.md, #930): a milestone's issues may not
209
+ dispatch until its black-box contract exists.
210
+
211
+ Returns ``None`` when dispatch may proceed — either the repo has no
212
+ ``acceptance.drivers`` entry configured (Gate A is an oracle-loop
213
+ concept; repos outside that model dispatch exactly as before #930), or
214
+ the contract file already exists on the repo's default branch. Returns a
215
+ human-readable block reason otherwise, naming the missing path and the
216
+ command that produces it.
217
+ """
218
+ if not config.acceptance.has_driver(repo_cfg.name):
219
+ return None
220
+
221
+ from coord.acceptance import gate_a_contract_path # noqa: PLC0415
222
+
223
+ path = gate_a_contract_path(milestone_number)
224
+ check = file_exists or _default_gate_a_file_exists
225
+ if check(repo_cfg.github, path, repo_cfg.default_branch):
226
+ return None
227
+ return (
228
+ f"Gate A not satisfied: {path!r} does not exist yet on "
229
+ f"{repo_cfg.default_branch!r}. Run `coord acceptance mock {repo_cfg.name} "
230
+ "<tracking_issue>` (docs/ORACLE_LOOP.md) to render the mock + write "
231
+ "the contract before dispatching this milestone's issues."
232
+ )
233
+
234
+
235
+ # (repo_github, path, branch) -> file content, or None if it doesn't exist.
236
+ # Injected so tests never hit `gh` — mirrors GateAFileExists above.
237
+ ManifestFetch = Callable[[str, str, str], "str | None"]
238
+
239
+
240
+ def _default_fetch_repo_file(repo_github: str, path: str, branch: str) -> str | None:
241
+ from coord import github_ops # noqa: PLC0415
242
+
243
+ try:
244
+ return github_ops.get_repo_file(repo_github, path, branch=branch)
245
+ except RuntimeError:
246
+ return None
247
+
248
+
249
+ def _fetch_manifest_data(
250
+ repo_github: str, milestone_number: int, branch: str, fetch: ManifestFetch,
251
+ ):
252
+ from coord.acceptance import ( # noqa: PLC0415
253
+ ACCEPTANCE_DIRNAME,
254
+ ManifestData,
255
+ ManifestError,
256
+ ms_dirname,
257
+ parse_manifest_text,
258
+ )
259
+
260
+ ms_dir = ms_dirname(milestone_number)
261
+ for ext in (".yml", ".yaml", ".json"):
262
+ path = f"{ACCEPTANCE_DIRNAME}/{ms_dir}/manifest{ext}"
263
+ content = fetch(repo_github, path, branch)
264
+ if content is None:
265
+ continue
266
+ try:
267
+ return parse_manifest_text(content, source=path)
268
+ except ManifestError:
269
+ # Malformed manifest degrades to "no slice authored" rather
270
+ # than crashing dispatch — same fail-soft posture as
271
+ # oracle_loop_contract_block (#945).
272
+ return ManifestData()
273
+ return ManifestData()
274
+
275
+
276
+ def _unsupported_driver_kinds(entry: "AcceptanceDriverConfig") -> tuple[str, ...]:
277
+ """Every driver ``kind`` *entry* declares (its own flat ``kind``, or —
278
+ for a routed entry (#1125) — every ``routes[].kind``) that isn't in
279
+ :data:`coord.acceptance_drivers.SUPPORTED_KINDS` yet.
280
+
281
+ This is the "live check against the currently-installed coord package"
282
+ #1138 asks for: a repo can declare a driver kind in ``coordinator.yml``
283
+ ahead of the code that implements it (exactly what happened with
284
+ ``cli-pytest``/#1125 landing 48 minutes after v0.4.68 shipped) and
285
+ nothing previously noticed before dispatching an issue into it.
286
+ """
287
+ from coord.acceptance_drivers import SUPPORTED_KINDS # noqa: PLC0415
288
+
289
+ kinds = {route.kind for route in entry.routes} if entry.routes else {entry.kind}
290
+ return tuple(sorted(k for k in kinds if k and k not in SUPPORTED_KINDS))
291
+
292
+
293
+ # (repo_name, milestone_number) -> the stored Gate-A verdict dict, or None.
294
+ # Injected so tests never touch the board DB — mirrors ManifestFetch above.
295
+ GateAApprovalFetch = Callable[[str, int], "dict | None"]
296
+
297
+
298
+ def _default_fetch_gate_a_approval(repo_name: str, milestone_number: int):
299
+ from coord import state # noqa: PLC0415
300
+
301
+ try:
302
+ return state.get_gate_a_approval(
303
+ repo_name=repo_name, milestone_number=milestone_number
304
+ )
305
+ except Exception: # noqa: BLE001
306
+ # Fail CLOSED: an unreadable board collapses to "no verdict
307
+ # recorded", which refuses with an operator-readable remedy rather
308
+ # than letting work dispatch against an unreviewed surface (#2063).
309
+ return None
310
+
311
+
312
+ def gate_a_signoff(
313
+ repo_cfg: Repo,
314
+ milestone_number: int,
315
+ manifest,
316
+ *,
317
+ fetch: ManifestFetch,
318
+ approval_fetch: GateAApprovalFetch,
319
+ ):
320
+ """The #2063 human sign-off half of Gate A, as a
321
+ :class:`coord.gate_a.GateADecision`.
322
+
323
+ Gate A has always had two halves that #2063 finally separates: *does the
324
+ contract exist* (:func:`gate_a_status`, #930) and *has a human read it*.
325
+ The second was a convention — "merging the Gate-A PR is sign-off" — which
326
+ anything able to merge a PR satisfied, silently, on CI green. This
327
+ fetches the contract's **current content**, hashes it, and asks whether a
328
+ recorded verdict covers exactly that content.
329
+
330
+ Deliberately consumed here rather than at the merge: the Gate-A PR is
331
+ merged with ``gh pr merge``, outside coord entirely, so no coord-side
332
+ check ever sees it. Refusing where the contract is *consumed* means an
333
+ unapproved contract merging is harmless — nothing is authored and no
334
+ work dispatches until a human records a verdict.
335
+ """
336
+ from coord import gate_a as gate_a_mod # noqa: PLC0415
337
+ from coord.acceptance import gate_a_contract_path # noqa: PLC0415
338
+
339
+ contract_text = fetch(
340
+ repo_cfg.github,
341
+ gate_a_contract_path(milestone_number),
342
+ repo_cfg.default_branch,
343
+ )
344
+ return gate_a_mod.evaluate(
345
+ repo_name=repo_cfg.name,
346
+ milestone_number=milestone_number,
347
+ contract_text=contract_text,
348
+ approval=approval_fetch(repo_cfg.name, milestone_number),
349
+ exempt=bool(getattr(manifest, "gate_a_exempt", False)),
350
+ exempt_reason=str(getattr(manifest, "gate_a_exempt_reason", "") or ""),
351
+ )
352
+
353
+
354
+ def gate_a_signoff_status(
355
+ repo_cfg: Repo,
356
+ config: "Config",
357
+ milestone_number: int,
358
+ *,
359
+ fetch_manifest: ManifestFetch | None = None,
360
+ fetch_gate_a_approval: GateAApprovalFetch | None = None,
361
+ ) -> str | None:
362
+ """``None`` when a human has signed off on this milestone's contract,
363
+ a refusal reason otherwise (#2063).
364
+
365
+ The milestone-level convenience wrapper around :func:`gate_a_signoff`,
366
+ shaped like :func:`gate_a_status` so callers that gate on a *milestone*
367
+ rather than an issue — ``coord acceptance author``, which dispatches the
368
+ independent ``test-author`` — read the same one-line refusal. That path
369
+ is the one that actually burned money on coord-portal ms-2: a sealed
370
+ slice authored against a contract nobody had approved. It happened to be
371
+ a good contract; that was luck, not process.
372
+
373
+ A no-op (``None``) for repos with no ``acceptance.drivers`` entry, and
374
+ for a milestone whose contract does not exist yet — the latter is
375
+ :func:`gate_a_status`'s own, already-surfaced refusal.
376
+ """
377
+ if not config.acceptance.has_driver(repo_cfg.name):
378
+ return None
379
+
380
+ # One memoised fetch seam for both the existence probe and the content
381
+ # read — `gate_a_status` only wants "is it there", `gate_a_signoff`
382
+ # wants the bytes, and without this the contract would be pulled twice
383
+ # over `gh` on every call.
384
+ base = fetch_manifest or _default_fetch_repo_file
385
+ seen: dict[tuple[str, str, str], "str | None"] = {}
386
+
387
+ def fetch(repo_github: str, path: str, branch: str) -> "str | None":
388
+ key = (repo_github, path, branch)
389
+ if key not in seen:
390
+ seen[key] = base(repo_github, path, branch)
391
+ return seen[key]
392
+
393
+ if gate_a_status(
394
+ repo_cfg,
395
+ config,
396
+ milestone_number,
397
+ file_exists=lambda g, p, b: fetch(g, p, b) is not None,
398
+ ) is not None:
399
+ return None
400
+ manifest = _fetch_manifest_data(
401
+ repo_cfg.github, milestone_number, repo_cfg.default_branch, fetch,
402
+ )
403
+ decision = gate_a_signoff(
404
+ repo_cfg,
405
+ milestone_number,
406
+ manifest,
407
+ fetch=fetch,
408
+ approval_fetch=fetch_gate_a_approval or _default_fetch_gate_a_approval,
409
+ )
410
+ return None if decision.ok else decision.reason
411
+
412
+
413
+ @dataclass(frozen=True)
414
+ class OracleReadiness:
415
+ """Issue-level oracle-loop dispatch readiness (#1138), layered on top of
416
+ the milestone-level :func:`gate_a_status`.
417
+
418
+ ``applies`` is ``False`` — a no-op, dispatch proceeds exactly as before
419
+ #1138 — for every issue outside this gate's scope: no milestone, no
420
+ ``acceptance.drivers`` entry configured for the repo, or Gate A itself
421
+ not yet satisfied for this milestone (that's a distinct, already-
422
+ surfaced refusal — see :func:`gate_a_status` — firing a confusing
423
+ "no slice yet" message before the contract even exists would be worse,
424
+ not better). When ``applies`` is ``True``, ``reason`` is ``None`` iff
425
+ dispatch may proceed: either the issue is ``exempt``, or it has an
426
+ authored slice (``has_slice``) AND every driver kind its repo declares
427
+ is implemented by this install (``unsupported_kinds`` empty).
428
+ """
429
+
430
+ applies: bool = False
431
+ exempt: bool = False
432
+ has_slice: bool = False
433
+ unsupported_kinds: tuple[str, ...] = ()
434
+ reason: str | None = None
435
+ #: #2063: the Gate-A human sign-off state for this issue's milestone —
436
+ #: one of :mod:`coord.gate_a`'s ``STATE_*`` values, or ``""`` when this
437
+ #: gate doesn't apply. ``"approved"``/``"exempt"`` are the only values
438
+ #: that let ``reason`` be ``None``.
439
+ gate_a_state: str = ""
440
+
441
+
442
+ def issue_oracle_ready(
443
+ repo_cfg: Repo,
444
+ config: "Config",
445
+ milestone_number: int | None,
446
+ issue_number: int,
447
+ issue_labels: Iterable[str] = (),
448
+ *,
449
+ file_exists: GateAFileExists | None = None,
450
+ fetch_manifest: ManifestFetch | None = None,
451
+ fetch_gate_a_approval: GateAApprovalFetch | None = None,
452
+ ) -> OracleReadiness:
453
+ """The #1138 hard gate: refuse Work dispatch for an issue that belongs
454
+ to an oracle-opted-in milestone (Gate A satisfied — ``contract.md``
455
+ exists) but has no JIT-authored acceptance slice yet, or whose repo
456
+ declares an acceptance driver ``kind`` this ``coord`` install doesn't
457
+ implement — the exact gap that let #1118 dispatch and merge through the
458
+ ordinary Work→Test→Review→Merge pipeline despite ms-37's Gate A already
459
+ being satisfied (2026-07-13 incident, see #1138).
460
+
461
+ Scenarios (b) non-opted-in milestones/epics and (c) plain issues with no
462
+ milestone are unaffected: this only activates when
463
+ ``tests/acceptance/ms-N/contract.md`` exists for *issue_number*'s
464
+ milestone — the same signal :func:`gate_a_status` checks, no new config
465
+ surface. An issue may opt out of the sealed suite (e.g. it builds the
466
+ driver rather than consuming it, like #1125) via an explicit
467
+ ``exempt:`` list in the milestone's manifest or an ``oracle:exempt``
468
+ label — a declared, reviewable decision rather than tribal knowledge.
469
+
470
+ #2063 adds a second refusal on the same seam: the contract exists but
471
+ carries **no recorded human sign-off** for its current content (see
472
+ :func:`gate_a_signoff`). That refusal's prose carries
473
+ :func:`coord.gate_a.park_marker` so ``coord drive-queue``'s tick parks
474
+ the entry (re-checked every tick) instead of landing it in terminal
475
+ ``blocked``, which nothing re-evaluates (#2040) — this is an explicitly
476
+ operator-fixable condition with a one-command remedy.
477
+ """
478
+ if milestone_number is None or not config.acceptance.has_driver(repo_cfg.name):
479
+ return OracleReadiness()
480
+
481
+ # One memoised fetch seam shared by `gate_a_status`'s existence probe and
482
+ # the manifest/contract content reads below — same fix
483
+ # `gate_a_signoff_status` already applies for its own pair of calls.
484
+ # Without this, every dispatch-readiness check against an oracle-opted
485
+ # milestone pulls `tests/acceptance/ms-NN/contract.md` over `gh` TWICE:
486
+ # once here (existence only, content discarded) and again inside
487
+ # `gate_a_signoff` below (content, to hash it). Only takes effect when
488
+ # *file_exists* isn't explicitly overridden — callers that inject their
489
+ # own (tests) keep exactly their prior behaviour.
490
+ base_fetch = fetch_manifest or _default_fetch_repo_file
491
+ _fetch_seen: dict[tuple[str, str, str], "str | None"] = {}
492
+
493
+ def fetch(repo_github: str, path: str, branch: str) -> "str | None":
494
+ key = (repo_github, path, branch)
495
+ if key not in _fetch_seen:
496
+ _fetch_seen[key] = base_fetch(repo_github, path, branch)
497
+ return _fetch_seen[key]
498
+
499
+ effective_file_exists = file_exists or (
500
+ lambda g, p, b: fetch(g, p, b) is not None
501
+ )
502
+
503
+ if gate_a_status(
504
+ repo_cfg, config, milestone_number, file_exists=effective_file_exists
505
+ ) is not None:
506
+ return OracleReadiness()
507
+
508
+ from coord.acceptance import test_ids_for_issue # noqa: PLC0415
509
+ from coord.acceptance_drivers import SUPPORTED_KINDS # noqa: PLC0415
510
+
511
+ manifest = _fetch_manifest_data(
512
+ repo_cfg.github, milestone_number, repo_cfg.default_branch, fetch,
513
+ )
514
+ has_slice = bool(test_ids_for_issue(manifest.tests, issue_number))
515
+ exempt = issue_number in manifest.exempt or "oracle:exempt" in set(issue_labels)
516
+
517
+ entry = config.acceptance.drivers.get(repo_cfg.name)
518
+ unsupported = _unsupported_driver_kinds(entry) if entry is not None else ()
519
+
520
+ # #2063: the human sign-off half of Gate A is checked FIRST, and is not
521
+ # bypassed by the issue-level `exempt` list. `exempt` says "this ISSUE
522
+ # doesn't consume the sealed suite" (e.g. #1125, which builds the
523
+ # driver); it says nothing about whether a human has read the milestone's
524
+ # contract — the surface every sibling issue is about to be built
525
+ # against. The one legitimate opt-out is milestone-level and declared in
526
+ # the manifest (`gate_a: {exempt: true}`), which `gate_a_signoff` honours.
527
+ signoff = gate_a_signoff(
528
+ repo_cfg,
529
+ milestone_number,
530
+ manifest,
531
+ fetch=fetch,
532
+ approval_fetch=fetch_gate_a_approval or _default_fetch_gate_a_approval,
533
+ )
534
+ if not signoff.ok:
535
+ return OracleReadiness(
536
+ applies=True, exempt=exempt, has_slice=has_slice,
537
+ unsupported_kinds=unsupported, reason=signoff.reason,
538
+ gate_a_state=signoff.state,
539
+ )
540
+
541
+ if exempt:
542
+ return OracleReadiness(
543
+ applies=True, exempt=True, has_slice=has_slice,
544
+ unsupported_kinds=unsupported, gate_a_state=signoff.state,
545
+ )
546
+
547
+ reason: str | None = None
548
+ if not has_slice:
549
+ reason = (
550
+ f"Issue #{issue_number} is part of oracle-opted-in milestone "
551
+ f"ms-{milestone_number} (Gate A satisfied) but has no acceptance "
552
+ "slice yet — run `coord acceptance author "
553
+ f"{repo_cfg.name} <tracking_issue> --issue {issue_number}` first. "
554
+ "Already covered by its own unit tests instead (e.g. it builds "
555
+ f"the driver rather than consuming it)? Add {issue_number} to "
556
+ f"tests/acceptance/ms-{milestone_number}/manifest.yml's `exempt:` "
557
+ "list, or label the issue `oracle:exempt`."
558
+ )
559
+ elif unsupported:
560
+ reason = (
561
+ f"Issue #{issue_number}'s repo {repo_cfg.name!r} declares "
562
+ f"acceptance driver kind(s) {', '.join(unsupported)} that this "
563
+ f"coord install doesn't implement yet (supported: "
564
+ f"{', '.join(SUPPORTED_KINDS)}) — update coord "
565
+ "(`coord agent update`) before dispatching."
566
+ )
567
+ return OracleReadiness(
568
+ applies=True, exempt=False, has_slice=has_slice,
569
+ unsupported_kinds=unsupported, reason=reason,
570
+ gate_a_state=signoff.state,
571
+ )
572
+
573
+
574
+ def pick_machine(
575
+ repo_name: str,
576
+ board: Board,
577
+ config: "Config",
578
+ *,
579
+ exclude: frozenset[str] = frozenset(),
580
+ ) -> Machine | None:
581
+ """Deterministically pick an idle, capable, unpaused machine for *repo_name*.
582
+
583
+ Mirrors the candidate filter ``coord.reconcile._reassign`` and
584
+ ``coord.review.pick_reviewer_machine`` already use: idle (no running
585
+ assignment on the board), lists *repo_name* in its ``repos:`` (this is
586
+ what keeps coord-self work off a machine like dellserver whose
587
+ ``coordinator.yml`` entry omits ``claude-coordinator`` — #688), has a
588
+ configured ``repo_paths`` entry, and isn't routing-paused
589
+ (``coord pause``). First match wins in ``config.machines`` order — no
590
+ scoring, no LLM.
591
+
592
+ Deliberately computes "busy" from ``board.active`` directly (like
593
+ ``_reassign``/``pick_reviewer_machine`` do) rather than via
594
+ ``Board.idle_machines()``, which filters ``board.machines`` — a separate
595
+ DB-synced snapshot that isn't guaranteed to be populated on every board
596
+ read path. ``config.machines`` is the authoritative machine list here.
597
+ """
598
+ from coord.machine_pause import paused_set # noqa: PLC0415
599
+
600
+ busy = {a.machine_name for a in board.active if a.status == "running"}
601
+ paused = paused_set(config.machines)
602
+ for m in config.machines:
603
+ if m.name in exclude:
604
+ continue
605
+ if m.name in busy:
606
+ continue
607
+ if m.name in paused:
608
+ continue
609
+ if not m.can_work_on(repo_name):
610
+ continue
611
+ if m.repo_path(repo_name) is None:
612
+ continue
613
+ return m
614
+ return None
615
+
616
+
617
+ @dataclass(frozen=True)
618
+ class MachinePick:
619
+ """A ready-frontier entry paired with the machine it would dispatch to."""
620
+
621
+ entry: FrontierEntry
622
+ machine: Machine
623
+
624
+
625
+ @dataclass(frozen=True)
626
+ class NoMachineAvailable:
627
+ """A ready-frontier entry with nowhere to dispatch it *right now*.
628
+
629
+ Distinct from :class:`~coord.milestone_order.BlockedNode` — the frontier
630
+ itself considers this entry ready (dependencies satisfied, unclaimed,
631
+ unconflicted); it just has no idle capable machine this tick. It will be
632
+ reconsidered on the next ``coord milestone dispatch`` / daemon tick.
633
+ """
634
+
635
+ entry: FrontierEntry
636
+ reason: str = "no idle machine available for this repo"
637
+
638
+
639
+ @dataclass(frozen=True)
640
+ class MilestonePlan:
641
+ """The result of :func:`plan_dispatch`: what to dispatch now, what's
642
+ idle-machine-starved, and what Phase 0's frontier says is still blocked.
643
+ """
644
+
645
+ to_dispatch: tuple[MachinePick, ...] = ()
646
+ skipped: tuple[NoMachineAvailable, ...] = ()
647
+ waiting: tuple[BlockedNode, ...] = ()
648
+
649
+
650
+ def plan_dispatch(
651
+ work_order: WorkOrder,
652
+ board: Board,
653
+ config: "Config",
654
+ repo_cfg: Repo,
655
+ terminal_issues: frozenset[int] | set[int],
656
+ ) -> MilestonePlan:
657
+ """Compute the ready frontier and pick a machine for each ready entry.
658
+
659
+ Pure — no GitHub/HTTP calls, no dispatch side effects. Greedily assigns
660
+ each :class:`~coord.milestone_order.FrontierEntry` in frontier order to
661
+ the first idle+capable machine not already claimed by an earlier entry
662
+ in *this* call (so a cohort of N ready issues fans out across up to N
663
+ distinct idle machines instead of piling onto one).
664
+ """
665
+ frontier = ready_frontier(
666
+ work_order,
667
+ board,
668
+ repo_name=repo_cfg.name,
669
+ repo_github=repo_cfg.github,
670
+ terminal_issues=set(terminal_issues),
671
+ )
672
+ picks: list[MachinePick] = []
673
+ skipped: list[NoMachineAvailable] = []
674
+ used: set[str] = set()
675
+ for entry in frontier.ready:
676
+ machine = pick_machine(repo_cfg.name, board, config, exclude=frozenset(used))
677
+ if machine is None:
678
+ skipped.append(NoMachineAvailable(entry))
679
+ continue
680
+ used.add(machine.name)
681
+ picks.append(MachinePick(entry, machine))
682
+ return MilestonePlan(
683
+ to_dispatch=tuple(picks), skipped=tuple(skipped), waiting=frontier.blocked
684
+ )
685
+
686
+
687
+ @dataclass(frozen=True)
688
+ class DispatchOutcome:
689
+ """The result of one :func:`dispatch_entry` call."""
690
+
691
+ issue_number: int
692
+ machine_name: str
693
+ ok: bool
694
+ assignment_id: str | None = None
695
+ error: str | None = None
696
+ # #1454: the resolved model + a human-readable reason (e.g. "via label
697
+ # 'tier:large'" / "default; no label match") — see
698
+ # `coord.config.describe_model_choice`. `None` on a failed outcome (no
699
+ # model was ever resolved) or for a non-"work" proposal_type.
700
+ model: str | None = None
701
+ model_reason: str | None = None
702
+ # #1889: mirrors `model_reason` above, for the effective PROVIDER
703
+ # instead — see `coord.providers.describe_provider_choice`. `None` on a
704
+ # failed outcome (no provider was ever resolved).
705
+ provider_reason: str | None = None
706
+
707
+
708
+ def dispatch_entry(
709
+ pick: MachinePick,
710
+ repo_cfg: Repo,
711
+ config: "Config",
712
+ board: Board,
713
+ *,
714
+ tracking_issue: int | None = None,
715
+ ) -> DispatchOutcome:
716
+ """Dispatch one ready-frontier entry to its picked machine.
717
+
718
+ Mirrors ``coord.commands.dispatch_workers._dispatch_headless``'s logic
719
+ (build a :class:`~coord.models.Proposal` → defensive claim recheck →
720
+ :func:`coord.dispatch.dispatch` → ``record_dispatched`` →
721
+ ``post_briefing``) without its ``click.echo``/``sys.exit`` coupling, so
722
+ it's usable from both ``coord milestone dispatch`` and the daemon's
723
+ auto-drain tick.
724
+
725
+ On success, appends a lightweight ``running`` :class:`~coord.models.
726
+ Assignment` stub to *board*'s ``active`` list in place — so a caller
727
+ dispatching several entries (or several milestones) in the same batch
728
+ sees the machine as busy for the *next* :func:`plan_dispatch` /
729
+ :func:`pick_machine` call without re-reading the board over the network.
730
+ This does not itself persist the board; ``record_dispatched`` already
731
+ wrote the real assignment row.
732
+
733
+ Re-checks :func:`coord.claim.find_work_claim` immediately before
734
+ dispatching (defense-in-depth against the frontier snapshot going stale
735
+ between planning and dispatch — e.g. a race with a manual `coord
736
+ assign`), matching the same check ``_dispatch_headless`` performs.
737
+ """
738
+ from coord import github_ops # noqa: PLC0415
739
+ from coord.claim import claim_message, find_work_claim # noqa: PLC0415
740
+ from coord.dispatch import ( # noqa: PLC0415
741
+ dispatch,
742
+ post_briefing,
743
+ resolve_dispatch_model_alias,
744
+ )
745
+ from coord.providers import resolve_provider_name # noqa: PLC0415
746
+ from coord.state import record_dispatched # noqa: PLC0415
747
+
748
+ issue_number = pick.entry.issue_number
749
+ machine = pick.machine
750
+
751
+ claim = find_work_claim(issue_number, repo_cfg.name, repo_cfg.github, board)
752
+ if claim is not None:
753
+ return DispatchOutcome(
754
+ issue_number=issue_number,
755
+ machine_name=machine.name,
756
+ ok=False,
757
+ error=claim_message(claim),
758
+ )
759
+
760
+ try:
761
+ issue_data = github_ops.get_issue(repo_cfg.github, issue_number)
762
+ except RuntimeError as e:
763
+ return DispatchOutcome(
764
+ issue_number=issue_number,
765
+ machine_name=machine.name,
766
+ ok=False,
767
+ error=f"could not fetch #{issue_number}: {e}",
768
+ )
769
+ issue_title = issue_data.get("title", f"Issue #{issue_number}")
770
+ issue_body = issue_data.get("body") or ""
771
+ briefing = f"Issue #{issue_number}: {issue_title}\n\n{issue_body}"
772
+ if tracking_issue is not None:
773
+ group_note = f" (group {pick.entry.group})" if pick.entry.group else ""
774
+ briefing += (
775
+ "\n\n---\nDispatched by `coord milestone dispatch` as part of the "
776
+ f"declared work order in #{tracking_issue}{group_note}."
777
+ )
778
+
779
+ issue_labels = [lbl.get("name", "") for lbl in (issue_data.get("labels") or [])]
780
+ required_gates = list(config.pipeline.default_gates)
781
+ for lbl in issue_labels:
782
+ if lbl in config.pipeline.labels:
783
+ required_gates = list(config.pipeline.labels[lbl])
784
+ break
785
+
786
+ # #934: this issue's GitHub Milestone number, when it has one —
787
+ # `issue_data` already carries it (`coord.github_ops.get_issue`'s
788
+ # `milestone` field), so no extra fetch is needed. Threaded onto the
789
+ # Proposal so `coord.dispatch.dispatch()` can resolve the worker's base
790
+ # branch via `coord.branch_model.resolve_base_branch` (`feature/ms-NN`
791
+ # for a repo that opted into the git model, `default_branch` otherwise).
792
+ issue_milestone = issue_data.get("milestone") or {}
793
+ milestone_number = issue_milestone.get("number") if isinstance(issue_milestone, dict) else None
794
+
795
+ if milestone_number is not None and getattr(repo_cfg, "develop_branch", None):
796
+ from coord.branch_model import ensure_feature_branch_exists # noqa: PLC0415
797
+
798
+ try:
799
+ ensure_feature_branch_exists(repo_cfg, milestone_number)
800
+ except (ValueError, RuntimeError) as e:
801
+ return DispatchOutcome(
802
+ issue_number=issue_number,
803
+ machine_name=machine.name,
804
+ ok=False,
805
+ error=f"could not ensure feature/ms-{milestone_number} exists: {e}",
806
+ )
807
+
808
+ # #1430: models.labels routes work dispatches by the issue's tier/type
809
+ # label; plan workers (require_plan=true) deliberately stay on
810
+ # `default` — read-only/cheap, must not inherit a tier:large -> opus
811
+ # routing meant for the eventual work dispatch.
812
+ proposal_type = "plan" if config.dispatch.require_plan else "work"
813
+ label_model, matched_label, shadowed_labels = (
814
+ config.models.model_for_labels_with_reason(issue_labels)
815
+ if proposal_type == "work"
816
+ else (None, None, [])
817
+ )
818
+ # #1889: providers.labels gets the identical type="work"-only gating —
819
+ # this is also the daemon's auto-drain tick, a headless path with no
820
+ # human to type `--provider`, exactly what #1889 exists for.
821
+ provider_issue_labels = issue_labels if proposal_type == "work" else None
822
+ # #1706 review fix: milestone dispatch has no per-call `--provider`
823
+ # override, so the effective provider is spec(None) → label → repo →
824
+ # default — same chain `coord.dispatch.dispatch()` uses. Route model
825
+ # resolution through `resolve_dispatch_model_alias` so a non-claude/
826
+ # claude-pty provider's own pinned `model` isn't shadowed by
827
+ # `models.default`; see that function's docstring for the full
828
+ # rationale. Without this, `resolved_model` was always truthy by the
829
+ # time it reached `Proposal.model`, so `dispatch()`'s own
830
+ # provider-aware fallback could never fire for `coord milestone
831
+ # dispatch`.
832
+ effective_provider_name = resolve_provider_name(
833
+ None, repo_cfg.provider, config.providers,
834
+ issue_labels=provider_issue_labels,
835
+ )
836
+ resolved_model = resolve_dispatch_model_alias(
837
+ explicit_model=None,
838
+ label_model=label_model,
839
+ config=config,
840
+ effective_provider_name=effective_provider_name,
841
+ )
842
+ # #1454: surfaced on the outcome so `coord milestone dispatch`'s CLI
843
+ # output states *why* this model was picked, same as `coord assign` /
844
+ # `coord approve`. `issue_labels` (above, line ~572) came from
845
+ # `issue_data` fetched fresh just moments ago in this same call — so
846
+ # this is never stale, unlike a proposal snapshot carried over from an
847
+ # earlier `coord plan`/`coord milestone plan` run.
848
+ from coord.config import describe_model_choice # noqa: PLC0415
849
+
850
+ if resolved_model:
851
+ model_reason = describe_model_choice(
852
+ resolved_model=resolved_model,
853
+ matched_label=matched_label,
854
+ shadowed_labels=shadowed_labels,
855
+ )
856
+ else:
857
+ # #1706/#1798: resolved_model is None when the effective provider's
858
+ # own `providers.definitions.<name>.model` is pinned — state that
859
+ # explicitly rather than feeding `None` to describe_model_choice
860
+ # (which expects a str). #1798: after the precedence fix, this can
861
+ # now happen *with* a matched label too (the pin winning over it,
862
+ # not just "no label matched") — surface which label lost, same as
863
+ # `_dispatch_headless`'s equivalent branch in dispatch_workers.py,
864
+ # so `coord milestone dispatch`'s output doesn't silently omit it.
865
+ _pinned = config.providers.definitions.get(effective_provider_name)
866
+ if _pinned is not None and _pinned.model:
867
+ _via = f"providers.definitions[{effective_provider_name!r}].model"
868
+ model_reason = (
869
+ f"{_pinned.model} (via {_via}, overriding label {matched_label!r})"
870
+ if matched_label
871
+ else f"{_pinned.model} (via {_via})"
872
+ )
873
+ else:
874
+ model_reason = "none (provider default; no --model support at this call site)"
875
+
876
+ # #1889: mirrors the model_reason block above — state which link of the
877
+ # spec(None) → providers.labels → repo → default chain
878
+ # (coord.providers.resolve_provider_name) won, so a providers.labels
879
+ # match (e.g. `harness:opencode`) is legible in `coord milestone
880
+ # dispatch`'s output, not just discoverable via coordinator.yml.
881
+ from coord.providers import describe_provider_choice # noqa: PLC0415
882
+
883
+ provider_reason = describe_provider_choice(
884
+ None, repo_cfg.provider, config.providers,
885
+ issue_labels=provider_issue_labels,
886
+ )
887
+
888
+ proposal = Proposal(
889
+ id=0,
890
+ machine_name=machine.name,
891
+ repo_name=repo_cfg.name,
892
+ issue_number=issue_number,
893
+ issue_title=issue_title,
894
+ rationale="milestone work-order dispatch (coord milestone dispatch)",
895
+ briefing=briefing,
896
+ model=resolved_model,
897
+ type=proposal_type,
898
+ required_gates=required_gates,
899
+ milestone_number=milestone_number,
900
+ issue_labels=issue_labels,
901
+ )
902
+
903
+ try:
904
+ response = dispatch(proposal, config)
905
+ except (httpx.HTTPError, ValueError) as e:
906
+ return DispatchOutcome(
907
+ issue_number=issue_number, machine_name=machine.name, ok=False, error=str(e)
908
+ )
909
+
910
+ assignment_id = response.get("id", "pending")
911
+ record_dispatched(
912
+ assignment_id=assignment_id,
913
+ proposal=proposal,
914
+ repo_github=repo_cfg.github,
915
+ provider_name=response.get("_provider_name"),
916
+ )
917
+
918
+ try:
919
+ post_briefing(proposal, config, assignment_id=assignment_id, do_not_touch=())
920
+ except Exception: # noqa: BLE001 — best-effort, mirrors _dispatch_headless
921
+ pass
922
+
923
+ board.active.append(
924
+ Assignment(
925
+ machine_name=machine.name,
926
+ repo_name=repo_cfg.name,
927
+ issue_number=issue_number,
928
+ issue_title=issue_title,
929
+ assignment_id=str(assignment_id),
930
+ status="running",
931
+ type=proposal.type,
932
+ )
933
+ )
934
+
935
+ return DispatchOutcome(
936
+ issue_number=issue_number,
937
+ machine_name=machine.name,
938
+ ok=True,
939
+ assignment_id=str(assignment_id),
940
+ model=resolved_model,
941
+ model_reason=model_reason,
942
+ provider_reason=provider_reason,
943
+ )