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/test_chat.py ADDED
@@ -0,0 +1,352 @@
1
+ """#314 Phase B: dispatch a `type="test-chat"` session for a completed work assignment.
2
+
3
+ Used by `coord test-chat <work_assignment_id>` (the CLI command shelled out by
4
+ the TUI's T keybind on a Test-stage issue). Builds a seed briefing that
5
+ pre-loads the worker with the PR diff, the most recent build log, the
6
+ worker's SMOKE_TESTS block, the repo's run command, and the repo's CLAUDE.md
7
+ — the context a developer needs to validate the change.
8
+
9
+ The session itself runs as a `type="test-chat"` assignment on an agent
10
+ server. Tools are restricted to ``Read,Bash`` (see ``coord/agent.py``); write-
11
+ side Bash commands are blocked by the deny list injected into the system
12
+ prompt. The developer drives the conversation via ``POST /inject/{id}`` from
13
+ the TUI.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import subprocess
19
+ import time
20
+ import uuid
21
+ from pathlib import Path
22
+
23
+ import httpx
24
+
25
+ from coord.config import Config
26
+ from coord.dispatch import AGENT_PORT
27
+ from coord.models import Assignment, Machine, Repo
28
+
29
+ # Soft caps so the seed briefing stays bounded. Diffs on large refactors can
30
+ # run to tens of thousands of lines; build logs can be even larger.
31
+ MAX_DIFF_LINES = 500
32
+ MAX_BUILD_LOG_LINES = 200
33
+ MAX_CLAUDE_MD_CHARS = 8000
34
+
35
+ # Commands the test-chat worker is not allowed to run. Injected into the
36
+ # AssignmentSpec payload so ``agent.py`` can surface them in the deny prompt.
37
+ _TEST_CHAT_DENY_COMMANDS = [
38
+ "Bash(gh *)",
39
+ "Bash(git push *)",
40
+ "Bash(git commit *)",
41
+ "Bash(git checkout *)",
42
+ ]
43
+
44
+
45
+ def pick_test_chat_machine(cfg: Config, repo: str) -> Machine | None:
46
+ """Pick a machine to run the test-chat session on.
47
+
48
+ Returns the first reachable machine that lists *repo* and is not paused.
49
+ Test-chat is read-plus-bash and short-lived, so any qualified machine
50
+ works — no freshness / capacity weighting. Returns ``None`` when no
51
+ machine claims the repo.
52
+ """
53
+ from coord.machine_pause import paused_set # noqa: PLC0415
54
+
55
+ paused = paused_set(cfg.machines)
56
+ for m in cfg.machines:
57
+ if (
58
+ m.can_work_on(repo)
59
+ and m.repo_path(repo) is not None
60
+ and m.name not in paused
61
+ ):
62
+ return m
63
+ return None
64
+
65
+
66
+ def _fetch_diff(branch: str, repo_root: Path) -> str:
67
+ """Best-effort fetch of the diff between *branch* and its upstream base.
68
+
69
+ Tries ``git diff origin/main...HEAD`` (triple-dot — shows only the commits
70
+ on the feature branch). Falls back to a two-dot diff against main, then
71
+ gives up with a placeholder. Clamped to ``MAX_DIFF_LINES`` lines so the
72
+ seed stays bounded even for large PRs.
73
+ """
74
+ diff_cmds: list[list[str]] = [
75
+ ["git", "diff", "origin/main...", "HEAD"],
76
+ ["git", "diff", f"origin/main...{branch}"],
77
+ ["git", "diff", "main...", branch],
78
+ ]
79
+ for cmd in diff_cmds:
80
+ try:
81
+ result = subprocess.run(
82
+ cmd,
83
+ cwd=str(repo_root),
84
+ capture_output=True,
85
+ text=True,
86
+ timeout=15,
87
+ )
88
+ if result.returncode == 0 and result.stdout.strip():
89
+ lines = result.stdout.splitlines()
90
+ if len(lines) > MAX_DIFF_LINES:
91
+ truncated = len(lines) - MAX_DIFF_LINES
92
+ lines = lines[:MAX_DIFF_LINES]
93
+ lines.append(f"…[{truncated} more diff lines truncated]")
94
+ return "\n".join(lines)
95
+ except (OSError, subprocess.TimeoutExpired):
96
+ continue
97
+ return "(diff not available)"
98
+
99
+
100
+ def _read_build_log(work_assignment_id: str) -> str:
101
+ """Read the most recent Phase-1 build log for *work_assignment_id*.
102
+
103
+ The TUI writes ``~/.coord/test-build-<id>.log`` when the user presses B
104
+ to run ``coord test``. Returns a placeholder when the file doesn't exist
105
+ (user hasn't run the build yet) or can't be read. Clamped to
106
+ ``MAX_BUILD_LOG_LINES`` lines.
107
+ """
108
+ log_path = Path.home() / ".coord" / f"test-build-{work_assignment_id}.log"
109
+ try:
110
+ text = log_path.read_text(encoding="utf-8", errors="replace")
111
+ except (OSError, FileNotFoundError):
112
+ return "(no build log — press B in the TUI to run `coord test` first)"
113
+ lines = text.splitlines()
114
+ if len(lines) > MAX_BUILD_LOG_LINES:
115
+ truncated = len(lines) - MAX_BUILD_LOG_LINES
116
+ lines = lines[:MAX_BUILD_LOG_LINES]
117
+ lines.append(f"…[{truncated} more log lines truncated]")
118
+ return "\n".join(lines) if lines else "(build log empty)"
119
+
120
+
121
+ def _read_claude_md(repo_root: Path) -> str:
122
+ """Read the repo's top-level CLAUDE.md, clamped to ``MAX_CLAUDE_MD_CHARS``."""
123
+ path = repo_root / "CLAUDE.md"
124
+ try:
125
+ text = path.read_text(encoding="utf-8", errors="replace")
126
+ except (OSError, FileNotFoundError):
127
+ return ""
128
+ if len(text) > MAX_CLAUDE_MD_CHARS:
129
+ return text[:MAX_CLAUDE_MD_CHARS] + "\n…[truncated]"
130
+ return text
131
+
132
+
133
+ def build_test_briefing(
134
+ *,
135
+ work_assignment_id: str,
136
+ issue_number: int,
137
+ issue_title: str,
138
+ repo_slug: str,
139
+ branch: str | None,
140
+ smoke_tests: list[str] | None,
141
+ run_cmd: str | None,
142
+ diff: str,
143
+ build_log: str,
144
+ claude_md: str,
145
+ ) -> str:
146
+ """Compose the seed briefing the test-chat worker sees as its first message.
147
+
148
+ The agent's ``TEST_CHAT_SYSTEM_PROMPT`` already tells it how to behave;
149
+ this packs the per-PR context so the assistant can give specific guidance.
150
+ """
151
+ parts: list[str] = []
152
+ parts.append(
153
+ f"=== Test-chat context for {repo_slug}#{issue_number}: {issue_title} ===\n"
154
+ )
155
+
156
+ # PR diff — clamp to MAX_DIFF_LINES even when the caller already fetched
157
+ # a bounded diff, as a defensive second cap in case large diffs slip through.
158
+ diff_lines = diff.splitlines() if diff else []
159
+ if len(diff_lines) > MAX_DIFF_LINES:
160
+ truncated = len(diff_lines) - MAX_DIFF_LINES
161
+ diff_lines = diff_lines[:MAX_DIFF_LINES]
162
+ diff_lines.append(f"…[{truncated} more diff lines truncated]")
163
+ diff_clamped = "\n".join(diff_lines)
164
+ parts.append("PR DIFF:")
165
+ parts.append(diff_clamped.strip() if diff_clamped.strip() else "(no diff available)")
166
+ parts.append("")
167
+
168
+ # Build log.
169
+ parts.append("MOST RECENT BUILD LOG:")
170
+ parts.append(build_log.strip() if build_log.strip() else "(no build log)")
171
+ parts.append("")
172
+
173
+ # Smoke-test bullets from the worker.
174
+ parts.append("WORKER SMOKE_TESTS:")
175
+ if smoke_tests is None:
176
+ parts.append("(worker did not emit a SMOKE_TESTS block)")
177
+ elif len(smoke_tests) == 0:
178
+ parts.append("(none — worker reported change is internal)")
179
+ else:
180
+ for bullet in smoke_tests:
181
+ parts.append(f"- {bullet}")
182
+ parts.append("")
183
+
184
+ # App launch command.
185
+ parts.append("RUN COMMAND:")
186
+ parts.append(run_cmd.strip() if run_cmd and run_cmd.strip() else "(not configured)")
187
+ parts.append("")
188
+
189
+ # Project CLAUDE.md.
190
+ parts.append("PROJECT CLAUDE.md:")
191
+ parts.append(claude_md.strip() if claude_md.strip() else "(not found)")
192
+ parts.append("")
193
+
194
+ parts.append("---")
195
+ parts.append(
196
+ "The developer will now ask you questions about how to test this change. "
197
+ "Help them understand what to verify, which smoke-test bullets to prioritise, "
198
+ "and how to interpret build output. Use Read and Bash (read-only) to "
199
+ "inspect the repo when helpful. Keep replies short."
200
+ )
201
+ return "\n".join(parts)
202
+
203
+
204
+ def dispatch_test_chat(
205
+ *,
206
+ cfg: Config,
207
+ repo_cfg: Repo,
208
+ repo: str,
209
+ work_assignment_id: str,
210
+ machine_override: str | None = None,
211
+ ) -> tuple[str, str]:
212
+ """End-to-end: pick a machine, seed the briefing, dispatch a test-chat
213
+ assignment. Returns ``(assignment_id, machine_name)``.
214
+
215
+ Raises ``RuntimeError`` when no machine claims the repo, when the
216
+ work assignment can't be found in the DB, or when the agent rejects
217
+ the dispatch.
218
+ """
219
+ # Pick the machine.
220
+ if machine_override:
221
+ machine = next(
222
+ (m for m in cfg.machines if m.name == machine_override),
223
+ None,
224
+ )
225
+ if machine is None:
226
+ raise RuntimeError(
227
+ f"machine {machine_override!r} not in coordinator.yml"
228
+ )
229
+ if not machine.can_work_on(repo):
230
+ raise RuntimeError(
231
+ f"machine {machine_override!r} does not list repo {repo!r}"
232
+ )
233
+ else:
234
+ picked = pick_test_chat_machine(cfg, repo)
235
+ if picked is None:
236
+ raise RuntimeError(
237
+ f"no machine claims repo {repo!r} — test-chat needs a "
238
+ f"machine that has the repo cloned"
239
+ )
240
+ machine = picked
241
+
242
+ repo_path = machine.repo_path(repo)
243
+ if repo_path is None:
244
+ raise RuntimeError(
245
+ f"machine {machine.name!r} has no resolved path for repo {repo!r}"
246
+ )
247
+
248
+ # Look up the work assignment from the DB.
249
+ from coord.db import get_connection # noqa: PLC0415
250
+
251
+ conn = get_connection()
252
+ row = conn.execute(
253
+ "SELECT issue_number, issue_title, branch, smoke_tests "
254
+ "FROM assignments WHERE assignment_id=?",
255
+ (work_assignment_id,),
256
+ ).fetchone()
257
+ if row is None:
258
+ raise RuntimeError(
259
+ f"work assignment {work_assignment_id!r} not found in DB"
260
+ )
261
+ issue_number: int = row["issue_number"]
262
+ issue_title: str = row["issue_title"]
263
+ branch: str | None = row["branch"]
264
+
265
+ # Decode smoke_tests JSON column.
266
+ smoke_tests: list[str] | None = None
267
+ raw_smoke = row["smoke_tests"]
268
+ if raw_smoke is not None:
269
+ try:
270
+ decoded = json.loads(raw_smoke)
271
+ smoke_tests = decoded if isinstance(decoded, list) else None
272
+ except (ValueError, TypeError):
273
+ smoke_tests = None
274
+
275
+ # Gather diff, build log, run_cmd, and CLAUDE.md.
276
+ repo_root = Path(repo_path).expanduser()
277
+ diff = _fetch_diff(branch, repo_root) if branch else "(no branch recorded)"
278
+ build_log = _read_build_log(work_assignment_id)
279
+ run_cmd = repo_cfg.run_cmd
280
+ claude_md = _read_claude_md(repo_root)
281
+
282
+ briefing = build_test_briefing(
283
+ work_assignment_id=work_assignment_id,
284
+ issue_number=issue_number,
285
+ issue_title=issue_title,
286
+ repo_slug=repo_cfg.github,
287
+ branch=branch,
288
+ smoke_tests=smoke_tests,
289
+ run_cmd=run_cmd,
290
+ diff=diff,
291
+ build_log=build_log,
292
+ claude_md=claude_md,
293
+ )
294
+
295
+ # Resolve model and branch for the agent payload.
296
+ resolved_model = cfg.models.default
297
+ wire_model = cfg.models.resolve(resolved_model)
298
+ default_branch = repo_cfg.default_branch or "main"
299
+
300
+ # Coordinator-only files the worker must not touch.
301
+ files_forbidden: list[str] = list(repo_cfg.coordinator_only_files)
302
+
303
+ # POST directly to the agent so we can inject the test-chat–specific
304
+ # deny_commands (not present on the Proposal dataclass). This mirrors
305
+ # the pattern used by review.py.
306
+ url = f"http://{machine.host}:{AGENT_PORT}/assign"
307
+ payload: dict = {
308
+ "repo_name": repo,
309
+ "repo_path": repo_path,
310
+ "issue_number": issue_number,
311
+ "issue_title": issue_title,
312
+ "briefing": briefing,
313
+ "files_allowed": [],
314
+ "files_forbidden": files_forbidden,
315
+ "pull_repos": [],
316
+ "deny_commands": _TEST_CHAT_DENY_COMMANDS,
317
+ "model": wire_model,
318
+ "type": "test-chat",
319
+ "branch": default_branch,
320
+ }
321
+ try:
322
+ resp = httpx.post(url, json=payload, timeout=15)
323
+ resp.raise_for_status()
324
+ agent_response = resp.json()
325
+ except (httpx.HTTPError, httpx.TimeoutException) as exc:
326
+ raise RuntimeError(f"agent rejected test-chat dispatch: {exc}") from exc
327
+
328
+ assignment_id: str = agent_response.get("id") or uuid.uuid4().hex[:12]
329
+
330
+ # Persist the assignment row so the TUI can find it by polling the DB.
331
+ asg = Assignment(
332
+ machine_name=machine.name,
333
+ repo_name=repo,
334
+ issue_number=issue_number,
335
+ issue_title=issue_title,
336
+ files_allowed=[],
337
+ files_forbidden=files_forbidden,
338
+ briefing=briefing,
339
+ assignment_id=assignment_id,
340
+ status="running",
341
+ dispatched_at=time.time(),
342
+ type="test-chat",
343
+ model=resolved_model,
344
+ )
345
+ from coord.state import record_dispatched_assignment # noqa: PLC0415
346
+
347
+ record_dispatched_assignment(
348
+ assignment=asg,
349
+ repo_github=repo_cfg.github,
350
+ )
351
+
352
+ return assignment_id, machine.name