code-coordinator 0.5.46__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
|
@@ -0,0 +1,1176 @@
|
|
|
1
|
+
"""Decision half of merge-triggered propagation (#1835, PKG-7).
|
|
2
|
+
|
|
3
|
+
PKG-7 closes the release loop: **merging a PR to `main` is the only human
|
|
4
|
+
action in a release.** The pipeline it creates is deliberately cut in two,
|
|
5
|
+
and the cut is the whole design:
|
|
6
|
+
|
|
7
|
+
* **Publish** — fully automatic, on merge. `.github/workflows/auto-release.yml`
|
|
8
|
+
picks the next `vX.Y.Z` from the tag history and pushes it; `publish.yml`
|
|
9
|
+
(#1242, PKG-6) turns that one tag into one Release carrying the wheel, the
|
|
10
|
+
`coord-tui` binaries and the bundled webapp. Publishing touches no running
|
|
11
|
+
host, so it is safe at any instant.
|
|
12
|
+
|
|
13
|
+
* **Propagate** — automatic, but scheduled against *fleet quiescence*, never
|
|
14
|
+
against the clock. This module is that scheduler's judgement.
|
|
15
|
+
|
|
16
|
+
Why the cut is not optional: ``coord agent update`` restarts the agent, and
|
|
17
|
+
a restart kills every in-flight headless worker (``coord/agent_app.py``'s
|
|
18
|
+
``/update`` refuses outright when assignments are live, for exactly this
|
|
19
|
+
reason). With overnight drive queues (#56/#1750) the fleet is rarely idle,
|
|
20
|
+
so "on merge, upgrade the fleet" would routinely destroy work — and the
|
|
21
|
+
better the queue works, the more it destroys.
|
|
22
|
+
|
|
23
|
+
QUIESCENCE IS THE DRIVE QUEUE'S, NOT A SECOND OPINION
|
|
24
|
+
-----------------------------------------------------
|
|
25
|
+
#1835 is explicit that propagation must reuse the drive queue's existing
|
|
26
|
+
gate mechanism rather than invent a rival definition of "the fleet is busy"
|
|
27
|
+
— two competing definitions of quiescence is the same class of defect as two
|
|
28
|
+
overseers driving one milestone (#1440). So :func:`assess_quiescence` reads
|
|
29
|
+
exactly the states :mod:`coord.drive_queue` already publishes, and imports
|
|
30
|
+
its constants rather than re-spelling them:
|
|
31
|
+
|
|
32
|
+
* a queue entry in :data:`~coord.drive_queue.STATE_RUNNING` is in-flight work
|
|
33
|
+
→ **busy**;
|
|
34
|
+
* an agent with live (``RUNNING``/``PENDING``) assignments is in-flight work
|
|
35
|
+
→ **busy** (the board is authoritative here for the same reason
|
|
36
|
+
``coord.drive_queue`` rule 1 gives: a drive whose *observer* gave up leaves
|
|
37
|
+
the worker running and invisible to session counts);
|
|
38
|
+
* a **fired** deploy gate (:data:`~coord.drive_queue.HOLD_FIRED`) is
|
|
39
|
+
**not** busy — it is the opposite. `--hold-after` means "this entry landed
|
|
40
|
+
a change that crosses a deploy lane; stop launching until a human deploys
|
|
41
|
+
and releases it" (#1757). The queue has *deliberately stopped*. That is the
|
|
42
|
+
best propagation window there is, and propagation is precisely the deploy
|
|
43
|
+
the gate is waiting for. So a fired hold is an *invitation*, and a verified
|
|
44
|
+
propagation releases it (see :func:`holds_to_release`) — the gate stops the
|
|
45
|
+
queue for the deploy, propagation performs the deploy, propagation restarts
|
|
46
|
+
the queue. One mechanism, one loop, no second notion of quiescence.
|
|
47
|
+
|
|
48
|
+
QUIESCENCE IS PER-HOST, NOT FLEET-WIDE ALL-OR-NOTHING
|
|
49
|
+
------------------------------------------------------
|
|
50
|
+
#2067: every :class:`Busy` signal already carries the host it belongs to —
|
|
51
|
+
a drive-queue entry names the host that launched it, a live assignment
|
|
52
|
+
names the machine running it. Collapsing that to one fleet-wide boolean
|
|
53
|
+
(``not busy``) means a single continuously-running drive queue, on any one
|
|
54
|
+
host, defers propagation *forever*: the queue refills from its backlog
|
|
55
|
+
every few minutes, so at least one entry is running essentially always, and
|
|
56
|
+
"quiescent" never arrives. That is not a rare edge case, it is the steady
|
|
57
|
+
state of a working overnight queue — the busier and more useful the queue
|
|
58
|
+
is, the less this fleet-wide reading ever fires.
|
|
59
|
+
|
|
60
|
+
:attr:`Quiescence.quiescent`/``reason`` remain the fleet-wide summary (still
|
|
61
|
+
the right answer for `--force`, and for the one case that genuinely must
|
|
62
|
+
stay all-or-nothing — see below). A caller that wants to roll a
|
|
63
|
+
partially-busy fleet uses :meth:`Quiescence.rollable_hosts` instead: a host
|
|
64
|
+
with no busy signal against it is free to roll *now*, independent of
|
|
65
|
+
whatever else is running elsewhere. A signal that cannot be pinned to one
|
|
66
|
+
host (the board itself unreadable, a drive-queue row with no recorded
|
|
67
|
+
``launch_host``) is the one thing that still has to block everything —
|
|
68
|
+
see :attr:`Quiescence.fleet_wide_busy` — because there is no way to tell
|
|
69
|
+
"busy everywhere" apart from "busy on some host we can't name".
|
|
70
|
+
|
|
71
|
+
Per-host quiescence does not repeal the daemon-leads invariant below: if
|
|
72
|
+
the daemon host itself is occupied (and not already on the target), no
|
|
73
|
+
other host's python lane may roll ahead of it either, because that would
|
|
74
|
+
put a caller on a newer `coord` than the daemon it talks to — the
|
|
75
|
+
documented 405. The shell (`coord/commands/release.py`) enforces this by
|
|
76
|
+
deferring the whole run only in that one case; every other combination of
|
|
77
|
+
busy hosts rolls whatever it can and defers only what it can't.
|
|
78
|
+
|
|
79
|
+
LANE ORDER ANSWERS THE SKEW QUESTION
|
|
80
|
+
------------------------------------
|
|
81
|
+
#1835 asks whether a fleet mid-roll — hosts at two versions — is safe for
|
|
82
|
+
the board protocol, and insists on an explicit answer rather than an
|
|
83
|
+
assumption. It is safe **in one direction only**, and the direction is
|
|
84
|
+
already a documented failure: a *caller* on a new version calling a *daemon*
|
|
85
|
+
that predates the endpoint it wants gets a 405. New callers must therefore
|
|
86
|
+
never appear before the daemon can serve them.
|
|
87
|
+
|
|
88
|
+
:func:`plan_lanes` encodes that as a total order rather than leaving it to
|
|
89
|
+
whoever wrote the loop:
|
|
90
|
+
|
|
91
|
+
1. the **daemon host**'s Python lane first — it must lead, always;
|
|
92
|
+
2. every **other machine**'s Python lane;
|
|
93
|
+
3. each host's **systemd unit** lane (#1831 — `deploy/**` ships in the wheel
|
|
94
|
+
as ``coord/deploy/``, so this lane can only roll *after* that host's venv
|
|
95
|
+
swapped);
|
|
96
|
+
4. the **coord-tui** binaries last — a pure client of the board API, so it is
|
|
97
|
+
the one lane that is safe at any skew.
|
|
98
|
+
|
|
99
|
+
Propagation is therefore explicitly **not** all-or-nothing; it is ordered so
|
|
100
|
+
that every intermediate state is one the protocol already tolerates
|
|
101
|
+
(old caller → new daemon), and never the one it does not.
|
|
102
|
+
|
|
103
|
+
THE GATE'S SCOPE MUST MATCH PROPAGATION'S REACH
|
|
104
|
+
-----------------------------------------------
|
|
105
|
+
#2052: propagation gates its roll on ``coord release verify``, but verify
|
|
106
|
+
grades lanes propagation **cannot roll**. On 2026-08-09, the first run that
|
|
107
|
+
ever reached the verify step did everything it was capable of — three python
|
|
108
|
+
lanes, three unit lanes, the one ``coord-tui`` it could reach — and still
|
|
109
|
+
came back red, because verify also counted ``~/.coord-cli-venv`` (a lane this
|
|
110
|
+
module has zero references to), the two *remote* ``coord-tui`` binaries
|
|
111
|
+
(which propagation itself reports have no remote install path) and the
|
|
112
|
+
``coord-serve`` process (whose venv had swapped but whose process nothing
|
|
113
|
+
here restarts). ``--rollback-on-red`` then reverted its own good work — and
|
|
114
|
+
would have done so on every run, forever.
|
|
115
|
+
|
|
116
|
+
:func:`scope_verification` is the fix, and the rule it encodes is general:
|
|
117
|
+
**a verify gate must not be able to fail for reasons the thing it gates
|
|
118
|
+
cannot influence.** Findings on lanes this run attempted and could have moved
|
|
119
|
+
are *blocking*; findings on lanes with no channel are *advisory* — reported,
|
|
120
|
+
journalled in full, never grounds for a rollback. Rolling back a good python
|
|
121
|
+
roll because a per-host binary could not be installed remotely is a category
|
|
122
|
+
error, not a safety measure.
|
|
123
|
+
|
|
124
|
+
Advisory is emphatically not "ignored", and the exemption is an allow-list of
|
|
125
|
+
*known* gaps rather than "anything we failed to classify" — an unrecognised
|
|
126
|
+
lane keeps the gate. A check that quietly stopped checking is the failure
|
|
127
|
+
this whole module exists to prevent.
|
|
128
|
+
|
|
129
|
+
Worth naming on its own: this defect was invisible for five runs because
|
|
130
|
+
every one of them found live work and correctly deferred. The deferral path
|
|
131
|
+
was thoroughly tested by circumstance; the success path was not tested at
|
|
132
|
+
all. **A mechanism whose failure mode only appears on the happy path needs
|
|
133
|
+
its happy path tested first, not last.**
|
|
134
|
+
|
|
135
|
+
REACH MUST GROW TO MEET THE GATE, NOT THE OTHER WAY AROUND
|
|
136
|
+
------------------------------------------------------------
|
|
137
|
+
#2069 (split out of #2067, the window bug): #2052 correctly stopped a run
|
|
138
|
+
from being punished for lanes it could not move. It did not make those lanes
|
|
139
|
+
move — a run could restart nothing but ``coord-agent``, report success, and
|
|
140
|
+
leave ``coord-serve`` serving v0.5.8's ``review.py`` while every readout said
|
|
141
|
+
v0.5.13. Advisory is "not ignored", but nothing downstream *acts* on an
|
|
142
|
+
advisory finding either, so a half-deploy and a whole one produced the same
|
|
143
|
+
green result — quietly, because #2052 had (correctly) silenced the loud
|
|
144
|
+
wrong version of this failure.
|
|
145
|
+
|
|
146
|
+
The fix is not a bigger allow-list; it is closing the gaps :data:`
|
|
147
|
+
OUT_OF_REACH_LANES` was recording. ``coord-serve``, ``coord-web`` and
|
|
148
|
+
``coord-drive-queue`` now get restarted as the last step of their host's
|
|
149
|
+
python lane (see :data:`RESTARTED_BY_PYTHON_LANE` and ``coord/commands/
|
|
150
|
+
release.py``'s ``_roll_python``), so their ``<unit> spawns`` findings — and,
|
|
151
|
+
for the daemon specifically, its own ``coord-serve process`` version — are
|
|
152
|
+
graded exactly like ``coord-agent spawns`` always was: blocking when this run
|
|
153
|
+
attempted that host's python lane, advisory when it did not. Coverage and
|
|
154
|
+
enforcement move together on purpose: :func:`scope_verification` reads
|
|
155
|
+
:data:`OUT_OF_REACH_LANES` and :data:`RESTARTED_BY_PYTHON_LANE` directly, so
|
|
156
|
+
shrinking one of those sets is the whole patch — no second edit to the gate
|
|
157
|
+
logic is needed to re-arm it for a lane that just gained a channel.
|
|
158
|
+
|
|
159
|
+
``~/.coord-cli-venv`` and the two remote ``coord-tui`` binaries are still
|
|
160
|
+
open — #2069's fix 2 and fix 3, respectively. Neither has a channel yet, so
|
|
161
|
+
both stay in :data:`OUT_OF_REACH_LANES` (``coord-tui`` per-host is handled
|
|
162
|
+
separately, by :func:`lane_is_out_of_reach`'s "unrollable" path rather than
|
|
163
|
+
this set — see :func:`plan_lanes`'s LANE ORDER section).
|
|
164
|
+
|
|
165
|
+
PURITY
|
|
166
|
+
------
|
|
167
|
+
Nothing in this module runs a subprocess, opens a socket, touches the DB or
|
|
168
|
+
reads the clock — same split ``coord/drive_queue.py`` documents, for the same
|
|
169
|
+
reason: every bug worth catching lives in the decision half. The clock is
|
|
170
|
+
passed in. ``coord/commands/release.py`` is the I/O shell that gathers the
|
|
171
|
+
facts, calls in here, executes the plan and appends the journal.
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
from __future__ import annotations
|
|
175
|
+
|
|
176
|
+
import json
|
|
177
|
+
import re
|
|
178
|
+
from dataclasses import asdict, dataclass, field
|
|
179
|
+
from pathlib import Path
|
|
180
|
+
from typing import Any, Iterable, Mapping, Sequence
|
|
181
|
+
|
|
182
|
+
from coord.drive_queue import HOLD_FIRED, STATE_RUNNING, build_board_view, entry_key
|
|
183
|
+
|
|
184
|
+
# ── lane kinds ───────────────────────────────────────────────────────────────
|
|
185
|
+
#
|
|
186
|
+
# One string per *kind* of thing that has to move for the fleet to reach a
|
|
187
|
+
# version. These are the lanes #1835's precondition list names, and the same
|
|
188
|
+
# lanes `coord release verify` (#1834) grades afterwards. A release that
|
|
189
|
+
# propagates only `python` would have shipped #1543's `--dist` flag and none
|
|
190
|
+
# of its behaviour (that change was three unit files and a shell script).
|
|
191
|
+
LANE_PYTHON = "python"
|
|
192
|
+
LANE_UNITS = "units"
|
|
193
|
+
LANE_TUI = "tui"
|
|
194
|
+
|
|
195
|
+
#: Every lane this module knows how to roll, in no particular order — the
|
|
196
|
+
#: *order* is :func:`plan_lanes`'s output, not this tuple.
|
|
197
|
+
ALL_LANES: tuple[str, ...] = (LANE_PYTHON, LANE_UNITS, LANE_TUI)
|
|
198
|
+
|
|
199
|
+
# ── the gate's scope ─────────────────────────────────────────────────────────
|
|
200
|
+
#
|
|
201
|
+
# #2052: `coord release propagate` gated its roll on `coord release verify`,
|
|
202
|
+
# but verify grades lanes propagation **cannot roll**. On the first quiescent
|
|
203
|
+
# window the run did everything it was capable of — three python lanes, three
|
|
204
|
+
# unit lanes, the one coord-tui it could reach — and still came back red,
|
|
205
|
+
# because verify also counted `~/.coord-cli-venv` (a lane this module has no
|
|
206
|
+
# model of at all), the two remote `coord-tui` binaries (which propagation
|
|
207
|
+
# itself reports have NO remote install path), and the `coord-serve` process
|
|
208
|
+
# (whose venv had swapped but whose process nothing here restarts). With
|
|
209
|
+
# `--rollback-on-red` that reverted its own good work — every run, forever.
|
|
210
|
+
#
|
|
211
|
+
# The rule this section encodes: **a verify gate must not be able to fail for
|
|
212
|
+
# reasons the thing it gates cannot influence.** Findings on lanes this run
|
|
213
|
+
# actually attempted are BLOCKING; findings on lanes propagation has no
|
|
214
|
+
# channel for are ADVISORY — reported loudly, never grounds for a rollback.
|
|
215
|
+
#
|
|
216
|
+
# Advisory is not "ignored". An advisory crit is a real defect somebody must
|
|
217
|
+
# fix by hand; it is simply not evidence that *this roll* was bad, and
|
|
218
|
+
# reverting a good python roll because a per-host binary could not be
|
|
219
|
+
# installed remotely is a category error.
|
|
220
|
+
|
|
221
|
+
#: ``coord release verify`` lane name -> the propagation lane that could move
|
|
222
|
+
#: it. Exact names, matched against :func:`coord.release_verify.lanes_for_host`
|
|
223
|
+
#: and :func:`~coord.release_verify.findings_for_host`.
|
|
224
|
+
#:
|
|
225
|
+
#: ``coord-serve process`` (:func:`coord.release_verify.daemon_lanes`) is the
|
|
226
|
+
#: daemon's own introspected version — a different lane than ``coord-serve
|
|
227
|
+
#: spawns`` (what it would hand subprocesses), and the one #2069's restart
|
|
228
|
+
#: actually targets, so it needs its own exact entry rather than falling out
|
|
229
|
+
#: of the ``" spawns"`` suffix rule below.
|
|
230
|
+
_VERIFY_LANE_EXACT: dict[str, str] = {
|
|
231
|
+
"~/.coord-venv": LANE_PYTHON,
|
|
232
|
+
"coord-tui": LANE_TUI,
|
|
233
|
+
"coord-serve process": LANE_PYTHON,
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#: Units whose *live process* a python-lane roll actually replaces. ``POST
|
|
237
|
+
#: /update`` swaps the venv and re-execs **the agent**; #2069 closes the rest
|
|
238
|
+
#: of the gap it used to leave open — the same roll now also asks the agent
|
|
239
|
+
#: to ``systemctl --user restart`` every sibling unit it finds running on
|
|
240
|
+
#: that host (``POST /restart-services``, called right after ``/update``
|
|
241
|
+
#: lands — see ``coord/commands/release.py``'s ``_roll_python``). Before
|
|
242
|
+
#: #2069, ``coord-serve``, ``coord-web`` and ``coord-drive-queue`` kept
|
|
243
|
+
#: running the generation they started with until a human restarted them by
|
|
244
|
+
#: hand — exactly the third failure in #2052's run: "the venv swapped, but
|
|
245
|
+
#: the *process* had not been restarted at the moment verify ran". A host
|
|
246
|
+
#: that does not actually run one of these units is unaffected — which unit
|
|
247
|
+
#: to restart is read off that host's own ``spawned_coord`` facts (a
|
|
248
|
+
#: topology question), never assumed from this list.
|
|
249
|
+
RESTARTED_BY_PYTHON_LANE: frozenset[str] = frozenset(
|
|
250
|
+
{"coord-agent", "coord-serve", "coord-web", "coord-drive-queue"}
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
#: Lanes ``coord release verify`` grades that propagation has no channel for,
|
|
254
|
+
#: named individually so the exemption is a decision rather than a fallthrough.
|
|
255
|
+
#: ``~/.coord-cli-venv`` is the headline: `release_propagate.py` contains zero
|
|
256
|
+
#: references to it (#2069's fix 2 is still open — see the module docstring);
|
|
257
|
+
#: ``webapp bundle`` is SHA-versioned off a continuous timer and never
|
|
258
|
+
#: pip-versioned at all, so it has no "target version" to roll TO in the
|
|
259
|
+
#: first place. ``coord-serve process`` used to live here too — #2069 gave it
|
|
260
|
+
#: a channel (the restart above), so it now has its own entry in
|
|
261
|
+
#: :data:`_VERIFY_LANE_EXACT` instead, graded the same way every other
|
|
262
|
+
#: reachable lane is: blocking exactly when this run attempted that host's
|
|
263
|
+
#: python lane. Shrinking this set is exactly how new coverage is meant to
|
|
264
|
+
#: re-arm the gate (see the module docstring above).
|
|
265
|
+
OUT_OF_REACH_LANES: frozenset[str] = frozenset({"~/.coord-cli-venv", "webapp bundle"})
|
|
266
|
+
|
|
267
|
+
#: ``"~/.coord-venv (precision)"`` — the label `coord release verify` builds
|
|
268
|
+
#: for a lane, and the only form a grouped finding names its lanes by.
|
|
269
|
+
_LANE_LABEL = re.compile(r"^(?P<lane>.+?)\s+\((?P<host>[^()]+)\)$")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def parse_lane_label(label: str) -> tuple[str, str] | None:
|
|
273
|
+
"""``"~/.coord-venv (precision)"`` -> ``("precision", "~/.coord-venv")``.
|
|
274
|
+
|
|
275
|
+
``coord release verify`` groups an ``--expected`` mismatch into ONE
|
|
276
|
+
finding per offending version, naming its lanes as a comma-joined list of
|
|
277
|
+
these labels. Scoping the gate therefore has to take such a finding apart
|
|
278
|
+
again: "0.5.4, expected 0.5.8" across the CLI venv and the daemon process
|
|
279
|
+
is advisory, the same sentence across a host's ``~/.coord-venv`` is not.
|
|
280
|
+
"""
|
|
281
|
+
match = _LANE_LABEL.match(label.strip())
|
|
282
|
+
if not match:
|
|
283
|
+
return None
|
|
284
|
+
return match.group("host").strip(), match.group("lane").strip()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def verify_lane_kind(lane: str) -> str | None:
|
|
288
|
+
"""Which propagation lane could move ``coord release verify``'s *lane*.
|
|
289
|
+
|
|
290
|
+
``None`` means "no propagation lane moves this" — which is not the same
|
|
291
|
+
as "this lane is fine", only "this run is not what would fix it".
|
|
292
|
+
"""
|
|
293
|
+
name = lane.strip()
|
|
294
|
+
if name in _VERIFY_LANE_EXACT:
|
|
295
|
+
return _VERIFY_LANE_EXACT[name]
|
|
296
|
+
if name.startswith("unit "):
|
|
297
|
+
# `unit coord-agent.service` — the #1831 deploy/** lane, rolled by
|
|
298
|
+
# POST /deploy-units.
|
|
299
|
+
return LANE_UNITS
|
|
300
|
+
if name.endswith(" spawns"):
|
|
301
|
+
unit = name[: -len(" spawns")].strip()
|
|
302
|
+
return LANE_PYTHON if unit in RESTARTED_BY_PYTHON_LANE else None
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def lane_is_out_of_reach(lane: str) -> bool:
|
|
307
|
+
"""Is *lane* one propagation structurally cannot influence?
|
|
308
|
+
|
|
309
|
+
Deliberately an allow-list of *known* gaps rather than "anything
|
|
310
|
+
:func:`verify_lane_kind` didn't recognise". A lane nobody thought about
|
|
311
|
+
must keep the gate honest — the failure mode this whole module exists for
|
|
312
|
+
is a check that quietly stopped checking.
|
|
313
|
+
"""
|
|
314
|
+
name = lane.strip()
|
|
315
|
+
if name in OUT_OF_REACH_LANES:
|
|
316
|
+
return True
|
|
317
|
+
return name.endswith(" spawns") and verify_lane_kind(name) is None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
@dataclass(frozen=True)
|
|
321
|
+
class GateVerdict:
|
|
322
|
+
"""What the post-roll verification means *for this run*.
|
|
323
|
+
|
|
324
|
+
``severity`` is the worst **blocking** severity — the only thing
|
|
325
|
+
``--rollback-on-red`` may act on. ``advisory`` carries everything real but
|
|
326
|
+
out of reach, so scoping the gate never becomes hiding the finding.
|
|
327
|
+
"""
|
|
328
|
+
|
|
329
|
+
severity: str = "ok"
|
|
330
|
+
blocking: tuple[dict, ...] = ()
|
|
331
|
+
advisory: tuple[dict, ...] = ()
|
|
332
|
+
#: ``lane@host`` for every lane this run could not roll here at all.
|
|
333
|
+
unrollable: tuple[str, ...] = ()
|
|
334
|
+
|
|
335
|
+
@property
|
|
336
|
+
def red(self) -> bool:
|
|
337
|
+
return self.severity == "crit"
|
|
338
|
+
|
|
339
|
+
def to_dict(self) -> dict:
|
|
340
|
+
return {
|
|
341
|
+
"severity": self.severity,
|
|
342
|
+
"blocking": [dict(f) for f in self.blocking],
|
|
343
|
+
"advisory": [dict(f) for f in self.advisory],
|
|
344
|
+
"unrollable": list(self.unrollable),
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
#: Same ranking `coord release verify` uses; re-spelled rather than imported
|
|
349
|
+
#: to keep this module import-free of the verifier (the shell owns that seam).
|
|
350
|
+
_SEVERITY_RANK = {"ok": 0, "unknown": 1, "warn": 2, "crit": 3}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _finding_pairs(finding: Mapping[str, Any]) -> list[tuple[str, str]]:
|
|
354
|
+
"""``(host, lane)`` for every lane a verify finding actually speaks about."""
|
|
355
|
+
lane_field = str(finding.get("lane") or "")
|
|
356
|
+
pairs = [
|
|
357
|
+
parsed
|
|
358
|
+
for part in lane_field.split(", ")
|
|
359
|
+
if (parsed := parse_lane_label(part)) is not None
|
|
360
|
+
]
|
|
361
|
+
if pairs:
|
|
362
|
+
return pairs
|
|
363
|
+
return [(str(finding.get("host") or ""), lane_field)]
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def attempted_scope(lanes: Iterable[Mapping[str, Any]]) -> set[tuple[str, str]]:
|
|
367
|
+
"""``{(lane, host)}`` this run actually attempted and could have moved.
|
|
368
|
+
|
|
369
|
+
Read straight off the journalled lane records, so the gate's scope is
|
|
370
|
+
exactly what the history says the run did — not a second, parallel
|
|
371
|
+
account of it that could drift.
|
|
372
|
+
|
|
373
|
+
Excluded, on purpose:
|
|
374
|
+
|
|
375
|
+
* ``ok is None`` **without** a real attempt — a host skipped because the
|
|
376
|
+
daemon's python lane failed, or already on the target;
|
|
377
|
+
* anything flagged ``unrollable`` — a lane with no channel *on this host*
|
|
378
|
+
(the remote ``coord-tui``, an agent that predates ``/deploy-units``).
|
|
379
|
+
Attempting is not the same as being able to.
|
|
380
|
+
"""
|
|
381
|
+
scope: set[tuple[str, str]] = set()
|
|
382
|
+
for row in lanes:
|
|
383
|
+
lane = str(row.get("lane") or "")
|
|
384
|
+
host = str(row.get("host") or "")
|
|
385
|
+
if lane not in ALL_LANES or not host:
|
|
386
|
+
continue
|
|
387
|
+
if row.get("unrollable"):
|
|
388
|
+
continue
|
|
389
|
+
if row.get("ok") is None:
|
|
390
|
+
continue
|
|
391
|
+
scope.add((lane, host))
|
|
392
|
+
return scope
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def scope_verification(
|
|
396
|
+
verification: Mapping[str, Any] | None,
|
|
397
|
+
*,
|
|
398
|
+
lanes: Iterable[Mapping[str, Any]] = (),
|
|
399
|
+
) -> GateVerdict:
|
|
400
|
+
"""Split a ``coord release verify`` report into blocking and advisory.
|
|
401
|
+
|
|
402
|
+
*verification* is the report as :meth:`coord.release_verify.VerifyReport.
|
|
403
|
+
to_dict` renders it; *lanes* are this run's journalled lane records.
|
|
404
|
+
|
|
405
|
+
A finding is BLOCKING when any lane it names is one this run attempted
|
|
406
|
+
and could have moved, or when it names a lane nothing here recognises (an
|
|
407
|
+
unclassifiable finding must keep the gate honest — see
|
|
408
|
+
:func:`lane_is_out_of_reach`). Everything else is advisory.
|
|
409
|
+
"""
|
|
410
|
+
rows = list(lanes)
|
|
411
|
+
scope = attempted_scope(rows)
|
|
412
|
+
unrollable = tuple(
|
|
413
|
+
f"{row.get('lane')}@{row.get('host')}" for row in rows if row.get("unrollable")
|
|
414
|
+
)
|
|
415
|
+
if not verification:
|
|
416
|
+
return GateVerdict(unrollable=unrollable)
|
|
417
|
+
|
|
418
|
+
blocking: list[dict] = []
|
|
419
|
+
advisory: list[dict] = []
|
|
420
|
+
for finding in verification.get("findings") or []:
|
|
421
|
+
if not isinstance(finding, Mapping):
|
|
422
|
+
continue
|
|
423
|
+
pairs = _finding_pairs(finding)
|
|
424
|
+
holds = False
|
|
425
|
+
for host, lane in pairs:
|
|
426
|
+
kind = verify_lane_kind(lane)
|
|
427
|
+
if kind is not None:
|
|
428
|
+
holds = holds or (kind, host) in scope
|
|
429
|
+
elif not lane_is_out_of_reach(lane):
|
|
430
|
+
# Not a lane this module has an opinion about — e.g. an
|
|
431
|
+
# unreachable host's "(all lanes)", or a lane added to the
|
|
432
|
+
# verifier since. Fail toward keeping the gate.
|
|
433
|
+
holds = True
|
|
434
|
+
(blocking if holds else advisory).append(dict(finding))
|
|
435
|
+
|
|
436
|
+
severity = "ok"
|
|
437
|
+
for finding in blocking:
|
|
438
|
+
sev = str(finding.get("severity") or "unknown")
|
|
439
|
+
if _SEVERITY_RANK.get(sev, 1) > _SEVERITY_RANK[severity]:
|
|
440
|
+
severity = sev
|
|
441
|
+
return GateVerdict(
|
|
442
|
+
severity=severity,
|
|
443
|
+
blocking=tuple(blocking),
|
|
444
|
+
advisory=tuple(advisory),
|
|
445
|
+
unrollable=unrollable,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
# ── propagation outcomes ─────────────────────────────────────────────────────
|
|
450
|
+
#
|
|
451
|
+
# The status recorded in the journal for one propagation attempt. #1835:
|
|
452
|
+
# "a silent success is indistinguishable from a silent no-op, which is
|
|
453
|
+
# precisely how 2026-08-04 stayed invisible" — so *every* attempt appends a
|
|
454
|
+
# record, including the boring "deferred, fleet busy" ones. A timer that
|
|
455
|
+
# fired forty times and deferred forty times must look different from a
|
|
456
|
+
# timer that never fired at all.
|
|
457
|
+
STATUS_DEFERRED = "deferred"
|
|
458
|
+
STATUS_UP_TO_DATE = "up-to-date"
|
|
459
|
+
STATUS_ROLLED = "rolled"
|
|
460
|
+
STATUS_VERIFIED = "verified"
|
|
461
|
+
STATUS_ROLLED_BACK = "rolled-back"
|
|
462
|
+
STATUS_FAILED = "failed"
|
|
463
|
+
|
|
464
|
+
#: Statuses that mean "this attempt changed nothing on any host". Used by the
|
|
465
|
+
#: renderer to keep a long quiet night readable.
|
|
466
|
+
NO_OP_STATUSES: frozenset[str] = frozenset({STATUS_DEFERRED, STATUS_UP_TO_DATE})
|
|
467
|
+
|
|
468
|
+
#: Board assignment statuses that count as live work. Mirrors the set
|
|
469
|
+
#: ``coord/agent_app.py``'s ``/update`` refuses on, deliberately: propagation
|
|
470
|
+
#: must not schedule an update the agent would then refuse.
|
|
471
|
+
LIVE_ASSIGNMENT_STATUSES: frozenset[str] = frozenset({"RUNNING", "PENDING"})
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# ── busy signals ─────────────────────────────────────────────────────────────
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@dataclass(frozen=True)
|
|
478
|
+
class Busy:
|
|
479
|
+
"""One concrete reason the fleet is not quiescent right now.
|
|
480
|
+
|
|
481
|
+
Named down to the subject so a deferral is actionable prose ("dellserver
|
|
482
|
+
has 2 live assignments") rather than the useless "fleet busy" — a
|
|
483
|
+
deferral nobody can explain is a deferral nobody can distinguish from a
|
|
484
|
+
wedged timer.
|
|
485
|
+
"""
|
|
486
|
+
|
|
487
|
+
kind: str
|
|
488
|
+
subject: str
|
|
489
|
+
detail: str = ""
|
|
490
|
+
#: Which host this signal blocks, or ``None`` when it cannot be pinned
|
|
491
|
+
#: to one — a drive-queue entry launched before #1870 recorded
|
|
492
|
+
#: ``launch_host``, or a fleet-level fact like "the board itself is
|
|
493
|
+
#: unreadable" that says nothing about which host is actually busy.
|
|
494
|
+
#: #2067: an unattributable signal must fail toward blocking EVERY
|
|
495
|
+
#: host, never toward blocking none of them — see
|
|
496
|
+
#: :attr:`Quiescence.fleet_wide_busy`.
|
|
497
|
+
host: str | None = None
|
|
498
|
+
|
|
499
|
+
def describe(self) -> str:
|
|
500
|
+
base = f"{self.kind}: {self.subject}"
|
|
501
|
+
return f"{base} ({self.detail})" if self.detail else base
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
@dataclass(frozen=True)
|
|
505
|
+
class Quiescence:
|
|
506
|
+
"""Is there a window right now, and if not, where.
|
|
507
|
+
|
|
508
|
+
#2067: quiescence used to be one fleet-wide boolean, computed by
|
|
509
|
+
discarding the host every :class:`Busy` already carries. On a fleet
|
|
510
|
+
whose drive queue runs continuously, *some* host is busy essentially
|
|
511
|
+
always, so that boolean is false essentially always and propagation
|
|
512
|
+
never fires — "correctly, quietly, and uselessly". ``quiescent``/
|
|
513
|
+
``reason`` stay as the fleet-wide summary (still meaningful — an
|
|
514
|
+
unattributable signal or `--force` both want a single answer), but a
|
|
515
|
+
caller that wants to roll a partially-busy fleet should use
|
|
516
|
+
:meth:`rollable_hosts` instead.
|
|
517
|
+
"""
|
|
518
|
+
|
|
519
|
+
quiescent: bool
|
|
520
|
+
busy: tuple[Busy, ...] = ()
|
|
521
|
+
#: Fired deploy gates (#1757) found while assessing. Not busy signals —
|
|
522
|
+
#: see the module docstring. Carried through so the caller can release
|
|
523
|
+
#: them after a verified roll.
|
|
524
|
+
fired_holds: tuple[str, ...] = ()
|
|
525
|
+
#: #2110: `running` queue entries this assessment could DISPROVE — the
|
|
526
|
+
#: entry's own issue is landed (merged or closed) per the SAME board read
|
|
527
|
+
#: used everywhere else here, so the row cannot possibly still be
|
|
528
|
+
#: in-flight, whatever its `state` column says. Not a busy signal (the
|
|
529
|
+
#: whole point is that it does NOT block); not silent either (#1616's
|
|
530
|
+
#: "the pipeline has no clock" lesson) — surfaced here so a caller can
|
|
531
|
+
#: log it and point at `coord drive-queue tick --reconcile-only`, the
|
|
532
|
+
#: thing that actually clears the row, rather than the stale state
|
|
533
|
+
#: quietly evaporating from the reasoning with no record it was ever
|
|
534
|
+
#: wrong. See `_reconcile_running` in `coord/drive_queue.py` for the tick
|
|
535
|
+
#: doing the same disproof on its own cadence; this is the same evidence,
|
|
536
|
+
#: re-checked on READ so a stopped timer cannot make it unfalsifiable.
|
|
537
|
+
stale: tuple[str, ...] = ()
|
|
538
|
+
|
|
539
|
+
@property
|
|
540
|
+
def reason(self) -> str:
|
|
541
|
+
if self.quiescent:
|
|
542
|
+
if self.fired_holds:
|
|
543
|
+
return (
|
|
544
|
+
"quiescent — and "
|
|
545
|
+
f"{len(self.fired_holds)} deploy gate(s) are waiting on "
|
|
546
|
+
"exactly this deploy"
|
|
547
|
+
)
|
|
548
|
+
return "quiescent — nothing in flight"
|
|
549
|
+
return "; ".join(b.describe() for b in self.busy) or "busy"
|
|
550
|
+
|
|
551
|
+
@property
|
|
552
|
+
def fleet_wide_busy(self) -> tuple[Busy, ...]:
|
|
553
|
+
"""Busy signals that cannot be pinned to one host.
|
|
554
|
+
|
|
555
|
+
These block every host, not just the (nonexistent) one they name —
|
|
556
|
+
there is no way to know which host an unattributable signal
|
|
557
|
+
actually occupies, so the safe read is "all of them".
|
|
558
|
+
"""
|
|
559
|
+
return tuple(b for b in self.busy if b.host is None)
|
|
560
|
+
|
|
561
|
+
def busy_hosts(self) -> set[str]:
|
|
562
|
+
"""Every host a *specific* busy signal names.
|
|
563
|
+
|
|
564
|
+
Excludes unattributable signals — see :attr:`fleet_wide_busy` for
|
|
565
|
+
those; a caller must check both, which :meth:`rollable_hosts` does.
|
|
566
|
+
"""
|
|
567
|
+
return {b.host for b in self.busy if b.host}
|
|
568
|
+
|
|
569
|
+
def rollable_hosts(self, hosts: Iterable[str]) -> list[str]:
|
|
570
|
+
"""Which of *hosts* have no busy signal against them right now.
|
|
571
|
+
|
|
572
|
+
Empty whenever any busy signal cannot be attributed to a host: an
|
|
573
|
+
unreadable board, or a drive-queue entry with no recorded launch
|
|
574
|
+
host, means "the fleet is busy somewhere unknown", which is not
|
|
575
|
+
distinguishable from "everywhere" and must be treated as such.
|
|
576
|
+
"""
|
|
577
|
+
if self.fleet_wide_busy:
|
|
578
|
+
return []
|
|
579
|
+
occupied = self.busy_hosts()
|
|
580
|
+
return [h for h in hosts if h not in occupied]
|
|
581
|
+
|
|
582
|
+
def busy_reason_for_host(self, host: str) -> str:
|
|
583
|
+
"""Why *host* specifically is not rollable right now.
|
|
584
|
+
|
|
585
|
+
Empty string when nothing names it — the caller's cue that this
|
|
586
|
+
host is free.
|
|
587
|
+
"""
|
|
588
|
+
reasons = [b.describe() for b in self.busy if b.host is None or b.host == host]
|
|
589
|
+
return "; ".join(reasons)
|
|
590
|
+
|
|
591
|
+
def to_dict(self) -> dict:
|
|
592
|
+
return {
|
|
593
|
+
"quiescent": self.quiescent,
|
|
594
|
+
"reason": self.reason,
|
|
595
|
+
"busy": [asdict(b) for b in self.busy],
|
|
596
|
+
"fired_holds": list(self.fired_holds),
|
|
597
|
+
"stale": list(self.stale),
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _queue_key(entry: Mapping[str, Any]) -> str:
|
|
602
|
+
"""``repo#issue`` for a ``drive_queue`` row, however it reached us.
|
|
603
|
+
|
|
604
|
+
``/board`` publishes the sqlite columns verbatim (``repo_name`` /
|
|
605
|
+
``issue_number``); an already-rendered row may carry ``key``. Both are
|
|
606
|
+
accepted so this never silently degrades to ``"?"`` — a busy signal
|
|
607
|
+
nobody can name is a busy signal nobody can act on, and ``coord
|
|
608
|
+
drive-queue resume`` needs the real key to release the gate.
|
|
609
|
+
"""
|
|
610
|
+
key = entry.get("key")
|
|
611
|
+
if key:
|
|
612
|
+
return str(key)
|
|
613
|
+
repo = entry.get("repo_name") or entry.get("repo")
|
|
614
|
+
issue = entry.get("issue_number") or entry.get("issue")
|
|
615
|
+
if repo and issue is not None:
|
|
616
|
+
try:
|
|
617
|
+
return entry_key(str(repo), int(issue))
|
|
618
|
+
except (TypeError, ValueError):
|
|
619
|
+
return f"{repo}#{issue}"
|
|
620
|
+
return "?"
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def busy_host_for_entry(entry: Mapping[str, Any]) -> str | None:
|
|
624
|
+
"""Which host a ``running`` drive-queue row actually occupies (#2101).
|
|
625
|
+
|
|
626
|
+
#2067 attributed this to ``launch_host`` (#1870) — the host whose tick
|
|
627
|
+
launched the session — falling back to ``machine``. Measured on
|
|
628
|
+
2026-08-10, that reading pins the WHOLE fleet on every drive:
|
|
629
|
+
|
|
630
|
+
* the drive-queue tick runs on the timer host, so ``coord drive --tmux``
|
|
631
|
+
is spawned there and ``launch_host`` is *always* that host;
|
|
632
|
+
* that host is the daemon host, and the daemon must lead every roll, so
|
|
633
|
+
the one host it charges is the one host whose busyness defers every
|
|
634
|
+
other host's python lane (see the module docstring's LANE ORDER).
|
|
635
|
+
|
|
636
|
+
Net effect: **any drive anywhere pinned the entire fleet from rolling**,
|
|
637
|
+
which is fact 2 of #2101 and half of why the fleet sat eleven releases
|
|
638
|
+
behind. So the precedence is inverted: an entry pinned with ``--machine``
|
|
639
|
+
is charged to the machine that will actually run the WORKER, because the
|
|
640
|
+
worker is what an agent restart destroys (``coord/agent_app.py``'s
|
|
641
|
+
``/update`` refuses a host with live assignments for exactly that reason).
|
|
642
|
+
The launch host merely hosts an observer tmux session, and #2101's cordon
|
|
643
|
+
is what protects *it*: no new drive is launched onto a cordoned host at
|
|
644
|
+
all, so nothing new starts there while it waits to roll.
|
|
645
|
+
|
|
646
|
+
``launch_host`` remains the fallback for an unpinned (auto-picked) entry,
|
|
647
|
+
whose real worker machine is knowable only from the live assignment row —
|
|
648
|
+
and that row is a busy signal in its own right, attributed correctly, a
|
|
649
|
+
few lines below. Neither field present means the signal cannot be pinned
|
|
650
|
+
to a host at all and must block every host rather than none — see
|
|
651
|
+
:attr:`Quiescence.fleet_wide_busy`.
|
|
652
|
+
"""
|
|
653
|
+
return str(entry.get("machine") or entry.get("launch_host") or "") or None
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def assess_quiescence(
|
|
657
|
+
*,
|
|
658
|
+
queue_entries: Iterable[Mapping[str, Any]] = (),
|
|
659
|
+
assignments: Iterable[Mapping[str, Any]] = (),
|
|
660
|
+
issues: Iterable[Mapping[str, Any]] = (),
|
|
661
|
+
extra_busy: Iterable[Busy] = (),
|
|
662
|
+
) -> Quiescence:
|
|
663
|
+
"""Is the fleet idle enough to restart every agent on it?
|
|
664
|
+
|
|
665
|
+
*queue_entries* are ``drive_queue`` rows as they come off the board /
|
|
666
|
+
``coord drive-queue list --json``; *assignments* are board assignment
|
|
667
|
+
rows; *issues* are board issue rows (``repo_name``/``number``/``state``).
|
|
668
|
+
All three are read as plain mappings — #1523 §2's "typed state, never
|
|
669
|
+
CLI prose", the rule both bugs in the ad-hoc overnight sequencer broke.
|
|
670
|
+
|
|
671
|
+
``extra_busy`` is the seam for host-local signals the board cannot see
|
|
672
|
+
(an interactive tmux session, a machine paused by an operator); the
|
|
673
|
+
shell passes them in rather than this module growing a way to look.
|
|
674
|
+
|
|
675
|
+
#2110: a ``running`` queue row is not, on its own, proof of anything —
|
|
676
|
+
the reconciler that would have moved it to ``done`` lives inside
|
|
677
|
+
``coord drive-queue tick``, and a stopped timer means nothing ever runs
|
|
678
|
+
it. The 2026-08-10 incident deferred `coord release propagate` for over
|
|
679
|
+
an hour on a row describing a drive that had merged, closed and left no
|
|
680
|
+
trace anywhere on the fleet — the row was simply never re-examined. So
|
|
681
|
+
before trusting ``state == "running"`` this re-derives the SAME
|
|
682
|
+
disproof `coord.drive_queue._reconcile_running` uses on its own tick
|
|
683
|
+
(``coord.drive_queue.build_board_view(...).facts(key).landed`` — merged
|
|
684
|
+
or closed) against *this* read of the board, live at call time, rather
|
|
685
|
+
than only during a tick that may not be running right now. A row that
|
|
686
|
+
fails that check cannot possibly still be in flight, so it is excluded
|
|
687
|
+
from ``busy`` and reported in :attr:`Quiescence.stale` instead — visible,
|
|
688
|
+
not silently dropped (#1616).
|
|
689
|
+
|
|
690
|
+
This is deliberately narrower than the tick's own reconciliation: it has
|
|
691
|
+
no local tmux read (liveness is a LOCAL fact, #1870, and this may run on
|
|
692
|
+
any host) and no attempt-tracking, so it can only ever DISPROVE a
|
|
693
|
+
``running`` row, never retry or block one — that stays the tick's job.
|
|
694
|
+
It closes exactly the gap that let a landed row block a roll forever
|
|
695
|
+
with no clock and no way to contradict it.
|
|
696
|
+
"""
|
|
697
|
+
queue_entries = list(queue_entries)
|
|
698
|
+
assignments = list(assignments)
|
|
699
|
+
issues = list(issues)
|
|
700
|
+
board = build_board_view({"assignments": assignments, "issues": issues})
|
|
701
|
+
|
|
702
|
+
busy: list[Busy] = []
|
|
703
|
+
fired: list[str] = []
|
|
704
|
+
stale: list[str] = []
|
|
705
|
+
|
|
706
|
+
for entry in queue_entries:
|
|
707
|
+
state = str(entry.get("state") or "")
|
|
708
|
+
key = _queue_key(entry)
|
|
709
|
+
if state == STATE_RUNNING:
|
|
710
|
+
if board.facts(key).landed:
|
|
711
|
+
# Disproved: this issue is merged or closed, so the row
|
|
712
|
+
# cannot still be in flight whatever its `state` column
|
|
713
|
+
# says. Not busy — and not silently dropped either.
|
|
714
|
+
stale.append(key)
|
|
715
|
+
else:
|
|
716
|
+
host = busy_host_for_entry(entry)
|
|
717
|
+
busy.append(
|
|
718
|
+
Busy(
|
|
719
|
+
kind="drive-queue entry running",
|
|
720
|
+
subject=key,
|
|
721
|
+
detail="restarting agents now would kill it mid-flight",
|
|
722
|
+
host=host,
|
|
723
|
+
)
|
|
724
|
+
)
|
|
725
|
+
# A *fired* gate is the opposite of busy — the queue has stopped
|
|
726
|
+
# itself waiting for precisely this deploy. Recorded, never counted.
|
|
727
|
+
if str(entry.get("hold_state") or "") == HOLD_FIRED:
|
|
728
|
+
fired.append(key)
|
|
729
|
+
|
|
730
|
+
for row in assignments:
|
|
731
|
+
status = str(row.get("status") or "").upper()
|
|
732
|
+
if status not in LIVE_ASSIGNMENT_STATUSES:
|
|
733
|
+
continue
|
|
734
|
+
machine = str(row.get("machine_name") or row.get("machine") or "") or None
|
|
735
|
+
subject = str(
|
|
736
|
+
row.get("issue_number")
|
|
737
|
+
or row.get("issue")
|
|
738
|
+
or row.get("assignment_id")
|
|
739
|
+
or "?"
|
|
740
|
+
)
|
|
741
|
+
busy.append(
|
|
742
|
+
Busy(
|
|
743
|
+
kind=f"live {status} assignment",
|
|
744
|
+
subject=f"{machine or '?'}:{subject}",
|
|
745
|
+
detail="`coord agent update` would refuse this host anyway",
|
|
746
|
+
host=machine,
|
|
747
|
+
)
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
busy.extend(extra_busy)
|
|
751
|
+
return Quiescence(
|
|
752
|
+
quiescent=not busy,
|
|
753
|
+
busy=tuple(busy),
|
|
754
|
+
fired_holds=tuple(dict.fromkeys(fired)),
|
|
755
|
+
stale=tuple(dict.fromkeys(stale)),
|
|
756
|
+
)
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def holds_to_release(quiescence: Quiescence, *, verified: bool) -> tuple[str, ...]:
|
|
760
|
+
"""Which deploy gates a finished propagation should release (#1757).
|
|
761
|
+
|
|
762
|
+
Only after a **verified** roll. Releasing a gate on an unverified — or
|
|
763
|
+
rolled-back — propagation would restart the overnight queue into exactly
|
|
764
|
+
the "merged is not live" trap the gate exists to prevent, which is the
|
|
765
|
+
single most expensive recurring failure in this fleet.
|
|
766
|
+
"""
|
|
767
|
+
return quiescence.fired_holds if verified else ()
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
# ── the roll plan ────────────────────────────────────────────────────────────
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
@dataclass(frozen=True)
|
|
774
|
+
class LaneRoll:
|
|
775
|
+
"""One unit of propagation work: roll *lane* on *host* to a version."""
|
|
776
|
+
|
|
777
|
+
order: int
|
|
778
|
+
lane: str
|
|
779
|
+
host: str
|
|
780
|
+
#: Why this step sits where it does in the order. Rendered in `--dry-run`
|
|
781
|
+
#: and journalled, because the ordering is a protocol-safety argument and
|
|
782
|
+
#: an argument nobody can read is an argument nobody can check.
|
|
783
|
+
rationale: str = ""
|
|
784
|
+
|
|
785
|
+
@property
|
|
786
|
+
def label(self) -> str:
|
|
787
|
+
return f"{self.lane}@{self.host}"
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def plan_lanes(
|
|
791
|
+
*,
|
|
792
|
+
daemon_host: str | None,
|
|
793
|
+
hosts: Sequence[str],
|
|
794
|
+
lanes: Iterable[str] = ALL_LANES,
|
|
795
|
+
skip_hosts: Iterable[str] = (),
|
|
796
|
+
) -> list[LaneRoll]:
|
|
797
|
+
"""The total order in which lanes may roll. See the module docstring.
|
|
798
|
+
|
|
799
|
+
The invariant this function exists to hold: **the daemon never lags a
|
|
800
|
+
caller.** A host running a newer ``coord`` than the daemon it talks to
|
|
801
|
+
reproduces the documented 405 (caller wants an endpoint the daemon does
|
|
802
|
+
not serve yet); the reverse — a newer daemon serving older callers — is
|
|
803
|
+
the skew the board protocol is built to tolerate, since that is the
|
|
804
|
+
steady state between every release and every fleet update anyway.
|
|
805
|
+
|
|
806
|
+
*skip_hosts* drops hosts already on the target version, so a re-run after
|
|
807
|
+
a partial failure resumes rather than restarting.
|
|
808
|
+
"""
|
|
809
|
+
wanted = [lane for lane in ALL_LANES if lane in set(lanes)]
|
|
810
|
+
skip = set(skip_hosts)
|
|
811
|
+
ordered_hosts: list[str] = []
|
|
812
|
+
if daemon_host and daemon_host in hosts:
|
|
813
|
+
ordered_hosts.append(daemon_host)
|
|
814
|
+
ordered_hosts.extend(h for h in hosts if h != daemon_host)
|
|
815
|
+
|
|
816
|
+
rolls: list[LaneRoll] = []
|
|
817
|
+
order = 0
|
|
818
|
+
|
|
819
|
+
if LANE_PYTHON in wanted:
|
|
820
|
+
for host in ordered_hosts:
|
|
821
|
+
if host in skip:
|
|
822
|
+
continue
|
|
823
|
+
first = host == daemon_host
|
|
824
|
+
order += 1
|
|
825
|
+
rolls.append(
|
|
826
|
+
LaneRoll(
|
|
827
|
+
order=order,
|
|
828
|
+
lane=LANE_PYTHON,
|
|
829
|
+
host=host,
|
|
830
|
+
rationale=(
|
|
831
|
+
"daemon host leads: a caller must never reach an "
|
|
832
|
+
"endpoint its daemon predates (the documented 405)"
|
|
833
|
+
if first
|
|
834
|
+
else "callers follow the daemon, never lead it"
|
|
835
|
+
),
|
|
836
|
+
)
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
if LANE_UNITS in wanted:
|
|
840
|
+
for host in ordered_hosts:
|
|
841
|
+
if host in skip:
|
|
842
|
+
continue
|
|
843
|
+
order += 1
|
|
844
|
+
rolls.append(
|
|
845
|
+
LaneRoll(
|
|
846
|
+
order=order,
|
|
847
|
+
lane=LANE_UNITS,
|
|
848
|
+
host=host,
|
|
849
|
+
rationale=(
|
|
850
|
+
"#1831: the units ship inside the wheel as "
|
|
851
|
+
"coord/deploy/, so this host's venv must have "
|
|
852
|
+
"swapped first"
|
|
853
|
+
),
|
|
854
|
+
)
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
if LANE_TUI in wanted:
|
|
858
|
+
for host in ordered_hosts:
|
|
859
|
+
if host in skip:
|
|
860
|
+
continue
|
|
861
|
+
order += 1
|
|
862
|
+
rolls.append(
|
|
863
|
+
LaneRoll(
|
|
864
|
+
order=order,
|
|
865
|
+
lane=LANE_TUI,
|
|
866
|
+
host=host,
|
|
867
|
+
rationale=(
|
|
868
|
+
"coord-tui is a pure board-API client — safe at any "
|
|
869
|
+
"skew, so it goes last and can never block the fleet"
|
|
870
|
+
),
|
|
871
|
+
)
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
return rolls
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
def normalize_version(raw: str | None) -> str | None:
|
|
878
|
+
"""``v0.4.111`` / ``0.4.111`` -> ``0.4.111``; empty -> ``None``."""
|
|
879
|
+
if not raw:
|
|
880
|
+
return None
|
|
881
|
+
return str(raw).strip().lstrip("vV") or None
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def hosts_already_current(
|
|
885
|
+
lane_versions: Mapping[str, Iterable[str | None]], target: str | None
|
|
886
|
+
) -> list[str]:
|
|
887
|
+
"""Hosts whose every *known* lane already reports *target*.
|
|
888
|
+
|
|
889
|
+
A host with an unreadable lane is deliberately **not** current: #1834's
|
|
890
|
+
rule is that ``version=None`` means "no data", which is emphatically not
|
|
891
|
+
"agrees with everyone else". Skipping such a host would let the lane
|
|
892
|
+
nobody can see be the one that stays behind — the 2026-08-04 shape.
|
|
893
|
+
"""
|
|
894
|
+
want = normalize_version(target)
|
|
895
|
+
if not want:
|
|
896
|
+
return []
|
|
897
|
+
current: list[str] = []
|
|
898
|
+
for host, versions in lane_versions.items():
|
|
899
|
+
seen = list(versions)
|
|
900
|
+
if not seen or any(normalize_version(v) != want for v in seen):
|
|
901
|
+
continue
|
|
902
|
+
current.append(host)
|
|
903
|
+
return sorted(current)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
# ── the journal ──────────────────────────────────────────────────────────────
|
|
907
|
+
#
|
|
908
|
+
# #1835's fourth acceptance criterion: "the whole sequence is observable
|
|
909
|
+
# after the fact: what was published, when each lane rolled, what
|
|
910
|
+
# verification said." An append-only JSONL file, one object per attempt, on
|
|
911
|
+
# whichever host runs the propagation timer. Deliberately not a DB table:
|
|
912
|
+
# this record must survive a half-installed venv and be readable with `tail`
|
|
913
|
+
# while the very upgrade it describes is in flight — which is exactly when
|
|
914
|
+
# `coord` itself may not import.
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
#: Filename under the coord state root (``~/.coord`` on Linux — see
|
|
918
|
+
#: :func:`coord.platform_paths.default_coord_dir`).
|
|
919
|
+
JOURNAL_NAME = "release_propagation.jsonl"
|
|
920
|
+
|
|
921
|
+
#: Records kept when the journal is trimmed. Small enough to `cat`, long
|
|
922
|
+
#: enough to cover a week of a 15-minute timer's deferrals.
|
|
923
|
+
JOURNAL_MAX_RECORDS = 2000
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
@dataclass
|
|
927
|
+
class PropagationRecord:
|
|
928
|
+
"""One propagation attempt, start to finish, as journalled."""
|
|
929
|
+
|
|
930
|
+
started_at: float
|
|
931
|
+
target_version: str | None = None
|
|
932
|
+
status: str = STATUS_DEFERRED
|
|
933
|
+
quiescence: dict = field(default_factory=dict)
|
|
934
|
+
#: ``[{"lane":..., "host":..., "ok":..., "detail":...}, ...]``, in the
|
|
935
|
+
#: order they actually ran.
|
|
936
|
+
lanes: list[dict] = field(default_factory=list)
|
|
937
|
+
#: What `coord release verify` said, as its own JSON report. The WHOLE
|
|
938
|
+
#: report, always — scoping the gate (below) must never shrink the record.
|
|
939
|
+
verification: dict | None = None
|
|
940
|
+
#: :meth:`GateVerdict.to_dict` — which of those findings this run is
|
|
941
|
+
#: actually accountable for (#2052). This, not ``verification``, is what
|
|
942
|
+
#: ``--rollback-on-red`` acts on.
|
|
943
|
+
gate: dict | None = None
|
|
944
|
+
#: #2101: what this run did to the release-cordon store — which hosts it
|
|
945
|
+
#: cordoned so they would drain, which it uncordoned after rolling, which
|
|
946
|
+
#: cordons had lapsed on their own, and any drain-deadline escalation.
|
|
947
|
+
#: Journalled for the same reason everything else here is: a run that
|
|
948
|
+
#: cordoned the fleet and then died must leave a readable trace of having
|
|
949
|
+
#: done so, or the resulting quiet fleet is indistinguishable from #2082.
|
|
950
|
+
cordons: dict = field(default_factory=dict)
|
|
951
|
+
rolled_back: list[str] = field(default_factory=list)
|
|
952
|
+
released_holds: list[str] = field(default_factory=list)
|
|
953
|
+
finished_at: float | None = None
|
|
954
|
+
error: str | None = None
|
|
955
|
+
dry_run: bool = False
|
|
956
|
+
|
|
957
|
+
def to_dict(self) -> dict:
|
|
958
|
+
return asdict(self)
|
|
959
|
+
|
|
960
|
+
@property
|
|
961
|
+
def ok(self) -> bool:
|
|
962
|
+
return self.status in (STATUS_VERIFIED, STATUS_DEFERRED, STATUS_UP_TO_DATE)
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def journal_path(state_dir: Path) -> Path:
|
|
966
|
+
return Path(state_dir) / JOURNAL_NAME
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def append_record(state_dir: Path, record: PropagationRecord) -> Path:
|
|
970
|
+
"""Append *record* as one JSON line. Best effort by contract.
|
|
971
|
+
|
|
972
|
+
A propagation must never fail *because* it could not write its own
|
|
973
|
+
diary, but a silently-unwritten diary is the 2026-08-04 shape, so the
|
|
974
|
+
caller is told (by the raised error propagating out of here only for
|
|
975
|
+
genuinely unexpected types) — see the shell, which reports a write
|
|
976
|
+
failure as a warning line and still exits on the real outcome.
|
|
977
|
+
"""
|
|
978
|
+
path = journal_path(state_dir)
|
|
979
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
980
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
981
|
+
fh.write(json.dumps(record.to_dict(), sort_keys=True) + "\n")
|
|
982
|
+
return path
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def read_records(state_dir: Path, *, limit: int | None = None) -> list[dict]:
|
|
986
|
+
"""Most-recent-last records from the journal; unparseable lines skipped.
|
|
987
|
+
|
|
988
|
+
A torn final line (the process died mid-append) must not make the whole
|
|
989
|
+
history unreadable — the history is most valuable in exactly that case.
|
|
990
|
+
"""
|
|
991
|
+
path = journal_path(state_dir)
|
|
992
|
+
if not path.exists():
|
|
993
|
+
return []
|
|
994
|
+
out: list[dict] = []
|
|
995
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
996
|
+
line = line.strip()
|
|
997
|
+
if not line:
|
|
998
|
+
continue
|
|
999
|
+
try:
|
|
1000
|
+
obj = json.loads(line)
|
|
1001
|
+
except ValueError:
|
|
1002
|
+
continue
|
|
1003
|
+
if isinstance(obj, dict):
|
|
1004
|
+
out.append(obj)
|
|
1005
|
+
if limit is not None and limit >= 0:
|
|
1006
|
+
out = out[-limit:]
|
|
1007
|
+
return out
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def trim_journal(state_dir: Path, *, keep: int = JOURNAL_MAX_RECORDS) -> int:
|
|
1011
|
+
"""Truncate the journal to its last *keep* records. Returns records kept."""
|
|
1012
|
+
records = read_records(state_dir)
|
|
1013
|
+
if len(records) <= keep:
|
|
1014
|
+
return len(records)
|
|
1015
|
+
kept = records[-keep:]
|
|
1016
|
+
path = journal_path(state_dir)
|
|
1017
|
+
path.write_text(
|
|
1018
|
+
"".join(json.dumps(r, sort_keys=True) + "\n" for r in kept), encoding="utf-8"
|
|
1019
|
+
)
|
|
1020
|
+
return len(kept)
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
# ── rendering ────────────────────────────────────────────────────────────────
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def _stamp(ts: float | None) -> str:
|
|
1027
|
+
if not ts:
|
|
1028
|
+
return "?"
|
|
1029
|
+
import datetime as _dt # noqa: PLC0415 — leaf import, keeps the module light
|
|
1030
|
+
|
|
1031
|
+
return _dt.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
_STATUS_MARK = {
|
|
1035
|
+
STATUS_VERIFIED: "✓",
|
|
1036
|
+
STATUS_UP_TO_DATE: "=",
|
|
1037
|
+
STATUS_DEFERRED: "·",
|
|
1038
|
+
STATUS_ROLLED: "~",
|
|
1039
|
+
STATUS_ROLLED_BACK: "↩",
|
|
1040
|
+
STATUS_FAILED: "✗",
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def render_record(record: PropagationRecord | Mapping[str, Any]) -> list[str]:
|
|
1045
|
+
"""Human-readable lines for one attempt."""
|
|
1046
|
+
data = record.to_dict() if isinstance(record, PropagationRecord) else dict(record)
|
|
1047
|
+
status = str(data.get("status") or "?")
|
|
1048
|
+
mark = _STATUS_MARK.get(status, "?")
|
|
1049
|
+
version = data.get("target_version") or "?"
|
|
1050
|
+
prefix = "[dry-run] " if data.get("dry_run") else ""
|
|
1051
|
+
lines = [
|
|
1052
|
+
f"{mark} {prefix}{_stamp(data.get('started_at'))} v{version} {status}"
|
|
1053
|
+
]
|
|
1054
|
+
|
|
1055
|
+
quiescence = data.get("quiescence") or {}
|
|
1056
|
+
if quiescence.get("reason"):
|
|
1057
|
+
lines.append(f" window: {quiescence['reason']}")
|
|
1058
|
+
|
|
1059
|
+
for lane in data.get("lanes") or []:
|
|
1060
|
+
ok = lane.get("ok")
|
|
1061
|
+
lane_mark = "✓" if ok else ("·" if ok is None else "✗")
|
|
1062
|
+
detail = lane.get("detail") or ""
|
|
1063
|
+
lines.append(
|
|
1064
|
+
f" {lane_mark} {lane.get('lane')}@{lane.get('host')}"
|
|
1065
|
+
+ (f" — {detail}" if detail else "")
|
|
1066
|
+
)
|
|
1067
|
+
|
|
1068
|
+
verification = data.get("verification")
|
|
1069
|
+
if verification:
|
|
1070
|
+
sev = verification.get("severity", "?")
|
|
1071
|
+
findings = verification.get("findings") or []
|
|
1072
|
+
lines.append(
|
|
1073
|
+
f" verify: {sev} ({len(findings)} finding(s))"
|
|
1074
|
+
)
|
|
1075
|
+
for finding in findings[:5]:
|
|
1076
|
+
lines.append(
|
|
1077
|
+
f" - [{finding.get('severity')}] {finding.get('host')} "
|
|
1078
|
+
f"{finding.get('lane')}: {finding.get('summary')}"
|
|
1079
|
+
)
|
|
1080
|
+
|
|
1081
|
+
# #2052: the gate's scope is part of the answer, not debug output. A run
|
|
1082
|
+
# that was held to lanes it could not roll is exactly the defect this
|
|
1083
|
+
# block exists to make visible the next time it happens.
|
|
1084
|
+
gate = data.get("gate")
|
|
1085
|
+
if gate:
|
|
1086
|
+
blocking = gate.get("blocking") or []
|
|
1087
|
+
advisory = gate.get("advisory") or []
|
|
1088
|
+
lines.append(
|
|
1089
|
+
f" gate: {gate.get('severity', '?')} "
|
|
1090
|
+
f"({len(blocking)} blocking, {len(advisory)} advisory — "
|
|
1091
|
+
"advisory lanes are ones propagation cannot roll)"
|
|
1092
|
+
)
|
|
1093
|
+
for finding in advisory[:5]:
|
|
1094
|
+
lines.append(
|
|
1095
|
+
f" ~ advisory [{finding.get('severity')}] "
|
|
1096
|
+
f"{finding.get('host')} {finding.get('lane')}: "
|
|
1097
|
+
f"{finding.get('summary')}"
|
|
1098
|
+
)
|
|
1099
|
+
if gate.get("unrollable"):
|
|
1100
|
+
lines.append(
|
|
1101
|
+
" ~ no channel from here: "
|
|
1102
|
+
+ ", ".join(gate["unrollable"])
|
|
1103
|
+
)
|
|
1104
|
+
|
|
1105
|
+
# #2101: the cordon is the thing that CREATED this run's window (or is
|
|
1106
|
+
# still creating it), so it belongs in the record's headline lines, not
|
|
1107
|
+
# only in the JSON. A deferral that also cordoned reads completely
|
|
1108
|
+
# differently from one that just gave up.
|
|
1109
|
+
cordons = data.get("cordons") or {}
|
|
1110
|
+
if cordons.get("cordoned"):
|
|
1111
|
+
lines.append(
|
|
1112
|
+
" cordoned (draining to roll): " + ", ".join(cordons["cordoned"])
|
|
1113
|
+
)
|
|
1114
|
+
if cordons.get("uncordoned"):
|
|
1115
|
+
lines.append(" uncordoned: " + ", ".join(cordons["uncordoned"]))
|
|
1116
|
+
if cordons.get("expired"):
|
|
1117
|
+
lines.append(
|
|
1118
|
+
" cordons that lapsed on their own: " + ", ".join(cordons["expired"])
|
|
1119
|
+
)
|
|
1120
|
+
for esc in cordons.get("escalated") or []:
|
|
1121
|
+
lines.append(f" ! {esc.get('message') or esc}")
|
|
1122
|
+
for err in cordons.get("errors") or []:
|
|
1123
|
+
lines.append(f" cordon error: {err}")
|
|
1124
|
+
|
|
1125
|
+
if data.get("rolled_back"):
|
|
1126
|
+
lines.append(f" rolled back: {', '.join(data['rolled_back'])}")
|
|
1127
|
+
if data.get("released_holds"):
|
|
1128
|
+
lines.append(f" released deploy gates: {', '.join(data['released_holds'])}")
|
|
1129
|
+
if data.get("error"):
|
|
1130
|
+
lines.append(f" error: {data['error']}")
|
|
1131
|
+
return lines
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def render_history(records: Sequence[Mapping[str, Any]], *, verbose: bool = False) -> str:
|
|
1135
|
+
"""The `coord release history` body.
|
|
1136
|
+
|
|
1137
|
+
Without *verbose*, consecutive no-op attempts (deferred / already
|
|
1138
|
+
up-to-date) collapse to one summary line — a 15-minute timer produces
|
|
1139
|
+
~96 of those a day and a history nobody can skim is a history nobody
|
|
1140
|
+
reads. The count is always printed: #1835's "a silent success is
|
|
1141
|
+
indistinguishable from a silent no-op" cuts both ways, so the no-ops are
|
|
1142
|
+
summarised, never dropped.
|
|
1143
|
+
"""
|
|
1144
|
+
if not records:
|
|
1145
|
+
return (
|
|
1146
|
+
"no propagation attempts recorded yet — if the timer is supposed "
|
|
1147
|
+
"to be running, that is itself the finding (see `systemctl --user "
|
|
1148
|
+
"status coord-release-propagate.timer`)"
|
|
1149
|
+
)
|
|
1150
|
+
lines: list[str] = []
|
|
1151
|
+
run: list[Mapping[str, Any]] = []
|
|
1152
|
+
|
|
1153
|
+
def _flush() -> None:
|
|
1154
|
+
if not run:
|
|
1155
|
+
return
|
|
1156
|
+
first, last = run[0], run[-1]
|
|
1157
|
+
if len(run) == 1:
|
|
1158
|
+
lines.extend(render_record(first))
|
|
1159
|
+
else:
|
|
1160
|
+
lines.append(
|
|
1161
|
+
f"· {_stamp(first.get('started_at'))} .. "
|
|
1162
|
+
f"{_stamp(last.get('started_at'))} "
|
|
1163
|
+
f"{len(run)} no-op attempt(s) "
|
|
1164
|
+
f"(last: {last.get('status')} — "
|
|
1165
|
+
f"{(last.get('quiescence') or {}).get('reason', '?')})"
|
|
1166
|
+
)
|
|
1167
|
+
run.clear()
|
|
1168
|
+
|
|
1169
|
+
for record in records:
|
|
1170
|
+
if not verbose and str(record.get("status")) in NO_OP_STATUSES:
|
|
1171
|
+
run.append(record)
|
|
1172
|
+
continue
|
|
1173
|
+
_flush()
|
|
1174
|
+
lines.extend(render_record(record))
|
|
1175
|
+
_flush()
|
|
1176
|
+
return "\n".join(lines)
|