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/branch_model.py ADDED
@@ -0,0 +1,214 @@
1
+ """Pipeline v2 Phase 4 git model (#934, docs/PIPELINE_V2.md "Git model").
2
+
3
+ Resolves the *base branch* a milestone issue's work should branch off of,
4
+ a PR should diff/merge against, and a review should compare to — instead of
5
+ the flat ``repo.default_branch`` every dispatch/review/merge/reconcile call
6
+ site assumed before #934.
7
+
8
+ The model: a repo that opts in (sets ``Repo.develop_branch`` in
9
+ ``coordinator.yml``) gets a ``develop`` integration branch; each milestone
10
+ gets its own ``feature/ms-NN`` branch off ``develop``; issues that belong to
11
+ that milestone (its GitHub Milestone, per ``coord.github_ops.get_issue``'s
12
+ ``milestone`` field) branch off ``feature/ms-NN`` and merge back into it.
13
+ ``feature/ms-NN`` itself only merges to ``develop`` via ``coord milestone
14
+ ship`` (Gate D, ``coord/commands/milestone.py``), gated on Gate B + Gate C.
15
+
16
+ **Opt-in, not a rewrite.** A repo that never sets ``develop_branch`` — or an
17
+ issue with no GitHub Milestone — resolves to exactly today's behavior
18
+ (``repo.default_branch or "main"``). This is deliberate: it's the guard
19
+ against breaking in-flight work on ``main`` the moment one repo adopts the
20
+ new model (#934's own "migration story" requirement). Every call site also
21
+ skips the milestone-number lookup entirely (no `gh` call at all) when the
22
+ repo hasn't set ``develop_branch`` — a repo that hasn't opted in pays zero
23
+ extra cost, network or otherwise.
24
+
25
+ Wired into the 5 call sites named in #934's scope: ``coord/dispatch.py``
26
+ (worker branch-from), ``coord/review.py`` (PR base + diff base),
27
+ ``coord/merge_queue.py`` (merge target), ``coord/reconcile.py`` (retry /
28
+ stale-PR default-branch comparisons). Deliberately NOT wired (yet) into
29
+ ``coord/commands/dispatch_workers.py``'s interactive dispatch surface
30
+ (``--review-of``/``--smoke-of``/``--fix-of``/``--merge-of``/etc.) — same
31
+ mechanical swap, much larger surface, left for a follow-up issue per #934's
32
+ own "expect to decompose further when picked up" guidance.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from typing import TYPE_CHECKING, Callable
38
+
39
+ from coord import github_ops
40
+
41
+ if TYPE_CHECKING:
42
+ from coord.models import Repo
43
+
44
+ __all__ = [
45
+ "feature_branch_name",
46
+ "resolve_base_branch",
47
+ "resolve_base_branch_for_issue",
48
+ "resolve_base_branch_for_issue_number",
49
+ "ensure_feature_branch_exists",
50
+ "fetch_issue_milestone_number",
51
+ ]
52
+
53
+ # (repo_github, branch) -> exists on remote. Mirrors github_ops.branch_exists_on_remote.
54
+ BranchExistsFetch = Callable[[str, str], bool]
55
+ # (repo_github, branch) -> tip SHA. Mirrors github_ops.get_default_branch_head.
56
+ BranchShaFetch = Callable[[str, str], str]
57
+ # (repo_github, branch, sha) -> True on success. Mirrors a new github_ops.create_remote_branch.
58
+ BranchCreate = Callable[[str, str, str], bool]
59
+
60
+
61
+ def feature_branch_name(milestone_number: int) -> str:
62
+ """The per-milestone feature branch name: ``feature/ms-NN``."""
63
+ return f"feature/ms-{milestone_number}"
64
+
65
+
66
+ def resolve_base_branch(repo: "Repo", milestone_number: int | None) -> str:
67
+ """The branch a milestone issue's work/review/merge should target.
68
+
69
+ Returns ``feature_branch_name(milestone_number)`` only when *repo* has
70
+ opted into the git model (``develop_branch`` set) **and** the issue
71
+ belongs to a known milestone. Otherwise falls back to
72
+ ``repo.default_branch or "main"`` — today's behavior, unchanged.
73
+
74
+ Reads both attributes via ``getattr`` (default ``None``) rather than
75
+ direct attribute access — some test doubles construct a minimal
76
+ ``Repo``-shaped stand-in that predates ``develop_branch``, and a
77
+ fail-open default (today's flat ``default_branch`` behavior) is exactly
78
+ what a repo without the field should get anyway.
79
+ """
80
+ develop_branch = getattr(repo, "develop_branch", None)
81
+ if develop_branch and milestone_number is not None:
82
+ return feature_branch_name(milestone_number)
83
+ return getattr(repo, "default_branch", None) or "main"
84
+
85
+
86
+ def fetch_issue_milestone_number(
87
+ repo_github: str, issue_number: int, *, cache: dict | None = None,
88
+ ) -> int | None:
89
+ """Best-effort fetch of an issue's GitHub Milestone number.
90
+
91
+ ``None`` when the issue has no milestone OR the fetch fails — **fail
92
+ open**, mirroring ``coord.github_ops.work_is_terminal``: a transient
93
+ ``gh`` error must never block a legitimate dispatch/review/merge/
94
+ reconcile pass, it just falls back to :func:`resolve_base_branch`'s
95
+ ``default_branch`` behavior for that one call.
96
+
97
+ *cache* — optional ``dict`` keyed by ``(repo_github, issue_number)``,
98
+ shared across a batch (e.g. one ``enqueue_approved_work`` pass), so a
99
+ burst of entries for the same repo/issue costs one ``gh`` round-trip.
100
+ Shared-module-level fetchers (``coord.review._fetch_issue_milestone_
101
+ number`` and friends) delegate here rather than duplicating the ``gh``
102
+ call, so every call site fails open the same way.
103
+
104
+ Callers should only invoke this when the repo has opted into the git
105
+ model (``repo.develop_branch`` set) — see each call site's own guard —
106
+ so a repo that hasn't opted in never pays for this ``gh`` round-trip at
107
+ all, not even a fail-open one.
108
+ """
109
+ key = (repo_github, issue_number)
110
+ if cache is not None and key in cache:
111
+ return cache[key]
112
+ try:
113
+ issue_data = github_ops.get_issue(repo_github, issue_number)
114
+ milestone = issue_data.get("milestone") or {}
115
+ result = milestone.get("number") if isinstance(milestone, dict) else None
116
+ except Exception: # noqa: BLE001 — fail-open: a transient gh error (incl.
117
+ # subprocess.TimeoutExpired on a `gh` hang, json.JSONDecodeError on
118
+ # truncated output — neither is a RuntimeError) must never abort a
119
+ # whole dispatch/review/merge/reconcile batch pass over one bad
120
+ # milestone lookup. Mirrors github_ops.get_branch_diff_size's broad
121
+ # "fail open, unknown is not blocking" catch.
122
+ result = None
123
+ if cache is not None:
124
+ cache[key] = result
125
+ return result
126
+
127
+
128
+ def resolve_base_branch_for_issue(repo: "Repo", issue_data: dict) -> str:
129
+ """Like :func:`resolve_base_branch`, deriving the milestone from a
130
+ ``coord.github_ops.get_issue()``-shaped dict (its ``milestone`` field —
131
+ ``None`` or ``{"number": ..., "title": ...}``).
132
+
133
+ Callers that already fetched the issue (nearly every call site in
134
+ ``dispatch.py``/``review.py``/``merge_queue.py``/``reconcile.py`` does,
135
+ for the title/body) get milestone-awareness for free — no extra GitHub
136
+ call, no new field threaded through ``Assignment``/DB persistence.
137
+ """
138
+ milestone = issue_data.get("milestone") or {}
139
+ milestone_number = milestone.get("number") if isinstance(milestone, dict) else None
140
+ return resolve_base_branch(repo, milestone_number)
141
+
142
+
143
+ def resolve_base_branch_for_issue_number(
144
+ repo: "Repo", repo_github: str, issue_number: int, *, cache: dict | None = None,
145
+ ) -> str:
146
+ """Like :func:`resolve_base_branch_for_issue`, but for a call site that
147
+ only has an issue *number* rather than an already-fetched issue dict.
148
+
149
+ #1479-review: unifies "does this repo opt into the git model, and if so
150
+ which milestone/base branch does this issue resolve to" for call sites
151
+ that hadn't already fetched the full issue payload —
152
+ ``coord.merge_queue.enqueue_approved_work`` and
153
+ ``coord.state._stamp_test_staleness_anchor`` each reimplemented this
154
+ guard-then-lookup slightly differently before this helper existed
155
+ (one via :func:`fetch_issue_milestone_number` directly, the other via
156
+ :func:`resolve_base_branch_for_issue` plus a raw ``get_issue`` call). A
157
+ single implementation means the two can't silently drift apart.
158
+
159
+ Skips the milestone lookup entirely (no ``gh`` call at all) when the repo
160
+ hasn't opted into the git model (``repo.develop_branch`` unset) —
161
+ mirrors :func:`resolve_base_branch`'s own zero-extra-cost guarantee for
162
+ repos that haven't adopted it. *cache* is forwarded unchanged to
163
+ :func:`fetch_issue_milestone_number` for batch call sites that want to
164
+ dedupe repeated lookups across a pass.
165
+ """
166
+ if not getattr(repo, "develop_branch", None):
167
+ return getattr(repo, "default_branch", None) or "main"
168
+ milestone_number = fetch_issue_milestone_number(repo_github, issue_number, cache=cache)
169
+ return resolve_base_branch(repo, milestone_number)
170
+
171
+
172
+ def ensure_feature_branch_exists(
173
+ repo: "Repo",
174
+ milestone_number: int,
175
+ *,
176
+ exists: BranchExistsFetch = github_ops.branch_exists_on_remote,
177
+ get_sha: BranchShaFetch = github_ops.get_default_branch_head,
178
+ create: BranchCreate = github_ops.create_remote_branch,
179
+ ) -> str:
180
+ """Idempotently create ``feature/ms-NN`` off ``repo.develop_branch`` if
181
+ it doesn't already exist on the remote. Returns the branch name.
182
+
183
+ Raises ``ValueError`` if *repo* hasn't opted into the git model
184
+ (``develop_branch`` unset) — callers should only invoke this after
185
+ confirming ``repo.develop_branch`` is set (mirrors the guard in
186
+ :func:`resolve_base_branch`). Raises ``RuntimeError`` if *create* reports
187
+ failure (e.g. ``github_ops.create_remote_branch`` returning ``False`` on
188
+ a permissions error, transient API failure, or branch-protection
189
+ rejecting a direct ref-create) — callers must not treat the returned
190
+ branch name as guaranteed to exist on the remote unless this call
191
+ returns normally.
192
+ """
193
+ if not repo.develop_branch:
194
+ raise ValueError(
195
+ f"repo {repo.name!r} has no develop_branch configured — "
196
+ "cannot create a feature branch for it"
197
+ )
198
+ branch = feature_branch_name(milestone_number)
199
+ if exists(repo.github, branch):
200
+ return branch
201
+ sha = get_sha(repo.github, repo.develop_branch)
202
+ if not create(repo.github, branch, sha):
203
+ # github_ops.create_remote_branch catches its own RuntimeError and
204
+ # returns False on failure rather than raising (permissions,
205
+ # transient API error, a race, branch-protection requiring a PR
206
+ # instead of a direct ref-create) — check the return value and raise
207
+ # here so callers (e.g. milestone_dispatch.dispatch_entry's
208
+ # `except (ValueError, RuntimeError)`) actually catch it, instead of
209
+ # silently returning a branch name that doesn't exist on the remote.
210
+ raise RuntimeError(
211
+ f"failed to create feature branch {branch!r} for repo "
212
+ f"{repo.name!r} off {repo.develop_branch!r}"
213
+ )
214
+ return branch
coord/cargo_cache.py ADDED
@@ -0,0 +1,258 @@
1
+ """#1402: a shared, per-machine cargo target directory with a bounded GC.
2
+
3
+ Every Rust assignment used to pay a cold ``cargo`` build. Workers run inside an
4
+ ephemeral ``~/.coord/worktrees/<assignment_id>`` checkout, so cargo's default
5
+ ``<workspace>/target`` lived *inside* the worktree and
6
+ :meth:`AgentServer._cleanup_worktree` deleted it along with the tree. Two
7
+ workers on the same machine, on the same repo, shared nothing either — each
8
+ worktree was its own cold build. Measured on ``tui/``: ~3 min cold vs ~18 s
9
+ warm.
10
+
11
+ This module points every worker on a machine at one cache per repo::
12
+
13
+ ~/.coord/cargo-target/<repo_name>/
14
+
15
+ Concurrency is cargo's problem and cargo already solves it: it takes a build
16
+ lock on the target dir, so two workers building the same repo at the same time
17
+ block each other briefly rather than corrupting the cache. Correctness across
18
+ branches is likewise cargo's: it keys artifacts on a fingerprint of the source,
19
+ profile, features and rustc version, so a stale artifact from another branch is
20
+ rebuilt rather than reused.
21
+
22
+ Because the cache now *outlives* the worktree, it needs a bound. :func:`sweep`
23
+ is a least-recently-used GC: it totals the per-repo cache directories and
24
+ evicts whole directories, oldest-used first, until the total is back under the
25
+ cap (default 20 GiB, override with ``COORD_CARGO_CACHE_CAP_GB``). Evicting a
26
+ whole repo cache is safe by construction — it is a cache, and the next build
27
+ repopulates it. Caches belonging to a repo with a live assignment are never
28
+ evicted, so the GC can't delete a target dir out from under a running build.
29
+
30
+ Set ``COORD_SHARED_CARGO_TARGET=0`` to disable the whole feature; an operator
31
+ who exports their own ``CARGO_TARGET_DIR`` also wins (we never override it).
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import os
37
+ import re
38
+ import shutil
39
+ from pathlib import Path
40
+
41
+ # Directory under the agent state dir (``~/.coord``) that holds one
42
+ # subdirectory per repo.
43
+ CACHE_DIRNAME = "cargo-target"
44
+
45
+ # Total cap across every per-repo cache on this machine. A cold ``tui/``
46
+ # debug build is ~2 GiB, so 20 GiB comfortably holds several repos warm.
47
+ DEFAULT_CACHE_CAP_GB = 20.0
48
+
49
+ CAP_ENV = "COORD_CARGO_CACHE_CAP_GB"
50
+ ENABLE_ENV = "COORD_SHARED_CARGO_TARGET"
51
+ CARGO_ENV = "CARGO_TARGET_DIR"
52
+
53
+ _FALSEY = {"0", "false", "no", "off", ""}
54
+
55
+ # Repo names become a path component, so they must be a single safe segment.
56
+ # Anything else (a slash, "..", a control character) disables the cache for
57
+ # that repo rather than writing outside the cache root.
58
+ _SAFE_COMPONENT = re.compile(r"^[A-Za-z0-9._-]+$")
59
+
60
+
61
+ def _env(env: dict[str, str] | None) -> dict[str, str]:
62
+ return dict(os.environ) if env is None else env
63
+
64
+
65
+ def enabled(env: dict[str, str] | None = None) -> bool:
66
+ """True unless ``COORD_SHARED_CARGO_TARGET`` is set to a falsey value."""
67
+ raw = _env(env).get(ENABLE_ENV)
68
+ if raw is None:
69
+ return True
70
+ return raw.strip().lower() not in _FALSEY
71
+
72
+
73
+ def cache_root(state_dir: Path) -> Path:
74
+ """The per-machine cache root: ``<state_dir>/cargo-target``."""
75
+ return Path(state_dir) / CACHE_DIRNAME
76
+
77
+
78
+ def target_dir_for_repo(repo_name: str, state_dir: Path) -> Path | None:
79
+ """The shared target dir for *repo_name*, or ``None`` if the name is not a
80
+ safe single path component (never guessed, never sanitized into a
81
+ collision — an unusable name simply opts that repo out)."""
82
+ if not repo_name or not _SAFE_COMPONENT.match(repo_name):
83
+ return None
84
+ if repo_name in (".", ".."):
85
+ return None
86
+ return cache_root(state_dir) / repo_name
87
+
88
+
89
+ def cargo_env(
90
+ repo_name: str,
91
+ state_dir: Path,
92
+ base_env: dict[str, str] | None = None,
93
+ ) -> dict[str, str]:
94
+ """Environment overlay pointing cargo at the shared cache.
95
+
96
+ Returns ``{}`` (a no-op overlay) when the feature is disabled, the repo
97
+ name is unusable, or *base_env* already carries a ``CARGO_TARGET_DIR`` —
98
+ an operator's explicit choice always wins.
99
+
100
+ The directory is **not** created here: cargo does its own ``mkdir -p``, so
101
+ a repo that never invokes cargo never leaves an empty dir behind.
102
+ """
103
+ env = _env(base_env)
104
+ if not enabled(env):
105
+ return {}
106
+ if env.get(CARGO_ENV):
107
+ return {}
108
+ target = target_dir_for_repo(repo_name, state_dir)
109
+ if target is None:
110
+ return {}
111
+ return {CARGO_ENV: str(target)}
112
+
113
+
114
+ def cap_bytes(env: dict[str, str] | None = None) -> int | None:
115
+ """The GC cap in bytes, or ``None`` when GC is disabled (cap <= 0)."""
116
+ raw = _env(env).get(CAP_ENV)
117
+ gb = DEFAULT_CACHE_CAP_GB
118
+ if raw is not None:
119
+ try:
120
+ gb = float(raw)
121
+ except (TypeError, ValueError):
122
+ gb = DEFAULT_CACHE_CAP_GB
123
+ if gb <= 0:
124
+ return None
125
+ return int(gb * 1024 * 1024 * 1024)
126
+
127
+
128
+ def dir_size(path: Path) -> int:
129
+ """Total size of the regular files under *path* (symlinks not followed)."""
130
+ total = 0
131
+ for root, dirnames, filenames in os.walk(path, onerror=lambda _e: None):
132
+ # Never follow a symlinked subdirectory out of the cache.
133
+ dirnames[:] = [
134
+ d for d in dirnames if not os.path.islink(os.path.join(root, d))
135
+ ]
136
+ for name in filenames:
137
+ fp = os.path.join(root, name)
138
+ try:
139
+ st = os.lstat(fp)
140
+ except OSError:
141
+ continue
142
+ total += st.st_size
143
+ return total
144
+
145
+
146
+ def _last_used(path: Path) -> float:
147
+ """Best-effort "when was this cache last built into".
148
+
149
+ cargo rewrites files throughout a build, so the newest mtime anywhere in
150
+ the tree is a good proxy — but walking a multi-GiB tree twice is wasteful,
151
+ so we sample the shallow entries cargo always touches (the profile dirs and
152
+ their lock/fingerprint children) plus the directory itself.
153
+ """
154
+ try:
155
+ newest = path.stat().st_mtime
156
+ except OSError:
157
+ return 0.0
158
+ try:
159
+ children = list(path.iterdir())
160
+ except OSError:
161
+ return newest
162
+ for child in children:
163
+ try:
164
+ newest = max(newest, child.stat().st_mtime)
165
+ except OSError:
166
+ continue
167
+ if child.is_dir() and not child.is_symlink():
168
+ try:
169
+ grandchildren = list(child.iterdir())
170
+ except OSError:
171
+ continue
172
+ for entry in grandchildren:
173
+ try:
174
+ newest = max(newest, entry.stat().st_mtime)
175
+ except OSError:
176
+ continue
177
+ return newest
178
+
179
+
180
+ def sweep(
181
+ state_dir: Path,
182
+ *,
183
+ cap: int | None = -1,
184
+ protect_repos: "set[str] | frozenset[str] | None" = None,
185
+ dry_run: bool = False,
186
+ ) -> dict:
187
+ """Evict least-recently-used repo caches until the total is under the cap.
188
+
189
+ *cap* defaults to the sentinel ``-1`` meaning "read it from the
190
+ environment" (:func:`cap_bytes`); pass an explicit int to override, or
191
+ ``None`` to disable eviction (the sweep then only reports sizes).
192
+
193
+ *protect_repos* names repos with a live (pending/running) assignment on
194
+ this machine — their caches are never evicted, so a build in flight can't
195
+ have its target dir deleted underneath it. If the protected set alone
196
+ exceeds the cap, the sweep evicts everything it may and reports the
197
+ remaining overage via ``cargo_over_cap``; it never deletes a protected
198
+ cache.
199
+
200
+ Returns ``{"cargo_cache_bytes": B, "cargo_caches_evicted": N,
201
+ "cargo_evicted_repos": [...], "cargo_cap_bytes": C|None,
202
+ "cargo_over_cap": bool, "cargo_dry_run": bool}`` — ``cargo_cache_bytes``
203
+ is the total *after* the sweep (or, for ``dry_run``, what it would be).
204
+ """
205
+ limit = cap_bytes() if cap == -1 else cap
206
+ protected = set(protect_repos or ())
207
+ result: dict = {
208
+ "cargo_cache_bytes": 0,
209
+ "cargo_caches_evicted": 0,
210
+ "cargo_evicted_repos": [],
211
+ "cargo_cap_bytes": limit,
212
+ "cargo_over_cap": False,
213
+ "cargo_dry_run": dry_run,
214
+ }
215
+
216
+ root = cache_root(state_dir)
217
+ if not root.is_dir():
218
+ return result
219
+
220
+ entries: list[tuple[float, str, Path, int]] = []
221
+ total = 0
222
+ try:
223
+ children = sorted(root.iterdir())
224
+ except OSError:
225
+ return result
226
+ for child in children:
227
+ # Skip symlinks outright — we never chase one out of the cache root.
228
+ if child.is_symlink() or not child.is_dir():
229
+ continue
230
+ size = dir_size(child)
231
+ total += size
232
+ entries.append((_last_used(child), child.name, child, size))
233
+
234
+ result["cargo_cache_bytes"] = total
235
+ if limit is None or total <= limit:
236
+ return result
237
+
238
+ # Oldest-used first; ties broken by name so the sweep is deterministic.
239
+ entries.sort(key=lambda e: (e[0], e[1]))
240
+ evicted: list[str] = []
241
+ for _mtime, name, path, size in entries:
242
+ if total <= limit:
243
+ break
244
+ if name in protected:
245
+ continue
246
+ if not dry_run:
247
+ try:
248
+ shutil.rmtree(path)
249
+ except OSError:
250
+ continue
251
+ total -= size
252
+ evicted.append(name)
253
+
254
+ result["cargo_cache_bytes"] = total
255
+ result["cargo_caches_evicted"] = len(evicted)
256
+ result["cargo_evicted_repos"] = evicted
257
+ result["cargo_over_cap"] = total > limit
258
+ return result