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/review.py ADDED
@@ -0,0 +1,3317 @@
1
+ """Adversarial code review — dispatch an independent reviewer when a worker finishes.
2
+
3
+ When `reviews.auto_dispatch` is enabled in `coordinator.yml`, completion of a
4
+ "work" assignment triggers a fresh `claude -p` session on a *different* machine
5
+ that reads the diff, runs tests, and posts a `gh pr review`. The reviewer has
6
+ zero shared context with the worker — that's the whole point.
7
+
8
+ Public entry points:
9
+
10
+ - `pick_reviewer_machine(...)` — choose an idle machine different from the
11
+ worker, with a single-machine fallback.
12
+ - `build_review_briefing(...)` — assemble the reviewer's prompt from the
13
+ repo's CLAUDE.md, the generic checklist, and any repo-specific overrides.
14
+ - `dispatch_review(...)` — full path: find/open PR, pick reviewer,
15
+ build briefing, send to agent server, add a review `Assignment` to the
16
+ board. Called from reconcile when a work assignment transitions to done.
17
+
18
+ Why a separate module: the work-dispatch path (`coord/dispatch.py`) is shaped
19
+ around `Proposal` objects from the brain. Reviews are triggered by completion
20
+ events on the board and target an existing PR, so they share little of that
21
+ plumbing — keeping them apart avoids twisting both shapes.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import difflib
27
+ import logging
28
+ import re
29
+ import time
30
+ import uuid
31
+ from typing import Iterable, Iterator
32
+ from dataclasses import dataclass
33
+ from pathlib import Path
34
+
35
+ import httpx
36
+
37
+ from coord import github_ops
38
+ from coord.config import Config, ReviewsConfig
39
+ from coord.dispatch import AGENT_PORT
40
+ from coord.models import (
41
+ CLOSES_ISSUE_TYPES,
42
+ SEALED_PATH_AUTHOR_TYPES,
43
+ WORK_LIKE_TYPES,
44
+ Assignment,
45
+ Board,
46
+ Machine,
47
+ )
48
+
49
+ log = logging.getLogger(__name__)
50
+
51
+
52
+ # ── Review output parsing ────────────────────────────────────────────────────
53
+
54
+ @dataclass
55
+ class ReviewFindings:
56
+ """Structured review output extracted from a reviewer worker log."""
57
+ verdict: str # "approve" or "request-changes"
58
+ body: str
59
+
60
+
61
+ # Matches the structured block the reviewer is instructed to emit at end of session.
62
+ # Allows optional leading/trailing whitespace and tolerates both LF and CRLF.
63
+ # Accepts canonical verdicts (approve / request-changes) and short aliases
64
+ # (PASS → approve, FAIL → request-changes) for workers that use the shorter form.
65
+ #
66
+ # The `REVIEW_BODY:` marker is OPTIONAL (#608): reviewers commonly emit the
67
+ # verdict line followed directly by Markdown findings and `END_REVIEW`, omitting
68
+ # the `REVIEW_BODY:` header. When it's absent the body is everything between the
69
+ # verdict line and `END_REVIEW`. `END_REVIEW` stays the required terminator, so a
70
+ # stray "REVIEW_VERDICT:" in prose (with no terminator) still won't match.
71
+ #
72
+ # Markdown decoration around the markers is TOLERATED (#1346): reviewers write
73
+ # prose, and a non-trivial fraction of them emit the block as Markdown —
74
+ # `**REVIEW_VERDICT: request-changes**` / `**REVIEW_BODY:**` / `## END_REVIEW`,
75
+ # or bold only the value (`REVIEW_VERDICT: **approve**`). The original pattern
76
+ # required the verdict token to be followed by nothing but whitespace and a
77
+ # newline, so a single pair of trailing asterisks made a complete, correct
78
+ # review with a valid `END_REVIEW` terminator parse as "no review at all" — the
79
+ # verdict was then silently dropped on every consumer of this regex (the #606
80
+ # transcript-floor, `notify`, the auto-loop) and the operator was left with a
81
+ # blank verdict prompt. `_MD` absorbs emphasis/code-span/heading punctuation and
82
+ # surrounding whitespace on either side of each marker; `END_REVIEW` remains the
83
+ # required terminator, so the tolerance does not widen what counts as a review.
84
+ _MD = r"[*_`#\s]*"
85
+ _REVIEW_BLOCK_RE = re.compile(
86
+ rf"REVIEW_VERDICT:{_MD}(approve|request-changes|pass|fail){_MD}[\r\n]+"
87
+ rf"(?:{_MD}REVIEW_BODY:{_MD}[\r\n]+)?(.*?)[\r\n]*{_MD}END_REVIEW",
88
+ re.DOTALL | re.IGNORECASE,
89
+ )
90
+
91
+ # Map short-form aliases to the canonical verdicts understood by post_pr_review.
92
+ _VERDICT_ALIASES: dict[str, str] = {
93
+ "pass": "approve",
94
+ "fail": "request-changes",
95
+ }
96
+
97
+
98
+ # ── #1348: strict-parse failure diagnostic ──────────────────────────────────
99
+ #
100
+ # When `_parse_review_text` / `parse_review_from_log` returns None, the caller
101
+ # cannot distinguish "text contains no review" from "text HAS a review marker
102
+ # but the strict parser rejected it" (e.g. bolded **REVIEW_VERDICT:** from the
103
+ # #1346 incident — a 6.2 KB request-changes review was silently dropped because
104
+ # the trailing `**` made the verdict group fail to match `_REVIEW_BLOCK_RE`).
105
+ #
106
+ # `detect_unparsed_review_marker` is a DIAGNOSTIC ONLY. It MUST NOT be wired
107
+ # into `_parse_review_text` / `parse_review_from_log`, and MUST NOT be used to
108
+ # auto-record a verdict. `END_REVIEW` remains the required terminator for a
109
+ # legitimate strict parse. Call it only AFTER the strict parse has already
110
+ # returned `None` and the calling floor has confirmed attribution.
111
+ #
112
+ # #1348 round 3: `_parse_review_text` now also runs
113
+ # `_decode_transcript_for_diagnostic` before matching `_REVIEW_BLOCK_RE` (see
114
+ # below) — the NDJSON DECODE is shared between the strict parser and this
115
+ # diagnostic, because a stream-json log's JSON-escaped newlines defeat
116
+ # `[\r\n]+` regardless of which regex runs against it. Sharing the decode is
117
+ # NOT the same as wiring the diagnostic's loose marker detection
118
+ # (`_REVIEW_MARKER_DETECT_RE`, which has no `END_REVIEW` requirement) into the
119
+ # strict path — that restraint above is unchanged. `_REVIEW_BLOCK_RE` and its
120
+ # mandatory `END_REVIEW` terminator are exactly as strict as before; a
121
+ # malformed block (e.g. bolded `**REVIEW_VERDICT:**`, the #1346 shape) still
122
+ # fails `_parse_review_text` and must go through this diagnostic, same as
123
+ # always.
124
+
125
+ # Detect a REVIEW_VERDICT: line even when `_REVIEW_BLOCK_RE` cannot extract
126
+ # a clean block. Captures everything on the marker line so the verdict word
127
+ # can be extracted after stripping Markdown decorators (e.g. "request-changes**"
128
+ # → "request-changes"). No word-boundary constraint before REVIEW_VERDICT: so
129
+ # this also fires on bolded lines like "**REVIEW_VERDICT: request-changes**".
130
+ _REVIEW_MARKER_DETECT_RE = re.compile(
131
+ r"REVIEW_VERDICT:[^\S\r\n]*([^\r\n]*)", # [^\S\r\n]* = horizontal whitespace only
132
+ re.IGNORECASE,
133
+ )
134
+
135
+ #: Cap on the excerpt captured by :func:`detect_unparsed_review_marker`. A few
136
+ #: KB is enough to show the operator the malformed block; transcripts can be
137
+ #: multi-MB and we must not hold the whole thing.
138
+ _DIAGNOSTIC_EXCERPT_MAX: int = 4096
139
+
140
+
141
+ @dataclass
142
+ class UnparsedReviewMarker:
143
+ """Diagnostic returned when a transcript contains a ``REVIEW_VERDICT:``
144
+ marker that the strict parser rejected (#1348).
145
+
146
+ A strict-parse failure on a transcript that clearly contains a review must
147
+ be loud, not silent — the operator cannot distinguish "reviewer forgot
148
+ ``END_REVIEW``" from "there is nothing to recover" when both paths return
149
+ ``None``. This carries the raw excerpt and detected verdict word so the
150
+ coordinator surface can:
151
+
152
+ * Warn the operator with a greppable ``log.warning`` naming the host and
153
+ transcript path.
154
+ * Print output clearly distinct from "no verdict reported" — two different
155
+ failures, two different fixes, and they must not look the same.
156
+ * Seed the editor with the recovered excerpt so the operator edits /
157
+ confirms what the reviewer wrote rather than typing from scratch.
158
+ * Default the verdict prompt to the detected word when it is canonical.
159
+
160
+ Attributes:
161
+ verdict_word: Lowercased, Markdown-stripped word from the
162
+ ``REVIEW_VERDICT:`` line, or ``None`` when the line was blank.
163
+ When it matches a canonical verdict (``approve`` /
164
+ ``request-changes``) or a known alias (``pass`` → approve,
165
+ ``fail`` → request-changes) the operator prompt defaults to it
166
+ instead of ``[s]kip``.
167
+ excerpt: Bounded slice starting from the ``REVIEW_VERDICT:`` line,
168
+ capped at :data:`_DIAGNOSTIC_EXCERPT_MAX` chars. For stream-json
169
+ logs this is the DECODED assistant text (real newlines, no JSON
170
+ scaffolding) — see :func:`_decode_transcript_for_diagnostic`.
171
+ Passed to ``_collect_review_body_via_editor`` as ``pre_body`` so
172
+ the operator edits the real review text.
173
+ transcript_path: Filesystem path of the transcript scanned. For the
174
+ remote-ssh path this is the path **on the remote host** (useful in
175
+ a ``ssh <host> cat <path>`` hint). ``None`` when unknown.
176
+ host: SSH hostname the transcript was fetched from, or ``None`` for a
177
+ local transcript.
178
+ """
179
+
180
+ verdict_word: str | None
181
+ excerpt: str
182
+ transcript_path: str | None = None
183
+ host: str | None = None
184
+
185
+
186
+ def _decode_transcript_for_diagnostic(text: str) -> str | None:
187
+ """Best-effort NDJSON (stream-json) decode of *text*, for #1348 round 2.
188
+
189
+ ``claude -p --output-format stream-json`` (and Claude Code's own session
190
+ transcripts under ``~/.claude/projects/``) emit one JSON object per line.
191
+ Any real newline inside an assistant message's text is therefore stored
192
+ on disk as the two-character escape ``\\n``, not a ``0x0A`` byte — valid
193
+ JSON, but useless to a regex that anchors on ``[\\r\\n]``. Worse, line 1 of
194
+ every agent log is a non-JSON ``# agent=... argv=...`` comment that embeds
195
+ the reviewer's own ``--system-prompt`` argument verbatim (also
196
+ newline-escaped onto one physical line by ``agent.py``'s
197
+ ``.replace("\\n", "\\\\n")``) — and that system prompt CONTAINS the literal
198
+ ``REVIEW_VERDICT: approve\\nREVIEW_BODY:\\n<your full review text in
199
+ markdown>\\nEND_REVIEW`` template the reviewer is instructed to fill in.
200
+ A one-shot ``text.replace("\\\\n", "\\n")`` over the whole raw log would
201
+ turn that template into something that matches — and since it comes
202
+ first in the file, a plain ``.search()`` would find it before the real
203
+ verdict emitted later by the assistant.
204
+
205
+ This decodes *text* using the same machinery as the strict parser
206
+ (:func:`parse_event` / ``_assistant_text`` in :mod:`coord.worker_events`):
207
+ only lines that parse as a JSON object contribute anything at all, and
208
+ only ``"assistant"``-typed events contribute text — the non-JSON
209
+ argv/header comment line (and any ``"system"``/``"user"`` event that
210
+ might otherwise echo the system-prompt template back) is silently
211
+ skipped, never concatenated into the decoded text. Returns the assistant
212
+ texts joined by real ``"\\n"``, in emission order — or ``None`` when
213
+ *text* contains no NDJSON at all (e.g. an old-format plain-text log, or
214
+ plain prose in a test fixture), so the caller falls back to treating
215
+ *text* as-is.
216
+
217
+ #1348 round 3: this is now shared by :func:`_parse_review_text` (the
218
+ STRICT parser) as well as :func:`detect_unparsed_review_marker` (the
219
+ loose diagnostic) — see the comment above `detect_unparsed_review_marker`
220
+ for why sharing the decode does not loosen the strict grammar.
221
+ `parse_review_from_log`'s ``is_stream_json`` branch already ran
222
+ individual lines through `parse_event`/`_assistant_text`, but its
223
+ plain-text fallback branch (taken whenever `is_stream_json`'s
224
+ first-non-comment-line heuristic misses, even on a log that IS valid
225
+ NDJSON) handed the strict regex raw, undecoded text — silently dropping
226
+ well-formed verdicts whose newlines were still JSON-escaped on disk.
227
+
228
+ #1710 inventory: kept as a direct ``coord.worker_events`` import, not
229
+ routed through ``provider.parse_log()``. This decodes the generic
230
+ Anthropic-Messages-API ``type: "assistant"`` / ``message.content``
231
+ envelope — a wire-format detail any Agent-SDK-shaped backend can share —
232
+ not claude-*business* semantics, and ``Provider``/``WorkerSummary`` have
233
+ no equivalent "raw assistant text" primitive to route through (adding
234
+ one would mean a new abstract ``Provider`` method, touching every
235
+ concrete provider including ``opencode.py`` — out of scope here). See
236
+ ``tests/test_provider_seam.py::TestReviewExtractionForASecondProvider``
237
+ for a second-provider log that this already decodes correctly today.
238
+ """
239
+ from coord.worker_events import _assistant_text, parse_event # noqa: PLC0415
240
+
241
+ texts: list[str] = []
242
+ saw_json = False
243
+ for line in text.splitlines():
244
+ event = parse_event(line)
245
+ if event is None:
246
+ continue
247
+ saw_json = True
248
+ if event.type == "assistant":
249
+ t = _assistant_text(event)
250
+ if t:
251
+ texts.append(t)
252
+ if not saw_json:
253
+ return None
254
+ return "\n".join(texts)
255
+
256
+
257
+ def detect_unparsed_review_marker(
258
+ text: str,
259
+ *,
260
+ transcript_path: str | None = None,
261
+ host: str | None = None,
262
+ ) -> UnparsedReviewMarker | None:
263
+ """Return diagnostic info when *text* contains a ``REVIEW_VERDICT:`` marker
264
+ that the strict parser would reject (#1348).
265
+
266
+ **Diagnostic only — never a parser.** Must be called AFTER
267
+ :func:`parse_review_from_log` has returned ``None``. Never wire this into
268
+ :func:`_parse_review_text` / :func:`parse_review_from_log`, and never use
269
+ its return value to auto-record a verdict. ``END_REVIEW`` is still the
270
+ required terminator for a legitimate strict parse.
271
+
272
+ *text* is decoded via :func:`_decode_transcript_for_diagnostic` before any
273
+ matching happens (#1348 round 2) — callers pass the RAW file/transcript
274
+ content, which for a stream-json log has every real newline inside the
275
+ assistant's text escaped as ``\\n`` (two characters), not ``0x0A``; the
276
+ strict-parser regexes below would never match that unless it's decoded
277
+ first, same as the strict parser itself does via
278
+ ``parse_event``/``_assistant_text``. When *text* isn't NDJSON at all (old
279
+ plain-text logs) it's matched as-is, unchanged from before.
280
+
281
+ Returns ``None`` when:
282
+
283
+ * No ``REVIEW_VERDICT:`` marker is present — *text* is genuinely not a
284
+ review; no false positives.
285
+ * The strict parse actually SUCCEEDED — a defensive guard so a caller that
286
+ forgets the "call after strict-parse" contract never double-reports.
287
+
288
+ Otherwise returns an :class:`UnparsedReviewMarker` with a bounded excerpt
289
+ (capped at :data:`_DIAGNOSTIC_EXCERPT_MAX` chars from the marker line) and
290
+ the detected verdict word with Markdown decorators stripped.
291
+ """
292
+ decoded = _decode_transcript_for_diagnostic(text)
293
+ search_text = text if decoded is None else decoded
294
+
295
+ matches = list(_REVIEW_MARKER_DETECT_RE.finditer(search_text))
296
+ if not matches:
297
+ return None
298
+ # Guard: strict parse succeeded on the SAME (decoded) text → return None,
299
+ # never double-report (#1348).
300
+ if _REVIEW_BLOCK_RE.search(search_text):
301
+ return None
302
+ # Take the LAST match, not the first (#1348 round 2). Decoding already
303
+ # excludes the non-JSON argv/header comment line that embeds the
304
+ # reviewer's own system-prompt TEMPLATE (see
305
+ # _decode_transcript_for_diagnostic), but this also protects the
306
+ # plain-text fallback path (decoded is None) where that template text
307
+ # could still precede the real verdict in the raw log, and it mirrors
308
+ # `_parse_review_text`'s own `matches[-1]`: a reviewer that second-guesses
309
+ # itself mid-session emits the real verdict last.
310
+ m = matches[-1]
311
+ # Extract and normalize the verdict word. Strip common Markdown decorators
312
+ # (*_`#) so e.g. "**request-changes**" normalises to "request-changes".
313
+ raw_line = m.group(1).strip()
314
+ clean_word = re.sub(r"[*_`#]+", "", raw_line).strip().lower()
315
+ verdict_word = clean_word if clean_word else None
316
+ # Bounded excerpt: start at the beginning of the REVIEW_VERDICT: line,
317
+ # capture up to _DIAGNOSTIC_EXCERPT_MAX chars so the operator can see
318
+ # the full verdict block without holding the whole (possibly multi-MB) log.
319
+ line_start = search_text.rfind("\n", 0, m.start()) + 1 # +1 skips the \n itself
320
+ start = line_start
321
+ end = min(len(search_text), start + _DIAGNOSTIC_EXCERPT_MAX)
322
+ excerpt = search_text[start:end]
323
+ return UnparsedReviewMarker(
324
+ verdict_word=verdict_word,
325
+ excerpt=excerpt,
326
+ transcript_path=transcript_path,
327
+ host=host,
328
+ )
329
+
330
+
331
+ # ── #1956 ask 3: END_REVIEW present, REVIEW_VERDICT absent entirely ─────────
332
+ #
333
+ # quadraui#533's live incident: the reviewer wrote a complete, thorough
334
+ # review and ended with `END_REVIEW`, but never wrote `REVIEW_VERDICT:`
335
+ # ANYWHERE — grepping the raw log found the string exactly once, inside the
336
+ # briefing's own instructions, never in an assistant message. That is a
337
+ # DIFFERENT failure signature from #1348's "marker present but malformed"
338
+ # (e.g. a bolded `**REVIEW_VERDICT:**`): here the model followed the *tail*
339
+ # of the required format while dropping the *header* that carries the data
340
+ # entirely, rather than attempting the header and getting the syntax wrong.
341
+ # `detect_unparsed_review_marker` cannot see this case at all — it only
342
+ # fires when a `REVIEW_VERDICT:` marker exists to detect.
343
+
344
+ @dataclass
345
+ class EndReviewWithoutVerdict:
346
+ """Diagnostic returned when *text* has an ``END_REVIEW`` terminator but
347
+ NO ``REVIEW_VERDICT:`` marker anywhere (#1956 ask 3).
348
+
349
+ Distinguishing this from a crashed/truncated session (which never
350
+ reaches ``END_REVIEW`` at all — nothing to recover) matters
351
+ operationally: a session that wrote ``END_REVIEW`` almost certainly
352
+ reached a real verdict, it just never printed the machine-readable
353
+ header for it. The verdict is very likely recoverable from ``excerpt``
354
+ (the prose immediately before ``END_REVIEW``) by an operator reading it
355
+ and re-running ``coord report-result --assignment <id> --verdict
356
+ <approve|request-changes> --verdict-source recovered --verdict-reason
357
+ "..." --body-file <extracted-review.md>``.
358
+
359
+ **Diagnostic only — never a parser, same contract as
360
+ :func:`detect_unparsed_review_marker`.** MUST NOT be used to
361
+ auto-record a verdict, and must only be called AFTER
362
+ :func:`parse_review_from_log` / :func:`_parse_review_text` has already
363
+ returned ``None`` for this same text.
364
+ """
365
+
366
+ excerpt: str
367
+ transcript_path: str | None = None
368
+ host: str | None = None
369
+
370
+
371
+ _END_REVIEW_DETECT_RE = re.compile(rf"{_MD}END_REVIEW{_MD}", re.IGNORECASE)
372
+
373
+
374
+ def detect_end_review_without_verdict(
375
+ text: str,
376
+ *,
377
+ transcript_path: str | None = None,
378
+ host: str | None = None,
379
+ ) -> EndReviewWithoutVerdict | None:
380
+ """Return diagnostic info when *text* has ``END_REVIEW`` but no
381
+ ``REVIEW_VERDICT:`` marker at all (#1956 ask 3).
382
+
383
+ *text* is decoded via :func:`_decode_transcript_for_diagnostic` first,
384
+ exactly like the strict parser and :func:`detect_unparsed_review_marker`
385
+ — see that function's docstring for why (stream-json newline-escaping,
386
+ and excluding the non-JSON argv/header comment line whose embedded
387
+ system-prompt TEMPLATE would otherwise spuriously contain both markers).
388
+
389
+ Returns ``None`` when:
390
+
391
+ * No ``END_REVIEW`` terminator is present at all — this is NOT the
392
+ #1956 signature; a session that never reached ``END_REVIEW`` more
393
+ likely crashed or was truncated, a different failure with a different
394
+ (probably unrecoverable) remedy.
395
+ * A ``REVIEW_VERDICT:`` marker IS present somewhere, even a malformed
396
+ one — that is :func:`detect_unparsed_review_marker`'s territory: the
397
+ header was ATTEMPTED (and rejected), not omitted entirely. The two
398
+ diagnostics are mutually exclusive by construction.
399
+ * The strict parse actually SUCCEEDED on this same text — defensive
400
+ guard mirroring :func:`detect_unparsed_review_marker`, so a caller
401
+ that forgets the "call after strict-parse" contract never
402
+ double-reports.
403
+
404
+ The excerpt is the text immediately BEFORE the LAST ``END_REVIEW`` line
405
+ (a reviewer that second-guesses itself mid-session writes the real one
406
+ last — same convention as :func:`_parse_review_text`'s ``matches[-1]``),
407
+ capped at :data:`_DIAGNOSTIC_EXCERPT_MAX` chars — there's no
408
+ ``REVIEW_VERDICT:``/``REVIEW_BODY:`` line to anchor on instead, since by
409
+ definition neither exists in *text*.
410
+ """
411
+ decoded = _decode_transcript_for_diagnostic(text)
412
+ search_text = text if decoded is None else decoded
413
+
414
+ if _REVIEW_MARKER_DETECT_RE.search(search_text):
415
+ return None # a REVIEW_VERDICT: marker exists — different diagnostic
416
+ matches = list(_END_REVIEW_DETECT_RE.finditer(search_text))
417
+ if not matches:
418
+ return None
419
+ if _REVIEW_BLOCK_RE.search(search_text):
420
+ return None # strict parse actually succeeded — never double-report
421
+
422
+ m = matches[-1]
423
+ end = m.start()
424
+ start = max(0, end - _DIAGNOSTIC_EXCERPT_MAX)
425
+ excerpt = search_text[start:end].strip()
426
+ return EndReviewWithoutVerdict(
427
+ excerpt=excerpt,
428
+ transcript_path=transcript_path,
429
+ host=host,
430
+ )
431
+
432
+
433
+ # ── #248: machine-readable review header ────────────────────────────────────
434
+ #
435
+ # When the coordinator posts a review comment back to GitHub it prepends a
436
+ # short HTML comment carrying the verdict in machine-readable form. The
437
+ # header is invisible to humans on the PR but lets the TUI render a verdict
438
+ # badge and lets the coordinator session check the verdict without reading
439
+ # the full prose body (which can be several KB).
440
+ #
441
+ # Format:
442
+ # <!-- coord:review verdict=request-changes blocking=2 nonblocking=5 \
443
+ # nits=2 reviewer=elitebook assignment=144ffa027a31 -->
444
+ #
445
+ # `verdict` is always present. Counts are best-effort: when the prose
446
+ # body uses recognisable section headings, the coordinator counts items
447
+ # under each; when it can't, those tokens are omitted (parser tolerates
448
+ # missing tokens).
449
+ _REVIEW_HEADER_RE = re.compile(
450
+ r"<!--\s*coord:review\s+([^>]+?)\s*-->",
451
+ re.IGNORECASE,
452
+ )
453
+
454
+ # Maps human section-heading keywords (case-insensitive) to the count
455
+ # category they belong to. The heuristic walks the prose body, splits
456
+ # on markdown headings, and bucketises bullet-list items under each.
457
+ _SECTION_KEYWORDS: dict[str, tuple[str, ...]] = {
458
+ "blocking": ("blocking", "required change", "must fix", "must-fix",
459
+ "changes required"),
460
+ "nonblocking": ("non-blocking", "non blocking", "concerns",
461
+ "should fix", "should-fix", "observations"),
462
+ "nits": ("nits", "nit:", "polish", "minor", "style"),
463
+ }
464
+
465
+ # Check buckets in order of keyword specificity so that "Non-blocking
466
+ # concerns" doesn't accidentally match the `blocking` bucket first.
467
+ _ORDERED_BUCKETS: tuple[str, ...] = ("nonblocking", "nits", "blocking")
468
+
469
+ # Phrases that make an otherwise-prose line in a *blocking* section readable
470
+ # as "the reviewer explicitly raised nothing here" (#1456). Only consulted
471
+ # for short lines — a long paragraph is prose the bullet counter cannot see,
472
+ # and therefore evidence that the section is NOT confirmed empty.
473
+ _NO_FINDINGS_PHRASES: tuple[str, ...] = (
474
+ "none", "n/a", "nothing", "no blocking", "no issues", "no required",
475
+ "no must-fix", "no must fix", "all clear",
476
+ )
477
+ _NO_FINDINGS_MAX_LEN = 60
478
+
479
+
480
+ def format_review_header(
481
+ *,
482
+ verdict: str,
483
+ reviewer_machine: str | None = None,
484
+ assignment_id: str | None = None,
485
+ blocking: int | None = None,
486
+ nonblocking: int | None = None,
487
+ nits: int | None = None,
488
+ ) -> str:
489
+ """Build the HTML-comment header that machines parse.
490
+
491
+ `verdict` is required; everything else is optional and only emitted
492
+ when provided. Returns a single line (no trailing newline).
493
+ """
494
+ parts = [f"verdict={verdict}"]
495
+ if blocking is not None:
496
+ parts.append(f"blocking={blocking}")
497
+ if nonblocking is not None:
498
+ parts.append(f"nonblocking={nonblocking}")
499
+ if nits is not None:
500
+ parts.append(f"nits={nits}")
501
+ if reviewer_machine:
502
+ parts.append(f"reviewer={reviewer_machine}")
503
+ if assignment_id:
504
+ parts.append(f"assignment={assignment_id}")
505
+ return f"<!-- coord:review {' '.join(parts)} -->"
506
+
507
+
508
+ def parse_review_header(body: str) -> dict[str, str | int] | None:
509
+ """Extract the coord:review header from *body*, or ``None`` when missing.
510
+
511
+ Numeric tokens (``blocking``, ``nonblocking``, ``nits``) are returned as
512
+ ``int``; everything else stays a ``str``. Tolerates extra whitespace
513
+ and unknown tokens.
514
+ """
515
+ m = _REVIEW_HEADER_RE.search(body)
516
+ if not m:
517
+ return None
518
+ out: dict[str, str | int] = {}
519
+ for token in m.group(1).split():
520
+ if "=" not in token:
521
+ continue
522
+ key, value = token.split("=", 1)
523
+ key = key.lower()
524
+ if key in ("blocking", "nonblocking", "nits"):
525
+ try:
526
+ out[key] = int(value)
527
+ except ValueError:
528
+ continue
529
+ else:
530
+ out[key] = value
531
+ return out if "verdict" in out else None
532
+
533
+
534
+ _BULLET_RE = re.compile(r"^\s*(?:[-*]|\d+\.)\s+\S")
535
+
536
+
537
+ def _bucket_for_heading(heading_line: str) -> str | None:
538
+ """Map a markdown heading line to a `_SECTION_KEYWORDS` bucket, or None."""
539
+ heading_text = heading_line.lstrip("#").strip().lower()
540
+ for bucket in _ORDERED_BUCKETS:
541
+ if any(kw in heading_text for kw in _SECTION_KEYWORDS[bucket]):
542
+ return bucket
543
+ return None
544
+
545
+
546
+ def _iter_review_sections(body: str) -> Iterator[tuple[str | None, list[str]]]:
547
+ """Yield ``(bucket, lines)`` for each markdown section of *body*.
548
+
549
+ *bucket* is the `_SECTION_KEYWORDS` bucket the section's heading maps to,
550
+ or ``None`` for the preamble (text before the first heading) and for
551
+ headings that match no keyword. *lines* are the right-stripped lines
552
+ under that heading, up to the next heading.
553
+
554
+ Shared by `estimate_review_counts` (which counts bullets) and
555
+ `blocking_findings_confirmed_absent` (which inspects prose), so the two
556
+ can never disagree about where a section starts and ends (#1456).
557
+ """
558
+ current: str | None = None
559
+ lines: list[str] = []
560
+ for raw in body.splitlines():
561
+ line = raw.rstrip()
562
+ if line.startswith("#"):
563
+ yield current, lines
564
+ current = _bucket_for_heading(line)
565
+ lines = []
566
+ continue
567
+ lines.append(line)
568
+ yield current, lines
569
+
570
+
571
+ def estimate_review_counts(
572
+ body: str,
573
+ ) -> tuple[int | None, int | None, int | None]:
574
+ """Best-effort count of (blocking, nonblocking, nits) bullets in *body*.
575
+
576
+ Walks markdown sections. A section is recognised when its heading
577
+ contains one of `_SECTION_KEYWORDS`; counts are the number of `- ` /
578
+ `* ` / `1. ` bullets directly under that section (until the next
579
+ heading). Returns ``(None, None, None)`` when no recognised
580
+ sections appear — the heuristic refuses to guess.
581
+
582
+ **``None`` means "could not determine", never "zero" (#1456).** A caller
583
+ that conflates the two turns a heuristic miss into a positive claim that
584
+ the reviewer raised nothing — which is how a `request-changes` verdict got
585
+ silently rewritten to `approve` on #1445. Callers deciding *anything*
586
+ about whether blocking findings exist must go through
587
+ `blocking_findings_confirmed_absent`, not compare these values themselves.
588
+ """
589
+ counts: dict[str, int | None] = {"blocking": None, "nonblocking": None, "nits": None}
590
+ for bucket, lines in _iter_review_sections(body):
591
+ if bucket is None:
592
+ continue
593
+ # Initialise the count for this bucket so it shows as 0 (not None)
594
+ # even when the section is empty.
595
+ counts[bucket] = (counts[bucket] or 0) + sum(
596
+ 1 for line in lines if _BULLET_RE.match(line)
597
+ )
598
+ return counts["blocking"], counts["nonblocking"], counts["nits"]
599
+
600
+
601
+ def _is_no_findings_line(text: str) -> bool:
602
+ """True when *text* reads as an explicit "nothing here" marker.
603
+
604
+ Deliberately narrow: only short lines qualify, so a real finding written
605
+ as prose ("No error path is handled when the worktree leaks, so …") is
606
+ never mistaken for an empty section.
607
+ """
608
+ stripped = text.strip(" \t*_`~>-–—.:!()[]")
609
+ if not stripped:
610
+ return True
611
+ if len(stripped) > _NO_FINDINGS_MAX_LEN:
612
+ return False
613
+ low = stripped.lower()
614
+ return any(phrase in low for phrase in _NO_FINDINGS_PHRASES)
615
+
616
+
617
+ def blocking_findings_confirmed_absent(body: str) -> bool:
618
+ """True only when *body* carries POSITIVE evidence of zero blocking findings.
619
+
620
+ This is the evidence standard for overriding a reviewer's verdict (#1456).
621
+ It is deliberately *fail-closed*: everything the heuristic cannot read
622
+ returns ``False``, i.e. "assume the reviewer meant what it said".
623
+
624
+ Returns ``True`` only when **all** of the following hold:
625
+
626
+ 1. A blocking section was actually located (``blocking is not None`` —
627
+ a heading matching `_SECTION_KEYWORDS["blocking"]`). A body with no
628
+ such heading yields ``None`` = *unknown*, which must never be read as
629
+ zero: that conflation is the #1456 defect, where a well-formed prose
630
+ `request-changes` on #1445 was rewritten to `approve` because the
631
+ *nits* bucket happened to parse as 0 while *blocking* parsed as None.
632
+ 2. That section contains no bullets (an explicit parsed zero).
633
+ 3. That section contains no substantive prose either — a reviewer who
634
+ writes blocking findings as paragraphs under "## Blocking" would
635
+ otherwise count as zero and fail open all the same. Short "None" /
636
+ "N/A" markers are allowed (that's the shape being looked for).
637
+ """
638
+ blocking, _nonblocking, _nits = estimate_review_counts(body)
639
+ if blocking is None or blocking != 0:
640
+ return False
641
+ for bucket, lines in _iter_review_sections(body):
642
+ if bucket != "blocking":
643
+ continue
644
+ for line in lines:
645
+ if not _is_no_findings_line(line.strip()):
646
+ return False
647
+ return True
648
+
649
+
650
+ def _parse_review_text(text: str) -> ReviewFindings | None:
651
+ """Extract the last ReviewFindings block from *text*, or None.
652
+
653
+ *text* is decoded via :func:`_decode_transcript_for_diagnostic` before
654
+ `_REVIEW_BLOCK_RE` runs (#1348 round 3): a `claude -p --output-format
655
+ stream-json` log stores every real newline inside the assistant's review
656
+ text as the literal two-character escape ``\\n`` on disk, which
657
+ `_REVIEW_BLOCK_RE`'s ``[\\r\\n]+`` can never match unless it's decoded
658
+ first — and `parse_review_from_log`'s plain-text fallback branch (taken
659
+ whenever `is_stream_json`'s first-non-comment-line heuristic misses, even
660
+ on a log that IS valid NDJSON) was handing this function raw, undecoded
661
+ text, silently dropping well-formed verdicts. Decoding also drops the
662
+ non-JSON `# argv=...` header line, whose embedded system-prompt template
663
+ would otherwise be a spurious match — hence `matches[-1]` (last match),
664
+ not `.search()`, same defense as `detect_unparsed_review_marker`. When
665
+ *text* isn't NDJSON at all (plain-text log, or an already-decoded single
666
+ assistant-message chunk from the stream-json per-event path below), the
667
+ decode is a no-op and *text* is matched as-is — this does NOT loosen the
668
+ grammar: `END_REVIEW` is still the mandatory terminator, and a malformed
669
+ block (e.g. bolded markers) still fails here exactly as before.
670
+ """
671
+ decoded = _decode_transcript_for_diagnostic(text)
672
+ search_text = text if decoded is None else decoded
673
+ matches = list(_REVIEW_BLOCK_RE.finditer(search_text))
674
+ if not matches:
675
+ return None
676
+ m = matches[-1]
677
+ verdict_raw = m.group(1).lower().strip()
678
+ # Normalize aliases: PASS → approve, FAIL → request-changes.
679
+ verdict = _VERDICT_ALIASES.get(verdict_raw, verdict_raw)
680
+ body = m.group(2).strip()
681
+ if verdict not in ("approve", "request-changes"):
682
+ return None
683
+ return ReviewFindings(verdict=verdict, body=body)
684
+
685
+
686
+ def _parse_review_from_lines(
687
+ lines: Iterable[str],
688
+ *,
689
+ stream_json: bool,
690
+ ) -> ReviewFindings | None:
691
+ """Shared core: extract review findings from log lines.
692
+
693
+ `lines` may be any iterable of strings (file iterator, ``str.splitlines()``,
694
+ ``httpx.Response.text.splitlines()``). Used by both `parse_review_from_log`
695
+ (local file) and `parse_review_from_agent` (HTTP fetch).
696
+
697
+ #1710 inventory: kept direct — see the identical note on
698
+ ``_decode_transcript_for_diagnostic`` above. `stream_json` here is the
699
+ generic "is this NDJSON at all" detection (`is_stream_json`'s
700
+ first-non-comment-line heuristic), not a claude-specific check.
701
+ """
702
+ from coord.worker_events import _assistant_text, parse_event # noqa: PLC0415
703
+
704
+ if not stream_json:
705
+ text = "\n".join(lines)
706
+ return _parse_review_text(text)
707
+
708
+ all_texts: list[str] = []
709
+ for line in lines:
710
+ event = parse_event(line.rstrip("\n"))
711
+ if event is None:
712
+ continue
713
+ if event.type == "assistant":
714
+ text = _assistant_text(event)
715
+ if text:
716
+ all_texts.append(text)
717
+ # Search from the end — the reviewer emits the verdict last.
718
+ for text in reversed(all_texts):
719
+ findings = _parse_review_text(text)
720
+ if findings is not None:
721
+ return findings
722
+ # Fallback: search the full concatenated text (handles multi-turn output).
723
+ return _parse_review_text("\n".join(all_texts))
724
+
725
+
726
+ def parse_review_from_log(log_path: str | Path) -> ReviewFindings | None:
727
+ """Parse review findings from a completed reviewer worker log.
728
+
729
+ Handles both stream-json (``--output-format stream-json``) and plain-text
730
+ log formats. Returns ``None`` if the file does not exist or contains no
731
+ structured review output.
732
+
733
+ #1710 inventory: ``is_stream_json`` here is a generic on-disk-shape sniff
734
+ (does the first non-comment line start with ``{``?), not claude-specific
735
+ parsing — kept direct rather than routed through a ``Provider``. See the
736
+ note on ``_decode_transcript_for_diagnostic``.
737
+ """
738
+ from coord.worker_events import is_stream_json # noqa: PLC0415
739
+
740
+ p = Path(log_path)
741
+ if not p.exists():
742
+ return None
743
+
744
+ if is_stream_json(p):
745
+ try:
746
+ with open(p, encoding="utf-8", errors="replace") as f:
747
+ return _parse_review_from_lines(f, stream_json=True)
748
+ except OSError:
749
+ return None
750
+ else:
751
+ try:
752
+ text = p.read_text(encoding="utf-8", errors="replace")
753
+ except OSError:
754
+ return None
755
+ return _parse_review_from_lines(text.splitlines(), stream_json=False)
756
+
757
+
758
+ def parse_review_from_agent(
759
+ host: str,
760
+ assignment_id: str,
761
+ port: int = 7433,
762
+ timeout: float = 15.0,
763
+ ) -> ReviewFindings | None:
764
+ """Fetch a reviewer worker's log via the agent's ``/logs/<id>`` endpoint
765
+ and parse the verdict.
766
+
767
+ Use this instead of `parse_review_from_log` when the worker ran on a
768
+ remote agent and the log file isn't on the coordinator's local
769
+ filesystem. Returns ``None`` on network failure, empty log, or no
770
+ structured review output.
771
+ """
772
+ import httpx # noqa: PLC0415
773
+
774
+ url = f"http://{host}:{port}/logs/{assignment_id}"
775
+ try:
776
+ resp = httpx.get(url, timeout=timeout)
777
+ resp.raise_for_status()
778
+ text = resp.text
779
+ except (httpx.HTTPError, httpx.TimeoutException):
780
+ return None
781
+ if not text:
782
+ return None
783
+ lines = text.splitlines()
784
+ # Detect format the same way `is_stream_json` does for files: the first
785
+ # non-comment, non-blank line starts with `{`.
786
+ stream_json = False
787
+ for line in lines:
788
+ stripped = line.strip()
789
+ if not stripped or line.startswith("#"):
790
+ continue
791
+ stream_json = stripped.startswith("{")
792
+ break
793
+ return _parse_review_from_lines(lines, stream_json=stream_json)
794
+
795
+
796
+ def fetch_review_findings_from_github(
797
+ repo_github: str,
798
+ issue_number: int,
799
+ assignment_id: str,
800
+ ) -> ReviewFindings | None:
801
+ """Recover a review's findings from the GitHub message bus.
802
+
803
+ Interactive (claude-pty) reviews don't produce a parseable log; their full
804
+ body is instead posted to the issue under a `coord:review-findings` marker
805
+ by `report-result --body-file` (via the issue_store seam). This reads those
806
+ comments back, so a fix worker on ANY machine can recover the findings even
807
+ when the review ran elsewhere and isn't in the local DB — GitHub is the one
808
+ store every machine already reaches. Returns ``None`` on any failure.
809
+
810
+ Routed through :func:`coord.github_ops.get_issue_comments` (#1483) rather
811
+ than shelling out to ``gh`` directly — ``github_ops`` is the single ``gh``
812
+ sink so a GitLab/bare-DB backend has one seam to sit beside.
813
+ """
814
+ import subprocess as _sp # noqa: PLC0415
815
+
816
+ from coord import github_ops # noqa: PLC0415
817
+ from coord.comments import extract_findings_block # noqa: PLC0415
818
+
819
+ if not (repo_github and assignment_id):
820
+ return None
821
+ try:
822
+ comments = github_ops.get_issue_comments(repo_github, issue_number)
823
+ except (RuntimeError, _sp.TimeoutExpired, OSError, ValueError):
824
+ return None
825
+ # Newest-first so a re-review's findings win over an earlier iteration's.
826
+ for c in reversed(comments):
827
+ hit = extract_findings_block(c.get("body", ""), assignment_id)
828
+ if hit is not None:
829
+ verdict, body = hit
830
+ return ReviewFindings(verdict=verdict or "request-changes", body=body)
831
+ return None
832
+
833
+
834
+ # ── Test-gate verdict output parsing (#1351) ────────────────────────────────
835
+ #
836
+ # A human-attended Test (smoke) session had, before this, exactly ONE channel
837
+ # for its verdict to reach the board: the agent successfully running `coord
838
+ # test --passed|--fail` INSIDE the session. If `coord` wasn't on the
839
+ # session's PATH, the command errored, or the agent simply never got to it,
840
+ # the verdict was gone — no structured block, no transcript floor, no
841
+ # fallback. This is the same gap #651 closed for reviews (the
842
+ # ``REVIEW_VERDICT:``/``REVIEW_BODY:``/``END_REVIEW`` block, recovered by the
843
+ # #606 transcript-floor even when ``coord report-result`` never ran), applied
844
+ # to the Test gate: ``TEST_VERDICT: passed|failed`` / ``TEST_REASON:`` /
845
+ # ``END_TEST``, parsed exactly as tolerantly as ``_REVIEW_BLOCK_RE`` parses a
846
+ # review — Markdown decoration around the markers (bold, code-spans,
847
+ # headings) is absorbed by the same ``_MD`` pattern, for the same #1346
848
+ # reason: a reviewer/tester that bolds ``**TEST_VERDICT:**`` must not have an
849
+ # otherwise-complete, correctly-terminated block silently discarded.
850
+
851
+ @dataclass
852
+ class TestVerdictFindings:
853
+ """Structured Test-gate verdict extracted from a smoke-session log."""
854
+ __test__ = False # not a pytest test class — the name just starts with "Test"
855
+ verdict: str # "passed" or "failed"
856
+ reason: str
857
+
858
+
859
+ _TEST_BLOCK_RE = re.compile(
860
+ rf"TEST_VERDICT:{_MD}(passed|failed|pass|fail){_MD}[\r\n]+"
861
+ rf"(?:{_MD}TEST_REASON:{_MD}[\r\n]+)?(.*?)[\r\n]*{_MD}END_TEST",
862
+ re.DOTALL | re.IGNORECASE,
863
+ )
864
+
865
+ # Map short-form aliases to the canonical verdicts, mirroring
866
+ # `_VERDICT_ALIASES` for the review block.
867
+ _TEST_VERDICT_ALIASES: dict[str, str] = {
868
+ "pass": "passed",
869
+ "fail": "failed",
870
+ }
871
+
872
+
873
+ def _parse_test_verdict_text(text: str) -> TestVerdictFindings | None:
874
+ """Extract the last TestVerdictFindings block from *text*, or None.
875
+
876
+ Mirrors :func:`_parse_review_text` exactly: *text* is decoded via
877
+ :func:`_decode_transcript_for_diagnostic` first (a stream-json log stores
878
+ every real newline inside the assistant's text as the two-character
879
+ escape ``\\n``, which ``[\\r\\n]+`` can never match undecoded), and the
880
+ LAST match wins (``matches[-1]``, not ``.search()``) so a tester that
881
+ second-guesses itself mid-session has its final verdict win, and so the
882
+ non-JSON argv/header comment line's embedded briefing template (which
883
+ contains this exact block as an example — see ``_smoke_report_reminder``
884
+ in ``coord/commands/dispatch_workers.py``) never wins over a real,
885
+ later verdict.
886
+ """
887
+ decoded = _decode_transcript_for_diagnostic(text)
888
+ search_text = text if decoded is None else decoded
889
+ matches = list(_TEST_BLOCK_RE.finditer(search_text))
890
+ if not matches:
891
+ return None
892
+ m = matches[-1]
893
+ verdict_raw = m.group(1).lower().strip()
894
+ verdict = _TEST_VERDICT_ALIASES.get(verdict_raw, verdict_raw)
895
+ reason = m.group(2).strip()
896
+ if verdict not in ("passed", "failed"):
897
+ return None
898
+ return TestVerdictFindings(verdict=verdict, reason=reason)
899
+
900
+
901
+ def _parse_test_verdict_from_lines(
902
+ lines: Iterable[str],
903
+ *,
904
+ stream_json: bool,
905
+ ) -> TestVerdictFindings | None:
906
+ """Shared core: extract a Test-gate verdict from log lines.
907
+
908
+ Mirrors :func:`_parse_review_from_lines` — used by both
909
+ :func:`parse_test_verdict_from_log` (local file) and the remote
910
+ transcript-floor's own file-fetch-then-parse path.
911
+ """
912
+ from coord.worker_events import _assistant_text, parse_event # noqa: PLC0415
913
+
914
+ if not stream_json:
915
+ text = "\n".join(lines)
916
+ return _parse_test_verdict_text(text)
917
+
918
+ all_texts: list[str] = []
919
+ for line in lines:
920
+ event = parse_event(line.rstrip("\n"))
921
+ if event is None:
922
+ continue
923
+ if event.type == "assistant":
924
+ text = _assistant_text(event)
925
+ if text:
926
+ all_texts.append(text)
927
+ # Search from the end — the tester emits the verdict last.
928
+ for text in reversed(all_texts):
929
+ findings = _parse_test_verdict_text(text)
930
+ if findings is not None:
931
+ return findings
932
+ # Fallback: search the full concatenated text (handles multi-turn output).
933
+ return _parse_test_verdict_text("\n".join(all_texts))
934
+
935
+
936
+ def parse_test_verdict_from_log(log_path: str | Path) -> TestVerdictFindings | None:
937
+ """Parse a Test-gate verdict from a completed smoke-session log (#1351).
938
+
939
+ Sibling to :func:`parse_review_from_log`: same stream-json/plain-text
940
+ on-disk-shape detection, same tolerant grammar. Returns ``None`` if the
941
+ file does not exist or contains no structured ``TEST_VERDICT:`` block.
942
+ """
943
+ from coord.worker_events import is_stream_json # noqa: PLC0415
944
+
945
+ p = Path(log_path)
946
+ if not p.exists():
947
+ return None
948
+
949
+ if is_stream_json(p):
950
+ try:
951
+ with open(p, encoding="utf-8", errors="replace") as f:
952
+ return _parse_test_verdict_from_lines(f, stream_json=True)
953
+ except OSError:
954
+ return None
955
+ else:
956
+ try:
957
+ text = p.read_text(encoding="utf-8", errors="replace")
958
+ except OSError:
959
+ return None
960
+ return _parse_test_verdict_from_lines(text.splitlines(), stream_json=False)
961
+
962
+
963
+ REVIEWER_SYSTEM_PROMPT = """\
964
+ You are an independent code reviewer dispatched by the coordinator. \
965
+ Your job is to find problems — do NOT rubber-stamp.
966
+
967
+ Rules:
968
+ - You have a fresh session. You have NO context from the worker who wrote \
969
+ this code. Treat the diff as if you're reading it for the first time.
970
+ - You are NOT allowed to run any `gh` commands. The coordinator posts the \
971
+ review on your behalf after your session ends.
972
+ - DO NOT run the project's test suite, build, or any other command — a human \
973
+ reviewer reads the diff, they don't run the suite, and on some projects (e.g. \
974
+ headless GUI apps) running it hangs the session. You MAY read project files \
975
+ for context. Build/test validation is the separate pre-merge smoke gate's job.
976
+ - You are NOT allowed to push commits or modify the PR's code. You only \
977
+ review.
978
+
979
+ How to review:
980
+ 1. Read the project's CLAUDE.md for project conventions.
981
+ 2. Read the PR diff using `git diff` or the briefing instructions.
982
+ 3. Check the diff against the review checklist in your briefing.
983
+ 4. For each finding, cite the specific file:line and the rule it violates.
984
+ 5. Before you end your session, record your verdict TWICE — belt and \
985
+ braces, neither step substitutes for the other:
986
+
987
+ a. PRIMARY (do this FIRST, if you can): if the environment variable \
988
+ `COORD_ASSIGNMENT_ID` is set, write your full findings to a file and run:
989
+ `coord report-result --assignment "$COORD_ASSIGNMENT_ID" --status done \
990
+ --verdict approve|request-changes --body-file <file>`
991
+ This writes your verdict straight to the coordinator's board — the \
992
+ authoritative record. Check the command's output for a confirmation; if it \
993
+ errors, or `COORD_ASSIGNMENT_ID` is unset, or `coord` is not on your PATH, \
994
+ say so plainly and fall through to step b anyway — it is REQUIRED \
995
+ regardless.
996
+ b. BACKUP (always do this too, even after a successful step a): at the \
997
+ END of your session, output your verdict in this exact format:
998
+
999
+ REVIEW_VERDICT: approve
1000
+ REVIEW_BODY:
1001
+ <your full review text in markdown>
1002
+ END_REVIEW
1003
+
1004
+ Or for requesting changes:
1005
+
1006
+ REVIEW_VERDICT: request-changes
1007
+ REVIEW_BODY:
1008
+ <your full review text in markdown>
1009
+ END_REVIEW
1010
+
1011
+ This printed block is the PATH-independent fallback recovered from your \
1012
+ session transcript even when step a never ran or failed — it is REQUIRED \
1013
+ every time, not just when `coord report-result` is unavailable.
1014
+
1015
+ Structure the markdown body with these three headings, in this order, ALWAYS \
1016
+ all three even when a section is empty — write `None` under a heading with \
1017
+ nothing in it, and write every finding as a `- ` bullet, never as a bare \
1018
+ paragraph:
1019
+
1020
+ ## Blocking findings
1021
+ ## Non-blocking concerns
1022
+ ## Nits
1023
+
1024
+ The coordinator reads these sections to decide whether a `request-changes` is \
1025
+ a real must-fix or advisory-only. It is deliberately conservative: a body it \
1026
+ cannot read is treated as blocking, so an omitted or prose-only \
1027
+ `## Blocking findings` section costs a full extra fix+review round even when \
1028
+ you raised nothing blocking. Put blocking findings ONLY under \
1029
+ `## Blocking findings` — anything you would still merge over belongs in one of \
1030
+ the other two sections.
1031
+
1032
+ `END_REVIEW` is a HARD REQUIREMENT, not a formatting flourish: the coordinator \
1033
+ only records a verdict when it sees that exact line, so a review that is \
1034
+ otherwise complete and correct but stops one line early is discarded in its \
1035
+ entirety — the same as if you had never reviewed at all. The LAST LINE of your \
1036
+ LAST MESSAGE must be exactly `END_REVIEW` on its own line, with nothing after \
1037
+ it. Do not stop as soon as your review prose feels finished; write the \
1038
+ `END_REVIEW` line and then stop. Before you end your session, re-read your \
1039
+ final message and confirm its last line is `END_REVIEW`.
1040
+
1041
+ If the diff is clean, approve — but be thorough first.\
1042
+ """
1043
+
1044
+
1045
+ # ── Machine selection ───────────────────────────────────────────────────────
1046
+
1047
+ @dataclass
1048
+ class ReviewerChoice:
1049
+ machine: Machine
1050
+ same_as_worker: bool
1051
+ rationale: str
1052
+
1053
+
1054
+ def pick_reviewer_machine(
1055
+ worker_machine_name: str,
1056
+ repo_name: str,
1057
+ board: Board,
1058
+ config: Config,
1059
+ ) -> ReviewerChoice | None:
1060
+ """Pick a reviewer machine — different from the worker if possible.
1061
+
1062
+ Independence comes from a fresh session with no shared context, not from
1063
+ physical machine separation, so a same-machine fallback still produces a
1064
+ useful review — but we warn the caller via `same_as_worker=True`.
1065
+
1066
+ Returns None when no machine can handle this repo.
1067
+ """
1068
+ from coord.machine_pause import paused_set
1069
+ paused = paused_set(config.machines)
1070
+ candidates = [
1071
+ m for m in config.machines
1072
+ if m.can_work_on(repo_name) and m.name not in paused
1073
+ ]
1074
+ if not candidates:
1075
+ return None
1076
+
1077
+ busy = {a.machine_name for a in board.active if a.status in ("pending", "running")}
1078
+
1079
+ different = [
1080
+ m for m in candidates
1081
+ if m.name != worker_machine_name and m.name not in busy
1082
+ ]
1083
+ if different:
1084
+ return ReviewerChoice(
1085
+ machine=different[0],
1086
+ same_as_worker=False,
1087
+ rationale=(
1088
+ f"chose {different[0].name} — different machine from worker "
1089
+ f"({worker_machine_name})"
1090
+ ),
1091
+ )
1092
+
1093
+ # Fallback 1: any different machine, even if busy.
1094
+ different_busy = [m for m in candidates if m.name != worker_machine_name]
1095
+ if different_busy:
1096
+ return ReviewerChoice(
1097
+ machine=different_busy[0],
1098
+ same_as_worker=False,
1099
+ rationale=(
1100
+ f"chose {different_busy[0].name} — different machine from "
1101
+ f"worker, currently busy (review will queue)"
1102
+ ),
1103
+ )
1104
+
1105
+ # Fallback 2: same machine (only one available). Reduced independence.
1106
+ same = next((m for m in candidates if m.name == worker_machine_name), None)
1107
+ if same is None:
1108
+ return None
1109
+ return ReviewerChoice(
1110
+ machine=same,
1111
+ same_as_worker=True,
1112
+ rationale=(
1113
+ f"only {worker_machine_name} can handle {repo_name}; using same "
1114
+ f"machine — reviewer session is fresh but not on separate hardware"
1115
+ ),
1116
+ )
1117
+
1118
+
1119
+ def _ranked_reviewer_candidates(
1120
+ worker_machine_name: str,
1121
+ repo_name: str,
1122
+ board: Board,
1123
+ config: Config,
1124
+ ) -> list[tuple[Machine, bool]]:
1125
+ """Return **all** candidate reviewer machines in priority order.
1126
+
1127
+ Each element is ``(machine, same_as_worker)``. Priority mirrors
1128
+ ``pick_reviewer_machine``:
1129
+
1130
+ 1. Different from the worker, currently **idle** — best independence, no
1131
+ queue delay.
1132
+ 2. Different from the worker, currently **busy** — independence preserved;
1133
+ the review will queue on that agent.
1134
+ 3. **Same** machine as the worker — last resort; fresh session but no
1135
+ hardware separation.
1136
+
1137
+ Returns an empty list when no configured machine handles *repo_name*.
1138
+ Used by ``dispatch_review`` to iterate candidates instead of committing to
1139
+ a single pick, so a rejected agent (e.g. a 400 from config drift) can
1140
+ fall through to the next rather than silently failing (#904).
1141
+ """
1142
+ from coord.machine_pause import paused_set # noqa: PLC0415
1143
+
1144
+ paused = paused_set(config.machines)
1145
+ candidates = [
1146
+ m for m in config.machines
1147
+ if m.can_work_on(repo_name) and m.name not in paused
1148
+ ]
1149
+ if not candidates:
1150
+ return []
1151
+
1152
+ busy = {a.machine_name for a in board.active if a.status in ("pending", "running")}
1153
+
1154
+ result: list[tuple[Machine, bool]] = []
1155
+ for m in candidates:
1156
+ if m.name != worker_machine_name and m.name not in busy:
1157
+ result.append((m, False)) # different + idle
1158
+ for m in candidates:
1159
+ if m.name != worker_machine_name and m.name in busy:
1160
+ result.append((m, False)) # different + busy (will queue)
1161
+ for m in candidates:
1162
+ if m.name == worker_machine_name:
1163
+ result.append((m, True)) # same machine — last resort
1164
+ return result
1165
+
1166
+
1167
+ # ── Briefing construction ───────────────────────────────────────────────────
1168
+
1169
+ def _read_repo_claude_md(repo_path: Path) -> str | None:
1170
+ """Return the contents of CLAUDE.md at the repo root, or None.
1171
+
1172
+ The coordinator runs on the machine that dispatches; the reviewer runs on
1173
+ its own machine and will re-read CLAUDE.md there. We embed the content
1174
+ here so the briefing is self-contained — if the reviewer's checkout is
1175
+ behind, the worker's diff still gets reviewed against the rules the
1176
+ coordinator thought were current.
1177
+ """
1178
+ candidate = repo_path / "CLAUDE.md"
1179
+ if not candidate.exists():
1180
+ return None
1181
+ try:
1182
+ return candidate.read_text()
1183
+ except OSError:
1184
+ return None
1185
+
1186
+
1187
+ def _diff_file_paths(diff_text: str) -> list[str]:
1188
+ """Return every file path touched by *diff_text*, deduped, order-preserving.
1189
+
1190
+ Scans unified-diff file-header lines (``diff --git a/X b/Y``, ``---
1191
+ a/X``, ``+++ b/X``) — cheap, dependency-free (#944 sealing v1) ahead of a
1192
+ real diff parser. Shared by :func:`_diff_touched_sealed_paths` (which
1193
+ sealed prefixes) and :func:`_diff_paths_outside_sealed` (which actual
1194
+ paths, #1175 — the test-author/mock-author inverse tamper check needs
1195
+ the offending files, not just which prefixes matched).
1196
+ """
1197
+ paths: list[str] = []
1198
+ seen: set[str] = set()
1199
+ for line in diff_text.splitlines():
1200
+ candidates: list[str] = []
1201
+ if line.startswith("diff --git "):
1202
+ for part in line.split()[1:]:
1203
+ if part.startswith("a/") or part.startswith("b/"):
1204
+ candidates.append(part[2:])
1205
+ elif line.startswith("--- a/"):
1206
+ candidates.append(line[len("--- a/"):])
1207
+ elif line.startswith("+++ b/"):
1208
+ candidates.append(line[len("+++ b/"):])
1209
+ for c in candidates:
1210
+ if c not in seen:
1211
+ seen.add(c)
1212
+ paths.append(c)
1213
+ return paths
1214
+
1215
+
1216
+ def _path_is_sealed(path: str, sealed: str) -> bool:
1217
+ """Does *path* fall under the sealed entry *sealed*?
1218
+
1219
+ #1552: the sealed set is no longer uniformly directory prefixes. An
1220
+ entry ending in ``/`` (``tests/acceptance/``) is a prefix — everything
1221
+ beneath it is sealed. Anything else is a driver ``entrypoint:`` naming
1222
+ exactly one FILE (``tui/tests/acceptance.rs``), and must match exactly:
1223
+ a bare ``startswith`` would also swallow ``tui/tests/acceptance.rs.bak``
1224
+ and ``tui/tests/acceptance.rs.orig``, quietly widening the one narrow
1225
+ allowance a `test-author` gets.
1226
+ """
1227
+ if sealed.endswith("/"):
1228
+ return path.startswith(sealed)
1229
+ return path == sealed
1230
+
1231
+
1232
+ def _diff_touched_sealed_paths(diff_text: str, sealed_paths: list[str]) -> list[str]:
1233
+ """Return the sealed path prefixes actually touched by *diff_text*.
1234
+
1235
+ Cheap, dependency-free tamper detection (#944 sealing v1). Pure function,
1236
+ easy to test.
1237
+ """
1238
+ touched: set[str] = set()
1239
+ for c in _diff_file_paths(diff_text):
1240
+ for sealed in sealed_paths:
1241
+ if _path_is_sealed(c, sealed):
1242
+ touched.add(sealed)
1243
+ return sorted(touched)
1244
+
1245
+
1246
+ def _diff_paths_outside_sealed(diff_text: str, sealed_paths: list[str]) -> list[str]:
1247
+ """Return diff file paths that fall OUTSIDE every sealed prefix.
1248
+
1249
+ #1175: for a ``type="test-author"``/``"mock-author"`` PR, writing under
1250
+ *sealed_paths* (``tests/acceptance/ms-NN/**`` plus, #1552, each driver's
1251
+ declared ``entrypoint:``) is the assignment's entire job, not a
1252
+ violation — the oracle-tamper rule inverts for these types, so the
1253
+ reviewer needs the paths touched OUTSIDE the sealed prefix instead of
1254
+ the ones inside it.
1255
+ """
1256
+ return sorted(
1257
+ p for p in _diff_file_paths(diff_text)
1258
+ if not any(_path_is_sealed(p, sealed) for sealed in sealed_paths)
1259
+ )
1260
+
1261
+
1262
+ def build_review_briefing(
1263
+ *,
1264
+ pr_number: int | None,
1265
+ pr_url: str | None,
1266
+ repo_github: str,
1267
+ repo_name: str,
1268
+ issue_number: int,
1269
+ issue_title: str,
1270
+ issue_body: str,
1271
+ branch: str | None,
1272
+ worker_machine: str,
1273
+ same_as_worker: bool,
1274
+ reviews_cfg: ReviewsConfig,
1275
+ repo_claude_md: str | None,
1276
+ default_branch: str = "main",
1277
+ review_iteration: int = 0,
1278
+ diff_text: str | None = None,
1279
+ sealed_paths: list[str] | None = None,
1280
+ sealed_entrypoints: list[str] | None = None,
1281
+ assignment_type: str = "work",
1282
+ provider_same_as_worker: bool = False,
1283
+ review_provider: str | None = None,
1284
+ ) -> str:
1285
+ """Assemble the reviewer's prompt. Pure function — easy to test.
1286
+
1287
+ *provider_same_as_worker* (#1811) is True when the review's resolved
1288
+ provider (``review_provider``) is the same name as the worker's own
1289
+ resolved provider — e.g. a repo pinned to ``opencode`` with no
1290
+ ``reviews.provider`` override, so the review inherits it too. Provider
1291
+ co-location is a *larger* loss of independence than machine co-location
1292
+ (``same_as_worker`` below): a fresh session removes shared context, but
1293
+ not shared blind spots. When True, a note is appended mirroring
1294
+ ``same_as_worker``'s — the reviewer is told to be extra rigorous for the
1295
+ same reason a same-machine reviewer is.
1296
+
1297
+ When *review_iteration* > 0 the work is a re-review of a fix worker's
1298
+ commits (a prior round requested changes). The "What to do" section is
1299
+ then scoped to the fix delta instead of the whole PR (#476): re-reviewing
1300
+ the entire PR every round repeats work, wastes tokens, and surfaces fresh
1301
+ non-blocking nits that bounce an already-correct PR into another fix cycle.
1302
+
1303
+ When *diff_text* is non-empty (#612) the coordinator has already computed
1304
+ the merge-base (three-dot) diff and it is embedded verbatim, so the
1305
+ reviewer reviews exactly the branch's own changes — there is nothing for it
1306
+ to get wrong. A reviewer that deviates to a two-dot/stale-base diff would
1307
+ surface code merged to the default branch *after* the branch was cut as
1308
+ spurious deletions and flag it as a regression (#546). When *diff_text* is
1309
+ None the existing three-dot ``git diff`` fallback instructions stand.
1310
+
1311
+ *sealed_paths* (#944, docs/ORACLE_LOOP.md sealing v1) lists the paths the
1312
+ worker must never touch — ``tests/acceptance/`` plus, since #1552, each
1313
+ acceptance driver's declared ``entrypoint:``, derived from the driver
1314
+ definition by :meth:`coord.config.AcceptanceConfig.sealed_paths`. When
1315
+ non-empty a reviewer instruction is always appended; if *diff_text* is
1316
+ also given and actually touches one of the paths, a blocking "TAMPER
1317
+ DETECTED" banner is prepended instead of a soft reminder — this is the
1318
+ "reviewer flags any diff that touches tests/acceptance/**"
1319
+ tamper-detection policy.
1320
+
1321
+ *sealed_entrypoints* (#1552) is the subset of *sealed_paths* that are
1322
+ driver entry points rather than the sealed tree itself. They get a
1323
+ narrower rule in the author branch below: a slice file is invisible to
1324
+ an entry-point-linked runner (``cargo test --test acceptance``) until
1325
+ something registers it in the crate root, so a ``test-author`` ADDING a
1326
+ registration line there is doing its job, while rewriting or deleting
1327
+ what is already in the file is still tamper.
1328
+
1329
+ *assignment_type* (#1175) gates which direction that rule runs. For
1330
+ :data:`coord.models.SEALED_PATH_AUTHOR_TYPES` (``"test-author"``,
1331
+ ``"mock-author"``) — whose entire job IS to write under *sealed_paths* —
1332
+ the rule inverts: mandatory ``request-changes`` fires only when the diff
1333
+ touches something OUTSIDE *sealed_paths*; touching only the sealed area
1334
+ is expected and non-blocking. Every other type (default ``"work"``) keeps
1335
+ the original rule unchanged: any touch to *sealed_paths* is mandatory
1336
+ ``request-changes``.
1337
+ """
1338
+
1339
+ lines: list[str] = []
1340
+ lines.append(f"# Review assignment: {repo_github} PR #{pr_number}")
1341
+ lines.append("")
1342
+ lines.append(f"You are reviewing the worker's work on issue #{issue_number}: {issue_title}")
1343
+ lines.append("")
1344
+ lines.append("## Context")
1345
+ lines.append(f"- Repo: {repo_github} (local name: {repo_name})")
1346
+ lines.append(f"- Branch: {branch or '(unknown)'}")
1347
+ if pr_url:
1348
+ lines.append(f"- PR URL: {pr_url}")
1349
+ lines.append(f"- Worker machine: {worker_machine}")
1350
+ if same_as_worker:
1351
+ lines.append(
1352
+ "- NOTE: only one machine is configured for this repo, so you are "
1353
+ "running on the same machine as the worker. Your session is still "
1354
+ "fresh (no shared context), but be extra rigorous."
1355
+ )
1356
+ if provider_same_as_worker:
1357
+ lines.append(
1358
+ f"- NOTE: this review is running on the same provider "
1359
+ f"({review_provider or 'claude'}) as the worker's own dispatch. "
1360
+ "Your session is still fresh (no shared context), but a shared "
1361
+ "model family means shared blind spots — be extra rigorous."
1362
+ )
1363
+ lines.append("")
1364
+
1365
+ lines.append("## Issue")
1366
+ lines.append(f"**#{issue_number}: {issue_title}**")
1367
+ if issue_body.strip():
1368
+ lines.append("")
1369
+ lines.append(issue_body.strip())
1370
+ lines.append("")
1371
+
1372
+ if repo_claude_md:
1373
+ lines.append("## Project rules (from CLAUDE.md)")
1374
+ lines.append("")
1375
+ lines.append(repo_claude_md.strip())
1376
+ lines.append("")
1377
+
1378
+ lines.append("## Review checklist")
1379
+ lines.append("")
1380
+ if reviews_cfg.checklist:
1381
+ for item in reviews_cfg.checklist:
1382
+ lines.append(f"- {item}")
1383
+ else:
1384
+ lines.append("- Does the diff actually solve issue #" + str(issue_number) + "?")
1385
+ lines.append("- Do tests pass? Any regressions?")
1386
+ lines.append("- Are there CLAUDE.md violations?")
1387
+ lines.append("- Did the worker stay within the assigned file scope?")
1388
+ lines.append("- Any security issues (injection, auth bypass, credential exposure)?")
1389
+
1390
+ overrides = reviews_cfg.repo_overrides.get(repo_name, [])
1391
+ if overrides:
1392
+ lines.append("")
1393
+ lines.append(f"### Repo-specific focus ({repo_name})")
1394
+ for item in overrides:
1395
+ lines.append(f"- {item}")
1396
+
1397
+ if reviews_cfg.reviewer_prompt.strip():
1398
+ lines.append("")
1399
+ lines.append("## Additional instructions")
1400
+ lines.append(reviews_cfg.reviewer_prompt.strip())
1401
+
1402
+ if diff_text and diff_text.strip():
1403
+ # #612: embed the merge-base (three-dot) diff verbatim so the reviewer
1404
+ # has nothing to compute — a two-dot/stale-base diff would show
1405
+ # already-merged commits as spurious deletions (#546).
1406
+ lines.append("")
1407
+ lines.append("## Diff to review (authoritative)")
1408
+ lines.append(
1409
+ "This is the merge-base (three-dot) diff — exactly the branch's own "
1410
+ "changes, nothing else. Review THIS. Do NOT compute your own diff; a "
1411
+ "two-dot or stale-base diff would show unrelated already-merged "
1412
+ "commits as spurious deletions."
1413
+ )
1414
+ lines.append("")
1415
+ lines.append("```diff")
1416
+ lines.append(diff_text.strip())
1417
+ lines.append("```")
1418
+
1419
+ if sealed_paths:
1420
+ lines.append("")
1421
+ if assignment_type in SEALED_PATH_AUTHOR_TYPES:
1422
+ # #1175: for test-author/mock-author, writing under sealed_paths
1423
+ # IS the job — the tamper rule inverts. Flag only a touch OUTSIDE
1424
+ # the sealed area; a diff confined to it is expected, not tamper.
1425
+ outside = _diff_paths_outside_sealed(diff_text, sealed_paths) if diff_text else []
1426
+ if outside:
1427
+ lines.append("## \U0001f6a8 SEALED ORACLE SCOPE VIOLATION")
1428
+ lines.append("")
1429
+ lines.append(
1430
+ f"This is a `type={assignment_type!r}` assignment — its entire "
1431
+ "job is authoring under this repo's sealed acceptance oracle "
1432
+ + ", ".join(f"`{p}`" for p in sealed_paths)
1433
+ + " (docs/ORACLE_LOOP.md), so touching those paths is expected "
1434
+ "and NOT tamper. But this diff ALSO touches path(s) OUTSIDE the "
1435
+ "sealed area: " + ", ".join(f"`{p}`" for p in outside) + ". "
1436
+ "**request-changes is mandatory here**, regardless of anything "
1437
+ "else in this diff — this assignment type must touch ONLY the "
1438
+ "sealed acceptance tree and nothing else."
1439
+ )
1440
+ else:
1441
+ lines.append(
1442
+ f"## Sealed paths (expected writes for type={assignment_type!r})"
1443
+ )
1444
+ lines.append("")
1445
+ lines.append(
1446
+ f"This is a `type={assignment_type!r}` assignment: writing under "
1447
+ + ", ".join(f"`{p}`" for p in sealed_paths)
1448
+ + " (docs/ORACLE_LOOP.md) is its entire job, not a tamper "
1449
+ "violation. Do **not** request-changes solely because this "
1450
+ "diff touches the sealed acceptance tree — only flag it if the "
1451
+ "diff also touches anything outside that tree."
1452
+ )
1453
+ if sealed_entrypoints:
1454
+ # #1552: the entry point is sealed, but the allowance on it is
1455
+ # narrower than on the suite dir — additive registration only.
1456
+ lines.append("")
1457
+ lines.append("### Driver entry point — additive registration only")
1458
+ lines.append("")
1459
+ lines.append(
1460
+ ", ".join(f"`{p}`" for p in sealed_entrypoints)
1461
+ + " is this repo's acceptance driver **entry point** "
1462
+ "(declared as `entrypoint:` on the driver in "
1463
+ "coordinator.yml, #1552) — the crate root the runner "
1464
+ "links slices through, and part of the sealed oracle for "
1465
+ "that reason. A slice file under the sealed tree is "
1466
+ "INVISIBLE to the runner until it is registered there "
1467
+ "(e.g. an `include!(...)` line), so a slice with no "
1468
+ "registration line is dead code that never executes."
1469
+ )
1470
+ lines.append("")
1471
+ lines.append(
1472
+ "- **Expected, do NOT flag:** this diff ADDS registration "
1473
+ "lines for its own new slice files."
1474
+ )
1475
+ lines.append(
1476
+ "- **request-changes:** the entry-point hunk does anything "
1477
+ "more than that — rewriting, reordering, or deleting "
1478
+ "existing lines, registering files that are not part of "
1479
+ "this slice, or any other edit to that file."
1480
+ )
1481
+ lines.append(
1482
+ "- **request-changes:** the diff adds slice files under the "
1483
+ "sealed tree but does NOT register them in the entry "
1484
+ "point. Deleting the registration line to make a diff look "
1485
+ "clean is not a fix — it ships a suite that silently "
1486
+ "contributes zero tests."
1487
+ )
1488
+ else:
1489
+ touched = _diff_touched_sealed_paths(diff_text, sealed_paths) if diff_text else []
1490
+ if touched:
1491
+ lines.append("## \U0001f6a8 SEALED ORACLE TAMPER DETECTED")
1492
+ lines.append("")
1493
+ lines.append(
1494
+ "The diff modifies a path SEALED by this repo's acceptance "
1495
+ "oracle (docs/ORACLE_LOOP.md sealing v1): "
1496
+ + ", ".join(f"`{p}`" for p in touched)
1497
+ + ". The suite under these paths is authored independently — "
1498
+ "workers may only RUN it (`coord acceptance run`), never read "
1499
+ "or edit it. **request-changes is mandatory here**, regardless "
1500
+ "of anything else in this diff."
1501
+ )
1502
+ else:
1503
+ lines.append("## Sealed paths (do not touch)")
1504
+ lines.append("")
1505
+ lines.append(
1506
+ "This repo's acceptance oracle is sealed by policy: "
1507
+ + ", ".join(f"`{p}`" for p in sealed_paths)
1508
+ + ". If the diff modifies any of them, **request-changes** — "
1509
+ "this is a hard rule, not a suggestion (docs/ORACLE_LOOP.md)."
1510
+ )
1511
+
1512
+ lines.append("")
1513
+ lines.append("## What to do")
1514
+ lines.append("")
1515
+ if review_iteration > 0:
1516
+ # #476: re-review. A prior round requested changes and the worker
1517
+ # pushed fix commits. Scope to the fix delta — do NOT re-review the
1518
+ # whole PR from scratch, and do NOT raise NEW non-blocking nits on
1519
+ # already-accepted code. Only a genuine bug or an unaddressed
1520
+ # previously-requested change should block.
1521
+ lines.append(
1522
+ f"**This is re-review iteration {review_iteration}.** A previous "
1523
+ "review requested changes and the worker has pushed fix commits "
1524
+ "since then. Scope your review to those fixes — do NOT re-review "
1525
+ "the entire PR from scratch."
1526
+ )
1527
+ lines.append("")
1528
+ lines.append(
1529
+ "1. See what changed since the last review: "
1530
+ f"`git fetch origin && git log --oneline origin/{default_branch}..."
1531
+ f"origin/{branch or 'HEAD'}`. The most recent commit(s) are the fix "
1532
+ "for the last review round — concentrate there."
1533
+ )
1534
+ lines.append(
1535
+ "2. Verify the previously-requested changes were correctly made and "
1536
+ "that the fix commits introduce no regressions."
1537
+ )
1538
+ lines.append(
1539
+ "3. **Do NOT raise new non-blocking nits on unchanged, "
1540
+ "already-reviewed code.** Block (`request-changes`) ONLY for a "
1541
+ "genuine bug or a previously-requested change that was not "
1542
+ "addressed. If the fix is correct and you only have minor polish "
1543
+ "suggestions, **approve** and list them as non-blocking notes — "
1544
+ "the coordinator will not dispatch another fix round for "
1545
+ "non-blocking findings."
1546
+ )
1547
+ elif pr_number is not None:
1548
+ if diff_text and diff_text.strip():
1549
+ lines.append(
1550
+ "1. Review the diff in the '## Diff to review' section above "
1551
+ "(already fetched for you — the merge-base diff)."
1552
+ )
1553
+ else:
1554
+ lines.append(
1555
+ f"1. Get the diff: `git fetch origin && git diff origin/{default_branch}..."
1556
+ f"origin/{branch or 'HEAD'}` or ask the coordinator for the diff."
1557
+ )
1558
+ lines.append("2. Run the project's test suite.")
1559
+ lines.append("3. Review the diff against the checklist above.")
1560
+ else:
1561
+ if diff_text and diff_text.strip():
1562
+ lines.append(
1563
+ "1. Review the diff in the '## Diff to review' section above "
1564
+ "(already fetched for you — the merge-base diff)."
1565
+ )
1566
+ else:
1567
+ lines.append(
1568
+ f"1. The worker pushed branch `{branch}` but no PR was opened. "
1569
+ f"Get the diff: `git fetch origin && git diff origin/{default_branch}..."
1570
+ f"origin/{branch or '<branch>'}`. Always diff against `origin/` after "
1571
+ "fetching — a local base ref may be stale and would sweep in unrelated "
1572
+ "already-merged commits."
1573
+ )
1574
+ lines.append("2. Run the project's test suite.")
1575
+ lines.append("3. Review the diff against the checklist above.")
1576
+ lines.append("")
1577
+ lines.append(
1578
+ "4. Before you end your session, record your verdict TWICE — belt "
1579
+ "and braces, neither step substitutes for the other. FIRST, if the "
1580
+ "environment variable `COORD_ASSIGNMENT_ID` is set, write your full "
1581
+ "findings to a file and run `coord report-result --assignment "
1582
+ '"$COORD_ASSIGNMENT_ID" --status done --verdict '
1583
+ "approve|request-changes --body-file <file>` — this writes straight "
1584
+ "to the coordinator's board and is the authoritative record. If "
1585
+ "`COORD_ASSIGNMENT_ID` is unset, `coord` errors, or it's not on "
1586
+ "your PATH, say so plainly and move on to the required backup "
1587
+ "below regardless. THEN, at the END of your session, ALWAYS ALSO "
1588
+ "output your findings in this exact format as the PATH-independent "
1589
+ "backup (the coordinator will post the review to GitHub on your "
1590
+ "behalf — do NOT run any `gh` commands):"
1591
+ )
1592
+ lines.append("")
1593
+ lines.append("```")
1594
+ lines.append("REVIEW_VERDICT: approve")
1595
+ lines.append("REVIEW_BODY:")
1596
+ lines.append("<your full review text in markdown>")
1597
+ lines.append("END_REVIEW")
1598
+ lines.append("```")
1599
+ lines.append("")
1600
+ lines.append("Use `REVIEW_VERDICT: request-changes` if changes are needed.")
1601
+ # #1456: the coordinator's #476 gate (an advisory-only request-changes must
1602
+ # not burn another fix round) counts bullets under the body's section
1603
+ # headings, and since #1456 it fails CLOSED — an unparseable body keeps the
1604
+ # reviewer's verdict verbatim. Say so here as well as in
1605
+ # REVIEWER_SYSTEM_PROMPT: without an explicit blocking section the gate can
1606
+ # never fire, so every advisory review costs a full fix+re-review round.
1607
+ lines.append("")
1608
+ lines.append(
1609
+ "BODY STRUCTURE — the markdown body MUST use these three headings, "
1610
+ "always all three, with every finding as a `- ` bullet under one of "
1611
+ "them: `## Blocking findings`, `## Non-blocking concerns`, `## Nits`. "
1612
+ "Write the single line `None.` under a heading with nothing under it. "
1613
+ "These sections are machine-counted: an explicitly empty blocking "
1614
+ "section is how you tell the coordinator your objections are advisory "
1615
+ "and no fix round is needed, and a body it cannot read is treated as "
1616
+ "blocking. Never state a blocking objection only in prose outside "
1617
+ "these sections."
1618
+ )
1619
+ # #1346: the three marker lines are a machine contract, not prose. The
1620
+ # surrounding briefing is Markdown and the body placeholder invites
1621
+ # Markdown, so reviewers have emitted `**REVIEW_VERDICT: request-changes**`
1622
+ # — which the parser rejected outright, silently dropping a complete
1623
+ # review. State the constraint and show the failing string; a negative
1624
+ # example is what actually stops the drift.
1625
+ lines.append("")
1626
+ lines.append(
1627
+ "FORMAT CONTRACT — the three marker lines "
1628
+ "(`REVIEW_VERDICT:`, `REVIEW_BODY:`, `END_REVIEW`) are parsed by "
1629
+ "machine. Each must start at the beginning of its own line as "
1630
+ "literal plain text, with NO Markdown decoration: no `**bold**`, no "
1631
+ "backticks, no `#` heading marks, no list bullet. "
1632
+ "`**REVIEW_VERDICT: request-changes**` is WRONG. "
1633
+ "`REVIEW_VERDICT: request-changes` is right. The review BODY between "
1634
+ "the markers may be Markdown — the marker lines may not. "
1635
+ "`END_REVIEW` is a HARD REQUIREMENT: an otherwise-complete, correct "
1636
+ "review with no `END_REVIEW` line is discarded in its entirety, not "
1637
+ "recorded with a best guess — so write `END_REVIEW` even if your "
1638
+ "review prose already feels finished. Before you finish, re-read "
1639
+ "your last message and confirm the verdict line begins with "
1640
+ "`REVIEW_VERDICT:` with nothing preceding it, AND that the very "
1641
+ "last line is `END_REVIEW`."
1642
+ )
1643
+
1644
+ return "\n".join(lines)
1645
+
1646
+
1647
+ # ── Dispatch ────────────────────────────────────────────────────────────────
1648
+
1649
+ def _find_or_open_pr(
1650
+ repo_github: str,
1651
+ *,
1652
+ branch: str,
1653
+ default_branch: str,
1654
+ issue_number: int,
1655
+ issue_title: str,
1656
+ assignment_type: str = "work",
1657
+ ) -> dict | None:
1658
+ """Return {number, url, existed} for a PR on `branch`, opening one if needed.
1659
+
1660
+ Returns None when neither lookup nor open works — caller continues without
1661
+ a PR-targeted review (falls back to branch-diff review).
1662
+
1663
+ *assignment_type* decides the PR-body keyword (#1077): for types in
1664
+ :data:`coord.models.CLOSES_ISSUE_TYPES` (``"work"``), ``issue_number`` is
1665
+ the issue this PR resolves, so the body carries the closing keyword
1666
+ ``Closes #N`` and GitHub auto-closes it on merge. For any other
1667
+ WORK_LIKE type — notably ``"mock-author"`` (Gate A), whose
1668
+ ``issue_number`` is the milestone's *tracking* issue, not something the
1669
+ PR resolves — the body uses the non-closing ``Refs #N`` so the tracking
1670
+ issue still gets a discoverable backlink but does not flip to closed
1671
+ when the contract PR merges.
1672
+ """
1673
+ try:
1674
+ existing = github_ops.find_pr_for_branch(repo_github, branch)
1675
+ except RuntimeError:
1676
+ existing = None
1677
+ if existing is not None:
1678
+ return {
1679
+ "number": existing["number"],
1680
+ "url": existing.get("url"),
1681
+ "existed": True,
1682
+ }
1683
+ keyword = "Closes" if assignment_type in CLOSES_ISSUE_TYPES else "Refs"
1684
+ try:
1685
+ return github_ops.create_pr(
1686
+ repo_github,
1687
+ base=default_branch,
1688
+ head=branch,
1689
+ title=f"#{issue_number}: {issue_title}",
1690
+ body=(
1691
+ f"{keyword} #{issue_number}\n\n"
1692
+ f"Automated PR opened by coordinator for review of issue #{issue_number}."
1693
+ ),
1694
+ )
1695
+ except RuntimeError:
1696
+ return None
1697
+
1698
+
1699
+ def _fetch_agent_advertised_repos(
1700
+ host: str,
1701
+ port: int = AGENT_PORT,
1702
+ *,
1703
+ timeout: float = 2.0,
1704
+ ) -> list[str] | None:
1705
+ """Query an agent's ``/health`` endpoint and return the repos it handles.
1706
+
1707
+ Returns a list of repo names (strings) when the agent is reachable and
1708
+ returns well-formed JSON; returns ``None`` on *any* failure so callers
1709
+ can **fail-open** — never exclude a machine solely because its health probe
1710
+ hiccuped or timed out.
1711
+
1712
+ The short *timeout* (default 2 s) is intentional: this is a preventative
1713
+ pre-filter, not a blocking gate. If the agent is slow to respond, skip
1714
+ the filter and rely on the fall-through loop in ``dispatch_review`` to
1715
+ surface a definitive rejection.
1716
+ """
1717
+ url = f"http://{host}:{port}/health"
1718
+ try:
1719
+ resp = httpx.get(url, timeout=timeout)
1720
+ if resp.status_code == 200:
1721
+ data = resp.json()
1722
+ repos = data.get("repos")
1723
+ if isinstance(repos, list):
1724
+ return [str(r) for r in repos]
1725
+ except Exception: # noqa: BLE001 — fail-open: any network or parse error
1726
+ pass
1727
+ return None
1728
+
1729
+
1730
+ def dispatch_review(
1731
+ completed: Assignment,
1732
+ board: Board,
1733
+ config: Config,
1734
+ *,
1735
+ http_client: httpx.Client | None = None,
1736
+ pr_lookup=_find_or_open_pr,
1737
+ claude_md_reader=_read_repo_claude_md,
1738
+ issue_body_fetcher=None,
1739
+ now: float | None = None,
1740
+ terminal_cache: dict | None = None,
1741
+ remote_branch_checker=None,
1742
+ branch_sha_fetcher=None,
1743
+ health_checker=None,
1744
+ milestone_fetcher=None,
1745
+ patch_id_computer=None,
1746
+ diff_fetcher=None,
1747
+ commits_ahead_checker=None,
1748
+ ) -> Assignment | None:
1749
+ """Open a PR for `completed` and dispatch a review assignment.
1750
+
1751
+ Returns the new review Assignment, or None if review couldn't be dispatched
1752
+ (no machine handles the repo, no branch on the completed assignment, etc.).
1753
+ The caller is responsible for persisting the board.
1754
+
1755
+ *health_checker* is an optional ``(host: str) -> list[str] | None`` callable
1756
+ that returns the repo names a given agent advertises, or ``None`` to
1757
+ fail-open. When not provided, ``_fetch_agent_advertised_repos`` is called
1758
+ directly. Inject a stub in tests to avoid real network probes.
1759
+
1760
+ *patch_id_computer* is an optional ``(diff_text: str | None) -> str |
1761
+ None`` callable (#1475) that fingerprints the merge-base diff being
1762
+ reviewed. Defaults to ``github_ops.compute_patch_id`` (a pure, no-network
1763
+ ``git patch-id --stable`` call); inject a stub in tests that don't want
1764
+ to shell out to git.
1765
+
1766
+ *diff_fetcher* is an optional ``(repo_github: str, pr_number: int, *,
1767
+ max_chars: int | None) -> str | None`` callable (#1484) that fetches the
1768
+ merge-base diff embedded in the reviewer's briefing and hashed into
1769
+ ``review_patch_id``. Defaults to :func:`coord.github_ops.pr_diff` (a real
1770
+ ``gh pr diff`` subprocess call); inject a stub in tests so a PR-having
1771
+ dispatch never shells out to a live ``gh`` — mirrors
1772
+ :func:`dispatch_scoped_review`'s ``diff_fetcher`` for the same reason.
1773
+
1774
+ *commits_ahead_checker* is an optional ``(repo_github: str, base: str,
1775
+ branch: str) -> int | None`` callable (#1534) used by the zero-commit gate
1776
+ below. Defaults to :func:`coord.github_ops.branch_commits_ahead` (a real
1777
+ ``gh api compare`` call); inject a stub in tests so the gate is exercised
1778
+ without network.
1779
+ """
1780
+ # #1627: every early-exit guard below used to be a bare `return None`,
1781
+ # collapsing 11 distinct outcomes into one signal the caller couldn't
1782
+ # distinguish (see coord/commands/plan_followup.py's `review` command,
1783
+ # which used to print "no eligible reviewer machine, or a guard ...
1784
+ # blocked it — see the coordinator log" for every one of them, even
1785
+ # though most never logged anything). `_deny` records *why* on the
1786
+ # assignment itself (`review_dispatch_reason`, transient/in-memory —
1787
+ # see its docstring in models.py) and logs at info level, then returns
1788
+ # None so call sites can keep writing `return _deny(...)`.
1789
+ def _deny(reason: str) -> None:
1790
+ completed.review_dispatch_reason = reason
1791
+ log.info(
1792
+ "[review] not dispatching for %s: %s", completed.assignment_id, reason
1793
+ )
1794
+ return None
1795
+
1796
+ if not config.reviews.enabled or not config.reviews.auto_dispatch:
1797
+ return _deny(
1798
+ f"reviews disabled (reviews.enabled={config.reviews.enabled!r}, "
1799
+ f"reviews.auto_dispatch={config.reviews.auto_dispatch!r})"
1800
+ )
1801
+ if completed.type not in WORK_LIKE_TYPES:
1802
+ return _deny(
1803
+ f"assignment {completed.assignment_id} is type {completed.type!r}, "
1804
+ f"not reviewable work (reviewable types: {sorted(WORK_LIKE_TYPES)}). "
1805
+ "Did you mean the work assignment for this issue? Try: "
1806
+ f"coord diagnose {completed.repo_name} {completed.issue_number}"
1807
+ )
1808
+ if completed.status != "done":
1809
+ return _deny(
1810
+ f"assignment {completed.assignment_id} has status "
1811
+ f"{completed.status!r}, not 'done' — nothing to review yet"
1812
+ )
1813
+ if not completed.branch:
1814
+ # Without a branch we can't open a PR or diff. Skip silently — this
1815
+ # usually means the worker forgot to switch off main, which the
1816
+ # branch-capture code in agent._reap will have left as None.
1817
+ return _deny(
1818
+ f"assignment {completed.assignment_id} has no branch recorded — "
1819
+ "the worker may not have pushed yet"
1820
+ )
1821
+
1822
+ # Dedupe: don't fire a second review if one's already in flight for this
1823
+ # completed work assignment.
1824
+ from coord.claim import has_active_followup, has_active_work_followup
1825
+
1826
+ if has_active_followup(
1827
+ board, of_assignment_id=completed.assignment_id, assignment_type="review"
1828
+ ):
1829
+ return _deny(
1830
+ f"a review is already in flight for {completed.assignment_id}"
1831
+ )
1832
+
1833
+ # #459: skip review if a work or conflict-fix is actively rewriting the
1834
+ # branch for this issue (e.g. a coord-bounce fix iteration). Reviewing
1835
+ # stale code now would produce a verdict on code that's about to change.
1836
+ # Leave the caller's review_state as "pending" so the next reconcile pass
1837
+ # retries once the active fix finishes.
1838
+ #
1839
+ # #1553: compare on the *effective* issue (see
1840
+ # ``coord.models.effective_issue_number``), not the raw
1841
+ # ``completed.issue_number``. For an oracle-loop acceptance slice,
1842
+ # ``issue_number`` is the shared tracking issue, so keying on it here
1843
+ # would match ANY in-flight work/conflict-fix under that milestone (an
1844
+ # unrelated child) rather than only a live rewrite of THIS row's branch.
1845
+ # ``has_active_work_followup`` itself already keys its scan on the
1846
+ # effective issue; this call site has to match or the guard silently
1847
+ # stops firing for exactly the slices #1553 restored visibility for.
1848
+ from coord.models import effective_issue_number
1849
+
1850
+ if has_active_work_followup(
1851
+ board,
1852
+ repo_name=completed.repo_name,
1853
+ issue_number=effective_issue_number(completed),
1854
+ ):
1855
+ return _deny(
1856
+ "a work or fix assignment is actively rewriting the branch for "
1857
+ f"issue #{completed.issue_number} in {completed.repo_name!r} — "
1858
+ "review deferred until it finishes. If nothing is actually "
1859
+ "running, this may be a phantom 'running' row left by a worker "
1860
+ "that died mid-fix; check with: coord diagnose "
1861
+ f"{completed.repo_name} {completed.issue_number}"
1862
+ )
1863
+
1864
+ repo = config.repo(completed.repo_name)
1865
+ if repo is None:
1866
+ return _deny(f"repo {completed.repo_name!r} not found in config")
1867
+
1868
+ # #522: the review chokepoint. Never (re)dispatch a review for work that
1869
+ # is already done on GitHub — issue closed OR PR merged. This is the second
1870
+ # flood vector (reviews of already-merged #349/#194) that the auto-loop
1871
+ # fix-dispatch guard alone didn't cover. Mark the row done so the pending-
1872
+ # review loop stops treating it as eligible. Fail-open inside
1873
+ # work_is_terminal, so a transient gh error never blocks a real review.
1874
+ if github_ops.work_is_terminal(
1875
+ repo.github, completed.issue_number, completed.branch, cache=terminal_cache
1876
+ ):
1877
+ completed.review_state = "done"
1878
+ return _deny(
1879
+ f"issue #{completed.issue_number} is already closed or its PR "
1880
+ "already merged on GitHub — review is moot"
1881
+ )
1882
+
1883
+ # #437: STRUCTURAL TOS-COMPLIANCE GATE — auto-dispatched reviews are
1884
+ # an unattended path, so refuse to route them through a provider
1885
+ # whose capabilities mark it ``human_attended_only``. Deferred import
1886
+ # keeps the review module free of a module-level cycle with the
1887
+ # provider registry. On refusal we return None (same as "auto_dispatch
1888
+ # off" / "machine unreachable") so callers leave review_state as
1889
+ # 'pending' and retry on the next notify call — consistent with how
1890
+ # _reassign handles the same guard in reconcile.py.
1891
+ #
1892
+ # #1811: ``spec_provider=config.reviews.provider`` — a review-only
1893
+ # override that outranks ``repo.provider`` in the same precedence chain
1894
+ # (spec > repo > providers.default) every other dispatch path already
1895
+ # uses. ``None`` (unset) resolves to exactly the same effective name as
1896
+ # before this field existed, so an unconfigured deployment sees no
1897
+ # behavior change. The guard still refuses a ``human_attended_only``
1898
+ # resolution regardless of which link in the chain supplied it — a
1899
+ # named ``reviews.provider`` gets no exemption from the #437 gate.
1900
+ from coord.providers import guard_unattended_dispatch # noqa: PLC0415
1901
+ try:
1902
+ review_provider_name = guard_unattended_dispatch(
1903
+ spec_provider=config.reviews.provider,
1904
+ repo_provider=repo.provider,
1905
+ providers_cfg=config.providers,
1906
+ models_cfg=config.models,
1907
+ where="auto-dispatch review",
1908
+ )
1909
+ except ValueError as exc:
1910
+ print(f"[review] skipping auto-dispatch review: {exc}")
1911
+ return _deny(f"blocked by human-attended-only policy: {exc}")
1912
+
1913
+ # #934: resolve this issue's base branch — `feature/ms-NN` when it
1914
+ # belongs to a milestone and the repo opted into the git model,
1915
+ # `repo.default_branch` (today's behavior) otherwise. Resolved once and
1916
+ # reused for the PR base, the diff-command text in the briefing, and the
1917
+ # `branch` payload field below, so they never disagree. The milestone
1918
+ # lookup itself is skipped entirely (no `gh` call) when the repo hasn't
1919
+ # opted in — a non-opted-in repo pays zero extra cost.
1920
+ base_branch = repo.default_branch
1921
+ if getattr(repo, "develop_branch", None):
1922
+ from coord.branch_model import resolve_base_branch # noqa: PLC0415
1923
+
1924
+ fetch_milestone = milestone_fetcher or _fetch_issue_milestone_number
1925
+ milestone_number = fetch_milestone(repo.github, completed.issue_number)
1926
+ base_branch = resolve_base_branch(repo, milestone_number)
1927
+
1928
+ # #1534: ZERO-COMMIT GATE. Refuse to spend a metered review on a branch
1929
+ # that carries no commits over its base — there is literally nothing to
1930
+ # review, and every second of that reviewer's budget is wasted. This is
1931
+ # the same reasoning as #946's merge enqueue gate, one stage earlier.
1932
+ #
1933
+ # The observed incident: a `test-author` killed by the Claude session
1934
+ # usage limit was recorded `done` with an empty branch, and a review was
1935
+ # auto-dispatched against it. The reviewer diffed nothing against nothing
1936
+ # and (thanks to #873) returned a null verdict, so even that produced no
1937
+ # signal — the empty slice looked authored *and* reviewed for two days.
1938
+ #
1939
+ # Deliberately placed AFTER the `work_is_terminal` chokepoint (so an
1940
+ # already-merged branch keeps its existing `review_state="done"`
1941
+ # resolution) but BEFORE `pr_lookup` (which would otherwise open a PR for
1942
+ # the empty branch as a side effect of the check).
1943
+ #
1944
+ # FAIL-OPEN: `branch_commits_ahead` returns None — never 0 — on any gh
1945
+ # failure, so a network blip can never strand a real review. Only a
1946
+ # definite `ahead_by == 0` from GitHub blocks.
1947
+ _ahead_check = commits_ahead_checker or github_ops.branch_commits_ahead
1948
+ _ahead = _ahead_check(repo.github, base_branch, completed.branch)
1949
+ if _ahead == 0:
1950
+ log.warning(
1951
+ "[review] branch %r for %s has 0 commits ahead of %s — refusing to "
1952
+ "auto-dispatch a review against an empty diff (#1534). The work "
1953
+ "assignment did not produce anything; re-dispatch it instead.",
1954
+ completed.branch, completed.assignment_id, base_branch,
1955
+ )
1956
+ completed.review_state = "zero_commits"
1957
+ return _deny(
1958
+ f"branch {completed.branch!r} has 0 commits ahead of {base_branch} "
1959
+ "— refusing to review an empty diff; re-dispatch the work instead"
1960
+ )
1961
+
1962
+ pr = pr_lookup(
1963
+ repo.github,
1964
+ branch=completed.branch,
1965
+ default_branch=base_branch,
1966
+ issue_number=completed.issue_number,
1967
+ issue_title=completed.issue_title,
1968
+ assignment_type=completed.type,
1969
+ )
1970
+
1971
+ # #904 (fix #1): build a ranked list of ALL eligible reviewer machines so
1972
+ # we can fall through to the next if one rejects the dispatch. This
1973
+ # replaces the previous single-pick → silent-return-None path that could
1974
+ # park a work row at the merge gate forever when config drift caused a
1975
+ # "does not handle repo" 400 from the first (and only tried) machine.
1976
+ candidates = _ranked_reviewer_candidates(
1977
+ completed.machine_name, completed.repo_name, board, config
1978
+ )
1979
+ if not candidates:
1980
+ return _deny(
1981
+ f"no eligible reviewer machine configured for repo "
1982
+ f"{completed.repo_name!r}"
1983
+ )
1984
+
1985
+ # #586: if the branch isn't on the remote, only the original worker machine
1986
+ # has it locally — any cross-machine reviewer would crash on git-fetch.
1987
+ # Narrow the candidate list to just that machine; if it's unavailable too,
1988
+ # stall visibly with "branch_not_on_remote".
1989
+ any_cross_machine = any(not same for _, same in candidates)
1990
+ if any_cross_machine and completed.branch:
1991
+ _check_remote = remote_branch_checker or github_ops.branch_exists_on_remote
1992
+ if not _check_remote(repo.github, completed.branch):
1993
+ log.warning(
1994
+ "[review] branch %r not on remote for %s — routing review back "
1995
+ "to original worker machine %s to avoid cross-machine fetch failure",
1996
+ completed.branch, completed.assignment_id, completed.machine_name,
1997
+ )
1998
+ from coord.machine_pause import paused_set # noqa: PLC0415
1999
+ paused = paused_set(config.machines)
2000
+ worker_machine = next(
2001
+ (m for m in config.machines if m.name == completed.machine_name),
2002
+ None,
2003
+ )
2004
+ if (
2005
+ worker_machine is not None
2006
+ and worker_machine.can_work_on(completed.repo_name)
2007
+ and worker_machine.name not in paused
2008
+ ):
2009
+ # Restrict to just the worker machine — it has the branch locally.
2010
+ candidates = [(worker_machine, True)]
2011
+ else:
2012
+ # Original machine also unavailable — stall visibly.
2013
+ log.error(
2014
+ "[review] branch %r not on remote for %s and original machine "
2015
+ "%s is unavailable (paused or not configured) — "
2016
+ "review BLOCKED until branch is pushed to origin",
2017
+ completed.branch, completed.assignment_id, completed.machine_name,
2018
+ )
2019
+ completed.review_state = "branch_not_on_remote"
2020
+ return _deny(
2021
+ f"branch {completed.branch!r} not on remote and original "
2022
+ f"worker machine {completed.machine_name!r} is unavailable "
2023
+ "(paused or not configured) — push the branch to origin "
2024
+ "or unpause the worker machine"
2025
+ )
2026
+
2027
+ # Compute the parts that are constant across all candidate machines.
2028
+
2029
+ # #612: merge-base diff — embedded verbatim so the reviewer reviews exactly
2030
+ # the branch's own changes (a stale-base diff sweeps in already-merged
2031
+ # commits as spurious deletions, #546). Best-effort: None keeps the
2032
+ # fallback three-dot git-diff instructions in the briefing.
2033
+ # #1475: fetch the full, untruncated diff once — it's the input to the
2034
+ # content-hash (`review_patch_id` below) and must never be the mutated,
2035
+ # truncated-with-a-trailer string (hashing that gives a patch-id that can
2036
+ # never match the merge-time `branch_patch_id`, which is computed from an
2037
+ # uncapped compare-API diff). The display copy shown to the reviewer is
2038
+ # then truncated locally from the same fetch — no second `gh` call.
2039
+ _diff = diff_fetcher or github_ops.pr_diff
2040
+ full_diff_text = _diff(repo.github, pr["number"], max_chars=None) if pr else None
2041
+ diff_text = (
2042
+ github_ops.truncate_diff_text(full_diff_text) if full_diff_text is not None else None
2043
+ )
2044
+
2045
+ fetch_body = issue_body_fetcher or _fetch_issue_body
2046
+ issue_body = fetch_body(repo.github, completed.issue_number)
2047
+
2048
+ # #1811: does the resolved review provider share the worker's model
2049
+ # family? ``completed.provider_name`` is the *resolved* name recorded at
2050
+ # work-dispatch time (spec > repo > providers.default); ``None`` means a
2051
+ # row predating #324 or a path that doesn't set it, which the rest of
2052
+ # the codebase (e.g. coord/gates.py's TUI rendering) treats as the
2053
+ # implicit "claude" default. Provider co-location is a larger loss of
2054
+ # independence than machine co-location (a fresh session removes shared
2055
+ # context, but not shared blind spots) — surfaced below in the
2056
+ # reviewer's own briefing, mirroring ``same_as_worker``.
2057
+ worker_provider_name = completed.provider_name or "claude"
2058
+ provider_same_as_worker = review_provider_name == worker_provider_name
2059
+ if provider_same_as_worker:
2060
+ log.info(
2061
+ "[review] %s: reviewer provider %r matches worker provider — "
2062
+ "reduced independence (shared model family)",
2063
+ completed.assignment_id, review_provider_name,
2064
+ )
2065
+
2066
+ # Pin the reviewer's model to avoid the agent defaulting to Opus (#911).
2067
+ # #1430: deliberately not consulting models.labels — the reviewer's
2068
+ # effort scales with diff size, not the original work issue's tier
2069
+ # label, and #911 already pins this deliberately.
2070
+ review_model_alias = config.models.default
2071
+ review_model_wire = config.models.resolve(review_model_alias)
2072
+
2073
+ # #821: capture branch HEAD SHA once; staleness detected post-review.
2074
+ _get_sha = branch_sha_fetcher or github_ops.get_branch_sha
2075
+ review_head_sha: str | None = None
2076
+ try:
2077
+ review_head_sha = _get_sha(repo.github, completed.branch)
2078
+ except Exception: # noqa: BLE001 — fail-safe: missing SHA is not blocking
2079
+ pass
2080
+
2081
+ # #1475: fingerprint the *full* merge-base diff (`full_diff_text`, computed
2082
+ # above) — not the display-truncated `diff_text` — so this matches the
2083
+ # merge-time counterpart (`get_branch_patch_id`, also uncapped) for any PR
2084
+ # whose diff exceeds the display truncation threshold. Stored alongside
2085
+ # review_head_sha so a later commit-bound staleness check (a rebase moving
2086
+ # the SHA) can carry the approval forward when the content is byte-identical.
2087
+ _compute_patch_id = patch_id_computer or github_ops.compute_patch_id
2088
+ review_patch_id: str | None = None
2089
+ try:
2090
+ review_patch_id = _compute_patch_id(full_diff_text)
2091
+ except Exception: # noqa: BLE001 — fail-safe: missing patch-id is not blocking
2092
+ pass
2093
+
2094
+ # #603: per-issue context digest (cross-repo deps / prior findings).
2095
+ from coord.state import issue_context_block # noqa: PLC0415
2096
+ context_prefix = issue_context_block(completed.repo_name, completed.issue_number)
2097
+
2098
+ # #944 sealing v1: flag tests/acceptance/ as sealed when this repo has an
2099
+ # oracle-loop acceptance driver configured — the reviewer must reject any
2100
+ # diff that touches it (docs/ORACLE_LOOP.md).
2101
+ #
2102
+ # #1552: the set is DERIVED from the driver definition rather than
2103
+ # hardcoded to that one literal. `tests/acceptance/` alone fits a
2104
+ # directory-discovered suite (`pytest tests/acceptance/{ms}`) and is
2105
+ # structurally unsatisfiable for an entry-point-linked one
2106
+ # (`cargo test --test acceptance` sees nothing until `tui/tests/
2107
+ # acceptance.rs` include!s the slice) — under #1175's blanket refusal a
2108
+ # `test-author` on the Rust route could only wire its slice in and be
2109
+ # bounced, or leave it unwired and ship dead code. Each route now
2110
+ # declares its own `entrypoint:`.
2111
+ sealed_paths = config.acceptance.sealed_paths(completed.repo_name)
2112
+ sealed_entrypoints = config.acceptance.entrypoints(completed.repo_name)
2113
+
2114
+ client = http_client or httpx
2115
+
2116
+ # Iterate candidates in priority order. On agent rejection (4xx from a
2117
+ # misconfigured agent, health-check filter on a drifted config, etc.) we
2118
+ # log a warning and try the next candidate instead of giving up silently.
2119
+ # Only definitive rejections (4xx responses or health-check exclusions) set
2120
+ # had_rejection=True; transient network failures leave the row as "pending"
2121
+ # so the next reconcile/notify pass retries automatically.
2122
+ had_rejection = False
2123
+ for machine, same_as_worker in candidates:
2124
+ # Fix #2 (PREVENTATIVE): pre-filter against the agent's /health
2125
+ # ``repos`` list so a drifted local config can't pick a machine that
2126
+ # will 400. Fail-open: None means "probe failed, include anyway".
2127
+ # #1485: an empty list is NOT the same as None here — it means "this
2128
+ # agent has no local coordinator.yml at all" (the expected, correct
2129
+ # state for a worker-only machine — coordinator.yml lives on
2130
+ # dellserver only), which matches the agent's own interpretation in
2131
+ # AgentServer.assign (`if self.repos and spec.repo_name not in
2132
+ # self.repos`, coord/agent.py) where an empty list is falsy and means
2133
+ # "no restriction, accept everything." Treat `[]` the same way here —
2134
+ # only a *non-empty* advertised list that omits the repo is a genuine
2135
+ # drift signal worth skipping the candidate for.
2136
+ _hc = health_checker if health_checker is not None else _fetch_agent_advertised_repos
2137
+ advertised = _hc(machine.host)
2138
+ if advertised and completed.repo_name not in advertised:
2139
+ log.warning(
2140
+ "[review] skipping candidate %s: /health advertises repos %r "
2141
+ "but repo %r is not listed — possible config drift",
2142
+ machine.name, advertised, completed.repo_name,
2143
+ )
2144
+ had_rejection = True
2145
+ continue
2146
+
2147
+ repo_path = machine.repo_path(completed.repo_name)
2148
+ if repo_path is None:
2149
+ log.warning(
2150
+ "[review] skipping candidate %s: no repo_path for %r",
2151
+ machine.name, completed.repo_name,
2152
+ )
2153
+ continue
2154
+
2155
+ claude_md = claude_md_reader(Path(repo_path).expanduser())
2156
+
2157
+ # #476 / #612: briefing is rebuilt per candidate because same_as_worker
2158
+ # (warning note in the briefing) and claude_md path can differ between
2159
+ # machines.
2160
+ briefing = context_prefix + build_review_briefing(
2161
+ pr_number=pr["number"] if pr else None,
2162
+ pr_url=pr["url"] if pr else None,
2163
+ repo_github=repo.github,
2164
+ repo_name=repo.name,
2165
+ issue_number=completed.issue_number,
2166
+ issue_title=completed.issue_title,
2167
+ issue_body=issue_body,
2168
+ branch=completed.branch,
2169
+ worker_machine=completed.machine_name,
2170
+ same_as_worker=same_as_worker,
2171
+ provider_same_as_worker=provider_same_as_worker,
2172
+ review_provider=review_provider_name,
2173
+ reviews_cfg=config.reviews,
2174
+ repo_claude_md=claude_md,
2175
+ default_branch=base_branch,
2176
+ # #476: a fix worker carries review_iteration > 0; its re-review is
2177
+ # scoped to the fix delta rather than re-reviewing the whole PR.
2178
+ review_iteration=getattr(completed, "review_iteration", 0) or 0,
2179
+ diff_text=diff_text,
2180
+ sealed_paths=sealed_paths,
2181
+ sealed_entrypoints=sealed_entrypoints,
2182
+ assignment_type=completed.type,
2183
+ )
2184
+
2185
+ payload = {
2186
+ "repo_name": completed.repo_name,
2187
+ "repo_path": repo_path,
2188
+ "issue_number": completed.issue_number,
2189
+ "issue_title": f"[review] {completed.issue_title}",
2190
+ "briefing": briefing,
2191
+ "files_allowed": [],
2192
+ "files_forbidden": [],
2193
+ "pull_repos": [],
2194
+ "type": "review",
2195
+ "model": review_model_wire,
2196
+ "system_prompt": REVIEWER_SYSTEM_PROMPT,
2197
+ "review_target": str(pr["number"]) if pr else completed.branch,
2198
+ # #255: review checkout uses the PR branch, but the agent's worktree
2199
+ # setup still consults `branch` as the integration base when no PR
2200
+ # branch exists locally yet. Match the work-dispatch path.
2201
+ "branch": base_branch or "main",
2202
+ }
2203
+ # #1811: carry the resolved review provider onto the wire the same
2204
+ # way coord.dispatch.dispatch() does for work — without this the
2205
+ # agent's own AssignmentSpec.provider stays None and it silently
2206
+ # runs its legacy default worker command regardless of what
2207
+ # guard_unattended_dispatch resolved above, which is exactly the
2208
+ # "configuration appears to work while doing nothing" trap #1811
2209
+ # calls out. Gated by the same helper dispatch() uses so a vanilla,
2210
+ # uncustomized "claude" resolution keeps an unconfigured
2211
+ # deployment's wire payload byte-identical to before this field
2212
+ # existed.
2213
+ from coord.dispatch import _wire_payload_needs_provider_field # noqa: PLC0415
2214
+
2215
+ if review_provider_name and _wire_payload_needs_provider_field(
2216
+ review_provider_name, config,
2217
+ ):
2218
+ payload["provider"] = review_provider_name
2219
+
2220
+ url = f"http://{machine.host}:{AGENT_PORT}/assign"
2221
+ try:
2222
+ resp = client.post(url, json=payload, timeout=15)
2223
+ resp.raise_for_status()
2224
+ agent_response = resp.json()
2225
+ except httpx.HTTPStatusError as exc:
2226
+ # Fix #1 (PRIMARY): the agent definitively rejected the dispatch
2227
+ # (e.g. 400 "does not handle repo 'x'"). Try the next candidate
2228
+ # instead of silently returning None and leaving review_state as
2229
+ # 'pending' (#904).
2230
+ #
2231
+ # #904 (fix #2): only a 4xx is a *definitive* rejection — it means
2232
+ # the agent looked at the request and refused it (bad repo, bad
2233
+ # payload, etc.), which is a config-drift signal. A 5xx means the
2234
+ # agent's own handler blew up (mid-restart, disk full, unhandled
2235
+ # exception) and says nothing about whether this agent/repo pairing
2236
+ # is valid — treat it like the transient network branch below so
2237
+ # the row stays "pending" and retries next pass instead of
2238
+ # permanently stalling as "no_eligible_reviewer".
2239
+ if exc.response.is_client_error:
2240
+ log.warning(
2241
+ "[review] agent %s rejected dispatch with HTTP %d — "
2242
+ "trying next reviewer candidate",
2243
+ machine.name, exc.response.status_code,
2244
+ )
2245
+ had_rejection = True
2246
+ else:
2247
+ log.warning(
2248
+ "[review] agent %s returned server error HTTP %d (transient) — "
2249
+ "trying next reviewer candidate",
2250
+ machine.name, exc.response.status_code,
2251
+ )
2252
+ continue
2253
+ except (httpx.HTTPError, httpx.TimeoutException) as exc:
2254
+ # Transient network failure — try next candidate, and if all
2255
+ # fail transiently, leave review_state unchanged so the next
2256
+ # reconcile/notify pass retries automatically.
2257
+ log.warning(
2258
+ "[review] agent %s unreachable (%s) — trying next reviewer candidate",
2259
+ machine.name, exc,
2260
+ )
2261
+ continue
2262
+
2263
+ # Dispatch accepted — record the review assignment and return.
2264
+ review_assignment = Assignment(
2265
+ machine_name=machine.name,
2266
+ repo_name=completed.repo_name,
2267
+ issue_number=completed.issue_number,
2268
+ issue_title=f"[review] {completed.issue_title}",
2269
+ files_allowed=[],
2270
+ files_forbidden=[],
2271
+ briefing=briefing,
2272
+ assignment_id=agent_response.get("id") or uuid.uuid4().hex[:12],
2273
+ status="running",
2274
+ branch=completed.branch,
2275
+ pr_url=pr.get("url") if pr else None,
2276
+ dispatched_at=now if now is not None else time.time(),
2277
+ type="review",
2278
+ review_target=str(pr["number"]) if pr else completed.branch,
2279
+ review_of_assignment_id=completed.assignment_id,
2280
+ model=review_model_alias,
2281
+ # #1811: record the resolved review provider the same way
2282
+ # coord.dispatch.dispatch() records the work provider — so the
2283
+ # TUI/audit trail can distinguish a review that ran through
2284
+ # `reviews.provider`/`repo.provider` from one that fell through
2285
+ # to `providers.default`, instead of guessing "claude" for every
2286
+ # review row the way a `None` here used to force.
2287
+ provider_name=review_provider_name,
2288
+ review_head_sha=review_head_sha,
2289
+ review_patch_id=review_patch_id,
2290
+ # #1553: a review of an oracle-loop acceptance slice is work for
2291
+ # the CHILD issue, not for the milestone's tracking issue that
2292
+ # `completed.issue_number` carries. Inherit the slice attribution
2293
+ # so the child's Pipeline row shows the review as activity and
2294
+ # its cost rolls up to the child. None for every ordinary review
2295
+ # (the parent has no `for_issue_number`), so nothing changes for
2296
+ # non-slice work. See `coord.models.effective_issue_number`.
2297
+ for_issue_number=completed.for_issue_number,
2298
+ )
2299
+ board.active.append(review_assignment)
2300
+
2301
+ from coord.state import record_dispatched_assignment # noqa: PLC0415
2302
+ record_dispatched_assignment(
2303
+ assignment=review_assignment,
2304
+ repo_github=repo.github,
2305
+ )
2306
+
2307
+ return review_assignment
2308
+
2309
+ # All candidates exhausted. Distinguish definitive rejection (config
2310
+ # drift, drifted agent config) from transient network failures.
2311
+ if had_rejection:
2312
+ # At least one agent definitively rejected the repo — stall visibly
2313
+ # with a named state so `coord status` can surface an actionable error
2314
+ # and the pending-review loop stops silently retrying (#904).
2315
+ log.error(
2316
+ "[review] all reviewer candidates rejected dispatch for %s "
2317
+ "(repo=%r, branch=%r) — setting review_state='no_eligible_reviewer'. "
2318
+ "Check that every agent's repos list includes %r.",
2319
+ completed.assignment_id, completed.repo_name, completed.branch,
2320
+ completed.repo_name,
2321
+ )
2322
+ completed.review_state = "no_eligible_reviewer"
2323
+ completed.review_dispatch_reason = (
2324
+ f"all reviewer candidates rejected dispatch for repo "
2325
+ f"{completed.repo_name!r} (config drift — check every agent's "
2326
+ "repos list)"
2327
+ )
2328
+ else:
2329
+ # Only transient failures — leave review_state unchanged so the next
2330
+ # reconcile/notify pass retries automatically.
2331
+ log.warning(
2332
+ "[review] all reviewer candidates unreachable for %s "
2333
+ "(repo=%r) — will retry on next reconcile/notify pass",
2334
+ completed.assignment_id, completed.repo_name,
2335
+ )
2336
+ completed.review_dispatch_reason = (
2337
+ f"all reviewer candidates unreachable for repo "
2338
+ f"{completed.repo_name!r} — transient, will retry automatically"
2339
+ )
2340
+ return None
2341
+
2342
+
2343
+ def dispatch_pending_reviews(board, config, *, test_gate_active: bool = False, now=None):
2344
+ """Bounded bulk review dispatch — the flood guard (incident 2026-06-08).
2345
+
2346
+ Gather every completed-work row eligible for a review, then dispatch
2347
+ reviews subject to two limits that prevent the review-flood failure mode —
2348
+ a backlog "unmasking" firing hundreds of metered ``claude -p`` reviews in a
2349
+ single reconcile/notify pass:
2350
+
2351
+ 1. **Surge gate.** If the number of eligible rows exceeds
2352
+ ``reviews.flood_threshold`` (and the threshold is > 0), dispatch
2353
+ *nothing* and log loudly. A sudden surge is the unmasking signature, so
2354
+ we halt and require a human to either clear the stale backlog (mark it
2355
+ reviewed/skipped) or opt in via ``reviews.allow_review_flood: true`` /
2356
+ ``COORD_ALLOW_REVIEW_FLOOD=1``.
2357
+ 2. **Per-pass cap.** Otherwise dispatch at most
2358
+ ``reviews.max_auto_dispatch_per_pass`` reviews this pass (0 = unbounded);
2359
+ the remainder stay ``"pending"`` and are picked up next pass, so even a
2360
+ moderate batch bleeds out at a bounded rate instead of all at once.
2361
+
2362
+ A row is eligible when its ``review_state`` is ``None``/``"pending"``, its
2363
+ ``type`` is in :data:`coord.models.WORK_LIKE_TYPES` (``"work"`` or
2364
+ ``"mock-author"``, #930), the (optional) test gate is satisfied, and #459's
2365
+ ``has_active_work_followup`` is False (don't review code a live fix is
2366
+ rewriting). Both ``reconcile()`` and ``coord notify`` route bulk dispatch
2367
+ through here so the cap, surge gate, and #459 dedupe are enforced on every
2368
+ automatic path. Sets ``review_state="dispatched"`` on each row it
2369
+ dispatches and returns the dispatched review ``Assignment``s. The caller
2370
+ persists the board.
2371
+
2372
+ #1565: before the eligibility filter runs, any row whose ``review_state``
2373
+ reads ``pending``/``None`` but that already has a *terminal* verdict on a
2374
+ completed ``type="review"`` assignment targeting it is excluded and
2375
+ self-healed (``review_state`` set to ``"done"``) rather than trusted at
2376
+ face value — see the guard immediately below. This is the backstop for a
2377
+ row whose ``review_state`` regressed to ``pending`` after a review already
2378
+ rendered a verdict (a stale whole-board ``save_board()`` clobber, or a
2379
+ verdict that was never propagated to the parent row).
2380
+ """
2381
+ import logging
2382
+ import os
2383
+
2384
+ from coord.claim import has_active_work_followup
2385
+ from coord.models import effective_issue_number
2386
+
2387
+ logger = logging.getLogger("coord.review")
2388
+
2389
+ # Test-before-Review reorder: when the pipeline orders Test ahead of Review,
2390
+ # hold automatic review dispatch until the work carries a passed/skipped
2391
+ # test verdict, so the headless auto-loop matches the displayed
2392
+ # Work → Test → Review order (and never burns a metered review on code the
2393
+ # smoke test hasn't validated yet). Explicit callers can still force the
2394
+ # gate on via ``test_gate_active``; the explicit ``coord review``/``coord
2395
+ # pr`` paths (→ ``dispatch_review`` directly) stay ungated so a human can
2396
+ # always request a review deliberately.
2397
+ gate_test = test_gate_active or (
2398
+ getattr(config, "pipeline", None) is not None
2399
+ and config.pipeline.test_precedes_review()
2400
+ )
2401
+
2402
+ # #1076/#1152: a `type="mock-author"` (Gate A contract/fixture diff) or
2403
+ # `type="test-author"` (per-issue JIT acceptance-slice authoring, #931)
2404
+ # completion is a fixture/test-only diff — it matches no
2405
+ # `smoke_tests.capability_rules` rule by construction, so nothing ever
2406
+ # produces a Test-gate verdict for it and `test_state` stays NULL forever.
2407
+ # Under an active test gate that means the row is silently and
2408
+ # permanently excluded from `eligible` below — no error, no stuck
2409
+ # indicator, just a row that never gets reviewed (the #1076 repro,
2410
+ # assignment 9960b957ff3f; the #1152 repro, assignment 2e93ee72071c).
2411
+ # There is nothing to smoke-test for either shape of completion, so
2412
+ # "skipped" is always the correct verdict, not a judgment call — backfill
2413
+ # it here, the single choke point both reconcile() and `coord notify`
2414
+ # (`_dispatch_board_pending_reviews`) route bulk review dispatch through,
2415
+ # so this also retroactively unsticks any row that went "done" before
2416
+ # this fix shipped. `type="work"` rows are untouched — the test gate
2417
+ # still applies to them exactly as before (do NOT widen this to
2418
+ # `WORK_LIKE_TYPES`, which also contains `"work"`).
2419
+ _AUTO_SKIP_TEST_GATE_TYPES = ("mock-author", "test-author")
2420
+ if gate_test:
2421
+ from coord.state import record_test_verdict
2422
+
2423
+ for c in board.completed:
2424
+ if (
2425
+ c.type in _AUTO_SKIP_TEST_GATE_TYPES
2426
+ and c.review_state in (None, "pending")
2427
+ and c.test_state is None
2428
+ and c.assignment_id is not None
2429
+ ):
2430
+ record_test_verdict(
2431
+ assignment_id=c.assignment_id,
2432
+ test_state="skipped",
2433
+ test_reason=(
2434
+ f"Gate A {c.type}: contract/fixture-only diff, "
2435
+ "nothing to smoke-test (#1076/#1152)"
2436
+ ),
2437
+ )
2438
+ c.test_state = "skipped"
2439
+
2440
+ # #1565: dispatch-side backstop. review_state is supposed to be the
2441
+ # single source of truth for "does this row still need a review", but
2442
+ # it has been observed to regress to "pending" out from under a row that
2443
+ # already carries a real, terminal verdict on a completed review
2444
+ # assignment (a stale whole-board save_board() clobber, or a code path
2445
+ # that forgot to propagate the verdict onto the parent — see
2446
+ # `_advance_pipeline`'s #1565 fix). Before trusting review_state, check
2447
+ # for that shape directly and refuse to burn a second metered review
2448
+ # re-deriving a verdict that already exists — log loudly (a guard that
2449
+ # trips silently teaches nobody) and self-heal the row instead of
2450
+ # leaving it to trip this same guard every pass.
2451
+ for c in board.completed:
2452
+ if (
2453
+ c.review_state not in (None, "pending")
2454
+ or c.type not in WORK_LIKE_TYPES
2455
+ or c.assignment_id is None
2456
+ ):
2457
+ continue
2458
+ prior_verdict = next(
2459
+ (
2460
+ r.review_verdict
2461
+ for r in board.active + board.completed
2462
+ if r.type == "review"
2463
+ and r.review_of_assignment_id == c.assignment_id
2464
+ and r.review_verdict is not None
2465
+ ),
2466
+ None,
2467
+ )
2468
+ if prior_verdict is None:
2469
+ continue
2470
+ logger.warning(
2471
+ "dispatch guard (#1565): %s (%s #%s) already has a terminal "
2472
+ "review verdict %r recorded on a prior review assignment, but "
2473
+ "its own review_state=%r would make it eligible for another "
2474
+ "metered review — refusing to re-dispatch and self-healing "
2475
+ "review_state='done' instead.",
2476
+ c.assignment_id, c.repo_name, c.issue_number,
2477
+ prior_verdict, c.review_state,
2478
+ )
2479
+ from coord.state import record_work_review_verdict
2480
+
2481
+ c.review_state = "done"
2482
+ c.review_verdict = prior_verdict
2483
+ record_work_review_verdict(c.assignment_id, prior_verdict)
2484
+
2485
+ # #1612 step 2: enforce max_review_iterations here too, not just in
2486
+ # run_for_fix_transition. A fix row whose test verdict isn't in yet gets
2487
+ # deferred to this function (its review_state is set back to "pending"
2488
+ # rather than dispatching directly — see run_for_fix_transition's #1612
2489
+ # fix), so by the time it reaches the eligibility filter below its
2490
+ # review_iteration has normally already been checked once, upstream. This
2491
+ # is the defense-in-depth duplicate of that guard: without it, a row that
2492
+ # reaches review_state="pending" through any other path (a future code
2493
+ # path, a manual edit) would silently bypass the fix-loop cap instead of
2494
+ # stopping it here the same way the other bulk-path guards are duplicated
2495
+ # (claude-pty, terminal-work).
2496
+ _max_review_iter = config.pipeline.max_review_iterations
2497
+ for c in board.completed:
2498
+ if (
2499
+ c.review_state in (None, "pending")
2500
+ and c.type in WORK_LIKE_TYPES
2501
+ and c.status == "done"
2502
+ and (c.review_iteration or 0) >= _max_review_iter
2503
+ ):
2504
+ logger.warning(
2505
+ "dispatch_pending_reviews cap guard (#1612): %s (%s #%s) has "
2506
+ "review_iteration=%d >= max_review_iterations=%d — not "
2507
+ "dispatching another review.",
2508
+ c.assignment_id, c.repo_name, c.issue_number,
2509
+ c.review_iteration or 0, _max_review_iter,
2510
+ )
2511
+ from coord.auto_loop import _post_max_iterations_notice
2512
+
2513
+ _post_max_iterations_notice(c, config)
2514
+ c.review_state = "cap_hit"
2515
+
2516
+ eligible = [
2517
+ c
2518
+ for c in board.completed
2519
+ if c.review_state in (None, "pending")
2520
+ and c.type in WORK_LIKE_TYPES
2521
+ # #1534: only a genuinely SUCCESSFUL completion is review-eligible.
2522
+ # `dispatch_review` has always refused a non-`done` row internally,
2523
+ # but the bulk loop used to feed it every `failed`/`advisory` row on
2524
+ # the board on every pass (they carry `review_state=None`), which
2525
+ # made this loop's own eligibility list read as "review is pending
2526
+ # for these" when it was not — and made the surge/flood counters
2527
+ # below count rows that could never dispatch. Stating the invariant
2528
+ # here keeps the loop and the chokepoint agreeing.
2529
+ and c.status == "done"
2530
+ # #555: NEVER auto-dispatch a headless `claude -p` review for an
2531
+ # *interactive* (`provider_name="claude-pty"`) work completion. The
2532
+ # interactive Work→Review handoff is human-attended (TUI confirm →
2533
+ # interactive review); a metered headless review must not silently
2534
+ # follow it. This guard lives only in the automatic bulk path — the
2535
+ # explicit `coord review <id>` escape hatch (→ dispatch_review) still
2536
+ # lets a human deliberately request a headless review if they want one.
2537
+ and c.provider_name != "claude-pty"
2538
+ and (not gate_test or c.test_state in ("passed", "skipped"))
2539
+ # #1553: effective issue, not raw — see the matching comment on the
2540
+ # ``dispatch_review`` call site above; both must key on the same
2541
+ # thing has_active_work_followup itself keys on internally.
2542
+ and not has_active_work_followup(
2543
+ board, repo_name=c.repo_name, issue_number=effective_issue_number(c)
2544
+ )
2545
+ ]
2546
+ if not eligible:
2547
+ return []
2548
+
2549
+ threshold = config.reviews.flood_threshold
2550
+ override = (
2551
+ config.reviews.allow_review_flood
2552
+ or os.environ.get("COORD_ALLOW_REVIEW_FLOOD") == "1"
2553
+ )
2554
+ if threshold and len(eligible) > threshold and not override:
2555
+ logger.warning(
2556
+ "review flood guard: %d work rows are pending review (> "
2557
+ "reviews.flood_threshold=%d). Refusing bulk dispatch to avoid a "
2558
+ "metered review flood. Clear the stale backlog (mark reviewed/"
2559
+ "skipped), or set reviews.allow_review_flood: true (or "
2560
+ "COORD_ALLOW_REVIEW_FLOOD=1) to override.",
2561
+ len(eligible),
2562
+ threshold,
2563
+ )
2564
+ return []
2565
+
2566
+ cap = config.reviews.max_auto_dispatch_per_pass
2567
+ # #522: one terminal-state cache for this whole pass, so a backlog full of
2568
+ # already-merged rows (the #349 ×4 case) costs one gh lookup per issue, not
2569
+ # one per row revisited.
2570
+ terminal_cache: dict = {}
2571
+ dispatched: list = []
2572
+ for completed in eligible:
2573
+ if cap and len(dispatched) >= cap:
2574
+ break
2575
+ review = dispatch_review(
2576
+ completed, board, config, now=now, terminal_cache=terminal_cache
2577
+ )
2578
+ if review is not None:
2579
+ completed.review_state = "dispatched"
2580
+ dispatched.append(review)
2581
+ # On failure leave review_state as "pending" so the next pass retries.
2582
+ # Terminal rows are marked review_state="done" inside dispatch_review
2583
+ # (#522), dropping them from `eligible` on the next pass.
2584
+
2585
+ held = sum(1 for c in eligible if c.review_state in (None, "pending"))
2586
+ if held:
2587
+ logger.info(
2588
+ "review dispatch cap: dispatched %d this pass, %d held for next "
2589
+ "pass (reviews.max_auto_dispatch_per_pass=%d).",
2590
+ len(dispatched),
2591
+ held,
2592
+ cap,
2593
+ )
2594
+ return dispatched
2595
+
2596
+
2597
+ # ── Scoped re-review (#1476) ─────────────────────────────────────────────────
2598
+ #
2599
+ # When a conflict-fix rebase changes content under an already-`approve`d
2600
+ # review (the branch's patch-id no longer matches the one the review covered
2601
+ # — see #1475), `has_approved_review` correctly voids the stale approval, but
2602
+ # the only way to get an approval back today is a FULL re-review of the whole
2603
+ # PR — even when the conflict-fix resolution touched a handful of lines. The
2604
+ # functions below dispatch a review scoped to just that resolution delta
2605
+ # instead: the reviewer is told the PR was already approved, handed a diff
2606
+ # *of diffs* showing exactly what the rebase changed, and asked to rule on
2607
+ # that alone. `coord.merge_queue.find_scoped_review_candidate` /
2608
+ # `only_conflict_fix_since_review` gate when this path is eligible; a
2609
+ # request-changes verdict here is a completely ordinary `type="review"`
2610
+ # assignment (same `review_of_assignment_id` chain, same REVIEW_VERDICT
2611
+ # parsing), so the existing fix/re-review auto-loop drives it identically to
2612
+ # a full review — nothing downstream needs to know the review was scoped.
2613
+
2614
+
2615
+ def compute_resolution_delta(old_diff_text: str | None, new_diff_text: str | None) -> str | None:
2616
+ """Return a unified diff *between* two full unified diffs (#1476).
2617
+
2618
+ A diff of diffs: treats each of *old_diff_text* (the diff a prior review
2619
+ approved) and *new_diff_text* (the branch's current diff) as a plain text
2620
+ blob and runs :mod:`difflib` over them. The result shows exactly the
2621
+ lines a conflict-fix resolution touched — typically a couple of hunks —
2622
+ instead of forcing a reviewer to re-read the whole PR to find them.
2623
+
2624
+ Returns ``None`` when either input is missing/blank (nothing to scope a
2625
+ review around — the caller must fall back to a full review) or when the
2626
+ two diffs are textually identical (no delta to show — shouldn't happen
2627
+ once the caller has already confirmed the patch-ids differ, but this
2628
+ fails safe rather than dispatching a review with an empty "what changed"
2629
+ section).
2630
+ """
2631
+ if not old_diff_text or not old_diff_text.strip():
2632
+ return None
2633
+ if not new_diff_text or not new_diff_text.strip():
2634
+ return None
2635
+ old_lines = old_diff_text.splitlines(keepends=True)
2636
+ new_lines = new_diff_text.splitlines(keepends=True)
2637
+ delta = list(difflib.unified_diff(
2638
+ old_lines, new_lines,
2639
+ fromfile="previously-reviewed diff",
2640
+ tofile="current diff (post conflict-fix)",
2641
+ ))
2642
+ if not delta:
2643
+ return None
2644
+ return "".join(delta)
2645
+
2646
+
2647
+ def build_scoped_review_briefing(
2648
+ *,
2649
+ pr_number: int | None,
2650
+ pr_url: str | None,
2651
+ repo_github: str,
2652
+ repo_name: str,
2653
+ issue_number: int,
2654
+ issue_title: str,
2655
+ branch: str | None,
2656
+ resolution_delta: str,
2657
+ default_branch: str = "main",
2658
+ ) -> str:
2659
+ """Assemble a SCOPED re-review briefing (#1476). Pure function — testable.
2660
+
2661
+ Dispatched when a conflict-fix rebase changed content under an
2662
+ already-approved review with no other intervening work/fix commit
2663
+ (``coord.merge_queue.only_conflict_fix_since_review``). The reviewer is
2664
+ told the PR was already approved — it must not re-derive a verdict
2665
+ already reached — and is handed ONLY the resolution delta
2666
+ (:func:`compute_resolution_delta`) to rule on. This is the whole point:
2667
+ a ~1300-line PR whose conflict-fix resolution was two hunks costs a
2668
+ ~15-line read, not a full re-review (the #1453 motivating case).
2669
+ """
2670
+ lines: list[str] = []
2671
+ lines.append(f"# Scoped re-review: {repo_github} PR #{pr_number}")
2672
+ lines.append("")
2673
+ lines.append(
2674
+ f"You are re-reviewing issue #{issue_number}: {issue_title}. This PR "
2675
+ "was **already approved** by a previous review. Since then, an "
2676
+ "automated conflict-fix worker rebased the branch onto its target "
2677
+ "branch to resolve a merge conflict, and that rebase changed "
2678
+ "content — so the branch's content-fingerprint no longer matches "
2679
+ "what the prior review covered, and the approval was voided."
2680
+ )
2681
+ lines.append("")
2682
+ lines.append(
2683
+ "**You do NOT need to re-review the whole PR.** Everything the "
2684
+ "prior review already approved stands — do not re-litigate it and "
2685
+ "do not raise new findings about code the resolution delta below "
2686
+ "doesn't touch. Your ONLY job is to judge whether the conflict-fix "
2687
+ "resolution itself introduced a real bug or silently dropped "
2688
+ "something either side of the conflict needed."
2689
+ )
2690
+ lines.append("")
2691
+ lines.append("## Context")
2692
+ lines.append(f"- Repo: {repo_github} (local name: {repo_name})")
2693
+ lines.append(f"- Branch: {branch or '(unknown)'}")
2694
+ if pr_url:
2695
+ lines.append(f"- PR URL: {pr_url}")
2696
+ lines.append("")
2697
+ lines.append("## Resolution delta (review THIS)")
2698
+ lines.append("")
2699
+ lines.append(
2700
+ "This is a diff *of diffs* — the difference between the diff the "
2701
+ "prior review approved and the branch's current diff, computed "
2702
+ "with `git patch-id`-fingerprinted content that changed under the "
2703
+ "conflict-fix rebase. A line starting with `-` was in the "
2704
+ "previously-approved diff and is gone now; a line starting with "
2705
+ "`+` is new since the approval. This is exactly what the "
2706
+ "conflict-fix resolution changed — nothing else in the PR did."
2707
+ )
2708
+ lines.append("")
2709
+ lines.append("```diff")
2710
+ lines.append(resolution_delta.strip())
2711
+ lines.append("```")
2712
+ lines.append("")
2713
+ lines.append("## What to do")
2714
+ lines.append("")
2715
+ lines.append(
2716
+ "1. Read the resolution delta above. If a hunk doesn't make sense "
2717
+ "standalone, `git fetch origin && git diff origin/"
2718
+ f"{default_branch}...origin/{branch or 'HEAD'}` gets you the full "
2719
+ "current diff for extra context — but you should rarely need it."
2720
+ )
2721
+ lines.append(
2722
+ "2. `approve` unless the resolution itself introduces a genuine bug "
2723
+ "or silently drops content either side of the conflict needed. "
2724
+ "Do NOT block on style/nit findings about code outside the delta — "
2725
+ "that code was already approved."
2726
+ )
2727
+ lines.append(
2728
+ "3. Before you end your session, record your verdict TWICE — belt "
2729
+ "and braces, neither step substitutes for the other. FIRST, if the "
2730
+ "environment variable `COORD_ASSIGNMENT_ID` is set, write your full "
2731
+ "findings to a file and run `coord report-result --assignment "
2732
+ '"$COORD_ASSIGNMENT_ID" --status done --verdict '
2733
+ "approve|request-changes --body-file <file>` — this writes straight "
2734
+ "to the coordinator's board and is the authoritative record. If "
2735
+ "`COORD_ASSIGNMENT_ID` is unset, `coord` errors, or it's not on "
2736
+ "your PATH, say so plainly and move on to the required backup "
2737
+ "below regardless. THEN, at the END of your session, ALWAYS ALSO "
2738
+ "output your findings in this exact format as the PATH-independent "
2739
+ "backup (the coordinator posts the review to GitHub on your "
2740
+ "behalf — do NOT run any `gh` commands):"
2741
+ )
2742
+ lines.append("")
2743
+ lines.append("```")
2744
+ lines.append("REVIEW_VERDICT: approve")
2745
+ lines.append("REVIEW_BODY:")
2746
+ lines.append("<your full review text in markdown>")
2747
+ lines.append("END_REVIEW")
2748
+ lines.append("```")
2749
+ lines.append("")
2750
+ lines.append(
2751
+ "Use `REVIEW_VERDICT: request-changes` if the resolution introduced "
2752
+ "a real bug."
2753
+ )
2754
+ lines.append(
2755
+ "BODY STRUCTURE — same three headings as a normal review, always "
2756
+ "all three: `## Blocking findings`, `## Non-blocking concerns`, "
2757
+ "`## Nits`. Write the single line `None.` under a heading with "
2758
+ "nothing under it."
2759
+ )
2760
+ lines.append(
2761
+ "FORMAT CONTRACT — `REVIEW_VERDICT:`, `REVIEW_BODY:`, and "
2762
+ "`END_REVIEW` are parsed by machine: plain text at the start of "
2763
+ "their own line, no Markdown decoration. `END_REVIEW` is a HARD "
2764
+ "REQUIREMENT — an otherwise-complete review missing that exact "
2765
+ "line is discarded in its entirety."
2766
+ )
2767
+ return "\n".join(lines)
2768
+
2769
+
2770
+ def dispatch_scoped_review(
2771
+ entry,
2772
+ prior_review: Assignment,
2773
+ board: Board,
2774
+ config: Config,
2775
+ *,
2776
+ http_client: httpx.Client | None = None,
2777
+ now: float | None = None,
2778
+ diff_fetcher=None,
2779
+ branch_sha_fetcher=None,
2780
+ patch_id_computer=None,
2781
+ terminal_cache: dict | None = None,
2782
+ ) -> Assignment | None:
2783
+ """Dispatch a SCOPED re-review (#1476) for a merge entry whose approval
2784
+ was voided ONLY by a content-changing conflict-fix rebase.
2785
+
2786
+ Caller contract: only call this after
2787
+ :func:`coord.merge_queue.find_scoped_review_candidate` (its result is
2788
+ *prior_review*) and :func:`coord.merge_queue.only_conflict_fix_since_review`
2789
+ (the guardrail) have both confirmed this path applies — mirrors how
2790
+ :func:`dispatch_review` trusts :func:`dispatch_pending_reviews`'s
2791
+ eligibility filter rather than re-deriving it. *entry* is a
2792
+ ``coord.merge_queue.QueuedMerge``.
2793
+
2794
+ *diff_fetcher* defaults to :func:`coord.github_ops.get_compare_diff`
2795
+ (``(repo, base, ref) -> str | None``); inject a stub in tests. Fetches
2796
+ the diff *prior_review* covered (``target_branch...prior_review.
2797
+ review_head_sha``) and the branch's current diff (``target_branch...
2798
+ entry.branch``), computes the resolution delta between them, and — only
2799
+ when that delta is non-empty — dispatches a review briefed on just the
2800
+ delta. Returns ``None`` (caller falls back to a full :func:`dispatch_review`)
2801
+ when either diff can't be fetched, the delta comes back empty, no
2802
+ reviewer machine is available, or every candidate agent rejects the
2803
+ dispatch — never guesses at scope from partial information.
2804
+
2805
+ Returns the new review Assignment (already appended to ``board.active``,
2806
+ with ``review_scoped=True`` and ``review_scope_base_sha=prior_review.
2807
+ review_head_sha`` for the #1476 audit trail) on success.
2808
+
2809
+ Applies the same two structural guards as :func:`dispatch_review` before
2810
+ doing any work: the #522 terminal-work chokepoint (issue closed / PR
2811
+ merged — pass a shared *terminal_cache* dict across a bulk pass the same
2812
+ way :func:`dispatch_pending_reviews` does) and the #437 TOS-compliance
2813
+ gate (refuses a ``human_attended_only`` provider). Reviewer-candidate
2814
+ ranking excludes the machine that actually authored *entry*'s branch
2815
+ (looked up on *board* via ``entry.assignment_id`` — the work assignment,
2816
+ not the prior reviewer) so the scoped review stays independent of the
2817
+ code it's judging, mirroring :func:`dispatch_review`'s
2818
+ ``completed.machine_name`` contract.
2819
+ """
2820
+ if not config.reviews.enabled or not config.reviews.auto_dispatch:
2821
+ return None
2822
+ if not prior_review.review_head_sha:
2823
+ return None
2824
+
2825
+ repo = config.repo(entry.repo_name)
2826
+ if repo is None:
2827
+ return None
2828
+
2829
+ # #522 (mirrored from dispatch_review): never (re)dispatch a review for
2830
+ # work that's already done on GitHub — issue closed OR PR merged. Best
2831
+ # effort — a small race window remains before the merge-queue entry is
2832
+ # cleaned up, same as the full-review path.
2833
+ if github_ops.work_is_terminal(
2834
+ repo.github, entry.issue_number, entry.branch, cache=terminal_cache
2835
+ ):
2836
+ return None
2837
+
2838
+ # #437: STRUCTURAL TOS-COMPLIANCE GATE — mirrored from dispatch_review
2839
+ # (coord/review.py ~1463). Scoped reviews are dispatched from the same
2840
+ # unattended paths (reconcile()/coord notify) as a full review, so they
2841
+ # must be refused exactly the same way when the effective provider is
2842
+ # `human_attended_only` (interactive Claude Code via PTY, ToS §3.7).
2843
+ # Without this gate a repo/provider configured that way could have a
2844
+ # scoped review silently routed to it — the #1476 findings called this
2845
+ # out explicitly as a gap versus dispatch_review.
2846
+ # #1811: same review-only provider override as dispatch_review — see
2847
+ # its call site's comment for the precedence/no-op-when-unset rationale.
2848
+ from coord.providers import guard_unattended_dispatch # noqa: PLC0415
2849
+ try:
2850
+ review_provider_name = guard_unattended_dispatch(
2851
+ spec_provider=config.reviews.provider,
2852
+ repo_provider=repo.provider,
2853
+ providers_cfg=config.providers,
2854
+ models_cfg=config.models,
2855
+ where="auto-dispatch scoped review",
2856
+ )
2857
+ except ValueError as exc:
2858
+ log.warning("[review] skipping auto-dispatch scoped review: %s", exc)
2859
+ return None
2860
+
2861
+ base_branch = entry.target_branch or repo.default_branch
2862
+
2863
+ _diff = diff_fetcher or github_ops.get_compare_diff
2864
+ try:
2865
+ old_diff = _diff(repo.github, base_branch, prior_review.review_head_sha)
2866
+ except Exception: # noqa: BLE001 — fail-safe: unfetchable old diff → no scope
2867
+ old_diff = None
2868
+ try:
2869
+ new_diff = _diff(repo.github, base_branch, entry.branch)
2870
+ except Exception: # noqa: BLE001
2871
+ new_diff = None
2872
+
2873
+ delta = compute_resolution_delta(old_diff, new_diff)
2874
+ if delta is None:
2875
+ log.warning(
2876
+ "[review] scoped review for merge entry %s: could not compute a "
2877
+ "resolution delta (old/new diff unavailable or identical) — "
2878
+ "caller should fall back to a full review",
2879
+ entry.assignment_id,
2880
+ )
2881
+ return None
2882
+
2883
+ # #1476 fix: rank candidates against the machine that authored the code
2884
+ # under review — the WORK assignment behind *entry* — not the prior
2885
+ # reviewer's machine. ``QueuedMerge`` doesn't carry the worker's machine
2886
+ # name directly, so look up the work assignment on *board* by
2887
+ # ``entry.assignment_id``, exactly mirroring how ``dispatch_review``
2888
+ # passes ``completed.machine_name`` (``completed`` *is* the work
2889
+ # assignment there). Fall back to the prior reviewer's machine only if
2890
+ # the work assignment can no longer be found on the board (defensive;
2891
+ # keeps this fail-open rather than raising).
2892
+ worker_assignment = board.find_by_id(entry.assignment_id)
2893
+ worker_machine_name = (
2894
+ worker_assignment.machine_name if worker_assignment is not None
2895
+ else prior_review.machine_name
2896
+ )
2897
+ candidates = _ranked_reviewer_candidates(
2898
+ worker_machine_name, entry.repo_name, board, config
2899
+ )
2900
+ if not candidates:
2901
+ return None
2902
+
2903
+ review_model_alias = config.models.default
2904
+ review_model_wire = config.models.resolve(review_model_alias)
2905
+
2906
+ _get_sha = branch_sha_fetcher or github_ops.get_branch_sha
2907
+ review_head_sha: str | None = None
2908
+ try:
2909
+ review_head_sha = _get_sha(repo.github, entry.branch)
2910
+ except Exception: # noqa: BLE001 — fail-safe: missing SHA is not blocking
2911
+ pass
2912
+
2913
+ _compute_patch_id = patch_id_computer or github_ops.compute_patch_id
2914
+ review_patch_id: str | None = None
2915
+ try:
2916
+ review_patch_id = _compute_patch_id(new_diff)
2917
+ except Exception: # noqa: BLE001
2918
+ pass
2919
+
2920
+ client = http_client or httpx
2921
+ for machine, _same_as_worker in candidates:
2922
+ repo_path = machine.repo_path(entry.repo_name)
2923
+ if repo_path is None:
2924
+ continue
2925
+
2926
+ briefing = build_scoped_review_briefing(
2927
+ pr_number=entry.pr_number,
2928
+ pr_url=entry.pr_url,
2929
+ repo_github=repo.github,
2930
+ repo_name=repo.name,
2931
+ issue_number=entry.issue_number,
2932
+ issue_title=entry.issue_title,
2933
+ branch=entry.branch,
2934
+ resolution_delta=delta,
2935
+ default_branch=base_branch,
2936
+ )
2937
+
2938
+ payload = {
2939
+ "repo_name": entry.repo_name,
2940
+ "repo_path": repo_path,
2941
+ "issue_number": entry.issue_number,
2942
+ "issue_title": f"[scoped-review] {entry.issue_title}",
2943
+ "briefing": briefing,
2944
+ "files_allowed": [],
2945
+ "files_forbidden": [],
2946
+ "pull_repos": [],
2947
+ "type": "review",
2948
+ "model": review_model_wire,
2949
+ "system_prompt": REVIEWER_SYSTEM_PROMPT,
2950
+ "review_target": str(entry.pr_number) if entry.pr_number else entry.branch,
2951
+ "branch": base_branch or "main",
2952
+ }
2953
+ # #1811: mirror dispatch_review's wire-provider threading — see its
2954
+ # payload comment for why omitting this silently strands the
2955
+ # resolved provider at the TOS-gate check above.
2956
+ from coord.dispatch import _wire_payload_needs_provider_field # noqa: PLC0415
2957
+
2958
+ if review_provider_name and _wire_payload_needs_provider_field(
2959
+ review_provider_name, config,
2960
+ ):
2961
+ payload["provider"] = review_provider_name
2962
+
2963
+ url = f"http://{machine.host}:{AGENT_PORT}/assign"
2964
+ try:
2965
+ resp = client.post(url, json=payload, timeout=15)
2966
+ resp.raise_for_status()
2967
+ agent_response = resp.json()
2968
+ except (httpx.HTTPError, httpx.TimeoutException) as exc:
2969
+ log.warning(
2970
+ "[review] scoped-review agent %s unreachable/rejected (%s) — "
2971
+ "trying next candidate",
2972
+ machine.name, exc,
2973
+ )
2974
+ continue
2975
+
2976
+ review_assignment = Assignment(
2977
+ machine_name=machine.name,
2978
+ repo_name=entry.repo_name,
2979
+ issue_number=entry.issue_number,
2980
+ issue_title=f"[scoped-review] {entry.issue_title}",
2981
+ files_allowed=[],
2982
+ files_forbidden=[],
2983
+ briefing=briefing,
2984
+ assignment_id=agent_response.get("id") or uuid.uuid4().hex[:12],
2985
+ status="running",
2986
+ branch=entry.branch,
2987
+ pr_url=entry.pr_url,
2988
+ dispatched_at=now if now is not None else time.time(),
2989
+ type="review",
2990
+ review_target=str(entry.pr_number) if entry.pr_number else entry.branch,
2991
+ # Same parent as the review being superseded — keeps the
2992
+ # existing work-chain / fix-loop machinery (has_approved_review,
2993
+ # auto_loop's request-changes dispatch) working unmodified.
2994
+ review_of_assignment_id=prior_review.review_of_assignment_id,
2995
+ model=review_model_alias,
2996
+ provider_name=review_provider_name,
2997
+ review_head_sha=review_head_sha,
2998
+ review_patch_id=review_patch_id,
2999
+ # #1476 audit trail.
3000
+ review_scoped=True,
3001
+ review_scope_base_sha=prior_review.review_head_sha,
3002
+ )
3003
+ board.active.append(review_assignment)
3004
+
3005
+ from coord.state import record_dispatched_assignment # noqa: PLC0415
3006
+ record_dispatched_assignment(
3007
+ assignment=review_assignment,
3008
+ repo_github=repo.github,
3009
+ )
3010
+
3011
+ return review_assignment
3012
+
3013
+ return None
3014
+
3015
+
3016
+ def dispatch_scoped_reviews_for_queue(
3017
+ board: Board,
3018
+ config: Config,
3019
+ *,
3020
+ queue_items: list | None = None,
3021
+ http_client: httpx.Client | None = None,
3022
+ now: float | None = None,
3023
+ diff_fetcher=None,
3024
+ branch_sha_fetcher=None,
3025
+ branch_patch_id_fetcher=None,
3026
+ patch_id_computer=None,
3027
+ ) -> list[Assignment]:
3028
+ """Scan the merge queue for entries eligible for a #1476 SCOPED
3029
+ re-review and dispatch one for each, instead of leaving them blocked on
3030
+ "review required but not approved" until a human notices and manually
3031
+ forces a full re-review.
3032
+
3033
+ Mirrors :func:`dispatch_pending_reviews`'s "bounded pass, caller
3034
+ persists the board" shape so it slots into the same
3035
+ ``reconcile()``/``coord notify`` polling sites; unlike that function it
3036
+ also owns the merge-queue read/write itself (``queue_items`` defaults to
3037
+ :func:`coord.merge_queue.load_queue`, saved back at the end) since the
3038
+ scoped/full distinction is a property of the queue entry, not the board.
3039
+ It also mirrors that function's two review-flood-incident (2026-06-08)
3040
+ safety mechanisms — the ``reviews.flood_threshold`` surge gate and the
3041
+ ``reviews.max_auto_dispatch_per_pass`` per-pass cap — so a batch of
3042
+ conflict-fix rebases completing together can't fire an unbounded burst
3043
+ of metered ``claude -p`` reviews in a single pass.
3044
+
3045
+ An entry is eligible when: it's ``PENDING`` and review-gated
3046
+ (:func:`coord.merge_queue.requires_review`); it does NOT already have an
3047
+ approved review (:func:`coord.merge_queue.has_approved_review` — a
3048
+ content-identical rebase already carries the approval forward and needs
3049
+ nothing further);
3050
+ :func:`coord.merge_queue.find_scoped_review_candidate` finds a prior
3051
+ `approve`d review voided ONLY by a content-changing rebase; and
3052
+ :func:`coord.merge_queue.only_conflict_fix_since_review` confirms no
3053
+ other work/fix commit intervened. Entries failing any of these are left
3054
+ untouched for the existing full-review paths to handle. A dedupe check
3055
+ skips entries where a review dispatched after the prior approval is
3056
+ already in flight or completed, so a slow reconcile loop can't fire two
3057
+ scoped reviews for the same voided approval.
3058
+
3059
+ Returns the dispatched review Assignments (already on ``board.active``).
3060
+ """
3061
+ import os
3062
+
3063
+ from coord import merge_queue as mq # noqa: PLC0415
3064
+
3065
+ if not config.reviews.enabled or not config.reviews.auto_dispatch:
3066
+ return []
3067
+
3068
+ items = queue_items if queue_items is not None else mq.load_queue()
3069
+ _get_sha = branch_sha_fetcher or github_ops.get_branch_sha
3070
+ _get_branch_patch_id = branch_patch_id_fetcher or github_ops.get_branch_patch_id
3071
+
3072
+ eligible: list[tuple] = [] # (entry, prior_review)
3073
+ mutated = False
3074
+ for entry in items:
3075
+ if entry.state != mq.PENDING:
3076
+ continue
3077
+ if not mq.requires_review(entry, config):
3078
+ continue
3079
+
3080
+ if entry.branch_head_sha is None:
3081
+ try:
3082
+ entry.branch_head_sha = _get_sha(entry.repo_github, entry.branch)
3083
+ mutated = True
3084
+ except Exception: # noqa: BLE001 — fail-safe: leave unset
3085
+ pass
3086
+ if entry.branch_patch_id is None:
3087
+ try:
3088
+ entry.branch_patch_id = _get_branch_patch_id(
3089
+ entry.repo_github, entry.target_branch, entry.branch
3090
+ )
3091
+ mutated = True
3092
+ except Exception: # noqa: BLE001
3093
+ pass
3094
+
3095
+ if mq.has_approved_review(entry, board):
3096
+ continue # not stale, or a content-identical rebase covers it (#1475)
3097
+
3098
+ prior_review = mq.find_scoped_review_candidate(entry, board)
3099
+ if prior_review is None:
3100
+ continue # no scoped candidate — needs a full review, not this path
3101
+
3102
+ if not mq.only_conflict_fix_since_review(entry, board, prior_review):
3103
+ continue # guardrail: another commit intervened — full review required
3104
+
3105
+ pool = list(board.active) + list(board.completed)
3106
+ already_handled = any(
3107
+ a.type == "review"
3108
+ and a.review_of_assignment_id == prior_review.review_of_assignment_id
3109
+ and a.assignment_id != prior_review.assignment_id
3110
+ and (a.dispatched_at or 0) > (prior_review.dispatched_at or 0)
3111
+ for a in pool
3112
+ )
3113
+ if already_handled:
3114
+ continue
3115
+
3116
+ eligible.append((entry, prior_review))
3117
+
3118
+ def _persist() -> None:
3119
+ if mutated and queue_items is None:
3120
+ mq.save_queue(items)
3121
+
3122
+ if not eligible:
3123
+ _persist()
3124
+ return []
3125
+
3126
+ # Surge gate — same shape as dispatch_pending_reviews. A sudden surge is
3127
+ # the review-flood unmasking signature, so halt entirely and require a
3128
+ # human to clear the backlog or opt in.
3129
+ threshold = config.reviews.flood_threshold
3130
+ override = (
3131
+ config.reviews.allow_review_flood
3132
+ or os.environ.get("COORD_ALLOW_REVIEW_FLOOD") == "1"
3133
+ )
3134
+ if threshold and len(eligible) > threshold and not override:
3135
+ log.warning(
3136
+ "[review] scoped-review flood guard: %d merge-queue entries are "
3137
+ "eligible for a scoped re-review (> reviews.flood_threshold=%d). "
3138
+ "Refusing bulk dispatch to avoid a metered review flood. Clear "
3139
+ "the stale backlog, or set reviews.allow_review_flood: true (or "
3140
+ "COORD_ALLOW_REVIEW_FLOOD=1) to override.",
3141
+ len(eligible), threshold,
3142
+ )
3143
+ _persist()
3144
+ return []
3145
+
3146
+ # Per-pass cap — the remainder stay PENDING and are picked up next pass.
3147
+ cap = config.reviews.max_auto_dispatch_per_pass
3148
+ # #522: one terminal-state cache for this whole pass, mirrored from
3149
+ # dispatch_pending_reviews, so a backlog of already-merged entries costs
3150
+ # one gh lookup per issue, not one per entry revisited.
3151
+ terminal_cache: dict = {}
3152
+ dispatched: list[Assignment] = []
3153
+ for entry, prior_review in eligible:
3154
+ if cap and len(dispatched) >= cap:
3155
+ break
3156
+ review = dispatch_scoped_review(
3157
+ entry, prior_review, board, config,
3158
+ http_client=http_client,
3159
+ now=now,
3160
+ diff_fetcher=diff_fetcher,
3161
+ branch_sha_fetcher=branch_sha_fetcher,
3162
+ patch_id_computer=patch_id_computer,
3163
+ terminal_cache=terminal_cache,
3164
+ )
3165
+ if review is not None:
3166
+ dispatched.append(review)
3167
+
3168
+ _persist()
3169
+ return dispatched
3170
+
3171
+
3172
+ def _fetch_issue_body(repo_github: str, issue_number: int) -> str:
3173
+ """Best-effort fetch of the issue body for context. Empty on failure."""
3174
+ try:
3175
+ import json
3176
+ raw = github_ops._gh(
3177
+ "issue", "view", str(issue_number),
3178
+ "--repo", repo_github,
3179
+ "--json", "body",
3180
+ )
3181
+ return json.loads(raw).get("body", "") or ""
3182
+ except (RuntimeError, ValueError):
3183
+ return ""
3184
+
3185
+
3186
+ def _fetch_issue_milestone_number(repo_github: str, issue_number: int) -> int | None:
3187
+ """Best-effort fetch of the issue's GitHub Milestone number, or ``None``
3188
+ if it has none or the fetch fails (fail-open: #934's ``resolve_base_branch``
3189
+ falls back to ``default_branch`` when the milestone is unknown, same as
3190
+ when it's genuinely absent). Delegates to ``coord.branch_model.
3191
+ fetch_issue_milestone_number`` so every call site fails open the same way.
3192
+ """
3193
+ from coord.branch_model import fetch_issue_milestone_number # noqa: PLC0415
3194
+
3195
+ return fetch_issue_milestone_number(repo_github, issue_number)
3196
+
3197
+
3198
+ # ── Headless fix dispatch (dashboard / phone API) ────────────────────────────
3199
+
3200
+
3201
+ def dispatch_headless_fix(
3202
+ work: Assignment,
3203
+ board: Board,
3204
+ config: "Config",
3205
+ *,
3206
+ parent_type: str = "work",
3207
+ http_client=None,
3208
+ ) -> Assignment | None:
3209
+ """Dispatch a headless (``claude -p``) fix worker for a stalled pipeline item.
3210
+
3211
+ Called from ``POST /api/pipeline/action action=dispatch_fix`` so the phone
3212
+ can unstick a test-fail or request-changes item without attending an
3213
+ interactive terminal session.
3214
+
3215
+ ``work`` must be a ``type='work'`` assignment that already has a branch.
3216
+ ``parent_type`` selects which failure to address:
3217
+
3218
+ * ``"work"`` — fix a test-gate failure. The briefing is built from
3219
+ ``work.test_reason`` (recorded via ``coord test --fail --reason``).
3220
+ * ``"review"`` — fix a request-changes review verdict. The linked review
3221
+ assignment is located on the board and its findings are loaded via the
3222
+ multi-source chain in ``_load_review_findings`` (DB cache → local log →
3223
+ agent HTTP → GitHub message bus).
3224
+
3225
+ The fix worker is dispatched with ``target_branch=work.branch`` in the
3226
+ agent payload so it adds commits to the **existing** ``issue-N-*`` branch
3227
+ rather than branching fresh off main.
3228
+
3229
+ Returns the new fix ``Assignment`` (already added to ``board.active``),
3230
+ or ``None`` on failure (no capable machine, branch missing, findings
3231
+ unresolvable, or iteration limit reached).
3232
+ """
3233
+ from types import SimpleNamespace as _NS # noqa: PLC0415
3234
+
3235
+ # Deferred imports to avoid a circular-import cycle:
3236
+ # review.py is imported at module level by auto_loop.py, so we cannot
3237
+ # import auto_loop at review.py's module level.
3238
+ from coord.auto_loop import ( # noqa: PLC0415
3239
+ _build_fix_briefing,
3240
+ _dispatch_fix,
3241
+ _fix_model_for_iteration,
3242
+ _load_review_findings,
3243
+ _work_is_terminal,
3244
+ )
3245
+ from coord.state import issue_context_block # noqa: PLC0415
3246
+
3247
+ if not work.branch:
3248
+ return None
3249
+
3250
+ if _work_is_terminal(work, config):
3251
+ return None
3252
+
3253
+ next_iteration = (work.review_iteration or 0) + 1
3254
+ max_iter = config.pipeline.max_review_iterations
3255
+ if next_iteration > max_iter:
3256
+ return None
3257
+
3258
+ if parent_type == "review":
3259
+ # Find the review assignment linked to this work and load its findings.
3260
+ all_assignments = list(board.active) + list(board.completed)
3261
+ review_a: Assignment | None = next(
3262
+ (
3263
+ a for a in all_assignments
3264
+ if a.review_of_assignment_id == work.assignment_id
3265
+ and a.type == "review"
3266
+ ),
3267
+ None,
3268
+ )
3269
+ if review_a is None:
3270
+ return None
3271
+
3272
+ repo = config.repo(work.repo_name)
3273
+ repo_github = repo.github if repo is not None else None
3274
+ findings = _load_review_findings(
3275
+ review_a,
3276
+ None, # no local log path on the dashboard machine
3277
+ None, # no remote agent host — let GitHub fallback handle it
3278
+ repo_github=repo_github,
3279
+ )
3280
+ if findings is not None:
3281
+ findings_obj = findings
3282
+ else:
3283
+ # Fallback: generic pointer so the worker can still proceed.
3284
+ verdict = getattr(review_a, "review_verdict", None) or "request-changes"
3285
+ findings_obj = _NS(body=(
3286
+ f"(No structured findings were captured for review "
3287
+ f"{review_a.assignment_id}.) "
3288
+ f"The review verdict was {verdict!r}. "
3289
+ "Read the reviewer's feedback on the PR / issue comments and "
3290
+ "address every blocking item before pushing."
3291
+ ))
3292
+ else:
3293
+ # parent_type == "work": test-gate failure.
3294
+ test_story = (getattr(work, "test_reason", None) or "").strip()
3295
+ if test_story:
3296
+ findings_obj = _NS(body=(
3297
+ "The manual smoke test FAILED. The operator reported:\n\n"
3298
+ f"> {test_story}\n\n"
3299
+ "Reproduce the failure, fix the root cause, and re-validate "
3300
+ "before pushing."
3301
+ ))
3302
+ else:
3303
+ findings_obj = _NS(body=(
3304
+ "The manual smoke test FAILED (no reason text was recorded). "
3305
+ "Pull the branch, reproduce the failure the operator hit, "
3306
+ "and fix the root cause before pushing."
3307
+ ))
3308
+
3309
+ briefing = (
3310
+ issue_context_block(work.repo_name, work.issue_number)
3311
+ + _build_fix_briefing(work, findings_obj, next_iteration, max_iter)
3312
+ )
3313
+ model = _fix_model_for_iteration(config, next_iteration)
3314
+ return _dispatch_fix(
3315
+ work, briefing, board, config, next_iteration,
3316
+ model=model, http_client=http_client,
3317
+ )