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/smoke.py
ADDED
|
@@ -0,0 +1,1030 @@
|
|
|
1
|
+
"""Smoke-test orchestration — auto-queue validation on a capable machine.
|
|
2
|
+
|
|
3
|
+
When a worker finishes, the work often needs validation hardware the worker
|
|
4
|
+
didn't have. Example: a GTK key-routing fix built on a no-GTK server needs a
|
|
5
|
+
machine with GTK to actually verify the popup works. This module:
|
|
6
|
+
|
|
7
|
+
1. Reads the worker's diff (which files changed).
|
|
8
|
+
2. Looks at `smoke_tests.capability_rules` — each rule maps a file-path
|
|
9
|
+
prefix to a set of required machine capabilities.
|
|
10
|
+
3. Picks a machine that has all required capabilities, preferring one
|
|
11
|
+
different from the worker.
|
|
12
|
+
4. Dispatches a `type="smoke"` assignment with a briefing that tells
|
|
13
|
+
`claude -p` to fetch the branch, run the smoke command, and report
|
|
14
|
+
pass/fail through its exit code.
|
|
15
|
+
|
|
16
|
+
Public entry points:
|
|
17
|
+
|
|
18
|
+
- `match_rules(touched_files, rules)` — pure: returns the union of required
|
|
19
|
+
capabilities for any rule whose `files` prefix matches a touched file.
|
|
20
|
+
- `rank_smoke_machines(required_caps, repo, worker_machine, board, config)` —
|
|
21
|
+
every capability-matched machine, best first (#1672).
|
|
22
|
+
- `pick_smoke_machine(required_caps, worker_machine, board, config)` — picks
|
|
23
|
+
a capable machine, preferring the worker's own (its build cache is warm —
|
|
24
|
+
#1402); pass `prefer_worker=False` for the old different-machine-first
|
|
25
|
+
order. Thin wrapper over `rank_smoke_machines` — the head of the ranking.
|
|
26
|
+
- `dispatch_smoke(completed, board, config, ...)` — the full path; called
|
|
27
|
+
from reconcile when a work assignment transitions to done.
|
|
28
|
+
|
|
29
|
+
#1819: the unit a Test run measures is the **(branch, base)** pair, not the
|
|
30
|
+
work row that asked for it. Three guards in `dispatch_smoke` follow from that
|
|
31
|
+
— a branch-scoped in-flight dedupe, a supersession check that skips a work row
|
|
32
|
+
a later row replaced on the same branch, and a refusal to stamp the transient
|
|
33
|
+
`running` marker over a verdict that already exists. Together they are what
|
|
34
|
+
stops a fix round (which reuses the branch by design, so one branch carries
|
|
35
|
+
two `work` rows) from putting two machines on the identical suite and then
|
|
36
|
+
looping forever as each re-dispatch retracts the verdict the last one landed.
|
|
37
|
+
|
|
38
|
+
Why a separate module from `coord/review.py`: smoke tests target machine
|
|
39
|
+
capabilities (GTK/terminal/CUDA), not session independence. The selection
|
|
40
|
+
algorithm is different — for reviews we want a *different* machine for
|
|
41
|
+
independence; for smoke we want a *capable* machine for hardware, and
|
|
42
|
+
"different" is only a tie-breaker.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import json
|
|
48
|
+
import logging
|
|
49
|
+
import time
|
|
50
|
+
import uuid
|
|
51
|
+
from dataclasses import dataclass
|
|
52
|
+
from typing import Callable
|
|
53
|
+
|
|
54
|
+
import httpx
|
|
55
|
+
|
|
56
|
+
from coord import github_ops
|
|
57
|
+
from coord.config import Config, SmokeRule, SmokeTestsConfig
|
|
58
|
+
from coord.dispatch import AGENT_PORT
|
|
59
|
+
from coord.models import WORK_LIKE_TYPES, Assignment, Board, Machine
|
|
60
|
+
|
|
61
|
+
logger = logging.getLogger("coord.smoke")
|
|
62
|
+
|
|
63
|
+
# #2024: assignment ids whose `test-mode:smoke` Test-stage skip has already
|
|
64
|
+
# been reported this process (see `dispatch_pending_smoke`). Process-lifetime,
|
|
65
|
+
# not persisted — the point is one clear statement per daemon run, not an
|
|
66
|
+
# every-tick repeat of a refusal that is expected to hold for hours.
|
|
67
|
+
#
|
|
68
|
+
# Deliberately never pruned (e.g. when an operator clears the block with
|
|
69
|
+
# `coord set-test-mode <repo> <issue> auto` or records a verdict by hand):
|
|
70
|
+
# membership is a small, immutable assignment-id string per row that ever hit
|
|
71
|
+
# this skip, so the set's lifetime cost is bounded by "how many rows stalled
|
|
72
|
+
# on this policy while the daemon has been up" — negligible next to the board
|
|
73
|
+
# itself, and a daemon restart clears it for free (the next occurrence logs
|
|
74
|
+
# again, which is exactly when an operator wants to hear it).
|
|
75
|
+
_TEST_MODE_SKIP_LOGGED: set[str] = set()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
SMOKE_SYSTEM_PROMPT = """\
|
|
79
|
+
You are a smoke-test runner dispatched by the coordinator. \
|
|
80
|
+
Your only job: pull the branch, run the smoke command, report pass/fail.
|
|
81
|
+
|
|
82
|
+
Rules:
|
|
83
|
+
- Do NOT edit source files. Do NOT push commits. You only validate.
|
|
84
|
+
- You MAY perform test-environment SETUP the smoke command needs — creating
|
|
85
|
+
a venv, `pip install`-ing dev deps, symlinking a sibling checkout for a
|
|
86
|
+
path dependency (e.g. coord-tui's quadraui link), writing build artifacts.
|
|
87
|
+
None of that touches the branch's source; it is exactly what the smoke
|
|
88
|
+
command itself does when run locally, so do it without asking.
|
|
89
|
+
- Do NOT run `gh` commands. The coordinator owns GitHub interactions.
|
|
90
|
+
- You MAY run git, build commands, and test commands.
|
|
91
|
+
- Exit code is what the coordinator reads — exit 0 on pass, non-zero on \
|
|
92
|
+
fail. Print a final line `SMOKE: pass` or `SMOKE: fail <one-line reason>` \
|
|
93
|
+
before exiting so logs are readable.
|
|
94
|
+
|
|
95
|
+
Where you are:
|
|
96
|
+
- You are in a dedicated git worktree created for this run. Every git command \
|
|
97
|
+
below runs THERE, in your current directory.
|
|
98
|
+
- Do NOT `cd` to the machine's shared base checkout (`~/src/<repo>`), and do \
|
|
99
|
+
NOT `git checkout` / `git switch` inside it. Leaving that checkout parked on a \
|
|
100
|
+
feature branch makes every later dispatch against that branch on this machine \
|
|
101
|
+
fail (#1694).
|
|
102
|
+
|
|
103
|
+
Steps:
|
|
104
|
+
1. `git fetch origin && git checkout <branch>` (the branch is in your \
|
|
105
|
+
briefing) — in your worktree, never in the base checkout.
|
|
106
|
+
2. Run the smoke command from the briefing. Capture stdout/stderr.
|
|
107
|
+
3. If it exits 0 → print `SMOKE: pass` and exit 0.
|
|
108
|
+
4. If it fails → print `SMOKE: fail <short reason>` and exit non-zero.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── Rule matching ───────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def match_rules(touched_files: list[str], rules: list[SmokeRule]) -> list[str]:
|
|
116
|
+
"""Return the union of `requires` for any rule that any touched file hits.
|
|
117
|
+
|
|
118
|
+
Matching is path-prefix: a rule with `files=["src/gtk/"]` matches
|
|
119
|
+
`src/gtk/foo.c` but not `src/cli.py`. A rule with `files=["src/gtk"]`
|
|
120
|
+
(no slash) catches both `src/gtk/foo.c` and `src/gtk_helpers.c` — use
|
|
121
|
+
the trailing slash form to be strict.
|
|
122
|
+
|
|
123
|
+
Returns capabilities in deterministic order (first-seen across rules).
|
|
124
|
+
"""
|
|
125
|
+
seen: dict[str, None] = {}
|
|
126
|
+
for path in touched_files:
|
|
127
|
+
for rule in rules:
|
|
128
|
+
if not any(path.startswith(pattern) for pattern in rule.files):
|
|
129
|
+
continue
|
|
130
|
+
for cap in rule.requires:
|
|
131
|
+
seen.setdefault(cap, None)
|
|
132
|
+
return list(seen.keys())
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ── Machine selection ───────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass
|
|
139
|
+
class SmokeMachineChoice:
|
|
140
|
+
machine: Machine
|
|
141
|
+
is_worker: bool
|
|
142
|
+
rationale: str
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def rank_smoke_machines(
|
|
146
|
+
required_caps: list[str],
|
|
147
|
+
repo_name: str,
|
|
148
|
+
worker_machine_name: str,
|
|
149
|
+
board: Board,
|
|
150
|
+
config: Config,
|
|
151
|
+
*,
|
|
152
|
+
prefer_worker: bool = True,
|
|
153
|
+
) -> list[SmokeMachineChoice]:
|
|
154
|
+
"""Every machine that can smoke-test `repo_name` with all `required_caps`,
|
|
155
|
+
best candidate first (#1672).
|
|
156
|
+
|
|
157
|
+
Preference order (#1402):
|
|
158
|
+
1. The worker's own machine, if capable and idle
|
|
159
|
+
2. Idle, capable, different from worker (config order)
|
|
160
|
+
3. The worker's own machine, if capable (busy — smoke will queue)
|
|
161
|
+
4. Busy, capable, different from worker (config order; smoke will queue)
|
|
162
|
+
|
|
163
|
+
Every candidate appears exactly once; the head of the list is exactly what
|
|
164
|
+
:func:`pick_smoke_machine` used to return on its own.
|
|
165
|
+
|
|
166
|
+
**Why a ranking and not a single pick (#1672).** ``dispatch_smoke`` has to
|
|
167
|
+
reject a candidate *after* choosing it — its live ``/health`` probe can
|
|
168
|
+
contradict the capabilities ``coordinator.yml`` declares for it (#1570 D),
|
|
169
|
+
or it can turn out to have no ``repo_paths`` entry. Returning one machine
|
|
170
|
+
meant a single bad candidate ended the whole Test stage: on 2026-08-01
|
|
171
|
+
(#1678) the router picked the same unhealthy machine every 30 s forever
|
|
172
|
+
while two other machines declared the same capability and were never
|
|
173
|
+
tried. The caller now walks this list.
|
|
174
|
+
|
|
175
|
+
**Capability matching is unchanged and is never relaxed.** Only machines
|
|
176
|
+
that genuinely declare every required capability (and can work on the
|
|
177
|
+
repo) are in the list at all — a fallback that dispatched to a machine
|
|
178
|
+
lacking the capability would produce a green verdict from a machine that
|
|
179
|
+
cannot run the suite, which is worse than refusing.
|
|
180
|
+
|
|
181
|
+
**Why the worker machine is preferred.** This used to prefer a machine
|
|
182
|
+
*different* from the worker. That preference is right for **review**,
|
|
183
|
+
where independence from the worker's context is the entire point — but a
|
|
184
|
+
test run needs **capability**, not independence: it re-runs the suite
|
|
185
|
+
against the pushed commit and the verdict is identical wherever it runs.
|
|
186
|
+
Meanwhile the worker's machine is the one with a warm build cache
|
|
187
|
+
(``coord.cargo_cache``, #1402) and, for a Rust repo, that is the
|
|
188
|
+
difference between ~18 s and ~3 min. Capability rules still bind
|
|
189
|
+
absolutely: a GTK or browser suite goes to a capable machine even when
|
|
190
|
+
the worker ran somewhere else, and a worker machine that lacks a required
|
|
191
|
+
capability is never chosen.
|
|
192
|
+
|
|
193
|
+
Pass ``prefer_worker=False`` to restore the different-machine-first
|
|
194
|
+
ordering (used by callers that want independence, and by tests pinning
|
|
195
|
+
the old behaviour).
|
|
196
|
+
|
|
197
|
+
Returns an empty list when capabilities can't be matched.
|
|
198
|
+
"""
|
|
199
|
+
candidates = [
|
|
200
|
+
m for m in config.machines
|
|
201
|
+
if m.can_work_on(repo_name)
|
|
202
|
+
and all(cap in m.capabilities for cap in required_caps)
|
|
203
|
+
]
|
|
204
|
+
if not candidates:
|
|
205
|
+
return []
|
|
206
|
+
|
|
207
|
+
busy = {a.machine_name for a in board.active if a.status in ("pending", "running")}
|
|
208
|
+
|
|
209
|
+
same = next((m for m in candidates if m.name == worker_machine_name), None)
|
|
210
|
+
|
|
211
|
+
ranked: list[SmokeMachineChoice] = []
|
|
212
|
+
seen: set[str] = set()
|
|
213
|
+
|
|
214
|
+
def _add(choice: SmokeMachineChoice) -> None:
|
|
215
|
+
if choice.machine.name in seen:
|
|
216
|
+
return
|
|
217
|
+
seen.add(choice.machine.name)
|
|
218
|
+
ranked.append(choice)
|
|
219
|
+
|
|
220
|
+
if prefer_worker and same is not None and same.name not in busy:
|
|
221
|
+
_add(SmokeMachineChoice(
|
|
222
|
+
machine=same,
|
|
223
|
+
is_worker=True,
|
|
224
|
+
rationale=(
|
|
225
|
+
f"chose {same.name} — the worker machine, idle and has "
|
|
226
|
+
f"{required_caps}; its build cache is already warm"
|
|
227
|
+
),
|
|
228
|
+
))
|
|
229
|
+
|
|
230
|
+
for m in candidates:
|
|
231
|
+
if m.name == worker_machine_name or m.name in busy:
|
|
232
|
+
continue
|
|
233
|
+
_add(SmokeMachineChoice(
|
|
234
|
+
machine=m,
|
|
235
|
+
is_worker=False,
|
|
236
|
+
rationale=(
|
|
237
|
+
f"chose {m.name} — idle and has {required_caps} "
|
|
238
|
+
f"(worker was {worker_machine_name})"
|
|
239
|
+
),
|
|
240
|
+
))
|
|
241
|
+
|
|
242
|
+
if prefer_worker and same is not None:
|
|
243
|
+
_add(SmokeMachineChoice(
|
|
244
|
+
machine=same,
|
|
245
|
+
is_worker=True,
|
|
246
|
+
rationale=(
|
|
247
|
+
f"chose {same.name} — the worker machine has {required_caps} and a "
|
|
248
|
+
"warm build cache; capable but busy, smoke will queue"
|
|
249
|
+
),
|
|
250
|
+
))
|
|
251
|
+
|
|
252
|
+
for m in candidates:
|
|
253
|
+
if m.name == worker_machine_name:
|
|
254
|
+
continue
|
|
255
|
+
_add(SmokeMachineChoice(
|
|
256
|
+
machine=m,
|
|
257
|
+
is_worker=False,
|
|
258
|
+
rationale=(
|
|
259
|
+
f"chose {m.name} — capable but busy; smoke will queue"
|
|
260
|
+
),
|
|
261
|
+
))
|
|
262
|
+
|
|
263
|
+
if same is not None:
|
|
264
|
+
_add(SmokeMachineChoice(
|
|
265
|
+
machine=same,
|
|
266
|
+
is_worker=True,
|
|
267
|
+
rationale=(
|
|
268
|
+
f"only the worker machine ({worker_machine_name}) has {required_caps}; "
|
|
269
|
+
"smoke runs on the same machine"
|
|
270
|
+
),
|
|
271
|
+
))
|
|
272
|
+
return ranked
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def pick_smoke_machine(
|
|
276
|
+
required_caps: list[str],
|
|
277
|
+
repo_name: str,
|
|
278
|
+
worker_machine_name: str,
|
|
279
|
+
board: Board,
|
|
280
|
+
config: Config,
|
|
281
|
+
*,
|
|
282
|
+
prefer_worker: bool = True,
|
|
283
|
+
) -> SmokeMachineChoice | None:
|
|
284
|
+
"""The single best machine with all `required_caps` for `repo_name`.
|
|
285
|
+
|
|
286
|
+
The head of :func:`rank_smoke_machines` — see there for the preference
|
|
287
|
+
order and the reasoning. Returns None when capabilities can't be matched.
|
|
288
|
+
|
|
289
|
+
``dispatch_smoke`` uses the full ranking (#1672); this stays for callers
|
|
290
|
+
that only ever want the first choice.
|
|
291
|
+
"""
|
|
292
|
+
ranked = rank_smoke_machines(
|
|
293
|
+
required_caps, repo_name, worker_machine_name, board, config,
|
|
294
|
+
prefer_worker=prefer_worker,
|
|
295
|
+
)
|
|
296
|
+
return ranked[0] if ranked else None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _capability_probe_reasons(
|
|
300
|
+
machine: Machine,
|
|
301
|
+
required_caps: list[str],
|
|
302
|
+
*,
|
|
303
|
+
http_client: httpx.Client | None = None,
|
|
304
|
+
timeout: float = 5.0,
|
|
305
|
+
) -> dict[str, list[str]]:
|
|
306
|
+
"""Cross-reference `machine`'s live `/health` tool probes (#1570 B)
|
|
307
|
+
against `required_caps` before routing smoke work to it (#1570 D).
|
|
308
|
+
|
|
309
|
+
`pick_smoke_machine` only checks `machine.capabilities` — a hand-written
|
|
310
|
+
claim in `coordinator.yml` that nothing has ever verified (#1570's whole
|
|
311
|
+
point: `gh` was simply the first claim to bite). This asks the machine
|
|
312
|
+
itself.
|
|
313
|
+
|
|
314
|
+
Returns `{capability: [reason, ...]}` for any required capability whose
|
|
315
|
+
backing tool the machine's own probe says is missing or too old — empty
|
|
316
|
+
when everything checks out *or* when `/health` doesn't publish
|
|
317
|
+
`tool_versions` yet (an agent that predates #1570 B). The latter fails
|
|
318
|
+
OPEN, not closed: during rollout most of the fleet won't have the probe
|
|
319
|
+
immediately, and refusing every smoke dispatch on missing telemetry
|
|
320
|
+
would be strictly worse than the blind trust this replaces. Only an
|
|
321
|
+
*explicit* probe failure refuses routing.
|
|
322
|
+
|
|
323
|
+
Never raises — a connectivity hiccup here just skips the extra check;
|
|
324
|
+
the POST to `/assign` right after this call in `dispatch_smoke` is the
|
|
325
|
+
real reachability test and fails closed on its own if the machine is
|
|
326
|
+
down.
|
|
327
|
+
"""
|
|
328
|
+
client = http_client or httpx
|
|
329
|
+
try:
|
|
330
|
+
resp = client.get(f"http://{machine.host}:{AGENT_PORT}/health", timeout=timeout)
|
|
331
|
+
resp.raise_for_status()
|
|
332
|
+
health = resp.json()
|
|
333
|
+
except (httpx.HTTPError, httpx.TimeoutException, ValueError):
|
|
334
|
+
# Any connectivity/parsing hiccup here just skips the extra check —
|
|
335
|
+
# not widened to AttributeError: a caller's `http_client` double
|
|
336
|
+
# should implement `.get` (real httpx.Client always does), so a
|
|
337
|
+
# missing-method bug on our side surfaces instead of silently
|
|
338
|
+
# degrading like a genuine reachability problem.
|
|
339
|
+
return {}
|
|
340
|
+
raw_probes = health.get("tool_versions") if isinstance(health, dict) else None
|
|
341
|
+
if not raw_probes:
|
|
342
|
+
return {}
|
|
343
|
+
|
|
344
|
+
from coord.prereqs import ToolProbe, unmet_capabilities
|
|
345
|
+
|
|
346
|
+
probes = {
|
|
347
|
+
tool: ToolProbe(
|
|
348
|
+
tool=tool,
|
|
349
|
+
capability=info.get("capability"),
|
|
350
|
+
found=bool(info.get("found", False)),
|
|
351
|
+
version=info.get("version"),
|
|
352
|
+
min_version=info.get("min_version"),
|
|
353
|
+
meets_floor=info.get("meets_floor"),
|
|
354
|
+
what_breaks="",
|
|
355
|
+
)
|
|
356
|
+
for tool, info in raw_probes.items()
|
|
357
|
+
if isinstance(info, dict)
|
|
358
|
+
}
|
|
359
|
+
return unmet_capabilities(required_caps, probes)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ── Briefing ────────────────────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def build_smoke_briefing(
|
|
366
|
+
*,
|
|
367
|
+
repo_github: str,
|
|
368
|
+
repo_name: str,
|
|
369
|
+
branch: str,
|
|
370
|
+
issue_number: int,
|
|
371
|
+
issue_title: str,
|
|
372
|
+
smoke_command: str,
|
|
373
|
+
required_caps: list[str],
|
|
374
|
+
timeout_seconds: int,
|
|
375
|
+
is_worker: bool,
|
|
376
|
+
) -> str:
|
|
377
|
+
lines: list[str] = []
|
|
378
|
+
lines.append(f"# Smoke test: {repo_github} branch `{branch}`")
|
|
379
|
+
lines.append("")
|
|
380
|
+
lines.append(
|
|
381
|
+
f"Validate the worker's fix for issue #{issue_number}: {issue_title}"
|
|
382
|
+
)
|
|
383
|
+
lines.append("")
|
|
384
|
+
lines.append("## Context")
|
|
385
|
+
lines.append(f"- Repo: {repo_github} (local name: {repo_name})")
|
|
386
|
+
lines.append(f"- Branch: {branch}")
|
|
387
|
+
if required_caps:
|
|
388
|
+
lines.append(f"- Required capabilities: {', '.join(required_caps)}")
|
|
389
|
+
if is_worker:
|
|
390
|
+
lines.append(
|
|
391
|
+
"- NOTE: only this machine has the required capabilities, so the "
|
|
392
|
+
"smoke test is running on the same machine that built the change. "
|
|
393
|
+
"Test the *built artifact*, not the source — the build step here is "
|
|
394
|
+
"your verification that the change compiles."
|
|
395
|
+
)
|
|
396
|
+
lines.append(f"- Timeout: {timeout_seconds}s")
|
|
397
|
+
lines.append("")
|
|
398
|
+
lines.append("## What to do")
|
|
399
|
+
lines.append("")
|
|
400
|
+
lines.append(
|
|
401
|
+
"Run these in your **worktree** (your current directory). Do NOT "
|
|
402
|
+
"`cd` into the machine's shared base checkout and do NOT "
|
|
403
|
+
"`git checkout` there — leaving it parked on a feature branch breaks "
|
|
404
|
+
"every later dispatch against that branch on this machine (#1694)."
|
|
405
|
+
)
|
|
406
|
+
lines.append("")
|
|
407
|
+
lines.append("```bash")
|
|
408
|
+
lines.append("git fetch origin")
|
|
409
|
+
lines.append(f"git checkout {branch}")
|
|
410
|
+
lines.append("git pull --ff-only origin " + branch)
|
|
411
|
+
lines.append(smoke_command)
|
|
412
|
+
lines.append("```")
|
|
413
|
+
lines.append("")
|
|
414
|
+
lines.append(
|
|
415
|
+
"Report `SMOKE: pass` on exit 0, or "
|
|
416
|
+
"`SMOKE: fail <one-line reason>` on non-zero. The coordinator reads "
|
|
417
|
+
"the final exit code."
|
|
418
|
+
)
|
|
419
|
+
return "\n".join(lines)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# ── Diff lookup (which files did the worker change?) ────────────────────────
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _fetch_touched_files(repo_github: str, branch: str) -> list[str]:
|
|
426
|
+
"""Return the list of files changed on `branch` vs the base branch.
|
|
427
|
+
|
|
428
|
+
Uses `gh pr view --json files` so the lookup works without a local
|
|
429
|
+
checkout on the coordinator. Returns an empty list on lookup failure —
|
|
430
|
+
the caller treats that as "no rules matched" and skips smoke.
|
|
431
|
+
"""
|
|
432
|
+
pr = None
|
|
433
|
+
try:
|
|
434
|
+
pr = github_ops.find_pr_for_branch(repo_github, branch)
|
|
435
|
+
except RuntimeError:
|
|
436
|
+
pr = None
|
|
437
|
+
if pr is None:
|
|
438
|
+
return []
|
|
439
|
+
try:
|
|
440
|
+
raw = github_ops._gh(
|
|
441
|
+
"pr", "view", str(pr["number"]),
|
|
442
|
+
"--repo", repo_github,
|
|
443
|
+
"--json", "files",
|
|
444
|
+
)
|
|
445
|
+
except RuntimeError:
|
|
446
|
+
return []
|
|
447
|
+
try:
|
|
448
|
+
data = json.loads(raw)
|
|
449
|
+
except ValueError:
|
|
450
|
+
return []
|
|
451
|
+
files = data.get("files", []) or []
|
|
452
|
+
return [f.get("path", "") for f in files if f.get("path")]
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
# ── Unroutable reporting (#1672) ────────────────────────────────────────────
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
#: ``test_state`` recorded on the parent work row when no capability-matched
|
|
459
|
+
#: machine can run the Test stage and the condition will NOT clear on its own
|
|
460
|
+
#: (#1672). Deliberately distinct from ``"failed"``: nothing is wrong with the
|
|
461
|
+
#: branch, so this must never trigger a fix round — it is a fleet/config fault,
|
|
462
|
+
#: and every gate that asks for ``"passed"``/``"skipped"`` keeps the merge shut
|
|
463
|
+
#: exactly as it did while the state was NULL.
|
|
464
|
+
TEST_STATE_BLOCKED = "blocked"
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
#: Soft (transient) unroutable reports already logged this process, keyed by
|
|
468
|
+
#: ``(assignment_id, message)``. Transient conditions — a machine that is
|
|
469
|
+
#: merely unreachable right now — are left re-dispatchable so the stage
|
|
470
|
+
#: self-heals on a later tick; this memo is what stops the retry from also
|
|
471
|
+
#: re-logging every 30 s (#1672). Bounded so a long-lived daemon can't grow it
|
|
472
|
+
#: without limit.
|
|
473
|
+
_SOFT_REPORTS_SEEN: set[tuple[str, str]] = set()
|
|
474
|
+
_SOFT_REPORTS_MAX = 512
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@dataclass
|
|
478
|
+
class SmokeAttempt:
|
|
479
|
+
"""One machine `dispatch_smoke` tried and could not use (#1672)."""
|
|
480
|
+
|
|
481
|
+
machine_name: str
|
|
482
|
+
reason: str
|
|
483
|
+
#: True when the reason is expected to clear without operator action (a
|
|
484
|
+
#: connectivity blip). False for durable faults — an explicit `/health`
|
|
485
|
+
#: probe contradiction (#1570 D) or a missing `repo_paths` entry — which
|
|
486
|
+
#: stay broken until somebody fixes the machine or the config.
|
|
487
|
+
transient: bool = False
|
|
488
|
+
|
|
489
|
+
def describe(self) -> str:
|
|
490
|
+
return f"{self.machine_name}: {self.reason}"
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def _report_unroutable_smoke(
|
|
494
|
+
completed: Assignment,
|
|
495
|
+
required_caps: list[str],
|
|
496
|
+
attempts: list[SmokeAttempt],
|
|
497
|
+
) -> None:
|
|
498
|
+
"""Report — once — that the Test stage has no machine it can run on.
|
|
499
|
+
|
|
500
|
+
#1672/#1678: the old code logged a WARNING and returned. Nothing was
|
|
501
|
+
written anywhere the TUI, `coord gates` or the board could show it, and
|
|
502
|
+
the daemon re-ran the identical refusal every 30 s forever. The Test stage
|
|
503
|
+
simply never started and the only trace was `journalctl` on the daemon
|
|
504
|
+
host — the #1616 failure shape again: the pipeline stops and the product
|
|
505
|
+
says nothing.
|
|
506
|
+
|
|
507
|
+
Two outcomes, split on whether the condition can clear by itself:
|
|
508
|
+
|
|
509
|
+
* **Durable** (every candidate hard-refused, or there were no candidates
|
|
510
|
+
at all) — record ``test_state=TEST_STATE_BLOCKED`` with the full reason
|
|
511
|
+
on the parent work row. That is board state: `coord gates` prints it,
|
|
512
|
+
the TUI reads it off the row, and `record_test_verdict` writes an
|
|
513
|
+
``test_blocked`` audit row. It also ends the spin, because
|
|
514
|
+
`dispatch_pending_smoke` skips rows that already carry a verdict — the
|
|
515
|
+
escalation happens once, not every tick.
|
|
516
|
+
* **Transient** (at least one candidate failed only on connectivity) — do
|
|
517
|
+
NOT poison the row. A machine that is rebooting comes back, and marking
|
|
518
|
+
the row blocked would demand a manual `coord diagnose --reset` for what
|
|
519
|
+
the next tick would have fixed for free. Log it once per process
|
|
520
|
+
instead, and leave the row re-dispatchable.
|
|
521
|
+
|
|
522
|
+
Never raises: a board-write failure must not take the caller down.
|
|
523
|
+
"""
|
|
524
|
+
transient = any(a.transient for a in attempts)
|
|
525
|
+
caps = ", ".join(required_caps) if required_caps else "(none — any capable machine)"
|
|
526
|
+
if attempts:
|
|
527
|
+
message = (
|
|
528
|
+
f"Test stage cannot be routed: every machine that declares "
|
|
529
|
+
f"capability [{caps}] for repo {completed.repo_name!r} refused. "
|
|
530
|
+
f"Tried {len(attempts)} — "
|
|
531
|
+
+ "; ".join(a.describe() for a in attempts)
|
|
532
|
+
+ "."
|
|
533
|
+
)
|
|
534
|
+
else:
|
|
535
|
+
message = (
|
|
536
|
+
f"Test stage cannot be routed: no configured machine declares "
|
|
537
|
+
f"capability [{caps}] AND can build repo "
|
|
538
|
+
f"{completed.repo_name!r} — the Test stage cannot run for this "
|
|
539
|
+
f"completion until a capable machine is added."
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
def _log_once(level: int, text: str) -> None:
|
|
543
|
+
"""Log `text` at most once per (row, message) for this process.
|
|
544
|
+
|
|
545
|
+
The board row is what normally makes the durable report fire once; a
|
|
546
|
+
transient dead end deliberately does NOT write to the row (it must
|
|
547
|
+
stay re-dispatchable), and a row with no assignment_id has nowhere to
|
|
548
|
+
write at all — so both need this memo instead. Either way the daemon
|
|
549
|
+
journal gets ONE line, never one every 30 s.
|
|
550
|
+
"""
|
|
551
|
+
key = (completed.assignment_id or "", text)
|
|
552
|
+
if key in _SOFT_REPORTS_SEEN:
|
|
553
|
+
return
|
|
554
|
+
if len(_SOFT_REPORTS_SEEN) >= _SOFT_REPORTS_MAX:
|
|
555
|
+
_SOFT_REPORTS_SEEN.clear()
|
|
556
|
+
_SOFT_REPORTS_SEEN.add(key)
|
|
557
|
+
logger.log(
|
|
558
|
+
level, "dispatch_smoke: %s#%s — %s",
|
|
559
|
+
completed.repo_name, completed.issue_number, text,
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
if transient:
|
|
563
|
+
_log_once(
|
|
564
|
+
logging.WARNING,
|
|
565
|
+
f"{message} Leaving the row re-dispatchable; a later tick "
|
|
566
|
+
"retries. (#1672)",
|
|
567
|
+
)
|
|
568
|
+
return
|
|
569
|
+
|
|
570
|
+
reason = (
|
|
571
|
+
f"{message} Fix the machine (or add a capable one) and clear this "
|
|
572
|
+
f"with `coord diagnose {completed.repo_name} "
|
|
573
|
+
f"{completed.issue_number} --stage test --reset` to re-dispatch. "
|
|
574
|
+
"(#1672)"
|
|
575
|
+
)
|
|
576
|
+
if completed.test_state == TEST_STATE_BLOCKED:
|
|
577
|
+
return # already recorded on the row — the report has been made
|
|
578
|
+
if completed.assignment_id is None:
|
|
579
|
+
# No row to write to (shouldn't happen for a board completion) — the
|
|
580
|
+
# log is the only surface left, so at least don't repeat it forever.
|
|
581
|
+
_log_once(logging.ERROR, reason)
|
|
582
|
+
return
|
|
583
|
+
logger.error(
|
|
584
|
+
"dispatch_smoke: %s#%s — %s", completed.repo_name,
|
|
585
|
+
completed.issue_number, reason,
|
|
586
|
+
)
|
|
587
|
+
try:
|
|
588
|
+
from coord.state import record_test_verdict
|
|
589
|
+
|
|
590
|
+
record_test_verdict(
|
|
591
|
+
assignment_id=completed.assignment_id,
|
|
592
|
+
test_state=TEST_STATE_BLOCKED,
|
|
593
|
+
test_reason=reason,
|
|
594
|
+
)
|
|
595
|
+
except Exception: # noqa: BLE001 — reporting must never break dispatch
|
|
596
|
+
logger.exception(
|
|
597
|
+
"dispatch_smoke: failed to record the blocked Test verdict for %s",
|
|
598
|
+
completed.assignment_id,
|
|
599
|
+
)
|
|
600
|
+
return
|
|
601
|
+
completed.test_state = TEST_STATE_BLOCKED
|
|
602
|
+
completed.test_reason = reason
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
# ── Dispatch ────────────────────────────────────────────────────────────────
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
PRLookup = Callable[..., dict | None]
|
|
609
|
+
DiffLookup = Callable[[str, str], list[str]]
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def dispatch_smoke(
|
|
613
|
+
completed: Assignment,
|
|
614
|
+
board: Board,
|
|
615
|
+
config: Config,
|
|
616
|
+
*,
|
|
617
|
+
http_client: httpx.Client | None = None,
|
|
618
|
+
diff_lookup: DiffLookup = _fetch_touched_files,
|
|
619
|
+
now: float | None = None,
|
|
620
|
+
) -> Assignment | None:
|
|
621
|
+
"""Queue a smoke test for a completed work-like assignment (#930: also
|
|
622
|
+
``type="mock-author"`` — see :data:`coord.models.WORK_LIKE_TYPES`).
|
|
623
|
+
|
|
624
|
+
Returns the new smoke `Assignment`, or None when no smoke is needed
|
|
625
|
+
(no rules matched, no capable machine, smoke disabled, etc.). The
|
|
626
|
+
caller is responsible for persisting the board.
|
|
627
|
+
|
|
628
|
+
#1672: routing walks the FULL capability-matched candidate list (see
|
|
629
|
+
:func:`rank_smoke_machines`) instead of standing or falling on one
|
|
630
|
+
machine, and a dead end is reported on the row rather than re-logged on
|
|
631
|
+
every daemon tick — see :func:`_report_unroutable_smoke`.
|
|
632
|
+
"""
|
|
633
|
+
smoke_cfg = getattr(config, "smoke_tests", SmokeTestsConfig())
|
|
634
|
+
if not smoke_cfg.auto_queue:
|
|
635
|
+
return None
|
|
636
|
+
if completed.type not in WORK_LIKE_TYPES:
|
|
637
|
+
return None
|
|
638
|
+
if completed.status != "done":
|
|
639
|
+
return None
|
|
640
|
+
if not completed.branch:
|
|
641
|
+
return None
|
|
642
|
+
if completed.test_state == TEST_STATE_BLOCKED:
|
|
643
|
+
# #1672: already reported as unroutable, with the reason on the row.
|
|
644
|
+
# Re-probing the same broken fleet on every tick is exactly the spin
|
|
645
|
+
# this issue is about — an operator clears it (`coord diagnose
|
|
646
|
+
# --stage test --reset`) once the fleet is fixed. `dispatch_pending_
|
|
647
|
+
# smoke` already skips rows with a verdict; this covers the callers
|
|
648
|
+
# that hand us a row directly (reconcile).
|
|
649
|
+
return None
|
|
650
|
+
|
|
651
|
+
# Dedupe: don't fire a second smoke if one's already in flight.
|
|
652
|
+
from coord.claim import (
|
|
653
|
+
has_active_branch_followup,
|
|
654
|
+
has_active_followup,
|
|
655
|
+
superseding_work_row,
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
if has_active_followup(
|
|
659
|
+
board, of_assignment_id=completed.assignment_id, assignment_type="smoke"
|
|
660
|
+
):
|
|
661
|
+
return None
|
|
662
|
+
|
|
663
|
+
# #1819: ...and don't fire one if another row on the SAME BRANCH already
|
|
664
|
+
# has one in flight. The suite measures the branch, not the row that
|
|
665
|
+
# pushed it; after a fix round (`--fix-of` reuses the branch by design)
|
|
666
|
+
# one branch carries two `work` rows, and the row-keyed dedupe above
|
|
667
|
+
# waved the sibling straight through — two machines ran the identical
|
|
668
|
+
# suite on the identical branch and raced to write the verdict (#1797).
|
|
669
|
+
if has_active_branch_followup(
|
|
670
|
+
board,
|
|
671
|
+
repo_name=completed.repo_name,
|
|
672
|
+
branch=completed.branch,
|
|
673
|
+
assignment_type="smoke",
|
|
674
|
+
):
|
|
675
|
+
return None
|
|
676
|
+
|
|
677
|
+
# #1819: a row that a LATER work-like row superseded on the same branch is
|
|
678
|
+
# not a dispatch target at all. It did not produce the branch's current
|
|
679
|
+
# content, so testing it burns a machine on a result the later row's own
|
|
680
|
+
# dispatch already computes, and the verdict lands on a row nothing gates
|
|
681
|
+
# on. This is what keeps the round-1 row (review=request-changes, fixed by
|
|
682
|
+
# round 2) from consuming a machine every time the base moves.
|
|
683
|
+
superseded_by = superseding_work_row(board, completed)
|
|
684
|
+
if superseded_by is not None:
|
|
685
|
+
logger.debug(
|
|
686
|
+
"dispatch_smoke: skipping %s#%s row %s — superseded on branch %s "
|
|
687
|
+
"by the later work row %s (#1819).",
|
|
688
|
+
completed.repo_name, completed.issue_number,
|
|
689
|
+
completed.assignment_id, completed.branch,
|
|
690
|
+
getattr(superseded_by, "assignment_id", None),
|
|
691
|
+
)
|
|
692
|
+
return None
|
|
693
|
+
|
|
694
|
+
repo = config.repo(completed.repo_name)
|
|
695
|
+
if repo is None:
|
|
696
|
+
return None
|
|
697
|
+
|
|
698
|
+
touched = diff_lookup(repo.github, completed.branch)
|
|
699
|
+
required_caps = match_rules(touched, smoke_cfg.capability_rules)
|
|
700
|
+
smoke_command = smoke_cfg.default_command or repo.test_command
|
|
701
|
+
|
|
702
|
+
if not required_caps:
|
|
703
|
+
# #1426: a capability-rule miss used to mean "skip silently" — the
|
|
704
|
+
# exact blocker that kept the Test stage from ever dispatching for
|
|
705
|
+
# any repo/diff not explicitly covered by a `capability_rules` entry
|
|
706
|
+
# (historically only `tui/` and `coord/dashboard/webapp/` were
|
|
707
|
+
# routed; everything else — most `coord/**` Python work included —
|
|
708
|
+
# never got a headless Test-stage dispatch at all).
|
|
709
|
+
#
|
|
710
|
+
# It now means "no EXTRA hardware capability required", not "nothing
|
|
711
|
+
# to test": a `type="work"` completion still dispatches, to any
|
|
712
|
+
# machine that can build/test the repo at all, as long as a real
|
|
713
|
+
# command is configured (`smoke_tests.default_command` or the repo's
|
|
714
|
+
# `test_command`) — see `pick_smoke_machine`, which treats an empty
|
|
715
|
+
# `required_caps` as "any capable-for-repo machine".
|
|
716
|
+
#
|
|
717
|
+
# `mock-author`/`test-author` (#930/#1176) keep the OLD skip-on-miss
|
|
718
|
+
# behavior: #1076/#1152 established that a rule miss for THOSE types
|
|
719
|
+
# means "genuinely nothing to smoke-test" (a Gate-A contract/fixture-
|
|
720
|
+
# only diff), and `dispatch_pending_reviews` back-fills
|
|
721
|
+
# `test_state="skipped"` for them — dispatching a real suite run
|
|
722
|
+
# here would duplicate that and burn a full test run on a diff that
|
|
723
|
+
# never touches source.
|
|
724
|
+
if completed.type != "work" or smoke_command is None:
|
|
725
|
+
return None
|
|
726
|
+
|
|
727
|
+
if smoke_command is None:
|
|
728
|
+
logger.warning(
|
|
729
|
+
"dispatch_smoke: %s#%s needs capabilities %s but no smoke "
|
|
730
|
+
"command is configured (smoke_tests.default_command or this "
|
|
731
|
+
"repo's test_command) — skipping. Configure one so the Test "
|
|
732
|
+
"stage stops silently no-oping for this repo.",
|
|
733
|
+
completed.repo_name, completed.issue_number, required_caps,
|
|
734
|
+
)
|
|
735
|
+
return None
|
|
736
|
+
|
|
737
|
+
# #1672: the FULL capability-matched candidate list, best first. Picking
|
|
738
|
+
# one machine and giving up on it meant a single bad candidate ended the
|
|
739
|
+
# whole Test stage — #1678, where the router re-chose the same unhealthy
|
|
740
|
+
# machine every 30 s while two other machines declared the same
|
|
741
|
+
# capability and were never tried. Capability matching itself is NOT
|
|
742
|
+
# relaxed: `rank_smoke_machines` only ever yields machines that genuinely
|
|
743
|
+
# declare every required capability.
|
|
744
|
+
candidates = rank_smoke_machines(
|
|
745
|
+
required_caps, completed.repo_name, completed.machine_name, board, config
|
|
746
|
+
)
|
|
747
|
+
attempts: list[SmokeAttempt] = []
|
|
748
|
+
client = http_client or httpx
|
|
749
|
+
dispatched: tuple[SmokeMachineChoice, str, dict] | None = None
|
|
750
|
+
|
|
751
|
+
for choice in candidates:
|
|
752
|
+
if required_caps:
|
|
753
|
+
unmet = _capability_probe_reasons(
|
|
754
|
+
choice.machine, required_caps, http_client=http_client
|
|
755
|
+
)
|
|
756
|
+
if unmet:
|
|
757
|
+
# #1570 D: the machine *claims* every required capability in
|
|
758
|
+
# `coordinator.yml`, but its own `/health` probe (#1570 B)
|
|
759
|
+
# says otherwise — refuse to route HERE rather than dispatch
|
|
760
|
+
# a worker that fails 20 minutes in with a confusing,
|
|
761
|
+
# unrelated error. #1672: that refusal is per-machine, so
|
|
762
|
+
# keep walking the candidate list instead of ending the
|
|
763
|
+
# stage. Durable, not transient — the probe disagrees until
|
|
764
|
+
# somebody installs the tool.
|
|
765
|
+
logger.warning(
|
|
766
|
+
"dispatch_smoke: skipping machine %s for %s#%s — its own "
|
|
767
|
+
"/health probe disagrees with its declared capabilities "
|
|
768
|
+
"%s — %s — refusing to route (#1570 D). Trying the next "
|
|
769
|
+
"capability-matched machine (#1672); run `coord doctor` "
|
|
770
|
+
"to check the fleet.",
|
|
771
|
+
choice.machine.name, completed.repo_name,
|
|
772
|
+
completed.issue_number, required_caps, unmet,
|
|
773
|
+
)
|
|
774
|
+
attempts.append(SmokeAttempt(
|
|
775
|
+
machine_name=choice.machine.name,
|
|
776
|
+
reason=(
|
|
777
|
+
f"/health probe contradicts its declared capabilities "
|
|
778
|
+
f"{required_caps} — {unmet} (#1570 D)"
|
|
779
|
+
),
|
|
780
|
+
))
|
|
781
|
+
continue
|
|
782
|
+
|
|
783
|
+
repo_path = choice.machine.repo_path(completed.repo_name)
|
|
784
|
+
if repo_path is None:
|
|
785
|
+
logger.warning(
|
|
786
|
+
"dispatch_smoke: skipping machine %s for %s#%s — it has no "
|
|
787
|
+
"repo_paths entry for %r.",
|
|
788
|
+
choice.machine.name, completed.repo_name,
|
|
789
|
+
completed.issue_number, completed.repo_name,
|
|
790
|
+
)
|
|
791
|
+
attempts.append(SmokeAttempt(
|
|
792
|
+
machine_name=choice.machine.name,
|
|
793
|
+
reason=f"no repo_paths entry for {completed.repo_name!r}",
|
|
794
|
+
))
|
|
795
|
+
continue
|
|
796
|
+
|
|
797
|
+
briefing = build_smoke_briefing(
|
|
798
|
+
repo_github=repo.github,
|
|
799
|
+
repo_name=repo.name,
|
|
800
|
+
branch=completed.branch,
|
|
801
|
+
issue_number=completed.issue_number,
|
|
802
|
+
issue_title=completed.issue_title,
|
|
803
|
+
smoke_command=smoke_command,
|
|
804
|
+
required_caps=required_caps,
|
|
805
|
+
timeout_seconds=smoke_cfg.timeout_seconds,
|
|
806
|
+
is_worker=choice.is_worker,
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
payload = {
|
|
810
|
+
"repo_name": completed.repo_name,
|
|
811
|
+
"repo_path": repo_path,
|
|
812
|
+
"issue_number": completed.issue_number,
|
|
813
|
+
"issue_title": f"[smoke] {completed.issue_title}",
|
|
814
|
+
"briefing": briefing,
|
|
815
|
+
"files_allowed": [],
|
|
816
|
+
"files_forbidden": [],
|
|
817
|
+
"pull_repos": [],
|
|
818
|
+
"type": "smoke",
|
|
819
|
+
"system_prompt": SMOKE_SYSTEM_PROMPT,
|
|
820
|
+
"review_target": completed.branch,
|
|
821
|
+
# #255: smoke checks out the worker's PR branch but the agent still
|
|
822
|
+
# consults `branch` as the integration base.
|
|
823
|
+
"branch": repo.default_branch or "main",
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
url = f"http://{choice.machine.host}:{AGENT_PORT}/assign"
|
|
827
|
+
try:
|
|
828
|
+
resp = client.post(url, json=payload, timeout=15)
|
|
829
|
+
resp.raise_for_status()
|
|
830
|
+
agent_response = resp.json()
|
|
831
|
+
except (httpx.HTTPError, httpx.TimeoutException) as exc:
|
|
832
|
+
# #1672: TRANSIENT — the machine is capable and its probe agreed,
|
|
833
|
+
# it just didn't answer. Try the next candidate, but if none is
|
|
834
|
+
# left the row stays re-dispatchable rather than blocked: a
|
|
835
|
+
# rebooting machine comes back, and poisoning the row would cost
|
|
836
|
+
# an operator a manual reset for something the next tick fixes.
|
|
837
|
+
logger.warning(
|
|
838
|
+
"dispatch_smoke: POST /assign to %s for %s#%s failed (%s) — "
|
|
839
|
+
"trying the next capability-matched machine (#1672).",
|
|
840
|
+
choice.machine.name, completed.repo_name,
|
|
841
|
+
completed.issue_number, exc,
|
|
842
|
+
)
|
|
843
|
+
attempts.append(SmokeAttempt(
|
|
844
|
+
machine_name=choice.machine.name,
|
|
845
|
+
reason=f"POST /assign failed — {exc}",
|
|
846
|
+
transient=True,
|
|
847
|
+
))
|
|
848
|
+
continue
|
|
849
|
+
|
|
850
|
+
dispatched = (choice, briefing, agent_response)
|
|
851
|
+
break
|
|
852
|
+
|
|
853
|
+
if dispatched is None:
|
|
854
|
+
# Every capability-matched machine was tried and none could take it
|
|
855
|
+
# (or there were none at all). Report it where the board can show it,
|
|
856
|
+
# exactly once — never the silent 30 s spin of #1678.
|
|
857
|
+
_report_unroutable_smoke(completed, required_caps, attempts)
|
|
858
|
+
return None
|
|
859
|
+
|
|
860
|
+
choice, briefing, agent_response = dispatched
|
|
861
|
+
|
|
862
|
+
smoke_assignment = Assignment(
|
|
863
|
+
machine_name=choice.machine.name,
|
|
864
|
+
repo_name=completed.repo_name,
|
|
865
|
+
issue_number=completed.issue_number,
|
|
866
|
+
issue_title=f"[smoke] {completed.issue_title}",
|
|
867
|
+
files_allowed=[],
|
|
868
|
+
files_forbidden=[],
|
|
869
|
+
briefing=briefing,
|
|
870
|
+
assignment_id=agent_response.get("id") or uuid.uuid4().hex[:12],
|
|
871
|
+
status="running",
|
|
872
|
+
branch=completed.branch,
|
|
873
|
+
pr_url=completed.pr_url,
|
|
874
|
+
dispatched_at=now if now is not None else time.time(),
|
|
875
|
+
type="smoke",
|
|
876
|
+
review_target=completed.branch,
|
|
877
|
+
review_of_assignment_id=completed.assignment_id,
|
|
878
|
+
)
|
|
879
|
+
board.active.append(smoke_assignment)
|
|
880
|
+
|
|
881
|
+
from coord.state import record_dispatched_assignment
|
|
882
|
+
repo = config.repo(completed.repo_name)
|
|
883
|
+
if repo is not None:
|
|
884
|
+
record_dispatched_assignment(
|
|
885
|
+
assignment=smoke_assignment,
|
|
886
|
+
repo_github=repo.github,
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
# #1395/#1426: mark the PARENT work row's Test verdict "running" the
|
|
890
|
+
# moment the smoke assignment is dispatched — the same marker
|
|
891
|
+
# `coord test --running` set for the old local-subprocess path, so the
|
|
892
|
+
# board/TUI reads the Test box Active for the run's duration instead of
|
|
893
|
+
# idle/Pending. Without this, dispatching the Test stage as a real
|
|
894
|
+
# assignment would silently reopen the #1395 gap it was built to close:
|
|
895
|
+
# `test_state` would stay NULL from dispatch until the terminal verdict
|
|
896
|
+
# lands, indistinguishable from "not started yet".
|
|
897
|
+
#
|
|
898
|
+
# #1819: ...but NEVER over a terminal verdict. `running` is read as "no
|
|
899
|
+
# verdict yet" by every gate (#1395), so stamping it on a row that already
|
|
900
|
+
# says `passed`/`skipped` *un-satisfies a gate that was satisfied* — the
|
|
901
|
+
# merge entry drops out of the queue and the whole cycle restarts. That is
|
|
902
|
+
# the self-sustaining loop observed on #1797: verdict lands → merge
|
|
903
|
+
# enqueues → a re-dispatch a minute later clobbers the gate field back to
|
|
904
|
+
# `running` → the merge never fires → repeat. Dispatching a *fresh* run
|
|
905
|
+
# must never, by itself, retract the previous answer; the new verdict
|
|
906
|
+
# replaces the old one when it actually lands.
|
|
907
|
+
if completed.assignment_id is not None and completed.test_state not in (
|
|
908
|
+
"passed", "skipped", "failed",
|
|
909
|
+
):
|
|
910
|
+
from coord.state import record_test_verdict
|
|
911
|
+
|
|
912
|
+
record_test_verdict(
|
|
913
|
+
assignment_id=completed.assignment_id,
|
|
914
|
+
test_state="running",
|
|
915
|
+
test_reason="dispatched: Test stage running (#1426)",
|
|
916
|
+
)
|
|
917
|
+
completed.test_state = "running"
|
|
918
|
+
|
|
919
|
+
return smoke_assignment
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
# ── Bulk dispatch (#1426) ────────────────────────────────────────────────────
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
def dispatch_pending_smoke(
|
|
926
|
+
board: Board,
|
|
927
|
+
config: Config,
|
|
928
|
+
*,
|
|
929
|
+
now: float | None = None,
|
|
930
|
+
) -> list[Assignment]:
|
|
931
|
+
"""Bulk Test-stage dispatch — the smoke analogue of
|
|
932
|
+
:func:`coord.review.dispatch_pending_reviews`.
|
|
933
|
+
|
|
934
|
+
Scans the FULL completed backlog on `board` (not just rows that just
|
|
935
|
+
transitioned this pass) for work-like completions with no test verdict
|
|
936
|
+
yet, and dispatches a smoke assignment for each eligible one via
|
|
937
|
+
:func:`dispatch_smoke` (which itself enforces `auto_queue`, the #459-style
|
|
938
|
+
dedupe via `has_active_followup`, and capability routing).
|
|
939
|
+
|
|
940
|
+
This is the single choke point both `reconcile()` (human-invoked `coord
|
|
941
|
+
resume`) and `coord notify` (the unattended 5-minute timer) route bulk
|
|
942
|
+
Test-stage dispatch through — mirroring `dispatch_pending_reviews`
|
|
943
|
+
exactly. Before this, `dispatch_smoke` was only ever called from
|
|
944
|
+
`reconcile()`'s per-item loop over that pass's newly-done rows, so a
|
|
945
|
+
thin-client/timer-only setup with nobody running `coord resume` never
|
|
946
|
+
dispatched the Test stage at all — the gap `drive-issue.sh` had to paper
|
|
947
|
+
over with a local `scripts/coord-test-runner.sh` subprocess (#1395).
|
|
948
|
+
|
|
949
|
+
Returns the list of smoke `Assignment`s actually dispatched. The caller
|
|
950
|
+
is responsible for persisting the board.
|
|
951
|
+
"""
|
|
952
|
+
smoke_cfg = getattr(config, "smoke_tests", None)
|
|
953
|
+
if smoke_cfg is None or not smoke_cfg.auto_queue:
|
|
954
|
+
return []
|
|
955
|
+
|
|
956
|
+
from coord.state import get_issue_test_mode
|
|
957
|
+
|
|
958
|
+
dispatched: list[Assignment] = []
|
|
959
|
+
for completed in board.completed:
|
|
960
|
+
if completed.type not in WORK_LIKE_TYPES:
|
|
961
|
+
continue
|
|
962
|
+
if completed.status != "done":
|
|
963
|
+
continue
|
|
964
|
+
if completed.test_state is not None:
|
|
965
|
+
# Already has a verdict ("passed"/"failed"/"skipped"), or is
|
|
966
|
+
# "running" — someone (an interactive --smoke-of session, or a
|
|
967
|
+
# smoke assignment already in flight) is already handling it.
|
|
968
|
+
#
|
|
969
|
+
# #1672: this is also what makes the unroutable report fire ONCE.
|
|
970
|
+
# `dispatch_smoke` records `test_state="blocked"` when no
|
|
971
|
+
# capability-matched machine can take the stage, so the next tick
|
|
972
|
+
# lands here and skips instead of re-probing a fleet that is
|
|
973
|
+
# still broken and re-logging the identical refusal every 30 s
|
|
974
|
+
# (#1678). Clearing it (`coord diagnose <repo> <issue> --stage
|
|
975
|
+
# test --reset`) puts the row back in this scan.
|
|
976
|
+
continue
|
|
977
|
+
|
|
978
|
+
# #685: per-issue test-mode policy gates auto-smoke dispatch.
|
|
979
|
+
# test-mode:auto → headless smoke (auto-dispatch here).
|
|
980
|
+
# test-mode:smoke → skip; the TUI offers the interactive smoke agent.
|
|
981
|
+
# no label → no policy set → respect auto_queue (back-compat).
|
|
982
|
+
test_mode = get_issue_test_mode(completed.repo_name, completed.issue_number)
|
|
983
|
+
if test_mode == "smoke":
|
|
984
|
+
# #2024: say so. This skip is CORRECT (the policy asked for a
|
|
985
|
+
# human-attended Test stage) but it was completely silent, and
|
|
986
|
+
# silence here is what dead-ends an unattended `--fix-of` round:
|
|
987
|
+
# review dispatch is held until this row carries a passed/skipped
|
|
988
|
+
# verdict (`pipeline.test_precedes_review`), and under this policy
|
|
989
|
+
# no automatic component will ever produce one. Round 0 gets
|
|
990
|
+
# attended because a human is watching; rounds 1..N complete at 3am
|
|
991
|
+
# and sit there (vimcode#635: 25 min, then 160 min). The DRIVER now
|
|
992
|
+
# recognises the shape from the same label and escalates instead of
|
|
993
|
+
# counting (`coord.dead_end` shape 3) — this line is the daemon-side
|
|
994
|
+
# half of the same statement, for whoever reads the log first.
|
|
995
|
+
#
|
|
996
|
+
# Deliberately NOT widened to "dispatch anyway for fix rounds":
|
|
997
|
+
# that would silently override an explicit per-issue policy. The
|
|
998
|
+
# answer to "nobody will produce a verdict" is to SAY so, not to
|
|
999
|
+
# take the decision away from the operator who set the label.
|
|
1000
|
+
#
|
|
1001
|
+
# ONCE per row per process, not once per tick — #1678 is the
|
|
1002
|
+
# standing lesson that a refusal re-logged every 30 s against a
|
|
1003
|
+
# state that cannot change is its own kind of noise (this row can
|
|
1004
|
+
# sit here for hours by design). The daemon is long-lived, so the
|
|
1005
|
+
# set is the right lifetime; a restart re-states it, which is
|
|
1006
|
+
# exactly when an operator wants to hear it again.
|
|
1007
|
+
if completed.assignment_id in _TEST_MODE_SKIP_LOGGED:
|
|
1008
|
+
continue
|
|
1009
|
+
if completed.assignment_id:
|
|
1010
|
+
_TEST_MODE_SKIP_LOGGED.add(completed.assignment_id)
|
|
1011
|
+
logger.info(
|
|
1012
|
+
"dispatch_pending_smoke: skipping %s#%s row %s — issue is "
|
|
1013
|
+
"labelled `test-mode:smoke`, so the headless Test stage is off "
|
|
1014
|
+
"for it by policy (#685) and NOTHING automatic will record a "
|
|
1015
|
+
"verdict. Review dispatch stays held until one exists: `coord "
|
|
1016
|
+
"test %s --passed|--skipped`, or run the attended stage with "
|
|
1017
|
+
"`coord assign <machine> %s %s --smoke-of %s --interactive` "
|
|
1018
|
+
"(#2024).",
|
|
1019
|
+
completed.repo_name, completed.issue_number,
|
|
1020
|
+
completed.assignment_id, completed.assignment_id,
|
|
1021
|
+
completed.repo_name, completed.issue_number,
|
|
1022
|
+
completed.assignment_id,
|
|
1023
|
+
)
|
|
1024
|
+
continue
|
|
1025
|
+
|
|
1026
|
+
smoke = dispatch_smoke(completed, board, config, now=now)
|
|
1027
|
+
if smoke is not None:
|
|
1028
|
+
dispatched.append(smoke)
|
|
1029
|
+
|
|
1030
|
+
return dispatched
|