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,525 @@
1
+ """Release cordons: **create** a propagation window instead of waiting for one
2
+ (#2101).
3
+
4
+ `coord release propagate` (#1835/#2067) waits for fleet quiescence. On a fleet
5
+ whose drive queue refills every three minutes, that window is a coincidence —
6
+ and two facts make the coincidence rare enough to never arrive:
7
+
8
+ 1. the daemon host may not be rolled while it is busy (a caller on a newer
9
+ ``coord`` than its daemon is the documented 405), and nothing may roll
10
+ ahead of the daemon, so **a busy daemon host defers the whole fleet**;
11
+ 2. every drive-queue entry charges *some* host as busy for the entire life of
12
+ the drive, and the queue relaunches on a 3-minute tick.
13
+
14
+ Observed on 2026-08-10: the fleet sat eleven releases behind for a day with
15
+ elitebook idle and rollable the whole time.
16
+
17
+ This module is the decision half of the fix. Waiting is replaced by a loop
18
+ that manufactures the window:
19
+
20
+ detect → **cordon** each behind host → drain (nothing is killed) →
21
+ roll the moment it empties → **uncordon immediately** → repeat.
22
+
23
+ THE CORDON IS A ROUTING PAUSE WITH AN OWNER
24
+ --------------------------------------------
25
+ `coord pause` already means exactly "no NEW agents route here; in-flight work
26
+ is untouched" (#1563 made it daemon-backed, which is the only reason this is
27
+ buildable at all). A cordon reuses that routing semantics — see
28
+ :mod:`coord.machine_pause`, which folds active cordons into the one
29
+ ``paused_set()`` every dispatcher already consults — but it is emphatically
30
+ **not** the same flag:
31
+
32
+ * an operator's ``coord unpause`` must not lift a cordon mid-drain;
33
+ * the post-roll uncordon must not clear a pause an operator set deliberately.
34
+
35
+ So a cordon is stored under its own key with an ``owner``, and each side
36
+ clears only its own (:func:`coord.machine_pause.clear_cordon` vs
37
+ ``local_unpause``). Trap A of #2101.
38
+
39
+ EVERY CORDON EXPIRES, BECAUSE THE THING THAT SET IT GETS RESTARTED
40
+ -------------------------------------------------------------------
41
+ The cordon lives in daemon state and the daemon itself is restarted by the
42
+ roll it is gating. A propagate run killed between "cordon" and "uncordon"
43
+ would otherwise leave every machine refusing work forever — which looks
44
+ exactly like a quiet fleet, i.e. #2082 in a new costume. Trap B.
45
+
46
+ So a :class:`Cordon` carries ``owner``, ``reason``, ``created_at`` and
47
+ ``expires_at``, and **the read side ignores an expired record** (nothing has
48
+ to run for it to lapse — a dead propagate loop cannot fail to clean up,
49
+ because cleanup is not an action). The live loop renews on every run while
50
+ the host is still behind, so a TTL comfortably longer than the propagate
51
+ timer's interval is invisible in normal operation and self-healing after a
52
+ crash.
53
+
54
+ A HOST THAT NEVER DRAINS IS AN ESCALATION, NEVER A SILENT WAIT
55
+ ---------------------------------------------------------------
56
+ A wedged worker means the cordon never lifts. :func:`plan_cordons` measures
57
+ the drain from ``created_at`` (preserved across renewals — a renewal is not
58
+ a new cordon) and, past :data:`DEFAULT_DRAIN_DEADLINE_SECONDS`, emits a
59
+ :class:`DrainEscalation`. The cordon is still renewed — the host really is
60
+ behind, and lifting it would just start work that the next run has to drain
61
+ again — but it is now loud, and its message names the override. Trap C.
62
+
63
+ THE TRIGGER IS COUPLED TO RELEASE FREQUENCY, SO IT IS A KNOB
64
+ --------------------------------------------------------------
65
+ Cordon-on-any-drift costs one fleet drain per release. Before #2081 landed,
66
+ releases cut roughly every 40 minutes — at that cadence this mechanism would
67
+ leave the fleet draining more often than working. #2081 reduced the cadence,
68
+ so the **default is any drift** (:data:`DEFAULT_DRIFT_THRESHOLD` = 1): a
69
+ fleet that is one release behind is a fleet running code nobody is testing,
70
+ and #2082 is the cost of tolerating that. The threshold is a knob
71
+ (``coord release propagate --cordon-after N``) precisely so a future cadence
72
+ change does not need a code change. Trap F.
73
+
74
+ PURITY
75
+ ------
76
+ Nothing here reads the clock, the filesystem, the network or the DB — same
77
+ split :mod:`coord.release_propagate` documents, for the same reason. The
78
+ clock is passed in; ``coord/commands/release.py`` is the I/O shell.
79
+ """
80
+
81
+ from __future__ import annotations
82
+
83
+ from dataclasses import dataclass, field
84
+ from typing import Any, Iterable, Mapping
85
+
86
+ #: Who set a cordon. An operator's ``coord pause`` is NOT an owner of any
87
+ #: cordon — it lives under a different key entirely (see the module docstring
88
+ #: and :mod:`coord.machine_pause`). This exists so a future second automated
89
+ #: owner (a deploy gate, a maintenance window) can share the mechanism
90
+ #: without either being able to clear the other's flag.
91
+ OWNER_RELEASE = "release"
92
+
93
+ #: How long a cordon record stays effective without being renewed. The
94
+ #: propagate timer runs every 15-20 minutes and renews on every run while the
95
+ #: host is still behind, so this is invisible in normal operation; what it
96
+ #: bounds is the crash case (trap B): a run killed between cordon and roll
97
+ #: leaves the fleet refusing work for at most this long, with no cleanup
98
+ #: process required — an expired record is ignored on READ.
99
+ DEFAULT_TTL_SECONDS = 3600.0
100
+
101
+ #: How long a host may fail to drain before the cordon escalates (trap C).
102
+ #: Measured from the cordon's ``created_at``, which survives renewal — a
103
+ #: renewal is the same cordon, not a new one, so a wedged host cannot reset
104
+ #: its own deadline by being cordoned again. Deliberately longer than a
105
+ #: normal drive (the thing being drained) and shorter than a night.
106
+ DEFAULT_DRAIN_DEADLINE_SECONDS = 5400.0
107
+
108
+ #: How many releases behind a host must be before it is cordoned. 1 = any
109
+ #: drift. See the module docstring's trap-F section for why this is a knob.
110
+ DEFAULT_DRIFT_THRESHOLD = 1
111
+
112
+ #: Returned by :func:`version_drift` when a host's version cannot be compared
113
+ #: to the target at all (unreadable lane, or a different minor series). A host
114
+ #: whose version we cannot read is NEVER cordoned — cordoning stops real work,
115
+ #: and doing that on a guess is the failure this fleet keeps repeating.
116
+ DRIFT_UNKNOWN = None
117
+
118
+ #: The drift reported for a host on a different ``major.minor`` series than
119
+ #: the target: larger than any sane threshold, because it genuinely is.
120
+ CROSS_SERIES_DRIFT = 9999
121
+
122
+
123
+ def normalize_version(raw: str | None) -> str | None:
124
+ """``v0.5.31`` / ``0.5.31`` -> ``0.5.31``; empty -> ``None``."""
125
+ if not raw:
126
+ return None
127
+ return str(raw).strip().lstrip("vV") or None
128
+
129
+
130
+ def _parts(raw: str | None) -> tuple[int, ...] | None:
131
+ version = normalize_version(raw)
132
+ if not version:
133
+ return None
134
+ out: list[int] = []
135
+ for chunk in version.split("."):
136
+ digits = ""
137
+ for ch in chunk:
138
+ if not ch.isdigit():
139
+ break
140
+ digits += ch
141
+ if not digits:
142
+ break
143
+ out.append(int(digits))
144
+ return tuple(out) if out else None
145
+
146
+
147
+ def version_drift(current: str | None, target: str | None) -> int | None:
148
+ """How many releases *current* is behind *target*.
149
+
150
+ ``0`` means level or ahead; ``None`` (:data:`DRIFT_UNKNOWN`) means the two
151
+ cannot be compared — an unreadable version, or a target on a different
152
+ ``major.minor`` series, where "how many releases" has no answer from the
153
+ two strings alone.
154
+
155
+ Deliberately arithmetic on the patch component rather than a lookup
156
+ against the index: this runs on every propagate tick, and a decision that
157
+ needs a network call to be made is a decision that stops being made the
158
+ moment the network hiccups. A cross-minor gap reports
159
+ :data:`CROSS_SERIES_DRIFT` — "definitely behind, by more than any
160
+ threshold" — which is the only honest reading of ``0.4.x`` vs ``0.5.y``.
161
+ """
162
+ a, b = _parts(current), _parts(target)
163
+ if a is None or b is None:
164
+ return DRIFT_UNKNOWN
165
+ if a >= b:
166
+ return 0
167
+ if a[:2] != b[:2]:
168
+ return CROSS_SERIES_DRIFT
169
+ patch_a = a[2] if len(a) > 2 else 0
170
+ patch_b = b[2] if len(b) > 2 else 0
171
+ return max(0, patch_b - patch_a)
172
+
173
+
174
+ @dataclass(frozen=True)
175
+ class Cordon:
176
+ """One machine's release cordon, as stored and as read back.
177
+
178
+ ``created_at`` is the moment the host was FIRST cordoned for this drain
179
+ and is preserved across renewals — the drain deadline (trap C) measures
180
+ from it, so a wedged host cannot postpone its own escalation forever by
181
+ being renewed. ``renewed_at``/``expires_at`` move on every renewal.
182
+ """
183
+
184
+ machine: str
185
+ owner: str = OWNER_RELEASE
186
+ reason: str = ""
187
+ target_version: str | None = None
188
+ created_at: float = 0.0
189
+ renewed_at: float = 0.0
190
+ expires_at: float = 0.0
191
+
192
+ def active(self, now: float) -> bool:
193
+ """Is this record still in force at *now*?
194
+
195
+ An ``expires_at`` of 0 (a hand-written record with no expiry) is
196
+ treated as ACTIVE — a cordon nobody can express an expiry for is
197
+ still a cordon — but every record this module writes has one.
198
+ """
199
+ return not self.expires_at or now < self.expires_at
200
+
201
+ def expired(self, now: float) -> bool:
202
+ return not self.active(now)
203
+
204
+ def age(self, now: float) -> float:
205
+ return max(0.0, now - self.created_at) if self.created_at else 0.0
206
+
207
+ def overdue(self, now: float, deadline: float = DEFAULT_DRAIN_DEADLINE_SECONDS) -> bool:
208
+ """Has this host failed to drain within *deadline* seconds?"""
209
+ return bool(self.created_at) and self.age(now) >= deadline > 0
210
+
211
+ def describe(self) -> str:
212
+ """The one sentence every surface shows (#2101 trap E).
213
+
214
+ Work stopping with no stated reason is the thing this fleet keeps
215
+ doing to itself, so this is deliberately a whole explanation rather
216
+ than a status word: ``cordoned: draining for v0.5.31``.
217
+ """
218
+ if self.target_version:
219
+ return f"cordoned: draining for v{self.target_version}"
220
+ return self.reason or "cordoned: draining for a release"
221
+
222
+ def to_dict(self) -> dict:
223
+ return {
224
+ "machine": self.machine,
225
+ "owner": self.owner,
226
+ "reason": self.reason,
227
+ "target_version": self.target_version,
228
+ "created_at": self.created_at,
229
+ "renewed_at": self.renewed_at,
230
+ "expires_at": self.expires_at,
231
+ }
232
+
233
+ @classmethod
234
+ def from_dict(cls, raw: Mapping[str, Any]) -> "Cordon":
235
+ """Type one stored record, tolerantly.
236
+
237
+ A malformed field degrades to its default rather than raising: this
238
+ is read on every dispatch decision in the fleet, and a cordon store
239
+ nobody can parse must not be able to wedge routing.
240
+ """
241
+
242
+ def _float(key: str) -> float:
243
+ try:
244
+ return float(raw.get(key) or 0.0)
245
+ except (TypeError, ValueError):
246
+ return 0.0
247
+
248
+ target = raw.get("target_version")
249
+ return cls(
250
+ machine=str(raw.get("machine") or ""),
251
+ owner=str(raw.get("owner") or OWNER_RELEASE),
252
+ reason=str(raw.get("reason") or ""),
253
+ target_version=str(target) if target else None,
254
+ created_at=_float("created_at"),
255
+ renewed_at=_float("renewed_at"),
256
+ expires_at=_float("expires_at"),
257
+ )
258
+
259
+
260
+ @dataclass(frozen=True)
261
+ class DrainEscalation:
262
+ """A host that has been cordoned longer than the drain deadline.
263
+
264
+ Surfaced, never merely recorded: #2101's acceptance criterion 4 asks for
265
+ the *message*, not an internal state change, because a silent forever-wait
266
+ is the failure this whole mechanism is meant to replace.
267
+ """
268
+
269
+ machine: str
270
+ waited_seconds: float
271
+ deadline_seconds: float
272
+ target_version: str | None = None
273
+ #: What is still holding the host, in the same prose
274
+ #: ``coord release propagate`` uses for a deferral.
275
+ busy_reason: str = ""
276
+
277
+ @property
278
+ def message(self) -> str:
279
+ minutes = self.waited_seconds / 60.0
280
+ limit = self.deadline_seconds / 60.0
281
+ version = f"v{self.target_version}" if self.target_version else "the release"
282
+ holding = f" — still busy: {self.busy_reason}" if self.busy_reason else ""
283
+ return (
284
+ f"DRAIN OVERDUE: {self.machine} has been cordoned for "
285
+ f"{minutes:.0f}m waiting to drain for {version}, past the "
286
+ f"{limit:.0f}m deadline{holding}. New work is NOT being routed "
287
+ f"there. Override with `coord release cordon --clear "
288
+ f"{self.machine}` (which lets work resume and leaves the host "
289
+ f"behind), or clear whatever is wedged and let it drain."
290
+ )
291
+
292
+ @property
293
+ def command(self) -> str:
294
+ return f"coord release cordon --clear {self.machine}"
295
+
296
+ def to_dict(self) -> dict:
297
+ return {
298
+ "machine": self.machine,
299
+ "waited_seconds": self.waited_seconds,
300
+ "deadline_seconds": self.deadline_seconds,
301
+ "target_version": self.target_version,
302
+ "busy_reason": self.busy_reason,
303
+ "message": self.message,
304
+ }
305
+
306
+
307
+ @dataclass(frozen=True)
308
+ class CordonPlan:
309
+ """What one propagate run wants the cordon store to look like.
310
+
311
+ Applied by the shell; nothing here writes. ``cordon`` holds both brand-new
312
+ cordons and renewals of existing ones (they are the same write — see
313
+ :class:`Cordon` for why ``created_at`` survives).
314
+ """
315
+
316
+ cordon: tuple[Cordon, ...] = ()
317
+ uncordon: tuple[str, ...] = ()
318
+ escalations: tuple[DrainEscalation, ...] = ()
319
+ #: Records that lapsed on their own since the last run (trap B working).
320
+ #: Reported so a self-healed cordon leaves a trace rather than silently
321
+ #: evaporating from the reasoning.
322
+ expired: tuple[str, ...] = ()
323
+ #: Cordoned hosts this run could neither prove current nor prove behind
324
+ #: (unreadable version, or drift under the threshold). Left exactly as
325
+ #: they are — see :func:`plan_cordons` for why neither direction is safe.
326
+ unknown: tuple[str, ...] = ()
327
+
328
+ @property
329
+ def empty(self) -> bool:
330
+ return not (self.cordon or self.uncordon or self.escalations or self.expired)
331
+
332
+ def to_dict(self) -> dict:
333
+ return {
334
+ "cordon": [c.to_dict() for c in self.cordon],
335
+ "uncordon": list(self.uncordon),
336
+ "escalations": [e.to_dict() for e in self.escalations],
337
+ "expired": list(self.expired),
338
+ "unknown": list(self.unknown),
339
+ }
340
+
341
+ def render(self) -> list[str]:
342
+ """Human lines for `coord release propagate`'s output."""
343
+ lines: list[str] = []
344
+ for item in self.cordon:
345
+ lines.append(f" ⊘ cordon {item.machine}: {item.describe()}")
346
+ for name in self.uncordon:
347
+ lines.append(f" ✓ uncordon {name}: up to date, work may resume")
348
+ for name in self.expired:
349
+ lines.append(
350
+ f" · cordon on {name} expired on its own (no propagate run "
351
+ "renewed it) — work may resume there"
352
+ )
353
+ for name in self.unknown:
354
+ lines.append(
355
+ f" ? {name}: cordoned, but this run could neither prove it "
356
+ "current nor prove it behind — left as-is, and it will lapse "
357
+ "on its own if no later run renews it"
358
+ )
359
+ for esc in self.escalations:
360
+ lines.append(f" ! {esc.message}")
361
+ return lines
362
+
363
+
364
+ @dataclass(frozen=True)
365
+ class HostDrift:
366
+ """Which hosts are behind, current, or neither — and why.
367
+
368
+ Four buckets, not two, because the two "neither" cases must never be
369
+ collapsed into "current": a host whose version could not be read is not
370
+ evidence of agreement (#1834), and a host one release behind a threshold
371
+ of three is deliberately tolerated rather than proven level.
372
+ """
373
+
374
+ behind: frozenset[str] = frozenset()
375
+ current: frozenset[str] = frozenset()
376
+ #: Version unreadable — never cordoned (a cordon on a guess stops real
377
+ #: work) and never uncordoned (an HTTP blip must not open the fleet up
378
+ #: mid-roll).
379
+ unreadable: frozenset[str] = frozenset()
380
+ #: Behind, but by less than the threshold (trap F).
381
+ under_threshold: frozenset[str] = frozenset()
382
+
383
+ @property
384
+ def undecided(self) -> frozenset[str]:
385
+ return self.unreadable | self.under_threshold
386
+
387
+
388
+ def classify_hosts(
389
+ host_versions: Mapping[str, str | None],
390
+ target: str | None,
391
+ *,
392
+ threshold: int = DEFAULT_DRIFT_THRESHOLD,
393
+ ) -> HostDrift:
394
+ """Bucket *host_versions* against *target*. See :class:`HostDrift`.
395
+
396
+ *host_versions* maps a machine name to the version its python lane
397
+ reports, ``None`` when no lane could be read. "Current" requires proof:
398
+ an unreadable version is ``unreadable``, never ``current`` — the same rule
399
+ :func:`coord.release_propagate.hosts_already_current` applies, and for
400
+ the same reason (#1834: ``version=None`` means "no data", which is
401
+ emphatically not "agrees with everyone else").
402
+ """
403
+ want = max(1, int(threshold))
404
+ behind: set[str] = set()
405
+ current: set[str] = set()
406
+ unreadable: set[str] = set()
407
+ under: set[str] = set()
408
+ for host, version in host_versions.items():
409
+ drift = version_drift(version, target)
410
+ if drift is DRIFT_UNKNOWN:
411
+ unreadable.add(host)
412
+ elif drift == 0:
413
+ current.add(host)
414
+ elif drift >= want:
415
+ behind.add(host)
416
+ else:
417
+ under.add(host)
418
+ return HostDrift(
419
+ behind=frozenset(behind),
420
+ current=frozenset(current),
421
+ unreadable=frozenset(unreadable),
422
+ under_threshold=frozenset(under),
423
+ )
424
+
425
+
426
+ def plan_cordons(
427
+ *,
428
+ target_version: str | None,
429
+ host_versions: Mapping[str, str | None],
430
+ existing: Mapping[str, Cordon] | Iterable[Cordon] = (),
431
+ now: float,
432
+ ttl_seconds: float = DEFAULT_TTL_SECONDS,
433
+ drain_deadline: float = DEFAULT_DRAIN_DEADLINE_SECONDS,
434
+ threshold: int = DEFAULT_DRIFT_THRESHOLD,
435
+ busy_reasons: Mapping[str, str] | None = None,
436
+ enabled: bool = True,
437
+ ) -> CordonPlan:
438
+ """Decide this run's cordon writes. Pure.
439
+
440
+ *existing* is the store as read (expired records included — this function
441
+ is what notices they lapsed). *busy_reasons* maps a host to why it is not
442
+ yet drained, purely so an escalation can say what is holding it.
443
+
444
+ ``enabled=False`` (``coord release propagate --no-cordon``) plans no new
445
+ cordons but STILL clears the ones this owner already set: turning the
446
+ mechanism off must release the fleet, not freeze it in whatever state the
447
+ last run left behind.
448
+ """
449
+ records = _as_records(existing)
450
+ live = {name: c for name, c in records.items() if c.active(now)}
451
+ expired = tuple(sorted(name for name in records if name not in live))
452
+
453
+ drift = classify_hosts(host_versions, target_version, threshold=threshold)
454
+
455
+ if not enabled:
456
+ return CordonPlan(uncordon=tuple(sorted(live)), expired=expired)
457
+
458
+ to_cordon: list[Cordon] = []
459
+ escalations: list[DrainEscalation] = []
460
+ for host in sorted(drift.behind):
461
+ previous = live.get(host)
462
+ created = previous.created_at if previous and previous.created_at else now
463
+ to_cordon.append(
464
+ Cordon(
465
+ machine=host,
466
+ owner=OWNER_RELEASE,
467
+ reason=f"draining for v{target_version}" if target_version else "draining for a release",
468
+ target_version=target_version,
469
+ created_at=created,
470
+ renewed_at=now,
471
+ expires_at=now + max(0.0, float(ttl_seconds)),
472
+ )
473
+ )
474
+ if previous is not None and previous.overdue(now, drain_deadline):
475
+ escalations.append(
476
+ DrainEscalation(
477
+ machine=host,
478
+ waited_seconds=previous.age(now),
479
+ deadline_seconds=drain_deadline,
480
+ target_version=target_version,
481
+ busy_reason=(busy_reasons or {}).get(host, ""),
482
+ )
483
+ )
484
+
485
+ # Uncordon: PROVEN current, and nothing else. A host whose version could
486
+ # not be read keeps whatever cordon it has until that cordon EXPIRES —
487
+ # clearing on "we couldn't read the version" would open the fleet up
488
+ # mid-roll on the strength of one failed HTTP call.
489
+ to_uncordon = tuple(sorted(name for name in live if name in drift.current))
490
+
491
+ return CordonPlan(
492
+ cordon=tuple(to_cordon),
493
+ uncordon=to_uncordon,
494
+ escalations=tuple(escalations),
495
+ expired=expired,
496
+ unknown=tuple(sorted(drift.undecided & set(live))),
497
+ )
498
+
499
+
500
+ def _as_records(
501
+ existing: Mapping[str, Cordon] | Iterable[Cordon],
502
+ ) -> dict[str, Cordon]:
503
+ if isinstance(existing, Mapping):
504
+ return {str(k): v for k, v in existing.items()}
505
+ return {c.machine: c for c in existing}
506
+
507
+
508
+ @dataclass
509
+ class CordonOutcome:
510
+ """What the shell actually did, for the propagation journal."""
511
+
512
+ cordoned: list[str] = field(default_factory=list)
513
+ uncordoned: list[str] = field(default_factory=list)
514
+ expired: list[str] = field(default_factory=list)
515
+ escalated: list[dict] = field(default_factory=list)
516
+ errors: list[str] = field(default_factory=list)
517
+
518
+ def to_dict(self) -> dict:
519
+ return {
520
+ "cordoned": list(self.cordoned),
521
+ "uncordoned": list(self.uncordoned),
522
+ "expired": list(self.expired),
523
+ "escalated": [dict(e) for e in self.escalated],
524
+ "errors": list(self.errors),
525
+ }