code-coordinator 0.5.46__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. coord/worker_events.py +954 -0
@@ -0,0 +1,477 @@
1
+ """Tick-refreshed fleet-health snapshot, aggregated onto board state (#1630).
2
+
3
+ Mirrors :mod:`coord.gate_snapshot`'s shape exactly, and for the same reason:
4
+ the ``/board`` read path must perform no per-request I/O (#1336 invariant 1).
5
+ Polling N agents' ``/health`` endpoints, shelling out to ``pip show`` once
6
+ for the daemon's own ``coord-serve`` install, and cross-referencing every
7
+ machine's ``/status`` for phantom rows is all real network/subprocess work —
8
+ it runs on the daemon's slow tick cadence (``coord.serve_app``'s
9
+ ``_health_refresh_loop``), never inline inside a ``GET /board``.
10
+
11
+ **#1806: this module gathers only genuinely daemon-host-local facts.** The
12
+ CLI-venv version and the ``tui/`` binary-vs-source staleness used to be
13
+ ``os.stat``-ed here too, which was wrong — both are facts about the
14
+ *operator's* machine, not the daemon's, and the two are frequently different
15
+ boxes. Those two now ride each machine's own ``/health`` poll (the
16
+ ``cli_venv``/``tui_binary`` machine-scope checks in
17
+ :mod:`coord.health.checks.deploy_lane_facts`) and are aggregated fleet-wide
18
+ in :mod:`coord.health.checks.fleet_deploy_lanes` from ``ctx.fleet.machines``,
19
+ not from ``daemon_host``.
20
+
21
+ **Advisory only (the hard constraint of #1630).** This module writes to
22
+ :func:`coord.state.save_machine_health` and hands its snapshot to
23
+ ``/board``'s response body — never to a :class:`coord.models.Board`. Nothing
24
+ here is reachable from ``coord.merge_queue.plan``, ``coord.dispatch``, or
25
+ ``coord.review``'s routing, because none of those take this module's output
26
+ as an argument. See ``tests/test_health_advisory_only.py``.
27
+
28
+ **Unknown, not green, when a signal is missing.** A machine that never
29
+ responds, or one whose last response is older than :data:`STALE_AFTER_
30
+ SECONDS`, is surfaced as ``severity="unknown"`` — never silently dropped
31
+ (that would look identical to "nothing wrong", #1485's exact failure mode)
32
+ and never left at its last-known-good severity forever (that would look
33
+ identical to "still healthy", the regression #1630 itself calls out).
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ import logging
40
+ import os
41
+ import time
42
+ from dataclasses import dataclass, field
43
+ from pathlib import Path
44
+
45
+ log = logging.getLogger("coord.serve")
46
+
47
+ # How old a machine's last-received health poll can get before it reads as
48
+ # `unknown` rather than its last-known severity. A few multiples of the
49
+ # health-poll tick's own cadence (COORD_HEALTH_POLL_INTERVAL, default 60s) —
50
+ # generous enough that one slow/missed tick doesn't flap a healthy machine
51
+ # to unknown, tight enough that a genuinely dead agent doesn't stay green
52
+ # for hours. Overridable for tests.
53
+ STALE_AFTER_SECONDS = float(os.environ.get("COORD_HEALTH_STALE_SECS", "900"))
54
+
55
+ # #1337/#1336/#1597: this repo has hit multi-MB /board payloads from
56
+ # unbounded per-item detail three times. The health block budgets itself
57
+ # independently and much tighter — 256 KiB is enormous headroom for what's
58
+ # at most a few dozen machines x a dozen short check rows, while still
59
+ # catching a pathological probe (e.g. one that dumps a huge `values` blob)
60
+ # before it repeats that history.
61
+ MAX_HEALTH_BLOCK_BYTES = 256 * 1024
62
+
63
+ def _trim_check_result(r: dict) -> dict:
64
+ """Drop the bytes-heavy, lowest-value fields from an OK-severity result.
65
+
66
+ Applied only when the whole block is over budget (see
67
+ :func:`bound_health_payload`) — a healthy check's `values`/`detail` are
68
+ the least actionable bytes in the payload; a WARN/CRIT/UNKNOWN result is
69
+ left untouched because that's exactly the row someone is about to read.
70
+ """
71
+ if r.get("severity") != "ok":
72
+ return r
73
+ trimmed = dict(r)
74
+ trimmed["values"] = {}
75
+ trimmed["detail"] = ""
76
+ return trimmed
77
+
78
+
79
+ def _hard_truncate_result(r: dict, *, max_field_len: int = 200) -> dict:
80
+ """Last-resort trim applied regardless of severity: cap `detail` and
81
+ `values` to a fixed size. Unlike :func:`_trim_check_result` this DOES
82
+ touch WARN/CRIT/UNKNOWN rows — by the time this runs, staying under
83
+ budget has won out over "never touch the row someone is about to read";
84
+ a truncated-but-present WARN/CRIT beats a payload dropped/rejected
85
+ outright for being oversized.
86
+ """
87
+ trimmed = dict(r)
88
+ detail = trimmed.get("detail") or ""
89
+ if len(detail) > max_field_len:
90
+ trimmed["detail"] = detail[:max_field_len] + "…"
91
+ values = trimmed.get("values") or {}
92
+ if values and len(json.dumps(values)) > max_field_len:
93
+ trimmed["values"] = {"_truncated": True}
94
+ return trimmed
95
+
96
+
97
+ def bound_health_payload(
98
+ machine_health: list[dict],
99
+ fleet_checks: list[dict],
100
+ *,
101
+ max_bytes: int = MAX_HEALTH_BLOCK_BYTES,
102
+ ) -> tuple[list[dict], list[dict], bool]:
103
+ """Trim *machine_health*/*fleet_checks* to fit under *max_bytes* serialized.
104
+
105
+ Escalating, cheapest-first stages, each re-checked before reaching for
106
+ the next:
107
+
108
+ 1. strip `values`/`detail` off OK-severity result rows fleet-wide (the
109
+ common case: everything's fine, most bytes are the least useful).
110
+ 2. cap each machine's `results` list to its first 25 entries (H-1's
111
+ registry runs well under this per machine — a backstop, not the
112
+ expected path).
113
+ 3. hard-truncate `detail`/`values` on EVERY remaining row, including
114
+ WARN/CRIT/UNKNOWN — this is the guarantee-of-last-resort: no single
115
+ pathological probe (a check that dumps a huge blob into `values`) can
116
+ blow the budget, full stop. #1337/#1336/#1597 are all "an unbounded
117
+ per-item field made the payload huge"; this stage exists so the same
118
+ shape of bug in a *health* probe is caught here instead of repeating
119
+ that history a fourth time.
120
+
121
+ Returns ``(machine_health, fleet_checks, truncated)`` — *truncated* is
122
+ True iff stage 2 or 3 actually altered data (as opposed to stage 1's
123
+ already-lossless-for-a-healthy-row trim), so a caller can log/flag it
124
+ rather than truncate silently (no-silent-caps).
125
+ """
126
+
127
+ def _size(mh: list[dict], fc: list[dict]) -> int:
128
+ return len(json.dumps({"machine_health": mh, "fleet_checks": fc}))
129
+
130
+ if _size(machine_health, fleet_checks) <= max_bytes:
131
+ return machine_health, fleet_checks, False
132
+
133
+ trimmed_mh = [
134
+ {**m, "results": [_trim_check_result(r) for r in (m.get("results") or [])]}
135
+ for m in machine_health
136
+ ]
137
+ trimmed_fc = [_trim_check_result(r) for r in fleet_checks]
138
+ if _size(trimmed_mh, trimmed_fc) <= max_bytes:
139
+ return trimmed_mh, trimmed_fc, False
140
+
141
+ capped_mh = [
142
+ {**m, "results": (m.get("results") or [])[:25]} for m in trimmed_mh
143
+ ]
144
+ if _size(capped_mh, trimmed_fc) <= max_bytes:
145
+ return capped_mh, trimmed_fc, True
146
+
147
+ hard_mh = [
148
+ {**m, "results": [_hard_truncate_result(r) for r in (m.get("results") or [])]}
149
+ for m in capped_mh
150
+ ]
151
+ hard_fc = [_hard_truncate_result(r) for r in trimmed_fc]
152
+ if _size(hard_mh, hard_fc) <= max_bytes:
153
+ return hard_mh, hard_fc, True
154
+
155
+ # Stage 4, the actual mathematical guarantee: every row is now bounded
156
+ # to a fixed small size, so the ONLY remaining unbounded dimension is
157
+ # machine count. Drop machines from the tail until the fleet fits — a
158
+ # fleet large enough to need this is not a realistic coordinator install
159
+ # (dozens, not thousands, of machines), so this is a backstop for "the
160
+ # bound must hold, full stop," not an expected path.
161
+ lo, hi = 0, len(hard_mh)
162
+ while lo < hi:
163
+ mid = (lo + hi + 1) // 2
164
+ if _size(hard_mh[:mid], hard_fc) <= max_bytes:
165
+ lo = mid
166
+ else:
167
+ hi = mid - 1
168
+ return hard_mh[:lo], hard_fc, True
169
+
170
+
171
+ @dataclass(frozen=True)
172
+ class FleetHealthSnapshot:
173
+ """Immutable, atomically-swapped view of the last fleet-health refresh.
174
+
175
+ ``machine_health`` is a list (not a dict) so it serializes directly into
176
+ ``/board``'s JSON body in a stable, order-preserving shape — one entry
177
+ per machine in ``config.machines`` order.
178
+ """
179
+
180
+ machine_health: list[dict] = field(default_factory=list)
181
+ fleet_checks: list[dict] = field(default_factory=list)
182
+ refreshed_at: float | None = None
183
+ truncated: bool = False
184
+
185
+ def to_dict(self) -> dict:
186
+ return {
187
+ "schema": 1,
188
+ "refreshed_at": self.refreshed_at,
189
+ "machine_health": self.machine_health,
190
+ "fleet_checks": self.fleet_checks,
191
+ "truncated": self.truncated,
192
+ }
193
+
194
+
195
+ def _effective_severity(entry: dict, *, now: float) -> tuple[str, bool]:
196
+ """(severity, stale) for one machine's aggregated health row.
197
+
198
+ `unknown` — never a carried-forward "ok" — whenever the daemon can't
199
+ currently vouch for the number: offline/unreachable, no health block at
200
+ all (old agent, or an agent that hasn't completed its first local run
201
+ yet), or a received_at older than STALE_AFTER_SECONDS.
202
+ """
203
+ received_at = entry.get("received_at")
204
+ stale = received_at is None or (now - received_at) > STALE_AFTER_SECONDS
205
+ if stale or entry.get("state") != "online":
206
+ return "unknown", stale
207
+ checks = entry.get("health") or {}
208
+ severity = checks.get("severity")
209
+ return (severity or "unknown"), stale
210
+
211
+
212
+ def _machine_health_rows(machine_names: list[str], raw: dict, *, now: float) -> list[dict]:
213
+ rows: list[dict] = []
214
+ for name in machine_names:
215
+ entry = raw.get(name) or {"state": "unknown", "reason": "never polled",
216
+ "latency_ms": None, "received_at": None, "health": None}
217
+ severity, stale = _effective_severity(entry, now=now)
218
+ checks = entry.get("health") or {}
219
+ # #1630: `results` is the last-known detail even when `severity` above
220
+ # has been downgraded to "unknown" for staleness — a renderer needs
221
+ # both "trust this right now? no" (severity/stale) AND "what did we
222
+ # last see?" (results/checked_at) to tell "OK" apart from "last
223
+ # measured OK, a while ago" per the issue's own framing. Only truly
224
+ # absent when there is no last-known data at all (never reported, or
225
+ # unreachable with no prior successful poll).
226
+ rows.append(
227
+ {
228
+ "machine": name,
229
+ "state": entry.get("state", "unknown"),
230
+ "reason": entry.get("reason", ""),
231
+ "latency_ms": entry.get("latency_ms"),
232
+ "received_at": entry.get("received_at"),
233
+ "stale": stale,
234
+ "severity": severity,
235
+ "checked_at": checks.get("checked_at"),
236
+ "results": checks.get("results", []),
237
+ }
238
+ )
239
+ return rows
240
+
241
+
242
+ class FleetHealthRefresher:
243
+ """Owns the current :class:`FleetHealthSnapshot`; refreshed by the daemon tick.
244
+
245
+ ``snapshot()`` is what the ``/board`` read path consumes — a bare
246
+ attribute read, no I/O. ``refresh(config)`` is the only method that
247
+ talks to agents/subprocesses and must only ever run from the daemon's
248
+ tick machinery (or a test driving it explicitly).
249
+ """
250
+
251
+ def __init__(self) -> None:
252
+ self._snapshot = FleetHealthSnapshot()
253
+ # #1597/#1336/#1337: the /board handler is the only thing that
254
+ # actually measures its own rebuild latency and serialized payload
255
+ # size — it calls `record_board_stats` right after each publish.
256
+ # `None` until the first /board build after daemon startup, which
257
+ # the fleet_board_latency check already reports as UNKNOWN rather
258
+ # than fabricating a 0.
259
+ self._board_latency_ms: float | None = None
260
+ self._board_payload_bytes: int | None = None
261
+
262
+ def snapshot(self) -> FleetHealthSnapshot:
263
+ return self._snapshot
264
+
265
+ def record_board_stats(self, latency_ms: float, payload_bytes: int) -> None:
266
+ """Called by the /board handler after each publish (#1630/#1597).
267
+
268
+ Deliberately NOT I/O and NOT gated on the tick cadence — recording a
269
+ float+int is free, and the alternative (recomputing board latency
270
+ independently from this refresher) would mean building a whole
271
+ second board just to measure it, defeating the point of the check.
272
+ """
273
+ self._board_latency_ms = latency_ms
274
+ self._board_payload_bytes = payload_bytes
275
+
276
+ def refresh(self, config) -> FleetHealthSnapshot: # noqa: ANN001 — coord.config.Config
277
+ from coord import network, state # noqa: PLC0415
278
+ from coord.health.context import build_context # noqa: PLC0415
279
+ from coord.health.models import FleetSnapshot # noqa: PLC0415
280
+ from coord.health.registry import run_all # noqa: PLC0415
281
+
282
+ now = time.time()
283
+ machines = list(getattr(config, "machines", ()) or ())
284
+
285
+ # Last-known health blocks, read BEFORE this poll — a machine that's
286
+ # merely offline for one tick keeps its last-known check results (so
287
+ # a renderer can still show "disk 90% full, last seen 4 min ago")
288
+ # while `state`/`severity` flip to unreachable/unknown immediately.
289
+ # Only a poll that ACTUALLY returns a fresh health block replaces it;
290
+ # `received_at` always advances to `now` either way, since that's the
291
+ # daemon's own "did I just try to poll this machine" clock, which is
292
+ # what STALE_AFTER_SECONDS below guards (a dead tick loop, not a
293
+ # merely-offline machine).
294
+ previously_known = state.load_machine_health()
295
+
296
+ # ── 1. poll every agent's /health, persist each as-of-now ──────────
297
+ for machine in machines:
298
+ try:
299
+ status = network.check_machine(machine)
300
+ except Exception as exc: # noqa: BLE001 — one bad machine must not abort the tick
301
+ log.warning("health poll: %s raised %s", machine.name, exc)
302
+ status = None
303
+ last_known_health = (previously_known.get(machine.name) or {}).get("health")
304
+ if status is None:
305
+ state.save_machine_health(
306
+ machine.name, state="unknown", reason="poll raised",
307
+ latency_ms=None, health=last_known_health, received_at=now,
308
+ )
309
+ continue
310
+ health_block = (
311
+ status.health.get("health") if status.health else None
312
+ ) or last_known_health
313
+ state.save_machine_health(
314
+ machine.name,
315
+ state=status.state,
316
+ reason=status.reason,
317
+ latency_ms=status.latency_ms,
318
+ health=health_block,
319
+ received_at=now,
320
+ )
321
+
322
+ raw = state.load_machine_health()
323
+ machine_names = [m.name for m in machines]
324
+ machine_health = _machine_health_rows(machine_names, raw, now=now)
325
+
326
+ # ── 2. daemon-host-local facts the fleet checks need ───────────────
327
+ daemon_host = self._daemon_host_facts(config)
328
+ daemon_host["phantom_running"] = self._phantom_running_rows(machines)
329
+ daemon_host["board_latency_ms"] = self._board_latency_ms
330
+ daemon_host["board_payload_bytes"] = self._board_payload_bytes
331
+
332
+ # ── 3. run the fleet-scope registry over the assembled snapshot ────
333
+ by_name = {row["machine"]: {
334
+ "state": row["state"], "reason": row["reason"],
335
+ "latency_ms": row["latency_ms"], "received_at": row["received_at"],
336
+ "checks": {"results": row["results"], "checked_at": row["checked_at"]},
337
+ } for row in machine_health}
338
+ fleet = FleetSnapshot(machines=by_name, daemon_host=daemon_host)
339
+ ctx = build_context(config, now=now, allow_network=False)
340
+ ctx.fleet = fleet
341
+ try:
342
+ report = run_all(ctx, scopes=("fleet",))
343
+ fleet_checks = report.to_dict()["results"]
344
+ except Exception as exc: # noqa: BLE001 — fail soft, never break the tick
345
+ log.warning("fleet health checks failed", exc_info=True)
346
+ fleet_checks = [{
347
+ "key": "fleet_health_error", "check_id": "fleet_health_error",
348
+ "scope": "fleet", "subject": None, "title": "fleet health",
349
+ "label": "fleet health", "severity": "unknown",
350
+ "headroom": f"fleet check run failed: {exc}", "threshold": "",
351
+ "detail": "", "trend": None, "values": {}, "error": str(exc),
352
+ }]
353
+
354
+ machine_health, fleet_checks, truncated = bound_health_payload(
355
+ machine_health, fleet_checks
356
+ )
357
+ if truncated:
358
+ log.warning(
359
+ "fleet health snapshot exceeded %d bytes even after trimming "
360
+ "OK-severity detail — per-machine results capped to 25 rows",
361
+ MAX_HEALTH_BLOCK_BYTES,
362
+ )
363
+
364
+ self._snapshot = FleetHealthSnapshot(
365
+ machine_health=machine_health,
366
+ fleet_checks=fleet_checks,
367
+ refreshed_at=now,
368
+ truncated=truncated,
369
+ )
370
+ return self._snapshot
371
+
372
+ @staticmethod
373
+ def _daemon_host_facts(config) -> dict: # noqa: ANN001
374
+ """Best-effort facts that are genuinely local to the daemon process.
375
+
376
+ #1806: this used to also gather the CLI-venv version and the tui/
377
+ binary-vs-source comparison here — both wrong, since the daemon host
378
+ is frequently not the operator's machine. Those two now come from
379
+ each machine's own ``/health`` poll instead (see this module's
380
+ docstring and :mod:`coord.health.checks.deploy_lane_facts`); only
381
+ ``coord-serve``'s own install — which can *only* be introspected from
382
+ the process running it — and the toolchain facts below (already
383
+ correctly fleet-wide, per #1806's own triage) stay here.
384
+
385
+ Every lookup is individually fail-soft — this must never raise, since
386
+ a raise here would take the whole tick down with it.
387
+ """
388
+ import sys # noqa: PLC0415
389
+
390
+ from coord.health.checks.agent_install import pip_show # noqa: PLC0415
391
+
392
+ facts: dict = {}
393
+
394
+ try:
395
+ own = pip_show(Path(sys.executable))
396
+ facts["coord_serve_version"] = own.get("Version") or None
397
+ facts["coord_serve_editable"] = bool(own.get("Editable project location"))
398
+ except Exception: # noqa: BLE001
399
+ facts["coord_serve_version"] = None
400
+ facts["coord_serve_editable"] = None
401
+
402
+ # #1629 (H-2): per-repo toolchain kinds + CI's pinned version, read
403
+ # from whichever local checkouts exist on THIS (daemon) host — same
404
+ # "derive from configured checkouts, never guess a path" rule as the
405
+ # tui/ source dir above. A repo with no local checkout on the daemon
406
+ # host simply has no entry — `probe_toolchain_skew` reads that as "no
407
+ # repo has a resolvable toolchain" for it, not as "in sync".
408
+ facts["repo_toolchain_kinds"] = {}
409
+ facts["ci_toolchains"] = {}
410
+ try:
411
+ from coord.health.checks.toolchain import ( # noqa: PLC0415
412
+ ci_toolchain_versions,
413
+ repo_toolchain_kinds,
414
+ )
415
+ from coord.health.context import local_checkouts # noqa: PLC0415
416
+
417
+ for checkout in local_checkouts(config):
418
+ kinds = repo_toolchain_kinds(checkout.path)
419
+ if kinds:
420
+ facts["repo_toolchain_kinds"][checkout.name] = kinds
421
+ facts["ci_toolchains"][checkout.name] = ci_toolchain_versions(
422
+ checkout.path
423
+ )
424
+ except Exception: # noqa: BLE001 — a fact gatherer must never break the tick
425
+ pass
426
+
427
+ return facts
428
+
429
+ @staticmethod
430
+ def _phantom_running_rows(machines: list) -> list[dict]:
431
+ """Board rows marked running whose owning machine no longer agrees.
432
+
433
+ Read-only: fetches each busy machine's own `/status` and compares —
434
+ never writes to the board, never finalizes anything (that stays
435
+ `coord diagnose`'s job). A machine that doesn't answer `/status` is
436
+ skipped entirely for this check (its offline-ness is already
437
+ reported by the per-machine health row; "phantom" specifically means
438
+ "reachable AND disagrees", not "unreachable").
439
+ """
440
+ from coord import network # noqa: PLC0415
441
+ from coord.state import build_board # noqa: PLC0415
442
+
443
+ try:
444
+ board = build_board()
445
+ except Exception: # noqa: BLE001
446
+ return []
447
+
448
+ running_by_machine: dict[str, list] = {}
449
+ for a in board.active:
450
+ if a.status == "running" and a.assignment_id:
451
+ running_by_machine.setdefault(a.machine_name, []).append(a)
452
+
453
+ phantom: list[dict] = []
454
+ for machine in machines:
455
+ rows = running_by_machine.get(machine.name)
456
+ if not rows:
457
+ continue
458
+ try:
459
+ result = network.fetch_status(machine)
460
+ except Exception: # noqa: BLE001
461
+ continue
462
+ if not result.ok or not isinstance(result.data, dict):
463
+ continue
464
+ live_ids = {
465
+ e.get("id") for e in (result.data.get("active") or []) if isinstance(e, dict)
466
+ }
467
+ for a in rows:
468
+ if a.assignment_id not in live_ids:
469
+ phantom.append(
470
+ {
471
+ "assignment_id": a.assignment_id,
472
+ "machine": machine.name,
473
+ "repo_name": a.repo_name,
474
+ "issue_number": a.issue_number,
475
+ }
476
+ )
477
+ return phantom