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
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
"""Milestone dispatch — Phase 1 of #767 (milestone-driven workflow, #769).
|
|
2
|
+
|
|
3
|
+
Turns Phase 0's pure DAG/frontier (:mod:`coord.milestone_order`) into actual
|
|
4
|
+
dispatches: fetch a milestone's tracking-issue context from GitHub, compute
|
|
5
|
+
the ready frontier, pick an idle/capable machine for each ready issue, and
|
|
6
|
+
dispatch it through the same primitives ``coord assign`` uses
|
|
7
|
+
(:func:`coord.dispatch.dispatch` + ``record_dispatched`` + ``post_briefing``)
|
|
8
|
+
— no new dispatch mechanism.
|
|
9
|
+
|
|
10
|
+
Deliberately mechanical / ~zero-Claude-per-decision, matching Phase 0's
|
|
11
|
+
design note: machine selection is a plain deterministic filter (idle,
|
|
12
|
+
``Machine.can_work_on(repo)``, not routing-paused — the same candidate filter
|
|
13
|
+
``coord.reconcile._reassign`` and ``coord.review.pick_reviewer_machine`` use),
|
|
14
|
+
not an LLM judgment call like ``coord.brain.propose``.
|
|
15
|
+
|
|
16
|
+
Three call sites share this module:
|
|
17
|
+
|
|
18
|
+
- ``coord milestone dispatch`` (``coord/commands/milestone.py``) — the
|
|
19
|
+
one-shot CLI dispatch (bulk or ``--next`` single-pick).
|
|
20
|
+
- The daemon's auto-drain tick (``coord.serve_app._milestone_drain_tick``,
|
|
21
|
+
opt-in via ``coordinator.yml`` ``milestone.auto_dispatch``) — re-runs the
|
|
22
|
+
same fetch → plan → dispatch sequence for milestones registered via a
|
|
23
|
+
non-dry-run ``coord milestone dispatch`` call, so newly-unblocked frontier
|
|
24
|
+
entries dispatch automatically as dependencies complete.
|
|
25
|
+
- Tests exercise the pure ``plan_dispatch``/``pick_machine`` functions
|
|
26
|
+
directly with a seeded :class:`~coord.models.Board`, no GitHub or HTTP.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from typing import TYPE_CHECKING, Callable, Iterable
|
|
33
|
+
|
|
34
|
+
import httpx
|
|
35
|
+
|
|
36
|
+
from coord.milestone_order import (
|
|
37
|
+
BlockedNode,
|
|
38
|
+
FrontierEntry,
|
|
39
|
+
WorkOrder,
|
|
40
|
+
WorkOrderError,
|
|
41
|
+
parse_work_order,
|
|
42
|
+
ready_frontier,
|
|
43
|
+
validate_milestone_membership,
|
|
44
|
+
)
|
|
45
|
+
from coord.models import Assignment, Board, Machine, Proposal, Repo
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
from coord.config import AcceptanceDriverConfig, Config
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"MilestoneDispatchError",
|
|
52
|
+
"MilestoneContext",
|
|
53
|
+
"fetch_milestone_context",
|
|
54
|
+
"GateAFileExists",
|
|
55
|
+
"gate_a_status",
|
|
56
|
+
"ManifestFetch",
|
|
57
|
+
"GateAApprovalFetch",
|
|
58
|
+
"gate_a_signoff",
|
|
59
|
+
"gate_a_signoff_status",
|
|
60
|
+
"OracleReadiness",
|
|
61
|
+
"issue_oracle_ready",
|
|
62
|
+
"pick_machine",
|
|
63
|
+
"MachinePick",
|
|
64
|
+
"NoMachineAvailable",
|
|
65
|
+
"MilestonePlan",
|
|
66
|
+
"plan_dispatch",
|
|
67
|
+
"DispatchOutcome",
|
|
68
|
+
"dispatch_entry",
|
|
69
|
+
"is_milestone_complete",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class MilestoneDispatchError(Exception):
|
|
74
|
+
"""A milestone's tracking-issue context could not be fetched or is invalid.
|
|
75
|
+
|
|
76
|
+
Covers the same failure modes as ``coord milestone order``'s inline
|
|
77
|
+
error handling (GitHub fetch failure, no milestone on the tracking
|
|
78
|
+
issue, a malformed ``## Work order`` block, or a node that isn't a
|
|
79
|
+
member of the milestone) but as a plain exception rather than
|
|
80
|
+
``click.echo`` + ``sys.exit`` — so both the CLI and the daemon tick can
|
|
81
|
+
catch it and decide how to report it themselves.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True)
|
|
86
|
+
class MilestoneContext:
|
|
87
|
+
"""The fetched + validated inputs to :func:`plan_dispatch`."""
|
|
88
|
+
|
|
89
|
+
tracking_issue: int
|
|
90
|
+
milestone_number: int
|
|
91
|
+
work_order: WorkOrder
|
|
92
|
+
terminal_issues: frozenset[int] = field(default_factory=frozenset)
|
|
93
|
+
#: The tracking issue's own GitHub state (``"OPEN"``/``"CLOSED"``).
|
|
94
|
+
#: #1929: the milestone gate machine's Gate D observes "has this shipped"
|
|
95
|
+
#: from here — closing the epic is the observable end of the walk. Comes
|
|
96
|
+
#: free from the ``get_issue`` call this function already makes; defaulted
|
|
97
|
+
#: so every existing construction site (tests included) is unaffected.
|
|
98
|
+
tracking_issue_state: str = "OPEN"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def fetch_milestone_context(repo_cfg: Repo, tracking_issue: int) -> MilestoneContext:
|
|
102
|
+
"""Fetch the tracking issue, parse its work order, and resolve terminal state.
|
|
103
|
+
|
|
104
|
+
Shared by ``coord milestone order`` and ``coord milestone dispatch`` (and
|
|
105
|
+
the daemon's auto-drain tick) so all three compute the frontier from
|
|
106
|
+
identical inputs. Raises :class:`MilestoneDispatchError` on any fetch,
|
|
107
|
+
parse, or membership-validation failure.
|
|
108
|
+
"""
|
|
109
|
+
from coord import github_ops # noqa: PLC0415
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
issue_data = github_ops.get_issue(repo_cfg.github, tracking_issue)
|
|
113
|
+
except RuntimeError as e:
|
|
114
|
+
raise MilestoneDispatchError(f"could not fetch #{tracking_issue}: {e}") from e
|
|
115
|
+
|
|
116
|
+
milestone = issue_data.get("milestone") or {}
|
|
117
|
+
milestone_number = milestone.get("number")
|
|
118
|
+
if milestone_number is None:
|
|
119
|
+
raise MilestoneDispatchError(f"#{tracking_issue} has no milestone")
|
|
120
|
+
|
|
121
|
+
body = issue_data.get("body") or ""
|
|
122
|
+
try:
|
|
123
|
+
work_order = parse_work_order(body)
|
|
124
|
+
except WorkOrderError as e:
|
|
125
|
+
raise MilestoneDispatchError(str(e)) from e
|
|
126
|
+
|
|
127
|
+
tracking_issue_state = str(issue_data.get("state") or "OPEN").upper()
|
|
128
|
+
|
|
129
|
+
if not work_order.nodes:
|
|
130
|
+
return MilestoneContext(
|
|
131
|
+
tracking_issue=tracking_issue,
|
|
132
|
+
milestone_number=milestone_number,
|
|
133
|
+
work_order=work_order,
|
|
134
|
+
terminal_issues=frozenset(),
|
|
135
|
+
tracking_issue_state=tracking_issue_state,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Membership + terminal state — mirrors coord/commands/milestone.py's
|
|
139
|
+
# original inline logic (Phase 0): issues currently open under the
|
|
140
|
+
# milestone come free from one `get_open_issues` call; anything a node
|
|
141
|
+
# references that isn't in that set gets an individual lookup (closed,
|
|
142
|
+
# or foreign).
|
|
143
|
+
open_issues = github_ops.get_open_issues(repo_cfg.github)
|
|
144
|
+
milestone_issue_numbers = {
|
|
145
|
+
i["number"]
|
|
146
|
+
for i in open_issues
|
|
147
|
+
if (i.get("milestone") or {}).get("number") == milestone_number
|
|
148
|
+
}
|
|
149
|
+
terminal_issues: set[int] = set()
|
|
150
|
+
for node in work_order.nodes:
|
|
151
|
+
if node.issue_number in milestone_issue_numbers:
|
|
152
|
+
continue
|
|
153
|
+
try:
|
|
154
|
+
node_data = github_ops.get_issue(repo_cfg.github, node.issue_number)
|
|
155
|
+
except RuntimeError as e:
|
|
156
|
+
raise MilestoneDispatchError(
|
|
157
|
+
f"could not fetch #{node.issue_number}: {e}"
|
|
158
|
+
) from e
|
|
159
|
+
node_milestone_number = (node_data.get("milestone") or {}).get("number")
|
|
160
|
+
if node_milestone_number == milestone_number:
|
|
161
|
+
milestone_issue_numbers.add(node.issue_number)
|
|
162
|
+
if node_data.get("state", "").upper() == "CLOSED":
|
|
163
|
+
terminal_issues.add(node.issue_number)
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
validate_milestone_membership(work_order, milestone_issue_numbers)
|
|
167
|
+
except WorkOrderError as e:
|
|
168
|
+
raise MilestoneDispatchError(str(e)) from e
|
|
169
|
+
|
|
170
|
+
return MilestoneContext(
|
|
171
|
+
tracking_issue=tracking_issue,
|
|
172
|
+
milestone_number=milestone_number,
|
|
173
|
+
work_order=work_order,
|
|
174
|
+
terminal_issues=frozenset(terminal_issues),
|
|
175
|
+
tracking_issue_state=tracking_issue_state,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def is_milestone_complete(ctx: MilestoneContext) -> bool:
|
|
180
|
+
"""Whether every node in the work order has reached a terminal state."""
|
|
181
|
+
return all(
|
|
182
|
+
n.issue_number in ctx.terminal_issues for n in ctx.work_order.nodes
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# (repo_github, path, branch) -> True if the file exists at that ref.
|
|
187
|
+
# Injected so tests never hit `gh` — mirrors ``coord.claim``'s BranchLookup.
|
|
188
|
+
GateAFileExists = Callable[[str, str, str], bool]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _default_gate_a_file_exists(repo_github: str, path: str, branch: str) -> bool:
|
|
192
|
+
from coord import github_ops # noqa: PLC0415
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
github_ops.get_repo_file(repo_github, path, branch=branch)
|
|
196
|
+
return True
|
|
197
|
+
except RuntimeError:
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def gate_a_status(
|
|
202
|
+
repo_cfg: Repo,
|
|
203
|
+
config: "Config",
|
|
204
|
+
milestone_number: int,
|
|
205
|
+
*,
|
|
206
|
+
file_exists: GateAFileExists | None = None,
|
|
207
|
+
) -> str | None:
|
|
208
|
+
"""Gate A (docs/ORACLE_LOOP.md, #930): a milestone's issues may not
|
|
209
|
+
dispatch until its black-box contract exists.
|
|
210
|
+
|
|
211
|
+
Returns ``None`` when dispatch may proceed — either the repo has no
|
|
212
|
+
``acceptance.drivers`` entry configured (Gate A is an oracle-loop
|
|
213
|
+
concept; repos outside that model dispatch exactly as before #930), or
|
|
214
|
+
the contract file already exists on the repo's default branch. Returns a
|
|
215
|
+
human-readable block reason otherwise, naming the missing path and the
|
|
216
|
+
command that produces it.
|
|
217
|
+
"""
|
|
218
|
+
if not config.acceptance.has_driver(repo_cfg.name):
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
from coord.acceptance import gate_a_contract_path # noqa: PLC0415
|
|
222
|
+
|
|
223
|
+
path = gate_a_contract_path(milestone_number)
|
|
224
|
+
check = file_exists or _default_gate_a_file_exists
|
|
225
|
+
if check(repo_cfg.github, path, repo_cfg.default_branch):
|
|
226
|
+
return None
|
|
227
|
+
return (
|
|
228
|
+
f"Gate A not satisfied: {path!r} does not exist yet on "
|
|
229
|
+
f"{repo_cfg.default_branch!r}. Run `coord acceptance mock {repo_cfg.name} "
|
|
230
|
+
"<tracking_issue>` (docs/ORACLE_LOOP.md) to render the mock + write "
|
|
231
|
+
"the contract before dispatching this milestone's issues."
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# (repo_github, path, branch) -> file content, or None if it doesn't exist.
|
|
236
|
+
# Injected so tests never hit `gh` — mirrors GateAFileExists above.
|
|
237
|
+
ManifestFetch = Callable[[str, str, str], "str | None"]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _default_fetch_repo_file(repo_github: str, path: str, branch: str) -> str | None:
|
|
241
|
+
from coord import github_ops # noqa: PLC0415
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
return github_ops.get_repo_file(repo_github, path, branch=branch)
|
|
245
|
+
except RuntimeError:
|
|
246
|
+
return None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _fetch_manifest_data(
|
|
250
|
+
repo_github: str, milestone_number: int, branch: str, fetch: ManifestFetch,
|
|
251
|
+
):
|
|
252
|
+
from coord.acceptance import ( # noqa: PLC0415
|
|
253
|
+
ACCEPTANCE_DIRNAME,
|
|
254
|
+
ManifestData,
|
|
255
|
+
ManifestError,
|
|
256
|
+
ms_dirname,
|
|
257
|
+
parse_manifest_text,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
ms_dir = ms_dirname(milestone_number)
|
|
261
|
+
for ext in (".yml", ".yaml", ".json"):
|
|
262
|
+
path = f"{ACCEPTANCE_DIRNAME}/{ms_dir}/manifest{ext}"
|
|
263
|
+
content = fetch(repo_github, path, branch)
|
|
264
|
+
if content is None:
|
|
265
|
+
continue
|
|
266
|
+
try:
|
|
267
|
+
return parse_manifest_text(content, source=path)
|
|
268
|
+
except ManifestError:
|
|
269
|
+
# Malformed manifest degrades to "no slice authored" rather
|
|
270
|
+
# than crashing dispatch — same fail-soft posture as
|
|
271
|
+
# oracle_loop_contract_block (#945).
|
|
272
|
+
return ManifestData()
|
|
273
|
+
return ManifestData()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _unsupported_driver_kinds(entry: "AcceptanceDriverConfig") -> tuple[str, ...]:
|
|
277
|
+
"""Every driver ``kind`` *entry* declares (its own flat ``kind``, or —
|
|
278
|
+
for a routed entry (#1125) — every ``routes[].kind``) that isn't in
|
|
279
|
+
:data:`coord.acceptance_drivers.SUPPORTED_KINDS` yet.
|
|
280
|
+
|
|
281
|
+
This is the "live check against the currently-installed coord package"
|
|
282
|
+
#1138 asks for: a repo can declare a driver kind in ``coordinator.yml``
|
|
283
|
+
ahead of the code that implements it (exactly what happened with
|
|
284
|
+
``cli-pytest``/#1125 landing 48 minutes after v0.4.68 shipped) and
|
|
285
|
+
nothing previously noticed before dispatching an issue into it.
|
|
286
|
+
"""
|
|
287
|
+
from coord.acceptance_drivers import SUPPORTED_KINDS # noqa: PLC0415
|
|
288
|
+
|
|
289
|
+
kinds = {route.kind for route in entry.routes} if entry.routes else {entry.kind}
|
|
290
|
+
return tuple(sorted(k for k in kinds if k and k not in SUPPORTED_KINDS))
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# (repo_name, milestone_number) -> the stored Gate-A verdict dict, or None.
|
|
294
|
+
# Injected so tests never touch the board DB — mirrors ManifestFetch above.
|
|
295
|
+
GateAApprovalFetch = Callable[[str, int], "dict | None"]
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _default_fetch_gate_a_approval(repo_name: str, milestone_number: int):
|
|
299
|
+
from coord import state # noqa: PLC0415
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
return state.get_gate_a_approval(
|
|
303
|
+
repo_name=repo_name, milestone_number=milestone_number
|
|
304
|
+
)
|
|
305
|
+
except Exception: # noqa: BLE001
|
|
306
|
+
# Fail CLOSED: an unreadable board collapses to "no verdict
|
|
307
|
+
# recorded", which refuses with an operator-readable remedy rather
|
|
308
|
+
# than letting work dispatch against an unreviewed surface (#2063).
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def gate_a_signoff(
|
|
313
|
+
repo_cfg: Repo,
|
|
314
|
+
milestone_number: int,
|
|
315
|
+
manifest,
|
|
316
|
+
*,
|
|
317
|
+
fetch: ManifestFetch,
|
|
318
|
+
approval_fetch: GateAApprovalFetch,
|
|
319
|
+
):
|
|
320
|
+
"""The #2063 human sign-off half of Gate A, as a
|
|
321
|
+
:class:`coord.gate_a.GateADecision`.
|
|
322
|
+
|
|
323
|
+
Gate A has always had two halves that #2063 finally separates: *does the
|
|
324
|
+
contract exist* (:func:`gate_a_status`, #930) and *has a human read it*.
|
|
325
|
+
The second was a convention — "merging the Gate-A PR is sign-off" — which
|
|
326
|
+
anything able to merge a PR satisfied, silently, on CI green. This
|
|
327
|
+
fetches the contract's **current content**, hashes it, and asks whether a
|
|
328
|
+
recorded verdict covers exactly that content.
|
|
329
|
+
|
|
330
|
+
Deliberately consumed here rather than at the merge: the Gate-A PR is
|
|
331
|
+
merged with ``gh pr merge``, outside coord entirely, so no coord-side
|
|
332
|
+
check ever sees it. Refusing where the contract is *consumed* means an
|
|
333
|
+
unapproved contract merging is harmless — nothing is authored and no
|
|
334
|
+
work dispatches until a human records a verdict.
|
|
335
|
+
"""
|
|
336
|
+
from coord import gate_a as gate_a_mod # noqa: PLC0415
|
|
337
|
+
from coord.acceptance import gate_a_contract_path # noqa: PLC0415
|
|
338
|
+
|
|
339
|
+
contract_text = fetch(
|
|
340
|
+
repo_cfg.github,
|
|
341
|
+
gate_a_contract_path(milestone_number),
|
|
342
|
+
repo_cfg.default_branch,
|
|
343
|
+
)
|
|
344
|
+
return gate_a_mod.evaluate(
|
|
345
|
+
repo_name=repo_cfg.name,
|
|
346
|
+
milestone_number=milestone_number,
|
|
347
|
+
contract_text=contract_text,
|
|
348
|
+
approval=approval_fetch(repo_cfg.name, milestone_number),
|
|
349
|
+
exempt=bool(getattr(manifest, "gate_a_exempt", False)),
|
|
350
|
+
exempt_reason=str(getattr(manifest, "gate_a_exempt_reason", "") or ""),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def gate_a_signoff_status(
|
|
355
|
+
repo_cfg: Repo,
|
|
356
|
+
config: "Config",
|
|
357
|
+
milestone_number: int,
|
|
358
|
+
*,
|
|
359
|
+
fetch_manifest: ManifestFetch | None = None,
|
|
360
|
+
fetch_gate_a_approval: GateAApprovalFetch | None = None,
|
|
361
|
+
) -> str | None:
|
|
362
|
+
"""``None`` when a human has signed off on this milestone's contract,
|
|
363
|
+
a refusal reason otherwise (#2063).
|
|
364
|
+
|
|
365
|
+
The milestone-level convenience wrapper around :func:`gate_a_signoff`,
|
|
366
|
+
shaped like :func:`gate_a_status` so callers that gate on a *milestone*
|
|
367
|
+
rather than an issue — ``coord acceptance author``, which dispatches the
|
|
368
|
+
independent ``test-author`` — read the same one-line refusal. That path
|
|
369
|
+
is the one that actually burned money on coord-portal ms-2: a sealed
|
|
370
|
+
slice authored against a contract nobody had approved. It happened to be
|
|
371
|
+
a good contract; that was luck, not process.
|
|
372
|
+
|
|
373
|
+
A no-op (``None``) for repos with no ``acceptance.drivers`` entry, and
|
|
374
|
+
for a milestone whose contract does not exist yet — the latter is
|
|
375
|
+
:func:`gate_a_status`'s own, already-surfaced refusal.
|
|
376
|
+
"""
|
|
377
|
+
if not config.acceptance.has_driver(repo_cfg.name):
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
# One memoised fetch seam for both the existence probe and the content
|
|
381
|
+
# read — `gate_a_status` only wants "is it there", `gate_a_signoff`
|
|
382
|
+
# wants the bytes, and without this the contract would be pulled twice
|
|
383
|
+
# over `gh` on every call.
|
|
384
|
+
base = fetch_manifest or _default_fetch_repo_file
|
|
385
|
+
seen: dict[tuple[str, str, str], "str | None"] = {}
|
|
386
|
+
|
|
387
|
+
def fetch(repo_github: str, path: str, branch: str) -> "str | None":
|
|
388
|
+
key = (repo_github, path, branch)
|
|
389
|
+
if key not in seen:
|
|
390
|
+
seen[key] = base(repo_github, path, branch)
|
|
391
|
+
return seen[key]
|
|
392
|
+
|
|
393
|
+
if gate_a_status(
|
|
394
|
+
repo_cfg,
|
|
395
|
+
config,
|
|
396
|
+
milestone_number,
|
|
397
|
+
file_exists=lambda g, p, b: fetch(g, p, b) is not None,
|
|
398
|
+
) is not None:
|
|
399
|
+
return None
|
|
400
|
+
manifest = _fetch_manifest_data(
|
|
401
|
+
repo_cfg.github, milestone_number, repo_cfg.default_branch, fetch,
|
|
402
|
+
)
|
|
403
|
+
decision = gate_a_signoff(
|
|
404
|
+
repo_cfg,
|
|
405
|
+
milestone_number,
|
|
406
|
+
manifest,
|
|
407
|
+
fetch=fetch,
|
|
408
|
+
approval_fetch=fetch_gate_a_approval or _default_fetch_gate_a_approval,
|
|
409
|
+
)
|
|
410
|
+
return None if decision.ok else decision.reason
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
@dataclass(frozen=True)
|
|
414
|
+
class OracleReadiness:
|
|
415
|
+
"""Issue-level oracle-loop dispatch readiness (#1138), layered on top of
|
|
416
|
+
the milestone-level :func:`gate_a_status`.
|
|
417
|
+
|
|
418
|
+
``applies`` is ``False`` — a no-op, dispatch proceeds exactly as before
|
|
419
|
+
#1138 — for every issue outside this gate's scope: no milestone, no
|
|
420
|
+
``acceptance.drivers`` entry configured for the repo, or Gate A itself
|
|
421
|
+
not yet satisfied for this milestone (that's a distinct, already-
|
|
422
|
+
surfaced refusal — see :func:`gate_a_status` — firing a confusing
|
|
423
|
+
"no slice yet" message before the contract even exists would be worse,
|
|
424
|
+
not better). When ``applies`` is ``True``, ``reason`` is ``None`` iff
|
|
425
|
+
dispatch may proceed: either the issue is ``exempt``, or it has an
|
|
426
|
+
authored slice (``has_slice``) AND every driver kind its repo declares
|
|
427
|
+
is implemented by this install (``unsupported_kinds`` empty).
|
|
428
|
+
"""
|
|
429
|
+
|
|
430
|
+
applies: bool = False
|
|
431
|
+
exempt: bool = False
|
|
432
|
+
has_slice: bool = False
|
|
433
|
+
unsupported_kinds: tuple[str, ...] = ()
|
|
434
|
+
reason: str | None = None
|
|
435
|
+
#: #2063: the Gate-A human sign-off state for this issue's milestone —
|
|
436
|
+
#: one of :mod:`coord.gate_a`'s ``STATE_*`` values, or ``""`` when this
|
|
437
|
+
#: gate doesn't apply. ``"approved"``/``"exempt"`` are the only values
|
|
438
|
+
#: that let ``reason`` be ``None``.
|
|
439
|
+
gate_a_state: str = ""
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def issue_oracle_ready(
|
|
443
|
+
repo_cfg: Repo,
|
|
444
|
+
config: "Config",
|
|
445
|
+
milestone_number: int | None,
|
|
446
|
+
issue_number: int,
|
|
447
|
+
issue_labels: Iterable[str] = (),
|
|
448
|
+
*,
|
|
449
|
+
file_exists: GateAFileExists | None = None,
|
|
450
|
+
fetch_manifest: ManifestFetch | None = None,
|
|
451
|
+
fetch_gate_a_approval: GateAApprovalFetch | None = None,
|
|
452
|
+
) -> OracleReadiness:
|
|
453
|
+
"""The #1138 hard gate: refuse Work dispatch for an issue that belongs
|
|
454
|
+
to an oracle-opted-in milestone (Gate A satisfied — ``contract.md``
|
|
455
|
+
exists) but has no JIT-authored acceptance slice yet, or whose repo
|
|
456
|
+
declares an acceptance driver ``kind`` this ``coord`` install doesn't
|
|
457
|
+
implement — the exact gap that let #1118 dispatch and merge through the
|
|
458
|
+
ordinary Work→Test→Review→Merge pipeline despite ms-37's Gate A already
|
|
459
|
+
being satisfied (2026-07-13 incident, see #1138).
|
|
460
|
+
|
|
461
|
+
Scenarios (b) non-opted-in milestones/epics and (c) plain issues with no
|
|
462
|
+
milestone are unaffected: this only activates when
|
|
463
|
+
``tests/acceptance/ms-N/contract.md`` exists for *issue_number*'s
|
|
464
|
+
milestone — the same signal :func:`gate_a_status` checks, no new config
|
|
465
|
+
surface. An issue may opt out of the sealed suite (e.g. it builds the
|
|
466
|
+
driver rather than consuming it, like #1125) via an explicit
|
|
467
|
+
``exempt:`` list in the milestone's manifest or an ``oracle:exempt``
|
|
468
|
+
label — a declared, reviewable decision rather than tribal knowledge.
|
|
469
|
+
|
|
470
|
+
#2063 adds a second refusal on the same seam: the contract exists but
|
|
471
|
+
carries **no recorded human sign-off** for its current content (see
|
|
472
|
+
:func:`gate_a_signoff`). That refusal's prose carries
|
|
473
|
+
:func:`coord.gate_a.park_marker` so ``coord drive-queue``'s tick parks
|
|
474
|
+
the entry (re-checked every tick) instead of landing it in terminal
|
|
475
|
+
``blocked``, which nothing re-evaluates (#2040) — this is an explicitly
|
|
476
|
+
operator-fixable condition with a one-command remedy.
|
|
477
|
+
"""
|
|
478
|
+
if milestone_number is None or not config.acceptance.has_driver(repo_cfg.name):
|
|
479
|
+
return OracleReadiness()
|
|
480
|
+
|
|
481
|
+
# One memoised fetch seam shared by `gate_a_status`'s existence probe and
|
|
482
|
+
# the manifest/contract content reads below — same fix
|
|
483
|
+
# `gate_a_signoff_status` already applies for its own pair of calls.
|
|
484
|
+
# Without this, every dispatch-readiness check against an oracle-opted
|
|
485
|
+
# milestone pulls `tests/acceptance/ms-NN/contract.md` over `gh` TWICE:
|
|
486
|
+
# once here (existence only, content discarded) and again inside
|
|
487
|
+
# `gate_a_signoff` below (content, to hash it). Only takes effect when
|
|
488
|
+
# *file_exists* isn't explicitly overridden — callers that inject their
|
|
489
|
+
# own (tests) keep exactly their prior behaviour.
|
|
490
|
+
base_fetch = fetch_manifest or _default_fetch_repo_file
|
|
491
|
+
_fetch_seen: dict[tuple[str, str, str], "str | None"] = {}
|
|
492
|
+
|
|
493
|
+
def fetch(repo_github: str, path: str, branch: str) -> "str | None":
|
|
494
|
+
key = (repo_github, path, branch)
|
|
495
|
+
if key not in _fetch_seen:
|
|
496
|
+
_fetch_seen[key] = base_fetch(repo_github, path, branch)
|
|
497
|
+
return _fetch_seen[key]
|
|
498
|
+
|
|
499
|
+
effective_file_exists = file_exists or (
|
|
500
|
+
lambda g, p, b: fetch(g, p, b) is not None
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
if gate_a_status(
|
|
504
|
+
repo_cfg, config, milestone_number, file_exists=effective_file_exists
|
|
505
|
+
) is not None:
|
|
506
|
+
return OracleReadiness()
|
|
507
|
+
|
|
508
|
+
from coord.acceptance import test_ids_for_issue # noqa: PLC0415
|
|
509
|
+
from coord.acceptance_drivers import SUPPORTED_KINDS # noqa: PLC0415
|
|
510
|
+
|
|
511
|
+
manifest = _fetch_manifest_data(
|
|
512
|
+
repo_cfg.github, milestone_number, repo_cfg.default_branch, fetch,
|
|
513
|
+
)
|
|
514
|
+
has_slice = bool(test_ids_for_issue(manifest.tests, issue_number))
|
|
515
|
+
exempt = issue_number in manifest.exempt or "oracle:exempt" in set(issue_labels)
|
|
516
|
+
|
|
517
|
+
entry = config.acceptance.drivers.get(repo_cfg.name)
|
|
518
|
+
unsupported = _unsupported_driver_kinds(entry) if entry is not None else ()
|
|
519
|
+
|
|
520
|
+
# #2063: the human sign-off half of Gate A is checked FIRST, and is not
|
|
521
|
+
# bypassed by the issue-level `exempt` list. `exempt` says "this ISSUE
|
|
522
|
+
# doesn't consume the sealed suite" (e.g. #1125, which builds the
|
|
523
|
+
# driver); it says nothing about whether a human has read the milestone's
|
|
524
|
+
# contract — the surface every sibling issue is about to be built
|
|
525
|
+
# against. The one legitimate opt-out is milestone-level and declared in
|
|
526
|
+
# the manifest (`gate_a: {exempt: true}`), which `gate_a_signoff` honours.
|
|
527
|
+
signoff = gate_a_signoff(
|
|
528
|
+
repo_cfg,
|
|
529
|
+
milestone_number,
|
|
530
|
+
manifest,
|
|
531
|
+
fetch=fetch,
|
|
532
|
+
approval_fetch=fetch_gate_a_approval or _default_fetch_gate_a_approval,
|
|
533
|
+
)
|
|
534
|
+
if not signoff.ok:
|
|
535
|
+
return OracleReadiness(
|
|
536
|
+
applies=True, exempt=exempt, has_slice=has_slice,
|
|
537
|
+
unsupported_kinds=unsupported, reason=signoff.reason,
|
|
538
|
+
gate_a_state=signoff.state,
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
if exempt:
|
|
542
|
+
return OracleReadiness(
|
|
543
|
+
applies=True, exempt=True, has_slice=has_slice,
|
|
544
|
+
unsupported_kinds=unsupported, gate_a_state=signoff.state,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
reason: str | None = None
|
|
548
|
+
if not has_slice:
|
|
549
|
+
reason = (
|
|
550
|
+
f"Issue #{issue_number} is part of oracle-opted-in milestone "
|
|
551
|
+
f"ms-{milestone_number} (Gate A satisfied) but has no acceptance "
|
|
552
|
+
"slice yet — run `coord acceptance author "
|
|
553
|
+
f"{repo_cfg.name} <tracking_issue> --issue {issue_number}` first. "
|
|
554
|
+
"Already covered by its own unit tests instead (e.g. it builds "
|
|
555
|
+
f"the driver rather than consuming it)? Add {issue_number} to "
|
|
556
|
+
f"tests/acceptance/ms-{milestone_number}/manifest.yml's `exempt:` "
|
|
557
|
+
"list, or label the issue `oracle:exempt`."
|
|
558
|
+
)
|
|
559
|
+
elif unsupported:
|
|
560
|
+
reason = (
|
|
561
|
+
f"Issue #{issue_number}'s repo {repo_cfg.name!r} declares "
|
|
562
|
+
f"acceptance driver kind(s) {', '.join(unsupported)} that this "
|
|
563
|
+
f"coord install doesn't implement yet (supported: "
|
|
564
|
+
f"{', '.join(SUPPORTED_KINDS)}) — update coord "
|
|
565
|
+
"(`coord agent update`) before dispatching."
|
|
566
|
+
)
|
|
567
|
+
return OracleReadiness(
|
|
568
|
+
applies=True, exempt=False, has_slice=has_slice,
|
|
569
|
+
unsupported_kinds=unsupported, reason=reason,
|
|
570
|
+
gate_a_state=signoff.state,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def pick_machine(
|
|
575
|
+
repo_name: str,
|
|
576
|
+
board: Board,
|
|
577
|
+
config: "Config",
|
|
578
|
+
*,
|
|
579
|
+
exclude: frozenset[str] = frozenset(),
|
|
580
|
+
) -> Machine | None:
|
|
581
|
+
"""Deterministically pick an idle, capable, unpaused machine for *repo_name*.
|
|
582
|
+
|
|
583
|
+
Mirrors the candidate filter ``coord.reconcile._reassign`` and
|
|
584
|
+
``coord.review.pick_reviewer_machine`` already use: idle (no running
|
|
585
|
+
assignment on the board), lists *repo_name* in its ``repos:`` (this is
|
|
586
|
+
what keeps coord-self work off a machine like dellserver whose
|
|
587
|
+
``coordinator.yml`` entry omits ``claude-coordinator`` — #688), has a
|
|
588
|
+
configured ``repo_paths`` entry, and isn't routing-paused
|
|
589
|
+
(``coord pause``). First match wins in ``config.machines`` order — no
|
|
590
|
+
scoring, no LLM.
|
|
591
|
+
|
|
592
|
+
Deliberately computes "busy" from ``board.active`` directly (like
|
|
593
|
+
``_reassign``/``pick_reviewer_machine`` do) rather than via
|
|
594
|
+
``Board.idle_machines()``, which filters ``board.machines`` — a separate
|
|
595
|
+
DB-synced snapshot that isn't guaranteed to be populated on every board
|
|
596
|
+
read path. ``config.machines`` is the authoritative machine list here.
|
|
597
|
+
"""
|
|
598
|
+
from coord.machine_pause import paused_set # noqa: PLC0415
|
|
599
|
+
|
|
600
|
+
busy = {a.machine_name for a in board.active if a.status == "running"}
|
|
601
|
+
paused = paused_set(config.machines)
|
|
602
|
+
for m in config.machines:
|
|
603
|
+
if m.name in exclude:
|
|
604
|
+
continue
|
|
605
|
+
if m.name in busy:
|
|
606
|
+
continue
|
|
607
|
+
if m.name in paused:
|
|
608
|
+
continue
|
|
609
|
+
if not m.can_work_on(repo_name):
|
|
610
|
+
continue
|
|
611
|
+
if m.repo_path(repo_name) is None:
|
|
612
|
+
continue
|
|
613
|
+
return m
|
|
614
|
+
return None
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
@dataclass(frozen=True)
|
|
618
|
+
class MachinePick:
|
|
619
|
+
"""A ready-frontier entry paired with the machine it would dispatch to."""
|
|
620
|
+
|
|
621
|
+
entry: FrontierEntry
|
|
622
|
+
machine: Machine
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
@dataclass(frozen=True)
|
|
626
|
+
class NoMachineAvailable:
|
|
627
|
+
"""A ready-frontier entry with nowhere to dispatch it *right now*.
|
|
628
|
+
|
|
629
|
+
Distinct from :class:`~coord.milestone_order.BlockedNode` — the frontier
|
|
630
|
+
itself considers this entry ready (dependencies satisfied, unclaimed,
|
|
631
|
+
unconflicted); it just has no idle capable machine this tick. It will be
|
|
632
|
+
reconsidered on the next ``coord milestone dispatch`` / daemon tick.
|
|
633
|
+
"""
|
|
634
|
+
|
|
635
|
+
entry: FrontierEntry
|
|
636
|
+
reason: str = "no idle machine available for this repo"
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
@dataclass(frozen=True)
|
|
640
|
+
class MilestonePlan:
|
|
641
|
+
"""The result of :func:`plan_dispatch`: what to dispatch now, what's
|
|
642
|
+
idle-machine-starved, and what Phase 0's frontier says is still blocked.
|
|
643
|
+
"""
|
|
644
|
+
|
|
645
|
+
to_dispatch: tuple[MachinePick, ...] = ()
|
|
646
|
+
skipped: tuple[NoMachineAvailable, ...] = ()
|
|
647
|
+
waiting: tuple[BlockedNode, ...] = ()
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def plan_dispatch(
|
|
651
|
+
work_order: WorkOrder,
|
|
652
|
+
board: Board,
|
|
653
|
+
config: "Config",
|
|
654
|
+
repo_cfg: Repo,
|
|
655
|
+
terminal_issues: frozenset[int] | set[int],
|
|
656
|
+
) -> MilestonePlan:
|
|
657
|
+
"""Compute the ready frontier and pick a machine for each ready entry.
|
|
658
|
+
|
|
659
|
+
Pure — no GitHub/HTTP calls, no dispatch side effects. Greedily assigns
|
|
660
|
+
each :class:`~coord.milestone_order.FrontierEntry` in frontier order to
|
|
661
|
+
the first idle+capable machine not already claimed by an earlier entry
|
|
662
|
+
in *this* call (so a cohort of N ready issues fans out across up to N
|
|
663
|
+
distinct idle machines instead of piling onto one).
|
|
664
|
+
"""
|
|
665
|
+
frontier = ready_frontier(
|
|
666
|
+
work_order,
|
|
667
|
+
board,
|
|
668
|
+
repo_name=repo_cfg.name,
|
|
669
|
+
repo_github=repo_cfg.github,
|
|
670
|
+
terminal_issues=set(terminal_issues),
|
|
671
|
+
)
|
|
672
|
+
picks: list[MachinePick] = []
|
|
673
|
+
skipped: list[NoMachineAvailable] = []
|
|
674
|
+
used: set[str] = set()
|
|
675
|
+
for entry in frontier.ready:
|
|
676
|
+
machine = pick_machine(repo_cfg.name, board, config, exclude=frozenset(used))
|
|
677
|
+
if machine is None:
|
|
678
|
+
skipped.append(NoMachineAvailable(entry))
|
|
679
|
+
continue
|
|
680
|
+
used.add(machine.name)
|
|
681
|
+
picks.append(MachinePick(entry, machine))
|
|
682
|
+
return MilestonePlan(
|
|
683
|
+
to_dispatch=tuple(picks), skipped=tuple(skipped), waiting=frontier.blocked
|
|
684
|
+
)
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
@dataclass(frozen=True)
|
|
688
|
+
class DispatchOutcome:
|
|
689
|
+
"""The result of one :func:`dispatch_entry` call."""
|
|
690
|
+
|
|
691
|
+
issue_number: int
|
|
692
|
+
machine_name: str
|
|
693
|
+
ok: bool
|
|
694
|
+
assignment_id: str | None = None
|
|
695
|
+
error: str | None = None
|
|
696
|
+
# #1454: the resolved model + a human-readable reason (e.g. "via label
|
|
697
|
+
# 'tier:large'" / "default; no label match") — see
|
|
698
|
+
# `coord.config.describe_model_choice`. `None` on a failed outcome (no
|
|
699
|
+
# model was ever resolved) or for a non-"work" proposal_type.
|
|
700
|
+
model: str | None = None
|
|
701
|
+
model_reason: str | None = None
|
|
702
|
+
# #1889: mirrors `model_reason` above, for the effective PROVIDER
|
|
703
|
+
# instead — see `coord.providers.describe_provider_choice`. `None` on a
|
|
704
|
+
# failed outcome (no provider was ever resolved).
|
|
705
|
+
provider_reason: str | None = None
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def dispatch_entry(
|
|
709
|
+
pick: MachinePick,
|
|
710
|
+
repo_cfg: Repo,
|
|
711
|
+
config: "Config",
|
|
712
|
+
board: Board,
|
|
713
|
+
*,
|
|
714
|
+
tracking_issue: int | None = None,
|
|
715
|
+
) -> DispatchOutcome:
|
|
716
|
+
"""Dispatch one ready-frontier entry to its picked machine.
|
|
717
|
+
|
|
718
|
+
Mirrors ``coord.commands.dispatch_workers._dispatch_headless``'s logic
|
|
719
|
+
(build a :class:`~coord.models.Proposal` → defensive claim recheck →
|
|
720
|
+
:func:`coord.dispatch.dispatch` → ``record_dispatched`` →
|
|
721
|
+
``post_briefing``) without its ``click.echo``/``sys.exit`` coupling, so
|
|
722
|
+
it's usable from both ``coord milestone dispatch`` and the daemon's
|
|
723
|
+
auto-drain tick.
|
|
724
|
+
|
|
725
|
+
On success, appends a lightweight ``running`` :class:`~coord.models.
|
|
726
|
+
Assignment` stub to *board*'s ``active`` list in place — so a caller
|
|
727
|
+
dispatching several entries (or several milestones) in the same batch
|
|
728
|
+
sees the machine as busy for the *next* :func:`plan_dispatch` /
|
|
729
|
+
:func:`pick_machine` call without re-reading the board over the network.
|
|
730
|
+
This does not itself persist the board; ``record_dispatched`` already
|
|
731
|
+
wrote the real assignment row.
|
|
732
|
+
|
|
733
|
+
Re-checks :func:`coord.claim.find_work_claim` immediately before
|
|
734
|
+
dispatching (defense-in-depth against the frontier snapshot going stale
|
|
735
|
+
between planning and dispatch — e.g. a race with a manual `coord
|
|
736
|
+
assign`), matching the same check ``_dispatch_headless`` performs.
|
|
737
|
+
"""
|
|
738
|
+
from coord import github_ops # noqa: PLC0415
|
|
739
|
+
from coord.claim import claim_message, find_work_claim # noqa: PLC0415
|
|
740
|
+
from coord.dispatch import ( # noqa: PLC0415
|
|
741
|
+
dispatch,
|
|
742
|
+
post_briefing,
|
|
743
|
+
resolve_dispatch_model_alias,
|
|
744
|
+
)
|
|
745
|
+
from coord.providers import resolve_provider_name # noqa: PLC0415
|
|
746
|
+
from coord.state import record_dispatched # noqa: PLC0415
|
|
747
|
+
|
|
748
|
+
issue_number = pick.entry.issue_number
|
|
749
|
+
machine = pick.machine
|
|
750
|
+
|
|
751
|
+
claim = find_work_claim(issue_number, repo_cfg.name, repo_cfg.github, board)
|
|
752
|
+
if claim is not None:
|
|
753
|
+
return DispatchOutcome(
|
|
754
|
+
issue_number=issue_number,
|
|
755
|
+
machine_name=machine.name,
|
|
756
|
+
ok=False,
|
|
757
|
+
error=claim_message(claim),
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
try:
|
|
761
|
+
issue_data = github_ops.get_issue(repo_cfg.github, issue_number)
|
|
762
|
+
except RuntimeError as e:
|
|
763
|
+
return DispatchOutcome(
|
|
764
|
+
issue_number=issue_number,
|
|
765
|
+
machine_name=machine.name,
|
|
766
|
+
ok=False,
|
|
767
|
+
error=f"could not fetch #{issue_number}: {e}",
|
|
768
|
+
)
|
|
769
|
+
issue_title = issue_data.get("title", f"Issue #{issue_number}")
|
|
770
|
+
issue_body = issue_data.get("body") or ""
|
|
771
|
+
briefing = f"Issue #{issue_number}: {issue_title}\n\n{issue_body}"
|
|
772
|
+
if tracking_issue is not None:
|
|
773
|
+
group_note = f" (group {pick.entry.group})" if pick.entry.group else ""
|
|
774
|
+
briefing += (
|
|
775
|
+
"\n\n---\nDispatched by `coord milestone dispatch` as part of the "
|
|
776
|
+
f"declared work order in #{tracking_issue}{group_note}."
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
issue_labels = [lbl.get("name", "") for lbl in (issue_data.get("labels") or [])]
|
|
780
|
+
required_gates = list(config.pipeline.default_gates)
|
|
781
|
+
for lbl in issue_labels:
|
|
782
|
+
if lbl in config.pipeline.labels:
|
|
783
|
+
required_gates = list(config.pipeline.labels[lbl])
|
|
784
|
+
break
|
|
785
|
+
|
|
786
|
+
# #934: this issue's GitHub Milestone number, when it has one —
|
|
787
|
+
# `issue_data` already carries it (`coord.github_ops.get_issue`'s
|
|
788
|
+
# `milestone` field), so no extra fetch is needed. Threaded onto the
|
|
789
|
+
# Proposal so `coord.dispatch.dispatch()` can resolve the worker's base
|
|
790
|
+
# branch via `coord.branch_model.resolve_base_branch` (`feature/ms-NN`
|
|
791
|
+
# for a repo that opted into the git model, `default_branch` otherwise).
|
|
792
|
+
issue_milestone = issue_data.get("milestone") or {}
|
|
793
|
+
milestone_number = issue_milestone.get("number") if isinstance(issue_milestone, dict) else None
|
|
794
|
+
|
|
795
|
+
if milestone_number is not None and getattr(repo_cfg, "develop_branch", None):
|
|
796
|
+
from coord.branch_model import ensure_feature_branch_exists # noqa: PLC0415
|
|
797
|
+
|
|
798
|
+
try:
|
|
799
|
+
ensure_feature_branch_exists(repo_cfg, milestone_number)
|
|
800
|
+
except (ValueError, RuntimeError) as e:
|
|
801
|
+
return DispatchOutcome(
|
|
802
|
+
issue_number=issue_number,
|
|
803
|
+
machine_name=machine.name,
|
|
804
|
+
ok=False,
|
|
805
|
+
error=f"could not ensure feature/ms-{milestone_number} exists: {e}",
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
# #1430: models.labels routes work dispatches by the issue's tier/type
|
|
809
|
+
# label; plan workers (require_plan=true) deliberately stay on
|
|
810
|
+
# `default` — read-only/cheap, must not inherit a tier:large -> opus
|
|
811
|
+
# routing meant for the eventual work dispatch.
|
|
812
|
+
proposal_type = "plan" if config.dispatch.require_plan else "work"
|
|
813
|
+
label_model, matched_label, shadowed_labels = (
|
|
814
|
+
config.models.model_for_labels_with_reason(issue_labels)
|
|
815
|
+
if proposal_type == "work"
|
|
816
|
+
else (None, None, [])
|
|
817
|
+
)
|
|
818
|
+
# #1889: providers.labels gets the identical type="work"-only gating —
|
|
819
|
+
# this is also the daemon's auto-drain tick, a headless path with no
|
|
820
|
+
# human to type `--provider`, exactly what #1889 exists for.
|
|
821
|
+
provider_issue_labels = issue_labels if proposal_type == "work" else None
|
|
822
|
+
# #1706 review fix: milestone dispatch has no per-call `--provider`
|
|
823
|
+
# override, so the effective provider is spec(None) → label → repo →
|
|
824
|
+
# default — same chain `coord.dispatch.dispatch()` uses. Route model
|
|
825
|
+
# resolution through `resolve_dispatch_model_alias` so a non-claude/
|
|
826
|
+
# claude-pty provider's own pinned `model` isn't shadowed by
|
|
827
|
+
# `models.default`; see that function's docstring for the full
|
|
828
|
+
# rationale. Without this, `resolved_model` was always truthy by the
|
|
829
|
+
# time it reached `Proposal.model`, so `dispatch()`'s own
|
|
830
|
+
# provider-aware fallback could never fire for `coord milestone
|
|
831
|
+
# dispatch`.
|
|
832
|
+
effective_provider_name = resolve_provider_name(
|
|
833
|
+
None, repo_cfg.provider, config.providers,
|
|
834
|
+
issue_labels=provider_issue_labels,
|
|
835
|
+
)
|
|
836
|
+
resolved_model = resolve_dispatch_model_alias(
|
|
837
|
+
explicit_model=None,
|
|
838
|
+
label_model=label_model,
|
|
839
|
+
config=config,
|
|
840
|
+
effective_provider_name=effective_provider_name,
|
|
841
|
+
)
|
|
842
|
+
# #1454: surfaced on the outcome so `coord milestone dispatch`'s CLI
|
|
843
|
+
# output states *why* this model was picked, same as `coord assign` /
|
|
844
|
+
# `coord approve`. `issue_labels` (above, line ~572) came from
|
|
845
|
+
# `issue_data` fetched fresh just moments ago in this same call — so
|
|
846
|
+
# this is never stale, unlike a proposal snapshot carried over from an
|
|
847
|
+
# earlier `coord plan`/`coord milestone plan` run.
|
|
848
|
+
from coord.config import describe_model_choice # noqa: PLC0415
|
|
849
|
+
|
|
850
|
+
if resolved_model:
|
|
851
|
+
model_reason = describe_model_choice(
|
|
852
|
+
resolved_model=resolved_model,
|
|
853
|
+
matched_label=matched_label,
|
|
854
|
+
shadowed_labels=shadowed_labels,
|
|
855
|
+
)
|
|
856
|
+
else:
|
|
857
|
+
# #1706/#1798: resolved_model is None when the effective provider's
|
|
858
|
+
# own `providers.definitions.<name>.model` is pinned — state that
|
|
859
|
+
# explicitly rather than feeding `None` to describe_model_choice
|
|
860
|
+
# (which expects a str). #1798: after the precedence fix, this can
|
|
861
|
+
# now happen *with* a matched label too (the pin winning over it,
|
|
862
|
+
# not just "no label matched") — surface which label lost, same as
|
|
863
|
+
# `_dispatch_headless`'s equivalent branch in dispatch_workers.py,
|
|
864
|
+
# so `coord milestone dispatch`'s output doesn't silently omit it.
|
|
865
|
+
_pinned = config.providers.definitions.get(effective_provider_name)
|
|
866
|
+
if _pinned is not None and _pinned.model:
|
|
867
|
+
_via = f"providers.definitions[{effective_provider_name!r}].model"
|
|
868
|
+
model_reason = (
|
|
869
|
+
f"{_pinned.model} (via {_via}, overriding label {matched_label!r})"
|
|
870
|
+
if matched_label
|
|
871
|
+
else f"{_pinned.model} (via {_via})"
|
|
872
|
+
)
|
|
873
|
+
else:
|
|
874
|
+
model_reason = "none (provider default; no --model support at this call site)"
|
|
875
|
+
|
|
876
|
+
# #1889: mirrors the model_reason block above — state which link of the
|
|
877
|
+
# spec(None) → providers.labels → repo → default chain
|
|
878
|
+
# (coord.providers.resolve_provider_name) won, so a providers.labels
|
|
879
|
+
# match (e.g. `harness:opencode`) is legible in `coord milestone
|
|
880
|
+
# dispatch`'s output, not just discoverable via coordinator.yml.
|
|
881
|
+
from coord.providers import describe_provider_choice # noqa: PLC0415
|
|
882
|
+
|
|
883
|
+
provider_reason = describe_provider_choice(
|
|
884
|
+
None, repo_cfg.provider, config.providers,
|
|
885
|
+
issue_labels=provider_issue_labels,
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
proposal = Proposal(
|
|
889
|
+
id=0,
|
|
890
|
+
machine_name=machine.name,
|
|
891
|
+
repo_name=repo_cfg.name,
|
|
892
|
+
issue_number=issue_number,
|
|
893
|
+
issue_title=issue_title,
|
|
894
|
+
rationale="milestone work-order dispatch (coord milestone dispatch)",
|
|
895
|
+
briefing=briefing,
|
|
896
|
+
model=resolved_model,
|
|
897
|
+
type=proposal_type,
|
|
898
|
+
required_gates=required_gates,
|
|
899
|
+
milestone_number=milestone_number,
|
|
900
|
+
issue_labels=issue_labels,
|
|
901
|
+
)
|
|
902
|
+
|
|
903
|
+
try:
|
|
904
|
+
response = dispatch(proposal, config)
|
|
905
|
+
except (httpx.HTTPError, ValueError) as e:
|
|
906
|
+
return DispatchOutcome(
|
|
907
|
+
issue_number=issue_number, machine_name=machine.name, ok=False, error=str(e)
|
|
908
|
+
)
|
|
909
|
+
|
|
910
|
+
assignment_id = response.get("id", "pending")
|
|
911
|
+
record_dispatched(
|
|
912
|
+
assignment_id=assignment_id,
|
|
913
|
+
proposal=proposal,
|
|
914
|
+
repo_github=repo_cfg.github,
|
|
915
|
+
provider_name=response.get("_provider_name"),
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
try:
|
|
919
|
+
post_briefing(proposal, config, assignment_id=assignment_id, do_not_touch=())
|
|
920
|
+
except Exception: # noqa: BLE001 — best-effort, mirrors _dispatch_headless
|
|
921
|
+
pass
|
|
922
|
+
|
|
923
|
+
board.active.append(
|
|
924
|
+
Assignment(
|
|
925
|
+
machine_name=machine.name,
|
|
926
|
+
repo_name=repo_cfg.name,
|
|
927
|
+
issue_number=issue_number,
|
|
928
|
+
issue_title=issue_title,
|
|
929
|
+
assignment_id=str(assignment_id),
|
|
930
|
+
status="running",
|
|
931
|
+
type=proposal.type,
|
|
932
|
+
)
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
return DispatchOutcome(
|
|
936
|
+
issue_number=issue_number,
|
|
937
|
+
machine_name=machine.name,
|
|
938
|
+
ok=True,
|
|
939
|
+
assignment_id=str(assignment_id),
|
|
940
|
+
model=resolved_model,
|
|
941
|
+
model_reason=model_reason,
|
|
942
|
+
provider_reason=provider_reason,
|
|
943
|
+
)
|