code-coordinator 0.5.46__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
coord/agent_app.py
ADDED
|
@@ -0,0 +1,1555 @@
|
|
|
1
|
+
"""Starlette HTTP layer over `AgentServer`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
import time
|
|
14
|
+
import urllib.request
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Callable
|
|
17
|
+
|
|
18
|
+
from starlette.applications import Starlette
|
|
19
|
+
from starlette.requests import Request
|
|
20
|
+
from starlette.responses import JSONResponse, PlainTextResponse, Response, StreamingResponse
|
|
21
|
+
from starlette.routing import Route
|
|
22
|
+
|
|
23
|
+
from coord import __version__, agent_update
|
|
24
|
+
from coord.agent import RUNNING, PENDING, AgentAssignment, AgentServer, AssignmentSpec
|
|
25
|
+
from coord.dist_name import DistributionNotFoundError, resolve_installed, resolve_installed_name
|
|
26
|
+
from coord.dist_name import pkg_spec as _dist_pkg_spec
|
|
27
|
+
from coord.events import stream_assignment_log
|
|
28
|
+
from coord.openapi import build_spec, dataclass_schema, openapi_and_docs_routes
|
|
29
|
+
|
|
30
|
+
_log = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _agent_pkg_spec() -> str:
|
|
34
|
+
"""What `POST /update` asks pip to install (#1237). An agent *is* the
|
|
35
|
+
server half of the package, so it must reinstall itself WITH the
|
|
36
|
+
`[server]` extra — a bare upgrade would, on a fresh venv, leave the
|
|
37
|
+
agent without starlette/uvicorn and dead on the next restart.
|
|
38
|
+
|
|
39
|
+
#2103: resolved tolerantly against whichever of `code-coordinator` /
|
|
40
|
+
`claude-coordinator` is currently installed, rather than the old
|
|
41
|
+
hardcoded `claude-coordinator[server]` — installing the wrong name once
|
|
42
|
+
the fleet is mid-rename either 404s against PyPI or, worse, silently
|
|
43
|
+
reinstalls the stale package. Deliberately NOT caught here: if neither
|
|
44
|
+
name resolves, the caller (`_do_update`'s existing try/except) turns
|
|
45
|
+
that into an explicit `last_update.json` failure naming both names
|
|
46
|
+
tried, instead of guessing.
|
|
47
|
+
"""
|
|
48
|
+
return _dist_pkg_spec(extra="server")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
#: The sibling systemd *user* units `POST /restart-services` (#2069) is
|
|
52
|
+
#: allowed to restart. `coord-agent` is deliberately excluded — that unit
|
|
53
|
+
#: restarts itself, via `/update`/`/rollback`/`/restart`, and doing it again
|
|
54
|
+
#: here would race those endpoints' own restart threads. Matches
|
|
55
|
+
#: `coord.health.checks.spawned_coord.DEFAULT_UNITS` minus `coord-agent` and
|
|
56
|
+
#: `coord-notify` (the latter has no deploy lane of its own to be behind on).
|
|
57
|
+
RESTARTABLE_SIBLING_UNITS: frozenset[str] = frozenset(
|
|
58
|
+
{"coord-serve", "coord-web", "coord-drive-queue"}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _venv_dir() -> Path:
|
|
63
|
+
"""Root of the venv `coord agent update` swaps blue/green (#1241).
|
|
64
|
+
|
|
65
|
+
Overridable via `COORD_VENV_DIR` (tests, non-default installs);
|
|
66
|
+
defaults to `~/.coord-venv` — the path `install-agent.sh` creates and
|
|
67
|
+
every `deploy/coord-*.service` unit hardcodes as `ExecStart`'s venv.
|
|
68
|
+
"""
|
|
69
|
+
override = os.environ.get("COORD_VENV_DIR")
|
|
70
|
+
return Path(override) if override else Path.home() / ".coord-venv"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _installed_version() -> str | None:
|
|
74
|
+
"""Return the currently-installed coordinator distribution's version.
|
|
75
|
+
|
|
76
|
+
#1238: ``coord.__version__`` (imported once, at module-import time — see
|
|
77
|
+
the module-level ``from coord import __version__`` above) and this are
|
|
78
|
+
deliberately different reads. This one re-queries ``importlib.metadata``
|
|
79
|
+
fresh on every call, so it reflects a ``pip install``/``pip install
|
|
80
|
+
--upgrade`` that happened to site-packages *after* this process started,
|
|
81
|
+
without needing a restart — exactly what ``/health`` needs to tell "the
|
|
82
|
+
process hasn't restarted since the last update" apart from "the update
|
|
83
|
+
never happened".
|
|
84
|
+
|
|
85
|
+
#2103: tries `code-coordinator` then falls back to `claude-coordinator`
|
|
86
|
+
(see ``coord.dist_name``) rather than hardcoding one name — installing
|
|
87
|
+
under the name this process doesn't query used to make a fully-updated
|
|
88
|
+
agent report ``None`` here, the exact false negative behind the
|
|
89
|
+
fleet's most-recurring `✗ did not come back`.
|
|
90
|
+
|
|
91
|
+
This is a *report* site, not an *act* site (contrast `_agent_pkg_spec`,
|
|
92
|
+
which deliberately lets `DistributionNotFoundError` propagate): callers
|
|
93
|
+
already fall back to the literal string `"unknown"` when this returns
|
|
94
|
+
`None`, so "neither name resolved" isn't silently swallowed end to end
|
|
95
|
+
— but it's still logged here, naming both names tried, rather than
|
|
96
|
+
disappearing into a bare `None` with no trace of why.
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
return resolve_installed().version
|
|
100
|
+
except DistributionNotFoundError as exc:
|
|
101
|
+
_log.warning("could not determine installed version: %s", exc)
|
|
102
|
+
return None
|
|
103
|
+
except Exception:
|
|
104
|
+
_log.exception("unexpected error resolving installed version")
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _write_last_update(state_dir: Path, payload: dict) -> None:
|
|
109
|
+
"""Persist the most recent update attempt summary so /health can
|
|
110
|
+
surface it after the agent restarts."""
|
|
111
|
+
try:
|
|
112
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
113
|
+
(state_dir / "last_update.json").write_text(json.dumps(payload, indent=2))
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _read_last_update(state_dir: Path) -> dict | None:
|
|
119
|
+
try:
|
|
120
|
+
return json.loads((state_dir / "last_update.json").read_text())
|
|
121
|
+
except Exception:
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _running_under_systemd() -> bool:
|
|
126
|
+
"""True when this process was started by systemd (in practice, a user
|
|
127
|
+
unit — see ``deploy/coord-agent.service``).
|
|
128
|
+
|
|
129
|
+
``INVOCATION_ID`` is set by systemd for every unit invocation (since
|
|
130
|
+
v232) and is the standard "am I running under systemd" signal — unlike
|
|
131
|
+
checking the parent PID, it survives the process being reparented.
|
|
132
|
+
"""
|
|
133
|
+
return bool(os.environ.get("INVOCATION_ID"))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _restart_via_systemctl(unit: str = "coord-agent") -> bool:
|
|
137
|
+
"""Best-effort ``systemctl --user restart <unit>``, run from *inside*
|
|
138
|
+
the unit's own process.
|
|
139
|
+
|
|
140
|
+
#404 / #1886: ``os.execv`` self-restart does not take under systemd —
|
|
141
|
+
same PID survives with stale code loaded, and nothing detected it
|
|
142
|
+
(that silent survival is the concrete failure #1886 reports). Asking
|
|
143
|
+
systemd itself to restart the unit is the mechanism that's known to
|
|
144
|
+
work — it's the documented manual workaround, and what
|
|
145
|
+
``coord.commands.agent_ops._escalate_restart`` already does over SSH
|
|
146
|
+
as a fallback from the CLI side. Doing it from inside the process
|
|
147
|
+
removes the dependency on a human noticing the stall and running it
|
|
148
|
+
by hand.
|
|
149
|
+
|
|
150
|
+
Returns True once the ``systemctl`` command has been launched — NOT
|
|
151
|
+
whether the restart actually completed; the caller's process is about
|
|
152
|
+
to exit either way, so there is nothing left here to poll for.
|
|
153
|
+
"""
|
|
154
|
+
env = dict(os.environ)
|
|
155
|
+
# Should already be set for a process systemd itself started, but
|
|
156
|
+
# setting it explicitly costs nothing and matches the SSH-driven
|
|
157
|
+
# fallback in agent_ops.py, where it IS load-bearing.
|
|
158
|
+
env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
|
|
159
|
+
try:
|
|
160
|
+
subprocess.Popen(
|
|
161
|
+
["systemctl", "--user", "restart", unit],
|
|
162
|
+
env=env,
|
|
163
|
+
stdout=subprocess.DEVNULL,
|
|
164
|
+
stderr=subprocess.DEVNULL,
|
|
165
|
+
)
|
|
166
|
+
except Exception:
|
|
167
|
+
return False
|
|
168
|
+
return True
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# Unit -> the HTTP path this process should GET, on localhost, once
|
|
172
|
+
# `systemctl` reports the unit active, before believing the restart actually
|
|
173
|
+
# worked (#2095). `is-active` proves the process exists; it does not prove a
|
|
174
|
+
# freshly-started server has finished binding its socket, nor that it is not
|
|
175
|
+
# about to crash-loop moments later. coord-web's entire job is answering
|
|
176
|
+
# HTTP GETs — `/api/pipeline` is the exact endpoint the 2026-08-10 incident
|
|
177
|
+
# report used by hand to confirm the dashboard was down — so a GET is the
|
|
178
|
+
# honest check for it. Every other sibling unit gets no probe here, exactly
|
|
179
|
+
# the pre-#2095 behaviour: `is-active` alone is still what decides them.
|
|
180
|
+
_LIVENESS_PROBE_PATHS: dict[str, str] = {"coord-web": "/api/pipeline"}
|
|
181
|
+
|
|
182
|
+
# Unit -> (explicit-override env var, last-resort port) for the probe above.
|
|
183
|
+
# The override exists for setups systemd cannot answer for (a hand-started
|
|
184
|
+
# `coord web`, a test harness pointing the probe at an ephemeral server); the
|
|
185
|
+
# last-resort value is only reached when BOTH the override and systemd itself
|
|
186
|
+
# have nothing to say, and it is the only hardcoded port left in this path.
|
|
187
|
+
_LIVENESS_PORT_SOURCES: dict[str, tuple[str, str]] = {"coord-web": ("COORD_WEB_PORT", "7434")}
|
|
188
|
+
|
|
189
|
+
# `--port 7434` / `--port=7434` on a unit's ExecStart command line.
|
|
190
|
+
_EXEC_START_PORT_RE = re.compile(r"--port[= ](\d+)")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _unit_listen_port(unit: str) -> str | None:
|
|
194
|
+
"""The ``--port`` value on *unit*'s **installed** ``ExecStart``, or None.
|
|
195
|
+
|
|
196
|
+
#2095 review: the liveness probe below has to GET the port coord-web is
|
|
197
|
+
actually listening on, and the only authority on that is the unit file
|
|
198
|
+
systemd is running right now — ``deploy/coord-web.service``'s
|
|
199
|
+
``ExecStart=... --port 7434``. Asking systemd for it (rather than
|
|
200
|
+
declaring the number a second time somewhere this process can read)
|
|
201
|
+
keeps the probe and the listener reading ONE source: change ``--port``
|
|
202
|
+
on the unit, restart it, and the probe follows on its own.
|
|
203
|
+
|
|
204
|
+
The first cut of this instead read a ``COORD_WEB_PORT`` env var declared
|
|
205
|
+
on ``coord-web.service`` — which this process, running under
|
|
206
|
+
``coord-agent.service``, can never see (systemd does not share
|
|
207
|
+
``Environment=`` across units), so the probe silently fell back to a
|
|
208
|
+
hardcoded ``7434`` no matter what the unit said. Declaring the same
|
|
209
|
+
number on ``coord-agent.service`` instead would make it *readable*, but
|
|
210
|
+
would still be a second surface that has to agree with the first by
|
|
211
|
+
hand — the exact shape epic #2096 rules out. Reading the live unit is
|
|
212
|
+
the version with only one surface.
|
|
213
|
+
|
|
214
|
+
Returns None (caller falls back) whenever systemd cannot answer: no
|
|
215
|
+
systemctl on PATH, no user bus, unit not installed, or an ExecStart with
|
|
216
|
+
no ``--port`` on it.
|
|
217
|
+
"""
|
|
218
|
+
env = dict(os.environ)
|
|
219
|
+
env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
|
|
220
|
+
try:
|
|
221
|
+
result = subprocess.run(
|
|
222
|
+
["systemctl", "--user", "show", unit, "--property=ExecStart"],
|
|
223
|
+
env=env, capture_output=True, text=True, timeout=5,
|
|
224
|
+
)
|
|
225
|
+
except Exception: # noqa: BLE001 - no systemctl, no bus, timeout: all "unknown"
|
|
226
|
+
return None
|
|
227
|
+
if result.returncode != 0:
|
|
228
|
+
return None
|
|
229
|
+
match = _EXEC_START_PORT_RE.search(result.stdout or "")
|
|
230
|
+
return match.group(1) if match else None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _probe_port(unit: str) -> str:
|
|
234
|
+
"""Which local port :func:`_probe_liveness` should GET for *unit*.
|
|
235
|
+
|
|
236
|
+
Precedence: an explicit env override, then the live unit's own
|
|
237
|
+
``ExecStart`` (:func:`_unit_listen_port`), then the last-resort default.
|
|
238
|
+
The override comes first so a deliberately-pointed probe (tests, a
|
|
239
|
+
hand-started server on another port) always wins over what systemd
|
|
240
|
+
happens to have installed; nothing in `deploy/` sets it, so on a real
|
|
241
|
+
host the unit's own ``--port`` is what decides.
|
|
242
|
+
"""
|
|
243
|
+
env_var, default = _LIVENESS_PORT_SOURCES.get(unit, ("", "7434"))
|
|
244
|
+
override = os.environ.get(env_var) if env_var else None
|
|
245
|
+
if override:
|
|
246
|
+
return override
|
|
247
|
+
return _unit_listen_port(unit) or default
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _probe_liveness(unit: str, *, timeout: float = 5.0) -> tuple[bool, str] | None:
|
|
251
|
+
"""GET ``_LIVENESS_PROBE_PATHS[unit]`` on localhost, or ``None`` if
|
|
252
|
+
*unit* has no configured probe — the caller then trusts ``is-active``
|
|
253
|
+
alone, same as every unit did before #2095.
|
|
254
|
+
|
|
255
|
+
Always localhost, never the unit's configured bind host: this only ever
|
|
256
|
+
runs from inside :func:`_restart_sibling_unit`, which only ever runs for
|
|
257
|
+
a unit this same host's ``/restart-services`` found actually running
|
|
258
|
+
HERE (see ``restart_services``'s docstring) — there is no other host to
|
|
259
|
+
reach, and a phone/tailnet address would just add a second way to fail
|
|
260
|
+
that has nothing to do with whether the process itself is answering.
|
|
261
|
+
"""
|
|
262
|
+
path = _LIVENESS_PROBE_PATHS.get(unit)
|
|
263
|
+
if path is None:
|
|
264
|
+
return None
|
|
265
|
+
port = _probe_port(unit)
|
|
266
|
+
url = f"http://127.0.0.1:{port}{path}"
|
|
267
|
+
try:
|
|
268
|
+
with urllib.request.urlopen(url, timeout=timeout) as resp: # noqa: S310
|
|
269
|
+
status = resp.getcode()
|
|
270
|
+
except Exception as exc: # noqa: BLE001
|
|
271
|
+
# #2095 nit: urlopen() raises HTTPError (a subclass of Exception,
|
|
272
|
+
# caught above) for any response status >= 400 -- it never returns
|
|
273
|
+
# normally with such a status -- so a >=500 check after this except
|
|
274
|
+
# clause would be unreachable dead code. Any failure to answer,
|
|
275
|
+
# connection-level or an HTTP error status, is reported the same
|
|
276
|
+
# way: coord-web is active but not usably serving.
|
|
277
|
+
return False, f"active, but not answering GET {path}: {type(exc).__name__}: {exc}"
|
|
278
|
+
return True, f"active and answering GET {path} (HTTP {status})"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _restart_sibling_unit(unit: str, *, timeout: float = 30.0) -> tuple[bool, str]:
|
|
282
|
+
"""``systemctl --user restart <unit>`` for a UNIT OTHER THAN THIS ONE,
|
|
283
|
+
and wait for it to report active — and, for units with a liveness probe
|
|
284
|
+
configured above, actually answering (#2069, #2095).
|
|
285
|
+
|
|
286
|
+
Unlike :func:`_restart_via_systemctl` — used for the agent's OWN restart,
|
|
287
|
+
where the caller is about to exit and there is nothing left here to poll
|
|
288
|
+
for — this process stays alive throughout a sibling's restart, so it can
|
|
289
|
+
and should wait rather than fire-and-forget. "The systemctl command was
|
|
290
|
+
launched" is a statement about the request, not the outcome; #2052 fault
|
|
291
|
+
1 is exactly what trusting that distinction cost on a self-restart, and
|
|
292
|
+
there's no reason to reintroduce it here just because it's a neighbour's
|
|
293
|
+
process instead of this one's.
|
|
294
|
+
|
|
295
|
+
#2095: the restart itself used to be issued with a hard 15s
|
|
296
|
+
``subprocess.run`` timeout on a BLOCKING ``systemctl restart`` — which
|
|
297
|
+
waits for the unit to fully stop before returning. A unit serving
|
|
298
|
+
``text/event-stream`` (coord-web, #700) does not stop on its own while a
|
|
299
|
+
browser or the phone PWA holds an SSE connection open, so that 15s cap
|
|
300
|
+
fired routinely, raised ``TimeoutExpired``, and left the unit abandoned
|
|
301
|
+
mid-stop: not restarted, STOPPED — worse than never having tried, and
|
|
302
|
+
reported as a failure that nonetheless printed a leading ``✓`` one layer
|
|
303
|
+
up (see ``coord/commands/release.py``'s ``_roll_python``). ``--no-block``
|
|
304
|
+
returns the instant the restart job is QUEUED, regardless of how long the
|
|
305
|
+
unit actually takes to stop and start — the ``is-active`` poll below,
|
|
306
|
+
which already existed and already had nothing to do with the abandoned
|
|
307
|
+
call, is what actually decides the outcome now, exactly as its own
|
|
308
|
+
docstring paragraph above argues it should.
|
|
309
|
+
"""
|
|
310
|
+
env = dict(os.environ)
|
|
311
|
+
env.setdefault("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
|
|
312
|
+
try:
|
|
313
|
+
result = subprocess.run(
|
|
314
|
+
["systemctl", "--user", "restart", "--no-block", unit],
|
|
315
|
+
env=env, capture_output=True, text=True, timeout=15,
|
|
316
|
+
)
|
|
317
|
+
except Exception as exc: # noqa: BLE001
|
|
318
|
+
return False, f"{type(exc).__name__}: {exc}"
|
|
319
|
+
if result.returncode != 0:
|
|
320
|
+
return False, (result.stderr or result.stdout or "systemctl restart failed").strip()[:300]
|
|
321
|
+
|
|
322
|
+
deadline = time.time() + max(timeout, 0.0)
|
|
323
|
+
# #2095 review: a unit whose stop was forced via `TimeoutStopSec` (added
|
|
324
|
+
# by this same PR, alongside `KillMode=process`, specifically to escalate
|
|
325
|
+
# to SIGKILL against a stuck SSE-holding stop) is commonly observed to
|
|
326
|
+
# transiently report `ActiveState=failed` (Result: timeout) for one
|
|
327
|
+
# `is-active` poll before the start half of the same `restart --no-block`
|
|
328
|
+
# job takes over and settles at `active` moments later. Trusting the
|
|
329
|
+
# FIRST sight of `failed` outright would misread that blip as a genuine
|
|
330
|
+
# failure on precisely the unit and mechanism this issue is about — so a
|
|
331
|
+
# single "failed" only counts once it is seen on two consecutive polls.
|
|
332
|
+
# A unit that is actually dead reports `failed` again immediately, so
|
|
333
|
+
# this still exits well before burning the rest of the deadline; it just
|
|
334
|
+
# no longer trusts a single sample.
|
|
335
|
+
saw_failed = False
|
|
336
|
+
while True:
|
|
337
|
+
try:
|
|
338
|
+
probe = subprocess.run(
|
|
339
|
+
["systemctl", "--user", "is-active", unit],
|
|
340
|
+
env=env, capture_output=True, text=True, timeout=5,
|
|
341
|
+
)
|
|
342
|
+
except Exception as exc: # noqa: BLE001
|
|
343
|
+
return False, f"{type(exc).__name__}: {exc}"
|
|
344
|
+
state = probe.stdout.strip()
|
|
345
|
+
if state != "failed":
|
|
346
|
+
# Any non-`failed` sighting — `active` included — ends the run of
|
|
347
|
+
# consecutive failures the check below counts. Without this, a
|
|
348
|
+
# `failed` -> `active` (up, not yet answering) -> `failed`
|
|
349
|
+
# sequence would trip "two consecutive failed polls" on a single
|
|
350
|
+
# fresh sighting, after the blip it was written to tolerate had
|
|
351
|
+
# already resolved (#2095 review).
|
|
352
|
+
saw_failed = False
|
|
353
|
+
if state == "active":
|
|
354
|
+
live = _probe_liveness(unit)
|
|
355
|
+
if live is None:
|
|
356
|
+
return True, "active"
|
|
357
|
+
live_ok, live_detail = live
|
|
358
|
+
if live_ok:
|
|
359
|
+
return True, live_detail
|
|
360
|
+
if time.time() >= deadline:
|
|
361
|
+
return False, live_detail
|
|
362
|
+
time.sleep(0.5)
|
|
363
|
+
continue
|
|
364
|
+
if state == "failed":
|
|
365
|
+
if saw_failed:
|
|
366
|
+
return False, "unit failed to (re)start"
|
|
367
|
+
saw_failed = True
|
|
368
|
+
if time.time() >= deadline:
|
|
369
|
+
return False, "unit failed to (re)start"
|
|
370
|
+
time.sleep(0.5)
|
|
371
|
+
continue
|
|
372
|
+
# (the reset for this branch happens at the top of the loop, above)
|
|
373
|
+
if time.time() >= deadline:
|
|
374
|
+
return False, f"still {state or 'unknown'} {timeout:.0f}s after restart"
|
|
375
|
+
time.sleep(0.5)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _default_exec_restart(argv: list[str]) -> None:
|
|
379
|
+
"""Restart the agent process — via systemd when running under it,
|
|
380
|
+
otherwise by re-exec'ing in place.
|
|
381
|
+
|
|
382
|
+
#404 / #1886: a bare ``os.execv`` doesn't take under systemd (same
|
|
383
|
+
PID, stale code), and nothing used to detect it. Under systemd, ask
|
|
384
|
+
systemd to restart the unit instead — the mechanism actually known to
|
|
385
|
+
work — and let this process exit; that also re-runs `ExecStart`
|
|
386
|
+
through `~/.coord-venv` fresh, which is what makes a #1241 blue/green
|
|
387
|
+
swap actually take effect (see below).
|
|
388
|
+
|
|
389
|
+
#1241: falls back to ``os.execv`` using the *current* `~/.coord-venv`
|
|
390
|
+
symlink's python, re-resolved right now — NOT ``sys.executable``.
|
|
391
|
+
``sys.executable`` is the literal interpreter path baked into this
|
|
392
|
+
process's own venv *slot* at the time it started (e.g.
|
|
393
|
+
``~/.coord-venv.blue/bin/python3``, from that slot's own shebang line)
|
|
394
|
+
and stays pinned to that slot for the process's whole life, even after
|
|
395
|
+
a blue/green swap flips the symlink onto the other slot. Re-exec'ing
|
|
396
|
+
with it would silently keep running the OLD slot forever — the process
|
|
397
|
+
"restarts" but never advances. Resolving through the symlink instead
|
|
398
|
+
picks up whichever slot is live *right now*. Falls back to
|
|
399
|
+
``sys.executable`` when there's no such venv at all (dev/editable
|
|
400
|
+
installs not using the blue/green layout), preserving the pre-#1241
|
|
401
|
+
behaviour there.
|
|
402
|
+
"""
|
|
403
|
+
if _running_under_systemd() and _restart_via_systemctl():
|
|
404
|
+
os._exit(0)
|
|
405
|
+
venv_python = _venv_dir() / "bin" / "python"
|
|
406
|
+
executable = str(venv_python) if venv_python.exists() else sys.executable
|
|
407
|
+
os.execv(executable, [executable] + argv)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _detect_install_mode() -> tuple[bool, str | None]:
|
|
411
|
+
"""Return ``(is_editable, project_path)``.
|
|
412
|
+
|
|
413
|
+
*is_editable* is True when the package is installed in editable mode (i.e.
|
|
414
|
+
``pip install -e .``). *project_path* is the on-disk source directory for
|
|
415
|
+
editable installs, or *None* for regular (site-packages) installs.
|
|
416
|
+
|
|
417
|
+
#2103: ``pip show`` needs an exact distribution name, so this resolves
|
|
418
|
+
which of `code-coordinator` / `claude-coordinator` is actually installed
|
|
419
|
+
(see ``coord.dist_name``) first rather than hardcoding one — asking
|
|
420
|
+
`pip show` for a name nothing is installed under always reports "not
|
|
421
|
+
editable", which would misreport a real editable install once the
|
|
422
|
+
fleet's mid-rename.
|
|
423
|
+
"""
|
|
424
|
+
dist_name = resolve_installed_name()
|
|
425
|
+
if dist_name is None:
|
|
426
|
+
return False, None
|
|
427
|
+
try:
|
|
428
|
+
result = subprocess.run(
|
|
429
|
+
[sys.executable, "-m", "pip", "show", dist_name],
|
|
430
|
+
capture_output=True,
|
|
431
|
+
text=True,
|
|
432
|
+
timeout=15,
|
|
433
|
+
)
|
|
434
|
+
for line in result.stdout.splitlines():
|
|
435
|
+
if line.startswith("Editable project location:"):
|
|
436
|
+
path = line.split(":", 1)[1].strip()
|
|
437
|
+
return True, path
|
|
438
|
+
return False, None
|
|
439
|
+
except Exception:
|
|
440
|
+
return False, None
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _path_param(name: str, description: str = "") -> dict:
|
|
444
|
+
return {
|
|
445
|
+
"name": name,
|
|
446
|
+
"in": "path",
|
|
447
|
+
"required": True,
|
|
448
|
+
"schema": {"type": "string"},
|
|
449
|
+
"description": description,
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _openapi_spec() -> dict:
|
|
454
|
+
"""#757: the agent's OpenAPI 3 document.
|
|
455
|
+
|
|
456
|
+
``POST /assign`` is fully specified (request = ``AssignmentSpec``,
|
|
457
|
+
response = ``AgentAssignment``, both introspected via
|
|
458
|
+
:func:`coord.openapi.dataclass_schema`); the remaining routes carry a
|
|
459
|
+
summary/description and path-param shapes but a loosely-typed body, since
|
|
460
|
+
they return small ad-hoc dicts rather than a dataclass.
|
|
461
|
+
"""
|
|
462
|
+
components: dict = {}
|
|
463
|
+
assign_request = dataclass_schema(AssignmentSpec, components)
|
|
464
|
+
assign_response = dataclass_schema(AgentAssignment, components)
|
|
465
|
+
paths = {
|
|
466
|
+
"/health": {
|
|
467
|
+
"get": {
|
|
468
|
+
"summary": "Agent health + version",
|
|
469
|
+
"responses": {"200": {"description": "OK"}},
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
"/status": {
|
|
473
|
+
"get": {
|
|
474
|
+
"summary": "List this agent's assignments (active + completed)",
|
|
475
|
+
"responses": {"200": {"description": "OK"}},
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
"/repos": {
|
|
479
|
+
"get": {
|
|
480
|
+
"summary": "Repos this agent can dispatch work into",
|
|
481
|
+
"responses": {"200": {"description": "OK"}},
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
"/assign": {
|
|
485
|
+
"post": {
|
|
486
|
+
"summary": "Dispatch a new assignment (spawns `claude -p`)",
|
|
487
|
+
"requestBody": {
|
|
488
|
+
"required": True,
|
|
489
|
+
"content": {"application/json": {"schema": assign_request}},
|
|
490
|
+
},
|
|
491
|
+
"responses": {
|
|
492
|
+
"202": {
|
|
493
|
+
"description": "Accepted",
|
|
494
|
+
"content": {"application/json": {"schema": assign_response}},
|
|
495
|
+
},
|
|
496
|
+
"400": {"description": "Bad assignment payload"},
|
|
497
|
+
},
|
|
498
|
+
}
|
|
499
|
+
},
|
|
500
|
+
"/cancel/{id}": {
|
|
501
|
+
"post": {
|
|
502
|
+
"summary": "Cancel a running/pending assignment",
|
|
503
|
+
"description": (
|
|
504
|
+
"#1567: by default, any uncommitted worker changes are "
|
|
505
|
+
"committed locally but NOT pushed anywhere — the "
|
|
506
|
+
"worker's remote branch is left unchanged. Pass "
|
|
507
|
+
"?rescue=1 to push the WIP commit to a disposable "
|
|
508
|
+
"rescue/<id> ref instead (the worker's own branch is "
|
|
509
|
+
"still never touched)."
|
|
510
|
+
),
|
|
511
|
+
"parameters": [
|
|
512
|
+
_path_param("id", "assignment id"),
|
|
513
|
+
{
|
|
514
|
+
"name": "rescue",
|
|
515
|
+
"in": "query",
|
|
516
|
+
"required": False,
|
|
517
|
+
"schema": {"type": "boolean", "default": False},
|
|
518
|
+
"description": (
|
|
519
|
+
"Push the WIP commit to rescue/<id> instead of "
|
|
520
|
+
"leaving it local-only."
|
|
521
|
+
),
|
|
522
|
+
},
|
|
523
|
+
],
|
|
524
|
+
"responses": {
|
|
525
|
+
"200": {
|
|
526
|
+
"description": "OK",
|
|
527
|
+
"content": {"application/json": {"schema": assign_response}},
|
|
528
|
+
},
|
|
529
|
+
"404": {"description": "Unknown assignment"},
|
|
530
|
+
},
|
|
531
|
+
}
|
|
532
|
+
},
|
|
533
|
+
"/inject/{id}": {
|
|
534
|
+
"post": {
|
|
535
|
+
"summary": "Inject a new user message into a running worker's session",
|
|
536
|
+
"parameters": [_path_param("id", "assignment id")],
|
|
537
|
+
"requestBody": {
|
|
538
|
+
"required": True,
|
|
539
|
+
"content": {
|
|
540
|
+
"application/json": {
|
|
541
|
+
"schema": {
|
|
542
|
+
"type": "object",
|
|
543
|
+
"properties": {"text": {"type": "string"}},
|
|
544
|
+
"required": ["text"],
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
"responses": {
|
|
550
|
+
"202": {"description": "Delivered"},
|
|
551
|
+
"404": {"description": "Unknown assignment"},
|
|
552
|
+
"409": {"description": "Worker not running"},
|
|
553
|
+
"410": {"description": "Worker stdin already closed"},
|
|
554
|
+
},
|
|
555
|
+
}
|
|
556
|
+
},
|
|
557
|
+
"/logs/{id}": {
|
|
558
|
+
"get": {
|
|
559
|
+
"summary": "Read (a tail of) the worker's log file",
|
|
560
|
+
"parameters": [
|
|
561
|
+
_path_param("id", "assignment id"),
|
|
562
|
+
{
|
|
563
|
+
"name": "since",
|
|
564
|
+
"in": "query",
|
|
565
|
+
"required": False,
|
|
566
|
+
"schema": {"type": "integer"},
|
|
567
|
+
"description": "byte offset to read from",
|
|
568
|
+
},
|
|
569
|
+
],
|
|
570
|
+
"responses": {
|
|
571
|
+
"200": {"description": "OK"},
|
|
572
|
+
"404": {"description": "Unknown assignment or no log file"},
|
|
573
|
+
},
|
|
574
|
+
}
|
|
575
|
+
},
|
|
576
|
+
"/stream/{id}": {
|
|
577
|
+
"get": {
|
|
578
|
+
"summary": "Server-sent-event stream of the worker's log",
|
|
579
|
+
"parameters": [_path_param("id", "assignment id")],
|
|
580
|
+
"responses": {"200": {"description": "text/event-stream"}},
|
|
581
|
+
}
|
|
582
|
+
},
|
|
583
|
+
"/update": {
|
|
584
|
+
"post": {
|
|
585
|
+
"summary": "Upgrade the installed package and restart the agent process",
|
|
586
|
+
"responses": {"202": {"description": "Updating"}},
|
|
587
|
+
}
|
|
588
|
+
},
|
|
589
|
+
"/deploy-units": {
|
|
590
|
+
"post": {
|
|
591
|
+
"summary": (
|
|
592
|
+
"Install this host's systemd user units from the units "
|
|
593
|
+
"packaged in the running release (#1831/#1835). Restarts "
|
|
594
|
+
"nothing — daemon-reload only."
|
|
595
|
+
),
|
|
596
|
+
"responses": {
|
|
597
|
+
"200": {"description": "Units deployed (or nothing to do)"},
|
|
598
|
+
"500": {"description": "A unit could not be written, or daemon-reload failed"},
|
|
599
|
+
},
|
|
600
|
+
}
|
|
601
|
+
},
|
|
602
|
+
"/restart-services": {
|
|
603
|
+
"post": {
|
|
604
|
+
"summary": (
|
|
605
|
+
"Restart whichever of coord-serve/coord-web/coord-drive-queue "
|
|
606
|
+
"are actually running on this host (#2069) — the rest of a "
|
|
607
|
+
"python-lane roll that /update itself only does for "
|
|
608
|
+
"coord-agent. Call AFTER /update on the same host."
|
|
609
|
+
),
|
|
610
|
+
"requestBody": {
|
|
611
|
+
"required": False,
|
|
612
|
+
"content": {
|
|
613
|
+
"application/json": {
|
|
614
|
+
"schema": {
|
|
615
|
+
"type": "object",
|
|
616
|
+
"properties": {
|
|
617
|
+
"units": {
|
|
618
|
+
"type": "array",
|
|
619
|
+
"items": {"type": "string"},
|
|
620
|
+
}
|
|
621
|
+
},
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
"responses": {
|
|
627
|
+
"200": {"description": "Restart attempted for every running sibling unit"},
|
|
628
|
+
"400": {"description": "\"units\" named something not restartable here"},
|
|
629
|
+
},
|
|
630
|
+
}
|
|
631
|
+
},
|
|
632
|
+
"/rollback": {
|
|
633
|
+
"post": {
|
|
634
|
+
"summary": "Roll back to the previous blue/green venv generation and restart",
|
|
635
|
+
"responses": {
|
|
636
|
+
"202": {"description": "Rolling back"},
|
|
637
|
+
"404": {"description": "No previous generation to roll back to"},
|
|
638
|
+
"409": {"description": "Live sessions running; pass force=true"},
|
|
639
|
+
},
|
|
640
|
+
}
|
|
641
|
+
},
|
|
642
|
+
"/restart": {
|
|
643
|
+
"post": {
|
|
644
|
+
"summary": "Gracefully restart the agent process",
|
|
645
|
+
"requestBody": {
|
|
646
|
+
"required": False,
|
|
647
|
+
"content": {
|
|
648
|
+
"application/json": {
|
|
649
|
+
"schema": {
|
|
650
|
+
"type": "object",
|
|
651
|
+
"properties": {"cancel_timeout": {"type": "number"}},
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
},
|
|
655
|
+
},
|
|
656
|
+
"responses": {"202": {"description": "Restarting"}},
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
"/worktree-clean": {
|
|
660
|
+
"post": {
|
|
661
|
+
"summary": "Remove stale git worktrees managed by this agent",
|
|
662
|
+
"requestBody": {
|
|
663
|
+
"required": False,
|
|
664
|
+
"content": {
|
|
665
|
+
"application/json": {
|
|
666
|
+
"schema": {
|
|
667
|
+
"type": "object",
|
|
668
|
+
"properties": {
|
|
669
|
+
"recent_secs": {"type": "number"},
|
|
670
|
+
"protect": {
|
|
671
|
+
"type": "array",
|
|
672
|
+
"items": {"type": "string"},
|
|
673
|
+
"description": (
|
|
674
|
+
"#1295: assignment IDs the caller "
|
|
675
|
+
"considers non-terminal; the agent "
|
|
676
|
+
"keeps their worktrees regardless "
|
|
677
|
+
"of its own state. Optional — an "
|
|
678
|
+
"older agent without this field "
|
|
679
|
+
"behaves exactly as before."
|
|
680
|
+
),
|
|
681
|
+
},
|
|
682
|
+
},
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
},
|
|
686
|
+
},
|
|
687
|
+
"responses": {"200": {"description": "OK"}},
|
|
688
|
+
}
|
|
689
|
+
},
|
|
690
|
+
"/artifact/{repo}/{branch}": {
|
|
691
|
+
"get": {
|
|
692
|
+
"summary": "Manifest of stashed build artifacts for a (repo, branch) pair",
|
|
693
|
+
"parameters": [
|
|
694
|
+
_path_param("repo", "repo name"),
|
|
695
|
+
_path_param("branch", "sanitized branch name"),
|
|
696
|
+
],
|
|
697
|
+
"responses": {
|
|
698
|
+
"200": {"description": "OK"},
|
|
699
|
+
"404": {"description": "No artifacts for this repo/branch"},
|
|
700
|
+
},
|
|
701
|
+
}
|
|
702
|
+
},
|
|
703
|
+
"/metrics": {
|
|
704
|
+
"get": {
|
|
705
|
+
"summary": "CPU + memory snapshot for the agent machine",
|
|
706
|
+
"responses": {
|
|
707
|
+
"200": {"description": "OK"},
|
|
708
|
+
"503": {"description": "psutil not installed"},
|
|
709
|
+
},
|
|
710
|
+
}
|
|
711
|
+
},
|
|
712
|
+
}
|
|
713
|
+
return build_spec(
|
|
714
|
+
title="coord agent",
|
|
715
|
+
version=__version__,
|
|
716
|
+
description="Per-machine agent server: spawns and tracks `claude -p` workers.",
|
|
717
|
+
paths=paths,
|
|
718
|
+
components=components,
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def build_app(
|
|
723
|
+
server: AgentServer,
|
|
724
|
+
*,
|
|
725
|
+
exec_restart: Callable[[list[str]], None] | None = None,
|
|
726
|
+
) -> Starlette:
|
|
727
|
+
"""Build the Starlette app bound to a specific AgentServer instance.
|
|
728
|
+
|
|
729
|
+
Parameters
|
|
730
|
+
----------
|
|
731
|
+
server:
|
|
732
|
+
The ``AgentServer`` instance to bind routes to.
|
|
733
|
+
exec_restart:
|
|
734
|
+
Callable invoked to replace the current process when ``/update`` or
|
|
735
|
+
``/restart`` completes. Receives ``sys.argv`` as its argument.
|
|
736
|
+
Defaults to :func:`_default_exec_restart` (calls ``os.execv``).
|
|
737
|
+
Tests may inject a no-op or a mock to prevent the test process from
|
|
738
|
+
being replaced.
|
|
739
|
+
"""
|
|
740
|
+
if exec_restart is None:
|
|
741
|
+
exec_restart = _default_exec_restart
|
|
742
|
+
|
|
743
|
+
async def health(request: Request) -> JSONResponse:
|
|
744
|
+
# server.health() can shell out to probe tool versions (#1570 B,
|
|
745
|
+
# via AgentServer._cached_tool_versions -> probe_all) — real
|
|
746
|
+
# subprocess.run calls with a per-tool timeout. Running that inline
|
|
747
|
+
# would block this event loop (and every in-flight /assign) for up
|
|
748
|
+
# to the probe timeout on a slow/hung tool. Cache TTL means this
|
|
749
|
+
# only bites the first /health after a restart or every few
|
|
750
|
+
# minutes, but push it off-loop regardless.
|
|
751
|
+
data = await asyncio.to_thread(server.health)
|
|
752
|
+
# #1886 Path B: `version` is bound at process import time (see the
|
|
753
|
+
# module-level `from coord import __version__` above) and never
|
|
754
|
+
# changes for the life of this process — it is the *running*,
|
|
755
|
+
# loaded-module version. `installed_version` is a fresh disk read
|
|
756
|
+
# (see `_installed_version` above) and changes the instant `pip`
|
|
757
|
+
# writes to site-packages, regardless of whether this process has
|
|
758
|
+
# restarted to pick it up. Exposing both — instead of just one,
|
|
759
|
+
# ambiguous "version" — lets a caller (`coord agent update`'s poll
|
|
760
|
+
# loop, `coord status`) detect a process that never restarted
|
|
761
|
+
# after an update purely from /health, without inferring it from
|
|
762
|
+
# liveness or PID.
|
|
763
|
+
data["version"] = __version__
|
|
764
|
+
data["installed_version"] = _installed_version()
|
|
765
|
+
# Surface the most recent /update attempt so the CLI can show
|
|
766
|
+
# "0.3.0 → 0.4.0" or "no_change (0.3.0)" or "failed: <error>".
|
|
767
|
+
last = _read_last_update(server.state_dir)
|
|
768
|
+
if last is not None:
|
|
769
|
+
data["last_update"] = last
|
|
770
|
+
return JSONResponse(data)
|
|
771
|
+
|
|
772
|
+
async def status(request: Request) -> JSONResponse:
|
|
773
|
+
data = server.list_assignments()
|
|
774
|
+
data["version"] = __version__
|
|
775
|
+
return JSONResponse(data)
|
|
776
|
+
|
|
777
|
+
async def repos(request: Request) -> JSONResponse:
|
|
778
|
+
return JSONResponse(server.list_repos())
|
|
779
|
+
|
|
780
|
+
async def assign(request: Request) -> JSONResponse:
|
|
781
|
+
try:
|
|
782
|
+
body = await request.json()
|
|
783
|
+
except ValueError:
|
|
784
|
+
return JSONResponse({"error": "invalid JSON"}, status_code=400)
|
|
785
|
+
if not isinstance(body, dict):
|
|
786
|
+
return JSONResponse({"error": "body must be a JSON object"}, status_code=400)
|
|
787
|
+
try:
|
|
788
|
+
spec = AssignmentSpec(**body)
|
|
789
|
+
except TypeError as e:
|
|
790
|
+
return JSONResponse({"error": f"bad assignment payload: {e}"}, status_code=400)
|
|
791
|
+
try:
|
|
792
|
+
assignment = server.assign(spec)
|
|
793
|
+
except ValueError as e:
|
|
794
|
+
return JSONResponse({"error": str(e)}, status_code=400)
|
|
795
|
+
return JSONResponse(assignment.to_dict(), status_code=202)
|
|
796
|
+
|
|
797
|
+
async def cancel(request: Request) -> JSONResponse:
|
|
798
|
+
assignment_id = request.path_params["id"]
|
|
799
|
+
# #1567: ?rescue=1 opts into pushing the WIP commit to a disposable
|
|
800
|
+
# rescue/<id> ref. Default (no query param, or any falsy value) is
|
|
801
|
+
# to commit locally only and leave the remote branch untouched.
|
|
802
|
+
rescue_param = request.query_params.get("rescue", "")
|
|
803
|
+
rescue = rescue_param.strip().lower() in ("1", "true", "yes")
|
|
804
|
+
# ``push_mode``, when given, overrides the rescue-derived default —
|
|
805
|
+
# an internal-only escape hatch for callers that are not an operator
|
|
806
|
+
# `coord stop` (e.g. `coord resume-stuck`, which cancels a stuck
|
|
807
|
+
# worker but immediately dispatches a continuation onto the SAME
|
|
808
|
+
# branch and needs the WIP pushed there, not withheld or diverted to
|
|
809
|
+
# a rescue ref — see coord/commands/plan_followup.py::resume_stuck).
|
|
810
|
+
push_mode = request.query_params.get("push_mode") or None
|
|
811
|
+
try:
|
|
812
|
+
assignment = server.cancel(
|
|
813
|
+
assignment_id, rescue=rescue, push_mode=push_mode
|
|
814
|
+
)
|
|
815
|
+
except KeyError:
|
|
816
|
+
return JSONResponse({"error": f"unknown assignment {assignment_id}"}, status_code=404)
|
|
817
|
+
return JSONResponse(assignment.to_dict())
|
|
818
|
+
|
|
819
|
+
async def inject(request: Request) -> JSONResponse:
|
|
820
|
+
"""Inject a new user message into a running worker's session.
|
|
821
|
+
|
|
822
|
+
Body (JSON): ``{"text": "..."}``. Worker picks up the message at
|
|
823
|
+
its next turn boundary. Returns 404 if the assignment isn't on
|
|
824
|
+
this agent, 409 if it isn't running, 410 if the worker's stdin
|
|
825
|
+
is already closed.
|
|
826
|
+
"""
|
|
827
|
+
assignment_id = request.path_params["id"]
|
|
828
|
+
try:
|
|
829
|
+
body = await request.json()
|
|
830
|
+
except Exception:
|
|
831
|
+
return JSONResponse({"error": "invalid JSON body"}, status_code=400)
|
|
832
|
+
text = body.get("text") if isinstance(body, dict) else None
|
|
833
|
+
if not isinstance(text, str) or not text.strip():
|
|
834
|
+
return JSONResponse(
|
|
835
|
+
{"error": "body must be {\"text\": \"<non-empty string>\"}"},
|
|
836
|
+
status_code=400,
|
|
837
|
+
)
|
|
838
|
+
try:
|
|
839
|
+
server.inject_message(assignment_id, text)
|
|
840
|
+
except KeyError:
|
|
841
|
+
return JSONResponse(
|
|
842
|
+
{"error": f"unknown assignment {assignment_id}"}, status_code=404
|
|
843
|
+
)
|
|
844
|
+
except RuntimeError as e:
|
|
845
|
+
return JSONResponse({"error": str(e)}, status_code=409)
|
|
846
|
+
except BrokenPipeError as e:
|
|
847
|
+
return JSONResponse({"error": str(e)}, status_code=410)
|
|
848
|
+
return JSONResponse({"status": "delivered"}, status_code=202)
|
|
849
|
+
|
|
850
|
+
async def logs(request: Request) -> Response:
|
|
851
|
+
assignment_id = request.path_params["id"]
|
|
852
|
+
assignment = server.get(assignment_id)
|
|
853
|
+
if assignment is None or assignment.log_path is None:
|
|
854
|
+
return JSONResponse(
|
|
855
|
+
{"error": f"unknown assignment {assignment_id}"}, status_code=404
|
|
856
|
+
)
|
|
857
|
+
log_path = Path(assignment.log_path)
|
|
858
|
+
if not log_path.exists():
|
|
859
|
+
return JSONResponse(
|
|
860
|
+
{"error": f"no log file for assignment {assignment_id}"}, status_code=404
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
since_raw = request.query_params.get("since", "0")
|
|
864
|
+
try:
|
|
865
|
+
since = max(0, int(since_raw))
|
|
866
|
+
except ValueError:
|
|
867
|
+
return JSONResponse(
|
|
868
|
+
{"error": f"invalid since value: {since_raw!r}"}, status_code=400
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
with open(log_path, "rb") as f:
|
|
872
|
+
f.seek(since)
|
|
873
|
+
body = f.read()
|
|
874
|
+
total_size = log_path.stat().st_size
|
|
875
|
+
headers = {
|
|
876
|
+
"X-Coord-Log-Total": str(total_size),
|
|
877
|
+
"X-Coord-Log-Status": assignment.status,
|
|
878
|
+
}
|
|
879
|
+
return PlainTextResponse(body.decode("utf-8", errors="replace"), headers=headers)
|
|
880
|
+
|
|
881
|
+
async def stream(request: Request) -> Response:
|
|
882
|
+
assignment_id = request.path_params["id"]
|
|
883
|
+
assignment = server.get(assignment_id)
|
|
884
|
+
if assignment is None or assignment.log_path is None:
|
|
885
|
+
return JSONResponse(
|
|
886
|
+
{"error": f"unknown assignment {assignment_id}"}, status_code=404
|
|
887
|
+
)
|
|
888
|
+
log_path = Path(assignment.log_path)
|
|
889
|
+
|
|
890
|
+
last_event_id = request.headers.get("last-event-id")
|
|
891
|
+
if last_event_id is not None:
|
|
892
|
+
try:
|
|
893
|
+
start_offset = max(0, int(last_event_id))
|
|
894
|
+
except ValueError:
|
|
895
|
+
start_offset = 0
|
|
896
|
+
else:
|
|
897
|
+
try:
|
|
898
|
+
start_offset = max(0, int(request.query_params.get("since", "0")))
|
|
899
|
+
except ValueError:
|
|
900
|
+
start_offset = 0
|
|
901
|
+
|
|
902
|
+
def is_active() -> bool:
|
|
903
|
+
current = server.get(assignment_id)
|
|
904
|
+
return current is not None and current.status in (PENDING, RUNNING)
|
|
905
|
+
|
|
906
|
+
gen = stream_assignment_log(
|
|
907
|
+
log_path,
|
|
908
|
+
is_active=is_active,
|
|
909
|
+
request=request,
|
|
910
|
+
start_offset=start_offset,
|
|
911
|
+
)
|
|
912
|
+
return StreamingResponse(
|
|
913
|
+
gen,
|
|
914
|
+
media_type="text/event-stream",
|
|
915
|
+
headers={
|
|
916
|
+
"Cache-Control": "no-cache, no-transform",
|
|
917
|
+
"X-Accel-Buffering": "no",
|
|
918
|
+
"Connection": "keep-alive",
|
|
919
|
+
},
|
|
920
|
+
)
|
|
921
|
+
|
|
922
|
+
async def update(request: Request) -> JSONResponse:
|
|
923
|
+
"""Atomically install the target version and restart the agent (#1241).
|
|
924
|
+
|
|
925
|
+
Installs into a *fresh* venv slot next to the live one, smoke-checks
|
|
926
|
+
it, then atomically flips ``~/.coord-venv`` onto it — see
|
|
927
|
+
:mod:`coord.agent_update` for why an in-place ``pip install
|
|
928
|
+
--upgrade`` isn't safe (it can leave a concurrent ``coord``
|
|
929
|
+
invocation observing a half-written ``site-packages``). The process
|
|
930
|
+
is restarted with ``exec_restart`` after a successful swap. Both the
|
|
931
|
+
install and the restart run in a daemon-less background thread so
|
|
932
|
+
the HTTP response reaches the caller before the process is
|
|
933
|
+
replaced.
|
|
934
|
+
|
|
935
|
+
Refuses outright — HTTP 409, nothing touched, no restart — rather
|
|
936
|
+
than acting, in two cases:
|
|
937
|
+
|
|
938
|
+
- **Editable install** (``pip install -e .``): ``~/.coord-venv``
|
|
939
|
+
must stay a PyPI install (mirrors ``coord.health.checks.
|
|
940
|
+
agent_install``'s ``agent_venv`` check). An editable checkout is
|
|
941
|
+
reported as drift, never silently ``git pull``ed — the operator
|
|
942
|
+
switches it back by hand (see ``docs/AGENT_OPERATIONS.md``'s
|
|
943
|
+
editable → PyPI section).
|
|
944
|
+
- **Live sessions**: when this agent has active (RUNNING/PENDING)
|
|
945
|
+
assignments and the caller didn't pass ``{"force": true}`` — the
|
|
946
|
+
restart-after-swap kills any in-flight worker, the same "never
|
|
947
|
+
restart during live sessions" operator rule ``/restart`` already
|
|
948
|
+
documents, now enforced here too.
|
|
949
|
+
|
|
950
|
+
Request body (JSON, optional)::
|
|
951
|
+
|
|
952
|
+
{"target_version": "0.4.85", "force": false}
|
|
953
|
+
|
|
954
|
+
#1568: when the caller (``coord agent update``) knows exactly which
|
|
955
|
+
release it's asking for, it passes ``target_version``, pinning the
|
|
956
|
+
pip install to that exact version rather than a bare ``--upgrade``
|
|
957
|
+
so a stale PyPI index/cache produces a loud pip failure ("no
|
|
958
|
+
matching distribution") instead of a silent no-op. ``target_version``
|
|
959
|
+
is echoed back in ``last_update`` so ``/health`` lets the caller
|
|
960
|
+
verify the upgrade actually landed.
|
|
961
|
+
"""
|
|
962
|
+
is_editable, project_path = _detect_install_mode()
|
|
963
|
+
if is_editable:
|
|
964
|
+
payload = {
|
|
965
|
+
"mode": "editable (refused)",
|
|
966
|
+
"started_at": time.time(),
|
|
967
|
+
"finished_at": time.time(),
|
|
968
|
+
"result": "refused",
|
|
969
|
+
"error": (
|
|
970
|
+
f"editable install detected at {project_path!r} — "
|
|
971
|
+
"refusing to touch it automatically (#1241: "
|
|
972
|
+
"~/.coord-venv must stay a PyPI install). Switch it "
|
|
973
|
+
"back by hand — see docs/AGENT_OPERATIONS.md's "
|
|
974
|
+
"editable → PyPI section — then retry."
|
|
975
|
+
),
|
|
976
|
+
}
|
|
977
|
+
_write_last_update(server.state_dir, payload)
|
|
978
|
+
return JSONResponse(payload, status_code=409)
|
|
979
|
+
|
|
980
|
+
body: dict = {}
|
|
981
|
+
try:
|
|
982
|
+
body = await request.json()
|
|
983
|
+
except Exception:
|
|
984
|
+
pass
|
|
985
|
+
if not isinstance(body, dict):
|
|
986
|
+
body = {}
|
|
987
|
+
target_version = body.get("target_version") or None
|
|
988
|
+
force = bool(body.get("force"))
|
|
989
|
+
|
|
990
|
+
with server._lock:
|
|
991
|
+
active_count = sum(
|
|
992
|
+
1
|
|
993
|
+
for a in server._assignments.values()
|
|
994
|
+
if a.status in (PENDING, RUNNING)
|
|
995
|
+
)
|
|
996
|
+
if active_count and not force:
|
|
997
|
+
payload = {
|
|
998
|
+
"mode": "pip install (blue/green)",
|
|
999
|
+
"started_at": time.time(),
|
|
1000
|
+
"finished_at": time.time(),
|
|
1001
|
+
"target_version": target_version,
|
|
1002
|
+
"result": "refused",
|
|
1003
|
+
"error": (
|
|
1004
|
+
f"{active_count} active assignment(s) running — "
|
|
1005
|
+
"updating restarts the process and kills them "
|
|
1006
|
+
'mid-flight. Pass {"force": true} (CLI: `coord agent '
|
|
1007
|
+
"update --force`) to update anyway, or wait for them "
|
|
1008
|
+
"to finish."
|
|
1009
|
+
),
|
|
1010
|
+
}
|
|
1011
|
+
_write_last_update(server.state_dir, payload)
|
|
1012
|
+
return JSONResponse(payload, status_code=409)
|
|
1013
|
+
|
|
1014
|
+
mode = "pip install (blue/green)"
|
|
1015
|
+
|
|
1016
|
+
# Capture argv now — exec_restart replaces the process later.
|
|
1017
|
+
saved_argv = list(sys.argv)
|
|
1018
|
+
state_dir = server.state_dir
|
|
1019
|
+
|
|
1020
|
+
def _do_update() -> None:
|
|
1021
|
+
version_before = _installed_version() or "unknown"
|
|
1022
|
+
started_at = time.time()
|
|
1023
|
+
payload: dict = {
|
|
1024
|
+
"mode": mode,
|
|
1025
|
+
"started_at": started_at,
|
|
1026
|
+
"version_before": version_before,
|
|
1027
|
+
"version_after": version_before,
|
|
1028
|
+
"target_version": target_version,
|
|
1029
|
+
"result": "failed",
|
|
1030
|
+
"error": None,
|
|
1031
|
+
"log_excerpt": "",
|
|
1032
|
+
}
|
|
1033
|
+
try:
|
|
1034
|
+
venv_dir = _venv_dir()
|
|
1035
|
+
result = agent_update.perform_update(
|
|
1036
|
+
venv_dir, _agent_pkg_spec(), target_version=target_version,
|
|
1037
|
+
)
|
|
1038
|
+
payload["finished_at"] = time.time()
|
|
1039
|
+
# Persist the full venv/pip/smoke-check transcript to a log
|
|
1040
|
+
# file so the user can read it after the agent restarts.
|
|
1041
|
+
log_path = state_dir / "last_update.log"
|
|
1042
|
+
try:
|
|
1043
|
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1044
|
+
log_path.write_text(
|
|
1045
|
+
f"# mode: {mode}\n"
|
|
1046
|
+
f"# venv_dir: {venv_dir}\n"
|
|
1047
|
+
f"# ok: {result.ok} swapped: {result.swapped}\n"
|
|
1048
|
+
f"# slot: {result.slot} previous_slot: {result.previous_slot}\n\n"
|
|
1049
|
+
f"{result.log}\n"
|
|
1050
|
+
)
|
|
1051
|
+
except Exception: # noqa: BLE001
|
|
1052
|
+
pass
|
|
1053
|
+
# Keep a short excerpt inline so it appears in /health.
|
|
1054
|
+
tail = (result.log or "").splitlines()
|
|
1055
|
+
payload["log_excerpt"] = "\n".join(tail[-20:])
|
|
1056
|
+
|
|
1057
|
+
if not result.ok:
|
|
1058
|
+
payload["error"] = result.error or (
|
|
1059
|
+
"blue/green update failed; see "
|
|
1060
|
+
"~/.coord/last_update.log on this machine"
|
|
1061
|
+
)
|
|
1062
|
+
_write_last_update(state_dir, payload)
|
|
1063
|
+
return
|
|
1064
|
+
|
|
1065
|
+
# #1241: prefer the version the smoke check already read
|
|
1066
|
+
# straight from the new slot (deterministic, no reliance on
|
|
1067
|
+
# this process's own site-packages resolution having
|
|
1068
|
+
# noticed the symlink flip yet) — fall back to a fresh
|
|
1069
|
+
# in-process read only if that's somehow missing.
|
|
1070
|
+
version_after = result.new_version or _installed_version() or "unknown"
|
|
1071
|
+
payload["version_after"] = version_after
|
|
1072
|
+
if version_after == version_before:
|
|
1073
|
+
# Swap "succeeded" but landed on the same version —
|
|
1074
|
+
# shouldn't happen (the smoke check already verified
|
|
1075
|
+
# target_version when one was given) but don't restart
|
|
1076
|
+
# into a no-op.
|
|
1077
|
+
payload["result"] = "no_change"
|
|
1078
|
+
payload["error"] = (
|
|
1079
|
+
f"swap completed but resolved to {version_after} "
|
|
1080
|
+
"(same as before) — unexpected for a successful "
|
|
1081
|
+
"blue/green update"
|
|
1082
|
+
)
|
|
1083
|
+
_write_last_update(state_dir, payload)
|
|
1084
|
+
return
|
|
1085
|
+
|
|
1086
|
+
payload["result"] = "upgraded"
|
|
1087
|
+
_write_last_update(state_dir, payload)
|
|
1088
|
+
|
|
1089
|
+
# Brief pause so the HTTP response reaches the client first.
|
|
1090
|
+
time.sleep(0.5)
|
|
1091
|
+
exec_restart(saved_argv)
|
|
1092
|
+
except Exception as e:
|
|
1093
|
+
payload["error"] = f"{type(e).__name__}: {e}"
|
|
1094
|
+
_write_last_update(state_dir, payload)
|
|
1095
|
+
|
|
1096
|
+
threading.Thread(target=_do_update, daemon=False, name="agent-update").start()
|
|
1097
|
+
return JSONResponse({"status": "updating", "mode": mode}, status_code=202)
|
|
1098
|
+
|
|
1099
|
+
async def deploy_units(request: Request) -> JSONResponse:
|
|
1100
|
+
"""Install this host's systemd user units from the wheel (#1831/#1835).
|
|
1101
|
+
|
|
1102
|
+
The `deploy/**` lane's missing *deploy step*. ``unit_drift`` already
|
|
1103
|
+
detects that ``~/.config/systemd/user/coord-*.service`` has fallen
|
|
1104
|
+
behind the units packaged in the installed release; until now the
|
|
1105
|
+
remedy was a human with ``cp`` and ``systemctl``, which is exactly
|
|
1106
|
+
the gap #1835 cannot ship around — #1543's whole mechanism was three
|
|
1107
|
+
unit files and a shell script.
|
|
1108
|
+
|
|
1109
|
+
Ordering matters and is the caller's job, not this endpoint's: the
|
|
1110
|
+
reference is ``coord/deploy/`` *inside the installed distribution*,
|
|
1111
|
+
so this must run **after** that host's ``/update`` swapped the venv,
|
|
1112
|
+
or it re-installs the version the host already had. ``coord release
|
|
1113
|
+
propagate`` (:func:`coord.release_propagate.plan_lanes`) encodes that
|
|
1114
|
+
order.
|
|
1115
|
+
|
|
1116
|
+
Synchronous, unlike ``/update``: writing a handful of unit files and
|
|
1117
|
+
running ``daemon-reload`` takes milliseconds and — critically —
|
|
1118
|
+
**restarts nothing**. A ``daemon-reload`` re-reads unit files; it
|
|
1119
|
+
does not restart running services, so no in-flight worker dies here.
|
|
1120
|
+
Restarting the affected services stays an explicit, separate
|
|
1121
|
+
operator action.
|
|
1122
|
+
|
|
1123
|
+
Body (JSON, optional)::
|
|
1124
|
+
|
|
1125
|
+
{"dry_run": false}
|
|
1126
|
+
|
|
1127
|
+
A units-lane deploy touches only units this host *already* has
|
|
1128
|
+
installed, and keeps a ``.bak`` of each — see
|
|
1129
|
+
:mod:`coord.deploy_units` for why both are deliberate.
|
|
1130
|
+
|
|
1131
|
+
Also asserts every installed *timer* is enabled and running
|
|
1132
|
+
(:func:`coord.deploy_units.enable_timers`, #2082): refreshing a
|
|
1133
|
+
timer's content has never implied enabling it, and that gap is
|
|
1134
|
+
exactly how ``coord-release-propagate.timer`` reached this host with
|
|
1135
|
+
matching content and sat disabled for a day with nothing noticing.
|
|
1136
|
+
``enable --now`` is idempotent, so this runs on every call, not just
|
|
1137
|
+
when content changed — an already-enabled timer is untouched.
|
|
1138
|
+
"""
|
|
1139
|
+
from coord import deploy_units as du # noqa: PLC0415
|
|
1140
|
+
from coord.brain import AGENT_PORT # noqa: PLC0415
|
|
1141
|
+
|
|
1142
|
+
body: dict = {}
|
|
1143
|
+
try:
|
|
1144
|
+
body = await request.json()
|
|
1145
|
+
except Exception:
|
|
1146
|
+
pass
|
|
1147
|
+
if not isinstance(body, dict):
|
|
1148
|
+
body = {}
|
|
1149
|
+
dry_run = bool(body.get("dry_run"))
|
|
1150
|
+
|
|
1151
|
+
report = du.install_units(
|
|
1152
|
+
machine_name=getattr(server, "machine_name", None),
|
|
1153
|
+
port=AGENT_PORT,
|
|
1154
|
+
version=_installed_version(),
|
|
1155
|
+
dry_run=dry_run,
|
|
1156
|
+
)
|
|
1157
|
+
payload = report.to_dict()
|
|
1158
|
+
payload["dry_run"] = dry_run
|
|
1159
|
+
payload["reloaded"] = False
|
|
1160
|
+
payload["reload_detail"] = ""
|
|
1161
|
+
if report.changed and not dry_run:
|
|
1162
|
+
ok, detail = du.daemon_reload()
|
|
1163
|
+
payload["reloaded"] = ok
|
|
1164
|
+
payload["reload_detail"] = detail
|
|
1165
|
+
if not ok:
|
|
1166
|
+
payload["ok"] = False
|
|
1167
|
+
|
|
1168
|
+
payload["timers_enabled"] = {}
|
|
1169
|
+
if not dry_run:
|
|
1170
|
+
timer_results = du.enable_timers(report)
|
|
1171
|
+
payload["timers_enabled"] = {
|
|
1172
|
+
name: {"ok": ok, "detail": detail}
|
|
1173
|
+
for name, (ok, detail) in timer_results.items()
|
|
1174
|
+
}
|
|
1175
|
+
if any(not ok for ok, _ in timer_results.values()):
|
|
1176
|
+
payload["ok"] = False
|
|
1177
|
+
return JSONResponse(payload, status_code=200 if payload.get("ok") else 500)
|
|
1178
|
+
|
|
1179
|
+
async def restart_services(request: Request) -> JSONResponse:
|
|
1180
|
+
"""Restart this host's sibling coord-* units (#2069).
|
|
1181
|
+
|
|
1182
|
+
``POST /update`` swaps the venv and re-execs **the agent** — and
|
|
1183
|
+
nothing else. ``coord-serve``, ``coord-web`` and ``coord-drive-queue``
|
|
1184
|
+
keep running the generation they started with until something
|
|
1185
|
+
restarts THEM, which used to mean a human. This is that something,
|
|
1186
|
+
meant to be called right after ``/update`` lands on the same host
|
|
1187
|
+
(see ``coord/commands/release.py``'s ``_roll_python``).
|
|
1188
|
+
|
|
1189
|
+
Which of the three units to touch is decided HERE, from what
|
|
1190
|
+
``coord.health.checks.spawned_coord`` finds actually running on this
|
|
1191
|
+
host right now — the same "which services a host runs is a topology
|
|
1192
|
+
decision, not a release decision" rule ``/deploy-units`` already
|
|
1193
|
+
follows. A host that never ran ``coord-web`` never gets one started
|
|
1194
|
+
by this call.
|
|
1195
|
+
|
|
1196
|
+
Synchronous, unlike ``/update``: restarting a sibling process does
|
|
1197
|
+
not kill the request handling this restart (that's the whole reason
|
|
1198
|
+
it can wait for confirmation rather than fire-and-forget — see
|
|
1199
|
+
:func:`_restart_sibling_unit`).
|
|
1200
|
+
|
|
1201
|
+
Request body (JSON, optional)::
|
|
1202
|
+
|
|
1203
|
+
{"units": ["coord-serve", "coord-web"]}
|
|
1204
|
+
|
|
1205
|
+
Omit ``units`` (or POST ``{}``/no body) to consider all of
|
|
1206
|
+
:data:`RESTARTABLE_SIBLING_UNITS`. Naming a unit outside that set is
|
|
1207
|
+
a 400 — ``coord-agent`` restarts itself through ``/update``,
|
|
1208
|
+
``/rollback`` and ``/restart``, and doing it again from here would
|
|
1209
|
+
race those endpoints' own restart threads.
|
|
1210
|
+
"""
|
|
1211
|
+
from coord.health.checks.spawned_coord import running_unit_pids # noqa: PLC0415
|
|
1212
|
+
|
|
1213
|
+
body: dict = {}
|
|
1214
|
+
try:
|
|
1215
|
+
body = await request.json()
|
|
1216
|
+
except Exception:
|
|
1217
|
+
pass
|
|
1218
|
+
if not isinstance(body, dict):
|
|
1219
|
+
body = {}
|
|
1220
|
+
raw_units = body.get("units")
|
|
1221
|
+
if raw_units is None:
|
|
1222
|
+
wanted = set(RESTARTABLE_SIBLING_UNITS)
|
|
1223
|
+
elif isinstance(raw_units, list):
|
|
1224
|
+
wanted = {str(u) for u in raw_units}
|
|
1225
|
+
else:
|
|
1226
|
+
return JSONResponse({"error": "\"units\" must be a list"}, status_code=400)
|
|
1227
|
+
unknown = wanted - RESTARTABLE_SIBLING_UNITS
|
|
1228
|
+
if unknown:
|
|
1229
|
+
return JSONResponse(
|
|
1230
|
+
{
|
|
1231
|
+
"error": (
|
|
1232
|
+
f"not a restartable sibling unit here: {', '.join(sorted(unknown))} "
|
|
1233
|
+
f"— must be one of {', '.join(sorted(RESTARTABLE_SIBLING_UNITS))}"
|
|
1234
|
+
)
|
|
1235
|
+
},
|
|
1236
|
+
status_code=400,
|
|
1237
|
+
)
|
|
1238
|
+
|
|
1239
|
+
if not _running_under_systemd():
|
|
1240
|
+
return JSONResponse(
|
|
1241
|
+
{
|
|
1242
|
+
"units": {},
|
|
1243
|
+
"detail": (
|
|
1244
|
+
"this agent is not running under systemd — nothing here "
|
|
1245
|
+
"can restart a sibling unit"
|
|
1246
|
+
),
|
|
1247
|
+
},
|
|
1248
|
+
status_code=200,
|
|
1249
|
+
)
|
|
1250
|
+
|
|
1251
|
+
running = running_unit_pids(tuple(sorted(wanted)))
|
|
1252
|
+
results: dict[str, dict] = {}
|
|
1253
|
+
all_ok = True
|
|
1254
|
+
for unit in sorted(wanted):
|
|
1255
|
+
if unit not in running:
|
|
1256
|
+
results[unit] = {"restarted": None, "detail": "not running on this host"}
|
|
1257
|
+
continue
|
|
1258
|
+
# #2069 follow-up: _restart_sibling_unit is synchronous — subprocess.run
|
|
1259
|
+
# calls plus a time.sleep(0.5) poll loop for up to `timeout` seconds per
|
|
1260
|
+
# unit. This handler is `async def`, so Starlette does not thread it
|
|
1261
|
+
# automatically; running that blocking work inline here would freeze the
|
|
1262
|
+
# single uvicorn event loop (no other request — status polls, cancels,
|
|
1263
|
+
# health checks — could be served) for up to ~90s across 3 units. Same
|
|
1264
|
+
# fix as `server.health` above: hand it to a worker thread and await it.
|
|
1265
|
+
restarted, detail = await asyncio.to_thread(_restart_sibling_unit, unit)
|
|
1266
|
+
results[unit] = {"restarted": restarted, "detail": detail}
|
|
1267
|
+
all_ok = all_ok and restarted
|
|
1268
|
+
return JSONResponse({"units": results}, status_code=200 if all_ok else 500)
|
|
1269
|
+
|
|
1270
|
+
async def rollback(request: Request) -> JSONResponse:
|
|
1271
|
+
"""Flip ``~/.coord-venv`` back onto the previous blue/green
|
|
1272
|
+
generation and restart (#1241).
|
|
1273
|
+
|
|
1274
|
+
Every successful ``/update`` keeps exactly one prior generation on
|
|
1275
|
+
disk (see :mod:`coord.agent_update`) precisely so this exists.
|
|
1276
|
+
Refuses — 404, nothing touched — when there's no previous
|
|
1277
|
+
generation (e.g. this machine has never run a blue/green
|
|
1278
|
+
``/update``), and 409 (same as ``/update``, same ``{"force":
|
|
1279
|
+
true}`` override) when live sessions are running.
|
|
1280
|
+
|
|
1281
|
+
Request body (JSON, optional)::
|
|
1282
|
+
|
|
1283
|
+
{"force": false}
|
|
1284
|
+
"""
|
|
1285
|
+
body: dict = {}
|
|
1286
|
+
try:
|
|
1287
|
+
body = await request.json()
|
|
1288
|
+
except Exception:
|
|
1289
|
+
pass
|
|
1290
|
+
if not isinstance(body, dict):
|
|
1291
|
+
body = {}
|
|
1292
|
+
force = bool(body.get("force"))
|
|
1293
|
+
|
|
1294
|
+
with server._lock:
|
|
1295
|
+
active_count = sum(
|
|
1296
|
+
1
|
|
1297
|
+
for a in server._assignments.values()
|
|
1298
|
+
if a.status in (PENDING, RUNNING)
|
|
1299
|
+
)
|
|
1300
|
+
if active_count and not force:
|
|
1301
|
+
payload = {
|
|
1302
|
+
"mode": "rollback",
|
|
1303
|
+
"started_at": time.time(),
|
|
1304
|
+
"finished_at": time.time(),
|
|
1305
|
+
"result": "refused",
|
|
1306
|
+
"error": (
|
|
1307
|
+
f"{active_count} active assignment(s) running — rolling "
|
|
1308
|
+
"back restarts the process and kills them mid-flight. "
|
|
1309
|
+
'Pass {"force": true} to roll back anyway, or wait for '
|
|
1310
|
+
"them to finish."
|
|
1311
|
+
),
|
|
1312
|
+
}
|
|
1313
|
+
_write_last_update(server.state_dir, payload)
|
|
1314
|
+
return JSONResponse(payload, status_code=409)
|
|
1315
|
+
|
|
1316
|
+
venv_dir = _venv_dir()
|
|
1317
|
+
version_before = _installed_version() or "unknown"
|
|
1318
|
+
result = agent_update.rollback(venv_dir)
|
|
1319
|
+
if not result.ok:
|
|
1320
|
+
payload = {
|
|
1321
|
+
"mode": "rollback",
|
|
1322
|
+
"started_at": time.time(),
|
|
1323
|
+
"finished_at": time.time(),
|
|
1324
|
+
"version_before": version_before,
|
|
1325
|
+
"result": "failed",
|
|
1326
|
+
"error": result.error,
|
|
1327
|
+
"log_excerpt": "\n".join((result.log or "").splitlines()[-20:]),
|
|
1328
|
+
}
|
|
1329
|
+
_write_last_update(server.state_dir, payload)
|
|
1330
|
+
return JSONResponse(payload, status_code=404 if "no previous generation" in (result.error or "") else 500)
|
|
1331
|
+
|
|
1332
|
+
saved_argv = list(sys.argv)
|
|
1333
|
+
state_dir = server.state_dir
|
|
1334
|
+
|
|
1335
|
+
def _do_rollback() -> None:
|
|
1336
|
+
payload = {
|
|
1337
|
+
"mode": "rollback",
|
|
1338
|
+
"started_at": time.time(),
|
|
1339
|
+
"finished_at": time.time(),
|
|
1340
|
+
"version_before": version_before,
|
|
1341
|
+
"version_after": result.new_version or "unknown",
|
|
1342
|
+
"result": "upgraded",
|
|
1343
|
+
"error": None,
|
|
1344
|
+
"log_excerpt": "\n".join((result.log or "").splitlines()[-20:]),
|
|
1345
|
+
}
|
|
1346
|
+
_write_last_update(state_dir, payload)
|
|
1347
|
+
time.sleep(0.5)
|
|
1348
|
+
exec_restart(saved_argv)
|
|
1349
|
+
|
|
1350
|
+
threading.Thread(target=_do_rollback, daemon=False, name="agent-rollback").start()
|
|
1351
|
+
return JSONResponse(
|
|
1352
|
+
{"status": "rolling back", "slot": str(result.slot)}, status_code=202
|
|
1353
|
+
)
|
|
1354
|
+
|
|
1355
|
+
async def artifact_manifest(request: Request) -> JSONResponse:
|
|
1356
|
+
"""Return a JSON manifest of stashed artifacts for a (repo, branch) pair.
|
|
1357
|
+
|
|
1358
|
+
Path parameters:
|
|
1359
|
+
repo — repo name (e.g. ``quadraui``)
|
|
1360
|
+
branch — sanitized branch name (slashes already replaced with
|
|
1361
|
+
dashes, e.g. ``issue-305-artifact-pull``)
|
|
1362
|
+
|
|
1363
|
+
Response (200)::
|
|
1364
|
+
|
|
1365
|
+
{
|
|
1366
|
+
"files": [{"name": "...", "size": N, "mtime": N}, ...],
|
|
1367
|
+
"total_bytes": N,
|
|
1368
|
+
"built_by_assignment_id": "abc123" | null
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
Returns 404 when no stash exists for the given (repo, branch) pair.
|
|
1372
|
+
The 404 body's ``error`` field carries the agent's ground-truth
|
|
1373
|
+
reason (#914) — e.g. a live worktree exists but was never stashed,
|
|
1374
|
+
vs. genuinely nothing was ever built here — rather than a generic
|
|
1375
|
+
message, since only this host can tell the difference.
|
|
1376
|
+
"""
|
|
1377
|
+
repo = request.path_params["repo"]
|
|
1378
|
+
branch = request.path_params["branch"]
|
|
1379
|
+
manifest = server.artifact_manifest(repo, branch)
|
|
1380
|
+
if manifest is None:
|
|
1381
|
+
reason = server.artifact_absence_reason(repo, branch)
|
|
1382
|
+
return JSONResponse(
|
|
1383
|
+
{"error": f"no artifacts for repo={repo!r} branch={branch!r}: {reason}"},
|
|
1384
|
+
status_code=404,
|
|
1385
|
+
)
|
|
1386
|
+
return JSONResponse(manifest)
|
|
1387
|
+
|
|
1388
|
+
async def worktree_clean(request: Request) -> JSONResponse:
|
|
1389
|
+
"""Remove stale git worktrees managed by this agent.
|
|
1390
|
+
|
|
1391
|
+
Idempotent POST — skips worktrees for running/pending assignments
|
|
1392
|
+
and those finished within the last 5 minutes. Returns a JSON
|
|
1393
|
+
summary: ``{"cleaned": N, "kept": M, "bytes_freed": B}``.
|
|
1394
|
+
|
|
1395
|
+
Optional JSON body::
|
|
1396
|
+
|
|
1397
|
+
{
|
|
1398
|
+
"recent_secs": 300, # override recency window (s)
|
|
1399
|
+
"protect": ["aid1", "aid2"] # #1295: never sweep these AIDs
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
``protect`` is optional and free-form — unknown/extra keys in the
|
|
1403
|
+
body are ignored, so a coordinator sending the new field to an
|
|
1404
|
+
older agent that ignores it, and a coordinator omitting the field
|
|
1405
|
+
entirely against a new agent, both work. A protected entry is
|
|
1406
|
+
counted as ``kept`` in the response; the return shape is
|
|
1407
|
+
unchanged.
|
|
1408
|
+
"""
|
|
1409
|
+
body: dict = {}
|
|
1410
|
+
try:
|
|
1411
|
+
body = await request.json()
|
|
1412
|
+
except Exception:
|
|
1413
|
+
pass
|
|
1414
|
+
recent_secs = float(body.get("recent_secs", 300.0))
|
|
1415
|
+
# #1295: accept "protect" as either a list or omitted. Anything
|
|
1416
|
+
# non-list-like (a string, a dict, garbage) is dropped rather
|
|
1417
|
+
# than 400ing — we prefer to sweep what we can over rejecting an
|
|
1418
|
+
# otherwise-valid request because of a malformed side field.
|
|
1419
|
+
raw_protect = body.get("protect")
|
|
1420
|
+
protect: list[str] | None
|
|
1421
|
+
if isinstance(raw_protect, list):
|
|
1422
|
+
protect = [str(x) for x in raw_protect if isinstance(x, str)]
|
|
1423
|
+
else:
|
|
1424
|
+
protect = None
|
|
1425
|
+
result = server.clean_worktrees(recent_secs=recent_secs, protect=protect)
|
|
1426
|
+
return JSONResponse(result)
|
|
1427
|
+
|
|
1428
|
+
async def restart(request: Request) -> JSONResponse:
|
|
1429
|
+
"""Gracefully restart the agent process.
|
|
1430
|
+
|
|
1431
|
+
Waits up to ``cancel_timeout`` seconds (default 30) for active workers
|
|
1432
|
+
to finish on their own. Any workers still running after the timeout
|
|
1433
|
+
are cancelled before the process is replaced. Returns HTTP 202
|
|
1434
|
+
immediately; the actual restart happens in a background thread.
|
|
1435
|
+
|
|
1436
|
+
Request body (JSON, optional)::
|
|
1437
|
+
|
|
1438
|
+
{"cancel_timeout": 30}
|
|
1439
|
+
"""
|
|
1440
|
+
body: dict = {}
|
|
1441
|
+
try:
|
|
1442
|
+
body = await request.json()
|
|
1443
|
+
except Exception:
|
|
1444
|
+
pass
|
|
1445
|
+
|
|
1446
|
+
cancel_timeout = float(body.get("cancel_timeout", 30))
|
|
1447
|
+
saved_argv = list(sys.argv)
|
|
1448
|
+
|
|
1449
|
+
with server._lock:
|
|
1450
|
+
active_count = sum(
|
|
1451
|
+
1
|
|
1452
|
+
for a in server._assignments.values()
|
|
1453
|
+
if a.status in (PENDING, RUNNING)
|
|
1454
|
+
)
|
|
1455
|
+
|
|
1456
|
+
def _do_restart() -> None:
|
|
1457
|
+
# Wait for workers to drain.
|
|
1458
|
+
deadline = time.time() + cancel_timeout
|
|
1459
|
+
while time.time() < deadline:
|
|
1460
|
+
with server._lock:
|
|
1461
|
+
still_active = sum(
|
|
1462
|
+
1
|
|
1463
|
+
for a in server._assignments.values()
|
|
1464
|
+
if a.status in (PENDING, RUNNING)
|
|
1465
|
+
)
|
|
1466
|
+
if still_active == 0:
|
|
1467
|
+
break
|
|
1468
|
+
time.sleep(1)
|
|
1469
|
+
|
|
1470
|
+
# Cancel any workers that are still running.
|
|
1471
|
+
with server._lock:
|
|
1472
|
+
pending_ids = [
|
|
1473
|
+
aid
|
|
1474
|
+
for aid, a in server._assignments.items()
|
|
1475
|
+
if a.status in (PENDING, RUNNING)
|
|
1476
|
+
]
|
|
1477
|
+
for aid in pending_ids:
|
|
1478
|
+
try:
|
|
1479
|
+
# #1567: this is an infra-triggered restart, not an
|
|
1480
|
+
# operator `coord stop` — nobody decided this work was
|
|
1481
|
+
# unwanted, so keep the pre-#1567 behaviour of pushing
|
|
1482
|
+
# any WIP straight onto the worker's own branch.
|
|
1483
|
+
server.cancel(aid, push_mode="branch")
|
|
1484
|
+
except Exception:
|
|
1485
|
+
pass
|
|
1486
|
+
|
|
1487
|
+
time.sleep(0.5)
|
|
1488
|
+
exec_restart(saved_argv)
|
|
1489
|
+
|
|
1490
|
+
threading.Thread(target=_do_restart, daemon=False, name="agent-restart").start()
|
|
1491
|
+
return JSONResponse(
|
|
1492
|
+
{
|
|
1493
|
+
"status": "restarting",
|
|
1494
|
+
"active_workers": active_count,
|
|
1495
|
+
"cancel_timeout": cancel_timeout,
|
|
1496
|
+
},
|
|
1497
|
+
status_code=202,
|
|
1498
|
+
)
|
|
1499
|
+
|
|
1500
|
+
async def metrics(_request: Request) -> JSONResponse:
|
|
1501
|
+
"""#207: Return CPU and memory metrics for the agent machine.
|
|
1502
|
+
|
|
1503
|
+
Uses ``psutil`` for sub-millisecond, non-blocking snapshots.
|
|
1504
|
+
``cpu_percent(interval=None)`` returns the CPU utilisation since
|
|
1505
|
+
the previous call (or since process start on the very first call),
|
|
1506
|
+
which is essentially free — no sleep, no blocking.
|
|
1507
|
+
"""
|
|
1508
|
+
try:
|
|
1509
|
+
import psutil # lazy import — keeps startup fast on old agents
|
|
1510
|
+
except ImportError:
|
|
1511
|
+
return JSONResponse(
|
|
1512
|
+
{"error": "psutil not installed on this agent"},
|
|
1513
|
+
status_code=503,
|
|
1514
|
+
)
|
|
1515
|
+
cpu = psutil.cpu_percent(interval=None)
|
|
1516
|
+
vm = psutil.virtual_memory()
|
|
1517
|
+
return JSONResponse(
|
|
1518
|
+
{
|
|
1519
|
+
"cpu_percent": cpu,
|
|
1520
|
+
"mem_percent": vm.percent,
|
|
1521
|
+
"mem_used_mb": round(vm.used / (1024 * 1024), 1),
|
|
1522
|
+
"mem_total_mb": round(vm.total / (1024 * 1024), 1),
|
|
1523
|
+
"timestamp": time.time(),
|
|
1524
|
+
}
|
|
1525
|
+
)
|
|
1526
|
+
|
|
1527
|
+
routes = [
|
|
1528
|
+
Route("/health", health, methods=["GET"]),
|
|
1529
|
+
Route("/status", status, methods=["GET"]),
|
|
1530
|
+
Route("/repos", repos, methods=["GET"]),
|
|
1531
|
+
Route("/assign", assign, methods=["POST"]),
|
|
1532
|
+
Route("/cancel/{id}", cancel, methods=["POST"]),
|
|
1533
|
+
Route("/inject/{id}", inject, methods=["POST"]),
|
|
1534
|
+
Route("/logs/{id}", logs, methods=["GET"]),
|
|
1535
|
+
Route("/stream/{id}", stream, methods=["GET"]),
|
|
1536
|
+
Route("/update", update, methods=["POST"]),
|
|
1537
|
+
# #1831/#1835: the `deploy/**` lane's deploy step. Must be POSTed
|
|
1538
|
+
# AFTER /update on the same host — see the handler's docstring.
|
|
1539
|
+
Route("/deploy-units", deploy_units, methods=["POST"]),
|
|
1540
|
+
# #2069: restarts coord-serve/coord-web/coord-drive-queue — the rest
|
|
1541
|
+
# of a python-lane roll /update itself only does for coord-agent.
|
|
1542
|
+
# Must be POSTed AFTER /update on the same host, same reason as
|
|
1543
|
+
# /deploy-units above.
|
|
1544
|
+
Route("/restart-services", restart_services, methods=["POST"]),
|
|
1545
|
+
Route("/rollback", rollback, methods=["POST"]),
|
|
1546
|
+
Route("/restart", restart, methods=["POST"]),
|
|
1547
|
+
Route("/worktree-clean", worktree_clean, methods=["POST"]),
|
|
1548
|
+
# #305: artifact stash manifest (GET /artifact/<repo>/<branch>)
|
|
1549
|
+
Route("/artifact/{repo}/{branch}", artifact_manifest, methods=["GET"]),
|
|
1550
|
+
# #207: CPU + memory snapshot for TUI sparklines
|
|
1551
|
+
Route("/metrics", metrics, methods=["GET"]),
|
|
1552
|
+
]
|
|
1553
|
+
# #757: served OpenAPI 3 spec + Swagger UI docs page.
|
|
1554
|
+
routes.extend(openapi_and_docs_routes(_openapi_spec()))
|
|
1555
|
+
return Starlette(routes=routes)
|