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,1339 @@
|
|
|
1
|
+
"""The `coord agent` group: per-machine agent server lifecycle
|
|
2
|
+
(start/update/restart/clean-worktrees) plus `pause`/`unpause`.
|
|
3
|
+
Extracted from coord/cli.py (#747)."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import socket
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from coord import __version__
|
|
20
|
+
from coord.config import Config
|
|
21
|
+
from coord.dist_name import CANDIDATE_NAMES
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING: # pragma: no cover — typing only
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
|
|
26
|
+
from coord.commands._common import (
|
|
27
|
+
AGENT_PORT,
|
|
28
|
+
_CONFIG_OPTION,
|
|
29
|
+
_load_config,
|
|
30
|
+
server_extra_guard,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@click.group(
|
|
35
|
+
invoke_without_command=True,
|
|
36
|
+
help=(
|
|
37
|
+
"Agent server management. Without a subcommand, starts the agent "
|
|
38
|
+
"server on this machine (port 7433)."
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@_CONFIG_OPTION
|
|
44
|
+
@click.option(
|
|
45
|
+
"--machine",
|
|
46
|
+
"machine_name",
|
|
47
|
+
default=None,
|
|
48
|
+
help="Machine name from coordinator.yml (defaults to hostname match).",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@click.option("--host", "bind_host", default="0.0.0.0", show_default=True)
|
|
53
|
+
@click.option("--port", "bind_port", default=AGENT_PORT, show_default=True, type=int)
|
|
54
|
+
@click.pass_context
|
|
55
|
+
def agent(
|
|
56
|
+
ctx: click.Context,
|
|
57
|
+
config_path: Path,
|
|
58
|
+
machine_name: str | None,
|
|
59
|
+
bind_host: str,
|
|
60
|
+
bind_port: int,
|
|
61
|
+
) -> None:
|
|
62
|
+
ctx.ensure_object(dict)
|
|
63
|
+
ctx.obj.update(
|
|
64
|
+
config_path=config_path,
|
|
65
|
+
machine_name=machine_name,
|
|
66
|
+
bind_host=bind_host,
|
|
67
|
+
bind_port=bind_port,
|
|
68
|
+
)
|
|
69
|
+
if ctx.invoked_subcommand is None:
|
|
70
|
+
_start_agent_server(config_path, machine_name, bind_host, bind_port)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _startup_diagnostic_lines(
|
|
74
|
+
capabilities: list[str], *, path_env: str | None = None
|
|
75
|
+
) -> list[str]:
|
|
76
|
+
"""Lines to log once at agent startup so the #1671 failure class is
|
|
77
|
+
visible in `journalctl --user -u coord-agent` right after a restart —
|
|
78
|
+
no hand-run `coord doctor` (or SSH + `/proc/<pid>/environ`) required.
|
|
79
|
+
|
|
80
|
+
#1671: every machine's `rust` capability read unmet even though `cargo`
|
|
81
|
+
was installed, because the *capability probe* resolves through the
|
|
82
|
+
*agent process's* PATH — and a systemd user unit's PATH is minimal
|
|
83
|
+
(omits `~/.cargo/bin`) unless the unit says otherwise (see
|
|
84
|
+
`deploy/coord-agent.service`). The probe result alone doesn't say
|
|
85
|
+
*why* a tool is missing; logging the resolved PATH plus any declared
|
|
86
|
+
capability that its own probe contradicts turns "mysteriously unmet"
|
|
87
|
+
into "read the last agent restart's log line."
|
|
88
|
+
|
|
89
|
+
Pure function (no I/O of its own) so it's cheaply testable without
|
|
90
|
+
mocking `click.echo`/subprocess at the call site — callers pass in
|
|
91
|
+
already-probed data.
|
|
92
|
+
"""
|
|
93
|
+
from coord.prereqs import probe_all, tool_versions_summary, unmet_capabilities
|
|
94
|
+
|
|
95
|
+
lines = [f"coord agent: PATH={path_env if path_env is not None else os.environ.get('PATH', '')}"]
|
|
96
|
+
|
|
97
|
+
probes = probe_all(capabilities)
|
|
98
|
+
unmet = unmet_capabilities(capabilities, probes)
|
|
99
|
+
if not unmet:
|
|
100
|
+
lines.append(
|
|
101
|
+
f"coord agent: capabilities {capabilities} all probe OK "
|
|
102
|
+
f"({tool_versions_summary(probes)})"
|
|
103
|
+
)
|
|
104
|
+
else:
|
|
105
|
+
for cap, reasons in unmet.items():
|
|
106
|
+
for reason in reasons:
|
|
107
|
+
lines.append(
|
|
108
|
+
f"coord agent: WARNING capability '{cap}' declared in "
|
|
109
|
+
f"coordinator.yml but its own probe disagrees: {reason} "
|
|
110
|
+
f"— dispatch_smoke will refuse to route to this machine "
|
|
111
|
+
f"for '{cap}'-gated work (#1570 D) until this is fixed"
|
|
112
|
+
)
|
|
113
|
+
return lines
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _log_install_location() -> str:
|
|
117
|
+
"""One line describing how *this* agent process's coordinator distro
|
|
118
|
+
(whichever of :data:`coord.dist_name.CANDIDATE_NAMES` resolves — #2103)
|
|
119
|
+
is installed — editable (a dev checkout, #1628's flagged risk) vs a
|
|
120
|
+
normal PyPI/site-packages install — logged once at startup so it's
|
|
121
|
+
visible without a separate `coord health` run.
|
|
122
|
+
|
|
123
|
+
Best-effort: any failure to determine this (pip missing, `pip show`
|
|
124
|
+
timing out, ...) degrades to a note saying so rather than blowing up
|
|
125
|
+
agent startup over a diagnostic.
|
|
126
|
+
"""
|
|
127
|
+
from coord.health.checks.agent_install import pip_show
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
fields = pip_show(Path(sys.executable))
|
|
131
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
132
|
+
return f"coord agent: install location unknown ({type(exc).__name__}: {exc})"
|
|
133
|
+
|
|
134
|
+
if not fields:
|
|
135
|
+
tried = " or ".join(CANDIDATE_NAMES)
|
|
136
|
+
return f"coord agent: install location unknown (pip show returned nothing for {tried})"
|
|
137
|
+
|
|
138
|
+
name = fields.get("Name", "/".join(CANDIDATE_NAMES))
|
|
139
|
+
version = fields.get("Version", "?")
|
|
140
|
+
editable_location = fields.get("Editable project location") or ""
|
|
141
|
+
if editable_location:
|
|
142
|
+
return (
|
|
143
|
+
f"coord agent: {name} {version} — EDITABLE at "
|
|
144
|
+
f"{editable_location} (not a PyPI install — see #1628)"
|
|
145
|
+
)
|
|
146
|
+
location = fields.get("Location", "")
|
|
147
|
+
return f"coord agent: {name} {version} — pypi install at {location}"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True)
|
|
151
|
+
class _AgentStartup:
|
|
152
|
+
"""Everything :func:`_start_agent_server` needs out of the config layer,
|
|
153
|
+
resolved in one place so the *order* of resolution is directly testable
|
|
154
|
+
without booting uvicorn (#1712).
|
|
155
|
+
|
|
156
|
+
``config_free_reason`` is ``None`` on the normal path and a human-readable
|
|
157
|
+
explanation when the agent genuinely could not obtain a config from any
|
|
158
|
+
source — it rides into ``/health`` so a capability-less agent is
|
|
159
|
+
distinguishable from a misconfigured one.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
machine: Any
|
|
163
|
+
health_config: Any | None = None
|
|
164
|
+
concurrency: Any = None
|
|
165
|
+
artifact_paths: dict[str, list[str]] = field(default_factory=dict)
|
|
166
|
+
build_commands: dict[str, str] = field(default_factory=dict)
|
|
167
|
+
providers: dict[str, object] = field(default_factory=dict)
|
|
168
|
+
config_free_reason: str | None = None
|
|
169
|
+
notices: tuple[str, ...] = ()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _load_agent_config(
|
|
173
|
+
config_path: Path,
|
|
174
|
+
svc: Any | None,
|
|
175
|
+
*,
|
|
176
|
+
attempts: int = 3,
|
|
177
|
+
retry_delay: float = 2.0,
|
|
178
|
+
sleep: "Callable[[float], None]" = time.sleep,
|
|
179
|
+
) -> Config:
|
|
180
|
+
"""Load the coordinator config for `coord agent`, retrying a thin-client
|
|
181
|
+
fetch before giving up — and NEVER degrading to config-free mode (#1712).
|
|
182
|
+
|
|
183
|
+
``_load_config`` already knows how to obtain a config without a local file
|
|
184
|
+
(#1080's thin-client ``GET /config``); it exits(2) when it can't. The only
|
|
185
|
+
thing this wrapper adds is (a) a bounded retry when the config source is a
|
|
186
|
+
*network* one — the agent and the daemon often start at the same time after
|
|
187
|
+
a reboot, and a 2-second-early agent must not lose the whole fleet's
|
|
188
|
+
capability routing over it — and (b) a loud explanation of what the agent
|
|
189
|
+
refused to do, so the journal says "could not reach the daemon" rather than
|
|
190
|
+
leaving an operator to infer it from an empty ``capabilities`` list days
|
|
191
|
+
later (the #1673 / #1712 failure).
|
|
192
|
+
"""
|
|
193
|
+
last_exit: SystemExit | None = None
|
|
194
|
+
for attempt in range(1, max(1, attempts) + 1):
|
|
195
|
+
try:
|
|
196
|
+
return _load_config(config_path)
|
|
197
|
+
except SystemExit as exc:
|
|
198
|
+
last_exit = exc
|
|
199
|
+
# Only a remote source is worth retrying; a malformed/absent local
|
|
200
|
+
# file will fail identically every time.
|
|
201
|
+
if svc is None or attempt >= attempts:
|
|
202
|
+
break
|
|
203
|
+
click.echo(
|
|
204
|
+
f"coord agent: config load failed (attempt {attempt}/{attempts}) "
|
|
205
|
+
f"— retrying in {retry_delay:g}s",
|
|
206
|
+
err=True,
|
|
207
|
+
)
|
|
208
|
+
sleep(retry_delay)
|
|
209
|
+
|
|
210
|
+
local_state = "present" if config_path.exists() else "ABSENT"
|
|
211
|
+
svc_state = f"configured ({svc.url})" if svc is not None else "not configured"
|
|
212
|
+
click.echo(
|
|
213
|
+
"coord agent: FATAL — could not load a coordinator config.\n"
|
|
214
|
+
f" local coordinator.yml: {config_path} ({local_state})\n"
|
|
215
|
+
f" board service: {svc_state}\n"
|
|
216
|
+
" Refusing to fall back to config-free mode: this machine would come "
|
|
217
|
+
"up publishing capabilities=[] and repos=[], silently ineligible for "
|
|
218
|
+
"capability-matched routing with nothing anywhere reporting an error "
|
|
219
|
+
"(#1712). Fix the config source and restart.",
|
|
220
|
+
err=True,
|
|
221
|
+
)
|
|
222
|
+
raise last_exit if last_exit is not None else SystemExit(2)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _resolve_agent_startup(
|
|
226
|
+
config_path: Path,
|
|
227
|
+
machine_name: str | None,
|
|
228
|
+
*,
|
|
229
|
+
sleep: "Callable[[float], None]" = time.sleep,
|
|
230
|
+
attempts: int = 3,
|
|
231
|
+
retry_delay: float = 2.0,
|
|
232
|
+
) -> _AgentStartup:
|
|
233
|
+
"""Resolve this agent's machine identity + config-derived settings.
|
|
234
|
+
|
|
235
|
+
#1712 — THE ORDER HERE IS THE FIX. The old code branched on
|
|
236
|
+
``not config_path.exists() and machine_name`` *first*, so passing
|
|
237
|
+
``--machine`` on a host with no local ``coordinator.yml`` entered
|
|
238
|
+
config-free mode and never even attempted the thin-client daemon fetch
|
|
239
|
+
that lives inside ``_load_config`` (#1080). Two machines with byte-identical
|
|
240
|
+
config availability (no local file, daemon reachable) published different
|
|
241
|
+
capabilities purely because one systemd unit passed ``--machine`` and the
|
|
242
|
+
other didn't — the more explicit invocation being the broken one. So:
|
|
243
|
+
``--machine`` MUST NOT change which config source is used.
|
|
244
|
+
|
|
245
|
+
The old guard conflated two different conditions:
|
|
246
|
+
|
|
247
|
+
* *"there is no local config file"* — often true and completely fine,
|
|
248
|
+
because the daemon has one;
|
|
249
|
+
* *"there is no config obtainable at all"* — the only condition that may
|
|
250
|
+
trigger config-free mode.
|
|
251
|
+
|
|
252
|
+
Config-free mode is therefore the LAST resort: it needs no local file
|
|
253
|
+
**and** no board service configured. That genuine case (ephemeral Azure
|
|
254
|
+
workers, docs/EPHEMERAL_WORKERS.md) still starts, still publishes
|
|
255
|
+
``capabilities=[]``, and still does not crash — it just says so out loud
|
|
256
|
+
now, and carries a reason into ``/health`` so a legitimately config-free
|
|
257
|
+
worker is distinguishable from a machine whose declared capabilities
|
|
258
|
+
vanished.
|
|
259
|
+
"""
|
|
260
|
+
from coord.client import resolve_board_service # noqa: PLC0415
|
|
261
|
+
from coord.config import ConcurrencyConfig as _ConcurrencyConfig # noqa: PLC0415
|
|
262
|
+
|
|
263
|
+
svc = resolve_board_service()
|
|
264
|
+
has_local = config_path.exists()
|
|
265
|
+
|
|
266
|
+
if not has_local and svc is None:
|
|
267
|
+
# Genuine config-free mode: nothing to load from, anywhere.
|
|
268
|
+
if not machine_name:
|
|
269
|
+
click.echo(
|
|
270
|
+
f"error: no coordinator.yml at {config_path}, no board service "
|
|
271
|
+
"configured (~/.coord/client.toml / $COORD_SERVICE_URL), and no "
|
|
272
|
+
"--machine given — this agent cannot determine its own identity. "
|
|
273
|
+
"Pass --machine NAME to run config-free "
|
|
274
|
+
"(docs/EPHEMERAL_WORKERS.md), or configure a board service.",
|
|
275
|
+
err=True,
|
|
276
|
+
)
|
|
277
|
+
sys.exit(2)
|
|
278
|
+
from coord.models import Machine as _Machine # noqa: PLC0415
|
|
279
|
+
|
|
280
|
+
reason = (
|
|
281
|
+
f"no local coordinator.yml at {config_path} and no board service "
|
|
282
|
+
"configured — running config-free (capabilities and repos come "
|
|
283
|
+
"from the coordinator at dispatch time)"
|
|
284
|
+
)
|
|
285
|
+
return _AgentStartup(
|
|
286
|
+
machine=_Machine(
|
|
287
|
+
name=machine_name,
|
|
288
|
+
host="localhost",
|
|
289
|
+
capabilities=[],
|
|
290
|
+
repos=[],
|
|
291
|
+
repo_paths={},
|
|
292
|
+
),
|
|
293
|
+
concurrency=_ConcurrencyConfig(),
|
|
294
|
+
config_free_reason=reason,
|
|
295
|
+
# #1712 item 2: never let this be silent. #1671's startup
|
|
296
|
+
# diagnostics iterate over `machine.capabilities`, so with an
|
|
297
|
+
# empty list they print nothing in exactly the case that most
|
|
298
|
+
# needs a signal.
|
|
299
|
+
notices=(f"coord agent: NOTICE {reason}",),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
cfg = _load_agent_config(
|
|
303
|
+
config_path, svc, attempts=attempts, retry_delay=retry_delay, sleep=sleep
|
|
304
|
+
)
|
|
305
|
+
machine = _resolve_machine(cfg, machine_name)
|
|
306
|
+
|
|
307
|
+
from coord.providers import build_provider as _build_provider # noqa: PLC0415
|
|
308
|
+
|
|
309
|
+
providers_registry: dict[str, object] = {}
|
|
310
|
+
# #425: instantiate each named provider so the agent can dispatch to it
|
|
311
|
+
# when an assignment names it (spec.provider). An unknown provider type
|
|
312
|
+
# raises ValueError from build_provider — surface it as a startup failure
|
|
313
|
+
# rather than silently dropping the definition, so operators notice
|
|
314
|
+
# misconfiguration early.
|
|
315
|
+
for prov_name, defn in cfg.providers.definitions.items():
|
|
316
|
+
providers_registry[prov_name] = _build_provider(prov_name, defn, cfg.models)
|
|
317
|
+
|
|
318
|
+
notices: list[str] = []
|
|
319
|
+
source = "daemon (thin client)" if svc is not None else str(config_path)
|
|
320
|
+
notices.append(
|
|
321
|
+
f"coord agent: config source={source} machine={machine.name} "
|
|
322
|
+
f"capabilities={list(machine.capabilities)}"
|
|
323
|
+
)
|
|
324
|
+
if not machine.capabilities:
|
|
325
|
+
# Declaring no capabilities is legal, but it means dispatch_smoke can
|
|
326
|
+
# never pick this machine for capability-gated work — say so once at
|
|
327
|
+
# startup rather than letting it read as "healthy, just quiet" (#1712).
|
|
328
|
+
notices.append(
|
|
329
|
+
f"coord agent: NOTICE machine {machine.name!r} declares NO "
|
|
330
|
+
"capabilities in coordinator.yml — it is ineligible for every "
|
|
331
|
+
"capability-matched dispatch (#1570 D)"
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
return _AgentStartup(
|
|
335
|
+
machine=machine,
|
|
336
|
+
health_config=cfg,
|
|
337
|
+
concurrency=cfg.concurrency,
|
|
338
|
+
# #305: collect artifact_paths per repo for the stash helper.
|
|
339
|
+
artifact_paths={r.name: r.artifact_paths for r in cfg.repos if r.artifact_paths},
|
|
340
|
+
# #1323 (fix #3): collect build_command per repo so _stash_artifacts
|
|
341
|
+
# can run it in the worktree before globbing, ensuring the binary
|
|
342
|
+
# exists regardless of the worker's dev-loop feature flags.
|
|
343
|
+
build_commands={r.name: r.build_command for r in cfg.repos if r.build_command},
|
|
344
|
+
providers=providers_registry,
|
|
345
|
+
notices=tuple(notices),
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _start_agent_server(
|
|
350
|
+
config_path: Path,
|
|
351
|
+
machine_name: str | None,
|
|
352
|
+
bind_host: str,
|
|
353
|
+
bind_port: int,
|
|
354
|
+
) -> None:
|
|
355
|
+
"""Internal helper: start the uvicorn-backed agent server."""
|
|
356
|
+
# #1237: these imports are function-local *and* guarded so (a) `import
|
|
357
|
+
# coord.cli` stays client-clean on a base install and (b) hitting `coord
|
|
358
|
+
# agent` there says "install the [server] extra" instead of raising a raw
|
|
359
|
+
# ModuleNotFoundError.
|
|
360
|
+
with server_extra_guard("agent"):
|
|
361
|
+
import uvicorn
|
|
362
|
+
|
|
363
|
+
from coord.agent import AgentServer
|
|
364
|
+
from coord.agent_app import build_app
|
|
365
|
+
|
|
366
|
+
startup = _resolve_agent_startup(config_path, machine_name)
|
|
367
|
+
machine = startup.machine
|
|
368
|
+
concurrency = startup.concurrency
|
|
369
|
+
|
|
370
|
+
server = AgentServer(
|
|
371
|
+
machine_name=machine.name,
|
|
372
|
+
capabilities=machine.capabilities,
|
|
373
|
+
repos=machine.repos,
|
|
374
|
+
repo_paths=machine.repo_paths,
|
|
375
|
+
bash_wrap_spawn=concurrency.bash_wrap_spawn,
|
|
376
|
+
first_output_timeout=concurrency.first_output_timeout,
|
|
377
|
+
artifact_paths=startup.artifact_paths,
|
|
378
|
+
build_commands=startup.build_commands,
|
|
379
|
+
providers=startup.providers,
|
|
380
|
+
# #1630: the loaded Config, threaded into AgentServer purely so
|
|
381
|
+
# /health's periodic local check run can resolve this machine's
|
|
382
|
+
# checkouts (coord.health.context.build_context) the same way
|
|
383
|
+
# `coord health` does. None in config-free mode — the health engine
|
|
384
|
+
# still reports every machine-scope check, just with no checkouts to
|
|
385
|
+
# sweep (same fallback `coord health` itself uses with no
|
|
386
|
+
# coordinator.yml).
|
|
387
|
+
health_config=startup.health_config,
|
|
388
|
+
# #1712: rides into /health so `coord doctor` can tell a legitimately
|
|
389
|
+
# config-free ephemeral worker from a machine whose declared
|
|
390
|
+
# capabilities went missing.
|
|
391
|
+
config_free_reason=startup.config_free_reason,
|
|
392
|
+
)
|
|
393
|
+
app = build_app(server)
|
|
394
|
+
# #1671: loud-by-default startup diagnostics — resolved PATH, install
|
|
395
|
+
# location, and any declared capability this machine's own probe
|
|
396
|
+
# contradicts — so the class of failure in #1671 shows up in
|
|
397
|
+
# `journalctl --user -u coord-agent` on the very next restart instead
|
|
398
|
+
# of needing an operator to notice a `coord doctor` red and go SSH in.
|
|
399
|
+
click.echo(_log_install_location())
|
|
400
|
+
# #1712: config-source / capability notices go FIRST — with
|
|
401
|
+
# capabilities=[] the #1671 loop below has nothing to say, which is
|
|
402
|
+
# exactly the case that most needs a signal.
|
|
403
|
+
for line in startup.notices:
|
|
404
|
+
click.echo(line)
|
|
405
|
+
for line in _startup_diagnostic_lines(machine.capabilities):
|
|
406
|
+
click.echo(line)
|
|
407
|
+
click.echo(
|
|
408
|
+
f"coord agent: machine={machine.name} repos={machine.repos} "
|
|
409
|
+
f"listening on http://{bind_host}:{bind_port}"
|
|
410
|
+
)
|
|
411
|
+
try:
|
|
412
|
+
uvicorn.run(app, host=bind_host, port=bind_port, log_level="info")
|
|
413
|
+
finally:
|
|
414
|
+
server.shutdown()
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _resolve_target_version(
|
|
418
|
+
explicit_version: str | None,
|
|
419
|
+
*,
|
|
420
|
+
own_version: str = __version__,
|
|
421
|
+
index_url: str = "https://pypi.org/simple",
|
|
422
|
+
timeout: float = 5.0,
|
|
423
|
+
) -> tuple[str, list[str]]:
|
|
424
|
+
"""Resolve the version `coord agent update` should target.
|
|
425
|
+
|
|
426
|
+
#1886 Path A: the target used to come straight from this CLI's own
|
|
427
|
+
``__version__`` — reasonable-sounding ("bring the fleet in line with
|
|
428
|
+
whatever's running here"), but wrong the instant the operator's own
|
|
429
|
+
install is behind PyPI: a stale CLI silently under-updates the entire
|
|
430
|
+
fleet and still reports success on every machine, because success was
|
|
431
|
+
judged against the wrong target from the start. The target must come
|
|
432
|
+
from PyPI's simple index instead — the same source ``pip install -U``
|
|
433
|
+
itself resolves against (see ``coord.health.pypi`` for why the simple
|
|
434
|
+
index and not the JSON API) — so a stale operator CLI either targets
|
|
435
|
+
the real, newer release or says so loudly, never silently targets its
|
|
436
|
+
own age.
|
|
437
|
+
|
|
438
|
+
``explicit_version`` (``--version``) always wins and skips the network
|
|
439
|
+
call entirely — the documented escape hatch for pinning to a rollback
|
|
440
|
+
or a pre-release on purpose.
|
|
441
|
+
|
|
442
|
+
Returns ``(target_version, warning_lines)``. Warnings are informational
|
|
443
|
+
except for the "operator CLI is behind PyPI" case, where the returned
|
|
444
|
+
target is the newer PyPI release (never ``own_version``) — that's the
|
|
445
|
+
whole point of resolving from PyPI instead of trusting the caller.
|
|
446
|
+
A PyPI lookup failure (network down, unparseable index, ...) degrades
|
|
447
|
+
to targeting ``own_version`` with a warning rather than failing the
|
|
448
|
+
whole command — this fix must not turn "no network" into "can no
|
|
449
|
+
longer update the fleet at all."
|
|
450
|
+
"""
|
|
451
|
+
if explicit_version:
|
|
452
|
+
return explicit_version, []
|
|
453
|
+
|
|
454
|
+
from coord.health.pypi import latest_release_any, parse_version # noqa: PLC0415
|
|
455
|
+
|
|
456
|
+
try:
|
|
457
|
+
project, latest, _finals = latest_release_any(index_url=index_url, timeout=timeout)
|
|
458
|
+
except Exception as exc: # noqa: BLE001 — degrade, don't fail the update
|
|
459
|
+
return own_version, [
|
|
460
|
+
"⚠ could not resolve the latest release from PyPI's simple "
|
|
461
|
+
f"index ({type(exc).__name__}: {exc}) — targeting this CLI's "
|
|
462
|
+
f"own version v{own_version} instead. Pass --version to pin "
|
|
463
|
+
"explicitly, or fix network access so this can be verified.",
|
|
464
|
+
]
|
|
465
|
+
|
|
466
|
+
if latest is None:
|
|
467
|
+
return own_version, [
|
|
468
|
+
"⚠ PyPI's simple index returned no parseable releases under "
|
|
469
|
+
f"{' or '.join(CANDIDATE_NAMES)} — targeting this CLI's own "
|
|
470
|
+
f"version v{own_version} instead. Pass --version to pin "
|
|
471
|
+
"explicitly.",
|
|
472
|
+
]
|
|
473
|
+
|
|
474
|
+
own = parse_version(own_version)
|
|
475
|
+
if own is None or latest > own:
|
|
476
|
+
return latest.raw, [
|
|
477
|
+
f"⚠ this operator CLI is v{own_version} but PyPI's latest "
|
|
478
|
+
f"release ({project}) is v{latest.raw} — targeting v{latest.raw} "
|
|
479
|
+
"(the PyPI release), not this CLI's own version. This CLI's own "
|
|
480
|
+
f"`{project}` install is stale; consider `pip install --upgrade "
|
|
481
|
+
f"{project}` here too.",
|
|
482
|
+
]
|
|
483
|
+
|
|
484
|
+
return own_version, []
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@agent.command(
|
|
488
|
+
"update",
|
|
489
|
+
help=(
|
|
490
|
+
"POST /update to one or all agent servers, pinning the upgrade to "
|
|
491
|
+
"the latest release on PyPI's simple index (or --version, when "
|
|
492
|
+
"given) — NOT this CLI's own version (#1886: a stale operator "
|
|
493
|
+
"install must never silently under-update the fleet). #1241: each "
|
|
494
|
+
"agent installs the target version into a FRESH venv slot, "
|
|
495
|
+
"smoke-checks it, then atomically swaps it into place — an "
|
|
496
|
+
"in-flight update can never leave a torn/partial install for a "
|
|
497
|
+
"concurrent `coord` invocation to observe. An editable install "
|
|
498
|
+
"(`pip install -e .`) is refused outright, never silently `git "
|
|
499
|
+
"pull`ed. An agent with live (RUNNING/PENDING) assignments also "
|
|
500
|
+
"refuses unless --force is given, since the restart-after-swap "
|
|
501
|
+
"kills them mid-flight. Polls each agent's self-reported "
|
|
502
|
+
"*running* version for up to --timeout seconds and reports "
|
|
503
|
+
"success only once it matches the requested version, escalating "
|
|
504
|
+
"to a `systemctl --user restart coord-agent` if the version is "
|
|
505
|
+
"stuck."
|
|
506
|
+
),
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
@_CONFIG_OPTION
|
|
511
|
+
@click.option(
|
|
512
|
+
"--machine",
|
|
513
|
+
"machine_filter",
|
|
514
|
+
default=None,
|
|
515
|
+
help="Name of a single machine to update (from coordinator.yml).",
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@click.option(
|
|
520
|
+
"--all",
|
|
521
|
+
"all_machines",
|
|
522
|
+
is_flag=True,
|
|
523
|
+
help="Update all machines (mutually exclusive with --machine).",
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
@click.option(
|
|
528
|
+
"--version",
|
|
529
|
+
"version_override",
|
|
530
|
+
default=None,
|
|
531
|
+
help=(
|
|
532
|
+
"Pin the upgrade to this exact version instead of resolving the "
|
|
533
|
+
"latest release from PyPI's simple index (#1886)."
|
|
534
|
+
),
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
@click.option(
|
|
539
|
+
"--timeout",
|
|
540
|
+
default=120,
|
|
541
|
+
show_default=True,
|
|
542
|
+
type=int,
|
|
543
|
+
help="Seconds to wait for the agent to come back online after restart.",
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
@click.option(
|
|
548
|
+
"--force",
|
|
549
|
+
is_flag=True,
|
|
550
|
+
help=(
|
|
551
|
+
"Update even if the agent has live (RUNNING/PENDING) assignments — "
|
|
552
|
+
"the restart-after-swap kills them mid-flight (#1241). Without "
|
|
553
|
+
"this, an agent with live sessions refuses the update outright."
|
|
554
|
+
),
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def agent_update(
|
|
559
|
+
config_path: Path,
|
|
560
|
+
machine_filter: str | None,
|
|
561
|
+
all_machines: bool,
|
|
562
|
+
version_override: str | None,
|
|
563
|
+
timeout: int,
|
|
564
|
+
force: bool,
|
|
565
|
+
) -> None:
|
|
566
|
+
# #1886 Path A: the target used to be `__version__` — this CLI's own
|
|
567
|
+
# version. A stale operator install (PyPI already has v0.4.108, this
|
|
568
|
+
# CLI is still v0.4.107) silently under-updated the whole fleet and
|
|
569
|
+
# still printed three clean checkmarks, because "success" was judged
|
|
570
|
+
# against the wrong target from the start. Resolve the target from
|
|
571
|
+
# PyPI's simple index instead (the same source `pip install -U`
|
|
572
|
+
# itself resolves against — see coord.health.pypi) so a stale operator
|
|
573
|
+
# CLI is either overridden by the real target or refuses loudly,
|
|
574
|
+
# rather than silently pinning the fleet to its own age. --version
|
|
575
|
+
# remains an explicit escape hatch for pinning to something else on
|
|
576
|
+
# purpose (a rollback, a pre-release, ...).
|
|
577
|
+
target_version, resolve_warnings = _resolve_target_version(version_override)
|
|
578
|
+
for line in resolve_warnings:
|
|
579
|
+
click.echo(line, err=True)
|
|
580
|
+
|
|
581
|
+
# #1568: sending an explicit target_version lets the agent pin its pip
|
|
582
|
+
# install to that exact release (turning a stale-index no-op into a
|
|
583
|
+
# loud pip failure) and lets THIS command verify success by polling
|
|
584
|
+
# for that exact version, instead of inferring success from "the POST
|
|
585
|
+
# was accepted" (false positive on a cache-stale no-op) or failure
|
|
586
|
+
# from "the process stopped answering pings" (false negative on the
|
|
587
|
+
# execv-under-systemd restart, #404).
|
|
588
|
+
|
|
589
|
+
cfg = _load_config(config_path)
|
|
590
|
+
targets = _resolve_agent_targets(cfg, machine_filter, all_machines)
|
|
591
|
+
if not targets:
|
|
592
|
+
click.echo("No machines to update.", err=True)
|
|
593
|
+
sys.exit(2)
|
|
594
|
+
|
|
595
|
+
click.echo(f"Requesting upgrade to v{target_version}...")
|
|
596
|
+
|
|
597
|
+
# Capture each agent's start time BEFORE we trigger /update so the
|
|
598
|
+
# wait loop can distinguish "old agent still answering during pip"
|
|
599
|
+
# from "new agent came back up".
|
|
600
|
+
pre_started_at = _fetch_pre_started_at(targets)
|
|
601
|
+
|
|
602
|
+
# #1241: a machine that refuses (editable install, or live sessions
|
|
603
|
+
# without --force) never gets restarted, so polling it for a version
|
|
604
|
+
# change would just burn the whole --timeout window for nothing.
|
|
605
|
+
# `posted` is the subset of `targets` that actually accepted the POST;
|
|
606
|
+
# `refused_reasons` carries why the rest didn't.
|
|
607
|
+
posted: list = []
|
|
608
|
+
refused_reasons: dict[str, str] = {}
|
|
609
|
+
|
|
610
|
+
for machine in targets:
|
|
611
|
+
url = f"http://{machine.host}:{AGENT_PORT}/update"
|
|
612
|
+
click.echo(f" {machine.name}: POST {url} ...", nl=False)
|
|
613
|
+
try:
|
|
614
|
+
resp = httpx.post(
|
|
615
|
+
url, json={"target_version": target_version, "force": force}, timeout=10
|
|
616
|
+
)
|
|
617
|
+
if resp.status_code == 202:
|
|
618
|
+
data = resp.json()
|
|
619
|
+
click.echo(f" accepted (mode: {data.get('mode', '?')})")
|
|
620
|
+
posted.append(machine)
|
|
621
|
+
elif resp.status_code == 409:
|
|
622
|
+
try:
|
|
623
|
+
reason = resp.json().get("error") or "refused"
|
|
624
|
+
except Exception:
|
|
625
|
+
reason = "refused"
|
|
626
|
+
click.echo(f" refused — {reason}")
|
|
627
|
+
refused_reasons[machine.name] = reason
|
|
628
|
+
else:
|
|
629
|
+
click.echo(f" HTTP {resp.status_code}")
|
|
630
|
+
refused_reasons[machine.name] = f"HTTP {resp.status_code}"
|
|
631
|
+
except (httpx.HTTPError, httpx.TimeoutException) as e:
|
|
632
|
+
click.echo(f" error: {e}")
|
|
633
|
+
refused_reasons[machine.name] = str(e)
|
|
634
|
+
|
|
635
|
+
all_matched = not refused_reasons
|
|
636
|
+
if posted:
|
|
637
|
+
click.echo(
|
|
638
|
+
f"\nWaiting up to {timeout}s for agent(s) to report v{target_version}..."
|
|
639
|
+
)
|
|
640
|
+
outcomes = _wait_agents_updated(
|
|
641
|
+
posted,
|
|
642
|
+
target_version=target_version,
|
|
643
|
+
timeout=timeout,
|
|
644
|
+
pre_started_at=pre_started_at,
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
click.echo("")
|
|
648
|
+
for machine in posted:
|
|
649
|
+
outcome = outcomes[machine.name]
|
|
650
|
+
version_now = outcome["version_now"]
|
|
651
|
+
if outcome["matched"]:
|
|
652
|
+
vbefore = outcome.get("version_before") or "?"
|
|
653
|
+
click.echo(f" {machine.name}: ✓ {vbefore} → {target_version}")
|
|
654
|
+
continue
|
|
655
|
+
|
|
656
|
+
all_matched = False
|
|
657
|
+
result = outcome.get("result")
|
|
658
|
+
if result == "no_change":
|
|
659
|
+
click.echo(
|
|
660
|
+
f" {machine.name}: ✗ no change (still {version_now}) — "
|
|
661
|
+
f"{outcome.get('error') or 'pip resolved to the same version'}",
|
|
662
|
+
err=True,
|
|
663
|
+
)
|
|
664
|
+
elif result == "failed":
|
|
665
|
+
err = outcome.get("error") or "pip failed; see ~/.coord/last_update.log"
|
|
666
|
+
click.echo(f" {machine.name}: ✗ failed — {err}", err=True)
|
|
667
|
+
elif outcome.get("escalated"):
|
|
668
|
+
click.echo(
|
|
669
|
+
f" {machine.name}: ✗ pip upgraded to {target_version} but the "
|
|
670
|
+
f"process is stuck reporting {version_now} even after a "
|
|
671
|
+
"`systemctl --user restart` — needs manual investigation",
|
|
672
|
+
err=True,
|
|
673
|
+
)
|
|
674
|
+
elif not outcome.get("came_online"):
|
|
675
|
+
click.echo(f" {machine.name}: ✗ did not come back online", err=True)
|
|
676
|
+
else:
|
|
677
|
+
installed_now = outcome.get("installed_version_now")
|
|
678
|
+
if installed_now and installed_now != version_now:
|
|
679
|
+
# #1886 Path B: pip (or the disk) has already moved to
|
|
680
|
+
# a newer install, but the running process — the only
|
|
681
|
+
# thing "matched" ever keys off of — hasn't caught up.
|
|
682
|
+
# Distinguishing this from a bare "still reporting X"
|
|
683
|
+
# is the whole point: it says the process needs a
|
|
684
|
+
# restart, not another pip attempt.
|
|
685
|
+
click.echo(
|
|
686
|
+
f" {machine.name}: ✗ installed {installed_now} but the "
|
|
687
|
+
f"running process still reports {version_now} (expected "
|
|
688
|
+
f"{target_version}) — it hasn't restarted since the "
|
|
689
|
+
"update; try `systemctl --user restart coord-agent` "
|
|
690
|
+
"on that machine",
|
|
691
|
+
err=True,
|
|
692
|
+
)
|
|
693
|
+
else:
|
|
694
|
+
click.echo(
|
|
695
|
+
f" {machine.name}: ✗ still reporting {version_now}, "
|
|
696
|
+
f"expected {target_version}",
|
|
697
|
+
err=True,
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
if refused_reasons:
|
|
701
|
+
if posted:
|
|
702
|
+
click.echo("")
|
|
703
|
+
for name, reason in refused_reasons.items():
|
|
704
|
+
click.echo(f" {name}: ✗ refused — {reason}", err=True)
|
|
705
|
+
|
|
706
|
+
if not all_matched:
|
|
707
|
+
sys.exit(1)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
@agent.command(
|
|
711
|
+
"restart",
|
|
712
|
+
help=(
|
|
713
|
+
"POST /restart to one or all agent servers. The agent waits for "
|
|
714
|
+
"active workers to finish (or cancels them after --cancel-timeout "
|
|
715
|
+
"seconds) then restarts itself. Waits up to --timeout seconds for "
|
|
716
|
+
"the agent(s) to come back online."
|
|
717
|
+
),
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
@_CONFIG_OPTION
|
|
722
|
+
@click.option(
|
|
723
|
+
"--machine",
|
|
724
|
+
"machine_filter",
|
|
725
|
+
default=None,
|
|
726
|
+
help="Name of a single machine to restart (from coordinator.yml).",
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
@click.option(
|
|
731
|
+
"--all",
|
|
732
|
+
"all_machines",
|
|
733
|
+
is_flag=True,
|
|
734
|
+
help="Restart all machines (mutually exclusive with --machine).",
|
|
735
|
+
)
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
@click.option(
|
|
739
|
+
"--timeout",
|
|
740
|
+
default=120,
|
|
741
|
+
show_default=True,
|
|
742
|
+
type=int,
|
|
743
|
+
help="Seconds to wait for the agent to come back online after restart.",
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
@click.option(
|
|
748
|
+
"--cancel-timeout",
|
|
749
|
+
default=30,
|
|
750
|
+
show_default=True,
|
|
751
|
+
type=int,
|
|
752
|
+
help="Seconds the agent waits for active workers to finish before cancelling them.",
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
|
|
756
|
+
def agent_restart(
|
|
757
|
+
config_path: Path,
|
|
758
|
+
machine_filter: str | None,
|
|
759
|
+
all_machines: bool,
|
|
760
|
+
timeout: int,
|
|
761
|
+
cancel_timeout: int,
|
|
762
|
+
) -> None:
|
|
763
|
+
cfg = _load_config(config_path)
|
|
764
|
+
targets = _resolve_agent_targets(cfg, machine_filter, all_machines)
|
|
765
|
+
if not targets:
|
|
766
|
+
click.echo("No machines to restart.", err=True)
|
|
767
|
+
sys.exit(2)
|
|
768
|
+
|
|
769
|
+
for machine in targets:
|
|
770
|
+
url = f"http://{machine.host}:{AGENT_PORT}/restart"
|
|
771
|
+
click.echo(f" {machine.name}: POST {url} ...", nl=False)
|
|
772
|
+
try:
|
|
773
|
+
resp = httpx.post(
|
|
774
|
+
url,
|
|
775
|
+
json={"cancel_timeout": cancel_timeout},
|
|
776
|
+
timeout=10,
|
|
777
|
+
)
|
|
778
|
+
if resp.status_code == 202:
|
|
779
|
+
data = resp.json()
|
|
780
|
+
active = data.get("active_workers", 0)
|
|
781
|
+
click.echo(f" accepted ({active} active worker(s))")
|
|
782
|
+
else:
|
|
783
|
+
click.echo(f" HTTP {resp.status_code}")
|
|
784
|
+
except (httpx.HTTPError, httpx.TimeoutException) as e:
|
|
785
|
+
click.echo(f" error: {e}")
|
|
786
|
+
|
|
787
|
+
if targets:
|
|
788
|
+
click.echo(f"\nWaiting up to {timeout}s for agent(s) to come back online...")
|
|
789
|
+
results = _wait_agents_online(targets, timeout=timeout)
|
|
790
|
+
for name, came_back in results.items():
|
|
791
|
+
tag = "✓ online" if came_back else "✗ did not come back"
|
|
792
|
+
click.echo(f" {name}: {tag}")
|
|
793
|
+
if not all(results.values()):
|
|
794
|
+
sys.exit(1)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
@agent.command(
|
|
798
|
+
"clean-worktrees",
|
|
799
|
+
help=(
|
|
800
|
+
"POST /worktree-clean to one or all agent servers. Each agent "
|
|
801
|
+
"removes git worktrees whose assignment is in a terminal state "
|
|
802
|
+
"(done/failed/cancelled) and finished more than --recent-secs ago. "
|
|
803
|
+
"Running/pending worktrees are never touched."
|
|
804
|
+
),
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
@_CONFIG_OPTION
|
|
809
|
+
@click.option(
|
|
810
|
+
"--machine",
|
|
811
|
+
"machine_filter",
|
|
812
|
+
default=None,
|
|
813
|
+
help="Name of a single machine to clean (from coordinator.yml).",
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
@click.option(
|
|
818
|
+
"--all",
|
|
819
|
+
"all_machines",
|
|
820
|
+
is_flag=True,
|
|
821
|
+
help="Clean all machines (mutually exclusive with --machine).",
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
@click.option(
|
|
826
|
+
"--recent-secs",
|
|
827
|
+
default=300,
|
|
828
|
+
show_default=True,
|
|
829
|
+
type=int,
|
|
830
|
+
help=(
|
|
831
|
+
"Minimum age in seconds for a terminal assignment's worktree to be "
|
|
832
|
+
"eligible for removal (guards against racing with a just-finished worker)."
|
|
833
|
+
),
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def agent_clean_worktrees(
|
|
838
|
+
config_path: Path,
|
|
839
|
+
machine_filter: str | None,
|
|
840
|
+
all_machines: bool,
|
|
841
|
+
recent_secs: int,
|
|
842
|
+
) -> None:
|
|
843
|
+
cfg = _load_config(config_path)
|
|
844
|
+
targets = _resolve_agent_targets(cfg, machine_filter, all_machines)
|
|
845
|
+
if not targets:
|
|
846
|
+
click.echo("No machines to clean.", err=True)
|
|
847
|
+
sys.exit(2)
|
|
848
|
+
|
|
849
|
+
any_error = False
|
|
850
|
+
for machine in targets:
|
|
851
|
+
url = f"http://{machine.host}:{AGENT_PORT}/worktree-clean"
|
|
852
|
+
click.echo(f" {machine.name}: POST {url} ...", nl=False)
|
|
853
|
+
try:
|
|
854
|
+
resp = httpx.post(url, json={"recent_secs": recent_secs}, timeout=30)
|
|
855
|
+
if resp.status_code == 200:
|
|
856
|
+
data = resp.json()
|
|
857
|
+
cleaned = data.get("cleaned", 0)
|
|
858
|
+
kept = data.get("kept", 0)
|
|
859
|
+
freed = data.get("bytes_freed", 0)
|
|
860
|
+
freed_mb = freed / (1024 * 1024)
|
|
861
|
+
# #1402: the same endpoint GCs the shared cargo target cache.
|
|
862
|
+
cargo_mb = data.get("cargo_cache_bytes", 0) / (1024 * 1024)
|
|
863
|
+
evicted = data.get("cargo_caches_evicted", 0)
|
|
864
|
+
click.echo(
|
|
865
|
+
f" cleaned={cleaned} kept={kept} freed={freed_mb:.1f} MB "
|
|
866
|
+
f"cargo-cache={cargo_mb:.1f} MB (evicted {evicted})"
|
|
867
|
+
)
|
|
868
|
+
else:
|
|
869
|
+
click.echo(f" HTTP {resp.status_code}")
|
|
870
|
+
any_error = True
|
|
871
|
+
except (httpx.HTTPError, httpx.TimeoutException) as e:
|
|
872
|
+
click.echo(f" error: {e}")
|
|
873
|
+
any_error = True
|
|
874
|
+
|
|
875
|
+
if any_error:
|
|
876
|
+
sys.exit(1)
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
@agent.command(
|
|
880
|
+
"versions",
|
|
881
|
+
help=(
|
|
882
|
+
"GET /health from one or all agent servers and print each one's "
|
|
883
|
+
"self-reported version alongside the coordinator's own. #1568: "
|
|
884
|
+
"a version split-brain across the fleet is only detectable by "
|
|
885
|
+
"comparing versions directly — this is the fleet-wide check to "
|
|
886
|
+
"run before trusting a rule change, and after `coord agent "
|
|
887
|
+
"update` to confirm it actually landed everywhere."
|
|
888
|
+
),
|
|
889
|
+
)
|
|
890
|
+
@_CONFIG_OPTION
|
|
891
|
+
@click.option(
|
|
892
|
+
"--machine",
|
|
893
|
+
"machine_filter",
|
|
894
|
+
default=None,
|
|
895
|
+
help="Name of a single machine to check (from coordinator.yml).",
|
|
896
|
+
)
|
|
897
|
+
@click.option(
|
|
898
|
+
"--all",
|
|
899
|
+
"all_machines",
|
|
900
|
+
is_flag=True,
|
|
901
|
+
help="Check all machines (mutually exclusive with --machine).",
|
|
902
|
+
)
|
|
903
|
+
def agent_versions(
|
|
904
|
+
config_path: Path,
|
|
905
|
+
machine_filter: str | None,
|
|
906
|
+
all_machines: bool,
|
|
907
|
+
) -> None:
|
|
908
|
+
cfg = _load_config(config_path)
|
|
909
|
+
targets = _resolve_agent_targets(cfg, machine_filter, all_machines)
|
|
910
|
+
if not targets:
|
|
911
|
+
click.echo("No machines to check.", err=True)
|
|
912
|
+
sys.exit(2)
|
|
913
|
+
|
|
914
|
+
click.echo(f"coordinator: v{__version__}\n")
|
|
915
|
+
|
|
916
|
+
versions_seen: set[str] = set()
|
|
917
|
+
any_offline = False
|
|
918
|
+
any_mismatch = False
|
|
919
|
+
for machine in targets:
|
|
920
|
+
version: str | None
|
|
921
|
+
try:
|
|
922
|
+
resp = httpx.get(f"http://{machine.host}:{AGENT_PORT}/health", timeout=5)
|
|
923
|
+
version = resp.json().get("version") if resp.status_code == 200 else None
|
|
924
|
+
except (httpx.HTTPError, httpx.TimeoutException):
|
|
925
|
+
version = None
|
|
926
|
+
|
|
927
|
+
if version is None:
|
|
928
|
+
click.echo(f" {machine.name}: ✗ unreachable", err=True)
|
|
929
|
+
any_offline = True
|
|
930
|
+
continue
|
|
931
|
+
|
|
932
|
+
versions_seen.add(version)
|
|
933
|
+
mismatch = version != __version__
|
|
934
|
+
any_mismatch = any_mismatch or mismatch
|
|
935
|
+
marker = " ⚠ mismatch" if mismatch else ""
|
|
936
|
+
click.echo(f" {machine.name}: v{version}{marker}")
|
|
937
|
+
|
|
938
|
+
if len(versions_seen) > 1:
|
|
939
|
+
click.echo(
|
|
940
|
+
f"\n⚠ split-brain: {len(versions_seen)} distinct versions across the "
|
|
941
|
+
f"fleet ({', '.join(sorted(versions_seen))}). Do not trust a rule "
|
|
942
|
+
"change until `coord agent update --all` brings everyone in line.",
|
|
943
|
+
err=True,
|
|
944
|
+
)
|
|
945
|
+
sys.exit(1)
|
|
946
|
+
if any_mismatch:
|
|
947
|
+
click.echo(
|
|
948
|
+
f"\n⚠ mismatch: fleet is uniformly on a version that differs from "
|
|
949
|
+
f"the coordinator's own v{__version__}. Run `coord agent update "
|
|
950
|
+
"--all` to bring the fleet in line.",
|
|
951
|
+
err=True,
|
|
952
|
+
)
|
|
953
|
+
sys.exit(1)
|
|
954
|
+
if any_offline:
|
|
955
|
+
sys.exit(1)
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _resolve_agent_targets(cfg, machine_filter: str | None, all_machines: bool):
|
|
959
|
+
"""Return the list of Machine objects to target for update/restart.
|
|
960
|
+
|
|
961
|
+
Validates --machine / --all flags and prints errors on bad input.
|
|
962
|
+
"""
|
|
963
|
+
if machine_filter and all_machines:
|
|
964
|
+
click.echo("error: --machine and --all are mutually exclusive.", err=True)
|
|
965
|
+
sys.exit(2)
|
|
966
|
+
if not machine_filter and not all_machines:
|
|
967
|
+
click.echo(
|
|
968
|
+
"error: specify either --machine NAME or --all.", err=True
|
|
969
|
+
)
|
|
970
|
+
sys.exit(2)
|
|
971
|
+
|
|
972
|
+
if machine_filter:
|
|
973
|
+
machine = next((m for m in cfg.machines if m.name == machine_filter), None)
|
|
974
|
+
if machine is None:
|
|
975
|
+
click.echo(
|
|
976
|
+
f"error: machine {machine_filter!r} not in coordinator.yml "
|
|
977
|
+
f"(have: {[m.name for m in cfg.machines]})",
|
|
978
|
+
err=True,
|
|
979
|
+
)
|
|
980
|
+
sys.exit(2)
|
|
981
|
+
return [machine]
|
|
982
|
+
|
|
983
|
+
return list(cfg.machines)
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _wait_agents_online(
|
|
987
|
+
machines: list,
|
|
988
|
+
*,
|
|
989
|
+
timeout: float = 120.0,
|
|
990
|
+
poll_interval: float = 2.0,
|
|
991
|
+
pre_started_at: dict[str, float | None] | None = None,
|
|
992
|
+
) -> dict[str, bool]:
|
|
993
|
+
"""Poll /health on each machine until all are online or timeout expires.
|
|
994
|
+
|
|
995
|
+
When ``pre_started_at`` is provided, a machine is only considered
|
|
996
|
+
"back" once its reported ``agent_started_at`` differs from the
|
|
997
|
+
pre-update value (or appears for the first time on an agent that
|
|
998
|
+
didn't expose it before). This stops the CLI from racing the old
|
|
999
|
+
agent while a pip upgrade is still running inside it.
|
|
1000
|
+
|
|
1001
|
+
For agents that don't expose ``agent_started_at`` at all (pre-v0.4.3),
|
|
1002
|
+
we fall back to "responding to /health is enough."
|
|
1003
|
+
|
|
1004
|
+
Returns ``{machine_name: came_back_online}`` for every machine.
|
|
1005
|
+
"""
|
|
1006
|
+
# Scale the sleep down for short timeouts (e.g. tests passing
|
|
1007
|
+
# --timeout 1) so a tiny deadline isn't dominated by a single fixed
|
|
1008
|
+
# 2s sleep — callers that want the full 2s just pass a bigger timeout.
|
|
1009
|
+
poll_interval = min(poll_interval, max(timeout / 5, 0.05))
|
|
1010
|
+
deadline = time.time() + timeout
|
|
1011
|
+
online: set[str] = set()
|
|
1012
|
+
pre = pre_started_at or {}
|
|
1013
|
+
|
|
1014
|
+
while time.time() < deadline:
|
|
1015
|
+
for machine in machines:
|
|
1016
|
+
if machine.name in online:
|
|
1017
|
+
continue
|
|
1018
|
+
try:
|
|
1019
|
+
resp = httpx.get(
|
|
1020
|
+
f"http://{machine.host}:{AGENT_PORT}/health",
|
|
1021
|
+
timeout=3.0,
|
|
1022
|
+
)
|
|
1023
|
+
if resp.status_code != 200:
|
|
1024
|
+
continue
|
|
1025
|
+
if machine.name in pre:
|
|
1026
|
+
pre_val = pre[machine.name]
|
|
1027
|
+
try:
|
|
1028
|
+
cur = resp.json().get("agent_started_at")
|
|
1029
|
+
except Exception:
|
|
1030
|
+
cur = None
|
|
1031
|
+
if cur is None:
|
|
1032
|
+
# Old agent (no started_at) — fall back to "alive
|
|
1033
|
+
# is good enough" so /update on a pre-v0.4.3
|
|
1034
|
+
# agent isn't blocked forever.
|
|
1035
|
+
online.add(machine.name)
|
|
1036
|
+
elif pre_val is None or cur != pre_val:
|
|
1037
|
+
# Either the agent didn't expose started_at
|
|
1038
|
+
# before (just upgraded TO v0.4.3) or the value
|
|
1039
|
+
# changed (restart happened).
|
|
1040
|
+
online.add(machine.name)
|
|
1041
|
+
else:
|
|
1042
|
+
online.add(machine.name)
|
|
1043
|
+
except Exception:
|
|
1044
|
+
pass
|
|
1045
|
+
|
|
1046
|
+
if len(online) == len(machines):
|
|
1047
|
+
break
|
|
1048
|
+
time.sleep(poll_interval)
|
|
1049
|
+
|
|
1050
|
+
return {m.name: m.name in online for m in machines}
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
def _wait_agents_updated(
|
|
1054
|
+
machines: list,
|
|
1055
|
+
*,
|
|
1056
|
+
target_version: str,
|
|
1057
|
+
timeout: float = 120.0,
|
|
1058
|
+
poll_interval: float = 2.0,
|
|
1059
|
+
pre_started_at: dict[str, float | None] | None = None,
|
|
1060
|
+
) -> dict[str, dict]:
|
|
1061
|
+
"""Poll /health on each machine until its self-reported version equals
|
|
1062
|
+
``target_version``, escalating to a driven restart if needed.
|
|
1063
|
+
|
|
1064
|
+
#1568: success is judged by the version the agent actually reports —
|
|
1065
|
+
never by "the POST was accepted" and never by "the process answers
|
|
1066
|
+
pings again." Those liveness signals fail in opposite directions:
|
|
1067
|
+
|
|
1068
|
+
- Cause A: pip resolves to a cached/stale version and exits 0. The
|
|
1069
|
+
POST is accepted, the process never restarts, but nothing changed —
|
|
1070
|
+
the old ``_wait_agents_online``-based check reported success anyway.
|
|
1071
|
+
- Cause B (#404): the update's ``os.execv`` self-restart doesn't take
|
|
1072
|
+
under systemd, so the OLD process keeps answering /health after a
|
|
1073
|
+
real upgrade. ``_wait_agents_online`` reported "did not come back"
|
|
1074
|
+
even though the new version was installed and the service was
|
|
1075
|
+
active.
|
|
1076
|
+
|
|
1077
|
+
When a machine's pip step genuinely succeeded (``last_update.result
|
|
1078
|
+
== "upgraded"``) but the version still hasn't advanced once the
|
|
1079
|
+
normal poll window elapses, escalate once with an SSH-driven
|
|
1080
|
+
``systemctl --user restart coord-agent`` — the documented fix for the
|
|
1081
|
+
execv-under-systemd stall (see docs/AGENT_OPERATIONS.md) — and give
|
|
1082
|
+
it one more short window before giving up.
|
|
1083
|
+
|
|
1084
|
+
Returns ``{machine_name: {matched, came_online, version_now,
|
|
1085
|
+
installed_version_now, version_before, result, error, escalated}}``.
|
|
1086
|
+
"""
|
|
1087
|
+
# Scale the sleep down for short timeouts (e.g. tests passing
|
|
1088
|
+
# --timeout 1) so a tiny deadline isn't dominated by a single fixed
|
|
1089
|
+
# 2s sleep — callers that want the full 2s just pass a bigger timeout.
|
|
1090
|
+
poll_interval = min(poll_interval, max(timeout / 5, 0.05))
|
|
1091
|
+
pre = pre_started_at or {}
|
|
1092
|
+
out: dict[str, dict] = {
|
|
1093
|
+
m.name: {
|
|
1094
|
+
"matched": False,
|
|
1095
|
+
"came_online": False,
|
|
1096
|
+
"version_now": "?",
|
|
1097
|
+
"installed_version_now": None,
|
|
1098
|
+
"version_before": None,
|
|
1099
|
+
"result": None,
|
|
1100
|
+
"error": None,
|
|
1101
|
+
"escalated": False,
|
|
1102
|
+
}
|
|
1103
|
+
for m in machines
|
|
1104
|
+
}
|
|
1105
|
+
pending = {m.name: m for m in machines}
|
|
1106
|
+
|
|
1107
|
+
def _poll_once(machine) -> bool:
|
|
1108
|
+
"""Fetch /health once, update out[machine.name], return True on match."""
|
|
1109
|
+
info = out[machine.name]
|
|
1110
|
+
try:
|
|
1111
|
+
resp = httpx.get(f"http://{machine.host}:{AGENT_PORT}/health", timeout=3.0)
|
|
1112
|
+
if resp.status_code != 200:
|
|
1113
|
+
return False
|
|
1114
|
+
health = resp.json()
|
|
1115
|
+
except Exception:
|
|
1116
|
+
return False
|
|
1117
|
+
|
|
1118
|
+
# #1886 Path B: `version` (checked below) is the RUNNING process's
|
|
1119
|
+
# loaded-module version — the only thing "matched" may key off of.
|
|
1120
|
+
# `installed_version` is a disk read that can advance the instant
|
|
1121
|
+
# pip writes to site-packages, well before (or without) a restart
|
|
1122
|
+
# (#404's execv-under-systemd stall). Recording both lets a
|
|
1123
|
+
# still-pending outcome say *why* — "installed advanced, running
|
|
1124
|
+
# didn't" — instead of a bare "still reporting X".
|
|
1125
|
+
version_now = health.get("version")
|
|
1126
|
+
info["version_now"] = version_now or "?"
|
|
1127
|
+
info["installed_version_now"] = health.get("installed_version")
|
|
1128
|
+
last = health.get("last_update") or {}
|
|
1129
|
+
info["result"] = last.get("result")
|
|
1130
|
+
info["error"] = last.get("error")
|
|
1131
|
+
info["version_before"] = last.get("version_before")
|
|
1132
|
+
|
|
1133
|
+
if machine.name in pre:
|
|
1134
|
+
pre_val = pre[machine.name]
|
|
1135
|
+
cur = health.get("agent_started_at")
|
|
1136
|
+
if cur is None or pre_val is None or cur != pre_val:
|
|
1137
|
+
info["came_online"] = True
|
|
1138
|
+
else:
|
|
1139
|
+
info["came_online"] = True
|
|
1140
|
+
|
|
1141
|
+
if version_now == target_version:
|
|
1142
|
+
info["matched"] = True
|
|
1143
|
+
return True
|
|
1144
|
+
return False
|
|
1145
|
+
|
|
1146
|
+
deadline = time.time() + timeout
|
|
1147
|
+
while time.time() < deadline and pending:
|
|
1148
|
+
for name in list(pending):
|
|
1149
|
+
if _poll_once(pending[name]):
|
|
1150
|
+
del pending[name]
|
|
1151
|
+
if not pending:
|
|
1152
|
+
break
|
|
1153
|
+
time.sleep(poll_interval)
|
|
1154
|
+
|
|
1155
|
+
# Escalate the machines still stuck on the old version whose pip step
|
|
1156
|
+
# actually succeeded — the classic execv-under-systemd stall. Give
|
|
1157
|
+
# each a short follow-up window after the driven restart.
|
|
1158
|
+
escalate_timeout = min(30.0, max(timeout / 2, 15.0))
|
|
1159
|
+
for name in list(pending):
|
|
1160
|
+
machine = pending[name]
|
|
1161
|
+
info = out[name]
|
|
1162
|
+
if info["result"] != "upgraded":
|
|
1163
|
+
continue
|
|
1164
|
+
info["escalated"] = _escalate_restart(machine)
|
|
1165
|
+
if not info["escalated"]:
|
|
1166
|
+
continue
|
|
1167
|
+
sub_deadline = time.time() + escalate_timeout
|
|
1168
|
+
while time.time() < sub_deadline:
|
|
1169
|
+
if _poll_once(machine):
|
|
1170
|
+
del pending[name]
|
|
1171
|
+
break
|
|
1172
|
+
time.sleep(poll_interval)
|
|
1173
|
+
|
|
1174
|
+
return out
|
|
1175
|
+
|
|
1176
|
+
|
|
1177
|
+
def _escalate_restart(machine) -> bool:
|
|
1178
|
+
"""Best-effort ``systemctl --user restart coord-agent`` over SSH.
|
|
1179
|
+
|
|
1180
|
+
#404 / #1568: ``/update``'s ``os.execv`` self-restart does not take
|
|
1181
|
+
under systemd — same PID, stale version. ``XDG_RUNTIME_DIR=/run/user/
|
|
1182
|
+
$(id -u)`` is load-bearing: a bare ``systemctl --user restart``
|
|
1183
|
+
silently no-ops in a non-interactive SSH session. See
|
|
1184
|
+
docs/AGENT_OPERATIONS.md for the manual runbook this automates.
|
|
1185
|
+
|
|
1186
|
+
Returns True if the ssh command itself exited 0 — NOT whether the
|
|
1187
|
+
agent actually came back on the new version; the caller re-polls
|
|
1188
|
+
/health afterwards to confirm that.
|
|
1189
|
+
"""
|
|
1190
|
+
cmd = [
|
|
1191
|
+
"ssh",
|
|
1192
|
+
"-o", "BatchMode=yes",
|
|
1193
|
+
"-o", "ConnectTimeout=10",
|
|
1194
|
+
"-o", "StrictHostKeyChecking=accept-new",
|
|
1195
|
+
machine.host,
|
|
1196
|
+
"XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user restart coord-agent",
|
|
1197
|
+
]
|
|
1198
|
+
try:
|
|
1199
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=20)
|
|
1200
|
+
except Exception:
|
|
1201
|
+
return False
|
|
1202
|
+
return result.returncode == 0
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def _fetch_pre_started_at(machines: list) -> dict[str, float | None]:
|
|
1206
|
+
"""Capture each agent's `agent_started_at` BEFORE we trigger /update.
|
|
1207
|
+
|
|
1208
|
+
Returns ``{name: started_at_or_None}`` — None when the agent is
|
|
1209
|
+
unreachable or doesn't expose the field yet.
|
|
1210
|
+
"""
|
|
1211
|
+
out: dict[str, float | None] = {}
|
|
1212
|
+
for m in machines:
|
|
1213
|
+
try:
|
|
1214
|
+
resp = httpx.get(f"http://{m.host}:{AGENT_PORT}/health", timeout=3.0)
|
|
1215
|
+
if resp.status_code == 200:
|
|
1216
|
+
out[m.name] = resp.json().get("agent_started_at")
|
|
1217
|
+
else:
|
|
1218
|
+
out[m.name] = None
|
|
1219
|
+
except Exception:
|
|
1220
|
+
out[m.name] = None
|
|
1221
|
+
return out
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def _resolve_machine(cfg: Config, explicit_name: str | None):
|
|
1225
|
+
if explicit_name:
|
|
1226
|
+
m = next((m for m in cfg.machines if m.name == explicit_name), None)
|
|
1227
|
+
if m is None:
|
|
1228
|
+
click.echo(
|
|
1229
|
+
f"error: machine {explicit_name!r} not in coordinator.yml "
|
|
1230
|
+
f"(have: {[m.name for m in cfg.machines]})",
|
|
1231
|
+
err=True,
|
|
1232
|
+
)
|
|
1233
|
+
sys.exit(2)
|
|
1234
|
+
return m
|
|
1235
|
+
|
|
1236
|
+
hostname = socket.gethostname().lower()
|
|
1237
|
+
short = hostname.split(".")[0]
|
|
1238
|
+
candidates = [m for m in cfg.machines if m.name.lower() == short or m.host.lower() == hostname or m.host.lower().split(".")[0] == short]
|
|
1239
|
+
if len(candidates) == 1:
|
|
1240
|
+
return candidates[0]
|
|
1241
|
+
if not candidates:
|
|
1242
|
+
click.echo(
|
|
1243
|
+
f"error: could not match hostname {hostname!r} to any machine in coordinator.yml. "
|
|
1244
|
+
f"Pass --machine explicitly. Known: {[m.name for m in cfg.machines]}",
|
|
1245
|
+
err=True,
|
|
1246
|
+
)
|
|
1247
|
+
sys.exit(2)
|
|
1248
|
+
click.echo(
|
|
1249
|
+
f"error: hostname {hostname!r} matches multiple machines: "
|
|
1250
|
+
f"{[m.name for m in candidates]}. Pass --machine explicitly.",
|
|
1251
|
+
err=True,
|
|
1252
|
+
)
|
|
1253
|
+
sys.exit(2)
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
@click.command(
|
|
1257
|
+
help=(
|
|
1258
|
+
"Pause a machine — no new agents will be routed to it until "
|
|
1259
|
+
"`coord unpause` is called. In-flight assignments are NOT "
|
|
1260
|
+
"cancelled (use `coord stop` for that).\n\n"
|
|
1261
|
+
"MACHINE is the local name from coordinator.yml."
|
|
1262
|
+
),
|
|
1263
|
+
)
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
@_CONFIG_OPTION
|
|
1267
|
+
@click.argument("machine")
|
|
1268
|
+
def pause(config_path: Path, machine: str) -> None:
|
|
1269
|
+
from coord.machine_pause import pause as _pause
|
|
1270
|
+
|
|
1271
|
+
# #1563: on a thin client this routes to the daemon's `/pause` endpoint
|
|
1272
|
+
# and can raise (network/HTTP failure) — surface that loudly rather than
|
|
1273
|
+
# letting a bare traceback stand in for "coord pause silently no-oped",
|
|
1274
|
+
# which is exactly the failure mode this fix closes.
|
|
1275
|
+
try:
|
|
1276
|
+
changed = _pause(machine)
|
|
1277
|
+
except Exception as e: # noqa: BLE001
|
|
1278
|
+
click.echo(
|
|
1279
|
+
f"error: could not confirm pause of {machine!r} with the daemon: {e}",
|
|
1280
|
+
err=True,
|
|
1281
|
+
)
|
|
1282
|
+
sys.exit(1)
|
|
1283
|
+
if changed:
|
|
1284
|
+
click.echo(f"paused: {machine}")
|
|
1285
|
+
else:
|
|
1286
|
+
click.echo(f"already paused: {machine}")
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
@click.command(
|
|
1290
|
+
help=(
|
|
1291
|
+
"Resume a paused machine — new assignments can be routed to it "
|
|
1292
|
+
"again. No-op if the machine wasn't paused.\n\n"
|
|
1293
|
+
"#1862: if MACHINE isn't hand-paused but IS inside its "
|
|
1294
|
+
"coordinator.yml `quiet_hours` window, this overrides that window "
|
|
1295
|
+
"until it ends rather than silently doing nothing (a bare re-read "
|
|
1296
|
+
"would otherwise show it paused again on the very next poll)."
|
|
1297
|
+
),
|
|
1298
|
+
)
|
|
1299
|
+
|
|
1300
|
+
|
|
1301
|
+
@_CONFIG_OPTION
|
|
1302
|
+
@click.argument("machine")
|
|
1303
|
+
def unpause(config_path: Path, machine: str) -> None:
|
|
1304
|
+
from coord.machine_pause import unpause as _unpause
|
|
1305
|
+
|
|
1306
|
+
# #1862: best-effort quiet-hours context — an unloadable/placeholder
|
|
1307
|
+
# config must not block the (still fully functional) explicit-unpause
|
|
1308
|
+
# path below, it only means quiet-hours overrides can't be resolved.
|
|
1309
|
+
# Deliberately NOT `_load_config()`: that helper `sys.exit(2)`s on a
|
|
1310
|
+
# ConfigError (a SystemExit, which a bare `except Exception:` doesn't
|
|
1311
|
+
# catch) and, on a thin client, fetches the daemon's remote config over
|
|
1312
|
+
# HTTP — wasted work here, since a thin client's `_unpause()` call below
|
|
1313
|
+
# routes over HTTP too and never even looks at `machines`.
|
|
1314
|
+
machines = None
|
|
1315
|
+
try:
|
|
1316
|
+
from coord.config import load as _load_yaml_config # noqa: PLC0415
|
|
1317
|
+
|
|
1318
|
+
machines = _load_yaml_config(config_path).machines
|
|
1319
|
+
except Exception: # noqa: BLE001
|
|
1320
|
+
pass
|
|
1321
|
+
|
|
1322
|
+
# #1563: fail loudly, see the matching comment on pause() above.
|
|
1323
|
+
try:
|
|
1324
|
+
outcome = _unpause(machine, machines)
|
|
1325
|
+
except Exception as e: # noqa: BLE001
|
|
1326
|
+
click.echo(
|
|
1327
|
+
f"error: could not confirm unpause of {machine!r} with the daemon: {e}",
|
|
1328
|
+
err=True,
|
|
1329
|
+
)
|
|
1330
|
+
sys.exit(1)
|
|
1331
|
+
if outcome.kind == "resumed":
|
|
1332
|
+
click.echo(f"resumed: {machine}")
|
|
1333
|
+
elif outcome.kind == "quiet_override":
|
|
1334
|
+
click.echo(
|
|
1335
|
+
f"{machine}: quiet hours overridden until {outcome.quiet_until} "
|
|
1336
|
+
f"({outcome.tz}) — resumes its normal quiet schedule next window"
|
|
1337
|
+
)
|
|
1338
|
+
else:
|
|
1339
|
+
click.echo(f"not paused: {machine}")
|