code-coordinator 0.5.46__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
coord/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
|