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.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- 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
|