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.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
coord/commands/review.py
ADDED
|
@@ -0,0 +1,1523 @@
|
|
|
1
|
+
"""Review-gate commands: `report-result`, `set-review-findings`,
|
|
2
|
+
`fix-briefing`, and the shared `_prompt_and_relay_review_verdict` helper
|
|
3
|
+
used by both the dispatch and sessions modules. Extracted from
|
|
4
|
+
coord/cli.py (#747)."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import shlex
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
from coord.commands._common import _CONFIG_OPTION, _load_config
|
|
17
|
+
|
|
18
|
+
log = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _collect_review_body_via_editor(
|
|
22
|
+
*, assignment_id: str, summary: str, pre_body: str | None = None
|
|
23
|
+
) -> str | None:
|
|
24
|
+
"""Open ``$EDITOR`` for the operator to enter the full review findings (#617).
|
|
25
|
+
|
|
26
|
+
Last-resort body capture for :func:`_prompt_and_relay_review_verdict` when
|
|
27
|
+
neither a durable ``coord report-result`` nor the (remote-aware)
|
|
28
|
+
transcript-floor produced the findings. A ``request-changes`` verdict must
|
|
29
|
+
never be recorded bodyless — the write seam refuses it and the fix worker
|
|
30
|
+
would be dispatched with nothing to fix (#607) — so this collects the body
|
|
31
|
+
the operator just wrote in the review session.
|
|
32
|
+
|
|
33
|
+
When *pre_body* is provided it is used as the initial seed, so the operator
|
|
34
|
+
edits or confirms already-recovered findings rather than typing from scratch
|
|
35
|
+
(#877 editor-blank fix).
|
|
36
|
+
|
|
37
|
+
Returns the entered body (template comment lines stripped) or ``None`` when
|
|
38
|
+
empty / no editor available, so the caller can refuse + print the manual
|
|
39
|
+
``--body-file`` hint.
|
|
40
|
+
"""
|
|
41
|
+
template = (
|
|
42
|
+
"\n\n"
|
|
43
|
+
f"# ── Review findings for assignment {assignment_id} ───────────────\n"
|
|
44
|
+
"# Enter the full findings above (Markdown). Every BLOCKING item, with\n"
|
|
45
|
+
"# file:line. This is exactly what the fix worker is briefed with, and\n"
|
|
46
|
+
"# what the #603 per-issue context store records for every future\n"
|
|
47
|
+
"# iteration on this issue.\n"
|
|
48
|
+
"# Lines starting with '#' are ignored. Save an empty file to cancel.\n"
|
|
49
|
+
)
|
|
50
|
+
if pre_body and pre_body.strip():
|
|
51
|
+
seed = pre_body.strip() + "\n" + template
|
|
52
|
+
else:
|
|
53
|
+
seed = (f"{summary.strip()}\n" if summary.strip() else "") + template
|
|
54
|
+
try:
|
|
55
|
+
edited = click.edit(seed)
|
|
56
|
+
except Exception: # noqa: BLE001 — no editor / editor failed → treat as cancel
|
|
57
|
+
edited = None
|
|
58
|
+
if not edited:
|
|
59
|
+
return None
|
|
60
|
+
body = "\n".join(
|
|
61
|
+
ln for ln in edited.splitlines() if not ln.lstrip().startswith("#")
|
|
62
|
+
).strip()
|
|
63
|
+
return body or None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _prompt_and_relay_review_verdict(
|
|
67
|
+
*,
|
|
68
|
+
assignment_id: str,
|
|
69
|
+
repo_name: str,
|
|
70
|
+
repo_github: str,
|
|
71
|
+
issue_number: int,
|
|
72
|
+
machine_name: str,
|
|
73
|
+
verdict_cmd_hint: str,
|
|
74
|
+
started_at: float | None = None,
|
|
75
|
+
ssh_target: str | None = None,
|
|
76
|
+
) -> bool:
|
|
77
|
+
"""Prompt the operator for a review verdict on exit and relay it (#486d / #877).
|
|
78
|
+
|
|
79
|
+
Backstop used by BOTH interactive-review exit paths when the reviewer left
|
|
80
|
+
without running `coord report-result` (local or remote — since #590 a
|
|
81
|
+
remote `report-result` routes to the coordinator's shared DB via the daemon,
|
|
82
|
+
so both paths *can* self-report; this prompt only fires when they didn't).
|
|
83
|
+
|
|
84
|
+
Without it the verdict silently never reaches the merge gate and the
|
|
85
|
+
Work→Review→Fix flow stalls. Prompt the operator here (the terminal is a
|
|
86
|
+
TTY) and relay through the same `issue_store` seam `coord report-result`
|
|
87
|
+
uses — which itself routes to the daemon when `board_service` is set.
|
|
88
|
+
|
|
89
|
+
**#877 board-content gate (primary)**: before prompting, read the assignment
|
|
90
|
+
from the local DB for ``review_verdict`` + ``review_findings``. When both
|
|
91
|
+
are present the editor is NOT opened — the already-captured body is used
|
|
92
|
+
directly and the prompt defaults to the captured verdict (not ``[s]``).
|
|
93
|
+
This handles the status=failed-with-valid-verdict inconsistency where the
|
|
94
|
+
board already captured the findings (e.g. via ``notify`` or a previous
|
|
95
|
+
partial attempt) but ``already_recorded`` was False when ``finalize`` ran.
|
|
96
|
+
|
|
97
|
+
**#877 remote-transcript backstop (secondary)**: when the board is empty and
|
|
98
|
+
``ssh_target`` is set, the remote transcript-floor is re-run against the
|
|
99
|
+
session's own host before any editor is opened. Findings recovered here
|
|
100
|
+
also seed the editor so it is never blank.
|
|
101
|
+
|
|
102
|
+
**#1348 parse-failure diagnostic**: when neither the board nor the transcript
|
|
103
|
+
floor recovered a verdict, but a ``REVIEW_VERDICT:`` marker WAS found in a
|
|
104
|
+
transcript that passes both attribution gates, the operator sees a distinct
|
|
105
|
+
"REVIEW PARSE FAILED" message (not the generic "no verdict reported"), a
|
|
106
|
+
greppable ``log.warning`` with host + path, and the editor opens pre-seeded
|
|
107
|
+
with the recovered excerpt. When the marker line carries a recognisable
|
|
108
|
+
verdict word, the prompt defaults to it instead of ``[s]kip``. The operator
|
|
109
|
+
still confirms; nothing is auto-recorded.
|
|
110
|
+
|
|
111
|
+
*started_at*: session start timestamp (epoch). Passed to the transcript-
|
|
112
|
+
floor so only transcripts active during this session are considered. When
|
|
113
|
+
``None``, the remote scan uses a permissive ``cutoff=0`` (bounded solely by
|
|
114
|
+
the issue-number gate) and the local diagnostic scan is skipped.
|
|
115
|
+
|
|
116
|
+
*ssh_target*: SSH hostname of the machine where the review session ran.
|
|
117
|
+
``None`` for local sessions; set to ``machine.host`` for remote reviews.
|
|
118
|
+
|
|
119
|
+
No-op that prints the manual hint when stdin isn't a TTY AND no pre-
|
|
120
|
+
captured data is available (tests / headless invocations where no prior
|
|
121
|
+
recording happened).
|
|
122
|
+
Returns True when a verdict was recorded.
|
|
123
|
+
"""
|
|
124
|
+
# ── Step 1: board-content gate (#877) ───────────────────────────────────
|
|
125
|
+
# Check the local DB first (cheapest) for already-captured verdict+findings.
|
|
126
|
+
# This is intentionally BEFORE the TTY check so headless callers also benefit
|
|
127
|
+
# (auto-relay the captured data without prompting).
|
|
128
|
+
_pre_verdict: str | None = None
|
|
129
|
+
_pre_body: str | None = None
|
|
130
|
+
# #1348: diagnostic out-parameter. A non-None list activates unparsed-marker
|
|
131
|
+
# detection in the transcript scans below; the first matching UnparsedReviewMarker
|
|
132
|
+
# (newest transcript) is appended when the strict parse failed.
|
|
133
|
+
_unparsed_markers: list = []
|
|
134
|
+
try:
|
|
135
|
+
from coord.state import load_assignment_review_findings # noqa: PLC0415
|
|
136
|
+
|
|
137
|
+
_cached = load_assignment_review_findings(assignment_id)
|
|
138
|
+
if _cached is not None:
|
|
139
|
+
_pre_verdict, _pre_body = _cached
|
|
140
|
+
except Exception as exc: # noqa: BLE001 — #1349: log + surface, don't swallow
|
|
141
|
+
# A *read* failure (timeout/transport/auth) is NOT the same outcome as
|
|
142
|
+
# "board genuinely has no verdict yet" — collapsing the two here would
|
|
143
|
+
# reproduce the #1349 incident (a read failure silently masquerading as
|
|
144
|
+
# "the agent never reported", re-prompting the operator with zero
|
|
145
|
+
# evidence of which happened). Falling through to Step 2 / the prompt
|
|
146
|
+
# below is still fine — the eventual write is idempotent — but it must
|
|
147
|
+
# not happen silently.
|
|
148
|
+
log.warning(
|
|
149
|
+
"review-verdict board-content gate: could not read cached verdict/"
|
|
150
|
+
"findings for assignment %s: %s", assignment_id, exc,
|
|
151
|
+
)
|
|
152
|
+
click.echo(
|
|
153
|
+
f" warning: could not read the board for a cached review verdict "
|
|
154
|
+
f"on {assignment_id}: {exc}\n"
|
|
155
|
+
" This does NOT mean no verdict was captured — the read itself "
|
|
156
|
+
"failed. Falling back to the prompt below.",
|
|
157
|
+
err=True,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
# ── Step 2: remote transcript-floor (#877) ──────────────────────────────
|
|
161
|
+
# When board is empty and ssh_target is known, re-run the transcript-floor
|
|
162
|
+
# against the session's own host. Reuses the same floor that
|
|
163
|
+
# finalize_interactive_exit already attempted — a 2nd pass here catches the
|
|
164
|
+
# flush-race / timing window where the JSONL wasn't fully written yet when
|
|
165
|
+
# finalize ran. started_at=None falls through to cutoff=0 (all transcripts
|
|
166
|
+
# in the remote listing) — still bounded by the issue-number gate.
|
|
167
|
+
if _pre_verdict is None and ssh_target:
|
|
168
|
+
try:
|
|
169
|
+
from coord.interactive import ( # noqa: PLC0415
|
|
170
|
+
_review_findings_from_transcript,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
_tf = _review_findings_from_transcript(
|
|
174
|
+
issue_number, started_at, assignment_id=assignment_id,
|
|
175
|
+
ssh_target=ssh_target,
|
|
176
|
+
_diagnostic=_unparsed_markers, # #1348
|
|
177
|
+
)
|
|
178
|
+
if _tf is not None:
|
|
179
|
+
_pre_verdict = _tf.verdict
|
|
180
|
+
_pre_body = _tf.body
|
|
181
|
+
except Exception: # noqa: BLE001 — ssh unavailable → fall through
|
|
182
|
+
pass
|
|
183
|
+
|
|
184
|
+
# ── Step 2b: local transcript diagnostic scan (#1348) ───────────────────
|
|
185
|
+
# For LOCAL sessions (no ssh_target) the transcript-floor already ran in
|
|
186
|
+
# finalize_interactive_exit without diagnostic collection, so any unparsed
|
|
187
|
+
# marker was silently missed. Re-run here with _unparsed_markers to catch
|
|
188
|
+
# the case — same 2nd-pass rationale as Step 2, same attribution gates,
|
|
189
|
+
# same "first/newest hit only" semantics. Skipped when started_at is None
|
|
190
|
+
# (no bounded window → too likely to match a stale unrelated transcript).
|
|
191
|
+
if _pre_verdict is None and not ssh_target and started_at is not None:
|
|
192
|
+
try:
|
|
193
|
+
from coord.interactive import ( # noqa: PLC0415
|
|
194
|
+
_review_findings_from_transcript,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
_tf2 = _review_findings_from_transcript(
|
|
198
|
+
issue_number, started_at, assignment_id=assignment_id,
|
|
199
|
+
ssh_target=None,
|
|
200
|
+
_diagnostic=_unparsed_markers, # #1348
|
|
201
|
+
)
|
|
202
|
+
if _tf2 is not None:
|
|
203
|
+
_pre_verdict = _tf2.verdict
|
|
204
|
+
_pre_body = _tf2.body
|
|
205
|
+
except Exception: # noqa: BLE001 — scan failed → fall through
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
# ── #1348: parse-failure diagnostic ─────────────────────────────────────
|
|
209
|
+
# No verdict recovered, but a REVIEW_VERDICT: marker WAS found in a
|
|
210
|
+
# transcript that passed both attribution gates. This is a different failure
|
|
211
|
+
# from "no verdict reported": the review text IS there; the strict parser
|
|
212
|
+
# rejected it (e.g. bolded markers, missing END_REVIEW — the #1346 incident).
|
|
213
|
+
# Make the distinction operator-visible and surface the excerpt so the editor
|
|
214
|
+
# is never blank.
|
|
215
|
+
#
|
|
216
|
+
# _marker_excerpt: excerpt from the unparsed marker, passed to the editor as
|
|
217
|
+
# pre_body (distinct from _pre_body, which is a fully-captured body that
|
|
218
|
+
# bypasses the editor — the excerpt must always open the editor so the
|
|
219
|
+
# operator can clean it up).
|
|
220
|
+
# _marker_default: normalised verdict for the prompt default.
|
|
221
|
+
_marker_excerpt: str | None = None
|
|
222
|
+
_marker_default: str | None = None
|
|
223
|
+
if _pre_verdict is None and _unparsed_markers:
|
|
224
|
+
_um = _unparsed_markers[0]
|
|
225
|
+
_loc = _um.transcript_path or "(unknown path)"
|
|
226
|
+
_host_desc = f" on {_um.host!r}" if _um.host else ""
|
|
227
|
+
log.warning(
|
|
228
|
+
"[#1348] review parse FAILED for assignment %s: "
|
|
229
|
+
"REVIEW_VERDICT: marker found in transcript %r%s "
|
|
230
|
+
"but _REVIEW_BLOCK_RE rejected it — surfacing excerpt for operator "
|
|
231
|
+
"confirmation (detected verdict word: %r)",
|
|
232
|
+
assignment_id, _loc, _host_desc, _um.verdict_word,
|
|
233
|
+
)
|
|
234
|
+
_inspect_hint = (
|
|
235
|
+
f"ssh {_um.host} cat {shlex.quote(_loc)}" if _um.host
|
|
236
|
+
else f"cat {shlex.quote(_loc)}"
|
|
237
|
+
)
|
|
238
|
+
click.echo(
|
|
239
|
+
f"\n ⚠ REVIEW PARSE FAILED\n"
|
|
240
|
+
f" Transcript: {_loc}{_host_desc}\n"
|
|
241
|
+
" A REVIEW_VERDICT: marker was found in the above transcript but the\n"
|
|
242
|
+
" strict parser could not extract the findings (e.g. bolded markers\n"
|
|
243
|
+
" like **REVIEW_VERDICT:** or a missing END_REVIEW terminator).\n"
|
|
244
|
+
"\n"
|
|
245
|
+
" This is NOT 'no verdict reported' — the review text IS present.\n"
|
|
246
|
+
" The excerpt has been pre-loaded into the editor for confirmation.\n"
|
|
247
|
+
f" To inspect the raw transcript: {_inspect_hint}"
|
|
248
|
+
)
|
|
249
|
+
_marker_excerpt = _um.excerpt
|
|
250
|
+
# Normalize the detected verdict word: aliases (pass→approve,
|
|
251
|
+
# fail→request-changes) and canonical forms are both valid defaults.
|
|
252
|
+
if _um.verdict_word:
|
|
253
|
+
from coord.review import _VERDICT_ALIASES # noqa: PLC0415
|
|
254
|
+
_norm = _VERDICT_ALIASES.get(_um.verdict_word, _um.verdict_word)
|
|
255
|
+
if _norm in ("approve", "request-changes"):
|
|
256
|
+
_marker_default = _norm
|
|
257
|
+
|
|
258
|
+
# ── Surface pre-captured findings ───────────────────────────────────────
|
|
259
|
+
if _pre_verdict is not None:
|
|
260
|
+
click.echo(f"\n Captured review verdict: {_pre_verdict!r}")
|
|
261
|
+
if _pre_body:
|
|
262
|
+
_preview = _pre_body[:300].rstrip()
|
|
263
|
+
if len(_pre_body) > 300:
|
|
264
|
+
_preview += f"\n … ({len(_pre_body)} chars total)"
|
|
265
|
+
click.echo(f" Findings preview: {_preview}\n")
|
|
266
|
+
|
|
267
|
+
# ── Non-TTY path ─────────────────────────────────────────────────────────
|
|
268
|
+
if not sys.stdin.isatty():
|
|
269
|
+
if _pre_verdict is None:
|
|
270
|
+
if _marker_excerpt is not None:
|
|
271
|
+
# #1348: parse failed — different message from "no verdict".
|
|
272
|
+
_um = _unparsed_markers[0]
|
|
273
|
+
_loc = _um.transcript_path or "(unknown path)"
|
|
274
|
+
_host_desc = f" on {_um.host!r}" if _um.host else ""
|
|
275
|
+
click.echo(
|
|
276
|
+
f" review parse FAILED (non-TTY): REVIEW_VERDICT: marker found in "
|
|
277
|
+
f"{_loc!r}{_host_desc} but strict parser rejected it "
|
|
278
|
+
f"(detected verdict: {_um.verdict_word!r}). "
|
|
279
|
+
f"Cannot open editor without a TTY. Recover manually with:\n"
|
|
280
|
+
f"{verdict_cmd_hint}"
|
|
281
|
+
)
|
|
282
|
+
else:
|
|
283
|
+
click.echo(f" no verdict reported — record it with:\n{verdict_cmd_hint}")
|
|
284
|
+
return False
|
|
285
|
+
# Headless + pre-captured: auto-relay (no prompt, no editor).
|
|
286
|
+
# Applies to CI / daemon scenarios where both verdict AND body are
|
|
287
|
+
# already in the board — no human input needed.
|
|
288
|
+
if _pre_verdict == "request-changes" and not _pre_body:
|
|
289
|
+
click.echo(
|
|
290
|
+
" headless: captured verdict is request-changes but findings body "
|
|
291
|
+
f"is missing — record manually with:\n{verdict_cmd_hint}"
|
|
292
|
+
)
|
|
293
|
+
return False
|
|
294
|
+
verdict: str = _pre_verdict
|
|
295
|
+
summary: str = ""
|
|
296
|
+
findings_body: str | None = _pre_body
|
|
297
|
+
else:
|
|
298
|
+
# ── TTY prompt ──────────────────────────────────────────────────────
|
|
299
|
+
# Default: board verdict > detected verdict from unparsed marker > [s]kip.
|
|
300
|
+
# _pre_verdict is the fully-parsed, confirmed one; _marker_default is the
|
|
301
|
+
# marker-line word (only used when _pre_verdict is None — #1348).
|
|
302
|
+
_default = {"approve": "a", "request-changes": "r"}.get(
|
|
303
|
+
_pre_verdict or _marker_default or "", "s"
|
|
304
|
+
)
|
|
305
|
+
ans = click.prompt(
|
|
306
|
+
" Review verdict — [a]pprove / [r]equest-changes / [s]kip",
|
|
307
|
+
type=click.Choice(["a", "r", "s"], case_sensitive=False),
|
|
308
|
+
default=_default,
|
|
309
|
+
show_choices=True,
|
|
310
|
+
)
|
|
311
|
+
verdict = {"a": "approve", "r": "request-changes"}.get(ans.lower(), "")
|
|
312
|
+
if not verdict:
|
|
313
|
+
click.echo(f" skipped — record the verdict later with:\n{verdict_cmd_hint}")
|
|
314
|
+
return False
|
|
315
|
+
summary = click.prompt(
|
|
316
|
+
" one-line summary (optional, Enter to skip)", default="", show_default=False
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
# ── Findings body for request-changes ───────────────────────────────
|
|
320
|
+
# #617: request-changes MUST carry the full findings body.
|
|
321
|
+
# #877: when the board/transcript already has the body, use it directly
|
|
322
|
+
# (no blank editor). When missing, open the editor — seeded with:
|
|
323
|
+
# * the unparsed-marker excerpt (#1348), when the parse failed but
|
|
324
|
+
# we recovered text — so the operator edits what the reviewer wrote;
|
|
325
|
+
# * blank as a last resort, with a hint pointing at the transcript.
|
|
326
|
+
findings_body = None
|
|
327
|
+
if verdict == "request-changes":
|
|
328
|
+
if _pre_body:
|
|
329
|
+
# Pre-captured body — use it directly; editor not opened (#877).
|
|
330
|
+
findings_body = _pre_body
|
|
331
|
+
click.echo(
|
|
332
|
+
f" Using {len(findings_body)}-char findings body from "
|
|
333
|
+
"board/transcript (editor not opened)."
|
|
334
|
+
)
|
|
335
|
+
else:
|
|
336
|
+
# No pre-captured body — open editor, seeded with the recovered
|
|
337
|
+
# marker excerpt if available (#1348), or blank otherwise.
|
|
338
|
+
if _marker_excerpt:
|
|
339
|
+
# Parse-failure case: excerpt already printed above. Editor
|
|
340
|
+
# opens with the recovered text so the operator can clean it up.
|
|
341
|
+
pass
|
|
342
|
+
elif ssh_target:
|
|
343
|
+
click.echo(
|
|
344
|
+
f" Findings not recovered from {ssh_target!r}. "
|
|
345
|
+
"Opening editor — enter the full review (every blocking "
|
|
346
|
+
"item, file:line)."
|
|
347
|
+
)
|
|
348
|
+
click.echo(
|
|
349
|
+
f" To fetch the transcript manually: "
|
|
350
|
+
f"ssh {ssh_target} "
|
|
351
|
+
r"'find $HOME/.claude/projects -name \"*.jsonl\" "
|
|
352
|
+
r"| sort -rn | head -10'"
|
|
353
|
+
)
|
|
354
|
+
else:
|
|
355
|
+
click.echo(
|
|
356
|
+
" request-changes needs your full findings — opening an "
|
|
357
|
+
"editor (every blocking item, file:line)…"
|
|
358
|
+
)
|
|
359
|
+
findings_body = _collect_review_body_via_editor(
|
|
360
|
+
assignment_id=assignment_id, summary=summary,
|
|
361
|
+
pre_body=_marker_excerpt, # #1348: None when no marker was found
|
|
362
|
+
)
|
|
363
|
+
if not findings_body:
|
|
364
|
+
click.echo(
|
|
365
|
+
" verdict NOT recorded: request-changes requires the findings "
|
|
366
|
+
"body — recording it without one would strand the fix worker "
|
|
367
|
+
"(#607). Record it when ready with:\n"
|
|
368
|
+
f" coord report-result --assignment {assignment_id} "
|
|
369
|
+
"--status done --verdict request-changes "
|
|
370
|
+
f"--body-file /tmp/review-{assignment_id}.md",
|
|
371
|
+
err=True,
|
|
372
|
+
)
|
|
373
|
+
return False
|
|
374
|
+
|
|
375
|
+
# ── Relay through issue_store seam ──────────────────────────────────────
|
|
376
|
+
try:
|
|
377
|
+
from coord import issue_store # noqa: PLC0415
|
|
378
|
+
|
|
379
|
+
outcome = issue_store.post_result(
|
|
380
|
+
issue_store.ResultRecord(
|
|
381
|
+
assignment_id=assignment_id,
|
|
382
|
+
machine_name=machine_name,
|
|
383
|
+
repo_name=repo_name,
|
|
384
|
+
repo_github=repo_github,
|
|
385
|
+
issue_number=int(issue_number),
|
|
386
|
+
status="done",
|
|
387
|
+
verdict=verdict, # type: ignore[arg-type] # narrowed to approve/request-changes above
|
|
388
|
+
summary=summary,
|
|
389
|
+
branch=None,
|
|
390
|
+
findings_body=findings_body,
|
|
391
|
+
)
|
|
392
|
+
)
|
|
393
|
+
click.echo(
|
|
394
|
+
f" verdict '{verdict}' recorded (posted_to_github={outcome.posted})."
|
|
395
|
+
)
|
|
396
|
+
if not outcome.findings_written:
|
|
397
|
+
# #650: this is exactly the shape of the original incident — a
|
|
398
|
+
# second exit-prompt capture for a review that already has
|
|
399
|
+
# non-empty findings on the row. The stored verdict already
|
|
400
|
+
# matched (or `_persist_review_verdict` would have raised), so
|
|
401
|
+
# the good findings were preserved — just tell the operator.
|
|
402
|
+
click.echo(
|
|
403
|
+
" note: existing review findings were NOT overwritten "
|
|
404
|
+
"(#650 clobber guard) — this assignment already had "
|
|
405
|
+
"different, non-empty findings recorded; they are "
|
|
406
|
+
"preserved. Use `coord report-result ... --force` if this "
|
|
407
|
+
"overwrite was intentional."
|
|
408
|
+
)
|
|
409
|
+
if outcome.error:
|
|
410
|
+
click.echo(f" github post warning: {outcome.error}", err=True)
|
|
411
|
+
return True
|
|
412
|
+
except Exception as exc: # noqa: BLE001 — best-effort; fall back to the hint
|
|
413
|
+
click.echo(
|
|
414
|
+
f" warning: failed to record verdict inline: {exc}\n{verdict_cmd_hint}",
|
|
415
|
+
err=True,
|
|
416
|
+
)
|
|
417
|
+
return False
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _prompt_and_relay_test_verdict(
|
|
421
|
+
*,
|
|
422
|
+
work_assignment_id: str,
|
|
423
|
+
smoke_assignment_id: str,
|
|
424
|
+
repo_name: str,
|
|
425
|
+
repo_github: str,
|
|
426
|
+
issue_number: int,
|
|
427
|
+
machine_name: str,
|
|
428
|
+
verdict_cmd_hint: str,
|
|
429
|
+
) -> bool:
|
|
430
|
+
"""Prompt the operator for a Test-gate verdict on exit and relay it (#923).
|
|
431
|
+
|
|
432
|
+
Backstop for interactive SMOKE-OF sessions that exit without the agent
|
|
433
|
+
running ``coord test --passed|--fail|--skipped``. Without it the test
|
|
434
|
+
verdict is silently lost, the Test box greys, and the merge gate blocks
|
|
435
|
+
with no error message.
|
|
436
|
+
|
|
437
|
+
**Idempotent**: reads ``work_assignment_id`` from the board first; when
|
|
438
|
+
``test_state`` is already set (the agent DID run ``coord test``), this
|
|
439
|
+
function prints a confirmation and returns True immediately — no
|
|
440
|
+
double-prompt.
|
|
441
|
+
|
|
442
|
+
**#1349 point lookup**: the idempotency read is a single field off a
|
|
443
|
+
single row, so it uses ``GET /assignment/{id}`` (point endpoint, ~2.7 KB)
|
|
444
|
+
instead of the full ``GET /board`` collection (4.4 MB, 0.7-1.2s to build
|
|
445
|
+
against a 5s client timeout) — mirrors
|
|
446
|
+
:func:`coord.state.load_assignment_review_findings`'s point-lookup-first
|
|
447
|
+
shape, including its 404 compatibility fallback to the collection for a
|
|
448
|
+
pre-#1336 daemon. A read *failure* here is logged and surfaced to the
|
|
449
|
+
operator distinctly from "no verdict recorded" — see the incident writeup
|
|
450
|
+
in #1349: silently falling through to the prompt on any exception erased
|
|
451
|
+
the evidence of what actually happened.
|
|
452
|
+
|
|
453
|
+
Mirrors :func:`_prompt_and_relay_review_verdict` (which handles the REVIEW
|
|
454
|
+
stage). Same contract for headless callers: no-op + hint when stdin is
|
|
455
|
+
not a TTY.
|
|
456
|
+
|
|
457
|
+
*work_assignment_id*: the WORK assignment being tested (passed as
|
|
458
|
+
``smoke_of`` at dispatch time). The test verdict is ALWAYS recorded on
|
|
459
|
+
the work row, not the smoke session row.
|
|
460
|
+
|
|
461
|
+
*smoke_assignment_id*: the smoke session assignment id (used for log
|
|
462
|
+
messages only).
|
|
463
|
+
|
|
464
|
+
Returns True when a verdict was successfully recorded.
|
|
465
|
+
"""
|
|
466
|
+
# Context label for the log lines below — identifies which repo/issue/
|
|
467
|
+
# machine/smoke-session this backstop is acting on (#923 review nit: these
|
|
468
|
+
# params were previously accepted but never read).
|
|
469
|
+
_ctx = (
|
|
470
|
+
f"{repo_name} ({repo_github}) issue #{issue_number} on {machine_name} "
|
|
471
|
+
f"[smoke={smoke_assignment_id}]"
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
# ── Idempotency gate (#1349) ────────────────────────────────────────────
|
|
475
|
+
# Read the WORK row (not the smoke session) and check test_state. If it
|
|
476
|
+
# already has test_state set the agent self-reported — do nothing.
|
|
477
|
+
#
|
|
478
|
+
# Point lookup first (#1349): a thin client hits GET /assignment/{id}
|
|
479
|
+
# instead of paying for the full GET /board collection just to read one
|
|
480
|
+
# field off one row — see load_assignment_review_findings for the model
|
|
481
|
+
# this mirrors, including the 404 compatibility fallback for a
|
|
482
|
+
# pre-#1336 daemon. On the daemon host itself (no board_service
|
|
483
|
+
# configured) the local DB is canonical and this isn't an HTTP round
|
|
484
|
+
# trip at all, so the pre-existing local Board lookup is unchanged.
|
|
485
|
+
#
|
|
486
|
+
# A *read* failure must stay distinguishable from "board says no verdict"
|
|
487
|
+
# — the old code was `except Exception: pass`, which turned a timeout /
|
|
488
|
+
# transport / auth failure into the exact same outcome as "genuinely no
|
|
489
|
+
# verdict yet", silently re-prompting the operator with zero evidence of
|
|
490
|
+
# which happened. That is why the incident that motivated this fix
|
|
491
|
+
# (operator re-typing a verdict 5s after `coord test --passed` had
|
|
492
|
+
# already succeeded) could not be diagnosed from the logs afterwards —
|
|
493
|
+
# the failure erased its own evidence. Re-prompting after a read failure
|
|
494
|
+
# is still fine and safe (the write is idempotent); being SILENT about
|
|
495
|
+
# it is the bug.
|
|
496
|
+
_test_state: str | None = None
|
|
497
|
+
_read_exc: Exception | None = None
|
|
498
|
+
try:
|
|
499
|
+
from coord.board_service import resolve as _resolve_board_service # noqa: PLC0415
|
|
500
|
+
|
|
501
|
+
_svc = _resolve_board_service()
|
|
502
|
+
if _svc is not None:
|
|
503
|
+
from coord.client import fetch_assignment, fetch_board_payload # noqa: PLC0415
|
|
504
|
+
|
|
505
|
+
_row = fetch_assignment(_svc, work_assignment_id)
|
|
506
|
+
if _row is None:
|
|
507
|
+
# 404: unknown id, or a pre-#1336 daemon (an unmatched route
|
|
508
|
+
# is also a 404) — one compatibility pass through the
|
|
509
|
+
# collection payload, exactly like load_assignment_review_findings.
|
|
510
|
+
_payload = fetch_board_payload(_svc)
|
|
511
|
+
_row = next(
|
|
512
|
+
(
|
|
513
|
+
a
|
|
514
|
+
for a in _payload.get("assignments", [])
|
|
515
|
+
if a.get("assignment_id") == work_assignment_id
|
|
516
|
+
),
|
|
517
|
+
None,
|
|
518
|
+
)
|
|
519
|
+
if _row is not None:
|
|
520
|
+
_test_state = (_row.get("test_state") or "").strip() or None
|
|
521
|
+
else:
|
|
522
|
+
from coord.board_service import read_board as _read_board_tv # noqa: PLC0415
|
|
523
|
+
|
|
524
|
+
_work = _read_board_tv().find_by_id(work_assignment_id)
|
|
525
|
+
if _work is not None:
|
|
526
|
+
_test_state = (_work.test_state or "").strip() or None
|
|
527
|
+
except Exception as exc: # noqa: BLE001 — #1349: log + surface, never swallow
|
|
528
|
+
_read_exc = exc
|
|
529
|
+
log.warning(
|
|
530
|
+
"test-verdict idempotency gate: could not read assignment %s "
|
|
531
|
+
"from the board: %s", work_assignment_id, exc,
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
if _test_state:
|
|
535
|
+
click.echo(
|
|
536
|
+
f" test verdict already recorded: {_test_state!r} for "
|
|
537
|
+
f"{_ctx} (agent used `coord test` — no operator prompt needed)"
|
|
538
|
+
)
|
|
539
|
+
return True
|
|
540
|
+
|
|
541
|
+
if _read_exc is not None:
|
|
542
|
+
click.echo(
|
|
543
|
+
" warning: could not read the board to check for an existing "
|
|
544
|
+
f"test verdict for {_ctx}: {_read_exc}\n"
|
|
545
|
+
" This does NOT mean the agent failed to report — the read "
|
|
546
|
+
"itself failed. Falling back to the prompt below; re-answering "
|
|
547
|
+
"is safe (the write is idempotent).",
|
|
548
|
+
err=True,
|
|
549
|
+
)
|
|
550
|
+
|
|
551
|
+
# ── Non-TTY path ──────────────────────────────────────────────────────────
|
|
552
|
+
if not sys.stdin.isatty():
|
|
553
|
+
click.echo(
|
|
554
|
+
f" no test verdict recorded for {_ctx} — record it with:\n"
|
|
555
|
+
f"{verdict_cmd_hint}"
|
|
556
|
+
)
|
|
557
|
+
return False
|
|
558
|
+
|
|
559
|
+
# ── TTY prompt ────────────────────────────────────────────────────────────
|
|
560
|
+
ans = click.prompt(
|
|
561
|
+
" Test verdict — [p]assed / [f]ailed / [s]kip",
|
|
562
|
+
type=click.Choice(["p", "f", "s"], case_sensitive=False),
|
|
563
|
+
default="s",
|
|
564
|
+
show_choices=True,
|
|
565
|
+
)
|
|
566
|
+
if ans.lower() == "s":
|
|
567
|
+
click.echo(
|
|
568
|
+
f" skipped — record the test verdict later with:\n{verdict_cmd_hint}"
|
|
569
|
+
)
|
|
570
|
+
return False
|
|
571
|
+
|
|
572
|
+
test_state = {"p": "passed", "f": "failed"}[ans.lower()]
|
|
573
|
+
reason: str = ""
|
|
574
|
+
if test_state == "failed":
|
|
575
|
+
reason = click.prompt(
|
|
576
|
+
" failure reason (what was checked, expected vs actual, repro "
|
|
577
|
+
"steps, suspected files — this IS the fix worker's brief)",
|
|
578
|
+
default="",
|
|
579
|
+
show_default=False,
|
|
580
|
+
).strip()
|
|
581
|
+
|
|
582
|
+
# ── Relay via daemon-routed record_test_verdict ────────────────────────────
|
|
583
|
+
try:
|
|
584
|
+
from coord.state import record_test_verdict as _record_tv # noqa: PLC0415
|
|
585
|
+
|
|
586
|
+
_record_tv(
|
|
587
|
+
assignment_id=work_assignment_id,
|
|
588
|
+
test_state=test_state,
|
|
589
|
+
test_reason=reason if reason else None,
|
|
590
|
+
# Mirror to legacy smoke_test columns (pipeline.py reads both).
|
|
591
|
+
smoke_test="pass" if test_state == "passed" else "fail",
|
|
592
|
+
smoke_test_reason=reason if test_state == "failed" and reason else None,
|
|
593
|
+
)
|
|
594
|
+
click.echo(
|
|
595
|
+
f" test verdict '{test_state}' recorded for work assignment "
|
|
596
|
+
f"{work_assignment_id} ({_ctx})."
|
|
597
|
+
)
|
|
598
|
+
return True
|
|
599
|
+
except Exception as exc: # noqa: BLE001 — best-effort; fall back to the hint
|
|
600
|
+
click.echo(
|
|
601
|
+
f" warning: failed to record test verdict for {_ctx}: {exc}\n"
|
|
602
|
+
f"{verdict_cmd_hint}",
|
|
603
|
+
err=True,
|
|
604
|
+
)
|
|
605
|
+
return False
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
@click.command(
|
|
609
|
+
"report-result",
|
|
610
|
+
help=(
|
|
611
|
+
"Report the outcome of an interactive session through the "
|
|
612
|
+
"coordinator's issue_store seam (#466). "
|
|
613
|
+
"REQUIRED for review sessions where the verdict can only come "
|
|
614
|
+
"from the agent."
|
|
615
|
+
),
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
@click.option(
|
|
620
|
+
"--assignment", "assignment_id_opt", default=None,
|
|
621
|
+
help="The assignment id (defaults to $COORD_ASSIGNMENT_ID).",
|
|
622
|
+
)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
@click.option(
|
|
626
|
+
"--status",
|
|
627
|
+
type=click.Choice(["done", "blocked", "already-implemented"]),
|
|
628
|
+
required=True,
|
|
629
|
+
help=(
|
|
630
|
+
"Terminal result: `done` = work landed; `blocked` = cannot proceed; "
|
|
631
|
+
"`already-implemented` = nothing to do (advisory)."
|
|
632
|
+
),
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
@click.option(
|
|
637
|
+
"--verdict",
|
|
638
|
+
type=click.Choice(["approve", "request-changes"]),
|
|
639
|
+
default=None,
|
|
640
|
+
help=(
|
|
641
|
+
"Review verdict — only meaningful for review sessions where no "
|
|
642
|
+
"commits are pushed. Recorded so the merge-gate sees the same "
|
|
643
|
+
"field a claude-p reviewer would have populated."
|
|
644
|
+
),
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
@click.option(
|
|
649
|
+
"--summary", default="",
|
|
650
|
+
help="One-paragraph summary posted on the issue under the result.",
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
@click.option(
|
|
655
|
+
"--body-file", "body_file",
|
|
656
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
657
|
+
default=None,
|
|
658
|
+
help=(
|
|
659
|
+
"Path to a file with the FULL findings body (markdown). For a REVIEW "
|
|
660
|
+
"session, write your complete review here and pass this — it is persisted "
|
|
661
|
+
"on the assignment AND posted to the issue under a machine-parseable "
|
|
662
|
+
"marker, so the fix worker is briefed with the actual findings (from any "
|
|
663
|
+
"machine, via the GitHub message bus), not just the one-line --summary. "
|
|
664
|
+
"REQUIRED with `--verdict request-changes` (#580)."
|
|
665
|
+
),
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
@click.option(
|
|
670
|
+
"--body", "body_inline", default=None,
|
|
671
|
+
help=(
|
|
672
|
+
"Inline alternative to --body-file (the full findings body as a string, "
|
|
673
|
+
"e.g. --body \"$(cat findings.md)\"). One of --body/--body-file is "
|
|
674
|
+
"required with `--verdict request-changes`."
|
|
675
|
+
),
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
@click.option(
|
|
680
|
+
"--audit-json", "audit_json_file",
|
|
681
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
682
|
+
default=None,
|
|
683
|
+
help=(
|
|
684
|
+
"Milestone Outcome Audit (#886 Phase 2) ONLY — path to a JSON file "
|
|
685
|
+
"with the structured verdict: "
|
|
686
|
+
'{"bottom_line": "...", "goals": [{"goal": "...", "metric_before": '
|
|
687
|
+
'"...", "metric_after": "...", "verdict": "met|partial|gap", '
|
|
688
|
+
'"evidence": "..."}]}. Routes the result through the audit dual-write '
|
|
689
|
+
"path (assignment row + epic comment + #603 context store) instead of "
|
|
690
|
+
"the generic done-comment body. Only valid on a --audit-of assignment."
|
|
691
|
+
),
|
|
692
|
+
)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
@click.option(
|
|
696
|
+
"--force",
|
|
697
|
+
is_flag=True,
|
|
698
|
+
default=False,
|
|
699
|
+
help=(
|
|
700
|
+
"Confirm overwriting already-captured review findings for this "
|
|
701
|
+
"assignment (#650). Without it, a --verdict request-changes/--body "
|
|
702
|
+
"write that would replace non-empty existing review_findings with a "
|
|
703
|
+
"different body is refused — a second capture for the same "
|
|
704
|
+
"assignment is, by construction, a re-run (finishing the exit "
|
|
705
|
+
"process twice, a stray reattach), never a legitimate new review."
|
|
706
|
+
),
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
@click.option(
|
|
711
|
+
"--verdict-source",
|
|
712
|
+
type=click.Choice(["recovered", "overridden"]),
|
|
713
|
+
default=None,
|
|
714
|
+
help=(
|
|
715
|
+
"#1956: state EXPLICITLY when --verdict is not the reviewer's own "
|
|
716
|
+
"fresh self-report. 'recovered' = the reviewer reached this verdict "
|
|
717
|
+
"and said so in prose, but never emitted the machine-readable "
|
|
718
|
+
"REVIEW_VERDICT header — you rescued it from the transcript. "
|
|
719
|
+
"'overridden' = a human is recording a DIFFERENT verdict than what "
|
|
720
|
+
"the reviewer actually produced (or no reviewer verdict exists at "
|
|
721
|
+
"all). Omit this for the normal case: an agent self-reporting its "
|
|
722
|
+
"own session's verdict before exiting — that is recorded as "
|
|
723
|
+
"'agent' automatically. Requires --verdict-reason."
|
|
724
|
+
),
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
@click.option(
|
|
729
|
+
"--verdict-reason",
|
|
730
|
+
default=None,
|
|
731
|
+
help=(
|
|
732
|
+
"Required with --verdict-source: a short justification for the "
|
|
733
|
+
"recovery/override, so the provenance is auditable rather than "
|
|
734
|
+
"reading identically to an agent-produced verdict (#1956)."
|
|
735
|
+
),
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
@_CONFIG_OPTION
|
|
740
|
+
def report_result(
|
|
741
|
+
assignment_id_opt: str | None,
|
|
742
|
+
status: str,
|
|
743
|
+
verdict: str | None,
|
|
744
|
+
summary: str,
|
|
745
|
+
body_file: str | None,
|
|
746
|
+
body_inline: str | None,
|
|
747
|
+
audit_json_file: str | None,
|
|
748
|
+
force: bool,
|
|
749
|
+
verdict_source: str | None,
|
|
750
|
+
verdict_reason: str | None,
|
|
751
|
+
config_path: Path,
|
|
752
|
+
) -> None:
|
|
753
|
+
"""``coord report-result --assignment <id> --status <s> [--verdict <v>] --summary <text>``
|
|
754
|
+
|
|
755
|
+
The single coordinator-mediated command an interactive Claude
|
|
756
|
+
session may invoke before it exits. Writes the outcome through the
|
|
757
|
+
:mod:`coord.issue_store` seam (same path the git-floor backstop
|
|
758
|
+
uses), so the GitHub message bus and the local DB see a
|
|
759
|
+
structurally-identical completion regardless of which mechanism
|
|
760
|
+
produced it.
|
|
761
|
+
"""
|
|
762
|
+
import os as _os # noqa: PLC0415
|
|
763
|
+
|
|
764
|
+
from coord import issue_store # noqa: PLC0415
|
|
765
|
+
from coord.client import resolve_board_service # noqa: PLC0415
|
|
766
|
+
|
|
767
|
+
assignment_id = assignment_id_opt or _os.environ.get("COORD_ASSIGNMENT_ID")
|
|
768
|
+
if not assignment_id:
|
|
769
|
+
click.echo(
|
|
770
|
+
"error: --assignment is required (or set $COORD_ASSIGNMENT_ID)",
|
|
771
|
+
err=True,
|
|
772
|
+
)
|
|
773
|
+
sys.exit(2)
|
|
774
|
+
|
|
775
|
+
# #886 Phase 2: Milestone Outcome Audit structured verdict. Parsed and
|
|
776
|
+
# validated FIRST — before any board/config resolution — so an obvious
|
|
777
|
+
# malformed-JSON typo fails fast without needing a reachable daemon/config
|
|
778
|
+
# (issue_store._validate_result re-validates server-side too, the one seam
|
|
779
|
+
# every caller funnels through, but this saves the agent a round trip).
|
|
780
|
+
audit_goals: list[dict] | None = None
|
|
781
|
+
audit_bottom_line: str | None = None
|
|
782
|
+
if audit_json_file:
|
|
783
|
+
import json as _json # noqa: PLC0415
|
|
784
|
+
|
|
785
|
+
try:
|
|
786
|
+
audit_payload = _json.loads(Path(audit_json_file).read_text(encoding="utf-8"))
|
|
787
|
+
except (OSError, ValueError) as exc:
|
|
788
|
+
click.echo(
|
|
789
|
+
f"error: could not read/parse --audit-json {audit_json_file!r}: {exc}",
|
|
790
|
+
err=True,
|
|
791
|
+
)
|
|
792
|
+
sys.exit(2)
|
|
793
|
+
if not isinstance(audit_payload, dict) or not isinstance(
|
|
794
|
+
audit_payload.get("goals"), list
|
|
795
|
+
):
|
|
796
|
+
click.echo(
|
|
797
|
+
"error: --audit-json must be a JSON object with a 'goals' list, "
|
|
798
|
+
'e.g. {"bottom_line": "...", "goals": [{"goal": "...", '
|
|
799
|
+
'"metric_before": "...", "metric_after": "...", "verdict": '
|
|
800
|
+
'"met|partial|gap", "evidence": "..."}]}',
|
|
801
|
+
err=True,
|
|
802
|
+
)
|
|
803
|
+
sys.exit(2)
|
|
804
|
+
audit_goals = audit_payload["goals"]
|
|
805
|
+
audit_bottom_line = audit_payload.get("bottom_line") or None
|
|
806
|
+
for _goal in audit_goals:
|
|
807
|
+
_verdict = _goal.get("verdict") if isinstance(_goal, dict) else None
|
|
808
|
+
if _verdict not in ("met", "partial", "gap"):
|
|
809
|
+
click.echo(
|
|
810
|
+
f"error: --audit-json goal {_goal!r} has invalid verdict "
|
|
811
|
+
f"{_verdict!r} (expected one of 'met'/'partial'/'gap')",
|
|
812
|
+
err=True,
|
|
813
|
+
)
|
|
814
|
+
sys.exit(2)
|
|
815
|
+
|
|
816
|
+
repo_github: str | None = None
|
|
817
|
+
repo_name: str | None = None
|
|
818
|
+
machine_name: str | None = None
|
|
819
|
+
issue_number: int | None = None
|
|
820
|
+
branch: str | None = None
|
|
821
|
+
|
|
822
|
+
svc = resolve_board_service()
|
|
823
|
+
prefetch_failed = False
|
|
824
|
+
if svc is not None:
|
|
825
|
+
# Thin client (#590): no local DB/config — resolve the assignment's
|
|
826
|
+
# identity from the daemon, then let issue_store.post_result route the
|
|
827
|
+
# write back to the daemon's shared DB. This is what lets a remote
|
|
828
|
+
# interactive session self-report instead of the old "do NOT run
|
|
829
|
+
# report-result" workaround.
|
|
830
|
+
#
|
|
831
|
+
# #1336: this prefetch is an *enrichment*, not a prerequisite — the
|
|
832
|
+
# verdict write MUST NOT be discarded because a read was slow. It
|
|
833
|
+
# uses the point endpoint (GET /assignment/{id}; falls back to the
|
|
834
|
+
# full /board payload only for a pre-#1336 daemon), gets the write
|
|
835
|
+
# timeout (it rides a write operation), and on ANY failure we warn and
|
|
836
|
+
# proceed with the POST: the daemon owns the DB and resolves the
|
|
837
|
+
# identity fields server-side.
|
|
838
|
+
from coord.client import ( # noqa: PLC0415
|
|
839
|
+
_WRITE_TIMEOUT,
|
|
840
|
+
fetch_assignment,
|
|
841
|
+
fetch_board_payload,
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
row = None
|
|
845
|
+
try:
|
|
846
|
+
row = fetch_assignment(svc, assignment_id, timeout=_WRITE_TIMEOUT)
|
|
847
|
+
if row is None:
|
|
848
|
+
# 404: unknown id — or a daemon that predates the point
|
|
849
|
+
# endpoint (an unmatched route is also a 404). One
|
|
850
|
+
# compatibility fallback through the collection payload.
|
|
851
|
+
payload = fetch_board_payload(svc, timeout=_WRITE_TIMEOUT)
|
|
852
|
+
row = next(
|
|
853
|
+
(
|
|
854
|
+
a
|
|
855
|
+
for a in payload.get("assignments", [])
|
|
856
|
+
if a.get("assignment_id") == assignment_id
|
|
857
|
+
),
|
|
858
|
+
None,
|
|
859
|
+
)
|
|
860
|
+
except Exception as exc: # noqa: BLE001
|
|
861
|
+
prefetch_failed = True
|
|
862
|
+
click.echo(
|
|
863
|
+
f"warning: identity prefetch from {svc.url} failed ({exc}).\n"
|
|
864
|
+
" This is a slow/failed BOARD READ, not a write failure — "
|
|
865
|
+
"proceeding to record the verdict; the board service resolves "
|
|
866
|
+
"the assignment's repo/machine/issue itself (#1336).",
|
|
867
|
+
err=True,
|
|
868
|
+
)
|
|
869
|
+
if row is not None:
|
|
870
|
+
repo_github = row.get("repo_github")
|
|
871
|
+
repo_name = row.get("repo_name")
|
|
872
|
+
machine_name = row.get("machine_name")
|
|
873
|
+
issue_number = row.get("issue_number")
|
|
874
|
+
branch = row.get("branch")
|
|
875
|
+
else:
|
|
876
|
+
from coord.state import build_board, load_dispatched # noqa: PLC0415
|
|
877
|
+
|
|
878
|
+
cfg = _load_config(config_path)
|
|
879
|
+
|
|
880
|
+
# Look up the assignment metadata. Prefer the dispatched ledger
|
|
881
|
+
# because it always has repo_github, then fall back to the live
|
|
882
|
+
# board for in-flight rows that haven't been queried elsewhere.
|
|
883
|
+
record = next(
|
|
884
|
+
(r for r in load_dispatched() if r.get("assignment_id") == assignment_id),
|
|
885
|
+
None,
|
|
886
|
+
)
|
|
887
|
+
if record is not None:
|
|
888
|
+
repo_github = record.get("repo_github")
|
|
889
|
+
repo_name = record.get("repo_name")
|
|
890
|
+
machine_name = record.get("machine_name")
|
|
891
|
+
issue_number = record.get("issue_number")
|
|
892
|
+
|
|
893
|
+
board = build_board()
|
|
894
|
+
assignment_obj = board.find_by_id(assignment_id)
|
|
895
|
+
if assignment_obj is not None:
|
|
896
|
+
repo_name = repo_name or assignment_obj.repo_name
|
|
897
|
+
machine_name = machine_name or assignment_obj.machine_name
|
|
898
|
+
issue_number = issue_number or assignment_obj.issue_number
|
|
899
|
+
branch = assignment_obj.branch
|
|
900
|
+
if repo_github is None:
|
|
901
|
+
repo_cfg = cfg.repo(assignment_obj.repo_name)
|
|
902
|
+
if repo_cfg is not None:
|
|
903
|
+
repo_github = repo_cfg.github
|
|
904
|
+
|
|
905
|
+
# Final fallback: if a config repo matches the recorded repo_name,
|
|
906
|
+
# use its github slug.
|
|
907
|
+
if repo_github is None and repo_name is not None:
|
|
908
|
+
repo_cfg = cfg.repo(repo_name)
|
|
909
|
+
if repo_cfg is not None:
|
|
910
|
+
repo_github = repo_cfg.github
|
|
911
|
+
|
|
912
|
+
if not (repo_github and repo_name and machine_name and issue_number):
|
|
913
|
+
if svc is not None and prefetch_failed:
|
|
914
|
+
# #1336 invariant 4: a failed READ must never discard the WRITE.
|
|
915
|
+
# Post the record with blank identity — the daemon fills it in
|
|
916
|
+
# from its own assignments row (`_enrich_result_identity`).
|
|
917
|
+
click.echo(
|
|
918
|
+
" identity unresolved locally — the board service will "
|
|
919
|
+
"resolve it from its own DB.",
|
|
920
|
+
err=True,
|
|
921
|
+
)
|
|
922
|
+
else:
|
|
923
|
+
click.echo(
|
|
924
|
+
f"error: could not resolve assignment {assignment_id!r} from "
|
|
925
|
+
"board/dispatched ledger; pass --assignment with a known id "
|
|
926
|
+
"or run from the originating coordinator machine.",
|
|
927
|
+
err=True,
|
|
928
|
+
)
|
|
929
|
+
sys.exit(1)
|
|
930
|
+
|
|
931
|
+
findings_body: str | None = None
|
|
932
|
+
if body_file:
|
|
933
|
+
try:
|
|
934
|
+
findings_body = Path(body_file).read_text(encoding="utf-8").strip() or None
|
|
935
|
+
except OSError as exc:
|
|
936
|
+
click.echo(
|
|
937
|
+
f"warning: could not read --body-file {body_file!r}: {exc}",
|
|
938
|
+
err=True,
|
|
939
|
+
)
|
|
940
|
+
if findings_body is None and body_inline and body_inline.strip():
|
|
941
|
+
findings_body = body_inline.strip()
|
|
942
|
+
|
|
943
|
+
# #580: a request-changes verdict MUST carry the reviewer's findings.
|
|
944
|
+
# Recording it with only a one-line --summary silently discards the
|
|
945
|
+
# objections, so the iteration-N+1 fix agent gets dispatched with nothing
|
|
946
|
+
# to fix. Require the body (file or inline) and fail loudly otherwise.
|
|
947
|
+
if verdict == "request-changes" and not findings_body:
|
|
948
|
+
click.echo(
|
|
949
|
+
"error: --verdict request-changes requires the review body — pass "
|
|
950
|
+
"--body-file <path> (or --body \"<text>\") with your full findings "
|
|
951
|
+
"(every blocking item, file:line). The one-line --summary is not "
|
|
952
|
+
"enough; it's what the fix worker is briefed with.\n"
|
|
953
|
+
" Write your findings to a file and re-run, e.g.:\n"
|
|
954
|
+
f" coord report-result --assignment {assignment_id} --status done "
|
|
955
|
+
"--verdict request-changes --summary <one-line> "
|
|
956
|
+
f"--body-file /tmp/review-{assignment_id}.md",
|
|
957
|
+
err=True,
|
|
958
|
+
)
|
|
959
|
+
sys.exit(2)
|
|
960
|
+
|
|
961
|
+
# #1956: fast client-side feedback for the same invariant
|
|
962
|
+
# issue_store._validate_result enforces server-side — a relayed verdict
|
|
963
|
+
# with no stated reason is indistinguishable from an agent's own, so
|
|
964
|
+
# refuse it here before any network/board resolution happens.
|
|
965
|
+
if verdict_source is not None and verdict is None:
|
|
966
|
+
click.echo(
|
|
967
|
+
"error: --verdict-source only makes sense alongside --verdict "
|
|
968
|
+
"(it describes the provenance of the verdict being recorded).",
|
|
969
|
+
err=True,
|
|
970
|
+
)
|
|
971
|
+
sys.exit(2)
|
|
972
|
+
if verdict_source is not None and not (verdict_reason and verdict_reason.strip()):
|
|
973
|
+
click.echo(
|
|
974
|
+
f"error: --verdict-source {verdict_source!r} requires "
|
|
975
|
+
"--verdict-reason — a relayed verdict must carry a reason so "
|
|
976
|
+
"it's auditable, not silently read as agent-produced (#1956).",
|
|
977
|
+
err=True,
|
|
978
|
+
)
|
|
979
|
+
sys.exit(2)
|
|
980
|
+
|
|
981
|
+
# #949: push the worktree's commits BEFORE recording 'done' so completed
|
|
982
|
+
# interactive work is never stranded. report-result is the "done" path for
|
|
983
|
+
# interactive fix/merge/work sessions; when a session declares done here
|
|
984
|
+
# (rather than through finalize_interactive_exit, which is bypassed on a
|
|
985
|
+
# detached/reattached tmux), nothing else pushes: the stale-session reaper
|
|
986
|
+
# skips the already-'done' row, and the worker itself may have been told not
|
|
987
|
+
# to push (e.g. a repo CLAUDE.md). Without this, the commit sits unpushed in
|
|
988
|
+
# ~/.coord/worktrees/<aid> and the next test/review agent tests stale code —
|
|
989
|
+
# the confirmed root cause of #407 and #782. Best-effort, non-force: it
|
|
990
|
+
# fast-forwards real work and safely no-ops on a diverged/rebased branch (the
|
|
991
|
+
# merge agent owns force-pushes). Review sessions have no worktree, so the
|
|
992
|
+
# .exists() guard skips them.
|
|
993
|
+
if status == "done":
|
|
994
|
+
from coord.interactive import _git_push # noqa: PLC0415
|
|
995
|
+
from coord.state import COORD_DIR # noqa: PLC0415
|
|
996
|
+
|
|
997
|
+
_wt = COORD_DIR / "worktrees" / assignment_id
|
|
998
|
+
if _wt.exists():
|
|
999
|
+
_pushed, _perr = _git_push(_wt)
|
|
1000
|
+
if _pushed:
|
|
1001
|
+
click.echo(f" pushed {branch or 'HEAD'} to origin (#949)")
|
|
1002
|
+
else:
|
|
1003
|
+
click.echo(
|
|
1004
|
+
f" ⚠ WARNING: could not push commits for {assignment_id} "
|
|
1005
|
+
f"to origin: {_perr}\n"
|
|
1006
|
+
f" work is committed but UNPUSHED in {_wt}\n"
|
|
1007
|
+
f" push manually: git -C {_wt} push origin HEAD",
|
|
1008
|
+
err=True,
|
|
1009
|
+
)
|
|
1010
|
+
|
|
1011
|
+
record_obj = issue_store.ResultRecord(
|
|
1012
|
+
assignment_id=assignment_id,
|
|
1013
|
+
machine_name=machine_name or "",
|
|
1014
|
+
repo_name=repo_name or "",
|
|
1015
|
+
repo_github=repo_github or "",
|
|
1016
|
+
# 0 = "unresolved" (only reachable on the prefetch-failed thin-client
|
|
1017
|
+
# path above) — the daemon's identity enrichment treats falsy fields
|
|
1018
|
+
# as blanks to fill from its own assignments row.
|
|
1019
|
+
issue_number=int(issue_number) if issue_number else 0,
|
|
1020
|
+
status=status, # type: ignore[arg-type]
|
|
1021
|
+
verdict=verdict, # type: ignore[arg-type]
|
|
1022
|
+
summary=summary,
|
|
1023
|
+
branch=branch,
|
|
1024
|
+
findings_body=findings_body,
|
|
1025
|
+
audit_goals=audit_goals,
|
|
1026
|
+
audit_bottom_line=audit_bottom_line,
|
|
1027
|
+
allow_overwrite_findings=force,
|
|
1028
|
+
verdict_source=verdict_source,
|
|
1029
|
+
verdict_source_reason=verdict_reason,
|
|
1030
|
+
)
|
|
1031
|
+
try:
|
|
1032
|
+
outcome = issue_store.post_result(record_obj)
|
|
1033
|
+
except ValueError as exc:
|
|
1034
|
+
click.echo(f"error: {exc}", err=True)
|
|
1035
|
+
sys.exit(2)
|
|
1036
|
+
except RuntimeError as exc:
|
|
1037
|
+
# #886/#990: the verdict write couldn't be durably confirmed (retries
|
|
1038
|
+
# exhausted, or a readback mismatch) — surface this loudly instead of
|
|
1039
|
+
# reporting success while the merge-gate-critical review_verdict (or
|
|
1040
|
+
# the audit run_number versioning invariant) column never actually
|
|
1041
|
+
# landed. Re-running the identical command is the recovery path.
|
|
1042
|
+
# (A #650 clobber-guard refusal is reported below via
|
|
1043
|
+
# `outcome.findings_written`, not as an exception — the verdict
|
|
1044
|
+
# itself still lands cleanly in that case.)
|
|
1045
|
+
click.echo(f"error: {exc}", err=True)
|
|
1046
|
+
sys.exit(1)
|
|
1047
|
+
|
|
1048
|
+
click.echo(
|
|
1049
|
+
f"result recorded: status={outcome.status} event={outcome.event} "
|
|
1050
|
+
f"posted_to_github={outcome.posted}"
|
|
1051
|
+
)
|
|
1052
|
+
if not outcome.findings_written:
|
|
1053
|
+
click.echo(
|
|
1054
|
+
" note: review findings were NOT overwritten (#650 clobber "
|
|
1055
|
+
"guard) — non-empty findings already existed for this "
|
|
1056
|
+
"assignment and the stored verdict already matches; the "
|
|
1057
|
+
"previously captured findings are preserved. Re-run with "
|
|
1058
|
+
"--force if this overwrite was intentional."
|
|
1059
|
+
)
|
|
1060
|
+
if outcome.error:
|
|
1061
|
+
click.echo(f" github post warning: {outcome.error}", err=True)
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
@click.command(
|
|
1065
|
+
"set-review-findings",
|
|
1066
|
+
help=(
|
|
1067
|
+
"Write review findings to the DB for a completed review assignment (#587). "
|
|
1068
|
+
"Used by the TUI rework dialog so the fix worker is briefed with the "
|
|
1069
|
+
"reviewer's feedback even when the review ran as a human-attended "
|
|
1070
|
+
"claude-pty session (which produces no parseable log)."
|
|
1071
|
+
),
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
@click.argument("assignment_id")
|
|
1076
|
+
@click.option(
|
|
1077
|
+
"--findings",
|
|
1078
|
+
required=True,
|
|
1079
|
+
help=(
|
|
1080
|
+
"The reviewer's findings, in plain text or markdown. Written as the "
|
|
1081
|
+
"REVIEW_BODY so `_load_review_findings` can serve it from the DB "
|
|
1082
|
+
"cache on the next `coord assign --fix-of` dispatch."
|
|
1083
|
+
),
|
|
1084
|
+
)
|
|
1085
|
+
@click.option(
|
|
1086
|
+
"--force",
|
|
1087
|
+
is_flag=True,
|
|
1088
|
+
default=False,
|
|
1089
|
+
help=(
|
|
1090
|
+
"Confirm overwriting already-captured, non-empty review findings "
|
|
1091
|
+
"for this assignment (#650) — the TUI rework dialog's write is "
|
|
1092
|
+
"otherwise refused when the row already carries a different "
|
|
1093
|
+
"findings body, so a re-run of the dialog can't silently stomp a "
|
|
1094
|
+
"good capture with a placeholder."
|
|
1095
|
+
),
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1098
|
+
|
|
1099
|
+
@_CONFIG_OPTION
|
|
1100
|
+
def set_review_findings(
|
|
1101
|
+
assignment_id: str,
|
|
1102
|
+
findings: str,
|
|
1103
|
+
force: bool,
|
|
1104
|
+
config_path: Path,
|
|
1105
|
+
) -> None:
|
|
1106
|
+
"""``coord set-review-findings <id> --findings <text>``
|
|
1107
|
+
|
|
1108
|
+
Persist review findings for a human-attended (claude-pty) review whose
|
|
1109
|
+
verdict was already recorded via ``coord report-result --verdict
|
|
1110
|
+
request-changes``. The DB cache written here is the first source
|
|
1111
|
+
``_load_review_findings`` checks, so the subsequent ``coord assign
|
|
1112
|
+
--fix-of`` dispatch will read it and brief the fix worker correctly
|
|
1113
|
+
instead of emitting the "(No structured findings were captured)" fallback.
|
|
1114
|
+
|
|
1115
|
+
#650: refuses to replace already-captured, non-empty findings with a
|
|
1116
|
+
different body unless ``--force`` is passed — a single assignment backs
|
|
1117
|
+
exactly one review, so a second, differing write here is a re-run of the
|
|
1118
|
+
dialog, never a legitimate new review.
|
|
1119
|
+
"""
|
|
1120
|
+
from coord.state import update_assignment_review_findings # noqa: PLC0415
|
|
1121
|
+
|
|
1122
|
+
findings_text = findings.strip()
|
|
1123
|
+
if not findings_text:
|
|
1124
|
+
click.echo("error: --findings must not be empty", err=True)
|
|
1125
|
+
sys.exit(2)
|
|
1126
|
+
|
|
1127
|
+
written = update_assignment_review_findings(
|
|
1128
|
+
assignment_id,
|
|
1129
|
+
verdict="request-changes",
|
|
1130
|
+
body=findings_text,
|
|
1131
|
+
allow_overwrite=force,
|
|
1132
|
+
)
|
|
1133
|
+
if not written:
|
|
1134
|
+
click.echo(
|
|
1135
|
+
f"refused: assignment {assignment_id} already has different, "
|
|
1136
|
+
"non-empty review findings recorded (#650 clobber guard) — "
|
|
1137
|
+
"re-run with --force to overwrite them.",
|
|
1138
|
+
err=True,
|
|
1139
|
+
)
|
|
1140
|
+
sys.exit(1)
|
|
1141
|
+
click.echo(f"findings recorded for {assignment_id}")
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
@click.command("fix-briefing")
|
|
1145
|
+
@click.argument("aid")
|
|
1146
|
+
@_CONFIG_OPTION
|
|
1147
|
+
def fix_briefing_cmd(aid: str, config_path: Path) -> None:
|
|
1148
|
+
"""Print the briefing a `--fix-of <aid>` fix worker would receive — the
|
|
1149
|
+
per-issue context block + the resolved findings / test-failure story (#603).
|
|
1150
|
+
|
|
1151
|
+
coord-tui shells out to this to preview the fix in the fail→fix / rework
|
|
1152
|
+
confirm dialog so the operator sees exactly what the worker is briefed with
|
|
1153
|
+
before launching. Output is the briefing text ONLY (stdout). AID is either
|
|
1154
|
+
a request-changes REVIEW id or a test-failed WORK id (mirrors --fix-of).
|
|
1155
|
+
"""
|
|
1156
|
+
from types import SimpleNamespace
|
|
1157
|
+
|
|
1158
|
+
from coord.auto_loop import _build_fix_briefing, _load_review_findings
|
|
1159
|
+
from coord.board_service import read_board
|
|
1160
|
+
from coord.state import COORD_DIR as _CTX_COORD_DIR, issue_context_block
|
|
1161
|
+
|
|
1162
|
+
cfg = _load_config(config_path)
|
|
1163
|
+
board = read_board()
|
|
1164
|
+
target = board.find_by_id(aid)
|
|
1165
|
+
if target is None:
|
|
1166
|
+
click.echo(f"error: no assignment {aid} on the board.", err=True)
|
|
1167
|
+
sys.exit(2)
|
|
1168
|
+
|
|
1169
|
+
# Mirror the --fix-of fork (cli.py): a test-failed WORK id fixes itself
|
|
1170
|
+
# (findings = test_reason); a request-changes REVIEW id fixes its linked work.
|
|
1171
|
+
fix_from_test_fail = (
|
|
1172
|
+
target.type != "review" and getattr(target, "test_state", None) == "failed"
|
|
1173
|
+
)
|
|
1174
|
+
if fix_from_test_fail:
|
|
1175
|
+
work = target
|
|
1176
|
+
elif target.type == "review":
|
|
1177
|
+
work = (
|
|
1178
|
+
board.find_by_id(target.review_of_assignment_id)
|
|
1179
|
+
if target.review_of_assignment_id else None
|
|
1180
|
+
)
|
|
1181
|
+
else:
|
|
1182
|
+
click.echo(
|
|
1183
|
+
f"error: {aid} is not a fixable target "
|
|
1184
|
+
f"(type={target.type!r}, test_state={getattr(target, 'test_state', None)!r}).",
|
|
1185
|
+
err=True,
|
|
1186
|
+
)
|
|
1187
|
+
sys.exit(2)
|
|
1188
|
+
if work is None or not work.branch:
|
|
1189
|
+
click.echo("error: no linked work assignment with a branch to fix.", err=True)
|
|
1190
|
+
sys.exit(2)
|
|
1191
|
+
|
|
1192
|
+
repo_cfg = cfg.repo(work.repo_name)
|
|
1193
|
+
repo_github = repo_cfg.github if repo_cfg else work.repo_name
|
|
1194
|
+
next_iteration = (work.review_iteration or 0) + 1
|
|
1195
|
+
max_iter = cfg.pipeline.max_review_iterations
|
|
1196
|
+
if fix_from_test_fail:
|
|
1197
|
+
# #1337: the board wire carries a bounded PREVIEW of test_reason; the
|
|
1198
|
+
# briefing quotes it verbatim — read full text via the detail endpoint.
|
|
1199
|
+
from coord.state import load_assignment_test_reason as _load_tr # noqa: PLC0415
|
|
1200
|
+
|
|
1201
|
+
story = (
|
|
1202
|
+
_load_tr(work.assignment_id or "")
|
|
1203
|
+
or getattr(work, "test_reason", None)
|
|
1204
|
+
or ""
|
|
1205
|
+
).strip()
|
|
1206
|
+
findings_body = (
|
|
1207
|
+
"The manual smoke test FAILED. The operator reported:\n\n"
|
|
1208
|
+
f"> {story}\n\nReproduce the failure, fix the root cause, and "
|
|
1209
|
+
"re-validate before pushing."
|
|
1210
|
+
if story else
|
|
1211
|
+
"The manual smoke test FAILED (no reason text was recorded). Pull the "
|
|
1212
|
+
"branch, reproduce the failure the operator hit, and fix the root "
|
|
1213
|
+
"cause before pushing."
|
|
1214
|
+
)
|
|
1215
|
+
else:
|
|
1216
|
+
_log = _CTX_COORD_DIR / "logs" / f"{aid}.log"
|
|
1217
|
+
try:
|
|
1218
|
+
findings = _load_review_findings(
|
|
1219
|
+
target, str(_log) if _log.exists() else None, None,
|
|
1220
|
+
repo_github=repo_github,
|
|
1221
|
+
)
|
|
1222
|
+
except Exception: # noqa: BLE001
|
|
1223
|
+
findings = None
|
|
1224
|
+
findings_body = (
|
|
1225
|
+
findings.body.strip()
|
|
1226
|
+
if findings and (getattr(findings, "body", "") or "").strip()
|
|
1227
|
+
else (
|
|
1228
|
+
f"(No structured findings were captured for review {aid}.) "
|
|
1229
|
+
f"The review verdict was {target.review_verdict or 'request-changes'!r}. "
|
|
1230
|
+
"Read the reviewer's feedback and address every blocking item "
|
|
1231
|
+
"before pushing."
|
|
1232
|
+
)
|
|
1233
|
+
)
|
|
1234
|
+
fix_briefing = _build_fix_briefing(
|
|
1235
|
+
work, SimpleNamespace(body=findings_body), next_iteration, max_iter
|
|
1236
|
+
)
|
|
1237
|
+
ctx = issue_context_block(work.repo_name, work.issue_number)
|
|
1238
|
+
click.echo(ctx + fix_briefing, nl=False)
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
def _count_diff_changed_lines(diff_text: str) -> int:
|
|
1242
|
+
"""Count content +/- lines in a unified diff, excluding the ``+++``/``---``
|
|
1243
|
+
file-header lines (which start with the same characters but carry no
|
|
1244
|
+
content). Used by ``review-reaffirm`` to size the delta against the
|
|
1245
|
+
``reviews.reaffirm_max_diff_lines`` sanity bound.
|
|
1246
|
+
|
|
1247
|
+
The header exclusion is **positional**, not a bare prefix test: a header
|
|
1248
|
+
only ever appears outside a hunk body (before the file's first ``@@``),
|
|
1249
|
+
so once a ``@@`` hunk header is seen every ``+``/``-`` line counts until
|
|
1250
|
+
the next ``diff --git`` resets the state. A naive ``startswith("---")``
|
|
1251
|
+
silently drops genuine content whose own source text begins with
|
|
1252
|
+
``--``/``++`` — a removed Markdown/YAML ``---`` separator renders as
|
|
1253
|
+
``----``, an added ``++counter`` renders as ``+++counter`` — which would
|
|
1254
|
+
*undercount* the delta and let a diff that should be hard-refused slip
|
|
1255
|
+
under the bound. Where the input isn't recognizable as a unified diff
|
|
1256
|
+
(no ``@@`` at all) this errs toward over-counting, which fails closed.
|
|
1257
|
+
"""
|
|
1258
|
+
n = 0
|
|
1259
|
+
in_hunk = False
|
|
1260
|
+
for line in diff_text.splitlines():
|
|
1261
|
+
if line.startswith("@@"):
|
|
1262
|
+
in_hunk = True
|
|
1263
|
+
continue
|
|
1264
|
+
if line.startswith("diff --git "):
|
|
1265
|
+
in_hunk = False
|
|
1266
|
+
continue
|
|
1267
|
+
if not in_hunk and (
|
|
1268
|
+
line.startswith("--- ") or line.startswith("+++ ")
|
|
1269
|
+
or line in ("---", "+++")
|
|
1270
|
+
):
|
|
1271
|
+
continue # unified-diff file header (never inside a hunk body)
|
|
1272
|
+
if line.startswith("+") or line.startswith("-"):
|
|
1273
|
+
n += 1
|
|
1274
|
+
return n
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
@click.command(
|
|
1278
|
+
"review-reaffirm",
|
|
1279
|
+
help=(
|
|
1280
|
+
"Re-point a stale-but-content-changed approved review to the branch's "
|
|
1281
|
+
"current head, with an audited reason (#1488) — the sanctioned "
|
|
1282
|
+
"alternative to a full re-review or leaving the merge queue for a "
|
|
1283
|
+
"mechanical conflict-resolution delta."
|
|
1284
|
+
),
|
|
1285
|
+
)
|
|
1286
|
+
@click.argument("work_assignment_id")
|
|
1287
|
+
@click.option(
|
|
1288
|
+
"--reason",
|
|
1289
|
+
required=True,
|
|
1290
|
+
help=(
|
|
1291
|
+
"Why this delta is safe to reaffirm without a full re-review "
|
|
1292
|
+
"(e.g. 'conflict resolution: merged #1461 + #1472 filters, suite "
|
|
1293
|
+
"green'). Written to the audit trail alongside the SHA anchors."
|
|
1294
|
+
),
|
|
1295
|
+
)
|
|
1296
|
+
@click.option(
|
|
1297
|
+
"--yes", is_flag=True, default=False,
|
|
1298
|
+
help="Skip the interactive confirmation prompt (for scripting).",
|
|
1299
|
+
)
|
|
1300
|
+
@_CONFIG_OPTION
|
|
1301
|
+
def review_reaffirm(
|
|
1302
|
+
work_assignment_id: str, reason: str, yes: bool, config_path: Path,
|
|
1303
|
+
) -> None:
|
|
1304
|
+
"""``coord review-reaffirm <work_assignment_id> --reason "..."``
|
|
1305
|
+
|
|
1306
|
+
#1488: a content-changing rebase (typically a conflict resolution during
|
|
1307
|
+
`coord merge`) correctly voids `has_approved_review`'s approval — but
|
|
1308
|
+
until now the only sanctioned way past that was a full re-review
|
|
1309
|
+
dispatch, or leaving the merge queue entirely via `gh pr merge` (which
|
|
1310
|
+
left no board-visible record at all). This re-points the approving
|
|
1311
|
+
review row's `review_head_sha`/`review_patch_id` to the branch's current
|
|
1312
|
+
head instead, after showing the exact delta being waved through and
|
|
1313
|
+
requiring confirmation — refusing outright (no override flag) when the
|
|
1314
|
+
delta exceeds `reviews.reaffirm_max_diff_lines`, so this stays an escape
|
|
1315
|
+
hatch for mechanical resolutions, never a review bypass.
|
|
1316
|
+
|
|
1317
|
+
Reuses the exact same eligibility checks `dispatch_scoped_reviews_for_queue`
|
|
1318
|
+
(#1476) uses to decide a *scoped re-review* is safe to dispatch —
|
|
1319
|
+
`coord.merge_queue.has_approved_review`, `find_scoped_review_candidate`
|
|
1320
|
+
**and** the `only_conflict_fix_since_review` guardrail — so this command
|
|
1321
|
+
takes the human-in-the-loop path through that same gap instead of
|
|
1322
|
+
dispatching another `claude -p` review. It splits that guardrail's two
|
|
1323
|
+
failure modes: work/fix rounds dispatched after the approval are refused
|
|
1324
|
+
outright, while a delta no coord-tracked conflict-fix can account for
|
|
1325
|
+
(a hand-run rebase) warns loudly and is stamped as unattributed in the
|
|
1326
|
+
audit row.
|
|
1327
|
+
"""
|
|
1328
|
+
from coord import github_ops # noqa: PLC0415
|
|
1329
|
+
from coord import merge_queue as mq # noqa: PLC0415
|
|
1330
|
+
from coord.board_service import read_board # noqa: PLC0415
|
|
1331
|
+
from coord.state import record_review_reaffirm # noqa: PLC0415
|
|
1332
|
+
|
|
1333
|
+
if not reason.strip():
|
|
1334
|
+
click.echo("error: --reason must not be empty", err=True)
|
|
1335
|
+
sys.exit(2)
|
|
1336
|
+
|
|
1337
|
+
cfg = _load_config(config_path)
|
|
1338
|
+
board = read_board()
|
|
1339
|
+
work = board.find_by_id(work_assignment_id)
|
|
1340
|
+
if work is None:
|
|
1341
|
+
click.echo(f"error: assignment {work_assignment_id!r} not found on the board", err=True)
|
|
1342
|
+
sys.exit(1)
|
|
1343
|
+
if not work.branch:
|
|
1344
|
+
click.echo(f"error: assignment {work_assignment_id!r} has no branch recorded", err=True)
|
|
1345
|
+
sys.exit(1)
|
|
1346
|
+
|
|
1347
|
+
repo = cfg.repo(work.repo_name)
|
|
1348
|
+
if repo is None:
|
|
1349
|
+
click.echo(f"error: unknown repo {work.repo_name!r}", err=True)
|
|
1350
|
+
sys.exit(1)
|
|
1351
|
+
|
|
1352
|
+
# Reuse a live merge-queue entry when one exists (the common case — the
|
|
1353
|
+
# operator got here because `coord merge` just refused with
|
|
1354
|
+
# review_required); synthesize a throwaway one otherwise. Either way
|
|
1355
|
+
# branch_head_sha/branch_patch_id are never trusted from load_queue()
|
|
1356
|
+
# (they're transient, recomputed by `process()` on every run, never
|
|
1357
|
+
# persisted) — always refetched live below so the gate is evaluated
|
|
1358
|
+
# against the branch's ACTUAL current head, not a stale in-memory value.
|
|
1359
|
+
items = mq.load_queue()
|
|
1360
|
+
entry = next(
|
|
1361
|
+
(e for e in items if e.repo_github == repo.github and e.branch == work.branch),
|
|
1362
|
+
None,
|
|
1363
|
+
)
|
|
1364
|
+
if entry is None:
|
|
1365
|
+
entry = mq.QueuedMerge(
|
|
1366
|
+
assignment_id=work.assignment_id,
|
|
1367
|
+
repo_name=work.repo_name,
|
|
1368
|
+
repo_github=repo.github,
|
|
1369
|
+
branch=work.branch,
|
|
1370
|
+
target_branch=repo.default_branch,
|
|
1371
|
+
issue_number=work.issue_number,
|
|
1372
|
+
issue_title=work.issue_title or "",
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
entry.branch_head_sha = github_ops.get_branch_sha(entry.repo_github, entry.branch)
|
|
1376
|
+
if entry.branch_head_sha is None:
|
|
1377
|
+
click.echo(
|
|
1378
|
+
f"error: could not resolve the current HEAD sha for branch "
|
|
1379
|
+
f"{entry.branch!r} (gh api failure) — refusing to reaffirm "
|
|
1380
|
+
f"without confirming the branch's actual current state",
|
|
1381
|
+
err=True,
|
|
1382
|
+
)
|
|
1383
|
+
sys.exit(1)
|
|
1384
|
+
entry.branch_patch_id = None # force a fresh backfill below, never trust a stale value
|
|
1385
|
+
|
|
1386
|
+
if mq.has_approved_review(entry, board, github_ops):
|
|
1387
|
+
click.echo(
|
|
1388
|
+
"nothing to reaffirm — an approved review already covers the "
|
|
1389
|
+
"branch's current head (either fresh, or a content-identical "
|
|
1390
|
+
"rebase already carried forward by #1475)."
|
|
1391
|
+
)
|
|
1392
|
+
return
|
|
1393
|
+
|
|
1394
|
+
prior_review = mq.find_scoped_review_candidate(entry, board, github_ops)
|
|
1395
|
+
if prior_review is None:
|
|
1396
|
+
click.echo(
|
|
1397
|
+
"error: no reaffirmable approval found — either this branch was "
|
|
1398
|
+
"never reviewed+approved, or the delta since its last approval "
|
|
1399
|
+
"can't be confirmed (missing patch-id data). A full re-review is "
|
|
1400
|
+
f"required: coord review {work_assignment_id}",
|
|
1401
|
+
err=True,
|
|
1402
|
+
)
|
|
1403
|
+
sys.exit(1)
|
|
1404
|
+
|
|
1405
|
+
# #1488 review round 1: `find_scoped_review_candidate` alone does NOT
|
|
1406
|
+
# establish that the delta is a mechanical rebase — its "voided ONLY by a
|
|
1407
|
+
# content-changing rebase" guarantee comes from the caller pairing it with
|
|
1408
|
+
# `only_conflict_fix_since_review` (exactly what the automated scoped
|
|
1409
|
+
# dispatcher does at coord/review.py's "guardrail: another commit
|
|
1410
|
+
# intervened" check). Without it, a bounce round carrying a genuine second
|
|
1411
|
+
# batch of new logic looks identical to a conflict resolution here, and
|
|
1412
|
+
# would ride through on one y/n keystroke.
|
|
1413
|
+
#
|
|
1414
|
+
# The guardrail's two failure modes are NOT equivalent, so they're split:
|
|
1415
|
+
# * intervening work/fix rounds dispatched after the approval ⇒ new
|
|
1416
|
+
# logic the approval provably never saw ⇒ hard refuse, no override.
|
|
1417
|
+
# * no coord-tracked conflict-fix explains the delta (the operator
|
|
1418
|
+
# rebased by hand — the single most common way to land here, and the
|
|
1419
|
+
# exact gap this escape hatch exists to fill) ⇒ unattributable but not
|
|
1420
|
+
# evidence of new logic ⇒ loud warning above the confirm prompt, and
|
|
1421
|
+
# the fact is stamped into the audit row so the trail records whether
|
|
1422
|
+
# coord could attribute the delta or the human vouched for it alone.
|
|
1423
|
+
intervening = mq.intervening_work_since_review(entry, board, prior_review)
|
|
1424
|
+
if intervening:
|
|
1425
|
+
listed = ", ".join(
|
|
1426
|
+
f"{a.assignment_id} ({a.type})" for a in intervening[:5]
|
|
1427
|
+
)
|
|
1428
|
+
click.echo(
|
|
1429
|
+
f"error: {len(intervening)} work/fix assignment(s) were dispatched "
|
|
1430
|
+
f"AFTER review {prior_review.assignment_id} approved this branch: "
|
|
1431
|
+
f"{listed} — the delta is new logic that approval never saw, not a "
|
|
1432
|
+
f"mechanical conflict resolution. Reaffirmation is refused (no "
|
|
1433
|
+
f"override flag). Dispatch a full re-review instead: "
|
|
1434
|
+
f"coord review {work_assignment_id}",
|
|
1435
|
+
err=True,
|
|
1436
|
+
)
|
|
1437
|
+
sys.exit(1)
|
|
1438
|
+
|
|
1439
|
+
conflict_fix_only = mq.only_conflict_fix_since_review(entry, board, prior_review)
|
|
1440
|
+
|
|
1441
|
+
old_sha = prior_review.review_head_sha
|
|
1442
|
+
new_sha = entry.branch_head_sha
|
|
1443
|
+
diff_text = github_ops.get_compare_diff(entry.repo_github, old_sha, new_sha)
|
|
1444
|
+
if diff_text is None:
|
|
1445
|
+
click.echo(
|
|
1446
|
+
f"error: could not fetch the diff between the approved sha "
|
|
1447
|
+
f"{old_sha!r} and the current head {new_sha!r} (gh api compare "
|
|
1448
|
+
f"failed) — refusing to reaffirm without being able to show "
|
|
1449
|
+
f"what's being waved through",
|
|
1450
|
+
err=True,
|
|
1451
|
+
)
|
|
1452
|
+
sys.exit(1)
|
|
1453
|
+
|
|
1454
|
+
diff_lines = _count_diff_changed_lines(diff_text)
|
|
1455
|
+
max_lines = cfg.reviews.reaffirm_max_diff_lines
|
|
1456
|
+
if max_lines > 0 and diff_lines > max_lines:
|
|
1457
|
+
click.echo(
|
|
1458
|
+
f"error: delta is {diff_lines} changed lines, exceeding "
|
|
1459
|
+
f"reviews.reaffirm_max_diff_lines ({max_lines}) — this is an "
|
|
1460
|
+
f"escape hatch for mechanical conflict resolutions, not a review "
|
|
1461
|
+
f"bypass. Dispatch a full re-review instead: "
|
|
1462
|
+
f"coord review {work_assignment_id}",
|
|
1463
|
+
err=True,
|
|
1464
|
+
)
|
|
1465
|
+
sys.exit(1)
|
|
1466
|
+
|
|
1467
|
+
click.echo(
|
|
1468
|
+
f"Reaffirming review {prior_review.assignment_id} for "
|
|
1469
|
+
f"{work.repo_name}#{work.issue_number} ({work.branch})"
|
|
1470
|
+
)
|
|
1471
|
+
click.echo(f" approved sha: {old_sha}")
|
|
1472
|
+
click.echo(f" current head: {new_sha}")
|
|
1473
|
+
click.echo(f" delta: {diff_lines} changed lines")
|
|
1474
|
+
click.echo(f" reason: {reason}")
|
|
1475
|
+
click.echo(
|
|
1476
|
+
" attribution: "
|
|
1477
|
+
+ (
|
|
1478
|
+
"a completed conflict-fix accounts for this delta"
|
|
1479
|
+
if conflict_fix_only
|
|
1480
|
+
else "UNATTRIBUTED — no coord-tracked conflict-fix explains it"
|
|
1481
|
+
)
|
|
1482
|
+
)
|
|
1483
|
+
click.echo()
|
|
1484
|
+
click.echo(github_ops.truncate_diff_text(diff_text))
|
|
1485
|
+
click.echo()
|
|
1486
|
+
if not conflict_fix_only:
|
|
1487
|
+
click.echo(
|
|
1488
|
+
"WARNING: coord has no completed conflict-fix on record for this "
|
|
1489
|
+
"branch since the approval, so it cannot confirm the delta above "
|
|
1490
|
+
"is only a mechanical rebase resolution. Nothing intervened on the "
|
|
1491
|
+
"board (that would have been refused outright) — but you are "
|
|
1492
|
+
"vouching for this diff yourself. Read every line before "
|
|
1493
|
+
"confirming.",
|
|
1494
|
+
err=True,
|
|
1495
|
+
)
|
|
1496
|
+
click.echo()
|
|
1497
|
+
|
|
1498
|
+
if not yes and not click.confirm(
|
|
1499
|
+
"Reaffirm this approval to cover the current head?"
|
|
1500
|
+
):
|
|
1501
|
+
click.echo("aborted — approval NOT reaffirmed.")
|
|
1502
|
+
sys.exit(1)
|
|
1503
|
+
|
|
1504
|
+
new_patch_id = github_ops.get_branch_patch_id(
|
|
1505
|
+
entry.repo_github, entry.target_branch, entry.branch
|
|
1506
|
+
)
|
|
1507
|
+
try:
|
|
1508
|
+
record_review_reaffirm(
|
|
1509
|
+
review_assignment_id=prior_review.assignment_id,
|
|
1510
|
+
new_head_sha=new_sha,
|
|
1511
|
+
new_patch_id=new_patch_id,
|
|
1512
|
+
reason=reason,
|
|
1513
|
+
conflict_fix_only=conflict_fix_only,
|
|
1514
|
+
)
|
|
1515
|
+
except Exception as e: # noqa: BLE001
|
|
1516
|
+
click.echo(f"error: reaffirm write failed: {e}", err=True)
|
|
1517
|
+
sys.exit(1)
|
|
1518
|
+
|
|
1519
|
+
click.echo(
|
|
1520
|
+
f"Reaffirmed: review {prior_review.assignment_id} now covers "
|
|
1521
|
+
f"{new_sha[:12]} — {work.repo_name}#{work.issue_number} is unblocked "
|
|
1522
|
+
f"for merge."
|
|
1523
|
+
)
|