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
coord/dispatch.py ADDED
@@ -0,0 +1,1009 @@
1
+ """Dispatch approved assignments to agent servers and post briefings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Iterable
7
+
8
+ import httpx
9
+
10
+ from coord import github_ops
11
+ from coord.comments import (
12
+ format_advisory,
13
+ format_briefing,
14
+ format_completion,
15
+ format_failure,
16
+ )
17
+ from coord.config import Config
18
+ from coord.models import Proposal, Repo
19
+
20
+ AGENT_PORT = 7433
21
+
22
+
23
+ class DispatchRefused(ValueError):
24
+ """A pre-dispatch guard's refusal — deterministic, not transient (#1844).
25
+
26
+ Raised by :func:`enforce_oracle_readiness` and
27
+ :func:`enforce_epic_dispatch_guard` instead of a plain ``ValueError``:
28
+ both refuse on a condition that CANNOT change between attempts (no
29
+ acceptance slice exists yet; a tracking issue carries the epic label),
30
+ unlike the other ``ValueError``s ``dispatch()`` can raise (an unresolved
31
+ machine/repo_path, the #437 TOS gate, a provider/machine capability
32
+ mismatch) which this deliberately leaves alone — those are refusals too,
33
+ but reclassifying their retry-worthiness is outside what this issue
34
+ covers, and several of them ARE operator-fixable in ways a running fleet
35
+ can race (e.g. adding a capability to a machine's config while `coord
36
+ drive-queue` is ticking).
37
+
38
+ A subclass of ``ValueError``, not a new hierarchy: every existing
39
+ ``except ValueError`` catch (CLI error handling, tests asserting the
40
+ message) keeps working completely unchanged — `str(exc)` is still the
41
+ plain refusal text. Only a caller that specifically wants to know "was
42
+ this refusal deterministic" (``coord drive``'s subprocess boundary, via
43
+ ``coord assign``/``coord approve-plan``/``coord fix`` mapping THIS
44
+ exception — and no other — to ``coord.drive.EXIT_DISPATCH_REFUSED``)
45
+ needs to catch it by name.
46
+ """
47
+
48
+
49
+ def enforce_oracle_readiness(
50
+ *, proposal_type: str, repo: Repo | None, config: Config, issue_number: int,
51
+ ) -> None:
52
+ """#1138: hard-gate a ``type="work"`` dispatch on the issue-level oracle
53
+ gate (:func:`coord.milestone_dispatch.issue_oracle_ready`) — refuses an
54
+ issue that belongs to an oracle-opted-in milestone (Gate A already
55
+ satisfied) but has no JIT-authored acceptance slice yet, or whose repo
56
+ declares a driver ``kind`` this install doesn't implement.
57
+
58
+ Raises :class:`DispatchRefused` on refusal — a :class:`ValueError`
59
+ subclass (#1844), so callers get "refuse cleanly" for free via their
60
+ existing ``except ValueError`` handling (``coord approve``, ``coord
61
+ assign``, ``coord milestone dispatch``) with zero CLI-layer changes, same
62
+ as before this was split out from a plain ``ValueError``. Distinct from
63
+ it specifically so `coord drive`'s subprocess boundary can tell THIS
64
+ refusal — deterministic, no acceptance slice will appear on retry —
65
+ apart from a transient one (missing ``repo_path``, the #437 TOS gate).
66
+
67
+ Cheap no-op — no network call — for every dispatch outside #1138's
68
+ scope: non-work proposal types (``plan``, ``review``, ``smoke``, ...),
69
+ an unknown repo, or a repo with no ``acceptance.drivers`` entry
70
+ configured (``has_driver`` is a local dict lookup). Shared by the
71
+ headless ``dispatch()`` POST below and the ``--interactive``
72
+ human-attended work launcher (``_dispatch_interactive_work``, which
73
+ never calls ``dispatch()``) so both flavours of Work dispatch are
74
+ covered, not just the unattended one.
75
+
76
+ Fails OPEN (proceeds, doesn't gate) if the issue itself can't be
77
+ fetched — mirroring the fail-soft posture the rest of the oracle-loop
78
+ machinery already uses (``oracle_loop_contract_block``, #945: "never let
79
+ a [...] read break dispatch"). By the time ``dispatch()`` runs, the
80
+ caller has already successfully fetched this same issue once (for its
81
+ title/briefing) moments earlier, so a failure here is a genuine
82
+ transient blip, not a sign the issue doesn't exist — treating it as a
83
+ hard stop would turn a GitHub hiccup into a fleet-wide outage for every
84
+ oracle-configured repo, which is a worse failure mode than the gap
85
+ #1138 closes.
86
+ """
87
+ if proposal_type != "work" or repo is None:
88
+ return
89
+ if not config.acceptance.has_driver(repo.name):
90
+ return
91
+
92
+ from coord import github_ops # noqa: PLC0415
93
+ from coord.milestone_dispatch import issue_oracle_ready # noqa: PLC0415
94
+
95
+ try:
96
+ issue_data = github_ops.get_issue(repo.github, issue_number)
97
+ except RuntimeError:
98
+ return
99
+
100
+ milestone_number = (issue_data.get("milestone") or {}).get("number")
101
+ issue_labels = [lbl.get("name", "") for lbl in (issue_data.get("labels") or [])]
102
+
103
+ readiness = issue_oracle_ready(
104
+ repo, config, milestone_number, issue_number, issue_labels,
105
+ )
106
+ if readiness.reason is not None:
107
+ raise DispatchRefused(readiness.reason)
108
+
109
+
110
+ def enforce_epic_dispatch_guard(
111
+ *, proposal_type: str, repo: Repo | None, config: Config, issue_number: int,
112
+ ) -> None:
113
+ """#1314: refuse a dispatch that would auto-close an epic/tracking issue
114
+ on merge (``proposal_type`` in :data:`coord.models.CLOSES_ISSUE_TYPES`,
115
+ e.g. ``"work"``) when *issue_number* itself carries the ``"epic"``
116
+ label (:data:`coord.milestone_order.TRACKING_ISSUE_LABEL`).
117
+
118
+ The #1077/#1142 ``CLOSES_ISSUE_TYPES`` split (see ``coord/models.py``)
119
+ already assumes only ``mock-author``/``test-author`` are ever dispatched
120
+ directly against a tracking issue's own number — a small correction to
121
+ an already-merged Gate-A contract, with no properly-typed tool for it
122
+ yet, falls back to a plain ``coord assign`` (``type="work"``) instead.
123
+ That silently breaks the same assumption a ``type="work"`` merge relies
124
+ on everywhere else: that ``issue_number`` is real, resolvable work, not
125
+ a milestone's tracking issue. Hit in practice against epic #1120's Gate
126
+ A contract (PR #1312) — this is the dispatch-time half of the fix;
127
+ ``coord/commands/plan_followup.py``'s ``pr()`` command independently
128
+ checks the same label so the PR body never carries the closing keyword
129
+ even for an already-dispatched assignment.
130
+
131
+ Override: label the issue ``oracle:exempt`` (the existing "I know what
132
+ I'm doing, let this bypass oracle-loop-specific gating" signal — see
133
+ :func:`enforce_oracle_readiness`) to dispatch anyway. Raises
134
+ :class:`DispatchRefused` on refusal (#1844) — same deterministic-refusal
135
+ reasoning as :func:`enforce_oracle_readiness`, and still a
136
+ :class:`ValueError` under the hood, so callers get "refuse cleanly" for
137
+ free via their existing ``except ValueError`` handling.
138
+
139
+ Fails OPEN (proceeds) if the issue can't be fetched or *repo* is
140
+ ``None`` — mirrors :func:`enforce_oracle_readiness`'s posture; a
141
+ transient GitHub read failure must not turn into a fleet-wide dispatch
142
+ outage.
143
+
144
+ Scoped to repos with an ``acceptance.drivers`` entry configured (same
145
+ cheap no-op test :func:`enforce_oracle_readiness` uses) — a local dict
146
+ lookup, no network call, for every dispatch outside this scope. #1314's
147
+ actual failure mode is inherent to the oracle loop's own convention of
148
+ dispatching ``mock-author``/``test-author`` against a tracking issue's
149
+ number in the first place; a repo with no acceptance driver has no such
150
+ convention, so this intentionally does not add a `gh` round-trip to
151
+ every "work" dispatch fleet-wide for a scenario that can't arise there.
152
+ A plain (non-oracle) repo whose operator manually dispatches "work"
153
+ against an epic's own number is a real but separate gap, same as the
154
+ one #1138's oracle-readiness gate already accepts for the same reason.
155
+ """
156
+ from coord.models import CLOSES_ISSUE_TYPES # noqa: PLC0415
157
+
158
+ if proposal_type not in CLOSES_ISSUE_TYPES or repo is None:
159
+ return
160
+ if not config.acceptance.has_driver(repo.name):
161
+ return
162
+
163
+ from coord.milestone_order import TRACKING_ISSUE_LABEL # noqa: PLC0415
164
+
165
+ try:
166
+ issue_data = github_ops.get_issue(repo.github, issue_number)
167
+ except RuntimeError:
168
+ return
169
+
170
+ issue_labels = {lbl.get("name", "") for lbl in (issue_data.get("labels") or [])}
171
+ if TRACKING_ISSUE_LABEL not in issue_labels or "oracle:exempt" in issue_labels:
172
+ return
173
+
174
+ raise DispatchRefused(
175
+ f"refusing type={proposal_type!r} dispatch against #{issue_number}: it "
176
+ f"carries the {TRACKING_ISSUE_LABEL!r} label (a milestone tracking/epic "
177
+ "issue) — merging this would close the epic while its real sub-issues "
178
+ "stay open/untouched (#1314). If this is a deliberate meta-level "
179
+ "dispatch against the tracking issue's own number (e.g. a Gate-A "
180
+ "contract correction), label the issue 'oracle:exempt' to override, "
181
+ "or use a properly-typed dispatch instead — e.g. `coord acceptance "
182
+ "mock <repo> <tracking_issue> --amend '<correction>'` for a "
183
+ "targeted fix to an already-merged Gate-A contract (#1315)."
184
+ )
185
+
186
+
187
+ def enforce_model_provider_compatibility(
188
+ *, wire_model: str | None, effective_provider_name: str, config: Config,
189
+ ) -> None:
190
+ """#1798: STRUCTURAL MODEL/PROVIDER GATE — refuse to dispatch a *wire_model*
191
+ that cannot plausibly belong to the resolved provider's backend type
192
+ (:func:`coord.config.model_plausible_for_provider_type`), e.g. a Claude
193
+ alias (``"sonnet"``) handed to an ``opencode``-type provider.
194
+
195
+ :func:`resolve_dispatch_model_alias`'s precedence fix (provider pin wins
196
+ over label routing) closes the common path into this failure, but
197
+ doesn't close every one — an operator can still pass an explicit
198
+ ``--model`` that mismatches the resolved provider, or configure a
199
+ non-claude provider with no pin at all (so a namespace-mismatched
200
+ *label_model* falls through unchecked). Before this gate, a mismatch
201
+ like that was only discovered when the backend itself rejected the
202
+ argument mid-run, minutes into a worker, after the assignment row
203
+ existed and the worktree was built — see the #1798 issue's #1708 proof
204
+ run, where an explicit ``--model opencode/glm-5.2`` override against a
205
+ silently-``claude``-falling-back agent made a masked bug visible only
206
+ because the mismatch was loud. Placed alongside the other structural
207
+ gates in :func:`dispatch` (oracle readiness, epic-target, TOS,
208
+ provider-availability), before any worktree/HTTP work happens.
209
+
210
+ A ``None`` *wire_model* (``--model`` omitted, provider's own default
211
+ applies) is always a no-op — there's nothing to validate.
212
+
213
+ Raises:
214
+ ValueError: When *wire_model* is set but implausible for the
215
+ resolved provider's type, naming both.
216
+ """
217
+ if wire_model is None:
218
+ return
219
+ from coord.providers import provider_type_for # noqa: PLC0415
220
+ from coord.config import model_plausible_for_provider_type # noqa: PLC0415
221
+
222
+ provider_type = provider_type_for(effective_provider_name, config.providers)
223
+ if model_plausible_for_provider_type(wire_model, provider_type):
224
+ return
225
+ raise ValueError(
226
+ f"refusing dispatch: model {wire_model!r} is not valid for provider "
227
+ f"{effective_provider_name!r} (type {provider_type!r}) — the two "
228
+ "belong to different model namespaces (#1798). Pass an explicit "
229
+ "--model in the resolved provider's own namespace, or leave "
230
+ "--model unset and pin providers.definitions"
231
+ f"[{effective_provider_name!r}].model in coordinator.yml instead."
232
+ )
233
+
234
+
235
+ def _reraise_with_body(
236
+ exc: httpx.HTTPStatusError, machine_name: str,
237
+ ) -> httpx.HTTPStatusError:
238
+ """#1527: fold the agent's rejection reason into the raised exception.
239
+
240
+ ``AgentServer.assign`` (coord/agent.py) raises a precise ``ValueError``
241
+ for every dispatch-time rejection — unhandled repo, missing
242
+ ``repo_path``, unknown ``pull_repos``, the #425/#324 provider
243
+ capability gates — and ``agent_app.py``'s ``assign`` route faithfully
244
+ returns each as ``{"error": "<reason>"}`` with a 400. Plain
245
+ ``resp.raise_for_status()`` discards that body: every existing caller
246
+ that catches ``httpx.HTTPError`` and renders ``str(e)`` (``coord/
247
+ commands/dispatch.py``, ``milestone_dispatch.py``, ``plan_followup.py``,
248
+ ``dispatch_workers.py``, ...) only ever saw the generic status line
249
+ ("400 Bad Request"), never the agent's own reason.
250
+
251
+ Returns a **new** ``httpx.HTTPStatusError`` — never raises one — so
252
+ ``dispatch()`` can ``raise ... from e`` at the call site. Carries the
253
+ same ``request``/``response`` as *exc* so ``classify_error``/
254
+ ``is_retryable`` (coord/network.py), which inspect
255
+ ``exc.response.status_code``, keep working unchanged; only the message
256
+ gains the detail.
257
+
258
+ Degrades gracefully rather than raising a *different* error out of an
259
+ error handler: a non-JSON or JSON-but-not-``{"error": ...}`` body falls
260
+ back to the raw response text (truncated); an empty body falls back to
261
+ the original status-line message.
262
+ """
263
+ detail = ""
264
+ try:
265
+ parsed = exc.response.json()
266
+ except ValueError:
267
+ parsed = None
268
+ if isinstance(parsed, dict):
269
+ detail = str(parsed.get("error") or "")
270
+ if not detail:
271
+ detail = exc.response.text[:500].strip()
272
+ message = (
273
+ f"{machine_name} rejected the assignment: {detail}" if detail else str(exc)
274
+ )
275
+ return httpx.HTTPStatusError(message, request=exc.request, response=exc.response)
276
+
277
+
278
+ def resolve_dispatch_model_alias(
279
+ *,
280
+ explicit_model: str | None,
281
+ label_model: str | None,
282
+ config: Config,
283
+ effective_provider_name: str,
284
+ ) -> str | None:
285
+ """Resolve the dispatch model *alias* (before ``models.resolve()``).
286
+
287
+ Precedence: *explicit_model* → the effective provider's own pinned
288
+ ``ProviderDef.model`` (for non-claude/claude-pty backends only) →
289
+ *label_model* → ``models.default``. The board/DB stores this alias for
290
+ legibility; the caller is responsible for the final
291
+ ``config.models.resolve()`` translation to an exact model id (typically
292
+ deferred to :func:`dispatch`/:func:`resolve_dispatch_model`, which run
293
+ at actual-wire-payload time — see the module docstring of each CLI
294
+ entry point that calls this function for why: re-resolving here would
295
+ bake an exact id into ``Proposal.model``/board bookkeeping instead of
296
+ the human-legible alias).
297
+
298
+ #1798: *label_model* comes from ``models.labels``, which maps to
299
+ Anthropic aliases (``tier:small -> haiku``) — a namespace that means
300
+ nothing to a non-claude/claude-pty backend. Letting it win over a
301
+ pinned non-claude provider's own ``ProviderDef.model`` (the OLD
302
+ precedence, `explicit_model or label_model` unconditionally) dispatched
303
+ e.g. ``opencode`` with ``--model sonnet``, a Claude alias the opencode
304
+ binary cannot serve — silently, with no validation at dispatch time.
305
+ Now the provider's own pin wins over label routing whenever it applies
306
+ (*provider_pins_model*); only an *explicit_model* — a human being
307
+ specific — still overrides the pin. Claude/claude-pty backends are
308
+ unaffected: *provider_pins_model* is always ``False`` for them (their
309
+ ``ProviderDef.model``, if any, is folded into ``models.default`` via a
310
+ different path — see :attr:`coord.config.ProviderDef.model`'s
311
+ docstring), so label routing there is unchanged.
312
+
313
+ #1430: label routing (*label_model*) is gated to ``type="work"`` by
314
+ every caller — plan workers are read-only/cheap and must not inherit a
315
+ ``tier:large`` -> opus routing meant for the eventual work dispatch.
316
+ Callers for which label routing doesn't apply (plan-only, review,
317
+ smoke, chat, etc.) pass ``label_model=None``.
318
+
319
+ #1706 review fix: ``config.models.default`` (e.g. ``"sonnet"``) is a
320
+ Claude model alias and means nothing to a non-Claude backend — passing
321
+ it through as ``--model sonnet`` to ``opencode run`` (or any future
322
+ non-Claude provider) is nonsensical. When *explicit_model* didn't supply
323
+ a model, and the effective provider's backend *type* is NOT
324
+ ``claude``/``claude-pty``, check whether that provider's own
325
+ ``ProviderDef.model`` (coordinator.yml ``providers.
326
+ definitions.<name>.model``) is pinned; if so, return ``None`` instead
327
+ of *label_model*/``models.default`` so the eventual wire payload omits
328
+ ``--model`` entirely and the provider instance's own ``build_command``
329
+ falls back to its ``self._model`` (threaded in from ``ProviderDef.model``
330
+ at construction — see ``coord/providers/opencode.py`` and
331
+ ``coord/providers/claude.py``). This makes ``providers.definitions.
332
+ <name>.model`` actually reachable for a normal ``coord assign``/``coord
333
+ approve``/``coord milestone dispatch`` with no explicit ``--model``,
334
+ instead of being permanently shadowed by ``config.models.default`` OR
335
+ (#1798) by a namespace-mismatched *label_model*.
336
+
337
+ Only an explicit override (``--model``) wins over the provider's pin —
338
+ matching the precedence already implemented in each provider's
339
+ ``build_command``: resolved_model > spec.model > definition.model.
340
+ Label routing does NOT win over the pin (#1798 fix): ``models.labels``
341
+ resolves to Claude aliases, which are meaningless to a pinned non-claude
342
+ provider, so the pin — the only value in the correct namespace — wins
343
+ instead.
344
+
345
+ This function is called from every site that used to inline the
346
+ ``explicit or label or config.models.default`` rule so the
347
+ provider-aware exception lives in exactly one place:
348
+ ``coord.dispatch.dispatch`` (the fallback, for callers that pass
349
+ ``proposal.model=None``), ``coord.commands.dispatch_workers.
350
+ _dispatch_headless`` (``coord assign``), the ``approve`` command's
351
+ per-proposal loop in ``coord.commands.dispatch``, and
352
+ ``coord.milestone_dispatch.dispatch_entry`` (``coord milestone
353
+ dispatch``).
354
+
355
+ Deliberately keyed off ``ProviderDef.type``, not the definition's name:
356
+ an operator can register a ``claude``/``claude-pty`` backend under an
357
+ arbitrary name (e.g. ``fast-claude`` in ``coordinator.example.yml``),
358
+ and that backend's ``--model`` must still flow through
359
+ ``config.models.resolve()``'s alias -> exact-id translation
360
+ (``models.versions``) — bypassing ``models.default`` for it here would
361
+ let an unresolved alias leak through instead (see ``ProviderDef.model``'s
362
+ docstring for the caveat).
363
+
364
+ Args:
365
+ explicit_model: The explicit per-dispatch override (``--model``
366
+ flag, or an already-resolved ``proposal.model`` from an earlier
367
+ stage), if any.
368
+ label_model: The issue-label-routed model (``type="work"`` only),
369
+ or ``None`` when label routing doesn't apply.
370
+ config: The coordinator config (``models`` and ``providers``).
371
+ effective_provider_name: The already-resolved effective provider
372
+ name (spec > repo > ``providers.default``), as returned by
373
+ :func:`coord.providers.resolve_provider_name` or
374
+ :func:`coord.providers.guard_unattended_dispatch`.
375
+
376
+ Returns:
377
+ The model alias (NOT yet passed through ``config.models.resolve()``),
378
+ or ``None`` to omit ``--model``.
379
+ """
380
+ provider_def = config.providers.definitions.get(effective_provider_name)
381
+ provider_pins_model = (
382
+ provider_def is not None
383
+ and provider_def.type not in ("claude", "claude-pty")
384
+ and provider_def.model is not None
385
+ )
386
+ if explicit_model:
387
+ return explicit_model
388
+ if provider_pins_model:
389
+ return None
390
+ if label_model:
391
+ return label_model
392
+ return config.models.default
393
+
394
+
395
+ def resolve_dispatch_model(
396
+ proposal: Proposal, config: Config, effective_provider_name: str,
397
+ ) -> str | None:
398
+ """Resolve the wire-payload ``model`` for a :func:`dispatch` call.
399
+
400
+ Thin wrapper around :func:`resolve_dispatch_model_alias`: computes
401
+ *label_model* from ``proposal.issue_labels`` (gated to ``type="work"``,
402
+ per #1430 — see that function's docstring), then translates the
403
+ resulting alias to an exact model id via ``config.models.resolve()``
404
+ (``models.versions``, when configured) for the actual wire payload.
405
+
406
+ #1430: most callers (``coord approve``, ``coord assign``, ``coord
407
+ milestone dispatch``) already pre-resolve ``proposal.model`` via
408
+ :func:`resolve_dispatch_model_alias` themselves before calling
409
+ :func:`dispatch` — for bookkeeping, so the dispatched record/board
410
+ reflect what actually ran. This function is the fallback for any
411
+ caller that doesn't: it repeats the same rule so :func:`dispatch` is
412
+ correct on its own, not just when every caller remembers to do the
413
+ work upfront.
414
+
415
+ Args:
416
+ proposal: The dispatch proposal (``proposal.model`` is the explicit
417
+ per-dispatch override, if any — already resolved by an earlier
418
+ stage for most callers).
419
+ config: The coordinator config (``models`` and ``providers``).
420
+ effective_provider_name: The already-resolved effective provider
421
+ name (spec > repo > ``providers.default``), as returned by
422
+ :func:`coord.providers.guard_unattended_dispatch`.
423
+
424
+ Returns:
425
+ The wire-ready model id/alias (already passed through
426
+ ``config.models.resolve()``), or ``None`` to omit ``--model``.
427
+ """
428
+ label_model = (
429
+ config.models.model_for_labels(proposal.issue_labels)
430
+ if proposal.type == "work"
431
+ else None
432
+ )
433
+ alias = resolve_dispatch_model_alias(
434
+ explicit_model=proposal.model,
435
+ label_model=label_model,
436
+ config=config,
437
+ effective_provider_name=effective_provider_name,
438
+ )
439
+ return config.models.resolve(alias)
440
+
441
+
442
+ def _wire_payload_needs_provider_field(
443
+ effective_provider_name: str, config: Config,
444
+ ) -> bool:
445
+ """Whether :func:`dispatch`'s wire payload must include ``"provider"``
446
+ (#1711 review of #324's payload-omission gap).
447
+
448
+ The historical rule (send ``provider`` only when the effective name is
449
+ not ``"claude"``) was deliberate old-agent compatibility, but it hid a
450
+ reachable divergence: the ``"claude"`` entry in
451
+ ``providers.definitions`` can be **customized** (a redefined
452
+ ``binary``, ``env``, or ``extra_args`` — legal per ``ProviderDef``'s
453
+ docstring, e.g. to point ``claude`` at a wrapped binary) without ever
454
+ changing its NAME. Omitting the field for name ``"claude"``
455
+ unconditionally sends the agent down its hardcoded legacy spawn path
456
+ (``coord.agent.default_worker_command``, always the bare ``"claude"``
457
+ binary with no env/extra_args) instead of through the provider seam
458
+ that would apply those customizations — so the coordinator's recorded
459
+ ``provider_name="claude"`` would silently stop matching what actually
460
+ ran on the agent, corrupting exactly the kind of comparison this
461
+ provider-plumbing epic (#1709) exists to make trustworthy.
462
+
463
+ Fix: still omit the field for the vanilla (uncustomized) ``"claude"``
464
+ definition — preserving byte-identical payloads for every no-
465
+ ``providers:``-block deployment and genuine old-agent compatibility —
466
+ but include it the moment that definition carries ANY customization,
467
+ so the agent is told to route through the provider seam and actually
468
+ apply it. A non-``"claude"`` effective name always needs the field
469
+ regardless (unchanged from #324).
470
+ """
471
+ if effective_provider_name != "claude":
472
+ return True
473
+ definition = config.providers.definitions.get("claude")
474
+ if definition is None:
475
+ return False
476
+ return bool(
477
+ definition.binary or definition.model or definition.env or definition.extra_args
478
+ )
479
+
480
+
481
+ def dispatch(
482
+ proposal: Proposal,
483
+ config: Config,
484
+ *,
485
+ pull_repos: Iterable[str] = (),
486
+ fresh_branch: bool = False,
487
+ ) -> dict:
488
+ """POST an assignment to the agent server on the target machine.
489
+
490
+ Returns the response JSON from the agent server (which includes the
491
+ server-assigned `id`).
492
+ """
493
+ machine = next(
494
+ (m for m in config.machines if m.name == proposal.machine_name), None
495
+ )
496
+ if machine is None:
497
+ raise ValueError(f"Unknown machine: {proposal.machine_name!r}")
498
+
499
+ repo_path = machine.repo_path(proposal.repo_name)
500
+ if repo_path is None:
501
+ raise ValueError(
502
+ f"No repo_path configured for {proposal.repo_name!r} on machine {machine.name!r}. "
503
+ f"Add it to coordinator.yml under machines[].repo_paths."
504
+ )
505
+
506
+ # Resolve deny-list from the repo's worker_permissions config.
507
+ repo = config.repo(proposal.repo_name)
508
+
509
+ # #1138: STRUCTURAL ORACLE-LOOP GATE — refuse a `type="work"` dispatch
510
+ # for an issue inside an oracle-opted-in milestone (Gate A satisfied)
511
+ # that has no JIT-authored acceptance slice yet, or whose repo declares
512
+ # a driver kind this install doesn't implement. Placed early / before
513
+ # the TOS gate below so a refusal never depends on provider resolution
514
+ # succeeding first.
515
+ enforce_oracle_readiness(
516
+ proposal_type=proposal.type, repo=repo, config=config,
517
+ issue_number=proposal.issue_number,
518
+ )
519
+
520
+ # #1314: STRUCTURAL EPIC-TARGET GATE — refuse a dispatch that would
521
+ # auto-close a tracking/epic issue on merge (see
522
+ # `enforce_epic_dispatch_guard`'s docstring). Placed alongside the
523
+ # oracle-readiness gate above, before the TOS gate, for the same reason.
524
+ enforce_epic_dispatch_guard(
525
+ proposal_type=proposal.type, repo=repo, config=config,
526
+ issue_number=proposal.issue_number,
527
+ )
528
+
529
+ # #437: STRUCTURAL TOS-COMPLIANCE GATE — refuse to route an
530
+ # unattended dispatch through a provider whose capabilities mark it
531
+ # ``human_attended_only`` (subscription-billed interactive Claude
532
+ # Code). Precedence: per-proposal override (if the brain ever sets
533
+ # one) → ``providers.labels`` match (#1889) → per-repo
534
+ # ``Repo.provider`` → ``config.providers.default``. Deferred import so
535
+ # the unattended dispatch surface stays free of a module-level cycle
536
+ # with the provider registry.
537
+ from coord.providers import guard_unattended_dispatch # noqa: PLC0415
538
+ spec_provider = getattr(proposal, "provider", None)
539
+ # #1889: providers.labels routes work dispatches by the issue's
540
+ # harness-eval label (e.g. `harness:opencode`); plan/review/smoke
541
+ # proposals deliberately stay off it, same as `models.labels` (#1430) —
542
+ # a label meant for the eventual work dispatch must not leak into a
543
+ # cheap/read-only stage.
544
+ provider_issue_labels = proposal.issue_labels if proposal.type == "work" else None
545
+ # #324: resolve the effective provider name (spec > label > repo >
546
+ # default) so the coordinator DB always records the winning provider
547
+ # regardless of which level supplied it, and the wire payload carries
548
+ # the exact name the agent should look up in its registry.
549
+ effective_provider_name: str = guard_unattended_dispatch(
550
+ spec_provider=spec_provider,
551
+ repo_provider=repo.provider if repo is not None else None,
552
+ providers_cfg=config.providers,
553
+ models_cfg=config.models,
554
+ where="coord approve / dispatch",
555
+ issue_labels=provider_issue_labels,
556
+ )
557
+
558
+ # #1711: STRUCTURAL PROVIDER-AVAILABILITY GATE — refuse to route a
559
+ # dispatch to a machine that hasn't declared it can run the resolved
560
+ # provider (e.g. an `opencode` assignment landing on a machine with no
561
+ # `provider:opencode` capability). Without this, the failure only
562
+ # surfaced at spawn time inside the agent process as an ENOENT-shaped
563
+ # subprocess error, after the assignment row existed and the worktree
564
+ # was built. Placed right after the TOS gate above, for the same
565
+ # reason: both are structural refusals keyed off the same resolved
566
+ # provider name, before any repo/worktree/HTTP work happens.
567
+ from coord.providers import guard_provider_machine_capability # noqa: PLC0415
568
+
569
+ guard_provider_machine_capability(
570
+ provider_name=effective_provider_name,
571
+ machine=machine,
572
+ all_machines=config.machines,
573
+ providers_cfg=config.providers,
574
+ where="coord approve / dispatch",
575
+ )
576
+ deny_commands: list[str] = []
577
+ if repo is not None and repo.worker_permissions is not None:
578
+ deny_commands = repo.worker_permissions.deny
579
+
580
+ # Resolve coordinator-only files (workers must not read or modify these).
581
+ files_forbidden: list[str] = []
582
+ if repo is not None and repo.coordinator_only_files:
583
+ files_forbidden = list(repo.coordinator_only_files)
584
+
585
+ # #944 sealing v1 (docs/ORACLE_LOOP.md): the acceptance oracle is
586
+ # read-only/run-only for the worker — it's authored by an independent
587
+ # test-author, not the worker under test. Auto-forbid it for any repo
588
+ # with an acceptance driver configured, so sealing doesn't depend on an
589
+ # operator remembering to also list it under coordinator_only_files.
590
+ # #930: exempt `mock-author` — the one type whose entire job IS writing
591
+ # under tests/acceptance/ms-NN/ (Gate A). A future `test-author` (#931)
592
+ # gets the same exemption when it lands.
593
+ # #1552: forbid the driver's declared `entrypoint:` alongside the tree.
594
+ # It is the oracle's crate root — a `type="work"` worker editing
595
+ # `tui/tests/acceptance.rs` can unwire (or re-point) the very slice it is
596
+ # being graded against without ever touching `tests/acceptance/**`.
597
+ if (
598
+ proposal.type != "mock-author"
599
+ and config.acceptance.has_driver(proposal.repo_name)
600
+ ):
601
+ for sealed in config.acceptance.sealed_paths(proposal.repo_name):
602
+ if sealed not in files_forbidden:
603
+ files_forbidden.append(sealed)
604
+
605
+ # Resolve model: proposal override → provider-definition pin
606
+ # (non-claude/claude-pty only) → models.labels (type="work" only) →
607
+ # config default. See resolve_dispatch_model()'s docstring for the full
608
+ # precedence rationale (#1430, #1706, #1798).
609
+ wire_model = resolve_dispatch_model(proposal, config, effective_provider_name)
610
+
611
+ # #1798: STRUCTURAL MODEL/PROVIDER GATE — refuse a resolved model that
612
+ # cannot plausibly belong to the resolved provider's backend type (e.g.
613
+ # a Claude alias handed to an opencode-type provider) before any
614
+ # worktree/HTTP work happens. See enforce_model_provider_compatibility's
615
+ # docstring for why this is still needed even after the precedence fix
616
+ # above (an explicit --model, or an unpinned non-claude provider, can
617
+ # still produce a mismatch).
618
+ enforce_model_provider_compatibility(
619
+ wire_model=wire_model, effective_provider_name=effective_provider_name,
620
+ config=config,
621
+ )
622
+
623
+ # #255: pin the worker's branch base to the repo's configured default
624
+ # branch. Without this the agent fell back to a hardcoded "main", which
625
+ # silently routed around `default_branch: develop` repos like quadraui
626
+ # and let local-only commits on the default branch slip into worker
627
+ # branches.
628
+ #
629
+ # #934: when the target issue belongs to a milestone (`proposal.
630
+ # milestone_number`, set by callers like `coord.milestone_dispatch.
631
+ # dispatch_entry` that already fetched the issue) and the repo has
632
+ # opted into the develop + feature-branch-per-milestone git model
633
+ # (`repo.develop_branch` set), branch off `feature/ms-NN` instead —
634
+ # `coord.branch_model.resolve_base_branch` falls back to today's flat
635
+ # `default_branch` behavior for every other repo/proposal.
636
+ from coord.branch_model import resolve_base_branch # noqa: PLC0415
637
+
638
+ if repo is not None:
639
+ default_branch = resolve_base_branch(repo, proposal.milestone_number)
640
+ else:
641
+ default_branch = "main"
642
+
643
+ # #305: artifact_paths are only relevant for work assignments. Skip for
644
+ # review, smoke, refinement, and other non-work types.
645
+ artifact_paths: list[str] = []
646
+ if proposal.type == "work" and repo is not None:
647
+ artifact_paths = list(repo.artifact_paths)
648
+
649
+ # #352: resolve new-issue guidance for new-issue-chat assignments.
650
+ # Only resolve when the repo *explicitly configured* new_issue_guidance —
651
+ # the resolver always returns a non-empty _DEFAULT, so checking
652
+ # `if new_issue_guidance:` below would always send the field, causing
653
+ # agents that predate #352 to reject the payload with a 400. Gating on
654
+ # the raw config field lets repos without guidance dispatch to any agent
655
+ # (the agent's built-in NEW_ISSUE_CHAT_SYSTEM_PROMPT is fine without it).
656
+ new_issue_guidance: str = ""
657
+ if proposal.type == "new-issue-chat" and repo is not None and repo.new_issue_guidance:
658
+ from pathlib import Path
659
+ new_issue_guidance = repo.resolve_new_issue_guidance(Path(repo_path).expanduser())
660
+
661
+ # #603: prepend the per-issue context digest to the TOP of a -p WORK
662
+ # briefing (cross-repo deps / prior-attempt findings) so the worker reads
663
+ # them first. Only `work` (chat/refinement/conflict-fix carry no issue
664
+ # context); the interactive and auto-loop fix/review paths inject at their
665
+ # own sites, so this is the single -p work chokepoint (no double injection).
666
+ #
667
+ # #945 (docs/ORACLE_LOOP.md "The worker briefing contract"): right after
668
+ # the #603 digest, prepend the oracle-loop contract when this repo has an
669
+ # acceptance driver configured (the oracle-loop proxy — #944 never landed
670
+ # a milestone-level flag, so "driver configured for this repo" is the
671
+ # signal, mirroring the tests/acceptance/ auto-seal above) AND this issue
672
+ # already has an authored slice (oracle_loop_contract_block returns ""
673
+ # otherwise, e.g. before Gate A/#931 has run for it).
674
+ briefing_text = proposal.briefing
675
+ if proposal.type == "work" and proposal.issue_number:
676
+ from pathlib import Path # noqa: PLC0415
677
+
678
+ from coord.state import issue_context_block # noqa: PLC0415
679
+
680
+ oracle_contract = ""
681
+ if config.acceptance.has_driver(proposal.repo_name):
682
+ from coord.acceptance import ( # noqa: PLC0415
683
+ ACCEPTANCE_DIRNAME,
684
+ oracle_loop_contract_block,
685
+ )
686
+
687
+ oracle_contract = oracle_loop_contract_block(
688
+ Path(repo_path).expanduser() / ACCEPTANCE_DIRNAME,
689
+ proposal.repo_name,
690
+ proposal.issue_number,
691
+ )
692
+
693
+ # #1720: dispatch-time file-overlap fence — the union of file
694
+ # footprints of every OTHER currently-running work-like assignment
695
+ # in this repo, derived from live branch diffs (not the brain.py
696
+ # prompt-only heuristic, which only covers `coord plan` and guesses
697
+ # from issue body text). Advisory only; never blocks this dispatch —
698
+ # see coord.overlap_fence.compute_overlap_fence's docstring for the
699
+ # fail-open contract. "" when there's nothing running (or nothing
700
+ # with a pushed branch), same no-op-prefix shape as issue_context_block.
701
+ overlap_fence = ""
702
+ if repo is not None and repo.github:
703
+ from coord.overlap_fence import compute_overlap_fence # noqa: PLC0415
704
+
705
+ overlap_fence = compute_overlap_fence(
706
+ proposal.repo_name,
707
+ repo.github,
708
+ default_branch,
709
+ exclude_issue_number=proposal.issue_number,
710
+ )
711
+
712
+ briefing_text = (
713
+ issue_context_block(proposal.repo_name, proposal.issue_number)
714
+ + overlap_fence
715
+ + oracle_contract
716
+ + briefing_text
717
+ )
718
+
719
+ url = f"http://{machine.host}:{AGENT_PORT}/assign"
720
+ payload: dict = {
721
+ "repo_name": proposal.repo_name,
722
+ "repo_path": repo_path,
723
+ "issue_number": proposal.issue_number,
724
+ "issue_title": proposal.issue_title,
725
+ "briefing": briefing_text,
726
+ "files_allowed": proposal.files_likely,
727
+ "files_forbidden": files_forbidden,
728
+ "pull_repos": list(pull_repos),
729
+ "deny_commands": deny_commands,
730
+ "model": wire_model,
731
+ "type": proposal.type,
732
+ "branch": default_branch,
733
+ }
734
+ # #351: only send artifact_paths when non-empty — older agents reject
735
+ # unknown payload keys with a 400. When absent the agent falls back to
736
+ # self.artifact_paths (startup config).
737
+ if artifact_paths:
738
+ payload["artifact_paths"] = artifact_paths
739
+ # #352: only send new_issue_guidance when non-empty — older agents don't
740
+ # have this field and will reject the payload with a 400.
741
+ if new_issue_guidance:
742
+ payload["new_issue_guidance"] = new_issue_guidance
743
+ # Only send fresh_branch when True — older agents don't have this field
744
+ # and will reject the payload with a 400.
745
+ if fresh_branch:
746
+ payload["fresh_branch"] = True
747
+ # Only send target_branch when set — agents predating #target_branch
748
+ # (and the AssignmentSpec(**body) kwargs check) reject unknown fields.
749
+ if proposal.target_branch:
750
+ payload["target_branch"] = proposal.target_branch
751
+ # #315: only send resume_session_id when set — older agents without the
752
+ # field reject unknown payload keys with a 400.
753
+ if getattr(proposal, "resume_session_id", None):
754
+ payload["resume_session_id"] = proposal.resume_session_id
755
+ # #324/#1711: send the resolved provider name unless it's the vanilla
756
+ # (uncustomized) implicit "claude" default — see
757
+ # _wire_payload_needs_provider_field's docstring for why a customized
758
+ # "claude" definition must NOT be silently omitted (#1711 review of the
759
+ # #324 payload-omission gap). Older agents that predate #425's
760
+ # spec.provider field would reject an unknown payload key; omitting the
761
+ # field for the untouched default keeps every no-providers.-block
762
+ # deployment's wire payload byte-identical to pre-#324 (no-config
763
+ # parity requirement).
764
+ if effective_provider_name and _wire_payload_needs_provider_field(
765
+ effective_provider_name, config,
766
+ ):
767
+ payload["provider"] = effective_provider_name
768
+
769
+ resp = httpx.post(url, json=payload, timeout=15)
770
+ if (
771
+ resp.status_code == 400
772
+ and "provider" in payload
773
+ and "provider_def" not in payload
774
+ and config.providers.definitions.get(effective_provider_name) is not None
775
+ ):
776
+ # #1796 fix iteration 1 (review finding, blocking): the first
777
+ # attempt above deliberately never carries "provider_def" — the
778
+ # original #1796 patch attached it unconditionally to every
779
+ # named-provider dispatch whenever the coordinator's own config had
780
+ # a matching definition, which is a strictly wider condition than
781
+ # "the agent actually needs it". "provider_def" is a field ONLY an
782
+ # agent already updated PAST this release understands —
783
+ # AssignmentSpec(**body) 400s on any unrecognized kwarg
784
+ # (coord/agent_app.py) — so an agent that already supports
785
+ # "provider" (#324) but hasn't yet received #1796's release would
786
+ # get a brand-new hard 400 on every named-provider dispatch,
787
+ # including ones that were already working correctly against its
788
+ # own local providers.definitions (coordinator.yml). That's a
789
+ # regression of an already-working path, not just a failure to fix
790
+ # the config-free case #1796 targets — see docs/EPHEMERAL_WORKERS.md
791
+ # and coord.agent.AgentServer._resolve_provider's docstring for what
792
+ # "provider_def" is actually for.
793
+ #
794
+ # So "provider_def" is attached ONLY as a one-shot retry, fired
795
+ # only once the agent has ALREADY refused the bare "provider"
796
+ # payload with a 400. That 400 can only come from one of two agent
797
+ # generations: (a) one old enough to predate #1796's refusal logic
798
+ # never 400s here at all — it either resolves the name from its own
799
+ # local registry (unchanged, no regression) or silently falls back
800
+ # to the legacy claude path (the pre-#1796 bug, unaffected either
801
+ # way by this change and unaffected by whether provider_def would
802
+ # have helped); or (b) one new enough to run #1796's
803
+ # `_resolve_provider` (coord/agent.py) — which means it is ALSO new
804
+ # enough to accept "provider_def" — could not resolve the name from
805
+ # its own local registry (a genuinely config-free agent, or a local
806
+ # registry that's missing/stale relative to the coordinator's own)
807
+ # and explicitly refused rather than guess. Retrying is safe to do
808
+ # unconditionally on any 400 here because every ValueError
809
+ # `AgentServer.assign` can raise is checked before it ever creates
810
+ # an AgentAssignment or touches git/worktree state (coord/agent.py)
811
+ # — so a retry can never double-spawn a worker or leak a worktree.
812
+ # An unrelated 400 (e.g. a bad repo_path) just fails identically on
813
+ # the retry too, and that message is what the caller ultimately
814
+ # sees via _reraise_with_body below.
815
+ from coord.providers import provider_def_to_wire # noqa: PLC0415
816
+
817
+ definition = config.providers.definitions[effective_provider_name]
818
+ retry_payload = dict(payload, provider_def=provider_def_to_wire(definition))
819
+ resp = httpx.post(url, json=retry_payload, timeout=15)
820
+
821
+ try:
822
+ resp.raise_for_status()
823
+ except httpx.HTTPStatusError as e:
824
+ raise _reraise_with_body(e, machine.name) from e
825
+ result = resp.json()
826
+ # #324: attach the resolved provider name to the response dict so callers
827
+ # that record the dispatched assignment (cli.py, dashboard/server.py) can
828
+ # persist it without re-resolving the config precedence chain.
829
+ result["_provider_name"] = effective_provider_name
830
+ return result
831
+
832
+
833
+ def dispatch_with_retry(
834
+ proposal: Proposal,
835
+ config: Config,
836
+ *,
837
+ max_retries: int = 3,
838
+ backoff_base: float = 60.0,
839
+ pull_repos: Iterable[str] = (),
840
+ fresh_branch: bool = False,
841
+ on_retry: callable | None = None,
842
+ ) -> dict:
843
+ """Dispatch with exponential backoff on transient failures."""
844
+ from coord.network import classify_error, is_retryable
845
+
846
+ last_exc: Exception | None = None
847
+ for attempt in range(max_retries + 1):
848
+ try:
849
+ return dispatch(proposal, config, pull_repos=pull_repos, fresh_branch=fresh_branch)
850
+ except httpx.HTTPError as exc:
851
+ state, reason = classify_error(exc)
852
+ if not is_retryable(state) or attempt == max_retries:
853
+ raise
854
+ wait = backoff_base * (2 ** attempt)
855
+ if on_retry:
856
+ on_retry(attempt + 1, max_retries, state, reason, wait)
857
+ time.sleep(wait)
858
+ last_exc = exc
859
+ except ValueError:
860
+ raise
861
+ raise last_exc # unreachable, but satisfies type checker
862
+
863
+
864
+ def compute_do_not_touch(
865
+ proposal: Proposal,
866
+ peers: Iterable[Proposal],
867
+ in_flight: Iterable[dict] = (),
868
+ ) -> list[tuple[str, str]]:
869
+ """Compute (file, reason) pairs for other work touching `proposal.repo_name`.
870
+
871
+ `peers` are other proposals being dispatched in the same batch.
872
+ `in_flight` are records loaded from ~/.coord/dispatched.json (each with
873
+ keys: machine_name, repo_name, files_likely).
874
+ """
875
+ pairs: list[tuple[str, str]] = []
876
+ seen: set[tuple[str, str]] = set()
877
+
878
+ def _add(machine_name: str, files: Iterable[str]) -> None:
879
+ for f in files:
880
+ key = (machine_name, f)
881
+ if key in seen:
882
+ continue
883
+ seen.add(key)
884
+ pairs.append((f, f"{machine_name} is working there"))
885
+
886
+ for peer in peers:
887
+ if peer is proposal:
888
+ continue
889
+ if peer.repo_name != proposal.repo_name:
890
+ continue
891
+ _add(peer.machine_name, peer.files_likely)
892
+
893
+ for record in in_flight:
894
+ if record.get("repo_name") != proposal.repo_name:
895
+ continue
896
+ if record.get("machine_name") == proposal.machine_name:
897
+ continue
898
+ _add(record.get("machine_name", "?"), record.get("files_likely", []))
899
+
900
+ return pairs
901
+
902
+
903
+ def post_briefing(
904
+ proposal: Proposal,
905
+ config: Config,
906
+ *,
907
+ assignment_id: str = "pending",
908
+ do_not_touch: Iterable[tuple[str, str]] = (),
909
+ ) -> None:
910
+ """Post the assignment briefing as a GitHub issue comment."""
911
+ repo = config.repo(proposal.repo_name)
912
+ if repo is None:
913
+ raise ValueError(f"Unknown repo: {proposal.repo_name!r}")
914
+
915
+ body = format_briefing(
916
+ assignment_id=assignment_id,
917
+ machine_name=proposal.machine_name,
918
+ repo_name=proposal.repo_name,
919
+ issue_number=proposal.issue_number,
920
+ briefing=proposal.briefing,
921
+ files_likely=proposal.files_likely,
922
+ do_not_touch=do_not_touch,
923
+ )
924
+ github_ops.post_issue_comment(repo.github, proposal.issue_number, body)
925
+
926
+ # Auto-tag the issue with pipeline_tracked_labels so the TUI's Pipeline
927
+ # panel picks it up on the next `gh search issues` poll. Without this,
928
+ # manually filed issues stay invisible until the user remembers to
929
+ # label them (we hit this filing quadraui#263). Best-effort — never
930
+ # fail the briefing post on a labeling error.
931
+ tracked = config.pipeline.tracked_labels()
932
+ if tracked:
933
+ try:
934
+ github_ops.add_issue_labels(repo.github, proposal.issue_number, tracked)
935
+ except (RuntimeError, OSError):
936
+ pass
937
+
938
+
939
+ def post_completion(
940
+ *,
941
+ assignment_id: str,
942
+ machine_name: str,
943
+ repo_github: str,
944
+ repo_name: str,
945
+ issue_number: int,
946
+ exit_code: int,
947
+ duration_seconds: float | None = None,
948
+ log_path: str | None = None,
949
+ summary: str = "",
950
+ ) -> None:
951
+ body = format_completion(
952
+ assignment_id=assignment_id,
953
+ machine_name=machine_name,
954
+ repo_name=repo_name,
955
+ issue_number=issue_number,
956
+ exit_code=exit_code,
957
+ duration_seconds=duration_seconds,
958
+ log_path=log_path,
959
+ summary=summary,
960
+ )
961
+ github_ops.post_issue_comment(repo_github, issue_number, body)
962
+
963
+
964
+ def post_failure(
965
+ *,
966
+ assignment_id: str,
967
+ machine_name: str,
968
+ repo_github: str,
969
+ repo_name: str,
970
+ issue_number: int,
971
+ exit_code: int | None,
972
+ duration_seconds: float | None = None,
973
+ log_path: str | None = None,
974
+ error: str = "",
975
+ ) -> None:
976
+ body = format_failure(
977
+ assignment_id=assignment_id,
978
+ machine_name=machine_name,
979
+ repo_name=repo_name,
980
+ issue_number=issue_number,
981
+ exit_code=exit_code,
982
+ duration_seconds=duration_seconds,
983
+ log_path=log_path,
984
+ error=error,
985
+ )
986
+ github_ops.post_issue_comment(repo_github, issue_number, body)
987
+
988
+
989
+ def post_advisory(
990
+ *,
991
+ assignment_id: str,
992
+ machine_name: str,
993
+ repo_github: str,
994
+ repo_name: str,
995
+ issue_number: int,
996
+ duration_seconds: float | None = None,
997
+ log_path: str | None = None,
998
+ reason: str = "",
999
+ ) -> None:
1000
+ body = format_advisory(
1001
+ assignment_id=assignment_id,
1002
+ machine_name=machine_name,
1003
+ repo_name=repo_name,
1004
+ issue_number=issue_number,
1005
+ duration_seconds=duration_seconds,
1006
+ log_path=log_path,
1007
+ reason=reason,
1008
+ )
1009
+ github_ops.post_issue_comment(repo_github, issue_number, body)