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
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
"""Tick-refreshed fleet-health snapshot, aggregated onto board state (#1630).
|
|
2
|
+
|
|
3
|
+
Mirrors :mod:`coord.gate_snapshot`'s shape exactly, and for the same reason:
|
|
4
|
+
the ``/board`` read path must perform no per-request I/O (#1336 invariant 1).
|
|
5
|
+
Polling N agents' ``/health`` endpoints, shelling out to ``pip show`` once
|
|
6
|
+
for the daemon's own ``coord-serve`` install, and cross-referencing every
|
|
7
|
+
machine's ``/status`` for phantom rows is all real network/subprocess work —
|
|
8
|
+
it runs on the daemon's slow tick cadence (``coord.serve_app``'s
|
|
9
|
+
``_health_refresh_loop``), never inline inside a ``GET /board``.
|
|
10
|
+
|
|
11
|
+
**#1806: this module gathers only genuinely daemon-host-local facts.** The
|
|
12
|
+
CLI-venv version and the ``tui/`` binary-vs-source staleness used to be
|
|
13
|
+
``os.stat``-ed here too, which was wrong — both are facts about the
|
|
14
|
+
*operator's* machine, not the daemon's, and the two are frequently different
|
|
15
|
+
boxes. Those two now ride each machine's own ``/health`` poll (the
|
|
16
|
+
``cli_venv``/``tui_binary`` machine-scope checks in
|
|
17
|
+
:mod:`coord.health.checks.deploy_lane_facts`) and are aggregated fleet-wide
|
|
18
|
+
in :mod:`coord.health.checks.fleet_deploy_lanes` from ``ctx.fleet.machines``,
|
|
19
|
+
not from ``daemon_host``.
|
|
20
|
+
|
|
21
|
+
**Advisory only (the hard constraint of #1630).** This module writes to
|
|
22
|
+
:func:`coord.state.save_machine_health` and hands its snapshot to
|
|
23
|
+
``/board``'s response body — never to a :class:`coord.models.Board`. Nothing
|
|
24
|
+
here is reachable from ``coord.merge_queue.plan``, ``coord.dispatch``, or
|
|
25
|
+
``coord.review``'s routing, because none of those take this module's output
|
|
26
|
+
as an argument. See ``tests/test_health_advisory_only.py``.
|
|
27
|
+
|
|
28
|
+
**Unknown, not green, when a signal is missing.** A machine that never
|
|
29
|
+
responds, or one whose last response is older than :data:`STALE_AFTER_
|
|
30
|
+
SECONDS`, is surfaced as ``severity="unknown"`` — never silently dropped
|
|
31
|
+
(that would look identical to "nothing wrong", #1485's exact failure mode)
|
|
32
|
+
and never left at its last-known-good severity forever (that would look
|
|
33
|
+
identical to "still healthy", the regression #1630 itself calls out).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import json
|
|
39
|
+
import logging
|
|
40
|
+
import os
|
|
41
|
+
import time
|
|
42
|
+
from dataclasses import dataclass, field
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
log = logging.getLogger("coord.serve")
|
|
46
|
+
|
|
47
|
+
# How old a machine's last-received health poll can get before it reads as
|
|
48
|
+
# `unknown` rather than its last-known severity. A few multiples of the
|
|
49
|
+
# health-poll tick's own cadence (COORD_HEALTH_POLL_INTERVAL, default 60s) —
|
|
50
|
+
# generous enough that one slow/missed tick doesn't flap a healthy machine
|
|
51
|
+
# to unknown, tight enough that a genuinely dead agent doesn't stay green
|
|
52
|
+
# for hours. Overridable for tests.
|
|
53
|
+
STALE_AFTER_SECONDS = float(os.environ.get("COORD_HEALTH_STALE_SECS", "900"))
|
|
54
|
+
|
|
55
|
+
# #1337/#1336/#1597: this repo has hit multi-MB /board payloads from
|
|
56
|
+
# unbounded per-item detail three times. The health block budgets itself
|
|
57
|
+
# independently and much tighter — 256 KiB is enormous headroom for what's
|
|
58
|
+
# at most a few dozen machines x a dozen short check rows, while still
|
|
59
|
+
# catching a pathological probe (e.g. one that dumps a huge `values` blob)
|
|
60
|
+
# before it repeats that history.
|
|
61
|
+
MAX_HEALTH_BLOCK_BYTES = 256 * 1024
|
|
62
|
+
|
|
63
|
+
def _trim_check_result(r: dict) -> dict:
|
|
64
|
+
"""Drop the bytes-heavy, lowest-value fields from an OK-severity result.
|
|
65
|
+
|
|
66
|
+
Applied only when the whole block is over budget (see
|
|
67
|
+
:func:`bound_health_payload`) — a healthy check's `values`/`detail` are
|
|
68
|
+
the least actionable bytes in the payload; a WARN/CRIT/UNKNOWN result is
|
|
69
|
+
left untouched because that's exactly the row someone is about to read.
|
|
70
|
+
"""
|
|
71
|
+
if r.get("severity") != "ok":
|
|
72
|
+
return r
|
|
73
|
+
trimmed = dict(r)
|
|
74
|
+
trimmed["values"] = {}
|
|
75
|
+
trimmed["detail"] = ""
|
|
76
|
+
return trimmed
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _hard_truncate_result(r: dict, *, max_field_len: int = 200) -> dict:
|
|
80
|
+
"""Last-resort trim applied regardless of severity: cap `detail` and
|
|
81
|
+
`values` to a fixed size. Unlike :func:`_trim_check_result` this DOES
|
|
82
|
+
touch WARN/CRIT/UNKNOWN rows — by the time this runs, staying under
|
|
83
|
+
budget has won out over "never touch the row someone is about to read";
|
|
84
|
+
a truncated-but-present WARN/CRIT beats a payload dropped/rejected
|
|
85
|
+
outright for being oversized.
|
|
86
|
+
"""
|
|
87
|
+
trimmed = dict(r)
|
|
88
|
+
detail = trimmed.get("detail") or ""
|
|
89
|
+
if len(detail) > max_field_len:
|
|
90
|
+
trimmed["detail"] = detail[:max_field_len] + "…"
|
|
91
|
+
values = trimmed.get("values") or {}
|
|
92
|
+
if values and len(json.dumps(values)) > max_field_len:
|
|
93
|
+
trimmed["values"] = {"_truncated": True}
|
|
94
|
+
return trimmed
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def bound_health_payload(
|
|
98
|
+
machine_health: list[dict],
|
|
99
|
+
fleet_checks: list[dict],
|
|
100
|
+
*,
|
|
101
|
+
max_bytes: int = MAX_HEALTH_BLOCK_BYTES,
|
|
102
|
+
) -> tuple[list[dict], list[dict], bool]:
|
|
103
|
+
"""Trim *machine_health*/*fleet_checks* to fit under *max_bytes* serialized.
|
|
104
|
+
|
|
105
|
+
Escalating, cheapest-first stages, each re-checked before reaching for
|
|
106
|
+
the next:
|
|
107
|
+
|
|
108
|
+
1. strip `values`/`detail` off OK-severity result rows fleet-wide (the
|
|
109
|
+
common case: everything's fine, most bytes are the least useful).
|
|
110
|
+
2. cap each machine's `results` list to its first 25 entries (H-1's
|
|
111
|
+
registry runs well under this per machine — a backstop, not the
|
|
112
|
+
expected path).
|
|
113
|
+
3. hard-truncate `detail`/`values` on EVERY remaining row, including
|
|
114
|
+
WARN/CRIT/UNKNOWN — this is the guarantee-of-last-resort: no single
|
|
115
|
+
pathological probe (a check that dumps a huge blob into `values`) can
|
|
116
|
+
blow the budget, full stop. #1337/#1336/#1597 are all "an unbounded
|
|
117
|
+
per-item field made the payload huge"; this stage exists so the same
|
|
118
|
+
shape of bug in a *health* probe is caught here instead of repeating
|
|
119
|
+
that history a fourth time.
|
|
120
|
+
|
|
121
|
+
Returns ``(machine_health, fleet_checks, truncated)`` — *truncated* is
|
|
122
|
+
True iff stage 2 or 3 actually altered data (as opposed to stage 1's
|
|
123
|
+
already-lossless-for-a-healthy-row trim), so a caller can log/flag it
|
|
124
|
+
rather than truncate silently (no-silent-caps).
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def _size(mh: list[dict], fc: list[dict]) -> int:
|
|
128
|
+
return len(json.dumps({"machine_health": mh, "fleet_checks": fc}))
|
|
129
|
+
|
|
130
|
+
if _size(machine_health, fleet_checks) <= max_bytes:
|
|
131
|
+
return machine_health, fleet_checks, False
|
|
132
|
+
|
|
133
|
+
trimmed_mh = [
|
|
134
|
+
{**m, "results": [_trim_check_result(r) for r in (m.get("results") or [])]}
|
|
135
|
+
for m in machine_health
|
|
136
|
+
]
|
|
137
|
+
trimmed_fc = [_trim_check_result(r) for r in fleet_checks]
|
|
138
|
+
if _size(trimmed_mh, trimmed_fc) <= max_bytes:
|
|
139
|
+
return trimmed_mh, trimmed_fc, False
|
|
140
|
+
|
|
141
|
+
capped_mh = [
|
|
142
|
+
{**m, "results": (m.get("results") or [])[:25]} for m in trimmed_mh
|
|
143
|
+
]
|
|
144
|
+
if _size(capped_mh, trimmed_fc) <= max_bytes:
|
|
145
|
+
return capped_mh, trimmed_fc, True
|
|
146
|
+
|
|
147
|
+
hard_mh = [
|
|
148
|
+
{**m, "results": [_hard_truncate_result(r) for r in (m.get("results") or [])]}
|
|
149
|
+
for m in capped_mh
|
|
150
|
+
]
|
|
151
|
+
hard_fc = [_hard_truncate_result(r) for r in trimmed_fc]
|
|
152
|
+
if _size(hard_mh, hard_fc) <= max_bytes:
|
|
153
|
+
return hard_mh, hard_fc, True
|
|
154
|
+
|
|
155
|
+
# Stage 4, the actual mathematical guarantee: every row is now bounded
|
|
156
|
+
# to a fixed small size, so the ONLY remaining unbounded dimension is
|
|
157
|
+
# machine count. Drop machines from the tail until the fleet fits — a
|
|
158
|
+
# fleet large enough to need this is not a realistic coordinator install
|
|
159
|
+
# (dozens, not thousands, of machines), so this is a backstop for "the
|
|
160
|
+
# bound must hold, full stop," not an expected path.
|
|
161
|
+
lo, hi = 0, len(hard_mh)
|
|
162
|
+
while lo < hi:
|
|
163
|
+
mid = (lo + hi + 1) // 2
|
|
164
|
+
if _size(hard_mh[:mid], hard_fc) <= max_bytes:
|
|
165
|
+
lo = mid
|
|
166
|
+
else:
|
|
167
|
+
hi = mid - 1
|
|
168
|
+
return hard_mh[:lo], hard_fc, True
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@dataclass(frozen=True)
|
|
172
|
+
class FleetHealthSnapshot:
|
|
173
|
+
"""Immutable, atomically-swapped view of the last fleet-health refresh.
|
|
174
|
+
|
|
175
|
+
``machine_health`` is a list (not a dict) so it serializes directly into
|
|
176
|
+
``/board``'s JSON body in a stable, order-preserving shape — one entry
|
|
177
|
+
per machine in ``config.machines`` order.
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
machine_health: list[dict] = field(default_factory=list)
|
|
181
|
+
fleet_checks: list[dict] = field(default_factory=list)
|
|
182
|
+
refreshed_at: float | None = None
|
|
183
|
+
truncated: bool = False
|
|
184
|
+
|
|
185
|
+
def to_dict(self) -> dict:
|
|
186
|
+
return {
|
|
187
|
+
"schema": 1,
|
|
188
|
+
"refreshed_at": self.refreshed_at,
|
|
189
|
+
"machine_health": self.machine_health,
|
|
190
|
+
"fleet_checks": self.fleet_checks,
|
|
191
|
+
"truncated": self.truncated,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _effective_severity(entry: dict, *, now: float) -> tuple[str, bool]:
|
|
196
|
+
"""(severity, stale) for one machine's aggregated health row.
|
|
197
|
+
|
|
198
|
+
`unknown` — never a carried-forward "ok" — whenever the daemon can't
|
|
199
|
+
currently vouch for the number: offline/unreachable, no health block at
|
|
200
|
+
all (old agent, or an agent that hasn't completed its first local run
|
|
201
|
+
yet), or a received_at older than STALE_AFTER_SECONDS.
|
|
202
|
+
"""
|
|
203
|
+
received_at = entry.get("received_at")
|
|
204
|
+
stale = received_at is None or (now - received_at) > STALE_AFTER_SECONDS
|
|
205
|
+
if stale or entry.get("state") != "online":
|
|
206
|
+
return "unknown", stale
|
|
207
|
+
checks = entry.get("health") or {}
|
|
208
|
+
severity = checks.get("severity")
|
|
209
|
+
return (severity or "unknown"), stale
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _machine_health_rows(machine_names: list[str], raw: dict, *, now: float) -> list[dict]:
|
|
213
|
+
rows: list[dict] = []
|
|
214
|
+
for name in machine_names:
|
|
215
|
+
entry = raw.get(name) or {"state": "unknown", "reason": "never polled",
|
|
216
|
+
"latency_ms": None, "received_at": None, "health": None}
|
|
217
|
+
severity, stale = _effective_severity(entry, now=now)
|
|
218
|
+
checks = entry.get("health") or {}
|
|
219
|
+
# #1630: `results` is the last-known detail even when `severity` above
|
|
220
|
+
# has been downgraded to "unknown" for staleness — a renderer needs
|
|
221
|
+
# both "trust this right now? no" (severity/stale) AND "what did we
|
|
222
|
+
# last see?" (results/checked_at) to tell "OK" apart from "last
|
|
223
|
+
# measured OK, a while ago" per the issue's own framing. Only truly
|
|
224
|
+
# absent when there is no last-known data at all (never reported, or
|
|
225
|
+
# unreachable with no prior successful poll).
|
|
226
|
+
rows.append(
|
|
227
|
+
{
|
|
228
|
+
"machine": name,
|
|
229
|
+
"state": entry.get("state", "unknown"),
|
|
230
|
+
"reason": entry.get("reason", ""),
|
|
231
|
+
"latency_ms": entry.get("latency_ms"),
|
|
232
|
+
"received_at": entry.get("received_at"),
|
|
233
|
+
"stale": stale,
|
|
234
|
+
"severity": severity,
|
|
235
|
+
"checked_at": checks.get("checked_at"),
|
|
236
|
+
"results": checks.get("results", []),
|
|
237
|
+
}
|
|
238
|
+
)
|
|
239
|
+
return rows
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class FleetHealthRefresher:
|
|
243
|
+
"""Owns the current :class:`FleetHealthSnapshot`; refreshed by the daemon tick.
|
|
244
|
+
|
|
245
|
+
``snapshot()`` is what the ``/board`` read path consumes — a bare
|
|
246
|
+
attribute read, no I/O. ``refresh(config)`` is the only method that
|
|
247
|
+
talks to agents/subprocesses and must only ever run from the daemon's
|
|
248
|
+
tick machinery (or a test driving it explicitly).
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
def __init__(self) -> None:
|
|
252
|
+
self._snapshot = FleetHealthSnapshot()
|
|
253
|
+
# #1597/#1336/#1337: the /board handler is the only thing that
|
|
254
|
+
# actually measures its own rebuild latency and serialized payload
|
|
255
|
+
# size — it calls `record_board_stats` right after each publish.
|
|
256
|
+
# `None` until the first /board build after daemon startup, which
|
|
257
|
+
# the fleet_board_latency check already reports as UNKNOWN rather
|
|
258
|
+
# than fabricating a 0.
|
|
259
|
+
self._board_latency_ms: float | None = None
|
|
260
|
+
self._board_payload_bytes: int | None = None
|
|
261
|
+
|
|
262
|
+
def snapshot(self) -> FleetHealthSnapshot:
|
|
263
|
+
return self._snapshot
|
|
264
|
+
|
|
265
|
+
def record_board_stats(self, latency_ms: float, payload_bytes: int) -> None:
|
|
266
|
+
"""Called by the /board handler after each publish (#1630/#1597).
|
|
267
|
+
|
|
268
|
+
Deliberately NOT I/O and NOT gated on the tick cadence — recording a
|
|
269
|
+
float+int is free, and the alternative (recomputing board latency
|
|
270
|
+
independently from this refresher) would mean building a whole
|
|
271
|
+
second board just to measure it, defeating the point of the check.
|
|
272
|
+
"""
|
|
273
|
+
self._board_latency_ms = latency_ms
|
|
274
|
+
self._board_payload_bytes = payload_bytes
|
|
275
|
+
|
|
276
|
+
def refresh(self, config) -> FleetHealthSnapshot: # noqa: ANN001 — coord.config.Config
|
|
277
|
+
from coord import network, state # noqa: PLC0415
|
|
278
|
+
from coord.health.context import build_context # noqa: PLC0415
|
|
279
|
+
from coord.health.models import FleetSnapshot # noqa: PLC0415
|
|
280
|
+
from coord.health.registry import run_all # noqa: PLC0415
|
|
281
|
+
|
|
282
|
+
now = time.time()
|
|
283
|
+
machines = list(getattr(config, "machines", ()) or ())
|
|
284
|
+
|
|
285
|
+
# Last-known health blocks, read BEFORE this poll — a machine that's
|
|
286
|
+
# merely offline for one tick keeps its last-known check results (so
|
|
287
|
+
# a renderer can still show "disk 90% full, last seen 4 min ago")
|
|
288
|
+
# while `state`/`severity` flip to unreachable/unknown immediately.
|
|
289
|
+
# Only a poll that ACTUALLY returns a fresh health block replaces it;
|
|
290
|
+
# `received_at` always advances to `now` either way, since that's the
|
|
291
|
+
# daemon's own "did I just try to poll this machine" clock, which is
|
|
292
|
+
# what STALE_AFTER_SECONDS below guards (a dead tick loop, not a
|
|
293
|
+
# merely-offline machine).
|
|
294
|
+
previously_known = state.load_machine_health()
|
|
295
|
+
|
|
296
|
+
# ── 1. poll every agent's /health, persist each as-of-now ──────────
|
|
297
|
+
for machine in machines:
|
|
298
|
+
try:
|
|
299
|
+
status = network.check_machine(machine)
|
|
300
|
+
except Exception as exc: # noqa: BLE001 — one bad machine must not abort the tick
|
|
301
|
+
log.warning("health poll: %s raised %s", machine.name, exc)
|
|
302
|
+
status = None
|
|
303
|
+
last_known_health = (previously_known.get(machine.name) or {}).get("health")
|
|
304
|
+
if status is None:
|
|
305
|
+
state.save_machine_health(
|
|
306
|
+
machine.name, state="unknown", reason="poll raised",
|
|
307
|
+
latency_ms=None, health=last_known_health, received_at=now,
|
|
308
|
+
)
|
|
309
|
+
continue
|
|
310
|
+
health_block = (
|
|
311
|
+
status.health.get("health") if status.health else None
|
|
312
|
+
) or last_known_health
|
|
313
|
+
state.save_machine_health(
|
|
314
|
+
machine.name,
|
|
315
|
+
state=status.state,
|
|
316
|
+
reason=status.reason,
|
|
317
|
+
latency_ms=status.latency_ms,
|
|
318
|
+
health=health_block,
|
|
319
|
+
received_at=now,
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
raw = state.load_machine_health()
|
|
323
|
+
machine_names = [m.name for m in machines]
|
|
324
|
+
machine_health = _machine_health_rows(machine_names, raw, now=now)
|
|
325
|
+
|
|
326
|
+
# ── 2. daemon-host-local facts the fleet checks need ───────────────
|
|
327
|
+
daemon_host = self._daemon_host_facts(config)
|
|
328
|
+
daemon_host["phantom_running"] = self._phantom_running_rows(machines)
|
|
329
|
+
daemon_host["board_latency_ms"] = self._board_latency_ms
|
|
330
|
+
daemon_host["board_payload_bytes"] = self._board_payload_bytes
|
|
331
|
+
|
|
332
|
+
# ── 3. run the fleet-scope registry over the assembled snapshot ────
|
|
333
|
+
by_name = {row["machine"]: {
|
|
334
|
+
"state": row["state"], "reason": row["reason"],
|
|
335
|
+
"latency_ms": row["latency_ms"], "received_at": row["received_at"],
|
|
336
|
+
"checks": {"results": row["results"], "checked_at": row["checked_at"]},
|
|
337
|
+
} for row in machine_health}
|
|
338
|
+
fleet = FleetSnapshot(machines=by_name, daemon_host=daemon_host)
|
|
339
|
+
ctx = build_context(config, now=now, allow_network=False)
|
|
340
|
+
ctx.fleet = fleet
|
|
341
|
+
try:
|
|
342
|
+
report = run_all(ctx, scopes=("fleet",))
|
|
343
|
+
fleet_checks = report.to_dict()["results"]
|
|
344
|
+
except Exception as exc: # noqa: BLE001 — fail soft, never break the tick
|
|
345
|
+
log.warning("fleet health checks failed", exc_info=True)
|
|
346
|
+
fleet_checks = [{
|
|
347
|
+
"key": "fleet_health_error", "check_id": "fleet_health_error",
|
|
348
|
+
"scope": "fleet", "subject": None, "title": "fleet health",
|
|
349
|
+
"label": "fleet health", "severity": "unknown",
|
|
350
|
+
"headroom": f"fleet check run failed: {exc}", "threshold": "",
|
|
351
|
+
"detail": "", "trend": None, "values": {}, "error": str(exc),
|
|
352
|
+
}]
|
|
353
|
+
|
|
354
|
+
machine_health, fleet_checks, truncated = bound_health_payload(
|
|
355
|
+
machine_health, fleet_checks
|
|
356
|
+
)
|
|
357
|
+
if truncated:
|
|
358
|
+
log.warning(
|
|
359
|
+
"fleet health snapshot exceeded %d bytes even after trimming "
|
|
360
|
+
"OK-severity detail — per-machine results capped to 25 rows",
|
|
361
|
+
MAX_HEALTH_BLOCK_BYTES,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
self._snapshot = FleetHealthSnapshot(
|
|
365
|
+
machine_health=machine_health,
|
|
366
|
+
fleet_checks=fleet_checks,
|
|
367
|
+
refreshed_at=now,
|
|
368
|
+
truncated=truncated,
|
|
369
|
+
)
|
|
370
|
+
return self._snapshot
|
|
371
|
+
|
|
372
|
+
@staticmethod
|
|
373
|
+
def _daemon_host_facts(config) -> dict: # noqa: ANN001
|
|
374
|
+
"""Best-effort facts that are genuinely local to the daemon process.
|
|
375
|
+
|
|
376
|
+
#1806: this used to also gather the CLI-venv version and the tui/
|
|
377
|
+
binary-vs-source comparison here — both wrong, since the daemon host
|
|
378
|
+
is frequently not the operator's machine. Those two now come from
|
|
379
|
+
each machine's own ``/health`` poll instead (see this module's
|
|
380
|
+
docstring and :mod:`coord.health.checks.deploy_lane_facts`); only
|
|
381
|
+
``coord-serve``'s own install — which can *only* be introspected from
|
|
382
|
+
the process running it — and the toolchain facts below (already
|
|
383
|
+
correctly fleet-wide, per #1806's own triage) stay here.
|
|
384
|
+
|
|
385
|
+
Every lookup is individually fail-soft — this must never raise, since
|
|
386
|
+
a raise here would take the whole tick down with it.
|
|
387
|
+
"""
|
|
388
|
+
import sys # noqa: PLC0415
|
|
389
|
+
|
|
390
|
+
from coord.health.checks.agent_install import pip_show # noqa: PLC0415
|
|
391
|
+
|
|
392
|
+
facts: dict = {}
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
own = pip_show(Path(sys.executable))
|
|
396
|
+
facts["coord_serve_version"] = own.get("Version") or None
|
|
397
|
+
facts["coord_serve_editable"] = bool(own.get("Editable project location"))
|
|
398
|
+
except Exception: # noqa: BLE001
|
|
399
|
+
facts["coord_serve_version"] = None
|
|
400
|
+
facts["coord_serve_editable"] = None
|
|
401
|
+
|
|
402
|
+
# #1629 (H-2): per-repo toolchain kinds + CI's pinned version, read
|
|
403
|
+
# from whichever local checkouts exist on THIS (daemon) host — same
|
|
404
|
+
# "derive from configured checkouts, never guess a path" rule as the
|
|
405
|
+
# tui/ source dir above. A repo with no local checkout on the daemon
|
|
406
|
+
# host simply has no entry — `probe_toolchain_skew` reads that as "no
|
|
407
|
+
# repo has a resolvable toolchain" for it, not as "in sync".
|
|
408
|
+
facts["repo_toolchain_kinds"] = {}
|
|
409
|
+
facts["ci_toolchains"] = {}
|
|
410
|
+
try:
|
|
411
|
+
from coord.health.checks.toolchain import ( # noqa: PLC0415
|
|
412
|
+
ci_toolchain_versions,
|
|
413
|
+
repo_toolchain_kinds,
|
|
414
|
+
)
|
|
415
|
+
from coord.health.context import local_checkouts # noqa: PLC0415
|
|
416
|
+
|
|
417
|
+
for checkout in local_checkouts(config):
|
|
418
|
+
kinds = repo_toolchain_kinds(checkout.path)
|
|
419
|
+
if kinds:
|
|
420
|
+
facts["repo_toolchain_kinds"][checkout.name] = kinds
|
|
421
|
+
facts["ci_toolchains"][checkout.name] = ci_toolchain_versions(
|
|
422
|
+
checkout.path
|
|
423
|
+
)
|
|
424
|
+
except Exception: # noqa: BLE001 — a fact gatherer must never break the tick
|
|
425
|
+
pass
|
|
426
|
+
|
|
427
|
+
return facts
|
|
428
|
+
|
|
429
|
+
@staticmethod
|
|
430
|
+
def _phantom_running_rows(machines: list) -> list[dict]:
|
|
431
|
+
"""Board rows marked running whose owning machine no longer agrees.
|
|
432
|
+
|
|
433
|
+
Read-only: fetches each busy machine's own `/status` and compares —
|
|
434
|
+
never writes to the board, never finalizes anything (that stays
|
|
435
|
+
`coord diagnose`'s job). A machine that doesn't answer `/status` is
|
|
436
|
+
skipped entirely for this check (its offline-ness is already
|
|
437
|
+
reported by the per-machine health row; "phantom" specifically means
|
|
438
|
+
"reachable AND disagrees", not "unreachable").
|
|
439
|
+
"""
|
|
440
|
+
from coord import network # noqa: PLC0415
|
|
441
|
+
from coord.state import build_board # noqa: PLC0415
|
|
442
|
+
|
|
443
|
+
try:
|
|
444
|
+
board = build_board()
|
|
445
|
+
except Exception: # noqa: BLE001
|
|
446
|
+
return []
|
|
447
|
+
|
|
448
|
+
running_by_machine: dict[str, list] = {}
|
|
449
|
+
for a in board.active:
|
|
450
|
+
if a.status == "running" and a.assignment_id:
|
|
451
|
+
running_by_machine.setdefault(a.machine_name, []).append(a)
|
|
452
|
+
|
|
453
|
+
phantom: list[dict] = []
|
|
454
|
+
for machine in machines:
|
|
455
|
+
rows = running_by_machine.get(machine.name)
|
|
456
|
+
if not rows:
|
|
457
|
+
continue
|
|
458
|
+
try:
|
|
459
|
+
result = network.fetch_status(machine)
|
|
460
|
+
except Exception: # noqa: BLE001
|
|
461
|
+
continue
|
|
462
|
+
if not result.ok or not isinstance(result.data, dict):
|
|
463
|
+
continue
|
|
464
|
+
live_ids = {
|
|
465
|
+
e.get("id") for e in (result.data.get("active") or []) if isinstance(e, dict)
|
|
466
|
+
}
|
|
467
|
+
for a in rows:
|
|
468
|
+
if a.assignment_id not in live_ids:
|
|
469
|
+
phantom.append(
|
|
470
|
+
{
|
|
471
|
+
"assignment_id": a.assignment_id,
|
|
472
|
+
"machine": machine.name,
|
|
473
|
+
"repo_name": a.repo_name,
|
|
474
|
+
"issue_number": a.issue_number,
|
|
475
|
+
}
|
|
476
|
+
)
|
|
477
|
+
return phantom
|