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,171 @@
1
+ """Systemd unit *enablement* vs. the per-role manifest (#2098).
2
+
3
+ `unit_drift` (:mod:`coord.health.checks.unit_drift`) answers "does the
4
+ installed unit's *content* match the release" and deliberately treats
5
+ absence as OK — most hosts don't run every deploy-lane unit, and which
6
+ units a host runs is a topology decision neither that check nor
7
+ `coord.deploy_units` will infer (#1831). Neither of those probes answers
8
+ the question that actually cost a day: **an installed unit whose content
9
+ is byte-perfect can still be sitting there disabled.**
10
+
11
+ `coord-release-propagate.timer` was `cp`'d onto dellserver correctly and
12
+ then never `systemctl --user enable --now`'d, because the runbook that
13
+ would have said so never mentioned it (fixed in
14
+ `docs/AGENT_OPERATIONS.md`, but the doc fix alone leaves the *next* missed
15
+ enable step just as invisible). A disabled timer and a deferring timer
16
+ both produce zero log lines — the fleet ran 11 releases behind for a day
17
+ with every readout looking normal, because nothing distinguished "not
18
+ enabled" from "enabled and just hasn't fired yet".
19
+
20
+ `coord.deploy_manifest` is this check's reference: a role -> unit-name
21
+ table that used to exist only as prose (or, before that, only as
22
+ `~/.config/systemd/user/timers.target.wants/` symlinks on dellserver, which
23
+ is machine state that dies with the machine). This probe reads
24
+ :func:`~coord.deploy_manifest.all_manifest_units` but only asks about units
25
+ this host has *already chosen to install* — same "don't guess topology"
26
+ boundary as `unit_drift`: an uninstalled manifest unit is not a fault,
27
+ because most hosts are workers and are not supposed to run the daemon
28
+ lanes. An *installed* one that the manifest expects and that
29
+ `systemctl --user is-enabled` reports as anything other than enabled is
30
+ exactly the state that hid the propagate timer.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import subprocess
36
+
37
+ from coord.deploy_manifest import all_manifest_units
38
+ from coord.health.checks.unit_drift import resolve_systemd_user_dir
39
+ from coord.health.models import CheckResult, HealthContext, Severity
40
+ from coord.health.registry import check
41
+
42
+ # `systemctl --user is-enabled` states that mean "this will run". `static`
43
+ # is deliberately excluded: every manifest unit ships an `[Install]`
44
+ # section (it is either a timer or a persistent service), so an installed
45
+ # manifest unit reporting `static` has lost that section relative to
46
+ # `deploy/` — a real fault, not a unit that can't be enabled by design.
47
+ #
48
+ # `alias` means systemctl resolved the queried name to a *different* unit
49
+ # via an `Alias=` in that other unit's `[Install]` section and reports the
50
+ # alias target's own state — i.e. "enabled, under another name" — not a
51
+ # distinct half-enabled state. None of the units this check reads out of
52
+ # `deploy/` declare an `Alias=`, so this should not currently be reachable
53
+ # in practice; it is kept in the accepted set (rather than omitted or
54
+ # treated as WARN) because, per `systemd.unit`(5), it never means "this
55
+ # will NOT run" — the one thing this check exists to catch — the opposite
56
+ # would risk a false WARN on a legitimately-running unit.
57
+ #
58
+ # systemctl/`coord --version` are both fast, but a wedged one must not eat
59
+ # the ~2s registry budget for the whole tick (mirrors
60
+ # `coord.health.checks.spawned_coord`'s `_SYSTEMCTL_TIMEOUT`).
61
+ _SYSTEMCTL_TIMEOUT = 5.0
62
+
63
+ _ENABLED_STATES = {"enabled", "enabled-runtime", "alias"}
64
+
65
+
66
+ def _is_enabled(unit: str, *, runner=None) -> tuple[str | None, str | None]:
67
+ """`(state, error)` from `systemctl --user is-enabled <unit>`.
68
+
69
+ systemctl exits non-zero for every state that isn't `enabled` —
70
+ `disabled`, `static`, `masked`, `not-found`... — so returncode is
71
+ never the signal here, only stdout is. Split out with an injectable
72
+ *runner* (mirrors `coord.deploy_units.daemon_reload`) so the probe is
73
+ testable without a real systemd.
74
+ """
75
+ run = runner or subprocess.run
76
+ try:
77
+ proc = run(
78
+ ["systemctl", "--user", "is-enabled", unit],
79
+ capture_output=True,
80
+ text=True,
81
+ timeout=_SYSTEMCTL_TIMEOUT,
82
+ )
83
+ except FileNotFoundError:
84
+ return None, "systemctl not found (no systemd on this host)"
85
+ except (OSError, subprocess.SubprocessError) as exc:
86
+ return None, f"{type(exc).__name__}: {exc}"
87
+ state = (proc.stdout or "").strip() or (proc.stderr or "").strip() or "unknown"
88
+ return state, None
89
+
90
+
91
+ @check(
92
+ id="unit_enablement",
93
+ scope="machine",
94
+ title="unit enablement",
95
+ order=48,
96
+ description=(
97
+ "Installed deploy-lane units the per-role manifest "
98
+ "(coord/deploy_manifest.py) says should run are actually "
99
+ "`systemctl --user enable`d, not just present (#2098)."
100
+ ),
101
+ )
102
+ def probe_unit_enablement(ctx: HealthContext) -> list[CheckResult]:
103
+ installed_dir = resolve_systemd_user_dir(ctx)
104
+ results: list[CheckResult] = []
105
+
106
+ for name in all_manifest_units():
107
+ installed_path = installed_dir / name
108
+ if not installed_path.exists():
109
+ # Not this host's topology. `unit_drift` reports the same
110
+ # absence as OK for the same reason (#1831/#1927) — this probe
111
+ # only judges units a host has already chosen to install.
112
+ continue
113
+
114
+ state, error = _is_enabled(name)
115
+ values: dict = {"installed_path": str(installed_path), "state": state}
116
+
117
+ if error:
118
+ results.append(
119
+ CheckResult(
120
+ check_id="unit_enablement",
121
+ scope="machine",
122
+ subject=name,
123
+ severity=Severity.UNKNOWN,
124
+ headroom=f"could not check enablement: {error}",
125
+ error=error,
126
+ values=values,
127
+ )
128
+ )
129
+ continue
130
+
131
+ if state in _ENABLED_STATES:
132
+ results.append(
133
+ CheckResult(
134
+ check_id="unit_enablement",
135
+ scope="machine",
136
+ subject=name,
137
+ severity=Severity.OK,
138
+ headroom=state,
139
+ values=values,
140
+ )
141
+ )
142
+ continue
143
+
144
+ results.append(
145
+ CheckResult(
146
+ check_id="unit_enablement",
147
+ scope="machine",
148
+ subject=name,
149
+ severity=Severity.WARN,
150
+ headroom=(
151
+ f"installed but {state} — a disabled unit and a working "
152
+ "one produce identical evidence until something needed "
153
+ "it (#2098)"
154
+ ),
155
+ detail=f"systemctl --user daemon-reload && systemctl --user enable --now {name}",
156
+ threshold="warn when a manifest-listed, installed unit is not enabled",
157
+ values=values,
158
+ )
159
+ )
160
+
161
+ if not results:
162
+ return [
163
+ CheckResult(
164
+ check_id="unit_enablement",
165
+ scope="machine",
166
+ severity=Severity.OK,
167
+ headroom="no manifest-listed unit installed on this host",
168
+ values={"installed_dir": str(installed_dir)},
169
+ )
170
+ ]
171
+ return results
@@ -0,0 +1,96 @@
1
+ """Stale coordinator worktrees under ``~/.coord/worktrees`` (#1628).
2
+
3
+ Every dispatched worker gets an ephemeral checkout here and
4
+ ``AgentServer._cleanup_worktree`` is supposed to remove it. When cleanup
5
+ doesn't run — a killed daemon, a worker that died mid-task, a dirty tree the
6
+ pruner correctly refuses to delete — the directory survives, and each one
7
+ carries a full checkout (and, on Rust repos, potentially a ``target/``).
8
+ They accumulate silently.
9
+
10
+ **"Stale" here is deliberately mtime-based, not liveness-based.**
11
+ ``coord diagnose --orphan-worktrees`` already does the precise thing —
12
+ cross-reference each worktree's assignment id against live tmux sessions and
13
+ running DB rows — but that needs board state, and board state is H-3's job,
14
+ not this child's. Reading a directory's mtime is one ``stat`` per entry, no
15
+ DB, no network, and a worktree nothing has touched in two days is a
16
+ perfectly good proxy for one nobody is using. When the fleet-scope probes
17
+ land they can sharpen this; until then it is honest and cheap.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from pathlib import Path
23
+
24
+ from coord.health.models import CheckResult, HealthContext, Severity
25
+ from coord.health.registry import check
26
+ from coord.health.units import human_hours
27
+
28
+
29
+ @check(
30
+ id="worktrees",
31
+ scope="machine",
32
+ title="worktrees",
33
+ order=30,
34
+ description="Coordinator worktrees nothing has touched recently.",
35
+ )
36
+ def probe_worktrees(ctx: HealthContext) -> CheckResult | None:
37
+ th = ctx.thresholds
38
+ root: Path = ctx.coord_dir / "worktrees"
39
+ try:
40
+ entries = [e for e in root.iterdir() if e.is_dir() and not e.is_symlink()]
41
+ except OSError:
42
+ # No worktrees dir at all is the normal state on a machine that has
43
+ # never been dispatched to — not a finding.
44
+ return None
45
+
46
+ stale_cutoff = ctx.now - th.worktree_stale_hours * 3600.0
47
+ stale: list[tuple[str, float]] = []
48
+ for entry in entries:
49
+ try:
50
+ mtime = entry.stat().st_mtime
51
+ except OSError:
52
+ continue
53
+ if mtime < stale_cutoff:
54
+ stale.append((entry.name, ctx.now - mtime))
55
+
56
+ count = len(stale)
57
+ if count > th.worktree_crit_count:
58
+ severity = Severity.CRIT
59
+ elif count > th.worktree_warn_count:
60
+ severity = Severity.WARN
61
+ else:
62
+ severity = Severity.OK
63
+
64
+ stale.sort(key=lambda pair: pair[1], reverse=True)
65
+ if count == 0:
66
+ headroom = f"0 stale of {len(entries)}"
67
+ else:
68
+ oldest_name, oldest_age = stale[0]
69
+ headroom = (
70
+ f"{count} stale of {len(entries)} "
71
+ f"(oldest {oldest_name} {human_hours(oldest_age)})"
72
+ )
73
+
74
+ return CheckResult(
75
+ check_id="worktrees",
76
+ scope="machine",
77
+ severity=severity,
78
+ headroom=headroom,
79
+ threshold=f"crit above {th.worktree_crit_count}",
80
+ detail=(
81
+ "prune with `coord diagnose --orphan-worktrees`"
82
+ if severity is not Severity.OK
83
+ else ""
84
+ ),
85
+ values={
86
+ "root": str(root),
87
+ "total": len(entries),
88
+ "stale_count": count,
89
+ "stale_hours_threshold": th.worktree_stale_hours,
90
+ "stale": [
91
+ {"name": name, "age_hours": round(age / 3600.0, 2)} for name, age in stale
92
+ ],
93
+ "warn_count": th.worktree_warn_count,
94
+ "crit_count": th.worktree_crit_count,
95
+ },
96
+ )
coord/health/cli.py ADDED
@@ -0,0 +1,121 @@
1
+ """``coord health`` — run the check registry against this machine (#1628).
2
+
3
+ Deliberately the *only* consumer of the engine in this child. No transport,
4
+ no board write, no daemon: H-1 ends at a CLI you can run by hand. H-3 adds
5
+ the fleet-scope probes and the board projection; H-4 adds renderers beyond
6
+ this one. Both consume ``--json``, whose shape is
7
+ :meth:`coord.health.registry.HealthReport.to_dict`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+
15
+ import click
16
+
17
+ from coord.commands._common import _CONFIG_OPTION
18
+ from coord.health.context import build_context
19
+ from coord.health.models import Severity
20
+ from coord.health.registry import run_all
21
+ from coord.health.render import render_report
22
+
23
+ # Exit codes under --exit-code. Chosen so a timer can distinguish "needs a
24
+ # human now" from "worth a look" without parsing anything.
25
+ EXIT_OK = 0
26
+ EXIT_WARN = 1
27
+ EXIT_CRIT = 2
28
+
29
+
30
+ @click.command("health")
31
+ @_CONFIG_OPTION
32
+ @click.option(
33
+ "--local",
34
+ is_flag=True,
35
+ default=False,
36
+ help=(
37
+ "Restrict to machine- and checkout-scope checks (this machine and its "
38
+ "checkouts). Fleet-scope probes do not exist yet, so today this is "
39
+ "equivalent to the default — pass it to keep the intent explicit."
40
+ ),
41
+ )
42
+ @click.option("--json", "output_json", is_flag=True, default=False,
43
+ help="Emit the machine-readable report instead of the text one.")
44
+ @click.option(
45
+ "--no-network",
46
+ is_flag=True,
47
+ default=False,
48
+ help=(
49
+ "Skip probes that leave the machine (the PyPI simple-index fetch and "
50
+ "the Max-plan usage probe). Skipped checks are listed, never silently "
51
+ "reported as healthy."
52
+ ),
53
+ )
54
+ @click.option("--check", "only", multiple=True,
55
+ help="Run only these check ids (repeatable).")
56
+ @click.option("-v", "--verbose", is_flag=True, default=False,
57
+ help="Show each check's detail/fix line, including for healthy ones.")
58
+ @click.option(
59
+ "--exit-code",
60
+ is_flag=True,
61
+ default=False,
62
+ help=f"Exit {EXIT_CRIT} on any CRIT, {EXIT_WARN} on any WARN (default: always 0).",
63
+ )
64
+ def health(
65
+ config_path: Path,
66
+ local: bool,
67
+ output_json: bool,
68
+ no_network: bool,
69
+ only: tuple[str, ...],
70
+ verbose: bool,
71
+ exit_code: bool,
72
+ ) -> None:
73
+ """Report how much headroom is left on this machine and its checkouts.
74
+
75
+ Read-only and non-destructive by construction: every probe stats, reads,
76
+ or shells out to a read-only command. Nothing here writes, prunes, or
77
+ remediates — deciding what may self-heal is a separate call.
78
+ """
79
+ config = _load_config_or_none(config_path)
80
+ ctx = build_context(config, allow_network=not no_network)
81
+
82
+ if not getattr(ctx.thresholds, "enabled", True):
83
+ # A disabled engine still reports *that* it is disabled — a health
84
+ # check silently switched off looks identical to a healthy fleet.
85
+ if output_json:
86
+ click.echo(json.dumps({"schema": 1, "enabled": False, "results": []}))
87
+ else:
88
+ click.echo("health checks disabled in coordinator.yml (health.enabled: false)")
89
+ return
90
+
91
+ scopes = ("machine", "checkout") if local else None
92
+ report = run_all(ctx, scopes=scopes, only=list(only) or None)
93
+
94
+ if output_json:
95
+ click.echo(json.dumps(report.to_dict(), indent=2, sort_keys=True))
96
+ else:
97
+ click.echo(render_report(report, verbose=verbose))
98
+
99
+ if exit_code:
100
+ if report.severity is Severity.CRIT:
101
+ raise SystemExit(EXIT_CRIT)
102
+ if report.severity is Severity.WARN:
103
+ raise SystemExit(EXIT_WARN)
104
+
105
+
106
+ def _load_config_or_none(config_path: Path):
107
+ """Load ``coordinator.yml``, or carry on without one.
108
+
109
+ A machine with no config still has disks, worktrees, a venv and a
110
+ ``claude`` binary — refusing to report any of that because a YAML file is
111
+ missing would make the check useless on exactly the machine most likely
112
+ to be misconfigured.
113
+ """
114
+ from coord.config import ConfigError, load # noqa: PLC0415
115
+
116
+ try:
117
+ return load(config_path)
118
+ except (ConfigError, OSError) as exc:
119
+ click.echo(f"note: no usable coordinator.yml ({exc}); "
120
+ f"running machine-scope checks only", err=True)
121
+ return None
@@ -0,0 +1,106 @@
1
+ """Build the :class:`~coord.health.models.HealthContext` a run needs (#1628).
2
+
3
+ Kept out of the registry so probes never reach for ``coord.config.load()``
4
+ or ``Path.home()`` themselves — a probe that only reads its context is one
5
+ that a unit test can drive with a fake filesystem and no ``coordinator.yml``.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import socket
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from coord.health.models import Checkout, HealthContext
16
+
17
+
18
+ def local_checkouts(config: Any) -> tuple[Checkout, ...]:
19
+ """The checkouts named in ``coordinator.yml`` that actually exist here.
20
+
21
+ Same scope rule as ``coord diagnose --graph`` / ``--orphan-worktrees``:
22
+ local machine only. Machines whose name/host matches this hostname are
23
+ considered first so a repo configured on several machines resolves to
24
+ *this* box's path; any other machine's path is a fallback for the common
25
+ single-user case where every machine shares a layout.
26
+ """
27
+ if config is None:
28
+ return ()
29
+ try:
30
+ local_hostname = socket.gethostname().split(".")[0]
31
+ except OSError: # pragma: no cover — gethostname basically cannot fail
32
+ local_hostname = ""
33
+
34
+ seen: set[Path] = set()
35
+ # Also deduped by repo NAME, not just path: a repo listed on several
36
+ # machines with different paths, more than one of which happens to exist
37
+ # here, must still be one row in the report. Without this the fallback
38
+ # pass re-adds another machine's path for a repo the hostname-matched
39
+ # pass already resolved, and every checkout-scope check reports it twice
40
+ # — with the *other* machine's answer.
41
+ seen_names: set[str] = set()
42
+ out: list[Checkout] = []
43
+ for pass_no in range(2):
44
+ for machine in getattr(config, "machines", ()) or ():
45
+ on_this_machine = (
46
+ machine.name == local_hostname
47
+ or machine.host.split(".")[0] == local_hostname
48
+ )
49
+ if pass_no == 0 and not on_this_machine:
50
+ continue
51
+ if pass_no == 1 and on_this_machine:
52
+ continue
53
+ for repo in getattr(config, "repos", ()) or ():
54
+ raw = machine.repo_path(repo.name)
55
+ if not raw:
56
+ continue
57
+ if repo.name in seen_names:
58
+ continue
59
+ path = Path(raw).expanduser()
60
+ if path in seen or not (path / ".git").exists():
61
+ continue
62
+ seen.add(path)
63
+ seen_names.add(repo.name)
64
+ out.append(
65
+ Checkout(
66
+ name=repo.name,
67
+ path=path,
68
+ default_branch=getattr(repo, "default_branch", "main") or "main",
69
+ develop_branch=getattr(repo, "develop_branch", None),
70
+ )
71
+ )
72
+ return tuple(out)
73
+
74
+
75
+ def build_context(
76
+ config: Any = None,
77
+ *,
78
+ thresholds: Any = None,
79
+ home: Path | None = None,
80
+ coord_dir: Path | None = None,
81
+ allow_network: bool = True,
82
+ now: float | None = None,
83
+ ) -> HealthContext:
84
+ """Assemble a context from a loaded config (or from nothing at all).
85
+
86
+ ``config=None`` is a supported mode: ``coord health`` on a machine with
87
+ no ``coordinator.yml`` still reports every ``machine``-scope check, it
88
+ just has no checkouts to sweep.
89
+ """
90
+ if thresholds is None:
91
+ if config is not None and getattr(config, "health", None) is not None:
92
+ thresholds = config.health
93
+ else:
94
+ from coord.config import HealthConfig # noqa: PLC0415 — avoid import cycle
95
+
96
+ thresholds = HealthConfig()
97
+ resolved_home = home or Path.home()
98
+ return HealthContext(
99
+ thresholds=thresholds,
100
+ home=resolved_home,
101
+ coord_dir=coord_dir or (resolved_home / ".coord"),
102
+ now=time.time() if now is None else now,
103
+ checkouts=local_checkouts(config),
104
+ config=config,
105
+ allow_network=allow_network,
106
+ )