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/smoke.py ADDED
@@ -0,0 +1,1030 @@
1
+ """Smoke-test orchestration — auto-queue validation on a capable machine.
2
+
3
+ When a worker finishes, the work often needs validation hardware the worker
4
+ didn't have. Example: a GTK key-routing fix built on a no-GTK server needs a
5
+ machine with GTK to actually verify the popup works. This module:
6
+
7
+ 1. Reads the worker's diff (which files changed).
8
+ 2. Looks at `smoke_tests.capability_rules` — each rule maps a file-path
9
+ prefix to a set of required machine capabilities.
10
+ 3. Picks a machine that has all required capabilities, preferring one
11
+ different from the worker.
12
+ 4. Dispatches a `type="smoke"` assignment with a briefing that tells
13
+ `claude -p` to fetch the branch, run the smoke command, and report
14
+ pass/fail through its exit code.
15
+
16
+ Public entry points:
17
+
18
+ - `match_rules(touched_files, rules)` — pure: returns the union of required
19
+ capabilities for any rule whose `files` prefix matches a touched file.
20
+ - `rank_smoke_machines(required_caps, repo, worker_machine, board, config)` —
21
+ every capability-matched machine, best first (#1672).
22
+ - `pick_smoke_machine(required_caps, worker_machine, board, config)` — picks
23
+ a capable machine, preferring the worker's own (its build cache is warm —
24
+ #1402); pass `prefer_worker=False` for the old different-machine-first
25
+ order. Thin wrapper over `rank_smoke_machines` — the head of the ranking.
26
+ - `dispatch_smoke(completed, board, config, ...)` — the full path; called
27
+ from reconcile when a work assignment transitions to done.
28
+
29
+ #1819: the unit a Test run measures is the **(branch, base)** pair, not the
30
+ work row that asked for it. Three guards in `dispatch_smoke` follow from that
31
+ — a branch-scoped in-flight dedupe, a supersession check that skips a work row
32
+ a later row replaced on the same branch, and a refusal to stamp the transient
33
+ `running` marker over a verdict that already exists. Together they are what
34
+ stops a fix round (which reuses the branch by design, so one branch carries
35
+ two `work` rows) from putting two machines on the identical suite and then
36
+ looping forever as each re-dispatch retracts the verdict the last one landed.
37
+
38
+ Why a separate module from `coord/review.py`: smoke tests target machine
39
+ capabilities (GTK/terminal/CUDA), not session independence. The selection
40
+ algorithm is different — for reviews we want a *different* machine for
41
+ independence; for smoke we want a *capable* machine for hardware, and
42
+ "different" is only a tie-breaker.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import json
48
+ import logging
49
+ import time
50
+ import uuid
51
+ from dataclasses import dataclass
52
+ from typing import Callable
53
+
54
+ import httpx
55
+
56
+ from coord import github_ops
57
+ from coord.config import Config, SmokeRule, SmokeTestsConfig
58
+ from coord.dispatch import AGENT_PORT
59
+ from coord.models import WORK_LIKE_TYPES, Assignment, Board, Machine
60
+
61
+ logger = logging.getLogger("coord.smoke")
62
+
63
+ # #2024: assignment ids whose `test-mode:smoke` Test-stage skip has already
64
+ # been reported this process (see `dispatch_pending_smoke`). Process-lifetime,
65
+ # not persisted — the point is one clear statement per daemon run, not an
66
+ # every-tick repeat of a refusal that is expected to hold for hours.
67
+ #
68
+ # Deliberately never pruned (e.g. when an operator clears the block with
69
+ # `coord set-test-mode <repo> <issue> auto` or records a verdict by hand):
70
+ # membership is a small, immutable assignment-id string per row that ever hit
71
+ # this skip, so the set's lifetime cost is bounded by "how many rows stalled
72
+ # on this policy while the daemon has been up" — negligible next to the board
73
+ # itself, and a daemon restart clears it for free (the next occurrence logs
74
+ # again, which is exactly when an operator wants to hear it).
75
+ _TEST_MODE_SKIP_LOGGED: set[str] = set()
76
+
77
+
78
+ SMOKE_SYSTEM_PROMPT = """\
79
+ You are a smoke-test runner dispatched by the coordinator. \
80
+ Your only job: pull the branch, run the smoke command, report pass/fail.
81
+
82
+ Rules:
83
+ - Do NOT edit source files. Do NOT push commits. You only validate.
84
+ - You MAY perform test-environment SETUP the smoke command needs — creating
85
+ a venv, `pip install`-ing dev deps, symlinking a sibling checkout for a
86
+ path dependency (e.g. coord-tui's quadraui link), writing build artifacts.
87
+ None of that touches the branch's source; it is exactly what the smoke
88
+ command itself does when run locally, so do it without asking.
89
+ - Do NOT run `gh` commands. The coordinator owns GitHub interactions.
90
+ - You MAY run git, build commands, and test commands.
91
+ - Exit code is what the coordinator reads — exit 0 on pass, non-zero on \
92
+ fail. Print a final line `SMOKE: pass` or `SMOKE: fail <one-line reason>` \
93
+ before exiting so logs are readable.
94
+
95
+ Where you are:
96
+ - You are in a dedicated git worktree created for this run. Every git command \
97
+ below runs THERE, in your current directory.
98
+ - Do NOT `cd` to the machine's shared base checkout (`~/src/<repo>`), and do \
99
+ NOT `git checkout` / `git switch` inside it. Leaving that checkout parked on a \
100
+ feature branch makes every later dispatch against that branch on this machine \
101
+ fail (#1694).
102
+
103
+ Steps:
104
+ 1. `git fetch origin && git checkout <branch>` (the branch is in your \
105
+ briefing) — in your worktree, never in the base checkout.
106
+ 2. Run the smoke command from the briefing. Capture stdout/stderr.
107
+ 3. If it exits 0 → print `SMOKE: pass` and exit 0.
108
+ 4. If it fails → print `SMOKE: fail <short reason>` and exit non-zero.
109
+ """
110
+
111
+
112
+ # ── Rule matching ───────────────────────────────────────────────────────────
113
+
114
+
115
+ def match_rules(touched_files: list[str], rules: list[SmokeRule]) -> list[str]:
116
+ """Return the union of `requires` for any rule that any touched file hits.
117
+
118
+ Matching is path-prefix: a rule with `files=["src/gtk/"]` matches
119
+ `src/gtk/foo.c` but not `src/cli.py`. A rule with `files=["src/gtk"]`
120
+ (no slash) catches both `src/gtk/foo.c` and `src/gtk_helpers.c` — use
121
+ the trailing slash form to be strict.
122
+
123
+ Returns capabilities in deterministic order (first-seen across rules).
124
+ """
125
+ seen: dict[str, None] = {}
126
+ for path in touched_files:
127
+ for rule in rules:
128
+ if not any(path.startswith(pattern) for pattern in rule.files):
129
+ continue
130
+ for cap in rule.requires:
131
+ seen.setdefault(cap, None)
132
+ return list(seen.keys())
133
+
134
+
135
+ # ── Machine selection ───────────────────────────────────────────────────────
136
+
137
+
138
+ @dataclass
139
+ class SmokeMachineChoice:
140
+ machine: Machine
141
+ is_worker: bool
142
+ rationale: str
143
+
144
+
145
+ def rank_smoke_machines(
146
+ required_caps: list[str],
147
+ repo_name: str,
148
+ worker_machine_name: str,
149
+ board: Board,
150
+ config: Config,
151
+ *,
152
+ prefer_worker: bool = True,
153
+ ) -> list[SmokeMachineChoice]:
154
+ """Every machine that can smoke-test `repo_name` with all `required_caps`,
155
+ best candidate first (#1672).
156
+
157
+ Preference order (#1402):
158
+ 1. The worker's own machine, if capable and idle
159
+ 2. Idle, capable, different from worker (config order)
160
+ 3. The worker's own machine, if capable (busy — smoke will queue)
161
+ 4. Busy, capable, different from worker (config order; smoke will queue)
162
+
163
+ Every candidate appears exactly once; the head of the list is exactly what
164
+ :func:`pick_smoke_machine` used to return on its own.
165
+
166
+ **Why a ranking and not a single pick (#1672).** ``dispatch_smoke`` has to
167
+ reject a candidate *after* choosing it — its live ``/health`` probe can
168
+ contradict the capabilities ``coordinator.yml`` declares for it (#1570 D),
169
+ or it can turn out to have no ``repo_paths`` entry. Returning one machine
170
+ meant a single bad candidate ended the whole Test stage: on 2026-08-01
171
+ (#1678) the router picked the same unhealthy machine every 30 s forever
172
+ while two other machines declared the same capability and were never
173
+ tried. The caller now walks this list.
174
+
175
+ **Capability matching is unchanged and is never relaxed.** Only machines
176
+ that genuinely declare every required capability (and can work on the
177
+ repo) are in the list at all — a fallback that dispatched to a machine
178
+ lacking the capability would produce a green verdict from a machine that
179
+ cannot run the suite, which is worse than refusing.
180
+
181
+ **Why the worker machine is preferred.** This used to prefer a machine
182
+ *different* from the worker. That preference is right for **review**,
183
+ where independence from the worker's context is the entire point — but a
184
+ test run needs **capability**, not independence: it re-runs the suite
185
+ against the pushed commit and the verdict is identical wherever it runs.
186
+ Meanwhile the worker's machine is the one with a warm build cache
187
+ (``coord.cargo_cache``, #1402) and, for a Rust repo, that is the
188
+ difference between ~18 s and ~3 min. Capability rules still bind
189
+ absolutely: a GTK or browser suite goes to a capable machine even when
190
+ the worker ran somewhere else, and a worker machine that lacks a required
191
+ capability is never chosen.
192
+
193
+ Pass ``prefer_worker=False`` to restore the different-machine-first
194
+ ordering (used by callers that want independence, and by tests pinning
195
+ the old behaviour).
196
+
197
+ Returns an empty list when capabilities can't be matched.
198
+ """
199
+ candidates = [
200
+ m for m in config.machines
201
+ if m.can_work_on(repo_name)
202
+ and all(cap in m.capabilities for cap in required_caps)
203
+ ]
204
+ if not candidates:
205
+ return []
206
+
207
+ busy = {a.machine_name for a in board.active if a.status in ("pending", "running")}
208
+
209
+ same = next((m for m in candidates if m.name == worker_machine_name), None)
210
+
211
+ ranked: list[SmokeMachineChoice] = []
212
+ seen: set[str] = set()
213
+
214
+ def _add(choice: SmokeMachineChoice) -> None:
215
+ if choice.machine.name in seen:
216
+ return
217
+ seen.add(choice.machine.name)
218
+ ranked.append(choice)
219
+
220
+ if prefer_worker and same is not None and same.name not in busy:
221
+ _add(SmokeMachineChoice(
222
+ machine=same,
223
+ is_worker=True,
224
+ rationale=(
225
+ f"chose {same.name} — the worker machine, idle and has "
226
+ f"{required_caps}; its build cache is already warm"
227
+ ),
228
+ ))
229
+
230
+ for m in candidates:
231
+ if m.name == worker_machine_name or m.name in busy:
232
+ continue
233
+ _add(SmokeMachineChoice(
234
+ machine=m,
235
+ is_worker=False,
236
+ rationale=(
237
+ f"chose {m.name} — idle and has {required_caps} "
238
+ f"(worker was {worker_machine_name})"
239
+ ),
240
+ ))
241
+
242
+ if prefer_worker and same is not None:
243
+ _add(SmokeMachineChoice(
244
+ machine=same,
245
+ is_worker=True,
246
+ rationale=(
247
+ f"chose {same.name} — the worker machine has {required_caps} and a "
248
+ "warm build cache; capable but busy, smoke will queue"
249
+ ),
250
+ ))
251
+
252
+ for m in candidates:
253
+ if m.name == worker_machine_name:
254
+ continue
255
+ _add(SmokeMachineChoice(
256
+ machine=m,
257
+ is_worker=False,
258
+ rationale=(
259
+ f"chose {m.name} — capable but busy; smoke will queue"
260
+ ),
261
+ ))
262
+
263
+ if same is not None:
264
+ _add(SmokeMachineChoice(
265
+ machine=same,
266
+ is_worker=True,
267
+ rationale=(
268
+ f"only the worker machine ({worker_machine_name}) has {required_caps}; "
269
+ "smoke runs on the same machine"
270
+ ),
271
+ ))
272
+ return ranked
273
+
274
+
275
+ def pick_smoke_machine(
276
+ required_caps: list[str],
277
+ repo_name: str,
278
+ worker_machine_name: str,
279
+ board: Board,
280
+ config: Config,
281
+ *,
282
+ prefer_worker: bool = True,
283
+ ) -> SmokeMachineChoice | None:
284
+ """The single best machine with all `required_caps` for `repo_name`.
285
+
286
+ The head of :func:`rank_smoke_machines` — see there for the preference
287
+ order and the reasoning. Returns None when capabilities can't be matched.
288
+
289
+ ``dispatch_smoke`` uses the full ranking (#1672); this stays for callers
290
+ that only ever want the first choice.
291
+ """
292
+ ranked = rank_smoke_machines(
293
+ required_caps, repo_name, worker_machine_name, board, config,
294
+ prefer_worker=prefer_worker,
295
+ )
296
+ return ranked[0] if ranked else None
297
+
298
+
299
+ def _capability_probe_reasons(
300
+ machine: Machine,
301
+ required_caps: list[str],
302
+ *,
303
+ http_client: httpx.Client | None = None,
304
+ timeout: float = 5.0,
305
+ ) -> dict[str, list[str]]:
306
+ """Cross-reference `machine`'s live `/health` tool probes (#1570 B)
307
+ against `required_caps` before routing smoke work to it (#1570 D).
308
+
309
+ `pick_smoke_machine` only checks `machine.capabilities` — a hand-written
310
+ claim in `coordinator.yml` that nothing has ever verified (#1570's whole
311
+ point: `gh` was simply the first claim to bite). This asks the machine
312
+ itself.
313
+
314
+ Returns `{capability: [reason, ...]}` for any required capability whose
315
+ backing tool the machine's own probe says is missing or too old — empty
316
+ when everything checks out *or* when `/health` doesn't publish
317
+ `tool_versions` yet (an agent that predates #1570 B). The latter fails
318
+ OPEN, not closed: during rollout most of the fleet won't have the probe
319
+ immediately, and refusing every smoke dispatch on missing telemetry
320
+ would be strictly worse than the blind trust this replaces. Only an
321
+ *explicit* probe failure refuses routing.
322
+
323
+ Never raises — a connectivity hiccup here just skips the extra check;
324
+ the POST to `/assign` right after this call in `dispatch_smoke` is the
325
+ real reachability test and fails closed on its own if the machine is
326
+ down.
327
+ """
328
+ client = http_client or httpx
329
+ try:
330
+ resp = client.get(f"http://{machine.host}:{AGENT_PORT}/health", timeout=timeout)
331
+ resp.raise_for_status()
332
+ health = resp.json()
333
+ except (httpx.HTTPError, httpx.TimeoutException, ValueError):
334
+ # Any connectivity/parsing hiccup here just skips the extra check —
335
+ # not widened to AttributeError: a caller's `http_client` double
336
+ # should implement `.get` (real httpx.Client always does), so a
337
+ # missing-method bug on our side surfaces instead of silently
338
+ # degrading like a genuine reachability problem.
339
+ return {}
340
+ raw_probes = health.get("tool_versions") if isinstance(health, dict) else None
341
+ if not raw_probes:
342
+ return {}
343
+
344
+ from coord.prereqs import ToolProbe, unmet_capabilities
345
+
346
+ probes = {
347
+ tool: ToolProbe(
348
+ tool=tool,
349
+ capability=info.get("capability"),
350
+ found=bool(info.get("found", False)),
351
+ version=info.get("version"),
352
+ min_version=info.get("min_version"),
353
+ meets_floor=info.get("meets_floor"),
354
+ what_breaks="",
355
+ )
356
+ for tool, info in raw_probes.items()
357
+ if isinstance(info, dict)
358
+ }
359
+ return unmet_capabilities(required_caps, probes)
360
+
361
+
362
+ # ── Briefing ────────────────────────────────────────────────────────────────
363
+
364
+
365
+ def build_smoke_briefing(
366
+ *,
367
+ repo_github: str,
368
+ repo_name: str,
369
+ branch: str,
370
+ issue_number: int,
371
+ issue_title: str,
372
+ smoke_command: str,
373
+ required_caps: list[str],
374
+ timeout_seconds: int,
375
+ is_worker: bool,
376
+ ) -> str:
377
+ lines: list[str] = []
378
+ lines.append(f"# Smoke test: {repo_github} branch `{branch}`")
379
+ lines.append("")
380
+ lines.append(
381
+ f"Validate the worker's fix for issue #{issue_number}: {issue_title}"
382
+ )
383
+ lines.append("")
384
+ lines.append("## Context")
385
+ lines.append(f"- Repo: {repo_github} (local name: {repo_name})")
386
+ lines.append(f"- Branch: {branch}")
387
+ if required_caps:
388
+ lines.append(f"- Required capabilities: {', '.join(required_caps)}")
389
+ if is_worker:
390
+ lines.append(
391
+ "- NOTE: only this machine has the required capabilities, so the "
392
+ "smoke test is running on the same machine that built the change. "
393
+ "Test the *built artifact*, not the source — the build step here is "
394
+ "your verification that the change compiles."
395
+ )
396
+ lines.append(f"- Timeout: {timeout_seconds}s")
397
+ lines.append("")
398
+ lines.append("## What to do")
399
+ lines.append("")
400
+ lines.append(
401
+ "Run these in your **worktree** (your current directory). Do NOT "
402
+ "`cd` into the machine's shared base checkout and do NOT "
403
+ "`git checkout` there — leaving it parked on a feature branch breaks "
404
+ "every later dispatch against that branch on this machine (#1694)."
405
+ )
406
+ lines.append("")
407
+ lines.append("```bash")
408
+ lines.append("git fetch origin")
409
+ lines.append(f"git checkout {branch}")
410
+ lines.append("git pull --ff-only origin " + branch)
411
+ lines.append(smoke_command)
412
+ lines.append("```")
413
+ lines.append("")
414
+ lines.append(
415
+ "Report `SMOKE: pass` on exit 0, or "
416
+ "`SMOKE: fail <one-line reason>` on non-zero. The coordinator reads "
417
+ "the final exit code."
418
+ )
419
+ return "\n".join(lines)
420
+
421
+
422
+ # ── Diff lookup (which files did the worker change?) ────────────────────────
423
+
424
+
425
+ def _fetch_touched_files(repo_github: str, branch: str) -> list[str]:
426
+ """Return the list of files changed on `branch` vs the base branch.
427
+
428
+ Uses `gh pr view --json files` so the lookup works without a local
429
+ checkout on the coordinator. Returns an empty list on lookup failure —
430
+ the caller treats that as "no rules matched" and skips smoke.
431
+ """
432
+ pr = None
433
+ try:
434
+ pr = github_ops.find_pr_for_branch(repo_github, branch)
435
+ except RuntimeError:
436
+ pr = None
437
+ if pr is None:
438
+ return []
439
+ try:
440
+ raw = github_ops._gh(
441
+ "pr", "view", str(pr["number"]),
442
+ "--repo", repo_github,
443
+ "--json", "files",
444
+ )
445
+ except RuntimeError:
446
+ return []
447
+ try:
448
+ data = json.loads(raw)
449
+ except ValueError:
450
+ return []
451
+ files = data.get("files", []) or []
452
+ return [f.get("path", "") for f in files if f.get("path")]
453
+
454
+
455
+ # ── Unroutable reporting (#1672) ────────────────────────────────────────────
456
+
457
+
458
+ #: ``test_state`` recorded on the parent work row when no capability-matched
459
+ #: machine can run the Test stage and the condition will NOT clear on its own
460
+ #: (#1672). Deliberately distinct from ``"failed"``: nothing is wrong with the
461
+ #: branch, so this must never trigger a fix round — it is a fleet/config fault,
462
+ #: and every gate that asks for ``"passed"``/``"skipped"`` keeps the merge shut
463
+ #: exactly as it did while the state was NULL.
464
+ TEST_STATE_BLOCKED = "blocked"
465
+
466
+
467
+ #: Soft (transient) unroutable reports already logged this process, keyed by
468
+ #: ``(assignment_id, message)``. Transient conditions — a machine that is
469
+ #: merely unreachable right now — are left re-dispatchable so the stage
470
+ #: self-heals on a later tick; this memo is what stops the retry from also
471
+ #: re-logging every 30 s (#1672). Bounded so a long-lived daemon can't grow it
472
+ #: without limit.
473
+ _SOFT_REPORTS_SEEN: set[tuple[str, str]] = set()
474
+ _SOFT_REPORTS_MAX = 512
475
+
476
+
477
+ @dataclass
478
+ class SmokeAttempt:
479
+ """One machine `dispatch_smoke` tried and could not use (#1672)."""
480
+
481
+ machine_name: str
482
+ reason: str
483
+ #: True when the reason is expected to clear without operator action (a
484
+ #: connectivity blip). False for durable faults — an explicit `/health`
485
+ #: probe contradiction (#1570 D) or a missing `repo_paths` entry — which
486
+ #: stay broken until somebody fixes the machine or the config.
487
+ transient: bool = False
488
+
489
+ def describe(self) -> str:
490
+ return f"{self.machine_name}: {self.reason}"
491
+
492
+
493
+ def _report_unroutable_smoke(
494
+ completed: Assignment,
495
+ required_caps: list[str],
496
+ attempts: list[SmokeAttempt],
497
+ ) -> None:
498
+ """Report — once — that the Test stage has no machine it can run on.
499
+
500
+ #1672/#1678: the old code logged a WARNING and returned. Nothing was
501
+ written anywhere the TUI, `coord gates` or the board could show it, and
502
+ the daemon re-ran the identical refusal every 30 s forever. The Test stage
503
+ simply never started and the only trace was `journalctl` on the daemon
504
+ host — the #1616 failure shape again: the pipeline stops and the product
505
+ says nothing.
506
+
507
+ Two outcomes, split on whether the condition can clear by itself:
508
+
509
+ * **Durable** (every candidate hard-refused, or there were no candidates
510
+ at all) — record ``test_state=TEST_STATE_BLOCKED`` with the full reason
511
+ on the parent work row. That is board state: `coord gates` prints it,
512
+ the TUI reads it off the row, and `record_test_verdict` writes an
513
+ ``test_blocked`` audit row. It also ends the spin, because
514
+ `dispatch_pending_smoke` skips rows that already carry a verdict — the
515
+ escalation happens once, not every tick.
516
+ * **Transient** (at least one candidate failed only on connectivity) — do
517
+ NOT poison the row. A machine that is rebooting comes back, and marking
518
+ the row blocked would demand a manual `coord diagnose --reset` for what
519
+ the next tick would have fixed for free. Log it once per process
520
+ instead, and leave the row re-dispatchable.
521
+
522
+ Never raises: a board-write failure must not take the caller down.
523
+ """
524
+ transient = any(a.transient for a in attempts)
525
+ caps = ", ".join(required_caps) if required_caps else "(none — any capable machine)"
526
+ if attempts:
527
+ message = (
528
+ f"Test stage cannot be routed: every machine that declares "
529
+ f"capability [{caps}] for repo {completed.repo_name!r} refused. "
530
+ f"Tried {len(attempts)} — "
531
+ + "; ".join(a.describe() for a in attempts)
532
+ + "."
533
+ )
534
+ else:
535
+ message = (
536
+ f"Test stage cannot be routed: no configured machine declares "
537
+ f"capability [{caps}] AND can build repo "
538
+ f"{completed.repo_name!r} — the Test stage cannot run for this "
539
+ f"completion until a capable machine is added."
540
+ )
541
+
542
+ def _log_once(level: int, text: str) -> None:
543
+ """Log `text` at most once per (row, message) for this process.
544
+
545
+ The board row is what normally makes the durable report fire once; a
546
+ transient dead end deliberately does NOT write to the row (it must
547
+ stay re-dispatchable), and a row with no assignment_id has nowhere to
548
+ write at all — so both need this memo instead. Either way the daemon
549
+ journal gets ONE line, never one every 30 s.
550
+ """
551
+ key = (completed.assignment_id or "", text)
552
+ if key in _SOFT_REPORTS_SEEN:
553
+ return
554
+ if len(_SOFT_REPORTS_SEEN) >= _SOFT_REPORTS_MAX:
555
+ _SOFT_REPORTS_SEEN.clear()
556
+ _SOFT_REPORTS_SEEN.add(key)
557
+ logger.log(
558
+ level, "dispatch_smoke: %s#%s — %s",
559
+ completed.repo_name, completed.issue_number, text,
560
+ )
561
+
562
+ if transient:
563
+ _log_once(
564
+ logging.WARNING,
565
+ f"{message} Leaving the row re-dispatchable; a later tick "
566
+ "retries. (#1672)",
567
+ )
568
+ return
569
+
570
+ reason = (
571
+ f"{message} Fix the machine (or add a capable one) and clear this "
572
+ f"with `coord diagnose {completed.repo_name} "
573
+ f"{completed.issue_number} --stage test --reset` to re-dispatch. "
574
+ "(#1672)"
575
+ )
576
+ if completed.test_state == TEST_STATE_BLOCKED:
577
+ return # already recorded on the row — the report has been made
578
+ if completed.assignment_id is None:
579
+ # No row to write to (shouldn't happen for a board completion) — the
580
+ # log is the only surface left, so at least don't repeat it forever.
581
+ _log_once(logging.ERROR, reason)
582
+ return
583
+ logger.error(
584
+ "dispatch_smoke: %s#%s — %s", completed.repo_name,
585
+ completed.issue_number, reason,
586
+ )
587
+ try:
588
+ from coord.state import record_test_verdict
589
+
590
+ record_test_verdict(
591
+ assignment_id=completed.assignment_id,
592
+ test_state=TEST_STATE_BLOCKED,
593
+ test_reason=reason,
594
+ )
595
+ except Exception: # noqa: BLE001 — reporting must never break dispatch
596
+ logger.exception(
597
+ "dispatch_smoke: failed to record the blocked Test verdict for %s",
598
+ completed.assignment_id,
599
+ )
600
+ return
601
+ completed.test_state = TEST_STATE_BLOCKED
602
+ completed.test_reason = reason
603
+
604
+
605
+ # ── Dispatch ────────────────────────────────────────────────────────────────
606
+
607
+
608
+ PRLookup = Callable[..., dict | None]
609
+ DiffLookup = Callable[[str, str], list[str]]
610
+
611
+
612
+ def dispatch_smoke(
613
+ completed: Assignment,
614
+ board: Board,
615
+ config: Config,
616
+ *,
617
+ http_client: httpx.Client | None = None,
618
+ diff_lookup: DiffLookup = _fetch_touched_files,
619
+ now: float | None = None,
620
+ ) -> Assignment | None:
621
+ """Queue a smoke test for a completed work-like assignment (#930: also
622
+ ``type="mock-author"`` — see :data:`coord.models.WORK_LIKE_TYPES`).
623
+
624
+ Returns the new smoke `Assignment`, or None when no smoke is needed
625
+ (no rules matched, no capable machine, smoke disabled, etc.). The
626
+ caller is responsible for persisting the board.
627
+
628
+ #1672: routing walks the FULL capability-matched candidate list (see
629
+ :func:`rank_smoke_machines`) instead of standing or falling on one
630
+ machine, and a dead end is reported on the row rather than re-logged on
631
+ every daemon tick — see :func:`_report_unroutable_smoke`.
632
+ """
633
+ smoke_cfg = getattr(config, "smoke_tests", SmokeTestsConfig())
634
+ if not smoke_cfg.auto_queue:
635
+ return None
636
+ if completed.type not in WORK_LIKE_TYPES:
637
+ return None
638
+ if completed.status != "done":
639
+ return None
640
+ if not completed.branch:
641
+ return None
642
+ if completed.test_state == TEST_STATE_BLOCKED:
643
+ # #1672: already reported as unroutable, with the reason on the row.
644
+ # Re-probing the same broken fleet on every tick is exactly the spin
645
+ # this issue is about — an operator clears it (`coord diagnose
646
+ # --stage test --reset`) once the fleet is fixed. `dispatch_pending_
647
+ # smoke` already skips rows with a verdict; this covers the callers
648
+ # that hand us a row directly (reconcile).
649
+ return None
650
+
651
+ # Dedupe: don't fire a second smoke if one's already in flight.
652
+ from coord.claim import (
653
+ has_active_branch_followup,
654
+ has_active_followup,
655
+ superseding_work_row,
656
+ )
657
+
658
+ if has_active_followup(
659
+ board, of_assignment_id=completed.assignment_id, assignment_type="smoke"
660
+ ):
661
+ return None
662
+
663
+ # #1819: ...and don't fire one if another row on the SAME BRANCH already
664
+ # has one in flight. The suite measures the branch, not the row that
665
+ # pushed it; after a fix round (`--fix-of` reuses the branch by design)
666
+ # one branch carries two `work` rows, and the row-keyed dedupe above
667
+ # waved the sibling straight through — two machines ran the identical
668
+ # suite on the identical branch and raced to write the verdict (#1797).
669
+ if has_active_branch_followup(
670
+ board,
671
+ repo_name=completed.repo_name,
672
+ branch=completed.branch,
673
+ assignment_type="smoke",
674
+ ):
675
+ return None
676
+
677
+ # #1819: a row that a LATER work-like row superseded on the same branch is
678
+ # not a dispatch target at all. It did not produce the branch's current
679
+ # content, so testing it burns a machine on a result the later row's own
680
+ # dispatch already computes, and the verdict lands on a row nothing gates
681
+ # on. This is what keeps the round-1 row (review=request-changes, fixed by
682
+ # round 2) from consuming a machine every time the base moves.
683
+ superseded_by = superseding_work_row(board, completed)
684
+ if superseded_by is not None:
685
+ logger.debug(
686
+ "dispatch_smoke: skipping %s#%s row %s — superseded on branch %s "
687
+ "by the later work row %s (#1819).",
688
+ completed.repo_name, completed.issue_number,
689
+ completed.assignment_id, completed.branch,
690
+ getattr(superseded_by, "assignment_id", None),
691
+ )
692
+ return None
693
+
694
+ repo = config.repo(completed.repo_name)
695
+ if repo is None:
696
+ return None
697
+
698
+ touched = diff_lookup(repo.github, completed.branch)
699
+ required_caps = match_rules(touched, smoke_cfg.capability_rules)
700
+ smoke_command = smoke_cfg.default_command or repo.test_command
701
+
702
+ if not required_caps:
703
+ # #1426: a capability-rule miss used to mean "skip silently" — the
704
+ # exact blocker that kept the Test stage from ever dispatching for
705
+ # any repo/diff not explicitly covered by a `capability_rules` entry
706
+ # (historically only `tui/` and `coord/dashboard/webapp/` were
707
+ # routed; everything else — most `coord/**` Python work included —
708
+ # never got a headless Test-stage dispatch at all).
709
+ #
710
+ # It now means "no EXTRA hardware capability required", not "nothing
711
+ # to test": a `type="work"` completion still dispatches, to any
712
+ # machine that can build/test the repo at all, as long as a real
713
+ # command is configured (`smoke_tests.default_command` or the repo's
714
+ # `test_command`) — see `pick_smoke_machine`, which treats an empty
715
+ # `required_caps` as "any capable-for-repo machine".
716
+ #
717
+ # `mock-author`/`test-author` (#930/#1176) keep the OLD skip-on-miss
718
+ # behavior: #1076/#1152 established that a rule miss for THOSE types
719
+ # means "genuinely nothing to smoke-test" (a Gate-A contract/fixture-
720
+ # only diff), and `dispatch_pending_reviews` back-fills
721
+ # `test_state="skipped"` for them — dispatching a real suite run
722
+ # here would duplicate that and burn a full test run on a diff that
723
+ # never touches source.
724
+ if completed.type != "work" or smoke_command is None:
725
+ return None
726
+
727
+ if smoke_command is None:
728
+ logger.warning(
729
+ "dispatch_smoke: %s#%s needs capabilities %s but no smoke "
730
+ "command is configured (smoke_tests.default_command or this "
731
+ "repo's test_command) — skipping. Configure one so the Test "
732
+ "stage stops silently no-oping for this repo.",
733
+ completed.repo_name, completed.issue_number, required_caps,
734
+ )
735
+ return None
736
+
737
+ # #1672: the FULL capability-matched candidate list, best first. Picking
738
+ # one machine and giving up on it meant a single bad candidate ended the
739
+ # whole Test stage — #1678, where the router re-chose the same unhealthy
740
+ # machine every 30 s while two other machines declared the same
741
+ # capability and were never tried. Capability matching itself is NOT
742
+ # relaxed: `rank_smoke_machines` only ever yields machines that genuinely
743
+ # declare every required capability.
744
+ candidates = rank_smoke_machines(
745
+ required_caps, completed.repo_name, completed.machine_name, board, config
746
+ )
747
+ attempts: list[SmokeAttempt] = []
748
+ client = http_client or httpx
749
+ dispatched: tuple[SmokeMachineChoice, str, dict] | None = None
750
+
751
+ for choice in candidates:
752
+ if required_caps:
753
+ unmet = _capability_probe_reasons(
754
+ choice.machine, required_caps, http_client=http_client
755
+ )
756
+ if unmet:
757
+ # #1570 D: the machine *claims* every required capability in
758
+ # `coordinator.yml`, but its own `/health` probe (#1570 B)
759
+ # says otherwise — refuse to route HERE rather than dispatch
760
+ # a worker that fails 20 minutes in with a confusing,
761
+ # unrelated error. #1672: that refusal is per-machine, so
762
+ # keep walking the candidate list instead of ending the
763
+ # stage. Durable, not transient — the probe disagrees until
764
+ # somebody installs the tool.
765
+ logger.warning(
766
+ "dispatch_smoke: skipping machine %s for %s#%s — its own "
767
+ "/health probe disagrees with its declared capabilities "
768
+ "%s — %s — refusing to route (#1570 D). Trying the next "
769
+ "capability-matched machine (#1672); run `coord doctor` "
770
+ "to check the fleet.",
771
+ choice.machine.name, completed.repo_name,
772
+ completed.issue_number, required_caps, unmet,
773
+ )
774
+ attempts.append(SmokeAttempt(
775
+ machine_name=choice.machine.name,
776
+ reason=(
777
+ f"/health probe contradicts its declared capabilities "
778
+ f"{required_caps} — {unmet} (#1570 D)"
779
+ ),
780
+ ))
781
+ continue
782
+
783
+ repo_path = choice.machine.repo_path(completed.repo_name)
784
+ if repo_path is None:
785
+ logger.warning(
786
+ "dispatch_smoke: skipping machine %s for %s#%s — it has no "
787
+ "repo_paths entry for %r.",
788
+ choice.machine.name, completed.repo_name,
789
+ completed.issue_number, completed.repo_name,
790
+ )
791
+ attempts.append(SmokeAttempt(
792
+ machine_name=choice.machine.name,
793
+ reason=f"no repo_paths entry for {completed.repo_name!r}",
794
+ ))
795
+ continue
796
+
797
+ briefing = build_smoke_briefing(
798
+ repo_github=repo.github,
799
+ repo_name=repo.name,
800
+ branch=completed.branch,
801
+ issue_number=completed.issue_number,
802
+ issue_title=completed.issue_title,
803
+ smoke_command=smoke_command,
804
+ required_caps=required_caps,
805
+ timeout_seconds=smoke_cfg.timeout_seconds,
806
+ is_worker=choice.is_worker,
807
+ )
808
+
809
+ payload = {
810
+ "repo_name": completed.repo_name,
811
+ "repo_path": repo_path,
812
+ "issue_number": completed.issue_number,
813
+ "issue_title": f"[smoke] {completed.issue_title}",
814
+ "briefing": briefing,
815
+ "files_allowed": [],
816
+ "files_forbidden": [],
817
+ "pull_repos": [],
818
+ "type": "smoke",
819
+ "system_prompt": SMOKE_SYSTEM_PROMPT,
820
+ "review_target": completed.branch,
821
+ # #255: smoke checks out the worker's PR branch but the agent still
822
+ # consults `branch` as the integration base.
823
+ "branch": repo.default_branch or "main",
824
+ }
825
+
826
+ url = f"http://{choice.machine.host}:{AGENT_PORT}/assign"
827
+ try:
828
+ resp = client.post(url, json=payload, timeout=15)
829
+ resp.raise_for_status()
830
+ agent_response = resp.json()
831
+ except (httpx.HTTPError, httpx.TimeoutException) as exc:
832
+ # #1672: TRANSIENT — the machine is capable and its probe agreed,
833
+ # it just didn't answer. Try the next candidate, but if none is
834
+ # left the row stays re-dispatchable rather than blocked: a
835
+ # rebooting machine comes back, and poisoning the row would cost
836
+ # an operator a manual reset for something the next tick fixes.
837
+ logger.warning(
838
+ "dispatch_smoke: POST /assign to %s for %s#%s failed (%s) — "
839
+ "trying the next capability-matched machine (#1672).",
840
+ choice.machine.name, completed.repo_name,
841
+ completed.issue_number, exc,
842
+ )
843
+ attempts.append(SmokeAttempt(
844
+ machine_name=choice.machine.name,
845
+ reason=f"POST /assign failed — {exc}",
846
+ transient=True,
847
+ ))
848
+ continue
849
+
850
+ dispatched = (choice, briefing, agent_response)
851
+ break
852
+
853
+ if dispatched is None:
854
+ # Every capability-matched machine was tried and none could take it
855
+ # (or there were none at all). Report it where the board can show it,
856
+ # exactly once — never the silent 30 s spin of #1678.
857
+ _report_unroutable_smoke(completed, required_caps, attempts)
858
+ return None
859
+
860
+ choice, briefing, agent_response = dispatched
861
+
862
+ smoke_assignment = Assignment(
863
+ machine_name=choice.machine.name,
864
+ repo_name=completed.repo_name,
865
+ issue_number=completed.issue_number,
866
+ issue_title=f"[smoke] {completed.issue_title}",
867
+ files_allowed=[],
868
+ files_forbidden=[],
869
+ briefing=briefing,
870
+ assignment_id=agent_response.get("id") or uuid.uuid4().hex[:12],
871
+ status="running",
872
+ branch=completed.branch,
873
+ pr_url=completed.pr_url,
874
+ dispatched_at=now if now is not None else time.time(),
875
+ type="smoke",
876
+ review_target=completed.branch,
877
+ review_of_assignment_id=completed.assignment_id,
878
+ )
879
+ board.active.append(smoke_assignment)
880
+
881
+ from coord.state import record_dispatched_assignment
882
+ repo = config.repo(completed.repo_name)
883
+ if repo is not None:
884
+ record_dispatched_assignment(
885
+ assignment=smoke_assignment,
886
+ repo_github=repo.github,
887
+ )
888
+
889
+ # #1395/#1426: mark the PARENT work row's Test verdict "running" the
890
+ # moment the smoke assignment is dispatched — the same marker
891
+ # `coord test --running` set for the old local-subprocess path, so the
892
+ # board/TUI reads the Test box Active for the run's duration instead of
893
+ # idle/Pending. Without this, dispatching the Test stage as a real
894
+ # assignment would silently reopen the #1395 gap it was built to close:
895
+ # `test_state` would stay NULL from dispatch until the terminal verdict
896
+ # lands, indistinguishable from "not started yet".
897
+ #
898
+ # #1819: ...but NEVER over a terminal verdict. `running` is read as "no
899
+ # verdict yet" by every gate (#1395), so stamping it on a row that already
900
+ # says `passed`/`skipped` *un-satisfies a gate that was satisfied* — the
901
+ # merge entry drops out of the queue and the whole cycle restarts. That is
902
+ # the self-sustaining loop observed on #1797: verdict lands → merge
903
+ # enqueues → a re-dispatch a minute later clobbers the gate field back to
904
+ # `running` → the merge never fires → repeat. Dispatching a *fresh* run
905
+ # must never, by itself, retract the previous answer; the new verdict
906
+ # replaces the old one when it actually lands.
907
+ if completed.assignment_id is not None and completed.test_state not in (
908
+ "passed", "skipped", "failed",
909
+ ):
910
+ from coord.state import record_test_verdict
911
+
912
+ record_test_verdict(
913
+ assignment_id=completed.assignment_id,
914
+ test_state="running",
915
+ test_reason="dispatched: Test stage running (#1426)",
916
+ )
917
+ completed.test_state = "running"
918
+
919
+ return smoke_assignment
920
+
921
+
922
+ # ── Bulk dispatch (#1426) ────────────────────────────────────────────────────
923
+
924
+
925
+ def dispatch_pending_smoke(
926
+ board: Board,
927
+ config: Config,
928
+ *,
929
+ now: float | None = None,
930
+ ) -> list[Assignment]:
931
+ """Bulk Test-stage dispatch — the smoke analogue of
932
+ :func:`coord.review.dispatch_pending_reviews`.
933
+
934
+ Scans the FULL completed backlog on `board` (not just rows that just
935
+ transitioned this pass) for work-like completions with no test verdict
936
+ yet, and dispatches a smoke assignment for each eligible one via
937
+ :func:`dispatch_smoke` (which itself enforces `auto_queue`, the #459-style
938
+ dedupe via `has_active_followup`, and capability routing).
939
+
940
+ This is the single choke point both `reconcile()` (human-invoked `coord
941
+ resume`) and `coord notify` (the unattended 5-minute timer) route bulk
942
+ Test-stage dispatch through — mirroring `dispatch_pending_reviews`
943
+ exactly. Before this, `dispatch_smoke` was only ever called from
944
+ `reconcile()`'s per-item loop over that pass's newly-done rows, so a
945
+ thin-client/timer-only setup with nobody running `coord resume` never
946
+ dispatched the Test stage at all — the gap `drive-issue.sh` had to paper
947
+ over with a local `scripts/coord-test-runner.sh` subprocess (#1395).
948
+
949
+ Returns the list of smoke `Assignment`s actually dispatched. The caller
950
+ is responsible for persisting the board.
951
+ """
952
+ smoke_cfg = getattr(config, "smoke_tests", None)
953
+ if smoke_cfg is None or not smoke_cfg.auto_queue:
954
+ return []
955
+
956
+ from coord.state import get_issue_test_mode
957
+
958
+ dispatched: list[Assignment] = []
959
+ for completed in board.completed:
960
+ if completed.type not in WORK_LIKE_TYPES:
961
+ continue
962
+ if completed.status != "done":
963
+ continue
964
+ if completed.test_state is not None:
965
+ # Already has a verdict ("passed"/"failed"/"skipped"), or is
966
+ # "running" — someone (an interactive --smoke-of session, or a
967
+ # smoke assignment already in flight) is already handling it.
968
+ #
969
+ # #1672: this is also what makes the unroutable report fire ONCE.
970
+ # `dispatch_smoke` records `test_state="blocked"` when no
971
+ # capability-matched machine can take the stage, so the next tick
972
+ # lands here and skips instead of re-probing a fleet that is
973
+ # still broken and re-logging the identical refusal every 30 s
974
+ # (#1678). Clearing it (`coord diagnose <repo> <issue> --stage
975
+ # test --reset`) puts the row back in this scan.
976
+ continue
977
+
978
+ # #685: per-issue test-mode policy gates auto-smoke dispatch.
979
+ # test-mode:auto → headless smoke (auto-dispatch here).
980
+ # test-mode:smoke → skip; the TUI offers the interactive smoke agent.
981
+ # no label → no policy set → respect auto_queue (back-compat).
982
+ test_mode = get_issue_test_mode(completed.repo_name, completed.issue_number)
983
+ if test_mode == "smoke":
984
+ # #2024: say so. This skip is CORRECT (the policy asked for a
985
+ # human-attended Test stage) but it was completely silent, and
986
+ # silence here is what dead-ends an unattended `--fix-of` round:
987
+ # review dispatch is held until this row carries a passed/skipped
988
+ # verdict (`pipeline.test_precedes_review`), and under this policy
989
+ # no automatic component will ever produce one. Round 0 gets
990
+ # attended because a human is watching; rounds 1..N complete at 3am
991
+ # and sit there (vimcode#635: 25 min, then 160 min). The DRIVER now
992
+ # recognises the shape from the same label and escalates instead of
993
+ # counting (`coord.dead_end` shape 3) — this line is the daemon-side
994
+ # half of the same statement, for whoever reads the log first.
995
+ #
996
+ # Deliberately NOT widened to "dispatch anyway for fix rounds":
997
+ # that would silently override an explicit per-issue policy. The
998
+ # answer to "nobody will produce a verdict" is to SAY so, not to
999
+ # take the decision away from the operator who set the label.
1000
+ #
1001
+ # ONCE per row per process, not once per tick — #1678 is the
1002
+ # standing lesson that a refusal re-logged every 30 s against a
1003
+ # state that cannot change is its own kind of noise (this row can
1004
+ # sit here for hours by design). The daemon is long-lived, so the
1005
+ # set is the right lifetime; a restart re-states it, which is
1006
+ # exactly when an operator wants to hear it again.
1007
+ if completed.assignment_id in _TEST_MODE_SKIP_LOGGED:
1008
+ continue
1009
+ if completed.assignment_id:
1010
+ _TEST_MODE_SKIP_LOGGED.add(completed.assignment_id)
1011
+ logger.info(
1012
+ "dispatch_pending_smoke: skipping %s#%s row %s — issue is "
1013
+ "labelled `test-mode:smoke`, so the headless Test stage is off "
1014
+ "for it by policy (#685) and NOTHING automatic will record a "
1015
+ "verdict. Review dispatch stays held until one exists: `coord "
1016
+ "test %s --passed|--skipped`, or run the attended stage with "
1017
+ "`coord assign <machine> %s %s --smoke-of %s --interactive` "
1018
+ "(#2024).",
1019
+ completed.repo_name, completed.issue_number,
1020
+ completed.assignment_id, completed.assignment_id,
1021
+ completed.repo_name, completed.issue_number,
1022
+ completed.assignment_id,
1023
+ )
1024
+ continue
1025
+
1026
+ smoke = dispatch_smoke(completed, board, config, now=now)
1027
+ if smoke is not None:
1028
+ dispatched.append(smoke)
1029
+
1030
+ return dispatched