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/milestone_gate.py
ADDED
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
"""Milestone gate state machine — S-1 of epic #1440 (#1929).
|
|
2
|
+
|
|
3
|
+
The skeleton the rest of #1440 hangs off: a durable, board-backed **gate
|
|
4
|
+
record** per driven milestone plus the pure transition function that walks it
|
|
5
|
+
|
|
6
|
+
Gate A (contract) → work → Gate B (architecture) → Gate C (acceptance)
|
|
7
|
+
→ Gate D (ship) → done
|
|
8
|
+
|
|
9
|
+
exactly once, resumably. Where ``drive-issue.sh`` gets its resumability from
|
|
10
|
+
re-reading the board on every loop, the daemon gets it from
|
|
11
|
+
:func:`coord.state.list_milestone_gates`: the record says which gate a
|
|
12
|
+
milestone is in, when it entered, and what it is waiting on, so a daemon that
|
|
13
|
+
restarts mid-milestone picks the walk back up without ever re-running a gate
|
|
14
|
+
it already cleared.
|
|
15
|
+
|
|
16
|
+
**Every edge here is a "hold and report".** That is deliberate and is the
|
|
17
|
+
whole point of shipping this first. Each of #1440's other children replaces
|
|
18
|
+
exactly one hold in :func:`evaluate_gate` with a real decision (Gate-A pause,
|
|
19
|
+
contract amendment, red acceptance, Gate-B request-changes, Gate-C red).
|
|
20
|
+
Until then a gate that cannot advance logs *why* and stays put. There is no
|
|
21
|
+
silent fall-through anywhere in this module — that is the failure mode that
|
|
22
|
+
produced the 240-minute advisory spin in the per-issue driver, and the one
|
|
23
|
+
thing this file exists to make impossible.
|
|
24
|
+
|
|
25
|
+
Layering
|
|
26
|
+
--------
|
|
27
|
+
|
|
28
|
+
- :func:`evaluate_gate` is **pure**: gate name + :class:`GateProbes` in, one
|
|
29
|
+
:class:`GateStep` out. No board, no GitHub, no clock. Every edge in the
|
|
30
|
+
machine is decided here and nowhere else.
|
|
31
|
+
- :func:`probe_milestone` is the I/O half — it turns a fetched
|
|
32
|
+
:class:`~coord.milestone_dispatch.MilestoneContext` plus a board snapshot
|
|
33
|
+
into a :class:`GateProbes`. Injectable probe callables keep tests off
|
|
34
|
+
``gh``, mirroring ``coord.milestone_dispatch``'s ``GateAFileExists``.
|
|
35
|
+
- :func:`plan_sequence` walks the whole remaining sequence from a record's
|
|
36
|
+
current gate for ``--dry-run``, so the operator sees every gate and what
|
|
37
|
+
would happen at each — not just the next step.
|
|
38
|
+
- :func:`coord.serve_app._milestone_gate_tick` is the driver that persists
|
|
39
|
+
the result. ``work`` is the one gate with a side effect, and it delegates
|
|
40
|
+
to the existing drain (``coord.milestone_dispatch.plan_dispatch`` /
|
|
41
|
+
``dispatch_entry``) rather than inventing a second dispatch path.
|
|
42
|
+
|
|
43
|
+
Relationship to ``milestone.auto_dispatch`` (#1929's explicit question)
|
|
44
|
+
----------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
They are **mutually exclusive per milestone, gate driving wins.**
|
|
47
|
+
|
|
48
|
+
``milestone.auto_dispatch`` gates the *legacy standalone drain*
|
|
49
|
+
(``_milestone_drain_tick``): a milestone registered by ``coord milestone
|
|
50
|
+
dispatch`` whose frontier the daemon re-drains with no gate walk around it.
|
|
51
|
+
A milestone with a gate record is driven by ``_milestone_gate_tick`` instead,
|
|
52
|
+
which owns the drain as its ``work`` state — so ``_milestone_drain_tick``
|
|
53
|
+
**skips** any ``(repo_name, tracking_issue)`` that has a gate record. Without
|
|
54
|
+
that exclusion a milestone sitting at Gate A (contract missing, or a future
|
|
55
|
+
sibling's Gate-A pause) could still have its frontier dispatched by the
|
|
56
|
+
independently-gated drain path, which is precisely the "two gates disagreeing
|
|
57
|
+
about whether work may start" bug.
|
|
58
|
+
|
|
59
|
+
Gate driving is therefore its **own** opt-in, per milestone, via ``coord
|
|
60
|
+
milestone drive`` — not a global config flag. ``_milestone_gate_tick`` is
|
|
61
|
+
consequently *not* gated on ``milestone.auto_dispatch``: an operator who
|
|
62
|
+
explicitly asked for one milestone to be gate-driven has already given the
|
|
63
|
+
approval that flag exists to represent, and leaving the tick behind it would
|
|
64
|
+
mean a ``drive`` that silently does nothing.
|
|
65
|
+
|
|
66
|
+
Exactly one overseer (#1930, epic #1440 S-2)
|
|
67
|
+
---------------------------------------------
|
|
68
|
+
|
|
69
|
+
#1440's acceptance says the "exactly one overseer" decision must be
|
|
70
|
+
structural, not documentary — #1870 is the counter-example this file exists
|
|
71
|
+
to not repeat: a second entry point observed stale state and raced the first,
|
|
72
|
+
producing a duplicate drive. Applied to a gate-controlled milestone, the
|
|
73
|
+
**daemon's gate tick is the sole owner of its ``work`` drain** — every other
|
|
74
|
+
entry point that could dispatch the same frontier must refuse or delegate,
|
|
75
|
+
never guess-and-race:
|
|
76
|
+
|
|
77
|
+
- ``coord milestone drive`` **delegates**. It writes a ``GateRecord`` (cold
|
|
78
|
+
start) or re-persists the existing one (resume) but never calls
|
|
79
|
+
:func:`apply_step` and never dispatches — only ``_milestone_gate_tick``
|
|
80
|
+
does either. Running it twice, from two operators or two machines, is
|
|
81
|
+
therefore inert past the first call: same key, same record, no side
|
|
82
|
+
effect. There is nothing here for a second caller to race.
|
|
83
|
+
- ``coord milestone dispatch`` (the manual, non-gate CLI) **refuses**. Before
|
|
84
|
+
#1930 it had no idea a gate record existed and would happily dispatch the
|
|
85
|
+
same ready frontier the gate tick's ``work`` state was about to (or had
|
|
86
|
+
just) dispatched — the exact "two gates disagreeing about whether work may
|
|
87
|
+
start" shape #1870 already taught this codebase to fear, just reached from
|
|
88
|
+
the operator's keyboard instead of a second host's timer. See
|
|
89
|
+
``coord.commands.milestone.milestone_dispatch_cmd``'s gate-record check,
|
|
90
|
+
which runs before the GitHub fetch and before ``--dry-run``/``--next``, so
|
|
91
|
+
the refusal is unconditional and cheap.
|
|
92
|
+
- ``_milestone_drain_tick`` (the legacy ``milestone.auto_dispatch`` path)
|
|
93
|
+
**delegates** by skipping any milestone with a gate record — see above.
|
|
94
|
+
- A second daemon process ticking the same board is out of scope here: this
|
|
95
|
+
module has no leader-election of its own and assumes the fleet convention
|
|
96
|
+
documented in ``docs/DRIVE_QUEUE.md`` (one daemon owns tick duty) holds for
|
|
97
|
+
the milestone-gate tick too. ``_save_milestone_gate_local``'s whole-record
|
|
98
|
+
upsert is not a compare-and-swap, so two daemons ticking concurrently could
|
|
99
|
+
still interleave writes — that would need a real distributed lock, which is
|
|
100
|
+
a different, larger change than this issue's scope (client vs. daemon
|
|
101
|
+
entry points). Flagged here rather than left for the next reader to
|
|
102
|
+
discover the hard way.
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
from __future__ import annotations
|
|
106
|
+
|
|
107
|
+
import time
|
|
108
|
+
from dataclasses import dataclass, field, replace
|
|
109
|
+
from typing import TYPE_CHECKING, Callable
|
|
110
|
+
|
|
111
|
+
if TYPE_CHECKING:
|
|
112
|
+
from coord.config import Config
|
|
113
|
+
from coord.milestone_dispatch import MilestoneContext
|
|
114
|
+
from coord.models import Board, Repo
|
|
115
|
+
|
|
116
|
+
__all__ = [
|
|
117
|
+
"GATE_A",
|
|
118
|
+
"WORK",
|
|
119
|
+
"GATE_B",
|
|
120
|
+
"GATE_C",
|
|
121
|
+
"GATE_D",
|
|
122
|
+
"DONE",
|
|
123
|
+
"GATE_SEQUENCE",
|
|
124
|
+
"GATE_LABELS",
|
|
125
|
+
"TERMINAL_GATES",
|
|
126
|
+
"ADVANCE",
|
|
127
|
+
"HOLD",
|
|
128
|
+
"DISPATCH",
|
|
129
|
+
"TERMINAL",
|
|
130
|
+
"GateRecord",
|
|
131
|
+
"GateStep",
|
|
132
|
+
"GateProbes",
|
|
133
|
+
"evaluate_gate",
|
|
134
|
+
"probe_milestone",
|
|
135
|
+
"plan_sequence",
|
|
136
|
+
"apply_step",
|
|
137
|
+
"format_plan",
|
|
138
|
+
]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ── The gates ────────────────────────────────────────────────────────────────
|
|
142
|
+
#
|
|
143
|
+
# Names are the persisted wire values (they land in board_meta JSON), so
|
|
144
|
+
# renaming one is a migration, not a refactor. ``work`` is deliberately a
|
|
145
|
+
# peer *state* in this sequence and not a peer *machine*: the drain is the
|
|
146
|
+
# middle of this walk, entered only once Gate A has cleared and exited only
|
|
147
|
+
# once every work-order node is terminal.
|
|
148
|
+
|
|
149
|
+
GATE_A = "gate_a"
|
|
150
|
+
WORK = "work"
|
|
151
|
+
GATE_B = "gate_b"
|
|
152
|
+
GATE_C = "gate_c"
|
|
153
|
+
GATE_D = "gate_d"
|
|
154
|
+
DONE = "done"
|
|
155
|
+
|
|
156
|
+
GATE_SEQUENCE: tuple[str, ...] = (GATE_A, WORK, GATE_B, GATE_C, GATE_D, DONE)
|
|
157
|
+
|
|
158
|
+
GATE_LABELS: dict[str, str] = {
|
|
159
|
+
GATE_A: "Gate A — contract",
|
|
160
|
+
WORK: "work — drain the ready frontier",
|
|
161
|
+
GATE_B: "Gate B — architecture review",
|
|
162
|
+
GATE_C: "Gate C — full acceptance suite",
|
|
163
|
+
GATE_D: "Gate D — ship",
|
|
164
|
+
DONE: "done",
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#: Gates from which the machine never leaves; reaching one deregisters the
|
|
168
|
+
#: milestone from gate driving (the gate-record analogue of
|
|
169
|
+
#: ``deregister_milestone_drain``).
|
|
170
|
+
TERMINAL_GATES: frozenset[str] = frozenset({DONE})
|
|
171
|
+
|
|
172
|
+
# Step actions.
|
|
173
|
+
ADVANCE = "advance"
|
|
174
|
+
HOLD = "hold"
|
|
175
|
+
DISPATCH = "dispatch"
|
|
176
|
+
TERMINAL = "terminal"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def next_gate(gate: str) -> str | None:
|
|
180
|
+
"""The gate that follows *gate* in :data:`GATE_SEQUENCE`, or ``None``.
|
|
181
|
+
|
|
182
|
+
``None`` both for a terminal gate and for an unknown one — callers treat
|
|
183
|
+
"nowhere to go" identically in either case.
|
|
184
|
+
"""
|
|
185
|
+
try:
|
|
186
|
+
idx = GATE_SEQUENCE.index(gate)
|
|
187
|
+
except ValueError:
|
|
188
|
+
return None
|
|
189
|
+
if idx + 1 >= len(GATE_SEQUENCE):
|
|
190
|
+
return None
|
|
191
|
+
return GATE_SEQUENCE[idx + 1]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ── The durable record ───────────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass(frozen=True)
|
|
198
|
+
class GateRecord:
|
|
199
|
+
"""One driven milestone's persisted position in the gate walk.
|
|
200
|
+
|
|
201
|
+
Stored as JSON under the ``milestone_gates`` ``board_meta`` key (see
|
|
202
|
+
:func:`coord.state.list_milestone_gates`), one entry per
|
|
203
|
+
``(repo_name, tracking_issue)``. Everything the daemon needs to resume
|
|
204
|
+
without re-deriving history:
|
|
205
|
+
|
|
206
|
+
``gate``
|
|
207
|
+
Which gate the milestone is in *right now*. A restart reads this and
|
|
208
|
+
resumes there — it never restarts the walk at :data:`GATE_A`, which
|
|
209
|
+
is what "never re-runs a completed gate" means concretely.
|
|
210
|
+
``entered_at``
|
|
211
|
+
Wall-clock seconds when the milestone entered ``gate``. Stamped only
|
|
212
|
+
on an actual transition, so it survives arbitrarily many no-op ticks
|
|
213
|
+
and answers "how long has this been stuck".
|
|
214
|
+
``waiting_on``
|
|
215
|
+
The current hold reason, verbatim from :func:`evaluate_gate`. Empty
|
|
216
|
+
while a gate is advancing. This is the field that makes a hold
|
|
217
|
+
*reported* rather than silent.
|
|
218
|
+
``cleared``
|
|
219
|
+
Every gate this milestone has already left, in order. The audit
|
|
220
|
+
trail for "did we run Gate B twice"; also what a future sibling reads
|
|
221
|
+
to decide whether a bounce means re-entering a cleared gate.
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
repo_name: str
|
|
225
|
+
tracking_issue: int
|
|
226
|
+
gate: str = GATE_A
|
|
227
|
+
entered_at: float = 0.0
|
|
228
|
+
updated_at: float = 0.0
|
|
229
|
+
waiting_on: str = ""
|
|
230
|
+
milestone_number: int | None = None
|
|
231
|
+
cleared: tuple[str, ...] = field(default_factory=tuple)
|
|
232
|
+
#: Bumped when the persisted shape changes incompatibly. A record whose
|
|
233
|
+
#: schema this build doesn't understand is treated as absent (cold start)
|
|
234
|
+
#: rather than mis-parsed — see :meth:`from_dict`.
|
|
235
|
+
schema: int = 1
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def key(self) -> tuple[str, int]:
|
|
239
|
+
return (self.repo_name, self.tracking_issue)
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def label(self) -> str:
|
|
243
|
+
return f"{self.repo_name}#{self.tracking_issue}"
|
|
244
|
+
|
|
245
|
+
@property
|
|
246
|
+
def is_terminal(self) -> bool:
|
|
247
|
+
return self.gate in TERMINAL_GATES
|
|
248
|
+
|
|
249
|
+
def to_dict(self) -> dict:
|
|
250
|
+
return {
|
|
251
|
+
"schema": self.schema,
|
|
252
|
+
"repo_name": self.repo_name,
|
|
253
|
+
"tracking_issue": self.tracking_issue,
|
|
254
|
+
"gate": self.gate,
|
|
255
|
+
"entered_at": self.entered_at,
|
|
256
|
+
"updated_at": self.updated_at,
|
|
257
|
+
"waiting_on": self.waiting_on,
|
|
258
|
+
"milestone_number": self.milestone_number,
|
|
259
|
+
"cleared": list(self.cleared),
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
@classmethod
|
|
263
|
+
def from_dict(cls, raw: dict) -> "GateRecord | None":
|
|
264
|
+
"""Rebuild a record from persisted JSON, or ``None`` if unusable.
|
|
265
|
+
|
|
266
|
+
Tolerant on purpose, in the same spirit as
|
|
267
|
+
``coord.state._load_milestone_drains_raw``: one corrupt entry must
|
|
268
|
+
degrade to "this milestone isn't gate-driven" rather than take down
|
|
269
|
+
the whole tick for every other milestone. An unknown ``gate`` value
|
|
270
|
+
is rejected too — silently coercing it to Gate A would re-run
|
|
271
|
+
already-cleared gates, the exact thing this record exists to prevent.
|
|
272
|
+
"""
|
|
273
|
+
if not isinstance(raw, dict):
|
|
274
|
+
return None
|
|
275
|
+
try:
|
|
276
|
+
if int(raw.get("schema", 1)) != 1:
|
|
277
|
+
return None
|
|
278
|
+
repo_name = raw["repo_name"]
|
|
279
|
+
tracking_issue = int(raw["tracking_issue"])
|
|
280
|
+
except (KeyError, TypeError, ValueError):
|
|
281
|
+
return None
|
|
282
|
+
if not isinstance(repo_name, str) or not repo_name:
|
|
283
|
+
return None
|
|
284
|
+
|
|
285
|
+
gate = raw.get("gate", GATE_A)
|
|
286
|
+
if gate not in GATE_SEQUENCE:
|
|
287
|
+
return None
|
|
288
|
+
|
|
289
|
+
milestone_number = raw.get("milestone_number")
|
|
290
|
+
if milestone_number is not None:
|
|
291
|
+
try:
|
|
292
|
+
milestone_number = int(milestone_number)
|
|
293
|
+
except (TypeError, ValueError):
|
|
294
|
+
milestone_number = None
|
|
295
|
+
|
|
296
|
+
cleared_raw = raw.get("cleared") or []
|
|
297
|
+
cleared = tuple(
|
|
298
|
+
g for g in cleared_raw if isinstance(g, str) and g in GATE_SEQUENCE
|
|
299
|
+
) if isinstance(cleared_raw, list) else ()
|
|
300
|
+
|
|
301
|
+
def _f(key: str) -> float:
|
|
302
|
+
try:
|
|
303
|
+
return float(raw.get(key) or 0.0)
|
|
304
|
+
except (TypeError, ValueError):
|
|
305
|
+
return 0.0
|
|
306
|
+
|
|
307
|
+
waiting_on = raw.get("waiting_on") or ""
|
|
308
|
+
return cls(
|
|
309
|
+
repo_name=repo_name,
|
|
310
|
+
tracking_issue=tracking_issue,
|
|
311
|
+
gate=gate,
|
|
312
|
+
entered_at=_f("entered_at"),
|
|
313
|
+
updated_at=_f("updated_at"),
|
|
314
|
+
waiting_on=waiting_on if isinstance(waiting_on, str) else "",
|
|
315
|
+
milestone_number=milestone_number,
|
|
316
|
+
cleared=cleared,
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ── The transition function ──────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@dataclass(frozen=True)
|
|
324
|
+
class GateStep:
|
|
325
|
+
"""What the machine decided for one gate.
|
|
326
|
+
|
|
327
|
+
``action`` is one of :data:`ADVANCE`, :data:`HOLD`, :data:`DISPATCH`
|
|
328
|
+
(``work`` only — drain the frontier and stay put) or :data:`TERMINAL`.
|
|
329
|
+
``reason`` is always populated, including on an advance, so a log line or
|
|
330
|
+
a ``--dry-run`` row never has to say just "held" with no explanation.
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
gate: str
|
|
334
|
+
action: str
|
|
335
|
+
reason: str
|
|
336
|
+
to_gate: str | None = None
|
|
337
|
+
#: True when this step is a *projection* rather than a live decision —
|
|
338
|
+
#: set by :func:`plan_sequence` for gates downstream of the first hold,
|
|
339
|
+
#: whose real inputs cannot exist yet.
|
|
340
|
+
projected: bool = False
|
|
341
|
+
|
|
342
|
+
@property
|
|
343
|
+
def advances(self) -> bool:
|
|
344
|
+
return self.action == ADVANCE
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
@dataclass(frozen=True)
|
|
348
|
+
class GateProbes:
|
|
349
|
+
"""The live inputs every gate edge is decided from.
|
|
350
|
+
|
|
351
|
+
Split out from :func:`evaluate_gate` so the machine itself is pure and a
|
|
352
|
+
test can drive any edge by constructing this directly — no GitHub, no
|
|
353
|
+
board, no ``gh``.
|
|
354
|
+
"""
|
|
355
|
+
|
|
356
|
+
#: ``coord.milestone_dispatch.gate_a_status`` — a human-readable block
|
|
357
|
+
#: reason, or ``None`` when the contract exists (or the repo is outside
|
|
358
|
+
#: the oracle-loop model entirely and skips Gate A).
|
|
359
|
+
gate_a_blocked: str | None = None
|
|
360
|
+
#: ``coord.milestone_dispatch.is_milestone_complete`` — every work-order
|
|
361
|
+
#: node terminal.
|
|
362
|
+
work_complete: bool = False
|
|
363
|
+
#: How many work-order nodes are still not terminal (display only).
|
|
364
|
+
work_remaining: int = 0
|
|
365
|
+
#: ``coord.gate_b.latest_gate_b_verdict`` — ``"approve"``,
|
|
366
|
+
#: ``"request-changes"``, or ``None`` when no Gate B review has landed.
|
|
367
|
+
gate_b_verdict: str | None = None
|
|
368
|
+
#: Tri-state on purpose. ``None`` means *no durable Gate C result
|
|
369
|
+
#: exists* — which is today's reality: ``coord milestone gate-c`` is a
|
|
370
|
+
#: read-only operator run that records nothing on the board. Making it
|
|
371
|
+
#: durable is a #1440 sibling; until then Gate C always holds on ``None``
|
|
372
|
+
#: rather than guessing.
|
|
373
|
+
gate_c_green: bool | None = None
|
|
374
|
+
#: Whether the milestone has shipped. Gate D never ships anything from
|
|
375
|
+
#: here (``coord milestone ship`` is an explicit operator action); this
|
|
376
|
+
#: only *observes* that it happened so the walk can terminate. The
|
|
377
|
+
#: observable signal is the tracking issue's own GitHub state — ``ship``
|
|
378
|
+
#: closes it as the last step of a successful run, so this is true iff
|
|
379
|
+
#: that closure (by ``ship`` or otherwise) has landed. See
|
|
380
|
+
#: :func:`probe_milestone`.
|
|
381
|
+
shipped: bool = False
|
|
382
|
+
#: Whether the work order is empty — a tracking issue with no ``## Work
|
|
383
|
+
#: order`` block can't be driven, and saying so beats holding forever at
|
|
384
|
+
#: ``work`` with an empty frontier.
|
|
385
|
+
work_order_empty: bool = False
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def evaluate_gate(gate: str, probes: GateProbes) -> GateStep:
|
|
389
|
+
"""Decide what happens at *gate*, given *probes*. Pure.
|
|
390
|
+
|
|
391
|
+
The single place any gate edge is decided. Every branch below either
|
|
392
|
+
advances or produces an explicit, human-readable hold — there is no
|
|
393
|
+
``else: pass`` and no path that returns "nothing to say". When a #1440
|
|
394
|
+
sibling lands a real decision for an edge, it replaces that edge's hold
|
|
395
|
+
here and the rest of the walk is unchanged.
|
|
396
|
+
"""
|
|
397
|
+
if gate == GATE_A:
|
|
398
|
+
if probes.work_order_empty:
|
|
399
|
+
return GateStep(
|
|
400
|
+
gate, HOLD,
|
|
401
|
+
"tracking issue has no `## Work order` block — nothing to drive "
|
|
402
|
+
"(write one with `coord milestone write-order`)",
|
|
403
|
+
)
|
|
404
|
+
if probes.gate_a_blocked:
|
|
405
|
+
# SIBLING (#1440, Gate-A pause): today a missing contract is a
|
|
406
|
+
# plain wait-and-retry. The Gate-A child decides whether to
|
|
407
|
+
# pause the milestone, dispatch the mock author, or escalate.
|
|
408
|
+
return GateStep(gate, HOLD, probes.gate_a_blocked)
|
|
409
|
+
return GateStep(
|
|
410
|
+
gate, ADVANCE,
|
|
411
|
+
"contract present on the default branch — work may dispatch",
|
|
412
|
+
to_gate=WORK,
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
if gate == WORK:
|
|
416
|
+
if probes.work_complete:
|
|
417
|
+
return GateStep(
|
|
418
|
+
gate, ADVANCE,
|
|
419
|
+
"every work-order node is terminal", to_gate=GATE_B,
|
|
420
|
+
)
|
|
421
|
+
# The one gate with a side effect. Staying here IS the drain.
|
|
422
|
+
return GateStep(
|
|
423
|
+
gate, DISPATCH,
|
|
424
|
+
f"{probes.work_remaining} work-order node(s) not terminal — "
|
|
425
|
+
"draining the ready frontier",
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
if gate == GATE_B:
|
|
429
|
+
verdict = probes.gate_b_verdict
|
|
430
|
+
if verdict == "approve":
|
|
431
|
+
return GateStep(
|
|
432
|
+
gate, ADVANCE, "Gate B approved", to_gate=GATE_C,
|
|
433
|
+
)
|
|
434
|
+
if verdict == "request-changes":
|
|
435
|
+
# SIBLING (#1440, Gate-B request-changes): bouncing the milestone
|
|
436
|
+
# back to `work` with a rework cohort is that child's decision.
|
|
437
|
+
return GateStep(
|
|
438
|
+
gate, HOLD,
|
|
439
|
+
"Gate B returned request-changes — bounce handling is a "
|
|
440
|
+
"separate #1440 child; holding rather than shipping",
|
|
441
|
+
)
|
|
442
|
+
if verdict:
|
|
443
|
+
return GateStep(
|
|
444
|
+
gate, HOLD,
|
|
445
|
+
f"Gate B verdict {verdict!r} is not an approval — holding",
|
|
446
|
+
)
|
|
447
|
+
return GateStep(
|
|
448
|
+
gate, HOLD,
|
|
449
|
+
"no Gate B verdict yet — run `coord milestone gate-b <repo> "
|
|
450
|
+
"<tracking_issue>`",
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
if gate == GATE_C:
|
|
454
|
+
if probes.gate_c_green is True:
|
|
455
|
+
return GateStep(
|
|
456
|
+
gate, ADVANCE,
|
|
457
|
+
"full acceptance suite recorded green", to_gate=GATE_D,
|
|
458
|
+
)
|
|
459
|
+
if probes.gate_c_green is False:
|
|
460
|
+
# SIBLING (#1440, Gate-C red): deciding whether red means bounce,
|
|
461
|
+
# amend the contract, or file a follow-up is that child's job.
|
|
462
|
+
return GateStep(
|
|
463
|
+
gate, HOLD,
|
|
464
|
+
"full acceptance suite is red — red-acceptance handling is a "
|
|
465
|
+
"separate #1440 child; holding",
|
|
466
|
+
)
|
|
467
|
+
return GateStep(
|
|
468
|
+
gate, HOLD,
|
|
469
|
+
"no durable Gate C result exists — `coord milestone gate-c` is "
|
|
470
|
+
"read-only today and records nothing on the board; run it "
|
|
471
|
+
"manually (making it durable is a separate #1440 child)",
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
if gate == GATE_D:
|
|
475
|
+
if probes.shipped:
|
|
476
|
+
return GateStep(gate, ADVANCE, "milestone shipped", to_gate=DONE)
|
|
477
|
+
return GateStep(
|
|
478
|
+
gate, HOLD,
|
|
479
|
+
"ship is an explicit operator action — run `coord milestone ship "
|
|
480
|
+
"<repo> <tracking_issue>`, which merges feature/ms-NN into develop "
|
|
481
|
+
"*and then closes the tracking issue on success*; this gate "
|
|
482
|
+
"watches for that closure (not the ship command running) to "
|
|
483
|
+
"detect completion. If the tracking issue was already closed by "
|
|
484
|
+
"some other means, that satisfies this gate too — but a bare "
|
|
485
|
+
"`ship` run without a closed issue never will",
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
if gate == DONE:
|
|
489
|
+
return GateStep(
|
|
490
|
+
gate, TERMINAL, "milestone walk complete — deregistering",
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
# Unreachable via GateRecord.from_dict (which rejects unknown gates), but
|
|
494
|
+
# an explicit refusal beats falling off the end of the function.
|
|
495
|
+
return GateStep(gate, HOLD, f"unknown gate {gate!r} — holding")
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def apply_step(record: GateRecord, step: GateStep, *, now: float | None = None) -> GateRecord:
|
|
499
|
+
"""Fold *step* into *record*, returning the record to persist.
|
|
500
|
+
|
|
501
|
+
An advance stamps a fresh ``entered_at`` and appends the gate just left
|
|
502
|
+
to ``cleared`` — that append is what "never re-runs a completed gate"
|
|
503
|
+
is written down as. A hold only refreshes ``waiting_on``/``updated_at``,
|
|
504
|
+
so a milestone stuck for an hour still reports the timestamp it actually
|
|
505
|
+
entered the gate.
|
|
506
|
+
"""
|
|
507
|
+
stamp = time.time() if now is None else now
|
|
508
|
+
if step.advances and step.to_gate:
|
|
509
|
+
cleared = record.cleared
|
|
510
|
+
if record.gate not in cleared:
|
|
511
|
+
cleared = (*cleared, record.gate)
|
|
512
|
+
return replace(
|
|
513
|
+
record,
|
|
514
|
+
gate=step.to_gate,
|
|
515
|
+
entered_at=stamp,
|
|
516
|
+
updated_at=stamp,
|
|
517
|
+
waiting_on="",
|
|
518
|
+
cleared=cleared,
|
|
519
|
+
)
|
|
520
|
+
return replace(
|
|
521
|
+
record,
|
|
522
|
+
updated_at=stamp,
|
|
523
|
+
waiting_on="" if step.action in (ADVANCE, DISPATCH) else step.reason,
|
|
524
|
+
entered_at=record.entered_at or stamp,
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
# ── The I/O half ─────────────────────────────────────────────────────────────
|
|
529
|
+
|
|
530
|
+
#: ``(repo_cfg, config, milestone_number) -> block reason | None``. Injected
|
|
531
|
+
#: so tests never touch ``gh`` — mirrors ``milestone_dispatch.GateAFileExists``.
|
|
532
|
+
GateAProbe = Callable[["Repo", "Config", int], "str | None"]
|
|
533
|
+
#: ``(board, repo_name, tracking_issue, milestone_number) -> verdict | None``.
|
|
534
|
+
GateBProbe = Callable[["Board", str, int, int], "str | None"]
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def probe_milestone(
|
|
538
|
+
ctx: "MilestoneContext",
|
|
539
|
+
board: "Board",
|
|
540
|
+
config: "Config",
|
|
541
|
+
repo_cfg: "Repo",
|
|
542
|
+
*,
|
|
543
|
+
gate_a_probe: GateAProbe | None = None,
|
|
544
|
+
gate_b_probe: GateBProbe | None = None,
|
|
545
|
+
) -> GateProbes:
|
|
546
|
+
"""Gather every live input :func:`evaluate_gate` needs, in one place.
|
|
547
|
+
|
|
548
|
+
Reuses the *existing* gate readers verbatim
|
|
549
|
+
(:func:`coord.milestone_dispatch.gate_a_status`,
|
|
550
|
+
:func:`coord.gate_b.latest_gate_b_verdict`) rather than reimplementing
|
|
551
|
+
them, so the gate walk and the manual CLI commands can never disagree
|
|
552
|
+
about whether a gate is satisfied.
|
|
553
|
+
|
|
554
|
+
``shipped`` is read off the tracking issue's own state: closing the epic
|
|
555
|
+
is the observable end of the walk, and it costs nothing extra — the
|
|
556
|
+
tracking issue is already fetched by
|
|
557
|
+
:func:`~coord.milestone_dispatch.fetch_milestone_context`. ``coord
|
|
558
|
+
milestone ship`` (``coord/commands/milestone.py``) closes the tracking
|
|
559
|
+
issue itself once the merge succeeds, specifically so this probe has
|
|
560
|
+
something to observe — Gate D does not hold forever waiting on a manual
|
|
561
|
+
close that nothing in the pipeline actually performs.
|
|
562
|
+
|
|
563
|
+
This always computes *every* probe, regardless of which gate the
|
|
564
|
+
milestone is currently sitting in — a milestone parked at Gate C still
|
|
565
|
+
pays for a Gate-A contract-file fetch every tick. That is deliberate,
|
|
566
|
+
not an oversight: :func:`plan_sequence` evaluates every gate from the
|
|
567
|
+
current one through :data:`DONE` against this *same* probe set to build
|
|
568
|
+
the ``--dry-run`` projection, so the downstream probes have to exist
|
|
569
|
+
even when the live gate doesn't need them yet. Making this lazy would
|
|
570
|
+
mean threading "which gates does the caller actually need" through two
|
|
571
|
+
layers to save one GitHub call per tick — a real but minor cost, not a
|
|
572
|
+
correctness issue.
|
|
573
|
+
"""
|
|
574
|
+
from coord.gate_b import latest_gate_b_verdict # noqa: PLC0415
|
|
575
|
+
from coord.milestone_dispatch import gate_a_status, is_milestone_complete # noqa: PLC0415
|
|
576
|
+
|
|
577
|
+
a_probe = gate_a_probe or (
|
|
578
|
+
lambda r, c, m: gate_a_status(r, c, m)
|
|
579
|
+
)
|
|
580
|
+
b_probe = gate_b_probe or (
|
|
581
|
+
lambda b, rn, ti, mn: latest_gate_b_verdict(b, rn, ti, mn)
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
nodes = list(ctx.work_order.nodes)
|
|
585
|
+
remaining = [n for n in nodes if n.issue_number not in ctx.terminal_issues]
|
|
586
|
+
|
|
587
|
+
return GateProbes(
|
|
588
|
+
gate_a_blocked=a_probe(repo_cfg, config, ctx.milestone_number),
|
|
589
|
+
work_complete=bool(nodes) and is_milestone_complete(ctx),
|
|
590
|
+
work_remaining=len(remaining),
|
|
591
|
+
gate_b_verdict=b_probe(
|
|
592
|
+
board, repo_cfg.name, ctx.tracking_issue, ctx.milestone_number
|
|
593
|
+
),
|
|
594
|
+
# Tri-state None — see GateProbes.gate_c_green. No durable Gate C
|
|
595
|
+
# record exists on the board today, so this is never True/False yet.
|
|
596
|
+
gate_c_green=None,
|
|
597
|
+
shipped=str(ctx.tracking_issue_state or "").upper() == "CLOSED",
|
|
598
|
+
work_order_empty=not nodes,
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
# ── Dry-run: the whole remaining sequence ────────────────────────────────────
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def plan_sequence(record: GateRecord, probes: GateProbes) -> list[GateStep]:
|
|
606
|
+
"""The full remaining walk from ``record.gate``, one step per gate.
|
|
607
|
+
|
|
608
|
+
Every gate from the current one through :data:`DONE` appears — not just
|
|
609
|
+
the next one — which is what makes ``--dry-run`` reviewable. Gates after
|
|
610
|
+
the first hold are still evaluated against the *current* probes and
|
|
611
|
+
flagged ``projected=True``: their real inputs cannot exist yet (there is
|
|
612
|
+
no Gate B verdict while work is still draining), so the row shows what
|
|
613
|
+
they would say today, honestly labelled, rather than being omitted.
|
|
614
|
+
"""
|
|
615
|
+
try:
|
|
616
|
+
start = GATE_SEQUENCE.index(record.gate)
|
|
617
|
+
except ValueError:
|
|
618
|
+
return [evaluate_gate(record.gate, probes)]
|
|
619
|
+
|
|
620
|
+
steps: list[GateStep] = []
|
|
621
|
+
stalled = False
|
|
622
|
+
for gate in GATE_SEQUENCE[start:]:
|
|
623
|
+
step = evaluate_gate(gate, probes)
|
|
624
|
+
steps.append(replace(step, projected=stalled))
|
|
625
|
+
if not step.advances:
|
|
626
|
+
# The walk stops advancing here; everything past this point is a
|
|
627
|
+
# projection, but keep emitting it so the operator sees the shape
|
|
628
|
+
# of the whole milestone.
|
|
629
|
+
stalled = True
|
|
630
|
+
return steps
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def format_plan(
|
|
634
|
+
record: GateRecord,
|
|
635
|
+
steps: list[GateStep],
|
|
636
|
+
*,
|
|
637
|
+
to_dispatch: list = (),
|
|
638
|
+
skipped: list = (),
|
|
639
|
+
waiting: list = (),
|
|
640
|
+
footer: bool = True,
|
|
641
|
+
) -> list[str]:
|
|
642
|
+
"""Render a ``--dry-run`` report: the gate walk plus the work frontier.
|
|
643
|
+
|
|
644
|
+
``to_dispatch``/``skipped``/``waiting`` come straight off a
|
|
645
|
+
:class:`~coord.milestone_dispatch.MilestonePlan` (the same frontier
|
|
646
|
+
``coord milestone dispatch --dry-run`` prints), so the operator sees both
|
|
647
|
+
halves of #1440's fourth acceptance bullet — every gate, *and* what would
|
|
648
|
+
dispatch — in one output. Returns lines rather than echoing so both the
|
|
649
|
+
CLI and a test can consume it.
|
|
650
|
+
|
|
651
|
+
``footer`` controls whether the trailing blank line + "(dry run — ...)"
|
|
652
|
+
line is included. A non-dry-run confirmation (``coord milestone drive``
|
|
653
|
+
without ``--dry-run``) wants the same report *without* that footer, since
|
|
654
|
+
the record was in fact written — pass ``footer=False`` there rather than
|
|
655
|
+
slicing the returned list, which silently breaks if this function's own
|
|
656
|
+
footer shape ever changes.
|
|
657
|
+
"""
|
|
658
|
+
lines: list[str] = [
|
|
659
|
+
f"Gate walk for {record.label}"
|
|
660
|
+
+ (
|
|
661
|
+
f" (milestone #{record.milestone_number})"
|
|
662
|
+
if record.milestone_number is not None
|
|
663
|
+
else ""
|
|
664
|
+
),
|
|
665
|
+
f" current gate: {GATE_LABELS.get(record.gate, record.gate)}",
|
|
666
|
+
]
|
|
667
|
+
if record.cleared:
|
|
668
|
+
lines.append(
|
|
669
|
+
" already cleared: "
|
|
670
|
+
+ ", ".join(GATE_LABELS.get(g, g) for g in record.cleared)
|
|
671
|
+
)
|
|
672
|
+
lines.append("")
|
|
673
|
+
lines.append("Planned sequence:")
|
|
674
|
+
|
|
675
|
+
marks = {ADVANCE: "->", HOLD: "||", DISPATCH: "**", TERMINAL: "..."}
|
|
676
|
+
for step in steps:
|
|
677
|
+
mark = marks.get(step.action, " ")
|
|
678
|
+
suffix = " [projected]" if step.projected else ""
|
|
679
|
+
lines.append(
|
|
680
|
+
f" {mark} {GATE_LABELS.get(step.gate, step.gate)}: "
|
|
681
|
+
f"{step.action.upper()} — {step.reason}{suffix}"
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
lines.append("")
|
|
685
|
+
lines.append("Work-order frontier (what the `work` gate would dispatch):")
|
|
686
|
+
if to_dispatch:
|
|
687
|
+
for pick in to_dispatch:
|
|
688
|
+
group = getattr(pick.entry, "group", None)
|
|
689
|
+
grp = f" (group {group})" if group else ""
|
|
690
|
+
lines.append(
|
|
691
|
+
f" would dispatch #{pick.entry.issue_number} -> "
|
|
692
|
+
f"{pick.machine.name}{grp}"
|
|
693
|
+
)
|
|
694
|
+
else:
|
|
695
|
+
lines.append(" (nothing ready to dispatch right now)")
|
|
696
|
+
|
|
697
|
+
if skipped:
|
|
698
|
+
lines.append(" Ready but no idle machine:")
|
|
699
|
+
for s in skipped:
|
|
700
|
+
lines.append(f" #{s.entry.issue_number}: {s.reason}")
|
|
701
|
+
if waiting:
|
|
702
|
+
lines.append(" Waiting on declared-order dependencies:")
|
|
703
|
+
for b in waiting:
|
|
704
|
+
lines.append(f" #{b.issue_number}: {b.reason}")
|
|
705
|
+
|
|
706
|
+
if footer:
|
|
707
|
+
lines.append("")
|
|
708
|
+
lines.append("(dry run — nothing dispatched, no gate record written)")
|
|
709
|
+
return lines
|