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/drive_state.py
ADDED
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
"""Read-only per-issue pipeline state oracle for ``coord drive`` (#1392).
|
|
2
|
+
|
|
3
|
+
Answers the one question the coord CLI has no single command for: *"what stage
|
|
4
|
+
is issue N in, and what is blocking it?"*
|
|
5
|
+
|
|
6
|
+
This is the in-process port of ``scripts/coord_issue_state.py``, which was a
|
|
7
|
+
standalone script whose output the bash driver ``eval``-ed as ``KEY='value'``
|
|
8
|
+
lines. The shell-quoting handshake is gone (that ``eval`` was one of the
|
|
9
|
+
bugs — a diagnostic on stdout would have executed as shell); the driver now
|
|
10
|
+
imports :func:`project` and branches on a typed :class:`IssueState`.
|
|
11
|
+
|
|
12
|
+
Why this is a projection over ``GET /board`` rather than an existing command:
|
|
13
|
+
|
|
14
|
+
- ``coord wait`` reads the **local** dispatched ledger (``load_dispatched()``),
|
|
15
|
+
which is empty on a thin client — so it cannot be used from an operator box
|
|
16
|
+
that reads the board from the daemon. This polls the daemon instead.
|
|
17
|
+
- ``coord diagnose --json`` is per-*stage* and **mutates** (it performs
|
|
18
|
+
best-effort recovery). A driver loop needs a pure read.
|
|
19
|
+
- ``GET /board`` is ~4.4 MB, but it supports ETags. We cache the payload and
|
|
20
|
+
send ``If-None-Match``, so a steady-state poll is a 304 in ~30 ms instead of
|
|
21
|
+
a multi-megabyte transfer. This keeps a 60-second poll loop from hammering
|
|
22
|
+
the daemon (the failure mode behind the #1244 / board-timeout incidents).
|
|
23
|
+
|
|
24
|
+
Everything here is a pure function over a board payload except
|
|
25
|
+
:func:`fetch_board`, which is the one I/O boundary.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import hashlib
|
|
31
|
+
import json
|
|
32
|
+
import os
|
|
33
|
+
from dataclasses import asdict, dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from coord.merge_queue import is_ci_infra_reason
|
|
38
|
+
from coord.models import WORK_LIKE_TYPES, test_mode_from_labels
|
|
39
|
+
|
|
40
|
+
# Assignment types that can carry the Test/Review gates for an issue. Sourced
|
|
41
|
+
# from coord.models so this never drifts from the source of truth (#1141 was
|
|
42
|
+
# exactly a hardcoded copy of this set going stale).
|
|
43
|
+
WORK_LIKE: frozenset[str] = WORK_LIKE_TYPES
|
|
44
|
+
|
|
45
|
+
TERMINAL_STATUSES = frozenset({"done", "failed", "cancelled", "merged", "advisory"})
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class DriveStateError(Exception):
|
|
49
|
+
"""The board or config could not be read well enough to drive anything."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── the projection ───────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class IssueState:
|
|
57
|
+
"""Everything ``coord drive``'s state machine branches on, and nothing else.
|
|
58
|
+
|
|
59
|
+
Field names mirror the ``KEY='value'`` variables the bash driver used, so
|
|
60
|
+
the ``--dry-run`` JSON stays recognisable to anyone who ran the script
|
|
61
|
+
(see :meth:`as_flat_dict`).
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
repo: str
|
|
65
|
+
issue: int
|
|
66
|
+
repo_github: str = ""
|
|
67
|
+
repo_default_branch: str = "main"
|
|
68
|
+
repo_test_command: str = ""
|
|
69
|
+
max_review_iterations: int = 5
|
|
70
|
+
auto_loop: bool = True
|
|
71
|
+
|
|
72
|
+
plan_aid: str = ""
|
|
73
|
+
plan_status: str = ""
|
|
74
|
+
|
|
75
|
+
work_aid: str = ""
|
|
76
|
+
work_type: str = ""
|
|
77
|
+
work_status: str = ""
|
|
78
|
+
work_branch: str = ""
|
|
79
|
+
work_machine: str = ""
|
|
80
|
+
work_provider: str = ""
|
|
81
|
+
work_test_state: str = ""
|
|
82
|
+
work_test_reason: str = ""
|
|
83
|
+
work_review_state: str = ""
|
|
84
|
+
work_review_iter: int = 0
|
|
85
|
+
work_exit_code: int | None = None
|
|
86
|
+
work_failure_reason: str = ""
|
|
87
|
+
|
|
88
|
+
review_aid: str = ""
|
|
89
|
+
review_status: str = ""
|
|
90
|
+
review_verdict: str = ""
|
|
91
|
+
# #1584: mirrors `work_failure_reason` — surfaces a review worker's
|
|
92
|
+
# persisted `failure_reason` (usage-limit-kill or terminal-API-error
|
|
93
|
+
# diagnostic; see `coord.reconcile._record_usage_limit_reason`) so
|
|
94
|
+
# `_decide_review` can report *why* a failed review died instead of a
|
|
95
|
+
# bare "failed".
|
|
96
|
+
review_failure_reason: str = ""
|
|
97
|
+
|
|
98
|
+
smoke_aid: str = ""
|
|
99
|
+
smoke_status: str = ""
|
|
100
|
+
# #1605: mirrors `work_failure_reason`/`review_failure_reason` — the Test
|
|
101
|
+
# stage's own child (`type="smoke"`) assignment's persisted
|
|
102
|
+
# `failure_reason`, so `_decide_test` can recognise an environmental
|
|
103
|
+
# death (#1590) or report *why* a stranded Test stage died instead of
|
|
104
|
+
# polling `test_state == "running"` forever against a child that has
|
|
105
|
+
# already finished.
|
|
106
|
+
smoke_failure_reason: str = ""
|
|
107
|
+
|
|
108
|
+
active_count: int = 0
|
|
109
|
+
active_types: tuple[str, ...] = ()
|
|
110
|
+
|
|
111
|
+
merge_status: str = ""
|
|
112
|
+
merge_reason: str = ""
|
|
113
|
+
merge_pr_url: str = ""
|
|
114
|
+
merge_aid: str = ""
|
|
115
|
+
|
|
116
|
+
picked_machine: str = ""
|
|
117
|
+
# #1906: the provider `picked_machine` was actually filtered against
|
|
118
|
+
# (`""` when no candidate machine hosted `repo` at all — provider
|
|
119
|
+
# resolution never ran). `picked_machine_provider_reason` is
|
|
120
|
+
# `coord.providers.describe_provider_choice`'s provenance string, so
|
|
121
|
+
# `--dry-run` shows not just the winning provider but *why* (spec →
|
|
122
|
+
# `providers.labels` → repo → `providers.default`) — the same
|
|
123
|
+
# transparency `coord assign --dry-run` already gives a hand dispatch.
|
|
124
|
+
picked_machine_provider: str = ""
|
|
125
|
+
picked_machine_provider_reason: str = ""
|
|
126
|
+
# True when at least one unpaused machine hosts `repo` (so this is NOT
|
|
127
|
+
# the plain "no unpaused machine hosts {repo}" case) but NONE of them
|
|
128
|
+
# advertise `picked_machine_provider` — the distinct #1906 failure mode
|
|
129
|
+
# `preflight()` reports separately, per #1711's own refusal shape.
|
|
130
|
+
picked_machine_no_capable: bool = False
|
|
131
|
+
|
|
132
|
+
# ── #1453: oracle-loop JIT slice authoring ──────────────────────────
|
|
133
|
+
# `milestone_number` is the issue's own GitHub milestone (the `ms-NN`
|
|
134
|
+
# Gate-A contract this issue's slice would live under); resolved from
|
|
135
|
+
# the same `/board` `issues` list the TUI's `pipeline_issue_milestone`
|
|
136
|
+
# reads. `milestone_tracking_issue` is the epic that owns the `##
|
|
137
|
+
# Work order` block this issue is a member node of — resolved from
|
|
138
|
+
# `milestone_work_orders`, mirroring the TUI's
|
|
139
|
+
# `milestone_tracking_issue_for` (tui/src/app/pipeline.rs). Both are
|
|
140
|
+
# ``None`` for a plain issue with no milestone, or one not (yet) a
|
|
141
|
+
# member of any tracked work order — the "normal drive" case.
|
|
142
|
+
milestone_number: int | None = None
|
|
143
|
+
milestone_tracking_issue: int | None = None
|
|
144
|
+
|
|
145
|
+
# The JIT slice's own `type="test-author"` assignment (#1171: keyed on
|
|
146
|
+
# `for_issue_number == issue`, NOT `issue_number` — that field is the
|
|
147
|
+
# milestone's TRACKING issue, so this row is invisible to `work_aid`
|
|
148
|
+
# above by design). Empty until `coord acceptance author ... --issue
|
|
149
|
+
# <N>` has been dispatched for this issue.
|
|
150
|
+
acceptance_author_aid: str = ""
|
|
151
|
+
acceptance_author_status: str = ""
|
|
152
|
+
acceptance_author_branch: str = ""
|
|
153
|
+
acceptance_author_machine: str = ""
|
|
154
|
+
|
|
155
|
+
# ── #2079: the JIT slice's OWN landing state ────────────────────────
|
|
156
|
+
# The slice row is `WORK_LIKE` (coord.models.WORK_LIKE_TYPES includes
|
|
157
|
+
# "test-author"), so the daemon's passive tick dispatches its Test and
|
|
158
|
+
# Review stages and `enqueue_approved_work` puts it in the merge queue —
|
|
159
|
+
# all of that runs unconditionally. The ONE step that does not is the
|
|
160
|
+
# final drain (`serve_app._auto_drain_tick`, gated on
|
|
161
|
+
# `merge.auto_drain`, which is `false` in the standing fleet config), so
|
|
162
|
+
# a green, READY slice sits there forever and `coord drive` idles to its
|
|
163
|
+
# deadline waiting for a merge nobody will perform. These fields are
|
|
164
|
+
# what let `coord.drive._decide_acceptance_landing` drive that last step
|
|
165
|
+
# itself (`coord merge --only <slice aid>`), exactly as it already does
|
|
166
|
+
# for the issue's own work row.
|
|
167
|
+
#
|
|
168
|
+
# All four reads are over data already on `/board` — no extra I/O. The
|
|
169
|
+
# merge entry is matched on the slice's ASSIGNMENT ID, not on
|
|
170
|
+
# (repo, issue): the slice row's `issue_number` is the milestone's
|
|
171
|
+
# TRACKING issue, which may carry other queue entries of its own (the
|
|
172
|
+
# Gate-A mock, a sibling issue's slice).
|
|
173
|
+
acceptance_author_test_state: str = ""
|
|
174
|
+
acceptance_review_aid: str = ""
|
|
175
|
+
acceptance_review_verdict: str = ""
|
|
176
|
+
acceptance_merge_status: str = ""
|
|
177
|
+
acceptance_merge_reason: str = ""
|
|
178
|
+
acceptance_merge_aid: str = ""
|
|
179
|
+
acceptance_merge_pr_url: str = ""
|
|
180
|
+
|
|
181
|
+
# #2024/#685: the issue's Test-stage POLICY, read from the same
|
|
182
|
+
# `test-mode:*` labels `coord.smoke.dispatch_pending_smoke` gates on
|
|
183
|
+
# (`coord.models.test_mode_from_labels` — one shared reading, no drift).
|
|
184
|
+
# "" → no label: the headless Test stage auto-dispatches per
|
|
185
|
+
# `smoke_tests.auto_queue`.
|
|
186
|
+
# "auto" → same, explicitly opted in.
|
|
187
|
+
# "smoke" → the headless path deliberately SKIPS this issue; the Test
|
|
188
|
+
# stage is human-attended (the TUI's interactive smoke agent).
|
|
189
|
+
# The driver needs this because a completed row with no test verdict means
|
|
190
|
+
# two opposite things depending on it: "the daemon will dispatch on the
|
|
191
|
+
# next tick" (poll) vs "nothing automatic will EVER dispatch" (dead end —
|
|
192
|
+
# `coord.dead_end` shape 3).
|
|
193
|
+
issue_test_mode: str = ""
|
|
194
|
+
|
|
195
|
+
# ── derived ──────────────────────────────────────────────────────────
|
|
196
|
+
@property
|
|
197
|
+
def fingerprint(self) -> str:
|
|
198
|
+
"""Compact fingerprint of every field the state machine branches on.
|
|
199
|
+
|
|
200
|
+
Used to tell a *stall* (no transition) apart from "still working" —
|
|
201
|
+
the bash ``state_fingerprint`` function, field-for-field.
|
|
202
|
+
|
|
203
|
+
#1526: ``merge_reason`` is included alongside ``merge_status`` — once
|
|
204
|
+
``_merge_gate_divergence`` started branching on it too, a
|
|
205
|
+
``coord merge`` attempt that leaves ``merge_status`` unchanged (e.g.
|
|
206
|
+
still ``READY``) but writes a NEW refusal reason onto the board is a
|
|
207
|
+
real transition the driver just reacted to, not a stall. Omitting it
|
|
208
|
+
would both mute the ``state:`` log line for that change and let the
|
|
209
|
+
stall timer keep counting through it.
|
|
210
|
+
|
|
211
|
+
#2079: the oracle-mode JIT slice's own landing fields are here for
|
|
212
|
+
the same reason. While `coord drive` is waiting on the slice, EVERY
|
|
213
|
+
work-row field above is empty and frozen (the work row does not
|
|
214
|
+
exist yet, by construction), so the slice progressing from
|
|
215
|
+
`test_state=""` → `passed` → an approved review → a READY queue
|
|
216
|
+
entry produced no fingerprint change at all: the `state:` line never
|
|
217
|
+
printed, and the stall detector nudged `coord notify` every
|
|
218
|
+
`--stall` minutes as if nothing were happening. Real transitions,
|
|
219
|
+
rendered as a stall.
|
|
220
|
+
"""
|
|
221
|
+
return "|".join(
|
|
222
|
+
str(v)
|
|
223
|
+
for v in (
|
|
224
|
+
self.work_aid,
|
|
225
|
+
self.work_status,
|
|
226
|
+
self.work_test_state,
|
|
227
|
+
self.work_review_state,
|
|
228
|
+
self.work_review_iter,
|
|
229
|
+
self.review_status,
|
|
230
|
+
self.review_verdict,
|
|
231
|
+
self.merge_status,
|
|
232
|
+
self.merge_reason,
|
|
233
|
+
self.acceptance_author_aid,
|
|
234
|
+
self.acceptance_author_status,
|
|
235
|
+
self.acceptance_author_test_state,
|
|
236
|
+
self.acceptance_review_verdict,
|
|
237
|
+
self.acceptance_merge_status,
|
|
238
|
+
self.acceptance_merge_reason,
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def as_flat_dict(self) -> dict[str, Any]:
|
|
243
|
+
"""Upper-cased flat dict, matching the old script's variable names."""
|
|
244
|
+
out: dict[str, Any] = {}
|
|
245
|
+
for key, value in asdict(self).items():
|
|
246
|
+
if isinstance(value, tuple):
|
|
247
|
+
value = ",".join(value)
|
|
248
|
+
elif isinstance(value, bool):
|
|
249
|
+
value = "1" if value else "0"
|
|
250
|
+
elif value is None:
|
|
251
|
+
value = ""
|
|
252
|
+
out[key.upper()] = value
|
|
253
|
+
return out
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _latest(rows: list[dict]) -> dict | None:
|
|
257
|
+
"""The most recently dispatched row, or ``None``."""
|
|
258
|
+
if not rows:
|
|
259
|
+
return None
|
|
260
|
+
return max(rows, key=lambda r: r.get("dispatched_at") or 0.0)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def project(payload: dict, repo: str, issue: int, config: Any) -> IssueState:
|
|
264
|
+
"""Reduce a whole ``/board`` payload to the facts the driver branches on.
|
|
265
|
+
|
|
266
|
+
Raises :class:`DriveStateError` when *repo* is not in coordinator.yml —
|
|
267
|
+
a configuration error the driver must report, not poll through.
|
|
268
|
+
"""
|
|
269
|
+
repo_cfg = config.repo(repo)
|
|
270
|
+
if repo_cfg is None:
|
|
271
|
+
raise DriveStateError(f"repo {repo!r} is not in coordinator.yml")
|
|
272
|
+
|
|
273
|
+
mine = [
|
|
274
|
+
a
|
|
275
|
+
for a in payload.get("assignments") or []
|
|
276
|
+
if a.get("repo_name") == repo and a.get("issue_number") == issue
|
|
277
|
+
]
|
|
278
|
+
|
|
279
|
+
plan = _latest([a for a in mine if a.get("type") == "plan"])
|
|
280
|
+
work = _latest([a for a in mine if a.get("type") in WORK_LIKE])
|
|
281
|
+
work_aid = (work or {}).get("assignment_id") or ""
|
|
282
|
+
|
|
283
|
+
# The review that reviewed *this* work row. Fix rounds produce a new work
|
|
284
|
+
# row and a new review, so keying on the work id (not just the issue) is
|
|
285
|
+
# what keeps a stale earlier verdict from being read as the current one.
|
|
286
|
+
review = _latest(
|
|
287
|
+
[
|
|
288
|
+
a
|
|
289
|
+
for a in mine
|
|
290
|
+
if a.get("type") == "review"
|
|
291
|
+
and a.get("review_of_assignment_id") == work_aid
|
|
292
|
+
]
|
|
293
|
+
)
|
|
294
|
+
smoke = _latest(
|
|
295
|
+
[
|
|
296
|
+
a
|
|
297
|
+
for a in mine
|
|
298
|
+
if a.get("type") == "smoke"
|
|
299
|
+
and a.get("review_of_assignment_id") == work_aid
|
|
300
|
+
]
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
active = [a for a in mine if (a.get("status") or "") not in TERMINAL_STATUSES]
|
|
304
|
+
|
|
305
|
+
merge_entry = _merge_entry(payload, repo, issue)
|
|
306
|
+
|
|
307
|
+
def g(row: dict | None, key: str, default: Any = "") -> Any:
|
|
308
|
+
value = (row or {}).get(key)
|
|
309
|
+
return default if value is None else value
|
|
310
|
+
|
|
311
|
+
exit_code = (work or {}).get("exit_code")
|
|
312
|
+
|
|
313
|
+
# #1453: oracle-loop JIT slice resolution — both reads are over data
|
|
314
|
+
# already published on /board, no extra I/O (see IssueState's docstring
|
|
315
|
+
# for the two source lists and their TUI-side counterparts).
|
|
316
|
+
milestone_number = None
|
|
317
|
+
# #1906: the same cached `/board` `issues` row already carries this
|
|
318
|
+
# issue's GitHub labels (`coord.dao`'s `issues: {"labels"}` JSON column)
|
|
319
|
+
# — reused below by `pick_machine` to resolve the effective provider
|
|
320
|
+
# (`coord.providers.resolve_provider_name`'s `providers.labels` link,
|
|
321
|
+
# #1889) BEFORE picking a machine, so selection is capability-aware
|
|
322
|
+
# instead of discovering a mismatch only when #1711's dispatch-time
|
|
323
|
+
# guard refuses it. No extra I/O: `issues` is already part of *payload*.
|
|
324
|
+
issue_labels: list[str] = []
|
|
325
|
+
for oi in payload.get("issues") or []:
|
|
326
|
+
if oi.get("repo_name") == repo and oi.get("number") == issue:
|
|
327
|
+
milestone_number = oi.get("milestone_number")
|
|
328
|
+
issue_labels = list(oi.get("labels") or [])
|
|
329
|
+
break
|
|
330
|
+
|
|
331
|
+
milestone_tracking_issue = None
|
|
332
|
+
for mwo in payload.get("milestone_work_orders") or []:
|
|
333
|
+
if mwo.get("repo_name") != repo:
|
|
334
|
+
continue
|
|
335
|
+
if any(n.get("issue_number") == issue for n in mwo.get("nodes") or []):
|
|
336
|
+
milestone_tracking_issue = mwo.get("tracking_issue")
|
|
337
|
+
break
|
|
338
|
+
|
|
339
|
+
# The JIT slice's own assignment row: keyed on `for_issue_number`, NOT
|
|
340
|
+
# `issue_number` (that field carries the milestone's TRACKING issue for
|
|
341
|
+
# this dispatch shape — #1171/#1138) — so it is deliberately excluded
|
|
342
|
+
# from `mine`/`work_aid` above.
|
|
343
|
+
acceptance_author = _latest(
|
|
344
|
+
[
|
|
345
|
+
a
|
|
346
|
+
for a in payload.get("assignments") or []
|
|
347
|
+
if a.get("repo_name") == repo
|
|
348
|
+
and a.get("type") == "test-author"
|
|
349
|
+
and a.get("for_issue_number") == issue
|
|
350
|
+
]
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
# #2079: the slice's own Test/Review/Merge landing state. Its review
|
|
354
|
+
# child is keyed the same way the work row's is (`review_of_assignment_id`
|
|
355
|
+
# → the reviewed row's id), and its merge-queue entry is matched on that
|
|
356
|
+
# id too — see the `acceptance_*` field block in `IssueState` for why the
|
|
357
|
+
# (repo, issue) match `_merge_entry` uses for the work row would be wrong
|
|
358
|
+
# here.
|
|
359
|
+
acceptance_author_aid = g(acceptance_author, "assignment_id")
|
|
360
|
+
acceptance_review = (
|
|
361
|
+
_latest(
|
|
362
|
+
[
|
|
363
|
+
a
|
|
364
|
+
for a in payload.get("assignments") or []
|
|
365
|
+
if a.get("repo_name") == repo
|
|
366
|
+
and a.get("type") == "review"
|
|
367
|
+
and a.get("review_of_assignment_id") == acceptance_author_aid
|
|
368
|
+
]
|
|
369
|
+
)
|
|
370
|
+
if acceptance_author_aid
|
|
371
|
+
else None
|
|
372
|
+
)
|
|
373
|
+
acceptance_merge = (
|
|
374
|
+
_merge_entry(payload, repo, issue, assignment_id=acceptance_author_aid)
|
|
375
|
+
if acceptance_author_aid
|
|
376
|
+
else None
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
_machine_pick = pick_machine_choice(
|
|
380
|
+
payload, repo, config, issue_labels=issue_labels,
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
return IssueState(
|
|
384
|
+
repo=repo,
|
|
385
|
+
issue=issue,
|
|
386
|
+
repo_github=repo_cfg.github or "",
|
|
387
|
+
repo_default_branch=repo_cfg.default_branch or "main",
|
|
388
|
+
repo_test_command=repo_cfg.test_command or "",
|
|
389
|
+
max_review_iterations=config.pipeline.max_review_iterations,
|
|
390
|
+
auto_loop=bool(config.pipeline.auto_loop),
|
|
391
|
+
plan_aid=g(plan, "assignment_id"),
|
|
392
|
+
plan_status=g(plan, "status"),
|
|
393
|
+
work_aid=work_aid,
|
|
394
|
+
work_type=g(work, "type"),
|
|
395
|
+
work_status=g(work, "status"),
|
|
396
|
+
work_branch=g(work, "branch"),
|
|
397
|
+
work_machine=g(work, "machine_name"),
|
|
398
|
+
work_provider=g(work, "provider_name"),
|
|
399
|
+
work_test_state=g(work, "test_state"),
|
|
400
|
+
work_test_reason=g(work, "test_reason"),
|
|
401
|
+
work_review_state=g(work, "review_state"),
|
|
402
|
+
work_review_iter=int(g(work, "review_iteration", 0) or 0),
|
|
403
|
+
work_exit_code=None if exit_code is None else int(exit_code),
|
|
404
|
+
work_failure_reason=g(work, "failure_reason"),
|
|
405
|
+
review_aid=g(review, "assignment_id"),
|
|
406
|
+
review_status=g(review, "status"),
|
|
407
|
+
review_verdict=g(review, "review_verdict"),
|
|
408
|
+
review_failure_reason=g(review, "failure_reason"),
|
|
409
|
+
smoke_aid=g(smoke, "assignment_id"),
|
|
410
|
+
smoke_status=g(smoke, "status"),
|
|
411
|
+
smoke_failure_reason=g(smoke, "failure_reason"),
|
|
412
|
+
active_count=len(active),
|
|
413
|
+
active_types=tuple(sorted({(a.get("type") or "?") for a in active})),
|
|
414
|
+
merge_status=(merge_entry or {}).get("status") or "",
|
|
415
|
+
merge_reason=(merge_entry or {}).get("reason") or "",
|
|
416
|
+
merge_pr_url=(merge_entry or {}).get("pr_url") or "",
|
|
417
|
+
merge_aid=(merge_entry or {}).get("assignment_id") or "",
|
|
418
|
+
picked_machine=_machine_pick.name,
|
|
419
|
+
picked_machine_provider=_machine_pick.provider_name,
|
|
420
|
+
picked_machine_provider_reason=_machine_pick.provider_reason,
|
|
421
|
+
picked_machine_no_capable=_machine_pick.no_capable_machine,
|
|
422
|
+
milestone_number=milestone_number,
|
|
423
|
+
milestone_tracking_issue=milestone_tracking_issue,
|
|
424
|
+
# #2024: the per-issue Test-stage policy, off the labels already read
|
|
425
|
+
# above (no extra I/O). `test_mode_from_labels` is the same function
|
|
426
|
+
# `coord.state._get_issue_test_mode_local` uses, so the driver and the
|
|
427
|
+
# dispatcher cannot disagree about what the label means.
|
|
428
|
+
issue_test_mode=test_mode_from_labels(issue_labels) or "",
|
|
429
|
+
acceptance_author_aid=acceptance_author_aid,
|
|
430
|
+
acceptance_author_status=g(acceptance_author, "status"),
|
|
431
|
+
acceptance_author_branch=g(acceptance_author, "branch"),
|
|
432
|
+
acceptance_author_machine=g(acceptance_author, "machine_name"),
|
|
433
|
+
acceptance_author_test_state=g(acceptance_author, "test_state"),
|
|
434
|
+
acceptance_review_aid=g(acceptance_review, "assignment_id"),
|
|
435
|
+
acceptance_review_verdict=g(acceptance_review, "review_verdict"),
|
|
436
|
+
acceptance_merge_status=(acceptance_merge or {}).get("status") or "",
|
|
437
|
+
acceptance_merge_reason=(acceptance_merge or {}).get("reason") or "",
|
|
438
|
+
acceptance_merge_aid=(acceptance_merge or {}).get("assignment_id") or "",
|
|
439
|
+
acceptance_merge_pr_url=(acceptance_merge or {}).get("pr_url") or "",
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _merge_entry(
|
|
444
|
+
payload: dict, repo: str, issue: int, *, assignment_id: str = ""
|
|
445
|
+
) -> dict | None:
|
|
446
|
+
"""Merge state for (*repo*, *issue*): the plan entry, cross-checked
|
|
447
|
+
against the raw queue row.
|
|
448
|
+
|
|
449
|
+
Matched on (repo, issue) rather than assignment id on purpose: the
|
|
450
|
+
enqueued entry may be keyed to an earlier work row in a fix chain.
|
|
451
|
+
|
|
452
|
+
#2079: *assignment_id* overrides that, matching the queue entry's own
|
|
453
|
+
``assignment_id`` instead. It is the right key — and (repo, issue) the
|
|
454
|
+
wrong one — for exactly one caller, the oracle-mode JIT acceptance
|
|
455
|
+
slice: that row's ``issue_number`` is the milestone's TRACKING issue
|
|
456
|
+
(#1171/#1138), which routinely carries queue entries belonging to OTHER
|
|
457
|
+
rows (the Gate-A mock, a sibling member issue's slice), so matching on
|
|
458
|
+
the issue would hand the driver a stranger's merge status. The fix-chain
|
|
459
|
+
concern above does not apply, because the caller resolves the slice row
|
|
460
|
+
with ``_latest`` — it is already looking at the newest aid, which is the
|
|
461
|
+
one ``enqueue_approved_work`` re-keys the entry to.
|
|
462
|
+
|
|
463
|
+
#1505 review fix: ``merge_queue.plan()``'s ``_state_to_plan_status``
|
|
464
|
+
deliberately collapses CONFLICT, HUMAN_REQUIRED, and SKIPPED into a
|
|
465
|
+
single "NEEDS_ATTENTION" bucket for operator-facing display (see that
|
|
466
|
+
function's docstring). But ``_decide_merge``'s retry-vs-escalate branch
|
|
467
|
+
needs exactly the distinction that collapse erases: CONFLICT is still
|
|
468
|
+
auto-fixable (a ``coord merge --only`` retry dispatches
|
|
469
|
+
``classify_conflict``/``dispatch_conflict_fix``, #1474) while
|
|
470
|
+
HUMAN_REQUIRED and SKIPPED are terminal. ``merge_plan`` is populated on
|
|
471
|
+
nearly every ``/board`` build (``serve_app.board()`` calls
|
|
472
|
+
``merge_queue.plan()`` unconditionally, falling back to ``[]`` only on
|
|
473
|
+
an exception), so without this cross-check a fresh, still-retryable
|
|
474
|
+
conflict presents to ``_decide_merge`` as NEEDS_ATTENTION and escalates
|
|
475
|
+
on first sight instead of retrying — reintroducing the #1453/#1461
|
|
476
|
+
stall in a new shape (immediate give-up instead of infinite wait). When
|
|
477
|
+
the plan reports NEEDS_ATTENTION, this looks up the SAME entry's raw
|
|
478
|
+
state in ``merge_queue`` and reports that instead, recovering the
|
|
479
|
+
distinction.
|
|
480
|
+
|
|
481
|
+
Also recovers ``pr_url``: the ``PlannedMerge`` dataclass ``merge_plan``
|
|
482
|
+
entries are serialized from carries ``pr_number``, not a URL — this
|
|
483
|
+
falls back to the raw queue row's ``pr_url``, then reconstructs one from
|
|
484
|
+
``repo_github`` + ``pr_number`` when neither is present, so the
|
|
485
|
+
escalation record's proposed ``gh pr merge`` command still gets a PR
|
|
486
|
+
number on a normal daemon-backed board.
|
|
487
|
+
"""
|
|
488
|
+
|
|
489
|
+
def _matches(entry: dict) -> bool:
|
|
490
|
+
if entry.get("repo_name") != repo:
|
|
491
|
+
return False
|
|
492
|
+
if assignment_id:
|
|
493
|
+
return entry.get("assignment_id") == assignment_id
|
|
494
|
+
return entry.get("issue_number") == issue
|
|
495
|
+
|
|
496
|
+
plan_entry = None
|
|
497
|
+
for entry in payload.get("merge_plan") or []:
|
|
498
|
+
if _matches(entry):
|
|
499
|
+
plan_entry = entry
|
|
500
|
+
break
|
|
501
|
+
|
|
502
|
+
raw_entry = None
|
|
503
|
+
for entry in payload.get("merge_queue") or []:
|
|
504
|
+
if _matches(entry):
|
|
505
|
+
raw_entry = entry
|
|
506
|
+
break
|
|
507
|
+
|
|
508
|
+
if plan_entry is None:
|
|
509
|
+
if raw_entry is None:
|
|
510
|
+
return None
|
|
511
|
+
return {
|
|
512
|
+
"status": (raw_entry.get("state") or "").upper(),
|
|
513
|
+
"reason": raw_entry.get("error"),
|
|
514
|
+
"pr_url": raw_entry.get("pr_url"),
|
|
515
|
+
"assignment_id": raw_entry.get("assignment_id"),
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
status = (plan_entry.get("status") or "").upper()
|
|
519
|
+
if status == "NEEDS_ATTENTION" and raw_entry is not None:
|
|
520
|
+
# Recover the pre-collapse state (CONFLICT / HUMAN_REQUIRED /
|
|
521
|
+
# SKIPPED) so a retryable conflict doesn't masquerade as a terminal
|
|
522
|
+
# NEEDS_ATTENTION and escalate prematurely.
|
|
523
|
+
status = (raw_entry.get("state") or status).upper()
|
|
524
|
+
|
|
525
|
+
pr_url = plan_entry.get("pr_url") or (raw_entry or {}).get("pr_url")
|
|
526
|
+
if not pr_url and plan_entry.get("pr_number") and plan_entry.get("repo_github"):
|
|
527
|
+
pr_url = (
|
|
528
|
+
f"https://github.com/{plan_entry['repo_github']}"
|
|
529
|
+
f"/pull/{plan_entry['pr_number']}"
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
reason = plan_entry.get("reason") or (raw_entry or {}).get("error")
|
|
533
|
+
# #1892: `plan_entry["reason"]` is `_entry_gate_status`'s FRESH
|
|
534
|
+
# re-derivation at board-build time — and that function never computes
|
|
535
|
+
# the CI_INFRA_PREFIX classification, because doing so needs an extra
|
|
536
|
+
# `gh api .../jobs` call the board *read* path must never make (see
|
|
537
|
+
# `coord.gate_snapshot`'s Invariant 1). Only a LIVE `coord merge`
|
|
538
|
+
# attempt (`merge_queue.process()`, which already pays for fresh truth)
|
|
539
|
+
# computes it and persists it onto the raw row's `error`. So a
|
|
540
|
+
# verdictless CI failure always re-derives as the plain "checks failed:
|
|
541
|
+
# ..." wording in `plan_entry`, shadowing the more specific reading the
|
|
542
|
+
# raw row already has — recover it here, mirroring the NEEDS_ATTENTION
|
|
543
|
+
# recovery above: prefer the raw row's reason whenever IT carries the
|
|
544
|
+
# #1892 classification and the plan's own fresher reason doesn't.
|
|
545
|
+
if raw_entry is not None:
|
|
546
|
+
raw_reason = raw_entry.get("error")
|
|
547
|
+
if is_ci_infra_reason(raw_reason) and not is_ci_infra_reason(reason):
|
|
548
|
+
reason = raw_reason
|
|
549
|
+
|
|
550
|
+
return {
|
|
551
|
+
"status": status,
|
|
552
|
+
"reason": reason,
|
|
553
|
+
"pr_url": pr_url,
|
|
554
|
+
"assignment_id": plan_entry.get("assignment_id"),
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
@dataclass(frozen=True)
|
|
559
|
+
class MachineChoice:
|
|
560
|
+
"""The result of :func:`pick_machine_choice` — a picked machine name plus
|
|
561
|
+
enough provenance for :func:`coord.drive.preflight` to tell the two
|
|
562
|
+
"nothing to dispatch to" failure modes apart (#1906).
|
|
563
|
+
|
|
564
|
+
``name`` is ``""`` in both failure modes: no unpaused machine hosts the
|
|
565
|
+
repo at all, or at least one does but none advertise the resolved
|
|
566
|
+
provider. ``no_capable_machine`` is what distinguishes them — see
|
|
567
|
+
``IssueState.picked_machine_no_capable``'s docstring.
|
|
568
|
+
"""
|
|
569
|
+
|
|
570
|
+
name: str = ""
|
|
571
|
+
provider_name: str = ""
|
|
572
|
+
provider_reason: str = ""
|
|
573
|
+
no_capable_machine: bool = False
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def pick_machine_choice(
|
|
577
|
+
payload: dict,
|
|
578
|
+
repo: str,
|
|
579
|
+
config: Any,
|
|
580
|
+
*,
|
|
581
|
+
issue_labels: list[str] | None = None,
|
|
582
|
+
) -> MachineChoice:
|
|
583
|
+
"""Least-loaded unpaused **and capable** machine that hosts *repo*.
|
|
584
|
+
|
|
585
|
+
Deliberately simple — this is not ``coord plan``'s brain (which costs an
|
|
586
|
+
LLM call). Load is counted from the board's non-terminal rows, so a
|
|
587
|
+
machine already running two workers loses to an idle peer.
|
|
588
|
+
|
|
589
|
+
#1906: *issue_labels* (``None`` skips provider resolution entirely,
|
|
590
|
+
reproducing the pre-#1906 provider-blind pick byte-for-byte — every
|
|
591
|
+
caller that doesn't pass it, including every pre-#1906 test) resolves
|
|
592
|
+
the effective provider (spec(None) -> ``providers.labels`` -> repo ->
|
|
593
|
+
``providers.default``, :func:`coord.providers.resolve_provider_name`)
|
|
594
|
+
and narrows candidates to those :func:`coord.providers.
|
|
595
|
+
machine_supports_provider` agrees can run it — the SAME predicate
|
|
596
|
+
#1711's ``guard_provider_machine_capability`` uses to refuse a mismatch
|
|
597
|
+
at dispatch time. Selection now agrees with that gate instead of
|
|
598
|
+
discovering the mismatch from its refusal message after the fact.
|
|
599
|
+
|
|
600
|
+
An empty *issue_labels* list (the issue is real but carries no labels,
|
|
601
|
+
or isn't in the local `/board` issues cache yet) still resolves a
|
|
602
|
+
provider — spec/label just contribute nothing, same as ``None`` would,
|
|
603
|
+
but capability filtering still applies (repo/``providers.default`` can
|
|
604
|
+
still name a non-implicit provider).
|
|
605
|
+
"""
|
|
606
|
+
try:
|
|
607
|
+
from coord.machine_pause import paused_set # noqa: PLC0415
|
|
608
|
+
|
|
609
|
+
paused = paused_set(config.machines)
|
|
610
|
+
except Exception: # noqa: BLE001 — a missing pause file means nothing paused
|
|
611
|
+
paused = set()
|
|
612
|
+
|
|
613
|
+
load: dict[str, int] = {}
|
|
614
|
+
for a in payload.get("assignments") or []:
|
|
615
|
+
if (a.get("status") or "") not in TERMINAL_STATUSES:
|
|
616
|
+
name = a.get("machine_name") or ""
|
|
617
|
+
load[name] = load.get(name, 0) + 1
|
|
618
|
+
|
|
619
|
+
hosts = [
|
|
620
|
+
m for m in config.machines if repo in (m.repos or []) and m.name not in paused
|
|
621
|
+
]
|
|
622
|
+
if not hosts:
|
|
623
|
+
return MachineChoice()
|
|
624
|
+
|
|
625
|
+
candidates = hosts
|
|
626
|
+
provider_name = ""
|
|
627
|
+
provider_reason = ""
|
|
628
|
+
if issue_labels is not None:
|
|
629
|
+
from coord.providers import ( # noqa: PLC0415
|
|
630
|
+
describe_provider_choice,
|
|
631
|
+
machine_supports_provider,
|
|
632
|
+
resolve_provider_name,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
repo_cfg = config.repo(repo)
|
|
636
|
+
repo_provider = repo_cfg.provider if repo_cfg is not None else None
|
|
637
|
+
provider_name = resolve_provider_name(
|
|
638
|
+
None, repo_provider, config.providers, issue_labels=issue_labels or None,
|
|
639
|
+
)
|
|
640
|
+
provider_reason = describe_provider_choice(
|
|
641
|
+
None, repo_provider, config.providers, issue_labels=issue_labels or None,
|
|
642
|
+
)
|
|
643
|
+
candidates = [
|
|
644
|
+
m for m in hosts
|
|
645
|
+
if machine_supports_provider(m, provider_name, config.providers)
|
|
646
|
+
]
|
|
647
|
+
if not candidates:
|
|
648
|
+
return MachineChoice(
|
|
649
|
+
provider_name=provider_name,
|
|
650
|
+
provider_reason=provider_reason,
|
|
651
|
+
no_capable_machine=True,
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
candidates = sorted(candidates, key=lambda m: (load.get(m.name, 0), m.name))
|
|
655
|
+
return MachineChoice(
|
|
656
|
+
name=candidates[0].name,
|
|
657
|
+
provider_name=provider_name,
|
|
658
|
+
provider_reason=provider_reason,
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def pick_machine(
|
|
663
|
+
payload: dict, repo: str, config: Any, *, issue_labels: list[str] | None = None,
|
|
664
|
+
) -> str:
|
|
665
|
+
"""Thin string-returning wrapper around :func:`pick_machine_choice`.
|
|
666
|
+
|
|
667
|
+
Kept for callers (and the pre-#1906 test suite) that only want the
|
|
668
|
+
picked machine's name, not the provider provenance / failure-mode split
|
|
669
|
+
— see that function's docstring for the *issue_labels* contract.
|
|
670
|
+
"""
|
|
671
|
+
return pick_machine_choice(payload, repo, config, issue_labels=issue_labels).name
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
# ── board fetch (the one I/O boundary) ───────────────────────────────────────
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def scratch_dir() -> Path:
|
|
678
|
+
"""Per-user scratch directory shared by every ``coord drive`` run.
|
|
679
|
+
|
|
680
|
+
Holds the per-issue run lock + holder file, the run log, the fleet merge
|
|
681
|
+
lock, and the shared board cache.
|
|
682
|
+
|
|
683
|
+
The ``coord-drive-issue-`` name is deliberately the one ``drive-issue.sh``
|
|
684
|
+
used, and every file inside keeps its old name too. During the changeover
|
|
685
|
+
a straggler bash driver launched from an older checkout still collides on
|
|
686
|
+
the *same* ``lock-<repo>-<issue>`` file, so it cannot double-dispatch
|
|
687
|
+
alongside a ``coord drive`` on the same issue. Renaming the directory
|
|
688
|
+
would have silently disabled that mutual exclusion for exactly as long as
|
|
689
|
+
an old checkout existed anywhere in the fleet.
|
|
690
|
+
"""
|
|
691
|
+
base = Path(os.environ.get("TMPDIR", "/tmp")) / f"coord-drive-issue-{os.getuid()}"
|
|
692
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
693
|
+
return base
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _local_issue_rows() -> list[dict]:
|
|
697
|
+
"""``issues`` rows straight from the local DB (daemon-host path only).
|
|
698
|
+
|
|
699
|
+
#2040: :meth:`BoardFetcher._fetch_local`'s standalone payload used to
|
|
700
|
+
carry no ``issues`` key at all (see that method's docstring) — this is
|
|
701
|
+
the top-up. Same fail-soft posture and the same
|
|
702
|
+
``coord.db.get_connection()`` singleton
|
|
703
|
+
``coord.commands.drive_queue._local_issue_rows`` already uses for its own
|
|
704
|
+
(narrower — ``repo_name, number, state`` only) top-up of the same
|
|
705
|
+
standalone-payload gap; this one additionally selects ``milestone_number``
|
|
706
|
+
/ ``milestone_title`` / ``labels`` / ``body`` — what :func:`project`'s
|
|
707
|
+
oracle-loop resolution and :func:`coord.milestone_order.
|
|
708
|
+
milestone_work_order_membership` need that the narrower query doesn't
|
|
709
|
+
carry.
|
|
710
|
+
|
|
711
|
+
Deliberately queries ``get_connection()`` rather than
|
|
712
|
+
``coord.dao.SqliteStore`` — see :meth:`BoardFetcher._fetch_local`'s
|
|
713
|
+
docstring for why the latter is wrong for anything running in-process
|
|
714
|
+
with the rest of the CLI (as ``coord drive`` does on the daemon host).
|
|
715
|
+
|
|
716
|
+
Fail-soft: an unreadable/absent table degrades to ``[]``, which puts the
|
|
717
|
+
daemon host back on the assignment-only signals rather than aborting the
|
|
718
|
+
whole board read over one bad table.
|
|
719
|
+
"""
|
|
720
|
+
from coord.db import get_connection # noqa: PLC0415
|
|
721
|
+
|
|
722
|
+
try:
|
|
723
|
+
rows = get_connection().execute(
|
|
724
|
+
"SELECT repo_name, number, state, milestone_number, milestone_title, "
|
|
725
|
+
"labels, body FROM issues"
|
|
726
|
+
).fetchall()
|
|
727
|
+
except Exception: # noqa: BLE001 — see the fail-soft note above
|
|
728
|
+
return []
|
|
729
|
+
|
|
730
|
+
out: list[dict] = []
|
|
731
|
+
for r in rows:
|
|
732
|
+
d = dict(r)
|
|
733
|
+
labels = d.get("labels")
|
|
734
|
+
if isinstance(labels, (str, bytes, bytearray)):
|
|
735
|
+
try:
|
|
736
|
+
d["labels"] = json.loads(labels) if labels else None
|
|
737
|
+
except (json.JSONDecodeError, TypeError):
|
|
738
|
+
d["labels"] = None
|
|
739
|
+
out.append(d)
|
|
740
|
+
return out
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
@dataclass
|
|
744
|
+
class BoardFetcher:
|
|
745
|
+
"""``GET /board`` with an ETag cache, or the local DB when standalone.
|
|
746
|
+
|
|
747
|
+
The cache is deliberately SHARED across concurrent drivers rather than
|
|
748
|
+
split per-issue: the ``/board`` payload is identical for every issue, so
|
|
749
|
+
sharing means one driver's fetch serves everyone else's 304.
|
|
750
|
+
"""
|
|
751
|
+
|
|
752
|
+
cache_dir: Path = field(default_factory=scratch_dir)
|
|
753
|
+
timeout: float = 60.0
|
|
754
|
+
|
|
755
|
+
def fetch(self) -> dict:
|
|
756
|
+
from coord.client import _headers, resolve_board_service # noqa: PLC0415
|
|
757
|
+
|
|
758
|
+
svc = resolve_board_service()
|
|
759
|
+
if svc is None:
|
|
760
|
+
return self._fetch_local()
|
|
761
|
+
|
|
762
|
+
import httpx # noqa: PLC0415
|
|
763
|
+
|
|
764
|
+
cache_path = self._cache_path(svc.url)
|
|
765
|
+
cached = self._read_cache(cache_path)
|
|
766
|
+
|
|
767
|
+
headers = dict(_headers(svc))
|
|
768
|
+
etag = (cached or {}).get("etag")
|
|
769
|
+
if etag:
|
|
770
|
+
headers["if-none-match"] = etag
|
|
771
|
+
|
|
772
|
+
resp = httpx.get(f"{svc.url}/board", headers=headers, timeout=self.timeout)
|
|
773
|
+
if resp.status_code == 304 and cached is not None:
|
|
774
|
+
return cached["payload"]
|
|
775
|
+
resp.raise_for_status()
|
|
776
|
+
payload = resp.json()
|
|
777
|
+
self._write_cache(cache_path, resp.headers.get("etag"), payload)
|
|
778
|
+
return payload
|
|
779
|
+
|
|
780
|
+
@staticmethod
|
|
781
|
+
def _fetch_local() -> dict:
|
|
782
|
+
"""Standalone (daemon host, no ``board_service`` configured): the old
|
|
783
|
+
``{assignments, round_number}`` write-serialization, topped up with
|
|
784
|
+
the two keys :func:`project`'s #1453 oracle-loop resolution needs.
|
|
785
|
+
|
|
786
|
+
#2040: this used to be JUST ``serialize_board(read_board())`` —
|
|
787
|
+
``coord.client.serialize_board`` is the ``POST /board`` UPSERT
|
|
788
|
+
payload (only what ``coord.state.save_board`` persists), reused here
|
|
789
|
+
by accident for a READ. It carries no ``issues`` key at all, so
|
|
790
|
+
:func:`project`'s ``milestone_number`` / ``milestone_tracking_issue``
|
|
791
|
+
resolution always saw ``None`` on the daemon host — silently
|
|
792
|
+
defeating the #1453 oracle gate (every oracle-opted-in issue read as
|
|
793
|
+
a plain "normal drive" and dead-ended on the #1138 refusal #1453
|
|
794
|
+
exists to prevent).
|
|
795
|
+
|
|
796
|
+
Deliberately NOT ``coord.dao.SqliteStore`` (what ``coord.serve_app``'s
|
|
797
|
+
``/board`` handler uses): that class opens its OWN ``sqlite3``
|
|
798
|
+
connection straight at ``coord.db.DB_PATH``, bypassing the
|
|
799
|
+
``coord.db.get_connection()`` singleton entirely — the exact
|
|
800
|
+
production-DB-read-during-a-test shape #1960's
|
|
801
|
+
``ProductionDatabaseGuardError`` exists to catch, just through a path
|
|
802
|
+
that guard doesn't cover (confirmed the hard way: swapping it in here
|
|
803
|
+
made 20 ``tests/test_cli_drive_queue.py`` tests silently read the
|
|
804
|
+
real ``~/.coord/coord.db`` instead of the seeded ``:memory:`` one).
|
|
805
|
+
``coord drive``/``drive-queue tick`` run IN-PROCESS with the rest of
|
|
806
|
+
the CLI on the daemon host (unlike ``coord serve``, a separate
|
|
807
|
+
process), so this has to go through the same connection every other
|
|
808
|
+
in-process reader does — :func:`_local_issue_rows` below queries it
|
|
809
|
+
directly, mirroring ``coord.commands.drive_queue._local_issue_rows``'s
|
|
810
|
+
established fail-soft top-up pattern for the same standalone-payload
|
|
811
|
+
gap, one table over.
|
|
812
|
+
|
|
813
|
+
``milestone_work_orders`` — the other key :func:`project` needs, and
|
|
814
|
+
the one no raw table backs — is derived from those same rows by
|
|
815
|
+
:func:`coord.milestone_order.milestone_work_order_membership`; see
|
|
816
|
+
its docstring for why a membership-only projection (no readiness) is
|
|
817
|
+
the right scope for this call site.
|
|
818
|
+
"""
|
|
819
|
+
from coord.board_service import read_board # noqa: PLC0415
|
|
820
|
+
from coord.client import serialize_board # noqa: PLC0415
|
|
821
|
+
from coord.milestone_order import milestone_work_order_membership # noqa: PLC0415
|
|
822
|
+
|
|
823
|
+
payload = serialize_board(read_board())
|
|
824
|
+
payload["issues"] = _local_issue_rows()
|
|
825
|
+
payload["milestone_work_orders"] = milestone_work_order_membership(
|
|
826
|
+
payload["issues"]
|
|
827
|
+
)
|
|
828
|
+
return payload
|
|
829
|
+
|
|
830
|
+
def _cache_path(self, url: str) -> Path:
|
|
831
|
+
key = hashlib.sha256(url.encode()).hexdigest()[:16]
|
|
832
|
+
return self.cache_dir / f"board-{key}.json"
|
|
833
|
+
|
|
834
|
+
@staticmethod
|
|
835
|
+
def _read_cache(path: Path) -> dict | None:
|
|
836
|
+
try:
|
|
837
|
+
data = json.loads(path.read_text())
|
|
838
|
+
except (OSError, ValueError):
|
|
839
|
+
return None # absent, unreadable, or a torn write from an old version
|
|
840
|
+
if not isinstance(data, dict) or "payload" not in data:
|
|
841
|
+
return None
|
|
842
|
+
return data
|
|
843
|
+
|
|
844
|
+
@staticmethod
|
|
845
|
+
def _write_cache(path: Path, etag: str | None, payload: dict) -> None:
|
|
846
|
+
"""Store the ETag and the payload TOGETHER in one atomically-replaced file.
|
|
847
|
+
|
|
848
|
+
They were two files, written body-then-etag. That is safe for a single
|
|
849
|
+
writer, but two concurrent drivers can interleave so that a reader
|
|
850
|
+
pairs process A's *newer* etag with process B's *older* body — it then
|
|
851
|
+
sends ``If-None-Match``, gets a 304, and confidently serves the WRONG
|
|
852
|
+
board. A driver acting on a stale board is precisely the class of
|
|
853
|
+
silent wrongness this whole tool exists to avoid.
|
|
854
|
+
|
|
855
|
+
One file makes the pair inseparable; ``os.replace`` is atomic on POSIX,
|
|
856
|
+
and the temp file is created in the same directory so the rename never
|
|
857
|
+
crosses a filesystem boundary. The pid suffix keeps two writers from
|
|
858
|
+
colliding on the temp name itself.
|
|
859
|
+
"""
|
|
860
|
+
tmp = path.with_suffix(f".{os.getpid()}.tmp")
|
|
861
|
+
try:
|
|
862
|
+
tmp.write_text(json.dumps({"etag": etag, "payload": payload}))
|
|
863
|
+
os.replace(tmp, path)
|
|
864
|
+
except OSError:
|
|
865
|
+
# The cache is an optimisation, never a correctness dependency — a
|
|
866
|
+
# failed write just costs the next poll a full fetch.
|
|
867
|
+
try:
|
|
868
|
+
tmp.unlink()
|
|
869
|
+
except OSError:
|
|
870
|
+
pass
|