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/dispatch.py
ADDED
|
@@ -0,0 +1,1009 @@
|
|
|
1
|
+
"""Dispatch approved assignments to agent servers and post briefings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from coord import github_ops
|
|
11
|
+
from coord.comments import (
|
|
12
|
+
format_advisory,
|
|
13
|
+
format_briefing,
|
|
14
|
+
format_completion,
|
|
15
|
+
format_failure,
|
|
16
|
+
)
|
|
17
|
+
from coord.config import Config
|
|
18
|
+
from coord.models import Proposal, Repo
|
|
19
|
+
|
|
20
|
+
AGENT_PORT = 7433
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class DispatchRefused(ValueError):
|
|
24
|
+
"""A pre-dispatch guard's refusal — deterministic, not transient (#1844).
|
|
25
|
+
|
|
26
|
+
Raised by :func:`enforce_oracle_readiness` and
|
|
27
|
+
:func:`enforce_epic_dispatch_guard` instead of a plain ``ValueError``:
|
|
28
|
+
both refuse on a condition that CANNOT change between attempts (no
|
|
29
|
+
acceptance slice exists yet; a tracking issue carries the epic label),
|
|
30
|
+
unlike the other ``ValueError``s ``dispatch()`` can raise (an unresolved
|
|
31
|
+
machine/repo_path, the #437 TOS gate, a provider/machine capability
|
|
32
|
+
mismatch) which this deliberately leaves alone — those are refusals too,
|
|
33
|
+
but reclassifying their retry-worthiness is outside what this issue
|
|
34
|
+
covers, and several of them ARE operator-fixable in ways a running fleet
|
|
35
|
+
can race (e.g. adding a capability to a machine's config while `coord
|
|
36
|
+
drive-queue` is ticking).
|
|
37
|
+
|
|
38
|
+
A subclass of ``ValueError``, not a new hierarchy: every existing
|
|
39
|
+
``except ValueError`` catch (CLI error handling, tests asserting the
|
|
40
|
+
message) keeps working completely unchanged — `str(exc)` is still the
|
|
41
|
+
plain refusal text. Only a caller that specifically wants to know "was
|
|
42
|
+
this refusal deterministic" (``coord drive``'s subprocess boundary, via
|
|
43
|
+
``coord assign``/``coord approve-plan``/``coord fix`` mapping THIS
|
|
44
|
+
exception — and no other — to ``coord.drive.EXIT_DISPATCH_REFUSED``)
|
|
45
|
+
needs to catch it by name.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def enforce_oracle_readiness(
|
|
50
|
+
*, proposal_type: str, repo: Repo | None, config: Config, issue_number: int,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""#1138: hard-gate a ``type="work"`` dispatch on the issue-level oracle
|
|
53
|
+
gate (:func:`coord.milestone_dispatch.issue_oracle_ready`) — refuses an
|
|
54
|
+
issue that belongs to an oracle-opted-in milestone (Gate A already
|
|
55
|
+
satisfied) but has no JIT-authored acceptance slice yet, or whose repo
|
|
56
|
+
declares a driver ``kind`` this install doesn't implement.
|
|
57
|
+
|
|
58
|
+
Raises :class:`DispatchRefused` on refusal — a :class:`ValueError`
|
|
59
|
+
subclass (#1844), so callers get "refuse cleanly" for free via their
|
|
60
|
+
existing ``except ValueError`` handling (``coord approve``, ``coord
|
|
61
|
+
assign``, ``coord milestone dispatch``) with zero CLI-layer changes, same
|
|
62
|
+
as before this was split out from a plain ``ValueError``. Distinct from
|
|
63
|
+
it specifically so `coord drive`'s subprocess boundary can tell THIS
|
|
64
|
+
refusal — deterministic, no acceptance slice will appear on retry —
|
|
65
|
+
apart from a transient one (missing ``repo_path``, the #437 TOS gate).
|
|
66
|
+
|
|
67
|
+
Cheap no-op — no network call — for every dispatch outside #1138's
|
|
68
|
+
scope: non-work proposal types (``plan``, ``review``, ``smoke``, ...),
|
|
69
|
+
an unknown repo, or a repo with no ``acceptance.drivers`` entry
|
|
70
|
+
configured (``has_driver`` is a local dict lookup). Shared by the
|
|
71
|
+
headless ``dispatch()`` POST below and the ``--interactive``
|
|
72
|
+
human-attended work launcher (``_dispatch_interactive_work``, which
|
|
73
|
+
never calls ``dispatch()``) so both flavours of Work dispatch are
|
|
74
|
+
covered, not just the unattended one.
|
|
75
|
+
|
|
76
|
+
Fails OPEN (proceeds, doesn't gate) if the issue itself can't be
|
|
77
|
+
fetched — mirroring the fail-soft posture the rest of the oracle-loop
|
|
78
|
+
machinery already uses (``oracle_loop_contract_block``, #945: "never let
|
|
79
|
+
a [...] read break dispatch"). By the time ``dispatch()`` runs, the
|
|
80
|
+
caller has already successfully fetched this same issue once (for its
|
|
81
|
+
title/briefing) moments earlier, so a failure here is a genuine
|
|
82
|
+
transient blip, not a sign the issue doesn't exist — treating it as a
|
|
83
|
+
hard stop would turn a GitHub hiccup into a fleet-wide outage for every
|
|
84
|
+
oracle-configured repo, which is a worse failure mode than the gap
|
|
85
|
+
#1138 closes.
|
|
86
|
+
"""
|
|
87
|
+
if proposal_type != "work" or repo is None:
|
|
88
|
+
return
|
|
89
|
+
if not config.acceptance.has_driver(repo.name):
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
from coord import github_ops # noqa: PLC0415
|
|
93
|
+
from coord.milestone_dispatch import issue_oracle_ready # noqa: PLC0415
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
issue_data = github_ops.get_issue(repo.github, issue_number)
|
|
97
|
+
except RuntimeError:
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
milestone_number = (issue_data.get("milestone") or {}).get("number")
|
|
101
|
+
issue_labels = [lbl.get("name", "") for lbl in (issue_data.get("labels") or [])]
|
|
102
|
+
|
|
103
|
+
readiness = issue_oracle_ready(
|
|
104
|
+
repo, config, milestone_number, issue_number, issue_labels,
|
|
105
|
+
)
|
|
106
|
+
if readiness.reason is not None:
|
|
107
|
+
raise DispatchRefused(readiness.reason)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def enforce_epic_dispatch_guard(
|
|
111
|
+
*, proposal_type: str, repo: Repo | None, config: Config, issue_number: int,
|
|
112
|
+
) -> None:
|
|
113
|
+
"""#1314: refuse a dispatch that would auto-close an epic/tracking issue
|
|
114
|
+
on merge (``proposal_type`` in :data:`coord.models.CLOSES_ISSUE_TYPES`,
|
|
115
|
+
e.g. ``"work"``) when *issue_number* itself carries the ``"epic"``
|
|
116
|
+
label (:data:`coord.milestone_order.TRACKING_ISSUE_LABEL`).
|
|
117
|
+
|
|
118
|
+
The #1077/#1142 ``CLOSES_ISSUE_TYPES`` split (see ``coord/models.py``)
|
|
119
|
+
already assumes only ``mock-author``/``test-author`` are ever dispatched
|
|
120
|
+
directly against a tracking issue's own number — a small correction to
|
|
121
|
+
an already-merged Gate-A contract, with no properly-typed tool for it
|
|
122
|
+
yet, falls back to a plain ``coord assign`` (``type="work"``) instead.
|
|
123
|
+
That silently breaks the same assumption a ``type="work"`` merge relies
|
|
124
|
+
on everywhere else: that ``issue_number`` is real, resolvable work, not
|
|
125
|
+
a milestone's tracking issue. Hit in practice against epic #1120's Gate
|
|
126
|
+
A contract (PR #1312) — this is the dispatch-time half of the fix;
|
|
127
|
+
``coord/commands/plan_followup.py``'s ``pr()`` command independently
|
|
128
|
+
checks the same label so the PR body never carries the closing keyword
|
|
129
|
+
even for an already-dispatched assignment.
|
|
130
|
+
|
|
131
|
+
Override: label the issue ``oracle:exempt`` (the existing "I know what
|
|
132
|
+
I'm doing, let this bypass oracle-loop-specific gating" signal — see
|
|
133
|
+
:func:`enforce_oracle_readiness`) to dispatch anyway. Raises
|
|
134
|
+
:class:`DispatchRefused` on refusal (#1844) — same deterministic-refusal
|
|
135
|
+
reasoning as :func:`enforce_oracle_readiness`, and still a
|
|
136
|
+
:class:`ValueError` under the hood, so callers get "refuse cleanly" for
|
|
137
|
+
free via their existing ``except ValueError`` handling.
|
|
138
|
+
|
|
139
|
+
Fails OPEN (proceeds) if the issue can't be fetched or *repo* is
|
|
140
|
+
``None`` — mirrors :func:`enforce_oracle_readiness`'s posture; a
|
|
141
|
+
transient GitHub read failure must not turn into a fleet-wide dispatch
|
|
142
|
+
outage.
|
|
143
|
+
|
|
144
|
+
Scoped to repos with an ``acceptance.drivers`` entry configured (same
|
|
145
|
+
cheap no-op test :func:`enforce_oracle_readiness` uses) — a local dict
|
|
146
|
+
lookup, no network call, for every dispatch outside this scope. #1314's
|
|
147
|
+
actual failure mode is inherent to the oracle loop's own convention of
|
|
148
|
+
dispatching ``mock-author``/``test-author`` against a tracking issue's
|
|
149
|
+
number in the first place; a repo with no acceptance driver has no such
|
|
150
|
+
convention, so this intentionally does not add a `gh` round-trip to
|
|
151
|
+
every "work" dispatch fleet-wide for a scenario that can't arise there.
|
|
152
|
+
A plain (non-oracle) repo whose operator manually dispatches "work"
|
|
153
|
+
against an epic's own number is a real but separate gap, same as the
|
|
154
|
+
one #1138's oracle-readiness gate already accepts for the same reason.
|
|
155
|
+
"""
|
|
156
|
+
from coord.models import CLOSES_ISSUE_TYPES # noqa: PLC0415
|
|
157
|
+
|
|
158
|
+
if proposal_type not in CLOSES_ISSUE_TYPES or repo is None:
|
|
159
|
+
return
|
|
160
|
+
if not config.acceptance.has_driver(repo.name):
|
|
161
|
+
return
|
|
162
|
+
|
|
163
|
+
from coord.milestone_order import TRACKING_ISSUE_LABEL # noqa: PLC0415
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
issue_data = github_ops.get_issue(repo.github, issue_number)
|
|
167
|
+
except RuntimeError:
|
|
168
|
+
return
|
|
169
|
+
|
|
170
|
+
issue_labels = {lbl.get("name", "") for lbl in (issue_data.get("labels") or [])}
|
|
171
|
+
if TRACKING_ISSUE_LABEL not in issue_labels or "oracle:exempt" in issue_labels:
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
raise DispatchRefused(
|
|
175
|
+
f"refusing type={proposal_type!r} dispatch against #{issue_number}: it "
|
|
176
|
+
f"carries the {TRACKING_ISSUE_LABEL!r} label (a milestone tracking/epic "
|
|
177
|
+
"issue) — merging this would close the epic while its real sub-issues "
|
|
178
|
+
"stay open/untouched (#1314). If this is a deliberate meta-level "
|
|
179
|
+
"dispatch against the tracking issue's own number (e.g. a Gate-A "
|
|
180
|
+
"contract correction), label the issue 'oracle:exempt' to override, "
|
|
181
|
+
"or use a properly-typed dispatch instead — e.g. `coord acceptance "
|
|
182
|
+
"mock <repo> <tracking_issue> --amend '<correction>'` for a "
|
|
183
|
+
"targeted fix to an already-merged Gate-A contract (#1315)."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def enforce_model_provider_compatibility(
|
|
188
|
+
*, wire_model: str | None, effective_provider_name: str, config: Config,
|
|
189
|
+
) -> None:
|
|
190
|
+
"""#1798: STRUCTURAL MODEL/PROVIDER GATE — refuse to dispatch a *wire_model*
|
|
191
|
+
that cannot plausibly belong to the resolved provider's backend type
|
|
192
|
+
(:func:`coord.config.model_plausible_for_provider_type`), e.g. a Claude
|
|
193
|
+
alias (``"sonnet"``) handed to an ``opencode``-type provider.
|
|
194
|
+
|
|
195
|
+
:func:`resolve_dispatch_model_alias`'s precedence fix (provider pin wins
|
|
196
|
+
over label routing) closes the common path into this failure, but
|
|
197
|
+
doesn't close every one — an operator can still pass an explicit
|
|
198
|
+
``--model`` that mismatches the resolved provider, or configure a
|
|
199
|
+
non-claude provider with no pin at all (so a namespace-mismatched
|
|
200
|
+
*label_model* falls through unchecked). Before this gate, a mismatch
|
|
201
|
+
like that was only discovered when the backend itself rejected the
|
|
202
|
+
argument mid-run, minutes into a worker, after the assignment row
|
|
203
|
+
existed and the worktree was built — see the #1798 issue's #1708 proof
|
|
204
|
+
run, where an explicit ``--model opencode/glm-5.2`` override against a
|
|
205
|
+
silently-``claude``-falling-back agent made a masked bug visible only
|
|
206
|
+
because the mismatch was loud. Placed alongside the other structural
|
|
207
|
+
gates in :func:`dispatch` (oracle readiness, epic-target, TOS,
|
|
208
|
+
provider-availability), before any worktree/HTTP work happens.
|
|
209
|
+
|
|
210
|
+
A ``None`` *wire_model* (``--model`` omitted, provider's own default
|
|
211
|
+
applies) is always a no-op — there's nothing to validate.
|
|
212
|
+
|
|
213
|
+
Raises:
|
|
214
|
+
ValueError: When *wire_model* is set but implausible for the
|
|
215
|
+
resolved provider's type, naming both.
|
|
216
|
+
"""
|
|
217
|
+
if wire_model is None:
|
|
218
|
+
return
|
|
219
|
+
from coord.providers import provider_type_for # noqa: PLC0415
|
|
220
|
+
from coord.config import model_plausible_for_provider_type # noqa: PLC0415
|
|
221
|
+
|
|
222
|
+
provider_type = provider_type_for(effective_provider_name, config.providers)
|
|
223
|
+
if model_plausible_for_provider_type(wire_model, provider_type):
|
|
224
|
+
return
|
|
225
|
+
raise ValueError(
|
|
226
|
+
f"refusing dispatch: model {wire_model!r} is not valid for provider "
|
|
227
|
+
f"{effective_provider_name!r} (type {provider_type!r}) — the two "
|
|
228
|
+
"belong to different model namespaces (#1798). Pass an explicit "
|
|
229
|
+
"--model in the resolved provider's own namespace, or leave "
|
|
230
|
+
"--model unset and pin providers.definitions"
|
|
231
|
+
f"[{effective_provider_name!r}].model in coordinator.yml instead."
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _reraise_with_body(
|
|
236
|
+
exc: httpx.HTTPStatusError, machine_name: str,
|
|
237
|
+
) -> httpx.HTTPStatusError:
|
|
238
|
+
"""#1527: fold the agent's rejection reason into the raised exception.
|
|
239
|
+
|
|
240
|
+
``AgentServer.assign`` (coord/agent.py) raises a precise ``ValueError``
|
|
241
|
+
for every dispatch-time rejection — unhandled repo, missing
|
|
242
|
+
``repo_path``, unknown ``pull_repos``, the #425/#324 provider
|
|
243
|
+
capability gates — and ``agent_app.py``'s ``assign`` route faithfully
|
|
244
|
+
returns each as ``{"error": "<reason>"}`` with a 400. Plain
|
|
245
|
+
``resp.raise_for_status()`` discards that body: every existing caller
|
|
246
|
+
that catches ``httpx.HTTPError`` and renders ``str(e)`` (``coord/
|
|
247
|
+
commands/dispatch.py``, ``milestone_dispatch.py``, ``plan_followup.py``,
|
|
248
|
+
``dispatch_workers.py``, ...) only ever saw the generic status line
|
|
249
|
+
("400 Bad Request"), never the agent's own reason.
|
|
250
|
+
|
|
251
|
+
Returns a **new** ``httpx.HTTPStatusError`` — never raises one — so
|
|
252
|
+
``dispatch()`` can ``raise ... from e`` at the call site. Carries the
|
|
253
|
+
same ``request``/``response`` as *exc* so ``classify_error``/
|
|
254
|
+
``is_retryable`` (coord/network.py), which inspect
|
|
255
|
+
``exc.response.status_code``, keep working unchanged; only the message
|
|
256
|
+
gains the detail.
|
|
257
|
+
|
|
258
|
+
Degrades gracefully rather than raising a *different* error out of an
|
|
259
|
+
error handler: a non-JSON or JSON-but-not-``{"error": ...}`` body falls
|
|
260
|
+
back to the raw response text (truncated); an empty body falls back to
|
|
261
|
+
the original status-line message.
|
|
262
|
+
"""
|
|
263
|
+
detail = ""
|
|
264
|
+
try:
|
|
265
|
+
parsed = exc.response.json()
|
|
266
|
+
except ValueError:
|
|
267
|
+
parsed = None
|
|
268
|
+
if isinstance(parsed, dict):
|
|
269
|
+
detail = str(parsed.get("error") or "")
|
|
270
|
+
if not detail:
|
|
271
|
+
detail = exc.response.text[:500].strip()
|
|
272
|
+
message = (
|
|
273
|
+
f"{machine_name} rejected the assignment: {detail}" if detail else str(exc)
|
|
274
|
+
)
|
|
275
|
+
return httpx.HTTPStatusError(message, request=exc.request, response=exc.response)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def resolve_dispatch_model_alias(
|
|
279
|
+
*,
|
|
280
|
+
explicit_model: str | None,
|
|
281
|
+
label_model: str | None,
|
|
282
|
+
config: Config,
|
|
283
|
+
effective_provider_name: str,
|
|
284
|
+
) -> str | None:
|
|
285
|
+
"""Resolve the dispatch model *alias* (before ``models.resolve()``).
|
|
286
|
+
|
|
287
|
+
Precedence: *explicit_model* → the effective provider's own pinned
|
|
288
|
+
``ProviderDef.model`` (for non-claude/claude-pty backends only) →
|
|
289
|
+
*label_model* → ``models.default``. The board/DB stores this alias for
|
|
290
|
+
legibility; the caller is responsible for the final
|
|
291
|
+
``config.models.resolve()`` translation to an exact model id (typically
|
|
292
|
+
deferred to :func:`dispatch`/:func:`resolve_dispatch_model`, which run
|
|
293
|
+
at actual-wire-payload time — see the module docstring of each CLI
|
|
294
|
+
entry point that calls this function for why: re-resolving here would
|
|
295
|
+
bake an exact id into ``Proposal.model``/board bookkeeping instead of
|
|
296
|
+
the human-legible alias).
|
|
297
|
+
|
|
298
|
+
#1798: *label_model* comes from ``models.labels``, which maps to
|
|
299
|
+
Anthropic aliases (``tier:small -> haiku``) — a namespace that means
|
|
300
|
+
nothing to a non-claude/claude-pty backend. Letting it win over a
|
|
301
|
+
pinned non-claude provider's own ``ProviderDef.model`` (the OLD
|
|
302
|
+
precedence, `explicit_model or label_model` unconditionally) dispatched
|
|
303
|
+
e.g. ``opencode`` with ``--model sonnet``, a Claude alias the opencode
|
|
304
|
+
binary cannot serve — silently, with no validation at dispatch time.
|
|
305
|
+
Now the provider's own pin wins over label routing whenever it applies
|
|
306
|
+
(*provider_pins_model*); only an *explicit_model* — a human being
|
|
307
|
+
specific — still overrides the pin. Claude/claude-pty backends are
|
|
308
|
+
unaffected: *provider_pins_model* is always ``False`` for them (their
|
|
309
|
+
``ProviderDef.model``, if any, is folded into ``models.default`` via a
|
|
310
|
+
different path — see :attr:`coord.config.ProviderDef.model`'s
|
|
311
|
+
docstring), so label routing there is unchanged.
|
|
312
|
+
|
|
313
|
+
#1430: label routing (*label_model*) is gated to ``type="work"`` by
|
|
314
|
+
every caller — plan workers are read-only/cheap and must not inherit a
|
|
315
|
+
``tier:large`` -> opus routing meant for the eventual work dispatch.
|
|
316
|
+
Callers for which label routing doesn't apply (plan-only, review,
|
|
317
|
+
smoke, chat, etc.) pass ``label_model=None``.
|
|
318
|
+
|
|
319
|
+
#1706 review fix: ``config.models.default`` (e.g. ``"sonnet"``) is a
|
|
320
|
+
Claude model alias and means nothing to a non-Claude backend — passing
|
|
321
|
+
it through as ``--model sonnet`` to ``opencode run`` (or any future
|
|
322
|
+
non-Claude provider) is nonsensical. When *explicit_model* didn't supply
|
|
323
|
+
a model, and the effective provider's backend *type* is NOT
|
|
324
|
+
``claude``/``claude-pty``, check whether that provider's own
|
|
325
|
+
``ProviderDef.model`` (coordinator.yml ``providers.
|
|
326
|
+
definitions.<name>.model``) is pinned; if so, return ``None`` instead
|
|
327
|
+
of *label_model*/``models.default`` so the eventual wire payload omits
|
|
328
|
+
``--model`` entirely and the provider instance's own ``build_command``
|
|
329
|
+
falls back to its ``self._model`` (threaded in from ``ProviderDef.model``
|
|
330
|
+
at construction — see ``coord/providers/opencode.py`` and
|
|
331
|
+
``coord/providers/claude.py``). This makes ``providers.definitions.
|
|
332
|
+
<name>.model`` actually reachable for a normal ``coord assign``/``coord
|
|
333
|
+
approve``/``coord milestone dispatch`` with no explicit ``--model``,
|
|
334
|
+
instead of being permanently shadowed by ``config.models.default`` OR
|
|
335
|
+
(#1798) by a namespace-mismatched *label_model*.
|
|
336
|
+
|
|
337
|
+
Only an explicit override (``--model``) wins over the provider's pin —
|
|
338
|
+
matching the precedence already implemented in each provider's
|
|
339
|
+
``build_command``: resolved_model > spec.model > definition.model.
|
|
340
|
+
Label routing does NOT win over the pin (#1798 fix): ``models.labels``
|
|
341
|
+
resolves to Claude aliases, which are meaningless to a pinned non-claude
|
|
342
|
+
provider, so the pin — the only value in the correct namespace — wins
|
|
343
|
+
instead.
|
|
344
|
+
|
|
345
|
+
This function is called from every site that used to inline the
|
|
346
|
+
``explicit or label or config.models.default`` rule so the
|
|
347
|
+
provider-aware exception lives in exactly one place:
|
|
348
|
+
``coord.dispatch.dispatch`` (the fallback, for callers that pass
|
|
349
|
+
``proposal.model=None``), ``coord.commands.dispatch_workers.
|
|
350
|
+
_dispatch_headless`` (``coord assign``), the ``approve`` command's
|
|
351
|
+
per-proposal loop in ``coord.commands.dispatch``, and
|
|
352
|
+
``coord.milestone_dispatch.dispatch_entry`` (``coord milestone
|
|
353
|
+
dispatch``).
|
|
354
|
+
|
|
355
|
+
Deliberately keyed off ``ProviderDef.type``, not the definition's name:
|
|
356
|
+
an operator can register a ``claude``/``claude-pty`` backend under an
|
|
357
|
+
arbitrary name (e.g. ``fast-claude`` in ``coordinator.example.yml``),
|
|
358
|
+
and that backend's ``--model`` must still flow through
|
|
359
|
+
``config.models.resolve()``'s alias -> exact-id translation
|
|
360
|
+
(``models.versions``) — bypassing ``models.default`` for it here would
|
|
361
|
+
let an unresolved alias leak through instead (see ``ProviderDef.model``'s
|
|
362
|
+
docstring for the caveat).
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
explicit_model: The explicit per-dispatch override (``--model``
|
|
366
|
+
flag, or an already-resolved ``proposal.model`` from an earlier
|
|
367
|
+
stage), if any.
|
|
368
|
+
label_model: The issue-label-routed model (``type="work"`` only),
|
|
369
|
+
or ``None`` when label routing doesn't apply.
|
|
370
|
+
config: The coordinator config (``models`` and ``providers``).
|
|
371
|
+
effective_provider_name: The already-resolved effective provider
|
|
372
|
+
name (spec > repo > ``providers.default``), as returned by
|
|
373
|
+
:func:`coord.providers.resolve_provider_name` or
|
|
374
|
+
:func:`coord.providers.guard_unattended_dispatch`.
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
The model alias (NOT yet passed through ``config.models.resolve()``),
|
|
378
|
+
or ``None`` to omit ``--model``.
|
|
379
|
+
"""
|
|
380
|
+
provider_def = config.providers.definitions.get(effective_provider_name)
|
|
381
|
+
provider_pins_model = (
|
|
382
|
+
provider_def is not None
|
|
383
|
+
and provider_def.type not in ("claude", "claude-pty")
|
|
384
|
+
and provider_def.model is not None
|
|
385
|
+
)
|
|
386
|
+
if explicit_model:
|
|
387
|
+
return explicit_model
|
|
388
|
+
if provider_pins_model:
|
|
389
|
+
return None
|
|
390
|
+
if label_model:
|
|
391
|
+
return label_model
|
|
392
|
+
return config.models.default
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def resolve_dispatch_model(
|
|
396
|
+
proposal: Proposal, config: Config, effective_provider_name: str,
|
|
397
|
+
) -> str | None:
|
|
398
|
+
"""Resolve the wire-payload ``model`` for a :func:`dispatch` call.
|
|
399
|
+
|
|
400
|
+
Thin wrapper around :func:`resolve_dispatch_model_alias`: computes
|
|
401
|
+
*label_model* from ``proposal.issue_labels`` (gated to ``type="work"``,
|
|
402
|
+
per #1430 — see that function's docstring), then translates the
|
|
403
|
+
resulting alias to an exact model id via ``config.models.resolve()``
|
|
404
|
+
(``models.versions``, when configured) for the actual wire payload.
|
|
405
|
+
|
|
406
|
+
#1430: most callers (``coord approve``, ``coord assign``, ``coord
|
|
407
|
+
milestone dispatch``) already pre-resolve ``proposal.model`` via
|
|
408
|
+
:func:`resolve_dispatch_model_alias` themselves before calling
|
|
409
|
+
:func:`dispatch` — for bookkeeping, so the dispatched record/board
|
|
410
|
+
reflect what actually ran. This function is the fallback for any
|
|
411
|
+
caller that doesn't: it repeats the same rule so :func:`dispatch` is
|
|
412
|
+
correct on its own, not just when every caller remembers to do the
|
|
413
|
+
work upfront.
|
|
414
|
+
|
|
415
|
+
Args:
|
|
416
|
+
proposal: The dispatch proposal (``proposal.model`` is the explicit
|
|
417
|
+
per-dispatch override, if any — already resolved by an earlier
|
|
418
|
+
stage for most callers).
|
|
419
|
+
config: The coordinator config (``models`` and ``providers``).
|
|
420
|
+
effective_provider_name: The already-resolved effective provider
|
|
421
|
+
name (spec > repo > ``providers.default``), as returned by
|
|
422
|
+
:func:`coord.providers.guard_unattended_dispatch`.
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
The wire-ready model id/alias (already passed through
|
|
426
|
+
``config.models.resolve()``), or ``None`` to omit ``--model``.
|
|
427
|
+
"""
|
|
428
|
+
label_model = (
|
|
429
|
+
config.models.model_for_labels(proposal.issue_labels)
|
|
430
|
+
if proposal.type == "work"
|
|
431
|
+
else None
|
|
432
|
+
)
|
|
433
|
+
alias = resolve_dispatch_model_alias(
|
|
434
|
+
explicit_model=proposal.model,
|
|
435
|
+
label_model=label_model,
|
|
436
|
+
config=config,
|
|
437
|
+
effective_provider_name=effective_provider_name,
|
|
438
|
+
)
|
|
439
|
+
return config.models.resolve(alias)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _wire_payload_needs_provider_field(
|
|
443
|
+
effective_provider_name: str, config: Config,
|
|
444
|
+
) -> bool:
|
|
445
|
+
"""Whether :func:`dispatch`'s wire payload must include ``"provider"``
|
|
446
|
+
(#1711 review of #324's payload-omission gap).
|
|
447
|
+
|
|
448
|
+
The historical rule (send ``provider`` only when the effective name is
|
|
449
|
+
not ``"claude"``) was deliberate old-agent compatibility, but it hid a
|
|
450
|
+
reachable divergence: the ``"claude"`` entry in
|
|
451
|
+
``providers.definitions`` can be **customized** (a redefined
|
|
452
|
+
``binary``, ``env``, or ``extra_args`` — legal per ``ProviderDef``'s
|
|
453
|
+
docstring, e.g. to point ``claude`` at a wrapped binary) without ever
|
|
454
|
+
changing its NAME. Omitting the field for name ``"claude"``
|
|
455
|
+
unconditionally sends the agent down its hardcoded legacy spawn path
|
|
456
|
+
(``coord.agent.default_worker_command``, always the bare ``"claude"``
|
|
457
|
+
binary with no env/extra_args) instead of through the provider seam
|
|
458
|
+
that would apply those customizations — so the coordinator's recorded
|
|
459
|
+
``provider_name="claude"`` would silently stop matching what actually
|
|
460
|
+
ran on the agent, corrupting exactly the kind of comparison this
|
|
461
|
+
provider-plumbing epic (#1709) exists to make trustworthy.
|
|
462
|
+
|
|
463
|
+
Fix: still omit the field for the vanilla (uncustomized) ``"claude"``
|
|
464
|
+
definition — preserving byte-identical payloads for every no-
|
|
465
|
+
``providers:``-block deployment and genuine old-agent compatibility —
|
|
466
|
+
but include it the moment that definition carries ANY customization,
|
|
467
|
+
so the agent is told to route through the provider seam and actually
|
|
468
|
+
apply it. A non-``"claude"`` effective name always needs the field
|
|
469
|
+
regardless (unchanged from #324).
|
|
470
|
+
"""
|
|
471
|
+
if effective_provider_name != "claude":
|
|
472
|
+
return True
|
|
473
|
+
definition = config.providers.definitions.get("claude")
|
|
474
|
+
if definition is None:
|
|
475
|
+
return False
|
|
476
|
+
return bool(
|
|
477
|
+
definition.binary or definition.model or definition.env or definition.extra_args
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def dispatch(
|
|
482
|
+
proposal: Proposal,
|
|
483
|
+
config: Config,
|
|
484
|
+
*,
|
|
485
|
+
pull_repos: Iterable[str] = (),
|
|
486
|
+
fresh_branch: bool = False,
|
|
487
|
+
) -> dict:
|
|
488
|
+
"""POST an assignment to the agent server on the target machine.
|
|
489
|
+
|
|
490
|
+
Returns the response JSON from the agent server (which includes the
|
|
491
|
+
server-assigned `id`).
|
|
492
|
+
"""
|
|
493
|
+
machine = next(
|
|
494
|
+
(m for m in config.machines if m.name == proposal.machine_name), None
|
|
495
|
+
)
|
|
496
|
+
if machine is None:
|
|
497
|
+
raise ValueError(f"Unknown machine: {proposal.machine_name!r}")
|
|
498
|
+
|
|
499
|
+
repo_path = machine.repo_path(proposal.repo_name)
|
|
500
|
+
if repo_path is None:
|
|
501
|
+
raise ValueError(
|
|
502
|
+
f"No repo_path configured for {proposal.repo_name!r} on machine {machine.name!r}. "
|
|
503
|
+
f"Add it to coordinator.yml under machines[].repo_paths."
|
|
504
|
+
)
|
|
505
|
+
|
|
506
|
+
# Resolve deny-list from the repo's worker_permissions config.
|
|
507
|
+
repo = config.repo(proposal.repo_name)
|
|
508
|
+
|
|
509
|
+
# #1138: STRUCTURAL ORACLE-LOOP GATE — refuse a `type="work"` dispatch
|
|
510
|
+
# for an issue inside an oracle-opted-in milestone (Gate A satisfied)
|
|
511
|
+
# that has no JIT-authored acceptance slice yet, or whose repo declares
|
|
512
|
+
# a driver kind this install doesn't implement. Placed early / before
|
|
513
|
+
# the TOS gate below so a refusal never depends on provider resolution
|
|
514
|
+
# succeeding first.
|
|
515
|
+
enforce_oracle_readiness(
|
|
516
|
+
proposal_type=proposal.type, repo=repo, config=config,
|
|
517
|
+
issue_number=proposal.issue_number,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
# #1314: STRUCTURAL EPIC-TARGET GATE — refuse a dispatch that would
|
|
521
|
+
# auto-close a tracking/epic issue on merge (see
|
|
522
|
+
# `enforce_epic_dispatch_guard`'s docstring). Placed alongside the
|
|
523
|
+
# oracle-readiness gate above, before the TOS gate, for the same reason.
|
|
524
|
+
enforce_epic_dispatch_guard(
|
|
525
|
+
proposal_type=proposal.type, repo=repo, config=config,
|
|
526
|
+
issue_number=proposal.issue_number,
|
|
527
|
+
)
|
|
528
|
+
|
|
529
|
+
# #437: STRUCTURAL TOS-COMPLIANCE GATE — refuse to route an
|
|
530
|
+
# unattended dispatch through a provider whose capabilities mark it
|
|
531
|
+
# ``human_attended_only`` (subscription-billed interactive Claude
|
|
532
|
+
# Code). Precedence: per-proposal override (if the brain ever sets
|
|
533
|
+
# one) → ``providers.labels`` match (#1889) → per-repo
|
|
534
|
+
# ``Repo.provider`` → ``config.providers.default``. Deferred import so
|
|
535
|
+
# the unattended dispatch surface stays free of a module-level cycle
|
|
536
|
+
# with the provider registry.
|
|
537
|
+
from coord.providers import guard_unattended_dispatch # noqa: PLC0415
|
|
538
|
+
spec_provider = getattr(proposal, "provider", None)
|
|
539
|
+
# #1889: providers.labels routes work dispatches by the issue's
|
|
540
|
+
# harness-eval label (e.g. `harness:opencode`); plan/review/smoke
|
|
541
|
+
# proposals deliberately stay off it, same as `models.labels` (#1430) —
|
|
542
|
+
# a label meant for the eventual work dispatch must not leak into a
|
|
543
|
+
# cheap/read-only stage.
|
|
544
|
+
provider_issue_labels = proposal.issue_labels if proposal.type == "work" else None
|
|
545
|
+
# #324: resolve the effective provider name (spec > label > repo >
|
|
546
|
+
# default) so the coordinator DB always records the winning provider
|
|
547
|
+
# regardless of which level supplied it, and the wire payload carries
|
|
548
|
+
# the exact name the agent should look up in its registry.
|
|
549
|
+
effective_provider_name: str = guard_unattended_dispatch(
|
|
550
|
+
spec_provider=spec_provider,
|
|
551
|
+
repo_provider=repo.provider if repo is not None else None,
|
|
552
|
+
providers_cfg=config.providers,
|
|
553
|
+
models_cfg=config.models,
|
|
554
|
+
where="coord approve / dispatch",
|
|
555
|
+
issue_labels=provider_issue_labels,
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
# #1711: STRUCTURAL PROVIDER-AVAILABILITY GATE — refuse to route a
|
|
559
|
+
# dispatch to a machine that hasn't declared it can run the resolved
|
|
560
|
+
# provider (e.g. an `opencode` assignment landing on a machine with no
|
|
561
|
+
# `provider:opencode` capability). Without this, the failure only
|
|
562
|
+
# surfaced at spawn time inside the agent process as an ENOENT-shaped
|
|
563
|
+
# subprocess error, after the assignment row existed and the worktree
|
|
564
|
+
# was built. Placed right after the TOS gate above, for the same
|
|
565
|
+
# reason: both are structural refusals keyed off the same resolved
|
|
566
|
+
# provider name, before any repo/worktree/HTTP work happens.
|
|
567
|
+
from coord.providers import guard_provider_machine_capability # noqa: PLC0415
|
|
568
|
+
|
|
569
|
+
guard_provider_machine_capability(
|
|
570
|
+
provider_name=effective_provider_name,
|
|
571
|
+
machine=machine,
|
|
572
|
+
all_machines=config.machines,
|
|
573
|
+
providers_cfg=config.providers,
|
|
574
|
+
where="coord approve / dispatch",
|
|
575
|
+
)
|
|
576
|
+
deny_commands: list[str] = []
|
|
577
|
+
if repo is not None and repo.worker_permissions is not None:
|
|
578
|
+
deny_commands = repo.worker_permissions.deny
|
|
579
|
+
|
|
580
|
+
# Resolve coordinator-only files (workers must not read or modify these).
|
|
581
|
+
files_forbidden: list[str] = []
|
|
582
|
+
if repo is not None and repo.coordinator_only_files:
|
|
583
|
+
files_forbidden = list(repo.coordinator_only_files)
|
|
584
|
+
|
|
585
|
+
# #944 sealing v1 (docs/ORACLE_LOOP.md): the acceptance oracle is
|
|
586
|
+
# read-only/run-only for the worker — it's authored by an independent
|
|
587
|
+
# test-author, not the worker under test. Auto-forbid it for any repo
|
|
588
|
+
# with an acceptance driver configured, so sealing doesn't depend on an
|
|
589
|
+
# operator remembering to also list it under coordinator_only_files.
|
|
590
|
+
# #930: exempt `mock-author` — the one type whose entire job IS writing
|
|
591
|
+
# under tests/acceptance/ms-NN/ (Gate A). A future `test-author` (#931)
|
|
592
|
+
# gets the same exemption when it lands.
|
|
593
|
+
# #1552: forbid the driver's declared `entrypoint:` alongside the tree.
|
|
594
|
+
# It is the oracle's crate root — a `type="work"` worker editing
|
|
595
|
+
# `tui/tests/acceptance.rs` can unwire (or re-point) the very slice it is
|
|
596
|
+
# being graded against without ever touching `tests/acceptance/**`.
|
|
597
|
+
if (
|
|
598
|
+
proposal.type != "mock-author"
|
|
599
|
+
and config.acceptance.has_driver(proposal.repo_name)
|
|
600
|
+
):
|
|
601
|
+
for sealed in config.acceptance.sealed_paths(proposal.repo_name):
|
|
602
|
+
if sealed not in files_forbidden:
|
|
603
|
+
files_forbidden.append(sealed)
|
|
604
|
+
|
|
605
|
+
# Resolve model: proposal override → provider-definition pin
|
|
606
|
+
# (non-claude/claude-pty only) → models.labels (type="work" only) →
|
|
607
|
+
# config default. See resolve_dispatch_model()'s docstring for the full
|
|
608
|
+
# precedence rationale (#1430, #1706, #1798).
|
|
609
|
+
wire_model = resolve_dispatch_model(proposal, config, effective_provider_name)
|
|
610
|
+
|
|
611
|
+
# #1798: STRUCTURAL MODEL/PROVIDER GATE — refuse a resolved model that
|
|
612
|
+
# cannot plausibly belong to the resolved provider's backend type (e.g.
|
|
613
|
+
# a Claude alias handed to an opencode-type provider) before any
|
|
614
|
+
# worktree/HTTP work happens. See enforce_model_provider_compatibility's
|
|
615
|
+
# docstring for why this is still needed even after the precedence fix
|
|
616
|
+
# above (an explicit --model, or an unpinned non-claude provider, can
|
|
617
|
+
# still produce a mismatch).
|
|
618
|
+
enforce_model_provider_compatibility(
|
|
619
|
+
wire_model=wire_model, effective_provider_name=effective_provider_name,
|
|
620
|
+
config=config,
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
# #255: pin the worker's branch base to the repo's configured default
|
|
624
|
+
# branch. Without this the agent fell back to a hardcoded "main", which
|
|
625
|
+
# silently routed around `default_branch: develop` repos like quadraui
|
|
626
|
+
# and let local-only commits on the default branch slip into worker
|
|
627
|
+
# branches.
|
|
628
|
+
#
|
|
629
|
+
# #934: when the target issue belongs to a milestone (`proposal.
|
|
630
|
+
# milestone_number`, set by callers like `coord.milestone_dispatch.
|
|
631
|
+
# dispatch_entry` that already fetched the issue) and the repo has
|
|
632
|
+
# opted into the develop + feature-branch-per-milestone git model
|
|
633
|
+
# (`repo.develop_branch` set), branch off `feature/ms-NN` instead —
|
|
634
|
+
# `coord.branch_model.resolve_base_branch` falls back to today's flat
|
|
635
|
+
# `default_branch` behavior for every other repo/proposal.
|
|
636
|
+
from coord.branch_model import resolve_base_branch # noqa: PLC0415
|
|
637
|
+
|
|
638
|
+
if repo is not None:
|
|
639
|
+
default_branch = resolve_base_branch(repo, proposal.milestone_number)
|
|
640
|
+
else:
|
|
641
|
+
default_branch = "main"
|
|
642
|
+
|
|
643
|
+
# #305: artifact_paths are only relevant for work assignments. Skip for
|
|
644
|
+
# review, smoke, refinement, and other non-work types.
|
|
645
|
+
artifact_paths: list[str] = []
|
|
646
|
+
if proposal.type == "work" and repo is not None:
|
|
647
|
+
artifact_paths = list(repo.artifact_paths)
|
|
648
|
+
|
|
649
|
+
# #352: resolve new-issue guidance for new-issue-chat assignments.
|
|
650
|
+
# Only resolve when the repo *explicitly configured* new_issue_guidance —
|
|
651
|
+
# the resolver always returns a non-empty _DEFAULT, so checking
|
|
652
|
+
# `if new_issue_guidance:` below would always send the field, causing
|
|
653
|
+
# agents that predate #352 to reject the payload with a 400. Gating on
|
|
654
|
+
# the raw config field lets repos without guidance dispatch to any agent
|
|
655
|
+
# (the agent's built-in NEW_ISSUE_CHAT_SYSTEM_PROMPT is fine without it).
|
|
656
|
+
new_issue_guidance: str = ""
|
|
657
|
+
if proposal.type == "new-issue-chat" and repo is not None and repo.new_issue_guidance:
|
|
658
|
+
from pathlib import Path
|
|
659
|
+
new_issue_guidance = repo.resolve_new_issue_guidance(Path(repo_path).expanduser())
|
|
660
|
+
|
|
661
|
+
# #603: prepend the per-issue context digest to the TOP of a -p WORK
|
|
662
|
+
# briefing (cross-repo deps / prior-attempt findings) so the worker reads
|
|
663
|
+
# them first. Only `work` (chat/refinement/conflict-fix carry no issue
|
|
664
|
+
# context); the interactive and auto-loop fix/review paths inject at their
|
|
665
|
+
# own sites, so this is the single -p work chokepoint (no double injection).
|
|
666
|
+
#
|
|
667
|
+
# #945 (docs/ORACLE_LOOP.md "The worker briefing contract"): right after
|
|
668
|
+
# the #603 digest, prepend the oracle-loop contract when this repo has an
|
|
669
|
+
# acceptance driver configured (the oracle-loop proxy — #944 never landed
|
|
670
|
+
# a milestone-level flag, so "driver configured for this repo" is the
|
|
671
|
+
# signal, mirroring the tests/acceptance/ auto-seal above) AND this issue
|
|
672
|
+
# already has an authored slice (oracle_loop_contract_block returns ""
|
|
673
|
+
# otherwise, e.g. before Gate A/#931 has run for it).
|
|
674
|
+
briefing_text = proposal.briefing
|
|
675
|
+
if proposal.type == "work" and proposal.issue_number:
|
|
676
|
+
from pathlib import Path # noqa: PLC0415
|
|
677
|
+
|
|
678
|
+
from coord.state import issue_context_block # noqa: PLC0415
|
|
679
|
+
|
|
680
|
+
oracle_contract = ""
|
|
681
|
+
if config.acceptance.has_driver(proposal.repo_name):
|
|
682
|
+
from coord.acceptance import ( # noqa: PLC0415
|
|
683
|
+
ACCEPTANCE_DIRNAME,
|
|
684
|
+
oracle_loop_contract_block,
|
|
685
|
+
)
|
|
686
|
+
|
|
687
|
+
oracle_contract = oracle_loop_contract_block(
|
|
688
|
+
Path(repo_path).expanduser() / ACCEPTANCE_DIRNAME,
|
|
689
|
+
proposal.repo_name,
|
|
690
|
+
proposal.issue_number,
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
# #1720: dispatch-time file-overlap fence — the union of file
|
|
694
|
+
# footprints of every OTHER currently-running work-like assignment
|
|
695
|
+
# in this repo, derived from live branch diffs (not the brain.py
|
|
696
|
+
# prompt-only heuristic, which only covers `coord plan` and guesses
|
|
697
|
+
# from issue body text). Advisory only; never blocks this dispatch —
|
|
698
|
+
# see coord.overlap_fence.compute_overlap_fence's docstring for the
|
|
699
|
+
# fail-open contract. "" when there's nothing running (or nothing
|
|
700
|
+
# with a pushed branch), same no-op-prefix shape as issue_context_block.
|
|
701
|
+
overlap_fence = ""
|
|
702
|
+
if repo is not None and repo.github:
|
|
703
|
+
from coord.overlap_fence import compute_overlap_fence # noqa: PLC0415
|
|
704
|
+
|
|
705
|
+
overlap_fence = compute_overlap_fence(
|
|
706
|
+
proposal.repo_name,
|
|
707
|
+
repo.github,
|
|
708
|
+
default_branch,
|
|
709
|
+
exclude_issue_number=proposal.issue_number,
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
briefing_text = (
|
|
713
|
+
issue_context_block(proposal.repo_name, proposal.issue_number)
|
|
714
|
+
+ overlap_fence
|
|
715
|
+
+ oracle_contract
|
|
716
|
+
+ briefing_text
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
url = f"http://{machine.host}:{AGENT_PORT}/assign"
|
|
720
|
+
payload: dict = {
|
|
721
|
+
"repo_name": proposal.repo_name,
|
|
722
|
+
"repo_path": repo_path,
|
|
723
|
+
"issue_number": proposal.issue_number,
|
|
724
|
+
"issue_title": proposal.issue_title,
|
|
725
|
+
"briefing": briefing_text,
|
|
726
|
+
"files_allowed": proposal.files_likely,
|
|
727
|
+
"files_forbidden": files_forbidden,
|
|
728
|
+
"pull_repos": list(pull_repos),
|
|
729
|
+
"deny_commands": deny_commands,
|
|
730
|
+
"model": wire_model,
|
|
731
|
+
"type": proposal.type,
|
|
732
|
+
"branch": default_branch,
|
|
733
|
+
}
|
|
734
|
+
# #351: only send artifact_paths when non-empty — older agents reject
|
|
735
|
+
# unknown payload keys with a 400. When absent the agent falls back to
|
|
736
|
+
# self.artifact_paths (startup config).
|
|
737
|
+
if artifact_paths:
|
|
738
|
+
payload["artifact_paths"] = artifact_paths
|
|
739
|
+
# #352: only send new_issue_guidance when non-empty — older agents don't
|
|
740
|
+
# have this field and will reject the payload with a 400.
|
|
741
|
+
if new_issue_guidance:
|
|
742
|
+
payload["new_issue_guidance"] = new_issue_guidance
|
|
743
|
+
# Only send fresh_branch when True — older agents don't have this field
|
|
744
|
+
# and will reject the payload with a 400.
|
|
745
|
+
if fresh_branch:
|
|
746
|
+
payload["fresh_branch"] = True
|
|
747
|
+
# Only send target_branch when set — agents predating #target_branch
|
|
748
|
+
# (and the AssignmentSpec(**body) kwargs check) reject unknown fields.
|
|
749
|
+
if proposal.target_branch:
|
|
750
|
+
payload["target_branch"] = proposal.target_branch
|
|
751
|
+
# #315: only send resume_session_id when set — older agents without the
|
|
752
|
+
# field reject unknown payload keys with a 400.
|
|
753
|
+
if getattr(proposal, "resume_session_id", None):
|
|
754
|
+
payload["resume_session_id"] = proposal.resume_session_id
|
|
755
|
+
# #324/#1711: send the resolved provider name unless it's the vanilla
|
|
756
|
+
# (uncustomized) implicit "claude" default — see
|
|
757
|
+
# _wire_payload_needs_provider_field's docstring for why a customized
|
|
758
|
+
# "claude" definition must NOT be silently omitted (#1711 review of the
|
|
759
|
+
# #324 payload-omission gap). Older agents that predate #425's
|
|
760
|
+
# spec.provider field would reject an unknown payload key; omitting the
|
|
761
|
+
# field for the untouched default keeps every no-providers.-block
|
|
762
|
+
# deployment's wire payload byte-identical to pre-#324 (no-config
|
|
763
|
+
# parity requirement).
|
|
764
|
+
if effective_provider_name and _wire_payload_needs_provider_field(
|
|
765
|
+
effective_provider_name, config,
|
|
766
|
+
):
|
|
767
|
+
payload["provider"] = effective_provider_name
|
|
768
|
+
|
|
769
|
+
resp = httpx.post(url, json=payload, timeout=15)
|
|
770
|
+
if (
|
|
771
|
+
resp.status_code == 400
|
|
772
|
+
and "provider" in payload
|
|
773
|
+
and "provider_def" not in payload
|
|
774
|
+
and config.providers.definitions.get(effective_provider_name) is not None
|
|
775
|
+
):
|
|
776
|
+
# #1796 fix iteration 1 (review finding, blocking): the first
|
|
777
|
+
# attempt above deliberately never carries "provider_def" — the
|
|
778
|
+
# original #1796 patch attached it unconditionally to every
|
|
779
|
+
# named-provider dispatch whenever the coordinator's own config had
|
|
780
|
+
# a matching definition, which is a strictly wider condition than
|
|
781
|
+
# "the agent actually needs it". "provider_def" is a field ONLY an
|
|
782
|
+
# agent already updated PAST this release understands —
|
|
783
|
+
# AssignmentSpec(**body) 400s on any unrecognized kwarg
|
|
784
|
+
# (coord/agent_app.py) — so an agent that already supports
|
|
785
|
+
# "provider" (#324) but hasn't yet received #1796's release would
|
|
786
|
+
# get a brand-new hard 400 on every named-provider dispatch,
|
|
787
|
+
# including ones that were already working correctly against its
|
|
788
|
+
# own local providers.definitions (coordinator.yml). That's a
|
|
789
|
+
# regression of an already-working path, not just a failure to fix
|
|
790
|
+
# the config-free case #1796 targets — see docs/EPHEMERAL_WORKERS.md
|
|
791
|
+
# and coord.agent.AgentServer._resolve_provider's docstring for what
|
|
792
|
+
# "provider_def" is actually for.
|
|
793
|
+
#
|
|
794
|
+
# So "provider_def" is attached ONLY as a one-shot retry, fired
|
|
795
|
+
# only once the agent has ALREADY refused the bare "provider"
|
|
796
|
+
# payload with a 400. That 400 can only come from one of two agent
|
|
797
|
+
# generations: (a) one old enough to predate #1796's refusal logic
|
|
798
|
+
# never 400s here at all — it either resolves the name from its own
|
|
799
|
+
# local registry (unchanged, no regression) or silently falls back
|
|
800
|
+
# to the legacy claude path (the pre-#1796 bug, unaffected either
|
|
801
|
+
# way by this change and unaffected by whether provider_def would
|
|
802
|
+
# have helped); or (b) one new enough to run #1796's
|
|
803
|
+
# `_resolve_provider` (coord/agent.py) — which means it is ALSO new
|
|
804
|
+
# enough to accept "provider_def" — could not resolve the name from
|
|
805
|
+
# its own local registry (a genuinely config-free agent, or a local
|
|
806
|
+
# registry that's missing/stale relative to the coordinator's own)
|
|
807
|
+
# and explicitly refused rather than guess. Retrying is safe to do
|
|
808
|
+
# unconditionally on any 400 here because every ValueError
|
|
809
|
+
# `AgentServer.assign` can raise is checked before it ever creates
|
|
810
|
+
# an AgentAssignment or touches git/worktree state (coord/agent.py)
|
|
811
|
+
# — so a retry can never double-spawn a worker or leak a worktree.
|
|
812
|
+
# An unrelated 400 (e.g. a bad repo_path) just fails identically on
|
|
813
|
+
# the retry too, and that message is what the caller ultimately
|
|
814
|
+
# sees via _reraise_with_body below.
|
|
815
|
+
from coord.providers import provider_def_to_wire # noqa: PLC0415
|
|
816
|
+
|
|
817
|
+
definition = config.providers.definitions[effective_provider_name]
|
|
818
|
+
retry_payload = dict(payload, provider_def=provider_def_to_wire(definition))
|
|
819
|
+
resp = httpx.post(url, json=retry_payload, timeout=15)
|
|
820
|
+
|
|
821
|
+
try:
|
|
822
|
+
resp.raise_for_status()
|
|
823
|
+
except httpx.HTTPStatusError as e:
|
|
824
|
+
raise _reraise_with_body(e, machine.name) from e
|
|
825
|
+
result = resp.json()
|
|
826
|
+
# #324: attach the resolved provider name to the response dict so callers
|
|
827
|
+
# that record the dispatched assignment (cli.py, dashboard/server.py) can
|
|
828
|
+
# persist it without re-resolving the config precedence chain.
|
|
829
|
+
result["_provider_name"] = effective_provider_name
|
|
830
|
+
return result
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def dispatch_with_retry(
|
|
834
|
+
proposal: Proposal,
|
|
835
|
+
config: Config,
|
|
836
|
+
*,
|
|
837
|
+
max_retries: int = 3,
|
|
838
|
+
backoff_base: float = 60.0,
|
|
839
|
+
pull_repos: Iterable[str] = (),
|
|
840
|
+
fresh_branch: bool = False,
|
|
841
|
+
on_retry: callable | None = None,
|
|
842
|
+
) -> dict:
|
|
843
|
+
"""Dispatch with exponential backoff on transient failures."""
|
|
844
|
+
from coord.network import classify_error, is_retryable
|
|
845
|
+
|
|
846
|
+
last_exc: Exception | None = None
|
|
847
|
+
for attempt in range(max_retries + 1):
|
|
848
|
+
try:
|
|
849
|
+
return dispatch(proposal, config, pull_repos=pull_repos, fresh_branch=fresh_branch)
|
|
850
|
+
except httpx.HTTPError as exc:
|
|
851
|
+
state, reason = classify_error(exc)
|
|
852
|
+
if not is_retryable(state) or attempt == max_retries:
|
|
853
|
+
raise
|
|
854
|
+
wait = backoff_base * (2 ** attempt)
|
|
855
|
+
if on_retry:
|
|
856
|
+
on_retry(attempt + 1, max_retries, state, reason, wait)
|
|
857
|
+
time.sleep(wait)
|
|
858
|
+
last_exc = exc
|
|
859
|
+
except ValueError:
|
|
860
|
+
raise
|
|
861
|
+
raise last_exc # unreachable, but satisfies type checker
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def compute_do_not_touch(
|
|
865
|
+
proposal: Proposal,
|
|
866
|
+
peers: Iterable[Proposal],
|
|
867
|
+
in_flight: Iterable[dict] = (),
|
|
868
|
+
) -> list[tuple[str, str]]:
|
|
869
|
+
"""Compute (file, reason) pairs for other work touching `proposal.repo_name`.
|
|
870
|
+
|
|
871
|
+
`peers` are other proposals being dispatched in the same batch.
|
|
872
|
+
`in_flight` are records loaded from ~/.coord/dispatched.json (each with
|
|
873
|
+
keys: machine_name, repo_name, files_likely).
|
|
874
|
+
"""
|
|
875
|
+
pairs: list[tuple[str, str]] = []
|
|
876
|
+
seen: set[tuple[str, str]] = set()
|
|
877
|
+
|
|
878
|
+
def _add(machine_name: str, files: Iterable[str]) -> None:
|
|
879
|
+
for f in files:
|
|
880
|
+
key = (machine_name, f)
|
|
881
|
+
if key in seen:
|
|
882
|
+
continue
|
|
883
|
+
seen.add(key)
|
|
884
|
+
pairs.append((f, f"{machine_name} is working there"))
|
|
885
|
+
|
|
886
|
+
for peer in peers:
|
|
887
|
+
if peer is proposal:
|
|
888
|
+
continue
|
|
889
|
+
if peer.repo_name != proposal.repo_name:
|
|
890
|
+
continue
|
|
891
|
+
_add(peer.machine_name, peer.files_likely)
|
|
892
|
+
|
|
893
|
+
for record in in_flight:
|
|
894
|
+
if record.get("repo_name") != proposal.repo_name:
|
|
895
|
+
continue
|
|
896
|
+
if record.get("machine_name") == proposal.machine_name:
|
|
897
|
+
continue
|
|
898
|
+
_add(record.get("machine_name", "?"), record.get("files_likely", []))
|
|
899
|
+
|
|
900
|
+
return pairs
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def post_briefing(
|
|
904
|
+
proposal: Proposal,
|
|
905
|
+
config: Config,
|
|
906
|
+
*,
|
|
907
|
+
assignment_id: str = "pending",
|
|
908
|
+
do_not_touch: Iterable[tuple[str, str]] = (),
|
|
909
|
+
) -> None:
|
|
910
|
+
"""Post the assignment briefing as a GitHub issue comment."""
|
|
911
|
+
repo = config.repo(proposal.repo_name)
|
|
912
|
+
if repo is None:
|
|
913
|
+
raise ValueError(f"Unknown repo: {proposal.repo_name!r}")
|
|
914
|
+
|
|
915
|
+
body = format_briefing(
|
|
916
|
+
assignment_id=assignment_id,
|
|
917
|
+
machine_name=proposal.machine_name,
|
|
918
|
+
repo_name=proposal.repo_name,
|
|
919
|
+
issue_number=proposal.issue_number,
|
|
920
|
+
briefing=proposal.briefing,
|
|
921
|
+
files_likely=proposal.files_likely,
|
|
922
|
+
do_not_touch=do_not_touch,
|
|
923
|
+
)
|
|
924
|
+
github_ops.post_issue_comment(repo.github, proposal.issue_number, body)
|
|
925
|
+
|
|
926
|
+
# Auto-tag the issue with pipeline_tracked_labels so the TUI's Pipeline
|
|
927
|
+
# panel picks it up on the next `gh search issues` poll. Without this,
|
|
928
|
+
# manually filed issues stay invisible until the user remembers to
|
|
929
|
+
# label them (we hit this filing quadraui#263). Best-effort — never
|
|
930
|
+
# fail the briefing post on a labeling error.
|
|
931
|
+
tracked = config.pipeline.tracked_labels()
|
|
932
|
+
if tracked:
|
|
933
|
+
try:
|
|
934
|
+
github_ops.add_issue_labels(repo.github, proposal.issue_number, tracked)
|
|
935
|
+
except (RuntimeError, OSError):
|
|
936
|
+
pass
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def post_completion(
|
|
940
|
+
*,
|
|
941
|
+
assignment_id: str,
|
|
942
|
+
machine_name: str,
|
|
943
|
+
repo_github: str,
|
|
944
|
+
repo_name: str,
|
|
945
|
+
issue_number: int,
|
|
946
|
+
exit_code: int,
|
|
947
|
+
duration_seconds: float | None = None,
|
|
948
|
+
log_path: str | None = None,
|
|
949
|
+
summary: str = "",
|
|
950
|
+
) -> None:
|
|
951
|
+
body = format_completion(
|
|
952
|
+
assignment_id=assignment_id,
|
|
953
|
+
machine_name=machine_name,
|
|
954
|
+
repo_name=repo_name,
|
|
955
|
+
issue_number=issue_number,
|
|
956
|
+
exit_code=exit_code,
|
|
957
|
+
duration_seconds=duration_seconds,
|
|
958
|
+
log_path=log_path,
|
|
959
|
+
summary=summary,
|
|
960
|
+
)
|
|
961
|
+
github_ops.post_issue_comment(repo_github, issue_number, body)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def post_failure(
|
|
965
|
+
*,
|
|
966
|
+
assignment_id: str,
|
|
967
|
+
machine_name: str,
|
|
968
|
+
repo_github: str,
|
|
969
|
+
repo_name: str,
|
|
970
|
+
issue_number: int,
|
|
971
|
+
exit_code: int | None,
|
|
972
|
+
duration_seconds: float | None = None,
|
|
973
|
+
log_path: str | None = None,
|
|
974
|
+
error: str = "",
|
|
975
|
+
) -> None:
|
|
976
|
+
body = format_failure(
|
|
977
|
+
assignment_id=assignment_id,
|
|
978
|
+
machine_name=machine_name,
|
|
979
|
+
repo_name=repo_name,
|
|
980
|
+
issue_number=issue_number,
|
|
981
|
+
exit_code=exit_code,
|
|
982
|
+
duration_seconds=duration_seconds,
|
|
983
|
+
log_path=log_path,
|
|
984
|
+
error=error,
|
|
985
|
+
)
|
|
986
|
+
github_ops.post_issue_comment(repo_github, issue_number, body)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
def post_advisory(
|
|
990
|
+
*,
|
|
991
|
+
assignment_id: str,
|
|
992
|
+
machine_name: str,
|
|
993
|
+
repo_github: str,
|
|
994
|
+
repo_name: str,
|
|
995
|
+
issue_number: int,
|
|
996
|
+
duration_seconds: float | None = None,
|
|
997
|
+
log_path: str | None = None,
|
|
998
|
+
reason: str = "",
|
|
999
|
+
) -> None:
|
|
1000
|
+
body = format_advisory(
|
|
1001
|
+
assignment_id=assignment_id,
|
|
1002
|
+
machine_name=machine_name,
|
|
1003
|
+
repo_name=repo_name,
|
|
1004
|
+
issue_number=issue_number,
|
|
1005
|
+
duration_seconds=duration_seconds,
|
|
1006
|
+
log_path=log_path,
|
|
1007
|
+
reason=reason,
|
|
1008
|
+
)
|
|
1009
|
+
github_ops.post_issue_comment(repo_github, issue_number, body)
|