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,4894 @@
1
+ """`assign --interactive` mode implementations: review/smoke/troubleshoot/
2
+ chat/fix/rework/merge/plain-interactive/headless dispatch. Split out of
3
+ dispatch.py to keep either file from becoming a new god-file.
4
+ Extracted from coord/cli.py (#747)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import subprocess
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 Config
18
+
19
+
20
+ from coord.commands.review import (
21
+ _prompt_and_relay_review_verdict,
22
+ _prompt_and_relay_test_verdict,
23
+ )
24
+
25
+
26
+ def _echo_artifact_stash(fr: object) -> None:
27
+ """Echo the artifact-stash outcome from a remote interactive finalize (#1295).
28
+
29
+ ``fr.artifacts_stashed`` is ``None`` when no stash was even attempted
30
+ (no ``artifact_paths`` configured for the repo) — say nothing in that
31
+ case, it's not news. ``0`` means a stash DID run — on the remote host,
32
+ over ssh — but 0 files matched the configured globs; that's the exact
33
+ silent failure the issue reported (stderr redirected to ``/dev/null``
34
+ hid the agent-side warning), so surface it loudly here instead of
35
+ letting it disappear again on the operator's own terminal.
36
+ """
37
+ n = getattr(fr, "artifacts_stashed", None)
38
+ if n is None:
39
+ return
40
+ if n == 0:
41
+ click.echo(
42
+ " warning: remote stash copied 0 artifacts — check "
43
+ "artifact_paths config and that the build actually produced "
44
+ "the expected outputs",
45
+ err=True,
46
+ )
47
+ else:
48
+ click.echo(f" stashed {n} artifact(s)")
49
+
50
+
51
+ def _smoke_report_reminder(*, assignment_id: str, smoke_of: str) -> str:
52
+ """The reporting contract prepended to an interactive smoke briefing
53
+ (#1349, updated #1351).
54
+
55
+ Recording the verdict is the ONE artifact this session exists to produce.
56
+ Until #1351, a test verdict had NO PATH-independent fallback channel — a
57
+ review has always had one (the transcript-recoverable ``REVIEW_VERDICT``
58
+ block, #606/#651): if ``coord test`` never ran, or ran and failed, the
59
+ verdict was simply lost and the operator was re-prompted on exit for
60
+ something the agent was dispatched to do. #1351 closes that gap by giving
61
+ the Test gate the same two-channel contract a review has always had: a
62
+ PRIMARY fast path (``coord test``, written straight to the board) plus a
63
+ BACKUP structured block (``TEST_VERDICT:``/``TEST_REASON:``/
64
+ ``END_TEST``) recovered from the session transcript even when the
65
+ primary path never ran. Both are REQUIRED, every time — the backup does
66
+ not excuse skipping the primary, and vice versa.
67
+
68
+ Extracted to module scope so the contract is unit-testable — the briefing
69
+ text IS the product here, and a silent edit that drops the obligation is
70
+ exactly the drift this session is trying to stop.
71
+ """
72
+ return (
73
+ f"[Coordinator smoke assignment {assignment_id}] HUMAN-ATTENDED "
74
+ "interactive smoke test.\n"
75
+ " Record your verdict TWICE before you finish — belt and braces, "
76
+ "neither step substitutes for the other, even if the session was "
77
+ "short or the operator seems done:\n"
78
+ " a. PRIMARY (do this first, once the operator has a clear "
79
+ "position):\n"
80
+ f" coord test --passed {smoke_of}\n"
81
+ f" coord test --fail {smoke_of} --reason \"<full failure brief>\"\n"
82
+ " Run exactly one of them. Then CHECK THE OUTPUT: the command "
83
+ "prints a confirmation. If it errored, or `coord` is not on your "
84
+ "PATH, say so plainly to the operator — and fall through to step b "
85
+ "anyway; it is REQUIRED regardless. Do NOT report the verdict as "
86
+ "recorded when you have not seen it confirmed.\n"
87
+ " b. BACKUP (always do this too, even after a successful step a): "
88
+ "at the END of your session, output your verdict in this exact "
89
+ "format:\n\n"
90
+ "TEST_VERDICT: passed\n"
91
+ "TEST_REASON:\n"
92
+ "<none, or brief notes>\n"
93
+ "END_TEST\n\n"
94
+ "Or for a failure:\n\n"
95
+ "TEST_VERDICT: failed\n"
96
+ "TEST_REASON:\n"
97
+ "<the COMPLETE failure brief: what was checked, expected vs actual, "
98
+ "the repro steps, and the suspected files/area — not just one "
99
+ "line; this reason IS what the fix worker is briefed with, so make "
100
+ "it self-contained>\n"
101
+ "END_TEST\n\n"
102
+ "This printed block is the PATH-independent fallback recovered from "
103
+ "your session transcript even when step a never ran or failed — it "
104
+ "is REQUIRED every time, not just when `coord test` is unavailable. "
105
+ "`END_TEST` is a HARD REQUIREMENT: the coordinator only records a "
106
+ "verdict recovered this way when it sees that exact line. The LAST "
107
+ "LINE of your LAST MESSAGE must be exactly `END_TEST` on its own "
108
+ "line, with nothing after it.\n"
109
+ f" Then run `coord report-result --assignment {assignment_id} "
110
+ "--status done --summary <one-line summary>` so this session's row "
111
+ "closes.\n\n"
112
+ )
113
+
114
+
115
+ def _dispatch_review_of(
116
+ *,
117
+ machine: str,
118
+ repo: str,
119
+ issue: int,
120
+ briefing: str,
121
+ model: str | None,
122
+ dry_run: bool,
123
+ review_of: str,
124
+ cfg: Config,
125
+ machine_obj: object,
126
+ repo_cfg: object,
127
+ issue_data: dict,
128
+ issue_title: str,
129
+ provider: object,
130
+ _is_local: bool,
131
+ _svc: object,
132
+ _interactive_board: object,
133
+ _issue_ctx: str,
134
+ ) -> None:
135
+ import time as _time # noqa: PLC0415
136
+ import uuid as _uuid # noqa: PLC0415
137
+
138
+ from coord.interactive import ( # noqa: PLC0415
139
+ TmuxHost,
140
+ _launch_via_tmux as _tmux_launch,
141
+ finalize_interactive_exit,
142
+ launch_human_attended_interactive,
143
+ tmux_available as _tmux_avail,
144
+ tmux_session_alive as _tmux_alive,
145
+ tmux_session_name as _tmux_name,
146
+ )
147
+
148
+ from coord.review import ( # noqa: PLC0415
149
+ REVIEWER_SYSTEM_PROMPT,
150
+ _read_repo_claude_md,
151
+ build_review_briefing,
152
+ )
153
+ from coord.models import Assignment # noqa: PLC0415
154
+ from coord.state import ( # noqa: PLC0415
155
+ build_board as _build_board_rv,
156
+ record_dispatched_assignment,
157
+ save_board as _save_board_rv,
158
+ )
159
+ from coord.agent import AssignmentSpec as _AssignmentSpecRv # noqa: PLC0415
160
+
161
+ _rv_board = _interactive_board(_build_board_rv)
162
+ work = _rv_board.find_by_id(review_of)
163
+ if work is None:
164
+ click.echo(
165
+ f"error: --review-of {review_of}: no such assignment on the "
166
+ "board (use the work id from `coord status`).",
167
+ err=True,
168
+ )
169
+ sys.exit(2)
170
+ if not work.branch:
171
+ click.echo(
172
+ f"error: work assignment {review_of} has no branch to review.",
173
+ err=True,
174
+ )
175
+ sys.exit(2)
176
+
177
+ # Track B / #486: the review runs either on the LOCAL TTY (in the
178
+ # live checkout) or on a REMOTE machine over ssh+tmux. A review is
179
+ # read-only either way (no worktree, no branch mutation), so the
180
+ # remote path is the lowest-risk Track-B leg.
181
+ if _is_local:
182
+ # Expand `~` — the path is handed straight to a local child cwd.
183
+ review_repo_path = str(
184
+ Path(machine_obj.repo_path(repo) or str(Path.cwd())).expanduser()
185
+ )
186
+ else:
187
+ # Keep the raw path so the remote shell expands `~`/$HOME itself.
188
+ review_repo_path = machine_obj.repo_path(repo) or f"~/src/{repo}"
189
+ review_default_branch = repo_cfg.default_branch or "main"
190
+ resolved_model = model if model else cfg.models.default
191
+ assignment_id = _uuid.uuid4().hex[:12]
192
+
193
+ # CLAUDE.md: local → read the live checkout; remote → leave empty and
194
+ # have the reviewer read ./CLAUDE.md in the remote checkout it sits
195
+ # in (its actual rules, not the coordinator's possibly-divergent copy).
196
+ claude_md = (
197
+ _read_repo_claude_md(Path(review_repo_path)) if _is_local else ""
198
+ )
199
+
200
+ # #612: embed the merge-base (three-dot) diff so the reviewer has
201
+ # nothing to compute — a stale-base diff would show already-merged
202
+ # commits as spurious deletions (#546). Prefer the PR diff via gh
203
+ # when a PR exists; otherwise (the #546 no-PR case) compute the
204
+ # local three-dot diff in the live checkout. Remote / on-failure
205
+ # → diff_text=None and the briefing keeps its fallback instructions.
206
+ review_diff_text: str | None = None
207
+ try:
208
+ _existing_pr = github_ops.find_pr_for_branch(
209
+ repo_cfg.github, work.branch
210
+ )
211
+ except RuntimeError:
212
+ _existing_pr = None
213
+ if _existing_pr is not None:
214
+ review_diff_text = github_ops.pr_diff(
215
+ repo_cfg.github, _existing_pr["number"]
216
+ )
217
+ elif _is_local:
218
+ # No PR: compute the branch's own changes locally with a
219
+ # three-dot (merge-base) diff against origin/<default_branch>.
220
+ try:
221
+ subprocess.run(
222
+ ["git", "-C", review_repo_path, "fetch", "origin"],
223
+ capture_output=True, text=True, timeout=60,
224
+ )
225
+ _diff_proc = subprocess.run(
226
+ [
227
+ "git", "-C", review_repo_path, "diff",
228
+ f"origin/{review_default_branch}...origin/{work.branch}",
229
+ ],
230
+ capture_output=True, text=True, timeout=60,
231
+ )
232
+ if _diff_proc.returncode == 0 and _diff_proc.stdout.strip():
233
+ review_diff_text = _diff_proc.stdout
234
+ if len(review_diff_text) > 60000:
235
+ review_diff_text = (
236
+ review_diff_text[:60000]
237
+ + "\n... [diff truncated at 60000 chars] ..."
238
+ )
239
+ except (subprocess.SubprocessError, OSError):
240
+ review_diff_text = None
241
+
242
+ review_briefing = build_review_briefing(
243
+ pr_number=None,
244
+ pr_url=None,
245
+ repo_github=repo_cfg.github,
246
+ repo_name=repo,
247
+ issue_number=issue,
248
+ issue_title=issue_title,
249
+ issue_body=issue_data.get("body", ""),
250
+ branch=work.branch,
251
+ worker_machine=work.machine_name or machine,
252
+ same_as_worker=False,
253
+ reviews_cfg=cfg.reviews,
254
+ repo_claude_md=claude_md,
255
+ default_branch=review_default_branch,
256
+ review_iteration=getattr(work, "review_iteration", 0) or 0,
257
+ diff_text=review_diff_text,
258
+ )
259
+ # #651 (reversed by #1457): #651 told the reviewer to report via
260
+ # report-result "instead of" the REVIEW_VERDICT block the briefing's
261
+ # tail (below) describes — two conflicting instructions for the same
262
+ # handoff — and framed the printed block as PRIMARY with
263
+ # `coord report-result` as an OPTIONAL fast path never actually asked
264
+ # for. That left the "preferred self-report path" (the #606 PATH-fix's
265
+ # own words, coord/interactive.py:_with_coord_on_path) unreachable
266
+ # without a human asking for it every session: the operator always had
267
+ # to prompt the agent to run report-result before exiting. #1457: BOTH
268
+ # are now REQUIRED, belt and braces — `coord report-result` FIRST as
269
+ # the authoritative write straight to the coordinator's board (its
270
+ # PATH is fixed for exactly this by #606, its assignment id is $COORD_
271
+ # ASSIGNMENT_ID exported below, and #590 makes it work from a remote
272
+ # session too), and the REVIEW_VERDICT block ALWAYS ALSO, unconditionally,
273
+ # as the PATH-independent backup the #606 transcript-floor recovery scans
274
+ # for when report-result never ran or failed. Neither step substitutes
275
+ # for the other, and the exit finalize's `already_recorded` check
276
+ # (coord/interactive.py:_assignment_already_recorded) means a successful
277
+ # report-result is never clobbered by the backstop.
278
+ _remote_note = (
279
+ ""
280
+ if _is_local
281
+ else (
282
+ "running on a REMOTE machine. You are in the live checkout — "
283
+ "read ./CLAUDE.md (and any sub-repo CLAUDE.md) for the project "
284
+ "rules before reviewing. "
285
+ )
286
+ )
287
+ report_reminder = (
288
+ f"[Coordinator review assignment {assignment_id}] This is a "
289
+ f"HUMAN-ATTENDED interactive review {_remote_note}When you finish, "
290
+ "record your verdict TWICE — belt and braces, neither step "
291
+ "substitutes for the other:\n"
292
+ " 1. REQUIRED, PRIMARY — write your full findings (every blocking "
293
+ f"item, with file:line) to a file (e.g. /tmp/review-{assignment_id}.md) "
294
+ "and run, BEFORE you end your session:\n"
295
+ f" coord report-result --assignment {assignment_id} "
296
+ "--status done --verdict approve|request-changes "
297
+ f"--summary <one-line summary> --body-file /tmp/review-{assignment_id}.md\n"
298
+ " This writes your verdict straight to the coordinator's shared "
299
+ "board (#590) — the authoritative record, and it reaches the merge "
300
+ "gate the moment you run it. `coord` is on your PATH (the coordinator "
301
+ "prepends its venv bin for this) and your assignment id is also in "
302
+ "$COORD_ASSIGNMENT_ID. Check the command's output for a "
303
+ "confirmation; if it errors, say so plainly — then continue to "
304
+ "step 2 regardless.\n"
305
+ " 2. REQUIRED, BACKUP — ALWAYS ALSO, regardless of whether step 1 "
306
+ "ran or succeeded, end your session by outputting the "
307
+ "`REVIEW_VERDICT: / REVIEW_BODY: / END_REVIEW` block described at "
308
+ "the end of this briefing, with the SAME full findings in "
309
+ "REVIEW_BODY. This is the PATH-independent fallback — it is "
310
+ "recovered from your session transcript even when step 1 never ran. "
311
+ "It does NOT replace step 1; do both, every time.\n"
312
+ " The three marker lines are parsed by machine: emit each at "
313
+ "the start of its own line as literal plain text, with NO Markdown "
314
+ "decoration. `**REVIEW_VERDICT: request-changes**` is WRONG and is "
315
+ "silently dropped (#1346); `REVIEW_VERDICT: request-changes` is "
316
+ "right. The body between the markers may be Markdown.\n"
317
+ " Do NOT run any `gh` commands; the coordinator posts the verdict + "
318
+ "findings for you.\n\n"
319
+ )
320
+ effective_briefing = _issue_ctx + report_reminder + review_briefing
321
+
322
+ spec = _AssignmentSpecRv(
323
+ repo_name=repo,
324
+ repo_path=review_repo_path,
325
+ issue_number=issue,
326
+ issue_title=f"[review] {issue_title}",
327
+ briefing=effective_briefing,
328
+ model=resolved_model,
329
+ type="review",
330
+ provider="claude-pty",
331
+ )
332
+ # A review is READ-ONLY: use the reviewer system prompt (not the
333
+ # worker default build_command would otherwise apply for an
334
+ # unrecognised type) and drop Edit/Write from the tool set so the
335
+ # session can't mutate the live checkout. Bash stays for
336
+ # git fetch/diff/log; Read/Grep/Glob for inspecting the code.
337
+ argv = provider.build_command(
338
+ spec,
339
+ resolved_model=resolved_model,
340
+ system_prompt=REVIEWER_SYSTEM_PROMPT,
341
+ allowed_tools="Read,Bash,Grep,Glob",
342
+ )
343
+ # Remote: a bare "claude" is not on the SSH login PATH (#424/#425);
344
+ # swap argv[0] for the absolute path the remote shell can find.
345
+ if not _is_local:
346
+ argv = ["~/.local/bin/claude"] + list(argv)[1:]
347
+
348
+ _rv_location = (
349
+ "local TTY" if _is_local
350
+ else f"{machine_obj.host} (remote tmux)"
351
+ )
352
+ click.echo(
353
+ f"{machine} ({_rv_location}) → REVIEW of #{issue} "
354
+ f"on branch {work.branch}: {issue_title}"
355
+ )
356
+ click.echo(
357
+ " mode: HUMAN-ATTENDED interactive review "
358
+ "(migration A1 / Track B #486)"
359
+ )
360
+ click.echo(
361
+ f" assignment id: {assignment_id} (review_of={review_of})"
362
+ )
363
+ if _is_local:
364
+ click.echo(
365
+ f" cwd: {review_repo_path} (live checkout — read-only, "
366
+ "no worktree)"
367
+ )
368
+ else:
369
+ click.echo(
370
+ f" remote checkout: {review_repo_path} on "
371
+ f"{machine_obj.host} (read-only, no worktree)"
372
+ )
373
+ if dry_run:
374
+ click.echo(" (dry run — not launched)")
375
+ click.echo(f" would exec: {argv}")
376
+ return
377
+
378
+ review_assignment = Assignment(
379
+ machine_name=machine,
380
+ repo_name=repo,
381
+ issue_number=issue,
382
+ issue_title=f"[review] {issue_title}",
383
+ briefing=effective_briefing,
384
+ assignment_id=assignment_id,
385
+ status="running",
386
+ branch=work.branch,
387
+ dispatched_at=_time.time(),
388
+ type="review",
389
+ review_of_assignment_id=review_of,
390
+ review_target=work.branch,
391
+ model=resolved_model,
392
+ provider_name="claude-pty",
393
+ )
394
+ record_dispatched_assignment(
395
+ assignment=review_assignment, repo_github=repo_cfg.github
396
+ )
397
+ if _svc is None:
398
+ _save_board_rv(_build_board_rv())
399
+ os.environ["COORD_ASSIGNMENT_ID"] = assignment_id
400
+
401
+ started_at = _time.time()
402
+ if _is_local:
403
+ exit_code = launch_human_attended_interactive(
404
+ argv,
405
+ effective_briefing,
406
+ assignment_id=assignment_id,
407
+ cwd=review_repo_path,
408
+ )
409
+ if exit_code != 0:
410
+ click.echo(
411
+ f" claude exited with status {exit_code}", err=True
412
+ )
413
+
414
+ _sname = _tmux_name(assignment_id) if _tmux_avail() else None
415
+ if _sname and _tmux_alive(_sname):
416
+ click.echo(
417
+ f" session still running in tmux: {_sname}\n"
418
+ f" reattach with: coord reattach {assignment_id}"
419
+ )
420
+ sys.exit(0)
421
+
422
+ # finalize with worktree_path=None — the backstop must never push
423
+ # or remove the live checkout. If the reviewer ran
424
+ # `coord report-result --verdict`, finalize sees the terminal row
425
+ # and leaves the verdict untouched.
426
+ try:
427
+ finalize_result = finalize_interactive_exit(
428
+ assignment_id=assignment_id,
429
+ repo_name=repo,
430
+ repo_github=repo_cfg.github,
431
+ issue_number=issue,
432
+ machine_name=machine,
433
+ worktree_path=None,
434
+ base_branch=review_default_branch,
435
+ exit_code=exit_code,
436
+ started_at=started_at,
437
+ log_path=None,
438
+ repo_path=None,
439
+ )
440
+ if finalize_result.already_recorded:
441
+ click.echo(" verdict recorded via `coord report-result`")
442
+ else:
443
+ # The reviewer exited without running `coord
444
+ # report-result`. Mirror the remote path (#486d): prompt
445
+ # the operator here (this is a TTY) and relay the verdict
446
+ # through the same issue_store seam, so the merge gate /
447
+ # Fix routing sees it instead of silently stalling on a
448
+ # missing verdict.
449
+ _verdict_cmd = (
450
+ f" coord report-result --assignment {assignment_id} "
451
+ "--status done --verdict approve|request-changes "
452
+ "--summary <one-line summary>"
453
+ )
454
+ if not _prompt_and_relay_review_verdict(
455
+ assignment_id=assignment_id,
456
+ repo_name=repo,
457
+ repo_github=repo_cfg.github,
458
+ issue_number=issue,
459
+ machine_name=machine,
460
+ verdict_cmd_hint=_verdict_cmd,
461
+ started_at=started_at,
462
+ # Local review — session ran on THIS machine; no ssh needed.
463
+ # The transcript-floor already scanned the local projects dir
464
+ # in finalize_interactive_exit; ssh_target=None skips the
465
+ # remote SSH path while still checking the board (#877).
466
+ ssh_target=None,
467
+ ):
468
+ click.echo(
469
+ " review session ended with no verdict reported "
470
+ f"(status={finalize_result.terminal_status}) — the "
471
+ "merge gate stays blocked until a verdict is "
472
+ "reported."
473
+ )
474
+ except Exception as exc: # noqa: BLE001 — best-effort backstop
475
+ click.echo(
476
+ f" warning: backstop failed to record review exit: {exc}",
477
+ err=True,
478
+ )
479
+ return
480
+
481
+ # ── REMOTE REVIEW (Track B / #486) ────────────────────────────
482
+ # Read-only: cd into the remote LIVE checkout, fetch+prune so the
483
+ # reviewer can diff origin/<branch>, then launch the reviewer. NO
484
+ # worktree and NO branch mutation — the live checkout is the worker
485
+ # worktree base and must not be disturbed. Verdict comes back via
486
+ # the operator running `coord report-result` on THIS coordinator
487
+ # (the assignment row lives here); a remote report-result would
488
+ # write the remote DB and never reach the merge gate (#486d).
489
+ import shlex as _shlex_rv # noqa: PLC0415
490
+
491
+ _rp_sh = (
492
+ "$HOME/" + review_repo_path[2:]
493
+ if review_repo_path.startswith("~/")
494
+ else ("$HOME" if review_repo_path == "~" else review_repo_path)
495
+ )
496
+ _claude_args = _shlex_rv.join(list(argv)[1:])
497
+ _remote_cmd = (
498
+ f"cd {_rp_sh}"
499
+ f" && git fetch origin --prune 2>/dev/null || true"
500
+ f" && COORD_ASSIGNMENT_ID={assignment_id} {argv[0]} {_claude_args}"
501
+ )
502
+ _tmux_host = TmuxHost(ssh_target=machine_obj.host)
503
+ _sname = _tmux_name(assignment_id)
504
+
505
+ # Echo the briefing to the LOCAL terminal before attaching, so the
506
+ # operator can read it before pressing Enter (mirrors the remote
507
+ # work path).
508
+ if effective_briefing.strip():
509
+ _hdr = (
510
+ "--- seeded briefing -- review below; "
511
+ "submit the pre-filled input in Claude to send ---"
512
+ )
513
+ _ftr = "-" * len(_hdr)
514
+ _preview = f"\n{_hdr}\n{effective_briefing.rstrip()}\n{_ftr}\n\n"
515
+ try:
516
+ os.write(sys.stdout.fileno(), _preview.encode("utf-8"))
517
+ except OSError:
518
+ pass
519
+
520
+ _rc = _tmux_launch(
521
+ argv,
522
+ effective_briefing,
523
+ _sname,
524
+ cwd=None,
525
+ host=_tmux_host,
526
+ raw_shell_cmd=_remote_cmd,
527
+ )
528
+ if _rc is None:
529
+ click.echo(
530
+ " error: could not create remote tmux session on "
531
+ f"{machine_obj.host}",
532
+ err=True,
533
+ )
534
+ sys.exit(1)
535
+ exit_code = _rc
536
+ if exit_code != 0:
537
+ click.echo(f" claude exited with status {exit_code}", err=True)
538
+
539
+ _still_alive = _tmux_alive(_sname, host=_tmux_host)
540
+ # Verdict-out (#486d): the verdict is recorded on THIS coordinator,
541
+ # where the assignment row lives and the merge gate reads
542
+ # `review_verdict` — never on the remote machine's DB.
543
+ _verdict_cmd = (
544
+ f" coord report-result --assignment {assignment_id} "
545
+ "--status done --verdict approve|request-changes "
546
+ "--summary <one-line summary>"
547
+ )
548
+ if _still_alive:
549
+ click.echo(
550
+ f" session still running in remote tmux: {_sname}\n"
551
+ f" reattach with: ssh -t {machine_obj.host}"
552
+ f" tmux attach-session -t {_sname}"
553
+ )
554
+ click.echo(
555
+ " to record the verdict (the merge gate keys on it), run ON "
556
+ f"THIS coordinator:\n{_verdict_cmd}"
557
+ )
558
+ sys.exit(0)
559
+
560
+ # Session ended. Record a terminal state so the review row does NOT
561
+ # linger as a phantom 'running' worker that holds the issue claim
562
+ # forever — the bug this path used to have (it printed the verdict
563
+ # reminder and exited, never going terminal). A review is
564
+ # read-only, so finalize with worktree_path=None / repo_path=None:
565
+ # the backstop only writes the coordinator DB (no push, no worktree
566
+ # touch), identical to the local review path above. An operator
567
+ # `coord report-result` is respected (already_recorded → no clobber).
568
+ try:
569
+ finalize_result = finalize_interactive_exit(
570
+ assignment_id=assignment_id,
571
+ repo_name=repo,
572
+ repo_github=repo_cfg.github,
573
+ issue_number=issue,
574
+ machine_name=machine,
575
+ worktree_path=None,
576
+ base_branch=review_default_branch,
577
+ exit_code=exit_code,
578
+ started_at=started_at,
579
+ log_path=None,
580
+ repo_path=None,
581
+ # #617: this review ran on the REMOTE host, so its Claude
582
+ # session transcript lives THERE — hand the transcript-floor
583
+ # the ssh target so it recovers the verdict + findings from
584
+ # the session's OWN host instead of scanning this (blind)
585
+ # coordinator's `~/.claude/projects`. Without it the #606
586
+ # recovery always misses for a remote review and the exit
587
+ # falls straight to the operator prompt (the #607 silent
588
+ # drop). Mirrors the `coord reattach` remote-review path.
589
+ ssh_target=machine_obj.host,
590
+ )
591
+ if finalize_result.already_recorded:
592
+ click.echo(" verdict recorded via `coord report-result`")
593
+ else:
594
+ # #486d: don't leave the verdict as a manual step — prompt the
595
+ # operator here (on the coordinator, where the row lives) and
596
+ # relay it, so the merge gate / leg-3 Fix routing sees it.
597
+ _prompt_and_relay_review_verdict(
598
+ assignment_id=assignment_id,
599
+ repo_name=repo,
600
+ repo_github=repo_cfg.github,
601
+ issue_number=issue,
602
+ machine_name=machine,
603
+ verdict_cmd_hint=_verdict_cmd,
604
+ started_at=started_at,
605
+ # #877: remote review — session ran on machine_obj.host, so the
606
+ # transcript lives THERE. Pass the ssh_target so the board-
607
+ # content gate can re-run the remote transcript-floor here
608
+ # (a 2nd attempt; the first ran in finalize_interactive_exit via
609
+ # its own ssh_target). Catches any timing window where the
610
+ # JSONL wasn't fully flushed when finalize ran.
611
+ ssh_target=machine_obj.host,
612
+ )
613
+ except Exception as exc: # noqa: BLE001 — best-effort backstop
614
+ click.echo(
615
+ f" warning: backstop failed to record review exit: {exc}",
616
+ err=True,
617
+ )
618
+ sys.exit(exit_code)
619
+
620
+
621
+ def _dispatch_smoke_of(
622
+ *,
623
+ machine: str,
624
+ repo: str,
625
+ issue: int,
626
+ briefing: str,
627
+ model: str | None,
628
+ dry_run: bool,
629
+ smoke_of: str,
630
+ cfg: Config,
631
+ machine_obj: object,
632
+ repo_cfg: object,
633
+ issue_data: dict,
634
+ issue_title: str,
635
+ provider: object,
636
+ _is_local: bool,
637
+ _svc: object,
638
+ _interactive_board: object,
639
+ _issue_ctx: str,
640
+ ) -> None:
641
+ import time as _time # noqa: PLC0415
642
+ import uuid as _uuid # noqa: PLC0415
643
+
644
+ from coord.interactive import ( # noqa: PLC0415
645
+ TmuxHost,
646
+ _launch_via_tmux as _tmux_launch,
647
+ finalize_interactive_exit,
648
+ launch_human_attended_interactive,
649
+ snapshot_live_checkout_for_smoke,
650
+ tmux_available as _tmux_avail,
651
+ tmux_session_alive as _tmux_alive,
652
+ tmux_session_name as _tmux_name,
653
+ )
654
+
655
+ from coord.models import Assignment as _AssignmentSm # noqa: PLC0415
656
+ from coord.state import ( # noqa: PLC0415
657
+ build_board as _build_board_sm,
658
+ get_test_plan as _get_test_plan_sm,
659
+ record_dispatched_assignment as _record_sm,
660
+ save_board as _save_board_sm,
661
+ )
662
+ from coord.agent import AssignmentSpec as _AssignmentSpecSm # noqa: PLC0415
663
+
664
+ _sm_board = _interactive_board(_build_board_sm)
665
+ work = _sm_board.find_by_id(smoke_of)
666
+ if work is None:
667
+ click.echo(
668
+ f"error: --smoke-of {smoke_of}: no such assignment on the "
669
+ "board (use the work id from `coord status`).",
670
+ err=True,
671
+ )
672
+ sys.exit(2)
673
+ if not work.branch:
674
+ click.echo(
675
+ f"error: work assignment {smoke_of} has no branch to test.",
676
+ err=True,
677
+ )
678
+ sys.exit(2)
679
+
680
+ # #1010 (mirrors --review-of / Track B #486): smoke is READ-ONLY (no
681
+ # worktree, no branch mutation) either way, so the remote path is the
682
+ # same low-risk shape as the review path — ssh+tmux into the live
683
+ # checkout, no worktree machinery needed.
684
+ if _is_local:
685
+ smoke_repo_path = str(
686
+ Path(machine_obj.repo_path(repo) or str(Path.cwd())).expanduser()
687
+ )
688
+ else:
689
+ # Keep the raw path so the remote shell expands `~`/$HOME itself.
690
+ smoke_repo_path = machine_obj.repo_path(repo) or f"~/src/{repo}"
691
+ smoke_default_branch = repo_cfg.default_branch or "main"
692
+ resolved_model = model if model else cfg.models.default
693
+ assignment_id = _uuid.uuid4().hex[:12]
694
+
695
+ # Surface the cached smoke-test plan (#342) when one exists so the
696
+ # agent can lead with the concrete steps instead of re-deriving them.
697
+ try:
698
+ _plan = _get_test_plan_sm(smoke_of)
699
+ except Exception: # noqa: BLE001
700
+ _plan = None
701
+ if _plan and isinstance(_plan, dict) and _plan.get("steps"):
702
+ import json as _json_sm # noqa: PLC0415
703
+ _plan_block = (
704
+ "A cached smoke-test plan exists for this branch:\n\n"
705
+ "```json\n" + _json_sm.dumps(_plan, indent=2) + "\n```\n"
706
+ )
707
+ else:
708
+ _plan_block = (
709
+ "No cached smoke-test plan was found. Run "
710
+ f"`coord test-plan {smoke_of}` to generate one (it reads the "
711
+ "PR diff, the repo's CLAUDE.md and the artifact manifest), "
712
+ "then read it back to the operator.\n"
713
+ )
714
+
715
+ INTERACTIVE_SMOKE_SYSTEM_PROMPT = (
716
+ "You are a human-attended smoke-test guide dispatched by the "
717
+ "coordinator. A human operator is at the keyboard with you. Your "
718
+ "job is to walk them through validating a completed branch and "
719
+ "then record their verdict.\n\n"
720
+ "Rules:\n"
721
+ "- Do NOT modify code, push commits, or open/merge PRs. You only "
722
+ "help validate. (Edit/Write are not available to you.)\n"
723
+ "- Do NOT run `gh` commands. The coordinator owns GitHub.\n"
724
+ "- You MAY run git (read-only), build/run commands, and the "
725
+ "`coord pull-artifact` / `coord test-plan` / `coord test` "
726
+ "commands.\n"
727
+ "- Keep it conversational: propose ONE concrete next command at a "
728
+ "time, wait for the operator to run it (or run it yourself when "
729
+ "it's safe and read-only) and tell you what they saw.\n\n"
730
+ "Flow:\n"
731
+ "1. Read the smoke-test plan (below, or generate one). List the "
732
+ "checks for the operator.\n"
733
+ "2. Offer to pull the prebuilt artifact for this branch with "
734
+ "`coord pull-artifact <work_aid>` so they don't have to rebuild.\n"
735
+ "3. Walk through each check. Ask what they observed. If something "
736
+ "is wrong, interview them for a clear repro (expected vs actual, "
737
+ "suspected area/files) — this becomes the fix brief.\n"
738
+ "4. When every check has a clear position, record the verdict:\n"
739
+ " - All good → run `coord test --passed <work_aid>`\n"
740
+ " - Broken → run `coord test --fail <work_aid> --reason "
741
+ "\"<story>\"` where <story> is the COMPLETE failure brief the fix "
742
+ "worker needs: what was checked, expected vs actual, the repro "
743
+ "steps, and the suspected files/area — not just one line. This "
744
+ "reason IS what the fix worker is briefed with, so make it "
745
+ "self-contained.\n"
746
+ " Then ALSO print the TEST_VERDICT:/TEST_REASON:/END_TEST backup "
747
+ "block described in your first message, even after `coord test` "
748
+ "succeeds — it is the PATH-independent fallback recovered from this "
749
+ "session's transcript if `coord test` didn't actually land.\n"
750
+ " Then tell the operator exactly what happens next (the TUI "
751
+ "will offer the fix or merge step).\n"
752
+ )
753
+
754
+ smoke_briefing = (
755
+ f"# Smoke-test assignment: {repo_cfg.github} #{issue}\n\n"
756
+ f"**Issue:** {issue_title}\n"
757
+ f"**Branch under test:** `{work.branch}` "
758
+ f"(worker: {work.machine_name or machine})\n"
759
+ f"**Work assignment id (use this for `coord test` / "
760
+ f"`coord pull-artifact`):** `{smoke_of}`\n"
761
+ f"**Repo checkout:** {smoke_repo_path}\n"
762
+ f"**Default branch:** {smoke_default_branch}\n\n"
763
+ "## ⚠ Do NOT move this checkout's branch (#601)\n\n"
764
+ f"`{smoke_repo_path}` is the **live checkout that runs the "
765
+ "coordinator itself** (and the worktree base for workers). Do "
766
+ "**NOT** `git checkout` / `git switch` / `git reset` / "
767
+ "`git stash` it — checking out the branch here silently "
768
+ "downgrades the running `coord` to this branch's code until it's "
769
+ "restored. To inspect the branch under test WITHOUT moving it:\n"
770
+ f" - `git -C {smoke_repo_path} fetch origin && "
771
+ f"git -C {smoke_repo_path} diff {smoke_default_branch}...origin/{work.branch}`\n"
772
+ f" - `git -C {smoke_repo_path} show origin/{work.branch}:<path>` for a single file\n"
773
+ f" - or make your OWN scratch worktree: "
774
+ f"`git -C {smoke_repo_path} worktree add /tmp/smoke-{smoke_of} origin/{work.branch}` "
775
+ f"(remove it with `git -C {smoke_repo_path} worktree remove /tmp/smoke-{smoke_of}` when done)\n"
776
+ " - prefer `coord pull-artifact` (above) for the prebuilt binary.\n\n"
777
+ f"## Issue body\n\n{issue_data.get('body', '') or '(none)'}\n\n"
778
+ f"## Smoke-test plan\n\n{_plan_block}\n"
779
+ "## Your job\n\n"
780
+ "Guide the operator through validating this branch (see the "
781
+ "system prompt for the flow), then record the verdict with "
782
+ f"`coord test --passed {smoke_of}` or `coord test --fail "
783
+ f"{smoke_of} --reason \"...\"`.\n"
784
+ )
785
+
786
+ report_reminder = _smoke_report_reminder(
787
+ assignment_id=assignment_id, smoke_of=smoke_of
788
+ )
789
+ effective_briefing = _issue_ctx + report_reminder + smoke_briefing
790
+
791
+ spec = _AssignmentSpecSm(
792
+ repo_name=repo,
793
+ repo_path=smoke_repo_path,
794
+ issue_number=issue,
795
+ issue_title=f"[smoke] {issue_title}",
796
+ briefing=effective_briefing,
797
+ model=resolved_model,
798
+ type="smoke",
799
+ provider="claude-pty",
800
+ )
801
+ # READ-ONLY like --review-of: no Edit/Write — the smoke agent
802
+ # validates, it does not fix. Bash stays for build/run + the
803
+ # coord helper commands; Read/Grep/Glob for inspecting the code.
804
+ argv = provider.build_command(
805
+ spec,
806
+ resolved_model=resolved_model,
807
+ system_prompt=INTERACTIVE_SMOKE_SYSTEM_PROMPT,
808
+ allowed_tools="Read,Bash,Grep,Glob",
809
+ )
810
+ # Remote: a bare "claude" is not on the SSH login PATH (#424/#425).
811
+ if not _is_local:
812
+ argv = ["~/.local/bin/claude"] + list(argv)[1:]
813
+
814
+ _sm_location = (
815
+ "local TTY" if _is_local
816
+ else f"{machine_obj.host} (remote tmux)"
817
+ )
818
+ click.echo(
819
+ f"{machine} ({_sm_location}) → SMOKE TEST of #{issue} "
820
+ f"on branch {work.branch}: {issue_title}"
821
+ )
822
+ click.echo(
823
+ " mode: HUMAN-ATTENDED interactive smoke test (leg 3c / A3, #1010)"
824
+ )
825
+ click.echo(
826
+ f" assignment id: {assignment_id} (smoke_of={smoke_of})"
827
+ )
828
+ if _is_local:
829
+ click.echo(
830
+ f" cwd: {smoke_repo_path} (live checkout — read-only, "
831
+ "no worktree)"
832
+ )
833
+ else:
834
+ click.echo(
835
+ f" remote checkout: {smoke_repo_path} on "
836
+ f"{machine_obj.host} (read-only, no worktree)"
837
+ )
838
+ if dry_run:
839
+ click.echo(" (dry run — not launched)")
840
+ click.echo(f" would exec: {argv}")
841
+ return
842
+
843
+ smoke_assignment = _AssignmentSm(
844
+ machine_name=machine,
845
+ repo_name=repo,
846
+ issue_number=issue,
847
+ issue_title=f"[smoke] {issue_title}",
848
+ briefing=effective_briefing,
849
+ assignment_id=assignment_id,
850
+ status="running",
851
+ branch=work.branch,
852
+ dispatched_at=_time.time(),
853
+ type="smoke",
854
+ review_of_assignment_id=smoke_of,
855
+ review_target=work.branch,
856
+ model=resolved_model,
857
+ provider_name="claude-pty",
858
+ )
859
+ _record_sm(assignment=smoke_assignment, repo_github=repo_cfg.github)
860
+ if _svc is None:
861
+ _save_board_sm(_build_board_sm())
862
+ os.environ["COORD_ASSIGNMENT_ID"] = assignment_id
863
+
864
+ # #923: Test-verdict backstop hint — shared by both the local and
865
+ # remote paths below. The verdict itself lands on the shared board the
866
+ # moment `coord test` runs INSIDE the session (routes through the
867
+ # daemon, #590, same as `coord report-result`) — this hint/prompt only
868
+ # covers the case where the agent exited without running it.
869
+ _test_verdict_cmd = (
870
+ f" coord test --passed {smoke_of} # all good\n"
871
+ f" coord test --fail {smoke_of} --reason \"<story>\" # broken"
872
+ )
873
+
874
+ if _is_local:
875
+ # #1256: snapshot the live checkout's branch + dirty-path baseline
876
+ # BEFORE the agent can mutate it (e.g. a path-scoped
877
+ # `git checkout <branch> -- <path>` used to exercise an agent-side
878
+ # file that must actually sit in this editable-install checkout to
879
+ # take effect). finalize_interactive_exit's restore-on-exit safety
880
+ # net reverts anything new dirtied since this point.
881
+ snapshot_live_checkout_for_smoke(smoke_repo_path, assignment_id)
882
+
883
+ started_at = _time.time()
884
+ exit_code = launch_human_attended_interactive(
885
+ argv,
886
+ effective_briefing,
887
+ assignment_id=assignment_id,
888
+ cwd=smoke_repo_path,
889
+ )
890
+ if exit_code != 0:
891
+ click.echo(f" claude exited with status {exit_code}", err=True)
892
+
893
+ _sname = _tmux_name(assignment_id) if _tmux_avail() else None
894
+ if _sname and _tmux_alive(_sname):
895
+ click.echo(
896
+ f" session still running in tmux: {_sname}\n"
897
+ f" reattach with: coord reattach {assignment_id}"
898
+ )
899
+ sys.exit(0)
900
+
901
+ # worktree_path=None: read-only smoke runs in the live checkout, the
902
+ # backstop must never push or remove it. The verdict that matters
903
+ # is the `coord test` write on the WORK row, not this session's row.
904
+ # smoke_repo_path=... triggers the #1256 restore-on-exit safety net.
905
+ try:
906
+ _fr = finalize_interactive_exit(
907
+ assignment_id=assignment_id,
908
+ repo_name=repo,
909
+ repo_github=repo_cfg.github,
910
+ issue_number=issue,
911
+ machine_name=machine,
912
+ worktree_path=None,
913
+ base_branch=smoke_default_branch,
914
+ exit_code=exit_code,
915
+ started_at=started_at,
916
+ log_path=None,
917
+ repo_path=None,
918
+ smoke_repo_path=smoke_repo_path,
919
+ smoke_of=smoke_of,
920
+ )
921
+ if _fr.test_verdict_recovered:
922
+ click.echo(
923
+ f" test verdict {_fr.test_verdict_recovered!r} recovered "
924
+ "from the session transcript and recorded on the work "
925
+ f"assignment {smoke_of} (#1351)"
926
+ )
927
+ if _fr.smoke_restored_paths:
928
+ click.echo(
929
+ " live checkout restored (#1256): reverted "
930
+ + ", ".join(_fr.smoke_restored_paths)
931
+ )
932
+ if _fr.smoke_restore_error:
933
+ click.echo(
934
+ f" warning: live-checkout restore failed: "
935
+ f"{_fr.smoke_restore_error}",
936
+ err=True,
937
+ )
938
+ except Exception as exc: # noqa: BLE001 — best-effort backstop
939
+ click.echo(
940
+ f" warning: backstop failed to record smoke exit: {exc}",
941
+ err=True,
942
+ )
943
+
944
+ try:
945
+ if not _prompt_and_relay_test_verdict(
946
+ work_assignment_id=smoke_of,
947
+ smoke_assignment_id=assignment_id,
948
+ repo_name=repo,
949
+ repo_github=repo_cfg.github,
950
+ issue_number=issue,
951
+ machine_name=machine,
952
+ verdict_cmd_hint=_test_verdict_cmd,
953
+ ):
954
+ click.echo(
955
+ " smoke session ended with no test verdict recorded "
956
+ "— the merge gate stays blocked until a verdict is "
957
+ "reported."
958
+ )
959
+ except Exception as exc: # noqa: BLE001 — best-effort backstop
960
+ click.echo(
961
+ f" warning: test-verdict backstop failed: {exc}",
962
+ err=True,
963
+ )
964
+ return
965
+
966
+ # ── REMOTE SMOKE TEST (#1010) ──────────────────────────────────
967
+ # Read-only: cd into the remote LIVE checkout, fetch+prune so the
968
+ # smoke agent can inspect origin/<branch>, then launch it. NO
969
+ # worktree and NO branch mutation — the live checkout is the worker
970
+ # worktree base and must not be disturbed (same shape as the remote
971
+ # review path). The Test verdict is recorded on the shared board the
972
+ # moment `coord test --passed|--fail` runs INSIDE the remote session
973
+ # (routes through the daemon, #590) — no remote-specific plumbing
974
+ # needed for that part; only the SESSION row's terminal state (this
975
+ # smoke assignment, not the WORK row) needs a coordinator-side backstop.
976
+ import shlex as _shlex_sm # noqa: PLC0415
977
+
978
+ _rp_sh = (
979
+ "$HOME/" + smoke_repo_path[2:]
980
+ if smoke_repo_path.startswith("~/")
981
+ else ("$HOME" if smoke_repo_path == "~" else smoke_repo_path)
982
+ )
983
+ # #1256: snapshot the remote live checkout's branch + dirty-path
984
+ # baseline before launch — same rationale as the local branch above.
985
+ snapshot_live_checkout_for_smoke(
986
+ _rp_sh, assignment_id, ssh_target=machine_obj.host
987
+ )
988
+ _claude_args = _shlex_sm.join(list(argv)[1:])
989
+ _remote_cmd = (
990
+ f"cd {_rp_sh}"
991
+ f" && git fetch origin --prune 2>/dev/null || true"
992
+ f" && COORD_ASSIGNMENT_ID={assignment_id} {argv[0]} {_claude_args}"
993
+ )
994
+ _tmux_host = TmuxHost(ssh_target=machine_obj.host)
995
+ _sname = _tmux_name(assignment_id)
996
+
997
+ # Echo the briefing to the LOCAL terminal before attaching, so the
998
+ # operator can read it before pressing Enter (mirrors the remote
999
+ # review/work paths).
1000
+ if effective_briefing.strip():
1001
+ _hdr = (
1002
+ "--- seeded briefing -- review below; "
1003
+ "submit the pre-filled input in Claude to send ---"
1004
+ )
1005
+ _ftr = "-" * len(_hdr)
1006
+ _preview = f"\n{_hdr}\n{effective_briefing.rstrip()}\n{_ftr}\n\n"
1007
+ try:
1008
+ os.write(sys.stdout.fileno(), _preview.encode("utf-8"))
1009
+ except OSError:
1010
+ pass
1011
+
1012
+ started_at = _time.time()
1013
+ _rc = _tmux_launch(
1014
+ argv,
1015
+ effective_briefing,
1016
+ _sname,
1017
+ cwd=None,
1018
+ host=_tmux_host,
1019
+ raw_shell_cmd=_remote_cmd,
1020
+ )
1021
+ if _rc is None:
1022
+ click.echo(
1023
+ " error: could not create remote tmux session on "
1024
+ f"{machine_obj.host}",
1025
+ err=True,
1026
+ )
1027
+ sys.exit(1)
1028
+ exit_code = _rc
1029
+ if exit_code != 0:
1030
+ click.echo(f" claude exited with status {exit_code}", err=True)
1031
+
1032
+ if _tmux_alive(_sname, host=_tmux_host):
1033
+ click.echo(
1034
+ f" session still running in remote tmux: {_sname}\n"
1035
+ f" reattach with: ssh -t {machine_obj.host}"
1036
+ f" tmux attach-session -t {_sname}"
1037
+ )
1038
+ sys.exit(0)
1039
+
1040
+ # Session ended. Record a terminal state for THIS SMOKE SESSION row
1041
+ # (not the WORK row) so it doesn't linger as a phantom 'running' worker
1042
+ # holding the issue claim forever — worktree_path=None/repo_path=None
1043
+ # since this is read-only (no push, no worktree touch), identical to
1044
+ # the local path above. ssh_target lets any future transcript-floor
1045
+ # recovery read the session's OWN host (mirrors the #617 review fix).
1046
+ try:
1047
+ _fr = finalize_interactive_exit(
1048
+ assignment_id=assignment_id,
1049
+ repo_name=repo,
1050
+ repo_github=repo_cfg.github,
1051
+ issue_number=issue,
1052
+ machine_name=machine,
1053
+ worktree_path=None,
1054
+ base_branch=smoke_default_branch,
1055
+ exit_code=exit_code,
1056
+ started_at=started_at,
1057
+ log_path=None,
1058
+ repo_path=None,
1059
+ ssh_target=machine_obj.host,
1060
+ smoke_repo_path=_rp_sh,
1061
+ smoke_of=smoke_of,
1062
+ )
1063
+ if _fr.test_verdict_recovered:
1064
+ click.echo(
1065
+ f" test verdict {_fr.test_verdict_recovered!r} recovered "
1066
+ "from the session transcript and recorded on the work "
1067
+ f"assignment {smoke_of} (#1351)"
1068
+ )
1069
+ if _fr.smoke_restored_paths:
1070
+ click.echo(
1071
+ " live checkout restored (#1256): reverted "
1072
+ + ", ".join(_fr.smoke_restored_paths)
1073
+ )
1074
+ if _fr.smoke_restore_error:
1075
+ click.echo(
1076
+ f" warning: live-checkout restore failed: "
1077
+ f"{_fr.smoke_restore_error}",
1078
+ err=True,
1079
+ )
1080
+ except Exception as exc: # noqa: BLE001 — best-effort backstop
1081
+ click.echo(
1082
+ f" warning: backstop failed to record smoke exit: {exc}",
1083
+ err=True,
1084
+ )
1085
+
1086
+ # #923: Test-verdict backstop — see comment above the shared
1087
+ # _test_verdict_cmd definition.
1088
+ try:
1089
+ if not _prompt_and_relay_test_verdict(
1090
+ work_assignment_id=smoke_of,
1091
+ smoke_assignment_id=assignment_id,
1092
+ repo_name=repo,
1093
+ repo_github=repo_cfg.github,
1094
+ issue_number=issue,
1095
+ machine_name=machine,
1096
+ verdict_cmd_hint=_test_verdict_cmd,
1097
+ ):
1098
+ click.echo(
1099
+ " smoke session ended with no test verdict recorded "
1100
+ "— the merge gate stays blocked until a verdict is reported."
1101
+ )
1102
+ except Exception as exc: # noqa: BLE001 — best-effort backstop
1103
+ click.echo(
1104
+ f" warning: test-verdict backstop failed: {exc}",
1105
+ err=True,
1106
+ )
1107
+ sys.exit(exit_code)
1108
+
1109
+
1110
+ def _dispatch_audit_of(
1111
+ *,
1112
+ machine: str,
1113
+ repo: str,
1114
+ model: str | None,
1115
+ dry_run: bool,
1116
+ audit_of: str,
1117
+ cfg: Config,
1118
+ machine_obj: object,
1119
+ repo_cfg: object,
1120
+ provider: object,
1121
+ _is_local: bool,
1122
+ _issue_ctx: str,
1123
+ ) -> None:
1124
+ """Milestone Outcome Audit Phase 1 (#885): human-attended, READ-ONLY
1125
+ milestone-outcome analyst for the milestone's tracking EPIC ISSUE.
1126
+
1127
+ Mirrors `_dispatch_smoke_of`'s read-only / live-checkout / no-worktree
1128
+ shape, but the target (``audit_of``) is a GitHub issue number — the
1129
+ milestone's tracking epic — not a board work-assignment id, so there is
1130
+ no board lookup here. The epic's own body (goals/acceptance/plan
1131
+ checklist) and its milestone's issue states are fetched fresh and handed
1132
+ to the agent, which measures each goal against the code with shell tools
1133
+ and posts a scorecard via `coord report-result` (landing as a comment on
1134
+ the epic, per issue_number below).
1135
+ """
1136
+ import time as _time # noqa: PLC0415
1137
+ import uuid as _uuid # noqa: PLC0415
1138
+
1139
+ from coord.board_service import read_board as _read_board_au # noqa: PLC0415
1140
+ from coord.board_service import write_board as _write_board_au # noqa: PLC0415
1141
+ from coord.interactive import ( # noqa: PLC0415
1142
+ finalize_interactive_exit,
1143
+ launch_human_attended_interactive,
1144
+ tmux_available as _tmux_avail,
1145
+ tmux_session_alive as _tmux_alive,
1146
+ tmux_session_name as _tmux_name,
1147
+ )
1148
+ from coord.models import Assignment as _AssignmentAu # noqa: PLC0415
1149
+ from coord.state import record_dispatched_assignment as _record_au # noqa: PLC0415
1150
+ from coord.agent import AssignmentSpec as _AssignmentSpecAu # noqa: PLC0415
1151
+
1152
+ if not _is_local:
1153
+ click.echo(
1154
+ "error: --audit-of is local-only for now; run it on the "
1155
+ "machine that holds the checkout.",
1156
+ err=True,
1157
+ )
1158
+ sys.exit(2)
1159
+
1160
+ try:
1161
+ epic_num = int(audit_of)
1162
+ except ValueError:
1163
+ click.echo(
1164
+ f"error: --audit-of {audit_of!r} must be a GitHub issue number "
1165
+ "(the milestone's tracking epic).",
1166
+ err=True,
1167
+ )
1168
+ sys.exit(2)
1169
+
1170
+ try:
1171
+ epic_data = github_ops.get_issue(repo_cfg.github, epic_num)
1172
+ except RuntimeError as e:
1173
+ click.echo(f"error: could not fetch epic issue #{epic_num}: {e}", err=True)
1174
+ sys.exit(1)
1175
+
1176
+ epic_title = epic_data.get("title") or f"Issue #{epic_num}"
1177
+ epic_body = epic_data.get("body") or "(no body)"
1178
+ milestone = epic_data.get("milestone")
1179
+ if not milestone or not milestone.get("title"):
1180
+ click.echo(
1181
+ f"error: --audit-of {epic_num}: issue has no milestone — the "
1182
+ "audit needs a milestone to enumerate issue states for (assign "
1183
+ "one with `coord milestone assign` first).",
1184
+ err=True,
1185
+ )
1186
+ sys.exit(2)
1187
+ milestone_title = milestone["title"]
1188
+
1189
+ try:
1190
+ milestone_issues = github_ops.get_milestone_issues(repo_cfg.github, milestone_title)
1191
+ except RuntimeError as e:
1192
+ click.echo(
1193
+ f"error: could not list issues for milestone {milestone_title!r}: {e}",
1194
+ err=True,
1195
+ )
1196
+ sys.exit(1)
1197
+
1198
+ audit_repo_path = str(
1199
+ Path(machine_obj.repo_path(repo) or str(Path.cwd())).expanduser()
1200
+ )
1201
+ audit_default_branch = repo_cfg.default_branch or "main"
1202
+ resolved_model = model if model else cfg.models.default
1203
+ assignment_id = _uuid.uuid4().hex[:12]
1204
+
1205
+ # Table of milestone issue states (number/state/title/labels) so the
1206
+ # agent has them up front — it has no `gh` access to fetch this itself.
1207
+ _issue_lines = [
1208
+ "- #{num} [{state}] {title}{labels}".format(
1209
+ num=iss.get("number"),
1210
+ state=iss.get("state", "?"),
1211
+ title=iss.get("title", ""),
1212
+ labels=(
1213
+ " ({})".format(", ".join(lbl.get("name", "") for lbl in iss.get("labels") or []))
1214
+ if iss.get("labels")
1215
+ else ""
1216
+ ),
1217
+ )
1218
+ for iss in sorted(milestone_issues, key=lambda i: i.get("number", 0))
1219
+ ]
1220
+ milestone_issues_block = "\n".join(_issue_lines) or "(no issues found under this milestone)"
1221
+
1222
+ # #886 Phase 2: fetch prior `--audit-of` runs against this epic (if any) so
1223
+ # the agent measures AGAINST the last verdict — the concrete "re-ask the
1224
+ # question" loop the issue asks for — and so the next run_number is known
1225
+ # up front. Best-effort: a lookup failure just means this looks like run 1.
1226
+ import json as _json_au # noqa: PLC0415
1227
+
1228
+ from coord import issue_store as _issue_store_au # noqa: PLC0415
1229
+
1230
+ try:
1231
+ _prior_audit_runs = _issue_store_au.get_audit_runs_for_epic(repo, epic_num)
1232
+ except Exception: # noqa: BLE001 — best-effort; treat as no prior runs
1233
+ _prior_audit_runs = []
1234
+ _next_run_number = len(_prior_audit_runs) + 1
1235
+ if _prior_audit_runs:
1236
+ _last_run = _prior_audit_runs[-1]
1237
+ _last_goals: list = []
1238
+ try:
1239
+ _last_goals = _json_au.loads(_last_run.get("audit_goals_json") or "[]")
1240
+ except (TypeError, ValueError):
1241
+ _last_goals = []
1242
+ _prior_goal_lines = [
1243
+ "- {goal}: {verdict} ({evidence})".format(
1244
+ goal=g.get("goal", "?"),
1245
+ verdict=g.get("verdict", "?"),
1246
+ evidence=g.get("evidence", "") or "no evidence recorded",
1247
+ )
1248
+ for g in _last_goals
1249
+ ]
1250
+ prior_run_block = (
1251
+ f"## Prior audit run (v{_last_run.get('audit_run_number')})\n\n"
1252
+ f"**Bottom line:** {_last_run.get('audit_bottom_line') or '(none recorded)'}\n\n"
1253
+ + ("\n".join(_prior_goal_lines) or "(no goals recorded)")
1254
+ + "\n\n**This is run v"
1255
+ + str(_next_run_number)
1256
+ + "** — measure each goal again from scratch (never trust the "
1257
+ "prior verdict without re-checking), then report whether each "
1258
+ "moved (gap→met, still open, regressed, or new).\n\n"
1259
+ )
1260
+ else:
1261
+ prior_run_block = (
1262
+ f"## Prior audit runs\n\n(none — this is run v{_next_run_number}, "
1263
+ "the first audit of this milestone.)\n\n"
1264
+ )
1265
+
1266
+ INTERACTIVE_AUDIT_SYSTEM_PROMPT = (
1267
+ "You are a human-attended MILESTONE OUTCOME AUDITOR dispatched by "
1268
+ "the coordinator (#885). You are an independent analyst — measure "
1269
+ "reality, do not rubber-stamp ticket state.\n\n"
1270
+ "Rules:\n"
1271
+ "- READ-ONLY. Do NOT modify code, commit, push, or open/merge PRs "
1272
+ "(Edit/Write are not available to you).\n"
1273
+ "- Do NOT run `gh` commands. The coordinator owns GitHub — the epic "
1274
+ "body and milestone issue states are already in your briefing "
1275
+ "below.\n"
1276
+ "- You MAY run read-only git (log/diff/show/merge-base/branch -r) "
1277
+ "and any read-only shell tool (wc, grep -r, find, cat, test/coverage "
1278
+ "runs, etc.) against the LIVE checkout to measure reality.\n\n"
1279
+ "Method:\n"
1280
+ "1. Read the epic's goals, acceptance criteria, and any plan/work-"
1281
+ "order checklist from its body (below).\n"
1282
+ "2. Review the milestone's issue states (below) — which are open, "
1283
+ "closed, still in flight.\n"
1284
+ "3. For EACH goal, MEASURE it against the actual code — never trust "
1285
+ "ticket state or a self-reported summary. Concretely:\n"
1286
+ " - decomposition/size claims -> `wc -l <file>` on the files in "
1287
+ "question\n"
1288
+ " - \"feature X exists\" claims -> `grep -rn` for the actual "
1289
+ "symbols/call sites\n"
1290
+ " - \"branch Y merged\" claims -> `git merge-base --is-ancestor "
1291
+ "<branch> origin/main` (or confirm the file exists on the default "
1292
+ "branch)\n"
1293
+ " - test/coverage claims -> run the test suite or read coverage "
1294
+ "output where relevant\n"
1295
+ "4. Emit a scorecard: one row per goal — before/after, "
1296
+ "met|partial|gap, and the concrete evidence (command + result) that "
1297
+ "backs the verdict. Call out specific files/line-counts/gaps by "
1298
+ "name (e.g. a god-file that grew instead of shrank, an open seam "
1299
+ "issue that was supposed to close).\n"
1300
+ "5. End with a one-line bottom-line verdict (e.g. \"5/6 goals "
1301
+ "met\").\n"
1302
+ "6. If a PRIOR AUDIT RUN is included in your briefing below, measure "
1303
+ "each of ITS goals again too (same goal text) so the coordinator can "
1304
+ "compute the diff — which gaps closed, which are still open, whether "
1305
+ "anything regressed.\n\n"
1306
+ "When done, write BOTH of these and relay them together in ONE "
1307
+ "`coord report-result` call:\n"
1308
+ " (a) the FULL prose scorecard to a temp file (--body-file), AND\n"
1309
+ " (b) a STRUCTURED JSON file (--audit-json) shaped exactly like:\n"
1310
+ ' {"bottom_line": "5/6 goals met", "goals": [{"goal": '
1311
+ '"short goal name — MUST match the prior run\'s goal text exactly '
1312
+ 'when re-measuring it", "metric_before": "...", "metric_after": '
1313
+ '"...", "verdict": "met|partial|gap", "evidence": "command + '
1314
+ 'result"}, ...]}\n'
1315
+ " coord report-result --assignment <assignment_id> --status done "
1316
+ "--summary \"<bottom-line, one paragraph>\" --body-file "
1317
+ "<path-to-scorecard.md> --audit-json <path-to-verdict.json>\n"
1318
+ "Both flags are REQUIRED — --body-file posts the readable scorecard "
1319
+ "as a comment; --audit-json is what makes the verdict structured, "
1320
+ "versioned, and diffable run-over-run (#886). Omitting --audit-json "
1321
+ "leaves this run un-diffable against the next one.\n"
1322
+ )
1323
+
1324
+ audit_briefing = (
1325
+ f"# Milestone outcome audit: {repo_cfg.github} epic #{epic_num}\n\n"
1326
+ f"**Epic:** {epic_title}\n"
1327
+ f"**Milestone:** {milestone_title}\n"
1328
+ f"**Repo checkout:** {audit_repo_path}\n\n"
1329
+ "## ⚠ Do NOT move this checkout's branch\n\n"
1330
+ f"`{audit_repo_path}` is the **live checkout that runs the "
1331
+ "coordinator itself** (and the worktree base for workers). Do "
1332
+ "**NOT** `git checkout` / `git switch` / `git reset` / "
1333
+ "`git stash` it. Read files, run read-only git/shell commands, and "
1334
+ "measure — never move the branch.\n\n"
1335
+ f"## Epic body (goals / acceptance / plan)\n\n{epic_body}\n\n"
1336
+ f"## Milestone issue states ({milestone_title})\n\n"
1337
+ f"{milestone_issues_block}\n\n"
1338
+ f"{prior_run_block}"
1339
+ "## Your job\n\n"
1340
+ "Measure each goal against the code (see the system prompt for the "
1341
+ "method), emit a scorecard, and relay it with `coord report-result "
1342
+ f"--assignment {assignment_id} --status done --summary \"...\" "
1343
+ "--body-file <scorecard.md> --audit-json <verdict.json>`.\n"
1344
+ )
1345
+
1346
+ report_reminder = (
1347
+ f"[Coordinator audit assignment {assignment_id}] HUMAN-ATTENDED "
1348
+ "read-only milestone-outcome audit (#885/#886), run v"
1349
+ f"{_next_run_number}. When done, run `coord "
1350
+ f"report-result --assignment {assignment_id} --status done "
1351
+ "--summary \"<bottom-line>\" --body-file <scorecard.md> --audit-json "
1352
+ "<verdict.json>` so the scorecard AND the structured verdict post "
1353
+ "together and this session's row closes.\n\n"
1354
+ )
1355
+ effective_briefing = _issue_ctx + report_reminder + audit_briefing
1356
+
1357
+ spec = _AssignmentSpecAu(
1358
+ repo_name=repo,
1359
+ repo_path=audit_repo_path,
1360
+ issue_number=epic_num,
1361
+ issue_title=f"[audit] {epic_title}",
1362
+ briefing=effective_briefing,
1363
+ model=resolved_model,
1364
+ type="audit",
1365
+ provider="claude-pty",
1366
+ )
1367
+ # READ-ONLY: no Edit/Write — the audit measures, it never fixes.
1368
+ argv = provider.build_command(
1369
+ spec,
1370
+ resolved_model=resolved_model,
1371
+ system_prompt=INTERACTIVE_AUDIT_SYSTEM_PROMPT,
1372
+ allowed_tools="Read,Bash,Grep,Glob",
1373
+ )
1374
+
1375
+ click.echo(
1376
+ f"{machine} (local TTY) → AUDIT of epic #{epic_num}: {epic_title}"
1377
+ )
1378
+ click.echo(" mode: HUMAN-ATTENDED read-only milestone-outcome audit (#885)")
1379
+ click.echo(f" assignment id: {assignment_id} (audit_of={epic_num})")
1380
+ click.echo(f" milestone: {milestone_title}")
1381
+ click.echo(
1382
+ f" cwd: {audit_repo_path} (live checkout — read-only, no worktree)"
1383
+ )
1384
+ if dry_run:
1385
+ click.echo(" (dry run — not launched)")
1386
+ click.echo(f" would exec: {argv}")
1387
+ return
1388
+
1389
+ audit_assignment = _AssignmentAu(
1390
+ machine_name=machine,
1391
+ repo_name=repo,
1392
+ issue_number=epic_num,
1393
+ issue_title=f"[audit] {epic_title}",
1394
+ briefing=effective_briefing,
1395
+ assignment_id=assignment_id,
1396
+ status="running",
1397
+ dispatched_at=_time.time(),
1398
+ type="audit",
1399
+ model=resolved_model,
1400
+ provider_name="claude-pty",
1401
+ )
1402
+ _record_au(assignment=audit_assignment, repo_github=repo_cfg.github)
1403
+ _write_board_au(_read_board_au())
1404
+ os.environ["COORD_ASSIGNMENT_ID"] = assignment_id
1405
+
1406
+ started_at = _time.time()
1407
+ exit_code = launch_human_attended_interactive(
1408
+ argv,
1409
+ effective_briefing,
1410
+ assignment_id=assignment_id,
1411
+ cwd=audit_repo_path,
1412
+ )
1413
+ if exit_code != 0:
1414
+ click.echo(f" claude exited with status {exit_code}", err=True)
1415
+
1416
+ _sname = _tmux_name(assignment_id) if _tmux_avail() else None
1417
+ if _sname and _tmux_alive(_sname):
1418
+ click.echo(
1419
+ f" session still running in tmux: {_sname}\n"
1420
+ f" reattach with: coord reattach {assignment_id}"
1421
+ )
1422
+ sys.exit(0)
1423
+
1424
+ # worktree_path=None: read-only, live checkout — never push/remove it.
1425
+ try:
1426
+ finalize_interactive_exit(
1427
+ assignment_id=assignment_id,
1428
+ repo_name=repo,
1429
+ repo_github=repo_cfg.github,
1430
+ issue_number=epic_num,
1431
+ machine_name=machine,
1432
+ worktree_path=None,
1433
+ base_branch=audit_default_branch,
1434
+ exit_code=exit_code,
1435
+ started_at=started_at,
1436
+ log_path=None,
1437
+ repo_path=None,
1438
+ )
1439
+ except Exception as exc: # noqa: BLE001 — best-effort backstop
1440
+ click.echo(
1441
+ f" warning: backstop failed to record audit exit: {exc}",
1442
+ err=True,
1443
+ )
1444
+ return
1445
+
1446
+
1447
+ def _dispatch_milestone_chat_of(
1448
+ *,
1449
+ machine: str,
1450
+ repo: str,
1451
+ model: str | None,
1452
+ dry_run: bool,
1453
+ milestone_chat_of: str,
1454
+ add_child: str | None,
1455
+ cfg: Config,
1456
+ machine_obj: object,
1457
+ repo_cfg: object,
1458
+ provider: object,
1459
+ _is_local: bool,
1460
+ ) -> None:
1461
+ """#1029: human-attended, genuine tmux-attached interactive milestone-chat
1462
+ session — replaces the headless `claude -p` / SSE-overlay mechanism
1463
+ (`coord/milestone_chat.py::dispatch_milestone_chat`, still used by the
1464
+ remote/headless `coord milestone chat` CLI path and by
1465
+ `dispatch_new_milestone_chat`'s `--new` flow, which stays out of scope).
1466
+
1467
+ Mirrors `_dispatch_audit_of`'s shape: the target (`milestone_chat_of`) is
1468
+ a GitHub issue number — the milestone's tracking issue — not a board
1469
+ work-assignment id, so there is no board lookup. The milestone/issue
1470
+ resolution and seed-briefing content are shared with the headless path
1471
+ via `coord.milestone_chat.resolve_milestone_chat_briefing` so the two
1472
+ dispatch mechanisms can never drift apart on what the agent is told.
1473
+
1474
+ `--system-prompt`/`--allowedTools` are deliberately NOT passed to
1475
+ `provider.build_command` — `ClaudePtyProvider.build_command` already has
1476
+ a `spec.type == "milestone-chat"` branch (added for the headless path)
1477
+ that supplies `MILESTONE_CHAT_SYSTEM_PROMPT` +
1478
+ `build_deny_prompt(MILESTONE_CHAT_DENY_COMMANDS)` and `Read,Bash` tools
1479
+ automatically from `spec.type` alone.
1480
+ """
1481
+ import tempfile as _tempfile # noqa: PLC0415
1482
+ import time as _time # noqa: PLC0415
1483
+ import uuid as _uuid # noqa: PLC0415
1484
+
1485
+ from coord.interactive import ( # noqa: PLC0415
1486
+ finalize_interactive_exit,
1487
+ launch_human_attended_interactive,
1488
+ tmux_available as _tmux_avail,
1489
+ tmux_session_alive as _tmux_alive,
1490
+ tmux_session_name as _tmux_name,
1491
+ )
1492
+ from coord.milestone_chat import resolve_milestone_chat_briefing # noqa: PLC0415
1493
+ from coord.board_service import read_board as _read_board_mc # noqa: PLC0415
1494
+ from coord.board_service import write_board as _write_board_mc # noqa: PLC0415
1495
+ from coord.models import Assignment as _AssignmentMc # noqa: PLC0415
1496
+ from coord.state import record_dispatched_assignment as _record_mc # noqa: PLC0415
1497
+ from coord.agent import AssignmentSpec as _AssignmentSpecMc # noqa: PLC0415
1498
+
1499
+ if not _is_local:
1500
+ click.echo(
1501
+ "error: --milestone-chat-of is local-only for now; run it on the "
1502
+ "machine that holds the checkout.",
1503
+ err=True,
1504
+ )
1505
+ sys.exit(2)
1506
+
1507
+ try:
1508
+ tracking_issue = int(milestone_chat_of)
1509
+ except ValueError:
1510
+ click.echo(
1511
+ f"error: --milestone-chat-of {milestone_chat_of!r} must be a GitHub "
1512
+ "issue number (the milestone's tracking issue).",
1513
+ err=True,
1514
+ )
1515
+ sys.exit(2)
1516
+
1517
+ add_child_issue: int | None = None
1518
+ if add_child is not None:
1519
+ try:
1520
+ add_child_issue = int(add_child)
1521
+ except ValueError:
1522
+ click.echo(
1523
+ f"error: --add-child {add_child!r} must be a GitHub issue number.",
1524
+ err=True,
1525
+ )
1526
+ sys.exit(2)
1527
+
1528
+ try:
1529
+ ctx = resolve_milestone_chat_briefing(
1530
+ repo, tracking_issue, cfg, add_child_issue=add_child_issue,
1531
+ )
1532
+ except RuntimeError as e:
1533
+ click.echo(f"error: {e}", err=True)
1534
+ sys.exit(1)
1535
+
1536
+ mc_repo_path = str(
1537
+ Path(machine_obj.repo_path(repo) or str(Path.cwd())).expanduser()
1538
+ )
1539
+ mc_default_branch = repo_cfg.default_branch or "main"
1540
+ resolved_model = model if model else cfg.models.default
1541
+ assignment_id = _uuid.uuid4().hex[:12]
1542
+
1543
+ # Write the full briefing to a temp file and pre-fill only a SHORT
1544
+ # single-line seed pointing at it — mirrors `_run_troubleshoot_or_chat`'s
1545
+ # rationale: a multi-KB multi-line paste over the embedded-terminal /
1546
+ # tmux path is less reliable than a short one, and this degrades
1547
+ # gracefully (the operator can open the file by hand if the paste misses).
1548
+ _mc_brief_path = str(
1549
+ Path(_tempfile.gettempdir()) / f"coord-milestone-chat-{tracking_issue}.md"
1550
+ )
1551
+ Path(_mc_brief_path).write_text(ctx.briefing, encoding="utf-8")
1552
+ if add_child_issue is not None:
1553
+ seed_prompt = (
1554
+ f"Milestone chat for {repo} #{tracking_issue} ({ctx.milestone_title}): "
1555
+ f"read the full context at {_mc_brief_path} — it includes candidate "
1556
+ f"sub-issue #{add_child_issue}. Discuss whether it belongs under this "
1557
+ "epic and, once I confirm, run the `coord milestone add-child` splice "
1558
+ "described there."
1559
+ )
1560
+ else:
1561
+ seed_prompt = (
1562
+ f"Milestone chat for {repo} #{tracking_issue} ({ctx.milestone_title}): "
1563
+ f"read the full context at {_mc_brief_path} (tracking issue body, open "
1564
+ "issues under the milestone, current work order) and let's talk it "
1565
+ "through. Once I confirm a work order / edit / assignment, write it "
1566
+ "with the exact `coord milestone ...` command described there."
1567
+ )
1568
+
1569
+ spec = _AssignmentSpecMc(
1570
+ repo_name=repo,
1571
+ repo_path=mc_repo_path,
1572
+ issue_number=tracking_issue,
1573
+ issue_title=f"[milestone-chat] {ctx.tracking_title}",
1574
+ briefing=ctx.briefing,
1575
+ model=resolved_model,
1576
+ type="milestone-chat",
1577
+ provider="claude-pty",
1578
+ )
1579
+ # No explicit system_prompt/allowed_tools: ClaudePtyProvider.build_command's
1580
+ # spec.type == "milestone-chat" branch supplies MILESTONE_CHAT_SYSTEM_PROMPT
1581
+ # + the deny-list + Read,Bash automatically.
1582
+ argv = provider.build_command(spec, resolved_model=resolved_model)
1583
+
1584
+ click.echo(
1585
+ f"{machine} (local TTY) → MILESTONE CHAT #{tracking_issue}: "
1586
+ f"{ctx.milestone_title}"
1587
+ )
1588
+ click.echo(
1589
+ " mode: HUMAN-ATTENDED interactive milestone chat "
1590
+ "(live checkout, no claim, no worktree) (#1029)"
1591
+ )
1592
+ click.echo(f" assignment id: {assignment_id} (milestone_chat_of={tracking_issue})")
1593
+ if add_child_issue is not None:
1594
+ click.echo(f" add-child candidate: #{add_child_issue}")
1595
+ click.echo(f" cwd: {mc_repo_path} (live checkout — read-only, no worktree)")
1596
+ if dry_run:
1597
+ click.echo(" (dry run — not launched)")
1598
+ click.echo(f" would exec: {argv}")
1599
+ return
1600
+
1601
+ mc_assignment = _AssignmentMc(
1602
+ machine_name=machine,
1603
+ repo_name=repo,
1604
+ issue_number=tracking_issue,
1605
+ issue_title=f"[milestone-chat] {ctx.tracking_title}",
1606
+ briefing=ctx.briefing,
1607
+ assignment_id=assignment_id,
1608
+ status="running",
1609
+ dispatched_at=_time.time(),
1610
+ type="milestone-chat",
1611
+ model=resolved_model,
1612
+ provider_name="claude-pty",
1613
+ )
1614
+ _record_mc(assignment=mc_assignment, repo_github=repo_cfg.github)
1615
+ _write_board_mc(_read_board_mc())
1616
+ os.environ["COORD_ASSIGNMENT_ID"] = assignment_id
1617
+
1618
+ started_at = _time.time()
1619
+ exit_code = launch_human_attended_interactive(
1620
+ argv,
1621
+ seed_prompt,
1622
+ assignment_id=assignment_id,
1623
+ cwd=mc_repo_path,
1624
+ )
1625
+ if exit_code != 0:
1626
+ click.echo(f" claude exited with status {exit_code}", err=True)
1627
+
1628
+ _sname = _tmux_name(assignment_id) if _tmux_avail() else None
1629
+ if _sname and _tmux_alive(_sname):
1630
+ click.echo(
1631
+ f" session still running in tmux: {_sname}\n"
1632
+ f" reattach with: coord reattach {assignment_id}"
1633
+ )
1634
+ sys.exit(0)
1635
+
1636
+ # worktree_path=None: read-only, live checkout — never push/remove it.
1637
+ try:
1638
+ finalize_interactive_exit(
1639
+ assignment_id=assignment_id,
1640
+ repo_name=repo,
1641
+ repo_github=repo_cfg.github,
1642
+ issue_number=tracking_issue,
1643
+ machine_name=machine,
1644
+ worktree_path=None,
1645
+ base_branch=mc_default_branch,
1646
+ exit_code=exit_code,
1647
+ started_at=started_at,
1648
+ log_path=None,
1649
+ repo_path=None,
1650
+ )
1651
+ except Exception as exc: # noqa: BLE001 — best-effort backstop
1652
+ click.echo(
1653
+ f" warning: backstop failed to record milestone-chat exit: {exc}",
1654
+ err=True,
1655
+ )
1656
+ return
1657
+
1658
+
1659
+ def _run_troubleshoot_or_chat(
1660
+ is_chat: bool,
1661
+ *,
1662
+ machine: str,
1663
+ repo: str,
1664
+ issue: int,
1665
+ briefing: str,
1666
+ briefing_file: str | None,
1667
+ model: str | None,
1668
+ dry_run: bool,
1669
+ cfg: Config,
1670
+ machine_obj: object,
1671
+ repo_cfg: object,
1672
+ issue_title: str,
1673
+ provider: object,
1674
+ _is_local: bool,
1675
+ _issue_ctx: str,
1676
+ _ctx_write_hint: str,
1677
+ ) -> None:
1678
+ import time as _time # noqa: PLC0415
1679
+ import uuid as _uuid # noqa: PLC0415
1680
+
1681
+ from coord.interactive import ( # noqa: PLC0415
1682
+ finalize_interactive_exit,
1683
+ launch_human_attended_interactive,
1684
+ tmux_available as _tmux_avail,
1685
+ tmux_session_alive as _tmux_alive,
1686
+ tmux_session_name as _tmux_name,
1687
+ )
1688
+
1689
+ from coord.board_service import read_board as _read_board_ts # noqa: PLC0415
1690
+ from coord.board_service import write_board as _write_board_ts # noqa: PLC0415
1691
+ from coord.models import Assignment as _AssignmentTs # noqa: PLC0415
1692
+ from coord.state import ( # noqa: PLC0415
1693
+ record_dispatched_assignment as _record_ts,
1694
+ )
1695
+ from coord.agent import AssignmentSpec as _AssignmentSpecTs # noqa: PLC0415
1696
+
1697
+ # #628: --chat shares troubleshoot's shape (human-attended, live
1698
+ # checkout, no claim/worktree, briefed-from-file, finalize
1699
+ # worktree_path=None). It differs only in the system prompt/seed
1700
+ # (general Q&A + may EDIT the issue via coord) and type=chat.
1701
+ _is_chat = is_chat
1702
+ _mode = "chat" if _is_chat else "troubleshoot"
1703
+ if not _is_chat and not (briefing or "").strip():
1704
+ click.echo(
1705
+ "error: --troubleshoot requires a briefing "
1706
+ "(--briefing or --briefing-file).",
1707
+ err=True,
1708
+ )
1709
+ sys.exit(2)
1710
+ if _is_chat and not (briefing or "").strip():
1711
+ # The TUI seeds a rich briefing; a bare CLI --chat gets a minimal
1712
+ # one so the session still knows what issue it's about.
1713
+ briefing = (
1714
+ f"Chat about {repo} #{issue}. Gather any context you need with "
1715
+ f"`gh issue view {issue} --comments` and `coord` reads."
1716
+ )
1717
+ if not _is_local:
1718
+ click.echo(f"error: --{_mode} is local-only for now.", err=True)
1719
+ sys.exit(2)
1720
+
1721
+ ts_repo_path = str(
1722
+ Path(machine_obj.repo_path(repo) or str(Path.cwd())).expanduser()
1723
+ )
1724
+ ts_default_branch = repo_cfg.default_branch or "main"
1725
+ resolved_model = model if model else cfg.models.default
1726
+ assignment_id = _uuid.uuid4().hex[:12]
1727
+
1728
+ if _is_chat:
1729
+ _ts_system_prompt = (
1730
+ "You are a coordinator assistant in a HUMAN-ATTENDED 'chat "
1731
+ "about this issue' session. Help the operator think it "
1732
+ "through: answer open questions (is it still needed? what "
1733
+ "milestone? scope?), sketch designs/UX, and diagnose a stalled "
1734
+ "pipeline item. You MAY update the issue itself with `coord "
1735
+ "issue edit <repo> <issue> --title/--body-file` and send it to "
1736
+ "the Pending stage with `coord ready <repo> <issue>` — confirm "
1737
+ "with the operator before each write. Do NOT edit files in this "
1738
+ "live checkout, do NOT commit, and do NOT run `gh` to mutate — "
1739
+ "go through `coord` so the tracker stays behind the seam. "
1740
+ "Type /update-issue at any point to synthesize what we agreed "
1741
+ "and write it back to the issue body. "
1742
+ "CRITICAL — this session is diagnostic-only: do NOT run "
1743
+ "`coord report-result --status done` or `--status blocked` "
1744
+ "(you have no committed work to back a success claim, and doing "
1745
+ "so leaves a false-done box on the pipeline). "
1746
+ "For a stalled item, start with `coord diagnose <repo> <issue>` "
1747
+ "— it auto-recovers phantoms, orphaned worktrees, and dropped "
1748
+ "findings; confirm any --reset with the operator first."
1749
+ )
1750
+ ts_reminder = (
1751
+ f"[Coordinator chat assignment {assignment_id}] HUMAN-ATTENDED "
1752
+ "chat about this issue. You may edit the issue (`coord issue "
1753
+ "edit`) and send it to Pending (`coord ready`) — confirm first. "
1754
+ "Type /update-issue to synthesize what we agreed and write it "
1755
+ "back to the issue body. "
1756
+ "You are in the LIVE checkout: do NOT modify files or commit "
1757
+ "here (it is the editable coordinator + worker-worktree base); "
1758
+ "for a code change, surface a plan so the operator can dispatch "
1759
+ "Work. "
1760
+ "IMPORTANT: do NOT run `coord report-result --status done` — "
1761
+ "this session has no committed work and claiming done would "
1762
+ "leave a false-good-to-go box on the pipeline (#676).\n\n"
1763
+ )
1764
+ else:
1765
+ _ts_system_prompt = (
1766
+ "You are a coordinator troubleshooter in a HUMAN-ATTENDED "
1767
+ "session. You are READ-ONLY in a live checkout: do NOT modify "
1768
+ "files, do NOT commit, do NOT run `gh`. Investigate the stalled "
1769
+ "pipeline item using coord, git, and sqlite3 reads; explain "
1770
+ "what is wrong and what will unstick it; and surface any plan "
1771
+ "for the operator to approve before mutating anything. "
1772
+ "Start with `coord diagnose <repo> <issue>` — it auto-recovers "
1773
+ "phantoms, orphaned worktrees, and dropped findings; confirm "
1774
+ "any --reset with the operator first. "
1775
+ "CRITICAL: do NOT run `coord report-result --status done` or "
1776
+ "`--status blocked` — this session is diagnostic-only with no "
1777
+ "committed work, and claiming done leaves a false status on the "
1778
+ "pipeline (#676)."
1779
+ )
1780
+ ts_reminder = (
1781
+ f"[Coordinator troubleshoot assignment {assignment_id}] "
1782
+ "HUMAN-ATTENDED, READ-ONLY diagnostic for a stalled pipeline "
1783
+ "item. You are in the LIVE checkout — do NOT modify files here "
1784
+ "(it is the editable coordinator and the worker-worktree base). "
1785
+ "If a code fix is needed, surface the plan so the operator can "
1786
+ "dispatch a proper Fix. "
1787
+ "IMPORTANT: do NOT run `coord report-result --status done` — "
1788
+ "this session has no committed work (#676).\n\n"
1789
+ )
1790
+ effective_briefing = _issue_ctx + ts_reminder + briefing + _ctx_write_hint
1791
+
1792
+ # Pre-fill a SHORT, single-line prompt that points the session at
1793
+ # the full diagnostic on disk, rather than pasting the whole
1794
+ # multi-line briefing into the input box. A short paste lands
1795
+ # reliably; a multi-KB multi-line paste over the embedded-terminal /
1796
+ # nested-tmux path often is dropped by the readiness poll
1797
+ # (interactive._inject_briefing_into_tmux_session is best-effort).
1798
+ # And it degrades gracefully — if the paste is missed, the operator
1799
+ # can type the one short line by hand instead of being stranded with
1800
+ # no context. The full briefing still lives in the file and on the
1801
+ # assignment row.
1802
+ if briefing_file:
1803
+ _ts_brief_path = str(Path(briefing_file).expanduser())
1804
+ else:
1805
+ import tempfile as _tempfile # noqa: PLC0415
1806
+
1807
+ _ts_brief_path = str(
1808
+ Path(_tempfile.gettempdir()) / f"coord-{_mode}-{issue}.md"
1809
+ )
1810
+ Path(_ts_brief_path).write_text(effective_briefing, encoding="utf-8")
1811
+ if _is_chat:
1812
+ seed_prompt = (
1813
+ f"Chat about {repo} #{issue}: read the full context at "
1814
+ f"{_ts_brief_path} (the issue, comments, and board state), then "
1815
+ "let's talk it through. Ask me what I want — questions about "
1816
+ "scope/milestone/whether it's still needed, a UX sketch, or "
1817
+ "diagnosing a stall. You can update the issue with `coord issue "
1818
+ "edit` and send it to Pending with `coord ready` once we've "
1819
+ "settled it — just confirm with me first."
1820
+ )
1821
+ else:
1822
+ seed_prompt = (
1823
+ f"Troubleshoot {repo} #{issue}: read the diagnostic briefing at "
1824
+ f"{_ts_brief_path} (board state, assignments, merge-queue, CI, "
1825
+ "and a playbook of likely causes), then tell me what's wrong and "
1826
+ "the options to unstick it. You are read-only — do not modify "
1827
+ "files, commit, or run gh; surface any fix plan for me to "
1828
+ "approve."
1829
+ )
1830
+
1831
+ spec = _AssignmentSpecTs(
1832
+ repo_name=repo,
1833
+ repo_path=ts_repo_path,
1834
+ issue_number=issue,
1835
+ issue_title=f"[{_mode}] {issue_title}",
1836
+ briefing=effective_briefing,
1837
+ model=resolved_model,
1838
+ type=_mode,
1839
+ provider="claude-pty",
1840
+ )
1841
+ # No Edit/Write — the live checkout must not be mutated. Bash carries
1842
+ # coord/git/sqlite3 reads (and, for chat, the `coord issue edit` /
1843
+ # `coord ready` ISSUE writes); Read/Grep/Glob for inspection.
1844
+ argv = provider.build_command(
1845
+ spec,
1846
+ resolved_model=resolved_model,
1847
+ system_prompt=_ts_system_prompt,
1848
+ allowed_tools="Read,Bash,Grep,Glob",
1849
+ )
1850
+
1851
+ click.echo(
1852
+ f"{machine} (local TTY) → {_mode.upper()} #{issue}: {issue_title}"
1853
+ )
1854
+ click.echo(
1855
+ " mode: HUMAN-ATTENDED interactive "
1856
+ + ("chat about the issue " if _is_chat else "diagnostic ")
1857
+ + "(live checkout, no claim, no worktree) "
1858
+ + ("(#628)" if _is_chat else "(#569)")
1859
+ )
1860
+ click.echo(f" assignment id: {assignment_id}")
1861
+ click.echo(f" cwd: {ts_repo_path} (live checkout — read-only)")
1862
+ if dry_run:
1863
+ click.echo(" (dry run — not launched)")
1864
+ click.echo(f" would exec: {argv}")
1865
+ return
1866
+
1867
+ ts_assignment = _AssignmentTs(
1868
+ machine_name=machine,
1869
+ repo_name=repo,
1870
+ issue_number=issue,
1871
+ issue_title=f"[{_mode}] {issue_title}",
1872
+ briefing=effective_briefing,
1873
+ assignment_id=assignment_id,
1874
+ status="running",
1875
+ dispatched_at=_time.time(),
1876
+ type=_mode,
1877
+ model=resolved_model,
1878
+ provider_name="claude-pty",
1879
+ )
1880
+ _record_ts(assignment=ts_assignment, repo_github=repo_cfg.github)
1881
+ # #749: previously unconditional local build_board/save_board — this
1882
+ # function (unlike the --x-of variants) never had thin-client awareness,
1883
+ # so it silently wrote board_meta to a non-canonical local DB.
1884
+ _write_board_ts(_read_board_ts())
1885
+ os.environ["COORD_ASSIGNMENT_ID"] = assignment_id
1886
+
1887
+ started_at = _time.time()
1888
+ # Pre-fill the SHORT seed prompt (not the full briefing) — see the
1889
+ # seed_prompt rationale above.
1890
+ exit_code = launch_human_attended_interactive(
1891
+ argv,
1892
+ seed_prompt,
1893
+ assignment_id=assignment_id,
1894
+ cwd=ts_repo_path,
1895
+ )
1896
+ if exit_code != 0:
1897
+ click.echo(f" claude exited with status {exit_code}", err=True)
1898
+
1899
+ _sname = _tmux_name(assignment_id) if _tmux_avail() else None
1900
+ if _sname and _tmux_alive(_sname):
1901
+ click.echo(
1902
+ f" session still running in tmux: {_sname}\n"
1903
+ f" reattach with: coord reattach {assignment_id}"
1904
+ )
1905
+ sys.exit(0)
1906
+
1907
+ # finalize with worktree_path=None — read-only, never push/remove the
1908
+ # live checkout; the git-floor backstop just records a terminal row.
1909
+ try:
1910
+ finalize_interactive_exit(
1911
+ assignment_id=assignment_id,
1912
+ repo_name=repo,
1913
+ repo_github=repo_cfg.github,
1914
+ issue_number=issue,
1915
+ machine_name=machine,
1916
+ worktree_path=None,
1917
+ base_branch=ts_default_branch,
1918
+ exit_code=exit_code,
1919
+ started_at=started_at,
1920
+ log_path=None,
1921
+ repo_path=None,
1922
+ )
1923
+ except Exception as exc: # noqa: BLE001 — best-effort backstop
1924
+ click.echo(
1925
+ f" warning: backstop failed to record troubleshoot exit: {exc}",
1926
+ err=True,
1927
+ )
1928
+ return
1929
+
1930
+
1931
+ def _dispatch_troubleshoot(
1932
+ *,
1933
+ machine: str,
1934
+ repo: str,
1935
+ issue: int,
1936
+ briefing: str,
1937
+ briefing_file: str | None,
1938
+ model: str | None,
1939
+ dry_run: bool,
1940
+ cfg: Config,
1941
+ machine_obj: object,
1942
+ repo_cfg: object,
1943
+ issue_title: str,
1944
+ provider: object,
1945
+ _is_local: bool,
1946
+ _issue_ctx: str,
1947
+ _ctx_write_hint: str,
1948
+ ) -> None:
1949
+ """#569: human-attended, READ-ONLY diagnostic interactive session."""
1950
+ _run_troubleshoot_or_chat(
1951
+ is_chat=False,
1952
+ machine=machine,
1953
+ repo=repo,
1954
+ issue=issue,
1955
+ briefing=briefing,
1956
+ briefing_file=briefing_file,
1957
+ model=model,
1958
+ dry_run=dry_run,
1959
+ cfg=cfg,
1960
+ machine_obj=machine_obj,
1961
+ repo_cfg=repo_cfg,
1962
+ issue_title=issue_title,
1963
+ provider=provider,
1964
+ _is_local=_is_local,
1965
+ _issue_ctx=_issue_ctx,
1966
+ _ctx_write_hint=_ctx_write_hint,
1967
+ )
1968
+
1969
+
1970
+ def _dispatch_chat(
1971
+ *,
1972
+ machine: str,
1973
+ repo: str,
1974
+ issue: int,
1975
+ briefing: str,
1976
+ briefing_file: str | None,
1977
+ model: str | None,
1978
+ dry_run: bool,
1979
+ cfg: Config,
1980
+ machine_obj: object,
1981
+ repo_cfg: object,
1982
+ issue_title: str,
1983
+ provider: object,
1984
+ _is_local: bool,
1985
+ _issue_ctx: str,
1986
+ _ctx_write_hint: str,
1987
+ ) -> None:
1988
+ """#628: human-attended 'Chat about issue' interactive session."""
1989
+ _run_troubleshoot_or_chat(
1990
+ is_chat=True,
1991
+ machine=machine,
1992
+ repo=repo,
1993
+ issue=issue,
1994
+ briefing=briefing,
1995
+ briefing_file=briefing_file,
1996
+ model=model,
1997
+ dry_run=dry_run,
1998
+ cfg=cfg,
1999
+ machine_obj=machine_obj,
2000
+ repo_cfg=repo_cfg,
2001
+ issue_title=issue_title,
2002
+ provider=provider,
2003
+ _is_local=_is_local,
2004
+ _issue_ctx=_issue_ctx,
2005
+ _ctx_write_hint=_ctx_write_hint,
2006
+ )
2007
+
2008
+
2009
+ def _dispatch_fix_of(
2010
+ *,
2011
+ machine: str,
2012
+ repo: str,
2013
+ issue: int,
2014
+ briefing: str,
2015
+ model: str | None,
2016
+ dry_run: bool,
2017
+ force: bool,
2018
+ fix_of: str,
2019
+ cfg: Config,
2020
+ machine_obj: object,
2021
+ repo_cfg: object,
2022
+ issue_title: str,
2023
+ provider: object,
2024
+ _is_local: bool,
2025
+ _svc: object,
2026
+ _interactive_board: object,
2027
+ _issue_ctx: str,
2028
+ _ctx_write_hint: str,
2029
+ ) -> None:
2030
+ """Leg 3 (#517): human-attended FIX for a review whose verdict was request-changes (also accepts a failed-test work id, #581)."""
2031
+ import time as _time # noqa: PLC0415
2032
+ import uuid as _uuid # noqa: PLC0415
2033
+
2034
+ from coord.interactive import ( # noqa: PLC0415
2035
+ TmuxHost,
2036
+ _holder_is_base_checkout as _holder_is_base,
2037
+ _launch_via_tmux as _tmux_launch,
2038
+ _remote_base_checkout_free_branch as _remote_free_base,
2039
+ _remote_orphan_is_safe_to_prune as _remote_orphan_safe,
2040
+ _remote_worktree_remove as _remote_wt_remove,
2041
+ find_remote_branch_holder as _find_branch_holder,
2042
+ finalize_interactive_exit,
2043
+ finalize_remote_interactive_exit,
2044
+ launch_human_attended_interactive,
2045
+ remote_worktree_exists as _remote_wt_exists,
2046
+ tmux_available as _tmux_avail,
2047
+ tmux_session_alive as _tmux_alive,
2048
+ tmux_session_name as _tmux_name,
2049
+ )
2050
+
2051
+ from coord.auto_loop import ( # noqa: PLC0415
2052
+ _build_fix_briefing,
2053
+ _fix_model_for_iteration,
2054
+ _load_review_findings,
2055
+ )
2056
+ from coord.agent import ( # noqa: PLC0415
2057
+ AssignmentSpec as _AssignmentSpecFx,
2058
+ _GitError as _AgentGitErrorFx,
2059
+ narrow_artifact_paths as _narrow_ap_fx,
2060
+ setup_interactive_worktree as _setup_wt_fx,
2061
+ )
2062
+ from coord.models import Assignment as _AssignmentFx # noqa: PLC0415
2063
+ from coord.state import ( # noqa: PLC0415
2064
+ COORD_DIR as _COORD_DIR_FX,
2065
+ build_board as _build_board_fx,
2066
+ record_dispatched_assignment as _record_fx,
2067
+ save_board as _save_board_fx,
2068
+ set_assignment_failure_reason as _set_fail_reason_fx,
2069
+ )
2070
+
2071
+ # Track B / #486: a fix runs on the LOCAL TTY or on a REMOTE
2072
+ # machine over ssh+tmux. Unlike review, a fix WRITES — it needs a
2073
+ # worktree on the EXISTING branch and its commits must be pushed
2074
+ # back to origin (the #486d push-back, via
2075
+ # finalize_remote_interactive_exit; the local finalize only sees
2076
+ # the local filesystem).
2077
+ _fx_board = _interactive_board(_build_board_fx)
2078
+ review = _fx_board.find_by_id(fix_of)
2079
+ if review is None:
2080
+ click.echo(
2081
+ f"error: --fix-of {fix_of}: no such assignment on the board.",
2082
+ err=True,
2083
+ )
2084
+ sys.exit(2)
2085
+ # Two accepted shapes for --fix-of (#581):
2086
+ # (a) a REVIEW assignment whose verdict was request-changes — the
2087
+ # original leg-3a path; work = review.review_of_assignment_id,
2088
+ # findings = the reviewer's findings.
2089
+ # (b) a WORK assignment whose Test gate FAILED — the test-fail fix
2090
+ # front door; work = the target itself, findings = the recorded
2091
+ # test-failure story (test_reason).
2092
+ _fix_from_test_fail = (
2093
+ review.type != "review"
2094
+ and (getattr(review, "test_state", None) == "failed")
2095
+ )
2096
+ if review.type != "review" and not _fix_from_test_fail:
2097
+ click.echo(
2098
+ f"error: --fix-of {fix_of} is type={review.type!r} with "
2099
+ f"test_state={getattr(review, 'test_state', None)!r}. Pass "
2100
+ "either a REVIEW id whose verdict was request-changes, or a "
2101
+ "WORK id whose Test gate failed.",
2102
+ err=True,
2103
+ )
2104
+ sys.exit(2)
2105
+ if _fix_from_test_fail:
2106
+ work = review # the failed work row IS the thing to fix
2107
+ else:
2108
+ work = (
2109
+ _fx_board.find_by_id(review.review_of_assignment_id)
2110
+ if review.review_of_assignment_id
2111
+ else None
2112
+ )
2113
+ if work is None:
2114
+ click.echo(
2115
+ f"error: review {fix_of} has no linked work assignment "
2116
+ "(review_of_assignment_id is unset).",
2117
+ err=True,
2118
+ )
2119
+ sys.exit(2)
2120
+ if not work.branch:
2121
+ click.echo(
2122
+ f"error: work assignment {work.assignment_id} has no branch "
2123
+ "to fix.",
2124
+ err=True,
2125
+ )
2126
+ sys.exit(2)
2127
+
2128
+ # Iteration accounting mirrors the auto-loop fix path so the merge
2129
+ # gate and the next review see an identical work→fix→review chain.
2130
+ next_iteration = (work.review_iteration or 0) + 1
2131
+ max_iter = cfg.pipeline.max_review_iterations
2132
+ if next_iteration > max_iter:
2133
+ if force:
2134
+ # #855-follow-up: intractable stories (e.g. vimcode#515) legitimately
2135
+ # need more than the cap. --force is the operator's explicit "I know,
2136
+ # keep going" override so a hard stop isn't the only exit.
2137
+ click.echo(
2138
+ f"warning: max_review_iterations ({max_iter}) reached for "
2139
+ f"work {work.assignment_id}; dispatching iteration "
2140
+ f"{next_iteration} anyway (--force).",
2141
+ err=True,
2142
+ )
2143
+ else:
2144
+ click.echo(
2145
+ f"error: max_review_iterations ({max_iter}) reached for "
2146
+ f"work {work.assignment_id}; not dispatching another fix. "
2147
+ "Re-run with --force to override for this dispatch, or bump "
2148
+ "pipeline.max_review_iterations in coordinator.yml.",
2149
+ err=True,
2150
+ )
2151
+ sys.exit(2)
2152
+
2153
+ # Findings: reuse the SAME loader the claude -p fix path uses (DB
2154
+ # cache → log → agent). Local-only ⇒ no machine_host. Fall back to
2155
+ # a pointer-to-the-review brief when nothing structured was captured
2156
+ # (interactive reviews may report only a one-line verdict summary).
2157
+ if _fix_from_test_fail:
2158
+ # The findings ARE the recorded test-failure story (#581). No
2159
+ # reviewer log to consult — the operator's `coord test --fail
2160
+ # --reason` text is what the fix worker needs.
2161
+ # #1337: the board wire carries only a bounded PREVIEW of test_reason;
2162
+ # the briefing quotes it verbatim, so read the full text through the
2163
+ # detail endpoint (falls back to the board-carried value).
2164
+ from coord.state import load_assignment_test_reason as _load_tr # noqa: PLC0415
2165
+
2166
+ _test_story = (
2167
+ _load_tr(work.assignment_id or "")
2168
+ or getattr(work, "test_reason", None)
2169
+ or ""
2170
+ ).strip()
2171
+ _findings_body = (
2172
+ "The manual smoke test FAILED. The operator reported:\n\n"
2173
+ f"> {_test_story}\n\n"
2174
+ "Reproduce the failure, fix the root cause, and re-validate "
2175
+ "before pushing."
2176
+ if _test_story
2177
+ else (
2178
+ "The manual smoke test FAILED (no reason text was "
2179
+ "recorded). Pull the branch, reproduce the failure the "
2180
+ "operator hit, and fix the root cause before pushing."
2181
+ )
2182
+ )
2183
+ else:
2184
+ _fx_log = _COORD_DIR_FX / "logs" / f"{fix_of}.log"
2185
+ _fx_log_path = str(_fx_log) if _fx_log.exists() else None
2186
+ try:
2187
+ findings = _load_review_findings(
2188
+ review, _fx_log_path, None, repo_github=repo_cfg.github,
2189
+ )
2190
+ except Exception: # noqa: BLE001 — best-effort; fall back below
2191
+ findings = None
2192
+ if findings is not None and (getattr(findings, "body", "") or "").strip():
2193
+ _findings_body = findings.body.strip()
2194
+ else:
2195
+ _findings_body = (
2196
+ f"(No structured findings were captured for review {fix_of}.) "
2197
+ f"The review verdict was {review.review_verdict or 'request-changes'!r}. "
2198
+ "Read the reviewer's feedback on the PR / issue and address "
2199
+ "every blocking item before pushing."
2200
+ )
2201
+ from types import SimpleNamespace as _SNS # noqa: PLC0415
2202
+ fix_briefing = _build_fix_briefing(
2203
+ work, _SNS(body=_findings_body), next_iteration, max_iter,
2204
+ )
2205
+
2206
+ # #803: escalate the model per fix iteration, mirroring the headless
2207
+ # auto-loop path. Explicit --model always wins; when omitted,
2208
+ # _fix_model_for_iteration returns the appropriate tier (or None when
2209
+ # pipeline.escalate_fix_model=False), falling back to cfg.models.default.
2210
+ resolved_model = (
2211
+ model
2212
+ or _fix_model_for_iteration(cfg, next_iteration)
2213
+ or cfg.models.default
2214
+ )
2215
+ assignment_id = _uuid.uuid4().hex[:12]
2216
+ if _is_local:
2217
+ fix_repo_path = str(
2218
+ Path(machine_obj.repo_path(repo) or str(Path.cwd())).expanduser()
2219
+ )
2220
+ else:
2221
+ fix_repo_path = machine_obj.repo_path(repo) or f"~/src/{repo}"
2222
+ fix_default_branch = repo_cfg.default_branch or "main"
2223
+
2224
+ os.environ["COORD_ASSIGNMENT_ID"] = assignment_id
2225
+ if _is_local:
2226
+ report_reminder = (
2227
+ f"[Coordinator fix assignment {assignment_id}] HUMAN-ATTENDED "
2228
+ f"fix iteration {next_iteration}/{max_iter} on branch "
2229
+ f"{work.branch}. Before you exit, run `coord report-result "
2230
+ f"--assignment {assignment_id} --status done --summary <text>` "
2231
+ "so the coordinator records the result and can re-review.\n\n"
2232
+ )
2233
+ else:
2234
+ report_reminder = (
2235
+ f"[Coordinator fix assignment {assignment_id}] HUMAN-ATTENDED "
2236
+ f"fix iteration {next_iteration}/{max_iter} on branch "
2237
+ f"{work.branch}, running on a REMOTE machine. Make your "
2238
+ "changes and COMMIT them. Before you exit, run `coord "
2239
+ f"report-result --assignment {assignment_id} --status done "
2240
+ "--summary <text>` — since #590 it routes to the "
2241
+ "coordinator's shared board, so the result is recorded from "
2242
+ "here. Do NOT run any `gh` commands. When you exit, the "
2243
+ f"coordinator also pushes your commits to origin/{work.branch} "
2244
+ "and re-reviews.\n\n"
2245
+ )
2246
+ effective_briefing = _issue_ctx + report_reminder + fix_briefing + _ctx_write_hint
2247
+
2248
+ spec = _AssignmentSpecFx(
2249
+ repo_name=repo,
2250
+ repo_path=fix_repo_path,
2251
+ issue_number=issue,
2252
+ issue_title=f"[fix-{next_iteration}] {issue_title}",
2253
+ briefing=effective_briefing,
2254
+ model=resolved_model,
2255
+ type="work",
2256
+ provider="claude-pty",
2257
+ )
2258
+ # type="work" ⇒ default worker tool set (Read/Edit/Write/Bash): the
2259
+ # fix session must be able to mutate the checkout, unlike --review-of.
2260
+ argv = provider.build_command(spec, resolved_model=resolved_model)
2261
+ # Remote: bare "claude" isn't on the SSH login PATH (#424/#425).
2262
+ if not _is_local:
2263
+ argv = ["~/.local/bin/claude"] + list(argv)[1:]
2264
+
2265
+ _fx_location = (
2266
+ "local TTY" if _is_local
2267
+ else f"{machine_obj.host} (remote tmux)"
2268
+ )
2269
+ click.echo(
2270
+ f"{machine} ({_fx_location}) → FIX of #{issue} "
2271
+ f"(iteration {next_iteration}/{max_iter}) on branch {work.branch}"
2272
+ )
2273
+ click.echo(
2274
+ " mode: HUMAN-ATTENDED interactive fix "
2275
+ "(migration leg 3 / Track B #486)"
2276
+ )
2277
+ # #803: show the escalated model so an opus escalation is visible.
2278
+ _explicit = "(explicit --model override)" if model else "(auto-selected)"
2279
+ click.echo(f" model: {resolved_model} {_explicit}")
2280
+ click.echo(
2281
+ f" assignment id: {assignment_id} (fix_of={fix_of}, "
2282
+ f"work={work.assignment_id})"
2283
+ )
2284
+ if dry_run:
2285
+ click.echo(" (dry run — not launched)")
2286
+ click.echo(f" would continue branch: {work.branch}")
2287
+ if not _is_local:
2288
+ click.echo(
2289
+ f" remote worktree: $HOME/.coord/worktrees/{assignment_id}"
2290
+ f" on {machine_obj.host} (branch: {work.branch})"
2291
+ )
2292
+ click.echo(f" would exec: {argv}")
2293
+ return
2294
+
2295
+ fix_assignment = _AssignmentFx(
2296
+ machine_name=machine,
2297
+ repo_name=repo,
2298
+ issue_number=issue,
2299
+ issue_title=f"[fix-{next_iteration}] {issue_title}",
2300
+ briefing=effective_briefing,
2301
+ assignment_id=assignment_id,
2302
+ status="running",
2303
+ branch=work.branch,
2304
+ pr_url=work.pr_url,
2305
+ dispatched_at=_time.time(),
2306
+ type="work",
2307
+ review_of_assignment_id=work.assignment_id,
2308
+ review_iteration=next_iteration,
2309
+ model=resolved_model,
2310
+ provider_name="claude-pty",
2311
+ )
2312
+ _record_fx(assignment=fix_assignment, repo_github=repo_cfg.github)
2313
+ if _svc is None:
2314
+ _save_board_fx(_build_board_fx())
2315
+
2316
+ if _is_local:
2317
+ try:
2318
+ _wt_path, _ = _setup_wt_fx(
2319
+ Path(fix_repo_path),
2320
+ issue_number=issue,
2321
+ issue_title=issue_title,
2322
+ assignment_id=assignment_id,
2323
+ default_branch=fix_default_branch,
2324
+ existing_branch=work.branch,
2325
+ )
2326
+ worktree_path = str(_wt_path)
2327
+ except (_AgentGitErrorFx, OSError) as _wt_err:
2328
+ _reason_fx = (
2329
+ f"worktree-add failed for branch {work.branch}: {_wt_err}"
2330
+ )
2331
+ click.echo(f" error: {_reason_fx}", err=True)
2332
+ # #618: persist reason + mark terminal immediately so the
2333
+ # TUI shows WHY the box is red (no log file on this path).
2334
+ _set_fail_reason_fx(assignment_id, _reason_fx)
2335
+ sys.exit(1)
2336
+ click.echo(f" worktree: {worktree_path} (branch: {work.branch})")
2337
+
2338
+ started_at = _time.time()
2339
+ exit_code = launch_human_attended_interactive(
2340
+ argv,
2341
+ effective_briefing,
2342
+ assignment_id=assignment_id,
2343
+ cwd=worktree_path,
2344
+ )
2345
+ if exit_code != 0:
2346
+ click.echo(f" claude exited with status {exit_code}", err=True)
2347
+
2348
+ _sname = _tmux_name(assignment_id) if _tmux_avail() else None
2349
+ if _sname and _tmux_alive(_sname):
2350
+ click.echo(
2351
+ f" session still running in tmux: {_sname}\n"
2352
+ f" reattach with: coord reattach {assignment_id}"
2353
+ )
2354
+ sys.exit(0)
2355
+
2356
+ # #982: narrow the stash to only the examples named in the work
2357
+ # assignment's smoke tests (if any). Falls back to the repo-wide
2358
+ # glob when no smoke tests were captured or nothing matched.
2359
+ _aps_fx = _narrow_ap_fx(
2360
+ list(repo_cfg.artifact_paths),
2361
+ getattr(work, "smoke_tests", None),
2362
+ )
2363
+ try:
2364
+ finalize_result = finalize_interactive_exit(
2365
+ assignment_id=assignment_id,
2366
+ repo_name=repo,
2367
+ repo_github=repo_cfg.github,
2368
+ issue_number=issue,
2369
+ machine_name=machine,
2370
+ worktree_path=worktree_path,
2371
+ base_branch=fix_default_branch,
2372
+ exit_code=exit_code,
2373
+ started_at=started_at,
2374
+ log_path=None,
2375
+ repo_path=fix_repo_path,
2376
+ artifact_paths=_aps_fx,
2377
+ branch=work.branch,
2378
+ )
2379
+ if finalize_result.already_recorded:
2380
+ click.echo(
2381
+ " result recorded via `coord report-result`; backstop "
2382
+ "did not overwrite"
2383
+ )
2384
+ else:
2385
+ click.echo(
2386
+ f" backstop: status={finalize_result.terminal_status} "
2387
+ f"commits_ahead={finalize_result.commits_ahead}"
2388
+ )
2389
+ if not finalize_result.push_ok:
2390
+ click.echo(
2391
+ f" warning: git push failed: {finalize_result.push_error}",
2392
+ err=True,
2393
+ )
2394
+ except Exception as exc: # noqa: BLE001 — best-effort backstop
2395
+ click.echo(
2396
+ f" warning: backstop failed to record fix exit: {exc}",
2397
+ err=True,
2398
+ )
2399
+ sys.exit(exit_code)
2400
+
2401
+ # ── REMOTE FIX (Track B / #486) ───────────────────────────────
2402
+ # A remote worktree on the EXISTING branch (`-B <branch>
2403
+ # origin/<branch>` resets a dedicated local branch to the reviewed
2404
+ # work's branch); the session commits there; on exit the
2405
+ # coordinator pushes the commits back to origin + records the
2406
+ # completion (#486d) so the re-review fires.
2407
+ import shlex as _shlex_fx # noqa: PLC0415
2408
+
2409
+ _remote_wt = "$HOME/.coord/worktrees/" + assignment_id
2410
+ _rp_sh = (
2411
+ "$HOME/" + fix_repo_path[2:]
2412
+ if fix_repo_path.startswith("~/")
2413
+ else ("$HOME" if fix_repo_path == "~" else fix_repo_path)
2414
+ )
2415
+ _claude_args = _shlex_fx.join(list(argv)[1:])
2416
+ _br_q = _shlex_fx.quote(work.branch)
2417
+ _orig_ref = _shlex_fx.quote(f"origin/{work.branch}")
2418
+ _remote_cmd = (
2419
+ f"mkdir -p $HOME/.coord/worktrees"
2420
+ f" && cd {_rp_sh}"
2421
+ f" && git fetch origin --prune 2>/dev/null || true"
2422
+ f" && git worktree prune 2>/dev/null || true"
2423
+ f" && git worktree add -B {_br_q} {_remote_wt} {_orig_ref}"
2424
+ f" && cd {_remote_wt}"
2425
+ f" && COORD_ASSIGNMENT_ID={assignment_id} {argv[0]} {_claude_args}"
2426
+ )
2427
+ _tmux_host = TmuxHost(ssh_target=machine_obj.host)
2428
+ _sname = _tmux_name(assignment_id)
2429
+ click.echo(
2430
+ f" remote worktree: $HOME/.coord/worktrees/{assignment_id}"
2431
+ f" on {machine_obj.host} (branch: {work.branch})"
2432
+ )
2433
+
2434
+ if effective_briefing.strip():
2435
+ _hdr = (
2436
+ "--- seeded briefing -- review below; "
2437
+ "submit the pre-filled input in Claude to send ---"
2438
+ )
2439
+ _ftr = "-" * len(_hdr)
2440
+ _preview = f"\n{_hdr}\n{effective_briefing.rstrip()}\n{_ftr}\n\n"
2441
+ try:
2442
+ os.write(sys.stdout.fileno(), _preview.encode("utf-8"))
2443
+ except OSError:
2444
+ pass
2445
+
2446
+ started_at = _time.time()
2447
+ _rc = _tmux_launch(
2448
+ argv,
2449
+ effective_briefing,
2450
+ _sname,
2451
+ cwd=None,
2452
+ host=_tmux_host,
2453
+ raw_shell_cmd=_remote_cmd,
2454
+ )
2455
+ if _rc is None:
2456
+ click.echo(
2457
+ " error: could not create remote tmux session on "
2458
+ f"{machine_obj.host}",
2459
+ err=True,
2460
+ )
2461
+ sys.exit(1)
2462
+ exit_code = _rc
2463
+
2464
+ if _tmux_alive(_sname, host=_tmux_host):
2465
+ click.echo(
2466
+ f" session still running in remote tmux: {_sname}\n"
2467
+ f" reattach with: ssh -t {machine_obj.host}"
2468
+ f" tmux attach-session -t {_sname}\n"
2469
+ " (the fix is not pushed until the session ends and the "
2470
+ "coordinator finalizes)"
2471
+ )
2472
+ sys.exit(0)
2473
+
2474
+ # ── #560: detect setup failures (worktree never created) ──────
2475
+ # If the session exited non-zero AND the remote worktree directory
2476
+ # was never created, the failure is a setup error — most commonly
2477
+ # git worktree add refused because the branch is already checked
2478
+ # out in another worktree (a leftover interactive session or
2479
+ # orphaned non-interactive worktree). Print an actionable message
2480
+ # and skip the misleading "commits preserved" noise. The finalize
2481
+ # call below still records the failure on the board so the pipeline
2482
+ # shows "failed" rather than leaving the row as "running".
2483
+ _wt_setup_ok: bool = True
2484
+ if exit_code != 0:
2485
+ if not _remote_wt_exists(machine_obj.host, _remote_wt):
2486
+ _wt_setup_ok = False
2487
+ _holder = _find_branch_holder(
2488
+ machine_obj.host, _rp_sh, work.branch,
2489
+ )
2490
+ if _holder:
2491
+ _holder_aid = Path(_holder).name
2492
+ _holder_sname = f"coord-{_holder_aid}"
2493
+ _holder_live = _tmux_alive(_holder_sname, host=_tmux_host)
2494
+ _err_header = (
2495
+ f" error: setup failed — branch {work.branch!r} is "
2496
+ f"already checked out at {_holder} on {machine_obj.host}."
2497
+ )
2498
+ if _holder_live:
2499
+ click.echo("\n".join([
2500
+ _err_header,
2501
+ f" active tmux session: {_holder_sname}",
2502
+ f" reattach: coord reattach {_holder_aid}",
2503
+ " exit the session first, then retry this fix.",
2504
+ ]), err=True)
2505
+ else:
2506
+ # Dead holder — detect base checkout vs stale orphan (#814).
2507
+ if _holder_is_base(_holder):
2508
+ # #814: the base checkout ~/src/<repo> is on the
2509
+ # issue branch. NEVER prune the base (#561) —
2510
+ # checkout the default branch to free the lock.
2511
+ click.echo(
2512
+ f" base checkout {_holder!r} is on branch"
2513
+ f" {work.branch!r} on {machine_obj.host}"
2514
+ f" — checking out {fix_default_branch!r}"
2515
+ f" to free it …"
2516
+ )
2517
+ _freed = _remote_free_base(
2518
+ machine_obj.host, _rp_sh, fix_default_branch,
2519
+ )
2520
+ if _freed:
2521
+ click.echo(
2522
+ " base checkout freed — retrying launch …"
2523
+ )
2524
+ _rc2 = _tmux_launch(
2525
+ argv, effective_briefing, _sname,
2526
+ cwd=None, host=_tmux_host,
2527
+ raw_shell_cmd=_remote_cmd,
2528
+ )
2529
+ if _rc2 is None:
2530
+ click.echo(
2531
+ f" error: could not create remote tmux"
2532
+ f" session on {machine_obj.host}"
2533
+ f" (retry after base-checkout free)",
2534
+ err=True,
2535
+ )
2536
+ else:
2537
+ exit_code = _rc2
2538
+ if _tmux_alive(_sname, host=_tmux_host):
2539
+ click.echo(
2540
+ f" session still running in remote"
2541
+ f" tmux: {_sname}\n"
2542
+ f" reattach with: ssh -t"
2543
+ f" {machine_obj.host}"
2544
+ f" tmux attach-session -t {_sname}\n"
2545
+ " (the fix is not pushed until the"
2546
+ " session ends and the coordinator"
2547
+ " finalizes)"
2548
+ )
2549
+ sys.exit(0)
2550
+ if (
2551
+ exit_code == 0
2552
+ or _remote_wt_exists(
2553
+ machine_obj.host, _remote_wt
2554
+ )
2555
+ ):
2556
+ _wt_setup_ok = True # retry succeeded
2557
+ else:
2558
+ click.echo("\n".join([
2559
+ _err_header,
2560
+ f" base checkout is stuck on"
2561
+ f" {work.branch!r} —",
2562
+ f" free it manually with:",
2563
+ f" ssh {machine_obj.host}"
2564
+ f" 'git -C {_rp_sh} checkout"
2565
+ f" {fix_default_branch}'",
2566
+ ]), err=True)
2567
+ else:
2568
+ # Dead orphan — safety-gated auto-prune-and-retry (#759).
2569
+ _auto_pruned = False
2570
+ if _remote_orphan_safe(
2571
+ machine_obj.host, _rp_sh, _holder, work.branch,
2572
+ ):
2573
+ click.echo(
2574
+ f" auto-pruning stale orphan {_holder}"
2575
+ f" on {machine_obj.host} …"
2576
+ )
2577
+ _auto_pruned = _remote_wt_remove(
2578
+ machine_obj.host, _rp_sh, _holder,
2579
+ )
2580
+ if _auto_pruned:
2581
+ click.echo(
2582
+ " stale orphan auto-pruned — retrying launch …"
2583
+ )
2584
+ _rc2 = _tmux_launch(
2585
+ argv, effective_briefing, _sname,
2586
+ cwd=None, host=_tmux_host,
2587
+ raw_shell_cmd=_remote_cmd,
2588
+ )
2589
+ if _rc2 is None:
2590
+ click.echo(
2591
+ f" error: could not create remote tmux"
2592
+ f" session on {machine_obj.host}"
2593
+ f" (retry after prune)",
2594
+ err=True,
2595
+ )
2596
+ else:
2597
+ exit_code = _rc2
2598
+ if _tmux_alive(_sname, host=_tmux_host):
2599
+ click.echo(
2600
+ f" session still running in remote"
2601
+ f" tmux: {_sname}\n"
2602
+ f" reattach with: ssh -t"
2603
+ f" {machine_obj.host}"
2604
+ f" tmux attach-session -t {_sname}\n"
2605
+ " (the fix is not pushed until the"
2606
+ " session ends and the coordinator"
2607
+ " finalizes)"
2608
+ )
2609
+ sys.exit(0)
2610
+ if (
2611
+ exit_code == 0
2612
+ or _remote_wt_exists(
2613
+ machine_obj.host, _remote_wt
2614
+ )
2615
+ ):
2616
+ _wt_setup_ok = True # retry succeeded
2617
+ else:
2618
+ # Not safe or prune failed → manual command.
2619
+ click.echo("\n".join([
2620
+ _err_header,
2621
+ " stale worktree — prune it first:",
2622
+ f" ssh {machine_obj.host} 'cd {_rp_sh}"
2623
+ f" && git worktree remove --force"
2624
+ f" {_shlex_fx.quote(_holder)}'",
2625
+ ]), err=True)
2626
+ else:
2627
+ click.echo(
2628
+ f" error: setup failed — the remote worktree was "
2629
+ f"never created (git worktree add refused on "
2630
+ f"{machine_obj.host}).",
2631
+ err=True,
2632
+ )
2633
+ else:
2634
+ click.echo(
2635
+ f" claude exited with status {exit_code}", err=True,
2636
+ )
2637
+
2638
+ # Remote finalize (#486d): push the fix commits to origin/<branch>,
2639
+ # record the completion, and clean up the remote worktree. Safe
2640
+ # even when the worktree was never created — _remote_push_and_count
2641
+ # detects the missing directory (exit 91) and marks push_ok=False.
2642
+ #
2643
+ # #982: narrow the stash to only the examples named in the work
2644
+ # assignment's smoke tests (if any).
2645
+ _aps_fx_remote = _narrow_ap_fx(
2646
+ list(repo_cfg.artifact_paths),
2647
+ getattr(work, "smoke_tests", None),
2648
+ )
2649
+ try:
2650
+ _fr = finalize_remote_interactive_exit(
2651
+ assignment_id=assignment_id,
2652
+ repo_name=repo,
2653
+ repo_github=repo_cfg.github,
2654
+ issue_number=issue,
2655
+ machine_name=machine,
2656
+ ssh_target=machine_obj.host,
2657
+ remote_worktree_sh=_remote_wt,
2658
+ remote_repo_sh=_rp_sh,
2659
+ branch=work.branch,
2660
+ base_branch=fix_default_branch,
2661
+ exit_code=exit_code,
2662
+ started_at=started_at,
2663
+ artifact_paths=_aps_fx_remote,
2664
+ )
2665
+ if _fr.already_recorded:
2666
+ click.echo(
2667
+ " result recorded via `coord report-result`; remote "
2668
+ "backstop did not overwrite"
2669
+ )
2670
+ else:
2671
+ click.echo(
2672
+ f" remote backstop: status={_fr.terminal_status} "
2673
+ f"commits_ahead={_fr.commits_ahead} pushed={_fr.push_ok}"
2674
+ )
2675
+ if not _fr.push_ok and _wt_setup_ok:
2676
+ # Worker ran but push failed — commits ARE preserved in
2677
+ # the worktree (which was successfully created).
2678
+ click.echo(
2679
+ f" warning: remote push failed: {_fr.push_error}",
2680
+ err=True,
2681
+ )
2682
+ click.echo(
2683
+ f" fix commits preserved in {_remote_wt} on "
2684
+ f"{machine_obj.host} (worktree NOT removed)",
2685
+ err=True,
2686
+ )
2687
+ # else _wt_setup_ok is False: setup never happened; error
2688
+ # already printed above — no "commits preserved" noise.
2689
+ _echo_artifact_stash(_fr)
2690
+ except Exception as exc: # noqa: BLE001 — best-effort backstop
2691
+ click.echo(
2692
+ f" warning: remote backstop failed to record fix exit: {exc}",
2693
+ err=True,
2694
+ )
2695
+ sys.exit(exit_code)
2696
+
2697
+
2698
+ def _dispatch_rework_of(
2699
+ *,
2700
+ machine: str,
2701
+ repo: str,
2702
+ issue: int,
2703
+ briefing: str,
2704
+ model: str | None,
2705
+ dry_run: bool,
2706
+ force: bool,
2707
+ rework_of: str,
2708
+ cfg: Config,
2709
+ machine_obj: object,
2710
+ repo_cfg: object,
2711
+ issue_title: str,
2712
+ provider: object,
2713
+ _is_local: bool,
2714
+ _svc: object,
2715
+ _interactive_board: object,
2716
+ _issue_ctx: str,
2717
+ ) -> None:
2718
+ import time as _time # noqa: PLC0415
2719
+ import uuid as _uuid # noqa: PLC0415
2720
+
2721
+ from coord.interactive import ( # noqa: PLC0415
2722
+ TmuxHost,
2723
+ _holder_is_base_checkout as _holder_is_base,
2724
+ _launch_via_tmux as _tmux_launch,
2725
+ _remote_base_checkout_free_branch as _remote_free_base,
2726
+ _remote_orphan_is_safe_to_prune as _remote_orphan_safe,
2727
+ _remote_worktree_remove as _remote_wt_remove,
2728
+ find_remote_branch_holder as _find_branch_holder,
2729
+ finalize_interactive_exit,
2730
+ finalize_remote_interactive_exit,
2731
+ launch_human_attended_interactive,
2732
+ remote_worktree_exists as _remote_wt_exists,
2733
+ tmux_available as _tmux_avail,
2734
+ tmux_session_alive as _tmux_alive,
2735
+ tmux_session_name as _tmux_name,
2736
+ )
2737
+
2738
+ from coord.agent import ( # noqa: PLC0415
2739
+ AssignmentSpec as _AssignmentSpecRw,
2740
+ _GitError as _AgentGitErrorRw,
2741
+ narrow_artifact_paths as _narrow_ap_rw,
2742
+ setup_interactive_worktree as _setup_wt_rw,
2743
+ )
2744
+ from coord.models import Assignment as _AssignmentRw # noqa: PLC0415
2745
+ from coord.state import ( # noqa: PLC0415
2746
+ COORD_DIR as _COORD_DIR_RW,
2747
+ build_board as _build_board_rw,
2748
+ record_dispatched_assignment as _record_rw,
2749
+ save_board as _save_board_rw,
2750
+ set_assignment_failure_reason as _set_fail_reason_rw,
2751
+ )
2752
+
2753
+ # Resolve branch: try to find a work assignment by ID first, then
2754
+ # fall back to treating the argument as a literal branch name.
2755
+ _rw_board = _interactive_board(_build_board_rw)
2756
+ _rw_work = _rw_board.find_by_id(rework_of)
2757
+ if _rw_work is not None:
2758
+ if not _rw_work.branch:
2759
+ click.echo(
2760
+ f"error: work assignment {rework_of} has no branch.",
2761
+ err=True,
2762
+ )
2763
+ sys.exit(2)
2764
+ rw_branch = _rw_work.branch
2765
+ next_rw_iteration = (_rw_work.review_iteration or 0) + 1
2766
+ rw_work_id: str | None = _rw_work.assignment_id
2767
+ else:
2768
+ # Treat the argument as a branch name — useful when the
2769
+ # original assignment has aged off the board.
2770
+ rw_branch = rework_of
2771
+ # Look for any completed work on that branch to inherit
2772
+ # the iteration counter; default to 1 if none found.
2773
+ _branch_work = next(
2774
+ (
2775
+ a for a in _rw_board.completed
2776
+ if a.branch == rw_branch and a.type in ("work", "plan")
2777
+ ),
2778
+ None,
2779
+ )
2780
+ next_rw_iteration = (
2781
+ (_branch_work.review_iteration or 0) + 1
2782
+ if _branch_work is not None
2783
+ else 1
2784
+ )
2785
+ rw_work_id = (
2786
+ _branch_work.assignment_id if _branch_work is not None else None
2787
+ )
2788
+
2789
+ resolved_model = model if model else cfg.models.default
2790
+ assignment_id = _uuid.uuid4().hex[:12]
2791
+ if _is_local:
2792
+ rw_repo_path = str(
2793
+ Path(machine_obj.repo_path(repo) or str(Path.cwd())).expanduser()
2794
+ )
2795
+ else:
2796
+ rw_repo_path = machine_obj.repo_path(repo) or f"~/src/{repo}"
2797
+ rw_default_branch = repo_cfg.default_branch or "main"
2798
+
2799
+ os.environ["COORD_ASSIGNMENT_ID"] = assignment_id
2800
+ if _is_local:
2801
+ rw_report_reminder = (
2802
+ f"[Coordinator rework assignment {assignment_id}] "
2803
+ f"HUMAN-ATTENDED rework (iteration {next_rw_iteration}) "
2804
+ f"on branch {rw_branch}. Before you exit, run "
2805
+ f"`coord report-result --assignment {assignment_id} "
2806
+ "--status done --summary <text>` so the coordinator "
2807
+ "records the result and can re-review.\n\n"
2808
+ )
2809
+ else:
2810
+ rw_report_reminder = (
2811
+ f"[Coordinator rework assignment {assignment_id}] "
2812
+ f"HUMAN-ATTENDED rework (iteration {next_rw_iteration}) "
2813
+ f"on branch {rw_branch}, running on a REMOTE machine. "
2814
+ "Make your changes and COMMIT them. Before you exit, run "
2815
+ f"`coord report-result --assignment {assignment_id} "
2816
+ "--status done --summary <text>` — since #590 it routes to "
2817
+ "the coordinator's shared board, so the result is recorded "
2818
+ "from here. Do NOT run any `gh` commands. When you exit, the "
2819
+ f"coordinator also pushes your commits to origin/{rw_branch} "
2820
+ "and re-reviews.\n\n"
2821
+ )
2822
+ effective_briefing = _issue_ctx + rw_report_reminder + briefing
2823
+
2824
+ spec = _AssignmentSpecRw(
2825
+ repo_name=repo,
2826
+ repo_path=rw_repo_path,
2827
+ issue_number=issue,
2828
+ issue_title=f"[rework-{next_rw_iteration}] {issue_title}",
2829
+ briefing=effective_briefing,
2830
+ model=resolved_model,
2831
+ type="work",
2832
+ provider="claude-pty",
2833
+ )
2834
+ argv = provider.build_command(spec, resolved_model=resolved_model)
2835
+ if not _is_local:
2836
+ argv = ["~/.local/bin/claude"] + list(argv)[1:]
2837
+
2838
+ _rw_location = (
2839
+ "local TTY" if _is_local
2840
+ else f"{machine_obj.host} (remote tmux)"
2841
+ )
2842
+ _rw_max_iter = cfg.pipeline.max_review_iterations
2843
+ click.echo(
2844
+ f"{machine} ({_rw_location}) → REWORK of #{issue} "
2845
+ f"(iteration {next_rw_iteration}/{_rw_max_iter}) on branch {rw_branch}"
2846
+ )
2847
+ click.echo(
2848
+ " mode: HUMAN-ATTENDED interactive rework (#563)"
2849
+ )
2850
+ click.echo(
2851
+ f" assignment id: {assignment_id} "
2852
+ f"(rework_of={rework_of!r}, branch={rw_branch})"
2853
+ )
2854
+ if dry_run:
2855
+ click.echo(" (dry run — not launched)")
2856
+ click.echo(f" would continue branch: {rw_branch}")
2857
+ if not _is_local:
2858
+ click.echo(
2859
+ f" remote worktree: $HOME/.coord/worktrees/{assignment_id}"
2860
+ f" on {machine_obj.host} (branch: {rw_branch})"
2861
+ )
2862
+ click.echo(f" would exec: {argv}")
2863
+ return
2864
+
2865
+ rw_assignment = _AssignmentRw(
2866
+ machine_name=machine,
2867
+ repo_name=repo,
2868
+ issue_number=issue,
2869
+ issue_title=f"[rework-{next_rw_iteration}] {issue_title}",
2870
+ briefing=effective_briefing,
2871
+ assignment_id=assignment_id,
2872
+ status="running",
2873
+ branch=rw_branch,
2874
+ pr_url=_rw_work.pr_url if _rw_work is not None else None,
2875
+ dispatched_at=_time.time(),
2876
+ type="work",
2877
+ review_of_assignment_id=rw_work_id,
2878
+ review_iteration=next_rw_iteration,
2879
+ model=resolved_model,
2880
+ provider_name="claude-pty",
2881
+ )
2882
+ _record_rw(assignment=rw_assignment, repo_github=repo_cfg.github)
2883
+ if _svc is None:
2884
+ _save_board_rw(_build_board_rw())
2885
+
2886
+ if _is_local:
2887
+ try:
2888
+ _wt_path, _ = _setup_wt_rw(
2889
+ Path(rw_repo_path),
2890
+ issue_number=issue,
2891
+ issue_title=issue_title,
2892
+ assignment_id=assignment_id,
2893
+ default_branch=rw_default_branch,
2894
+ existing_branch=rw_branch,
2895
+ )
2896
+ worktree_path = str(_wt_path)
2897
+ except (_AgentGitErrorRw, OSError) as _wt_err:
2898
+ _reason_rw = (
2899
+ f"worktree-add failed for branch {rw_branch}: {_wt_err}"
2900
+ )
2901
+ click.echo(f" error: {_reason_rw}", err=True)
2902
+ # #618: persist reason + mark terminal immediately so the
2903
+ # TUI shows WHY the box is red (no log file on this path).
2904
+ _set_fail_reason_rw(assignment_id, _reason_rw)
2905
+ sys.exit(1)
2906
+ click.echo(f" worktree: {worktree_path} (branch: {rw_branch})")
2907
+
2908
+ started_at = _time.time()
2909
+ exit_code = launch_human_attended_interactive(
2910
+ argv,
2911
+ effective_briefing,
2912
+ assignment_id=assignment_id,
2913
+ cwd=worktree_path,
2914
+ )
2915
+ if exit_code != 0:
2916
+ click.echo(f" claude exited with status {exit_code}", err=True)
2917
+
2918
+ _sname = _tmux_name(assignment_id) if _tmux_avail() else None
2919
+ if _sname and _tmux_alive(_sname):
2920
+ click.echo(
2921
+ f" session still running in tmux: {_sname}\n"
2922
+ f" reattach with: coord reattach {assignment_id}"
2923
+ )
2924
+ sys.exit(0)
2925
+
2926
+ # #982: narrow the stash to only the examples named in the
2927
+ # rework-target assignment's smoke tests (if any).
2928
+ _aps_rw = _narrow_ap_rw(
2929
+ list(repo_cfg.artifact_paths),
2930
+ getattr(_rw_work, "smoke_tests", None) if _rw_work is not None else None,
2931
+ )
2932
+ try:
2933
+ finalize_result = finalize_interactive_exit(
2934
+ assignment_id=assignment_id,
2935
+ repo_name=repo,
2936
+ repo_github=repo_cfg.github,
2937
+ issue_number=issue,
2938
+ machine_name=machine,
2939
+ worktree_path=worktree_path,
2940
+ base_branch=rw_default_branch,
2941
+ exit_code=exit_code,
2942
+ started_at=started_at,
2943
+ log_path=None,
2944
+ repo_path=rw_repo_path,
2945
+ artifact_paths=_aps_rw,
2946
+ branch=rw_branch,
2947
+ )
2948
+ if finalize_result.already_recorded:
2949
+ click.echo(
2950
+ " result recorded via `coord report-result`; backstop "
2951
+ "did not overwrite"
2952
+ )
2953
+ else:
2954
+ click.echo(
2955
+ f" backstop: status={finalize_result.terminal_status} "
2956
+ f"commits_ahead={finalize_result.commits_ahead}"
2957
+ )
2958
+ if not finalize_result.push_ok:
2959
+ click.echo(
2960
+ f" warning: git push failed: {finalize_result.push_error}",
2961
+ err=True,
2962
+ )
2963
+ except Exception as exc: # noqa: BLE001 — best-effort backstop
2964
+ click.echo(
2965
+ f" warning: backstop failed to record rework exit: {exc}",
2966
+ err=True,
2967
+ )
2968
+ sys.exit(exit_code)
2969
+
2970
+ # ── REMOTE REWORK ─────────────────────────────────────────────
2971
+ import shlex as _shlex_rw # noqa: PLC0415
2972
+
2973
+ _remote_wt = "$HOME/.coord/worktrees/" + assignment_id
2974
+ _rp_sh = (
2975
+ "$HOME/" + rw_repo_path[2:]
2976
+ if rw_repo_path.startswith("~/")
2977
+ else ("$HOME" if rw_repo_path == "~" else rw_repo_path)
2978
+ )
2979
+ _claude_args = _shlex_rw.join(list(argv)[1:])
2980
+ _br_q = _shlex_rw.quote(rw_branch)
2981
+ _orig_ref = _shlex_rw.quote(f"origin/{rw_branch}")
2982
+ _remote_cmd = (
2983
+ f"mkdir -p $HOME/.coord/worktrees"
2984
+ f" && cd {_rp_sh}"
2985
+ f" && git fetch origin --prune 2>/dev/null || true"
2986
+ f" && git worktree prune 2>/dev/null || true"
2987
+ f" && git worktree add -B {_br_q} {_remote_wt} {_orig_ref}"
2988
+ f" && cd {_remote_wt}"
2989
+ f" && COORD_ASSIGNMENT_ID={assignment_id} {argv[0]} {_claude_args}"
2990
+ )
2991
+ _tmux_host = TmuxHost(ssh_target=machine_obj.host)
2992
+ _sname = _tmux_name(assignment_id)
2993
+ click.echo(
2994
+ f" remote worktree: $HOME/.coord/worktrees/{assignment_id}"
2995
+ f" on {machine_obj.host} (branch: {rw_branch})"
2996
+ )
2997
+
2998
+ if effective_briefing.strip():
2999
+ _hdr = (
3000
+ "--- seeded briefing -- review below; "
3001
+ "submit the pre-filled input in Claude to send ---"
3002
+ )
3003
+ _ftr = "-" * len(_hdr)
3004
+ _preview = f"\n{_hdr}\n{effective_briefing.rstrip()}\n{_ftr}\n\n"
3005
+ try:
3006
+ os.write(sys.stdout.fileno(), _preview.encode("utf-8"))
3007
+ except OSError:
3008
+ pass
3009
+
3010
+ started_at = _time.time()
3011
+ _rc = _tmux_launch(
3012
+ argv,
3013
+ effective_briefing,
3014
+ _sname,
3015
+ cwd=None,
3016
+ host=_tmux_host,
3017
+ raw_shell_cmd=_remote_cmd,
3018
+ )
3019
+ if _rc is None:
3020
+ click.echo(
3021
+ " error: could not create remote tmux session on "
3022
+ f"{machine_obj.host}",
3023
+ err=True,
3024
+ )
3025
+ sys.exit(1)
3026
+ exit_code = _rc
3027
+
3028
+ if _tmux_alive(_sname, host=_tmux_host):
3029
+ click.echo(
3030
+ f" session still running in remote tmux: {_sname}\n"
3031
+ f" reattach with: ssh -t {machine_obj.host}"
3032
+ f" tmux attach-session -t {_sname}\n"
3033
+ " (the rework is not pushed until the session ends and "
3034
+ "the coordinator finalizes)"
3035
+ )
3036
+ sys.exit(0)
3037
+
3038
+ # ── #560: detect setup failures (worktree never created) ──────
3039
+ _wt_setup_ok_rw: bool = True
3040
+ if exit_code != 0:
3041
+ if not _remote_wt_exists(machine_obj.host, _remote_wt):
3042
+ _wt_setup_ok_rw = False
3043
+ _holder = _find_branch_holder(
3044
+ machine_obj.host, _rp_sh, rw_branch,
3045
+ )
3046
+ if _holder:
3047
+ _holder_aid = Path(_holder).name
3048
+ _holder_sname = f"coord-{_holder_aid}"
3049
+ _holder_live = _tmux_alive(_holder_sname, host=_tmux_host)
3050
+ _err_header = (
3051
+ f" error: setup failed — branch {rw_branch!r} is "
3052
+ f"already checked out at {_holder} on {machine_obj.host}."
3053
+ )
3054
+ if _holder_live:
3055
+ click.echo("\n".join([
3056
+ _err_header,
3057
+ f" active tmux session: {_holder_sname}",
3058
+ f" reattach: coord reattach {_holder_aid}",
3059
+ " exit the session first, then retry this rework.",
3060
+ ]), err=True)
3061
+ else:
3062
+ # Dead holder — detect base checkout vs stale orphan (#814).
3063
+ if _holder_is_base(_holder):
3064
+ # #814: the base checkout ~/src/<repo> is on the
3065
+ # rework branch. NEVER prune the base (#561) —
3066
+ # checkout the default branch to free the lock.
3067
+ click.echo(
3068
+ f" base checkout {_holder!r} is on branch"
3069
+ f" {rw_branch!r} on {machine_obj.host}"
3070
+ f" — checking out {rw_default_branch!r}"
3071
+ f" to free it …"
3072
+ )
3073
+ _freed = _remote_free_base(
3074
+ machine_obj.host, _rp_sh, rw_default_branch,
3075
+ )
3076
+ if _freed:
3077
+ click.echo(
3078
+ " base checkout freed — retrying launch …"
3079
+ )
3080
+ _rc2 = _tmux_launch(
3081
+ argv, effective_briefing, _sname,
3082
+ cwd=None, host=_tmux_host,
3083
+ raw_shell_cmd=_remote_cmd,
3084
+ )
3085
+ if _rc2 is None:
3086
+ click.echo(
3087
+ f" error: could not create remote tmux"
3088
+ f" session on {machine_obj.host}"
3089
+ f" (retry after base-checkout free)",
3090
+ err=True,
3091
+ )
3092
+ else:
3093
+ exit_code = _rc2
3094
+ if _tmux_alive(_sname, host=_tmux_host):
3095
+ click.echo(
3096
+ f" session still running in remote"
3097
+ f" tmux: {_sname}\n"
3098
+ f" reattach with: ssh -t"
3099
+ f" {machine_obj.host}"
3100
+ f" tmux attach-session -t {_sname}\n"
3101
+ " (the rework is not pushed until"
3102
+ " the session ends and the coordinator"
3103
+ " finalizes)"
3104
+ )
3105
+ sys.exit(0)
3106
+ if (
3107
+ exit_code == 0
3108
+ or _remote_wt_exists(
3109
+ machine_obj.host, _remote_wt
3110
+ )
3111
+ ):
3112
+ _wt_setup_ok_rw = True # retry succeeded
3113
+ else:
3114
+ click.echo("\n".join([
3115
+ _err_header,
3116
+ f" base checkout is stuck on"
3117
+ f" {rw_branch!r} —",
3118
+ f" free it manually with:",
3119
+ f" ssh {machine_obj.host}"
3120
+ f" 'git -C {_rp_sh} checkout"
3121
+ f" {rw_default_branch}'",
3122
+ ]), err=True)
3123
+ else:
3124
+ # Dead orphan — safety-gated auto-prune-and-retry (#759).
3125
+ _auto_pruned = False
3126
+ if _remote_orphan_safe(
3127
+ machine_obj.host, _rp_sh, _holder, rw_branch,
3128
+ ):
3129
+ click.echo(
3130
+ f" auto-pruning stale orphan {_holder}"
3131
+ f" on {machine_obj.host} …"
3132
+ )
3133
+ _auto_pruned = _remote_wt_remove(
3134
+ machine_obj.host, _rp_sh, _holder,
3135
+ )
3136
+ if _auto_pruned:
3137
+ click.echo(
3138
+ " stale orphan auto-pruned — retrying launch …"
3139
+ )
3140
+ _rc2 = _tmux_launch(
3141
+ argv, effective_briefing, _sname,
3142
+ cwd=None, host=_tmux_host,
3143
+ raw_shell_cmd=_remote_cmd,
3144
+ )
3145
+ if _rc2 is None:
3146
+ click.echo(
3147
+ f" error: could not create remote tmux"
3148
+ f" session on {machine_obj.host}"
3149
+ f" (retry after prune)",
3150
+ err=True,
3151
+ )
3152
+ else:
3153
+ exit_code = _rc2
3154
+ if _tmux_alive(_sname, host=_tmux_host):
3155
+ click.echo(
3156
+ f" session still running in remote"
3157
+ f" tmux: {_sname}\n"
3158
+ f" reattach with: ssh -t"
3159
+ f" {machine_obj.host}"
3160
+ f" tmux attach-session -t {_sname}\n"
3161
+ " (the rework is not pushed until"
3162
+ " the session ends and the coordinator"
3163
+ " finalizes)"
3164
+ )
3165
+ sys.exit(0)
3166
+ if (
3167
+ exit_code == 0
3168
+ or _remote_wt_exists(
3169
+ machine_obj.host, _remote_wt
3170
+ )
3171
+ ):
3172
+ _wt_setup_ok_rw = True # retry succeeded
3173
+ else:
3174
+ # Not safe or prune failed → manual command.
3175
+ click.echo("\n".join([
3176
+ _err_header,
3177
+ " stale worktree — prune it first:",
3178
+ f" ssh {machine_obj.host} 'cd {_rp_sh}"
3179
+ f" && git worktree remove --force"
3180
+ f" {_shlex_rw.quote(_holder)}'",
3181
+ ]), err=True)
3182
+ else:
3183
+ click.echo(
3184
+ f" error: setup failed — the remote worktree was "
3185
+ f"never created (git worktree add refused on "
3186
+ f"{machine_obj.host}).",
3187
+ err=True,
3188
+ )
3189
+ else:
3190
+ click.echo(
3191
+ f" claude exited with status {exit_code}", err=True,
3192
+ )
3193
+
3194
+ # Remote finalize: push the rework commits to origin/<branch>,
3195
+ # record the completion, and clean up the remote worktree.
3196
+ # #982: narrow the stash to the examples named in the rework-target's
3197
+ # smoke tests (if any).
3198
+ _aps_rw_remote = _narrow_ap_rw(
3199
+ list(repo_cfg.artifact_paths),
3200
+ getattr(_rw_work, "smoke_tests", None) if _rw_work is not None else None,
3201
+ )
3202
+ try:
3203
+ _fr = finalize_remote_interactive_exit(
3204
+ assignment_id=assignment_id,
3205
+ repo_name=repo,
3206
+ repo_github=repo_cfg.github,
3207
+ issue_number=issue,
3208
+ machine_name=machine,
3209
+ ssh_target=machine_obj.host,
3210
+ remote_worktree_sh=_remote_wt,
3211
+ remote_repo_sh=_rp_sh,
3212
+ branch=rw_branch,
3213
+ base_branch=rw_default_branch,
3214
+ exit_code=exit_code,
3215
+ started_at=started_at,
3216
+ artifact_paths=_aps_rw_remote,
3217
+ )
3218
+ if _fr.already_recorded:
3219
+ click.echo(
3220
+ " result recorded via `coord report-result`; remote "
3221
+ "backstop did not overwrite"
3222
+ )
3223
+ else:
3224
+ click.echo(
3225
+ f" remote backstop: status={_fr.terminal_status} "
3226
+ f"commits_ahead={_fr.commits_ahead} pushed={_fr.push_ok}"
3227
+ )
3228
+ if not _fr.push_ok and _wt_setup_ok_rw:
3229
+ click.echo(
3230
+ f" warning: remote push failed: {_fr.push_error}",
3231
+ err=True,
3232
+ )
3233
+ click.echo(
3234
+ f" rework commits preserved in {_remote_wt} on "
3235
+ f"{machine_obj.host} (worktree NOT removed)",
3236
+ err=True,
3237
+ )
3238
+ # else _wt_setup_ok_rw is False: setup never happened; error
3239
+ # already printed above — no "commits preserved" noise.
3240
+ _echo_artifact_stash(_fr)
3241
+ except Exception as exc: # noqa: BLE001 — best-effort backstop
3242
+ click.echo(
3243
+ f" warning: remote backstop failed to record rework exit: {exc}",
3244
+ err=True,
3245
+ )
3246
+ sys.exit(exit_code)
3247
+
3248
+
3249
+ def _dispatch_merge_of(
3250
+ *,
3251
+ machine: str,
3252
+ repo: str,
3253
+ issue: int,
3254
+ briefing: str,
3255
+ model: str | None,
3256
+ dry_run: bool,
3257
+ force: bool,
3258
+ merge_of: str,
3259
+ cfg: Config,
3260
+ machine_obj: object,
3261
+ repo_cfg: object,
3262
+ issue_title: str,
3263
+ provider: object,
3264
+ _is_local: bool,
3265
+ _svc: object,
3266
+ _interactive_board: object,
3267
+ _issue_ctx: str,
3268
+ ) -> None:
3269
+ """Leg 3c (#517, #306): human-attended MERGE-PREP for approved work.
3270
+
3271
+ #1007: runs on the LOCAL TTY or on a REMOTE machine over ssh+tmux,
3272
+ mirroring the local/remote split already in production for
3273
+ ``--review-of`` / ``--fix-of`` / ``--rework-of``. Like a fix, a merge
3274
+ WRITES (rebase + force-push) — it needs a worktree on the EXISTING
3275
+ branch; unlike a fix, the merge-prep agent itself runs `git push
3276
+ --force-with-lease` and `coord merge` as part of its own instructed
3277
+ flow, so the coordinator's remote finalize does not need a push-back
3278
+ step of its own (the #604 verify-merge gate is the piece that must run
3279
+ on whichever host holds the worktree).
3280
+ """
3281
+ import time as _time # noqa: PLC0415
3282
+ import uuid as _uuid # noqa: PLC0415
3283
+
3284
+ from coord.interactive import ( # noqa: PLC0415
3285
+ TmuxHost,
3286
+ _holder_is_base_checkout as _holder_is_base,
3287
+ _launch_via_tmux as _tmux_launch,
3288
+ _remote_base_checkout_free_branch as _remote_free_base,
3289
+ _remote_orphan_is_safe_to_prune as _remote_orphan_safe,
3290
+ _remote_worktree_remove as _remote_wt_remove,
3291
+ find_remote_branch_holder as _find_branch_holder,
3292
+ finalize_interactive_exit,
3293
+ finalize_remote_interactive_exit,
3294
+ launch_human_attended_interactive,
3295
+ remote_worktree_exists as _remote_wt_exists,
3296
+ tmux_available as _tmux_avail,
3297
+ tmux_session_alive as _tmux_alive,
3298
+ tmux_session_name as _tmux_name,
3299
+ )
3300
+
3301
+ from coord.agent import ( # noqa: PLC0415
3302
+ AssignmentSpec as _AssignmentSpecMg,
3303
+ _GitError as _AgentGitErrorMg,
3304
+ setup_interactive_worktree as _setup_wt_mg,
3305
+ )
3306
+ from coord.models import Assignment as _AssignmentMg # noqa: PLC0415
3307
+ from coord.state import ( # noqa: PLC0415
3308
+ build_board as _build_board_mg,
3309
+ record_dispatched_assignment as _record_mg,
3310
+ save_board as _save_board_mg,
3311
+ )
3312
+
3313
+ def _echo_merge_finalize(finalize_result: object, target_branch: str) -> None:
3314
+ # Forensics (#604): the worktree + reflog are gone by now, so this
3315
+ # echo is the only post-hoc record of what would merge.
3316
+ _mv = finalize_result.merge_verify
3317
+ if _mv is not None:
3318
+ click.echo(
3319
+ f" merge verify: {target_branch}-ahead="
3320
+ f"{_mv.default_ahead} added={len(_mv.added)} commit(s)"
3321
+ )
3322
+ for _sha, _subj in _mv.added:
3323
+ _flag = " [FOREIGN]" if (_sha, _subj) in _mv.foreign else ""
3324
+ click.echo(f" {_sha[:9]} {_subj}{_flag}")
3325
+ if not _mv.ok:
3326
+ click.echo(
3327
+ " ✗ MERGE BLOCKED (#604): "
3328
+ f"{_mv.block_summary(target_branch)}",
3329
+ err=True,
3330
+ )
3331
+ if finalize_result.already_recorded and (_mv is None or _mv.ok):
3332
+ click.echo(
3333
+ " result recorded via `coord report-result`; backstop "
3334
+ "did not overwrite"
3335
+ )
3336
+ else:
3337
+ click.echo(
3338
+ f" backstop: status={finalize_result.terminal_status} "
3339
+ f"commits_ahead={finalize_result.commits_ahead}"
3340
+ )
3341
+
3342
+ _mg_board = _interactive_board(_build_board_mg)
3343
+ work = _mg_board.find_by_id(merge_of)
3344
+ if work is None:
3345
+ click.echo(
3346
+ f"error: --merge-of {merge_of}: no such assignment on the "
3347
+ "board (use the work id from `coord status`).",
3348
+ err=True,
3349
+ )
3350
+ sys.exit(2)
3351
+ if not work.branch:
3352
+ click.echo(
3353
+ f"error: work assignment {merge_of} has no branch to merge.",
3354
+ err=True,
3355
+ )
3356
+ sys.exit(2)
3357
+
3358
+ resolved_model = model if model else cfg.models.default
3359
+ assignment_id = _uuid.uuid4().hex[:12]
3360
+ if _is_local:
3361
+ merge_repo_path = str(
3362
+ Path(machine_obj.repo_path(repo) or str(Path.cwd())).expanduser()
3363
+ )
3364
+ else:
3365
+ merge_repo_path = machine_obj.repo_path(repo) or f"~/src/{repo}"
3366
+ merge_target_branch = repo_cfg.default_branch or "main"
3367
+ _merge_test_cmd = None
3368
+ try:
3369
+ _merge_test_cmd = getattr(repo_cfg, "test_command", None)
3370
+ except Exception: # noqa: BLE001
3371
+ _merge_test_cmd = None
3372
+
3373
+ INTERACTIVE_MERGE_SYSTEM_PROMPT = (
3374
+ "You are a merge-prep agent dispatched by the coordinator. This "
3375
+ "session may be attended by a human operator or it may be "
3376
+ "unattended — do not assume anyone is watching. The branch has "
3377
+ "been reviewed/approved; your job is to get it cleanly rebased, "
3378
+ "verified, and MERGED.\n\n"
3379
+ "Rules:\n"
3380
+ "- Stay on the worker's branch. NEVER push to the default branch "
3381
+ "directly.\n"
3382
+ "- Use `git push --force-with-lease` (NOT plain --force) after a "
3383
+ "rebase.\n"
3384
+ "- Resolve MECHANICAL conflicts (non-overlapping struct fields, "
3385
+ "list/import entries, separate functions) additively — keep both "
3386
+ "sides. For SEMANTIC conflicts (same logic changed two ways), do "
3387
+ "NOT guess: explain the conflict to the operator and let them "
3388
+ "decide.\n"
3389
+ "- NEVER push to the default branch directly or use `gh`. "
3390
+ f"`coord merge --repo {repo}` is the ONLY merge path — it routes "
3391
+ "to the coordinator and re-checks the CI / review / smoke gates "
3392
+ "before merging via PR. You run it yourself, but ONLY after "
3393
+ "`coord verify-merge` passes clean.\n"
3394
+ "- If `coord merge` reports a gate failure (CI red, conflict, "
3395
+ "missing verdict), STOP and report it to the operator — never "
3396
+ "`--force-merge` / `--skip-*` on your own.\n\n"
3397
+ "Flow:\n"
3398
+ "1. `git fetch origin`.\n"
3399
+ "2. Rebase the branch onto `origin/<default_branch>`.\n"
3400
+ "3. Resolve conflicts (mechanical additively; semantic with the "
3401
+ "operator).\n"
3402
+ "4. Run the project's build/tests to confirm nothing broke.\n"
3403
+ "5. `git push --force-with-lease`.\n"
3404
+ f"6. Run `coord verify-merge {merge_of}` to self-check the "
3405
+ "result.\n"
3406
+ " `default-ahead != 0` means the rebase landed on a stale "
3407
+ "base — redo the rebase onto the current `origin/<default_branch>` "
3408
+ "and re-push.\n"
3409
+ " FOREIGN commits: `coord verify-merge` flags a commit as "
3410
+ "FOREIGN when its *subject line* does not contain the issue "
3411
+ "number. It NEVER reads the diff — only the subject string. "
3412
+ "This means a commit that is this branch's own work can be "
3413
+ "flagged if the subject has a typo'd or omitted issue ref. "
3414
+ "Before treating any FOREIGN flag as a real strand, apply this "
3415
+ "decision procedure for each flagged commit:\n"
3416
+ " (a) Run `git show <sha>` and read the diff. If the content "
3417
+ "is clearly this issue's own work → reword the commit subject "
3418
+ "to include the issue number (NEVER drop the commit). Verify "
3419
+ "the tree is unchanged: `git rev-parse HEAD^{tree}` before and "
3420
+ "after must be identical. Re-run `coord verify-merge` — it "
3421
+ "should now pass.\n"
3422
+ " (b) Genuinely unrelated content AND the commit is already an "
3423
+ "ancestor of the base branch → drop it from the branch.\n"
3424
+ " (c) Genuinely unrelated content AND NOT on the base branch → "
3425
+ "this is a real foreign strand. Escalate to the operator.\n"
3426
+ " Exhaust-before-blocking checklist (complete this BEFORE "
3427
+ "reporting `--status blocked`):\n"
3428
+ " [ ] List every FOREIGN-flagged commit\n"
3429
+ " [ ] Run `git show <sha>` on each to read the diff\n"
3430
+ " [ ] Categorise each: (a) own-mislabeled, (b) ancestor-on-"
3431
+ "base, (c) genuine strand\n"
3432
+ " [ ] Resolve (a) and (b) autonomously; only escalate (c)\n"
3433
+ " A `--status blocked` report MUST state: what you tried, "
3434
+ "which specific commit is the obstacle, and the single decision "
3435
+ "the operator must make. Blocking without that detail is itself "
3436
+ "a prompt violation.\n"
3437
+ " Blocking cost: the operator must context-switch to this "
3438
+ "session and the branch sits stranded until they do. It is not "
3439
+ "a free exit — exhaust autonomous options first.\n"
3440
+ " The coordinator also runs this check on exit and will record "
3441
+ "`blocked` (not `done`) if it still fails after you exit, so a "
3442
+ "bad rebase cannot slip through regardless.\n"
3443
+ f"7. Complete the merge: run `coord merge --repo {repo}` (it opens "
3444
+ "the PR, enforces the gates, and merges in dependency order). "
3445
+ "Report the outcome — merged, or which gate blocked.\n"
3446
+ )
3447
+
3448
+ merge_briefing = (
3449
+ f"# Merge-prep assignment: {repo_cfg.github} #{issue}\n\n"
3450
+ f"**Issue:** {issue_title}\n"
3451
+ f"**Branch to merge:** `{work.branch}` "
3452
+ f"(worker: {work.machine_name or machine})\n"
3453
+ f"**Rebase onto:** `origin/{merge_target_branch}`\n"
3454
+ f"**Work assignment id:** `{merge_of}`\n"
3455
+ + (
3456
+ f"**Test command:** `{_merge_test_cmd}`\n"
3457
+ if _merge_test_cmd
3458
+ else ""
3459
+ )
3460
+ + "\n## Your job\n\n"
3461
+ "This branch is approved. Fetch, rebase it onto "
3462
+ f"`origin/{merge_target_branch}` (#306 proactive rebase), resolve "
3463
+ "any conflicts (mechanical additively; semantic with the "
3464
+ "operator), run the tests, and `git push --force-with-lease`. "
3465
+ f"Before you report done, run `coord verify-merge {merge_of}`. "
3466
+ f"If `{merge_target_branch}-ahead != 0`, the rebase is on a "
3467
+ "stale base — redo it. If FOREIGN commits are listed, read the "
3468
+ "system-prompt decision procedure (a/b/c) before deciding whether "
3469
+ "to reword, drop, or escalate — FOREIGN is a subject-string check "
3470
+ "that never reads diffs, so it can fire on this branch's own "
3471
+ "mislabeled commit (#604). "
3472
+ f"Once verify-merge passes clean, COMPLETE the merge yourself: "
3473
+ f"run `coord merge --repo {repo}` (it re-checks CI/review/smoke "
3474
+ "on the coordinator and merges via PR). If it reports a gate "
3475
+ "failure, report that to the operator instead of forcing.\n"
3476
+ )
3477
+
3478
+ os.environ["COORD_ASSIGNMENT_ID"] = assignment_id
3479
+ report_reminder = (
3480
+ f"[Coordinator merge assignment {assignment_id}] HUMAN-ATTENDED "
3481
+ f"merge-prep on branch {work.branch} (rebasing onto "
3482
+ f"{merge_target_branch}). Before you exit, run `coord "
3483
+ f"report-result --assignment {assignment_id} --status done "
3484
+ "--summary <text>` (use --status blocked only for a semantic "
3485
+ "conflict or genuine foreign strand that needs the operator; "
3486
+ "blocked reports must name what was tried and the single decision "
3487
+ "needed).\n\n"
3488
+ )
3489
+ effective_briefing = _issue_ctx + report_reminder + merge_briefing
3490
+
3491
+ spec = _AssignmentSpecMg(
3492
+ repo_name=repo,
3493
+ repo_path=merge_repo_path,
3494
+ issue_number=issue,
3495
+ issue_title=f"[merge] {issue_title}",
3496
+ briefing=effective_briefing,
3497
+ model=resolved_model,
3498
+ type="conflict-fix",
3499
+ provider="claude-pty",
3500
+ )
3501
+ # Full worker tool set (Read/Edit/Write/Bash) — rebasing and resolving
3502
+ # conflicts mutates the checkout.
3503
+ argv = provider.build_command(
3504
+ spec,
3505
+ resolved_model=resolved_model,
3506
+ system_prompt=INTERACTIVE_MERGE_SYSTEM_PROMPT,
3507
+ )
3508
+ # Remote: bare "claude" isn't on the SSH login PATH (#424/#425).
3509
+ if not _is_local:
3510
+ argv = ["~/.local/bin/claude"] + list(argv)[1:]
3511
+
3512
+ _mg_location = (
3513
+ "local TTY" if _is_local
3514
+ else f"{machine_obj.host} (remote tmux)"
3515
+ )
3516
+ click.echo(
3517
+ f"{machine} ({_mg_location}) → MERGE-PREP of #{issue} "
3518
+ f"on branch {work.branch}: {issue_title}"
3519
+ )
3520
+ click.echo(" mode: HUMAN-ATTENDED interactive merge agent (leg 3c)")
3521
+ click.echo(
3522
+ f" assignment id: {assignment_id} (merge_of={merge_of}, "
3523
+ f"rebase onto origin/{merge_target_branch})"
3524
+ )
3525
+ if dry_run:
3526
+ click.echo(" (dry run — not launched)")
3527
+ click.echo(f" would continue branch: {work.branch}")
3528
+ if not _is_local:
3529
+ click.echo(
3530
+ f" remote worktree: $HOME/.coord/worktrees/{assignment_id}"
3531
+ f" on {machine_obj.host} (branch: {work.branch})"
3532
+ )
3533
+ click.echo(f" would exec: {argv}")
3534
+ return
3535
+
3536
+ merge_assignment = _AssignmentMg(
3537
+ machine_name=machine,
3538
+ repo_name=repo,
3539
+ issue_number=issue,
3540
+ issue_title=f"[merge] {issue_title}",
3541
+ briefing=effective_briefing,
3542
+ assignment_id=assignment_id,
3543
+ status="running",
3544
+ branch=work.branch,
3545
+ pr_url=work.pr_url,
3546
+ dispatched_at=_time.time(),
3547
+ type="conflict-fix",
3548
+ review_of_assignment_id=work.assignment_id,
3549
+ model=resolved_model,
3550
+ provider_name="claude-pty",
3551
+ )
3552
+ _record_mg(assignment=merge_assignment, repo_github=repo_cfg.github)
3553
+ if _svc is None:
3554
+ _save_board_mg(_build_board_mg())
3555
+
3556
+ if _is_local:
3557
+ try:
3558
+ _wt_path, _ = _setup_wt_mg(
3559
+ Path(merge_repo_path),
3560
+ issue_number=issue,
3561
+ issue_title=issue_title,
3562
+ assignment_id=assignment_id,
3563
+ default_branch=merge_target_branch,
3564
+ existing_branch=work.branch,
3565
+ )
3566
+ worktree_path = str(_wt_path)
3567
+ except (_AgentGitErrorMg, OSError) as _wt_err:
3568
+ click.echo(
3569
+ f" error: could not create merge worktree on branch "
3570
+ f"{work.branch}: {_wt_err}",
3571
+ err=True,
3572
+ )
3573
+ sys.exit(1)
3574
+ click.echo(f" worktree: {worktree_path} (branch: {work.branch})")
3575
+
3576
+ started_at = _time.time()
3577
+ exit_code = launch_human_attended_interactive(
3578
+ argv,
3579
+ effective_briefing,
3580
+ assignment_id=assignment_id,
3581
+ cwd=worktree_path,
3582
+ )
3583
+ if exit_code != 0:
3584
+ click.echo(f" claude exited with status {exit_code}", err=True)
3585
+
3586
+ _sname = _tmux_name(assignment_id) if _tmux_avail() else None
3587
+ if _sname and _tmux_alive(_sname):
3588
+ click.echo(
3589
+ f" session still running in tmux: {_sname}\n"
3590
+ f" reattach with: coord reattach {assignment_id}"
3591
+ )
3592
+ sys.exit(0)
3593
+
3594
+ try:
3595
+ finalize_result = finalize_interactive_exit(
3596
+ assignment_id=assignment_id,
3597
+ repo_name=repo,
3598
+ repo_github=repo_cfg.github,
3599
+ issue_number=issue,
3600
+ machine_name=machine,
3601
+ worktree_path=worktree_path,
3602
+ base_branch=merge_target_branch,
3603
+ exit_code=exit_code,
3604
+ started_at=started_at,
3605
+ log_path=None,
3606
+ repo_path=merge_repo_path,
3607
+ # #604: git truth overrides the agent's self-report on the
3608
+ # merge path — a botched rebase records `blocked`, not `done`.
3609
+ verify_merge=True,
3610
+ branch=work.branch,
3611
+ )
3612
+ _echo_merge_finalize(finalize_result, merge_target_branch)
3613
+ except Exception as exc: # noqa: BLE001 — best-effort backstop
3614
+ click.echo(
3615
+ f" warning: backstop failed to record merge exit: {exc}",
3616
+ err=True,
3617
+ )
3618
+ sys.exit(exit_code)
3619
+
3620
+ # ── REMOTE MERGE (#1007) ──────────────────────────────────────
3621
+ # A remote worktree on the EXISTING branch (`-B <branch>
3622
+ # origin/<branch>` resets a dedicated local branch to the reviewed
3623
+ # work's branch) — same shape as the remote fix path. The merge-prep
3624
+ # agent itself runs `git push --force-with-lease` and `coord merge` as
3625
+ # part of its instructed flow (INTERACTIVE_MERGE_SYSTEM_PROMPT above),
3626
+ # so on exit the coordinator's job is to VERIFY the rebase (#604) on
3627
+ # the remote worktree before trusting any self-report, then record
3628
+ # the completion.
3629
+ import shlex as _shlex_mg # noqa: PLC0415
3630
+
3631
+ _remote_wt = "$HOME/.coord/worktrees/" + assignment_id
3632
+ _rp_sh = (
3633
+ "$HOME/" + merge_repo_path[2:]
3634
+ if merge_repo_path.startswith("~/")
3635
+ else ("$HOME" if merge_repo_path == "~" else merge_repo_path)
3636
+ )
3637
+ _claude_args = _shlex_mg.join(list(argv)[1:])
3638
+ _br_q = _shlex_mg.quote(work.branch)
3639
+ _orig_ref = _shlex_mg.quote(f"origin/{work.branch}")
3640
+ _remote_cmd = (
3641
+ f"mkdir -p $HOME/.coord/worktrees"
3642
+ f" && cd {_rp_sh}"
3643
+ f" && git fetch origin --prune 2>/dev/null || true"
3644
+ f" && git worktree prune 2>/dev/null || true"
3645
+ f" && git worktree add -B {_br_q} {_remote_wt} {_orig_ref}"
3646
+ f" && cd {_remote_wt}"
3647
+ f" && COORD_ASSIGNMENT_ID={assignment_id} {argv[0]} {_claude_args}"
3648
+ )
3649
+ _tmux_host = TmuxHost(ssh_target=machine_obj.host)
3650
+ _sname = _tmux_name(assignment_id)
3651
+ click.echo(
3652
+ f" remote worktree: $HOME/.coord/worktrees/{assignment_id}"
3653
+ f" on {machine_obj.host} (branch: {work.branch})"
3654
+ )
3655
+
3656
+ if effective_briefing.strip():
3657
+ _hdr = (
3658
+ "--- seeded briefing -- review below; "
3659
+ "submit the pre-filled input in Claude to send ---"
3660
+ )
3661
+ _ftr = "-" * len(_hdr)
3662
+ _preview = f"\n{_hdr}\n{effective_briefing.rstrip()}\n{_ftr}\n\n"
3663
+ try:
3664
+ os.write(sys.stdout.fileno(), _preview.encode("utf-8"))
3665
+ except OSError:
3666
+ pass
3667
+
3668
+ started_at = _time.time()
3669
+ _rc = _tmux_launch(
3670
+ argv,
3671
+ effective_briefing,
3672
+ _sname,
3673
+ cwd=None,
3674
+ host=_tmux_host,
3675
+ raw_shell_cmd=_remote_cmd,
3676
+ )
3677
+ if _rc is None:
3678
+ click.echo(
3679
+ " error: could not create remote tmux session on "
3680
+ f"{machine_obj.host}",
3681
+ err=True,
3682
+ )
3683
+ sys.exit(1)
3684
+ exit_code = _rc
3685
+
3686
+ if _tmux_alive(_sname, host=_tmux_host):
3687
+ click.echo(
3688
+ f" session still running in remote tmux: {_sname}\n"
3689
+ f" reattach with: ssh -t {machine_obj.host}"
3690
+ f" tmux attach-session -t {_sname}\n"
3691
+ " (the merge is not finalized until the session ends and "
3692
+ "the coordinator finalizes)"
3693
+ )
3694
+ sys.exit(0)
3695
+
3696
+ # ── #560: detect setup failures (worktree never created) ──────
3697
+ _wt_setup_ok_mg: bool = True
3698
+ if exit_code != 0:
3699
+ if not _remote_wt_exists(machine_obj.host, _remote_wt):
3700
+ _wt_setup_ok_mg = False
3701
+ _holder = _find_branch_holder(
3702
+ machine_obj.host, _rp_sh, work.branch,
3703
+ )
3704
+ if _holder:
3705
+ _holder_aid = Path(_holder).name
3706
+ _holder_sname = f"coord-{_holder_aid}"
3707
+ _holder_live = _tmux_alive(_holder_sname, host=_tmux_host)
3708
+ _err_header = (
3709
+ f" error: setup failed — branch {work.branch!r} is "
3710
+ f"already checked out at {_holder} on {machine_obj.host}."
3711
+ )
3712
+ if _holder_live:
3713
+ click.echo("\n".join([
3714
+ _err_header,
3715
+ f" active tmux session: {_holder_sname}",
3716
+ f" reattach: coord reattach {_holder_aid}",
3717
+ " exit the session first, then retry this merge.",
3718
+ ]), err=True)
3719
+ else:
3720
+ # Dead holder — detect base checkout vs stale orphan (#814).
3721
+ if _holder_is_base(_holder):
3722
+ # #814: the base checkout ~/src/<repo> is on the
3723
+ # merge branch. NEVER prune the base (#561) —
3724
+ # checkout the default branch to free the lock.
3725
+ click.echo(
3726
+ f" base checkout {_holder!r} is on branch"
3727
+ f" {work.branch!r} on {machine_obj.host}"
3728
+ f" — checking out {merge_target_branch!r}"
3729
+ f" to free it …"
3730
+ )
3731
+ _freed = _remote_free_base(
3732
+ machine_obj.host, _rp_sh, merge_target_branch,
3733
+ )
3734
+ if _freed:
3735
+ click.echo(
3736
+ " base checkout freed — retrying launch …"
3737
+ )
3738
+ _rc2 = _tmux_launch(
3739
+ argv, effective_briefing, _sname,
3740
+ cwd=None, host=_tmux_host,
3741
+ raw_shell_cmd=_remote_cmd,
3742
+ )
3743
+ if _rc2 is None:
3744
+ click.echo(
3745
+ f" error: could not create remote tmux"
3746
+ f" session on {machine_obj.host}"
3747
+ f" (retry after base-checkout free)",
3748
+ err=True,
3749
+ )
3750
+ else:
3751
+ exit_code = _rc2
3752
+ if _tmux_alive(_sname, host=_tmux_host):
3753
+ click.echo(
3754
+ f" session still running in remote"
3755
+ f" tmux: {_sname}\n"
3756
+ f" reattach with: ssh -t"
3757
+ f" {machine_obj.host}"
3758
+ f" tmux attach-session -t {_sname}\n"
3759
+ " (the merge is not finalized until"
3760
+ " the session ends and the"
3761
+ " coordinator finalizes)"
3762
+ )
3763
+ sys.exit(0)
3764
+ if (
3765
+ exit_code == 0
3766
+ or _remote_wt_exists(
3767
+ machine_obj.host, _remote_wt
3768
+ )
3769
+ ):
3770
+ _wt_setup_ok_mg = True # retry succeeded
3771
+ else:
3772
+ click.echo("\n".join([
3773
+ _err_header,
3774
+ f" base checkout is stuck on"
3775
+ f" {work.branch!r} —",
3776
+ f" free it manually with:",
3777
+ f" ssh {machine_obj.host}"
3778
+ f" 'git -C {_rp_sh} checkout"
3779
+ f" {merge_target_branch}'",
3780
+ ]), err=True)
3781
+ else:
3782
+ # Dead orphan — safety-gated auto-prune-and-retry (#759).
3783
+ _auto_pruned = False
3784
+ if _remote_orphan_safe(
3785
+ machine_obj.host, _rp_sh, _holder, work.branch,
3786
+ ):
3787
+ click.echo(
3788
+ f" auto-pruning stale orphan {_holder}"
3789
+ f" on {machine_obj.host} …"
3790
+ )
3791
+ _auto_pruned = _remote_wt_remove(
3792
+ machine_obj.host, _rp_sh, _holder,
3793
+ )
3794
+ if _auto_pruned:
3795
+ click.echo(
3796
+ " stale orphan auto-pruned — retrying launch …"
3797
+ )
3798
+ _rc2 = _tmux_launch(
3799
+ argv, effective_briefing, _sname,
3800
+ cwd=None, host=_tmux_host,
3801
+ raw_shell_cmd=_remote_cmd,
3802
+ )
3803
+ if _rc2 is None:
3804
+ click.echo(
3805
+ f" error: could not create remote tmux"
3806
+ f" session on {machine_obj.host}"
3807
+ f" (retry after prune)",
3808
+ err=True,
3809
+ )
3810
+ else:
3811
+ exit_code = _rc2
3812
+ if _tmux_alive(_sname, host=_tmux_host):
3813
+ click.echo(
3814
+ f" session still running in remote"
3815
+ f" tmux: {_sname}\n"
3816
+ f" reattach with: ssh -t"
3817
+ f" {machine_obj.host}"
3818
+ f" tmux attach-session -t {_sname}\n"
3819
+ " (the merge is not finalized until"
3820
+ " the session ends and the"
3821
+ " coordinator finalizes)"
3822
+ )
3823
+ sys.exit(0)
3824
+ if (
3825
+ exit_code == 0
3826
+ or _remote_wt_exists(
3827
+ machine_obj.host, _remote_wt
3828
+ )
3829
+ ):
3830
+ _wt_setup_ok_mg = True # retry succeeded
3831
+ else:
3832
+ # Not safe or prune failed → manual command.
3833
+ click.echo("\n".join([
3834
+ _err_header,
3835
+ " stale worktree — prune it first:",
3836
+ f" ssh {machine_obj.host} 'cd {_rp_sh}"
3837
+ f" && git worktree remove --force"
3838
+ f" {_shlex_mg.quote(_holder)}'",
3839
+ ]), err=True)
3840
+ else:
3841
+ click.echo(
3842
+ f" error: setup failed — the remote worktree was "
3843
+ f"never created (git worktree add refused on "
3844
+ f"{machine_obj.host}).",
3845
+ err=True,
3846
+ )
3847
+ else:
3848
+ click.echo(
3849
+ f" claude exited with status {exit_code}", err=True,
3850
+ )
3851
+
3852
+ # Remote finalize (#1007): verify the rebase (#604) on the remote
3853
+ # worktree BEFORE trusting any self-report, then record the
3854
+ # completion and clean up. Safe even when the worktree was never
3855
+ # created — remote_worktree_exists() gates the verify step and
3856
+ # _remote_push_and_count detects the missing directory.
3857
+ try:
3858
+ finalize_result = finalize_remote_interactive_exit(
3859
+ assignment_id=assignment_id,
3860
+ repo_name=repo,
3861
+ repo_github=repo_cfg.github,
3862
+ issue_number=issue,
3863
+ machine_name=machine,
3864
+ ssh_target=machine_obj.host,
3865
+ remote_worktree_sh=_remote_wt,
3866
+ remote_repo_sh=_rp_sh,
3867
+ branch=work.branch,
3868
+ base_branch=merge_target_branch,
3869
+ exit_code=exit_code,
3870
+ started_at=started_at,
3871
+ verify_merge=True,
3872
+ )
3873
+ _echo_merge_finalize(finalize_result, merge_target_branch)
3874
+ _mv = finalize_result.merge_verify
3875
+ if not finalize_result.push_ok and _wt_setup_ok_mg and (
3876
+ _mv is None or _mv.ok
3877
+ ):
3878
+ # Worker ran but the coordinator's own backstop push failed —
3879
+ # the agent's own `git push --force-with-lease` (briefing step
3880
+ # 5) may still have landed; this is a secondary signal only.
3881
+ click.echo(
3882
+ f" warning: remote push failed: {finalize_result.push_error}",
3883
+ err=True,
3884
+ )
3885
+ click.echo(
3886
+ f" merge commits preserved in {_remote_wt} on "
3887
+ f"{machine_obj.host} (worktree NOT removed)",
3888
+ err=True,
3889
+ )
3890
+ except Exception as exc: # noqa: BLE001 — best-effort backstop
3891
+ click.echo(
3892
+ f" warning: backstop failed to record remote merge exit: {exc}",
3893
+ err=True,
3894
+ )
3895
+ sys.exit(exit_code)
3896
+
3897
+
3898
+ def _dispatch_interactive_work(
3899
+ *,
3900
+ machine: str,
3901
+ repo: str,
3902
+ issue: int,
3903
+ briefing: str,
3904
+ model: str | None,
3905
+ dry_run: bool,
3906
+ plan_only: bool,
3907
+ no_plan: bool,
3908
+ force: bool,
3909
+ cfg: Config,
3910
+ machine_obj: object,
3911
+ repo_cfg: object,
3912
+ issue_title: str,
3913
+ provider: object,
3914
+ _is_local: bool,
3915
+ _issue_ctx: str,
3916
+ _ctx_write_hint: str,
3917
+ ) -> None:
3918
+ """#437: the flavour-less human-attended interactive work/plan launch (no --review-of/--fix-of/etc)."""
3919
+ import time as _time # noqa: PLC0415
3920
+ import uuid as _uuid # noqa: PLC0415
3921
+
3922
+ from coord.interactive import ( # noqa: PLC0415
3923
+ TmuxHost,
3924
+ _holder_is_base_checkout as _holder_is_base,
3925
+ _launch_via_tmux as _tmux_launch,
3926
+ _remote_base_checkout_free_branch as _remote_free_base,
3927
+ _remote_orphan_is_safe_to_prune as _remote_orphan_safe,
3928
+ _remote_worktree_remove as _remote_wt_remove,
3929
+ find_remote_branch_holder as _find_branch_holder,
3930
+ finalize_interactive_exit,
3931
+ finalize_remote_interactive_exit,
3932
+ launch_human_attended_interactive,
3933
+ remote_worktree_exists as _remote_wt_exists,
3934
+ tmux_available as _tmux_avail,
3935
+ tmux_session_alive as _tmux_alive,
3936
+ tmux_session_name as _tmux_name,
3937
+ )
3938
+
3939
+ if _is_local:
3940
+ # Expand `~` — repo_paths in coordinator.yml use `~/src/...`,
3941
+ # and unlike the agent (which expands everywhere) this local
3942
+ # interactive launch passes the path straight to the child's cwd,
3943
+ # so a literal `~` would fail with "No such file or directory".
3944
+ # Local launch ⇒ local home.
3945
+ repo_path = str(
3946
+ Path(machine_obj.repo_path(repo) or str(Path.cwd())).expanduser()
3947
+ )
3948
+ else:
3949
+ # Remote: keep the raw path from coordinator.yml so the remote
3950
+ # shell can expand `~` itself. Fall back to ~/src/<repo> when no
3951
+ # repo_path is configured (common for new machines).
3952
+ repo_path = machine_obj.repo_path(repo) or f"~/src/{repo}"
3953
+
3954
+ effective_plan_only = plan_only or (
3955
+ cfg.dispatch.require_plan and not no_plan
3956
+ )
3957
+ repo_default_branch = repo_cfg.default_branch or "main"
3958
+
3959
+ # ── Claim check. Without this an operator can spawn two
3960
+ # interactive sessions on the same issue and both push competing
3961
+ # branches. --force bypasses the check (mirrors the
3962
+ # claude -p path below).
3963
+ from coord.board_service import read_board, write_board # noqa: PLC0415
3964
+ from coord.claim import claim_message, find_work_claim # noqa: PLC0415
3965
+ from coord.state import record_dispatched # noqa: PLC0415
3966
+
3967
+ board_check = read_board()
3968
+ if not force:
3969
+ claim = find_work_claim(issue, repo, repo_cfg.github, board_check)
3970
+ if claim is not None:
3971
+ click.echo(
3972
+ f" skipping: {claim_message(claim)}",
3973
+ err=True,
3974
+ )
3975
+ sys.exit(1)
3976
+
3977
+ # #1138: the oracle-loop issue-level gate applies to --interactive work
3978
+ # dispatch too, not just headless — a human at the keyboard can miss the
3979
+ # same gap a `claude -p` worker would. Deliberately NOT bypassed by
3980
+ # --force (that flag is documented as an infra-retry/claim escape hatch,
3981
+ # not a way around the acceptance-authoring requirement).
3982
+ from coord.dispatch import ( # noqa: PLC0415
3983
+ enforce_epic_dispatch_guard,
3984
+ enforce_oracle_readiness,
3985
+ )
3986
+
3987
+ try:
3988
+ enforce_oracle_readiness(
3989
+ proposal_type="plan" if effective_plan_only else "work",
3990
+ repo=repo_cfg, config=cfg, issue_number=issue,
3991
+ )
3992
+ # #1314: same epic-target gate as the headless dispatch() path —
3993
+ # a human at the keyboard can dispatch --interactive directly
3994
+ # against a tracking issue's own number just as easily as a
3995
+ # headless worker.
3996
+ enforce_epic_dispatch_guard(
3997
+ proposal_type="plan" if effective_plan_only else "work",
3998
+ repo=repo_cfg, config=cfg, issue_number=issue,
3999
+ )
4000
+ except ValueError as e:
4001
+ click.echo(f" skipping: {e}", err=True)
4002
+ sys.exit(1)
4003
+
4004
+ from coord.agent import AssignmentSpec # noqa: PLC0415
4005
+ from coord.models import Proposal # noqa: PLC0415
4006
+
4007
+ # #1430: deliberately NOT consulting models.labels here. This is the
4008
+ # human-attended `--interactive` launcher (subscription-billed, not
4009
+ # metered per dispatch) — the operator is at the keyboard and can pass
4010
+ # --model directly when a tier matters; unlike the headless paths, a
4011
+ # wrong default here doesn't silently burn API budget unattended.
4012
+ resolved_model = model if model else cfg.models.default
4013
+ assignment_id = _uuid.uuid4().hex[:12]
4014
+
4015
+ spec = AssignmentSpec(
4016
+ repo_name=repo,
4017
+ repo_path=repo_path,
4018
+ issue_number=issue,
4019
+ issue_title=issue_title,
4020
+ briefing=briefing,
4021
+ model=resolved_model,
4022
+ type="plan" if effective_plan_only else "work",
4023
+ provider="claude-pty",
4024
+ )
4025
+
4026
+ # Build a minimal Proposal — only the fields record_dispatched
4027
+ # consumes need to be set. The actual record_dispatched call is
4028
+ # deferred until after the dry-run gate below so `--dry-run`
4029
+ # leaves no phantom "running" row in the DB.
4030
+ proposal = Proposal(
4031
+ id=0,
4032
+ machine_name=machine,
4033
+ repo_name=repo,
4034
+ issue_number=issue,
4035
+ issue_title=issue_title,
4036
+ rationale="manual --interactive dispatch (human-attended)",
4037
+ briefing=briefing,
4038
+ model=resolved_model,
4039
+ type="plan" if effective_plan_only else "work",
4040
+ required_gates=[],
4041
+ )
4042
+
4043
+ argv = provider.build_command(spec, resolved_model=resolved_model)
4044
+
4045
+ # For remote: replace the binary (argv[0]) with the absolute path
4046
+ # to claude on the remote machine. A bare "claude" is not on the
4047
+ # SSH login PATH (#424/#425); ~/.local/bin/claude is the canonical
4048
+ # location installed by `claude` setup on Linux.
4049
+ _REMOTE_CLAUDE_BIN = "~/.local/bin/claude"
4050
+ if not _is_local:
4051
+ argv = [_REMOTE_CLAUDE_BIN] + list(argv)[1:]
4052
+
4053
+ _location = "local TTY" if _is_local else f"{machine_obj.host} (remote tmux)"
4054
+ click.echo(f"{machine} ({_location}) → {repo} #{issue}: {issue_title}")
4055
+ click.echo(" mode: HUMAN-ATTENDED interactive launch (#437)")
4056
+ click.echo(f" assignment id: {assignment_id}")
4057
+ click.echo(
4058
+ " the briefing will be PRE-FILLED in the input box; "
4059
+ "press Enter to submit; Ctrl-C / `/exit` to end the session."
4060
+ )
4061
+ if dry_run:
4062
+ from coord.agent import _slugify as _slugify_dry # noqa: PLC0415
4063
+ _dry_branch = f"issue-{issue}-{_slugify_dry(issue_title)}"
4064
+ click.echo(" (dry run — not launched)")
4065
+ click.echo(f" would exec: {argv}")
4066
+ if _is_local:
4067
+ click.echo(
4068
+ f" cwd: worktree for {_dry_branch} "
4069
+ f"(under ~/.coord/worktrees/<assignment_id>)"
4070
+ )
4071
+ else:
4072
+ _dry_wt = f"~/.coord/worktrees/{assignment_id}"
4073
+ click.echo(
4074
+ f" remote worktree: {_dry_wt} on {machine_obj.host}"
4075
+ f" (branch: {_dry_branch})"
4076
+ )
4077
+ return
4078
+
4079
+ if _is_local:
4080
+ # ── LOCAL PATH ────────────────────────────────────────────────
4081
+ # Byte-identical to the pre-#494 behaviour: create an isolated
4082
+ # worktree + feature branch locally via setup_interactive_worktree,
4083
+ # then attach the current terminal directly via
4084
+ # launch_human_attended_interactive.
4085
+ from coord.agent import ( # noqa: PLC0415
4086
+ _GitError as _AgentGitError,
4087
+ setup_interactive_worktree,
4088
+ )
4089
+ try:
4090
+ _wt_path, _interactive_branch = setup_interactive_worktree(
4091
+ Path(repo_path),
4092
+ issue_number=issue,
4093
+ issue_title=issue_title,
4094
+ assignment_id=assignment_id,
4095
+ default_branch=repo_default_branch,
4096
+ )
4097
+ worktree_path = str(_wt_path)
4098
+ except (_AgentGitError, OSError) as _wt_err:
4099
+ click.echo(
4100
+ f" error: could not create worktree for interactive session: {_wt_err}",
4101
+ err=True,
4102
+ )
4103
+ sys.exit(1)
4104
+
4105
+ click.echo(f" worktree: {worktree_path} (branch: {_interactive_branch})")
4106
+
4107
+ # State mutations (DB row, env var, board write) ONLY on real
4108
+ # dispatch — never in dry-run. Record up front so:
4109
+ # * claim detection refuses a duplicate the second the human
4110
+ # hits Enter on a parallel `coord assign --interactive`,
4111
+ # * the board shows the in-flight interactive session,
4112
+ # * the issue_store seam has a row to UPDATE on exit.
4113
+ record_dispatched(
4114
+ assignment_id=assignment_id,
4115
+ proposal=proposal,
4116
+ repo_github=repo_cfg.github,
4117
+ provider_name="claude-pty",
4118
+ )
4119
+
4120
+ # #466: Inject the assignment id into the agent's process env so
4121
+ # the interactive Claude session can run
4122
+ # `coord report-result --assignment $COORD_ASSIGNMENT_ID …` to
4123
+ # report a structured result before exiting. Also prepend a
4124
+ # short reminder to the briefing so the operator notices.
4125
+ #
4126
+ # #646: do NOT offer --verdict here — this is a work/plan session.
4127
+ # A verdict belongs only on a review session. Offering it led the
4128
+ # work agent to run `report-result --verdict approve` against its
4129
+ # OWN work id, stamping a bogus verdict and finalizing a still-live
4130
+ # session (the write seam now rejects that too, but don't tempt it).
4131
+ os.environ["COORD_ASSIGNMENT_ID"] = assignment_id
4132
+ report_reminder = (
4133
+ f"[Coordinator assignment {assignment_id}] "
4134
+ "Before you exit, please run `coord report-result "
4135
+ f"--assignment {assignment_id} --status <done|blocked|"
4136
+ "already-implemented> --summary <text>` so the coordinator "
4137
+ "records the result.\n\n"
4138
+ )
4139
+ effective_briefing = _issue_ctx + report_reminder + briefing + _ctx_write_hint
4140
+
4141
+ # Update board metadata (round_number / board_initialized).
4142
+ # `record_dispatched` already wrote the assignment row, so the
4143
+ # read_board → write_board round-trip is a no-op for the
4144
+ # assignments table; the useful side-effect is board_meta — and
4145
+ # write_board() now actually reaches the daemon on a thin client
4146
+ # instead of silently touching a local DB that isn't canonical (#749).
4147
+ write_board(read_board())
4148
+
4149
+ started_at = _time.time()
4150
+ # #487: pass assignment_id so the tmux path names the session
4151
+ # coord-<assignment_id>, enabling reattach after a TUI crash.
4152
+ exit_code = launch_human_attended_interactive(
4153
+ argv, effective_briefing, assignment_id=assignment_id, cwd=worktree_path,
4154
+ )
4155
+ if exit_code != 0:
4156
+ click.echo(f" claude exited with status {exit_code}", err=True)
4157
+
4158
+ # #487: if the tmux session is still alive the user just detached
4159
+ # (Ctrl-b d) or the TUI crashed. Skip finalize — the session is
4160
+ # still running. Tell the operator how to reattach and let them
4161
+ # close the session themselves (at which point coord report-result
4162
+ # or coord reattach will record the terminal state).
4163
+ _sname = _tmux_name(assignment_id) if _tmux_avail() else None
4164
+ if _sname and _tmux_alive(_sname):
4165
+ click.echo(
4166
+ f" session still running in tmux: {_sname}\n"
4167
+ f" reattach with: coord reattach {assignment_id}\n"
4168
+ f" or from shell: tmux attach-session -t {_sname}"
4169
+ )
4170
+ sys.exit(0)
4171
+
4172
+ # #466 — git-floor backstop. ALWAYS write a terminal state for
4173
+ # this assignment through the issue_store seam, regardless of
4174
+ # whether the agent typed `coord report-result` first. The
4175
+ # finalizer respects an existing report (it checks the DB row's
4176
+ # status before clobbering).
4177
+ # #982: a fresh interactive work session has no pre-existing
4178
+ # smoke_tests to narrow against (none are available until the
4179
+ # worker's own session finishes, and that session runs in a live
4180
+ # tmux/TTY pane rather than a log file this code can parse), so
4181
+ # this always stashes the full repo-wide glob — narrowing happens
4182
+ # centrally in AgentServer._stash_artifacts for the headless Work
4183
+ # path instead.
4184
+ try:
4185
+ finalize_result = finalize_interactive_exit(
4186
+ assignment_id=assignment_id,
4187
+ repo_name=repo,
4188
+ repo_github=repo_cfg.github,
4189
+ issue_number=issue,
4190
+ machine_name=machine,
4191
+ worktree_path=worktree_path,
4192
+ base_branch=repo_default_branch,
4193
+ exit_code=exit_code,
4194
+ started_at=started_at,
4195
+ log_path=None,
4196
+ repo_path=repo_path,
4197
+ artifact_paths=repo_cfg.artifact_paths,
4198
+ branch=_interactive_branch,
4199
+ )
4200
+ if finalize_result.already_recorded:
4201
+ click.echo(
4202
+ " result already recorded via `coord report-result`; "
4203
+ "backstop did not overwrite",
4204
+ )
4205
+ else:
4206
+ click.echo(
4207
+ f" backstop: status={finalize_result.terminal_status} "
4208
+ f"commits_ahead={finalize_result.commits_ahead}"
4209
+ )
4210
+ if not finalize_result.push_ok:
4211
+ click.echo(
4212
+ f" warning: git push failed: {finalize_result.push_error}",
4213
+ err=True,
4214
+ )
4215
+ except Exception as exc: # noqa: BLE001 — best-effort backstop
4216
+ click.echo(
4217
+ f" warning: backstop failed to record completion: {exc}",
4218
+ err=True,
4219
+ )
4220
+
4221
+ sys.exit(exit_code)
4222
+
4223
+ else:
4224
+ # ── REMOTE PATH (#494 / #486b) ────────────────────────────────
4225
+ # The target machine is not the local host. We create a named
4226
+ # tmux session ON THE REMOTE machine that:
4227
+ # 1. cd's into the remote repo checkout,
4228
+ # 2. fetches origin + prunes worktrees,
4229
+ # 3. creates a feature-branch worktree at
4230
+ # ~/.coord/worktrees/<assignment_id>,
4231
+ # 4. cd's into the worktree,
4232
+ # 5. launches claude with COORD_ASSIGNMENT_ID set inline.
4233
+ #
4234
+ # The local terminal ATTACHES to the remote tmux session so the
4235
+ # operator can drive it as if it were a local session.
4236
+ #
4237
+ # We use $HOME in the shell command (not ~) so that the paths
4238
+ # survive single-quote wrapping during remote transmission.
4239
+ # (~ inside single quotes is NOT expanded; $HOME inside a
4240
+ # tmux-run shell command IS expanded by the final shell.)
4241
+ import shlex as _shlex # noqa: PLC0415
4242
+
4243
+ from coord.agent import _slugify as _remote_slugify # noqa: PLC0415
4244
+
4245
+ # Mirror setup_interactive_worktree branch/worktree naming.
4246
+ _remote_branch = f"issue-{issue}-{_remote_slugify(issue_title)}"
4247
+ _remote_wt = "$HOME/.coord/worktrees/" + assignment_id
4248
+
4249
+ # repo_path may be ~/src/repo or an absolute path; replace
4250
+ # leading ~/ with $HOME/ so the shell expands it correctly.
4251
+ _rp_sh = (
4252
+ "$HOME/" + repo_path[2:]
4253
+ if repo_path.startswith("~/")
4254
+ else ("$HOME" if repo_path == "~" else repo_path)
4255
+ )
4256
+
4257
+ # Build the shell command the remote tmux session will run.
4258
+ # Tries fresh -b first (new branch from origin/default), falls
4259
+ # back to -B (force-reset) from origin/<branch> (retry case),
4260
+ # then from origin/<default> as a last resort.
4261
+ _claude_args = _shlex.join(list(argv)[1:])
4262
+ _remote_cmd = (
4263
+ f"mkdir -p $HOME/.coord/worktrees"
4264
+ f" && cd {_rp_sh}"
4265
+ f" && git fetch origin --prune 2>/dev/null || true"
4266
+ f" && git worktree prune 2>/dev/null || true"
4267
+ f" && (git worktree add -b {_remote_branch} {_remote_wt}"
4268
+ f" origin/{repo_default_branch} 2>/dev/null"
4269
+ f" || git worktree add -B {_remote_branch} {_remote_wt}"
4270
+ f" origin/{_remote_branch} 2>/dev/null"
4271
+ f" || git worktree add -B {_remote_branch} {_remote_wt}"
4272
+ f" origin/{repo_default_branch})"
4273
+ f" && cd {_remote_wt}"
4274
+ f" && COORD_ASSIGNMENT_ID={assignment_id}"
4275
+ f" {argv[0]} {_claude_args}"
4276
+ )
4277
+
4278
+ _tmux_host = TmuxHost(ssh_target=machine_obj.host)
4279
+ _sname = _tmux_name(assignment_id)
4280
+
4281
+ click.echo(
4282
+ f" remote worktree: $HOME/.coord/worktrees/{assignment_id}"
4283
+ f" on {machine_obj.host} (branch: {_remote_branch})"
4284
+ )
4285
+
4286
+ # State mutations (DB row, env var, board write) — same as local.
4287
+ record_dispatched(
4288
+ assignment_id=assignment_id,
4289
+ proposal=proposal,
4290
+ repo_github=repo_cfg.github,
4291
+ provider_name="claude-pty",
4292
+ )
4293
+ # #486d: record the remote feature branch on the assignment so a
4294
+ # later `coord reattach` can push it back (record_dispatched writes
4295
+ # from the Proposal, which carries no branch).
4296
+ try:
4297
+ from coord.state import get_connection as _gc_wb # noqa: PLC0415
4298
+ _conn_wb = _gc_wb()
4299
+ _conn_wb.execute(
4300
+ "UPDATE assignments SET branch=? WHERE assignment_id=?",
4301
+ (_remote_branch, assignment_id),
4302
+ )
4303
+ _conn_wb.commit()
4304
+ except Exception: # noqa: BLE001
4305
+ pass
4306
+
4307
+ # Set COORD_ASSIGNMENT_ID in the local coordinator env as well
4308
+ # (for symmetry with local path; the remote process gets it
4309
+ # inline via the shell command).
4310
+ os.environ["COORD_ASSIGNMENT_ID"] = assignment_id
4311
+ # #646: no --verdict for a work/plan session (review-only field).
4312
+ report_reminder = (
4313
+ f"[Coordinator assignment {assignment_id}] "
4314
+ "Before you exit, please run `coord report-result "
4315
+ f"--assignment {assignment_id} --status <done|blocked|"
4316
+ "already-implemented> --summary <text>` so the coordinator "
4317
+ "records the result.\n\n"
4318
+ )
4319
+ effective_briefing = _issue_ctx + report_reminder + briefing + _ctx_write_hint
4320
+
4321
+ write_board(read_board())
4322
+
4323
+ # Echo briefing to the LOCAL terminal before connecting to the
4324
+ # remote session, so the operator can read it before pressing
4325
+ # Enter (mirrors the tmux path in launch_human_attended_interactive).
4326
+ if effective_briefing.strip():
4327
+ _hdr = (
4328
+ "--- seeded briefing -- review below; "
4329
+ "submit the pre-filled input in Claude to send ---"
4330
+ )
4331
+ _ftr = "-" * len(_hdr)
4332
+ _preview = f"\n{_hdr}\n{effective_briefing.rstrip()}\n{_ftr}\n\n"
4333
+ try:
4334
+ os.write(sys.stdout.fileno(), _preview.encode("utf-8"))
4335
+ except OSError:
4336
+ pass
4337
+
4338
+ # Launch the remote tmux session and attach to it. Pass
4339
+ # raw_shell_cmd so _launch_via_tmux uses the verbatim shell
4340
+ # command (with $HOME paths and && operators) rather than
4341
+ # re-quoting argv through shlex.join.
4342
+ started_at = _time.time()
4343
+ _rc = _tmux_launch(
4344
+ argv,
4345
+ effective_briefing,
4346
+ _sname,
4347
+ cwd=None,
4348
+ host=_tmux_host,
4349
+ raw_shell_cmd=_remote_cmd,
4350
+ )
4351
+ if _rc is None:
4352
+ click.echo(
4353
+ f" error: could not create remote tmux session on"
4354
+ f" {machine_obj.host}",
4355
+ err=True,
4356
+ )
4357
+ sys.exit(1)
4358
+ exit_code = _rc
4359
+
4360
+ # Check if the remote session is still alive (user detached).
4361
+ if _tmux_alive(_sname, host=_tmux_host):
4362
+ click.echo(
4363
+ f" session still running in remote tmux: {_sname}\n"
4364
+ f" reattach with: coord reattach {assignment_id}\n"
4365
+ " (work commits are pushed when the session ends and the "
4366
+ "coordinator finalizes)"
4367
+ )
4368
+ sys.exit(0)
4369
+
4370
+ # ── #560: detect setup failures (worktree never created) ──────
4371
+ _wt_setup_ok_work: bool = True
4372
+ if exit_code != 0:
4373
+ if not _remote_wt_exists(machine_obj.host, _remote_wt):
4374
+ _wt_setup_ok_work = False
4375
+ _holder = _find_branch_holder(
4376
+ machine_obj.host, _rp_sh, _remote_branch,
4377
+ )
4378
+ if _holder:
4379
+ _holder_aid = Path(_holder).name
4380
+ _holder_sname = f"coord-{_holder_aid}"
4381
+ _holder_live = _tmux_alive(_holder_sname, host=_tmux_host)
4382
+ _err_header = (
4383
+ f" error: setup failed — branch {_remote_branch!r} is "
4384
+ f"already checked out at {_holder} on {machine_obj.host}."
4385
+ )
4386
+ if _holder_live:
4387
+ click.echo("\n".join([
4388
+ _err_header,
4389
+ f" active tmux session: {_holder_sname}",
4390
+ f" reattach: coord reattach {_holder_aid}",
4391
+ " exit the session first, then retry this work.",
4392
+ ]), err=True)
4393
+ else:
4394
+ # Dead holder — detect base checkout vs stale orphan (#814).
4395
+ if _holder_is_base(_holder):
4396
+ # #814: the base checkout ~/src/<repo> is on the
4397
+ # work branch. NEVER prune the base (#561) —
4398
+ # checkout the default branch to free the lock.
4399
+ click.echo(
4400
+ f" base checkout {_holder!r} is on branch"
4401
+ f" {_remote_branch!r} on {machine_obj.host}"
4402
+ f" — checking out {repo_default_branch!r}"
4403
+ f" to free it …"
4404
+ )
4405
+ _freed = _remote_free_base(
4406
+ machine_obj.host, _rp_sh, repo_default_branch,
4407
+ )
4408
+ if _freed:
4409
+ click.echo(
4410
+ " base checkout freed — retrying launch …"
4411
+ )
4412
+ _rc2 = _tmux_launch(
4413
+ argv, effective_briefing, _sname,
4414
+ cwd=None, host=_tmux_host,
4415
+ raw_shell_cmd=_remote_cmd,
4416
+ )
4417
+ if _rc2 is None:
4418
+ click.echo(
4419
+ f" error: could not create remote tmux"
4420
+ f" session on {machine_obj.host}"
4421
+ f" (retry after base-checkout free)",
4422
+ err=True,
4423
+ )
4424
+ else:
4425
+ exit_code = _rc2
4426
+ if _tmux_alive(_sname, host=_tmux_host):
4427
+ click.echo(
4428
+ f" session still running in remote"
4429
+ f" tmux: {_sname}\n"
4430
+ f" reattach with: coord reattach"
4431
+ f" {assignment_id}\n"
4432
+ " (work commits are pushed when"
4433
+ " the session ends and the"
4434
+ " coordinator finalizes)"
4435
+ )
4436
+ sys.exit(0)
4437
+ if (
4438
+ exit_code == 0
4439
+ or _remote_wt_exists(
4440
+ machine_obj.host, _remote_wt
4441
+ )
4442
+ ):
4443
+ _wt_setup_ok_work = True # retry succeeded
4444
+ else:
4445
+ click.echo("\n".join([
4446
+ _err_header,
4447
+ f" base checkout is stuck on"
4448
+ f" {_remote_branch!r} —",
4449
+ f" free it manually with:",
4450
+ f" ssh {machine_obj.host}"
4451
+ f" 'git -C {_rp_sh} checkout"
4452
+ f" {repo_default_branch}'",
4453
+ ]), err=True)
4454
+ else:
4455
+ # Dead orphan — safety-gated auto-prune-and-retry (#759).
4456
+ _auto_pruned = False
4457
+ if _remote_orphan_safe(
4458
+ machine_obj.host, _rp_sh, _holder, _remote_branch,
4459
+ ):
4460
+ click.echo(
4461
+ f" auto-pruning stale orphan {_holder}"
4462
+ f" on {machine_obj.host} …"
4463
+ )
4464
+ _auto_pruned = _remote_wt_remove(
4465
+ machine_obj.host, _rp_sh, _holder,
4466
+ )
4467
+ if _auto_pruned:
4468
+ click.echo(
4469
+ " stale orphan auto-pruned — retrying launch …"
4470
+ )
4471
+ _rc2 = _tmux_launch(
4472
+ argv, effective_briefing, _sname,
4473
+ cwd=None, host=_tmux_host,
4474
+ raw_shell_cmd=_remote_cmd,
4475
+ )
4476
+ if _rc2 is None:
4477
+ click.echo(
4478
+ f" error: could not create remote tmux"
4479
+ f" session on {machine_obj.host}"
4480
+ f" (retry after prune)",
4481
+ err=True,
4482
+ )
4483
+ else:
4484
+ exit_code = _rc2
4485
+ if _tmux_alive(_sname, host=_tmux_host):
4486
+ click.echo(
4487
+ f" session still running in remote"
4488
+ f" tmux: {_sname}\n"
4489
+ f" reattach with: coord reattach"
4490
+ f" {assignment_id}\n"
4491
+ " (work commits are pushed when"
4492
+ " the session ends and the"
4493
+ " coordinator finalizes)"
4494
+ )
4495
+ sys.exit(0)
4496
+ if (
4497
+ exit_code == 0
4498
+ or _remote_wt_exists(
4499
+ machine_obj.host, _remote_wt
4500
+ )
4501
+ ):
4502
+ _wt_setup_ok_work = True # retry succeeded
4503
+ else:
4504
+ # Not safe or prune failed → manual command.
4505
+ click.echo("\n".join([
4506
+ _err_header,
4507
+ " stale worktree — prune it first:",
4508
+ f" ssh {machine_obj.host} 'cd {_rp_sh}"
4509
+ f" && git worktree remove --force"
4510
+ f" {_shlex.quote(_holder)}'",
4511
+ ]), err=True)
4512
+ else:
4513
+ click.echo(
4514
+ f" error: setup failed — the remote worktree was "
4515
+ f"never created (git worktree add refused on "
4516
+ f"{machine_obj.host}).",
4517
+ err=True,
4518
+ )
4519
+ else:
4520
+ click.echo(
4521
+ f" claude exited with status {exit_code}", err=True,
4522
+ )
4523
+
4524
+ # Remote finalize (#486d): push the work commits to origin/<branch>,
4525
+ # record the completion (so the pipeline advances + a re-review can
4526
+ # fire), and clean up the remote worktree. Mirrors the remote-FIX
4527
+ # path; the only difference is the branch is the fresh feature
4528
+ # branch this work session created, not an existing one.
4529
+ # #982: same as the local finalize above — no pre-existing
4530
+ # smoke_tests for a fresh interactive work session, so this always
4531
+ # stashes the full repo-wide glob.
4532
+ try:
4533
+ _fr = finalize_remote_interactive_exit(
4534
+ assignment_id=assignment_id,
4535
+ repo_name=repo,
4536
+ repo_github=repo_cfg.github,
4537
+ issue_number=issue,
4538
+ machine_name=machine,
4539
+ ssh_target=machine_obj.host,
4540
+ remote_worktree_sh=_remote_wt,
4541
+ remote_repo_sh=_rp_sh,
4542
+ branch=_remote_branch,
4543
+ base_branch=repo_default_branch,
4544
+ exit_code=exit_code,
4545
+ started_at=started_at,
4546
+ artifact_paths=repo_cfg.artifact_paths,
4547
+ )
4548
+ if _fr.already_recorded:
4549
+ click.echo(
4550
+ " result recorded via `coord report-result`; remote "
4551
+ "backstop did not overwrite"
4552
+ )
4553
+ else:
4554
+ click.echo(
4555
+ f" remote backstop: status={_fr.terminal_status} "
4556
+ f"commits_ahead={_fr.commits_ahead} pushed={_fr.push_ok}"
4557
+ )
4558
+ if not _fr.push_ok and _wt_setup_ok_work:
4559
+ click.echo(
4560
+ f" warning: remote push failed: {_fr.push_error}",
4561
+ err=True,
4562
+ )
4563
+ click.echo(
4564
+ f" work commits preserved in {_remote_wt} on "
4565
+ f"{machine_obj.host} (worktree NOT removed)",
4566
+ err=True,
4567
+ )
4568
+ # else _wt_setup_ok_work is False: setup never happened; error
4569
+ # already printed above — no "commits preserved" noise.
4570
+ _echo_artifact_stash(_fr)
4571
+ except Exception as exc: # noqa: BLE001 — best-effort backstop
4572
+ click.echo(
4573
+ f" warning: remote backstop failed to record work exit: {exc}",
4574
+ err=True,
4575
+ )
4576
+
4577
+ sys.exit(exit_code)
4578
+
4579
+
4580
+ def _dispatch_headless(
4581
+ *,
4582
+ machine: str,
4583
+ repo: str,
4584
+ issue: int,
4585
+ briefing: str,
4586
+ model: str | None,
4587
+ dry_run: bool,
4588
+ plan_only: bool,
4589
+ no_plan: bool,
4590
+ force: bool,
4591
+ no_pull: bool,
4592
+ skip_freshness: bool,
4593
+ cfg: Config,
4594
+ machine_obj: object,
4595
+ repo_cfg: object,
4596
+ issue_data: dict,
4597
+ issue_title: str,
4598
+ driven_by: str | None = None,
4599
+ provider: str | None = None,
4600
+ ) -> None:
4601
+ """The plain (non --interactive) HTTP-dispatch path: build a Proposal,
4602
+ run the claim + dependency-freshness checks, POST to the agent server,
4603
+ and record + post the briefing.
4604
+
4605
+ ``driven_by`` (#1499): durable provenance threaded from ``coord assign
4606
+ --driven-by`` (set by ``coord drive``'s work-stage dispatch). ``None``
4607
+ for a hand ``coord assign`` — the overwhelming majority of callers.
4608
+
4609
+ ``provider`` (#1707): the raw ``--provider`` flag value, already
4610
+ validated against ``cfg.providers.definitions`` by ``assign()`` before
4611
+ this function runs. ``None`` (the default — no flag given) reproduces
4612
+ pre-#1707 behaviour exactly: ``Proposal.provider`` stays ``None`` and
4613
+ ``coord.dispatch.dispatch()``'s spec → repo → providers.default
4614
+ precedence chain falls through to the repo/global default unchanged.
4615
+ """
4616
+ from coord.board_service import read_board, write_board # noqa: PLC0415
4617
+ from coord.dispatch import ( # noqa: PLC0415
4618
+ DispatchRefused,
4619
+ dispatch,
4620
+ post_briefing,
4621
+ resolve_dispatch_model_alias,
4622
+ )
4623
+ from coord.providers import resolve_provider_name # noqa: PLC0415
4624
+ from coord.state import record_dispatched # noqa: PLC0415
4625
+
4626
+
4627
+ # Build a Proposal inline
4628
+ from coord.models import Proposal
4629
+
4630
+ # Resolve required_gates: check issue labels against pipeline.labels config,
4631
+ # fall back to pipeline.default_gates.
4632
+ issue_labels: list[str] = [
4633
+ lbl.get("name", "") for lbl in (issue_data.get("labels") or [])
4634
+ ]
4635
+ resolved_gates: list[str] = list(cfg.pipeline.default_gates)
4636
+ for lbl in issue_labels:
4637
+ if lbl in cfg.pipeline.labels:
4638
+ resolved_gates = list(cfg.pipeline.labels[lbl])
4639
+ break
4640
+
4641
+ # Determine effective plan-only mode.
4642
+ # --plan-only always wins; --no-plan overrides dispatch.require_plan;
4643
+ # otherwise dispatch.require_plan sets the default.
4644
+ effective_plan_only = plan_only or (cfg.dispatch.require_plan and not no_plan)
4645
+
4646
+ # #1707: resolve the effective provider BEFORE the model, so model
4647
+ # resolution can be provider-aware (#1706 review fix, below).
4648
+ # #1889: providers.labels (work dispatch only, mirrors models.labels'
4649
+ # #1430 gating) — must be resolved with the SAME issue_labels the model
4650
+ # resolution below uses, or the two could silently disagree about which
4651
+ # provider is effective.
4652
+ effective_provider_name = resolve_provider_name(
4653
+ provider, repo_cfg.provider, cfg.providers,
4654
+ issue_labels=issue_labels if not effective_plan_only else None,
4655
+ )
4656
+
4657
+ # Resolve model: --model flag → models.labels (work dispatch only) →
4658
+ # the effective provider's own pinned model (non-claude/claude-pty
4659
+ # only) → config default. #1430: plan workers are read-only/cheap and
4660
+ # must not inherit a tier:large -> opus routing meant for the eventual
4661
+ # work dispatch, so label resolution only applies when this is a work
4662
+ # dispatch, not a plan-only one. #1706: `resolve_dispatch_model_alias`
4663
+ # is the single place the provider-aware exception to `models.default`
4664
+ # lives — see its docstring. Without this, `resolved_model` was always
4665
+ # truthy by the time it reached `Proposal.model`, which meant
4666
+ # `coord.dispatch.dispatch()`'s own (also provider-aware)
4667
+ # `definition.model` fallback could never fire for a plain `coord
4668
+ # assign` — the primary scenario #1706 was meant to unblock.
4669
+ label_model, matched_label, shadowed_labels = (
4670
+ cfg.models.model_for_labels_with_reason(issue_labels)
4671
+ if not effective_plan_only
4672
+ else (None, None, [])
4673
+ )
4674
+ resolved_model = resolve_dispatch_model_alias(
4675
+ explicit_model=model,
4676
+ label_model=label_model,
4677
+ config=cfg,
4678
+ effective_provider_name=effective_provider_name,
4679
+ )
4680
+
4681
+ proposal = Proposal(
4682
+ id=0,
4683
+ machine_name=machine,
4684
+ repo_name=repo,
4685
+ issue_number=issue,
4686
+ issue_title=issue_title,
4687
+ rationale="manual assignment via coord assign",
4688
+ briefing=briefing,
4689
+ model=resolved_model,
4690
+ type="plan" if effective_plan_only else "work",
4691
+ required_gates=resolved_gates,
4692
+ issue_labels=issue_labels,
4693
+ driven_by=driven_by,
4694
+ provider=provider,
4695
+ )
4696
+
4697
+ click.echo(f"{machine} → {repo} #{issue}: {issue_title}")
4698
+ if driven_by:
4699
+ click.echo(f" driven by: {driven_by}")
4700
+ if effective_plan_only:
4701
+ if cfg.dispatch.require_plan and not plan_only:
4702
+ click.echo(" mode: plan-only (dispatch.require_plan=true; use --no-plan to override)")
4703
+ else:
4704
+ click.echo(" mode: plan-only (read-only, no worktree)")
4705
+ if resolved_model:
4706
+ # #1454: state *why* this model was picked — a silent fall-through
4707
+ # to models.default (stale/missing label) used to be indistinguishable
4708
+ # from an intentional default in this same line of output.
4709
+ from coord.config import describe_model_choice # noqa: PLC0415
4710
+
4711
+ click.echo(
4712
+ " model: "
4713
+ + describe_model_choice(
4714
+ resolved_model=resolved_model,
4715
+ explicit_reason="explicit --model" if model else None,
4716
+ matched_label=matched_label,
4717
+ shadowed_labels=shadowed_labels,
4718
+ )
4719
+ )
4720
+ else:
4721
+ # #1706/#1798: resolved_model is None when the effective provider's
4722
+ # own `providers.definitions.<name>.model` is pinned and --model
4723
+ # wasn't given — state that explicitly instead of silently printing
4724
+ # nothing, so the operator can see --model was deliberately left
4725
+ # off the wire payload in favor of the pin. #1798: this now also
4726
+ # covers the case where a label DID match (e.g. `tier:small`) but
4727
+ # lost to the pin — surface that explicitly too, since a
4728
+ # namespace-mismatched label (a Claude alias) silently losing to a
4729
+ # non-claude provider's pin is exactly the fix this issue made.
4730
+ _pinned = cfg.providers.definitions.get(effective_provider_name)
4731
+ if _pinned is not None and _pinned.model:
4732
+ _via = f"providers.definitions[{effective_provider_name!r}].model"
4733
+ if matched_label:
4734
+ click.echo(
4735
+ f" model: {_pinned.model} (via {_via}, "
4736
+ f"overriding label {matched_label!r})"
4737
+ )
4738
+ else:
4739
+ click.echo(f" model: {_pinned.model} (via {_via})")
4740
+
4741
+ # #1707: mirror the --model reasoning line above — state which link of
4742
+ # the spec (--provider) → providers.labels → repo (Repo.provider) →
4743
+ # providers.default chain (coord.providers.resolve_provider_name) won,
4744
+ # so a mixed claude/opencode fleet is legible at dispatch time
4745
+ # (including under --dry-run, before any of it is committed) instead of
4746
+ # only discoverable via coordinator.yml. #1889: issue_labels threaded
4747
+ # through so a providers.labels match (e.g. `harness:opencode`) is named
4748
+ # explicitly, not just inferred from source.
4749
+ from coord.providers import describe_provider_choice # noqa: PLC0415
4750
+
4751
+ click.echo(
4752
+ " provider: "
4753
+ + describe_provider_choice(
4754
+ spec_provider=provider,
4755
+ repo_provider=repo_cfg.provider,
4756
+ providers_cfg=cfg.providers,
4757
+ issue_labels=issue_labels if not effective_plan_only else None,
4758
+ )
4759
+ )
4760
+
4761
+ if dry_run:
4762
+ click.echo(" (dry run — not dispatched)")
4763
+ return
4764
+
4765
+ # Claim check
4766
+ from coord.claim import claim_message, find_work_claim
4767
+
4768
+ board = read_board()
4769
+ if not force:
4770
+ claim = find_work_claim(issue, repo, repo_cfg.github, board)
4771
+ if claim is not None:
4772
+ click.echo(
4773
+ f" skipping: {claim_message(claim)}",
4774
+ err=True,
4775
+ )
4776
+ sys.exit(1)
4777
+
4778
+ # #267: dependency freshness check — same machinery `coord approve`
4779
+ # uses. Default for `coord assign` is `--auto-pull` (the manual /
4780
+ # right-click dispatch path is a deliberate user action; we want it
4781
+ # to be safe by default). `--no-pull` falls back to the briefing
4782
+ # addendum; `--skip-freshness` bypasses entirely.
4783
+ # #268: `relevant_repos` covers both transitive `depends_on` (build
4784
+ # deps) and direct `reference_repos` (context).
4785
+ pull_repos: list[str] = []
4786
+ if not skip_freshness:
4787
+ from coord import freshness as _fresh # noqa: PLC0415
4788
+ from coord.network import fetch_repos # noqa: PLC0415
4789
+
4790
+ agent_repos = fetch_repos(machine_obj) or {}
4791
+
4792
+ repos_needed = _fresh.relevant_repos(proposal, cfg)
4793
+ github_heads: dict[str, str | None] = {}
4794
+ for dep_name, _kind in repos_needed:
4795
+ dep_cfg = cfg.repo(dep_name)
4796
+ if dep_cfg is None:
4797
+ github_heads[dep_name] = None
4798
+ continue
4799
+ try:
4800
+ github_heads[dep_name] = github_ops.get_default_branch_head(
4801
+ dep_cfg.github, dep_cfg.default_branch
4802
+ )
4803
+ except RuntimeError as e:
4804
+ click.echo(
4805
+ f" warning: could not get HEAD of {dep_cfg.github}: {e}",
4806
+ err=True,
4807
+ )
4808
+ github_heads[dep_name] = None
4809
+
4810
+ freshness = _fresh.dependency_freshness(
4811
+ proposal, cfg, agent_repos, github_heads
4812
+ )
4813
+ needs = _fresh.stale_or_dirty(freshness)
4814
+ if needs:
4815
+ for f in needs:
4816
+ click.echo(
4817
+ f" dependency {f.repo_name}: {f.state}"
4818
+ + (f" ({f.error})" if f.error else ""),
4819
+ )
4820
+ if not no_pull:
4821
+ pull_repos = [f.repo_name for f in needs if f.state == _fresh.STALE]
4822
+ if pull_repos:
4823
+ click.echo(f" will pull on agent before worker: {pull_repos}")
4824
+ else:
4825
+ addendum = _fresh.format_briefing_addendum(freshness)
4826
+ if addendum:
4827
+ proposal.briefing = (proposal.briefing or "") + addendum
4828
+
4829
+ # Dispatch to agent server
4830
+ try:
4831
+ response = dispatch(
4832
+ proposal, cfg, pull_repos=pull_repos, fresh_branch=force,
4833
+ )
4834
+ except httpx.HTTPError as e:
4835
+ click.echo(f" dispatch failed: {e}", err=True)
4836
+ sys.exit(1)
4837
+ except DispatchRefused as e:
4838
+ # #1844: `enforce_oracle_readiness`/`enforce_epic_dispatch_guard`
4839
+ # raise THIS (a `ValueError` subclass, caught before the generic
4840
+ # branch below) specifically because their refusal is DETERMINISTIC —
4841
+ # nothing about retrying this exact command changes the condition
4842
+ # that refused it (no acceptance slice appears, no label gets added).
4843
+ # A distinct exit code is what lets `coord drive`'s own subprocess
4844
+ # call — and, through it, `coord drive-queue`'s tick — tell this
4845
+ # apart from a crash instead of retrying a guaranteed-to-fail
4846
+ # dispatch until `max_attempts` is exhausted (the #1817 overnight
4847
+ # incident this issue is named for).
4848
+ from coord.drive import EXIT_DISPATCH_REFUSED # noqa: PLC0415
4849
+
4850
+ click.echo(f" dispatch failed: {e}", err=True)
4851
+ sys.exit(EXIT_DISPATCH_REFUSED)
4852
+ except ValueError as e:
4853
+ click.echo(f" dispatch failed: {e}", err=True)
4854
+ sys.exit(1)
4855
+
4856
+ assignment_id = response.get("id", "pending")
4857
+ click.echo(f" dispatched (assignment {assignment_id})")
4858
+
4859
+ # Record the dispatch
4860
+ record_dispatched(
4861
+ assignment_id=assignment_id,
4862
+ proposal=proposal,
4863
+ repo_github=repo_cfg.github,
4864
+ provider_name=response.get("_provider_name"),
4865
+ )
4866
+
4867
+ # Update board (read now so the briefing can see the full in-flight picture).
4868
+ board = read_board()
4869
+
4870
+ # Post briefing to GitHub
4871
+ try:
4872
+ from coord.dispatch import compute_do_not_touch
4873
+
4874
+ # #906: use board.active instead of load_dispatched() so a thin client
4875
+ # (empty local DB) still sees peer assignments running on other machines.
4876
+ # Exclude the just-dispatched assignment to avoid it listing itself.
4877
+ in_flight = [
4878
+ {"machine_name": a.machine_name, "repo_name": a.repo_name, "files_likely": a.files_allowed}
4879
+ for a in board.active
4880
+ if a.assignment_id != assignment_id
4881
+ ]
4882
+ do_not_touch = compute_do_not_touch(proposal, peers=[], in_flight=in_flight)
4883
+ post_briefing(proposal, cfg, assignment_id=assignment_id, do_not_touch=do_not_touch)
4884
+ click.echo(" briefing posted to GitHub")
4885
+ except Exception as e:
4886
+ click.echo(f" briefing post failed: {e}", err=True)
4887
+
4888
+ write_board(board)
4889
+
4890
+ # Mark session start on first dispatch of the session
4891
+ from coord.state import load_session, write_session_start
4892
+ session = load_session()
4893
+ if session is None or session.get("clean_shutdown", True):
4894
+ write_session_start()