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/auto_loop.py ADDED
@@ -0,0 +1,1440 @@
1
+ """Auto-loop: drive the review → fix → re-review cycle until clean.
2
+
3
+ When a **review** assignment completes with a verdict of ``request-changes``,
4
+ this module dispatches a fix worker on the same branch with the reviewer's
5
+ findings as the briefing. When the fix worker finishes, the normal review
6
+ dispatch machinery (reconcile / notify) fires another review automatically —
7
+ creating a closed loop.
8
+
9
+ The loop terminates when:
10
+ - A review approves the changes (verdict = ``approve``)
11
+ - The iteration count hits ``pipeline.max_review_iterations``
12
+ - A fix worker fails to dispatch (agent unreachable, no capable machine, etc.)
13
+
14
+ Config (coordinator.yml)::
15
+
16
+ pipeline:
17
+ auto_loop: true # default true
18
+ max_review_iterations: 3 # default 3
19
+
20
+ Integration:
21
+ Called from ``coord.notify.run()`` after review completion transitions are
22
+ posted. ``run_for_review_transition`` loads the board, processes the review,
23
+ saves if a fix was dispatched, and returns a list of :class:`LoopAction`
24
+ for logging.
25
+
26
+ Data model:
27
+ ``Assignment.review_iteration`` tracks the fix-round number. The original
28
+ work assignment has ``review_iteration=0``. Each fix worker gets the
29
+ previous worker's iteration + 1. When the auto-loop sees a review that
30
+ requests changes and the reviewed work's ``review_iteration >=
31
+ max_review_iterations``, it posts a notice to GitHub and stops.
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import logging
37
+ import time
38
+ import uuid
39
+ from dataclasses import dataclass
40
+
41
+ import httpx
42
+
43
+ from coord.config import Config
44
+ from coord.dispatch import AGENT_PORT
45
+ from coord import github_ops
46
+ from coord.models import Assignment, Board
47
+ from coord.review import (
48
+ ReviewFindings,
49
+ blocking_findings_confirmed_absent,
50
+ dispatch_review,
51
+ estimate_review_counts,
52
+ parse_review_from_agent,
53
+ parse_review_from_log,
54
+ )
55
+ from coord.board_service import read_board, write_board
56
+ from coord.state import record_dispatched_assignment
57
+ from coord.test_author import TEST_AUTHOR_SYSTEM_PROMPT, test_author_deny_commands
58
+
59
+ log = logging.getLogger(__name__)
60
+
61
+
62
+ # #1176 review: every assignment ``type`` value ``_dispatch_fix`` can emit,
63
+ # kept in sync with the ``fix_type`` mapping in that function. Exposed so
64
+ # ``coord.notify``'s fix-completion detector doesn't hardcode ``"work"`` and
65
+ # silently miss a newer fix-dispatch type — the same class of bug as #1141
66
+ # ("test-author was never added to WORK_LIKE_TYPES").
67
+ FIX_DISPATCH_TYPES: frozenset[str] = frozenset({"work", "test-author"})
68
+
69
+
70
+ # Every :class:`LoopAction` kind whose production means the in-memory board was
71
+ # mutated and must be written back. A fix was dispatched (new assignment row),
72
+ # an approve was parsed (so ``review_verdict`` is persisted for the merge gate,
73
+ # #253), an advisory-only review advanced the pipeline (#476 — ``review_verdict``
74
+ # flips to approve + ``review_state="done"`` so the merge gate unblocks; without
75
+ # this the gate suppresses the fix but the advance is never persisted and the PR
76
+ # silently can't merge), the work was found terminal (#522), or (#1663) a
77
+ # request-changes verdict was propagated onto the parent row without a fix
78
+ # dispatch.
79
+ #
80
+ # #1622: module-level rather than a local in ``_run_for_review_transition``
81
+ # because ``coord fix`` is now a second caller of
82
+ # :func:`process_review_completion` and has the same persist obligation. Two
83
+ # copies of this tuple is exactly the drift shape #1601/#1624 keep hitting.
84
+ PERSIST_ACTION_KINDS: tuple[str, ...] = (
85
+ "fix_dispatched", "approved", "approved_with_nits", "terminal_skip",
86
+ "verdict_propagated",
87
+ )
88
+
89
+
90
+ # ── Action reporting ──────────────────────────────────────────────────────────
91
+
92
+ @dataclass
93
+ class LoopAction:
94
+ """One step taken by the auto-loop, for logging and test assertions."""
95
+
96
+ kind: str
97
+ """One of:
98
+ - ``"fix_dispatched"`` — a fix worker was dispatched
99
+ - ``"approved"`` — review approved; no further action needed
100
+ - ``"approved_with_nits"`` — review said request-changes but flagged no
101
+ blocking findings (#476); pipeline advanced and
102
+ no fix dispatched
103
+ - ``"max_iterations"`` — loop stopped; user intervention required
104
+ - ``"no_findings"`` — log had no structured REVIEW_VERDICT block
105
+ - ``"no_work_found"`` — could not locate the work assignment on the board
106
+ - ``"disabled"`` — auto_loop is disabled in config
107
+ - ``"review_dispatched"`` — a re-review was dispatched after a fix worker completed
108
+ - ``"iteration_cap_hit"`` — fix.review_iteration >= max_review_iterations;
109
+ not dispatching another review
110
+ - ``"terminal_skip"`` — the work's issue is already closed or its PR is
111
+ already merged; no fix/review dispatched (#522)
112
+ - ``"interactive_skip"`` — the fix was an interactive (claude-pty) session;
113
+ its re-review is human-attended, so no headless
114
+ review was dispatched (#555)
115
+ - ``"test_gate_held"`` — pipeline.default_gates orders test before review
116
+ and the fix's test verdict isn't in yet
117
+ (``running``/``None``/``failed``); review_state
118
+ was set to ``"pending"`` so
119
+ ``dispatch_pending_reviews`` picks it up once a
120
+ ``passed``/``skipped`` verdict lands (#1612)
121
+ - ``"verdict_propagated"`` — #1663: the ``request-changes`` verdict was
122
+ written onto the parent work row but NO fix
123
+ worker was dispatched, because the caller asked
124
+ for bookkeeping only (``dispatch_fixes=False``
125
+ — the daemon drain, which must never spawn a
126
+ fix worker; #476/#477)
127
+ """
128
+ assignment_id: str | None
129
+ detail: str = ""
130
+
131
+
132
+ # ── Terminal-state guard (#522) ───────────────────────────────────────────────
133
+
134
+ def _work_is_terminal(
135
+ work: Assignment,
136
+ config: Config,
137
+ *,
138
+ cache: dict | None = None,
139
+ ) -> bool:
140
+ """True when *work* is already done on GitHub and must not be re-dispatched.
141
+
142
+ Thin Assignment/Config-shaped wrapper over
143
+ :func:`coord.github_ops.work_is_terminal` (the shared chokepoint guard,
144
+ #522) — resolves the repo's GitHub slug and delegates. Fail-open.
145
+ """
146
+ repo = config.repo(work.repo_name)
147
+ if repo is None or not repo.github:
148
+ return False
149
+
150
+ from coord import github_ops # noqa: PLC0415
151
+
152
+ return github_ops.work_is_terminal(
153
+ repo.github, work.issue_number, work.branch, cache=cache
154
+ )
155
+
156
+
157
+ # ── Core logic ────────────────────────────────────────────────────────────────
158
+
159
+ def _load_review_findings(
160
+ review: Assignment,
161
+ log_path: str | None,
162
+ machine_host: str | None,
163
+ repo_github: str | None = None,
164
+ ) -> ReviewFindings | None:
165
+ """Resolve a reviewer's structured findings.
166
+
167
+ Resolution order, cheapest first:
168
+ 1. **DB cache** — `notify` (or `report-result --body-file`) populates
169
+ `review_findings` on the row. Hit means zero I/O.
170
+ 2. **Local log file** — works when the review ran on this machine.
171
+ 3. **Agent HTTP `/logs/<id>`** — fetches the worker's full log
172
+ from the remote agent (claude -p reviews on another machine).
173
+ 4. **GitHub message bus** — when `repo_github` is supplied, recover the
174
+ findings posted to the issue under a `coord:review-findings` marker.
175
+ This is the cross-machine path for INTERACTIVE (claude-pty) reviews,
176
+ which have no parseable log and may not be in this machine's DB.
177
+
178
+ Returns `None` only when ALL sources fail.
179
+ """
180
+ # 1. DB cache — fastest.
181
+ if review.assignment_id:
182
+ try:
183
+ from coord.state import load_assignment_review_findings # noqa: PLC0415
184
+ cached = load_assignment_review_findings(review.assignment_id)
185
+ except Exception as exc: # noqa: BLE001
186
+ log.debug(
187
+ "auto_loop: DB cache lookup failed for %s: %s",
188
+ review.assignment_id, exc,
189
+ )
190
+ cached = None
191
+ if cached is not None:
192
+ verdict, body = cached
193
+ return ReviewFindings(verdict=verdict, body=body)
194
+
195
+ # 2. Local log file.
196
+ findings = parse_review_from_log(log_path) if log_path else None
197
+ if findings is not None:
198
+ return findings
199
+
200
+ # 3. Agent HTTP fallback.
201
+ if machine_host:
202
+ try:
203
+ findings = parse_review_from_agent(machine_host, review.assignment_id or "")
204
+ except Exception as exc: # noqa: BLE001
205
+ log.warning(
206
+ "auto_loop: failed to fetch review log from agent %s for %s: %s",
207
+ machine_host, review.assignment_id, exc,
208
+ )
209
+ findings = None
210
+ if findings is not None:
211
+ return findings
212
+
213
+ # 4. GitHub message bus — works on ANY machine (no shared DB / local log
214
+ # needed). Interactive (claude-pty) reviews post their full body to the
215
+ # issue under a `coord:review-findings` marker via `--body-file`; recover
216
+ # it here when the review ran elsewhere. This is the cross-machine path.
217
+ issue_number = getattr(review, "issue_number", None)
218
+ if repo_github and issue_number and review.assignment_id:
219
+ try:
220
+ from coord.review import fetch_review_findings_from_github # noqa: PLC0415
221
+ gh_findings = fetch_review_findings_from_github(
222
+ repo_github, int(issue_number), review.assignment_id,
223
+ )
224
+ except Exception as exc: # noqa: BLE001
225
+ log.debug(
226
+ "auto_loop: GitHub findings fetch failed for %s: %s",
227
+ review.assignment_id, exc,
228
+ )
229
+ gh_findings = None
230
+ if gh_findings is not None:
231
+ return gh_findings
232
+
233
+ return findings
234
+
235
+
236
+ def process_review_completion(
237
+ review: Assignment,
238
+ board: Board,
239
+ config: Config,
240
+ *,
241
+ log_path: str | None = None,
242
+ machine_host: str | None = None,
243
+ http_client: httpx.Client | None = None,
244
+ terminal_cache: dict | None = None,
245
+ dispatch_fixes: bool = True,
246
+ ) -> list[LoopAction]:
247
+ """Process a completed review assignment through the auto-loop.
248
+
249
+ Parses the reviewer's verdict (local log or agent HTTP fallback when
250
+ *machine_host* is supplied), then either:
251
+
252
+ - Returns an ``approved`` action (no side effects) if verdict is
253
+ ``approve``.
254
+ - Dispatches a fix worker (mutates *board*) if verdict is
255
+ ``request-changes`` and the iteration limit has not been reached.
256
+ - Posts a GitHub notice and returns a ``max_iterations`` action when the
257
+ loop has run too many times.
258
+
259
+ The caller is responsible for persisting the board after this returns.
260
+
261
+ **#1663 —** *dispatch_fixes* is the bookkeeping/dispatch seam. With
262
+ ``dispatch_fixes=False`` every verdict-determination step still runs (the
263
+ #476 approve-with-nits gate, the #1456 fail-closed rule, the
264
+ ``review_verdict`` capture) and the verdict is still propagated onto the
265
+ parent work row via :func:`propagate_review_verdict` — but a genuine
266
+ ``request-changes`` returns ``verdict_propagated`` instead of entering
267
+ :func:`_dispatch_fix_for_review`. No metered worker is ever spawned. This
268
+ is what the daemon drain calls: the drain's responsibility table puts the
269
+ parent-row write squarely in the "bookkeeping, no race, no cost if
270
+ repeated" class and fix dispatch squarely outside it, and before #1663 the
271
+ only way to get the former was to accept the latter.
272
+ """
273
+ if not config.pipeline.auto_loop:
274
+ return [LoopAction(kind="disabled", assignment_id=review.assignment_id)]
275
+
276
+ _rg = None
277
+ try:
278
+ _rc = config.repo(review.repo_name)
279
+ _rg = _rc.github if _rc is not None else None
280
+ except Exception: # noqa: BLE001
281
+ _rg = None
282
+ findings = _load_review_findings(review, log_path, machine_host, repo_github=_rg)
283
+ if findings is None:
284
+ log.debug(
285
+ "auto_loop: no structured REVIEW_VERDICT for %s (log=%r, host=%r) — skipping",
286
+ review.assignment_id, log_path, machine_host,
287
+ )
288
+ return [LoopAction(
289
+ kind="no_findings",
290
+ assignment_id=review.assignment_id,
291
+ detail=(
292
+ f"No structured review output (log={log_path!r}, host={machine_host!r})"
293
+ ),
294
+ )]
295
+
296
+ # #253: persist the parsed verdict on the review assignment so the merge
297
+ # gate can refuse to merge work whose review hasn't approved.
298
+ review.review_verdict = findings.verdict
299
+
300
+ if findings.verdict == "approve":
301
+ return _advance_pipeline(
302
+ review, board, config,
303
+ kind="approved",
304
+ detail="Review verdict: approve — pipeline advancing",
305
+ )
306
+
307
+ # verdict == "request-changes". #476 decision gate: a request-changes
308
+ # verdict that flags NO blocking findings — only non-blocking observations
309
+ # or nits — must NOT trigger another fix+review cycle. Doing so churns an
310
+ # already-correct PR over cosmetic suggestions and burns the session budget
311
+ # (the 2026-06-11 #532 incident: 3 real fix rounds, then a 4th round
312
+ # dispatched over a single cosmetic one-liner the reviewer itself counted
313
+ # as non-blocking). Treat advisory-only request-changes as approve-with-
314
+ # nits: advance the pipeline, surface the nits, and do not dispatch a fix.
315
+ #
316
+ # #1456 (CRITICAL, fails open): this gate used to fire on
317
+ # `parsed_any and not bool(blocking)`, which treated `blocking=None`
318
+ # ("could not determine") identically to `blocking=0` ("reviewer raised
319
+ # nothing blocking") — opposite meanings needing opposite defaults. On
320
+ # #1445 a reviewer's well-formed prose `request-changes` was rewritten to
321
+ # `approve` and marked merge-ready because the *nits* bucket happened to
322
+ # parse as 0 (satisfying `parsed_any`) while *blocking* parsed as None.
323
+ # The evidence standard now lives in `blocking_findings_confirmed_absent`
324
+ # and is fail-closed: no positive evidence ⇒ the reviewer's verdict stands.
325
+ blocking, nonblocking, nits = estimate_review_counts(findings.body)
326
+ if blocking_findings_confirmed_absent(findings.body):
327
+ # log.warning, not info: this is the coordinator overriding a human-
328
+ # readable rejection. It must be conspicuous in the log, and it is
329
+ # additionally recorded on the assignment (below) and posted to GitHub.
330
+ log.warning(
331
+ "auto_loop: OVERRIDING reviewer verdict for review %s — "
332
+ "request-changes with an explicitly empty blocking section "
333
+ "(blocking=%r nonblocking=%r nits=%r); advancing as approve-with-"
334
+ "nits per the #476 gate, not dispatching a fix",
335
+ review.assignment_id, blocking, nonblocking, nits,
336
+ )
337
+ # The merge gate keys off review_verdict; record approve so the nits
338
+ # don't block the merge. The nits remain visible in the review comment
339
+ # already posted to the PR, plus the advisory notice below.
340
+ #
341
+ # #1456: never *silently* rewrite a verdict — preserve the reviewer's
342
+ # own verdict alongside the override plus the counts that justified it,
343
+ # so an audit can always tell a reviewer approval from a coordinator
344
+ # one.
345
+ override_reason = (
346
+ f"#476 approve-with-nits gate: blocking={blocking} "
347
+ f"nonblocking={nonblocking} nits={nits}"
348
+ )
349
+ review.review_verdict_original = findings.verdict
350
+ review.review_verdict_override_reason = override_reason
351
+ review.review_verdict = "approve"
352
+ # #1956: this IS a coordinator override of the reviewer's own
353
+ # verdict — stamp the same provenance a human relaying an override
354
+ # via `coord report-result --verdict-source overridden` would carry,
355
+ # so this automatic path and a manual one read identically at every
356
+ # surface that shows verdict_source, instead of only the manual path
357
+ # being auditable.
358
+ review.verdict_source = "overridden"
359
+ review.verdict_source_reason = override_reason
360
+ _record_verdict_override(
361
+ review, board,
362
+ original_verdict=findings.verdict,
363
+ blocking=blocking, nonblocking=nonblocking, nits=nits,
364
+ )
365
+ _post_advisory_nits_notice(
366
+ review, board, config, nonblocking, nits,
367
+ original_verdict=findings.verdict,
368
+ )
369
+ return _advance_pipeline(
370
+ review, board, config,
371
+ kind="approved_with_nits",
372
+ detail=(
373
+ "Review requested changes but its blocking section was "
374
+ f"explicitly empty (nonblocking={nonblocking}, nits={nits}) — "
375
+ "advancing as approve-with-nits (reviewer verdict preserved in "
376
+ "review_verdict_original); no fix dispatched"
377
+ ),
378
+ )
379
+
380
+ if blocking is None:
381
+ # The common case now: the reviewer wrote prose with no recognisable
382
+ # blocking section. Fail closed and say so, so the operator can see
383
+ # *why* a fix round was dispatched rather than the gate firing.
384
+ log.info(
385
+ "auto_loop: could not determine a blocking-finding count for review "
386
+ "%s (blocking=None nonblocking=%r nits=%r) — honouring the "
387
+ "reviewer's request-changes verdict (#1456 fail-closed)",
388
+ review.assignment_id, nonblocking, nits,
389
+ )
390
+
391
+ # Genuine blocking findings (or counts unparseable) → dispatch a fix worker.
392
+ if not dispatch_fixes:
393
+ # #1663: the caller (the daemon drain) is explicitly not allowed to
394
+ # spawn a fix worker — that is the #476/#477 blast radius. Do the
395
+ # bookkeeping half anyway so the work row says `request-changes`
396
+ # instead of lying about being `dispatched` with no verdict: a drive,
397
+ # a human, or the #1441 stalled sweep can then act on a legible row.
398
+ # No merge-queue refresh — a request-changes row can never satisfy
399
+ # `has_approved_review`.
400
+ work = propagate_review_verdict(
401
+ review, board, config, refresh_merge_queue=False,
402
+ )
403
+ return [LoopAction(
404
+ kind="verdict_propagated",
405
+ assignment_id=review.assignment_id,
406
+ detail=(
407
+ "Review verdict: request-changes — propagated to work "
408
+ f"{work.assignment_id if work is not None else '<not on board>'}; "
409
+ "fix dispatch withheld (caller is bookkeeping-only)"
410
+ ),
411
+ )]
412
+
413
+ return _dispatch_fix_for_review(
414
+ review, findings, board, config,
415
+ http_client=http_client, terminal_cache=terminal_cache,
416
+ )
417
+
418
+
419
+ def propagate_review_verdict(
420
+ review: Assignment,
421
+ board: Board,
422
+ config: Config,
423
+ *,
424
+ refresh_merge_queue: bool = True,
425
+ ) -> "Assignment | None":
426
+ """Write *review*'s verdict onto the parent **work** row. Bookkeeping only.
427
+
428
+ This is the half of :func:`process_review_completion` that is pure
429
+ bookkeeping — no metered worker, no race, no cost if repeated:
430
+
431
+ - ``work.review_state = "done"``
432
+ - ``work.review_verdict = review.review_verdict``
433
+ - :func:`coord.state.record_work_review_verdict` (durable single-row write)
434
+ - the merge-queue entry refresh (*refresh_merge_queue*, approve only)
435
+
436
+ **#1663** is why this is a separate function. The exclusion that keeps
437
+ fix-worker dispatch out of the daemon drain (#476/#477) used to sit at
438
+ *function* granularity — the drain refused to enter
439
+ ``process_review_completion`` at all, which silently took the parent-row
440
+ write with it. Every verdict consumed by the drain instead of by a human's
441
+ ``coord notify`` left its work row at ``review_state='dispatched'`` /
442
+ ``review_verdict=NULL`` forever (the 2026-08-01 overnight batch: five
443
+ issues, four clean approves, 4h02m of wall clock, zero merges). Splitting
444
+ the bookkeeping out lets the drain call *this* without ever reaching
445
+ ``_dispatch_fix_for_review``.
446
+
447
+ Returns the parent work assignment when one was found and written, else
448
+ ``None`` (no ``review_of_assignment_id``, or it isn't on *board*).
449
+
450
+ *refresh_merge_queue* is ``True`` on the approve paths — #292 (Defect 2)
451
+ wants the entry created/re-keyed so the TUI shows Merge as ready without a
452
+ manual ``coord merge`` — and ``False`` on the request-changes paths, where
453
+ creating a PENDING queue entry would only add a row that
454
+ ``has_approved_review`` is guaranteed to reject.
455
+ """
456
+ if not review.review_of_assignment_id:
457
+ return None
458
+ work = board.find_by_id(review.review_of_assignment_id)
459
+ if work is None:
460
+ return None
461
+
462
+ work.review_state = "done"
463
+ # #1565: propagate the verdict onto the parent work row itself —
464
+ # it used to live only on the review assignment, which left the
465
+ # work row's own review_verdict NULL forever. Persist it as an
466
+ # immediate, scoped single-row write (not deferred to whichever
467
+ # caller happens to save the whole board afterward) so a crash,
468
+ # a skipped persist step, or a stale concurrent save_board()
469
+ # elsewhere can't leave the parent stuck at review_state=
470
+ # 'pending' with a real approval sitting unreferenced on the
471
+ # review row — the #1565 incident (4 redundant metered reviews
472
+ # re-deriving the same approval).
473
+ work.review_verdict = review.review_verdict
474
+ if work.assignment_id and review.review_verdict:
475
+ from coord.state import record_work_review_verdict # noqa: PLC0415
476
+
477
+ record_work_review_verdict(work.assignment_id, review.review_verdict)
478
+
479
+ if not refresh_merge_queue:
480
+ return work
481
+
482
+ # #292 (Defect 2): proactively enqueue/refresh the merge queue
483
+ # entry so the TUI shows the Merge stage as ready without requiring
484
+ # a manual `coord merge` run first. If the entry was keyed to an
485
+ # earlier work assignment (the original pre-bounce assignment),
486
+ # refresh_entry_assignment updates its assignment_id so
487
+ # has_approved_review can find this approval.
488
+ try:
489
+ from coord import merge_queue as mq # noqa: PLC0415
490
+ repo_cfg = config.repo(work.repo_name)
491
+ if repo_cfg is not None and work.branch:
492
+ # #934: target `feature/ms-NN` when this issue belongs
493
+ # to a milestone and the repo opted into the git model —
494
+ # the milestone lookup itself is skipped (no `gh` call)
495
+ # when it hasn't, falling back to `default_branch`.
496
+ target_branch = repo_cfg.default_branch
497
+ if getattr(repo_cfg, "develop_branch", None):
498
+ from coord.branch_model import ( # noqa: PLC0415
499
+ fetch_issue_milestone_number,
500
+ resolve_base_branch,
501
+ )
502
+
503
+ milestone_number = fetch_issue_milestone_number(
504
+ repo_cfg.github, work.issue_number,
505
+ )
506
+ target_branch = resolve_base_branch(repo_cfg, milestone_number)
507
+ mq.refresh_entry_assignment(
508
+ work,
509
+ repo_github=repo_cfg.github,
510
+ target_branch=target_branch,
511
+ )
512
+ except Exception as exc: # noqa: BLE001 — best-effort; merge gate still works
513
+ log.warning(
514
+ "auto_loop: refresh_entry_assignment failed for %s: %s",
515
+ work.assignment_id, exc,
516
+ )
517
+ return work
518
+
519
+
520
+ def _advance_pipeline(
521
+ review: Assignment,
522
+ board: Board,
523
+ config: Config,
524
+ *,
525
+ kind: str,
526
+ detail: str,
527
+ ) -> list[LoopAction]:
528
+ """Mark the reviewed work approved and refresh its merge-queue entry.
529
+
530
+ Shared by the plain ``approve`` path and the #476 approve-with-nits path so
531
+ both advance the pipeline identically (the only difference is the action
532
+ ``kind``/``detail`` reported back to the caller).
533
+ """
534
+ propagate_review_verdict(review, board, config)
535
+ return [LoopAction(kind=kind, assignment_id=review.assignment_id, detail=detail)]
536
+
537
+
538
+ def _record_verdict_override(
539
+ review: Assignment,
540
+ board: Board,
541
+ *,
542
+ original_verdict: str,
543
+ blocking: int | None,
544
+ nonblocking: int | None,
545
+ nits: int | None,
546
+ ) -> None:
547
+ """#1456: write a durable audit row when the coordinator downgrades a
548
+ reviewer's ``request-changes`` to ``approve``.
549
+
550
+ The third leg of the audit trail, after the board row (which carries both
551
+ verdicts) and the GitHub notice: a timestamped business-tier event, so an
552
+ operator can answer "which merges rode an overridden verdict?" after the
553
+ fact — including for work whose issue has since been closed. Best-effort;
554
+ an audit failure must never block the pipeline.
555
+ """
556
+ work = (
557
+ board.find_by_id(review.review_of_assignment_id)
558
+ if review.review_of_assignment_id
559
+ else None
560
+ )
561
+ try:
562
+ from coord.audit import record_audit # noqa: PLC0415
563
+
564
+ record_audit(
565
+ tier="business",
566
+ category="review",
567
+ event_type="review_verdict_overridden",
568
+ actor="coordinator",
569
+ summary=(
570
+ f"Coordinator overrode review verdict {original_verdict} → "
571
+ f"approve for {review.repo_name}#{review.issue_number} "
572
+ f"(#476 advisory-only gate: blocking={blocking}, "
573
+ f"nonblocking={nonblocking}, nits={nits})"
574
+ ),
575
+ repo=review.repo_name,
576
+ issue=review.issue_number,
577
+ assignment_id=review.assignment_id,
578
+ machine=review.machine_name,
579
+ details={
580
+ "original_verdict": original_verdict,
581
+ "effective_verdict": "approve",
582
+ "blocking": blocking,
583
+ "nonblocking": nonblocking,
584
+ "nits": nits,
585
+ "work_assignment_id": work.assignment_id if work else None,
586
+ },
587
+ )
588
+ except Exception as exc: # noqa: BLE001 — audit is best-effort
589
+ log.warning(
590
+ "auto_loop: failed to record verdict-override audit for %s: %s",
591
+ review.assignment_id, exc,
592
+ )
593
+
594
+
595
+ def _post_advisory_nits_notice(
596
+ review: Assignment,
597
+ board: Board,
598
+ config: Config,
599
+ nonblocking: int | None,
600
+ nits: int | None,
601
+ original_verdict: str = "request-changes",
602
+ ) -> None:
603
+ """Post a short audit-trail comment when the loop auto-advances past an
604
+ advisory-only request-changes verdict (#476).
605
+
606
+ Keeps the auto-advance *visible* — the user was previously burned by silent
607
+ auto-loop behaviour. Best-effort: a gh failure must never block the
608
+ pipeline. The full findings are already on the PR via the review comment;
609
+ this just records the decision not to dispatch another fix round.
610
+
611
+ #1456: the comment now names both verdicts — the reviewer's
612
+ (*original_verdict*) and the coordinator's override — so the GitHub thread
613
+ reads as an override, not as an approval the reviewer never gave.
614
+ """
615
+ work = (
616
+ board.find_by_id(review.review_of_assignment_id)
617
+ if review.review_of_assignment_id
618
+ else None
619
+ )
620
+ if work is None:
621
+ return
622
+ repo = config.repo(work.repo_name)
623
+ if repo is None:
624
+ return
625
+ from coord import github_ops # noqa: PLC0415
626
+
627
+ body = (
628
+ f"<!-- coord:event=auto_loop_advisory_advance assignment={work.assignment_id} "
629
+ f"original_verdict={original_verdict} override_verdict=approve -->\n"
630
+ f"## ⚠️ Coordinator overrode the review verdict (no blocking findings)\n\n"
631
+ f"- **Reviewer's verdict:** `{original_verdict}`\n"
632
+ f"- **Coordinator's override:** `approve` (approve-with-nits)\n"
633
+ f"- **Evidence:** the review's blocking section was **explicitly empty** "
634
+ f"(non-blocking={nonblocking}, nits={nits})\n\n"
635
+ f"The latest review of issue **#{work.issue_number}** returned "
636
+ f"`{original_verdict}` while flagging no blocking findings. Per the #476 "
637
+ f"decision gate, the coordinator is **not** dispatching another fix "
638
+ f"round over non-blocking suggestions — the PR advances to the merge "
639
+ f"gate. The reviewer's own verdict is preserved on the assignment "
640
+ f"(`review_verdict_original`); it has not been erased.\n\n"
641
+ f"The reviewer's notes remain in the review comment above. If any nit "
642
+ f"is in fact a must-fix, dispatch a fix manually with `coord assign` "
643
+ f"or bounce it before merging.\n"
644
+ )
645
+ try:
646
+ github_ops.post_issue_comment(repo.github, work.issue_number, body)
647
+ except Exception as exc: # noqa: BLE001
648
+ log.warning(
649
+ "auto_loop: failed to post advisory-advance notice for %s: %s",
650
+ work.assignment_id, exc,
651
+ )
652
+
653
+
654
+ def _fix_model_for_iteration(config: Config, iteration: int) -> str | None:
655
+ """Choose the model alias for a fix worker on a given bounce *iteration*.
656
+
657
+ Pure function so the iteration → model mapping is unit-testable.
658
+
659
+ Returns ``None`` when ``pipeline.escalate_fix_model`` is disabled — the
660
+ fix dispatch then sets no model and the agent falls back to ``claude -p``'s
661
+ default (today's behaviour).
662
+
663
+ When escalation is enabled:
664
+ - iteration 1 → ``config.models.default`` (first fix stays cheap/fast).
665
+ - iteration 2+ → climb one rung up ``config.models.escalation`` per
666
+ iteration, capped at the top of the ladder.
667
+
668
+ Example with escalation ``[haiku, sonnet, opus]`` and default ``sonnet``:
669
+ iter 1 → sonnet, iter 2 → opus, iter 3 → opus (capped).
670
+ """
671
+ if not config.pipeline.escalate_fix_model:
672
+ return None
673
+
674
+ model = config.models.default
675
+ # iteration 1 stays on the base model; each later iteration escalates one
676
+ # rung (next_model caps at the top of the ladder).
677
+ for _ in range(max(iteration, 1) - 1):
678
+ model = config.models.next_model(model)
679
+ return model
680
+
681
+
682
+ def _dispatch_fix_for_review(
683
+ review: Assignment,
684
+ findings,
685
+ board: Board,
686
+ config: Config,
687
+ *,
688
+ http_client: httpx.Client | None = None,
689
+ terminal_cache: dict | None = None,
690
+ ) -> list[LoopAction]:
691
+ """Find the reviewed work assignment and dispatch a fix worker for it."""
692
+ # Locate the work assignment that was reviewed.
693
+ work: Assignment | None = None
694
+ if review.review_of_assignment_id:
695
+ work = board.find_by_id(review.review_of_assignment_id)
696
+
697
+ if work is None:
698
+ log.warning(
699
+ "auto_loop: cannot find work assignment for review %s "
700
+ "(review_of_assignment_id=%r)",
701
+ review.assignment_id,
702
+ review.review_of_assignment_id,
703
+ )
704
+ return [LoopAction(
705
+ kind="no_work_found",
706
+ assignment_id=review.assignment_id,
707
+ detail=(
708
+ f"work assignment {review.review_of_assignment_id!r} not on board"
709
+ ),
710
+ )]
711
+
712
+ # #1663 (second gap): write the verdict onto the parent work row on the
713
+ # request-changes path too. `_advance_pipeline` has always done this for
714
+ # approve (#1565) but there was no request-changes twin — the only place
715
+ # this function ever touched the parent was the `_work_is_terminal` early
716
+ # return below, and even that set `review_state` without `review_verdict`.
717
+ # The fix worker is dispatched off the *transition* and doesn't need the
718
+ # row, so nothing broke loudly; what it cost was legibility — a row that
719
+ # still reads `dispatched`/NULL after a real rejection is invisible to
720
+ # `coord drive`, to the TUI's Review stage, and to any state-derived
721
+ # recovery sweep. Done BEFORE the terminal/iteration-cap branches so every
722
+ # outcome of this function leaves an honest parent row, not just the happy
723
+ # one. No merge-queue refresh: request-changes can never satisfy
724
+ # `has_approved_review`.
725
+ propagate_review_verdict(review, board, config, refresh_merge_queue=False)
726
+
727
+ # #522: never dispatch a fix for work that is already done on GitHub.
728
+ # A merged PR / closed issue must not re-enter the review→fix loop — this
729
+ # is the root cause of the 2026-06-09 launch flood (#349 ×4, #194).
730
+ if _work_is_terminal(work, config, cache=terminal_cache):
731
+ log.info(
732
+ "auto_loop: NOT dispatching fix for %s — issue #%s is terminal "
733
+ "(merged/closed)",
734
+ work.assignment_id, work.issue_number,
735
+ )
736
+ # The review of merged work is moot; mark it resolved so the board /
737
+ # merge gate stop treating it as needing another round.
738
+ work.review_state = "done"
739
+ return [LoopAction(
740
+ kind="terminal_skip",
741
+ assignment_id=review.assignment_id,
742
+ detail=(
743
+ f"issue #{work.issue_number} already merged/closed — "
744
+ "no fix dispatched"
745
+ ),
746
+ )]
747
+
748
+ # Compute the next iteration number and check the limit.
749
+ next_iteration = (work.review_iteration or 0) + 1
750
+ max_iter = config.pipeline.max_review_iterations
751
+
752
+ if next_iteration > max_iter:
753
+ log.warning(
754
+ "auto_loop: max_review_iterations (%d) reached for assignment %s "
755
+ "— stopping loop and notifying user",
756
+ max_iter, work.assignment_id,
757
+ )
758
+ _post_max_iterations_notice(work, config)
759
+ return [LoopAction(
760
+ kind="max_iterations",
761
+ assignment_id=review.assignment_id,
762
+ detail=(
763
+ f"max_review_iterations={max_iter} reached for "
764
+ f"work assignment {work.assignment_id}"
765
+ ),
766
+ )]
767
+
768
+ # Build briefing and dispatch. The fix worker escalates the model per
769
+ # iteration (when pipeline.escalate_fix_model is enabled); compute it here
770
+ # where the iteration is known and thread it into the dispatch.
771
+ # #603: prepend the per-issue context digest (prior-attempt findings,
772
+ # cross-repo deps) to the TOP of the -p fix briefing. The interactive fix
773
+ # path prefixes it at its own call site, so the shared _build_fix_briefing
774
+ # stays pure (no double injection).
775
+ from coord.state import issue_context_block # noqa: PLC0415
776
+
777
+ briefing = issue_context_block(work.repo_name, work.issue_number) + _build_fix_briefing(
778
+ work, findings, next_iteration, max_iter
779
+ )
780
+ model = _fix_model_for_iteration(config, next_iteration)
781
+ fix = _dispatch_fix(
782
+ work, briefing, board, config, next_iteration,
783
+ model=model, http_client=http_client,
784
+ )
785
+
786
+ if fix is None:
787
+ return [LoopAction(
788
+ kind="no_work_found",
789
+ assignment_id=review.assignment_id,
790
+ detail="fix worker dispatch failed (agent unreachable or no capable machine)",
791
+ )]
792
+
793
+ log.info(
794
+ "auto_loop: dispatched fix worker %s for review %s (iteration %d/%d)",
795
+ fix.assignment_id, review.assignment_id, next_iteration, max_iter,
796
+ )
797
+ return [LoopAction(
798
+ kind="fix_dispatched",
799
+ assignment_id=review.assignment_id,
800
+ detail=(
801
+ f"fix worker {fix.assignment_id} dispatched to {fix.machine_name} "
802
+ f"(iteration {next_iteration}/{max_iter})"
803
+ ),
804
+ )]
805
+
806
+
807
+ def _build_fix_briefing(
808
+ work: Assignment,
809
+ findings,
810
+ iteration: int,
811
+ max_iter: int,
812
+ ) -> str:
813
+ """Assemble the briefing for the fix worker. Pure function — easy to test.
814
+
815
+ #1176: a ``type="test-author"`` source row gets test-authoring-flavored
816
+ instructions instead — "make the acceptance suite pass" is actively
817
+ wrong guidance for an oracle that must stay RED until the real
818
+ implementation lands.
819
+ """
820
+ if work.type == "test-author":
821
+ return _build_test_author_fix_briefing(work, findings, iteration, max_iter)
822
+
823
+ lines: list[str] = [
824
+ f"# Fix assignment (iteration {iteration}/{max_iter}): {work.issue_title}",
825
+ "",
826
+ f"You are fixing review findings for issue #{work.issue_number}.",
827
+ (
828
+ f"Work on branch `{work.branch or '(check your git branches)'}` — "
829
+ "**do not change the branch name**."
830
+ ),
831
+ "",
832
+ "## Reviewer findings to address",
833
+ "",
834
+ findings.body.strip(),
835
+ "",
836
+ "## Instructions",
837
+ "",
838
+ "1. Read the review findings above carefully.",
839
+ "2. Fix **every** issue identified by the reviewer.",
840
+ "3. Stay on the **same branch** — push your fixes to the existing branch.",
841
+ "4. Run the project test suite and ensure all tests pass before pushing.",
842
+ (
843
+ f"5. This is fix iteration {iteration} of {max_iter} allowed. "
844
+ "Address all findings completely so the next review can approve."
845
+ ),
846
+ "",
847
+ "STATUS: reading review findings → implementing fixes → confidence: high",
848
+ "",
849
+ ]
850
+ if work.briefing and work.briefing.strip():
851
+ lines += [
852
+ "## Original work briefing",
853
+ "",
854
+ work.briefing.strip(),
855
+ "",
856
+ ]
857
+ return "\n".join(lines)
858
+
859
+
860
+ def _build_test_author_fix_briefing(
861
+ work: Assignment,
862
+ findings,
863
+ iteration: int,
864
+ max_iter: int,
865
+ ) -> str:
866
+ """Fix briefing for a ``type="test-author"`` slice (#1176).
867
+
868
+ Findings here are almost always test-quality issues (a dead
869
+ ``monkeypatch.setattr``, a fragile assertion that would false-fail a
870
+ correct implementation) — not a request to implement anything. The
871
+ acceptance suite must stay RED until the real implementation lands, so
872
+ "run tests, make them pass" (the generic fix instruction) is actively
873
+ wrong guidance and is deliberately NOT used here. The dispatcher pairs
874
+ this briefing with ``TEST_AUTHOR_SYSTEM_PROMPT`` (independence rules,
875
+ RED verification) — see ``_dispatch_fix``.
876
+ """
877
+ lines: list[str] = [
878
+ f"# Test-author fix (iteration {iteration}/{max_iter}): {work.issue_title}",
879
+ "",
880
+ f"You are the independent acceptance-test author fixing review findings "
881
+ f"against the acceptance suite for issue #{work.issue_number}.",
882
+ (
883
+ f"Work on branch `{work.branch or '(check your git branches)'}` — "
884
+ "**do not change the branch name**."
885
+ ),
886
+ "",
887
+ "## Reviewer findings to address",
888
+ "",
889
+ findings.body.strip(),
890
+ "",
891
+ "## Instructions",
892
+ "",
893
+ (
894
+ "1. Read the reviewer findings above carefully — they are almost "
895
+ "always test-quality issues (fragile assertions, stale/dead mocks, "
896
+ "tests that would false-fail a correct implementation), not a "
897
+ "request to implement anything."
898
+ ),
899
+ (
900
+ "2. Fix **every** issue identified by the reviewer, in the "
901
+ "acceptance suite under `tests/acceptance/` only."
902
+ ),
903
+ "3. Stay on the **same branch** — push your fixes to the existing branch.",
904
+ (
905
+ "4. Your tests MUST remain RED. Do NOT touch any implementation to "
906
+ "make them pass. Run the driver's run command yourself and confirm "
907
+ "the fixed tests still fail cleanly (not error out from a broken "
908
+ "framework hookup)."
909
+ ),
910
+ (
911
+ "5. Update the manifest if you added, removed, or renamed any test "
912
+ "ids — merge with the existing manifest, don't clobber it."
913
+ ),
914
+ (
915
+ f"6. This is fix iteration {iteration} of {max_iter} allowed. "
916
+ "Address all findings completely so the next review can approve."
917
+ ),
918
+ "",
919
+ "STATUS: reading review findings → fixing acceptance suite → confidence: high",
920
+ "",
921
+ ]
922
+ if work.briefing and work.briefing.strip():
923
+ lines += [
924
+ "## Original test-author briefing",
925
+ "",
926
+ work.briefing.strip(),
927
+ "",
928
+ ]
929
+ return "\n".join(lines)
930
+
931
+
932
+ def _dispatch_fix(
933
+ work: Assignment,
934
+ briefing: str,
935
+ board: Board,
936
+ config: Config,
937
+ iteration: int,
938
+ *,
939
+ model: str | None = None,
940
+ http_client: httpx.Client | None = None,
941
+ remote_branch_checker=None,
942
+ ) -> Assignment | None:
943
+ """POST a fix assignment to the agent server.
944
+
945
+ Prefers the same machine as the original worker (the branch is already
946
+ checked out there). Falls back to any capable machine.
947
+
948
+ #1176: the dispatched type mirrors ``work.type`` for ``"test-author"``
949
+ (with ``TEST_AUTHOR_SYSTEM_PROMPT`` + its deny-list, since the fix needs
950
+ the same acceptance-path authorization and independence rules as the
951
+ original test-author dispatch); every other source type still gets the
952
+ long-standing plain ``"work"`` fix.
953
+
954
+ Returns the new Assignment (already added to ``board.active``), or None
955
+ on failure.
956
+ """
957
+ # Pick machine: prefer the original worker's machine first.
958
+ machine = next(
959
+ (m for m in config.machines if m.name == work.machine_name), None
960
+ )
961
+ from coord.machine_pause import paused_set
962
+ paused = paused_set(config.machines)
963
+ if (
964
+ machine is None
965
+ or not machine.can_work_on(work.repo_name)
966
+ or machine.repo_path(work.repo_name) is None
967
+ or machine.name in paused
968
+ ):
969
+ # Fallback: any machine capable of working on this repo, minus
970
+ # any the user has paused via `coord pause` (routing-pause).
971
+ candidates = [
972
+ m for m in config.machines
973
+ if m.can_work_on(work.repo_name)
974
+ and m.repo_path(work.repo_name) is not None
975
+ and m.name not in paused
976
+ ]
977
+ if not candidates:
978
+ log.warning(
979
+ "auto_loop: no machine can handle repo %r (paused=%r)",
980
+ work.repo_name, sorted(paused)
981
+ )
982
+ return None
983
+ machine = candidates[0]
984
+
985
+ # #586: if we ended up routing to a different machine than the original
986
+ # worker, the branch must exist on the remote so the fix worker can fetch
987
+ # it. If the worker never pushed, this assignment would crash in 2–3
988
+ # seconds with no commits and no exit code — the classic branch-absent
989
+ # silent failure. Block early and surface a clear log message instead.
990
+ if machine.name != work.machine_name and work.branch:
991
+ repo_obj = config.repo(work.repo_name)
992
+ if repo_obj is not None:
993
+ _check_remote = remote_branch_checker or github_ops.branch_exists_on_remote
994
+ if not _check_remote(repo_obj.github, work.branch):
995
+ log.error(
996
+ "auto_loop: branch %r not on remote — cannot dispatch fix "
997
+ "to different machine %s; original worker %s must push "
998
+ "the branch to origin first",
999
+ work.branch, machine.name, work.machine_name,
1000
+ )
1001
+ return None
1002
+
1003
+ repo_path = machine.repo_path(work.repo_name)
1004
+ if repo_path is None:
1005
+ return None
1006
+
1007
+ repo = config.repo(work.repo_name)
1008
+ if repo is None:
1009
+ return None
1010
+
1011
+ # #1176: preserve the source row's type for a test-author slice fix —
1012
+ # dispatching `type="work"` sends a plain worker that is forbidden from
1013
+ # (and never given the system prompt authorizing) exactly the
1014
+ # `tests/acceptance/**` path it needs to fix. Every other source type
1015
+ # (including today's "work") keeps the long-standing "work" fix.
1016
+ fix_type = "test-author" if work.type == "test-author" else "work"
1017
+
1018
+ payload = {
1019
+ "repo_name": work.repo_name,
1020
+ "repo_path": repo_path,
1021
+ "issue_number": work.issue_number,
1022
+ "issue_title": f"[fix-{iteration}] {work.issue_title}",
1023
+ "briefing": briefing,
1024
+ "files_allowed": work.files_allowed,
1025
+ "files_forbidden": work.files_forbidden,
1026
+ "pull_repos": [],
1027
+ "type": fix_type,
1028
+ # #255: fix-loop dispatches inherit the repo's configured default
1029
+ # branch so the agent branches from origin/<default> rather than
1030
+ # any local-only ref.
1031
+ "branch": repo.default_branch or "main",
1032
+ # #target_branch: tell the agent to check out the ORIGINAL work's
1033
+ # branch rather than deriving a new one from the `[fix-N] …`
1034
+ # issue title. Without this the fix worker pushed to a
1035
+ # new orphan branch and the existing PR never received the fix
1036
+ # commits (quadraui#166 hit this hard).
1037
+ "target_branch": work.branch,
1038
+ }
1039
+ if fix_type == "test-author":
1040
+ # `agent.py`'s dispatch table has no `elif spec.type == "test-author"`
1041
+ # branch — it relies on the caller supplying `system_prompt`
1042
+ # explicitly (mirrors `test_author.dispatch_test_author`). Without
1043
+ # this the fix worker gets the generic WORKER_SYSTEM_PROMPT, which
1044
+ # is what #1176 is about: it never authorizes editing
1045
+ # `tests/acceptance/**` and doesn't carry the independence /
1046
+ # stay-RED rules a test-author session needs.
1047
+ payload["system_prompt"] = TEST_AUTHOR_SYSTEM_PROMPT
1048
+ payload["deny_commands"] = test_author_deny_commands(config, work.repo_name)
1049
+ # Escalated model per bounce iteration (None when pipeline
1050
+ # .escalate_fix_model is disabled — preserves today's no-model behaviour).
1051
+ # The board record keeps the alias for legibility; the wire payload is
1052
+ # resolved through models.versions so claude -p gets an exact id when
1053
+ # one is pinned.
1054
+ if model is not None:
1055
+ payload["model"] = config.models.resolve(model)
1056
+
1057
+ url = f"http://{machine.host}:{AGENT_PORT}/assign"
1058
+ client = http_client or httpx
1059
+ try:
1060
+ resp = client.post(url, json=payload, timeout=15)
1061
+ resp.raise_for_status()
1062
+ agent_response = resp.json()
1063
+ except (httpx.HTTPError, httpx.TimeoutException) as exc:
1064
+ log.warning("auto_loop: agent request failed for fix dispatch: %s", exc)
1065
+ return None
1066
+
1067
+ fix_assignment = Assignment(
1068
+ machine_name=machine.name,
1069
+ repo_name=work.repo_name,
1070
+ issue_number=work.issue_number,
1071
+ issue_title=f"[fix-{iteration}] {work.issue_title}",
1072
+ files_allowed=list(work.files_allowed),
1073
+ files_forbidden=list(work.files_forbidden),
1074
+ briefing=briefing,
1075
+ assignment_id=agent_response.get("id") or uuid.uuid4().hex[:12],
1076
+ status="running",
1077
+ branch=work.branch,
1078
+ pr_url=work.pr_url,
1079
+ dispatched_at=time.time(),
1080
+ type=fix_type,
1081
+ # Link back so the next review can find the work chain.
1082
+ review_of_assignment_id=work.assignment_id,
1083
+ # Iteration counter so the loop knows when to stop.
1084
+ review_iteration=iteration,
1085
+ # Escalated model for this bounce iteration (None preserves the
1086
+ # legacy behaviour where the agent picks claude -p's default).
1087
+ model=model,
1088
+ # #1176: carry over the JIT-slice correlation so the TUI's
1089
+ # per-issue Acceptance-Authoring mini-pipeline still recognizes
1090
+ # this fix as the same member issue's slice (None for every other
1091
+ # type, matching `test_author.dispatch_test_author`).
1092
+ for_issue_number=work.for_issue_number,
1093
+ )
1094
+ board.active.append(fix_assignment)
1095
+
1096
+ record_dispatched_assignment(
1097
+ assignment=fix_assignment,
1098
+ repo_github=repo.github,
1099
+ )
1100
+ return fix_assignment
1101
+
1102
+
1103
+ def _post_max_iterations_notice(work: Assignment, config: Config) -> None:
1104
+ """Post a GitHub issue comment when the loop hits the iteration limit."""
1105
+ from coord import github_ops # noqa: PLC0415
1106
+
1107
+ repo = config.repo(work.repo_name)
1108
+ if repo is None:
1109
+ return
1110
+
1111
+ completed_rounds = work.review_iteration # rounds completed so far
1112
+ max_iter = config.pipeline.max_review_iterations
1113
+ body = (
1114
+ f"<!-- coord:event=auto_loop_stopped assignment={work.assignment_id} -->\n"
1115
+ f"## ⚠️ Auto-loop stopped — max review iterations reached\n\n"
1116
+ f"The review → fix cycle for issue **#{work.issue_number}** has "
1117
+ f"completed **{completed_rounds}** fix round(s) without receiving an "
1118
+ f"approval, which equals the configured maximum of "
1119
+ f"**{max_iter}** `pipeline.max_review_iterations`.\n\n"
1120
+ f"**Manual intervention required.** Options:\n"
1121
+ f"- Review the diff and the reviewer's latest findings, then dispatch "
1122
+ f"a fix manually with `coord assign`.\n"
1123
+ f"- Run `coord merge --force-merge` to merge the branch as-is "
1124
+ f"(if the review findings are acceptable).\n"
1125
+ f"- Bump `pipeline.max_review_iterations` in `coordinator.yml` "
1126
+ f"(currently `{max_iter}`) to allow more automated fix rounds.\n"
1127
+ f"- Adjust the issue scope and open a fresh issue.\n\n"
1128
+ f"Details:\n"
1129
+ f"- Assignment: `{work.assignment_id}`\n"
1130
+ f"- Branch: `{work.branch or '(unknown)'}`\n"
1131
+ f"- Completed fix rounds: {completed_rounds}/{max_iter}\n"
1132
+ )
1133
+ try:
1134
+ github_ops.post_issue_comment(repo.github, work.issue_number, body)
1135
+ except Exception as exc: # noqa: BLE001
1136
+ log.warning(
1137
+ "auto_loop: failed to post max-iterations notice for %s: %s",
1138
+ work.assignment_id, exc,
1139
+ )
1140
+
1141
+
1142
+ # ── notify.py integration ─────────────────────────────────────────────────────
1143
+
1144
+ def run_for_fix_transition(
1145
+ assignment_id: str,
1146
+ config: Config,
1147
+ *,
1148
+ terminal_cache: dict | None = None,
1149
+ ) -> list[LoopAction]:
1150
+ """Entry point called from ``notify.run()`` for each completed fix worker.
1151
+
1152
+ When a bounce-fix worker (``type="work"``, ``review_of_assignment_id``
1153
+ IS NOT NULL, title starting with ``"[fix-"``) completes, dispatch a fresh
1154
+ review against it so the review → fix → re-review cycle closes
1155
+ automatically without manual ``coord pr`` invocations.
1156
+
1157
+ Caps re-review iterations at ``config.pipeline.max_review_iterations``
1158
+ using the fix worker's ``review_iteration`` field. When
1159
+ ``fix.review_iteration >= max_review_iterations`` the loop has already
1160
+ used all its fix rounds, so no further review is dispatched and an
1161
+ ``iteration_cap_hit`` action is returned instead.
1162
+
1163
+ Parameters
1164
+ ----------
1165
+ assignment_id:
1166
+ The completed fix worker's assignment ID.
1167
+ config:
1168
+ Parsed coordinator config.
1169
+
1170
+ Returns
1171
+ -------
1172
+ list[LoopAction]
1173
+ ``[LoopAction(kind="review_dispatched", ...)]`` on success,
1174
+ ``[LoopAction(kind="iteration_cap_hit", ...)]`` when the cap is hit,
1175
+ ``[LoopAction(kind="disabled", ...)]`` when auto_loop is off, or
1176
+ ``[]`` when the assignment is not found on the board or
1177
+ ``dispatch_review`` cannot find a capable machine.
1178
+ """
1179
+ if not config.pipeline.auto_loop:
1180
+ return [LoopAction(kind="disabled", assignment_id=assignment_id)]
1181
+
1182
+ # #749: read_board()/write_board() route through the daemon when
1183
+ # board_service is configured — previously this always hit the local DB
1184
+ # directly regardless of thin-client status.
1185
+ board = read_board()
1186
+
1187
+ fix = board.find_by_id(assignment_id)
1188
+ if fix is None:
1189
+ log.debug(
1190
+ "auto_loop: fix assignment %s not found on board — skipping",
1191
+ assignment_id,
1192
+ )
1193
+ return []
1194
+
1195
+ # #555: an *interactive* fix (provider_name="claude-pty") gets its re-review
1196
+ # from the human-attended TUI flow (leg 3 #517), never a headless metered
1197
+ # `claude -p` review. Skip the automatic re-review dispatch — mirrors the
1198
+ # dispatch_pending_reviews guard for the same interactive-blindness gap.
1199
+ if fix.provider_name == "claude-pty":
1200
+ log.info(
1201
+ "auto_loop: NOT dispatching headless re-review for %s — interactive "
1202
+ "fix (provider_name=claude-pty); re-review is human-attended",
1203
+ assignment_id,
1204
+ )
1205
+ return [LoopAction(
1206
+ kind="interactive_skip",
1207
+ assignment_id=assignment_id,
1208
+ detail=(
1209
+ "interactive fix — re-review is human-attended; "
1210
+ "no headless review dispatched"
1211
+ ),
1212
+ )]
1213
+
1214
+ # #522: a fix worker that finished against already-merged/closed work must
1215
+ # not trigger another review. Guards the second flood vector (re-review
1216
+ # dispatch) the same way the fix-dispatch path is guarded above.
1217
+ if _work_is_terminal(fix, config, cache=terminal_cache):
1218
+ log.info(
1219
+ "auto_loop: NOT dispatching re-review for %s — issue #%s is "
1220
+ "terminal (merged/closed)",
1221
+ assignment_id, fix.issue_number,
1222
+ )
1223
+ fix.review_state = "done"
1224
+ write_board(board)
1225
+ return [LoopAction(
1226
+ kind="terminal_skip",
1227
+ assignment_id=assignment_id,
1228
+ detail=(
1229
+ f"issue #{fix.issue_number} already merged/closed — "
1230
+ "no re-review dispatched"
1231
+ ),
1232
+ )]
1233
+
1234
+ max_iter = config.pipeline.max_review_iterations
1235
+ if fix.review_iteration >= max_iter:
1236
+ log.warning(
1237
+ "auto_loop: fix %s has review_iteration=%d >= max_review_iterations=%d "
1238
+ "— not dispatching another review",
1239
+ assignment_id, fix.review_iteration, max_iter,
1240
+ )
1241
+ # Surface the cap-hit as a persisted blocker: post a GitHub comment so
1242
+ # the operator sees it outside the TUI, mark the board entry with a
1243
+ # distinct review_state so `coord status` shows an explicit blocker line,
1244
+ # and save the board so the state survives a coordinator restart.
1245
+ _post_max_iterations_notice(fix, config)
1246
+ fix.review_state = "cap_hit"
1247
+ write_board(board)
1248
+ return [LoopAction(
1249
+ kind="iteration_cap_hit",
1250
+ assignment_id=assignment_id,
1251
+ detail=(
1252
+ f"fix iteration {fix.review_iteration} >= "
1253
+ f"max_review_iterations {max_iter}; "
1254
+ "not dispatching another review"
1255
+ ),
1256
+ )]
1257
+
1258
+ # #1612: this is the *other* auto-dispatch path — `dispatch_pending_reviews`
1259
+ # (reached from reconcile()/`coord notify`) holds review dispatch until the
1260
+ # work carries a passed/skipped test verdict whenever
1261
+ # `pipeline.default_gates` orders Test before Review, but this fix-transition
1262
+ # path called `dispatch_review` directly, bypassing that gate entirely. A
1263
+ # fix round's review was therefore dispatched while its smoke test was
1264
+ # still `running` (or before it had even started), burning a metered
1265
+ # review on code of unknown quality. Mirror the gate here — but holding
1266
+ # cannot just `return []`: this function fires once, on the fix worker's
1267
+ # completion transition (`notify.py`'s `fix_completions`), and is never
1268
+ # re-entered, so a bare return would strand the row with no review ever
1269
+ # dispatched. Instead, set `review_state="pending"` so the row becomes
1270
+ # eligible for `dispatch_pending_reviews`, which runs every pass and
1271
+ # already carries the correct gate — this is a deferral to that path, not
1272
+ # a drop.
1273
+ if config.pipeline.test_precedes_review() and fix.test_state not in (
1274
+ "passed", "skipped",
1275
+ ):
1276
+ log.info(
1277
+ "auto_loop: NOT dispatching re-review for %s yet — test gate is "
1278
+ "active and fix.test_state=%r is not a passed/skipped verdict; "
1279
+ "deferring to dispatch_pending_reviews",
1280
+ assignment_id, fix.test_state,
1281
+ )
1282
+ fix.review_state = "pending"
1283
+ write_board(board)
1284
+ return [LoopAction(
1285
+ kind="test_gate_held",
1286
+ assignment_id=assignment_id,
1287
+ detail=(
1288
+ f"test gate active — fix.test_state={fix.test_state!r} is not "
1289
+ "passed/skipped; review_state set to 'pending' for "
1290
+ "dispatch_pending_reviews to pick up once the verdict lands"
1291
+ ),
1292
+ )]
1293
+
1294
+ review = dispatch_review(fix, board, config)
1295
+
1296
+ if review is None:
1297
+ log.warning(
1298
+ "auto_loop: dispatch_review returned None for fix %s "
1299
+ "(no capable machine or dedup check rejected the dispatch)",
1300
+ assignment_id,
1301
+ )
1302
+ return []
1303
+
1304
+ fix.review_state = "dispatched"
1305
+ write_board(board)
1306
+
1307
+ log.info(
1308
+ "auto_loop: dispatched re-review %s for fix worker %s (iteration %d/%d)",
1309
+ review.assignment_id, assignment_id, fix.review_iteration, max_iter,
1310
+ )
1311
+ return [LoopAction(
1312
+ kind="review_dispatched",
1313
+ assignment_id=assignment_id,
1314
+ detail=(
1315
+ f"re-review {review.assignment_id} dispatched to "
1316
+ f"{review.machine_name} (fix iteration {fix.review_iteration}/"
1317
+ f"{max_iter})"
1318
+ ),
1319
+ )]
1320
+
1321
+
1322
+ def run_for_review_transition(
1323
+ assignment_id: str,
1324
+ record: dict,
1325
+ entry: dict,
1326
+ config: Config,
1327
+ *,
1328
+ terminal_cache: dict | None = None,
1329
+ ) -> list[LoopAction]:
1330
+ """Entry point called from ``notify.run()`` for each completed review.
1331
+
1332
+ Loads the board from the database, processes the review completion, saves
1333
+ the board if a fix worker was dispatched, and returns the list of actions
1334
+ taken.
1335
+
1336
+ **May dispatch a metered fix worker** — this is the ``coord notify`` /
1337
+ drive-nudge entry point. Callers that must not spawn one (the daemon
1338
+ drain) want :func:`propagate_review_verdict_for_transition` instead;
1339
+ ``tests/test_notify_drain.py::test_does_not_run_the_review_auto_loop``
1340
+ patches *this* name and asserts it is never called from a drain pass.
1341
+
1342
+ Parameters
1343
+ ----------
1344
+ assignment_id:
1345
+ The completed review's assignment ID.
1346
+ record:
1347
+ The dispatched-assignment record dict (from ``load_dispatched()``).
1348
+ entry:
1349
+ The agent /status entry for this assignment (contains ``log_path``).
1350
+ config:
1351
+ Parsed coordinator config.
1352
+ """
1353
+ return _run_for_review_transition(
1354
+ assignment_id, record, entry, config,
1355
+ terminal_cache=terminal_cache, dispatch_fixes=True,
1356
+ )
1357
+
1358
+
1359
+ def propagate_review_verdict_for_transition(
1360
+ assignment_id: str,
1361
+ record: dict,
1362
+ entry: dict,
1363
+ config: Config,
1364
+ ) -> list[LoopAction]:
1365
+ """#1663: the drain-safe half of :func:`run_for_review_transition`.
1366
+
1367
+ Same board load / verdict resolution / parent-row write / persist, with
1368
+ :func:`_dispatch_fix_for_review` unreachable — a genuine ``request-changes``
1369
+ comes back as ``verdict_propagated`` rather than ``fix_dispatched``.
1370
+
1371
+ A deliberately *separate public name*, not a keyword argument on
1372
+ ``run_for_review_transition``: the #476/#477 guard tests patch that symbol
1373
+ and assert it is never called from ``notify.run_drain``, and a flag would
1374
+ have made "the drain called the auto-loop entry point" indistinguishable
1375
+ from "the drain dispatched a fix worker" at the seam the guard watches.
1376
+
1377
+ No ``terminal_cache``: nothing on this path calls ``_work_is_terminal``, so
1378
+ a drain pass makes no ``gh`` round-trips on behalf of this function.
1379
+ """
1380
+ return _run_for_review_transition(
1381
+ assignment_id, record, entry, config,
1382
+ terminal_cache=None, dispatch_fixes=False,
1383
+ )
1384
+
1385
+
1386
+ def _run_for_review_transition(
1387
+ assignment_id: str,
1388
+ record: dict,
1389
+ entry: dict,
1390
+ config: Config,
1391
+ *,
1392
+ terminal_cache: dict | None = None,
1393
+ dispatch_fixes: bool = True,
1394
+ ) -> list[LoopAction]:
1395
+ """Shared body of the two review-transition entry points above."""
1396
+ if not config.pipeline.auto_loop:
1397
+ return [LoopAction(kind="disabled", assignment_id=assignment_id)]
1398
+
1399
+ if record.get("type") != "review":
1400
+ return []
1401
+
1402
+ # #749: read_board() routes through the daemon when board_service is
1403
+ # configured, instead of always hitting the local DB directly.
1404
+ board = read_board()
1405
+
1406
+ review = board.find_by_id(assignment_id)
1407
+ if review is None:
1408
+ # Review not on board yet — it was recorded by notify but the board
1409
+ # might not be persisted. Try looking it up by review_of_assignment_id
1410
+ # from the record dict and create a minimal proxy.
1411
+ log.debug(
1412
+ "auto_loop: review %s not found on board — cannot process", assignment_id
1413
+ )
1414
+ return []
1415
+
1416
+ log_path: str | None = entry.get("log_path")
1417
+ # #fix-cli: include the agent's host so the auto-loop can fall back
1418
+ # to HTTP /logs/<id> when the local log isn't on this filesystem
1419
+ # (the gap that left quadraui#166 without a fix dispatch).
1420
+ machine_host: str | None = None
1421
+ machine_name = record.get("machine_name")
1422
+ if machine_name:
1423
+ machine = next((m for m in config.machines if m.name == machine_name), None)
1424
+ if machine is not None and machine.host:
1425
+ machine_host = machine.host
1426
+ actions = process_review_completion(
1427
+ review,
1428
+ board,
1429
+ config,
1430
+ log_path=log_path,
1431
+ machine_host=machine_host,
1432
+ terminal_cache=terminal_cache,
1433
+ dispatch_fixes=dispatch_fixes,
1434
+ )
1435
+
1436
+ # See PERSIST_ACTION_KINDS for why each kind implies a board write.
1437
+ if any(a.kind in PERSIST_ACTION_KINDS for a in actions):
1438
+ write_board(board)
1439
+
1440
+ return actions