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,1780 @@
1
+ """Core dispatch commands: `assign`, `approve`, `plan`, `retry`, `stop`,
2
+ `inject`, `chat-continue`. The mode-specific `_dispatch_*` worker
3
+ implementations `assign` delegates to live in dispatch_workers.py.
4
+ Extracted from coord/cli.py (#747)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import dataclasses
9
+ import socket
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ import click
14
+ import httpx
15
+
16
+ from coord import github_ops
17
+ from coord.config import describe_model_choice
18
+
19
+ from coord.commands._common import AGENT_PORT, _CONFIG_OPTION, _load_config
20
+ from coord.commands.dispatch_workers import (
21
+ _dispatch_audit_of,
22
+ _dispatch_chat,
23
+ _dispatch_fix_of,
24
+ _dispatch_headless,
25
+ _dispatch_interactive_work,
26
+ _dispatch_merge_of,
27
+ _dispatch_milestone_chat_of,
28
+ _dispatch_review_of,
29
+ _dispatch_rework_of,
30
+ _dispatch_smoke_of,
31
+ _dispatch_troubleshoot,
32
+ )
33
+
34
+
35
+ def _stdin_is_tty() -> bool:
36
+ """Seam over ``sys.stdin.isatty()`` — see ``_require_interactive_tty``
37
+ for what this gates and why (#2086).
38
+
39
+ ``click.testing.CliRunner.invoke()`` swaps in its own non-TTY stdin
40
+ object for the duration of the call, so a test that monkeypatches
41
+ ``sys.stdin.isatty`` *before* calling ``invoke()`` is patching an
42
+ attribute on the object ``sys.stdin`` pointed at pre-call — CliRunner's
43
+ replacement object is unaffected. Routing the check through this
44
+ module-level function gives tests (and any other caller) a stable seam
45
+ to patch directly instead.
46
+ """
47
+ return sys.stdin.isatty()
48
+
49
+
50
+ def _require_interactive_tty(dry_run: bool) -> None:
51
+ """Refuse ``--interactive`` when stdin is not a TTY and this isn't a
52
+ dry run (#2086).
53
+
54
+ Every ``--interactive`` flavour hands the child session (and, for a
55
+ remote/tmux target, the final ``tmux attach-session``) the operator's
56
+ own terminal — there's no human at the keyboard to drive the
57
+ paste/attach handshake without one. Left unchecked, this used to fall
58
+ through to ``input()`` in interactive.py swallowing the resulting
59
+ ``EOFError``, the attach failing ("Pseudo-terminal will not be
60
+ allocated…" / "no sessions"), and the session never actually starting
61
+ — yet by then the assignment was already claimed + recorded as
62
+ dispatched, so the #466 git-floor backstop could go on to record a
63
+ false ``done`` and the auto-loop would dispatch real, metered
64
+ downstream stages (Test, Review, Merge) against work that never
65
+ happened. Same precedent as ``--skip-review`` refusing explicitly
66
+ rather than silently degrading when routed to the daemon (#821/#1489):
67
+ fail loudly, before any claim/board write, so a confusing multi-minute
68
+ cascade becomes one clear error instead.
69
+
70
+ Called from :func:`_build_interactive_launch_setup` — the single choke
71
+ point every ``--interactive`` flavour passes through in BOTH callers
72
+ (``assign()`` and ``coord.test_author.dispatch_test_author_interactive``)
73
+ — so neither can bypass it by construction. ``assign()`` also calls
74
+ this directly, before its own harmless read-only issue-title fetch,
75
+ purely so a no-TTY dispatch fails before that network round-trip too;
76
+ it is the exact same check reused, not a re-implementation of it.
77
+
78
+ A dry run never claims or writes anything (every ``_dispatch_*_of``
79
+ flavour short-circuits on ``dry_run`` before any claim/attach code
80
+ runs), so it's exempt from this gate too.
81
+ """
82
+ if dry_run or _stdin_is_tty():
83
+ return
84
+ raise RuntimeError(
85
+ "--interactive requires a TTY on stdin — it drives a "
86
+ "human-attended claude session (pre-filling the briefing and "
87
+ "attaching your terminal to it). Run this from an actual "
88
+ "terminal, or omit --interactive for headless dispatch."
89
+ )
90
+
91
+
92
+ @click.command(help="Brain proposes assignments for idle machines.")
93
+ @_CONFIG_OPTION
94
+ @click.option("--dry-run", is_flag=True, help="Plan without saving proposals.")
95
+ def plan(config_path: Path, dry_run: bool) -> None:
96
+ from coord.brain import propose
97
+ from coord.state import save_proposals, save_split_proposals
98
+
99
+ cfg = _load_config(config_path)
100
+ click.echo("Gathering context...", nl=False)
101
+ sys.stdout.flush()
102
+
103
+ from coord.brain import gather_context, build_prompt, call_claude, parse_proposals, parse_split_proposals, resolve_models, resolve_required_gates, SYSTEM_PROMPT
104
+ context = gather_context(cfg)
105
+ issue_count = sum(len(v) for v in context["issues_by_repo"].values())
106
+ online = sum(1 for v in context["machine_status"].values() if v.get("status") != "offline" and "error" not in str(v))
107
+ click.echo(f" {issue_count} issues across {len(cfg.repos)} repos, {online} machines online.")
108
+ click.echo("Calling Claude (this may take 1-2 minutes)...", nl=False)
109
+ sys.stdout.flush()
110
+
111
+ try:
112
+ prompt = build_prompt(cfg, context)
113
+ response = call_claude(SYSTEM_PROMPT, prompt)
114
+ proposals = parse_proposals(response)
115
+ resolve_required_gates(proposals, cfg, context["issues_by_repo"])
116
+ # #1454: this CLI wrapper never called resolve_models() (only
117
+ # coord.brain.propose()'s full cycle did) — so `models.labels`
118
+ # routing was silently dead for the entire `coord plan` ->
119
+ # `coord approve` two-step; every work proposal saved by `coord
120
+ # plan` reached `approve()` with `p.model` unset regardless of the
121
+ # issue's tier/category label, and fell back to `models.default`.
122
+ resolve_models(proposals, cfg, context["issues_by_repo"])
123
+ splits = parse_split_proposals(response)
124
+ except RuntimeError as e:
125
+ click.echo(f"error: {e}", err=True)
126
+ sys.exit(1)
127
+
128
+ # #1711: never show a proposal `coord approve` would immediately refuse
129
+ # for lacking the resolved provider's machine capability (e.g. an
130
+ # opencode-routed repo proposed against a machine with no
131
+ # `provider:opencode`) — see coord.brain.filter_unroutable_provider_proposals.
132
+ from coord.brain import filter_unroutable_provider_proposals
133
+
134
+ proposals, dropped = filter_unroutable_provider_proposals(proposals, cfg)
135
+ for p, reason in dropped:
136
+ click.echo(
137
+ f" ⚠ dropped proposal: {p.machine_name} → {p.repo_name} "
138
+ f"#{p.issue_number}: {reason}"
139
+ )
140
+
141
+ if splits:
142
+ click.echo(f"{len(splits)} split proposal(s):\n")
143
+ for s in splits:
144
+ click.echo(f" [S{s.id}] {s.repo_name} #{s.issue_number}: {s.issue_title}")
145
+ click.echo(f" {s.rationale}")
146
+ click.echo(f" chunks ({len(s.chunks)}):")
147
+ for j, chunk in enumerate(s.chunks, 1):
148
+ click.echo(f" {j}. {chunk.title}")
149
+ click.echo(f" {chunk.scope}")
150
+ click.echo()
151
+
152
+ if proposals:
153
+ click.echo(f"{len(proposals)} assignment proposal(s):\n")
154
+ for p in proposals:
155
+ click.echo(f" [{p.id}] {p.machine_name} → {p.repo_name} #{p.issue_number}: {p.issue_title}")
156
+ click.echo(f" {p.rationale}")
157
+ if p.files_likely:
158
+ click.echo(f" files: {', '.join(p.files_likely)}")
159
+ click.echo()
160
+
161
+ if not proposals and not splits:
162
+ click.echo("No assignments to propose.")
163
+ return
164
+
165
+ if dry_run:
166
+ click.echo("(dry run — proposals not saved)")
167
+ else:
168
+ if proposals:
169
+ save_proposals(proposals)
170
+ if splits:
171
+ save_split_proposals(splits)
172
+ click.echo("Proposals saved.")
173
+ if proposals:
174
+ click.echo("Run `coord approve <ids>` to dispatch (e.g. coord approve 1,2)")
175
+ if splits:
176
+ click.echo("Run `coord split <ids>` to create sub-issues (e.g. coord split S1)")
177
+
178
+
179
+ @click.command(help="Dispatch approved assignments (comma-separated IDs).")
180
+ @click.argument("ids")
181
+ @_CONFIG_OPTION
182
+ @click.option("--dry-run", is_flag=True, help="Show what would be dispatched.")
183
+ @click.option(
184
+ "--auto-pull",
185
+ is_flag=True,
186
+ help="Tell the agent to `git pull --ff-only` stale dependency repos before starting.",
187
+ )
188
+
189
+
190
+ @click.option(
191
+ "--skip-freshness",
192
+ is_flag=True,
193
+ help="Skip the dependency freshness check (faster, no network for GH HEADs).",
194
+ )
195
+
196
+
197
+ def approve(
198
+ ids: str, config_path: Path, dry_run: bool, auto_pull: bool, skip_freshness: bool
199
+ ) -> None:
200
+ from coord import freshness as fresh
201
+ from coord.board_service import read_board, write_board
202
+ from coord.deps import blocked_repos as compute_blocked, build_dep_graph, transitive_deps
203
+ from coord.dispatch import compute_do_not_touch, dispatch, dispatch_with_retry, post_briefing
204
+ from coord.network import classify_error, fetch_repos
205
+ from coord.state import (
206
+ clear_proposals,
207
+ load_proposals,
208
+ record_dispatched,
209
+ )
210
+
211
+ cfg = _load_config(config_path)
212
+ proposals = load_proposals()
213
+ if not proposals:
214
+ click.echo("No pending proposals. Run `coord plan` first.", err=True)
215
+ sys.exit(1)
216
+
217
+ # ── Max-plan usage-window pre-check (#1466) ─────────────────────────
218
+ # A batch approve can start several headless workers at once — exactly
219
+ # what runs a Max-plan 5h/weekly window dry mid-batch, stranding the
220
+ # later workers' branches. The probe is server-side/account-wide and
221
+ # ~60s cached (coord.usage_limits) so this costs nothing extra even
222
+ # across several `coord approve` calls in a row. `usage_gate.mode`
223
+ # defaults to "warn" — never refuses until an operator opts into
224
+ # "block" in coordinator.yml. See UsageGateConfig's docstring for the
225
+ # CAVEAT: this is predictive only while headless usage still draws the
226
+ # subscription windows `/usage` reports (paused rollout as of
227
+ # 2026-06-15) rather than a separate monthly credit pool.
228
+ if cfg.usage_gate.mode != "disabled":
229
+ from coord.usage_limits import evaluate_usage_gate, get_plan_limits
230
+
231
+ gate_result = evaluate_usage_gate(get_plan_limits(), cfg.usage_gate)
232
+ if gate_result.action == "block":
233
+ click.echo(
234
+ f"error: {gate_result.message} (usage_gate.mode: block) — "
235
+ "refusing to dispatch. Wait for the window to reset, or set "
236
+ "usage_gate.mode: warn in coordinator.yml.",
237
+ err=True,
238
+ )
239
+ sys.exit(1)
240
+ elif gate_result.action == "warn":
241
+ click.echo(f"warning: {gate_result.message}", err=True)
242
+
243
+ try:
244
+ selected_ids = [int(x.strip()) for x in ids.split(",")]
245
+ except ValueError:
246
+ click.echo("error: IDs must be comma-separated integers (e.g. 1,3)", err=True)
247
+ sys.exit(2)
248
+
249
+ selected = [p for p in proposals if p.id in selected_ids]
250
+ missing = set(selected_ids) - {p.id for p in selected}
251
+ if missing:
252
+ click.echo(f"error: unknown proposal IDs: {missing}", err=True)
253
+ sys.exit(2)
254
+
255
+ # Warn about dependency-blocked repos
256
+ board = read_board()
257
+ blocked = compute_blocked(cfg.repos, board.active)
258
+ for p in selected:
259
+ if p.repo_name in blocked:
260
+ click.echo(f" warning: {p.repo_name} is blocked by upstream work:", err=True)
261
+ for reason in blocked[p.repo_name]:
262
+ click.echo(f" - {reason}", err=True)
263
+
264
+ # #906: derive in_flight from board.active instead of load_dispatched() so
265
+ # a thin client (empty local DB) still sees peer assignments that are
266
+ # running on other machines (the daemon board tracks them all).
267
+ in_flight = [
268
+ {"machine_name": a.machine_name, "repo_name": a.repo_name, "files_likely": a.files_allowed}
269
+ for a in board.active
270
+ ]
271
+
272
+ # ── Claim pre-check ──────────────────────────────────────────────
273
+ # Refuse any proposal whose issue is already being worked on (board
274
+ # has an active assignment, or remote has an `issue-{N}-*` branch).
275
+ from coord.claim import claim_message, find_work_claim
276
+
277
+ unclaimed: list = []
278
+ for p in selected:
279
+ repo_cfg = cfg.repo(p.repo_name)
280
+ if repo_cfg is None:
281
+ unclaimed.append(p)
282
+ continue
283
+ claim = find_work_claim(
284
+ p.issue_number, p.repo_name, repo_cfg.github, board
285
+ )
286
+ if claim is not None:
287
+ click.echo(
288
+ f"[{p.id}] skipping {p.repo_name} #{p.issue_number}: "
289
+ f"{claim_message(claim)}",
290
+ err=True,
291
+ )
292
+ continue
293
+ unclaimed.append(p)
294
+
295
+ if not unclaimed:
296
+ click.echo("No proposals remain after claim check.", err=True)
297
+ sys.exit(1)
298
+ selected = unclaimed
299
+
300
+ # ── Freshness pre-check ──────────────────────────────────────────
301
+ machine_repos: dict[str, dict | None] = {}
302
+ github_heads: dict[str, str | None] = {}
303
+ if not skip_freshness and not dry_run:
304
+ graph = build_dep_graph(cfg.repos)
305
+ machines_needed = {p.machine_name for p in selected}
306
+ for mname in machines_needed:
307
+ machine = next((m for m in cfg.machines if m.name == mname), None)
308
+ machine_repos[mname] = fetch_repos(machine) if machine else None
309
+
310
+ repos_needed: set[str] = set()
311
+ for p in selected:
312
+ repos_needed.update(transitive_deps(p.repo_name, graph))
313
+ for repo_name in repos_needed:
314
+ repo_cfg = cfg.repo(repo_name)
315
+ if repo_cfg is None:
316
+ github_heads[repo_name] = None
317
+ continue
318
+ try:
319
+ github_heads[repo_name] = github_ops.get_default_branch_head(
320
+ repo_cfg.github, repo_cfg.default_branch
321
+ )
322
+ except RuntimeError as e:
323
+ click.echo(f" warning: could not get HEAD of {repo_cfg.github}: {e}", err=True)
324
+ github_heads[repo_name] = None
325
+
326
+ # ── Auto-split advisory ───────────────────────────────────────────────
327
+ if cfg.dispatch.auto_split:
328
+ from coord.split_work import analyze_plan, format_chunks_summary
329
+
330
+ for p in selected:
331
+ chunks = analyze_plan(p.files_likely, cfg.dispatch)
332
+ if len(chunks) > 1:
333
+ click.echo(
334
+ f" ⚠ [{p.id}] {p.repo_name} #{p.issue_number} touches "
335
+ f"{len(p.files_likely)} files (threshold: "
336
+ f"{cfg.dispatch.max_files_per_worker}) — consider splitting:"
337
+ )
338
+ click.echo(format_chunks_summary(chunks))
339
+
340
+ for p in selected:
341
+ click.echo(f"[{p.id}] {p.machine_name} → {p.repo_name} #{p.issue_number}: {p.issue_title}")
342
+ # Resolve model so the dispatched record and board reflect what ran.
343
+ # #1430: coord.brain.resolve_models() already set p.model from
344
+ # models.labels (via config.models.model_for_labels) for work
345
+ # proposals with a matching label, during `coord plan`. That call
346
+ # sets `proposal.model` *directly* from the label match — with zero
347
+ # awareness of the effective provider or its pin.
348
+ #
349
+ # #1454: that resolution is only as fresh as the issue's labels AT
350
+ # PLAN TIME. "Label it, then dispatch it" is the documented tier
351
+ # workflow (#1430) — and `coord plan` / `coord approve` are two
352
+ # separate invocations that can be minutes or hours apart, so a
353
+ # label added after planning must still win here, not silently fall
354
+ # back to `models.default` because the plan-time snapshot missed
355
+ # it. When there's no already-resolved model, re-check the issue's
356
+ # CURRENT labels with a live fetch (same as `coord assign` /
357
+ # `coord milestone dispatch` already do) before falling back.
358
+ #
359
+ # #1798 review fix: a plan-time `p.model` used to flow straight into
360
+ # `dispatch()` untouched. `dispatch()` (via `resolve_dispatch_model()`)
361
+ # treats a non-None `proposal.model` as an *explicit* override — the
362
+ # branch meant for "a human was specific" — so a plan-time label
363
+ # match (e.g. "haiku") won over a pinned non-claude provider's own
364
+ # model exactly like before the #1798 precedence fix, and now trips
365
+ # the new `enforce_model_provider_compatibility` gate instead of
366
+ # just silently misdispatching. `coord approve` has no `--model`
367
+ # flag of its own, so there is no genuine explicit override at this
368
+ # call site: every non-None `p.model` reaching this loop is
369
+ # label-derived (from `coord.brain.resolve_models()` at plan time,
370
+ # or a human editing the saved proposal — either way label-shaped,
371
+ # not "a human was specific about --model just now"). Feed it to
372
+ # `resolve_dispatch_model_alias` as *label_model*, never as
373
+ # *explicit_model*, the same way `_dispatch_headless` and
374
+ # `milestone_dispatch.dispatch_entry` already do — so a provider's
375
+ # pin still wins over it.
376
+ matched_label: str | None = None
377
+ shadowed_labels: list[str] = []
378
+ label_model: str | None = None
379
+ used_plan_time_snapshot = False
380
+ # #1889: this issue's labels, used for BOTH models.labels (below)
381
+ # and providers.labels (the resolve_provider_name call right after
382
+ # this block).
383
+ work_issue_labels: list[str] = []
384
+ if p.type == "work":
385
+ if p.model:
386
+ label_model = p.model
387
+ used_plan_time_snapshot = True
388
+ # #1889: no live re-fetch here — mirrors the model-resolution
389
+ # optimization above (a plan-time-resolved model is trusted
390
+ # as-is, no freshness re-check). Reuse the plan-time label
391
+ # snapshot `coord.brain.resolve_models()` already stamped
392
+ # onto the proposal (empty if that never ran, e.g. a hand-
393
+ # authored/edited proposal) so providers.labels has SOME
394
+ # signal here without adding a GitHub call this branch has
395
+ # deliberately never made.
396
+ work_issue_labels = list(p.issue_labels)
397
+ else:
398
+ repo_for_model = cfg.repo(p.repo_name)
399
+ if repo_for_model is not None:
400
+ try:
401
+ fresh_issue = github_ops.get_issue(repo_for_model.github, p.issue_number)
402
+ work_issue_labels = [
403
+ lbl.get("name", "") for lbl in (fresh_issue.get("labels") or [])
404
+ ]
405
+ except RuntimeError:
406
+ work_issue_labels = [] # fail open — fall back to default below
407
+ label_model, matched_label, shadowed_labels = (
408
+ cfg.models.model_for_labels_with_reason(work_issue_labels)
409
+ )
410
+ # #1889: write the (possibly freshly-fetched) labels back onto
411
+ # the proposal — `dispatch()` (called below) does its OWN
412
+ # providers.labels resolution from `proposal.issue_labels`, and
413
+ # it must agree with what THIS loop just echoed, or the dry-run/
414
+ # echoed reason and the actual wire payload could silently
415
+ # disagree (the exact divergence class #1798 was about, one
416
+ # level up).
417
+ p.issue_labels = work_issue_labels
418
+ # #1707: needed regardless of whether p.model still needs resolving
419
+ # below — also used by the "model:" echo's provider-pin branch.
420
+ # #1889: `issue_labels=work_issue_labels` threads providers.labels
421
+ # through the same precedence chain — see resolve_provider_name's
422
+ # docstring for the full spec > label > repo > default order.
423
+ from coord.providers import resolve_provider_name # noqa: PLC0415
424
+
425
+ repo_for_provider = cfg.repo(p.repo_name)
426
+ effective_provider_name = resolve_provider_name(
427
+ p.provider,
428
+ repo_for_provider.provider if repo_for_provider is not None else None,
429
+ cfg.providers,
430
+ issue_labels=work_issue_labels,
431
+ )
432
+ # #1706 review fix: don't force `models.default` (a Claude model
433
+ # alias) onto a non-claude/claude-pty provider that pins its own
434
+ # `model` in `providers.definitions.<name>.model` — see
435
+ # `resolve_dispatch_model_alias`'s docstring. #1798 review fix:
436
+ # unconditional now (was `if not p.model:`), so a plan-time/already-
437
+ # resolved `label_model` (above) is still checked against the pin
438
+ # instead of bypassing it — see the comment above for why trusting
439
+ # a pre-set `p.model` as an implicit *explicit_model* was the bug.
440
+ from coord.dispatch import resolve_dispatch_model_alias # noqa: PLC0415
441
+
442
+ p.model = resolve_dispatch_model_alias(
443
+ explicit_model=None,
444
+ label_model=label_model,
445
+ config=cfg,
446
+ effective_provider_name=effective_provider_name,
447
+ )
448
+ if p.model:
449
+ click.echo(
450
+ " model: "
451
+ + describe_model_choice(
452
+ resolved_model=p.model,
453
+ explicit_reason="resolved at plan time" if used_plan_time_snapshot else None,
454
+ matched_label=matched_label,
455
+ shadowed_labels=shadowed_labels,
456
+ )
457
+ )
458
+ else:
459
+ # #1706/#1798: p.model is None when the effective provider's own
460
+ # `providers.definitions.<name>.model` is pinned and neither an
461
+ # explicit/plan-time model nor a label matched — or when one DID
462
+ # match/was already resolved but lost to the pin. Surface which
463
+ # one lost explicitly, mirroring `_dispatch_headless`'s
464
+ # equivalent branch, instead of silently printing nothing — a
465
+ # namespace-mismatched label/plan-time model (a Claude alias)
466
+ # silently losing to a non-claude provider's pin is exactly the
467
+ # transparency #1798 asked for.
468
+ _pinned = cfg.providers.definitions.get(effective_provider_name)
469
+ if _pinned is not None and _pinned.model:
470
+ _via = f"providers.definitions[{effective_provider_name!r}].model"
471
+ if matched_label:
472
+ click.echo(
473
+ f" model: {_pinned.model} (via {_via}, "
474
+ f"overriding label {matched_label!r})"
475
+ )
476
+ elif used_plan_time_snapshot and label_model:
477
+ click.echo(
478
+ f" model: {_pinned.model} (via {_via}, "
479
+ f"overriding plan-time model {label_model!r})"
480
+ )
481
+ else:
482
+ click.echo(f" model: {_pinned.model} (via {_via})")
483
+ # #1889: mirror the --model reasoning line above — state which link
484
+ # of the spec (Proposal.provider) → providers.labels → repo
485
+ # (Repo.provider) → providers.default chain
486
+ # (coord.providers.resolve_provider_name) won, so a
487
+ # providers.labels match (e.g. `harness:opencode`) is legible at
488
+ # `coord approve`/`--dry-run` time, not just discoverable via
489
+ # coordinator.yml.
490
+ from coord.providers import describe_provider_choice # noqa: PLC0415
491
+
492
+ click.echo(
493
+ " provider: "
494
+ + describe_provider_choice(
495
+ spec_provider=p.provider,
496
+ repo_provider=repo_for_provider.provider if repo_for_provider is not None else None,
497
+ providers_cfg=cfg.providers,
498
+ issue_labels=work_issue_labels,
499
+ )
500
+ )
501
+ # Resolve required_gates: fall back to config default for proposals
502
+ # that were saved before label-based gate resolution was wired in.
503
+ if not p.required_gates:
504
+ p.required_gates = list(cfg.pipeline.default_gates)
505
+ if dry_run:
506
+ click.echo(" (dry run — not dispatched)")
507
+ continue
508
+
509
+ pull_repos: list[str] = []
510
+ if not skip_freshness:
511
+ agent_repos = machine_repos.get(p.machine_name) or {}
512
+ freshness = fresh.dependency_freshness(p, cfg, agent_repos, github_heads)
513
+ needs = fresh.stale_or_dirty(freshness)
514
+ if needs:
515
+ for f in needs:
516
+ click.echo(
517
+ f" dependency {f.repo_name}: {f.state}"
518
+ + (f" ({f.error})" if f.error else ""),
519
+ err=True,
520
+ )
521
+ if auto_pull:
522
+ pull_repos = [f.repo_name for f in needs if f.state == fresh.STALE]
523
+ if pull_repos:
524
+ click.echo(f" will pull on agent before worker: {pull_repos}")
525
+ else:
526
+ addendum = fresh.format_briefing_addendum(freshness)
527
+ if addendum:
528
+ p.briefing = (p.briefing or "") + addendum
529
+
530
+ def _on_retry(attempt, max_r, state, reason, wait):
531
+ click.echo(
532
+ f" retry {attempt}/{max_r} after {state} ({reason}), "
533
+ f"waiting {wait:.0f}s...",
534
+ err=True,
535
+ )
536
+
537
+ try:
538
+ response = dispatch_with_retry(
539
+ p, cfg,
540
+ max_retries=cfg.concurrency.max_retries,
541
+ backoff_base=cfg.concurrency.backoff_base,
542
+ pull_repos=pull_repos,
543
+ on_retry=_on_retry,
544
+ )
545
+ except httpx.HTTPError as e:
546
+ state, reason = classify_error(e)
547
+ click.echo(
548
+ f" dispatch failed after {cfg.concurrency.max_retries} retries: "
549
+ f"{p.machine_name} {state} — {reason}",
550
+ err=True,
551
+ )
552
+ continue
553
+ except ValueError as e:
554
+ click.echo(f" dispatch failed: {e}", err=True)
555
+ continue
556
+ assignment_id = response.get("id", "pending")
557
+ click.echo(f" dispatched to agent server (assignment {assignment_id})")
558
+
559
+ repo = cfg.repo(p.repo_name)
560
+ if repo is not None:
561
+ record_dispatched(
562
+ assignment_id=assignment_id,
563
+ proposal=p,
564
+ repo_github=repo.github,
565
+ provider_name=response.get("_provider_name"),
566
+ )
567
+
568
+ try:
569
+ do_not_touch = compute_do_not_touch(p, peers=selected, in_flight=in_flight)
570
+ post_briefing(p, cfg, assignment_id=assignment_id, do_not_touch=do_not_touch)
571
+ click.echo(" briefing posted to GitHub")
572
+ except Exception as e:
573
+ click.echo(f" briefing post failed: {e}", err=True)
574
+
575
+ if not dry_run and p is not selected[-1] and cfg.concurrency.stagger_seconds > 0:
576
+ import time as _time
577
+ click.echo(f" staggering {cfg.concurrency.stagger_seconds:.0f}s before next dispatch...")
578
+ _time.sleep(cfg.concurrency.stagger_seconds)
579
+
580
+ if not dry_run:
581
+ clear_proposals()
582
+ board = read_board()
583
+ board.round_number += 1
584
+ write_board(board)
585
+ click.echo("\nPending proposals cleared. Board saved.")
586
+
587
+ # Mark session start on first dispatch of the session
588
+ from coord.state import load_session, write_session_start
589
+ session = load_session()
590
+ if session is None or session.get("clean_shutdown", True):
591
+ write_session_start()
592
+
593
+
594
+ @click.command(help="Directly assign an issue to a machine, bypassing coord plan.")
595
+ @click.argument("machine")
596
+ @click.argument("repo")
597
+ @click.argument("issue", type=int)
598
+ @_CONFIG_OPTION
599
+ @click.option("--briefing", default="", help="Optional briefing text for the worker.")
600
+ @click.option(
601
+ "--briefing-file",
602
+ "briefing_file",
603
+ default=None,
604
+ type=click.Path(exists=True, dir_okay=False),
605
+ help=(
606
+ "#569: read the briefing from a file instead of --briefing. Avoids "
607
+ "shell-quoting a multi-line briefing on the command line (a multi-line "
608
+ "--briefing typed into a PTY shell strands it at `quote>`). Overrides "
609
+ "--briefing when both are given."
610
+ ),
611
+ )
612
+
613
+
614
+ @click.option(
615
+ "--model",
616
+ default=None,
617
+ help="Claude model tier (haiku, sonnet, opus). Defaults to models.default.",
618
+ )
619
+
620
+
621
+ @click.option(
622
+ "--provider",
623
+ "cli_provider",
624
+ default=None,
625
+ help=(
626
+ "#1707: per-assignment worker-backend override — the human escape "
627
+ "hatch for the precedence chain this flag > Repo.provider > "
628
+ "providers.default (coord/providers/__init__.py's "
629
+ "resolve_provider_name). Must name a key in providers.definitions "
630
+ "(coordinator.yml); validated here, before dispatch, and the error "
631
+ "lists the valid names. Lets two concurrent `coord assign`s in the "
632
+ "SAME repo run different backends (e.g. claude on one issue, "
633
+ "opencode on another) since worktrees are already per-assignment. "
634
+ "Not supported with --interactive, which always spawns the "
635
+ "human-attended claude-pty provider directly. A human-attended-only "
636
+ "backend (capabilities().human_attended_only) is still refused for "
637
+ "this unattended path — see guard_unattended_dispatch."
638
+ ),
639
+ )
640
+
641
+
642
+ @click.option("--dry-run", is_flag=True, help="Show what would be dispatched.")
643
+ @click.option(
644
+ "--driven-by",
645
+ "driven_by",
646
+ default=None,
647
+ hidden=True,
648
+ help=(
649
+ "#1499: internal — stamps durable provenance (`Assignment.driven_by` / "
650
+ "`Proposal.driven_by`) on the dispatched assignment so it stays "
651
+ "distinguishable from a hand `coord assign` in the board/audit log "
652
+ "after the driver process exits. Set by `coord drive`'s work-stage "
653
+ "dispatch (`drive:<repo>#<issue>`); not meant to be typed by hand."
654
+ ),
655
+ )
656
+ @click.option(
657
+ "--plan-only",
658
+ is_flag=True,
659
+ help=(
660
+ "Dispatch a read-only planning worker. The worker reads the codebase "
661
+ "and outputs a structured plan (FILES_READ, FILES_MODIFY, APPROACH, "
662
+ "RISKS, ESTIMATE) without writing code or modifying files. "
663
+ "No worktree or feature branch is created."
664
+ ),
665
+ )
666
+
667
+
668
+ @click.option(
669
+ "--no-plan",
670
+ is_flag=True,
671
+ help=(
672
+ "Force a direct work dispatch even when dispatch.require_plan is true "
673
+ "in coordinator.yml. Has no effect when require_plan is false."
674
+ ),
675
+ )
676
+
677
+
678
+ @click.option(
679
+ "--force",
680
+ is_flag=True,
681
+ help=(
682
+ "Bypass claim detection (retry after infra failures); also overrides "
683
+ "the pipeline.max_review_iterations cap on --fix-of so an intractable "
684
+ "story can keep iterating."
685
+ ),
686
+ )
687
+
688
+
689
+ @click.option(
690
+ "--no-pull",
691
+ is_flag=True,
692
+ help=(
693
+ "Skip the auto-pull of stale dependency repos on the agent. "
694
+ "The briefing still carries a 'pull these before building' "
695
+ "addendum so the worker is aware (#267)."
696
+ ),
697
+ )
698
+
699
+
700
+ @click.option(
701
+ "--skip-freshness",
702
+ is_flag=True,
703
+ help=(
704
+ "Skip the dependency freshness check entirely — faster, no "
705
+ "network for GH HEADs. Matches `coord approve --skip-freshness` (#267)."
706
+ ),
707
+ )
708
+
709
+
710
+ @click.option(
711
+ "--interactive",
712
+ is_flag=True,
713
+ help=(
714
+ "HUMAN-ATTENDED launcher (#437): start interactive `claude` "
715
+ "locally on THIS terminal with the briefing PRE-FILLED in the "
716
+ "input box. You press Enter to submit and Ctrl-C / `/exit` to "
717
+ "end the session. Used for the subscription-billed path; the "
718
+ "coordinator does NOT watch the TTY, does NOT auto-submit, does "
719
+ "NOT advance the pipeline from session output. This bypasses "
720
+ "the agent HTTP server and runs `claude` as a child of your "
721
+ "shell."
722
+ ),
723
+ )
724
+
725
+
726
+ @click.option(
727
+ "--review-of",
728
+ "review_of",
729
+ default=None,
730
+ help=(
731
+ "Launch a human-attended interactive REVIEW of completed work "
732
+ "assignment <ID> (the work id from `coord status`). Implies a "
733
+ "review-shaped dispatch: type=review linked to the work (so the merge "
734
+ "gate's has_approved_review can find the verdict), the diff-only "
735
+ "review briefing, and NO isolated worktree (read-only in the live "
736
+ "checkout). Report your verdict with `coord report-result --verdict "
737
+ "approve|request-changes`. Requires --interactive; local-only for now "
738
+ "(remote review is Track B / #486)."
739
+ ),
740
+ )
741
+
742
+
743
+ @click.option(
744
+ "--fix-of",
745
+ "fix_of",
746
+ default=None,
747
+ help=(
748
+ "Leg 3 (#517): launch a human-attended interactive FIX for a review "
749
+ "assignment <ID> whose verdict was request-changes. Continues on the "
750
+ "reviewed work's EXISTING branch (so the same PR is updated, not a new "
751
+ "orphan branch), is briefed with the reviewer's findings, and bumps "
752
+ "review_iteration so the next review can scope to just the fix delta. "
753
+ "ALSO accepts a WORK assignment id whose test gate FAILED (#581): the "
754
+ "fix is then briefed with the recorded test-failure story. "
755
+ "Requires --interactive; local-only for now (remote is Track B / #486)."
756
+ ),
757
+ )
758
+
759
+
760
+ @click.option(
761
+ "--troubleshoot",
762
+ "troubleshoot",
763
+ is_flag=True,
764
+ default=False,
765
+ help=(
766
+ "#569: launch a human-attended READ-ONLY diagnostic session for a "
767
+ "stalled item. Runs in the LIVE checkout with NO claim and NO worktree "
768
+ "(so it never conflicts with the item's own in-progress claim), "
769
+ "type=troubleshoot, briefed from --briefing/--briefing-file. Requires "
770
+ "--interactive; local-only."
771
+ ),
772
+ )
773
+
774
+
775
+ @click.option(
776
+ "--chat",
777
+ "chat",
778
+ is_flag=True,
779
+ default=False,
780
+ help=(
781
+ "#628: launch a human-attended 'Chat about issue' session — a live "
782
+ "interactive `claude` seeded with everything we know about the issue "
783
+ "(body, comments, board state). Ask open questions ('is this still "
784
+ "needed?', 'what milestone?', 'sketch the UX'), diagnose a stall, edit "
785
+ "the issue (via `coord issue edit`), and send it to Pending (`coord "
786
+ "ready`). Runs in the LIVE checkout with NO claim and NO worktree; "
787
+ "type=chat. Mutates the ISSUE through coord (never raw gh) and never the "
788
+ "code/checkout. Requires --interactive; local-only."
789
+ ),
790
+ )
791
+
792
+
793
+ @click.option(
794
+ "--rework-of",
795
+ "rework_of",
796
+ default=None,
797
+ help=(
798
+ "#563: launch a human-attended interactive REWORK of an existing branch. "
799
+ "Accepts a work assignment ID (resolves its branch) or a branch name "
800
+ "directly. Continues on the EXISTING branch (no orphan branch), seeds "
801
+ "the session with the operator-supplied --briefing verbatim, and bumps "
802
+ "review_iteration so the reworked branch is re-reviewed before merge. "
803
+ "Requires --interactive and --briefing; works local and remote (same "
804
+ "worktree + push-back as --fix-of)."
805
+ ),
806
+ )
807
+
808
+
809
+ @click.option(
810
+ "--smoke-of",
811
+ "smoke_of",
812
+ default=None,
813
+ help=(
814
+ "Leg 3c / A3 (#517, #350, #581): launch a human-attended interactive "
815
+ "TESTING agent for completed work assignment <ID>. The agent lists the "
816
+ "smoke tests, pulls the build artifact, guides you through running it, "
817
+ "interviews you about what you saw, and records the verdict with "
818
+ "`coord test --passed|--fail`. Read-only tools, NO worktree (runs in the "
819
+ "live checkout). Requires --interactive; works local and remote (#1010, "
820
+ "same ssh+tmux live-checkout shape as --review-of)."
821
+ ),
822
+ )
823
+
824
+
825
+ @click.option(
826
+ "--merge-of",
827
+ "merge_of",
828
+ default=None,
829
+ help=(
830
+ "Leg 3c (#517, #306): launch a human-attended interactive MERGE agent "
831
+ "for completed+approved work assignment <ID>. Continues the work branch "
832
+ "in a worktree, fetches + rebases it onto the repo's default branch "
833
+ "(proactive rebase, #306), resolves mechanical conflicts, runs the "
834
+ "tests, pushes --force-with-lease, then guides you to merge. Requires "
835
+ "--interactive; works local and remote (#1007, same ssh+tmux worktree "
836
+ "as --fix-of/--rework-of)."
837
+ ),
838
+ )
839
+
840
+
841
+ @click.option(
842
+ "--audit-of",
843
+ "audit_of",
844
+ default=None,
845
+ help=(
846
+ "Milestone Outcome Audit Phase 1 (#885): launch a human-attended "
847
+ "READ-ONLY analyst for the milestone tracking epic <EPIC_ISSUE>. Reads "
848
+ "the epic body's goals/acceptance/plan checklist, enumerates the "
849
+ "milestone's issue states, measures each goal against the code with "
850
+ "shell tools (never trusting ticket state or self-report), and relays "
851
+ "a scorecard verdict via `coord report-result` — posted as a comment "
852
+ "on the epic. Runs in the LIVE checkout with NO claim and NO worktree "
853
+ "(read-only tools only). Requires --interactive; local-only for now."
854
+ ),
855
+ )
856
+
857
+
858
+ @click.option(
859
+ "--milestone-chat-of",
860
+ "milestone_chat_of",
861
+ default=None,
862
+ help=(
863
+ "#1029: launch a human-attended interactive MILESTONE CHAT for the "
864
+ "milestone's tracking issue <TRACKING_ISSUE> — a genuine tmux-attached "
865
+ "`claude` session, replacing the old headless `claude -p` / SSE-overlay "
866
+ "mechanism. Resolves the milestone/tracking-issue context the same way "
867
+ "the headless `coord milestone chat` CLI path does (shared via "
868
+ "`coord.milestone_chat.resolve_milestone_chat_briefing`), and may write "
869
+ "a `## Work order` block / edit the milestone / add a sub-issue via "
870
+ "`coord milestone ...` once you confirm in conversation — never raw "
871
+ "`gh`. Runs in the LIVE checkout with NO claim and NO worktree; "
872
+ "type=milestone-chat. Requires --interactive; local-only for now (the "
873
+ "headless path remains available for remote machines via `coord "
874
+ "milestone chat`)."
875
+ ),
876
+ )
877
+
878
+
879
+ @click.option(
880
+ "--add-child",
881
+ "chat_add_child",
882
+ default=None,
883
+ help=(
884
+ "#1029: paired with --milestone-chat-of — seeds an 'Add sub-issue' "
885
+ "milestone chat for candidate child issue <ISSUE> (mirrors `coord "
886
+ "milestone chat --add-child`). Ignored without --milestone-chat-of."
887
+ ),
888
+ )
889
+
890
+
891
+ def assign(
892
+ machine: str,
893
+ repo: str,
894
+ issue: int,
895
+ config_path: Path,
896
+ briefing: str,
897
+ model: str | None,
898
+ # #1707: named `cli_provider`, not `provider` — the --interactive branch
899
+ # below reuses the bare name `provider` for the ClaudePtyProvider
900
+ # *instance* it always spawns (see `_build_interactive_launch_setup`);
901
+ # colliding names here would shadow one with the other mid-function.
902
+ cli_provider: str | None,
903
+ dry_run: bool,
904
+ driven_by: str | None,
905
+ plan_only: bool,
906
+ no_plan: bool,
907
+ force: bool,
908
+ no_pull: bool,
909
+ skip_freshness: bool,
910
+ interactive: bool,
911
+ review_of: str | None,
912
+ fix_of: str | None,
913
+ briefing_file: str | None,
914
+ troubleshoot: bool,
915
+ chat: bool,
916
+ rework_of: str | None,
917
+ smoke_of: str | None,
918
+ merge_of: str | None,
919
+ audit_of: str | None,
920
+ milestone_chat_of: str | None,
921
+ chat_add_child: str | None,
922
+ ) -> None:
923
+ cfg = _load_config(config_path)
924
+
925
+ # Validate machine exists in config
926
+ machine_obj = next((m for m in cfg.machines if m.name == machine), None)
927
+ if machine_obj is None:
928
+ click.echo(
929
+ f"error: machine {machine!r} not in coordinator.yml "
930
+ f"(have: {[m.name for m in cfg.machines]})",
931
+ err=True,
932
+ )
933
+ sys.exit(2)
934
+
935
+ # Validate repo exists in config
936
+ repo_cfg = cfg.repo(repo)
937
+ if repo_cfg is None:
938
+ click.echo(
939
+ f"error: repo {repo!r} not in coordinator.yml "
940
+ f"(have: {[r.name for r in cfg.repos]})",
941
+ err=True,
942
+ )
943
+ sys.exit(2)
944
+
945
+ # Validate machine can work on this repo
946
+ if not machine_obj.can_work_on(repo):
947
+ click.echo(
948
+ f"error: machine {machine!r} does not list repo {repo!r} "
949
+ f"(has: {machine_obj.repos})",
950
+ err=True,
951
+ )
952
+ sys.exit(2)
953
+
954
+ # Refuse direct assignment to a paused machine — `coord pause` exists
955
+ # so the user can explicitly steer work away. If they meant to dispatch
956
+ # anyway they should `coord unpause` first. #1862: also refuses inside
957
+ # a machine's `quiet_hours` window — `coord unpause` is the fix there
958
+ # too (it grants a quiet-hours override) — but the message names the
959
+ # window and its end time rather than reusing the generic hand-pause
960
+ # wording, so the operator isn't left guessing whether this is a
961
+ # deliberate `coord pause` or just an overnight window about to lift
962
+ # on its own (same distinguishability #1862 requires of `coord status`
963
+ # and the TUI sidebar, applied to this refusal too).
964
+ from coord.machine_pause import describe_pause_state as _describe_pause_state
965
+ from coord.machine_pause import paused_set as _paused_set
966
+ paused = _paused_set(cfg.machines)
967
+ if machine in paused:
968
+ state = _describe_pause_state(machine_obj, paused)
969
+ if state is not None and state.kind == "quiet":
970
+ click.echo(
971
+ f"error: machine {machine!r} is in quiet hours {state.detail}; "
972
+ f"run `coord unpause {machine}` to override for the rest of the window",
973
+ err=True,
974
+ )
975
+ else:
976
+ click.echo(
977
+ f"error: machine {machine!r} is paused; run `coord unpause {machine}` first",
978
+ err=True,
979
+ )
980
+ sys.exit(2)
981
+
982
+ # #1707: validate --provider at the CLI, before any network call or
983
+ # dispatch — an unknown name must never reach dispatch() (where it would
984
+ # currently just fall through to the agent's own unknown-provider
985
+ # handling per guard_unattended_dispatch's docstring). "claude" is always
986
+ # valid even on a bare config because ProvidersConfig.__post_init__
987
+ # always materialises the implicit entry, so cfg.providers.definitions
988
+ # is the complete, authoritative set of valid names.
989
+ if cli_provider is not None and cli_provider not in cfg.providers.definitions:
990
+ click.echo(
991
+ f"error: provider {cli_provider!r} not in providers.definitions "
992
+ f"(have: {sorted(cfg.providers.definitions)})",
993
+ err=True,
994
+ )
995
+ sys.exit(2)
996
+
997
+ # --provider only has meaning on the headless (config-driven provider
998
+ # registry) dispatch path below. --interactive always spawns
999
+ # ClaudePtyProvider() directly (_build_interactive_launch_setup) — it
1000
+ # never looks a name up in providers.definitions — so accepting
1001
+ # --provider there would either silently do nothing or read as though it
1002
+ # could steer an interactive session onto a different backend. Refuse
1003
+ # instead of guessing.
1004
+ if cli_provider is not None and interactive:
1005
+ click.echo(
1006
+ "error: --provider is not supported with --interactive "
1007
+ "(interactive sessions always use the human-attended claude-pty "
1008
+ "provider directly; there is no name to select)",
1009
+ err=True,
1010
+ )
1011
+ sys.exit(2)
1012
+
1013
+ # #2086: fails fast, before the (harmless, read-only) issue-title fetch
1014
+ # below — see _require_interactive_tty's docstring for the full
1015
+ # rationale. This is the SAME check _build_interactive_launch_setup
1016
+ # enforces further down for every --interactive flavour (here and in
1017
+ # coord.test_author.dispatch_test_author_interactive), called again
1018
+ # here only so a no-TTY dispatch fails before that network round-trip.
1019
+ if interactive:
1020
+ try:
1021
+ _require_interactive_tty(dry_run)
1022
+ except RuntimeError as e:
1023
+ click.echo(f"error: {e}", err=True)
1024
+ sys.exit(2)
1025
+
1026
+ # Fetch the issue title from GitHub
1027
+ try:
1028
+ issue_data = github_ops.get_issue(repo_cfg.github, issue)
1029
+ except RuntimeError as e:
1030
+ click.echo(f"error: could not fetch issue #{issue}: {e}", err=True)
1031
+ sys.exit(1)
1032
+ issue_title = issue_data.get("title", f"Issue #{issue}")
1033
+
1034
+ # --briefing-file (#569): read the briefing from a file; this avoids having
1035
+ # to shell-quote a multi-line briefing on the command line (a multi-line
1036
+ # --briefing typed into a PTY shell strands it at `quote>`). Overrides
1037
+ # --briefing when both are given.
1038
+ if briefing_file:
1039
+ briefing = Path(briefing_file).read_text(encoding="utf-8")
1040
+
1041
+ # Auto-generate briefing from issue body when none provided.
1042
+ if not briefing:
1043
+ issue_body = issue_data.get("body", "")
1044
+ if issue_body:
1045
+ briefing = f"Issue #{issue}: {issue_title}\n\n{issue_body}"
1046
+
1047
+ # A1 (interactive-mode migration): --review-of is a flavour of the
1048
+ # human-attended interactive launcher, so it requires --interactive.
1049
+ if review_of is not None and not interactive:
1050
+ click.echo("error: --review-of requires --interactive", err=True)
1051
+ sys.exit(2)
1052
+
1053
+ # Leg 3 (#517): --fix-of is a sibling flavour — a human-attended fix of a
1054
+ # request-changes review. Same interactive requirement; mutually exclusive
1055
+ # with --review-of (a dispatch is one shape or the other).
1056
+ if fix_of is not None and not interactive:
1057
+ click.echo("error: --fix-of requires --interactive", err=True)
1058
+ sys.exit(2)
1059
+ if fix_of is not None and review_of is not None:
1060
+ click.echo("error: --fix-of and --review-of are mutually exclusive", err=True)
1061
+ sys.exit(2)
1062
+
1063
+ # #569: --troubleshoot is a read-only diagnostic flavour — requires
1064
+ # --interactive.
1065
+ if troubleshoot and not interactive:
1066
+ click.echo("error: --troubleshoot requires --interactive", err=True)
1067
+ sys.exit(2)
1068
+
1069
+ # #628: --chat (Chat about issue) — human-attended, requires --interactive.
1070
+ if chat and not interactive:
1071
+ click.echo("error: --chat requires --interactive", err=True)
1072
+ sys.exit(2)
1073
+
1074
+ # #563: --rework-of — requires --interactive, and --briefing so the operator
1075
+ # always supplies explicit rework instructions.
1076
+ if rework_of is not None and not interactive:
1077
+ click.echo("error: --rework-of requires --interactive", err=True)
1078
+ sys.exit(2)
1079
+ if rework_of is not None and not (briefing or "").strip():
1080
+ click.echo(
1081
+ "error: --rework-of requires --briefing (supply the rework instructions).",
1082
+ err=True,
1083
+ )
1084
+ sys.exit(2)
1085
+
1086
+ # Leg 3c (#517): --smoke-of (interactive testing agent) and --merge-of
1087
+ # (interactive merge agent) — each requires --interactive.
1088
+ if smoke_of is not None and not interactive:
1089
+ click.echo("error: --smoke-of requires --interactive", err=True)
1090
+ sys.exit(2)
1091
+ if merge_of is not None and not interactive:
1092
+ click.echo("error: --merge-of requires --interactive", err=True)
1093
+ sys.exit(2)
1094
+
1095
+ # #885: --audit-of (interactive milestone-outcome analyst) also requires
1096
+ # --interactive — same shape as smoke/merge above.
1097
+ if audit_of is not None and not interactive:
1098
+ click.echo("error: --audit-of requires --interactive", err=True)
1099
+ sys.exit(2)
1100
+
1101
+ # #1029: --milestone-chat-of (interactive milestone chat) — same shape.
1102
+ if milestone_chat_of is not None and not interactive:
1103
+ click.echo("error: --milestone-chat-of requires --interactive", err=True)
1104
+ sys.exit(2)
1105
+ if chat_add_child is not None and milestone_chat_of is None:
1106
+ click.echo("error: --add-child requires --milestone-chat-of", err=True)
1107
+ sys.exit(2)
1108
+
1109
+ # All interactive flavours are mutually exclusive — a dispatch is exactly
1110
+ # one shape (review / fix / troubleshoot / rework / smoke / merge / audit /
1111
+ # milestone-chat).
1112
+ _interactive_flavours = [
1113
+ ("--review-of", review_of is not None),
1114
+ ("--fix-of", fix_of is not None),
1115
+ ("--troubleshoot", troubleshoot),
1116
+ ("--chat", chat),
1117
+ ("--rework-of", rework_of is not None),
1118
+ ("--smoke-of", smoke_of is not None),
1119
+ ("--merge-of", merge_of is not None),
1120
+ ("--audit-of", audit_of is not None),
1121
+ ("--milestone-chat-of", milestone_chat_of is not None),
1122
+ ]
1123
+ _set_flavours = [name for name, on in _interactive_flavours if on]
1124
+ if len(_set_flavours) > 1:
1125
+ click.echo(
1126
+ f"error: {', '.join(_set_flavours)} are mutually exclusive "
1127
+ "(a dispatch is exactly one shape).",
1128
+ err=True,
1129
+ )
1130
+ sys.exit(2)
1131
+
1132
+ # #437: HUMAN-ATTENDED branch. When --interactive is set, we run
1133
+ # interactive `claude` as a child of THIS shell with the briefing
1134
+ # PRE-FILLED in the input box. No HTTP agent, no Proposal, no
1135
+ # GitHub posting, no board update — the operator drives the session
1136
+ # and closes it manually. This is the subscription-billed escape
1137
+ # hatch from Anthropic ToS §3.7 metering. Resolving
1138
+ # ClaudePtyProvider here AND asserting its capabilities are flagged
1139
+ # human_attended_only is the structural guarantee that this path is
1140
+ # the only one that can launch it; the unattended dispatch sites
1141
+ # (dispatch/review/reconcile) refuse the same capability.
1142
+ #
1143
+ # assign() is a thin dispatcher (#746): the validation above is the
1144
+ # only logic that lives here. Every dispatch SHAPE (review / fix /
1145
+ # troubleshoot / chat / rework / smoke / merge / plain-interactive /
1146
+ # headless) is a self-contained _dispatch_* function below — each one
1147
+ # mirrors exactly what used to be a top-level if/elif branch in this
1148
+ # function, taking the already-validated values as parameters.
1149
+ if interactive:
1150
+ setup = _build_interactive_launch_setup(
1151
+ machine=machine, repo=repo, issue=issue, machine_obj=machine_obj,
1152
+ dry_run=dry_run,
1153
+ )
1154
+ provider = setup.provider
1155
+ _is_local = setup.is_local
1156
+ _svc = setup.svc
1157
+ _interactive_board = setup.interactive_board
1158
+ _issue_ctx = setup.issue_ctx
1159
+ _ctx_write_hint = setup.ctx_write_hint
1160
+
1161
+ if review_of is not None:
1162
+ _dispatch_review_of(
1163
+ machine=machine, repo=repo, issue=issue, briefing=briefing,
1164
+ model=model, dry_run=dry_run, review_of=review_of,
1165
+ cfg=cfg, machine_obj=machine_obj, repo_cfg=repo_cfg,
1166
+ issue_data=issue_data, issue_title=issue_title,
1167
+ provider=provider, _is_local=_is_local, _svc=_svc,
1168
+ _interactive_board=_interactive_board, _issue_ctx=_issue_ctx,
1169
+ )
1170
+ return
1171
+ if smoke_of is not None:
1172
+ _dispatch_smoke_of(
1173
+ machine=machine, repo=repo, issue=issue, briefing=briefing,
1174
+ model=model, dry_run=dry_run, smoke_of=smoke_of,
1175
+ cfg=cfg, machine_obj=machine_obj, repo_cfg=repo_cfg,
1176
+ issue_data=issue_data, issue_title=issue_title,
1177
+ provider=provider, _is_local=_is_local, _svc=_svc,
1178
+ _interactive_board=_interactive_board, _issue_ctx=_issue_ctx,
1179
+ )
1180
+ return
1181
+ if troubleshoot:
1182
+ _dispatch_troubleshoot(
1183
+ machine=machine, repo=repo, issue=issue, briefing=briefing,
1184
+ briefing_file=briefing_file, model=model, dry_run=dry_run,
1185
+ cfg=cfg, machine_obj=machine_obj, repo_cfg=repo_cfg,
1186
+ issue_title=issue_title, provider=provider, _is_local=_is_local,
1187
+ _issue_ctx=_issue_ctx, _ctx_write_hint=_ctx_write_hint,
1188
+ )
1189
+ return
1190
+ if chat:
1191
+ _dispatch_chat(
1192
+ machine=machine, repo=repo, issue=issue, briefing=briefing,
1193
+ briefing_file=briefing_file, model=model, dry_run=dry_run,
1194
+ cfg=cfg, machine_obj=machine_obj, repo_cfg=repo_cfg,
1195
+ issue_title=issue_title, provider=provider, _is_local=_is_local,
1196
+ _issue_ctx=_issue_ctx, _ctx_write_hint=_ctx_write_hint,
1197
+ )
1198
+ return
1199
+ if fix_of is not None:
1200
+ _dispatch_fix_of(
1201
+ machine=machine, repo=repo, issue=issue, briefing=briefing,
1202
+ model=model, dry_run=dry_run, force=force, fix_of=fix_of,
1203
+ cfg=cfg, machine_obj=machine_obj, repo_cfg=repo_cfg,
1204
+ issue_title=issue_title, provider=provider, _is_local=_is_local,
1205
+ _svc=_svc, _interactive_board=_interactive_board,
1206
+ _issue_ctx=_issue_ctx, _ctx_write_hint=_ctx_write_hint,
1207
+ )
1208
+ return
1209
+ if rework_of is not None:
1210
+ _dispatch_rework_of(
1211
+ machine=machine, repo=repo, issue=issue, briefing=briefing,
1212
+ model=model, dry_run=dry_run, force=force, rework_of=rework_of,
1213
+ cfg=cfg, machine_obj=machine_obj, repo_cfg=repo_cfg,
1214
+ issue_title=issue_title, provider=provider, _is_local=_is_local,
1215
+ _svc=_svc, _interactive_board=_interactive_board, _issue_ctx=_issue_ctx,
1216
+ )
1217
+ return
1218
+ if merge_of is not None:
1219
+ _dispatch_merge_of(
1220
+ machine=machine, repo=repo, issue=issue, briefing=briefing,
1221
+ model=model, dry_run=dry_run, force=force, merge_of=merge_of,
1222
+ cfg=cfg, machine_obj=machine_obj, repo_cfg=repo_cfg,
1223
+ issue_title=issue_title, provider=provider, _is_local=_is_local,
1224
+ _svc=_svc, _interactive_board=_interactive_board, _issue_ctx=_issue_ctx,
1225
+ )
1226
+ return
1227
+ if audit_of is not None:
1228
+ _dispatch_audit_of(
1229
+ machine=machine, repo=repo, model=model,
1230
+ dry_run=dry_run, audit_of=audit_of,
1231
+ cfg=cfg, machine_obj=machine_obj, repo_cfg=repo_cfg,
1232
+ provider=provider, _is_local=_is_local,
1233
+ _issue_ctx=_issue_ctx,
1234
+ )
1235
+ return
1236
+ if milestone_chat_of is not None:
1237
+ _dispatch_milestone_chat_of(
1238
+ machine=machine, repo=repo, model=model,
1239
+ dry_run=dry_run, milestone_chat_of=milestone_chat_of,
1240
+ add_child=chat_add_child,
1241
+ cfg=cfg, machine_obj=machine_obj, repo_cfg=repo_cfg,
1242
+ provider=provider, _is_local=_is_local,
1243
+ )
1244
+ return
1245
+
1246
+ _dispatch_interactive_work(
1247
+ machine=machine, repo=repo, issue=issue, briefing=briefing,
1248
+ model=model, dry_run=dry_run, plan_only=plan_only, no_plan=no_plan,
1249
+ force=force, cfg=cfg, machine_obj=machine_obj, repo_cfg=repo_cfg,
1250
+ issue_title=issue_title, provider=provider, _is_local=_is_local,
1251
+ _issue_ctx=_issue_ctx, _ctx_write_hint=_ctx_write_hint,
1252
+ )
1253
+ return
1254
+
1255
+ _dispatch_headless(
1256
+ machine=machine, repo=repo, issue=issue, briefing=briefing,
1257
+ model=model, provider=cli_provider, dry_run=dry_run,
1258
+ plan_only=plan_only, no_plan=no_plan,
1259
+ force=force, no_pull=no_pull, skip_freshness=skip_freshness,
1260
+ cfg=cfg, machine_obj=machine_obj, repo_cfg=repo_cfg,
1261
+ issue_data=issue_data, issue_title=issue_title, driven_by=driven_by,
1262
+ )
1263
+
1264
+
1265
+ @dataclasses.dataclass
1266
+ class _InteractiveLaunchSetup:
1267
+ """Shared one-time setup for every `coord assign --interactive` flavour
1268
+ (review/fix/troubleshoot/chat/rework/smoke/merge/plain). Built once per
1269
+ dispatch by :func:`_build_interactive_launch_setup` and threaded into
1270
+ whichever ``_dispatch_*`` function the flavour flags select.
1271
+ """
1272
+
1273
+ provider: object
1274
+ is_local: bool
1275
+ svc: object
1276
+ interactive_board: object
1277
+ issue_ctx: str
1278
+ ctx_write_hint: str
1279
+
1280
+
1281
+ def _build_interactive_launch_setup(
1282
+ *,
1283
+ machine: str,
1284
+ repo: str,
1285
+ issue: int,
1286
+ machine_obj: object,
1287
+ dry_run: bool,
1288
+ ) -> _InteractiveLaunchSetup:
1289
+ # #2086: the authoritative gate — see _require_interactive_tty's
1290
+ # docstring. Every --interactive flavour in BOTH callers (assign()'s
1291
+ # _dispatch_*_of flavours and dispatch_test_author_interactive) passes
1292
+ # through here, so this is the one place neither caller can bypass by
1293
+ # forgetting its own earlier check.
1294
+ _require_interactive_tty(dry_run)
1295
+
1296
+ # #466: The interactive launcher path now CLAIMS the issue and
1297
+ # RECORDS the dispatched assignment up front (it used to write
1298
+ # nothing then sys.exit), and on session exit invokes the
1299
+ # git-floor backstop in :func:`finalize_interactive_exit` so the
1300
+ # board ALWAYS gets a terminal completion — even if the human
1301
+ # closed the TTY without typing `coord report-result`. Both the
1302
+ # backstop and the report-result subcommand write through the
1303
+ # single :mod:`coord.issue_store` seam so the future #183
1304
+ # IssueStore + coordination MCP can slot in without changing any
1305
+ # of these call sites.
1306
+
1307
+ from coord.providers import ClaudePtyProvider # noqa: PLC0415
1308
+
1309
+ provider = ClaudePtyProvider()
1310
+ caps = provider.capabilities()
1311
+ # Structural guard: confirm we wired the right backend.
1312
+ # Use RuntimeError (not assert) so this is never silently removed
1313
+ # when Python runs with -O.
1314
+ if not caps.human_attended_only:
1315
+ raise RuntimeError(
1316
+ "BUG: --interactive resolved a provider whose capabilities do "
1317
+ "NOT report human_attended_only=True; refusing to launch."
1318
+ )
1319
+
1320
+ # Detect whether the target machine is the local machine so we can
1321
+ # choose the local TTY path vs the remote SSH+tmux path (#494).
1322
+ # Mirrors the hostname-matching logic in _save_config_snapshot.
1323
+ _local_hn = socket.gethostname().split(".")[0].lower()
1324
+ _is_local = (
1325
+ machine_obj.name.lower() == _local_hn
1326
+ or machine_obj.host.split(".")[0].lower() == _local_hn
1327
+ )
1328
+
1329
+ # #590/#749: on a thin client the local board/DB is empty, so resolve the
1330
+ # interactive-launch target (--review-of/--fix-of/--rework-of/--smoke-of/
1331
+ # --merge-of) from the daemon's board, and skip the local post-dispatch
1332
+ # save_board (record_dispatched_assignment already routed the row to the
1333
+ # daemon; a local save would write/resurrect an empty local coord.db).
1334
+ from coord.board_service import read_board as _read_interactive_board
1335
+ from coord.board_service import resolve as _resolve_svc # noqa: PLC0415
1336
+
1337
+ _svc = _resolve_svc()
1338
+
1339
+ def _interactive_board(_local_build):
1340
+ """The board used to resolve a launch target: routes through
1341
+ board_service.read_board() (daemon when configured, else local) —
1342
+ *_local_build* (each call site's own ``build_board``) is accepted for
1343
+ backward-compat call-site signatures but no longer called directly."""
1344
+ del _local_build
1345
+ return _read_interactive_board()
1346
+
1347
+ # #603: the per-issue context digest, prepended to the TOP of EVERY
1348
+ # interactive briefing below so each agent reads prior-attempt findings
1349
+ # (cross-repo deps, approaches already tried, hard constraints) first.
1350
+ # Computed once per dispatch; "" when there's no context (no-op prefix).
1351
+ from coord.state import issue_context_block as _issue_context_block # noqa: PLC0415
1352
+
1353
+ _issue_ctx = _issue_context_block(repo, issue)
1354
+
1355
+ # #603 write-path hint: interactive agents run in the operator's
1356
+ # environment (so `coord` is on PATH, unlike #402-PATH-stripped -p
1357
+ # workers). Tell the implementer flavours to record durable findings so
1358
+ # the next agent doesn't rediscover them.
1359
+ _ctx_write_hint = (
1360
+ "\n\n## Record durable findings for future agents (#603)\n"
1361
+ "If you discover something a LATER agent on this issue must know — a "
1362
+ "cross-repo dependency (another repo's branch/commit you had to "
1363
+ "pull), an approach that FAILED and why, or a non-obvious constraint "
1364
+ "— record it so it survives to the next attempt:\n"
1365
+ f' `coord context add {repo} {issue} "<one-line finding>" --pin` '
1366
+ "(--pin for a hard dependency/constraint; omit for a normal note).\n"
1367
+ "It is injected at the TOP of every later briefing for this issue — "
1368
+ "don't rely on memory or the PR alone.\n"
1369
+ )
1370
+ return _InteractiveLaunchSetup(
1371
+ provider=provider,
1372
+ is_local=_is_local,
1373
+ svc=_svc,
1374
+ interactive_board=_interactive_board,
1375
+ issue_ctx=_issue_ctx,
1376
+ ctx_write_hint=_ctx_write_hint,
1377
+ )
1378
+
1379
+
1380
+ @click.command(help="Send a user message to a running worker mid-session.")
1381
+ @click.argument("assignment_id")
1382
+ @click.argument("text", nargs=-1, required=True)
1383
+ @_CONFIG_OPTION
1384
+ def inject(assignment_id: str, text: tuple[str, ...], config_path: Path) -> None:
1385
+ """Inject TEXT as a new user message into the running worker's session.
1386
+
1387
+ The worker picks the message up at its next turn boundary — between
1388
+ tool calls, not mid-tool. Useful for adding guidance to a worker
1389
+ that's going off the rails without having to stop + re-dispatch.
1390
+ """
1391
+ from coord.board_service import read_board
1392
+ from coord.network import inject_message
1393
+
1394
+ cfg = _load_config(config_path)
1395
+ board = read_board()
1396
+
1397
+ assignment = board.find_by_id(assignment_id)
1398
+ if assignment is None:
1399
+ click.echo(f"error: assignment {assignment_id!r} not found in board", err=True)
1400
+ sys.exit(1)
1401
+
1402
+ machine = next(
1403
+ (m for m in cfg.machines if m.name == assignment.machine_name), None
1404
+ )
1405
+ if machine is None:
1406
+ click.echo(f"error: machine {assignment.machine_name!r} not in config", err=True)
1407
+ sys.exit(1)
1408
+
1409
+ message = " ".join(text).strip()
1410
+ if not message:
1411
+ click.echo("error: message text is empty", err=True)
1412
+ sys.exit(2)
1413
+
1414
+ try:
1415
+ status, body = inject_message(machine, assignment_id, message)
1416
+ except (httpx.HTTPError, httpx.TimeoutException) as e:
1417
+ click.echo(f"error: could not reach agent on {machine.name}: {e}", err=True)
1418
+ sys.exit(1)
1419
+
1420
+ if status == 202:
1421
+ click.echo(
1422
+ f"Message delivered to {assignment.repo_name} #{assignment.issue_number} "
1423
+ f"on {machine.name}"
1424
+ )
1425
+ else:
1426
+ click.echo(
1427
+ f"error: agent rejected message (HTTP {status}): {body.get('error', body)}",
1428
+ err=True,
1429
+ )
1430
+ sys.exit(1)
1431
+
1432
+
1433
+ @click.command(name="chat-continue", help="Continue a finished chat session with a new message.")
1434
+ @click.argument("prior_assignment_id")
1435
+ @click.argument("text", nargs=-1, required=True)
1436
+ @_CONFIG_OPTION
1437
+ def chat_continue(
1438
+ prior_assignment_id: str,
1439
+ text: tuple[str, ...],
1440
+ config_path: Path,
1441
+ ) -> None:
1442
+ """Re-dispatch a finished refinement assignment with TEXT as the next user turn.
1443
+
1444
+ Looks up the claude session ID from the prior assignment and passes
1445
+ ``--resume <session_id>`` to the next worker so it loads the full
1446
+ conversation history before seeing TEXT as the next user message.
1447
+
1448
+ Prints the new assignment ID on stdout so the TUI can bind to it.
1449
+ Does NOT post a GitHub briefing comment (chat turns are developer-side
1450
+ conversation, not issue activity).
1451
+ """
1452
+ from coord.board_service import read_board # noqa: PLC0415
1453
+ from coord.dispatch import dispatch
1454
+ from coord.models import Proposal
1455
+ from coord.state import record_dispatched
1456
+
1457
+ cfg = _load_config(config_path)
1458
+
1459
+ # #906: use read_board() so a thin client with an empty local DB still
1460
+ # finds the prior assignment on the daemon's canonical board. The
1461
+ # Assignment model carries all fields we need; claude_session_id is handled
1462
+ # separately below (it is not projected into the board payload).
1463
+ board = read_board()
1464
+ prior = board.find_by_id(prior_assignment_id)
1465
+ if prior is None:
1466
+ click.echo(
1467
+ f"error: assignment {prior_assignment_id!r} not found in DB", err=True
1468
+ )
1469
+ sys.exit(1)
1470
+
1471
+ # claude_session_id is not projected into the board payload. On a daemon
1472
+ # host (no board_service configured), read it directly from the local DB.
1473
+ # On a thin client, svc is not None so we skip the DB and rely solely on
1474
+ # the #315 agent-/status fallback below.
1475
+ from coord.client import resolve_board_service as _resolve_svc # noqa: PLC0415
1476
+
1477
+ claude_session_id = None
1478
+ if _resolve_svc() is None:
1479
+ try:
1480
+ from coord.db import get_connection as _get_conn # noqa: PLC0415
1481
+
1482
+ _row = _get_conn().execute(
1483
+ "SELECT claude_session_id FROM assignments WHERE assignment_id = ?",
1484
+ (prior_assignment_id,),
1485
+ ).fetchone()
1486
+ if _row:
1487
+ claude_session_id = _row[0]
1488
+ except Exception: # noqa: BLE001
1489
+ pass
1490
+
1491
+ machine_name = prior.machine_name
1492
+ repo_name = prior.repo_name
1493
+ issue_number = prior.issue_number
1494
+ issue_title = prior.issue_title
1495
+ message_text = " ".join(text).strip()
1496
+
1497
+ # #316: preserve the chat type so the agent server uses the right system
1498
+ # prompt and tool restrictions on continuation. The known chat types are
1499
+ # "refinement", "test-chat", "new-issue-chat", and "milestone-chat"
1500
+ # (#770); anything else falls back to "refinement" (the original
1501
+ # behaviour before type-preservation).
1502
+ _CHAT_TYPES = {"refinement", "test-chat", "new-issue-chat", "milestone-chat"}
1503
+ prior_type: str = prior.type if prior.type in _CHAT_TYPES else "refinement"
1504
+
1505
+ # #315: if the DB doesn't have the session_id yet, fetch it directly
1506
+ # from the agent's /status endpoint. The notify cycle (typically every
1507
+ # 30s) is what syncs session_id from agent → DB; if the user types a
1508
+ # second chat message before notify catches up, the DB row is still
1509
+ # NULL even though the agent captured the session_id in memory.
1510
+ # Without this fallback every fast follow-up submit fails with
1511
+ # "no session ID captured" and the TUI's bind waits 30s and times out.
1512
+ if not claude_session_id:
1513
+ from coord.network import fetch_status # noqa: PLC0415
1514
+ machine_for_status = next(
1515
+ (m for m in cfg.machines if m.name == machine_name), None,
1516
+ )
1517
+ if machine_for_status is not None:
1518
+ status_result = fetch_status(machine_for_status)
1519
+ if status_result.ok and status_result.data:
1520
+ # /status returns {"active": [...], "completed": [...]}
1521
+ # each entry is AgentAssignment.to_dict() with an `id` field
1522
+ for bucket in ("active", "completed"):
1523
+ for entry in status_result.data.get(bucket, []):
1524
+ if entry.get("id") == prior_assignment_id:
1525
+ sid = entry.get("claude_session_id")
1526
+ if isinstance(sid, str) and sid:
1527
+ claude_session_id = sid
1528
+ # Persist to DB so subsequent calls (and the
1529
+ # coordinator's notify loop) don't re-fetch.
1530
+ try:
1531
+ from coord.state import update_assignment_claude_session_id # noqa: PLC0415
1532
+ update_assignment_claude_session_id(
1533
+ prior_assignment_id, sid,
1534
+ )
1535
+ except Exception: # noqa: BLE001
1536
+ pass
1537
+ break
1538
+ if claude_session_id:
1539
+ break
1540
+
1541
+ if not claude_session_id:
1542
+ click.echo(
1543
+ f"error: assignment {prior_assignment_id!r} has no session ID captured — "
1544
+ "agent has no session_id for this assignment (worker may not have "
1545
+ "emitted system.init, or the agent has restarted and forgotten it)",
1546
+ err=True,
1547
+ )
1548
+ sys.exit(1)
1549
+
1550
+ repo_cfg = cfg.repo(repo_name)
1551
+ if repo_cfg is None:
1552
+ click.echo(f"error: repo {repo_name!r} not found in config", err=True)
1553
+ sys.exit(1)
1554
+
1555
+ # Verify the target machine exists; warn but don't abort if missing
1556
+ # (the agent might still be reachable by name even if not in this config).
1557
+ machine = next((m for m in cfg.machines if m.name == machine_name), None)
1558
+ if machine is None:
1559
+ click.echo(
1560
+ f"warning: machine {machine_name!r} not in config — dispatch may fail",
1561
+ err=True,
1562
+ )
1563
+
1564
+ # #315/#314/#316: use the type from the prior assignment so the agent
1565
+ # server uses the right system prompt and tool restrictions on continuation.
1566
+ # resume_session_id passes --resume so the full prior conversation is
1567
+ # loaded before the new user message is appended.
1568
+ proposal = Proposal(
1569
+ id=0, # not inserted into proposals table; dummy value
1570
+ machine_name=machine_name,
1571
+ repo_name=repo_name,
1572
+ issue_number=issue_number,
1573
+ issue_title=issue_title,
1574
+ rationale="chat continuation",
1575
+ briefing=message_text,
1576
+ type=prior_type,
1577
+ resume_session_id=claude_session_id,
1578
+ )
1579
+
1580
+ try:
1581
+ response = dispatch(proposal, cfg)
1582
+ except Exception as e: # noqa: BLE001
1583
+ click.echo(f"error: dispatch failed: {e}", err=True)
1584
+ sys.exit(1)
1585
+
1586
+ assignment_id = response.get("id", "pending")
1587
+
1588
+ # Record in coordinator DB so the board / TUI / notify see it.
1589
+ record_dispatched(
1590
+ assignment_id=assignment_id,
1591
+ proposal=proposal,
1592
+ repo_github=repo_cfg.github,
1593
+ provider_name=response.get("_provider_name"),
1594
+ )
1595
+
1596
+ # Print the new assignment ID on stdout so callers (e.g. TUI) can bind.
1597
+ click.echo(assignment_id)
1598
+
1599
+
1600
+ @click.command(help="Cancel a running assignment.")
1601
+ @click.argument("assignment_id")
1602
+ @click.option(
1603
+ "--rescue",
1604
+ is_flag=True,
1605
+ default=False,
1606
+ help=(
1607
+ "Push any uncommitted WIP to a disposable rescue/<id> ref instead "
1608
+ "of leaving it local-only on the worker machine. Never touches the "
1609
+ "worker's own branch (#1567)."
1610
+ ),
1611
+ )
1612
+ @_CONFIG_OPTION
1613
+ def stop(assignment_id: str, rescue: bool, config_path: Path) -> None:
1614
+ from coord.board_service import read_board, write_board
1615
+
1616
+ cfg = _load_config(config_path)
1617
+ board = read_board()
1618
+
1619
+ assignment = board.find_by_id(assignment_id)
1620
+ if assignment is None:
1621
+ click.echo(f"error: assignment {assignment_id!r} not found in board", err=True)
1622
+ sys.exit(1)
1623
+
1624
+ machine = next(
1625
+ (m for m in cfg.machines if m.name == assignment.machine_name), None
1626
+ )
1627
+ if machine is None:
1628
+ click.echo(f"error: machine {assignment.machine_name!r} not in config", err=True)
1629
+ sys.exit(1)
1630
+
1631
+ try:
1632
+ resp = httpx.post(
1633
+ f"http://{machine.host}:{AGENT_PORT}/cancel/{assignment_id}",
1634
+ params={"rescue": "1"} if rescue else None,
1635
+ timeout=10,
1636
+ )
1637
+ resp.raise_for_status()
1638
+ click.echo(f"Assignment {assignment_id} cancelled on {machine.name}")
1639
+ # #1567: print exactly what happened to the worktree/branch — a
1640
+ # `coord stop` that silently pushed (or silently didn't) is the
1641
+ # whole reason this issue exists.
1642
+ try:
1643
+ payload = resp.json()
1644
+ except ValueError:
1645
+ payload = {}
1646
+ dirty_reason = payload.get("dirty_worktree_reason")
1647
+ if dirty_reason:
1648
+ click.echo(f"Worktree: {dirty_reason}")
1649
+ elif not rescue:
1650
+ click.echo(
1651
+ "Worktree: clean or already removed — nothing to rescue; "
1652
+ "the worker's remote branch is unchanged."
1653
+ )
1654
+ except (httpx.HTTPError, httpx.TimeoutException) as e:
1655
+ click.echo(f"warning: could not reach agent on {machine.name}: {e}", err=True)
1656
+
1657
+ board.mark_failed_by_id(assignment_id)
1658
+ write_board(board)
1659
+ click.echo(f"Board updated: {assignment.repo_name} #{assignment.issue_number} marked failed")
1660
+
1661
+
1662
+ @click.command(
1663
+ help=(
1664
+ "Re-dispatch a failed WORK-LIKE assignment (work/mock-author/"
1665
+ "test-author) to a different machine. Also accepts a genuine "
1666
+ "zero-commit ADVISORY (#1606) — an advisory whose branch carries "
1667
+ "real commits is refused; use `coord drive --accept-advisory` for "
1668
+ "that shape instead. A failed 'smoke' or 'review' assignment is "
1669
+ "refused with the command that re-runs its stage (#1636) — this "
1670
+ "never silently re-dispatches one as a fresh work worker."
1671
+ )
1672
+ )
1673
+ @click.argument("assignment_id")
1674
+ @_CONFIG_OPTION
1675
+ def retry(assignment_id: str, config_path: Path) -> None:
1676
+ from coord.board_service import read_board, write_board
1677
+ from coord.models import WORK_LIKE_TYPES
1678
+ from coord.reconcile import (
1679
+ UnsupportedRetryType,
1680
+ _reassign,
1681
+ describe_no_candidate_machines,
1682
+ describe_unsupported_retry_type,
1683
+ )
1684
+
1685
+ cfg = _load_config(config_path)
1686
+ board = read_board()
1687
+
1688
+ assignment = board.find_by_id(assignment_id)
1689
+ if assignment is None:
1690
+ click.echo(f"error: assignment {assignment_id!r} not found in board", err=True)
1691
+ sys.exit(1)
1692
+ if assignment.status not in ("failed", "advisory"):
1693
+ click.echo(
1694
+ f"error: assignment {assignment_id} is {assignment.status!r}, not "
1695
+ f"'failed' or 'advisory'. Only a failed assignment, or a genuine "
1696
+ f"zero-commit advisory (#1606), can be retried.",
1697
+ err=True,
1698
+ )
1699
+ sys.exit(1)
1700
+
1701
+ if assignment.status == "advisory":
1702
+ # #1606: an ADVISORY row is TERMINAL — nothing else on the board ever
1703
+ # re-dispatches it. Only the genuine zero-commit exit (nothing
1704
+ # pushed) is safe to blindly retry here; an advisory whose branch
1705
+ # carries real commits is the #1357 false-positive signature and
1706
+ # must go through `coord drive --accept-advisory` instead, so real
1707
+ # work is never silently discarded by a retry that assumes it's
1708
+ # empty. Shared with `coord diagnose --stage work`'s identical
1709
+ # question (coord/diagnose.py's `_work_advisory_commits_ahead`) via
1710
+ # `github_ops.branch_commits_ahead_for_assignment` — this used to be
1711
+ # an independent inline copy of the same "branch empty → 0, repo
1712
+ # missing → None, else ask GitHub" logic.
1713
+ ahead = github_ops.branch_commits_ahead_for_assignment(assignment, cfg)
1714
+ if ahead != 0:
1715
+ detail = (
1716
+ "its commit count could not be confirmed (gh lookup failed)"
1717
+ if ahead is None
1718
+ else f"{ahead} commit(s) present on its branch"
1719
+ )
1720
+ click.echo(
1721
+ f"error: assignment {assignment_id} is 'advisory', and {detail} "
1722
+ "— coord retry only re-dispatches a GENUINE zero-commit "
1723
+ "advisory. This looks like the #1357 false-positive "
1724
+ "signature instead; use `coord drive --accept-advisory` to "
1725
+ "proceed with the existing commits, or inspect by hand.",
1726
+ err=True,
1727
+ )
1728
+ sys.exit(1)
1729
+
1730
+ # #1636: `coord retry` only knows how to re-dispatch WORK_LIKE_TYPES
1731
+ # ("work", "mock-author", "test-author") through this path — it drives a
1732
+ # fresh worker down the Work→Test→Review→Merge pipeline. A "smoke" or
1733
+ # "review" (or other) row has its own re-dispatch command that re-runs
1734
+ # the right stage instead of silently spinning up a work worker on the
1735
+ # already-complete branch (#1636's reported bug). Checked up front, before
1736
+ # the model-escalation message, so a refusal doesn't first print a
1737
+ # reassuring "escalating model" line for a retry that's about to be
1738
+ # refused anyway.
1739
+ if assignment.type not in WORK_LIKE_TYPES:
1740
+ exc = UnsupportedRetryType(assignment.type, assignment.review_of_assignment_id)
1741
+ click.echo(f"error: {describe_unsupported_retry_type(exc)}", err=True)
1742
+ sys.exit(1)
1743
+
1744
+ # Determine escalated model for the retry.
1745
+ original_model = assignment.model or cfg.models.default
1746
+ escalated = cfg.models.next_model(original_model)
1747
+ if escalated != original_model:
1748
+ click.echo(f" escalating model: {original_model} → {escalated}")
1749
+
1750
+ try:
1751
+ result = _reassign(assignment, board, cfg, model=escalated)
1752
+ except UnsupportedRetryType as exc:
1753
+ # Defense in depth — the precheck above already covers this, but
1754
+ # `_reassign` is shared with `auto_reassign` and must never silently
1755
+ # downgrade a non-work retry even if a future caller skips the
1756
+ # precheck.
1757
+ click.echo(f"error: {describe_unsupported_retry_type(exc)}", err=True)
1758
+ sys.exit(1)
1759
+ if result is None:
1760
+ # #1396: name the blocking machines and their apparent load instead
1761
+ # of a bare "no available machine" — the usual cause is a phantom
1762
+ # `running` row from a dead interactive session nothing reaped.
1763
+ click.echo(
1764
+ f"error: {describe_no_candidate_machines(assignment, board, cfg)}",
1765
+ err=True,
1766
+ )
1767
+ sys.exit(1)
1768
+
1769
+ write_board(board)
1770
+ click.echo(
1771
+ f"Retried: {result.machine_name} → {result.repo_name} "
1772
+ f"#{result.issue_number} (assignment {result.assignment_id}, "
1773
+ f"type={result.type})"
1774
+ )
1775
+ # #1101: surface the continued branch so it's obvious the retry picked
1776
+ # up existing work instead of forking a fresh branch off the default.
1777
+ if result.branch:
1778
+ click.echo(f" continuing branch: {result.branch}")
1779
+ else:
1780
+ click.echo(" no prior branch recorded — starting a fresh branch")