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/db.py ADDED
@@ -0,0 +1,1076 @@
1
+ """SQLite connection management and schema for coordinator state.
2
+
3
+ Single DB at ~/.coord/coord.db with WAL mode. All coordinator state lives
4
+ here: assignments, proposals, merge queue, sessions, etc.
5
+
6
+ Usage
7
+ -----
8
+ - Production code: ``get_connection()`` returns the singleton connection.
9
+ - Tests: call ``override_connection(sqlite3.connect(":memory:"))`` then
10
+ ``close()`` in teardown (the ``coord_db`` fixture in conftest.py does this).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import sqlite3
18
+ from pathlib import Path
19
+
20
+ from coord.platform_paths import default_coord_dir
21
+
22
+ COORD_DIR = default_coord_dir()
23
+ DB_PATH = COORD_DIR / "coord.db"
24
+
25
+ _conn: sqlite3.Connection | None = None
26
+
27
+
28
+ class ProductionDatabaseGuardError(RuntimeError):
29
+ """Raised when code running under pytest tries to open the live
30
+ ``~/.coord/coord.db`` (#1960).
31
+
32
+ The autouse ``coord_db`` fixture in ``tests/conftest.py`` overrides the
33
+ module-level singleton with an isolated ``:memory:`` connection before
34
+ every test body runs, so ``get_connection()`` normally never reaches
35
+ ``_open()`` at all during a test. This guard exists for the paths that
36
+ fixture can't reach: code that calls ``_open(DB_PATH)`` directly, a
37
+ subprocess the test spawned that re-imports ``coord.db`` fresh (pytest's
38
+ ``PYTEST_CURRENT_TEST`` env var is inherited by child processes by
39
+ default), or a test that closes the override and lets the singleton fall
40
+ back to the real path.
41
+ """
42
+
43
+
44
+ def get_connection() -> sqlite3.Connection:
45
+ """Return the module-level singleton connection, opening it on first call."""
46
+ global _conn
47
+ if _conn is None:
48
+ _conn = _open(DB_PATH)
49
+ return _conn
50
+
51
+
52
+ def _open(path: Path) -> sqlite3.Connection:
53
+ if path == DB_PATH and os.environ.get("PYTEST_CURRENT_TEST"):
54
+ raise ProductionDatabaseGuardError(
55
+ f"Refusing to open the production coordinator database at "
56
+ f"{path} while running under pytest "
57
+ f"(PYTEST_CURRENT_TEST={os.environ['PYTEST_CURRENT_TEST']!r}). "
58
+ "A test (or a subprocess it spawned) resolved the real "
59
+ "~/.coord/coord.db instead of an isolated one -- see #1960. "
60
+ "Fix: rely on the autouse `coord_db` fixture (already active for "
61
+ "every test and overrides this singleton with an in-memory DB), "
62
+ "or pass an explicit isolated path instead of coord.db.DB_PATH."
63
+ )
64
+ path.parent.mkdir(parents=True, exist_ok=True)
65
+ conn = sqlite3.connect(str(path), check_same_thread=False)
66
+ conn.execute("PRAGMA journal_mode=WAL")
67
+ conn.execute("PRAGMA busy_timeout=5000")
68
+ conn.execute("PRAGMA foreign_keys=ON")
69
+ conn.row_factory = sqlite3.Row
70
+ _ensure_schema(conn)
71
+ _maybe_migrate_json(conn)
72
+ _migrate_gate_order(conn)
73
+ _backfill_orphaned_review_verdicts(conn)
74
+ return conn
75
+
76
+
77
+ def override_connection(conn: sqlite3.Connection) -> None:
78
+ """Replace the singleton connection. Used in tests to inject :memory: DBs."""
79
+ global _conn
80
+ _conn = conn
81
+
82
+
83
+ def close() -> None:
84
+ """Close the singleton connection and reset it to None."""
85
+ global _conn
86
+ if _conn is not None:
87
+ _conn.close()
88
+ _conn = None
89
+
90
+
91
+ # ── Schema ────────────────────────────────────────────────────────────────────
92
+
93
+ def _ensure_schema(conn: sqlite3.Connection) -> None:
94
+ """Create all tables and indexes if they don't already exist."""
95
+ conn.executescript("""
96
+ CREATE TABLE IF NOT EXISTS schema_version (
97
+ version INTEGER NOT NULL
98
+ );
99
+
100
+ CREATE TABLE IF NOT EXISTS assignments (
101
+ assignment_id TEXT PRIMARY KEY,
102
+ machine_name TEXT NOT NULL,
103
+ repo_name TEXT NOT NULL,
104
+ repo_github TEXT,
105
+ issue_number INTEGER NOT NULL,
106
+ issue_title TEXT NOT NULL,
107
+ status TEXT NOT NULL DEFAULT 'running',
108
+ type TEXT NOT NULL DEFAULT 'work',
109
+ branch TEXT,
110
+ pr_url TEXT,
111
+ briefing TEXT DEFAULT '',
112
+ files_allowed TEXT DEFAULT '[]',
113
+ files_forbidden TEXT DEFAULT '[]',
114
+ model TEXT,
115
+ dispatched_at REAL,
116
+ finished_at REAL,
117
+ smoke_test TEXT,
118
+ smoke_test_reason TEXT,
119
+ review_state TEXT,
120
+ review_of_assignment_id TEXT,
121
+ review_target TEXT,
122
+ required_gates TEXT DEFAULT '[]',
123
+ plan TEXT,
124
+ unreachable_count INTEGER DEFAULT 0,
125
+ exit_code INTEGER,
126
+ review_iteration INTEGER DEFAULT 0,
127
+ review_posted_at REAL,
128
+ test_state TEXT,
129
+ test_reason TEXT,
130
+ cost_usd REAL,
131
+ smoke_tests TEXT,
132
+ review_findings TEXT,
133
+ test_plan TEXT
134
+ );
135
+
136
+ CREATE TABLE IF NOT EXISTS notifications (
137
+ assignment_id TEXT PRIMARY KEY,
138
+ event TEXT NOT NULL,
139
+ branch TEXT,
140
+ posted_at REAL NOT NULL
141
+ );
142
+
143
+ CREATE TABLE IF NOT EXISTS proposals (
144
+ id INTEGER PRIMARY KEY,
145
+ machine_name TEXT NOT NULL,
146
+ repo_name TEXT NOT NULL,
147
+ issue_number INTEGER NOT NULL,
148
+ issue_title TEXT NOT NULL,
149
+ rationale TEXT DEFAULT '',
150
+ files_likely TEXT DEFAULT '[]',
151
+ briefing TEXT DEFAULT '',
152
+ model TEXT,
153
+ type TEXT DEFAULT 'work',
154
+ required_gates TEXT DEFAULT '[]'
155
+ );
156
+
157
+ CREATE TABLE IF NOT EXISTS split_proposals (
158
+ id INTEGER PRIMARY KEY,
159
+ repo_name TEXT NOT NULL,
160
+ issue_number INTEGER NOT NULL,
161
+ issue_title TEXT NOT NULL,
162
+ rationale TEXT DEFAULT ''
163
+ );
164
+
165
+ CREATE TABLE IF NOT EXISTS split_chunks (
166
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
167
+ split_proposal_id INTEGER NOT NULL REFERENCES split_proposals(id),
168
+ title TEXT NOT NULL,
169
+ scope TEXT NOT NULL,
170
+ files_likely TEXT DEFAULT '[]'
171
+ );
172
+
173
+ CREATE TABLE IF NOT EXISTS merge_queue (
174
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
175
+ assignment_id TEXT NOT NULL,
176
+ repo_name TEXT NOT NULL,
177
+ repo_github TEXT NOT NULL,
178
+ branch TEXT NOT NULL,
179
+ target_branch TEXT NOT NULL,
180
+ issue_number INTEGER NOT NULL,
181
+ issue_title TEXT NOT NULL,
182
+ state TEXT NOT NULL DEFAULT 'pending',
183
+ pr_number INTEGER,
184
+ pr_url TEXT,
185
+ size INTEGER,
186
+ last_attempt REAL,
187
+ error TEXT,
188
+ enqueued_at REAL,
189
+ assignment_type TEXT DEFAULT 'work',
190
+ required_gates TEXT,
191
+ ci_infra_reruns INTEGER NOT NULL DEFAULT 0
192
+ );
193
+
194
+ CREATE TABLE IF NOT EXISTS plans (
195
+ assignment_id TEXT PRIMARY KEY,
196
+ plan_data TEXT NOT NULL
197
+ );
198
+
199
+ CREATE TABLE IF NOT EXISTS sessions (
200
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
201
+ started_at TEXT,
202
+ ended_at TEXT,
203
+ clean_shutdown INTEGER DEFAULT 0,
204
+ completed_this_session TEXT,
205
+ issues_closed TEXT,
206
+ total_cost_usd REAL
207
+ );
208
+
209
+ CREATE TABLE IF NOT EXISTS machines (
210
+ name TEXT PRIMARY KEY,
211
+ host TEXT NOT NULL,
212
+ capabilities TEXT DEFAULT '[]',
213
+ repos TEXT DEFAULT '[]'
214
+ );
215
+
216
+ CREATE TABLE IF NOT EXISTS board_meta (
217
+ key TEXT PRIMARY KEY,
218
+ value TEXT
219
+ );
220
+
221
+ CREATE TABLE IF NOT EXISTS issues (
222
+ repo_name TEXT NOT NULL,
223
+ number INTEGER NOT NULL,
224
+ title TEXT NOT NULL DEFAULT '',
225
+ body TEXT NOT NULL DEFAULT '',
226
+ state TEXT NOT NULL DEFAULT 'open',
227
+ labels TEXT NOT NULL DEFAULT '[]',
228
+ synced_at REAL,
229
+ PRIMARY KEY (repo_name, number)
230
+ );
231
+
232
+ -- #603: per-issue rolling context digest. Short-lived curated notes
233
+ -- (cross-repo deps, failed approaches, hard constraints) that every
234
+ -- agent reads at the top of its briefing. Structured entries rendered
235
+ -- to markdown at read time; pinned rows stay on top, newest-first
236
+ -- below, oldest non-pinned aged out by a budget; dropped on close.
237
+ CREATE TABLE IF NOT EXISTS issue_context (
238
+ id INTEGER PRIMARY KEY,
239
+ repo_name TEXT NOT NULL,
240
+ issue_number INTEGER NOT NULL,
241
+ pinned INTEGER NOT NULL DEFAULT 0,
242
+ source TEXT,
243
+ body TEXT NOT NULL,
244
+ created_at REAL NOT NULL
245
+ );
246
+
247
+ -- #1036: durable, append-only audit trail. One row per real
248
+ -- state-changing transition, captured at the state._*_local /
249
+ -- issue_store write choke points (never at the ~30 CLI call
250
+ -- sites) so the log is topology-agnostic (thin-client vs daemon).
251
+ -- Modeled on issue_context above: additive, no UPDATE/DELETE
252
+ -- except the opportunistic audit.max_rows trim in coord/audit.py.
253
+ CREATE TABLE IF NOT EXISTS audit_log (
254
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
255
+ ts REAL NOT NULL,
256
+ tier TEXT NOT NULL,
257
+ category TEXT NOT NULL,
258
+ event_type TEXT NOT NULL,
259
+ actor TEXT NOT NULL,
260
+ repo TEXT,
261
+ issue INTEGER,
262
+ assignment_id TEXT,
263
+ machine TEXT,
264
+ summary TEXT NOT NULL DEFAULT '',
265
+ details_json TEXT
266
+ );
267
+
268
+ -- #873: durable, queryable mirror of GitHub issue comments — the
269
+ -- prose message bus (completion summaries, review bodies, failure
270
+ -- reports) today lives only on GitHub. Populated two ways: (1)
271
+ -- capture-at-write, instrumented at the single choke point
272
+ -- coord.github_ops.post_issue_comment (which close_issue/close_pr
273
+ -- also funnel through), so a coord-authored comment is durable the
274
+ -- instant it posts, regardless of which machine posted it; (2) the
275
+ -- backfill sync (coord.state.sync_issue_comments), which upserts
276
+ -- human + out-of-band comments idempotently keyed on gh_comment_id.
277
+ -- Retained INDEFINITELY — never pruned on close, unlike the
278
+ -- short-lived issue_context digest above.
279
+ --
280
+ -- repo_name here holds whatever identifier the write site had —
281
+ -- in practice the GitHub "owner/repo" slug (github_ops functions
282
+ -- only ever see the slug, never the coordinator's config repo
283
+ -- key) — which differs from the issues/issue_context tables above
284
+ -- where repo_name is the coordinator.yml repo key. Documented here
285
+ -- since it's a real inconsistency a future reader could trip on.
286
+ --
287
+ -- created_at/updated_at are epoch seconds (REAL), matching the
288
+ -- rest of this schema's timestamp columns. body_ref is reserved,
289
+ -- unused for now — the future Azure-blob offload seam.
290
+ CREATE TABLE IF NOT EXISTS issue_comments (
291
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
292
+ gh_comment_id INTEGER UNIQUE,
293
+ repo_name TEXT NOT NULL,
294
+ issue_number INTEGER NOT NULL,
295
+ author TEXT,
296
+ created_at REAL,
297
+ updated_at REAL,
298
+ body TEXT NOT NULL DEFAULT '',
299
+ body_ref TEXT,
300
+ coord_event TEXT,
301
+ coord_assignment_id TEXT,
302
+ machine TEXT,
303
+ verdict TEXT
304
+ );
305
+
306
+ -- #1505: board-visible "driver stuck" record. Written by `coord
307
+ -- drive`'s merge stage the moment it hits a status no amount of
308
+ -- retrying can fix (NEEDS_ATTENTION / an unrecognised status) instead
309
+ -- of silently burning the merge-attempt budget on it (see
310
+ -- coord/drive.py's `_decide_merge`). One row per (repo_name,
311
+ -- issue_number) — a fresh escalation replaces the previous one
312
+ -- (`ON CONFLICT ... DO UPDATE`, coord/state.py
313
+ -- `_record_drive_escalation_local`); `coord escalate dismiss` (or the
314
+ -- TUI's "Dismiss" menu item) deletes it outright. `gate_readings` is
315
+ -- a human-readable "key=value | key=value" summary, not JSON — the
316
+ -- whole record exists to be read by a human via the Pipeline row's
317
+ -- right-click menu, not machine-parsed.
318
+ CREATE TABLE IF NOT EXISTS drive_escalations (
319
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
320
+ repo_name TEXT NOT NULL,
321
+ issue_number INTEGER NOT NULL,
322
+ stage TEXT NOT NULL DEFAULT 'merge',
323
+ assignment_id TEXT,
324
+ reason TEXT NOT NULL,
325
+ gate_readings TEXT NOT NULL DEFAULT '',
326
+ proposed_command TEXT NOT NULL DEFAULT '',
327
+ created_at REAL NOT NULL,
328
+ UNIQUE(repo_name, issue_number)
329
+ );
330
+
331
+ -- #1753 (DQ-1): the operator-declared `coord drive` work queue. One
332
+ -- row per (repo_name, issue_number) — enqueueing an issue that is
333
+ -- already queued updates it in place (`ON CONFLICT ... DO UPDATE`,
334
+ -- coord/state.py `_enqueue_drive_queue_local`) rather than creating a
335
+ -- second row, so the queue can never hold the same issue twice.
336
+ -- Written by `coord drive-queue` (DQ-2) and by the tick processor that
337
+ -- launches entries; cleared by `coord drive-queue remove` (dequeue) —
338
+ -- terminal rows (`done`/`failed`) stay put until an operator removes
339
+ -- them, so the list doubles as a short run history.
340
+ --
341
+ -- `position` is DENSE and 0-BASED: enqueue-without-a-position appends
342
+ -- at max(position)+1 and `move` renumbers the affected span in one
343
+ -- transaction (no fractional positions — the queue is short by nature
344
+ -- and `coord drive-queue list` prints a dense integer order).
345
+ -- `machine` NULL means "let `coord drive` route it". `after_json` is
346
+ -- a JSON list of FULLY QUALIFIED pre-req keys (`["repo#N", ...]`) so a
347
+ -- cross-repo queue needs no second column; it is decoded to a real
348
+ -- list on the wire via coord/dao.py's `_JSON_COLUMNS`. This table
349
+ -- only STORES `after_json` — interpreting it (pre-req satisfaction) is
350
+ -- the tick processor's job, not the schema's.
351
+ --
352
+ -- #1757 (deploy gates) added the five `hold_*`/`resume_when` columns
353
+ -- below. They are declared here so a FRESH database gets them from
354
+ -- the CREATE, and repeated in `_migrate_add_columns` so an EXISTING
355
+ -- ~/.coord/coord.db (DQ-1 shipped before this) gains them in place —
356
+ -- SQLite has no "ADD COLUMN IF NOT EXISTS", hence both. See
357
+ -- coord/drive_queue.py for the lifecycle those columns encode.
358
+ --
359
+ -- #1870: `launch_host` is the SHORT HOSTNAME of the machine whose tick
360
+ -- actually ran `coord drive --tmux` for this entry — set alongside
361
+ -- `session_name`/`launched_at` when the launch succeeds, '' for a row
362
+ -- that predates this column or was hand-flipped to `running`.
363
+ -- Liveness (`coord.drive.list_drive_sessions`) is always a LOCAL tmux
364
+ -- read; without this column a tick running on a DIFFERENT host than
365
+ -- the one that launched the drive has no way to know that, sees no
366
+ -- session, and reaps a healthy drive out from under another machine
367
+ -- (the 2026-08-06 incident: a drive 47 minutes into Test on
368
+ -- `elitebook` was declared dead and relaunched by the timer on
369
+ -- `dellserver`). `_reconcile_running` treats a mismatch as UNKNOWN,
370
+ -- never as dead — see coord/drive_queue.py.
371
+ CREATE TABLE IF NOT EXISTS drive_queue (
372
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
373
+ repo_name TEXT NOT NULL,
374
+ issue_number INTEGER NOT NULL,
375
+ position INTEGER NOT NULL,
376
+ machine TEXT,
377
+ after_json TEXT NOT NULL DEFAULT '[]',
378
+ state TEXT NOT NULL DEFAULT 'waiting',
379
+ attempts INTEGER NOT NULL DEFAULT 0,
380
+ deferrals INTEGER NOT NULL DEFAULT 0,
381
+ last_reason TEXT NOT NULL DEFAULT '',
382
+ session_name TEXT,
383
+ launched_at REAL,
384
+ enqueued_at REAL NOT NULL,
385
+ hold_after INTEGER NOT NULL DEFAULT 0,
386
+ hold_reason TEXT NOT NULL DEFAULT '',
387
+ resume_when TEXT NOT NULL DEFAULT '',
388
+ hold_state TEXT NOT NULL DEFAULT '',
389
+ hold_probes INTEGER NOT NULL DEFAULT 0,
390
+ launch_host TEXT NOT NULL DEFAULT '',
391
+ UNIQUE(repo_name, issue_number)
392
+ );
393
+
394
+ -- #1630: daemon's aggregated view of each agent's /health payload
395
+ -- (H-1's check-registry results, when the agent reports them) plus
396
+ -- the poll's own reachability verdict. One row per machine, always
397
+ -- overwritten in place (`ON CONFLICT ... DO UPDATE`,
398
+ -- coord/state.py `save_machine_health`) — this is a live snapshot,
399
+ -- not a history. `received_at` is THIS daemon's clock at poll time,
400
+ -- not anything the agent claims, so staleness detection (an agent
401
+ -- that stops reporting must read `unknown`, never "still green")
402
+ -- can never be fooled by a stopped agent's stale self-reported
403
+ -- timestamp. `health_json` is NULL for an unreachable machine or an
404
+ -- agent too old to report a health block — advisory only, never
405
+ -- consulted by dispatch/routing/merge-queue code (see
406
+ -- tests/test_health_advisory_only.py).
407
+ CREATE TABLE IF NOT EXISTS machine_health (
408
+ machine_name TEXT PRIMARY KEY,
409
+ state TEXT NOT NULL,
410
+ reason TEXT NOT NULL DEFAULT '',
411
+ latency_ms REAL,
412
+ health_json TEXT,
413
+ received_at REAL NOT NULL
414
+ );
415
+
416
+ -- #2048: per-assignment tracking state for the cheap per-turn
417
+ -- liveness auditor (coord/liveness_auditor.py). One row per
418
+ -- assignment ever audited; consecutive_blocked is the running
419
+ -- streak of BLOCKED verdicts (reset to 0 by any continue/done),
420
+ -- and `raised` flips to 1 the one time the streak first reaches
421
+ -- the configured strike count, so a stall is reported once rather
422
+ -- than on every subsequent poll while the worker stays blocked.
423
+ -- Read/written ONLY by coord.notify.detect_liveness_stall and
424
+ -- coord.state's accessors below — never by anything that also
425
+ -- touches Assignment.status/review_state/test_state (the auditor
426
+ -- is a tripwire, not a gate).
427
+ CREATE TABLE IF NOT EXISTS liveness_audits (
428
+ assignment_id TEXT PRIMARY KEY,
429
+ consecutive_blocked INTEGER NOT NULL DEFAULT 0,
430
+ last_audit_at REAL,
431
+ last_verdict TEXT,
432
+ raised INTEGER NOT NULL DEFAULT 0
433
+ );
434
+
435
+ CREATE INDEX IF NOT EXISTS idx_assignments_status ON assignments(status);
436
+ CREATE INDEX IF NOT EXISTS idx_assignments_machine ON assignments(machine_name);
437
+ CREATE INDEX IF NOT EXISTS idx_merge_queue_state ON merge_queue(state);
438
+ CREATE INDEX IF NOT EXISTS idx_issue_context_issue
439
+ ON issue_context(repo_name, issue_number);
440
+ CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(ts);
441
+ CREATE INDEX IF NOT EXISTS idx_audit_log_assignment ON audit_log(assignment_id);
442
+ CREATE INDEX IF NOT EXISTS idx_issue_comments_issue
443
+ ON issue_comments(repo_name, issue_number);
444
+ CREATE INDEX IF NOT EXISTS idx_drive_escalations_issue
445
+ ON drive_escalations(repo_name, issue_number);
446
+ CREATE INDEX IF NOT EXISTS idx_drive_queue_state
447
+ ON drive_queue(state, position);
448
+
449
+ INSERT OR IGNORE INTO schema_version VALUES (1);
450
+ """)
451
+ conn.commit()
452
+ # Column-level migrations for existing databases. SQLite does not support
453
+ # "ADD COLUMN IF NOT EXISTS", so we catch OperationalError instead.
454
+ _migrate_add_columns(conn)
455
+
456
+
457
+ def _migrate_add_columns(conn: sqlite3.Connection) -> None:
458
+ """Add new columns to existing databases via ALTER TABLE.
459
+
460
+ Safe to call on databases that already have the columns — the
461
+ OperationalError raised by SQLite is silently swallowed.
462
+ """
463
+ migrations = [
464
+ "ALTER TABLE assignments ADD COLUMN review_iteration INTEGER DEFAULT 0",
465
+ "ALTER TABLE assignments ADD COLUMN review_posted_at REAL",
466
+ # #200: human-driven Test gate between Work and Review.
467
+ "ALTER TABLE assignments ADD COLUMN test_state TEXT",
468
+ "ALTER TABLE assignments ADD COLUMN test_reason TEXT",
469
+ # #253: persisted adversarial-review verdict so the merge gate can
470
+ # check approval without re-parsing logs after restart.
471
+ "ALTER TABLE assignments ADD COLUMN review_verdict TEXT",
472
+ # #208: worker cost captured from the final stream-json result event.
473
+ "ALTER TABLE assignments ADD COLUMN cost_usd REAL",
474
+ # #252: worker-emitted smoke-test list (JSON array of strings;
475
+ # NULL = not emitted, '[]' = explicit "(none — internal)").
476
+ "ALTER TABLE assignments ADD COLUMN smoke_tests TEXT",
477
+ # #bounce: cached review-findings body (markdown text) so coord
478
+ # bounce + the upcoming per-stage display don't have to re-fetch
479
+ # the review log from the agent. Populated by notify.py when
480
+ # the review is first parsed. NULL = not yet parsed; populated
481
+ # = full findings.body text.
482
+ "ALTER TABLE assignments ADD COLUMN review_findings TEXT",
483
+ # #315: claude session ID captured from the worker's `system.init`
484
+ # event. Set by notify.py after the agent reports completion. Used
485
+ # by `coord chat-continue` to pass `--resume <id>` to the next
486
+ # worker so it loads the prior conversation and continues it.
487
+ "ALTER TABLE assignments ADD COLUMN claude_session_id TEXT",
488
+ # #342 Phase A: AI-generated smoke test plan (JSON-encoded).
489
+ # NULL = not yet generated. Set by `coord test-plan` and read back
490
+ # by the CLI (cache hit) and eventually by the TUI (Phase B).
491
+ "ALTER TABLE assignments ADD COLUMN test_plan TEXT",
492
+ # #349 Phase B: branch HEAD SHA at the time `coord test-plan` last ran.
493
+ # Used by the TUI to detect staleness: if the branch has advanced
494
+ # since the plan was generated, it re-runs `coord test-plan --refresh`
495
+ # automatically. NULL = plan not yet generated, or generated without
496
+ # branch tracking (legacy). Always reset to NULL when set_test_plan
497
+ # is called with branch_head=None so no stale SHA persists.
498
+ "ALTER TABLE assignments ADD COLUMN test_plan_branch_head TEXT",
499
+ # #406 Phase A: milestone columns on the issues table.
500
+ # milestone_number is the GitHub milestone number (integer id); NULL for
501
+ # unassigned. milestone_title is the human-readable name (e.g. "v0.5");
502
+ # NULL when no milestone is assigned. Idempotent — SQLite raises
503
+ # OperationalError when the column already exists, which is swallowed
504
+ # below.
505
+ "ALTER TABLE issues ADD COLUMN milestone_number INTEGER",
506
+ "ALTER TABLE issues ADD COLUMN milestone_title TEXT",
507
+ # #324: resolved provider name recorded at dispatch time so the TUI
508
+ # can surface it in the assignment detail panel (#327). NULL for rows
509
+ # dispatched before #324 landed; the TUI shows "claude" as the
510
+ # implicit default when the column is NULL.
511
+ "ALTER TABLE assignments ADD COLUMN provider_name TEXT",
512
+ # #546: token counts for automated (claude -p) assignments, parsed from
513
+ # the final stream-json result event alongside cost_usd. All default
514
+ # 0 so the TUI can sum them without NULLs. Interactive (Max/OAuth)
515
+ # sessions do not bill per-token; those rows stay at 0 and the TUI
516
+ # labels them "Max (subscription)" rather than showing a dollar figure.
517
+ "ALTER TABLE assignments ADD COLUMN input_tokens INTEGER DEFAULT 0",
518
+ "ALTER TABLE assignments ADD COLUMN output_tokens INTEGER DEFAULT 0",
519
+ "ALTER TABLE assignments ADD COLUMN cache_creation_tokens INTEGER DEFAULT 0",
520
+ "ALTER TABLE assignments ADD COLUMN cache_read_tokens INTEGER DEFAULT 0",
521
+ # #546: track whether an assignment ran as a human-attended interactive
522
+ # session (Max/Pro subscription). Used by the TUI to show
523
+ # "Max (subscription)" accurately without misidentifying old automated
524
+ # rows that also lack cost_usd + token data.
525
+ "ALTER TABLE assignments ADD COLUMN is_interactive INTEGER DEFAULT 0",
526
+ # #618: short human-readable reason for a launch failure (e.g.
527
+ # "branch already checked out at ~/.coord/worktrees/<old-aid>").
528
+ # Written by the CLI immediately when an interactive session can't
529
+ # start so the TUI can explain the red box without any log file.
530
+ # NULL for assignments that launched successfully.
531
+ "ALTER TABLE assignments ADD COLUMN failure_reason TEXT",
532
+ # #776 (Merge Queue v2-A): track when an entry was added to the queue
533
+ # so the merge plan can display age and sort stably. NULL for entries
534
+ # created before this migration ran.
535
+ "ALTER TABLE merge_queue ADD COLUMN enqueued_at REAL",
536
+ # #821: commit-bound review gate — SHA of the branch HEAD at the time
537
+ # the review assignment ran. When both this column and the merge-queue
538
+ # entry's branch_head_sha are populated and differ, `has_approved_review`
539
+ # treats the approval as stale (new commits since the review → re-review
540
+ # required). NULL for rows predating this feature.
541
+ "ALTER TABLE assignments ADD COLUMN review_head_sha TEXT",
542
+ # #944: the Acceptance-gate verdict (oracle loop, docs/ORACLE_LOOP.md)
543
+ # — set by `coord acceptance record --issue N --sha <sha>`, the
544
+ # coordinator's external re-run of the sealed suite against the
545
+ # pushed SHA. NULL until a record has run. acceptance_reason carries
546
+ # a short failing-test summary (mirrors test_reason); acceptance_sha
547
+ # is the exact commit the verdict was recorded against, so staleness
548
+ # (new commits since the last record) is detectable the same way
549
+ # review_head_sha detects a stale review approval.
550
+ "ALTER TABLE assignments ADD COLUMN acceptance_state TEXT",
551
+ "ALTER TABLE assignments ADD COLUMN acceptance_reason TEXT",
552
+ "ALTER TABLE assignments ADD COLUMN acceptance_sha TEXT",
553
+ # #932: per-test counts alongside acceptance_state, so the Acceptance
554
+ # box can render "3/7 acceptance green" instead of a bare verdict.
555
+ "ALTER TABLE assignments ADD COLUMN acceptance_total INTEGER",
556
+ "ALTER TABLE assignments ADD COLUMN acceptance_passed INTEGER",
557
+ # #874: persist the worker's ### Summary prose block so the TUI's
558
+ # Summary tab has a durable, board-sourced field. NULL when the
559
+ # worker emitted no summary (best-effort; never blocks completion).
560
+ "ALTER TABLE assignments ADD COLUMN completion_summary TEXT",
561
+ # #886 Phase 2: structured Milestone Outcome Audit verdict. Written by
562
+ # issue_store._persist_audit_result for type="audit" assignments only
563
+ # (the epic's issue_number doubles as the audit's issue_number — see
564
+ # #885's _dispatch_audit_of). audit_goals_json is a JSON array of
565
+ # {goal, metric_before, metric_after, verdict (met|partial|gap),
566
+ # evidence} — kept as a raw JSON string on the wire, same convention
567
+ # as review_findings above. audit_run_number increments once per
568
+ # `--audit-of <epic>` run against the same (repo_name, issue_number)
569
+ # so later runs can diff against earlier ones. NULL for every row
570
+ # predating this feature and for non-audit assignment types.
571
+ "ALTER TABLE assignments ADD COLUMN audit_goals_json TEXT",
572
+ "ALTER TABLE assignments ADD COLUMN audit_bottom_line TEXT",
573
+ "ALTER TABLE assignments ADD COLUMN audit_run_number INTEGER",
574
+ # #1077: the originating assignment's `type` (e.g. "work",
575
+ # "mock-author"), so the merge processor can tell whether merging
576
+ # this entry's PR should deterministically close `issue_number` —
577
+ # see coord.models.CLOSES_ISSUE_TYPES. Existing rows default to
578
+ # 'work', preserving the prior always-close behavior for entries
579
+ # enqueued before this column existed.
580
+ "ALTER TABLE merge_queue ADD COLUMN assignment_type TEXT DEFAULT 'work'",
581
+ # #1084: for type="test-author" JIT-mode assignments, the work-order
582
+ # member issue this dispatch is extending the acceptance suite for —
583
+ # see coord.models.Assignment.for_issue_number. NULL for milestone-
584
+ # mode (Gate A) authoring, every other assignment type, and rows
585
+ # predating this column.
586
+ "ALTER TABLE assignments ADD COLUMN for_issue_number INTEGER",
587
+ # #1213: snapshot of the originating assignment's resolved
588
+ # required_gates (JSON array; NULL/'[]' = no per-issue override —
589
+ # callers fall back to config.pipeline.default_gates), captured at
590
+ # enqueue time so the review/smoke merge gates are commit-bound
591
+ # rather than re-resolved from the live board at merge time. NULL
592
+ # for rows predating this column, which fall back the same way.
593
+ "ALTER TABLE merge_queue ADD COLUMN required_gates TEXT",
594
+ # #1456: audit trail for a coordinator override of a reviewer's verdict
595
+ # (the #476 approve-with-nits gate). `review_verdict_original` holds
596
+ # the reviewer's own verdict and `review_verdict_override_reason` the
597
+ # parsed counts that justified the override; `review_verdict` keeps the
598
+ # effective value the merge gate reads. NULL for every row where the
599
+ # coordinator never overrode anything, and for rows predating this
600
+ # column — see coord.models.Assignment.review_verdict_original.
601
+ "ALTER TABLE assignments ADD COLUMN review_verdict_original TEXT",
602
+ "ALTER TABLE assignments ADD COLUMN review_verdict_override_reason TEXT",
603
+ # #1475: content-addressed fingerprint of the diff a review approved
604
+ # (`git patch-id --stable`), captured alongside `review_head_sha`. A
605
+ # rebase that changes no content produces the same patch-id even
606
+ # though the branch's HEAD SHA moved, so `has_approved_review` can
607
+ # carry the approval forward instead of re-blocking on the #821 SHA
608
+ # check. NULL for rows predating this column or where the patch-id
609
+ # could not be computed — those fall back to today's SHA-only
610
+ # staleness behaviour (fail closed).
611
+ "ALTER TABLE assignments ADD COLUMN review_patch_id TEXT",
612
+ # #1479: Test-gate staleness anchor, captured (best-effort) alongside
613
+ # a terminal test_state write in
614
+ # `coord.state._record_test_verdict_local`. `test_head_sha` /
615
+ # `test_patch_id` mirror `review_head_sha` / `review_patch_id`
616
+ # (#821/#1475) for the branch's own content; `test_base_sha` is the
617
+ # NEW piece — the target/merge branch's HEAD SHA at test time, so
618
+ # `coord.merge_queue.has_smoke_verdict` can detect a base that moved
619
+ # out from under an otherwise content-identical branch (a rebase can
620
+ # break tests without changing the branch's own diff). NULL for rows
621
+ # predating this column or where the anchor couldn't be computed —
622
+ # those fall back to today's no-staleness-check behavior (fail open).
623
+ "ALTER TABLE assignments ADD COLUMN test_head_sha TEXT",
624
+ "ALTER TABLE assignments ADD COLUMN test_patch_id TEXT",
625
+ "ALTER TABLE assignments ADD COLUMN test_base_sha TEXT",
626
+ # #1476: audit trail for a SCOPED re-review — dispatched when a
627
+ # conflict-fix rebase changed content under an already-approved
628
+ # review (patch-id mismatch) but no other work/fix commit
629
+ # intervened. `review_scoped` marks the review row as scoped (vs. a
630
+ # full re-review of the whole PR); `review_scope_base_sha` records
631
+ # the prior review's `review_head_sha` — the commit the resolution
632
+ # delta was computed from — so a later audit can distinguish
633
+ # "reviewed the 15-line resolution" from "reviewed the whole PR".
634
+ # 0/NULL for every row predating this column and for ordinary
635
+ # (non-scoped) reviews.
636
+ "ALTER TABLE assignments ADD COLUMN review_scoped INTEGER DEFAULT 0",
637
+ "ALTER TABLE assignments ADD COLUMN review_scope_base_sha TEXT",
638
+ # #1499: durable provenance — `f"drive:{repo}#{issue}"` when this
639
+ # assignment was dispatched by `coord drive` (via `coord assign
640
+ # --driven-by`), NULL for a hand `coord assign` and for every row
641
+ # predating this column. This is the piece that survives the
642
+ # driver process exiting — see coord.models.Assignment.driven_by.
643
+ "ALTER TABLE assignments ADD COLUMN driven_by TEXT",
644
+ # #1629 (H-2): the toolchain string that produced a Test-gate
645
+ # verdict — e.g. "rustc 1.95.0" or "python 3.12.4, node 20.11.0" —
646
+ # captured (best-effort) alongside `test_state` in
647
+ # `coord.state._record_test_verdict_local`. Annotation only: nothing
648
+ # reads this to gate dispatch/review/merge (see
649
+ # coord.health.checks.toolchain's fleet_toolchain_skew, which is the
650
+ # thing that actually judges skew). NULL for every row predating
651
+ # this column and for any verdict recorded without a resolvable
652
+ # toolchain — a historical/unknown value, never treated as a mismatch.
653
+ "ALTER TABLE assignments ADD COLUMN test_toolchain TEXT",
654
+ # #1757 (deploy gates): mark a queue entry so that when it completes,
655
+ # the tick STOPS launching and waits for a human deploy step —
656
+ # `merged != live` is this repo's most-repeated operational lesson and
657
+ # a queue that models merge but not deploy sequences work straight into
658
+ # it, unattended. DQ-1 shipped before this, so an existing
659
+ # ~/.coord/coord.db is upgraded in place here (the same columns are in
660
+ # `_ensure_schema`'s CREATE for fresh databases; the duplicate-column
661
+ # OperationalError is swallowed below).
662
+ #
663
+ # hold_after 0/1 — this entry ends with a deploy gate
664
+ # hold_reason shown to the operator when the gate fires
665
+ # resume_when optional probe command; '' = manual resume only
666
+ # hold_state ''|armed|fired|released (coord/drive_queue.py)
667
+ # hold_probes consecutive failed `resume_when` runs since the gate
668
+ # fired — a TYPED count, so the alert's rising attempt
669
+ # number never has to be parsed back out of prose
670
+ # (#1523 §2: typed state, never CLI prose).
671
+ "ALTER TABLE drive_queue ADD COLUMN hold_after INTEGER NOT NULL DEFAULT 0",
672
+ "ALTER TABLE drive_queue ADD COLUMN hold_reason TEXT NOT NULL DEFAULT ''",
673
+ "ALTER TABLE drive_queue ADD COLUMN resume_when TEXT NOT NULL DEFAULT ''",
674
+ "ALTER TABLE drive_queue ADD COLUMN hold_state TEXT NOT NULL DEFAULT ''",
675
+ "ALTER TABLE drive_queue ADD COLUMN hold_probes INTEGER NOT NULL DEFAULT 0",
676
+ # #1870: the short hostname of the machine that actually launched this
677
+ # entry's `coord drive --tmux` — see the CREATE TABLE comment above.
678
+ # '' for every row written before this migration; `_reconcile_running`
679
+ # treats that exactly like "launched here" (today's behaviour).
680
+ "ALTER TABLE drive_queue ADD COLUMN launch_host TEXT NOT NULL DEFAULT ''",
681
+ # #1892: count of automatic `CiStore.rerun_for_pr` calls `merge_queue
682
+ # .process()` has issued for this entry's current verdictless-CI-
683
+ # failure streak — see `coord.merge_queue.MAX_CI_INFRA_RERUNS` and
684
+ # `QueuedMerge.ci_infra_reruns`'s docstring. 0 for every row
685
+ # predating this column (no auto-reruns spent yet), same as the
686
+ # column's own default for freshly-enqueued entries.
687
+ "ALTER TABLE merge_queue ADD COLUMN ci_infra_reruns INTEGER NOT NULL DEFAULT 0",
688
+ # #1956: verdict provenance — WHO recorded `review_verdict` and HOW
689
+ # (see coord.models.Assignment.verdict_source for the three values
690
+ # and why conflating them was the second half of #1956). NULL for
691
+ # every row predating this column and for the common case (the
692
+ # reviewer's own log was parsed) — callers treat NULL identically to
693
+ # "agent".
694
+ "ALTER TABLE assignments ADD COLUMN verdict_source TEXT",
695
+ "ALTER TABLE assignments ADD COLUMN verdict_source_reason TEXT",
696
+ ]
697
+ for sql in migrations:
698
+ try:
699
+ conn.execute(sql)
700
+ conn.commit()
701
+ except sqlite3.OperationalError:
702
+ pass # Column already exists
703
+
704
+
705
+ # ── JSON migration ─────────────────────────────────────────────────────────────
706
+
707
+ def _maybe_migrate_json(conn: sqlite3.Connection) -> None:
708
+ """Migrate old JSON files to SQLite if dispatched.json exists and DB is empty.
709
+
710
+ The ``json_migrated`` marker in board_meta is checked first. Once set it
711
+ persists forever, so migration never re-runs even if JSON files reappear
712
+ (e.g. from stale code, test fixtures, or an agent writing legacy state).
713
+ """
714
+ # Marker check must come first — bail out immediately if migration already ran.
715
+ cursor = conn.execute(
716
+ "SELECT value FROM board_meta WHERE key='json_migrated'"
717
+ )
718
+ if cursor.fetchone() is not None:
719
+ return
720
+ dispatched_json = COORD_DIR / "dispatched.json"
721
+ if not dispatched_json.exists():
722
+ return
723
+ cursor = conn.execute("SELECT COUNT(*) FROM assignments")
724
+ if cursor.fetchone()[0] > 0:
725
+ return
726
+ try:
727
+ _migrate_json(conn)
728
+ except Exception as exc: # noqa: BLE001 — migration is best-effort
729
+ import sys
730
+ print(f"coord: warning: JSON→SQLite migration failed: {exc}", file=sys.stderr)
731
+
732
+
733
+ def _migrate_json(conn: sqlite3.Connection) -> None: # noqa: C901 — acceptable complexity
734
+ """One-shot migration from JSON files to SQLite. Renames JSON files to .bak."""
735
+ import time as _time
736
+
737
+ dispatched_json = COORD_DIR / "dispatched.json"
738
+ notified_json = COORD_DIR / "notified.json"
739
+ board_json = COORD_DIR / "board.json"
740
+ proposals_json = COORD_DIR / "pending_proposals.json"
741
+ splits_json = COORD_DIR / "pending_splits.json"
742
+ plans_json = COORD_DIR / "plans.json"
743
+ session_json = COORD_DIR / "session.json"
744
+ merge_queue_json = COORD_DIR / "merge_queue.json"
745
+
746
+ with conn: # single transaction
747
+ # 1. dispatched.json → assignments (initial insert, status='running')
748
+ dispatched_data: list[dict] = []
749
+ if dispatched_json.exists():
750
+ try:
751
+ dispatched_data = json.loads(dispatched_json.read_text())
752
+ except Exception: # noqa: BLE001
753
+ pass
754
+ for rec in dispatched_data:
755
+ conn.execute(
756
+ """INSERT OR IGNORE INTO assignments (
757
+ assignment_id, machine_name, repo_name, repo_github,
758
+ issue_number, issue_title, status, type, briefing,
759
+ files_allowed, model, dispatched_at, review_of_assignment_id,
760
+ required_gates
761
+ ) VALUES (?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?)""",
762
+ (
763
+ rec.get("assignment_id", ""),
764
+ rec.get("machine_name", ""),
765
+ rec.get("repo_name", ""),
766
+ rec.get("repo_github"),
767
+ rec.get("issue_number", 0),
768
+ rec.get("issue_title", ""),
769
+ rec.get("type", "work"),
770
+ rec.get("briefing", ""),
771
+ json.dumps(rec.get("files_likely", [])),
772
+ rec.get("model"),
773
+ rec.get("dispatched_at"),
774
+ rec.get("review_of_assignment_id"),
775
+ json.dumps(rec.get("required_gates", [])),
776
+ ),
777
+ )
778
+
779
+ # 2. notified.json → notifications
780
+ if notified_json.exists():
781
+ try:
782
+ notified: dict[str, dict] = json.loads(notified_json.read_text())
783
+ for aid, info in notified.items():
784
+ conn.execute(
785
+ """INSERT OR REPLACE INTO notifications
786
+ (assignment_id, event, branch, posted_at)
787
+ VALUES (?, ?, ?, ?)""",
788
+ (aid, info.get("event", ""), info.get("branch"),
789
+ info.get("posted_at", _time.time())),
790
+ )
791
+ except Exception: # noqa: BLE001
792
+ pass
793
+
794
+ # 3. board.json → assignments (richer status fields via REPLACE)
795
+ if board_json.exists():
796
+ try:
797
+ board_data = json.loads(board_json.read_text())
798
+ all_entries = (
799
+ board_data.get("active", []) + board_data.get("completed", [])
800
+ )
801
+ for a in all_entries:
802
+ aid = a.get("assignment_id")
803
+ if not aid:
804
+ continue
805
+ conn.execute(
806
+ """INSERT OR REPLACE INTO assignments (
807
+ assignment_id, machine_name, repo_name, repo_github,
808
+ issue_number, issue_title, status, type, branch, pr_url,
809
+ briefing, files_allowed, files_forbidden, model,
810
+ dispatched_at, finished_at, smoke_test, smoke_test_reason,
811
+ review_state, review_of_assignment_id, review_target,
812
+ required_gates, plan, unreachable_count, exit_code
813
+ ) VALUES (
814
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
815
+ ?, ?, ?, ?, ?, ?, ?, ?,
816
+ ?, ?, ?, ?, ?, ?, ?
817
+ )""",
818
+ (
819
+ aid,
820
+ a.get("machine_name", ""),
821
+ a.get("repo_name", ""),
822
+ None, # repo_github not in board JSON
823
+ a.get("issue_number", 0),
824
+ a.get("issue_title", ""),
825
+ a.get("status", "running"),
826
+ a.get("type", "work"),
827
+ a.get("branch"),
828
+ a.get("pr_url"),
829
+ a.get("briefing", ""),
830
+ json.dumps(a.get("files_allowed", [])),
831
+ json.dumps(a.get("files_forbidden", [])),
832
+ a.get("model"),
833
+ a.get("dispatched_at"),
834
+ a.get("finished_at"),
835
+ a.get("smoke_test"),
836
+ a.get("smoke_test_reason"),
837
+ a.get("review_state"),
838
+ a.get("review_of_assignment_id"),
839
+ a.get("review_target"),
840
+ json.dumps(a.get("required_gates", [])),
841
+ json.dumps(a.get("plan")) if a.get("plan") else None,
842
+ a.get("unreachable_count", 0),
843
+ a.get("exit_code"),
844
+ ),
845
+ )
846
+ round_number = board_data.get("round_number", 0)
847
+ conn.execute(
848
+ "INSERT OR REPLACE INTO board_meta (key, value) VALUES ('round_number', ?)",
849
+ (str(round_number),),
850
+ )
851
+ conn.execute(
852
+ "INSERT OR REPLACE INTO board_meta (key, value) VALUES ('board_initialized', '1')"
853
+ )
854
+ except Exception: # noqa: BLE001
855
+ pass
856
+
857
+ # 4. proposals
858
+ if proposals_json.exists():
859
+ try:
860
+ for p in json.loads(proposals_json.read_text()):
861
+ conn.execute(
862
+ """INSERT OR IGNORE INTO proposals
863
+ (id, machine_name, repo_name, issue_number, issue_title,
864
+ rationale, files_likely, briefing, model, type, required_gates)
865
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
866
+ (
867
+ p.get("id"), p.get("machine_name", ""), p.get("repo_name", ""),
868
+ p.get("issue_number", 0), p.get("issue_title", ""),
869
+ p.get("rationale", ""), json.dumps(p.get("files_likely", [])),
870
+ p.get("briefing", ""), p.get("model"),
871
+ p.get("type", "work"), json.dumps(p.get("required_gates", [])),
872
+ ),
873
+ )
874
+ except Exception: # noqa: BLE001
875
+ pass
876
+
877
+ # 5. split proposals + chunks
878
+ if splits_json.exists():
879
+ try:
880
+ for s in json.loads(splits_json.read_text()):
881
+ conn.execute(
882
+ """INSERT OR IGNORE INTO split_proposals
883
+ (id, repo_name, issue_number, issue_title, rationale)
884
+ VALUES (?, ?, ?, ?, ?)""",
885
+ (s.get("id"), s.get("repo_name", ""), s.get("issue_number", 0),
886
+ s.get("issue_title", ""), s.get("rationale", "")),
887
+ )
888
+ for chunk in s.get("chunks", []):
889
+ conn.execute(
890
+ """INSERT INTO split_chunks
891
+ (split_proposal_id, title, scope, files_likely)
892
+ VALUES (?, ?, ?, ?)""",
893
+ (s.get("id"), chunk.get("title", ""), chunk.get("scope", ""),
894
+ json.dumps(chunk.get("files_likely", []))),
895
+ )
896
+ except Exception: # noqa: BLE001
897
+ pass
898
+
899
+ # 6. plans
900
+ if plans_json.exists():
901
+ try:
902
+ for aid, plan_dict in json.loads(plans_json.read_text()).items():
903
+ conn.execute(
904
+ "INSERT OR IGNORE INTO plans (assignment_id, plan_data) VALUES (?, ?)",
905
+ (aid, json.dumps(plan_dict)),
906
+ )
907
+ except Exception: # noqa: BLE001
908
+ pass
909
+
910
+ # 7. session
911
+ if session_json.exists():
912
+ try:
913
+ sess = json.loads(session_json.read_text())
914
+ conn.execute(
915
+ """INSERT INTO sessions
916
+ (started_at, ended_at, clean_shutdown,
917
+ completed_this_session, issues_closed, total_cost_usd)
918
+ VALUES (?, ?, ?, ?, ?, ?)""",
919
+ (
920
+ sess.get("started_at"),
921
+ sess.get("ended_at"),
922
+ 1 if sess.get("clean_shutdown") else 0,
923
+ json.dumps(sess.get("completed_this_session", [])),
924
+ json.dumps(sess.get("issues_closed", [])),
925
+ sess.get("total_cost_usd"),
926
+ ),
927
+ )
928
+ except Exception: # noqa: BLE001
929
+ pass
930
+
931
+ # 8. merge queue
932
+ if merge_queue_json.exists():
933
+ try:
934
+ for entry in json.loads(merge_queue_json.read_text()):
935
+ conn.execute(
936
+ """INSERT INTO merge_queue (
937
+ assignment_id, repo_name, repo_github, branch,
938
+ target_branch, issue_number, issue_title, state,
939
+ pr_number, pr_url, size, last_attempt, error
940
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
941
+ (
942
+ entry.get("assignment_id"), entry.get("repo_name"),
943
+ entry.get("repo_github"), entry.get("branch"),
944
+ entry.get("target_branch"), entry.get("issue_number"),
945
+ entry.get("issue_title"), entry.get("state", "pending"),
946
+ entry.get("pr_number"), entry.get("pr_url"),
947
+ entry.get("size"), entry.get("last_attempt"),
948
+ entry.get("error"),
949
+ ),
950
+ )
951
+ except Exception: # noqa: BLE001
952
+ pass
953
+
954
+ # Persist migration marker — this is the canonical "already migrated" signal.
955
+ # Checked at the top of _maybe_migrate_json(), so JSON files reappearing
956
+ # later (stale code, test fixtures, agent writing legacy state) won't
957
+ # re-trigger the migration.
958
+ conn.execute(
959
+ "INSERT OR REPLACE INTO board_meta (key, value) VALUES ('json_migrated', ?)",
960
+ (str(_time.time()),),
961
+ )
962
+
963
+ # Rename JSON files to .bak so migration doesn't re-run
964
+ for f in [
965
+ dispatched_json, notified_json, board_json, proposals_json,
966
+ splits_json, plans_json, session_json, merge_queue_json,
967
+ ]:
968
+ if f.exists():
969
+ try:
970
+ f.rename(f.with_suffix(f.suffix + ".bak"))
971
+ except Exception: # noqa: BLE001
972
+ pass
973
+
974
+
975
+ # ── Gate-order migration (Test-before-Review reorder) ─────────────────────────
976
+
977
+ def _migrate_gate_order(conn: sqlite3.Connection) -> None:
978
+ """Rewrite the stale default gate order in stored rows.
979
+
980
+ The default gate order moved Test ahead of Review — from the #520-era
981
+ ``["review", "test", "merge"]`` to ``["test", "review", "merge"]`` — now
982
+ that the agent-assisted Testing stage is smooth enough to run as a smoke
983
+ test *before* the PR/review (the natural order), reversing #520's "get the
984
+ review over with first" workaround. Any assignment, proposal, or
985
+ ``board_meta`` row still carrying the previous implicit default is updated
986
+ so the pipeline display and the headless review gate
987
+ (:meth:`PipelineConfig.test_precedes_review`) agree. Only the exact
988
+ previous-default JSON string is touched — user-customised gate lists
989
+ (anything else) are left unchanged.
990
+
991
+ The target string (``["test", "review", "merge"]``) is also the pre-#520
992
+ original, so a DB that never migrated forward is already in the desired
993
+ state and untouched.
994
+
995
+ This function is idempotent: if the previous-default string is absent, no
996
+ rows change.
997
+ """
998
+ _OLD = '["review", "test", "merge"]'
999
+ _NEW = '["test", "review", "merge"]'
1000
+ conn.execute(
1001
+ "UPDATE assignments SET required_gates = ? WHERE required_gates = ?",
1002
+ (_NEW, _OLD),
1003
+ )
1004
+ conn.execute(
1005
+ "UPDATE proposals SET required_gates = ? WHERE required_gates = ?",
1006
+ (_NEW, _OLD),
1007
+ )
1008
+ conn.execute(
1009
+ "UPDATE board_meta SET value = ? "
1010
+ "WHERE key = 'pipeline_default_gates' AND value = ?",
1011
+ (_NEW, _OLD),
1012
+ )
1013
+ conn.commit()
1014
+
1015
+
1016
+ def _backfill_orphaned_review_verdicts(conn: sqlite3.Connection) -> int:
1017
+ """#1663: copy a captured review verdict onto its parent WORK row.
1018
+
1019
+ ``run_drain`` (#1616) captured the verdict onto the ``type='review'`` row
1020
+ and never propagated it to the parent, because the only path to that write
1021
+ ran through ``auto_loop.process_review_completion`` — which also dispatches
1022
+ fix workers, so the daemon's clock refused to enter it at all. Every
1023
+ verdict the drain consumed instead of a human's ``coord notify`` therefore
1024
+ left its work row at ``review_state='dispatched'`` / ``review_verdict``
1025
+ NULL. The code fix is in ``coord.notify._run_drain_locked`` +
1026
+ ``coord.auto_loop.propagate_review_verdict``; this repairs the rows already
1027
+ stranded when it landed (the 2026-08-01 batch — #1527 #1624 #1658 #1633
1028
+ #1353 — plus #544, #1078 and #1122), so none of them needs a re-review at
1029
+ $1-3 a head.
1030
+
1031
+ **Copies only.** The verdict is read from the existing review row via
1032
+ ``review_of_assignment_id``; nothing is synthesised, re-derived from a
1033
+ findings body, or inferred from pipeline state. A work row whose review
1034
+ row never captured a verdict (#1122's ``188ae219aca3``, lost to #1636/#1658
1035
+ and recovered by hand onto PR #1656) has nothing to copy and is left
1036
+ exactly as it is — a fabricated verdict there would overwrite hand-recovered
1037
+ findings with a guess.
1038
+
1039
+ Scoped to work rows that are actually stranded: a work-like ``type``, a NULL
1040
+ ``review_verdict`` of their own, and a review stage that demonstrably ran
1041
+ (``review_state`` in ``dispatched``/``done``). Rows at ``pending`` /
1042
+ ``advisory`` / NULL are untouched — their review hasn't run or was waived.
1043
+
1044
+ Idempotent, and runs on every connection open like
1045
+ :func:`_migrate_gate_order`: after the first pass every candidate has a
1046
+ non-NULL ``review_verdict``, so the ``UPDATE`` matches nothing. Returns the
1047
+ number of rows repaired (0 on the steady state) for logging/tests.
1048
+ """
1049
+ # Latest verdict-carrying review for this work row. `dispatched_at DESC`
1050
+ # picks the most recent round when a work row was reviewed more than once.
1051
+ # Deliberately NOT filtered on `r.status='done'`: #1122's review row is
1052
+ # `failed`, and a verdict recovered onto a failed row by hand (#617's
1053
+ # transcript recovery, `coord diagnose --stage review`) was still earned by
1054
+ # a real review and is exactly what we want to preserve.
1055
+ _LATEST_VERDICT = """
1056
+ SELECT r.review_verdict FROM assignments r
1057
+ WHERE r.review_of_assignment_id = assignments.assignment_id
1058
+ AND r.type = 'review'
1059
+ AND r.review_verdict IS NOT NULL
1060
+ AND r.review_verdict != ''
1061
+ ORDER BY r.dispatched_at DESC
1062
+ LIMIT 1
1063
+ """
1064
+ cur = conn.execute(
1065
+ f"""
1066
+ UPDATE assignments
1067
+ SET review_verdict = ({_LATEST_VERDICT}),
1068
+ review_state = 'done'
1069
+ WHERE type IN ('work', 'mock-author', 'test-author')
1070
+ AND review_verdict IS NULL
1071
+ AND review_state IN ('dispatched', 'done')
1072
+ AND ({_LATEST_VERDICT}) IS NOT NULL
1073
+ """
1074
+ )
1075
+ conn.commit()
1076
+ return cur.rowcount if cur.rowcount and cur.rowcount > 0 else 0