code-coordinator 0.5.46__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. coord/worker_events.py +954 -0
coord/revalidate.py ADDED
@@ -0,0 +1,1101 @@
1
+ """``coord merge --revalidate`` — the merge lane's stale-verdict resolution (#1769).
2
+
3
+ #1738 gave ``coord drive`` an arm for a STALE-but-``passed`` smoke verdict: it
4
+ re-dispatches the Test stage against the current base instead of escalating to
5
+ a human. That arm lives in :mod:`coord.drive` and only ever fires while a live
6
+ drive is watching the issue. Every *other* merge path — ``coord merge``, its
7
+ ``--only`` form, the auto-drain, the TUI merge action, the daemon ``/merge``
8
+ route — still had only escalate-or-block, so a branch that finishes and sits in
9
+ the merge queue with no live drive stays stuck: the next merge moves the base,
10
+ stales its verdict, and nobody is watching. Measured on 2026-08-03: three
11
+ stale-verdict stalls in one session, #1738's arm could fire on exactly one.
12
+
13
+ **This module is the resolution for that lane**, and it is deliberately
14
+ *opt-in*. ``coord merge`` with no flag is byte-identical to before — nothing
15
+ here runs unless the operator typed ``--revalidate``. An unattended dispatcher
16
+ firing test runs from inside the merge path is the shape that was gated off
17
+ after the 2026-06-07 auto-loop token-burn incident (it is why
18
+ ``merge.auto_drain`` defaults to ``false``), so the auto-drain and the daemon's
19
+ own periodic drain pass ``revalidate=False`` and always will.
20
+
21
+ STRATEGY — batch composite (#1715 Option 3)
22
+ -------------------------------------------
23
+ What the operator does by hand — three times in the session above — is: make a
24
+ worktree at the current base, compose every stale branch onto it, run the suite
25
+ **once**, and let them all through on that single result. That is what
26
+ :func:`revalidate` does, and it is what removes the *cascade*: N approved
27
+ branches against one base used to cost N−1 full suite runs, because the first
28
+ merge staled everything behind it. One composite run costs 1.
29
+
30
+ **The honest trade, stated plainly (#1715):** a composite run validates the
31
+ *composite*, not each branch alone. A green composite does not prove each
32
+ branch is green in isolation. That is acceptable here for one specific reason
33
+ — every branch in the set **already carries its own ``passed`` verdict**
34
+ against an earlier base, so the composite is re-confirming that those verdicts
35
+ still hold *together* against the current base. It is a re-confirmation, not a
36
+ first proof, which is exactly why :func:`coord.merge_queue.
37
+ revalidation_candidates` refuses to include an entry that never had a verdict
38
+ (``SMOKE_MISSING``), or one blocked on review/CI/conflict.
39
+
40
+ It is also a *truer* claim than it first looks: :func:`coord.merge_queue.
41
+ process` snapshots ``target_branch_head_sha`` **once per (repo, target_branch)
42
+ group**, so the whole batch merges against the same base the composite was
43
+ built on. The tree the composite validated is the tree that ends up on the
44
+ base branch.
45
+
46
+ FAILURE DOES NOT POISON THE BATCH (#1715)
47
+ -----------------------------------------
48
+ A red composite is the hard part: the naive version blocks all N on one
49
+ branch's fault. :func:`revalidate_group` is the resolution — on a red
50
+ composite it merges **nothing**, marks **nothing** failed, and falls back to
51
+ re-running each candidate **individually** against the current base. The
52
+ culprit is named by its own failing run; the innocent branches get a genuine
53
+ solo verdict and still merge in the same invocation.
54
+
55
+ Cost: a green composite (the overwhelmingly common case) is **one** run total.
56
+ A red composite is 1 + N in the worst case, which is the bound #1715 specifies.
57
+
58
+ Per-entry narrowing was chosen over a bisect: a bisect is only cheaper when
59
+ there is exactly **one** culprit, degrades as soon as there are two, and its
60
+ bookkeeping is subtle. The per-entry pass is flat O(N), identifies *every*
61
+ culprit rather than the first, and — the part that actually matters — leaves
62
+ each survivor with a verdict earned by a run that validated **that branch
63
+ alone against the current base**, which is a strictly stronger claim than
64
+ "was a member of some green subset". N here is a merge queue's depth (2–5),
65
+ so the constant factor is not worth the ambiguity.
66
+
67
+ Nothing here dispatches a worker or spends tokens: it is `git` + the repo's own
68
+ ``build_command``/``test_command``, run locally, exactly like ``coord test``
69
+ (#561's throwaway-worktree discipline included — the base checkout is never
70
+ moved, because on the daemon host it doubles as the live editable coordinator
71
+ source).
72
+
73
+ BOUNDED
74
+ -------
75
+ One composite run, plus at most one solo run per candidate, per ``coord merge
76
+ --revalidate`` invocation. There is no retry loop and no second composite: a
77
+ branch whose *solo* run fails terminates blocked with the failure quoted, and
78
+ the operator (or the next invocation) decides. That is the merge lane's
79
+ analogue of #1738's ``fix_rounds`` budget — a branch that cannot pass
80
+ terminates with a clear reason rather than spinning.
81
+
82
+ OPT-IN, ALWAYS
83
+ --------------
84
+ None of this may ever run unattended. ``merge.auto_drain`` is ``false`` by
85
+ design after the 2026-06-07 token-burn incident, and the daemon's
86
+ ``_auto_drain_tick`` passes ``revalidate=False`` permanently. Batch
87
+ revalidation inherits that posture wholesale: an operator asks for it, or it
88
+ does not happen.
89
+ """
90
+
91
+ from __future__ import annotations
92
+
93
+ import os
94
+ import shutil
95
+ import subprocess
96
+ from dataclasses import dataclass, field
97
+ from pathlib import Path
98
+
99
+ from coord.merge_queue import RevalidationCandidate
100
+
101
+ # Hard ceiling on the composite suite run. A hung test binary must not wedge a
102
+ # `coord merge` invocation forever — and on the daemon route this runs inside a
103
+ # request handler that holds `_merge_lock`, so every other merge in the fleet
104
+ # waits behind it. Generous but bounded: the claude-coordinator suite is ~6 min
105
+ # serial and the tui `cargo test` leg is slower still, so 30 min covers a cold
106
+ # composite with room to spare while still terminating a wedged run.
107
+ # `_merge_via_daemon` sizes the thin client's HTTP timeout off this value.
108
+ DEFAULT_TIMEOUT_SECONDS = 60 * 30
109
+
110
+ # #1715-review: `revalidate_group`'s red-composite fallback is 1 (composite) +
111
+ # N (one solo re-test per candidate) serial suite runs — see that function's
112
+ # docstring. #1769 sized the thin client's HTTP timeout for exactly ONE run;
113
+ # this module's own worst case is now 1 + N, and the client has to outlast it
114
+ # or the operator sees "error: merge via daemon failed" for a batch that
115
+ # actually finished (the daemon keeps running under `_merge_lock` regardless —
116
+ # see `_merge_via_daemon`'s docstring).
117
+ #
118
+ # The client posts to ``/merge`` before any candidate is known — computing the
119
+ # real N would mean re-implementing `merge_queue.revalidation_candidates`'s
120
+ # whole eligibility policy (board state, CI lookups) on a thin client, which
121
+ # is exactly what #584 routes to the daemon to avoid. So this is a documented,
122
+ # deliberately generous ceiling rather than a measured count: comfortably
123
+ # above the "merge queue depth (2-5)" this module's STRATEGY section cites, so
124
+ # a real-world batch never gets close to it. A batch that somehow exceeds it
125
+ # just gets the pre-#1715 false-negative report back — the daemon still
126
+ # finishes the merge either way.
127
+ MAX_REVALIDATION_BATCH = 10
128
+
129
+
130
+ def client_timeout_seconds(revalidate: bool) -> float:
131
+ """HTTP timeout ``_merge_via_daemon`` should give a ``/merge`` POST.
132
+
133
+ A plain merge gets the pre-#1769 900s ceiling. A ``--revalidate`` run can
134
+ execute the whole suite up to ``1 + MAX_REVALIDATION_BATCH`` times (see
135
+ that constant) before the daemon responds, so it gets a window sized off
136
+ that worst case instead of a single :data:`DEFAULT_TIMEOUT_SECONDS`.
137
+
138
+ #1925 adds a second, independent cost on the same request: the CI arm
139
+ (``_apply_ci_revalidation`` in ``coord/commands/merge.py``) now waits,
140
+ per CI-stale candidate, up to :data:`coord.ci_store.
141
+ CI_RERUN_MAX_WAIT_SECONDS` for a just-triggered re-run to settle before
142
+ ``process()`` evaluates it — bounded polling, not a suite run, but still
143
+ wall-clock the daemon holds the request open for. Worst case is every
144
+ candidate in the batch needing the full CI wait, serially, so the same
145
+ :data:`MAX_REVALIDATION_BATCH` ceiling scales this term too.
146
+ """
147
+ if not revalidate:
148
+ return 900.0
149
+ from coord.ci_store import CI_RERUN_MAX_WAIT_SECONDS # noqa: PLC0415
150
+
151
+ return (
152
+ float(DEFAULT_TIMEOUT_SECONDS) * (1 + MAX_REVALIDATION_BATCH)
153
+ + float(CI_RERUN_MAX_WAIT_SECONDS) * MAX_REVALIDATION_BATCH
154
+ + 300.0
155
+ )
156
+
157
+
158
+ # How much of a failing run's output to quote back. The whole point of the
159
+ # "a failed re-test leaves the entry blocked with the failure quoted" rule is
160
+ # that the operator can act on it without going hunting, but a full pytest
161
+ # log has no business being echoed into a merge summary.
162
+ _OUTPUT_TAIL_CHARS = 4000
163
+
164
+
165
+ def _tail(text: str, limit: int = _OUTPUT_TAIL_CHARS) -> str:
166
+ text = (text or "").strip()
167
+ if len(text) <= limit:
168
+ return text
169
+ return "…(truncated)…\n" + text[-limit:]
170
+
171
+
172
+ # Why a composite failed, which decides whether splitting it up can help.
173
+ #
174
+ # SETUP failures are *common-mode*: no test command, no local checkout, a
175
+ # failed fetch, candidates spanning two bases. Every candidate would hit the
176
+ # identical wall, so a per-entry fallback would just reproduce the same error
177
+ # N times for nothing. COMPOSE/BUILD/SUITE/TIMEOUT are per-branch-attributable
178
+ # — that is precisely what the fallback exists to narrow down.
179
+ #
180
+ # INFRA (#1814) is a third thing again, and the distinction it draws is the
181
+ # point of that issue: the suite did not FAIL, it did not RUN. The daemon that
182
+ # executes this is a systemd user unit whose PATH never saw ~/.cargo/bin, so
183
+ # `cargo` was simply not there and the shell's "command not found" was being
184
+ # reported as a red suite for a branch CI had already proven green. Like SETUP
185
+ # it is common-mode (never narrowable — every solo run hits the identical
186
+ # wall), but unlike SETUP it happened *after* the composite was built, so the
187
+ # worktree is kept and the operator-facing wording must say "could not run",
188
+ # never "SUITE FAILED".
189
+ KIND_OK = "ok"
190
+ KIND_SETUP = "setup"
191
+ KIND_COMPOSE = "compose"
192
+ KIND_BUILD = "build"
193
+ KIND_SUITE = "suite"
194
+ KIND_TIMEOUT = "timeout"
195
+ KIND_INFRA = "infra"
196
+
197
+ #: Composite failure kinds a per-entry pass can actually narrow (#1715).
198
+ NARROWABLE_KINDS = frozenset({
199
+ KIND_COMPOSE, KIND_BUILD, KIND_SUITE, KIND_TIMEOUT,
200
+ })
201
+
202
+ #: Kinds where no suite was actually executed, so "how many suite runs did
203
+ #: that cost" is zero and no verdict may be inferred in either direction.
204
+ NO_SUITE_RAN_KINDS = frozenset({KIND_SETUP, KIND_INFRA})
205
+
206
+ #: Exit code ``scripts/coord-test-runner.sh`` reserves for "the suite could not
207
+ #: run" (a missing toolchain). See that script's header. Documented here, but
208
+ #: deliberately NOT trusted on its own — see :func:`is_infrastructure_failure`.
209
+ RUNNER_INFRA_EXIT = 3
210
+
211
+ #: 127 is every POSIX shell's "command not found" — an arbitrary repo's own
212
+ #: ``test_command`` that dies this way never started a suite either. Unlike a
213
+ #: small integer, this one is reserved by the shell rather than chosen by the
214
+ #: command, so it carries the same meaning for a command we did not write.
215
+ SHELL_NOT_FOUND_EXIT = 127
216
+
217
+ #: What ``coord-test-runner.sh`` prints when it cannot find a toolchain. THIS
218
+ #: is the signal, not the exit code: a repo's own build/test command is free
219
+ #: to exit 3 for a perfectly genuine failure (this repo's own test suite has
220
+ #: a ``build_command = "exit 3"`` case), so keying on the number alone would
221
+ #: relabel real red builds as infrastructure — the dangerous direction.
222
+ INFRA_OUTPUT_MARKERS = ("TOOLCHAIN MISSING", "RESULT: INFRA")
223
+
224
+
225
+ def is_infrastructure_failure(returncode: int, output: str) -> bool:
226
+ """True when a build/test command never actually ran the suite (#1814).
227
+
228
+ Two signals, either of which is enough:
229
+
230
+ * a bare shell ``command not found`` (:data:`SHELL_NOT_FOUND_EXIT`) — the
231
+ universal one, and the exact shape of the bug that motivated this
232
+ (``cargo: command not found`` inside the ``coord-serve`` daemon);
233
+ * one of :data:`INFRA_OUTPUT_MARKERS` in the output — the runner's own
234
+ explicit, deliberately unmistakable statement that it could not run.
235
+
236
+ Note what is *not* a signal: :data:`RUNNER_INFRA_EXIT` on its own. Our
237
+ runner always prints a marker alongside it, and an arbitrary repo's
238
+ command may already use 3 for a real failure, so the number adds nothing
239
+ and risks laundering a red build into "could not run".
240
+
241
+ Deliberately narrow in the same spirit: this never guesses from a generic
242
+ substring like "not found", which appears in ordinary assertion messages.
243
+ Misclassifying a real failure is the worse error of the two — it is the
244
+ one that could eventually launder a merge — so the ambiguous cases all
245
+ fall through to "this is a verdict".
246
+
247
+ The marker check is anchored to the START of a line, not a bare substring
248
+ search over the whole blob (#1814 review). `coord-test-runner.sh`'s own
249
+ ``say()`` always emits a marker as the first characters of a line it
250
+ prints — but for `claude-coordinator` itself, ``test_command`` is that
251
+ runner's full ``pytest`` arm, which (as of this fix) contains tests whose
252
+ literal assertion text and parametrize IDs embed these exact marker
253
+ strings (see ``tests/test_coord_test_runner_toolchain.py`` and
254
+ ``tests/test_revalidate.py``). If any of those specific tests ever fails
255
+ for an unrelated reason, pytest's ``FAILED tests/...::test[MARKER...]``
256
+ summary line and ``E assert 'MARKER...' in '...'`` diff both contain
257
+ the marker text too — but never at the start of a line: pytest indents
258
+ diff lines with ``E ``/spaces and prefixes summary lines with
259
+ ``FAILED ``, and the runner's own re-run dumps
260
+ (``coord-test-runner.sh``'s ``tail -n 40 ... | sed 's/^/ /'``) are
261
+ explicitly indented before being echoed. A bare substring match would
262
+ misclassify that unrelated Python failure as infrastructure and hide it
263
+ behind "fix the runner environment"; anchoring to line-start does not.
264
+ """
265
+ if returncode == SHELL_NOT_FOUND_EXIT:
266
+ return True
267
+ lines = (output or "").splitlines()
268
+ return any(
269
+ line.startswith(marker) for line in lines for marker in INFRA_OUTPUT_MARKERS
270
+ )
271
+
272
+
273
+ @dataclass
274
+ class RevalidationResult:
275
+ """Outcome of one composite revalidation run.
276
+
277
+ ``ok`` is the only thing the merge path branches on: ``True`` means fresh
278
+ ``passed`` verdicts were recorded for every candidate and ``process()`` may
279
+ now find their smoke gate satisfied; ``False`` means every candidate is
280
+ left exactly as it was — still blocked, never merged.
281
+
282
+ ``kind`` classifies a failure (#1715) so :func:`revalidate_group` can tell
283
+ "this branch broke it" from "nothing here could ever have run".
284
+ """
285
+
286
+ ok: bool
287
+ reason: str = ""
288
+ output: str = ""
289
+ composed: list[str] = field(default_factory=list)
290
+ recorded: list[str] = field(default_factory=list)
291
+ worktree: Path | None = None
292
+ kind: str = KIND_OK
293
+
294
+ def __bool__(self) -> bool: # pragma: no cover — convenience only
295
+ return self.ok
296
+
297
+ @property
298
+ def narrowable(self) -> bool:
299
+ """True when re-running the candidates one at a time could help."""
300
+ return not self.ok and self.kind in NARROWABLE_KINDS
301
+
302
+
303
+ @dataclass
304
+ class BatchRevalidationResult:
305
+ """Outcome of one ``(repo, target_branch)`` group's revalidation (#1715).
306
+
307
+ A green composite is the whole story: ``composite.ok`` and ``per_entry``
308
+ empty, ``suite_runs == 1`` however many candidates there were — that count
309
+ is the entire point of the feature and the black-box tests assert it
310
+ directly.
311
+
312
+ A red composite fills ``per_entry`` with one solo result per candidate.
313
+ ``recorded`` then holds only the survivors' assignment ids, and
314
+ ``culprits`` names the branches whose own run failed.
315
+ """
316
+
317
+ composite: RevalidationResult
318
+ per_entry: list[tuple[str, RevalidationResult]] = field(default_factory=list)
319
+ recorded: list[str] = field(default_factory=list)
320
+ culprits: list[str] = field(default_factory=list)
321
+ suite_runs: int = 0
322
+
323
+ @property
324
+ def ok(self) -> bool:
325
+ """True when every candidate came out with a fresh verdict."""
326
+ return self.composite.ok
327
+
328
+ @property
329
+ def fell_back(self) -> bool:
330
+ return bool(self.per_entry)
331
+
332
+
333
+ class _Echo:
334
+ """Null echo so the library is usable without a Click context."""
335
+
336
+ def __call__(self, msg: str = "") -> None: # pragma: no cover
337
+ return None
338
+
339
+
340
+ def local_repo_dir(config, repo_name: str) -> Path | None:
341
+ """Resolve the base checkout for *repo_name*.
342
+
343
+ Same resolution ``coord test`` uses (``coord.commands.test_gate.
344
+ _local_repo_dir``): this machine's ``repo_paths`` first, then any machine
345
+ in the config that knows the repo. Returns an expanded :class:`Path`, or
346
+ ``None`` when no path is configured.
347
+ """
348
+ import socket
349
+
350
+ hostname = socket.gethostname().split(".")[0]
351
+ local_machine = next(
352
+ (
353
+ m for m in getattr(config, "machines", [])
354
+ if m.name == hostname or m.host.split(".")[0] == hostname
355
+ ),
356
+ None,
357
+ )
358
+ repo_path = None
359
+ if local_machine is not None:
360
+ repo_path = local_machine.repo_path(repo_name)
361
+ if repo_path is None:
362
+ for m in getattr(config, "machines", []):
363
+ repo_path = m.repo_path(repo_name)
364
+ if repo_path:
365
+ break
366
+ return Path(repo_path).expanduser() if repo_path else None
367
+
368
+
369
+ def revalidation_worktree_path(
370
+ repo_name: str, target_branch: str, slug: str | None = None,
371
+ ) -> Path:
372
+ """Throwaway worktree for a composite revalidation run.
373
+
374
+ Under ``~/.coord/revalidate-worktrees/`` — OUTSIDE the base checkout, for
375
+ the #561 reason: the base checkout doubles as the live editable coordinator
376
+ source on the daemon host, so moving its branch silently downgrades the
377
+ running ``coord`` until somebody restores it.
378
+
379
+ *slug* (#1715) distinguishes the per-entry fallback runs from the composite
380
+ they follow. Without it every solo run would reuse — and therefore delete —
381
+ the failed composite's worktree, which :func:`format_failure` has just
382
+ told the operator was "kept for inspection".
383
+ """
384
+ from coord.state import COORD_DIR
385
+
386
+ name = f"{repo_name}-{target_branch.replace('/', '-')}"
387
+ if slug:
388
+ name += f"--{slug.replace('/', '-')}"
389
+ return COORD_DIR / "revalidate-worktrees" / name
390
+
391
+
392
+ def _run(
393
+ args: list[str], *, cwd: Path, timeout: int | None = 300
394
+ ) -> subprocess.CompletedProcess:
395
+ return subprocess.run(
396
+ args, cwd=str(cwd), capture_output=True, text=True, timeout=timeout,
397
+ )
398
+
399
+
400
+ def _rev_parse(repo_dir: Path, ref: str) -> str | None:
401
+ """Resolve *ref* to a full SHA in *repo_dir*, or ``None``."""
402
+ try:
403
+ res = _run(["git", "rev-parse", ref], cwd=repo_dir, timeout=60)
404
+ except (subprocess.SubprocessError, OSError):
405
+ return None
406
+ if res.returncode != 0:
407
+ return None
408
+ sha = res.stdout.strip()
409
+ return sha or None
410
+
411
+
412
+ def _remove_worktree(repo_dir: Path, wt_path: Path) -> None:
413
+ """Best-effort removal (+ prune of the admin refs), mirroring ``coord test``.
414
+
415
+ Falls back to a plain directory delete: the path can survive as an
416
+ orphaned tree when the worktree was registered against a *different* base
417
+ checkout (a re-cloned repo, a moved ``repo_path``), and ``git worktree
418
+ add`` refuses a path that already exists — which would wedge every future
419
+ revalidation for that (repo, target) pair.
420
+ """
421
+ for args in (
422
+ ["git", "worktree", "remove", "--force", str(wt_path)],
423
+ ["git", "worktree", "prune"],
424
+ ):
425
+ try:
426
+ _run(args, cwd=repo_dir, timeout=60)
427
+ except (subprocess.SubprocessError, OSError):
428
+ pass
429
+ if wt_path.exists():
430
+ shutil.rmtree(wt_path, ignore_errors=True)
431
+
432
+
433
+ def describe_candidates(candidates: list[RevalidationCandidate]) -> list[str]:
434
+ """One operator-readable line per candidate, for ``--dry-run`` output."""
435
+ lines: list[str] = []
436
+ for c in candidates:
437
+ e = c.entry
438
+ lines.append(
439
+ f" revalidate: {e.repo_name} #{e.issue_number} ({e.branch} → "
440
+ f"{e.target_branch}) — "
441
+ f"{c.smoke.short_reason or 'verdict no longer covers the base'}"
442
+ )
443
+ return lines
444
+
445
+
446
+ def group_candidates(
447
+ candidates: list[RevalidationCandidate],
448
+ ) -> list[tuple[tuple[str, str], list[RevalidationCandidate]]]:
449
+ """Split *candidates* into the batches that will each cost one suite run.
450
+
451
+ Keyed by ``(repo_name, target_branch)`` — one composite can only be built
452
+ per base, so that pair is exactly the batch boundary. Sorted so the
453
+ ``--dry-run`` preview and the real run enumerate the batches identically.
454
+ """
455
+ groups: dict[tuple[str, str], list[RevalidationCandidate]] = {}
456
+ for c in candidates:
457
+ groups.setdefault(
458
+ (c.entry.repo_name, c.entry.target_branch), [],
459
+ ).append(c)
460
+ return sorted(groups.items())
461
+
462
+
463
+ def describe_batches(
464
+ candidates: list[RevalidationCandidate],
465
+ ) -> list[str]:
466
+ """``--dry-run`` preview: the batches, their members, and the run count.
467
+
468
+ #1715 requires the dry run to "name the batch members and state plainly
469
+ that one composed run will validate all of them" — an operator has to be
470
+ able to see, *before* committing 7 minutes, exactly which branches are
471
+ about to be composed together and that they cost one suite run rather
472
+ than one each.
473
+ """
474
+ lines: list[str] = []
475
+ batches = group_candidates(candidates)
476
+ for (repo_name, target_branch), group in batches:
477
+ n = len(group)
478
+ if n == 1:
479
+ lines.append(
480
+ f" --revalidate: (dry run) {repo_name} → {target_branch}: "
481
+ "1 entry, 1 suite run against the current base:"
482
+ )
483
+ else:
484
+ lines.append(
485
+ f" --revalidate: (dry run) {repo_name} → {target_branch}: "
486
+ f"BATCH of {n} — all {n} branches would be composed onto "
487
+ f"origin/{target_branch} together and validated by ONE "
488
+ f"composed suite run (not {n}):"
489
+ )
490
+ lines.extend(describe_candidates(group))
491
+ total = len(candidates)
492
+ lines.append(
493
+ f" --revalidate: (dry run) {total} entry(ies) in "
494
+ f"{len(batches)} batch(es) — {len(batches)} suite run(s), "
495
+ "then merge. Nothing has been run and no verdict written."
496
+ )
497
+ if any(len(g) > 1 for _, g in batches):
498
+ lines.append(
499
+ " --revalidate: (dry run) a composed run validates the "
500
+ "COMPOSITE, not each branch alone — every member already holds "
501
+ "its own passed verdict, so this re-confirms they still hold "
502
+ "together against the current base. If the composite fails, "
503
+ "nothing merges and each branch is then re-tested alone to find "
504
+ "the culprit."
505
+ )
506
+ return lines
507
+
508
+
509
+ def revalidate(
510
+ candidates: list[RevalidationCandidate],
511
+ config,
512
+ *,
513
+ echo=None,
514
+ timeout: int = DEFAULT_TIMEOUT_SECONDS,
515
+ runner=None,
516
+ worktree_slug: str | None = None,
517
+ ) -> RevalidationResult:
518
+ """Compose every candidate branch onto the current base, run the suite once.
519
+
520
+ On success, records a fresh ``passed`` Test-gate verdict for each
521
+ candidate's work assignment. ``coord.state.record_test_verdict`` re-stamps
522
+ the #1479 freshness anchors (``test_base_sha``/``test_head_sha``/
523
+ ``test_patch_id``) as part of that write, so the verdict is anchored to the
524
+ base the composite was actually validated against — which is the whole
525
+ point, and is why this cannot be done by hand-editing ``test_state``.
526
+
527
+ On **any** failure — a branch that will not compose, a build failure, a
528
+ test failure, a timeout, a missing local checkout or an unconfigured test
529
+ command — **no verdict is written at all** and every candidate is left
530
+ blocked. There is no partial credit: this must never become a laundering
531
+ path for a verdict that would not pass against the current base.
532
+
533
+ All candidates must share one ``(repo_name, target_branch)`` pair — the
534
+ caller groups them (``coord merge`` already processes the queue in exactly
535
+ those groups). A mixed list is refused rather than silently validating a
536
+ composite that means nothing.
537
+
538
+ *runner* (testing seam) replaces the ``build``/``test`` command execution:
539
+ ``runner(command: str, cwd: Path) -> subprocess.CompletedProcess``-alike
540
+ with ``returncode`` and ``stdout``/``stderr``. Defaults to a real
541
+ ``subprocess.run(shell=True)``.
542
+
543
+ *worktree_slug* (#1715) namespaces the throwaway worktree, so a per-entry
544
+ fallback run does not delete the failed composite's kept-for-inspection
545
+ tree.
546
+ """
547
+ echo = echo or _Echo()
548
+ if not candidates:
549
+ return RevalidationResult(ok=True, reason="no revalidation candidates")
550
+
551
+ repos = {c.entry.repo_name for c in candidates}
552
+ targets = {c.entry.target_branch for c in candidates}
553
+ if len(repos) != 1 or len(targets) != 1:
554
+ return RevalidationResult(
555
+ ok=False,
556
+ kind=KIND_SETUP,
557
+ reason=(
558
+ "revalidation candidates span more than one "
559
+ f"(repo, target_branch): repos={sorted(repos)} "
560
+ f"targets={sorted(targets)} — refusing to validate a "
561
+ "composite that spans bases"
562
+ ),
563
+ )
564
+
565
+ # Every candidate must name the row whose verdict we would re-record,
566
+ # checked BEFORE the suite runs. Discovering this afterwards used to abort
567
+ # mid-write, having already recorded a fresh verdict for the candidates
568
+ # ahead of the bad one — a partial write that contradicts the "on any
569
+ # failure, no verdict is written at all" contract three paragraphs up.
570
+ # Failing here also saves the operator a ~7-minute suite run that could
571
+ # never have been banked.
572
+ for c in candidates:
573
+ if not c.work_assignment_id:
574
+ return RevalidationResult(
575
+ ok=False,
576
+ kind=KIND_SETUP,
577
+ reason=(
578
+ f"{c.entry.repo_name} #{c.entry.issue_number}: the stale "
579
+ "verdict names no work assignment, so no fresh verdict "
580
+ "can be recorded — entry stays blocked"
581
+ ),
582
+ )
583
+ repo_name = repos.pop()
584
+ target_branch = targets.pop()
585
+
586
+ repo_cfg = config.repo(repo_name) if config is not None else None
587
+ if repo_cfg is None:
588
+ return RevalidationResult(
589
+ ok=False, kind=KIND_SETUP,
590
+ reason=f"no repo config for {repo_name!r}",
591
+ )
592
+ test_command = repo_cfg.test_command
593
+ if not test_command:
594
+ # Refusing here is the safe direction: with nothing to run, "passed"
595
+ # would be a claim about a suite that never executed — the exact lie
596
+ # #1738's escalation wording goes out of its way not to invite.
597
+ return RevalidationResult(
598
+ ok=False,
599
+ kind=KIND_SETUP,
600
+ reason=(
601
+ f"no test_command configured for {repo_name!r} — cannot "
602
+ "revalidate (recording a verdict for a suite that never ran "
603
+ "is never correct)"
604
+ ),
605
+ )
606
+
607
+ repo_dir = local_repo_dir(config, repo_name)
608
+ if repo_dir is None or not repo_dir.exists():
609
+ return RevalidationResult(
610
+ ok=False,
611
+ kind=KIND_SETUP,
612
+ reason=(
613
+ f"no local checkout for {repo_name!r} on this machine "
614
+ f"({repo_dir or 'no repo_path configured'}) — revalidation "
615
+ "runs the suite locally, so it must run where the repo lives "
616
+ "(the daemon host, via `coord merge --revalidate`)"
617
+ ),
618
+ )
619
+
620
+ branches = [c.entry.branch for c in candidates]
621
+ echo(
622
+ f" --revalidate: composing {len(branches)} branch(es) onto "
623
+ f"origin/{target_branch} and running the suite once (#1715 option 3)"
624
+ )
625
+
626
+ wt_path = revalidation_worktree_path(repo_name, target_branch, worktree_slug)
627
+ _remove_worktree(repo_dir, wt_path)
628
+ wt_path.parent.mkdir(parents=True, exist_ok=True)
629
+
630
+ try:
631
+ fetched = _run(["git", "fetch", "origin", "--prune"], cwd=repo_dir)
632
+ except (subprocess.SubprocessError, OSError) as e:
633
+ return RevalidationResult(
634
+ ok=False, kind=KIND_SETUP, reason=f"git fetch failed: {e}",
635
+ )
636
+ if fetched.returncode != 0:
637
+ return RevalidationResult(
638
+ ok=False, kind=KIND_SETUP,
639
+ reason=f"git fetch failed: {fetched.stderr.strip()}",
640
+ )
641
+
642
+ added = _run(
643
+ ["git", "worktree", "add", "--force", "--detach",
644
+ str(wt_path), f"origin/{target_branch}"],
645
+ cwd=repo_dir,
646
+ )
647
+ if added.returncode != 0:
648
+ return RevalidationResult(
649
+ ok=False,
650
+ kind=KIND_SETUP,
651
+ reason=(
652
+ f"could not create the revalidation worktree at "
653
+ f"origin/{target_branch}: {added.stderr.strip()}"
654
+ ),
655
+ )
656
+
657
+ composed: list[str] = []
658
+ # `git merge` (not rebase) with an explicit commit: we only need a tree
659
+ # that contains every candidate's content on top of the current base.
660
+ # Nothing here is ever pushed — the worktree is thrown away below and
661
+ # `coord merge` still does the real merge through `gh` afterwards.
662
+ for branch in branches:
663
+ merged = _run(
664
+ ["git", "merge", "--no-ff", "--no-edit", f"origin/{branch}"],
665
+ cwd=wt_path,
666
+ )
667
+ if merged.returncode != 0:
668
+ _run(["git", "merge", "--abort"], cwd=wt_path)
669
+ return RevalidationResult(
670
+ ok=False,
671
+ kind=KIND_COMPOSE,
672
+ reason=(
673
+ f"branch {branch!r} does not compose onto "
674
+ f"origin/{target_branch} (conflict) — resolve the "
675
+ "conflict before revalidating"
676
+ ),
677
+ output=_tail(merged.stdout + "\n" + merged.stderr),
678
+ composed=list(composed),
679
+ worktree=wt_path,
680
+ )
681
+ composed.append(branch)
682
+ echo(f" composed {branch}")
683
+
684
+ run_cmd = runner or _shell_runner
685
+ build_command = getattr(repo_cfg, "build_command", None)
686
+ if build_command:
687
+ echo(f" running build: {build_command}")
688
+ try:
689
+ built = run_cmd(build_command, wt_path, timeout)
690
+ except subprocess.TimeoutExpired:
691
+ return RevalidationResult(
692
+ ok=False,
693
+ kind=KIND_TIMEOUT,
694
+ reason=f"revalidation build timed out after {timeout}s",
695
+ composed=list(composed),
696
+ worktree=wt_path,
697
+ )
698
+ if built.returncode != 0:
699
+ build_output = (built.stdout or "") + "\n" + (built.stderr or "")
700
+ infra = is_infrastructure_failure(built.returncode, build_output)
701
+ return RevalidationResult(
702
+ ok=False,
703
+ kind=KIND_INFRA if infra else KIND_BUILD,
704
+ reason=(
705
+ _infra_reason("build", built.returncode)
706
+ if infra
707
+ else (
708
+ "revalidation BUILD FAILED against the current base "
709
+ f"(exit {built.returncode}) — every candidate stays "
710
+ "blocked"
711
+ )
712
+ ),
713
+ output=_tail(build_output),
714
+ composed=list(composed),
715
+ worktree=wt_path,
716
+ )
717
+
718
+ echo(f" running tests: {test_command}")
719
+ try:
720
+ tested = run_cmd(test_command, wt_path, timeout)
721
+ except subprocess.TimeoutExpired:
722
+ return RevalidationResult(
723
+ ok=False,
724
+ kind=KIND_TIMEOUT,
725
+ reason=f"revalidation suite timed out after {timeout}s",
726
+ composed=list(composed),
727
+ worktree=wt_path,
728
+ )
729
+ if tested.returncode != 0:
730
+ test_output = (tested.stdout or "") + "\n" + (tested.stderr or "")
731
+ infra = is_infrastructure_failure(tested.returncode, test_output)
732
+ return RevalidationResult(
733
+ ok=False,
734
+ kind=KIND_INFRA if infra else KIND_SUITE,
735
+ reason=(
736
+ _infra_reason("suite", tested.returncode)
737
+ if infra
738
+ else (
739
+ "revalidation SUITE FAILED against the current base "
740
+ f"(exit {tested.returncode}) — every candidate stays "
741
+ "blocked, nothing merged"
742
+ )
743
+ ),
744
+ output=_tail(test_output),
745
+ composed=list(composed),
746
+ worktree=wt_path,
747
+ )
748
+
749
+ # ── Suite green: record the fresh verdicts ──────────────────────────────
750
+ from coord.state import record_test_staleness_anchor, record_test_verdict
751
+
752
+ # The commits this run ACTUALLY validated, read from the local refs the
753
+ # worktree was built from — not re-discovered from GitHub afterwards. See
754
+ # `record_test_staleness_anchor`'s docstring for why that distinction is
755
+ # load-bearing rather than an optimisation.
756
+ validated_base_sha = _rev_parse(repo_dir, f"origin/{target_branch}")
757
+
758
+ recorded: list[str] = []
759
+ composite_note = (
760
+ "revalidated by `coord merge --revalidate` — composite of "
761
+ + ", ".join(composed)
762
+ + f" onto origin/{target_branch}"
763
+ )
764
+ for c in candidates:
765
+ # Non-empty for every candidate: checked up front, before the suite
766
+ # ran, precisely so this loop cannot abort part-way through having
767
+ # already written some of the verdicts.
768
+ aid = c.work_assignment_id
769
+ record_test_verdict(assignment_id=aid, test_state="passed")
770
+ record_test_staleness_anchor(
771
+ assignment_id=aid,
772
+ test_head_sha=_rev_parse(repo_dir, f"origin/{c.entry.branch}"),
773
+ test_base_sha=validated_base_sha,
774
+ # #1475's patch-id is GitHub's compare diff hashed — reproducing it
775
+ # from a local `git diff` is not guaranteed byte-identical, and a
776
+ # WRONG patch-id would read as "content unchanged" for content that
777
+ # did change. NULL is the fail-closed value the gate already
778
+ # understands ("cannot confirm identical content"), so a branch that
779
+ # moves after this run re-blocks on SHA alone, exactly as a
780
+ # pre-#1475 row does.
781
+ test_patch_id=None,
782
+ )
783
+ recorded.append(aid)
784
+ echo(
785
+ f" recorded fresh Test verdict for {c.entry.repo_name} "
786
+ f"#{c.entry.issue_number} ({aid})"
787
+ )
788
+
789
+ _remove_worktree(repo_dir, wt_path)
790
+ return RevalidationResult(
791
+ ok=True,
792
+ reason=composite_note,
793
+ composed=composed,
794
+ recorded=recorded,
795
+ worktree=None,
796
+ )
797
+
798
+
799
+ def _infra_reason(stage: str, returncode: int) -> str:
800
+ """Operator-facing wording for a run that never happened (#1814).
801
+
802
+ Every clause here is load-bearing. It must not contain the words "SUITE
803
+ FAILED" (the operator would go and debug a branch that is fine), it must
804
+ say out loud that the branch is unjudged rather than bad, and it must name
805
+ the environment as the thing to fix — because the failure that motivated
806
+ it (``cargo: command not found`` inside the ``coord-serve`` systemd user
807
+ unit) reads like a branch problem and is not one.
808
+
809
+ ``returncode`` alone does not always mean "exited immediately without
810
+ running anything" — :func:`is_infrastructure_failure` can also classify
811
+ on an :data:`INFRA_OUTPUT_MARKERS` hit at a returncode that isn't
812
+ :data:`SHELL_NOT_FOUND_EXIT` (e.g. the runner's own ``RESULT: INFRA``
813
+ line at exit 3, or a wrapped/nonstandard exit). The wording branches on
814
+ that so it never overclaims "without running anything" for a run whose
815
+ own output says it merely couldn't complete.
816
+ """
817
+ if returncode == SHELL_NOT_FOUND_EXIT:
818
+ run_desc = f"the {stage} command exited {returncode} without running anything"
819
+ else:
820
+ run_desc = (
821
+ f"the {stage} command's own output reported it could not run "
822
+ f"(exit {returncode})"
823
+ )
824
+ return (
825
+ f"revalidation COULD NOT RUN — {run_desc} (missing toolchain / broken "
826
+ "runner environment, NOT a test failure). This says nothing about the "
827
+ "branches: they keep their existing verdicts and stay blocked, and "
828
+ "nothing merged. Fix the runner environment and re-run — a systemd "
829
+ "user unit's PATH is not a login shell's (see #1814)"
830
+ )
831
+
832
+
833
+ def _label(candidate: RevalidationCandidate) -> str:
834
+ e = candidate.entry
835
+ return f"{e.repo_name} #{e.issue_number} ({e.branch})"
836
+
837
+
838
+ def revalidate_group(
839
+ candidates: list[RevalidationCandidate],
840
+ config,
841
+ *,
842
+ echo=None,
843
+ timeout: int = DEFAULT_TIMEOUT_SECONDS,
844
+ runner=None,
845
+ ) -> BatchRevalidationResult:
846
+ """Revalidate one ``(repo, target_branch)`` group: composite, then narrow.
847
+
848
+ This is the #1715 entry point and the one ``coord merge --revalidate``
849
+ calls. It is a thin policy layer over :func:`revalidate`:
850
+
851
+ 1. **Compose all N and run the suite once.** Green — which is the
852
+ overwhelmingly common case, since every candidate already holds a
853
+ ``passed`` verdict from an earlier base — and the group is done at a
854
+ cost of exactly **one** suite run, however large N was. That single
855
+ number is the whole feature.
856
+
857
+ 2. **Red composite: merge nothing, fail nothing, narrow.** No verdict was
858
+ written (:func:`revalidate` is all-or-nothing), so no entry can merge
859
+ off the back of it, and no entry is marked failed either — a composite
860
+ failure is evidence about the *set*, not a verdict on any member. Each
861
+ candidate is then re-run **alone** against the current base. A branch
862
+ that passes solo earns a real verdict and merges; a branch that fails
863
+ solo is the culprit and stays blocked with its own failure quoted.
864
+
865
+ N = 1 never falls back: the "composite" already *was* that single branch,
866
+ so a second run would be the identical run twice. That keeps this path
867
+ byte-identical to #1769's shipped single-entry behaviour.
868
+
869
+ A composite that failed for a **common-mode** reason (no ``test_command``,
870
+ no local checkout, a dead ``git fetch``, candidates spanning two bases, or
871
+ a missing toolchain — #1814's :data:`KIND_INFRA`) never falls back either
872
+ — see :data:`NARROWABLE_KINDS`. Every solo run would hit the same wall, so
873
+ narrowing would turn one clear error into N identical ones. For the INFRA
874
+ case that matters twice over: N solo runs would each print "could not
875
+ run", making a broken daemon environment look like N broken branches.
876
+
877
+ Worst case is therefore 1 + N runs, the bound #1715 specifies, and it is
878
+ reached only when a composite genuinely fails on a real build/test/merge
879
+ problem.
880
+ """
881
+ echo = echo or _Echo()
882
+ if not candidates:
883
+ return BatchRevalidationResult(
884
+ composite=RevalidationResult(
885
+ ok=True, reason="no revalidation candidates",
886
+ ),
887
+ )
888
+
889
+ composite = revalidate(
890
+ candidates, config, echo=echo, timeout=timeout, runner=runner,
891
+ )
892
+ # A setup refusal never reached a build/test command, so it did not cost a
893
+ # suite run; an INFRA failure reached it but the suite still never
894
+ # executed (#1814). Everything else did (or died trying), and the
895
+ # operator's mental model of "how many suites did that just run" should
896
+ # match.
897
+ ran = 0 if composite.kind in NO_SUITE_RAN_KINDS else 1
898
+ batch = BatchRevalidationResult(
899
+ composite=composite,
900
+ recorded=list(composite.recorded),
901
+ suite_runs=ran,
902
+ )
903
+
904
+ if composite.ok or len(candidates) == 1 or not composite.narrowable:
905
+ return batch
906
+
907
+ echo(
908
+ f" --revalidate: the composite of {len(candidates)} branches FAILED — "
909
+ "nothing merges on that result. Re-running each branch on its own "
910
+ "against the current base to find the culprit (#1715); branches that "
911
+ "pass alone still merge."
912
+ )
913
+
914
+ for c in candidates:
915
+ label = _label(c)
916
+ echo(f" --revalidate: re-testing {label} alone")
917
+ solo = revalidate(
918
+ [c], config, echo=echo, timeout=timeout, runner=runner,
919
+ # Its own worktree: the failed composite's tree was just advertised
920
+ # as "kept for inspection", and reusing the path would delete it.
921
+ worktree_slug=c.work_assignment_id or c.entry.branch,
922
+ )
923
+ if solo.kind != KIND_SETUP:
924
+ batch.suite_runs += 1
925
+ batch.per_entry.append((label, solo))
926
+ if solo.ok:
927
+ batch.recorded.extend(solo.recorded)
928
+ echo(f" --revalidate: {label} PASSES alone — cleared to merge")
929
+ else:
930
+ batch.culprits.append(label)
931
+ echo(f" --revalidate: {label} FAILS alone — {solo.reason}")
932
+
933
+ if not batch.culprits:
934
+ # Every branch is green by itself, yet together they are not. That is a
935
+ # genuine cross-branch interaction (two branches that each compile
936
+ # against the old base but not against each other), and it is the one
937
+ # case where the per-entry pass is *less* conservative than the
938
+ # composite it replaced. Say so out loud rather than letting a clean
939
+ # per-entry sweep quietly imply the composite was a fluke.
940
+ echo(
941
+ " --revalidate: WARNING — every branch passes alone but the "
942
+ "composite of all of them failed. That points at an interaction "
943
+ "between these branches rather than at any one of them; they are "
944
+ "merging on their solo verdicts. Re-run the suite on the base "
945
+ "afterwards."
946
+ )
947
+
948
+ return batch
949
+
950
+
951
+ def format_batch(batch: BatchRevalidationResult) -> list[str]:
952
+ """Operator-facing OUTCOME lines for one group's revalidation (#1715).
953
+
954
+ Deliberately excludes the composite's own failure report — that is
955
+ :func:`format_failure`'s job and belongs on stderr, whereas "…and here is
956
+ what merged anyway" is ordinary stdout. Keeping them separate is why the
957
+ caller does not have to route a "PASSED alone — merging" line to stderr
958
+ just because the composite that preceded it was red.
959
+
960
+ A per-entry (solo) failure is different: it names the actual culprit, and
961
+ the worktree :func:`revalidate` kept for it (per ``worktree_slug``) is the
962
+ one an operator would actually inspect — the composite's own kept
963
+ worktree is a different tree entirely. #1715-review: that pointer used to
964
+ be silently dropped here, even though :func:`format_failure` already knew
965
+ how to print it. Reuse it (skipping its leading reason line, which
966
+ :func:`format_batch` already renders with the branch label attached).
967
+ """
968
+ lines: list[str] = []
969
+ if batch.composite.ok:
970
+ lines.append(f" --revalidate: PASSED — {batch.composite.reason}")
971
+ return lines
972
+
973
+ if batch.composite.kind == KIND_INFRA:
974
+ # #1814: the one failure mode that is not about the branches at all.
975
+ # Say so on stdout too — the reason line goes to stderr, and an
976
+ # operator skimming the merge summary must not be left with a red
977
+ # composite and no explanation that it judged nothing.
978
+ lines.append(
979
+ " --revalidate: INFRASTRUCTURE FAILURE — the suite could not "
980
+ "run, so no branch was judged. Nothing merged, nothing marked "
981
+ "failed, no verdict changed; every candidate is exactly as it "
982
+ "was. Fix the runner environment, then re-run --revalidate."
983
+ )
984
+ return lines
985
+
986
+ if not batch.fell_back:
987
+ return lines
988
+
989
+ for label, solo in batch.per_entry:
990
+ if solo.ok:
991
+ lines.append(f" --revalidate: {label}: PASSED alone — merging")
992
+ else:
993
+ lines.append(f" --revalidate: {label}: BLOCKED — {solo.reason}")
994
+ lines.extend(format_failure(solo)[1:])
995
+ if batch.culprits:
996
+ lines.append(
997
+ " --revalidate: culprit(s): " + ", ".join(batch.culprits)
998
+ )
999
+ lines.append(f" --revalidate: {batch.suite_runs} suite run(s) total")
1000
+ return lines
1001
+
1002
+
1003
+ # #1924: every guard var `serve_app.py` sets on *itself* to keep a daemon
1004
+ # command handler from re-routing its own request back to the daemon (see
1005
+ # ``daemon_reroute_target()`` in board_service.py and its call sites in
1006
+ # commands/merge.py, commands/status.py, commands/acceptance.py,
1007
+ # commands/gates.py, commands/lifecycle.py). These are process-global — set
1008
+ # with a plain ``os.environ[...] = "1"`` around the handler body, not scoped
1009
+ # to the request — so when `coord merge --revalidate` is invoked from a thin
1010
+ # client, routed to the daemon, and its composed-suite subprocess inherits
1011
+ # the parent's environment by default, the suite sees whichever of these
1012
+ # happened to be set on `coord serve`'s own process at the time (in
1013
+ # particular `COORD_MERGE_ON_DAEMON`, set for the very request that is
1014
+ # running this revalidation). The suite is supposed to behave exactly like a
1015
+ # clean checkout's test run; a leaked guard var makes tests that assert on
1016
+ # these vars fail regardless of what the branch under test contains. Kept as
1017
+ # an explicit tuple (not a dynamic "*_ON_DAEMON" glob over os.environ) so
1018
+ # adding a new guard var in serve_app.py is a visible, deliberate edit here
1019
+ # too, rather than something that's silently swept up or silently missed.
1020
+ _DAEMON_GUARD_ENV_VARS = (
1021
+ "COORD_MERGE_ON_DAEMON",
1022
+ "COORD_RECONCILE_ON_DAEMON",
1023
+ "COORD_DIAGNOSE_ON_DAEMON",
1024
+ "COORD_GATES_ON_DAEMON",
1025
+ "COORD_TEST_PLAN_ON_DAEMON",
1026
+ "COORD_HOUSEKEEPING_ON_DAEMON",
1027
+ "COORD_NOTIFY_ON_DAEMON",
1028
+ "COORD_ACCEPTANCE_ON_DAEMON",
1029
+ )
1030
+
1031
+
1032
+ def _suite_subprocess_env() -> dict[str, str]:
1033
+ """``os.environ`` minus the daemon-internal routing guards (#1924).
1034
+
1035
+ The composed-suite subprocess should look like a clean checkout's test
1036
+ run irrespective of whether the parent process invoking it happens to be
1037
+ a bare shell or `coord serve` mid-request. See ``_DAEMON_GUARD_ENV_VARS``.
1038
+ """
1039
+ return {
1040
+ k: v for k, v in os.environ.items() if k not in _DAEMON_GUARD_ENV_VARS
1041
+ }
1042
+
1043
+
1044
+ def _shell_runner(command: str, cwd: Path, timeout: int):
1045
+ """Run *command* through the shell in *cwd*, capturing output.
1046
+
1047
+ Same shape as ``coord test``'s build/test step (``subprocess.run(cmd,
1048
+ shell=True, cwd=worktree)``) — the repo's own configured command, run in
1049
+ the composite worktree, inheriting the environment — MINUS the
1050
+ daemon-internal routing guards (#1924), which must never leak into a
1051
+ subprocess that is supposed to behave like a clean checkout.
1052
+ """
1053
+ return subprocess.run(
1054
+ command, shell=True, cwd=str(cwd), capture_output=True, text=True,
1055
+ timeout=timeout, env=_suite_subprocess_env(),
1056
+ )
1057
+
1058
+
1059
+ def format_failure(result: RevalidationResult) -> list[str]:
1060
+ """Operator-facing lines for a failed revalidation (blocked, not merged)."""
1061
+ lines = [f" --revalidate: {result.reason}"]
1062
+ if result.output:
1063
+ lines.append(" --revalidate: output tail:")
1064
+ lines.extend(
1065
+ " " + ln for ln in result.output.splitlines()
1066
+ )
1067
+ if result.worktree is not None:
1068
+ lines.append(
1069
+ f" --revalidate: worktree kept for inspection: {result.worktree}"
1070
+ )
1071
+ return lines
1072
+
1073
+
1074
+ __all__ = [
1075
+ "DEFAULT_TIMEOUT_SECONDS",
1076
+ "KIND_BUILD",
1077
+ "KIND_COMPOSE",
1078
+ "KIND_INFRA",
1079
+ "KIND_OK",
1080
+ "KIND_SETUP",
1081
+ "KIND_SUITE",
1082
+ "KIND_TIMEOUT",
1083
+ "MAX_REVALIDATION_BATCH",
1084
+ "NARROWABLE_KINDS",
1085
+ "NO_SUITE_RAN_KINDS",
1086
+ "RUNNER_INFRA_EXIT",
1087
+ "SHELL_NOT_FOUND_EXIT",
1088
+ "BatchRevalidationResult",
1089
+ "RevalidationResult",
1090
+ "client_timeout_seconds",
1091
+ "describe_batches",
1092
+ "describe_candidates",
1093
+ "format_batch",
1094
+ "format_failure",
1095
+ "group_candidates",
1096
+ "is_infrastructure_failure",
1097
+ "local_repo_dir",
1098
+ "revalidate",
1099
+ "revalidate_group",
1100
+ "revalidation_worktree_path",
1101
+ ]