code-coordinator 0.5.331__py3-none-any.whl → 0.5.332__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: code-coordinator
3
- Version: 0.5.331
3
+ Version: 0.5.332
4
4
  Summary: Coordinate Claude Code workers across multiple machines and repos.
5
5
  Author-email: John Donaghy <johnfdonaghy@gmail.com>
6
6
  License-Expression: LicenseRef-FSL-1.1-MIT
@@ -1,4 +1,4 @@
1
- code_coordinator-0.5.331.dist-info/licenses/LICENSE,sha256=851BVZZwf_pEv2ydSlMmcZrwnwSS5YJ98wdLze7lyL8,4212
1
+ code_coordinator-0.5.332.dist-info/licenses/LICENSE,sha256=851BVZZwf_pEv2ydSlMmcZrwnwSS5YJ98wdLze7lyL8,4212
2
2
  coord/__init__.py,sha256=3FN2qrrxtW87xSsSD557YcMwHLDahnFK8LvqQI7Wlf4,9783
3
3
  coord/_board_mapping.py,sha256=SGq8hC-gsyXHEZe2L58qy6pyOVQvswTpQ5FzPa_vZ8w,11012
4
4
  coord/acceptance.py,sha256=v7k1e_shUIjWKolKDQ9oJv67o0O8L659El3grvcF8ns,75452
@@ -63,6 +63,7 @@ coord/interactive.py,sha256=AEK-5S2aMknpP6k3Xx-aBuTEulw0_c-gbYPEKTwUjRE,219083
63
63
  coord/issue_store.py,sha256=abywea0vpXE_BZ5F3QHLDxLKYKDYF7iQYabZhppVYto,69451
64
64
  coord/issues_sync_status.py,sha256=_6ucx7oW3TUGZZvs93wRbzR_SUQcNlz-7RptOzr4alo,9262
65
65
  coord/liveness_auditor.py,sha256=sf_xBklt37DUnrIEeD4fVp4_gWPDaXbydZgfuWAPFRE,12478
66
+ coord/machine_metrics.py,sha256=mBEciin6gdYJaPw73gY7mrDnalm01WhX7kpzt8t0NEk,9396
66
67
  coord/machine_onboard.py,sha256=BTUAC7bdERcDyaIvO2yKkQ8xPwsdR063yHUrOznmHWk,58555
67
68
  coord/machine_pause.py,sha256=6s7Dtr5zLs1qB2NWI2-cfbMdPyigK62FTtLjzmY6slE,47521
68
69
  coord/merge_queue.py,sha256=UdwyoyfE9DplS6Mia4_aY6TK5a-f933M04Ns5mVHnsw,392677
@@ -107,7 +108,7 @@ coord/revalidate.py,sha256=fQaYCvObqUawSZWMEOkVWFtYswz9PQQwSMApAyP5zeA,66266
107
108
  coord/review.py,sha256=hsY6HUif44HL3pY52H7SoqNuMMZzA_9gfHfJKnC-fWE,183282
108
109
  coord/scorecard.py,sha256=axe_doBJBBFWtN21JZDRSijwDI0xaZrt2oVYbwJPW7U,20211
109
110
  coord/self_health.py,sha256=9PLVdaEReFLUY6y2QXMGVd6U1CdHAh9_8MzduwBOzAY,10651
110
- coord/serve_app.py,sha256=SXHTyxt7PdAH_zCUE6srqUvkx6Ps1PbEbW2MjLOpsGE,507632
111
+ coord/serve_app.py,sha256=_vyNSXqAUWVfsQfYCjJRnqefczof7qKObrMnTysyUkw,509398
111
112
  coord/smoke.py,sha256=INLh9Wu57WOcCoOY4CMOcbzArIhMxMolsV2D8hAW7zw,70481
112
113
  coord/spend_ceiling.py,sha256=hfuiXlnD4or7Wqoqf6BHcOMT2CXd92FBZuVdnQVlI2M,13573
113
114
  coord/split_work.py,sha256=Gp_cNtYBe7nDxBN7cDRs8CJG3V_Y0C1QUi35fHGcvnY,8502
@@ -248,8 +249,8 @@ coord/skills/release-now/SKILL.md,sha256=u5JY0Jm0IiZYs2JGjRSqNj265CUWcIA6pMaeulb
248
249
  coord/skills/review-verdict-recovery/SKILL.md,sha256=Grg8GWkJLK20ct2YiigbTFfNOkit7tNrdRIhdocKGJk,3941
249
250
  coord/skills/tui-quadraui-workflow/SKILL.md,sha256=4hUZza9wSSnPhf0eRAOh2r_MQZxK2pXYO5g-kxmJq-U,3832
250
251
  coord/skills/update-issue/SKILL.md,sha256=VrPinEmLZrCSNDrnz96hQomyO403o5c4i0RdsnUnWA0,3642
251
- code_coordinator-0.5.331.dist-info/METADATA,sha256=isRG7tzhdNWK37Ho940K1tFu_LWHd8Sg3LrzAur5FYU,27178
252
- code_coordinator-0.5.331.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
253
- code_coordinator-0.5.331.dist-info/entry_points.txt,sha256=o-Hq8pvG7ReGePvN_4iOKjLNqiq-MJ43HbPurqjNPZo,41
254
- code_coordinator-0.5.331.dist-info/top_level.txt,sha256=w5sTrXesTCebhXyYv2qufOZGo0PoD1Lxgn0Q3MAXjh4,6
255
- code_coordinator-0.5.331.dist-info/RECORD,,
252
+ code_coordinator-0.5.332.dist-info/METADATA,sha256=1zZHh_75sV2go8WtKq1cX-YI4TfozgKZnV3-wkhwgDk,27178
253
+ code_coordinator-0.5.332.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
254
+ code_coordinator-0.5.332.dist-info/entry_points.txt,sha256=o-Hq8pvG7ReGePvN_4iOKjLNqiq-MJ43HbPurqjNPZo,41
255
+ code_coordinator-0.5.332.dist-info/top_level.txt,sha256=w5sTrXesTCebhXyYv2qufOZGo0PoD1Lxgn0Q3MAXjh4,6
256
+ code_coordinator-0.5.332.dist-info/RECORD,,
@@ -0,0 +1,217 @@
1
+ """Coordinator-side machine metrics sampler + bounded ring buffer (#3020).
2
+
3
+ Part of milestone #71 (Fleet metrics + machines API) — the backend half of
4
+ the coord-web Machines panel.
5
+
6
+ ``coord-tui`` renders CPU/mem sparklines by polling **each agent's own**
7
+ ``/metrics`` endpoint directly on port 7433 (``coord.agent_app.metrics``, a
8
+ ``psutil`` cpu/mem snapshot) and buffering samples in-process for as long as
9
+ the panel stays open (see ``coord-tui/src/app/data.rs``
10
+ ``spawn_machine_metrics``). A browser can't do that — ``coord web`` is a
11
+ single-origin HTTP client of the dashboard API, not something that can fan
12
+ out to N agent hosts — and nothing anywhere persists a series; the TUI's
13
+ ``VecDeque`` dies with the panel.
14
+
15
+ This module is the one missing thing: a coordinator-side sampler, owned by
16
+ the board daemon (``coord serve``) and driven off its existing periodic-loop
17
+ machinery (mirrors ``coord.health.fleet_snapshot.FleetHealthRefresher`` and
18
+ ``coord.gate_snapshot.GateSnapshotRefresher`` — see ``coord.serve_app``'s
19
+ ``_machine_metrics_loop``, isolated from ``_tick_loop`` for the identical
20
+ reason those two are: a fan-out HTTP GET per agent must never block, or be
21
+ blocked by, the reconcile/enqueue/drain steps).
22
+
23
+ **Bounded, not incidental (#3020).** Each machine gets its own
24
+ ``collections.deque(maxlen=...)`` ring buffer sized for a ~6h window at the
25
+ default ~15s cadence (:data:`DEFAULT_MAX_SAMPLES` = 1440) — old samples are
26
+ evicted automatically as new ones arrive; the process can never accumulate
27
+ unbounded history no matter how long the daemon stays up.
28
+
29
+ **``unknown``, never a silent gap (#1485).** A failed, timed-out, or 503
30
+ (psutil not installed) poll still appends a sample — with
31
+ ``status="unknown"`` — never nothing. A renderer building a sparkline from
32
+ this series must be able to tell "no data point" (a gap it filled in itself)
33
+ from "the daemon tried and the agent didn't answer" (an explicit unknown
34
+ sample); silently skipping the append would collapse that distinction back
35
+ into "looks the same as healthy", the exact failure mode
36
+ ``coord.health.fleet_snapshot`` already guards against for machine health.
37
+
38
+ **No persistence.** This is purely in-memory; history is lost on daemon
39
+ restart by design (a sqlite time-series was considered and deliberately
40
+ rejected for this milestone — see the issue).
41
+
42
+ **Out of scope here.** The read endpoint for this series (#A2) and putting
43
+ it on ``/board`` (explicitly rejected — every TUI client polls ``/board`` on
44
+ a short interval and ``FleetHealthSnapshot`` already carries a 256 KiB cap
45
+ for exactly this reason) are not this module's job.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import logging
51
+ import time
52
+ from collections import deque
53
+ from concurrent.futures import ThreadPoolExecutor
54
+ from dataclasses import dataclass
55
+
56
+ import httpx
57
+
58
+ from coord.network import AGENT_PORT, DEFAULT_TIMEOUT, classify_error
59
+
60
+ log = logging.getLogger("coord.serve")
61
+
62
+ # ~6h of history at the default ~15s cadence. Explicit and enforced (each
63
+ # per-machine buffer is a `deque(maxlen=...)`, not a list someone forgot to
64
+ # trim) rather than incidental.
65
+ DEFAULT_MAX_SAMPLES = 1440
66
+ DEFAULT_CADENCE_SECONDS = 15.0
67
+
68
+ STATUS_OK = "ok"
69
+ STATUS_UNKNOWN = "unknown"
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class MetricsSample:
74
+ """One machine, one point in time. ``status="unknown"`` carries no
75
+ cpu/mem values — never fabricated zeros — only a human-readable
76
+ ``reason`` for why the poll didn't produce a real reading.
77
+ """
78
+
79
+ timestamp: float
80
+ status: str # STATUS_OK | STATUS_UNKNOWN
81
+ cpu_percent: float | None = None
82
+ mem_percent: float | None = None
83
+ mem_used_mb: float | None = None
84
+ mem_total_mb: float | None = None
85
+ reason: str = ""
86
+
87
+ def to_dict(self) -> dict:
88
+ return {
89
+ "timestamp": self.timestamp,
90
+ "status": self.status,
91
+ "cpu_percent": self.cpu_percent,
92
+ "mem_percent": self.mem_percent,
93
+ "mem_used_mb": self.mem_used_mb,
94
+ "mem_total_mb": self.mem_total_mb,
95
+ "reason": self.reason,
96
+ }
97
+
98
+
99
+ def _unknown(reason: str, *, now: float | None = None) -> MetricsSample:
100
+ return MetricsSample(
101
+ timestamp=now if now is not None else time.time(),
102
+ status=STATUS_UNKNOWN,
103
+ reason=reason,
104
+ )
105
+
106
+
107
+ def fetch_metrics(machine, timeout: float = DEFAULT_TIMEOUT) -> MetricsSample: # noqa: ANN001 — coord.models.Machine
108
+ """GET one machine's agent ``/metrics`` and classify the result.
109
+
110
+ Mirrors ``coord.network.check_machine``'s error classification but
111
+ targets ``/metrics`` instead of ``/health``. Never raises: every failure
112
+ mode — connect/read timeout, DNS failure, connection refused, a non-200
113
+ status (including the documented 503 an agent returns when ``psutil``
114
+ isn't installed), and malformed/missing-field JSON — becomes an explicit
115
+ :data:`STATUS_UNKNOWN` sample rather than an exception or a dropped
116
+ point. The bounded ``timeout`` (default :data:`coord.network.
117
+ DEFAULT_TIMEOUT`, 3s) is what keeps one hanging agent from stalling this
118
+ call past that ceiling; :class:`MachineMetricsSampler.refresh` is what
119
+ keeps that bounded stall from serializing behind every other machine.
120
+ """
121
+ url = f"http://{machine.host}:{AGENT_PORT}/metrics"
122
+ try:
123
+ resp = httpx.get(url, timeout=timeout)
124
+ except Exception as e: # noqa: BLE001 — classify every network error, never raise
125
+ _, reason = classify_error(e)
126
+ return _unknown(reason)
127
+
128
+ if resp.status_code == 503:
129
+ # #207's documented shape: psutil not installed on that agent.
130
+ return _unknown("psutil not installed on agent")
131
+ if resp.status_code != 200:
132
+ return _unknown(f"HTTP {resp.status_code}")
133
+
134
+ try:
135
+ data = resp.json()
136
+ except ValueError:
137
+ return _unknown("invalid JSON from /metrics")
138
+
139
+ try:
140
+ return MetricsSample(
141
+ timestamp=time.time(),
142
+ status=STATUS_OK,
143
+ cpu_percent=float(data["cpu_percent"]),
144
+ mem_percent=float(data["mem_percent"]),
145
+ mem_used_mb=float(data["mem_used_mb"]),
146
+ mem_total_mb=float(data["mem_total_mb"]),
147
+ )
148
+ except (KeyError, TypeError, ValueError):
149
+ return _unknown("malformed /metrics payload")
150
+
151
+
152
+ class MachineMetricsSampler:
153
+ """Owns the bounded per-machine ring buffers; refreshed by the daemon tick.
154
+
155
+ ``series()``/``all_series()`` are the read path — bare in-memory reads,
156
+ no I/O, safe to call from a request handler. ``refresh(config)`` is the
157
+ only method that talks to agents and must only ever run from the
158
+ daemon's tick machinery (or a test driving it explicitly), exactly the
159
+ same split as ``coord.health.fleet_snapshot.FleetHealthRefresher``.
160
+ """
161
+
162
+ def __init__(
163
+ self,
164
+ *,
165
+ max_samples: int = DEFAULT_MAX_SAMPLES,
166
+ timeout: float = DEFAULT_TIMEOUT,
167
+ ) -> None:
168
+ if max_samples <= 0:
169
+ raise ValueError("max_samples must be positive")
170
+ self._max_samples = max_samples
171
+ self._timeout = timeout
172
+ self._buffers: dict[str, deque] = {}
173
+
174
+ def series(self, machine_name: str) -> list[dict]:
175
+ """This machine's samples, oldest first. ``[]`` if never polled."""
176
+ buf = self._buffers.get(machine_name)
177
+ return [s.to_dict() for s in buf] if buf else []
178
+
179
+ def all_series(self) -> dict[str, list[dict]]:
180
+ """Every known machine's series, oldest-first, keyed by name."""
181
+ return {name: [s.to_dict() for s in buf] for name, buf in self._buffers.items()}
182
+
183
+ def refresh(self, config) -> None: # noqa: ANN001 — coord.config.Config
184
+ """Poll every machine in ``config.machines`` once and append a sample
185
+ each — :data:`STATUS_OK` or :data:`STATUS_UNKNOWN`, never neither.
186
+
187
+ Polls concurrently (mirrors ``coord.network.check_all``): each call
188
+ is individually bounded by ``timeout``, and running them in a thread
189
+ pool rather than sequentially means one slow/hanging agent's bounded
190
+ stall does not serialize in front of the rest of the fleet's polls —
191
+ the whole ``refresh()`` call takes roughly one ``timeout``, not
192
+ ``len(machines) * timeout``, no matter which machine is the slow
193
+ one. Called off the event loop (``run_in_threadpool`` from
194
+ ``coord.serve_app``'s own loop, isolated from ``_tick_loop``) so
195
+ even that bound never touches board-read latency.
196
+ """
197
+ machines = list(getattr(config, "machines", ()) or ())
198
+ if not machines:
199
+ return
200
+
201
+ workers = min(8, len(machines))
202
+ with ThreadPoolExecutor(max_workers=workers) as pool:
203
+ samples = list(pool.map(self._poll_one, machines))
204
+
205
+ for machine, sample in zip(machines, samples):
206
+ buf = self._buffers.get(machine.name)
207
+ if buf is None:
208
+ buf = deque(maxlen=self._max_samples)
209
+ self._buffers[machine.name] = buf
210
+ buf.append(sample)
211
+
212
+ def _poll_one(self, machine) -> MetricsSample: # noqa: ANN001
213
+ try:
214
+ return fetch_metrics(machine, timeout=self._timeout)
215
+ except Exception as exc: # noqa: BLE001 — one bad machine must never abort the refresh
216
+ log.warning("machine-metrics poll: %s raised %s", machine.name, exc)
217
+ return _unknown(f"poll raised: {exc}")
coord/serve_app.py CHANGED
@@ -5340,6 +5340,15 @@ def build_app(store: CoordStore, config: Config, *, token: str | None = None) ->
5340
5340
 
5341
5341
  _fleet_health_refresher = FleetHealthRefresher()
5342
5342
 
5343
+ # #3020: same shape again — polling every agent's own /metrics is real
5344
+ # per-machine I/O, so it runs on the tick loop's cadence
5345
+ # (`_machine_metrics_loop` below), never inline off a request handler.
5346
+ # Series live only in this sampler's bounded in-memory ring buffers; no
5347
+ # read endpoint exists yet (that's #A2) and nothing here touches /board.
5348
+ from coord.machine_metrics import MachineMetricsSampler # noqa: PLC0415
5349
+
5350
+ _machine_metrics_sampler = MachineMetricsSampler()
5351
+
5343
5352
  # Short-TTL cache for the computed /board projection so burst polls from the
5344
5353
  # TUI don't each pay the full board_projection + merge-plan + stage-projection
5345
5354
  # recomputation (~465-issue load measured in the issue). Keyed to nothing
@@ -9452,6 +9461,26 @@ def build_app(store: CoordStore, config: Config, *, token: str | None = None) ->
9452
9461
  except Exception: # noqa: BLE001 — keep serving the old snapshot
9453
9462
  log.warning("fleet-health refresh failed", exc_info=True)
9454
9463
 
9464
+ # #3020: CPU/mem sampler for the coord-web Machines panel — default
9465
+ # 15s (env COORD_METRICS_POLL_INTERVAL; 0 disables). Own loop, not a
9466
+ # `_tick_loop` step, for the identical reason as `_health_refresh_loop`
9467
+ # just above: it fans out one HTTP GET per agent and must never be
9468
+ # held up behind, or hold up, the reconcile/enqueue/drain steps.
9469
+ try:
9470
+ metrics_poll_interval = float(
9471
+ os.environ.get("COORD_METRICS_POLL_INTERVAL", "15")
9472
+ )
9473
+ except ValueError:
9474
+ metrics_poll_interval = 15.0
9475
+
9476
+ async def _machine_metrics_loop() -> None:
9477
+ while True:
9478
+ await asyncio.sleep(metrics_poll_interval)
9479
+ try:
9480
+ await run_in_threadpool(_machine_metrics_sampler.refresh, config)
9481
+ except Exception: # noqa: BLE001 — a tick must never crash the daemon
9482
+ log.warning("machine-metrics refresh failed", exc_info=True)
9483
+
9455
9484
  # #2570: the #2536 phantom-row auto-heal, run a SECOND time from
9456
9485
  # inside this long-lived process rather than relying solely on
9457
9486
  # `coord-notify.timer`'s oneshot `~/.coord-venv/bin/coord notify`
@@ -10218,12 +10247,18 @@ def build_app(store: CoordStore, config: Config, *, token: str | None = None) ->
10218
10247
  else None,
10219
10248
  "auto-revalidate",
10220
10249
  )
10250
+ machine_metrics_task = _watch(
10251
+ asyncio.create_task(_machine_metrics_loop())
10252
+ if interval > 0 and metrics_poll_interval > 0
10253
+ else None,
10254
+ "machine-metrics",
10255
+ )
10221
10256
  try:
10222
10257
  yield
10223
10258
  finally:
10224
10259
  for t in (
10225
10260
  task, gate_task, health_task, phantom_heal_task,
10226
- auto_revalidate_task,
10261
+ auto_revalidate_task, machine_metrics_task,
10227
10262
  ):
10228
10263
  if t is not None:
10229
10264
  t.cancel()