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/ci_store.py ADDED
@@ -0,0 +1,560 @@
1
+ """CiStore abstraction over CI check status.
2
+
3
+ Phase 1 (#240) of the CiStore abstraction: a thin Protocol over ``gh pr checks``
4
+ so the merge gate can hard-block on failed checks and the TUI can surface what
5
+ broke. Rerun, polling, and non-GitHub backends are deferred to later phases.
6
+
7
+ Phase 2 (#1851) adds the rerun half: :meth:`CiStore.rerun_for_pr` and the
8
+ :func:`checks_are_stale` predicate. A **green** check can itself be stale —
9
+ GitHub re-runs ``pull_request`` workflows on head ``synchronize``, never on
10
+ base movement, so a passing check proves the composite passed against the
11
+ base *as of the last head push*, not as of now. Polling and non-GitHub
12
+ backends remain deferred.
13
+
14
+ Phase 3 (#1892) adds a **RED** check's own analogue of the same question:
15
+ did this failure say anything about the code at all? :meth:`CiStore.
16
+ list_jobs_for_run` and :func:`is_verdictless_job` distinguish "never assigned
17
+ a runner" / "died before checkout" (a statement about the CI *platform*) from
18
+ a genuine test failure (a statement about the *code*) — used exclusively by
19
+ :mod:`coord.merge_queue`'s drive-retry accounting, never by the merge gate
20
+ itself (:func:`failed_checks` and ``_PASSING_CONCLUSIONS`` are unchanged: a
21
+ verdictless check still blocks the merge, it just doesn't cost a retry).
22
+
23
+ The split between :class:`CiStore` (Protocol) and the concrete backends
24
+ (:class:`coord.ci_github.GitHubCi`, :class:`NoOpCi`) means tests can pass a
25
+ stub through ``ci_store=`` without touching subprocess at all.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import time
31
+ from dataclasses import dataclass, field
32
+ from typing import Protocol, runtime_checkable
33
+
34
+
35
+ @dataclass
36
+ class CiCheckSummary:
37
+ """Structured rollup of a PR's CI checks — the board-wire analogue of the
38
+ TUI's Rust ``CiCheckSummary`` (``tui/src/app/types.rs``).
39
+
40
+ Populated server-side by :func:`summarize_counts` and attached to
41
+ :class:`coord.merge_queue.PlannedMerge` so the TUI can render its "2✓ 1✗"
42
+ badges straight from the ``/board`` payload instead of shelling out to
43
+ ``gh pr checks`` itself (#1344).
44
+ """
45
+
46
+ passed: int
47
+ failed: int
48
+ running: int
49
+ failed_names: list[str]
50
+ first_failed_url: str | None
51
+
52
+
53
+ @dataclass
54
+ class JobStep:
55
+ """One step of a GitHub Actions job (#1892).
56
+
57
+ ``conclusion`` mirrors :class:`CheckRun`'s field: ``None`` while the step
58
+ hasn't finished, otherwise success/failure/cancelled/skipped/... — the
59
+ same vocabulary GitHub uses for check-run conclusions, one level down.
60
+ """
61
+
62
+ name: str
63
+ conclusion: str | None
64
+
65
+
66
+ @dataclass
67
+ class JobRun:
68
+ """One job of a GitHub Actions run — the step-level detail a
69
+ :class:`CheckRun` doesn't carry (#1892).
70
+
71
+ Populated only on the CI-failure classification path (see
72
+ :func:`is_verdictless_job`): a :class:`CheckRun` names a *check*
73
+ (workflow name, e.g. ``test (3.12)``), and this is the matching *job*
74
+ (same name, fetched via ``gh api repos/{repo}/actions/runs/{id}/jobs``)
75
+ with its steps. ``runner_name`` is empty when GitHub never assigned this
76
+ job a runner at all — the "cancelled at the queue timeout" signature.
77
+ """
78
+
79
+ name: str
80
+ conclusion: str | None
81
+ runner_name: str
82
+ steps: list[JobStep] = field(default_factory=list)
83
+
84
+
85
+ @dataclass
86
+ class CheckRun:
87
+ """A single CI check run on a PR.
88
+
89
+ ``status`` is the lifecycle phase: queued / in_progress / completed.
90
+ ``conclusion`` is only meaningful when ``status == "completed"`` and is
91
+ normally one of success / failure / cancelled / skipped / neutral /
92
+ timed_out / action_required / stale — but GitHub can and does add new
93
+ conclusions over time, and :class:`coord.ci_github.GitHubCi` synthesizes
94
+ the conclusion ``"unknown"`` when it couldn't read a PR's checks at all
95
+ (#1525). ``failed_checks`` below is an **allow-list**: a completed check
96
+ passes only when its conclusion is affirmatively known-benign
97
+ (``success`` / ``skipped`` / ``neutral``); anything else — including a
98
+ conclusion this module has never seen — blocks the merge gate. ``status
99
+ != "completed"`` is in-flight, handled separately by
100
+ :func:`in_flight_checks`.
101
+ """
102
+
103
+ name: str
104
+ status: str
105
+ conclusion: str | None
106
+ url: str
107
+ run_id: str
108
+ started_at: float | None
109
+ completed_at: float | None
110
+
111
+
112
+ @runtime_checkable
113
+ class CiStore(Protocol):
114
+ """View of CI checks for a PR, plus (#1851) the one write operation this
115
+ abstraction supports: re-running them.
116
+
117
+ ``rerun_for_pr`` is deliberately the *only* mutating method — everything
118
+ else stays read-only exactly as Phase 1 (#240) left it, so every existing
119
+ stub-based test (a plain object/dataclass implementing
120
+ ``list_checks_for_pr``/``is_available`` with no ``rerun_for_pr`` at all)
121
+ keeps passing unmodified: nothing here reads ``rerun_for_pr`` off a
122
+ ``CiStore`` except the #1851 revalidate path, which only ever runs behind
123
+ the ``coord merge --revalidate`` flag.
124
+ """
125
+
126
+ def list_checks_for_pr(self, repo: str, number: int) -> list[CheckRun]: ...
127
+
128
+ @property
129
+ def is_available(self) -> bool: ...
130
+
131
+ def expects_checks(self, repo: str, number: int) -> bool:
132
+ """True when *repo*/*number* should have reported at least one check.
133
+
134
+ #1904: ``checks == []`` is genuinely ambiguous — "no CI is
135
+ configured for this repo" (merging is correct) and "CI exists but
136
+ was never triggered" (a throttled webhook, a wedged run, a
137
+ path-filtered-out workflow — merging is wrong) both produce it, and
138
+ every gate predicate (:func:`failed_checks`, :func:`in_flight_checks`,
139
+ :func:`checks_are_stale`) is a filter that reads an empty list as
140
+ "nothing wrong". This is the one method that answers "which of the
141
+ two is this" *without* looking at any particular PR's checks — it
142
+ asks whether the backend believes CI exists for this repo at all.
143
+ Callers (``coord.merge_queue``'s ``checks_absent`` gate) only
144
+ consult this when ``list_checks_for_pr`` has already come back
145
+ empty; a non-empty check list settles the question on its own.
146
+
147
+ :class:`NoOpCi` answers ``False`` unconditionally — it is the
148
+ supported "this repo has no CI" opt-out (``ci_store: {type:
149
+ none}``), so nothing it reports should ever read as "checks
150
+ absent". A backend that can't determine this at all should default
151
+ to ``True`` (fail closed, mirroring #1525's "unknown reads as
152
+ blocking" posture) rather than silently reopening the hole this
153
+ method exists to close.
154
+ """
155
+ ...
156
+
157
+ def rerun_for_pr(self, repo: str, number: int) -> bool:
158
+ """Re-run *repo*#*number*'s CI workflows. Returns whether it worked.
159
+
160
+ Cheap remedy for a CI result staled by base movement (#1851): a CI
161
+ re-run on GitHub-hosted runners costs minutes, not a routed Test-stage
162
+ agent dispatch. Never called unattended — see
163
+ :mod:`coord.revalidate`'s module docstring and
164
+ ``docs/DRIVE_QUEUE.md`` for why ``--revalidate`` is opt-in and
165
+ auto-drain must never trigger work on its own schedule.
166
+
167
+ #1892: this same method is ALSO the auto-rerun remedy for a
168
+ verdictless CI failure — see :mod:`coord.merge_queue`'s
169
+ ``_ci_infra_reason``/``MAX_CI_INFRA_RERUNS``. That call site runs
170
+ unattended (unlike ``--revalidate``), which is safe specifically
171
+ *because* the trigger is narrow (every failing check carries no
172
+ verdict about the code) and bounded (capped, then parked for a
173
+ human) — it is not a general license for auto-drain to rerun CI.
174
+ """
175
+ ...
176
+
177
+ def list_jobs_for_run(self, repo: str, run_id: str) -> list[JobRun]:
178
+ """Job/step detail for Actions run *run_id* on *repo* (#1892).
179
+
180
+ The one piece of data :class:`CheckRun` doesn't carry and the CI
181
+ gate (``failed_checks``/``_PASSING_CONCLUSIONS``) never needed: which
182
+ step (if any) actually ran before a check failed. Exists solely to
183
+ back :func:`is_verdictless_job` — the drive's retry-accounting
184
+ question "did this failure say anything about the code?", never the
185
+ merge gate itself.
186
+
187
+ Callers MUST only invoke this after a check has already been found
188
+ failing (:func:`failed_checks` non-empty) — never on the passing or
189
+ pending path, and never from a request-time board read (see
190
+ ``coord.gate_snapshot``'s Invariant 1: the read path performs no
191
+ third-party I/O). Best-effort: a backend that can't answer this
192
+ returns ``[]``, which :func:`is_verdictless_job` always reads as "no
193
+ job data, therefore not verdictless" — the same false-negative bias
194
+ as an unmatched job (see that function's docstring).
195
+ """
196
+ ...
197
+
198
+
199
+ class NoOpCi:
200
+ """Always-available fallback that returns no checks and reruns nothing.
201
+
202
+ Used when the user opts out of CI gating with ``ci_store: { type: none }``
203
+ or when no backend is configured. ``is_available`` is ``False`` so callers
204
+ can distinguish "no CI configured" from "CI says all clear".
205
+ """
206
+
207
+ def list_checks_for_pr(self, repo: str, number: int) -> list[CheckRun]:
208
+ return []
209
+
210
+ @property
211
+ def is_available(self) -> bool:
212
+ return False
213
+
214
+ def expects_checks(self, repo: str, number: int) -> bool:
215
+ """Always ``False`` — CI gating is opted out entirely (#1904), so an
216
+ empty check list is never "checks absent", it's "no CI here"."""
217
+ return False
218
+
219
+ def rerun_for_pr(self, repo: str, number: int) -> bool:
220
+ """No-op: CI gating is disabled entirely, so there is nothing to
221
+ re-run and nothing to report as stale (#1851)."""
222
+ return False
223
+
224
+ def list_jobs_for_run(self, repo: str, run_id: str) -> list[JobRun]:
225
+ """No-op: CI gating is disabled entirely, so there is no job/step
226
+ detail to fetch (#1892)."""
227
+ return []
228
+
229
+
230
+ # ── Classification helpers ──────────────────────────────────────────────────
231
+
232
+ # #1525: allow-list of conclusions known to be benign, not a deny-list of
233
+ # conclusions known to be bad. Before this, `_FAILED_CONCLUSIONS` enumerated
234
+ # {"failure", "cancelled", "timed_out", "action_required"} and anything NOT
235
+ # in that set — a `"stale"` conclusion, a future GitHub conclusion this code
236
+ # had never seen, or the synthetic `"unknown"` conclusion GitHubCi emits when
237
+ # a `gh pr checks` read fails — silently read as "not failing", i.e. passing.
238
+ # That is exactly the fail-open shape that let PR #1521 merge over a red
239
+ # `test (3.12)` run: an unrecognised or unreadable conclusion must default to
240
+ # BLOCKING, never to passing.
241
+ _PASSING_CONCLUSIONS = frozenset({"success", "skipped", "neutral"})
242
+
243
+
244
+ def _is_failing_conclusion(conclusion: str | None) -> bool:
245
+ return conclusion not in _PASSING_CONCLUSIONS
246
+
247
+
248
+ def failed_checks(checks: list[CheckRun]) -> list[CheckRun]:
249
+ """Return completed checks whose conclusion is not affirmatively passing.
250
+
251
+ Only evaluates ``status == "completed"`` checks — an in-flight check has
252
+ ``conclusion is None`` and is handled by :func:`in_flight_checks`
253
+ instead, not counted as failed here.
254
+ """
255
+ return [
256
+ c for c in checks
257
+ if c.status == "completed" and _is_failing_conclusion(c.conclusion)
258
+ ]
259
+
260
+
261
+ def in_flight_checks(checks: list[CheckRun]) -> list[CheckRun]:
262
+ """Return checks that are queued or running (not yet completed)."""
263
+ return [c for c in checks if c.status != "completed"]
264
+
265
+
266
+ # ── Post-rerun settle wait (#1925) ──────────────────────────────────────────
267
+ #
268
+ # `coord merge --revalidate`'s CI arm (`_apply_ci_revalidation` in
269
+ # `coord/commands/merge.py`) triggers `rerun_for_pr` for an entry whose CI
270
+ # checks are green but stale (predate the current base), then used to hand
271
+ # the entry straight to `merge_queue.process()`'s gate a moment later.
272
+ # `rerun_for_pr` invalidates the check cache, so that very next
273
+ # `list_checks_for_pr` read can land in the few-second window before GitHub
274
+ # has created ANY check-run record for the new workflow run: `gh pr checks`
275
+ # itself errors ("no checks reported on the ... branch"), which
276
+ # `coord.ci_github.GitHubCi._fetch` (correctly, per #1525) turns into a
277
+ # synthetic `conclusion="unknown"` check — and the fail-closed allow-list
278
+ # blocks on that exactly as it should for a GENUINE unreadable status. The
279
+ # bug was evaluating the gate a heartbeat after triggering the very re-run
280
+ # that caused this transient, self-inflicted reading — reliably, every time,
281
+ # since a freshly-triggered run cannot possibly have registered yet.
282
+ #
283
+ # `wait_for_ci_settle` closes that window: after triggering, poll (bounded)
284
+ # until the new run has both registered on GitHub AND finished, so the gate
285
+ # evaluates a real result — a genuine pass or a genuine failure — instead of
286
+ # the registration gap. This is what lets a healthy branch merge in the same
287
+ # `--revalidate` invocation instead of needing a human to come back once CI
288
+ # settles.
289
+ CI_RERUN_POLL_INTERVAL_SECONDS = 10.0
290
+
291
+ # ~3 minutes was the observed real-world settle time for the two 2026-08-07
292
+ # reproductions (#1925). This gives roughly a 2x margin without making a
293
+ # healthy `--revalidate` run hang needlessly long once CI is genuinely done.
294
+ CI_RERUN_MAX_WAIT_SECONDS = 360.0
295
+
296
+
297
+ def _is_unreadable_check(check) -> bool:
298
+ """True for the #1525 synthetic "could not read CI status" / "gh too
299
+ old" stand-ins (``coord.ci_github._unreadable_check`` /
300
+ ``_gh_too_old_check``) — the read itself failed, so this says nothing
301
+ about the code yet. Duck-typed on ``.conclusion``/``.name`` rather than
302
+ importing ``coord.ci_github`` — this module must stay backend-agnostic,
303
+ and every stub/fake ``CheckRun``-alike in the test suite already has
304
+ both attributes.
305
+ """
306
+ return (
307
+ getattr(check, "conclusion", None) == "unknown"
308
+ and str(getattr(check, "name", "")).startswith("coord: ")
309
+ )
310
+
311
+
312
+ @dataclass
313
+ class CiSettleResult:
314
+ """Outcome of :func:`wait_for_ci_settle` (#1925).
315
+
316
+ ``settled`` is the only thing callers need to branch on: ``True`` means
317
+ *checks* is a genuine, resolved result (real pass or real fail) safe to
318
+ hand to the merge gate. ``False`` means the wait budget ran out first —
319
+ ``registering`` then distinguishes *why*: ``True`` is the self-inflicted
320
+ "the re-run we just triggered still hasn't registered/resolved" case
321
+ (never a real CI verdict), ``False`` means real checks are in flight and
322
+ have simply not finished yet (an honest, already-correctly-classified
323
+ ``checks_pending``).
324
+ """
325
+
326
+ settled: bool
327
+ checks: list[CheckRun]
328
+ waited_seconds: float
329
+ registering: bool = False
330
+
331
+
332
+ def wait_for_ci_settle(
333
+ ci_store: "CiStore",
334
+ repo: str,
335
+ number: int,
336
+ *,
337
+ timeout: float = CI_RERUN_MAX_WAIT_SECONDS,
338
+ poll_interval: float = CI_RERUN_POLL_INTERVAL_SECONDS,
339
+ echo=None,
340
+ sleep=None,
341
+ clock=None,
342
+ ) -> CiSettleResult:
343
+ """Bounded poll for a just-triggered CI re-run to register and finish.
344
+
345
+ Only ever called right after a successful :meth:`CiStore.rerun_for_pr`
346
+ (#1925) — see this section's header comment for the exact bug this
347
+ closes. Every poll invalidates the backend's cache first (best-effort;
348
+ only :class:`coord.ci_github.GitHubCi` implements ``invalidate``, so a
349
+ stub without it is left alone) so each read is a fresh one, not a cached
350
+ pre-rerun snapshot.
351
+
352
+ A "registering" read — no checks at all yet, or every check present is
353
+ one of the #1525 synthetic unreadable stand-ins (see
354
+ :func:`_is_unreadable_check`) — never counts as settled, however many
355
+ times it's observed; only real, resolved checks do. Genuinely in-flight
356
+ real checks (a run that registered and is now actually executing) also
357
+ keep polling — that's the ordinary "wait for CI to finish" case this
358
+ function exists to cover, not just the registration gap.
359
+
360
+ Returns as soon as *checks* is non-empty, none of it is the synthetic
361
+ unreadable stand-in, and none of it is still in-flight — i.e. a genuine
362
+ resolved result, pass or fail alike; the caller (and ultimately
363
+ ``merge_queue.process()``) decides what that result means. Gives up once
364
+ *timeout* elapses, returning whatever was last observed with
365
+ ``settled=False``.
366
+ """
367
+ echo = echo or (lambda msg: None)
368
+ sleep = sleep if sleep is not None else time.sleep
369
+ clock = clock if clock is not None else time.monotonic
370
+
371
+ start = clock()
372
+ checks: list[CheckRun] = []
373
+ announced = False
374
+ while True:
375
+ invalidate = getattr(ci_store, "invalidate", None)
376
+ if callable(invalidate):
377
+ try:
378
+ invalidate(repo, number)
379
+ except Exception: # noqa: BLE001 — best-effort cache-bust only
380
+ pass
381
+ checks = ci_store.list_checks_for_pr(repo, number)
382
+ registering = not checks or all(_is_unreadable_check(c) for c in checks)
383
+ if not registering and not in_flight_checks(checks):
384
+ return CiSettleResult(
385
+ settled=True, checks=checks, waited_seconds=clock() - start,
386
+ )
387
+ elapsed = clock() - start
388
+ if elapsed >= timeout:
389
+ return CiSettleResult(
390
+ settled=False, checks=checks, waited_seconds=elapsed,
391
+ registering=registering,
392
+ )
393
+ if not announced:
394
+ echo(
395
+ f" --revalidate: waiting for the CI re-run on {repo}#{number} "
396
+ "to register and settle before evaluating the gate (#1925)..."
397
+ )
398
+ announced = True
399
+ sleep(poll_interval)
400
+
401
+
402
+ # #1892: two real signatures — recorded from JDonaghy/claude-coordinator run
403
+ # 31117792472 and JDonaghy/vimcode run 31119463000, both 2026-08-06 — for a
404
+ # CI failure that says nothing about the code:
405
+ #
406
+ # 1. Never assigned a runner: cancelled at the queue timeout, `runner_name`
407
+ # empty, `steps` empty. GitHub does create a job record for this (unlike
408
+ # a run that never even reaches job-scheduling), but with zero steps.
409
+ # 2. Got a runner, died before checkout: exactly one step, named literally
410
+ # "Set up job", with a non-passing conclusion — nothing past it ran, so
411
+ # no repo code executed either.
412
+ #
413
+ # Deliberately narrow — see this module's `_PASSING_CONCLUSIONS` comment for
414
+ # the identical lesson learned the hard way (#1525) about allow-lists vs.
415
+ # catch-alls, and the issue's own hazard note: a classifier that is too eager
416
+ # becomes a way to launder real failures into "infrastructure". Prefer false
417
+ # negatives — a platform failure misread as real costs one manual unblock; a
418
+ # real failure misread as platform noise costs a bad merge. So both
419
+ # `check is None`/`job is None` (no job data — including a fetch failure;
420
+ # see `CiStore.list_jobs_for_run`'s docstring) and any shape that isn't
421
+ # EXACTLY one of the two above read as "carries a verdict", never as
422
+ # verdictless.
423
+ _SET_UP_JOB_STEP_NAME = "Set up job"
424
+
425
+
426
+ def is_verdictless_job(check: CheckRun, job: JobRun | None) -> bool:
427
+ """True when *check* failed for a reason that says nothing about the
428
+ code — see the two signatures documented above (#1892).
429
+
430
+ Only meaningful for a check :func:`failed_checks` already selected
431
+ (``status == "completed"`` and a non-passing conclusion); a check that
432
+ is still in flight, or one this function is asked about with no
433
+ matching *job* record, always reads ``False`` — "carries a verdict",
434
+ the safe default per the false-negative bias above.
435
+
436
+ This is a **narrower** question than the merge gate's own — it does not
437
+ change whether the check counts as failed (:func:`failed_checks` is
438
+ untouched), only whether the failure is evidence about the *code*. Used
439
+ exclusively by the drive's retry accounting (:mod:`coord.merge_queue`'s
440
+ ``_ci_infra_reason``), never by the gate itself.
441
+ """
442
+ if check.status != "completed" or job is None:
443
+ return False
444
+ if check.conclusion == "cancelled":
445
+ return len(job.steps) == 0
446
+ failed_steps = [
447
+ s for s in job.steps if s.conclusion not in (None, "success", "skipped")
448
+ ]
449
+ return len(failed_steps) == 1 and failed_steps[0].name == _SET_UP_JOB_STEP_NAME
450
+
451
+
452
+ def checks_are_stale(checks: list[CheckRun], base_commit_time: float | None) -> bool:
453
+ """True when a **green** *checks* result predates *base_commit_time* (#1851).
454
+
455
+ GitHub attaches ``pull_request`` check runs to the PR's *head* SHA and
456
+ re-runs them on head ``synchronize`` — never on base movement — so a
457
+ check that started before the base's newest commit landed never saw that
458
+ commit. ``started_at`` (not ``completed_at``) is the comparison point:
459
+ what matters is what the base looked like when the run *began*, not when
460
+ it finished.
461
+
462
+ Callers should apply :func:`failed_checks`/:func:`in_flight_checks`
463
+ first — this function assumes *checks* is the all-passing remainder and
464
+ doesn't re-derive that itself, so it never contradicts "CI failed"/"CI
465
+ running" with a third, competing reading of the same checks. An empty
466
+ *checks* list (nothing to compare) reads as not-stale; the caller's own
467
+ "no checks" handling covers that case — see :meth:`CiStore.expects_checks`
468
+ and ``coord.merge_queue``'s ``checks_absent`` gate (#1904), which now
469
+ implements exactly that handling at all three call sites.
470
+
471
+ Fails closed toward **stale** — mirroring
472
+ :func:`coord.merge_queue._base_move_is_inert`'s documented bias ("a false
473
+ 'fresh' merges untested code; a false 'stale' only costs a re-run") —
474
+ whenever the comparison can't be made with confidence: *base_commit_time*
475
+ unreadable/``None``, or any check missing ``started_at``. Clock and
476
+ ordering skew between GitHub's check timestamps and its branch-commit
477
+ timestamps are real; this predicate is not exact, and errs toward the
478
+ cheap re-run rather than the silent stale-green pass.
479
+ """
480
+ if not checks:
481
+ return False
482
+ if base_commit_time is None:
483
+ return True
484
+ return any(c.started_at is None or c.started_at < base_commit_time for c in checks)
485
+
486
+
487
+ def build_ci_store(ci_store_type: str) -> CiStore:
488
+ """Construct the CiStore backend named by ``ci_store_type``.
489
+
490
+ Centralised here so callers (merge gate, TUI fetcher, tests) don't need to
491
+ branch on the config value themselves. Unknown types fall back to NoOpCi
492
+ so a typo in coordinator.yml doesn't crash the merge command.
493
+ """
494
+ if ci_store_type == "github":
495
+ from coord.ci_github import GitHubCi # noqa: PLC0415
496
+ return GitHubCi()
497
+ return NoOpCi()
498
+
499
+
500
+ def summarize(checks: list[CheckRun]) -> str:
501
+ """One-line summary: ``2✓ 1✗`` or ``no checks``.
502
+
503
+ Used by the TUI under the Merge stage row and by the CLI when reporting
504
+ why a merge was refused.
505
+ """
506
+ if not checks:
507
+ return "no checks"
508
+ passed = sum(1 for c in checks if c.conclusion == "success")
509
+ failed = len(failed_checks(checks))
510
+ running = len(in_flight_checks(checks))
511
+ parts: list[str] = []
512
+ if passed:
513
+ parts.append(f"{passed}✓")
514
+ if failed:
515
+ parts.append(f"{failed}✗")
516
+ if running:
517
+ parts.append(f"{running}⋯")
518
+ return " ".join(parts) if parts else "no checks"
519
+
520
+
521
+ def summarize_counts(checks: list[CheckRun]) -> CiCheckSummary:
522
+ """Structured rollup of *checks*, mirroring the classification the TUI's
523
+ (now-deleted) ``fetch_ci_check_summary`` used to compute client-side:
524
+
525
+ - not yet ``completed`` → running
526
+ - completed + conclusion NOT in ``_PASSING_CONCLUSIONS`` → failed (name +
527
+ first URL captured); this is an allow-list (#1525), so an unrecognised
528
+ or synthetic ``"unknown"`` conclusion counts as failed
529
+ - completed + conclusion in ``_PASSING_CONCLUSIONS`` (success / skipped /
530
+ neutral) → passed
531
+
532
+ Used to populate :class:`coord.merge_queue.PlannedMerge.ci_summary` so the
533
+ `/board` payload carries everything the TUI renders as CI badges (#1344).
534
+ """
535
+ # `checks` items are `CheckRun` in production but tests commonly pass
536
+ # lighter duck-typed fakes (see `failed_checks`/`in_flight_checks` above,
537
+ # which only ever touch `.status`/`.conclusion`) — `getattr` with a
538
+ # default keeps this function tolerant of fakes that omit `.url`.
539
+ passed = failed = running = 0
540
+ failed_names: list[str] = []
541
+ first_failed_url: str | None = None
542
+ for c in checks:
543
+ if c.status != "completed":
544
+ running += 1
545
+ continue
546
+ if _is_failing_conclusion(c.conclusion):
547
+ failed += 1
548
+ failed_names.append(c.name)
549
+ url = getattr(c, "url", "") or ""
550
+ if first_failed_url is None and url:
551
+ first_failed_url = url
552
+ else:
553
+ passed += 1
554
+ return CiCheckSummary(
555
+ passed=passed,
556
+ failed=failed,
557
+ running=running,
558
+ failed_names=failed_names,
559
+ first_failed_url=first_failed_url,
560
+ )