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,2232 @@
|
|
|
1
|
+
"""`coord release-preflight` — local sanity checks before cutting a release.
|
|
2
|
+
|
|
3
|
+
#1471: `main` is now a protected branch, so a plain ``git push origin main``
|
|
4
|
+
can be silently *rejected* while a subsequent ``git push origin vX.Y.Z``
|
|
5
|
+
still *succeeds* — the two pushes are independent refs and nothing couples
|
|
6
|
+
them. That let a v0.4.82 release publish (immutably) to PyPI from a commit
|
|
7
|
+
that, at that moment, existed nowhere but the releaser's local checkout and
|
|
8
|
+
the tag.
|
|
9
|
+
|
|
10
|
+
This command is a fast, local, no-side-effects check meant to run right
|
|
11
|
+
before tagging a release, per the flow in docs/AGENT_OPERATIONS.md (merge PR
|
|
12
|
+
-> pull merged main -> tag -> push tag — #1238 dropped the version-bump step
|
|
13
|
+
that used to precede it: the git tag *is* the version now, single-sourced
|
|
14
|
+
via setuptools-scm). It does not push, tag, or modify anything itself.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import click
|
|
24
|
+
|
|
25
|
+
from coord.commands._common import _CONFIG_OPTION
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _git(cwd: Path, *args: str) -> subprocess.CompletedProcess:
|
|
29
|
+
return subprocess.run(
|
|
30
|
+
["git", "-C", str(cwd), *args],
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
timeout=30,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def release_preflight_checks(repo_root: Path) -> list[str]:
|
|
38
|
+
"""Return a list of problems with *repo_root* as a release candidate.
|
|
39
|
+
|
|
40
|
+
Empty list == clear to tag. Kept as a pure(ish) function over a repo
|
|
41
|
+
checkout — the only side effect is a ``git fetch origin main`` — so it's
|
|
42
|
+
straightforward to unit test against local-only git fixtures (no real
|
|
43
|
+
network) and to reuse outside the CLI command if needed.
|
|
44
|
+
|
|
45
|
+
Checks, mirroring the issue's #1471 proposal:
|
|
46
|
+
- working tree is clean (no staged/unstaged changes)
|
|
47
|
+
- currently on ``main``, and local ``main`` == ``origin/main`` (the
|
|
48
|
+
protected-branch push must have already landed via a merged PR)
|
|
49
|
+
|
|
50
|
+
#1238: this used to also assert ``pyproject.toml``'s ``version`` and
|
|
51
|
+
``coord/__init__.py``'s ``__version__`` agreed, and that the version
|
|
52
|
+
they named wasn't already tagged. Both checks are gone along with the
|
|
53
|
+
hand-maintained version literals they compared — the version is now
|
|
54
|
+
single-sourced from the git tag itself (setuptools-scm), so there is no
|
|
55
|
+
bump left to forget or mismatch. Cutting a release is just choosing and
|
|
56
|
+
pushing a ``vX.Y.Z`` tag that doesn't exist yet; ``git tag vX.Y.Z``
|
|
57
|
+
itself already refuses a name collision, so a redundant check here would
|
|
58
|
+
add nothing.
|
|
59
|
+
"""
|
|
60
|
+
problems: list[str] = []
|
|
61
|
+
|
|
62
|
+
if not (repo_root / ".git").exists():
|
|
63
|
+
return [f"{repo_root} is not a git checkout"]
|
|
64
|
+
|
|
65
|
+
status = _git(repo_root, "status", "--porcelain")
|
|
66
|
+
if status.returncode != 0:
|
|
67
|
+
problems.append(f"git status failed: {status.stderr.strip()}")
|
|
68
|
+
elif status.stdout.strip():
|
|
69
|
+
problems.append(
|
|
70
|
+
"working tree is not clean — commit or stash changes before "
|
|
71
|
+
"releasing:\n" + status.stdout.rstrip()
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
branch = _git(repo_root, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
|
|
75
|
+
if branch != "main":
|
|
76
|
+
# #1525: this check fires by design on a `release-v*` bump branch —
|
|
77
|
+
# this command is a post-merge, pre-tag check (see the module
|
|
78
|
+
# docstring's flow), not something to run while the bump PR is still
|
|
79
|
+
# open. Spell that out here since the bare "not on main" message
|
|
80
|
+
# read as a bug the first time it fired on a release branch.
|
|
81
|
+
problems.append(
|
|
82
|
+
f"not on main (currently on '{branch}') — this is a post-merge, "
|
|
83
|
+
"pre-tag check: merge the release PR first, then `git checkout "
|
|
84
|
+
"main && git pull origin main` and re-run this from there"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
fetch = _git(repo_root, "fetch", "origin", "main")
|
|
88
|
+
if fetch.returncode != 0:
|
|
89
|
+
problems.append(f"git fetch origin main failed: {fetch.stderr.strip()}")
|
|
90
|
+
else:
|
|
91
|
+
local_head = _git(repo_root, "rev-parse", "HEAD").stdout.strip()
|
|
92
|
+
remote_head = _git(repo_root, "rev-parse", "origin/main").stdout.strip()
|
|
93
|
+
if local_head and remote_head and local_head != remote_head:
|
|
94
|
+
problems.append(
|
|
95
|
+
f"local main ({local_head[:8]}) != origin/main ({remote_head[:8]}) — "
|
|
96
|
+
"pull/rebase onto origin/main first. main is protected: your "
|
|
97
|
+
"change must land there via a merged PR *before* you tag "
|
|
98
|
+
"it (#1471) — a tag built from a commit main rejected still "
|
|
99
|
+
"publishes to PyPI, and PyPI releases are immutable."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
return problems
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@click.command(
|
|
106
|
+
"release-preflight",
|
|
107
|
+
help="Sanity-check the checkout before cutting a release (#1471).",
|
|
108
|
+
)
|
|
109
|
+
@click.option(
|
|
110
|
+
"--path",
|
|
111
|
+
"path_opt",
|
|
112
|
+
default=None,
|
|
113
|
+
help="Repo checkout to check (defaults to the current directory).",
|
|
114
|
+
)
|
|
115
|
+
def release_preflight(path_opt: str | None) -> None:
|
|
116
|
+
"""Fail loudly, before any tag is pushed, if release ordering would be wrong.
|
|
117
|
+
|
|
118
|
+
Run this right before ``git tag vX.Y.Z && git push origin vX.Y.Z``. It
|
|
119
|
+
fetches ``origin/main`` and confirms local ``main`` matches it and the
|
|
120
|
+
working tree is clean — so the #1471 failure mode (tagging a commit that
|
|
121
|
+
never actually landed on the protected ``main`` branch) is caught
|
|
122
|
+
locally instead of shipping an immutable bad PyPI release.
|
|
123
|
+
"""
|
|
124
|
+
repo_root = Path(path_opt).expanduser() if path_opt else Path.cwd()
|
|
125
|
+
problems = release_preflight_checks(repo_root)
|
|
126
|
+
if problems:
|
|
127
|
+
click.echo("release preflight FAILED:", err=True)
|
|
128
|
+
for problem in problems:
|
|
129
|
+
click.echo(f" - {problem}", err=True)
|
|
130
|
+
sys.exit(1)
|
|
131
|
+
click.echo(
|
|
132
|
+
"release preflight OK — local main matches origin/main, working "
|
|
133
|
+
"tree clean. Ready to tag: git tag vX.Y.Z && git push origin vX.Y.Z."
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
138
|
+
# `coord release verify` — the POST-release half (#1834)
|
|
139
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
140
|
+
#
|
|
141
|
+
# `release-preflight` above guards the moment *before* a tag is pushed. It
|
|
142
|
+
# says nothing about whether the release that came out the other end ever
|
|
143
|
+
# reached the fleet — and on 2026-08-04 it demonstrably had not, while four
|
|
144
|
+
# independent readouts said it had. See `coord/release_verify.py` for the
|
|
145
|
+
# incident and the design rules; this file only owns the click surface.
|
|
146
|
+
#
|
|
147
|
+
# `release-preflight` stays registered as a flat top-level command for
|
|
148
|
+
# backward compatibility (it is in every operator's muscle memory and in
|
|
149
|
+
# docs/AGENT_OPERATIONS.md); the new `release` group carries `verify`, and
|
|
150
|
+
# aliases `preflight` under it so the pair is discoverable together.
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@click.group("release", help="Release lifecycle checks (#1471, #1834).")
|
|
154
|
+
def release_group() -> None:
|
|
155
|
+
"""Pre-tag sanity checks and post-release fleet verification."""
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _resolve_expected(expected: str | None, *, use_pypi: bool, index_url: str,
|
|
159
|
+
timeout: float) -> tuple[str | None, str | None]:
|
|
160
|
+
"""(expected version, warning) — the version every lane *should* be on.
|
|
161
|
+
|
|
162
|
+
``--expected`` wins outright. ``--pypi`` asks the simple index (never the
|
|
163
|
+
JSON API — see ``coord.health.pypi`` for why that distinction is
|
|
164
|
+
load-bearing rather than pedantic). With neither, there is no absolute to
|
|
165
|
+
grade against and the command falls back to pure skew detection, which is
|
|
166
|
+
what actually caught 2026-08-04: nobody knew what to expect, but two
|
|
167
|
+
lanes disagreeing was already conclusive.
|
|
168
|
+
"""
|
|
169
|
+
if expected:
|
|
170
|
+
return expected.lstrip("v"), None
|
|
171
|
+
if not use_pypi:
|
|
172
|
+
return None, None
|
|
173
|
+
from coord.health.pypi import latest_release_any # noqa: PLC0415
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
_project, latest, _all = latest_release_any(index_url=index_url, timeout=timeout)
|
|
177
|
+
except Exception as exc: # noqa: BLE001 — read-only, degrade to skew-only
|
|
178
|
+
return None, f"could not read the PyPI simple index ({exc}); checking skew only"
|
|
179
|
+
if latest is None:
|
|
180
|
+
return None, "PyPI simple index returned no release; checking skew only"
|
|
181
|
+
return latest.raw, None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@release_group.command(
|
|
185
|
+
"verify",
|
|
186
|
+
help=(
|
|
187
|
+
"Assert every deploy lane on every host actually reflects the "
|
|
188
|
+
"released version (#1834). Read-only; safe to run mid-flight."
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
@_CONFIG_OPTION
|
|
192
|
+
@click.option(
|
|
193
|
+
"--expected",
|
|
194
|
+
default=None,
|
|
195
|
+
help=(
|
|
196
|
+
"The version every lane must be on (leading 'v' optional). Without "
|
|
197
|
+
"it, the command reports skew BETWEEN lanes, which is what the "
|
|
198
|
+
"2026-08-04 incident actually looked like."
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
@click.option(
|
|
202
|
+
"--pypi/--no-pypi",
|
|
203
|
+
"use_pypi",
|
|
204
|
+
default=True,
|
|
205
|
+
show_default=True,
|
|
206
|
+
help=(
|
|
207
|
+
"Resolve --expected from the PyPI simple index (the released "
|
|
208
|
+
"version). On by default since #2052: without an expected version "
|
|
209
|
+
"this command compares the fleet against ITSELF, so a fleet that is "
|
|
210
|
+
"uniformly four releases behind reports crit=0."
|
|
211
|
+
),
|
|
212
|
+
)
|
|
213
|
+
@click.option("--machine", "machine_filter", default=None,
|
|
214
|
+
help="Only poll this machine (still reports it as one lane set).")
|
|
215
|
+
@click.option("--timeout", default=5.0, show_default=True,
|
|
216
|
+
help="Per-host HTTP timeout, seconds.")
|
|
217
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit the report as JSON.")
|
|
218
|
+
@click.option("-v", "--verbose", is_flag=True, help="Show each lane's resolved path.")
|
|
219
|
+
@click.option(
|
|
220
|
+
"--exit-code/--no-exit-code",
|
|
221
|
+
default=True,
|
|
222
|
+
show_default=True,
|
|
223
|
+
help="Exit 2 on crit, 1 on warn/unknown (mirrors `coord health`).",
|
|
224
|
+
)
|
|
225
|
+
def release_verify(
|
|
226
|
+
config_path: Path,
|
|
227
|
+
expected: str | None,
|
|
228
|
+
use_pypi: bool,
|
|
229
|
+
machine_filter: str | None,
|
|
230
|
+
timeout: float,
|
|
231
|
+
as_json: bool,
|
|
232
|
+
verbose: bool,
|
|
233
|
+
exit_code: bool,
|
|
234
|
+
) -> None:
|
|
235
|
+
"""Post-release: prove the fleet is on the version you think it is.
|
|
236
|
+
|
|
237
|
+
Runs entirely over HTTP — each machine's own ``/health`` plus the
|
|
238
|
+
daemon's ``/board`` — so it works from a thin client with no checkout and
|
|
239
|
+
no credentials, and it never writes anything anywhere.
|
|
240
|
+
"""
|
|
241
|
+
import json as _json # noqa: PLC0415
|
|
242
|
+
|
|
243
|
+
from coord import release_verify as rv # noqa: PLC0415
|
|
244
|
+
from coord.commands._common import _load_config # noqa: PLC0415
|
|
245
|
+
|
|
246
|
+
config = _load_config(config_path)
|
|
247
|
+
index_url = getattr(getattr(config, "health", None), "pypi_index_url",
|
|
248
|
+
"https://pypi.org/simple")
|
|
249
|
+
resolved, warning = _resolve_expected(
|
|
250
|
+
expected, use_pypi=use_pypi, index_url=index_url, timeout=timeout
|
|
251
|
+
)
|
|
252
|
+
if warning and not as_json:
|
|
253
|
+
click.echo(f"warning: {warning}", err=True)
|
|
254
|
+
|
|
255
|
+
machine_health, unreachable, daemon_host, daemon_name = rv.gather(
|
|
256
|
+
config, timeout=timeout, machine_filter=machine_filter
|
|
257
|
+
)
|
|
258
|
+
report = rv.verify(
|
|
259
|
+
machine_health=machine_health,
|
|
260
|
+
unreachable=unreachable,
|
|
261
|
+
daemon_host=daemon_host,
|
|
262
|
+
daemon_host_name=daemon_name,
|
|
263
|
+
expected=resolved,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
if as_json:
|
|
267
|
+
click.echo(_json.dumps(report.to_dict(), indent=2, sort_keys=True))
|
|
268
|
+
else:
|
|
269
|
+
click.echo(rv.render(report, verbose=verbose))
|
|
270
|
+
|
|
271
|
+
if exit_code and report.exit_code:
|
|
272
|
+
sys.exit(report.exit_code)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
276
|
+
# `coord release propagate` — the PROPAGATE half (#1835, PKG-7)
|
|
277
|
+
# ──────────────────────────────────────────────────────────────────────────
|
|
278
|
+
#
|
|
279
|
+
# The I/O shell over `coord.release_propagate`. Everything that decides
|
|
280
|
+
# anything — is the fleet quiescent, in what order may lanes roll, which
|
|
281
|
+
# deploy gates a finished roll releases — lives in that module and is unit
|
|
282
|
+
# tested without a fleet. What lives here is the part that needs one:
|
|
283
|
+
# fetching the board, POSTing to agents, running the verifier, appending the
|
|
284
|
+
# journal.
|
|
285
|
+
#
|
|
286
|
+
# Publish and propagate are separate on purpose and the separation is the
|
|
287
|
+
# whole design: see `.github/workflows/auto-release.yml` and
|
|
288
|
+
# `coord/release_propagate.py`'s module docstring.
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _state_dir() -> Path:
|
|
292
|
+
from coord.platform_paths import default_coord_dir # noqa: PLC0415
|
|
293
|
+
|
|
294
|
+
return default_coord_dir()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _fetch_board() -> tuple[dict, str | None]:
|
|
298
|
+
"""``(board_payload, error)`` — never raises.
|
|
299
|
+
|
|
300
|
+
A board this command cannot read is a *deferral*, not a crash: the
|
|
301
|
+
propagation timer runs unattended, and an unreadable board means we
|
|
302
|
+
cannot prove the fleet is idle, which is exactly the state in which the
|
|
303
|
+
safe move is to do nothing and say so.
|
|
304
|
+
"""
|
|
305
|
+
from coord import release_verify as rv # noqa: PLC0415
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
return rv._default_board_fetch() or {}, None
|
|
309
|
+
except Exception as exc: # noqa: BLE001 — see docstring
|
|
310
|
+
return {}, f"{type(exc).__name__}: {exc}"
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _daemon_machine_name(
|
|
314
|
+
config, override: str | None, machine_health: dict | None = None
|
|
315
|
+
) -> str | None:
|
|
316
|
+
"""Which machine in ``coordinator.yml`` runs ``coord-serve``.
|
|
317
|
+
|
|
318
|
+
The daemon must lead every roll (see :func:`coord.release_propagate.
|
|
319
|
+
plan_lanes`), so getting this wrong is not cosmetic — it reintroduces
|
|
320
|
+
the documented 405, and #2052 watched exactly that happen: a partial
|
|
321
|
+
revert briefly left the daemon host on 0.5.4 while both callers sat on
|
|
322
|
+
0.5.8, because nothing could name the daemon and the roll fell back to
|
|
323
|
+
``coordinator.yml`` order.
|
|
324
|
+
|
|
325
|
+
Resolution order, derivation first and guesswork nowhere:
|
|
326
|
+
|
|
327
|
+
1. the explicit ``--daemon-host`` flag;
|
|
328
|
+
2. **derived** — the machine whose own ``/health`` reports a running
|
|
329
|
+
``coord-serve`` unit (:func:`coord.release_verify.
|
|
330
|
+
daemon_host_from_health`). This is the fact itself, not a proxy for it;
|
|
331
|
+
3. the host in the configured ``board_service`` URL matched against each
|
|
332
|
+
machine's host — still derived, just from config rather than from the
|
|
333
|
+
fleet;
|
|
334
|
+
4. ``None``, which the caller treats as *refuse the run*. Ordering is the
|
|
335
|
+
one thing protecting against the 405; a run that cannot order itself
|
|
336
|
+
must stop, not roll in whatever order the file happens to list.
|
|
337
|
+
"""
|
|
338
|
+
machines = list(getattr(config, "machines", ()) or ())
|
|
339
|
+
if override:
|
|
340
|
+
return override
|
|
341
|
+
|
|
342
|
+
if machine_health:
|
|
343
|
+
from coord.release_verify import daemon_host_from_health # noqa: PLC0415
|
|
344
|
+
|
|
345
|
+
derived = daemon_host_from_health(machine_health)
|
|
346
|
+
if derived:
|
|
347
|
+
return derived
|
|
348
|
+
|
|
349
|
+
try:
|
|
350
|
+
from urllib.parse import urlparse # noqa: PLC0415
|
|
351
|
+
|
|
352
|
+
from coord.client import resolve_board_service # noqa: PLC0415
|
|
353
|
+
|
|
354
|
+
svc = resolve_board_service()
|
|
355
|
+
if svc is None:
|
|
356
|
+
return None
|
|
357
|
+
host = (urlparse(svc.url).hostname or "").lower()
|
|
358
|
+
except Exception: # noqa: BLE001
|
|
359
|
+
return None
|
|
360
|
+
if not host:
|
|
361
|
+
return None
|
|
362
|
+
for machine in machines:
|
|
363
|
+
if str(getattr(machine, "host", "")).lower() == host:
|
|
364
|
+
return machine.name
|
|
365
|
+
if machine.name.lower() == host:
|
|
366
|
+
return machine.name
|
|
367
|
+
return None
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _post(url: str, payload: dict, *, timeout: float) -> tuple[int | None, dict, str]:
|
|
371
|
+
"""POST JSON, tolerantly. ``(status, body, error)``."""
|
|
372
|
+
import httpx # noqa: PLC0415
|
|
373
|
+
|
|
374
|
+
try:
|
|
375
|
+
resp = httpx.post(url, json=payload, timeout=timeout)
|
|
376
|
+
except Exception as exc: # noqa: BLE001
|
|
377
|
+
return None, {}, f"{type(exc).__name__}: {exc}"
|
|
378
|
+
try:
|
|
379
|
+
body = resp.json()
|
|
380
|
+
except Exception: # noqa: BLE001
|
|
381
|
+
body = {}
|
|
382
|
+
return resp.status_code, (body if isinstance(body, dict) else {}), ""
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _lane_versions_by_host(report) -> dict[str, list[str | None]]:
|
|
386
|
+
out: dict[str, list[str | None]] = {}
|
|
387
|
+
for lane in report.lanes:
|
|
388
|
+
out.setdefault(lane.host, []).append(lane.version)
|
|
389
|
+
return out
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@release_group.command(
|
|
393
|
+
"propagate",
|
|
394
|
+
help=(
|
|
395
|
+
"Roll the released version onto each host at ITS next quiescent "
|
|
396
|
+
"window (#2067 — per host, not fleet-wide), verify it, and roll "
|
|
397
|
+
"back on red (#1835). Safe to run from a timer: a busy host is a "
|
|
398
|
+
"recorded deferral for that host, not a failure of the run."
|
|
399
|
+
),
|
|
400
|
+
)
|
|
401
|
+
@_CONFIG_OPTION
|
|
402
|
+
@click.option("--target", "target", default=None,
|
|
403
|
+
help="Version to propagate (leading 'v' optional). Default: PyPI's latest.")
|
|
404
|
+
@click.option("--daemon-host", "daemon_host_override", default=None,
|
|
405
|
+
help="Machine name running coord-serve. It rolls FIRST — a caller "
|
|
406
|
+
"must never reach an endpoint its daemon predates. Normally "
|
|
407
|
+
"DERIVED from the fleet's own /health; pass this when it "
|
|
408
|
+
"cannot be, since an unorderable multi-host run refuses.")
|
|
409
|
+
@click.option("--lane", "lane_filter", multiple=True,
|
|
410
|
+
type=click.Choice(["python", "units", "tui"]),
|
|
411
|
+
help="Only roll these lanes (repeatable). Default: all of them.")
|
|
412
|
+
@click.option("--dry-run", is_flag=True,
|
|
413
|
+
help="Print the window verdict and the roll plan; change nothing.")
|
|
414
|
+
@click.option("--force", is_flag=True,
|
|
415
|
+
help="Roll even if the fleet is busy. This KILLS in-flight headless "
|
|
416
|
+
"workers — the whole reason propagation is quiescence-scheduled.")
|
|
417
|
+
@click.option("--verify/--no-verify", "do_verify", default=True, show_default=True,
|
|
418
|
+
help="Run `coord release verify` as the final gate.")
|
|
419
|
+
@click.option("--rollback-on-red/--no-rollback-on-red", default=True, show_default=True,
|
|
420
|
+
help="Roll every updated host back to its previous venv generation "
|
|
421
|
+
"when verification comes back CRIT *on a lane this run could "
|
|
422
|
+
"actually roll* (#2052). Findings on lanes propagation has no "
|
|
423
|
+
"channel for are advisory and never trigger this.")
|
|
424
|
+
@click.option("--release-holds/--no-release-holds", "release_holds", default=True,
|
|
425
|
+
show_default=True,
|
|
426
|
+
help="After a VERIFIED roll, release the drive-queue deploy gates "
|
|
427
|
+
"(#1757) that were waiting for exactly this deploy.")
|
|
428
|
+
@click.option("--timeout", default=180.0, show_default=True,
|
|
429
|
+
help="Seconds to wait for each agent to report the new version.")
|
|
430
|
+
@click.option("--cordon/--no-cordon", "do_cordon", default=True, show_default=True,
|
|
431
|
+
help="#2101: stop each behind host from starting NEW work until it "
|
|
432
|
+
"is up to date, so it drains into a rollable state instead of "
|
|
433
|
+
"waiting for a window that never comes. In-flight work is "
|
|
434
|
+
"never killed. --no-cordon also CLEARS any cordon this "
|
|
435
|
+
"mechanism already set — turning it off must release the "
|
|
436
|
+
"fleet, not freeze it.")
|
|
437
|
+
@click.option("--cordon-after", default=None, type=int,
|
|
438
|
+
help="Releases behind before a host is cordoned (default: 1, i.e. "
|
|
439
|
+
"any drift). Raise it if release cadence ever makes one "
|
|
440
|
+
"fleet drain per release too expensive — see #2101 trap F.")
|
|
441
|
+
@click.option("--cordon-ttl", default=None, type=float,
|
|
442
|
+
help="Seconds a cordon stays effective without being renewed "
|
|
443
|
+
"(default 3600). This is what stops a run killed mid-drain "
|
|
444
|
+
"from cordoning the fleet forever.")
|
|
445
|
+
@click.option("--drain-deadline", default=None, type=float,
|
|
446
|
+
help="Seconds a host may fail to drain before the cordon "
|
|
447
|
+
"escalates loudly (default 5400).")
|
|
448
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit the propagation record as JSON.")
|
|
449
|
+
def release_propagate( # noqa: PLR0912, PLR0915 — a pipeline; the decisions are elsewhere
|
|
450
|
+
config_path: Path,
|
|
451
|
+
target: str | None,
|
|
452
|
+
daemon_host_override: str | None,
|
|
453
|
+
lane_filter: tuple[str, ...],
|
|
454
|
+
dry_run: bool,
|
|
455
|
+
force: bool,
|
|
456
|
+
do_verify: bool,
|
|
457
|
+
rollback_on_red: bool,
|
|
458
|
+
release_holds: bool,
|
|
459
|
+
timeout: float,
|
|
460
|
+
do_cordon: bool,
|
|
461
|
+
cordon_after: int | None,
|
|
462
|
+
cordon_ttl: float | None,
|
|
463
|
+
drain_deadline: float | None,
|
|
464
|
+
as_json: bool,
|
|
465
|
+
) -> None:
|
|
466
|
+
"""One propagation attempt. Exit 0 on deferral, 1 on red, 2 on rollback.
|
|
467
|
+
|
|
468
|
+
#2067: the window is assessed PER HOST, not fleet-wide. A host with a
|
|
469
|
+
live assignment or a running drive-queue entry defers on its own; the
|
|
470
|
+
others roll and get verified this run regardless. The one case that
|
|
471
|
+
still defers the whole run is the daemon host itself being occupied —
|
|
472
|
+
every other host's python lane has to wait behind it (see
|
|
473
|
+
``coord/release_propagate.py``'s LANE ORDER section) — and the case a
|
|
474
|
+
signal can't be pinned to any one host at all (an unreadable board), in
|
|
475
|
+
which nothing can be proven safe to roll.
|
|
476
|
+
|
|
477
|
+
#2052: the final gate is scoped to the lanes this run attempted and could
|
|
478
|
+
have moved. Verify grades lanes propagation cannot roll — the operator's
|
|
479
|
+
``~/.coord-cli-venv`` and a remote ``coord-tui`` binary, currently — and
|
|
480
|
+
holding a roll to those made every successful run red, which
|
|
481
|
+
``--rollback-on-red`` then reverted. Those findings are still reported and
|
|
482
|
+
journalled in full; they are simply not evidence about *this* roll.
|
|
483
|
+
|
|
484
|
+
#2069: the python lane's reach used to stop at ``coord-agent`` — a venv
|
|
485
|
+
could swap cleanly while ``coord-serve`` kept serving the generation it
|
|
486
|
+
started with, and this command still exited green. ``_roll_python`` now
|
|
487
|
+
also restarts ``coord-serve``/``coord-web``/``coord-drive-queue`` on
|
|
488
|
+
whichever host actually runs them, right after that host's own ``/update``
|
|
489
|
+
lands, so the ``coord-serve process`` and ``<unit> spawns`` findings are
|
|
490
|
+
graded like any other python-lane lane instead of being permanently
|
|
491
|
+
advisory.
|
|
492
|
+
|
|
493
|
+
#2101: this command no longer only WAITS for a window, it CREATES one.
|
|
494
|
+
Every host that is behind the target is *cordoned* — no new agents route
|
|
495
|
+
there, in-flight work is untouched — so it drains itself into a rollable
|
|
496
|
+
state; the moment it is rolled it is uncordoned again. That is why the
|
|
497
|
+
version sweep below happens BEFORE the "fleet is busy, defer" branch: a
|
|
498
|
+
run that defers without cordoning is a run that will defer again in 20
|
|
499
|
+
minutes for exactly the same reason, which is how the fleet sat eleven
|
|
500
|
+
releases behind for a day with elitebook idle and rollable throughout.
|
|
501
|
+
"""
|
|
502
|
+
import json as _json # noqa: PLC0415
|
|
503
|
+
import time # noqa: PLC0415
|
|
504
|
+
|
|
505
|
+
from coord import release_propagate as rp # noqa: PLC0415
|
|
506
|
+
from coord import release_verify as rv # noqa: PLC0415
|
|
507
|
+
from coord.commands._common import AGENT_PORT, _load_config # noqa: PLC0415
|
|
508
|
+
|
|
509
|
+
config = _load_config(config_path)
|
|
510
|
+
state_dir = _state_dir()
|
|
511
|
+
record = rp.PropagationRecord(started_at=time.time(), dry_run=dry_run)
|
|
512
|
+
|
|
513
|
+
def _finish(status: str, exit_code: int = 0) -> None:
|
|
514
|
+
record.status = status
|
|
515
|
+
record.finished_at = time.time()
|
|
516
|
+
if not dry_run:
|
|
517
|
+
try:
|
|
518
|
+
rp.append_record(state_dir, record)
|
|
519
|
+
rp.trim_journal(state_dir)
|
|
520
|
+
except OSError as exc:
|
|
521
|
+
click.echo(f"warning: could not append the propagation journal: {exc}",
|
|
522
|
+
err=True)
|
|
523
|
+
if as_json:
|
|
524
|
+
click.echo(_json.dumps(record.to_dict(), indent=2, sort_keys=True))
|
|
525
|
+
else:
|
|
526
|
+
click.echo("\n".join(rp.render_record(record)))
|
|
527
|
+
sys.exit(exit_code)
|
|
528
|
+
|
|
529
|
+
# ── 1. what version are we propagating? ──────────────────────────────
|
|
530
|
+
index_url = getattr(getattr(config, "health", None), "pypi_index_url",
|
|
531
|
+
"https://pypi.org/simple")
|
|
532
|
+
resolved, warning = _resolve_expected(
|
|
533
|
+
target, use_pypi=not target, index_url=index_url, timeout=10.0
|
|
534
|
+
)
|
|
535
|
+
if warning:
|
|
536
|
+
click.echo(f"warning: {warning}", err=True)
|
|
537
|
+
record.target_version = rp.normalize_version(resolved)
|
|
538
|
+
if not record.target_version:
|
|
539
|
+
record.error = (
|
|
540
|
+
"could not resolve a target version — pass --target, or fix "
|
|
541
|
+
"access to the PyPI simple index"
|
|
542
|
+
)
|
|
543
|
+
_finish(rp.STATUS_FAILED, 1)
|
|
544
|
+
|
|
545
|
+
# ── 2. is there a window? (fleet + per-host, #2067) ──────────────────
|
|
546
|
+
board, board_error = _fetch_board()
|
|
547
|
+
extra_busy = []
|
|
548
|
+
if board_error:
|
|
549
|
+
extra_busy.append(
|
|
550
|
+
rp.Busy(kind="board unreadable", subject="/board", detail=board_error)
|
|
551
|
+
)
|
|
552
|
+
quiescence = rp.assess_quiescence(
|
|
553
|
+
queue_entries=board.get("drive_queue") or [],
|
|
554
|
+
assignments=board.get("assignments") or [],
|
|
555
|
+
issues=board.get("issues") or [],
|
|
556
|
+
extra_busy=extra_busy,
|
|
557
|
+
)
|
|
558
|
+
record.quiescence = quiescence.to_dict()
|
|
559
|
+
if quiescence.stale:
|
|
560
|
+
# #2110: a `running` row this assessment could disprove (its issue is
|
|
561
|
+
# merged/closed) — not a busy signal, but not silent either. Printed
|
|
562
|
+
# unconditionally, not just under `--json`, so a plain journal read
|
|
563
|
+
# shows the fleet self-corrected a stale row instead of that fact
|
|
564
|
+
# only ever existing inside `record.quiescence["stale"]`.
|
|
565
|
+
click.echo(
|
|
566
|
+
"note: ignoring stale drive-queue row(s) whose issue already "
|
|
567
|
+
f"landed: {', '.join(quiescence.stale)} (run `coord drive-queue "
|
|
568
|
+
"tick --reconcile-only` to clear them for good)",
|
|
569
|
+
err=True,
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
hosts = [m.name for m in (getattr(config, "machines", ()) or ())]
|
|
573
|
+
busy_hosts = quiescence.busy_hosts()
|
|
574
|
+
# #2067: a signal that cannot be pinned to a host (the board itself
|
|
575
|
+
# unreadable, a drive-queue entry with no recorded launch host) has to
|
|
576
|
+
# block every host — and so does every configured host individually
|
|
577
|
+
# being occupied, which is the same outcome by a different route.
|
|
578
|
+
fully_busy = bool(quiescence.fleet_wide_busy) or (
|
|
579
|
+
bool(hosts) and busy_hosts.issuperset(hosts)
|
|
580
|
+
)
|
|
581
|
+
if quiescence.busy and force:
|
|
582
|
+
click.echo(
|
|
583
|
+
"warning: --force — rolling over a BUSY fleet; in-flight "
|
|
584
|
+
f"headless workers will be killed ({quiescence.reason})",
|
|
585
|
+
err=True,
|
|
586
|
+
)
|
|
587
|
+
busy_hosts = set() # --force overrides per-host busyness too
|
|
588
|
+
|
|
589
|
+
# ── 3. who still needs it, and in what order? ────────────────────────
|
|
590
|
+
#
|
|
591
|
+
# #2101: this sweep used to sit BELOW the "fleet fully busy → defer"
|
|
592
|
+
# return, because a run that could roll nothing had nothing to learn from
|
|
593
|
+
# it. That is no longer true: a busy fleet is exactly the fleet that needs
|
|
594
|
+
# cordoning, and cordoning needs to know who is behind. The cost is one
|
|
595
|
+
# `/health` sweep (10s, parallel) on a tick that would otherwise have
|
|
596
|
+
# returned immediately — paid so a deferral can make the NEXT run
|
|
597
|
+
# different from this one instead of repeating it forever.
|
|
598
|
+
machine_health, unreachable, daemon_facts, daemon_label = rv.gather(
|
|
599
|
+
config, timeout=10.0
|
|
600
|
+
)
|
|
601
|
+
before = rv.verify(
|
|
602
|
+
machine_health=machine_health, unreachable=unreachable,
|
|
603
|
+
daemon_host=daemon_facts, daemon_host_name=daemon_label,
|
|
604
|
+
expected=record.target_version,
|
|
605
|
+
)
|
|
606
|
+
current = rp.hosts_already_current(_lane_versions_by_host(before), record.target_version)
|
|
607
|
+
|
|
608
|
+
# ── 3b. cordon the hosts that are behind, so they DRAIN (#2101) ──────
|
|
609
|
+
#
|
|
610
|
+
# Before the deferral return below, on purpose: cordoning is the thing
|
|
611
|
+
# that turns "no window" into "a window in a few minutes". A run that
|
|
612
|
+
# defers without cordoning has changed nothing about why it deferred.
|
|
613
|
+
record.cordons = _apply_cordons(
|
|
614
|
+
hosts=hosts,
|
|
615
|
+
report=before,
|
|
616
|
+
target_version=record.target_version,
|
|
617
|
+
busy_reasons={h: quiescence.busy_reason_for_host(h) for h in hosts},
|
|
618
|
+
enabled=do_cordon,
|
|
619
|
+
threshold=cordon_after,
|
|
620
|
+
ttl_seconds=cordon_ttl,
|
|
621
|
+
drain_deadline=drain_deadline,
|
|
622
|
+
dry_run=dry_run,
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
if fully_busy and not force:
|
|
626
|
+
# The single most important line in this command: a deferral is a
|
|
627
|
+
# normal, recorded, exit-0 outcome. A timer that defers all night
|
|
628
|
+
# must be visibly *working*, not visibly failing. #2101: and now it
|
|
629
|
+
# has cordoned whatever is behind on the way past, so the next tick
|
|
630
|
+
# meets a fleet that is actually draining.
|
|
631
|
+
_finish(rp.STATUS_DEFERRED, 0)
|
|
632
|
+
|
|
633
|
+
# #2101: resolved AFTER the deferral above, deliberately. This refusal is
|
|
634
|
+
# about the ORDER a roll happens in; a run that is going to roll nothing
|
|
635
|
+
# has no order to get wrong, and turning "the fleet is busy" into a
|
|
636
|
+
# `failed` record (exit 1, systemd marks the unit failed) would teach an
|
|
637
|
+
# operator to ignore the one signal that matters.
|
|
638
|
+
daemon_name = _daemon_machine_name(config, daemon_host_override, machine_health)
|
|
639
|
+
if daemon_name is None and len(hosts) > 1:
|
|
640
|
+
# #2052 fault 2: this used to warn and roll in coordinator.yml order.
|
|
641
|
+
# It then briefly put the daemon host BEHIND both its callers during a
|
|
642
|
+
# partial revert — the documented 405 hazard the warning itself named.
|
|
643
|
+
# Ordering is the one thing protecting against that, so an unorderable
|
|
644
|
+
# run refuses. It is not a failure of the fleet, but it is a failure of
|
|
645
|
+
# this run, and a recorded one.
|
|
646
|
+
record.error = (
|
|
647
|
+
"could not identify which machine runs coord-serve, and this "
|
|
648
|
+
"fleet has more than one host — REFUSING to roll. The lane order "
|
|
649
|
+
"(daemon first) is the only thing preventing the documented 405, "
|
|
650
|
+
"and rolling in coordinator.yml order is a guess, not an order. "
|
|
651
|
+
"Fix the daemon host's /health so its coord-serve unit is "
|
|
652
|
+
"visible, or pass --daemon-host <machine>."
|
|
653
|
+
)
|
|
654
|
+
click.echo(f"error: {record.error}", err=True)
|
|
655
|
+
_finish(rp.STATUS_FAILED, 1)
|
|
656
|
+
|
|
657
|
+
# #2067: the daemon must lead every python-lane roll (see the module
|
|
658
|
+
# docstring's LANE ORDER section) — if it is itself occupied and not
|
|
659
|
+
# already on the target, nothing may roll ahead of it, because that
|
|
660
|
+
# would put a caller on a newer `coord` than the daemon it talks to
|
|
661
|
+
# (the documented 405). This is the one case a per-host window still
|
|
662
|
+
# has to defer the WHOLE run rather than just skip the busy host.
|
|
663
|
+
if daemon_name in busy_hosts and daemon_name not in current:
|
|
664
|
+
_finish(rp.STATUS_DEFERRED, 0)
|
|
665
|
+
|
|
666
|
+
still_busy = busy_hosts - set(current)
|
|
667
|
+
rolls = rp.plan_lanes(
|
|
668
|
+
daemon_host=daemon_name,
|
|
669
|
+
hosts=hosts,
|
|
670
|
+
lanes=lane_filter or rp.ALL_LANES,
|
|
671
|
+
skip_hosts=set(current) | busy_hosts,
|
|
672
|
+
)
|
|
673
|
+
for host in current:
|
|
674
|
+
record.lanes.append(
|
|
675
|
+
{"lane": "-", "host": host, "ok": None,
|
|
676
|
+
"detail": f"already on v{record.target_version}"}
|
|
677
|
+
)
|
|
678
|
+
for host in sorted(still_busy):
|
|
679
|
+
# #2067: the whole point — a busy host defers on its own, it does
|
|
680
|
+
# not hold every OTHER host hostage. A re-run resumes it, same as
|
|
681
|
+
# an unreachable host or a failed daemon roll does today.
|
|
682
|
+
record.lanes.append(
|
|
683
|
+
{"lane": "-", "host": host, "ok": None,
|
|
684
|
+
"detail": f"deferred — {quiescence.busy_reason_for_host(host)}"}
|
|
685
|
+
)
|
|
686
|
+
for host, reason in sorted(unreachable.items()):
|
|
687
|
+
record.lanes.append(
|
|
688
|
+
{"lane": "-", "host": host, "ok": False, "detail": f"unreachable: {reason}"}
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
if dry_run:
|
|
692
|
+
for roll in rolls:
|
|
693
|
+
record.lanes.append(
|
|
694
|
+
{"lane": roll.lane, "host": roll.host, "ok": None,
|
|
695
|
+
"detail": f"would roll ({roll.rationale})"}
|
|
696
|
+
)
|
|
697
|
+
if rolls:
|
|
698
|
+
_finish(rp.STATUS_ROLLED, 0)
|
|
699
|
+
_finish(rp.STATUS_DEFERRED if still_busy else rp.STATUS_UP_TO_DATE, 0)
|
|
700
|
+
|
|
701
|
+
if not rolls:
|
|
702
|
+
_finish(rp.STATUS_DEFERRED if still_busy else rp.STATUS_UP_TO_DATE, 0)
|
|
703
|
+
|
|
704
|
+
# ── 4. roll, in the planned order ────────────────────────────────────
|
|
705
|
+
by_name = {m.name: m for m in (getattr(config, "machines", ()) or ())}
|
|
706
|
+
updated_hosts: list[str] = []
|
|
707
|
+
local_name = _local_machine_name(config)
|
|
708
|
+
|
|
709
|
+
# #1835 review: plan_lanes() puts the daemon host's python lane first
|
|
710
|
+
# specifically so "a caller must never reach an endpoint its daemon
|
|
711
|
+
# predates" holds — but that is only true if a failure there actually
|
|
712
|
+
# stops every other host's python lane from rolling forward. Without
|
|
713
|
+
# this, a failed daemon roll left the loop free to advance every other
|
|
714
|
+
# host to target_version anyway, reproducing the documented 405 skew
|
|
715
|
+
# for the rest of this run (up to --timeout seconds per remaining
|
|
716
|
+
# host) until the final `coord release verify` gate caught it — or,
|
|
717
|
+
# with --no-verify, not at all. So this is an enforced precondition,
|
|
718
|
+
# not just an ordering suggestion: once the daemon's own python roll
|
|
719
|
+
# fails, every other host's python lane is skipped outright.
|
|
720
|
+
#
|
|
721
|
+
# #2095 review: this used to be set from `_roll_python`'s own overall
|
|
722
|
+
# `ok`, which #2095 correctly made `False` whenever ANY restarted
|
|
723
|
+
# sibling failed — including coord-web, which has nothing to do with the
|
|
724
|
+
# 405 hazard this flag exists to prevent (that hazard is specifically
|
|
725
|
+
# "a caller running ahead of a daemon whose coord-serve hasn't reached
|
|
726
|
+
# target_version yet"). Reusing that aggregate here meant a coord-web-
|
|
727
|
+
# only failure on the daemon host — coord-serve itself restarts and
|
|
728
|
+
# reports target_version fine — would ALSO halt every other host's
|
|
729
|
+
# python lane for the rest of the run: a materially larger blast radius
|
|
730
|
+
# than before #2095, and exactly the shape of the 2026-08-10 incident
|
|
731
|
+
# this issue is about (dellserver's coord-serve was fine; coord-web was
|
|
732
|
+
# what failed). `_roll_python` now reports `serve_unit_ok` separately —
|
|
733
|
+
# whether coord-serve ITSELF is confirmed on target_version — and that,
|
|
734
|
+
# not the lane's own `ok`, is what decides this.
|
|
735
|
+
daemon_python_failed = False
|
|
736
|
+
|
|
737
|
+
for roll in rolls:
|
|
738
|
+
machine = by_name.get(roll.host)
|
|
739
|
+
if machine is None:
|
|
740
|
+
record.lanes.append({"lane": roll.lane, "host": roll.host, "ok": False,
|
|
741
|
+
"detail": "not in coordinator.yml"})
|
|
742
|
+
continue
|
|
743
|
+
if roll.host in unreachable:
|
|
744
|
+
record.lanes.append({"lane": roll.lane, "host": roll.host, "ok": False,
|
|
745
|
+
"detail": "skipped — host unreachable"})
|
|
746
|
+
continue
|
|
747
|
+
|
|
748
|
+
if (
|
|
749
|
+
roll.lane == rp.LANE_PYTHON
|
|
750
|
+
and daemon_python_failed
|
|
751
|
+
and roll.host != daemon_name
|
|
752
|
+
):
|
|
753
|
+
# Not a failure of *this* host — it was simply never attempted,
|
|
754
|
+
# because attempting it would put it ahead of a daemon that
|
|
755
|
+
# cannot yet serve it. A re-run after the daemon is fixed
|
|
756
|
+
# should resume here, not treat this host as needing rollback.
|
|
757
|
+
detail = (
|
|
758
|
+
"not attempted — daemon host's python lane failed; rolling "
|
|
759
|
+
"this host first would reproduce the 405 skew the lane "
|
|
760
|
+
"order exists to prevent"
|
|
761
|
+
)
|
|
762
|
+
record.lanes.append({"lane": roll.lane, "host": roll.host, "ok": None,
|
|
763
|
+
"detail": detail})
|
|
764
|
+
click.echo(f" · {roll.label}: {detail}")
|
|
765
|
+
continue
|
|
766
|
+
|
|
767
|
+
if roll.lane == rp.LANE_PYTHON:
|
|
768
|
+
ok, detail, serve_unit_ok = _roll_python(
|
|
769
|
+
machine, target_version=record.target_version,
|
|
770
|
+
agent_port=AGENT_PORT, timeout=timeout, force=force,
|
|
771
|
+
)
|
|
772
|
+
if ok:
|
|
773
|
+
updated_hosts.append(roll.host)
|
|
774
|
+
elif roll.host == daemon_name and not serve_unit_ok:
|
|
775
|
+
daemon_python_failed = True
|
|
776
|
+
elif roll.lane == rp.LANE_UNITS:
|
|
777
|
+
ok, detail = _roll_units(machine, agent_port=AGENT_PORT)
|
|
778
|
+
else:
|
|
779
|
+
ok, detail = _roll_tui(
|
|
780
|
+
machine, target_version=record.target_version, local_name=local_name
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
# #2052: `ok is None` from a lane executor means "there is no channel
|
|
784
|
+
# for this lane on this host" — not a failure, and emphatically not
|
|
785
|
+
# something the post-roll gate may hold this run to. The remote
|
|
786
|
+
# coord-tui binary is the canonical case: propagation itself reports
|
|
787
|
+
# there is no remote install path, so counting its staleness as
|
|
788
|
+
# grounds for rolling back a good python roll is a category error.
|
|
789
|
+
entry = {"lane": roll.lane, "host": roll.host, "ok": ok, "detail": detail}
|
|
790
|
+
if ok is None:
|
|
791
|
+
entry["unrollable"] = True
|
|
792
|
+
record.lanes.append(entry)
|
|
793
|
+
click.echo(f" {'·' if ok is None else ('✓' if ok else '✗')} "
|
|
794
|
+
f"{roll.label}: {detail}")
|
|
795
|
+
|
|
796
|
+
# ── 4b. uncordon what just rolled, immediately (#2101) ───────────────
|
|
797
|
+
#
|
|
798
|
+
# Immediately, and not after the verify gate below: the host is on the
|
|
799
|
+
# target version and its agent has re-execed, so there is nothing left to
|
|
800
|
+
# drain for and every extra second of cordon is work the fleet is not
|
|
801
|
+
# doing. If verification then comes back red and rolls the host back, the
|
|
802
|
+
# NEXT run re-cordons it — one loop, converging, rather than a cordon
|
|
803
|
+
# whose lifetime is coupled to an unrelated gate.
|
|
804
|
+
_uncordon_hosts(updated_hosts, record.cordons)
|
|
805
|
+
|
|
806
|
+
# ── 5. the final gate ────────────────────────────────────────────────
|
|
807
|
+
if not do_verify:
|
|
808
|
+
_finish(rp.STATUS_ROLLED, 0)
|
|
809
|
+
|
|
810
|
+
machine_health, unreachable, daemon_facts, daemon_label = rv.gather(
|
|
811
|
+
config, timeout=10.0
|
|
812
|
+
)
|
|
813
|
+
after = rv.verify(
|
|
814
|
+
machine_health=machine_health, unreachable=unreachable,
|
|
815
|
+
daemon_host=daemon_facts, daemon_host_name=daemon_label,
|
|
816
|
+
expected=record.target_version,
|
|
817
|
+
)
|
|
818
|
+
record.verification = after.to_dict()
|
|
819
|
+
|
|
820
|
+
# #2052: the gate is scoped to the lanes this run attempted and could
|
|
821
|
+
# have moved. The full report above is still journalled verbatim — this
|
|
822
|
+
# narrows what may TRIGGER a rollback, not what gets reported.
|
|
823
|
+
gate = rp.scope_verification(record.verification, lanes=record.lanes)
|
|
824
|
+
record.gate = gate.to_dict()
|
|
825
|
+
for finding in gate.advisory:
|
|
826
|
+
click.echo(
|
|
827
|
+
f" ~ advisory [{finding.get('severity')}] {finding.get('host')} "
|
|
828
|
+
f"{finding.get('lane')}: {finding.get('summary')} "
|
|
829
|
+
"— outside propagation's reach, fix by hand",
|
|
830
|
+
err=True,
|
|
831
|
+
)
|
|
832
|
+
if gate.unrollable:
|
|
833
|
+
click.echo(
|
|
834
|
+
" ~ lanes with no channel from this host: "
|
|
835
|
+
+ ", ".join(gate.unrollable),
|
|
836
|
+
err=True,
|
|
837
|
+
)
|
|
838
|
+
|
|
839
|
+
if gate.red and rollback_on_red:
|
|
840
|
+
# #1835: "a red post-deploy verification must roll back, not just
|
|
841
|
+
# report." Only the hosts THIS run updated — rolling back a host we
|
|
842
|
+
# never touched would undo somebody else's deliberate state.
|
|
843
|
+
down: list[str] = []
|
|
844
|
+
for host in updated_hosts:
|
|
845
|
+
machine = by_name.get(host)
|
|
846
|
+
if machine is None:
|
|
847
|
+
continue
|
|
848
|
+
ok, detail = _rollback_host(
|
|
849
|
+
machine, agent_port=AGENT_PORT, timeout=min(timeout, 120.0)
|
|
850
|
+
)
|
|
851
|
+
record.rolled_back.append(f"{host}: {detail}")
|
|
852
|
+
if not ok:
|
|
853
|
+
down.append(host)
|
|
854
|
+
click.echo(f" {'↩' if ok else '✗'} rollback {host}: {detail}")
|
|
855
|
+
# #2052 fault 1: a rollback that stops a service and does not restore
|
|
856
|
+
# it leaves the fleet WORSE off than the failed roll did — precision's
|
|
857
|
+
# coord-agent sat `inactive (dead)` until a human noticed. If any host
|
|
858
|
+
# did not come back, that is the headline, not a footnote.
|
|
859
|
+
if down:
|
|
860
|
+
record.error = (
|
|
861
|
+
"ROLLBACK LEFT AGENTS DOWN: "
|
|
862
|
+
+ ", ".join(down)
|
|
863
|
+
+ " — these hosts answered the rollback but never came back "
|
|
864
|
+
"on /health, and an SSH `systemctl --user restart "
|
|
865
|
+
"coord-agent` did not revive them either. Recover by hand "
|
|
866
|
+
"before anything else."
|
|
867
|
+
)
|
|
868
|
+
click.echo(f"error: {record.error}", err=True)
|
|
869
|
+
_finish(rp.STATUS_ROLLED_BACK, 2)
|
|
870
|
+
|
|
871
|
+
if gate.red:
|
|
872
|
+
_finish(rp.STATUS_FAILED, 1)
|
|
873
|
+
|
|
874
|
+
# ── 6. release the deploy gates that were waiting for this ───────────
|
|
875
|
+
# Reaching this line means `after.severity != "crit"` — both crit
|
|
876
|
+
# branches above already exit — so this roll is, definitionally, verified.
|
|
877
|
+
for key in rp.holds_to_release(quiescence, verified=True):
|
|
878
|
+
if not release_holds:
|
|
879
|
+
click.echo(f" · deploy gate {key} left held (--no-release-holds)")
|
|
880
|
+
continue
|
|
881
|
+
ok, detail = _release_hold(key)
|
|
882
|
+
if ok:
|
|
883
|
+
record.released_holds.append(key)
|
|
884
|
+
click.echo(f" {'✓' if ok else '✗'} release deploy gate {key}: {detail}")
|
|
885
|
+
|
|
886
|
+
_finish(rp.STATUS_VERIFIED, 0)
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
# ── #2101: the cordon loop's I/O half ───────────────────────────────────────
|
|
890
|
+
#
|
|
891
|
+
# The decisions live in `coord/release_cordon.py` (pure, clock passed in);
|
|
892
|
+
# what lives here is the part that needs a fleet: reading each host's python
|
|
893
|
+
# lane out of the `/health` sweep this command already did, writing the
|
|
894
|
+
# daemon-backed cordon store, and surfacing a blown drain deadline where an
|
|
895
|
+
# operator will actually see it.
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _python_lane_versions(
|
|
899
|
+
report, hosts: list[str], target_version: str | None
|
|
900
|
+
) -> dict[str, str | None]:
|
|
901
|
+
"""``{host: the OLDEST version its python lane reports}`` (#2101).
|
|
902
|
+
|
|
903
|
+
"Python lane" is whatever :func:`coord.release_propagate.verify_lane_kind`
|
|
904
|
+
grades as one — the venv itself plus the live `coord-serve` process —
|
|
905
|
+
rather than a second list that could drift from the one the roll and the
|
|
906
|
+
gate already use.
|
|
907
|
+
|
|
908
|
+
Two deliberate readings:
|
|
909
|
+
|
|
910
|
+
* the OLDEST readable version wins, because a host is as behind as its
|
|
911
|
+
most stale python lane. A venv that swapped while `coord-serve` still
|
|
912
|
+
runs the old generation is #2069's exact defect, and it must read as
|
|
913
|
+
"behind", not as "done";
|
|
914
|
+
* a host with an unreadable lane and no readable lane BEHIND the target is
|
|
915
|
+
``None`` — "no data", never "current" (#1834). `None` is what stops the
|
|
916
|
+
host being cordoned on a guess *and* what stops an existing cordon being
|
|
917
|
+
cleared on a failed HTTP call.
|
|
918
|
+
"""
|
|
919
|
+
from coord import release_cordon as rc # noqa: PLC0415
|
|
920
|
+
from coord import release_propagate as rp # noqa: PLC0415
|
|
921
|
+
|
|
922
|
+
seen: dict[str, list[str | None]] = {}
|
|
923
|
+
for lane in report.lanes:
|
|
924
|
+
if rp.verify_lane_kind(lane.lane) != rp.LANE_PYTHON:
|
|
925
|
+
continue
|
|
926
|
+
seen.setdefault(lane.host, []).append(lane.version)
|
|
927
|
+
|
|
928
|
+
out: dict[str, str | None] = {}
|
|
929
|
+
for host in hosts:
|
|
930
|
+
versions = seen.get(host) or []
|
|
931
|
+
oldest = _oldest_version(versions)
|
|
932
|
+
if (
|
|
933
|
+
oldest is not None
|
|
934
|
+
and any(v is None for v in versions)
|
|
935
|
+
and rc.version_drift(oldest, target_version) == 0
|
|
936
|
+
):
|
|
937
|
+
# Some lane could not be read at all, and every lane that COULD be
|
|
938
|
+
# read is already on the target. That is not proof of "current" —
|
|
939
|
+
# the unreadable lane is exactly the one #1834 says will be the
|
|
940
|
+
# one that bites — so report "no data" and leave any existing
|
|
941
|
+
# cordon exactly as it is. (A readable lane that IS behind is
|
|
942
|
+
# proof enough to cordon, and `_oldest_version` already
|
|
943
|
+
# surfaced it.)
|
|
944
|
+
oldest = None
|
|
945
|
+
out[host] = oldest
|
|
946
|
+
return out
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _oldest_version(versions: list[str | None]) -> str | None:
|
|
950
|
+
"""The lowest readable version in *versions*, or ``None``.
|
|
951
|
+
|
|
952
|
+
String comparison is wrong here (``0.5.9`` sorts above ``0.5.31``), so
|
|
953
|
+
this compares numerically component by component.
|
|
954
|
+
"""
|
|
955
|
+
def _key(raw: str) -> tuple[int, ...]:
|
|
956
|
+
parts: list[int] = []
|
|
957
|
+
for chunk in raw.lstrip("vV").split("."):
|
|
958
|
+
digits = ""
|
|
959
|
+
for ch in chunk:
|
|
960
|
+
if not ch.isdigit():
|
|
961
|
+
break
|
|
962
|
+
digits += ch
|
|
963
|
+
if not digits:
|
|
964
|
+
break
|
|
965
|
+
parts.append(int(digits))
|
|
966
|
+
return tuple(parts)
|
|
967
|
+
|
|
968
|
+
readable = [v for v in versions if v]
|
|
969
|
+
if not readable:
|
|
970
|
+
return None
|
|
971
|
+
return min(readable, key=_key)
|
|
972
|
+
|
|
973
|
+
|
|
974
|
+
def _apply_cordons(
|
|
975
|
+
*,
|
|
976
|
+
hosts: list[str],
|
|
977
|
+
report,
|
|
978
|
+
target_version: str | None,
|
|
979
|
+
busy_reasons: dict[str, str],
|
|
980
|
+
enabled: bool,
|
|
981
|
+
threshold: int | None,
|
|
982
|
+
ttl_seconds: float | None,
|
|
983
|
+
drain_deadline: float | None,
|
|
984
|
+
dry_run: bool,
|
|
985
|
+
) -> dict:
|
|
986
|
+
"""Plan and apply this run's cordons. Returns the journal fragment.
|
|
987
|
+
|
|
988
|
+
Never raises: a cordon store this run cannot write is recorded as an
|
|
989
|
+
error and the roll continues on its existing (quiescence-based) rules.
|
|
990
|
+
Failing the whole propagation because the cordon could not be renewed
|
|
991
|
+
would make #2101's fix strictly worse than not having it.
|
|
992
|
+
"""
|
|
993
|
+
import time # noqa: PLC0415
|
|
994
|
+
|
|
995
|
+
from coord import release_cordon as rc # noqa: PLC0415
|
|
996
|
+
from coord.machine_pause import ( # noqa: PLC0415
|
|
997
|
+
clear_cordon,
|
|
998
|
+
cordons as read_cordons,
|
|
999
|
+
set_cordon,
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
outcome = rc.CordonOutcome()
|
|
1003
|
+
try:
|
|
1004
|
+
existing = read_cordons()
|
|
1005
|
+
except Exception as exc: # noqa: BLE001 — see docstring
|
|
1006
|
+
outcome.errors.append(f"could not read the cordon store: {exc}")
|
|
1007
|
+
return outcome.to_dict()
|
|
1008
|
+
|
|
1009
|
+
plan = rc.plan_cordons(
|
|
1010
|
+
target_version=target_version,
|
|
1011
|
+
host_versions=_python_lane_versions(report, hosts, target_version),
|
|
1012
|
+
existing=existing,
|
|
1013
|
+
now=time.time(),
|
|
1014
|
+
ttl_seconds=(
|
|
1015
|
+
rc.DEFAULT_TTL_SECONDS if ttl_seconds is None else ttl_seconds
|
|
1016
|
+
),
|
|
1017
|
+
drain_deadline=(
|
|
1018
|
+
rc.DEFAULT_DRAIN_DEADLINE_SECONDS
|
|
1019
|
+
if drain_deadline is None
|
|
1020
|
+
else drain_deadline
|
|
1021
|
+
),
|
|
1022
|
+
threshold=(
|
|
1023
|
+
rc.DEFAULT_DRIFT_THRESHOLD if threshold is None else threshold
|
|
1024
|
+
),
|
|
1025
|
+
busy_reasons=busy_reasons,
|
|
1026
|
+
enabled=enabled,
|
|
1027
|
+
)
|
|
1028
|
+
for line in plan.render():
|
|
1029
|
+
click.echo(line)
|
|
1030
|
+
if dry_run:
|
|
1031
|
+
# `--dry-run` promises to change nothing, cordon store included. The
|
|
1032
|
+
# plan above is still printed, so a dry run answers "and what would
|
|
1033
|
+
# this do to the fleet's routing?" rather than going silent on it.
|
|
1034
|
+
outcome.errors.append("dry-run: cordon store not written")
|
|
1035
|
+
return {**plan.to_dict(), **outcome.to_dict()}
|
|
1036
|
+
|
|
1037
|
+
outcome.expired = list(plan.expired)
|
|
1038
|
+
for record in plan.cordon:
|
|
1039
|
+
try:
|
|
1040
|
+
set_cordon(
|
|
1041
|
+
record.machine,
|
|
1042
|
+
reason=record.reason,
|
|
1043
|
+
target_version=record.target_version,
|
|
1044
|
+
ttl_seconds=max(0.0, record.expires_at - record.renewed_at),
|
|
1045
|
+
)
|
|
1046
|
+
outcome.cordoned.append(record.machine)
|
|
1047
|
+
except Exception as exc: # noqa: BLE001 — see docstring
|
|
1048
|
+
outcome.errors.append(f"cordon {record.machine}: {exc}")
|
|
1049
|
+
click.echo(f" ✗ cordon {record.machine}: {exc}", err=True)
|
|
1050
|
+
for name in plan.uncordon:
|
|
1051
|
+
try:
|
|
1052
|
+
if clear_cordon(name):
|
|
1053
|
+
outcome.uncordoned.append(name)
|
|
1054
|
+
except Exception as exc: # noqa: BLE001 — see docstring
|
|
1055
|
+
outcome.errors.append(f"uncordon {name}: {exc}")
|
|
1056
|
+
click.echo(f" ✗ uncordon {name}: {exc}", err=True)
|
|
1057
|
+
|
|
1058
|
+
for escalation in plan.escalations:
|
|
1059
|
+
_escalate_drain(escalation)
|
|
1060
|
+
outcome.escalated.append(escalation.to_dict())
|
|
1061
|
+
|
|
1062
|
+
return outcome.to_dict()
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
#: Where a blown drain deadline (#2101 trap C) is recorded, in the same
|
|
1066
|
+
#: escalation channel `coord drive-queue`'s own alerts use — so it shows up in
|
|
1067
|
+
#: the TUI's escalations panel and `coord drive escalations` with no new
|
|
1068
|
+
#: surface to remember to look at. Mirrors
|
|
1069
|
+
#: `coord.drive_queue.QUEUE_ALERT_REPO`'s pseudo-repo convention.
|
|
1070
|
+
DRAIN_ALERT_REPO = "(release-cordon)"
|
|
1071
|
+
DRAIN_ALERT_ISSUE = 0
|
|
1072
|
+
DRAIN_ALERT_STAGE = "release-cordon"
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _escalate_drain(escalation) -> None:
|
|
1076
|
+
"""Surface a host that will not drain — loudly, in three places.
|
|
1077
|
+
|
|
1078
|
+
stderr (the timer's journal), the escalation table (the TUI and
|
|
1079
|
+
`coord drive escalations`) and the propagation journal. #2101's acceptance
|
|
1080
|
+
criterion 4 is explicitly about the surfaced MESSAGE rather than an
|
|
1081
|
+
internal state change, because a silent forever-wait is the failure this
|
|
1082
|
+
whole mechanism replaces.
|
|
1083
|
+
"""
|
|
1084
|
+
click.echo(f" ! {escalation.message}", err=True)
|
|
1085
|
+
try:
|
|
1086
|
+
from coord.state import record_drive_escalation # noqa: PLC0415
|
|
1087
|
+
|
|
1088
|
+
record_drive_escalation(
|
|
1089
|
+
DRAIN_ALERT_REPO,
|
|
1090
|
+
DRAIN_ALERT_ISSUE,
|
|
1091
|
+
stage=DRAIN_ALERT_STAGE,
|
|
1092
|
+
reason=escalation.message,
|
|
1093
|
+
gate_readings=(
|
|
1094
|
+
f"machine={escalation.machine} | "
|
|
1095
|
+
f"waited={escalation.waited_seconds:.0f}s | "
|
|
1096
|
+
f"deadline={escalation.deadline_seconds:.0f}s"
|
|
1097
|
+
),
|
|
1098
|
+
proposed_command=escalation.command,
|
|
1099
|
+
)
|
|
1100
|
+
except Exception as exc: # noqa: BLE001 — the stderr line above is the
|
|
1101
|
+
# floor; an escalation table that cannot be written must not take the
|
|
1102
|
+
# message down with it.
|
|
1103
|
+
click.echo(f" (could not record the drain escalation: {exc})", err=True)
|
|
1104
|
+
|
|
1105
|
+
|
|
1106
|
+
def _uncordon_hosts(hosts: list[str], journal: dict) -> None:
|
|
1107
|
+
"""Clear the release cordon on every host in *hosts*, best effort."""
|
|
1108
|
+
if not hosts:
|
|
1109
|
+
return
|
|
1110
|
+
from coord.machine_pause import clear_cordon # noqa: PLC0415
|
|
1111
|
+
|
|
1112
|
+
for host in hosts:
|
|
1113
|
+
try:
|
|
1114
|
+
if clear_cordon(host):
|
|
1115
|
+
journal.setdefault("uncordoned", []).append(host)
|
|
1116
|
+
click.echo(f" ✓ uncordon {host}: rolled, work may resume")
|
|
1117
|
+
except Exception as exc: # noqa: BLE001
|
|
1118
|
+
journal.setdefault("errors", []).append(f"uncordon {host}: {exc}")
|
|
1119
|
+
click.echo(f" ✗ uncordon {host}: {exc}", err=True)
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def _local_machine_name(config) -> str | None:
|
|
1123
|
+
"""This host's name in ``coordinator.yml``, if it is in there at all."""
|
|
1124
|
+
import socket # noqa: PLC0415
|
|
1125
|
+
|
|
1126
|
+
here = socket.gethostname().split(".")[0].lower()
|
|
1127
|
+
for machine in getattr(config, "machines", ()) or ():
|
|
1128
|
+
if machine.name.lower() == here:
|
|
1129
|
+
return machine.name
|
|
1130
|
+
if str(getattr(machine, "host", "")).split(".")[0].lower() == here:
|
|
1131
|
+
return machine.name
|
|
1132
|
+
return None
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def _roll_python(machine, *, target_version: str, agent_port: int, timeout: float,
|
|
1136
|
+
force: bool) -> tuple[bool, str, bool]:
|
|
1137
|
+
"""POST /update and wait for the agent to actually report the version.
|
|
1138
|
+
|
|
1139
|
+
Success is judged by the version the agent reports, never by "the POST
|
|
1140
|
+
was accepted" (#1568: a stale pip index makes a no-op look like a
|
|
1141
|
+
success) — the wait loop is ``coord agent update``'s own, reused rather
|
|
1142
|
+
than reimplemented so the two can't drift.
|
|
1143
|
+
|
|
1144
|
+
Three-element return, ``(ok, detail, serve_unit_ok)`` (#2095 review):
|
|
1145
|
+
|
|
1146
|
+
* ``ok`` is the whole lane's own verdict, exactly as before — #2095
|
|
1147
|
+
correctly made this ``False`` whenever ANY restarted sibling failed,
|
|
1148
|
+
coord-web included, so the lane never prints a `✓` over a real
|
|
1149
|
+
outage.
|
|
1150
|
+
* ``serve_unit_ok`` is narrower and answers a different question: is
|
|
1151
|
+
*coord-serve itself* — the unit whose version every other host's
|
|
1152
|
+
caller depends on, and the entire reason the main roll loop's
|
|
1153
|
+
``daemon_python_failed`` cascade exists — confirmed to be on
|
|
1154
|
+
``target_version`` and running? It is ``False`` only when the venv
|
|
1155
|
+
swap itself never completed (nothing downstream can be trusted
|
|
1156
|
+
either) or coord-serve was itself the sibling that failed to
|
|
1157
|
+
restart. A coord-web-only (or coord-drive-queue-only) failure
|
|
1158
|
+
leaves it ``True``. Reusing ``ok`` for that cascade decision used to
|
|
1159
|
+
mean a coord-web outage on the daemon host — coord-serve unaffected —
|
|
1160
|
+
also halted every other host's python lane for the rest of the run:
|
|
1161
|
+
a materially larger blast radius than before #2095, and exactly the
|
|
1162
|
+
2026-08-10 incident's shape (dellserver's coord-serve was fine;
|
|
1163
|
+
coord-web was what failed). Callers deciding whether it's safe to
|
|
1164
|
+
let OTHER hosts proceed must key off ``serve_unit_ok``, not ``ok``.
|
|
1165
|
+
"""
|
|
1166
|
+
from coord.commands.agent_ops import ( # noqa: PLC0415
|
|
1167
|
+
_fetch_pre_started_at,
|
|
1168
|
+
_wait_agents_updated,
|
|
1169
|
+
)
|
|
1170
|
+
from coord.release_verify import DAEMON_UNIT # noqa: PLC0415
|
|
1171
|
+
|
|
1172
|
+
pre = _fetch_pre_started_at([machine])
|
|
1173
|
+
status, body, error = _post(
|
|
1174
|
+
f"http://{machine.host}:{agent_port}/update",
|
|
1175
|
+
{"target_version": target_version, "force": force},
|
|
1176
|
+
timeout=15.0,
|
|
1177
|
+
)
|
|
1178
|
+
if error:
|
|
1179
|
+
return False, error, False
|
|
1180
|
+
if status == 409:
|
|
1181
|
+
# The agent refused: live sessions, or an editable install. Both are
|
|
1182
|
+
# correct refusals and neither is this command's to override.
|
|
1183
|
+
return False, str(body.get("error") or "refused (409)"), False
|
|
1184
|
+
if status != 202:
|
|
1185
|
+
return False, f"HTTP {status}", False
|
|
1186
|
+
|
|
1187
|
+
outcomes = _wait_agents_updated(
|
|
1188
|
+
[machine], target_version=target_version, timeout=timeout,
|
|
1189
|
+
pre_started_at=pre,
|
|
1190
|
+
)
|
|
1191
|
+
outcome = outcomes.get(machine.name) or {}
|
|
1192
|
+
if not outcome.get("matched"):
|
|
1193
|
+
return False, str(
|
|
1194
|
+
outcome.get("error")
|
|
1195
|
+
or f"still reporting v{outcome.get('version_now', '?')} after "
|
|
1196
|
+
f"{timeout:.0f}s (last update result: {outcome.get('result')})"
|
|
1197
|
+
), False
|
|
1198
|
+
|
|
1199
|
+
# #2069: /update only ever restarted the agent — coord-serve, coord-web
|
|
1200
|
+
# and coord-drive-queue kept running the generation they started with
|
|
1201
|
+
# until a human restarted them by hand. This is the rest of the lane,
|
|
1202
|
+
# not a separate one: it runs against whichever of those three units the
|
|
1203
|
+
# freshly-restarted agent finds actually running on ITS host, so a run
|
|
1204
|
+
# that never touches coord-web anywhere still reports "now vX.Y.Z" clean.
|
|
1205
|
+
sib_ok, sib_detail, sib_failed = _restart_sibling_services(machine, agent_port=agent_port)
|
|
1206
|
+
if sib_ok is not False:
|
|
1207
|
+
# True (nothing failed) and None (this agent predates
|
|
1208
|
+
# /restart-services entirely — no channel to have restarted anything
|
|
1209
|
+
# through, see `_restart_sibling_services`) both still count as a
|
|
1210
|
+
# lane success: neither one is a service THIS run took down.
|
|
1211
|
+
return True, f"now v{target_version}; {sib_detail}", True
|
|
1212
|
+
# #2095: this used to return `True` here too — "the venv swap succeeded"
|
|
1213
|
+
# bleeding into "the lane succeeded", printed as a leading `✓` over a
|
|
1214
|
+
# line that itself said `FAILED to restart: coord-web`. That is exactly
|
|
1215
|
+
# what happened during the 2026-08-10 0.5.15 -> 0.5.26 roll: the phone
|
|
1216
|
+
# dashboard went offline and the run reported success. The old comment
|
|
1217
|
+
# here claimed `coord release verify` would catch the resulting skew as
|
|
1218
|
+
# the justification for staying green — it does not: verify grades
|
|
1219
|
+
# *versions*, and there is no coord-web lane in it at all, so a dead
|
|
1220
|
+
# service is invisible to the thing named as its backstop. A sibling
|
|
1221
|
+
# that failed to (re)start — or restarted but never answered its own
|
|
1222
|
+
# liveness probe, see `agent_app._probe_liveness` — is not a lane
|
|
1223
|
+
# success, full stop, whatever the venv itself did.
|
|
1224
|
+
#
|
|
1225
|
+
# `sib_failed` is only populated when the endpoint told us per-unit
|
|
1226
|
+
# detail (a real 200/500-with-`units`-body); the opaque-failure branches
|
|
1227
|
+
# in `_restart_sibling_services` (a network error reaching the endpoint
|
|
1228
|
+
# at all, or a 500 with no body) return it empty because coord-serve's
|
|
1229
|
+
# own fate is genuinely unknown there — treated conservatively as NOT
|
|
1230
|
+
# confirmed, same as before #2095's per-unit distinction existed.
|
|
1231
|
+
serve_unit_ok = (DAEMON_UNIT not in sib_failed) if sib_failed else False
|
|
1232
|
+
return False, f"now v{target_version}, but {sib_detail}", serve_unit_ok
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
def _restart_sibling_services(
|
|
1236
|
+
machine, *, agent_port: int, timeout: float = 120.0
|
|
1237
|
+
) -> tuple[bool | None, str, dict[str, str]]:
|
|
1238
|
+
"""``POST /restart-services`` — the rest of a python-lane roll (#2069).
|
|
1239
|
+
|
|
1240
|
+
``/update`` swaps the venv and re-execs *the agent* — and nothing else.
|
|
1241
|
+
``coord-serve``, ``coord-web`` and ``coord-drive-queue`` keep running the
|
|
1242
|
+
generation they started with until something restarts them, so this is
|
|
1243
|
+
called right after ``/update`` reports success above. Which of the three
|
|
1244
|
+
units actually need restarting is decided on the agent side, from what
|
|
1245
|
+
it finds running on its own host (see the endpoint's docstring) — this
|
|
1246
|
+
function only reports what came back.
|
|
1247
|
+
|
|
1248
|
+
Three-element return: ``(ok, detail, failed)``. ``ok``/``detail`` follow
|
|
1249
|
+
the same tri-state convention as the other lane executors below
|
|
1250
|
+
(``_roll_units``/``_roll_tui``'s ``ok=None`` "no channel"):
|
|
1251
|
+
|
|
1252
|
+
* ``True`` — the endpoint answered and no unit it touched failed.
|
|
1253
|
+
* ``False`` (#2095) — a sibling this run took down and never brought
|
|
1254
|
+
back: a real outage, not a cosmetic detail to carry forward under a
|
|
1255
|
+
`✓`. DOES fail the python lane — see ``_roll_python``. This used to
|
|
1256
|
+
defer to `coord release verify` catching the resulting skew; it
|
|
1257
|
+
cannot — verify grades versions, not liveness, and carries no lane
|
|
1258
|
+
for these units at all, so relying on it left exactly the outage this
|
|
1259
|
+
issue is about invisible to its own named backstop.
|
|
1260
|
+
* ``None`` — this host's agent predates the endpoint entirely (HTTP
|
|
1261
|
+
404): there is no channel here to have restarted anything through,
|
|
1262
|
+
the same "unrollable" shape as a lane with no executor at all, not a
|
|
1263
|
+
failure of this roll.
|
|
1264
|
+
|
|
1265
|
+
``failed`` (#2095 review) is the ``{unit: detail}`` mapping of units
|
|
1266
|
+
explicitly confirmed to have failed to restart — empty when ``ok`` is
|
|
1267
|
+
not ``False``, and ALSO empty for the opaque-failure branches below (a
|
|
1268
|
+
network error reaching the endpoint, or a 500 with no ``units`` body),
|
|
1269
|
+
where no individual unit's fate is actually known. ``_roll_python`` uses
|
|
1270
|
+
this — not the aggregate ``ok`` — to tell whether coord-serve itself is
|
|
1271
|
+
the sibling that failed, which is the only thing its ``serve_unit_ok``
|
|
1272
|
+
(and, through it, the main roll loop's daemon-python-failed cascade)
|
|
1273
|
+
cares about: a coord-web-only failure must not be indistinguishable from
|
|
1274
|
+
a coord-serve one to that caller.
|
|
1275
|
+
"""
|
|
1276
|
+
status, body, error = _post(
|
|
1277
|
+
f"http://{machine.host}:{agent_port}/restart-services", {}, timeout=timeout,
|
|
1278
|
+
)
|
|
1279
|
+
if error:
|
|
1280
|
+
return False, f"sibling service restart: {error}", {}
|
|
1281
|
+
if status == 404:
|
|
1282
|
+
# Pre-#2069 agent build: /restart-services doesn't exist yet. Not
|
|
1283
|
+
# this run's failure to have restarted anything through a channel
|
|
1284
|
+
# that was never there — `coord agent update --all` is what closes
|
|
1285
|
+
# this gap, not a red python lane.
|
|
1286
|
+
return None, "agent predates /restart-services (HTTP 404) — update the agent build", {}
|
|
1287
|
+
# The endpoint (`agent_app.py`'s `restart_services`) returns HTTP 500 — with the
|
|
1288
|
+
# *same* `{"units": {...}}` body shape as 200 — whenever any single unit fails to
|
|
1289
|
+
# restart. That is the exact partial-failure path this function exists to report
|
|
1290
|
+
# in detail, so a 500 *with a `units` body* must still be parsed below rather than
|
|
1291
|
+
# treated as an opaque failure. A 500 WITHOUT a `units` body is a different,
|
|
1292
|
+
# genuinely-unexpected failure (an unhandled exception, a proxy error, ...) —
|
|
1293
|
+
# Starlette's own default error page carries no such body — and must still
|
|
1294
|
+
# short-circuit, or a real crash would be misread as "no sibling units to
|
|
1295
|
+
# restart" / a false-positive success.
|
|
1296
|
+
if status != 200 and not (status == 500 and "units" in body):
|
|
1297
|
+
return False, f"sibling service restart: {body.get('error') or f'HTTP {status}'}", {}
|
|
1298
|
+
|
|
1299
|
+
units = body.get("units") or {}
|
|
1300
|
+
restarted = sorted(u for u, r in units.items() if isinstance(r, dict) and r.get("restarted"))
|
|
1301
|
+
skipped = sorted(
|
|
1302
|
+
u for u, r in units.items() if isinstance(r, dict) and r.get("restarted") is None
|
|
1303
|
+
)
|
|
1304
|
+
failed = {
|
|
1305
|
+
u: (r.get("detail") or "?")
|
|
1306
|
+
for u, r in units.items() if isinstance(r, dict) and r.get("restarted") is False
|
|
1307
|
+
}
|
|
1308
|
+
parts = []
|
|
1309
|
+
if restarted:
|
|
1310
|
+
parts.append(f"restarted {', '.join(restarted)}")
|
|
1311
|
+
if skipped:
|
|
1312
|
+
parts.append(f"not running here: {', '.join(skipped)}")
|
|
1313
|
+
if failed:
|
|
1314
|
+
parts.append(
|
|
1315
|
+
"FAILED to restart: "
|
|
1316
|
+
+ ", ".join(f"{u} ({detail})" for u, detail in sorted(failed.items()))
|
|
1317
|
+
)
|
|
1318
|
+
if not parts:
|
|
1319
|
+
parts.append(body.get("detail") or "no sibling units to restart")
|
|
1320
|
+
return not failed, "; ".join(parts), failed
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def _roll_units(machine, *, agent_port: int) -> tuple[bool | None, str]:
|
|
1324
|
+
"""POST /deploy-units — the `deploy/**` lane's deploy step (#1831).
|
|
1325
|
+
|
|
1326
|
+
Returns ``ok=None`` when this host offers no channel for the lane at all
|
|
1327
|
+
(see :func:`_roll_tui` and #2052): the run is not accountable for a lane
|
|
1328
|
+
it was structurally unable to roll.
|
|
1329
|
+
"""
|
|
1330
|
+
status, body, error = _post(
|
|
1331
|
+
f"http://{machine.host}:{agent_port}/deploy-units", {}, timeout=30.0
|
|
1332
|
+
)
|
|
1333
|
+
if error:
|
|
1334
|
+
return False, error
|
|
1335
|
+
if status in (404, 405):
|
|
1336
|
+
# Bootstrap: this agent predates the endpoint. It will have it after
|
|
1337
|
+
# the python lane above lands, so this is a *next run* fact, not a
|
|
1338
|
+
# failure — recorded rather than swallowed, and NOT grounds for the
|
|
1339
|
+
# gate to revert everything else this run got right (#2052).
|
|
1340
|
+
return None, ("agent has no /deploy-units yet (predates #1835) — "
|
|
1341
|
+
"the next propagation will roll this lane")
|
|
1342
|
+
if status != 200:
|
|
1343
|
+
return False, str(body.get("error") or body.get("summary") or f"HTTP {status}")
|
|
1344
|
+
units = body.get("units") or []
|
|
1345
|
+
changed = [u.get("name") for u in units if u.get("action") == "updated"]
|
|
1346
|
+
new = [u.get("name") for u in units if u.get("action") == "new"]
|
|
1347
|
+
parts = []
|
|
1348
|
+
parts.append(f"{len(changed)} unit(s) refreshed" if changed else "units already current")
|
|
1349
|
+
if body.get("reloaded"):
|
|
1350
|
+
parts.append("daemon-reload ok")
|
|
1351
|
+
if new:
|
|
1352
|
+
parts.append(
|
|
1353
|
+
f"{len(new)} packaged unit(s) NOT installed here ({', '.join(sorted(map(str, new)))}) "
|
|
1354
|
+
"— a release does not decide which services a host runs"
|
|
1355
|
+
)
|
|
1356
|
+
return True, "; ".join(parts)
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
def _roll_tui(
|
|
1360
|
+
machine, *, target_version: str, local_name: str | None
|
|
1361
|
+
) -> tuple[bool | None, str]:
|
|
1362
|
+
"""`coord tui update` — local host only, and honest about the rest.
|
|
1363
|
+
|
|
1364
|
+
``coord-tui`` is a binary in each host's ``~/.local/bin``; there is no
|
|
1365
|
+
agent endpoint that installs it, so this lane can only roll where this
|
|
1366
|
+
command is running. Remote hosts are recorded as an explicit gap rather
|
|
1367
|
+
than silently omitted — a lane nobody can see is the lane that bites
|
|
1368
|
+
(#1834).
|
|
1369
|
+
|
|
1370
|
+
#2052: that gap returns ``ok=None``, not ``ok=False``. It used to return
|
|
1371
|
+
False, which the post-roll gate then read as a failed lane and
|
|
1372
|
+
``--rollback-on-red`` used as grounds to revert three good python rolls.
|
|
1373
|
+
A lane that reports "there is no remote install path" in its own failure
|
|
1374
|
+
message cannot also be evidence that this run went wrong.
|
|
1375
|
+
"""
|
|
1376
|
+
import subprocess # noqa: PLC0415
|
|
1377
|
+
|
|
1378
|
+
if local_name is None or machine.name != local_name:
|
|
1379
|
+
return None, (
|
|
1380
|
+
f"coord-tui is a per-host binary with no remote install path — run "
|
|
1381
|
+
f"`coord tui update --version {target_version}` on {machine.name}"
|
|
1382
|
+
)
|
|
1383
|
+
try:
|
|
1384
|
+
proc = subprocess.run(
|
|
1385
|
+
[sys.executable, "-m", "coord.cli", "tui", "update",
|
|
1386
|
+
"--version", target_version],
|
|
1387
|
+
capture_output=True, text=True, timeout=300,
|
|
1388
|
+
)
|
|
1389
|
+
except Exception as exc: # noqa: BLE001
|
|
1390
|
+
return False, f"{type(exc).__name__}: {exc}"
|
|
1391
|
+
if proc.returncode == 0:
|
|
1392
|
+
return True, f"coord-tui now v{target_version}"
|
|
1393
|
+
return False, (proc.stderr or proc.stdout or f"exit {proc.returncode}").strip()[:300]
|
|
1394
|
+
|
|
1395
|
+
|
|
1396
|
+
def _get(url: str, *, timeout: float) -> tuple[int | None, dict]:
|
|
1397
|
+
"""GET JSON, tolerantly. ``(status, body)`` — never raises."""
|
|
1398
|
+
import httpx # noqa: PLC0415
|
|
1399
|
+
|
|
1400
|
+
try:
|
|
1401
|
+
resp = httpx.get(url, timeout=timeout)
|
|
1402
|
+
except Exception: # noqa: BLE001
|
|
1403
|
+
return None, {}
|
|
1404
|
+
try:
|
|
1405
|
+
body = resp.json()
|
|
1406
|
+
except Exception: # noqa: BLE001
|
|
1407
|
+
body = {}
|
|
1408
|
+
return resp.status_code, (body if isinstance(body, dict) else {})
|
|
1409
|
+
|
|
1410
|
+
|
|
1411
|
+
def _wait_agent_back(machine, *, agent_port: int, timeout: float) -> tuple[bool, str]:
|
|
1412
|
+
"""Poll ``/health`` until the agent answers again. ``(back, version)``.
|
|
1413
|
+
|
|
1414
|
+
A rollback re-execs the agent process, and #2052 fault 1 is what happens
|
|
1415
|
+
when that re-exec does not take: precision's ``coord-agent`` went
|
|
1416
|
+
``inactive (dead)`` at the moment of the rollback and stayed there until
|
|
1417
|
+
a human noticed. "The POST was accepted" is therefore not an outcome —
|
|
1418
|
+
the outcome is whether the service is serving again.
|
|
1419
|
+
"""
|
|
1420
|
+
import time # noqa: PLC0415
|
|
1421
|
+
|
|
1422
|
+
deadline = time.time() + max(timeout, 1.0)
|
|
1423
|
+
poll = min(2.0, max(timeout / 10, 0.05))
|
|
1424
|
+
while True:
|
|
1425
|
+
status, body = _get(f"http://{machine.host}:{agent_port}/health", timeout=3.0)
|
|
1426
|
+
if status == 200:
|
|
1427
|
+
return True, str(body.get("version") or "?")
|
|
1428
|
+
if time.time() >= deadline:
|
|
1429
|
+
return False, "?"
|
|
1430
|
+
time.sleep(poll)
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
def _rollback_host(
|
|
1434
|
+
machine, *, agent_port: int, timeout: float = 90.0
|
|
1435
|
+
) -> tuple[bool, str]:
|
|
1436
|
+
"""POST /rollback — back to the previous blue/green generation (#1241) —
|
|
1437
|
+
and then put the service back on its feet.
|
|
1438
|
+
|
|
1439
|
+
#2052 fault 1: "a rollback that stops a service and does not restore it
|
|
1440
|
+
leaves the fleet worse off than the failed roll did." This used to return
|
|
1441
|
+
True the instant the agent answered 202, which is a statement about the
|
|
1442
|
+
*request*, not about the host. It now waits for ``/health`` to answer
|
|
1443
|
+
again, escalates once to the documented SSH ``systemctl --user restart
|
|
1444
|
+
coord-agent`` (#404/#1568 — ``os.execv`` self-restart does not always
|
|
1445
|
+
take under systemd), and only then gives up — loudly, naming the host as
|
|
1446
|
+
DOWN rather than reporting a tidy "rolling back".
|
|
1447
|
+
"""
|
|
1448
|
+
from coord.commands.agent_ops import _escalate_restart # noqa: PLC0415
|
|
1449
|
+
|
|
1450
|
+
status, body, error = _post(
|
|
1451
|
+
f"http://{machine.host}:{agent_port}/rollback", {"force": True}, timeout=30.0
|
|
1452
|
+
)
|
|
1453
|
+
if error:
|
|
1454
|
+
return False, error
|
|
1455
|
+
if status == 404:
|
|
1456
|
+
return False, "no previous generation on this host"
|
|
1457
|
+
if status != 202:
|
|
1458
|
+
return False, str(body.get("error") or f"HTTP {status}")
|
|
1459
|
+
|
|
1460
|
+
back, version = _wait_agent_back(machine, agent_port=agent_port, timeout=timeout)
|
|
1461
|
+
if back:
|
|
1462
|
+
return True, f"rolled back; agent is serving again on v{version}"
|
|
1463
|
+
|
|
1464
|
+
# The re-exec did not take. This is the documented systemd stall, and it
|
|
1465
|
+
# has a documented fix — apply it rather than handing the operator a
|
|
1466
|
+
# dead host and a tidy success message.
|
|
1467
|
+
escalated = _escalate_restart(machine)
|
|
1468
|
+
if escalated:
|
|
1469
|
+
back, version = _wait_agent_back(
|
|
1470
|
+
machine, agent_port=agent_port, timeout=min(timeout, 60.0)
|
|
1471
|
+
)
|
|
1472
|
+
if back:
|
|
1473
|
+
return True, (
|
|
1474
|
+
f"rolled back; agent needed an SSH `systemctl --user restart "
|
|
1475
|
+
f"coord-agent` but is serving again on v{version}"
|
|
1476
|
+
)
|
|
1477
|
+
return False, (
|
|
1478
|
+
"rolled back the venv but the agent is DOWN — it never came back on "
|
|
1479
|
+
f"/health within {timeout:.0f}s and "
|
|
1480
|
+
+ (
|
|
1481
|
+
"the SSH restart did not revive it"
|
|
1482
|
+
if escalated
|
|
1483
|
+
else "the SSH `systemctl --user restart coord-agent` escalation "
|
|
1484
|
+
"could not run"
|
|
1485
|
+
)
|
|
1486
|
+
+ f". Recover by hand on {machine.name}."
|
|
1487
|
+
)
|
|
1488
|
+
|
|
1489
|
+
|
|
1490
|
+
def _release_hold(key: str) -> tuple[bool, str]:
|
|
1491
|
+
"""``coord drive-queue resume REPO ISSUE`` — the gate the deploy was for.
|
|
1492
|
+
|
|
1493
|
+
The queue's own command takes the pair, not the ``repo#issue`` key, so
|
|
1494
|
+
the key is split here rather than a second spelling of "resume this
|
|
1495
|
+
gate" being invented alongside it.
|
|
1496
|
+
"""
|
|
1497
|
+
import subprocess # noqa: PLC0415
|
|
1498
|
+
|
|
1499
|
+
from coord.drive_queue import parse_key # noqa: PLC0415
|
|
1500
|
+
|
|
1501
|
+
parsed = parse_key(key)
|
|
1502
|
+
if parsed is None:
|
|
1503
|
+
return False, f"unparseable queue key {key!r}"
|
|
1504
|
+
repo, issue = parsed
|
|
1505
|
+
try:
|
|
1506
|
+
proc = subprocess.run(
|
|
1507
|
+
[sys.executable, "-m", "coord.cli", "drive-queue", "resume",
|
|
1508
|
+
repo, str(issue)],
|
|
1509
|
+
capture_output=True, text=True, timeout=60,
|
|
1510
|
+
)
|
|
1511
|
+
except Exception as exc: # noqa: BLE001
|
|
1512
|
+
return False, f"{type(exc).__name__}: {exc}"
|
|
1513
|
+
if proc.returncode == 0:
|
|
1514
|
+
return True, "queue released"
|
|
1515
|
+
return False, (proc.stderr or proc.stdout or f"exit {proc.returncode}").strip()[:200]
|
|
1516
|
+
|
|
1517
|
+
|
|
1518
|
+
@release_group.command(
|
|
1519
|
+
"cordon",
|
|
1520
|
+
help=(
|
|
1521
|
+
"Inspect, set or clear release cordons (#2101). A cordoned machine "
|
|
1522
|
+
"starts no NEW work — in-flight work is never touched — so it drains "
|
|
1523
|
+
"into a state where `coord release propagate` can roll it. "
|
|
1524
|
+
"`coord release propagate` manages these automatically; this command "
|
|
1525
|
+
"is the operator's window into them and the documented override for a "
|
|
1526
|
+
"host that will not drain."
|
|
1527
|
+
),
|
|
1528
|
+
)
|
|
1529
|
+
@click.argument("machines", nargs=-1)
|
|
1530
|
+
@click.option("--clear", "clear", is_flag=True,
|
|
1531
|
+
help="Clear the named machines' cordons (or --all of them). "
|
|
1532
|
+
"This lets work resume and LEAVES THE HOST BEHIND — the "
|
|
1533
|
+
"next propagate run will cordon it again unless whatever "
|
|
1534
|
+
"was wedged has been fixed.")
|
|
1535
|
+
@click.option("--all", "all_machines", is_flag=True,
|
|
1536
|
+
help="With --clear: clear every release cordon.")
|
|
1537
|
+
@click.option("--reason", default="", help="Free text stored with the cordon.")
|
|
1538
|
+
@click.option("--target", "target_version", default=None,
|
|
1539
|
+
help="Version this cordon is draining for; shown in every "
|
|
1540
|
+
"surface that renders the cordon.")
|
|
1541
|
+
@click.option("--ttl", default=None, type=float,
|
|
1542
|
+
help="Seconds before the cordon lapses on its own "
|
|
1543
|
+
"(default 3600). A cordon ALWAYS expires — see #2101.")
|
|
1544
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit JSON.")
|
|
1545
|
+
def release_cordon(
|
|
1546
|
+
machines: tuple[str, ...],
|
|
1547
|
+
clear: bool,
|
|
1548
|
+
all_machines: bool,
|
|
1549
|
+
reason: str,
|
|
1550
|
+
target_version: str | None,
|
|
1551
|
+
ttl: float | None,
|
|
1552
|
+
as_json: bool,
|
|
1553
|
+
) -> None:
|
|
1554
|
+
"""List (no args), set, or clear release cordons.
|
|
1555
|
+
|
|
1556
|
+
Every cordon carries an owner, a reason, a creation time and an expiry,
|
|
1557
|
+
and is stored separately from `coord pause` — so this command can never
|
|
1558
|
+
clear a pause an operator set by hand, and `coord unpause` can never lift
|
|
1559
|
+
a cordon out from under a drain (#2101 trap A).
|
|
1560
|
+
"""
|
|
1561
|
+
import json as _json # noqa: PLC0415
|
|
1562
|
+
import time # noqa: PLC0415
|
|
1563
|
+
|
|
1564
|
+
from coord import release_cordon as rc # noqa: PLC0415
|
|
1565
|
+
from coord.machine_pause import ( # noqa: PLC0415
|
|
1566
|
+
clear_cordon,
|
|
1567
|
+
cordons as read_cordons,
|
|
1568
|
+
set_cordon,
|
|
1569
|
+
)
|
|
1570
|
+
|
|
1571
|
+
if clear:
|
|
1572
|
+
targets = list(machines)
|
|
1573
|
+
if all_machines:
|
|
1574
|
+
targets = sorted(read_cordons())
|
|
1575
|
+
if not targets:
|
|
1576
|
+
raise click.ClickException(
|
|
1577
|
+
"name at least one machine, or pass --all"
|
|
1578
|
+
)
|
|
1579
|
+
cleared = [name for name in targets if clear_cordon(name)]
|
|
1580
|
+
if as_json:
|
|
1581
|
+
click.echo(_json.dumps({"cleared": cleared}, indent=2, sort_keys=True))
|
|
1582
|
+
elif cleared:
|
|
1583
|
+
click.echo("uncordoned: " + ", ".join(cleared))
|
|
1584
|
+
click.echo(
|
|
1585
|
+
"note: these hosts are still BEHIND the released version — "
|
|
1586
|
+
"the next `coord release propagate` will cordon them again "
|
|
1587
|
+
"unless the thing that stopped them draining is fixed."
|
|
1588
|
+
)
|
|
1589
|
+
else:
|
|
1590
|
+
click.echo("nothing to do — none of those machines was cordoned")
|
|
1591
|
+
return
|
|
1592
|
+
|
|
1593
|
+
if machines:
|
|
1594
|
+
written = [
|
|
1595
|
+
set_cordon(
|
|
1596
|
+
name,
|
|
1597
|
+
reason=reason or "cordoned by hand",
|
|
1598
|
+
target_version=target_version,
|
|
1599
|
+
ttl_seconds=ttl,
|
|
1600
|
+
)
|
|
1601
|
+
for name in machines
|
|
1602
|
+
]
|
|
1603
|
+
if as_json:
|
|
1604
|
+
click.echo(
|
|
1605
|
+
_json.dumps([c.to_dict() for c in written], indent=2, sort_keys=True)
|
|
1606
|
+
)
|
|
1607
|
+
else:
|
|
1608
|
+
for record in written:
|
|
1609
|
+
click.echo(f"⊘ {record.machine}: {record.describe()}")
|
|
1610
|
+
return
|
|
1611
|
+
|
|
1612
|
+
now = time.time()
|
|
1613
|
+
active = read_cordons(now=now)
|
|
1614
|
+
if as_json:
|
|
1615
|
+
click.echo(
|
|
1616
|
+
_json.dumps(
|
|
1617
|
+
[c.to_dict() for _, c in sorted(active.items())],
|
|
1618
|
+
indent=2,
|
|
1619
|
+
sort_keys=True,
|
|
1620
|
+
)
|
|
1621
|
+
)
|
|
1622
|
+
return
|
|
1623
|
+
if not active:
|
|
1624
|
+
click.echo("no machines are cordoned — the fleet is free to take work")
|
|
1625
|
+
return
|
|
1626
|
+
for name, record in sorted(active.items()):
|
|
1627
|
+
remaining = max(0.0, record.expires_at - now) / 60.0
|
|
1628
|
+
overdue = " OVERDUE" if record.overdue(now) else ""
|
|
1629
|
+
click.echo(
|
|
1630
|
+
f"⊘ {name}: {record.describe()} "
|
|
1631
|
+
f"[{record.age(now) / 60.0:.0f}m draining, lapses in "
|
|
1632
|
+
f"{remaining:.0f}m, owner={record.owner}]{overdue}"
|
|
1633
|
+
)
|
|
1634
|
+
click.echo(
|
|
1635
|
+
f"\nclear one with `coord release cordon --clear <machine>` "
|
|
1636
|
+
f"(default lifetime {rc.DEFAULT_TTL_SECONDS / 60:.0f}m — a cordon "
|
|
1637
|
+
"nobody renews lapses on its own)"
|
|
1638
|
+
)
|
|
1639
|
+
|
|
1640
|
+
|
|
1641
|
+
@release_group.command(
|
|
1642
|
+
"rollback",
|
|
1643
|
+
help=(
|
|
1644
|
+
"ONE command that puts every agent back on its previous venv "
|
|
1645
|
+
"generation (#1241/#1560). The escape hatch for a bad release."
|
|
1646
|
+
),
|
|
1647
|
+
)
|
|
1648
|
+
@_CONFIG_OPTION
|
|
1649
|
+
@click.option("--machine", "machine_filter", default=None, help="Only this machine.")
|
|
1650
|
+
@click.option("--yes", is_flag=True, help="Skip the confirmation prompt.")
|
|
1651
|
+
@click.option("--wait", default=90.0, show_default=True,
|
|
1652
|
+
help="Seconds to wait for each agent to start serving again "
|
|
1653
|
+
"before escalating to an SSH restart (#2052).")
|
|
1654
|
+
def release_rollback(config_path: Path, machine_filter: str | None, yes: bool,
|
|
1655
|
+
wait: float) -> None:
|
|
1656
|
+
"""#1560 requires rollback to be one command, not a runbook.
|
|
1657
|
+
|
|
1658
|
+
Every successful ``/update`` leaves the previous generation on disk
|
|
1659
|
+
(``coord.agent_update``'s two fixed blue/green slots) precisely so this
|
|
1660
|
+
can exist. It force-rolls: a rollback is what you reach for when the
|
|
1661
|
+
fleet is broken, and refusing because a worker is running on a broken
|
|
1662
|
+
release would be the wrong tradeoff at exactly the wrong moment.
|
|
1663
|
+
"""
|
|
1664
|
+
from coord.commands._common import AGENT_PORT, _load_config # noqa: PLC0415
|
|
1665
|
+
|
|
1666
|
+
config = _load_config(config_path)
|
|
1667
|
+
machines = [
|
|
1668
|
+
m for m in (getattr(config, "machines", ()) or ())
|
|
1669
|
+
if not machine_filter or m.name == machine_filter
|
|
1670
|
+
]
|
|
1671
|
+
if not machines:
|
|
1672
|
+
click.echo("no machines to roll back", err=True)
|
|
1673
|
+
sys.exit(2)
|
|
1674
|
+
if not yes:
|
|
1675
|
+
click.confirm(
|
|
1676
|
+
f"Roll back {len(machines)} agent(s) to the previous venv generation "
|
|
1677
|
+
"and restart them (this kills any in-flight worker)?",
|
|
1678
|
+
abort=True,
|
|
1679
|
+
)
|
|
1680
|
+
failures = 0
|
|
1681
|
+
for machine in machines:
|
|
1682
|
+
ok, detail = _rollback_host(machine, agent_port=AGENT_PORT, timeout=wait)
|
|
1683
|
+
click.echo(f" {'↩' if ok else '✗'} {machine.name}: {detail}")
|
|
1684
|
+
failures += 0 if ok else 1
|
|
1685
|
+
if failures:
|
|
1686
|
+
sys.exit(1)
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
|
+
@release_group.command(
|
|
1690
|
+
"history",
|
|
1691
|
+
help="What propagation actually did, and when (#1835's observability gate).",
|
|
1692
|
+
)
|
|
1693
|
+
@click.option("--limit", default=40, show_default=True,
|
|
1694
|
+
help="Show at most this many recorded attempts (most recent last).")
|
|
1695
|
+
@click.option("-v", "--verbose", is_flag=True,
|
|
1696
|
+
help="Show every no-op attempt individually instead of collapsing runs.")
|
|
1697
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit the raw records as JSON.")
|
|
1698
|
+
def release_history(limit: int, verbose: bool, as_json: bool) -> None:
|
|
1699
|
+
"""Read the propagation journal.
|
|
1700
|
+
|
|
1701
|
+
#1835: "a silent success is indistinguishable from a silent no-op, which
|
|
1702
|
+
is precisely how 2026-08-04 stayed invisible." Every attempt is
|
|
1703
|
+
journalled, including the deferrals — so an empty history means the
|
|
1704
|
+
timer never ran, which is itself the finding.
|
|
1705
|
+
"""
|
|
1706
|
+
import json as _json # noqa: PLC0415
|
|
1707
|
+
|
|
1708
|
+
from coord import release_propagate as rp # noqa: PLC0415
|
|
1709
|
+
|
|
1710
|
+
records = rp.read_records(_state_dir(), limit=limit)
|
|
1711
|
+
if as_json:
|
|
1712
|
+
click.echo(_json.dumps(records, indent=2, sort_keys=True))
|
|
1713
|
+
return
|
|
1714
|
+
click.echo(rp.render_history(records, verbose=verbose))
|
|
1715
|
+
|
|
1716
|
+
|
|
1717
|
+
# ── #2112: the nightly daemon-host release window ───────────────────────────
|
|
1718
|
+
#
|
|
1719
|
+
# `coord release propagate` waits for a quiescent window that may never
|
|
1720
|
+
# arrive on the daemon host specifically, because it both leads every roll
|
|
1721
|
+
# (the documented 405) and is the box that launches drive-queue work, so
|
|
1722
|
+
# almost any drive anywhere keeps it "busy" and defers the entire fleet
|
|
1723
|
+
# (see `coord/release_window.py`'s module docstring for the full mechanism
|
|
1724
|
+
# and the 2026-08-10 measurement). This section is the I/O shell over that
|
|
1725
|
+
# module's decisions: stop the queue timer, drain in-flight drives bounded
|
|
1726
|
+
# by a deadline, roll via `coord release propagate`, and ALWAYS restart the
|
|
1727
|
+
# timer — the pure judgement (`needs_roll`, the journal, the record shape)
|
|
1728
|
+
# lives in `coord/release_window.py`, same split as `release_propagate`'s
|
|
1729
|
+
# own I/O shell above.
|
|
1730
|
+
#
|
|
1731
|
+
# GATED ON #2110: the drain loop below calls `coord drive-queue tick
|
|
1732
|
+
# --reconcile-only` on every poll specifically because stopping the timer
|
|
1733
|
+
# also stops the ONLY thing that otherwise reconciles a finished drive's row
|
|
1734
|
+
# from `running` to `done` — without #2110 making that reconciliation safe to
|
|
1735
|
+
# call standalone, this whole mechanism would deadlock every night.
|
|
1736
|
+
|
|
1737
|
+
|
|
1738
|
+
def _systemctl(unit: str, action: str, *, runner=None, timeout: float = 30.0) -> tuple[bool, str]:
|
|
1739
|
+
"""``systemctl --user <action> <unit>``, tolerantly.
|
|
1740
|
+
|
|
1741
|
+
Mirrors `coord.deploy_units.enable_timers`'s own systemctl wrapper: same
|
|
1742
|
+
injectable ``runner`` seam for tests (a fleet-free unit test never
|
|
1743
|
+
spawns a real ``systemctl``), same "never raise" contract — a window run
|
|
1744
|
+
must be able to report "could not stop the queue" as a normal, recorded
|
|
1745
|
+
outcome rather than crash.
|
|
1746
|
+
"""
|
|
1747
|
+
import subprocess # noqa: PLC0415
|
|
1748
|
+
|
|
1749
|
+
run = runner or subprocess.run
|
|
1750
|
+
try:
|
|
1751
|
+
proc = run(
|
|
1752
|
+
["systemctl", "--user", action, unit],
|
|
1753
|
+
capture_output=True, text=True, timeout=timeout,
|
|
1754
|
+
)
|
|
1755
|
+
except FileNotFoundError:
|
|
1756
|
+
return False, "systemctl not found (no systemd on this host)"
|
|
1757
|
+
except Exception as exc: # noqa: BLE001
|
|
1758
|
+
return False, f"{type(exc).__name__}: {exc}"
|
|
1759
|
+
ok = getattr(proc, "returncode", 1) == 0
|
|
1760
|
+
detail = (getattr(proc, "stderr", "") or getattr(proc, "stdout", "") or "").strip()
|
|
1761
|
+
return ok, detail or (f"{action} ok" if ok else f"{action} failed")
|
|
1762
|
+
|
|
1763
|
+
|
|
1764
|
+
def _run_reconcile_tick(config_path: Path, *, runner=None) -> tuple[bool, str]:
|
|
1765
|
+
"""``coord drive-queue tick --reconcile-only`` — best effort, #2110.
|
|
1766
|
+
|
|
1767
|
+
Stopping `coord-drive-queue.timer` for the drain also stops the ONLY
|
|
1768
|
+
thing that otherwise reconciles a finished drive's row from ``running``
|
|
1769
|
+
to ``done``. Without calling this on every poll, a drive that finished
|
|
1770
|
+
*after* the timer stopped would stay stuck ``running`` for the rest of
|
|
1771
|
+
the drain no matter how generous the deadline is — the exact deadlock
|
|
1772
|
+
#2110 fixed reconciliation to no longer require the timer for.
|
|
1773
|
+
"""
|
|
1774
|
+
import subprocess # noqa: PLC0415
|
|
1775
|
+
|
|
1776
|
+
run = runner or subprocess.run
|
|
1777
|
+
try:
|
|
1778
|
+
proc = run(
|
|
1779
|
+
[sys.executable, "-m", "coord.cli", "drive-queue", "tick",
|
|
1780
|
+
"--reconcile-only", "--config", str(config_path)],
|
|
1781
|
+
capture_output=True, text=True, timeout=120,
|
|
1782
|
+
)
|
|
1783
|
+
except Exception as exc: # noqa: BLE001 — best effort; the quiescence
|
|
1784
|
+
# check right after this call is the real signal a stuck reconcile
|
|
1785
|
+
# eventually clears on a later poll, not this one call's job to
|
|
1786
|
+
# guarantee.
|
|
1787
|
+
return False, f"{type(exc).__name__}: {exc}"
|
|
1788
|
+
ok = getattr(proc, "returncode", 1) == 0
|
|
1789
|
+
detail = (getattr(proc, "stderr", "") or getattr(proc, "stdout", "") or "").strip()
|
|
1790
|
+
return ok, detail[:200]
|
|
1791
|
+
|
|
1792
|
+
|
|
1793
|
+
def _drain(
|
|
1794
|
+
*,
|
|
1795
|
+
daemon_host: str,
|
|
1796
|
+
config_path: Path,
|
|
1797
|
+
deadline: float,
|
|
1798
|
+
poll_interval: float,
|
|
1799
|
+
reconcile=None,
|
|
1800
|
+
board_fetch=None,
|
|
1801
|
+
now=None,
|
|
1802
|
+
sleep=None,
|
|
1803
|
+
):
|
|
1804
|
+
"""Bounded wait for *daemon_host* to stop being busy (trap 2).
|
|
1805
|
+
|
|
1806
|
+
Reuses `release_propagate.assess_quiescence` — the SAME computation
|
|
1807
|
+
`coord release propagate` itself uses to decide whether the daemon host
|
|
1808
|
+
may lead a roll — rather than a second definition of "busy" (#2096's
|
|
1809
|
+
"two surfaces, one function" rule). ``now``/``sleep`` are injectable so
|
|
1810
|
+
this loop is unit-testable without a real clock; ``reconcile``/
|
|
1811
|
+
``board_fetch`` default to the real subprocess/board calls.
|
|
1812
|
+
"""
|
|
1813
|
+
import time as _time # noqa: PLC0415
|
|
1814
|
+
|
|
1815
|
+
from coord import release_propagate as rp # noqa: PLC0415
|
|
1816
|
+
from coord import release_window as rw # noqa: PLC0415
|
|
1817
|
+
|
|
1818
|
+
now_fn = now or _time.time
|
|
1819
|
+
sleep_fn = sleep or _time.sleep
|
|
1820
|
+
reconcile_fn = reconcile or (lambda: _run_reconcile_tick(config_path))
|
|
1821
|
+
fetch_fn = board_fetch or _fetch_board
|
|
1822
|
+
|
|
1823
|
+
start = now_fn()
|
|
1824
|
+
while True:
|
|
1825
|
+
reconcile_fn()
|
|
1826
|
+
board, board_error = fetch_fn()
|
|
1827
|
+
extra_busy = []
|
|
1828
|
+
if board_error:
|
|
1829
|
+
extra_busy.append(
|
|
1830
|
+
rp.Busy(kind="board unreadable", subject="/board", detail=board_error)
|
|
1831
|
+
)
|
|
1832
|
+
quiescence = rp.assess_quiescence(
|
|
1833
|
+
queue_entries=board.get("drive_queue") or [],
|
|
1834
|
+
assignments=board.get("assignments") or [],
|
|
1835
|
+
issues=board.get("issues") or [],
|
|
1836
|
+
extra_busy=extra_busy,
|
|
1837
|
+
)
|
|
1838
|
+
busy = bool(quiescence.fleet_wide_busy) or daemon_host in quiescence.busy_hosts()
|
|
1839
|
+
elapsed = now_fn() - start
|
|
1840
|
+
if not busy:
|
|
1841
|
+
return rw.DrainOutcome(drained=True, elapsed_seconds=elapsed, detail="drained")
|
|
1842
|
+
detail = quiescence.busy_reason_for_host(daemon_host) or quiescence.reason
|
|
1843
|
+
if elapsed >= deadline:
|
|
1844
|
+
return rw.DrainOutcome(drained=False, elapsed_seconds=elapsed, detail=detail)
|
|
1845
|
+
sleep_fn(max(0.0, min(poll_interval, deadline - elapsed)))
|
|
1846
|
+
|
|
1847
|
+
|
|
1848
|
+
def _run_propagate(
|
|
1849
|
+
*, daemon_host: str, target_version: str, config_path: Path, runner=None,
|
|
1850
|
+
) -> tuple[str, int, str]:
|
|
1851
|
+
"""``coord release propagate --daemon-host ... --target ... --json``.
|
|
1852
|
+
|
|
1853
|
+
A real subprocess of THIS interpreter (matches `_roll_tui`/
|
|
1854
|
+
`_release_hold` above), not an in-process call: `release_propagate` is a
|
|
1855
|
+
click command that calls ``sys.exit()`` itself and is not meant to be
|
|
1856
|
+
invoked as a plain function. ``--target`` is passed explicitly — not
|
|
1857
|
+
left to re-resolve PyPI's "latest" a second time — so the version this
|
|
1858
|
+
run decided was needed is the version that actually rolls, even if a new
|
|
1859
|
+
release lands on PyPI mid-drain.
|
|
1860
|
+
|
|
1861
|
+
Returns ``(status, exit_code, combined_output)``. ``status`` is parsed
|
|
1862
|
+
from the last JSON line of stdout (propagate's own record); parsing
|
|
1863
|
+
failures fall back to ``f"exit {code}"`` rather than raising — a window
|
|
1864
|
+
run must never crash because propagate's output was unexpected.
|
|
1865
|
+
"""
|
|
1866
|
+
import json as _json # noqa: PLC0415
|
|
1867
|
+
import subprocess # noqa: PLC0415
|
|
1868
|
+
|
|
1869
|
+
run = runner or subprocess.run
|
|
1870
|
+
argv = [
|
|
1871
|
+
sys.executable, "-m", "coord.cli", "release", "propagate",
|
|
1872
|
+
"--daemon-host", daemon_host, "--target", target_version,
|
|
1873
|
+
"--json", "--config", str(config_path),
|
|
1874
|
+
]
|
|
1875
|
+
try:
|
|
1876
|
+
proc = run(argv, capture_output=True, text=True, timeout=1800)
|
|
1877
|
+
except Exception as exc: # noqa: BLE001
|
|
1878
|
+
detail = f"{type(exc).__name__}: {exc}"
|
|
1879
|
+
return f"error: {detail}", 1, detail
|
|
1880
|
+
code = getattr(proc, "returncode", 1)
|
|
1881
|
+
stdout = getattr(proc, "stdout", "") or ""
|
|
1882
|
+
stderr = getattr(proc, "stderr", "") or ""
|
|
1883
|
+
status = f"exit {code}"
|
|
1884
|
+
for line in reversed(stdout.strip().splitlines()):
|
|
1885
|
+
line = line.strip()
|
|
1886
|
+
if not line.startswith("{"):
|
|
1887
|
+
continue
|
|
1888
|
+
try:
|
|
1889
|
+
payload = _json.loads(line)
|
|
1890
|
+
except ValueError:
|
|
1891
|
+
continue
|
|
1892
|
+
if isinstance(payload, dict) and payload.get("status"):
|
|
1893
|
+
status = str(payload["status"])
|
|
1894
|
+
break
|
|
1895
|
+
return status, code, (stdout + ("\n" + stderr if stderr else "")).strip()
|
|
1896
|
+
|
|
1897
|
+
|
|
1898
|
+
#: Where a skipped/failed nightly window (trap 3) is recorded — same
|
|
1899
|
+
#: escalation channel #2101's drain-deadline escalation and `coord
|
|
1900
|
+
#: drive-queue`'s own alerts use, so it shows up in the TUI's escalations
|
|
1901
|
+
#: panel and `coord drive escalations` with no new surface to remember to
|
|
1902
|
+
#: look at. Mirrors `_escalate_drain`'s `DRAIN_ALERT_*` convention above.
|
|
1903
|
+
WINDOW_ALERT_REPO = "(release-window)"
|
|
1904
|
+
WINDOW_ALERT_ISSUE = 0
|
|
1905
|
+
WINDOW_ALERT_STAGE = "release-window"
|
|
1906
|
+
|
|
1907
|
+
|
|
1908
|
+
def _escalate_window(record, *, reason: str) -> None:
|
|
1909
|
+
"""Surface a skipped/failed nightly window loudly (trap 3).
|
|
1910
|
+
|
|
1911
|
+
#2112's whole point: a night propagation was supposed to happen and did
|
|
1912
|
+
not is exactly the state #2082 exists to make loud elsewhere in this
|
|
1913
|
+
fleet. Silence must not be the report here either.
|
|
1914
|
+
"""
|
|
1915
|
+
click.echo(f" ! {reason}", err=True)
|
|
1916
|
+
try:
|
|
1917
|
+
from coord.state import record_drive_escalation # noqa: PLC0415
|
|
1918
|
+
|
|
1919
|
+
proposed = (
|
|
1920
|
+
f"coord release propagate --daemon-host {record.daemon_host} "
|
|
1921
|
+
f"--target {record.target_version}"
|
|
1922
|
+
if record.daemon_host and record.target_version
|
|
1923
|
+
else "coord release nightly-window --dry-run # investigate first"
|
|
1924
|
+
)
|
|
1925
|
+
record_drive_escalation(
|
|
1926
|
+
WINDOW_ALERT_REPO, WINDOW_ALERT_ISSUE, stage=WINDOW_ALERT_STAGE,
|
|
1927
|
+
reason=reason,
|
|
1928
|
+
gate_readings=(
|
|
1929
|
+
f"daemon_host={record.daemon_host} | target={record.target_version} | "
|
|
1930
|
+
f"daemon_version={record.daemon_version} | status={record.status}"
|
|
1931
|
+
),
|
|
1932
|
+
proposed_command=proposed,
|
|
1933
|
+
)
|
|
1934
|
+
except Exception as exc: # noqa: BLE001 — the stderr line above is the
|
|
1935
|
+
# floor; an escalation table that cannot be written must not take
|
|
1936
|
+
# the message down with it.
|
|
1937
|
+
click.echo(f" (could not record the window escalation: {exc})", err=True)
|
|
1938
|
+
|
|
1939
|
+
|
|
1940
|
+
@release_group.command(
|
|
1941
|
+
"nightly-window",
|
|
1942
|
+
help=(
|
|
1943
|
+
"Guarantee the daemon host rolls at a nightly window instead of "
|
|
1944
|
+
"waiting for a fleet-wide quiescent moment that may never come "
|
|
1945
|
+
"(#2112). Stops the drive queue, drains in-flight drives (bounded), "
|
|
1946
|
+
"rolls with `coord release propagate`, and ALWAYS restarts the "
|
|
1947
|
+
"queue timer before exiting — whether or not anything rolled."
|
|
1948
|
+
),
|
|
1949
|
+
)
|
|
1950
|
+
@_CONFIG_OPTION
|
|
1951
|
+
@click.option("--target", default=None,
|
|
1952
|
+
help="Version to propagate (leading 'v' optional). Default: PyPI's latest.")
|
|
1953
|
+
@click.option("--daemon-host", "daemon_host_override", default=None,
|
|
1954
|
+
help="Machine name running coord-serve. Normally DERIVED from the "
|
|
1955
|
+
"fleet's own /health, same as `coord release propagate`.")
|
|
1956
|
+
@click.option("--queue-timer", default="coord-drive-queue.timer", show_default=True,
|
|
1957
|
+
help="The systemd --user timer to stop for the duration of the drain.")
|
|
1958
|
+
@click.option("--drain-deadline", default=3600.0, show_default=True, type=float,
|
|
1959
|
+
help="Bounded wait (seconds) for in-flight drives before giving up, "
|
|
1960
|
+
"restarting the queue and reporting failure (trap 2) — the "
|
|
1961
|
+
"queue is never left stopped past this.")
|
|
1962
|
+
@click.option("--poll-interval", default=30.0, show_default=True, type=float,
|
|
1963
|
+
help="Seconds between drain re-checks.")
|
|
1964
|
+
@click.option("--dry-run", is_flag=True,
|
|
1965
|
+
help="Print what this run would do; stop nothing, roll nothing.")
|
|
1966
|
+
@click.option("--ensure-queue-running", is_flag=True,
|
|
1967
|
+
help="Do ONLY `systemctl --user start <queue-timer>` and exit — the "
|
|
1968
|
+
"SIGKILL-safe half of trap 4/acceptance 4. Wired as "
|
|
1969
|
+
"deploy/coord-release-window.service's ExecStopPost=, which "
|
|
1970
|
+
"systemd runs after the main process exits for ANY reason "
|
|
1971
|
+
"(success, failure, or a signal a Python `finally` cannot "
|
|
1972
|
+
"catch). Every other option is ignored with this flag.")
|
|
1973
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit the window record as JSON.")
|
|
1974
|
+
def release_nightly_window( # noqa: PLR0912, PLR0915 — a pipeline; see module docstring
|
|
1975
|
+
config_path: Path,
|
|
1976
|
+
target: str | None,
|
|
1977
|
+
daemon_host_override: str | None,
|
|
1978
|
+
queue_timer: str,
|
|
1979
|
+
drain_deadline: float,
|
|
1980
|
+
poll_interval: float,
|
|
1981
|
+
dry_run: bool,
|
|
1982
|
+
ensure_queue_running: bool,
|
|
1983
|
+
as_json: bool,
|
|
1984
|
+
) -> None:
|
|
1985
|
+
"""One nightly-window attempt. Exit 0 on up-to-date/rolled/dry-run, 1+ otherwise.
|
|
1986
|
+
|
|
1987
|
+
#2112: `coord release propagate` cannot roll the daemon host past a busy
|
|
1988
|
+
fleet on its own — the daemon leads every roll (the documented 405), and
|
|
1989
|
+
dellserver's own drive-queue tick charges itself as busy for essentially
|
|
1990
|
+
any queued drive (see `coord/release_window.py`'s module docstring). This
|
|
1991
|
+
command manufactures the window propagate can't: stop the queue, drain
|
|
1992
|
+
what's already running (bounded — trap 2), roll, restart the queue —
|
|
1993
|
+
ALWAYS restart the queue (trap 4), whatever happened in between.
|
|
1994
|
+
|
|
1995
|
+
Never carries `--force` to the `coord release propagate` it shells out
|
|
1996
|
+
to (trap 1) — a drain that did not finish is reported and declined, not
|
|
1997
|
+
overridden.
|
|
1998
|
+
"""
|
|
1999
|
+
import json as _json # noqa: PLC0415
|
|
2000
|
+
import time # noqa: PLC0415
|
|
2001
|
+
|
|
2002
|
+
from coord import release_propagate as rp # noqa: PLC0415
|
|
2003
|
+
from coord import release_verify as rv # noqa: PLC0415
|
|
2004
|
+
from coord import release_window as rw # noqa: PLC0415
|
|
2005
|
+
from coord.commands._common import _load_config # noqa: PLC0415
|
|
2006
|
+
|
|
2007
|
+
if ensure_queue_running:
|
|
2008
|
+
ok, detail = _systemctl(queue_timer, "start")
|
|
2009
|
+
click.echo(f"{'✓' if ok else '✗'} ensure {queue_timer} running: {detail}")
|
|
2010
|
+
sys.exit(0 if ok else 1)
|
|
2011
|
+
|
|
2012
|
+
config = _load_config(config_path)
|
|
2013
|
+
state_dir = _state_dir()
|
|
2014
|
+
record = rw.WindowRecord(
|
|
2015
|
+
started_at=time.time(), dry_run=dry_run, queue_timer=queue_timer
|
|
2016
|
+
)
|
|
2017
|
+
|
|
2018
|
+
def _finish(status: str, exit_code: int = 0) -> None:
|
|
2019
|
+
record.status = status
|
|
2020
|
+
record.finished_at = time.time()
|
|
2021
|
+
if not dry_run:
|
|
2022
|
+
try:
|
|
2023
|
+
rw.append_record(state_dir, record)
|
|
2024
|
+
rw.trim_journal(state_dir)
|
|
2025
|
+
except OSError as exc:
|
|
2026
|
+
click.echo(f"warning: could not append the window journal: {exc}",
|
|
2027
|
+
err=True)
|
|
2028
|
+
if as_json:
|
|
2029
|
+
click.echo(_json.dumps(record.to_dict(), indent=2, sort_keys=True))
|
|
2030
|
+
else:
|
|
2031
|
+
click.echo("\n".join(rw.render_record(record)))
|
|
2032
|
+
sys.exit(exit_code)
|
|
2033
|
+
|
|
2034
|
+
# ── 1. what version, and who leads? ───────────────────────────────────
|
|
2035
|
+
index_url = getattr(getattr(config, "health", None), "pypi_index_url",
|
|
2036
|
+
"https://pypi.org/simple")
|
|
2037
|
+
resolved, warning = _resolve_expected(
|
|
2038
|
+
target, use_pypi=not target, index_url=index_url, timeout=10.0
|
|
2039
|
+
)
|
|
2040
|
+
if warning:
|
|
2041
|
+
click.echo(f"warning: {warning}", err=True)
|
|
2042
|
+
record.target_version = rp.normalize_version(resolved)
|
|
2043
|
+
if not record.target_version:
|
|
2044
|
+
record.error = (
|
|
2045
|
+
"could not resolve a target version — pass --target, or fix "
|
|
2046
|
+
"access to the PyPI simple index"
|
|
2047
|
+
)
|
|
2048
|
+
click.echo(f"error: {record.error}", err=True)
|
|
2049
|
+
_escalate_window(record, reason=record.error)
|
|
2050
|
+
_finish(rw.STATUS_ERROR, 1)
|
|
2051
|
+
|
|
2052
|
+
machine_health, unreachable, daemon_facts, daemon_label = rv.gather(config, timeout=10.0)
|
|
2053
|
+
daemon_name = _daemon_machine_name(config, daemon_host_override, machine_health)
|
|
2054
|
+
if daemon_name is None:
|
|
2055
|
+
record.error = (
|
|
2056
|
+
"could not identify which machine runs coord-serve — pass "
|
|
2057
|
+
"--daemon-host, or fix its /health so the unit is visible "
|
|
2058
|
+
"(same requirement as `coord release propagate`)"
|
|
2059
|
+
)
|
|
2060
|
+
click.echo(f"error: {record.error}", err=True)
|
|
2061
|
+
_escalate_window(record, reason=record.error)
|
|
2062
|
+
_finish(rw.STATUS_ERROR, 1)
|
|
2063
|
+
record.daemon_host = daemon_name
|
|
2064
|
+
|
|
2065
|
+
report = rv.verify(
|
|
2066
|
+
machine_health=machine_health, unreachable=unreachable,
|
|
2067
|
+
daemon_host=daemon_facts, daemon_host_name=daemon_label,
|
|
2068
|
+
expected=record.target_version,
|
|
2069
|
+
)
|
|
2070
|
+
record.daemon_version = _python_lane_versions(
|
|
2071
|
+
report, [daemon_name], record.target_version
|
|
2072
|
+
).get(daemon_name)
|
|
2073
|
+
|
|
2074
|
+
# ── 2. acceptance 3 — already current, so the queue is never touched ──
|
|
2075
|
+
if not rw.needs_roll(record.daemon_version, record.target_version):
|
|
2076
|
+
_finish(rw.STATUS_UP_TO_DATE, 0)
|
|
2077
|
+
|
|
2078
|
+
if dry_run:
|
|
2079
|
+
click.echo(
|
|
2080
|
+
f"would stop {queue_timer}, drain up to {drain_deadline:.0f}s "
|
|
2081
|
+
f"({daemon_name} currently reports v{record.daemon_version or '?'}, "
|
|
2082
|
+
f"target v{record.target_version}), then `coord release propagate "
|
|
2083
|
+
f"--daemon-host {daemon_name} --target {record.target_version}`, "
|
|
2084
|
+
f"then restart {queue_timer}"
|
|
2085
|
+
)
|
|
2086
|
+
_finish(rw.STATUS_DRY_RUN, 0)
|
|
2087
|
+
|
|
2088
|
+
# ── 3. stop the queue — no new drives may launch for the rest of this
|
|
2089
|
+
# run — and from here on ALWAYS restart it before exiting ─────────
|
|
2090
|
+
stop_ok, stop_detail = _systemctl(queue_timer, "stop")
|
|
2091
|
+
record.queue_stopped = stop_ok
|
|
2092
|
+
record.queue_stop_detail = stop_detail
|
|
2093
|
+
click.echo(f"{'✓' if stop_ok else '✗'} stop {queue_timer}: {stop_detail}")
|
|
2094
|
+
|
|
2095
|
+
status = rw.STATUS_ERROR
|
|
2096
|
+
exit_code = 1
|
|
2097
|
+
try:
|
|
2098
|
+
if not stop_ok:
|
|
2099
|
+
record.error = (
|
|
2100
|
+
f"could not stop {queue_timer} — refusing to drain or roll "
|
|
2101
|
+
f"without a guaranteed no-new-launches window: {stop_detail}"
|
|
2102
|
+
)
|
|
2103
|
+
click.echo(f"error: {record.error}", err=True)
|
|
2104
|
+
_escalate_window(record, reason=record.error)
|
|
2105
|
+
else:
|
|
2106
|
+
# ── 4. bounded drain (trap 2) ──────────────────────────────────
|
|
2107
|
+
outcome = _drain(
|
|
2108
|
+
daemon_host=daemon_name, config_path=config_path,
|
|
2109
|
+
deadline=drain_deadline, poll_interval=poll_interval,
|
|
2110
|
+
)
|
|
2111
|
+
record.drained = outcome.drained
|
|
2112
|
+
record.drain_seconds = outcome.elapsed_seconds
|
|
2113
|
+
record.drain_detail = outcome.detail
|
|
2114
|
+
click.echo(
|
|
2115
|
+
f"{'✓' if outcome.drained else '✗'} drain: "
|
|
2116
|
+
f"{'clean' if outcome.drained else 'TIMED OUT'} after "
|
|
2117
|
+
f"{outcome.elapsed_seconds:.0f}s"
|
|
2118
|
+
+ (f" — {outcome.detail}" if outcome.detail else "")
|
|
2119
|
+
)
|
|
2120
|
+
|
|
2121
|
+
if not outcome.drained:
|
|
2122
|
+
record.error = (
|
|
2123
|
+
f"drain deadline ({drain_deadline:.0f}s) hit with "
|
|
2124
|
+
f"{daemon_name} still busy — {outcome.detail}; declining "
|
|
2125
|
+
"to roll (never --force from an unattended window)"
|
|
2126
|
+
)
|
|
2127
|
+
click.echo(f"error: {record.error}", err=True)
|
|
2128
|
+
_escalate_window(record, reason=record.error)
|
|
2129
|
+
status = rw.STATUS_DRAIN_TIMEOUT
|
|
2130
|
+
else:
|
|
2131
|
+
# ── 5. roll — the daemon host is now provably free ─────────
|
|
2132
|
+
prop_status, prop_exit, prop_output = _run_propagate(
|
|
2133
|
+
daemon_host=daemon_name, target_version=record.target_version,
|
|
2134
|
+
config_path=config_path,
|
|
2135
|
+
)
|
|
2136
|
+
record.propagate_status = prop_status
|
|
2137
|
+
record.propagate_exit_code = prop_exit
|
|
2138
|
+
record.propagate_output = prop_output
|
|
2139
|
+
if prop_output:
|
|
2140
|
+
click.echo(prop_output)
|
|
2141
|
+
|
|
2142
|
+
if prop_exit == 0 and prop_status in (
|
|
2143
|
+
rp.STATUS_VERIFIED, rp.STATUS_UP_TO_DATE, rp.STATUS_ROLLED,
|
|
2144
|
+
):
|
|
2145
|
+
status = (
|
|
2146
|
+
rw.STATUS_UP_TO_DATE if prop_status == rp.STATUS_UP_TO_DATE
|
|
2147
|
+
else rw.STATUS_ROLLED
|
|
2148
|
+
)
|
|
2149
|
+
exit_code = 0
|
|
2150
|
+
elif prop_exit == 0 and prop_status == rp.STATUS_DEFERRED:
|
|
2151
|
+
# A drained daemon host still deferred: some OTHER host
|
|
2152
|
+
# (or an unattributable signal) is busy. Not this
|
|
2153
|
+
# command's #2110-shaped deadlock — that would show up
|
|
2154
|
+
# as the drain never clearing — but still a night that
|
|
2155
|
+
# did not roll, and just as loud (trap 3).
|
|
2156
|
+
record.error = (
|
|
2157
|
+
f"{daemon_name} drained clean but `coord release "
|
|
2158
|
+
f"propagate` still deferred (status={prop_status}) — "
|
|
2159
|
+
"see propagate_output for why"
|
|
2160
|
+
)
|
|
2161
|
+
click.echo(f"error: {record.error}", err=True)
|
|
2162
|
+
_escalate_window(record, reason=record.error)
|
|
2163
|
+
status = rw.STATUS_PROPAGATE_DEFERRED
|
|
2164
|
+
else:
|
|
2165
|
+
record.error = (
|
|
2166
|
+
f"coord release propagate did not verify a roll "
|
|
2167
|
+
f"(status={prop_status}, exit={prop_exit}) despite a "
|
|
2168
|
+
f"drained daemon host"
|
|
2169
|
+
)
|
|
2170
|
+
click.echo(f"error: {record.error}", err=True)
|
|
2171
|
+
_escalate_window(record, reason=record.error)
|
|
2172
|
+
status = rw.STATUS_PROPAGATE_FAILED
|
|
2173
|
+
exit_code = prop_exit or 1
|
|
2174
|
+
finally:
|
|
2175
|
+
# Trap 4 / acceptance 4: ALWAYS restart the timer — whatever
|
|
2176
|
+
# happened above, including an exception raised out of this block.
|
|
2177
|
+
# This is the in-process half of the guarantee; --ensure-queue-running
|
|
2178
|
+
# wired as ExecStopPost= (deploy/coord-release-window.service) is the
|
|
2179
|
+
# SIGKILL-safe half, since a `finally` cannot run after SIGKILL.
|
|
2180
|
+
# Leaving the fleet's work queue stopped is the worst outcome this
|
|
2181
|
+
# mechanism exists to prevent, so this runs no matter what.
|
|
2182
|
+
start_ok, start_detail = _systemctl(queue_timer, "start")
|
|
2183
|
+
record.queue_restarted = start_ok
|
|
2184
|
+
record.queue_restart_detail = start_detail
|
|
2185
|
+
click.echo(f"{'✓' if start_ok else '✗'} restart {queue_timer}: {start_detail}")
|
|
2186
|
+
if not start_ok:
|
|
2187
|
+
_escalate_window(
|
|
2188
|
+
record,
|
|
2189
|
+
reason=(
|
|
2190
|
+
"queue timer restart FAILED after a nightly window run — "
|
|
2191
|
+
f"{queue_timer} may be stopped: {start_detail}. Run "
|
|
2192
|
+
f"`systemctl --user start {queue_timer}` by hand NOW."
|
|
2193
|
+
),
|
|
2194
|
+
)
|
|
2195
|
+
|
|
2196
|
+
_finish(status, exit_code)
|
|
2197
|
+
|
|
2198
|
+
|
|
2199
|
+
@release_group.command(
|
|
2200
|
+
"window-history",
|
|
2201
|
+
help="What the nightly release window actually did, and when (#2112).",
|
|
2202
|
+
)
|
|
2203
|
+
@click.option("--limit", default=40, show_default=True,
|
|
2204
|
+
help="Show at most this many recorded attempts (most recent last).")
|
|
2205
|
+
@click.option("--json", "as_json", is_flag=True, help="Emit the raw records as JSON.")
|
|
2206
|
+
def release_window_history(limit: int, as_json: bool) -> None:
|
|
2207
|
+
"""Read the `coord release nightly-window` journal.
|
|
2208
|
+
|
|
2209
|
+
Separate from `coord release history` (`release_propagate`'s journal):
|
|
2210
|
+
this record carries fields — the queue stop/drain/restart outcome — a
|
|
2211
|
+
plain propagate attempt does not have.
|
|
2212
|
+
"""
|
|
2213
|
+
import json as _json # noqa: PLC0415
|
|
2214
|
+
|
|
2215
|
+
from coord import release_window as rw # noqa: PLC0415
|
|
2216
|
+
|
|
2217
|
+
records = rw.read_records(_state_dir(), limit=limit)
|
|
2218
|
+
if as_json:
|
|
2219
|
+
click.echo(_json.dumps(records, indent=2, sort_keys=True))
|
|
2220
|
+
return
|
|
2221
|
+
if not records:
|
|
2222
|
+
click.echo("no nightly-window attempts recorded yet")
|
|
2223
|
+
return
|
|
2224
|
+
for rec in records:
|
|
2225
|
+
click.echo("\n".join(rw.render_record(rec)))
|
|
2226
|
+
click.echo("")
|
|
2227
|
+
|
|
2228
|
+
|
|
2229
|
+
# Same callback under the group, so `coord release preflight` and `coord
|
|
2230
|
+
# release verify` are one discoverable pair. The flat `coord
|
|
2231
|
+
# release-preflight` above keeps working unchanged.
|
|
2232
|
+
release_group.add_command(release_preflight, name="preflight")
|