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
coord/machine_pause.py ADDED
@@ -0,0 +1,755 @@
1
+ """Persistent pause/resume state for machines (#routing-pause, #1563).
2
+
3
+ The pause set is a tiny JSON file at ``~/.coord/paused_machines.json``
4
+ holding ``{"paused": [<name>, ...]}``. Both the Python coordinator
5
+ (`coord plan`, `coord assign`, auto_loop, reconcile, review,
6
+ refine_chat) and the Rust TUI read it to decide whether a given
7
+ machine is a candidate for new work — paused machines stay reachable
8
+ and visible but never receive new assignments.
9
+
10
+ Pause does NOT cancel in-flight assignments; the user can `coord stop`
11
+ those separately if needed. This module only governs the routing
12
+ decision for *new* work.
13
+
14
+ #1563: pause is FLEET state, not host state. The autonomous dispatcher
15
+ (`coord serve`'s `_tick_loop` → `reconcile()` / `dispatch_pending_reviews()`
16
+ / `auto_loop`) runs *inside the daemon*, which has no `board_service`
17
+ configured for itself — so it always reads the local JSON file below,
18
+ same as before. What used to be broken is a *thin client*: `coord pause`
19
+ run on an operator's laptop wrote to the laptop's own copy of this file,
20
+ which the daemon never saw. The public `paused_set()` / `pause()` /
21
+ `unpause()` below now check `coord.board_service.resolve()` first: when a
22
+ board service IS configured (thin client), they route over HTTP to the
23
+ daemon's `/pause` endpoint — which itself calls the same local-only
24
+ helpers here, so both the daemon's own tick loop and every thin client
25
+ end up reading/writing the *one* copy of this file that actually governs
26
+ dispatch. When no board service is configured (solo/local use, or the
27
+ daemon's own in-process calls), behaviour is unchanged: same file, same
28
+ atomic tempfile-rename writes.
29
+
30
+ `pause()`/`unpause()` (explicit user actions) fail LOUDLY on a thin
31
+ client — an HTTP/transport error propagates rather than reporting
32
+ success (#1563: "there is no configuration in which a thin-client pause
33
+ fails loudly. It always reports success and always fails open").
34
+ `paused_set()` (the read side, consulted on every dispatch decision)
35
+ stays fail-soft on a remote fetch error, consistent with this module's
36
+ existing local-read behaviour and every other daemon read-through helper
37
+ in `coord.client` (`fetch_issue_context`, `fetch_drive_escalations`, …):
38
+ a transient network blip degrades to "nothing is paused" rather than
39
+ wedging the dispatcher, matching the pre-existing contract documented
40
+ below for a malformed/missing local file.
41
+
42
+ #1862: per-machine quiet hours. `Machine.quiet_hours` (parsed by
43
+ `coord.config`) declares a recurring daily window during which a machine
44
+ should receive no NEW dispatch — the same routing-pause semantics as an
45
+ explicit `coord pause`, computed instead of stored. This module is the
46
+ single place that union happens: `paused_set()`/`local_paused_set()`
47
+ accept an optional `machines` sequence and, when given one, fold in
48
+ `{m.name for m in machines if m.quiet_hours.covers(now)}` — every one of
49
+ the eight call sites listed in #1862 already has its `Config` in local
50
+ scope, so they pass `config.machines` and inherit quiet hours with no
51
+ second routing check anywhere else. `machines=None` (the default)
52
+ degrades to "explicit pauses only", i.e. unchanged pre-#1862 behaviour —
53
+ this is what keeps every deployment with no `quiet_hours:` block, and
54
+ every caller not yet threading `machines` through, byte-identical to
55
+ before.
56
+
57
+ #2101: RELEASE CORDONS. A cordon is the same *routing* semantics as a pause —
58
+ no new agents route to that machine, in-flight work is untouched — set
59
+ automatically by `coord release propagate` so a host behind the released
60
+ version drains itself into a rollable state instead of waiting for a window
61
+ that never arrives. It shares `paused_set()` (every dispatcher already reads
62
+ it, so nothing else in the fleet needs to learn a second concept) and NOTHING
63
+ else:
64
+
65
+ * it lives under its own `release_cordons` key, with an `owner`, a `reason`,
66
+ a `created_at` and an `expires_at` — `local_pause`/`local_unpause` never
67
+ read or write it, and `set_cordon`/`clear_cordon` never touch `paused`.
68
+ An operator's `coord unpause` therefore cannot lift a cordon mid-drain, and
69
+ the post-roll uncordon cannot clear a pause an operator set deliberately
70
+ (#2101 trap A);
71
+ * it EXPIRES. The cordon lives in daemon state and the daemon is restarted
72
+ by the very roll it gates, so a propagate run killed between cordon and
73
+ uncordon would otherwise leave the fleet refusing work forever — which
74
+ looks exactly like a quiet fleet (#2082 in a new costume). The read side
75
+ (`local_cordons()`) simply ignores an expired record, so nothing has to run
76
+ for a cordon to lapse; the live loop renews on every run while the host is
77
+ still behind (#2101 trap B).
78
+
79
+ `coord unpause` during an active quiet window would otherwise be a lie
80
+ (#1563's failure class: reports success, changes nothing, the machine is
81
+ paused again on the very next read). `local_unpause_effective()` picks
82
+ the "explicit override" resolution named in #1862: unpausing a
83
+ quiet-covered, not-explicitly-paused machine records an override that
84
+ suppresses quiet hours until the CURRENT window's end (persisted
85
+ alongside the explicit-pause list below, under `quiet_overrides`) and
86
+ says so — never silently re-paused, never silently accepted as a no-op.
87
+ """
88
+ from __future__ import annotations
89
+
90
+ import json
91
+ import os
92
+ import tempfile
93
+ from collections.abc import Mapping, Sequence
94
+ from dataclasses import dataclass
95
+ from datetime import datetime, timezone
96
+ from pathlib import Path
97
+ from typing import TYPE_CHECKING
98
+ from zoneinfo import ZoneInfo
99
+
100
+ if TYPE_CHECKING:
101
+ from coord.models import Machine
102
+
103
+ _STATE_FILENAME = "paused_machines.json"
104
+
105
+
106
+ def _state_path() -> Path:
107
+ """Return the absolute path to the pause-state file.
108
+
109
+ Lives under ``$HOME/.coord/`` so it sits alongside the rest of the
110
+ runtime state (`assignments.db`, `agent_state.json`, etc.).
111
+ """
112
+ home = Path(os.environ.get("HOME", "/tmp")).expanduser()
113
+ return home / ".coord" / _STATE_FILENAME
114
+
115
+
116
+ def paused_set(
117
+ machines: Sequence["Machine"] | None = None, *, now: datetime | None = None,
118
+ ) -> set[str]:
119
+ """Read the current set of paused machine names (#1563: daemon-aware).
120
+
121
+ Routes through the daemon's `/pause` endpoint when a board service is
122
+ configured (thin client); otherwise reads the local file directly. The
123
+ remote fetch fails SOFT (any error → empty set) — see module docstring
124
+ for why reads stay fail-open while explicit `pause()`/`unpause()` calls
125
+ don't.
126
+
127
+ On a thin client *machines*/*now* are ignored — the daemon's own
128
+ `/pause` endpoint already folds its quiet-hours-covered machines into
129
+ the response (see `coord.serve_app.get_pause`), so the thin client's
130
+ view is correct without knowing about quiet hours itself. Pass
131
+ *machines* (almost always `config.machines`, already in scope at every
132
+ call site) to fold quiet hours into the LOCAL computation — the daemon's
133
+ own in-process tick-loop calls (no board service configured for
134
+ itself), and any solo/local use with no daemon at all.
135
+ """
136
+ svc = _resolve_service()
137
+ if svc is not None:
138
+ from coord.client import fetch_paused_machines # noqa: PLC0415
139
+
140
+ try:
141
+ return fetch_paused_machines(svc)
142
+ except Exception: # noqa: BLE001 — fail-soft read, see module docstring
143
+ return set()
144
+ return local_paused_set(machines, now=now)
145
+
146
+
147
+ def is_paused(
148
+ name: str, machines: Sequence["Machine"] | None = None, *, now: datetime | None = None,
149
+ ) -> bool:
150
+ """Convenience: True when *name* is in the paused set."""
151
+ return name in paused_set(machines, now=now)
152
+
153
+
154
+ def pause(name: str) -> bool:
155
+ """Add *name* to the paused set. Returns True when the set changed
156
+ (i.e. *name* was not already paused).
157
+
158
+ #1563: on a thin client this POSTs to the daemon's `/pause` endpoint
159
+ and raises (`httpx.HTTPError`) if that can't be confirmed — a pause
160
+ that silently fails to reach the daemon is the exact failure mode this
161
+ module exists to close. Callers that must never raise (e.g. an
162
+ interactive command that wants to print a clean error) should catch
163
+ around this call.
164
+ """
165
+ svc = _resolve_service()
166
+ if svc is not None:
167
+ from coord.client import post_pause # noqa: PLC0415
168
+
169
+ result = post_pause(svc, name, "pause")
170
+ return bool(result.get("changed"))
171
+ return local_pause(name)
172
+
173
+
174
+ @dataclass(frozen=True)
175
+ class UnpauseOutcome:
176
+ """Result of an #1862-aware unpause — see `local_unpause_effective()`.
177
+
178
+ `kind` is one of:
179
+ * ``"resumed"`` — an explicit `coord pause` was lifted.
180
+ * ``"quiet_override"`` — the machine wasn't explicitly paused but WAS
181
+ inside its quiet-hours window; an override now suppresses that
182
+ window until it would have ended anyway (`quiet_until`/`tz`).
183
+ * ``"not_paused"`` — genuinely not paused by any mechanism; a
184
+ true no-op, distinguishable from the two "did something" cases
185
+ above so a caller never reports success for nothing happening.
186
+ """
187
+
188
+ changed: bool
189
+ kind: str
190
+ quiet_until: str | None = None # local "HH:MM" the override lasts until
191
+ tz: str | None = None
192
+
193
+
194
+ def unpause(
195
+ name: str, machines: Sequence["Machine"] | None = None, *, now: datetime | None = None,
196
+ ) -> UnpauseOutcome:
197
+ """Resolve `coord unpause <name>` (#1862-aware). See `pause()` for the
198
+ thin-client routing / fail-loudly contract, and `UnpauseOutcome` for
199
+ what `.kind` means.
200
+
201
+ #1862: unpausing a machine that is inside its quiet-hours window but
202
+ was never explicitly paused must not silently no-op (the machine would
203
+ be paused again on the very next read — #1563's failure class) and
204
+ must not silently pretend to have lifted a pause that was never set.
205
+ Instead it records an explicit override for the remainder of that
206
+ window — see `local_unpause_effective()`.
207
+ """
208
+ svc = _resolve_service()
209
+ if svc is not None:
210
+ from coord.client import post_pause # noqa: PLC0415
211
+
212
+ result = post_pause(svc, name, "unpause")
213
+ changed = bool(result.get("changed"))
214
+ return UnpauseOutcome(
215
+ changed=changed,
216
+ kind=result.get("kind") or ("resumed" if changed else "not_paused"),
217
+ quiet_until=result.get("quiet_until"),
218
+ tz=result.get("tz"),
219
+ )
220
+ return local_unpause_effective(name, machines, now=now)
221
+
222
+
223
+ def _resolve_service(): # -> coord.client.ServiceConfig | None
224
+ from coord.board_service import resolve # noqa: PLC0415
225
+
226
+ return resolve()
227
+
228
+
229
+ # ── local-only (#1563: always used by the daemon's own `/pause` endpoint and
230
+ # tick loop, and by every caller when no board service is configured) ───────
231
+
232
+
233
+ def local_paused_set(
234
+ machines: Sequence["Machine"] | None = None, *, now: datetime | None = None,
235
+ ) -> set[str]:
236
+ """The local, effective paused-machine set: explicit pauses UNION any
237
+ machine currently inside its quiet-hours window (#1862) UNION any machine
238
+ under an unexpired release cordon (#2101), minus any machine with an
239
+ active `coord unpause` override for that quiet window.
240
+
241
+ Omitting *machines* drops only the quiet-hours half — that is what keeps
242
+ every pre-#1862 caller (and `local_pause`/`local_unpause` below, which use
243
+ `_explicit_paused_set()` directly to test EXPLICIT membership)
244
+ byte-identical. Cordons need no config to resolve, so they are folded in
245
+ unconditionally: a cordon that only applied when the caller happened to
246
+ pass `config.machines` would be a cordon half the dispatchers ignore.
247
+ Malformed/missing local file degrades to "nothing paused" — failure to
248
+ read should never block routing.
249
+
250
+ Unlike `paused_set()`, this NEVER goes over the network — it is what
251
+ the daemon's `/pause` endpoint handler calls (passing `config.machines`
252
+ so quiet hours apply), and what `paused_set()` itself falls through to
253
+ when no board service is configured.
254
+ """
255
+ effective = _explicit_paused_set() | cordoned_names(now=_epoch(now))
256
+ if not machines:
257
+ return effective
258
+ return effective | _quiet_covered_names(machines, now=now)
259
+
260
+
261
+ def local_pause(name: str) -> bool:
262
+ """Add *name* to the local EXPLICIT paused set. Returns True when the
263
+ set changed (i.e. *name* was not already explicitly paused).
264
+
265
+ Deliberately orthogonal to quiet hours: an explicit pause is tracked
266
+ and reported independently of whatever a machine's `quiet_hours` window
267
+ happens to be doing at the same moment.
268
+ """
269
+ current = _explicit_paused_set()
270
+ if name in current:
271
+ return False
272
+ current.add(name)
273
+ _save_state(paused=current)
274
+ return True
275
+
276
+
277
+ def local_unpause(name: str) -> bool:
278
+ """Remove *name* from the local EXPLICIT paused set. Returns True when
279
+ the set changed (i.e. *name* was actually explicitly paused).
280
+
281
+ This is the pre-#1862 primitive: it only ever looks at explicit pauses,
282
+ so it correctly reports "not paused" (False) for a machine that is
283
+ merely inside its quiet-hours window — `local_unpause_effective()`
284
+ below is what gives THAT case a truthful, non-lying answer.
285
+ """
286
+ current = _explicit_paused_set()
287
+ if name not in current:
288
+ return False
289
+ current.discard(name)
290
+ _save_state(paused=current)
291
+ return True
292
+
293
+
294
+ def local_unpause_effective(
295
+ name: str, machines: Sequence["Machine"] | None = None, *, now: datetime | None = None,
296
+ ) -> UnpauseOutcome:
297
+ """#1862: the truthful `coord unpause` — see `UnpauseOutcome` for the
298
+ three possible outcomes and the module docstring for why a bare
299
+ `local_unpause()` (explicit-only) would otherwise let `coord unpause`
300
+ report success and change nothing for a quiet-covered machine.
301
+ """
302
+ if local_unpause(name):
303
+ return UnpauseOutcome(changed=True, kind="resumed")
304
+
305
+ now = now if now is not None else datetime.now(timezone.utc)
306
+ machine = next((m for m in (machines or ()) if m.name == name), None)
307
+ qh = machine.quiet_hours if machine is not None else None
308
+ if qh is not None and qh.covers(now):
309
+ until_utc = qh.window_end_instant(now)
310
+ _set_quiet_override(name, until_utc)
311
+ until_local = until_utc.astimezone(ZoneInfo(qh.tz))
312
+ return UnpauseOutcome(
313
+ changed=True,
314
+ kind="quiet_override",
315
+ quiet_until=until_local.strftime("%H:%M"),
316
+ tz=qh.tz,
317
+ )
318
+ return UnpauseOutcome(changed=False, kind="not_paused")
319
+
320
+
321
+ # ── #1862: quiet-hours computation ──────────────────────────────────────────
322
+
323
+
324
+ def quiet_paused_names(
325
+ machines: Sequence["Machine"] | None = None, *, now: datetime | None = None,
326
+ ) -> set[str]:
327
+ """Public: names of machines currently paused SPECIFICALLY because a
328
+ `quiet_hours` window covers *now* (never overridden — an active
329
+ `coord unpause` override excludes a machine from this set, same as it
330
+ excludes it from `local_paused_set()`'s union).
331
+
332
+ Always a subset of `local_paused_set(machines, now=now)`. Review finding
333
+ on #1862's original PR: `coord status`'s `describe_pause_state()`
334
+ distinguished a quiet-paused machine from a hand-paused one, but the
335
+ daemon's `/pause` endpoint and the TUI sidebar badge did not — this is
336
+ the choke point both now call so a machine "asleep until 08:00" reads
337
+ differently from one someone explicitly paused, everywhere pause state
338
+ is displayed, not just `coord status`.
339
+ """
340
+ if not machines:
341
+ return set()
342
+ return _quiet_covered_names(machines, now=now)
343
+
344
+
345
+ def _quiet_covered_names(
346
+ machines: Sequence["Machine"], *, now: datetime | None = None,
347
+ ) -> set[str]:
348
+ """Names of machines whose `quiet_hours` window covers *now*, excluding
349
+ any with an active `coord unpause` override for that window."""
350
+ now = now if now is not None else datetime.now(timezone.utc)
351
+ overridden = _active_quiet_override_names(now)
352
+ return {
353
+ m.name
354
+ for m in machines
355
+ if m.quiet_hours is not None
356
+ and m.name not in overridden
357
+ and m.quiet_hours.covers(now)
358
+ }
359
+
360
+
361
+ def _active_quiet_override_names(now: datetime | None = None) -> set[str]:
362
+ now = now if now is not None else datetime.now(timezone.utc)
363
+ active: set[str] = set()
364
+ for name, raw_until in _quiet_overrides().items():
365
+ try:
366
+ until = datetime.fromisoformat(raw_until)
367
+ except ValueError:
368
+ continue
369
+ if until.tzinfo is None:
370
+ until = until.replace(tzinfo=timezone.utc)
371
+ if now < until:
372
+ active.add(name)
373
+ return active
374
+
375
+
376
+ def _set_quiet_override(name: str, until_utc: datetime) -> None:
377
+ overrides = _quiet_overrides()
378
+ overrides[name] = until_utc.astimezone(timezone.utc).isoformat()
379
+ _save_state(quiet_overrides=overrides)
380
+
381
+
382
+ # ── #2101: release cordons ───────────────────────────────────────────────────
383
+ #
384
+ # A THIRD, independent axis of this file (see the module docstring). The three
385
+ # never write each other's key: `paused` is the operator's, `quiet_overrides`
386
+ # is `coord unpause`'s, `release_cordons` is `coord release propagate`'s.
387
+ # `_save_state` preserves whichever two the caller didn't pass, which is the
388
+ # single line that makes "each clears only its own" true rather than merely
389
+ # intended.
390
+
391
+
392
+ def _epoch(now: "datetime | float | None") -> float | None:
393
+ """Accept either clock form the callers in this module use.
394
+
395
+ Quiet hours are computed from an aware `datetime`; cordon expiry is an
396
+ epoch float (it is compared against `time.time()` stamps written by the
397
+ propagate shell). One helper rather than two clock conventions leaking
398
+ into every signature.
399
+ """
400
+ if now is None:
401
+ return None
402
+ if isinstance(now, datetime):
403
+ return now.timestamp()
404
+ return float(now)
405
+
406
+
407
+ def _now_epoch(now: float | None = None) -> float:
408
+ return now if now is not None else datetime.now(timezone.utc).timestamp()
409
+
410
+
411
+ def local_cordons(*, now: float | None = None, include_expired: bool = False) -> dict:
412
+ """``{machine: Cordon}`` — the local cordon store.
413
+
414
+ Expired records are dropped unless *include_expired*: a cordon lapses by
415
+ being IGNORED, never by being cleaned up, so a propagate run that died
416
+ mid-drain (or a daemon that was restarted by the very roll it was gating)
417
+ cannot leave the fleet cordoned forever — trap B of #2101. Nothing has to
418
+ run for that to hold.
419
+ """
420
+ from coord.release_cordon import Cordon # noqa: PLC0415 — leaf import
421
+
422
+ when = _now_epoch(now)
423
+ out: dict[str, Cordon] = {}
424
+ for name, raw in _cordon_records().items():
425
+ record = Cordon.from_dict({**raw, "machine": raw.get("machine") or name})
426
+ if include_expired or record.active(when):
427
+ out[name] = record
428
+ return out
429
+
430
+
431
+ def cordoned_names(*, now: float | None = None) -> set[str]:
432
+ """Names of machines under an unexpired release cordon.
433
+
434
+ Always a subset of `local_paused_set()`; fail-soft on an unreadable store,
435
+ same contract as `_explicit_paused_set()`.
436
+ """
437
+ try:
438
+ return set(local_cordons(now=now))
439
+ except Exception: # noqa: BLE001 — a bad store must never wedge routing
440
+ return set()
441
+
442
+
443
+ def local_set_cordon(
444
+ name: str,
445
+ *,
446
+ reason: str = "",
447
+ target_version: str | None = None,
448
+ ttl_seconds: float | None = None,
449
+ owner: str | None = None,
450
+ created_at: float | None = None,
451
+ now: float | None = None,
452
+ ) -> object:
453
+ """Write (or renew) *name*'s release cordon. Returns the stored `Cordon`.
454
+
455
+ Renewal preserves `created_at` — the drain deadline (#2101 trap C)
456
+ measures from the FIRST cordon of this drain, so a wedged host cannot
457
+ postpone its own escalation by being renewed every 20 minutes.
458
+ """
459
+ from coord.release_cordon import ( # noqa: PLC0415
460
+ DEFAULT_TTL_SECONDS,
461
+ OWNER_RELEASE,
462
+ Cordon,
463
+ )
464
+
465
+ when = _now_epoch(now)
466
+ ttl = DEFAULT_TTL_SECONDS if ttl_seconds is None else max(0.0, float(ttl_seconds))
467
+ existing = _cordon_records().get(name) or {}
468
+ try:
469
+ previous_created = float(existing.get("created_at") or 0.0)
470
+ except (TypeError, ValueError):
471
+ previous_created = 0.0
472
+ record = Cordon(
473
+ machine=name,
474
+ owner=owner or OWNER_RELEASE,
475
+ reason=reason,
476
+ target_version=target_version,
477
+ created_at=created_at or previous_created or when,
478
+ renewed_at=when,
479
+ expires_at=when + ttl,
480
+ )
481
+ records = _cordon_records()
482
+ records[name] = record.to_dict()
483
+ _save_state(cordons=records)
484
+ return record
485
+
486
+
487
+ def local_clear_cordon(name: str, *, owner: str | None = None) -> bool:
488
+ """Drop *name*'s cordon. True when one was actually there.
489
+
490
+ *owner* (when given) is enforced: an owner may only clear its own cordon.
491
+ An operator's `coord pause` is untouched either way — it is not stored
492
+ here at all.
493
+ """
494
+ records = _cordon_records()
495
+ record = records.get(name)
496
+ if record is None:
497
+ return False
498
+ if owner is not None and str(record.get("owner") or "") != owner:
499
+ return False
500
+ del records[name]
501
+ _save_state(cordons=records)
502
+ return True
503
+
504
+
505
+ def local_prune_cordons(*, now: float | None = None) -> list[str]:
506
+ """Delete expired records and return their names.
507
+
508
+ Purely hygiene: `local_cordons()` already ignores them, so nothing
509
+ depends on this having run. It exists so the store does not accumulate
510
+ dead rows and so `coord release cordon --list` can report "these lapsed"
511
+ once rather than forever.
512
+ """
513
+ when = _now_epoch(now)
514
+ live = set(local_cordons(now=when))
515
+ records = _cordon_records()
516
+ dropped = sorted(name for name in records if name not in live)
517
+ if dropped:
518
+ for name in dropped:
519
+ records.pop(name, None)
520
+ _save_state(cordons=records)
521
+ return dropped
522
+
523
+
524
+ # ── #2101: daemon-aware cordon read/write (same seam as pause/unpause) ───────
525
+
526
+
527
+ def cordons(*, now: float | None = None) -> dict:
528
+ """`{machine: Cordon}` from the daemon when one is configured (#1563's
529
+ seam), else the local store. Fail-soft on a remote error, exactly like
530
+ `paused_set()` — see the module docstring for why reads degrade to
531
+ "nothing is cordoned" while writes below fail loudly.
532
+ """
533
+ svc = _resolve_service()
534
+ if svc is None:
535
+ return local_cordons(now=now)
536
+ from coord.client import fetch_cordons # noqa: PLC0415
537
+ from coord.release_cordon import Cordon # noqa: PLC0415
538
+
539
+ try:
540
+ rows = fetch_cordons(svc)
541
+ except Exception: # noqa: BLE001 — fail-soft read, see module docstring
542
+ return {}
543
+ when = _now_epoch(now)
544
+ out: dict[str, Cordon] = {}
545
+ for raw in rows:
546
+ record = Cordon.from_dict(raw)
547
+ if record.machine and record.active(when):
548
+ out[record.machine] = record
549
+ return out
550
+
551
+
552
+ def set_cordon(
553
+ name: str,
554
+ *,
555
+ reason: str = "",
556
+ target_version: str | None = None,
557
+ ttl_seconds: float | None = None,
558
+ ) -> object:
559
+ """Cordon *name*, daemon-first. Raises on a transport/HTTP failure.
560
+
561
+ A cordon that silently fails to reach the daemon is worse than no cordon
562
+ at all: `coord release propagate` would go on to restart agents believing
563
+ it had stopped new work, which is the in-flight-worker massacre the whole
564
+ quiescence design exists to prevent. So this fails LOUDLY, like
565
+ `pause()`/`unpause()` and unlike the read side.
566
+ """
567
+ svc = _resolve_service()
568
+ if svc is None:
569
+ return local_set_cordon(
570
+ name,
571
+ reason=reason,
572
+ target_version=target_version,
573
+ ttl_seconds=ttl_seconds,
574
+ )
575
+ from coord.client import post_cordon # noqa: PLC0415
576
+ from coord.release_cordon import Cordon # noqa: PLC0415
577
+
578
+ result = post_cordon(
579
+ svc,
580
+ name,
581
+ "cordon",
582
+ reason=reason,
583
+ target_version=target_version,
584
+ ttl_seconds=ttl_seconds,
585
+ )
586
+ return Cordon.from_dict(result.get("cordon") or {"machine": name})
587
+
588
+
589
+ def clear_cordon(name: str) -> bool:
590
+ """Uncordon *name*, daemon-first. Raises on transport/HTTP failure."""
591
+ svc = _resolve_service()
592
+ if svc is None:
593
+ return local_clear_cordon(name)
594
+ from coord.client import post_cordon # noqa: PLC0415
595
+
596
+ return bool(post_cordon(svc, name, "uncordon").get("changed"))
597
+
598
+
599
+ # ── internals ────────────────────────────────────────────────────────────────
600
+
601
+
602
+ def _load_raw() -> dict:
603
+ path = _state_path()
604
+ if not path.exists():
605
+ return {}
606
+ with path.open("r", encoding="utf-8") as fh:
607
+ return json.load(fh)
608
+
609
+
610
+ def _explicit_paused_set() -> set[str]:
611
+ try:
612
+ data = _load_raw()
613
+ except (OSError, ValueError):
614
+ return set()
615
+ items = data.get("paused")
616
+ if not isinstance(items, list):
617
+ return set()
618
+ return {str(x) for x in items if isinstance(x, str) and x}
619
+
620
+
621
+ def _quiet_overrides() -> dict[str, str]:
622
+ try:
623
+ data = _load_raw()
624
+ except (OSError, ValueError):
625
+ return {}
626
+ items = data.get("quiet_overrides")
627
+ if not isinstance(items, dict):
628
+ return {}
629
+ return {str(k): str(v) for k, v in items.items() if isinstance(k, str) and isinstance(v, str)}
630
+
631
+
632
+ def _cordon_records() -> dict[str, dict]:
633
+ """#2101: the raw `release_cordons` map, `{machine: record}`.
634
+
635
+ Malformed entries are dropped individually rather than failing the whole
636
+ read — this is consulted on every dispatch decision in the fleet.
637
+ """
638
+ try:
639
+ data = _load_raw()
640
+ except (OSError, ValueError):
641
+ return {}
642
+ items = data.get("release_cordons")
643
+ if not isinstance(items, dict):
644
+ return {}
645
+ return {
646
+ str(k): dict(v)
647
+ for k, v in items.items()
648
+ if isinstance(k, str) and k and isinstance(v, dict)
649
+ }
650
+
651
+
652
+ def _save_state(
653
+ *,
654
+ paused: set[str] | None = None,
655
+ quiet_overrides: dict[str, str] | None = None,
656
+ cordons: dict[str, dict] | None = None,
657
+ ) -> None:
658
+ """Read-modify-write the local state file, preserving whichever axes the
659
+ caller doesn't pass.
660
+
661
+ Explicit pauses, quiet-hours overrides and release cordons (#2101) are
662
+ three INDEPENDENT axes with three different owners; a write to one must
663
+ never clobber another. This one function is what makes #2101 trap A
664
+ ("each must clear only its own") true rather than merely intended, so
665
+ every new key added here must be preserved the same way.
666
+ """
667
+ path = _state_path()
668
+ path.parent.mkdir(parents=True, exist_ok=True)
669
+ new_paused = sorted(paused) if paused is not None else sorted(_explicit_paused_set())
670
+ new_overrides = quiet_overrides if quiet_overrides is not None else _quiet_overrides()
671
+ new_cordons = cordons if cordons is not None else _cordon_records()
672
+ payload = {
673
+ "paused": new_paused,
674
+ "quiet_overrides": dict(sorted(new_overrides.items())),
675
+ "release_cordons": {k: dict(v) for k, v in sorted(new_cordons.items())},
676
+ }
677
+ # Atomic write: tempfile in the same dir then rename so a crashed
678
+ # writer can never leave a partially-written file in place.
679
+ fd, tmp = tempfile.mkstemp(prefix=".paused_machines.", suffix=".tmp", dir=path.parent)
680
+ try:
681
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
682
+ json.dump(payload, fh, indent=2, sort_keys=True)
683
+ fh.write("\n")
684
+ os.replace(tmp, path)
685
+ except Exception:
686
+ try:
687
+ os.unlink(tmp)
688
+ except OSError:
689
+ pass
690
+ raise
691
+
692
+
693
+ # ── #1862: pause-state display (`coord status`, and any future consumer
694
+ # that wants to distinguish a hand pause from a quiet-hours one) ────────────
695
+
696
+
697
+ @dataclass(frozen=True)
698
+ class PauseState:
699
+ """Why a machine is (or isn't, but would be if not overridden) paused,
700
+ for display purposes only — never consulted for routing.
701
+
702
+ `kind` is one of:
703
+ * ``"hand"`` — an explicit `coord pause`.
704
+ * ``"quiet"`` — inside its quiet-hours window right now.
705
+ * ``"quiet_overridden"`` — inside its quiet-hours window, but a
706
+ `coord unpause` override is currently suppressing it (so it is NOT
707
+ in the effective paused set, and IS dispatchable).
708
+ * ``"cordon"`` — a #2101 release cordon: this machine is
709
+ draining so it can be rolled onto the released version. It will lift
710
+ itself the moment the roll lands (or expire on its own if the run
711
+ that set it died), so it must NOT read as "someone paused this".
712
+ """
713
+
714
+ kind: str
715
+ detail: str
716
+
717
+
718
+ def describe_pause_state(
719
+ machine: "Machine",
720
+ paused: set[str],
721
+ *,
722
+ now: datetime | None = None,
723
+ cordons: "Mapping[str, object] | None" = None,
724
+ ) -> PauseState | None:
725
+ """Derive *machine*'s pause state purely from *paused* (the already-
726
+ fetched effective set from `paused_set()`) plus the machine's own
727
+ locally-known `quiet_hours` config — no extra network round trip.
728
+
729
+ *cordons* is the already-fetched `{machine: Cordon}` map (#2101). Passing
730
+ it is what lets a caller say "cordoned: draining for v0.5.31" instead of
731
+ the flatly wrong "PAUSED": a cordon is not an operator's decision and is
732
+ not cleared by `coord unpause`, so rendering the two identically is the
733
+ "work stopped and nobody said why" failure #2101 trap E names. Omitting
734
+ it degrades to the pre-#2101 rendering.
735
+
736
+ Returns ``None`` when the machine isn't paused by any mechanism and
737
+ isn't in an overridden quiet window either.
738
+ """
739
+ covered = machine.quiet_hours is not None and machine.quiet_hours.covers(now)
740
+ in_paused = machine.name in paused
741
+ cordon = (cordons or {}).get(machine.name)
742
+ if cordon is not None:
743
+ # Checked FIRST: a cordoned machine is in `paused` (that is how
744
+ # routing honours it), so every branch below would otherwise claim it.
745
+ describe = getattr(cordon, "describe", None)
746
+ return PauseState(kind="cordon", detail=describe() if callable(describe) else str(cordon))
747
+ if in_paused and covered:
748
+ qh = machine.quiet_hours
749
+ assert qh is not None # covered implies quiet_hours is set
750
+ return PauseState(kind="quiet", detail=f"until {qh.end.strftime('%H:%M')} ({qh.tz})")
751
+ if in_paused:
752
+ return PauseState(kind="hand", detail="")
753
+ if covered:
754
+ return PauseState(kind="quiet_overridden", detail="override active — dispatchable")
755
+ return None