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/issue_store.py ADDED
@@ -0,0 +1,1496 @@
1
+ """Issue-store seam (#466) — the one and only path through which the
2
+ interactive-launcher git-floor backstop and the ``coord report-result``
3
+ subcommand write a session result to the message bus and the local DB.
4
+
5
+ This module is the deliberately narrow contract that the future
6
+ :issue:`183` ``IssueStore`` refactor (and the planned coordination MCP
7
+ server) will slot into. Until then, the GitHub backend is the existing
8
+ :mod:`coord.github_ops` ``gh``-CLI wrapper. Callers MUST NOT reach
9
+ around this seam — the whole point is that swapping the backend later
10
+ must not require touching the launcher, the CLI subcommand, or the
11
+ pipeline-state code paths that consume completions.
12
+
13
+ Two public surface entry points are intentionally provided:
14
+
15
+ * :func:`post_completion` — the **git-floor backstop** sink, called by
16
+ the interactive launcher exit path. Inputs are everything the
17
+ launcher learned from the local filesystem alone: ``exit_code``,
18
+ ``commits_ahead``, the assignment metadata. This function chooses
19
+ ``done`` vs ``advisory`` vs ``failed`` purely from those numbers — it
20
+ does NOT trust any agent self-report.
21
+
22
+ * :func:`post_result` — the structured-report sink, called by
23
+ ``coord report-result``. Inputs are the result the interactive
24
+ agent typed (``status``, ``verdict``, ``summary``) plus the
25
+ assignment id. The agent is expected to invoke this **before**
26
+ exiting; this is the only coordinator-mediated command the
27
+ interactive agent is allowed to run. Required for review sessions
28
+ (0 commits → verdict can only come from the agent).
29
+
30
+ Both entry points fan in to the same private helpers that update the
31
+ local assignments table and post a coordinator-authored comment on the
32
+ issue, so the pipeline sees an interactive completion identically to a
33
+ ``claude -p`` worker completion.
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ import logging
40
+ import time
41
+ from dataclasses import asdict, dataclass
42
+ from typing import Literal
43
+
44
+ from coord import github_ops
45
+ from coord.comments import (
46
+ EVENT_ADVISORY,
47
+ EVENT_COMPLETION,
48
+ EVENT_FAILURE,
49
+ format_advisory,
50
+ format_audit_scorecard,
51
+ format_completion,
52
+ format_failure,
53
+ )
54
+
55
+
56
+ __all__ = [
57
+ "CompletionRecord",
58
+ "ResultRecord",
59
+ "post_completion",
60
+ "post_result",
61
+ "ResultStatus",
62
+ "ResultVerdict",
63
+ "AuditVerdict",
64
+ "STATUS_DONE",
65
+ "STATUS_BLOCKED",
66
+ "STATUS_ALREADY_IMPLEMENTED",
67
+ "VERDICT_APPROVE",
68
+ "VERDICT_REQUEST_CHANGES",
69
+ "VERDICT_SOURCE_AGENT",
70
+ "VERDICT_SOURCE_RECOVERED",
71
+ "VERDICT_SOURCE_OVERRIDDEN",
72
+ "get_audit_runs_for_epic",
73
+ "diff_audit_goals",
74
+ ]
75
+
76
+ log = logging.getLogger(__name__)
77
+
78
+
79
+ # ── Public enum-style constants ─────────────────────────────────────────────
80
+
81
+ STATUS_DONE = "done"
82
+ STATUS_BLOCKED = "blocked"
83
+ STATUS_ALREADY_IMPLEMENTED = "already-implemented"
84
+
85
+ VERDICT_APPROVE = "approve"
86
+ VERDICT_REQUEST_CHANGES = "request-changes"
87
+
88
+ _VALID_STATUSES = (STATUS_DONE, STATUS_BLOCKED, STATUS_ALREADY_IMPLEMENTED)
89
+ _VALID_VERDICTS = (VERDICT_APPROVE, VERDICT_REQUEST_CHANGES)
90
+
91
+ # #1956: verdict provenance — see coord.models.Assignment.verdict_source.
92
+ VERDICT_SOURCE_AGENT = "agent"
93
+ VERDICT_SOURCE_RECOVERED = "recovered"
94
+ VERDICT_SOURCE_OVERRIDDEN = "overridden"
95
+ _VALID_VERDICT_SOURCES = (
96
+ VERDICT_SOURCE_AGENT, VERDICT_SOURCE_RECOVERED, VERDICT_SOURCE_OVERRIDDEN,
97
+ )
98
+
99
+ ResultStatus = Literal["done", "blocked", "already-implemented"]
100
+ ResultVerdict = Literal["approve", "request-changes"]
101
+
102
+ # #886 Phase 2: per-goal verdict for a Milestone Outcome Audit run.
103
+ AuditVerdict = Literal["met", "partial", "gap"]
104
+ _VALID_AUDIT_VERDICTS = ("met", "partial", "gap")
105
+ # Ranking used by diff_audit_goals to classify a goal's movement between runs.
106
+ _AUDIT_VERDICT_RANK = {"gap": 0, "partial": 1, "met": 2}
107
+
108
+
109
+ # ── Records (the wire shape the future IssueStore interface accepts) ────────
110
+
111
+
112
+ @dataclass
113
+ class CompletionRecord:
114
+ """Inputs to :func:`post_completion` — the git-floor backstop path.
115
+
116
+ Mirrors the future ``IssueStore.post_completion`` signature so the
117
+ backend can be swapped to MCP without changing the call sites.
118
+ """
119
+
120
+ assignment_id: str
121
+ machine_name: str
122
+ repo_name: str
123
+ repo_github: str
124
+ issue_number: int
125
+ exit_code: int
126
+ commits_ahead: int | None # None = unknown (git failed) → treat as non-zero
127
+ branch: str | None = None
128
+ duration_seconds: float | None = None
129
+ log_path: str | None = None
130
+ summary: str = ""
131
+ # #1155: True when this record came from the interactive launcher's
132
+ # git-floor backstop (finalize_interactive_exit), False for a headless
133
+ # claude -p worker. Distinguishes "commits_ahead is None because a real,
134
+ # already-pushed branch hit a transient git failure" (headless — keep the
135
+ # #448 None→done policy) from "commits_ahead is None because the
136
+ # interactive session's worktree never resolved and nothing was ever
137
+ # confirmed pushed" (interactive work — must not silently become `done`
138
+ # with an empty, unreviewable branch). Defaults False so every existing
139
+ # headless caller (dispatch.py/notify.py/serve_app.py's /completion route
140
+ # for older clients) is unaffected.
141
+ is_interactive: bool = False
142
+
143
+
144
+ @dataclass
145
+ class ResultRecord:
146
+ """Inputs to :func:`post_result` — the ``coord report-result`` path."""
147
+
148
+ assignment_id: str
149
+ machine_name: str
150
+ repo_name: str
151
+ repo_github: str
152
+ issue_number: int
153
+ status: ResultStatus
154
+ verdict: ResultVerdict | None
155
+ summary: str
156
+ duration_seconds: float | None = None
157
+ log_path: str | None = None
158
+ branch: str | None = None
159
+ # Full review/findings body (markdown). When present it is persisted on the
160
+ # assignment row (review_findings) AND posted to the issue under a
161
+ # machine-parseable marker so the fix worker can recover it from any machine
162
+ # via the GitHub message bus (not just the one-line `summary`).
163
+ findings_body: str | None = None
164
+ # #886 Phase 2: structured Milestone Outcome Audit verdict — only meaningful
165
+ # for a type="audit" assignment (see #885's --audit-of). One dict per goal:
166
+ # {"goal": str, "metric_before": str, "metric_after": str,
167
+ # "verdict": "met"|"partial"|"gap", "evidence": str}. When present, the
168
+ # write routes through the audit dual-write path (assignment row + epic
169
+ # comment + #603 context store) instead of the generic done-comment body.
170
+ audit_goals: list[dict] | None = None
171
+ audit_bottom_line: str | None = None
172
+ # #650: explicit operator confirmation to replace ALREADY non-empty
173
+ # `review_findings` on this assignment row with a different blob. A
174
+ # single assignment_id backs exactly one review session, so a second,
175
+ # differing write to the same row is — absent this flag — refused by the
176
+ # clobber guard in `coord.state.update_assignment_review_findings`
177
+ # (the #650 incident: a 5166-char review clobbered to a 58-char
178
+ # placeholder by finishing the exit process twice).
179
+ allow_overwrite_findings: bool = False
180
+ # #1956: verdict provenance — see coord.models.Assignment.verdict_source
181
+ # for the three values and why conflating them was the second half of
182
+ # #1956. None (the default — every existing caller, including the
183
+ # ordinary agent self-report path) is recorded as "agent"; an operator
184
+ # relaying a verdict that was NOT freshly produced by this session's own
185
+ # reasoning (a transcript recovery, or a deliberate override of what the
186
+ # session actually reported) must say so explicitly via
187
+ # ``--verdict-source`` plus a required ``--verdict-reason`` — see
188
+ # ``_validate_result``.
189
+ verdict_source: str | None = None
190
+ verdict_source_reason: str | None = None
191
+
192
+
193
+ # ── Resolved terminal state (what the seam writes back) ─────────────────────
194
+
195
+
196
+ @dataclass
197
+ class StoreOutcome:
198
+ """What the seam ended up writing. Returned for caller diagnostics
199
+ (and for tests so they can assert the chosen branch without
200
+ re-reading the DB)."""
201
+
202
+ status: str # "done" | "advisory" | "failed"
203
+ event: str # comments.EVENT_*
204
+ posted: bool # True iff the GitHub comment post succeeded
205
+ error: str | None = None # populated when post failed
206
+ # #650: False when a review-findings write was attempted but the clobber
207
+ # guard refused it (non-empty findings already on the row, no explicit
208
+ # `allow_overwrite_findings`). True for every other outcome, including
209
+ # when no findings write was attempted at all — callers only need to
210
+ # react to the one "your write did not land" case.
211
+ findings_written: bool = True
212
+
213
+
214
+ # ── Internal helpers — the ONE place this module touches state/github_ops ──
215
+
216
+
217
+ def _update_local_state(
218
+ *,
219
+ assignment_id: str,
220
+ terminal_status: str,
221
+ branch: str | None,
222
+ review_state: str | None,
223
+ failure_reason: str | None = None,
224
+ exit_code: int | None = None,
225
+ ) -> None:
226
+ """Update the local assignments row + notifications ledger.
227
+
228
+ Encapsulates the SQL so the rest of the module never touches
229
+ ``coord.state`` or the DB directly — keeps the seam clean for the
230
+ future :issue:`183` refactor (which will likely replace this with
231
+ an :class:`IssueStore` write).
232
+
233
+ ``failure_reason`` (#1461) is optional and, when given, is written
234
+ verbatim — currently only used to stamp a usage-limit-kill diagnostic
235
+ (see ``coord.worker_events.format_usage_limit_reason``) onto the row so
236
+ ``coord status`` and ``coord drive`` can recognise it without re-parsing
237
+ the worker log themselves. It never forces or implies a particular
238
+ ``terminal_status`` — the caller decides that independently.
239
+
240
+ ``exit_code`` (#1605) is likewise optional. Before this, NO write path
241
+ ever persisted the ``assignments.exit_code`` column for a headless
242
+ completion — the field existed in the schema (read directly by the Rust
243
+ TUI, ``tui/src/app/data.rs``) but was always ``NULL``, which is exactly
244
+ why a failed Test-stage worker's reap left both ``failure_reason`` AND
245
+ ``exit_code`` null and undiagnosable from the board (#1605). Written
246
+ verbatim when given; omitted (``None``) leaves the column untouched, so
247
+ callers with no exit code available (e.g. a launch failure) behave
248
+ exactly as before this parameter existed.
249
+ """
250
+ # Import inside the function so test fixtures that stub the seam can
251
+ # still import this module without dragging in the DB layer.
252
+ from coord.state import get_connection # noqa: PLC0415
253
+
254
+ if not assignment_id:
255
+ return
256
+ now = time.time()
257
+ conn = get_connection()
258
+ fields = ["status=?", "finished_at=?"]
259
+ params: list[object] = [terminal_status, now]
260
+ if branch is not None:
261
+ fields.append("branch=?")
262
+ params.append(branch)
263
+ if review_state is not None:
264
+ fields.append("review_state=?")
265
+ params.append(review_state)
266
+ if failure_reason is not None:
267
+ fields.append("failure_reason=?")
268
+ params.append(failure_reason[:512]) # cap at 512 chars — one-liner
269
+ if exit_code is not None:
270
+ fields.append("exit_code=?")
271
+ params.append(exit_code)
272
+ params.append(assignment_id)
273
+ conn.execute(
274
+ f"UPDATE assignments SET {', '.join(fields)} WHERE assignment_id=?",
275
+ tuple(params),
276
+ )
277
+ conn.commit()
278
+
279
+
280
+ def _record_notification(
281
+ *, assignment_id: str, event: str, branch: str | None, actor: str = "worker",
282
+ ) -> None:
283
+ """Best-effort notification-ledger write so ``coord notify`` won't
284
+ double-post the same completion.
285
+
286
+ #1036: this is the issue_store analogue of ``state.mark_notified`` — the
287
+ single funnel every interactive-session outcome (``post_completion``'s
288
+ git-floor backstop AND ``post_result``'s agent self-report) reaches, so
289
+ it is where ``record_audit`` is hooked rather than at each call site.
290
+ ``actor`` defaults to ``"worker"`` (the ``post_result`` self-report
291
+ case, the majority of call sites); the git-floor backstop paths
292
+ (``_post_done_path``/``_post_advisory_path``/``_post_failure_path``)
293
+ pass ``actor="coordinator"`` since those are inferred from git state,
294
+ not agent self-report.
295
+ """
296
+ from coord.audit import record_audit # noqa: PLC0415
297
+ from coord.state import get_connection # noqa: PLC0415
298
+
299
+ if not assignment_id:
300
+ return
301
+ conn = get_connection()
302
+ conn.execute(
303
+ """INSERT OR REPLACE INTO notifications
304
+ (assignment_id, event, branch, posted_at)
305
+ VALUES (?, ?, ?, ?)""",
306
+ (assignment_id, event, branch, time.time()),
307
+ )
308
+ conn.commit()
309
+ row = conn.execute(
310
+ "SELECT repo_name, issue_number, machine_name FROM assignments WHERE assignment_id=?",
311
+ (assignment_id,),
312
+ ).fetchone()
313
+ record_audit(
314
+ tier="business",
315
+ category="dispatch",
316
+ event_type=event,
317
+ actor=actor,
318
+ summary=f"{event} notified: "
319
+ f"{row['repo_name']}#{row['issue_number']}" if row is not None
320
+ else f"{event} notified: {assignment_id}",
321
+ repo=row["repo_name"] if row is not None else None,
322
+ issue=row["issue_number"] if row is not None else None,
323
+ assignment_id=assignment_id,
324
+ machine=row["machine_name"] if row is not None else None,
325
+ details={"branch": branch} if branch is not None else None,
326
+ )
327
+
328
+
329
+ def _post_github_comment(
330
+ *, repo_github: str, issue_number: int, body: str,
331
+ ) -> tuple[bool, str | None]:
332
+ """Single GitHub-coupling sink for the seam.
333
+
334
+ Returns ``(ok, error)``. We never raise — the local DB write is the
335
+ authoritative state record; a comment post failure is surfaced to
336
+ the caller as diagnostics but must not undo the state transition.
337
+ """
338
+ try:
339
+ github_ops.post_issue_comment(repo_github, issue_number, body)
340
+ return True, None
341
+ except Exception as exc: # noqa: BLE001 — best-effort notification
342
+ return False, str(exc)
343
+
344
+
345
+ # ── Daemon routing (#590) ───────────────────────────────────────────────────
346
+ #
347
+ # When ``board_service`` is configured (a thin client over Tailscale, per #584),
348
+ # the seam's DB writes must land on the daemon's shared DB, not the client's
349
+ # local ``coord.db``. We route the *whole* record to the daemon — it re-invokes
350
+ # the ``_local`` implementation against the one shared DB (posting the GitHub
351
+ # comment and writing the assignments/notifications rows there). This is what
352
+ # lets a remote interactive session self-report via ``coord report-result``
353
+ # instead of the old "do NOT run report-result here" workaround.
354
+ #
355
+ # ``board_service`` unset → the ``_local`` path runs unchanged (no regression).
356
+ # The daemon endpoints call ``_post_*_local`` directly, so a daemon process can
357
+ # never recurse back out over HTTP even if it somehow had a service configured.
358
+
359
+
360
+ def _remote_service(): # -> ServiceConfig | None
361
+ """The configured board service, or ``None`` for the local-DB path."""
362
+ from coord.client import resolve_board_service # noqa: PLC0415
363
+
364
+ return resolve_board_service()
365
+
366
+
367
+ def _validate_result(record: ResultRecord) -> None:
368
+ """Reject invalid ``status`` / ``verdict`` before any write or POST."""
369
+ if record.status not in _VALID_STATUSES:
370
+ raise ValueError(
371
+ f"invalid status {record.status!r} (expected one of {_VALID_STATUSES!r})"
372
+ )
373
+ if record.verdict is not None and record.verdict not in _VALID_VERDICTS:
374
+ raise ValueError(
375
+ f"invalid verdict {record.verdict!r} "
376
+ f"(expected one of {_VALID_VERDICTS!r} or None)"
377
+ )
378
+ # ── Keystone invariant (#617): request-changes MUST carry findings ───────
379
+ # A `request-changes` verdict with no body silently strands the review: the
380
+ # iteration-N+1 fix worker is dispatched with nothing to fix, and the #603
381
+ # per-issue context store (which is auto-injected into every future
382
+ # briefing) never learns why the change was rejected (#607).
383
+ #
384
+ # The #580 guard that catches this lives ONLY in the `coord report-result`
385
+ # CLI command — every OTHER caller (the operator-prompt verdict relay, the
386
+ # transcript-floor, any future path) routes around it and can persist a
387
+ # bodyless verdict. Enforcing it HERE, at the single write seam through
388
+ # which all of them funnel, makes the bad state unrepresentable: a dropped
389
+ # review becomes a loud, recoverable error instead of silent data loss.
390
+ # Callers that can recover (read the transcript, prompt the operator for the
391
+ # body) catch this and retry with the findings attached.
392
+ if record.verdict == "request-changes" and not (
393
+ record.findings_body and record.findings_body.strip()
394
+ ):
395
+ raise ValueError(
396
+ "request-changes verdict requires findings_body — refusing to record "
397
+ "a review with no body, which would strand the fix worker with "
398
+ "nothing to fix (#607). Recover the findings from the session "
399
+ "transcript or supply them with --body-file."
400
+ )
401
+
402
+ # ── #1956: verdict provenance ─────────────────────────────────────────────
403
+ # A relayed verdict recorded with no marker of WHO decided it (an
404
+ # operator "recovering" it from a transcript vs. deliberately
405
+ # "overriding" what the reviewer actually said) is indistinguishable
406
+ # from an agent-produced one at every downstream reader — the merge
407
+ # gate, `coord gates`, the TUI, the audit trail. Refusing an unstated
408
+ # non-"agent" source at this single write seam (mirroring #617's
409
+ # empty-findings refusal above) makes that ambiguity unrepresentable
410
+ # instead of relying on every caller to remember to pass a reason.
411
+ if record.verdict_source is not None and record.verdict_source not in _VALID_VERDICT_SOURCES:
412
+ raise ValueError(
413
+ f"invalid verdict_source {record.verdict_source!r} "
414
+ f"(expected one of {_VALID_VERDICT_SOURCES!r} or None)"
415
+ )
416
+ # Mirror the `coord report-result` CLI's fast client-side guard here too
417
+ # (coord/commands/review.py) — this is the single write seam ALL callers
418
+ # funnel through (the CLI, the operator-prompt verdict relay, any future
419
+ # direct `post_result` caller), and a direct non-CLI caller passing
420
+ # `verdict_source` without `verdict` would otherwise sail through this
421
+ # validator, reach `_post_result_local`, and have its stated provenance
422
+ # silently discarded — `_persist_verdict_source` is only invoked inside
423
+ # the `if record.verdict is not None:` block, so nothing would ever be
424
+ # written. Refusing it here, at the seam, makes that gap unrepresentable
425
+ # regardless of which caller triggers it.
426
+ if record.verdict_source is not None and record.verdict is None:
427
+ raise ValueError(
428
+ "verdict_source only makes sense alongside verdict — it describes "
429
+ "the provenance of the verdict being recorded, and there is no "
430
+ "verdict here for it to describe (#1956)."
431
+ )
432
+ if record.verdict_source in ("recovered", "overridden") and not (
433
+ record.verdict_source_reason and record.verdict_source_reason.strip()
434
+ ):
435
+ raise ValueError(
436
+ f"verdict_source={record.verdict_source!r} requires a non-empty "
437
+ "verdict_source_reason (--verdict-reason on the CLI) — a relayed "
438
+ "verdict must carry a reason so it is auditable, not silently "
439
+ "indistinguishable from one the reviewer agent itself produced "
440
+ "(#1956)."
441
+ )
442
+
443
+ # ── #886 Phase 2: structured audit verdict shape ─────────────────────────
444
+ # A dropped/garbled goal here would corrupt the versioned diff every later
445
+ # `--audit-of` run depends on, so validate the full shape up front rather
446
+ # than discovering a bad goal mid-persist.
447
+ if record.audit_goals is not None:
448
+ if not record.audit_goals:
449
+ raise ValueError(
450
+ "audit_goals must be a non-empty list when supplied — an audit "
451
+ "run reporting zero goals is not a meaningful verdict (#886)"
452
+ )
453
+ for goal in record.audit_goals:
454
+ if not isinstance(goal, dict) or not str(goal.get("goal", "")).strip():
455
+ raise ValueError(
456
+ f"audit goal missing non-empty 'goal' text: {goal!r}"
457
+ )
458
+ verdict = goal.get("verdict")
459
+ if verdict not in _VALID_AUDIT_VERDICTS:
460
+ raise ValueError(
461
+ f"invalid audit goal verdict {verdict!r} for goal "
462
+ f"{goal.get('goal')!r} (expected one of "
463
+ f"{_VALID_AUDIT_VERDICTS!r})"
464
+ )
465
+
466
+
467
+ # ── Public surface ──────────────────────────────────────────────────────────
468
+
469
+
470
+ def post_completion(record: CompletionRecord) -> StoreOutcome:
471
+ """Git-floor backstop — routes to the daemon when ``board_service`` is set.
472
+
473
+ A daemon round-trip failure must NOT crash the launcher exit path (the
474
+ backstop is best-effort), so a network error degrades to an ``error``
475
+ outcome rather than raising. ``board_service`` unset → local DB write.
476
+ """
477
+ svc = _remote_service()
478
+ if svc is None:
479
+ return _post_completion_local(record)
480
+ try:
481
+ from coord.client import post_record # noqa: PLC0415
482
+
483
+ return StoreOutcome(**post_record(svc, "/completion", asdict(record)))
484
+ except Exception as exc: # noqa: BLE001 — backstop must not crash the exit path
485
+ return StoreOutcome(status="error", event="", posted=False, error=str(exc))
486
+
487
+
488
+ def post_result(record: ResultRecord) -> StoreOutcome:
489
+ """Structured report from the interactive agent — routes to the daemon
490
+ when ``board_service`` is set.
491
+
492
+ Validation runs client-side first (fast feedback for the operator), then
493
+ the record is POSTed; a daemon failure raises so ``coord report-result``
494
+ exits non-zero and the operator knows the verdict did not land.
495
+ ``board_service`` unset → local DB write (unchanged).
496
+ """
497
+ _validate_result(record)
498
+ svc = _remote_service()
499
+ if svc is None:
500
+ return _post_result_local(record)
501
+ from coord.client import post_record # noqa: PLC0415
502
+ import httpx as _httpx # noqa: PLC0415
503
+
504
+ try:
505
+ return StoreOutcome(**post_record(svc, "/result", asdict(record)))
506
+ except _httpx.HTTPStatusError as exc:
507
+ # #676: the daemon's _post_result_local can raise ValueError for
508
+ # guard violations (e.g. chat session attempting to claim done).
509
+ # The daemon serialises ValueError → HTTP 400 {"error": "..."}; convert
510
+ # it back to ValueError here so the CLI's `except ValueError` catches it
511
+ # and shows a clean error instead of a raw HTTPStatusError traceback.
512
+ if exc.response.status_code == 400:
513
+ try:
514
+ detail = exc.response.json().get("error", str(exc))
515
+ except Exception: # noqa: BLE001
516
+ detail = str(exc)
517
+ raise ValueError(detail) from exc
518
+ if exc.response.status_code == 503:
519
+ # #990: the daemon's _post_result_local raises RuntimeError when a
520
+ # review verdict can't be durably persisted (retries exhausted /
521
+ # readback mismatch); serve_app.py serialises that as HTTP 503
522
+ # {"error": "result write failed", "detail": "..."}. Convert back
523
+ # to RuntimeError so the CLI's `except RuntimeError` shows a clean
524
+ # message instead of a raw HTTPStatusError traceback.
525
+ try:
526
+ payload = exc.response.json()
527
+ detail = payload.get("detail") or payload.get("error") or str(exc)
528
+ except Exception: # noqa: BLE001
529
+ detail = str(exc)
530
+ raise RuntimeError(detail) from exc
531
+ raise
532
+
533
+
534
+ def _post_completion_local(record: CompletionRecord) -> StoreOutcome:
535
+ """Git-floor backstop.
536
+
537
+ Resolves the terminal status from ``exit_code`` and ``commits_ahead``
538
+ (no agent self-report is consulted) and writes the completion through
539
+ the seam:
540
+
541
+ * ``exit_code != 0`` → ``failed``
542
+ * ``exit_code == 0``, commits == 0 → ``advisory`` (the #448 state)
543
+ * ``exit_code == 0``, commits >= 1 → ``done`` (eligible for review/smoke)
544
+ * ``exit_code == 0``, commits is None → ``done`` (git failed; do not
545
+ falsely flag advisory — same policy as #448 in agent.py:_reap)
546
+ * ``exit_code == 0``, commits is None, ``is_interactive`` WORK session →
547
+ ``done`` UNLESS GitHub confirms no branch was ever pushed, in which
548
+ case ``advisory`` (#1155 — closes the #448 guard's interactive gap:
549
+ an unresolved worktree at finalize time must not silently masquerade
550
+ as reviewable work with an empty branch).
551
+
552
+ **Exception — chat / troubleshoot sessions (#676):** these are
553
+ non-mutating diagnostics that never produce committed work, so they are
554
+ *always* recorded as ``advisory`` regardless of exit code. A non-zero
555
+ exit from a chat session (e.g. the claude process crashed) must not leave
556
+ a red ``failed`` box on the pipeline.
557
+
558
+ Always writes a local state transition. Always attempts to post a
559
+ coordinator-authored comment. Comment-post failure is non-fatal.
560
+ """
561
+ # #676: chat and troubleshoot sessions are non-mutating diagnostics.
562
+ # Always mark them advisory — never done or failed — so a crash or
563
+ # abnormal close doesn't create a red box that blocks the pipeline.
564
+ import dataclasses as _dc # noqa: PLC0415
565
+
566
+ atype = _assignment_type_local(record.assignment_id)
567
+ if atype in ("chat", "troubleshoot"):
568
+ if not record.summary:
569
+ record = _dc.replace(
570
+ record,
571
+ summary=(
572
+ f"Human-attended {atype} session closed"
573
+ " (diagnostic-only — no committed work)."
574
+ ),
575
+ )
576
+ return _post_advisory_path(record)
577
+
578
+ # #812: interactive review session that failed to start or exited without a
579
+ # verdict. Reviews never commit code, so commits_ahead is always None here
580
+ # (no worktree). The legitimate "done" path for a review is post_result,
581
+ # which is called by coord report-result or the transcript-floor — both run
582
+ # BEFORE post_completion in finalize_interactive_exit and return early.
583
+ # Reaching post_completion for a review means neither path captured a verdict,
584
+ # so the session was abandoned or never started. Finalise as failed
585
+ # (→ red/recoverable in the TUI) rather than done (→ permanently stuck blue).
586
+ if atype == "review":
587
+ summary = record.summary or (
588
+ "Interactive review session exited without producing a verdict "
589
+ "(session may have failed to start). "
590
+ "Re-dispatch a fresh review via 'Start review'."
591
+ )
592
+ record = _dc.replace(record, summary=summary)
593
+ return _post_failure_path(record)
594
+
595
+ if record.exit_code != 0:
596
+ return _post_failure_path(record)
597
+
598
+ if record.commits_ahead == 0:
599
+ return _post_advisory_path(record)
600
+
601
+ # #1155: commits_ahead is None (git failed / worktree never resolved) on
602
+ # an interactive WORK session. Unlike the headless case, we can't trust
603
+ # that "None" means "a real pushed branch hit a transient hiccup" — it
604
+ # may equally mean "this session produced nothing at all". Ask GitHub
605
+ # directly before defaulting to done; only demote when the remote
606
+ # authoritatively confirms no branch was ever pushed.
607
+ if (
608
+ record.commits_ahead is None
609
+ and record.is_interactive
610
+ and atype == "work"
611
+ and not _interactive_work_has_pushed_branch(record)
612
+ ):
613
+ return _post_advisory_path(record)
614
+
615
+ # commits_ahead is >=1 or None (unknown, and either non-interactive or
616
+ # confirmed to have a real pushed branch). Treat as DONE so the work is
617
+ # eligible for review/smoke. Matches #448 policy: a git failure never
618
+ # demotes a clean exit to advisory.
619
+ return _post_done_path(record)
620
+
621
+
622
+ def _interactive_work_has_pushed_branch(record: CompletionRecord) -> bool:
623
+ """#1155: authoritative remote check for an interactive WORK session whose
624
+ ``commits_ahead`` is unknown (git failed locally, or the session's
625
+ worktree path never resolved at finalize time — see #1151).
626
+
627
+ Fails OPEN (returns ``True``, i.e. "assume it's there, keep done") on any
628
+ lookup problem — an unresponsive ``gh``, a network glitch, or an
629
+ unresolvable ``repo_github`` must never falsely demote real pushed work
630
+ to advisory. Only returns ``False`` when GitHub positively confirms no
631
+ matching branch exists. Mirrors :func:`coord.github_ops.branch_exists_on_remote`'s
632
+ fail-open policy.
633
+ """
634
+ if not record.repo_github:
635
+ return True
636
+
637
+ branch = (record.branch or "").strip()
638
+ if branch:
639
+ try:
640
+ return github_ops.branch_exists_on_remote(record.repo_github, branch)
641
+ except Exception: # noqa: BLE001 — fail open, never demote on a lookup error
642
+ return True
643
+
644
+ # No branch name captured at all (the #1151 shape: branch_now was None
645
+ # and no branch kwarg was supplied either) — fall back to scanning for
646
+ # ANY issue-<N>-* branch actually pushed for this issue.
647
+ try:
648
+ names = github_ops.list_remote_branch_names(record.repo_github)
649
+ except Exception: # noqa: BLE001 — fail open, never demote on a lookup error
650
+ return True
651
+ if not names:
652
+ # list_remote_branch_names already fails OPEN to an empty set on any
653
+ # gh/network error (see its docstring) — a real repo always has at
654
+ # least one branch, so an empty result here is itself an error
655
+ # signal, not "confirmed no branches". Fail open.
656
+ return True
657
+ prefix = f"issue-{record.issue_number}-"
658
+ return any(name.startswith(prefix) for name in names)
659
+
660
+
661
+ def _assignment_type_local(assignment_id: str) -> str | None:
662
+ """The board ``type`` ("work"/"review"/"smoke"/…) for *assignment_id* from
663
+ the local DB, or ``None`` when the row is absent or the lookup fails.
664
+
665
+ Used by the verdict-target invariant in :func:`_post_result_local`. A lookup
666
+ failure returns ``None`` (don't gate) so a transient DB hiccup never blocks a
667
+ legitimate write.
668
+ """
669
+ from coord.state import get_connection # noqa: PLC0415
670
+
671
+ try:
672
+ conn = get_connection()
673
+ row = conn.execute(
674
+ "SELECT type FROM assignments WHERE assignment_id = ?",
675
+ (assignment_id,),
676
+ ).fetchone()
677
+ except Exception: # noqa: BLE001 — a lookup failure must not block the write
678
+ return None
679
+ if row is None:
680
+ return None
681
+ return row["type"] if hasattr(row, "keys") else row[0]
682
+
683
+
684
+ def _read_review_verdict_local(assignment_id: str) -> str | None:
685
+ """Read back the persisted ``review_verdict`` column, or ``None`` if the
686
+ row is absent. Used by :func:`_persist_review_verdict` to verify a write
687
+ actually landed rather than trusting a bare ``commit()`` call."""
688
+ from coord.state import get_connection # noqa: PLC0415
689
+
690
+ conn = get_connection()
691
+ row = conn.execute(
692
+ "SELECT review_verdict FROM assignments WHERE assignment_id = ?",
693
+ (assignment_id,),
694
+ ).fetchone()
695
+ if row is None:
696
+ return None
697
+ return row["review_verdict"] if hasattr(row, "keys") else row[0]
698
+
699
+
700
+ def _persist_review_verdict(record: ResultRecord) -> bool:
701
+ """Durably record ``record.verdict`` on the assignment row.
702
+
703
+ #990: this used to be a bare ``UPDATE ... ; except Exception: pass`` —
704
+ a transient SQLite lock (the daemon DB is concurrently written by other
705
+ ticks/agents) could make the write silently no-op while the caller
706
+ (``coord report-result``) still reported success and posted a GitHub
707
+ comment showing the verdict. The merge gate (``has_approved_review`` in
708
+ ``coord.merge_queue``) reads exactly this column, so a swallowed failure
709
+ here quietly undermines the merge gate's trustworthiness.
710
+
711
+ Retries a few times with backoff to absorb transient contention, then
712
+ reads the column back and compares it to what we intended to write —
713
+ catches both a raised exception AND a write that silently no-ops
714
+ (e.g. a stale connection, or a commit that didn't persist). Raises
715
+ ``RuntimeError`` if it still can't confirm the write landed; callers
716
+ MUST NOT swallow this — let it propagate so the CLI exits non-zero and
717
+ the operator knows to retry, instead of trusting a false success.
718
+
719
+ Returns ``True`` when the findings write landed (or no findings body was
720
+ supplied at all), ``False`` when the #650 clobber guard refused to
721
+ replace already-captured, different findings because
722
+ ``record.allow_overwrite_findings`` was not set — the verdict column
723
+ already matches ``record.verdict`` in that case (the common "duplicate
724
+ re-capture of the same verdict" shape), so this is reported as a
725
+ guarded no-op, not a failure.
726
+ """
727
+ attempts = 4
728
+ delay = 0.15
729
+ last_exc: Exception | None = None
730
+ for attempt in range(1, attempts + 1):
731
+ try:
732
+ if record.findings_body and record.findings_body.strip():
733
+ from coord.state import update_assignment_review_findings # noqa: PLC0415
734
+
735
+ findings_written = update_assignment_review_findings(
736
+ record.assignment_id,
737
+ verdict=record.verdict,
738
+ body=record.findings_body.strip(),
739
+ allow_overwrite=record.allow_overwrite_findings,
740
+ )
741
+ if not findings_written:
742
+ # #650 clobber guard refused the write: the guard only
743
+ # ever fires when the stored verdict already equals
744
+ # `record.verdict` (a differing verdict is always a real
745
+ # transition and is written through unguarded) — so the
746
+ # pre-existing findings are exactly what should stay on
747
+ # the row. Nothing more to do; no need to retry.
748
+ return False
749
+ bodyless_verdict = False
750
+ else:
751
+ from coord.state import get_connection # noqa: PLC0415
752
+
753
+ conn = get_connection()
754
+ conn.execute(
755
+ "UPDATE assignments SET review_verdict=? WHERE assignment_id=?",
756
+ (record.verdict, record.assignment_id),
757
+ )
758
+ conn.commit()
759
+ bodyless_verdict = True
760
+ actual = _read_review_verdict_local(record.assignment_id)
761
+ if actual == record.verdict:
762
+ # #1036: the update_assignment_review_findings() branch above
763
+ # already emits an audit row itself (it funnels through
764
+ # state._update_assignment_review_findings_local, hooked
765
+ # there). This is the bodyless-verdict twin of that write —
766
+ # only reachable here, so hook it here, and only once the
767
+ # readback has confirmed the write is durable (avoids a
768
+ # duplicate row per retry attempt).
769
+ if bodyless_verdict:
770
+ from coord.audit import record_audit # noqa: PLC0415
771
+ from coord.state import get_connection # noqa: PLC0415
772
+
773
+ conn = get_connection()
774
+ row = conn.execute(
775
+ "SELECT repo_name, issue_number, machine_name FROM assignments "
776
+ "WHERE assignment_id=?",
777
+ (record.assignment_id,),
778
+ ).fetchone()
779
+ if row is not None:
780
+ record_audit(
781
+ tier="business",
782
+ category="review",
783
+ event_type=f"review_{record.verdict}",
784
+ actor="worker",
785
+ summary=f"Review {record.verdict}: "
786
+ f"{row['repo_name']}#{row['issue_number']}",
787
+ repo=row["repo_name"],
788
+ issue=row["issue_number"],
789
+ assignment_id=record.assignment_id,
790
+ machine=row["machine_name"],
791
+ )
792
+ return True
793
+ last_exc = RuntimeError(
794
+ f"review_verdict readback mismatch for assignment "
795
+ f"{record.assignment_id!r}: wrote {record.verdict!r}, read back "
796
+ f"{actual!r} (attempt {attempt}/{attempts})"
797
+ )
798
+ except Exception as exc: # noqa: BLE001 — retried below; re-raised after
799
+ last_exc = exc
800
+ if attempt < attempts:
801
+ time.sleep(delay)
802
+ delay *= 2
803
+ raise RuntimeError(
804
+ f"failed to durably persist review_verdict={record.verdict!r} for "
805
+ f"assignment {record.assignment_id!r} after {attempts} attempts "
806
+ f"(#990): {last_exc}"
807
+ ) from last_exc
808
+
809
+
810
+ def _read_verdict_source_local(assignment_id: str) -> tuple[str | None, str | None]:
811
+ """Read back the persisted ``(verdict_source, verdict_source_reason)``
812
+ columns, or ``(None, None)`` if the row is absent. Used by
813
+ :func:`_persist_verdict_source` to verify a write actually landed
814
+ rather than trusting a bare ``commit()`` call — mirrors
815
+ :func:`_read_review_verdict_local` above."""
816
+ from coord.state import get_connection # noqa: PLC0415
817
+
818
+ conn = get_connection()
819
+ row = conn.execute(
820
+ "SELECT verdict_source, verdict_source_reason FROM assignments "
821
+ "WHERE assignment_id = ?",
822
+ (assignment_id,),
823
+ ).fetchone()
824
+ if row is None:
825
+ return None, None
826
+ if hasattr(row, "keys"):
827
+ return row["verdict_source"], row["verdict_source_reason"]
828
+ return row[0], row[1]
829
+
830
+
831
+ def _persist_verdict_source(record: ResultRecord) -> None:
832
+ """Best-effort: stamp verdict provenance (#1956) alongside `review_verdict`.
833
+
834
+ Deliberately separate from :func:`_persist_review_verdict`'s retry/RAISE
835
+ contract — provenance is metadata ABOUT a verdict write that already
836
+ succeeded (this is only ever called after that function returns), so a
837
+ failure here must not turn a landed verdict into a reported CLI error.
838
+ ``record.verdict_source`` defaults to ``"agent"`` when unset
839
+ (``_validate_result`` already confirmed it's a valid value, or None).
840
+
841
+ Still best-effort — this function never raises — but it is NOT a bare
842
+ ``except Exception: pass`` anymore. That used to be exactly the
843
+ anti-pattern :func:`_persist_review_verdict`'s own docstring (#990)
844
+ documents fixing for the sibling ``review_verdict`` column: a transient
845
+ SQLite lock (the daemon DB is concurrently written by other ticks/
846
+ agents) could make this write silently no-op right after
847
+ ``_persist_review_verdict`` durably landed the verdict itself, leaving
848
+ ``verdict_source IS NULL`` — which every reader (``format_gate_report``,
849
+ ``coord.models.Assignment.verdict_source``'s own docstring) treats as
850
+ ``"agent"``, indistinguishable from an earned verdict. That reproduces,
851
+ for the provenance feature itself, the exact "silent loss" failure
852
+ #1956 exists to close.
853
+
854
+ So: retries a few times with backoff to absorb transient contention,
855
+ reads the columns back and compares them to what we intended to write
856
+ (catches both a raised exception AND a write that silently no-ops), and
857
+ — if it still can't confirm the write landed — ``log.warning``s loudly
858
+ with a recovery command instead of swallowing the failure silently. The
859
+ CLI call itself still exits 0 (the verdict itself IS durably recorded;
860
+ only its provenance annotation is at risk), but the failure is now
861
+ discoverable instead of invisible.
862
+
863
+ Always called from :func:`_post_result_local`, which — like every other
864
+ caller in this module — runs strictly LOCALLY (either directly, or
865
+ inside the daemon's own ``/result`` handler after it reconstructs the
866
+ ``ResultRecord``): no separate daemon route is needed for this column,
867
+ unlike ``update_assignment_review_findings``'s ``POST
868
+ /review-findings``, because the routing already happened one level up
869
+ at ``post_result``.
870
+ """
871
+ from coord.state import get_connection # noqa: PLC0415
872
+
873
+ if not record.assignment_id:
874
+ return
875
+ source = record.verdict_source or VERDICT_SOURCE_AGENT
876
+ reason = record.verdict_source_reason
877
+
878
+ attempts = 3
879
+ delay = 0.15
880
+ last_exc: Exception | None = None
881
+ for attempt in range(1, attempts + 1):
882
+ try:
883
+ conn = get_connection()
884
+ conn.execute(
885
+ "UPDATE assignments SET verdict_source=?, verdict_source_reason=? "
886
+ "WHERE assignment_id=?",
887
+ (source, reason, record.assignment_id),
888
+ )
889
+ conn.commit()
890
+ actual_source, actual_reason = _read_verdict_source_local(record.assignment_id)
891
+ if actual_source == source and actual_reason == reason:
892
+ return
893
+ last_exc = RuntimeError(
894
+ f"verdict_source readback mismatch for assignment "
895
+ f"{record.assignment_id!r}: wrote {(source, reason)!r}, "
896
+ f"read back {(actual_source, actual_reason)!r} "
897
+ f"(attempt {attempt}/{attempts})"
898
+ )
899
+ except Exception as exc: # noqa: BLE001 — retried below; logged after
900
+ last_exc = exc
901
+ if attempt < attempts:
902
+ time.sleep(delay)
903
+ delay *= 2
904
+ log.warning(
905
+ "failed to durably persist verdict_source=%r (reason=%r) for "
906
+ "assignment %r after %d attempts (#1956): %s — review_verdict "
907
+ "IS landed, but its provenance is not: the row will read as "
908
+ "verdict_source=agent (indistinguishable from earned) until this "
909
+ "is corrected manually, e.g.:\n"
910
+ " coord report-result --assignment %s --status done "
911
+ "--verdict <same-verdict> --verdict-source %s --verdict-reason %r",
912
+ source, reason, record.assignment_id, attempts, last_exc,
913
+ record.assignment_id, source, reason,
914
+ )
915
+
916
+
917
+ # ── Milestone Outcome Audit — versioned runs + diff (#886 Phase 2) ─────────
918
+
919
+
920
+ def get_audit_runs_for_epic(repo_name: str, epic_issue_number: int) -> list[dict]:
921
+ """All ``type="audit"`` assignment rows for ``(repo_name, epic_issue_number)``
922
+ that have a persisted verdict, oldest run first.
923
+
924
+ The epic's own issue number doubles as the audit assignment's
925
+ ``issue_number`` (see #885's ``_dispatch_audit_of``), so a single
926
+ ``(repo_name, issue_number)`` pair identifies every ``--audit-of`` run ever
927
+ made against that milestone. Used both to compute the next
928
+ ``audit_run_number`` (``len(...) + 1``) and to diff the newest run against
929
+ the previous one. Returns ``[]`` on any lookup failure — a transient DB
930
+ hiccup here must not crash the reporting path (the caller falls back to
931
+ treating this as the first run, which just skips the diff).
932
+ """
933
+ from coord.state import get_connection # noqa: PLC0415
934
+
935
+ try:
936
+ conn = get_connection()
937
+ rows = conn.execute(
938
+ "SELECT assignment_id, audit_run_number, audit_goals_json, "
939
+ "audit_bottom_line, dispatched_at FROM assignments "
940
+ "WHERE repo_name=? AND issue_number=? AND type='audit' "
941
+ "AND audit_run_number IS NOT NULL ORDER BY audit_run_number ASC",
942
+ (repo_name, epic_issue_number),
943
+ ).fetchall()
944
+ except Exception: # noqa: BLE001 — best-effort; treat as "no prior runs"
945
+ return []
946
+ return [dict(r) for r in rows]
947
+
948
+
949
+ def diff_audit_goals(
950
+ prev_goals: list[dict] | None, new_goals: list[dict]
951
+ ) -> dict[str, list[str]]:
952
+ """Classify how each goal in ``new_goals`` moved relative to ``prev_goals``
953
+ (keyed by the ``goal`` text — the only stable identifier an agent-authored
954
+ scorecard has across runs).
955
+
956
+ Returns ``{"closed": [...], "regressed": [...], "still_open": [...],
957
+ "new": [...]}`` — the concrete "v1: 3 gaps -> v2: 0 gaps" delta the issue
958
+ asks for. ``closed`` = moved to ``met`` from something else; ``regressed``
959
+ = moved to a lower rank (e.g. ``met`` -> ``gap``, a real regression worth
960
+ flagging loudly); ``still_open`` = present in both runs, still not
961
+ ``met``; ``new`` = a goal that didn't appear in the prior run at all
962
+ (scope changed, or first time this goal was tracked).
963
+ """
964
+ prev_by_goal = {g.get("goal"): g.get("verdict") for g in (prev_goals or [])}
965
+ closed: list[str] = []
966
+ regressed: list[str] = []
967
+ still_open: list[str] = []
968
+ new: list[str] = []
969
+ for goal in new_goals:
970
+ name = goal.get("goal")
971
+ verdict = goal.get("verdict")
972
+ if name not in prev_by_goal:
973
+ new.append(name)
974
+ continue
975
+ prev_verdict = prev_by_goal[name]
976
+ prev_rank = _AUDIT_VERDICT_RANK.get(prev_verdict, 0)
977
+ new_rank = _AUDIT_VERDICT_RANK.get(verdict, 0)
978
+ if new_rank == _AUDIT_VERDICT_RANK["met"] and prev_rank != new_rank:
979
+ closed.append(name)
980
+ elif new_rank < prev_rank:
981
+ regressed.append(name)
982
+ elif new_rank != _AUDIT_VERDICT_RANK["met"]:
983
+ still_open.append(name)
984
+ return {
985
+ "closed": closed,
986
+ "regressed": regressed,
987
+ "still_open": still_open,
988
+ "new": new,
989
+ }
990
+
991
+
992
+ def _read_audit_run_local(assignment_id: str) -> int | None:
993
+ """Read back the persisted ``audit_run_number`` column, or ``None`` if the
994
+ row is absent. Used by :func:`_persist_audit_result` to verify a write
995
+ actually landed rather than trusting a bare ``commit()`` call."""
996
+ from coord.state import get_connection # noqa: PLC0415
997
+
998
+ conn = get_connection()
999
+ row = conn.execute(
1000
+ "SELECT audit_run_number FROM assignments WHERE assignment_id = ?",
1001
+ (assignment_id,),
1002
+ ).fetchone()
1003
+ if row is None:
1004
+ return None
1005
+ return row["audit_run_number"] if hasattr(row, "keys") else row[0]
1006
+
1007
+
1008
+ def _persist_audit_result(record: ResultRecord, *, run_number: int) -> None:
1009
+ """Durably record the structured audit verdict on the assignment row.
1010
+
1011
+ Mirrors :func:`_persist_review_verdict` (#990): retries a few times with
1012
+ backoff, then reads the ``audit_run_number`` column back and compares it
1013
+ to what was intended — a silently-dropped write here would corrupt the
1014
+ versioning invariant every later ``--audit-of`` diff depends on (two runs
1015
+ could collide on the same ``run_number``, or a run could vanish from the
1016
+ history entirely). Raises ``RuntimeError`` if the write can't be
1017
+ confirmed; callers MUST NOT swallow this.
1018
+ """
1019
+ goals_json = json.dumps(record.audit_goals)
1020
+ attempts = 4
1021
+ delay = 0.15
1022
+ last_exc: Exception | None = None
1023
+ for attempt in range(1, attempts + 1):
1024
+ try:
1025
+ from coord.state import get_connection # noqa: PLC0415
1026
+
1027
+ conn = get_connection()
1028
+ conn.execute(
1029
+ "UPDATE assignments SET audit_goals_json=?, audit_bottom_line=?, "
1030
+ "audit_run_number=? WHERE assignment_id=?",
1031
+ (
1032
+ goals_json,
1033
+ record.audit_bottom_line,
1034
+ run_number,
1035
+ record.assignment_id,
1036
+ ),
1037
+ )
1038
+ conn.commit()
1039
+ actual = _read_audit_run_local(record.assignment_id)
1040
+ if actual == run_number:
1041
+ return
1042
+ last_exc = RuntimeError(
1043
+ f"audit_run_number readback mismatch for assignment "
1044
+ f"{record.assignment_id!r}: wrote {run_number!r}, read back "
1045
+ f"{actual!r} (attempt {attempt}/{attempts})"
1046
+ )
1047
+ except Exception as exc: # noqa: BLE001 — retried below; re-raised after
1048
+ last_exc = exc
1049
+ if attempt < attempts:
1050
+ time.sleep(delay)
1051
+ delay *= 2
1052
+ raise RuntimeError(
1053
+ f"failed to durably persist audit run {run_number} for assignment "
1054
+ f"{record.assignment_id!r} after {attempts} attempts (#886): {last_exc}"
1055
+ ) from last_exc
1056
+
1057
+
1058
+ def _post_audit_result_path(record: ResultRecord) -> StoreOutcome:
1059
+ """Milestone Outcome Audit (#886 Phase 2) dual-write path.
1060
+
1061
+ Reached from :func:`_post_result_local` when ``record.audit_goals`` is
1062
+ supplied (i.e. ``coord report-result --audit-json`` was used). Writes the
1063
+ structured verdict three ways for durability, exactly as the issue asks:
1064
+
1065
+ 1. the assignment row (``audit_goals_json``/``audit_bottom_line``/
1066
+ ``audit_run_number`` — see :func:`_persist_audit_result`);
1067
+ 2. a comment on the epic issue carrying the rendered scorecard, the delta
1068
+ vs the prior run, and the raw JSON (:func:`coord.comments.
1069
+ format_audit_scorecard`) so any machine can recover the full verdict
1070
+ from the GitHub message bus alone, same as the review-findings block;
1071
+ 3. the #603 per-issue context store, so the next ``--audit-of`` run (and
1072
+ every other future agent on this epic) sees a durable one-line note
1073
+ without re-fetching/re-parsing the GitHub comment.
1074
+ """
1075
+ prior_runs = get_audit_runs_for_epic(record.repo_name, record.issue_number)
1076
+ run_number = len(prior_runs) + 1
1077
+ prev_goals: list[dict] | None = None
1078
+ if prior_runs and prior_runs[-1].get("audit_goals_json"):
1079
+ try:
1080
+ prev_goals = json.loads(prior_runs[-1]["audit_goals_json"])
1081
+ except (TypeError, ValueError):
1082
+ prev_goals = None
1083
+ diff = diff_audit_goals(prev_goals, record.audit_goals) if prev_goals is not None else None
1084
+
1085
+ _persist_audit_result(record, run_number=run_number)
1086
+
1087
+ bottom_line = (record.audit_bottom_line or record.summary or "").strip()
1088
+ scorecard_body = format_audit_scorecard(
1089
+ assignment_id=record.assignment_id,
1090
+ run_number=run_number,
1091
+ bottom_line=bottom_line,
1092
+ goals=record.audit_goals,
1093
+ diff=diff,
1094
+ )
1095
+ completion_body = format_completion(
1096
+ assignment_id=record.assignment_id,
1097
+ machine_name=record.machine_name,
1098
+ repo_name=record.repo_name,
1099
+ issue_number=record.issue_number,
1100
+ exit_code=0,
1101
+ duration_seconds=record.duration_seconds,
1102
+ log_path=record.log_path,
1103
+ summary=record.summary or bottom_line,
1104
+ )
1105
+ posted, err = _post_github_comment(
1106
+ repo_github=record.repo_github,
1107
+ issue_number=record.issue_number,
1108
+ body=completion_body + "\n\n" + scorecard_body,
1109
+ )
1110
+ _update_local_state(
1111
+ assignment_id=record.assignment_id,
1112
+ terminal_status="done",
1113
+ branch=record.branch,
1114
+ review_state="pending",
1115
+ )
1116
+ _record_notification(
1117
+ assignment_id=record.assignment_id,
1118
+ event=EVENT_COMPLETION,
1119
+ branch=record.branch,
1120
+ )
1121
+ # #603: durable one-line finding for every future agent on this epic —
1122
+ # the "re-ask the question" payoff without re-parsing the GitHub comment.
1123
+ try:
1124
+ from coord.state import _add_issue_context_entry_local # noqa: PLC0415
1125
+
1126
+ total = len(record.audit_goals)
1127
+ met = sum(1 for g in record.audit_goals if g.get("verdict") == "met")
1128
+ gap = sum(1 for g in record.audit_goals if g.get("verdict") == "gap")
1129
+ partial = total - met - gap
1130
+ note = f"Audit v{run_number}: {met}/{total} goals met"
1131
+ if partial:
1132
+ note += f", {partial} partial"
1133
+ if gap:
1134
+ note += f", {gap} gap"
1135
+ if diff:
1136
+ if diff.get("closed"):
1137
+ note += f" — closed: {', '.join(diff['closed'])}"
1138
+ if diff.get("still_open"):
1139
+ note += f"; still open: {', '.join(diff['still_open'])}"
1140
+ if diff.get("regressed"):
1141
+ note += f"; REGRESSED: {', '.join(diff['regressed'])}"
1142
+ _add_issue_context_entry_local(
1143
+ record.repo_name, record.issue_number, note, source="audit",
1144
+ )
1145
+ except Exception: # noqa: BLE001 — best-effort; never blocks the write
1146
+ pass
1147
+ return StoreOutcome(
1148
+ status="done", event=EVENT_COMPLETION, posted=posted, error=err,
1149
+ )
1150
+
1151
+
1152
+ def _post_result_local(record: ResultRecord) -> StoreOutcome:
1153
+ """Structured report from the interactive agent (local-DB write).
1154
+
1155
+ Maps the agent-reported ``status`` to the same three terminal states
1156
+ the git-floor backstop produces:
1157
+
1158
+ * ``done`` → ``done`` (eligible for review/smoke). If a
1159
+ ``verdict`` was supplied (only meaningful for a review session
1160
+ where no commits exist) it is recorded on the assignment row so
1161
+ the merge gate (``has_approved_review``) sees the same field a
1162
+ claude-p reviewer would have populated.
1163
+ * ``blocked`` → ``failed`` (the operator explicitly says
1164
+ the work cannot proceed; pipeline treats it the same as a worker
1165
+ that exited non-zero).
1166
+ * ``already-implemented`` → ``advisory`` (same shape as a 0-commit
1167
+ clean exit; not a clean DONE, not a hard FAIL → no auto_reassign
1168
+ loop).
1169
+ """
1170
+ _validate_result(record)
1171
+
1172
+ # Invariant: a review verdict belongs ONLY on a type="review" assignment.
1173
+ # A `report-result --verdict` misrouted onto a work/plan/smoke id would mark
1174
+ # that row done AND stamp a bogus review_verdict — exactly what silently
1175
+ # finalized a still-live interactive WORK session (#646: a claude-pty work
1176
+ # row ended up status=done + review_verdict=approve with no review row in
1177
+ # sight, which hid the TUI reattach option). Refuse the misrouted write at
1178
+ # this single seam so the bad state is unrepresentable and the caller learns
1179
+ # it targeted the wrong id. Only gate when the type is KNOWN and not
1180
+ # "review" — an unknown id (row not yet visible) falls through to the
1181
+ # existing no-op UPDATE rather than erroring on a benign race.
1182
+ if record.verdict is not None:
1183
+ atype = _assignment_type_local(record.assignment_id)
1184
+ if atype is not None and atype != "review":
1185
+ raise ValueError(
1186
+ f"refusing to record a review verdict on assignment "
1187
+ f"{record.assignment_id!r}: it is type={atype!r}, not 'review'. "
1188
+ "A verdict belongs on a review assignment — re-run "
1189
+ "`coord report-result` with the review id. A verdict on a "
1190
+ "non-review row marks it done and stamps a bogus review_verdict "
1191
+ "(the #646 premature-finalize of a live interactive session)."
1192
+ )
1193
+
1194
+ # #886 Phase 2: same misrouting invariant as the review-verdict gate above,
1195
+ # but for the structured audit verdict — it belongs ONLY on a type="audit"
1196
+ # assignment (see #885's --audit-of). Only gate when the type is KNOWN.
1197
+ if record.audit_goals is not None:
1198
+ atype = _assignment_type_local(record.assignment_id)
1199
+ if atype is not None and atype != "audit":
1200
+ raise ValueError(
1201
+ f"refusing to record a structured audit verdict on assignment "
1202
+ f"{record.assignment_id!r}: it is type={atype!r}, not 'audit'. "
1203
+ "--audit-json belongs on a --audit-of assignment (#886) — "
1204
+ "re-run `coord report-result` with the audit id."
1205
+ )
1206
+
1207
+ # #676: chat and troubleshoot sessions are non-mutating diagnostics — they
1208
+ # never produce committed work and therefore must never claim `done` or
1209
+ # `blocked` (both map to a terminal state that can advance or stall the
1210
+ # pipeline). A chat session claiming `done` without committed work is a
1211
+ # false success that masks the real problem (#676 root-mechanism comment).
1212
+ # `already-implemented` → `advisory` is the one neutral signal allowed,
1213
+ # because it expresses "no work was needed" without a false done/fail.
1214
+ # Only gate when the type is KNOWN — an unknown row falls through so a
1215
+ # transient DB lookup failure never blocks a legitimate write.
1216
+ if record.status in (STATUS_DONE, STATUS_BLOCKED):
1217
+ atype = _assignment_type_local(record.assignment_id)
1218
+ if atype in ("chat", "troubleshoot"):
1219
+ raise ValueError(
1220
+ f"refusing to record status={record.status!r} on assignment "
1221
+ f"{record.assignment_id!r}: it is type={atype!r}, a non-mutating "
1222
+ "diagnostic session. A chat/troubleshoot session cannot claim "
1223
+ "'done' or 'blocked' without committed+pushed work — use "
1224
+ "`coord assign --work` to dispatch actual work (#676)."
1225
+ )
1226
+
1227
+ if record.status == STATUS_BLOCKED:
1228
+ # Render as failure on the issue and in the DB. This keeps the
1229
+ # auto-reassign default OFF unless the user explicitly opts in
1230
+ # (concurrency.auto_reassign): mirroring how a claude -p worker
1231
+ # exit-1 is handled today.
1232
+ body = format_failure(
1233
+ assignment_id=record.assignment_id,
1234
+ machine_name=record.machine_name,
1235
+ repo_name=record.repo_name,
1236
+ issue_number=record.issue_number,
1237
+ exit_code=1,
1238
+ duration_seconds=record.duration_seconds,
1239
+ log_path=record.log_path,
1240
+ error=record.summary or "Operator reported the session as blocked.",
1241
+ )
1242
+ posted, err = _post_github_comment(
1243
+ repo_github=record.repo_github,
1244
+ issue_number=record.issue_number,
1245
+ body=body,
1246
+ )
1247
+ _update_local_state(
1248
+ assignment_id=record.assignment_id,
1249
+ terminal_status="failed",
1250
+ branch=record.branch,
1251
+ review_state=None,
1252
+ )
1253
+ _record_notification(
1254
+ assignment_id=record.assignment_id,
1255
+ event=EVENT_FAILURE,
1256
+ branch=record.branch,
1257
+ )
1258
+ return StoreOutcome(
1259
+ status="failed", event=EVENT_FAILURE, posted=posted, error=err,
1260
+ )
1261
+
1262
+ if record.status == STATUS_ALREADY_IMPLEMENTED:
1263
+ body = format_advisory(
1264
+ assignment_id=record.assignment_id,
1265
+ machine_name=record.machine_name,
1266
+ repo_name=record.repo_name,
1267
+ issue_number=record.issue_number,
1268
+ duration_seconds=record.duration_seconds,
1269
+ log_path=record.log_path,
1270
+ reason=record.summary or "Operator reported: already implemented.",
1271
+ )
1272
+ posted, err = _post_github_comment(
1273
+ repo_github=record.repo_github,
1274
+ issue_number=record.issue_number,
1275
+ body=body,
1276
+ )
1277
+ _update_local_state(
1278
+ assignment_id=record.assignment_id,
1279
+ terminal_status="advisory",
1280
+ branch=record.branch,
1281
+ # Mark review_state=advisory so the reconcile review-dispatch
1282
+ # loop skips this entry (mirrors #448's advisory handling).
1283
+ review_state="advisory",
1284
+ )
1285
+ _record_notification(
1286
+ assignment_id=record.assignment_id,
1287
+ event=EVENT_ADVISORY,
1288
+ branch=record.branch,
1289
+ )
1290
+ return StoreOutcome(
1291
+ status="advisory", event=EVENT_ADVISORY, posted=posted, error=err,
1292
+ )
1293
+
1294
+ # status == "done"
1295
+ # #886 Phase 2: a structured audit verdict routes through its own
1296
+ # dual-write path (assignment row + epic comment + #603 context store)
1297
+ # instead of the generic done-comment body below.
1298
+ if record.audit_goals is not None:
1299
+ return _post_audit_result_path(record)
1300
+
1301
+ summary_lines: list[str] = []
1302
+ if record.summary.strip():
1303
+ summary_lines.append(record.summary.strip())
1304
+ if record.verdict is not None:
1305
+ summary_lines.append("")
1306
+ summary_lines.append(f"**Verdict:** {record.verdict}")
1307
+ body = format_completion(
1308
+ assignment_id=record.assignment_id,
1309
+ machine_name=record.machine_name,
1310
+ repo_name=record.repo_name,
1311
+ issue_number=record.issue_number,
1312
+ exit_code=0,
1313
+ duration_seconds=record.duration_seconds,
1314
+ log_path=record.log_path,
1315
+ summary="\n".join(summary_lines),
1316
+ )
1317
+ # Embed the full findings under a parseable marker so a fix worker can
1318
+ # recover them from the GitHub message bus on ANY machine (no shared DB).
1319
+ if record.findings_body and record.findings_body.strip():
1320
+ from coord.comments import format_findings_block # noqa: PLC0415
1321
+ body = body + "\n\n" + format_findings_block(
1322
+ record.assignment_id, record.verdict, record.findings_body.strip()
1323
+ )
1324
+ posted, err = _post_github_comment(
1325
+ repo_github=record.repo_github,
1326
+ issue_number=record.issue_number,
1327
+ body=body,
1328
+ )
1329
+ # review_state=pending so reconcile picks it up like a claude -p worker.
1330
+ _update_local_state(
1331
+ assignment_id=record.assignment_id,
1332
+ terminal_status="done",
1333
+ branch=record.branch,
1334
+ review_state="pending",
1335
+ )
1336
+ _record_notification(
1337
+ assignment_id=record.assignment_id,
1338
+ event=EVENT_COMPLETION,
1339
+ branch=record.branch,
1340
+ )
1341
+ # When a verdict was supplied (review session — no commits) record it
1342
+ # on the assignment row so the merge-gate sees the same field a
1343
+ # claude -p reviewer's parsed REVIEW_VERDICT would have set. When the full
1344
+ # findings body was also supplied (--body-file), persist BOTH together via
1345
+ # the same JSON column the claude -p path uses, so the fix worker's DB-cache
1346
+ # lookup (load_assignment_review_findings) hits on this machine.
1347
+ findings_written = True
1348
+ if record.verdict is not None:
1349
+ findings_written = _persist_review_verdict(record)
1350
+ # #1956: stamp provenance alongside the verdict itself. Best-effort
1351
+ # by design (a provenance-column write failure must not turn an
1352
+ # already-durably-persisted verdict into a reported CLI failure —
1353
+ # `_persist_review_verdict` above is the one write this function
1354
+ # raises loudly for) but still ALWAYS attempted, defaulting to
1355
+ # "agent" when the caller didn't say otherwise — the common case
1356
+ # (an agent self-reporting its own session) is the overwhelming
1357
+ # majority of `report-result --verdict` calls and must read as
1358
+ # exactly that, not as an unlabeled NULL indistinguishable from a
1359
+ # pre-#1956 row.
1360
+ _persist_verdict_source(record)
1361
+ # #603: a request-changes verdict is durable context for EVERY future agent
1362
+ # on the issue — record a short note in the per-issue digest (local writer;
1363
+ # daemon-side on a thin client, so use the _local variant).
1364
+ #
1365
+ # #650: only when the findings write actually landed. When the clobber
1366
+ # guard refused it (a re-capture of an already-recorded review), this is
1367
+ # a duplicate call for a review the issue already has a context entry
1368
+ # for — adding another one was the second half of the #650 incident.
1369
+ if record.verdict == VERDICT_REQUEST_CHANGES and findings_written:
1370
+ try:
1371
+ from coord.state import _add_issue_context_entry_local # noqa: PLC0415
1372
+
1373
+ summary = (record.findings_body or record.summary or "").strip()
1374
+ if summary:
1375
+ if len(summary) > 240:
1376
+ summary = summary[:240].rstrip() + "…"
1377
+ _add_issue_context_entry_local(
1378
+ record.repo_name,
1379
+ record.issue_number,
1380
+ f"Review requested changes: {summary}",
1381
+ source="review",
1382
+ )
1383
+ except Exception: # noqa: BLE001 — best-effort
1384
+ pass
1385
+ return StoreOutcome(
1386
+ status="done",
1387
+ event=EVENT_COMPLETION,
1388
+ posted=posted,
1389
+ error=err,
1390
+ findings_written=findings_written,
1391
+ )
1392
+
1393
+
1394
+ # ── private terminal-path helpers for post_completion ──────────────────────
1395
+
1396
+
1397
+ def _post_done_path(record: CompletionRecord) -> StoreOutcome:
1398
+ body = format_completion(
1399
+ assignment_id=record.assignment_id,
1400
+ machine_name=record.machine_name,
1401
+ repo_name=record.repo_name,
1402
+ issue_number=record.issue_number,
1403
+ exit_code=record.exit_code,
1404
+ duration_seconds=record.duration_seconds,
1405
+ log_path=record.log_path,
1406
+ summary=record.summary,
1407
+ )
1408
+ posted, err = _post_github_comment(
1409
+ repo_github=record.repo_github,
1410
+ issue_number=record.issue_number,
1411
+ body=body,
1412
+ )
1413
+ _update_local_state(
1414
+ assignment_id=record.assignment_id,
1415
+ terminal_status="done",
1416
+ branch=record.branch,
1417
+ review_state="pending",
1418
+ )
1419
+ _record_notification(
1420
+ assignment_id=record.assignment_id,
1421
+ event=EVENT_COMPLETION,
1422
+ branch=record.branch,
1423
+ actor="coordinator",
1424
+ )
1425
+ return StoreOutcome(
1426
+ status="done", event=EVENT_COMPLETION, posted=posted, error=err,
1427
+ )
1428
+
1429
+
1430
+ def _post_advisory_path(record: CompletionRecord) -> StoreOutcome:
1431
+ reason = record.summary or (
1432
+ "Interactive session exited cleanly but pushed 0 commits "
1433
+ "and produced no structured result via `coord report-result`."
1434
+ )
1435
+ body = format_advisory(
1436
+ assignment_id=record.assignment_id,
1437
+ machine_name=record.machine_name,
1438
+ repo_name=record.repo_name,
1439
+ issue_number=record.issue_number,
1440
+ duration_seconds=record.duration_seconds,
1441
+ log_path=record.log_path,
1442
+ reason=reason,
1443
+ )
1444
+ posted, err = _post_github_comment(
1445
+ repo_github=record.repo_github,
1446
+ issue_number=record.issue_number,
1447
+ body=body,
1448
+ )
1449
+ _update_local_state(
1450
+ assignment_id=record.assignment_id,
1451
+ terminal_status="advisory",
1452
+ branch=record.branch,
1453
+ review_state="advisory",
1454
+ )
1455
+ _record_notification(
1456
+ assignment_id=record.assignment_id,
1457
+ event=EVENT_ADVISORY,
1458
+ branch=record.branch,
1459
+ actor="coordinator",
1460
+ )
1461
+ return StoreOutcome(
1462
+ status="advisory", event=EVENT_ADVISORY, posted=posted, error=err,
1463
+ )
1464
+
1465
+
1466
+ def _post_failure_path(record: CompletionRecord) -> StoreOutcome:
1467
+ body = format_failure(
1468
+ assignment_id=record.assignment_id,
1469
+ machine_name=record.machine_name,
1470
+ repo_name=record.repo_name,
1471
+ issue_number=record.issue_number,
1472
+ exit_code=record.exit_code,
1473
+ duration_seconds=record.duration_seconds,
1474
+ log_path=record.log_path,
1475
+ error=record.summary or f"Interactive session exited with status {record.exit_code}.",
1476
+ )
1477
+ posted, err = _post_github_comment(
1478
+ repo_github=record.repo_github,
1479
+ issue_number=record.issue_number,
1480
+ body=body,
1481
+ )
1482
+ _update_local_state(
1483
+ assignment_id=record.assignment_id,
1484
+ terminal_status="failed",
1485
+ branch=record.branch,
1486
+ review_state=None,
1487
+ )
1488
+ _record_notification(
1489
+ assignment_id=record.assignment_id,
1490
+ event=EVENT_FAILURE,
1491
+ branch=record.branch,
1492
+ actor="coordinator",
1493
+ )
1494
+ return StoreOutcome(
1495
+ status="failed", event=EVENT_FAILURE, posted=posted, error=err,
1496
+ )