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/network.py ADDED
@@ -0,0 +1,269 @@
1
+ """Network health checks for agent servers over Tailscale (plain HTTP).
2
+
3
+ Tailscale's MagicDNS resolves hostnames and the tailnet encrypts the
4
+ connection, so we use plain HTTP on the agent port. This module classifies
5
+ connection failures so the CLI can give actionable diagnostics rather than
6
+ a generic "offline".
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import socket
12
+ import time
13
+ from concurrent.futures import ThreadPoolExecutor
14
+ from dataclasses import dataclass
15
+ from typing import Iterable
16
+
17
+ import httpx
18
+
19
+ from coord.models import Machine
20
+
21
+ AGENT_PORT = 7433
22
+ DEFAULT_TIMEOUT = 3.0
23
+
24
+ # Status categories — keep these stable; downstream code may key off them.
25
+ ONLINE = "online"
26
+ OFFLINE = "offline"
27
+ TIMEOUT = "timeout"
28
+ DNS_ERROR = "dns_error"
29
+ HTTP_ERROR = "http_error"
30
+ RATE_LIMITED = "rate_limited"
31
+ UNKNOWN = "unknown"
32
+
33
+
34
+ @dataclass
35
+ class MachineStatus:
36
+ machine: Machine
37
+ state: str
38
+ reason: str = ""
39
+ latency_ms: float | None = None
40
+ health: dict | None = None
41
+
42
+ @property
43
+ def is_online(self) -> bool:
44
+ return self.state == ONLINE
45
+
46
+
47
+ def classify_error(exc: Exception) -> tuple[str, str]:
48
+ """Map an httpx/network exception to a (state, reason) pair."""
49
+ if isinstance(exc, httpx.ConnectTimeout) or isinstance(exc, httpx.ReadTimeout):
50
+ return TIMEOUT, "timed out"
51
+ if isinstance(exc, httpx.ConnectError):
52
+ msg = str(exc).lower()
53
+ if "name or service not known" in msg or "nodename nor servname" in msg or "getaddrinfo" in msg:
54
+ return DNS_ERROR, "hostname not resolvable (Tailscale up?)"
55
+ if "connection refused" in msg:
56
+ return OFFLINE, "connection refused (agent not running?)"
57
+ return OFFLINE, f"connection failed ({exc})"
58
+ if isinstance(exc, socket.gaierror):
59
+ return DNS_ERROR, "hostname not resolvable (Tailscale up?)"
60
+ if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 429:
61
+ return RATE_LIMITED, "rate limited (429 Too Many Requests)"
62
+ if isinstance(exc, httpx.HTTPError):
63
+ return HTTP_ERROR, f"http error: {exc}"
64
+ return UNKNOWN, f"{type(exc).__name__}: {exc}"
65
+
66
+
67
+ def is_retryable(state: str) -> bool:
68
+ """Whether a classified error state should trigger a retry."""
69
+ return state in (TIMEOUT, RATE_LIMITED, HTTP_ERROR)
70
+
71
+
72
+ def check_machine(machine: Machine, timeout: float = DEFAULT_TIMEOUT) -> MachineStatus:
73
+ """Ping `machine`'s /health endpoint and classify the result."""
74
+ url = f"http://{machine.host}:{AGENT_PORT}/health"
75
+ start = time.perf_counter()
76
+ try:
77
+ resp = httpx.get(url, timeout=timeout)
78
+ except Exception as e: # noqa: BLE001 — we classify all network errors
79
+ state, reason = classify_error(e)
80
+ return MachineStatus(machine=machine, state=state, reason=reason)
81
+
82
+ latency_ms = (time.perf_counter() - start) * 1000.0
83
+ if resp.status_code != 200:
84
+ return MachineStatus(
85
+ machine=machine,
86
+ state=HTTP_ERROR,
87
+ reason=f"HTTP {resp.status_code}",
88
+ latency_ms=latency_ms,
89
+ )
90
+ try:
91
+ health = resp.json()
92
+ except ValueError:
93
+ return MachineStatus(
94
+ machine=machine,
95
+ state=HTTP_ERROR,
96
+ reason="invalid JSON from /health",
97
+ latency_ms=latency_ms,
98
+ )
99
+ return MachineStatus(
100
+ machine=machine,
101
+ state=ONLINE,
102
+ reason="",
103
+ latency_ms=latency_ms,
104
+ health=health,
105
+ )
106
+
107
+
108
+ def check_all(
109
+ machines: Iterable[Machine],
110
+ timeout: float = DEFAULT_TIMEOUT,
111
+ max_workers: int | None = None,
112
+ ) -> list[MachineStatus]:
113
+ """Health-check every machine concurrently. Preserves input order."""
114
+ machines = list(machines)
115
+ if not machines:
116
+ return []
117
+ workers = max_workers or min(8, len(machines))
118
+ with ThreadPoolExecutor(max_workers=workers) as pool:
119
+ return list(pool.map(lambda m: check_machine(m, timeout=timeout), machines))
120
+
121
+
122
+ @dataclass
123
+ class StatusResult:
124
+ """Result of fetching /status from an agent.
125
+
126
+ On success, ``data`` holds the parsed response and ``error`` is None.
127
+ On failure, ``data`` is None and ``error`` describes what went wrong.
128
+ """
129
+
130
+ data: dict | None = None
131
+ error: str | None = None
132
+
133
+ @property
134
+ def ok(self) -> bool:
135
+ return self.data is not None
136
+
137
+
138
+ def fetch_status(machine: Machine, timeout: float = DEFAULT_TIMEOUT) -> StatusResult:
139
+ """GET /status from a machine. Returns a StatusResult (never None)."""
140
+ try:
141
+ resp = httpx.get(
142
+ f"http://{machine.host}:{AGENT_PORT}/status", timeout=timeout
143
+ )
144
+ resp.raise_for_status()
145
+ return StatusResult(data=resp.json())
146
+ except httpx.HTTPStatusError as exc:
147
+ return StatusResult(error=f"HTTP {exc.response.status_code}")
148
+ except (httpx.ConnectTimeout, httpx.ReadTimeout):
149
+ return StatusResult(error="timeout")
150
+ except httpx.ConnectError as exc:
151
+ return StatusResult(error=f"connection error: {exc}")
152
+ except (httpx.HTTPError, ValueError) as exc:
153
+ return StatusResult(error=str(exc))
154
+
155
+
156
+ def fetch_repos(machine: Machine, timeout: float = DEFAULT_TIMEOUT) -> dict | None:
157
+ """GET /repos. Returns None on network error (per-repo errors come back inside the dict)."""
158
+ try:
159
+ resp = httpx.get(
160
+ f"http://{machine.host}:{AGENT_PORT}/repos", timeout=timeout
161
+ )
162
+ resp.raise_for_status()
163
+ return resp.json()
164
+ except (httpx.HTTPError, ValueError):
165
+ return None
166
+
167
+
168
+ def fetch_log(
169
+ machine: Machine,
170
+ assignment_id: str,
171
+ *,
172
+ since: int = 0,
173
+ timeout: float = DEFAULT_TIMEOUT,
174
+ ) -> tuple[int, bytes]:
175
+ """GET /logs/{id} from a machine. Returns (status_code, body)."""
176
+ url = f"http://{machine.host}:{AGENT_PORT}/logs/{assignment_id}"
177
+ params = {"since": since} if since else None
178
+ resp = httpx.get(url, params=params, timeout=timeout)
179
+ return resp.status_code, resp.content
180
+
181
+
182
+ def inject_message(
183
+ machine: Machine,
184
+ assignment_id: str,
185
+ text: str,
186
+ *,
187
+ timeout: float = DEFAULT_TIMEOUT,
188
+ ) -> tuple[int, dict]:
189
+ """POST /inject/{id} to send `text` as a new user message to a running
190
+ worker. Returns (status_code, response_body_dict). Returned status:
191
+ 202 on delivery, 404 unknown assignment, 409 not running, 410 stdin
192
+ closed, 400 bad body.
193
+ """
194
+ url = f"http://{machine.host}:{AGENT_PORT}/inject/{assignment_id}"
195
+ resp = httpx.post(url, json={"text": text}, timeout=timeout)
196
+ try:
197
+ body = resp.json()
198
+ except Exception:
199
+ body = {"error": "non-json response", "raw": resp.text[:200]}
200
+ return resp.status_code, body
201
+
202
+
203
+ def clean_worktrees(
204
+ machine: Machine,
205
+ *,
206
+ recent_secs: float = 300.0,
207
+ protect: list[str] | None = None,
208
+ timeout: float = 30.0,
209
+ ) -> dict:
210
+ """POST /worktree-clean to `machine` (#1220).
211
+
212
+ Same endpoint the manual ``coord agent clean-worktrees`` CLI hits — this
213
+ is the helper a fleet-wide *automatic* sweep (``coord.serve_app``'s tick
214
+ loop) calls per machine. Never raises: any network/HTTP/decode failure
215
+ is folded into the returned dict's ``error`` field (``ok=False``) so one
216
+ unreachable machine can't abort a sweep across the rest of the fleet.
217
+
218
+ #1295: an optional *protect* list of assignment IDs the coordinator
219
+ knows are still live (interactive sessions, dispatched-but-not-yet-
220
+ finished work) is forwarded to the agent so those worktrees are kept
221
+ even when the agent's own record has been lost (agent restart, DB
222
+ reload race). A None / empty *protect* is omitted from the request
223
+ body so an older agent that doesn't know about the field sees the
224
+ same wire shape as before.
225
+
226
+ Returns ``{"ok": bool, "cleaned": int, "kept": int, "bytes_freed": int,
227
+ "cargo_cache_bytes": int, "cargo_caches_evicted": int,
228
+ "error": str | None}``. The ``cargo_*`` fields (#1402) report the shared
229
+ cargo target cache's size after the agent's GC and how many per-repo
230
+ caches it evicted; they are 0 against an agent too old to report them.
231
+ """
232
+ url = f"http://{machine.host}:{AGENT_PORT}/worktree-clean"
233
+ payload: dict[str, object] = {"recent_secs": recent_secs}
234
+ if protect:
235
+ payload["protect"] = list(protect)
236
+ try:
237
+ resp = httpx.post(url, json=payload, timeout=timeout)
238
+ except Exception as e: # noqa: BLE001 — classify uniformly, never propagate
239
+ _, reason = classify_error(e)
240
+ return {"ok": False, "cleaned": 0, "kept": 0, "bytes_freed": 0, "error": reason}
241
+ if resp.status_code != 200:
242
+ return {
243
+ "ok": False,
244
+ "cleaned": 0,
245
+ "kept": 0,
246
+ "bytes_freed": 0,
247
+ "error": f"HTTP {resp.status_code}",
248
+ }
249
+ try:
250
+ data = resp.json()
251
+ except ValueError:
252
+ return {
253
+ "ok": False,
254
+ "cleaned": 0,
255
+ "kept": 0,
256
+ "bytes_freed": 0,
257
+ "error": "invalid JSON from /worktree-clean",
258
+ }
259
+ return {
260
+ "ok": True,
261
+ "cleaned": data.get("cleaned", 0),
262
+ "kept": data.get("kept", 0),
263
+ "bytes_freed": data.get("bytes_freed", 0),
264
+ # #1402: shared cargo-cache GC counters. ``.get`` defaults keep an
265
+ # older agent (which doesn't report them) on the same wire shape.
266
+ "cargo_cache_bytes": data.get("cargo_cache_bytes", 0),
267
+ "cargo_caches_evicted": data.get("cargo_caches_evicted", 0),
268
+ "error": None,
269
+ }
@@ -0,0 +1,229 @@
1
+ """#316: dispatch a `type="new-issue-chat"` session for drafting a new GitHub issue.
2
+
3
+ Used by `coord new-issue-chat <repo>` (the CLI command shelled out by the
4
+ TUI's "New issue" action). Seeds the worker with the repo's CLAUDE.md, the
5
+ per-repo new-issue guidance from ``coordinator.yml``, and a bounded list of
6
+ recently open issues so the chat can flag near-duplicates. The developer
7
+ drives the conversation; the worker helps articulate intent and produces a
8
+ finished issue body in the ``TITLE: / --- / body`` format.
9
+
10
+ The session runs as a ``type="new-issue-chat"`` assignment on an agent server.
11
+ Tools are restricted to ``Read,Bash`` with a deny list that blocks all
12
+ mutations (``gh issue create``, ``git push``, etc.) — the user-side TUI
13
+ handles the actual ``gh issue create`` submission.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import time
19
+ import uuid
20
+ from pathlib import Path
21
+
22
+ from coord import github_ops
23
+ from coord.config import Config
24
+ from coord.models import Machine, Proposal, Repo
25
+ from coord.refine_chat import _read_claude_md
26
+
27
+ # How many open issues to fetch for near-duplicate detection.
28
+ MAX_OPEN_ISSUES = 20
29
+
30
+
31
+ def pick_new_issue_chat_machine(cfg: Config, repo: str) -> Machine | None:
32
+ """Pick a machine to run the new-issue-chat session on.
33
+
34
+ Returns the first unpaused machine that lists *repo* and has a resolved
35
+ ``repo_path`` for it. New-issue-chat is read-only and short-lived, so
36
+ any qualified machine works — no freshness / capacity weighting needed.
37
+ Returns ``None`` when no machine claims the repo.
38
+ """
39
+ from coord.machine_pause import paused_set
40
+
41
+ paused = paused_set(cfg.machines)
42
+ for m in cfg.machines:
43
+ if (
44
+ m.can_work_on(repo)
45
+ and m.repo_path(repo) is not None
46
+ and m.name not in paused
47
+ ):
48
+ return m
49
+ return None
50
+
51
+
52
+ def _fetch_open_issues(slug: str, limit: int = MAX_OPEN_ISSUES) -> list[dict]:
53
+ """Best-effort fetch of open issue titles from ``gh issue list``.
54
+
55
+ Returns a list of ``{"number": int, "title": str}`` dicts, truncated to
56
+ *limit*. Returns an empty list on any failure so the seed still works
57
+ without issue-list context.
58
+ """
59
+ try:
60
+ raw = github_ops._gh(
61
+ "issue", "list",
62
+ "--repo", slug,
63
+ "--state", "open",
64
+ "--limit", str(limit),
65
+ "--json", "number,title",
66
+ )
67
+ data = json.loads(raw)
68
+ if not isinstance(data, list):
69
+ return []
70
+ return data[:limit]
71
+ except (RuntimeError, ValueError, json.JSONDecodeError):
72
+ return []
73
+
74
+
75
+ def build_new_issue_briefing(
76
+ *,
77
+ repo_slug: str,
78
+ claude_md: str,
79
+ new_issue_guidance: str,
80
+ open_issues: list[dict],
81
+ ) -> str:
82
+ """Compose the seed briefing the worker sees as its first user message.
83
+
84
+ The agent's ``NEW_ISSUE_CHAT_SYSTEM_PROMPT`` already tells it how to
85
+ behave; this packs the repo context into the first user turn.
86
+ """
87
+ parts: list[str] = []
88
+ parts.append(f"=== New-issue chat context for {repo_slug} ===\n")
89
+
90
+ parts.append("ISSUE GUIDANCE (required sections and conventions):")
91
+ parts.append(new_issue_guidance.strip() if new_issue_guidance.strip() else "(none)")
92
+ parts.append("")
93
+
94
+ parts.append("PROJECT CLAUDE.md:")
95
+ parts.append(claude_md.strip() if claude_md.strip() else "(not found)")
96
+ parts.append("")
97
+
98
+ parts.append(
99
+ f"RECENT OPEN ISSUES (last {MAX_OPEN_ISSUES}, check for near-duplicates):"
100
+ )
101
+ if open_issues:
102
+ for issue in open_issues:
103
+ num = issue.get("number", "?")
104
+ title = (issue.get("title") or "").strip()
105
+ parts.append(f" #{num}: {title}")
106
+ else:
107
+ parts.append(" (none fetched)")
108
+ parts.append("")
109
+
110
+ parts.append("---")
111
+ parts.append(
112
+ "The developer will now describe the feature or bug they want to file "
113
+ "as an issue. Ask focused clarifying questions to help shape a "
114
+ "well-structured draft. When ready, present the draft in the "
115
+ "TITLE: / --- / body format."
116
+ )
117
+ return "\n".join(parts)
118
+
119
+
120
+ def dispatch_new_issue_chat(
121
+ repo_name: str,
122
+ config: Config,
123
+ *,
124
+ machine_override: str | None = None,
125
+ ) -> tuple[str, str]:
126
+ """End-to-end: pick a machine, seed the briefing, dispatch a
127
+ ``type="new-issue-chat"`` assignment. Returns ``(assignment_id, machine_name)``.
128
+
129
+ Raises ``RuntimeError`` when the repo is unknown, no machine claims it,
130
+ or the agent rejects the dispatch.
131
+ """
132
+ repo_cfg = config.repo(repo_name)
133
+ if repo_cfg is None:
134
+ raise RuntimeError(
135
+ f"repo {repo_name!r} not in coordinator.yml"
136
+ )
137
+
138
+ # Pick the machine.
139
+ if machine_override:
140
+ machine = next(
141
+ (m for m in config.machines if m.name == machine_override),
142
+ None,
143
+ )
144
+ if machine is None:
145
+ raise RuntimeError(
146
+ f"machine {machine_override!r} not in coordinator.yml"
147
+ )
148
+ if not machine.can_work_on(repo_name):
149
+ raise RuntimeError(
150
+ f"machine {machine_override!r} does not list repo {repo_name!r}"
151
+ )
152
+ else:
153
+ picked = pick_new_issue_chat_machine(config, repo_name)
154
+ if picked is None:
155
+ raise RuntimeError(
156
+ f"no machine claims repo {repo_name!r} — new-issue-chat needs a "
157
+ "machine that has the repo cloned to read CLAUDE.md"
158
+ )
159
+ machine = picked
160
+
161
+ repo_path = machine.repo_path(repo_name)
162
+ if repo_path is None:
163
+ raise RuntimeError(
164
+ f"machine {machine.name!r} has no resolved path for repo {repo_name!r}"
165
+ )
166
+
167
+ # Read local context (coordinator's view of the repo).
168
+ repo_root = Path(repo_path).expanduser()
169
+ claude_md = _read_claude_md(repo_root)
170
+ new_issue_guidance = repo_cfg.resolve_new_issue_guidance(repo_root)
171
+ open_issues = _fetch_open_issues(repo_cfg.github)
172
+
173
+ briefing = build_new_issue_briefing(
174
+ repo_slug=repo_cfg.github,
175
+ claude_md=claude_md,
176
+ new_issue_guidance=new_issue_guidance,
177
+ open_issues=open_issues,
178
+ )
179
+
180
+ resolved_model = config.models.default
181
+ proposal = Proposal(
182
+ id=0,
183
+ machine_name=machine.name,
184
+ repo_name=repo_name,
185
+ # No existing issue — use 0 as a sentinel. The DB column is NOT NULL
186
+ # but there is no issue to reference yet.
187
+ issue_number=0,
188
+ issue_title="(new issue draft)",
189
+ rationale="new-issue-chat",
190
+ briefing=briefing,
191
+ model=resolved_model,
192
+ type="new-issue-chat",
193
+ required_gates=[],
194
+ )
195
+
196
+ from coord.dispatch import dispatch_with_retry
197
+ from coord.models import Assignment
198
+ from coord.state import record_dispatched_assignment
199
+
200
+ response = dispatch_with_retry(
201
+ proposal,
202
+ config,
203
+ max_retries=config.concurrency.max_retries,
204
+ backoff_base=config.concurrency.backoff_base,
205
+ )
206
+
207
+ assignment_id = response.get("id") or uuid.uuid4().hex[:12]
208
+
209
+ # Persist the assignment row so the TUI / notify / board can find it.
210
+ asg = Assignment(
211
+ machine_name=machine.name,
212
+ repo_name=repo_name,
213
+ issue_number=0,
214
+ issue_title="(new issue draft)",
215
+ files_allowed=[],
216
+ files_forbidden=[],
217
+ briefing=briefing,
218
+ assignment_id=assignment_id,
219
+ status="running",
220
+ dispatched_at=time.time(),
221
+ type="new-issue-chat",
222
+ model=resolved_model,
223
+ )
224
+ record_dispatched_assignment(
225
+ assignment=asg,
226
+ repo_github=repo_cfg.github,
227
+ )
228
+
229
+ return assignment_id, machine.name