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/gate_snapshot.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""Tick-refreshed snapshot of the third-party merge-gate inputs (#1336).
|
|
2
|
+
|
|
3
|
+
**Invariant 1 of the /board read path: read endpoints perform no third-party
|
|
4
|
+
I/O.** Before this module, every cold ``GET /board`` build paid live ``gh``
|
|
5
|
+
subprocess calls — ``gh pr checks`` per pending merge-queue entry (via a
|
|
6
|
+
:class:`coord.ci_github.GitHubCi` constructed *per request*, so its cache
|
|
7
|
+
never survived) plus ``gh pr view --json commits`` and ``gh issue view`` per
|
|
8
|
+
entry for the #1318 epic-closing-keyword gate. Board latency was therefore a
|
|
9
|
+
function of GitHub's latency × the number of open PRs — the root mechanism of
|
|
10
|
+
the #762/#715/#1336 timeout-overrun failure class.
|
|
11
|
+
|
|
12
|
+
Now the daemon's tick loop calls :meth:`GateSnapshotRefresher.refresh` on its
|
|
13
|
+
own cadence; the ``/board`` handler consumes the immutable
|
|
14
|
+
:class:`GateSnapshot` it last produced. The snapshot duck-types both
|
|
15
|
+
consumer seams:
|
|
16
|
+
|
|
17
|
+
* the :class:`coord.ci_store.CiStore` protocol (``list_checks_for_pr`` /
|
|
18
|
+
``is_available``) — consumed by ``merge_queue.plan`` and
|
|
19
|
+
``stage_projection``;
|
|
20
|
+
* the two ``coord.github_ops`` functions ``merge_queue._entry_gate_status``
|
|
21
|
+
reads for the epic-closing gate (``get_pr_commit_messages`` /
|
|
22
|
+
``is_epic_issue``);
|
|
23
|
+
* the two ``coord.github_ops`` functions the #821/#1475/#1479 review- and
|
|
24
|
+
smoke-freshness checks read (``get_branch_sha`` / ``get_branch_patch_id``)
|
|
25
|
+
— added by #1640, see below;
|
|
26
|
+
* the ``coord.github_ops.get_branch_commit_timestamp`` function
|
|
27
|
+
``merge_queue._ci_checks_are_stale`` reads for the #1851 CI-staleness gate
|
|
28
|
+
— added by #1998, see below.
|
|
29
|
+
|
|
30
|
+
#1640: the snapshot used to duck-type only the first two seams, and
|
|
31
|
+
``merge_queue.has_smoke_verdict`` wraps each of its ``gh_ops`` lookups in a
|
|
32
|
+
fail-open ``except Exception``. Handing it a snapshot that had no
|
|
33
|
+
``get_branch_sha`` therefore did not fail loudly — the ``AttributeError`` was
|
|
34
|
+
swallowed and *every* staleness check silently degraded to a no-op. The
|
|
35
|
+
result was the exact "two readers, one truth" split #1640 reports:
|
|
36
|
+
``coord merge --plan`` (served from ``/board``, i.e. this snapshot) printed
|
|
37
|
+
READY for an entry that ``coord merge --only`` (live ``github_ops``, which
|
|
38
|
+
does resolve the SHAs) correctly refused as stale. The lookups are served
|
|
39
|
+
here from tick-refreshed data so the read path still performs no third-party
|
|
40
|
+
I/O, and the two readers now apply the identical #1479 binding.
|
|
41
|
+
|
|
42
|
+
#1998: the exact same hole existed for ``get_branch_commit_timestamp`` — but
|
|
43
|
+
inverted, and worse. ``merge_queue._ci_checks_are_stale`` (the #1851 gate)
|
|
44
|
+
explicitly *fails closed* (returns "stale") when handed a *gh_ops* stand-in
|
|
45
|
+
with no ``get_branch_commit_timestamp``, rather than fail-open like the
|
|
46
|
+
#1479 checks above. A snapshot missing this method therefore did not degrade
|
|
47
|
+
to a no-op silently — it reported EVERY green, non-pending CI check served
|
|
48
|
+
through ``/board`` as permanently stale, unconditionally, for as long as the
|
|
49
|
+
attribute was absent. That is a "pessimistic display" by the gate's own
|
|
50
|
+
docstring, a defensible tradeoff on its own — until ``coord.drive_queue``'s
|
|
51
|
+
#1891 park/resume machinery, which reads its "has CI reported?" signal off
|
|
52
|
+
this exact ``/board``-served reason, treated the display's pessimism as
|
|
53
|
+
nothing stronger than "still running" (which does not hold a park open) and
|
|
54
|
+
moved on — except the *other* consumer of the same reason, an operator
|
|
55
|
+
running ``coord merge --plan``, saw "CI stale" and a `--revalidate` remedy
|
|
56
|
+
that could not actually clear it (the live gate, e.g. ``coord merge
|
|
57
|
+
--dry-run``/``--only``, was never stale at all). Same #1640 mechanism, same
|
|
58
|
+
fix: serve the timestamp from tick-refreshed data instead of leaving the
|
|
59
|
+
duck-typed seam unimplemented.
|
|
60
|
+
|
|
61
|
+
Fail-open by construction for a pair that has *never* been refreshed at all
|
|
62
|
+
(no backend configured yet, ``ci_available=False``): ``commit_messages`` /
|
|
63
|
+
``epic_issues`` still yield ``[]`` / ``False`` in that case, and
|
|
64
|
+
``list_checks_for_pr`` yields ``[]`` too — so a fresh daemon serves a correct
|
|
65
|
+
(if CI-unannotated) board instantly instead of blocking on GitHub.
|
|
66
|
+
|
|
67
|
+
Once a CI backend *is* configured, ``list_checks_for_pr`` is no longer
|
|
68
|
+
allowed to go stale silently (#1525): a snapshot older than
|
|
69
|
+
:data:`STALE_AFTER_SECONDS`, or one whose refresh loop has stalled entirely,
|
|
70
|
+
returns a synthetic failing check rather than the last-known (possibly
|
|
71
|
+
long-stale) green data. A stale-green board display was never the actual
|
|
72
|
+
mechanism behind the #1525 incident — ``coord merge`` doesn't consult this
|
|
73
|
+
snapshot at all, see below — but an unattended ``coord drive`` reads exactly
|
|
74
|
+
this display before deciding whether a merge is worth attempting, so it gets
|
|
75
|
+
the same fail-closed treatment as the live gate.
|
|
76
|
+
|
|
77
|
+
The *live* merge execution path (``coord merge``, auto-drain) keeps its own
|
|
78
|
+
live ``CiStore`` — merging is a write and is allowed to pay for fresh truth;
|
|
79
|
+
only the read path serves from the snapshot.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
from __future__ import annotations
|
|
83
|
+
|
|
84
|
+
import logging
|
|
85
|
+
import time
|
|
86
|
+
from dataclasses import dataclass, field
|
|
87
|
+
|
|
88
|
+
from coord.ci_store import CheckRun, build_ci_store
|
|
89
|
+
|
|
90
|
+
log = logging.getLogger("coord.serve")
|
|
91
|
+
|
|
92
|
+
# #1525: how old a published snapshot can get before the CI-check read path
|
|
93
|
+
# stops trusting its "no failing checks" silence. The refresh loop's default
|
|
94
|
+
# cadence is 30s (COORD_GATE_REFRESH_INTERVAL); 180s is 6x that — enough
|
|
95
|
+
# headroom that one slow refresh pass doesn't flap the board between READY
|
|
96
|
+
# and BLOCKED, while still catching a refresher that has stalled or died.
|
|
97
|
+
# This only gates checks (the safety-critical CI read); commit_messages /
|
|
98
|
+
# epic_issues keep the original fail-open contract described in the module
|
|
99
|
+
# docstring — the #1318 epic-closing-keyword gate isn't a merge-safety gate
|
|
100
|
+
# in the same sense CI is.
|
|
101
|
+
#
|
|
102
|
+
# This only protects the ``/board`` *display* path — ``coord merge`` and
|
|
103
|
+
# auto-drain always build their own live :class:`coord.ci_store.CiStore`
|
|
104
|
+
# (see the module docstring), never this snapshot. But a stale-green board
|
|
105
|
+
# is exactly what an unattended ``coord drive`` looks at before deciding
|
|
106
|
+
# whether a merge is even worth attempting, so a display-only lie here is
|
|
107
|
+
# still worth refusing to tell.
|
|
108
|
+
STALE_AFTER_SECONDS = 180.0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _stale_check(age_seconds: float | None) -> CheckRun:
|
|
112
|
+
"""Synthetic failing :class:`CheckRun` standing in for "snapshot too old
|
|
113
|
+
to trust" — mirrors :func:`coord.ci_github._unreadable_check`.
|
|
114
|
+
``conclusion="unknown"`` is not in
|
|
115
|
+
:data:`coord.ci_store._PASSING_CONCLUSIONS`, so ``failed_checks`` treats
|
|
116
|
+
this like any other hard failure.
|
|
117
|
+
"""
|
|
118
|
+
age_desc = "never refreshed" if age_seconds is None else f"{age_seconds:.0f}s old"
|
|
119
|
+
return CheckRun(
|
|
120
|
+
name=f"coord: gate snapshot stale ({age_desc}, max {STALE_AFTER_SECONDS:.0f}s)",
|
|
121
|
+
status="completed",
|
|
122
|
+
conclusion="unknown",
|
|
123
|
+
url="",
|
|
124
|
+
run_id="",
|
|
125
|
+
started_at=None,
|
|
126
|
+
completed_at=None,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass(frozen=True)
|
|
131
|
+
class GateSnapshot:
|
|
132
|
+
"""Immutable, atomically-swapped view of the last gate refresh.
|
|
133
|
+
|
|
134
|
+
Safe to hand to concurrent ``/board`` builds: the refresher never mutates
|
|
135
|
+
a published snapshot, it swaps in a new one.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
checks: dict[tuple[str, int], list[CheckRun]] = field(default_factory=dict)
|
|
139
|
+
commit_messages: dict[tuple[str, int], list[str]] = field(default_factory=dict)
|
|
140
|
+
epic_issues: dict[tuple[str, int], bool] = field(default_factory=dict)
|
|
141
|
+
# #1640: (repo, branch) -> HEAD SHA, and (repo, base, head) -> patch-id,
|
|
142
|
+
# for the #821/#1475/#1479 review/smoke freshness checks. A key that is
|
|
143
|
+
# absent (never refreshed, or the lookup failed) yields None, which those
|
|
144
|
+
# checks already treat as "anchor unavailable → skip that half" — the
|
|
145
|
+
# same fail-open convention they apply to a live `gh` call that errors.
|
|
146
|
+
branch_shas: dict[tuple[str, str], str | None] = field(default_factory=dict)
|
|
147
|
+
branch_patch_ids: dict[tuple[str, str, str], str | None] = field(
|
|
148
|
+
default_factory=dict
|
|
149
|
+
)
|
|
150
|
+
# #1998: (repo, branch) -> that branch's HEAD commit's unix timestamp —
|
|
151
|
+
# the base-side half of the #1851 CI-staleness comparison
|
|
152
|
+
# (`merge_queue._ci_checks_are_stale`). A key that is absent (never
|
|
153
|
+
# refreshed, or the lookup failed) yields `None`, which that function
|
|
154
|
+
# already treats as "can't compare" and fails closed on — same fail-open
|
|
155
|
+
# *caching* convention as `branch_shas`/`branch_patch_ids` above; the
|
|
156
|
+
# fail-*closed* verdict itself lives entirely in the consumer, not here.
|
|
157
|
+
branch_commit_timestamps: dict[tuple[str, str], float | None] = field(
|
|
158
|
+
default_factory=dict
|
|
159
|
+
)
|
|
160
|
+
# #1904: repo -> whether the inner CiStore believes this repo declares
|
|
161
|
+
# CI at all — the signal `expects_checks` below needs to tell "no CI
|
|
162
|
+
# configured" apart from "CI exists but never reported for this PR"
|
|
163
|
+
# when `list_checks_for_pr` comes back empty. Keyed on repo (not
|
|
164
|
+
# (repo, number)) since workflow declarations are repo-wide.
|
|
165
|
+
workflows_declared: dict[str, bool] = field(default_factory=dict)
|
|
166
|
+
ci_available: bool = False
|
|
167
|
+
refreshed_at: float | None = None
|
|
168
|
+
|
|
169
|
+
# ── CiStore protocol ────────────────────────────────────────────────────
|
|
170
|
+
def list_checks_for_pr(self, repo: str, number: int) -> list[CheckRun]:
|
|
171
|
+
# #1525: once a CI backend is configured (`ci_available`), a snapshot
|
|
172
|
+
# that's stale-beyond-bound must not silently read as "no failing
|
|
173
|
+
# checks" — that's the same fail-open shape as an unreadable `gh`
|
|
174
|
+
# call, just reached via a dead/slow refresh loop instead of a dead
|
|
175
|
+
# `gh` process. `ci_available=False` (no backend configured) is left
|
|
176
|
+
# alone: there's nothing to be stale about.
|
|
177
|
+
if self.ci_available:
|
|
178
|
+
age = None if self.refreshed_at is None else time.time() - self.refreshed_at
|
|
179
|
+
if age is None or age > STALE_AFTER_SECONDS:
|
|
180
|
+
return [_stale_check(age)]
|
|
181
|
+
return self.checks.get((repo, number), [])
|
|
182
|
+
|
|
183
|
+
def expects_checks(self, repo: str, number: int) -> bool:
|
|
184
|
+
# #1904: unlike `list_checks_for_pr`'s "unknown reads as failing"
|
|
185
|
+
# (#1525) posture, a repo this snapshot hasn't cached an answer for
|
|
186
|
+
# yet — never refreshed, or `ci_available=False` — reads as `False`
|
|
187
|
+
# (not "checks absent"). This mirrors the module docstring's
|
|
188
|
+
# documented "fail-open by construction" tradeoff for a snapshot
|
|
189
|
+
# that hasn't (yet) done any I/O: a fresh daemon boot serves an
|
|
190
|
+
# unannotated board instantly rather than reading every pending
|
|
191
|
+
# entry as untested-and-blocked before the first tick even runs.
|
|
192
|
+
# Once `refresh()` has run at least once for this repo, the cached
|
|
193
|
+
# answer is real GitHub truth, not a guess.
|
|
194
|
+
return self.workflows_declared.get(repo, False)
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def is_available(self) -> bool:
|
|
198
|
+
return self.ci_available
|
|
199
|
+
|
|
200
|
+
# ── github_ops view consumed by merge_queue._entry_gate_status ─────────
|
|
201
|
+
def get_pr_commit_messages(self, repo: str, number: int) -> list[str]:
|
|
202
|
+
return self.commit_messages.get((repo, number), [])
|
|
203
|
+
|
|
204
|
+
def is_epic_issue(self, repo: str, number: int) -> bool:
|
|
205
|
+
return self.epic_issues.get((repo, number), False)
|
|
206
|
+
|
|
207
|
+
# ── github_ops view consumed by the review/smoke freshness checks ──────
|
|
208
|
+
# (#1640 — merge_queue.has_approved_review / evaluate_smoke_verdict)
|
|
209
|
+
def get_branch_sha(self, repo: str, branch: str) -> str | None:
|
|
210
|
+
return self.branch_shas.get((repo, branch))
|
|
211
|
+
|
|
212
|
+
def get_branch_patch_id(self, repo: str, base: str, branch: str) -> str | None:
|
|
213
|
+
return self.branch_patch_ids.get((repo, base, branch))
|
|
214
|
+
|
|
215
|
+
def get_branch_commit_timestamp(self, repo: str, branch: str) -> float | None:
|
|
216
|
+
return self.branch_commit_timestamps.get((repo, branch))
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class GateSnapshotRefresher:
|
|
220
|
+
"""Owns the current :class:`GateSnapshot`; refreshed by the daemon tick.
|
|
221
|
+
|
|
222
|
+
``snapshot()`` is what the read path consumes — a bare attribute read
|
|
223
|
+
(atomic under CPython), never I/O. ``refresh(config)`` is the only
|
|
224
|
+
method that talks to GitHub and must only ever run from the daemon's
|
|
225
|
+
tick machinery (or a test driving it explicitly).
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
def __init__(self) -> None:
|
|
229
|
+
self._snapshot = GateSnapshot()
|
|
230
|
+
self._ci_type: str | None = None
|
|
231
|
+
self._inner_ci = None # CiStore | None — rebuilt when config type changes
|
|
232
|
+
|
|
233
|
+
def snapshot(self) -> GateSnapshot:
|
|
234
|
+
return self._snapshot
|
|
235
|
+
|
|
236
|
+
# ── the tick-side refresh (the ONLY third-party I/O) ────────────────────
|
|
237
|
+
def refresh(self, config) -> GateSnapshot: # noqa: ANN001 — coord.config.Config
|
|
238
|
+
"""One refresh pass over the pending merge-queue entries.
|
|
239
|
+
|
|
240
|
+
Reads the queue from the local DB, fetches CI checks + PR commit
|
|
241
|
+
messages (+ epic-ness of any closing-keyword targets) per pending
|
|
242
|
+
entry with a PR, plus (#1640) the branch/base HEAD SHAs and the
|
|
243
|
+
branch's patch-id, and (#1998) the target branch's HEAD commit
|
|
244
|
+
timestamp, for every pending entry, and atomically publishes a new
|
|
245
|
+
snapshot. Per-entry failures degrade that entry to the fail-open
|
|
246
|
+
values; they never abort the pass or unpublish other entries' data.
|
|
247
|
+
|
|
248
|
+
Cost note (#1640): the SHA sweep adds up to two ``gh api
|
|
249
|
+
repos/…/branches/…`` calls and one ``gh api compare`` per pending
|
|
250
|
+
entry per pass. Branch SHAs are deduped across entries, so a group
|
|
251
|
+
of N entries sharing one target branch pays for that base once. The
|
|
252
|
+
merge queue's *pending* set is what bounds this — merged history is
|
|
253
|
+
never refreshed — so it stays proportional to work actually waiting
|
|
254
|
+
to merge, not to project age. The #1998 commit-timestamp sweep reads
|
|
255
|
+
the same ``repos/…/branches/{target_branch}`` endpoint already
|
|
256
|
+
fetched for ``branch_shas`` above but is not merged into that same
|
|
257
|
+
call — it is deduped independently, on ``(repo, target_branch)``
|
|
258
|
+
only, so it adds at most one further already-cached-shape ``gh api``
|
|
259
|
+
call per distinct target branch per pass, same bound as the SHA
|
|
260
|
+
sweep.
|
|
261
|
+
"""
|
|
262
|
+
from coord import github_ops # noqa: PLC0415
|
|
263
|
+
from coord.merge_queue import PENDING, load_queue # noqa: PLC0415
|
|
264
|
+
from coord.pr_body_lint import find_closing_references # noqa: PLC0415
|
|
265
|
+
|
|
266
|
+
ci_type = getattr(getattr(config, "ci_store", None), "type", "none")
|
|
267
|
+
if ci_type != self._ci_type:
|
|
268
|
+
self._ci_type = ci_type
|
|
269
|
+
try:
|
|
270
|
+
self._inner_ci = build_ci_store(ci_type)
|
|
271
|
+
except Exception: # noqa: BLE001 — unknown type: disable the CI gate
|
|
272
|
+
self._inner_ci = None
|
|
273
|
+
inner = self._inner_ci
|
|
274
|
+
ci_available = bool(inner is not None and inner.is_available)
|
|
275
|
+
|
|
276
|
+
try:
|
|
277
|
+
# #1640: the SHA/patch-id sweep covers every PENDING entry, not
|
|
278
|
+
# just the ones with a PR — a freshly-enqueued entry has no PR
|
|
279
|
+
# yet but is exactly the one `coord merge --plan` renders, and
|
|
280
|
+
# leaving it out is what let the plan show READY for a verdict
|
|
281
|
+
# the live gate rejects as stale.
|
|
282
|
+
pending = [e for e in load_queue() if e.state == PENDING]
|
|
283
|
+
except Exception: # noqa: BLE001 — DB hiccup: keep serving the old snapshot
|
|
284
|
+
log.warning("gate refresh: could not load merge queue", exc_info=True)
|
|
285
|
+
return self._snapshot
|
|
286
|
+
entries = [e for e in pending if e.pr_number]
|
|
287
|
+
|
|
288
|
+
checks: dict[tuple[str, int], list[CheckRun]] = {}
|
|
289
|
+
messages: dict[tuple[str, int], list[str]] = {}
|
|
290
|
+
epics: dict[tuple[str, int], bool] = {}
|
|
291
|
+
branch_shas: dict[tuple[str, str], str | None] = {}
|
|
292
|
+
branch_patch_ids: dict[tuple[str, str, str], str | None] = {}
|
|
293
|
+
branch_commit_timestamps: dict[tuple[str, str], float | None] = {}
|
|
294
|
+
workflows_declared: dict[str, bool] = {}
|
|
295
|
+
for entry in pending:
|
|
296
|
+
# Branch HEAD + merge-base HEAD + the branch's patch-id against
|
|
297
|
+
# that base — the three anchors #821/#1475/#1479 compare a
|
|
298
|
+
# recorded review/test verdict against. Per-lookup failures are
|
|
299
|
+
# cached as None (fail open for that half of the check), exactly
|
|
300
|
+
# as the live path treats a `gh` error.
|
|
301
|
+
for repo, branch in (
|
|
302
|
+
(entry.repo_github, entry.branch),
|
|
303
|
+
(entry.repo_github, entry.target_branch),
|
|
304
|
+
):
|
|
305
|
+
if not repo or not branch or (repo, branch) in branch_shas:
|
|
306
|
+
continue
|
|
307
|
+
try:
|
|
308
|
+
branch_shas[(repo, branch)] = github_ops.get_branch_sha(repo, branch)
|
|
309
|
+
except Exception: # noqa: BLE001 — fail-open for this branch
|
|
310
|
+
branch_shas[(repo, branch)] = None
|
|
311
|
+
# #1998: the target branch's HEAD commit timestamp — the anchor
|
|
312
|
+
# `merge_queue._ci_checks_are_stale` (#1851) needs to tell a
|
|
313
|
+
# genuinely-stale green check apart from one that predates the
|
|
314
|
+
# base by nothing at all. Keyed on (repo, target_branch) only —
|
|
315
|
+
# unlike `branch_shas` above, the staleness gate never reads the
|
|
316
|
+
# entry's OWN branch's timestamp, only the base's — deduped the
|
|
317
|
+
# same way, one `gh api` call per distinct target branch per
|
|
318
|
+
# tick, not per entry.
|
|
319
|
+
ts_repo, ts_branch = entry.repo_github, entry.target_branch
|
|
320
|
+
if (
|
|
321
|
+
ts_repo
|
|
322
|
+
and ts_branch
|
|
323
|
+
and (ts_repo, ts_branch) not in branch_commit_timestamps
|
|
324
|
+
):
|
|
325
|
+
try:
|
|
326
|
+
branch_commit_timestamps[(ts_repo, ts_branch)] = (
|
|
327
|
+
github_ops.get_branch_commit_timestamp(ts_repo, ts_branch)
|
|
328
|
+
)
|
|
329
|
+
except Exception: # noqa: BLE001 — fail-open for this branch
|
|
330
|
+
branch_commit_timestamps[(ts_repo, ts_branch)] = None
|
|
331
|
+
pid_key = (entry.repo_github, entry.target_branch, entry.branch)
|
|
332
|
+
if all(pid_key) and pid_key not in branch_patch_ids:
|
|
333
|
+
try:
|
|
334
|
+
branch_patch_ids[pid_key] = github_ops.get_branch_patch_id(*pid_key)
|
|
335
|
+
except Exception: # noqa: BLE001 — fail-open for this entry
|
|
336
|
+
branch_patch_ids[pid_key] = None
|
|
337
|
+
|
|
338
|
+
for entry in entries:
|
|
339
|
+
key = (entry.repo_github, int(entry.pr_number))
|
|
340
|
+
if ci_available:
|
|
341
|
+
try:
|
|
342
|
+
checks[key] = inner.list_checks_for_pr(*key)
|
|
343
|
+
except Exception: # noqa: BLE001 — fail-open for this entry
|
|
344
|
+
checks[key] = []
|
|
345
|
+
# #1904: repo-wide, so dedupe across every pending entry in
|
|
346
|
+
# the same repo — one `gh api .../actions/workflows` call
|
|
347
|
+
# per repo per tick, not one per PR. A failure here reads
|
|
348
|
+
# as `False` (not "checks absent") for this snapshot cycle
|
|
349
|
+
# — see `expects_checks`' docstring for why the *display*
|
|
350
|
+
# path stays fail-open where the live gate fails closed.
|
|
351
|
+
if entry.repo_github not in workflows_declared:
|
|
352
|
+
try:
|
|
353
|
+
workflows_declared[entry.repo_github] = inner.expects_checks(*key)
|
|
354
|
+
except Exception: # noqa: BLE001 — fail-open for this repo
|
|
355
|
+
workflows_declared[entry.repo_github] = False
|
|
356
|
+
try:
|
|
357
|
+
msgs = github_ops.get_pr_commit_messages(*key)
|
|
358
|
+
except Exception: # noqa: BLE001
|
|
359
|
+
msgs = []
|
|
360
|
+
messages[key] = msgs
|
|
361
|
+
referenced: set[int] = set()
|
|
362
|
+
for message in msgs:
|
|
363
|
+
referenced.update(find_closing_references(message))
|
|
364
|
+
for n in sorted(referenced):
|
|
365
|
+
epic_key = (entry.repo_github, n)
|
|
366
|
+
if epic_key in epics:
|
|
367
|
+
continue
|
|
368
|
+
try:
|
|
369
|
+
epics[epic_key] = github_ops.is_epic_issue(*epic_key)
|
|
370
|
+
except Exception: # noqa: BLE001 — fail-open, matches the live gate
|
|
371
|
+
epics[epic_key] = False
|
|
372
|
+
|
|
373
|
+
snap = GateSnapshot(
|
|
374
|
+
checks=checks,
|
|
375
|
+
commit_messages=messages,
|
|
376
|
+
epic_issues=epics,
|
|
377
|
+
branch_shas=branch_shas,
|
|
378
|
+
branch_patch_ids=branch_patch_ids,
|
|
379
|
+
branch_commit_timestamps=branch_commit_timestamps,
|
|
380
|
+
workflows_declared=workflows_declared,
|
|
381
|
+
ci_available=ci_available,
|
|
382
|
+
refreshed_at=time.time(),
|
|
383
|
+
)
|
|
384
|
+
self._snapshot = snap # atomic publish
|
|
385
|
+
return snap
|