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/revalidate.py
ADDED
|
@@ -0,0 +1,1101 @@
|
|
|
1
|
+
"""``coord merge --revalidate`` — the merge lane's stale-verdict resolution (#1769).
|
|
2
|
+
|
|
3
|
+
#1738 gave ``coord drive`` an arm for a STALE-but-``passed`` smoke verdict: it
|
|
4
|
+
re-dispatches the Test stage against the current base instead of escalating to
|
|
5
|
+
a human. That arm lives in :mod:`coord.drive` and only ever fires while a live
|
|
6
|
+
drive is watching the issue. Every *other* merge path — ``coord merge``, its
|
|
7
|
+
``--only`` form, the auto-drain, the TUI merge action, the daemon ``/merge``
|
|
8
|
+
route — still had only escalate-or-block, so a branch that finishes and sits in
|
|
9
|
+
the merge queue with no live drive stays stuck: the next merge moves the base,
|
|
10
|
+
stales its verdict, and nobody is watching. Measured on 2026-08-03: three
|
|
11
|
+
stale-verdict stalls in one session, #1738's arm could fire on exactly one.
|
|
12
|
+
|
|
13
|
+
**This module is the resolution for that lane**, and it is deliberately
|
|
14
|
+
*opt-in*. ``coord merge`` with no flag is byte-identical to before — nothing
|
|
15
|
+
here runs unless the operator typed ``--revalidate``. An unattended dispatcher
|
|
16
|
+
firing test runs from inside the merge path is the shape that was gated off
|
|
17
|
+
after the 2026-06-07 auto-loop token-burn incident (it is why
|
|
18
|
+
``merge.auto_drain`` defaults to ``false``), so the auto-drain and the daemon's
|
|
19
|
+
own periodic drain pass ``revalidate=False`` and always will.
|
|
20
|
+
|
|
21
|
+
STRATEGY — batch composite (#1715 Option 3)
|
|
22
|
+
-------------------------------------------
|
|
23
|
+
What the operator does by hand — three times in the session above — is: make a
|
|
24
|
+
worktree at the current base, compose every stale branch onto it, run the suite
|
|
25
|
+
**once**, and let them all through on that single result. That is what
|
|
26
|
+
:func:`revalidate` does, and it is what removes the *cascade*: N approved
|
|
27
|
+
branches against one base used to cost N−1 full suite runs, because the first
|
|
28
|
+
merge staled everything behind it. One composite run costs 1.
|
|
29
|
+
|
|
30
|
+
**The honest trade, stated plainly (#1715):** a composite run validates the
|
|
31
|
+
*composite*, not each branch alone. A green composite does not prove each
|
|
32
|
+
branch is green in isolation. That is acceptable here for one specific reason
|
|
33
|
+
— every branch in the set **already carries its own ``passed`` verdict**
|
|
34
|
+
against an earlier base, so the composite is re-confirming that those verdicts
|
|
35
|
+
still hold *together* against the current base. It is a re-confirmation, not a
|
|
36
|
+
first proof, which is exactly why :func:`coord.merge_queue.
|
|
37
|
+
revalidation_candidates` refuses to include an entry that never had a verdict
|
|
38
|
+
(``SMOKE_MISSING``), or one blocked on review/CI/conflict.
|
|
39
|
+
|
|
40
|
+
It is also a *truer* claim than it first looks: :func:`coord.merge_queue.
|
|
41
|
+
process` snapshots ``target_branch_head_sha`` **once per (repo, target_branch)
|
|
42
|
+
group**, so the whole batch merges against the same base the composite was
|
|
43
|
+
built on. The tree the composite validated is the tree that ends up on the
|
|
44
|
+
base branch.
|
|
45
|
+
|
|
46
|
+
FAILURE DOES NOT POISON THE BATCH (#1715)
|
|
47
|
+
-----------------------------------------
|
|
48
|
+
A red composite is the hard part: the naive version blocks all N on one
|
|
49
|
+
branch's fault. :func:`revalidate_group` is the resolution — on a red
|
|
50
|
+
composite it merges **nothing**, marks **nothing** failed, and falls back to
|
|
51
|
+
re-running each candidate **individually** against the current base. The
|
|
52
|
+
culprit is named by its own failing run; the innocent branches get a genuine
|
|
53
|
+
solo verdict and still merge in the same invocation.
|
|
54
|
+
|
|
55
|
+
Cost: a green composite (the overwhelmingly common case) is **one** run total.
|
|
56
|
+
A red composite is 1 + N in the worst case, which is the bound #1715 specifies.
|
|
57
|
+
|
|
58
|
+
Per-entry narrowing was chosen over a bisect: a bisect is only cheaper when
|
|
59
|
+
there is exactly **one** culprit, degrades as soon as there are two, and its
|
|
60
|
+
bookkeeping is subtle. The per-entry pass is flat O(N), identifies *every*
|
|
61
|
+
culprit rather than the first, and — the part that actually matters — leaves
|
|
62
|
+
each survivor with a verdict earned by a run that validated **that branch
|
|
63
|
+
alone against the current base**, which is a strictly stronger claim than
|
|
64
|
+
"was a member of some green subset". N here is a merge queue's depth (2–5),
|
|
65
|
+
so the constant factor is not worth the ambiguity.
|
|
66
|
+
|
|
67
|
+
Nothing here dispatches a worker or spends tokens: it is `git` + the repo's own
|
|
68
|
+
``build_command``/``test_command``, run locally, exactly like ``coord test``
|
|
69
|
+
(#561's throwaway-worktree discipline included — the base checkout is never
|
|
70
|
+
moved, because on the daemon host it doubles as the live editable coordinator
|
|
71
|
+
source).
|
|
72
|
+
|
|
73
|
+
BOUNDED
|
|
74
|
+
-------
|
|
75
|
+
One composite run, plus at most one solo run per candidate, per ``coord merge
|
|
76
|
+
--revalidate`` invocation. There is no retry loop and no second composite: a
|
|
77
|
+
branch whose *solo* run fails terminates blocked with the failure quoted, and
|
|
78
|
+
the operator (or the next invocation) decides. That is the merge lane's
|
|
79
|
+
analogue of #1738's ``fix_rounds`` budget — a branch that cannot pass
|
|
80
|
+
terminates with a clear reason rather than spinning.
|
|
81
|
+
|
|
82
|
+
OPT-IN, ALWAYS
|
|
83
|
+
--------------
|
|
84
|
+
None of this may ever run unattended. ``merge.auto_drain`` is ``false`` by
|
|
85
|
+
design after the 2026-06-07 token-burn incident, and the daemon's
|
|
86
|
+
``_auto_drain_tick`` passes ``revalidate=False`` permanently. Batch
|
|
87
|
+
revalidation inherits that posture wholesale: an operator asks for it, or it
|
|
88
|
+
does not happen.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
from __future__ import annotations
|
|
92
|
+
|
|
93
|
+
import os
|
|
94
|
+
import shutil
|
|
95
|
+
import subprocess
|
|
96
|
+
from dataclasses import dataclass, field
|
|
97
|
+
from pathlib import Path
|
|
98
|
+
|
|
99
|
+
from coord.merge_queue import RevalidationCandidate
|
|
100
|
+
|
|
101
|
+
# Hard ceiling on the composite suite run. A hung test binary must not wedge a
|
|
102
|
+
# `coord merge` invocation forever — and on the daemon route this runs inside a
|
|
103
|
+
# request handler that holds `_merge_lock`, so every other merge in the fleet
|
|
104
|
+
# waits behind it. Generous but bounded: the claude-coordinator suite is ~6 min
|
|
105
|
+
# serial and the tui `cargo test` leg is slower still, so 30 min covers a cold
|
|
106
|
+
# composite with room to spare while still terminating a wedged run.
|
|
107
|
+
# `_merge_via_daemon` sizes the thin client's HTTP timeout off this value.
|
|
108
|
+
DEFAULT_TIMEOUT_SECONDS = 60 * 30
|
|
109
|
+
|
|
110
|
+
# #1715-review: `revalidate_group`'s red-composite fallback is 1 (composite) +
|
|
111
|
+
# N (one solo re-test per candidate) serial suite runs — see that function's
|
|
112
|
+
# docstring. #1769 sized the thin client's HTTP timeout for exactly ONE run;
|
|
113
|
+
# this module's own worst case is now 1 + N, and the client has to outlast it
|
|
114
|
+
# or the operator sees "error: merge via daemon failed" for a batch that
|
|
115
|
+
# actually finished (the daemon keeps running under `_merge_lock` regardless —
|
|
116
|
+
# see `_merge_via_daemon`'s docstring).
|
|
117
|
+
#
|
|
118
|
+
# The client posts to ``/merge`` before any candidate is known — computing the
|
|
119
|
+
# real N would mean re-implementing `merge_queue.revalidation_candidates`'s
|
|
120
|
+
# whole eligibility policy (board state, CI lookups) on a thin client, which
|
|
121
|
+
# is exactly what #584 routes to the daemon to avoid. So this is a documented,
|
|
122
|
+
# deliberately generous ceiling rather than a measured count: comfortably
|
|
123
|
+
# above the "merge queue depth (2-5)" this module's STRATEGY section cites, so
|
|
124
|
+
# a real-world batch never gets close to it. A batch that somehow exceeds it
|
|
125
|
+
# just gets the pre-#1715 false-negative report back — the daemon still
|
|
126
|
+
# finishes the merge either way.
|
|
127
|
+
MAX_REVALIDATION_BATCH = 10
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def client_timeout_seconds(revalidate: bool) -> float:
|
|
131
|
+
"""HTTP timeout ``_merge_via_daemon`` should give a ``/merge`` POST.
|
|
132
|
+
|
|
133
|
+
A plain merge gets the pre-#1769 900s ceiling. A ``--revalidate`` run can
|
|
134
|
+
execute the whole suite up to ``1 + MAX_REVALIDATION_BATCH`` times (see
|
|
135
|
+
that constant) before the daemon responds, so it gets a window sized off
|
|
136
|
+
that worst case instead of a single :data:`DEFAULT_TIMEOUT_SECONDS`.
|
|
137
|
+
|
|
138
|
+
#1925 adds a second, independent cost on the same request: the CI arm
|
|
139
|
+
(``_apply_ci_revalidation`` in ``coord/commands/merge.py``) now waits,
|
|
140
|
+
per CI-stale candidate, up to :data:`coord.ci_store.
|
|
141
|
+
CI_RERUN_MAX_WAIT_SECONDS` for a just-triggered re-run to settle before
|
|
142
|
+
``process()`` evaluates it — bounded polling, not a suite run, but still
|
|
143
|
+
wall-clock the daemon holds the request open for. Worst case is every
|
|
144
|
+
candidate in the batch needing the full CI wait, serially, so the same
|
|
145
|
+
:data:`MAX_REVALIDATION_BATCH` ceiling scales this term too.
|
|
146
|
+
"""
|
|
147
|
+
if not revalidate:
|
|
148
|
+
return 900.0
|
|
149
|
+
from coord.ci_store import CI_RERUN_MAX_WAIT_SECONDS # noqa: PLC0415
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
float(DEFAULT_TIMEOUT_SECONDS) * (1 + MAX_REVALIDATION_BATCH)
|
|
153
|
+
+ float(CI_RERUN_MAX_WAIT_SECONDS) * MAX_REVALIDATION_BATCH
|
|
154
|
+
+ 300.0
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
# How much of a failing run's output to quote back. The whole point of the
|
|
159
|
+
# "a failed re-test leaves the entry blocked with the failure quoted" rule is
|
|
160
|
+
# that the operator can act on it without going hunting, but a full pytest
|
|
161
|
+
# log has no business being echoed into a merge summary.
|
|
162
|
+
_OUTPUT_TAIL_CHARS = 4000
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _tail(text: str, limit: int = _OUTPUT_TAIL_CHARS) -> str:
|
|
166
|
+
text = (text or "").strip()
|
|
167
|
+
if len(text) <= limit:
|
|
168
|
+
return text
|
|
169
|
+
return "…(truncated)…\n" + text[-limit:]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# Why a composite failed, which decides whether splitting it up can help.
|
|
173
|
+
#
|
|
174
|
+
# SETUP failures are *common-mode*: no test command, no local checkout, a
|
|
175
|
+
# failed fetch, candidates spanning two bases. Every candidate would hit the
|
|
176
|
+
# identical wall, so a per-entry fallback would just reproduce the same error
|
|
177
|
+
# N times for nothing. COMPOSE/BUILD/SUITE/TIMEOUT are per-branch-attributable
|
|
178
|
+
# — that is precisely what the fallback exists to narrow down.
|
|
179
|
+
#
|
|
180
|
+
# INFRA (#1814) is a third thing again, and the distinction it draws is the
|
|
181
|
+
# point of that issue: the suite did not FAIL, it did not RUN. The daemon that
|
|
182
|
+
# executes this is a systemd user unit whose PATH never saw ~/.cargo/bin, so
|
|
183
|
+
# `cargo` was simply not there and the shell's "command not found" was being
|
|
184
|
+
# reported as a red suite for a branch CI had already proven green. Like SETUP
|
|
185
|
+
# it is common-mode (never narrowable — every solo run hits the identical
|
|
186
|
+
# wall), but unlike SETUP it happened *after* the composite was built, so the
|
|
187
|
+
# worktree is kept and the operator-facing wording must say "could not run",
|
|
188
|
+
# never "SUITE FAILED".
|
|
189
|
+
KIND_OK = "ok"
|
|
190
|
+
KIND_SETUP = "setup"
|
|
191
|
+
KIND_COMPOSE = "compose"
|
|
192
|
+
KIND_BUILD = "build"
|
|
193
|
+
KIND_SUITE = "suite"
|
|
194
|
+
KIND_TIMEOUT = "timeout"
|
|
195
|
+
KIND_INFRA = "infra"
|
|
196
|
+
|
|
197
|
+
#: Composite failure kinds a per-entry pass can actually narrow (#1715).
|
|
198
|
+
NARROWABLE_KINDS = frozenset({
|
|
199
|
+
KIND_COMPOSE, KIND_BUILD, KIND_SUITE, KIND_TIMEOUT,
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
#: Kinds where no suite was actually executed, so "how many suite runs did
|
|
203
|
+
#: that cost" is zero and no verdict may be inferred in either direction.
|
|
204
|
+
NO_SUITE_RAN_KINDS = frozenset({KIND_SETUP, KIND_INFRA})
|
|
205
|
+
|
|
206
|
+
#: Exit code ``scripts/coord-test-runner.sh`` reserves for "the suite could not
|
|
207
|
+
#: run" (a missing toolchain). See that script's header. Documented here, but
|
|
208
|
+
#: deliberately NOT trusted on its own — see :func:`is_infrastructure_failure`.
|
|
209
|
+
RUNNER_INFRA_EXIT = 3
|
|
210
|
+
|
|
211
|
+
#: 127 is every POSIX shell's "command not found" — an arbitrary repo's own
|
|
212
|
+
#: ``test_command`` that dies this way never started a suite either. Unlike a
|
|
213
|
+
#: small integer, this one is reserved by the shell rather than chosen by the
|
|
214
|
+
#: command, so it carries the same meaning for a command we did not write.
|
|
215
|
+
SHELL_NOT_FOUND_EXIT = 127
|
|
216
|
+
|
|
217
|
+
#: What ``coord-test-runner.sh`` prints when it cannot find a toolchain. THIS
|
|
218
|
+
#: is the signal, not the exit code: a repo's own build/test command is free
|
|
219
|
+
#: to exit 3 for a perfectly genuine failure (this repo's own test suite has
|
|
220
|
+
#: a ``build_command = "exit 3"`` case), so keying on the number alone would
|
|
221
|
+
#: relabel real red builds as infrastructure — the dangerous direction.
|
|
222
|
+
INFRA_OUTPUT_MARKERS = ("TOOLCHAIN MISSING", "RESULT: INFRA")
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def is_infrastructure_failure(returncode: int, output: str) -> bool:
|
|
226
|
+
"""True when a build/test command never actually ran the suite (#1814).
|
|
227
|
+
|
|
228
|
+
Two signals, either of which is enough:
|
|
229
|
+
|
|
230
|
+
* a bare shell ``command not found`` (:data:`SHELL_NOT_FOUND_EXIT`) — the
|
|
231
|
+
universal one, and the exact shape of the bug that motivated this
|
|
232
|
+
(``cargo: command not found`` inside the ``coord-serve`` daemon);
|
|
233
|
+
* one of :data:`INFRA_OUTPUT_MARKERS` in the output — the runner's own
|
|
234
|
+
explicit, deliberately unmistakable statement that it could not run.
|
|
235
|
+
|
|
236
|
+
Note what is *not* a signal: :data:`RUNNER_INFRA_EXIT` on its own. Our
|
|
237
|
+
runner always prints a marker alongside it, and an arbitrary repo's
|
|
238
|
+
command may already use 3 for a real failure, so the number adds nothing
|
|
239
|
+
and risks laundering a red build into "could not run".
|
|
240
|
+
|
|
241
|
+
Deliberately narrow in the same spirit: this never guesses from a generic
|
|
242
|
+
substring like "not found", which appears in ordinary assertion messages.
|
|
243
|
+
Misclassifying a real failure is the worse error of the two — it is the
|
|
244
|
+
one that could eventually launder a merge — so the ambiguous cases all
|
|
245
|
+
fall through to "this is a verdict".
|
|
246
|
+
|
|
247
|
+
The marker check is anchored to the START of a line, not a bare substring
|
|
248
|
+
search over the whole blob (#1814 review). `coord-test-runner.sh`'s own
|
|
249
|
+
``say()`` always emits a marker as the first characters of a line it
|
|
250
|
+
prints — but for `claude-coordinator` itself, ``test_command`` is that
|
|
251
|
+
runner's full ``pytest`` arm, which (as of this fix) contains tests whose
|
|
252
|
+
literal assertion text and parametrize IDs embed these exact marker
|
|
253
|
+
strings (see ``tests/test_coord_test_runner_toolchain.py`` and
|
|
254
|
+
``tests/test_revalidate.py``). If any of those specific tests ever fails
|
|
255
|
+
for an unrelated reason, pytest's ``FAILED tests/...::test[MARKER...]``
|
|
256
|
+
summary line and ``E assert 'MARKER...' in '...'`` diff both contain
|
|
257
|
+
the marker text too — but never at the start of a line: pytest indents
|
|
258
|
+
diff lines with ``E ``/spaces and prefixes summary lines with
|
|
259
|
+
``FAILED ``, and the runner's own re-run dumps
|
|
260
|
+
(``coord-test-runner.sh``'s ``tail -n 40 ... | sed 's/^/ /'``) are
|
|
261
|
+
explicitly indented before being echoed. A bare substring match would
|
|
262
|
+
misclassify that unrelated Python failure as infrastructure and hide it
|
|
263
|
+
behind "fix the runner environment"; anchoring to line-start does not.
|
|
264
|
+
"""
|
|
265
|
+
if returncode == SHELL_NOT_FOUND_EXIT:
|
|
266
|
+
return True
|
|
267
|
+
lines = (output or "").splitlines()
|
|
268
|
+
return any(
|
|
269
|
+
line.startswith(marker) for line in lines for marker in INFRA_OUTPUT_MARKERS
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass
|
|
274
|
+
class RevalidationResult:
|
|
275
|
+
"""Outcome of one composite revalidation run.
|
|
276
|
+
|
|
277
|
+
``ok`` is the only thing the merge path branches on: ``True`` means fresh
|
|
278
|
+
``passed`` verdicts were recorded for every candidate and ``process()`` may
|
|
279
|
+
now find their smoke gate satisfied; ``False`` means every candidate is
|
|
280
|
+
left exactly as it was — still blocked, never merged.
|
|
281
|
+
|
|
282
|
+
``kind`` classifies a failure (#1715) so :func:`revalidate_group` can tell
|
|
283
|
+
"this branch broke it" from "nothing here could ever have run".
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
ok: bool
|
|
287
|
+
reason: str = ""
|
|
288
|
+
output: str = ""
|
|
289
|
+
composed: list[str] = field(default_factory=list)
|
|
290
|
+
recorded: list[str] = field(default_factory=list)
|
|
291
|
+
worktree: Path | None = None
|
|
292
|
+
kind: str = KIND_OK
|
|
293
|
+
|
|
294
|
+
def __bool__(self) -> bool: # pragma: no cover — convenience only
|
|
295
|
+
return self.ok
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def narrowable(self) -> bool:
|
|
299
|
+
"""True when re-running the candidates one at a time could help."""
|
|
300
|
+
return not self.ok and self.kind in NARROWABLE_KINDS
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
@dataclass
|
|
304
|
+
class BatchRevalidationResult:
|
|
305
|
+
"""Outcome of one ``(repo, target_branch)`` group's revalidation (#1715).
|
|
306
|
+
|
|
307
|
+
A green composite is the whole story: ``composite.ok`` and ``per_entry``
|
|
308
|
+
empty, ``suite_runs == 1`` however many candidates there were — that count
|
|
309
|
+
is the entire point of the feature and the black-box tests assert it
|
|
310
|
+
directly.
|
|
311
|
+
|
|
312
|
+
A red composite fills ``per_entry`` with one solo result per candidate.
|
|
313
|
+
``recorded`` then holds only the survivors' assignment ids, and
|
|
314
|
+
``culprits`` names the branches whose own run failed.
|
|
315
|
+
"""
|
|
316
|
+
|
|
317
|
+
composite: RevalidationResult
|
|
318
|
+
per_entry: list[tuple[str, RevalidationResult]] = field(default_factory=list)
|
|
319
|
+
recorded: list[str] = field(default_factory=list)
|
|
320
|
+
culprits: list[str] = field(default_factory=list)
|
|
321
|
+
suite_runs: int = 0
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def ok(self) -> bool:
|
|
325
|
+
"""True when every candidate came out with a fresh verdict."""
|
|
326
|
+
return self.composite.ok
|
|
327
|
+
|
|
328
|
+
@property
|
|
329
|
+
def fell_back(self) -> bool:
|
|
330
|
+
return bool(self.per_entry)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class _Echo:
|
|
334
|
+
"""Null echo so the library is usable without a Click context."""
|
|
335
|
+
|
|
336
|
+
def __call__(self, msg: str = "") -> None: # pragma: no cover
|
|
337
|
+
return None
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def local_repo_dir(config, repo_name: str) -> Path | None:
|
|
341
|
+
"""Resolve the base checkout for *repo_name*.
|
|
342
|
+
|
|
343
|
+
Same resolution ``coord test`` uses (``coord.commands.test_gate.
|
|
344
|
+
_local_repo_dir``): this machine's ``repo_paths`` first, then any machine
|
|
345
|
+
in the config that knows the repo. Returns an expanded :class:`Path`, or
|
|
346
|
+
``None`` when no path is configured.
|
|
347
|
+
"""
|
|
348
|
+
import socket
|
|
349
|
+
|
|
350
|
+
hostname = socket.gethostname().split(".")[0]
|
|
351
|
+
local_machine = next(
|
|
352
|
+
(
|
|
353
|
+
m for m in getattr(config, "machines", [])
|
|
354
|
+
if m.name == hostname or m.host.split(".")[0] == hostname
|
|
355
|
+
),
|
|
356
|
+
None,
|
|
357
|
+
)
|
|
358
|
+
repo_path = None
|
|
359
|
+
if local_machine is not None:
|
|
360
|
+
repo_path = local_machine.repo_path(repo_name)
|
|
361
|
+
if repo_path is None:
|
|
362
|
+
for m in getattr(config, "machines", []):
|
|
363
|
+
repo_path = m.repo_path(repo_name)
|
|
364
|
+
if repo_path:
|
|
365
|
+
break
|
|
366
|
+
return Path(repo_path).expanduser() if repo_path else None
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def revalidation_worktree_path(
|
|
370
|
+
repo_name: str, target_branch: str, slug: str | None = None,
|
|
371
|
+
) -> Path:
|
|
372
|
+
"""Throwaway worktree for a composite revalidation run.
|
|
373
|
+
|
|
374
|
+
Under ``~/.coord/revalidate-worktrees/`` — OUTSIDE the base checkout, for
|
|
375
|
+
the #561 reason: the base checkout doubles as the live editable coordinator
|
|
376
|
+
source on the daemon host, so moving its branch silently downgrades the
|
|
377
|
+
running ``coord`` until somebody restores it.
|
|
378
|
+
|
|
379
|
+
*slug* (#1715) distinguishes the per-entry fallback runs from the composite
|
|
380
|
+
they follow. Without it every solo run would reuse — and therefore delete —
|
|
381
|
+
the failed composite's worktree, which :func:`format_failure` has just
|
|
382
|
+
told the operator was "kept for inspection".
|
|
383
|
+
"""
|
|
384
|
+
from coord.state import COORD_DIR
|
|
385
|
+
|
|
386
|
+
name = f"{repo_name}-{target_branch.replace('/', '-')}"
|
|
387
|
+
if slug:
|
|
388
|
+
name += f"--{slug.replace('/', '-')}"
|
|
389
|
+
return COORD_DIR / "revalidate-worktrees" / name
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _run(
|
|
393
|
+
args: list[str], *, cwd: Path, timeout: int | None = 300
|
|
394
|
+
) -> subprocess.CompletedProcess:
|
|
395
|
+
return subprocess.run(
|
|
396
|
+
args, cwd=str(cwd), capture_output=True, text=True, timeout=timeout,
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _rev_parse(repo_dir: Path, ref: str) -> str | None:
|
|
401
|
+
"""Resolve *ref* to a full SHA in *repo_dir*, or ``None``."""
|
|
402
|
+
try:
|
|
403
|
+
res = _run(["git", "rev-parse", ref], cwd=repo_dir, timeout=60)
|
|
404
|
+
except (subprocess.SubprocessError, OSError):
|
|
405
|
+
return None
|
|
406
|
+
if res.returncode != 0:
|
|
407
|
+
return None
|
|
408
|
+
sha = res.stdout.strip()
|
|
409
|
+
return sha or None
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _remove_worktree(repo_dir: Path, wt_path: Path) -> None:
|
|
413
|
+
"""Best-effort removal (+ prune of the admin refs), mirroring ``coord test``.
|
|
414
|
+
|
|
415
|
+
Falls back to a plain directory delete: the path can survive as an
|
|
416
|
+
orphaned tree when the worktree was registered against a *different* base
|
|
417
|
+
checkout (a re-cloned repo, a moved ``repo_path``), and ``git worktree
|
|
418
|
+
add`` refuses a path that already exists — which would wedge every future
|
|
419
|
+
revalidation for that (repo, target) pair.
|
|
420
|
+
"""
|
|
421
|
+
for args in (
|
|
422
|
+
["git", "worktree", "remove", "--force", str(wt_path)],
|
|
423
|
+
["git", "worktree", "prune"],
|
|
424
|
+
):
|
|
425
|
+
try:
|
|
426
|
+
_run(args, cwd=repo_dir, timeout=60)
|
|
427
|
+
except (subprocess.SubprocessError, OSError):
|
|
428
|
+
pass
|
|
429
|
+
if wt_path.exists():
|
|
430
|
+
shutil.rmtree(wt_path, ignore_errors=True)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def describe_candidates(candidates: list[RevalidationCandidate]) -> list[str]:
|
|
434
|
+
"""One operator-readable line per candidate, for ``--dry-run`` output."""
|
|
435
|
+
lines: list[str] = []
|
|
436
|
+
for c in candidates:
|
|
437
|
+
e = c.entry
|
|
438
|
+
lines.append(
|
|
439
|
+
f" revalidate: {e.repo_name} #{e.issue_number} ({e.branch} → "
|
|
440
|
+
f"{e.target_branch}) — "
|
|
441
|
+
f"{c.smoke.short_reason or 'verdict no longer covers the base'}"
|
|
442
|
+
)
|
|
443
|
+
return lines
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def group_candidates(
|
|
447
|
+
candidates: list[RevalidationCandidate],
|
|
448
|
+
) -> list[tuple[tuple[str, str], list[RevalidationCandidate]]]:
|
|
449
|
+
"""Split *candidates* into the batches that will each cost one suite run.
|
|
450
|
+
|
|
451
|
+
Keyed by ``(repo_name, target_branch)`` — one composite can only be built
|
|
452
|
+
per base, so that pair is exactly the batch boundary. Sorted so the
|
|
453
|
+
``--dry-run`` preview and the real run enumerate the batches identically.
|
|
454
|
+
"""
|
|
455
|
+
groups: dict[tuple[str, str], list[RevalidationCandidate]] = {}
|
|
456
|
+
for c in candidates:
|
|
457
|
+
groups.setdefault(
|
|
458
|
+
(c.entry.repo_name, c.entry.target_branch), [],
|
|
459
|
+
).append(c)
|
|
460
|
+
return sorted(groups.items())
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def describe_batches(
|
|
464
|
+
candidates: list[RevalidationCandidate],
|
|
465
|
+
) -> list[str]:
|
|
466
|
+
"""``--dry-run`` preview: the batches, their members, and the run count.
|
|
467
|
+
|
|
468
|
+
#1715 requires the dry run to "name the batch members and state plainly
|
|
469
|
+
that one composed run will validate all of them" — an operator has to be
|
|
470
|
+
able to see, *before* committing 7 minutes, exactly which branches are
|
|
471
|
+
about to be composed together and that they cost one suite run rather
|
|
472
|
+
than one each.
|
|
473
|
+
"""
|
|
474
|
+
lines: list[str] = []
|
|
475
|
+
batches = group_candidates(candidates)
|
|
476
|
+
for (repo_name, target_branch), group in batches:
|
|
477
|
+
n = len(group)
|
|
478
|
+
if n == 1:
|
|
479
|
+
lines.append(
|
|
480
|
+
f" --revalidate: (dry run) {repo_name} → {target_branch}: "
|
|
481
|
+
"1 entry, 1 suite run against the current base:"
|
|
482
|
+
)
|
|
483
|
+
else:
|
|
484
|
+
lines.append(
|
|
485
|
+
f" --revalidate: (dry run) {repo_name} → {target_branch}: "
|
|
486
|
+
f"BATCH of {n} — all {n} branches would be composed onto "
|
|
487
|
+
f"origin/{target_branch} together and validated by ONE "
|
|
488
|
+
f"composed suite run (not {n}):"
|
|
489
|
+
)
|
|
490
|
+
lines.extend(describe_candidates(group))
|
|
491
|
+
total = len(candidates)
|
|
492
|
+
lines.append(
|
|
493
|
+
f" --revalidate: (dry run) {total} entry(ies) in "
|
|
494
|
+
f"{len(batches)} batch(es) — {len(batches)} suite run(s), "
|
|
495
|
+
"then merge. Nothing has been run and no verdict written."
|
|
496
|
+
)
|
|
497
|
+
if any(len(g) > 1 for _, g in batches):
|
|
498
|
+
lines.append(
|
|
499
|
+
" --revalidate: (dry run) a composed run validates the "
|
|
500
|
+
"COMPOSITE, not each branch alone — every member already holds "
|
|
501
|
+
"its own passed verdict, so this re-confirms they still hold "
|
|
502
|
+
"together against the current base. If the composite fails, "
|
|
503
|
+
"nothing merges and each branch is then re-tested alone to find "
|
|
504
|
+
"the culprit."
|
|
505
|
+
)
|
|
506
|
+
return lines
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def revalidate(
|
|
510
|
+
candidates: list[RevalidationCandidate],
|
|
511
|
+
config,
|
|
512
|
+
*,
|
|
513
|
+
echo=None,
|
|
514
|
+
timeout: int = DEFAULT_TIMEOUT_SECONDS,
|
|
515
|
+
runner=None,
|
|
516
|
+
worktree_slug: str | None = None,
|
|
517
|
+
) -> RevalidationResult:
|
|
518
|
+
"""Compose every candidate branch onto the current base, run the suite once.
|
|
519
|
+
|
|
520
|
+
On success, records a fresh ``passed`` Test-gate verdict for each
|
|
521
|
+
candidate's work assignment. ``coord.state.record_test_verdict`` re-stamps
|
|
522
|
+
the #1479 freshness anchors (``test_base_sha``/``test_head_sha``/
|
|
523
|
+
``test_patch_id``) as part of that write, so the verdict is anchored to the
|
|
524
|
+
base the composite was actually validated against — which is the whole
|
|
525
|
+
point, and is why this cannot be done by hand-editing ``test_state``.
|
|
526
|
+
|
|
527
|
+
On **any** failure — a branch that will not compose, a build failure, a
|
|
528
|
+
test failure, a timeout, a missing local checkout or an unconfigured test
|
|
529
|
+
command — **no verdict is written at all** and every candidate is left
|
|
530
|
+
blocked. There is no partial credit: this must never become a laundering
|
|
531
|
+
path for a verdict that would not pass against the current base.
|
|
532
|
+
|
|
533
|
+
All candidates must share one ``(repo_name, target_branch)`` pair — the
|
|
534
|
+
caller groups them (``coord merge`` already processes the queue in exactly
|
|
535
|
+
those groups). A mixed list is refused rather than silently validating a
|
|
536
|
+
composite that means nothing.
|
|
537
|
+
|
|
538
|
+
*runner* (testing seam) replaces the ``build``/``test`` command execution:
|
|
539
|
+
``runner(command: str, cwd: Path) -> subprocess.CompletedProcess``-alike
|
|
540
|
+
with ``returncode`` and ``stdout``/``stderr``. Defaults to a real
|
|
541
|
+
``subprocess.run(shell=True)``.
|
|
542
|
+
|
|
543
|
+
*worktree_slug* (#1715) namespaces the throwaway worktree, so a per-entry
|
|
544
|
+
fallback run does not delete the failed composite's kept-for-inspection
|
|
545
|
+
tree.
|
|
546
|
+
"""
|
|
547
|
+
echo = echo or _Echo()
|
|
548
|
+
if not candidates:
|
|
549
|
+
return RevalidationResult(ok=True, reason="no revalidation candidates")
|
|
550
|
+
|
|
551
|
+
repos = {c.entry.repo_name for c in candidates}
|
|
552
|
+
targets = {c.entry.target_branch for c in candidates}
|
|
553
|
+
if len(repos) != 1 or len(targets) != 1:
|
|
554
|
+
return RevalidationResult(
|
|
555
|
+
ok=False,
|
|
556
|
+
kind=KIND_SETUP,
|
|
557
|
+
reason=(
|
|
558
|
+
"revalidation candidates span more than one "
|
|
559
|
+
f"(repo, target_branch): repos={sorted(repos)} "
|
|
560
|
+
f"targets={sorted(targets)} — refusing to validate a "
|
|
561
|
+
"composite that spans bases"
|
|
562
|
+
),
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
# Every candidate must name the row whose verdict we would re-record,
|
|
566
|
+
# checked BEFORE the suite runs. Discovering this afterwards used to abort
|
|
567
|
+
# mid-write, having already recorded a fresh verdict for the candidates
|
|
568
|
+
# ahead of the bad one — a partial write that contradicts the "on any
|
|
569
|
+
# failure, no verdict is written at all" contract three paragraphs up.
|
|
570
|
+
# Failing here also saves the operator a ~7-minute suite run that could
|
|
571
|
+
# never have been banked.
|
|
572
|
+
for c in candidates:
|
|
573
|
+
if not c.work_assignment_id:
|
|
574
|
+
return RevalidationResult(
|
|
575
|
+
ok=False,
|
|
576
|
+
kind=KIND_SETUP,
|
|
577
|
+
reason=(
|
|
578
|
+
f"{c.entry.repo_name} #{c.entry.issue_number}: the stale "
|
|
579
|
+
"verdict names no work assignment, so no fresh verdict "
|
|
580
|
+
"can be recorded — entry stays blocked"
|
|
581
|
+
),
|
|
582
|
+
)
|
|
583
|
+
repo_name = repos.pop()
|
|
584
|
+
target_branch = targets.pop()
|
|
585
|
+
|
|
586
|
+
repo_cfg = config.repo(repo_name) if config is not None else None
|
|
587
|
+
if repo_cfg is None:
|
|
588
|
+
return RevalidationResult(
|
|
589
|
+
ok=False, kind=KIND_SETUP,
|
|
590
|
+
reason=f"no repo config for {repo_name!r}",
|
|
591
|
+
)
|
|
592
|
+
test_command = repo_cfg.test_command
|
|
593
|
+
if not test_command:
|
|
594
|
+
# Refusing here is the safe direction: with nothing to run, "passed"
|
|
595
|
+
# would be a claim about a suite that never executed — the exact lie
|
|
596
|
+
# #1738's escalation wording goes out of its way not to invite.
|
|
597
|
+
return RevalidationResult(
|
|
598
|
+
ok=False,
|
|
599
|
+
kind=KIND_SETUP,
|
|
600
|
+
reason=(
|
|
601
|
+
f"no test_command configured for {repo_name!r} — cannot "
|
|
602
|
+
"revalidate (recording a verdict for a suite that never ran "
|
|
603
|
+
"is never correct)"
|
|
604
|
+
),
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
repo_dir = local_repo_dir(config, repo_name)
|
|
608
|
+
if repo_dir is None or not repo_dir.exists():
|
|
609
|
+
return RevalidationResult(
|
|
610
|
+
ok=False,
|
|
611
|
+
kind=KIND_SETUP,
|
|
612
|
+
reason=(
|
|
613
|
+
f"no local checkout for {repo_name!r} on this machine "
|
|
614
|
+
f"({repo_dir or 'no repo_path configured'}) — revalidation "
|
|
615
|
+
"runs the suite locally, so it must run where the repo lives "
|
|
616
|
+
"(the daemon host, via `coord merge --revalidate`)"
|
|
617
|
+
),
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
branches = [c.entry.branch for c in candidates]
|
|
621
|
+
echo(
|
|
622
|
+
f" --revalidate: composing {len(branches)} branch(es) onto "
|
|
623
|
+
f"origin/{target_branch} and running the suite once (#1715 option 3)"
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
wt_path = revalidation_worktree_path(repo_name, target_branch, worktree_slug)
|
|
627
|
+
_remove_worktree(repo_dir, wt_path)
|
|
628
|
+
wt_path.parent.mkdir(parents=True, exist_ok=True)
|
|
629
|
+
|
|
630
|
+
try:
|
|
631
|
+
fetched = _run(["git", "fetch", "origin", "--prune"], cwd=repo_dir)
|
|
632
|
+
except (subprocess.SubprocessError, OSError) as e:
|
|
633
|
+
return RevalidationResult(
|
|
634
|
+
ok=False, kind=KIND_SETUP, reason=f"git fetch failed: {e}",
|
|
635
|
+
)
|
|
636
|
+
if fetched.returncode != 0:
|
|
637
|
+
return RevalidationResult(
|
|
638
|
+
ok=False, kind=KIND_SETUP,
|
|
639
|
+
reason=f"git fetch failed: {fetched.stderr.strip()}",
|
|
640
|
+
)
|
|
641
|
+
|
|
642
|
+
added = _run(
|
|
643
|
+
["git", "worktree", "add", "--force", "--detach",
|
|
644
|
+
str(wt_path), f"origin/{target_branch}"],
|
|
645
|
+
cwd=repo_dir,
|
|
646
|
+
)
|
|
647
|
+
if added.returncode != 0:
|
|
648
|
+
return RevalidationResult(
|
|
649
|
+
ok=False,
|
|
650
|
+
kind=KIND_SETUP,
|
|
651
|
+
reason=(
|
|
652
|
+
f"could not create the revalidation worktree at "
|
|
653
|
+
f"origin/{target_branch}: {added.stderr.strip()}"
|
|
654
|
+
),
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
composed: list[str] = []
|
|
658
|
+
# `git merge` (not rebase) with an explicit commit: we only need a tree
|
|
659
|
+
# that contains every candidate's content on top of the current base.
|
|
660
|
+
# Nothing here is ever pushed — the worktree is thrown away below and
|
|
661
|
+
# `coord merge` still does the real merge through `gh` afterwards.
|
|
662
|
+
for branch in branches:
|
|
663
|
+
merged = _run(
|
|
664
|
+
["git", "merge", "--no-ff", "--no-edit", f"origin/{branch}"],
|
|
665
|
+
cwd=wt_path,
|
|
666
|
+
)
|
|
667
|
+
if merged.returncode != 0:
|
|
668
|
+
_run(["git", "merge", "--abort"], cwd=wt_path)
|
|
669
|
+
return RevalidationResult(
|
|
670
|
+
ok=False,
|
|
671
|
+
kind=KIND_COMPOSE,
|
|
672
|
+
reason=(
|
|
673
|
+
f"branch {branch!r} does not compose onto "
|
|
674
|
+
f"origin/{target_branch} (conflict) — resolve the "
|
|
675
|
+
"conflict before revalidating"
|
|
676
|
+
),
|
|
677
|
+
output=_tail(merged.stdout + "\n" + merged.stderr),
|
|
678
|
+
composed=list(composed),
|
|
679
|
+
worktree=wt_path,
|
|
680
|
+
)
|
|
681
|
+
composed.append(branch)
|
|
682
|
+
echo(f" composed {branch}")
|
|
683
|
+
|
|
684
|
+
run_cmd = runner or _shell_runner
|
|
685
|
+
build_command = getattr(repo_cfg, "build_command", None)
|
|
686
|
+
if build_command:
|
|
687
|
+
echo(f" running build: {build_command}")
|
|
688
|
+
try:
|
|
689
|
+
built = run_cmd(build_command, wt_path, timeout)
|
|
690
|
+
except subprocess.TimeoutExpired:
|
|
691
|
+
return RevalidationResult(
|
|
692
|
+
ok=False,
|
|
693
|
+
kind=KIND_TIMEOUT,
|
|
694
|
+
reason=f"revalidation build timed out after {timeout}s",
|
|
695
|
+
composed=list(composed),
|
|
696
|
+
worktree=wt_path,
|
|
697
|
+
)
|
|
698
|
+
if built.returncode != 0:
|
|
699
|
+
build_output = (built.stdout or "") + "\n" + (built.stderr or "")
|
|
700
|
+
infra = is_infrastructure_failure(built.returncode, build_output)
|
|
701
|
+
return RevalidationResult(
|
|
702
|
+
ok=False,
|
|
703
|
+
kind=KIND_INFRA if infra else KIND_BUILD,
|
|
704
|
+
reason=(
|
|
705
|
+
_infra_reason("build", built.returncode)
|
|
706
|
+
if infra
|
|
707
|
+
else (
|
|
708
|
+
"revalidation BUILD FAILED against the current base "
|
|
709
|
+
f"(exit {built.returncode}) — every candidate stays "
|
|
710
|
+
"blocked"
|
|
711
|
+
)
|
|
712
|
+
),
|
|
713
|
+
output=_tail(build_output),
|
|
714
|
+
composed=list(composed),
|
|
715
|
+
worktree=wt_path,
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
echo(f" running tests: {test_command}")
|
|
719
|
+
try:
|
|
720
|
+
tested = run_cmd(test_command, wt_path, timeout)
|
|
721
|
+
except subprocess.TimeoutExpired:
|
|
722
|
+
return RevalidationResult(
|
|
723
|
+
ok=False,
|
|
724
|
+
kind=KIND_TIMEOUT,
|
|
725
|
+
reason=f"revalidation suite timed out after {timeout}s",
|
|
726
|
+
composed=list(composed),
|
|
727
|
+
worktree=wt_path,
|
|
728
|
+
)
|
|
729
|
+
if tested.returncode != 0:
|
|
730
|
+
test_output = (tested.stdout or "") + "\n" + (tested.stderr or "")
|
|
731
|
+
infra = is_infrastructure_failure(tested.returncode, test_output)
|
|
732
|
+
return RevalidationResult(
|
|
733
|
+
ok=False,
|
|
734
|
+
kind=KIND_INFRA if infra else KIND_SUITE,
|
|
735
|
+
reason=(
|
|
736
|
+
_infra_reason("suite", tested.returncode)
|
|
737
|
+
if infra
|
|
738
|
+
else (
|
|
739
|
+
"revalidation SUITE FAILED against the current base "
|
|
740
|
+
f"(exit {tested.returncode}) — every candidate stays "
|
|
741
|
+
"blocked, nothing merged"
|
|
742
|
+
)
|
|
743
|
+
),
|
|
744
|
+
output=_tail(test_output),
|
|
745
|
+
composed=list(composed),
|
|
746
|
+
worktree=wt_path,
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
# ── Suite green: record the fresh verdicts ──────────────────────────────
|
|
750
|
+
from coord.state import record_test_staleness_anchor, record_test_verdict
|
|
751
|
+
|
|
752
|
+
# The commits this run ACTUALLY validated, read from the local refs the
|
|
753
|
+
# worktree was built from — not re-discovered from GitHub afterwards. See
|
|
754
|
+
# `record_test_staleness_anchor`'s docstring for why that distinction is
|
|
755
|
+
# load-bearing rather than an optimisation.
|
|
756
|
+
validated_base_sha = _rev_parse(repo_dir, f"origin/{target_branch}")
|
|
757
|
+
|
|
758
|
+
recorded: list[str] = []
|
|
759
|
+
composite_note = (
|
|
760
|
+
"revalidated by `coord merge --revalidate` — composite of "
|
|
761
|
+
+ ", ".join(composed)
|
|
762
|
+
+ f" onto origin/{target_branch}"
|
|
763
|
+
)
|
|
764
|
+
for c in candidates:
|
|
765
|
+
# Non-empty for every candidate: checked up front, before the suite
|
|
766
|
+
# ran, precisely so this loop cannot abort part-way through having
|
|
767
|
+
# already written some of the verdicts.
|
|
768
|
+
aid = c.work_assignment_id
|
|
769
|
+
record_test_verdict(assignment_id=aid, test_state="passed")
|
|
770
|
+
record_test_staleness_anchor(
|
|
771
|
+
assignment_id=aid,
|
|
772
|
+
test_head_sha=_rev_parse(repo_dir, f"origin/{c.entry.branch}"),
|
|
773
|
+
test_base_sha=validated_base_sha,
|
|
774
|
+
# #1475's patch-id is GitHub's compare diff hashed — reproducing it
|
|
775
|
+
# from a local `git diff` is not guaranteed byte-identical, and a
|
|
776
|
+
# WRONG patch-id would read as "content unchanged" for content that
|
|
777
|
+
# did change. NULL is the fail-closed value the gate already
|
|
778
|
+
# understands ("cannot confirm identical content"), so a branch that
|
|
779
|
+
# moves after this run re-blocks on SHA alone, exactly as a
|
|
780
|
+
# pre-#1475 row does.
|
|
781
|
+
test_patch_id=None,
|
|
782
|
+
)
|
|
783
|
+
recorded.append(aid)
|
|
784
|
+
echo(
|
|
785
|
+
f" recorded fresh Test verdict for {c.entry.repo_name} "
|
|
786
|
+
f"#{c.entry.issue_number} ({aid})"
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
_remove_worktree(repo_dir, wt_path)
|
|
790
|
+
return RevalidationResult(
|
|
791
|
+
ok=True,
|
|
792
|
+
reason=composite_note,
|
|
793
|
+
composed=composed,
|
|
794
|
+
recorded=recorded,
|
|
795
|
+
worktree=None,
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def _infra_reason(stage: str, returncode: int) -> str:
|
|
800
|
+
"""Operator-facing wording for a run that never happened (#1814).
|
|
801
|
+
|
|
802
|
+
Every clause here is load-bearing. It must not contain the words "SUITE
|
|
803
|
+
FAILED" (the operator would go and debug a branch that is fine), it must
|
|
804
|
+
say out loud that the branch is unjudged rather than bad, and it must name
|
|
805
|
+
the environment as the thing to fix — because the failure that motivated
|
|
806
|
+
it (``cargo: command not found`` inside the ``coord-serve`` systemd user
|
|
807
|
+
unit) reads like a branch problem and is not one.
|
|
808
|
+
|
|
809
|
+
``returncode`` alone does not always mean "exited immediately without
|
|
810
|
+
running anything" — :func:`is_infrastructure_failure` can also classify
|
|
811
|
+
on an :data:`INFRA_OUTPUT_MARKERS` hit at a returncode that isn't
|
|
812
|
+
:data:`SHELL_NOT_FOUND_EXIT` (e.g. the runner's own ``RESULT: INFRA``
|
|
813
|
+
line at exit 3, or a wrapped/nonstandard exit). The wording branches on
|
|
814
|
+
that so it never overclaims "without running anything" for a run whose
|
|
815
|
+
own output says it merely couldn't complete.
|
|
816
|
+
"""
|
|
817
|
+
if returncode == SHELL_NOT_FOUND_EXIT:
|
|
818
|
+
run_desc = f"the {stage} command exited {returncode} without running anything"
|
|
819
|
+
else:
|
|
820
|
+
run_desc = (
|
|
821
|
+
f"the {stage} command's own output reported it could not run "
|
|
822
|
+
f"(exit {returncode})"
|
|
823
|
+
)
|
|
824
|
+
return (
|
|
825
|
+
f"revalidation COULD NOT RUN — {run_desc} (missing toolchain / broken "
|
|
826
|
+
"runner environment, NOT a test failure). This says nothing about the "
|
|
827
|
+
"branches: they keep their existing verdicts and stay blocked, and "
|
|
828
|
+
"nothing merged. Fix the runner environment and re-run — a systemd "
|
|
829
|
+
"user unit's PATH is not a login shell's (see #1814)"
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _label(candidate: RevalidationCandidate) -> str:
|
|
834
|
+
e = candidate.entry
|
|
835
|
+
return f"{e.repo_name} #{e.issue_number} ({e.branch})"
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def revalidate_group(
|
|
839
|
+
candidates: list[RevalidationCandidate],
|
|
840
|
+
config,
|
|
841
|
+
*,
|
|
842
|
+
echo=None,
|
|
843
|
+
timeout: int = DEFAULT_TIMEOUT_SECONDS,
|
|
844
|
+
runner=None,
|
|
845
|
+
) -> BatchRevalidationResult:
|
|
846
|
+
"""Revalidate one ``(repo, target_branch)`` group: composite, then narrow.
|
|
847
|
+
|
|
848
|
+
This is the #1715 entry point and the one ``coord merge --revalidate``
|
|
849
|
+
calls. It is a thin policy layer over :func:`revalidate`:
|
|
850
|
+
|
|
851
|
+
1. **Compose all N and run the suite once.** Green — which is the
|
|
852
|
+
overwhelmingly common case, since every candidate already holds a
|
|
853
|
+
``passed`` verdict from an earlier base — and the group is done at a
|
|
854
|
+
cost of exactly **one** suite run, however large N was. That single
|
|
855
|
+
number is the whole feature.
|
|
856
|
+
|
|
857
|
+
2. **Red composite: merge nothing, fail nothing, narrow.** No verdict was
|
|
858
|
+
written (:func:`revalidate` is all-or-nothing), so no entry can merge
|
|
859
|
+
off the back of it, and no entry is marked failed either — a composite
|
|
860
|
+
failure is evidence about the *set*, not a verdict on any member. Each
|
|
861
|
+
candidate is then re-run **alone** against the current base. A branch
|
|
862
|
+
that passes solo earns a real verdict and merges; a branch that fails
|
|
863
|
+
solo is the culprit and stays blocked with its own failure quoted.
|
|
864
|
+
|
|
865
|
+
N = 1 never falls back: the "composite" already *was* that single branch,
|
|
866
|
+
so a second run would be the identical run twice. That keeps this path
|
|
867
|
+
byte-identical to #1769's shipped single-entry behaviour.
|
|
868
|
+
|
|
869
|
+
A composite that failed for a **common-mode** reason (no ``test_command``,
|
|
870
|
+
no local checkout, a dead ``git fetch``, candidates spanning two bases, or
|
|
871
|
+
a missing toolchain — #1814's :data:`KIND_INFRA`) never falls back either
|
|
872
|
+
— see :data:`NARROWABLE_KINDS`. Every solo run would hit the same wall, so
|
|
873
|
+
narrowing would turn one clear error into N identical ones. For the INFRA
|
|
874
|
+
case that matters twice over: N solo runs would each print "could not
|
|
875
|
+
run", making a broken daemon environment look like N broken branches.
|
|
876
|
+
|
|
877
|
+
Worst case is therefore 1 + N runs, the bound #1715 specifies, and it is
|
|
878
|
+
reached only when a composite genuinely fails on a real build/test/merge
|
|
879
|
+
problem.
|
|
880
|
+
"""
|
|
881
|
+
echo = echo or _Echo()
|
|
882
|
+
if not candidates:
|
|
883
|
+
return BatchRevalidationResult(
|
|
884
|
+
composite=RevalidationResult(
|
|
885
|
+
ok=True, reason="no revalidation candidates",
|
|
886
|
+
),
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
composite = revalidate(
|
|
890
|
+
candidates, config, echo=echo, timeout=timeout, runner=runner,
|
|
891
|
+
)
|
|
892
|
+
# A setup refusal never reached a build/test command, so it did not cost a
|
|
893
|
+
# suite run; an INFRA failure reached it but the suite still never
|
|
894
|
+
# executed (#1814). Everything else did (or died trying), and the
|
|
895
|
+
# operator's mental model of "how many suites did that just run" should
|
|
896
|
+
# match.
|
|
897
|
+
ran = 0 if composite.kind in NO_SUITE_RAN_KINDS else 1
|
|
898
|
+
batch = BatchRevalidationResult(
|
|
899
|
+
composite=composite,
|
|
900
|
+
recorded=list(composite.recorded),
|
|
901
|
+
suite_runs=ran,
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
if composite.ok or len(candidates) == 1 or not composite.narrowable:
|
|
905
|
+
return batch
|
|
906
|
+
|
|
907
|
+
echo(
|
|
908
|
+
f" --revalidate: the composite of {len(candidates)} branches FAILED — "
|
|
909
|
+
"nothing merges on that result. Re-running each branch on its own "
|
|
910
|
+
"against the current base to find the culprit (#1715); branches that "
|
|
911
|
+
"pass alone still merge."
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
for c in candidates:
|
|
915
|
+
label = _label(c)
|
|
916
|
+
echo(f" --revalidate: re-testing {label} alone")
|
|
917
|
+
solo = revalidate(
|
|
918
|
+
[c], config, echo=echo, timeout=timeout, runner=runner,
|
|
919
|
+
# Its own worktree: the failed composite's tree was just advertised
|
|
920
|
+
# as "kept for inspection", and reusing the path would delete it.
|
|
921
|
+
worktree_slug=c.work_assignment_id or c.entry.branch,
|
|
922
|
+
)
|
|
923
|
+
if solo.kind != KIND_SETUP:
|
|
924
|
+
batch.suite_runs += 1
|
|
925
|
+
batch.per_entry.append((label, solo))
|
|
926
|
+
if solo.ok:
|
|
927
|
+
batch.recorded.extend(solo.recorded)
|
|
928
|
+
echo(f" --revalidate: {label} PASSES alone — cleared to merge")
|
|
929
|
+
else:
|
|
930
|
+
batch.culprits.append(label)
|
|
931
|
+
echo(f" --revalidate: {label} FAILS alone — {solo.reason}")
|
|
932
|
+
|
|
933
|
+
if not batch.culprits:
|
|
934
|
+
# Every branch is green by itself, yet together they are not. That is a
|
|
935
|
+
# genuine cross-branch interaction (two branches that each compile
|
|
936
|
+
# against the old base but not against each other), and it is the one
|
|
937
|
+
# case where the per-entry pass is *less* conservative than the
|
|
938
|
+
# composite it replaced. Say so out loud rather than letting a clean
|
|
939
|
+
# per-entry sweep quietly imply the composite was a fluke.
|
|
940
|
+
echo(
|
|
941
|
+
" --revalidate: WARNING — every branch passes alone but the "
|
|
942
|
+
"composite of all of them failed. That points at an interaction "
|
|
943
|
+
"between these branches rather than at any one of them; they are "
|
|
944
|
+
"merging on their solo verdicts. Re-run the suite on the base "
|
|
945
|
+
"afterwards."
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
return batch
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def format_batch(batch: BatchRevalidationResult) -> list[str]:
|
|
952
|
+
"""Operator-facing OUTCOME lines for one group's revalidation (#1715).
|
|
953
|
+
|
|
954
|
+
Deliberately excludes the composite's own failure report — that is
|
|
955
|
+
:func:`format_failure`'s job and belongs on stderr, whereas "…and here is
|
|
956
|
+
what merged anyway" is ordinary stdout. Keeping them separate is why the
|
|
957
|
+
caller does not have to route a "PASSED alone — merging" line to stderr
|
|
958
|
+
just because the composite that preceded it was red.
|
|
959
|
+
|
|
960
|
+
A per-entry (solo) failure is different: it names the actual culprit, and
|
|
961
|
+
the worktree :func:`revalidate` kept for it (per ``worktree_slug``) is the
|
|
962
|
+
one an operator would actually inspect — the composite's own kept
|
|
963
|
+
worktree is a different tree entirely. #1715-review: that pointer used to
|
|
964
|
+
be silently dropped here, even though :func:`format_failure` already knew
|
|
965
|
+
how to print it. Reuse it (skipping its leading reason line, which
|
|
966
|
+
:func:`format_batch` already renders with the branch label attached).
|
|
967
|
+
"""
|
|
968
|
+
lines: list[str] = []
|
|
969
|
+
if batch.composite.ok:
|
|
970
|
+
lines.append(f" --revalidate: PASSED — {batch.composite.reason}")
|
|
971
|
+
return lines
|
|
972
|
+
|
|
973
|
+
if batch.composite.kind == KIND_INFRA:
|
|
974
|
+
# #1814: the one failure mode that is not about the branches at all.
|
|
975
|
+
# Say so on stdout too — the reason line goes to stderr, and an
|
|
976
|
+
# operator skimming the merge summary must not be left with a red
|
|
977
|
+
# composite and no explanation that it judged nothing.
|
|
978
|
+
lines.append(
|
|
979
|
+
" --revalidate: INFRASTRUCTURE FAILURE — the suite could not "
|
|
980
|
+
"run, so no branch was judged. Nothing merged, nothing marked "
|
|
981
|
+
"failed, no verdict changed; every candidate is exactly as it "
|
|
982
|
+
"was. Fix the runner environment, then re-run --revalidate."
|
|
983
|
+
)
|
|
984
|
+
return lines
|
|
985
|
+
|
|
986
|
+
if not batch.fell_back:
|
|
987
|
+
return lines
|
|
988
|
+
|
|
989
|
+
for label, solo in batch.per_entry:
|
|
990
|
+
if solo.ok:
|
|
991
|
+
lines.append(f" --revalidate: {label}: PASSED alone — merging")
|
|
992
|
+
else:
|
|
993
|
+
lines.append(f" --revalidate: {label}: BLOCKED — {solo.reason}")
|
|
994
|
+
lines.extend(format_failure(solo)[1:])
|
|
995
|
+
if batch.culprits:
|
|
996
|
+
lines.append(
|
|
997
|
+
" --revalidate: culprit(s): " + ", ".join(batch.culprits)
|
|
998
|
+
)
|
|
999
|
+
lines.append(f" --revalidate: {batch.suite_runs} suite run(s) total")
|
|
1000
|
+
return lines
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
# #1924: every guard var `serve_app.py` sets on *itself* to keep a daemon
|
|
1004
|
+
# command handler from re-routing its own request back to the daemon (see
|
|
1005
|
+
# ``daemon_reroute_target()`` in board_service.py and its call sites in
|
|
1006
|
+
# commands/merge.py, commands/status.py, commands/acceptance.py,
|
|
1007
|
+
# commands/gates.py, commands/lifecycle.py). These are process-global — set
|
|
1008
|
+
# with a plain ``os.environ[...] = "1"`` around the handler body, not scoped
|
|
1009
|
+
# to the request — so when `coord merge --revalidate` is invoked from a thin
|
|
1010
|
+
# client, routed to the daemon, and its composed-suite subprocess inherits
|
|
1011
|
+
# the parent's environment by default, the suite sees whichever of these
|
|
1012
|
+
# happened to be set on `coord serve`'s own process at the time (in
|
|
1013
|
+
# particular `COORD_MERGE_ON_DAEMON`, set for the very request that is
|
|
1014
|
+
# running this revalidation). The suite is supposed to behave exactly like a
|
|
1015
|
+
# clean checkout's test run; a leaked guard var makes tests that assert on
|
|
1016
|
+
# these vars fail regardless of what the branch under test contains. Kept as
|
|
1017
|
+
# an explicit tuple (not a dynamic "*_ON_DAEMON" glob over os.environ) so
|
|
1018
|
+
# adding a new guard var in serve_app.py is a visible, deliberate edit here
|
|
1019
|
+
# too, rather than something that's silently swept up or silently missed.
|
|
1020
|
+
_DAEMON_GUARD_ENV_VARS = (
|
|
1021
|
+
"COORD_MERGE_ON_DAEMON",
|
|
1022
|
+
"COORD_RECONCILE_ON_DAEMON",
|
|
1023
|
+
"COORD_DIAGNOSE_ON_DAEMON",
|
|
1024
|
+
"COORD_GATES_ON_DAEMON",
|
|
1025
|
+
"COORD_TEST_PLAN_ON_DAEMON",
|
|
1026
|
+
"COORD_HOUSEKEEPING_ON_DAEMON",
|
|
1027
|
+
"COORD_NOTIFY_ON_DAEMON",
|
|
1028
|
+
"COORD_ACCEPTANCE_ON_DAEMON",
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def _suite_subprocess_env() -> dict[str, str]:
|
|
1033
|
+
"""``os.environ`` minus the daemon-internal routing guards (#1924).
|
|
1034
|
+
|
|
1035
|
+
The composed-suite subprocess should look like a clean checkout's test
|
|
1036
|
+
run irrespective of whether the parent process invoking it happens to be
|
|
1037
|
+
a bare shell or `coord serve` mid-request. See ``_DAEMON_GUARD_ENV_VARS``.
|
|
1038
|
+
"""
|
|
1039
|
+
return {
|
|
1040
|
+
k: v for k, v in os.environ.items() if k not in _DAEMON_GUARD_ENV_VARS
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _shell_runner(command: str, cwd: Path, timeout: int):
|
|
1045
|
+
"""Run *command* through the shell in *cwd*, capturing output.
|
|
1046
|
+
|
|
1047
|
+
Same shape as ``coord test``'s build/test step (``subprocess.run(cmd,
|
|
1048
|
+
shell=True, cwd=worktree)``) — the repo's own configured command, run in
|
|
1049
|
+
the composite worktree, inheriting the environment — MINUS the
|
|
1050
|
+
daemon-internal routing guards (#1924), which must never leak into a
|
|
1051
|
+
subprocess that is supposed to behave like a clean checkout.
|
|
1052
|
+
"""
|
|
1053
|
+
return subprocess.run(
|
|
1054
|
+
command, shell=True, cwd=str(cwd), capture_output=True, text=True,
|
|
1055
|
+
timeout=timeout, env=_suite_subprocess_env(),
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
def format_failure(result: RevalidationResult) -> list[str]:
|
|
1060
|
+
"""Operator-facing lines for a failed revalidation (blocked, not merged)."""
|
|
1061
|
+
lines = [f" --revalidate: {result.reason}"]
|
|
1062
|
+
if result.output:
|
|
1063
|
+
lines.append(" --revalidate: output tail:")
|
|
1064
|
+
lines.extend(
|
|
1065
|
+
" " + ln for ln in result.output.splitlines()
|
|
1066
|
+
)
|
|
1067
|
+
if result.worktree is not None:
|
|
1068
|
+
lines.append(
|
|
1069
|
+
f" --revalidate: worktree kept for inspection: {result.worktree}"
|
|
1070
|
+
)
|
|
1071
|
+
return lines
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
__all__ = [
|
|
1075
|
+
"DEFAULT_TIMEOUT_SECONDS",
|
|
1076
|
+
"KIND_BUILD",
|
|
1077
|
+
"KIND_COMPOSE",
|
|
1078
|
+
"KIND_INFRA",
|
|
1079
|
+
"KIND_OK",
|
|
1080
|
+
"KIND_SETUP",
|
|
1081
|
+
"KIND_SUITE",
|
|
1082
|
+
"KIND_TIMEOUT",
|
|
1083
|
+
"MAX_REVALIDATION_BATCH",
|
|
1084
|
+
"NARROWABLE_KINDS",
|
|
1085
|
+
"NO_SUITE_RAN_KINDS",
|
|
1086
|
+
"RUNNER_INFRA_EXIT",
|
|
1087
|
+
"SHELL_NOT_FOUND_EXIT",
|
|
1088
|
+
"BatchRevalidationResult",
|
|
1089
|
+
"RevalidationResult",
|
|
1090
|
+
"client_timeout_seconds",
|
|
1091
|
+
"describe_batches",
|
|
1092
|
+
"describe_candidates",
|
|
1093
|
+
"format_batch",
|
|
1094
|
+
"format_failure",
|
|
1095
|
+
"group_candidates",
|
|
1096
|
+
"is_infrastructure_failure",
|
|
1097
|
+
"local_repo_dir",
|
|
1098
|
+
"revalidate",
|
|
1099
|
+
"revalidate_group",
|
|
1100
|
+
"revalidation_worktree_path",
|
|
1101
|
+
]
|