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/conflict_fix.py ADDED
@@ -0,0 +1,633 @@
1
+ """Auto-dispatch a worker to rebase a merge-conflicted branch (#241).
2
+
3
+ When ``coord merge`` fails with a mechanical rebase conflict (classified by
4
+ :func:`coord.merge_queue.classify_conflict` as ``"rebaseable"``), the
5
+ coordinator queues a ``type="conflict-fix"`` assignment that:
6
+
7
+ 1. Pulls the latest target branch.
8
+ 2. Rebases the worker's branch on top of it.
9
+ 3. Resolves obvious additive merges (non-overlapping struct fields, list
10
+ entries, imports).
11
+ 4. Runs the project's test command.
12
+ 5. ``git push --force-with-lease`` to the same branch.
13
+
14
+ On success, the coordinator re-enqueues the original merge entry so
15
+ ``coord merge`` retries. On failure, the merge entry is marked
16
+ :data:`coord.merge_queue.HUMAN_REQUIRED` and surfaced in the TUI.
17
+
18
+ Why a separate module: same reason :mod:`coord.review` lives apart from
19
+ ``coord.dispatch`` — conflict-fix is triggered by a merge_queue event, not
20
+ by a planner proposal, so it shares little with the work-dispatch shape.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import time
26
+ import uuid
27
+
28
+ import httpx
29
+
30
+ from coord.config import Config
31
+ from coord.dispatch import AGENT_PORT
32
+ from coord.merge_queue import QueuedMerge
33
+ from coord.models import Assignment, Board, Machine
34
+
35
+
36
+ CONFLICT_FIX_SYSTEM_PROMPT = """\
37
+ You are a Claude Code conflict-fix worker. The merge of a worker's branch \
38
+ into the project's target branch failed because the branch is out of date \
39
+ or has a conflict. Your job is to rebase the branch and push it back.
40
+
41
+ Rules:
42
+ - The coordinator denies `gh` and `git push --force` for this worker. \
43
+ Don't try to use them — the harness will reject the call.
44
+ - Stay on the worker's branch — do NOT push to main / develop / target.
45
+ - Use git push --force-with-lease (NOT --force).
46
+ - If conflicts are mechanical (non-overlapping struct fields, list entries, \
47
+ imports, separate functions), resolve them additively — keep both sides.
48
+ - If conflicts are SEMANTIC (same function modified two ways, contradictory \
49
+ logic), DO NOT GUESS. Exit non-zero with a STUCK: line that starts with the \
50
+ marker `coord:conflict=semantic` and then names the conflicting files and \
51
+ line ranges, e.g.
52
+ STUCK: coord:conflict=semantic src/foo.py:40-72 — both sides rewrote \
53
+ parse_args() differently
54
+ The coordinator reads that marker and decides what happens next.
55
+
56
+ Progress reporting:
57
+ - After each significant step (rebase started, conflicts resolved, tests \
58
+ passed, pushed), output:
59
+ STATUS: [what you just did] → [what you're about to do] → [confidence]
60
+ - If you've tried and failed, output:
61
+ STUCK: [what you tried] [why it failed]
62
+ Then stop and wait for guidance.\
63
+ """
64
+
65
+ # Denied for conflict-fix workers. The agent's deny_commands enforcement
66
+ # (coord/agent.py:build_deny_prompt + harness gate) refuses these patterns
67
+ # regardless of what the prompt asks for. Keeps CLAUDE.md's "gh is denied"
68
+ # claim honest (#243-review-2).
69
+ CONFLICT_FIX_DENY_COMMANDS = [
70
+ "Bash(gh *)",
71
+ "Bash(git push --force *)",
72
+ "Bash(git push -f *)",
73
+ ]
74
+
75
+
76
+ # ── #1291: semantic-conflict escalation ─────────────────────────────────────
77
+ #
78
+ # A conflict-fix worker decides for itself whether a conflict is mechanical or
79
+ # semantic (see the "When NOT to guess" section of the briefing below) and
80
+ # reports the verdict on its STUCK: line. The verdict is machine-parseable —
81
+ # a fixed marker, NOT a prose regex over "semantic"/"same function"/… which
82
+ # would rot the first time a worker phrased its give-up differently.
83
+ SEMANTIC_STUCK_MARKER = "coord:conflict=semantic"
84
+
85
+ # Prefix on the escalated worker's `issue_title`. Coordinator-generated (so
86
+ # matching it is not prose-matching), and it is what makes the escalation
87
+ # visible: the TUI Pipeline renders the conflict-fix row's title, and
88
+ # `has_prior_semantic_escalation` uses it to enforce exactly-one-retry.
89
+ SEMANTIC_FIX_TITLE_PREFIX = "[semantic-merge]"
90
+
91
+ SEMANTIC_CONFLICT_SYSTEM_PROMPT = """\
92
+ You are a Claude Code merge worker. A first worker rebased a branch onto its \
93
+ target branch, hit a conflict it judged SEMANTIC — the two sides changed the \
94
+ same behaviour in incompatible ways — and stopped rather than guess. You are \
95
+ the second attempt: your job is to understand both intents and produce a \
96
+ resolution that honours both.
97
+
98
+ Constraints:
99
+ - The coordinator denies `gh` and `git push --force` for this worker.
100
+ - Stay on the worker's branch — never push to main / develop / the target \
101
+ branch. Push with `git push --force-with-lease`.
102
+ - The project's tests must pass before you push. A resolution that compiles \
103
+ but breaks behaviour is worse than no resolution.
104
+ - If you cannot honour both intents with confidence, stop and say so with a \
105
+ `STUCK:` line. The coordinator escalates to a human; that is a fine outcome \
106
+ and much better than a plausible guess.
107
+
108
+ Progress reporting: emit `STATUS:` lines as you go, and a `STUCK:` line if \
109
+ you stop.\
110
+ """
111
+
112
+
113
+ def build_semantic_conflict_briefing(
114
+ *,
115
+ entry: QueuedMerge,
116
+ repo_path: str,
117
+ test_command: str | None,
118
+ stuck_summary: str | None = None,
119
+ ) -> str:
120
+ """#1291: briefing for the escalated (semantic) second attempt.
121
+
122
+ Deliberately goal-and-constraint shaped rather than a numbered recipe —
123
+ the mechanical briefing's step list is the right shape for a rebase, and
124
+ the wrong shape here: over-prescriptive prompts measurably reduce the
125
+ stronger model's output quality on open-ended reasoning. The first
126
+ worker already proved the mechanical path doesn't apply.
127
+ """
128
+ test_cmd = test_command or "echo '(no test command configured)'"
129
+ lines: list[str] = [
130
+ f"# Semantic merge: {entry.repo_github} `{entry.branch}` → "
131
+ f"`{entry.target_branch}`",
132
+ "",
133
+ f"Issue: #{entry.issue_number} — {entry.issue_title}",
134
+ "",
135
+ "A first conflict-fix worker attempted the rebase and stopped: it "
136
+ "judged the conflict **semantic** — both sides changed the same "
137
+ "behaviour in incompatible ways, so keeping both hunks is not a "
138
+ "resolution.",
139
+ "",
140
+ f"You are already in a dedicated git worktree on `{entry.branch}`. "
141
+ f"Work here — do NOT `cd {repo_path}` (the machine's shared base "
142
+ "checkout) and do NOT `git checkout` / `git switch` anywhere; that is "
143
+ "what leaves the base parked on a feature branch and breaks later "
144
+ "dispatches (#1694).",
145
+ "",
146
+ ]
147
+ if stuck_summary:
148
+ lines += [f"What it reported: {stuck_summary}", ""]
149
+ lines += [
150
+ "## Goal",
151
+ "",
152
+ f"`{entry.branch}` rebased onto `{entry.target_branch}`, with a "
153
+ "resolution that preserves what BOTH sides were trying to do, "
154
+ "tests green, pushed to the same branch.",
155
+ "",
156
+ "Read enough of the history and the surrounding code to know what "
157
+ "each side intended before you write the resolution. Intent, not "
158
+ "textual reconciliation, is the whole job here.",
159
+ "",
160
+ "## Constraints",
161
+ "",
162
+ f"- Tests must pass: `{test_cmd}`. Do not push a red tree.",
163
+ f"- Push only `{entry.branch}`, only with `git push --force-with-lease`.",
164
+ "- `gh` and `git push --force` are denied by the harness. The "
165
+ "coordinator owns PR retries, merges, and issue comments.",
166
+ "- Every merge gate still applies after you finish (tests, CI, "
167
+ "verify-merge, review). Nothing here is force-merged, so a "
168
+ "resolution you are not confident in will be caught — but it will "
169
+ "cost a human a review cycle.",
170
+ "- If both intents cannot be honoured together, stop and explain on "
171
+ "a `STUCK:` line. Handing this to a human is the correct outcome; "
172
+ "guessing is not.",
173
+ "",
174
+ f"Last merge error: {entry.error or 'unknown conflict'}",
175
+ ]
176
+ return "\n".join(lines)
177
+
178
+
179
+ def build_conflict_fix_briefing(
180
+ *,
181
+ entry: QueuedMerge,
182
+ repo_path: str,
183
+ test_command: str | None,
184
+ ) -> str:
185
+ """Assemble the conflict-fix worker's briefing. Pure function — testable."""
186
+ test_cmd = test_command or "echo '(no test command configured)'"
187
+ lines: list[str] = [
188
+ f"# Conflict fix: {entry.repo_github} branch `{entry.branch}`",
189
+ "",
190
+ f"The merge of `{entry.branch}` → `{entry.target_branch}` failed.",
191
+ f"Reason: {entry.error or 'unknown conflict'}",
192
+ "",
193
+ f"Issue: #{entry.issue_number} — {entry.issue_title}",
194
+ "",
195
+ "## Where you are",
196
+ "",
197
+ f"You are already in a dedicated git worktree checked out on "
198
+ f"`{entry.branch}` — the coordinator created it for you. Work HERE.",
199
+ "",
200
+ f"Do **NOT** `cd {repo_path}` (that is the machine's shared base "
201
+ "checkout) and do NOT `git checkout` / `git switch` anywhere. Leaving "
202
+ f"the base checkout parked on `{entry.branch}` breaks every later "
203
+ "dispatch against that branch on this machine (#1694).",
204
+ "",
205
+ "## Steps",
206
+ "",
207
+ "1. `git fetch origin`",
208
+ f"2. `git pull --rebase origin {entry.target_branch}`",
209
+ "3. Resolve any conflict markers. Prefer additive merges; preserve",
210
+ " both sides when the conflict is in non-overlapping struct fields,",
211
+ " list entries, imports, or separate functions.",
212
+ f"4. Run tests: `{test_cmd}`",
213
+ f"5. `git push --force-with-lease origin {entry.branch}`",
214
+ "6. Exit 0 if push succeeds; non-zero otherwise.",
215
+ "",
216
+ "## When NOT to guess",
217
+ "",
218
+ "If the conflict is **semantic** — the same function modified two",
219
+ "different ways, contradictory logic, an API rename that the other",
220
+ "side doesn't know about — DO NOT guess. Exit non-zero with a",
221
+ "`STUCK:` line that begins with the exact marker",
222
+ f"`{SEMANTIC_STUCK_MARKER}` and then names the file(s) and line",
223
+ "ranges in conflict, e.g.",
224
+ "",
225
+ f" STUCK: {SEMANTIC_STUCK_MARKER} src/foo.py:40-72 — both sides",
226
+ " rewrote parse_args() differently",
227
+ "",
228
+ "The coordinator reads that marker to decide what happens next: the",
229
+ f"outcome is posted on issue #{entry.issue_number} and the merge",
230
+ "entry is either escalated for one stronger attempt or marked as",
231
+ "needing human resolution.",
232
+ "",
233
+ "You will NOT use `gh` or `git push --force` — both are denied by",
234
+ "the harness. The coordinator owns PR retries and issue posting.",
235
+ ]
236
+ return "\n".join(lines)
237
+
238
+
239
+ # ── Semantic-verdict detection (#1291) ──────────────────────────────────────
240
+
241
+
242
+ def _decode_worker_text(raw: str) -> str:
243
+ """Return the human-readable text of a worker log.
244
+
245
+ Handles both plain-text logs and the stream-json format (each line a
246
+ JSON event) the agent writes by default — in the latter case the
247
+ assistant text blocks are concatenated. Mirrors the detection used by
248
+ :func:`coord.progress.parse_completion_summary_from_agent`.
249
+
250
+ #1710 inventory: kept as a direct ``coord.worker_events`` import — this
251
+ decodes the generic Anthropic-Messages-API ``type: "assistant"`` /
252
+ ``message.content`` envelope (not claude business semantics), same
253
+ reasoning as the equivalent helpers in ``coord.review``/
254
+ ``coord.plan_parser``.
255
+ """
256
+ stream_json = False
257
+ for line in raw.splitlines():
258
+ stripped = line.strip()
259
+ if not stripped or stripped.startswith("#"):
260
+ continue
261
+ stream_json = stripped.startswith("{")
262
+ break
263
+
264
+ if not stream_json:
265
+ return raw
266
+
267
+ from coord.worker_events import _assistant_text, parse_event # noqa: PLC0415
268
+
269
+ decoded: list[str] = []
270
+ for line in raw.splitlines():
271
+ event = parse_event(line.rstrip("\n"))
272
+ if event is None or event.type != "assistant":
273
+ continue
274
+ text = _assistant_text(event)
275
+ if text:
276
+ decoded.append(text)
277
+ return "\n".join(decoded)
278
+
279
+
280
+ def semantic_verdict_in_text(text: str | None) -> bool:
281
+ """True when a worker log carries the semantic-conflict marker."""
282
+ if not text:
283
+ return False
284
+ return SEMANTIC_STUCK_MARKER in _decode_worker_text(text)
285
+
286
+
287
+ def detect_semantic_conflict(
288
+ *,
289
+ log_path: str | None = None,
290
+ host: str | None = None,
291
+ assignment_id: str | None = None,
292
+ port: int = AGENT_PORT,
293
+ timeout: float = 15.0,
294
+ ) -> bool:
295
+ """True when the finished conflict-fix worker reported a SEMANTIC conflict.
296
+
297
+ Tries the local log file first (coordinator-local worker), then falls
298
+ back to the agent's ``/logs/<id>`` endpoint for remote workers — the
299
+ same two-step every other log-parsing consumer uses (review findings,
300
+ plans, completion summaries). Best-effort: any read/transport failure
301
+ returns ``False``, which means "not semantic" and preserves today's
302
+ HUMAN_REQUIRED behaviour.
303
+ """
304
+ if log_path:
305
+ try:
306
+ from pathlib import Path # noqa: PLC0415
307
+
308
+ p = Path(log_path)
309
+ if p.exists():
310
+ raw = p.read_text(encoding="utf-8", errors="replace")
311
+ if semantic_verdict_in_text(raw):
312
+ return True
313
+ except OSError:
314
+ pass
315
+
316
+ if host and assignment_id:
317
+ try:
318
+ resp = httpx.get(
319
+ f"http://{host}:{port}/logs/{assignment_id}", timeout=timeout
320
+ )
321
+ resp.raise_for_status()
322
+ return semantic_verdict_in_text(resp.text)
323
+ except (httpx.HTTPError, httpx.TimeoutException):
324
+ return False
325
+
326
+ return False
327
+
328
+
329
+ def has_prior_semantic_escalation(board: Board, merge_entry_id: str | None) -> bool:
330
+ """True when this merge entry already had its ONE escalated attempt.
331
+
332
+ Matches on any status — running, done, failed — so the escalation can
333
+ never fire twice for the same entry. A second semantic failure falls
334
+ through to HUMAN_REQUIRED exactly as before (#1291: one retry, no loop).
335
+ """
336
+ if merge_entry_id is None:
337
+ return False
338
+ for a in list(board.active) + list(board.completed):
339
+ if a.type != "conflict-fix":
340
+ continue
341
+ if a.review_of_assignment_id != merge_entry_id:
342
+ continue
343
+ if (a.issue_title or "").startswith(SEMANTIC_FIX_TITLE_PREFIX):
344
+ return True
345
+ return False
346
+
347
+
348
+ # ── Retry-cap guard ─────────────────────────────────────────────────────────
349
+
350
+
351
+ def _has_active_conflict_fix(board: Board, merge_entry_id: str | None) -> bool:
352
+ """True when a conflict-fix for *merge_entry_id* is running or pending."""
353
+ if merge_entry_id is None:
354
+ return False
355
+ return any(
356
+ a.type == "conflict-fix"
357
+ and a.review_of_assignment_id == merge_entry_id
358
+ and a.status in ("running", "pending")
359
+ for a in list(board.active) + list(board.completed)
360
+ )
361
+
362
+
363
+ def has_prior_conflict_fix(board: Board, merge_entry_id: str | None) -> bool:
364
+ """True when a second conflict-fix dispatch for *merge_entry_id* is blocked.
365
+
366
+ Blocks when a conflict-fix is **active** (running/pending — don't spawn a
367
+ duplicate) or has **genuinely failed** (failed/advisory — retry cap
368
+ consumed, escalate to human).
369
+
370
+ #784: a conflict-fix that completed **successfully** (``status="done"``)
371
+ does *not* block a subsequent dispatch. A successful rebase can be
372
+ followed by a new conflict if other PRs merged in the meantime; that is a
373
+ fresh situation and warrants a fresh fix attempt. Only actual failures
374
+ consume the one-per-entry cap.
375
+ """
376
+ if merge_entry_id is None:
377
+ return False
378
+ for a in list(board.active) + list(board.completed):
379
+ if a.type != "conflict-fix":
380
+ continue
381
+ if a.review_of_assignment_id != merge_entry_id:
382
+ continue
383
+ # Active attempt in flight — prevent duplicate dispatch.
384
+ if a.status in ("running", "pending"):
385
+ return True
386
+ # Genuine failure — retry cap consumed, surface to human.
387
+ if a.status in ("failed", "advisory"):
388
+ return True
389
+ # "done" = successful rebase → cap not consumed; a re-conflict is new.
390
+ # "cancelled" falls through here too — a cancelled attempt did no work,
391
+ # so it is treated the same as "done": re-dispatch is allowed.
392
+ return False
393
+
394
+
395
+ # ── Machine selection ───────────────────────────────────────────────────────
396
+
397
+ def pick_conflict_fix_machine(
398
+ repo_name: str,
399
+ board: Board,
400
+ config: Config,
401
+ *,
402
+ prefer_machine: str | None = None,
403
+ ) -> Machine | None:
404
+ """Pick a machine that has *repo_name* checked out. ``prefer_machine``
405
+ wins if it can handle the repo (typically the original worker), so the
406
+ rebase uses an existing local checkout.
407
+
408
+ Returns ``None`` when no configured machine can handle the repo.
409
+ """
410
+ candidates = [m for m in config.machines if m.can_work_on(repo_name)]
411
+ if not candidates:
412
+ return None
413
+
414
+ busy = {a.machine_name for a in board.active if a.status in ("pending", "running")}
415
+
416
+ # 1. The preferred machine if it's idle and can handle the repo.
417
+ if prefer_machine is not None:
418
+ preferred = next((m for m in candidates if m.name == prefer_machine), None)
419
+ if preferred is not None and preferred.name not in busy:
420
+ return preferred
421
+
422
+ # 2. Any idle machine that handles the repo.
423
+ idle = [m for m in candidates if m.name not in busy]
424
+ if idle:
425
+ return idle[0]
426
+
427
+ # 3. Anyone (including busy) — the assignment will queue on the agent.
428
+ return candidates[0]
429
+
430
+
431
+ # ── Dispatch ────────────────────────────────────────────────────────────────
432
+
433
+
434
+ def dispatch_conflict_fix(
435
+ entry: QueuedMerge,
436
+ board: Board,
437
+ config: Config,
438
+ *,
439
+ http_client: httpx.Client | None = None,
440
+ prefer_machine: str | None = None,
441
+ now: float | None = None,
442
+ semantic: bool = False,
443
+ model: str | None = None,
444
+ stuck_summary: str | None = None,
445
+ ) -> Assignment | None:
446
+ """Send a ``type="conflict-fix"`` assignment for *entry* to an agent.
447
+
448
+ Returns the new ``Assignment``, or ``None`` when dispatch couldn't proceed
449
+ (no capable machine, no ``repo_path`` configured, agent unreachable, …).
450
+ The caller is responsible for persisting the board.
451
+
452
+ Retry cap: blocks on two conditions — (1) an **active** conflict-fix
453
+ (``running``/``pending``) for this entry is already in flight, preventing
454
+ duplicate dispatch; or (2) a **failed** conflict-fix (``failed``/
455
+ ``advisory``) already completed, consuming the one-per-entry retry cap so
456
+ the caller marks the entry ``HUMAN_REQUIRED``. A ``done`` (successful)
457
+ conflict-fix does *not* block a new dispatch — a successful rebase can be
458
+ followed by a fresh conflict if other PRs merged in the meantime, and that
459
+ warrants a new attempt rather than an immediate human escalation (#784).
460
+
461
+ ``semantic=True`` (#1291) dispatches the escalated second attempt: a
462
+ different, less prescriptive briefing (see
463
+ :func:`build_semantic_conflict_briefing`) and, with *model*, a stronger
464
+ model. It deliberately bypasses the *failed-prior* half of the retry
465
+ cap — the mechanical attempt that just failed is precisely what triggers
466
+ it — but is itself capped at one per entry by
467
+ :func:`has_prior_semantic_escalation`, and still refuses to dispatch
468
+ while another conflict-fix is in flight. Because the escalated attempt
469
+ is itself a ``conflict-fix`` row, its failure consumes the ordinary
470
+ retry cap and the entry goes HUMAN_REQUIRED — no loop.
471
+ """
472
+ if semantic:
473
+ if has_prior_semantic_escalation(board, entry.assignment_id):
474
+ return None
475
+ if _has_active_conflict_fix(board, entry.assignment_id):
476
+ return None
477
+ elif has_prior_conflict_fix(board, entry.assignment_id):
478
+ return None
479
+
480
+ repo = config.repo(entry.repo_name)
481
+ if repo is None:
482
+ return None
483
+
484
+ machine = pick_conflict_fix_machine(
485
+ entry.repo_name, board, config, prefer_machine=prefer_machine,
486
+ )
487
+ if machine is None:
488
+ return None
489
+
490
+ repo_path = machine.repo_path(entry.repo_name)
491
+ if repo_path is None:
492
+ return None
493
+
494
+ if semantic:
495
+ briefing = build_semantic_conflict_briefing(
496
+ entry=entry,
497
+ repo_path=repo_path,
498
+ test_command=repo.test_command,
499
+ stuck_summary=stuck_summary,
500
+ )
501
+ system_prompt = SEMANTIC_CONFLICT_SYSTEM_PROMPT
502
+ # #1291 visibility: the title is what the TUI Pipeline row shows, so
503
+ # the operator can see a semantic merge was attempted (and by which
504
+ # model) rather than discovering it post-merge.
505
+ title = f"{SEMANTIC_FIX_TITLE_PREFIX} {entry.issue_title}"
506
+ if model:
507
+ title = f"{SEMANTIC_FIX_TITLE_PREFIX}[{model}] {entry.issue_title}"
508
+ else:
509
+ briefing = build_conflict_fix_briefing(
510
+ entry=entry,
511
+ repo_path=repo_path,
512
+ test_command=repo.test_command,
513
+ )
514
+ system_prompt = CONFLICT_FIX_SYSTEM_PROMPT
515
+ title = f"[conflict-fix] {entry.issue_title}"
516
+
517
+ # Merge repo-level deny rules with the conflict-fix-specific ones so
518
+ # `gh` and `git push --force` are actually denied (not just discouraged
519
+ # by the system prompt). Dedupe by simple set conversion — patterns
520
+ # are exact strings on the agent side, so collisions are safe to fold.
521
+ repo_deny = (
522
+ list(repo.worker_permissions.deny) if repo.worker_permissions else []
523
+ )
524
+ deny_commands = list(dict.fromkeys(repo_deny + CONFLICT_FIX_DENY_COMMANDS))
525
+
526
+ payload = {
527
+ "repo_name": entry.repo_name,
528
+ "repo_path": repo_path,
529
+ "issue_number": entry.issue_number,
530
+ "issue_title": title,
531
+ "briefing": briefing,
532
+ "files_allowed": [],
533
+ "files_forbidden": [],
534
+ "pull_repos": [],
535
+ "deny_commands": deny_commands,
536
+ "type": "conflict-fix",
537
+ "system_prompt": system_prompt,
538
+ "review_target": entry.branch,
539
+ # #1694 (review finding): `branch` is the wire field every other
540
+ # dispatcher (dispatch.py/review.py/smoke.py/gate_b.py/auto_loop.py/
541
+ # reconcile.py) fills with the REPO'S REAL default/integration branch
542
+ # — `_setup_worktree`'s and `_restore_base_checkout`'s `default_branch`
543
+ # come straight from it (`assignment.spec.branch or "main"`). This
544
+ # dispatcher used to send `entry.branch` here too — the SAME value as
545
+ # `target_branch` below (the work branch) — so a base checkout parked
546
+ # on `entry.branch` looked identical to "already on the default
547
+ # branch" and both Part A's restore and Part B's `_git_worktree_add`
548
+ # remedy silently no-op'd (`branch == default_branch` short-circuit).
549
+ # `entry.target_branch` is the merge target `QueuedMerge` actually
550
+ # carries for this entry (repo.default_branch, or the milestone
551
+ # feature branch when the repo opted into that git model — see
552
+ # merge_queue.enqueue) — exactly the "default branch" Part A/B need,
553
+ # and never equal to `entry.branch` by construction.
554
+ "branch": entry.target_branch or "main",
555
+ # #277: pin the agent to the original branch — otherwise it derives a
556
+ # slug from the "[conflict-fix] …" issue_title and pushes the rebase
557
+ # to an orphan branch, leaving the real PR stale.
558
+ "target_branch": entry.branch,
559
+ }
560
+ if model:
561
+ # Same wire shape the fix/review dispatchers use: the alias is
562
+ # resolved to a pinned exact model id when `models.versions` maps it.
563
+ payload["model"] = config.models.resolve(model)
564
+
565
+ url = f"http://{machine.host}:{AGENT_PORT}/assign"
566
+ client = http_client or httpx
567
+ try:
568
+ resp = client.post(url, json=payload, timeout=15)
569
+ resp.raise_for_status()
570
+ agent_response = resp.json()
571
+ except (httpx.HTTPError, httpx.TimeoutException):
572
+ return None
573
+
574
+ fix_assignment = Assignment(
575
+ machine_name=machine.name,
576
+ repo_name=entry.repo_name,
577
+ issue_number=entry.issue_number,
578
+ issue_title=title,
579
+ files_allowed=[],
580
+ files_forbidden=[],
581
+ briefing=briefing,
582
+ model=model,
583
+ assignment_id=agent_response.get("id") or uuid.uuid4().hex[:12],
584
+ status="running",
585
+ branch=entry.branch,
586
+ dispatched_at=now if now is not None else time.time(),
587
+ type="conflict-fix",
588
+ review_target=entry.branch,
589
+ review_of_assignment_id=entry.assignment_id,
590
+ )
591
+ board.active.append(fix_assignment)
592
+
593
+ from coord.state import record_dispatched_assignment # noqa: PLC0415
594
+
595
+ record_dispatched_assignment(
596
+ assignment=fix_assignment,
597
+ repo_github=repo.github,
598
+ )
599
+
600
+ # #1038: operational-tier row alongside the business-tier "dispatched"
601
+ # row `record_dispatched_assignment` already writes. The business row
602
+ # marks WHAT happened (a conflict-fix assignment was dispatched); this
603
+ # marks that it was the coordinator's own automatic mechanical-conflict
604
+ # classification/retry-cap logic that decided to do it, not a human
605
+ # picking this entry — the same distinction the other #1038 hooks draw.
606
+ from coord.audit import record_audit # noqa: PLC0415
607
+
608
+ record_audit(
609
+ tier="operational",
610
+ category="merge",
611
+ event_type=(
612
+ "semantic_conflict_escalated" if semantic else "conflict_fix_dispatched"
613
+ ),
614
+ actor="daemon",
615
+ summary=(
616
+ f"semantic conflict escalated to {model or 'default model'}: "
617
+ f"{entry.repo_name}#{entry.issue_number} → {machine.name}"
618
+ if semantic
619
+ else f"conflict-fix dispatched: {entry.repo_name}#{entry.issue_number} "
620
+ f"→ {machine.name}"
621
+ ),
622
+ repo=entry.repo_name,
623
+ issue=entry.issue_number,
624
+ assignment_id=fix_assignment.assignment_id,
625
+ machine=machine.name,
626
+ details={
627
+ "merge_entry_id": entry.assignment_id,
628
+ "semantic": semantic,
629
+ "model": model,
630
+ },
631
+ )
632
+
633
+ return fix_assignment