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