code-coordinator 0.5.46__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
"""Smoke test plan generator — Phase A of #342.
|
|
2
|
+
|
|
3
|
+
Generates a structured, AI-assisted smoke test plan for a completed assignment
|
|
4
|
+
by calling ``claude -p`` (Haiku by default) with the PR diff, the repo's
|
|
5
|
+
CLAUDE.md, the artifact manifest from the agent, and the GitHub issue body.
|
|
6
|
+
|
|
7
|
+
The generated plan is validated against a known JSON shape and cached in the
|
|
8
|
+
``test_plan`` column of the ``assignments`` table. The CLI command
|
|
9
|
+
``coord test-plan`` is the primary consumer.
|
|
10
|
+
|
|
11
|
+
Phase B (TUI rendering) and Phase C (verdict auto-routing) are explicitly out
|
|
12
|
+
of scope here.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import re
|
|
21
|
+
import shutil
|
|
22
|
+
import socket
|
|
23
|
+
import subprocess
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
from coord.config import Config
|
|
29
|
+
from coord.models import Machine
|
|
30
|
+
|
|
31
|
+
AGENT_PORT = 7433
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
# ── System prompt for the plan generator ─────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
PLAN_SYSTEM_PROMPT = """\
|
|
38
|
+
You are generating a smoke test plan for a developer reviewing a code change.
|
|
39
|
+
Output JSON only, matching this exact shape:
|
|
40
|
+
{"steps": [{"kind": "pull"|"run"|"verify", "cmd": "...", "label": "...", "check": "..."}], "blockers": ["..."]}
|
|
41
|
+
|
|
42
|
+
Rules:
|
|
43
|
+
- Max 8 steps total.
|
|
44
|
+
- Prefer pulling pre-built artifacts (kind: "pull") over local rebuilds when \
|
|
45
|
+
the artifact manifest shows matching binaries. For a "pull" step include both \
|
|
46
|
+
"cmd" (the pull/copy command) and "label" (what is being pulled).
|
|
47
|
+
- When the manifest lists many files but the diff only concerns one or two \
|
|
48
|
+
example binaries, scope the pull with `coord pull-artifact <assignment_id> \
|
|
49
|
+
--only <name>` (repeatable, glob-matched) instead of pulling the whole \
|
|
50
|
+
stash — stashes can be dozens of ~100MB debug binaries (#940).
|
|
51
|
+
- For kind "run": include "cmd" with the exact shell command to run.
|
|
52
|
+
- For kind "verify": include "check" with a one-line concrete, observable \
|
|
53
|
+
assertion ("text glyphs visible inside GTK cells", not "looks correct"). \
|
|
54
|
+
Do NOT include "cmd" on verify steps unless a command produces the thing to inspect.
|
|
55
|
+
- The "blockers" array lists prerequisites that must be satisfied before \
|
|
56
|
+
testing can begin. Leave it empty when there are none.
|
|
57
|
+
- Never include COORD_CONFIG=<value> in cmd strings. The test runner sets \
|
|
58
|
+
the config path itself; baking it into a cmd creates a path that is wrong on \
|
|
59
|
+
every machine except the one that generated the plan.
|
|
60
|
+
- No markdown, no commentary, no extra keys outside the JSON object.\
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Matches a COORD_CONFIG=<value> token anywhere in a command string so it can
|
|
65
|
+
# be stripped from generated "cmd" fields. Pattern is intentionally narrow:
|
|
66
|
+
# it only removes the env-var assignment token (COORD_CONFIG=<non-whitespace>)
|
|
67
|
+
# followed by any separating whitespace — it does NOT touch other leading
|
|
68
|
+
# tokens such as "timeout 3 ..." or "sudo ...".
|
|
69
|
+
_COORD_CONFIG_RE = re.compile(r"\bCOORD_CONFIG=\S+\s*")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ── Internal helpers ──────────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
def _strip_fences(text: str) -> str:
|
|
75
|
+
"""Remove optional ```json ... ``` fences from Claude's response."""
|
|
76
|
+
cleaned = text.strip()
|
|
77
|
+
fence = re.match(r"^```(?:json)?\s*\n(.*?)```\s*$", cleaned, re.DOTALL)
|
|
78
|
+
return fence.group(1).strip() if fence else cleaned
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _validate_plan(data: object) -> dict:
|
|
82
|
+
"""Validate a raw parsed object against the plan shape.
|
|
83
|
+
|
|
84
|
+
Accepts extra keys on step objects (forward-compatibility).
|
|
85
|
+
Raises ``ValueError`` on structural violations.
|
|
86
|
+
Returns a normalised dict (steps capped at 8, blockers stringified).
|
|
87
|
+
"""
|
|
88
|
+
if not isinstance(data, dict):
|
|
89
|
+
raise ValueError(f"plan must be a JSON object, got {type(data).__name__}")
|
|
90
|
+
if "steps" not in data:
|
|
91
|
+
raise ValueError("plan missing required key 'steps'")
|
|
92
|
+
if "blockers" not in data:
|
|
93
|
+
raise ValueError("plan missing required key 'blockers'")
|
|
94
|
+
|
|
95
|
+
steps = data["steps"]
|
|
96
|
+
if not isinstance(steps, list):
|
|
97
|
+
raise ValueError(f"'steps' must be an array, got {type(steps).__name__}")
|
|
98
|
+
blockers = data["blockers"]
|
|
99
|
+
if not isinstance(blockers, list):
|
|
100
|
+
raise ValueError(f"'blockers' must be an array, got {type(blockers).__name__}")
|
|
101
|
+
|
|
102
|
+
valid_kinds = {"pull", "run", "verify"}
|
|
103
|
+
validated_steps: list[dict] = []
|
|
104
|
+
for i, step in enumerate(steps[:8]): # cap at 8
|
|
105
|
+
if not isinstance(step, dict):
|
|
106
|
+
raise ValueError(f"step {i} must be an object, got {type(step).__name__}")
|
|
107
|
+
kind = step.get("kind")
|
|
108
|
+
if kind not in valid_kinds:
|
|
109
|
+
raise ValueError(
|
|
110
|
+
f"step {i} has invalid kind {kind!r}; expected one of {sorted(valid_kinds)}"
|
|
111
|
+
)
|
|
112
|
+
# Pass through all keys (extra keys are allowed for forward-compat).
|
|
113
|
+
# Strip COORD_CONFIG=<value> from "cmd" fields — Claude sometimes
|
|
114
|
+
# prepends the env-var to commands; the test runner owns the config
|
|
115
|
+
# path and must not have it baked into the plan (#1100).
|
|
116
|
+
cleaned: dict = {}
|
|
117
|
+
for k, v in step.items():
|
|
118
|
+
if k == "cmd" and isinstance(v, str):
|
|
119
|
+
v = _COORD_CONFIG_RE.sub("", v).strip()
|
|
120
|
+
cleaned[k] = v
|
|
121
|
+
validated_steps.append(cleaned)
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
"steps": validated_steps,
|
|
125
|
+
"blockers": [str(b) for b in blockers],
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def resolve_claude_bin() -> str:
|
|
130
|
+
"""Resolve an absolute path to the ``claude`` binary (#859).
|
|
131
|
+
|
|
132
|
+
``_call_claude`` used to shell out to bare ``"claude"``, which relies on
|
|
133
|
+
the invoking process's ``$PATH`` containing ``~/.local/bin`` (where the
|
|
134
|
+
binary is typically installed). That holds for an interactive shell but
|
|
135
|
+
not for ``coord-serve``, which runs under ``systemd --user`` with the
|
|
136
|
+
default (empty ``Environment=``) PATH — so daemon-side plan generation
|
|
137
|
+
(a cache miss on ``coord test-plan``, routed to the daemon by #851)
|
|
138
|
+
failed with ``FileNotFoundError``. Same lesson as the #424/#425 PTY
|
|
139
|
+
escape hatch: cross-machine/service invocation must use an absolute
|
|
140
|
+
path, not a bare command name.
|
|
141
|
+
|
|
142
|
+
Resolution order:
|
|
143
|
+
1. ``$CLAUDE_BIN`` — explicit override for non-standard installs.
|
|
144
|
+
2. ``shutil.which("claude")`` — PATH lookup; works whenever the
|
|
145
|
+
caller's environment is sane (e.g. interactive shells).
|
|
146
|
+
3. ``~/.local/bin/claude`` — the standard install location, used
|
|
147
|
+
verbatim as a last-resort fallback even if it doesn't exist, so a
|
|
148
|
+
resulting ``FileNotFoundError`` still names the path that was
|
|
149
|
+
expected (easier to diagnose than a bare ``'claude'``).
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Absolute path (str) to use as argv[0] for the ``claude`` subprocess.
|
|
153
|
+
"""
|
|
154
|
+
override = os.environ.get("CLAUDE_BIN")
|
|
155
|
+
if override:
|
|
156
|
+
return override
|
|
157
|
+
found = shutil.which("claude")
|
|
158
|
+
if found:
|
|
159
|
+
return found
|
|
160
|
+
return str(Path.home() / ".local" / "bin" / "claude")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _call_claude(system: str, user: str, *, model: str = "haiku") -> str:
|
|
164
|
+
"""Invoke ``claude -p`` and return the text result.
|
|
165
|
+
|
|
166
|
+
Mirrors the pattern in ``coord.brain.call_claude`` — no Anthropic SDK.
|
|
167
|
+
The ``--output-format json`` flag gives a structured envelope; we extract
|
|
168
|
+
the ``result`` field. The binary is resolved to an absolute path (see
|
|
169
|
+
:func:`resolve_claude_bin`) so this works under ``coord-serve``'s
|
|
170
|
+
restricted-PATH ``systemd --user`` environment, not just interactively.
|
|
171
|
+
"""
|
|
172
|
+
cmd = [
|
|
173
|
+
resolve_claude_bin(), "-p",
|
|
174
|
+
"--system-prompt", system,
|
|
175
|
+
"--output-format", "json",
|
|
176
|
+
]
|
|
177
|
+
if model:
|
|
178
|
+
cmd += ["--model", model]
|
|
179
|
+
|
|
180
|
+
result = subprocess.run(
|
|
181
|
+
cmd,
|
|
182
|
+
input=user,
|
|
183
|
+
capture_output=True,
|
|
184
|
+
text=True,
|
|
185
|
+
timeout=120,
|
|
186
|
+
)
|
|
187
|
+
if result.returncode != 0:
|
|
188
|
+
raise RuntimeError(
|
|
189
|
+
f"claude -p failed (exit {result.returncode}): {result.stderr.strip()}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
outer = json.loads(result.stdout)
|
|
193
|
+
return outer.get("result", result.stdout)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _fetch_artifact_manifest(
|
|
197
|
+
machine_host: str,
|
|
198
|
+
repo_name: str,
|
|
199
|
+
branch: str,
|
|
200
|
+
) -> dict | None:
|
|
201
|
+
"""GET /artifact/<repo>/<sanitized_branch> from the agent.
|
|
202
|
+
|
|
203
|
+
Returns the manifest dict on success, ``None`` on 404 or network error.
|
|
204
|
+
The 404 case is not an error — the stash may have been GC'd or the
|
|
205
|
+
repo doesn't have ``artifact_paths`` configured.
|
|
206
|
+
"""
|
|
207
|
+
# Import lazily to avoid circular imports at module load time.
|
|
208
|
+
from coord.agent import _sanitize_branch # noqa: PLC0415
|
|
209
|
+
|
|
210
|
+
sanitized = _sanitize_branch(branch)
|
|
211
|
+
url = f"http://{machine_host}:{AGENT_PORT}/artifact/{repo_name}/{sanitized}"
|
|
212
|
+
try:
|
|
213
|
+
resp = httpx.get(url, timeout=10)
|
|
214
|
+
if resp.status_code == 404:
|
|
215
|
+
return None
|
|
216
|
+
resp.raise_for_status()
|
|
217
|
+
return resp.json()
|
|
218
|
+
except (httpx.HTTPError, ValueError) as exc:
|
|
219
|
+
log.debug("artifact manifest fetch failed for %s/%s: %s", repo_name, branch, exc)
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _get_pr_diff(pr_url: str, repo_github: str) -> str:
|
|
224
|
+
"""Fetch the diff via the ``github_ops`` seam (#1483 — no direct ``gh``
|
|
225
|
+
invocations outside ``coord.github_ops``).
|
|
226
|
+
|
|
227
|
+
Returns an empty string when the PR URL is missing, the gh CLI is
|
|
228
|
+
unavailable, or the command fails.
|
|
229
|
+
"""
|
|
230
|
+
if not pr_url or not repo_github:
|
|
231
|
+
return ""
|
|
232
|
+
m = re.search(r"/pull/(\d+)", pr_url)
|
|
233
|
+
if not m:
|
|
234
|
+
return ""
|
|
235
|
+
pr_number = int(m.group(1))
|
|
236
|
+
from coord import github_ops # noqa: PLC0415
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
diff = github_ops.pr_diff(repo_github, pr_number)
|
|
240
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
|
241
|
+
log.debug("gh pr diff failed: %s", exc)
|
|
242
|
+
return ""
|
|
243
|
+
return (diff or "").strip()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _get_git_diff(branch: str, default_branch: str, repo_dir: Path) -> str:
|
|
247
|
+
"""Fall-back: ``git diff <default_branch>...<branch>`` run locally.
|
|
248
|
+
|
|
249
|
+
Returns an empty string on any error.
|
|
250
|
+
"""
|
|
251
|
+
if not branch or not repo_dir.exists():
|
|
252
|
+
return ""
|
|
253
|
+
try:
|
|
254
|
+
result = subprocess.run(
|
|
255
|
+
["git", "diff", f"{default_branch}...{branch}"],
|
|
256
|
+
cwd=str(repo_dir),
|
|
257
|
+
capture_output=True, text=True, timeout=30,
|
|
258
|
+
)
|
|
259
|
+
if result.returncode == 0:
|
|
260
|
+
return result.stdout.strip()
|
|
261
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc:
|
|
262
|
+
log.debug("git diff failed: %s", exc)
|
|
263
|
+
return ""
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _get_issue_body(repo_github: str, issue_number: int) -> str:
|
|
267
|
+
"""Fetch issue title + body via the ``github_ops`` seam (#1483).
|
|
268
|
+
|
|
269
|
+
Returns a markdown string "## <title>\\n\\n<body>" or empty string on error.
|
|
270
|
+
"""
|
|
271
|
+
if not repo_github or not issue_number:
|
|
272
|
+
return ""
|
|
273
|
+
from coord import github_ops # noqa: PLC0415
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
data = github_ops.get_issue(repo_github, issue_number)
|
|
277
|
+
except (RuntimeError, subprocess.TimeoutExpired, FileNotFoundError, OSError,
|
|
278
|
+
json.JSONDecodeError) as exc:
|
|
279
|
+
log.debug("gh issue view failed: %s", exc)
|
|
280
|
+
return ""
|
|
281
|
+
title = data.get("title", "")
|
|
282
|
+
body = (data.get("body") or "").strip()
|
|
283
|
+
return f"## {title}\n\n{body}" if body else f"## {title}"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def local_machine(config: Config) -> Machine | None:
|
|
287
|
+
"""The configured ``Machine`` (if any) whose ``name`` or ``host`` prefix
|
|
288
|
+
matches this process's hostname.
|
|
289
|
+
|
|
290
|
+
Extracted out of :func:`find_local_repo_path`'s hostname-matching so
|
|
291
|
+
callers that need the ``Machine`` object itself (e.g. #966's acceptance
|
|
292
|
+
capability check, which needs ``.capabilities``, not just a repo path)
|
|
293
|
+
don't have to re-derive the match. Returns ``None`` when this host isn't
|
|
294
|
+
a recognized machine in ``coordinator.yml``.
|
|
295
|
+
"""
|
|
296
|
+
local_hostname = socket.gethostname().split(".")[0]
|
|
297
|
+
for machine in config.machines:
|
|
298
|
+
if machine.name == local_hostname or machine.host.split(".")[0] == local_hostname:
|
|
299
|
+
return machine
|
|
300
|
+
return None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def find_local_repo_path(repo_name: str, config: Config) -> Path | None:
|
|
304
|
+
"""Locate the repo on the local machine by matching against coordinator.yml.
|
|
305
|
+
|
|
306
|
+
Tries the machine whose name or host prefix matches this machine's hostname
|
|
307
|
+
first, then falls back to scanning all machines. Returns ``None`` when no
|
|
308
|
+
``repo_paths`` entry exists for *repo_name*.
|
|
309
|
+
|
|
310
|
+
Public so that callers outside this module (e.g. ``coord/cli.py``) can
|
|
311
|
+
reuse it without duplicating the hostname-matching logic.
|
|
312
|
+
"""
|
|
313
|
+
# Prefer a machine entry that looks like this machine.
|
|
314
|
+
here = local_machine(config)
|
|
315
|
+
if here is not None:
|
|
316
|
+
p = here.repo_path(repo_name)
|
|
317
|
+
if p:
|
|
318
|
+
return Path(p).expanduser()
|
|
319
|
+
# Fall back to any machine that has a repo_path configured.
|
|
320
|
+
for machine in config.machines:
|
|
321
|
+
p = machine.repo_path(repo_name)
|
|
322
|
+
if p:
|
|
323
|
+
return Path(p).expanduser()
|
|
324
|
+
return None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# Keep old private name as an alias for backward compatibility with any
|
|
328
|
+
# internal callers that haven't been updated yet.
|
|
329
|
+
_find_local_repo_path = find_local_repo_path
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _build_user_prompt(
|
|
333
|
+
*,
|
|
334
|
+
issue_number: int,
|
|
335
|
+
issue_body: str,
|
|
336
|
+
claude_md: str,
|
|
337
|
+
diff_text: str,
|
|
338
|
+
manifest: dict | None,
|
|
339
|
+
) -> str:
|
|
340
|
+
"""Assemble the user-facing prompt from the gathered context."""
|
|
341
|
+
parts: list[str] = []
|
|
342
|
+
|
|
343
|
+
if claude_md.strip():
|
|
344
|
+
parts.append(f"## CLAUDE.md (project rules)\n\n{claude_md.strip()}")
|
|
345
|
+
|
|
346
|
+
if issue_body.strip():
|
|
347
|
+
parts.append(f"## Issue #{issue_number}\n\n{issue_body.strip()}")
|
|
348
|
+
|
|
349
|
+
if diff_text:
|
|
350
|
+
# Truncate very large diffs to keep token budget under control.
|
|
351
|
+
if len(diff_text) > 20_000:
|
|
352
|
+
diff_text = diff_text[:20_000] + "\n... (diff truncated at 20 000 chars)"
|
|
353
|
+
parts.append(f"## Diff\n\n```diff\n{diff_text}\n```")
|
|
354
|
+
else:
|
|
355
|
+
parts.append("## Diff\n\n(not available)")
|
|
356
|
+
|
|
357
|
+
if manifest:
|
|
358
|
+
manifest_json = json.dumps(manifest, indent=2)
|
|
359
|
+
parts.append(
|
|
360
|
+
f"## Artifact manifest\n\n"
|
|
361
|
+
f"Pre-built binaries are available on the agent machine. "
|
|
362
|
+
f"Prefer `coord pull-artifact` to fetch them rather than rebuilding locally.\n\n"
|
|
363
|
+
f"```json\n{manifest_json}\n```"
|
|
364
|
+
)
|
|
365
|
+
else:
|
|
366
|
+
parts.append(
|
|
367
|
+
"## Artifact manifest\n\n"
|
|
368
|
+
"(not available — no pre-built artifacts stashed; plan must include "
|
|
369
|
+
"a local rebuild step if a binary is needed for testing)"
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
parts.append("Generate a smoke test plan for this change.")
|
|
373
|
+
return "\n\n".join(parts)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# ── Public API ────────────────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
def generate_plan(
|
|
379
|
+
assignment_id: str,
|
|
380
|
+
config: Config,
|
|
381
|
+
*,
|
|
382
|
+
model: str = "haiku",
|
|
383
|
+
) -> dict:
|
|
384
|
+
"""Generate a smoke test plan for *assignment_id*.
|
|
385
|
+
|
|
386
|
+
Gathers context (diff, CLAUDE.md, artifact manifest, issue body), calls
|
|
387
|
+
``claude -p`` (Haiku by default) with a tight system prompt, validates the
|
|
388
|
+
returned JSON, retries ONCE on malformed output, and returns the validated
|
|
389
|
+
plan dict::
|
|
390
|
+
|
|
391
|
+
{
|
|
392
|
+
"steps": [
|
|
393
|
+
{"kind": "pull"|"run"|"verify", "cmd": "...", "label": "...", "check": "..."},
|
|
394
|
+
...
|
|
395
|
+
],
|
|
396
|
+
"blockers": ["..."],
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
On two consecutive failures (bad JSON or claude exit != 0) returns::
|
|
400
|
+
|
|
401
|
+
{"steps": [], "blockers": ["plan generation failed"]}
|
|
402
|
+
|
|
403
|
+
This function does NOT persist the plan — call ``coord.state.set_test_plan``
|
|
404
|
+
after receiving the return value.
|
|
405
|
+
"""
|
|
406
|
+
from coord.db import get_connection # noqa: PLC0415 — lazy to avoid circular imports
|
|
407
|
+
|
|
408
|
+
FALLBACK: dict = {"steps": [], "blockers": ["plan generation failed"]}
|
|
409
|
+
|
|
410
|
+
# ── Look up the assignment ────────────────────────────────────────────
|
|
411
|
+
conn = get_connection()
|
|
412
|
+
row = conn.execute(
|
|
413
|
+
"SELECT machine_name, repo_name, repo_github, issue_number, branch, pr_url "
|
|
414
|
+
"FROM assignments WHERE assignment_id = ?",
|
|
415
|
+
(assignment_id,),
|
|
416
|
+
).fetchone()
|
|
417
|
+
if row is None:
|
|
418
|
+
log.warning("generate_plan: assignment %r not found in DB", assignment_id)
|
|
419
|
+
return {"steps": [], "blockers": [f"assignment {assignment_id!r} not found"]}
|
|
420
|
+
|
|
421
|
+
machine_name: str = row["machine_name"]
|
|
422
|
+
repo_name: str = row["repo_name"]
|
|
423
|
+
repo_github: str = row["repo_github"] or ""
|
|
424
|
+
issue_number: int = row["issue_number"]
|
|
425
|
+
branch: str = row["branch"] or ""
|
|
426
|
+
pr_url: str = row["pr_url"] or ""
|
|
427
|
+
|
|
428
|
+
# ── Machine config ────────────────────────────────────────────────────
|
|
429
|
+
machine = next((m for m in config.machines if m.name == machine_name), None)
|
|
430
|
+
repo_cfg = config.repo(repo_name)
|
|
431
|
+
|
|
432
|
+
# ── Read CLAUDE.md ────────────────────────────────────────────────────
|
|
433
|
+
claude_md = ""
|
|
434
|
+
local_repo_dir = _find_local_repo_path(repo_name, config)
|
|
435
|
+
if local_repo_dir and local_repo_dir.exists():
|
|
436
|
+
claude_md_path = local_repo_dir / "CLAUDE.md"
|
|
437
|
+
try:
|
|
438
|
+
claude_md = claude_md_path.read_text(encoding="utf-8", errors="replace")
|
|
439
|
+
except OSError:
|
|
440
|
+
claude_md = ""
|
|
441
|
+
|
|
442
|
+
# ── Get diff ─────────────────────────────────────────────────────────
|
|
443
|
+
diff_text = _get_pr_diff(pr_url, repo_github)
|
|
444
|
+
if not diff_text and branch and local_repo_dir:
|
|
445
|
+
default_branch = repo_cfg.default_branch if repo_cfg else "main"
|
|
446
|
+
diff_text = _get_git_diff(branch, default_branch, local_repo_dir)
|
|
447
|
+
|
|
448
|
+
# ── Get artifact manifest ─────────────────────────────────────────────
|
|
449
|
+
manifest: dict | None = None
|
|
450
|
+
if machine and branch:
|
|
451
|
+
manifest = _fetch_artifact_manifest(machine.host, repo_name, branch)
|
|
452
|
+
|
|
453
|
+
# ── Get issue body ────────────────────────────────────────────────────
|
|
454
|
+
issue_body = _get_issue_body(repo_github, issue_number)
|
|
455
|
+
|
|
456
|
+
# ── Build prompt ──────────────────────────────────────────────────────
|
|
457
|
+
user_prompt = _build_user_prompt(
|
|
458
|
+
issue_number=issue_number,
|
|
459
|
+
issue_body=issue_body,
|
|
460
|
+
claude_md=claude_md,
|
|
461
|
+
diff_text=diff_text,
|
|
462
|
+
manifest=manifest,
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
# ── Call claude -p with one retry ─────────────────────────────────────
|
|
466
|
+
last_exc: str = ""
|
|
467
|
+
for attempt in range(2):
|
|
468
|
+
prompt = user_prompt
|
|
469
|
+
if attempt > 0:
|
|
470
|
+
prompt = (
|
|
471
|
+
user_prompt
|
|
472
|
+
+ f"\n\nYour previous output was not valid JSON ({last_exc}); "
|
|
473
|
+
"try again. Output ONLY the JSON object — no other text."
|
|
474
|
+
)
|
|
475
|
+
try:
|
|
476
|
+
raw = _call_claude(PLAN_SYSTEM_PROMPT, prompt, model=model)
|
|
477
|
+
except (RuntimeError, subprocess.TimeoutExpired, json.JSONDecodeError) as exc:
|
|
478
|
+
last_exc = str(exc)
|
|
479
|
+
log.warning("plan generation attempt %d error: %s", attempt + 1, exc)
|
|
480
|
+
continue
|
|
481
|
+
|
|
482
|
+
try:
|
|
483
|
+
parsed = json.loads(_strip_fences(raw))
|
|
484
|
+
return _validate_plan(parsed)
|
|
485
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
486
|
+
last_exc = str(exc)
|
|
487
|
+
log.warning("plan parse/validate failed on attempt %d: %s", attempt + 1, exc)
|
|
488
|
+
# Continue to retry on attempt 0; fall through to FALLBACK on attempt 1.
|
|
489
|
+
|
|
490
|
+
log.error(
|
|
491
|
+
"plan generation failed for assignment %r after 2 attempts: %s",
|
|
492
|
+
assignment_id, last_exc,
|
|
493
|
+
)
|
|
494
|
+
return FALLBACK
|
coord/test_report.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Pure parsers for the coord-test-runner.sh (#1436) failure classifier.
|
|
2
|
+
|
|
3
|
+
`scripts/coord-test-runner.sh` decides whether a Test-gate run is a genuine
|
|
4
|
+
failure, a flake, a build/collection error (never flake-retried), or a run
|
|
5
|
+
whose output it simply could not parse (which must never be silently
|
|
6
|
+
recorded as a pass). Four of those decisions were `grep`/`awk` one-liners
|
|
7
|
+
with zero test coverage; this module extracts them into tested Python
|
|
8
|
+
functions. The shell script is untouched by this change — see #1392 for the
|
|
9
|
+
follow-up that ports the state machine and wires these in.
|
|
10
|
+
|
|
11
|
+
Each function's docstring names the shell expression it mirrors and any
|
|
12
|
+
behavioural difference (the ``pytest_failed_node_ids`` parametrized-id
|
|
13
|
+
truncation fix is the one deliberate difference — see its docstring).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
|
|
20
|
+
# ── pytest ───────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
# pytest's own "short test summary info" format is:
|
|
23
|
+
# FAILED <node id> - <failure message, possibly truncated with '...'>
|
|
24
|
+
# The " - " separator is not part of any valid node id (pytest node ids are
|
|
25
|
+
# `path::name[params]`), so splitting on the first occurrence of it is safe
|
|
26
|
+
# even when the message itself contains " - ".
|
|
27
|
+
_FAILED_PREFIX = "FAILED "
|
|
28
|
+
_FAILED_MESSAGE_SEP = " - "
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def pytest_failed_node_ids(output: str) -> list[str]:
|
|
32
|
+
"""Extract unique pytest failure node ids from ``pytest`` output.
|
|
33
|
+
|
|
34
|
+
Mirrors the shell's::
|
|
35
|
+
|
|
36
|
+
grep '^FAILED ' | awk '{print $2}' | sort -u
|
|
37
|
+
|
|
38
|
+
with one deliberate fix: ``awk '{print $2}'`` splits the line on
|
|
39
|
+
whitespace, so a parametrized node id containing a space — e.g.
|
|
40
|
+
``test_x[a b]`` — is truncated to ``test_x[a``. This is a live bug in
|
|
41
|
+
``coord-test-runner.sh`` today (see #1436). This implementation instead
|
|
42
|
+
strips the leading ``FAILED `` marker and takes everything up to the
|
|
43
|
+
first `` - `` (the separator pytest itself puts before the failure
|
|
44
|
+
summary message), so a node id with an embedded space survives intact.
|
|
45
|
+
|
|
46
|
+
Returns node ids deduped and sorted, matching ``sort -u``.
|
|
47
|
+
"""
|
|
48
|
+
seen: set[str] = set()
|
|
49
|
+
for line in output.splitlines():
|
|
50
|
+
if not line.startswith(_FAILED_PREFIX):
|
|
51
|
+
continue
|
|
52
|
+
rest = line[len(_FAILED_PREFIX) :]
|
|
53
|
+
node_id = rest.split(_FAILED_MESSAGE_SEP, 1)[0].strip()
|
|
54
|
+
if node_id:
|
|
55
|
+
seen.add(node_id)
|
|
56
|
+
return sorted(seen)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# A collection/import error is never a flake — the suite could not even run,
|
|
60
|
+
# so re-running it in isolation reproduces the identical error. Matched
|
|
61
|
+
# structurally against pytest's own session-level markers rather than a bare
|
|
62
|
+
# "line starts with ERROR" substring check: a test whose *body* prints a
|
|
63
|
+
# string starting with "ERROR" (e.g. `print("ERROR: retrying request")`) ends
|
|
64
|
+
# up verbatim in "Captured stdout call" and would trip a naive check — see
|
|
65
|
+
# tests/fixtures/test_report/pytest_error_string_in_body.txt for a genuine
|
|
66
|
+
# repro. INTERNALERROR (pytest itself crashing, as opposed to a user test
|
|
67
|
+
# failing) always uses the "INTERNALERROR>" marker.
|
|
68
|
+
_PYTEST_COLLECTION_ERROR_PATTERNS = [
|
|
69
|
+
re.compile(r"^INTERNALERROR>"),
|
|
70
|
+
re.compile(r"^=+\s*ERRORS\s*=+\s*$"),
|
|
71
|
+
re.compile(r"^!+\s*Interrupted:.*error.*!+\s*$"),
|
|
72
|
+
]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def pytest_has_collection_error(output: str) -> bool:
|
|
76
|
+
"""True if ``output`` shows a pytest collection/import error or an INTERNALERROR.
|
|
77
|
+
|
|
78
|
+
Mirrors the shell's intent behind::
|
|
79
|
+
|
|
80
|
+
grep -qE "^(ERROR|INTERNALERROR)"
|
|
81
|
+
|
|
82
|
+
but checks pytest's own structural markers (the "=== ERRORS ===" section
|
|
83
|
+
header, the "Interrupted: N error during collection" summary line, and
|
|
84
|
+
"INTERNALERROR>") instead of a bare line-start substring match, so a test
|
|
85
|
+
body printing "ERROR ..." to stdout does not false-positive.
|
|
86
|
+
"""
|
|
87
|
+
lines = output.splitlines()
|
|
88
|
+
return any(p.match(line) for line in lines for p in _PYTEST_COLLECTION_ERROR_PATTERNS)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ── cargo ────────────────────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
# cargo's `failures:` summary block lists bare test names, four-space
|
|
94
|
+
# indented, one per line — e.g. " tests::module::test_name". The block
|
|
95
|
+
# appears twice per test *target* (once interleaved with per-test stdout
|
|
96
|
+
# dumps, once as the final list) and cargo prints one such pair PER TARGET
|
|
97
|
+
# when a run spans multiple test binaries (lib + integration tests, etc).
|
|
98
|
+
# The block is closed by the next "test result:" line, which resets
|
|
99
|
+
# collection so a second target's block cannot bleed into or concatenate
|
|
100
|
+
# with the first — see tests/fixtures/test_report/cargo_multi_target_failures.txt
|
|
101
|
+
# for a genuine two-target repro.
|
|
102
|
+
#
|
|
103
|
+
# Deliberate fix vs. the shell's `/^ [a-zA-Z_]+::/` pattern: that pattern
|
|
104
|
+
# REQUIRES a "::" after the leading identifier, matching module-qualified
|
|
105
|
+
# names like "tests::lib_another_failure" but silently dropping top-level
|
|
106
|
+
# test functions with no enclosing `mod` — which is exactly the shape of
|
|
107
|
+
# tui/tests/acceptance.rs's `#[test]` functions. A run where such a test
|
|
108
|
+
# fails would report an empty failure list (misclassified as "no parseable
|
|
109
|
+
# failure list", skipping flake-retry entirely) even though cargo named the
|
|
110
|
+
# test plainly. Matched here in cargo_multi_target_failures.txt by the bare
|
|
111
|
+
# "integration_genuine_failure" entry (no "::"), captured from a genuine
|
|
112
|
+
# `cargo test --no-fail-fast` run against a two-target project.
|
|
113
|
+
_CARGO_FAILURE_LINE_RE = re.compile(r"^ ([A-Za-z_][A-Za-z0-9_:]*)\s*$")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def cargo_failed_test_names(output: str) -> list[str]:
|
|
117
|
+
"""Extract unique failing test names from ``cargo test`` output.
|
|
118
|
+
|
|
119
|
+
Mirrors the intent of the shell's::
|
|
120
|
+
|
|
121
|
+
awk '/^failures:$/{f=1;next} /^test result:/{f=0}
|
|
122
|
+
f && /^ [a-zA-Z_]+::/{print $1}' | sort -u
|
|
123
|
+
|
|
124
|
+
with one deliberate fix: the awk pattern requires "::" in the name, so a
|
|
125
|
+
top-level test with no enclosing module (see comment above) is silently
|
|
126
|
+
dropped. This implementation matches any four-space-indented line that
|
|
127
|
+
is *entirely* a test-name token (letters/digits/underscore/colon), with
|
|
128
|
+
or without "::".
|
|
129
|
+
|
|
130
|
+
Returns test names deduped and sorted, matching ``sort -u``.
|
|
131
|
+
"""
|
|
132
|
+
seen: set[str] = set()
|
|
133
|
+
collecting = False
|
|
134
|
+
for line in output.splitlines():
|
|
135
|
+
if line == "failures:":
|
|
136
|
+
collecting = True
|
|
137
|
+
continue
|
|
138
|
+
if line.startswith("test result:"):
|
|
139
|
+
collecting = False
|
|
140
|
+
continue
|
|
141
|
+
if collecting:
|
|
142
|
+
m = _CARGO_FAILURE_LINE_RE.match(line)
|
|
143
|
+
if m:
|
|
144
|
+
seen.add(m.group(1))
|
|
145
|
+
return sorted(seen)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# A compile error is never a flake. Deliberate fix vs. the shell's
|
|
149
|
+
# `^error(\[E[0-9]+\])?:|could not compile`: the bare `^error:` (no `[E..]`
|
|
150
|
+
# code) branch also matches cargo's own generic test-failure wrapper lines —
|
|
151
|
+
# "error: test failed, to rerun pass `--lib`" and "error: 2 targets failed:"
|
|
152
|
+
# — which cargo prints on ANY nonzero-exit test run, compile error or not.
|
|
153
|
+
# Genuinely captured: cargo_multi_target_failures.txt and
|
|
154
|
+
# cargo_no_parseable_failures.txt both contain "error: test failed, to rerun
|
|
155
|
+
# pass ..." despite having no compile error at all; the literal shell regex
|
|
156
|
+
# would misclassify every such run as a compile error, never reaching (or in
|
|
157
|
+
# the future, never flake-retrying) the actual failure list. "error[E####]:"
|
|
158
|
+
# (rustc's own coded diagnostics) and "could not compile" (cargo's own
|
|
159
|
+
# compile-failure summary line) are unambiguous; the bare "error:" prefix is
|
|
160
|
+
# not. Matched per line, matching grep's per-line semantics (the "^" anchor
|
|
161
|
+
# is start-of-line, not start-of-output).
|
|
162
|
+
_CARGO_COMPILE_ERROR_RE = re.compile(r"^error\[E\d+\]:|could not compile")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def cargo_has_compile_error(output: str) -> bool:
|
|
166
|
+
"""True if ``output`` shows a genuine rustc/cargo compile error.
|
|
167
|
+
|
|
168
|
+
Mirrors the intent of the shell's::
|
|
169
|
+
|
|
170
|
+
grep -qE "^error(\\[E[0-9]+\\])?:|could not compile"
|
|
171
|
+
|
|
172
|
+
with one deliberate fix: the bare `^error:` alternative (no error code)
|
|
173
|
+
is dropped because it also matches cargo's generic "error: test failed"
|
|
174
|
+
/ "error: N targets failed" wrapper lines, which appear on any test
|
|
175
|
+
failure — not just compile errors. See the comment above for a genuine
|
|
176
|
+
repro of the misclassification this caused.
|
|
177
|
+
"""
|
|
178
|
+
return any(_CARGO_COMPILE_ERROR_RE.search(line) for line in output.splitlines())
|