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,254 @@
1
+ """Are this host's installed systemd *timers* actually enabled and running?
2
+ (#2082)
3
+
4
+ :mod:`coord.health.checks.unit_drift` (#1831) proves an installed unit's
5
+ **content** matches the release. It says nothing about whether systemd was
6
+ ever told to run it — and `coord-release-propagate.timer` demonstrated that
7
+ gap directly. On 2026-08-10 its content matched `deploy/` byte for byte
8
+ (`unit_drift` green, always) while the fleet sat eleven releases behind:
9
+
10
+ $ systemctl --user list-unit-files 'coord-release-propagate*'
11
+ coord-release-propagate.timer disabled enabled
12
+ ^^^^^^^^ ^^^^^^^
13
+ STATE VENDOR PRESET
14
+
15
+ A `.timer` unit's *file* being current says nothing about whether
16
+ ``systemctl --user enable --now`` was ever run on it. Writing (or
17
+ refreshing) a unit's content — by hand, or via
18
+ :func:`coord.deploy_units.install_units` — never implies enabling it;
19
+ those are two independent systemd operations, and the second one has no
20
+ visible symptom until someone thinks to ask "when did this last fire?".
21
+ That is precisely how the timer's off state and its working state produced
22
+ identical files on disk and stayed indistinguishable from the outside for a
23
+ day.
24
+
25
+ Deliberately generic over every packaged ``*.timer`` (not hardcoded to
26
+ ``coord-release-propagate.timer``): every timer this fleet ships
27
+ (``coord-drive-queue``, ``coord-notify``, ``coord-web-dist-build``, this
28
+ one) declares ``[Install] WantedBy=timers.target`` — see ``deploy/*.timer``
29
+ — so "installed but not enabled" is the same defect on any of them, not a
30
+ property of this one release's timer.
31
+
32
+ Reuses :mod:`coord.health.checks.unit_drift`'s reference resolution
33
+ (:func:`~coord.health.checks.unit_drift.resolve_reference`,
34
+ :func:`~coord.health.checks.unit_drift.resolve_systemd_user_dir`) rather
35
+ than a second implementation of "which ``deploy/`` is authoritative" —
36
+ two answers to that question is exactly the class of defect #2096 names:
37
+ two surfaces that happen to agree today and can silently drift apart.
38
+
39
+ Judgement (:func:`grade_timer_state`) is pure and takes plain
40
+ ``systemctl --user show`` fields, so it is unit-testable against a
41
+ hand-built dict with no systemd, no fleet, and no root — same split
42
+ ``spawned_coord.py`` documents for "measure locally, judge centrally".
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import subprocess
48
+
49
+ from coord.health.checks.unit_drift import (
50
+ _unit_files,
51
+ resolve_reference,
52
+ resolve_systemd_user_dir,
53
+ )
54
+ from coord.health.models import CheckResult, HealthContext, Severity
55
+ from coord.health.registry import check
56
+
57
+ # systemctl is fast, but a wedged call must not eat the whole health-tick
58
+ # budget — same rationale/value as spawned_coord._SYSTEMCTL_TIMEOUT.
59
+ _SYSTEMCTL_TIMEOUT = 5.0
60
+
61
+ #: ``UnitFileState`` values that mean "systemd will not fire this unit on
62
+ #: its own schedule", whatever content is on disk.
63
+ _INACTIVE_STATES = frozenset(
64
+ {"disabled", "linked", "linked-runtime", "masked", "masked-runtime"}
65
+ )
66
+
67
+
68
+ def _timer_states(units: tuple[str, ...]) -> dict[str, dict[str, str]]:
69
+ """One ``systemctl --user show`` call for every timer in *units*.
70
+
71
+ Batched into a single subprocess the same way
72
+ ``spawned_coord.running_unit_pids`` batches its own systemctl call: this
73
+ runs on every health tick on every machine, and N timers must cost one
74
+ subprocess, not N. Returns ``{}`` when there is no systemd on this host
75
+ (macOS, a container, a thin client) — that is "no data", handled by the
76
+ caller, not a crash.
77
+ """
78
+ if not units:
79
+ return {}
80
+ argv = [
81
+ "systemctl",
82
+ "--user",
83
+ "show",
84
+ "--property=Id",
85
+ "--property=UnitFileState",
86
+ "--property=UnitFilePreset",
87
+ "--property=ActiveState",
88
+ "--property=SubState",
89
+ *units,
90
+ ]
91
+ try:
92
+ proc = subprocess.run(
93
+ argv,
94
+ capture_output=True,
95
+ text=True,
96
+ timeout=_SYSTEMCTL_TIMEOUT,
97
+ check=False,
98
+ )
99
+ except (OSError, subprocess.SubprocessError):
100
+ return {}
101
+
102
+ out: dict[str, dict[str, str]] = {}
103
+ # `systemctl show` emits one KEY=VALUE block per unit, blank-line
104
+ # separated, same shape spawned_coord already parses defensively.
105
+ for block in proc.stdout.split("\n\n"):
106
+ fields: dict[str, str] = {}
107
+ for line in block.splitlines():
108
+ key, sep, value = line.partition("=")
109
+ if sep:
110
+ fields[key.strip()] = value.strip()
111
+ unit_id = fields.get("Id", "")
112
+ if unit_id:
113
+ out[unit_id] = fields
114
+ return out
115
+
116
+
117
+ def grade_timer_state(fields: dict[str, str]) -> tuple[Severity, str]:
118
+ """Pure judgement over one timer's ``systemctl --user show`` fields.
119
+
120
+ The rule is deliberately simple and does not consult
121
+ ``UnitFilePreset``: a ``.timer`` unit copied into
122
+ ``~/.config/systemd/user/`` is, by construction, meant to run — nobody
123
+ installs a schedule to leave it off, and every packaged timer in this
124
+ fleet declares ``WantedBy=timers.target``. So *installed* implies
125
+ *should be enabled and active*, full stop; the moment that stops being
126
+ true the fleet has exactly the invisible-off-switch problem #2082
127
+ describes. (Contrast ``coord.deploy_units.enable_timers``, the write
128
+ side, which applies the identical rule when it (re)installs a timer's
129
+ content — one invariant, asserted by both the detector and the fixer.)
130
+ """
131
+ state = fields.get("UnitFileState", "")
132
+ active = fields.get("ActiveState", "")
133
+ sub = fields.get("SubState", "")
134
+
135
+ if not state and not active:
136
+ return Severity.UNKNOWN, "systemd reported no state for this unit"
137
+
138
+ if state in _INACTIVE_STATES:
139
+ return (
140
+ Severity.CRIT,
141
+ f"installed but {state.upper()} — a copied .timer unit that is "
142
+ "never enabled will not fire on its own; run 'systemctl --user "
143
+ f"enable --now <unit>' (ActiveState={active or '?'})",
144
+ )
145
+
146
+ if active != "active":
147
+ return (
148
+ Severity.CRIT,
149
+ f"enabled but not active (ActiveState={active or '?'}, "
150
+ f"SubState={sub or '?'}) — check 'systemctl --user status <unit>'",
151
+ )
152
+
153
+ return Severity.OK, f"enabled and active (state={state or '?'})"
154
+
155
+
156
+ @check(
157
+ id="timer_active",
158
+ scope="machine",
159
+ title="timer active",
160
+ order=45,
161
+ description=(
162
+ "Installed systemd *user* timers (deploy/*.timer) are enabled and "
163
+ "active, not merely present with matching content (#2082) — a "
164
+ "disabled timer's file is indistinguishable from an active one's "
165
+ "until something asks systemd whether it actually runs."
166
+ ),
167
+ )
168
+ def probe_timer_active(ctx: HealthContext) -> list[CheckResult]:
169
+ reference = resolve_reference(ctx)
170
+ if reference is None:
171
+ return [
172
+ CheckResult(
173
+ check_id="timer_active",
174
+ scope="machine",
175
+ severity=Severity.OK,
176
+ headroom="no deploy/ checkout found on this machine",
177
+ values={"reference_source": None},
178
+ )
179
+ ]
180
+
181
+ timers = [p.name for p in _unit_files(reference.path) if p.name.endswith(".timer")]
182
+ if not timers:
183
+ return [
184
+ CheckResult(
185
+ check_id="timer_active",
186
+ scope="machine",
187
+ severity=Severity.OK,
188
+ headroom="no timer units in the packaged reference",
189
+ values={"reference_source": reference.source},
190
+ )
191
+ ]
192
+
193
+ installed_dir = resolve_systemd_user_dir(ctx)
194
+ present = [name for name in timers if (installed_dir / name).exists()]
195
+ if not present:
196
+ # The common case — most machines run none of the fleet's timers.
197
+ # Same "absence is OK, not a fault" convention as spawned_coord /
198
+ # cli_venv / tui_binary.
199
+ return [
200
+ CheckResult(
201
+ check_id="timer_active",
202
+ scope="machine",
203
+ severity=Severity.OK,
204
+ headroom="no packaged timer is installed on this machine",
205
+ values={"reference_source": reference.source},
206
+ )
207
+ ]
208
+
209
+ states = _timer_states(tuple(present))
210
+ results: list[CheckResult] = []
211
+ for name in present:
212
+ fields = states.get(name)
213
+ values = {"unit": name, "reference_source": reference.source}
214
+ if fields is None:
215
+ results.append(
216
+ CheckResult(
217
+ check_id="timer_active",
218
+ scope="machine",
219
+ subject=name,
220
+ severity=Severity.UNKNOWN,
221
+ headroom="could not read systemd state for this unit",
222
+ detail=(
223
+ "no systemd on this host, or 'systemctl --user show' "
224
+ "failed — this lane is unverified, not confirmed active"
225
+ ),
226
+ error="systemctl unavailable",
227
+ values=values,
228
+ )
229
+ )
230
+ continue
231
+
232
+ severity, headroom = grade_timer_state(fields)
233
+ values.update(
234
+ {
235
+ "unit_file_state": fields.get("UnitFileState"),
236
+ "unit_file_preset": fields.get("UnitFilePreset"),
237
+ "active_state": fields.get("ActiveState"),
238
+ "sub_state": fields.get("SubState"),
239
+ }
240
+ )
241
+ results.append(
242
+ CheckResult(
243
+ check_id="timer_active",
244
+ scope="machine",
245
+ subject=name,
246
+ severity=severity,
247
+ headroom=headroom,
248
+ detail=f"systemctl --user enable --now {name}" if severity is Severity.CRIT else "",
249
+ threshold="crit when an installed timer is not enabled and active",
250
+ values=values,
251
+ )
252
+ )
253
+
254
+ return results