code-coordinator 0.5.46__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. coord/worker_events.py +954 -0
coord/dead_end.py ADDED
@@ -0,0 +1,332 @@
1
+ """The driver's dead-end predicate (#2019).
2
+
3
+ ``coord drive`` could not tell **"still working"** apart from **"finished in
4
+ a state I cannot act on."** Both rendered as ``no state change in N m`` and
5
+ the second one looped forever — a counter incrementing against an event that
6
+ can never occur, holding a tmux session, a queue slot and (since #1972) an
7
+ entire repo's capacity lane. The live case this module is named for burned
8
+ **140 minutes** on claude-coordinator#1956 with ``review=done/-`` on screen
9
+ the whole time.
10
+
11
+ WHAT THIS MODULE IS
12
+ -------------------
13
+ One pure function, :func:`detect_dead_end`, over the driver's already-computed
14
+ :class:`coord.drive_state.IssueState`. It answers a single question:
15
+
16
+ Is this row **terminal AND unactionable** — i.e. is there no board
17
+ transition left that any amount of polling could produce?
18
+
19
+ It is **not** a stall detector. Per #2019 ask 4, elapsed time is deliberately
20
+ NOT an input: a healthy long-running Work stage is legitimately quiet for
21
+ hours, and a dead end is knowable at minute zero. The only clock-shaped
22
+ guard here is the hard precondition ``active_count == 0`` — if anything at all
23
+ is running on the fleet, this function returns ``None``, however long it has
24
+ been running. That guard is what makes a false positive on a healthy stage
25
+ structurally impossible rather than merely unlikely.
26
+
27
+ WHY THE 140 MINUTES HAPPENED (the field-mismatch bug)
28
+ -----------------------------------------------------
29
+ ``coord.drive._decide_review`` has had a ``_die`` for "review finished with no
30
+ verdict" since the original bash port — but it keys on
31
+ ``state.work_review_state``, the **work** row's projected ``review_state``.
32
+ The incident's board showed ``review=done/-``, which is
33
+ ``state.review_status`` — the **review assignment's own** status. On #1956
34
+ the review row reached ``status="done"`` while the work row's ``review_state``
35
+ was never advanced (advancing it is exactly what recording the verdict does),
36
+ so the die never fired and ``_decide_review`` fell through to a bare
37
+ ``_wait()``. Two readings of "the review is done", one checked, one not.
38
+ :data:`_TERMINAL_REVIEW_STATUSES` closes that by keying on the review row.
39
+
40
+ WHAT IS DELIBERATELY *NOT* COVERED
41
+ ----------------------------------
42
+ * **A Test stage that was never dispatched, with no policy saying so**
43
+ (``test_state=""``, no smoke row, no ``test-mode:*`` label) — from board
44
+ state alone that is indistinguishable from "the daemon will dispatch it on
45
+ the next tick", and the rest of the dispatcher's refusal set
46
+ (``coord.smoke.dispatch_smoke`` returns ``None`` for a superseded row, a
47
+ capability-rule miss on a non-``work`` type, a missing smoke command, …) is
48
+ not re-derivable here without duplicating it. Guessing would escalate
49
+ healthy rows. Two variants ARE covered, both because something on the board
50
+ positively states that no dispatch is coming: ``test_state="blocked"``, the
51
+ marker the dispatcher leaves when it gives up (#1672, shape 2), and
52
+ ``test-mode:smoke``, the per-issue POLICY that switches the headless Test
53
+ stage off entirely (#685/#2024, shape 3).
54
+ * **A ``done`` smoke row whose verdict has not landed yet** — #1605 already
55
+ established that this has an expected, bounded propagation lag and is not a
56
+ defect. Only the contradictions #1605 itself detects are terminal.
57
+ * **A zero-commit advisory / a refused pre-dispatch guard** — already
58
+ escalated correctly by ``coord.drive._decide_advisory``, the "finished with
59
+ no branch" die, and #1844's ``EXIT_DISPATCH_REFUSED``. vimcode#634 proved
60
+ that path works; this module deliberately does not touch it.
61
+
62
+ The registry below is meant to grow one *proven* shape at a time. A shape
63
+ belongs here only when the board state alone makes the row unactionable — not
64
+ when it merely looks quiet.
65
+ """
66
+
67
+ from __future__ import annotations
68
+
69
+ from dataclasses import dataclass
70
+
71
+ # A review assignment that reached one of these is FINISHED — no worker is
72
+ # coming back to add a verdict. `"failed"` is deliberately absent: #1584
73
+ # owns it with a bounded `coord review` re-dispatch, and a dead-end verdict
74
+ # there would steal a retry that genuinely can succeed.
75
+ _TERMINAL_REVIEW_STATUSES = frozenset({"done", "cancelled"})
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class DeadEnd:
80
+ """One terminal-and-unactionable board shape, fully described.
81
+
82
+ ``reason`` names the SPECIFIC dead end (#2019 ask 3) — "review reached
83
+ done with no verdict" rather than "no state change in 140.558m" — and
84
+ ``recovery`` is the command an operator can paste. Both end up on the
85
+ ``coord escalate record`` row, in the drive-queue entry's ``last_reason``,
86
+ and in the GitHub escalation comment, so all three surfaces say the same
87
+ thing.
88
+ """
89
+
90
+ kind: str
91
+ """Stable slug for the shape, e.g. ``review_terminal_no_verdict``."""
92
+
93
+ stage: str
94
+ """Pipeline stage the dead end sits in — ``coord escalate record --stage``."""
95
+
96
+ reason: str
97
+ """Why no poll can change this. One paragraph, operator-facing."""
98
+
99
+ recovery: str
100
+ """The command that unblocks it, ready to paste."""
101
+
102
+ assignment_id: str = ""
103
+ """The row an operator should look at first (``""`` when unknown)."""
104
+
105
+ gates: tuple[tuple[str, str], ...] = ()
106
+ """Observed gate readings, ``(key, value)`` — mirrors ``_escalate_merge``."""
107
+
108
+
109
+ def _gates(state) -> tuple[tuple[str, str], ...]:
110
+ """The board readings worth recording on any dead end.
111
+
112
+ Same shape as ``coord.drive._escalate_merge``'s ``gate_pairs`` so the two
113
+ escalation kinds render identically in ``coord escalate list``.
114
+ """
115
+ return (
116
+ ("work_status", state.work_status or "(empty)"),
117
+ ("test_state", state.work_test_state or "(none)"),
118
+ # #2024: the per-issue Test-stage POLICY is a reading in its own right
119
+ # — for shape 3 it is the entire proof, and on every other shape it is
120
+ # the difference between "no verdict yet" and "no verdict, ever".
121
+ ("issue_test_mode", state.issue_test_mode or "(none)"),
122
+ ("review_status", state.review_status or "(none)"),
123
+ ("review_verdict", state.review_verdict or "(none)"),
124
+ ("active", str(state.active_count)),
125
+ )
126
+
127
+
128
+ def detect_dead_end(state, *, can_waive_test_gate: bool = False) -> DeadEnd | None:
129
+ """The predicate: ``None`` when the row can still move on its own.
130
+
131
+ *state* is a :class:`coord.drive_state.IssueState` (untyped here purely to
132
+ keep this module import-light — it reads a handful of attributes and
133
+ nothing else).
134
+
135
+ *can_waive_test_gate* (#2024) is the caller saying "I still have a Test-
136
+ stage move of my own" — today that is ``coord drive --skip-test``, whose
137
+ ``_decide_test`` records ``skipped`` for a verdict-less row. Shape 3 below
138
+ is exactly the shape that flag exists to unblock, so with it set that shape
139
+ is not a dead end: the driver must be allowed to use the move the operator
140
+ explicitly asked for rather than escalate past it.
141
+
142
+ Pure, cheap, and safe to call on every poll: no I/O, no clock, no
143
+ counters. The caller (``coord.drive.decide``) turns a non-``None`` result
144
+ into an ``EXIT`` action carrying ``coord.drive.EXIT_DEAD_END``.
145
+ """
146
+ # #1672's "the fleet cannot route this Test stage" marker. IMPORTED (not
147
+ # mirrored as a literal) so the two can never drift: `dispatch_smoke`
148
+ # bails out on this exact value, and its refusal to re-probe is precisely
149
+ # why a driver polling against it can never make progress. Deferred to
150
+ # call time purely to keep THIS module importable on its own without
151
+ # dragging in `coord.smoke`'s `httpx` dependency — `coord.drive` already
152
+ # pulls httpx in via `coord.usage_limits`, so nothing is saved there, but
153
+ # a predicate this small should stay cheap to import and test in
154
+ # isolation.
155
+ from coord.smoke import TEST_STATE_BLOCKED # noqa: PLC0415
156
+ # THE conservative guard, and it stays first. #2019 acceptance: "a
157
+ # genuinely long-running work stage (active=1) does NOT escalate, however
158
+ # long it runs." Anything in flight — work, test, review, a fix round,
159
+ # the acceptance author — makes every question below premature.
160
+ if state.active_count > 0:
161
+ return None
162
+
163
+ # ── shape 1: the review row is terminal and carries no verdict ──────────
164
+ # claude-coordinator#1956, 2026-08-08. The driver can neither dispatch a
165
+ # fix (there is no `request-changes` to fix) nor proceed to merge (there
166
+ # is no `approve` to merge on), and the row that would have supplied
167
+ # either is finished. Nothing about waiting changes that.
168
+ if (
169
+ state.review_aid
170
+ and state.review_status in _TERMINAL_REVIEW_STATUSES
171
+ and not state.review_verdict
172
+ # Belt-and-braces: an actionable failed test outranks a stale review
173
+ # on the same work row — let `_decide_test`'s bounded fix loop have
174
+ # it rather than dead-ending a row that still has a live move.
175
+ and state.work_test_state != "failed"
176
+ ):
177
+ if state.review_status == "cancelled":
178
+ return DeadEnd(
179
+ kind="review_cancelled_no_verdict",
180
+ stage="review",
181
+ reason=(
182
+ f"review {state.review_aid} was CANCELLED before recording "
183
+ "a verdict — no verdict is coming, and this driver has "
184
+ "nothing to approve, fix, or merge on. Terminal on the "
185
+ "board; polling cannot change it (#2019)."
186
+ ),
187
+ recovery=f"coord review {state.work_aid}",
188
+ assignment_id=state.review_aid,
189
+ gates=_gates(state),
190
+ )
191
+ # status == "done": the session finalised CLEANLY. Saying so matters
192
+ # — #2019 acceptance requires this text to distinguish an
193
+ # END_REVIEW-without-verdict from a crashed session, and to stop
194
+ # citing the CLOSED #812 (which was about INTERACTIVE reviews that
195
+ # never started) at a headless review that ran to completion. A
196
+ # review worker that actually died lands `status="failed"`, which is
197
+ # `_decide_review`'s #1584 bounded-retry arm, not this one.
198
+ #
199
+ # #812 is deliberately absent from the text below — even as a
200
+ # disclaimer. An operator who greps the reason for an issue number
201
+ # to open must never land on a closed one, and "not #812" reads as
202
+ # "#812" to every mechanical reader (and to a tired one at 2am). The
203
+ # correction lives here, in the source, where it belongs.
204
+ return DeadEnd(
205
+ kind="review_terminal_no_verdict",
206
+ stage="review",
207
+ reason=(
208
+ f"review {state.review_aid} reached status=done carrying NO "
209
+ "verdict. The review session finalised cleanly — a crashed, "
210
+ "killed or never-started session lands status='failed' (which "
211
+ "this driver retries), so this is the END_REVIEW-without-"
212
+ "verdict class: the reviewer finished and its REVIEW_VERDICT "
213
+ "header was never emitted or never parsed (#1956, "
214
+ "coord.review.detect_end_review_without_verdict). The verdict "
215
+ "is very likely already sitting in the transcript. With no "
216
+ "verdict there is no fix to dispatch and no approval to merge "
217
+ "on, and the review row is terminal — no number of polls "
218
+ "changes that (#2019)."
219
+ ),
220
+ # Straight out of docs/OPERATING_GOTCHAS.md's "Recovery — do NOT
221
+ # re-dispatch" block: re-running the review costs a full cycle to
222
+ # re-derive a conclusion already in the log, and the drop
223
+ # reproduces at a documented ~14% rate (#873).
224
+ recovery=(
225
+ f"coord report-result --assignment {state.review_aid} "
226
+ "--status done --verdict <approve|request-changes> "
227
+ "--verdict-source recovered --verdict-reason 'REVIEW_VERDICT "
228
+ "header missing, recovered from transcript (#1956)' "
229
+ "--body-file <extracted-review.md>"
230
+ ),
231
+ assignment_id=state.review_aid,
232
+ gates=_gates(state),
233
+ )
234
+
235
+ # ── shape 2: the Test stage is BLOCKED (#1672) ─────────────────────────
236
+ # `dispatch_smoke` stamps `test_state="blocked"` when no capability-
237
+ # matched machine could run the suite, and then REFUSES to re-probe on
238
+ # every tick — deliberately, that spin is what #1672 closed. So the
239
+ # driver polling for a verdict is polling for something the dispatcher
240
+ # has already decided not to produce. vimcode#635's shape, in the one
241
+ # variant the board makes provable.
242
+ if state.work_test_state == TEST_STATE_BLOCKED:
243
+ return DeadEnd(
244
+ kind="test_stage_blocked",
245
+ stage="test",
246
+ reason=(
247
+ f"the Test stage for {state.work_aid} is BLOCKED: "
248
+ f"{state.work_test_reason or 'no reason recorded'} — "
249
+ "coord.smoke.dispatch_smoke found no capability-matched "
250
+ "machine and recorded 'blocked' rather than re-probing a "
251
+ "broken fleet on every tick (#1672). It will not try again "
252
+ "on its own, so waiting for a verdict here waits forever "
253
+ "(#2019). Fix the fleet (or the capability rules), then "
254
+ "clear the marker."
255
+ ),
256
+ recovery=(
257
+ f"coord diagnose {state.repo} {state.issue} --stage test --reset"
258
+ ),
259
+ assignment_id=state.work_aid,
260
+ gates=_gates(state),
261
+ )
262
+
263
+ # ── shape 3: the Test stage is HUMAN-ATTENDED by policy (#685/#2024) ────
264
+ # `test-mode:smoke` means "the headless Test stage does not run for this
265
+ # issue; the TUI offers an interactive smoke agent instead" —
266
+ # `dispatch_pending_smoke` skips the row unconditionally, on every tick,
267
+ # forever. Review dispatch is meanwhile gated on a passed/skipped verdict
268
+ # for THIS work row (`pipeline.test_precedes_review()`, honoured by both
269
+ # `dispatch_pending_reviews` and `auto_loop.run_for_fix_transition`), so a
270
+ # completed row with no verdict of its own cannot advance: one component
271
+ # requires a verdict and, by policy, no component will produce one.
272
+ #
273
+ # Why this bites `--fix-of` rounds specifically (#2024): a fix round is a
274
+ # NEW work row on the SAME branch, and it carries its own empty
275
+ # `test_state`. The parent's verdict satisfies the branch-scoped MERGE
276
+ # gate (`coord gates` reads `test: passed` off it — see the note
277
+ # `coord.gates.build_gate_report` now emits), which is why the stall reads
278
+ # as "slow" rather than "blocked". Round 0 gets attended because a human is
279
+ # watching the first pass; rounds 1..N complete unattended at 3am and sit
280
+ # there. Observed twice on JDonaghy/vimcode#635 (2026-08-08): 25 minutes,
281
+ # then 160 minutes on the same issue, each cleared within minutes of an
282
+ # operator running `coord test <fix_aid> --passed` by hand.
283
+ #
284
+ # Deliberately narrow, so it can never fire on a healthy row:
285
+ # * `active_count == 0` (above) — an interactive smoke session IS a live
286
+ # board row, so an attended Test stage in progress never reaches here.
287
+ # * `smoke_aid` empty — a Test-stage child dispatched for THIS row (by a
288
+ # human, `--smoke-of`, or an earlier auto pass) means the stage did
289
+ # happen; #1605 owns a `done` smoke whose verdict hasn't landed yet.
290
+ # * `work_test_state` empty — not "running" (#1395's transient marker,
291
+ # `_decide_test` waits on it), not a terminal verdict, not "blocked"
292
+ # (shape 2 above owns that, and reads better).
293
+ if (
294
+ not can_waive_test_gate
295
+ and state.issue_test_mode == "smoke"
296
+ and state.work_status == "done"
297
+ and state.work_aid
298
+ and not state.work_test_state
299
+ and not state.smoke_aid
300
+ ):
301
+ return DeadEnd(
302
+ kind="test_stage_human_attended",
303
+ stage="test",
304
+ reason=(
305
+ f"work {state.work_aid} finished with NO Test verdict, and "
306
+ f"{state.repo}#{state.issue} is labelled `test-mode:smoke` — "
307
+ "the per-issue policy (#685) that switches the HEADLESS Test "
308
+ "stage off: coord.smoke.dispatch_pending_smoke skips this "
309
+ "issue on every tick by design, so no smoke assignment is "
310
+ "coming. Review dispatch is meanwhile held until THIS row "
311
+ "carries a passed/skipped verdict "
312
+ "(pipeline.test_precedes_review), so nothing can advance "
313
+ "without a human. This is the #2024 shape: a --fix-of round "
314
+ "is a new work row with its own empty test_state, and the "
315
+ "parent's verdict satisfies only the branch-scoped merge gate "
316
+ "(`coord gates` reads `test: passed` off the parent row) — "
317
+ "which is why this reads as slow rather than blocked (#2019)."
318
+ ),
319
+ # Two doors, cheapest first: record the verdict directly, or run
320
+ # the attended Test stage the label asked for. Both are real; the
321
+ # operator picks by whether the suite actually needs running.
322
+ recovery=(
323
+ f"coord test {state.work_aid} --passed # or --skipped "
324
+ "--reason '<why>'; to actually run it, `coord assign "
325
+ f"<machine> {state.repo} {state.issue} --smoke-of "
326
+ f"{state.work_aid} --interactive`"
327
+ ),
328
+ assignment_id=state.work_aid,
329
+ gates=_gates(state),
330
+ )
331
+
332
+ return None
coord/deploy/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # `coord/deploy/` — the packaged systemd units (#1927)
2
+
3
+ **This directory is a byte-identical copy of the repo-root `deploy/` unit
4
+ files, and it is the copy that ships in the wheel.**
5
+
6
+ Edit `deploy/<name>` at the repo root — that is the reviewed source of
7
+ truth — then copy it here:
8
+
9
+ ```
10
+ cp deploy/*.service deploy/*.timer coord/deploy/
11
+ ```
12
+
13
+ `tests/test_packaged_deploy_units.py` fails if the two ever disagree, so the
14
+ copy cannot drift silently.
15
+
16
+ ## Why the copy exists
17
+
18
+ `coord release verify`'s unit-drift check
19
+ (`coord/health/checks/unit_drift.py`, #1831) diffs each host's *installed*
20
+ unit under `~/.config/systemd/user/` against a reference. Until #1927 that
21
+ reference was `<checkout>/deploy/<name>` — a file in the host's own git
22
+ working copy, which nothing verifies is current.
23
+
24
+ Installed units and checkouts go stale for the same reason (nobody pulled),
25
+ so they go stale *together*, and the diff then reports clean — the check was
26
+ least reliable in exactly the #1831 case it exists to catch, and its printed
27
+ remedy (`cp <checkout>/deploy/... ~/.config/systemd/user/...`) cemented the
28
+ stale unit.
29
+
30
+ Files under `coord/` ship in the wheel, so on a pip-installed host this
31
+ directory is the unit set *as of the installed version*. It cannot drift
32
+ with the host. The `*.sh` helpers in the repo-root `deploy/` are not copied
33
+ here: the drift check only reads `*.service`/`*.timer`.
@@ -0,0 +1,89 @@
1
+ # coord agent — per-machine worker/dispatch server (#1570/#1671).
2
+ #
3
+ # A systemd *user* unit — the FIRST of the four coord services (every
4
+ # worker box runs this one; coord-serve/coord-web/coord-notify run only on
5
+ # the box that owns ~/.coord/coord.db). Runs `coord agent`, which accepts
6
+ # dispatched assignments, spawns `claude -p` workers, and answers /health
7
+ # with this machine's resolved tool versions (coord/prereqs.py) so
8
+ # `coord doctor` can see capability drift without SSHing in.
9
+ #
10
+ # `install-agent.sh` generates a unit like this one inline (heredoc) as
11
+ # part of the curl|bash installer flow and stays the SOURCE OF TRUTH for a
12
+ # fresh install — keep the two in sync by hand. This checked-in copy
13
+ # exists so:
14
+ # (a) a manual install / install from a checkout has something to `cp`
15
+ # (mirrors coord-serve/coord-web/coord-notify below, all `cp`-installed
16
+ # from this same directory), and
17
+ # (b) the PATH fix below is documented in the repo instead of living only
18
+ # inside a shell heredoc nobody reads until #1671 bites again.
19
+ #
20
+ # #1671: PATH MUST include ~/.cargo/bin (and ~/.local/bin). A systemd user
21
+ # unit's default PATH is minimal — it omits anything rustup/pipx/etc.
22
+ # installed under $HOME. Without this, the `rust` capability probe
23
+ # (coord/prereqs.py, literal `cargo --version`) reads "not found" even
24
+ # though cargo IS installed, and #1570 D's dispatch guard then refuses to
25
+ # route ANY tui/** work to this machine forever — a silent 30s retry loop,
26
+ # no smoke row at all (see #1671).
27
+ #
28
+ # Do NOT "fix" this by symlinking just the `cargo` binary onto the
29
+ # existing PATH instead of adding the whole directory. `cargo build` also
30
+ # shells out to `rustc`, which lives in the same `~/.cargo/bin` — a probe
31
+ # that resolves `cargo` alone reports the capability MET while
32
+ # `cargo build` still fails inside the worker. That is a false green,
33
+ # strictly worse than the honest refusal this issue started from. Also
34
+ # note #402: a worker's PATH is derived from *this agent's own* PATH
35
+ # (venv stripped) — so probe-side and worker-side resolution move
36
+ # together only if the fix is "widen the agent's PATH here," not "make
37
+ # the probe search harder while the worker still can't find the tool."
38
+ #
39
+ # #1678: the `browser` capability (Playwright acceptance suites, e.g.
40
+ # `coord/dashboard/webapp` -> `npm run test:e2e`) needs `node`/`npm` here
41
+ # too, but Node must NOT be added the way ~/.cargo/bin was. nvm installs
42
+ # into a version-stamped directory (~/.nvm/versions/node/vX.Y.Z/bin), so a
43
+ # literal entry on the PATH line below would break silently at the next
44
+ # `nvm install` and put the capability right back where #1678 found it.
45
+ # Instead `install-agent.sh` drops run-time-resolving shims (node/npm/npx ->
46
+ # coord-node-shim, see deploy/node-shim.sh) into ~/.local/bin, which is
47
+ # ALREADY on the PATH line below — so nothing here changes and a Node
48
+ # version bump is a no-op. Keep ~/.local/bin on the PATH for that reason.
49
+ #
50
+ # Placeholders to fill in before installing (install-agent.sh fills the
51
+ # equivalents in automatically from its --machine/--port flags):
52
+ # <MACHINE_NAME> — this machine's name in coordinator.yml (often the
53
+ # short hostname; `hostname -s`)
54
+ # <PORT> — 7433 unless this box intentionally runs more than
55
+ # one agent
56
+ #
57
+ # Install (manual / from a checkout — normally use install-agent.sh
58
+ # instead, which does not need this file):
59
+ # mkdir -p ~/.config/systemd/user
60
+ # sed -e "s/<MACHINE_NAME>/$(hostname -s)/" -e 's/<PORT>/7433/' \
61
+ # deploy/coord-agent.service > ~/.config/systemd/user/coord-agent.service
62
+ # loginctl enable-linger "$USER" # survive logout / reboot
63
+ # systemctl --user daemon-reload
64
+ # systemctl --user enable --now coord-agent
65
+ #
66
+ # Already have a coord-agent unit without this PATH entry (e.g. installed
67
+ # before #1671)? A systemd drop-in is the least-disruptive fix — no need
68
+ # to replace the whole unit:
69
+ # mkdir -p ~/.config/systemd/user/coord-agent.service.d
70
+ # printf '[Service]\nEnvironment=PATH=%%h/.coord-venv/bin:%%h/.cargo/bin:%%h/.local/bin:/usr/local/bin:/usr/bin:/bin\n' \
71
+ # > ~/.config/systemd/user/coord-agent.service.d/path.conf
72
+ # systemctl --user daemon-reload
73
+ # systemctl --user restart coord-agent
74
+ #
75
+ # See docs/AGENT_OPERATIONS.md → "Install a new agent (first time)".
76
+
77
+ [Unit]
78
+ Description=Coordinator agent server (port <PORT>)
79
+ After=network-online.target
80
+
81
+ [Service]
82
+ Type=simple
83
+ ExecStart=%h/.coord-venv/bin/coord agent --machine <MACHINE_NAME> --port <PORT>
84
+ Restart=on-failure
85
+ RestartSec=5
86
+ Environment=PATH=%h/.coord-venv/bin:%h/.cargo/bin:%h/.local/bin:/usr/local/bin:/usr/bin:/bin
87
+
88
+ [Install]
89
+ WantedBy=default.target
@@ -0,0 +1,60 @@
1
+ # coord-db-backup — hourly coord.db snapshot to the external SSD, interim
2
+ # stopgap pending #1822 (continuous backup + verified restore, tier:large,
3
+ # unstarted), paired with coord-db-backup.timer.
4
+ #
5
+ # A systemd *user* unit (mirrors coord-serve/coord-web/coord-notify/
6
+ # coord-drive-queue), Type=oneshot, timer-fired. Runs
7
+ # ~/.local/bin/coord-db-backup.sh (deploy/coord-db-backup.sh), which:
8
+ # - refuses to run unless the destination directory's parent is an actual
9
+ # mountpoint (default /media/crucial) — the external SSD must be
10
+ # physically present, or it would otherwise write "backups" onto the
11
+ # very disk (the daemon host's root filesystem) it exists to protect
12
+ # against,
13
+ # - takes a live, WAL-safe snapshot via `sqlite3 ... VACUUM INTO`, never
14
+ # `cp` (a plain copy of a WAL-mode db under concurrent writes can capture
15
+ # a torn file — the failure you only discover at restore),
16
+ # - `PRAGMA integrity_check`s the snapshot and sanity-checks it has a
17
+ # non-empty `assignments` table before it counts; either failure keeps
18
+ # the bad file on disk renamed `.REJECTED` rather than deleting it or
19
+ # leaving it indistinguishable from a good backup,
20
+ # - prunes snapshots beyond `COORD_BACKUP_RETAIN` (default 168 ≈ 7 days
21
+ # hourly), never touching `.REJECTED` files.
22
+ #
23
+ # Install ONLY on the box that owns ~/.coord/coord.db (dellserver in
24
+ # production — the same host that runs coord-serve/coord-web/coord-notify/
25
+ # coord-drive-queue).
26
+ #
27
+ # NOT a substitute for off-box backup: this protects against db corruption,
28
+ # a bad migration, accidental deletion and OS-disk failure. It does NOT
29
+ # protect against the machine itself being lost, stolen or burned — see
30
+ # #1822, which owns the real (continuous + verified-restore + off-box)
31
+ # answer. This interim unit is deliberately small in scope.
32
+ #
33
+ # Install:
34
+ # mkdir -p ~/.local/bin ~/.config/systemd/user
35
+ # cp deploy/coord-db-backup.sh ~/.local/bin/ && chmod +x ~/.local/bin/coord-db-backup.sh
36
+ # cp deploy/coord-db-backup.service deploy/coord-db-backup.timer ~/.config/systemd/user/
37
+ # loginctl enable-linger "$USER" # survive logout / reboot
38
+ # systemctl --user daemon-reload
39
+ # systemctl --user enable --now coord-db-backup.timer
40
+ #
41
+ # This is a Type=oneshot service activated BY THE TIMER — do not
42
+ # `systemctl --user enable coord-db-backup.service` directly (it has no
43
+ # [Install] section; the timer unit is what you enable).
44
+ #
45
+ # See docs/AGENT_OPERATIONS.md → "coord.db backups to the external SSD".
46
+
47
+ [Unit]
48
+ Description=Snapshot coord.db to the external SSD (interim, pending #1822)
49
+ Documentation=https://github.com/JDonaghy/claude-coordinator/issues/1822
50
+
51
+ [Service]
52
+ Type=oneshot
53
+ ExecStart=%h/.local/bin/coord-db-backup.sh
54
+ # `VACUUM INTO` on a live, busy coord.db can stall under lock contention.
55
+ # Generous relative to a normal run (a snapshot this size takes seconds),
56
+ # well below the timer's hourly cadence (coord-db-backup.timer) so a
57
+ # genuinely wedged run can't run into the next fire indefinitely — same
58
+ # pattern as every other oneshot unit in this directory (see
59
+ # coord-drive-queue.service, coord-web-dist-build.service).
60
+ TimeoutStartSec=600
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env bash
2
+ # Interim coord.db snapshot to the external SSD (pending #1822).
3
+ #
4
+ # VACUUM INTO, not cp: it takes a consistent snapshot of a live SQLite database
5
+ # while coord-serve keeps writing. A plain cp of a WAL-mode db under load can
6
+ # capture a torn file, which is the failure mode you only discover at restore.
7
+ #
8
+ # Every snapshot is integrity-checked and sanity-checked (assignments table
9
+ # present and non-empty) before it is allowed to count, and any failed check
10
+ # leaves the snapshot on disk named .REJECTED so it can be looked at rather
11
+ # than silently deleted OR silently left lying around under its normal name,
12
+ # indistinguishable from a good backup.
13
+ #
14
+ # NOT a substitute for off-box backup: this protects against db corruption, a
15
+ # bad migration, accidental deletion and OS-disk failure. It does NOT protect
16
+ # against the machine being lost, stolen or burned. See #1822.
17
+ set -uo pipefail
18
+
19
+ SRC="${COORD_DB:-$HOME/.coord/coord.db}"
20
+ DEST_DIR="${COORD_BACKUP_DIR:-/media/crucial/coord-backups}"
21
+ RETAIN="${COORD_BACKUP_RETAIN:-168}" # hourly x 7 days
22
+ STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
23
+ OUT="$DEST_DIR/coord.db.$STAMP"
24
+
25
+ fail() { echo "coord-db-backup: FAILED: $*" >&2; exit 1; }
26
+
27
+ [ -f "$SRC" ] || fail "source db not found: $SRC"
28
+
29
+ # The mount must actually be a mount. If the SSD is unplugged, /media/crucial
30
+ # is still a directory on the root filesystem, and we would cheerfully write
31
+ # "backups" onto the very disk we are protecting against.
32
+ mountpoint -q "$(dirname "$DEST_DIR")" || fail "$(dirname "$DEST_DIR") is not a mountpoint — external SSD not mounted"
33
+
34
+ mkdir -p "$DEST_DIR" || fail "cannot create $DEST_DIR"
35
+
36
+ sqlite3 "$SRC" "VACUUM INTO '$OUT';" || fail "VACUUM INTO failed"
37
+
38
+ CHECK="$(sqlite3 "$OUT" 'PRAGMA integrity_check;' 2>&1)"
39
+ if [ "$CHECK" != "ok" ]; then
40
+ mv "$OUT" "$OUT.REJECTED"
41
+ fail "integrity_check on snapshot: $CHECK (kept as $OUT.REJECTED)"
42
+ fi
43
+
44
+ # Prove it is a coord db and not an empty file that passed integrity_check.
45
+ # Same "keep it as .REJECTED, never silently delete or silently leave it
46
+ # lying around under the normal name" contract as the integrity_check
47
+ # rejection above -- an unlinked, unmarked snapshot sitting next to good
48
+ # ones is exactly the kind of thing retention pruning would eventually
49
+ # rotate out indistinguishably from a real backup.
50
+ ROWS="$(sqlite3 "$OUT" 'SELECT COUNT(*) FROM assignments;' 2>&1)"
51
+ RC=$?
52
+ if [ "$RC" -ne 0 ]; then
53
+ mv "$OUT" "$OUT.REJECTED"
54
+ fail "snapshot has no assignments table: $ROWS (kept as $OUT.REJECTED)"
55
+ fi
56
+ case "$ROWS" in
57
+ ''|*[!0-9]*)
58
+ mv "$OUT" "$OUT.REJECTED"
59
+ fail "unexpected assignments count: $ROWS (kept as $OUT.REJECTED)"
60
+ ;;
61
+ esac
62
+ if [ "$ROWS" -eq 0 ]; then
63
+ mv "$OUT" "$OUT.REJECTED"
64
+ fail "snapshot has 0 assignments — refusing to count this as a backup (kept as $OUT.REJECTED)"
65
+ fi
66
+
67
+ ln -sfn "$OUT" "$DEST_DIR/coord.db.latest"
68
+
69
+ # Prune oldest beyond RETAIN. Never touches .REJECTED files.
70
+ mapfile -t OLD < <(ls -1 "$DEST_DIR"/coord.db.2* 2>/dev/null | grep -v '\.REJECTED$' | sort | head -n -"$RETAIN")
71
+ for f in "${OLD[@]:-}"; do [ -n "$f" ] && rm -f "$f"; done
72
+
73
+ SIZE="$(du -h "$OUT" | cut -f1)"
74
+ echo "coord-db-backup: ok $OUT ($SIZE, ${ROWS} assignments, $(ls -1 "$DEST_DIR"/coord.db.2* 2>/dev/null | grep -vc '\.REJECTED$') snapshots retained)"
@@ -0,0 +1,18 @@
1
+ # Fires coord-db-backup.service hourly. See that unit's header for the full
2
+ # rationale, install steps and safety guarantees.
3
+ #
4
+ # Persistent=true: if the daemon host was down/rebooted when a fire was
5
+ # missed, the next boot catches it up immediately rather than waiting up to
6
+ # an hour for the next scheduled tick.
7
+
8
+ [Unit]
9
+ Description=Hourly coord.db snapshot to the external SSD (interim, pending #1822)
10
+
11
+ [Timer]
12
+ OnBootSec=5min
13
+ OnUnitActiveSec=1h
14
+ AccuracySec=5min
15
+ Persistent=true
16
+
17
+ [Install]
18
+ WantedBy=timers.target