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/review.py
ADDED
|
@@ -0,0 +1,3317 @@
|
|
|
1
|
+
"""Adversarial code review — dispatch an independent reviewer when a worker finishes.
|
|
2
|
+
|
|
3
|
+
When `reviews.auto_dispatch` is enabled in `coordinator.yml`, completion of a
|
|
4
|
+
"work" assignment triggers a fresh `claude -p` session on a *different* machine
|
|
5
|
+
that reads the diff, runs tests, and posts a `gh pr review`. The reviewer has
|
|
6
|
+
zero shared context with the worker — that's the whole point.
|
|
7
|
+
|
|
8
|
+
Public entry points:
|
|
9
|
+
|
|
10
|
+
- `pick_reviewer_machine(...)` — choose an idle machine different from the
|
|
11
|
+
worker, with a single-machine fallback.
|
|
12
|
+
- `build_review_briefing(...)` — assemble the reviewer's prompt from the
|
|
13
|
+
repo's CLAUDE.md, the generic checklist, and any repo-specific overrides.
|
|
14
|
+
- `dispatch_review(...)` — full path: find/open PR, pick reviewer,
|
|
15
|
+
build briefing, send to agent server, add a review `Assignment` to the
|
|
16
|
+
board. Called from reconcile when a work assignment transitions to done.
|
|
17
|
+
|
|
18
|
+
Why a separate module: the work-dispatch path (`coord/dispatch.py`) is shaped
|
|
19
|
+
around `Proposal` objects from the brain. Reviews are triggered by completion
|
|
20
|
+
events on the board and target an existing PR, so they share little of that
|
|
21
|
+
plumbing — keeping them apart avoids twisting both shapes.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import difflib
|
|
27
|
+
import logging
|
|
28
|
+
import re
|
|
29
|
+
import time
|
|
30
|
+
import uuid
|
|
31
|
+
from typing import Iterable, Iterator
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
import httpx
|
|
36
|
+
|
|
37
|
+
from coord import github_ops
|
|
38
|
+
from coord.config import Config, ReviewsConfig
|
|
39
|
+
from coord.dispatch import AGENT_PORT
|
|
40
|
+
from coord.models import (
|
|
41
|
+
CLOSES_ISSUE_TYPES,
|
|
42
|
+
SEALED_PATH_AUTHOR_TYPES,
|
|
43
|
+
WORK_LIKE_TYPES,
|
|
44
|
+
Assignment,
|
|
45
|
+
Board,
|
|
46
|
+
Machine,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
log = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── Review output parsing ────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ReviewFindings:
|
|
56
|
+
"""Structured review output extracted from a reviewer worker log."""
|
|
57
|
+
verdict: str # "approve" or "request-changes"
|
|
58
|
+
body: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Matches the structured block the reviewer is instructed to emit at end of session.
|
|
62
|
+
# Allows optional leading/trailing whitespace and tolerates both LF and CRLF.
|
|
63
|
+
# Accepts canonical verdicts (approve / request-changes) and short aliases
|
|
64
|
+
# (PASS → approve, FAIL → request-changes) for workers that use the shorter form.
|
|
65
|
+
#
|
|
66
|
+
# The `REVIEW_BODY:` marker is OPTIONAL (#608): reviewers commonly emit the
|
|
67
|
+
# verdict line followed directly by Markdown findings and `END_REVIEW`, omitting
|
|
68
|
+
# the `REVIEW_BODY:` header. When it's absent the body is everything between the
|
|
69
|
+
# verdict line and `END_REVIEW`. `END_REVIEW` stays the required terminator, so a
|
|
70
|
+
# stray "REVIEW_VERDICT:" in prose (with no terminator) still won't match.
|
|
71
|
+
#
|
|
72
|
+
# Markdown decoration around the markers is TOLERATED (#1346): reviewers write
|
|
73
|
+
# prose, and a non-trivial fraction of them emit the block as Markdown —
|
|
74
|
+
# `**REVIEW_VERDICT: request-changes**` / `**REVIEW_BODY:**` / `## END_REVIEW`,
|
|
75
|
+
# or bold only the value (`REVIEW_VERDICT: **approve**`). The original pattern
|
|
76
|
+
# required the verdict token to be followed by nothing but whitespace and a
|
|
77
|
+
# newline, so a single pair of trailing asterisks made a complete, correct
|
|
78
|
+
# review with a valid `END_REVIEW` terminator parse as "no review at all" — the
|
|
79
|
+
# verdict was then silently dropped on every consumer of this regex (the #606
|
|
80
|
+
# transcript-floor, `notify`, the auto-loop) and the operator was left with a
|
|
81
|
+
# blank verdict prompt. `_MD` absorbs emphasis/code-span/heading punctuation and
|
|
82
|
+
# surrounding whitespace on either side of each marker; `END_REVIEW` remains the
|
|
83
|
+
# required terminator, so the tolerance does not widen what counts as a review.
|
|
84
|
+
_MD = r"[*_`#\s]*"
|
|
85
|
+
_REVIEW_BLOCK_RE = re.compile(
|
|
86
|
+
rf"REVIEW_VERDICT:{_MD}(approve|request-changes|pass|fail){_MD}[\r\n]+"
|
|
87
|
+
rf"(?:{_MD}REVIEW_BODY:{_MD}[\r\n]+)?(.*?)[\r\n]*{_MD}END_REVIEW",
|
|
88
|
+
re.DOTALL | re.IGNORECASE,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Map short-form aliases to the canonical verdicts understood by post_pr_review.
|
|
92
|
+
_VERDICT_ALIASES: dict[str, str] = {
|
|
93
|
+
"pass": "approve",
|
|
94
|
+
"fail": "request-changes",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ── #1348: strict-parse failure diagnostic ──────────────────────────────────
|
|
99
|
+
#
|
|
100
|
+
# When `_parse_review_text` / `parse_review_from_log` returns None, the caller
|
|
101
|
+
# cannot distinguish "text contains no review" from "text HAS a review marker
|
|
102
|
+
# but the strict parser rejected it" (e.g. bolded **REVIEW_VERDICT:** from the
|
|
103
|
+
# #1346 incident — a 6.2 KB request-changes review was silently dropped because
|
|
104
|
+
# the trailing `**` made the verdict group fail to match `_REVIEW_BLOCK_RE`).
|
|
105
|
+
#
|
|
106
|
+
# `detect_unparsed_review_marker` is a DIAGNOSTIC ONLY. It MUST NOT be wired
|
|
107
|
+
# into `_parse_review_text` / `parse_review_from_log`, and MUST NOT be used to
|
|
108
|
+
# auto-record a verdict. `END_REVIEW` remains the required terminator for a
|
|
109
|
+
# legitimate strict parse. Call it only AFTER the strict parse has already
|
|
110
|
+
# returned `None` and the calling floor has confirmed attribution.
|
|
111
|
+
#
|
|
112
|
+
# #1348 round 3: `_parse_review_text` now also runs
|
|
113
|
+
# `_decode_transcript_for_diagnostic` before matching `_REVIEW_BLOCK_RE` (see
|
|
114
|
+
# below) — the NDJSON DECODE is shared between the strict parser and this
|
|
115
|
+
# diagnostic, because a stream-json log's JSON-escaped newlines defeat
|
|
116
|
+
# `[\r\n]+` regardless of which regex runs against it. Sharing the decode is
|
|
117
|
+
# NOT the same as wiring the diagnostic's loose marker detection
|
|
118
|
+
# (`_REVIEW_MARKER_DETECT_RE`, which has no `END_REVIEW` requirement) into the
|
|
119
|
+
# strict path — that restraint above is unchanged. `_REVIEW_BLOCK_RE` and its
|
|
120
|
+
# mandatory `END_REVIEW` terminator are exactly as strict as before; a
|
|
121
|
+
# malformed block (e.g. bolded `**REVIEW_VERDICT:**`, the #1346 shape) still
|
|
122
|
+
# fails `_parse_review_text` and must go through this diagnostic, same as
|
|
123
|
+
# always.
|
|
124
|
+
|
|
125
|
+
# Detect a REVIEW_VERDICT: line even when `_REVIEW_BLOCK_RE` cannot extract
|
|
126
|
+
# a clean block. Captures everything on the marker line so the verdict word
|
|
127
|
+
# can be extracted after stripping Markdown decorators (e.g. "request-changes**"
|
|
128
|
+
# → "request-changes"). No word-boundary constraint before REVIEW_VERDICT: so
|
|
129
|
+
# this also fires on bolded lines like "**REVIEW_VERDICT: request-changes**".
|
|
130
|
+
_REVIEW_MARKER_DETECT_RE = re.compile(
|
|
131
|
+
r"REVIEW_VERDICT:[^\S\r\n]*([^\r\n]*)", # [^\S\r\n]* = horizontal whitespace only
|
|
132
|
+
re.IGNORECASE,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
#: Cap on the excerpt captured by :func:`detect_unparsed_review_marker`. A few
|
|
136
|
+
#: KB is enough to show the operator the malformed block; transcripts can be
|
|
137
|
+
#: multi-MB and we must not hold the whole thing.
|
|
138
|
+
_DIAGNOSTIC_EXCERPT_MAX: int = 4096
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass
|
|
142
|
+
class UnparsedReviewMarker:
|
|
143
|
+
"""Diagnostic returned when a transcript contains a ``REVIEW_VERDICT:``
|
|
144
|
+
marker that the strict parser rejected (#1348).
|
|
145
|
+
|
|
146
|
+
A strict-parse failure on a transcript that clearly contains a review must
|
|
147
|
+
be loud, not silent — the operator cannot distinguish "reviewer forgot
|
|
148
|
+
``END_REVIEW``" from "there is nothing to recover" when both paths return
|
|
149
|
+
``None``. This carries the raw excerpt and detected verdict word so the
|
|
150
|
+
coordinator surface can:
|
|
151
|
+
|
|
152
|
+
* Warn the operator with a greppable ``log.warning`` naming the host and
|
|
153
|
+
transcript path.
|
|
154
|
+
* Print output clearly distinct from "no verdict reported" — two different
|
|
155
|
+
failures, two different fixes, and they must not look the same.
|
|
156
|
+
* Seed the editor with the recovered excerpt so the operator edits /
|
|
157
|
+
confirms what the reviewer wrote rather than typing from scratch.
|
|
158
|
+
* Default the verdict prompt to the detected word when it is canonical.
|
|
159
|
+
|
|
160
|
+
Attributes:
|
|
161
|
+
verdict_word: Lowercased, Markdown-stripped word from the
|
|
162
|
+
``REVIEW_VERDICT:`` line, or ``None`` when the line was blank.
|
|
163
|
+
When it matches a canonical verdict (``approve`` /
|
|
164
|
+
``request-changes``) or a known alias (``pass`` → approve,
|
|
165
|
+
``fail`` → request-changes) the operator prompt defaults to it
|
|
166
|
+
instead of ``[s]kip``.
|
|
167
|
+
excerpt: Bounded slice starting from the ``REVIEW_VERDICT:`` line,
|
|
168
|
+
capped at :data:`_DIAGNOSTIC_EXCERPT_MAX` chars. For stream-json
|
|
169
|
+
logs this is the DECODED assistant text (real newlines, no JSON
|
|
170
|
+
scaffolding) — see :func:`_decode_transcript_for_diagnostic`.
|
|
171
|
+
Passed to ``_collect_review_body_via_editor`` as ``pre_body`` so
|
|
172
|
+
the operator edits the real review text.
|
|
173
|
+
transcript_path: Filesystem path of the transcript scanned. For the
|
|
174
|
+
remote-ssh path this is the path **on the remote host** (useful in
|
|
175
|
+
a ``ssh <host> cat <path>`` hint). ``None`` when unknown.
|
|
176
|
+
host: SSH hostname the transcript was fetched from, or ``None`` for a
|
|
177
|
+
local transcript.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
verdict_word: str | None
|
|
181
|
+
excerpt: str
|
|
182
|
+
transcript_path: str | None = None
|
|
183
|
+
host: str | None = None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _decode_transcript_for_diagnostic(text: str) -> str | None:
|
|
187
|
+
"""Best-effort NDJSON (stream-json) decode of *text*, for #1348 round 2.
|
|
188
|
+
|
|
189
|
+
``claude -p --output-format stream-json`` (and Claude Code's own session
|
|
190
|
+
transcripts under ``~/.claude/projects/``) emit one JSON object per line.
|
|
191
|
+
Any real newline inside an assistant message's text is therefore stored
|
|
192
|
+
on disk as the two-character escape ``\\n``, not a ``0x0A`` byte — valid
|
|
193
|
+
JSON, but useless to a regex that anchors on ``[\\r\\n]``. Worse, line 1 of
|
|
194
|
+
every agent log is a non-JSON ``# agent=... argv=...`` comment that embeds
|
|
195
|
+
the reviewer's own ``--system-prompt`` argument verbatim (also
|
|
196
|
+
newline-escaped onto one physical line by ``agent.py``'s
|
|
197
|
+
``.replace("\\n", "\\\\n")``) — and that system prompt CONTAINS the literal
|
|
198
|
+
``REVIEW_VERDICT: approve\\nREVIEW_BODY:\\n<your full review text in
|
|
199
|
+
markdown>\\nEND_REVIEW`` template the reviewer is instructed to fill in.
|
|
200
|
+
A one-shot ``text.replace("\\\\n", "\\n")`` over the whole raw log would
|
|
201
|
+
turn that template into something that matches — and since it comes
|
|
202
|
+
first in the file, a plain ``.search()`` would find it before the real
|
|
203
|
+
verdict emitted later by the assistant.
|
|
204
|
+
|
|
205
|
+
This decodes *text* using the same machinery as the strict parser
|
|
206
|
+
(:func:`parse_event` / ``_assistant_text`` in :mod:`coord.worker_events`):
|
|
207
|
+
only lines that parse as a JSON object contribute anything at all, and
|
|
208
|
+
only ``"assistant"``-typed events contribute text — the non-JSON
|
|
209
|
+
argv/header comment line (and any ``"system"``/``"user"`` event that
|
|
210
|
+
might otherwise echo the system-prompt template back) is silently
|
|
211
|
+
skipped, never concatenated into the decoded text. Returns the assistant
|
|
212
|
+
texts joined by real ``"\\n"``, in emission order — or ``None`` when
|
|
213
|
+
*text* contains no NDJSON at all (e.g. an old-format plain-text log, or
|
|
214
|
+
plain prose in a test fixture), so the caller falls back to treating
|
|
215
|
+
*text* as-is.
|
|
216
|
+
|
|
217
|
+
#1348 round 3: this is now shared by :func:`_parse_review_text` (the
|
|
218
|
+
STRICT parser) as well as :func:`detect_unparsed_review_marker` (the
|
|
219
|
+
loose diagnostic) — see the comment above `detect_unparsed_review_marker`
|
|
220
|
+
for why sharing the decode does not loosen the strict grammar.
|
|
221
|
+
`parse_review_from_log`'s ``is_stream_json`` branch already ran
|
|
222
|
+
individual lines through `parse_event`/`_assistant_text`, but its
|
|
223
|
+
plain-text fallback branch (taken whenever `is_stream_json`'s
|
|
224
|
+
first-non-comment-line heuristic misses, even on a log that IS valid
|
|
225
|
+
NDJSON) handed the strict regex raw, undecoded text — silently dropping
|
|
226
|
+
well-formed verdicts whose newlines were still JSON-escaped on disk.
|
|
227
|
+
|
|
228
|
+
#1710 inventory: kept as a direct ``coord.worker_events`` import, not
|
|
229
|
+
routed through ``provider.parse_log()``. This decodes the generic
|
|
230
|
+
Anthropic-Messages-API ``type: "assistant"`` / ``message.content``
|
|
231
|
+
envelope — a wire-format detail any Agent-SDK-shaped backend can share —
|
|
232
|
+
not claude-*business* semantics, and ``Provider``/``WorkerSummary`` have
|
|
233
|
+
no equivalent "raw assistant text" primitive to route through (adding
|
|
234
|
+
one would mean a new abstract ``Provider`` method, touching every
|
|
235
|
+
concrete provider including ``opencode.py`` — out of scope here). See
|
|
236
|
+
``tests/test_provider_seam.py::TestReviewExtractionForASecondProvider``
|
|
237
|
+
for a second-provider log that this already decodes correctly today.
|
|
238
|
+
"""
|
|
239
|
+
from coord.worker_events import _assistant_text, parse_event # noqa: PLC0415
|
|
240
|
+
|
|
241
|
+
texts: list[str] = []
|
|
242
|
+
saw_json = False
|
|
243
|
+
for line in text.splitlines():
|
|
244
|
+
event = parse_event(line)
|
|
245
|
+
if event is None:
|
|
246
|
+
continue
|
|
247
|
+
saw_json = True
|
|
248
|
+
if event.type == "assistant":
|
|
249
|
+
t = _assistant_text(event)
|
|
250
|
+
if t:
|
|
251
|
+
texts.append(t)
|
|
252
|
+
if not saw_json:
|
|
253
|
+
return None
|
|
254
|
+
return "\n".join(texts)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def detect_unparsed_review_marker(
|
|
258
|
+
text: str,
|
|
259
|
+
*,
|
|
260
|
+
transcript_path: str | None = None,
|
|
261
|
+
host: str | None = None,
|
|
262
|
+
) -> UnparsedReviewMarker | None:
|
|
263
|
+
"""Return diagnostic info when *text* contains a ``REVIEW_VERDICT:`` marker
|
|
264
|
+
that the strict parser would reject (#1348).
|
|
265
|
+
|
|
266
|
+
**Diagnostic only — never a parser.** Must be called AFTER
|
|
267
|
+
:func:`parse_review_from_log` has returned ``None``. Never wire this into
|
|
268
|
+
:func:`_parse_review_text` / :func:`parse_review_from_log`, and never use
|
|
269
|
+
its return value to auto-record a verdict. ``END_REVIEW`` is still the
|
|
270
|
+
required terminator for a legitimate strict parse.
|
|
271
|
+
|
|
272
|
+
*text* is decoded via :func:`_decode_transcript_for_diagnostic` before any
|
|
273
|
+
matching happens (#1348 round 2) — callers pass the RAW file/transcript
|
|
274
|
+
content, which for a stream-json log has every real newline inside the
|
|
275
|
+
assistant's text escaped as ``\\n`` (two characters), not ``0x0A``; the
|
|
276
|
+
strict-parser regexes below would never match that unless it's decoded
|
|
277
|
+
first, same as the strict parser itself does via
|
|
278
|
+
``parse_event``/``_assistant_text``. When *text* isn't NDJSON at all (old
|
|
279
|
+
plain-text logs) it's matched as-is, unchanged from before.
|
|
280
|
+
|
|
281
|
+
Returns ``None`` when:
|
|
282
|
+
|
|
283
|
+
* No ``REVIEW_VERDICT:`` marker is present — *text* is genuinely not a
|
|
284
|
+
review; no false positives.
|
|
285
|
+
* The strict parse actually SUCCEEDED — a defensive guard so a caller that
|
|
286
|
+
forgets the "call after strict-parse" contract never double-reports.
|
|
287
|
+
|
|
288
|
+
Otherwise returns an :class:`UnparsedReviewMarker` with a bounded excerpt
|
|
289
|
+
(capped at :data:`_DIAGNOSTIC_EXCERPT_MAX` chars from the marker line) and
|
|
290
|
+
the detected verdict word with Markdown decorators stripped.
|
|
291
|
+
"""
|
|
292
|
+
decoded = _decode_transcript_for_diagnostic(text)
|
|
293
|
+
search_text = text if decoded is None else decoded
|
|
294
|
+
|
|
295
|
+
matches = list(_REVIEW_MARKER_DETECT_RE.finditer(search_text))
|
|
296
|
+
if not matches:
|
|
297
|
+
return None
|
|
298
|
+
# Guard: strict parse succeeded on the SAME (decoded) text → return None,
|
|
299
|
+
# never double-report (#1348).
|
|
300
|
+
if _REVIEW_BLOCK_RE.search(search_text):
|
|
301
|
+
return None
|
|
302
|
+
# Take the LAST match, not the first (#1348 round 2). Decoding already
|
|
303
|
+
# excludes the non-JSON argv/header comment line that embeds the
|
|
304
|
+
# reviewer's own system-prompt TEMPLATE (see
|
|
305
|
+
# _decode_transcript_for_diagnostic), but this also protects the
|
|
306
|
+
# plain-text fallback path (decoded is None) where that template text
|
|
307
|
+
# could still precede the real verdict in the raw log, and it mirrors
|
|
308
|
+
# `_parse_review_text`'s own `matches[-1]`: a reviewer that second-guesses
|
|
309
|
+
# itself mid-session emits the real verdict last.
|
|
310
|
+
m = matches[-1]
|
|
311
|
+
# Extract and normalize the verdict word. Strip common Markdown decorators
|
|
312
|
+
# (*_`#) so e.g. "**request-changes**" normalises to "request-changes".
|
|
313
|
+
raw_line = m.group(1).strip()
|
|
314
|
+
clean_word = re.sub(r"[*_`#]+", "", raw_line).strip().lower()
|
|
315
|
+
verdict_word = clean_word if clean_word else None
|
|
316
|
+
# Bounded excerpt: start at the beginning of the REVIEW_VERDICT: line,
|
|
317
|
+
# capture up to _DIAGNOSTIC_EXCERPT_MAX chars so the operator can see
|
|
318
|
+
# the full verdict block without holding the whole (possibly multi-MB) log.
|
|
319
|
+
line_start = search_text.rfind("\n", 0, m.start()) + 1 # +1 skips the \n itself
|
|
320
|
+
start = line_start
|
|
321
|
+
end = min(len(search_text), start + _DIAGNOSTIC_EXCERPT_MAX)
|
|
322
|
+
excerpt = search_text[start:end]
|
|
323
|
+
return UnparsedReviewMarker(
|
|
324
|
+
verdict_word=verdict_word,
|
|
325
|
+
excerpt=excerpt,
|
|
326
|
+
transcript_path=transcript_path,
|
|
327
|
+
host=host,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# ── #1956 ask 3: END_REVIEW present, REVIEW_VERDICT absent entirely ─────────
|
|
332
|
+
#
|
|
333
|
+
# quadraui#533's live incident: the reviewer wrote a complete, thorough
|
|
334
|
+
# review and ended with `END_REVIEW`, but never wrote `REVIEW_VERDICT:`
|
|
335
|
+
# ANYWHERE — grepping the raw log found the string exactly once, inside the
|
|
336
|
+
# briefing's own instructions, never in an assistant message. That is a
|
|
337
|
+
# DIFFERENT failure signature from #1348's "marker present but malformed"
|
|
338
|
+
# (e.g. a bolded `**REVIEW_VERDICT:**`): here the model followed the *tail*
|
|
339
|
+
# of the required format while dropping the *header* that carries the data
|
|
340
|
+
# entirely, rather than attempting the header and getting the syntax wrong.
|
|
341
|
+
# `detect_unparsed_review_marker` cannot see this case at all — it only
|
|
342
|
+
# fires when a `REVIEW_VERDICT:` marker exists to detect.
|
|
343
|
+
|
|
344
|
+
@dataclass
|
|
345
|
+
class EndReviewWithoutVerdict:
|
|
346
|
+
"""Diagnostic returned when *text* has an ``END_REVIEW`` terminator but
|
|
347
|
+
NO ``REVIEW_VERDICT:`` marker anywhere (#1956 ask 3).
|
|
348
|
+
|
|
349
|
+
Distinguishing this from a crashed/truncated session (which never
|
|
350
|
+
reaches ``END_REVIEW`` at all — nothing to recover) matters
|
|
351
|
+
operationally: a session that wrote ``END_REVIEW`` almost certainly
|
|
352
|
+
reached a real verdict, it just never printed the machine-readable
|
|
353
|
+
header for it. The verdict is very likely recoverable from ``excerpt``
|
|
354
|
+
(the prose immediately before ``END_REVIEW``) by an operator reading it
|
|
355
|
+
and re-running ``coord report-result --assignment <id> --verdict
|
|
356
|
+
<approve|request-changes> --verdict-source recovered --verdict-reason
|
|
357
|
+
"..." --body-file <extracted-review.md>``.
|
|
358
|
+
|
|
359
|
+
**Diagnostic only — never a parser, same contract as
|
|
360
|
+
:func:`detect_unparsed_review_marker`.** MUST NOT be used to
|
|
361
|
+
auto-record a verdict, and must only be called AFTER
|
|
362
|
+
:func:`parse_review_from_log` / :func:`_parse_review_text` has already
|
|
363
|
+
returned ``None`` for this same text.
|
|
364
|
+
"""
|
|
365
|
+
|
|
366
|
+
excerpt: str
|
|
367
|
+
transcript_path: str | None = None
|
|
368
|
+
host: str | None = None
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
_END_REVIEW_DETECT_RE = re.compile(rf"{_MD}END_REVIEW{_MD}", re.IGNORECASE)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def detect_end_review_without_verdict(
|
|
375
|
+
text: str,
|
|
376
|
+
*,
|
|
377
|
+
transcript_path: str | None = None,
|
|
378
|
+
host: str | None = None,
|
|
379
|
+
) -> EndReviewWithoutVerdict | None:
|
|
380
|
+
"""Return diagnostic info when *text* has ``END_REVIEW`` but no
|
|
381
|
+
``REVIEW_VERDICT:`` marker at all (#1956 ask 3).
|
|
382
|
+
|
|
383
|
+
*text* is decoded via :func:`_decode_transcript_for_diagnostic` first,
|
|
384
|
+
exactly like the strict parser and :func:`detect_unparsed_review_marker`
|
|
385
|
+
— see that function's docstring for why (stream-json newline-escaping,
|
|
386
|
+
and excluding the non-JSON argv/header comment line whose embedded
|
|
387
|
+
system-prompt TEMPLATE would otherwise spuriously contain both markers).
|
|
388
|
+
|
|
389
|
+
Returns ``None`` when:
|
|
390
|
+
|
|
391
|
+
* No ``END_REVIEW`` terminator is present at all — this is NOT the
|
|
392
|
+
#1956 signature; a session that never reached ``END_REVIEW`` more
|
|
393
|
+
likely crashed or was truncated, a different failure with a different
|
|
394
|
+
(probably unrecoverable) remedy.
|
|
395
|
+
* A ``REVIEW_VERDICT:`` marker IS present somewhere, even a malformed
|
|
396
|
+
one — that is :func:`detect_unparsed_review_marker`'s territory: the
|
|
397
|
+
header was ATTEMPTED (and rejected), not omitted entirely. The two
|
|
398
|
+
diagnostics are mutually exclusive by construction.
|
|
399
|
+
* The strict parse actually SUCCEEDED on this same text — defensive
|
|
400
|
+
guard mirroring :func:`detect_unparsed_review_marker`, so a caller
|
|
401
|
+
that forgets the "call after strict-parse" contract never
|
|
402
|
+
double-reports.
|
|
403
|
+
|
|
404
|
+
The excerpt is the text immediately BEFORE the LAST ``END_REVIEW`` line
|
|
405
|
+
(a reviewer that second-guesses itself mid-session writes the real one
|
|
406
|
+
last — same convention as :func:`_parse_review_text`'s ``matches[-1]``),
|
|
407
|
+
capped at :data:`_DIAGNOSTIC_EXCERPT_MAX` chars — there's no
|
|
408
|
+
``REVIEW_VERDICT:``/``REVIEW_BODY:`` line to anchor on instead, since by
|
|
409
|
+
definition neither exists in *text*.
|
|
410
|
+
"""
|
|
411
|
+
decoded = _decode_transcript_for_diagnostic(text)
|
|
412
|
+
search_text = text if decoded is None else decoded
|
|
413
|
+
|
|
414
|
+
if _REVIEW_MARKER_DETECT_RE.search(search_text):
|
|
415
|
+
return None # a REVIEW_VERDICT: marker exists — different diagnostic
|
|
416
|
+
matches = list(_END_REVIEW_DETECT_RE.finditer(search_text))
|
|
417
|
+
if not matches:
|
|
418
|
+
return None
|
|
419
|
+
if _REVIEW_BLOCK_RE.search(search_text):
|
|
420
|
+
return None # strict parse actually succeeded — never double-report
|
|
421
|
+
|
|
422
|
+
m = matches[-1]
|
|
423
|
+
end = m.start()
|
|
424
|
+
start = max(0, end - _DIAGNOSTIC_EXCERPT_MAX)
|
|
425
|
+
excerpt = search_text[start:end].strip()
|
|
426
|
+
return EndReviewWithoutVerdict(
|
|
427
|
+
excerpt=excerpt,
|
|
428
|
+
transcript_path=transcript_path,
|
|
429
|
+
host=host,
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
# ── #248: machine-readable review header ────────────────────────────────────
|
|
434
|
+
#
|
|
435
|
+
# When the coordinator posts a review comment back to GitHub it prepends a
|
|
436
|
+
# short HTML comment carrying the verdict in machine-readable form. The
|
|
437
|
+
# header is invisible to humans on the PR but lets the TUI render a verdict
|
|
438
|
+
# badge and lets the coordinator session check the verdict without reading
|
|
439
|
+
# the full prose body (which can be several KB).
|
|
440
|
+
#
|
|
441
|
+
# Format:
|
|
442
|
+
# <!-- coord:review verdict=request-changes blocking=2 nonblocking=5 \
|
|
443
|
+
# nits=2 reviewer=elitebook assignment=144ffa027a31 -->
|
|
444
|
+
#
|
|
445
|
+
# `verdict` is always present. Counts are best-effort: when the prose
|
|
446
|
+
# body uses recognisable section headings, the coordinator counts items
|
|
447
|
+
# under each; when it can't, those tokens are omitted (parser tolerates
|
|
448
|
+
# missing tokens).
|
|
449
|
+
_REVIEW_HEADER_RE = re.compile(
|
|
450
|
+
r"<!--\s*coord:review\s+([^>]+?)\s*-->",
|
|
451
|
+
re.IGNORECASE,
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
# Maps human section-heading keywords (case-insensitive) to the count
|
|
455
|
+
# category they belong to. The heuristic walks the prose body, splits
|
|
456
|
+
# on markdown headings, and bucketises bullet-list items under each.
|
|
457
|
+
_SECTION_KEYWORDS: dict[str, tuple[str, ...]] = {
|
|
458
|
+
"blocking": ("blocking", "required change", "must fix", "must-fix",
|
|
459
|
+
"changes required"),
|
|
460
|
+
"nonblocking": ("non-blocking", "non blocking", "concerns",
|
|
461
|
+
"should fix", "should-fix", "observations"),
|
|
462
|
+
"nits": ("nits", "nit:", "polish", "minor", "style"),
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
# Check buckets in order of keyword specificity so that "Non-blocking
|
|
466
|
+
# concerns" doesn't accidentally match the `blocking` bucket first.
|
|
467
|
+
_ORDERED_BUCKETS: tuple[str, ...] = ("nonblocking", "nits", "blocking")
|
|
468
|
+
|
|
469
|
+
# Phrases that make an otherwise-prose line in a *blocking* section readable
|
|
470
|
+
# as "the reviewer explicitly raised nothing here" (#1456). Only consulted
|
|
471
|
+
# for short lines — a long paragraph is prose the bullet counter cannot see,
|
|
472
|
+
# and therefore evidence that the section is NOT confirmed empty.
|
|
473
|
+
_NO_FINDINGS_PHRASES: tuple[str, ...] = (
|
|
474
|
+
"none", "n/a", "nothing", "no blocking", "no issues", "no required",
|
|
475
|
+
"no must-fix", "no must fix", "all clear",
|
|
476
|
+
)
|
|
477
|
+
_NO_FINDINGS_MAX_LEN = 60
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def format_review_header(
|
|
481
|
+
*,
|
|
482
|
+
verdict: str,
|
|
483
|
+
reviewer_machine: str | None = None,
|
|
484
|
+
assignment_id: str | None = None,
|
|
485
|
+
blocking: int | None = None,
|
|
486
|
+
nonblocking: int | None = None,
|
|
487
|
+
nits: int | None = None,
|
|
488
|
+
) -> str:
|
|
489
|
+
"""Build the HTML-comment header that machines parse.
|
|
490
|
+
|
|
491
|
+
`verdict` is required; everything else is optional and only emitted
|
|
492
|
+
when provided. Returns a single line (no trailing newline).
|
|
493
|
+
"""
|
|
494
|
+
parts = [f"verdict={verdict}"]
|
|
495
|
+
if blocking is not None:
|
|
496
|
+
parts.append(f"blocking={blocking}")
|
|
497
|
+
if nonblocking is not None:
|
|
498
|
+
parts.append(f"nonblocking={nonblocking}")
|
|
499
|
+
if nits is not None:
|
|
500
|
+
parts.append(f"nits={nits}")
|
|
501
|
+
if reviewer_machine:
|
|
502
|
+
parts.append(f"reviewer={reviewer_machine}")
|
|
503
|
+
if assignment_id:
|
|
504
|
+
parts.append(f"assignment={assignment_id}")
|
|
505
|
+
return f"<!-- coord:review {' '.join(parts)} -->"
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def parse_review_header(body: str) -> dict[str, str | int] | None:
|
|
509
|
+
"""Extract the coord:review header from *body*, or ``None`` when missing.
|
|
510
|
+
|
|
511
|
+
Numeric tokens (``blocking``, ``nonblocking``, ``nits``) are returned as
|
|
512
|
+
``int``; everything else stays a ``str``. Tolerates extra whitespace
|
|
513
|
+
and unknown tokens.
|
|
514
|
+
"""
|
|
515
|
+
m = _REVIEW_HEADER_RE.search(body)
|
|
516
|
+
if not m:
|
|
517
|
+
return None
|
|
518
|
+
out: dict[str, str | int] = {}
|
|
519
|
+
for token in m.group(1).split():
|
|
520
|
+
if "=" not in token:
|
|
521
|
+
continue
|
|
522
|
+
key, value = token.split("=", 1)
|
|
523
|
+
key = key.lower()
|
|
524
|
+
if key in ("blocking", "nonblocking", "nits"):
|
|
525
|
+
try:
|
|
526
|
+
out[key] = int(value)
|
|
527
|
+
except ValueError:
|
|
528
|
+
continue
|
|
529
|
+
else:
|
|
530
|
+
out[key] = value
|
|
531
|
+
return out if "verdict" in out else None
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
_BULLET_RE = re.compile(r"^\s*(?:[-*]|\d+\.)\s+\S")
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _bucket_for_heading(heading_line: str) -> str | None:
|
|
538
|
+
"""Map a markdown heading line to a `_SECTION_KEYWORDS` bucket, or None."""
|
|
539
|
+
heading_text = heading_line.lstrip("#").strip().lower()
|
|
540
|
+
for bucket in _ORDERED_BUCKETS:
|
|
541
|
+
if any(kw in heading_text for kw in _SECTION_KEYWORDS[bucket]):
|
|
542
|
+
return bucket
|
|
543
|
+
return None
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _iter_review_sections(body: str) -> Iterator[tuple[str | None, list[str]]]:
|
|
547
|
+
"""Yield ``(bucket, lines)`` for each markdown section of *body*.
|
|
548
|
+
|
|
549
|
+
*bucket* is the `_SECTION_KEYWORDS` bucket the section's heading maps to,
|
|
550
|
+
or ``None`` for the preamble (text before the first heading) and for
|
|
551
|
+
headings that match no keyword. *lines* are the right-stripped lines
|
|
552
|
+
under that heading, up to the next heading.
|
|
553
|
+
|
|
554
|
+
Shared by `estimate_review_counts` (which counts bullets) and
|
|
555
|
+
`blocking_findings_confirmed_absent` (which inspects prose), so the two
|
|
556
|
+
can never disagree about where a section starts and ends (#1456).
|
|
557
|
+
"""
|
|
558
|
+
current: str | None = None
|
|
559
|
+
lines: list[str] = []
|
|
560
|
+
for raw in body.splitlines():
|
|
561
|
+
line = raw.rstrip()
|
|
562
|
+
if line.startswith("#"):
|
|
563
|
+
yield current, lines
|
|
564
|
+
current = _bucket_for_heading(line)
|
|
565
|
+
lines = []
|
|
566
|
+
continue
|
|
567
|
+
lines.append(line)
|
|
568
|
+
yield current, lines
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def estimate_review_counts(
|
|
572
|
+
body: str,
|
|
573
|
+
) -> tuple[int | None, int | None, int | None]:
|
|
574
|
+
"""Best-effort count of (blocking, nonblocking, nits) bullets in *body*.
|
|
575
|
+
|
|
576
|
+
Walks markdown sections. A section is recognised when its heading
|
|
577
|
+
contains one of `_SECTION_KEYWORDS`; counts are the number of `- ` /
|
|
578
|
+
`* ` / `1. ` bullets directly under that section (until the next
|
|
579
|
+
heading). Returns ``(None, None, None)`` when no recognised
|
|
580
|
+
sections appear — the heuristic refuses to guess.
|
|
581
|
+
|
|
582
|
+
**``None`` means "could not determine", never "zero" (#1456).** A caller
|
|
583
|
+
that conflates the two turns a heuristic miss into a positive claim that
|
|
584
|
+
the reviewer raised nothing — which is how a `request-changes` verdict got
|
|
585
|
+
silently rewritten to `approve` on #1445. Callers deciding *anything*
|
|
586
|
+
about whether blocking findings exist must go through
|
|
587
|
+
`blocking_findings_confirmed_absent`, not compare these values themselves.
|
|
588
|
+
"""
|
|
589
|
+
counts: dict[str, int | None] = {"blocking": None, "nonblocking": None, "nits": None}
|
|
590
|
+
for bucket, lines in _iter_review_sections(body):
|
|
591
|
+
if bucket is None:
|
|
592
|
+
continue
|
|
593
|
+
# Initialise the count for this bucket so it shows as 0 (not None)
|
|
594
|
+
# even when the section is empty.
|
|
595
|
+
counts[bucket] = (counts[bucket] or 0) + sum(
|
|
596
|
+
1 for line in lines if _BULLET_RE.match(line)
|
|
597
|
+
)
|
|
598
|
+
return counts["blocking"], counts["nonblocking"], counts["nits"]
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _is_no_findings_line(text: str) -> bool:
|
|
602
|
+
"""True when *text* reads as an explicit "nothing here" marker.
|
|
603
|
+
|
|
604
|
+
Deliberately narrow: only short lines qualify, so a real finding written
|
|
605
|
+
as prose ("No error path is handled when the worktree leaks, so …") is
|
|
606
|
+
never mistaken for an empty section.
|
|
607
|
+
"""
|
|
608
|
+
stripped = text.strip(" \t*_`~>-–—.:!()[]")
|
|
609
|
+
if not stripped:
|
|
610
|
+
return True
|
|
611
|
+
if len(stripped) > _NO_FINDINGS_MAX_LEN:
|
|
612
|
+
return False
|
|
613
|
+
low = stripped.lower()
|
|
614
|
+
return any(phrase in low for phrase in _NO_FINDINGS_PHRASES)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def blocking_findings_confirmed_absent(body: str) -> bool:
|
|
618
|
+
"""True only when *body* carries POSITIVE evidence of zero blocking findings.
|
|
619
|
+
|
|
620
|
+
This is the evidence standard for overriding a reviewer's verdict (#1456).
|
|
621
|
+
It is deliberately *fail-closed*: everything the heuristic cannot read
|
|
622
|
+
returns ``False``, i.e. "assume the reviewer meant what it said".
|
|
623
|
+
|
|
624
|
+
Returns ``True`` only when **all** of the following hold:
|
|
625
|
+
|
|
626
|
+
1. A blocking section was actually located (``blocking is not None`` —
|
|
627
|
+
a heading matching `_SECTION_KEYWORDS["blocking"]`). A body with no
|
|
628
|
+
such heading yields ``None`` = *unknown*, which must never be read as
|
|
629
|
+
zero: that conflation is the #1456 defect, where a well-formed prose
|
|
630
|
+
`request-changes` on #1445 was rewritten to `approve` because the
|
|
631
|
+
*nits* bucket happened to parse as 0 while *blocking* parsed as None.
|
|
632
|
+
2. That section contains no bullets (an explicit parsed zero).
|
|
633
|
+
3. That section contains no substantive prose either — a reviewer who
|
|
634
|
+
writes blocking findings as paragraphs under "## Blocking" would
|
|
635
|
+
otherwise count as zero and fail open all the same. Short "None" /
|
|
636
|
+
"N/A" markers are allowed (that's the shape being looked for).
|
|
637
|
+
"""
|
|
638
|
+
blocking, _nonblocking, _nits = estimate_review_counts(body)
|
|
639
|
+
if blocking is None or blocking != 0:
|
|
640
|
+
return False
|
|
641
|
+
for bucket, lines in _iter_review_sections(body):
|
|
642
|
+
if bucket != "blocking":
|
|
643
|
+
continue
|
|
644
|
+
for line in lines:
|
|
645
|
+
if not _is_no_findings_line(line.strip()):
|
|
646
|
+
return False
|
|
647
|
+
return True
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _parse_review_text(text: str) -> ReviewFindings | None:
|
|
651
|
+
"""Extract the last ReviewFindings block from *text*, or None.
|
|
652
|
+
|
|
653
|
+
*text* is decoded via :func:`_decode_transcript_for_diagnostic` before
|
|
654
|
+
`_REVIEW_BLOCK_RE` runs (#1348 round 3): a `claude -p --output-format
|
|
655
|
+
stream-json` log stores every real newline inside the assistant's review
|
|
656
|
+
text as the literal two-character escape ``\\n`` on disk, which
|
|
657
|
+
`_REVIEW_BLOCK_RE`'s ``[\\r\\n]+`` can never match unless it's decoded
|
|
658
|
+
first — and `parse_review_from_log`'s plain-text fallback branch (taken
|
|
659
|
+
whenever `is_stream_json`'s first-non-comment-line heuristic misses, even
|
|
660
|
+
on a log that IS valid NDJSON) was handing this function raw, undecoded
|
|
661
|
+
text, silently dropping well-formed verdicts. Decoding also drops the
|
|
662
|
+
non-JSON `# argv=...` header line, whose embedded system-prompt template
|
|
663
|
+
would otherwise be a spurious match — hence `matches[-1]` (last match),
|
|
664
|
+
not `.search()`, same defense as `detect_unparsed_review_marker`. When
|
|
665
|
+
*text* isn't NDJSON at all (plain-text log, or an already-decoded single
|
|
666
|
+
assistant-message chunk from the stream-json per-event path below), the
|
|
667
|
+
decode is a no-op and *text* is matched as-is — this does NOT loosen the
|
|
668
|
+
grammar: `END_REVIEW` is still the mandatory terminator, and a malformed
|
|
669
|
+
block (e.g. bolded markers) still fails here exactly as before.
|
|
670
|
+
"""
|
|
671
|
+
decoded = _decode_transcript_for_diagnostic(text)
|
|
672
|
+
search_text = text if decoded is None else decoded
|
|
673
|
+
matches = list(_REVIEW_BLOCK_RE.finditer(search_text))
|
|
674
|
+
if not matches:
|
|
675
|
+
return None
|
|
676
|
+
m = matches[-1]
|
|
677
|
+
verdict_raw = m.group(1).lower().strip()
|
|
678
|
+
# Normalize aliases: PASS → approve, FAIL → request-changes.
|
|
679
|
+
verdict = _VERDICT_ALIASES.get(verdict_raw, verdict_raw)
|
|
680
|
+
body = m.group(2).strip()
|
|
681
|
+
if verdict not in ("approve", "request-changes"):
|
|
682
|
+
return None
|
|
683
|
+
return ReviewFindings(verdict=verdict, body=body)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _parse_review_from_lines(
|
|
687
|
+
lines: Iterable[str],
|
|
688
|
+
*,
|
|
689
|
+
stream_json: bool,
|
|
690
|
+
) -> ReviewFindings | None:
|
|
691
|
+
"""Shared core: extract review findings from log lines.
|
|
692
|
+
|
|
693
|
+
`lines` may be any iterable of strings (file iterator, ``str.splitlines()``,
|
|
694
|
+
``httpx.Response.text.splitlines()``). Used by both `parse_review_from_log`
|
|
695
|
+
(local file) and `parse_review_from_agent` (HTTP fetch).
|
|
696
|
+
|
|
697
|
+
#1710 inventory: kept direct — see the identical note on
|
|
698
|
+
``_decode_transcript_for_diagnostic`` above. `stream_json` here is the
|
|
699
|
+
generic "is this NDJSON at all" detection (`is_stream_json`'s
|
|
700
|
+
first-non-comment-line heuristic), not a claude-specific check.
|
|
701
|
+
"""
|
|
702
|
+
from coord.worker_events import _assistant_text, parse_event # noqa: PLC0415
|
|
703
|
+
|
|
704
|
+
if not stream_json:
|
|
705
|
+
text = "\n".join(lines)
|
|
706
|
+
return _parse_review_text(text)
|
|
707
|
+
|
|
708
|
+
all_texts: list[str] = []
|
|
709
|
+
for line in lines:
|
|
710
|
+
event = parse_event(line.rstrip("\n"))
|
|
711
|
+
if event is None:
|
|
712
|
+
continue
|
|
713
|
+
if event.type == "assistant":
|
|
714
|
+
text = _assistant_text(event)
|
|
715
|
+
if text:
|
|
716
|
+
all_texts.append(text)
|
|
717
|
+
# Search from the end — the reviewer emits the verdict last.
|
|
718
|
+
for text in reversed(all_texts):
|
|
719
|
+
findings = _parse_review_text(text)
|
|
720
|
+
if findings is not None:
|
|
721
|
+
return findings
|
|
722
|
+
# Fallback: search the full concatenated text (handles multi-turn output).
|
|
723
|
+
return _parse_review_text("\n".join(all_texts))
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
def parse_review_from_log(log_path: str | Path) -> ReviewFindings | None:
|
|
727
|
+
"""Parse review findings from a completed reviewer worker log.
|
|
728
|
+
|
|
729
|
+
Handles both stream-json (``--output-format stream-json``) and plain-text
|
|
730
|
+
log formats. Returns ``None`` if the file does not exist or contains no
|
|
731
|
+
structured review output.
|
|
732
|
+
|
|
733
|
+
#1710 inventory: ``is_stream_json`` here is a generic on-disk-shape sniff
|
|
734
|
+
(does the first non-comment line start with ``{``?), not claude-specific
|
|
735
|
+
parsing — kept direct rather than routed through a ``Provider``. See the
|
|
736
|
+
note on ``_decode_transcript_for_diagnostic``.
|
|
737
|
+
"""
|
|
738
|
+
from coord.worker_events import is_stream_json # noqa: PLC0415
|
|
739
|
+
|
|
740
|
+
p = Path(log_path)
|
|
741
|
+
if not p.exists():
|
|
742
|
+
return None
|
|
743
|
+
|
|
744
|
+
if is_stream_json(p):
|
|
745
|
+
try:
|
|
746
|
+
with open(p, encoding="utf-8", errors="replace") as f:
|
|
747
|
+
return _parse_review_from_lines(f, stream_json=True)
|
|
748
|
+
except OSError:
|
|
749
|
+
return None
|
|
750
|
+
else:
|
|
751
|
+
try:
|
|
752
|
+
text = p.read_text(encoding="utf-8", errors="replace")
|
|
753
|
+
except OSError:
|
|
754
|
+
return None
|
|
755
|
+
return _parse_review_from_lines(text.splitlines(), stream_json=False)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def parse_review_from_agent(
|
|
759
|
+
host: str,
|
|
760
|
+
assignment_id: str,
|
|
761
|
+
port: int = 7433,
|
|
762
|
+
timeout: float = 15.0,
|
|
763
|
+
) -> ReviewFindings | None:
|
|
764
|
+
"""Fetch a reviewer worker's log via the agent's ``/logs/<id>`` endpoint
|
|
765
|
+
and parse the verdict.
|
|
766
|
+
|
|
767
|
+
Use this instead of `parse_review_from_log` when the worker ran on a
|
|
768
|
+
remote agent and the log file isn't on the coordinator's local
|
|
769
|
+
filesystem. Returns ``None`` on network failure, empty log, or no
|
|
770
|
+
structured review output.
|
|
771
|
+
"""
|
|
772
|
+
import httpx # noqa: PLC0415
|
|
773
|
+
|
|
774
|
+
url = f"http://{host}:{port}/logs/{assignment_id}"
|
|
775
|
+
try:
|
|
776
|
+
resp = httpx.get(url, timeout=timeout)
|
|
777
|
+
resp.raise_for_status()
|
|
778
|
+
text = resp.text
|
|
779
|
+
except (httpx.HTTPError, httpx.TimeoutException):
|
|
780
|
+
return None
|
|
781
|
+
if not text:
|
|
782
|
+
return None
|
|
783
|
+
lines = text.splitlines()
|
|
784
|
+
# Detect format the same way `is_stream_json` does for files: the first
|
|
785
|
+
# non-comment, non-blank line starts with `{`.
|
|
786
|
+
stream_json = False
|
|
787
|
+
for line in lines:
|
|
788
|
+
stripped = line.strip()
|
|
789
|
+
if not stripped or line.startswith("#"):
|
|
790
|
+
continue
|
|
791
|
+
stream_json = stripped.startswith("{")
|
|
792
|
+
break
|
|
793
|
+
return _parse_review_from_lines(lines, stream_json=stream_json)
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def fetch_review_findings_from_github(
|
|
797
|
+
repo_github: str,
|
|
798
|
+
issue_number: int,
|
|
799
|
+
assignment_id: str,
|
|
800
|
+
) -> ReviewFindings | None:
|
|
801
|
+
"""Recover a review's findings from the GitHub message bus.
|
|
802
|
+
|
|
803
|
+
Interactive (claude-pty) reviews don't produce a parseable log; their full
|
|
804
|
+
body is instead posted to the issue under a `coord:review-findings` marker
|
|
805
|
+
by `report-result --body-file` (via the issue_store seam). This reads those
|
|
806
|
+
comments back, so a fix worker on ANY machine can recover the findings even
|
|
807
|
+
when the review ran elsewhere and isn't in the local DB — GitHub is the one
|
|
808
|
+
store every machine already reaches. Returns ``None`` on any failure.
|
|
809
|
+
|
|
810
|
+
Routed through :func:`coord.github_ops.get_issue_comments` (#1483) rather
|
|
811
|
+
than shelling out to ``gh`` directly — ``github_ops`` is the single ``gh``
|
|
812
|
+
sink so a GitLab/bare-DB backend has one seam to sit beside.
|
|
813
|
+
"""
|
|
814
|
+
import subprocess as _sp # noqa: PLC0415
|
|
815
|
+
|
|
816
|
+
from coord import github_ops # noqa: PLC0415
|
|
817
|
+
from coord.comments import extract_findings_block # noqa: PLC0415
|
|
818
|
+
|
|
819
|
+
if not (repo_github and assignment_id):
|
|
820
|
+
return None
|
|
821
|
+
try:
|
|
822
|
+
comments = github_ops.get_issue_comments(repo_github, issue_number)
|
|
823
|
+
except (RuntimeError, _sp.TimeoutExpired, OSError, ValueError):
|
|
824
|
+
return None
|
|
825
|
+
# Newest-first so a re-review's findings win over an earlier iteration's.
|
|
826
|
+
for c in reversed(comments):
|
|
827
|
+
hit = extract_findings_block(c.get("body", ""), assignment_id)
|
|
828
|
+
if hit is not None:
|
|
829
|
+
verdict, body = hit
|
|
830
|
+
return ReviewFindings(verdict=verdict or "request-changes", body=body)
|
|
831
|
+
return None
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
# ── Test-gate verdict output parsing (#1351) ────────────────────────────────
|
|
835
|
+
#
|
|
836
|
+
# A human-attended Test (smoke) session had, before this, exactly ONE channel
|
|
837
|
+
# for its verdict to reach the board: the agent successfully running `coord
|
|
838
|
+
# test --passed|--fail` INSIDE the session. If `coord` wasn't on the
|
|
839
|
+
# session's PATH, the command errored, or the agent simply never got to it,
|
|
840
|
+
# the verdict was gone — no structured block, no transcript floor, no
|
|
841
|
+
# fallback. This is the same gap #651 closed for reviews (the
|
|
842
|
+
# ``REVIEW_VERDICT:``/``REVIEW_BODY:``/``END_REVIEW`` block, recovered by the
|
|
843
|
+
# #606 transcript-floor even when ``coord report-result`` never ran), applied
|
|
844
|
+
# to the Test gate: ``TEST_VERDICT: passed|failed`` / ``TEST_REASON:`` /
|
|
845
|
+
# ``END_TEST``, parsed exactly as tolerantly as ``_REVIEW_BLOCK_RE`` parses a
|
|
846
|
+
# review — Markdown decoration around the markers (bold, code-spans,
|
|
847
|
+
# headings) is absorbed by the same ``_MD`` pattern, for the same #1346
|
|
848
|
+
# reason: a reviewer/tester that bolds ``**TEST_VERDICT:**`` must not have an
|
|
849
|
+
# otherwise-complete, correctly-terminated block silently discarded.
|
|
850
|
+
|
|
851
|
+
@dataclass
|
|
852
|
+
class TestVerdictFindings:
|
|
853
|
+
"""Structured Test-gate verdict extracted from a smoke-session log."""
|
|
854
|
+
__test__ = False # not a pytest test class — the name just starts with "Test"
|
|
855
|
+
verdict: str # "passed" or "failed"
|
|
856
|
+
reason: str
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
_TEST_BLOCK_RE = re.compile(
|
|
860
|
+
rf"TEST_VERDICT:{_MD}(passed|failed|pass|fail){_MD}[\r\n]+"
|
|
861
|
+
rf"(?:{_MD}TEST_REASON:{_MD}[\r\n]+)?(.*?)[\r\n]*{_MD}END_TEST",
|
|
862
|
+
re.DOTALL | re.IGNORECASE,
|
|
863
|
+
)
|
|
864
|
+
|
|
865
|
+
# Map short-form aliases to the canonical verdicts, mirroring
|
|
866
|
+
# `_VERDICT_ALIASES` for the review block.
|
|
867
|
+
_TEST_VERDICT_ALIASES: dict[str, str] = {
|
|
868
|
+
"pass": "passed",
|
|
869
|
+
"fail": "failed",
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def _parse_test_verdict_text(text: str) -> TestVerdictFindings | None:
|
|
874
|
+
"""Extract the last TestVerdictFindings block from *text*, or None.
|
|
875
|
+
|
|
876
|
+
Mirrors :func:`_parse_review_text` exactly: *text* is decoded via
|
|
877
|
+
:func:`_decode_transcript_for_diagnostic` first (a stream-json log stores
|
|
878
|
+
every real newline inside the assistant's text as the two-character
|
|
879
|
+
escape ``\\n``, which ``[\\r\\n]+`` can never match undecoded), and the
|
|
880
|
+
LAST match wins (``matches[-1]``, not ``.search()``) so a tester that
|
|
881
|
+
second-guesses itself mid-session has its final verdict win, and so the
|
|
882
|
+
non-JSON argv/header comment line's embedded briefing template (which
|
|
883
|
+
contains this exact block as an example — see ``_smoke_report_reminder``
|
|
884
|
+
in ``coord/commands/dispatch_workers.py``) never wins over a real,
|
|
885
|
+
later verdict.
|
|
886
|
+
"""
|
|
887
|
+
decoded = _decode_transcript_for_diagnostic(text)
|
|
888
|
+
search_text = text if decoded is None else decoded
|
|
889
|
+
matches = list(_TEST_BLOCK_RE.finditer(search_text))
|
|
890
|
+
if not matches:
|
|
891
|
+
return None
|
|
892
|
+
m = matches[-1]
|
|
893
|
+
verdict_raw = m.group(1).lower().strip()
|
|
894
|
+
verdict = _TEST_VERDICT_ALIASES.get(verdict_raw, verdict_raw)
|
|
895
|
+
reason = m.group(2).strip()
|
|
896
|
+
if verdict not in ("passed", "failed"):
|
|
897
|
+
return None
|
|
898
|
+
return TestVerdictFindings(verdict=verdict, reason=reason)
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _parse_test_verdict_from_lines(
|
|
902
|
+
lines: Iterable[str],
|
|
903
|
+
*,
|
|
904
|
+
stream_json: bool,
|
|
905
|
+
) -> TestVerdictFindings | None:
|
|
906
|
+
"""Shared core: extract a Test-gate verdict from log lines.
|
|
907
|
+
|
|
908
|
+
Mirrors :func:`_parse_review_from_lines` — used by both
|
|
909
|
+
:func:`parse_test_verdict_from_log` (local file) and the remote
|
|
910
|
+
transcript-floor's own file-fetch-then-parse path.
|
|
911
|
+
"""
|
|
912
|
+
from coord.worker_events import _assistant_text, parse_event # noqa: PLC0415
|
|
913
|
+
|
|
914
|
+
if not stream_json:
|
|
915
|
+
text = "\n".join(lines)
|
|
916
|
+
return _parse_test_verdict_text(text)
|
|
917
|
+
|
|
918
|
+
all_texts: list[str] = []
|
|
919
|
+
for line in lines:
|
|
920
|
+
event = parse_event(line.rstrip("\n"))
|
|
921
|
+
if event is None:
|
|
922
|
+
continue
|
|
923
|
+
if event.type == "assistant":
|
|
924
|
+
text = _assistant_text(event)
|
|
925
|
+
if text:
|
|
926
|
+
all_texts.append(text)
|
|
927
|
+
# Search from the end — the tester emits the verdict last.
|
|
928
|
+
for text in reversed(all_texts):
|
|
929
|
+
findings = _parse_test_verdict_text(text)
|
|
930
|
+
if findings is not None:
|
|
931
|
+
return findings
|
|
932
|
+
# Fallback: search the full concatenated text (handles multi-turn output).
|
|
933
|
+
return _parse_test_verdict_text("\n".join(all_texts))
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def parse_test_verdict_from_log(log_path: str | Path) -> TestVerdictFindings | None:
|
|
937
|
+
"""Parse a Test-gate verdict from a completed smoke-session log (#1351).
|
|
938
|
+
|
|
939
|
+
Sibling to :func:`parse_review_from_log`: same stream-json/plain-text
|
|
940
|
+
on-disk-shape detection, same tolerant grammar. Returns ``None`` if the
|
|
941
|
+
file does not exist or contains no structured ``TEST_VERDICT:`` block.
|
|
942
|
+
"""
|
|
943
|
+
from coord.worker_events import is_stream_json # noqa: PLC0415
|
|
944
|
+
|
|
945
|
+
p = Path(log_path)
|
|
946
|
+
if not p.exists():
|
|
947
|
+
return None
|
|
948
|
+
|
|
949
|
+
if is_stream_json(p):
|
|
950
|
+
try:
|
|
951
|
+
with open(p, encoding="utf-8", errors="replace") as f:
|
|
952
|
+
return _parse_test_verdict_from_lines(f, stream_json=True)
|
|
953
|
+
except OSError:
|
|
954
|
+
return None
|
|
955
|
+
else:
|
|
956
|
+
try:
|
|
957
|
+
text = p.read_text(encoding="utf-8", errors="replace")
|
|
958
|
+
except OSError:
|
|
959
|
+
return None
|
|
960
|
+
return _parse_test_verdict_from_lines(text.splitlines(), stream_json=False)
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
REVIEWER_SYSTEM_PROMPT = """\
|
|
964
|
+
You are an independent code reviewer dispatched by the coordinator. \
|
|
965
|
+
Your job is to find problems — do NOT rubber-stamp.
|
|
966
|
+
|
|
967
|
+
Rules:
|
|
968
|
+
- You have a fresh session. You have NO context from the worker who wrote \
|
|
969
|
+
this code. Treat the diff as if you're reading it for the first time.
|
|
970
|
+
- You are NOT allowed to run any `gh` commands. The coordinator posts the \
|
|
971
|
+
review on your behalf after your session ends.
|
|
972
|
+
- DO NOT run the project's test suite, build, or any other command — a human \
|
|
973
|
+
reviewer reads the diff, they don't run the suite, and on some projects (e.g. \
|
|
974
|
+
headless GUI apps) running it hangs the session. You MAY read project files \
|
|
975
|
+
for context. Build/test validation is the separate pre-merge smoke gate's job.
|
|
976
|
+
- You are NOT allowed to push commits or modify the PR's code. You only \
|
|
977
|
+
review.
|
|
978
|
+
|
|
979
|
+
How to review:
|
|
980
|
+
1. Read the project's CLAUDE.md for project conventions.
|
|
981
|
+
2. Read the PR diff using `git diff` or the briefing instructions.
|
|
982
|
+
3. Check the diff against the review checklist in your briefing.
|
|
983
|
+
4. For each finding, cite the specific file:line and the rule it violates.
|
|
984
|
+
5. Before you end your session, record your verdict TWICE — belt and \
|
|
985
|
+
braces, neither step substitutes for the other:
|
|
986
|
+
|
|
987
|
+
a. PRIMARY (do this FIRST, if you can): if the environment variable \
|
|
988
|
+
`COORD_ASSIGNMENT_ID` is set, write your full findings to a file and run:
|
|
989
|
+
`coord report-result --assignment "$COORD_ASSIGNMENT_ID" --status done \
|
|
990
|
+
--verdict approve|request-changes --body-file <file>`
|
|
991
|
+
This writes your verdict straight to the coordinator's board — the \
|
|
992
|
+
authoritative record. Check the command's output for a confirmation; if it \
|
|
993
|
+
errors, or `COORD_ASSIGNMENT_ID` is unset, or `coord` is not on your PATH, \
|
|
994
|
+
say so plainly and fall through to step b anyway — it is REQUIRED \
|
|
995
|
+
regardless.
|
|
996
|
+
b. BACKUP (always do this too, even after a successful step a): at the \
|
|
997
|
+
END of your session, output your verdict in this exact format:
|
|
998
|
+
|
|
999
|
+
REVIEW_VERDICT: approve
|
|
1000
|
+
REVIEW_BODY:
|
|
1001
|
+
<your full review text in markdown>
|
|
1002
|
+
END_REVIEW
|
|
1003
|
+
|
|
1004
|
+
Or for requesting changes:
|
|
1005
|
+
|
|
1006
|
+
REVIEW_VERDICT: request-changes
|
|
1007
|
+
REVIEW_BODY:
|
|
1008
|
+
<your full review text in markdown>
|
|
1009
|
+
END_REVIEW
|
|
1010
|
+
|
|
1011
|
+
This printed block is the PATH-independent fallback recovered from your \
|
|
1012
|
+
session transcript even when step a never ran or failed — it is REQUIRED \
|
|
1013
|
+
every time, not just when `coord report-result` is unavailable.
|
|
1014
|
+
|
|
1015
|
+
Structure the markdown body with these three headings, in this order, ALWAYS \
|
|
1016
|
+
all three even when a section is empty — write `None` under a heading with \
|
|
1017
|
+
nothing in it, and write every finding as a `- ` bullet, never as a bare \
|
|
1018
|
+
paragraph:
|
|
1019
|
+
|
|
1020
|
+
## Blocking findings
|
|
1021
|
+
## Non-blocking concerns
|
|
1022
|
+
## Nits
|
|
1023
|
+
|
|
1024
|
+
The coordinator reads these sections to decide whether a `request-changes` is \
|
|
1025
|
+
a real must-fix or advisory-only. It is deliberately conservative: a body it \
|
|
1026
|
+
cannot read is treated as blocking, so an omitted or prose-only \
|
|
1027
|
+
`## Blocking findings` section costs a full extra fix+review round even when \
|
|
1028
|
+
you raised nothing blocking. Put blocking findings ONLY under \
|
|
1029
|
+
`## Blocking findings` — anything you would still merge over belongs in one of \
|
|
1030
|
+
the other two sections.
|
|
1031
|
+
|
|
1032
|
+
`END_REVIEW` is a HARD REQUIREMENT, not a formatting flourish: the coordinator \
|
|
1033
|
+
only records a verdict when it sees that exact line, so a review that is \
|
|
1034
|
+
otherwise complete and correct but stops one line early is discarded in its \
|
|
1035
|
+
entirety — the same as if you had never reviewed at all. The LAST LINE of your \
|
|
1036
|
+
LAST MESSAGE must be exactly `END_REVIEW` on its own line, with nothing after \
|
|
1037
|
+
it. Do not stop as soon as your review prose feels finished; write the \
|
|
1038
|
+
`END_REVIEW` line and then stop. Before you end your session, re-read your \
|
|
1039
|
+
final message and confirm its last line is `END_REVIEW`.
|
|
1040
|
+
|
|
1041
|
+
If the diff is clean, approve — but be thorough first.\
|
|
1042
|
+
"""
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
# ── Machine selection ───────────────────────────────────────────────────────
|
|
1046
|
+
|
|
1047
|
+
@dataclass
|
|
1048
|
+
class ReviewerChoice:
|
|
1049
|
+
machine: Machine
|
|
1050
|
+
same_as_worker: bool
|
|
1051
|
+
rationale: str
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def pick_reviewer_machine(
|
|
1055
|
+
worker_machine_name: str,
|
|
1056
|
+
repo_name: str,
|
|
1057
|
+
board: Board,
|
|
1058
|
+
config: Config,
|
|
1059
|
+
) -> ReviewerChoice | None:
|
|
1060
|
+
"""Pick a reviewer machine — different from the worker if possible.
|
|
1061
|
+
|
|
1062
|
+
Independence comes from a fresh session with no shared context, not from
|
|
1063
|
+
physical machine separation, so a same-machine fallback still produces a
|
|
1064
|
+
useful review — but we warn the caller via `same_as_worker=True`.
|
|
1065
|
+
|
|
1066
|
+
Returns None when no machine can handle this repo.
|
|
1067
|
+
"""
|
|
1068
|
+
from coord.machine_pause import paused_set
|
|
1069
|
+
paused = paused_set(config.machines)
|
|
1070
|
+
candidates = [
|
|
1071
|
+
m for m in config.machines
|
|
1072
|
+
if m.can_work_on(repo_name) and m.name not in paused
|
|
1073
|
+
]
|
|
1074
|
+
if not candidates:
|
|
1075
|
+
return None
|
|
1076
|
+
|
|
1077
|
+
busy = {a.machine_name for a in board.active if a.status in ("pending", "running")}
|
|
1078
|
+
|
|
1079
|
+
different = [
|
|
1080
|
+
m for m in candidates
|
|
1081
|
+
if m.name != worker_machine_name and m.name not in busy
|
|
1082
|
+
]
|
|
1083
|
+
if different:
|
|
1084
|
+
return ReviewerChoice(
|
|
1085
|
+
machine=different[0],
|
|
1086
|
+
same_as_worker=False,
|
|
1087
|
+
rationale=(
|
|
1088
|
+
f"chose {different[0].name} — different machine from worker "
|
|
1089
|
+
f"({worker_machine_name})"
|
|
1090
|
+
),
|
|
1091
|
+
)
|
|
1092
|
+
|
|
1093
|
+
# Fallback 1: any different machine, even if busy.
|
|
1094
|
+
different_busy = [m for m in candidates if m.name != worker_machine_name]
|
|
1095
|
+
if different_busy:
|
|
1096
|
+
return ReviewerChoice(
|
|
1097
|
+
machine=different_busy[0],
|
|
1098
|
+
same_as_worker=False,
|
|
1099
|
+
rationale=(
|
|
1100
|
+
f"chose {different_busy[0].name} — different machine from "
|
|
1101
|
+
f"worker, currently busy (review will queue)"
|
|
1102
|
+
),
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
# Fallback 2: same machine (only one available). Reduced independence.
|
|
1106
|
+
same = next((m for m in candidates if m.name == worker_machine_name), None)
|
|
1107
|
+
if same is None:
|
|
1108
|
+
return None
|
|
1109
|
+
return ReviewerChoice(
|
|
1110
|
+
machine=same,
|
|
1111
|
+
same_as_worker=True,
|
|
1112
|
+
rationale=(
|
|
1113
|
+
f"only {worker_machine_name} can handle {repo_name}; using same "
|
|
1114
|
+
f"machine — reviewer session is fresh but not on separate hardware"
|
|
1115
|
+
),
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
def _ranked_reviewer_candidates(
|
|
1120
|
+
worker_machine_name: str,
|
|
1121
|
+
repo_name: str,
|
|
1122
|
+
board: Board,
|
|
1123
|
+
config: Config,
|
|
1124
|
+
) -> list[tuple[Machine, bool]]:
|
|
1125
|
+
"""Return **all** candidate reviewer machines in priority order.
|
|
1126
|
+
|
|
1127
|
+
Each element is ``(machine, same_as_worker)``. Priority mirrors
|
|
1128
|
+
``pick_reviewer_machine``:
|
|
1129
|
+
|
|
1130
|
+
1. Different from the worker, currently **idle** — best independence, no
|
|
1131
|
+
queue delay.
|
|
1132
|
+
2. Different from the worker, currently **busy** — independence preserved;
|
|
1133
|
+
the review will queue on that agent.
|
|
1134
|
+
3. **Same** machine as the worker — last resort; fresh session but no
|
|
1135
|
+
hardware separation.
|
|
1136
|
+
|
|
1137
|
+
Returns an empty list when no configured machine handles *repo_name*.
|
|
1138
|
+
Used by ``dispatch_review`` to iterate candidates instead of committing to
|
|
1139
|
+
a single pick, so a rejected agent (e.g. a 400 from config drift) can
|
|
1140
|
+
fall through to the next rather than silently failing (#904).
|
|
1141
|
+
"""
|
|
1142
|
+
from coord.machine_pause import paused_set # noqa: PLC0415
|
|
1143
|
+
|
|
1144
|
+
paused = paused_set(config.machines)
|
|
1145
|
+
candidates = [
|
|
1146
|
+
m for m in config.machines
|
|
1147
|
+
if m.can_work_on(repo_name) and m.name not in paused
|
|
1148
|
+
]
|
|
1149
|
+
if not candidates:
|
|
1150
|
+
return []
|
|
1151
|
+
|
|
1152
|
+
busy = {a.machine_name for a in board.active if a.status in ("pending", "running")}
|
|
1153
|
+
|
|
1154
|
+
result: list[tuple[Machine, bool]] = []
|
|
1155
|
+
for m in candidates:
|
|
1156
|
+
if m.name != worker_machine_name and m.name not in busy:
|
|
1157
|
+
result.append((m, False)) # different + idle
|
|
1158
|
+
for m in candidates:
|
|
1159
|
+
if m.name != worker_machine_name and m.name in busy:
|
|
1160
|
+
result.append((m, False)) # different + busy (will queue)
|
|
1161
|
+
for m in candidates:
|
|
1162
|
+
if m.name == worker_machine_name:
|
|
1163
|
+
result.append((m, True)) # same machine — last resort
|
|
1164
|
+
return result
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
# ── Briefing construction ───────────────────────────────────────────────────
|
|
1168
|
+
|
|
1169
|
+
def _read_repo_claude_md(repo_path: Path) -> str | None:
|
|
1170
|
+
"""Return the contents of CLAUDE.md at the repo root, or None.
|
|
1171
|
+
|
|
1172
|
+
The coordinator runs on the machine that dispatches; the reviewer runs on
|
|
1173
|
+
its own machine and will re-read CLAUDE.md there. We embed the content
|
|
1174
|
+
here so the briefing is self-contained — if the reviewer's checkout is
|
|
1175
|
+
behind, the worker's diff still gets reviewed against the rules the
|
|
1176
|
+
coordinator thought were current.
|
|
1177
|
+
"""
|
|
1178
|
+
candidate = repo_path / "CLAUDE.md"
|
|
1179
|
+
if not candidate.exists():
|
|
1180
|
+
return None
|
|
1181
|
+
try:
|
|
1182
|
+
return candidate.read_text()
|
|
1183
|
+
except OSError:
|
|
1184
|
+
return None
|
|
1185
|
+
|
|
1186
|
+
|
|
1187
|
+
def _diff_file_paths(diff_text: str) -> list[str]:
|
|
1188
|
+
"""Return every file path touched by *diff_text*, deduped, order-preserving.
|
|
1189
|
+
|
|
1190
|
+
Scans unified-diff file-header lines (``diff --git a/X b/Y``, ``---
|
|
1191
|
+
a/X``, ``+++ b/X``) — cheap, dependency-free (#944 sealing v1) ahead of a
|
|
1192
|
+
real diff parser. Shared by :func:`_diff_touched_sealed_paths` (which
|
|
1193
|
+
sealed prefixes) and :func:`_diff_paths_outside_sealed` (which actual
|
|
1194
|
+
paths, #1175 — the test-author/mock-author inverse tamper check needs
|
|
1195
|
+
the offending files, not just which prefixes matched).
|
|
1196
|
+
"""
|
|
1197
|
+
paths: list[str] = []
|
|
1198
|
+
seen: set[str] = set()
|
|
1199
|
+
for line in diff_text.splitlines():
|
|
1200
|
+
candidates: list[str] = []
|
|
1201
|
+
if line.startswith("diff --git "):
|
|
1202
|
+
for part in line.split()[1:]:
|
|
1203
|
+
if part.startswith("a/") or part.startswith("b/"):
|
|
1204
|
+
candidates.append(part[2:])
|
|
1205
|
+
elif line.startswith("--- a/"):
|
|
1206
|
+
candidates.append(line[len("--- a/"):])
|
|
1207
|
+
elif line.startswith("+++ b/"):
|
|
1208
|
+
candidates.append(line[len("+++ b/"):])
|
|
1209
|
+
for c in candidates:
|
|
1210
|
+
if c not in seen:
|
|
1211
|
+
seen.add(c)
|
|
1212
|
+
paths.append(c)
|
|
1213
|
+
return paths
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def _path_is_sealed(path: str, sealed: str) -> bool:
|
|
1217
|
+
"""Does *path* fall under the sealed entry *sealed*?
|
|
1218
|
+
|
|
1219
|
+
#1552: the sealed set is no longer uniformly directory prefixes. An
|
|
1220
|
+
entry ending in ``/`` (``tests/acceptance/``) is a prefix — everything
|
|
1221
|
+
beneath it is sealed. Anything else is a driver ``entrypoint:`` naming
|
|
1222
|
+
exactly one FILE (``tui/tests/acceptance.rs``), and must match exactly:
|
|
1223
|
+
a bare ``startswith`` would also swallow ``tui/tests/acceptance.rs.bak``
|
|
1224
|
+
and ``tui/tests/acceptance.rs.orig``, quietly widening the one narrow
|
|
1225
|
+
allowance a `test-author` gets.
|
|
1226
|
+
"""
|
|
1227
|
+
if sealed.endswith("/"):
|
|
1228
|
+
return path.startswith(sealed)
|
|
1229
|
+
return path == sealed
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def _diff_touched_sealed_paths(diff_text: str, sealed_paths: list[str]) -> list[str]:
|
|
1233
|
+
"""Return the sealed path prefixes actually touched by *diff_text*.
|
|
1234
|
+
|
|
1235
|
+
Cheap, dependency-free tamper detection (#944 sealing v1). Pure function,
|
|
1236
|
+
easy to test.
|
|
1237
|
+
"""
|
|
1238
|
+
touched: set[str] = set()
|
|
1239
|
+
for c in _diff_file_paths(diff_text):
|
|
1240
|
+
for sealed in sealed_paths:
|
|
1241
|
+
if _path_is_sealed(c, sealed):
|
|
1242
|
+
touched.add(sealed)
|
|
1243
|
+
return sorted(touched)
|
|
1244
|
+
|
|
1245
|
+
|
|
1246
|
+
def _diff_paths_outside_sealed(diff_text: str, sealed_paths: list[str]) -> list[str]:
|
|
1247
|
+
"""Return diff file paths that fall OUTSIDE every sealed prefix.
|
|
1248
|
+
|
|
1249
|
+
#1175: for a ``type="test-author"``/``"mock-author"`` PR, writing under
|
|
1250
|
+
*sealed_paths* (``tests/acceptance/ms-NN/**`` plus, #1552, each driver's
|
|
1251
|
+
declared ``entrypoint:``) is the assignment's entire job, not a
|
|
1252
|
+
violation — the oracle-tamper rule inverts for these types, so the
|
|
1253
|
+
reviewer needs the paths touched OUTSIDE the sealed prefix instead of
|
|
1254
|
+
the ones inside it.
|
|
1255
|
+
"""
|
|
1256
|
+
return sorted(
|
|
1257
|
+
p for p in _diff_file_paths(diff_text)
|
|
1258
|
+
if not any(_path_is_sealed(p, sealed) for sealed in sealed_paths)
|
|
1259
|
+
)
|
|
1260
|
+
|
|
1261
|
+
|
|
1262
|
+
def build_review_briefing(
|
|
1263
|
+
*,
|
|
1264
|
+
pr_number: int | None,
|
|
1265
|
+
pr_url: str | None,
|
|
1266
|
+
repo_github: str,
|
|
1267
|
+
repo_name: str,
|
|
1268
|
+
issue_number: int,
|
|
1269
|
+
issue_title: str,
|
|
1270
|
+
issue_body: str,
|
|
1271
|
+
branch: str | None,
|
|
1272
|
+
worker_machine: str,
|
|
1273
|
+
same_as_worker: bool,
|
|
1274
|
+
reviews_cfg: ReviewsConfig,
|
|
1275
|
+
repo_claude_md: str | None,
|
|
1276
|
+
default_branch: str = "main",
|
|
1277
|
+
review_iteration: int = 0,
|
|
1278
|
+
diff_text: str | None = None,
|
|
1279
|
+
sealed_paths: list[str] | None = None,
|
|
1280
|
+
sealed_entrypoints: list[str] | None = None,
|
|
1281
|
+
assignment_type: str = "work",
|
|
1282
|
+
provider_same_as_worker: bool = False,
|
|
1283
|
+
review_provider: str | None = None,
|
|
1284
|
+
) -> str:
|
|
1285
|
+
"""Assemble the reviewer's prompt. Pure function — easy to test.
|
|
1286
|
+
|
|
1287
|
+
*provider_same_as_worker* (#1811) is True when the review's resolved
|
|
1288
|
+
provider (``review_provider``) is the same name as the worker's own
|
|
1289
|
+
resolved provider — e.g. a repo pinned to ``opencode`` with no
|
|
1290
|
+
``reviews.provider`` override, so the review inherits it too. Provider
|
|
1291
|
+
co-location is a *larger* loss of independence than machine co-location
|
|
1292
|
+
(``same_as_worker`` below): a fresh session removes shared context, but
|
|
1293
|
+
not shared blind spots. When True, a note is appended mirroring
|
|
1294
|
+
``same_as_worker``'s — the reviewer is told to be extra rigorous for the
|
|
1295
|
+
same reason a same-machine reviewer is.
|
|
1296
|
+
|
|
1297
|
+
When *review_iteration* > 0 the work is a re-review of a fix worker's
|
|
1298
|
+
commits (a prior round requested changes). The "What to do" section is
|
|
1299
|
+
then scoped to the fix delta instead of the whole PR (#476): re-reviewing
|
|
1300
|
+
the entire PR every round repeats work, wastes tokens, and surfaces fresh
|
|
1301
|
+
non-blocking nits that bounce an already-correct PR into another fix cycle.
|
|
1302
|
+
|
|
1303
|
+
When *diff_text* is non-empty (#612) the coordinator has already computed
|
|
1304
|
+
the merge-base (three-dot) diff and it is embedded verbatim, so the
|
|
1305
|
+
reviewer reviews exactly the branch's own changes — there is nothing for it
|
|
1306
|
+
to get wrong. A reviewer that deviates to a two-dot/stale-base diff would
|
|
1307
|
+
surface code merged to the default branch *after* the branch was cut as
|
|
1308
|
+
spurious deletions and flag it as a regression (#546). When *diff_text* is
|
|
1309
|
+
None the existing three-dot ``git diff`` fallback instructions stand.
|
|
1310
|
+
|
|
1311
|
+
*sealed_paths* (#944, docs/ORACLE_LOOP.md sealing v1) lists the paths the
|
|
1312
|
+
worker must never touch — ``tests/acceptance/`` plus, since #1552, each
|
|
1313
|
+
acceptance driver's declared ``entrypoint:``, derived from the driver
|
|
1314
|
+
definition by :meth:`coord.config.AcceptanceConfig.sealed_paths`. When
|
|
1315
|
+
non-empty a reviewer instruction is always appended; if *diff_text* is
|
|
1316
|
+
also given and actually touches one of the paths, a blocking "TAMPER
|
|
1317
|
+
DETECTED" banner is prepended instead of a soft reminder — this is the
|
|
1318
|
+
"reviewer flags any diff that touches tests/acceptance/**"
|
|
1319
|
+
tamper-detection policy.
|
|
1320
|
+
|
|
1321
|
+
*sealed_entrypoints* (#1552) is the subset of *sealed_paths* that are
|
|
1322
|
+
driver entry points rather than the sealed tree itself. They get a
|
|
1323
|
+
narrower rule in the author branch below: a slice file is invisible to
|
|
1324
|
+
an entry-point-linked runner (``cargo test --test acceptance``) until
|
|
1325
|
+
something registers it in the crate root, so a ``test-author`` ADDING a
|
|
1326
|
+
registration line there is doing its job, while rewriting or deleting
|
|
1327
|
+
what is already in the file is still tamper.
|
|
1328
|
+
|
|
1329
|
+
*assignment_type* (#1175) gates which direction that rule runs. For
|
|
1330
|
+
:data:`coord.models.SEALED_PATH_AUTHOR_TYPES` (``"test-author"``,
|
|
1331
|
+
``"mock-author"``) — whose entire job IS to write under *sealed_paths* —
|
|
1332
|
+
the rule inverts: mandatory ``request-changes`` fires only when the diff
|
|
1333
|
+
touches something OUTSIDE *sealed_paths*; touching only the sealed area
|
|
1334
|
+
is expected and non-blocking. Every other type (default ``"work"``) keeps
|
|
1335
|
+
the original rule unchanged: any touch to *sealed_paths* is mandatory
|
|
1336
|
+
``request-changes``.
|
|
1337
|
+
"""
|
|
1338
|
+
|
|
1339
|
+
lines: list[str] = []
|
|
1340
|
+
lines.append(f"# Review assignment: {repo_github} PR #{pr_number}")
|
|
1341
|
+
lines.append("")
|
|
1342
|
+
lines.append(f"You are reviewing the worker's work on issue #{issue_number}: {issue_title}")
|
|
1343
|
+
lines.append("")
|
|
1344
|
+
lines.append("## Context")
|
|
1345
|
+
lines.append(f"- Repo: {repo_github} (local name: {repo_name})")
|
|
1346
|
+
lines.append(f"- Branch: {branch or '(unknown)'}")
|
|
1347
|
+
if pr_url:
|
|
1348
|
+
lines.append(f"- PR URL: {pr_url}")
|
|
1349
|
+
lines.append(f"- Worker machine: {worker_machine}")
|
|
1350
|
+
if same_as_worker:
|
|
1351
|
+
lines.append(
|
|
1352
|
+
"- NOTE: only one machine is configured for this repo, so you are "
|
|
1353
|
+
"running on the same machine as the worker. Your session is still "
|
|
1354
|
+
"fresh (no shared context), but be extra rigorous."
|
|
1355
|
+
)
|
|
1356
|
+
if provider_same_as_worker:
|
|
1357
|
+
lines.append(
|
|
1358
|
+
f"- NOTE: this review is running on the same provider "
|
|
1359
|
+
f"({review_provider or 'claude'}) as the worker's own dispatch. "
|
|
1360
|
+
"Your session is still fresh (no shared context), but a shared "
|
|
1361
|
+
"model family means shared blind spots — be extra rigorous."
|
|
1362
|
+
)
|
|
1363
|
+
lines.append("")
|
|
1364
|
+
|
|
1365
|
+
lines.append("## Issue")
|
|
1366
|
+
lines.append(f"**#{issue_number}: {issue_title}**")
|
|
1367
|
+
if issue_body.strip():
|
|
1368
|
+
lines.append("")
|
|
1369
|
+
lines.append(issue_body.strip())
|
|
1370
|
+
lines.append("")
|
|
1371
|
+
|
|
1372
|
+
if repo_claude_md:
|
|
1373
|
+
lines.append("## Project rules (from CLAUDE.md)")
|
|
1374
|
+
lines.append("")
|
|
1375
|
+
lines.append(repo_claude_md.strip())
|
|
1376
|
+
lines.append("")
|
|
1377
|
+
|
|
1378
|
+
lines.append("## Review checklist")
|
|
1379
|
+
lines.append("")
|
|
1380
|
+
if reviews_cfg.checklist:
|
|
1381
|
+
for item in reviews_cfg.checklist:
|
|
1382
|
+
lines.append(f"- {item}")
|
|
1383
|
+
else:
|
|
1384
|
+
lines.append("- Does the diff actually solve issue #" + str(issue_number) + "?")
|
|
1385
|
+
lines.append("- Do tests pass? Any regressions?")
|
|
1386
|
+
lines.append("- Are there CLAUDE.md violations?")
|
|
1387
|
+
lines.append("- Did the worker stay within the assigned file scope?")
|
|
1388
|
+
lines.append("- Any security issues (injection, auth bypass, credential exposure)?")
|
|
1389
|
+
|
|
1390
|
+
overrides = reviews_cfg.repo_overrides.get(repo_name, [])
|
|
1391
|
+
if overrides:
|
|
1392
|
+
lines.append("")
|
|
1393
|
+
lines.append(f"### Repo-specific focus ({repo_name})")
|
|
1394
|
+
for item in overrides:
|
|
1395
|
+
lines.append(f"- {item}")
|
|
1396
|
+
|
|
1397
|
+
if reviews_cfg.reviewer_prompt.strip():
|
|
1398
|
+
lines.append("")
|
|
1399
|
+
lines.append("## Additional instructions")
|
|
1400
|
+
lines.append(reviews_cfg.reviewer_prompt.strip())
|
|
1401
|
+
|
|
1402
|
+
if diff_text and diff_text.strip():
|
|
1403
|
+
# #612: embed the merge-base (three-dot) diff verbatim so the reviewer
|
|
1404
|
+
# has nothing to compute — a two-dot/stale-base diff would show
|
|
1405
|
+
# already-merged commits as spurious deletions (#546).
|
|
1406
|
+
lines.append("")
|
|
1407
|
+
lines.append("## Diff to review (authoritative)")
|
|
1408
|
+
lines.append(
|
|
1409
|
+
"This is the merge-base (three-dot) diff — exactly the branch's own "
|
|
1410
|
+
"changes, nothing else. Review THIS. Do NOT compute your own diff; a "
|
|
1411
|
+
"two-dot or stale-base diff would show unrelated already-merged "
|
|
1412
|
+
"commits as spurious deletions."
|
|
1413
|
+
)
|
|
1414
|
+
lines.append("")
|
|
1415
|
+
lines.append("```diff")
|
|
1416
|
+
lines.append(diff_text.strip())
|
|
1417
|
+
lines.append("```")
|
|
1418
|
+
|
|
1419
|
+
if sealed_paths:
|
|
1420
|
+
lines.append("")
|
|
1421
|
+
if assignment_type in SEALED_PATH_AUTHOR_TYPES:
|
|
1422
|
+
# #1175: for test-author/mock-author, writing under sealed_paths
|
|
1423
|
+
# IS the job — the tamper rule inverts. Flag only a touch OUTSIDE
|
|
1424
|
+
# the sealed area; a diff confined to it is expected, not tamper.
|
|
1425
|
+
outside = _diff_paths_outside_sealed(diff_text, sealed_paths) if diff_text else []
|
|
1426
|
+
if outside:
|
|
1427
|
+
lines.append("## \U0001f6a8 SEALED ORACLE SCOPE VIOLATION")
|
|
1428
|
+
lines.append("")
|
|
1429
|
+
lines.append(
|
|
1430
|
+
f"This is a `type={assignment_type!r}` assignment — its entire "
|
|
1431
|
+
"job is authoring under this repo's sealed acceptance oracle "
|
|
1432
|
+
+ ", ".join(f"`{p}`" for p in sealed_paths)
|
|
1433
|
+
+ " (docs/ORACLE_LOOP.md), so touching those paths is expected "
|
|
1434
|
+
"and NOT tamper. But this diff ALSO touches path(s) OUTSIDE the "
|
|
1435
|
+
"sealed area: " + ", ".join(f"`{p}`" for p in outside) + ". "
|
|
1436
|
+
"**request-changes is mandatory here**, regardless of anything "
|
|
1437
|
+
"else in this diff — this assignment type must touch ONLY the "
|
|
1438
|
+
"sealed acceptance tree and nothing else."
|
|
1439
|
+
)
|
|
1440
|
+
else:
|
|
1441
|
+
lines.append(
|
|
1442
|
+
f"## Sealed paths (expected writes for type={assignment_type!r})"
|
|
1443
|
+
)
|
|
1444
|
+
lines.append("")
|
|
1445
|
+
lines.append(
|
|
1446
|
+
f"This is a `type={assignment_type!r}` assignment: writing under "
|
|
1447
|
+
+ ", ".join(f"`{p}`" for p in sealed_paths)
|
|
1448
|
+
+ " (docs/ORACLE_LOOP.md) is its entire job, not a tamper "
|
|
1449
|
+
"violation. Do **not** request-changes solely because this "
|
|
1450
|
+
"diff touches the sealed acceptance tree — only flag it if the "
|
|
1451
|
+
"diff also touches anything outside that tree."
|
|
1452
|
+
)
|
|
1453
|
+
if sealed_entrypoints:
|
|
1454
|
+
# #1552: the entry point is sealed, but the allowance on it is
|
|
1455
|
+
# narrower than on the suite dir — additive registration only.
|
|
1456
|
+
lines.append("")
|
|
1457
|
+
lines.append("### Driver entry point — additive registration only")
|
|
1458
|
+
lines.append("")
|
|
1459
|
+
lines.append(
|
|
1460
|
+
", ".join(f"`{p}`" for p in sealed_entrypoints)
|
|
1461
|
+
+ " is this repo's acceptance driver **entry point** "
|
|
1462
|
+
"(declared as `entrypoint:` on the driver in "
|
|
1463
|
+
"coordinator.yml, #1552) — the crate root the runner "
|
|
1464
|
+
"links slices through, and part of the sealed oracle for "
|
|
1465
|
+
"that reason. A slice file under the sealed tree is "
|
|
1466
|
+
"INVISIBLE to the runner until it is registered there "
|
|
1467
|
+
"(e.g. an `include!(...)` line), so a slice with no "
|
|
1468
|
+
"registration line is dead code that never executes."
|
|
1469
|
+
)
|
|
1470
|
+
lines.append("")
|
|
1471
|
+
lines.append(
|
|
1472
|
+
"- **Expected, do NOT flag:** this diff ADDS registration "
|
|
1473
|
+
"lines for its own new slice files."
|
|
1474
|
+
)
|
|
1475
|
+
lines.append(
|
|
1476
|
+
"- **request-changes:** the entry-point hunk does anything "
|
|
1477
|
+
"more than that — rewriting, reordering, or deleting "
|
|
1478
|
+
"existing lines, registering files that are not part of "
|
|
1479
|
+
"this slice, or any other edit to that file."
|
|
1480
|
+
)
|
|
1481
|
+
lines.append(
|
|
1482
|
+
"- **request-changes:** the diff adds slice files under the "
|
|
1483
|
+
"sealed tree but does NOT register them in the entry "
|
|
1484
|
+
"point. Deleting the registration line to make a diff look "
|
|
1485
|
+
"clean is not a fix — it ships a suite that silently "
|
|
1486
|
+
"contributes zero tests."
|
|
1487
|
+
)
|
|
1488
|
+
else:
|
|
1489
|
+
touched = _diff_touched_sealed_paths(diff_text, sealed_paths) if diff_text else []
|
|
1490
|
+
if touched:
|
|
1491
|
+
lines.append("## \U0001f6a8 SEALED ORACLE TAMPER DETECTED")
|
|
1492
|
+
lines.append("")
|
|
1493
|
+
lines.append(
|
|
1494
|
+
"The diff modifies a path SEALED by this repo's acceptance "
|
|
1495
|
+
"oracle (docs/ORACLE_LOOP.md sealing v1): "
|
|
1496
|
+
+ ", ".join(f"`{p}`" for p in touched)
|
|
1497
|
+
+ ". The suite under these paths is authored independently — "
|
|
1498
|
+
"workers may only RUN it (`coord acceptance run`), never read "
|
|
1499
|
+
"or edit it. **request-changes is mandatory here**, regardless "
|
|
1500
|
+
"of anything else in this diff."
|
|
1501
|
+
)
|
|
1502
|
+
else:
|
|
1503
|
+
lines.append("## Sealed paths (do not touch)")
|
|
1504
|
+
lines.append("")
|
|
1505
|
+
lines.append(
|
|
1506
|
+
"This repo's acceptance oracle is sealed by policy: "
|
|
1507
|
+
+ ", ".join(f"`{p}`" for p in sealed_paths)
|
|
1508
|
+
+ ". If the diff modifies any of them, **request-changes** — "
|
|
1509
|
+
"this is a hard rule, not a suggestion (docs/ORACLE_LOOP.md)."
|
|
1510
|
+
)
|
|
1511
|
+
|
|
1512
|
+
lines.append("")
|
|
1513
|
+
lines.append("## What to do")
|
|
1514
|
+
lines.append("")
|
|
1515
|
+
if review_iteration > 0:
|
|
1516
|
+
# #476: re-review. A prior round requested changes and the worker
|
|
1517
|
+
# pushed fix commits. Scope to the fix delta — do NOT re-review the
|
|
1518
|
+
# whole PR from scratch, and do NOT raise NEW non-blocking nits on
|
|
1519
|
+
# already-accepted code. Only a genuine bug or an unaddressed
|
|
1520
|
+
# previously-requested change should block.
|
|
1521
|
+
lines.append(
|
|
1522
|
+
f"**This is re-review iteration {review_iteration}.** A previous "
|
|
1523
|
+
"review requested changes and the worker has pushed fix commits "
|
|
1524
|
+
"since then. Scope your review to those fixes — do NOT re-review "
|
|
1525
|
+
"the entire PR from scratch."
|
|
1526
|
+
)
|
|
1527
|
+
lines.append("")
|
|
1528
|
+
lines.append(
|
|
1529
|
+
"1. See what changed since the last review: "
|
|
1530
|
+
f"`git fetch origin && git log --oneline origin/{default_branch}..."
|
|
1531
|
+
f"origin/{branch or 'HEAD'}`. The most recent commit(s) are the fix "
|
|
1532
|
+
"for the last review round — concentrate there."
|
|
1533
|
+
)
|
|
1534
|
+
lines.append(
|
|
1535
|
+
"2. Verify the previously-requested changes were correctly made and "
|
|
1536
|
+
"that the fix commits introduce no regressions."
|
|
1537
|
+
)
|
|
1538
|
+
lines.append(
|
|
1539
|
+
"3. **Do NOT raise new non-blocking nits on unchanged, "
|
|
1540
|
+
"already-reviewed code.** Block (`request-changes`) ONLY for a "
|
|
1541
|
+
"genuine bug or a previously-requested change that was not "
|
|
1542
|
+
"addressed. If the fix is correct and you only have minor polish "
|
|
1543
|
+
"suggestions, **approve** and list them as non-blocking notes — "
|
|
1544
|
+
"the coordinator will not dispatch another fix round for "
|
|
1545
|
+
"non-blocking findings."
|
|
1546
|
+
)
|
|
1547
|
+
elif pr_number is not None:
|
|
1548
|
+
if diff_text and diff_text.strip():
|
|
1549
|
+
lines.append(
|
|
1550
|
+
"1. Review the diff in the '## Diff to review' section above "
|
|
1551
|
+
"(already fetched for you — the merge-base diff)."
|
|
1552
|
+
)
|
|
1553
|
+
else:
|
|
1554
|
+
lines.append(
|
|
1555
|
+
f"1. Get the diff: `git fetch origin && git diff origin/{default_branch}..."
|
|
1556
|
+
f"origin/{branch or 'HEAD'}` or ask the coordinator for the diff."
|
|
1557
|
+
)
|
|
1558
|
+
lines.append("2. Run the project's test suite.")
|
|
1559
|
+
lines.append("3. Review the diff against the checklist above.")
|
|
1560
|
+
else:
|
|
1561
|
+
if diff_text and diff_text.strip():
|
|
1562
|
+
lines.append(
|
|
1563
|
+
"1. Review the diff in the '## Diff to review' section above "
|
|
1564
|
+
"(already fetched for you — the merge-base diff)."
|
|
1565
|
+
)
|
|
1566
|
+
else:
|
|
1567
|
+
lines.append(
|
|
1568
|
+
f"1. The worker pushed branch `{branch}` but no PR was opened. "
|
|
1569
|
+
f"Get the diff: `git fetch origin && git diff origin/{default_branch}..."
|
|
1570
|
+
f"origin/{branch or '<branch>'}`. Always diff against `origin/` after "
|
|
1571
|
+
"fetching — a local base ref may be stale and would sweep in unrelated "
|
|
1572
|
+
"already-merged commits."
|
|
1573
|
+
)
|
|
1574
|
+
lines.append("2. Run the project's test suite.")
|
|
1575
|
+
lines.append("3. Review the diff against the checklist above.")
|
|
1576
|
+
lines.append("")
|
|
1577
|
+
lines.append(
|
|
1578
|
+
"4. Before you end your session, record your verdict TWICE — belt "
|
|
1579
|
+
"and braces, neither step substitutes for the other. FIRST, if the "
|
|
1580
|
+
"environment variable `COORD_ASSIGNMENT_ID` is set, write your full "
|
|
1581
|
+
"findings to a file and run `coord report-result --assignment "
|
|
1582
|
+
'"$COORD_ASSIGNMENT_ID" --status done --verdict '
|
|
1583
|
+
"approve|request-changes --body-file <file>` — this writes straight "
|
|
1584
|
+
"to the coordinator's board and is the authoritative record. If "
|
|
1585
|
+
"`COORD_ASSIGNMENT_ID` is unset, `coord` errors, or it's not on "
|
|
1586
|
+
"your PATH, say so plainly and move on to the required backup "
|
|
1587
|
+
"below regardless. THEN, at the END of your session, ALWAYS ALSO "
|
|
1588
|
+
"output your findings in this exact format as the PATH-independent "
|
|
1589
|
+
"backup (the coordinator will post the review to GitHub on your "
|
|
1590
|
+
"behalf — do NOT run any `gh` commands):"
|
|
1591
|
+
)
|
|
1592
|
+
lines.append("")
|
|
1593
|
+
lines.append("```")
|
|
1594
|
+
lines.append("REVIEW_VERDICT: approve")
|
|
1595
|
+
lines.append("REVIEW_BODY:")
|
|
1596
|
+
lines.append("<your full review text in markdown>")
|
|
1597
|
+
lines.append("END_REVIEW")
|
|
1598
|
+
lines.append("```")
|
|
1599
|
+
lines.append("")
|
|
1600
|
+
lines.append("Use `REVIEW_VERDICT: request-changes` if changes are needed.")
|
|
1601
|
+
# #1456: the coordinator's #476 gate (an advisory-only request-changes must
|
|
1602
|
+
# not burn another fix round) counts bullets under the body's section
|
|
1603
|
+
# headings, and since #1456 it fails CLOSED — an unparseable body keeps the
|
|
1604
|
+
# reviewer's verdict verbatim. Say so here as well as in
|
|
1605
|
+
# REVIEWER_SYSTEM_PROMPT: without an explicit blocking section the gate can
|
|
1606
|
+
# never fire, so every advisory review costs a full fix+re-review round.
|
|
1607
|
+
lines.append("")
|
|
1608
|
+
lines.append(
|
|
1609
|
+
"BODY STRUCTURE — the markdown body MUST use these three headings, "
|
|
1610
|
+
"always all three, with every finding as a `- ` bullet under one of "
|
|
1611
|
+
"them: `## Blocking findings`, `## Non-blocking concerns`, `## Nits`. "
|
|
1612
|
+
"Write the single line `None.` under a heading with nothing under it. "
|
|
1613
|
+
"These sections are machine-counted: an explicitly empty blocking "
|
|
1614
|
+
"section is how you tell the coordinator your objections are advisory "
|
|
1615
|
+
"and no fix round is needed, and a body it cannot read is treated as "
|
|
1616
|
+
"blocking. Never state a blocking objection only in prose outside "
|
|
1617
|
+
"these sections."
|
|
1618
|
+
)
|
|
1619
|
+
# #1346: the three marker lines are a machine contract, not prose. The
|
|
1620
|
+
# surrounding briefing is Markdown and the body placeholder invites
|
|
1621
|
+
# Markdown, so reviewers have emitted `**REVIEW_VERDICT: request-changes**`
|
|
1622
|
+
# — which the parser rejected outright, silently dropping a complete
|
|
1623
|
+
# review. State the constraint and show the failing string; a negative
|
|
1624
|
+
# example is what actually stops the drift.
|
|
1625
|
+
lines.append("")
|
|
1626
|
+
lines.append(
|
|
1627
|
+
"FORMAT CONTRACT — the three marker lines "
|
|
1628
|
+
"(`REVIEW_VERDICT:`, `REVIEW_BODY:`, `END_REVIEW`) are parsed by "
|
|
1629
|
+
"machine. Each must start at the beginning of its own line as "
|
|
1630
|
+
"literal plain text, with NO Markdown decoration: no `**bold**`, no "
|
|
1631
|
+
"backticks, no `#` heading marks, no list bullet. "
|
|
1632
|
+
"`**REVIEW_VERDICT: request-changes**` is WRONG. "
|
|
1633
|
+
"`REVIEW_VERDICT: request-changes` is right. The review BODY between "
|
|
1634
|
+
"the markers may be Markdown — the marker lines may not. "
|
|
1635
|
+
"`END_REVIEW` is a HARD REQUIREMENT: an otherwise-complete, correct "
|
|
1636
|
+
"review with no `END_REVIEW` line is discarded in its entirety, not "
|
|
1637
|
+
"recorded with a best guess — so write `END_REVIEW` even if your "
|
|
1638
|
+
"review prose already feels finished. Before you finish, re-read "
|
|
1639
|
+
"your last message and confirm the verdict line begins with "
|
|
1640
|
+
"`REVIEW_VERDICT:` with nothing preceding it, AND that the very "
|
|
1641
|
+
"last line is `END_REVIEW`."
|
|
1642
|
+
)
|
|
1643
|
+
|
|
1644
|
+
return "\n".join(lines)
|
|
1645
|
+
|
|
1646
|
+
|
|
1647
|
+
# ── Dispatch ────────────────────────────────────────────────────────────────
|
|
1648
|
+
|
|
1649
|
+
def _find_or_open_pr(
|
|
1650
|
+
repo_github: str,
|
|
1651
|
+
*,
|
|
1652
|
+
branch: str,
|
|
1653
|
+
default_branch: str,
|
|
1654
|
+
issue_number: int,
|
|
1655
|
+
issue_title: str,
|
|
1656
|
+
assignment_type: str = "work",
|
|
1657
|
+
) -> dict | None:
|
|
1658
|
+
"""Return {number, url, existed} for a PR on `branch`, opening one if needed.
|
|
1659
|
+
|
|
1660
|
+
Returns None when neither lookup nor open works — caller continues without
|
|
1661
|
+
a PR-targeted review (falls back to branch-diff review).
|
|
1662
|
+
|
|
1663
|
+
*assignment_type* decides the PR-body keyword (#1077): for types in
|
|
1664
|
+
:data:`coord.models.CLOSES_ISSUE_TYPES` (``"work"``), ``issue_number`` is
|
|
1665
|
+
the issue this PR resolves, so the body carries the closing keyword
|
|
1666
|
+
``Closes #N`` and GitHub auto-closes it on merge. For any other
|
|
1667
|
+
WORK_LIKE type — notably ``"mock-author"`` (Gate A), whose
|
|
1668
|
+
``issue_number`` is the milestone's *tracking* issue, not something the
|
|
1669
|
+
PR resolves — the body uses the non-closing ``Refs #N`` so the tracking
|
|
1670
|
+
issue still gets a discoverable backlink but does not flip to closed
|
|
1671
|
+
when the contract PR merges.
|
|
1672
|
+
"""
|
|
1673
|
+
try:
|
|
1674
|
+
existing = github_ops.find_pr_for_branch(repo_github, branch)
|
|
1675
|
+
except RuntimeError:
|
|
1676
|
+
existing = None
|
|
1677
|
+
if existing is not None:
|
|
1678
|
+
return {
|
|
1679
|
+
"number": existing["number"],
|
|
1680
|
+
"url": existing.get("url"),
|
|
1681
|
+
"existed": True,
|
|
1682
|
+
}
|
|
1683
|
+
keyword = "Closes" if assignment_type in CLOSES_ISSUE_TYPES else "Refs"
|
|
1684
|
+
try:
|
|
1685
|
+
return github_ops.create_pr(
|
|
1686
|
+
repo_github,
|
|
1687
|
+
base=default_branch,
|
|
1688
|
+
head=branch,
|
|
1689
|
+
title=f"#{issue_number}: {issue_title}",
|
|
1690
|
+
body=(
|
|
1691
|
+
f"{keyword} #{issue_number}\n\n"
|
|
1692
|
+
f"Automated PR opened by coordinator for review of issue #{issue_number}."
|
|
1693
|
+
),
|
|
1694
|
+
)
|
|
1695
|
+
except RuntimeError:
|
|
1696
|
+
return None
|
|
1697
|
+
|
|
1698
|
+
|
|
1699
|
+
def _fetch_agent_advertised_repos(
|
|
1700
|
+
host: str,
|
|
1701
|
+
port: int = AGENT_PORT,
|
|
1702
|
+
*,
|
|
1703
|
+
timeout: float = 2.0,
|
|
1704
|
+
) -> list[str] | None:
|
|
1705
|
+
"""Query an agent's ``/health`` endpoint and return the repos it handles.
|
|
1706
|
+
|
|
1707
|
+
Returns a list of repo names (strings) when the agent is reachable and
|
|
1708
|
+
returns well-formed JSON; returns ``None`` on *any* failure so callers
|
|
1709
|
+
can **fail-open** — never exclude a machine solely because its health probe
|
|
1710
|
+
hiccuped or timed out.
|
|
1711
|
+
|
|
1712
|
+
The short *timeout* (default 2 s) is intentional: this is a preventative
|
|
1713
|
+
pre-filter, not a blocking gate. If the agent is slow to respond, skip
|
|
1714
|
+
the filter and rely on the fall-through loop in ``dispatch_review`` to
|
|
1715
|
+
surface a definitive rejection.
|
|
1716
|
+
"""
|
|
1717
|
+
url = f"http://{host}:{port}/health"
|
|
1718
|
+
try:
|
|
1719
|
+
resp = httpx.get(url, timeout=timeout)
|
|
1720
|
+
if resp.status_code == 200:
|
|
1721
|
+
data = resp.json()
|
|
1722
|
+
repos = data.get("repos")
|
|
1723
|
+
if isinstance(repos, list):
|
|
1724
|
+
return [str(r) for r in repos]
|
|
1725
|
+
except Exception: # noqa: BLE001 — fail-open: any network or parse error
|
|
1726
|
+
pass
|
|
1727
|
+
return None
|
|
1728
|
+
|
|
1729
|
+
|
|
1730
|
+
def dispatch_review(
|
|
1731
|
+
completed: Assignment,
|
|
1732
|
+
board: Board,
|
|
1733
|
+
config: Config,
|
|
1734
|
+
*,
|
|
1735
|
+
http_client: httpx.Client | None = None,
|
|
1736
|
+
pr_lookup=_find_or_open_pr,
|
|
1737
|
+
claude_md_reader=_read_repo_claude_md,
|
|
1738
|
+
issue_body_fetcher=None,
|
|
1739
|
+
now: float | None = None,
|
|
1740
|
+
terminal_cache: dict | None = None,
|
|
1741
|
+
remote_branch_checker=None,
|
|
1742
|
+
branch_sha_fetcher=None,
|
|
1743
|
+
health_checker=None,
|
|
1744
|
+
milestone_fetcher=None,
|
|
1745
|
+
patch_id_computer=None,
|
|
1746
|
+
diff_fetcher=None,
|
|
1747
|
+
commits_ahead_checker=None,
|
|
1748
|
+
) -> Assignment | None:
|
|
1749
|
+
"""Open a PR for `completed` and dispatch a review assignment.
|
|
1750
|
+
|
|
1751
|
+
Returns the new review Assignment, or None if review couldn't be dispatched
|
|
1752
|
+
(no machine handles the repo, no branch on the completed assignment, etc.).
|
|
1753
|
+
The caller is responsible for persisting the board.
|
|
1754
|
+
|
|
1755
|
+
*health_checker* is an optional ``(host: str) -> list[str] | None`` callable
|
|
1756
|
+
that returns the repo names a given agent advertises, or ``None`` to
|
|
1757
|
+
fail-open. When not provided, ``_fetch_agent_advertised_repos`` is called
|
|
1758
|
+
directly. Inject a stub in tests to avoid real network probes.
|
|
1759
|
+
|
|
1760
|
+
*patch_id_computer* is an optional ``(diff_text: str | None) -> str |
|
|
1761
|
+
None`` callable (#1475) that fingerprints the merge-base diff being
|
|
1762
|
+
reviewed. Defaults to ``github_ops.compute_patch_id`` (a pure, no-network
|
|
1763
|
+
``git patch-id --stable`` call); inject a stub in tests that don't want
|
|
1764
|
+
to shell out to git.
|
|
1765
|
+
|
|
1766
|
+
*diff_fetcher* is an optional ``(repo_github: str, pr_number: int, *,
|
|
1767
|
+
max_chars: int | None) -> str | None`` callable (#1484) that fetches the
|
|
1768
|
+
merge-base diff embedded in the reviewer's briefing and hashed into
|
|
1769
|
+
``review_patch_id``. Defaults to :func:`coord.github_ops.pr_diff` (a real
|
|
1770
|
+
``gh pr diff`` subprocess call); inject a stub in tests so a PR-having
|
|
1771
|
+
dispatch never shells out to a live ``gh`` — mirrors
|
|
1772
|
+
:func:`dispatch_scoped_review`'s ``diff_fetcher`` for the same reason.
|
|
1773
|
+
|
|
1774
|
+
*commits_ahead_checker* is an optional ``(repo_github: str, base: str,
|
|
1775
|
+
branch: str) -> int | None`` callable (#1534) used by the zero-commit gate
|
|
1776
|
+
below. Defaults to :func:`coord.github_ops.branch_commits_ahead` (a real
|
|
1777
|
+
``gh api compare`` call); inject a stub in tests so the gate is exercised
|
|
1778
|
+
without network.
|
|
1779
|
+
"""
|
|
1780
|
+
# #1627: every early-exit guard below used to be a bare `return None`,
|
|
1781
|
+
# collapsing 11 distinct outcomes into one signal the caller couldn't
|
|
1782
|
+
# distinguish (see coord/commands/plan_followup.py's `review` command,
|
|
1783
|
+
# which used to print "no eligible reviewer machine, or a guard ...
|
|
1784
|
+
# blocked it — see the coordinator log" for every one of them, even
|
|
1785
|
+
# though most never logged anything). `_deny` records *why* on the
|
|
1786
|
+
# assignment itself (`review_dispatch_reason`, transient/in-memory —
|
|
1787
|
+
# see its docstring in models.py) and logs at info level, then returns
|
|
1788
|
+
# None so call sites can keep writing `return _deny(...)`.
|
|
1789
|
+
def _deny(reason: str) -> None:
|
|
1790
|
+
completed.review_dispatch_reason = reason
|
|
1791
|
+
log.info(
|
|
1792
|
+
"[review] not dispatching for %s: %s", completed.assignment_id, reason
|
|
1793
|
+
)
|
|
1794
|
+
return None
|
|
1795
|
+
|
|
1796
|
+
if not config.reviews.enabled or not config.reviews.auto_dispatch:
|
|
1797
|
+
return _deny(
|
|
1798
|
+
f"reviews disabled (reviews.enabled={config.reviews.enabled!r}, "
|
|
1799
|
+
f"reviews.auto_dispatch={config.reviews.auto_dispatch!r})"
|
|
1800
|
+
)
|
|
1801
|
+
if completed.type not in WORK_LIKE_TYPES:
|
|
1802
|
+
return _deny(
|
|
1803
|
+
f"assignment {completed.assignment_id} is type {completed.type!r}, "
|
|
1804
|
+
f"not reviewable work (reviewable types: {sorted(WORK_LIKE_TYPES)}). "
|
|
1805
|
+
"Did you mean the work assignment for this issue? Try: "
|
|
1806
|
+
f"coord diagnose {completed.repo_name} {completed.issue_number}"
|
|
1807
|
+
)
|
|
1808
|
+
if completed.status != "done":
|
|
1809
|
+
return _deny(
|
|
1810
|
+
f"assignment {completed.assignment_id} has status "
|
|
1811
|
+
f"{completed.status!r}, not 'done' — nothing to review yet"
|
|
1812
|
+
)
|
|
1813
|
+
if not completed.branch:
|
|
1814
|
+
# Without a branch we can't open a PR or diff. Skip silently — this
|
|
1815
|
+
# usually means the worker forgot to switch off main, which the
|
|
1816
|
+
# branch-capture code in agent._reap will have left as None.
|
|
1817
|
+
return _deny(
|
|
1818
|
+
f"assignment {completed.assignment_id} has no branch recorded — "
|
|
1819
|
+
"the worker may not have pushed yet"
|
|
1820
|
+
)
|
|
1821
|
+
|
|
1822
|
+
# Dedupe: don't fire a second review if one's already in flight for this
|
|
1823
|
+
# completed work assignment.
|
|
1824
|
+
from coord.claim import has_active_followup, has_active_work_followup
|
|
1825
|
+
|
|
1826
|
+
if has_active_followup(
|
|
1827
|
+
board, of_assignment_id=completed.assignment_id, assignment_type="review"
|
|
1828
|
+
):
|
|
1829
|
+
return _deny(
|
|
1830
|
+
f"a review is already in flight for {completed.assignment_id}"
|
|
1831
|
+
)
|
|
1832
|
+
|
|
1833
|
+
# #459: skip review if a work or conflict-fix is actively rewriting the
|
|
1834
|
+
# branch for this issue (e.g. a coord-bounce fix iteration). Reviewing
|
|
1835
|
+
# stale code now would produce a verdict on code that's about to change.
|
|
1836
|
+
# Leave the caller's review_state as "pending" so the next reconcile pass
|
|
1837
|
+
# retries once the active fix finishes.
|
|
1838
|
+
#
|
|
1839
|
+
# #1553: compare on the *effective* issue (see
|
|
1840
|
+
# ``coord.models.effective_issue_number``), not the raw
|
|
1841
|
+
# ``completed.issue_number``. For an oracle-loop acceptance slice,
|
|
1842
|
+
# ``issue_number`` is the shared tracking issue, so keying on it here
|
|
1843
|
+
# would match ANY in-flight work/conflict-fix under that milestone (an
|
|
1844
|
+
# unrelated child) rather than only a live rewrite of THIS row's branch.
|
|
1845
|
+
# ``has_active_work_followup`` itself already keys its scan on the
|
|
1846
|
+
# effective issue; this call site has to match or the guard silently
|
|
1847
|
+
# stops firing for exactly the slices #1553 restored visibility for.
|
|
1848
|
+
from coord.models import effective_issue_number
|
|
1849
|
+
|
|
1850
|
+
if has_active_work_followup(
|
|
1851
|
+
board,
|
|
1852
|
+
repo_name=completed.repo_name,
|
|
1853
|
+
issue_number=effective_issue_number(completed),
|
|
1854
|
+
):
|
|
1855
|
+
return _deny(
|
|
1856
|
+
"a work or fix assignment is actively rewriting the branch for "
|
|
1857
|
+
f"issue #{completed.issue_number} in {completed.repo_name!r} — "
|
|
1858
|
+
"review deferred until it finishes. If nothing is actually "
|
|
1859
|
+
"running, this may be a phantom 'running' row left by a worker "
|
|
1860
|
+
"that died mid-fix; check with: coord diagnose "
|
|
1861
|
+
f"{completed.repo_name} {completed.issue_number}"
|
|
1862
|
+
)
|
|
1863
|
+
|
|
1864
|
+
repo = config.repo(completed.repo_name)
|
|
1865
|
+
if repo is None:
|
|
1866
|
+
return _deny(f"repo {completed.repo_name!r} not found in config")
|
|
1867
|
+
|
|
1868
|
+
# #522: the review chokepoint. Never (re)dispatch a review for work that
|
|
1869
|
+
# is already done on GitHub — issue closed OR PR merged. This is the second
|
|
1870
|
+
# flood vector (reviews of already-merged #349/#194) that the auto-loop
|
|
1871
|
+
# fix-dispatch guard alone didn't cover. Mark the row done so the pending-
|
|
1872
|
+
# review loop stops treating it as eligible. Fail-open inside
|
|
1873
|
+
# work_is_terminal, so a transient gh error never blocks a real review.
|
|
1874
|
+
if github_ops.work_is_terminal(
|
|
1875
|
+
repo.github, completed.issue_number, completed.branch, cache=terminal_cache
|
|
1876
|
+
):
|
|
1877
|
+
completed.review_state = "done"
|
|
1878
|
+
return _deny(
|
|
1879
|
+
f"issue #{completed.issue_number} is already closed or its PR "
|
|
1880
|
+
"already merged on GitHub — review is moot"
|
|
1881
|
+
)
|
|
1882
|
+
|
|
1883
|
+
# #437: STRUCTURAL TOS-COMPLIANCE GATE — auto-dispatched reviews are
|
|
1884
|
+
# an unattended path, so refuse to route them through a provider
|
|
1885
|
+
# whose capabilities mark it ``human_attended_only``. Deferred import
|
|
1886
|
+
# keeps the review module free of a module-level cycle with the
|
|
1887
|
+
# provider registry. On refusal we return None (same as "auto_dispatch
|
|
1888
|
+
# off" / "machine unreachable") so callers leave review_state as
|
|
1889
|
+
# 'pending' and retry on the next notify call — consistent with how
|
|
1890
|
+
# _reassign handles the same guard in reconcile.py.
|
|
1891
|
+
#
|
|
1892
|
+
# #1811: ``spec_provider=config.reviews.provider`` — a review-only
|
|
1893
|
+
# override that outranks ``repo.provider`` in the same precedence chain
|
|
1894
|
+
# (spec > repo > providers.default) every other dispatch path already
|
|
1895
|
+
# uses. ``None`` (unset) resolves to exactly the same effective name as
|
|
1896
|
+
# before this field existed, so an unconfigured deployment sees no
|
|
1897
|
+
# behavior change. The guard still refuses a ``human_attended_only``
|
|
1898
|
+
# resolution regardless of which link in the chain supplied it — a
|
|
1899
|
+
# named ``reviews.provider`` gets no exemption from the #437 gate.
|
|
1900
|
+
from coord.providers import guard_unattended_dispatch # noqa: PLC0415
|
|
1901
|
+
try:
|
|
1902
|
+
review_provider_name = guard_unattended_dispatch(
|
|
1903
|
+
spec_provider=config.reviews.provider,
|
|
1904
|
+
repo_provider=repo.provider,
|
|
1905
|
+
providers_cfg=config.providers,
|
|
1906
|
+
models_cfg=config.models,
|
|
1907
|
+
where="auto-dispatch review",
|
|
1908
|
+
)
|
|
1909
|
+
except ValueError as exc:
|
|
1910
|
+
print(f"[review] skipping auto-dispatch review: {exc}")
|
|
1911
|
+
return _deny(f"blocked by human-attended-only policy: {exc}")
|
|
1912
|
+
|
|
1913
|
+
# #934: resolve this issue's base branch — `feature/ms-NN` when it
|
|
1914
|
+
# belongs to a milestone and the repo opted into the git model,
|
|
1915
|
+
# `repo.default_branch` (today's behavior) otherwise. Resolved once and
|
|
1916
|
+
# reused for the PR base, the diff-command text in the briefing, and the
|
|
1917
|
+
# `branch` payload field below, so they never disagree. The milestone
|
|
1918
|
+
# lookup itself is skipped entirely (no `gh` call) when the repo hasn't
|
|
1919
|
+
# opted in — a non-opted-in repo pays zero extra cost.
|
|
1920
|
+
base_branch = repo.default_branch
|
|
1921
|
+
if getattr(repo, "develop_branch", None):
|
|
1922
|
+
from coord.branch_model import resolve_base_branch # noqa: PLC0415
|
|
1923
|
+
|
|
1924
|
+
fetch_milestone = milestone_fetcher or _fetch_issue_milestone_number
|
|
1925
|
+
milestone_number = fetch_milestone(repo.github, completed.issue_number)
|
|
1926
|
+
base_branch = resolve_base_branch(repo, milestone_number)
|
|
1927
|
+
|
|
1928
|
+
# #1534: ZERO-COMMIT GATE. Refuse to spend a metered review on a branch
|
|
1929
|
+
# that carries no commits over its base — there is literally nothing to
|
|
1930
|
+
# review, and every second of that reviewer's budget is wasted. This is
|
|
1931
|
+
# the same reasoning as #946's merge enqueue gate, one stage earlier.
|
|
1932
|
+
#
|
|
1933
|
+
# The observed incident: a `test-author` killed by the Claude session
|
|
1934
|
+
# usage limit was recorded `done` with an empty branch, and a review was
|
|
1935
|
+
# auto-dispatched against it. The reviewer diffed nothing against nothing
|
|
1936
|
+
# and (thanks to #873) returned a null verdict, so even that produced no
|
|
1937
|
+
# signal — the empty slice looked authored *and* reviewed for two days.
|
|
1938
|
+
#
|
|
1939
|
+
# Deliberately placed AFTER the `work_is_terminal` chokepoint (so an
|
|
1940
|
+
# already-merged branch keeps its existing `review_state="done"`
|
|
1941
|
+
# resolution) but BEFORE `pr_lookup` (which would otherwise open a PR for
|
|
1942
|
+
# the empty branch as a side effect of the check).
|
|
1943
|
+
#
|
|
1944
|
+
# FAIL-OPEN: `branch_commits_ahead` returns None — never 0 — on any gh
|
|
1945
|
+
# failure, so a network blip can never strand a real review. Only a
|
|
1946
|
+
# definite `ahead_by == 0` from GitHub blocks.
|
|
1947
|
+
_ahead_check = commits_ahead_checker or github_ops.branch_commits_ahead
|
|
1948
|
+
_ahead = _ahead_check(repo.github, base_branch, completed.branch)
|
|
1949
|
+
if _ahead == 0:
|
|
1950
|
+
log.warning(
|
|
1951
|
+
"[review] branch %r for %s has 0 commits ahead of %s — refusing to "
|
|
1952
|
+
"auto-dispatch a review against an empty diff (#1534). The work "
|
|
1953
|
+
"assignment did not produce anything; re-dispatch it instead.",
|
|
1954
|
+
completed.branch, completed.assignment_id, base_branch,
|
|
1955
|
+
)
|
|
1956
|
+
completed.review_state = "zero_commits"
|
|
1957
|
+
return _deny(
|
|
1958
|
+
f"branch {completed.branch!r} has 0 commits ahead of {base_branch} "
|
|
1959
|
+
"— refusing to review an empty diff; re-dispatch the work instead"
|
|
1960
|
+
)
|
|
1961
|
+
|
|
1962
|
+
pr = pr_lookup(
|
|
1963
|
+
repo.github,
|
|
1964
|
+
branch=completed.branch,
|
|
1965
|
+
default_branch=base_branch,
|
|
1966
|
+
issue_number=completed.issue_number,
|
|
1967
|
+
issue_title=completed.issue_title,
|
|
1968
|
+
assignment_type=completed.type,
|
|
1969
|
+
)
|
|
1970
|
+
|
|
1971
|
+
# #904 (fix #1): build a ranked list of ALL eligible reviewer machines so
|
|
1972
|
+
# we can fall through to the next if one rejects the dispatch. This
|
|
1973
|
+
# replaces the previous single-pick → silent-return-None path that could
|
|
1974
|
+
# park a work row at the merge gate forever when config drift caused a
|
|
1975
|
+
# "does not handle repo" 400 from the first (and only tried) machine.
|
|
1976
|
+
candidates = _ranked_reviewer_candidates(
|
|
1977
|
+
completed.machine_name, completed.repo_name, board, config
|
|
1978
|
+
)
|
|
1979
|
+
if not candidates:
|
|
1980
|
+
return _deny(
|
|
1981
|
+
f"no eligible reviewer machine configured for repo "
|
|
1982
|
+
f"{completed.repo_name!r}"
|
|
1983
|
+
)
|
|
1984
|
+
|
|
1985
|
+
# #586: if the branch isn't on the remote, only the original worker machine
|
|
1986
|
+
# has it locally — any cross-machine reviewer would crash on git-fetch.
|
|
1987
|
+
# Narrow the candidate list to just that machine; if it's unavailable too,
|
|
1988
|
+
# stall visibly with "branch_not_on_remote".
|
|
1989
|
+
any_cross_machine = any(not same for _, same in candidates)
|
|
1990
|
+
if any_cross_machine and completed.branch:
|
|
1991
|
+
_check_remote = remote_branch_checker or github_ops.branch_exists_on_remote
|
|
1992
|
+
if not _check_remote(repo.github, completed.branch):
|
|
1993
|
+
log.warning(
|
|
1994
|
+
"[review] branch %r not on remote for %s — routing review back "
|
|
1995
|
+
"to original worker machine %s to avoid cross-machine fetch failure",
|
|
1996
|
+
completed.branch, completed.assignment_id, completed.machine_name,
|
|
1997
|
+
)
|
|
1998
|
+
from coord.machine_pause import paused_set # noqa: PLC0415
|
|
1999
|
+
paused = paused_set(config.machines)
|
|
2000
|
+
worker_machine = next(
|
|
2001
|
+
(m for m in config.machines if m.name == completed.machine_name),
|
|
2002
|
+
None,
|
|
2003
|
+
)
|
|
2004
|
+
if (
|
|
2005
|
+
worker_machine is not None
|
|
2006
|
+
and worker_machine.can_work_on(completed.repo_name)
|
|
2007
|
+
and worker_machine.name not in paused
|
|
2008
|
+
):
|
|
2009
|
+
# Restrict to just the worker machine — it has the branch locally.
|
|
2010
|
+
candidates = [(worker_machine, True)]
|
|
2011
|
+
else:
|
|
2012
|
+
# Original machine also unavailable — stall visibly.
|
|
2013
|
+
log.error(
|
|
2014
|
+
"[review] branch %r not on remote for %s and original machine "
|
|
2015
|
+
"%s is unavailable (paused or not configured) — "
|
|
2016
|
+
"review BLOCKED until branch is pushed to origin",
|
|
2017
|
+
completed.branch, completed.assignment_id, completed.machine_name,
|
|
2018
|
+
)
|
|
2019
|
+
completed.review_state = "branch_not_on_remote"
|
|
2020
|
+
return _deny(
|
|
2021
|
+
f"branch {completed.branch!r} not on remote and original "
|
|
2022
|
+
f"worker machine {completed.machine_name!r} is unavailable "
|
|
2023
|
+
"(paused or not configured) — push the branch to origin "
|
|
2024
|
+
"or unpause the worker machine"
|
|
2025
|
+
)
|
|
2026
|
+
|
|
2027
|
+
# Compute the parts that are constant across all candidate machines.
|
|
2028
|
+
|
|
2029
|
+
# #612: merge-base diff — embedded verbatim so the reviewer reviews exactly
|
|
2030
|
+
# the branch's own changes (a stale-base diff sweeps in already-merged
|
|
2031
|
+
# commits as spurious deletions, #546). Best-effort: None keeps the
|
|
2032
|
+
# fallback three-dot git-diff instructions in the briefing.
|
|
2033
|
+
# #1475: fetch the full, untruncated diff once — it's the input to the
|
|
2034
|
+
# content-hash (`review_patch_id` below) and must never be the mutated,
|
|
2035
|
+
# truncated-with-a-trailer string (hashing that gives a patch-id that can
|
|
2036
|
+
# never match the merge-time `branch_patch_id`, which is computed from an
|
|
2037
|
+
# uncapped compare-API diff). The display copy shown to the reviewer is
|
|
2038
|
+
# then truncated locally from the same fetch — no second `gh` call.
|
|
2039
|
+
_diff = diff_fetcher or github_ops.pr_diff
|
|
2040
|
+
full_diff_text = _diff(repo.github, pr["number"], max_chars=None) if pr else None
|
|
2041
|
+
diff_text = (
|
|
2042
|
+
github_ops.truncate_diff_text(full_diff_text) if full_diff_text is not None else None
|
|
2043
|
+
)
|
|
2044
|
+
|
|
2045
|
+
fetch_body = issue_body_fetcher or _fetch_issue_body
|
|
2046
|
+
issue_body = fetch_body(repo.github, completed.issue_number)
|
|
2047
|
+
|
|
2048
|
+
# #1811: does the resolved review provider share the worker's model
|
|
2049
|
+
# family? ``completed.provider_name`` is the *resolved* name recorded at
|
|
2050
|
+
# work-dispatch time (spec > repo > providers.default); ``None`` means a
|
|
2051
|
+
# row predating #324 or a path that doesn't set it, which the rest of
|
|
2052
|
+
# the codebase (e.g. coord/gates.py's TUI rendering) treats as the
|
|
2053
|
+
# implicit "claude" default. Provider co-location is a larger loss of
|
|
2054
|
+
# independence than machine co-location (a fresh session removes shared
|
|
2055
|
+
# context, but not shared blind spots) — surfaced below in the
|
|
2056
|
+
# reviewer's own briefing, mirroring ``same_as_worker``.
|
|
2057
|
+
worker_provider_name = completed.provider_name or "claude"
|
|
2058
|
+
provider_same_as_worker = review_provider_name == worker_provider_name
|
|
2059
|
+
if provider_same_as_worker:
|
|
2060
|
+
log.info(
|
|
2061
|
+
"[review] %s: reviewer provider %r matches worker provider — "
|
|
2062
|
+
"reduced independence (shared model family)",
|
|
2063
|
+
completed.assignment_id, review_provider_name,
|
|
2064
|
+
)
|
|
2065
|
+
|
|
2066
|
+
# Pin the reviewer's model to avoid the agent defaulting to Opus (#911).
|
|
2067
|
+
# #1430: deliberately not consulting models.labels — the reviewer's
|
|
2068
|
+
# effort scales with diff size, not the original work issue's tier
|
|
2069
|
+
# label, and #911 already pins this deliberately.
|
|
2070
|
+
review_model_alias = config.models.default
|
|
2071
|
+
review_model_wire = config.models.resolve(review_model_alias)
|
|
2072
|
+
|
|
2073
|
+
# #821: capture branch HEAD SHA once; staleness detected post-review.
|
|
2074
|
+
_get_sha = branch_sha_fetcher or github_ops.get_branch_sha
|
|
2075
|
+
review_head_sha: str | None = None
|
|
2076
|
+
try:
|
|
2077
|
+
review_head_sha = _get_sha(repo.github, completed.branch)
|
|
2078
|
+
except Exception: # noqa: BLE001 — fail-safe: missing SHA is not blocking
|
|
2079
|
+
pass
|
|
2080
|
+
|
|
2081
|
+
# #1475: fingerprint the *full* merge-base diff (`full_diff_text`, computed
|
|
2082
|
+
# above) — not the display-truncated `diff_text` — so this matches the
|
|
2083
|
+
# merge-time counterpart (`get_branch_patch_id`, also uncapped) for any PR
|
|
2084
|
+
# whose diff exceeds the display truncation threshold. Stored alongside
|
|
2085
|
+
# review_head_sha so a later commit-bound staleness check (a rebase moving
|
|
2086
|
+
# the SHA) can carry the approval forward when the content is byte-identical.
|
|
2087
|
+
_compute_patch_id = patch_id_computer or github_ops.compute_patch_id
|
|
2088
|
+
review_patch_id: str | None = None
|
|
2089
|
+
try:
|
|
2090
|
+
review_patch_id = _compute_patch_id(full_diff_text)
|
|
2091
|
+
except Exception: # noqa: BLE001 — fail-safe: missing patch-id is not blocking
|
|
2092
|
+
pass
|
|
2093
|
+
|
|
2094
|
+
# #603: per-issue context digest (cross-repo deps / prior findings).
|
|
2095
|
+
from coord.state import issue_context_block # noqa: PLC0415
|
|
2096
|
+
context_prefix = issue_context_block(completed.repo_name, completed.issue_number)
|
|
2097
|
+
|
|
2098
|
+
# #944 sealing v1: flag tests/acceptance/ as sealed when this repo has an
|
|
2099
|
+
# oracle-loop acceptance driver configured — the reviewer must reject any
|
|
2100
|
+
# diff that touches it (docs/ORACLE_LOOP.md).
|
|
2101
|
+
#
|
|
2102
|
+
# #1552: the set is DERIVED from the driver definition rather than
|
|
2103
|
+
# hardcoded to that one literal. `tests/acceptance/` alone fits a
|
|
2104
|
+
# directory-discovered suite (`pytest tests/acceptance/{ms}`) and is
|
|
2105
|
+
# structurally unsatisfiable for an entry-point-linked one
|
|
2106
|
+
# (`cargo test --test acceptance` sees nothing until `tui/tests/
|
|
2107
|
+
# acceptance.rs` include!s the slice) — under #1175's blanket refusal a
|
|
2108
|
+
# `test-author` on the Rust route could only wire its slice in and be
|
|
2109
|
+
# bounced, or leave it unwired and ship dead code. Each route now
|
|
2110
|
+
# declares its own `entrypoint:`.
|
|
2111
|
+
sealed_paths = config.acceptance.sealed_paths(completed.repo_name)
|
|
2112
|
+
sealed_entrypoints = config.acceptance.entrypoints(completed.repo_name)
|
|
2113
|
+
|
|
2114
|
+
client = http_client or httpx
|
|
2115
|
+
|
|
2116
|
+
# Iterate candidates in priority order. On agent rejection (4xx from a
|
|
2117
|
+
# misconfigured agent, health-check filter on a drifted config, etc.) we
|
|
2118
|
+
# log a warning and try the next candidate instead of giving up silently.
|
|
2119
|
+
# Only definitive rejections (4xx responses or health-check exclusions) set
|
|
2120
|
+
# had_rejection=True; transient network failures leave the row as "pending"
|
|
2121
|
+
# so the next reconcile/notify pass retries automatically.
|
|
2122
|
+
had_rejection = False
|
|
2123
|
+
for machine, same_as_worker in candidates:
|
|
2124
|
+
# Fix #2 (PREVENTATIVE): pre-filter against the agent's /health
|
|
2125
|
+
# ``repos`` list so a drifted local config can't pick a machine that
|
|
2126
|
+
# will 400. Fail-open: None means "probe failed, include anyway".
|
|
2127
|
+
# #1485: an empty list is NOT the same as None here — it means "this
|
|
2128
|
+
# agent has no local coordinator.yml at all" (the expected, correct
|
|
2129
|
+
# state for a worker-only machine — coordinator.yml lives on
|
|
2130
|
+
# dellserver only), which matches the agent's own interpretation in
|
|
2131
|
+
# AgentServer.assign (`if self.repos and spec.repo_name not in
|
|
2132
|
+
# self.repos`, coord/agent.py) where an empty list is falsy and means
|
|
2133
|
+
# "no restriction, accept everything." Treat `[]` the same way here —
|
|
2134
|
+
# only a *non-empty* advertised list that omits the repo is a genuine
|
|
2135
|
+
# drift signal worth skipping the candidate for.
|
|
2136
|
+
_hc = health_checker if health_checker is not None else _fetch_agent_advertised_repos
|
|
2137
|
+
advertised = _hc(machine.host)
|
|
2138
|
+
if advertised and completed.repo_name not in advertised:
|
|
2139
|
+
log.warning(
|
|
2140
|
+
"[review] skipping candidate %s: /health advertises repos %r "
|
|
2141
|
+
"but repo %r is not listed — possible config drift",
|
|
2142
|
+
machine.name, advertised, completed.repo_name,
|
|
2143
|
+
)
|
|
2144
|
+
had_rejection = True
|
|
2145
|
+
continue
|
|
2146
|
+
|
|
2147
|
+
repo_path = machine.repo_path(completed.repo_name)
|
|
2148
|
+
if repo_path is None:
|
|
2149
|
+
log.warning(
|
|
2150
|
+
"[review] skipping candidate %s: no repo_path for %r",
|
|
2151
|
+
machine.name, completed.repo_name,
|
|
2152
|
+
)
|
|
2153
|
+
continue
|
|
2154
|
+
|
|
2155
|
+
claude_md = claude_md_reader(Path(repo_path).expanduser())
|
|
2156
|
+
|
|
2157
|
+
# #476 / #612: briefing is rebuilt per candidate because same_as_worker
|
|
2158
|
+
# (warning note in the briefing) and claude_md path can differ between
|
|
2159
|
+
# machines.
|
|
2160
|
+
briefing = context_prefix + build_review_briefing(
|
|
2161
|
+
pr_number=pr["number"] if pr else None,
|
|
2162
|
+
pr_url=pr["url"] if pr else None,
|
|
2163
|
+
repo_github=repo.github,
|
|
2164
|
+
repo_name=repo.name,
|
|
2165
|
+
issue_number=completed.issue_number,
|
|
2166
|
+
issue_title=completed.issue_title,
|
|
2167
|
+
issue_body=issue_body,
|
|
2168
|
+
branch=completed.branch,
|
|
2169
|
+
worker_machine=completed.machine_name,
|
|
2170
|
+
same_as_worker=same_as_worker,
|
|
2171
|
+
provider_same_as_worker=provider_same_as_worker,
|
|
2172
|
+
review_provider=review_provider_name,
|
|
2173
|
+
reviews_cfg=config.reviews,
|
|
2174
|
+
repo_claude_md=claude_md,
|
|
2175
|
+
default_branch=base_branch,
|
|
2176
|
+
# #476: a fix worker carries review_iteration > 0; its re-review is
|
|
2177
|
+
# scoped to the fix delta rather than re-reviewing the whole PR.
|
|
2178
|
+
review_iteration=getattr(completed, "review_iteration", 0) or 0,
|
|
2179
|
+
diff_text=diff_text,
|
|
2180
|
+
sealed_paths=sealed_paths,
|
|
2181
|
+
sealed_entrypoints=sealed_entrypoints,
|
|
2182
|
+
assignment_type=completed.type,
|
|
2183
|
+
)
|
|
2184
|
+
|
|
2185
|
+
payload = {
|
|
2186
|
+
"repo_name": completed.repo_name,
|
|
2187
|
+
"repo_path": repo_path,
|
|
2188
|
+
"issue_number": completed.issue_number,
|
|
2189
|
+
"issue_title": f"[review] {completed.issue_title}",
|
|
2190
|
+
"briefing": briefing,
|
|
2191
|
+
"files_allowed": [],
|
|
2192
|
+
"files_forbidden": [],
|
|
2193
|
+
"pull_repos": [],
|
|
2194
|
+
"type": "review",
|
|
2195
|
+
"model": review_model_wire,
|
|
2196
|
+
"system_prompt": REVIEWER_SYSTEM_PROMPT,
|
|
2197
|
+
"review_target": str(pr["number"]) if pr else completed.branch,
|
|
2198
|
+
# #255: review checkout uses the PR branch, but the agent's worktree
|
|
2199
|
+
# setup still consults `branch` as the integration base when no PR
|
|
2200
|
+
# branch exists locally yet. Match the work-dispatch path.
|
|
2201
|
+
"branch": base_branch or "main",
|
|
2202
|
+
}
|
|
2203
|
+
# #1811: carry the resolved review provider onto the wire the same
|
|
2204
|
+
# way coord.dispatch.dispatch() does for work — without this the
|
|
2205
|
+
# agent's own AssignmentSpec.provider stays None and it silently
|
|
2206
|
+
# runs its legacy default worker command regardless of what
|
|
2207
|
+
# guard_unattended_dispatch resolved above, which is exactly the
|
|
2208
|
+
# "configuration appears to work while doing nothing" trap #1811
|
|
2209
|
+
# calls out. Gated by the same helper dispatch() uses so a vanilla,
|
|
2210
|
+
# uncustomized "claude" resolution keeps an unconfigured
|
|
2211
|
+
# deployment's wire payload byte-identical to before this field
|
|
2212
|
+
# existed.
|
|
2213
|
+
from coord.dispatch import _wire_payload_needs_provider_field # noqa: PLC0415
|
|
2214
|
+
|
|
2215
|
+
if review_provider_name and _wire_payload_needs_provider_field(
|
|
2216
|
+
review_provider_name, config,
|
|
2217
|
+
):
|
|
2218
|
+
payload["provider"] = review_provider_name
|
|
2219
|
+
|
|
2220
|
+
url = f"http://{machine.host}:{AGENT_PORT}/assign"
|
|
2221
|
+
try:
|
|
2222
|
+
resp = client.post(url, json=payload, timeout=15)
|
|
2223
|
+
resp.raise_for_status()
|
|
2224
|
+
agent_response = resp.json()
|
|
2225
|
+
except httpx.HTTPStatusError as exc:
|
|
2226
|
+
# Fix #1 (PRIMARY): the agent definitively rejected the dispatch
|
|
2227
|
+
# (e.g. 400 "does not handle repo 'x'"). Try the next candidate
|
|
2228
|
+
# instead of silently returning None and leaving review_state as
|
|
2229
|
+
# 'pending' (#904).
|
|
2230
|
+
#
|
|
2231
|
+
# #904 (fix #2): only a 4xx is a *definitive* rejection — it means
|
|
2232
|
+
# the agent looked at the request and refused it (bad repo, bad
|
|
2233
|
+
# payload, etc.), which is a config-drift signal. A 5xx means the
|
|
2234
|
+
# agent's own handler blew up (mid-restart, disk full, unhandled
|
|
2235
|
+
# exception) and says nothing about whether this agent/repo pairing
|
|
2236
|
+
# is valid — treat it like the transient network branch below so
|
|
2237
|
+
# the row stays "pending" and retries next pass instead of
|
|
2238
|
+
# permanently stalling as "no_eligible_reviewer".
|
|
2239
|
+
if exc.response.is_client_error:
|
|
2240
|
+
log.warning(
|
|
2241
|
+
"[review] agent %s rejected dispatch with HTTP %d — "
|
|
2242
|
+
"trying next reviewer candidate",
|
|
2243
|
+
machine.name, exc.response.status_code,
|
|
2244
|
+
)
|
|
2245
|
+
had_rejection = True
|
|
2246
|
+
else:
|
|
2247
|
+
log.warning(
|
|
2248
|
+
"[review] agent %s returned server error HTTP %d (transient) — "
|
|
2249
|
+
"trying next reviewer candidate",
|
|
2250
|
+
machine.name, exc.response.status_code,
|
|
2251
|
+
)
|
|
2252
|
+
continue
|
|
2253
|
+
except (httpx.HTTPError, httpx.TimeoutException) as exc:
|
|
2254
|
+
# Transient network failure — try next candidate, and if all
|
|
2255
|
+
# fail transiently, leave review_state unchanged so the next
|
|
2256
|
+
# reconcile/notify pass retries automatically.
|
|
2257
|
+
log.warning(
|
|
2258
|
+
"[review] agent %s unreachable (%s) — trying next reviewer candidate",
|
|
2259
|
+
machine.name, exc,
|
|
2260
|
+
)
|
|
2261
|
+
continue
|
|
2262
|
+
|
|
2263
|
+
# Dispatch accepted — record the review assignment and return.
|
|
2264
|
+
review_assignment = Assignment(
|
|
2265
|
+
machine_name=machine.name,
|
|
2266
|
+
repo_name=completed.repo_name,
|
|
2267
|
+
issue_number=completed.issue_number,
|
|
2268
|
+
issue_title=f"[review] {completed.issue_title}",
|
|
2269
|
+
files_allowed=[],
|
|
2270
|
+
files_forbidden=[],
|
|
2271
|
+
briefing=briefing,
|
|
2272
|
+
assignment_id=agent_response.get("id") or uuid.uuid4().hex[:12],
|
|
2273
|
+
status="running",
|
|
2274
|
+
branch=completed.branch,
|
|
2275
|
+
pr_url=pr.get("url") if pr else None,
|
|
2276
|
+
dispatched_at=now if now is not None else time.time(),
|
|
2277
|
+
type="review",
|
|
2278
|
+
review_target=str(pr["number"]) if pr else completed.branch,
|
|
2279
|
+
review_of_assignment_id=completed.assignment_id,
|
|
2280
|
+
model=review_model_alias,
|
|
2281
|
+
# #1811: record the resolved review provider the same way
|
|
2282
|
+
# coord.dispatch.dispatch() records the work provider — so the
|
|
2283
|
+
# TUI/audit trail can distinguish a review that ran through
|
|
2284
|
+
# `reviews.provider`/`repo.provider` from one that fell through
|
|
2285
|
+
# to `providers.default`, instead of guessing "claude" for every
|
|
2286
|
+
# review row the way a `None` here used to force.
|
|
2287
|
+
provider_name=review_provider_name,
|
|
2288
|
+
review_head_sha=review_head_sha,
|
|
2289
|
+
review_patch_id=review_patch_id,
|
|
2290
|
+
# #1553: a review of an oracle-loop acceptance slice is work for
|
|
2291
|
+
# the CHILD issue, not for the milestone's tracking issue that
|
|
2292
|
+
# `completed.issue_number` carries. Inherit the slice attribution
|
|
2293
|
+
# so the child's Pipeline row shows the review as activity and
|
|
2294
|
+
# its cost rolls up to the child. None for every ordinary review
|
|
2295
|
+
# (the parent has no `for_issue_number`), so nothing changes for
|
|
2296
|
+
# non-slice work. See `coord.models.effective_issue_number`.
|
|
2297
|
+
for_issue_number=completed.for_issue_number,
|
|
2298
|
+
)
|
|
2299
|
+
board.active.append(review_assignment)
|
|
2300
|
+
|
|
2301
|
+
from coord.state import record_dispatched_assignment # noqa: PLC0415
|
|
2302
|
+
record_dispatched_assignment(
|
|
2303
|
+
assignment=review_assignment,
|
|
2304
|
+
repo_github=repo.github,
|
|
2305
|
+
)
|
|
2306
|
+
|
|
2307
|
+
return review_assignment
|
|
2308
|
+
|
|
2309
|
+
# All candidates exhausted. Distinguish definitive rejection (config
|
|
2310
|
+
# drift, drifted agent config) from transient network failures.
|
|
2311
|
+
if had_rejection:
|
|
2312
|
+
# At least one agent definitively rejected the repo — stall visibly
|
|
2313
|
+
# with a named state so `coord status` can surface an actionable error
|
|
2314
|
+
# and the pending-review loop stops silently retrying (#904).
|
|
2315
|
+
log.error(
|
|
2316
|
+
"[review] all reviewer candidates rejected dispatch for %s "
|
|
2317
|
+
"(repo=%r, branch=%r) — setting review_state='no_eligible_reviewer'. "
|
|
2318
|
+
"Check that every agent's repos list includes %r.",
|
|
2319
|
+
completed.assignment_id, completed.repo_name, completed.branch,
|
|
2320
|
+
completed.repo_name,
|
|
2321
|
+
)
|
|
2322
|
+
completed.review_state = "no_eligible_reviewer"
|
|
2323
|
+
completed.review_dispatch_reason = (
|
|
2324
|
+
f"all reviewer candidates rejected dispatch for repo "
|
|
2325
|
+
f"{completed.repo_name!r} (config drift — check every agent's "
|
|
2326
|
+
"repos list)"
|
|
2327
|
+
)
|
|
2328
|
+
else:
|
|
2329
|
+
# Only transient failures — leave review_state unchanged so the next
|
|
2330
|
+
# reconcile/notify pass retries automatically.
|
|
2331
|
+
log.warning(
|
|
2332
|
+
"[review] all reviewer candidates unreachable for %s "
|
|
2333
|
+
"(repo=%r) — will retry on next reconcile/notify pass",
|
|
2334
|
+
completed.assignment_id, completed.repo_name,
|
|
2335
|
+
)
|
|
2336
|
+
completed.review_dispatch_reason = (
|
|
2337
|
+
f"all reviewer candidates unreachable for repo "
|
|
2338
|
+
f"{completed.repo_name!r} — transient, will retry automatically"
|
|
2339
|
+
)
|
|
2340
|
+
return None
|
|
2341
|
+
|
|
2342
|
+
|
|
2343
|
+
def dispatch_pending_reviews(board, config, *, test_gate_active: bool = False, now=None):
|
|
2344
|
+
"""Bounded bulk review dispatch — the flood guard (incident 2026-06-08).
|
|
2345
|
+
|
|
2346
|
+
Gather every completed-work row eligible for a review, then dispatch
|
|
2347
|
+
reviews subject to two limits that prevent the review-flood failure mode —
|
|
2348
|
+
a backlog "unmasking" firing hundreds of metered ``claude -p`` reviews in a
|
|
2349
|
+
single reconcile/notify pass:
|
|
2350
|
+
|
|
2351
|
+
1. **Surge gate.** If the number of eligible rows exceeds
|
|
2352
|
+
``reviews.flood_threshold`` (and the threshold is > 0), dispatch
|
|
2353
|
+
*nothing* and log loudly. A sudden surge is the unmasking signature, so
|
|
2354
|
+
we halt and require a human to either clear the stale backlog (mark it
|
|
2355
|
+
reviewed/skipped) or opt in via ``reviews.allow_review_flood: true`` /
|
|
2356
|
+
``COORD_ALLOW_REVIEW_FLOOD=1``.
|
|
2357
|
+
2. **Per-pass cap.** Otherwise dispatch at most
|
|
2358
|
+
``reviews.max_auto_dispatch_per_pass`` reviews this pass (0 = unbounded);
|
|
2359
|
+
the remainder stay ``"pending"`` and are picked up next pass, so even a
|
|
2360
|
+
moderate batch bleeds out at a bounded rate instead of all at once.
|
|
2361
|
+
|
|
2362
|
+
A row is eligible when its ``review_state`` is ``None``/``"pending"``, its
|
|
2363
|
+
``type`` is in :data:`coord.models.WORK_LIKE_TYPES` (``"work"`` or
|
|
2364
|
+
``"mock-author"``, #930), the (optional) test gate is satisfied, and #459's
|
|
2365
|
+
``has_active_work_followup`` is False (don't review code a live fix is
|
|
2366
|
+
rewriting). Both ``reconcile()`` and ``coord notify`` route bulk dispatch
|
|
2367
|
+
through here so the cap, surge gate, and #459 dedupe are enforced on every
|
|
2368
|
+
automatic path. Sets ``review_state="dispatched"`` on each row it
|
|
2369
|
+
dispatches and returns the dispatched review ``Assignment``s. The caller
|
|
2370
|
+
persists the board.
|
|
2371
|
+
|
|
2372
|
+
#1565: before the eligibility filter runs, any row whose ``review_state``
|
|
2373
|
+
reads ``pending``/``None`` but that already has a *terminal* verdict on a
|
|
2374
|
+
completed ``type="review"`` assignment targeting it is excluded and
|
|
2375
|
+
self-healed (``review_state`` set to ``"done"``) rather than trusted at
|
|
2376
|
+
face value — see the guard immediately below. This is the backstop for a
|
|
2377
|
+
row whose ``review_state`` regressed to ``pending`` after a review already
|
|
2378
|
+
rendered a verdict (a stale whole-board ``save_board()`` clobber, or a
|
|
2379
|
+
verdict that was never propagated to the parent row).
|
|
2380
|
+
"""
|
|
2381
|
+
import logging
|
|
2382
|
+
import os
|
|
2383
|
+
|
|
2384
|
+
from coord.claim import has_active_work_followup
|
|
2385
|
+
from coord.models import effective_issue_number
|
|
2386
|
+
|
|
2387
|
+
logger = logging.getLogger("coord.review")
|
|
2388
|
+
|
|
2389
|
+
# Test-before-Review reorder: when the pipeline orders Test ahead of Review,
|
|
2390
|
+
# hold automatic review dispatch until the work carries a passed/skipped
|
|
2391
|
+
# test verdict, so the headless auto-loop matches the displayed
|
|
2392
|
+
# Work → Test → Review order (and never burns a metered review on code the
|
|
2393
|
+
# smoke test hasn't validated yet). Explicit callers can still force the
|
|
2394
|
+
# gate on via ``test_gate_active``; the explicit ``coord review``/``coord
|
|
2395
|
+
# pr`` paths (→ ``dispatch_review`` directly) stay ungated so a human can
|
|
2396
|
+
# always request a review deliberately.
|
|
2397
|
+
gate_test = test_gate_active or (
|
|
2398
|
+
getattr(config, "pipeline", None) is not None
|
|
2399
|
+
and config.pipeline.test_precedes_review()
|
|
2400
|
+
)
|
|
2401
|
+
|
|
2402
|
+
# #1076/#1152: a `type="mock-author"` (Gate A contract/fixture diff) or
|
|
2403
|
+
# `type="test-author"` (per-issue JIT acceptance-slice authoring, #931)
|
|
2404
|
+
# completion is a fixture/test-only diff — it matches no
|
|
2405
|
+
# `smoke_tests.capability_rules` rule by construction, so nothing ever
|
|
2406
|
+
# produces a Test-gate verdict for it and `test_state` stays NULL forever.
|
|
2407
|
+
# Under an active test gate that means the row is silently and
|
|
2408
|
+
# permanently excluded from `eligible` below — no error, no stuck
|
|
2409
|
+
# indicator, just a row that never gets reviewed (the #1076 repro,
|
|
2410
|
+
# assignment 9960b957ff3f; the #1152 repro, assignment 2e93ee72071c).
|
|
2411
|
+
# There is nothing to smoke-test for either shape of completion, so
|
|
2412
|
+
# "skipped" is always the correct verdict, not a judgment call — backfill
|
|
2413
|
+
# it here, the single choke point both reconcile() and `coord notify`
|
|
2414
|
+
# (`_dispatch_board_pending_reviews`) route bulk review dispatch through,
|
|
2415
|
+
# so this also retroactively unsticks any row that went "done" before
|
|
2416
|
+
# this fix shipped. `type="work"` rows are untouched — the test gate
|
|
2417
|
+
# still applies to them exactly as before (do NOT widen this to
|
|
2418
|
+
# `WORK_LIKE_TYPES`, which also contains `"work"`).
|
|
2419
|
+
_AUTO_SKIP_TEST_GATE_TYPES = ("mock-author", "test-author")
|
|
2420
|
+
if gate_test:
|
|
2421
|
+
from coord.state import record_test_verdict
|
|
2422
|
+
|
|
2423
|
+
for c in board.completed:
|
|
2424
|
+
if (
|
|
2425
|
+
c.type in _AUTO_SKIP_TEST_GATE_TYPES
|
|
2426
|
+
and c.review_state in (None, "pending")
|
|
2427
|
+
and c.test_state is None
|
|
2428
|
+
and c.assignment_id is not None
|
|
2429
|
+
):
|
|
2430
|
+
record_test_verdict(
|
|
2431
|
+
assignment_id=c.assignment_id,
|
|
2432
|
+
test_state="skipped",
|
|
2433
|
+
test_reason=(
|
|
2434
|
+
f"Gate A {c.type}: contract/fixture-only diff, "
|
|
2435
|
+
"nothing to smoke-test (#1076/#1152)"
|
|
2436
|
+
),
|
|
2437
|
+
)
|
|
2438
|
+
c.test_state = "skipped"
|
|
2439
|
+
|
|
2440
|
+
# #1565: dispatch-side backstop. review_state is supposed to be the
|
|
2441
|
+
# single source of truth for "does this row still need a review", but
|
|
2442
|
+
# it has been observed to regress to "pending" out from under a row that
|
|
2443
|
+
# already carries a real, terminal verdict on a completed review
|
|
2444
|
+
# assignment (a stale whole-board save_board() clobber, or a code path
|
|
2445
|
+
# that forgot to propagate the verdict onto the parent — see
|
|
2446
|
+
# `_advance_pipeline`'s #1565 fix). Before trusting review_state, check
|
|
2447
|
+
# for that shape directly and refuse to burn a second metered review
|
|
2448
|
+
# re-deriving a verdict that already exists — log loudly (a guard that
|
|
2449
|
+
# trips silently teaches nobody) and self-heal the row instead of
|
|
2450
|
+
# leaving it to trip this same guard every pass.
|
|
2451
|
+
for c in board.completed:
|
|
2452
|
+
if (
|
|
2453
|
+
c.review_state not in (None, "pending")
|
|
2454
|
+
or c.type not in WORK_LIKE_TYPES
|
|
2455
|
+
or c.assignment_id is None
|
|
2456
|
+
):
|
|
2457
|
+
continue
|
|
2458
|
+
prior_verdict = next(
|
|
2459
|
+
(
|
|
2460
|
+
r.review_verdict
|
|
2461
|
+
for r in board.active + board.completed
|
|
2462
|
+
if r.type == "review"
|
|
2463
|
+
and r.review_of_assignment_id == c.assignment_id
|
|
2464
|
+
and r.review_verdict is not None
|
|
2465
|
+
),
|
|
2466
|
+
None,
|
|
2467
|
+
)
|
|
2468
|
+
if prior_verdict is None:
|
|
2469
|
+
continue
|
|
2470
|
+
logger.warning(
|
|
2471
|
+
"dispatch guard (#1565): %s (%s #%s) already has a terminal "
|
|
2472
|
+
"review verdict %r recorded on a prior review assignment, but "
|
|
2473
|
+
"its own review_state=%r would make it eligible for another "
|
|
2474
|
+
"metered review — refusing to re-dispatch and self-healing "
|
|
2475
|
+
"review_state='done' instead.",
|
|
2476
|
+
c.assignment_id, c.repo_name, c.issue_number,
|
|
2477
|
+
prior_verdict, c.review_state,
|
|
2478
|
+
)
|
|
2479
|
+
from coord.state import record_work_review_verdict
|
|
2480
|
+
|
|
2481
|
+
c.review_state = "done"
|
|
2482
|
+
c.review_verdict = prior_verdict
|
|
2483
|
+
record_work_review_verdict(c.assignment_id, prior_verdict)
|
|
2484
|
+
|
|
2485
|
+
# #1612 step 2: enforce max_review_iterations here too, not just in
|
|
2486
|
+
# run_for_fix_transition. A fix row whose test verdict isn't in yet gets
|
|
2487
|
+
# deferred to this function (its review_state is set back to "pending"
|
|
2488
|
+
# rather than dispatching directly — see run_for_fix_transition's #1612
|
|
2489
|
+
# fix), so by the time it reaches the eligibility filter below its
|
|
2490
|
+
# review_iteration has normally already been checked once, upstream. This
|
|
2491
|
+
# is the defense-in-depth duplicate of that guard: without it, a row that
|
|
2492
|
+
# reaches review_state="pending" through any other path (a future code
|
|
2493
|
+
# path, a manual edit) would silently bypass the fix-loop cap instead of
|
|
2494
|
+
# stopping it here the same way the other bulk-path guards are duplicated
|
|
2495
|
+
# (claude-pty, terminal-work).
|
|
2496
|
+
_max_review_iter = config.pipeline.max_review_iterations
|
|
2497
|
+
for c in board.completed:
|
|
2498
|
+
if (
|
|
2499
|
+
c.review_state in (None, "pending")
|
|
2500
|
+
and c.type in WORK_LIKE_TYPES
|
|
2501
|
+
and c.status == "done"
|
|
2502
|
+
and (c.review_iteration or 0) >= _max_review_iter
|
|
2503
|
+
):
|
|
2504
|
+
logger.warning(
|
|
2505
|
+
"dispatch_pending_reviews cap guard (#1612): %s (%s #%s) has "
|
|
2506
|
+
"review_iteration=%d >= max_review_iterations=%d — not "
|
|
2507
|
+
"dispatching another review.",
|
|
2508
|
+
c.assignment_id, c.repo_name, c.issue_number,
|
|
2509
|
+
c.review_iteration or 0, _max_review_iter,
|
|
2510
|
+
)
|
|
2511
|
+
from coord.auto_loop import _post_max_iterations_notice
|
|
2512
|
+
|
|
2513
|
+
_post_max_iterations_notice(c, config)
|
|
2514
|
+
c.review_state = "cap_hit"
|
|
2515
|
+
|
|
2516
|
+
eligible = [
|
|
2517
|
+
c
|
|
2518
|
+
for c in board.completed
|
|
2519
|
+
if c.review_state in (None, "pending")
|
|
2520
|
+
and c.type in WORK_LIKE_TYPES
|
|
2521
|
+
# #1534: only a genuinely SUCCESSFUL completion is review-eligible.
|
|
2522
|
+
# `dispatch_review` has always refused a non-`done` row internally,
|
|
2523
|
+
# but the bulk loop used to feed it every `failed`/`advisory` row on
|
|
2524
|
+
# the board on every pass (they carry `review_state=None`), which
|
|
2525
|
+
# made this loop's own eligibility list read as "review is pending
|
|
2526
|
+
# for these" when it was not — and made the surge/flood counters
|
|
2527
|
+
# below count rows that could never dispatch. Stating the invariant
|
|
2528
|
+
# here keeps the loop and the chokepoint agreeing.
|
|
2529
|
+
and c.status == "done"
|
|
2530
|
+
# #555: NEVER auto-dispatch a headless `claude -p` review for an
|
|
2531
|
+
# *interactive* (`provider_name="claude-pty"`) work completion. The
|
|
2532
|
+
# interactive Work→Review handoff is human-attended (TUI confirm →
|
|
2533
|
+
# interactive review); a metered headless review must not silently
|
|
2534
|
+
# follow it. This guard lives only in the automatic bulk path — the
|
|
2535
|
+
# explicit `coord review <id>` escape hatch (→ dispatch_review) still
|
|
2536
|
+
# lets a human deliberately request a headless review if they want one.
|
|
2537
|
+
and c.provider_name != "claude-pty"
|
|
2538
|
+
and (not gate_test or c.test_state in ("passed", "skipped"))
|
|
2539
|
+
# #1553: effective issue, not raw — see the matching comment on the
|
|
2540
|
+
# ``dispatch_review`` call site above; both must key on the same
|
|
2541
|
+
# thing has_active_work_followup itself keys on internally.
|
|
2542
|
+
and not has_active_work_followup(
|
|
2543
|
+
board, repo_name=c.repo_name, issue_number=effective_issue_number(c)
|
|
2544
|
+
)
|
|
2545
|
+
]
|
|
2546
|
+
if not eligible:
|
|
2547
|
+
return []
|
|
2548
|
+
|
|
2549
|
+
threshold = config.reviews.flood_threshold
|
|
2550
|
+
override = (
|
|
2551
|
+
config.reviews.allow_review_flood
|
|
2552
|
+
or os.environ.get("COORD_ALLOW_REVIEW_FLOOD") == "1"
|
|
2553
|
+
)
|
|
2554
|
+
if threshold and len(eligible) > threshold and not override:
|
|
2555
|
+
logger.warning(
|
|
2556
|
+
"review flood guard: %d work rows are pending review (> "
|
|
2557
|
+
"reviews.flood_threshold=%d). Refusing bulk dispatch to avoid a "
|
|
2558
|
+
"metered review flood. Clear the stale backlog (mark reviewed/"
|
|
2559
|
+
"skipped), or set reviews.allow_review_flood: true (or "
|
|
2560
|
+
"COORD_ALLOW_REVIEW_FLOOD=1) to override.",
|
|
2561
|
+
len(eligible),
|
|
2562
|
+
threshold,
|
|
2563
|
+
)
|
|
2564
|
+
return []
|
|
2565
|
+
|
|
2566
|
+
cap = config.reviews.max_auto_dispatch_per_pass
|
|
2567
|
+
# #522: one terminal-state cache for this whole pass, so a backlog full of
|
|
2568
|
+
# already-merged rows (the #349 ×4 case) costs one gh lookup per issue, not
|
|
2569
|
+
# one per row revisited.
|
|
2570
|
+
terminal_cache: dict = {}
|
|
2571
|
+
dispatched: list = []
|
|
2572
|
+
for completed in eligible:
|
|
2573
|
+
if cap and len(dispatched) >= cap:
|
|
2574
|
+
break
|
|
2575
|
+
review = dispatch_review(
|
|
2576
|
+
completed, board, config, now=now, terminal_cache=terminal_cache
|
|
2577
|
+
)
|
|
2578
|
+
if review is not None:
|
|
2579
|
+
completed.review_state = "dispatched"
|
|
2580
|
+
dispatched.append(review)
|
|
2581
|
+
# On failure leave review_state as "pending" so the next pass retries.
|
|
2582
|
+
# Terminal rows are marked review_state="done" inside dispatch_review
|
|
2583
|
+
# (#522), dropping them from `eligible` on the next pass.
|
|
2584
|
+
|
|
2585
|
+
held = sum(1 for c in eligible if c.review_state in (None, "pending"))
|
|
2586
|
+
if held:
|
|
2587
|
+
logger.info(
|
|
2588
|
+
"review dispatch cap: dispatched %d this pass, %d held for next "
|
|
2589
|
+
"pass (reviews.max_auto_dispatch_per_pass=%d).",
|
|
2590
|
+
len(dispatched),
|
|
2591
|
+
held,
|
|
2592
|
+
cap,
|
|
2593
|
+
)
|
|
2594
|
+
return dispatched
|
|
2595
|
+
|
|
2596
|
+
|
|
2597
|
+
# ── Scoped re-review (#1476) ─────────────────────────────────────────────────
|
|
2598
|
+
#
|
|
2599
|
+
# When a conflict-fix rebase changes content under an already-`approve`d
|
|
2600
|
+
# review (the branch's patch-id no longer matches the one the review covered
|
|
2601
|
+
# — see #1475), `has_approved_review` correctly voids the stale approval, but
|
|
2602
|
+
# the only way to get an approval back today is a FULL re-review of the whole
|
|
2603
|
+
# PR — even when the conflict-fix resolution touched a handful of lines. The
|
|
2604
|
+
# functions below dispatch a review scoped to just that resolution delta
|
|
2605
|
+
# instead: the reviewer is told the PR was already approved, handed a diff
|
|
2606
|
+
# *of diffs* showing exactly what the rebase changed, and asked to rule on
|
|
2607
|
+
# that alone. `coord.merge_queue.find_scoped_review_candidate` /
|
|
2608
|
+
# `only_conflict_fix_since_review` gate when this path is eligible; a
|
|
2609
|
+
# request-changes verdict here is a completely ordinary `type="review"`
|
|
2610
|
+
# assignment (same `review_of_assignment_id` chain, same REVIEW_VERDICT
|
|
2611
|
+
# parsing), so the existing fix/re-review auto-loop drives it identically to
|
|
2612
|
+
# a full review — nothing downstream needs to know the review was scoped.
|
|
2613
|
+
|
|
2614
|
+
|
|
2615
|
+
def compute_resolution_delta(old_diff_text: str | None, new_diff_text: str | None) -> str | None:
|
|
2616
|
+
"""Return a unified diff *between* two full unified diffs (#1476).
|
|
2617
|
+
|
|
2618
|
+
A diff of diffs: treats each of *old_diff_text* (the diff a prior review
|
|
2619
|
+
approved) and *new_diff_text* (the branch's current diff) as a plain text
|
|
2620
|
+
blob and runs :mod:`difflib` over them. The result shows exactly the
|
|
2621
|
+
lines a conflict-fix resolution touched — typically a couple of hunks —
|
|
2622
|
+
instead of forcing a reviewer to re-read the whole PR to find them.
|
|
2623
|
+
|
|
2624
|
+
Returns ``None`` when either input is missing/blank (nothing to scope a
|
|
2625
|
+
review around — the caller must fall back to a full review) or when the
|
|
2626
|
+
two diffs are textually identical (no delta to show — shouldn't happen
|
|
2627
|
+
once the caller has already confirmed the patch-ids differ, but this
|
|
2628
|
+
fails safe rather than dispatching a review with an empty "what changed"
|
|
2629
|
+
section).
|
|
2630
|
+
"""
|
|
2631
|
+
if not old_diff_text or not old_diff_text.strip():
|
|
2632
|
+
return None
|
|
2633
|
+
if not new_diff_text or not new_diff_text.strip():
|
|
2634
|
+
return None
|
|
2635
|
+
old_lines = old_diff_text.splitlines(keepends=True)
|
|
2636
|
+
new_lines = new_diff_text.splitlines(keepends=True)
|
|
2637
|
+
delta = list(difflib.unified_diff(
|
|
2638
|
+
old_lines, new_lines,
|
|
2639
|
+
fromfile="previously-reviewed diff",
|
|
2640
|
+
tofile="current diff (post conflict-fix)",
|
|
2641
|
+
))
|
|
2642
|
+
if not delta:
|
|
2643
|
+
return None
|
|
2644
|
+
return "".join(delta)
|
|
2645
|
+
|
|
2646
|
+
|
|
2647
|
+
def build_scoped_review_briefing(
|
|
2648
|
+
*,
|
|
2649
|
+
pr_number: int | None,
|
|
2650
|
+
pr_url: str | None,
|
|
2651
|
+
repo_github: str,
|
|
2652
|
+
repo_name: str,
|
|
2653
|
+
issue_number: int,
|
|
2654
|
+
issue_title: str,
|
|
2655
|
+
branch: str | None,
|
|
2656
|
+
resolution_delta: str,
|
|
2657
|
+
default_branch: str = "main",
|
|
2658
|
+
) -> str:
|
|
2659
|
+
"""Assemble a SCOPED re-review briefing (#1476). Pure function — testable.
|
|
2660
|
+
|
|
2661
|
+
Dispatched when a conflict-fix rebase changed content under an
|
|
2662
|
+
already-approved review with no other intervening work/fix commit
|
|
2663
|
+
(``coord.merge_queue.only_conflict_fix_since_review``). The reviewer is
|
|
2664
|
+
told the PR was already approved — it must not re-derive a verdict
|
|
2665
|
+
already reached — and is handed ONLY the resolution delta
|
|
2666
|
+
(:func:`compute_resolution_delta`) to rule on. This is the whole point:
|
|
2667
|
+
a ~1300-line PR whose conflict-fix resolution was two hunks costs a
|
|
2668
|
+
~15-line read, not a full re-review (the #1453 motivating case).
|
|
2669
|
+
"""
|
|
2670
|
+
lines: list[str] = []
|
|
2671
|
+
lines.append(f"# Scoped re-review: {repo_github} PR #{pr_number}")
|
|
2672
|
+
lines.append("")
|
|
2673
|
+
lines.append(
|
|
2674
|
+
f"You are re-reviewing issue #{issue_number}: {issue_title}. This PR "
|
|
2675
|
+
"was **already approved** by a previous review. Since then, an "
|
|
2676
|
+
"automated conflict-fix worker rebased the branch onto its target "
|
|
2677
|
+
"branch to resolve a merge conflict, and that rebase changed "
|
|
2678
|
+
"content — so the branch's content-fingerprint no longer matches "
|
|
2679
|
+
"what the prior review covered, and the approval was voided."
|
|
2680
|
+
)
|
|
2681
|
+
lines.append("")
|
|
2682
|
+
lines.append(
|
|
2683
|
+
"**You do NOT need to re-review the whole PR.** Everything the "
|
|
2684
|
+
"prior review already approved stands — do not re-litigate it and "
|
|
2685
|
+
"do not raise new findings about code the resolution delta below "
|
|
2686
|
+
"doesn't touch. Your ONLY job is to judge whether the conflict-fix "
|
|
2687
|
+
"resolution itself introduced a real bug or silently dropped "
|
|
2688
|
+
"something either side of the conflict needed."
|
|
2689
|
+
)
|
|
2690
|
+
lines.append("")
|
|
2691
|
+
lines.append("## Context")
|
|
2692
|
+
lines.append(f"- Repo: {repo_github} (local name: {repo_name})")
|
|
2693
|
+
lines.append(f"- Branch: {branch or '(unknown)'}")
|
|
2694
|
+
if pr_url:
|
|
2695
|
+
lines.append(f"- PR URL: {pr_url}")
|
|
2696
|
+
lines.append("")
|
|
2697
|
+
lines.append("## Resolution delta (review THIS)")
|
|
2698
|
+
lines.append("")
|
|
2699
|
+
lines.append(
|
|
2700
|
+
"This is a diff *of diffs* — the difference between the diff the "
|
|
2701
|
+
"prior review approved and the branch's current diff, computed "
|
|
2702
|
+
"with `git patch-id`-fingerprinted content that changed under the "
|
|
2703
|
+
"conflict-fix rebase. A line starting with `-` was in the "
|
|
2704
|
+
"previously-approved diff and is gone now; a line starting with "
|
|
2705
|
+
"`+` is new since the approval. This is exactly what the "
|
|
2706
|
+
"conflict-fix resolution changed — nothing else in the PR did."
|
|
2707
|
+
)
|
|
2708
|
+
lines.append("")
|
|
2709
|
+
lines.append("```diff")
|
|
2710
|
+
lines.append(resolution_delta.strip())
|
|
2711
|
+
lines.append("```")
|
|
2712
|
+
lines.append("")
|
|
2713
|
+
lines.append("## What to do")
|
|
2714
|
+
lines.append("")
|
|
2715
|
+
lines.append(
|
|
2716
|
+
"1. Read the resolution delta above. If a hunk doesn't make sense "
|
|
2717
|
+
"standalone, `git fetch origin && git diff origin/"
|
|
2718
|
+
f"{default_branch}...origin/{branch or 'HEAD'}` gets you the full "
|
|
2719
|
+
"current diff for extra context — but you should rarely need it."
|
|
2720
|
+
)
|
|
2721
|
+
lines.append(
|
|
2722
|
+
"2. `approve` unless the resolution itself introduces a genuine bug "
|
|
2723
|
+
"or silently drops content either side of the conflict needed. "
|
|
2724
|
+
"Do NOT block on style/nit findings about code outside the delta — "
|
|
2725
|
+
"that code was already approved."
|
|
2726
|
+
)
|
|
2727
|
+
lines.append(
|
|
2728
|
+
"3. Before you end your session, record your verdict TWICE — belt "
|
|
2729
|
+
"and braces, neither step substitutes for the other. FIRST, if the "
|
|
2730
|
+
"environment variable `COORD_ASSIGNMENT_ID` is set, write your full "
|
|
2731
|
+
"findings to a file and run `coord report-result --assignment "
|
|
2732
|
+
'"$COORD_ASSIGNMENT_ID" --status done --verdict '
|
|
2733
|
+
"approve|request-changes --body-file <file>` — this writes straight "
|
|
2734
|
+
"to the coordinator's board and is the authoritative record. If "
|
|
2735
|
+
"`COORD_ASSIGNMENT_ID` is unset, `coord` errors, or it's not on "
|
|
2736
|
+
"your PATH, say so plainly and move on to the required backup "
|
|
2737
|
+
"below regardless. THEN, at the END of your session, ALWAYS ALSO "
|
|
2738
|
+
"output your findings in this exact format as the PATH-independent "
|
|
2739
|
+
"backup (the coordinator posts the review to GitHub on your "
|
|
2740
|
+
"behalf — do NOT run any `gh` commands):"
|
|
2741
|
+
)
|
|
2742
|
+
lines.append("")
|
|
2743
|
+
lines.append("```")
|
|
2744
|
+
lines.append("REVIEW_VERDICT: approve")
|
|
2745
|
+
lines.append("REVIEW_BODY:")
|
|
2746
|
+
lines.append("<your full review text in markdown>")
|
|
2747
|
+
lines.append("END_REVIEW")
|
|
2748
|
+
lines.append("```")
|
|
2749
|
+
lines.append("")
|
|
2750
|
+
lines.append(
|
|
2751
|
+
"Use `REVIEW_VERDICT: request-changes` if the resolution introduced "
|
|
2752
|
+
"a real bug."
|
|
2753
|
+
)
|
|
2754
|
+
lines.append(
|
|
2755
|
+
"BODY STRUCTURE — same three headings as a normal review, always "
|
|
2756
|
+
"all three: `## Blocking findings`, `## Non-blocking concerns`, "
|
|
2757
|
+
"`## Nits`. Write the single line `None.` under a heading with "
|
|
2758
|
+
"nothing under it."
|
|
2759
|
+
)
|
|
2760
|
+
lines.append(
|
|
2761
|
+
"FORMAT CONTRACT — `REVIEW_VERDICT:`, `REVIEW_BODY:`, and "
|
|
2762
|
+
"`END_REVIEW` are parsed by machine: plain text at the start of "
|
|
2763
|
+
"their own line, no Markdown decoration. `END_REVIEW` is a HARD "
|
|
2764
|
+
"REQUIREMENT — an otherwise-complete review missing that exact "
|
|
2765
|
+
"line is discarded in its entirety."
|
|
2766
|
+
)
|
|
2767
|
+
return "\n".join(lines)
|
|
2768
|
+
|
|
2769
|
+
|
|
2770
|
+
def dispatch_scoped_review(
|
|
2771
|
+
entry,
|
|
2772
|
+
prior_review: Assignment,
|
|
2773
|
+
board: Board,
|
|
2774
|
+
config: Config,
|
|
2775
|
+
*,
|
|
2776
|
+
http_client: httpx.Client | None = None,
|
|
2777
|
+
now: float | None = None,
|
|
2778
|
+
diff_fetcher=None,
|
|
2779
|
+
branch_sha_fetcher=None,
|
|
2780
|
+
patch_id_computer=None,
|
|
2781
|
+
terminal_cache: dict | None = None,
|
|
2782
|
+
) -> Assignment | None:
|
|
2783
|
+
"""Dispatch a SCOPED re-review (#1476) for a merge entry whose approval
|
|
2784
|
+
was voided ONLY by a content-changing conflict-fix rebase.
|
|
2785
|
+
|
|
2786
|
+
Caller contract: only call this after
|
|
2787
|
+
:func:`coord.merge_queue.find_scoped_review_candidate` (its result is
|
|
2788
|
+
*prior_review*) and :func:`coord.merge_queue.only_conflict_fix_since_review`
|
|
2789
|
+
(the guardrail) have both confirmed this path applies — mirrors how
|
|
2790
|
+
:func:`dispatch_review` trusts :func:`dispatch_pending_reviews`'s
|
|
2791
|
+
eligibility filter rather than re-deriving it. *entry* is a
|
|
2792
|
+
``coord.merge_queue.QueuedMerge``.
|
|
2793
|
+
|
|
2794
|
+
*diff_fetcher* defaults to :func:`coord.github_ops.get_compare_diff`
|
|
2795
|
+
(``(repo, base, ref) -> str | None``); inject a stub in tests. Fetches
|
|
2796
|
+
the diff *prior_review* covered (``target_branch...prior_review.
|
|
2797
|
+
review_head_sha``) and the branch's current diff (``target_branch...
|
|
2798
|
+
entry.branch``), computes the resolution delta between them, and — only
|
|
2799
|
+
when that delta is non-empty — dispatches a review briefed on just the
|
|
2800
|
+
delta. Returns ``None`` (caller falls back to a full :func:`dispatch_review`)
|
|
2801
|
+
when either diff can't be fetched, the delta comes back empty, no
|
|
2802
|
+
reviewer machine is available, or every candidate agent rejects the
|
|
2803
|
+
dispatch — never guesses at scope from partial information.
|
|
2804
|
+
|
|
2805
|
+
Returns the new review Assignment (already appended to ``board.active``,
|
|
2806
|
+
with ``review_scoped=True`` and ``review_scope_base_sha=prior_review.
|
|
2807
|
+
review_head_sha`` for the #1476 audit trail) on success.
|
|
2808
|
+
|
|
2809
|
+
Applies the same two structural guards as :func:`dispatch_review` before
|
|
2810
|
+
doing any work: the #522 terminal-work chokepoint (issue closed / PR
|
|
2811
|
+
merged — pass a shared *terminal_cache* dict across a bulk pass the same
|
|
2812
|
+
way :func:`dispatch_pending_reviews` does) and the #437 TOS-compliance
|
|
2813
|
+
gate (refuses a ``human_attended_only`` provider). Reviewer-candidate
|
|
2814
|
+
ranking excludes the machine that actually authored *entry*'s branch
|
|
2815
|
+
(looked up on *board* via ``entry.assignment_id`` — the work assignment,
|
|
2816
|
+
not the prior reviewer) so the scoped review stays independent of the
|
|
2817
|
+
code it's judging, mirroring :func:`dispatch_review`'s
|
|
2818
|
+
``completed.machine_name`` contract.
|
|
2819
|
+
"""
|
|
2820
|
+
if not config.reviews.enabled or not config.reviews.auto_dispatch:
|
|
2821
|
+
return None
|
|
2822
|
+
if not prior_review.review_head_sha:
|
|
2823
|
+
return None
|
|
2824
|
+
|
|
2825
|
+
repo = config.repo(entry.repo_name)
|
|
2826
|
+
if repo is None:
|
|
2827
|
+
return None
|
|
2828
|
+
|
|
2829
|
+
# #522 (mirrored from dispatch_review): never (re)dispatch a review for
|
|
2830
|
+
# work that's already done on GitHub — issue closed OR PR merged. Best
|
|
2831
|
+
# effort — a small race window remains before the merge-queue entry is
|
|
2832
|
+
# cleaned up, same as the full-review path.
|
|
2833
|
+
if github_ops.work_is_terminal(
|
|
2834
|
+
repo.github, entry.issue_number, entry.branch, cache=terminal_cache
|
|
2835
|
+
):
|
|
2836
|
+
return None
|
|
2837
|
+
|
|
2838
|
+
# #437: STRUCTURAL TOS-COMPLIANCE GATE — mirrored from dispatch_review
|
|
2839
|
+
# (coord/review.py ~1463). Scoped reviews are dispatched from the same
|
|
2840
|
+
# unattended paths (reconcile()/coord notify) as a full review, so they
|
|
2841
|
+
# must be refused exactly the same way when the effective provider is
|
|
2842
|
+
# `human_attended_only` (interactive Claude Code via PTY, ToS §3.7).
|
|
2843
|
+
# Without this gate a repo/provider configured that way could have a
|
|
2844
|
+
# scoped review silently routed to it — the #1476 findings called this
|
|
2845
|
+
# out explicitly as a gap versus dispatch_review.
|
|
2846
|
+
# #1811: same review-only provider override as dispatch_review — see
|
|
2847
|
+
# its call site's comment for the precedence/no-op-when-unset rationale.
|
|
2848
|
+
from coord.providers import guard_unattended_dispatch # noqa: PLC0415
|
|
2849
|
+
try:
|
|
2850
|
+
review_provider_name = guard_unattended_dispatch(
|
|
2851
|
+
spec_provider=config.reviews.provider,
|
|
2852
|
+
repo_provider=repo.provider,
|
|
2853
|
+
providers_cfg=config.providers,
|
|
2854
|
+
models_cfg=config.models,
|
|
2855
|
+
where="auto-dispatch scoped review",
|
|
2856
|
+
)
|
|
2857
|
+
except ValueError as exc:
|
|
2858
|
+
log.warning("[review] skipping auto-dispatch scoped review: %s", exc)
|
|
2859
|
+
return None
|
|
2860
|
+
|
|
2861
|
+
base_branch = entry.target_branch or repo.default_branch
|
|
2862
|
+
|
|
2863
|
+
_diff = diff_fetcher or github_ops.get_compare_diff
|
|
2864
|
+
try:
|
|
2865
|
+
old_diff = _diff(repo.github, base_branch, prior_review.review_head_sha)
|
|
2866
|
+
except Exception: # noqa: BLE001 — fail-safe: unfetchable old diff → no scope
|
|
2867
|
+
old_diff = None
|
|
2868
|
+
try:
|
|
2869
|
+
new_diff = _diff(repo.github, base_branch, entry.branch)
|
|
2870
|
+
except Exception: # noqa: BLE001
|
|
2871
|
+
new_diff = None
|
|
2872
|
+
|
|
2873
|
+
delta = compute_resolution_delta(old_diff, new_diff)
|
|
2874
|
+
if delta is None:
|
|
2875
|
+
log.warning(
|
|
2876
|
+
"[review] scoped review for merge entry %s: could not compute a "
|
|
2877
|
+
"resolution delta (old/new diff unavailable or identical) — "
|
|
2878
|
+
"caller should fall back to a full review",
|
|
2879
|
+
entry.assignment_id,
|
|
2880
|
+
)
|
|
2881
|
+
return None
|
|
2882
|
+
|
|
2883
|
+
# #1476 fix: rank candidates against the machine that authored the code
|
|
2884
|
+
# under review — the WORK assignment behind *entry* — not the prior
|
|
2885
|
+
# reviewer's machine. ``QueuedMerge`` doesn't carry the worker's machine
|
|
2886
|
+
# name directly, so look up the work assignment on *board* by
|
|
2887
|
+
# ``entry.assignment_id``, exactly mirroring how ``dispatch_review``
|
|
2888
|
+
# passes ``completed.machine_name`` (``completed`` *is* the work
|
|
2889
|
+
# assignment there). Fall back to the prior reviewer's machine only if
|
|
2890
|
+
# the work assignment can no longer be found on the board (defensive;
|
|
2891
|
+
# keeps this fail-open rather than raising).
|
|
2892
|
+
worker_assignment = board.find_by_id(entry.assignment_id)
|
|
2893
|
+
worker_machine_name = (
|
|
2894
|
+
worker_assignment.machine_name if worker_assignment is not None
|
|
2895
|
+
else prior_review.machine_name
|
|
2896
|
+
)
|
|
2897
|
+
candidates = _ranked_reviewer_candidates(
|
|
2898
|
+
worker_machine_name, entry.repo_name, board, config
|
|
2899
|
+
)
|
|
2900
|
+
if not candidates:
|
|
2901
|
+
return None
|
|
2902
|
+
|
|
2903
|
+
review_model_alias = config.models.default
|
|
2904
|
+
review_model_wire = config.models.resolve(review_model_alias)
|
|
2905
|
+
|
|
2906
|
+
_get_sha = branch_sha_fetcher or github_ops.get_branch_sha
|
|
2907
|
+
review_head_sha: str | None = None
|
|
2908
|
+
try:
|
|
2909
|
+
review_head_sha = _get_sha(repo.github, entry.branch)
|
|
2910
|
+
except Exception: # noqa: BLE001 — fail-safe: missing SHA is not blocking
|
|
2911
|
+
pass
|
|
2912
|
+
|
|
2913
|
+
_compute_patch_id = patch_id_computer or github_ops.compute_patch_id
|
|
2914
|
+
review_patch_id: str | None = None
|
|
2915
|
+
try:
|
|
2916
|
+
review_patch_id = _compute_patch_id(new_diff)
|
|
2917
|
+
except Exception: # noqa: BLE001
|
|
2918
|
+
pass
|
|
2919
|
+
|
|
2920
|
+
client = http_client or httpx
|
|
2921
|
+
for machine, _same_as_worker in candidates:
|
|
2922
|
+
repo_path = machine.repo_path(entry.repo_name)
|
|
2923
|
+
if repo_path is None:
|
|
2924
|
+
continue
|
|
2925
|
+
|
|
2926
|
+
briefing = build_scoped_review_briefing(
|
|
2927
|
+
pr_number=entry.pr_number,
|
|
2928
|
+
pr_url=entry.pr_url,
|
|
2929
|
+
repo_github=repo.github,
|
|
2930
|
+
repo_name=repo.name,
|
|
2931
|
+
issue_number=entry.issue_number,
|
|
2932
|
+
issue_title=entry.issue_title,
|
|
2933
|
+
branch=entry.branch,
|
|
2934
|
+
resolution_delta=delta,
|
|
2935
|
+
default_branch=base_branch,
|
|
2936
|
+
)
|
|
2937
|
+
|
|
2938
|
+
payload = {
|
|
2939
|
+
"repo_name": entry.repo_name,
|
|
2940
|
+
"repo_path": repo_path,
|
|
2941
|
+
"issue_number": entry.issue_number,
|
|
2942
|
+
"issue_title": f"[scoped-review] {entry.issue_title}",
|
|
2943
|
+
"briefing": briefing,
|
|
2944
|
+
"files_allowed": [],
|
|
2945
|
+
"files_forbidden": [],
|
|
2946
|
+
"pull_repos": [],
|
|
2947
|
+
"type": "review",
|
|
2948
|
+
"model": review_model_wire,
|
|
2949
|
+
"system_prompt": REVIEWER_SYSTEM_PROMPT,
|
|
2950
|
+
"review_target": str(entry.pr_number) if entry.pr_number else entry.branch,
|
|
2951
|
+
"branch": base_branch or "main",
|
|
2952
|
+
}
|
|
2953
|
+
# #1811: mirror dispatch_review's wire-provider threading — see its
|
|
2954
|
+
# payload comment for why omitting this silently strands the
|
|
2955
|
+
# resolved provider at the TOS-gate check above.
|
|
2956
|
+
from coord.dispatch import _wire_payload_needs_provider_field # noqa: PLC0415
|
|
2957
|
+
|
|
2958
|
+
if review_provider_name and _wire_payload_needs_provider_field(
|
|
2959
|
+
review_provider_name, config,
|
|
2960
|
+
):
|
|
2961
|
+
payload["provider"] = review_provider_name
|
|
2962
|
+
|
|
2963
|
+
url = f"http://{machine.host}:{AGENT_PORT}/assign"
|
|
2964
|
+
try:
|
|
2965
|
+
resp = client.post(url, json=payload, timeout=15)
|
|
2966
|
+
resp.raise_for_status()
|
|
2967
|
+
agent_response = resp.json()
|
|
2968
|
+
except (httpx.HTTPError, httpx.TimeoutException) as exc:
|
|
2969
|
+
log.warning(
|
|
2970
|
+
"[review] scoped-review agent %s unreachable/rejected (%s) — "
|
|
2971
|
+
"trying next candidate",
|
|
2972
|
+
machine.name, exc,
|
|
2973
|
+
)
|
|
2974
|
+
continue
|
|
2975
|
+
|
|
2976
|
+
review_assignment = Assignment(
|
|
2977
|
+
machine_name=machine.name,
|
|
2978
|
+
repo_name=entry.repo_name,
|
|
2979
|
+
issue_number=entry.issue_number,
|
|
2980
|
+
issue_title=f"[scoped-review] {entry.issue_title}",
|
|
2981
|
+
files_allowed=[],
|
|
2982
|
+
files_forbidden=[],
|
|
2983
|
+
briefing=briefing,
|
|
2984
|
+
assignment_id=agent_response.get("id") or uuid.uuid4().hex[:12],
|
|
2985
|
+
status="running",
|
|
2986
|
+
branch=entry.branch,
|
|
2987
|
+
pr_url=entry.pr_url,
|
|
2988
|
+
dispatched_at=now if now is not None else time.time(),
|
|
2989
|
+
type="review",
|
|
2990
|
+
review_target=str(entry.pr_number) if entry.pr_number else entry.branch,
|
|
2991
|
+
# Same parent as the review being superseded — keeps the
|
|
2992
|
+
# existing work-chain / fix-loop machinery (has_approved_review,
|
|
2993
|
+
# auto_loop's request-changes dispatch) working unmodified.
|
|
2994
|
+
review_of_assignment_id=prior_review.review_of_assignment_id,
|
|
2995
|
+
model=review_model_alias,
|
|
2996
|
+
provider_name=review_provider_name,
|
|
2997
|
+
review_head_sha=review_head_sha,
|
|
2998
|
+
review_patch_id=review_patch_id,
|
|
2999
|
+
# #1476 audit trail.
|
|
3000
|
+
review_scoped=True,
|
|
3001
|
+
review_scope_base_sha=prior_review.review_head_sha,
|
|
3002
|
+
)
|
|
3003
|
+
board.active.append(review_assignment)
|
|
3004
|
+
|
|
3005
|
+
from coord.state import record_dispatched_assignment # noqa: PLC0415
|
|
3006
|
+
record_dispatched_assignment(
|
|
3007
|
+
assignment=review_assignment,
|
|
3008
|
+
repo_github=repo.github,
|
|
3009
|
+
)
|
|
3010
|
+
|
|
3011
|
+
return review_assignment
|
|
3012
|
+
|
|
3013
|
+
return None
|
|
3014
|
+
|
|
3015
|
+
|
|
3016
|
+
def dispatch_scoped_reviews_for_queue(
|
|
3017
|
+
board: Board,
|
|
3018
|
+
config: Config,
|
|
3019
|
+
*,
|
|
3020
|
+
queue_items: list | None = None,
|
|
3021
|
+
http_client: httpx.Client | None = None,
|
|
3022
|
+
now: float | None = None,
|
|
3023
|
+
diff_fetcher=None,
|
|
3024
|
+
branch_sha_fetcher=None,
|
|
3025
|
+
branch_patch_id_fetcher=None,
|
|
3026
|
+
patch_id_computer=None,
|
|
3027
|
+
) -> list[Assignment]:
|
|
3028
|
+
"""Scan the merge queue for entries eligible for a #1476 SCOPED
|
|
3029
|
+
re-review and dispatch one for each, instead of leaving them blocked on
|
|
3030
|
+
"review required but not approved" until a human notices and manually
|
|
3031
|
+
forces a full re-review.
|
|
3032
|
+
|
|
3033
|
+
Mirrors :func:`dispatch_pending_reviews`'s "bounded pass, caller
|
|
3034
|
+
persists the board" shape so it slots into the same
|
|
3035
|
+
``reconcile()``/``coord notify`` polling sites; unlike that function it
|
|
3036
|
+
also owns the merge-queue read/write itself (``queue_items`` defaults to
|
|
3037
|
+
:func:`coord.merge_queue.load_queue`, saved back at the end) since the
|
|
3038
|
+
scoped/full distinction is a property of the queue entry, not the board.
|
|
3039
|
+
It also mirrors that function's two review-flood-incident (2026-06-08)
|
|
3040
|
+
safety mechanisms — the ``reviews.flood_threshold`` surge gate and the
|
|
3041
|
+
``reviews.max_auto_dispatch_per_pass`` per-pass cap — so a batch of
|
|
3042
|
+
conflict-fix rebases completing together can't fire an unbounded burst
|
|
3043
|
+
of metered ``claude -p`` reviews in a single pass.
|
|
3044
|
+
|
|
3045
|
+
An entry is eligible when: it's ``PENDING`` and review-gated
|
|
3046
|
+
(:func:`coord.merge_queue.requires_review`); it does NOT already have an
|
|
3047
|
+
approved review (:func:`coord.merge_queue.has_approved_review` — a
|
|
3048
|
+
content-identical rebase already carries the approval forward and needs
|
|
3049
|
+
nothing further);
|
|
3050
|
+
:func:`coord.merge_queue.find_scoped_review_candidate` finds a prior
|
|
3051
|
+
`approve`d review voided ONLY by a content-changing rebase; and
|
|
3052
|
+
:func:`coord.merge_queue.only_conflict_fix_since_review` confirms no
|
|
3053
|
+
other work/fix commit intervened. Entries failing any of these are left
|
|
3054
|
+
untouched for the existing full-review paths to handle. A dedupe check
|
|
3055
|
+
skips entries where a review dispatched after the prior approval is
|
|
3056
|
+
already in flight or completed, so a slow reconcile loop can't fire two
|
|
3057
|
+
scoped reviews for the same voided approval.
|
|
3058
|
+
|
|
3059
|
+
Returns the dispatched review Assignments (already on ``board.active``).
|
|
3060
|
+
"""
|
|
3061
|
+
import os
|
|
3062
|
+
|
|
3063
|
+
from coord import merge_queue as mq # noqa: PLC0415
|
|
3064
|
+
|
|
3065
|
+
if not config.reviews.enabled or not config.reviews.auto_dispatch:
|
|
3066
|
+
return []
|
|
3067
|
+
|
|
3068
|
+
items = queue_items if queue_items is not None else mq.load_queue()
|
|
3069
|
+
_get_sha = branch_sha_fetcher or github_ops.get_branch_sha
|
|
3070
|
+
_get_branch_patch_id = branch_patch_id_fetcher or github_ops.get_branch_patch_id
|
|
3071
|
+
|
|
3072
|
+
eligible: list[tuple] = [] # (entry, prior_review)
|
|
3073
|
+
mutated = False
|
|
3074
|
+
for entry in items:
|
|
3075
|
+
if entry.state != mq.PENDING:
|
|
3076
|
+
continue
|
|
3077
|
+
if not mq.requires_review(entry, config):
|
|
3078
|
+
continue
|
|
3079
|
+
|
|
3080
|
+
if entry.branch_head_sha is None:
|
|
3081
|
+
try:
|
|
3082
|
+
entry.branch_head_sha = _get_sha(entry.repo_github, entry.branch)
|
|
3083
|
+
mutated = True
|
|
3084
|
+
except Exception: # noqa: BLE001 — fail-safe: leave unset
|
|
3085
|
+
pass
|
|
3086
|
+
if entry.branch_patch_id is None:
|
|
3087
|
+
try:
|
|
3088
|
+
entry.branch_patch_id = _get_branch_patch_id(
|
|
3089
|
+
entry.repo_github, entry.target_branch, entry.branch
|
|
3090
|
+
)
|
|
3091
|
+
mutated = True
|
|
3092
|
+
except Exception: # noqa: BLE001
|
|
3093
|
+
pass
|
|
3094
|
+
|
|
3095
|
+
if mq.has_approved_review(entry, board):
|
|
3096
|
+
continue # not stale, or a content-identical rebase covers it (#1475)
|
|
3097
|
+
|
|
3098
|
+
prior_review = mq.find_scoped_review_candidate(entry, board)
|
|
3099
|
+
if prior_review is None:
|
|
3100
|
+
continue # no scoped candidate — needs a full review, not this path
|
|
3101
|
+
|
|
3102
|
+
if not mq.only_conflict_fix_since_review(entry, board, prior_review):
|
|
3103
|
+
continue # guardrail: another commit intervened — full review required
|
|
3104
|
+
|
|
3105
|
+
pool = list(board.active) + list(board.completed)
|
|
3106
|
+
already_handled = any(
|
|
3107
|
+
a.type == "review"
|
|
3108
|
+
and a.review_of_assignment_id == prior_review.review_of_assignment_id
|
|
3109
|
+
and a.assignment_id != prior_review.assignment_id
|
|
3110
|
+
and (a.dispatched_at or 0) > (prior_review.dispatched_at or 0)
|
|
3111
|
+
for a in pool
|
|
3112
|
+
)
|
|
3113
|
+
if already_handled:
|
|
3114
|
+
continue
|
|
3115
|
+
|
|
3116
|
+
eligible.append((entry, prior_review))
|
|
3117
|
+
|
|
3118
|
+
def _persist() -> None:
|
|
3119
|
+
if mutated and queue_items is None:
|
|
3120
|
+
mq.save_queue(items)
|
|
3121
|
+
|
|
3122
|
+
if not eligible:
|
|
3123
|
+
_persist()
|
|
3124
|
+
return []
|
|
3125
|
+
|
|
3126
|
+
# Surge gate — same shape as dispatch_pending_reviews. A sudden surge is
|
|
3127
|
+
# the review-flood unmasking signature, so halt entirely and require a
|
|
3128
|
+
# human to clear the backlog or opt in.
|
|
3129
|
+
threshold = config.reviews.flood_threshold
|
|
3130
|
+
override = (
|
|
3131
|
+
config.reviews.allow_review_flood
|
|
3132
|
+
or os.environ.get("COORD_ALLOW_REVIEW_FLOOD") == "1"
|
|
3133
|
+
)
|
|
3134
|
+
if threshold and len(eligible) > threshold and not override:
|
|
3135
|
+
log.warning(
|
|
3136
|
+
"[review] scoped-review flood guard: %d merge-queue entries are "
|
|
3137
|
+
"eligible for a scoped re-review (> reviews.flood_threshold=%d). "
|
|
3138
|
+
"Refusing bulk dispatch to avoid a metered review flood. Clear "
|
|
3139
|
+
"the stale backlog, or set reviews.allow_review_flood: true (or "
|
|
3140
|
+
"COORD_ALLOW_REVIEW_FLOOD=1) to override.",
|
|
3141
|
+
len(eligible), threshold,
|
|
3142
|
+
)
|
|
3143
|
+
_persist()
|
|
3144
|
+
return []
|
|
3145
|
+
|
|
3146
|
+
# Per-pass cap — the remainder stay PENDING and are picked up next pass.
|
|
3147
|
+
cap = config.reviews.max_auto_dispatch_per_pass
|
|
3148
|
+
# #522: one terminal-state cache for this whole pass, mirrored from
|
|
3149
|
+
# dispatch_pending_reviews, so a backlog of already-merged entries costs
|
|
3150
|
+
# one gh lookup per issue, not one per entry revisited.
|
|
3151
|
+
terminal_cache: dict = {}
|
|
3152
|
+
dispatched: list[Assignment] = []
|
|
3153
|
+
for entry, prior_review in eligible:
|
|
3154
|
+
if cap and len(dispatched) >= cap:
|
|
3155
|
+
break
|
|
3156
|
+
review = dispatch_scoped_review(
|
|
3157
|
+
entry, prior_review, board, config,
|
|
3158
|
+
http_client=http_client,
|
|
3159
|
+
now=now,
|
|
3160
|
+
diff_fetcher=diff_fetcher,
|
|
3161
|
+
branch_sha_fetcher=branch_sha_fetcher,
|
|
3162
|
+
patch_id_computer=patch_id_computer,
|
|
3163
|
+
terminal_cache=terminal_cache,
|
|
3164
|
+
)
|
|
3165
|
+
if review is not None:
|
|
3166
|
+
dispatched.append(review)
|
|
3167
|
+
|
|
3168
|
+
_persist()
|
|
3169
|
+
return dispatched
|
|
3170
|
+
|
|
3171
|
+
|
|
3172
|
+
def _fetch_issue_body(repo_github: str, issue_number: int) -> str:
|
|
3173
|
+
"""Best-effort fetch of the issue body for context. Empty on failure."""
|
|
3174
|
+
try:
|
|
3175
|
+
import json
|
|
3176
|
+
raw = github_ops._gh(
|
|
3177
|
+
"issue", "view", str(issue_number),
|
|
3178
|
+
"--repo", repo_github,
|
|
3179
|
+
"--json", "body",
|
|
3180
|
+
)
|
|
3181
|
+
return json.loads(raw).get("body", "") or ""
|
|
3182
|
+
except (RuntimeError, ValueError):
|
|
3183
|
+
return ""
|
|
3184
|
+
|
|
3185
|
+
|
|
3186
|
+
def _fetch_issue_milestone_number(repo_github: str, issue_number: int) -> int | None:
|
|
3187
|
+
"""Best-effort fetch of the issue's GitHub Milestone number, or ``None``
|
|
3188
|
+
if it has none or the fetch fails (fail-open: #934's ``resolve_base_branch``
|
|
3189
|
+
falls back to ``default_branch`` when the milestone is unknown, same as
|
|
3190
|
+
when it's genuinely absent). Delegates to ``coord.branch_model.
|
|
3191
|
+
fetch_issue_milestone_number`` so every call site fails open the same way.
|
|
3192
|
+
"""
|
|
3193
|
+
from coord.branch_model import fetch_issue_milestone_number # noqa: PLC0415
|
|
3194
|
+
|
|
3195
|
+
return fetch_issue_milestone_number(repo_github, issue_number)
|
|
3196
|
+
|
|
3197
|
+
|
|
3198
|
+
# ── Headless fix dispatch (dashboard / phone API) ────────────────────────────
|
|
3199
|
+
|
|
3200
|
+
|
|
3201
|
+
def dispatch_headless_fix(
|
|
3202
|
+
work: Assignment,
|
|
3203
|
+
board: Board,
|
|
3204
|
+
config: "Config",
|
|
3205
|
+
*,
|
|
3206
|
+
parent_type: str = "work",
|
|
3207
|
+
http_client=None,
|
|
3208
|
+
) -> Assignment | None:
|
|
3209
|
+
"""Dispatch a headless (``claude -p``) fix worker for a stalled pipeline item.
|
|
3210
|
+
|
|
3211
|
+
Called from ``POST /api/pipeline/action action=dispatch_fix`` so the phone
|
|
3212
|
+
can unstick a test-fail or request-changes item without attending an
|
|
3213
|
+
interactive terminal session.
|
|
3214
|
+
|
|
3215
|
+
``work`` must be a ``type='work'`` assignment that already has a branch.
|
|
3216
|
+
``parent_type`` selects which failure to address:
|
|
3217
|
+
|
|
3218
|
+
* ``"work"`` — fix a test-gate failure. The briefing is built from
|
|
3219
|
+
``work.test_reason`` (recorded via ``coord test --fail --reason``).
|
|
3220
|
+
* ``"review"`` — fix a request-changes review verdict. The linked review
|
|
3221
|
+
assignment is located on the board and its findings are loaded via the
|
|
3222
|
+
multi-source chain in ``_load_review_findings`` (DB cache → local log →
|
|
3223
|
+
agent HTTP → GitHub message bus).
|
|
3224
|
+
|
|
3225
|
+
The fix worker is dispatched with ``target_branch=work.branch`` in the
|
|
3226
|
+
agent payload so it adds commits to the **existing** ``issue-N-*`` branch
|
|
3227
|
+
rather than branching fresh off main.
|
|
3228
|
+
|
|
3229
|
+
Returns the new fix ``Assignment`` (already added to ``board.active``),
|
|
3230
|
+
or ``None`` on failure (no capable machine, branch missing, findings
|
|
3231
|
+
unresolvable, or iteration limit reached).
|
|
3232
|
+
"""
|
|
3233
|
+
from types import SimpleNamespace as _NS # noqa: PLC0415
|
|
3234
|
+
|
|
3235
|
+
# Deferred imports to avoid a circular-import cycle:
|
|
3236
|
+
# review.py is imported at module level by auto_loop.py, so we cannot
|
|
3237
|
+
# import auto_loop at review.py's module level.
|
|
3238
|
+
from coord.auto_loop import ( # noqa: PLC0415
|
|
3239
|
+
_build_fix_briefing,
|
|
3240
|
+
_dispatch_fix,
|
|
3241
|
+
_fix_model_for_iteration,
|
|
3242
|
+
_load_review_findings,
|
|
3243
|
+
_work_is_terminal,
|
|
3244
|
+
)
|
|
3245
|
+
from coord.state import issue_context_block # noqa: PLC0415
|
|
3246
|
+
|
|
3247
|
+
if not work.branch:
|
|
3248
|
+
return None
|
|
3249
|
+
|
|
3250
|
+
if _work_is_terminal(work, config):
|
|
3251
|
+
return None
|
|
3252
|
+
|
|
3253
|
+
next_iteration = (work.review_iteration or 0) + 1
|
|
3254
|
+
max_iter = config.pipeline.max_review_iterations
|
|
3255
|
+
if next_iteration > max_iter:
|
|
3256
|
+
return None
|
|
3257
|
+
|
|
3258
|
+
if parent_type == "review":
|
|
3259
|
+
# Find the review assignment linked to this work and load its findings.
|
|
3260
|
+
all_assignments = list(board.active) + list(board.completed)
|
|
3261
|
+
review_a: Assignment | None = next(
|
|
3262
|
+
(
|
|
3263
|
+
a for a in all_assignments
|
|
3264
|
+
if a.review_of_assignment_id == work.assignment_id
|
|
3265
|
+
and a.type == "review"
|
|
3266
|
+
),
|
|
3267
|
+
None,
|
|
3268
|
+
)
|
|
3269
|
+
if review_a is None:
|
|
3270
|
+
return None
|
|
3271
|
+
|
|
3272
|
+
repo = config.repo(work.repo_name)
|
|
3273
|
+
repo_github = repo.github if repo is not None else None
|
|
3274
|
+
findings = _load_review_findings(
|
|
3275
|
+
review_a,
|
|
3276
|
+
None, # no local log path on the dashboard machine
|
|
3277
|
+
None, # no remote agent host — let GitHub fallback handle it
|
|
3278
|
+
repo_github=repo_github,
|
|
3279
|
+
)
|
|
3280
|
+
if findings is not None:
|
|
3281
|
+
findings_obj = findings
|
|
3282
|
+
else:
|
|
3283
|
+
# Fallback: generic pointer so the worker can still proceed.
|
|
3284
|
+
verdict = getattr(review_a, "review_verdict", None) or "request-changes"
|
|
3285
|
+
findings_obj = _NS(body=(
|
|
3286
|
+
f"(No structured findings were captured for review "
|
|
3287
|
+
f"{review_a.assignment_id}.) "
|
|
3288
|
+
f"The review verdict was {verdict!r}. "
|
|
3289
|
+
"Read the reviewer's feedback on the PR / issue comments and "
|
|
3290
|
+
"address every blocking item before pushing."
|
|
3291
|
+
))
|
|
3292
|
+
else:
|
|
3293
|
+
# parent_type == "work": test-gate failure.
|
|
3294
|
+
test_story = (getattr(work, "test_reason", None) or "").strip()
|
|
3295
|
+
if test_story:
|
|
3296
|
+
findings_obj = _NS(body=(
|
|
3297
|
+
"The manual smoke test FAILED. The operator reported:\n\n"
|
|
3298
|
+
f"> {test_story}\n\n"
|
|
3299
|
+
"Reproduce the failure, fix the root cause, and re-validate "
|
|
3300
|
+
"before pushing."
|
|
3301
|
+
))
|
|
3302
|
+
else:
|
|
3303
|
+
findings_obj = _NS(body=(
|
|
3304
|
+
"The manual smoke test FAILED (no reason text was recorded). "
|
|
3305
|
+
"Pull the branch, reproduce the failure the operator hit, "
|
|
3306
|
+
"and fix the root cause before pushing."
|
|
3307
|
+
))
|
|
3308
|
+
|
|
3309
|
+
briefing = (
|
|
3310
|
+
issue_context_block(work.repo_name, work.issue_number)
|
|
3311
|
+
+ _build_fix_briefing(work, findings_obj, next_iteration, max_iter)
|
|
3312
|
+
)
|
|
3313
|
+
model = _fix_model_for_iteration(config, next_iteration)
|
|
3314
|
+
return _dispatch_fix(
|
|
3315
|
+
work, briefing, board, config, next_iteration,
|
|
3316
|
+
model=model, http_client=http_client,
|
|
3317
|
+
)
|