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/release_cordon.py
ADDED
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
"""Release cordons: **create** a propagation window instead of waiting for one
|
|
2
|
+
(#2101).
|
|
3
|
+
|
|
4
|
+
`coord release propagate` (#1835/#2067) waits for fleet quiescence. On a fleet
|
|
5
|
+
whose drive queue refills every three minutes, that window is a coincidence —
|
|
6
|
+
and two facts make the coincidence rare enough to never arrive:
|
|
7
|
+
|
|
8
|
+
1. the daemon host may not be rolled while it is busy (a caller on a newer
|
|
9
|
+
``coord`` than its daemon is the documented 405), and nothing may roll
|
|
10
|
+
ahead of the daemon, so **a busy daemon host defers the whole fleet**;
|
|
11
|
+
2. every drive-queue entry charges *some* host as busy for the entire life of
|
|
12
|
+
the drive, and the queue relaunches on a 3-minute tick.
|
|
13
|
+
|
|
14
|
+
Observed on 2026-08-10: the fleet sat eleven releases behind for a day with
|
|
15
|
+
elitebook idle and rollable the whole time.
|
|
16
|
+
|
|
17
|
+
This module is the decision half of the fix. Waiting is replaced by a loop
|
|
18
|
+
that manufactures the window:
|
|
19
|
+
|
|
20
|
+
detect → **cordon** each behind host → drain (nothing is killed) →
|
|
21
|
+
roll the moment it empties → **uncordon immediately** → repeat.
|
|
22
|
+
|
|
23
|
+
THE CORDON IS A ROUTING PAUSE WITH AN OWNER
|
|
24
|
+
--------------------------------------------
|
|
25
|
+
`coord pause` already means exactly "no NEW agents route here; in-flight work
|
|
26
|
+
is untouched" (#1563 made it daemon-backed, which is the only reason this is
|
|
27
|
+
buildable at all). A cordon reuses that routing semantics — see
|
|
28
|
+
:mod:`coord.machine_pause`, which folds active cordons into the one
|
|
29
|
+
``paused_set()`` every dispatcher already consults — but it is emphatically
|
|
30
|
+
**not** the same flag:
|
|
31
|
+
|
|
32
|
+
* an operator's ``coord unpause`` must not lift a cordon mid-drain;
|
|
33
|
+
* the post-roll uncordon must not clear a pause an operator set deliberately.
|
|
34
|
+
|
|
35
|
+
So a cordon is stored under its own key with an ``owner``, and each side
|
|
36
|
+
clears only its own (:func:`coord.machine_pause.clear_cordon` vs
|
|
37
|
+
``local_unpause``). Trap A of #2101.
|
|
38
|
+
|
|
39
|
+
EVERY CORDON EXPIRES, BECAUSE THE THING THAT SET IT GETS RESTARTED
|
|
40
|
+
-------------------------------------------------------------------
|
|
41
|
+
The cordon lives in daemon state and the daemon itself is restarted by the
|
|
42
|
+
roll it is gating. A propagate run killed between "cordon" and "uncordon"
|
|
43
|
+
would otherwise leave every machine refusing work forever — which looks
|
|
44
|
+
exactly like a quiet fleet, i.e. #2082 in a new costume. Trap B.
|
|
45
|
+
|
|
46
|
+
So a :class:`Cordon` carries ``owner``, ``reason``, ``created_at`` and
|
|
47
|
+
``expires_at``, and **the read side ignores an expired record** (nothing has
|
|
48
|
+
to run for it to lapse — a dead propagate loop cannot fail to clean up,
|
|
49
|
+
because cleanup is not an action). The live loop renews on every run while
|
|
50
|
+
the host is still behind, so a TTL comfortably longer than the propagate
|
|
51
|
+
timer's interval is invisible in normal operation and self-healing after a
|
|
52
|
+
crash.
|
|
53
|
+
|
|
54
|
+
A HOST THAT NEVER DRAINS IS AN ESCALATION, NEVER A SILENT WAIT
|
|
55
|
+
---------------------------------------------------------------
|
|
56
|
+
A wedged worker means the cordon never lifts. :func:`plan_cordons` measures
|
|
57
|
+
the drain from ``created_at`` (preserved across renewals — a renewal is not
|
|
58
|
+
a new cordon) and, past :data:`DEFAULT_DRAIN_DEADLINE_SECONDS`, emits a
|
|
59
|
+
:class:`DrainEscalation`. The cordon is still renewed — the host really is
|
|
60
|
+
behind, and lifting it would just start work that the next run has to drain
|
|
61
|
+
again — but it is now loud, and its message names the override. Trap C.
|
|
62
|
+
|
|
63
|
+
THE TRIGGER IS COUPLED TO RELEASE FREQUENCY, SO IT IS A KNOB
|
|
64
|
+
--------------------------------------------------------------
|
|
65
|
+
Cordon-on-any-drift costs one fleet drain per release. Before #2081 landed,
|
|
66
|
+
releases cut roughly every 40 minutes — at that cadence this mechanism would
|
|
67
|
+
leave the fleet draining more often than working. #2081 reduced the cadence,
|
|
68
|
+
so the **default is any drift** (:data:`DEFAULT_DRIFT_THRESHOLD` = 1): a
|
|
69
|
+
fleet that is one release behind is a fleet running code nobody is testing,
|
|
70
|
+
and #2082 is the cost of tolerating that. The threshold is a knob
|
|
71
|
+
(``coord release propagate --cordon-after N``) precisely so a future cadence
|
|
72
|
+
change does not need a code change. Trap F.
|
|
73
|
+
|
|
74
|
+
PURITY
|
|
75
|
+
------
|
|
76
|
+
Nothing here reads the clock, the filesystem, the network or the DB — same
|
|
77
|
+
split :mod:`coord.release_propagate` documents, for the same reason. The
|
|
78
|
+
clock is passed in; ``coord/commands/release.py`` is the I/O shell.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
from __future__ import annotations
|
|
82
|
+
|
|
83
|
+
from dataclasses import dataclass, field
|
|
84
|
+
from typing import Any, Iterable, Mapping
|
|
85
|
+
|
|
86
|
+
#: Who set a cordon. An operator's ``coord pause`` is NOT an owner of any
|
|
87
|
+
#: cordon — it lives under a different key entirely (see the module docstring
|
|
88
|
+
#: and :mod:`coord.machine_pause`). This exists so a future second automated
|
|
89
|
+
#: owner (a deploy gate, a maintenance window) can share the mechanism
|
|
90
|
+
#: without either being able to clear the other's flag.
|
|
91
|
+
OWNER_RELEASE = "release"
|
|
92
|
+
|
|
93
|
+
#: How long a cordon record stays effective without being renewed. The
|
|
94
|
+
#: propagate timer runs every 15-20 minutes and renews on every run while the
|
|
95
|
+
#: host is still behind, so this is invisible in normal operation; what it
|
|
96
|
+
#: bounds is the crash case (trap B): a run killed between cordon and roll
|
|
97
|
+
#: leaves the fleet refusing work for at most this long, with no cleanup
|
|
98
|
+
#: process required — an expired record is ignored on READ.
|
|
99
|
+
DEFAULT_TTL_SECONDS = 3600.0
|
|
100
|
+
|
|
101
|
+
#: How long a host may fail to drain before the cordon escalates (trap C).
|
|
102
|
+
#: Measured from the cordon's ``created_at``, which survives renewal — a
|
|
103
|
+
#: renewal is the same cordon, not a new one, so a wedged host cannot reset
|
|
104
|
+
#: its own deadline by being cordoned again. Deliberately longer than a
|
|
105
|
+
#: normal drive (the thing being drained) and shorter than a night.
|
|
106
|
+
DEFAULT_DRAIN_DEADLINE_SECONDS = 5400.0
|
|
107
|
+
|
|
108
|
+
#: How many releases behind a host must be before it is cordoned. 1 = any
|
|
109
|
+
#: drift. See the module docstring's trap-F section for why this is a knob.
|
|
110
|
+
DEFAULT_DRIFT_THRESHOLD = 1
|
|
111
|
+
|
|
112
|
+
#: Returned by :func:`version_drift` when a host's version cannot be compared
|
|
113
|
+
#: to the target at all (unreadable lane, or a different minor series). A host
|
|
114
|
+
#: whose version we cannot read is NEVER cordoned — cordoning stops real work,
|
|
115
|
+
#: and doing that on a guess is the failure this fleet keeps repeating.
|
|
116
|
+
DRIFT_UNKNOWN = None
|
|
117
|
+
|
|
118
|
+
#: The drift reported for a host on a different ``major.minor`` series than
|
|
119
|
+
#: the target: larger than any sane threshold, because it genuinely is.
|
|
120
|
+
CROSS_SERIES_DRIFT = 9999
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def normalize_version(raw: str | None) -> str | None:
|
|
124
|
+
"""``v0.5.31`` / ``0.5.31`` -> ``0.5.31``; empty -> ``None``."""
|
|
125
|
+
if not raw:
|
|
126
|
+
return None
|
|
127
|
+
return str(raw).strip().lstrip("vV") or None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _parts(raw: str | None) -> tuple[int, ...] | None:
|
|
131
|
+
version = normalize_version(raw)
|
|
132
|
+
if not version:
|
|
133
|
+
return None
|
|
134
|
+
out: list[int] = []
|
|
135
|
+
for chunk in version.split("."):
|
|
136
|
+
digits = ""
|
|
137
|
+
for ch in chunk:
|
|
138
|
+
if not ch.isdigit():
|
|
139
|
+
break
|
|
140
|
+
digits += ch
|
|
141
|
+
if not digits:
|
|
142
|
+
break
|
|
143
|
+
out.append(int(digits))
|
|
144
|
+
return tuple(out) if out else None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def version_drift(current: str | None, target: str | None) -> int | None:
|
|
148
|
+
"""How many releases *current* is behind *target*.
|
|
149
|
+
|
|
150
|
+
``0`` means level or ahead; ``None`` (:data:`DRIFT_UNKNOWN`) means the two
|
|
151
|
+
cannot be compared — an unreadable version, or a target on a different
|
|
152
|
+
``major.minor`` series, where "how many releases" has no answer from the
|
|
153
|
+
two strings alone.
|
|
154
|
+
|
|
155
|
+
Deliberately arithmetic on the patch component rather than a lookup
|
|
156
|
+
against the index: this runs on every propagate tick, and a decision that
|
|
157
|
+
needs a network call to be made is a decision that stops being made the
|
|
158
|
+
moment the network hiccups. A cross-minor gap reports
|
|
159
|
+
:data:`CROSS_SERIES_DRIFT` — "definitely behind, by more than any
|
|
160
|
+
threshold" — which is the only honest reading of ``0.4.x`` vs ``0.5.y``.
|
|
161
|
+
"""
|
|
162
|
+
a, b = _parts(current), _parts(target)
|
|
163
|
+
if a is None or b is None:
|
|
164
|
+
return DRIFT_UNKNOWN
|
|
165
|
+
if a >= b:
|
|
166
|
+
return 0
|
|
167
|
+
if a[:2] != b[:2]:
|
|
168
|
+
return CROSS_SERIES_DRIFT
|
|
169
|
+
patch_a = a[2] if len(a) > 2 else 0
|
|
170
|
+
patch_b = b[2] if len(b) > 2 else 0
|
|
171
|
+
return max(0, patch_b - patch_a)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass(frozen=True)
|
|
175
|
+
class Cordon:
|
|
176
|
+
"""One machine's release cordon, as stored and as read back.
|
|
177
|
+
|
|
178
|
+
``created_at`` is the moment the host was FIRST cordoned for this drain
|
|
179
|
+
and is preserved across renewals — the drain deadline (trap C) measures
|
|
180
|
+
from it, so a wedged host cannot postpone its own escalation forever by
|
|
181
|
+
being renewed. ``renewed_at``/``expires_at`` move on every renewal.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
machine: str
|
|
185
|
+
owner: str = OWNER_RELEASE
|
|
186
|
+
reason: str = ""
|
|
187
|
+
target_version: str | None = None
|
|
188
|
+
created_at: float = 0.0
|
|
189
|
+
renewed_at: float = 0.0
|
|
190
|
+
expires_at: float = 0.0
|
|
191
|
+
|
|
192
|
+
def active(self, now: float) -> bool:
|
|
193
|
+
"""Is this record still in force at *now*?
|
|
194
|
+
|
|
195
|
+
An ``expires_at`` of 0 (a hand-written record with no expiry) is
|
|
196
|
+
treated as ACTIVE — a cordon nobody can express an expiry for is
|
|
197
|
+
still a cordon — but every record this module writes has one.
|
|
198
|
+
"""
|
|
199
|
+
return not self.expires_at or now < self.expires_at
|
|
200
|
+
|
|
201
|
+
def expired(self, now: float) -> bool:
|
|
202
|
+
return not self.active(now)
|
|
203
|
+
|
|
204
|
+
def age(self, now: float) -> float:
|
|
205
|
+
return max(0.0, now - self.created_at) if self.created_at else 0.0
|
|
206
|
+
|
|
207
|
+
def overdue(self, now: float, deadline: float = DEFAULT_DRAIN_DEADLINE_SECONDS) -> bool:
|
|
208
|
+
"""Has this host failed to drain within *deadline* seconds?"""
|
|
209
|
+
return bool(self.created_at) and self.age(now) >= deadline > 0
|
|
210
|
+
|
|
211
|
+
def describe(self) -> str:
|
|
212
|
+
"""The one sentence every surface shows (#2101 trap E).
|
|
213
|
+
|
|
214
|
+
Work stopping with no stated reason is the thing this fleet keeps
|
|
215
|
+
doing to itself, so this is deliberately a whole explanation rather
|
|
216
|
+
than a status word: ``cordoned: draining for v0.5.31``.
|
|
217
|
+
"""
|
|
218
|
+
if self.target_version:
|
|
219
|
+
return f"cordoned: draining for v{self.target_version}"
|
|
220
|
+
return self.reason or "cordoned: draining for a release"
|
|
221
|
+
|
|
222
|
+
def to_dict(self) -> dict:
|
|
223
|
+
return {
|
|
224
|
+
"machine": self.machine,
|
|
225
|
+
"owner": self.owner,
|
|
226
|
+
"reason": self.reason,
|
|
227
|
+
"target_version": self.target_version,
|
|
228
|
+
"created_at": self.created_at,
|
|
229
|
+
"renewed_at": self.renewed_at,
|
|
230
|
+
"expires_at": self.expires_at,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
@classmethod
|
|
234
|
+
def from_dict(cls, raw: Mapping[str, Any]) -> "Cordon":
|
|
235
|
+
"""Type one stored record, tolerantly.
|
|
236
|
+
|
|
237
|
+
A malformed field degrades to its default rather than raising: this
|
|
238
|
+
is read on every dispatch decision in the fleet, and a cordon store
|
|
239
|
+
nobody can parse must not be able to wedge routing.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
def _float(key: str) -> float:
|
|
243
|
+
try:
|
|
244
|
+
return float(raw.get(key) or 0.0)
|
|
245
|
+
except (TypeError, ValueError):
|
|
246
|
+
return 0.0
|
|
247
|
+
|
|
248
|
+
target = raw.get("target_version")
|
|
249
|
+
return cls(
|
|
250
|
+
machine=str(raw.get("machine") or ""),
|
|
251
|
+
owner=str(raw.get("owner") or OWNER_RELEASE),
|
|
252
|
+
reason=str(raw.get("reason") or ""),
|
|
253
|
+
target_version=str(target) if target else None,
|
|
254
|
+
created_at=_float("created_at"),
|
|
255
|
+
renewed_at=_float("renewed_at"),
|
|
256
|
+
expires_at=_float("expires_at"),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
@dataclass(frozen=True)
|
|
261
|
+
class DrainEscalation:
|
|
262
|
+
"""A host that has been cordoned longer than the drain deadline.
|
|
263
|
+
|
|
264
|
+
Surfaced, never merely recorded: #2101's acceptance criterion 4 asks for
|
|
265
|
+
the *message*, not an internal state change, because a silent forever-wait
|
|
266
|
+
is the failure this whole mechanism is meant to replace.
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
machine: str
|
|
270
|
+
waited_seconds: float
|
|
271
|
+
deadline_seconds: float
|
|
272
|
+
target_version: str | None = None
|
|
273
|
+
#: What is still holding the host, in the same prose
|
|
274
|
+
#: ``coord release propagate`` uses for a deferral.
|
|
275
|
+
busy_reason: str = ""
|
|
276
|
+
|
|
277
|
+
@property
|
|
278
|
+
def message(self) -> str:
|
|
279
|
+
minutes = self.waited_seconds / 60.0
|
|
280
|
+
limit = self.deadline_seconds / 60.0
|
|
281
|
+
version = f"v{self.target_version}" if self.target_version else "the release"
|
|
282
|
+
holding = f" — still busy: {self.busy_reason}" if self.busy_reason else ""
|
|
283
|
+
return (
|
|
284
|
+
f"DRAIN OVERDUE: {self.machine} has been cordoned for "
|
|
285
|
+
f"{minutes:.0f}m waiting to drain for {version}, past the "
|
|
286
|
+
f"{limit:.0f}m deadline{holding}. New work is NOT being routed "
|
|
287
|
+
f"there. Override with `coord release cordon --clear "
|
|
288
|
+
f"{self.machine}` (which lets work resume and leaves the host "
|
|
289
|
+
f"behind), or clear whatever is wedged and let it drain."
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def command(self) -> str:
|
|
294
|
+
return f"coord release cordon --clear {self.machine}"
|
|
295
|
+
|
|
296
|
+
def to_dict(self) -> dict:
|
|
297
|
+
return {
|
|
298
|
+
"machine": self.machine,
|
|
299
|
+
"waited_seconds": self.waited_seconds,
|
|
300
|
+
"deadline_seconds": self.deadline_seconds,
|
|
301
|
+
"target_version": self.target_version,
|
|
302
|
+
"busy_reason": self.busy_reason,
|
|
303
|
+
"message": self.message,
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@dataclass(frozen=True)
|
|
308
|
+
class CordonPlan:
|
|
309
|
+
"""What one propagate run wants the cordon store to look like.
|
|
310
|
+
|
|
311
|
+
Applied by the shell; nothing here writes. ``cordon`` holds both brand-new
|
|
312
|
+
cordons and renewals of existing ones (they are the same write — see
|
|
313
|
+
:class:`Cordon` for why ``created_at`` survives).
|
|
314
|
+
"""
|
|
315
|
+
|
|
316
|
+
cordon: tuple[Cordon, ...] = ()
|
|
317
|
+
uncordon: tuple[str, ...] = ()
|
|
318
|
+
escalations: tuple[DrainEscalation, ...] = ()
|
|
319
|
+
#: Records that lapsed on their own since the last run (trap B working).
|
|
320
|
+
#: Reported so a self-healed cordon leaves a trace rather than silently
|
|
321
|
+
#: evaporating from the reasoning.
|
|
322
|
+
expired: tuple[str, ...] = ()
|
|
323
|
+
#: Cordoned hosts this run could neither prove current nor prove behind
|
|
324
|
+
#: (unreadable version, or drift under the threshold). Left exactly as
|
|
325
|
+
#: they are — see :func:`plan_cordons` for why neither direction is safe.
|
|
326
|
+
unknown: tuple[str, ...] = ()
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def empty(self) -> bool:
|
|
330
|
+
return not (self.cordon or self.uncordon or self.escalations or self.expired)
|
|
331
|
+
|
|
332
|
+
def to_dict(self) -> dict:
|
|
333
|
+
return {
|
|
334
|
+
"cordon": [c.to_dict() for c in self.cordon],
|
|
335
|
+
"uncordon": list(self.uncordon),
|
|
336
|
+
"escalations": [e.to_dict() for e in self.escalations],
|
|
337
|
+
"expired": list(self.expired),
|
|
338
|
+
"unknown": list(self.unknown),
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
def render(self) -> list[str]:
|
|
342
|
+
"""Human lines for `coord release propagate`'s output."""
|
|
343
|
+
lines: list[str] = []
|
|
344
|
+
for item in self.cordon:
|
|
345
|
+
lines.append(f" ⊘ cordon {item.machine}: {item.describe()}")
|
|
346
|
+
for name in self.uncordon:
|
|
347
|
+
lines.append(f" ✓ uncordon {name}: up to date, work may resume")
|
|
348
|
+
for name in self.expired:
|
|
349
|
+
lines.append(
|
|
350
|
+
f" · cordon on {name} expired on its own (no propagate run "
|
|
351
|
+
"renewed it) — work may resume there"
|
|
352
|
+
)
|
|
353
|
+
for name in self.unknown:
|
|
354
|
+
lines.append(
|
|
355
|
+
f" ? {name}: cordoned, but this run could neither prove it "
|
|
356
|
+
"current nor prove it behind — left as-is, and it will lapse "
|
|
357
|
+
"on its own if no later run renews it"
|
|
358
|
+
)
|
|
359
|
+
for esc in self.escalations:
|
|
360
|
+
lines.append(f" ! {esc.message}")
|
|
361
|
+
return lines
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@dataclass(frozen=True)
|
|
365
|
+
class HostDrift:
|
|
366
|
+
"""Which hosts are behind, current, or neither — and why.
|
|
367
|
+
|
|
368
|
+
Four buckets, not two, because the two "neither" cases must never be
|
|
369
|
+
collapsed into "current": a host whose version could not be read is not
|
|
370
|
+
evidence of agreement (#1834), and a host one release behind a threshold
|
|
371
|
+
of three is deliberately tolerated rather than proven level.
|
|
372
|
+
"""
|
|
373
|
+
|
|
374
|
+
behind: frozenset[str] = frozenset()
|
|
375
|
+
current: frozenset[str] = frozenset()
|
|
376
|
+
#: Version unreadable — never cordoned (a cordon on a guess stops real
|
|
377
|
+
#: work) and never uncordoned (an HTTP blip must not open the fleet up
|
|
378
|
+
#: mid-roll).
|
|
379
|
+
unreadable: frozenset[str] = frozenset()
|
|
380
|
+
#: Behind, but by less than the threshold (trap F).
|
|
381
|
+
under_threshold: frozenset[str] = frozenset()
|
|
382
|
+
|
|
383
|
+
@property
|
|
384
|
+
def undecided(self) -> frozenset[str]:
|
|
385
|
+
return self.unreadable | self.under_threshold
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def classify_hosts(
|
|
389
|
+
host_versions: Mapping[str, str | None],
|
|
390
|
+
target: str | None,
|
|
391
|
+
*,
|
|
392
|
+
threshold: int = DEFAULT_DRIFT_THRESHOLD,
|
|
393
|
+
) -> HostDrift:
|
|
394
|
+
"""Bucket *host_versions* against *target*. See :class:`HostDrift`.
|
|
395
|
+
|
|
396
|
+
*host_versions* maps a machine name to the version its python lane
|
|
397
|
+
reports, ``None`` when no lane could be read. "Current" requires proof:
|
|
398
|
+
an unreadable version is ``unreadable``, never ``current`` — the same rule
|
|
399
|
+
:func:`coord.release_propagate.hosts_already_current` applies, and for
|
|
400
|
+
the same reason (#1834: ``version=None`` means "no data", which is
|
|
401
|
+
emphatically not "agrees with everyone else").
|
|
402
|
+
"""
|
|
403
|
+
want = max(1, int(threshold))
|
|
404
|
+
behind: set[str] = set()
|
|
405
|
+
current: set[str] = set()
|
|
406
|
+
unreadable: set[str] = set()
|
|
407
|
+
under: set[str] = set()
|
|
408
|
+
for host, version in host_versions.items():
|
|
409
|
+
drift = version_drift(version, target)
|
|
410
|
+
if drift is DRIFT_UNKNOWN:
|
|
411
|
+
unreadable.add(host)
|
|
412
|
+
elif drift == 0:
|
|
413
|
+
current.add(host)
|
|
414
|
+
elif drift >= want:
|
|
415
|
+
behind.add(host)
|
|
416
|
+
else:
|
|
417
|
+
under.add(host)
|
|
418
|
+
return HostDrift(
|
|
419
|
+
behind=frozenset(behind),
|
|
420
|
+
current=frozenset(current),
|
|
421
|
+
unreadable=frozenset(unreadable),
|
|
422
|
+
under_threshold=frozenset(under),
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def plan_cordons(
|
|
427
|
+
*,
|
|
428
|
+
target_version: str | None,
|
|
429
|
+
host_versions: Mapping[str, str | None],
|
|
430
|
+
existing: Mapping[str, Cordon] | Iterable[Cordon] = (),
|
|
431
|
+
now: float,
|
|
432
|
+
ttl_seconds: float = DEFAULT_TTL_SECONDS,
|
|
433
|
+
drain_deadline: float = DEFAULT_DRAIN_DEADLINE_SECONDS,
|
|
434
|
+
threshold: int = DEFAULT_DRIFT_THRESHOLD,
|
|
435
|
+
busy_reasons: Mapping[str, str] | None = None,
|
|
436
|
+
enabled: bool = True,
|
|
437
|
+
) -> CordonPlan:
|
|
438
|
+
"""Decide this run's cordon writes. Pure.
|
|
439
|
+
|
|
440
|
+
*existing* is the store as read (expired records included — this function
|
|
441
|
+
is what notices they lapsed). *busy_reasons* maps a host to why it is not
|
|
442
|
+
yet drained, purely so an escalation can say what is holding it.
|
|
443
|
+
|
|
444
|
+
``enabled=False`` (``coord release propagate --no-cordon``) plans no new
|
|
445
|
+
cordons but STILL clears the ones this owner already set: turning the
|
|
446
|
+
mechanism off must release the fleet, not freeze it in whatever state the
|
|
447
|
+
last run left behind.
|
|
448
|
+
"""
|
|
449
|
+
records = _as_records(existing)
|
|
450
|
+
live = {name: c for name, c in records.items() if c.active(now)}
|
|
451
|
+
expired = tuple(sorted(name for name in records if name not in live))
|
|
452
|
+
|
|
453
|
+
drift = classify_hosts(host_versions, target_version, threshold=threshold)
|
|
454
|
+
|
|
455
|
+
if not enabled:
|
|
456
|
+
return CordonPlan(uncordon=tuple(sorted(live)), expired=expired)
|
|
457
|
+
|
|
458
|
+
to_cordon: list[Cordon] = []
|
|
459
|
+
escalations: list[DrainEscalation] = []
|
|
460
|
+
for host in sorted(drift.behind):
|
|
461
|
+
previous = live.get(host)
|
|
462
|
+
created = previous.created_at if previous and previous.created_at else now
|
|
463
|
+
to_cordon.append(
|
|
464
|
+
Cordon(
|
|
465
|
+
machine=host,
|
|
466
|
+
owner=OWNER_RELEASE,
|
|
467
|
+
reason=f"draining for v{target_version}" if target_version else "draining for a release",
|
|
468
|
+
target_version=target_version,
|
|
469
|
+
created_at=created,
|
|
470
|
+
renewed_at=now,
|
|
471
|
+
expires_at=now + max(0.0, float(ttl_seconds)),
|
|
472
|
+
)
|
|
473
|
+
)
|
|
474
|
+
if previous is not None and previous.overdue(now, drain_deadline):
|
|
475
|
+
escalations.append(
|
|
476
|
+
DrainEscalation(
|
|
477
|
+
machine=host,
|
|
478
|
+
waited_seconds=previous.age(now),
|
|
479
|
+
deadline_seconds=drain_deadline,
|
|
480
|
+
target_version=target_version,
|
|
481
|
+
busy_reason=(busy_reasons or {}).get(host, ""),
|
|
482
|
+
)
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
# Uncordon: PROVEN current, and nothing else. A host whose version could
|
|
486
|
+
# not be read keeps whatever cordon it has until that cordon EXPIRES —
|
|
487
|
+
# clearing on "we couldn't read the version" would open the fleet up
|
|
488
|
+
# mid-roll on the strength of one failed HTTP call.
|
|
489
|
+
to_uncordon = tuple(sorted(name for name in live if name in drift.current))
|
|
490
|
+
|
|
491
|
+
return CordonPlan(
|
|
492
|
+
cordon=tuple(to_cordon),
|
|
493
|
+
uncordon=to_uncordon,
|
|
494
|
+
escalations=tuple(escalations),
|
|
495
|
+
expired=expired,
|
|
496
|
+
unknown=tuple(sorted(drift.undecided & set(live))),
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _as_records(
|
|
501
|
+
existing: Mapping[str, Cordon] | Iterable[Cordon],
|
|
502
|
+
) -> dict[str, Cordon]:
|
|
503
|
+
if isinstance(existing, Mapping):
|
|
504
|
+
return {str(k): v for k, v in existing.items()}
|
|
505
|
+
return {c.machine: c for c in existing}
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
@dataclass
|
|
509
|
+
class CordonOutcome:
|
|
510
|
+
"""What the shell actually did, for the propagation journal."""
|
|
511
|
+
|
|
512
|
+
cordoned: list[str] = field(default_factory=list)
|
|
513
|
+
uncordoned: list[str] = field(default_factory=list)
|
|
514
|
+
expired: list[str] = field(default_factory=list)
|
|
515
|
+
escalated: list[dict] = field(default_factory=list)
|
|
516
|
+
errors: list[str] = field(default_factory=list)
|
|
517
|
+
|
|
518
|
+
def to_dict(self) -> dict:
|
|
519
|
+
return {
|
|
520
|
+
"cordoned": list(self.cordoned),
|
|
521
|
+
"uncordoned": list(self.uncordoned),
|
|
522
|
+
"expired": list(self.expired),
|
|
523
|
+
"escalated": [dict(e) for e in self.escalated],
|
|
524
|
+
"errors": list(self.errors),
|
|
525
|
+
}
|