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,465 @@
1
+ """What a running coord service actually SPAWNS, not what its venv says (#1834).
2
+
3
+ This is the lane that was blind on 2026-08-04, hours after v0.4.105 shipped:
4
+
5
+ =========================================== ========
6
+ readout said
7
+ =========================================== ========
8
+ PyPI simple index 0.4.105
9
+ ``coord status`` (all three agents) 0.4.105
10
+ ``~/.coord-venv/bin/coord version`` 0.4.105
11
+ ``~/.local/bin/coord version`` 0.4.105
12
+ **what the daemon actually spawned** **0.4.103**
13
+ =========================================== ========
14
+
15
+ Every existing lane check reads an *install* — a venv's ``pip show``, a
16
+ binary's mtime, a unit file's text. None of them read the thing that
17
+ actually executes. ``coord-serve``'s hand-installed unit began its
18
+ ``Environment=PATH=`` with an editable checkout two releases behind, and
19
+ :func:`coord.drive.coord_argv` resolves every subprocess with
20
+ ``shutil.which("coord")`` — so the daemon ran the release and everything it
21
+ spawned ran the checkout. Four green readouts, one split brain.
22
+
23
+ :mod:`coord.health.checks.unit_drift` catches the *static* half of this: an
24
+ editable ``.venv/bin`` ahead of a release marker in the unit file's PATH
25
+ line. That is necessary but not sufficient, and it was written from the
26
+ same incident, so it is worth being precise about why both exist:
27
+
28
+ * ``unit_drift`` reads ``deploy/`` and ``~/.config/systemd/user/`` — files.
29
+ It is blind to a unit whose *on-disk* PATH is fine but whose running
30
+ process inherited a different one (``systemctl --user set-environment``, a
31
+ drop-in under ``<unit>.d/``, an ``EnvironmentFile``, a manually-started
32
+ ``coord serve`` in a shell with a dev venv activated), and blind to a PATH
33
+ entry that is not literally spelled ``.venv/bin`` but still resolves
34
+ ``coord`` to a stale tree.
35
+ * This check reads ``/proc/<mainpid>/environ`` — the PATH the kernel is
36
+ actually holding for the live process — resolves ``coord`` on it exactly
37
+ the way ``coord_argv()`` will, and asks *that* binary its version. It
38
+ cannot be fooled by how the PATH got there.
39
+
40
+ The design constraint from #1834, learned the expensive way: **verify the
41
+ running process, not the venv.** ``pip install --upgrade`` silently no-ops
42
+ often enough to be a documented fleet gotcha, so a venv reporting the right
43
+ version proves nothing about what is executing.
44
+
45
+ Two findings, either of which is CRIT on its own:
46
+
47
+ ``version skew``
48
+ The spawned ``coord`` reports a different version from the process doing
49
+ the reporting (an agent that *is* a coord install, on the same box).
50
+ This is 2026-08-04 exactly.
51
+
52
+ ``editable on a service PATH``
53
+ The spawned ``coord`` resolves into a checkout rather than
54
+ site-packages — CRIT **regardless of the version it currently reports**,
55
+ per #1834: an editable install on a service PATH is a drift amplifier
56
+ that silently tracks a checkout nothing keeps current, so today's
57
+ accidental agreement is not evidence of anything.
58
+
59
+ Absence is the common case and is never a fault: a box with no coord user
60
+ units running reports a single OK row, same convention as ``cli_venv`` /
61
+ ``tui_binary`` (:mod:`coord.health.checks.deploy_lane_facts`).
62
+
63
+ Strictly read-only — no ``systemctl start/restart``, no writes anywhere.
64
+ ``coord release verify`` is safe to run mid-flight (#1834's non-goal:
65
+ fixing drift belongs to ``coord agent update``).
66
+ """
67
+
68
+ from __future__ import annotations
69
+
70
+ import os
71
+ import shutil
72
+ import subprocess
73
+ from pathlib import Path
74
+
75
+ from coord import __version__ as OWN_VERSION
76
+ from coord.health.models import CheckResult, HealthContext, Severity
77
+ from coord.health.registry import check
78
+
79
+ # The systemd *user* units in deploy/ that spawn `coord` subprocesses.
80
+ # Overridable via `health.spawned_coord_units` for a fleet that renames them;
81
+ # an empty list disables the check (the one knob here that does mean "off",
82
+ # because the unit *names* are the check's entire subject).
83
+ DEFAULT_UNITS: tuple[str, ...] = (
84
+ "coord-serve",
85
+ "coord-agent",
86
+ "coord-web",
87
+ "coord-drive-queue",
88
+ "coord-notify",
89
+ )
90
+
91
+ # systemctl/`coord --version` are both fast, but a wedged one must not eat
92
+ # the ~2s registry budget for the whole tick.
93
+ _SYSTEMCTL_TIMEOUT = 5.0
94
+ _VERSION_TIMEOUT = 12.0
95
+
96
+ _PROC_ROOT = Path("/proc")
97
+
98
+
99
+ def configured_units(ctx: HealthContext) -> tuple[str, ...]:
100
+ """Unit names to inspect, without the ``.service`` suffix."""
101
+ configured = getattr(ctx.thresholds, "spawned_coord_units", None)
102
+ if configured is None:
103
+ return DEFAULT_UNITS
104
+ return tuple(str(u).removesuffix(".service") for u in configured)
105
+
106
+
107
+ def running_unit_pids(units: tuple[str, ...]) -> dict[str, int]:
108
+ """``unit -> MainPID`` for every one of *units* actually running here.
109
+
110
+ One ``systemctl show`` call for all of them rather than one per unit:
111
+ this runs on every health tick on every machine, and five subprocess
112
+ spawns to learn that a box runs none of them is a bad trade. Units that
113
+ are absent, inactive, or have no main PID are simply left out — the
114
+ per-unit distinction between "not installed" and "installed but stopped"
115
+ belongs to ``systemctl status``, not to a deploy-lane version check.
116
+ """
117
+ if not units:
118
+ return {}
119
+ argv = [
120
+ "systemctl",
121
+ "--user",
122
+ "show",
123
+ "--property=Id",
124
+ "--property=MainPID",
125
+ "--property=ActiveState",
126
+ *[f"{u}.service" for u in units],
127
+ ]
128
+ try:
129
+ proc = subprocess.run(
130
+ argv,
131
+ capture_output=True,
132
+ text=True,
133
+ timeout=_SYSTEMCTL_TIMEOUT,
134
+ check=False,
135
+ )
136
+ except (OSError, subprocess.SubprocessError):
137
+ # No systemd (macOS, a container, a thin client) — not a fault.
138
+ return {}
139
+
140
+ out: dict[str, int] = {}
141
+ # `systemctl show` emits one KEY=VALUE block per unit, blank-line
142
+ # separated. Parse defensively: field order is not contractual.
143
+ for block in proc.stdout.split("\n\n"):
144
+ fields: dict[str, str] = {}
145
+ for line in block.splitlines():
146
+ key, sep, value = line.partition("=")
147
+ if sep:
148
+ fields[key.strip()] = value.strip()
149
+ unit_id = fields.get("Id", "")
150
+ if not unit_id:
151
+ continue
152
+ if fields.get("ActiveState") != "active":
153
+ continue
154
+ try:
155
+ pid = int(fields.get("MainPID", "0"))
156
+ except ValueError:
157
+ continue
158
+ if pid > 0:
159
+ out[unit_id.removesuffix(".service")] = pid
160
+ return out
161
+
162
+
163
+ def process_path(pid: int, *, proc_root: Path | None = None) -> str | None:
164
+ """The ``PATH`` the live process *pid* is actually holding, or None.
165
+
166
+ Read from ``/proc/<pid>/environ`` — the kernel's copy — deliberately
167
+ *not* re-derived from the unit file. A unit whose file says one thing
168
+ and whose process holds another (drop-in, ``EnvironmentFile``,
169
+ ``systemctl --user set-environment``, or a service started by hand from
170
+ a shell with a dev venv activated) is precisely the case a file-reading
171
+ check cannot see, and precisely the case that bit on 2026-08-04.
172
+ """
173
+ root = proc_root or _PROC_ROOT
174
+ try:
175
+ raw = (root / str(pid) / "environ").read_bytes()
176
+ except OSError:
177
+ # Permission denied (another user's process), or the service exited
178
+ # between the systemctl call and here. Either way: no data, not OK.
179
+ return None
180
+ for entry in raw.split(b"\0"):
181
+ key, sep, value = entry.partition(b"=")
182
+ if sep and key == b"PATH":
183
+ return value.decode("utf-8", "replace")
184
+ return None
185
+
186
+
187
+ def resolve_coord(path_value: str) -> str | None:
188
+ """``shutil.which("coord", path=...)`` — the exact call ``coord_argv`` makes.
189
+
190
+ Not an approximation of it: :func:`coord.drive.coord_argv` literally
191
+ calls ``shutil.which("coord")`` against the inherited environment, so
192
+ resolving the same name against the same PATH string is the only way to
193
+ predict which binary a spawned subprocess gets. ``None`` here is the
194
+ benign case — ``coord_argv`` then falls back to ``[sys.executable, "-m",
195
+ "coord.cli"]``, i.e. the *parent's own* install, which by construction
196
+ cannot be skewed against the parent.
197
+
198
+ KNOWN BLIND SPOT: ``coord_argv()`` checks ``$COORD_DRIVE_COORD_BIN``
199
+ *before* ever calling ``shutil.which`` and, if set, returns that override
200
+ verbatim — this function has no way to see that env var (it isn't part
201
+ of ``PATH``, which is all ``/proc/<pid>/environ`` gives this check reason
202
+ to read) or predict it. A live service whose environment sets that
203
+ variable would have its actual spawn target silently diverge from what
204
+ this lane reports, in either direction (a false OK or a false CRIT,
205
+ depending on what happens to also resolve on PATH). It is documented on
206
+ ``coord_argv`` as existing "for tests" but is not fenced off from
207
+ production, so this is a real if unlikely gap — not a hypothetical one.
208
+ """
209
+ if not path_value:
210
+ return None
211
+ return shutil.which("coord", path=path_value)
212
+
213
+
214
+ def spawned_identity(binary: str) -> tuple[str | None, str | None, str | None]:
215
+ """``(version, module_file, error)`` for the ``coord`` at *binary*.
216
+
217
+ Resolved through the console script's shebang interpreter and a bare
218
+ ``import coord`` rather than ``coord --version``: importing the package
219
+ is ~20x cheaper than building the whole click command tree, and it also
220
+ yields ``coord.__file__``, which is what tells site-packages (a release)
221
+ apart from a checkout (an editable install). ``coord --version`` is the
222
+ fallback for an exotic console script with no readable shebang — it
223
+ gives the version but no module path, so editability then reads as
224
+ "unknown" rather than being guessed.
225
+ """
226
+ interpreter = _shebang_interpreter(binary)
227
+ if interpreter:
228
+ code = "import coord;print(coord.__version__);print(coord.__file__)"
229
+ try:
230
+ proc = subprocess.run(
231
+ [interpreter, "-c", code],
232
+ capture_output=True,
233
+ text=True,
234
+ timeout=_VERSION_TIMEOUT,
235
+ check=False,
236
+ )
237
+ except (OSError, subprocess.SubprocessError) as exc:
238
+ return None, None, f"{type(exc).__name__}: {exc}"
239
+ if proc.returncode == 0:
240
+ lines = proc.stdout.strip().splitlines()
241
+ if len(lines) >= 2:
242
+ return lines[0].strip(), lines[1].strip(), None
243
+ if lines:
244
+ return lines[0].strip(), None, None
245
+ return None, None, (proc.stderr or proc.stdout).strip()[:300] or "no output"
246
+
247
+ try:
248
+ proc = subprocess.run(
249
+ [binary, "--version"],
250
+ capture_output=True,
251
+ text=True,
252
+ timeout=_VERSION_TIMEOUT,
253
+ check=False,
254
+ )
255
+ except (OSError, subprocess.SubprocessError) as exc:
256
+ return None, None, f"{type(exc).__name__}: {exc}"
257
+ if proc.returncode != 0:
258
+ return None, None, (proc.stderr or proc.stdout).strip()[:300] or "no output"
259
+ # "coord, version 0.4.108"
260
+ token = proc.stdout.strip().rsplit(" ", 1)[-1] if proc.stdout.strip() else ""
261
+ return (token or None), None, None
262
+
263
+
264
+ def _shebang_interpreter(binary: str) -> str | None:
265
+ try:
266
+ with open(binary, "rb") as fh:
267
+ first = fh.readline(512)
268
+ except OSError:
269
+ return None
270
+ if not first.startswith(b"#!"):
271
+ return None
272
+ parts = first[2:].decode("utf-8", "replace").strip().split()
273
+ if not parts:
274
+ return None
275
+ # `#!/usr/bin/env python3` — take the argument, not `env` itself.
276
+ if os.path.basename(parts[0]) == "env" and len(parts) > 1:
277
+ return parts[1]
278
+ return parts[0]
279
+
280
+
281
+ def is_editable(module_file: str | None) -> bool | None:
282
+ """Whether *module_file* is a checkout rather than an installed release.
283
+
284
+ ``None`` when there is no module path to judge (the ``coord --version``
285
+ fallback above) — deliberately not ``False``: #1834 makes "editable on a
286
+ service PATH" a finding in its own right, so guessing "not editable"
287
+ from missing evidence would silence exactly the finding this exists for.
288
+ """
289
+ if not module_file:
290
+ return None
291
+ return "site-packages" not in Path(module_file).parts
292
+
293
+
294
+ @check(
295
+ id="spawned_coord",
296
+ scope="machine",
297
+ title="spawned coord",
298
+ order=46,
299
+ description=(
300
+ "The `coord` that each running coord service would actually spawn — "
301
+ "resolved from the live process's own PATH via shutil.which, the way "
302
+ "coord_argv() does — matches the version reporting it, and is not an "
303
+ "editable checkout (#1834, the 2026-08-04 daemon-spawns-N-2 blind spot)."
304
+ ),
305
+ )
306
+ def probe_spawned_coord(ctx: HealthContext) -> list[CheckResult]:
307
+ units = configured_units(ctx)
308
+ pids = running_unit_pids(units)
309
+
310
+ if not pids:
311
+ # Thin clients, workers with no user units, macOS, containers. Not a
312
+ # fault, and not UNKNOWN: there is genuinely no spawning service here.
313
+ return [
314
+ CheckResult(
315
+ check_id="spawned_coord",
316
+ scope="machine",
317
+ severity=Severity.OK,
318
+ headroom="no coord service running on this machine",
319
+ values={"units": list(units), "running": {}, "own_version": OWN_VERSION},
320
+ )
321
+ ]
322
+
323
+ results: list[CheckResult] = []
324
+ # One resolve+version subprocess per DISTINCT binary, not per unit: the
325
+ # five units normally share one PATH, and this runs on every tick.
326
+ identities: dict[str, tuple[str | None, str | None, str | None]] = {}
327
+
328
+ for unit in sorted(pids):
329
+ pid = pids[unit]
330
+ values: dict = {"unit": unit, "pid": pid, "own_version": OWN_VERSION}
331
+
332
+ path_value = process_path(pid)
333
+ if path_value is None:
334
+ results.append(
335
+ CheckResult(
336
+ check_id="spawned_coord",
337
+ scope="machine",
338
+ subject=unit,
339
+ severity=Severity.UNKNOWN,
340
+ headroom=f"could not read PATH of pid {pid}",
341
+ detail=(
342
+ "/proc/<pid>/environ is unreadable — the service runs "
343
+ "as another user, or exited mid-probe. This lane is "
344
+ "unverified, which is not the same as in sync."
345
+ ),
346
+ error="environ unreadable",
347
+ values={**values, "path": None, "version": None},
348
+ )
349
+ )
350
+ continue
351
+
352
+ values["path"] = path_value
353
+ resolved = resolve_coord(path_value)
354
+ values["resolved"] = resolved
355
+
356
+ if resolved is None:
357
+ # coord_argv() falls back to `sys.executable -m coord.cli`, i.e.
358
+ # the spawning process's OWN install — structurally unskewable.
359
+ results.append(
360
+ CheckResult(
361
+ check_id="spawned_coord",
362
+ scope="machine",
363
+ subject=unit,
364
+ severity=Severity.OK,
365
+ headroom="no `coord` on the service PATH — subprocesses use the parent's own interpreter",
366
+ detail=(
367
+ "coord_argv() falls back to `python -m coord.cli` "
368
+ "(coord/drive.py), which cannot disagree with the "
369
+ "process that spawned it."
370
+ ),
371
+ values={**values, "fallback": True, "version": None},
372
+ )
373
+ )
374
+ continue
375
+
376
+ if resolved not in identities:
377
+ identities[resolved] = spawned_identity(resolved)
378
+ version, module_file, error = identities[resolved]
379
+ editable = is_editable(module_file)
380
+ values.update(
381
+ {
382
+ "version": version,
383
+ "module_file": module_file,
384
+ "editable": editable,
385
+ "fallback": False,
386
+ }
387
+ )
388
+
389
+ if version is None:
390
+ results.append(
391
+ CheckResult(
392
+ check_id="spawned_coord",
393
+ scope="machine",
394
+ subject=unit,
395
+ severity=Severity.UNKNOWN,
396
+ headroom=f"{resolved} would not report a version",
397
+ detail=(
398
+ "the binary this unit's PATH resolves `coord` to could "
399
+ "not be introspected — treat as unverified, not as in sync"
400
+ ),
401
+ error=error,
402
+ values=values,
403
+ )
404
+ )
405
+ continue
406
+
407
+ # Editable first: #1834 makes it a finding independent of the version
408
+ # it happens to report today, so it must not be masked by a version
409
+ # that currently agrees.
410
+ if editable:
411
+ results.append(
412
+ CheckResult(
413
+ check_id="spawned_coord",
414
+ scope="machine",
415
+ subject=unit,
416
+ severity=Severity.CRIT,
417
+ headroom=f"spawns an EDITABLE checkout ({version}) via {resolved}",
418
+ detail=(
419
+ f"`coord` on this unit's PATH resolves to {module_file} "
420
+ "— a checkout, not an installed release. Every "
421
+ "subprocess this service spawns runs whatever branch "
422
+ "that tree is parked on, tracked by no release. Put "
423
+ "~/.local/bin or ~/.coord-venv/bin first on the unit's "
424
+ "Environment=PATH= (#1834)."
425
+ ),
426
+ threshold="crit for any editable install on a service PATH",
427
+ values=values,
428
+ )
429
+ )
430
+ continue
431
+
432
+ if version != OWN_VERSION:
433
+ results.append(
434
+ CheckResult(
435
+ check_id="spawned_coord",
436
+ scope="machine",
437
+ subject=unit,
438
+ severity=Severity.CRIT,
439
+ headroom=f"spawns {version}, but this install is {OWN_VERSION}",
440
+ detail=(
441
+ f"{resolved} is what shutil.which(\"coord\") resolves to "
442
+ f"on this unit's live PATH, so every subprocess it "
443
+ f"spawns runs {version} while the service itself runs "
444
+ f"{OWN_VERSION}. This is the 2026-08-04 split brain "
445
+ "(#1834) — four other readouts said the fleet was on "
446
+ "one version while it was running two."
447
+ ),
448
+ threshold="crit when the spawned version differs from the spawning one",
449
+ values=values,
450
+ )
451
+ )
452
+ continue
453
+
454
+ results.append(
455
+ CheckResult(
456
+ check_id="spawned_coord",
457
+ scope="machine",
458
+ subject=unit,
459
+ severity=Severity.OK,
460
+ headroom=f"spawns {version} (matches)",
461
+ values=values,
462
+ )
463
+ )
464
+
465
+ return results