code-coordinator 0.5.46__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. coord/worker_events.py +954 -0
@@ -0,0 +1,775 @@
1
+ """Test-gate commands: `test` (pull/record verdict), `test-plan`,
2
+ `set-test-mode`. Extracted from coord/cli.py (#747).
3
+
4
+ #615: the original ``@main.command("test", ...)`` "queue a smoke test"
5
+ command (`test_cmd`) was dead code even in the pre-#747 cli.py — the
6
+ `test` function below registers the same Click command name "test" and
7
+ always won (last registration in main.commands wins), so `test_cmd` could
8
+ never run. It also called `build_board()`/`load_board()`/`save_board()`
9
+ unconditionally, unlike every reachable board-mutating command, which are
10
+ now daemon-routed (see the #615 audit note via `coord context show
11
+ claude-coordinator 615`). Removed rather than migrated, since routing
12
+ dead code would be pure ceremony.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import subprocess
20
+ import sys
21
+ from pathlib import Path
22
+
23
+ import click
24
+
25
+ from coord.config import Config
26
+
27
+ from coord.commands._common import (
28
+ _apply_label_change,
29
+ _CONFIG_OPTION,
30
+ _load_config,
31
+ )
32
+
33
+
34
+ def _get_assignment_branch_head(
35
+ assignment_id: str,
36
+ config: "Config",
37
+ repo_path_fn: "Callable[[str, Config], Path | None]",
38
+ ) -> str | None:
39
+ """#349: Resolve the current HEAD SHA for an assignment's branch.
40
+
41
+ Looks up the assignment's repo_name + branch from the DB, finds the local
42
+ repo path via *repo_path_fn* (typically
43
+ ``coord.test_orchestrator.find_local_repo_path``), then runs
44
+ ``git rev-parse <branch>`` to get the SHA.
45
+
46
+ Returns ``None`` when the assignment is not found, has no branch set, the
47
+ local repo path can't be resolved, or git fails. The caller treats ``None``
48
+ as "HEAD unknown — skip staleness tracking".
49
+ """
50
+ import subprocess # noqa: PLC0415 — lazy import keeps startup fast
51
+ from coord.db import get_connection # noqa: PLC0415
52
+
53
+ conn = get_connection()
54
+ row = conn.execute(
55
+ "SELECT repo_name, branch FROM assignments WHERE assignment_id=?",
56
+ (assignment_id,),
57
+ ).fetchone()
58
+ if not row:
59
+ return None
60
+ repo_name: str = row["repo_name"] if hasattr(row, "keys") else row[0]
61
+ branch: str = (row["branch"] if hasattr(row, "keys") else row[1]) or ""
62
+ if not branch:
63
+ return None
64
+ local_path = repo_path_fn(repo_name, config)
65
+ if not local_path or not local_path.exists():
66
+ return None
67
+ try:
68
+ result = subprocess.run(
69
+ ["git", "rev-parse", branch],
70
+ cwd=str(local_path),
71
+ capture_output=True,
72
+ text=True,
73
+ timeout=10,
74
+ )
75
+ if result.returncode == 0:
76
+ return result.stdout.strip() or None
77
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
78
+ pass
79
+ return None
80
+
81
+
82
+ def _maybe_reconcile_branch(
83
+ assignment, repo_dir, *, original_error: str, config,
84
+ ):
85
+ """When `git checkout <db_branch>` fails, try to learn the PR's actual
86
+ head ref from GitHub and reconcile the DB.
87
+
88
+ Returns the new branch name when reconciliation succeeded (DB
89
+ updated + the reconciled branch verified on origin), or `None` when no
90
+ PR is associated, the gh call failed, the head ref matches what we
91
+ already had, or the reconciled ref is missing on origin. The caller
92
+ falls back to the original error in those cases.
93
+ """
94
+ from coord.db import get_connection
95
+
96
+ # Need a PR number to look up the head ref. Pull it from the
97
+ # merge_queue entry for this assignment.
98
+ aid = assignment.assignment_id
99
+ if not aid:
100
+ return None
101
+ conn = get_connection()
102
+ row = conn.execute(
103
+ "SELECT pr_number, repo_github FROM merge_queue "
104
+ "WHERE assignment_id=?",
105
+ (aid,),
106
+ ).fetchone()
107
+ if row is None:
108
+ return None
109
+ pr_number = row["pr_number"]
110
+ repo_github = row["repo_github"]
111
+ if pr_number is None or not repo_github:
112
+ return None
113
+
114
+ # Fetch the PR's actual head ref from GitHub. Returns the real
115
+ # branch name even when the DB has a stale slug. Routed through the
116
+ # github_ops seam (#1483) rather than a direct `gh` invocation here.
117
+ from coord import github_ops # noqa: PLC0415
118
+
119
+ real_branch = github_ops.get_pr_head_ref(repo_github, pr_number) or ""
120
+ if not real_branch:
121
+ return None
122
+ if real_branch == assignment.branch:
123
+ # The PR DOES point at the DB-recorded branch; checkout failed
124
+ # for some other reason (local-only clone, network, etc.).
125
+ # Don't pretend we fixed it.
126
+ return None
127
+
128
+ # Validate the reconciled branch exists on origin before writing it to
129
+ # the DB. #561: this MUST be non-mutating — never `git checkout` in the
130
+ # base checkout (it doubles as the live editable coordinator source).
131
+ # `git fetch origin` already ran in the caller, so origin/<branch> is
132
+ # current; a rev-parse verify confirms it without moving HEAD.
133
+ try:
134
+ subprocess.run(
135
+ ["git", "rev-parse", "--verify", f"origin/{real_branch}"],
136
+ cwd=str(repo_dir), check=True, capture_output=True, text=True,
137
+ )
138
+ except subprocess.CalledProcessError:
139
+ return None
140
+
141
+ # Persist the reconciled branch on both tables so future runs of
142
+ # coord test / coord merge / TUI etc. all see the right value.
143
+ conn.execute(
144
+ "UPDATE assignments SET branch=? WHERE assignment_id=?",
145
+ (real_branch, aid),
146
+ )
147
+ conn.execute(
148
+ "UPDATE merge_queue SET branch=? WHERE assignment_id=?",
149
+ (real_branch, aid),
150
+ )
151
+ conn.commit()
152
+
153
+ # Mute the unused 'original_error' / 'config' params — they're
154
+ # there for future use (e.g. logging context, post-back to GitHub).
155
+ _ = original_error
156
+ _ = config
157
+ return real_branch
158
+
159
+
160
+ @click.command(
161
+ "set-test-mode",
162
+ help=(
163
+ "Set the per-issue test-mode policy for headless Work sessions.\n\n"
164
+ "MODE must be 'smoke' (pause at the Test gate for a human-attended\n"
165
+ "interactive smoke agent, the default) or 'auto' (run the smoke test\n"
166
+ "headless and continue toward merge without stopping).\n\n"
167
+ "The policy is persisted as a 'test-mode:smoke' / 'test-mode:auto'\n"
168
+ "GitHub label. The TUI reads this label when a headless Work session\n"
169
+ "completes to decide whether to offer the interactive smoke agent or\n"
170
+ "auto-dispatch smoke.py. The label can be flipped at any time before\n"
171
+ "the issue reaches the Test gate.\n\n"
172
+ "REPO is the local repo name from coordinator.yml; ISSUE is the GH\n"
173
+ "issue number."
174
+ ),
175
+ )
176
+
177
+
178
+ @click.argument("repo")
179
+ @click.argument("issue", type=int)
180
+ @click.argument("mode", type=click.Choice(["smoke", "auto"]))
181
+ @_CONFIG_OPTION
182
+ def set_test_mode(repo: str, issue: int, mode: str, config_path: Path) -> None:
183
+ """#685: TUI test-mode dialog and right-click flip fire this command."""
184
+ from coord import github_ops # noqa: PLC0415
185
+
186
+ cfg = _load_config(config_path)
187
+ repo_entry = cfg.repo(repo)
188
+ if repo_entry is None:
189
+ click.echo(f"error: unknown repo {repo!r} (not in coordinator.yml)", err=True)
190
+ sys.exit(1)
191
+ slug = repo_entry.github
192
+
193
+ # Ensure the test-mode:* labels exist in the repo before we try to add
194
+ # them — `gh issue edit --add-label` fails with "label not found" when the
195
+ # label has never been created. `gh label create --force` is idempotent
196
+ # (no-ops if the label already exists, creates it if absent). Routed
197
+ # through the github_ops seam (#1483) rather than a direct `gh` call here.
198
+ _TEST_MODE_LABEL_COLOR = "0075ca" # default blue; matches GitHub's "documentation" label
199
+ for lbl in ("test-mode:smoke", "test-mode:auto"):
200
+ try:
201
+ github_ops.create_label(
202
+ slug, lbl,
203
+ color=_TEST_MODE_LABEL_COLOR,
204
+ description="coord: per-issue test-mode policy",
205
+ )
206
+ except RuntimeError:
207
+ pass # best-effort — label creation failure is surfaced when add-label fails
208
+
209
+ _apply_label_change(
210
+ repo, issue, config_path,
211
+ add={f"test-mode:{mode}"},
212
+ remove_if_present={
213
+ lbl for lbl in ("test-mode:smoke", "test-mode:auto")
214
+ if lbl != f"test-mode:{mode}"
215
+ },
216
+ success_message=f"#{issue} ({slug}) test mode set to '{mode}'",
217
+ no_op_message=f"#{issue} ({slug}) already has test-mode:{mode}",
218
+ )
219
+
220
+
221
+ def _test_worktree_path(assignment_id: str, repo_name: str) -> Path:
222
+ """#561: throwaway worktree path for `coord test`'s build (per assignment).
223
+
224
+ Lives under ``~/.coord/test-worktrees/`` — OUTSIDE the base checkout — so a
225
+ Build never moves the base checkout's branch (which doubles as the live
226
+ editable coordinator source).
227
+ """
228
+ from coord.state import COORD_DIR # noqa: PLC0415
229
+
230
+ return COORD_DIR / "test-worktrees" / f"{repo_name}-{assignment_id}"
231
+
232
+
233
+ def _remove_test_worktree(repo_dir: Path, wt_path: Path) -> None:
234
+ """Best-effort removal of a `coord test` worktree (+ prune admin refs)."""
235
+ import subprocess # noqa: PLC0415
236
+
237
+ if not wt_path.exists():
238
+ return
239
+ for args in (
240
+ ["git", "worktree", "remove", "--force", str(wt_path)],
241
+ ["git", "worktree", "prune"],
242
+ ):
243
+ try:
244
+ subprocess.run(
245
+ args, cwd=str(repo_dir), capture_output=True, text=True, timeout=30
246
+ )
247
+ except (subprocess.SubprocessError, OSError):
248
+ pass
249
+
250
+
251
+ def _stash_test_artifacts(repo, assignment, wt_path: Path) -> None:
252
+ """#1249: stash the built artifact right after `coord test`'s build
253
+ succeeds — the ONE stage that reliably runs `build_command` and produces
254
+ the exact binary the Test-stage `kind: "pull"` step (and the next
255
+ `--fix-of` re-test on this branch) wants to pull instead of rebuilding.
256
+
257
+ The Work-completion stash (`AgentServer._stash_artifacts`) fires right
258
+ before that worktree is removed, but a Work session isn't required to
259
+ run `build_command` — only `coord test` (and the interactive
260
+ `--smoke-of` path, already covered by `finalize_interactive_exit`) is.
261
+ Delegates to the same `stash_artifacts_for_branch` helper both of those
262
+ call, so one filter/copy/GC implementation serves all three call sites.
263
+
264
+ Best-effort and silent on the "nothing to do" cases (no configured
265
+ `artifact_paths`, no branch, no worktree on disk) — mirrors the guard
266
+ clauses in `AgentServer._stash_artifacts`.
267
+ """
268
+ if repo is None or not repo.artifact_paths:
269
+ return
270
+ if not assignment.branch:
271
+ return
272
+ if not wt_path.exists():
273
+ return
274
+
275
+ from coord.agent import stash_artifacts_for_branch # noqa: PLC0415
276
+ from coord.state import COORD_DIR # noqa: PLC0415
277
+
278
+ patterns = list(repo.artifact_paths)
279
+ copied = stash_artifacts_for_branch(
280
+ worktree_path=wt_path,
281
+ branch=assignment.branch,
282
+ repo_name=assignment.repo_name,
283
+ patterns=patterns,
284
+ state_dir=COORD_DIR,
285
+ assignment_id=assignment.assignment_id,
286
+ log_path=None,
287
+ )
288
+ if copied > 0:
289
+ click.echo(f" stashed {copied} artifact(s) for {assignment.branch!r}.")
290
+ else:
291
+ # #1249 review finding #2: the other two call sites
292
+ # (AgentServer._stash_artifacts, finalize_interactive_exit) pass a
293
+ # real log_path, so stash_artifacts_for_branch's #1248 "loud 0-copy"
294
+ # warning lands in the assignment log. This call site has no log
295
+ # file — `coord test` runs directly in the operator's terminal —
296
+ # so log_path=None above means that warning is silently swallowed.
297
+ # Echo it here instead, mirroring the same message.
298
+ click.echo(
299
+ f" warning: 0 artifact(s) matched {patterns!r} in {wt_path} — "
300
+ "check artifact_paths config and that the build actually "
301
+ "produced the expected outputs.",
302
+ err=True,
303
+ )
304
+
305
+
306
+ def _cleanup_test_worktree(cfg, assignment) -> None:
307
+ """Remove the test worktree for *assignment* (called on a pass/skip verdict).
308
+
309
+ Resolves the base checkout the same way the build path does; a no-op when no
310
+ worktree exists (e.g. a verdict recorded without a prior Build).
311
+ """
312
+ if not assignment.assignment_id:
313
+ return
314
+ repo_dir = _local_repo_dir(cfg, assignment.repo_name)
315
+ if repo_dir is None:
316
+ return
317
+ _remove_test_worktree(
318
+ repo_dir, _test_worktree_path(assignment.assignment_id, assignment.repo_name)
319
+ )
320
+
321
+
322
+ def _local_repo_dir(cfg, repo_name: str) -> Path | None:
323
+ """Resolve the base checkout for *repo_name* (local machine first, then any
324
+ machine that knows it). Returns an expanded ``Path`` or ``None``."""
325
+ import socket # noqa: PLC0415
326
+
327
+ hostname = socket.gethostname().split(".")[0]
328
+ local_machine = next(
329
+ (m for m in cfg.machines if m.name == hostname or m.host.split(".")[0] == hostname),
330
+ None,
331
+ )
332
+ repo_path = None
333
+ if local_machine:
334
+ repo_path = local_machine.repo_path(repo_name)
335
+ if repo_path is None:
336
+ for m in cfg.machines:
337
+ repo_path = m.repo_path(repo_name)
338
+ if repo_path:
339
+ break
340
+ return Path(repo_path).expanduser() if repo_path else None
341
+
342
+
343
+ def _restore_default_branch_after_test(cfg, assignment) -> None:
344
+ """#271 part 1: switch the local checkout back to the repo's
345
+ `default_branch` after a pass/skip verdict.
346
+
347
+ Resolves the repo path the same way `coord test`'s checkout step
348
+ does (local machine's `repo_paths` first, then any machine that
349
+ knows the repo). Best-effort: a failed `git checkout` is surfaced
350
+ as a warning but doesn't fail the verdict recording.
351
+ """
352
+ import socket # noqa: PLC0415
353
+ import subprocess # noqa: PLC0415
354
+ from pathlib import Path as _Path # noqa: PLC0415
355
+
356
+ if not assignment.branch:
357
+ # No branch was ever checked out — nothing to restore.
358
+ return
359
+
360
+ repo = cfg.repo(assignment.repo_name)
361
+ if repo is None or not repo.default_branch:
362
+ return
363
+
364
+ hostname = socket.gethostname().split(".")[0]
365
+ local_machine = next(
366
+ (m for m in cfg.machines if m.name == hostname or m.host.split(".")[0] == hostname),
367
+ None,
368
+ )
369
+ repo_path = None
370
+ if local_machine:
371
+ repo_path = local_machine.repo_path(assignment.repo_name)
372
+ if repo_path is None:
373
+ for m in cfg.machines:
374
+ repo_path = m.repo_path(assignment.repo_name)
375
+ if repo_path:
376
+ break
377
+ if repo_path is None:
378
+ return
379
+
380
+ repo_dir = _Path(repo_path).expanduser()
381
+ if not repo_dir.exists():
382
+ return
383
+
384
+ # Quick early-out: if the user is already on the default branch
385
+ # (e.g. they switched manually after running `coord test`), there's
386
+ # nothing to do and no need to announce a no-op.
387
+ try:
388
+ head = subprocess.run(
389
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
390
+ cwd=str(repo_dir), capture_output=True, text=True, timeout=5,
391
+ )
392
+ if head.returncode == 0 and head.stdout.strip() == repo.default_branch:
393
+ return
394
+ except (subprocess.TimeoutExpired, OSError):
395
+ # If we can't even check the current branch, don't try to switch.
396
+ return
397
+
398
+ try:
399
+ result = subprocess.run(
400
+ ["git", "checkout", repo.default_branch],
401
+ cwd=str(repo_dir), capture_output=True, text=True, timeout=15,
402
+ )
403
+ except (subprocess.TimeoutExpired, OSError) as e:
404
+ click.echo(f" warning: could not restore default branch: {e}", err=True)
405
+ return
406
+ if result.returncode != 0:
407
+ # Most common cause: dirty working tree from manual edits during
408
+ # testing. Surface it so the user can stash + retry manually.
409
+ click.echo(
410
+ f" warning: could not switch back to {repo.default_branch!r}: "
411
+ f"{result.stderr.strip()}",
412
+ err=True,
413
+ )
414
+ return
415
+ click.echo(f" restored: {repo.default_branch} in {repo_dir}")
416
+
417
+
418
+ @click.command(help="Pull a worker's branch locally for testing, or record a Test gate verdict.")
419
+ @click.argument("assignment_id")
420
+ @_CONFIG_OPTION
421
+ @click.option("--passed", "verdict", flag_value="pass", help="Mark Test gate as passed.")
422
+ @click.option("--fail", "verdict", flag_value="fail", help="Mark Test gate as failed.")
423
+ @click.option("--skipped", "verdict", flag_value="skip", help="Mark Test gate as skipped (trivial change).")
424
+ @click.option(
425
+ "--running", "verdict", flag_value="running",
426
+ help=(
427
+ "Mark the Test gate as in-progress (#1395). A transient, non-verdict "
428
+ "marker for a driver that runs the suite locally instead of through "
429
+ "dispatch_smoke — set right before the run starts so the TUI/board "
430
+ "reads the Test box Active instead of Pending for the run's "
431
+ "duration. Overwrite it with --passed/--fail/--skipped when the run "
432
+ "concludes; every gate treats it as 'no verdict yet'."
433
+ ),
434
+ )
435
+ @click.option("--reason", default="", help="Reason for failure or skip (used with --fail/--skipped).")
436
+ @click.option("--output", "output_file", type=click.Path(), default=None,
437
+ help="File with test output to store (used with --fail).")
438
+
439
+
440
+ def test(assignment_id: str, config_path: Path, verdict: str | None, reason: str, output_file: str | None) -> None:
441
+ from coord.board_service import read_board
442
+ from coord.state import record_test_verdict
443
+
444
+ cfg = _load_config(config_path)
445
+ # #590 Phase 2 / #1337: a thin client reads the board from the daemon (its
446
+ # local DB is empty) so the assignment resolves; the verdict is recorded
447
+ # via the single-row record_test_verdict on BOTH paths (it self-routes:
448
+ # daemon when board_service is set, direct local UPDATE otherwise).
449
+ board = read_board()
450
+
451
+ assignment = board.find_by_id(assignment_id)
452
+ if assignment is None:
453
+ click.echo(f"error: assignment {assignment_id!r} not found in board", err=True)
454
+ sys.exit(1)
455
+
456
+ repo = cfg.repo(assignment.repo_name)
457
+
458
+ # ── Record verdict ──────────────────────────────────────────────────
459
+ if verdict:
460
+ # Map CLI verdict flags to the canonical test_state values used by the
461
+ # TUI's Test stage and the reconcile review-gating logic. "running"
462
+ # (#1395) is NOT a verdict — it's a transient in-progress marker a
463
+ # driver sets around a locally-run suite, overwritten by one of the
464
+ # other three flags when the run concludes.
465
+ test_state_map = {"pass": "passed", "fail": "failed", "skip": "skipped", "running": "running"}
466
+ assignment.test_state = test_state_map[verdict]
467
+ # #1213: a --skipped verdict carries a reason too (e.g. "trivial dep
468
+ # bump, covered by regression test in the same PR") — that reason IS
469
+ # the audit trail for why the human test was bypassed, so it must
470
+ # not be discarded the way --passed's (never has one) is. Only a
471
+ # bare --passed clears any stale reason from a prior verdict.
472
+ assignment.test_reason = reason if verdict in ("fail", "skip") else None
473
+ # Mirror to legacy smoke_test for the existing smoke-stage scoring in
474
+ # pipeline.py (which predates the human Test gate).
475
+ if verdict in ("pass", "fail"):
476
+ assignment.smoke_test = verdict
477
+ assignment.smoke_test_reason = reason if verdict == "fail" else None
478
+
479
+ # Store test output when --fail --output is provided
480
+ if verdict == "fail" and output_file:
481
+ output_path = Path(output_file)
482
+ if output_path.exists():
483
+ from coord.state import COORD_DIR
484
+
485
+ test_output_dir = COORD_DIR / "test_output"
486
+ test_output_dir.mkdir(parents=True, exist_ok=True)
487
+ stored = test_output_dir / f"{assignment_id}.txt"
488
+ stored.write_text(output_path.read_text())
489
+ # Record the stored path so coord fix can find it
490
+ reason_with_output = (
491
+ f"{reason} [output: {stored}]" if reason else f"[output: {stored}]"
492
+ )
493
+ assignment.test_reason = reason_with_output
494
+ assignment.smoke_test_reason = reason_with_output
495
+ click.echo(f" test output stored: {stored}")
496
+ else:
497
+ click.echo(f" warning: output file not found: {output_file}", err=True)
498
+
499
+ # #1629 (H-2): best-effort — which toolchain, on THIS machine, ran the
500
+ # suite that produced a pass/fail verdict. "skip"/"running" carry no
501
+ # test run, so there's nothing to attribute. Never blocks the verdict
502
+ # write: any failure here (repo path unresolved, no known toolchain,
503
+ # the binary not on PATH) just leaves it None, same as every
504
+ # pre-#1629 verdict.
505
+ test_toolchain = None
506
+ if verdict in ("pass", "fail"):
507
+ try:
508
+ from coord.health.checks.toolchain import local_toolchain_label
509
+
510
+ repo_dir = _local_repo_dir(cfg, assignment.repo_name)
511
+ if repo_dir is not None:
512
+ test_toolchain = local_toolchain_label(repo_dir)
513
+ except Exception: # noqa: BLE001 — annotation only, never fatal
514
+ test_toolchain = None
515
+
516
+ # #1337: single-row verdict write on BOTH paths (record_test_verdict
517
+ # self-routes: daemon when board_service is set, direct UPDATE
518
+ # locally). The old local-path save_board() relied on the whole-board
519
+ # upsert carrying test_reason/smoke_test_reason — those free-text
520
+ # columns are now excluded from that upsert so a bounded /board
521
+ # preview can never round-trip over the full stored text.
522
+ record_test_verdict(
523
+ assignment_id=assignment_id,
524
+ test_state=assignment.test_state,
525
+ test_reason=assignment.test_reason,
526
+ smoke_test=assignment.smoke_test,
527
+ smoke_test_reason=assignment.smoke_test_reason,
528
+ test_toolchain=test_toolchain,
529
+ )
530
+ verdict_word = {
531
+ "pass": "PASSED", "fail": "FAILED", "skip": "SKIPPED", "running": "RUNNING",
532
+ }[verdict]
533
+ click.echo(f"Test gate {verdict_word} for {assignment.repo_name} #{assignment.issue_number}")
534
+ if verdict in ("fail", "skip") and reason:
535
+ click.echo(f" reason: {reason}")
536
+ elif verdict == "pass":
537
+ click.echo(" Run: coord merge to proceed")
538
+ elif verdict == "running":
539
+ click.echo(" (transient — record --passed/--fail/--skipped when the run concludes)")
540
+
541
+ # #271 part 1: restore the local checkout to `default_branch` after a
542
+ # pass/skip verdict (legacy safety — #561 means a Build no longer moves
543
+ # the base, so this is a no-op on fresh checkouts), and #561: remove the
544
+ # throwaway test worktree now that testing concluded. `--fail` leaves
545
+ # the worktree so the user can dig into the failure. `running` is
546
+ # neither — the driver manages its own scratch worktree separately, so
547
+ # this command never touched one in the first place.
548
+ if verdict in ("pass", "skip"):
549
+ _restore_default_branch_after_test(cfg, assignment)
550
+ _cleanup_test_worktree(cfg, assignment)
551
+ return
552
+
553
+ # ── Checkout and build (in a throwaway worktree — #561) ──────────────
554
+ if not assignment.branch:
555
+ click.echo(
556
+ f"error: assignment {assignment_id} has no branch recorded. "
557
+ f"The worker may not have pushed yet, or the branch wasn't captured during reconciliation.",
558
+ err=True,
559
+ )
560
+ sys.exit(1)
561
+
562
+ import subprocess
563
+
564
+ repo_dir = _local_repo_dir(cfg, assignment.repo_name)
565
+ if repo_dir is None:
566
+ click.echo(
567
+ f"error: no repo_path configured for {assignment.repo_name!r}. "
568
+ f"Add it to coordinator.yml under machines[].repo_paths.",
569
+ err=True,
570
+ )
571
+ sys.exit(1)
572
+ if not repo_dir.exists():
573
+ click.echo(f"error: repo path does not exist: {repo_dir}", err=True)
574
+ sys.exit(1)
575
+
576
+ # #561: build/test in a throwaway worktree fetched fresh from origin —
577
+ # NEVER `git checkout` in the base checkout. The base doubles as the live
578
+ # editable coordinator source, so moving its branch silently downgrades the
579
+ # running coord (disabled guards, reintroduced bugs) until restored. A
580
+ # `git fetch` is safe (it doesn't move HEAD); the worktree gets its own.
581
+ wt_path = _test_worktree_path(assignment_id, assignment.repo_name)
582
+ click.echo(
583
+ f"Fetching origin and preparing test worktree for {assignment.branch!r} "
584
+ f"(base checkout {repo_dir} stays untouched)..."
585
+ )
586
+ try:
587
+ subprocess.run(
588
+ ["git", "fetch", "origin", "--prune"], cwd=str(repo_dir),
589
+ check=True, capture_output=True, text=True,
590
+ )
591
+ except subprocess.CalledProcessError as e:
592
+ click.echo(f"error: git fetch failed: {e.stderr.strip()}", err=True)
593
+ sys.exit(1)
594
+
595
+ # Clear any stale worktree from a prior Build of this assignment.
596
+ _remove_test_worktree(repo_dir, wt_path)
597
+ wt_path.parent.mkdir(parents=True, exist_ok=True)
598
+
599
+ def _add_worktree(branch: str):
600
+ # --detach: we only read the tree to build/test; no local branch needed.
601
+ return subprocess.run(
602
+ ["git", "worktree", "add", "--force", "--detach",
603
+ str(wt_path), f"origin/{branch}"],
604
+ cwd=str(repo_dir), capture_output=True, text=True,
605
+ )
606
+
607
+ res = _add_worktree(assignment.branch)
608
+ if res.returncode != 0:
609
+ # Branch drift (auto-loop orphan branches; slugifier max_len changes
610
+ # across releases; manual `git branch -m` on origin). When the worktree
611
+ # add fails AND the issue has a PR, resolve the PR's actual headRefName
612
+ # (non-mutating), update the DB, and retry.
613
+ reconciled = _maybe_reconcile_branch(
614
+ assignment, repo_dir, original_error=res.stderr.strip(), config=cfg,
615
+ )
616
+ if reconciled is None:
617
+ click.echo(
618
+ f"error: could not create test worktree: {res.stderr.strip()}",
619
+ err=True,
620
+ )
621
+ sys.exit(1)
622
+ assignment.branch = reconciled
623
+ click.echo(
624
+ f" branch drift reconciled: using the PR's actual head ref "
625
+ f"{assignment.branch!r}"
626
+ )
627
+ res = _add_worktree(assignment.branch)
628
+ if res.returncode != 0:
629
+ click.echo(
630
+ f"error: could not create test worktree: {res.stderr.strip()}",
631
+ err=True,
632
+ )
633
+ sys.exit(1)
634
+
635
+ click.echo(f"Test worktree ready at {wt_path} (branch {assignment.branch!r}).")
636
+
637
+ if repo and repo.build_command:
638
+ click.echo(f"Running build: {repo.build_command}")
639
+ result = subprocess.run(repo.build_command, shell=True, cwd=str(wt_path))
640
+ if result.returncode != 0:
641
+ click.echo(f"Build failed (exit {result.returncode})", err=True)
642
+ click.echo(f" worktree kept for inspection: {wt_path}")
643
+ sys.exit(1)
644
+ click.echo("Build succeeded.")
645
+ # #1249: stash immediately after a successful build — regardless of
646
+ # whether the test step below then passes or fails — so a later
647
+ # `--fix-of` re-test on this branch can pull instead of rebuilding.
648
+ _stash_test_artifacts(repo, assignment, wt_path)
649
+
650
+ if repo and repo.test_command:
651
+ click.echo(f"Running tests: {repo.test_command}")
652
+ result = subprocess.run(repo.test_command, shell=True, cwd=str(wt_path))
653
+ if result.returncode != 0:
654
+ click.echo(f"Tests failed (exit {result.returncode})", err=True)
655
+ click.echo(f" worktree kept for inspection: {wt_path}")
656
+ sys.exit(1)
657
+ click.echo("Tests passed.")
658
+
659
+ click.echo(
660
+ f"\nReady for smoke test (worktree: {wt_path}). Run:\n"
661
+ f" coord test --passed {assignment_id} # if it looks good (removes the worktree)\n"
662
+ f" coord test --fail {assignment_id} --reason \"description\" # keeps the worktree to dig in"
663
+ )
664
+
665
+
666
+ def _test_plan_via_daemon(svc, params: dict) -> None:
667
+ """#851: run ``coord test-plan`` on the daemon host (the assignment + its
668
+ cached ``test_plan`` live in the daemon's canonical DB, not a thin
669
+ client's empty local one) and relay its output. Mirrors
670
+ ``_diagnose_via_daemon`` in ``coord/commands/status.py``."""
671
+ from coord.client import post_record # noqa: PLC0415
672
+
673
+ try:
674
+ resp = post_record(svc, "/test-plan", params, timeout=120.0)
675
+ except Exception as exc: # noqa: BLE001
676
+ click.echo(f"error: test-plan via daemon failed: {exc}", err=True)
677
+ sys.exit(1)
678
+ output = resp.get("output") or ""
679
+ if output:
680
+ click.echo(output, nl=False)
681
+ if resp.get("error"):
682
+ click.echo(f"error: {resp['error']}", err=True)
683
+ code = resp.get("exit_code") or 0
684
+ if code:
685
+ sys.exit(int(code))
686
+
687
+
688
+ @click.command(
689
+ "test-plan",
690
+ help=(
691
+ "Generate (or display) a smoke test plan for a completed assignment.\n\n"
692
+ "On first call the plan is generated by calling claude -p (Haiku by default) "
693
+ "with the PR diff, CLAUDE.md, artifact manifest, and issue body. The result "
694
+ "is cached in the database. Subsequent calls return the cached plan instantly "
695
+ "without invoking Claude.\n\n"
696
+ "Use --refresh to regenerate and overwrite the cached plan."
697
+ ),
698
+ )
699
+
700
+
701
+ @click.argument("assignment_id")
702
+ @click.option(
703
+ "--refresh",
704
+ is_flag=True,
705
+ default=False,
706
+ help="Regenerate the plan even if a cached one exists.",
707
+ )
708
+
709
+
710
+ @click.option(
711
+ "--model",
712
+ default="haiku",
713
+ show_default=True,
714
+ help="Claude model alias to use for plan generation.",
715
+ )
716
+
717
+
718
+ @_CONFIG_OPTION
719
+ def test_plan_cmd(
720
+ assignment_id: str,
721
+ refresh: bool,
722
+ model: str,
723
+ config_path: Path,
724
+ ) -> None:
725
+ """Generate or display the smoke test plan for ASSIGNMENT_ID."""
726
+ # #851: the assignment row (and its cached test_plan) live in the
727
+ # daemon's canonical DB. `generate_plan` queries the local DB directly,
728
+ # so on a thin client (empty local DB) it always reports "not found"
729
+ # even for a perfectly valid id — the same #584-class gap `diagnose` /
730
+ # `merge` / `reconcile-merges` already had. Route the whole command to
731
+ # the daemon, mirroring that pattern. COORD_TEST_PLAN_ON_DAEMON guards
732
+ # the daemon against re-routing to itself.
733
+ from coord.client import resolve_board_service # noqa: PLC0415
734
+
735
+ svc = resolve_board_service()
736
+ if svc is not None and not os.environ.get("COORD_TEST_PLAN_ON_DAEMON"):
737
+ _test_plan_via_daemon(
738
+ svc,
739
+ {"assignment_id": assignment_id, "refresh": refresh, "model": model},
740
+ )
741
+ return
742
+
743
+ from coord.state import get_test_plan, set_test_plan
744
+ from coord.test_orchestrator import find_local_repo_path, generate_plan
745
+
746
+ cfg = _load_config(config_path)
747
+
748
+ # ── Cache hit path ────────────────────────────────────────────────────
749
+ if not refresh:
750
+ cached = get_test_plan(assignment_id)
751
+ if cached is not None:
752
+ click.echo(json.dumps(cached, indent=2))
753
+ return
754
+
755
+ # ── Generate ──────────────────────────────────────────────────────────
756
+ click.echo(
757
+ f"Generating smoke test plan for assignment {assignment_id!r} "
758
+ f"(model: {model})...",
759
+ err=True,
760
+ )
761
+ plan = generate_plan(assignment_id, cfg, model=model)
762
+
763
+ # ── Capture branch HEAD SHA for staleness detection ────────────────────
764
+ # Read the assignment's branch from the DB, then resolve the HEAD SHA on
765
+ # the local machine so the TUI can detect when the branch has advanced
766
+ # since this plan was generated and trigger a refresh automatically.
767
+ branch_head = _get_assignment_branch_head(assignment_id, cfg, find_local_repo_path)
768
+
769
+ # Persist (always, even the fallback — so a subsequent call without
770
+ # --refresh shows the cached result rather than hitting Claude again).
771
+ # Always write branch_head (even when None) so stale SHAs from a prior
772
+ # run are cleared.
773
+ set_test_plan(assignment_id, plan, branch_head=branch_head)
774
+
775
+ click.echo(json.dumps(plan, indent=2))