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
@@ -0,0 +1,1900 @@
1
+ """`coord merge` and the rest of the merge-queue surface: `verify-merge`,
2
+ `reconcile-merges`, `bounce`, `post-pending-reviews`. Extracted from
3
+ coord/cli.py (#747)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import sys
8
+ from pathlib import Path
9
+
10
+ import click
11
+
12
+
13
+ from coord.commands._common import _CONFIG_OPTION, _load_config
14
+ from coord.models import WORK_LIKE_TYPES
15
+
16
+
17
+ def _machine_for_assignment(board, assignment_id: str | None) -> str | None:
18
+ """Return the machine name that ran *assignment_id*, or None.
19
+
20
+ Used by ``coord merge`` (#241) to prefer dispatching a conflict-fix to
21
+ the original worker's machine — that machine already has the repo
22
+ checked out, the branch present, and the test deps installed.
23
+ """
24
+ if assignment_id is None or board is None:
25
+ return None
26
+ target = board.find_by_id(assignment_id)
27
+ return target.machine_name if target is not None else None
28
+
29
+
30
+ def _apply_revalidation(items, board, config, gh_ops, *, dry_run: bool):
31
+ """#1769: the ``--revalidate`` arm for the merge lane.
32
+
33
+ Finds the entries in *items* blocked **solely** on a stale-but-``passed``
34
+ smoke verdict (:func:`coord.merge_queue.revalidation_candidates` is the
35
+ whole eligibility policy — review/CI/conflict/missing-verdict blocks are
36
+ never touched), re-tests them against the current base, and lets
37
+ :func:`coord.merge_queue.process` re-evaluate afterwards.
38
+
39
+ Batch (#1715): candidates are grouped by ``(repo, target_branch)`` and each
40
+ group is composed onto its current base and validated by ONE suite run —
41
+ which is exactly what the operator did by hand three times in the
42
+ 2026-08-03 session that motivated this. N approved branches on one base
43
+ cost one suite run, not N.
44
+
45
+ A group whose composite fails merges **nothing** on that result and marks
46
+ nothing failed; :func:`coord.revalidate.revalidate_group` then re-tests each
47
+ branch alone so the culprit is named and the innocent branches still merge.
48
+ A failing re-test can never launder a merge in either pass.
49
+
50
+ Under ``--dry-run`` this only *names* the batches and their members — no
51
+ worktree, no suite, no verdict write.
52
+
53
+ Returns the board to hand to ``process()``: a freshly-loaded one when any
54
+ verdict was actually recorded (the in-memory board predates that write and
55
+ would still show the entries as stale), otherwise *board* unchanged.
56
+ """
57
+ from coord import merge_queue as _mq # noqa: PLC0415
58
+ from coord import revalidate as _rv # noqa: PLC0415
59
+
60
+ candidates = _mq.revalidation_candidates(items, board, config, gh_ops)
61
+ if not candidates:
62
+ click.echo(
63
+ " --revalidate: no entry is blocked solely on a stale test "
64
+ "verdict — nothing to revalidate (review/CI/conflict/missing-"
65
+ "verdict blocks are never revalidated)"
66
+ )
67
+ return board
68
+
69
+ if dry_run:
70
+ for line in _rv.describe_batches(candidates):
71
+ click.echo(line)
72
+ return board
73
+
74
+ for line in _rv.describe_candidates(candidates):
75
+ click.echo(line)
76
+
77
+ recorded_any = False
78
+ total_runs = 0
79
+ groups = _rv.group_candidates(candidates)
80
+ for (repo_name, target_branch), group in groups:
81
+ click.echo(
82
+ f" --revalidate: {repo_name} → {target_branch}: "
83
+ f"{len(group)} entry(ies)"
84
+ )
85
+ batch = _rv.revalidate_group(group, config, echo=click.echo)
86
+ total_runs += batch.suite_runs
87
+ recorded_any = recorded_any or bool(batch.recorded)
88
+ if not batch.composite.ok:
89
+ # The composite's own failure report — stderr, as before.
90
+ for line in _rv.format_failure(batch.composite):
91
+ click.echo(line, err=True)
92
+ # What actually came of it (what merged anyway, who the culprit was)
93
+ # is ordinary output, even when the composite above was red.
94
+ for line in _rv.format_batch(batch):
95
+ click.echo(line)
96
+
97
+ if len(candidates) > 1:
98
+ click.echo(
99
+ f" --revalidate: {total_runs} suite run(s) for "
100
+ f"{len(candidates)} entry(ies)"
101
+ )
102
+
103
+ if not recorded_any:
104
+ return board
105
+
106
+ from coord.models import Board as _Board # noqa: PLC0415
107
+ from coord.state import load_board as _load_board # noqa: PLC0415
108
+
109
+ refreshed = _load_board()
110
+ return refreshed if refreshed is not None else _Board(active=[], completed=[])
111
+
112
+
113
+ def _apply_ci_revalidation(
114
+ items, board, config, ci_store, gh_ops, *, dry_run: bool,
115
+ poll_sleep=None, poll_clock=None,
116
+ ) -> set[str]:
117
+ """#1851: the ``--revalidate`` arm for CI staleness — the CI analogue of
118
+ :func:`_apply_revalidation`'s stale-local-verdict arm, resolving a
119
+ different staleness signal (see ``coord/ci_store.py``'s module docstring
120
+ for why the two are distinct: GitHub re-runs ``pull_request`` checks on
121
+ head ``synchronize``, never on base movement, so a green check can
122
+ outlive the base it actually validated even when the local Test verdict
123
+ is perfectly fresh).
124
+
125
+ Unlike :func:`_apply_revalidation` there is nothing to compose and no
126
+ local suite to run — the remedy is :meth:`coord.ci_store.CiStore.
127
+ rerun_for_pr`, a ``gh run rerun`` that costs CI minutes on GitHub's own
128
+ runners, not a routed Test-stage agent. Strictly cheaper than what
129
+ :func:`_apply_revalidation` does for the same reason CI should always be
130
+ preferred when both would establish the same fact (#1851's "Cost
131
+ framing").
132
+
133
+ Opt-in behind ``--revalidate`` exactly like :func:`_apply_revalidation`
134
+ — never called from auto-drain (see ``docs/DRIVE_QUEUE.md``). Under
135
+ ``--dry-run`` this only *names* what it would trigger; no ``gh`` mutation
136
+ runs.
137
+
138
+ #1925: triggering a rerun and then handing the entry straight to
139
+ ``merge_queue.process()`` used to fail-close on the rerun's OWN
140
+ registration gap — ``gh pr checks`` errors for the few seconds before
141
+ GitHub has created any check-run record, which reads as the #1525
142
+ synthetic ``unknown`` conclusion and blocks exactly like a genuinely
143
+ broken CI would. :func:`coord.ci_store.wait_for_ci_settle` closes that
144
+ gap with a bounded poll right here, so the caller's subsequent
145
+ ``process()`` call sees a real, resolved result — pass or fail — for the
146
+ common case.
147
+
148
+ Returns the ``assignment_id``s whose wait ran out the budget while STILL
149
+ only seeing the registration-gap symptom (never a real check) —
150
+ :func:`wait_for_ci_settle`'s ``registering=True`` case. The caller must
151
+ exclude these from the ``process()`` call that follows: evaluating the
152
+ gate against that exact symptom is the bug this fixes, so it must not
153
+ reappear at the timeout edge just because the wait gave up. The entry
154
+ stays ``PENDING`` with an explanatory ``entry.error`` instead — legible
155
+ as "come back shortly", never as "checks failed" (#1925's acceptance:
156
+ an ``unknown`` this command caused must not be presented identically to
157
+ an ``unknown`` from genuinely broken CI).
158
+ """
159
+ from coord import merge_queue as _mq # noqa: PLC0415
160
+ from coord.ci_store import wait_for_ci_settle # noqa: PLC0415
161
+
162
+ deferred: set[str] = set()
163
+ if ci_store is None or not ci_store.is_available:
164
+ return deferred
165
+ candidates = _mq.ci_revalidation_candidates(items, board, config, ci_store, gh_ops)
166
+ if not candidates:
167
+ click.echo(
168
+ " --revalidate: no entry is blocked solely on stale CI checks "
169
+ "— nothing to re-run"
170
+ )
171
+ return deferred
172
+ for entry in candidates:
173
+ label = f"{entry.repo_name} #{entry.issue_number} ({entry.branch})"
174
+ if dry_run:
175
+ click.echo(
176
+ f" --revalidate: would re-run CI for {label} "
177
+ f"(PR #{entry.pr_number}) — checks predate the current base"
178
+ )
179
+ continue
180
+ ok = ci_store.rerun_for_pr(entry.repo_github, entry.pr_number)
181
+ if not ok:
182
+ click.echo(
183
+ f" --revalidate: could not trigger a CI re-run for {label} "
184
+ f"(PR #{entry.pr_number}) — see gh output above",
185
+ err=True,
186
+ )
187
+ continue
188
+ click.echo(
189
+ f" --revalidate: triggered a CI re-run for {label} "
190
+ f"(PR #{entry.pr_number})"
191
+ )
192
+ result = wait_for_ci_settle(
193
+ ci_store, entry.repo_github, entry.pr_number,
194
+ echo=click.echo, sleep=poll_sleep, clock=poll_clock,
195
+ )
196
+ if result.settled:
197
+ click.echo(
198
+ f" --revalidate: CI re-run for {label} settled after "
199
+ f"{result.waited_seconds:.0f}s — the merge gate will "
200
+ "evaluate the fresh result"
201
+ )
202
+ elif result.registering:
203
+ entry.error = (
204
+ f"{label}: the CI re-run --revalidate just triggered "
205
+ f"(PR #{entry.pr_number}) has not registered on GitHub yet "
206
+ f"after {result.waited_seconds:.0f}s — this is the re-run "
207
+ "THIS command started, not a CI failure; re-run `coord "
208
+ "merge --revalidate` (or plain `coord merge`) shortly (#1925)"
209
+ )
210
+ deferred.add(entry.assignment_id)
211
+ click.echo(f" --revalidate: {entry.error}")
212
+ else:
213
+ click.echo(
214
+ f" --revalidate: CI re-run for {label} is still running "
215
+ f"after {result.waited_seconds:.0f}s — leaving it to the "
216
+ "merge gate this pass (will report as CI still running)"
217
+ )
218
+ return deferred
219
+
220
+
221
+ def _dispatch_conflict_fixes(events, config, *, dry_run: bool) -> None:
222
+ """#241: classify any conflict events and dispatch a conflict-fix worker
223
+ for the eligible ones. Mutates each conflict event's ``ev.entry.state``
224
+ in place (to ``HUMAN_REQUIRED`` on a retry-cap hit or a non-rebaseable
225
+ classification) — ``ev.entry`` is the same object the caller's own
226
+ items list holds, so its subsequent save-queue step picks the mutation
227
+ up naturally, without a separate write here.
228
+
229
+ Shared by the whole-queue path and the ``--only`` surgical path
230
+ (#1474 review finding): the whole-queue path always ran this block, but
231
+ ``--only`` returned before ever reaching it — so a ``--only``-only
232
+ caller (``coord drive``, the TUI's ``--merge-of``) could park an entry
233
+ at ``CONFLICT`` with no conflict-fix ever dispatched and nothing
234
+ watching it, permanently: ``merge_queue.process()`` only ever acts on
235
+ ``PENDING`` entries, so a bare ``CONFLICT`` row is never reprocessed and
236
+ never gets a second chance at this classify-and-dispatch step.
237
+ """
238
+ conflict_events = [ev for ev in events if ev.kind == "conflict"]
239
+ if not conflict_events or dry_run:
240
+ return
241
+
242
+ from coord.audit import record_audit # noqa: PLC0415
243
+ from coord.conflict_fix import ( # noqa: PLC0415
244
+ dispatch_conflict_fix,
245
+ has_prior_conflict_fix,
246
+ )
247
+ from coord.merge_queue import ( # noqa: PLC0415
248
+ HUMAN_REQUIRED,
249
+ classify_conflict,
250
+ is_rebase_refusal,
251
+ )
252
+ from coord.state import load_board, save_board # noqa: PLC0415
253
+
254
+ fix_board = load_board()
255
+ if fix_board is None:
256
+ return
257
+ dispatched_any = False
258
+ for ev in conflict_events:
259
+ kind = classify_conflict(ev.entry.error)
260
+ if kind == "rebaseable":
261
+ # Retry cap (#241/#784): if a conflict-fix already ran and
262
+ # failed for this entry in this session, don't loop — mark
263
+ # HUMAN_REQUIRED so the user takes over. A successful
264
+ # prior fix does not trigger this guard (#784).
265
+ if has_prior_conflict_fix(fix_board, ev.entry.assignment_id):
266
+ ev.entry.state = HUMAN_REQUIRED
267
+ click.echo(
268
+ f" {ev.entry.repo_name} #{ev.entry.issue_number}: "
269
+ "conflict-fix retry cap hit — manual resolution required"
270
+ )
271
+ # #1467: a rebase-refusal ("This branch can't be rebased")
272
+ # is fixed by linearising the branch — spell out the exact
273
+ # recovery so it isn't left to archaeology, and give the
274
+ # durable `repo#issue` key (#1477) `--only` accepts.
275
+ if is_rebase_refusal(ev.entry.error):
276
+ _key = f"{ev.entry.repo_name}#{ev.entry.issue_number}"
277
+ click.echo(
278
+ f" recovery: git checkout {ev.entry.branch} && "
279
+ "git rebase origin/"
280
+ f"{ev.entry.target_branch} && "
281
+ "git push --force-with-lease, then "
282
+ f"`coord merge --only {_key} "
283
+ '--override-human-required "rebase refusal '
284
+ 'resolved manually"`'
285
+ )
286
+ # #1038: the coordinator's own retry-cap logic made this
287
+ # call, not the human running `coord merge` — operational
288
+ # tier, same as the other automatic conflict-classification
289
+ # outcomes below.
290
+ record_audit(
291
+ tier="operational",
292
+ category="merge",
293
+ event_type="conflict_human_required",
294
+ actor="daemon",
295
+ summary=f"conflict-fix retry cap hit: "
296
+ f"{ev.entry.repo_name}#{ev.entry.issue_number} — "
297
+ "manual resolution required",
298
+ repo=ev.entry.repo_name,
299
+ issue=ev.entry.issue_number,
300
+ assignment_id=ev.entry.assignment_id,
301
+ details={"reason": "retry_cap"},
302
+ )
303
+ continue
304
+ fix = dispatch_conflict_fix(
305
+ ev.entry,
306
+ fix_board,
307
+ config,
308
+ prefer_machine=_machine_for_assignment(
309
+ fix_board, ev.entry.assignment_id,
310
+ ),
311
+ )
312
+ if fix is not None:
313
+ click.echo(
314
+ f" {ev.entry.repo_name} #{ev.entry.issue_number}: "
315
+ f"conflict-fix dispatched to {fix.machine_name}"
316
+ )
317
+ dispatched_any = True
318
+ else:
319
+ click.echo(
320
+ f" {ev.entry.repo_name} #{ev.entry.issue_number}: "
321
+ "conflict-fix not dispatched (no machine / already in flight)"
322
+ )
323
+ elif kind == "human":
324
+ ev.entry.state = HUMAN_REQUIRED
325
+ click.echo(
326
+ f" {ev.entry.repo_name} #{ev.entry.issue_number}: "
327
+ "permission/protection error — manual resolution required"
328
+ )
329
+ record_audit(
330
+ tier="operational",
331
+ category="merge",
332
+ event_type="conflict_human_required",
333
+ actor="daemon",
334
+ summary=f"conflict classified non-rebaseable: "
335
+ f"{ev.entry.repo_name}#{ev.entry.issue_number} — "
336
+ "manual resolution required",
337
+ repo=ev.entry.repo_name,
338
+ issue=ev.entry.issue_number,
339
+ assignment_id=ev.entry.assignment_id,
340
+ details={"reason": "permission_or_protection"},
341
+ )
342
+ if dispatched_any:
343
+ save_board(fix_board)
344
+
345
+
346
+ @click.command(
347
+ "verify-merge",
348
+ help=(
349
+ "Self-check a --merge-of rebase before reporting done (#604). Run from "
350
+ "inside the merge worktree: `coord verify-merge <work_aid>`. Reports how "
351
+ "many commits the branch is still MISSING from the default branch "
352
+ "(`default-ahead`, must be 0), the commits it adds, and any FOREIGN "
353
+ "commits (referencing a different issue) — the signature of a botched "
354
+ "rebase that dragged in unrelated history. Exits non-zero when the "
355
+ "branch is not merge-ready."
356
+ ),
357
+ )
358
+
359
+
360
+ @click.argument("work_aid")
361
+ @click.option(
362
+ "--path",
363
+ "path_opt",
364
+ type=click.Path(file_okay=False),
365
+ default=None,
366
+ help="Worktree to check (default: current directory).",
367
+ )
368
+
369
+
370
+ @click.option(
371
+ "--repo",
372
+ "repo_opt",
373
+ default=None,
374
+ help=(
375
+ "Repo name — fallback when the assignment is not found on the board "
376
+ "(thin-client machines where the board lives on the daemon, #681)."
377
+ ),
378
+ )
379
+
380
+
381
+ @click.option(
382
+ "--issue-number",
383
+ "issue_number_opt",
384
+ type=int,
385
+ default=None,
386
+ help=(
387
+ "Issue number — fallback when the assignment is not found on the board "
388
+ "(thin-client machines where the board lives on the daemon, #681)."
389
+ ),
390
+ )
391
+
392
+
393
+ @_CONFIG_OPTION
394
+ def verify_merge(
395
+ work_aid: str,
396
+ path_opt: str | None,
397
+ repo_opt: str | None,
398
+ issue_number_opt: int | None,
399
+ config_path: Path,
400
+ ) -> None:
401
+ """``coord verify-merge <work_aid>`` — git-truth check of a merge-prep branch.
402
+
403
+ Resolves the issue + default branch from the *work* assignment id (the same
404
+ id passed to ``coord assign --merge-of``) and runs the shared
405
+ :func:`coord.agent.verify_merge_branch` primitive against the worktree the
406
+ merge agent is sitting in. This is the defense-in-depth twin of the
407
+ coordinator-side gate in :func:`coord.interactive.finalize_interactive_exit`:
408
+ same check, available to the agent before it self-reports.
409
+
410
+ On thin-client machines (where the canonical board lives on a daemon) the
411
+ board is fetched from the daemon automatically (#681). As a last-resort
412
+ fallback, supply ``--repo`` and ``--issue-number`` explicitly so the check
413
+ can run even when the board lookup returns nothing.
414
+ """
415
+ from coord.agent import ( # noqa: PLC0415
416
+ resolve_closed_issue_numbers,
417
+ verify_merge_branch,
418
+ )
419
+ from coord.board_service import read_board # noqa: PLC0415
420
+
421
+ cfg = _load_config(config_path)
422
+ board = read_board()
423
+ work = board.find_by_id(work_aid)
424
+ if work is None:
425
+ if repo_opt and issue_number_opt is not None:
426
+ # Thin-client fallback: the board lookup found nothing (empty local
427
+ # DB or daemon didn't carry this aid), but the caller supplied the
428
+ # known values explicitly via --repo / --issue-number (#681).
429
+ repo_name = repo_opt
430
+ issue_num = issue_number_opt
431
+ branch_display = "(unknown)"
432
+ else:
433
+ click.echo(
434
+ f"error: no assignment {work_aid!r} on the board "
435
+ "(use the work id from `coord status`, or supply "
436
+ "--repo and --issue-number as a fallback).",
437
+ err=True,
438
+ )
439
+ sys.exit(2)
440
+ else:
441
+ repo_name = work.repo_name
442
+ issue_num = int(work.issue_number)
443
+ branch_display = work.branch or "(unknown)"
444
+
445
+ repo_cfg = cfg.repo(repo_name)
446
+ base = (repo_cfg.default_branch if repo_cfg else None) or "main"
447
+ if repo_cfg is not None and getattr(repo_cfg, "develop_branch", None):
448
+ # #934: verify against `feature/ms-NN` when this issue belongs to a
449
+ # milestone and the repo opted into the git model — falls back to
450
+ # `default_branch` (above) for everything else.
451
+ from coord.branch_model import ( # noqa: PLC0415
452
+ fetch_issue_milestone_number,
453
+ resolve_base_branch,
454
+ )
455
+
456
+ milestone_number = fetch_issue_milestone_number(repo_cfg.github, issue_num)
457
+ base = resolve_base_branch(repo_cfg, milestone_number)
458
+ repo_github = repo_cfg.github if repo_cfg else None
459
+ wt_path = Path(path_opt).expanduser() if path_opt else Path.cwd()
460
+
461
+ mv = verify_merge_branch(wt_path, base=base, issue_number=issue_num)
462
+ # #1279: only worth a `gh` round-trip when the cheap git-only pass above
463
+ # actually found blocking foreign commits — corroborate against GitHub's
464
+ # closed-issue state and re-verify with the downgrade signal populated.
465
+ closed = resolve_closed_issue_numbers(repo_github, mv.foreign, issue_num)
466
+ if closed:
467
+ mv = verify_merge_branch(
468
+ wt_path, base=base, issue_number=issue_num, closed_issue_numbers=closed
469
+ )
470
+
471
+ click.echo(f"branch: {branch_display}")
472
+ click.echo(f"target base: {base}")
473
+ click.echo(f"{base}-ahead: {mv.default_ahead} (must be 0)")
474
+ click.echo(f"adds {len(mv.added)} commit(s) over {base}:")
475
+ advisory_set = set(mv.advisory_foreign)
476
+ for sha, subj in mv.added:
477
+ if (sha, subj) in mv.foreign:
478
+ flag = " [FOREIGN — BLOCKING]"
479
+ elif (sha, subj) in advisory_set:
480
+ flag = " [advisory: references closed issue]"
481
+ else:
482
+ flag = ""
483
+ click.echo(f" {sha[:9]} {subj}{flag}")
484
+
485
+ if mv.ok:
486
+ click.echo("✓ merge-ready: base fully contained, no foreign commits.")
487
+ note = mv.advisory_note()
488
+ if note:
489
+ click.echo(f" {note}")
490
+ return
491
+ click.echo(f"✗ NOT merge-ready: {mv.block_summary(base)}", err=True)
492
+ sys.exit(1)
493
+
494
+
495
+ @click.command(
496
+ help=(
497
+ "Bounce the pipeline back to Work after a review requested changes. "
498
+ "Dispatches a fix worker that reads the reviewer's findings as its "
499
+ "briefing and pushes corrections to the same branch."
500
+ ),
501
+ )
502
+
503
+
504
+ @click.argument("review_assignment_id")
505
+ @_CONFIG_OPTION
506
+ def bounce(review_assignment_id: str, config_path: Path) -> None:
507
+ """Manual trigger for the auto-loop's fix-dispatch path.
508
+
509
+ `coord notify` already runs this automatically the first time a
510
+ review completion is observed, but the auto-loop bails when the
511
+ review log isn't reachable at that moment (remote agent offline /
512
+ log pruned). This command re-runs the same dispatch on demand —
513
+ useful as a recovery path for the user and as the TUI's "Fix"
514
+ button.
515
+ """
516
+ from coord.auto_loop import process_review_completion
517
+ from coord.board_service import read_board, write_board
518
+ from coord.state import COORD_DIR
519
+
520
+ cfg = _load_config(config_path)
521
+ board = read_board()
522
+
523
+ review = board.find_by_id(review_assignment_id)
524
+ if review is None:
525
+ click.echo(
526
+ f"error: assignment {review_assignment_id!r} not found in board",
527
+ err=True,
528
+ )
529
+ sys.exit(1)
530
+ if review.type != "review":
531
+ click.echo(
532
+ f"error: {review_assignment_id} is type={review.type!r}, not 'review'. "
533
+ f"Pass the review assignment id, not the work assignment id.",
534
+ err=True,
535
+ )
536
+ sys.exit(1)
537
+ if review.review_verdict not in ("request-changes", None):
538
+ click.echo(
539
+ f"info: review verdict is {review.review_verdict!r} — only "
540
+ f"'request-changes' triggers a fix dispatch. Nothing to do.",
541
+ err=True,
542
+ )
543
+ sys.exit(1)
544
+
545
+ # Try local log first; fall back to agent HTTP /logs when the
546
+ # review ran on a remote machine and the file isn't on this
547
+ # coordinator's filesystem.
548
+ machine = next(
549
+ (m for m in cfg.machines if m.name == review.machine_name), None,
550
+ )
551
+ machine_host = machine.host if machine and machine.host else None
552
+ local_log = COORD_DIR / "logs" / f"{review_assignment_id}.log"
553
+ log_path = str(local_log) if local_log.exists() else None
554
+
555
+ actions = process_review_completion(
556
+ review,
557
+ board,
558
+ cfg,
559
+ log_path=log_path,
560
+ machine_host=machine_host,
561
+ )
562
+
563
+ dispatched = any(a.kind == "fix_dispatched" for a in actions)
564
+ # #522: terminal_skip mutates work.review_state="done" in
565
+ # process_review_completion — persist it (same as the notify path) so the
566
+ # row doesn't get re-evaluated, and treat it as a clean (not failed) exit.
567
+ terminal = any(a.kind == "terminal_skip" for a in actions)
568
+ if dispatched or terminal:
569
+ write_board(board)
570
+
571
+ for a in actions:
572
+ click.echo(f"{a.kind}: {a.detail}")
573
+
574
+ if not dispatched:
575
+ # Distinguish clean outcomes (approve / already-merged-or-closed) from
576
+ # genuine failure modes.
577
+ if any(a.kind in ("approved", "terminal_skip") for a in actions):
578
+ sys.exit(0)
579
+ sys.exit(1)
580
+
581
+
582
+ @click.command(
583
+ "reconcile-merges",
584
+ help=(
585
+ "Reconcile done work assignments against git/GitHub reality.\n\n"
586
+ "Three conservative sweeps:\n"
587
+ " #611 — backfill a missing branch from a matching `issue-N-*` remote "
588
+ "branch (a remote interactive session can finish done with branch=None, "
589
+ "greying the TUI Start review/test/merge buttons);\n"
590
+ " #609 — flip work merged out-of-band (direct GitHub merge or a drained "
591
+ "merge_queue row) to status='merged' so the TUI stops showing a grey "
592
+ "merge box forever;\n"
593
+ " #721 — close open PRs whose work has already landed (issue closed or "
594
+ "branch fully on the default branch) — review-PRs accumulate forever "
595
+ "after squash merges otherwise.\n\n"
596
+ "Acts only when certain; skips and explains otherwise."
597
+ ),
598
+ )
599
+
600
+
601
+ @click.option("--repo", "repo_name", default=None, help="Only reconcile this repo.")
602
+ @click.option(
603
+ "--dry-run", is_flag=True, help="Show what would change without writing."
604
+ )
605
+
606
+
607
+ @_CONFIG_OPTION
608
+ def reconcile_merges(repo_name: str | None, dry_run: bool, config_path: Path) -> None:
609
+ """#609/#611: record out-of-band merges and backfill missing branches."""
610
+ # #584: the canonical board + gh live on the daemon host, so on a thin
611
+ # client this would sweep an empty local board and silently do nothing.
612
+ # Route the whole operation to the daemon (mirrors `coord merge`).
613
+ # COORD_RECONCILE_ON_DAEMON guards the daemon against re-routing to itself.
614
+ from coord.board_service import daemon_reroute_target # noqa: PLC0415
615
+
616
+ _svc = daemon_reroute_target("COORD_RECONCILE_ON_DAEMON")
617
+ if _svc is not None:
618
+ _reconcile_via_daemon(_svc, {"repo": repo_name, "dry_run": dry_run})
619
+ return
620
+
621
+ from coord.reconcile import reconcile_board_merges
622
+ from coord.state import build_board, save_board
623
+
624
+ cfg = _load_config(config_path)
625
+ board = build_board()
626
+ actions = reconcile_board_merges(
627
+ board, cfg, repo=repo_name, dry_run=dry_run
628
+ )
629
+ if not dry_run:
630
+ save_board(board)
631
+ if not actions:
632
+ click.echo("Nothing to reconcile.")
633
+ return
634
+ for action in actions:
635
+ click.echo(action)
636
+
637
+
638
+ @click.command(
639
+ "post-pending-reviews",
640
+ help=(
641
+ "Post unposted review findings for done review assignments.\n\n"
642
+ "Useful when a reviewer finished but notify didn't see the transition "
643
+ "(e.g. agent reported 'cancelled', reap hung, or notify ran at the wrong time). "
644
+ "Idempotent — already-posted findings are never re-posted."
645
+ ),
646
+ )
647
+
648
+
649
+ @_CONFIG_OPTION
650
+ @click.option("--repo", "repo_name", default=None, help="Only process assignments for this repo.")
651
+ def post_pending_reviews(config_path: Path, repo_name: str | None) -> None:
652
+ from coord.notify import post_orphaned_review_findings
653
+ from coord.state import load_done_reviews_needing_post
654
+
655
+ cfg = _load_config(config_path)
656
+
657
+ candidates = load_done_reviews_needing_post(repo_name=repo_name)
658
+ if not candidates:
659
+ click.echo("No pending review assignments found.")
660
+ return
661
+
662
+ click.echo(f"Found {len(candidates)} review assignment(s) with unposted findings:")
663
+ for row in candidates:
664
+ aid = row["assignment_id"]
665
+ click.echo(
666
+ f" {aid} — {row['repo_name']} #{row['issue_number']} "
667
+ f"(machine: {row['machine_name']}, target: {row['review_target'] or 'n/a'})"
668
+ )
669
+
670
+ posted_ids = post_orphaned_review_findings(cfg, repo_name=repo_name)
671
+
672
+ if not posted_ids:
673
+ click.echo("\nNo findings posted (agents may be offline or logs unavailable).")
674
+ return
675
+
676
+ click.echo(f"\nPosted findings for {len(posted_ids)} assignment(s):")
677
+ for aid in posted_ids:
678
+ click.echo(f" {aid}")
679
+
680
+ still_pending = load_done_reviews_needing_post(repo_name=repo_name)
681
+ if still_pending:
682
+ click.echo(f"\n{len(still_pending)} assignment(s) still pending (logs not available):")
683
+ for row in still_pending:
684
+ click.echo(
685
+ f" {row['assignment_id']} — {row['repo_name']} #{row['issue_number']} "
686
+ f"(machine: {row['machine_name']})"
687
+ )
688
+
689
+
690
+ def _load_issue_states() -> tuple[dict[str, set[int]], dict[str, set[int]]]:
691
+ """Return ``(open_by_repo, known_by_repo)``.
692
+
693
+ - ``open_by_repo[repo]`` = set of issue numbers with state='open'.
694
+ - ``known_by_repo[repo]`` = set of issue numbers with ANY state row in
695
+ the cache.
696
+
697
+ Used by the `coord merge` auto-enqueue path (#242). Filter logic
698
+ (in the caller) is permissive on cache misses:
699
+
700
+ - issue in ``known_by_repo[repo]`` AND not in ``open_by_repo[repo]``
701
+ → deny (we have explicit "closed" evidence)
702
+ - otherwise → allow
703
+
704
+ The earlier implementation denied any issue whose repo had ANY rows in
705
+ the issues table but no row for the specific number — which silently
706
+ skipped issues created after the cache's most-recent sync (we hit this
707
+ when #278/#280 landed but the local cache stopped at #271).
708
+ """
709
+ try:
710
+ from coord.db import get_connection
711
+
712
+ conn = get_connection()
713
+ rows = conn.execute(
714
+ "SELECT repo_name, number, state FROM issues"
715
+ ).fetchall()
716
+ except Exception: # noqa: BLE001 — caller treats empty as "unknown"
717
+ return {}, {}
718
+
719
+ open_by_repo: dict[str, set[int]] = {}
720
+ known_by_repo: dict[str, set[int]] = {}
721
+ for row in rows:
722
+ repo_name = row[0]
723
+ number = int(row[1])
724
+ known_by_repo.setdefault(repo_name, set()).add(number)
725
+ if row[2] == "open":
726
+ open_by_repo.setdefault(repo_name, set()).add(number)
727
+ return open_by_repo, known_by_repo
728
+
729
+
730
+ def _reconcile_via_daemon(svc, params: dict) -> None:
731
+ """#584: run ``coord reconcile-merges`` on the daemon host (where the
732
+ canonical DB lives + gh is authenticated) and relay its output, so the
733
+ command does real work from a thin client instead of no-opping against an
734
+ empty local board. Reconcile is gh-bound but quick, hence the shorter
735
+ timeout."""
736
+ from coord.client import post_record # noqa: PLC0415
737
+
738
+ try:
739
+ resp = post_record(svc, "/reconcile-merges", params, timeout=120.0)
740
+ except Exception as exc: # noqa: BLE001
741
+ click.echo(f"error: reconcile-merges via daemon failed: {exc}", err=True)
742
+ sys.exit(1)
743
+ output = resp.get("output") or ""
744
+ if output:
745
+ click.echo(output, nl=False)
746
+ if resp.get("error"):
747
+ click.echo(f"error: {resp['error']}", err=True)
748
+ code = resp.get("exit_code") or 0
749
+ if code:
750
+ sys.exit(int(code))
751
+
752
+
753
+ def _print_merge_plan_entries(planned: list) -> None:
754
+ """Print a list of PlannedMerge entries grouped by repo → target_branch."""
755
+ if not planned:
756
+ click.echo("Merge queue is empty (nothing to plan).")
757
+ return
758
+ _last_group: tuple[str, str] | None = None
759
+ for _p in planned:
760
+ _gkey = (_p.repo_name, _p.target_branch)
761
+ if _gkey != _last_group:
762
+ if _last_group is not None:
763
+ click.echo("")
764
+ click.echo(f"{_p.repo_name} → {_p.target_branch}")
765
+ _last_group = _gkey
766
+ _size_str = f"+{_p.size}" if _p.size is not None else "?"
767
+ _status_str = _p.status
768
+ if _p.reason:
769
+ _status_str = f"{_p.status} {_p.reason}"
770
+ click.echo(
771
+ f" {_p.rank}. #{_p.issue_number} {_size_str} "
772
+ f"{_status_str} {_p.issue_title}"
773
+ )
774
+
775
+
776
+ def _print_sibling_overlap_warnings(warnings: list) -> None:
777
+ """#920: print "these approved branches will conflict" warnings.
778
+
779
+ ``warnings`` is a list of :class:`coord.merge_queue.SiblingOverlapWarning`
780
+ (or daemon-payload reconstructions of the same shape). No-op when empty —
781
+ callers don't need to guard the call.
782
+ """
783
+ if not warnings:
784
+ return
785
+ click.echo("")
786
+ click.echo("⚠ Sibling overlap (approved branches aging against a moving main):")
787
+ for w in warnings:
788
+ order = " → ".join(f"#{n}" for n in w.issue_numbers)
789
+ files = list(w.overlapping_files)
790
+ files_str = ", ".join(files[:5])
791
+ if len(files) > 5:
792
+ files_str += f", +{len(files) - 5} more"
793
+ click.echo(f" {w.repo_name} → {w.target_branch}: {order}")
794
+ click.echo(f" overlapping files: {files_str}")
795
+ click.echo(
796
+ f" oldest waiting {w.oldest_age_hours:.1f}h — these will conflict if merged"
797
+ " out of order or later; merge promptly, oldest first"
798
+ " ('coord merge --order <assignment_ids>' to force this order)."
799
+ )
800
+
801
+
802
+ def _board_row_gate_report(a, config, board, gh_ops: "GhOps | None"):
803
+ """The live, #1479-freshness-aware gate report for board row *a* — the
804
+ SAME evaluation ``coord gates <repo> <issue>`` runs
805
+ (:func:`coord.gates.build_gate_report`), or ``None`` when it couldn't be
806
+ computed (repo not configured, no branch, board/config error, …).
807
+
808
+ Shared by both ``--only`` call sites that used to re-derive gate status
809
+ by calling :func:`coord.merge_queue.merge_gate_failures` directly on a
810
+ raw board ``Assignment`` — the #1845 retry-enqueue probe below and
811
+ :func:`_explain_missing_only_entry`'s diagnostic. A bare ``Assignment``
812
+ has no ``repo_github``/``branch_head_sha``/``target_branch_head_sha``/
813
+ ``branch_patch_id`` (those only exist on a
814
+ :class:`~coord.merge_queue.QueuedMerge` that has been through
815
+ :func:`~coord.merge_queue.process`), so every #1479 staleness check
816
+ inside :func:`~coord.merge_queue.evaluate_smoke_verdict` silently no-ops
817
+ against it — the row reads as fresh no matter how stale it really is.
818
+ ``build_gate_report`` builds the same synthetic, live-SHA-backed
819
+ ``QueuedMerge`` ``coord gates`` does, so the two can never disagree
820
+ (#1926).
821
+
822
+ Never raises: any failure degrades to ``None``, which both callers
823
+ treat as "not gated" / "not evaluated" rather than a guessed pass.
824
+ """
825
+ from coord.gates import build_gate_report as _build_gate_report # noqa: PLC0415
826
+ from coord.models import effective_issue_number as _effective_issue_number # noqa: PLC0415
827
+
828
+ try:
829
+ return _build_gate_report(
830
+ board,
831
+ config,
832
+ a.repo_name,
833
+ _effective_issue_number(a) or a.issue_number,
834
+ gh_ops=gh_ops,
835
+ )
836
+ except Exception: # noqa: BLE001 — diagnostics/probes must never mask the error
837
+ return None
838
+
839
+
840
+ def _board_row_merge_gate_ok(a, config, board, gh_ops: "GhOps | None") -> bool:
841
+ """True only when *a*'s live gate report says the merge gate is
842
+ satisfied — see :func:`_board_row_gate_report`. Fails closed to
843
+ ``False`` (not gated) whenever the report itself couldn't be computed,
844
+ matching #1926's "silence is safe, optimism is not": a ``False`` here
845
+ just means the caller falls through to a diagnostic, never a wrongly
846
+ attempted enqueue/merge.
847
+ """
848
+ report = _board_row_gate_report(a, config, board, gh_ops)
849
+ if report is None:
850
+ return False
851
+ merge_decision = next((d for d in report.decisions if d.gate == "merge"), None)
852
+ return merge_decision is not None and merge_decision.ok
853
+
854
+
855
+ def _explain_missing_only_entry(
856
+ key: str, config, gh_ops: "GhOps | None" = None
857
+ ) -> list[str]:
858
+ """#1695: explain why ``coord merge --only <key>`` found no queue entry.
859
+
860
+ Returns the lines to print on stderr. The pre-#1695 message was a single
861
+ line — *"no entry found for 'X' (tried assignment_id, repo#issue, issue
862
+ number, and branch name)"* — which describes a **key-lookup** failure and
863
+ is what sent the #1695 operator hunting for a different identifier for 40
864
+ minutes. In the case that actually happens, the identifier resolved fine
865
+ and the row simply never became an entry because a gate blocked enqueue.
866
+
867
+ So the three cases are now stated separately:
868
+
869
+ 1. **No board row either.** The identifier genuinely did not resolve —
870
+ the old wording, now only printed when it is true.
871
+ 2. **A board row exists and a gate is failing.** Name the row, name the
872
+ gate, and point at the flag that waives it. Under #1695 the row is
873
+ enqueued (visibly BLOCKED) by the auto-enqueue scan, so the fix is to
874
+ let a plain ``coord merge`` pass run the scan and then retry ``--only``
875
+ with the waiver — hence the explicit next-step line.
876
+ 3. **A board row exists and every gate passes.** Then it was one of the
877
+ non-gate skips (issue closed, branch gone from origin, PR already
878
+ merged) or the scan simply has not run yet.
879
+
880
+ #1926: case 2/3 used to call :func:`coord.merge_queue.merge_gate_failures`
881
+ directly on the raw board ``Assignment`` with no *gh_ops* and no live
882
+ SHAs, which makes every #1479 staleness check inside
883
+ :func:`~coord.merge_queue.evaluate_smoke_verdict` a silent no-op (it needs
884
+ ``entry.repo_github``/``entry.target_branch_head_sha``/etc., none of which
885
+ a bare work ``Assignment`` carries — those only exist on a
886
+ :class:`~coord.merge_queue.QueuedMerge` that has been through
887
+ :func:`~coord.merge_queue.process`). That let this fallback print "all
888
+ merge gates pass" for a row ``coord gates`` reported ``BLOCKED`` with a
889
+ STALE verdict — the exact false-green this issue is about. Routing
890
+ through :func:`coord.gates.build_gate_report` instead reuses the SAME
891
+ live-SHA-backed evaluation ``coord gates`` runs, so the two can never
892
+ disagree.
893
+
894
+ Never raises: a board/config problem, or a gate report that can't reach a
895
+ merge decision (no branch, repo not configured, …), degrades to an
896
+ honest "not evaluated" line rather than a guessed pass.
897
+ """
898
+ from coord import merge_queue as _mq # noqa: PLC0415
899
+ from coord.state import load_board as _load_board # noqa: PLC0415
900
+
901
+ not_found = (
902
+ f"merge-queue: no entry found for {key!r} "
903
+ "(tried assignment_id, repo#issue, issue number, and branch name)"
904
+ )
905
+ try:
906
+ board = _load_board()
907
+ rows = _mq.resolve_board_work_key(board, key) if board is not None else []
908
+ except Exception: # noqa: BLE001 — diagnostics must never mask the error
909
+ rows = []
910
+ if not rows:
911
+ return [
912
+ not_found,
913
+ " no done work row on the board matches that identifier either — "
914
+ "the identifier did not resolve.",
915
+ ]
916
+
917
+ lines = [
918
+ f"merge-queue: no entry found for {key!r}, but "
919
+ f"{len(rows)} done work row(s) on the board match it:",
920
+ ]
921
+ any_blocked = False
922
+ for a in rows:
923
+ where = f"{a.repo_name} #{a.issue_number} (assignment {a.assignment_id}, branch {a.branch})"
924
+ report = _board_row_gate_report(a, config, board, gh_ops)
925
+ merge_decision = (
926
+ next((d for d in report.decisions if d.gate == "merge"), None)
927
+ if report is not None
928
+ else None
929
+ )
930
+ if merge_decision is None:
931
+ # Same evaluation `coord gates` runs couldn't reach a merge
932
+ # decision for this row (repo not configured, no branch on the
933
+ # winning assignment, board/config error, …). Say so instead of
934
+ # guessing — silence is safe, optimism is not (#1926).
935
+ lines.append(
936
+ f" {where} — gate status not evaluated on this path; run "
937
+ f"`coord gates {a.repo_name} {a.issue_number}` for the live "
938
+ "decision."
939
+ )
940
+ continue
941
+ if not merge_decision.ok:
942
+ any_blocked = True
943
+ clauses = []
944
+ for gate_name, waiver_flag in (
945
+ ("review", "--skip-review"),
946
+ ("test", "--skip-smoke"),
947
+ ):
948
+ d = next((x for x in report.decisions if x.gate == gate_name), None)
949
+ if d is not None and not d.ok:
950
+ clauses.append(f"{gate_name} gate — {d.reason} (waive with {waiver_flag})")
951
+ lines.append(f" {where} — enqueue blocked by {'; '.join(clauses)}")
952
+ else:
953
+ lines.append(
954
+ f" {where} — all merge gates pass (#1479 freshness checked); "
955
+ "it was skipped for a non-gate reason (issue closed, branch "
956
+ "missing from origin, or PR already merged), or the "
957
+ "auto-enqueue scan has not run yet."
958
+ )
959
+ if any_blocked:
960
+ lines.append(
961
+ " next: run `coord merge --dry-run` (the auto-enqueue scan "
962
+ "enqueues gate-blocked rows in a visibly BLOCKED state, #1695), "
963
+ "then retry --only with the waiver flag named above."
964
+ )
965
+ return lines
966
+
967
+
968
+ def _show_plan_from_daemon(
969
+ svc,
970
+ *,
971
+ repo_filter: str | None,
972
+ order: str | None,
973
+ ) -> None:
974
+ """#779-fix: display merge plan via /board — never touches /merge.
975
+
976
+ Older daemons (≤v0.4.53 pre-#779) receive ``plan=True`` via ``/merge``
977
+ but have no show_plan handler, so they fall through to a full live merge
978
+ cycle with side effects. The ``merge_plan`` field has been injected into
979
+ ``/board`` since #776/v0.4.53, so we fetch that instead — guaranteed
980
+ read-only on every supported daemon version.
981
+
982
+ Exits with an error message if the daemon payload lacks ``merge_plan``
983
+ (daemon predates v0.4.53); the caller should not fall through to a local
984
+ path that would show an empty thin-client queue.
985
+ """
986
+ from coord.client import fetch_board_payload # noqa: PLC0415
987
+ from coord.merge_queue import PlannedMerge # noqa: PLC0415
988
+
989
+ try:
990
+ payload = fetch_board_payload(svc)
991
+ except Exception as exc: # noqa: BLE001
992
+ click.echo(f"error: fetch board for --plan failed: {exc}", err=True)
993
+ sys.exit(1)
994
+
995
+ if "merge_plan" not in payload:
996
+ click.echo(
997
+ "error: daemon does not expose merge_plan in /board "
998
+ "(upgrade the daemon to v0.4.53+ to use coord merge --plan).",
999
+ err=True,
1000
+ )
1001
+ sys.exit(1)
1002
+
1003
+ raw: list[dict] = payload.get("merge_plan") or []
1004
+ known = set(PlannedMerge.__dataclass_fields__)
1005
+ planned = [PlannedMerge(**{k: v for k, v in d.items() if k in known}) for d in raw]
1006
+
1007
+ if repo_filter:
1008
+ planned = [p for p in planned if p.repo_name == repo_filter]
1009
+
1010
+ if order:
1011
+ _override_ids = [s.strip() for s in order.split(",") if s.strip()]
1012
+ _by_id = {p.assignment_id: p for p in planned}
1013
+ _head = [_by_id[aid] for aid in _override_ids if aid in _by_id]
1014
+ _tail = [p for p in planned if p.assignment_id not in set(_override_ids)]
1015
+ planned = _head + _tail
1016
+ for _i, _p in enumerate(planned, 1):
1017
+ _p.rank = _i
1018
+
1019
+ _print_merge_plan_entries(planned)
1020
+
1021
+ # #920: sibling-overlap warnings — precomputed server-side into /board
1022
+ # (see coord.serve_app's `sibling_overlap_warnings` projection) since a
1023
+ # thin client has no local queue/board to compute them from itself.
1024
+ from coord.merge_queue import SiblingOverlapWarning # noqa: PLC0415
1025
+
1026
+ raw_overlaps: list[dict] = payload.get("sibling_overlap_warnings") or []
1027
+ known_ow = set(SiblingOverlapWarning.__dataclass_fields__)
1028
+ overlaps = [
1029
+ SiblingOverlapWarning(**{
1030
+ k: (tuple(v) if isinstance(v, list) else v)
1031
+ for k, v in d.items() if k in known_ow
1032
+ })
1033
+ for d in raw_overlaps
1034
+ ]
1035
+ if repo_filter:
1036
+ overlaps = [w for w in overlaps if w.repo_name == repo_filter]
1037
+ _print_sibling_overlap_warnings(overlaps)
1038
+
1039
+
1040
+ def _merge_via_daemon(svc, params: dict) -> None:
1041
+ """#584: run ``coord merge`` on the daemon host (where the canonical DB +
1042
+ merge queue + gh live) and relay its output, so the TUI 'Go' button and
1043
+ ``coord merge`` work from any thin client. Merges can take minutes (PR
1044
+ creation, CI waits), hence the long timeout.
1045
+
1046
+ #1769: a ``--revalidate`` run additionally executes the repo's whole test
1047
+ suite on the daemon host, which is minutes-to-tens-of-minutes on its own —
1048
+ the default 900 s ceiling would abandon the client mid-suite (the daemon
1049
+ keeps going and finishes the merge, so the operator sees a timeout error
1050
+ for a run that actually succeeded).
1051
+
1052
+ #1715-review: batch revalidation's own worst case is 1 (composite) + N
1053
+ (per-entry fallback) serial suite runs, not just one — see
1054
+ :func:`coord.revalidate.revalidate_group`. :func:`coord.revalidate.
1055
+ client_timeout_seconds` sizes the window off that worst case (a
1056
+ documented ceiling on N, since the client posts before any candidate is
1057
+ known) rather than a single :data:`coord.revalidate.
1058
+ DEFAULT_TIMEOUT_SECONDS`, which is the ceiling ONE suite run is killed
1059
+ at."""
1060
+ from coord.client import post_record # noqa: PLC0415
1061
+ from coord.revalidate import client_timeout_seconds # noqa: PLC0415
1062
+
1063
+ timeout = client_timeout_seconds(bool(params.get("revalidate")))
1064
+
1065
+ try:
1066
+ resp = post_record(svc, "/merge", params, timeout=timeout)
1067
+ except Exception as exc: # noqa: BLE001
1068
+ click.echo(f"error: merge via daemon failed: {exc}", err=True)
1069
+ sys.exit(1)
1070
+ output = resp.get("output") or ""
1071
+ if output:
1072
+ click.echo(output, nl=False)
1073
+ if resp.get("error"):
1074
+ click.echo(f"error: {resp['error']}", err=True)
1075
+ code = resp.get("exit_code") or 0
1076
+ if code:
1077
+ sys.exit(int(code))
1078
+
1079
+
1080
+ @click.command(help="Process the merge queue: open PRs and merge in sequence.")
1081
+ @_CONFIG_OPTION
1082
+ @click.option("--dry-run", is_flag=True, help="Show the plan without opening or merging PRs.")
1083
+ @click.option(
1084
+ "--plan",
1085
+ "show_plan",
1086
+ is_flag=True,
1087
+ help=(
1088
+ "#779: Print the ranked merge order and per-entry gate status. "
1089
+ "No PRs opened, no merges — purely read-only."
1090
+ ),
1091
+ )
1092
+
1093
+
1094
+ @click.option(
1095
+ "--order",
1096
+ default=None,
1097
+ help="Comma-separated assignment IDs to merge first (overrides size-based sequencing).",
1098
+ )
1099
+
1100
+
1101
+ @click.option("--repo", "repo_filter", default=None, help="Only process this repo's queue.")
1102
+ @click.option(
1103
+ "--method",
1104
+ type=click.Choice(["rebase", "squash", "merge"]),
1105
+ default="rebase",
1106
+ show_default=True,
1107
+ )
1108
+
1109
+
1110
+ @click.option(
1111
+ "--force-merge",
1112
+ is_flag=True,
1113
+ help=(
1114
+ "Skip the CI check gate — merge even if checks failed or are still running. "
1115
+ "Also overrides the #1318 epic-closing-keyword guard: merge anyway even "
1116
+ "when a commit message on the branch contains a closing keyword targeting "
1117
+ "an epic (the epic WILL auto-close on GitHub)."
1118
+ ),
1119
+ )
1120
+
1121
+
1122
+ @click.option(
1123
+ "--skip-review",
1124
+ is_flag=True,
1125
+ help=(
1126
+ "Skip the review-approval gate — merge even when no approved review is on "
1127
+ "the board (#253). Local-only: when this run is routed to the daemon "
1128
+ "(thin client / no local canonical DB), the daemon rejects a truthy "
1129
+ "--skip-review outright (non-zero exit, explicit error) rather than "
1130
+ "honouring or silently dropping it — the review gate can never be "
1131
+ "bypassed remotely (#821, #1489)."
1132
+ ),
1133
+ )
1134
+
1135
+
1136
+ @click.option(
1137
+ "--skip-smoke",
1138
+ is_flag=True,
1139
+ help="Skip the interactive smoke-test gate — merge even when no smoke verdict is recorded (#465).",
1140
+ )
1141
+
1142
+
1143
+ @click.option(
1144
+ "--revalidate",
1145
+ is_flag=True,
1146
+ help=(
1147
+ "#1769/#1715: re-test entries blocked SOLELY on a stale-but-passed "
1148
+ "test verdict against the current base, then merge them. Off by "
1149
+ "default — `coord merge` with no flag is unchanged, and the unattended "
1150
+ "auto-drain never sets it. Applies only to the stale case: an entry "
1151
+ "blocked on review, CI, conflict, or a genuinely missing verdict is "
1152
+ "left untouched. "
1153
+ "BATCH (#1715): when several entries share a base they are composed "
1154
+ "onto it together and validated by ONE suite run, not one run each — "
1155
+ "N approved branches cost 1 run. The honest trade: that validates the "
1156
+ "COMPOSITE, not each branch alone. It is a re-confirmation rather than "
1157
+ "a first proof — every member already holds its own passed verdict "
1158
+ "from an earlier base — and the batch merges against the same base "
1159
+ "snapshot the composite was built on. "
1160
+ "If the composite FAILS, nothing merges and nothing is marked failed; "
1161
+ "each branch is then re-tested alone, so the culprit is named and the "
1162
+ "innocent branches still merge (worst case 1+N runs, typical case 1). "
1163
+ "Runs the repo's own build/test commands locally, so it must run where "
1164
+ "the repo is checked out (on a thin client it routes to the daemon "
1165
+ "host, like the rest of `coord merge`). Distinct from --skip-smoke, "
1166
+ "which waives the gate instead of satisfying it."
1167
+ ),
1168
+ )
1169
+
1170
+
1171
+ @click.option(
1172
+ "--drop",
1173
+ "drop_assignment",
1174
+ default=None,
1175
+ metavar="ASSIGNMENT_ID",
1176
+ help=(
1177
+ "#732: Drop exactly one merge_queue entry — accepts the assignment_id, the "
1178
+ "durable 'repo#issue' form (#1477), a bare issue number, or the branch name "
1179
+ "(#1490) — whichever the board printed, since assignment_id can re-key across "
1180
+ "a drop + re-enqueue (or an auto-enqueue tick) between the read and this call. "
1181
+ "Routes through the daemon so thin clients don't need local DB access."
1182
+ ),
1183
+ )
1184
+
1185
+
1186
+ @click.option(
1187
+ "--only",
1188
+ "only_assignment",
1189
+ default=None,
1190
+ metavar="ASSIGNMENT_ID",
1191
+ help=(
1192
+ "#780: Merge exactly one entry — accepts the assignment_id, the durable "
1193
+ "'repo#issue' form (#1477), e.g. 'acme/api#1461', a bare issue number, or the "
1194
+ "branch name (#1490) — whichever the board printed. Resolution falls back "
1195
+ "through these forms in order, so an id that was re-keyed by a concurrent "
1196
+ "auto-enqueue tick since the board was last read still resolves via issue "
1197
+ "number or branch. Leaves the rest of the queue untouched. Mutually "
1198
+ "exclusive with --order. BLOCKED entries are reported and skipped (use "
1199
+ "--force-merge to override gates)."
1200
+ ),
1201
+ )
1202
+
1203
+
1204
+ @click.option(
1205
+ "--override-human-required",
1206
+ "override_human_required",
1207
+ default=None,
1208
+ metavar="REASON",
1209
+ help=(
1210
+ "#1251: explicit, audited override for a HUMAN_REQUIRED entry — clears the "
1211
+ "flag and requeues it as PENDING so this run's other gates (--skip-review, "
1212
+ "--skip-smoke, --force-merge) can still apply normally. Requires --only "
1213
+ "<assignment_id> and a reason string, which is written to the audit trail "
1214
+ "alongside the original conflict_human_required event. Distinct from "
1215
+ "--force-merge on purpose: human_required means an automated process already "
1216
+ "gave up on this entry, not just that a gate wasn't run."
1217
+ ),
1218
+ )
1219
+
1220
+
1221
+ def merge(
1222
+ config_path: Path,
1223
+ dry_run: bool,
1224
+ show_plan: bool,
1225
+ order: str | None,
1226
+ repo_filter: str | None,
1227
+ method: str,
1228
+ force_merge: bool,
1229
+ skip_review: bool,
1230
+ skip_smoke: bool,
1231
+ revalidate: bool,
1232
+ drop_assignment: str | None,
1233
+ only_assignment: str | None,
1234
+ override_human_required: str | None,
1235
+ ) -> None:
1236
+ # #1251: --override-human-required is a surgical single-entry override — it
1237
+ # only makes sense paired with --only, which pins down the one entry it
1238
+ # applies to. Validate up front (before any daemon round-trip) so a thin
1239
+ # client fails fast instead of silently no-op'ing the flag on the daemon
1240
+ # side (only_assignment gates the block that actually consumes it below).
1241
+ #
1242
+ # #1251-review: both this check and the later `if override_human_required:`
1243
+ # gate treat an empty/whitespace-only reason as falsy, so
1244
+ # `--override-human-required ""` would otherwise skip *every* validation
1245
+ # and *every* effect — no error, no override, no audit row — leaving the
1246
+ # entry stuck HUMAN_REQUIRED with no feedback that the reason was
1247
+ # rejected. Catch it explicitly first, before the --only check, since an
1248
+ # empty reason is invalid regardless of what else was passed.
1249
+ if override_human_required is not None and not override_human_required.strip():
1250
+ click.echo(
1251
+ "error: --override-human-required requires a non-empty reason string",
1252
+ err=True,
1253
+ )
1254
+ sys.exit(1)
1255
+ if override_human_required and not only_assignment:
1256
+ click.echo(
1257
+ "error: --override-human-required requires --only <assignment_id> — "
1258
+ "it targets exactly one entry, never a repo-wide scan",
1259
+ err=True,
1260
+ )
1261
+ sys.exit(1)
1262
+
1263
+ # #584: the merge queue + board live in the canonical (host-local) DB, so on
1264
+ # a thin client `coord merge` (and the TUI 'Go' button, which shells out to
1265
+ # it) would silently no-op against an empty local board. Route the whole
1266
+ # operation to the daemon — it runs the merge where the DB + gh live and
1267
+ # returns its output. COORD_MERGE_ON_DAEMON guards the daemon against
1268
+ # re-routing to itself (it calls this same command with the env var set).
1269
+ from coord.board_service import daemon_reroute_target # noqa: PLC0415
1270
+
1271
+ _merge_svc = daemon_reroute_target("COORD_MERGE_ON_DAEMON")
1272
+ if _merge_svc is not None:
1273
+ # #779-fix: --plan must never reach /merge on an older daemon — it has
1274
+ # no show_plan handler and falls through to a live merge cycle (side
1275
+ # effects). Route through /board instead; merge_plan has been in the
1276
+ # /board payload since #776/v0.4.53.
1277
+ if show_plan:
1278
+ _show_plan_from_daemon(_merge_svc, repo_filter=repo_filter, order=order)
1279
+ return
1280
+ _merge_via_daemon(_merge_svc, {
1281
+ "dry_run": dry_run, "order": order,
1282
+ "repo_filter": repo_filter, "method": method,
1283
+ "force_merge": force_merge, "skip_review": skip_review,
1284
+ "skip_smoke": skip_smoke, "revalidate": revalidate,
1285
+ "drop": drop_assignment,
1286
+ "only": only_assignment,
1287
+ "override_human_required": override_human_required,
1288
+ })
1289
+ return
1290
+
1291
+ # #732: --drop is a surgical single-entry removal; handle before the full
1292
+ # merge pipeline so it works even when the queue is otherwise busy/blocked.
1293
+ if drop_assignment:
1294
+ from coord import merge_queue as _mq # noqa: PLC0415
1295
+
1296
+ removed = _mq.drop_entry(drop_assignment)
1297
+ if removed:
1298
+ click.echo(f"merge-queue: dropped entry {drop_assignment}")
1299
+ else:
1300
+ click.echo(
1301
+ f"merge-queue: no entry found for {drop_assignment!r} "
1302
+ "(tried assignment_id, repo#issue, issue number, and branch name)",
1303
+ err=True,
1304
+ )
1305
+ sys.exit(1)
1306
+ return
1307
+
1308
+ # #779: --plan is a pure read-only path; handle it before the auto-enqueue
1309
+ # scan so it never causes side effects. When a daemon is present this path
1310
+ # is short-circuited above by _show_plan_from_daemon (/board, not /merge).
1311
+ # This local branch runs on the daemon itself (COORD_MERGE_ON_DAEMON set)
1312
+ # or when no daemon is configured (standalone dev environment).
1313
+ #
1314
+ # #1477-review: the reconcile call just below is a deliberate, narrow
1315
+ # carve-out from "never causes side effects" — it persists CONFLICT ->
1316
+ # PENDING (clearing entry.error) via save_queue() when the reconciled
1317
+ # branch turns out to be clean. That's accepted here because (a) it's a
1318
+ # state *correction*, not a merge action — no PR is touched — and (b)
1319
+ # daemon-fronted setups (the common case) never reach this branch at all,
1320
+ # since --plan is short-circuited above to the read-only /board path.
1321
+ # Only a standalone/no-daemon dev environment running --plan directly
1322
+ # observes this side effect.
1323
+ if show_plan:
1324
+ from coord import github_ops as _plan_gh_ops # noqa: PLC0415
1325
+ from coord import merge_queue as _plan_mq # noqa: PLC0415
1326
+ from coord.ci_store import build_ci_store as _build_ci_store # noqa: PLC0415
1327
+ from coord.state import load_board as _load_board # noqa: PLC0415
1328
+
1329
+ _cfg = _load_config(config_path)
1330
+ _board = _load_board()
1331
+ _ci = _build_ci_store(_cfg.ci_store.type)
1332
+
1333
+ # #1477: re-test any parked CONFLICT entry against GitHub's own
1334
+ # mergeability computation before building the plan — otherwise a
1335
+ # branch repaired by a conflict-fix worker (or by hand) keeps
1336
+ # showing its stale conflict verdict here indefinitely. See the
1337
+ # #1477-review note above the `if show_plan:` line for why this is a
1338
+ # deliberate exception to the "no side effects" contract.
1339
+ for _ev in _plan_mq.reconcile_conflict_entries(_plan_gh_ops):
1340
+ click.echo(
1341
+ f" {_ev.entry.repo_name} #{_ev.entry.issue_number} "
1342
+ f"({_ev.entry.branch}): {_ev.kind} — {_ev.message}"
1343
+ )
1344
+
1345
+ planned = _plan_mq.plan(_board, _cfg, _ci, gh_ops=_plan_gh_ops)
1346
+
1347
+ # --repo scoping
1348
+ if repo_filter:
1349
+ planned = [p for p in planned if p.repo_name == repo_filter]
1350
+
1351
+ # --order: put the named IDs first, then renumber ranks so the display
1352
+ # matches what a subsequent `coord merge --order <ids>` would actually do.
1353
+ if order:
1354
+ _override_ids = [s.strip() for s in order.split(",") if s.strip()]
1355
+ _by_id = {p.assignment_id: p for p in planned}
1356
+ _head = [_by_id[aid] for aid in _override_ids if aid in _by_id]
1357
+ _tail = [p for p in planned if p.assignment_id not in set(_override_ids)]
1358
+ planned = _head + _tail
1359
+ for _i, _p in enumerate(planned, 1):
1360
+ _p.rank = _i
1361
+
1362
+ _print_merge_plan_entries(planned)
1363
+
1364
+ # #920: sibling-overlap warnings, computed live off the same board.
1365
+ try:
1366
+ _overlaps = _plan_mq.find_sibling_overlaps(_board, _cfg)
1367
+ except Exception: # noqa: BLE001 — never let the warning break --plan
1368
+ _overlaps = []
1369
+ if repo_filter:
1370
+ _overlaps = [w for w in _overlaps if w.repo_name == repo_filter]
1371
+ _print_sibling_overlap_warnings(_overlaps)
1372
+ return
1373
+
1374
+ from coord import github_ops as gh_ops
1375
+ from coord import merge_queue as mq
1376
+ from coord.ci_store import build_ci_store
1377
+ from coord.merge_queue import CONFLICT, HUMAN_REQUIRED, PENDING
1378
+ from coord.state import load_board
1379
+
1380
+ # #780: --only is a surgical single-entry merge that leaves all other queue
1381
+ # entries in PENDING state. Handled early — before the full auto-enqueue
1382
+ # scan — so a --only run doesn't touch unrelated entries.
1383
+ if only_assignment:
1384
+ if order:
1385
+ click.echo(
1386
+ "error: --only and --order are mutually exclusive", err=True
1387
+ )
1388
+ sys.exit(1)
1389
+ cfg_only = _load_config(config_path)
1390
+ # #1477: re-test any parked CONFLICT entry before resolving
1391
+ # --only — a branch repaired since the last tick should be eligible
1392
+ # for --only the moment it's clean, not only after a manual --drop.
1393
+ for _ev in mq.reconcile_conflict_entries(gh_ops):
1394
+ click.echo(
1395
+ f" {_ev.entry.repo_name} #{_ev.entry.issue_number} "
1396
+ f"({_ev.entry.branch}): {_ev.kind} — {_ev.message}"
1397
+ )
1398
+ only_queue = mq.load_queue()
1399
+ only_entry = mq.resolve_entry_key(only_queue, only_assignment)
1400
+ if only_entry is None:
1401
+ # #1845: `--only` used to fail outright right here even when the
1402
+ # addressed row is done, fully gated, and simply hasn't been
1403
+ # through the auto-enqueue scan yet — `coord merge` (no --only)
1404
+ # runs that scan on every invocation, but the surgical --only
1405
+ # path went straight to the queue lookup and never triggered it.
1406
+ # A drive's merge stage hits this race routinely: the work
1407
+ # finishes moments before the daemon tick's own scan runs, and
1408
+ # each `--only` attempt in that window used to burn one of the
1409
+ # drive's few merge attempts on a false negative — see the
1410
+ # overnight incident in #1845. `enqueue_approved_work` is the
1411
+ # SAME scan `coord merge` (no --only) runs; when a matching
1412
+ # board row exists and every gate already passes, run it inline
1413
+ # and retry the resolution once before reporting failure.
1414
+ #
1415
+ # #1926: "every gate already passes" is decided by
1416
+ # `_board_row_merge_gate_ok`, NOT a direct
1417
+ # `mq.merge_gate_failures(a, ...)` call on the raw board
1418
+ # `Assignment` — that call silently no-ops every #1479 staleness
1419
+ # check (the Assignment has no repo_github/live-SHA fields), so
1420
+ # a row with a genuinely STALE verdict used to read as "gated"
1421
+ # here and get enqueued (and then merge-attempted) on the same
1422
+ # false-green basis the fallback message below was fixed for.
1423
+ _retry_board = load_board()
1424
+ if _retry_board is not None:
1425
+ _retry_rows = mq.resolve_board_work_key(_retry_board, only_assignment)
1426
+ _retry_all_gated = bool(_retry_rows) and all(
1427
+ _board_row_merge_gate_ok(a, cfg_only, _retry_board, gh_ops)
1428
+ for a in _retry_rows
1429
+ )
1430
+ if _retry_all_gated:
1431
+ mq.enqueue_approved_work(cfg_only, _retry_board)
1432
+ only_queue = mq.load_queue()
1433
+ only_entry = mq.resolve_entry_key(only_queue, only_assignment)
1434
+ if only_entry is None:
1435
+ # #1695: the old message said only "tried assignment_id,
1436
+ # repo#issue, issue number, and branch name", which reads as a
1437
+ # key-lookup problem and sends the operator hunting for a
1438
+ # different identifier. Usually the identifier was fine and a
1439
+ # gate was the reason no entry exists — so say which.
1440
+ for line in _explain_missing_only_entry(only_assignment, cfg_only, gh_ops):
1441
+ click.echo(line, err=True)
1442
+ sys.exit(1)
1443
+ # #1251: --override-human-required is the explicit, audited escape
1444
+ # hatch for an entry an automated conflict-fix (or a permission /
1445
+ # branch-protection classification) already gave up on. It's a
1446
+ # different class of override from --skip-smoke/--skip-review/
1447
+ # --force-merge — those waive a gate that simply wasn't run; this
1448
+ # clears a flag that says "automation gave up, a human must decide" —
1449
+ # so it gets its own flag, its own validation, and its own audit
1450
+ # row, never bundled into --force-merge.
1451
+ if override_human_required:
1452
+ if only_entry.state != HUMAN_REQUIRED:
1453
+ click.echo(
1454
+ "error: --override-human-required only applies to a "
1455
+ f"HUMAN_REQUIRED entry; {only_assignment!r} is in state "
1456
+ f"{only_entry.state!r}",
1457
+ err=True,
1458
+ )
1459
+ sys.exit(1)
1460
+ if dry_run:
1461
+ click.echo(
1462
+ " --override-human-required: (dry run) would clear "
1463
+ f"HUMAN_REQUIRED on {only_assignment!r} — "
1464
+ f"{override_human_required!r}"
1465
+ )
1466
+ else:
1467
+ from coord.audit import record_audit # noqa: PLC0415
1468
+
1469
+ record_audit(
1470
+ tier="business",
1471
+ category="merge",
1472
+ event_type="human_required_override",
1473
+ actor="user",
1474
+ summary=(
1475
+ f"human_required override: {only_entry.repo_name}"
1476
+ f"#{only_entry.issue_number} ({only_assignment}) — "
1477
+ f"{override_human_required}"
1478
+ ),
1479
+ repo=only_entry.repo_name,
1480
+ issue=only_entry.issue_number,
1481
+ assignment_id=only_entry.assignment_id,
1482
+ details={"reason": override_human_required},
1483
+ )
1484
+ click.echo(
1485
+ " --override-human-required: cleared HUMAN_REQUIRED on "
1486
+ f"{only_assignment!r} — {override_human_required!r} — "
1487
+ "requeued as PENDING"
1488
+ )
1489
+ # Reset in-memory state either way so the dry-run event stream
1490
+ # below reflects what a real run would do (matching the review/
1491
+ # smoke gate dry-run convention); actual persistence is still
1492
+ # gated on `not dry_run` in the save block further down.
1493
+ only_entry.state = PENDING
1494
+ only_entry.error = None
1495
+ if only_entry.state != PENDING:
1496
+ click.echo(
1497
+ f"merge-queue: entry {only_assignment!r} is in state "
1498
+ f"{only_entry.state!r} (not PENDING) — cannot merge",
1499
+ err=True,
1500
+ )
1501
+ sys.exit(1)
1502
+ # #821: never pass None to process() — use an empty board so
1503
+ # has_smoke_verdict can apply its "no work found → fail open" rule.
1504
+ # process() blocks on board=None when a gate IS required; an empty
1505
+ # board lets the gate function decide.
1506
+ from coord.models import Board as _Board # noqa: PLC0415
1507
+ _raw_board_only = load_board()
1508
+ board_only = _raw_board_only if _raw_board_only is not None else _Board(active=[], completed=[])
1509
+ ci_store_only = build_ci_store(cfg_only.ci_store.type)
1510
+ # #1695: name the blocking gate(s) up front. Under #1695 a gate-blocked
1511
+ # row IS enqueued (visibly BLOCKED) instead of being dropped, so
1512
+ # `--only` now resolves it — and the operator needs to be told, before
1513
+ # process() runs, exactly which gate stands between them and the merge
1514
+ # and which flag waives it. process() still enforces every gate below;
1515
+ # this is a report, not a decision.
1516
+ _only_gate_failures = mq.merge_gate_failures(
1517
+ only_entry, cfg_only, board_only, gh_ops,
1518
+ )
1519
+ for _gf in _only_gate_failures:
1520
+ # NB: --force-merge is deliberately absent here — it waives the CI
1521
+ # gate only (merge_queue.process), never review or smoke.
1522
+ _waived = (
1523
+ (_gf.gate == "review" and skip_review)
1524
+ or (_gf.gate == "smoke" and skip_smoke)
1525
+ )
1526
+ _status = "waived by this run" if _waived else "will block this merge"
1527
+ click.echo(f" gate {_gf.gate}: {_gf.reason} — {_status}")
1528
+ if skip_review:
1529
+ click.echo(" --skip-review: review-approval gate bypassed (#253)")
1530
+ if skip_smoke:
1531
+ click.echo(" --skip-smoke: interactive smoke-test gate bypassed (#465)")
1532
+ only_items = [only_entry]
1533
+ # #1769: re-test a stale-but-passed verdict against the current base
1534
+ # before the gate runs. Opt-in only; `--skip-smoke` is unaffected (it
1535
+ # waives the gate, this satisfies it), and a failing re-test leaves the
1536
+ # entry blocked exactly as it was.
1537
+ if revalidate and not skip_smoke:
1538
+ board_only = _apply_revalidation(
1539
+ only_items, board_only, cfg_only, gh_ops, dry_run=dry_run,
1540
+ )
1541
+ # #1851: CI staleness is a separate gate from the local smoke
1542
+ # verdict — always run under --revalidate, independent of
1543
+ # --skip-smoke (which waives the smoke gate specifically, not CI).
1544
+ deferred_ci: set[str] = set()
1545
+ if revalidate:
1546
+ deferred_ci = _apply_ci_revalidation(
1547
+ only_items, board_only, cfg_only, ci_store_only, gh_ops,
1548
+ dry_run=dry_run,
1549
+ )
1550
+ # #1925: an entry deferred by the CI-settle wait above must not go
1551
+ # through process() this pass — that would immediately re-derive the
1552
+ # exact self-triggered "unknown" reading the wait was just trying to
1553
+ # avoid handing to the gate. It stays PENDING with the explanatory
1554
+ # `entry.error` _apply_ci_revalidation already set.
1555
+ if only_entry.assignment_id in deferred_ci:
1556
+ events_only = []
1557
+ else:
1558
+ events_only = mq.process(
1559
+ only_items, gh_ops,
1560
+ method=method, dry_run=dry_run, presorted=True,
1561
+ ci_store=ci_store_only, force_merge=force_merge,
1562
+ config=cfg_only, board=board_only,
1563
+ skip_review=skip_review, skip_smoke=skip_smoke,
1564
+ )
1565
+ for ev in events_only:
1566
+ e = ev.entry
1567
+ prefix = f" {e.repo_name} #{e.issue_number} ({e.branch})"
1568
+ click.echo(f"{prefix}: {ev.kind} — {ev.message}")
1569
+ # #1474 review: --only used to return here without ever classifying
1570
+ # a fresh conflict — see _dispatch_conflict_fixes's docstring. Run it
1571
+ # before the save below so a retry-cap/non-rebaseable HUMAN_REQUIRED
1572
+ # mutation on only_entry.state is persisted, not lost.
1573
+ _dispatch_conflict_fixes(events_only, cfg_only, dry_run=dry_run)
1574
+ if not dry_run:
1575
+ # Save only the modified entry back; all other entries are untouched.
1576
+ all_items_only = mq.load_queue()
1577
+ by_id_only = {only_entry.assignment_id: only_entry}
1578
+ merged_only = [by_id_only.get(x.assignment_id, x) for x in all_items_only]
1579
+ mq.save_queue(merged_only)
1580
+ click.echo("")
1581
+ click.echo(
1582
+ "Summary (--only): "
1583
+ + ", ".join(f"{k}={v}" for k, v in sorted(
1584
+ {x.state: 1 for x in only_items}.items()
1585
+ ))
1586
+ )
1587
+ return
1588
+
1589
+ cfg = _load_config(config_path)
1590
+
1591
+ # #242: Before processing, scan board.completed for done work assignments
1592
+ # that should be queued but aren't. Without this, `coord merge` silently
1593
+ # no-ops when a work assignment reached "done" via a path that didn't
1594
+ # also trigger the `coord status` enqueue hook (restart, notify-driven
1595
+ # mark_done, etc.). enqueue() is idempotent — by assignment_id — so this
1596
+ # is safe to call on every invocation.
1597
+ #
1598
+ # Filter on issue.state == 'open': a closed issue was almost certainly
1599
+ # already merged externally (or won't-fix'd) and re-attempting a merge
1600
+ # for it would open spurious PRs against branches that may not even
1601
+ # exist anymore. When the issues table has no row for an issue (cache
1602
+ # miss), default to OPEN — that matches the prior coord status enqueue
1603
+ # path which had no such check.
1604
+ # #821: never pass None to process() — use an empty board so
1605
+ # has_smoke_verdict can apply its "no work found → fail open" rule.
1606
+ # process() blocks on board=None when a gate IS required; an empty
1607
+ # board lets the gate function decide.
1608
+ from coord.models import Board as _Board # noqa: PLC0415
1609
+ _raw_board = load_board()
1610
+ board = _raw_board if _raw_board is not None else _Board(active=[], completed=[])
1611
+ open_by_repo, known_by_repo = _load_issue_states()
1612
+
1613
+ auto_enqueued: list[str] = []
1614
+ # Per-repo cache of branches that still exist on origin. Lets us skip
1615
+ # re-enqueuing done-work whose branch was already merged-and-deleted — the
1616
+ # dominant merge-queue clog source. A done assignment for a closed issue
1617
+ # often isn't in the open-only issues cache, so the issue-state filter
1618
+ # above misses it; branch-existence catches every merge path (coord merge,
1619
+ # gh pr merge, manual) uniformly. Fail OPEN on lookup failure.
1620
+ from coord import github_ops as _gho
1621
+ branch_cache: dict[str, set[str]] = {}
1622
+ # #525: per-run cache for work_is_terminal; shared across the whole
1623
+ # auto-enqueue loop so one gh round-trip covers every repeated
1624
+ # (repo, issue, branch) triple.
1625
+ terminal_cache: dict = {}
1626
+ # #934: per-run cache for the issue -> milestone-number lookup, mirroring
1627
+ # terminal_cache above.
1628
+ milestone_cache: dict = {}
1629
+ if board is not None:
1630
+ # #1490: resolve every branch to a single winning work-like row
1631
+ # before any of the filters below run. A fix/bounce cycle piles up
1632
+ # more than one WORK_LIKE_TYPES row on the same branch (the
1633
+ # original dispatch plus every retry), and processing each
1634
+ # independently used to call refresh_entry_assignment once per row
1635
+ # — re-keying the branch's one queue entry, and re-printing
1636
+ # "auto-enqueued", every single time, forever (the exact symptom
1637
+ # #1490 reports: one branch, three identical announcements, on
1638
+ # every `coord merge` pass). Superseded rows are reported once here
1639
+ # and never reach refresh_entry_assignment at all.
1640
+ scoped_completed = [
1641
+ a for a in board.completed
1642
+ if not repo_filter or a.repo_name == repo_filter
1643
+ ]
1644
+ for a, superseded in mq.group_branch_candidates(scoped_completed):
1645
+ for row in superseded:
1646
+ auto_enqueued.append(
1647
+ f" superseded: {row.repo_name} #{row.issue_number} "
1648
+ f"(assignment {row.assignment_id}, branch {row.branch}) — "
1649
+ "not the winning row for this branch, skipped (#1490)"
1650
+ )
1651
+ repo_cfg = cfg.repo(a.repo_name)
1652
+ if repo_cfg is None:
1653
+ continue
1654
+ # #1353: isolate the rest of this assignment's scan — a bad
1655
+ # `gh` round-trip (e.g. empty stdout on exit 0, see
1656
+ # github_ops._gh) or a transient decode failure anywhere below
1657
+ # used to raise straight out of this loop and abort auto-enqueue
1658
+ # for every *other* assignment in the batch too, with the CLI
1659
+ # printing nothing before dying. One assignment misbehaving is
1660
+ # not a reason to skip the whole drain — catch it, report which
1661
+ # assignment and why, and keep scanning the rest.
1662
+ try:
1663
+ # Issue-state filter: skip closed issues (probably merged
1664
+ # elsewhere). We deny only when the cache has explicit
1665
+ # evidence the issue is closed — i.e. there's a row for this
1666
+ # (repo, number) and its state isn't 'open'. If the cache
1667
+ # simply has no row for this issue (e.g. it was created
1668
+ # after the last sync), treat as unknown and allow — denying
1669
+ # on cache miss silently skipped post-sync issues
1670
+ # (#278/#280 hit this).
1671
+ known_issues = known_by_repo.get(a.repo_name, set())
1672
+ open_issues = open_by_repo.get(a.repo_name, set())
1673
+ if a.issue_number in known_issues and a.issue_number not in open_issues:
1674
+ continue
1675
+ # Skip work whose branch no longer exists on origin (already
1676
+ # merged + deleted). Fail OPEN: only skip when we got a real
1677
+ # (non-empty) branch list back and the branch isn't in it.
1678
+ origin_branches = branch_cache.get(a.repo_name)
1679
+ if origin_branches is None:
1680
+ origin_branches = _gho.list_remote_branch_names(repo_cfg.github)
1681
+ branch_cache[a.repo_name] = origin_branches
1682
+ if origin_branches and a.branch not in origin_branches:
1683
+ continue
1684
+ # #525: never enqueue work that is already done on GitHub —
1685
+ # issue closed OR PR merged. Mirrors the #522 guard in
1686
+ # review.dispatch_review. Fail OPEN: a transient gh error
1687
+ # must never block a real enqueue.
1688
+ if _gho.work_is_terminal(
1689
+ repo_cfg.github, a.issue_number, a.branch,
1690
+ cache=terminal_cache,
1691
+ ):
1692
+ continue
1693
+ # #946: review + smoke gates, via the shared predicate — this
1694
+ # loop was the primary ungated enqueue path (#782/#795 reached
1695
+ # the merge queue with a failed test / no review at all).
1696
+ #
1697
+ # #1695: the gate no longer *drops* the row here. Blocking at
1698
+ # enqueue time made `coord merge --skip-review` structurally
1699
+ # unreachable — the flag waives the gate for an entry that
1700
+ # already exists, but an un-approved row could never become
1701
+ # an entry, so `--only` had nothing to address and the silent
1702
+ # `continue` printed nothing about why. The row is now
1703
+ # enqueued in a visibly BLOCKED state (it will render as
1704
+ # BLOCKED in `--plan`/`--dry-run` via `_entry_gate_status`,
1705
+ # and `--only` can name it), and the gate is enforced where
1706
+ # its override lives: `process()` still refuses to merge it
1707
+ # unless `--skip-review`/`--skip-smoke` is given. Enqueueing
1708
+ # changes visibility, never eligibility — auto-drain only
1709
+ # ever touches PLAN_READY entries, and a blocked entry is
1710
+ # PLAN_BLOCKED, so this is safe with `merge.auto_drain: true`.
1711
+ #
1712
+ # #934: target `feature/ms-NN` when this issue belongs to a
1713
+ # milestone and the repo opted into the git model — the
1714
+ # milestone lookup itself is skipped (no `gh` call) when it
1715
+ # hasn't, falling back to `default_branch` unchanged.
1716
+ # #2085: resolved BEFORE the gate check now (it used to run
1717
+ # after) — `mq.live_gate_entry` below needs a target_branch
1718
+ # to populate the #821/#1479 freshness anchors live.
1719
+ target_branch = repo_cfg.default_branch
1720
+ if getattr(repo_cfg, "develop_branch", None):
1721
+ from coord.branch_model import ( # noqa: PLC0415
1722
+ fetch_issue_milestone_number,
1723
+ resolve_base_branch,
1724
+ )
1725
+
1726
+ milestone_number = fetch_issue_milestone_number(
1727
+ repo_cfg.github, a.issue_number, cache=milestone_cache,
1728
+ )
1729
+ target_branch = resolve_base_branch(repo_cfg, milestone_number)
1730
+
1731
+ # #2085: `a` is a raw board Assignment — no `branch_head_sha`/
1732
+ # `repo_github`/`target_branch` attribute, so handing it
1733
+ # straight to `merge_gate_failures` made the #821
1734
+ # SHA-freshness check inside `has_approved_review`
1735
+ # permanently unconfirmable (fails closed on every review
1736
+ # carrying a real `review_head_sha`, i.e. virtually every
1737
+ # modern approval) — this scan printed "BLOCKED: review
1738
+ # required but not approved" for ordinary fresh approvals on
1739
+ # every single invocation. `mq.live_gate_entry` builds the
1740
+ # same live-anchored synthetic entry
1741
+ # `coord.gates.build_gate_report` uses, backed by `_gho`
1742
+ # (already available in this loop for the terminal-state
1743
+ # check above), so a genuinely fresh approval reads BLOCKED
1744
+ # only when it actually is.
1745
+ gate_entry = mq.live_gate_entry(a, repo_cfg.github, target_branch, _gho)
1746
+ gate_failures = mq.merge_gate_failures(gate_entry, cfg, board, _gho)
1747
+ gate_note = ""
1748
+ if gate_failures:
1749
+ gate_note = (
1750
+ " — BLOCKED: "
1751
+ + mq.describe_merge_gate_failures(gate_failures)
1752
+ )
1753
+ # #736 / #292: use refresh_entry_assignment (not bare
1754
+ # enqueue) so an existing PENDING entry is re-keyed to the
1755
+ # latest fix assignment when the original assignment_id no
1756
+ # longer matches. Dedup by (repo_github, branch) is
1757
+ # preserved — refresh_entry_assignment is a no-op when the
1758
+ # entry is already correctly keyed.
1759
+ if mq.refresh_entry_assignment(
1760
+ a,
1761
+ repo_github=repo_cfg.github,
1762
+ target_branch=target_branch,
1763
+ ):
1764
+ auto_enqueued.append(
1765
+ f" auto-enqueued: {a.repo_name} #{a.issue_number} "
1766
+ f"({a.branch} → {target_branch}){gate_note}"
1767
+ )
1768
+ elif gate_failures:
1769
+ # Already queued and still blocked. Re-state it every pass
1770
+ # rather than once at creation: a blocked entry is exactly
1771
+ # the row the operator is looking for, and #1695's whole
1772
+ # complaint is that this state was invisible. Emitted at
1773
+ # most once per branch — `group_branch_candidates` has
1774
+ # already collapsed the fix/bounce rows (#1490).
1775
+ auto_enqueued.append(
1776
+ f" blocked: {a.repo_name} #{a.issue_number} "
1777
+ f"(assignment {a.assignment_id}, {a.branch} → "
1778
+ f"{target_branch}) — "
1779
+ f"{mq.describe_merge_gate_failures(gate_failures)}"
1780
+ )
1781
+ except Exception as e: # noqa: BLE001
1782
+ auto_enqueued.append(
1783
+ f" skipped: {a.repo_name} #{a.issue_number} "
1784
+ f"(assignment {a.assignment_id}) — auto-enqueue scan "
1785
+ f"failed, skipping this assignment: {e!r}"
1786
+ )
1787
+ for line in auto_enqueued:
1788
+ click.echo(line)
1789
+
1790
+ # #1477: re-test any parked CONFLICT entry against GitHub's own
1791
+ # mergeability computation before the pending scan below — a branch
1792
+ # repaired by a conflict-fix worker (or by hand) since the last tick
1793
+ # must clear itself here, not require a manual --drop + re-enqueue +
1794
+ # --only. Runs unconditionally (even under --dry-run): it's a state
1795
+ # correction, not a merge action, same posture as the auto-enqueue scan
1796
+ # above. #1477-review: unlike every other --dry-run line in this
1797
+ # command, this one really does persist (it corrects previously-cached
1798
+ # state rather than proposing a merge action), so it's called out
1799
+ # explicitly here rather than left to blend in with the "(dry run)
1800
+ # would ..." lines below.
1801
+ for ev in mq.reconcile_conflict_entries(gh_ops):
1802
+ e = ev.entry
1803
+ suffix = " (reconciled — persisted even under --dry-run)" if dry_run else ""
1804
+ click.echo(
1805
+ f" {e.repo_name} #{e.issue_number} ({e.branch}): {ev.kind} — {ev.message}{suffix}"
1806
+ )
1807
+
1808
+ items = mq.load_queue()
1809
+ if repo_filter:
1810
+ items = [x for x in items if x.repo_name == repo_filter]
1811
+ if not items:
1812
+ # Distinguish "nothing in the queue" from "nothing to do because
1813
+ # there's no completed work to merge" — the latter is the common
1814
+ # case before #242 was fixed and was the silent-fail symptom.
1815
+ if board is not None and any(
1816
+ a.type in WORK_LIKE_TYPES and a.status == "done" and a.branch
1817
+ for a in board.completed
1818
+ if (not repo_filter or a.repo_name == repo_filter)
1819
+ ):
1820
+ click.echo("Merge queue is empty (all done-work is already merged or has no branch).")
1821
+ else:
1822
+ click.echo("Merge queue is empty (no completed work to merge).")
1823
+ return
1824
+
1825
+ presorted = False
1826
+ if order:
1827
+ ids = [s.strip() for s in order.split(",") if s.strip()]
1828
+ items = mq.reorder(items, ids)
1829
+ presorted = True
1830
+
1831
+ pending = [x for x in items if x.state == PENDING]
1832
+ if not pending:
1833
+ # Still surface terminal states so the user knows what happened.
1834
+ for x in items:
1835
+ click.echo(f" [{x.state}] {x.repo_name} #{x.issue_number} ({x.branch})")
1836
+ return
1837
+
1838
+ ci_store = build_ci_store(cfg.ci_store.type)
1839
+ if skip_review:
1840
+ click.echo(" --skip-review: review-approval gate bypassed (#253)")
1841
+ if skip_smoke:
1842
+ click.echo(" --skip-smoke: interactive smoke-test gate bypassed (#465)")
1843
+ # #1769: the merge lane's stale-verdict resolution. Off unless the operator
1844
+ # asked for it — with no `--revalidate` this block does not run at all and
1845
+ # `coord merge` behaves byte-identically to before.
1846
+ if revalidate and not skip_smoke:
1847
+ board = _apply_revalidation(pending, board, cfg, gh_ops, dry_run=dry_run)
1848
+ # #1851: CI staleness is a separate gate from the local smoke verdict —
1849
+ # always run under --revalidate, independent of --skip-smoke (which
1850
+ # waives the smoke gate specifically, not CI).
1851
+ deferred_ci: set[str] = set()
1852
+ if revalidate:
1853
+ deferred_ci = _apply_ci_revalidation(
1854
+ pending, board, cfg, ci_store, gh_ops, dry_run=dry_run,
1855
+ )
1856
+ # #1925: entries the CI-settle wait above gave up on while still only
1857
+ # seeing the registration-gap symptom must not go through process() this
1858
+ # pass — see `_apply_ci_revalidation`'s docstring. They keep their
1859
+ # PENDING state and the explanatory `entry.error` it already set; `items`
1860
+ # (unfiltered) still carries them through to the save step below.
1861
+ process_items = (
1862
+ [x for x in items if x.assignment_id not in deferred_ci]
1863
+ if deferred_ci else items
1864
+ )
1865
+ events = mq.process(
1866
+ process_items, gh_ops,
1867
+ method=method, dry_run=dry_run, presorted=presorted,
1868
+ ci_store=ci_store, force_merge=force_merge,
1869
+ config=cfg, board=board, skip_review=skip_review, skip_smoke=skip_smoke,
1870
+ )
1871
+
1872
+ for ev in events:
1873
+ e = ev.entry
1874
+ prefix = f" {e.repo_name} #{e.issue_number} ({e.branch})"
1875
+ click.echo(f"{prefix}: {ev.kind} — {ev.message}")
1876
+
1877
+ # #241: classify any conflict events and dispatch a conflict-fix worker
1878
+ # for the eligible ones (extracted to _dispatch_conflict_fixes, #1474
1879
+ # review, so the --only path below can share it).
1880
+ _dispatch_conflict_fixes(events, cfg, dry_run=dry_run)
1881
+
1882
+ # Save state only when we actually moved
1883
+ if not dry_run:
1884
+ # Persist the updated entries by merging back over the on-disk queue.
1885
+ all_items = mq.load_queue()
1886
+ by_id = {x.assignment_id: x for x in items}
1887
+ merged = [by_id.get(x.assignment_id, x) for x in all_items]
1888
+ mq.save_queue(merged)
1889
+
1890
+ # Summary
1891
+ states: dict[str, int] = {}
1892
+ for x in items:
1893
+ states[x.state] = states.get(x.state, 0) + 1
1894
+ click.echo("")
1895
+ click.echo(
1896
+ "Summary: "
1897
+ + ", ".join(f"{k}={v}" for k, v in sorted(states.items()))
1898
+ )
1899
+ if states.get(CONFLICT):
1900
+ click.echo("note: at least one PR has a conflict — resolve manually, then re-run.")