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.
Files changed (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. 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)