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/claim.py ADDED
@@ -0,0 +1,353 @@
1
+ """Issue claim detection — prevent two agents picking up the same issue.
2
+
3
+ Without this, two coordinator runs (e.g. two operators, or a manual dispatch
4
+ racing an auto-dispatch) can both kick off work on the same issue because
5
+ neither side notices the other. The fix is a simple pre-dispatch check:
6
+
7
+ 1. Is there an active board assignment for `(issue_number, repo_name)`?
8
+ 2. Does the remote already have a branch matching `issue-{N}-*` for this
9
+ repo? (Workers create branches in that shape — its existence is treated
10
+ as a claim signal even if our board doesn't know about it yet.)
11
+
12
+ If either is true, the dispatch site refuses with a clear message. Reviews
13
+ and smoke tests run *after* a worker has pushed a branch, so they don't
14
+ participate in this check — they have their own dedupe (no two reviews of
15
+ the same completed assignment).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from dataclasses import dataclass
22
+ from typing import Callable
23
+
24
+ from coord.models import Board
25
+
26
+
27
+ # A branch_lookup takes (repo_github, issue_number) and returns the matching
28
+ # branch names. Injected so tests don't hit GitHub.
29
+ BranchLookup = Callable[[str, int], list[str]]
30
+
31
+
32
+ @dataclass
33
+ class Claim:
34
+ """Evidence that an issue is already being worked on."""
35
+
36
+ issue_number: int
37
+ repo_name: str
38
+ source: str # "board" or "remote_branch"
39
+ machine_name: str | None = None
40
+ branch: str | None = None
41
+ assignment_id: str | None = None
42
+
43
+
44
+ def find_work_claim(
45
+ issue_number: int,
46
+ repo_name: str,
47
+ repo_github: str,
48
+ board: Board,
49
+ *,
50
+ branch_lookup: BranchLookup | None = None,
51
+ ) -> Claim | None:
52
+ """Return a `Claim` if dispatching new work for this issue would conflict.
53
+
54
+ Checks the board first (cheap, local) then the remote (one `gh` call).
55
+ Returns the first hit — callers only need to know *that* it's claimed,
56
+ not enumerate every claim source.
57
+ """
58
+ for a in board.active:
59
+ if a.issue_number == issue_number and a.repo_name == repo_name:
60
+ if a.status == "failed":
61
+ continue
62
+ # #1059 fix: "chat"/"troubleshoot" are human-attended, read-only
63
+ # diagnostic sessions (#628/#676, coord/issue_store.py's
64
+ # `("chat", "troubleshoot")` advisory-only special case) — they
65
+ # never commit code and are always finalized as "advisory"
66
+ # regardless of how they end. A stale one (session died without
67
+ # the TUI ever finalizing it, e.g. a "Chat about issue" left
68
+ # open on an epic) must not permanently block a real dispatch
69
+ # for the same issue, the way a genuine work/review/smoke claim
70
+ # should. Without this, a leftover chat row on a tracking issue
71
+ # wedged every future "Dispatch Gate A mock" attempt with
72
+ # "already in flight" against a claim with no backing worker and
73
+ # no way to clear it short of `coord diagnose`.
74
+ if a.type in ("plan", "review", "smoke", "chat", "troubleshoot"):
75
+ continue
76
+ return Claim(
77
+ issue_number=issue_number,
78
+ repo_name=repo_name,
79
+ source="board",
80
+ machine_name=a.machine_name,
81
+ branch=a.branch,
82
+ assignment_id=a.assignment_id,
83
+ )
84
+
85
+ lookup = branch_lookup or _default_branch_lookup
86
+ branches = lookup(repo_github, issue_number)
87
+ if branches:
88
+ return Claim(
89
+ issue_number=issue_number,
90
+ repo_name=repo_name,
91
+ source="remote_branch",
92
+ branch=branches[0],
93
+ )
94
+ return None
95
+
96
+
97
+ def claim_message(claim: Claim) -> str:
98
+ """Human-readable error for a refused dispatch."""
99
+ if claim.source == "board":
100
+ parts = [
101
+ f"issue #{claim.issue_number} ({claim.repo_name}) already claimed",
102
+ ]
103
+ if claim.machine_name:
104
+ parts.append(f"by {claim.machine_name}")
105
+ if claim.assignment_id:
106
+ parts.append(f"(assignment {claim.assignment_id})")
107
+ if claim.branch:
108
+ parts.append(f"on branch {claim.branch}")
109
+ return " ".join(parts)
110
+ return (
111
+ f"issue #{claim.issue_number} ({claim.repo_name}) already claimed: "
112
+ f"remote branch {claim.branch} exists"
113
+ )
114
+
115
+
116
+ # ── Dedupe for downstream auto-dispatch (review / smoke) ────────────────────
117
+
118
+
119
+ def has_active_followup(
120
+ board: Board,
121
+ *,
122
+ of_assignment_id: str | None,
123
+ assignment_type: str,
124
+ ) -> bool:
125
+ """True if `board.active` already has a review/smoke of the given work.
126
+
127
+ Used by `dispatch_review`/`dispatch_smoke` to skip when one is already in
128
+ flight. The check is by `review_of_assignment_id` rather than `(issue,
129
+ repo)` so that re-dispatching after a worker re-runs the same issue
130
+ isn't accidentally blocked.
131
+ """
132
+ if of_assignment_id is None:
133
+ return False
134
+ for a in board.active:
135
+ if a.type != assignment_type:
136
+ continue
137
+ if a.review_of_assignment_id == of_assignment_id:
138
+ return True
139
+ return False
140
+
141
+
142
+ def has_active_branch_followup(
143
+ board: Board,
144
+ *,
145
+ repo_name: str,
146
+ branch: str | None,
147
+ assignment_type: str,
148
+ ) -> bool:
149
+ """True if `board.active` already has a follow-up for this ``(repo, branch)``.
150
+
151
+ #1819: the branch-scoped peer of :func:`has_active_followup`. The unit a
152
+ Test-stage run actually measures is the **branch**, not the work row that
153
+ happened to push it — and after a fix round (``coord fix`` / ``--fix-of``
154
+ reuses the branch **by design**) one branch carries *two* ``work`` rows.
155
+ The ``of_assignment_id``-keyed dedupe asks "does *this row* already have a
156
+ smoke in flight?" and answers "no" for the sibling row, so both rows
157
+ dispatched their own Test worker: two machines running the identical suite
158
+ on the identical branch, racing to write a verdict (observed live on
159
+ #1797, 2026-08-04).
160
+
161
+ Deliberately NOT applied to reviews. A review is a judgement *of a work
162
+ row's contribution* — the fix round genuinely needs its own review even
163
+ though it shares a branch with the round it fixes — whereas a smoke run
164
+ on branch B is the same measurement no matter which row asked for it.
165
+ """
166
+ if not branch:
167
+ return False
168
+ for a in board.active:
169
+ if a.type != assignment_type:
170
+ continue
171
+ if a.repo_name == repo_name and a.branch == branch:
172
+ return True
173
+ return False
174
+
175
+
176
+ def superseding_work_row(board: Board, assignment) -> "Assignment | None":
177
+ """The later work-like row on the same ``(repo, branch)``, if any (#1819).
178
+
179
+ A ``work`` row that another work-like row was dispatched *after*, on the
180
+ same branch, is **superseded**: the branch's current content is the later
181
+ row's output, so the earlier row is not a meaningful dispatch target for
182
+ the Test stage. Dispatching against it burns a machine re-testing a branch
183
+ it did not produce and lands the verdict on a row nothing gates on.
184
+
185
+ Ordering is by ``dispatched_at``, with the assignment id as a deterministic
186
+ tie-break so two rows stamped in the same second still order stably. A
187
+ ``failed`` or ``advisory`` later row does not supersede — both are
188
+ terminal no-op outcomes (``_ZERO_COMMIT_TYPES`` in ``coord/agent.py``:
189
+ zero commits pushed, e.g. "already fixed" or a graceful usage-limit exit)
190
+ that leave the branch exactly as the earlier row left it, so the earlier
191
+ row is still the branch's author. Without excluding ``advisory`` here, a
192
+ `--fix-of` round that lands zero commits would falsely mark the row it
193
+ was fixing as superseded — and since an ``advisory`` row is itself never
194
+ a valid dispatch target (``dispatch_smoke`` requires ``status == "done"``),
195
+ the branch would then never get *any* Test dispatch.
196
+
197
+ Returns the (newest) superseding row so callers can name it in a log line,
198
+ or ``None`` when *assignment* is the branch's current work row. Related but
199
+ distinct from #1277, which is the *display* side of the same idea.
200
+ """
201
+ from coord.models import WORK_LIKE_TYPES # noqa: PLC0415
202
+
203
+ branch = getattr(assignment, "branch", None)
204
+ if not branch:
205
+ return None
206
+ aid = getattr(assignment, "assignment_id", None)
207
+ key = (getattr(assignment, "dispatched_at", None) or 0.0, aid or "")
208
+
209
+ best = None
210
+ best_key: tuple[float, str] | None = None
211
+ for a in list(board.active) + list(board.completed):
212
+ if a is assignment:
213
+ continue
214
+ if a.type not in WORK_LIKE_TYPES:
215
+ continue
216
+ if a.status in ("failed", "advisory"):
217
+ continue
218
+ if a.repo_name != assignment.repo_name or a.branch != branch:
219
+ continue
220
+ other_aid = getattr(a, "assignment_id", None)
221
+ if other_aid is not None and other_aid == aid:
222
+ continue
223
+ other_key = (getattr(a, "dispatched_at", None) or 0.0, other_aid or "")
224
+ if other_key <= key:
225
+ continue
226
+ if best_key is None or other_key > best_key:
227
+ best, best_key = a, other_key
228
+ return best
229
+
230
+
231
+ def has_active_work_followup(
232
+ board: Board,
233
+ *,
234
+ repo_name: str,
235
+ issue_number: int,
236
+ ) -> bool:
237
+ """True if a work or conflict-fix assignment is actively running for (repo, issue).
238
+
239
+ Used before dispatching a review to skip when a coord-bounce fix is
240
+ actively rewriting the branch — dispatching a review against stale code
241
+ produces a verdict on code that's about to change and causes unnecessary
242
+ churn. The existing ``has_active_followup`` covers duplicate-review
243
+ dedupe; this covers the orthogonal case where a *work* re-run (not a
244
+ review) is live for the same issue.
245
+
246
+ Called from both the reconcile review-dispatch loop and ``dispatch_review``
247
+ for defence in depth.
248
+
249
+ #1553: keyed on the *effective* issue
250
+ (:func:`coord.models.effective_issue_number`), not the raw
251
+ ``issue_number``. Every oracle-loop acceptance slice under one milestone
252
+ shares the tracking issue's number, so keying on the raw field made an
253
+ in-flight fix for child A block review dispatch for child B — and for the
254
+ epic itself — even though they are unrelated pieces of work. With the
255
+ effective issue, a slice's follow-up only guards its own child.
256
+ """
257
+ from coord.models import effective_issue_number # noqa: PLC0415
258
+
259
+ _WORK_TYPES = frozenset({"work", "conflict-fix"})
260
+ for a in board.active:
261
+ if a.type not in _WORK_TYPES:
262
+ continue
263
+ if a.status == "failed":
264
+ continue
265
+ if a.repo_name == repo_name and effective_issue_number(a) == issue_number:
266
+ return True
267
+ return False
268
+
269
+
270
+ # ── Default branch lookup (uses gh) ─────────────────────────────────────────
271
+
272
+
273
+ def _default_branch_lookup(repo_github: str, issue_number: int) -> list[str]:
274
+ """Return remote branches whose name starts with `issue-{N}-`.
275
+
276
+ Uses `gh api repos/.../git/matching-refs/heads/issue-{N}-`. Empty result
277
+ on any lookup failure — we'd rather wave through a dispatch than block
278
+ on a transient GH error.
279
+ """
280
+ from coord import github_ops
281
+
282
+ try:
283
+ raw = github_ops._gh(
284
+ "api",
285
+ f"repos/{repo_github}/git/matching-refs/heads/issue-{issue_number}-",
286
+ )
287
+ except RuntimeError:
288
+ return []
289
+ try:
290
+ refs = json.loads(raw)
291
+ except ValueError:
292
+ return []
293
+ if not isinstance(refs, list):
294
+ return []
295
+ branches: list[str] = []
296
+ for r in refs:
297
+ if not isinstance(r, dict):
298
+ continue
299
+ ref = r.get("ref", "")
300
+ if isinstance(ref, str) and ref.startswith("refs/heads/"):
301
+ branches.append(ref[len("refs/heads/"):])
302
+ # Drop branches already fully merged into the default branch — a merged
303
+ # branch is finished work, not an active claim. A stale merged branch (e.g. a
304
+ # PR head that wasn't auto-deleted on merge) must not block new work on the
305
+ # issue forever (the chat→work block on a long-merged issue-N-* branch).
306
+ return _drop_merged_branches(repo_github, branches)
307
+
308
+
309
+ def _repo_default_branch(repo_github: str) -> str | None:
310
+ """The repo's default branch via the GH API, or None on any error."""
311
+ from coord import github_ops
312
+
313
+ try:
314
+ data = json.loads(github_ops._gh("api", f"repos/{repo_github}"))
315
+ except (RuntimeError, ValueError):
316
+ return None
317
+ val = data.get("default_branch") if isinstance(data, dict) else None
318
+ return val if isinstance(val, str) and val else None
319
+
320
+
321
+ def _drop_merged_branches(repo_github: str, branches: list[str]) -> list[str]:
322
+ """Filter out branches fully merged into the repo's default branch.
323
+
324
+ A branch with zero commits ahead of the default branch is finished work,
325
+ not an active claim, so it must not block a fresh dispatch. Conservative on
326
+ every uncertainty — unknown default branch, compare-API error, or a branch
327
+ that IS ahead — keeps the branch as a claim (fail toward blocking duplicate
328
+ work, never toward allowing it).
329
+ """
330
+ if not branches:
331
+ return branches
332
+ from coord import github_ops
333
+
334
+ default_branch = _repo_default_branch(repo_github)
335
+ if not default_branch:
336
+ return branches # can't determine merged-ness → keep all (conservative)
337
+ kept: list[str] = []
338
+ for b in branches:
339
+ if b == default_branch:
340
+ continue
341
+ try:
342
+ cmp = json.loads(
343
+ github_ops._gh(
344
+ "api", f"repos/{repo_github}/compare/{default_branch}...{b}"
345
+ )
346
+ )
347
+ ahead = cmp.get("ahead_by") if isinstance(cmp, dict) else None
348
+ except (RuntimeError, ValueError):
349
+ ahead = None
350
+ if ahead == 0:
351
+ continue # fully merged → not an active claim
352
+ kept.append(b) # ahead > 0, or unknown → keep (conservative)
353
+ return kept