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,1176 @@
1
+ """Decision half of merge-triggered propagation (#1835, PKG-7).
2
+
3
+ PKG-7 closes the release loop: **merging a PR to `main` is the only human
4
+ action in a release.** The pipeline it creates is deliberately cut in two,
5
+ and the cut is the whole design:
6
+
7
+ * **Publish** — fully automatic, on merge. `.github/workflows/auto-release.yml`
8
+ picks the next `vX.Y.Z` from the tag history and pushes it; `publish.yml`
9
+ (#1242, PKG-6) turns that one tag into one Release carrying the wheel, the
10
+ `coord-tui` binaries and the bundled webapp. Publishing touches no running
11
+ host, so it is safe at any instant.
12
+
13
+ * **Propagate** — automatic, but scheduled against *fleet quiescence*, never
14
+ against the clock. This module is that scheduler's judgement.
15
+
16
+ Why the cut is not optional: ``coord agent update`` restarts the agent, and
17
+ a restart kills every in-flight headless worker (``coord/agent_app.py``'s
18
+ ``/update`` refuses outright when assignments are live, for exactly this
19
+ reason). With overnight drive queues (#56/#1750) the fleet is rarely idle,
20
+ so "on merge, upgrade the fleet" would routinely destroy work — and the
21
+ better the queue works, the more it destroys.
22
+
23
+ QUIESCENCE IS THE DRIVE QUEUE'S, NOT A SECOND OPINION
24
+ -----------------------------------------------------
25
+ #1835 is explicit that propagation must reuse the drive queue's existing
26
+ gate mechanism rather than invent a rival definition of "the fleet is busy"
27
+ — two competing definitions of quiescence is the same class of defect as two
28
+ overseers driving one milestone (#1440). So :func:`assess_quiescence` reads
29
+ exactly the states :mod:`coord.drive_queue` already publishes, and imports
30
+ its constants rather than re-spelling them:
31
+
32
+ * a queue entry in :data:`~coord.drive_queue.STATE_RUNNING` is in-flight work
33
+ → **busy**;
34
+ * an agent with live (``RUNNING``/``PENDING``) assignments is in-flight work
35
+ → **busy** (the board is authoritative here for the same reason
36
+ ``coord.drive_queue`` rule 1 gives: a drive whose *observer* gave up leaves
37
+ the worker running and invisible to session counts);
38
+ * a **fired** deploy gate (:data:`~coord.drive_queue.HOLD_FIRED`) is
39
+ **not** busy — it is the opposite. `--hold-after` means "this entry landed
40
+ a change that crosses a deploy lane; stop launching until a human deploys
41
+ and releases it" (#1757). The queue has *deliberately stopped*. That is the
42
+ best propagation window there is, and propagation is precisely the deploy
43
+ the gate is waiting for. So a fired hold is an *invitation*, and a verified
44
+ propagation releases it (see :func:`holds_to_release`) — the gate stops the
45
+ queue for the deploy, propagation performs the deploy, propagation restarts
46
+ the queue. One mechanism, one loop, no second notion of quiescence.
47
+
48
+ QUIESCENCE IS PER-HOST, NOT FLEET-WIDE ALL-OR-NOTHING
49
+ ------------------------------------------------------
50
+ #2067: every :class:`Busy` signal already carries the host it belongs to —
51
+ a drive-queue entry names the host that launched it, a live assignment
52
+ names the machine running it. Collapsing that to one fleet-wide boolean
53
+ (``not busy``) means a single continuously-running drive queue, on any one
54
+ host, defers propagation *forever*: the queue refills from its backlog
55
+ every few minutes, so at least one entry is running essentially always, and
56
+ "quiescent" never arrives. That is not a rare edge case, it is the steady
57
+ state of a working overnight queue — the busier and more useful the queue
58
+ is, the less this fleet-wide reading ever fires.
59
+
60
+ :attr:`Quiescence.quiescent`/``reason`` remain the fleet-wide summary (still
61
+ the right answer for `--force`, and for the one case that genuinely must
62
+ stay all-or-nothing — see below). A caller that wants to roll a
63
+ partially-busy fleet uses :meth:`Quiescence.rollable_hosts` instead: a host
64
+ with no busy signal against it is free to roll *now*, independent of
65
+ whatever else is running elsewhere. A signal that cannot be pinned to one
66
+ host (the board itself unreadable, a drive-queue row with no recorded
67
+ ``launch_host``) is the one thing that still has to block everything —
68
+ see :attr:`Quiescence.fleet_wide_busy` — because there is no way to tell
69
+ "busy everywhere" apart from "busy on some host we can't name".
70
+
71
+ Per-host quiescence does not repeal the daemon-leads invariant below: if
72
+ the daemon host itself is occupied (and not already on the target), no
73
+ other host's python lane may roll ahead of it either, because that would
74
+ put a caller on a newer `coord` than the daemon it talks to — the
75
+ documented 405. The shell (`coord/commands/release.py`) enforces this by
76
+ deferring the whole run only in that one case; every other combination of
77
+ busy hosts rolls whatever it can and defers only what it can't.
78
+
79
+ LANE ORDER ANSWERS THE SKEW QUESTION
80
+ ------------------------------------
81
+ #1835 asks whether a fleet mid-roll — hosts at two versions — is safe for
82
+ the board protocol, and insists on an explicit answer rather than an
83
+ assumption. It is safe **in one direction only**, and the direction is
84
+ already a documented failure: a *caller* on a new version calling a *daemon*
85
+ that predates the endpoint it wants gets a 405. New callers must therefore
86
+ never appear before the daemon can serve them.
87
+
88
+ :func:`plan_lanes` encodes that as a total order rather than leaving it to
89
+ whoever wrote the loop:
90
+
91
+ 1. the **daemon host**'s Python lane first — it must lead, always;
92
+ 2. every **other machine**'s Python lane;
93
+ 3. each host's **systemd unit** lane (#1831 — `deploy/**` ships in the wheel
94
+ as ``coord/deploy/``, so this lane can only roll *after* that host's venv
95
+ swapped);
96
+ 4. the **coord-tui** binaries last — a pure client of the board API, so it is
97
+ the one lane that is safe at any skew.
98
+
99
+ Propagation is therefore explicitly **not** all-or-nothing; it is ordered so
100
+ that every intermediate state is one the protocol already tolerates
101
+ (old caller → new daemon), and never the one it does not.
102
+
103
+ THE GATE'S SCOPE MUST MATCH PROPAGATION'S REACH
104
+ -----------------------------------------------
105
+ #2052: propagation gates its roll on ``coord release verify``, but verify
106
+ grades lanes propagation **cannot roll**. On 2026-08-09, the first run that
107
+ ever reached the verify step did everything it was capable of — three python
108
+ lanes, three unit lanes, the one ``coord-tui`` it could reach — and still
109
+ came back red, because verify also counted ``~/.coord-cli-venv`` (a lane this
110
+ module has zero references to), the two *remote* ``coord-tui`` binaries
111
+ (which propagation itself reports have no remote install path) and the
112
+ ``coord-serve`` process (whose venv had swapped but whose process nothing
113
+ here restarts). ``--rollback-on-red`` then reverted its own good work — and
114
+ would have done so on every run, forever.
115
+
116
+ :func:`scope_verification` is the fix, and the rule it encodes is general:
117
+ **a verify gate must not be able to fail for reasons the thing it gates
118
+ cannot influence.** Findings on lanes this run attempted and could have moved
119
+ are *blocking*; findings on lanes with no channel are *advisory* — reported,
120
+ journalled in full, never grounds for a rollback. Rolling back a good python
121
+ roll because a per-host binary could not be installed remotely is a category
122
+ error, not a safety measure.
123
+
124
+ Advisory is emphatically not "ignored", and the exemption is an allow-list of
125
+ *known* gaps rather than "anything we failed to classify" — an unrecognised
126
+ lane keeps the gate. A check that quietly stopped checking is the failure
127
+ this whole module exists to prevent.
128
+
129
+ Worth naming on its own: this defect was invisible for five runs because
130
+ every one of them found live work and correctly deferred. The deferral path
131
+ was thoroughly tested by circumstance; the success path was not tested at
132
+ all. **A mechanism whose failure mode only appears on the happy path needs
133
+ its happy path tested first, not last.**
134
+
135
+ REACH MUST GROW TO MEET THE GATE, NOT THE OTHER WAY AROUND
136
+ ------------------------------------------------------------
137
+ #2069 (split out of #2067, the window bug): #2052 correctly stopped a run
138
+ from being punished for lanes it could not move. It did not make those lanes
139
+ move — a run could restart nothing but ``coord-agent``, report success, and
140
+ leave ``coord-serve`` serving v0.5.8's ``review.py`` while every readout said
141
+ v0.5.13. Advisory is "not ignored", but nothing downstream *acts* on an
142
+ advisory finding either, so a half-deploy and a whole one produced the same
143
+ green result — quietly, because #2052 had (correctly) silenced the loud
144
+ wrong version of this failure.
145
+
146
+ The fix is not a bigger allow-list; it is closing the gaps :data:`
147
+ OUT_OF_REACH_LANES` was recording. ``coord-serve``, ``coord-web`` and
148
+ ``coord-drive-queue`` now get restarted as the last step of their host's
149
+ python lane (see :data:`RESTARTED_BY_PYTHON_LANE` and ``coord/commands/
150
+ release.py``'s ``_roll_python``), so their ``<unit> spawns`` findings — and,
151
+ for the daemon specifically, its own ``coord-serve process`` version — are
152
+ graded exactly like ``coord-agent spawns`` always was: blocking when this run
153
+ attempted that host's python lane, advisory when it did not. Coverage and
154
+ enforcement move together on purpose: :func:`scope_verification` reads
155
+ :data:`OUT_OF_REACH_LANES` and :data:`RESTARTED_BY_PYTHON_LANE` directly, so
156
+ shrinking one of those sets is the whole patch — no second edit to the gate
157
+ logic is needed to re-arm it for a lane that just gained a channel.
158
+
159
+ ``~/.coord-cli-venv`` and the two remote ``coord-tui`` binaries are still
160
+ open — #2069's fix 2 and fix 3, respectively. Neither has a channel yet, so
161
+ both stay in :data:`OUT_OF_REACH_LANES` (``coord-tui`` per-host is handled
162
+ separately, by :func:`lane_is_out_of_reach`'s "unrollable" path rather than
163
+ this set — see :func:`plan_lanes`'s LANE ORDER section).
164
+
165
+ PURITY
166
+ ------
167
+ Nothing in this module runs a subprocess, opens a socket, touches the DB or
168
+ reads the clock — same split ``coord/drive_queue.py`` documents, for the same
169
+ reason: every bug worth catching lives in the decision half. The clock is
170
+ passed in. ``coord/commands/release.py`` is the I/O shell that gathers the
171
+ facts, calls in here, executes the plan and appends the journal.
172
+ """
173
+
174
+ from __future__ import annotations
175
+
176
+ import json
177
+ import re
178
+ from dataclasses import asdict, dataclass, field
179
+ from pathlib import Path
180
+ from typing import Any, Iterable, Mapping, Sequence
181
+
182
+ from coord.drive_queue import HOLD_FIRED, STATE_RUNNING, build_board_view, entry_key
183
+
184
+ # ── lane kinds ───────────────────────────────────────────────────────────────
185
+ #
186
+ # One string per *kind* of thing that has to move for the fleet to reach a
187
+ # version. These are the lanes #1835's precondition list names, and the same
188
+ # lanes `coord release verify` (#1834) grades afterwards. A release that
189
+ # propagates only `python` would have shipped #1543's `--dist` flag and none
190
+ # of its behaviour (that change was three unit files and a shell script).
191
+ LANE_PYTHON = "python"
192
+ LANE_UNITS = "units"
193
+ LANE_TUI = "tui"
194
+
195
+ #: Every lane this module knows how to roll, in no particular order — the
196
+ #: *order* is :func:`plan_lanes`'s output, not this tuple.
197
+ ALL_LANES: tuple[str, ...] = (LANE_PYTHON, LANE_UNITS, LANE_TUI)
198
+
199
+ # ── the gate's scope ─────────────────────────────────────────────────────────
200
+ #
201
+ # #2052: `coord release propagate` gated its roll on `coord release verify`,
202
+ # but verify grades lanes propagation **cannot roll**. On the first quiescent
203
+ # window the run did everything it was capable of — three python lanes, three
204
+ # unit lanes, the one coord-tui it could reach — and still came back red,
205
+ # because verify also counted `~/.coord-cli-venv` (a lane this module has no
206
+ # model of at all), the two remote `coord-tui` binaries (which propagation
207
+ # itself reports have NO remote install path), and the `coord-serve` process
208
+ # (whose venv had swapped but whose process nothing here restarts). With
209
+ # `--rollback-on-red` that reverted its own good work — every run, forever.
210
+ #
211
+ # The rule this section encodes: **a verify gate must not be able to fail for
212
+ # reasons the thing it gates cannot influence.** Findings on lanes this run
213
+ # actually attempted are BLOCKING; findings on lanes propagation has no
214
+ # channel for are ADVISORY — reported loudly, never grounds for a rollback.
215
+ #
216
+ # Advisory is not "ignored". An advisory crit is a real defect somebody must
217
+ # fix by hand; it is simply not evidence that *this roll* was bad, and
218
+ # reverting a good python roll because a per-host binary could not be
219
+ # installed remotely is a category error.
220
+
221
+ #: ``coord release verify`` lane name -> the propagation lane that could move
222
+ #: it. Exact names, matched against :func:`coord.release_verify.lanes_for_host`
223
+ #: and :func:`~coord.release_verify.findings_for_host`.
224
+ #:
225
+ #: ``coord-serve process`` (:func:`coord.release_verify.daemon_lanes`) is the
226
+ #: daemon's own introspected version — a different lane than ``coord-serve
227
+ #: spawns`` (what it would hand subprocesses), and the one #2069's restart
228
+ #: actually targets, so it needs its own exact entry rather than falling out
229
+ #: of the ``" spawns"`` suffix rule below.
230
+ _VERIFY_LANE_EXACT: dict[str, str] = {
231
+ "~/.coord-venv": LANE_PYTHON,
232
+ "coord-tui": LANE_TUI,
233
+ "coord-serve process": LANE_PYTHON,
234
+ }
235
+
236
+ #: Units whose *live process* a python-lane roll actually replaces. ``POST
237
+ #: /update`` swaps the venv and re-execs **the agent**; #2069 closes the rest
238
+ #: of the gap it used to leave open — the same roll now also asks the agent
239
+ #: to ``systemctl --user restart`` every sibling unit it finds running on
240
+ #: that host (``POST /restart-services``, called right after ``/update``
241
+ #: lands — see ``coord/commands/release.py``'s ``_roll_python``). Before
242
+ #: #2069, ``coord-serve``, ``coord-web`` and ``coord-drive-queue`` kept
243
+ #: running the generation they started with until a human restarted them by
244
+ #: hand — exactly the third failure in #2052's run: "the venv swapped, but
245
+ #: the *process* had not been restarted at the moment verify ran". A host
246
+ #: that does not actually run one of these units is unaffected — which unit
247
+ #: to restart is read off that host's own ``spawned_coord`` facts (a
248
+ #: topology question), never assumed from this list.
249
+ RESTARTED_BY_PYTHON_LANE: frozenset[str] = frozenset(
250
+ {"coord-agent", "coord-serve", "coord-web", "coord-drive-queue"}
251
+ )
252
+
253
+ #: Lanes ``coord release verify`` grades that propagation has no channel for,
254
+ #: named individually so the exemption is a decision rather than a fallthrough.
255
+ #: ``~/.coord-cli-venv`` is the headline: `release_propagate.py` contains zero
256
+ #: references to it (#2069's fix 2 is still open — see the module docstring);
257
+ #: ``webapp bundle`` is SHA-versioned off a continuous timer and never
258
+ #: pip-versioned at all, so it has no "target version" to roll TO in the
259
+ #: first place. ``coord-serve process`` used to live here too — #2069 gave it
260
+ #: a channel (the restart above), so it now has its own entry in
261
+ #: :data:`_VERIFY_LANE_EXACT` instead, graded the same way every other
262
+ #: reachable lane is: blocking exactly when this run attempted that host's
263
+ #: python lane. Shrinking this set is exactly how new coverage is meant to
264
+ #: re-arm the gate (see the module docstring above).
265
+ OUT_OF_REACH_LANES: frozenset[str] = frozenset({"~/.coord-cli-venv", "webapp bundle"})
266
+
267
+ #: ``"~/.coord-venv (precision)"`` — the label `coord release verify` builds
268
+ #: for a lane, and the only form a grouped finding names its lanes by.
269
+ _LANE_LABEL = re.compile(r"^(?P<lane>.+?)\s+\((?P<host>[^()]+)\)$")
270
+
271
+
272
+ def parse_lane_label(label: str) -> tuple[str, str] | None:
273
+ """``"~/.coord-venv (precision)"`` -> ``("precision", "~/.coord-venv")``.
274
+
275
+ ``coord release verify`` groups an ``--expected`` mismatch into ONE
276
+ finding per offending version, naming its lanes as a comma-joined list of
277
+ these labels. Scoping the gate therefore has to take such a finding apart
278
+ again: "0.5.4, expected 0.5.8" across the CLI venv and the daemon process
279
+ is advisory, the same sentence across a host's ``~/.coord-venv`` is not.
280
+ """
281
+ match = _LANE_LABEL.match(label.strip())
282
+ if not match:
283
+ return None
284
+ return match.group("host").strip(), match.group("lane").strip()
285
+
286
+
287
+ def verify_lane_kind(lane: str) -> str | None:
288
+ """Which propagation lane could move ``coord release verify``'s *lane*.
289
+
290
+ ``None`` means "no propagation lane moves this" — which is not the same
291
+ as "this lane is fine", only "this run is not what would fix it".
292
+ """
293
+ name = lane.strip()
294
+ if name in _VERIFY_LANE_EXACT:
295
+ return _VERIFY_LANE_EXACT[name]
296
+ if name.startswith("unit "):
297
+ # `unit coord-agent.service` — the #1831 deploy/** lane, rolled by
298
+ # POST /deploy-units.
299
+ return LANE_UNITS
300
+ if name.endswith(" spawns"):
301
+ unit = name[: -len(" spawns")].strip()
302
+ return LANE_PYTHON if unit in RESTARTED_BY_PYTHON_LANE else None
303
+ return None
304
+
305
+
306
+ def lane_is_out_of_reach(lane: str) -> bool:
307
+ """Is *lane* one propagation structurally cannot influence?
308
+
309
+ Deliberately an allow-list of *known* gaps rather than "anything
310
+ :func:`verify_lane_kind` didn't recognise". A lane nobody thought about
311
+ must keep the gate honest — the failure mode this whole module exists for
312
+ is a check that quietly stopped checking.
313
+ """
314
+ name = lane.strip()
315
+ if name in OUT_OF_REACH_LANES:
316
+ return True
317
+ return name.endswith(" spawns") and verify_lane_kind(name) is None
318
+
319
+
320
+ @dataclass(frozen=True)
321
+ class GateVerdict:
322
+ """What the post-roll verification means *for this run*.
323
+
324
+ ``severity`` is the worst **blocking** severity — the only thing
325
+ ``--rollback-on-red`` may act on. ``advisory`` carries everything real but
326
+ out of reach, so scoping the gate never becomes hiding the finding.
327
+ """
328
+
329
+ severity: str = "ok"
330
+ blocking: tuple[dict, ...] = ()
331
+ advisory: tuple[dict, ...] = ()
332
+ #: ``lane@host`` for every lane this run could not roll here at all.
333
+ unrollable: tuple[str, ...] = ()
334
+
335
+ @property
336
+ def red(self) -> bool:
337
+ return self.severity == "crit"
338
+
339
+ def to_dict(self) -> dict:
340
+ return {
341
+ "severity": self.severity,
342
+ "blocking": [dict(f) for f in self.blocking],
343
+ "advisory": [dict(f) for f in self.advisory],
344
+ "unrollable": list(self.unrollable),
345
+ }
346
+
347
+
348
+ #: Same ranking `coord release verify` uses; re-spelled rather than imported
349
+ #: to keep this module import-free of the verifier (the shell owns that seam).
350
+ _SEVERITY_RANK = {"ok": 0, "unknown": 1, "warn": 2, "crit": 3}
351
+
352
+
353
+ def _finding_pairs(finding: Mapping[str, Any]) -> list[tuple[str, str]]:
354
+ """``(host, lane)`` for every lane a verify finding actually speaks about."""
355
+ lane_field = str(finding.get("lane") or "")
356
+ pairs = [
357
+ parsed
358
+ for part in lane_field.split(", ")
359
+ if (parsed := parse_lane_label(part)) is not None
360
+ ]
361
+ if pairs:
362
+ return pairs
363
+ return [(str(finding.get("host") or ""), lane_field)]
364
+
365
+
366
+ def attempted_scope(lanes: Iterable[Mapping[str, Any]]) -> set[tuple[str, str]]:
367
+ """``{(lane, host)}`` this run actually attempted and could have moved.
368
+
369
+ Read straight off the journalled lane records, so the gate's scope is
370
+ exactly what the history says the run did — not a second, parallel
371
+ account of it that could drift.
372
+
373
+ Excluded, on purpose:
374
+
375
+ * ``ok is None`` **without** a real attempt — a host skipped because the
376
+ daemon's python lane failed, or already on the target;
377
+ * anything flagged ``unrollable`` — a lane with no channel *on this host*
378
+ (the remote ``coord-tui``, an agent that predates ``/deploy-units``).
379
+ Attempting is not the same as being able to.
380
+ """
381
+ scope: set[tuple[str, str]] = set()
382
+ for row in lanes:
383
+ lane = str(row.get("lane") or "")
384
+ host = str(row.get("host") or "")
385
+ if lane not in ALL_LANES or not host:
386
+ continue
387
+ if row.get("unrollable"):
388
+ continue
389
+ if row.get("ok") is None:
390
+ continue
391
+ scope.add((lane, host))
392
+ return scope
393
+
394
+
395
+ def scope_verification(
396
+ verification: Mapping[str, Any] | None,
397
+ *,
398
+ lanes: Iterable[Mapping[str, Any]] = (),
399
+ ) -> GateVerdict:
400
+ """Split a ``coord release verify`` report into blocking and advisory.
401
+
402
+ *verification* is the report as :meth:`coord.release_verify.VerifyReport.
403
+ to_dict` renders it; *lanes* are this run's journalled lane records.
404
+
405
+ A finding is BLOCKING when any lane it names is one this run attempted
406
+ and could have moved, or when it names a lane nothing here recognises (an
407
+ unclassifiable finding must keep the gate honest — see
408
+ :func:`lane_is_out_of_reach`). Everything else is advisory.
409
+ """
410
+ rows = list(lanes)
411
+ scope = attempted_scope(rows)
412
+ unrollable = tuple(
413
+ f"{row.get('lane')}@{row.get('host')}" for row in rows if row.get("unrollable")
414
+ )
415
+ if not verification:
416
+ return GateVerdict(unrollable=unrollable)
417
+
418
+ blocking: list[dict] = []
419
+ advisory: list[dict] = []
420
+ for finding in verification.get("findings") or []:
421
+ if not isinstance(finding, Mapping):
422
+ continue
423
+ pairs = _finding_pairs(finding)
424
+ holds = False
425
+ for host, lane in pairs:
426
+ kind = verify_lane_kind(lane)
427
+ if kind is not None:
428
+ holds = holds or (kind, host) in scope
429
+ elif not lane_is_out_of_reach(lane):
430
+ # Not a lane this module has an opinion about — e.g. an
431
+ # unreachable host's "(all lanes)", or a lane added to the
432
+ # verifier since. Fail toward keeping the gate.
433
+ holds = True
434
+ (blocking if holds else advisory).append(dict(finding))
435
+
436
+ severity = "ok"
437
+ for finding in blocking:
438
+ sev = str(finding.get("severity") or "unknown")
439
+ if _SEVERITY_RANK.get(sev, 1) > _SEVERITY_RANK[severity]:
440
+ severity = sev
441
+ return GateVerdict(
442
+ severity=severity,
443
+ blocking=tuple(blocking),
444
+ advisory=tuple(advisory),
445
+ unrollable=unrollable,
446
+ )
447
+
448
+
449
+ # ── propagation outcomes ─────────────────────────────────────────────────────
450
+ #
451
+ # The status recorded in the journal for one propagation attempt. #1835:
452
+ # "a silent success is indistinguishable from a silent no-op, which is
453
+ # precisely how 2026-08-04 stayed invisible" — so *every* attempt appends a
454
+ # record, including the boring "deferred, fleet busy" ones. A timer that
455
+ # fired forty times and deferred forty times must look different from a
456
+ # timer that never fired at all.
457
+ STATUS_DEFERRED = "deferred"
458
+ STATUS_UP_TO_DATE = "up-to-date"
459
+ STATUS_ROLLED = "rolled"
460
+ STATUS_VERIFIED = "verified"
461
+ STATUS_ROLLED_BACK = "rolled-back"
462
+ STATUS_FAILED = "failed"
463
+
464
+ #: Statuses that mean "this attempt changed nothing on any host". Used by the
465
+ #: renderer to keep a long quiet night readable.
466
+ NO_OP_STATUSES: frozenset[str] = frozenset({STATUS_DEFERRED, STATUS_UP_TO_DATE})
467
+
468
+ #: Board assignment statuses that count as live work. Mirrors the set
469
+ #: ``coord/agent_app.py``'s ``/update`` refuses on, deliberately: propagation
470
+ #: must not schedule an update the agent would then refuse.
471
+ LIVE_ASSIGNMENT_STATUSES: frozenset[str] = frozenset({"RUNNING", "PENDING"})
472
+
473
+
474
+ # ── busy signals ─────────────────────────────────────────────────────────────
475
+
476
+
477
+ @dataclass(frozen=True)
478
+ class Busy:
479
+ """One concrete reason the fleet is not quiescent right now.
480
+
481
+ Named down to the subject so a deferral is actionable prose ("dellserver
482
+ has 2 live assignments") rather than the useless "fleet busy" — a
483
+ deferral nobody can explain is a deferral nobody can distinguish from a
484
+ wedged timer.
485
+ """
486
+
487
+ kind: str
488
+ subject: str
489
+ detail: str = ""
490
+ #: Which host this signal blocks, or ``None`` when it cannot be pinned
491
+ #: to one — a drive-queue entry launched before #1870 recorded
492
+ #: ``launch_host``, or a fleet-level fact like "the board itself is
493
+ #: unreadable" that says nothing about which host is actually busy.
494
+ #: #2067: an unattributable signal must fail toward blocking EVERY
495
+ #: host, never toward blocking none of them — see
496
+ #: :attr:`Quiescence.fleet_wide_busy`.
497
+ host: str | None = None
498
+
499
+ def describe(self) -> str:
500
+ base = f"{self.kind}: {self.subject}"
501
+ return f"{base} ({self.detail})" if self.detail else base
502
+
503
+
504
+ @dataclass(frozen=True)
505
+ class Quiescence:
506
+ """Is there a window right now, and if not, where.
507
+
508
+ #2067: quiescence used to be one fleet-wide boolean, computed by
509
+ discarding the host every :class:`Busy` already carries. On a fleet
510
+ whose drive queue runs continuously, *some* host is busy essentially
511
+ always, so that boolean is false essentially always and propagation
512
+ never fires — "correctly, quietly, and uselessly". ``quiescent``/
513
+ ``reason`` stay as the fleet-wide summary (still meaningful — an
514
+ unattributable signal or `--force` both want a single answer), but a
515
+ caller that wants to roll a partially-busy fleet should use
516
+ :meth:`rollable_hosts` instead.
517
+ """
518
+
519
+ quiescent: bool
520
+ busy: tuple[Busy, ...] = ()
521
+ #: Fired deploy gates (#1757) found while assessing. Not busy signals —
522
+ #: see the module docstring. Carried through so the caller can release
523
+ #: them after a verified roll.
524
+ fired_holds: tuple[str, ...] = ()
525
+ #: #2110: `running` queue entries this assessment could DISPROVE — the
526
+ #: entry's own issue is landed (merged or closed) per the SAME board read
527
+ #: used everywhere else here, so the row cannot possibly still be
528
+ #: in-flight, whatever its `state` column says. Not a busy signal (the
529
+ #: whole point is that it does NOT block); not silent either (#1616's
530
+ #: "the pipeline has no clock" lesson) — surfaced here so a caller can
531
+ #: log it and point at `coord drive-queue tick --reconcile-only`, the
532
+ #: thing that actually clears the row, rather than the stale state
533
+ #: quietly evaporating from the reasoning with no record it was ever
534
+ #: wrong. See `_reconcile_running` in `coord/drive_queue.py` for the tick
535
+ #: doing the same disproof on its own cadence; this is the same evidence,
536
+ #: re-checked on READ so a stopped timer cannot make it unfalsifiable.
537
+ stale: tuple[str, ...] = ()
538
+
539
+ @property
540
+ def reason(self) -> str:
541
+ if self.quiescent:
542
+ if self.fired_holds:
543
+ return (
544
+ "quiescent — and "
545
+ f"{len(self.fired_holds)} deploy gate(s) are waiting on "
546
+ "exactly this deploy"
547
+ )
548
+ return "quiescent — nothing in flight"
549
+ return "; ".join(b.describe() for b in self.busy) or "busy"
550
+
551
+ @property
552
+ def fleet_wide_busy(self) -> tuple[Busy, ...]:
553
+ """Busy signals that cannot be pinned to one host.
554
+
555
+ These block every host, not just the (nonexistent) one they name —
556
+ there is no way to know which host an unattributable signal
557
+ actually occupies, so the safe read is "all of them".
558
+ """
559
+ return tuple(b for b in self.busy if b.host is None)
560
+
561
+ def busy_hosts(self) -> set[str]:
562
+ """Every host a *specific* busy signal names.
563
+
564
+ Excludes unattributable signals — see :attr:`fleet_wide_busy` for
565
+ those; a caller must check both, which :meth:`rollable_hosts` does.
566
+ """
567
+ return {b.host for b in self.busy if b.host}
568
+
569
+ def rollable_hosts(self, hosts: Iterable[str]) -> list[str]:
570
+ """Which of *hosts* have no busy signal against them right now.
571
+
572
+ Empty whenever any busy signal cannot be attributed to a host: an
573
+ unreadable board, or a drive-queue entry with no recorded launch
574
+ host, means "the fleet is busy somewhere unknown", which is not
575
+ distinguishable from "everywhere" and must be treated as such.
576
+ """
577
+ if self.fleet_wide_busy:
578
+ return []
579
+ occupied = self.busy_hosts()
580
+ return [h for h in hosts if h not in occupied]
581
+
582
+ def busy_reason_for_host(self, host: str) -> str:
583
+ """Why *host* specifically is not rollable right now.
584
+
585
+ Empty string when nothing names it — the caller's cue that this
586
+ host is free.
587
+ """
588
+ reasons = [b.describe() for b in self.busy if b.host is None or b.host == host]
589
+ return "; ".join(reasons)
590
+
591
+ def to_dict(self) -> dict:
592
+ return {
593
+ "quiescent": self.quiescent,
594
+ "reason": self.reason,
595
+ "busy": [asdict(b) for b in self.busy],
596
+ "fired_holds": list(self.fired_holds),
597
+ "stale": list(self.stale),
598
+ }
599
+
600
+
601
+ def _queue_key(entry: Mapping[str, Any]) -> str:
602
+ """``repo#issue`` for a ``drive_queue`` row, however it reached us.
603
+
604
+ ``/board`` publishes the sqlite columns verbatim (``repo_name`` /
605
+ ``issue_number``); an already-rendered row may carry ``key``. Both are
606
+ accepted so this never silently degrades to ``"?"`` — a busy signal
607
+ nobody can name is a busy signal nobody can act on, and ``coord
608
+ drive-queue resume`` needs the real key to release the gate.
609
+ """
610
+ key = entry.get("key")
611
+ if key:
612
+ return str(key)
613
+ repo = entry.get("repo_name") or entry.get("repo")
614
+ issue = entry.get("issue_number") or entry.get("issue")
615
+ if repo and issue is not None:
616
+ try:
617
+ return entry_key(str(repo), int(issue))
618
+ except (TypeError, ValueError):
619
+ return f"{repo}#{issue}"
620
+ return "?"
621
+
622
+
623
+ def busy_host_for_entry(entry: Mapping[str, Any]) -> str | None:
624
+ """Which host a ``running`` drive-queue row actually occupies (#2101).
625
+
626
+ #2067 attributed this to ``launch_host`` (#1870) — the host whose tick
627
+ launched the session — falling back to ``machine``. Measured on
628
+ 2026-08-10, that reading pins the WHOLE fleet on every drive:
629
+
630
+ * the drive-queue tick runs on the timer host, so ``coord drive --tmux``
631
+ is spawned there and ``launch_host`` is *always* that host;
632
+ * that host is the daemon host, and the daemon must lead every roll, so
633
+ the one host it charges is the one host whose busyness defers every
634
+ other host's python lane (see the module docstring's LANE ORDER).
635
+
636
+ Net effect: **any drive anywhere pinned the entire fleet from rolling**,
637
+ which is fact 2 of #2101 and half of why the fleet sat eleven releases
638
+ behind. So the precedence is inverted: an entry pinned with ``--machine``
639
+ is charged to the machine that will actually run the WORKER, because the
640
+ worker is what an agent restart destroys (``coord/agent_app.py``'s
641
+ ``/update`` refuses a host with live assignments for exactly that reason).
642
+ The launch host merely hosts an observer tmux session, and #2101's cordon
643
+ is what protects *it*: no new drive is launched onto a cordoned host at
644
+ all, so nothing new starts there while it waits to roll.
645
+
646
+ ``launch_host`` remains the fallback for an unpinned (auto-picked) entry,
647
+ whose real worker machine is knowable only from the live assignment row —
648
+ and that row is a busy signal in its own right, attributed correctly, a
649
+ few lines below. Neither field present means the signal cannot be pinned
650
+ to a host at all and must block every host rather than none — see
651
+ :attr:`Quiescence.fleet_wide_busy`.
652
+ """
653
+ return str(entry.get("machine") or entry.get("launch_host") or "") or None
654
+
655
+
656
+ def assess_quiescence(
657
+ *,
658
+ queue_entries: Iterable[Mapping[str, Any]] = (),
659
+ assignments: Iterable[Mapping[str, Any]] = (),
660
+ issues: Iterable[Mapping[str, Any]] = (),
661
+ extra_busy: Iterable[Busy] = (),
662
+ ) -> Quiescence:
663
+ """Is the fleet idle enough to restart every agent on it?
664
+
665
+ *queue_entries* are ``drive_queue`` rows as they come off the board /
666
+ ``coord drive-queue list --json``; *assignments* are board assignment
667
+ rows; *issues* are board issue rows (``repo_name``/``number``/``state``).
668
+ All three are read as plain mappings — #1523 §2's "typed state, never
669
+ CLI prose", the rule both bugs in the ad-hoc overnight sequencer broke.
670
+
671
+ ``extra_busy`` is the seam for host-local signals the board cannot see
672
+ (an interactive tmux session, a machine paused by an operator); the
673
+ shell passes them in rather than this module growing a way to look.
674
+
675
+ #2110: a ``running`` queue row is not, on its own, proof of anything —
676
+ the reconciler that would have moved it to ``done`` lives inside
677
+ ``coord drive-queue tick``, and a stopped timer means nothing ever runs
678
+ it. The 2026-08-10 incident deferred `coord release propagate` for over
679
+ an hour on a row describing a drive that had merged, closed and left no
680
+ trace anywhere on the fleet — the row was simply never re-examined. So
681
+ before trusting ``state == "running"`` this re-derives the SAME
682
+ disproof `coord.drive_queue._reconcile_running` uses on its own tick
683
+ (``coord.drive_queue.build_board_view(...).facts(key).landed`` — merged
684
+ or closed) against *this* read of the board, live at call time, rather
685
+ than only during a tick that may not be running right now. A row that
686
+ fails that check cannot possibly still be in flight, so it is excluded
687
+ from ``busy`` and reported in :attr:`Quiescence.stale` instead — visible,
688
+ not silently dropped (#1616).
689
+
690
+ This is deliberately narrower than the tick's own reconciliation: it has
691
+ no local tmux read (liveness is a LOCAL fact, #1870, and this may run on
692
+ any host) and no attempt-tracking, so it can only ever DISPROVE a
693
+ ``running`` row, never retry or block one — that stays the tick's job.
694
+ It closes exactly the gap that let a landed row block a roll forever
695
+ with no clock and no way to contradict it.
696
+ """
697
+ queue_entries = list(queue_entries)
698
+ assignments = list(assignments)
699
+ issues = list(issues)
700
+ board = build_board_view({"assignments": assignments, "issues": issues})
701
+
702
+ busy: list[Busy] = []
703
+ fired: list[str] = []
704
+ stale: list[str] = []
705
+
706
+ for entry in queue_entries:
707
+ state = str(entry.get("state") or "")
708
+ key = _queue_key(entry)
709
+ if state == STATE_RUNNING:
710
+ if board.facts(key).landed:
711
+ # Disproved: this issue is merged or closed, so the row
712
+ # cannot still be in flight whatever its `state` column
713
+ # says. Not busy — and not silently dropped either.
714
+ stale.append(key)
715
+ else:
716
+ host = busy_host_for_entry(entry)
717
+ busy.append(
718
+ Busy(
719
+ kind="drive-queue entry running",
720
+ subject=key,
721
+ detail="restarting agents now would kill it mid-flight",
722
+ host=host,
723
+ )
724
+ )
725
+ # A *fired* gate is the opposite of busy — the queue has stopped
726
+ # itself waiting for precisely this deploy. Recorded, never counted.
727
+ if str(entry.get("hold_state") or "") == HOLD_FIRED:
728
+ fired.append(key)
729
+
730
+ for row in assignments:
731
+ status = str(row.get("status") or "").upper()
732
+ if status not in LIVE_ASSIGNMENT_STATUSES:
733
+ continue
734
+ machine = str(row.get("machine_name") or row.get("machine") or "") or None
735
+ subject = str(
736
+ row.get("issue_number")
737
+ or row.get("issue")
738
+ or row.get("assignment_id")
739
+ or "?"
740
+ )
741
+ busy.append(
742
+ Busy(
743
+ kind=f"live {status} assignment",
744
+ subject=f"{machine or '?'}:{subject}",
745
+ detail="`coord agent update` would refuse this host anyway",
746
+ host=machine,
747
+ )
748
+ )
749
+
750
+ busy.extend(extra_busy)
751
+ return Quiescence(
752
+ quiescent=not busy,
753
+ busy=tuple(busy),
754
+ fired_holds=tuple(dict.fromkeys(fired)),
755
+ stale=tuple(dict.fromkeys(stale)),
756
+ )
757
+
758
+
759
+ def holds_to_release(quiescence: Quiescence, *, verified: bool) -> tuple[str, ...]:
760
+ """Which deploy gates a finished propagation should release (#1757).
761
+
762
+ Only after a **verified** roll. Releasing a gate on an unverified — or
763
+ rolled-back — propagation would restart the overnight queue into exactly
764
+ the "merged is not live" trap the gate exists to prevent, which is the
765
+ single most expensive recurring failure in this fleet.
766
+ """
767
+ return quiescence.fired_holds if verified else ()
768
+
769
+
770
+ # ── the roll plan ────────────────────────────────────────────────────────────
771
+
772
+
773
+ @dataclass(frozen=True)
774
+ class LaneRoll:
775
+ """One unit of propagation work: roll *lane* on *host* to a version."""
776
+
777
+ order: int
778
+ lane: str
779
+ host: str
780
+ #: Why this step sits where it does in the order. Rendered in `--dry-run`
781
+ #: and journalled, because the ordering is a protocol-safety argument and
782
+ #: an argument nobody can read is an argument nobody can check.
783
+ rationale: str = ""
784
+
785
+ @property
786
+ def label(self) -> str:
787
+ return f"{self.lane}@{self.host}"
788
+
789
+
790
+ def plan_lanes(
791
+ *,
792
+ daemon_host: str | None,
793
+ hosts: Sequence[str],
794
+ lanes: Iterable[str] = ALL_LANES,
795
+ skip_hosts: Iterable[str] = (),
796
+ ) -> list[LaneRoll]:
797
+ """The total order in which lanes may roll. See the module docstring.
798
+
799
+ The invariant this function exists to hold: **the daemon never lags a
800
+ caller.** A host running a newer ``coord`` than the daemon it talks to
801
+ reproduces the documented 405 (caller wants an endpoint the daemon does
802
+ not serve yet); the reverse — a newer daemon serving older callers — is
803
+ the skew the board protocol is built to tolerate, since that is the
804
+ steady state between every release and every fleet update anyway.
805
+
806
+ *skip_hosts* drops hosts already on the target version, so a re-run after
807
+ a partial failure resumes rather than restarting.
808
+ """
809
+ wanted = [lane for lane in ALL_LANES if lane in set(lanes)]
810
+ skip = set(skip_hosts)
811
+ ordered_hosts: list[str] = []
812
+ if daemon_host and daemon_host in hosts:
813
+ ordered_hosts.append(daemon_host)
814
+ ordered_hosts.extend(h for h in hosts if h != daemon_host)
815
+
816
+ rolls: list[LaneRoll] = []
817
+ order = 0
818
+
819
+ if LANE_PYTHON in wanted:
820
+ for host in ordered_hosts:
821
+ if host in skip:
822
+ continue
823
+ first = host == daemon_host
824
+ order += 1
825
+ rolls.append(
826
+ LaneRoll(
827
+ order=order,
828
+ lane=LANE_PYTHON,
829
+ host=host,
830
+ rationale=(
831
+ "daemon host leads: a caller must never reach an "
832
+ "endpoint its daemon predates (the documented 405)"
833
+ if first
834
+ else "callers follow the daemon, never lead it"
835
+ ),
836
+ )
837
+ )
838
+
839
+ if LANE_UNITS in wanted:
840
+ for host in ordered_hosts:
841
+ if host in skip:
842
+ continue
843
+ order += 1
844
+ rolls.append(
845
+ LaneRoll(
846
+ order=order,
847
+ lane=LANE_UNITS,
848
+ host=host,
849
+ rationale=(
850
+ "#1831: the units ship inside the wheel as "
851
+ "coord/deploy/, so this host's venv must have "
852
+ "swapped first"
853
+ ),
854
+ )
855
+ )
856
+
857
+ if LANE_TUI in wanted:
858
+ for host in ordered_hosts:
859
+ if host in skip:
860
+ continue
861
+ order += 1
862
+ rolls.append(
863
+ LaneRoll(
864
+ order=order,
865
+ lane=LANE_TUI,
866
+ host=host,
867
+ rationale=(
868
+ "coord-tui is a pure board-API client — safe at any "
869
+ "skew, so it goes last and can never block the fleet"
870
+ ),
871
+ )
872
+ )
873
+
874
+ return rolls
875
+
876
+
877
+ def normalize_version(raw: str | None) -> str | None:
878
+ """``v0.4.111`` / ``0.4.111`` -> ``0.4.111``; empty -> ``None``."""
879
+ if not raw:
880
+ return None
881
+ return str(raw).strip().lstrip("vV") or None
882
+
883
+
884
+ def hosts_already_current(
885
+ lane_versions: Mapping[str, Iterable[str | None]], target: str | None
886
+ ) -> list[str]:
887
+ """Hosts whose every *known* lane already reports *target*.
888
+
889
+ A host with an unreadable lane is deliberately **not** current: #1834's
890
+ rule is that ``version=None`` means "no data", which is emphatically not
891
+ "agrees with everyone else". Skipping such a host would let the lane
892
+ nobody can see be the one that stays behind — the 2026-08-04 shape.
893
+ """
894
+ want = normalize_version(target)
895
+ if not want:
896
+ return []
897
+ current: list[str] = []
898
+ for host, versions in lane_versions.items():
899
+ seen = list(versions)
900
+ if not seen or any(normalize_version(v) != want for v in seen):
901
+ continue
902
+ current.append(host)
903
+ return sorted(current)
904
+
905
+
906
+ # ── the journal ──────────────────────────────────────────────────────────────
907
+ #
908
+ # #1835's fourth acceptance criterion: "the whole sequence is observable
909
+ # after the fact: what was published, when each lane rolled, what
910
+ # verification said." An append-only JSONL file, one object per attempt, on
911
+ # whichever host runs the propagation timer. Deliberately not a DB table:
912
+ # this record must survive a half-installed venv and be readable with `tail`
913
+ # while the very upgrade it describes is in flight — which is exactly when
914
+ # `coord` itself may not import.
915
+
916
+
917
+ #: Filename under the coord state root (``~/.coord`` on Linux — see
918
+ #: :func:`coord.platform_paths.default_coord_dir`).
919
+ JOURNAL_NAME = "release_propagation.jsonl"
920
+
921
+ #: Records kept when the journal is trimmed. Small enough to `cat`, long
922
+ #: enough to cover a week of a 15-minute timer's deferrals.
923
+ JOURNAL_MAX_RECORDS = 2000
924
+
925
+
926
+ @dataclass
927
+ class PropagationRecord:
928
+ """One propagation attempt, start to finish, as journalled."""
929
+
930
+ started_at: float
931
+ target_version: str | None = None
932
+ status: str = STATUS_DEFERRED
933
+ quiescence: dict = field(default_factory=dict)
934
+ #: ``[{"lane":..., "host":..., "ok":..., "detail":...}, ...]``, in the
935
+ #: order they actually ran.
936
+ lanes: list[dict] = field(default_factory=list)
937
+ #: What `coord release verify` said, as its own JSON report. The WHOLE
938
+ #: report, always — scoping the gate (below) must never shrink the record.
939
+ verification: dict | None = None
940
+ #: :meth:`GateVerdict.to_dict` — which of those findings this run is
941
+ #: actually accountable for (#2052). This, not ``verification``, is what
942
+ #: ``--rollback-on-red`` acts on.
943
+ gate: dict | None = None
944
+ #: #2101: what this run did to the release-cordon store — which hosts it
945
+ #: cordoned so they would drain, which it uncordoned after rolling, which
946
+ #: cordons had lapsed on their own, and any drain-deadline escalation.
947
+ #: Journalled for the same reason everything else here is: a run that
948
+ #: cordoned the fleet and then died must leave a readable trace of having
949
+ #: done so, or the resulting quiet fleet is indistinguishable from #2082.
950
+ cordons: dict = field(default_factory=dict)
951
+ rolled_back: list[str] = field(default_factory=list)
952
+ released_holds: list[str] = field(default_factory=list)
953
+ finished_at: float | None = None
954
+ error: str | None = None
955
+ dry_run: bool = False
956
+
957
+ def to_dict(self) -> dict:
958
+ return asdict(self)
959
+
960
+ @property
961
+ def ok(self) -> bool:
962
+ return self.status in (STATUS_VERIFIED, STATUS_DEFERRED, STATUS_UP_TO_DATE)
963
+
964
+
965
+ def journal_path(state_dir: Path) -> Path:
966
+ return Path(state_dir) / JOURNAL_NAME
967
+
968
+
969
+ def append_record(state_dir: Path, record: PropagationRecord) -> Path:
970
+ """Append *record* as one JSON line. Best effort by contract.
971
+
972
+ A propagation must never fail *because* it could not write its own
973
+ diary, but a silently-unwritten diary is the 2026-08-04 shape, so the
974
+ caller is told (by the raised error propagating out of here only for
975
+ genuinely unexpected types) — see the shell, which reports a write
976
+ failure as a warning line and still exits on the real outcome.
977
+ """
978
+ path = journal_path(state_dir)
979
+ path.parent.mkdir(parents=True, exist_ok=True)
980
+ with path.open("a", encoding="utf-8") as fh:
981
+ fh.write(json.dumps(record.to_dict(), sort_keys=True) + "\n")
982
+ return path
983
+
984
+
985
+ def read_records(state_dir: Path, *, limit: int | None = None) -> list[dict]:
986
+ """Most-recent-last records from the journal; unparseable lines skipped.
987
+
988
+ A torn final line (the process died mid-append) must not make the whole
989
+ history unreadable — the history is most valuable in exactly that case.
990
+ """
991
+ path = journal_path(state_dir)
992
+ if not path.exists():
993
+ return []
994
+ out: list[dict] = []
995
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
996
+ line = line.strip()
997
+ if not line:
998
+ continue
999
+ try:
1000
+ obj = json.loads(line)
1001
+ except ValueError:
1002
+ continue
1003
+ if isinstance(obj, dict):
1004
+ out.append(obj)
1005
+ if limit is not None and limit >= 0:
1006
+ out = out[-limit:]
1007
+ return out
1008
+
1009
+
1010
+ def trim_journal(state_dir: Path, *, keep: int = JOURNAL_MAX_RECORDS) -> int:
1011
+ """Truncate the journal to its last *keep* records. Returns records kept."""
1012
+ records = read_records(state_dir)
1013
+ if len(records) <= keep:
1014
+ return len(records)
1015
+ kept = records[-keep:]
1016
+ path = journal_path(state_dir)
1017
+ path.write_text(
1018
+ "".join(json.dumps(r, sort_keys=True) + "\n" for r in kept), encoding="utf-8"
1019
+ )
1020
+ return len(kept)
1021
+
1022
+
1023
+ # ── rendering ────────────────────────────────────────────────────────────────
1024
+
1025
+
1026
+ def _stamp(ts: float | None) -> str:
1027
+ if not ts:
1028
+ return "?"
1029
+ import datetime as _dt # noqa: PLC0415 — leaf import, keeps the module light
1030
+
1031
+ return _dt.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
1032
+
1033
+
1034
+ _STATUS_MARK = {
1035
+ STATUS_VERIFIED: "✓",
1036
+ STATUS_UP_TO_DATE: "=",
1037
+ STATUS_DEFERRED: "·",
1038
+ STATUS_ROLLED: "~",
1039
+ STATUS_ROLLED_BACK: "↩",
1040
+ STATUS_FAILED: "✗",
1041
+ }
1042
+
1043
+
1044
+ def render_record(record: PropagationRecord | Mapping[str, Any]) -> list[str]:
1045
+ """Human-readable lines for one attempt."""
1046
+ data = record.to_dict() if isinstance(record, PropagationRecord) else dict(record)
1047
+ status = str(data.get("status") or "?")
1048
+ mark = _STATUS_MARK.get(status, "?")
1049
+ version = data.get("target_version") or "?"
1050
+ prefix = "[dry-run] " if data.get("dry_run") else ""
1051
+ lines = [
1052
+ f"{mark} {prefix}{_stamp(data.get('started_at'))} v{version} {status}"
1053
+ ]
1054
+
1055
+ quiescence = data.get("quiescence") or {}
1056
+ if quiescence.get("reason"):
1057
+ lines.append(f" window: {quiescence['reason']}")
1058
+
1059
+ for lane in data.get("lanes") or []:
1060
+ ok = lane.get("ok")
1061
+ lane_mark = "✓" if ok else ("·" if ok is None else "✗")
1062
+ detail = lane.get("detail") or ""
1063
+ lines.append(
1064
+ f" {lane_mark} {lane.get('lane')}@{lane.get('host')}"
1065
+ + (f" — {detail}" if detail else "")
1066
+ )
1067
+
1068
+ verification = data.get("verification")
1069
+ if verification:
1070
+ sev = verification.get("severity", "?")
1071
+ findings = verification.get("findings") or []
1072
+ lines.append(
1073
+ f" verify: {sev} ({len(findings)} finding(s))"
1074
+ )
1075
+ for finding in findings[:5]:
1076
+ lines.append(
1077
+ f" - [{finding.get('severity')}] {finding.get('host')} "
1078
+ f"{finding.get('lane')}: {finding.get('summary')}"
1079
+ )
1080
+
1081
+ # #2052: the gate's scope is part of the answer, not debug output. A run
1082
+ # that was held to lanes it could not roll is exactly the defect this
1083
+ # block exists to make visible the next time it happens.
1084
+ gate = data.get("gate")
1085
+ if gate:
1086
+ blocking = gate.get("blocking") or []
1087
+ advisory = gate.get("advisory") or []
1088
+ lines.append(
1089
+ f" gate: {gate.get('severity', '?')} "
1090
+ f"({len(blocking)} blocking, {len(advisory)} advisory — "
1091
+ "advisory lanes are ones propagation cannot roll)"
1092
+ )
1093
+ for finding in advisory[:5]:
1094
+ lines.append(
1095
+ f" ~ advisory [{finding.get('severity')}] "
1096
+ f"{finding.get('host')} {finding.get('lane')}: "
1097
+ f"{finding.get('summary')}"
1098
+ )
1099
+ if gate.get("unrollable"):
1100
+ lines.append(
1101
+ " ~ no channel from here: "
1102
+ + ", ".join(gate["unrollable"])
1103
+ )
1104
+
1105
+ # #2101: the cordon is the thing that CREATED this run's window (or is
1106
+ # still creating it), so it belongs in the record's headline lines, not
1107
+ # only in the JSON. A deferral that also cordoned reads completely
1108
+ # differently from one that just gave up.
1109
+ cordons = data.get("cordons") or {}
1110
+ if cordons.get("cordoned"):
1111
+ lines.append(
1112
+ " cordoned (draining to roll): " + ", ".join(cordons["cordoned"])
1113
+ )
1114
+ if cordons.get("uncordoned"):
1115
+ lines.append(" uncordoned: " + ", ".join(cordons["uncordoned"]))
1116
+ if cordons.get("expired"):
1117
+ lines.append(
1118
+ " cordons that lapsed on their own: " + ", ".join(cordons["expired"])
1119
+ )
1120
+ for esc in cordons.get("escalated") or []:
1121
+ lines.append(f" ! {esc.get('message') or esc}")
1122
+ for err in cordons.get("errors") or []:
1123
+ lines.append(f" cordon error: {err}")
1124
+
1125
+ if data.get("rolled_back"):
1126
+ lines.append(f" rolled back: {', '.join(data['rolled_back'])}")
1127
+ if data.get("released_holds"):
1128
+ lines.append(f" released deploy gates: {', '.join(data['released_holds'])}")
1129
+ if data.get("error"):
1130
+ lines.append(f" error: {data['error']}")
1131
+ return lines
1132
+
1133
+
1134
+ def render_history(records: Sequence[Mapping[str, Any]], *, verbose: bool = False) -> str:
1135
+ """The `coord release history` body.
1136
+
1137
+ Without *verbose*, consecutive no-op attempts (deferred / already
1138
+ up-to-date) collapse to one summary line — a 15-minute timer produces
1139
+ ~96 of those a day and a history nobody can skim is a history nobody
1140
+ reads. The count is always printed: #1835's "a silent success is
1141
+ indistinguishable from a silent no-op" cuts both ways, so the no-ops are
1142
+ summarised, never dropped.
1143
+ """
1144
+ if not records:
1145
+ return (
1146
+ "no propagation attempts recorded yet — if the timer is supposed "
1147
+ "to be running, that is itself the finding (see `systemctl --user "
1148
+ "status coord-release-propagate.timer`)"
1149
+ )
1150
+ lines: list[str] = []
1151
+ run: list[Mapping[str, Any]] = []
1152
+
1153
+ def _flush() -> None:
1154
+ if not run:
1155
+ return
1156
+ first, last = run[0], run[-1]
1157
+ if len(run) == 1:
1158
+ lines.extend(render_record(first))
1159
+ else:
1160
+ lines.append(
1161
+ f"· {_stamp(first.get('started_at'))} .. "
1162
+ f"{_stamp(last.get('started_at'))} "
1163
+ f"{len(run)} no-op attempt(s) "
1164
+ f"(last: {last.get('status')} — "
1165
+ f"{(last.get('quiescence') or {}).get('reason', '?')})"
1166
+ )
1167
+ run.clear()
1168
+
1169
+ for record in records:
1170
+ if not verbose and str(record.get("status")) in NO_OP_STATUSES:
1171
+ run.append(record)
1172
+ continue
1173
+ _flush()
1174
+ lines.extend(render_record(record))
1175
+ _flush()
1176
+ return "\n".join(lines)