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,2232 @@
1
+ """`coord release-preflight` — local sanity checks before cutting a release.
2
+
3
+ #1471: `main` is now a protected branch, so a plain ``git push origin main``
4
+ can be silently *rejected* while a subsequent ``git push origin vX.Y.Z``
5
+ still *succeeds* — the two pushes are independent refs and nothing couples
6
+ them. That let a v0.4.82 release publish (immutably) to PyPI from a commit
7
+ that, at that moment, existed nowhere but the releaser's local checkout and
8
+ the tag.
9
+
10
+ This command is a fast, local, no-side-effects check meant to run right
11
+ before tagging a release, per the flow in docs/AGENT_OPERATIONS.md (merge PR
12
+ -> pull merged main -> tag -> push tag — #1238 dropped the version-bump step
13
+ that used to precede it: the git tag *is* the version now, single-sourced
14
+ via setuptools-scm). It does not push, tag, or modify anything itself.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import subprocess
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ import click
24
+
25
+ from coord.commands._common import _CONFIG_OPTION
26
+
27
+
28
+ def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
29
+ return subprocess.run(
30
+ ["git", "-C", str(cwd), *args],
31
+ capture_output=True,
32
+ text=True,
33
+ timeout=30,
34
+ )
35
+
36
+
37
+ def release_preflight_checks(repo_root: Path) -> list[str]:
38
+ """Return a list of problems with *repo_root* as a release candidate.
39
+
40
+ Empty list == clear to tag. Kept as a pure(ish) function over a repo
41
+ checkout — the only side effect is a ``git fetch origin main`` — so it's
42
+ straightforward to unit test against local-only git fixtures (no real
43
+ network) and to reuse outside the CLI command if needed.
44
+
45
+ Checks, mirroring the issue's #1471 proposal:
46
+ - working tree is clean (no staged/unstaged changes)
47
+ - currently on ``main``, and local ``main`` == ``origin/main`` (the
48
+ protected-branch push must have already landed via a merged PR)
49
+
50
+ #1238: this used to also assert ``pyproject.toml``'s ``version`` and
51
+ ``coord/__init__.py``'s ``__version__`` agreed, and that the version
52
+ they named wasn't already tagged. Both checks are gone along with the
53
+ hand-maintained version literals they compared — the version is now
54
+ single-sourced from the git tag itself (setuptools-scm), so there is no
55
+ bump left to forget or mismatch. Cutting a release is just choosing and
56
+ pushing a ``vX.Y.Z`` tag that doesn't exist yet; ``git tag vX.Y.Z``
57
+ itself already refuses a name collision, so a redundant check here would
58
+ add nothing.
59
+ """
60
+ problems: list[str] = []
61
+
62
+ if not (repo_root / ".git").exists():
63
+ return [f"{repo_root} is not a git checkout"]
64
+
65
+ status = _git(repo_root, "status", "--porcelain")
66
+ if status.returncode != 0:
67
+ problems.append(f"git status failed: {status.stderr.strip()}")
68
+ elif status.stdout.strip():
69
+ problems.append(
70
+ "working tree is not clean — commit or stash changes before "
71
+ "releasing:\n" + status.stdout.rstrip()
72
+ )
73
+
74
+ branch = _git(repo_root, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
75
+ if branch != "main":
76
+ # #1525: this check fires by design on a `release-v*` bump branch —
77
+ # this command is a post-merge, pre-tag check (see the module
78
+ # docstring's flow), not something to run while the bump PR is still
79
+ # open. Spell that out here since the bare "not on main" message
80
+ # read as a bug the first time it fired on a release branch.
81
+ problems.append(
82
+ f"not on main (currently on '{branch}') — this is a post-merge, "
83
+ "pre-tag check: merge the release PR first, then `git checkout "
84
+ "main && git pull origin main` and re-run this from there"
85
+ )
86
+
87
+ fetch = _git(repo_root, "fetch", "origin", "main")
88
+ if fetch.returncode != 0:
89
+ problems.append(f"git fetch origin main failed: {fetch.stderr.strip()}")
90
+ else:
91
+ local_head = _git(repo_root, "rev-parse", "HEAD").stdout.strip()
92
+ remote_head = _git(repo_root, "rev-parse", "origin/main").stdout.strip()
93
+ if local_head and remote_head and local_head != remote_head:
94
+ problems.append(
95
+ f"local main ({local_head[:8]}) != origin/main ({remote_head[:8]}) — "
96
+ "pull/rebase onto origin/main first. main is protected: your "
97
+ "change must land there via a merged PR *before* you tag "
98
+ "it (#1471) — a tag built from a commit main rejected still "
99
+ "publishes to PyPI, and PyPI releases are immutable."
100
+ )
101
+
102
+ return problems
103
+
104
+
105
+ @click.command(
106
+ "release-preflight",
107
+ help="Sanity-check the checkout before cutting a release (#1471).",
108
+ )
109
+ @click.option(
110
+ "--path",
111
+ "path_opt",
112
+ default=None,
113
+ help="Repo checkout to check (defaults to the current directory).",
114
+ )
115
+ def release_preflight(path_opt: str | None) -> None:
116
+ """Fail loudly, before any tag is pushed, if release ordering would be wrong.
117
+
118
+ Run this right before ``git tag vX.Y.Z && git push origin vX.Y.Z``. It
119
+ fetches ``origin/main`` and confirms local ``main`` matches it and the
120
+ working tree is clean — so the #1471 failure mode (tagging a commit that
121
+ never actually landed on the protected ``main`` branch) is caught
122
+ locally instead of shipping an immutable bad PyPI release.
123
+ """
124
+ repo_root = Path(path_opt).expanduser() if path_opt else Path.cwd()
125
+ problems = release_preflight_checks(repo_root)
126
+ if problems:
127
+ click.echo("release preflight FAILED:", err=True)
128
+ for problem in problems:
129
+ click.echo(f" - {problem}", err=True)
130
+ sys.exit(1)
131
+ click.echo(
132
+ "release preflight OK — local main matches origin/main, working "
133
+ "tree clean. Ready to tag: git tag vX.Y.Z && git push origin vX.Y.Z."
134
+ )
135
+
136
+
137
+ # ──────────────────────────────────────────────────────────────────────────
138
+ # `coord release verify` — the POST-release half (#1834)
139
+ # ──────────────────────────────────────────────────────────────────────────
140
+ #
141
+ # `release-preflight` above guards the moment *before* a tag is pushed. It
142
+ # says nothing about whether the release that came out the other end ever
143
+ # reached the fleet — and on 2026-08-04 it demonstrably had not, while four
144
+ # independent readouts said it had. See `coord/release_verify.py` for the
145
+ # incident and the design rules; this file only owns the click surface.
146
+ #
147
+ # `release-preflight` stays registered as a flat top-level command for
148
+ # backward compatibility (it is in every operator's muscle memory and in
149
+ # docs/AGENT_OPERATIONS.md); the new `release` group carries `verify`, and
150
+ # aliases `preflight` under it so the pair is discoverable together.
151
+
152
+
153
+ @click.group("release", help="Release lifecycle checks (#1471, #1834).")
154
+ def release_group() -> None:
155
+ """Pre-tag sanity checks and post-release fleet verification."""
156
+
157
+
158
+ def _resolve_expected(expected: str | None, *, use_pypi: bool, index_url: str,
159
+ timeout: float) -> tuple[str | None, str | None]:
160
+ """(expected version, warning) — the version every lane *should* be on.
161
+
162
+ ``--expected`` wins outright. ``--pypi`` asks the simple index (never the
163
+ JSON API — see ``coord.health.pypi`` for why that distinction is
164
+ load-bearing rather than pedantic). With neither, there is no absolute to
165
+ grade against and the command falls back to pure skew detection, which is
166
+ what actually caught 2026-08-04: nobody knew what to expect, but two
167
+ lanes disagreeing was already conclusive.
168
+ """
169
+ if expected:
170
+ return expected.lstrip("v"), None
171
+ if not use_pypi:
172
+ return None, None
173
+ from coord.health.pypi import latest_release_any # noqa: PLC0415
174
+
175
+ try:
176
+ _project, latest, _all = latest_release_any(index_url=index_url, timeout=timeout)
177
+ except Exception as exc: # noqa: BLE001 — read-only, degrade to skew-only
178
+ return None, f"could not read the PyPI simple index ({exc}); checking skew only"
179
+ if latest is None:
180
+ return None, "PyPI simple index returned no release; checking skew only"
181
+ return latest.raw, None
182
+
183
+
184
+ @release_group.command(
185
+ "verify",
186
+ help=(
187
+ "Assert every deploy lane on every host actually reflects the "
188
+ "released version (#1834). Read-only; safe to run mid-flight."
189
+ ),
190
+ )
191
+ @_CONFIG_OPTION
192
+ @click.option(
193
+ "--expected",
194
+ default=None,
195
+ help=(
196
+ "The version every lane must be on (leading 'v' optional). Without "
197
+ "it, the command reports skew BETWEEN lanes, which is what the "
198
+ "2026-08-04 incident actually looked like."
199
+ ),
200
+ )
201
+ @click.option(
202
+ "--pypi/--no-pypi",
203
+ "use_pypi",
204
+ default=True,
205
+ show_default=True,
206
+ help=(
207
+ "Resolve --expected from the PyPI simple index (the released "
208
+ "version). On by default since #2052: without an expected version "
209
+ "this command compares the fleet against ITSELF, so a fleet that is "
210
+ "uniformly four releases behind reports crit=0."
211
+ ),
212
+ )
213
+ @click.option("--machine", "machine_filter", default=None,
214
+ help="Only poll this machine (still reports it as one lane set).")
215
+ @click.option("--timeout", default=5.0, show_default=True,
216
+ help="Per-host HTTP timeout, seconds.")
217
+ @click.option("--json", "as_json", is_flag=True, help="Emit the report as JSON.")
218
+ @click.option("-v", "--verbose", is_flag=True, help="Show each lane's resolved path.")
219
+ @click.option(
220
+ "--exit-code/--no-exit-code",
221
+ default=True,
222
+ show_default=True,
223
+ help="Exit 2 on crit, 1 on warn/unknown (mirrors `coord health`).",
224
+ )
225
+ def release_verify(
226
+ config_path: Path,
227
+ expected: str | None,
228
+ use_pypi: bool,
229
+ machine_filter: str | None,
230
+ timeout: float,
231
+ as_json: bool,
232
+ verbose: bool,
233
+ exit_code: bool,
234
+ ) -> None:
235
+ """Post-release: prove the fleet is on the version you think it is.
236
+
237
+ Runs entirely over HTTP — each machine's own ``/health`` plus the
238
+ daemon's ``/board`` — so it works from a thin client with no checkout and
239
+ no credentials, and it never writes anything anywhere.
240
+ """
241
+ import json as _json # noqa: PLC0415
242
+
243
+ from coord import release_verify as rv # noqa: PLC0415
244
+ from coord.commands._common import _load_config # noqa: PLC0415
245
+
246
+ config = _load_config(config_path)
247
+ index_url = getattr(getattr(config, "health", None), "pypi_index_url",
248
+ "https://pypi.org/simple")
249
+ resolved, warning = _resolve_expected(
250
+ expected, use_pypi=use_pypi, index_url=index_url, timeout=timeout
251
+ )
252
+ if warning and not as_json:
253
+ click.echo(f"warning: {warning}", err=True)
254
+
255
+ machine_health, unreachable, daemon_host, daemon_name = rv.gather(
256
+ config, timeout=timeout, machine_filter=machine_filter
257
+ )
258
+ report = rv.verify(
259
+ machine_health=machine_health,
260
+ unreachable=unreachable,
261
+ daemon_host=daemon_host,
262
+ daemon_host_name=daemon_name,
263
+ expected=resolved,
264
+ )
265
+
266
+ if as_json:
267
+ click.echo(_json.dumps(report.to_dict(), indent=2, sort_keys=True))
268
+ else:
269
+ click.echo(rv.render(report, verbose=verbose))
270
+
271
+ if exit_code and report.exit_code:
272
+ sys.exit(report.exit_code)
273
+
274
+
275
+ # ──────────────────────────────────────────────────────────────────────────
276
+ # `coord release propagate` — the PROPAGATE half (#1835, PKG-7)
277
+ # ──────────────────────────────────────────────────────────────────────────
278
+ #
279
+ # The I/O shell over `coord.release_propagate`. Everything that decides
280
+ # anything — is the fleet quiescent, in what order may lanes roll, which
281
+ # deploy gates a finished roll releases — lives in that module and is unit
282
+ # tested without a fleet. What lives here is the part that needs one:
283
+ # fetching the board, POSTing to agents, running the verifier, appending the
284
+ # journal.
285
+ #
286
+ # Publish and propagate are separate on purpose and the separation is the
287
+ # whole design: see `.github/workflows/auto-release.yml` and
288
+ # `coord/release_propagate.py`'s module docstring.
289
+
290
+
291
+ def _state_dir() -> Path:
292
+ from coord.platform_paths import default_coord_dir # noqa: PLC0415
293
+
294
+ return default_coord_dir()
295
+
296
+
297
+ def _fetch_board() -> tuple[dict, str | None]:
298
+ """``(board_payload, error)`` — never raises.
299
+
300
+ A board this command cannot read is a *deferral*, not a crash: the
301
+ propagation timer runs unattended, and an unreadable board means we
302
+ cannot prove the fleet is idle, which is exactly the state in which the
303
+ safe move is to do nothing and say so.
304
+ """
305
+ from coord import release_verify as rv # noqa: PLC0415
306
+
307
+ try:
308
+ return rv._default_board_fetch() or {}, None
309
+ except Exception as exc: # noqa: BLE001 — see docstring
310
+ return {}, f"{type(exc).__name__}: {exc}"
311
+
312
+
313
+ def _daemon_machine_name(
314
+ config, override: str | None, machine_health: dict | None = None
315
+ ) -> str | None:
316
+ """Which machine in ``coordinator.yml`` runs ``coord-serve``.
317
+
318
+ The daemon must lead every roll (see :func:`coord.release_propagate.
319
+ plan_lanes`), so getting this wrong is not cosmetic — it reintroduces
320
+ the documented 405, and #2052 watched exactly that happen: a partial
321
+ revert briefly left the daemon host on 0.5.4 while both callers sat on
322
+ 0.5.8, because nothing could name the daemon and the roll fell back to
323
+ ``coordinator.yml`` order.
324
+
325
+ Resolution order, derivation first and guesswork nowhere:
326
+
327
+ 1. the explicit ``--daemon-host`` flag;
328
+ 2. **derived** — the machine whose own ``/health`` reports a running
329
+ ``coord-serve`` unit (:func:`coord.release_verify.
330
+ daemon_host_from_health`). This is the fact itself, not a proxy for it;
331
+ 3. the host in the configured ``board_service`` URL matched against each
332
+ machine's host — still derived, just from config rather than from the
333
+ fleet;
334
+ 4. ``None``, which the caller treats as *refuse the run*. Ordering is the
335
+ one thing protecting against the 405; a run that cannot order itself
336
+ must stop, not roll in whatever order the file happens to list.
337
+ """
338
+ machines = list(getattr(config, "machines", ()) or ())
339
+ if override:
340
+ return override
341
+
342
+ if machine_health:
343
+ from coord.release_verify import daemon_host_from_health # noqa: PLC0415
344
+
345
+ derived = daemon_host_from_health(machine_health)
346
+ if derived:
347
+ return derived
348
+
349
+ try:
350
+ from urllib.parse import urlparse # noqa: PLC0415
351
+
352
+ from coord.client import resolve_board_service # noqa: PLC0415
353
+
354
+ svc = resolve_board_service()
355
+ if svc is None:
356
+ return None
357
+ host = (urlparse(svc.url).hostname or "").lower()
358
+ except Exception: # noqa: BLE001
359
+ return None
360
+ if not host:
361
+ return None
362
+ for machine in machines:
363
+ if str(getattr(machine, "host", "")).lower() == host:
364
+ return machine.name
365
+ if machine.name.lower() == host:
366
+ return machine.name
367
+ return None
368
+
369
+
370
+ def _post(url: str, payload: dict, *, timeout: float) -> tuple[int | None, dict, str]:
371
+ """POST JSON, tolerantly. ``(status, body, error)``."""
372
+ import httpx # noqa: PLC0415
373
+
374
+ try:
375
+ resp = httpx.post(url, json=payload, timeout=timeout)
376
+ except Exception as exc: # noqa: BLE001
377
+ return None, {}, f"{type(exc).__name__}: {exc}"
378
+ try:
379
+ body = resp.json()
380
+ except Exception: # noqa: BLE001
381
+ body = {}
382
+ return resp.status_code, (body if isinstance(body, dict) else {}), ""
383
+
384
+
385
+ def _lane_versions_by_host(report) -> dict[str, list[str | None]]:
386
+ out: dict[str, list[str | None]] = {}
387
+ for lane in report.lanes:
388
+ out.setdefault(lane.host, []).append(lane.version)
389
+ return out
390
+
391
+
392
+ @release_group.command(
393
+ "propagate",
394
+ help=(
395
+ "Roll the released version onto each host at ITS next quiescent "
396
+ "window (#2067 — per host, not fleet-wide), verify it, and roll "
397
+ "back on red (#1835). Safe to run from a timer: a busy host is a "
398
+ "recorded deferral for that host, not a failure of the run."
399
+ ),
400
+ )
401
+ @_CONFIG_OPTION
402
+ @click.option("--target", "target", default=None,
403
+ help="Version to propagate (leading 'v' optional). Default: PyPI's latest.")
404
+ @click.option("--daemon-host", "daemon_host_override", default=None,
405
+ help="Machine name running coord-serve. It rolls FIRST — a caller "
406
+ "must never reach an endpoint its daemon predates. Normally "
407
+ "DERIVED from the fleet's own /health; pass this when it "
408
+ "cannot be, since an unorderable multi-host run refuses.")
409
+ @click.option("--lane", "lane_filter", multiple=True,
410
+ type=click.Choice(["python", "units", "tui"]),
411
+ help="Only roll these lanes (repeatable). Default: all of them.")
412
+ @click.option("--dry-run", is_flag=True,
413
+ help="Print the window verdict and the roll plan; change nothing.")
414
+ @click.option("--force", is_flag=True,
415
+ help="Roll even if the fleet is busy. This KILLS in-flight headless "
416
+ "workers — the whole reason propagation is quiescence-scheduled.")
417
+ @click.option("--verify/--no-verify", "do_verify", default=True, show_default=True,
418
+ help="Run `coord release verify` as the final gate.")
419
+ @click.option("--rollback-on-red/--no-rollback-on-red", default=True, show_default=True,
420
+ help="Roll every updated host back to its previous venv generation "
421
+ "when verification comes back CRIT *on a lane this run could "
422
+ "actually roll* (#2052). Findings on lanes propagation has no "
423
+ "channel for are advisory and never trigger this.")
424
+ @click.option("--release-holds/--no-release-holds", "release_holds", default=True,
425
+ show_default=True,
426
+ help="After a VERIFIED roll, release the drive-queue deploy gates "
427
+ "(#1757) that were waiting for exactly this deploy.")
428
+ @click.option("--timeout", default=180.0, show_default=True,
429
+ help="Seconds to wait for each agent to report the new version.")
430
+ @click.option("--cordon/--no-cordon", "do_cordon", default=True, show_default=True,
431
+ help="#2101: stop each behind host from starting NEW work until it "
432
+ "is up to date, so it drains into a rollable state instead of "
433
+ "waiting for a window that never comes. In-flight work is "
434
+ "never killed. --no-cordon also CLEARS any cordon this "
435
+ "mechanism already set — turning it off must release the "
436
+ "fleet, not freeze it.")
437
+ @click.option("--cordon-after", default=None, type=int,
438
+ help="Releases behind before a host is cordoned (default: 1, i.e. "
439
+ "any drift). Raise it if release cadence ever makes one "
440
+ "fleet drain per release too expensive — see #2101 trap F.")
441
+ @click.option("--cordon-ttl", default=None, type=float,
442
+ help="Seconds a cordon stays effective without being renewed "
443
+ "(default 3600). This is what stops a run killed mid-drain "
444
+ "from cordoning the fleet forever.")
445
+ @click.option("--drain-deadline", default=None, type=float,
446
+ help="Seconds a host may fail to drain before the cordon "
447
+ "escalates loudly (default 5400).")
448
+ @click.option("--json", "as_json", is_flag=True, help="Emit the propagation record as JSON.")
449
+ def release_propagate( # noqa: PLR0912, PLR0915 — a pipeline; the decisions are elsewhere
450
+ config_path: Path,
451
+ target: str | None,
452
+ daemon_host_override: str | None,
453
+ lane_filter: tuple[str, ...],
454
+ dry_run: bool,
455
+ force: bool,
456
+ do_verify: bool,
457
+ rollback_on_red: bool,
458
+ release_holds: bool,
459
+ timeout: float,
460
+ do_cordon: bool,
461
+ cordon_after: int | None,
462
+ cordon_ttl: float | None,
463
+ drain_deadline: float | None,
464
+ as_json: bool,
465
+ ) -> None:
466
+ """One propagation attempt. Exit 0 on deferral, 1 on red, 2 on rollback.
467
+
468
+ #2067: the window is assessed PER HOST, not fleet-wide. A host with a
469
+ live assignment or a running drive-queue entry defers on its own; the
470
+ others roll and get verified this run regardless. The one case that
471
+ still defers the whole run is the daemon host itself being occupied —
472
+ every other host's python lane has to wait behind it (see
473
+ ``coord/release_propagate.py``'s LANE ORDER section) — and the case a
474
+ signal can't be pinned to any one host at all (an unreadable board), in
475
+ which nothing can be proven safe to roll.
476
+
477
+ #2052: the final gate is scoped to the lanes this run attempted and could
478
+ have moved. Verify grades lanes propagation cannot roll — the operator's
479
+ ``~/.coord-cli-venv`` and a remote ``coord-tui`` binary, currently — and
480
+ holding a roll to those made every successful run red, which
481
+ ``--rollback-on-red`` then reverted. Those findings are still reported and
482
+ journalled in full; they are simply not evidence about *this* roll.
483
+
484
+ #2069: the python lane's reach used to stop at ``coord-agent`` — a venv
485
+ could swap cleanly while ``coord-serve`` kept serving the generation it
486
+ started with, and this command still exited green. ``_roll_python`` now
487
+ also restarts ``coord-serve``/``coord-web``/``coord-drive-queue`` on
488
+ whichever host actually runs them, right after that host's own ``/update``
489
+ lands, so the ``coord-serve process`` and ``<unit> spawns`` findings are
490
+ graded like any other python-lane lane instead of being permanently
491
+ advisory.
492
+
493
+ #2101: this command no longer only WAITS for a window, it CREATES one.
494
+ Every host that is behind the target is *cordoned* — no new agents route
495
+ there, in-flight work is untouched — so it drains itself into a rollable
496
+ state; the moment it is rolled it is uncordoned again. That is why the
497
+ version sweep below happens BEFORE the "fleet is busy, defer" branch: a
498
+ run that defers without cordoning is a run that will defer again in 20
499
+ minutes for exactly the same reason, which is how the fleet sat eleven
500
+ releases behind for a day with elitebook idle and rollable throughout.
501
+ """
502
+ import json as _json # noqa: PLC0415
503
+ import time # noqa: PLC0415
504
+
505
+ from coord import release_propagate as rp # noqa: PLC0415
506
+ from coord import release_verify as rv # noqa: PLC0415
507
+ from coord.commands._common import AGENT_PORT, _load_config # noqa: PLC0415
508
+
509
+ config = _load_config(config_path)
510
+ state_dir = _state_dir()
511
+ record = rp.PropagationRecord(started_at=time.time(), dry_run=dry_run)
512
+
513
+ def _finish(status: str, exit_code: int = 0) -> None:
514
+ record.status = status
515
+ record.finished_at = time.time()
516
+ if not dry_run:
517
+ try:
518
+ rp.append_record(state_dir, record)
519
+ rp.trim_journal(state_dir)
520
+ except OSError as exc:
521
+ click.echo(f"warning: could not append the propagation journal: {exc}",
522
+ err=True)
523
+ if as_json:
524
+ click.echo(_json.dumps(record.to_dict(), indent=2, sort_keys=True))
525
+ else:
526
+ click.echo("\n".join(rp.render_record(record)))
527
+ sys.exit(exit_code)
528
+
529
+ # ── 1. what version are we propagating? ──────────────────────────────
530
+ index_url = getattr(getattr(config, "health", None), "pypi_index_url",
531
+ "https://pypi.org/simple")
532
+ resolved, warning = _resolve_expected(
533
+ target, use_pypi=not target, index_url=index_url, timeout=10.0
534
+ )
535
+ if warning:
536
+ click.echo(f"warning: {warning}", err=True)
537
+ record.target_version = rp.normalize_version(resolved)
538
+ if not record.target_version:
539
+ record.error = (
540
+ "could not resolve a target version — pass --target, or fix "
541
+ "access to the PyPI simple index"
542
+ )
543
+ _finish(rp.STATUS_FAILED, 1)
544
+
545
+ # ── 2. is there a window? (fleet + per-host, #2067) ──────────────────
546
+ board, board_error = _fetch_board()
547
+ extra_busy = []
548
+ if board_error:
549
+ extra_busy.append(
550
+ rp.Busy(kind="board unreadable", subject="/board", detail=board_error)
551
+ )
552
+ quiescence = rp.assess_quiescence(
553
+ queue_entries=board.get("drive_queue") or [],
554
+ assignments=board.get("assignments") or [],
555
+ issues=board.get("issues") or [],
556
+ extra_busy=extra_busy,
557
+ )
558
+ record.quiescence = quiescence.to_dict()
559
+ if quiescence.stale:
560
+ # #2110: a `running` row this assessment could disprove (its issue is
561
+ # merged/closed) — not a busy signal, but not silent either. Printed
562
+ # unconditionally, not just under `--json`, so a plain journal read
563
+ # shows the fleet self-corrected a stale row instead of that fact
564
+ # only ever existing inside `record.quiescence["stale"]`.
565
+ click.echo(
566
+ "note: ignoring stale drive-queue row(s) whose issue already "
567
+ f"landed: {', '.join(quiescence.stale)} (run `coord drive-queue "
568
+ "tick --reconcile-only` to clear them for good)",
569
+ err=True,
570
+ )
571
+
572
+ hosts = [m.name for m in (getattr(config, "machines", ()) or ())]
573
+ busy_hosts = quiescence.busy_hosts()
574
+ # #2067: a signal that cannot be pinned to a host (the board itself
575
+ # unreadable, a drive-queue entry with no recorded launch host) has to
576
+ # block every host — and so does every configured host individually
577
+ # being occupied, which is the same outcome by a different route.
578
+ fully_busy = bool(quiescence.fleet_wide_busy) or (
579
+ bool(hosts) and busy_hosts.issuperset(hosts)
580
+ )
581
+ if quiescence.busy and force:
582
+ click.echo(
583
+ "warning: --force — rolling over a BUSY fleet; in-flight "
584
+ f"headless workers will be killed ({quiescence.reason})",
585
+ err=True,
586
+ )
587
+ busy_hosts = set() # --force overrides per-host busyness too
588
+
589
+ # ── 3. who still needs it, and in what order? ────────────────────────
590
+ #
591
+ # #2101: this sweep used to sit BELOW the "fleet fully busy → defer"
592
+ # return, because a run that could roll nothing had nothing to learn from
593
+ # it. That is no longer true: a busy fleet is exactly the fleet that needs
594
+ # cordoning, and cordoning needs to know who is behind. The cost is one
595
+ # `/health` sweep (10s, parallel) on a tick that would otherwise have
596
+ # returned immediately — paid so a deferral can make the NEXT run
597
+ # different from this one instead of repeating it forever.
598
+ machine_health, unreachable, daemon_facts, daemon_label = rv.gather(
599
+ config, timeout=10.0
600
+ )
601
+ before = rv.verify(
602
+ machine_health=machine_health, unreachable=unreachable,
603
+ daemon_host=daemon_facts, daemon_host_name=daemon_label,
604
+ expected=record.target_version,
605
+ )
606
+ current = rp.hosts_already_current(_lane_versions_by_host(before), record.target_version)
607
+
608
+ # ── 3b. cordon the hosts that are behind, so they DRAIN (#2101) ──────
609
+ #
610
+ # Before the deferral return below, on purpose: cordoning is the thing
611
+ # that turns "no window" into "a window in a few minutes". A run that
612
+ # defers without cordoning has changed nothing about why it deferred.
613
+ record.cordons = _apply_cordons(
614
+ hosts=hosts,
615
+ report=before,
616
+ target_version=record.target_version,
617
+ busy_reasons={h: quiescence.busy_reason_for_host(h) for h in hosts},
618
+ enabled=do_cordon,
619
+ threshold=cordon_after,
620
+ ttl_seconds=cordon_ttl,
621
+ drain_deadline=drain_deadline,
622
+ dry_run=dry_run,
623
+ )
624
+
625
+ if fully_busy and not force:
626
+ # The single most important line in this command: a deferral is a
627
+ # normal, recorded, exit-0 outcome. A timer that defers all night
628
+ # must be visibly *working*, not visibly failing. #2101: and now it
629
+ # has cordoned whatever is behind on the way past, so the next tick
630
+ # meets a fleet that is actually draining.
631
+ _finish(rp.STATUS_DEFERRED, 0)
632
+
633
+ # #2101: resolved AFTER the deferral above, deliberately. This refusal is
634
+ # about the ORDER a roll happens in; a run that is going to roll nothing
635
+ # has no order to get wrong, and turning "the fleet is busy" into a
636
+ # `failed` record (exit 1, systemd marks the unit failed) would teach an
637
+ # operator to ignore the one signal that matters.
638
+ daemon_name = _daemon_machine_name(config, daemon_host_override, machine_health)
639
+ if daemon_name is None and len(hosts) > 1:
640
+ # #2052 fault 2: this used to warn and roll in coordinator.yml order.
641
+ # It then briefly put the daemon host BEHIND both its callers during a
642
+ # partial revert — the documented 405 hazard the warning itself named.
643
+ # Ordering is the one thing protecting against that, so an unorderable
644
+ # run refuses. It is not a failure of the fleet, but it is a failure of
645
+ # this run, and a recorded one.
646
+ record.error = (
647
+ "could not identify which machine runs coord-serve, and this "
648
+ "fleet has more than one host — REFUSING to roll. The lane order "
649
+ "(daemon first) is the only thing preventing the documented 405, "
650
+ "and rolling in coordinator.yml order is a guess, not an order. "
651
+ "Fix the daemon host's /health so its coord-serve unit is "
652
+ "visible, or pass --daemon-host <machine>."
653
+ )
654
+ click.echo(f"error: {record.error}", err=True)
655
+ _finish(rp.STATUS_FAILED, 1)
656
+
657
+ # #2067: the daemon must lead every python-lane roll (see the module
658
+ # docstring's LANE ORDER section) — if it is itself occupied and not
659
+ # already on the target, nothing may roll ahead of it, because that
660
+ # would put a caller on a newer `coord` than the daemon it talks to
661
+ # (the documented 405). This is the one case a per-host window still
662
+ # has to defer the WHOLE run rather than just skip the busy host.
663
+ if daemon_name in busy_hosts and daemon_name not in current:
664
+ _finish(rp.STATUS_DEFERRED, 0)
665
+
666
+ still_busy = busy_hosts - set(current)
667
+ rolls = rp.plan_lanes(
668
+ daemon_host=daemon_name,
669
+ hosts=hosts,
670
+ lanes=lane_filter or rp.ALL_LANES,
671
+ skip_hosts=set(current) | busy_hosts,
672
+ )
673
+ for host in current:
674
+ record.lanes.append(
675
+ {"lane": "-", "host": host, "ok": None,
676
+ "detail": f"already on v{record.target_version}"}
677
+ )
678
+ for host in sorted(still_busy):
679
+ # #2067: the whole point — a busy host defers on its own, it does
680
+ # not hold every OTHER host hostage. A re-run resumes it, same as
681
+ # an unreachable host or a failed daemon roll does today.
682
+ record.lanes.append(
683
+ {"lane": "-", "host": host, "ok": None,
684
+ "detail": f"deferred — {quiescence.busy_reason_for_host(host)}"}
685
+ )
686
+ for host, reason in sorted(unreachable.items()):
687
+ record.lanes.append(
688
+ {"lane": "-", "host": host, "ok": False, "detail": f"unreachable: {reason}"}
689
+ )
690
+
691
+ if dry_run:
692
+ for roll in rolls:
693
+ record.lanes.append(
694
+ {"lane": roll.lane, "host": roll.host, "ok": None,
695
+ "detail": f"would roll ({roll.rationale})"}
696
+ )
697
+ if rolls:
698
+ _finish(rp.STATUS_ROLLED, 0)
699
+ _finish(rp.STATUS_DEFERRED if still_busy else rp.STATUS_UP_TO_DATE, 0)
700
+
701
+ if not rolls:
702
+ _finish(rp.STATUS_DEFERRED if still_busy else rp.STATUS_UP_TO_DATE, 0)
703
+
704
+ # ── 4. roll, in the planned order ────────────────────────────────────
705
+ by_name = {m.name: m for m in (getattr(config, "machines", ()) or ())}
706
+ updated_hosts: list[str] = []
707
+ local_name = _local_machine_name(config)
708
+
709
+ # #1835 review: plan_lanes() puts the daemon host's python lane first
710
+ # specifically so "a caller must never reach an endpoint its daemon
711
+ # predates" holds — but that is only true if a failure there actually
712
+ # stops every other host's python lane from rolling forward. Without
713
+ # this, a failed daemon roll left the loop free to advance every other
714
+ # host to target_version anyway, reproducing the documented 405 skew
715
+ # for the rest of this run (up to --timeout seconds per remaining
716
+ # host) until the final `coord release verify` gate caught it — or,
717
+ # with --no-verify, not at all. So this is an enforced precondition,
718
+ # not just an ordering suggestion: once the daemon's own python roll
719
+ # fails, every other host's python lane is skipped outright.
720
+ #
721
+ # #2095 review: this used to be set from `_roll_python`'s own overall
722
+ # `ok`, which #2095 correctly made `False` whenever ANY restarted
723
+ # sibling failed — including coord-web, which has nothing to do with the
724
+ # 405 hazard this flag exists to prevent (that hazard is specifically
725
+ # "a caller running ahead of a daemon whose coord-serve hasn't reached
726
+ # target_version yet"). Reusing that aggregate here meant a coord-web-
727
+ # only failure on the daemon host — coord-serve itself restarts and
728
+ # reports target_version fine — would ALSO halt every other host's
729
+ # python lane for the rest of the run: a materially larger blast radius
730
+ # than before #2095, and exactly the shape of the 2026-08-10 incident
731
+ # this issue is about (dellserver's coord-serve was fine; coord-web was
732
+ # what failed). `_roll_python` now reports `serve_unit_ok` separately —
733
+ # whether coord-serve ITSELF is confirmed on target_version — and that,
734
+ # not the lane's own `ok`, is what decides this.
735
+ daemon_python_failed = False
736
+
737
+ for roll in rolls:
738
+ machine = by_name.get(roll.host)
739
+ if machine is None:
740
+ record.lanes.append({"lane": roll.lane, "host": roll.host, "ok": False,
741
+ "detail": "not in coordinator.yml"})
742
+ continue
743
+ if roll.host in unreachable:
744
+ record.lanes.append({"lane": roll.lane, "host": roll.host, "ok": False,
745
+ "detail": "skipped — host unreachable"})
746
+ continue
747
+
748
+ if (
749
+ roll.lane == rp.LANE_PYTHON
750
+ and daemon_python_failed
751
+ and roll.host != daemon_name
752
+ ):
753
+ # Not a failure of *this* host — it was simply never attempted,
754
+ # because attempting it would put it ahead of a daemon that
755
+ # cannot yet serve it. A re-run after the daemon is fixed
756
+ # should resume here, not treat this host as needing rollback.
757
+ detail = (
758
+ "not attempted — daemon host's python lane failed; rolling "
759
+ "this host first would reproduce the 405 skew the lane "
760
+ "order exists to prevent"
761
+ )
762
+ record.lanes.append({"lane": roll.lane, "host": roll.host, "ok": None,
763
+ "detail": detail})
764
+ click.echo(f" · {roll.label}: {detail}")
765
+ continue
766
+
767
+ if roll.lane == rp.LANE_PYTHON:
768
+ ok, detail, serve_unit_ok = _roll_python(
769
+ machine, target_version=record.target_version,
770
+ agent_port=AGENT_PORT, timeout=timeout, force=force,
771
+ )
772
+ if ok:
773
+ updated_hosts.append(roll.host)
774
+ elif roll.host == daemon_name and not serve_unit_ok:
775
+ daemon_python_failed = True
776
+ elif roll.lane == rp.LANE_UNITS:
777
+ ok, detail = _roll_units(machine, agent_port=AGENT_PORT)
778
+ else:
779
+ ok, detail = _roll_tui(
780
+ machine, target_version=record.target_version, local_name=local_name
781
+ )
782
+
783
+ # #2052: `ok is None` from a lane executor means "there is no channel
784
+ # for this lane on this host" — not a failure, and emphatically not
785
+ # something the post-roll gate may hold this run to. The remote
786
+ # coord-tui binary is the canonical case: propagation itself reports
787
+ # there is no remote install path, so counting its staleness as
788
+ # grounds for rolling back a good python roll is a category error.
789
+ entry = {"lane": roll.lane, "host": roll.host, "ok": ok, "detail": detail}
790
+ if ok is None:
791
+ entry["unrollable"] = True
792
+ record.lanes.append(entry)
793
+ click.echo(f" {'·' if ok is None else ('✓' if ok else '✗')} "
794
+ f"{roll.label}: {detail}")
795
+
796
+ # ── 4b. uncordon what just rolled, immediately (#2101) ───────────────
797
+ #
798
+ # Immediately, and not after the verify gate below: the host is on the
799
+ # target version and its agent has re-execed, so there is nothing left to
800
+ # drain for and every extra second of cordon is work the fleet is not
801
+ # doing. If verification then comes back red and rolls the host back, the
802
+ # NEXT run re-cordons it — one loop, converging, rather than a cordon
803
+ # whose lifetime is coupled to an unrelated gate.
804
+ _uncordon_hosts(updated_hosts, record.cordons)
805
+
806
+ # ── 5. the final gate ────────────────────────────────────────────────
807
+ if not do_verify:
808
+ _finish(rp.STATUS_ROLLED, 0)
809
+
810
+ machine_health, unreachable, daemon_facts, daemon_label = rv.gather(
811
+ config, timeout=10.0
812
+ )
813
+ after = rv.verify(
814
+ machine_health=machine_health, unreachable=unreachable,
815
+ daemon_host=daemon_facts, daemon_host_name=daemon_label,
816
+ expected=record.target_version,
817
+ )
818
+ record.verification = after.to_dict()
819
+
820
+ # #2052: the gate is scoped to the lanes this run attempted and could
821
+ # have moved. The full report above is still journalled verbatim — this
822
+ # narrows what may TRIGGER a rollback, not what gets reported.
823
+ gate = rp.scope_verification(record.verification, lanes=record.lanes)
824
+ record.gate = gate.to_dict()
825
+ for finding in gate.advisory:
826
+ click.echo(
827
+ f" ~ advisory [{finding.get('severity')}] {finding.get('host')} "
828
+ f"{finding.get('lane')}: {finding.get('summary')} "
829
+ "— outside propagation's reach, fix by hand",
830
+ err=True,
831
+ )
832
+ if gate.unrollable:
833
+ click.echo(
834
+ " ~ lanes with no channel from this host: "
835
+ + ", ".join(gate.unrollable),
836
+ err=True,
837
+ )
838
+
839
+ if gate.red and rollback_on_red:
840
+ # #1835: "a red post-deploy verification must roll back, not just
841
+ # report." Only the hosts THIS run updated — rolling back a host we
842
+ # never touched would undo somebody else's deliberate state.
843
+ down: list[str] = []
844
+ for host in updated_hosts:
845
+ machine = by_name.get(host)
846
+ if machine is None:
847
+ continue
848
+ ok, detail = _rollback_host(
849
+ machine, agent_port=AGENT_PORT, timeout=min(timeout, 120.0)
850
+ )
851
+ record.rolled_back.append(f"{host}: {detail}")
852
+ if not ok:
853
+ down.append(host)
854
+ click.echo(f" {'↩' if ok else '✗'} rollback {host}: {detail}")
855
+ # #2052 fault 1: a rollback that stops a service and does not restore
856
+ # it leaves the fleet WORSE off than the failed roll did — precision's
857
+ # coord-agent sat `inactive (dead)` until a human noticed. If any host
858
+ # did not come back, that is the headline, not a footnote.
859
+ if down:
860
+ record.error = (
861
+ "ROLLBACK LEFT AGENTS DOWN: "
862
+ + ", ".join(down)
863
+ + " — these hosts answered the rollback but never came back "
864
+ "on /health, and an SSH `systemctl --user restart "
865
+ "coord-agent` did not revive them either. Recover by hand "
866
+ "before anything else."
867
+ )
868
+ click.echo(f"error: {record.error}", err=True)
869
+ _finish(rp.STATUS_ROLLED_BACK, 2)
870
+
871
+ if gate.red:
872
+ _finish(rp.STATUS_FAILED, 1)
873
+
874
+ # ── 6. release the deploy gates that were waiting for this ───────────
875
+ # Reaching this line means `after.severity != "crit"` — both crit
876
+ # branches above already exit — so this roll is, definitionally, verified.
877
+ for key in rp.holds_to_release(quiescence, verified=True):
878
+ if not release_holds:
879
+ click.echo(f" · deploy gate {key} left held (--no-release-holds)")
880
+ continue
881
+ ok, detail = _release_hold(key)
882
+ if ok:
883
+ record.released_holds.append(key)
884
+ click.echo(f" {'✓' if ok else '✗'} release deploy gate {key}: {detail}")
885
+
886
+ _finish(rp.STATUS_VERIFIED, 0)
887
+
888
+
889
+ # ── #2101: the cordon loop's I/O half ───────────────────────────────────────
890
+ #
891
+ # The decisions live in `coord/release_cordon.py` (pure, clock passed in);
892
+ # what lives here is the part that needs a fleet: reading each host's python
893
+ # lane out of the `/health` sweep this command already did, writing the
894
+ # daemon-backed cordon store, and surfacing a blown drain deadline where an
895
+ # operator will actually see it.
896
+
897
+
898
+ def _python_lane_versions(
899
+ report, hosts: list[str], target_version: str | None
900
+ ) -> dict[str, str | None]:
901
+ """``{host: the OLDEST version its python lane reports}`` (#2101).
902
+
903
+ "Python lane" is whatever :func:`coord.release_propagate.verify_lane_kind`
904
+ grades as one — the venv itself plus the live `coord-serve` process —
905
+ rather than a second list that could drift from the one the roll and the
906
+ gate already use.
907
+
908
+ Two deliberate readings:
909
+
910
+ * the OLDEST readable version wins, because a host is as behind as its
911
+ most stale python lane. A venv that swapped while `coord-serve` still
912
+ runs the old generation is #2069's exact defect, and it must read as
913
+ "behind", not as "done";
914
+ * a host with an unreadable lane and no readable lane BEHIND the target is
915
+ ``None`` — "no data", never "current" (#1834). `None` is what stops the
916
+ host being cordoned on a guess *and* what stops an existing cordon being
917
+ cleared on a failed HTTP call.
918
+ """
919
+ from coord import release_cordon as rc # noqa: PLC0415
920
+ from coord import release_propagate as rp # noqa: PLC0415
921
+
922
+ seen: dict[str, list[str | None]] = {}
923
+ for lane in report.lanes:
924
+ if rp.verify_lane_kind(lane.lane) != rp.LANE_PYTHON:
925
+ continue
926
+ seen.setdefault(lane.host, []).append(lane.version)
927
+
928
+ out: dict[str, str | None] = {}
929
+ for host in hosts:
930
+ versions = seen.get(host) or []
931
+ oldest = _oldest_version(versions)
932
+ if (
933
+ oldest is not None
934
+ and any(v is None for v in versions)
935
+ and rc.version_drift(oldest, target_version) == 0
936
+ ):
937
+ # Some lane could not be read at all, and every lane that COULD be
938
+ # read is already on the target. That is not proof of "current" —
939
+ # the unreadable lane is exactly the one #1834 says will be the
940
+ # one that bites — so report "no data" and leave any existing
941
+ # cordon exactly as it is. (A readable lane that IS behind is
942
+ # proof enough to cordon, and `_oldest_version` already
943
+ # surfaced it.)
944
+ oldest = None
945
+ out[host] = oldest
946
+ return out
947
+
948
+
949
+ def _oldest_version(versions: list[str | None]) -> str | None:
950
+ """The lowest readable version in *versions*, or ``None``.
951
+
952
+ String comparison is wrong here (``0.5.9`` sorts above ``0.5.31``), so
953
+ this compares numerically component by component.
954
+ """
955
+ def _key(raw: str) -> tuple[int, ...]:
956
+ parts: list[int] = []
957
+ for chunk in raw.lstrip("vV").split("."):
958
+ digits = ""
959
+ for ch in chunk:
960
+ if not ch.isdigit():
961
+ break
962
+ digits += ch
963
+ if not digits:
964
+ break
965
+ parts.append(int(digits))
966
+ return tuple(parts)
967
+
968
+ readable = [v for v in versions if v]
969
+ if not readable:
970
+ return None
971
+ return min(readable, key=_key)
972
+
973
+
974
+ def _apply_cordons(
975
+ *,
976
+ hosts: list[str],
977
+ report,
978
+ target_version: str | None,
979
+ busy_reasons: dict[str, str],
980
+ enabled: bool,
981
+ threshold: int | None,
982
+ ttl_seconds: float | None,
983
+ drain_deadline: float | None,
984
+ dry_run: bool,
985
+ ) -> dict:
986
+ """Plan and apply this run's cordons. Returns the journal fragment.
987
+
988
+ Never raises: a cordon store this run cannot write is recorded as an
989
+ error and the roll continues on its existing (quiescence-based) rules.
990
+ Failing the whole propagation because the cordon could not be renewed
991
+ would make #2101's fix strictly worse than not having it.
992
+ """
993
+ import time # noqa: PLC0415
994
+
995
+ from coord import release_cordon as rc # noqa: PLC0415
996
+ from coord.machine_pause import ( # noqa: PLC0415
997
+ clear_cordon,
998
+ cordons as read_cordons,
999
+ set_cordon,
1000
+ )
1001
+
1002
+ outcome = rc.CordonOutcome()
1003
+ try:
1004
+ existing = read_cordons()
1005
+ except Exception as exc: # noqa: BLE001 — see docstring
1006
+ outcome.errors.append(f"could not read the cordon store: {exc}")
1007
+ return outcome.to_dict()
1008
+
1009
+ plan = rc.plan_cordons(
1010
+ target_version=target_version,
1011
+ host_versions=_python_lane_versions(report, hosts, target_version),
1012
+ existing=existing,
1013
+ now=time.time(),
1014
+ ttl_seconds=(
1015
+ rc.DEFAULT_TTL_SECONDS if ttl_seconds is None else ttl_seconds
1016
+ ),
1017
+ drain_deadline=(
1018
+ rc.DEFAULT_DRAIN_DEADLINE_SECONDS
1019
+ if drain_deadline is None
1020
+ else drain_deadline
1021
+ ),
1022
+ threshold=(
1023
+ rc.DEFAULT_DRIFT_THRESHOLD if threshold is None else threshold
1024
+ ),
1025
+ busy_reasons=busy_reasons,
1026
+ enabled=enabled,
1027
+ )
1028
+ for line in plan.render():
1029
+ click.echo(line)
1030
+ if dry_run:
1031
+ # `--dry-run` promises to change nothing, cordon store included. The
1032
+ # plan above is still printed, so a dry run answers "and what would
1033
+ # this do to the fleet's routing?" rather than going silent on it.
1034
+ outcome.errors.append("dry-run: cordon store not written")
1035
+ return {**plan.to_dict(), **outcome.to_dict()}
1036
+
1037
+ outcome.expired = list(plan.expired)
1038
+ for record in plan.cordon:
1039
+ try:
1040
+ set_cordon(
1041
+ record.machine,
1042
+ reason=record.reason,
1043
+ target_version=record.target_version,
1044
+ ttl_seconds=max(0.0, record.expires_at - record.renewed_at),
1045
+ )
1046
+ outcome.cordoned.append(record.machine)
1047
+ except Exception as exc: # noqa: BLE001 — see docstring
1048
+ outcome.errors.append(f"cordon {record.machine}: {exc}")
1049
+ click.echo(f" ✗ cordon {record.machine}: {exc}", err=True)
1050
+ for name in plan.uncordon:
1051
+ try:
1052
+ if clear_cordon(name):
1053
+ outcome.uncordoned.append(name)
1054
+ except Exception as exc: # noqa: BLE001 — see docstring
1055
+ outcome.errors.append(f"uncordon {name}: {exc}")
1056
+ click.echo(f" ✗ uncordon {name}: {exc}", err=True)
1057
+
1058
+ for escalation in plan.escalations:
1059
+ _escalate_drain(escalation)
1060
+ outcome.escalated.append(escalation.to_dict())
1061
+
1062
+ return outcome.to_dict()
1063
+
1064
+
1065
+ #: Where a blown drain deadline (#2101 trap C) is recorded, in the same
1066
+ #: escalation channel `coord drive-queue`'s own alerts use — so it shows up in
1067
+ #: the TUI's escalations panel and `coord drive escalations` with no new
1068
+ #: surface to remember to look at. Mirrors
1069
+ #: `coord.drive_queue.QUEUE_ALERT_REPO`'s pseudo-repo convention.
1070
+ DRAIN_ALERT_REPO = "(release-cordon)"
1071
+ DRAIN_ALERT_ISSUE = 0
1072
+ DRAIN_ALERT_STAGE = "release-cordon"
1073
+
1074
+
1075
+ def _escalate_drain(escalation) -> None:
1076
+ """Surface a host that will not drain — loudly, in three places.
1077
+
1078
+ stderr (the timer's journal), the escalation table (the TUI and
1079
+ `coord drive escalations`) and the propagation journal. #2101's acceptance
1080
+ criterion 4 is explicitly about the surfaced MESSAGE rather than an
1081
+ internal state change, because a silent forever-wait is the failure this
1082
+ whole mechanism replaces.
1083
+ """
1084
+ click.echo(f" ! {escalation.message}", err=True)
1085
+ try:
1086
+ from coord.state import record_drive_escalation # noqa: PLC0415
1087
+
1088
+ record_drive_escalation(
1089
+ DRAIN_ALERT_REPO,
1090
+ DRAIN_ALERT_ISSUE,
1091
+ stage=DRAIN_ALERT_STAGE,
1092
+ reason=escalation.message,
1093
+ gate_readings=(
1094
+ f"machine={escalation.machine} | "
1095
+ f"waited={escalation.waited_seconds:.0f}s | "
1096
+ f"deadline={escalation.deadline_seconds:.0f}s"
1097
+ ),
1098
+ proposed_command=escalation.command,
1099
+ )
1100
+ except Exception as exc: # noqa: BLE001 — the stderr line above is the
1101
+ # floor; an escalation table that cannot be written must not take the
1102
+ # message down with it.
1103
+ click.echo(f" (could not record the drain escalation: {exc})", err=True)
1104
+
1105
+
1106
+ def _uncordon_hosts(hosts: list[str], journal: dict) -> None:
1107
+ """Clear the release cordon on every host in *hosts*, best effort."""
1108
+ if not hosts:
1109
+ return
1110
+ from coord.machine_pause import clear_cordon # noqa: PLC0415
1111
+
1112
+ for host in hosts:
1113
+ try:
1114
+ if clear_cordon(host):
1115
+ journal.setdefault("uncordoned", []).append(host)
1116
+ click.echo(f" ✓ uncordon {host}: rolled, work may resume")
1117
+ except Exception as exc: # noqa: BLE001
1118
+ journal.setdefault("errors", []).append(f"uncordon {host}: {exc}")
1119
+ click.echo(f" ✗ uncordon {host}: {exc}", err=True)
1120
+
1121
+
1122
+ def _local_machine_name(config) -> str | None:
1123
+ """This host's name in ``coordinator.yml``, if it is in there at all."""
1124
+ import socket # noqa: PLC0415
1125
+
1126
+ here = socket.gethostname().split(".")[0].lower()
1127
+ for machine in getattr(config, "machines", ()) or ():
1128
+ if machine.name.lower() == here:
1129
+ return machine.name
1130
+ if str(getattr(machine, "host", "")).split(".")[0].lower() == here:
1131
+ return machine.name
1132
+ return None
1133
+
1134
+
1135
+ def _roll_python(machine, *, target_version: str, agent_port: int, timeout: float,
1136
+ force: bool) -> tuple[bool, str, bool]:
1137
+ """POST /update and wait for the agent to actually report the version.
1138
+
1139
+ Success is judged by the version the agent reports, never by "the POST
1140
+ was accepted" (#1568: a stale pip index makes a no-op look like a
1141
+ success) — the wait loop is ``coord agent update``'s own, reused rather
1142
+ than reimplemented so the two can't drift.
1143
+
1144
+ Three-element return, ``(ok, detail, serve_unit_ok)`` (#2095 review):
1145
+
1146
+ * ``ok`` is the whole lane's own verdict, exactly as before — #2095
1147
+ correctly made this ``False`` whenever ANY restarted sibling failed,
1148
+ coord-web included, so the lane never prints a `✓` over a real
1149
+ outage.
1150
+ * ``serve_unit_ok`` is narrower and answers a different question: is
1151
+ *coord-serve itself* — the unit whose version every other host's
1152
+ caller depends on, and the entire reason the main roll loop's
1153
+ ``daemon_python_failed`` cascade exists — confirmed to be on
1154
+ ``target_version`` and running? It is ``False`` only when the venv
1155
+ swap itself never completed (nothing downstream can be trusted
1156
+ either) or coord-serve was itself the sibling that failed to
1157
+ restart. A coord-web-only (or coord-drive-queue-only) failure
1158
+ leaves it ``True``. Reusing ``ok`` for that cascade decision used to
1159
+ mean a coord-web outage on the daemon host — coord-serve unaffected —
1160
+ also halted every other host's python lane for the rest of the run:
1161
+ a materially larger blast radius than before #2095, and exactly the
1162
+ 2026-08-10 incident's shape (dellserver's coord-serve was fine;
1163
+ coord-web was what failed). Callers deciding whether it's safe to
1164
+ let OTHER hosts proceed must key off ``serve_unit_ok``, not ``ok``.
1165
+ """
1166
+ from coord.commands.agent_ops import ( # noqa: PLC0415
1167
+ _fetch_pre_started_at,
1168
+ _wait_agents_updated,
1169
+ )
1170
+ from coord.release_verify import DAEMON_UNIT # noqa: PLC0415
1171
+
1172
+ pre = _fetch_pre_started_at([machine])
1173
+ status, body, error = _post(
1174
+ f"http://{machine.host}:{agent_port}/update",
1175
+ {"target_version": target_version, "force": force},
1176
+ timeout=15.0,
1177
+ )
1178
+ if error:
1179
+ return False, error, False
1180
+ if status == 409:
1181
+ # The agent refused: live sessions, or an editable install. Both are
1182
+ # correct refusals and neither is this command's to override.
1183
+ return False, str(body.get("error") or "refused (409)"), False
1184
+ if status != 202:
1185
+ return False, f"HTTP {status}", False
1186
+
1187
+ outcomes = _wait_agents_updated(
1188
+ [machine], target_version=target_version, timeout=timeout,
1189
+ pre_started_at=pre,
1190
+ )
1191
+ outcome = outcomes.get(machine.name) or {}
1192
+ if not outcome.get("matched"):
1193
+ return False, str(
1194
+ outcome.get("error")
1195
+ or f"still reporting v{outcome.get('version_now', '?')} after "
1196
+ f"{timeout:.0f}s (last update result: {outcome.get('result')})"
1197
+ ), False
1198
+
1199
+ # #2069: /update only ever restarted the agent — coord-serve, coord-web
1200
+ # and coord-drive-queue kept running the generation they started with
1201
+ # until a human restarted them by hand. This is the rest of the lane,
1202
+ # not a separate one: it runs against whichever of those three units the
1203
+ # freshly-restarted agent finds actually running on ITS host, so a run
1204
+ # that never touches coord-web anywhere still reports "now vX.Y.Z" clean.
1205
+ sib_ok, sib_detail, sib_failed = _restart_sibling_services(machine, agent_port=agent_port)
1206
+ if sib_ok is not False:
1207
+ # True (nothing failed) and None (this agent predates
1208
+ # /restart-services entirely — no channel to have restarted anything
1209
+ # through, see `_restart_sibling_services`) both still count as a
1210
+ # lane success: neither one is a service THIS run took down.
1211
+ return True, f"now v{target_version}; {sib_detail}", True
1212
+ # #2095: this used to return `True` here too — "the venv swap succeeded"
1213
+ # bleeding into "the lane succeeded", printed as a leading `✓` over a
1214
+ # line that itself said `FAILED to restart: coord-web`. That is exactly
1215
+ # what happened during the 2026-08-10 0.5.15 -> 0.5.26 roll: the phone
1216
+ # dashboard went offline and the run reported success. The old comment
1217
+ # here claimed `coord release verify` would catch the resulting skew as
1218
+ # the justification for staying green — it does not: verify grades
1219
+ # *versions*, and there is no coord-web lane in it at all, so a dead
1220
+ # service is invisible to the thing named as its backstop. A sibling
1221
+ # that failed to (re)start — or restarted but never answered its own
1222
+ # liveness probe, see `agent_app._probe_liveness` — is not a lane
1223
+ # success, full stop, whatever the venv itself did.
1224
+ #
1225
+ # `sib_failed` is only populated when the endpoint told us per-unit
1226
+ # detail (a real 200/500-with-`units`-body); the opaque-failure branches
1227
+ # in `_restart_sibling_services` (a network error reaching the endpoint
1228
+ # at all, or a 500 with no body) return it empty because coord-serve's
1229
+ # own fate is genuinely unknown there — treated conservatively as NOT
1230
+ # confirmed, same as before #2095's per-unit distinction existed.
1231
+ serve_unit_ok = (DAEMON_UNIT not in sib_failed) if sib_failed else False
1232
+ return False, f"now v{target_version}, but {sib_detail}", serve_unit_ok
1233
+
1234
+
1235
+ def _restart_sibling_services(
1236
+ machine, *, agent_port: int, timeout: float = 120.0
1237
+ ) -> tuple[bool | None, str, dict[str, str]]:
1238
+ """``POST /restart-services`` — the rest of a python-lane roll (#2069).
1239
+
1240
+ ``/update`` swaps the venv and re-execs *the agent* — and nothing else.
1241
+ ``coord-serve``, ``coord-web`` and ``coord-drive-queue`` keep running the
1242
+ generation they started with until something restarts them, so this is
1243
+ called right after ``/update`` reports success above. Which of the three
1244
+ units actually need restarting is decided on the agent side, from what
1245
+ it finds running on its own host (see the endpoint's docstring) — this
1246
+ function only reports what came back.
1247
+
1248
+ Three-element return: ``(ok, detail, failed)``. ``ok``/``detail`` follow
1249
+ the same tri-state convention as the other lane executors below
1250
+ (``_roll_units``/``_roll_tui``'s ``ok=None`` "no channel"):
1251
+
1252
+ * ``True`` — the endpoint answered and no unit it touched failed.
1253
+ * ``False`` (#2095) — a sibling this run took down and never brought
1254
+ back: a real outage, not a cosmetic detail to carry forward under a
1255
+ `✓`. DOES fail the python lane — see ``_roll_python``. This used to
1256
+ defer to `coord release verify` catching the resulting skew; it
1257
+ cannot — verify grades versions, not liveness, and carries no lane
1258
+ for these units at all, so relying on it left exactly the outage this
1259
+ issue is about invisible to its own named backstop.
1260
+ * ``None`` — this host's agent predates the endpoint entirely (HTTP
1261
+ 404): there is no channel here to have restarted anything through,
1262
+ the same "unrollable" shape as a lane with no executor at all, not a
1263
+ failure of this roll.
1264
+
1265
+ ``failed`` (#2095 review) is the ``{unit: detail}`` mapping of units
1266
+ explicitly confirmed to have failed to restart — empty when ``ok`` is
1267
+ not ``False``, and ALSO empty for the opaque-failure branches below (a
1268
+ network error reaching the endpoint, or a 500 with no ``units`` body),
1269
+ where no individual unit's fate is actually known. ``_roll_python`` uses
1270
+ this — not the aggregate ``ok`` — to tell whether coord-serve itself is
1271
+ the sibling that failed, which is the only thing its ``serve_unit_ok``
1272
+ (and, through it, the main roll loop's daemon-python-failed cascade)
1273
+ cares about: a coord-web-only failure must not be indistinguishable from
1274
+ a coord-serve one to that caller.
1275
+ """
1276
+ status, body, error = _post(
1277
+ f"http://{machine.host}:{agent_port}/restart-services", {}, timeout=timeout,
1278
+ )
1279
+ if error:
1280
+ return False, f"sibling service restart: {error}", {}
1281
+ if status == 404:
1282
+ # Pre-#2069 agent build: /restart-services doesn't exist yet. Not
1283
+ # this run's failure to have restarted anything through a channel
1284
+ # that was never there — `coord agent update --all` is what closes
1285
+ # this gap, not a red python lane.
1286
+ return None, "agent predates /restart-services (HTTP 404) — update the agent build", {}
1287
+ # The endpoint (`agent_app.py`'s `restart_services`) returns HTTP 500 — with the
1288
+ # *same* `{"units": {...}}` body shape as 200 — whenever any single unit fails to
1289
+ # restart. That is the exact partial-failure path this function exists to report
1290
+ # in detail, so a 500 *with a `units` body* must still be parsed below rather than
1291
+ # treated as an opaque failure. A 500 WITHOUT a `units` body is a different,
1292
+ # genuinely-unexpected failure (an unhandled exception, a proxy error, ...) —
1293
+ # Starlette's own default error page carries no such body — and must still
1294
+ # short-circuit, or a real crash would be misread as "no sibling units to
1295
+ # restart" / a false-positive success.
1296
+ if status != 200 and not (status == 500 and "units" in body):
1297
+ return False, f"sibling service restart: {body.get('error') or f'HTTP {status}'}", {}
1298
+
1299
+ units = body.get("units") or {}
1300
+ restarted = sorted(u for u, r in units.items() if isinstance(r, dict) and r.get("restarted"))
1301
+ skipped = sorted(
1302
+ u for u, r in units.items() if isinstance(r, dict) and r.get("restarted") is None
1303
+ )
1304
+ failed = {
1305
+ u: (r.get("detail") or "?")
1306
+ for u, r in units.items() if isinstance(r, dict) and r.get("restarted") is False
1307
+ }
1308
+ parts = []
1309
+ if restarted:
1310
+ parts.append(f"restarted {', '.join(restarted)}")
1311
+ if skipped:
1312
+ parts.append(f"not running here: {', '.join(skipped)}")
1313
+ if failed:
1314
+ parts.append(
1315
+ "FAILED to restart: "
1316
+ + ", ".join(f"{u} ({detail})" for u, detail in sorted(failed.items()))
1317
+ )
1318
+ if not parts:
1319
+ parts.append(body.get("detail") or "no sibling units to restart")
1320
+ return not failed, "; ".join(parts), failed
1321
+
1322
+
1323
+ def _roll_units(machine, *, agent_port: int) -> tuple[bool | None, str]:
1324
+ """POST /deploy-units — the `deploy/**` lane's deploy step (#1831).
1325
+
1326
+ Returns ``ok=None`` when this host offers no channel for the lane at all
1327
+ (see :func:`_roll_tui` and #2052): the run is not accountable for a lane
1328
+ it was structurally unable to roll.
1329
+ """
1330
+ status, body, error = _post(
1331
+ f"http://{machine.host}:{agent_port}/deploy-units", {}, timeout=30.0
1332
+ )
1333
+ if error:
1334
+ return False, error
1335
+ if status in (404, 405):
1336
+ # Bootstrap: this agent predates the endpoint. It will have it after
1337
+ # the python lane above lands, so this is a *next run* fact, not a
1338
+ # failure — recorded rather than swallowed, and NOT grounds for the
1339
+ # gate to revert everything else this run got right (#2052).
1340
+ return None, ("agent has no /deploy-units yet (predates #1835) — "
1341
+ "the next propagation will roll this lane")
1342
+ if status != 200:
1343
+ return False, str(body.get("error") or body.get("summary") or f"HTTP {status}")
1344
+ units = body.get("units") or []
1345
+ changed = [u.get("name") for u in units if u.get("action") == "updated"]
1346
+ new = [u.get("name") for u in units if u.get("action") == "new"]
1347
+ parts = []
1348
+ parts.append(f"{len(changed)} unit(s) refreshed" if changed else "units already current")
1349
+ if body.get("reloaded"):
1350
+ parts.append("daemon-reload ok")
1351
+ if new:
1352
+ parts.append(
1353
+ f"{len(new)} packaged unit(s) NOT installed here ({', '.join(sorted(map(str, new)))}) "
1354
+ "— a release does not decide which services a host runs"
1355
+ )
1356
+ return True, "; ".join(parts)
1357
+
1358
+
1359
+ def _roll_tui(
1360
+ machine, *, target_version: str, local_name: str | None
1361
+ ) -> tuple[bool | None, str]:
1362
+ """`coord tui update` — local host only, and honest about the rest.
1363
+
1364
+ ``coord-tui`` is a binary in each host's ``~/.local/bin``; there is no
1365
+ agent endpoint that installs it, so this lane can only roll where this
1366
+ command is running. Remote hosts are recorded as an explicit gap rather
1367
+ than silently omitted — a lane nobody can see is the lane that bites
1368
+ (#1834).
1369
+
1370
+ #2052: that gap returns ``ok=None``, not ``ok=False``. It used to return
1371
+ False, which the post-roll gate then read as a failed lane and
1372
+ ``--rollback-on-red`` used as grounds to revert three good python rolls.
1373
+ A lane that reports "there is no remote install path" in its own failure
1374
+ message cannot also be evidence that this run went wrong.
1375
+ """
1376
+ import subprocess # noqa: PLC0415
1377
+
1378
+ if local_name is None or machine.name != local_name:
1379
+ return None, (
1380
+ f"coord-tui is a per-host binary with no remote install path — run "
1381
+ f"`coord tui update --version {target_version}` on {machine.name}"
1382
+ )
1383
+ try:
1384
+ proc = subprocess.run(
1385
+ [sys.executable, "-m", "coord.cli", "tui", "update",
1386
+ "--version", target_version],
1387
+ capture_output=True, text=True, timeout=300,
1388
+ )
1389
+ except Exception as exc: # noqa: BLE001
1390
+ return False, f"{type(exc).__name__}: {exc}"
1391
+ if proc.returncode == 0:
1392
+ return True, f"coord-tui now v{target_version}"
1393
+ return False, (proc.stderr or proc.stdout or f"exit {proc.returncode}").strip()[:300]
1394
+
1395
+
1396
+ def _get(url: str, *, timeout: float) -> tuple[int | None, dict]:
1397
+ """GET JSON, tolerantly. ``(status, body)`` — never raises."""
1398
+ import httpx # noqa: PLC0415
1399
+
1400
+ try:
1401
+ resp = httpx.get(url, timeout=timeout)
1402
+ except Exception: # noqa: BLE001
1403
+ return None, {}
1404
+ try:
1405
+ body = resp.json()
1406
+ except Exception: # noqa: BLE001
1407
+ body = {}
1408
+ return resp.status_code, (body if isinstance(body, dict) else {})
1409
+
1410
+
1411
+ def _wait_agent_back(machine, *, agent_port: int, timeout: float) -> tuple[bool, str]:
1412
+ """Poll ``/health`` until the agent answers again. ``(back, version)``.
1413
+
1414
+ A rollback re-execs the agent process, and #2052 fault 1 is what happens
1415
+ when that re-exec does not take: precision's ``coord-agent`` went
1416
+ ``inactive (dead)`` at the moment of the rollback and stayed there until
1417
+ a human noticed. "The POST was accepted" is therefore not an outcome —
1418
+ the outcome is whether the service is serving again.
1419
+ """
1420
+ import time # noqa: PLC0415
1421
+
1422
+ deadline = time.time() + max(timeout, 1.0)
1423
+ poll = min(2.0, max(timeout / 10, 0.05))
1424
+ while True:
1425
+ status, body = _get(f"http://{machine.host}:{agent_port}/health", timeout=3.0)
1426
+ if status == 200:
1427
+ return True, str(body.get("version") or "?")
1428
+ if time.time() >= deadline:
1429
+ return False, "?"
1430
+ time.sleep(poll)
1431
+
1432
+
1433
+ def _rollback_host(
1434
+ machine, *, agent_port: int, timeout: float = 90.0
1435
+ ) -> tuple[bool, str]:
1436
+ """POST /rollback — back to the previous blue/green generation (#1241) —
1437
+ and then put the service back on its feet.
1438
+
1439
+ #2052 fault 1: "a rollback that stops a service and does not restore it
1440
+ leaves the fleet worse off than the failed roll did." This used to return
1441
+ True the instant the agent answered 202, which is a statement about the
1442
+ *request*, not about the host. It now waits for ``/health`` to answer
1443
+ again, escalates once to the documented SSH ``systemctl --user restart
1444
+ coord-agent`` (#404/#1568 — ``os.execv`` self-restart does not always
1445
+ take under systemd), and only then gives up — loudly, naming the host as
1446
+ DOWN rather than reporting a tidy "rolling back".
1447
+ """
1448
+ from coord.commands.agent_ops import _escalate_restart # noqa: PLC0415
1449
+
1450
+ status, body, error = _post(
1451
+ f"http://{machine.host}:{agent_port}/rollback", {"force": True}, timeout=30.0
1452
+ )
1453
+ if error:
1454
+ return False, error
1455
+ if status == 404:
1456
+ return False, "no previous generation on this host"
1457
+ if status != 202:
1458
+ return False, str(body.get("error") or f"HTTP {status}")
1459
+
1460
+ back, version = _wait_agent_back(machine, agent_port=agent_port, timeout=timeout)
1461
+ if back:
1462
+ return True, f"rolled back; agent is serving again on v{version}"
1463
+
1464
+ # The re-exec did not take. This is the documented systemd stall, and it
1465
+ # has a documented fix — apply it rather than handing the operator a
1466
+ # dead host and a tidy success message.
1467
+ escalated = _escalate_restart(machine)
1468
+ if escalated:
1469
+ back, version = _wait_agent_back(
1470
+ machine, agent_port=agent_port, timeout=min(timeout, 60.0)
1471
+ )
1472
+ if back:
1473
+ return True, (
1474
+ f"rolled back; agent needed an SSH `systemctl --user restart "
1475
+ f"coord-agent` but is serving again on v{version}"
1476
+ )
1477
+ return False, (
1478
+ "rolled back the venv but the agent is DOWN — it never came back on "
1479
+ f"/health within {timeout:.0f}s and "
1480
+ + (
1481
+ "the SSH restart did not revive it"
1482
+ if escalated
1483
+ else "the SSH `systemctl --user restart coord-agent` escalation "
1484
+ "could not run"
1485
+ )
1486
+ + f". Recover by hand on {machine.name}."
1487
+ )
1488
+
1489
+
1490
+ def _release_hold(key: str) -> tuple[bool, str]:
1491
+ """``coord drive-queue resume REPO ISSUE`` — the gate the deploy was for.
1492
+
1493
+ The queue's own command takes the pair, not the ``repo#issue`` key, so
1494
+ the key is split here rather than a second spelling of "resume this
1495
+ gate" being invented alongside it.
1496
+ """
1497
+ import subprocess # noqa: PLC0415
1498
+
1499
+ from coord.drive_queue import parse_key # noqa: PLC0415
1500
+
1501
+ parsed = parse_key(key)
1502
+ if parsed is None:
1503
+ return False, f"unparseable queue key {key!r}"
1504
+ repo, issue = parsed
1505
+ try:
1506
+ proc = subprocess.run(
1507
+ [sys.executable, "-m", "coord.cli", "drive-queue", "resume",
1508
+ repo, str(issue)],
1509
+ capture_output=True, text=True, timeout=60,
1510
+ )
1511
+ except Exception as exc: # noqa: BLE001
1512
+ return False, f"{type(exc).__name__}: {exc}"
1513
+ if proc.returncode == 0:
1514
+ return True, "queue released"
1515
+ return False, (proc.stderr or proc.stdout or f"exit {proc.returncode}").strip()[:200]
1516
+
1517
+
1518
+ @release_group.command(
1519
+ "cordon",
1520
+ help=(
1521
+ "Inspect, set or clear release cordons (#2101). A cordoned machine "
1522
+ "starts no NEW work — in-flight work is never touched — so it drains "
1523
+ "into a state where `coord release propagate` can roll it. "
1524
+ "`coord release propagate` manages these automatically; this command "
1525
+ "is the operator's window into them and the documented override for a "
1526
+ "host that will not drain."
1527
+ ),
1528
+ )
1529
+ @click.argument("machines", nargs=-1)
1530
+ @click.option("--clear", "clear", is_flag=True,
1531
+ help="Clear the named machines' cordons (or --all of them). "
1532
+ "This lets work resume and LEAVES THE HOST BEHIND — the "
1533
+ "next propagate run will cordon it again unless whatever "
1534
+ "was wedged has been fixed.")
1535
+ @click.option("--all", "all_machines", is_flag=True,
1536
+ help="With --clear: clear every release cordon.")
1537
+ @click.option("--reason", default="", help="Free text stored with the cordon.")
1538
+ @click.option("--target", "target_version", default=None,
1539
+ help="Version this cordon is draining for; shown in every "
1540
+ "surface that renders the cordon.")
1541
+ @click.option("--ttl", default=None, type=float,
1542
+ help="Seconds before the cordon lapses on its own "
1543
+ "(default 3600). A cordon ALWAYS expires — see #2101.")
1544
+ @click.option("--json", "as_json", is_flag=True, help="Emit JSON.")
1545
+ def release_cordon(
1546
+ machines: tuple[str, ...],
1547
+ clear: bool,
1548
+ all_machines: bool,
1549
+ reason: str,
1550
+ target_version: str | None,
1551
+ ttl: float | None,
1552
+ as_json: bool,
1553
+ ) -> None:
1554
+ """List (no args), set, or clear release cordons.
1555
+
1556
+ Every cordon carries an owner, a reason, a creation time and an expiry,
1557
+ and is stored separately from `coord pause` — so this command can never
1558
+ clear a pause an operator set by hand, and `coord unpause` can never lift
1559
+ a cordon out from under a drain (#2101 trap A).
1560
+ """
1561
+ import json as _json # noqa: PLC0415
1562
+ import time # noqa: PLC0415
1563
+
1564
+ from coord import release_cordon as rc # noqa: PLC0415
1565
+ from coord.machine_pause import ( # noqa: PLC0415
1566
+ clear_cordon,
1567
+ cordons as read_cordons,
1568
+ set_cordon,
1569
+ )
1570
+
1571
+ if clear:
1572
+ targets = list(machines)
1573
+ if all_machines:
1574
+ targets = sorted(read_cordons())
1575
+ if not targets:
1576
+ raise click.ClickException(
1577
+ "name at least one machine, or pass --all"
1578
+ )
1579
+ cleared = [name for name in targets if clear_cordon(name)]
1580
+ if as_json:
1581
+ click.echo(_json.dumps({"cleared": cleared}, indent=2, sort_keys=True))
1582
+ elif cleared:
1583
+ click.echo("uncordoned: " + ", ".join(cleared))
1584
+ click.echo(
1585
+ "note: these hosts are still BEHIND the released version — "
1586
+ "the next `coord release propagate` will cordon them again "
1587
+ "unless the thing that stopped them draining is fixed."
1588
+ )
1589
+ else:
1590
+ click.echo("nothing to do — none of those machines was cordoned")
1591
+ return
1592
+
1593
+ if machines:
1594
+ written = [
1595
+ set_cordon(
1596
+ name,
1597
+ reason=reason or "cordoned by hand",
1598
+ target_version=target_version,
1599
+ ttl_seconds=ttl,
1600
+ )
1601
+ for name in machines
1602
+ ]
1603
+ if as_json:
1604
+ click.echo(
1605
+ _json.dumps([c.to_dict() for c in written], indent=2, sort_keys=True)
1606
+ )
1607
+ else:
1608
+ for record in written:
1609
+ click.echo(f"⊘ {record.machine}: {record.describe()}")
1610
+ return
1611
+
1612
+ now = time.time()
1613
+ active = read_cordons(now=now)
1614
+ if as_json:
1615
+ click.echo(
1616
+ _json.dumps(
1617
+ [c.to_dict() for _, c in sorted(active.items())],
1618
+ indent=2,
1619
+ sort_keys=True,
1620
+ )
1621
+ )
1622
+ return
1623
+ if not active:
1624
+ click.echo("no machines are cordoned — the fleet is free to take work")
1625
+ return
1626
+ for name, record in sorted(active.items()):
1627
+ remaining = max(0.0, record.expires_at - now) / 60.0
1628
+ overdue = " OVERDUE" if record.overdue(now) else ""
1629
+ click.echo(
1630
+ f"⊘ {name}: {record.describe()} "
1631
+ f"[{record.age(now) / 60.0:.0f}m draining, lapses in "
1632
+ f"{remaining:.0f}m, owner={record.owner}]{overdue}"
1633
+ )
1634
+ click.echo(
1635
+ f"\nclear one with `coord release cordon --clear <machine>` "
1636
+ f"(default lifetime {rc.DEFAULT_TTL_SECONDS / 60:.0f}m — a cordon "
1637
+ "nobody renews lapses on its own)"
1638
+ )
1639
+
1640
+
1641
+ @release_group.command(
1642
+ "rollback",
1643
+ help=(
1644
+ "ONE command that puts every agent back on its previous venv "
1645
+ "generation (#1241/#1560). The escape hatch for a bad release."
1646
+ ),
1647
+ )
1648
+ @_CONFIG_OPTION
1649
+ @click.option("--machine", "machine_filter", default=None, help="Only this machine.")
1650
+ @click.option("--yes", is_flag=True, help="Skip the confirmation prompt.")
1651
+ @click.option("--wait", default=90.0, show_default=True,
1652
+ help="Seconds to wait for each agent to start serving again "
1653
+ "before escalating to an SSH restart (#2052).")
1654
+ def release_rollback(config_path: Path, machine_filter: str | None, yes: bool,
1655
+ wait: float) -> None:
1656
+ """#1560 requires rollback to be one command, not a runbook.
1657
+
1658
+ Every successful ``/update`` leaves the previous generation on disk
1659
+ (``coord.agent_update``'s two fixed blue/green slots) precisely so this
1660
+ can exist. It force-rolls: a rollback is what you reach for when the
1661
+ fleet is broken, and refusing because a worker is running on a broken
1662
+ release would be the wrong tradeoff at exactly the wrong moment.
1663
+ """
1664
+ from coord.commands._common import AGENT_PORT, _load_config # noqa: PLC0415
1665
+
1666
+ config = _load_config(config_path)
1667
+ machines = [
1668
+ m for m in (getattr(config, "machines", ()) or ())
1669
+ if not machine_filter or m.name == machine_filter
1670
+ ]
1671
+ if not machines:
1672
+ click.echo("no machines to roll back", err=True)
1673
+ sys.exit(2)
1674
+ if not yes:
1675
+ click.confirm(
1676
+ f"Roll back {len(machines)} agent(s) to the previous venv generation "
1677
+ "and restart them (this kills any in-flight worker)?",
1678
+ abort=True,
1679
+ )
1680
+ failures = 0
1681
+ for machine in machines:
1682
+ ok, detail = _rollback_host(machine, agent_port=AGENT_PORT, timeout=wait)
1683
+ click.echo(f" {'↩' if ok else '✗'} {machine.name}: {detail}")
1684
+ failures += 0 if ok else 1
1685
+ if failures:
1686
+ sys.exit(1)
1687
+
1688
+
1689
+ @release_group.command(
1690
+ "history",
1691
+ help="What propagation actually did, and when (#1835's observability gate).",
1692
+ )
1693
+ @click.option("--limit", default=40, show_default=True,
1694
+ help="Show at most this many recorded attempts (most recent last).")
1695
+ @click.option("-v", "--verbose", is_flag=True,
1696
+ help="Show every no-op attempt individually instead of collapsing runs.")
1697
+ @click.option("--json", "as_json", is_flag=True, help="Emit the raw records as JSON.")
1698
+ def release_history(limit: int, verbose: bool, as_json: bool) -> None:
1699
+ """Read the propagation journal.
1700
+
1701
+ #1835: "a silent success is indistinguishable from a silent no-op, which
1702
+ is precisely how 2026-08-04 stayed invisible." Every attempt is
1703
+ journalled, including the deferrals — so an empty history means the
1704
+ timer never ran, which is itself the finding.
1705
+ """
1706
+ import json as _json # noqa: PLC0415
1707
+
1708
+ from coord import release_propagate as rp # noqa: PLC0415
1709
+
1710
+ records = rp.read_records(_state_dir(), limit=limit)
1711
+ if as_json:
1712
+ click.echo(_json.dumps(records, indent=2, sort_keys=True))
1713
+ return
1714
+ click.echo(rp.render_history(records, verbose=verbose))
1715
+
1716
+
1717
+ # ── #2112: the nightly daemon-host release window ───────────────────────────
1718
+ #
1719
+ # `coord release propagate` waits for a quiescent window that may never
1720
+ # arrive on the daemon host specifically, because it both leads every roll
1721
+ # (the documented 405) and is the box that launches drive-queue work, so
1722
+ # almost any drive anywhere keeps it "busy" and defers the entire fleet
1723
+ # (see `coord/release_window.py`'s module docstring for the full mechanism
1724
+ # and the 2026-08-10 measurement). This section is the I/O shell over that
1725
+ # module's decisions: stop the queue timer, drain in-flight drives bounded
1726
+ # by a deadline, roll via `coord release propagate`, and ALWAYS restart the
1727
+ # timer — the pure judgement (`needs_roll`, the journal, the record shape)
1728
+ # lives in `coord/release_window.py`, same split as `release_propagate`'s
1729
+ # own I/O shell above.
1730
+ #
1731
+ # GATED ON #2110: the drain loop below calls `coord drive-queue tick
1732
+ # --reconcile-only` on every poll specifically because stopping the timer
1733
+ # also stops the ONLY thing that otherwise reconciles a finished drive's row
1734
+ # from `running` to `done` — without #2110 making that reconciliation safe to
1735
+ # call standalone, this whole mechanism would deadlock every night.
1736
+
1737
+
1738
+ def _systemctl(unit: str, action: str, *, runner=None, timeout: float = 30.0) -> tuple[bool, str]:
1739
+ """``systemctl --user <action> <unit>``, tolerantly.
1740
+
1741
+ Mirrors `coord.deploy_units.enable_timers`'s own systemctl wrapper: same
1742
+ injectable ``runner`` seam for tests (a fleet-free unit test never
1743
+ spawns a real ``systemctl``), same "never raise" contract — a window run
1744
+ must be able to report "could not stop the queue" as a normal, recorded
1745
+ outcome rather than crash.
1746
+ """
1747
+ import subprocess # noqa: PLC0415
1748
+
1749
+ run = runner or subprocess.run
1750
+ try:
1751
+ proc = run(
1752
+ ["systemctl", "--user", action, unit],
1753
+ capture_output=True, text=True, timeout=timeout,
1754
+ )
1755
+ except FileNotFoundError:
1756
+ return False, "systemctl not found (no systemd on this host)"
1757
+ except Exception as exc: # noqa: BLE001
1758
+ return False, f"{type(exc).__name__}: {exc}"
1759
+ ok = getattr(proc, "returncode", 1) == 0
1760
+ detail = (getattr(proc, "stderr", "") or getattr(proc, "stdout", "") or "").strip()
1761
+ return ok, detail or (f"{action} ok" if ok else f"{action} failed")
1762
+
1763
+
1764
+ def _run_reconcile_tick(config_path: Path, *, runner=None) -> tuple[bool, str]:
1765
+ """``coord drive-queue tick --reconcile-only`` — best effort, #2110.
1766
+
1767
+ Stopping `coord-drive-queue.timer` for the drain also stops the ONLY
1768
+ thing that otherwise reconciles a finished drive's row from ``running``
1769
+ to ``done``. Without calling this on every poll, a drive that finished
1770
+ *after* the timer stopped would stay stuck ``running`` for the rest of
1771
+ the drain no matter how generous the deadline is — the exact deadlock
1772
+ #2110 fixed reconciliation to no longer require the timer for.
1773
+ """
1774
+ import subprocess # noqa: PLC0415
1775
+
1776
+ run = runner or subprocess.run
1777
+ try:
1778
+ proc = run(
1779
+ [sys.executable, "-m", "coord.cli", "drive-queue", "tick",
1780
+ "--reconcile-only", "--config", str(config_path)],
1781
+ capture_output=True, text=True, timeout=120,
1782
+ )
1783
+ except Exception as exc: # noqa: BLE001 — best effort; the quiescence
1784
+ # check right after this call is the real signal a stuck reconcile
1785
+ # eventually clears on a later poll, not this one call's job to
1786
+ # guarantee.
1787
+ return False, f"{type(exc).__name__}: {exc}"
1788
+ ok = getattr(proc, "returncode", 1) == 0
1789
+ detail = (getattr(proc, "stderr", "") or getattr(proc, "stdout", "") or "").strip()
1790
+ return ok, detail[:200]
1791
+
1792
+
1793
+ def _drain(
1794
+ *,
1795
+ daemon_host: str,
1796
+ config_path: Path,
1797
+ deadline: float,
1798
+ poll_interval: float,
1799
+ reconcile=None,
1800
+ board_fetch=None,
1801
+ now=None,
1802
+ sleep=None,
1803
+ ):
1804
+ """Bounded wait for *daemon_host* to stop being busy (trap 2).
1805
+
1806
+ Reuses `release_propagate.assess_quiescence` — the SAME computation
1807
+ `coord release propagate` itself uses to decide whether the daemon host
1808
+ may lead a roll — rather than a second definition of "busy" (#2096's
1809
+ "two surfaces, one function" rule). ``now``/``sleep`` are injectable so
1810
+ this loop is unit-testable without a real clock; ``reconcile``/
1811
+ ``board_fetch`` default to the real subprocess/board calls.
1812
+ """
1813
+ import time as _time # noqa: PLC0415
1814
+
1815
+ from coord import release_propagate as rp # noqa: PLC0415
1816
+ from coord import release_window as rw # noqa: PLC0415
1817
+
1818
+ now_fn = now or _time.time
1819
+ sleep_fn = sleep or _time.sleep
1820
+ reconcile_fn = reconcile or (lambda: _run_reconcile_tick(config_path))
1821
+ fetch_fn = board_fetch or _fetch_board
1822
+
1823
+ start = now_fn()
1824
+ while True:
1825
+ reconcile_fn()
1826
+ board, board_error = fetch_fn()
1827
+ extra_busy = []
1828
+ if board_error:
1829
+ extra_busy.append(
1830
+ rp.Busy(kind="board unreadable", subject="/board", detail=board_error)
1831
+ )
1832
+ quiescence = rp.assess_quiescence(
1833
+ queue_entries=board.get("drive_queue") or [],
1834
+ assignments=board.get("assignments") or [],
1835
+ issues=board.get("issues") or [],
1836
+ extra_busy=extra_busy,
1837
+ )
1838
+ busy = bool(quiescence.fleet_wide_busy) or daemon_host in quiescence.busy_hosts()
1839
+ elapsed = now_fn() - start
1840
+ if not busy:
1841
+ return rw.DrainOutcome(drained=True, elapsed_seconds=elapsed, detail="drained")
1842
+ detail = quiescence.busy_reason_for_host(daemon_host) or quiescence.reason
1843
+ if elapsed >= deadline:
1844
+ return rw.DrainOutcome(drained=False, elapsed_seconds=elapsed, detail=detail)
1845
+ sleep_fn(max(0.0, min(poll_interval, deadline - elapsed)))
1846
+
1847
+
1848
+ def _run_propagate(
1849
+ *, daemon_host: str, target_version: str, config_path: Path, runner=None,
1850
+ ) -> tuple[str, int, str]:
1851
+ """``coord release propagate --daemon-host ... --target ... --json``.
1852
+
1853
+ A real subprocess of THIS interpreter (matches `_roll_tui`/
1854
+ `_release_hold` above), not an in-process call: `release_propagate` is a
1855
+ click command that calls ``sys.exit()`` itself and is not meant to be
1856
+ invoked as a plain function. ``--target`` is passed explicitly — not
1857
+ left to re-resolve PyPI's "latest" a second time — so the version this
1858
+ run decided was needed is the version that actually rolls, even if a new
1859
+ release lands on PyPI mid-drain.
1860
+
1861
+ Returns ``(status, exit_code, combined_output)``. ``status`` is parsed
1862
+ from the last JSON line of stdout (propagate's own record); parsing
1863
+ failures fall back to ``f"exit {code}"`` rather than raising — a window
1864
+ run must never crash because propagate's output was unexpected.
1865
+ """
1866
+ import json as _json # noqa: PLC0415
1867
+ import subprocess # noqa: PLC0415
1868
+
1869
+ run = runner or subprocess.run
1870
+ argv = [
1871
+ sys.executable, "-m", "coord.cli", "release", "propagate",
1872
+ "--daemon-host", daemon_host, "--target", target_version,
1873
+ "--json", "--config", str(config_path),
1874
+ ]
1875
+ try:
1876
+ proc = run(argv, capture_output=True, text=True, timeout=1800)
1877
+ except Exception as exc: # noqa: BLE001
1878
+ detail = f"{type(exc).__name__}: {exc}"
1879
+ return f"error: {detail}", 1, detail
1880
+ code = getattr(proc, "returncode", 1)
1881
+ stdout = getattr(proc, "stdout", "") or ""
1882
+ stderr = getattr(proc, "stderr", "") or ""
1883
+ status = f"exit {code}"
1884
+ for line in reversed(stdout.strip().splitlines()):
1885
+ line = line.strip()
1886
+ if not line.startswith("{"):
1887
+ continue
1888
+ try:
1889
+ payload = _json.loads(line)
1890
+ except ValueError:
1891
+ continue
1892
+ if isinstance(payload, dict) and payload.get("status"):
1893
+ status = str(payload["status"])
1894
+ break
1895
+ return status, code, (stdout + ("\n" + stderr if stderr else "")).strip()
1896
+
1897
+
1898
+ #: Where a skipped/failed nightly window (trap 3) is recorded — same
1899
+ #: escalation channel #2101's drain-deadline escalation and `coord
1900
+ #: drive-queue`'s own alerts use, so it shows up in the TUI's escalations
1901
+ #: panel and `coord drive escalations` with no new surface to remember to
1902
+ #: look at. Mirrors `_escalate_drain`'s `DRAIN_ALERT_*` convention above.
1903
+ WINDOW_ALERT_REPO = "(release-window)"
1904
+ WINDOW_ALERT_ISSUE = 0
1905
+ WINDOW_ALERT_STAGE = "release-window"
1906
+
1907
+
1908
+ def _escalate_window(record, *, reason: str) -> None:
1909
+ """Surface a skipped/failed nightly window loudly (trap 3).
1910
+
1911
+ #2112's whole point: a night propagation was supposed to happen and did
1912
+ not is exactly the state #2082 exists to make loud elsewhere in this
1913
+ fleet. Silence must not be the report here either.
1914
+ """
1915
+ click.echo(f" ! {reason}", err=True)
1916
+ try:
1917
+ from coord.state import record_drive_escalation # noqa: PLC0415
1918
+
1919
+ proposed = (
1920
+ f"coord release propagate --daemon-host {record.daemon_host} "
1921
+ f"--target {record.target_version}"
1922
+ if record.daemon_host and record.target_version
1923
+ else "coord release nightly-window --dry-run # investigate first"
1924
+ )
1925
+ record_drive_escalation(
1926
+ WINDOW_ALERT_REPO, WINDOW_ALERT_ISSUE, stage=WINDOW_ALERT_STAGE,
1927
+ reason=reason,
1928
+ gate_readings=(
1929
+ f"daemon_host={record.daemon_host} | target={record.target_version} | "
1930
+ f"daemon_version={record.daemon_version} | status={record.status}"
1931
+ ),
1932
+ proposed_command=proposed,
1933
+ )
1934
+ except Exception as exc: # noqa: BLE001 — the stderr line above is the
1935
+ # floor; an escalation table that cannot be written must not take
1936
+ # the message down with it.
1937
+ click.echo(f" (could not record the window escalation: {exc})", err=True)
1938
+
1939
+
1940
+ @release_group.command(
1941
+ "nightly-window",
1942
+ help=(
1943
+ "Guarantee the daemon host rolls at a nightly window instead of "
1944
+ "waiting for a fleet-wide quiescent moment that may never come "
1945
+ "(#2112). Stops the drive queue, drains in-flight drives (bounded), "
1946
+ "rolls with `coord release propagate`, and ALWAYS restarts the "
1947
+ "queue timer before exiting — whether or not anything rolled."
1948
+ ),
1949
+ )
1950
+ @_CONFIG_OPTION
1951
+ @click.option("--target", default=None,
1952
+ help="Version to propagate (leading 'v' optional). Default: PyPI's latest.")
1953
+ @click.option("--daemon-host", "daemon_host_override", default=None,
1954
+ help="Machine name running coord-serve. Normally DERIVED from the "
1955
+ "fleet's own /health, same as `coord release propagate`.")
1956
+ @click.option("--queue-timer", default="coord-drive-queue.timer", show_default=True,
1957
+ help="The systemd --user timer to stop for the duration of the drain.")
1958
+ @click.option("--drain-deadline", default=3600.0, show_default=True, type=float,
1959
+ help="Bounded wait (seconds) for in-flight drives before giving up, "
1960
+ "restarting the queue and reporting failure (trap 2) — the "
1961
+ "queue is never left stopped past this.")
1962
+ @click.option("--poll-interval", default=30.0, show_default=True, type=float,
1963
+ help="Seconds between drain re-checks.")
1964
+ @click.option("--dry-run", is_flag=True,
1965
+ help="Print what this run would do; stop nothing, roll nothing.")
1966
+ @click.option("--ensure-queue-running", is_flag=True,
1967
+ help="Do ONLY `systemctl --user start <queue-timer>` and exit — the "
1968
+ "SIGKILL-safe half of trap 4/acceptance 4. Wired as "
1969
+ "deploy/coord-release-window.service's ExecStopPost=, which "
1970
+ "systemd runs after the main process exits for ANY reason "
1971
+ "(success, failure, or a signal a Python `finally` cannot "
1972
+ "catch). Every other option is ignored with this flag.")
1973
+ @click.option("--json", "as_json", is_flag=True, help="Emit the window record as JSON.")
1974
+ def release_nightly_window( # noqa: PLR0912, PLR0915 — a pipeline; see module docstring
1975
+ config_path: Path,
1976
+ target: str | None,
1977
+ daemon_host_override: str | None,
1978
+ queue_timer: str,
1979
+ drain_deadline: float,
1980
+ poll_interval: float,
1981
+ dry_run: bool,
1982
+ ensure_queue_running: bool,
1983
+ as_json: bool,
1984
+ ) -> None:
1985
+ """One nightly-window attempt. Exit 0 on up-to-date/rolled/dry-run, 1+ otherwise.
1986
+
1987
+ #2112: `coord release propagate` cannot roll the daemon host past a busy
1988
+ fleet on its own — the daemon leads every roll (the documented 405), and
1989
+ dellserver's own drive-queue tick charges itself as busy for essentially
1990
+ any queued drive (see `coord/release_window.py`'s module docstring). This
1991
+ command manufactures the window propagate can't: stop the queue, drain
1992
+ what's already running (bounded — trap 2), roll, restart the queue —
1993
+ ALWAYS restart the queue (trap 4), whatever happened in between.
1994
+
1995
+ Never carries `--force` to the `coord release propagate` it shells out
1996
+ to (trap 1) — a drain that did not finish is reported and declined, not
1997
+ overridden.
1998
+ """
1999
+ import json as _json # noqa: PLC0415
2000
+ import time # noqa: PLC0415
2001
+
2002
+ from coord import release_propagate as rp # noqa: PLC0415
2003
+ from coord import release_verify as rv # noqa: PLC0415
2004
+ from coord import release_window as rw # noqa: PLC0415
2005
+ from coord.commands._common import _load_config # noqa: PLC0415
2006
+
2007
+ if ensure_queue_running:
2008
+ ok, detail = _systemctl(queue_timer, "start")
2009
+ click.echo(f"{'✓' if ok else '✗'} ensure {queue_timer} running: {detail}")
2010
+ sys.exit(0 if ok else 1)
2011
+
2012
+ config = _load_config(config_path)
2013
+ state_dir = _state_dir()
2014
+ record = rw.WindowRecord(
2015
+ started_at=time.time(), dry_run=dry_run, queue_timer=queue_timer
2016
+ )
2017
+
2018
+ def _finish(status: str, exit_code: int = 0) -> None:
2019
+ record.status = status
2020
+ record.finished_at = time.time()
2021
+ if not dry_run:
2022
+ try:
2023
+ rw.append_record(state_dir, record)
2024
+ rw.trim_journal(state_dir)
2025
+ except OSError as exc:
2026
+ click.echo(f"warning: could not append the window journal: {exc}",
2027
+ err=True)
2028
+ if as_json:
2029
+ click.echo(_json.dumps(record.to_dict(), indent=2, sort_keys=True))
2030
+ else:
2031
+ click.echo("\n".join(rw.render_record(record)))
2032
+ sys.exit(exit_code)
2033
+
2034
+ # ── 1. what version, and who leads? ───────────────────────────────────
2035
+ index_url = getattr(getattr(config, "health", None), "pypi_index_url",
2036
+ "https://pypi.org/simple")
2037
+ resolved, warning = _resolve_expected(
2038
+ target, use_pypi=not target, index_url=index_url, timeout=10.0
2039
+ )
2040
+ if warning:
2041
+ click.echo(f"warning: {warning}", err=True)
2042
+ record.target_version = rp.normalize_version(resolved)
2043
+ if not record.target_version:
2044
+ record.error = (
2045
+ "could not resolve a target version — pass --target, or fix "
2046
+ "access to the PyPI simple index"
2047
+ )
2048
+ click.echo(f"error: {record.error}", err=True)
2049
+ _escalate_window(record, reason=record.error)
2050
+ _finish(rw.STATUS_ERROR, 1)
2051
+
2052
+ machine_health, unreachable, daemon_facts, daemon_label = rv.gather(config, timeout=10.0)
2053
+ daemon_name = _daemon_machine_name(config, daemon_host_override, machine_health)
2054
+ if daemon_name is None:
2055
+ record.error = (
2056
+ "could not identify which machine runs coord-serve — pass "
2057
+ "--daemon-host, or fix its /health so the unit is visible "
2058
+ "(same requirement as `coord release propagate`)"
2059
+ )
2060
+ click.echo(f"error: {record.error}", err=True)
2061
+ _escalate_window(record, reason=record.error)
2062
+ _finish(rw.STATUS_ERROR, 1)
2063
+ record.daemon_host = daemon_name
2064
+
2065
+ report = rv.verify(
2066
+ machine_health=machine_health, unreachable=unreachable,
2067
+ daemon_host=daemon_facts, daemon_host_name=daemon_label,
2068
+ expected=record.target_version,
2069
+ )
2070
+ record.daemon_version = _python_lane_versions(
2071
+ report, [daemon_name], record.target_version
2072
+ ).get(daemon_name)
2073
+
2074
+ # ── 2. acceptance 3 — already current, so the queue is never touched ──
2075
+ if not rw.needs_roll(record.daemon_version, record.target_version):
2076
+ _finish(rw.STATUS_UP_TO_DATE, 0)
2077
+
2078
+ if dry_run:
2079
+ click.echo(
2080
+ f"would stop {queue_timer}, drain up to {drain_deadline:.0f}s "
2081
+ f"({daemon_name} currently reports v{record.daemon_version or '?'}, "
2082
+ f"target v{record.target_version}), then `coord release propagate "
2083
+ f"--daemon-host {daemon_name} --target {record.target_version}`, "
2084
+ f"then restart {queue_timer}"
2085
+ )
2086
+ _finish(rw.STATUS_DRY_RUN, 0)
2087
+
2088
+ # ── 3. stop the queue — no new drives may launch for the rest of this
2089
+ # run — and from here on ALWAYS restart it before exiting ─────────
2090
+ stop_ok, stop_detail = _systemctl(queue_timer, "stop")
2091
+ record.queue_stopped = stop_ok
2092
+ record.queue_stop_detail = stop_detail
2093
+ click.echo(f"{'✓' if stop_ok else '✗'} stop {queue_timer}: {stop_detail}")
2094
+
2095
+ status = rw.STATUS_ERROR
2096
+ exit_code = 1
2097
+ try:
2098
+ if not stop_ok:
2099
+ record.error = (
2100
+ f"could not stop {queue_timer} — refusing to drain or roll "
2101
+ f"without a guaranteed no-new-launches window: {stop_detail}"
2102
+ )
2103
+ click.echo(f"error: {record.error}", err=True)
2104
+ _escalate_window(record, reason=record.error)
2105
+ else:
2106
+ # ── 4. bounded drain (trap 2) ──────────────────────────────────
2107
+ outcome = _drain(
2108
+ daemon_host=daemon_name, config_path=config_path,
2109
+ deadline=drain_deadline, poll_interval=poll_interval,
2110
+ )
2111
+ record.drained = outcome.drained
2112
+ record.drain_seconds = outcome.elapsed_seconds
2113
+ record.drain_detail = outcome.detail
2114
+ click.echo(
2115
+ f"{'✓' if outcome.drained else '✗'} drain: "
2116
+ f"{'clean' if outcome.drained else 'TIMED OUT'} after "
2117
+ f"{outcome.elapsed_seconds:.0f}s"
2118
+ + (f" — {outcome.detail}" if outcome.detail else "")
2119
+ )
2120
+
2121
+ if not outcome.drained:
2122
+ record.error = (
2123
+ f"drain deadline ({drain_deadline:.0f}s) hit with "
2124
+ f"{daemon_name} still busy — {outcome.detail}; declining "
2125
+ "to roll (never --force from an unattended window)"
2126
+ )
2127
+ click.echo(f"error: {record.error}", err=True)
2128
+ _escalate_window(record, reason=record.error)
2129
+ status = rw.STATUS_DRAIN_TIMEOUT
2130
+ else:
2131
+ # ── 5. roll — the daemon host is now provably free ─────────
2132
+ prop_status, prop_exit, prop_output = _run_propagate(
2133
+ daemon_host=daemon_name, target_version=record.target_version,
2134
+ config_path=config_path,
2135
+ )
2136
+ record.propagate_status = prop_status
2137
+ record.propagate_exit_code = prop_exit
2138
+ record.propagate_output = prop_output
2139
+ if prop_output:
2140
+ click.echo(prop_output)
2141
+
2142
+ if prop_exit == 0 and prop_status in (
2143
+ rp.STATUS_VERIFIED, rp.STATUS_UP_TO_DATE, rp.STATUS_ROLLED,
2144
+ ):
2145
+ status = (
2146
+ rw.STATUS_UP_TO_DATE if prop_status == rp.STATUS_UP_TO_DATE
2147
+ else rw.STATUS_ROLLED
2148
+ )
2149
+ exit_code = 0
2150
+ elif prop_exit == 0 and prop_status == rp.STATUS_DEFERRED:
2151
+ # A drained daemon host still deferred: some OTHER host
2152
+ # (or an unattributable signal) is busy. Not this
2153
+ # command's #2110-shaped deadlock — that would show up
2154
+ # as the drain never clearing — but still a night that
2155
+ # did not roll, and just as loud (trap 3).
2156
+ record.error = (
2157
+ f"{daemon_name} drained clean but `coord release "
2158
+ f"propagate` still deferred (status={prop_status}) — "
2159
+ "see propagate_output for why"
2160
+ )
2161
+ click.echo(f"error: {record.error}", err=True)
2162
+ _escalate_window(record, reason=record.error)
2163
+ status = rw.STATUS_PROPAGATE_DEFERRED
2164
+ else:
2165
+ record.error = (
2166
+ f"coord release propagate did not verify a roll "
2167
+ f"(status={prop_status}, exit={prop_exit}) despite a "
2168
+ f"drained daemon host"
2169
+ )
2170
+ click.echo(f"error: {record.error}", err=True)
2171
+ _escalate_window(record, reason=record.error)
2172
+ status = rw.STATUS_PROPAGATE_FAILED
2173
+ exit_code = prop_exit or 1
2174
+ finally:
2175
+ # Trap 4 / acceptance 4: ALWAYS restart the timer — whatever
2176
+ # happened above, including an exception raised out of this block.
2177
+ # This is the in-process half of the guarantee; --ensure-queue-running
2178
+ # wired as ExecStopPost= (deploy/coord-release-window.service) is the
2179
+ # SIGKILL-safe half, since a `finally` cannot run after SIGKILL.
2180
+ # Leaving the fleet's work queue stopped is the worst outcome this
2181
+ # mechanism exists to prevent, so this runs no matter what.
2182
+ start_ok, start_detail = _systemctl(queue_timer, "start")
2183
+ record.queue_restarted = start_ok
2184
+ record.queue_restart_detail = start_detail
2185
+ click.echo(f"{'✓' if start_ok else '✗'} restart {queue_timer}: {start_detail}")
2186
+ if not start_ok:
2187
+ _escalate_window(
2188
+ record,
2189
+ reason=(
2190
+ "queue timer restart FAILED after a nightly window run — "
2191
+ f"{queue_timer} may be stopped: {start_detail}. Run "
2192
+ f"`systemctl --user start {queue_timer}` by hand NOW."
2193
+ ),
2194
+ )
2195
+
2196
+ _finish(status, exit_code)
2197
+
2198
+
2199
+ @release_group.command(
2200
+ "window-history",
2201
+ help="What the nightly release window actually did, and when (#2112).",
2202
+ )
2203
+ @click.option("--limit", default=40, show_default=True,
2204
+ help="Show at most this many recorded attempts (most recent last).")
2205
+ @click.option("--json", "as_json", is_flag=True, help="Emit the raw records as JSON.")
2206
+ def release_window_history(limit: int, as_json: bool) -> None:
2207
+ """Read the `coord release nightly-window` journal.
2208
+
2209
+ Separate from `coord release history` (`release_propagate`'s journal):
2210
+ this record carries fields — the queue stop/drain/restart outcome — a
2211
+ plain propagate attempt does not have.
2212
+ """
2213
+ import json as _json # noqa: PLC0415
2214
+
2215
+ from coord import release_window as rw # noqa: PLC0415
2216
+
2217
+ records = rw.read_records(_state_dir(), limit=limit)
2218
+ if as_json:
2219
+ click.echo(_json.dumps(records, indent=2, sort_keys=True))
2220
+ return
2221
+ if not records:
2222
+ click.echo("no nightly-window attempts recorded yet")
2223
+ return
2224
+ for rec in records:
2225
+ click.echo("\n".join(rw.render_record(rec)))
2226
+ click.echo("")
2227
+
2228
+
2229
+ # Same callback under the group, so `coord release preflight` and `coord
2230
+ # release verify` are one discoverable pair. The flat `coord
2231
+ # release-preflight` above keeps working unchanged.
2232
+ release_group.add_command(release_preflight, name="preflight")