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/pipeline.py ADDED
@@ -0,0 +1,481 @@
1
+ """Pipeline stage tracking for the assignment lifecycle.
2
+
3
+ Every "work" assignment passes through a series of approval gates before it
4
+ is considered fully shipped. This module computes a ``PipelineView`` that
5
+ describes exactly where an assignment sits in the pipeline so the dashboard
6
+ (and any other consumer) can show status and offer one-click gate actions.
7
+
8
+ The pipeline is intentionally pure-computation: ``compute_pipeline`` takes
9
+ already-loaded data structures and returns a value object — no I/O, no side
10
+ effects. The dashboard server wires the real persistence layer.
11
+
12
+ Pipeline stages (in order): ``coding`` prepended, then whichever gates
13
+ ``config.pipeline.default_gates`` lists, in that order — e.g. the shipped
14
+ default ``["test", "review", "merge"]`` yields ``coding → smoke → review →
15
+ merge`` (see ``_STAGE_NAME_TO_GATE_NAME`` below for the ``smoke``/``test``
16
+ alias). #1724: this used to be hardcoded as ``coding → review → smoke →
17
+ merge``, which both mislabelled the gate and inverted Test/Review relative
18
+ to config.
19
+
20
+ Each stage may be "waiting", "active", "completed", or "skipped". The
21
+ ``required_gates`` field on the assignment (defaulting to
22
+ ``config.pipeline.default_gates``) controls which intermediate stages are
23
+ enforced — stages not in required_gates are marked "skipped" in the view.
24
+
25
+ ``current_stage`` is a fine-grained state name (e.g. "review_running",
26
+ "smoke_passed") that the UI uses for colour-coding and gate routing beyond
27
+ what the coarse PipelineStage status captures.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from dataclasses import dataclass, field
33
+ from typing import TYPE_CHECKING
34
+
35
+ if TYPE_CHECKING:
36
+ from coord.config import Config
37
+ from coord.merge_queue import QueuedMerge
38
+ from coord.models import Assignment, Board
39
+
40
+
41
+ # ── Data structures ─────────────────────────────────────────────────────────
42
+
43
+
44
+ @dataclass
45
+ class PipelineStage:
46
+ name: str
47
+ status: str # "active" | "completed" | "skipped" | "waiting"
48
+ is_current: bool = False
49
+
50
+
51
+ @dataclass
52
+ class PipelineGate:
53
+ action: str # e.g. "dispatch_review", "enqueue", "merge", "retry"
54
+ label: str # button text shown in the dashboard
55
+ endpoint: str # API path to POST to
56
+
57
+
58
+ @dataclass
59
+ class PipelineView:
60
+ assignment_id: str
61
+ issue_number: int
62
+ issue_title: str
63
+ repo_name: str
64
+ machine_name: str
65
+ stages: list[PipelineStage]
66
+ current_stage: str
67
+ available_gates: list[PipelineGate]
68
+ progress_pct: int # 0-100
69
+ # True when the review assignment completed but its findings have not yet
70
+ # been posted to GitHub (review_posted_at is None on the review assignment).
71
+ # The dashboard shows a ⚠ indicator and a "Post Findings" retry button.
72
+ review_findings_pending: bool = False
73
+ # Cached review verdict from the linked review assignment
74
+ # ("approve" | "request-changes" | None). Populated when the reviewer has
75
+ # completed and emitted a structured REVIEW_VERDICT block.
76
+ review_verdict: str | None = None
77
+ # #1456: when the coordinator overrode the reviewer's verdict (the #476
78
+ # approve-with-nits gate), the reviewer's OWN verdict and the evidence that
79
+ # justified the override. Both None when `review_verdict` is the
80
+ # reviewer's own call — which is the normal case. Exposed here so the
81
+ # dashboard/TUI can render "approve (coordinator override of
82
+ # request-changes)" instead of a bare approve the reviewer never gave.
83
+ review_verdict_original: str | None = None
84
+ review_verdict_override_reason: str | None = None
85
+ # Full text body of the review findings as cached by notify/auto_loop.
86
+ # Populated from the DB review_findings column so the phone detail screen
87
+ # can render them without a slow GitHub re-fetch. None when no findings
88
+ # have been cached yet.
89
+ review_findings_body: str | None = None
90
+ # Human-driven Test-gate verdict for the work assignment.
91
+ # Mirrors Assignment.test_state: None | "passed" | "failed" | "skipped".
92
+ # Populated by compute_pipeline so the phone detail screen can display the
93
+ # current verdict and the Record Test Verdict gate can offer Pass/Fail.
94
+ test_verdict: str | None = None
95
+ # #846: True when this assignment is running past its wall-clock
96
+ # threshold or thrashing through fix/review rounds without converging
97
+ # (see coord.notify.attention_signal — same detection core the
98
+ # coordinator's GitHub-comment backstop uses). Detection + surfacing
99
+ # only; nothing is killed or reassigned automatically.
100
+ needs_attention: bool = False
101
+ needs_attention_reason: str | None = None # "wall_clock" | "non_convergence" | None
102
+ needs_attention_detail: str | None = None
103
+ # #1218: most-recent completion timestamp available for this pipeline
104
+ # item, so the dashboard can sort the collapsed "Work done" section by
105
+ # recency. Mirrors the latest of Assignment.finished_at across the work
106
+ # assignment and any linked review/smoke assignment (whichever most
107
+ # recently made progress) — see compute_pipeline for the derivation.
108
+ # None only when nothing involved has finished yet (still coding).
109
+ finished_at: float | None = None
110
+
111
+
112
+ # ── Canonical gate naming (#1724) ────────────────────────────────────────────
113
+ #
114
+ # This module's internal stage/group name for the smoke-test gate is
115
+ # "smoke" — it matches ``assignment.type == "smoke"``, the ``_STAGE_GROUP``
116
+ # keys below, and the fine-grained "smoke_running"/"smoke_passed"/
117
+ # "smoke_failed" ``current_stage`` values, all of which describe the
118
+ # *smoke assignment*, not the gate. ``config.pipeline.default_gates`` (and
119
+ # ``Assignment.required_gates``) instead call this gate "test" — same
120
+ # convention ``coord/merge_queue.py``'s ``requires_smoke``/``_bypassed_gates``
121
+ # and ``coord/stage_projection.py`` (the TUI projection) already use. #1724
122
+ # found the code below comparing "smoke" against ``required_gates`` directly,
123
+ # so the membership check always failed. Translate at this one seam instead
124
+ # of scattering "smoke"/"test" string comparisons through the stage logic.
125
+ _STAGE_NAME_TO_GATE_NAME: dict[str, str] = {"smoke": "test"}
126
+ _GATE_NAME_TO_STAGE_NAME: dict[str, str] = {
127
+ v: k for k, v in _STAGE_NAME_TO_GATE_NAME.items()
128
+ }
129
+
130
+
131
+ def _gate_name_for(stage_name: str) -> str:
132
+ """Translate an internal stage/group name to the ``default_gates`` /
133
+ ``required_gates`` name it corresponds to (identity for stages that
134
+ aren't renamed, e.g. "review"/"merge")."""
135
+ return _STAGE_NAME_TO_GATE_NAME.get(stage_name, stage_name)
136
+
137
+
138
+ # ── Stage progression constants ──────────────────────────────────────────────
139
+
140
+ # Maps a detailed current_stage name to the coarse display stage group.
141
+ # Used to determine which PipelineStage.is_current should be set.
142
+ _STAGE_GROUP: dict[str, str | None] = {
143
+ "coding": "coding",
144
+ "failed": "coding", # failure occurred in coding step
145
+ "done": None, # between steps, nothing highlighted
146
+ "review_running": "review",
147
+ "review_done": "review",
148
+ "review_failed": "review",
149
+ "smoke_running": "smoke",
150
+ "smoke_passed": "smoke",
151
+ "smoke_failed": "smoke",
152
+ "merge_ready": "merge",
153
+ "merging": "merge",
154
+ "merged": "merge",
155
+ }
156
+
157
+ # Approximate progress percentage for each detailed stage.
158
+ _PROGRESS: dict[str, int] = {
159
+ "coding": 10,
160
+ "failed": 5,
161
+ "done": 20,
162
+ "review_running": 35,
163
+ "review_done": 50,
164
+ "review_failed": 35,
165
+ "smoke_running": 60,
166
+ "smoke_passed": 70,
167
+ "smoke_failed": 60,
168
+ "merge_ready": 80,
169
+ "merging": 90,
170
+ "merged": 100,
171
+ }
172
+
173
+
174
+ # ── Core computation ─────────────────────────────────────────────────────────
175
+
176
+
177
+ def compute_pipeline(
178
+ assignment: "Assignment",
179
+ board: "Board",
180
+ merge_queue_items: list, # list[QueuedMerge]
181
+ config: "Config",
182
+ *,
183
+ review_findings_body: str | None = None,
184
+ now: float | None = None,
185
+ ) -> PipelineView:
186
+ """Return a PipelineView for a type='work' assignment.
187
+
188
+ Scans ``board.active``, ``board.completed``, and ``merge_queue_items`` to
189
+ determine downstream state. Pure computation — no I/O.
190
+
191
+ *now* pins the clock the #846 ``needs_attention`` check reads (it is the
192
+ one wall-clock-dependent field in the view — its ``detail`` string embeds
193
+ "Running Nm"). ``None`` means ``time.time()``, i.e. today's behaviour for
194
+ every production caller; the ``coord web --fixture`` seeded-board server
195
+ (#1538) passes the fixture's frozen clock so two runs against the same
196
+ fixture produce byte-identical ``/api/pipeline`` output.
197
+ """
198
+ aid = assignment.assignment_id or ""
199
+
200
+ # Resolve effective required_gates: assignment field → config default.
201
+ required_gates: list[str] = (
202
+ assignment.required_gates
203
+ if assignment.required_gates
204
+ else list(config.pipeline.default_gates)
205
+ )
206
+
207
+ # ── Find linked downstream assignments ──────────────────────────────────
208
+ all_assignments = list(board.active) + list(board.completed)
209
+
210
+ review_assignment: Assignment | None = next(
211
+ (
212
+ a for a in all_assignments
213
+ if a.review_of_assignment_id == aid and a.type == "review"
214
+ ),
215
+ None,
216
+ )
217
+ smoke_assignment: Assignment | None = next(
218
+ (
219
+ a for a in all_assignments
220
+ if a.review_of_assignment_id == aid and a.type == "smoke"
221
+ ),
222
+ None,
223
+ )
224
+
225
+ # Find merge queue entry for this assignment.
226
+ mq_entry = next(
227
+ (m for m in merge_queue_items if m.assignment_id == aid),
228
+ None,
229
+ )
230
+
231
+ # ── Determine current_stage ──────────────────────────────────────────────
232
+ current_stage: str
233
+ if assignment.status == "running":
234
+ current_stage = "coding"
235
+ elif assignment.status == "failed":
236
+ current_stage = "failed"
237
+ elif assignment.status == "merged":
238
+ # #2084: `coord.reconcile`'s GitHub-truth sweep (`work_is_terminal`)
239
+ # flips a work assignment's OWN `status` to "merged" independently of
240
+ # `merge_queue` — it catches PRs merged outside `coord merge` (a
241
+ # manual `gh pr merge`, or any path that never touched the queue) as
242
+ # well as queue entries reconcile confirmed landed. Before this
243
+ # branch existed, "merged" fell through to the bare `else` below and
244
+ # was indistinguishable from a work item that had never been
245
+ # dispatched anywhere — offering "Dispatch Review"/"Queue for
246
+ # Merge"/"Record Test Verdict" on code that was already reviewed,
247
+ # tested, and merged (the dominant share of #2084's false-positive
248
+ # `available_gates`). `mq_entry`-based MERGED detection below still
249
+ # covers the narrower window between a queue drain and reconcile
250
+ # catching up.
251
+ current_stage = "merged"
252
+ elif assignment.status in ("done", "pending"):
253
+ # Evaluate from most advanced to least advanced.
254
+ if mq_entry is not None:
255
+ from coord.merge_queue import MERGED, MERGING
256
+
257
+ if mq_entry.state == MERGED:
258
+ current_stage = "merged"
259
+ elif mq_entry.state == MERGING:
260
+ current_stage = "merging"
261
+ else:
262
+ current_stage = "merge_ready"
263
+ elif assignment.smoke_test == "pass":
264
+ current_stage = "smoke_passed"
265
+ elif assignment.smoke_test == "fail":
266
+ current_stage = "smoke_failed"
267
+ elif smoke_assignment is not None and smoke_assignment.status in ("running", "pending"):
268
+ current_stage = "smoke_running"
269
+ elif smoke_assignment is not None and smoke_assignment.status == "failed":
270
+ # Smoke assignment itself failed (infra failure) — not the same as
271
+ # the smoke *test* failing, but still unblocks the work assignment.
272
+ current_stage = "smoke_failed"
273
+ elif smoke_assignment is not None and smoke_assignment.status in ("done",):
274
+ # Smoke assignment completed but smoke_test not yet set — treat as passed.
275
+ current_stage = "smoke_passed"
276
+ elif review_assignment is not None:
277
+ if review_assignment.status in ("running", "pending", "finalizing"):
278
+ # #1566: "finalizing" is a review row whose agent finished but
279
+ # whose verdict hasn't been parsed/posted by `coord notify`
280
+ # yet. Treating it as "review_done" here would surface the
281
+ # exact "no verdict, indistinguishable from dropped" gap
282
+ # #1566 was filed over — on the phone dashboard this also
283
+ # matters because review_done + no verdict adds the
284
+ # "record-review-verdict" gate below, inviting an operator to
285
+ # manually stamp a verdict for a review still being parsed.
286
+ current_stage = "review_running"
287
+ elif review_assignment.status == "failed":
288
+ current_stage = "review_failed"
289
+ else:
290
+ current_stage = "review_done"
291
+ else:
292
+ current_stage = "done"
293
+ else:
294
+ current_stage = "done"
295
+
296
+ # ── Build stages list ────────────────────────────────────────────────────
297
+ current_group = _STAGE_GROUP.get(current_stage)
298
+ stages: list[PipelineStage] = []
299
+
300
+ # Stages that appear after the current group are "waiting"; those before are
301
+ # "completed". "active" is the current group if still in progress.
302
+
303
+ # Define ordering for stage progression. #1724: derived from
304
+ # config.pipeline.default_gates (translating each configured gate name
305
+ # back to its internal stage name) rather than hardcoded, so the
306
+ # displayed order can't drift from the order the rest of the pipeline
307
+ # actually enforces. "coding" (the work stage) always prepends — it is
308
+ # never a configurable gate.
309
+ stage_order = ["coding"] + [
310
+ _GATE_NAME_TO_STAGE_NAME.get(gate, gate) for gate in config.pipeline.default_gates
311
+ ]
312
+ current_group_idx = (
313
+ stage_order.index(current_group) if current_group in stage_order else -1
314
+ )
315
+
316
+ for i, stage_name in enumerate(stage_order):
317
+ if stage_name != "coding" and _gate_name_for(stage_name) not in required_gates:
318
+ stages.append(PipelineStage(name=stage_name, status="skipped", is_current=False))
319
+ continue
320
+
321
+ is_current = current_group == stage_name
322
+
323
+ if i < current_group_idx:
324
+ # This stage is before the current group → completed.
325
+ status = "completed"
326
+ elif i == current_group_idx:
327
+ # This is the current stage.
328
+ if current_stage == "merged":
329
+ status = "completed" # final state, show as completed
330
+ elif current_stage in ("smoke_passed", "review_done"):
331
+ status = "completed" # sub-stage "done", ready for next gate
332
+ elif current_stage == "failed":
333
+ status = "active" # coding failed, still "active" (needs attention)
334
+ else:
335
+ status = "active"
336
+ else:
337
+ # Future stage.
338
+ status = "waiting"
339
+
340
+ stages.append(PipelineStage(name=stage_name, status=status, is_current=is_current))
341
+
342
+ # ── Compute available gate actions ───────────────────────────────────────
343
+ _EP = "/api/pipeline/action"
344
+ available_gates: list[PipelineGate] = []
345
+
346
+ # #2084: current_stage collapses to "done" for two different populations
347
+ # — a work assignment that genuinely just finished coding with nothing
348
+ # dispatched downstream yet (status in "done"/"pending" — the fresh case
349
+ # the gates below are written for), and any OTHER terminal status
350
+ # (chiefly "advisory": a 0-commit clean exit with no code to test,
351
+ # review, or merge; also a defensive catch-all for any status this
352
+ # module doesn't otherwise recognize) that fell through to the bare
353
+ # `else` above. The latter has nothing left to gate — a human can't
354
+ # meaningfully "Record Test Verdict" or "Dispatch Review" on a worker
355
+ # that made no changes — so only offer these gates for the genuinely
356
+ # fresh population.
357
+ if current_stage == "done" and assignment.status in ("done", "pending"):
358
+ # Offer the human Test-gate so the phone can record a verdict before
359
+ # review auto-dispatch fires (Test precedes Review in the pipeline).
360
+ available_gates.append(PipelineGate("test-verdict", "Record Test Verdict", _EP))
361
+ # Only offer review/smoke gates if those stages are actually required.
362
+ if "review" in required_gates:
363
+ available_gates.append(PipelineGate("dispatch_review", "Dispatch Review", _EP))
364
+ if _gate_name_for("smoke") in required_gates:
365
+ available_gates.append(PipelineGate("dispatch_smoke", "Dispatch Smoke", _EP))
366
+ available_gates.append(PipelineGate("enqueue", "Queue for Merge", _EP))
367
+ elif current_stage == "review_failed":
368
+ available_gates.append(PipelineGate("dispatch_review", "Dispatch Review", _EP))
369
+ elif current_stage == "review_done":
370
+ available_gates.append(PipelineGate("enqueue", "Queue for Merge", _EP))
371
+ if review_assignment is not None and review_assignment.review_posted_at is None:
372
+ available_gates.append(PipelineGate("post_findings", "Post Findings", _EP))
373
+ # Offer the phone a way to record a review verdict manually when the
374
+ # automated reviewer produced no structured REVIEW_VERDICT block.
375
+ if review_assignment is not None and review_assignment.review_verdict is None:
376
+ available_gates.append(
377
+ PipelineGate("record-review-verdict", "Record Review Verdict", _EP)
378
+ )
379
+ # Offer a headless fix when the review returned request-changes (#699):
380
+ # the phone can dispatch a fix worker without attending a terminal.
381
+ if (
382
+ review_assignment is not None
383
+ and review_assignment.review_verdict == "request-changes"
384
+ ):
385
+ available_gates.append(PipelineGate("dispatch_fix", "Dispatch Fix", _EP))
386
+ elif current_stage == "smoke_passed":
387
+ available_gates.append(PipelineGate("enqueue", "Queue for Merge", _EP))
388
+ elif current_stage == "merge_ready":
389
+ available_gates.append(PipelineGate("merge", "Merge", _EP))
390
+ elif current_stage == "smoke_failed":
391
+ available_gates.append(PipelineGate("dispatch_fix", "Dispatch Fix", _EP))
392
+ elif current_stage == "failed":
393
+ available_gates.append(PipelineGate("retry", "Retry", _EP))
394
+
395
+ progress_pct = _PROGRESS.get(current_stage, 0)
396
+
397
+ # Determine whether review findings need to be posted.
398
+ review_findings_pending = (
399
+ review_assignment is not None
400
+ and review_assignment.status == "done"
401
+ and review_assignment.review_posted_at is None
402
+ )
403
+
404
+ # Derive the cached review verdict from the in-memory review assignment
405
+ # (no I/O — review_assignment is already fetched from the board above).
406
+ review_verdict = review_assignment.review_verdict if review_assignment else None
407
+ # #1456: carry the override audit trail alongside the effective verdict.
408
+ review_verdict_original = (
409
+ review_assignment.review_verdict_original if review_assignment else None
410
+ )
411
+ review_verdict_override_reason = (
412
+ review_assignment.review_verdict_override_reason if review_assignment else None
413
+ )
414
+
415
+ # Expose the human Test-gate verdict so the phone detail screen can display
416
+ # it and the record-test-verdict gate can be conditionally shown.
417
+ # Reads from Assignment.test_state — None | "passed" | "failed" | "skipped"
418
+ # | "running" (#1395: a transient, non-verdict marker an unattended driver
419
+ # sets while it runs the suite locally; deliberately NOT filtered out
420
+ # here — it's real signal for the phone screen too, and `test_verdict ==
421
+ # "failed"` / `!= None` (the only comparisons made against this field)
422
+ # both already do the right thing while it's "running").
423
+ test_verdict = assignment.test_state
424
+
425
+ # #846: long-running / non-converging signal, shared with the coordinator
426
+ # backstop (coord.notify.detect_needs_attention) via the same pure
427
+ # coord.notify.attention_signal core — local import to avoid a module-load
428
+ # cycle (mirrors the coord.merge_queue import above).
429
+ from coord.notify import attention_signal # noqa: PLC0415
430
+
431
+ needs_attention_reason, needs_attention_detail = attention_signal(
432
+ assignment_type=assignment.type,
433
+ status=assignment.status,
434
+ dispatched_at=assignment.dispatched_at,
435
+ review_iteration=assignment.review_iteration,
436
+ config=config,
437
+ now=now,
438
+ provider_name=assignment.provider_name,
439
+ review_of_assignment_id=assignment.review_of_assignment_id,
440
+ )
441
+
442
+ # #1218: most-recent finished_at across the work assignment and any
443
+ # linked review/smoke assignment — whichever last made progress. This is
444
+ # what the dashboard sorts the collapsed "Work done" section by, so it
445
+ # needs to advance as an item moves review_done → smoke_passed →
446
+ # merge_ready, not freeze at the moment the work assignment itself
447
+ # finished.
448
+ finished_at = max(
449
+ (
450
+ ts
451
+ for ts in (
452
+ assignment.finished_at,
453
+ review_assignment.finished_at if review_assignment else None,
454
+ smoke_assignment.finished_at if smoke_assignment else None,
455
+ )
456
+ if ts is not None
457
+ ),
458
+ default=None,
459
+ )
460
+
461
+ return PipelineView(
462
+ assignment_id=aid,
463
+ issue_number=assignment.issue_number,
464
+ issue_title=assignment.issue_title,
465
+ repo_name=assignment.repo_name,
466
+ machine_name=assignment.machine_name,
467
+ stages=stages,
468
+ current_stage=current_stage,
469
+ available_gates=available_gates,
470
+ progress_pct=progress_pct,
471
+ review_findings_pending=review_findings_pending,
472
+ review_verdict=review_verdict,
473
+ review_verdict_original=review_verdict_original,
474
+ review_verdict_override_reason=review_verdict_override_reason,
475
+ review_findings_body=review_findings_body,
476
+ test_verdict=test_verdict,
477
+ needs_attention=needs_attention_reason is not None,
478
+ needs_attention_reason=needs_attention_reason,
479
+ needs_attention_detail=needs_attention_detail,
480
+ finished_at=finished_at,
481
+ )