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/filelock.py ADDED
@@ -0,0 +1,168 @@
1
+ """``flock``-based advisory file lock, shared by every coord process (#1616).
2
+
3
+ Extracted verbatim from :mod:`coord.drive`, where it lived while the drive
4
+ was the only thing that took ``~/.coord/notify.lock``. #1616 gave the daemon
5
+ its own pipeline clock (:func:`coord.notify.run_drain`), which must serialise
6
+ against a live ``coord drive``'s nudge — and "the same lock" is only a real
7
+ claim if both sides use the same *class* on the same *path*, not two
8
+ independent reimplementations that happen to agree on a filename today.
9
+
10
+ ``coord.drive`` re-exports ``FileLock``/``LockBusy`` from here, so every
11
+ existing ``from coord.drive import FileLock`` keeps working and every existing
12
+ lock keeps its identity.
13
+
14
+ Why ``flock(2)`` and not a pidfile: ``flock`` is released by the kernel when
15
+ the holding process dies, so a killed drive (or a ``coord-serve`` restart —
16
+ exactly the deploy step #1616 requires) can never strand the pipeline behind
17
+ a lock nobody holds. The lock is advisory and per-open-file-description, so
18
+ the two callers must both go through this class for it to mean anything.
19
+
20
+ Cross-platform (#1156): POSIX uses ``fcntl.flock``; Windows has no ``fcntl``,
21
+ so it goes through ``msvcrt.locking`` instead, behind the same non-blocking
22
+ try/timeout/``LockBusy`` contract. ``msvcrt.locking`` locks a byte range
23
+ rather than the whole file, so both backends lock/unlock the same single byte
24
+ at offset 0 — irrelevant to callers, who never read/write through the lock
25
+ fd, only hold it. Whichever backend is live, the process-death safety
26
+ property above holds: both OS lock primitives are released when the holding
27
+ process exits, even if it is killed without a chance to call
28
+ :meth:`FileLock.release`.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import errno
34
+ import os
35
+ import sys
36
+ import time
37
+ from dataclasses import dataclass, field
38
+ from pathlib import Path
39
+
40
+ # ``fcntl``/``msvcrt`` are both deferred into the two functions below rather
41
+ # than imported at module level: on POSIX, ``msvcrt`` genuinely doesn't
42
+ # exist; on Windows, ``fcntl`` doesn't. Deferring means this module (and
43
+ # every top-level importer of it, notably `coord.drive`) loads cleanly on
44
+ # either platform -- only the codepath actually exercised at lock time needs
45
+ # its platform's module to be importable (#1156).
46
+
47
+
48
+ class LockBusy(Exception):
49
+ """Someone else holds the lock."""
50
+
51
+
52
+ def _lock_exclusive_nonblocking(fd: int) -> None:
53
+ """Try to take an exclusive, non-blocking lock on *fd*.
54
+
55
+ Raises ``OSError`` with an ``errno`` matching the POSIX "already locked"
56
+ codes (``EACCES``/``EAGAIN``) on contention, on both backends, so
57
+ :meth:`FileLock.acquire`'s retry/timeout loop below needs no
58
+ platform branch of its own.
59
+ """
60
+ if sys.platform == "win32":
61
+ import msvcrt # stdlib, Windows-only -- deferred for platform safety # noqa: PLC0415
62
+
63
+ try:
64
+ # ``msvcrt.locking`` locks relative to the file's current
65
+ # position; ``acquire`` always hands us a freshly-opened fd
66
+ # positioned at 0, so this locks the single byte at offset 0 —
67
+ # a stable, single-byte region every caller agrees on.
68
+ msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
69
+ except OSError as exc:
70
+ if exc.errno is not None:
71
+ # ``msvcrt.locking`` already sets a real errno (contention
72
+ # maps to EACCES/EAGAIN via CPython's Windows error table,
73
+ # same as the POSIX path below) -- propagate it untouched
74
+ # rather than relabelling genuine, non-contention failures
75
+ # (e.g. disk I/O errors) as lock contention, which would
76
+ # make FileLock.acquire's retry loop spin forever instead
77
+ # of surfacing the real error.
78
+ raise
79
+ # Defensive fallback only: some failure without an errno
80
+ # attached at all -- treat conservatively as contention so the
81
+ # retry/timeout loop above still applies.
82
+ raise OSError(errno.EACCES, exc.strerror or str(exc)) from exc
83
+ else:
84
+ import fcntl # stdlib, POSIX-only -- deferred for platform safety # noqa: PLC0415
85
+
86
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
87
+
88
+
89
+ def _unlock(fd: int) -> None:
90
+ if sys.platform == "win32":
91
+ import msvcrt # stdlib, Windows-only -- deferred for platform safety # noqa: PLC0415
92
+
93
+ os.lseek(fd, 0, os.SEEK_SET)
94
+ msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
95
+ else:
96
+ import fcntl # stdlib, POSIX-only -- deferred for platform safety # noqa: PLC0415
97
+
98
+ fcntl.flock(fd, fcntl.LOCK_UN)
99
+
100
+
101
+ @dataclass
102
+ class FileLock:
103
+ """Advisory lock, the Python twin of the bash ``flock -n`` -- ``fcntl.flock``
104
+ on POSIX, ``msvcrt.locking`` on Windows (see module docstring)."""
105
+
106
+ path: Path
107
+ _fd: int | None = field(default=None, init=False, repr=False)
108
+
109
+ def acquire(self, timeout: float | None = 0.0) -> None:
110
+ """Take the lock. ``timeout=0`` is non-blocking; ``None`` blocks forever."""
111
+ self.path.parent.mkdir(parents=True, exist_ok=True)
112
+ fd = os.open(self.path, os.O_CREAT | os.O_RDWR, 0o644)
113
+ deadline = None if timeout is None else time.monotonic() + timeout
114
+ while True:
115
+ try:
116
+ _lock_exclusive_nonblocking(fd)
117
+ self._fd = fd
118
+ return
119
+ except OSError as exc:
120
+ if exc.errno not in (errno.EACCES, errno.EAGAIN):
121
+ os.close(fd)
122
+ raise
123
+ if deadline is not None and time.monotonic() >= deadline:
124
+ os.close(fd)
125
+ raise LockBusy(str(self.path)) from exc
126
+ time.sleep(0.25)
127
+
128
+ def release(self) -> None:
129
+ if self._fd is None:
130
+ return
131
+ try:
132
+ _unlock(self._fd)
133
+ finally:
134
+ os.close(self._fd)
135
+ self._fd = None
136
+
137
+ def __enter__(self) -> FileLock:
138
+ self.acquire(timeout=None)
139
+ return self
140
+
141
+ def __exit__(self, *exc: object) -> None:
142
+ self.release()
143
+
144
+
145
+ def notify_lock_path() -> Path:
146
+ """The one lock that serialises pipeline-advancing side effects.
147
+
148
+ A function rather than a module constant so tests (and a relocated
149
+ ``$HOME``) resolve it at call time — a constant captured at import would
150
+ freeze whatever ``Path.home()`` was when the module first loaded, which on
151
+ the daemon is process start.
152
+ """
153
+ return Path.home() / ".coord" / "notify.lock"
154
+
155
+
156
+ def drive_queue_lock_path() -> Path:
157
+ """The lock that keeps ``coord drive-queue tick`` from stacking (#1754).
158
+
159
+ Deliberately NOT :func:`notify_lock_path`: a tick fetches the board and
160
+ may spend several seconds waiting for ``coord drive --tmux`` to confirm a
161
+ live session, and holding the pipeline's own lock for that would stall
162
+ ``coord notify``/``run_drain`` for no reason. A tick advances nothing by
163
+ itself — it launches a drive, and *that* drive takes the notify lock when
164
+ it nudges.
165
+
166
+ Same call-time resolution rationale as above.
167
+ """
168
+ return Path.home() / ".coord" / "drive-queue.lock"
@@ -0,0 +1,300 @@
1
+ """Fleet ``coordinator.yml`` provenance: is the live config still the one that
2
+ was reviewed? (#1779)
3
+
4
+ ``coordinator.yml`` used to be a plain file at ``~/.coord/coordinator.yml`` on
5
+ the daemon host, hand-copied from a tracked copy elsewhere — real, and
6
+ undetectable, content drift. That copy has since been replaced by a
7
+ **symlink** into the ``JDonaghy/coord-settings`` checkout
8
+ (``~/src/coord-settings/coord/coordinator.yml`` by default), so the live file
9
+ *is* the tracked file and content drift is structurally impossible. That
10
+ closes one hole and opens three narrower ones, none visible from a running
11
+ fleet:
12
+
13
+ 1. **The symlink gets replaced by a regular file.** ``coord init`` offers to
14
+ overwrite ``coordinator.yml`` (``coord/commands/setup.py``), and any
15
+ ``scp``, ``cp``, or editor that writes-and-renames breaks the link. The
16
+ fleet is then silently back to running an untracked file — this is the
17
+ highest-value check here, and the loudest finding.
18
+ 2. **The checkout is dirty.** A direct edit to the live path writes *through*
19
+ the symlink into the checkout's working tree — recoverable, but the
20
+ running config now has uncommitted changes nobody has reviewed.
21
+ 3. **The checkout is behind (or ahead of) ``origin``.** Someone pushed a
22
+ config change that was never pulled onto the daemon host (or committed
23
+ locally but never pushed), so the reviewed intent and the running fleet
24
+ disagree.
25
+
26
+ A fourth state is *not* a problem: **no checkout at all.** The coord-settings
27
+ checkout is deliberately absent from every machine except the daemon host and
28
+ the operator's box (agents, thin clients, and every ephemeral Azure worker
29
+ have no reason to carry it, and — see #1779's cross-repo constraint — a
30
+ dispatched worker must never be able to edit the file governing its own
31
+ concurrency limits and review gates). :func:`config_provenance` reports that
32
+ as a neutral skip, never a warning.
33
+
34
+ Mirrors :mod:`coord.graph_health`'s shape (``coord diagnose --graph``'s "is
35
+ the artifact current?" check): a single read-only, best-effort probe function
36
+ plus a renderer, wired into ``coord diagnose`` as another local-machine sweep
37
+ alongside ``--graph``/``--orphan-worktrees``. No network access is required —
38
+ sync-vs-``origin`` is judged against the existing remote-tracking ref, never a
39
+ fresh ``git fetch``.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import os
45
+ import subprocess
46
+ from dataclasses import dataclass, field
47
+ from pathlib import Path
48
+
49
+ # The tracked config's path inside the coord-settings checkout.
50
+ TRACKED_CONFIG_REL = Path("coord") / "coordinator.yml"
51
+
52
+
53
+ def default_settings_dir() -> Path:
54
+ """``$COORD_SETTINGS_DIR``, defaulting to ``~/src/coord-settings``.
55
+
56
+ Computed fresh on every call (not a module-level constant) so a test can
57
+ override it via ``monkeypatch.setenv`` without also having to fight a
58
+ value baked in at import time.
59
+ """
60
+ env = os.environ.get("COORD_SETTINGS_DIR")
61
+ return Path(env).expanduser() if env else Path.home() / "src" / "coord-settings"
62
+
63
+
64
+ def default_live_config_path() -> Path:
65
+ """Where the daemon's live ``coordinator.yml`` lives.
66
+
67
+ Mirrors the first two steps of :func:`coord.config.resolve_config_path`
68
+ (``$COORD_CONFIG``, then ``~/.coord/coordinator.yml``), computed fresh at
69
+ call time rather than reusing ``coord.config.USER_CONFIG_PATH`` — that is
70
+ a module-level constant fixed at import time against whatever ``$HOME``
71
+ was then, so it would not follow a test's environment override.
72
+
73
+ Deliberately excludes ``resolve_config_path``'s third step
74
+ (``./coordinator.yml`` in cwd): that is a repo-checkout dev convenience
75
+ that the running daemon never uses, and folding it in here would let a
76
+ stray dev ``coordinator.yml`` in whatever directory this check happens to
77
+ run from masquerade as the fleet's live config.
78
+ """
79
+ env = os.environ.get("COORD_CONFIG")
80
+ if env:
81
+ return Path(env).expanduser()
82
+ return Path.home() / ".coord" / "coordinator.yml"
83
+
84
+
85
+ @dataclass
86
+ class ConfigProvenance:
87
+ """Provenance of one machine's live ``coordinator.yml``."""
88
+
89
+ live_path: Path
90
+ checkout_dir: Path
91
+ # False on every machine with no coord-settings checkout — the expected,
92
+ # neutral state everywhere except the daemon host / operator box.
93
+ checkout_present: bool = False
94
+ live_exists: bool = False
95
+ is_symlink: bool = False
96
+ resolved_target: Path | None = None
97
+ # True only when the symlink resolves to THIS checkout's tracked
98
+ # coord/coordinator.yml — not just "somewhere inside the checkout".
99
+ in_checkout: bool = False
100
+ dirty: bool = False
101
+ dirty_files: list[str] = field(default_factory=list)
102
+ ahead: int = 0
103
+ behind: int = 0
104
+ upstream: str | None = None
105
+ sync_unknown_reason: str | None = None
106
+
107
+ @property
108
+ def skip(self) -> bool:
109
+ """No coord-settings checkout here — nothing to check, not a problem."""
110
+ return not self.checkout_present
111
+
112
+ @property
113
+ def regression(self) -> bool:
114
+ """The highest-value finding: the live config is no longer a symlink
115
+ into the reviewed checkout (#1779's central failure mode)."""
116
+ return self.checkout_present and not self.in_checkout
117
+
118
+ @property
119
+ def in_sync(self) -> bool:
120
+ return self.sync_unknown_reason is None and self.ahead == 0 and self.behind == 0
121
+
122
+ @property
123
+ def healthy(self) -> bool:
124
+ return (
125
+ self.checkout_present
126
+ and self.in_checkout
127
+ and not self.dirty
128
+ and self.sync_unknown_reason is None
129
+ and self.behind == 0
130
+ )
131
+
132
+
133
+ def _git(checkout_dir: Path, *args: str) -> subprocess.CompletedProcess | None:
134
+ try:
135
+ return subprocess.run(
136
+ ["git", *args],
137
+ cwd=str(checkout_dir),
138
+ capture_output=True,
139
+ text=True,
140
+ timeout=10.0,
141
+ )
142
+ except (subprocess.SubprocessError, OSError):
143
+ return None
144
+
145
+
146
+ def config_provenance(
147
+ *, live_path: Path | None = None, checkout_dir: Path | None = None
148
+ ) -> ConfigProvenance:
149
+ """Provenance of *live_path* (default: this machine's live
150
+ ``coordinator.yml``) against *checkout_dir* (default: the coord-settings
151
+ checkout).
152
+
153
+ Read-only and best-effort: git failures, a missing checkout, or a missing
154
+ live file all return a populated :class:`ConfigProvenance` rather than
155
+ raising. Never touches the network — sync-vs-origin is read from the
156
+ existing remote-tracking ref, never a fresh ``git fetch``.
157
+ """
158
+ live_path = live_path or default_live_config_path()
159
+ checkout_dir = checkout_dir or default_settings_dir()
160
+ prov = ConfigProvenance(live_path=live_path, checkout_dir=checkout_dir)
161
+
162
+ # #1779: the checkout's mere presence is the whole "is this machine in
163
+ # scope" signal — absent on every machine except the daemon host and the
164
+ # operator box. Anything past this point only runs where it's present.
165
+ if not (checkout_dir / ".git").exists():
166
+ return prov
167
+ prov.checkout_present = True
168
+
169
+ prov.is_symlink = live_path.is_symlink()
170
+ prov.live_exists = live_path.exists() # follows the symlink; False if dangling/absent
171
+
172
+ tracked_path = checkout_dir / TRACKED_CONFIG_REL
173
+ if prov.is_symlink:
174
+ try:
175
+ prov.resolved_target = live_path.resolve()
176
+ except OSError:
177
+ prov.resolved_target = None
178
+ if prov.resolved_target is not None:
179
+ prov.in_checkout = prov.resolved_target == tracked_path.resolve()
180
+
181
+ if not prov.in_checkout:
182
+ # Regression case (not a symlink at all, or a symlink pointing
183
+ # somewhere else) — nothing downstream (dirty/sync) is meaningful
184
+ # against a file that isn't even the tracked one.
185
+ return prov
186
+
187
+ # ── Checkout clean? ──────────────────────────────────────────────────
188
+ status = _git(checkout_dir, "status", "--porcelain", "--", str(TRACKED_CONFIG_REL))
189
+ if status is not None and status.returncode == 0:
190
+ out = status.stdout.strip()
191
+ if out:
192
+ prov.dirty = True
193
+ prov.dirty_files = [line for line in out.splitlines() if line.strip()]
194
+
195
+ # ── In sync with origin? (no fetch — existing remote-tracking ref only) ─
196
+ upstream = _git(checkout_dir, "rev-parse", "--abbrev-ref", "@{upstream}")
197
+ if upstream is None or upstream.returncode != 0:
198
+ prov.sync_unknown_reason = (
199
+ "no upstream tracking ref configured for the coord-settings checkout"
200
+ )
201
+ return prov
202
+ prov.upstream = upstream.stdout.strip()
203
+
204
+ counts = _git(
205
+ checkout_dir, "rev-list", "--left-right", "--count", f"{prov.upstream}...HEAD"
206
+ )
207
+ if counts is None or counts.returncode != 0:
208
+ prov.sync_unknown_reason = f"could not compare HEAD to {prov.upstream}"
209
+ return prov
210
+ parts = counts.stdout.split()
211
+ if len(parts) != 2:
212
+ prov.sync_unknown_reason = (
213
+ f"unexpected `git rev-list` output comparing HEAD to {prov.upstream}"
214
+ )
215
+ return prov
216
+ try:
217
+ prov.behind, prov.ahead = int(parts[0]), int(parts[1])
218
+ except ValueError:
219
+ prov.sync_unknown_reason = (
220
+ f"unexpected `git rev-list` output comparing HEAD to {prov.upstream}"
221
+ )
222
+ return prov
223
+
224
+
225
+ def format_provenance_lines(prov: ConfigProvenance) -> list[str]:
226
+ """Human-readable report lines for *prov* (used by ``coord diagnose
227
+ --config-provenance``). Each of the three failure modes gets its own,
228
+ distinctly-worded line — never collapsed into one generic "drift" line."""
229
+ lines: list[str] = []
230
+
231
+ if not prov.checkout_present:
232
+ lines.append(
233
+ f"· no coord-settings checkout at {prov.checkout_dir} — skipping "
234
+ "config provenance (expected on every machine except the daemon "
235
+ "host and the operator box)"
236
+ )
237
+ return lines
238
+
239
+ fix = f"ln -sf {prov.checkout_dir / TRACKED_CONFIG_REL} {prov.live_path}"
240
+ if not prov.is_symlink:
241
+ exists_note = "exists as a REGULAR FILE" if prov.live_exists else "does not exist"
242
+ lines.append(
243
+ f"✗ REGRESSION: {prov.live_path} is NOT a symlink into "
244
+ f"{prov.checkout_dir} — it {exists_note}. The fleet is running "
245
+ f"an untracked config again. Fix: {fix}"
246
+ )
247
+ return lines
248
+ if not prov.in_checkout:
249
+ lines.append(
250
+ f"✗ REGRESSION: {prov.live_path} is a symlink, but resolves to "
251
+ f"{prov.resolved_target} — not {prov.checkout_dir / TRACKED_CONFIG_REL}. "
252
+ f"Fix: {fix}"
253
+ )
254
+ return lines
255
+
256
+ lines.append(f"✓ {prov.live_path} → {prov.resolved_target} (symlinked into the checkout)")
257
+
258
+ if prov.dirty:
259
+ lines.append(
260
+ f"⚠ uncommitted changes to {TRACKED_CONFIG_REL} in {prov.checkout_dir} "
261
+ f"({len(prov.dirty_files)} line(s) of `git status --porcelain`) — the "
262
+ "running config has changes nobody has reviewed"
263
+ )
264
+ else:
265
+ lines.append(f"✓ {prov.checkout_dir}: checkout is clean (no uncommitted config changes)")
266
+
267
+ if prov.sync_unknown_reason:
268
+ lines.append(f"? sync vs origin unknown — {prov.sync_unknown_reason}")
269
+ elif prov.behind and prov.ahead:
270
+ lines.append(
271
+ f"⚠ diverged from {prov.upstream}: {prov.behind} commit(s) behind, "
272
+ f"{prov.ahead} ahead — reconcile on the daemon host"
273
+ )
274
+ elif prov.behind:
275
+ lines.append(
276
+ f"⚠ {prov.behind} commit(s) behind {prov.upstream} — reviewed config "
277
+ f"not yet deployed. Fix: git -C {prov.checkout_dir} pull"
278
+ )
279
+ elif prov.ahead:
280
+ lines.append(
281
+ f"⚠ {prov.ahead} commit(s) ahead of {prov.upstream} — local commit(s) "
282
+ "not yet pushed"
283
+ )
284
+ else:
285
+ lines.append(f"✓ in sync with {prov.upstream}")
286
+
287
+ return lines
288
+
289
+
290
+ def summary_line(prov: ConfigProvenance) -> str:
291
+ """The machine-readable trailer ``coord diagnose --config-provenance``
292
+ prints, mirroring ``GRAPH_HEALTH:``/``DIAGNOSE_RESULT:``."""
293
+ if not prov.checkout_present:
294
+ return "CONFIG_PROVENANCE: checkout=absent skip=true"
295
+ return (
296
+ "CONFIG_PROVENANCE: checkout=present "
297
+ f"symlinked={'true' if prov.in_checkout else 'false'} "
298
+ f"dirty={'true' if prov.dirty else 'false'} "
299
+ f"behind={prov.behind} ahead={prov.ahead}"
300
+ )
coord/freshness.py ADDED
@@ -0,0 +1,206 @@
1
+ """Compare agent-reported local repo state against GitHub HEADs.
2
+
3
+ Pure logic — no IO. Callers pass in the data they fetched from agents and
4
+ GitHub; this module decides what's stale, current, dirty, or unknown.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from typing import Iterable
11
+
12
+ from coord.config import Config
13
+ from coord.deps import build_dep_graph, transitive_deps
14
+ from coord.models import Proposal
15
+
16
+ CURRENT = "current"
17
+ STALE = "stale"
18
+ DIRTY = "dirty"
19
+ MISSING = "missing"
20
+ UNKNOWN = "unknown"
21
+
22
+ # #268: kind of cross-repo relationship a freshness entry came from.
23
+ # `build` = transitive `depends_on` — required for the build.
24
+ # `reference` = `reference_repos` — pulled for context only.
25
+ BUILD = "build"
26
+ REFERENCE = "reference"
27
+
28
+
29
+ @dataclass
30
+ class RepoFreshness:
31
+ repo_name: str
32
+ state: str # one of the constants above
33
+ local_sha: str | None = None
34
+ remote_sha: str | None = None
35
+ branch: str | None = None
36
+ dirty: bool = False
37
+ error: str | None = None
38
+ # #268: where this entry came from in `coordinator.yml`. Drives the
39
+ # briefing addendum so the worker knows whether to actually USE the
40
+ # pulled code or just READ it for context. Defaults to `BUILD` for
41
+ # back-compat with existing call sites that don't supply a kind.
42
+ kind: str = BUILD
43
+
44
+ @property
45
+ def needs_pull(self) -> bool:
46
+ return self.state == STALE
47
+
48
+
49
+ def compare(
50
+ repo_name: str,
51
+ local_info: dict | None,
52
+ remote_sha: str | None,
53
+ *,
54
+ kind: str = BUILD,
55
+ ) -> RepoFreshness:
56
+ """Classify one repo given its agent-reported local info and the remote SHA.
57
+
58
+ `local_info` is the dict returned by `AgentServer.list_repos()[repo_name]`
59
+ (or None if the agent had no entry). `remote_sha` is the full SHA on
60
+ GitHub's default branch (or None if lookup failed).
61
+
62
+ `kind` (#268) tags the result as either a build dep or a reference
63
+ repo so the briefing addendum can label each entry appropriately.
64
+ """
65
+ if local_info is None:
66
+ return RepoFreshness(repo_name=repo_name, state=MISSING, remote_sha=remote_sha,
67
+ error="agent did not report this repo", kind=kind)
68
+
69
+ error = local_info.get("error")
70
+ if error:
71
+ return RepoFreshness(
72
+ repo_name=repo_name,
73
+ state=MISSING,
74
+ remote_sha=remote_sha,
75
+ error=error,
76
+ kind=kind,
77
+ )
78
+
79
+ local_sha = local_info.get("sha")
80
+ branch = local_info.get("branch")
81
+ dirty = bool(local_info.get("dirty"))
82
+
83
+ if remote_sha is None:
84
+ return RepoFreshness(
85
+ repo_name=repo_name,
86
+ state=UNKNOWN,
87
+ local_sha=local_sha,
88
+ branch=branch,
89
+ dirty=dirty,
90
+ error="remote head not available",
91
+ kind=kind,
92
+ )
93
+
94
+ if dirty:
95
+ return RepoFreshness(
96
+ repo_name=repo_name,
97
+ state=DIRTY,
98
+ local_sha=local_sha,
99
+ remote_sha=remote_sha,
100
+ branch=branch,
101
+ dirty=True,
102
+ kind=kind,
103
+ )
104
+
105
+ state = CURRENT if local_sha == remote_sha else STALE
106
+ return RepoFreshness(
107
+ repo_name=repo_name,
108
+ state=state,
109
+ local_sha=local_sha,
110
+ remote_sha=remote_sha,
111
+ branch=branch,
112
+ kind=kind,
113
+ )
114
+
115
+
116
+ def relevant_repos(proposal: Proposal, config: Config) -> list[tuple[str, str]]:
117
+ """#268: compute the cross-repo set to freshen for *proposal*.
118
+
119
+ Returns ``[(repo_name, kind)]`` pairs covering:
120
+
121
+ - **Transitive `depends_on`** of the proposal's repo (tagged `BUILD`).
122
+ These walk the graph because A depending on B which depends on C
123
+ means a stale C poisons A's build.
124
+ - **Direct `reference_repos`** of the proposal's repo (tagged
125
+ `REFERENCE`). Reference entries do NOT walk transitively — they
126
+ describe a flat "you may want to look at these" list, not a build
127
+ dependency.
128
+
129
+ Entries that appear both as a build dep and a reference are
130
+ de-duplicated and kept as `BUILD` (the stricter constraint wins).
131
+ Sorted by name for stable output.
132
+ """
133
+ graph = build_dep_graph(config.repos)
134
+ build_set = transitive_deps(proposal.repo_name, graph)
135
+
136
+ own = config.repo(proposal.repo_name)
137
+ ref_set: set[str] = set()
138
+ if own is not None:
139
+ # Drop refs that are already build deps so they aren't tagged twice.
140
+ ref_set = {r for r in own.reference_repos if r not in build_set}
141
+
142
+ pairs: list[tuple[str, str]] = []
143
+ for name in sorted(build_set):
144
+ pairs.append((name, BUILD))
145
+ for name in sorted(ref_set):
146
+ pairs.append((name, REFERENCE))
147
+ return pairs
148
+
149
+
150
+ def dependency_freshness(
151
+ proposal: Proposal,
152
+ config: Config,
153
+ repo_heads: dict[str, dict],
154
+ github_heads: dict[str, str | None],
155
+ ) -> list[RepoFreshness]:
156
+ """Freshness for every cross-repo entry relevant to *proposal*.
157
+
158
+ Covers the transitive `depends_on` set (build deps) AND the direct
159
+ `reference_repos` set (context, #268). ``repo_heads`` is what the
160
+ target agent returned from /repos; ``github_heads`` maps repo_name
161
+ to remote SHA (None when the lookup failed).
162
+ """
163
+ return [
164
+ compare(name, repo_heads.get(name), github_heads.get(name), kind=kind)
165
+ for name, kind in relevant_repos(proposal, config)
166
+ ]
167
+
168
+
169
+ def stale_or_dirty(freshness: Iterable[RepoFreshness]) -> list[RepoFreshness]:
170
+ """Filter to entries that need user attention."""
171
+ return [f for f in freshness if f.state in (STALE, DIRTY, MISSING)]
172
+
173
+
174
+ def format_briefing_addendum(freshness: list[RepoFreshness]) -> str:
175
+ """Markdown block to append to a worker's briefing when deps are stale.
176
+
177
+ #268: build deps are tagged with **(build dep)** so the worker knows
178
+ to actually pull + build against them; reference repos are tagged
179
+ **(reference)** so the worker knows it can read them for context but
180
+ shouldn't expect them to be part of the build.
181
+ """
182
+ needs = stale_or_dirty(freshness)
183
+ if not needs:
184
+ return ""
185
+ lines = [
186
+ "",
187
+ "### Stale dependencies",
188
+ "Before building, pull these repos. Entries tagged "
189
+ "*(reference)* are not part of this repo's build — they're "
190
+ "siblings the worker may want to read for context.",
191
+ ]
192
+ for f in needs:
193
+ tag = " *(reference)*" if f.kind == REFERENCE else " *(build dep)*"
194
+ if f.state == STALE:
195
+ lines.append(
196
+ f"- `{f.repo_name}`{tag} (local {f.local_sha[:7] if f.local_sha else '?'} "
197
+ f"→ remote {f.remote_sha[:7] if f.remote_sha else '?'})"
198
+ )
199
+ elif f.state == DIRTY:
200
+ lines.append(
201
+ f"- `{f.repo_name}`{tag} is **dirty** on the agent — "
202
+ "uncommitted changes; do not pull, surface to operator"
203
+ )
204
+ else:
205
+ lines.append(f"- `{f.repo_name}`{tag}: {f.error or 'unknown state'}")
206
+ return "\n".join(lines)