code-coordinator 0.5.46__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. coord/worker_events.py +954 -0
coord/test_author.py ADDED
@@ -0,0 +1,1064 @@
1
+ """Dispatch a `type="test-author"` session (#931, docs/ORACLE_LOOP.md).
2
+
3
+ An independent, feature-level black-box acceptance-suite author — zero
4
+ shared context with the worker under test, the same independence principle
5
+ as the adversarial reviewer (`coord/review.py`). Authored **at the Gate-A
6
+ arch gate, before any work**, then extended **just-in-time** as each issue
7
+ firms up its slice of the surface.
8
+
9
+ Like `coord/smoke.py` / `coord/review.py` / `coord/conflict_fix.py`, this
10
+ bypasses `coord.dispatch.dispatch()` (the `Proposal`/brain-oriented path)
11
+ and POSTs directly to the agent's `/assign` endpoint. That matters here
12
+ specifically: `coord.dispatch.dispatch()` auto-forbids `tests/acceptance/`
13
+ for every proposal when the repo has an acceptance driver configured (the
14
+ seal that keeps a `type="work"` worker from editing the oracle it's
15
+ graded against) — but a test-author session's entire job IS writing there.
16
+ Bypassing `dispatch()` means that seal never applies to this type, by
17
+ construction, instead of needing a type-gated exception in a shared
18
+ hot path.
19
+
20
+ The dispatcher does NOT embed `contract.md`'s text into the worker's
21
+ briefing — no local checkout is required to dispatch, and the worker is
22
+ told, in its briefing, to read the contract from its own checkout. This
23
+ mirrors `coord.acceptance.oracle_loop_contract_block` (#945), which points
24
+ the *worker's* briefing at the same path rather than embedding the contract
25
+ text. (The coordinator itself now does fetch `contract.md`'s content
26
+ server-side, via `gate_a_signoff_status`'s #2063 sign-off check below — the
27
+ invariant this docstring is actually protecting is "never in the worker's
28
+ prompt", not "never read at all".)
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ import sys
35
+ import time
36
+ import uuid
37
+ from pathlib import Path
38
+
39
+ import click
40
+ import httpx
41
+
42
+ from coord import github_ops
43
+ from coord.acceptance import ACCEPTANCE_DIRNAME
44
+ from coord.config import Config
45
+ from coord.dispatch import AGENT_PORT, DispatchRefused
46
+ from coord.machine_pause import paused_set
47
+ from coord.milestone_dispatch import (
48
+ MilestoneDispatchError,
49
+ fetch_milestone_context,
50
+ gate_a_signoff_status,
51
+ )
52
+ from coord.models import Assignment, Machine
53
+
54
+ # The test-author never needs `gh` — every fact it needs (tracking issue,
55
+ # milestone membership, the JIT issue's title/body) is fetched by the
56
+ # coordinator and embedded in the briefing before dispatch. Mirrors
57
+ # `conflict_fix.CONFLICT_FIX_DENY_COMMANDS`'s "the coordinator owns GitHub
58
+ # interactions" rationale.
59
+ TEST_AUTHOR_DENY_COMMANDS: list[str] = [
60
+ "Bash(gh *)",
61
+ ]
62
+
63
+ TEST_AUTHOR_SYSTEM_PROMPT = """\
64
+ You are an INDEPENDENT acceptance-test author dispatched by the coordinator.
65
+
66
+ You have ZERO shared context with whoever implements this milestone's issues \
67
+ — the same independence principle as an adversarial code reviewer. You write \
68
+ to the CONTRACT, never to an implementation. Do not read, diff, or reason \
69
+ about any work branch, PR, or commit for the issue(s) in your briefing — if \
70
+ one exists, ignore it. Your tests must be derivable from the contract and \
71
+ the issue description alone. If you find yourself wanting to peek at code to \
72
+ "see what it actually does," stop — that would make you the worker grading \
73
+ its own homework, which is exactly what this assignment type exists to \
74
+ prevent.
75
+
76
+ Your job:
77
+ 1. Read the contract at the path given in your briefing \
78
+ (`tests/acceptance/ms-NN/contract.md`) from YOUR OWN checkout. If it does \
79
+ not exist, STOP and output:
80
+ STUCK: contract.md missing at <path> — Gate-A hasn't produced it yet
81
+ 1b. Also open the rendered mock(s) named `MOCKS:` in your briefing (under \
82
+ `tests/acceptance/ms-NN/mocks/`), if any exist. For a `tui-tuidriver` \
83
+ driver the `.screen` grid IS the assertion fixture (compare against it \
84
+ directly). For a `web-playwright` driver the `.html` file(s) are \
85
+ hand-authored wireframes, NOT auto-consumed fixtures — read their markup \
86
+ yourself and write your Playwright assertions (roles, visible text, \
87
+ `data-testid` locators, structure) against exactly what the mock renders. \
88
+ The mock is part of the contract; contract.md alone may not spell out \
89
+ every DOM detail.
90
+ Do not invent a contract yourself.
91
+ 1c. For a `web-playwright` driver ONLY: seed your slice with a fixture \
92
+ file, not `page.route()`. Write `tests/acceptance/ms-NN/fixtures/<name>.json` \
93
+ (schema: `coord/dashboard/fixture.py`'s module docstring — same shape as \
94
+ `tests/fixtures/board-pipeline-basic.json`, which is a worked example) and \
95
+ `playwright.acceptance.config.ts` will automatically boot a real \
96
+ `coord web --fixture` process seeded from it for your slice (#1818) — no \
97
+ `page.route()` interception needed anywhere in your spec, and no dev server \
98
+ guesswork about which endpoints exist. Write AT MOST ONE `*.json` file under \
99
+ that `fixtures/` directory; the config raises if it finds more than one. \
100
+ (The one exception in this repo is `tests/acceptance/ms-51/` — an earlier, \
101
+ deliberately-unmigrated `page.route()` slice kept as-is; do not use it as a \
102
+ template.)
103
+ 2. Author (or extend) the acceptance suite in `tests/acceptance/ms-NN/`, \
104
+ using the repo's declared driver framework (kind + run command are in your \
105
+ briefing) — the tests must be runnable by that exact command.
106
+ 2b. WIRE THE SLICE IN. If your briefing names an `ENTRY POINT:` path, that \
107
+ file is the driver's crate root and is part of your sealed surface: your \
108
+ slice does NOT execute until you register it there (for a Rust/cargo \
109
+ driver, an `include!("../../tests/acceptance/ms-NN/<slice>.rs");` line). \
110
+ ADD that line. Do not rewrite, reorder, or delete anything already in the \
111
+ file, and register nothing beyond your own new slice files. If the briefing \
112
+ says the driver has no entry point, it discovers tests by directory and \
113
+ there is nothing to wire.
114
+ 3. Update `tests/acceptance/ms-NN/manifest.(yml|json)` mapping every test id \
115
+ you added/kept to its issue number, in either accepted shape:
116
+ tests: {<test-id>: <issue-number>, ...}
117
+ or
118
+ issues: {<issue-number>: [<test-id>, ...], ...}
119
+ Merge with the existing manifest rather than clobbering it — other \
120
+ issues' slices may already be authored.
121
+ 4. Your tests MUST be RED right now (the implementation doesn't exist yet). \
122
+ Run the driver's run command yourself and confirm the new/changed tests \
123
+ fail (not error out from a missing framework hookup) — a red suite that \
124
+ doesn't even execute is not useful to the worker who inherits it. If the \
125
+ run reports ZERO tests for your ids, your slice is not wired in (see 2b) — \
126
+ that is a failure, not a pass.
127
+ 5. Do NOT touch anything outside `tests/acceptance/ms-NN/**`, with exactly \
128
+ ONE exception: the `ENTRY POINT:` file named in your briefing, and only to \
129
+ ADD the registration lines for your own slice (step 2b). You are not \
130
+ implementing, refactoring, or fixing anything else in the repo. NEVER drop \
131
+ the registration line to make your diff look narrower — an unwired slice is \
132
+ dead code that reports zero tests and fails the gate late and expensively \
133
+ (#1552).
134
+ 6. Commit and push your branch. Do not open a PR — the coordinator handles \
135
+ that.
136
+
137
+ If the contract is ambiguous or silent on a case you think matters, don't \
138
+ guess: write the test with a `// TODO(test-author): contract doesn't specify
139
+ X` comment (or the language's equivalent) rather than inventing behavior, \
140
+ and call it out in your final summary.
141
+ """
142
+
143
+ # #1173: the human-attended (`coord acceptance author --interactive`) variant
144
+ # of the system prompt above. The independence contract is UNCHANGED — same
145
+ # rules, same STOP-on-missing-contract, same "don't peek at the work branch"
146
+ # — this only tells the model an operator is attached, since that's a
147
+ # material fact about the session it would otherwise have no way to know.
148
+ TEST_AUTHOR_INTERACTIVE_SYSTEM_PROMPT = (
149
+ TEST_AUTHOR_SYSTEM_PROMPT
150
+ + "\n\nThis session is HUMAN-ATTENDED: an operator is at the keyboard "
151
+ "with you, watching your output and able to redirect you. That does "
152
+ "NOT relax anything above — you still author from the contract alone, "
153
+ "with zero shared context with the implementation. The operator is "
154
+ "here to catch a bad contract/mock read, a stuck session, or an "
155
+ "ambiguous case worth discussing out loud — not to hand you "
156
+ "implementation details or steer you toward a specific test shape "
157
+ "beyond what the contract says.\n"
158
+ )
159
+
160
+
161
+ def test_author_deny_commands(config: Config, repo_name: str) -> list[str]:
162
+ """Merge the repo's configured deny-list with :data:`TEST_AUTHOR_DENY_COMMANDS`.
163
+
164
+ Shared by :func:`dispatch_test_author` (the original dispatch) and
165
+ ``coord.auto_loop._dispatch_fix`` (a bounced test-author fix, #1176) so
166
+ the two call sites can't drift — a fix session POSTs `type="test-author"`
167
+ directly to `/assign` the same way the original dispatch does, and needs
168
+ the identical guardrails.
169
+ """
170
+ repo_cfg = config.repo(repo_name)
171
+ repo_deny = (
172
+ repo_cfg.worker_permissions.deny
173
+ if repo_cfg and repo_cfg.worker_permissions
174
+ else []
175
+ )
176
+ return list(dict.fromkeys(list(repo_deny) + TEST_AUTHOR_DENY_COMMANDS))
177
+
178
+
179
+ def pick_test_author_machine(
180
+ config: Config, repo_name: str, required_capability: str = ""
181
+ ) -> Machine | None:
182
+ """Pick a machine to run the test-author session on.
183
+
184
+ Any qualified, unpaused machine that has the repo cloned and (when the
185
+ repo's driver declares one) the required capability — mirrors
186
+ `refine_chat.pick_refinement_machine`'s "any qualified machine works"
187
+ simplicity; test-author is a one-off CLI-triggered dispatch (Gate A /
188
+ JIT), not a high-frequency auto-dispatch, so no idle/busy weighting is
189
+ needed. Unlike smoke, there's no "different from the worker" axis here
190
+ — there is no single worker machine to avoid, by design.
191
+ """
192
+ paused = paused_set(config.machines)
193
+ for m in config.machines:
194
+ if not m.can_work_on(repo_name):
195
+ continue
196
+ if m.repo_path(repo_name) is None:
197
+ continue
198
+ if m.name in paused:
199
+ continue
200
+ if required_capability and required_capability not in m.capabilities:
201
+ continue
202
+ return m
203
+ return None
204
+
205
+
206
+ def build_test_author_briefing(
207
+ *,
208
+ repo_name: str,
209
+ repo_github: str,
210
+ ms_dir: str,
211
+ tracking_issue: int,
212
+ milestone_number: int,
213
+ milestone_issue_numbers: list[int],
214
+ driver_kind: str,
215
+ driver_run: str,
216
+ issue_number: int | None,
217
+ issue_title: str | None,
218
+ issue_body: str | None,
219
+ driver_entrypoint: str = "",
220
+ driver_mock: str = "",
221
+ ) -> str:
222
+ """Compose the test-author's briefing (its first/only user message).
223
+
224
+ Two modes, matching docs/ORACLE_LOOP.md's "authored red ... before the
225
+ work, then extended just-in-time": *milestone mode* (`issue_number` is
226
+ None) authors the full initial suite from the contract; *JIT mode*
227
+ (`issue_number` set) extends just that issue's slice.
228
+
229
+ *driver_entrypoint* (#1552) is the driver's declared ``entrypoint:`` —
230
+ the crate root a slice must be registered in before the run command can
231
+ see it, for a framework that links tests through an entry point rather
232
+ than discovering them by directory. Stated explicitly in BOTH directions:
233
+ naming it authorises the one out-of-tree write the author needs, and
234
+ saying "none" stops an author on a directory-discovered route (pytest)
235
+ from inventing one.
236
+
237
+ *driver_mock* (#1542) is the driver's declared ``mock:`` glob (e.g.
238
+ ``"*.screen"``, ``"*.html"``) — named explicitly so the author knows
239
+ which files under ``mocks/`` to open. For a mock-format whose kind
240
+ treats the mock as source (``tui-tuidriver``'s ``.screen`` grid IS the
241
+ assertion fixture), this is just where to find it; for ``web-playwright``
242
+ the ``.html`` mock is a hand-authored wireframe the author must read and
243
+ transcribe into DOM assertions (see :data:`TEST_AUTHOR_SYSTEM_PROMPT`
244
+ step 1b) — contract.md alone is not guaranteed to spell out every
245
+ role/text/test-id.
246
+
247
+ For ``driver_kind == "web-playwright"`` a ``FIXTURES:`` line is also
248
+ emitted (#1818): ``playwright.acceptance.config.ts`` boots a real
249
+ ``coord web --fixture`` process seeded from
250
+ ``tests/acceptance/ms-NN/fixtures/*.json`` when present, so the author
251
+ should seed the slice that way instead of hand-rolling a
252
+ ``page.route()`` interception (see :data:`TEST_AUTHOR_SYSTEM_PROMPT`
253
+ step 1c).
254
+ """
255
+ contract_path = f"{ACCEPTANCE_DIRNAME}/{ms_dir}/contract.md"
256
+ manifest_glob = f"{ACCEPTANCE_DIRNAME}/{ms_dir}/manifest.(yml|json)"
257
+ mocks_glob = f"{ACCEPTANCE_DIRNAME}/{ms_dir}/mocks/{driver_mock}" if driver_mock else (
258
+ f"{ACCEPTANCE_DIRNAME}/{ms_dir}/mocks/ (glob not declared)"
259
+ )
260
+
261
+ parts: list[str] = []
262
+ parts.append(
263
+ f"=== Independent test-author session for {repo_github} — "
264
+ f"milestone #{milestone_number} (tracking issue #{tracking_issue}) ===\n"
265
+ )
266
+ parts.append(f"CONTRACT: {contract_path}")
267
+ parts.append(f"MOCKS: {mocks_glob}")
268
+ if driver_kind == "web-playwright":
269
+ parts.append(
270
+ f"FIXTURES: {ACCEPTANCE_DIRNAME}/{ms_dir}/fixtures/<name>.json "
271
+ "(at most one file — schema: coord/dashboard/fixture.py, worked "
272
+ "example: tests/fixtures/board-pipeline-basic.json). Seed your "
273
+ "slice with this instead of page.route() — #1818."
274
+ )
275
+ parts.append(f"MANIFEST: {manifest_glob}")
276
+ parts.append(f"DRIVER: kind={driver_kind!r} run={driver_run!r}")
277
+ if driver_entrypoint:
278
+ parts.append(f"ENTRY POINT: {driver_entrypoint}")
279
+ parts.append(
280
+ f" This driver links its slices through `{driver_entrypoint}` — "
281
+ "your slice files are INVISIBLE to the run command above until "
282
+ "they are registered there (for cargo: an "
283
+ f"`include!(\"../../{ACCEPTANCE_DIRNAME}/{ms_dir}/<slice>.rs\");` "
284
+ "line). Adding those registration lines is part of your job and "
285
+ "is explicitly allowed even though the file sits outside "
286
+ f"`{ACCEPTANCE_DIRNAME}/{ms_dir}/` — it is part of the sealed "
287
+ "oracle, declared as this driver's `entrypoint:` (#1552). ADD "
288
+ "only; do not rewrite, reorder, or delete what is already there, "
289
+ "and never remove a registration line to narrow your diff."
290
+ )
291
+ else:
292
+ parts.append(
293
+ "ENTRY POINT: (none — this driver discovers tests by directory, "
294
+ "so there is nothing to wire up and nothing to touch outside "
295
+ f"`{ACCEPTANCE_DIRNAME}/{ms_dir}/`)"
296
+ )
297
+ parts.append(
298
+ f"MILESTONE WORK-ORDER ISSUES: {milestone_issue_numbers or '(none recorded yet)'}"
299
+ )
300
+ parts.append("")
301
+
302
+ if issue_number is None:
303
+ parts.append(
304
+ "MODE: full milestone authoring (Gate A). Author the initial red "
305
+ f"acceptance suite under `{ACCEPTANCE_DIRNAME}/{ms_dir}/` covering "
306
+ "the whole black-box surface in the contract, with at least one "
307
+ "test per issue in the work-order list above, and write the "
308
+ "manifest mapping every test id to its issue number."
309
+ )
310
+ else:
311
+ parts.append(
312
+ f"MODE: just-in-time slice extension for issue #{issue_number}. "
313
+ "Extend the existing suite with tests covering ONLY this issue's "
314
+ "slice of the black-box surface — leave other issues' tests as "
315
+ "they are, and merge (don't clobber) the manifest."
316
+ )
317
+ parts.append("")
318
+ parts.append(f"ISSUE #{issue_number}: {issue_title or '(no title)'}")
319
+ parts.append(issue_body.strip() if issue_body and issue_body.strip() else "(no body)")
320
+
321
+ parts.append("")
322
+ parts.append("---")
323
+ parts.append(
324
+ "Follow the steps in your system prompt (read contract → author/"
325
+ "extend → "
326
+ + ("wire into the entry point → " if driver_entrypoint else "")
327
+ + "update manifest → verify red → commit + push, no PR)."
328
+ )
329
+ return "\n".join(parts)
330
+
331
+
332
+ def dispatch_test_author(
333
+ repo_name: str,
334
+ tracking_issue: int,
335
+ config: Config,
336
+ *,
337
+ issue_number: int | None = None,
338
+ machine_override: str | None = None,
339
+ path: str | None = None,
340
+ http_client: httpx.Client | None = None,
341
+ ) -> tuple[str, str]:
342
+ """End-to-end: resolve the milestone, pick a machine, seed the
343
+ briefing, dispatch a `type="test-author"` assignment.
344
+
345
+ *path* (#1125, repo-root-relative, e.g. ``"coord/foo.py"``) resolves
346
+ which driver to use when the repo's acceptance config is routed
347
+ (``acceptance.drivers.<repo>.routes``) — pass the milestone/issue's
348
+ representative subtree (see `AcceptanceConfig.driver_for` for the
349
+ single-path-per-call resolution rule). Unused (and unneeded) when the
350
+ repo has a flat, unrouted driver.
351
+
352
+ Returns `(assignment_id, machine_name)`. Raises `RuntimeError` on any
353
+ resolution failure (unknown repo, no acceptance driver configured (or,
354
+ for a routed repo, no driver resolves for *path*), bad tracking issue,
355
+ `issue_number` not a member of the milestone's work order, no qualified
356
+ machine, or the agent rejecting the dispatch) — EXCEPT the #2063 Gate-A
357
+ sign-off refusal (contract exists but carries no recorded human
358
+ verdict), which raises `coord.dispatch.DispatchRefused` (a `ValueError`
359
+ subclass) instead, so callers can tell a deterministic, operator-fixable
360
+ refusal apart from every other, non-recoverable failure mode here.
361
+
362
+ Branch: milestone mode (`issue_number=None`) shares one branch/PR across
363
+ repeated calls, keyed on *tracking_issue*. JIT mode (`issue_number` set)
364
+ gets its own per-slice branch keyed on `(tracking_issue, issue_number)`
365
+ — required so each member issue's slice can merge independently without
366
+ stranding the next slice on an already-closed PR (#1171).
367
+ """
368
+ repo_cfg = config.repo(repo_name)
369
+ if repo_cfg is None:
370
+ raise RuntimeError(f"repo {repo_name!r} not in coordinator.yml")
371
+
372
+ driver_cfg = config.acceptance.driver_for(repo_name, path)
373
+ if driver_cfg is None:
374
+ if config.acceptance.has_driver(repo_name):
375
+ raise RuntimeError(
376
+ f"repo {repo_name!r} has a routed acceptance driver "
377
+ "(acceptance.drivers routes) but no route matched — pass "
378
+ "--for-path to select the milestone's subtree (e.g. "
379
+ "'coord/**')"
380
+ )
381
+ raise RuntimeError(
382
+ f"no acceptance driver configured for repo {repo_name!r} "
383
+ "(add it under acceptance.drivers in coordinator.yml)"
384
+ )
385
+
386
+ try:
387
+ ctx = fetch_milestone_context(repo_cfg, tracking_issue)
388
+ except MilestoneDispatchError as e:
389
+ raise RuntimeError(str(e)) from e
390
+
391
+ if issue_number is not None and ctx.work_order.node(issue_number) is None:
392
+ raise RuntimeError(
393
+ f"issue #{issue_number} is not a member of milestone "
394
+ f"#{ctx.milestone_number}'s work order (tracking issue #{tracking_issue})"
395
+ )
396
+
397
+ # #2063: refuse to author a sealed slice against a contract no human has
398
+ # signed off on. This is the path that actually burned money on
399
+ # coord-portal ms-2 — an independent test-author authored ~$2.70 of
400
+ # sealed suite against an unapproved surface, and it happened to be a
401
+ # good contract by luck, not process. The strings a contract pins
402
+ # (button text, data-testid hooks, status vocabulary) become assertions
403
+ # the worker may never edit, so a wrong one is not cosmetic: it is a
404
+ # suite that enforces the wrong product.
405
+ #
406
+ # Raised as `DispatchRefused` (a `ValueError` subclass), NOT a plain
407
+ # `RuntimeError` like every other failure mode in this function: this
408
+ # refusal is deterministic and operator-fixable (`coord gate-a
409
+ # --approved`), not a crash, and `coord acceptance author`'s CLI handler
410
+ # maps THIS exception — and only this one — to `EXIT_DISPATCH_REFUSED`
411
+ # so `coord drive-queue`'s tick can park the entry (#1891/#1892) instead
412
+ # of burning attempts toward terminal `blocked` (#2040) on an unapproved
413
+ # contract. Same distinguishing pattern `enforce_oracle_readiness` (the
414
+ # Work-dispatch guard this mirrors) already uses in `coord/dispatch.py`.
415
+ signoff_refusal = gate_a_signoff_status(repo_cfg, config, ctx.milestone_number)
416
+ if signoff_refusal is not None:
417
+ raise DispatchRefused(signoff_refusal)
418
+
419
+ if machine_override:
420
+ machine = next(
421
+ (m for m in config.machines if m.name == machine_override), None
422
+ )
423
+ if machine is None:
424
+ raise RuntimeError(f"machine {machine_override!r} not in coordinator.yml")
425
+ if not machine.can_work_on(repo_name):
426
+ raise RuntimeError(
427
+ f"machine {machine_override!r} does not list repo {repo_name!r}"
428
+ )
429
+ else:
430
+ machine = pick_test_author_machine(config, repo_name, driver_cfg.capability)
431
+ if machine is None:
432
+ cap_note = (
433
+ f" with capability {driver_cfg.capability!r}"
434
+ if driver_cfg.capability else ""
435
+ )
436
+ raise RuntimeError(
437
+ f"no machine claims repo {repo_name!r}{cap_note} — "
438
+ "test-author needs a machine with the repo cloned"
439
+ + (" and the driver's required capability" if cap_note else "")
440
+ )
441
+
442
+ repo_path = machine.repo_path(repo_name)
443
+ if repo_path is None:
444
+ raise RuntimeError(
445
+ f"machine {machine.name!r} has no repo_path for {repo_name!r}"
446
+ )
447
+
448
+ issue_title: str | None = None
449
+ issue_body: str | None = None
450
+ if issue_number is not None:
451
+ try:
452
+ issue_data = github_ops.get_issue(repo_cfg.github, issue_number)
453
+ except RuntimeError as e:
454
+ raise RuntimeError(f"could not fetch #{issue_number}: {e}") from e
455
+ issue_title = issue_data.get("title") or ""
456
+ issue_body = issue_data.get("body") or ""
457
+
458
+ ms_dir = f"ms-{ctx.milestone_number}"
459
+ briefing = build_test_author_briefing(
460
+ repo_name=repo_name,
461
+ repo_github=repo_cfg.github,
462
+ ms_dir=ms_dir,
463
+ tracking_issue=tracking_issue,
464
+ milestone_number=ctx.milestone_number,
465
+ milestone_issue_numbers=list(ctx.work_order.issue_numbers),
466
+ driver_kind=driver_cfg.kind,
467
+ driver_run=driver_cfg.run,
468
+ driver_entrypoint=driver_cfg.entrypoint,
469
+ driver_mock=driver_cfg.mock,
470
+ issue_number=issue_number,
471
+ issue_title=issue_title,
472
+ issue_body=issue_body,
473
+ )
474
+
475
+ # #1171: milestone-mode dispatches (issue_number is None) keep a single
476
+ # FIXED assignment title so repeated calls derive the SAME shared branch
477
+ # (issue-{tracking_issue}-{slug(title)}, see AgentServer._setup_worktree)
478
+ # — that's Gate A's "extend the same in-flight suite" case, and the
479
+ # branch's PR does not merge until the whole milestone's suite is ready.
480
+ #
481
+ # JIT slices (issue_number set) must NOT reuse that shared branch: each
482
+ # member issue's slice needs its own PR because #1138's oracle gate
483
+ # merges it to unblock that issue's own Work — so the shared branch's PR
484
+ # is guaranteed closed before the next slice is authored, silently
485
+ # stranding it (#1171). Key the branch on (milestone, member issue)
486
+ # instead via an explicit `target_branch`, deliberately OUTSIDE the
487
+ # `issue-{N}-*` namespace: if this PR is squash-merged (ancestry breaks)
488
+ # and deleteBranchOnMerge=false lets the branch survive, an
489
+ # `issue-{issue_number}-*` name would false-positive `coord.claim`'s
490
+ # remote-branch check for that same member issue's own Work dispatch —
491
+ # re-wedging the exact stall this fix removes. A retry/continuation for
492
+ # the SAME (tracking_issue, issue_number) pair still resolves to the
493
+ # same branch name, so extending an already-authored slice keeps
494
+ # pushing to its own still-open PR rather than forking a new one.
495
+ if issue_number is None:
496
+ assignment_title = f"[test-author] ms-{ctx.milestone_number} acceptance suite"
497
+ target_branch: str | None = None
498
+ else:
499
+ assignment_title = (
500
+ f"[test-author] ms-{ctx.milestone_number} slice #{issue_number}"
501
+ )
502
+ target_branch = f"test-author-ms-{ctx.milestone_number}-slice-{issue_number}"
503
+
504
+ # #1172: resolve the branch this dispatch would push onto UP FRONT (same
505
+ # formula `AgentServer._setup_worktree` / the post-dispatch recording
506
+ # below use) and fail loudly if its PR has already merged, instead of
507
+ # silently dispatching a worker whose commits would land on a dead
508
+ # branch with no open PR for review/merge to ever pick up (#947/#1115 —
509
+ # a day-long invisible strand). This is defence-in-depth on top of
510
+ # #1171's branch-per-slice fix: it also catches a *retry* of the SAME
511
+ # (tracking_issue, issue_number) pair after that slice's own PR already
512
+ # merged (e.g. via #1138's oracle gate), and a stale milestone-mode
513
+ # dispatch after Gate A's shared-suite PR merged out from under it.
514
+ from coord.agent import _slugify # noqa: PLC0415
515
+
516
+ branch = target_branch or f"issue-{tracking_issue}-{_slugify(assignment_title)}"
517
+
518
+ if github_ops.pr_is_merged(repo_cfg.github, branch):
519
+ raise RuntimeError(
520
+ f"branch {branch!r} already has a merged PR — dispatching would "
521
+ "push new commits onto a dead branch with nothing left to open "
522
+ "a PR against them (#1172). "
523
+ + (
524
+ f"Issue #{issue_number}'s JIT slice already landed; if it "
525
+ "needs more tests, this needs a fresh branch (not "
526
+ "auto-forked yet) — do not retry as-is."
527
+ if issue_number is not None else
528
+ "The milestone's Gate-A suite PR already merged; if the "
529
+ "suite needs more work, open a fresh branch by hand — do "
530
+ "not retry as-is."
531
+ )
532
+ )
533
+
534
+ deny_commands = test_author_deny_commands(config, repo_name)
535
+
536
+ payload = {
537
+ "repo_name": repo_name,
538
+ "repo_path": repo_path,
539
+ "issue_number": tracking_issue,
540
+ "issue_title": assignment_title,
541
+ "briefing": briefing,
542
+ "files_allowed": [],
543
+ "files_forbidden": [],
544
+ "pull_repos": [],
545
+ "type": "test-author",
546
+ "system_prompt": TEST_AUTHOR_SYSTEM_PROMPT,
547
+ "deny_commands": deny_commands,
548
+ "branch": repo_cfg.default_branch or "main",
549
+ }
550
+ if target_branch:
551
+ payload["target_branch"] = target_branch
552
+
553
+ url = f"http://{machine.host}:{AGENT_PORT}/assign"
554
+ client = http_client or httpx
555
+ resp = client.post(url, json=payload, timeout=15)
556
+ resp.raise_for_status()
557
+ agent_response = resp.json()
558
+
559
+ assignment_id = agent_response.get("id") or uuid.uuid4().hex[:12]
560
+
561
+ # #1171: record the deterministic branch name up front (mirrors #706's
562
+ # `state._record_dispatched_local`) instead of leaving it NULL for
563
+ # `reconcile.py`'s `issue-{tracking_issue}-*` backfill sweep (#1083) to
564
+ # guess later — that sweep's prefix search can never find a JIT slice's
565
+ # `target_branch` (deliberately outside the `issue-{N}-*` namespace, see
566
+ # above), so the branch must be known at dispatch time here. (`branch`
567
+ # itself was already resolved above, ahead of the POST, for the #1172
568
+ # merged-PR guard.)
569
+
570
+ asg = Assignment(
571
+ machine_name=machine.name,
572
+ repo_name=repo_name,
573
+ issue_number=tracking_issue,
574
+ issue_title=assignment_title,
575
+ files_allowed=[],
576
+ files_forbidden=[],
577
+ briefing=briefing,
578
+ assignment_id=assignment_id,
579
+ status="running",
580
+ dispatched_at=time.time(),
581
+ branch=branch,
582
+ type="test-author",
583
+ # #1084: correlate this JIT dispatch back to the specific member
584
+ # issue it's extending, so the TUI's per-issue Acceptance-Authoring
585
+ # mini-pipeline can tell "issue #1039's slice" apart from a sibling
586
+ # issue's slice sharing the same tracking-issue-keyed assignment
587
+ # row. None in milestone mode (issue_number is None) — Gate A's own
588
+ # mock-author track doesn't need this field.
589
+ for_issue_number=issue_number,
590
+ )
591
+
592
+ from coord.state import record_dispatched_assignment # noqa: PLC0415
593
+
594
+ record_dispatched_assignment(assignment=asg, repo_github=repo_cfg.github)
595
+
596
+ return assignment_id, machine.name
597
+
598
+
599
+ def dispatch_test_author_interactive(
600
+ repo_name: str,
601
+ tracking_issue: int,
602
+ config: Config,
603
+ *,
604
+ issue_number: int | None = None,
605
+ machine_override: str | None = None,
606
+ path: str | None = None,
607
+ dry_run: bool = False,
608
+ ) -> int:
609
+ """Human-attended counterpart to :func:`dispatch_test_author` (#1173,
610
+ ``coord acceptance author --interactive``).
611
+
612
+ Reuses the SAME interactive-launch machinery every other attended stage
613
+ uses, instead of rebuilding any of it:
614
+
615
+ * :func:`coord.commands.dispatch._build_interactive_launch_setup` for the
616
+ shared ``ClaudePtyProvider`` / local-vs-remote detection / per-issue
617
+ context digest (#603) that every ``coord assign --interactive`` flavour
618
+ shares — including its #2086 ``_require_interactive_tty`` gate, which
619
+ refuses to claim/record this assignment when stdin is not a TTY (and
620
+ this isn't a dry run), the same protection ``coord assign
621
+ --interactive`` gets.
622
+ * :func:`coord.agent.setup_interactive_worktree` (local) / a raw
623
+ ``git worktree add`` shell command over ssh+tmux (remote) — the same
624
+ primitives :func:`~coord.commands.dispatch_workers._dispatch_rework_of`
625
+ uses to land on a NAMED branch rather than a fresh one.
626
+ * :func:`coord.interactive.launch_human_attended_interactive` /
627
+ :func:`~coord.interactive.finalize_interactive_exit` /
628
+ :func:`~coord.interactive.finalize_remote_interactive_exit` for the
629
+ actual PTY/tmux attach and the #466 git-floor completion backstop.
630
+
631
+ The session lands on the EXACT same derived branch a headless dispatch
632
+ of the same milestone/JIT slice would use — ``issue-{tracking_issue}-
633
+ {slug(assignment_title)}``, the same derivation
634
+ :meth:`coord.agent.AgentServer._setup_worktree` uses for
635
+ ``type="test-author"`` — so headless and interactive test-author
636
+ dispatches continue the SAME branch/PR rather than forking one each.
637
+
638
+ Keeps the independence seal unchanged: the briefing content is built by
639
+ the SAME :func:`build_test_author_briefing` the headless path uses, and
640
+ the system prompt is :data:`TEST_AUTHOR_INTERACTIVE_SYSTEM_PROMPT` — the
641
+ headless prompt plus a one-paragraph "an operator is watching" note.
642
+ ``--interactive`` changes who supervises the authoring, never who writes
643
+ the tests or what they may read.
644
+
645
+ Records a ``type="test-author"`` :class:`~coord.models.Assignment` with
646
+ ``provider_name="claude-pty"`` BEFORE launching (mirrors every other
647
+ interactive flavour) so the board always reflects the in-flight session.
648
+ That field is also what keeps this row out of automatic headless review
649
+ dispatch (#555's generic ``provider_name != "claude-pty"`` guard in
650
+ :func:`coord.review.dispatch_pending_reviews` — ``test-author`` is
651
+ already in :data:`coord.models.WORK_LIKE_TYPES`, so no type-specific
652
+ exclusion was needed). The explicit human-attended handoffs
653
+ (``coord review``, ``coord assign --interactive --review-of/--merge-of``)
654
+ pick this row up exactly like an interactive ``work`` completion — same
655
+ board membership, same ``.branch`` — so there's no new stall to plumb
656
+ around, only the same "human drives Test→Review→Merge" path #1173 asks
657
+ for.
658
+
659
+ Returns the child process's exit code (``0`` on a clean exit, or on a
660
+ dry run). Raises :class:`RuntimeError` on any resolution failure — the
661
+ same failure modes as :func:`dispatch_test_author` (unknown repo/driver/
662
+ milestone/issue-membership/machine), plus a worktree/remote-launch setup
663
+ failure (mirrors ``--rework-of``'s #618 failure-reason backstop: the
664
+ reason is recorded on the assignment row before the error is raised, so
665
+ the TUI can explain a red box with no log file) — EXCEPT the #2063
666
+ Gate-A sign-off refusal, which (as in :func:`dispatch_test_author`)
667
+ raises :class:`coord.dispatch.DispatchRefused` instead, so `coord
668
+ acceptance author --interactive`'s CLI handler can map it to
669
+ ``EXIT_DISPATCH_REFUSED``.
670
+ """
671
+ from coord.agent import ( # noqa: PLC0415
672
+ AssignmentSpec,
673
+ _GitError,
674
+ _slugify,
675
+ setup_interactive_worktree,
676
+ )
677
+ from coord.interactive import ( # noqa: PLC0415
678
+ TmuxHost,
679
+ _launch_via_tmux,
680
+ finalize_interactive_exit,
681
+ finalize_remote_interactive_exit,
682
+ launch_human_attended_interactive,
683
+ tmux_available,
684
+ tmux_session_alive,
685
+ tmux_session_name,
686
+ )
687
+ from coord.state import ( # noqa: PLC0415
688
+ build_board,
689
+ record_dispatched_assignment,
690
+ save_board,
691
+ set_assignment_failure_reason,
692
+ )
693
+
694
+ repo_cfg = config.repo(repo_name)
695
+ if repo_cfg is None:
696
+ raise RuntimeError(f"repo {repo_name!r} not in coordinator.yml")
697
+
698
+ driver_cfg = config.acceptance.driver_for(repo_name, path)
699
+ if driver_cfg is None:
700
+ if config.acceptance.has_driver(repo_name):
701
+ raise RuntimeError(
702
+ f"repo {repo_name!r} has a routed acceptance driver "
703
+ "(acceptance.drivers routes) but no route matched — pass "
704
+ "--for-path to select the milestone's subtree (e.g. "
705
+ "'coord/**')"
706
+ )
707
+ raise RuntimeError(
708
+ f"no acceptance driver configured for repo {repo_name!r} "
709
+ "(add it under acceptance.drivers in coordinator.yml)"
710
+ )
711
+
712
+ try:
713
+ ctx = fetch_milestone_context(repo_cfg, tracking_issue)
714
+ except MilestoneDispatchError as e:
715
+ raise RuntimeError(str(e)) from e
716
+
717
+ if issue_number is not None and ctx.work_order.node(issue_number) is None:
718
+ raise RuntimeError(
719
+ f"issue #{issue_number} is not a member of milestone "
720
+ f"#{ctx.milestone_number}'s work order (tracking issue #{tracking_issue})"
721
+ )
722
+
723
+ # #2063: refuse to author a sealed slice against a contract no human has
724
+ # signed off on. This is the path that actually burned money on
725
+ # coord-portal ms-2 — an independent test-author authored ~$2.70 of
726
+ # sealed suite against an unapproved surface, and it happened to be a
727
+ # good contract by luck, not process. The strings a contract pins
728
+ # (button text, data-testid hooks, status vocabulary) become assertions
729
+ # the worker may never edit, so a wrong one is not cosmetic: it is a
730
+ # suite that enforces the wrong product.
731
+ #
732
+ # Raised as `DispatchRefused` (a `ValueError` subclass), NOT a plain
733
+ # `RuntimeError` like every other failure mode in this function: this
734
+ # refusal is deterministic and operator-fixable (`coord gate-a
735
+ # --approved`), not a crash, and `coord acceptance author`'s CLI handler
736
+ # maps THIS exception — and only this one — to `EXIT_DISPATCH_REFUSED`
737
+ # so `coord drive-queue`'s tick can park the entry (#1891/#1892) instead
738
+ # of burning attempts toward terminal `blocked` (#2040) on an unapproved
739
+ # contract. Same distinguishing pattern `enforce_oracle_readiness` (the
740
+ # Work-dispatch guard this mirrors) already uses in `coord/dispatch.py`.
741
+ signoff_refusal = gate_a_signoff_status(repo_cfg, config, ctx.milestone_number)
742
+ if signoff_refusal is not None:
743
+ raise DispatchRefused(signoff_refusal)
744
+
745
+ if machine_override:
746
+ machine = next(
747
+ (m for m in config.machines if m.name == machine_override), None
748
+ )
749
+ if machine is None:
750
+ raise RuntimeError(f"machine {machine_override!r} not in coordinator.yml")
751
+ if not machine.can_work_on(repo_name):
752
+ raise RuntimeError(
753
+ f"machine {machine_override!r} does not list repo {repo_name!r}"
754
+ )
755
+ else:
756
+ machine = pick_test_author_machine(config, repo_name, driver_cfg.capability)
757
+ if machine is None:
758
+ cap_note = (
759
+ f" with capability {driver_cfg.capability!r}"
760
+ if driver_cfg.capability else ""
761
+ )
762
+ raise RuntimeError(
763
+ f"no machine claims repo {repo_name!r}{cap_note} — "
764
+ "test-author needs a machine with the repo cloned"
765
+ + (" and the driver's required capability" if cap_note else "")
766
+ )
767
+
768
+ repo_path_cfg = machine.repo_path(repo_name)
769
+ if repo_path_cfg is None:
770
+ raise RuntimeError(
771
+ f"machine {machine.name!r} has no repo_path for {repo_name!r}"
772
+ )
773
+
774
+ issue_title: str | None = None
775
+ issue_body: str | None = None
776
+ if issue_number is not None:
777
+ try:
778
+ issue_data = github_ops.get_issue(repo_cfg.github, issue_number)
779
+ except RuntimeError as e:
780
+ raise RuntimeError(f"could not fetch #{issue_number}: {e}") from e
781
+ issue_title = issue_data.get("title") or ""
782
+ issue_body = issue_data.get("body") or ""
783
+
784
+ ms_dir = f"ms-{ctx.milestone_number}"
785
+ briefing = build_test_author_briefing(
786
+ repo_name=repo_name,
787
+ repo_github=repo_cfg.github,
788
+ ms_dir=ms_dir,
789
+ tracking_issue=tracking_issue,
790
+ milestone_number=ctx.milestone_number,
791
+ milestone_issue_numbers=list(ctx.work_order.issue_numbers),
792
+ driver_kind=driver_cfg.kind,
793
+ driver_run=driver_cfg.run,
794
+ driver_entrypoint=driver_cfg.entrypoint,
795
+ driver_mock=driver_cfg.mock,
796
+ issue_number=issue_number,
797
+ issue_title=issue_title,
798
+ issue_body=issue_body,
799
+ )
800
+
801
+ # Same FIXED title as the headless dispatch (see dispatch_test_author's
802
+ # comment above `assignment_title`) so both modes derive the SAME
803
+ # branch name and continue the same branch/PR across repeated dispatches.
804
+ assignment_title = f"[test-author] ms-{ctx.milestone_number} acceptance suite"
805
+ repo_deny = repo_cfg.worker_permissions.deny if repo_cfg.worker_permissions else []
806
+ deny_commands = list(dict.fromkeys(list(repo_deny) + TEST_AUTHOR_DENY_COMMANDS))
807
+ default_branch = repo_cfg.default_branch or "main"
808
+ branch_name = f"issue-{tracking_issue}-{_slugify(assignment_title)}"
809
+
810
+ from coord.commands.dispatch import _build_interactive_launch_setup # noqa: PLC0415
811
+
812
+ setup = _build_interactive_launch_setup(
813
+ machine=machine.name, repo=repo_name, issue=tracking_issue, machine_obj=machine,
814
+ dry_run=dry_run,
815
+ )
816
+ provider = setup.provider
817
+ is_local = setup.is_local
818
+ issue_ctx = setup.issue_ctx
819
+ svc = setup.svc
820
+
821
+ if is_local:
822
+ ta_repo_path = str(Path(repo_path_cfg).expanduser())
823
+ else:
824
+ ta_repo_path = repo_path_cfg
825
+
826
+ resolved_model = config.models.default
827
+ assignment_id = uuid.uuid4().hex[:12]
828
+
829
+ scope = f"issue #{issue_number} slice" if issue_number is not None else "full milestone"
830
+ report_reminder = (
831
+ f"[Coordinator test-author assignment {assignment_id}] HUMAN-ATTENDED "
832
+ f"interactive test-authoring ({scope}) for {repo_cfg.github} milestone "
833
+ f"#{ctx.milestone_number} (tracking issue #{tracking_issue}). Before "
834
+ f"you exit, run `coord report-result --assignment {assignment_id} "
835
+ "--status <done|blocked> --summary <text>` so the coordinator "
836
+ "records the result.\n\n"
837
+ )
838
+ effective_briefing = issue_ctx + report_reminder + briefing
839
+
840
+ spec = AssignmentSpec(
841
+ repo_name=repo_name,
842
+ repo_path=ta_repo_path,
843
+ issue_number=tracking_issue,
844
+ issue_title=assignment_title,
845
+ briefing=effective_briefing,
846
+ model=resolved_model,
847
+ type="test-author",
848
+ provider="claude-pty",
849
+ system_prompt=TEST_AUTHOR_INTERACTIVE_SYSTEM_PROMPT,
850
+ deny_commands=deny_commands,
851
+ )
852
+ argv = provider.build_command(spec, resolved_model=resolved_model)
853
+ # Remote: a bare "claude" is not on the SSH login PATH (#424/#425).
854
+ if not is_local:
855
+ argv = ["~/.local/bin/claude"] + list(argv)[1:]
856
+
857
+ location = "local TTY" if is_local else f"{machine.host} (remote tmux)"
858
+ click.echo(
859
+ f"{machine.name} ({location}) → TEST-AUTHOR for {repo_cfg.github} "
860
+ f"milestone #{ctx.milestone_number} (tracking issue #{tracking_issue}, "
861
+ f"{scope})"
862
+ )
863
+ click.echo(" mode: HUMAN-ATTENDED interactive test-authoring (#1173)")
864
+ click.echo(f" assignment id: {assignment_id} (branch: {branch_name})")
865
+
866
+ if dry_run:
867
+ click.echo(" (dry run — not launched)")
868
+ click.echo(f" would exec: {argv}")
869
+ return 0
870
+
871
+ ta_assignment = Assignment(
872
+ machine_name=machine.name,
873
+ repo_name=repo_name,
874
+ issue_number=tracking_issue,
875
+ issue_title=assignment_title,
876
+ briefing=effective_briefing,
877
+ assignment_id=assignment_id,
878
+ status="running",
879
+ branch=branch_name,
880
+ dispatched_at=time.time(),
881
+ type="test-author",
882
+ for_issue_number=issue_number,
883
+ model=resolved_model,
884
+ provider_name="claude-pty",
885
+ )
886
+ record_dispatched_assignment(assignment=ta_assignment, repo_github=repo_cfg.github)
887
+ if svc is None:
888
+ save_board(build_board())
889
+ os.environ["COORD_ASSIGNMENT_ID"] = assignment_id
890
+
891
+ if is_local:
892
+ try:
893
+ wt_path, _ = setup_interactive_worktree(
894
+ Path(ta_repo_path),
895
+ issue_number=tracking_issue,
896
+ issue_title=assignment_title,
897
+ assignment_id=assignment_id,
898
+ default_branch=default_branch,
899
+ existing_branch=branch_name,
900
+ )
901
+ worktree_path = str(wt_path)
902
+ except (_GitError, OSError) as wt_err:
903
+ reason = f"worktree-add failed for branch {branch_name}: {wt_err}"
904
+ click.echo(f" error: {reason}", err=True)
905
+ set_assignment_failure_reason(assignment_id, reason)
906
+ raise RuntimeError(reason) from wt_err
907
+ click.echo(f" worktree: {worktree_path} (branch: {branch_name})")
908
+
909
+ started_at = time.time()
910
+ exit_code = launch_human_attended_interactive(
911
+ argv, effective_briefing, assignment_id=assignment_id, cwd=worktree_path,
912
+ )
913
+ if exit_code != 0:
914
+ click.echo(f" claude exited with status {exit_code}", err=True)
915
+
916
+ sname = tmux_session_name(assignment_id) if tmux_available() else None
917
+ if sname and tmux_session_alive(sname):
918
+ click.echo(
919
+ f" session still running in tmux: {sname}\n"
920
+ f" reattach with: coord reattach {assignment_id}"
921
+ )
922
+ return 0
923
+
924
+ try:
925
+ finalize_result = finalize_interactive_exit(
926
+ assignment_id=assignment_id,
927
+ repo_name=repo_name,
928
+ repo_github=repo_cfg.github,
929
+ issue_number=tracking_issue,
930
+ machine_name=machine.name,
931
+ worktree_path=worktree_path,
932
+ base_branch=default_branch,
933
+ exit_code=exit_code,
934
+ started_at=started_at,
935
+ log_path=None,
936
+ repo_path=ta_repo_path,
937
+ branch=branch_name,
938
+ )
939
+ if finalize_result.already_recorded:
940
+ click.echo(
941
+ " result recorded via `coord report-result`; backstop "
942
+ "did not overwrite"
943
+ )
944
+ else:
945
+ click.echo(
946
+ f" backstop: status={finalize_result.terminal_status} "
947
+ f"commits_ahead={finalize_result.commits_ahead}"
948
+ )
949
+ if not finalize_result.push_ok:
950
+ click.echo(
951
+ f" warning: git push failed: {finalize_result.push_error}",
952
+ err=True,
953
+ )
954
+ except Exception as exc: # noqa: BLE001 — best-effort backstop
955
+ click.echo(
956
+ f" warning: backstop failed to record test-author exit: {exc}",
957
+ err=True,
958
+ )
959
+ return exit_code
960
+
961
+ # ── REMOTE (#1173) ──────────────────────────────────────────────────
962
+ # Mirrors _dispatch_rework_of's remote shape (named-branch continuation
963
+ # + finalize_remote_interactive_exit) but WITHOUT its holder-detection
964
+ # retry maze (#759/#814) — test-author dispatch is a low-frequency Gate-A
965
+ # / JIT call, not the hot auto-loop path, so a branch/worktree collision
966
+ # is far less likely than for --rework-of's "resume a specific in-flight
967
+ # session" case. Lift that block from dispatch_workers._dispatch_rework_of
968
+ # if this turns out to need it.
969
+ import shlex
970
+
971
+ remote_wt = "$HOME/.coord/worktrees/" + assignment_id
972
+ rp_sh = (
973
+ "$HOME/" + ta_repo_path[2:]
974
+ if ta_repo_path.startswith("~/")
975
+ else ("$HOME" if ta_repo_path == "~" else ta_repo_path)
976
+ )
977
+ claude_args = shlex.join(list(argv)[1:])
978
+ br_q = shlex.quote(branch_name)
979
+ orig_ref = shlex.quote(f"origin/{branch_name}")
980
+ remote_cmd = (
981
+ f"mkdir -p $HOME/.coord/worktrees"
982
+ f" && cd {rp_sh}"
983
+ f" && git fetch origin --prune 2>/dev/null || true"
984
+ f" && git worktree prune 2>/dev/null || true"
985
+ f" && (git worktree add -B {br_q} {remote_wt} {orig_ref} 2>/dev/null"
986
+ f" || git worktree add -b {br_q} {remote_wt} origin/{default_branch})"
987
+ f" && cd {remote_wt}"
988
+ f" && COORD_ASSIGNMENT_ID={assignment_id} {argv[0]} {claude_args}"
989
+ )
990
+ tmux_host = TmuxHost(ssh_target=machine.host)
991
+ sname = tmux_session_name(assignment_id)
992
+ click.echo(
993
+ f" remote worktree: $HOME/.coord/worktrees/{assignment_id} on "
994
+ f"{machine.host} (branch: {branch_name})"
995
+ )
996
+
997
+ if effective_briefing.strip():
998
+ hdr = (
999
+ "--- seeded briefing -- review below; "
1000
+ "submit the pre-filled input in Claude to send ---"
1001
+ )
1002
+ ftr = "-" * len(hdr)
1003
+ preview = f"\n{hdr}\n{effective_briefing.rstrip()}\n{ftr}\n\n"
1004
+ try:
1005
+ os.write(sys.stdout.fileno(), preview.encode("utf-8"))
1006
+ except OSError:
1007
+ pass
1008
+
1009
+ started_at = time.time()
1010
+ rc = _launch_via_tmux(
1011
+ argv, effective_briefing, sname, cwd=None, host=tmux_host,
1012
+ raw_shell_cmd=remote_cmd,
1013
+ )
1014
+ if rc is None:
1015
+ reason = f"could not create remote tmux session on {machine.host}"
1016
+ click.echo(f" error: {reason}", err=True)
1017
+ set_assignment_failure_reason(assignment_id, reason)
1018
+ raise RuntimeError(reason)
1019
+ exit_code = rc
1020
+
1021
+ if tmux_session_alive(sname, host=tmux_host):
1022
+ click.echo(
1023
+ f" session still running in remote tmux: {sname}\n"
1024
+ f" reattach with: ssh -t {machine.host} tmux attach-session -t {sname}"
1025
+ )
1026
+ return 0
1027
+
1028
+ try:
1029
+ remote_result = finalize_remote_interactive_exit(
1030
+ assignment_id=assignment_id,
1031
+ repo_name=repo_name,
1032
+ repo_github=repo_cfg.github,
1033
+ issue_number=tracking_issue,
1034
+ machine_name=machine.name,
1035
+ ssh_target=machine.host,
1036
+ remote_worktree_sh=remote_wt,
1037
+ remote_repo_sh=rp_sh,
1038
+ branch=branch_name,
1039
+ base_branch=default_branch,
1040
+ exit_code=exit_code,
1041
+ started_at=started_at,
1042
+ )
1043
+ if remote_result.already_recorded:
1044
+ click.echo(
1045
+ " result recorded via `coord report-result`; remote "
1046
+ "backstop did not overwrite"
1047
+ )
1048
+ else:
1049
+ click.echo(
1050
+ f" remote backstop: status={remote_result.terminal_status} "
1051
+ f"commits_ahead={remote_result.commits_ahead} "
1052
+ f"pushed={remote_result.push_ok}"
1053
+ )
1054
+ if not remote_result.push_ok:
1055
+ click.echo(
1056
+ f" warning: remote push failed: {remote_result.push_error}",
1057
+ err=True,
1058
+ )
1059
+ except Exception as exc: # noqa: BLE001 — best-effort backstop
1060
+ click.echo(
1061
+ f" warning: remote backstop failed to record test-author exit: {exc}",
1062
+ err=True,
1063
+ )
1064
+ return exit_code