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/drive_state.py ADDED
@@ -0,0 +1,870 @@
1
+ """Read-only per-issue pipeline state oracle for ``coord drive`` (#1392).
2
+
3
+ Answers the one question the coord CLI has no single command for: *"what stage
4
+ is issue N in, and what is blocking it?"*
5
+
6
+ This is the in-process port of ``scripts/coord_issue_state.py``, which was a
7
+ standalone script whose output the bash driver ``eval``-ed as ``KEY='value'``
8
+ lines. The shell-quoting handshake is gone (that ``eval`` was one of the
9
+ bugs — a diagnostic on stdout would have executed as shell); the driver now
10
+ imports :func:`project` and branches on a typed :class:`IssueState`.
11
+
12
+ Why this is a projection over ``GET /board`` rather than an existing command:
13
+
14
+ - ``coord wait`` reads the **local** dispatched ledger (``load_dispatched()``),
15
+ which is empty on a thin client — so it cannot be used from an operator box
16
+ that reads the board from the daemon. This polls the daemon instead.
17
+ - ``coord diagnose --json`` is per-*stage* and **mutates** (it performs
18
+ best-effort recovery). A driver loop needs a pure read.
19
+ - ``GET /board`` is ~4.4 MB, but it supports ETags. We cache the payload and
20
+ send ``If-None-Match``, so a steady-state poll is a 304 in ~30 ms instead of
21
+ a multi-megabyte transfer. This keeps a 60-second poll loop from hammering
22
+ the daemon (the failure mode behind the #1244 / board-timeout incidents).
23
+
24
+ Everything here is a pure function over a board payload except
25
+ :func:`fetch_board`, which is the one I/O boundary.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import hashlib
31
+ import json
32
+ import os
33
+ from dataclasses import asdict, dataclass, field
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ from coord.merge_queue import is_ci_infra_reason
38
+ from coord.models import WORK_LIKE_TYPES, test_mode_from_labels
39
+
40
+ # Assignment types that can carry the Test/Review gates for an issue. Sourced
41
+ # from coord.models so this never drifts from the source of truth (#1141 was
42
+ # exactly a hardcoded copy of this set going stale).
43
+ WORK_LIKE: frozenset[str] = WORK_LIKE_TYPES
44
+
45
+ TERMINAL_STATUSES = frozenset({"done", "failed", "cancelled", "merged", "advisory"})
46
+
47
+
48
+ class DriveStateError(Exception):
49
+ """The board or config could not be read well enough to drive anything."""
50
+
51
+
52
+ # ── the projection ───────────────────────────────────────────────────────────
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class IssueState:
57
+ """Everything ``coord drive``'s state machine branches on, and nothing else.
58
+
59
+ Field names mirror the ``KEY='value'`` variables the bash driver used, so
60
+ the ``--dry-run`` JSON stays recognisable to anyone who ran the script
61
+ (see :meth:`as_flat_dict`).
62
+ """
63
+
64
+ repo: str
65
+ issue: int
66
+ repo_github: str = ""
67
+ repo_default_branch: str = "main"
68
+ repo_test_command: str = ""
69
+ max_review_iterations: int = 5
70
+ auto_loop: bool = True
71
+
72
+ plan_aid: str = ""
73
+ plan_status: str = ""
74
+
75
+ work_aid: str = ""
76
+ work_type: str = ""
77
+ work_status: str = ""
78
+ work_branch: str = ""
79
+ work_machine: str = ""
80
+ work_provider: str = ""
81
+ work_test_state: str = ""
82
+ work_test_reason: str = ""
83
+ work_review_state: str = ""
84
+ work_review_iter: int = 0
85
+ work_exit_code: int | None = None
86
+ work_failure_reason: str = ""
87
+
88
+ review_aid: str = ""
89
+ review_status: str = ""
90
+ review_verdict: str = ""
91
+ # #1584: mirrors `work_failure_reason` — surfaces a review worker's
92
+ # persisted `failure_reason` (usage-limit-kill or terminal-API-error
93
+ # diagnostic; see `coord.reconcile._record_usage_limit_reason`) so
94
+ # `_decide_review` can report *why* a failed review died instead of a
95
+ # bare "failed".
96
+ review_failure_reason: str = ""
97
+
98
+ smoke_aid: str = ""
99
+ smoke_status: str = ""
100
+ # #1605: mirrors `work_failure_reason`/`review_failure_reason` — the Test
101
+ # stage's own child (`type="smoke"`) assignment's persisted
102
+ # `failure_reason`, so `_decide_test` can recognise an environmental
103
+ # death (#1590) or report *why* a stranded Test stage died instead of
104
+ # polling `test_state == "running"` forever against a child that has
105
+ # already finished.
106
+ smoke_failure_reason: str = ""
107
+
108
+ active_count: int = 0
109
+ active_types: tuple[str, ...] = ()
110
+
111
+ merge_status: str = ""
112
+ merge_reason: str = ""
113
+ merge_pr_url: str = ""
114
+ merge_aid: str = ""
115
+
116
+ picked_machine: str = ""
117
+ # #1906: the provider `picked_machine` was actually filtered against
118
+ # (`""` when no candidate machine hosted `repo` at all — provider
119
+ # resolution never ran). `picked_machine_provider_reason` is
120
+ # `coord.providers.describe_provider_choice`'s provenance string, so
121
+ # `--dry-run` shows not just the winning provider but *why* (spec →
122
+ # `providers.labels` → repo → `providers.default`) — the same
123
+ # transparency `coord assign --dry-run` already gives a hand dispatch.
124
+ picked_machine_provider: str = ""
125
+ picked_machine_provider_reason: str = ""
126
+ # True when at least one unpaused machine hosts `repo` (so this is NOT
127
+ # the plain "no unpaused machine hosts {repo}" case) but NONE of them
128
+ # advertise `picked_machine_provider` — the distinct #1906 failure mode
129
+ # `preflight()` reports separately, per #1711's own refusal shape.
130
+ picked_machine_no_capable: bool = False
131
+
132
+ # ── #1453: oracle-loop JIT slice authoring ──────────────────────────
133
+ # `milestone_number` is the issue's own GitHub milestone (the `ms-NN`
134
+ # Gate-A contract this issue's slice would live under); resolved from
135
+ # the same `/board` `issues` list the TUI's `pipeline_issue_milestone`
136
+ # reads. `milestone_tracking_issue` is the epic that owns the `##
137
+ # Work order` block this issue is a member node of — resolved from
138
+ # `milestone_work_orders`, mirroring the TUI's
139
+ # `milestone_tracking_issue_for` (tui/src/app/pipeline.rs). Both are
140
+ # ``None`` for a plain issue with no milestone, or one not (yet) a
141
+ # member of any tracked work order — the "normal drive" case.
142
+ milestone_number: int | None = None
143
+ milestone_tracking_issue: int | None = None
144
+
145
+ # The JIT slice's own `type="test-author"` assignment (#1171: keyed on
146
+ # `for_issue_number == issue`, NOT `issue_number` — that field is the
147
+ # milestone's TRACKING issue, so this row is invisible to `work_aid`
148
+ # above by design). Empty until `coord acceptance author ... --issue
149
+ # <N>` has been dispatched for this issue.
150
+ acceptance_author_aid: str = ""
151
+ acceptance_author_status: str = ""
152
+ acceptance_author_branch: str = ""
153
+ acceptance_author_machine: str = ""
154
+
155
+ # ── #2079: the JIT slice's OWN landing state ────────────────────────
156
+ # The slice row is `WORK_LIKE` (coord.models.WORK_LIKE_TYPES includes
157
+ # "test-author"), so the daemon's passive tick dispatches its Test and
158
+ # Review stages and `enqueue_approved_work` puts it in the merge queue —
159
+ # all of that runs unconditionally. The ONE step that does not is the
160
+ # final drain (`serve_app._auto_drain_tick`, gated on
161
+ # `merge.auto_drain`, which is `false` in the standing fleet config), so
162
+ # a green, READY slice sits there forever and `coord drive` idles to its
163
+ # deadline waiting for a merge nobody will perform. These fields are
164
+ # what let `coord.drive._decide_acceptance_landing` drive that last step
165
+ # itself (`coord merge --only <slice aid>`), exactly as it already does
166
+ # for the issue's own work row.
167
+ #
168
+ # All four reads are over data already on `/board` — no extra I/O. The
169
+ # merge entry is matched on the slice's ASSIGNMENT ID, not on
170
+ # (repo, issue): the slice row's `issue_number` is the milestone's
171
+ # TRACKING issue, which may carry other queue entries of its own (the
172
+ # Gate-A mock, a sibling issue's slice).
173
+ acceptance_author_test_state: str = ""
174
+ acceptance_review_aid: str = ""
175
+ acceptance_review_verdict: str = ""
176
+ acceptance_merge_status: str = ""
177
+ acceptance_merge_reason: str = ""
178
+ acceptance_merge_aid: str = ""
179
+ acceptance_merge_pr_url: str = ""
180
+
181
+ # #2024/#685: the issue's Test-stage POLICY, read from the same
182
+ # `test-mode:*` labels `coord.smoke.dispatch_pending_smoke` gates on
183
+ # (`coord.models.test_mode_from_labels` — one shared reading, no drift).
184
+ # "" → no label: the headless Test stage auto-dispatches per
185
+ # `smoke_tests.auto_queue`.
186
+ # "auto" → same, explicitly opted in.
187
+ # "smoke" → the headless path deliberately SKIPS this issue; the Test
188
+ # stage is human-attended (the TUI's interactive smoke agent).
189
+ # The driver needs this because a completed row with no test verdict means
190
+ # two opposite things depending on it: "the daemon will dispatch on the
191
+ # next tick" (poll) vs "nothing automatic will EVER dispatch" (dead end —
192
+ # `coord.dead_end` shape 3).
193
+ issue_test_mode: str = ""
194
+
195
+ # ── derived ──────────────────────────────────────────────────────────
196
+ @property
197
+ def fingerprint(self) -> str:
198
+ """Compact fingerprint of every field the state machine branches on.
199
+
200
+ Used to tell a *stall* (no transition) apart from "still working" —
201
+ the bash ``state_fingerprint`` function, field-for-field.
202
+
203
+ #1526: ``merge_reason`` is included alongside ``merge_status`` — once
204
+ ``_merge_gate_divergence`` started branching on it too, a
205
+ ``coord merge`` attempt that leaves ``merge_status`` unchanged (e.g.
206
+ still ``READY``) but writes a NEW refusal reason onto the board is a
207
+ real transition the driver just reacted to, not a stall. Omitting it
208
+ would both mute the ``state:`` log line for that change and let the
209
+ stall timer keep counting through it.
210
+
211
+ #2079: the oracle-mode JIT slice's own landing fields are here for
212
+ the same reason. While `coord drive` is waiting on the slice, EVERY
213
+ work-row field above is empty and frozen (the work row does not
214
+ exist yet, by construction), so the slice progressing from
215
+ `test_state=""` → `passed` → an approved review → a READY queue
216
+ entry produced no fingerprint change at all: the `state:` line never
217
+ printed, and the stall detector nudged `coord notify` every
218
+ `--stall` minutes as if nothing were happening. Real transitions,
219
+ rendered as a stall.
220
+ """
221
+ return "|".join(
222
+ str(v)
223
+ for v in (
224
+ self.work_aid,
225
+ self.work_status,
226
+ self.work_test_state,
227
+ self.work_review_state,
228
+ self.work_review_iter,
229
+ self.review_status,
230
+ self.review_verdict,
231
+ self.merge_status,
232
+ self.merge_reason,
233
+ self.acceptance_author_aid,
234
+ self.acceptance_author_status,
235
+ self.acceptance_author_test_state,
236
+ self.acceptance_review_verdict,
237
+ self.acceptance_merge_status,
238
+ self.acceptance_merge_reason,
239
+ )
240
+ )
241
+
242
+ def as_flat_dict(self) -> dict[str, Any]:
243
+ """Upper-cased flat dict, matching the old script's variable names."""
244
+ out: dict[str, Any] = {}
245
+ for key, value in asdict(self).items():
246
+ if isinstance(value, tuple):
247
+ value = ",".join(value)
248
+ elif isinstance(value, bool):
249
+ value = "1" if value else "0"
250
+ elif value is None:
251
+ value = ""
252
+ out[key.upper()] = value
253
+ return out
254
+
255
+
256
+ def _latest(rows: list[dict]) -> dict | None:
257
+ """The most recently dispatched row, or ``None``."""
258
+ if not rows:
259
+ return None
260
+ return max(rows, key=lambda r: r.get("dispatched_at") or 0.0)
261
+
262
+
263
+ def project(payload: dict, repo: str, issue: int, config: Any) -> IssueState:
264
+ """Reduce a whole ``/board`` payload to the facts the driver branches on.
265
+
266
+ Raises :class:`DriveStateError` when *repo* is not in coordinator.yml —
267
+ a configuration error the driver must report, not poll through.
268
+ """
269
+ repo_cfg = config.repo(repo)
270
+ if repo_cfg is None:
271
+ raise DriveStateError(f"repo {repo!r} is not in coordinator.yml")
272
+
273
+ mine = [
274
+ a
275
+ for a in payload.get("assignments") or []
276
+ if a.get("repo_name") == repo and a.get("issue_number") == issue
277
+ ]
278
+
279
+ plan = _latest([a for a in mine if a.get("type") == "plan"])
280
+ work = _latest([a for a in mine if a.get("type") in WORK_LIKE])
281
+ work_aid = (work or {}).get("assignment_id") or ""
282
+
283
+ # The review that reviewed *this* work row. Fix rounds produce a new work
284
+ # row and a new review, so keying on the work id (not just the issue) is
285
+ # what keeps a stale earlier verdict from being read as the current one.
286
+ review = _latest(
287
+ [
288
+ a
289
+ for a in mine
290
+ if a.get("type") == "review"
291
+ and a.get("review_of_assignment_id") == work_aid
292
+ ]
293
+ )
294
+ smoke = _latest(
295
+ [
296
+ a
297
+ for a in mine
298
+ if a.get("type") == "smoke"
299
+ and a.get("review_of_assignment_id") == work_aid
300
+ ]
301
+ )
302
+
303
+ active = [a for a in mine if (a.get("status") or "") not in TERMINAL_STATUSES]
304
+
305
+ merge_entry = _merge_entry(payload, repo, issue)
306
+
307
+ def g(row: dict | None, key: str, default: Any = "") -> Any:
308
+ value = (row or {}).get(key)
309
+ return default if value is None else value
310
+
311
+ exit_code = (work or {}).get("exit_code")
312
+
313
+ # #1453: oracle-loop JIT slice resolution — both reads are over data
314
+ # already published on /board, no extra I/O (see IssueState's docstring
315
+ # for the two source lists and their TUI-side counterparts).
316
+ milestone_number = None
317
+ # #1906: the same cached `/board` `issues` row already carries this
318
+ # issue's GitHub labels (`coord.dao`'s `issues: {"labels"}` JSON column)
319
+ # — reused below by `pick_machine` to resolve the effective provider
320
+ # (`coord.providers.resolve_provider_name`'s `providers.labels` link,
321
+ # #1889) BEFORE picking a machine, so selection is capability-aware
322
+ # instead of discovering a mismatch only when #1711's dispatch-time
323
+ # guard refuses it. No extra I/O: `issues` is already part of *payload*.
324
+ issue_labels: list[str] = []
325
+ for oi in payload.get("issues") or []:
326
+ if oi.get("repo_name") == repo and oi.get("number") == issue:
327
+ milestone_number = oi.get("milestone_number")
328
+ issue_labels = list(oi.get("labels") or [])
329
+ break
330
+
331
+ milestone_tracking_issue = None
332
+ for mwo in payload.get("milestone_work_orders") or []:
333
+ if mwo.get("repo_name") != repo:
334
+ continue
335
+ if any(n.get("issue_number") == issue for n in mwo.get("nodes") or []):
336
+ milestone_tracking_issue = mwo.get("tracking_issue")
337
+ break
338
+
339
+ # The JIT slice's own assignment row: keyed on `for_issue_number`, NOT
340
+ # `issue_number` (that field carries the milestone's TRACKING issue for
341
+ # this dispatch shape — #1171/#1138) — so it is deliberately excluded
342
+ # from `mine`/`work_aid` above.
343
+ acceptance_author = _latest(
344
+ [
345
+ a
346
+ for a in payload.get("assignments") or []
347
+ if a.get("repo_name") == repo
348
+ and a.get("type") == "test-author"
349
+ and a.get("for_issue_number") == issue
350
+ ]
351
+ )
352
+
353
+ # #2079: the slice's own Test/Review/Merge landing state. Its review
354
+ # child is keyed the same way the work row's is (`review_of_assignment_id`
355
+ # → the reviewed row's id), and its merge-queue entry is matched on that
356
+ # id too — see the `acceptance_*` field block in `IssueState` for why the
357
+ # (repo, issue) match `_merge_entry` uses for the work row would be wrong
358
+ # here.
359
+ acceptance_author_aid = g(acceptance_author, "assignment_id")
360
+ acceptance_review = (
361
+ _latest(
362
+ [
363
+ a
364
+ for a in payload.get("assignments") or []
365
+ if a.get("repo_name") == repo
366
+ and a.get("type") == "review"
367
+ and a.get("review_of_assignment_id") == acceptance_author_aid
368
+ ]
369
+ )
370
+ if acceptance_author_aid
371
+ else None
372
+ )
373
+ acceptance_merge = (
374
+ _merge_entry(payload, repo, issue, assignment_id=acceptance_author_aid)
375
+ if acceptance_author_aid
376
+ else None
377
+ )
378
+
379
+ _machine_pick = pick_machine_choice(
380
+ payload, repo, config, issue_labels=issue_labels,
381
+ )
382
+
383
+ return IssueState(
384
+ repo=repo,
385
+ issue=issue,
386
+ repo_github=repo_cfg.github or "",
387
+ repo_default_branch=repo_cfg.default_branch or "main",
388
+ repo_test_command=repo_cfg.test_command or "",
389
+ max_review_iterations=config.pipeline.max_review_iterations,
390
+ auto_loop=bool(config.pipeline.auto_loop),
391
+ plan_aid=g(plan, "assignment_id"),
392
+ plan_status=g(plan, "status"),
393
+ work_aid=work_aid,
394
+ work_type=g(work, "type"),
395
+ work_status=g(work, "status"),
396
+ work_branch=g(work, "branch"),
397
+ work_machine=g(work, "machine_name"),
398
+ work_provider=g(work, "provider_name"),
399
+ work_test_state=g(work, "test_state"),
400
+ work_test_reason=g(work, "test_reason"),
401
+ work_review_state=g(work, "review_state"),
402
+ work_review_iter=int(g(work, "review_iteration", 0) or 0),
403
+ work_exit_code=None if exit_code is None else int(exit_code),
404
+ work_failure_reason=g(work, "failure_reason"),
405
+ review_aid=g(review, "assignment_id"),
406
+ review_status=g(review, "status"),
407
+ review_verdict=g(review, "review_verdict"),
408
+ review_failure_reason=g(review, "failure_reason"),
409
+ smoke_aid=g(smoke, "assignment_id"),
410
+ smoke_status=g(smoke, "status"),
411
+ smoke_failure_reason=g(smoke, "failure_reason"),
412
+ active_count=len(active),
413
+ active_types=tuple(sorted({(a.get("type") or "?") for a in active})),
414
+ merge_status=(merge_entry or {}).get("status") or "",
415
+ merge_reason=(merge_entry or {}).get("reason") or "",
416
+ merge_pr_url=(merge_entry or {}).get("pr_url") or "",
417
+ merge_aid=(merge_entry or {}).get("assignment_id") or "",
418
+ picked_machine=_machine_pick.name,
419
+ picked_machine_provider=_machine_pick.provider_name,
420
+ picked_machine_provider_reason=_machine_pick.provider_reason,
421
+ picked_machine_no_capable=_machine_pick.no_capable_machine,
422
+ milestone_number=milestone_number,
423
+ milestone_tracking_issue=milestone_tracking_issue,
424
+ # #2024: the per-issue Test-stage policy, off the labels already read
425
+ # above (no extra I/O). `test_mode_from_labels` is the same function
426
+ # `coord.state._get_issue_test_mode_local` uses, so the driver and the
427
+ # dispatcher cannot disagree about what the label means.
428
+ issue_test_mode=test_mode_from_labels(issue_labels) or "",
429
+ acceptance_author_aid=acceptance_author_aid,
430
+ acceptance_author_status=g(acceptance_author, "status"),
431
+ acceptance_author_branch=g(acceptance_author, "branch"),
432
+ acceptance_author_machine=g(acceptance_author, "machine_name"),
433
+ acceptance_author_test_state=g(acceptance_author, "test_state"),
434
+ acceptance_review_aid=g(acceptance_review, "assignment_id"),
435
+ acceptance_review_verdict=g(acceptance_review, "review_verdict"),
436
+ acceptance_merge_status=(acceptance_merge or {}).get("status") or "",
437
+ acceptance_merge_reason=(acceptance_merge or {}).get("reason") or "",
438
+ acceptance_merge_aid=(acceptance_merge or {}).get("assignment_id") or "",
439
+ acceptance_merge_pr_url=(acceptance_merge or {}).get("pr_url") or "",
440
+ )
441
+
442
+
443
+ def _merge_entry(
444
+ payload: dict, repo: str, issue: int, *, assignment_id: str = ""
445
+ ) -> dict | None:
446
+ """Merge state for (*repo*, *issue*): the plan entry, cross-checked
447
+ against the raw queue row.
448
+
449
+ Matched on (repo, issue) rather than assignment id on purpose: the
450
+ enqueued entry may be keyed to an earlier work row in a fix chain.
451
+
452
+ #2079: *assignment_id* overrides that, matching the queue entry's own
453
+ ``assignment_id`` instead. It is the right key — and (repo, issue) the
454
+ wrong one — for exactly one caller, the oracle-mode JIT acceptance
455
+ slice: that row's ``issue_number`` is the milestone's TRACKING issue
456
+ (#1171/#1138), which routinely carries queue entries belonging to OTHER
457
+ rows (the Gate-A mock, a sibling member issue's slice), so matching on
458
+ the issue would hand the driver a stranger's merge status. The fix-chain
459
+ concern above does not apply, because the caller resolves the slice row
460
+ with ``_latest`` — it is already looking at the newest aid, which is the
461
+ one ``enqueue_approved_work`` re-keys the entry to.
462
+
463
+ #1505 review fix: ``merge_queue.plan()``'s ``_state_to_plan_status``
464
+ deliberately collapses CONFLICT, HUMAN_REQUIRED, and SKIPPED into a
465
+ single "NEEDS_ATTENTION" bucket for operator-facing display (see that
466
+ function's docstring). But ``_decide_merge``'s retry-vs-escalate branch
467
+ needs exactly the distinction that collapse erases: CONFLICT is still
468
+ auto-fixable (a ``coord merge --only`` retry dispatches
469
+ ``classify_conflict``/``dispatch_conflict_fix``, #1474) while
470
+ HUMAN_REQUIRED and SKIPPED are terminal. ``merge_plan`` is populated on
471
+ nearly every ``/board`` build (``serve_app.board()`` calls
472
+ ``merge_queue.plan()`` unconditionally, falling back to ``[]`` only on
473
+ an exception), so without this cross-check a fresh, still-retryable
474
+ conflict presents to ``_decide_merge`` as NEEDS_ATTENTION and escalates
475
+ on first sight instead of retrying — reintroducing the #1453/#1461
476
+ stall in a new shape (immediate give-up instead of infinite wait). When
477
+ the plan reports NEEDS_ATTENTION, this looks up the SAME entry's raw
478
+ state in ``merge_queue`` and reports that instead, recovering the
479
+ distinction.
480
+
481
+ Also recovers ``pr_url``: the ``PlannedMerge`` dataclass ``merge_plan``
482
+ entries are serialized from carries ``pr_number``, not a URL — this
483
+ falls back to the raw queue row's ``pr_url``, then reconstructs one from
484
+ ``repo_github`` + ``pr_number`` when neither is present, so the
485
+ escalation record's proposed ``gh pr merge`` command still gets a PR
486
+ number on a normal daemon-backed board.
487
+ """
488
+
489
+ def _matches(entry: dict) -> bool:
490
+ if entry.get("repo_name") != repo:
491
+ return False
492
+ if assignment_id:
493
+ return entry.get("assignment_id") == assignment_id
494
+ return entry.get("issue_number") == issue
495
+
496
+ plan_entry = None
497
+ for entry in payload.get("merge_plan") or []:
498
+ if _matches(entry):
499
+ plan_entry = entry
500
+ break
501
+
502
+ raw_entry = None
503
+ for entry in payload.get("merge_queue") or []:
504
+ if _matches(entry):
505
+ raw_entry = entry
506
+ break
507
+
508
+ if plan_entry is None:
509
+ if raw_entry is None:
510
+ return None
511
+ return {
512
+ "status": (raw_entry.get("state") or "").upper(),
513
+ "reason": raw_entry.get("error"),
514
+ "pr_url": raw_entry.get("pr_url"),
515
+ "assignment_id": raw_entry.get("assignment_id"),
516
+ }
517
+
518
+ status = (plan_entry.get("status") or "").upper()
519
+ if status == "NEEDS_ATTENTION" and raw_entry is not None:
520
+ # Recover the pre-collapse state (CONFLICT / HUMAN_REQUIRED /
521
+ # SKIPPED) so a retryable conflict doesn't masquerade as a terminal
522
+ # NEEDS_ATTENTION and escalate prematurely.
523
+ status = (raw_entry.get("state") or status).upper()
524
+
525
+ pr_url = plan_entry.get("pr_url") or (raw_entry or {}).get("pr_url")
526
+ if not pr_url and plan_entry.get("pr_number") and plan_entry.get("repo_github"):
527
+ pr_url = (
528
+ f"https://github.com/{plan_entry['repo_github']}"
529
+ f"/pull/{plan_entry['pr_number']}"
530
+ )
531
+
532
+ reason = plan_entry.get("reason") or (raw_entry or {}).get("error")
533
+ # #1892: `plan_entry["reason"]` is `_entry_gate_status`'s FRESH
534
+ # re-derivation at board-build time — and that function never computes
535
+ # the CI_INFRA_PREFIX classification, because doing so needs an extra
536
+ # `gh api .../jobs` call the board *read* path must never make (see
537
+ # `coord.gate_snapshot`'s Invariant 1). Only a LIVE `coord merge`
538
+ # attempt (`merge_queue.process()`, which already pays for fresh truth)
539
+ # computes it and persists it onto the raw row's `error`. So a
540
+ # verdictless CI failure always re-derives as the plain "checks failed:
541
+ # ..." wording in `plan_entry`, shadowing the more specific reading the
542
+ # raw row already has — recover it here, mirroring the NEEDS_ATTENTION
543
+ # recovery above: prefer the raw row's reason whenever IT carries the
544
+ # #1892 classification and the plan's own fresher reason doesn't.
545
+ if raw_entry is not None:
546
+ raw_reason = raw_entry.get("error")
547
+ if is_ci_infra_reason(raw_reason) and not is_ci_infra_reason(reason):
548
+ reason = raw_reason
549
+
550
+ return {
551
+ "status": status,
552
+ "reason": reason,
553
+ "pr_url": pr_url,
554
+ "assignment_id": plan_entry.get("assignment_id"),
555
+ }
556
+
557
+
558
+ @dataclass(frozen=True)
559
+ class MachineChoice:
560
+ """The result of :func:`pick_machine_choice` — a picked machine name plus
561
+ enough provenance for :func:`coord.drive.preflight` to tell the two
562
+ "nothing to dispatch to" failure modes apart (#1906).
563
+
564
+ ``name`` is ``""`` in both failure modes: no unpaused machine hosts the
565
+ repo at all, or at least one does but none advertise the resolved
566
+ provider. ``no_capable_machine`` is what distinguishes them — see
567
+ ``IssueState.picked_machine_no_capable``'s docstring.
568
+ """
569
+
570
+ name: str = ""
571
+ provider_name: str = ""
572
+ provider_reason: str = ""
573
+ no_capable_machine: bool = False
574
+
575
+
576
+ def pick_machine_choice(
577
+ payload: dict,
578
+ repo: str,
579
+ config: Any,
580
+ *,
581
+ issue_labels: list[str] | None = None,
582
+ ) -> MachineChoice:
583
+ """Least-loaded unpaused **and capable** machine that hosts *repo*.
584
+
585
+ Deliberately simple — this is not ``coord plan``'s brain (which costs an
586
+ LLM call). Load is counted from the board's non-terminal rows, so a
587
+ machine already running two workers loses to an idle peer.
588
+
589
+ #1906: *issue_labels* (``None`` skips provider resolution entirely,
590
+ reproducing the pre-#1906 provider-blind pick byte-for-byte — every
591
+ caller that doesn't pass it, including every pre-#1906 test) resolves
592
+ the effective provider (spec(None) -> ``providers.labels`` -> repo ->
593
+ ``providers.default``, :func:`coord.providers.resolve_provider_name`)
594
+ and narrows candidates to those :func:`coord.providers.
595
+ machine_supports_provider` agrees can run it — the SAME predicate
596
+ #1711's ``guard_provider_machine_capability`` uses to refuse a mismatch
597
+ at dispatch time. Selection now agrees with that gate instead of
598
+ discovering the mismatch from its refusal message after the fact.
599
+
600
+ An empty *issue_labels* list (the issue is real but carries no labels,
601
+ or isn't in the local `/board` issues cache yet) still resolves a
602
+ provider — spec/label just contribute nothing, same as ``None`` would,
603
+ but capability filtering still applies (repo/``providers.default`` can
604
+ still name a non-implicit provider).
605
+ """
606
+ try:
607
+ from coord.machine_pause import paused_set # noqa: PLC0415
608
+
609
+ paused = paused_set(config.machines)
610
+ except Exception: # noqa: BLE001 — a missing pause file means nothing paused
611
+ paused = set()
612
+
613
+ load: dict[str, int] = {}
614
+ for a in payload.get("assignments") or []:
615
+ if (a.get("status") or "") not in TERMINAL_STATUSES:
616
+ name = a.get("machine_name") or ""
617
+ load[name] = load.get(name, 0) + 1
618
+
619
+ hosts = [
620
+ m for m in config.machines if repo in (m.repos or []) and m.name not in paused
621
+ ]
622
+ if not hosts:
623
+ return MachineChoice()
624
+
625
+ candidates = hosts
626
+ provider_name = ""
627
+ provider_reason = ""
628
+ if issue_labels is not None:
629
+ from coord.providers import ( # noqa: PLC0415
630
+ describe_provider_choice,
631
+ machine_supports_provider,
632
+ resolve_provider_name,
633
+ )
634
+
635
+ repo_cfg = config.repo(repo)
636
+ repo_provider = repo_cfg.provider if repo_cfg is not None else None
637
+ provider_name = resolve_provider_name(
638
+ None, repo_provider, config.providers, issue_labels=issue_labels or None,
639
+ )
640
+ provider_reason = describe_provider_choice(
641
+ None, repo_provider, config.providers, issue_labels=issue_labels or None,
642
+ )
643
+ candidates = [
644
+ m for m in hosts
645
+ if machine_supports_provider(m, provider_name, config.providers)
646
+ ]
647
+ if not candidates:
648
+ return MachineChoice(
649
+ provider_name=provider_name,
650
+ provider_reason=provider_reason,
651
+ no_capable_machine=True,
652
+ )
653
+
654
+ candidates = sorted(candidates, key=lambda m: (load.get(m.name, 0), m.name))
655
+ return MachineChoice(
656
+ name=candidates[0].name,
657
+ provider_name=provider_name,
658
+ provider_reason=provider_reason,
659
+ )
660
+
661
+
662
+ def pick_machine(
663
+ payload: dict, repo: str, config: Any, *, issue_labels: list[str] | None = None,
664
+ ) -> str:
665
+ """Thin string-returning wrapper around :func:`pick_machine_choice`.
666
+
667
+ Kept for callers (and the pre-#1906 test suite) that only want the
668
+ picked machine's name, not the provider provenance / failure-mode split
669
+ — see that function's docstring for the *issue_labels* contract.
670
+ """
671
+ return pick_machine_choice(payload, repo, config, issue_labels=issue_labels).name
672
+
673
+
674
+ # ── board fetch (the one I/O boundary) ───────────────────────────────────────
675
+
676
+
677
+ def scratch_dir() -> Path:
678
+ """Per-user scratch directory shared by every ``coord drive`` run.
679
+
680
+ Holds the per-issue run lock + holder file, the run log, the fleet merge
681
+ lock, and the shared board cache.
682
+
683
+ The ``coord-drive-issue-`` name is deliberately the one ``drive-issue.sh``
684
+ used, and every file inside keeps its old name too. During the changeover
685
+ a straggler bash driver launched from an older checkout still collides on
686
+ the *same* ``lock-<repo>-<issue>`` file, so it cannot double-dispatch
687
+ alongside a ``coord drive`` on the same issue. Renaming the directory
688
+ would have silently disabled that mutual exclusion for exactly as long as
689
+ an old checkout existed anywhere in the fleet.
690
+ """
691
+ base = Path(os.environ.get("TMPDIR", "/tmp")) / f"coord-drive-issue-{os.getuid()}"
692
+ base.mkdir(parents=True, exist_ok=True)
693
+ return base
694
+
695
+
696
+ def _local_issue_rows() -> list[dict]:
697
+ """``issues`` rows straight from the local DB (daemon-host path only).
698
+
699
+ #2040: :meth:`BoardFetcher._fetch_local`'s standalone payload used to
700
+ carry no ``issues`` key at all (see that method's docstring) — this is
701
+ the top-up. Same fail-soft posture and the same
702
+ ``coord.db.get_connection()`` singleton
703
+ ``coord.commands.drive_queue._local_issue_rows`` already uses for its own
704
+ (narrower — ``repo_name, number, state`` only) top-up of the same
705
+ standalone-payload gap; this one additionally selects ``milestone_number``
706
+ / ``milestone_title`` / ``labels`` / ``body`` — what :func:`project`'s
707
+ oracle-loop resolution and :func:`coord.milestone_order.
708
+ milestone_work_order_membership` need that the narrower query doesn't
709
+ carry.
710
+
711
+ Deliberately queries ``get_connection()`` rather than
712
+ ``coord.dao.SqliteStore`` — see :meth:`BoardFetcher._fetch_local`'s
713
+ docstring for why the latter is wrong for anything running in-process
714
+ with the rest of the CLI (as ``coord drive`` does on the daemon host).
715
+
716
+ Fail-soft: an unreadable/absent table degrades to ``[]``, which puts the
717
+ daemon host back on the assignment-only signals rather than aborting the
718
+ whole board read over one bad table.
719
+ """
720
+ from coord.db import get_connection # noqa: PLC0415
721
+
722
+ try:
723
+ rows = get_connection().execute(
724
+ "SELECT repo_name, number, state, milestone_number, milestone_title, "
725
+ "labels, body FROM issues"
726
+ ).fetchall()
727
+ except Exception: # noqa: BLE001 — see the fail-soft note above
728
+ return []
729
+
730
+ out: list[dict] = []
731
+ for r in rows:
732
+ d = dict(r)
733
+ labels = d.get("labels")
734
+ if isinstance(labels, (str, bytes, bytearray)):
735
+ try:
736
+ d["labels"] = json.loads(labels) if labels else None
737
+ except (json.JSONDecodeError, TypeError):
738
+ d["labels"] = None
739
+ out.append(d)
740
+ return out
741
+
742
+
743
+ @dataclass
744
+ class BoardFetcher:
745
+ """``GET /board`` with an ETag cache, or the local DB when standalone.
746
+
747
+ The cache is deliberately SHARED across concurrent drivers rather than
748
+ split per-issue: the ``/board`` payload is identical for every issue, so
749
+ sharing means one driver's fetch serves everyone else's 304.
750
+ """
751
+
752
+ cache_dir: Path = field(default_factory=scratch_dir)
753
+ timeout: float = 60.0
754
+
755
+ def fetch(self) -> dict:
756
+ from coord.client import _headers, resolve_board_service # noqa: PLC0415
757
+
758
+ svc = resolve_board_service()
759
+ if svc is None:
760
+ return self._fetch_local()
761
+
762
+ import httpx # noqa: PLC0415
763
+
764
+ cache_path = self._cache_path(svc.url)
765
+ cached = self._read_cache(cache_path)
766
+
767
+ headers = dict(_headers(svc))
768
+ etag = (cached or {}).get("etag")
769
+ if etag:
770
+ headers["if-none-match"] = etag
771
+
772
+ resp = httpx.get(f"{svc.url}/board", headers=headers, timeout=self.timeout)
773
+ if resp.status_code == 304 and cached is not None:
774
+ return cached["payload"]
775
+ resp.raise_for_status()
776
+ payload = resp.json()
777
+ self._write_cache(cache_path, resp.headers.get("etag"), payload)
778
+ return payload
779
+
780
+ @staticmethod
781
+ def _fetch_local() -> dict:
782
+ """Standalone (daemon host, no ``board_service`` configured): the old
783
+ ``{assignments, round_number}`` write-serialization, topped up with
784
+ the two keys :func:`project`'s #1453 oracle-loop resolution needs.
785
+
786
+ #2040: this used to be JUST ``serialize_board(read_board())`` —
787
+ ``coord.client.serialize_board`` is the ``POST /board`` UPSERT
788
+ payload (only what ``coord.state.save_board`` persists), reused here
789
+ by accident for a READ. It carries no ``issues`` key at all, so
790
+ :func:`project`'s ``milestone_number`` / ``milestone_tracking_issue``
791
+ resolution always saw ``None`` on the daemon host — silently
792
+ defeating the #1453 oracle gate (every oracle-opted-in issue read as
793
+ a plain "normal drive" and dead-ended on the #1138 refusal #1453
794
+ exists to prevent).
795
+
796
+ Deliberately NOT ``coord.dao.SqliteStore`` (what ``coord.serve_app``'s
797
+ ``/board`` handler uses): that class opens its OWN ``sqlite3``
798
+ connection straight at ``coord.db.DB_PATH``, bypassing the
799
+ ``coord.db.get_connection()`` singleton entirely — the exact
800
+ production-DB-read-during-a-test shape #1960's
801
+ ``ProductionDatabaseGuardError`` exists to catch, just through a path
802
+ that guard doesn't cover (confirmed the hard way: swapping it in here
803
+ made 20 ``tests/test_cli_drive_queue.py`` tests silently read the
804
+ real ``~/.coord/coord.db`` instead of the seeded ``:memory:`` one).
805
+ ``coord drive``/``drive-queue tick`` run IN-PROCESS with the rest of
806
+ the CLI on the daemon host (unlike ``coord serve``, a separate
807
+ process), so this has to go through the same connection every other
808
+ in-process reader does — :func:`_local_issue_rows` below queries it
809
+ directly, mirroring ``coord.commands.drive_queue._local_issue_rows``'s
810
+ established fail-soft top-up pattern for the same standalone-payload
811
+ gap, one table over.
812
+
813
+ ``milestone_work_orders`` — the other key :func:`project` needs, and
814
+ the one no raw table backs — is derived from those same rows by
815
+ :func:`coord.milestone_order.milestone_work_order_membership`; see
816
+ its docstring for why a membership-only projection (no readiness) is
817
+ the right scope for this call site.
818
+ """
819
+ from coord.board_service import read_board # noqa: PLC0415
820
+ from coord.client import serialize_board # noqa: PLC0415
821
+ from coord.milestone_order import milestone_work_order_membership # noqa: PLC0415
822
+
823
+ payload = serialize_board(read_board())
824
+ payload["issues"] = _local_issue_rows()
825
+ payload["milestone_work_orders"] = milestone_work_order_membership(
826
+ payload["issues"]
827
+ )
828
+ return payload
829
+
830
+ def _cache_path(self, url: str) -> Path:
831
+ key = hashlib.sha256(url.encode()).hexdigest()[:16]
832
+ return self.cache_dir / f"board-{key}.json"
833
+
834
+ @staticmethod
835
+ def _read_cache(path: Path) -> dict | None:
836
+ try:
837
+ data = json.loads(path.read_text())
838
+ except (OSError, ValueError):
839
+ return None # absent, unreadable, or a torn write from an old version
840
+ if not isinstance(data, dict) or "payload" not in data:
841
+ return None
842
+ return data
843
+
844
+ @staticmethod
845
+ def _write_cache(path: Path, etag: str | None, payload: dict) -> None:
846
+ """Store the ETag and the payload TOGETHER in one atomically-replaced file.
847
+
848
+ They were two files, written body-then-etag. That is safe for a single
849
+ writer, but two concurrent drivers can interleave so that a reader
850
+ pairs process A's *newer* etag with process B's *older* body — it then
851
+ sends ``If-None-Match``, gets a 304, and confidently serves the WRONG
852
+ board. A driver acting on a stale board is precisely the class of
853
+ silent wrongness this whole tool exists to avoid.
854
+
855
+ One file makes the pair inseparable; ``os.replace`` is atomic on POSIX,
856
+ and the temp file is created in the same directory so the rename never
857
+ crosses a filesystem boundary. The pid suffix keeps two writers from
858
+ colliding on the temp name itself.
859
+ """
860
+ tmp = path.with_suffix(f".{os.getpid()}.tmp")
861
+ try:
862
+ tmp.write_text(json.dumps({"etag": etag, "payload": payload}))
863
+ os.replace(tmp, path)
864
+ except OSError:
865
+ # The cache is an optimisation, never a correctness dependency — a
866
+ # failed write just costs the next poll a full fetch.
867
+ try:
868
+ tmp.unlink()
869
+ except OSError:
870
+ pass