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/comments.py
ADDED
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
"""Format and parse coordinator-authored issue comments.
|
|
2
|
+
|
|
3
|
+
Comments are the message bus between the coordinator and human reviewers. They
|
|
4
|
+
need to be readable in GitHub's UI *and* parseable by future automation, so
|
|
5
|
+
each comment carries an HTML-comment marker with key=value metadata.
|
|
6
|
+
|
|
7
|
+
Marker grammar: `<!-- coord:event=<event> assignment=<id> ... -->`
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Iterable
|
|
16
|
+
|
|
17
|
+
EVENT_BRIEFING = "briefing"
|
|
18
|
+
EVENT_COMPLETION = "completion"
|
|
19
|
+
EVENT_FAILURE = "failure"
|
|
20
|
+
EVENT_STUCK = "stuck"
|
|
21
|
+
EVENT_PLAN = "plan"
|
|
22
|
+
EVENT_ADVISORY = "advisory"
|
|
23
|
+
# #846: an assignment running past its wall-clock threshold, or thrashing
|
|
24
|
+
# through fix/review rounds without converging. Distinct from EVENT_STUCK
|
|
25
|
+
# (a worker self-reported STATUS/STUCK line) — this fires from time/round
|
|
26
|
+
# based detection, which also catches the "silently burned budget, looked
|
|
27
|
+
# productive the whole time" failure mode STUCK: lines miss (see #448).
|
|
28
|
+
EVENT_NEEDS_ATTENTION = "needs_attention"
|
|
29
|
+
# #1441: a pipeline row whose auto-loop TRANSITION already fired (a review
|
|
30
|
+
# completed, a fix completed) but which is now stuck on a precondition that
|
|
31
|
+
# landed too late for that one-shot reaction to see — e.g. a test verdict
|
|
32
|
+
# that arrives after the review it should have unblocked was already
|
|
33
|
+
# processed. Distinct from EVENT_NEEDS_ATTENTION (which watches *running*
|
|
34
|
+
# assignments for a wall-clock/convergence signal) — this watches *done*
|
|
35
|
+
# work chains for a downstream step that should have happened by now and
|
|
36
|
+
# didn't.
|
|
37
|
+
EVENT_STALLED = "stalled_pipeline"
|
|
38
|
+
# #2048: N consecutive BLOCKED verdicts from the cheap per-turn liveness
|
|
39
|
+
# auditor (coord/liveness_auditor.py). Distinct from EVENT_STUCK (self-
|
|
40
|
+
# reported) and EVENT_NEEDS_ATTENTION (wall-clock/round-count, no
|
|
41
|
+
# judgment) — this is an independent small-model *opinion* raised on a
|
|
42
|
+
# per-turn cadence, cheap enough to run continuously. Detection +
|
|
43
|
+
# surfacing only, exactly like EVENT_NEEDS_ATTENTION/EVENT_STALLED: never
|
|
44
|
+
# gates status/review_state/test_state.
|
|
45
|
+
EVENT_LIVENESS_STALL = "liveness_stall"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class CommentMarker:
|
|
50
|
+
event: str
|
|
51
|
+
fields: dict[str, str]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_MARKER_RE = re.compile(r"<!--\s*coord:(?P<body>[^>]*?)\s*-->")
|
|
55
|
+
_FIELD_RE = re.compile(r"(\w+)=(\S+)")
|
|
56
|
+
|
|
57
|
+
# ── Review-findings block ────────────────────────────────────────────────────
|
|
58
|
+
# The FULL review body, embedded in a completion comment under a parseable
|
|
59
|
+
# marker so a fix worker can recover it from the GitHub message bus on ANY
|
|
60
|
+
# machine (no shared DB required) — keyed by the review assignment id.
|
|
61
|
+
FINDINGS_BEGIN = "coord:review-findings"
|
|
62
|
+
FINDINGS_END = "coord:review-findings-end"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def format_findings_block(assignment_id: str, verdict: str | None, body: str) -> str:
|
|
66
|
+
"""Render the full review findings as a marked, GitHub-readable block."""
|
|
67
|
+
v = f" verdict={verdict}" if verdict else ""
|
|
68
|
+
return (
|
|
69
|
+
f"<!-- {FINDINGS_BEGIN} assignment={assignment_id}{v} -->\n"
|
|
70
|
+
f"### Review findings\n\n{body.strip()}\n"
|
|
71
|
+
f"<!-- {FINDINGS_END} -->"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def extract_findings_block(
|
|
76
|
+
comment_body: str, assignment_id: str
|
|
77
|
+
) -> tuple[str | None, str] | None:
|
|
78
|
+
"""Return ``(verdict, body)`` from a comment carrying the marked findings
|
|
79
|
+
block for ``assignment_id``, or ``None`` when absent. ``verdict`` is read
|
|
80
|
+
from the marker and may be ``None`` if it wasn't recorded."""
|
|
81
|
+
pat = re.compile(
|
|
82
|
+
r"<!--\s*" + re.escape(FINDINGS_BEGIN) + r"\s+assignment="
|
|
83
|
+
+ re.escape(assignment_id) + r"(?P<attrs>[^>]*)-->\s*(?P<body>.*?)\s*<!--\s*"
|
|
84
|
+
+ re.escape(FINDINGS_END) + r"\s*-->",
|
|
85
|
+
re.DOTALL,
|
|
86
|
+
)
|
|
87
|
+
m = pat.search(comment_body or "")
|
|
88
|
+
if not m:
|
|
89
|
+
return None
|
|
90
|
+
vm = re.search(r"verdict=(\S+)", m.group("attrs") or "")
|
|
91
|
+
verdict = vm.group(1) if vm else None
|
|
92
|
+
body = re.sub(r"^#+\s*Review findings\s*\n+", "", m.group("body").strip(), count=1)
|
|
93
|
+
body = body.strip()
|
|
94
|
+
if not body:
|
|
95
|
+
return None
|
|
96
|
+
return (verdict, body)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ── Milestone Outcome Audit scorecard (#886 Phase 2) ────────────────────────
|
|
100
|
+
# The structured, versioned verdict from a `--audit-of` run (#885), embedded
|
|
101
|
+
# under a parseable marker so ANY machine can recover the full goal-by-goal
|
|
102
|
+
# JSON from the GitHub message bus alone — the same "no shared DB required"
|
|
103
|
+
# design as the review-findings block above.
|
|
104
|
+
AUDIT_BEGIN = "coord:audit-scorecard"
|
|
105
|
+
AUDIT_END = "coord:audit-scorecard-end"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def format_audit_scorecard(
|
|
109
|
+
*,
|
|
110
|
+
assignment_id: str,
|
|
111
|
+
run_number: int,
|
|
112
|
+
bottom_line: str,
|
|
113
|
+
goals: list[dict],
|
|
114
|
+
diff: dict[str, list[str]] | None = None,
|
|
115
|
+
) -> str:
|
|
116
|
+
"""Render a milestone-audit scorecard: a human-readable table + delta vs
|
|
117
|
+
the prior run, with the raw goal JSON tucked in a collapsible section so
|
|
118
|
+
a future agent (or `extract_audit_scorecard`) can recover it exactly."""
|
|
119
|
+
|
|
120
|
+
def _cell(v: object) -> str:
|
|
121
|
+
return str(v if v is not None else "").replace("|", "\\|").replace("\n", " ")
|
|
122
|
+
|
|
123
|
+
table = ["| Goal | Before | After | Verdict | Evidence |", "|---|---|---|---|---|"]
|
|
124
|
+
for g in goals:
|
|
125
|
+
table.append(
|
|
126
|
+
f"| {_cell(g.get('goal'))} | {_cell(g.get('metric_before'))} | "
|
|
127
|
+
f"{_cell(g.get('metric_after'))} | {_cell(g.get('verdict'))} | "
|
|
128
|
+
f"{_cell(g.get('evidence'))} |"
|
|
129
|
+
)
|
|
130
|
+
lines = [
|
|
131
|
+
f"### Milestone outcome audit — run v{run_number}",
|
|
132
|
+
f"<!-- {AUDIT_BEGIN} assignment={assignment_id} run={run_number} -->",
|
|
133
|
+
"",
|
|
134
|
+
f"**Bottom line:** {bottom_line or '(none provided)'}",
|
|
135
|
+
"",
|
|
136
|
+
*table,
|
|
137
|
+
]
|
|
138
|
+
if diff and any(diff.get(k) for k in ("closed", "regressed", "still_open", "new")):
|
|
139
|
+
lines.append("")
|
|
140
|
+
lines.append(f"**Delta vs run v{run_number - 1}:**")
|
|
141
|
+
if diff.get("closed"):
|
|
142
|
+
lines.append(f"- ✅ closed: {', '.join(diff['closed'])}")
|
|
143
|
+
if diff.get("regressed"):
|
|
144
|
+
lines.append(f"- ⚠️ regressed: {', '.join(diff['regressed'])}")
|
|
145
|
+
if diff.get("still_open"):
|
|
146
|
+
lines.append(f"- still open: {', '.join(diff['still_open'])}")
|
|
147
|
+
if diff.get("new"):
|
|
148
|
+
lines.append(f"- new goals: {', '.join(diff['new'])}")
|
|
149
|
+
lines.append("")
|
|
150
|
+
lines.append("<details><summary>Structured verdict (JSON)</summary>")
|
|
151
|
+
lines.append("")
|
|
152
|
+
lines.append("```json")
|
|
153
|
+
lines.append(json.dumps(goals, indent=2))
|
|
154
|
+
lines.append("```")
|
|
155
|
+
lines.append("")
|
|
156
|
+
lines.append("</details>")
|
|
157
|
+
lines.append(f"<!-- {AUDIT_END} -->")
|
|
158
|
+
return "\n".join(lines)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def extract_audit_scorecard(comment_body: str) -> dict | None:
|
|
162
|
+
"""Parse a marked audit scorecard back into
|
|
163
|
+
``{"assignment_id", "run_number", "bottom_line", "goals"}``, or ``None``
|
|
164
|
+
if the comment carries no scorecard block. Round-trip counterpart to
|
|
165
|
+
:func:`format_audit_scorecard`."""
|
|
166
|
+
pat = re.compile(
|
|
167
|
+
r"<!--\s*" + re.escape(AUDIT_BEGIN)
|
|
168
|
+
+ r"\s+assignment=(?P<aid>\S+)\s+run=(?P<run>\d+)\s*-->"
|
|
169
|
+
r"(?P<body>.*?)<!--\s*" + re.escape(AUDIT_END) + r"\s*-->",
|
|
170
|
+
re.DOTALL,
|
|
171
|
+
)
|
|
172
|
+
m = pat.search(comment_body or "")
|
|
173
|
+
if not m:
|
|
174
|
+
return None
|
|
175
|
+
body = m.group("body")
|
|
176
|
+
bl_m = re.search(r"\*\*Bottom line:\*\*\s*(.+)", body)
|
|
177
|
+
bottom_line = bl_m.group(1).strip() if bl_m else ""
|
|
178
|
+
json_m = re.search(r"```json\s*(\[.*?\])\s*```", body, re.DOTALL)
|
|
179
|
+
try:
|
|
180
|
+
goals = json.loads(json_m.group(1)) if json_m else []
|
|
181
|
+
except (TypeError, ValueError):
|
|
182
|
+
goals = []
|
|
183
|
+
return {
|
|
184
|
+
"assignment_id": m.group("aid"),
|
|
185
|
+
"run_number": int(m.group("run")),
|
|
186
|
+
"bottom_line": bottom_line,
|
|
187
|
+
"goals": goals,
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _marker(event: str, **fields: str | int | None) -> str:
|
|
192
|
+
parts = [f"event={event}"]
|
|
193
|
+
for k, v in fields.items():
|
|
194
|
+
if v is None or v == "":
|
|
195
|
+
continue
|
|
196
|
+
parts.append(f"{k}={v}")
|
|
197
|
+
return f"<!-- coord:{' '.join(parts)} -->"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def parse_marker(body: str) -> CommentMarker | None:
|
|
201
|
+
"""Return the first coord marker in a comment body, or None."""
|
|
202
|
+
m = _MARKER_RE.search(body)
|
|
203
|
+
if not m:
|
|
204
|
+
return None
|
|
205
|
+
raw = m.group("body")
|
|
206
|
+
fields = dict(_FIELD_RE.findall(raw))
|
|
207
|
+
event = fields.pop("event", "")
|
|
208
|
+
if not event:
|
|
209
|
+
return None
|
|
210
|
+
return CommentMarker(event=event, fields=fields)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# ── Unified parse for durable storage (#873) ─────────────────────────────────
|
|
214
|
+
# coord.review posts a differently-shaped header — `<!-- coord:review
|
|
215
|
+
# verdict=... -->` (no `event=` token) — which parse_marker() above doesn't
|
|
216
|
+
# recognise (it requires `event=`). A self-contained regex is duplicated here
|
|
217
|
+
# rather than importing coord.review, to avoid a circular import: github_ops
|
|
218
|
+
# (the #873 capture-at-write choke point) needs coord.state, and coord.review
|
|
219
|
+
# already imports coord.github_ops.
|
|
220
|
+
_REVIEW_MARKER_RE = re.compile(r"<!--\s*coord:review\s+([^>]+?)\s*-->")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def parse_coord_comment_marker(body: str) -> dict[str, str | None] | None:
|
|
224
|
+
"""Unified marker parse across all coord comment grammars, for the
|
|
225
|
+
``issue_comments`` durable mirror (#873).
|
|
226
|
+
|
|
227
|
+
Tries the generic ``coord:event=...`` marker first (covers
|
|
228
|
+
briefing/completion/failure/stuck/plan/advisory/needs_attention), then
|
|
229
|
+
falls back to the ``coord:review verdict=...`` header format used by
|
|
230
|
+
posted review bodies. Returns ``{"event", "assignment_id", "machine",
|
|
231
|
+
"verdict"}`` (any value may be ``None``), or ``None`` when *body* carries
|
|
232
|
+
no recognisable coord marker at all.
|
|
233
|
+
"""
|
|
234
|
+
marker = parse_marker(body)
|
|
235
|
+
if marker is not None:
|
|
236
|
+
return {
|
|
237
|
+
"event": marker.event,
|
|
238
|
+
"assignment_id": marker.fields.get("assignment"),
|
|
239
|
+
"machine": marker.fields.get("machine"),
|
|
240
|
+
"verdict": marker.fields.get("verdict"),
|
|
241
|
+
}
|
|
242
|
+
m = _REVIEW_MARKER_RE.search(body or "")
|
|
243
|
+
if not m:
|
|
244
|
+
return None
|
|
245
|
+
fields = dict(_FIELD_RE.findall(m.group(1)))
|
|
246
|
+
if "verdict" not in fields:
|
|
247
|
+
return None
|
|
248
|
+
return {
|
|
249
|
+
"event": "review",
|
|
250
|
+
"assignment_id": fields.get("assignment"),
|
|
251
|
+
"machine": fields.get("reviewer"),
|
|
252
|
+
"verdict": fields.get("verdict"),
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _fmt_files(files: Iterable[str]) -> str:
|
|
257
|
+
files = list(files)
|
|
258
|
+
if not files:
|
|
259
|
+
return "(unspecified)"
|
|
260
|
+
return ", ".join(f"`{f}`" for f in files)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _fmt_duration(seconds: float | None) -> str:
|
|
264
|
+
if seconds is None:
|
|
265
|
+
return "—"
|
|
266
|
+
seconds = int(seconds)
|
|
267
|
+
if seconds < 60:
|
|
268
|
+
return f"{seconds}s"
|
|
269
|
+
m, s = divmod(seconds, 60)
|
|
270
|
+
if m < 60:
|
|
271
|
+
return f"{m}m {s}s"
|
|
272
|
+
h, m = divmod(m, 60)
|
|
273
|
+
return f"{h}h {m}m"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def format_briefing(
|
|
277
|
+
*,
|
|
278
|
+
assignment_id: str,
|
|
279
|
+
machine_name: str,
|
|
280
|
+
repo_name: str,
|
|
281
|
+
issue_number: int,
|
|
282
|
+
briefing: str,
|
|
283
|
+
files_likely: Iterable[str] = (),
|
|
284
|
+
do_not_touch: Iterable[tuple[str, str]] = (),
|
|
285
|
+
) -> str:
|
|
286
|
+
"""Build the issue comment posted when an assignment is dispatched.
|
|
287
|
+
|
|
288
|
+
`do_not_touch` is a sequence of (file_path, reason) pairs describing files
|
|
289
|
+
other workers are currently touching.
|
|
290
|
+
"""
|
|
291
|
+
marker = _marker(
|
|
292
|
+
EVENT_BRIEFING,
|
|
293
|
+
assignment=assignment_id,
|
|
294
|
+
machine=machine_name,
|
|
295
|
+
repo=repo_name,
|
|
296
|
+
issue=issue_number,
|
|
297
|
+
)
|
|
298
|
+
lines = [
|
|
299
|
+
"## Coordinator Assignment",
|
|
300
|
+
marker,
|
|
301
|
+
f"**Machine:** {machine_name}",
|
|
302
|
+
f"**Repo:** {repo_name}",
|
|
303
|
+
f"**Files:** {_fmt_files(files_likely)}",
|
|
304
|
+
]
|
|
305
|
+
dnt = list(do_not_touch)
|
|
306
|
+
if dnt:
|
|
307
|
+
rendered = ", ".join(f"`{path}` ({reason})" for path, reason in dnt)
|
|
308
|
+
lines.append(f"**Do not touch:** {rendered}")
|
|
309
|
+
else:
|
|
310
|
+
lines.append("**Do not touch:** (nothing else in flight)")
|
|
311
|
+
lines.append("")
|
|
312
|
+
lines.append("### Briefing")
|
|
313
|
+
lines.append(briefing.strip() or "(no briefing provided)")
|
|
314
|
+
return "\n".join(lines)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def format_completion(
|
|
318
|
+
*,
|
|
319
|
+
assignment_id: str,
|
|
320
|
+
machine_name: str,
|
|
321
|
+
repo_name: str,
|
|
322
|
+
issue_number: int,
|
|
323
|
+
exit_code: int,
|
|
324
|
+
duration_seconds: float | None = None,
|
|
325
|
+
log_path: str | None = None,
|
|
326
|
+
summary: str = "",
|
|
327
|
+
) -> str:
|
|
328
|
+
marker = _marker(
|
|
329
|
+
EVENT_COMPLETION,
|
|
330
|
+
assignment=assignment_id,
|
|
331
|
+
machine=machine_name,
|
|
332
|
+
repo=repo_name,
|
|
333
|
+
issue=issue_number,
|
|
334
|
+
exit_code=exit_code,
|
|
335
|
+
)
|
|
336
|
+
lines = [
|
|
337
|
+
"## Coordinator: Assignment Complete",
|
|
338
|
+
marker,
|
|
339
|
+
f"**Machine:** {machine_name}",
|
|
340
|
+
f"**Status:** done",
|
|
341
|
+
f"**Exit code:** {exit_code}",
|
|
342
|
+
f"**Duration:** {_fmt_duration(duration_seconds)}",
|
|
343
|
+
]
|
|
344
|
+
if log_path:
|
|
345
|
+
lines.append(f"**Log:** `{log_path}`")
|
|
346
|
+
if summary.strip():
|
|
347
|
+
lines.append("")
|
|
348
|
+
lines.append("### Summary")
|
|
349
|
+
lines.append(summary.strip())
|
|
350
|
+
return "\n".join(lines)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def format_stuck(
|
|
354
|
+
*,
|
|
355
|
+
assignment_id: str,
|
|
356
|
+
machine_name: str,
|
|
357
|
+
repo_name: str,
|
|
358
|
+
issue_number: int,
|
|
359
|
+
stuck_message: str,
|
|
360
|
+
) -> str:
|
|
361
|
+
marker = _marker(
|
|
362
|
+
EVENT_STUCK,
|
|
363
|
+
assignment=assignment_id,
|
|
364
|
+
machine=machine_name,
|
|
365
|
+
repo=repo_name,
|
|
366
|
+
)
|
|
367
|
+
lines = [
|
|
368
|
+
marker,
|
|
369
|
+
f"## ⚠️ Worker STUCK",
|
|
370
|
+
f"**Machine:** {machine_name}",
|
|
371
|
+
f"**Assignment:** {assignment_id}",
|
|
372
|
+
f"**Issue:** #{issue_number}",
|
|
373
|
+
"",
|
|
374
|
+
stuck_message.strip(),
|
|
375
|
+
"",
|
|
376
|
+
"The worker has stopped and is waiting for guidance. Use:",
|
|
377
|
+
f"`coord resume-stuck {assignment_id} --guidance \"your answer here\"`",
|
|
378
|
+
]
|
|
379
|
+
return "\n".join(lines)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def format_needs_attention(
|
|
383
|
+
*,
|
|
384
|
+
assignment_id: str,
|
|
385
|
+
machine_name: str,
|
|
386
|
+
repo_name: str,
|
|
387
|
+
issue_number: int,
|
|
388
|
+
reason: str,
|
|
389
|
+
detail: str,
|
|
390
|
+
) -> str:
|
|
391
|
+
"""Format a "needs attention" comment (#846) — a time/round-based signal
|
|
392
|
+
that an assignment is running long or thrashing, distinct from a
|
|
393
|
+
worker's self-reported ``STUCK:`` line.
|
|
394
|
+
|
|
395
|
+
*reason* is ``"wall_clock"`` or ``"non_convergence"``; *detail* is a
|
|
396
|
+
human-readable one-liner (e.g. "running 52m, threshold 45m" or
|
|
397
|
+
"4 fix/review rounds without a green test + approved review").
|
|
398
|
+
"""
|
|
399
|
+
marker = _marker(
|
|
400
|
+
EVENT_NEEDS_ATTENTION,
|
|
401
|
+
assignment=assignment_id,
|
|
402
|
+
machine=machine_name,
|
|
403
|
+
repo=repo_name,
|
|
404
|
+
reason=reason,
|
|
405
|
+
)
|
|
406
|
+
lines = [
|
|
407
|
+
marker,
|
|
408
|
+
"## ⏱ Needs attention",
|
|
409
|
+
f"**Machine:** {machine_name}",
|
|
410
|
+
f"**Assignment:** {assignment_id}",
|
|
411
|
+
f"**Issue:** #{issue_number}",
|
|
412
|
+
f"**Reason:** {'Running too long' if reason == 'wall_clock' else 'Not converging'}",
|
|
413
|
+
"",
|
|
414
|
+
detail.strip(),
|
|
415
|
+
"",
|
|
416
|
+
"Detection + surfacing only — nothing was killed or reassigned. A "
|
|
417
|
+
"human should take a look; use `coord log <id>` to see what it's "
|
|
418
|
+
"doing, or `coord retry`/`coord stop` to intervene.",
|
|
419
|
+
]
|
|
420
|
+
return "\n".join(lines)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def format_liveness_stall(
|
|
424
|
+
*,
|
|
425
|
+
assignment_id: str,
|
|
426
|
+
machine_name: str,
|
|
427
|
+
repo_name: str,
|
|
428
|
+
issue_number: int,
|
|
429
|
+
consecutive_blocked: int,
|
|
430
|
+
) -> str:
|
|
431
|
+
"""Format a "liveness auditor" comment (#2048) — *consecutive_blocked*
|
|
432
|
+
independent per-turn audits in a row judged this worker's latest turn
|
|
433
|
+
as showing no progress toward the objective.
|
|
434
|
+
|
|
435
|
+
This is a cheap small-model *opinion*, not a correctness check — it
|
|
436
|
+
complements, and is deliberately weaker than, an adversarial review or
|
|
437
|
+
the sealed oracle suite. See ``coord/liveness_auditor.py``'s module
|
|
438
|
+
docstring.
|
|
439
|
+
"""
|
|
440
|
+
marker = _marker(
|
|
441
|
+
EVENT_LIVENESS_STALL,
|
|
442
|
+
assignment=assignment_id,
|
|
443
|
+
machine=machine_name,
|
|
444
|
+
repo=repo_name,
|
|
445
|
+
strikes=consecutive_blocked,
|
|
446
|
+
)
|
|
447
|
+
lines = [
|
|
448
|
+
marker,
|
|
449
|
+
"## 🐢 Liveness auditor: possible stall",
|
|
450
|
+
f"**Machine:** {machine_name}",
|
|
451
|
+
f"**Assignment:** {assignment_id}",
|
|
452
|
+
f"**Issue:** #{issue_number}",
|
|
453
|
+
f"**Consecutive BLOCKED verdicts:** {consecutive_blocked}",
|
|
454
|
+
"",
|
|
455
|
+
(
|
|
456
|
+
"An independent, cheap small-model auditor judged this "
|
|
457
|
+
"worker's last few turns as showing no progress toward the "
|
|
458
|
+
"objective — repeating itself, confused, or spinning. It saw "
|
|
459
|
+
"only the objective and each turn's own output, never the "
|
|
460
|
+
"transcript or the worker's self-reported status."
|
|
461
|
+
),
|
|
462
|
+
"",
|
|
463
|
+
"This is a tripwire, not a verdict: nothing was killed, gated, or "
|
|
464
|
+
"reassigned, and no board status changed. It runs continuously "
|
|
465
|
+
"for pennies and is meant to be wrong sometimes — a human should "
|
|
466
|
+
"take a look with `coord log <id>` and decide whether to "
|
|
467
|
+
"intervene.",
|
|
468
|
+
]
|
|
469
|
+
return "\n".join(lines)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
_STALLED_REASON_LABELS = {
|
|
473
|
+
"review_request_changes_no_fix": "Review requested changes, no fix dispatched",
|
|
474
|
+
"review_done_no_verdict": "Review finished with no verdict ever captured (#812)",
|
|
475
|
+
"done_no_review": "Work done, no review ever dispatched",
|
|
476
|
+
"approved_not_queued": "Approved + tested, but not queued for merge",
|
|
477
|
+
"merge_conflict_unresolved": "Merge entry stuck in CONFLICT, no fix attempted",
|
|
478
|
+
"review_failed_no_verdict": "Review worker failed before producing a verdict",
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def format_stalled_pipeline(
|
|
483
|
+
*,
|
|
484
|
+
assignment_id: str,
|
|
485
|
+
machine_name: str,
|
|
486
|
+
repo_name: str,
|
|
487
|
+
issue_number: int,
|
|
488
|
+
reason: str,
|
|
489
|
+
detail: str,
|
|
490
|
+
) -> str:
|
|
491
|
+
"""Format a "stalled pipeline row" comment (#1441).
|
|
492
|
+
|
|
493
|
+
*reason* is one of ``"review_request_changes_no_fix"``,
|
|
494
|
+
``"done_no_review"``, ``"approved_not_queued"``,
|
|
495
|
+
``"merge_conflict_unresolved"``, or ``"review_failed_no_verdict"``
|
|
496
|
+
(#1584); *detail* is a human-readable one-liner naming the unmet
|
|
497
|
+
precondition.
|
|
498
|
+
"""
|
|
499
|
+
marker = _marker(
|
|
500
|
+
EVENT_STALLED,
|
|
501
|
+
assignment=assignment_id,
|
|
502
|
+
machine=machine_name,
|
|
503
|
+
repo=repo_name,
|
|
504
|
+
reason=reason,
|
|
505
|
+
)
|
|
506
|
+
label = _STALLED_REASON_LABELS.get(reason, reason)
|
|
507
|
+
lines = [
|
|
508
|
+
marker,
|
|
509
|
+
"## 🧊 Pipeline row stalled",
|
|
510
|
+
f"**Machine:** {machine_name}",
|
|
511
|
+
f"**Assignment:** {assignment_id}",
|
|
512
|
+
f"**Issue:** #{issue_number}",
|
|
513
|
+
f"**Reason:** {label}",
|
|
514
|
+
"",
|
|
515
|
+
detail.strip(),
|
|
516
|
+
"",
|
|
517
|
+
"Detection + surfacing only — nothing was dispatched automatically. "
|
|
518
|
+
"This row's auto-loop transition already fired once and won't fire "
|
|
519
|
+
"again on its own; a human should look at `coord status` and "
|
|
520
|
+
"decide whether to dispatch a fix/review manually or drop it with "
|
|
521
|
+
"`coord backlog`.",
|
|
522
|
+
]
|
|
523
|
+
return "\n".join(lines)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def format_stalled_pipeline_dispatch(
|
|
527
|
+
*,
|
|
528
|
+
assignment_id: str,
|
|
529
|
+
repo_name: str,
|
|
530
|
+
issue_number: int,
|
|
531
|
+
reason: str,
|
|
532
|
+
action_kind: str,
|
|
533
|
+
action_detail: str,
|
|
534
|
+
) -> str:
|
|
535
|
+
"""Format the #1478 auto-dispatch outcome comment for a stalled row.
|
|
536
|
+
|
|
537
|
+
Posted INSTEAD OF :func:`format_stalled_pipeline`'s diagnostic-only
|
|
538
|
+
comment when ``pipeline.auto_dispatch_stalled`` is on and
|
|
539
|
+
``coord.notify.dispatch_stalled_pipeline_action`` actually dispatched
|
|
540
|
+
something for this row — posting both would leave a directly
|
|
541
|
+
contradictory "nothing was dispatched automatically" comment sitting
|
|
542
|
+
right above this one on the same issue thread.
|
|
543
|
+
"""
|
|
544
|
+
marker = _marker(
|
|
545
|
+
EVENT_STALLED,
|
|
546
|
+
assignment=assignment_id,
|
|
547
|
+
repo=repo_name,
|
|
548
|
+
reason=reason,
|
|
549
|
+
auto_dispatch=action_kind,
|
|
550
|
+
)
|
|
551
|
+
label = _STALLED_REASON_LABELS.get(reason, reason)
|
|
552
|
+
lines = [
|
|
553
|
+
marker,
|
|
554
|
+
"## ⚙️ Stalled pipeline row auto-dispatched",
|
|
555
|
+
f"**Assignment:** {assignment_id}",
|
|
556
|
+
f"**Issue:** #{issue_number}",
|
|
557
|
+
f"**Stall reason:** {label}",
|
|
558
|
+
f"**Action taken:** `{action_kind}`",
|
|
559
|
+
"",
|
|
560
|
+
action_detail.strip(),
|
|
561
|
+
"",
|
|
562
|
+
"The coordinator's stalled-pipeline sweep (#1478, "
|
|
563
|
+
"`pipeline.auto_dispatch_stalled`) dispatched the action the "
|
|
564
|
+
"original auto-loop transition would have taken. This is "
|
|
565
|
+
"best-effort automation — check `coord status` if the outcome "
|
|
566
|
+
"looks wrong.",
|
|
567
|
+
]
|
|
568
|
+
return "\n".join(lines)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def format_failure(
|
|
572
|
+
*,
|
|
573
|
+
assignment_id: str,
|
|
574
|
+
machine_name: str,
|
|
575
|
+
repo_name: str,
|
|
576
|
+
issue_number: int,
|
|
577
|
+
exit_code: int | None,
|
|
578
|
+
duration_seconds: float | None = None,
|
|
579
|
+
log_path: str | None = None,
|
|
580
|
+
error: str = "",
|
|
581
|
+
) -> str:
|
|
582
|
+
marker = _marker(
|
|
583
|
+
EVENT_FAILURE,
|
|
584
|
+
assignment=assignment_id,
|
|
585
|
+
machine=machine_name,
|
|
586
|
+
repo=repo_name,
|
|
587
|
+
issue=issue_number,
|
|
588
|
+
exit_code=exit_code if exit_code is not None else "",
|
|
589
|
+
)
|
|
590
|
+
lines = [
|
|
591
|
+
"## Coordinator: Assignment Failed",
|
|
592
|
+
marker,
|
|
593
|
+
f"**Machine:** {machine_name}",
|
|
594
|
+
f"**Status:** failed",
|
|
595
|
+
f"**Exit code:** {exit_code if exit_code is not None else '—'}",
|
|
596
|
+
f"**Duration:** {_fmt_duration(duration_seconds)}",
|
|
597
|
+
]
|
|
598
|
+
if log_path:
|
|
599
|
+
lines.append(f"**Log:** `{log_path}`")
|
|
600
|
+
if error.strip():
|
|
601
|
+
lines.append("")
|
|
602
|
+
lines.append("### Error")
|
|
603
|
+
lines.append(error.strip())
|
|
604
|
+
return "\n".join(lines)
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def format_advisory(
|
|
608
|
+
*,
|
|
609
|
+
assignment_id: str,
|
|
610
|
+
machine_name: str,
|
|
611
|
+
repo_name: str,
|
|
612
|
+
issue_number: int,
|
|
613
|
+
duration_seconds: float | None = None,
|
|
614
|
+
log_path: str | None = None,
|
|
615
|
+
reason: str = "",
|
|
616
|
+
) -> str:
|
|
617
|
+
"""Format an advisory comment for a 0-commit clean exit.
|
|
618
|
+
|
|
619
|
+
Distinct from both completion (no code was produced) and failure (the
|
|
620
|
+
worker exited cleanly — exit_code 0 — so no error occurred). Human
|
|
621
|
+
review is needed to decide next steps.
|
|
622
|
+
"""
|
|
623
|
+
marker = _marker(
|
|
624
|
+
EVENT_ADVISORY,
|
|
625
|
+
assignment=assignment_id,
|
|
626
|
+
machine=machine_name,
|
|
627
|
+
repo=repo_name,
|
|
628
|
+
issue=issue_number,
|
|
629
|
+
)
|
|
630
|
+
lines = [
|
|
631
|
+
"## Coordinator: Advisory — Worker Exited With 0 Commits",
|
|
632
|
+
marker,
|
|
633
|
+
f"**Machine:** {machine_name}",
|
|
634
|
+
f"**Status:** advisory",
|
|
635
|
+
f"**Duration:** {_fmt_duration(duration_seconds)}",
|
|
636
|
+
]
|
|
637
|
+
if log_path:
|
|
638
|
+
lines.append(f"**Log:** `{log_path}`")
|
|
639
|
+
lines.append("")
|
|
640
|
+
lines.append(
|
|
641
|
+
"The worker exited cleanly (exit code 0) but pushed **no commits**. "
|
|
642
|
+
"This typically means the feature was already implemented or no change "
|
|
643
|
+
"was needed. Human review is required to decide the next step."
|
|
644
|
+
)
|
|
645
|
+
if reason.strip():
|
|
646
|
+
lines.append("")
|
|
647
|
+
lines.append("### Worker note")
|
|
648
|
+
lines.append(reason.strip())
|
|
649
|
+
return "\n".join(lines)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def format_plan(
|
|
653
|
+
*,
|
|
654
|
+
assignment_id: str,
|
|
655
|
+
machine_name: str,
|
|
656
|
+
repo_name: str,
|
|
657
|
+
issue_number: int,
|
|
658
|
+
plan: object, # coord.plan_parser.WorkerPlan (untyped to avoid circular import)
|
|
659
|
+
duration_seconds: float | None = None,
|
|
660
|
+
) -> str:
|
|
661
|
+
"""Build the issue comment posted when a plan-only assignment completes.
|
|
662
|
+
|
|
663
|
+
*plan* should be a :class:`coord.plan_parser.WorkerPlan` instance, but the
|
|
664
|
+
parameter is typed as ``object`` to avoid a circular import — callers that
|
|
665
|
+
already have the dataclass can pass it directly.
|
|
666
|
+
"""
|
|
667
|
+
marker = _marker(
|
|
668
|
+
EVENT_PLAN,
|
|
669
|
+
assignment=assignment_id,
|
|
670
|
+
machine=machine_name,
|
|
671
|
+
repo=repo_name,
|
|
672
|
+
issue=issue_number,
|
|
673
|
+
)
|
|
674
|
+
lines = [
|
|
675
|
+
"## Coordinator: Implementation Plan",
|
|
676
|
+
marker,
|
|
677
|
+
f"**Machine:** {machine_name}",
|
|
678
|
+
f"**Duration:** {_fmt_duration(duration_seconds)}",
|
|
679
|
+
"",
|
|
680
|
+
]
|
|
681
|
+
|
|
682
|
+
plan_text: str = getattr(plan, "plan", "") or ""
|
|
683
|
+
files_read: list[str] = getattr(plan, "files_read", []) or []
|
|
684
|
+
files_modify: list[str] = getattr(plan, "files_modify", []) or []
|
|
685
|
+
approach: str = getattr(plan, "approach", "") or ""
|
|
686
|
+
risks: str = getattr(plan, "risks", "") or ""
|
|
687
|
+
estimate: str = getattr(plan, "estimate", "") or ""
|
|
688
|
+
|
|
689
|
+
if plan_text.strip():
|
|
690
|
+
lines.append("### Summary")
|
|
691
|
+
lines.append(plan_text.strip())
|
|
692
|
+
lines.append("")
|
|
693
|
+
|
|
694
|
+
if files_read:
|
|
695
|
+
lines.append("### Files Read")
|
|
696
|
+
lines.append(", ".join(f"`{f}`" for f in files_read))
|
|
697
|
+
lines.append("")
|
|
698
|
+
|
|
699
|
+
if files_modify:
|
|
700
|
+
lines.append("### Files to Modify")
|
|
701
|
+
lines.append(", ".join(f"`{f}`" for f in files_modify))
|
|
702
|
+
lines.append("")
|
|
703
|
+
|
|
704
|
+
if approach.strip():
|
|
705
|
+
lines.append("### Approach")
|
|
706
|
+
lines.append(approach.strip())
|
|
707
|
+
lines.append("")
|
|
708
|
+
|
|
709
|
+
if risks.strip():
|
|
710
|
+
lines.append("### Risks")
|
|
711
|
+
lines.append(risks.strip())
|
|
712
|
+
lines.append("")
|
|
713
|
+
|
|
714
|
+
if estimate.strip():
|
|
715
|
+
lines.append("### Estimate")
|
|
716
|
+
lines.append(estimate.strip())
|
|
717
|
+
|
|
718
|
+
return "\n".join(lines)
|