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,1203 @@
1
+ """``coord drive-queue`` — the queue CLI and the tick processor (#1754, DQ-2).
2
+
3
+ The thin I/O shell around :mod:`coord.drive_queue`. Everything that *decides*
4
+ lives there (``plan_tick``); everything that *touches the world* lives here:
5
+ the flock, the board fetch, the DQ-1 state accessors, the ``coord drive
6
+ --tmux`` subprocess, the escalation write. Same split, for the same reason, as
7
+ ``coord/drive.py`` (pure ``decide``) and ``coord/commands/drive.py`` (thin
8
+ Click wrapper).
9
+
10
+ WHY A SEPARATE COMMAND GROUP. ``drive`` itself spends its argument positions
11
+ on ``REPO ISSUE``, which is why its ``--tmux`` companions are flat
12
+ (``drive-sessions``/``drive-attach``/``drive-stop``). The queue has a real
13
+ verb set of its own (add/list/remove/move/status/tick), so it gets a group —
14
+ ``coord drive-queue <verb>`` — rather than six more hyphenated top-level
15
+ commands.
16
+
17
+ TWO POSTURES WORTH KEEPING WHEN EDITING THIS FILE:
18
+
19
+ * **Fail closed.** An unreadable board aborts the tick without launching
20
+ anything. A transient GitHub/daemon error must never read as "nothing is
21
+ running" — that reads as free capacity and stacks drives on live work.
22
+ * **Launch out of process.** ``coord drive --tmux`` is a subprocess, never an
23
+ inline ``Driver.run()``. A drive runs 60–90 minutes; an inline one under a
24
+ ``Type=oneshot`` timer would hold the unit for hours, and the tick would
25
+ stop being a tick. ``--tmux`` already waits for a live session writing its
26
+ run log before exiting 0 (#1606), so a non-zero exit here is a genuinely
27
+ failed attempt, not an unknown.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json as _json
33
+ import socket
34
+ import subprocess
35
+ import time
36
+ from pathlib import Path
37
+ from typing import Any, Mapping
38
+
39
+ import click
40
+
41
+ from coord.commands._common import _CONFIG_OPTION
42
+ from coord.drive_queue import (
43
+ DEFAULT_MAX_ATTEMPTS,
44
+ DEFAULT_MAX_PARALLEL_PER_REPO,
45
+ HOLD_RELEASED,
46
+ QUEUE_ALERT_ISSUE,
47
+ QUEUE_ALERT_REPO,
48
+ QUEUE_ALERT_STAGE,
49
+ RESUME_PROBE_TIMEOUT_SECONDS,
50
+ STATE_BLOCKED,
51
+ STATE_PARKED,
52
+ STATE_RUNNING,
53
+ STATE_WAITING,
54
+ BoardView,
55
+ ProbeResult,
56
+ QueueEntry,
57
+ QueueError,
58
+ TickPlan,
59
+ build_board_view,
60
+ entries_from_rows,
61
+ entry_key,
62
+ fired_holds,
63
+ parse_after_spec,
64
+ parse_key,
65
+ pending_probe_targets,
66
+ plan_tick,
67
+ render_plan,
68
+ validate_enqueue,
69
+ )
70
+
71
+ # Wall-clock ceiling for the `coord drive --tmux` launch subprocess. The
72
+ # launch itself only blocks for #1606's liveness verification (16 × 0.5s) plus
73
+ # interpreter startup; this is a backstop against a wedged tmux server, not a
74
+ # budget.
75
+ _LAUNCH_TIMEOUT_SECONDS = 120.0
76
+
77
+ # Counts are rendered in pipeline order, not alphabetically, so
78
+ # `coord drive-queue status` reads as "1 running · 1 waiting". `parked`
79
+ # (#1891) sits between `waiting` and `blocked` — closer to "nothing wrong"
80
+ # than to "needs a human", but distinct from both, which is the entire point
81
+ # of the state: a held queue must not look like an idle one, see
82
+ # `coord.drive_queue.STATE_PARKED`'s docstring.
83
+ _STATE_ORDER = (
84
+ STATE_RUNNING, STATE_WAITING, STATE_PARKED, STATE_BLOCKED, "done", "failed",
85
+ )
86
+
87
+
88
+ _GROUP_HELP = """The operator-declared `coord drive` work queue (#1750).
89
+
90
+ `coord drive` drives ONE issue; nothing decided what to drive next, so an
91
+ overnight batch was a bash loop that (twice) launched on top of live work.
92
+ This is the durable, board-backed replacement: declare the order once, then
93
+ let `tick` launch at most one drive per run, first-eligible-wins, never past
94
+ the concurrency ceiling.
95
+
96
+ Run `tick` from a systemd timer (DQ-4) or by hand, on any machine that can
97
+ reach the board daemon — the board itself is fleet-global. Liveness of a
98
+ RUNNING entry is not: it is always a local `tmux` read, so a tick only ever
99
+ confirms a session it launched itself. A tick run on a different machine than
100
+ the one that launched an entry reads that entry as UNKNOWN, not dead, and
101
+ leaves it alone rather than reaping a healthy drive out from under another
102
+ host (#1870).
103
+ """
104
+
105
+
106
+ @click.group("drive-queue", help=_GROUP_HELP)
107
+ def drive_queue_group() -> None:
108
+ pass
109
+
110
+
111
+ # ── add ──────────────────────────────────────────────────────────────────────
112
+
113
+
114
+ @drive_queue_group.command("add")
115
+ @click.argument("repo")
116
+ @click.argument("issue", type=int)
117
+ @click.option("--machine", default="", help="Pin the drive to one machine (default: let `coord drive` route it).")
118
+ @click.option(
119
+ "--after",
120
+ "after_specs",
121
+ multiple=True,
122
+ default=(),
123
+ help=(
124
+ "Pre-req issues that must land first. `N` or `REPO#N`, comma-separated, "
125
+ "repeatable. Bare numbers resolve against REPO."
126
+ ),
127
+ )
128
+ @click.option(
129
+ "--position",
130
+ type=int,
131
+ default=None,
132
+ help="Insert at this 0-based slot instead of appending at the tail.",
133
+ )
134
+ @click.option(
135
+ "--hold-after",
136
+ is_flag=True,
137
+ default=False,
138
+ help=(
139
+ "Deploy gate: when this entry completes, hold the queue — launch "
140
+ "NOTHING until a human deploys and runs `drive-queue resume` (or "
141
+ "`--resume-when` starts passing). `merged` is not `live`."
142
+ ),
143
+ )
144
+ @click.option(
145
+ "--hold-reason",
146
+ default="",
147
+ help="What the operator must do while the gate is held. Shown in the alert.",
148
+ )
149
+ @click.option(
150
+ "--resume-when",
151
+ default="",
152
+ help=(
153
+ "Optional shell probe re-run each tick while the gate is held; exit 0 "
154
+ f"auto-releases it. Killed at {RESUME_PROBE_TIMEOUT_SECONDS:.0f}s and "
155
+ "treated as a failure. Requires --hold-after."
156
+ ),
157
+ )
158
+ @_CONFIG_OPTION
159
+ def drive_queue_add(
160
+ repo: str,
161
+ issue: int,
162
+ machine: str,
163
+ after_specs: tuple[str, ...],
164
+ position: int | None,
165
+ hold_after: bool,
166
+ hold_reason: str,
167
+ resume_when: str,
168
+ config_path: Path,
169
+ ) -> None:
170
+ """Queue REPO ISSUE for `coord drive`, or update it if already queued.
171
+
172
+ Validation happens BEFORE the write, the same posture `coord milestone
173
+ write-order` takes for `## Work order`: a self-edge or a dependency cycle
174
+ exits non-zero and leaves the queue exactly as it was.
175
+ """
176
+ from coord.state import enqueue_drive_queue, list_drive_queue # noqa: PLC0415
177
+
178
+ try:
179
+ after = parse_after_spec(after_specs, repo)
180
+ validate_config_repo(config_path, repo)
181
+ validate_hold_flags(hold_after, hold_reason, resume_when)
182
+ validate_enqueue(entries_from_rows(list_drive_queue()), repo, issue, after)
183
+ except QueueError as exc:
184
+ raise click.ClickException(str(exc)) from None
185
+
186
+ enqueue_drive_queue(
187
+ repo,
188
+ issue,
189
+ machine=machine or None,
190
+ after=after,
191
+ position=position,
192
+ hold_after=hold_after,
193
+ hold_reason=hold_reason,
194
+ resume_when=resume_when,
195
+ )
196
+ suffix = f" after {', '.join(after)}" if after else ""
197
+ pinned = f" on {machine}" if machine else ""
198
+ gate = ""
199
+ if hold_after:
200
+ gate = " · holds the queue when done"
201
+ if resume_when:
202
+ gate += f" (auto-resume when `{resume_when}` passes)"
203
+ click.echo(f"queued {entry_key(repo, issue)}{pinned}{suffix}{gate}")
204
+
205
+
206
+ def validate_hold_flags(hold_after: bool, hold_reason: str, resume_when: str) -> None:
207
+ """Refuse gate detail without a gate (#1757).
208
+
209
+ `--resume-when` / `--hold-reason` on an entry with no `--hold-after` would
210
+ be stored and then never read — a silent no-op on the ONE flag whose whole
211
+ job is to stop the queue. An operator who mistyped that has no signal at
212
+ all that overnight sequencing will now blow straight through the deploy
213
+ step, so this is a usage error, not a warning.
214
+ """
215
+ if hold_after:
216
+ return
217
+ offenders = [
218
+ flag
219
+ for flag, value in (("--resume-when", resume_when), ("--hold-reason", hold_reason))
220
+ if value
221
+ ]
222
+ if offenders:
223
+ raise QueueError(
224
+ f"{' and '.join(offenders)} require --hold-after "
225
+ "(without it there is no gate to resume or explain)"
226
+ )
227
+
228
+
229
+ def validate_config_repo(config_path: Path, repo: str) -> None:
230
+ """Refuse a repo coordinator.yml has never heard of.
231
+
232
+ `coord drive <repo> <issue>` would fail at preflight anyway
233
+ (``DriveStateError: repo ... is not in coordinator.yml``) — catching it at
234
+ ``add`` time turns a mysterious tick-time block into an immediate typo
235
+ report. Fail-OPEN on a config that won't load at all: a thin client whose
236
+ config cache is momentarily unreadable must still be able to queue work.
237
+ """
238
+ try:
239
+ from coord.commands._common import _load_config # noqa: PLC0415
240
+
241
+ config = _load_config(config_path)
242
+ except Exception: # noqa: BLE001 — see the fail-open note above
243
+ return
244
+ if config.repo(repo) is None:
245
+ known = ", ".join(sorted(r.name for r in config.repos)) or "(none)"
246
+ raise QueueError(f"repo {repo!r} is not in coordinator.yml (known: {known})")
247
+
248
+
249
+ # ── list ─────────────────────────────────────────────────────────────────────
250
+
251
+
252
+ @drive_queue_group.command("list")
253
+ @click.option("--repo", "repo", default=None, help="Restrict to one repo (default: every repo).")
254
+ @click.option("--json", "output_json", is_flag=True, default=False, help="Emit the raw rows as JSON.")
255
+ @_CONFIG_OPTION
256
+ def drive_queue_list(repo: str | None, output_json: bool, config_path: Path) -> None:
257
+ """Show the queue in run order."""
258
+ from coord.state import list_drive_queue # noqa: PLC0415
259
+
260
+ rows = list_drive_queue(repo)
261
+ if output_json:
262
+ click.echo(_json.dumps(rows))
263
+ return
264
+ if not rows:
265
+ click.echo("(drive queue is empty)")
266
+ return
267
+ for entry in entries_from_rows(rows):
268
+ bits = [f"{entry.position:>2} {entry.key:<28} {entry.state}"]
269
+ if entry.machine:
270
+ bits.append(f"machine={entry.machine}")
271
+ if entry.after:
272
+ bits.append(f"after={','.join(entry.after)}")
273
+ if entry.attempts:
274
+ bits.append(f"attempts={entry.attempts}")
275
+ if entry.deferrals:
276
+ bits.append(f"deferrals={entry.deferrals}")
277
+ if entry.hold_after:
278
+ bits.append(f"hold={entry.hold_state or 'armed'}")
279
+ click.echo(" ".join(bits))
280
+ if entry.last_reason:
281
+ click.echo(f" last: {entry.last_reason}")
282
+ for line in _hold_lines(entry):
283
+ click.echo(line)
284
+
285
+
286
+ def _hold_lines(entry: QueueEntry) -> list[str]:
287
+ """The gate's rendering for `list` / `status`, or `[]` when there is none.
288
+
289
+ Both verbs render through this one function so `list` and `status` can
290
+ never disagree about whether the queue is held — the failure mode that
291
+ makes an operator stop trusting either.
292
+ """
293
+ if not entry.hold_after:
294
+ return []
295
+ lines = [f" hold-after: {entry.gate_reason}"]
296
+ if entry.resume_when:
297
+ probe = f" resume-when: {entry.resume_when}"
298
+ if entry.hold_probes:
299
+ probe += f" (failed {entry.hold_probes}×)"
300
+ lines.append(probe)
301
+ return lines
302
+
303
+
304
+ # ── remove / move ────────────────────────────────────────────────────────────
305
+
306
+
307
+ @drive_queue_group.command("remove")
308
+ @click.argument("repo")
309
+ @click.argument("issue", type=int)
310
+ @_CONFIG_OPTION
311
+ def drive_queue_remove(repo: str, issue: int, config_path: Path) -> None:
312
+ """Drop REPO ISSUE from the queue (positions are renumbered dense)."""
313
+ from coord.state import dequeue_drive_queue # noqa: PLC0415
314
+
315
+ removed = dequeue_drive_queue(repo, issue)
316
+ if not removed:
317
+ raise click.ClickException(f"{entry_key(repo, issue)} is not in the drive queue")
318
+ click.echo(f"removed {entry_key(repo, issue)} from the drive queue")
319
+
320
+
321
+ @drive_queue_group.command("move")
322
+ @click.argument("repo")
323
+ @click.argument("issue", type=int)
324
+ @click.option("--to", "to_position", type=int, required=True, help="New 0-based position (clamped into range).")
325
+ @_CONFIG_OPTION
326
+ def drive_queue_move(repo: str, issue: int, to_position: int, config_path: Path) -> None:
327
+ """Move REPO ISSUE to a new position in the queue."""
328
+ from coord.state import move_drive_queue_entry # noqa: PLC0415
329
+
330
+ moved = move_drive_queue_entry(repo, issue, to_position)
331
+ if not moved:
332
+ raise click.ClickException(f"{entry_key(repo, issue)} is not in the drive queue")
333
+ click.echo(f"moved {entry_key(repo, issue)} to position {to_position}")
334
+
335
+
336
+ # ── status ───────────────────────────────────────────────────────────────────
337
+
338
+
339
+ def _counts(rows: list[Mapping[str, Any]]) -> dict[str, int]:
340
+ """State histogram for a queue read."""
341
+ counts: dict[str, int] = {}
342
+ for entry in entries_from_rows(rows):
343
+ counts[entry.state] = counts.get(entry.state, 0) + 1
344
+ return counts
345
+
346
+
347
+ def _queue_alert() -> dict | None:
348
+ """The current queue-level alert record, if a tick raised one.
349
+
350
+ Read back through the same synthetic escalation key the tick writes — see
351
+ ``QUEUE_ALERT_REPO`` in coord/drive_queue.py for why that seam and not a
352
+ synthetic ``drive_queue`` row.
353
+ """
354
+ from coord.state import get_drive_escalation # noqa: PLC0415
355
+
356
+ return get_drive_escalation(QUEUE_ALERT_REPO, QUEUE_ALERT_ISSUE)
357
+
358
+
359
+ @drive_queue_group.command("status")
360
+ @click.option("--json", "output_json", is_flag=True, default=False, help="Emit counts + alert as JSON.")
361
+ @_CONFIG_OPTION
362
+ def drive_queue_status(output_json: bool, config_path: Path) -> None:
363
+ """Queue counts by state, plus the current queue-level alert."""
364
+ from coord.state import list_drive_queue # noqa: PLC0415
365
+
366
+ rows = list_drive_queue()
367
+ counts = _counts(rows)
368
+ alert = _queue_alert()
369
+ held = fired_holds(entries_from_rows(rows))
370
+
371
+ if output_json:
372
+ click.echo(
373
+ _json.dumps(
374
+ {
375
+ "total": len(rows),
376
+ "counts": counts,
377
+ "alert": alert,
378
+ # #1757: typed, so a client (or a test) reads the gate
379
+ # without parsing the rendered sentence back out.
380
+ "held": [
381
+ {
382
+ "key": e.key,
383
+ "reason": e.gate_reason,
384
+ "resume_when": e.resume_when,
385
+ "probes": e.hold_probes,
386
+ }
387
+ for e in held
388
+ ],
389
+ }
390
+ )
391
+ )
392
+ return
393
+
394
+ if not rows:
395
+ click.echo("drive queue: empty")
396
+ else:
397
+ ordered = [s for s in _STATE_ORDER if counts.get(s)]
398
+ ordered += sorted(s for s in counts if s not in _STATE_ORDER)
399
+ click.echo(
400
+ "drive queue: " + " · ".join(f"{counts[s]} {s}" for s in ordered)
401
+ )
402
+ # The gate goes ABOVE the alert: "HELD" is the state, the alert is the
403
+ # note about it, and an operator scanning the first line must not have to
404
+ # read three more to learn the queue has stopped.
405
+ for entry in held:
406
+ click.echo(f"HELD — {entry.gate_reason}")
407
+ for line in _hold_lines(entry):
408
+ click.echo(line)
409
+ click.echo(" release with: coord drive-queue resume")
410
+ if alert is not None:
411
+ click.echo(f"alert: {alert.get('reason') or ''}")
412
+ for detail in (alert.get("gate_readings") or "").split(" | "):
413
+ if detail:
414
+ click.echo(f" {detail}")
415
+ else:
416
+ click.echo("alert: (none)")
417
+
418
+
419
+ # ── resume (#1757) ───────────────────────────────────────────────────────────
420
+
421
+
422
+ @drive_queue_group.command("resume")
423
+ @click.argument("repo", required=False)
424
+ @click.argument("issue", type=int, required=False)
425
+ @_CONFIG_OPTION
426
+ def drive_queue_resume(repo: str | None, issue: int | None, config_path: Path) -> None:
427
+ """Release a fired deploy gate so the next tick can launch again.
428
+
429
+ With no arguments this releases every held gate — in practice there is at
430
+ most one, because a held queue launches nothing and therefore cannot reach
431
+ a second one. Pass REPO ISSUE to name a specific entry.
432
+
433
+ The entry itself is NOT removed or re-run: the release is what unblocks
434
+ the queue, not the held entry leaving it, so `list` keeps its run history.
435
+ """
436
+ from coord.state import list_drive_queue, update_drive_queue_entry # noqa: PLC0415
437
+
438
+ held = fired_holds(entries_from_rows(list_drive_queue()))
439
+ if repo is not None:
440
+ if issue is None:
441
+ raise click.ClickException("give both REPO and ISSUE, or neither")
442
+ wanted = entry_key(repo, issue)
443
+ held = [e for e in held if e.key == wanted]
444
+ if not held:
445
+ raise click.ClickException(
446
+ f"{wanted} has no fired deploy gate to release "
447
+ "(see `coord drive-queue status`)"
448
+ )
449
+ if not held:
450
+ # Exit non-zero: "resume" on a queue that was never held is an
451
+ # operator misreading the board, and a silent success would confirm
452
+ # the misreading.
453
+ raise click.ClickException("no deploy gate is currently held")
454
+
455
+ for entry in held:
456
+ update_drive_queue_entry(
457
+ entry.repo, entry.issue, hold_state=HOLD_RELEASED, hold_probes=0
458
+ )
459
+ click.echo(f"released the deploy gate on {entry.key}")
460
+ _clear_queue_alert()
461
+ click.echo("the next tick will launch the next eligible entry")
462
+
463
+
464
+ def _clear_queue_alert() -> None:
465
+ """Drop the queue-level HELD alert once its gate is released.
466
+
467
+ Best-effort: the next tick overwrites (or re-raises) this record anyway,
468
+ but leaving a stale "QUEUE HELD" sitting in `status` between the release
469
+ and the next timer fire is exactly the kind of contradiction that trains
470
+ an operator to stop reading alerts.
471
+ """
472
+ try:
473
+ from coord.state import dismiss_drive_escalation # noqa: PLC0415
474
+
475
+ dismiss_drive_escalation(QUEUE_ALERT_REPO, QUEUE_ALERT_ISSUE)
476
+ except Exception: # noqa: BLE001 — cosmetic; never fail a release on it
477
+ pass
478
+
479
+
480
+ # ── tick ─────────────────────────────────────────────────────────────────────
481
+
482
+
483
+ def _local_issue_rows() -> list[dict]:
484
+ """``issues`` rows straight from the local DB (daemon-host path only).
485
+
486
+ ``BoardFetcher`` builds the standalone payload with
487
+ ``coord.client.serialize_board``, which ships assignment rows and
488
+ ``round_number`` and nothing else — no ``issues`` key at all. The daemon's
489
+ own ``GET /board`` (``coord.dao.board_projection``) DOES carry one, so a
490
+ thin client already sees issue open/closed state and the daemon host would
491
+ not. Without this top-up a pre-req that is simply open-and-undispatched
492
+ would look "unknown to the board" on the exact machine a systemd timer runs
493
+ the tick on, and get blocked instead of deferred.
494
+
495
+ Fail-soft: an unreadable/absent table degrades to ``[]``, which puts the
496
+ daemon host back on the assignment-only signals rather than aborting.
497
+ """
498
+ from coord.db import get_connection # noqa: PLC0415
499
+
500
+ try:
501
+ rows = get_connection().execute(
502
+ "SELECT repo_name, number, state FROM issues"
503
+ ).fetchall()
504
+ except Exception: # noqa: BLE001 — see the fail-soft note above
505
+ return []
506
+ return [dict(r) for r in rows]
507
+
508
+
509
+ def _local_merge_queue_rows() -> list[dict]:
510
+ """``merge_queue`` rows straight from the local DB (daemon-host path only).
511
+
512
+ Same gap as :func:`_local_issue_rows`, one table over: the standalone
513
+ ``coord.client.serialize_board`` payload ships assignment rows and
514
+ ``round_number`` only — no ``merge_queue`` (and no ``merge_plan``, which
515
+ is computed, not stored) — so on the daemon host itself (no
516
+ ``board_service`` configured, the tick reads the local DB directly)
517
+ :func:`build_board_view`'s ``merge_ci_pending`` fact (#1891) would never
518
+ see a checks-still-pending entry at all. ``merge_plan`` is deliberately
519
+ NOT backfilled here — it needs a live ``config``/``ci_store`` to compute,
520
+ and ``build_board_view`` already falls back to this raw table's ``error``
521
+ column when the plan section is absent, exactly the fallback
522
+ ``drive_state._merge_entry`` uses for the same gap.
523
+
524
+ Fail-soft: an unreadable table degrades to ``[]``, same posture as
525
+ :func:`_local_issue_rows`.
526
+ """
527
+ from coord.db import get_connection # noqa: PLC0415
528
+
529
+ try:
530
+ rows = get_connection().execute(
531
+ "SELECT repo_name, issue_number, error FROM merge_queue"
532
+ ).fetchall()
533
+ except Exception: # noqa: BLE001 — see the fail-soft note above
534
+ return []
535
+ return [dict(r) for r in rows]
536
+
537
+
538
+ def _local_host_id() -> str:
539
+ """This machine's identity for #1870's launch-host / reconcile matching.
540
+
541
+ Same normalisation every other host-locality check in this codebase uses
542
+ (``coord/commands/sessions.py``, ``coord/commands/_common.py``,
543
+ ``coord.interactive._get_local_short_hostname``): the short hostname,
544
+ lowercased, domain suffix dropped — so a machine addressed as
545
+ ``dellserver`` in one config and ``dellserver.local`` by DNS still
546
+ compares equal to itself.
547
+ """
548
+ return socket.gethostname().split(".")[0].lower()
549
+
550
+
551
+ def _fetch_cordons() -> dict[str, str]:
552
+ """``{machine: "cordoned: draining for v0.5.31"}`` (#2101).
553
+
554
+ Daemon-aware (`coord.machine_pause.cordons()` routes to `GET /pause` on a
555
+ thin client) and fail-SOFT: an unreadable cordon store degrades to "no
556
+ cordons", the same posture `paused_set()` takes on every dispatch decision
557
+ in this codebase. That is the right trade here even though the board fetch
558
+ above fails CLOSED: a missed cordon costs one drive launched into a host
559
+ about to roll (which `coord agent update` then refuses, leaving the entry
560
+ to retry), whereas failing the whole tick closed on a cordon read would
561
+ stop the queue on a network blip — the outage this issue exists to end,
562
+ reintroduced by its own fix.
563
+ """
564
+ from coord.machine_pause import cordons as fetch_cordons # noqa: PLC0415
565
+
566
+ try:
567
+ return {name: record.describe() for name, record in fetch_cordons().items()}
568
+ except Exception as exc: # noqa: BLE001 — see docstring
569
+ click.echo(f"warning: could not read release cordons ({exc}) — "
570
+ "treating the fleet as uncordoned", err=True)
571
+ return {}
572
+
573
+
574
+ def _fetch_board_view() -> BoardView:
575
+ """Board + live drive sessions, typed.
576
+
577
+ Raises whatever the fetch raised — the caller turns that into a fail-closed
578
+ abort. ``list_drive_sessions()`` is deliberately NOT allowed to fail the
579
+ tick: it returns ``[]`` when tmux is unavailable, and the board's
580
+ ``active_work`` signal still holds the capacity line in that case.
581
+ """
582
+ from coord.board_service import resolve as resolve_board_service # noqa: PLC0415
583
+ from coord.drive import list_drive_sessions # noqa: PLC0415
584
+ from coord.drive_state import BoardFetcher # noqa: PLC0415
585
+
586
+ payload = BoardFetcher().fetch()
587
+ if not isinstance(payload, dict):
588
+ raise ValueError(f"board payload is not an object: {type(payload).__name__}")
589
+ # Standalone shape top-up, gated per-key rather than on "issues" alone
590
+ # (#2040: BoardFetcher's own standalone path now supplies "issues" —
591
+ # see coord.drive_state.BoardFetcher._fetch_local — so a single combined
592
+ # gate on that key would silently stop topping up "merge_queue" too,
593
+ # regressing #1891's merge_ci_pending signal on the daemon host). Each
594
+ # top-up is independently gated on board_service being unset so a thin
595
+ # client never reads its own local DB — both keys are always present in
596
+ # the daemon's HTTP projection, even when empty.
597
+ if resolve_board_service() is None:
598
+ top_up: dict = {}
599
+ if "issues" not in payload:
600
+ top_up["issues"] = _local_issue_rows()
601
+ if "merge_queue" not in payload:
602
+ # #1891: same top-up, one table over — see _local_merge_queue_rows.
603
+ top_up["merge_queue"] = _local_merge_queue_rows()
604
+ if top_up:
605
+ payload = {**payload, **top_up}
606
+ return build_board_view(payload, list_drive_sessions())
607
+
608
+
609
+ def _fetch_exit_reasons(
610
+ entries: list,
611
+ ) -> tuple[dict[str, str], dict[str, bool], dict[str, bool]]:
612
+ """The drive's own ``drive_exited`` summary — and whether it was
613
+ PERMANENT (a pre-dispatch refusal, or a dead end) — for every ``running``
614
+ entry THIS launch, keyed by entry key (#1845/#1844/#2019).
615
+
616
+ ``coord.drive.Driver.run`` already writes the true reason a run stopped —
617
+ a deliberate refusal narrated in full, not just an exit code — to the
618
+ audit trail before it returns. Nothing downstream used to read it, so
619
+ `_reconcile_running`'s "no session, no active work, nothing landed" death
620
+ classifier (which also matches a clean, deliberate exit) always
621
+ overwrote it with a synthesised "drive session died" reason. This is the
622
+ one DB read the shell does to close that gap; `plan_tick`/
623
+ `_reconcile_running` stay pure and just consume the result as data (like
624
+ *probes*).
625
+
626
+ Returns ``(reasons, refused, dead_end)``. *reasons* is the summary text,
627
+ same as before #1844. *refused* is ``True`` for a key whose recorded
628
+ ``details.exit_code`` equals ``coord.drive.EXIT_DISPATCH_REFUSED`` — a
629
+ DETERMINISTIC pre-dispatch guard refusal (#1138's oracle-readiness gate,
630
+ #1314's epic-target gate, or any other check `coord assign`/`coord
631
+ approve-plan`/`coord fix` raises a plain ``ValueError`` for) rather than
632
+ a transient crash. `_reconcile_running` uses this second mapping to skip
633
+ straight to `blocked` without spending an attempt — see its ``refused``
634
+ branch.
635
+
636
+ *dead_end* (#2019) is the same signal for ``coord.drive.EXIT_DEAD_END``:
637
+ the drive's dead-end predicate found the row terminal and unactionable
638
+ and exited on the first poll rather than counting ``no state change``
639
+ against an event that cannot happen. Same disposition as *refused*
640
+ (`blocked`, no attempt spent), separate mapping so the blocked entry's
641
+ ``last_reason`` names the real cause instead of claiming a pre-dispatch
642
+ guard refused something.
643
+
644
+ Scoped with ``since=entry.launched_at`` so a stale reason (or refusal)
645
+ from a PRIOR attempt on the same (repo, issue) — the entry's key doesn't
646
+ change across a retry — is never replayed as if it explained the run
647
+ that just ended. An entry with no `launched_at` (a row from before this
648
+ launch was stamped) is skipped; the caller's fallback wording covers it.
649
+
650
+ Fail-soft per entry: an unreadable audit table degrades to "no reason
651
+ known for this entry", never aborts the tick — same posture as
652
+ :func:`_local_issue_rows`.
653
+ """
654
+ from coord.audit import query_audit_log # noqa: PLC0415
655
+ from coord.drive import EXIT_DEAD_END, EXIT_DISPATCH_REFUSED # noqa: PLC0415
656
+
657
+ reasons: dict[str, str] = {}
658
+ refused: dict[str, bool] = {}
659
+ dead_end: dict[str, bool] = {}
660
+ for e in entries:
661
+ if e.state != STATE_RUNNING or e.launched_at is None:
662
+ continue
663
+ try:
664
+ result = query_audit_log(
665
+ event_type="drive_exited",
666
+ repo=e.repo,
667
+ issue=e.issue,
668
+ since=e.launched_at,
669
+ limit=1,
670
+ )
671
+ except Exception: # noqa: BLE001 — see the fail-soft note above
672
+ continue
673
+ rows = result.get("entries") or []
674
+ if not rows:
675
+ continue
676
+ row = rows[0]
677
+ summary = row.get("summary")
678
+ if summary:
679
+ reasons[e.key] = str(summary)
680
+ details = row.get("details") or {}
681
+ if details.get("exit_code") == EXIT_DISPATCH_REFUSED:
682
+ refused[e.key] = True
683
+ elif details.get("exit_code") == EXIT_DEAD_END:
684
+ dead_end[e.key] = True
685
+ return reasons, refused, dead_end
686
+
687
+
688
+ def _fetch_gate_a_pending(entries: list) -> dict[str, bool]:
689
+ """``{entry_key: still_waiting_on_a_human}`` for every entry parked on a
690
+ missing Gate-A sign-off (#2063).
691
+
692
+ The ``(repo_name, milestone_number)`` pair comes out of the park
693
+ reason's own marker (:func:`coord.gate_a.parse_park_marker`), so this is
694
+ a pure board read — no ``gh`` call, and no work at all on a tick where
695
+ nothing is parked that way.
696
+
697
+ Fails **closed**: an entry whose verdict can't be read stays parked. The
698
+ cost of being wrong in that direction is a parked row an operator can
699
+ see and release; the cost of being wrong the other way is dispatching
700
+ work against a contract nobody approved, which is the whole issue.
701
+ """
702
+ from coord.gate_a import approval_fingerprint, parse_park_marker # noqa: PLC0415
703
+
704
+ pending: dict[str, bool] = {}
705
+ #: ``None`` marks "the board could not be read for this milestone".
706
+ cache: dict[tuple[str, int], str | None] = {}
707
+ for e in entries:
708
+ if e.state != STATE_PARKED:
709
+ continue
710
+ parsed = parse_park_marker(getattr(e, "last_reason", "") or "")
711
+ if parsed is None:
712
+ continue
713
+ repo_name, milestone_number, parked_fingerprint = parsed
714
+ key = (repo_name, milestone_number)
715
+ if key not in cache:
716
+ try:
717
+ from coord.state import get_gate_a_approval # noqa: PLC0415
718
+
719
+ raw = get_gate_a_approval(
720
+ repo_name=repo_name, milestone_number=milestone_number
721
+ )
722
+ cache[key] = approval_fingerprint(raw)
723
+ except Exception: # noqa: BLE001 — fail closed, stay parked
724
+ cache[key] = None
725
+ if cache[key] is None:
726
+ pending[e.key] = True
727
+ continue
728
+ # Resume when the stored verdict has CHANGED since the park — not
729
+ # merely when one exists. A `--changes` verdict refuses too, so
730
+ # "exists" would resume, relaunch, refuse and re-park every tick
731
+ # forever; "changed" bounds it at one relaunch per operator action.
732
+ # The guard itself re-derives the real answer (including the
733
+ # contract-SHA freshness check) on that relaunch, so this predicate
734
+ # deliberately does not duplicate it.
735
+ pending[e.key] = cache[key] == parked_fingerprint
736
+ return pending
737
+
738
+
739
+ def _launch_argv(entry: QueueEntry, config_path: Path | None) -> list[str]:
740
+ """The ``coord drive --tmux`` argv for *entry*.
741
+
742
+ #1809: this is the argv the tick actually spawns as a subprocess (below,
743
+ in the caller). When ``coord_argv()``'s PATH-less fallback was silently
744
+ broken (no ``__main__`` guard on ``coord/cli.py``), that subprocess
745
+ exited 0 having imported the module and run nothing — BEFORE ever
746
+ reaching ``launch_drive_in_tmux``'s #1606 alive/log-growth verification.
747
+ From the tick's side that is indistinguishable from a real launch that
748
+ passed verification: both are "subprocess exited 0". That fully explains
749
+ a launch reported as a success banner while its tmux session had already
750
+ died — no separate bug in this module's returncode handling or in
751
+ ``launch_drive_in_tmux``'s growth check (both were re-verified against
752
+ the #1809 investigation and are correct: a non-zero exit is never
753
+ counted as running — see ``test_a_failed_launch_is_a_consumed_attempt_
754
+ not_a_running_entry`` — and the growth check does register an
755
+ absent-before-launch log file that then gets written to, per
756
+ ``test_session_dies_immediately_raises_instead_of_reporting_success``).
757
+ Fixing the ``__main__`` guard closes this path too, since it is the same
758
+ fallback the driver's own ``coord assign`` calls go through.
759
+ """
760
+ from coord.drive import coord_argv # noqa: PLC0415
761
+
762
+ argv = coord_argv() + ["drive", entry.repo, str(entry.issue), "--tmux"]
763
+ if entry.machine:
764
+ argv += ["--machine", entry.machine]
765
+ if config_path:
766
+ argv += ["--config", str(config_path)]
767
+ return argv
768
+
769
+
770
+ def _run_resume_probe(entry: QueueEntry) -> ProbeResult:
771
+ """Run one entry's ``--resume-when`` probe with a hard timeout.
772
+
773
+ TRUST BOUNDARY — READ THIS BEFORE TOUCHING THE FUNCTION.
774
+ ``resume_when`` is a SHELL command, executed by the tick, as the tick's
775
+ user, on the daemon host. That is deliberate, and it is acceptable for
776
+ exactly one reason: the string is **operator-authored and
777
+ operator-scoped**, the same trust level as the ``ExecStart=`` line of the
778
+ systemd timer unit that invokes this tick in the first place. It is:
779
+
780
+ * NOT sent to a worker and never executed on a worker machine;
781
+ * NOT derived from an issue body, a PR, a review comment, a plan, or any
782
+ other model output;
783
+ * NOT reachable by anything an agent writes — ``coord drive-queue add`` is
784
+ the only writer of this column, and DQ-1's update whitelist
785
+ (``coord.state._DRIVE_QUEUE_UPDATABLE``) deliberately excludes it, so
786
+ not even the tick can rewrite its own probe.
787
+
788
+ If any of those three ever stops being true, this is remote code execution
789
+ on the daemon host and the feature must be redesigned — not patched.
790
+
791
+ Fails CLOSED: a non-zero exit, a timeout, or a command that could not be
792
+ spawned at all all keep the gate held. A gate that releases because its
793
+ probe crashed is a gate that never existed.
794
+ """
795
+ import os # noqa: PLC0415
796
+ import signal # noqa: PLC0415
797
+
798
+ try:
799
+ proc = subprocess.Popen( # noqa: S602 — operator-authored; see the trust note
800
+ entry.resume_when,
801
+ shell=True,
802
+ stdout=subprocess.PIPE,
803
+ stderr=subprocess.STDOUT,
804
+ text=True,
805
+ # Its own process GROUP, so a timeout can kill the whole tree.
806
+ # `sh -c 'a | b'` leaves children that outlive the shell; killing
807
+ # only the shell would leave a wedged probe holding the pipe and
808
+ # the tick blocked in communicate() — a tick that stops ticking is
809
+ # indistinguishable from a queue with nothing to do (#1616).
810
+ start_new_session=True,
811
+ )
812
+ except (OSError, ValueError) as exc:
813
+ return ProbeResult(entry.key, False, f"could not run the probe: {exc}")
814
+
815
+ try:
816
+ out, _ = proc.communicate(timeout=RESUME_PROBE_TIMEOUT_SECONDS)
817
+ except subprocess.TimeoutExpired:
818
+ try:
819
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
820
+ except OSError:
821
+ proc.kill()
822
+ try:
823
+ proc.communicate(timeout=2.0)
824
+ except (subprocess.SubprocessError, OSError):
825
+ pass
826
+ return ProbeResult(
827
+ entry.key,
828
+ False,
829
+ f"timed out after {RESUME_PROBE_TIMEOUT_SECONDS:.0f}s (killed)",
830
+ )
831
+
832
+ tail = (out or "").strip().splitlines()
833
+ detail = f"exit {proc.returncode}"
834
+ if proc.returncode != 0 and tail:
835
+ detail += f": {tail[-1][:160]}"
836
+ return ProbeResult(entry.key, proc.returncode == 0, detail)
837
+
838
+
839
+ def _apply_writes(plan: TickPlan) -> None:
840
+ from coord.state import update_drive_queue_entry # noqa: PLC0415
841
+
842
+ for key, updates in plan.writes():
843
+ parsed = parse_key(key)
844
+ if parsed is None:
845
+ continue
846
+ update_drive_queue_entry(parsed[0], parsed[1], **updates)
847
+
848
+
849
+ def _escalate(repo: str, issue: int, *, reason: str, gates: str, command: str) -> None:
850
+ from coord.state import record_drive_escalation # noqa: PLC0415
851
+
852
+ record_drive_escalation(
853
+ repo,
854
+ issue,
855
+ stage=QUEUE_ALERT_STAGE,
856
+ reason=reason,
857
+ gate_readings=gates,
858
+ proposed_command=command,
859
+ )
860
+
861
+
862
+ def _requeue_command(entry: QueueEntry | None, key: str) -> str:
863
+ """The one-key fix for a blocked entry: drop it and re-add it clean.
864
+
865
+ There is deliberately no ``coord drive-queue reset`` — DQ-1's update
866
+ whitelist keeps run state out of the operator's write surface, so
867
+ remove+add IS the reset (a fresh row is ``waiting`` with ``attempts=0``
868
+ and no ``after``). Re-adding without the bad ``--after`` is also the fix
869
+ for an unsatisfiable pre-req.
870
+ """
871
+ parsed = parse_key(key)
872
+ if parsed is None:
873
+ return "coord drive-queue list"
874
+ repo, issue = parsed
875
+ tail = f" --machine {entry.machine}" if entry is not None and entry.machine else ""
876
+ return (
877
+ f"coord drive-queue remove {repo} {issue} && "
878
+ f"coord drive-queue add {repo} {issue}{tail}"
879
+ )
880
+
881
+
882
+ @drive_queue_group.command("tick")
883
+ @click.option(
884
+ "--max-parallel",
885
+ type=int,
886
+ default=1,
887
+ show_default=True,
888
+ help=(
889
+ "Concurrency ceiling. Capacity is counted from BOARD state, not from a "
890
+ "session count, so a drive whose observer hit its deadline (#1660) "
891
+ "still occupies a slot."
892
+ ),
893
+ )
894
+ @click.option(
895
+ "--max-parallel-per-repo",
896
+ type=int,
897
+ default=DEFAULT_MAX_PARALLEL_PER_REPO,
898
+ show_default=True,
899
+ help=(
900
+ "Per-repo concurrency ceiling, applied after --max-parallel (#1972). "
901
+ "An entry whose repo is already at it DEFERS — position unchanged, no "
902
+ "attempt spent — so the walk lands on the first entry from a repo with "
903
+ "headroom: per-repo serialisation, cross-repo parallelism. 0 disables "
904
+ "it, restoring one global counter."
905
+ ),
906
+ )
907
+ @click.option(
908
+ "--dry-run",
909
+ is_flag=True,
910
+ default=False,
911
+ help="Print the resolved plan and mutate nothing.",
912
+ )
913
+ @click.option(
914
+ "--reconcile-only",
915
+ is_flag=True,
916
+ default=False,
917
+ help=(
918
+ "Update the queue's view of reality and launch nothing (#2110). "
919
+ "Every `running` entry is still checked against the board "
920
+ "(done/blocked/parked/retry, exactly as a normal tick would), but no "
921
+ "new `coord drive` is ever started this run — equivalent to "
922
+ "`--max-parallel 0`. This is the missing primitive for the "
923
+ "stop-the-timer-to-roll-the-fleet sequence: with the timer stopped, "
924
+ "nothing reconciles a finished drive's `running` row, and that stale "
925
+ "row alone can pin `coord release propagate` indefinitely. Run this "
926
+ "once (by hand, timer still stopped) to drain it before propagating, "
927
+ "then restart the timer."
928
+ ),
929
+ )
930
+ @_CONFIG_OPTION
931
+ def drive_queue_tick(
932
+ max_parallel: int,
933
+ max_parallel_per_repo: int,
934
+ dry_run: bool,
935
+ reconcile_only: bool,
936
+ config_path: Path,
937
+ ) -> None:
938
+ """Drain one step of the queue: reconcile, then launch at most one drive.
939
+
940
+ Safe to run on any interval and from any machine that can reach the board
941
+ daemon. A tick already in progress makes this a quiet no-op (exit 0) — a
942
+ slow tick must never stack, and two ticks seconds apart are safe: a drive
943
+ launched inside the startup grace window reconciles as `starting`
944
+ (occupying a slot, attempts untouched) rather than as a death (#1794).
945
+ Two ticks on DIFFERENT machines are also safe: liveness is always a local
946
+ `tmux` read, so a tick reconciles only the entries it itself launched —
947
+ one launched elsewhere reads as `unknown`, occupying its slot but never
948
+ retried or relaunched, rather than being declared dead out from under the
949
+ host actually running it (#1870).
950
+
951
+ Capacity has two ceilings (#1972): the global `--max-parallel`, then
952
+ `--max-parallel-per-repo` (default 1). Entries whose repo is already at the
953
+ per-repo ceiling defer, so a tick with a claude-coordinator drive running
954
+ skips the 38 queued claude-coordinator entries behind it and launches the
955
+ quadraui one — per-repo serialisation, cross-repo parallelism. `--dry-run`
956
+ prints the per-repo breakdown so "why didn't item 2 go?" is answerable from
957
+ the output alone.
958
+
959
+ `--max-parallel 0` (or `--reconcile-only`, the readable spelling of the
960
+ same thing — #2110) reconciles every `running` entry against the board and
961
+ then stops: no capacity walk, no deferrals, no queue-level alert, no
962
+ launch. Reconciliation (`plan_tick` step 1/1b: a finished entry moves to
963
+ `done`, a permanently-refused one to `blocked`, a CI-pending one parks) is
964
+ unconditional and runs regardless of capacity, which is what makes this
965
+ safe to run with the periodic timer stopped — see
966
+ `docs/AGENT_OPERATIONS.md`'s propagation section for why that combination
967
+ used to deadlock.
968
+ """
969
+ from coord.filelock import FileLock, LockBusy, drive_queue_lock_path # noqa: PLC0415
970
+ from coord.state import list_drive_queue, update_drive_queue_entry # noqa: PLC0415
971
+
972
+ if max_parallel < 0:
973
+ raise click.ClickException(
974
+ "--max-parallel must be at least 0 (0 = reconcile-only, launch "
975
+ "nothing this run — see --reconcile-only)"
976
+ )
977
+ if max_parallel_per_repo < 0:
978
+ raise click.ClickException(
979
+ "--max-parallel-per-repo must be 0 (no per-repo ceiling) or more"
980
+ )
981
+
982
+ # #2110: `--reconcile-only` and `--max-parallel 0` are the same request —
983
+ # one flag is a mnemonic for the other rather than a second code path, so
984
+ # there is exactly one way this behaves, not two that could drift apart.
985
+ reconcile_only = reconcile_only or max_parallel == 0
986
+ effective_capacity = 0 if reconcile_only else max_parallel
987
+
988
+ lock = FileLock(drive_queue_lock_path())
989
+ try:
990
+ lock.acquire(timeout=0.0)
991
+ except LockBusy:
992
+ # Quiet by design: this is the normal outcome when a timer fires while
993
+ # the previous tick is still verifying a launch. Noise here would
994
+ # train the operator to ignore the log.
995
+ click.echo("another drive-queue tick is running — skipping")
996
+ return
997
+ except OSError as exc:
998
+ raise click.ClickException(f"could not take the drive-queue lock: {exc}") from None
999
+
1000
+ try:
1001
+ # FAIL CLOSED. An unreadable board is not "nothing is running"; it is
1002
+ # "we do not know what is running", and launching on that assumption is
1003
+ # how a sequential batch becomes concurrent on the fleet.
1004
+ try:
1005
+ board = _fetch_board_view()
1006
+ except Exception as exc: # noqa: BLE001 — every fetch failure is fatal here
1007
+ raise click.ClickException(
1008
+ f"could not read the board — aborting without launching anything: {exc}"
1009
+ ) from None
1010
+
1011
+ entries = entries_from_rows(list_drive_queue())
1012
+
1013
+ # #1757: run each held gate's `--resume-when` BEFORE deciding
1014
+ # anything, and hand the results to `plan_tick` as data so the
1015
+ # decision half stays pure. Deliberately skipped under `--dry-run`:
1016
+ # the probe is an arbitrary operator-authored shell command and
1017
+ # `--dry-run` promises to touch nothing. The consequence — a dry run
1018
+ # reports the gate as still held even if the deploy just landed — is
1019
+ # stated in the output rather than left for the operator to discover.
1020
+ probes: dict[str, ProbeResult] = {}
1021
+ pending = pending_probe_targets(entries)
1022
+ if pending and dry_run:
1023
+ click.echo(
1024
+ f"(--dry-run: not running {len(pending)} --resume-when probe(s); "
1025
+ "a held gate below may already be releasable)"
1026
+ )
1027
+ elif not dry_run:
1028
+ for target in pending:
1029
+ probes[target.key] = _run_resume_probe(target)
1030
+
1031
+ # #1845/#1844: the drive's own `drive_exited` summary — and, when it
1032
+ # was a PERMANENT pre-dispatch guard refusal, that fact too — for
1033
+ # each `running` entry, when one was recorded for THIS launch. Read
1034
+ # here (the shell) and handed to `plan_tick` as data, same as
1035
+ # `probes`, so a "no session, no active work, nothing landed"
1036
+ # reconcile can report the drive's real reason instead of a
1037
+ # synthesised "drive session died" for an exit that was actually
1038
+ # deliberate, and — when it was a deterministic refusal — block
1039
+ # immediately instead of spending an attempt on a guaranteed-to-fail
1040
+ # retry. #2019 adds a second permanent cause with the same handling:
1041
+ # a drive that exited on its own dead-end predicate.
1042
+ exit_reasons, exit_refused, exit_dead_end = _fetch_exit_reasons(entries)
1043
+
1044
+ # #2063: for every entry parked on a missing Gate-A human sign-off,
1045
+ # re-read the recorded verdict so `plan_tick` can un-park it the tick
1046
+ # after the operator approves. Board-only (the (repo, milestone) pair
1047
+ # is embedded in the park reason's marker), so this costs nothing per
1048
+ # tick when no entry is parked that way — which is the common case.
1049
+ gate_a_pending = _fetch_gate_a_pending(entries)
1050
+
1051
+ # #2101: release cordons. THIS is the hole the issue names — the
1052
+ # queue's launcher had zero pause awareness (`coord/drive.py` checks
1053
+ # pause only when routing a *worker*), so a cordoned host kept getting
1054
+ # drive sessions launched on it and the fleet could never drain into
1055
+ # a rollable state.
1056
+ cordons = _fetch_cordons()
1057
+
1058
+ # #1794: the clock is the shell's to read, never `coord.drive_queue`'s.
1059
+ # It powers the startup grace window on both sides of the tick — a
1060
+ # drive launched seconds ago is `starting`, not dead, and cannot be
1061
+ # relaunched — so a tick that fires immediately after another (which
1062
+ # `docs/DRIVE_QUEUE.md` §2's install sequence reliably produces) sees
1063
+ # a running entry rather than a phantom death.
1064
+ #
1065
+ # #1870: same posture for the machine's own identity. Liveness is a
1066
+ # LOCAL tmux read; without `local_host` a tick on host B would read a
1067
+ # healthy drive launched on host A as dead the instant it fell out of
1068
+ # #1794's grace window, and reap it.
1069
+ plan = plan_tick(
1070
+ entries,
1071
+ board,
1072
+ effective_capacity,
1073
+ max_parallel_per_repo=max_parallel_per_repo,
1074
+ probes=probes,
1075
+ now=time.time(),
1076
+ local_host=_local_host_id(),
1077
+ exit_reasons=exit_reasons,
1078
+ exit_refused=exit_refused,
1079
+ exit_dead_end=exit_dead_end,
1080
+ gate_a_pending=gate_a_pending,
1081
+ cordons=cordons,
1082
+ )
1083
+
1084
+ if reconcile_only:
1085
+ # #2110: capacity 0 already makes `plan_tick` return before its
1086
+ # capacity walk (no deferrals, no queue-level alert, no launch —
1087
+ # see its docstring's step 3), so `plan.launch` is guaranteed
1088
+ # `None` below without any extra branching here. This line exists
1089
+ # purely so the log reads as an intentional reconcile-only run
1090
+ # rather than a queue that mysteriously stopped launching.
1091
+ click.echo("(--reconcile-only: updating queue state, launching nothing)")
1092
+
1093
+ for line in render_plan(plan, dry_run=dry_run):
1094
+ click.echo(line)
1095
+ if dry_run:
1096
+ return
1097
+
1098
+ _apply_writes(plan)
1099
+
1100
+ by_key = {e.key: e for e in entries}
1101
+ for item in plan.blocked:
1102
+ parsed = parse_key(item.key)
1103
+ if parsed is None:
1104
+ continue
1105
+ entry = by_key.get(item.key)
1106
+ _escalate(
1107
+ parsed[0],
1108
+ parsed[1],
1109
+ reason=item.reason,
1110
+ gates=(
1111
+ f"queue_state=blocked | position="
1112
+ f"{entry.position if entry else '?'} | after="
1113
+ f"{','.join(entry.after) if entry and entry.after else '(none)'}"
1114
+ ),
1115
+ command=_requeue_command(entry, item.key),
1116
+ )
1117
+
1118
+ if plan.alert is not None:
1119
+ _escalate(
1120
+ QUEUE_ALERT_REPO,
1121
+ QUEUE_ALERT_ISSUE,
1122
+ reason=plan.alert.reason,
1123
+ gates=" | ".join(plan.alert.details),
1124
+ command=plan.alert.command,
1125
+ )
1126
+ elif any(h.outcome == "released" for h in plan.holds):
1127
+ # A probe just auto-released the gate. Drop the "QUEUE HELD"
1128
+ # record in the same tick, or `status` keeps shouting HELD while
1129
+ # the queue is demonstrably running again — the contradiction that
1130
+ # teaches an operator to stop reading alerts.
1131
+ _clear_queue_alert()
1132
+
1133
+ target = plan.launch
1134
+ if target is None:
1135
+ return
1136
+
1137
+ argv = _launch_argv(target, config_path)
1138
+ try:
1139
+ result = subprocess.run( # noqa: S603 — argv built from coord_argv + typed row
1140
+ argv,
1141
+ capture_output=True,
1142
+ text=True,
1143
+ timeout=_LAUNCH_TIMEOUT_SECONDS,
1144
+ )
1145
+ returncode = result.returncode
1146
+ detail = (result.stderr or result.stdout or "").strip().splitlines()
1147
+ message = detail[-1] if detail else ""
1148
+ except (subprocess.SubprocessError, OSError) as exc:
1149
+ returncode, message = 1, str(exc)
1150
+
1151
+ if returncode == 0:
1152
+ from coord.drive import drive_session_name # noqa: PLC0415
1153
+
1154
+ session = drive_session_name(target.repo, target.issue)
1155
+ update_drive_queue_entry(
1156
+ target.repo,
1157
+ target.issue,
1158
+ state=STATE_RUNNING,
1159
+ session_name=session,
1160
+ launched_at=time.time(),
1161
+ last_reason="",
1162
+ # #1870: stamp THIS host as the launcher so a later tick —
1163
+ # possibly on a different machine — knows whose tmux to trust.
1164
+ launch_host=_local_host_id(),
1165
+ )
1166
+ click.echo(f"launched {target.key} in tmux session {session!r}")
1167
+ return
1168
+
1169
+ # #1606: `--tmux` only exits 0 once the session is live and writing its
1170
+ # run log, so a non-zero exit means nothing is running — record a
1171
+ # consumed attempt, never a running entry.
1172
+ attempts = target.attempts + 1
1173
+ reason = (
1174
+ f"launch failed (exit {returncode}): {message}"
1175
+ if message
1176
+ else f"launch failed (exit {returncode})"
1177
+ )
1178
+ if attempts < DEFAULT_MAX_ATTEMPTS:
1179
+ update_drive_queue_entry(
1180
+ target.repo,
1181
+ target.issue,
1182
+ state=STATE_WAITING,
1183
+ attempts=attempts,
1184
+ last_reason=reason,
1185
+ )
1186
+ else:
1187
+ update_drive_queue_entry(
1188
+ target.repo,
1189
+ target.issue,
1190
+ state=STATE_BLOCKED,
1191
+ attempts=attempts,
1192
+ last_reason=reason,
1193
+ )
1194
+ _escalate(
1195
+ target.repo,
1196
+ target.issue,
1197
+ reason=reason,
1198
+ gates=f"queue_state=blocked | attempts={attempts}",
1199
+ command=_requeue_command(target, target.key),
1200
+ )
1201
+ raise click.ClickException(reason)
1202
+ finally:
1203
+ lock.release()