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
@@ -0,0 +1,36 @@
1
+ # coord-web-dist-build — fires coord-web-dist-build.service (Type=oneshot) on
2
+ # a cadence. See that unit's header, and deploy/coord-web-dist-build.sh's
3
+ # header, for the full rationale.
4
+ #
5
+ # 1 minute — the #1543 acceptance target ("live... within a minute"). This
6
+ # is safe at a tight cadence for two reasons a 15-minute cadence (like
7
+ # coord-drive-queue.timer) doesn't need to justify:
8
+ #
9
+ # 1. Most fires are a no-op: the script compares origin/main's resolved SHA
10
+ # against the currently-published release and exits immediately if
11
+ # nothing merged since the last run — no npm invocation, no I/O beyond
12
+ # one `git fetch` + `rev-parse`.
13
+ # 2. The script's own flock means a slow build (network hiccup during
14
+ # `npm ci`, etc.) can't stack with the next fire — a busy run is
15
+ # detected and the new invocation exits 0 immediately, exactly like
16
+ # coord-drive-queue's tick.
17
+ #
18
+ # A build that actually runs (something under coord/dashboard/webapp/**
19
+ # merged) takes on the order of tens of seconds; that run and the next may
20
+ # overlap the flock, in which case the next fire simply retries a minute
21
+ # later — still within the acceptance target on average.
22
+ #
23
+ # Install: see deploy/coord-web-dist-build.sh's header, or
24
+ # docs/PHONE_WEBAPP.md / docs/AGENT_OPERATIONS.md.
25
+
26
+ [Unit]
27
+ Description=Rebuild coord webapp dist from merged main every minute (#1543)
28
+
29
+ [Timer]
30
+ OnBootSec=1min
31
+ OnUnitActiveSec=1min
32
+ AccuracySec=10s
33
+ Persistent=false
34
+
35
+ [Install]
36
+ WantedBy=timers.target
@@ -0,0 +1,125 @@
1
+ # coord web — phone/web control-center dashboard (#700/#703).
2
+ #
3
+ # A systemd *user* unit (mirrors coord-serve). Serves the board dashboard +
4
+ # the React/Vite phone PWA on port 7434. It is the THIRD always-on user
5
+ # service, after coord-agent (every worker box) and coord-serve (the DB host).
6
+ #
7
+ # Install on the SAME always-on box that owns ~/.coord/coord.db (dellserver):
8
+ # `coord web` reads the LOCAL board (state.load_board/build_board) — it does
9
+ # NOT yet go through the coord serve daemon — so it MUST run where the DB
10
+ # lives. Do not run it on a thin-client machine (it would render an empty
11
+ # board). Routing it through the daemon so it can run anywhere — like the
12
+ # CLI/TUI thin clients — is tracked in #749 (the BoardService facade).
13
+ #
14
+ # Install:
15
+ # mkdir -p ~/.config/systemd/user
16
+ # cp deploy/coord-web.service ~/.config/systemd/user/
17
+ # loginctl enable-linger "$USER" # survive logout / reboot
18
+ # # First build the dist dir BEFORE starting the service — the assets
19
+ # # Mount/SPA catch-all routes are registered once at process start, so
20
+ # # they need something to point at (see coord-web-dist-build.sh):
21
+ # ~/.local/bin/coord-web-dist-build.sh
22
+ # systemctl --user daemon-reload
23
+ # systemctl --user enable --now coord-web
24
+ # # ...and the timer that keeps the dist dir tracking merged main (#1543):
25
+ # cp deploy/coord-web-dist-build.service deploy/coord-web-dist-build.timer \
26
+ # ~/.config/systemd/user/
27
+ # systemctl --user enable --now coord-web-dist-build.timer
28
+ #
29
+ # Prereqs on the dashboard host (== the DB host):
30
+ # - ~/.coord-venv has code-coordinator installed via PyPI (`coord`
31
+ # itself — the daemon/CLI binary; unrelated to the webapp bundle below).
32
+ # - ~/.coord/coordinator.yml exists (canonical location; same resolution as
33
+ # the daemon: $COORD_CONFIG → ~/.coord/coordinator.yml → ./coordinator.yml).
34
+ # - ~/.coord/coord.db exists (this box owns it).
35
+ # - ~/coord-web-dist exists — a symlink that coord-web-dist-build.sh /
36
+ # .timer keep pointed at a fresh build of coord/dashboard/webapp from
37
+ # merged main (#1543; see deploy/coord-web-dist-build.sh's header for the
38
+ # full mechanism). This is intentionally decoupled from ~/.coord-venv: a
39
+ # merged webapp PR goes live within ~1 minute via that timer, with no
40
+ # `pip install`, no PyPI release, and no restart of coord-web,
41
+ # coord-agent, or coord-serve.
42
+ #
43
+ # #1543 CORRECTION (2026-08-04): earlier revisions of this file said
44
+ # ~/.coord-venv's wheel bundles the built webapp (#758, landed
45
+ # 2026-08-03) and that upgrading the venv was how a webapp change went
46
+ # live. That is still TRUE of the wheel, but is no longer how this unit
47
+ # serves the webapp — the --dist override below reads from
48
+ # ~/coord-web-dist instead, specifically so shipping a webapp change
49
+ # does not require upgrading the venv that coord-agent/coord-serve also
50
+ # run from. If ~/coord-web-dist is ever missing (fresh install before
51
+ # the first build, or the timer disabled), --dist falls back to the
52
+ # bundled coord/dashboard/webapp/dist inside the venv, same legacy
53
+ # fallback as before.
54
+ #
55
+ # Reach it from a phone on the tailnet: http://<dellserver-host>:7434
56
+ # (same-origin API, so the phone needs no config; Add to Home Screen installs
57
+ # the PWA).
58
+ #
59
+ # Restart over SSH needs the runtime-dir prefix (same #404 caveat as the agent):
60
+ # XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user restart coord-web
61
+ #
62
+ # Restarting coord-web (or its dist rebuilding) never touches ~/.coord-venv,
63
+ # so it cannot disturb coord-serve (7435) or coord-agent (7433) — see
64
+ # docs/PHONE_WEBAPP.md's "Going live automatically (#1543)" section for the
65
+ # before/after version proof this was verified against.
66
+ #
67
+ # See docs/AGENT_OPERATIONS.md → "Web dashboard (coord web)".
68
+ #
69
+ # TimeoutStopSec / KillMode (#2095): coord-web serves `text/event-stream`
70
+ # endpoints (coord/dashboard/server.py), and uvicorn's graceful shutdown
71
+ # waits for every open connection to drain. An SSE stream does not close on
72
+ # its own — a browser tab or the phone PWA left open holds the stop open
73
+ # INDEFINITELY, past any timeout a restart caller is willing to wait. During
74
+ # the 2026-08-10 0.5.15 -> 0.5.26 roll that left the unit `deactivating` for
75
+ # the run's entire wait, which the caller (coord/agent_app.py's
76
+ # `_restart_sibling_unit`, at the time a blocking `systemctl restart` under
77
+ # a hard 15s subprocess timeout) turned into an abandoned mid-stop unit:
78
+ # not restarted — STOPPED — while still being reported a success one layer
79
+ # up. Bounding the stop HERE, at the unit, means systemd escalates to
80
+ # SIGKILL on its own after TimeoutStopSec regardless of what any caller's
81
+ # own timeout is doing, instead of leaving an open-ended wait for a client
82
+ # that may never disconnect. KillMode=process (rather than the default
83
+ # control-group) targets only the main uvicorn process — sidestepping the
84
+ # same recovery failure the incident hit by hand afterwards
85
+ # (`systemctl --user kill -s SIGKILL coord-web` refused with "Failed to send
86
+ # signal SIGKILL to auxiliary processes: Invalid argument") — and follows
87
+ # the KillMode precedent already established on coord-drive-queue.service
88
+ # (#1830), even though the underlying reason differs (that one protects a
89
+ # spawned tmux server from this unit's own cgroup teardown; this one avoids
90
+ # ever having to signal a nonexistent auxiliary process group at all).
91
+
92
+ [Unit]
93
+ Description=coord web — phone/web control-center dashboard (#700)
94
+ After=network-online.target
95
+ Wants=network-online.target
96
+
97
+ [Service]
98
+ Type=simple
99
+ ExecStart=%h/.coord-venv/bin/coord web --config %h/.coord/coordinator.yml --host 0.0.0.0 --port 7434 --dist %h/coord-web-dist
100
+ # #2095: the `--port` above is the ONLY declaration of this dashboard's port.
101
+ # `coord/agent_app.py`'s `_probe_liveness` — the post-restart liveness check
102
+ # `_restart_sibling_unit` runs (from inside the coord-agent process) before
103
+ # trusting coord-web's restart — reads it back off this very line via
104
+ # `systemctl --user show coord-web --property=ExecStart`, so changing the port
105
+ # here needs no matching edit anywhere else and cannot desync the probe from
106
+ # the listener. An earlier revision declared `Environment=COORD_WEB_PORT=7434`
107
+ # here instead; that could never work, because systemd does not share
108
+ # `Environment=` across units and the process that reads it runs under
109
+ # coord-agent.service. Do not re-add it (here or there): a second declaration
110
+ # that has to agree with this one by hand is the failure mode, not the fix.
111
+ # Belt-and-suspenders for #1229: systemd user units have no controlling TTY so
112
+ # TERM is absent from the process environment. The terminal bridge code now
113
+ # calls env.setdefault("TERM", "xterm-256color") before spawning tmux, but
114
+ # setting it here too ensures any other subprocess spawned by coord web also
115
+ # has a sane terminal type without requiring a code fix for each callsite.
116
+ Environment=TERM=xterm-256color
117
+ Restart=on-failure
118
+ RestartSec=3
119
+ # #2095 — see the header comment above. Bounds the SSE-drain hang that took
120
+ # the dashboard down during the 2026-08-10 fleet roll.
121
+ TimeoutStopSec=10
122
+ KillMode=process
123
+
124
+ [Install]
125
+ WantedBy=default.target
@@ -0,0 +1,80 @@
1
+ """Per-role systemd unit manifest — which units a host runs (#2098).
2
+
3
+ `deploy/` ships every packaged unit and `coord release verify` /
4
+ `deploy_units.install_units` both deliberately decline to guess which of
5
+ them a given host should run — *"a release does not decide which services
6
+ a host runs"* (`coord/deploy_units.py`'s module docstring). That refusal is
7
+ correct, but it left the actual decision nowhere: not in a file, not in
8
+ code, only as `~/.config/systemd/user/timers.target.wants/` symlinks on
9
+ dellserver. Losing dellserver would have meant rebuilding that list from
10
+ memory.
11
+
12
+ This module is that list, in code instead of folklore. It mirrors
13
+ `docs/AGENT_OPERATIONS.md`'s "Daemon-host unit inventory" table — keep both
14
+ in sync; `tests/test_deploy_manifest.py` cross-checks the doc table against
15
+ :data:`ROLE_UNITS` so the two cannot quietly drift apart the way the
16
+ propagate-timer enable step did.
17
+
18
+ Each name is the unit that should actually be `systemctl --user enable
19
+ --now`'d. For a timer-backed lane that is always the `.timer`, never the
20
+ oneshot `.service` it fires — enabling the oneshot does nothing; only the
21
+ timer's own `[Install]` section wires anything into
22
+ `timers.target.wants/`. `coord-agent`, `coord-serve` and `coord-web` have
23
+ no timer and are listed as `.service` because they ARE the long-running
24
+ unit.
25
+
26
+ Consumed by :mod:`coord.health.checks.unit_enablement` (#2098), which
27
+ reads :func:`all_manifest_units` to decide which *installed* units it is
28
+ entitled to expect `enabled`. It does not use this module to guess which
29
+ role a given host plays — same "don't infer topology" boundary as
30
+ `unit_drift` and `deploy_units`: an installed-but-unlisted unit is not this
31
+ module's business, and an uninstalled manifest unit is not a fault (most
32
+ hosts are workers, and are not supposed to run the daemon lanes).
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ ROLE_WORKER = "worker"
38
+ ROLE_DAEMON = "daemon"
39
+
40
+ #: role -> unit names that role's host should have running, in the order
41
+ #: they appear in docs/AGENT_OPERATIONS.md's "Daemon-host unit inventory"
42
+ #: table. `coord-agent.service` is listed under both roles because every
43
+ #: machine — daemon host included — runs it.
44
+ ROLE_UNITS: dict[str, tuple[str, ...]] = {
45
+ ROLE_WORKER: (
46
+ "coord-agent.service",
47
+ ),
48
+ ROLE_DAEMON: (
49
+ "coord-agent.service",
50
+ "coord-serve.service",
51
+ "coord-web.service",
52
+ "coord-web-dist-build.timer",
53
+ "coord-notify.timer",
54
+ "coord-drive-queue.timer",
55
+ "coord-release-propagate.timer",
56
+ "coord-db-backup.timer",
57
+ ),
58
+ }
59
+
60
+
61
+ def units_for_role(role: str) -> tuple[str, ...]:
62
+ """The units *role* should run, or `()` for an unknown role."""
63
+ return ROLE_UNITS.get(role, ())
64
+
65
+
66
+ def all_manifest_units() -> tuple[str, ...]:
67
+ """Every unit named by any role, deduped and sorted.
68
+
69
+ What :mod:`coord.health.checks.unit_enablement` iterates: it does not
70
+ need to know *which* role this host plays, only whether an installed
71
+ unit is one this manifest ever expects to be enabled somewhere.
72
+ """
73
+ seen: set[str] = set()
74
+ out: list[str] = []
75
+ for units in ROLE_UNITS.values():
76
+ for name in units:
77
+ if name not in seen:
78
+ seen.add(name)
79
+ out.append(name)
80
+ return tuple(sorted(out))
coord/deploy_units.py ADDED
@@ -0,0 +1,384 @@
1
+ """The `deploy/**` lane's missing *deploy step* (#1831, wired up by #1835).
2
+
3
+ #1831 gave `deploy/**` a **detector** — ``coord.health.checks.unit_drift``
4
+ diffs each host's installed unit under ``~/.config/systemd/user/`` against
5
+ the units packaged in the wheel (``coord/deploy/``, #1927) — and a printed
6
+ remedy that a human then runs by hand. That was the right first half. It is
7
+ not a deploy lane: cutting v0.4.106 still meant installing five files into
8
+ ``~/.config/systemd/user/`` and ``~/.local/bin/`` on dellserver, plus a
9
+ ``daemon-reload``, plus retiring a machine-local drop-in.
10
+
11
+ #1835 cannot claim "the fleet reaches that version" while a whole lane needs
12
+ a human with ``cp`` and ``systemctl``, and this is not a corner case: #1543's
13
+ actual mechanism was three unit files and a shell script, while its Python
14
+ change was a single ``--dist`` flag. A release that propagated only the
15
+ Python lane would have shipped the flag and none of the behaviour.
16
+
17
+ So this module applies what ``unit_drift`` reports.
18
+
19
+ THREE SAFETY PROPERTIES, ALL DELIBERATE
20
+ ---------------------------------------
21
+ 1. **Refresh only units this host already runs.** Which services a host runs
22
+ is a *topology* decision (``coordinator.yml``), not a release decision. A
23
+ packaged unit with no installed counterpart is reported as ``new`` and
24
+ left alone — installing ``coord-web.service`` onto a machine that never
25
+ wanted a web server, because a release happened to contain the file, is a
26
+ far worse failure than a human running one ``cp``. The report names them
27
+ so the human action is visible rather than implicit.
28
+
29
+ 2. **Templates are rendered, never copied verbatim (#1928).** Several units
30
+ carry ``<MACHINE_NAME>`` / ``<PORT>`` placeholders. Copying one verbatim
31
+ installs the placeholder as literal text and the unit then refuses to
32
+ start — the exact hazard #1928 documented. Placeholders with no known
33
+ substitution abort *that unit* (reported, not written); they never get
34
+ guessed.
35
+
36
+ 3. **The previous content is kept.** Every overwrite writes
37
+ ``<name>.pre-<version>.bak`` next to the unit first, so the rollback for
38
+ this lane is a file copy the operator can see and `diff`, not a re-run of
39
+ an install script whose inputs have moved on.
40
+
41
+ The write itself is atomic per file (temp file + ``os.replace``), so a unit
42
+ is never observed half-written by a ``daemon-reload`` racing this.
43
+
44
+ Pure-ish by construction: every path is a parameter, so the whole thing is
45
+ testable against ``tmp_path`` with no systemd, no fleet, and no root.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import os
51
+ import re
52
+ import shutil
53
+ from dataclasses import asdict, dataclass, field
54
+ from pathlib import Path
55
+
56
+ # Reuse #1831's own definitions rather than re-spelling them. Two
57
+ # definitions of "which files are units" or "what a placeholder looks like"
58
+ # would let the detector and the deployer disagree — and a deployer that
59
+ # disagrees with its detector reports clean while shipping nothing.
60
+ from coord.health.checks.unit_drift import (
61
+ _KNOWN_PLACEHOLDER_VALUES,
62
+ _PLACEHOLDER_RE,
63
+ _SYSTEMD_USER_DIR,
64
+ _UNIT_GLOBS,
65
+ packaged_unit_dir,
66
+ )
67
+
68
+ #: Placeholder -> how to fill it, given the host facts we actually know.
69
+ #: ``unit_drift`` renders these as *shell* text for a copy-pasteable remedy
70
+ #: (``$(hostname -s)``); here they must be real values, so the mapping is
71
+ #: from placeholder name to the keyword of :func:`install_units`.
72
+ _PLACEHOLDER_SOURCES = {
73
+ "MACHINE_NAME": "machine_name",
74
+ "PORT": "port",
75
+ }
76
+
77
+ #: Outcome of one unit's deploy step.
78
+ ACTION_UNCHANGED = "unchanged"
79
+ ACTION_UPDATED = "updated"
80
+ ACTION_NEW = "new"
81
+ ACTION_SKIPPED = "skipped"
82
+ ACTION_FAILED = "failed"
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class UnitOutcome:
87
+ name: str
88
+ action: str
89
+ detail: str = ""
90
+ backup: str | None = None
91
+
92
+ def to_dict(self) -> dict:
93
+ return asdict(self)
94
+
95
+
96
+ @dataclass
97
+ class InstallReport:
98
+ """What the deploy step did, per unit."""
99
+
100
+ units: list[UnitOutcome] = field(default_factory=list)
101
+ reference: str | None = None
102
+ error: str | None = None
103
+ #: True once at least one unit's bytes changed — the only case that
104
+ #: needs a ``systemctl --user daemon-reload``.
105
+ @property
106
+ def changed(self) -> bool:
107
+ return any(u.action == ACTION_UPDATED for u in self.units)
108
+
109
+ @property
110
+ def ok(self) -> bool:
111
+ return self.error is None and not any(
112
+ u.action == ACTION_FAILED for u in self.units
113
+ )
114
+
115
+ def to_dict(self) -> dict:
116
+ return {
117
+ "reference": self.reference,
118
+ "error": self.error,
119
+ "changed": self.changed,
120
+ "ok": self.ok,
121
+ "units": [u.to_dict() for u in self.units],
122
+ }
123
+
124
+ def summary(self) -> str:
125
+ counts: dict[str, int] = {}
126
+ for unit in self.units:
127
+ counts[unit.action] = counts.get(unit.action, 0) + 1
128
+ if self.error:
129
+ return f"units: {self.error}"
130
+ if not counts:
131
+ return "units: nothing packaged to install"
132
+ return "units: " + ", ".join(
133
+ f"{count} {action}" for action, count in sorted(counts.items())
134
+ )
135
+
136
+
137
+ def systemd_user_dir(home: Path | None = None) -> Path:
138
+ base = home or Path.home()
139
+ return Path(str(_SYSTEMD_USER_DIR).replace("~", str(base), 1))
140
+
141
+
142
+ def _packaged_units(reference_dir: Path) -> list[Path]:
143
+ seen: set[str] = set()
144
+ out: list[Path] = []
145
+ for pattern in _UNIT_GLOBS:
146
+ for path in sorted(reference_dir.glob(pattern)):
147
+ if path.name in seen:
148
+ continue
149
+ seen.add(path.name)
150
+ out.append(path)
151
+ return sorted(out, key=lambda p: p.name)
152
+
153
+
154
+ def render_unit(text: str, *, machine_name: str | None, port: int | str | None) -> tuple[str | None, str]:
155
+ """Fill ``<PLACEHOLDER>`` tokens. Returns ``(rendered_or_None, note)``.
156
+
157
+ ``None`` means "this unit is a template with a placeholder we cannot
158
+ fill" — the caller must skip it and say so. Guessing a value here is how
159
+ a unit lands with ``<MACHINE_NAME>`` as literal text and then refuses to
160
+ start (#1928).
161
+ """
162
+ names = sorted(set(_PLACEHOLDER_RE.findall(text)))
163
+ if not names:
164
+ return text, ""
165
+ values = {"machine_name": machine_name, "port": port}
166
+ filled: dict[str, str] = {}
167
+ for name in names:
168
+ key = _PLACEHOLDER_SOURCES.get(name)
169
+ value = values.get(key) if key else None
170
+ if value in (None, ""):
171
+ fallback = _KNOWN_PLACEHOLDER_VALUES.get(name)
172
+ return None, (
173
+ f"template placeholder <{name}> has no value for this host"
174
+ + (f" (unit_drift's documented default is {fallback})" if fallback else "")
175
+ + " — refusing to install it verbatim (#1928); install this "
176
+ "unit by hand"
177
+ )
178
+ filled[name] = str(value)
179
+
180
+ def _sub(match: re.Match[str]) -> str:
181
+ return filled[match.group(1)]
182
+
183
+ return _PLACEHOLDER_RE.sub(_sub, text), f"rendered {', '.join(names)}"
184
+
185
+
186
+ def _atomic_write(path: Path, text: str) -> None:
187
+ tmp = path.with_name(path.name + ".coord-tmp")
188
+ tmp.write_text(text, encoding="utf-8")
189
+ os.replace(tmp, path)
190
+
191
+
192
+ def install_units(
193
+ *,
194
+ target_dir: Path | None = None,
195
+ reference_dir: Path | None = None,
196
+ machine_name: str | None = None,
197
+ port: int | str | None = None,
198
+ version: str | None = None,
199
+ dry_run: bool = False,
200
+ home: Path | None = None,
201
+ ) -> InstallReport:
202
+ """Refresh this host's installed systemd user units from the wheel.
203
+
204
+ *reference_dir* defaults to :func:`~coord.health.checks.unit_drift.
205
+ packaged_unit_dir` — ``coord/deploy/`` inside the *installed*
206
+ distribution, i.e. the released artifact, which cannot drift with the
207
+ host's checkout (#1927). Only units already present in *target_dir* are
208
+ rewritten; see the module docstring for why.
209
+ """
210
+ report = InstallReport()
211
+ ref = reference_dir or packaged_unit_dir()
212
+ if ref is None:
213
+ report.error = (
214
+ "this install ships no coord/deploy/ — it predates #1927, so "
215
+ "there is no released unit set to deploy from. Upgrade the "
216
+ "Python lane first."
217
+ )
218
+ return report
219
+ report.reference = str(ref)
220
+
221
+ dest_dir = target_dir or systemd_user_dir(home)
222
+ suffix = f".pre-{version}" if version else ".pre-update"
223
+
224
+ for source in _packaged_units(ref):
225
+ installed = dest_dir / source.name
226
+ if not installed.exists():
227
+ report.units.append(
228
+ UnitOutcome(
229
+ source.name,
230
+ ACTION_NEW,
231
+ "packaged but not installed on this host — a release does "
232
+ "not decide which services a host runs; install and enable "
233
+ "it by hand if this host should have it",
234
+ )
235
+ )
236
+ continue
237
+
238
+ try:
239
+ source_text = source.read_text(encoding="utf-8")
240
+ except OSError as exc:
241
+ report.units.append(
242
+ UnitOutcome(source.name, ACTION_FAILED, f"unreadable reference: {exc}")
243
+ )
244
+ continue
245
+
246
+ rendered, note = render_unit(
247
+ source_text, machine_name=machine_name, port=port
248
+ )
249
+ if rendered is None:
250
+ report.units.append(UnitOutcome(source.name, ACTION_SKIPPED, note))
251
+ continue
252
+
253
+ try:
254
+ current = installed.read_text(encoding="utf-8")
255
+ except OSError as exc:
256
+ report.units.append(
257
+ UnitOutcome(source.name, ACTION_FAILED, f"unreadable installed unit: {exc}")
258
+ )
259
+ continue
260
+
261
+ if current == rendered:
262
+ report.units.append(UnitOutcome(source.name, ACTION_UNCHANGED, note))
263
+ continue
264
+
265
+ if dry_run:
266
+ report.units.append(
267
+ UnitOutcome(source.name, ACTION_UPDATED, f"would rewrite ({note})".strip())
268
+ )
269
+ continue
270
+
271
+ backup = installed.with_name(installed.name + suffix + ".bak")
272
+ try:
273
+ shutil.copy2(installed, backup)
274
+ _atomic_write(installed, rendered)
275
+ except OSError as exc:
276
+ report.units.append(
277
+ UnitOutcome(source.name, ACTION_FAILED, f"write failed: {exc}")
278
+ )
279
+ continue
280
+ report.units.append(
281
+ UnitOutcome(
282
+ source.name,
283
+ ACTION_UPDATED,
284
+ note or "content refreshed from the packaged release",
285
+ backup=str(backup),
286
+ )
287
+ )
288
+
289
+ return report
290
+
291
+
292
+ def daemon_reload(*, runner=None, timeout: float = 30.0) -> tuple[bool, str]:
293
+ """``systemctl --user daemon-reload``. Returns ``(ok, output)``.
294
+
295
+ Split out and injectable so :func:`install_units` stays a pure filesystem
296
+ operation testable without systemd — and so a host with no systemd (a
297
+ macOS worker) degrades to a reported skip rather than a traceback.
298
+ """
299
+ import subprocess # noqa: PLC0415
300
+
301
+ run = runner or subprocess.run
302
+ try:
303
+ proc = run(
304
+ ["systemctl", "--user", "daemon-reload"],
305
+ capture_output=True,
306
+ text=True,
307
+ timeout=timeout,
308
+ )
309
+ except FileNotFoundError:
310
+ return False, "systemctl not found (no systemd on this host)"
311
+ except Exception as exc: # noqa: BLE001 — a reload must never crash a roll
312
+ return False, f"{type(exc).__name__}: {exc}"
313
+ ok = getattr(proc, "returncode", 1) == 0
314
+ out = (getattr(proc, "stderr", "") or getattr(proc, "stdout", "") or "").strip()
315
+ return ok, out or ("daemon-reload ok" if ok else "daemon-reload failed")
316
+
317
+
318
+ #: Which of :func:`install_units`'s outcomes mean "this unit is actually
319
+ #: present on this host right now" — the only units eligible to be enabled
320
+ #: below. ``ACTION_NEW`` (never installed here — safety property 1: a
321
+ #: release does not decide which services a host runs) and
322
+ #: ``ACTION_SKIPPED``/``ACTION_FAILED`` (this write did not land) are
323
+ #: deliberately excluded.
324
+ _PRESENT_ACTIONS = frozenset({ACTION_UNCHANGED, ACTION_UPDATED})
325
+
326
+
327
+ def enable_timers(
328
+ report: InstallReport, *, runner=None, timeout: float = 30.0,
329
+ ) -> dict[str, tuple[bool, str]]:
330
+ """``systemctl --user enable --now <unit>`` for every installed timer in
331
+ *report* (#2082).
332
+
333
+ #2082: ``coord-release-propagate.timer`` reached three hosts'
334
+ ``~/.config/systemd/user/`` and sat there — :func:`install_units`
335
+ refreshed its *content* on every release, but nothing ever ran
336
+ ``enable --now`` on it, and nothing noticed because a disabled timer's
337
+ file looks byte-for-byte identical to an active one's (see
338
+ :mod:`coord.health.checks.timer_active`, the detector for exactly this).
339
+
340
+ A ``.timer`` unit is different from a ``.service`` here: which
341
+ *services* a host runs is a one-time topology choice
342
+ (``install-agent.sh``, or a human at machine setup — see this module's
343
+ docstring), never something a routine refresh should override. A timer
344
+ that exists at all has no reason to exist disabled — its whole job is to
345
+ fire on a schedule with nobody watching, so "installed but not enabled"
346
+ is invisible from the outside and would otherwise self-heal never.
347
+ Applying this on every deploy makes enablement an assertion this lane
348
+ re-checks every time it runs, the same way :func:`install_units`
349
+ re-asserts content every time it runs.
350
+
351
+ Both ``enable`` and ``--now`` are idempotent — an already-enabled,
352
+ already-active timer is untouched — so calling this on every deploy is a
353
+ correctness check, not a state change, in the common case. Scoped to
354
+ units *this* report found actually installed (:data:`_PRESENT_ACTIONS`),
355
+ matching the deploy step's own "only touch what this host already runs"
356
+ rule.
357
+ """
358
+ import subprocess # noqa: PLC0415
359
+
360
+ run = runner or subprocess.run
361
+ out: dict[str, tuple[bool, str]] = {}
362
+ timers = sorted(
363
+ u.name
364
+ for u in report.units
365
+ if u.name.endswith(".timer") and u.action in _PRESENT_ACTIONS
366
+ )
367
+ for name in timers:
368
+ try:
369
+ proc = run(
370
+ ["systemctl", "--user", "enable", "--now", name],
371
+ capture_output=True,
372
+ text=True,
373
+ timeout=timeout,
374
+ )
375
+ except FileNotFoundError:
376
+ out[name] = (False, "systemctl not found (no systemd on this host)")
377
+ continue
378
+ except Exception as exc: # noqa: BLE001 — must never crash a deploy
379
+ out[name] = (False, f"{type(exc).__name__}: {exc}")
380
+ continue
381
+ ok = getattr(proc, "returncode", 1) == 0
382
+ detail = (getattr(proc, "stderr", "") or getattr(proc, "stdout", "") or "").strip()
383
+ out[name] = (ok, detail or ("enabled" if ok else "enable failed"))
384
+ return out