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/gate_snapshot.py ADDED
@@ -0,0 +1,385 @@
1
+ """Tick-refreshed snapshot of the third-party merge-gate inputs (#1336).
2
+
3
+ **Invariant 1 of the /board read path: read endpoints perform no third-party
4
+ I/O.** Before this module, every cold ``GET /board`` build paid live ``gh``
5
+ subprocess calls — ``gh pr checks`` per pending merge-queue entry (via a
6
+ :class:`coord.ci_github.GitHubCi` constructed *per request*, so its cache
7
+ never survived) plus ``gh pr view --json commits`` and ``gh issue view`` per
8
+ entry for the #1318 epic-closing-keyword gate. Board latency was therefore a
9
+ function of GitHub's latency × the number of open PRs — the root mechanism of
10
+ the #762/#715/#1336 timeout-overrun failure class.
11
+
12
+ Now the daemon's tick loop calls :meth:`GateSnapshotRefresher.refresh` on its
13
+ own cadence; the ``/board`` handler consumes the immutable
14
+ :class:`GateSnapshot` it last produced. The snapshot duck-types both
15
+ consumer seams:
16
+
17
+ * the :class:`coord.ci_store.CiStore` protocol (``list_checks_for_pr`` /
18
+ ``is_available``) — consumed by ``merge_queue.plan`` and
19
+ ``stage_projection``;
20
+ * the two ``coord.github_ops`` functions ``merge_queue._entry_gate_status``
21
+ reads for the epic-closing gate (``get_pr_commit_messages`` /
22
+ ``is_epic_issue``);
23
+ * the two ``coord.github_ops`` functions the #821/#1475/#1479 review- and
24
+ smoke-freshness checks read (``get_branch_sha`` / ``get_branch_patch_id``)
25
+ — added by #1640, see below;
26
+ * the ``coord.github_ops.get_branch_commit_timestamp`` function
27
+ ``merge_queue._ci_checks_are_stale`` reads for the #1851 CI-staleness gate
28
+ — added by #1998, see below.
29
+
30
+ #1640: the snapshot used to duck-type only the first two seams, and
31
+ ``merge_queue.has_smoke_verdict`` wraps each of its ``gh_ops`` lookups in a
32
+ fail-open ``except Exception``. Handing it a snapshot that had no
33
+ ``get_branch_sha`` therefore did not fail loudly — the ``AttributeError`` was
34
+ swallowed and *every* staleness check silently degraded to a no-op. The
35
+ result was the exact "two readers, one truth" split #1640 reports:
36
+ ``coord merge --plan`` (served from ``/board``, i.e. this snapshot) printed
37
+ READY for an entry that ``coord merge --only`` (live ``github_ops``, which
38
+ does resolve the SHAs) correctly refused as stale. The lookups are served
39
+ here from tick-refreshed data so the read path still performs no third-party
40
+ I/O, and the two readers now apply the identical #1479 binding.
41
+
42
+ #1998: the exact same hole existed for ``get_branch_commit_timestamp`` — but
43
+ inverted, and worse. ``merge_queue._ci_checks_are_stale`` (the #1851 gate)
44
+ explicitly *fails closed* (returns "stale") when handed a *gh_ops* stand-in
45
+ with no ``get_branch_commit_timestamp``, rather than fail-open like the
46
+ #1479 checks above. A snapshot missing this method therefore did not degrade
47
+ to a no-op silently — it reported EVERY green, non-pending CI check served
48
+ through ``/board`` as permanently stale, unconditionally, for as long as the
49
+ attribute was absent. That is a "pessimistic display" by the gate's own
50
+ docstring, a defensible tradeoff on its own — until ``coord.drive_queue``'s
51
+ #1891 park/resume machinery, which reads its "has CI reported?" signal off
52
+ this exact ``/board``-served reason, treated the display's pessimism as
53
+ nothing stronger than "still running" (which does not hold a park open) and
54
+ moved on — except the *other* consumer of the same reason, an operator
55
+ running ``coord merge --plan``, saw "CI stale" and a `--revalidate` remedy
56
+ that could not actually clear it (the live gate, e.g. ``coord merge
57
+ --dry-run``/``--only``, was never stale at all). Same #1640 mechanism, same
58
+ fix: serve the timestamp from tick-refreshed data instead of leaving the
59
+ duck-typed seam unimplemented.
60
+
61
+ Fail-open by construction for a pair that has *never* been refreshed at all
62
+ (no backend configured yet, ``ci_available=False``): ``commit_messages`` /
63
+ ``epic_issues`` still yield ``[]`` / ``False`` in that case, and
64
+ ``list_checks_for_pr`` yields ``[]`` too — so a fresh daemon serves a correct
65
+ (if CI-unannotated) board instantly instead of blocking on GitHub.
66
+
67
+ Once a CI backend *is* configured, ``list_checks_for_pr`` is no longer
68
+ allowed to go stale silently (#1525): a snapshot older than
69
+ :data:`STALE_AFTER_SECONDS`, or one whose refresh loop has stalled entirely,
70
+ returns a synthetic failing check rather than the last-known (possibly
71
+ long-stale) green data. A stale-green board display was never the actual
72
+ mechanism behind the #1525 incident — ``coord merge`` doesn't consult this
73
+ snapshot at all, see below — but an unattended ``coord drive`` reads exactly
74
+ this display before deciding whether a merge is worth attempting, so it gets
75
+ the same fail-closed treatment as the live gate.
76
+
77
+ The *live* merge execution path (``coord merge``, auto-drain) keeps its own
78
+ live ``CiStore`` — merging is a write and is allowed to pay for fresh truth;
79
+ only the read path serves from the snapshot.
80
+ """
81
+
82
+ from __future__ import annotations
83
+
84
+ import logging
85
+ import time
86
+ from dataclasses import dataclass, field
87
+
88
+ from coord.ci_store import CheckRun, build_ci_store
89
+
90
+ log = logging.getLogger("coord.serve")
91
+
92
+ # #1525: how old a published snapshot can get before the CI-check read path
93
+ # stops trusting its "no failing checks" silence. The refresh loop's default
94
+ # cadence is 30s (COORD_GATE_REFRESH_INTERVAL); 180s is 6x that — enough
95
+ # headroom that one slow refresh pass doesn't flap the board between READY
96
+ # and BLOCKED, while still catching a refresher that has stalled or died.
97
+ # This only gates checks (the safety-critical CI read); commit_messages /
98
+ # epic_issues keep the original fail-open contract described in the module
99
+ # docstring — the #1318 epic-closing-keyword gate isn't a merge-safety gate
100
+ # in the same sense CI is.
101
+ #
102
+ # This only protects the ``/board`` *display* path — ``coord merge`` and
103
+ # auto-drain always build their own live :class:`coord.ci_store.CiStore`
104
+ # (see the module docstring), never this snapshot. But a stale-green board
105
+ # is exactly what an unattended ``coord drive`` looks at before deciding
106
+ # whether a merge is even worth attempting, so a display-only lie here is
107
+ # still worth refusing to tell.
108
+ STALE_AFTER_SECONDS = 180.0
109
+
110
+
111
+ def _stale_check(age_seconds: float | None) -> CheckRun:
112
+ """Synthetic failing :class:`CheckRun` standing in for "snapshot too old
113
+ to trust" — mirrors :func:`coord.ci_github._unreadable_check`.
114
+ ``conclusion="unknown"`` is not in
115
+ :data:`coord.ci_store._PASSING_CONCLUSIONS`, so ``failed_checks`` treats
116
+ this like any other hard failure.
117
+ """
118
+ age_desc = "never refreshed" if age_seconds is None else f"{age_seconds:.0f}s old"
119
+ return CheckRun(
120
+ name=f"coord: gate snapshot stale ({age_desc}, max {STALE_AFTER_SECONDS:.0f}s)",
121
+ status="completed",
122
+ conclusion="unknown",
123
+ url="",
124
+ run_id="",
125
+ started_at=None,
126
+ completed_at=None,
127
+ )
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class GateSnapshot:
132
+ """Immutable, atomically-swapped view of the last gate refresh.
133
+
134
+ Safe to hand to concurrent ``/board`` builds: the refresher never mutates
135
+ a published snapshot, it swaps in a new one.
136
+ """
137
+
138
+ checks: dict[tuple[str, int], list[CheckRun]] = field(default_factory=dict)
139
+ commit_messages: dict[tuple[str, int], list[str]] = field(default_factory=dict)
140
+ epic_issues: dict[tuple[str, int], bool] = field(default_factory=dict)
141
+ # #1640: (repo, branch) -> HEAD SHA, and (repo, base, head) -> patch-id,
142
+ # for the #821/#1475/#1479 review/smoke freshness checks. A key that is
143
+ # absent (never refreshed, or the lookup failed) yields None, which those
144
+ # checks already treat as "anchor unavailable → skip that half" — the
145
+ # same fail-open convention they apply to a live `gh` call that errors.
146
+ branch_shas: dict[tuple[str, str], str | None] = field(default_factory=dict)
147
+ branch_patch_ids: dict[tuple[str, str, str], str | None] = field(
148
+ default_factory=dict
149
+ )
150
+ # #1998: (repo, branch) -> that branch's HEAD commit's unix timestamp —
151
+ # the base-side half of the #1851 CI-staleness comparison
152
+ # (`merge_queue._ci_checks_are_stale`). A key that is absent (never
153
+ # refreshed, or the lookup failed) yields `None`, which that function
154
+ # already treats as "can't compare" and fails closed on — same fail-open
155
+ # *caching* convention as `branch_shas`/`branch_patch_ids` above; the
156
+ # fail-*closed* verdict itself lives entirely in the consumer, not here.
157
+ branch_commit_timestamps: dict[tuple[str, str], float | None] = field(
158
+ default_factory=dict
159
+ )
160
+ # #1904: repo -> whether the inner CiStore believes this repo declares
161
+ # CI at all — the signal `expects_checks` below needs to tell "no CI
162
+ # configured" apart from "CI exists but never reported for this PR"
163
+ # when `list_checks_for_pr` comes back empty. Keyed on repo (not
164
+ # (repo, number)) since workflow declarations are repo-wide.
165
+ workflows_declared: dict[str, bool] = field(default_factory=dict)
166
+ ci_available: bool = False
167
+ refreshed_at: float | None = None
168
+
169
+ # ── CiStore protocol ────────────────────────────────────────────────────
170
+ def list_checks_for_pr(self, repo: str, number: int) -> list[CheckRun]:
171
+ # #1525: once a CI backend is configured (`ci_available`), a snapshot
172
+ # that's stale-beyond-bound must not silently read as "no failing
173
+ # checks" — that's the same fail-open shape as an unreadable `gh`
174
+ # call, just reached via a dead/slow refresh loop instead of a dead
175
+ # `gh` process. `ci_available=False` (no backend configured) is left
176
+ # alone: there's nothing to be stale about.
177
+ if self.ci_available:
178
+ age = None if self.refreshed_at is None else time.time() - self.refreshed_at
179
+ if age is None or age > STALE_AFTER_SECONDS:
180
+ return [_stale_check(age)]
181
+ return self.checks.get((repo, number), [])
182
+
183
+ def expects_checks(self, repo: str, number: int) -> bool:
184
+ # #1904: unlike `list_checks_for_pr`'s "unknown reads as failing"
185
+ # (#1525) posture, a repo this snapshot hasn't cached an answer for
186
+ # yet — never refreshed, or `ci_available=False` — reads as `False`
187
+ # (not "checks absent"). This mirrors the module docstring's
188
+ # documented "fail-open by construction" tradeoff for a snapshot
189
+ # that hasn't (yet) done any I/O: a fresh daemon boot serves an
190
+ # unannotated board instantly rather than reading every pending
191
+ # entry as untested-and-blocked before the first tick even runs.
192
+ # Once `refresh()` has run at least once for this repo, the cached
193
+ # answer is real GitHub truth, not a guess.
194
+ return self.workflows_declared.get(repo, False)
195
+
196
+ @property
197
+ def is_available(self) -> bool:
198
+ return self.ci_available
199
+
200
+ # ── github_ops view consumed by merge_queue._entry_gate_status ─────────
201
+ def get_pr_commit_messages(self, repo: str, number: int) -> list[str]:
202
+ return self.commit_messages.get((repo, number), [])
203
+
204
+ def is_epic_issue(self, repo: str, number: int) -> bool:
205
+ return self.epic_issues.get((repo, number), False)
206
+
207
+ # ── github_ops view consumed by the review/smoke freshness checks ──────
208
+ # (#1640 — merge_queue.has_approved_review / evaluate_smoke_verdict)
209
+ def get_branch_sha(self, repo: str, branch: str) -> str | None:
210
+ return self.branch_shas.get((repo, branch))
211
+
212
+ def get_branch_patch_id(self, repo: str, base: str, branch: str) -> str | None:
213
+ return self.branch_patch_ids.get((repo, base, branch))
214
+
215
+ def get_branch_commit_timestamp(self, repo: str, branch: str) -> float | None:
216
+ return self.branch_commit_timestamps.get((repo, branch))
217
+
218
+
219
+ class GateSnapshotRefresher:
220
+ """Owns the current :class:`GateSnapshot`; refreshed by the daemon tick.
221
+
222
+ ``snapshot()`` is what the read path consumes — a bare attribute read
223
+ (atomic under CPython), never I/O. ``refresh(config)`` is the only
224
+ method that talks to GitHub and must only ever run from the daemon's
225
+ tick machinery (or a test driving it explicitly).
226
+ """
227
+
228
+ def __init__(self) -> None:
229
+ self._snapshot = GateSnapshot()
230
+ self._ci_type: str | None = None
231
+ self._inner_ci = None # CiStore | None — rebuilt when config type changes
232
+
233
+ def snapshot(self) -> GateSnapshot:
234
+ return self._snapshot
235
+
236
+ # ── the tick-side refresh (the ONLY third-party I/O) ────────────────────
237
+ def refresh(self, config) -> GateSnapshot: # noqa: ANN001 — coord.config.Config
238
+ """One refresh pass over the pending merge-queue entries.
239
+
240
+ Reads the queue from the local DB, fetches CI checks + PR commit
241
+ messages (+ epic-ness of any closing-keyword targets) per pending
242
+ entry with a PR, plus (#1640) the branch/base HEAD SHAs and the
243
+ branch's patch-id, and (#1998) the target branch's HEAD commit
244
+ timestamp, for every pending entry, and atomically publishes a new
245
+ snapshot. Per-entry failures degrade that entry to the fail-open
246
+ values; they never abort the pass or unpublish other entries' data.
247
+
248
+ Cost note (#1640): the SHA sweep adds up to two ``gh api
249
+ repos/…/branches/…`` calls and one ``gh api compare`` per pending
250
+ entry per pass. Branch SHAs are deduped across entries, so a group
251
+ of N entries sharing one target branch pays for that base once. The
252
+ merge queue's *pending* set is what bounds this — merged history is
253
+ never refreshed — so it stays proportional to work actually waiting
254
+ to merge, not to project age. The #1998 commit-timestamp sweep reads
255
+ the same ``repos/…/branches/{target_branch}`` endpoint already
256
+ fetched for ``branch_shas`` above but is not merged into that same
257
+ call — it is deduped independently, on ``(repo, target_branch)``
258
+ only, so it adds at most one further already-cached-shape ``gh api``
259
+ call per distinct target branch per pass, same bound as the SHA
260
+ sweep.
261
+ """
262
+ from coord import github_ops # noqa: PLC0415
263
+ from coord.merge_queue import PENDING, load_queue # noqa: PLC0415
264
+ from coord.pr_body_lint import find_closing_references # noqa: PLC0415
265
+
266
+ ci_type = getattr(getattr(config, "ci_store", None), "type", "none")
267
+ if ci_type != self._ci_type:
268
+ self._ci_type = ci_type
269
+ try:
270
+ self._inner_ci = build_ci_store(ci_type)
271
+ except Exception: # noqa: BLE001 — unknown type: disable the CI gate
272
+ self._inner_ci = None
273
+ inner = self._inner_ci
274
+ ci_available = bool(inner is not None and inner.is_available)
275
+
276
+ try:
277
+ # #1640: the SHA/patch-id sweep covers every PENDING entry, not
278
+ # just the ones with a PR — a freshly-enqueued entry has no PR
279
+ # yet but is exactly the one `coord merge --plan` renders, and
280
+ # leaving it out is what let the plan show READY for a verdict
281
+ # the live gate rejects as stale.
282
+ pending = [e for e in load_queue() if e.state == PENDING]
283
+ except Exception: # noqa: BLE001 — DB hiccup: keep serving the old snapshot
284
+ log.warning("gate refresh: could not load merge queue", exc_info=True)
285
+ return self._snapshot
286
+ entries = [e for e in pending if e.pr_number]
287
+
288
+ checks: dict[tuple[str, int], list[CheckRun]] = {}
289
+ messages: dict[tuple[str, int], list[str]] = {}
290
+ epics: dict[tuple[str, int], bool] = {}
291
+ branch_shas: dict[tuple[str, str], str | None] = {}
292
+ branch_patch_ids: dict[tuple[str, str, str], str | None] = {}
293
+ branch_commit_timestamps: dict[tuple[str, str], float | None] = {}
294
+ workflows_declared: dict[str, bool] = {}
295
+ for entry in pending:
296
+ # Branch HEAD + merge-base HEAD + the branch's patch-id against
297
+ # that base — the three anchors #821/#1475/#1479 compare a
298
+ # recorded review/test verdict against. Per-lookup failures are
299
+ # cached as None (fail open for that half of the check), exactly
300
+ # as the live path treats a `gh` error.
301
+ for repo, branch in (
302
+ (entry.repo_github, entry.branch),
303
+ (entry.repo_github, entry.target_branch),
304
+ ):
305
+ if not repo or not branch or (repo, branch) in branch_shas:
306
+ continue
307
+ try:
308
+ branch_shas[(repo, branch)] = github_ops.get_branch_sha(repo, branch)
309
+ except Exception: # noqa: BLE001 — fail-open for this branch
310
+ branch_shas[(repo, branch)] = None
311
+ # #1998: the target branch's HEAD commit timestamp — the anchor
312
+ # `merge_queue._ci_checks_are_stale` (#1851) needs to tell a
313
+ # genuinely-stale green check apart from one that predates the
314
+ # base by nothing at all. Keyed on (repo, target_branch) only —
315
+ # unlike `branch_shas` above, the staleness gate never reads the
316
+ # entry's OWN branch's timestamp, only the base's — deduped the
317
+ # same way, one `gh api` call per distinct target branch per
318
+ # tick, not per entry.
319
+ ts_repo, ts_branch = entry.repo_github, entry.target_branch
320
+ if (
321
+ ts_repo
322
+ and ts_branch
323
+ and (ts_repo, ts_branch) not in branch_commit_timestamps
324
+ ):
325
+ try:
326
+ branch_commit_timestamps[(ts_repo, ts_branch)] = (
327
+ github_ops.get_branch_commit_timestamp(ts_repo, ts_branch)
328
+ )
329
+ except Exception: # noqa: BLE001 — fail-open for this branch
330
+ branch_commit_timestamps[(ts_repo, ts_branch)] = None
331
+ pid_key = (entry.repo_github, entry.target_branch, entry.branch)
332
+ if all(pid_key) and pid_key not in branch_patch_ids:
333
+ try:
334
+ branch_patch_ids[pid_key] = github_ops.get_branch_patch_id(*pid_key)
335
+ except Exception: # noqa: BLE001 — fail-open for this entry
336
+ branch_patch_ids[pid_key] = None
337
+
338
+ for entry in entries:
339
+ key = (entry.repo_github, int(entry.pr_number))
340
+ if ci_available:
341
+ try:
342
+ checks[key] = inner.list_checks_for_pr(*key)
343
+ except Exception: # noqa: BLE001 — fail-open for this entry
344
+ checks[key] = []
345
+ # #1904: repo-wide, so dedupe across every pending entry in
346
+ # the same repo — one `gh api .../actions/workflows` call
347
+ # per repo per tick, not one per PR. A failure here reads
348
+ # as `False` (not "checks absent") for this snapshot cycle
349
+ # — see `expects_checks`' docstring for why the *display*
350
+ # path stays fail-open where the live gate fails closed.
351
+ if entry.repo_github not in workflows_declared:
352
+ try:
353
+ workflows_declared[entry.repo_github] = inner.expects_checks(*key)
354
+ except Exception: # noqa: BLE001 — fail-open for this repo
355
+ workflows_declared[entry.repo_github] = False
356
+ try:
357
+ msgs = github_ops.get_pr_commit_messages(*key)
358
+ except Exception: # noqa: BLE001
359
+ msgs = []
360
+ messages[key] = msgs
361
+ referenced: set[int] = set()
362
+ for message in msgs:
363
+ referenced.update(find_closing_references(message))
364
+ for n in sorted(referenced):
365
+ epic_key = (entry.repo_github, n)
366
+ if epic_key in epics:
367
+ continue
368
+ try:
369
+ epics[epic_key] = github_ops.is_epic_issue(*epic_key)
370
+ except Exception: # noqa: BLE001 — fail-open, matches the live gate
371
+ epics[epic_key] = False
372
+
373
+ snap = GateSnapshot(
374
+ checks=checks,
375
+ commit_messages=messages,
376
+ epic_issues=epics,
377
+ branch_shas=branch_shas,
378
+ branch_patch_ids=branch_patch_ids,
379
+ branch_commit_timestamps=branch_commit_timestamps,
380
+ workflows_declared=workflows_declared,
381
+ ci_available=ci_available,
382
+ refreshed_at=time.time(),
383
+ )
384
+ self._snapshot = snap # atomic publish
385
+ return snap