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/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)
|