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,494 @@
1
+ """Smoke test plan generator — Phase A of #342.
2
+
3
+ Generates a structured, AI-assisted smoke test plan for a completed assignment
4
+ by calling ``claude -p`` (Haiku by default) with the PR diff, the repo's
5
+ CLAUDE.md, the artifact manifest from the agent, and the GitHub issue body.
6
+
7
+ The generated plan is validated against a known JSON shape and cached in the
8
+ ``test_plan`` column of the ``assignments`` table. The CLI command
9
+ ``coord test-plan`` is the primary consumer.
10
+
11
+ Phase B (TUI rendering) and Phase C (verdict auto-routing) are explicitly out
12
+ of scope here.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ import os
20
+ import re
21
+ import shutil
22
+ import socket
23
+ import subprocess
24
+ from pathlib import Path
25
+
26
+ import httpx
27
+
28
+ from coord.config import Config
29
+ from coord.models import Machine
30
+
31
+ AGENT_PORT = 7433
32
+
33
+ log = logging.getLogger(__name__)
34
+
35
+ # ── System prompt for the plan generator ─────────────────────────────────────
36
+
37
+ PLAN_SYSTEM_PROMPT = """\
38
+ You are generating a smoke test plan for a developer reviewing a code change.
39
+ Output JSON only, matching this exact shape:
40
+ {"steps": [{"kind": "pull"|"run"|"verify", "cmd": "...", "label": "...", "check": "..."}], "blockers": ["..."]}
41
+
42
+ Rules:
43
+ - Max 8 steps total.
44
+ - Prefer pulling pre-built artifacts (kind: "pull") over local rebuilds when \
45
+ the artifact manifest shows matching binaries. For a "pull" step include both \
46
+ "cmd" (the pull/copy command) and "label" (what is being pulled).
47
+ - When the manifest lists many files but the diff only concerns one or two \
48
+ example binaries, scope the pull with `coord pull-artifact <assignment_id> \
49
+ --only <name>` (repeatable, glob-matched) instead of pulling the whole \
50
+ stash — stashes can be dozens of ~100MB debug binaries (#940).
51
+ - For kind "run": include "cmd" with the exact shell command to run.
52
+ - For kind "verify": include "check" with a one-line concrete, observable \
53
+ assertion ("text glyphs visible inside GTK cells", not "looks correct"). \
54
+ Do NOT include "cmd" on verify steps unless a command produces the thing to inspect.
55
+ - The "blockers" array lists prerequisites that must be satisfied before \
56
+ testing can begin. Leave it empty when there are none.
57
+ - Never include COORD_CONFIG=<value> in cmd strings. The test runner sets \
58
+ the config path itself; baking it into a cmd creates a path that is wrong on \
59
+ every machine except the one that generated the plan.
60
+ - No markdown, no commentary, no extra keys outside the JSON object.\
61
+ """
62
+
63
+
64
+ # Matches a COORD_CONFIG=<value> token anywhere in a command string so it can
65
+ # be stripped from generated "cmd" fields. Pattern is intentionally narrow:
66
+ # it only removes the env-var assignment token (COORD_CONFIG=<non-whitespace>)
67
+ # followed by any separating whitespace — it does NOT touch other leading
68
+ # tokens such as "timeout 3 ..." or "sudo ...".
69
+ _COORD_CONFIG_RE = re.compile(r"\bCOORD_CONFIG=\S+\s*")
70
+
71
+
72
+ # ── Internal helpers ──────────────────────────────────────────────────────────
73
+
74
+ def _strip_fences(text: str) -> str:
75
+ """Remove optional ```json ... ``` fences from Claude's response."""
76
+ cleaned = text.strip()
77
+ fence = re.match(r"^```(?:json)?\s*\n(.*?)```\s*$", cleaned, re.DOTALL)
78
+ return fence.group(1).strip() if fence else cleaned
79
+
80
+
81
+ def _validate_plan(data: object) -> dict:
82
+ """Validate a raw parsed object against the plan shape.
83
+
84
+ Accepts extra keys on step objects (forward-compatibility).
85
+ Raises ``ValueError`` on structural violations.
86
+ Returns a normalised dict (steps capped at 8, blockers stringified).
87
+ """
88
+ if not isinstance(data, dict):
89
+ raise ValueError(f"plan must be a JSON object, got {type(data).__name__}")
90
+ if "steps" not in data:
91
+ raise ValueError("plan missing required key 'steps'")
92
+ if "blockers" not in data:
93
+ raise ValueError("plan missing required key 'blockers'")
94
+
95
+ steps = data["steps"]
96
+ if not isinstance(steps, list):
97
+ raise ValueError(f"'steps' must be an array, got {type(steps).__name__}")
98
+ blockers = data["blockers"]
99
+ if not isinstance(blockers, list):
100
+ raise ValueError(f"'blockers' must be an array, got {type(blockers).__name__}")
101
+
102
+ valid_kinds = {"pull", "run", "verify"}
103
+ validated_steps: list[dict] = []
104
+ for i, step in enumerate(steps[:8]): # cap at 8
105
+ if not isinstance(step, dict):
106
+ raise ValueError(f"step {i} must be an object, got {type(step).__name__}")
107
+ kind = step.get("kind")
108
+ if kind not in valid_kinds:
109
+ raise ValueError(
110
+ f"step {i} has invalid kind {kind!r}; expected one of {sorted(valid_kinds)}"
111
+ )
112
+ # Pass through all keys (extra keys are allowed for forward-compat).
113
+ # Strip COORD_CONFIG=<value> from "cmd" fields — Claude sometimes
114
+ # prepends the env-var to commands; the test runner owns the config
115
+ # path and must not have it baked into the plan (#1100).
116
+ cleaned: dict = {}
117
+ for k, v in step.items():
118
+ if k == "cmd" and isinstance(v, str):
119
+ v = _COORD_CONFIG_RE.sub("", v).strip()
120
+ cleaned[k] = v
121
+ validated_steps.append(cleaned)
122
+
123
+ return {
124
+ "steps": validated_steps,
125
+ "blockers": [str(b) for b in blockers],
126
+ }
127
+
128
+
129
+ def resolve_claude_bin() -> str:
130
+ """Resolve an absolute path to the ``claude`` binary (#859).
131
+
132
+ ``_call_claude`` used to shell out to bare ``"claude"``, which relies on
133
+ the invoking process's ``$PATH`` containing ``~/.local/bin`` (where the
134
+ binary is typically installed). That holds for an interactive shell but
135
+ not for ``coord-serve``, which runs under ``systemd --user`` with the
136
+ default (empty ``Environment=``) PATH — so daemon-side plan generation
137
+ (a cache miss on ``coord test-plan``, routed to the daemon by #851)
138
+ failed with ``FileNotFoundError``. Same lesson as the #424/#425 PTY
139
+ escape hatch: cross-machine/service invocation must use an absolute
140
+ path, not a bare command name.
141
+
142
+ Resolution order:
143
+ 1. ``$CLAUDE_BIN`` — explicit override for non-standard installs.
144
+ 2. ``shutil.which("claude")`` — PATH lookup; works whenever the
145
+ caller's environment is sane (e.g. interactive shells).
146
+ 3. ``~/.local/bin/claude`` — the standard install location, used
147
+ verbatim as a last-resort fallback even if it doesn't exist, so a
148
+ resulting ``FileNotFoundError`` still names the path that was
149
+ expected (easier to diagnose than a bare ``'claude'``).
150
+
151
+ Returns:
152
+ Absolute path (str) to use as argv[0] for the ``claude`` subprocess.
153
+ """
154
+ override = os.environ.get("CLAUDE_BIN")
155
+ if override:
156
+ return override
157
+ found = shutil.which("claude")
158
+ if found:
159
+ return found
160
+ return str(Path.home() / ".local" / "bin" / "claude")
161
+
162
+
163
+ def _call_claude(system: str, user: str, *, model: str = "haiku") -> str:
164
+ """Invoke ``claude -p`` and return the text result.
165
+
166
+ Mirrors the pattern in ``coord.brain.call_claude`` — no Anthropic SDK.
167
+ The ``--output-format json`` flag gives a structured envelope; we extract
168
+ the ``result`` field. The binary is resolved to an absolute path (see
169
+ :func:`resolve_claude_bin`) so this works under ``coord-serve``'s
170
+ restricted-PATH ``systemd --user`` environment, not just interactively.
171
+ """
172
+ cmd = [
173
+ resolve_claude_bin(), "-p",
174
+ "--system-prompt", system,
175
+ "--output-format", "json",
176
+ ]
177
+ if model:
178
+ cmd += ["--model", model]
179
+
180
+ result = subprocess.run(
181
+ cmd,
182
+ input=user,
183
+ capture_output=True,
184
+ text=True,
185
+ timeout=120,
186
+ )
187
+ if result.returncode != 0:
188
+ raise RuntimeError(
189
+ f"claude -p failed (exit {result.returncode}): {result.stderr.strip()}"
190
+ )
191
+
192
+ outer = json.loads(result.stdout)
193
+ return outer.get("result", result.stdout)
194
+
195
+
196
+ def _fetch_artifact_manifest(
197
+ machine_host: str,
198
+ repo_name: str,
199
+ branch: str,
200
+ ) -> dict | None:
201
+ """GET /artifact/<repo>/<sanitized_branch> from the agent.
202
+
203
+ Returns the manifest dict on success, ``None`` on 404 or network error.
204
+ The 404 case is not an error — the stash may have been GC'd or the
205
+ repo doesn't have ``artifact_paths`` configured.
206
+ """
207
+ # Import lazily to avoid circular imports at module load time.
208
+ from coord.agent import _sanitize_branch # noqa: PLC0415
209
+
210
+ sanitized = _sanitize_branch(branch)
211
+ url = f"http://{machine_host}:{AGENT_PORT}/artifact/{repo_name}/{sanitized}"
212
+ try:
213
+ resp = httpx.get(url, timeout=10)
214
+ if resp.status_code == 404:
215
+ return None
216
+ resp.raise_for_status()
217
+ return resp.json()
218
+ except (httpx.HTTPError, ValueError) as exc:
219
+ log.debug("artifact manifest fetch failed for %s/%s: %s", repo_name, branch, exc)
220
+ return None
221
+
222
+
223
+ def _get_pr_diff(pr_url: str, repo_github: str) -> str:
224
+ """Fetch the diff via the ``github_ops`` seam (#1483 — no direct ``gh``
225
+ invocations outside ``coord.github_ops``).
226
+
227
+ Returns an empty string when the PR URL is missing, the gh CLI is
228
+ unavailable, or the command fails.
229
+ """
230
+ if not pr_url or not repo_github:
231
+ return ""
232
+ m = re.search(r"/pull/(\d+)", pr_url)
233
+ if not m:
234
+ return ""
235
+ pr_number = int(m.group(1))
236
+ from coord import github_ops # noqa: PLC0415
237
+
238
+ try:
239
+ diff = github_ops.pr_diff(repo_github, pr_number)
240
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
241
+ log.debug("gh pr diff failed: %s", exc)
242
+ return ""
243
+ return (diff or "").strip()
244
+
245
+
246
+ def _get_git_diff(branch: str, default_branch: str, repo_dir: Path) -> str:
247
+ """Fall-back: ``git diff <default_branch>...<branch>`` run locally.
248
+
249
+ Returns an empty string on any error.
250
+ """
251
+ if not branch or not repo_dir.exists():
252
+ return ""
253
+ try:
254
+ result = subprocess.run(
255
+ ["git", "diff", f"{default_branch}...{branch}"],
256
+ cwd=str(repo_dir),
257
+ capture_output=True, text=True, timeout=30,
258
+ )
259
+ if result.returncode == 0:
260
+ return result.stdout.strip()
261
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
262
+ log.debug("git diff failed: %s", exc)
263
+ return ""
264
+
265
+
266
+ def _get_issue_body(repo_github: str, issue_number: int) -> str:
267
+ """Fetch issue title + body via the ``github_ops`` seam (#1483).
268
+
269
+ Returns a markdown string "## <title>\\n\\n<body>" or empty string on error.
270
+ """
271
+ if not repo_github or not issue_number:
272
+ return ""
273
+ from coord import github_ops # noqa: PLC0415
274
+
275
+ try:
276
+ data = github_ops.get_issue(repo_github, issue_number)
277
+ except (RuntimeError, subprocess.TimeoutExpired, FileNotFoundError, OSError,
278
+ json.JSONDecodeError) as exc:
279
+ log.debug("gh issue view failed: %s", exc)
280
+ return ""
281
+ title = data.get("title", "")
282
+ body = (data.get("body") or "").strip()
283
+ return f"## {title}\n\n{body}" if body else f"## {title}"
284
+
285
+
286
+ def local_machine(config: Config) -> Machine | None:
287
+ """The configured ``Machine`` (if any) whose ``name`` or ``host`` prefix
288
+ matches this process's hostname.
289
+
290
+ Extracted out of :func:`find_local_repo_path`'s hostname-matching so
291
+ callers that need the ``Machine`` object itself (e.g. #966's acceptance
292
+ capability check, which needs ``.capabilities``, not just a repo path)
293
+ don't have to re-derive the match. Returns ``None`` when this host isn't
294
+ a recognized machine in ``coordinator.yml``.
295
+ """
296
+ local_hostname = socket.gethostname().split(".")[0]
297
+ for machine in config.machines:
298
+ if machine.name == local_hostname or machine.host.split(".")[0] == local_hostname:
299
+ return machine
300
+ return None
301
+
302
+
303
+ def find_local_repo_path(repo_name: str, config: Config) -> Path | None:
304
+ """Locate the repo on the local machine by matching against coordinator.yml.
305
+
306
+ Tries the machine whose name or host prefix matches this machine's hostname
307
+ first, then falls back to scanning all machines. Returns ``None`` when no
308
+ ``repo_paths`` entry exists for *repo_name*.
309
+
310
+ Public so that callers outside this module (e.g. ``coord/cli.py``) can
311
+ reuse it without duplicating the hostname-matching logic.
312
+ """
313
+ # Prefer a machine entry that looks like this machine.
314
+ here = local_machine(config)
315
+ if here is not None:
316
+ p = here.repo_path(repo_name)
317
+ if p:
318
+ return Path(p).expanduser()
319
+ # Fall back to any machine that has a repo_path configured.
320
+ for machine in config.machines:
321
+ p = machine.repo_path(repo_name)
322
+ if p:
323
+ return Path(p).expanduser()
324
+ return None
325
+
326
+
327
+ # Keep old private name as an alias for backward compatibility with any
328
+ # internal callers that haven't been updated yet.
329
+ _find_local_repo_path = find_local_repo_path
330
+
331
+
332
+ def _build_user_prompt(
333
+ *,
334
+ issue_number: int,
335
+ issue_body: str,
336
+ claude_md: str,
337
+ diff_text: str,
338
+ manifest: dict | None,
339
+ ) -> str:
340
+ """Assemble the user-facing prompt from the gathered context."""
341
+ parts: list[str] = []
342
+
343
+ if claude_md.strip():
344
+ parts.append(f"## CLAUDE.md (project rules)\n\n{claude_md.strip()}")
345
+
346
+ if issue_body.strip():
347
+ parts.append(f"## Issue #{issue_number}\n\n{issue_body.strip()}")
348
+
349
+ if diff_text:
350
+ # Truncate very large diffs to keep token budget under control.
351
+ if len(diff_text) > 20_000:
352
+ diff_text = diff_text[:20_000] + "\n... (diff truncated at 20 000 chars)"
353
+ parts.append(f"## Diff\n\n```diff\n{diff_text}\n```")
354
+ else:
355
+ parts.append("## Diff\n\n(not available)")
356
+
357
+ if manifest:
358
+ manifest_json = json.dumps(manifest, indent=2)
359
+ parts.append(
360
+ f"## Artifact manifest\n\n"
361
+ f"Pre-built binaries are available on the agent machine. "
362
+ f"Prefer `coord pull-artifact` to fetch them rather than rebuilding locally.\n\n"
363
+ f"```json\n{manifest_json}\n```"
364
+ )
365
+ else:
366
+ parts.append(
367
+ "## Artifact manifest\n\n"
368
+ "(not available — no pre-built artifacts stashed; plan must include "
369
+ "a local rebuild step if a binary is needed for testing)"
370
+ )
371
+
372
+ parts.append("Generate a smoke test plan for this change.")
373
+ return "\n\n".join(parts)
374
+
375
+
376
+ # ── Public API ────────────────────────────────────────────────────────────────
377
+
378
+ def generate_plan(
379
+ assignment_id: str,
380
+ config: Config,
381
+ *,
382
+ model: str = "haiku",
383
+ ) -> dict:
384
+ """Generate a smoke test plan for *assignment_id*.
385
+
386
+ Gathers context (diff, CLAUDE.md, artifact manifest, issue body), calls
387
+ ``claude -p`` (Haiku by default) with a tight system prompt, validates the
388
+ returned JSON, retries ONCE on malformed output, and returns the validated
389
+ plan dict::
390
+
391
+ {
392
+ "steps": [
393
+ {"kind": "pull"|"run"|"verify", "cmd": "...", "label": "...", "check": "..."},
394
+ ...
395
+ ],
396
+ "blockers": ["..."],
397
+ }
398
+
399
+ On two consecutive failures (bad JSON or claude exit != 0) returns::
400
+
401
+ {"steps": [], "blockers": ["plan generation failed"]}
402
+
403
+ This function does NOT persist the plan — call ``coord.state.set_test_plan``
404
+ after receiving the return value.
405
+ """
406
+ from coord.db import get_connection # noqa: PLC0415 — lazy to avoid circular imports
407
+
408
+ FALLBACK: dict = {"steps": [], "blockers": ["plan generation failed"]}
409
+
410
+ # ── Look up the assignment ────────────────────────────────────────────
411
+ conn = get_connection()
412
+ row = conn.execute(
413
+ "SELECT machine_name, repo_name, repo_github, issue_number, branch, pr_url "
414
+ "FROM assignments WHERE assignment_id = ?",
415
+ (assignment_id,),
416
+ ).fetchone()
417
+ if row is None:
418
+ log.warning("generate_plan: assignment %r not found in DB", assignment_id)
419
+ return {"steps": [], "blockers": [f"assignment {assignment_id!r} not found"]}
420
+
421
+ machine_name: str = row["machine_name"]
422
+ repo_name: str = row["repo_name"]
423
+ repo_github: str = row["repo_github"] or ""
424
+ issue_number: int = row["issue_number"]
425
+ branch: str = row["branch"] or ""
426
+ pr_url: str = row["pr_url"] or ""
427
+
428
+ # ── Machine config ────────────────────────────────────────────────────
429
+ machine = next((m for m in config.machines if m.name == machine_name), None)
430
+ repo_cfg = config.repo(repo_name)
431
+
432
+ # ── Read CLAUDE.md ────────────────────────────────────────────────────
433
+ claude_md = ""
434
+ local_repo_dir = _find_local_repo_path(repo_name, config)
435
+ if local_repo_dir and local_repo_dir.exists():
436
+ claude_md_path = local_repo_dir / "CLAUDE.md"
437
+ try:
438
+ claude_md = claude_md_path.read_text(encoding="utf-8", errors="replace")
439
+ except OSError:
440
+ claude_md = ""
441
+
442
+ # ── Get diff ─────────────────────────────────────────────────────────
443
+ diff_text = _get_pr_diff(pr_url, repo_github)
444
+ if not diff_text and branch and local_repo_dir:
445
+ default_branch = repo_cfg.default_branch if repo_cfg else "main"
446
+ diff_text = _get_git_diff(branch, default_branch, local_repo_dir)
447
+
448
+ # ── Get artifact manifest ─────────────────────────────────────────────
449
+ manifest: dict | None = None
450
+ if machine and branch:
451
+ manifest = _fetch_artifact_manifest(machine.host, repo_name, branch)
452
+
453
+ # ── Get issue body ────────────────────────────────────────────────────
454
+ issue_body = _get_issue_body(repo_github, issue_number)
455
+
456
+ # ── Build prompt ──────────────────────────────────────────────────────
457
+ user_prompt = _build_user_prompt(
458
+ issue_number=issue_number,
459
+ issue_body=issue_body,
460
+ claude_md=claude_md,
461
+ diff_text=diff_text,
462
+ manifest=manifest,
463
+ )
464
+
465
+ # ── Call claude -p with one retry ─────────────────────────────────────
466
+ last_exc: str = ""
467
+ for attempt in range(2):
468
+ prompt = user_prompt
469
+ if attempt > 0:
470
+ prompt = (
471
+ user_prompt
472
+ + f"\n\nYour previous output was not valid JSON ({last_exc}); "
473
+ "try again. Output ONLY the JSON object — no other text."
474
+ )
475
+ try:
476
+ raw = _call_claude(PLAN_SYSTEM_PROMPT, prompt, model=model)
477
+ except (RuntimeError, subprocess.TimeoutExpired, json.JSONDecodeError) as exc:
478
+ last_exc = str(exc)
479
+ log.warning("plan generation attempt %d error: %s", attempt + 1, exc)
480
+ continue
481
+
482
+ try:
483
+ parsed = json.loads(_strip_fences(raw))
484
+ return _validate_plan(parsed)
485
+ except (json.JSONDecodeError, ValueError) as exc:
486
+ last_exc = str(exc)
487
+ log.warning("plan parse/validate failed on attempt %d: %s", attempt + 1, exc)
488
+ # Continue to retry on attempt 0; fall through to FALLBACK on attempt 1.
489
+
490
+ log.error(
491
+ "plan generation failed for assignment %r after 2 attempts: %s",
492
+ assignment_id, last_exc,
493
+ )
494
+ return FALLBACK
coord/test_report.py ADDED
@@ -0,0 +1,178 @@
1
+ """Pure parsers for the coord-test-runner.sh (#1436) failure classifier.
2
+
3
+ `scripts/coord-test-runner.sh` decides whether a Test-gate run is a genuine
4
+ failure, a flake, a build/collection error (never flake-retried), or a run
5
+ whose output it simply could not parse (which must never be silently
6
+ recorded as a pass). Four of those decisions were `grep`/`awk` one-liners
7
+ with zero test coverage; this module extracts them into tested Python
8
+ functions. The shell script is untouched by this change — see #1392 for the
9
+ follow-up that ports the state machine and wires these in.
10
+
11
+ Each function's docstring names the shell expression it mirrors and any
12
+ behavioural difference (the ``pytest_failed_node_ids`` parametrized-id
13
+ truncation fix is the one deliberate difference — see its docstring).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+
20
+ # ── pytest ───────────────────────────────────────────────────────────────────
21
+
22
+ # pytest's own "short test summary info" format is:
23
+ # FAILED <node id> - <failure message, possibly truncated with '...'>
24
+ # The " - " separator is not part of any valid node id (pytest node ids are
25
+ # `path::name[params]`), so splitting on the first occurrence of it is safe
26
+ # even when the message itself contains " - ".
27
+ _FAILED_PREFIX = "FAILED "
28
+ _FAILED_MESSAGE_SEP = " - "
29
+
30
+
31
+ def pytest_failed_node_ids(output: str) -> list[str]:
32
+ """Extract unique pytest failure node ids from ``pytest`` output.
33
+
34
+ Mirrors the shell's::
35
+
36
+ grep '^FAILED ' | awk '{print $2}' | sort -u
37
+
38
+ with one deliberate fix: ``awk '{print $2}'`` splits the line on
39
+ whitespace, so a parametrized node id containing a space — e.g.
40
+ ``test_x[a b]`` — is truncated to ``test_x[a``. This is a live bug in
41
+ ``coord-test-runner.sh`` today (see #1436). This implementation instead
42
+ strips the leading ``FAILED `` marker and takes everything up to the
43
+ first `` - `` (the separator pytest itself puts before the failure
44
+ summary message), so a node id with an embedded space survives intact.
45
+
46
+ Returns node ids deduped and sorted, matching ``sort -u``.
47
+ """
48
+ seen: set[str] = set()
49
+ for line in output.splitlines():
50
+ if not line.startswith(_FAILED_PREFIX):
51
+ continue
52
+ rest = line[len(_FAILED_PREFIX) :]
53
+ node_id = rest.split(_FAILED_MESSAGE_SEP, 1)[0].strip()
54
+ if node_id:
55
+ seen.add(node_id)
56
+ return sorted(seen)
57
+
58
+
59
+ # A collection/import error is never a flake — the suite could not even run,
60
+ # so re-running it in isolation reproduces the identical error. Matched
61
+ # structurally against pytest's own session-level markers rather than a bare
62
+ # "line starts with ERROR" substring check: a test whose *body* prints a
63
+ # string starting with "ERROR" (e.g. `print("ERROR: retrying request")`) ends
64
+ # up verbatim in "Captured stdout call" and would trip a naive check — see
65
+ # tests/fixtures/test_report/pytest_error_string_in_body.txt for a genuine
66
+ # repro. INTERNALERROR (pytest itself crashing, as opposed to a user test
67
+ # failing) always uses the "INTERNALERROR>" marker.
68
+ _PYTEST_COLLECTION_ERROR_PATTERNS = [
69
+ re.compile(r"^INTERNALERROR>"),
70
+ re.compile(r"^=+\s*ERRORS\s*=+\s*$"),
71
+ re.compile(r"^!+\s*Interrupted:.*error.*!+\s*$"),
72
+ ]
73
+
74
+
75
+ def pytest_has_collection_error(output: str) -> bool:
76
+ """True if ``output`` shows a pytest collection/import error or an INTERNALERROR.
77
+
78
+ Mirrors the shell's intent behind::
79
+
80
+ grep -qE "^(ERROR|INTERNALERROR)"
81
+
82
+ but checks pytest's own structural markers (the "=== ERRORS ===" section
83
+ header, the "Interrupted: N error during collection" summary line, and
84
+ "INTERNALERROR>") instead of a bare line-start substring match, so a test
85
+ body printing "ERROR ..." to stdout does not false-positive.
86
+ """
87
+ lines = output.splitlines()
88
+ return any(p.match(line) for line in lines for p in _PYTEST_COLLECTION_ERROR_PATTERNS)
89
+
90
+
91
+ # ── cargo ────────────────────────────────────────────────────────────────────
92
+
93
+ # cargo's `failures:` summary block lists bare test names, four-space
94
+ # indented, one per line — e.g. " tests::module::test_name". The block
95
+ # appears twice per test *target* (once interleaved with per-test stdout
96
+ # dumps, once as the final list) and cargo prints one such pair PER TARGET
97
+ # when a run spans multiple test binaries (lib + integration tests, etc).
98
+ # The block is closed by the next "test result:" line, which resets
99
+ # collection so a second target's block cannot bleed into or concatenate
100
+ # with the first — see tests/fixtures/test_report/cargo_multi_target_failures.txt
101
+ # for a genuine two-target repro.
102
+ #
103
+ # Deliberate fix vs. the shell's `/^ [a-zA-Z_]+::/` pattern: that pattern
104
+ # REQUIRES a "::" after the leading identifier, matching module-qualified
105
+ # names like "tests::lib_another_failure" but silently dropping top-level
106
+ # test functions with no enclosing `mod` — which is exactly the shape of
107
+ # tui/tests/acceptance.rs's `#[test]` functions. A run where such a test
108
+ # fails would report an empty failure list (misclassified as "no parseable
109
+ # failure list", skipping flake-retry entirely) even though cargo named the
110
+ # test plainly. Matched here in cargo_multi_target_failures.txt by the bare
111
+ # "integration_genuine_failure" entry (no "::"), captured from a genuine
112
+ # `cargo test --no-fail-fast` run against a two-target project.
113
+ _CARGO_FAILURE_LINE_RE = re.compile(r"^ ([A-Za-z_][A-Za-z0-9_:]*)\s*$")
114
+
115
+
116
+ def cargo_failed_test_names(output: str) -> list[str]:
117
+ """Extract unique failing test names from ``cargo test`` output.
118
+
119
+ Mirrors the intent of the shell's::
120
+
121
+ awk '/^failures:$/{f=1;next} /^test result:/{f=0}
122
+ f && /^ [a-zA-Z_]+::/{print $1}' | sort -u
123
+
124
+ with one deliberate fix: the awk pattern requires "::" in the name, so a
125
+ top-level test with no enclosing module (see comment above) is silently
126
+ dropped. This implementation matches any four-space-indented line that
127
+ is *entirely* a test-name token (letters/digits/underscore/colon), with
128
+ or without "::".
129
+
130
+ Returns test names deduped and sorted, matching ``sort -u``.
131
+ """
132
+ seen: set[str] = set()
133
+ collecting = False
134
+ for line in output.splitlines():
135
+ if line == "failures:":
136
+ collecting = True
137
+ continue
138
+ if line.startswith("test result:"):
139
+ collecting = False
140
+ continue
141
+ if collecting:
142
+ m = _CARGO_FAILURE_LINE_RE.match(line)
143
+ if m:
144
+ seen.add(m.group(1))
145
+ return sorted(seen)
146
+
147
+
148
+ # A compile error is never a flake. Deliberate fix vs. the shell's
149
+ # `^error(\[E[0-9]+\])?:|could not compile`: the bare `^error:` (no `[E..]`
150
+ # code) branch also matches cargo's own generic test-failure wrapper lines —
151
+ # "error: test failed, to rerun pass `--lib`" and "error: 2 targets failed:"
152
+ # — which cargo prints on ANY nonzero-exit test run, compile error or not.
153
+ # Genuinely captured: cargo_multi_target_failures.txt and
154
+ # cargo_no_parseable_failures.txt both contain "error: test failed, to rerun
155
+ # pass ..." despite having no compile error at all; the literal shell regex
156
+ # would misclassify every such run as a compile error, never reaching (or in
157
+ # the future, never flake-retrying) the actual failure list. "error[E####]:"
158
+ # (rustc's own coded diagnostics) and "could not compile" (cargo's own
159
+ # compile-failure summary line) are unambiguous; the bare "error:" prefix is
160
+ # not. Matched per line, matching grep's per-line semantics (the "^" anchor
161
+ # is start-of-line, not start-of-output).
162
+ _CARGO_COMPILE_ERROR_RE = re.compile(r"^error\[E\d+\]:|could not compile")
163
+
164
+
165
+ def cargo_has_compile_error(output: str) -> bool:
166
+ """True if ``output`` shows a genuine rustc/cargo compile error.
167
+
168
+ Mirrors the intent of the shell's::
169
+
170
+ grep -qE "^error(\\[E[0-9]+\\])?:|could not compile"
171
+
172
+ with one deliberate fix: the bare `^error:` alternative (no error code)
173
+ is dropped because it also matches cargo's generic "error: test failed"
174
+ / "error: N targets failed" wrapper lines, which appear on any test
175
+ failure — not just compile errors. See the comment above for a genuine
176
+ repro of the misclassification this caused.
177
+ """
178
+ return any(_CARGO_COMPILE_ERROR_RE.search(line) for line in output.splitlines())