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/auto_loop.py
ADDED
|
@@ -0,0 +1,1440 @@
|
|
|
1
|
+
"""Auto-loop: drive the review → fix → re-review cycle until clean.
|
|
2
|
+
|
|
3
|
+
When a **review** assignment completes with a verdict of ``request-changes``,
|
|
4
|
+
this module dispatches a fix worker on the same branch with the reviewer's
|
|
5
|
+
findings as the briefing. When the fix worker finishes, the normal review
|
|
6
|
+
dispatch machinery (reconcile / notify) fires another review automatically —
|
|
7
|
+
creating a closed loop.
|
|
8
|
+
|
|
9
|
+
The loop terminates when:
|
|
10
|
+
- A review approves the changes (verdict = ``approve``)
|
|
11
|
+
- The iteration count hits ``pipeline.max_review_iterations``
|
|
12
|
+
- A fix worker fails to dispatch (agent unreachable, no capable machine, etc.)
|
|
13
|
+
|
|
14
|
+
Config (coordinator.yml)::
|
|
15
|
+
|
|
16
|
+
pipeline:
|
|
17
|
+
auto_loop: true # default true
|
|
18
|
+
max_review_iterations: 3 # default 3
|
|
19
|
+
|
|
20
|
+
Integration:
|
|
21
|
+
Called from ``coord.notify.run()`` after review completion transitions are
|
|
22
|
+
posted. ``run_for_review_transition`` loads the board, processes the review,
|
|
23
|
+
saves if a fix was dispatched, and returns a list of :class:`LoopAction`
|
|
24
|
+
for logging.
|
|
25
|
+
|
|
26
|
+
Data model:
|
|
27
|
+
``Assignment.review_iteration`` tracks the fix-round number. The original
|
|
28
|
+
work assignment has ``review_iteration=0``. Each fix worker gets the
|
|
29
|
+
previous worker's iteration + 1. When the auto-loop sees a review that
|
|
30
|
+
requests changes and the reviewed work's ``review_iteration >=
|
|
31
|
+
max_review_iterations``, it posts a notice to GitHub and stops.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import logging
|
|
37
|
+
import time
|
|
38
|
+
import uuid
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
|
|
41
|
+
import httpx
|
|
42
|
+
|
|
43
|
+
from coord.config import Config
|
|
44
|
+
from coord.dispatch import AGENT_PORT
|
|
45
|
+
from coord import github_ops
|
|
46
|
+
from coord.models import Assignment, Board
|
|
47
|
+
from coord.review import (
|
|
48
|
+
ReviewFindings,
|
|
49
|
+
blocking_findings_confirmed_absent,
|
|
50
|
+
dispatch_review,
|
|
51
|
+
estimate_review_counts,
|
|
52
|
+
parse_review_from_agent,
|
|
53
|
+
parse_review_from_log,
|
|
54
|
+
)
|
|
55
|
+
from coord.board_service import read_board, write_board
|
|
56
|
+
from coord.state import record_dispatched_assignment
|
|
57
|
+
from coord.test_author import TEST_AUTHOR_SYSTEM_PROMPT, test_author_deny_commands
|
|
58
|
+
|
|
59
|
+
log = logging.getLogger(__name__)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# #1176 review: every assignment ``type`` value ``_dispatch_fix`` can emit,
|
|
63
|
+
# kept in sync with the ``fix_type`` mapping in that function. Exposed so
|
|
64
|
+
# ``coord.notify``'s fix-completion detector doesn't hardcode ``"work"`` and
|
|
65
|
+
# silently miss a newer fix-dispatch type — the same class of bug as #1141
|
|
66
|
+
# ("test-author was never added to WORK_LIKE_TYPES").
|
|
67
|
+
FIX_DISPATCH_TYPES: frozenset[str] = frozenset({"work", "test-author"})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Every :class:`LoopAction` kind whose production means the in-memory board was
|
|
71
|
+
# mutated and must be written back. A fix was dispatched (new assignment row),
|
|
72
|
+
# an approve was parsed (so ``review_verdict`` is persisted for the merge gate,
|
|
73
|
+
# #253), an advisory-only review advanced the pipeline (#476 — ``review_verdict``
|
|
74
|
+
# flips to approve + ``review_state="done"`` so the merge gate unblocks; without
|
|
75
|
+
# this the gate suppresses the fix but the advance is never persisted and the PR
|
|
76
|
+
# silently can't merge), the work was found terminal (#522), or (#1663) a
|
|
77
|
+
# request-changes verdict was propagated onto the parent row without a fix
|
|
78
|
+
# dispatch.
|
|
79
|
+
#
|
|
80
|
+
# #1622: module-level rather than a local in ``_run_for_review_transition``
|
|
81
|
+
# because ``coord fix`` is now a second caller of
|
|
82
|
+
# :func:`process_review_completion` and has the same persist obligation. Two
|
|
83
|
+
# copies of this tuple is exactly the drift shape #1601/#1624 keep hitting.
|
|
84
|
+
PERSIST_ACTION_KINDS: tuple[str, ...] = (
|
|
85
|
+
"fix_dispatched", "approved", "approved_with_nits", "terminal_skip",
|
|
86
|
+
"verdict_propagated",
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# ── Action reporting ──────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class LoopAction:
|
|
94
|
+
"""One step taken by the auto-loop, for logging and test assertions."""
|
|
95
|
+
|
|
96
|
+
kind: str
|
|
97
|
+
"""One of:
|
|
98
|
+
- ``"fix_dispatched"`` — a fix worker was dispatched
|
|
99
|
+
- ``"approved"`` — review approved; no further action needed
|
|
100
|
+
- ``"approved_with_nits"`` — review said request-changes but flagged no
|
|
101
|
+
blocking findings (#476); pipeline advanced and
|
|
102
|
+
no fix dispatched
|
|
103
|
+
- ``"max_iterations"`` — loop stopped; user intervention required
|
|
104
|
+
- ``"no_findings"`` — log had no structured REVIEW_VERDICT block
|
|
105
|
+
- ``"no_work_found"`` — could not locate the work assignment on the board
|
|
106
|
+
- ``"disabled"`` — auto_loop is disabled in config
|
|
107
|
+
- ``"review_dispatched"`` — a re-review was dispatched after a fix worker completed
|
|
108
|
+
- ``"iteration_cap_hit"`` — fix.review_iteration >= max_review_iterations;
|
|
109
|
+
not dispatching another review
|
|
110
|
+
- ``"terminal_skip"`` — the work's issue is already closed or its PR is
|
|
111
|
+
already merged; no fix/review dispatched (#522)
|
|
112
|
+
- ``"interactive_skip"`` — the fix was an interactive (claude-pty) session;
|
|
113
|
+
its re-review is human-attended, so no headless
|
|
114
|
+
review was dispatched (#555)
|
|
115
|
+
- ``"test_gate_held"`` — pipeline.default_gates orders test before review
|
|
116
|
+
and the fix's test verdict isn't in yet
|
|
117
|
+
(``running``/``None``/``failed``); review_state
|
|
118
|
+
was set to ``"pending"`` so
|
|
119
|
+
``dispatch_pending_reviews`` picks it up once a
|
|
120
|
+
``passed``/``skipped`` verdict lands (#1612)
|
|
121
|
+
- ``"verdict_propagated"`` — #1663: the ``request-changes`` verdict was
|
|
122
|
+
written onto the parent work row but NO fix
|
|
123
|
+
worker was dispatched, because the caller asked
|
|
124
|
+
for bookkeeping only (``dispatch_fixes=False``
|
|
125
|
+
— the daemon drain, which must never spawn a
|
|
126
|
+
fix worker; #476/#477)
|
|
127
|
+
"""
|
|
128
|
+
assignment_id: str | None
|
|
129
|
+
detail: str = ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── Terminal-state guard (#522) ───────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
def _work_is_terminal(
|
|
135
|
+
work: Assignment,
|
|
136
|
+
config: Config,
|
|
137
|
+
*,
|
|
138
|
+
cache: dict | None = None,
|
|
139
|
+
) -> bool:
|
|
140
|
+
"""True when *work* is already done on GitHub and must not be re-dispatched.
|
|
141
|
+
|
|
142
|
+
Thin Assignment/Config-shaped wrapper over
|
|
143
|
+
:func:`coord.github_ops.work_is_terminal` (the shared chokepoint guard,
|
|
144
|
+
#522) — resolves the repo's GitHub slug and delegates. Fail-open.
|
|
145
|
+
"""
|
|
146
|
+
repo = config.repo(work.repo_name)
|
|
147
|
+
if repo is None or not repo.github:
|
|
148
|
+
return False
|
|
149
|
+
|
|
150
|
+
from coord import github_ops # noqa: PLC0415
|
|
151
|
+
|
|
152
|
+
return github_ops.work_is_terminal(
|
|
153
|
+
repo.github, work.issue_number, work.branch, cache=cache
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ── Core logic ────────────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
def _load_review_findings(
|
|
160
|
+
review: Assignment,
|
|
161
|
+
log_path: str | None,
|
|
162
|
+
machine_host: str | None,
|
|
163
|
+
repo_github: str | None = None,
|
|
164
|
+
) -> ReviewFindings | None:
|
|
165
|
+
"""Resolve a reviewer's structured findings.
|
|
166
|
+
|
|
167
|
+
Resolution order, cheapest first:
|
|
168
|
+
1. **DB cache** — `notify` (or `report-result --body-file`) populates
|
|
169
|
+
`review_findings` on the row. Hit means zero I/O.
|
|
170
|
+
2. **Local log file** — works when the review ran on this machine.
|
|
171
|
+
3. **Agent HTTP `/logs/<id>`** — fetches the worker's full log
|
|
172
|
+
from the remote agent (claude -p reviews on another machine).
|
|
173
|
+
4. **GitHub message bus** — when `repo_github` is supplied, recover the
|
|
174
|
+
findings posted to the issue under a `coord:review-findings` marker.
|
|
175
|
+
This is the cross-machine path for INTERACTIVE (claude-pty) reviews,
|
|
176
|
+
which have no parseable log and may not be in this machine's DB.
|
|
177
|
+
|
|
178
|
+
Returns `None` only when ALL sources fail.
|
|
179
|
+
"""
|
|
180
|
+
# 1. DB cache — fastest.
|
|
181
|
+
if review.assignment_id:
|
|
182
|
+
try:
|
|
183
|
+
from coord.state import load_assignment_review_findings # noqa: PLC0415
|
|
184
|
+
cached = load_assignment_review_findings(review.assignment_id)
|
|
185
|
+
except Exception as exc: # noqa: BLE001
|
|
186
|
+
log.debug(
|
|
187
|
+
"auto_loop: DB cache lookup failed for %s: %s",
|
|
188
|
+
review.assignment_id, exc,
|
|
189
|
+
)
|
|
190
|
+
cached = None
|
|
191
|
+
if cached is not None:
|
|
192
|
+
verdict, body = cached
|
|
193
|
+
return ReviewFindings(verdict=verdict, body=body)
|
|
194
|
+
|
|
195
|
+
# 2. Local log file.
|
|
196
|
+
findings = parse_review_from_log(log_path) if log_path else None
|
|
197
|
+
if findings is not None:
|
|
198
|
+
return findings
|
|
199
|
+
|
|
200
|
+
# 3. Agent HTTP fallback.
|
|
201
|
+
if machine_host:
|
|
202
|
+
try:
|
|
203
|
+
findings = parse_review_from_agent(machine_host, review.assignment_id or "")
|
|
204
|
+
except Exception as exc: # noqa: BLE001
|
|
205
|
+
log.warning(
|
|
206
|
+
"auto_loop: failed to fetch review log from agent %s for %s: %s",
|
|
207
|
+
machine_host, review.assignment_id, exc,
|
|
208
|
+
)
|
|
209
|
+
findings = None
|
|
210
|
+
if findings is not None:
|
|
211
|
+
return findings
|
|
212
|
+
|
|
213
|
+
# 4. GitHub message bus — works on ANY machine (no shared DB / local log
|
|
214
|
+
# needed). Interactive (claude-pty) reviews post their full body to the
|
|
215
|
+
# issue under a `coord:review-findings` marker via `--body-file`; recover
|
|
216
|
+
# it here when the review ran elsewhere. This is the cross-machine path.
|
|
217
|
+
issue_number = getattr(review, "issue_number", None)
|
|
218
|
+
if repo_github and issue_number and review.assignment_id:
|
|
219
|
+
try:
|
|
220
|
+
from coord.review import fetch_review_findings_from_github # noqa: PLC0415
|
|
221
|
+
gh_findings = fetch_review_findings_from_github(
|
|
222
|
+
repo_github, int(issue_number), review.assignment_id,
|
|
223
|
+
)
|
|
224
|
+
except Exception as exc: # noqa: BLE001
|
|
225
|
+
log.debug(
|
|
226
|
+
"auto_loop: GitHub findings fetch failed for %s: %s",
|
|
227
|
+
review.assignment_id, exc,
|
|
228
|
+
)
|
|
229
|
+
gh_findings = None
|
|
230
|
+
if gh_findings is not None:
|
|
231
|
+
return gh_findings
|
|
232
|
+
|
|
233
|
+
return findings
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def process_review_completion(
|
|
237
|
+
review: Assignment,
|
|
238
|
+
board: Board,
|
|
239
|
+
config: Config,
|
|
240
|
+
*,
|
|
241
|
+
log_path: str | None = None,
|
|
242
|
+
machine_host: str | None = None,
|
|
243
|
+
http_client: httpx.Client | None = None,
|
|
244
|
+
terminal_cache: dict | None = None,
|
|
245
|
+
dispatch_fixes: bool = True,
|
|
246
|
+
) -> list[LoopAction]:
|
|
247
|
+
"""Process a completed review assignment through the auto-loop.
|
|
248
|
+
|
|
249
|
+
Parses the reviewer's verdict (local log or agent HTTP fallback when
|
|
250
|
+
*machine_host* is supplied), then either:
|
|
251
|
+
|
|
252
|
+
- Returns an ``approved`` action (no side effects) if verdict is
|
|
253
|
+
``approve``.
|
|
254
|
+
- Dispatches a fix worker (mutates *board*) if verdict is
|
|
255
|
+
``request-changes`` and the iteration limit has not been reached.
|
|
256
|
+
- Posts a GitHub notice and returns a ``max_iterations`` action when the
|
|
257
|
+
loop has run too many times.
|
|
258
|
+
|
|
259
|
+
The caller is responsible for persisting the board after this returns.
|
|
260
|
+
|
|
261
|
+
**#1663 —** *dispatch_fixes* is the bookkeeping/dispatch seam. With
|
|
262
|
+
``dispatch_fixes=False`` every verdict-determination step still runs (the
|
|
263
|
+
#476 approve-with-nits gate, the #1456 fail-closed rule, the
|
|
264
|
+
``review_verdict`` capture) and the verdict is still propagated onto the
|
|
265
|
+
parent work row via :func:`propagate_review_verdict` — but a genuine
|
|
266
|
+
``request-changes`` returns ``verdict_propagated`` instead of entering
|
|
267
|
+
:func:`_dispatch_fix_for_review`. No metered worker is ever spawned. This
|
|
268
|
+
is what the daemon drain calls: the drain's responsibility table puts the
|
|
269
|
+
parent-row write squarely in the "bookkeeping, no race, no cost if
|
|
270
|
+
repeated" class and fix dispatch squarely outside it, and before #1663 the
|
|
271
|
+
only way to get the former was to accept the latter.
|
|
272
|
+
"""
|
|
273
|
+
if not config.pipeline.auto_loop:
|
|
274
|
+
return [LoopAction(kind="disabled", assignment_id=review.assignment_id)]
|
|
275
|
+
|
|
276
|
+
_rg = None
|
|
277
|
+
try:
|
|
278
|
+
_rc = config.repo(review.repo_name)
|
|
279
|
+
_rg = _rc.github if _rc is not None else None
|
|
280
|
+
except Exception: # noqa: BLE001
|
|
281
|
+
_rg = None
|
|
282
|
+
findings = _load_review_findings(review, log_path, machine_host, repo_github=_rg)
|
|
283
|
+
if findings is None:
|
|
284
|
+
log.debug(
|
|
285
|
+
"auto_loop: no structured REVIEW_VERDICT for %s (log=%r, host=%r) — skipping",
|
|
286
|
+
review.assignment_id, log_path, machine_host,
|
|
287
|
+
)
|
|
288
|
+
return [LoopAction(
|
|
289
|
+
kind="no_findings",
|
|
290
|
+
assignment_id=review.assignment_id,
|
|
291
|
+
detail=(
|
|
292
|
+
f"No structured review output (log={log_path!r}, host={machine_host!r})"
|
|
293
|
+
),
|
|
294
|
+
)]
|
|
295
|
+
|
|
296
|
+
# #253: persist the parsed verdict on the review assignment so the merge
|
|
297
|
+
# gate can refuse to merge work whose review hasn't approved.
|
|
298
|
+
review.review_verdict = findings.verdict
|
|
299
|
+
|
|
300
|
+
if findings.verdict == "approve":
|
|
301
|
+
return _advance_pipeline(
|
|
302
|
+
review, board, config,
|
|
303
|
+
kind="approved",
|
|
304
|
+
detail="Review verdict: approve — pipeline advancing",
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# verdict == "request-changes". #476 decision gate: a request-changes
|
|
308
|
+
# verdict that flags NO blocking findings — only non-blocking observations
|
|
309
|
+
# or nits — must NOT trigger another fix+review cycle. Doing so churns an
|
|
310
|
+
# already-correct PR over cosmetic suggestions and burns the session budget
|
|
311
|
+
# (the 2026-06-11 #532 incident: 3 real fix rounds, then a 4th round
|
|
312
|
+
# dispatched over a single cosmetic one-liner the reviewer itself counted
|
|
313
|
+
# as non-blocking). Treat advisory-only request-changes as approve-with-
|
|
314
|
+
# nits: advance the pipeline, surface the nits, and do not dispatch a fix.
|
|
315
|
+
#
|
|
316
|
+
# #1456 (CRITICAL, fails open): this gate used to fire on
|
|
317
|
+
# `parsed_any and not bool(blocking)`, which treated `blocking=None`
|
|
318
|
+
# ("could not determine") identically to `blocking=0` ("reviewer raised
|
|
319
|
+
# nothing blocking") — opposite meanings needing opposite defaults. On
|
|
320
|
+
# #1445 a reviewer's well-formed prose `request-changes` was rewritten to
|
|
321
|
+
# `approve` and marked merge-ready because the *nits* bucket happened to
|
|
322
|
+
# parse as 0 (satisfying `parsed_any`) while *blocking* parsed as None.
|
|
323
|
+
# The evidence standard now lives in `blocking_findings_confirmed_absent`
|
|
324
|
+
# and is fail-closed: no positive evidence ⇒ the reviewer's verdict stands.
|
|
325
|
+
blocking, nonblocking, nits = estimate_review_counts(findings.body)
|
|
326
|
+
if blocking_findings_confirmed_absent(findings.body):
|
|
327
|
+
# log.warning, not info: this is the coordinator overriding a human-
|
|
328
|
+
# readable rejection. It must be conspicuous in the log, and it is
|
|
329
|
+
# additionally recorded on the assignment (below) and posted to GitHub.
|
|
330
|
+
log.warning(
|
|
331
|
+
"auto_loop: OVERRIDING reviewer verdict for review %s — "
|
|
332
|
+
"request-changes with an explicitly empty blocking section "
|
|
333
|
+
"(blocking=%r nonblocking=%r nits=%r); advancing as approve-with-"
|
|
334
|
+
"nits per the #476 gate, not dispatching a fix",
|
|
335
|
+
review.assignment_id, blocking, nonblocking, nits,
|
|
336
|
+
)
|
|
337
|
+
# The merge gate keys off review_verdict; record approve so the nits
|
|
338
|
+
# don't block the merge. The nits remain visible in the review comment
|
|
339
|
+
# already posted to the PR, plus the advisory notice below.
|
|
340
|
+
#
|
|
341
|
+
# #1456: never *silently* rewrite a verdict — preserve the reviewer's
|
|
342
|
+
# own verdict alongside the override plus the counts that justified it,
|
|
343
|
+
# so an audit can always tell a reviewer approval from a coordinator
|
|
344
|
+
# one.
|
|
345
|
+
override_reason = (
|
|
346
|
+
f"#476 approve-with-nits gate: blocking={blocking} "
|
|
347
|
+
f"nonblocking={nonblocking} nits={nits}"
|
|
348
|
+
)
|
|
349
|
+
review.review_verdict_original = findings.verdict
|
|
350
|
+
review.review_verdict_override_reason = override_reason
|
|
351
|
+
review.review_verdict = "approve"
|
|
352
|
+
# #1956: this IS a coordinator override of the reviewer's own
|
|
353
|
+
# verdict — stamp the same provenance a human relaying an override
|
|
354
|
+
# via `coord report-result --verdict-source overridden` would carry,
|
|
355
|
+
# so this automatic path and a manual one read identically at every
|
|
356
|
+
# surface that shows verdict_source, instead of only the manual path
|
|
357
|
+
# being auditable.
|
|
358
|
+
review.verdict_source = "overridden"
|
|
359
|
+
review.verdict_source_reason = override_reason
|
|
360
|
+
_record_verdict_override(
|
|
361
|
+
review, board,
|
|
362
|
+
original_verdict=findings.verdict,
|
|
363
|
+
blocking=blocking, nonblocking=nonblocking, nits=nits,
|
|
364
|
+
)
|
|
365
|
+
_post_advisory_nits_notice(
|
|
366
|
+
review, board, config, nonblocking, nits,
|
|
367
|
+
original_verdict=findings.verdict,
|
|
368
|
+
)
|
|
369
|
+
return _advance_pipeline(
|
|
370
|
+
review, board, config,
|
|
371
|
+
kind="approved_with_nits",
|
|
372
|
+
detail=(
|
|
373
|
+
"Review requested changes but its blocking section was "
|
|
374
|
+
f"explicitly empty (nonblocking={nonblocking}, nits={nits}) — "
|
|
375
|
+
"advancing as approve-with-nits (reviewer verdict preserved in "
|
|
376
|
+
"review_verdict_original); no fix dispatched"
|
|
377
|
+
),
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
if blocking is None:
|
|
381
|
+
# The common case now: the reviewer wrote prose with no recognisable
|
|
382
|
+
# blocking section. Fail closed and say so, so the operator can see
|
|
383
|
+
# *why* a fix round was dispatched rather than the gate firing.
|
|
384
|
+
log.info(
|
|
385
|
+
"auto_loop: could not determine a blocking-finding count for review "
|
|
386
|
+
"%s (blocking=None nonblocking=%r nits=%r) — honouring the "
|
|
387
|
+
"reviewer's request-changes verdict (#1456 fail-closed)",
|
|
388
|
+
review.assignment_id, nonblocking, nits,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
# Genuine blocking findings (or counts unparseable) → dispatch a fix worker.
|
|
392
|
+
if not dispatch_fixes:
|
|
393
|
+
# #1663: the caller (the daemon drain) is explicitly not allowed to
|
|
394
|
+
# spawn a fix worker — that is the #476/#477 blast radius. Do the
|
|
395
|
+
# bookkeeping half anyway so the work row says `request-changes`
|
|
396
|
+
# instead of lying about being `dispatched` with no verdict: a drive,
|
|
397
|
+
# a human, or the #1441 stalled sweep can then act on a legible row.
|
|
398
|
+
# No merge-queue refresh — a request-changes row can never satisfy
|
|
399
|
+
# `has_approved_review`.
|
|
400
|
+
work = propagate_review_verdict(
|
|
401
|
+
review, board, config, refresh_merge_queue=False,
|
|
402
|
+
)
|
|
403
|
+
return [LoopAction(
|
|
404
|
+
kind="verdict_propagated",
|
|
405
|
+
assignment_id=review.assignment_id,
|
|
406
|
+
detail=(
|
|
407
|
+
"Review verdict: request-changes — propagated to work "
|
|
408
|
+
f"{work.assignment_id if work is not None else '<not on board>'}; "
|
|
409
|
+
"fix dispatch withheld (caller is bookkeeping-only)"
|
|
410
|
+
),
|
|
411
|
+
)]
|
|
412
|
+
|
|
413
|
+
return _dispatch_fix_for_review(
|
|
414
|
+
review, findings, board, config,
|
|
415
|
+
http_client=http_client, terminal_cache=terminal_cache,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def propagate_review_verdict(
|
|
420
|
+
review: Assignment,
|
|
421
|
+
board: Board,
|
|
422
|
+
config: Config,
|
|
423
|
+
*,
|
|
424
|
+
refresh_merge_queue: bool = True,
|
|
425
|
+
) -> "Assignment | None":
|
|
426
|
+
"""Write *review*'s verdict onto the parent **work** row. Bookkeeping only.
|
|
427
|
+
|
|
428
|
+
This is the half of :func:`process_review_completion` that is pure
|
|
429
|
+
bookkeeping — no metered worker, no race, no cost if repeated:
|
|
430
|
+
|
|
431
|
+
- ``work.review_state = "done"``
|
|
432
|
+
- ``work.review_verdict = review.review_verdict``
|
|
433
|
+
- :func:`coord.state.record_work_review_verdict` (durable single-row write)
|
|
434
|
+
- the merge-queue entry refresh (*refresh_merge_queue*, approve only)
|
|
435
|
+
|
|
436
|
+
**#1663** is why this is a separate function. The exclusion that keeps
|
|
437
|
+
fix-worker dispatch out of the daemon drain (#476/#477) used to sit at
|
|
438
|
+
*function* granularity — the drain refused to enter
|
|
439
|
+
``process_review_completion`` at all, which silently took the parent-row
|
|
440
|
+
write with it. Every verdict consumed by the drain instead of by a human's
|
|
441
|
+
``coord notify`` left its work row at ``review_state='dispatched'`` /
|
|
442
|
+
``review_verdict=NULL`` forever (the 2026-08-01 overnight batch: five
|
|
443
|
+
issues, four clean approves, 4h02m of wall clock, zero merges). Splitting
|
|
444
|
+
the bookkeeping out lets the drain call *this* without ever reaching
|
|
445
|
+
``_dispatch_fix_for_review``.
|
|
446
|
+
|
|
447
|
+
Returns the parent work assignment when one was found and written, else
|
|
448
|
+
``None`` (no ``review_of_assignment_id``, or it isn't on *board*).
|
|
449
|
+
|
|
450
|
+
*refresh_merge_queue* is ``True`` on the approve paths — #292 (Defect 2)
|
|
451
|
+
wants the entry created/re-keyed so the TUI shows Merge as ready without a
|
|
452
|
+
manual ``coord merge`` — and ``False`` on the request-changes paths, where
|
|
453
|
+
creating a PENDING queue entry would only add a row that
|
|
454
|
+
``has_approved_review`` is guaranteed to reject.
|
|
455
|
+
"""
|
|
456
|
+
if not review.review_of_assignment_id:
|
|
457
|
+
return None
|
|
458
|
+
work = board.find_by_id(review.review_of_assignment_id)
|
|
459
|
+
if work is None:
|
|
460
|
+
return None
|
|
461
|
+
|
|
462
|
+
work.review_state = "done"
|
|
463
|
+
# #1565: propagate the verdict onto the parent work row itself —
|
|
464
|
+
# it used to live only on the review assignment, which left the
|
|
465
|
+
# work row's own review_verdict NULL forever. Persist it as an
|
|
466
|
+
# immediate, scoped single-row write (not deferred to whichever
|
|
467
|
+
# caller happens to save the whole board afterward) so a crash,
|
|
468
|
+
# a skipped persist step, or a stale concurrent save_board()
|
|
469
|
+
# elsewhere can't leave the parent stuck at review_state=
|
|
470
|
+
# 'pending' with a real approval sitting unreferenced on the
|
|
471
|
+
# review row — the #1565 incident (4 redundant metered reviews
|
|
472
|
+
# re-deriving the same approval).
|
|
473
|
+
work.review_verdict = review.review_verdict
|
|
474
|
+
if work.assignment_id and review.review_verdict:
|
|
475
|
+
from coord.state import record_work_review_verdict # noqa: PLC0415
|
|
476
|
+
|
|
477
|
+
record_work_review_verdict(work.assignment_id, review.review_verdict)
|
|
478
|
+
|
|
479
|
+
if not refresh_merge_queue:
|
|
480
|
+
return work
|
|
481
|
+
|
|
482
|
+
# #292 (Defect 2): proactively enqueue/refresh the merge queue
|
|
483
|
+
# entry so the TUI shows the Merge stage as ready without requiring
|
|
484
|
+
# a manual `coord merge` run first. If the entry was keyed to an
|
|
485
|
+
# earlier work assignment (the original pre-bounce assignment),
|
|
486
|
+
# refresh_entry_assignment updates its assignment_id so
|
|
487
|
+
# has_approved_review can find this approval.
|
|
488
|
+
try:
|
|
489
|
+
from coord import merge_queue as mq # noqa: PLC0415
|
|
490
|
+
repo_cfg = config.repo(work.repo_name)
|
|
491
|
+
if repo_cfg is not None and work.branch:
|
|
492
|
+
# #934: target `feature/ms-NN` when this issue belongs
|
|
493
|
+
# to a milestone and the repo opted into the git model —
|
|
494
|
+
# the milestone lookup itself is skipped (no `gh` call)
|
|
495
|
+
# when it hasn't, falling back to `default_branch`.
|
|
496
|
+
target_branch = repo_cfg.default_branch
|
|
497
|
+
if getattr(repo_cfg, "develop_branch", None):
|
|
498
|
+
from coord.branch_model import ( # noqa: PLC0415
|
|
499
|
+
fetch_issue_milestone_number,
|
|
500
|
+
resolve_base_branch,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
milestone_number = fetch_issue_milestone_number(
|
|
504
|
+
repo_cfg.github, work.issue_number,
|
|
505
|
+
)
|
|
506
|
+
target_branch = resolve_base_branch(repo_cfg, milestone_number)
|
|
507
|
+
mq.refresh_entry_assignment(
|
|
508
|
+
work,
|
|
509
|
+
repo_github=repo_cfg.github,
|
|
510
|
+
target_branch=target_branch,
|
|
511
|
+
)
|
|
512
|
+
except Exception as exc: # noqa: BLE001 — best-effort; merge gate still works
|
|
513
|
+
log.warning(
|
|
514
|
+
"auto_loop: refresh_entry_assignment failed for %s: %s",
|
|
515
|
+
work.assignment_id, exc,
|
|
516
|
+
)
|
|
517
|
+
return work
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def _advance_pipeline(
|
|
521
|
+
review: Assignment,
|
|
522
|
+
board: Board,
|
|
523
|
+
config: Config,
|
|
524
|
+
*,
|
|
525
|
+
kind: str,
|
|
526
|
+
detail: str,
|
|
527
|
+
) -> list[LoopAction]:
|
|
528
|
+
"""Mark the reviewed work approved and refresh its merge-queue entry.
|
|
529
|
+
|
|
530
|
+
Shared by the plain ``approve`` path and the #476 approve-with-nits path so
|
|
531
|
+
both advance the pipeline identically (the only difference is the action
|
|
532
|
+
``kind``/``detail`` reported back to the caller).
|
|
533
|
+
"""
|
|
534
|
+
propagate_review_verdict(review, board, config)
|
|
535
|
+
return [LoopAction(kind=kind, assignment_id=review.assignment_id, detail=detail)]
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _record_verdict_override(
|
|
539
|
+
review: Assignment,
|
|
540
|
+
board: Board,
|
|
541
|
+
*,
|
|
542
|
+
original_verdict: str,
|
|
543
|
+
blocking: int | None,
|
|
544
|
+
nonblocking: int | None,
|
|
545
|
+
nits: int | None,
|
|
546
|
+
) -> None:
|
|
547
|
+
"""#1456: write a durable audit row when the coordinator downgrades a
|
|
548
|
+
reviewer's ``request-changes`` to ``approve``.
|
|
549
|
+
|
|
550
|
+
The third leg of the audit trail, after the board row (which carries both
|
|
551
|
+
verdicts) and the GitHub notice: a timestamped business-tier event, so an
|
|
552
|
+
operator can answer "which merges rode an overridden verdict?" after the
|
|
553
|
+
fact — including for work whose issue has since been closed. Best-effort;
|
|
554
|
+
an audit failure must never block the pipeline.
|
|
555
|
+
"""
|
|
556
|
+
work = (
|
|
557
|
+
board.find_by_id(review.review_of_assignment_id)
|
|
558
|
+
if review.review_of_assignment_id
|
|
559
|
+
else None
|
|
560
|
+
)
|
|
561
|
+
try:
|
|
562
|
+
from coord.audit import record_audit # noqa: PLC0415
|
|
563
|
+
|
|
564
|
+
record_audit(
|
|
565
|
+
tier="business",
|
|
566
|
+
category="review",
|
|
567
|
+
event_type="review_verdict_overridden",
|
|
568
|
+
actor="coordinator",
|
|
569
|
+
summary=(
|
|
570
|
+
f"Coordinator overrode review verdict {original_verdict} → "
|
|
571
|
+
f"approve for {review.repo_name}#{review.issue_number} "
|
|
572
|
+
f"(#476 advisory-only gate: blocking={blocking}, "
|
|
573
|
+
f"nonblocking={nonblocking}, nits={nits})"
|
|
574
|
+
),
|
|
575
|
+
repo=review.repo_name,
|
|
576
|
+
issue=review.issue_number,
|
|
577
|
+
assignment_id=review.assignment_id,
|
|
578
|
+
machine=review.machine_name,
|
|
579
|
+
details={
|
|
580
|
+
"original_verdict": original_verdict,
|
|
581
|
+
"effective_verdict": "approve",
|
|
582
|
+
"blocking": blocking,
|
|
583
|
+
"nonblocking": nonblocking,
|
|
584
|
+
"nits": nits,
|
|
585
|
+
"work_assignment_id": work.assignment_id if work else None,
|
|
586
|
+
},
|
|
587
|
+
)
|
|
588
|
+
except Exception as exc: # noqa: BLE001 — audit is best-effort
|
|
589
|
+
log.warning(
|
|
590
|
+
"auto_loop: failed to record verdict-override audit for %s: %s",
|
|
591
|
+
review.assignment_id, exc,
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _post_advisory_nits_notice(
|
|
596
|
+
review: Assignment,
|
|
597
|
+
board: Board,
|
|
598
|
+
config: Config,
|
|
599
|
+
nonblocking: int | None,
|
|
600
|
+
nits: int | None,
|
|
601
|
+
original_verdict: str = "request-changes",
|
|
602
|
+
) -> None:
|
|
603
|
+
"""Post a short audit-trail comment when the loop auto-advances past an
|
|
604
|
+
advisory-only request-changes verdict (#476).
|
|
605
|
+
|
|
606
|
+
Keeps the auto-advance *visible* — the user was previously burned by silent
|
|
607
|
+
auto-loop behaviour. Best-effort: a gh failure must never block the
|
|
608
|
+
pipeline. The full findings are already on the PR via the review comment;
|
|
609
|
+
this just records the decision not to dispatch another fix round.
|
|
610
|
+
|
|
611
|
+
#1456: the comment now names both verdicts — the reviewer's
|
|
612
|
+
(*original_verdict*) and the coordinator's override — so the GitHub thread
|
|
613
|
+
reads as an override, not as an approval the reviewer never gave.
|
|
614
|
+
"""
|
|
615
|
+
work = (
|
|
616
|
+
board.find_by_id(review.review_of_assignment_id)
|
|
617
|
+
if review.review_of_assignment_id
|
|
618
|
+
else None
|
|
619
|
+
)
|
|
620
|
+
if work is None:
|
|
621
|
+
return
|
|
622
|
+
repo = config.repo(work.repo_name)
|
|
623
|
+
if repo is None:
|
|
624
|
+
return
|
|
625
|
+
from coord import github_ops # noqa: PLC0415
|
|
626
|
+
|
|
627
|
+
body = (
|
|
628
|
+
f"<!-- coord:event=auto_loop_advisory_advance assignment={work.assignment_id} "
|
|
629
|
+
f"original_verdict={original_verdict} override_verdict=approve -->\n"
|
|
630
|
+
f"## ⚠️ Coordinator overrode the review verdict (no blocking findings)\n\n"
|
|
631
|
+
f"- **Reviewer's verdict:** `{original_verdict}`\n"
|
|
632
|
+
f"- **Coordinator's override:** `approve` (approve-with-nits)\n"
|
|
633
|
+
f"- **Evidence:** the review's blocking section was **explicitly empty** "
|
|
634
|
+
f"(non-blocking={nonblocking}, nits={nits})\n\n"
|
|
635
|
+
f"The latest review of issue **#{work.issue_number}** returned "
|
|
636
|
+
f"`{original_verdict}` while flagging no blocking findings. Per the #476 "
|
|
637
|
+
f"decision gate, the coordinator is **not** dispatching another fix "
|
|
638
|
+
f"round over non-blocking suggestions — the PR advances to the merge "
|
|
639
|
+
f"gate. The reviewer's own verdict is preserved on the assignment "
|
|
640
|
+
f"(`review_verdict_original`); it has not been erased.\n\n"
|
|
641
|
+
f"The reviewer's notes remain in the review comment above. If any nit "
|
|
642
|
+
f"is in fact a must-fix, dispatch a fix manually with `coord assign` "
|
|
643
|
+
f"or bounce it before merging.\n"
|
|
644
|
+
)
|
|
645
|
+
try:
|
|
646
|
+
github_ops.post_issue_comment(repo.github, work.issue_number, body)
|
|
647
|
+
except Exception as exc: # noqa: BLE001
|
|
648
|
+
log.warning(
|
|
649
|
+
"auto_loop: failed to post advisory-advance notice for %s: %s",
|
|
650
|
+
work.assignment_id, exc,
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _fix_model_for_iteration(config: Config, iteration: int) -> str | None:
|
|
655
|
+
"""Choose the model alias for a fix worker on a given bounce *iteration*.
|
|
656
|
+
|
|
657
|
+
Pure function so the iteration → model mapping is unit-testable.
|
|
658
|
+
|
|
659
|
+
Returns ``None`` when ``pipeline.escalate_fix_model`` is disabled — the
|
|
660
|
+
fix dispatch then sets no model and the agent falls back to ``claude -p``'s
|
|
661
|
+
default (today's behaviour).
|
|
662
|
+
|
|
663
|
+
When escalation is enabled:
|
|
664
|
+
- iteration 1 → ``config.models.default`` (first fix stays cheap/fast).
|
|
665
|
+
- iteration 2+ → climb one rung up ``config.models.escalation`` per
|
|
666
|
+
iteration, capped at the top of the ladder.
|
|
667
|
+
|
|
668
|
+
Example with escalation ``[haiku, sonnet, opus]`` and default ``sonnet``:
|
|
669
|
+
iter 1 → sonnet, iter 2 → opus, iter 3 → opus (capped).
|
|
670
|
+
"""
|
|
671
|
+
if not config.pipeline.escalate_fix_model:
|
|
672
|
+
return None
|
|
673
|
+
|
|
674
|
+
model = config.models.default
|
|
675
|
+
# iteration 1 stays on the base model; each later iteration escalates one
|
|
676
|
+
# rung (next_model caps at the top of the ladder).
|
|
677
|
+
for _ in range(max(iteration, 1) - 1):
|
|
678
|
+
model = config.models.next_model(model)
|
|
679
|
+
return model
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _dispatch_fix_for_review(
|
|
683
|
+
review: Assignment,
|
|
684
|
+
findings,
|
|
685
|
+
board: Board,
|
|
686
|
+
config: Config,
|
|
687
|
+
*,
|
|
688
|
+
http_client: httpx.Client | None = None,
|
|
689
|
+
terminal_cache: dict | None = None,
|
|
690
|
+
) -> list[LoopAction]:
|
|
691
|
+
"""Find the reviewed work assignment and dispatch a fix worker for it."""
|
|
692
|
+
# Locate the work assignment that was reviewed.
|
|
693
|
+
work: Assignment | None = None
|
|
694
|
+
if review.review_of_assignment_id:
|
|
695
|
+
work = board.find_by_id(review.review_of_assignment_id)
|
|
696
|
+
|
|
697
|
+
if work is None:
|
|
698
|
+
log.warning(
|
|
699
|
+
"auto_loop: cannot find work assignment for review %s "
|
|
700
|
+
"(review_of_assignment_id=%r)",
|
|
701
|
+
review.assignment_id,
|
|
702
|
+
review.review_of_assignment_id,
|
|
703
|
+
)
|
|
704
|
+
return [LoopAction(
|
|
705
|
+
kind="no_work_found",
|
|
706
|
+
assignment_id=review.assignment_id,
|
|
707
|
+
detail=(
|
|
708
|
+
f"work assignment {review.review_of_assignment_id!r} not on board"
|
|
709
|
+
),
|
|
710
|
+
)]
|
|
711
|
+
|
|
712
|
+
# #1663 (second gap): write the verdict onto the parent work row on the
|
|
713
|
+
# request-changes path too. `_advance_pipeline` has always done this for
|
|
714
|
+
# approve (#1565) but there was no request-changes twin — the only place
|
|
715
|
+
# this function ever touched the parent was the `_work_is_terminal` early
|
|
716
|
+
# return below, and even that set `review_state` without `review_verdict`.
|
|
717
|
+
# The fix worker is dispatched off the *transition* and doesn't need the
|
|
718
|
+
# row, so nothing broke loudly; what it cost was legibility — a row that
|
|
719
|
+
# still reads `dispatched`/NULL after a real rejection is invisible to
|
|
720
|
+
# `coord drive`, to the TUI's Review stage, and to any state-derived
|
|
721
|
+
# recovery sweep. Done BEFORE the terminal/iteration-cap branches so every
|
|
722
|
+
# outcome of this function leaves an honest parent row, not just the happy
|
|
723
|
+
# one. No merge-queue refresh: request-changes can never satisfy
|
|
724
|
+
# `has_approved_review`.
|
|
725
|
+
propagate_review_verdict(review, board, config, refresh_merge_queue=False)
|
|
726
|
+
|
|
727
|
+
# #522: never dispatch a fix for work that is already done on GitHub.
|
|
728
|
+
# A merged PR / closed issue must not re-enter the review→fix loop — this
|
|
729
|
+
# is the root cause of the 2026-06-09 launch flood (#349 ×4, #194).
|
|
730
|
+
if _work_is_terminal(work, config, cache=terminal_cache):
|
|
731
|
+
log.info(
|
|
732
|
+
"auto_loop: NOT dispatching fix for %s — issue #%s is terminal "
|
|
733
|
+
"(merged/closed)",
|
|
734
|
+
work.assignment_id, work.issue_number,
|
|
735
|
+
)
|
|
736
|
+
# The review of merged work is moot; mark it resolved so the board /
|
|
737
|
+
# merge gate stop treating it as needing another round.
|
|
738
|
+
work.review_state = "done"
|
|
739
|
+
return [LoopAction(
|
|
740
|
+
kind="terminal_skip",
|
|
741
|
+
assignment_id=review.assignment_id,
|
|
742
|
+
detail=(
|
|
743
|
+
f"issue #{work.issue_number} already merged/closed — "
|
|
744
|
+
"no fix dispatched"
|
|
745
|
+
),
|
|
746
|
+
)]
|
|
747
|
+
|
|
748
|
+
# Compute the next iteration number and check the limit.
|
|
749
|
+
next_iteration = (work.review_iteration or 0) + 1
|
|
750
|
+
max_iter = config.pipeline.max_review_iterations
|
|
751
|
+
|
|
752
|
+
if next_iteration > max_iter:
|
|
753
|
+
log.warning(
|
|
754
|
+
"auto_loop: max_review_iterations (%d) reached for assignment %s "
|
|
755
|
+
"— stopping loop and notifying user",
|
|
756
|
+
max_iter, work.assignment_id,
|
|
757
|
+
)
|
|
758
|
+
_post_max_iterations_notice(work, config)
|
|
759
|
+
return [LoopAction(
|
|
760
|
+
kind="max_iterations",
|
|
761
|
+
assignment_id=review.assignment_id,
|
|
762
|
+
detail=(
|
|
763
|
+
f"max_review_iterations={max_iter} reached for "
|
|
764
|
+
f"work assignment {work.assignment_id}"
|
|
765
|
+
),
|
|
766
|
+
)]
|
|
767
|
+
|
|
768
|
+
# Build briefing and dispatch. The fix worker escalates the model per
|
|
769
|
+
# iteration (when pipeline.escalate_fix_model is enabled); compute it here
|
|
770
|
+
# where the iteration is known and thread it into the dispatch.
|
|
771
|
+
# #603: prepend the per-issue context digest (prior-attempt findings,
|
|
772
|
+
# cross-repo deps) to the TOP of the -p fix briefing. The interactive fix
|
|
773
|
+
# path prefixes it at its own call site, so the shared _build_fix_briefing
|
|
774
|
+
# stays pure (no double injection).
|
|
775
|
+
from coord.state import issue_context_block # noqa: PLC0415
|
|
776
|
+
|
|
777
|
+
briefing = issue_context_block(work.repo_name, work.issue_number) + _build_fix_briefing(
|
|
778
|
+
work, findings, next_iteration, max_iter
|
|
779
|
+
)
|
|
780
|
+
model = _fix_model_for_iteration(config, next_iteration)
|
|
781
|
+
fix = _dispatch_fix(
|
|
782
|
+
work, briefing, board, config, next_iteration,
|
|
783
|
+
model=model, http_client=http_client,
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
if fix is None:
|
|
787
|
+
return [LoopAction(
|
|
788
|
+
kind="no_work_found",
|
|
789
|
+
assignment_id=review.assignment_id,
|
|
790
|
+
detail="fix worker dispatch failed (agent unreachable or no capable machine)",
|
|
791
|
+
)]
|
|
792
|
+
|
|
793
|
+
log.info(
|
|
794
|
+
"auto_loop: dispatched fix worker %s for review %s (iteration %d/%d)",
|
|
795
|
+
fix.assignment_id, review.assignment_id, next_iteration, max_iter,
|
|
796
|
+
)
|
|
797
|
+
return [LoopAction(
|
|
798
|
+
kind="fix_dispatched",
|
|
799
|
+
assignment_id=review.assignment_id,
|
|
800
|
+
detail=(
|
|
801
|
+
f"fix worker {fix.assignment_id} dispatched to {fix.machine_name} "
|
|
802
|
+
f"(iteration {next_iteration}/{max_iter})"
|
|
803
|
+
),
|
|
804
|
+
)]
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _build_fix_briefing(
|
|
808
|
+
work: Assignment,
|
|
809
|
+
findings,
|
|
810
|
+
iteration: int,
|
|
811
|
+
max_iter: int,
|
|
812
|
+
) -> str:
|
|
813
|
+
"""Assemble the briefing for the fix worker. Pure function — easy to test.
|
|
814
|
+
|
|
815
|
+
#1176: a ``type="test-author"`` source row gets test-authoring-flavored
|
|
816
|
+
instructions instead — "make the acceptance suite pass" is actively
|
|
817
|
+
wrong guidance for an oracle that must stay RED until the real
|
|
818
|
+
implementation lands.
|
|
819
|
+
"""
|
|
820
|
+
if work.type == "test-author":
|
|
821
|
+
return _build_test_author_fix_briefing(work, findings, iteration, max_iter)
|
|
822
|
+
|
|
823
|
+
lines: list[str] = [
|
|
824
|
+
f"# Fix assignment (iteration {iteration}/{max_iter}): {work.issue_title}",
|
|
825
|
+
"",
|
|
826
|
+
f"You are fixing review findings for issue #{work.issue_number}.",
|
|
827
|
+
(
|
|
828
|
+
f"Work on branch `{work.branch or '(check your git branches)'}` — "
|
|
829
|
+
"**do not change the branch name**."
|
|
830
|
+
),
|
|
831
|
+
"",
|
|
832
|
+
"## Reviewer findings to address",
|
|
833
|
+
"",
|
|
834
|
+
findings.body.strip(),
|
|
835
|
+
"",
|
|
836
|
+
"## Instructions",
|
|
837
|
+
"",
|
|
838
|
+
"1. Read the review findings above carefully.",
|
|
839
|
+
"2. Fix **every** issue identified by the reviewer.",
|
|
840
|
+
"3. Stay on the **same branch** — push your fixes to the existing branch.",
|
|
841
|
+
"4. Run the project test suite and ensure all tests pass before pushing.",
|
|
842
|
+
(
|
|
843
|
+
f"5. This is fix iteration {iteration} of {max_iter} allowed. "
|
|
844
|
+
"Address all findings completely so the next review can approve."
|
|
845
|
+
),
|
|
846
|
+
"",
|
|
847
|
+
"STATUS: reading review findings → implementing fixes → confidence: high",
|
|
848
|
+
"",
|
|
849
|
+
]
|
|
850
|
+
if work.briefing and work.briefing.strip():
|
|
851
|
+
lines += [
|
|
852
|
+
"## Original work briefing",
|
|
853
|
+
"",
|
|
854
|
+
work.briefing.strip(),
|
|
855
|
+
"",
|
|
856
|
+
]
|
|
857
|
+
return "\n".join(lines)
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _build_test_author_fix_briefing(
|
|
861
|
+
work: Assignment,
|
|
862
|
+
findings,
|
|
863
|
+
iteration: int,
|
|
864
|
+
max_iter: int,
|
|
865
|
+
) -> str:
|
|
866
|
+
"""Fix briefing for a ``type="test-author"`` slice (#1176).
|
|
867
|
+
|
|
868
|
+
Findings here are almost always test-quality issues (a dead
|
|
869
|
+
``monkeypatch.setattr``, a fragile assertion that would false-fail a
|
|
870
|
+
correct implementation) — not a request to implement anything. The
|
|
871
|
+
acceptance suite must stay RED until the real implementation lands, so
|
|
872
|
+
"run tests, make them pass" (the generic fix instruction) is actively
|
|
873
|
+
wrong guidance and is deliberately NOT used here. The dispatcher pairs
|
|
874
|
+
this briefing with ``TEST_AUTHOR_SYSTEM_PROMPT`` (independence rules,
|
|
875
|
+
RED verification) — see ``_dispatch_fix``.
|
|
876
|
+
"""
|
|
877
|
+
lines: list[str] = [
|
|
878
|
+
f"# Test-author fix (iteration {iteration}/{max_iter}): {work.issue_title}",
|
|
879
|
+
"",
|
|
880
|
+
f"You are the independent acceptance-test author fixing review findings "
|
|
881
|
+
f"against the acceptance suite for issue #{work.issue_number}.",
|
|
882
|
+
(
|
|
883
|
+
f"Work on branch `{work.branch or '(check your git branches)'}` — "
|
|
884
|
+
"**do not change the branch name**."
|
|
885
|
+
),
|
|
886
|
+
"",
|
|
887
|
+
"## Reviewer findings to address",
|
|
888
|
+
"",
|
|
889
|
+
findings.body.strip(),
|
|
890
|
+
"",
|
|
891
|
+
"## Instructions",
|
|
892
|
+
"",
|
|
893
|
+
(
|
|
894
|
+
"1. Read the reviewer findings above carefully — they are almost "
|
|
895
|
+
"always test-quality issues (fragile assertions, stale/dead mocks, "
|
|
896
|
+
"tests that would false-fail a correct implementation), not a "
|
|
897
|
+
"request to implement anything."
|
|
898
|
+
),
|
|
899
|
+
(
|
|
900
|
+
"2. Fix **every** issue identified by the reviewer, in the "
|
|
901
|
+
"acceptance suite under `tests/acceptance/` only."
|
|
902
|
+
),
|
|
903
|
+
"3. Stay on the **same branch** — push your fixes to the existing branch.",
|
|
904
|
+
(
|
|
905
|
+
"4. Your tests MUST remain RED. Do NOT touch any implementation to "
|
|
906
|
+
"make them pass. Run the driver's run command yourself and confirm "
|
|
907
|
+
"the fixed tests still fail cleanly (not error out from a broken "
|
|
908
|
+
"framework hookup)."
|
|
909
|
+
),
|
|
910
|
+
(
|
|
911
|
+
"5. Update the manifest if you added, removed, or renamed any test "
|
|
912
|
+
"ids — merge with the existing manifest, don't clobber it."
|
|
913
|
+
),
|
|
914
|
+
(
|
|
915
|
+
f"6. This is fix iteration {iteration} of {max_iter} allowed. "
|
|
916
|
+
"Address all findings completely so the next review can approve."
|
|
917
|
+
),
|
|
918
|
+
"",
|
|
919
|
+
"STATUS: reading review findings → fixing acceptance suite → confidence: high",
|
|
920
|
+
"",
|
|
921
|
+
]
|
|
922
|
+
if work.briefing and work.briefing.strip():
|
|
923
|
+
lines += [
|
|
924
|
+
"## Original test-author briefing",
|
|
925
|
+
"",
|
|
926
|
+
work.briefing.strip(),
|
|
927
|
+
"",
|
|
928
|
+
]
|
|
929
|
+
return "\n".join(lines)
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _dispatch_fix(
|
|
933
|
+
work: Assignment,
|
|
934
|
+
briefing: str,
|
|
935
|
+
board: Board,
|
|
936
|
+
config: Config,
|
|
937
|
+
iteration: int,
|
|
938
|
+
*,
|
|
939
|
+
model: str | None = None,
|
|
940
|
+
http_client: httpx.Client | None = None,
|
|
941
|
+
remote_branch_checker=None,
|
|
942
|
+
) -> Assignment | None:
|
|
943
|
+
"""POST a fix assignment to the agent server.
|
|
944
|
+
|
|
945
|
+
Prefers the same machine as the original worker (the branch is already
|
|
946
|
+
checked out there). Falls back to any capable machine.
|
|
947
|
+
|
|
948
|
+
#1176: the dispatched type mirrors ``work.type`` for ``"test-author"``
|
|
949
|
+
(with ``TEST_AUTHOR_SYSTEM_PROMPT`` + its deny-list, since the fix needs
|
|
950
|
+
the same acceptance-path authorization and independence rules as the
|
|
951
|
+
original test-author dispatch); every other source type still gets the
|
|
952
|
+
long-standing plain ``"work"`` fix.
|
|
953
|
+
|
|
954
|
+
Returns the new Assignment (already added to ``board.active``), or None
|
|
955
|
+
on failure.
|
|
956
|
+
"""
|
|
957
|
+
# Pick machine: prefer the original worker's machine first.
|
|
958
|
+
machine = next(
|
|
959
|
+
(m for m in config.machines if m.name == work.machine_name), None
|
|
960
|
+
)
|
|
961
|
+
from coord.machine_pause import paused_set
|
|
962
|
+
paused = paused_set(config.machines)
|
|
963
|
+
if (
|
|
964
|
+
machine is None
|
|
965
|
+
or not machine.can_work_on(work.repo_name)
|
|
966
|
+
or machine.repo_path(work.repo_name) is None
|
|
967
|
+
or machine.name in paused
|
|
968
|
+
):
|
|
969
|
+
# Fallback: any machine capable of working on this repo, minus
|
|
970
|
+
# any the user has paused via `coord pause` (routing-pause).
|
|
971
|
+
candidates = [
|
|
972
|
+
m for m in config.machines
|
|
973
|
+
if m.can_work_on(work.repo_name)
|
|
974
|
+
and m.repo_path(work.repo_name) is not None
|
|
975
|
+
and m.name not in paused
|
|
976
|
+
]
|
|
977
|
+
if not candidates:
|
|
978
|
+
log.warning(
|
|
979
|
+
"auto_loop: no machine can handle repo %r (paused=%r)",
|
|
980
|
+
work.repo_name, sorted(paused)
|
|
981
|
+
)
|
|
982
|
+
return None
|
|
983
|
+
machine = candidates[0]
|
|
984
|
+
|
|
985
|
+
# #586: if we ended up routing to a different machine than the original
|
|
986
|
+
# worker, the branch must exist on the remote so the fix worker can fetch
|
|
987
|
+
# it. If the worker never pushed, this assignment would crash in 2–3
|
|
988
|
+
# seconds with no commits and no exit code — the classic branch-absent
|
|
989
|
+
# silent failure. Block early and surface a clear log message instead.
|
|
990
|
+
if machine.name != work.machine_name and work.branch:
|
|
991
|
+
repo_obj = config.repo(work.repo_name)
|
|
992
|
+
if repo_obj is not None:
|
|
993
|
+
_check_remote = remote_branch_checker or github_ops.branch_exists_on_remote
|
|
994
|
+
if not _check_remote(repo_obj.github, work.branch):
|
|
995
|
+
log.error(
|
|
996
|
+
"auto_loop: branch %r not on remote — cannot dispatch fix "
|
|
997
|
+
"to different machine %s; original worker %s must push "
|
|
998
|
+
"the branch to origin first",
|
|
999
|
+
work.branch, machine.name, work.machine_name,
|
|
1000
|
+
)
|
|
1001
|
+
return None
|
|
1002
|
+
|
|
1003
|
+
repo_path = machine.repo_path(work.repo_name)
|
|
1004
|
+
if repo_path is None:
|
|
1005
|
+
return None
|
|
1006
|
+
|
|
1007
|
+
repo = config.repo(work.repo_name)
|
|
1008
|
+
if repo is None:
|
|
1009
|
+
return None
|
|
1010
|
+
|
|
1011
|
+
# #1176: preserve the source row's type for a test-author slice fix —
|
|
1012
|
+
# dispatching `type="work"` sends a plain worker that is forbidden from
|
|
1013
|
+
# (and never given the system prompt authorizing) exactly the
|
|
1014
|
+
# `tests/acceptance/**` path it needs to fix. Every other source type
|
|
1015
|
+
# (including today's "work") keeps the long-standing "work" fix.
|
|
1016
|
+
fix_type = "test-author" if work.type == "test-author" else "work"
|
|
1017
|
+
|
|
1018
|
+
payload = {
|
|
1019
|
+
"repo_name": work.repo_name,
|
|
1020
|
+
"repo_path": repo_path,
|
|
1021
|
+
"issue_number": work.issue_number,
|
|
1022
|
+
"issue_title": f"[fix-{iteration}] {work.issue_title}",
|
|
1023
|
+
"briefing": briefing,
|
|
1024
|
+
"files_allowed": work.files_allowed,
|
|
1025
|
+
"files_forbidden": work.files_forbidden,
|
|
1026
|
+
"pull_repos": [],
|
|
1027
|
+
"type": fix_type,
|
|
1028
|
+
# #255: fix-loop dispatches inherit the repo's configured default
|
|
1029
|
+
# branch so the agent branches from origin/<default> rather than
|
|
1030
|
+
# any local-only ref.
|
|
1031
|
+
"branch": repo.default_branch or "main",
|
|
1032
|
+
# #target_branch: tell the agent to check out the ORIGINAL work's
|
|
1033
|
+
# branch rather than deriving a new one from the `[fix-N] …`
|
|
1034
|
+
# issue title. Without this the fix worker pushed to a
|
|
1035
|
+
# new orphan branch and the existing PR never received the fix
|
|
1036
|
+
# commits (quadraui#166 hit this hard).
|
|
1037
|
+
"target_branch": work.branch,
|
|
1038
|
+
}
|
|
1039
|
+
if fix_type == "test-author":
|
|
1040
|
+
# `agent.py`'s dispatch table has no `elif spec.type == "test-author"`
|
|
1041
|
+
# branch — it relies on the caller supplying `system_prompt`
|
|
1042
|
+
# explicitly (mirrors `test_author.dispatch_test_author`). Without
|
|
1043
|
+
# this the fix worker gets the generic WORKER_SYSTEM_PROMPT, which
|
|
1044
|
+
# is what #1176 is about: it never authorizes editing
|
|
1045
|
+
# `tests/acceptance/**` and doesn't carry the independence /
|
|
1046
|
+
# stay-RED rules a test-author session needs.
|
|
1047
|
+
payload["system_prompt"] = TEST_AUTHOR_SYSTEM_PROMPT
|
|
1048
|
+
payload["deny_commands"] = test_author_deny_commands(config, work.repo_name)
|
|
1049
|
+
# Escalated model per bounce iteration (None when pipeline
|
|
1050
|
+
# .escalate_fix_model is disabled — preserves today's no-model behaviour).
|
|
1051
|
+
# The board record keeps the alias for legibility; the wire payload is
|
|
1052
|
+
# resolved through models.versions so claude -p gets an exact id when
|
|
1053
|
+
# one is pinned.
|
|
1054
|
+
if model is not None:
|
|
1055
|
+
payload["model"] = config.models.resolve(model)
|
|
1056
|
+
|
|
1057
|
+
url = f"http://{machine.host}:{AGENT_PORT}/assign"
|
|
1058
|
+
client = http_client or httpx
|
|
1059
|
+
try:
|
|
1060
|
+
resp = client.post(url, json=payload, timeout=15)
|
|
1061
|
+
resp.raise_for_status()
|
|
1062
|
+
agent_response = resp.json()
|
|
1063
|
+
except (httpx.HTTPError, httpx.TimeoutException) as exc:
|
|
1064
|
+
log.warning("auto_loop: agent request failed for fix dispatch: %s", exc)
|
|
1065
|
+
return None
|
|
1066
|
+
|
|
1067
|
+
fix_assignment = Assignment(
|
|
1068
|
+
machine_name=machine.name,
|
|
1069
|
+
repo_name=work.repo_name,
|
|
1070
|
+
issue_number=work.issue_number,
|
|
1071
|
+
issue_title=f"[fix-{iteration}] {work.issue_title}",
|
|
1072
|
+
files_allowed=list(work.files_allowed),
|
|
1073
|
+
files_forbidden=list(work.files_forbidden),
|
|
1074
|
+
briefing=briefing,
|
|
1075
|
+
assignment_id=agent_response.get("id") or uuid.uuid4().hex[:12],
|
|
1076
|
+
status="running",
|
|
1077
|
+
branch=work.branch,
|
|
1078
|
+
pr_url=work.pr_url,
|
|
1079
|
+
dispatched_at=time.time(),
|
|
1080
|
+
type=fix_type,
|
|
1081
|
+
# Link back so the next review can find the work chain.
|
|
1082
|
+
review_of_assignment_id=work.assignment_id,
|
|
1083
|
+
# Iteration counter so the loop knows when to stop.
|
|
1084
|
+
review_iteration=iteration,
|
|
1085
|
+
# Escalated model for this bounce iteration (None preserves the
|
|
1086
|
+
# legacy behaviour where the agent picks claude -p's default).
|
|
1087
|
+
model=model,
|
|
1088
|
+
# #1176: carry over the JIT-slice correlation so the TUI's
|
|
1089
|
+
# per-issue Acceptance-Authoring mini-pipeline still recognizes
|
|
1090
|
+
# this fix as the same member issue's slice (None for every other
|
|
1091
|
+
# type, matching `test_author.dispatch_test_author`).
|
|
1092
|
+
for_issue_number=work.for_issue_number,
|
|
1093
|
+
)
|
|
1094
|
+
board.active.append(fix_assignment)
|
|
1095
|
+
|
|
1096
|
+
record_dispatched_assignment(
|
|
1097
|
+
assignment=fix_assignment,
|
|
1098
|
+
repo_github=repo.github,
|
|
1099
|
+
)
|
|
1100
|
+
return fix_assignment
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def _post_max_iterations_notice(work: Assignment, config: Config) -> None:
|
|
1104
|
+
"""Post a GitHub issue comment when the loop hits the iteration limit."""
|
|
1105
|
+
from coord import github_ops # noqa: PLC0415
|
|
1106
|
+
|
|
1107
|
+
repo = config.repo(work.repo_name)
|
|
1108
|
+
if repo is None:
|
|
1109
|
+
return
|
|
1110
|
+
|
|
1111
|
+
completed_rounds = work.review_iteration # rounds completed so far
|
|
1112
|
+
max_iter = config.pipeline.max_review_iterations
|
|
1113
|
+
body = (
|
|
1114
|
+
f"<!-- coord:event=auto_loop_stopped assignment={work.assignment_id} -->\n"
|
|
1115
|
+
f"## ⚠️ Auto-loop stopped — max review iterations reached\n\n"
|
|
1116
|
+
f"The review → fix cycle for issue **#{work.issue_number}** has "
|
|
1117
|
+
f"completed **{completed_rounds}** fix round(s) without receiving an "
|
|
1118
|
+
f"approval, which equals the configured maximum of "
|
|
1119
|
+
f"**{max_iter}** `pipeline.max_review_iterations`.\n\n"
|
|
1120
|
+
f"**Manual intervention required.** Options:\n"
|
|
1121
|
+
f"- Review the diff and the reviewer's latest findings, then dispatch "
|
|
1122
|
+
f"a fix manually with `coord assign`.\n"
|
|
1123
|
+
f"- Run `coord merge --force-merge` to merge the branch as-is "
|
|
1124
|
+
f"(if the review findings are acceptable).\n"
|
|
1125
|
+
f"- Bump `pipeline.max_review_iterations` in `coordinator.yml` "
|
|
1126
|
+
f"(currently `{max_iter}`) to allow more automated fix rounds.\n"
|
|
1127
|
+
f"- Adjust the issue scope and open a fresh issue.\n\n"
|
|
1128
|
+
f"Details:\n"
|
|
1129
|
+
f"- Assignment: `{work.assignment_id}`\n"
|
|
1130
|
+
f"- Branch: `{work.branch or '(unknown)'}`\n"
|
|
1131
|
+
f"- Completed fix rounds: {completed_rounds}/{max_iter}\n"
|
|
1132
|
+
)
|
|
1133
|
+
try:
|
|
1134
|
+
github_ops.post_issue_comment(repo.github, work.issue_number, body)
|
|
1135
|
+
except Exception as exc: # noqa: BLE001
|
|
1136
|
+
log.warning(
|
|
1137
|
+
"auto_loop: failed to post max-iterations notice for %s: %s",
|
|
1138
|
+
work.assignment_id, exc,
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
# ── notify.py integration ─────────────────────────────────────────────────────
|
|
1143
|
+
|
|
1144
|
+
def run_for_fix_transition(
|
|
1145
|
+
assignment_id: str,
|
|
1146
|
+
config: Config,
|
|
1147
|
+
*,
|
|
1148
|
+
terminal_cache: dict | None = None,
|
|
1149
|
+
) -> list[LoopAction]:
|
|
1150
|
+
"""Entry point called from ``notify.run()`` for each completed fix worker.
|
|
1151
|
+
|
|
1152
|
+
When a bounce-fix worker (``type="work"``, ``review_of_assignment_id``
|
|
1153
|
+
IS NOT NULL, title starting with ``"[fix-"``) completes, dispatch a fresh
|
|
1154
|
+
review against it so the review → fix → re-review cycle closes
|
|
1155
|
+
automatically without manual ``coord pr`` invocations.
|
|
1156
|
+
|
|
1157
|
+
Caps re-review iterations at ``config.pipeline.max_review_iterations``
|
|
1158
|
+
using the fix worker's ``review_iteration`` field. When
|
|
1159
|
+
``fix.review_iteration >= max_review_iterations`` the loop has already
|
|
1160
|
+
used all its fix rounds, so no further review is dispatched and an
|
|
1161
|
+
``iteration_cap_hit`` action is returned instead.
|
|
1162
|
+
|
|
1163
|
+
Parameters
|
|
1164
|
+
----------
|
|
1165
|
+
assignment_id:
|
|
1166
|
+
The completed fix worker's assignment ID.
|
|
1167
|
+
config:
|
|
1168
|
+
Parsed coordinator config.
|
|
1169
|
+
|
|
1170
|
+
Returns
|
|
1171
|
+
-------
|
|
1172
|
+
list[LoopAction]
|
|
1173
|
+
``[LoopAction(kind="review_dispatched", ...)]`` on success,
|
|
1174
|
+
``[LoopAction(kind="iteration_cap_hit", ...)]`` when the cap is hit,
|
|
1175
|
+
``[LoopAction(kind="disabled", ...)]`` when auto_loop is off, or
|
|
1176
|
+
``[]`` when the assignment is not found on the board or
|
|
1177
|
+
``dispatch_review`` cannot find a capable machine.
|
|
1178
|
+
"""
|
|
1179
|
+
if not config.pipeline.auto_loop:
|
|
1180
|
+
return [LoopAction(kind="disabled", assignment_id=assignment_id)]
|
|
1181
|
+
|
|
1182
|
+
# #749: read_board()/write_board() route through the daemon when
|
|
1183
|
+
# board_service is configured — previously this always hit the local DB
|
|
1184
|
+
# directly regardless of thin-client status.
|
|
1185
|
+
board = read_board()
|
|
1186
|
+
|
|
1187
|
+
fix = board.find_by_id(assignment_id)
|
|
1188
|
+
if fix is None:
|
|
1189
|
+
log.debug(
|
|
1190
|
+
"auto_loop: fix assignment %s not found on board — skipping",
|
|
1191
|
+
assignment_id,
|
|
1192
|
+
)
|
|
1193
|
+
return []
|
|
1194
|
+
|
|
1195
|
+
# #555: an *interactive* fix (provider_name="claude-pty") gets its re-review
|
|
1196
|
+
# from the human-attended TUI flow (leg 3 #517), never a headless metered
|
|
1197
|
+
# `claude -p` review. Skip the automatic re-review dispatch — mirrors the
|
|
1198
|
+
# dispatch_pending_reviews guard for the same interactive-blindness gap.
|
|
1199
|
+
if fix.provider_name == "claude-pty":
|
|
1200
|
+
log.info(
|
|
1201
|
+
"auto_loop: NOT dispatching headless re-review for %s — interactive "
|
|
1202
|
+
"fix (provider_name=claude-pty); re-review is human-attended",
|
|
1203
|
+
assignment_id,
|
|
1204
|
+
)
|
|
1205
|
+
return [LoopAction(
|
|
1206
|
+
kind="interactive_skip",
|
|
1207
|
+
assignment_id=assignment_id,
|
|
1208
|
+
detail=(
|
|
1209
|
+
"interactive fix — re-review is human-attended; "
|
|
1210
|
+
"no headless review dispatched"
|
|
1211
|
+
),
|
|
1212
|
+
)]
|
|
1213
|
+
|
|
1214
|
+
# #522: a fix worker that finished against already-merged/closed work must
|
|
1215
|
+
# not trigger another review. Guards the second flood vector (re-review
|
|
1216
|
+
# dispatch) the same way the fix-dispatch path is guarded above.
|
|
1217
|
+
if _work_is_terminal(fix, config, cache=terminal_cache):
|
|
1218
|
+
log.info(
|
|
1219
|
+
"auto_loop: NOT dispatching re-review for %s — issue #%s is "
|
|
1220
|
+
"terminal (merged/closed)",
|
|
1221
|
+
assignment_id, fix.issue_number,
|
|
1222
|
+
)
|
|
1223
|
+
fix.review_state = "done"
|
|
1224
|
+
write_board(board)
|
|
1225
|
+
return [LoopAction(
|
|
1226
|
+
kind="terminal_skip",
|
|
1227
|
+
assignment_id=assignment_id,
|
|
1228
|
+
detail=(
|
|
1229
|
+
f"issue #{fix.issue_number} already merged/closed — "
|
|
1230
|
+
"no re-review dispatched"
|
|
1231
|
+
),
|
|
1232
|
+
)]
|
|
1233
|
+
|
|
1234
|
+
max_iter = config.pipeline.max_review_iterations
|
|
1235
|
+
if fix.review_iteration >= max_iter:
|
|
1236
|
+
log.warning(
|
|
1237
|
+
"auto_loop: fix %s has review_iteration=%d >= max_review_iterations=%d "
|
|
1238
|
+
"— not dispatching another review",
|
|
1239
|
+
assignment_id, fix.review_iteration, max_iter,
|
|
1240
|
+
)
|
|
1241
|
+
# Surface the cap-hit as a persisted blocker: post a GitHub comment so
|
|
1242
|
+
# the operator sees it outside the TUI, mark the board entry with a
|
|
1243
|
+
# distinct review_state so `coord status` shows an explicit blocker line,
|
|
1244
|
+
# and save the board so the state survives a coordinator restart.
|
|
1245
|
+
_post_max_iterations_notice(fix, config)
|
|
1246
|
+
fix.review_state = "cap_hit"
|
|
1247
|
+
write_board(board)
|
|
1248
|
+
return [LoopAction(
|
|
1249
|
+
kind="iteration_cap_hit",
|
|
1250
|
+
assignment_id=assignment_id,
|
|
1251
|
+
detail=(
|
|
1252
|
+
f"fix iteration {fix.review_iteration} >= "
|
|
1253
|
+
f"max_review_iterations {max_iter}; "
|
|
1254
|
+
"not dispatching another review"
|
|
1255
|
+
),
|
|
1256
|
+
)]
|
|
1257
|
+
|
|
1258
|
+
# #1612: this is the *other* auto-dispatch path — `dispatch_pending_reviews`
|
|
1259
|
+
# (reached from reconcile()/`coord notify`) holds review dispatch until the
|
|
1260
|
+
# work carries a passed/skipped test verdict whenever
|
|
1261
|
+
# `pipeline.default_gates` orders Test before Review, but this fix-transition
|
|
1262
|
+
# path called `dispatch_review` directly, bypassing that gate entirely. A
|
|
1263
|
+
# fix round's review was therefore dispatched while its smoke test was
|
|
1264
|
+
# still `running` (or before it had even started), burning a metered
|
|
1265
|
+
# review on code of unknown quality. Mirror the gate here — but holding
|
|
1266
|
+
# cannot just `return []`: this function fires once, on the fix worker's
|
|
1267
|
+
# completion transition (`notify.py`'s `fix_completions`), and is never
|
|
1268
|
+
# re-entered, so a bare return would strand the row with no review ever
|
|
1269
|
+
# dispatched. Instead, set `review_state="pending"` so the row becomes
|
|
1270
|
+
# eligible for `dispatch_pending_reviews`, which runs every pass and
|
|
1271
|
+
# already carries the correct gate — this is a deferral to that path, not
|
|
1272
|
+
# a drop.
|
|
1273
|
+
if config.pipeline.test_precedes_review() and fix.test_state not in (
|
|
1274
|
+
"passed", "skipped",
|
|
1275
|
+
):
|
|
1276
|
+
log.info(
|
|
1277
|
+
"auto_loop: NOT dispatching re-review for %s yet — test gate is "
|
|
1278
|
+
"active and fix.test_state=%r is not a passed/skipped verdict; "
|
|
1279
|
+
"deferring to dispatch_pending_reviews",
|
|
1280
|
+
assignment_id, fix.test_state,
|
|
1281
|
+
)
|
|
1282
|
+
fix.review_state = "pending"
|
|
1283
|
+
write_board(board)
|
|
1284
|
+
return [LoopAction(
|
|
1285
|
+
kind="test_gate_held",
|
|
1286
|
+
assignment_id=assignment_id,
|
|
1287
|
+
detail=(
|
|
1288
|
+
f"test gate active — fix.test_state={fix.test_state!r} is not "
|
|
1289
|
+
"passed/skipped; review_state set to 'pending' for "
|
|
1290
|
+
"dispatch_pending_reviews to pick up once the verdict lands"
|
|
1291
|
+
),
|
|
1292
|
+
)]
|
|
1293
|
+
|
|
1294
|
+
review = dispatch_review(fix, board, config)
|
|
1295
|
+
|
|
1296
|
+
if review is None:
|
|
1297
|
+
log.warning(
|
|
1298
|
+
"auto_loop: dispatch_review returned None for fix %s "
|
|
1299
|
+
"(no capable machine or dedup check rejected the dispatch)",
|
|
1300
|
+
assignment_id,
|
|
1301
|
+
)
|
|
1302
|
+
return []
|
|
1303
|
+
|
|
1304
|
+
fix.review_state = "dispatched"
|
|
1305
|
+
write_board(board)
|
|
1306
|
+
|
|
1307
|
+
log.info(
|
|
1308
|
+
"auto_loop: dispatched re-review %s for fix worker %s (iteration %d/%d)",
|
|
1309
|
+
review.assignment_id, assignment_id, fix.review_iteration, max_iter,
|
|
1310
|
+
)
|
|
1311
|
+
return [LoopAction(
|
|
1312
|
+
kind="review_dispatched",
|
|
1313
|
+
assignment_id=assignment_id,
|
|
1314
|
+
detail=(
|
|
1315
|
+
f"re-review {review.assignment_id} dispatched to "
|
|
1316
|
+
f"{review.machine_name} (fix iteration {fix.review_iteration}/"
|
|
1317
|
+
f"{max_iter})"
|
|
1318
|
+
),
|
|
1319
|
+
)]
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def run_for_review_transition(
|
|
1323
|
+
assignment_id: str,
|
|
1324
|
+
record: dict,
|
|
1325
|
+
entry: dict,
|
|
1326
|
+
config: Config,
|
|
1327
|
+
*,
|
|
1328
|
+
terminal_cache: dict | None = None,
|
|
1329
|
+
) -> list[LoopAction]:
|
|
1330
|
+
"""Entry point called from ``notify.run()`` for each completed review.
|
|
1331
|
+
|
|
1332
|
+
Loads the board from the database, processes the review completion, saves
|
|
1333
|
+
the board if a fix worker was dispatched, and returns the list of actions
|
|
1334
|
+
taken.
|
|
1335
|
+
|
|
1336
|
+
**May dispatch a metered fix worker** — this is the ``coord notify`` /
|
|
1337
|
+
drive-nudge entry point. Callers that must not spawn one (the daemon
|
|
1338
|
+
drain) want :func:`propagate_review_verdict_for_transition` instead;
|
|
1339
|
+
``tests/test_notify_drain.py::test_does_not_run_the_review_auto_loop``
|
|
1340
|
+
patches *this* name and asserts it is never called from a drain pass.
|
|
1341
|
+
|
|
1342
|
+
Parameters
|
|
1343
|
+
----------
|
|
1344
|
+
assignment_id:
|
|
1345
|
+
The completed review's assignment ID.
|
|
1346
|
+
record:
|
|
1347
|
+
The dispatched-assignment record dict (from ``load_dispatched()``).
|
|
1348
|
+
entry:
|
|
1349
|
+
The agent /status entry for this assignment (contains ``log_path``).
|
|
1350
|
+
config:
|
|
1351
|
+
Parsed coordinator config.
|
|
1352
|
+
"""
|
|
1353
|
+
return _run_for_review_transition(
|
|
1354
|
+
assignment_id, record, entry, config,
|
|
1355
|
+
terminal_cache=terminal_cache, dispatch_fixes=True,
|
|
1356
|
+
)
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def propagate_review_verdict_for_transition(
|
|
1360
|
+
assignment_id: str,
|
|
1361
|
+
record: dict,
|
|
1362
|
+
entry: dict,
|
|
1363
|
+
config: Config,
|
|
1364
|
+
) -> list[LoopAction]:
|
|
1365
|
+
"""#1663: the drain-safe half of :func:`run_for_review_transition`.
|
|
1366
|
+
|
|
1367
|
+
Same board load / verdict resolution / parent-row write / persist, with
|
|
1368
|
+
:func:`_dispatch_fix_for_review` unreachable — a genuine ``request-changes``
|
|
1369
|
+
comes back as ``verdict_propagated`` rather than ``fix_dispatched``.
|
|
1370
|
+
|
|
1371
|
+
A deliberately *separate public name*, not a keyword argument on
|
|
1372
|
+
``run_for_review_transition``: the #476/#477 guard tests patch that symbol
|
|
1373
|
+
and assert it is never called from ``notify.run_drain``, and a flag would
|
|
1374
|
+
have made "the drain called the auto-loop entry point" indistinguishable
|
|
1375
|
+
from "the drain dispatched a fix worker" at the seam the guard watches.
|
|
1376
|
+
|
|
1377
|
+
No ``terminal_cache``: nothing on this path calls ``_work_is_terminal``, so
|
|
1378
|
+
a drain pass makes no ``gh`` round-trips on behalf of this function.
|
|
1379
|
+
"""
|
|
1380
|
+
return _run_for_review_transition(
|
|
1381
|
+
assignment_id, record, entry, config,
|
|
1382
|
+
terminal_cache=None, dispatch_fixes=False,
|
|
1383
|
+
)
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
def _run_for_review_transition(
|
|
1387
|
+
assignment_id: str,
|
|
1388
|
+
record: dict,
|
|
1389
|
+
entry: dict,
|
|
1390
|
+
config: Config,
|
|
1391
|
+
*,
|
|
1392
|
+
terminal_cache: dict | None = None,
|
|
1393
|
+
dispatch_fixes: bool = True,
|
|
1394
|
+
) -> list[LoopAction]:
|
|
1395
|
+
"""Shared body of the two review-transition entry points above."""
|
|
1396
|
+
if not config.pipeline.auto_loop:
|
|
1397
|
+
return [LoopAction(kind="disabled", assignment_id=assignment_id)]
|
|
1398
|
+
|
|
1399
|
+
if record.get("type") != "review":
|
|
1400
|
+
return []
|
|
1401
|
+
|
|
1402
|
+
# #749: read_board() routes through the daemon when board_service is
|
|
1403
|
+
# configured, instead of always hitting the local DB directly.
|
|
1404
|
+
board = read_board()
|
|
1405
|
+
|
|
1406
|
+
review = board.find_by_id(assignment_id)
|
|
1407
|
+
if review is None:
|
|
1408
|
+
# Review not on board yet — it was recorded by notify but the board
|
|
1409
|
+
# might not be persisted. Try looking it up by review_of_assignment_id
|
|
1410
|
+
# from the record dict and create a minimal proxy.
|
|
1411
|
+
log.debug(
|
|
1412
|
+
"auto_loop: review %s not found on board — cannot process", assignment_id
|
|
1413
|
+
)
|
|
1414
|
+
return []
|
|
1415
|
+
|
|
1416
|
+
log_path: str | None = entry.get("log_path")
|
|
1417
|
+
# #fix-cli: include the agent's host so the auto-loop can fall back
|
|
1418
|
+
# to HTTP /logs/<id> when the local log isn't on this filesystem
|
|
1419
|
+
# (the gap that left quadraui#166 without a fix dispatch).
|
|
1420
|
+
machine_host: str | None = None
|
|
1421
|
+
machine_name = record.get("machine_name")
|
|
1422
|
+
if machine_name:
|
|
1423
|
+
machine = next((m for m in config.machines if m.name == machine_name), None)
|
|
1424
|
+
if machine is not None and machine.host:
|
|
1425
|
+
machine_host = machine.host
|
|
1426
|
+
actions = process_review_completion(
|
|
1427
|
+
review,
|
|
1428
|
+
board,
|
|
1429
|
+
config,
|
|
1430
|
+
log_path=log_path,
|
|
1431
|
+
machine_host=machine_host,
|
|
1432
|
+
terminal_cache=terminal_cache,
|
|
1433
|
+
dispatch_fixes=dispatch_fixes,
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1436
|
+
# See PERSIST_ACTION_KINDS for why each kind implies a board write.
|
|
1437
|
+
if any(a.kind in PERSIST_ACTION_KINDS for a in actions):
|
|
1438
|
+
write_board(board)
|
|
1439
|
+
|
|
1440
|
+
return actions
|