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,775 @@
|
|
|
1
|
+
"""Test-gate commands: `test` (pull/record verdict), `test-plan`,
|
|
2
|
+
`set-test-mode`. Extracted from coord/cli.py (#747).
|
|
3
|
+
|
|
4
|
+
#615: the original ``@main.command("test", ...)`` "queue a smoke test"
|
|
5
|
+
command (`test_cmd`) was dead code even in the pre-#747 cli.py — the
|
|
6
|
+
`test` function below registers the same Click command name "test" and
|
|
7
|
+
always won (last registration in main.commands wins), so `test_cmd` could
|
|
8
|
+
never run. It also called `build_board()`/`load_board()`/`save_board()`
|
|
9
|
+
unconditionally, unlike every reachable board-mutating command, which are
|
|
10
|
+
now daemon-routed (see the #615 audit note via `coord context show
|
|
11
|
+
claude-coordinator 615`). Removed rather than migrated, since routing
|
|
12
|
+
dead code would be pure ceremony.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import click
|
|
24
|
+
|
|
25
|
+
from coord.config import Config
|
|
26
|
+
|
|
27
|
+
from coord.commands._common import (
|
|
28
|
+
_apply_label_change,
|
|
29
|
+
_CONFIG_OPTION,
|
|
30
|
+
_load_config,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _get_assignment_branch_head(
|
|
35
|
+
assignment_id: str,
|
|
36
|
+
config: "Config",
|
|
37
|
+
repo_path_fn: "Callable[[str, Config], Path | None]",
|
|
38
|
+
) -> str | None:
|
|
39
|
+
"""#349: Resolve the current HEAD SHA for an assignment's branch.
|
|
40
|
+
|
|
41
|
+
Looks up the assignment's repo_name + branch from the DB, finds the local
|
|
42
|
+
repo path via *repo_path_fn* (typically
|
|
43
|
+
``coord.test_orchestrator.find_local_repo_path``), then runs
|
|
44
|
+
``git rev-parse <branch>`` to get the SHA.
|
|
45
|
+
|
|
46
|
+
Returns ``None`` when the assignment is not found, has no branch set, the
|
|
47
|
+
local repo path can't be resolved, or git fails. The caller treats ``None``
|
|
48
|
+
as "HEAD unknown — skip staleness tracking".
|
|
49
|
+
"""
|
|
50
|
+
import subprocess # noqa: PLC0415 — lazy import keeps startup fast
|
|
51
|
+
from coord.db import get_connection # noqa: PLC0415
|
|
52
|
+
|
|
53
|
+
conn = get_connection()
|
|
54
|
+
row = conn.execute(
|
|
55
|
+
"SELECT repo_name, branch FROM assignments WHERE assignment_id=?",
|
|
56
|
+
(assignment_id,),
|
|
57
|
+
).fetchone()
|
|
58
|
+
if not row:
|
|
59
|
+
return None
|
|
60
|
+
repo_name: str = row["repo_name"] if hasattr(row, "keys") else row[0]
|
|
61
|
+
branch: str = (row["branch"] if hasattr(row, "keys") else row[1]) or ""
|
|
62
|
+
if not branch:
|
|
63
|
+
return None
|
|
64
|
+
local_path = repo_path_fn(repo_name, config)
|
|
65
|
+
if not local_path or not local_path.exists():
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
result = subprocess.run(
|
|
69
|
+
["git", "rev-parse", branch],
|
|
70
|
+
cwd=str(local_path),
|
|
71
|
+
capture_output=True,
|
|
72
|
+
text=True,
|
|
73
|
+
timeout=10,
|
|
74
|
+
)
|
|
75
|
+
if result.returncode == 0:
|
|
76
|
+
return result.stdout.strip() or None
|
|
77
|
+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
|
78
|
+
pass
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _maybe_reconcile_branch(
|
|
83
|
+
assignment, repo_dir, *, original_error: str, config,
|
|
84
|
+
):
|
|
85
|
+
"""When `git checkout <db_branch>` fails, try to learn the PR's actual
|
|
86
|
+
head ref from GitHub and reconcile the DB.
|
|
87
|
+
|
|
88
|
+
Returns the new branch name when reconciliation succeeded (DB
|
|
89
|
+
updated + the reconciled branch verified on origin), or `None` when no
|
|
90
|
+
PR is associated, the gh call failed, the head ref matches what we
|
|
91
|
+
already had, or the reconciled ref is missing on origin. The caller
|
|
92
|
+
falls back to the original error in those cases.
|
|
93
|
+
"""
|
|
94
|
+
from coord.db import get_connection
|
|
95
|
+
|
|
96
|
+
# Need a PR number to look up the head ref. Pull it from the
|
|
97
|
+
# merge_queue entry for this assignment.
|
|
98
|
+
aid = assignment.assignment_id
|
|
99
|
+
if not aid:
|
|
100
|
+
return None
|
|
101
|
+
conn = get_connection()
|
|
102
|
+
row = conn.execute(
|
|
103
|
+
"SELECT pr_number, repo_github FROM merge_queue "
|
|
104
|
+
"WHERE assignment_id=?",
|
|
105
|
+
(aid,),
|
|
106
|
+
).fetchone()
|
|
107
|
+
if row is None:
|
|
108
|
+
return None
|
|
109
|
+
pr_number = row["pr_number"]
|
|
110
|
+
repo_github = row["repo_github"]
|
|
111
|
+
if pr_number is None or not repo_github:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
# Fetch the PR's actual head ref from GitHub. Returns the real
|
|
115
|
+
# branch name even when the DB has a stale slug. Routed through the
|
|
116
|
+
# github_ops seam (#1483) rather than a direct `gh` invocation here.
|
|
117
|
+
from coord import github_ops # noqa: PLC0415
|
|
118
|
+
|
|
119
|
+
real_branch = github_ops.get_pr_head_ref(repo_github, pr_number) or ""
|
|
120
|
+
if not real_branch:
|
|
121
|
+
return None
|
|
122
|
+
if real_branch == assignment.branch:
|
|
123
|
+
# The PR DOES point at the DB-recorded branch; checkout failed
|
|
124
|
+
# for some other reason (local-only clone, network, etc.).
|
|
125
|
+
# Don't pretend we fixed it.
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
# Validate the reconciled branch exists on origin before writing it to
|
|
129
|
+
# the DB. #561: this MUST be non-mutating — never `git checkout` in the
|
|
130
|
+
# base checkout (it doubles as the live editable coordinator source).
|
|
131
|
+
# `git fetch origin` already ran in the caller, so origin/<branch> is
|
|
132
|
+
# current; a rev-parse verify confirms it without moving HEAD.
|
|
133
|
+
try:
|
|
134
|
+
subprocess.run(
|
|
135
|
+
["git", "rev-parse", "--verify", f"origin/{real_branch}"],
|
|
136
|
+
cwd=str(repo_dir), check=True, capture_output=True, text=True,
|
|
137
|
+
)
|
|
138
|
+
except subprocess.CalledProcessError:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
# Persist the reconciled branch on both tables so future runs of
|
|
142
|
+
# coord test / coord merge / TUI etc. all see the right value.
|
|
143
|
+
conn.execute(
|
|
144
|
+
"UPDATE assignments SET branch=? WHERE assignment_id=?",
|
|
145
|
+
(real_branch, aid),
|
|
146
|
+
)
|
|
147
|
+
conn.execute(
|
|
148
|
+
"UPDATE merge_queue SET branch=? WHERE assignment_id=?",
|
|
149
|
+
(real_branch, aid),
|
|
150
|
+
)
|
|
151
|
+
conn.commit()
|
|
152
|
+
|
|
153
|
+
# Mute the unused 'original_error' / 'config' params — they're
|
|
154
|
+
# there for future use (e.g. logging context, post-back to GitHub).
|
|
155
|
+
_ = original_error
|
|
156
|
+
_ = config
|
|
157
|
+
return real_branch
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@click.command(
|
|
161
|
+
"set-test-mode",
|
|
162
|
+
help=(
|
|
163
|
+
"Set the per-issue test-mode policy for headless Work sessions.\n\n"
|
|
164
|
+
"MODE must be 'smoke' (pause at the Test gate for a human-attended\n"
|
|
165
|
+
"interactive smoke agent, the default) or 'auto' (run the smoke test\n"
|
|
166
|
+
"headless and continue toward merge without stopping).\n\n"
|
|
167
|
+
"The policy is persisted as a 'test-mode:smoke' / 'test-mode:auto'\n"
|
|
168
|
+
"GitHub label. The TUI reads this label when a headless Work session\n"
|
|
169
|
+
"completes to decide whether to offer the interactive smoke agent or\n"
|
|
170
|
+
"auto-dispatch smoke.py. The label can be flipped at any time before\n"
|
|
171
|
+
"the issue reaches the Test gate.\n\n"
|
|
172
|
+
"REPO is the local repo name from coordinator.yml; ISSUE is the GH\n"
|
|
173
|
+
"issue number."
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
@click.argument("repo")
|
|
179
|
+
@click.argument("issue", type=int)
|
|
180
|
+
@click.argument("mode", type=click.Choice(["smoke", "auto"]))
|
|
181
|
+
@_CONFIG_OPTION
|
|
182
|
+
def set_test_mode(repo: str, issue: int, mode: str, config_path: Path) -> None:
|
|
183
|
+
"""#685: TUI test-mode dialog and right-click flip fire this command."""
|
|
184
|
+
from coord import github_ops # noqa: PLC0415
|
|
185
|
+
|
|
186
|
+
cfg = _load_config(config_path)
|
|
187
|
+
repo_entry = cfg.repo(repo)
|
|
188
|
+
if repo_entry is None:
|
|
189
|
+
click.echo(f"error: unknown repo {repo!r} (not in coordinator.yml)", err=True)
|
|
190
|
+
sys.exit(1)
|
|
191
|
+
slug = repo_entry.github
|
|
192
|
+
|
|
193
|
+
# Ensure the test-mode:* labels exist in the repo before we try to add
|
|
194
|
+
# them — `gh issue edit --add-label` fails with "label not found" when the
|
|
195
|
+
# label has never been created. `gh label create --force` is idempotent
|
|
196
|
+
# (no-ops if the label already exists, creates it if absent). Routed
|
|
197
|
+
# through the github_ops seam (#1483) rather than a direct `gh` call here.
|
|
198
|
+
_TEST_MODE_LABEL_COLOR = "0075ca" # default blue; matches GitHub's "documentation" label
|
|
199
|
+
for lbl in ("test-mode:smoke", "test-mode:auto"):
|
|
200
|
+
try:
|
|
201
|
+
github_ops.create_label(
|
|
202
|
+
slug, lbl,
|
|
203
|
+
color=_TEST_MODE_LABEL_COLOR,
|
|
204
|
+
description="coord: per-issue test-mode policy",
|
|
205
|
+
)
|
|
206
|
+
except RuntimeError:
|
|
207
|
+
pass # best-effort — label creation failure is surfaced when add-label fails
|
|
208
|
+
|
|
209
|
+
_apply_label_change(
|
|
210
|
+
repo, issue, config_path,
|
|
211
|
+
add={f"test-mode:{mode}"},
|
|
212
|
+
remove_if_present={
|
|
213
|
+
lbl for lbl in ("test-mode:smoke", "test-mode:auto")
|
|
214
|
+
if lbl != f"test-mode:{mode}"
|
|
215
|
+
},
|
|
216
|
+
success_message=f"#{issue} ({slug}) test mode set to '{mode}'",
|
|
217
|
+
no_op_message=f"#{issue} ({slug}) already has test-mode:{mode}",
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _test_worktree_path(assignment_id: str, repo_name: str) -> Path:
|
|
222
|
+
"""#561: throwaway worktree path for `coord test`'s build (per assignment).
|
|
223
|
+
|
|
224
|
+
Lives under ``~/.coord/test-worktrees/`` — OUTSIDE the base checkout — so a
|
|
225
|
+
Build never moves the base checkout's branch (which doubles as the live
|
|
226
|
+
editable coordinator source).
|
|
227
|
+
"""
|
|
228
|
+
from coord.state import COORD_DIR # noqa: PLC0415
|
|
229
|
+
|
|
230
|
+
return COORD_DIR / "test-worktrees" / f"{repo_name}-{assignment_id}"
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _remove_test_worktree(repo_dir: Path, wt_path: Path) -> None:
|
|
234
|
+
"""Best-effort removal of a `coord test` worktree (+ prune admin refs)."""
|
|
235
|
+
import subprocess # noqa: PLC0415
|
|
236
|
+
|
|
237
|
+
if not wt_path.exists():
|
|
238
|
+
return
|
|
239
|
+
for args in (
|
|
240
|
+
["git", "worktree", "remove", "--force", str(wt_path)],
|
|
241
|
+
["git", "worktree", "prune"],
|
|
242
|
+
):
|
|
243
|
+
try:
|
|
244
|
+
subprocess.run(
|
|
245
|
+
args, cwd=str(repo_dir), capture_output=True, text=True, timeout=30
|
|
246
|
+
)
|
|
247
|
+
except (subprocess.SubprocessError, OSError):
|
|
248
|
+
pass
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _stash_test_artifacts(repo, assignment, wt_path: Path) -> None:
|
|
252
|
+
"""#1249: stash the built artifact right after `coord test`'s build
|
|
253
|
+
succeeds — the ONE stage that reliably runs `build_command` and produces
|
|
254
|
+
the exact binary the Test-stage `kind: "pull"` step (and the next
|
|
255
|
+
`--fix-of` re-test on this branch) wants to pull instead of rebuilding.
|
|
256
|
+
|
|
257
|
+
The Work-completion stash (`AgentServer._stash_artifacts`) fires right
|
|
258
|
+
before that worktree is removed, but a Work session isn't required to
|
|
259
|
+
run `build_command` — only `coord test` (and the interactive
|
|
260
|
+
`--smoke-of` path, already covered by `finalize_interactive_exit`) is.
|
|
261
|
+
Delegates to the same `stash_artifacts_for_branch` helper both of those
|
|
262
|
+
call, so one filter/copy/GC implementation serves all three call sites.
|
|
263
|
+
|
|
264
|
+
Best-effort and silent on the "nothing to do" cases (no configured
|
|
265
|
+
`artifact_paths`, no branch, no worktree on disk) — mirrors the guard
|
|
266
|
+
clauses in `AgentServer._stash_artifacts`.
|
|
267
|
+
"""
|
|
268
|
+
if repo is None or not repo.artifact_paths:
|
|
269
|
+
return
|
|
270
|
+
if not assignment.branch:
|
|
271
|
+
return
|
|
272
|
+
if not wt_path.exists():
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
from coord.agent import stash_artifacts_for_branch # noqa: PLC0415
|
|
276
|
+
from coord.state import COORD_DIR # noqa: PLC0415
|
|
277
|
+
|
|
278
|
+
patterns = list(repo.artifact_paths)
|
|
279
|
+
copied = stash_artifacts_for_branch(
|
|
280
|
+
worktree_path=wt_path,
|
|
281
|
+
branch=assignment.branch,
|
|
282
|
+
repo_name=assignment.repo_name,
|
|
283
|
+
patterns=patterns,
|
|
284
|
+
state_dir=COORD_DIR,
|
|
285
|
+
assignment_id=assignment.assignment_id,
|
|
286
|
+
log_path=None,
|
|
287
|
+
)
|
|
288
|
+
if copied > 0:
|
|
289
|
+
click.echo(f" stashed {copied} artifact(s) for {assignment.branch!r}.")
|
|
290
|
+
else:
|
|
291
|
+
# #1249 review finding #2: the other two call sites
|
|
292
|
+
# (AgentServer._stash_artifacts, finalize_interactive_exit) pass a
|
|
293
|
+
# real log_path, so stash_artifacts_for_branch's #1248 "loud 0-copy"
|
|
294
|
+
# warning lands in the assignment log. This call site has no log
|
|
295
|
+
# file — `coord test` runs directly in the operator's terminal —
|
|
296
|
+
# so log_path=None above means that warning is silently swallowed.
|
|
297
|
+
# Echo it here instead, mirroring the same message.
|
|
298
|
+
click.echo(
|
|
299
|
+
f" warning: 0 artifact(s) matched {patterns!r} in {wt_path} — "
|
|
300
|
+
"check artifact_paths config and that the build actually "
|
|
301
|
+
"produced the expected outputs.",
|
|
302
|
+
err=True,
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _cleanup_test_worktree(cfg, assignment) -> None:
|
|
307
|
+
"""Remove the test worktree for *assignment* (called on a pass/skip verdict).
|
|
308
|
+
|
|
309
|
+
Resolves the base checkout the same way the build path does; a no-op when no
|
|
310
|
+
worktree exists (e.g. a verdict recorded without a prior Build).
|
|
311
|
+
"""
|
|
312
|
+
if not assignment.assignment_id:
|
|
313
|
+
return
|
|
314
|
+
repo_dir = _local_repo_dir(cfg, assignment.repo_name)
|
|
315
|
+
if repo_dir is None:
|
|
316
|
+
return
|
|
317
|
+
_remove_test_worktree(
|
|
318
|
+
repo_dir, _test_worktree_path(assignment.assignment_id, assignment.repo_name)
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _local_repo_dir(cfg, repo_name: str) -> Path | None:
|
|
323
|
+
"""Resolve the base checkout for *repo_name* (local machine first, then any
|
|
324
|
+
machine that knows it). Returns an expanded ``Path`` or ``None``."""
|
|
325
|
+
import socket # noqa: PLC0415
|
|
326
|
+
|
|
327
|
+
hostname = socket.gethostname().split(".")[0]
|
|
328
|
+
local_machine = next(
|
|
329
|
+
(m for m in cfg.machines if m.name == hostname or m.host.split(".")[0] == hostname),
|
|
330
|
+
None,
|
|
331
|
+
)
|
|
332
|
+
repo_path = None
|
|
333
|
+
if local_machine:
|
|
334
|
+
repo_path = local_machine.repo_path(repo_name)
|
|
335
|
+
if repo_path is None:
|
|
336
|
+
for m in cfg.machines:
|
|
337
|
+
repo_path = m.repo_path(repo_name)
|
|
338
|
+
if repo_path:
|
|
339
|
+
break
|
|
340
|
+
return Path(repo_path).expanduser() if repo_path else None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _restore_default_branch_after_test(cfg, assignment) -> None:
|
|
344
|
+
"""#271 part 1: switch the local checkout back to the repo's
|
|
345
|
+
`default_branch` after a pass/skip verdict.
|
|
346
|
+
|
|
347
|
+
Resolves the repo path the same way `coord test`'s checkout step
|
|
348
|
+
does (local machine's `repo_paths` first, then any machine that
|
|
349
|
+
knows the repo). Best-effort: a failed `git checkout` is surfaced
|
|
350
|
+
as a warning but doesn't fail the verdict recording.
|
|
351
|
+
"""
|
|
352
|
+
import socket # noqa: PLC0415
|
|
353
|
+
import subprocess # noqa: PLC0415
|
|
354
|
+
from pathlib import Path as _Path # noqa: PLC0415
|
|
355
|
+
|
|
356
|
+
if not assignment.branch:
|
|
357
|
+
# No branch was ever checked out — nothing to restore.
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
repo = cfg.repo(assignment.repo_name)
|
|
361
|
+
if repo is None or not repo.default_branch:
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
hostname = socket.gethostname().split(".")[0]
|
|
365
|
+
local_machine = next(
|
|
366
|
+
(m for m in cfg.machines if m.name == hostname or m.host.split(".")[0] == hostname),
|
|
367
|
+
None,
|
|
368
|
+
)
|
|
369
|
+
repo_path = None
|
|
370
|
+
if local_machine:
|
|
371
|
+
repo_path = local_machine.repo_path(assignment.repo_name)
|
|
372
|
+
if repo_path is None:
|
|
373
|
+
for m in cfg.machines:
|
|
374
|
+
repo_path = m.repo_path(assignment.repo_name)
|
|
375
|
+
if repo_path:
|
|
376
|
+
break
|
|
377
|
+
if repo_path is None:
|
|
378
|
+
return
|
|
379
|
+
|
|
380
|
+
repo_dir = _Path(repo_path).expanduser()
|
|
381
|
+
if not repo_dir.exists():
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
# Quick early-out: if the user is already on the default branch
|
|
385
|
+
# (e.g. they switched manually after running `coord test`), there's
|
|
386
|
+
# nothing to do and no need to announce a no-op.
|
|
387
|
+
try:
|
|
388
|
+
head = subprocess.run(
|
|
389
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
390
|
+
cwd=str(repo_dir), capture_output=True, text=True, timeout=5,
|
|
391
|
+
)
|
|
392
|
+
if head.returncode == 0 and head.stdout.strip() == repo.default_branch:
|
|
393
|
+
return
|
|
394
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
395
|
+
# If we can't even check the current branch, don't try to switch.
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
try:
|
|
399
|
+
result = subprocess.run(
|
|
400
|
+
["git", "checkout", repo.default_branch],
|
|
401
|
+
cwd=str(repo_dir), capture_output=True, text=True, timeout=15,
|
|
402
|
+
)
|
|
403
|
+
except (subprocess.TimeoutExpired, OSError) as e:
|
|
404
|
+
click.echo(f" warning: could not restore default branch: {e}", err=True)
|
|
405
|
+
return
|
|
406
|
+
if result.returncode != 0:
|
|
407
|
+
# Most common cause: dirty working tree from manual edits during
|
|
408
|
+
# testing. Surface it so the user can stash + retry manually.
|
|
409
|
+
click.echo(
|
|
410
|
+
f" warning: could not switch back to {repo.default_branch!r}: "
|
|
411
|
+
f"{result.stderr.strip()}",
|
|
412
|
+
err=True,
|
|
413
|
+
)
|
|
414
|
+
return
|
|
415
|
+
click.echo(f" restored: {repo.default_branch} in {repo_dir}")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@click.command(help="Pull a worker's branch locally for testing, or record a Test gate verdict.")
|
|
419
|
+
@click.argument("assignment_id")
|
|
420
|
+
@_CONFIG_OPTION
|
|
421
|
+
@click.option("--passed", "verdict", flag_value="pass", help="Mark Test gate as passed.")
|
|
422
|
+
@click.option("--fail", "verdict", flag_value="fail", help="Mark Test gate as failed.")
|
|
423
|
+
@click.option("--skipped", "verdict", flag_value="skip", help="Mark Test gate as skipped (trivial change).")
|
|
424
|
+
@click.option(
|
|
425
|
+
"--running", "verdict", flag_value="running",
|
|
426
|
+
help=(
|
|
427
|
+
"Mark the Test gate as in-progress (#1395). A transient, non-verdict "
|
|
428
|
+
"marker for a driver that runs the suite locally instead of through "
|
|
429
|
+
"dispatch_smoke — set right before the run starts so the TUI/board "
|
|
430
|
+
"reads the Test box Active instead of Pending for the run's "
|
|
431
|
+
"duration. Overwrite it with --passed/--fail/--skipped when the run "
|
|
432
|
+
"concludes; every gate treats it as 'no verdict yet'."
|
|
433
|
+
),
|
|
434
|
+
)
|
|
435
|
+
@click.option("--reason", default="", help="Reason for failure or skip (used with --fail/--skipped).")
|
|
436
|
+
@click.option("--output", "output_file", type=click.Path(), default=None,
|
|
437
|
+
help="File with test output to store (used with --fail).")
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def test(assignment_id: str, config_path: Path, verdict: str | None, reason: str, output_file: str | None) -> None:
|
|
441
|
+
from coord.board_service import read_board
|
|
442
|
+
from coord.state import record_test_verdict
|
|
443
|
+
|
|
444
|
+
cfg = _load_config(config_path)
|
|
445
|
+
# #590 Phase 2 / #1337: a thin client reads the board from the daemon (its
|
|
446
|
+
# local DB is empty) so the assignment resolves; the verdict is recorded
|
|
447
|
+
# via the single-row record_test_verdict on BOTH paths (it self-routes:
|
|
448
|
+
# daemon when board_service is set, direct local UPDATE otherwise).
|
|
449
|
+
board = read_board()
|
|
450
|
+
|
|
451
|
+
assignment = board.find_by_id(assignment_id)
|
|
452
|
+
if assignment is None:
|
|
453
|
+
click.echo(f"error: assignment {assignment_id!r} not found in board", err=True)
|
|
454
|
+
sys.exit(1)
|
|
455
|
+
|
|
456
|
+
repo = cfg.repo(assignment.repo_name)
|
|
457
|
+
|
|
458
|
+
# ── Record verdict ──────────────────────────────────────────────────
|
|
459
|
+
if verdict:
|
|
460
|
+
# Map CLI verdict flags to the canonical test_state values used by the
|
|
461
|
+
# TUI's Test stage and the reconcile review-gating logic. "running"
|
|
462
|
+
# (#1395) is NOT a verdict — it's a transient in-progress marker a
|
|
463
|
+
# driver sets around a locally-run suite, overwritten by one of the
|
|
464
|
+
# other three flags when the run concludes.
|
|
465
|
+
test_state_map = {"pass": "passed", "fail": "failed", "skip": "skipped", "running": "running"}
|
|
466
|
+
assignment.test_state = test_state_map[verdict]
|
|
467
|
+
# #1213: a --skipped verdict carries a reason too (e.g. "trivial dep
|
|
468
|
+
# bump, covered by regression test in the same PR") — that reason IS
|
|
469
|
+
# the audit trail for why the human test was bypassed, so it must
|
|
470
|
+
# not be discarded the way --passed's (never has one) is. Only a
|
|
471
|
+
# bare --passed clears any stale reason from a prior verdict.
|
|
472
|
+
assignment.test_reason = reason if verdict in ("fail", "skip") else None
|
|
473
|
+
# Mirror to legacy smoke_test for the existing smoke-stage scoring in
|
|
474
|
+
# pipeline.py (which predates the human Test gate).
|
|
475
|
+
if verdict in ("pass", "fail"):
|
|
476
|
+
assignment.smoke_test = verdict
|
|
477
|
+
assignment.smoke_test_reason = reason if verdict == "fail" else None
|
|
478
|
+
|
|
479
|
+
# Store test output when --fail --output is provided
|
|
480
|
+
if verdict == "fail" and output_file:
|
|
481
|
+
output_path = Path(output_file)
|
|
482
|
+
if output_path.exists():
|
|
483
|
+
from coord.state import COORD_DIR
|
|
484
|
+
|
|
485
|
+
test_output_dir = COORD_DIR / "test_output"
|
|
486
|
+
test_output_dir.mkdir(parents=True, exist_ok=True)
|
|
487
|
+
stored = test_output_dir / f"{assignment_id}.txt"
|
|
488
|
+
stored.write_text(output_path.read_text())
|
|
489
|
+
# Record the stored path so coord fix can find it
|
|
490
|
+
reason_with_output = (
|
|
491
|
+
f"{reason} [output: {stored}]" if reason else f"[output: {stored}]"
|
|
492
|
+
)
|
|
493
|
+
assignment.test_reason = reason_with_output
|
|
494
|
+
assignment.smoke_test_reason = reason_with_output
|
|
495
|
+
click.echo(f" test output stored: {stored}")
|
|
496
|
+
else:
|
|
497
|
+
click.echo(f" warning: output file not found: {output_file}", err=True)
|
|
498
|
+
|
|
499
|
+
# #1629 (H-2): best-effort — which toolchain, on THIS machine, ran the
|
|
500
|
+
# suite that produced a pass/fail verdict. "skip"/"running" carry no
|
|
501
|
+
# test run, so there's nothing to attribute. Never blocks the verdict
|
|
502
|
+
# write: any failure here (repo path unresolved, no known toolchain,
|
|
503
|
+
# the binary not on PATH) just leaves it None, same as every
|
|
504
|
+
# pre-#1629 verdict.
|
|
505
|
+
test_toolchain = None
|
|
506
|
+
if verdict in ("pass", "fail"):
|
|
507
|
+
try:
|
|
508
|
+
from coord.health.checks.toolchain import local_toolchain_label
|
|
509
|
+
|
|
510
|
+
repo_dir = _local_repo_dir(cfg, assignment.repo_name)
|
|
511
|
+
if repo_dir is not None:
|
|
512
|
+
test_toolchain = local_toolchain_label(repo_dir)
|
|
513
|
+
except Exception: # noqa: BLE001 — annotation only, never fatal
|
|
514
|
+
test_toolchain = None
|
|
515
|
+
|
|
516
|
+
# #1337: single-row verdict write on BOTH paths (record_test_verdict
|
|
517
|
+
# self-routes: daemon when board_service is set, direct UPDATE
|
|
518
|
+
# locally). The old local-path save_board() relied on the whole-board
|
|
519
|
+
# upsert carrying test_reason/smoke_test_reason — those free-text
|
|
520
|
+
# columns are now excluded from that upsert so a bounded /board
|
|
521
|
+
# preview can never round-trip over the full stored text.
|
|
522
|
+
record_test_verdict(
|
|
523
|
+
assignment_id=assignment_id,
|
|
524
|
+
test_state=assignment.test_state,
|
|
525
|
+
test_reason=assignment.test_reason,
|
|
526
|
+
smoke_test=assignment.smoke_test,
|
|
527
|
+
smoke_test_reason=assignment.smoke_test_reason,
|
|
528
|
+
test_toolchain=test_toolchain,
|
|
529
|
+
)
|
|
530
|
+
verdict_word = {
|
|
531
|
+
"pass": "PASSED", "fail": "FAILED", "skip": "SKIPPED", "running": "RUNNING",
|
|
532
|
+
}[verdict]
|
|
533
|
+
click.echo(f"Test gate {verdict_word} for {assignment.repo_name} #{assignment.issue_number}")
|
|
534
|
+
if verdict in ("fail", "skip") and reason:
|
|
535
|
+
click.echo(f" reason: {reason}")
|
|
536
|
+
elif verdict == "pass":
|
|
537
|
+
click.echo(" Run: coord merge to proceed")
|
|
538
|
+
elif verdict == "running":
|
|
539
|
+
click.echo(" (transient — record --passed/--fail/--skipped when the run concludes)")
|
|
540
|
+
|
|
541
|
+
# #271 part 1: restore the local checkout to `default_branch` after a
|
|
542
|
+
# pass/skip verdict (legacy safety — #561 means a Build no longer moves
|
|
543
|
+
# the base, so this is a no-op on fresh checkouts), and #561: remove the
|
|
544
|
+
# throwaway test worktree now that testing concluded. `--fail` leaves
|
|
545
|
+
# the worktree so the user can dig into the failure. `running` is
|
|
546
|
+
# neither — the driver manages its own scratch worktree separately, so
|
|
547
|
+
# this command never touched one in the first place.
|
|
548
|
+
if verdict in ("pass", "skip"):
|
|
549
|
+
_restore_default_branch_after_test(cfg, assignment)
|
|
550
|
+
_cleanup_test_worktree(cfg, assignment)
|
|
551
|
+
return
|
|
552
|
+
|
|
553
|
+
# ── Checkout and build (in a throwaway worktree — #561) ──────────────
|
|
554
|
+
if not assignment.branch:
|
|
555
|
+
click.echo(
|
|
556
|
+
f"error: assignment {assignment_id} has no branch recorded. "
|
|
557
|
+
f"The worker may not have pushed yet, or the branch wasn't captured during reconciliation.",
|
|
558
|
+
err=True,
|
|
559
|
+
)
|
|
560
|
+
sys.exit(1)
|
|
561
|
+
|
|
562
|
+
import subprocess
|
|
563
|
+
|
|
564
|
+
repo_dir = _local_repo_dir(cfg, assignment.repo_name)
|
|
565
|
+
if repo_dir is None:
|
|
566
|
+
click.echo(
|
|
567
|
+
f"error: no repo_path configured for {assignment.repo_name!r}. "
|
|
568
|
+
f"Add it to coordinator.yml under machines[].repo_paths.",
|
|
569
|
+
err=True,
|
|
570
|
+
)
|
|
571
|
+
sys.exit(1)
|
|
572
|
+
if not repo_dir.exists():
|
|
573
|
+
click.echo(f"error: repo path does not exist: {repo_dir}", err=True)
|
|
574
|
+
sys.exit(1)
|
|
575
|
+
|
|
576
|
+
# #561: build/test in a throwaway worktree fetched fresh from origin —
|
|
577
|
+
# NEVER `git checkout` in the base checkout. The base doubles as the live
|
|
578
|
+
# editable coordinator source, so moving its branch silently downgrades the
|
|
579
|
+
# running coord (disabled guards, reintroduced bugs) until restored. A
|
|
580
|
+
# `git fetch` is safe (it doesn't move HEAD); the worktree gets its own.
|
|
581
|
+
wt_path = _test_worktree_path(assignment_id, assignment.repo_name)
|
|
582
|
+
click.echo(
|
|
583
|
+
f"Fetching origin and preparing test worktree for {assignment.branch!r} "
|
|
584
|
+
f"(base checkout {repo_dir} stays untouched)..."
|
|
585
|
+
)
|
|
586
|
+
try:
|
|
587
|
+
subprocess.run(
|
|
588
|
+
["git", "fetch", "origin", "--prune"], cwd=str(repo_dir),
|
|
589
|
+
check=True, capture_output=True, text=True,
|
|
590
|
+
)
|
|
591
|
+
except subprocess.CalledProcessError as e:
|
|
592
|
+
click.echo(f"error: git fetch failed: {e.stderr.strip()}", err=True)
|
|
593
|
+
sys.exit(1)
|
|
594
|
+
|
|
595
|
+
# Clear any stale worktree from a prior Build of this assignment.
|
|
596
|
+
_remove_test_worktree(repo_dir, wt_path)
|
|
597
|
+
wt_path.parent.mkdir(parents=True, exist_ok=True)
|
|
598
|
+
|
|
599
|
+
def _add_worktree(branch: str):
|
|
600
|
+
# --detach: we only read the tree to build/test; no local branch needed.
|
|
601
|
+
return subprocess.run(
|
|
602
|
+
["git", "worktree", "add", "--force", "--detach",
|
|
603
|
+
str(wt_path), f"origin/{branch}"],
|
|
604
|
+
cwd=str(repo_dir), capture_output=True, text=True,
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
res = _add_worktree(assignment.branch)
|
|
608
|
+
if res.returncode != 0:
|
|
609
|
+
# Branch drift (auto-loop orphan branches; slugifier max_len changes
|
|
610
|
+
# across releases; manual `git branch -m` on origin). When the worktree
|
|
611
|
+
# add fails AND the issue has a PR, resolve the PR's actual headRefName
|
|
612
|
+
# (non-mutating), update the DB, and retry.
|
|
613
|
+
reconciled = _maybe_reconcile_branch(
|
|
614
|
+
assignment, repo_dir, original_error=res.stderr.strip(), config=cfg,
|
|
615
|
+
)
|
|
616
|
+
if reconciled is None:
|
|
617
|
+
click.echo(
|
|
618
|
+
f"error: could not create test worktree: {res.stderr.strip()}",
|
|
619
|
+
err=True,
|
|
620
|
+
)
|
|
621
|
+
sys.exit(1)
|
|
622
|
+
assignment.branch = reconciled
|
|
623
|
+
click.echo(
|
|
624
|
+
f" branch drift reconciled: using the PR's actual head ref "
|
|
625
|
+
f"{assignment.branch!r}"
|
|
626
|
+
)
|
|
627
|
+
res = _add_worktree(assignment.branch)
|
|
628
|
+
if res.returncode != 0:
|
|
629
|
+
click.echo(
|
|
630
|
+
f"error: could not create test worktree: {res.stderr.strip()}",
|
|
631
|
+
err=True,
|
|
632
|
+
)
|
|
633
|
+
sys.exit(1)
|
|
634
|
+
|
|
635
|
+
click.echo(f"Test worktree ready at {wt_path} (branch {assignment.branch!r}).")
|
|
636
|
+
|
|
637
|
+
if repo and repo.build_command:
|
|
638
|
+
click.echo(f"Running build: {repo.build_command}")
|
|
639
|
+
result = subprocess.run(repo.build_command, shell=True, cwd=str(wt_path))
|
|
640
|
+
if result.returncode != 0:
|
|
641
|
+
click.echo(f"Build failed (exit {result.returncode})", err=True)
|
|
642
|
+
click.echo(f" worktree kept for inspection: {wt_path}")
|
|
643
|
+
sys.exit(1)
|
|
644
|
+
click.echo("Build succeeded.")
|
|
645
|
+
# #1249: stash immediately after a successful build — regardless of
|
|
646
|
+
# whether the test step below then passes or fails — so a later
|
|
647
|
+
# `--fix-of` re-test on this branch can pull instead of rebuilding.
|
|
648
|
+
_stash_test_artifacts(repo, assignment, wt_path)
|
|
649
|
+
|
|
650
|
+
if repo and repo.test_command:
|
|
651
|
+
click.echo(f"Running tests: {repo.test_command}")
|
|
652
|
+
result = subprocess.run(repo.test_command, shell=True, cwd=str(wt_path))
|
|
653
|
+
if result.returncode != 0:
|
|
654
|
+
click.echo(f"Tests failed (exit {result.returncode})", err=True)
|
|
655
|
+
click.echo(f" worktree kept for inspection: {wt_path}")
|
|
656
|
+
sys.exit(1)
|
|
657
|
+
click.echo("Tests passed.")
|
|
658
|
+
|
|
659
|
+
click.echo(
|
|
660
|
+
f"\nReady for smoke test (worktree: {wt_path}). Run:\n"
|
|
661
|
+
f" coord test --passed {assignment_id} # if it looks good (removes the worktree)\n"
|
|
662
|
+
f" coord test --fail {assignment_id} --reason \"description\" # keeps the worktree to dig in"
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _test_plan_via_daemon(svc, params: dict) -> None:
|
|
667
|
+
"""#851: run ``coord test-plan`` on the daemon host (the assignment + its
|
|
668
|
+
cached ``test_plan`` live in the daemon's canonical DB, not a thin
|
|
669
|
+
client's empty local one) and relay its output. Mirrors
|
|
670
|
+
``_diagnose_via_daemon`` in ``coord/commands/status.py``."""
|
|
671
|
+
from coord.client import post_record # noqa: PLC0415
|
|
672
|
+
|
|
673
|
+
try:
|
|
674
|
+
resp = post_record(svc, "/test-plan", params, timeout=120.0)
|
|
675
|
+
except Exception as exc: # noqa: BLE001
|
|
676
|
+
click.echo(f"error: test-plan via daemon failed: {exc}", err=True)
|
|
677
|
+
sys.exit(1)
|
|
678
|
+
output = resp.get("output") or ""
|
|
679
|
+
if output:
|
|
680
|
+
click.echo(output, nl=False)
|
|
681
|
+
if resp.get("error"):
|
|
682
|
+
click.echo(f"error: {resp['error']}", err=True)
|
|
683
|
+
code = resp.get("exit_code") or 0
|
|
684
|
+
if code:
|
|
685
|
+
sys.exit(int(code))
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
@click.command(
|
|
689
|
+
"test-plan",
|
|
690
|
+
help=(
|
|
691
|
+
"Generate (or display) a smoke test plan for a completed assignment.\n\n"
|
|
692
|
+
"On first call the plan is generated by calling claude -p (Haiku by default) "
|
|
693
|
+
"with the PR diff, CLAUDE.md, artifact manifest, and issue body. The result "
|
|
694
|
+
"is cached in the database. Subsequent calls return the cached plan instantly "
|
|
695
|
+
"without invoking Claude.\n\n"
|
|
696
|
+
"Use --refresh to regenerate and overwrite the cached plan."
|
|
697
|
+
),
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
@click.argument("assignment_id")
|
|
702
|
+
@click.option(
|
|
703
|
+
"--refresh",
|
|
704
|
+
is_flag=True,
|
|
705
|
+
default=False,
|
|
706
|
+
help="Regenerate the plan even if a cached one exists.",
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
@click.option(
|
|
711
|
+
"--model",
|
|
712
|
+
default="haiku",
|
|
713
|
+
show_default=True,
|
|
714
|
+
help="Claude model alias to use for plan generation.",
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
@_CONFIG_OPTION
|
|
719
|
+
def test_plan_cmd(
|
|
720
|
+
assignment_id: str,
|
|
721
|
+
refresh: bool,
|
|
722
|
+
model: str,
|
|
723
|
+
config_path: Path,
|
|
724
|
+
) -> None:
|
|
725
|
+
"""Generate or display the smoke test plan for ASSIGNMENT_ID."""
|
|
726
|
+
# #851: the assignment row (and its cached test_plan) live in the
|
|
727
|
+
# daemon's canonical DB. `generate_plan` queries the local DB directly,
|
|
728
|
+
# so on a thin client (empty local DB) it always reports "not found"
|
|
729
|
+
# even for a perfectly valid id — the same #584-class gap `diagnose` /
|
|
730
|
+
# `merge` / `reconcile-merges` already had. Route the whole command to
|
|
731
|
+
# the daemon, mirroring that pattern. COORD_TEST_PLAN_ON_DAEMON guards
|
|
732
|
+
# the daemon against re-routing to itself.
|
|
733
|
+
from coord.client import resolve_board_service # noqa: PLC0415
|
|
734
|
+
|
|
735
|
+
svc = resolve_board_service()
|
|
736
|
+
if svc is not None and not os.environ.get("COORD_TEST_PLAN_ON_DAEMON"):
|
|
737
|
+
_test_plan_via_daemon(
|
|
738
|
+
svc,
|
|
739
|
+
{"assignment_id": assignment_id, "refresh": refresh, "model": model},
|
|
740
|
+
)
|
|
741
|
+
return
|
|
742
|
+
|
|
743
|
+
from coord.state import get_test_plan, set_test_plan
|
|
744
|
+
from coord.test_orchestrator import find_local_repo_path, generate_plan
|
|
745
|
+
|
|
746
|
+
cfg = _load_config(config_path)
|
|
747
|
+
|
|
748
|
+
# ── Cache hit path ────────────────────────────────────────────────────
|
|
749
|
+
if not refresh:
|
|
750
|
+
cached = get_test_plan(assignment_id)
|
|
751
|
+
if cached is not None:
|
|
752
|
+
click.echo(json.dumps(cached, indent=2))
|
|
753
|
+
return
|
|
754
|
+
|
|
755
|
+
# ── Generate ──────────────────────────────────────────────────────────
|
|
756
|
+
click.echo(
|
|
757
|
+
f"Generating smoke test plan for assignment {assignment_id!r} "
|
|
758
|
+
f"(model: {model})...",
|
|
759
|
+
err=True,
|
|
760
|
+
)
|
|
761
|
+
plan = generate_plan(assignment_id, cfg, model=model)
|
|
762
|
+
|
|
763
|
+
# ── Capture branch HEAD SHA for staleness detection ────────────────────
|
|
764
|
+
# Read the assignment's branch from the DB, then resolve the HEAD SHA on
|
|
765
|
+
# the local machine so the TUI can detect when the branch has advanced
|
|
766
|
+
# since this plan was generated and trigger a refresh automatically.
|
|
767
|
+
branch_head = _get_assignment_branch_head(assignment_id, cfg, find_local_repo_path)
|
|
768
|
+
|
|
769
|
+
# Persist (always, even the fallback — so a subsequent call without
|
|
770
|
+
# --refresh shows the cached result rather than hitting Claude again).
|
|
771
|
+
# Always write branch_head (even when None) so stale SHAs from a prior
|
|
772
|
+
# run are cleared.
|
|
773
|
+
set_test_plan(assignment_id, plan, branch_head=branch_head)
|
|
774
|
+
|
|
775
|
+
click.echo(json.dumps(plan, indent=2))
|