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,322 @@
1
+ """Decision half of the nightly daemon-host release window (#2112).
2
+
3
+ `coord release propagate` (#1835/#2067) rolls each host at ITS OWN quiescent
4
+ window — except the daemon host, which the daemon-first lane order (#1835's
5
+ LANE ORDER, the documented 405) forces to gate the *whole run*: no lane may
6
+ roll ahead of an unrolled daemon. dellserver is both the daemon host and a
7
+ work machine, and every unpinned drive-queue entry charges it via
8
+ ``launch_host`` (see :func:`coord.release_propagate.busy_host_for_entry`)
9
+ regardless of where the worker actually lands — so almost any drive anywhere
10
+ keeps the daemon "busy" and defers the entire fleet. Measured 2026-08-10: the
11
+ fleet sat eleven releases behind for a day with elitebook idle and rollable
12
+ throughout.
13
+
14
+ #2101 (release cordons) answers this for every OTHER host: cordon it,
15
+ drain it, roll it the moment it's free. It cannot answer it for the daemon
16
+ host itself — cordoning stops NEW work from routing there, but the daemon
17
+ host is what runs the drive-queue tick that launches drives fleet-wide in
18
+ the first place, so cordoning it does not stop new drives from being queued
19
+ against it. The only way to guarantee the daemon host reaches quiescence is
20
+ to stop the thing that launches work onto it. Hence this module: a nightly
21
+ window that stops `coord-drive-queue.timer`, waits (bounded) for whatever is
22
+ already running to finish, rolls, and restarts the timer — always, whether
23
+ or not the roll happened.
24
+
25
+ GATED ON #2110, HARD PREREQUISITE
26
+ ----------------------------------
27
+ Steps "stop the timer, wait, roll" are exactly the sequence that deadlocked
28
+ on 2026-08-10: the reconciler that moves a finished drive from `running` to
29
+ `done` lives *inside* `coord drive-queue tick`, so stopping the timer stops
30
+ reconciliation too, and the last drive's row stays `running` forever — the
31
+ daemon host reads as busy permanently and this window would defer forever,
32
+ every night, unattended, and exit 0. #2110 made `coord drive-queue tick
33
+ --reconcile-only` (equivalently `--max-parallel 0`) safe to call on its own,
34
+ which is exactly what the drain loop below needs: reconcile without
35
+ launching anything new.
36
+
37
+ THE THREE TRAPS THIS MODULE IS SHAPED AROUND
38
+ ----------------------------------------------
39
+ 1. **Never `--force`.** That flag kills in-flight headless workers — the
40
+ whole reason propagation is quiescence-scheduled — and an unattended
41
+ nightly job must never carry it. If the drain does not finish, this
42
+ module says so and declines; it does not reach for the escape hatch.
43
+ 2. **The drain is bounded.** :data:`DEFAULT_DRAIN_DEADLINE_SECONDS` is the
44
+ deadline after which the caller must restart the timer and report
45
+ failure rather than leave the queue stopped into the working day — the
46
+ mirror image of "an expired deadline stops the observer, not the work."
47
+ 3. **A skipped night must be loud.** Every non-happy status here
48
+ (:data:`STATUS_DRAIN_TIMEOUT`, :data:`STATUS_PROPAGATE_DEFERRED`,
49
+ :data:`STATUS_PROPAGATE_FAILED`, :data:`STATUS_ERROR`) is something the
50
+ I/O shell (`coord/commands/release.py`'s `nightly-window` command) is
51
+ expected to escalate through `coord.state.record_drive_escalation` — the
52
+ same channel #2101's drain-deadline escalation and #2082 exist to make
53
+ this class of silence impossible.
54
+
55
+ Architecture mirrors `coord/release_propagate.py` and `coord/release_cordon.py`
56
+ on purpose: this module is pure decision-making over already-fetched facts
57
+ (a version string, a deadline, elapsed time) plus the journal format, so it
58
+ is unit-testable with no fleet, no systemd and no board. Everything that
59
+ needs a live host — stopping/starting the timer, running the reconcile-only
60
+ tick, invoking `coord release propagate`, writing the escalation — lives in
61
+ the command's I/O shell, next to `release_propagate`'s own for the same
62
+ reason (see that module's docstring's "what lives here" split).
63
+ """
64
+
65
+ from __future__ import annotations
66
+
67
+ import json
68
+ from dataclasses import asdict, dataclass
69
+ from pathlib import Path
70
+ from typing import Any, Mapping
71
+
72
+ #: The systemd --user timer this window stops for the duration of the
73
+ #: drain. Overridable (`--queue-timer`) purely for tests and for an
74
+ #: unusual install that renamed the unit; production has exactly one name.
75
+ DEFAULT_QUEUE_TIMER = "coord-drive-queue.timer"
76
+
77
+ #: Bounded wait (trap 2) for in-flight drives to finish before the window
78
+ #: gives up, restarts the queue and reports failure. An hour leaves ample
79
+ #: room inside the 22:00-08:00 quiet-hours window even started as late as
80
+ #: 03:00 — the queue is never stopped anywhere near the working day.
81
+ DEFAULT_DRAIN_DEADLINE_SECONDS = 3600.0
82
+
83
+ #: How often the drain loop reconciles the queue and re-checks quiescence.
84
+ #: `coord-drive-queue.timer` itself fires every 3 minutes in production —
85
+ #: polling much faster than that buys nothing (nothing on the board can
86
+ #: change faster) and just spends `coord drive-queue tick --reconcile-only`
87
+ #: subprocesses for no reason.
88
+ DEFAULT_POLL_INTERVAL_SECONDS = 30.0
89
+
90
+
91
+ # ── status vocabulary ────────────────────────────────────────────────────
92
+ #
93
+ # A small closed set, mirroring release_propagate.py's STATUS_* — so an
94
+ # operator who already knows how to read `coord release history` does not
95
+ # have to learn a second vocabulary for this journal.
96
+
97
+ STATUS_UP_TO_DATE = "up-to-date"
98
+ STATUS_DRY_RUN = "dry-run"
99
+ STATUS_ROLLED = "rolled"
100
+ STATUS_DRAIN_TIMEOUT = "drain-timeout"
101
+ STATUS_PROPAGATE_DEFERRED = "propagate-deferred"
102
+ STATUS_PROPAGATE_FAILED = "propagate-failed"
103
+ STATUS_ERROR = "error"
104
+
105
+ #: Statuses meaning "this window did what it was for, or correctly had
106
+ #: nothing to do" — everything else is a night propagation was supposed to
107
+ #: happen and did not (trap 3: loud, not silent).
108
+ OK_STATUSES = frozenset({STATUS_UP_TO_DATE, STATUS_ROLLED, STATUS_DRY_RUN})
109
+
110
+ #: The inverse of OK_STATUSES, spelled out for readability at call sites.
111
+ LOUD_STATUSES = frozenset(
112
+ {STATUS_DRAIN_TIMEOUT, STATUS_PROPAGATE_DEFERRED, STATUS_PROPAGATE_FAILED, STATUS_ERROR}
113
+ )
114
+
115
+
116
+ def needs_roll(daemon_version: str | None, target_version: str | None) -> bool:
117
+ """Is there anything for this window to do?
118
+
119
+ #2112 acceptance 3: "with the fleet already current, the job does not
120
+ stop the queue at all." This is the check that has to answer that
121
+ *before* anything touches the queue.
122
+
123
+ `daemon_version=None` (no data — an unreachable daemon host, an
124
+ unreadable python lane) reads as NEEDS a roll, never as "current": #1834's
125
+ rule is that no-data is not evidence of agreement, and skipping the
126
+ window on a guess is exactly the silent-no-op shape this issue exists to
127
+ close. Delegates the actual comparison to
128
+ :func:`coord.release_cordon.version_drift` rather than a third
129
+ reimplementation of version arithmetic in this codebase.
130
+ """
131
+ from coord.release_cordon import version_drift # noqa: PLC0415
132
+
133
+ if not target_version:
134
+ return False
135
+ drift = version_drift(daemon_version, target_version)
136
+ return drift is None or drift > 0
137
+
138
+
139
+ @dataclass
140
+ class DrainOutcome:
141
+ """What the bounded wait for in-flight drives found (trap 2)."""
142
+
143
+ drained: bool
144
+ elapsed_seconds: float
145
+ detail: str = ""
146
+
147
+
148
+ @dataclass
149
+ class WindowRecord:
150
+ """One nightly-window attempt, start to finish, as journalled.
151
+
152
+ Deliberately shaped like `release_propagate.PropagationRecord`: same
153
+ append-only-JSONL-one-object-per-attempt journal, same reason — this
154
+ record must survive a half-installed venv and be readable with `tail`
155
+ while the very upgrade it describes is in flight.
156
+ """
157
+
158
+ started_at: float
159
+ target_version: str | None = None
160
+ daemon_host: str | None = None
161
+ daemon_version: str | None = None
162
+ status: str = STATUS_ERROR
163
+ queue_timer: str = DEFAULT_QUEUE_TIMER
164
+ queue_stopped: bool | None = None
165
+ queue_stop_detail: str = ""
166
+ drained: bool | None = None
167
+ drain_seconds: float | None = None
168
+ drain_detail: str = ""
169
+ queue_restarted: bool | None = None
170
+ queue_restart_detail: str = ""
171
+ propagate_status: str | None = None
172
+ propagate_exit_code: int | None = None
173
+ propagate_output: str = ""
174
+ finished_at: float | None = None
175
+ error: str | None = None
176
+ dry_run: bool = False
177
+
178
+ def to_dict(self) -> dict:
179
+ return asdict(self)
180
+
181
+ @property
182
+ def ok(self) -> bool:
183
+ return self.status in OK_STATUSES
184
+
185
+
186
+ # ── the journal ──────────────────────────────────────────────────────────
187
+
188
+ #: Filename under the coord state root (`~/.coord` on Linux — see
189
+ #: `coord.platform_paths.default_coord_dir`). Separate from
190
+ #: `release_propagate.JOURNAL_NAME`: this record carries fields (queue
191
+ #: stop/drain/restart) a plain propagate attempt does not have, and
192
+ #: conflating the two would make either journal's shape a lie about the
193
+ #: other.
194
+ JOURNAL_NAME = "release_window.jsonl"
195
+
196
+ #: Records kept when the journal is trimmed. One per night, so this is
197
+ #: years of history — small enough to `cat`, generous enough that "when did
198
+ #: this last actually roll something" never scrolls off.
199
+ JOURNAL_MAX_RECORDS = 2000
200
+
201
+
202
+ def journal_path(state_dir: Path) -> Path:
203
+ return Path(state_dir) / JOURNAL_NAME
204
+
205
+
206
+ def append_record(state_dir: Path, record: WindowRecord) -> Path:
207
+ """Append *record* as one JSON line. Best effort by contract.
208
+
209
+ A window run must never fail *because* it could not write its own
210
+ diary — but a silently-unwritten diary is the exact 2026-08-04/#2082
211
+ shape, so the caller is told (the shell reports a write failure as a
212
+ warning and still exits on the real outcome, same as
213
+ `release_propagate.append_record`).
214
+ """
215
+ path = journal_path(state_dir)
216
+ path.parent.mkdir(parents=True, exist_ok=True)
217
+ with path.open("a", encoding="utf-8") as fh:
218
+ fh.write(json.dumps(record.to_dict(), sort_keys=True) + "\n")
219
+ return path
220
+
221
+
222
+ def read_records(state_dir: Path, *, limit: int | None = None) -> list[dict]:
223
+ """Most-recent-last records from the journal; unparseable lines skipped.
224
+
225
+ A torn final line (the process killed mid-append — see #2112 acceptance
226
+ 4) must not make the whole history unreadable.
227
+ """
228
+ path = journal_path(state_dir)
229
+ if not path.exists():
230
+ return []
231
+ out: list[dict] = []
232
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
233
+ line = line.strip()
234
+ if not line:
235
+ continue
236
+ try:
237
+ obj = json.loads(line)
238
+ except ValueError:
239
+ continue
240
+ if isinstance(obj, dict):
241
+ out.append(obj)
242
+ if limit is not None and limit >= 0:
243
+ out = out[-limit:]
244
+ return out
245
+
246
+
247
+ def trim_journal(state_dir: Path, *, keep: int = JOURNAL_MAX_RECORDS) -> int:
248
+ """Truncate the journal to its last *keep* records. Returns records kept."""
249
+ records = read_records(state_dir)
250
+ if len(records) <= keep:
251
+ return len(records)
252
+ kept = records[-keep:]
253
+ path = journal_path(state_dir)
254
+ path.write_text(
255
+ "".join(json.dumps(r, sort_keys=True) + "\n" for r in kept), encoding="utf-8"
256
+ )
257
+ return len(kept)
258
+
259
+
260
+ # ── rendering ────────────────────────────────────────────────────────────
261
+
262
+
263
+ def _stamp(ts: float | None) -> str:
264
+ if not ts:
265
+ return "?"
266
+ import datetime as _dt # noqa: PLC0415 — leaf import, keeps the module light
267
+
268
+ return _dt.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
269
+
270
+
271
+ _STATUS_MARK = {
272
+ STATUS_UP_TO_DATE: "=",
273
+ STATUS_DRY_RUN: "·",
274
+ STATUS_ROLLED: "✓",
275
+ STATUS_DRAIN_TIMEOUT: "⏱",
276
+ STATUS_PROPAGATE_DEFERRED: "~",
277
+ STATUS_PROPAGATE_FAILED: "✗",
278
+ STATUS_ERROR: "✗",
279
+ }
280
+
281
+
282
+ def render_record(record: WindowRecord | Mapping[str, Any]) -> list[str]:
283
+ """Human-readable lines for one attempt — what `coord release
284
+ window-history` and the command's own stdout print."""
285
+ data = record.to_dict() if isinstance(record, WindowRecord) else dict(record)
286
+ status = str(data.get("status") or "?")
287
+ mark = _STATUS_MARK.get(status, "?")
288
+ version = data.get("target_version") or "?"
289
+ prefix = "[dry-run] " if data.get("dry_run") else ""
290
+ lines = [
291
+ f"{mark} {prefix}{_stamp(data.get('started_at'))} v{version} {status}"
292
+ ]
293
+ if data.get("daemon_host"):
294
+ lines.append(
295
+ f" daemon host: {data['daemon_host']} "
296
+ f"(was v{data.get('daemon_version') or '?'})"
297
+ )
298
+ if data.get("queue_stopped") is not None:
299
+ lines.append(
300
+ f" {data.get('queue_timer')}: stopped="
301
+ f"{data['queue_stopped']} ({data.get('queue_stop_detail') or '-'})"
302
+ )
303
+ if data.get("drained") is not None:
304
+ secs = data.get("drain_seconds")
305
+ verdict = "clean" if data["drained"] else "TIMED OUT"
306
+ suffix = f" after {secs:.0f}s" if secs is not None else ""
307
+ lines.append(f" drain: {verdict}{suffix}")
308
+ if data.get("drain_detail"):
309
+ lines.append(f" {data['drain_detail']}")
310
+ if data.get("propagate_status"):
311
+ lines.append(
312
+ f" propagate: {data['propagate_status']} "
313
+ f"(exit {data.get('propagate_exit_code')})"
314
+ )
315
+ if data.get("queue_restarted") is not None:
316
+ lines.append(
317
+ f" {data.get('queue_timer')}: restarted="
318
+ f"{data['queue_restarted']} ({data.get('queue_restart_detail') or '-'})"
319
+ )
320
+ if data.get("error"):
321
+ lines.append(f" error: {data['error']}")
322
+ return lines