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,1339 @@
1
+ """The `coord agent` group: per-machine agent server lifecycle
2
+ (start/update/restart/clean-worktrees) plus `pause`/`unpause`.
3
+ Extracted from coord/cli.py (#747)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+ import socket
9
+ import subprocess
10
+ import sys
11
+ import time
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ import click
17
+ import httpx
18
+
19
+ from coord import __version__
20
+ from coord.config import Config
21
+ from coord.dist_name import CANDIDATE_NAMES
22
+
23
+ if TYPE_CHECKING: # pragma: no cover — typing only
24
+ from collections.abc import Callable
25
+
26
+ from coord.commands._common import (
27
+ AGENT_PORT,
28
+ _CONFIG_OPTION,
29
+ _load_config,
30
+ server_extra_guard,
31
+ )
32
+
33
+
34
+ @click.group(
35
+ invoke_without_command=True,
36
+ help=(
37
+ "Agent server management. Without a subcommand, starts the agent "
38
+ "server on this machine (port 7433)."
39
+ ),
40
+ )
41
+
42
+
43
+ @_CONFIG_OPTION
44
+ @click.option(
45
+ "--machine",
46
+ "machine_name",
47
+ default=None,
48
+ help="Machine name from coordinator.yml (defaults to hostname match).",
49
+ )
50
+
51
+
52
+ @click.option("--host", "bind_host", default="0.0.0.0", show_default=True)
53
+ @click.option("--port", "bind_port", default=AGENT_PORT, show_default=True, type=int)
54
+ @click.pass_context
55
+ def agent(
56
+ ctx: click.Context,
57
+ config_path: Path,
58
+ machine_name: str | None,
59
+ bind_host: str,
60
+ bind_port: int,
61
+ ) -> None:
62
+ ctx.ensure_object(dict)
63
+ ctx.obj.update(
64
+ config_path=config_path,
65
+ machine_name=machine_name,
66
+ bind_host=bind_host,
67
+ bind_port=bind_port,
68
+ )
69
+ if ctx.invoked_subcommand is None:
70
+ _start_agent_server(config_path, machine_name, bind_host, bind_port)
71
+
72
+
73
+ def _startup_diagnostic_lines(
74
+ capabilities: list[str], *, path_env: str | None = None
75
+ ) -> list[str]:
76
+ """Lines to log once at agent startup so the #1671 failure class is
77
+ visible in `journalctl --user -u coord-agent` right after a restart —
78
+ no hand-run `coord doctor` (or SSH + `/proc/<pid>/environ`) required.
79
+
80
+ #1671: every machine's `rust` capability read unmet even though `cargo`
81
+ was installed, because the *capability probe* resolves through the
82
+ *agent process's* PATH — and a systemd user unit's PATH is minimal
83
+ (omits `~/.cargo/bin`) unless the unit says otherwise (see
84
+ `deploy/coord-agent.service`). The probe result alone doesn't say
85
+ *why* a tool is missing; logging the resolved PATH plus any declared
86
+ capability that its own probe contradicts turns "mysteriously unmet"
87
+ into "read the last agent restart's log line."
88
+
89
+ Pure function (no I/O of its own) so it's cheaply testable without
90
+ mocking `click.echo`/subprocess at the call site — callers pass in
91
+ already-probed data.
92
+ """
93
+ from coord.prereqs import probe_all, tool_versions_summary, unmet_capabilities
94
+
95
+ lines = [f"coord agent: PATH={path_env if path_env is not None else os.environ.get('PATH', '')}"]
96
+
97
+ probes = probe_all(capabilities)
98
+ unmet = unmet_capabilities(capabilities, probes)
99
+ if not unmet:
100
+ lines.append(
101
+ f"coord agent: capabilities {capabilities} all probe OK "
102
+ f"({tool_versions_summary(probes)})"
103
+ )
104
+ else:
105
+ for cap, reasons in unmet.items():
106
+ for reason in reasons:
107
+ lines.append(
108
+ f"coord agent: WARNING capability '{cap}' declared in "
109
+ f"coordinator.yml but its own probe disagrees: {reason} "
110
+ f"— dispatch_smoke will refuse to route to this machine "
111
+ f"for '{cap}'-gated work (#1570 D) until this is fixed"
112
+ )
113
+ return lines
114
+
115
+
116
+ def _log_install_location() -> str:
117
+ """One line describing how *this* agent process's coordinator distro
118
+ (whichever of :data:`coord.dist_name.CANDIDATE_NAMES` resolves — #2103)
119
+ is installed — editable (a dev checkout, #1628's flagged risk) vs a
120
+ normal PyPI/site-packages install — logged once at startup so it's
121
+ visible without a separate `coord health` run.
122
+
123
+ Best-effort: any failure to determine this (pip missing, `pip show`
124
+ timing out, ...) degrades to a note saying so rather than blowing up
125
+ agent startup over a diagnostic.
126
+ """
127
+ from coord.health.checks.agent_install import pip_show
128
+
129
+ try:
130
+ fields = pip_show(Path(sys.executable))
131
+ except (OSError, subprocess.SubprocessError) as exc:
132
+ return f"coord agent: install location unknown ({type(exc).__name__}: {exc})"
133
+
134
+ if not fields:
135
+ tried = " or ".join(CANDIDATE_NAMES)
136
+ return f"coord agent: install location unknown (pip show returned nothing for {tried})"
137
+
138
+ name = fields.get("Name", "/".join(CANDIDATE_NAMES))
139
+ version = fields.get("Version", "?")
140
+ editable_location = fields.get("Editable project location") or ""
141
+ if editable_location:
142
+ return (
143
+ f"coord agent: {name} {version} — EDITABLE at "
144
+ f"{editable_location} (not a PyPI install — see #1628)"
145
+ )
146
+ location = fields.get("Location", "")
147
+ return f"coord agent: {name} {version} — pypi install at {location}"
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class _AgentStartup:
152
+ """Everything :func:`_start_agent_server` needs out of the config layer,
153
+ resolved in one place so the *order* of resolution is directly testable
154
+ without booting uvicorn (#1712).
155
+
156
+ ``config_free_reason`` is ``None`` on the normal path and a human-readable
157
+ explanation when the agent genuinely could not obtain a config from any
158
+ source — it rides into ``/health`` so a capability-less agent is
159
+ distinguishable from a misconfigured one.
160
+ """
161
+
162
+ machine: Any
163
+ health_config: Any | None = None
164
+ concurrency: Any = None
165
+ artifact_paths: dict[str, list[str]] = field(default_factory=dict)
166
+ build_commands: dict[str, str] = field(default_factory=dict)
167
+ providers: dict[str, object] = field(default_factory=dict)
168
+ config_free_reason: str | None = None
169
+ notices: tuple[str, ...] = ()
170
+
171
+
172
+ def _load_agent_config(
173
+ config_path: Path,
174
+ svc: Any | None,
175
+ *,
176
+ attempts: int = 3,
177
+ retry_delay: float = 2.0,
178
+ sleep: "Callable[[float], None]" = time.sleep,
179
+ ) -> Config:
180
+ """Load the coordinator config for `coord agent`, retrying a thin-client
181
+ fetch before giving up — and NEVER degrading to config-free mode (#1712).
182
+
183
+ ``_load_config`` already knows how to obtain a config without a local file
184
+ (#1080's thin-client ``GET /config``); it exits(2) when it can't. The only
185
+ thing this wrapper adds is (a) a bounded retry when the config source is a
186
+ *network* one — the agent and the daemon often start at the same time after
187
+ a reboot, and a 2-second-early agent must not lose the whole fleet's
188
+ capability routing over it — and (b) a loud explanation of what the agent
189
+ refused to do, so the journal says "could not reach the daemon" rather than
190
+ leaving an operator to infer it from an empty ``capabilities`` list days
191
+ later (the #1673 / #1712 failure).
192
+ """
193
+ last_exit: SystemExit | None = None
194
+ for attempt in range(1, max(1, attempts) + 1):
195
+ try:
196
+ return _load_config(config_path)
197
+ except SystemExit as exc:
198
+ last_exit = exc
199
+ # Only a remote source is worth retrying; a malformed/absent local
200
+ # file will fail identically every time.
201
+ if svc is None or attempt >= attempts:
202
+ break
203
+ click.echo(
204
+ f"coord agent: config load failed (attempt {attempt}/{attempts}) "
205
+ f"— retrying in {retry_delay:g}s",
206
+ err=True,
207
+ )
208
+ sleep(retry_delay)
209
+
210
+ local_state = "present" if config_path.exists() else "ABSENT"
211
+ svc_state = f"configured ({svc.url})" if svc is not None else "not configured"
212
+ click.echo(
213
+ "coord agent: FATAL — could not load a coordinator config.\n"
214
+ f" local coordinator.yml: {config_path} ({local_state})\n"
215
+ f" board service: {svc_state}\n"
216
+ " Refusing to fall back to config-free mode: this machine would come "
217
+ "up publishing capabilities=[] and repos=[], silently ineligible for "
218
+ "capability-matched routing with nothing anywhere reporting an error "
219
+ "(#1712). Fix the config source and restart.",
220
+ err=True,
221
+ )
222
+ raise last_exit if last_exit is not None else SystemExit(2)
223
+
224
+
225
+ def _resolve_agent_startup(
226
+ config_path: Path,
227
+ machine_name: str | None,
228
+ *,
229
+ sleep: "Callable[[float], None]" = time.sleep,
230
+ attempts: int = 3,
231
+ retry_delay: float = 2.0,
232
+ ) -> _AgentStartup:
233
+ """Resolve this agent's machine identity + config-derived settings.
234
+
235
+ #1712 — THE ORDER HERE IS THE FIX. The old code branched on
236
+ ``not config_path.exists() and machine_name`` *first*, so passing
237
+ ``--machine`` on a host with no local ``coordinator.yml`` entered
238
+ config-free mode and never even attempted the thin-client daemon fetch
239
+ that lives inside ``_load_config`` (#1080). Two machines with byte-identical
240
+ config availability (no local file, daemon reachable) published different
241
+ capabilities purely because one systemd unit passed ``--machine`` and the
242
+ other didn't — the more explicit invocation being the broken one. So:
243
+ ``--machine`` MUST NOT change which config source is used.
244
+
245
+ The old guard conflated two different conditions:
246
+
247
+ * *"there is no local config file"* — often true and completely fine,
248
+ because the daemon has one;
249
+ * *"there is no config obtainable at all"* — the only condition that may
250
+ trigger config-free mode.
251
+
252
+ Config-free mode is therefore the LAST resort: it needs no local file
253
+ **and** no board service configured. That genuine case (ephemeral Azure
254
+ workers, docs/EPHEMERAL_WORKERS.md) still starts, still publishes
255
+ ``capabilities=[]``, and still does not crash — it just says so out loud
256
+ now, and carries a reason into ``/health`` so a legitimately config-free
257
+ worker is distinguishable from a machine whose declared capabilities
258
+ vanished.
259
+ """
260
+ from coord.client import resolve_board_service # noqa: PLC0415
261
+ from coord.config import ConcurrencyConfig as _ConcurrencyConfig # noqa: PLC0415
262
+
263
+ svc = resolve_board_service()
264
+ has_local = config_path.exists()
265
+
266
+ if not has_local and svc is None:
267
+ # Genuine config-free mode: nothing to load from, anywhere.
268
+ if not machine_name:
269
+ click.echo(
270
+ f"error: no coordinator.yml at {config_path}, no board service "
271
+ "configured (~/.coord/client.toml / $COORD_SERVICE_URL), and no "
272
+ "--machine given — this agent cannot determine its own identity. "
273
+ "Pass --machine NAME to run config-free "
274
+ "(docs/EPHEMERAL_WORKERS.md), or configure a board service.",
275
+ err=True,
276
+ )
277
+ sys.exit(2)
278
+ from coord.models import Machine as _Machine # noqa: PLC0415
279
+
280
+ reason = (
281
+ f"no local coordinator.yml at {config_path} and no board service "
282
+ "configured — running config-free (capabilities and repos come "
283
+ "from the coordinator at dispatch time)"
284
+ )
285
+ return _AgentStartup(
286
+ machine=_Machine(
287
+ name=machine_name,
288
+ host="localhost",
289
+ capabilities=[],
290
+ repos=[],
291
+ repo_paths={},
292
+ ),
293
+ concurrency=_ConcurrencyConfig(),
294
+ config_free_reason=reason,
295
+ # #1712 item 2: never let this be silent. #1671's startup
296
+ # diagnostics iterate over `machine.capabilities`, so with an
297
+ # empty list they print nothing in exactly the case that most
298
+ # needs a signal.
299
+ notices=(f"coord agent: NOTICE {reason}",),
300
+ )
301
+
302
+ cfg = _load_agent_config(
303
+ config_path, svc, attempts=attempts, retry_delay=retry_delay, sleep=sleep
304
+ )
305
+ machine = _resolve_machine(cfg, machine_name)
306
+
307
+ from coord.providers import build_provider as _build_provider # noqa: PLC0415
308
+
309
+ providers_registry: dict[str, object] = {}
310
+ # #425: instantiate each named provider so the agent can dispatch to it
311
+ # when an assignment names it (spec.provider). An unknown provider type
312
+ # raises ValueError from build_provider — surface it as a startup failure
313
+ # rather than silently dropping the definition, so operators notice
314
+ # misconfiguration early.
315
+ for prov_name, defn in cfg.providers.definitions.items():
316
+ providers_registry[prov_name] = _build_provider(prov_name, defn, cfg.models)
317
+
318
+ notices: list[str] = []
319
+ source = "daemon (thin client)" if svc is not None else str(config_path)
320
+ notices.append(
321
+ f"coord agent: config source={source} machine={machine.name} "
322
+ f"capabilities={list(machine.capabilities)}"
323
+ )
324
+ if not machine.capabilities:
325
+ # Declaring no capabilities is legal, but it means dispatch_smoke can
326
+ # never pick this machine for capability-gated work — say so once at
327
+ # startup rather than letting it read as "healthy, just quiet" (#1712).
328
+ notices.append(
329
+ f"coord agent: NOTICE machine {machine.name!r} declares NO "
330
+ "capabilities in coordinator.yml — it is ineligible for every "
331
+ "capability-matched dispatch (#1570 D)"
332
+ )
333
+
334
+ return _AgentStartup(
335
+ machine=machine,
336
+ health_config=cfg,
337
+ concurrency=cfg.concurrency,
338
+ # #305: collect artifact_paths per repo for the stash helper.
339
+ artifact_paths={r.name: r.artifact_paths for r in cfg.repos if r.artifact_paths},
340
+ # #1323 (fix #3): collect build_command per repo so _stash_artifacts
341
+ # can run it in the worktree before globbing, ensuring the binary
342
+ # exists regardless of the worker's dev-loop feature flags.
343
+ build_commands={r.name: r.build_command for r in cfg.repos if r.build_command},
344
+ providers=providers_registry,
345
+ notices=tuple(notices),
346
+ )
347
+
348
+
349
+ def _start_agent_server(
350
+ config_path: Path,
351
+ machine_name: str | None,
352
+ bind_host: str,
353
+ bind_port: int,
354
+ ) -> None:
355
+ """Internal helper: start the uvicorn-backed agent server."""
356
+ # #1237: these imports are function-local *and* guarded so (a) `import
357
+ # coord.cli` stays client-clean on a base install and (b) hitting `coord
358
+ # agent` there says "install the [server] extra" instead of raising a raw
359
+ # ModuleNotFoundError.
360
+ with server_extra_guard("agent"):
361
+ import uvicorn
362
+
363
+ from coord.agent import AgentServer
364
+ from coord.agent_app import build_app
365
+
366
+ startup = _resolve_agent_startup(config_path, machine_name)
367
+ machine = startup.machine
368
+ concurrency = startup.concurrency
369
+
370
+ server = AgentServer(
371
+ machine_name=machine.name,
372
+ capabilities=machine.capabilities,
373
+ repos=machine.repos,
374
+ repo_paths=machine.repo_paths,
375
+ bash_wrap_spawn=concurrency.bash_wrap_spawn,
376
+ first_output_timeout=concurrency.first_output_timeout,
377
+ artifact_paths=startup.artifact_paths,
378
+ build_commands=startup.build_commands,
379
+ providers=startup.providers,
380
+ # #1630: the loaded Config, threaded into AgentServer purely so
381
+ # /health's periodic local check run can resolve this machine's
382
+ # checkouts (coord.health.context.build_context) the same way
383
+ # `coord health` does. None in config-free mode — the health engine
384
+ # still reports every machine-scope check, just with no checkouts to
385
+ # sweep (same fallback `coord health` itself uses with no
386
+ # coordinator.yml).
387
+ health_config=startup.health_config,
388
+ # #1712: rides into /health so `coord doctor` can tell a legitimately
389
+ # config-free ephemeral worker from a machine whose declared
390
+ # capabilities went missing.
391
+ config_free_reason=startup.config_free_reason,
392
+ )
393
+ app = build_app(server)
394
+ # #1671: loud-by-default startup diagnostics — resolved PATH, install
395
+ # location, and any declared capability this machine's own probe
396
+ # contradicts — so the class of failure in #1671 shows up in
397
+ # `journalctl --user -u coord-agent` on the very next restart instead
398
+ # of needing an operator to notice a `coord doctor` red and go SSH in.
399
+ click.echo(_log_install_location())
400
+ # #1712: config-source / capability notices go FIRST — with
401
+ # capabilities=[] the #1671 loop below has nothing to say, which is
402
+ # exactly the case that most needs a signal.
403
+ for line in startup.notices:
404
+ click.echo(line)
405
+ for line in _startup_diagnostic_lines(machine.capabilities):
406
+ click.echo(line)
407
+ click.echo(
408
+ f"coord agent: machine={machine.name} repos={machine.repos} "
409
+ f"listening on http://{bind_host}:{bind_port}"
410
+ )
411
+ try:
412
+ uvicorn.run(app, host=bind_host, port=bind_port, log_level="info")
413
+ finally:
414
+ server.shutdown()
415
+
416
+
417
+ def _resolve_target_version(
418
+ explicit_version: str | None,
419
+ *,
420
+ own_version: str = __version__,
421
+ index_url: str = "https://pypi.org/simple",
422
+ timeout: float = 5.0,
423
+ ) -> tuple[str, list[str]]:
424
+ """Resolve the version `coord agent update` should target.
425
+
426
+ #1886 Path A: the target used to come straight from this CLI's own
427
+ ``__version__`` — reasonable-sounding ("bring the fleet in line with
428
+ whatever's running here"), but wrong the instant the operator's own
429
+ install is behind PyPI: a stale CLI silently under-updates the entire
430
+ fleet and still reports success on every machine, because success was
431
+ judged against the wrong target from the start. The target must come
432
+ from PyPI's simple index instead — the same source ``pip install -U``
433
+ itself resolves against (see ``coord.health.pypi`` for why the simple
434
+ index and not the JSON API) — so a stale operator CLI either targets
435
+ the real, newer release or says so loudly, never silently targets its
436
+ own age.
437
+
438
+ ``explicit_version`` (``--version``) always wins and skips the network
439
+ call entirely — the documented escape hatch for pinning to a rollback
440
+ or a pre-release on purpose.
441
+
442
+ Returns ``(target_version, warning_lines)``. Warnings are informational
443
+ except for the "operator CLI is behind PyPI" case, where the returned
444
+ target is the newer PyPI release (never ``own_version``) — that's the
445
+ whole point of resolving from PyPI instead of trusting the caller.
446
+ A PyPI lookup failure (network down, unparseable index, ...) degrades
447
+ to targeting ``own_version`` with a warning rather than failing the
448
+ whole command — this fix must not turn "no network" into "can no
449
+ longer update the fleet at all."
450
+ """
451
+ if explicit_version:
452
+ return explicit_version, []
453
+
454
+ from coord.health.pypi import latest_release_any, parse_version # noqa: PLC0415
455
+
456
+ try:
457
+ project, latest, _finals = latest_release_any(index_url=index_url, timeout=timeout)
458
+ except Exception as exc: # noqa: BLE001 — degrade, don't fail the update
459
+ return own_version, [
460
+ "⚠ could not resolve the latest release from PyPI's simple "
461
+ f"index ({type(exc).__name__}: {exc}) — targeting this CLI's "
462
+ f"own version v{own_version} instead. Pass --version to pin "
463
+ "explicitly, or fix network access so this can be verified.",
464
+ ]
465
+
466
+ if latest is None:
467
+ return own_version, [
468
+ "⚠ PyPI's simple index returned no parseable releases under "
469
+ f"{' or '.join(CANDIDATE_NAMES)} — targeting this CLI's own "
470
+ f"version v{own_version} instead. Pass --version to pin "
471
+ "explicitly.",
472
+ ]
473
+
474
+ own = parse_version(own_version)
475
+ if own is None or latest > own:
476
+ return latest.raw, [
477
+ f"⚠ this operator CLI is v{own_version} but PyPI's latest "
478
+ f"release ({project}) is v{latest.raw} — targeting v{latest.raw} "
479
+ "(the PyPI release), not this CLI's own version. This CLI's own "
480
+ f"`{project}` install is stale; consider `pip install --upgrade "
481
+ f"{project}` here too.",
482
+ ]
483
+
484
+ return own_version, []
485
+
486
+
487
+ @agent.command(
488
+ "update",
489
+ help=(
490
+ "POST /update to one or all agent servers, pinning the upgrade to "
491
+ "the latest release on PyPI's simple index (or --version, when "
492
+ "given) — NOT this CLI's own version (#1886: a stale operator "
493
+ "install must never silently under-update the fleet). #1241: each "
494
+ "agent installs the target version into a FRESH venv slot, "
495
+ "smoke-checks it, then atomically swaps it into place — an "
496
+ "in-flight update can never leave a torn/partial install for a "
497
+ "concurrent `coord` invocation to observe. An editable install "
498
+ "(`pip install -e .`) is refused outright, never silently `git "
499
+ "pull`ed. An agent with live (RUNNING/PENDING) assignments also "
500
+ "refuses unless --force is given, since the restart-after-swap "
501
+ "kills them mid-flight. Polls each agent's self-reported "
502
+ "*running* version for up to --timeout seconds and reports "
503
+ "success only once it matches the requested version, escalating "
504
+ "to a `systemctl --user restart coord-agent` if the version is "
505
+ "stuck."
506
+ ),
507
+ )
508
+
509
+
510
+ @_CONFIG_OPTION
511
+ @click.option(
512
+ "--machine",
513
+ "machine_filter",
514
+ default=None,
515
+ help="Name of a single machine to update (from coordinator.yml).",
516
+ )
517
+
518
+
519
+ @click.option(
520
+ "--all",
521
+ "all_machines",
522
+ is_flag=True,
523
+ help="Update all machines (mutually exclusive with --machine).",
524
+ )
525
+
526
+
527
+ @click.option(
528
+ "--version",
529
+ "version_override",
530
+ default=None,
531
+ help=(
532
+ "Pin the upgrade to this exact version instead of resolving the "
533
+ "latest release from PyPI's simple index (#1886)."
534
+ ),
535
+ )
536
+
537
+
538
+ @click.option(
539
+ "--timeout",
540
+ default=120,
541
+ show_default=True,
542
+ type=int,
543
+ help="Seconds to wait for the agent to come back online after restart.",
544
+ )
545
+
546
+
547
+ @click.option(
548
+ "--force",
549
+ is_flag=True,
550
+ help=(
551
+ "Update even if the agent has live (RUNNING/PENDING) assignments — "
552
+ "the restart-after-swap kills them mid-flight (#1241). Without "
553
+ "this, an agent with live sessions refuses the update outright."
554
+ ),
555
+ )
556
+
557
+
558
+ def agent_update(
559
+ config_path: Path,
560
+ machine_filter: str | None,
561
+ all_machines: bool,
562
+ version_override: str | None,
563
+ timeout: int,
564
+ force: bool,
565
+ ) -> None:
566
+ # #1886 Path A: the target used to be `__version__` — this CLI's own
567
+ # version. A stale operator install (PyPI already has v0.4.108, this
568
+ # CLI is still v0.4.107) silently under-updated the whole fleet and
569
+ # still printed three clean checkmarks, because "success" was judged
570
+ # against the wrong target from the start. Resolve the target from
571
+ # PyPI's simple index instead (the same source `pip install -U`
572
+ # itself resolves against — see coord.health.pypi) so a stale operator
573
+ # CLI is either overridden by the real target or refuses loudly,
574
+ # rather than silently pinning the fleet to its own age. --version
575
+ # remains an explicit escape hatch for pinning to something else on
576
+ # purpose (a rollback, a pre-release, ...).
577
+ target_version, resolve_warnings = _resolve_target_version(version_override)
578
+ for line in resolve_warnings:
579
+ click.echo(line, err=True)
580
+
581
+ # #1568: sending an explicit target_version lets the agent pin its pip
582
+ # install to that exact release (turning a stale-index no-op into a
583
+ # loud pip failure) and lets THIS command verify success by polling
584
+ # for that exact version, instead of inferring success from "the POST
585
+ # was accepted" (false positive on a cache-stale no-op) or failure
586
+ # from "the process stopped answering pings" (false negative on the
587
+ # execv-under-systemd restart, #404).
588
+
589
+ cfg = _load_config(config_path)
590
+ targets = _resolve_agent_targets(cfg, machine_filter, all_machines)
591
+ if not targets:
592
+ click.echo("No machines to update.", err=True)
593
+ sys.exit(2)
594
+
595
+ click.echo(f"Requesting upgrade to v{target_version}...")
596
+
597
+ # Capture each agent's start time BEFORE we trigger /update so the
598
+ # wait loop can distinguish "old agent still answering during pip"
599
+ # from "new agent came back up".
600
+ pre_started_at = _fetch_pre_started_at(targets)
601
+
602
+ # #1241: a machine that refuses (editable install, or live sessions
603
+ # without --force) never gets restarted, so polling it for a version
604
+ # change would just burn the whole --timeout window for nothing.
605
+ # `posted` is the subset of `targets` that actually accepted the POST;
606
+ # `refused_reasons` carries why the rest didn't.
607
+ posted: list = []
608
+ refused_reasons: dict[str, str] = {}
609
+
610
+ for machine in targets:
611
+ url = f"http://{machine.host}:{AGENT_PORT}/update"
612
+ click.echo(f" {machine.name}: POST {url} ...", nl=False)
613
+ try:
614
+ resp = httpx.post(
615
+ url, json={"target_version": target_version, "force": force}, timeout=10
616
+ )
617
+ if resp.status_code == 202:
618
+ data = resp.json()
619
+ click.echo(f" accepted (mode: {data.get('mode', '?')})")
620
+ posted.append(machine)
621
+ elif resp.status_code == 409:
622
+ try:
623
+ reason = resp.json().get("error") or "refused"
624
+ except Exception:
625
+ reason = "refused"
626
+ click.echo(f" refused — {reason}")
627
+ refused_reasons[machine.name] = reason
628
+ else:
629
+ click.echo(f" HTTP {resp.status_code}")
630
+ refused_reasons[machine.name] = f"HTTP {resp.status_code}"
631
+ except (httpx.HTTPError, httpx.TimeoutException) as e:
632
+ click.echo(f" error: {e}")
633
+ refused_reasons[machine.name] = str(e)
634
+
635
+ all_matched = not refused_reasons
636
+ if posted:
637
+ click.echo(
638
+ f"\nWaiting up to {timeout}s for agent(s) to report v{target_version}..."
639
+ )
640
+ outcomes = _wait_agents_updated(
641
+ posted,
642
+ target_version=target_version,
643
+ timeout=timeout,
644
+ pre_started_at=pre_started_at,
645
+ )
646
+
647
+ click.echo("")
648
+ for machine in posted:
649
+ outcome = outcomes[machine.name]
650
+ version_now = outcome["version_now"]
651
+ if outcome["matched"]:
652
+ vbefore = outcome.get("version_before") or "?"
653
+ click.echo(f" {machine.name}: ✓ {vbefore} → {target_version}")
654
+ continue
655
+
656
+ all_matched = False
657
+ result = outcome.get("result")
658
+ if result == "no_change":
659
+ click.echo(
660
+ f" {machine.name}: ✗ no change (still {version_now}) — "
661
+ f"{outcome.get('error') or 'pip resolved to the same version'}",
662
+ err=True,
663
+ )
664
+ elif result == "failed":
665
+ err = outcome.get("error") or "pip failed; see ~/.coord/last_update.log"
666
+ click.echo(f" {machine.name}: ✗ failed — {err}", err=True)
667
+ elif outcome.get("escalated"):
668
+ click.echo(
669
+ f" {machine.name}: ✗ pip upgraded to {target_version} but the "
670
+ f"process is stuck reporting {version_now} even after a "
671
+ "`systemctl --user restart` — needs manual investigation",
672
+ err=True,
673
+ )
674
+ elif not outcome.get("came_online"):
675
+ click.echo(f" {machine.name}: ✗ did not come back online", err=True)
676
+ else:
677
+ installed_now = outcome.get("installed_version_now")
678
+ if installed_now and installed_now != version_now:
679
+ # #1886 Path B: pip (or the disk) has already moved to
680
+ # a newer install, but the running process — the only
681
+ # thing "matched" ever keys off of — hasn't caught up.
682
+ # Distinguishing this from a bare "still reporting X"
683
+ # is the whole point: it says the process needs a
684
+ # restart, not another pip attempt.
685
+ click.echo(
686
+ f" {machine.name}: ✗ installed {installed_now} but the "
687
+ f"running process still reports {version_now} (expected "
688
+ f"{target_version}) — it hasn't restarted since the "
689
+ "update; try `systemctl --user restart coord-agent` "
690
+ "on that machine",
691
+ err=True,
692
+ )
693
+ else:
694
+ click.echo(
695
+ f" {machine.name}: ✗ still reporting {version_now}, "
696
+ f"expected {target_version}",
697
+ err=True,
698
+ )
699
+
700
+ if refused_reasons:
701
+ if posted:
702
+ click.echo("")
703
+ for name, reason in refused_reasons.items():
704
+ click.echo(f" {name}: ✗ refused — {reason}", err=True)
705
+
706
+ if not all_matched:
707
+ sys.exit(1)
708
+
709
+
710
+ @agent.command(
711
+ "restart",
712
+ help=(
713
+ "POST /restart to one or all agent servers. The agent waits for "
714
+ "active workers to finish (or cancels them after --cancel-timeout "
715
+ "seconds) then restarts itself. Waits up to --timeout seconds for "
716
+ "the agent(s) to come back online."
717
+ ),
718
+ )
719
+
720
+
721
+ @_CONFIG_OPTION
722
+ @click.option(
723
+ "--machine",
724
+ "machine_filter",
725
+ default=None,
726
+ help="Name of a single machine to restart (from coordinator.yml).",
727
+ )
728
+
729
+
730
+ @click.option(
731
+ "--all",
732
+ "all_machines",
733
+ is_flag=True,
734
+ help="Restart all machines (mutually exclusive with --machine).",
735
+ )
736
+
737
+
738
+ @click.option(
739
+ "--timeout",
740
+ default=120,
741
+ show_default=True,
742
+ type=int,
743
+ help="Seconds to wait for the agent to come back online after restart.",
744
+ )
745
+
746
+
747
+ @click.option(
748
+ "--cancel-timeout",
749
+ default=30,
750
+ show_default=True,
751
+ type=int,
752
+ help="Seconds the agent waits for active workers to finish before cancelling them.",
753
+ )
754
+
755
+
756
+ def agent_restart(
757
+ config_path: Path,
758
+ machine_filter: str | None,
759
+ all_machines: bool,
760
+ timeout: int,
761
+ cancel_timeout: int,
762
+ ) -> None:
763
+ cfg = _load_config(config_path)
764
+ targets = _resolve_agent_targets(cfg, machine_filter, all_machines)
765
+ if not targets:
766
+ click.echo("No machines to restart.", err=True)
767
+ sys.exit(2)
768
+
769
+ for machine in targets:
770
+ url = f"http://{machine.host}:{AGENT_PORT}/restart"
771
+ click.echo(f" {machine.name}: POST {url} ...", nl=False)
772
+ try:
773
+ resp = httpx.post(
774
+ url,
775
+ json={"cancel_timeout": cancel_timeout},
776
+ timeout=10,
777
+ )
778
+ if resp.status_code == 202:
779
+ data = resp.json()
780
+ active = data.get("active_workers", 0)
781
+ click.echo(f" accepted ({active} active worker(s))")
782
+ else:
783
+ click.echo(f" HTTP {resp.status_code}")
784
+ except (httpx.HTTPError, httpx.TimeoutException) as e:
785
+ click.echo(f" error: {e}")
786
+
787
+ if targets:
788
+ click.echo(f"\nWaiting up to {timeout}s for agent(s) to come back online...")
789
+ results = _wait_agents_online(targets, timeout=timeout)
790
+ for name, came_back in results.items():
791
+ tag = "✓ online" if came_back else "✗ did not come back"
792
+ click.echo(f" {name}: {tag}")
793
+ if not all(results.values()):
794
+ sys.exit(1)
795
+
796
+
797
+ @agent.command(
798
+ "clean-worktrees",
799
+ help=(
800
+ "POST /worktree-clean to one or all agent servers. Each agent "
801
+ "removes git worktrees whose assignment is in a terminal state "
802
+ "(done/failed/cancelled) and finished more than --recent-secs ago. "
803
+ "Running/pending worktrees are never touched."
804
+ ),
805
+ )
806
+
807
+
808
+ @_CONFIG_OPTION
809
+ @click.option(
810
+ "--machine",
811
+ "machine_filter",
812
+ default=None,
813
+ help="Name of a single machine to clean (from coordinator.yml).",
814
+ )
815
+
816
+
817
+ @click.option(
818
+ "--all",
819
+ "all_machines",
820
+ is_flag=True,
821
+ help="Clean all machines (mutually exclusive with --machine).",
822
+ )
823
+
824
+
825
+ @click.option(
826
+ "--recent-secs",
827
+ default=300,
828
+ show_default=True,
829
+ type=int,
830
+ help=(
831
+ "Minimum age in seconds for a terminal assignment's worktree to be "
832
+ "eligible for removal (guards against racing with a just-finished worker)."
833
+ ),
834
+ )
835
+
836
+
837
+ def agent_clean_worktrees(
838
+ config_path: Path,
839
+ machine_filter: str | None,
840
+ all_machines: bool,
841
+ recent_secs: int,
842
+ ) -> None:
843
+ cfg = _load_config(config_path)
844
+ targets = _resolve_agent_targets(cfg, machine_filter, all_machines)
845
+ if not targets:
846
+ click.echo("No machines to clean.", err=True)
847
+ sys.exit(2)
848
+
849
+ any_error = False
850
+ for machine in targets:
851
+ url = f"http://{machine.host}:{AGENT_PORT}/worktree-clean"
852
+ click.echo(f" {machine.name}: POST {url} ...", nl=False)
853
+ try:
854
+ resp = httpx.post(url, json={"recent_secs": recent_secs}, timeout=30)
855
+ if resp.status_code == 200:
856
+ data = resp.json()
857
+ cleaned = data.get("cleaned", 0)
858
+ kept = data.get("kept", 0)
859
+ freed = data.get("bytes_freed", 0)
860
+ freed_mb = freed / (1024 * 1024)
861
+ # #1402: the same endpoint GCs the shared cargo target cache.
862
+ cargo_mb = data.get("cargo_cache_bytes", 0) / (1024 * 1024)
863
+ evicted = data.get("cargo_caches_evicted", 0)
864
+ click.echo(
865
+ f" cleaned={cleaned} kept={kept} freed={freed_mb:.1f} MB "
866
+ f"cargo-cache={cargo_mb:.1f} MB (evicted {evicted})"
867
+ )
868
+ else:
869
+ click.echo(f" HTTP {resp.status_code}")
870
+ any_error = True
871
+ except (httpx.HTTPError, httpx.TimeoutException) as e:
872
+ click.echo(f" error: {e}")
873
+ any_error = True
874
+
875
+ if any_error:
876
+ sys.exit(1)
877
+
878
+
879
+ @agent.command(
880
+ "versions",
881
+ help=(
882
+ "GET /health from one or all agent servers and print each one's "
883
+ "self-reported version alongside the coordinator's own. #1568: "
884
+ "a version split-brain across the fleet is only detectable by "
885
+ "comparing versions directly — this is the fleet-wide check to "
886
+ "run before trusting a rule change, and after `coord agent "
887
+ "update` to confirm it actually landed everywhere."
888
+ ),
889
+ )
890
+ @_CONFIG_OPTION
891
+ @click.option(
892
+ "--machine",
893
+ "machine_filter",
894
+ default=None,
895
+ help="Name of a single machine to check (from coordinator.yml).",
896
+ )
897
+ @click.option(
898
+ "--all",
899
+ "all_machines",
900
+ is_flag=True,
901
+ help="Check all machines (mutually exclusive with --machine).",
902
+ )
903
+ def agent_versions(
904
+ config_path: Path,
905
+ machine_filter: str | None,
906
+ all_machines: bool,
907
+ ) -> None:
908
+ cfg = _load_config(config_path)
909
+ targets = _resolve_agent_targets(cfg, machine_filter, all_machines)
910
+ if not targets:
911
+ click.echo("No machines to check.", err=True)
912
+ sys.exit(2)
913
+
914
+ click.echo(f"coordinator: v{__version__}\n")
915
+
916
+ versions_seen: set[str] = set()
917
+ any_offline = False
918
+ any_mismatch = False
919
+ for machine in targets:
920
+ version: str | None
921
+ try:
922
+ resp = httpx.get(f"http://{machine.host}:{AGENT_PORT}/health", timeout=5)
923
+ version = resp.json().get("version") if resp.status_code == 200 else None
924
+ except (httpx.HTTPError, httpx.TimeoutException):
925
+ version = None
926
+
927
+ if version is None:
928
+ click.echo(f" {machine.name}: ✗ unreachable", err=True)
929
+ any_offline = True
930
+ continue
931
+
932
+ versions_seen.add(version)
933
+ mismatch = version != __version__
934
+ any_mismatch = any_mismatch or mismatch
935
+ marker = " ⚠ mismatch" if mismatch else ""
936
+ click.echo(f" {machine.name}: v{version}{marker}")
937
+
938
+ if len(versions_seen) > 1:
939
+ click.echo(
940
+ f"\n⚠ split-brain: {len(versions_seen)} distinct versions across the "
941
+ f"fleet ({', '.join(sorted(versions_seen))}). Do not trust a rule "
942
+ "change until `coord agent update --all` brings everyone in line.",
943
+ err=True,
944
+ )
945
+ sys.exit(1)
946
+ if any_mismatch:
947
+ click.echo(
948
+ f"\n⚠ mismatch: fleet is uniformly on a version that differs from "
949
+ f"the coordinator's own v{__version__}. Run `coord agent update "
950
+ "--all` to bring the fleet in line.",
951
+ err=True,
952
+ )
953
+ sys.exit(1)
954
+ if any_offline:
955
+ sys.exit(1)
956
+
957
+
958
+ def _resolve_agent_targets(cfg, machine_filter: str | None, all_machines: bool):
959
+ """Return the list of Machine objects to target for update/restart.
960
+
961
+ Validates --machine / --all flags and prints errors on bad input.
962
+ """
963
+ if machine_filter and all_machines:
964
+ click.echo("error: --machine and --all are mutually exclusive.", err=True)
965
+ sys.exit(2)
966
+ if not machine_filter and not all_machines:
967
+ click.echo(
968
+ "error: specify either --machine NAME or --all.", err=True
969
+ )
970
+ sys.exit(2)
971
+
972
+ if machine_filter:
973
+ machine = next((m for m in cfg.machines if m.name == machine_filter), None)
974
+ if machine is None:
975
+ click.echo(
976
+ f"error: machine {machine_filter!r} not in coordinator.yml "
977
+ f"(have: {[m.name for m in cfg.machines]})",
978
+ err=True,
979
+ )
980
+ sys.exit(2)
981
+ return [machine]
982
+
983
+ return list(cfg.machines)
984
+
985
+
986
+ def _wait_agents_online(
987
+ machines: list,
988
+ *,
989
+ timeout: float = 120.0,
990
+ poll_interval: float = 2.0,
991
+ pre_started_at: dict[str, float | None] | None = None,
992
+ ) -> dict[str, bool]:
993
+ """Poll /health on each machine until all are online or timeout expires.
994
+
995
+ When ``pre_started_at`` is provided, a machine is only considered
996
+ "back" once its reported ``agent_started_at`` differs from the
997
+ pre-update value (or appears for the first time on an agent that
998
+ didn't expose it before). This stops the CLI from racing the old
999
+ agent while a pip upgrade is still running inside it.
1000
+
1001
+ For agents that don't expose ``agent_started_at`` at all (pre-v0.4.3),
1002
+ we fall back to "responding to /health is enough."
1003
+
1004
+ Returns ``{machine_name: came_back_online}`` for every machine.
1005
+ """
1006
+ # Scale the sleep down for short timeouts (e.g. tests passing
1007
+ # --timeout 1) so a tiny deadline isn't dominated by a single fixed
1008
+ # 2s sleep — callers that want the full 2s just pass a bigger timeout.
1009
+ poll_interval = min(poll_interval, max(timeout / 5, 0.05))
1010
+ deadline = time.time() + timeout
1011
+ online: set[str] = set()
1012
+ pre = pre_started_at or {}
1013
+
1014
+ while time.time() < deadline:
1015
+ for machine in machines:
1016
+ if machine.name in online:
1017
+ continue
1018
+ try:
1019
+ resp = httpx.get(
1020
+ f"http://{machine.host}:{AGENT_PORT}/health",
1021
+ timeout=3.0,
1022
+ )
1023
+ if resp.status_code != 200:
1024
+ continue
1025
+ if machine.name in pre:
1026
+ pre_val = pre[machine.name]
1027
+ try:
1028
+ cur = resp.json().get("agent_started_at")
1029
+ except Exception:
1030
+ cur = None
1031
+ if cur is None:
1032
+ # Old agent (no started_at) — fall back to "alive
1033
+ # is good enough" so /update on a pre-v0.4.3
1034
+ # agent isn't blocked forever.
1035
+ online.add(machine.name)
1036
+ elif pre_val is None or cur != pre_val:
1037
+ # Either the agent didn't expose started_at
1038
+ # before (just upgraded TO v0.4.3) or the value
1039
+ # changed (restart happened).
1040
+ online.add(machine.name)
1041
+ else:
1042
+ online.add(machine.name)
1043
+ except Exception:
1044
+ pass
1045
+
1046
+ if len(online) == len(machines):
1047
+ break
1048
+ time.sleep(poll_interval)
1049
+
1050
+ return {m.name: m.name in online for m in machines}
1051
+
1052
+
1053
+ def _wait_agents_updated(
1054
+ machines: list,
1055
+ *,
1056
+ target_version: str,
1057
+ timeout: float = 120.0,
1058
+ poll_interval: float = 2.0,
1059
+ pre_started_at: dict[str, float | None] | None = None,
1060
+ ) -> dict[str, dict]:
1061
+ """Poll /health on each machine until its self-reported version equals
1062
+ ``target_version``, escalating to a driven restart if needed.
1063
+
1064
+ #1568: success is judged by the version the agent actually reports —
1065
+ never by "the POST was accepted" and never by "the process answers
1066
+ pings again." Those liveness signals fail in opposite directions:
1067
+
1068
+ - Cause A: pip resolves to a cached/stale version and exits 0. The
1069
+ POST is accepted, the process never restarts, but nothing changed —
1070
+ the old ``_wait_agents_online``-based check reported success anyway.
1071
+ - Cause B (#404): the update's ``os.execv`` self-restart doesn't take
1072
+ under systemd, so the OLD process keeps answering /health after a
1073
+ real upgrade. ``_wait_agents_online`` reported "did not come back"
1074
+ even though the new version was installed and the service was
1075
+ active.
1076
+
1077
+ When a machine's pip step genuinely succeeded (``last_update.result
1078
+ == "upgraded"``) but the version still hasn't advanced once the
1079
+ normal poll window elapses, escalate once with an SSH-driven
1080
+ ``systemctl --user restart coord-agent`` — the documented fix for the
1081
+ execv-under-systemd stall (see docs/AGENT_OPERATIONS.md) — and give
1082
+ it one more short window before giving up.
1083
+
1084
+ Returns ``{machine_name: {matched, came_online, version_now,
1085
+ installed_version_now, version_before, result, error, escalated}}``.
1086
+ """
1087
+ # Scale the sleep down for short timeouts (e.g. tests passing
1088
+ # --timeout 1) so a tiny deadline isn't dominated by a single fixed
1089
+ # 2s sleep — callers that want the full 2s just pass a bigger timeout.
1090
+ poll_interval = min(poll_interval, max(timeout / 5, 0.05))
1091
+ pre = pre_started_at or {}
1092
+ out: dict[str, dict] = {
1093
+ m.name: {
1094
+ "matched": False,
1095
+ "came_online": False,
1096
+ "version_now": "?",
1097
+ "installed_version_now": None,
1098
+ "version_before": None,
1099
+ "result": None,
1100
+ "error": None,
1101
+ "escalated": False,
1102
+ }
1103
+ for m in machines
1104
+ }
1105
+ pending = {m.name: m for m in machines}
1106
+
1107
+ def _poll_once(machine) -> bool:
1108
+ """Fetch /health once, update out[machine.name], return True on match."""
1109
+ info = out[machine.name]
1110
+ try:
1111
+ resp = httpx.get(f"http://{machine.host}:{AGENT_PORT}/health", timeout=3.0)
1112
+ if resp.status_code != 200:
1113
+ return False
1114
+ health = resp.json()
1115
+ except Exception:
1116
+ return False
1117
+
1118
+ # #1886 Path B: `version` (checked below) is the RUNNING process's
1119
+ # loaded-module version — the only thing "matched" may key off of.
1120
+ # `installed_version` is a disk read that can advance the instant
1121
+ # pip writes to site-packages, well before (or without) a restart
1122
+ # (#404's execv-under-systemd stall). Recording both lets a
1123
+ # still-pending outcome say *why* — "installed advanced, running
1124
+ # didn't" — instead of a bare "still reporting X".
1125
+ version_now = health.get("version")
1126
+ info["version_now"] = version_now or "?"
1127
+ info["installed_version_now"] = health.get("installed_version")
1128
+ last = health.get("last_update") or {}
1129
+ info["result"] = last.get("result")
1130
+ info["error"] = last.get("error")
1131
+ info["version_before"] = last.get("version_before")
1132
+
1133
+ if machine.name in pre:
1134
+ pre_val = pre[machine.name]
1135
+ cur = health.get("agent_started_at")
1136
+ if cur is None or pre_val is None or cur != pre_val:
1137
+ info["came_online"] = True
1138
+ else:
1139
+ info["came_online"] = True
1140
+
1141
+ if version_now == target_version:
1142
+ info["matched"] = True
1143
+ return True
1144
+ return False
1145
+
1146
+ deadline = time.time() + timeout
1147
+ while time.time() < deadline and pending:
1148
+ for name in list(pending):
1149
+ if _poll_once(pending[name]):
1150
+ del pending[name]
1151
+ if not pending:
1152
+ break
1153
+ time.sleep(poll_interval)
1154
+
1155
+ # Escalate the machines still stuck on the old version whose pip step
1156
+ # actually succeeded — the classic execv-under-systemd stall. Give
1157
+ # each a short follow-up window after the driven restart.
1158
+ escalate_timeout = min(30.0, max(timeout / 2, 15.0))
1159
+ for name in list(pending):
1160
+ machine = pending[name]
1161
+ info = out[name]
1162
+ if info["result"] != "upgraded":
1163
+ continue
1164
+ info["escalated"] = _escalate_restart(machine)
1165
+ if not info["escalated"]:
1166
+ continue
1167
+ sub_deadline = time.time() + escalate_timeout
1168
+ while time.time() < sub_deadline:
1169
+ if _poll_once(machine):
1170
+ del pending[name]
1171
+ break
1172
+ time.sleep(poll_interval)
1173
+
1174
+ return out
1175
+
1176
+
1177
+ def _escalate_restart(machine) -> bool:
1178
+ """Best-effort ``systemctl --user restart coord-agent`` over SSH.
1179
+
1180
+ #404 / #1568: ``/update``'s ``os.execv`` self-restart does not take
1181
+ under systemd — same PID, stale version. ``XDG_RUNTIME_DIR=/run/user/
1182
+ $(id -u)`` is load-bearing: a bare ``systemctl --user restart``
1183
+ silently no-ops in a non-interactive SSH session. See
1184
+ docs/AGENT_OPERATIONS.md for the manual runbook this automates.
1185
+
1186
+ Returns True if the ssh command itself exited 0 — NOT whether the
1187
+ agent actually came back on the new version; the caller re-polls
1188
+ /health afterwards to confirm that.
1189
+ """
1190
+ cmd = [
1191
+ "ssh",
1192
+ "-o", "BatchMode=yes",
1193
+ "-o", "ConnectTimeout=10",
1194
+ "-o", "StrictHostKeyChecking=accept-new",
1195
+ machine.host,
1196
+ "XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user restart coord-agent",
1197
+ ]
1198
+ try:
1199
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
1200
+ except Exception:
1201
+ return False
1202
+ return result.returncode == 0
1203
+
1204
+
1205
+ def _fetch_pre_started_at(machines: list) -> dict[str, float | None]:
1206
+ """Capture each agent's `agent_started_at` BEFORE we trigger /update.
1207
+
1208
+ Returns ``{name: started_at_or_None}`` — None when the agent is
1209
+ unreachable or doesn't expose the field yet.
1210
+ """
1211
+ out: dict[str, float | None] = {}
1212
+ for m in machines:
1213
+ try:
1214
+ resp = httpx.get(f"http://{m.host}:{AGENT_PORT}/health", timeout=3.0)
1215
+ if resp.status_code == 200:
1216
+ out[m.name] = resp.json().get("agent_started_at")
1217
+ else:
1218
+ out[m.name] = None
1219
+ except Exception:
1220
+ out[m.name] = None
1221
+ return out
1222
+
1223
+
1224
+ def _resolve_machine(cfg: Config, explicit_name: str | None):
1225
+ if explicit_name:
1226
+ m = next((m for m in cfg.machines if m.name == explicit_name), None)
1227
+ if m is None:
1228
+ click.echo(
1229
+ f"error: machine {explicit_name!r} not in coordinator.yml "
1230
+ f"(have: {[m.name for m in cfg.machines]})",
1231
+ err=True,
1232
+ )
1233
+ sys.exit(2)
1234
+ return m
1235
+
1236
+ hostname = socket.gethostname().lower()
1237
+ short = hostname.split(".")[0]
1238
+ candidates = [m for m in cfg.machines if m.name.lower() == short or m.host.lower() == hostname or m.host.lower().split(".")[0] == short]
1239
+ if len(candidates) == 1:
1240
+ return candidates[0]
1241
+ if not candidates:
1242
+ click.echo(
1243
+ f"error: could not match hostname {hostname!r} to any machine in coordinator.yml. "
1244
+ f"Pass --machine explicitly. Known: {[m.name for m in cfg.machines]}",
1245
+ err=True,
1246
+ )
1247
+ sys.exit(2)
1248
+ click.echo(
1249
+ f"error: hostname {hostname!r} matches multiple machines: "
1250
+ f"{[m.name for m in candidates]}. Pass --machine explicitly.",
1251
+ err=True,
1252
+ )
1253
+ sys.exit(2)
1254
+
1255
+
1256
+ @click.command(
1257
+ help=(
1258
+ "Pause a machine — no new agents will be routed to it until "
1259
+ "`coord unpause` is called. In-flight assignments are NOT "
1260
+ "cancelled (use `coord stop` for that).\n\n"
1261
+ "MACHINE is the local name from coordinator.yml."
1262
+ ),
1263
+ )
1264
+
1265
+
1266
+ @_CONFIG_OPTION
1267
+ @click.argument("machine")
1268
+ def pause(config_path: Path, machine: str) -> None:
1269
+ from coord.machine_pause import pause as _pause
1270
+
1271
+ # #1563: on a thin client this routes to the daemon's `/pause` endpoint
1272
+ # and can raise (network/HTTP failure) — surface that loudly rather than
1273
+ # letting a bare traceback stand in for "coord pause silently no-oped",
1274
+ # which is exactly the failure mode this fix closes.
1275
+ try:
1276
+ changed = _pause(machine)
1277
+ except Exception as e: # noqa: BLE001
1278
+ click.echo(
1279
+ f"error: could not confirm pause of {machine!r} with the daemon: {e}",
1280
+ err=True,
1281
+ )
1282
+ sys.exit(1)
1283
+ if changed:
1284
+ click.echo(f"paused: {machine}")
1285
+ else:
1286
+ click.echo(f"already paused: {machine}")
1287
+
1288
+
1289
+ @click.command(
1290
+ help=(
1291
+ "Resume a paused machine — new assignments can be routed to it "
1292
+ "again. No-op if the machine wasn't paused.\n\n"
1293
+ "#1862: if MACHINE isn't hand-paused but IS inside its "
1294
+ "coordinator.yml `quiet_hours` window, this overrides that window "
1295
+ "until it ends rather than silently doing nothing (a bare re-read "
1296
+ "would otherwise show it paused again on the very next poll)."
1297
+ ),
1298
+ )
1299
+
1300
+
1301
+ @_CONFIG_OPTION
1302
+ @click.argument("machine")
1303
+ def unpause(config_path: Path, machine: str) -> None:
1304
+ from coord.machine_pause import unpause as _unpause
1305
+
1306
+ # #1862: best-effort quiet-hours context — an unloadable/placeholder
1307
+ # config must not block the (still fully functional) explicit-unpause
1308
+ # path below, it only means quiet-hours overrides can't be resolved.
1309
+ # Deliberately NOT `_load_config()`: that helper `sys.exit(2)`s on a
1310
+ # ConfigError (a SystemExit, which a bare `except Exception:` doesn't
1311
+ # catch) and, on a thin client, fetches the daemon's remote config over
1312
+ # HTTP — wasted work here, since a thin client's `_unpause()` call below
1313
+ # routes over HTTP too and never even looks at `machines`.
1314
+ machines = None
1315
+ try:
1316
+ from coord.config import load as _load_yaml_config # noqa: PLC0415
1317
+
1318
+ machines = _load_yaml_config(config_path).machines
1319
+ except Exception: # noqa: BLE001
1320
+ pass
1321
+
1322
+ # #1563: fail loudly, see the matching comment on pause() above.
1323
+ try:
1324
+ outcome = _unpause(machine, machines)
1325
+ except Exception as e: # noqa: BLE001
1326
+ click.echo(
1327
+ f"error: could not confirm unpause of {machine!r} with the daemon: {e}",
1328
+ err=True,
1329
+ )
1330
+ sys.exit(1)
1331
+ if outcome.kind == "resumed":
1332
+ click.echo(f"resumed: {machine}")
1333
+ elif outcome.kind == "quiet_override":
1334
+ click.echo(
1335
+ f"{machine}: quiet hours overridden until {outcome.quiet_until} "
1336
+ f"({outcome.tz}) — resumes its normal quiet schedule next window"
1337
+ )
1338
+ else:
1339
+ click.echo(f"not paused: {machine}")