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,916 @@
|
|
|
1
|
+
"""``coord acceptance`` — the framework-agnostic oracle-loop runner (#944,
|
|
2
|
+
docs/ORACLE_LOOP.md).
|
|
3
|
+
|
|
4
|
+
Subcommands:
|
|
5
|
+
|
|
6
|
+
- ``coord acceptance run --repo R (--issue N | --all)`` — run the repo's
|
|
7
|
+
declared driver **in-session** (the worker's own warm loop) and print a
|
|
8
|
+
structured pass/fail verdict. Sealed: verdicts only, never test source.
|
|
9
|
+
- ``coord acceptance record --repo R --issue N --sha SHA`` — the
|
|
10
|
+
coordinator's **external** trust gate: re-run the sealed slice against the
|
|
11
|
+
pushed SHA in a throwaway worktree and write the verdict to the board (the
|
|
12
|
+
Acceptance box). Routes the whole command through the daemon (mirrors
|
|
13
|
+
``coord merge`` / ``coord diagnose`` — the no-local-DB rule), never a bare
|
|
14
|
+
``save_board``.
|
|
15
|
+
- ``coord acceptance mock <repo> <tracking_issue>`` — Gate A (#930): dispatch
|
|
16
|
+
an independent mock-author that renders a viewable mock + writes
|
|
17
|
+
``tests/acceptance/ms-NN/contract.md``.
|
|
18
|
+
``coord.milestone_dispatch.gate_a_status`` blocks the milestone's issue
|
|
19
|
+
dispatch until that contract exists. ``--amend``/``--amend-file`` (#1315)
|
|
20
|
+
instead dispatch a targeted correction to an already-merged contract — the
|
|
21
|
+
properly-typed tool for that, replacing the ``type="work"`` fallback that
|
|
22
|
+
caused #1314.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
import click
|
|
33
|
+
|
|
34
|
+
from coord import github_ops
|
|
35
|
+
from coord.acceptance import (
|
|
36
|
+
ACCEPTANCE_DIRNAME,
|
|
37
|
+
acceptance_capability_gap,
|
|
38
|
+
build_verdict,
|
|
39
|
+
dump_manifest_error_hint,
|
|
40
|
+
failure_summary,
|
|
41
|
+
load_manifest,
|
|
42
|
+
ms_dir_for_issue,
|
|
43
|
+
test_ids_for_issue,
|
|
44
|
+
)
|
|
45
|
+
from coord.acceptance_drivers import DriverError, run_driver
|
|
46
|
+
from coord.commands._common import _CONFIG_OPTION, _load_config
|
|
47
|
+
from coord.comments import format_needs_attention
|
|
48
|
+
from coord.dispatch import DispatchRefused
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@click.group("acceptance")
|
|
52
|
+
def acceptance_group() -> None:
|
|
53
|
+
"""The oracle-loop acceptance runner.
|
|
54
|
+
|
|
55
|
+
A thin, framework-agnostic front end over a per-repo driver adapter
|
|
56
|
+
declared in ``coordinator.yml`` (``acceptance.drivers``). ``run`` is what
|
|
57
|
+
a worker calls in its own warm session to check itself against the
|
|
58
|
+
sealed suite; ``record`` is the coordinator's external re-run against a
|
|
59
|
+
pushed SHA — the trust gate a headless worker can't fake.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _resolve_driver(cfg, repo: str, route_path: str | None = None):
|
|
64
|
+
"""Resolve *repo*'s acceptance driver, exit(1) with a clear message when
|
|
65
|
+
none resolves.
|
|
66
|
+
|
|
67
|
+
*route_path* (#1125, repo-root-relative — e.g. ``"coord/foo.py"``)
|
|
68
|
+
selects a route when the repo's driver is routed
|
|
69
|
+
(``acceptance.drivers.<repo>.routes``); it's ignored for a flat
|
|
70
|
+
(unrouted) driver. When the repo IS routed but *route_path* doesn't
|
|
71
|
+
resolve to a route (including ``None``), the error names the missing
|
|
72
|
+
``--for-path`` rather than the generic "not configured at all" message,
|
|
73
|
+
since those are different operator mistakes.
|
|
74
|
+
"""
|
|
75
|
+
driver_cfg = cfg.acceptance.driver_for(repo, route_path)
|
|
76
|
+
if driver_cfg is None:
|
|
77
|
+
if cfg.acceptance.has_driver(repo):
|
|
78
|
+
click.echo(
|
|
79
|
+
f"error: repo {repo!r} has a routed acceptance driver "
|
|
80
|
+
"(acceptance.drivers routes) but no route matched — pass "
|
|
81
|
+
"--for-path to select the subtree (e.g. 'coord/**')",
|
|
82
|
+
err=True,
|
|
83
|
+
)
|
|
84
|
+
else:
|
|
85
|
+
click.echo(
|
|
86
|
+
f"error: no acceptance driver configured for repo {repo!r} "
|
|
87
|
+
"(add it under acceptance.drivers in coordinator.yml)",
|
|
88
|
+
err=True,
|
|
89
|
+
)
|
|
90
|
+
sys.exit(1)
|
|
91
|
+
return driver_cfg
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _check_local_capability(driver_cfg, repo: str, cfg) -> None:
|
|
95
|
+
"""Fail loudly (#966) when this host is about to run *repo*'s acceptance
|
|
96
|
+
driver but lacks the capability it declares, and some other configured
|
|
97
|
+
machine has it. There's no remote-exec plumbing yet to actually route
|
|
98
|
+
the run there — see :func:`coord.acceptance.acceptance_capability_gap` —
|
|
99
|
+
so the best available behavior is a clear, actionable error instead of
|
|
100
|
+
silently executing on hardware that may not support the driver.
|
|
101
|
+
"""
|
|
102
|
+
gap = acceptance_capability_gap(driver_cfg.capability, repo, cfg)
|
|
103
|
+
if gap is None:
|
|
104
|
+
return
|
|
105
|
+
click.echo(
|
|
106
|
+
f"error: this host lacks the {driver_cfg.capability!r} capability "
|
|
107
|
+
f"required by {repo!r}'s acceptance driver ({driver_cfg.kind}); "
|
|
108
|
+
f"{gap.name!r} has it. Capability-matched remote routing isn't "
|
|
109
|
+
"implemented yet (#966) — run this command on that machine directly.",
|
|
110
|
+
err=True,
|
|
111
|
+
)
|
|
112
|
+
sys.exit(1)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _scoped_verdict(
|
|
116
|
+
tests: list[dict],
|
|
117
|
+
acceptance_root: Path,
|
|
118
|
+
issue_number: int,
|
|
119
|
+
*,
|
|
120
|
+
entrypoint: str = "",
|
|
121
|
+
) -> dict:
|
|
122
|
+
"""Filter *tests* down to *issue_number*'s manifest slice, or exit(1) with
|
|
123
|
+
a clear message when the manifest / slice doesn't exist yet.
|
|
124
|
+
|
|
125
|
+
#1552: when the manifest's test-ids don't all show up in the driver's
|
|
126
|
+
output, name the failure instead of leaving it as a bare
|
|
127
|
+
``total=0, green=false``. That combination has exactly one common cause
|
|
128
|
+
on an entry-point-linked driver — the slice was authored but never
|
|
129
|
+
registered in the driver's crate root, so cargo compiled it into
|
|
130
|
+
nothing — and a verdict that only says "not green" sends the next round
|
|
131
|
+
hunting for a test failure that never ran. The missing ids are recorded
|
|
132
|
+
on the verdict as ``missing_ids`` and, when nothing at all ran, as a
|
|
133
|
+
``reason`` string.
|
|
134
|
+
"""
|
|
135
|
+
manifest = load_manifest(acceptance_root)
|
|
136
|
+
if not manifest:
|
|
137
|
+
click.echo(f"error: {dump_manifest_error_hint(acceptance_root)}", err=True)
|
|
138
|
+
sys.exit(1)
|
|
139
|
+
ids = test_ids_for_issue(manifest, issue_number)
|
|
140
|
+
if not ids:
|
|
141
|
+
click.echo(
|
|
142
|
+
f"error: issue #{issue_number} has no acceptance slice in the "
|
|
143
|
+
"manifest yet.",
|
|
144
|
+
err=True,
|
|
145
|
+
)
|
|
146
|
+
sys.exit(1)
|
|
147
|
+
scoped = [t for t in tests if t["id"] in ids]
|
|
148
|
+
verdict = build_verdict(scoped, scope="issue", issue_number=issue_number)
|
|
149
|
+
|
|
150
|
+
missing = sorted(ids - {t["id"] for t in scoped})
|
|
151
|
+
if missing:
|
|
152
|
+
verdict["missing_ids"] = missing
|
|
153
|
+
wiring_hint = (
|
|
154
|
+
f" Register it in the driver's entry point `{entrypoint}` "
|
|
155
|
+
"(acceptance.drivers.<repo>[.routes[]].entrypoint) — a slice that "
|
|
156
|
+
"isn't registered there is compiled into nothing and reports no "
|
|
157
|
+
"tests at all."
|
|
158
|
+
if entrypoint else
|
|
159
|
+
" The slice is not being discovered by the driver's run command."
|
|
160
|
+
)
|
|
161
|
+
if not scoped:
|
|
162
|
+
reason = (
|
|
163
|
+
f"issue #{issue_number}'s manifest lists {len(ids)} test-id(s) "
|
|
164
|
+
f"({', '.join(sorted(ids))}), NONE of which appeared in the "
|
|
165
|
+
"driver output — the slice is authored but never executed, so "
|
|
166
|
+
"this is a wiring failure, not a test failure." + wiring_hint
|
|
167
|
+
)
|
|
168
|
+
verdict["reason"] = reason
|
|
169
|
+
click.echo(f"error: {reason}", err=True)
|
|
170
|
+
else:
|
|
171
|
+
click.echo(
|
|
172
|
+
f"warning: {len(missing)} of issue #{issue_number}'s "
|
|
173
|
+
f"{len(ids)} manifest test-id(s) did not appear in the driver "
|
|
174
|
+
f"output ({', '.join(missing)}) — they never ran." + wiring_hint,
|
|
175
|
+
err=True,
|
|
176
|
+
)
|
|
177
|
+
return verdict
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@acceptance_group.command("run")
|
|
181
|
+
@click.option("--repo", required=True, help="Local repo name (coordinator.yml repos[].name).")
|
|
182
|
+
@click.option(
|
|
183
|
+
"--issue", "issue_number", type=int, default=None,
|
|
184
|
+
help="Issue number to scope the verdict to (mutually exclusive with --all).",
|
|
185
|
+
)
|
|
186
|
+
@click.option(
|
|
187
|
+
"--all", "run_all", is_flag=True,
|
|
188
|
+
help="Run + report the full accumulated suite (Gate C) instead of one issue's slice.",
|
|
189
|
+
)
|
|
190
|
+
@click.option(
|
|
191
|
+
"--path", "path_opt", type=click.Path(file_okay=False), default=None,
|
|
192
|
+
help="Repo checkout to run the driver in (default: current directory).",
|
|
193
|
+
)
|
|
194
|
+
@click.option(
|
|
195
|
+
"--for-path", "route_path", default=None,
|
|
196
|
+
help=(
|
|
197
|
+
"Repo-relative path (e.g. 'coord/foo.py') used to resolve a "
|
|
198
|
+
"routed acceptance driver (acceptance.drivers.<repo>.routes) — "
|
|
199
|
+
"required when the repo's driver is routed; unused/ignored for a "
|
|
200
|
+
"flat (unrouted) driver. NOT the same as --path (the checkout dir)."
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
@_CONFIG_OPTION
|
|
204
|
+
def acceptance_run(
|
|
205
|
+
repo: str,
|
|
206
|
+
issue_number: int | None,
|
|
207
|
+
run_all: bool,
|
|
208
|
+
path_opt: str | None,
|
|
209
|
+
route_path: str | None,
|
|
210
|
+
config_path: Path,
|
|
211
|
+
) -> None:
|
|
212
|
+
"""Run REPO's sealed acceptance suite and print a structured verdict.
|
|
213
|
+
|
|
214
|
+
The in-session command a worker runs to check itself: ``coord acceptance
|
|
215
|
+
run --issue N`` iterates against the sealed oracle without needing to see
|
|
216
|
+
inside it — only pass/fail + failure messages are ever printed, never
|
|
217
|
+
test source.
|
|
218
|
+
"""
|
|
219
|
+
if not run_all and issue_number is None:
|
|
220
|
+
click.echo("error: pass --issue N or --all", err=True)
|
|
221
|
+
sys.exit(1)
|
|
222
|
+
if run_all and issue_number is not None:
|
|
223
|
+
click.echo("error: --issue and --all are mutually exclusive", err=True)
|
|
224
|
+
sys.exit(1)
|
|
225
|
+
|
|
226
|
+
cfg = _load_config(config_path)
|
|
227
|
+
driver_cfg = _resolve_driver(cfg, repo, route_path)
|
|
228
|
+
_check_local_capability(driver_cfg, repo, cfg)
|
|
229
|
+
cwd = Path(path_opt).expanduser() if path_opt else Path.cwd()
|
|
230
|
+
|
|
231
|
+
# #2038: name the commit this verdict is actually about, and warn (never
|
|
232
|
+
# gate — the worker loop legitimately runs against uncommitted work) when
|
|
233
|
+
# it's behind origin's default branch. Best-effort/non-fatal by design.
|
|
234
|
+
repo_entry = cfg.repo(repo)
|
|
235
|
+
default_branch = (getattr(repo_entry, "default_branch", None) or "main") if repo_entry else "main"
|
|
236
|
+
for line in _checkout_freshness_lines(cwd, repo, default_branch):
|
|
237
|
+
click.echo(line, err=True)
|
|
238
|
+
|
|
239
|
+
# #1125 review finding 2: resolve the `{ms}` template (e.g. a routed
|
|
240
|
+
# `run: "pytest tests/acceptance/{ms}"`) from the issue's manifest-mapped
|
|
241
|
+
# ms-NN dir *before* running, when scoped to one issue. Fails soft to
|
|
242
|
+
# `ms=None` on any manifest read hiccup (malformed YAML, not authored
|
|
243
|
+
# yet) — `_scoped_verdict` below still surfaces a clear error for that
|
|
244
|
+
# case; this must not turn into a crash on its own.
|
|
245
|
+
ms: str | None = None
|
|
246
|
+
if issue_number is not None:
|
|
247
|
+
try:
|
|
248
|
+
ms = ms_dir_for_issue(cwd / ACCEPTANCE_DIRNAME, issue_number)
|
|
249
|
+
except Exception: # noqa: BLE001
|
|
250
|
+
ms = None
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
result = run_driver(
|
|
254
|
+
driver_cfg.kind, driver_cfg.run, cwd=str(cwd), ms=ms,
|
|
255
|
+
setup_command=driver_cfg.setup,
|
|
256
|
+
)
|
|
257
|
+
except DriverError as e:
|
|
258
|
+
click.echo(f"error: {e}", err=True)
|
|
259
|
+
sys.exit(1)
|
|
260
|
+
|
|
261
|
+
if not result.tests and result.exit_code != 0:
|
|
262
|
+
# Compile error / crash before any test emitted a verdict — surface
|
|
263
|
+
# the driver's raw output so the worker can actually act on it,
|
|
264
|
+
# rather than a bare "0 tests found".
|
|
265
|
+
click.echo(result.raw_output, err=True)
|
|
266
|
+
|
|
267
|
+
if run_all:
|
|
268
|
+
verdict = build_verdict(result.tests, scope="all")
|
|
269
|
+
else:
|
|
270
|
+
verdict = _scoped_verdict(
|
|
271
|
+
result.tests, cwd / ACCEPTANCE_DIRNAME, issue_number,
|
|
272
|
+
entrypoint=driver_cfg.entrypoint,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
click.echo(json.dumps(verdict, indent=2))
|
|
276
|
+
if verdict["total"] == 0 or not verdict["green"]:
|
|
277
|
+
sys.exit(1)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _checkout_freshness_lines(cwd: Path, repo: str, default_branch: str) -> list[str]:
|
|
281
|
+
"""Best-effort ``acceptance: {repo} @ {sha} ({branch})`` header plus a
|
|
282
|
+
dirty-tree / behind-``origin/{default_branch}`` warning line for each
|
|
283
|
+
condition that applies (#2038).
|
|
284
|
+
|
|
285
|
+
``coord acceptance run`` answers "is the sealed suite green" for
|
|
286
|
+
*whatever is in the current checkout*, with no indication of which
|
|
287
|
+
commit that is — exactly right for a worker's own warm loop (it
|
|
288
|
+
legitimately runs against uncommitted work), but a false-signal
|
|
289
|
+
generator for a coordinator's red-first / sanity re-run against a
|
|
290
|
+
checkout that quietly fell behind ``origin``: every test 404s or fails
|
|
291
|
+
and nothing in the output names a commit, so it reads as a broken merge
|
|
292
|
+
rather than a stale tree. This stays informational (a warning, never a
|
|
293
|
+
gate) — failing closed here would break the worker loop.
|
|
294
|
+
|
|
295
|
+
Silently returns ``[]`` (no header at all) when *cwd* isn't a git
|
|
296
|
+
checkout, or any git call errors/times out — this must never turn an
|
|
297
|
+
otherwise-successful acceptance run into a crash, and a worker running
|
|
298
|
+
in a network-isolated sandbox shouldn't see a scary "git fetch failed"
|
|
299
|
+
on every single invocation.
|
|
300
|
+
"""
|
|
301
|
+
import subprocess # noqa: PLC0415 — mirrors the rest of this module's lazy subprocess imports
|
|
302
|
+
|
|
303
|
+
def _git(*args: str, timeout: float = 10.0) -> subprocess.CompletedProcess | None:
|
|
304
|
+
try:
|
|
305
|
+
return subprocess.run(
|
|
306
|
+
["git", *args], cwd=str(cwd), capture_output=True, text=True, timeout=timeout,
|
|
307
|
+
)
|
|
308
|
+
except (subprocess.SubprocessError, OSError):
|
|
309
|
+
return None
|
|
310
|
+
|
|
311
|
+
head = _git("rev-parse", "--short=9", "HEAD")
|
|
312
|
+
if head is None or head.returncode != 0:
|
|
313
|
+
return []
|
|
314
|
+
sha = head.stdout.strip()
|
|
315
|
+
|
|
316
|
+
branch_res = _git("branch", "--show-current")
|
|
317
|
+
branch = (branch_res.stdout.strip() if branch_res and branch_res.returncode == 0 else "") \
|
|
318
|
+
or "HEAD (detached)"
|
|
319
|
+
|
|
320
|
+
lines = [f"acceptance: {repo} @ {sha} ({branch})"]
|
|
321
|
+
|
|
322
|
+
status_res = _git("status", "--porcelain")
|
|
323
|
+
if status_res is not None and status_res.returncode == 0 and status_res.stdout.strip():
|
|
324
|
+
lines.append(" ⚠ working tree has uncommitted changes")
|
|
325
|
+
|
|
326
|
+
# Single-branch fetch (never touches the working tree or local branch
|
|
327
|
+
# refs) so the behind-check reflects the ACTUAL remote, not whatever
|
|
328
|
+
# `origin/<default_branch>` happened to be cached from a previous
|
|
329
|
+
# fetch elsewhere — that staleness is exactly what bit #2038.
|
|
330
|
+
fetch_res = _git("fetch", "origin", default_branch, "--quiet", timeout=20.0)
|
|
331
|
+
if fetch_res is not None and fetch_res.returncode == 0:
|
|
332
|
+
count_res = _git("rev-list", "--count", "HEAD..FETCH_HEAD")
|
|
333
|
+
if count_res is not None and count_res.returncode == 0:
|
|
334
|
+
raw = count_res.stdout.strip()
|
|
335
|
+
if raw.isdigit() and int(raw) > 0:
|
|
336
|
+
n = int(raw)
|
|
337
|
+
lines.append(
|
|
338
|
+
f" ⚠ checkout is {n} commit{'s' if n != 1 else ''} behind "
|
|
339
|
+
f"origin/{default_branch} — `git pull` before trusting a "
|
|
340
|
+
"red/green verdict"
|
|
341
|
+
)
|
|
342
|
+
return lines
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _acceptance_worktree_path(repo_name: str, issue_number: int) -> Path:
|
|
346
|
+
"""Throwaway worktree path for ``coord acceptance record``'s external
|
|
347
|
+
re-run. Lives under ``~/.coord/acceptance-worktrees/`` — OUTSIDE the base
|
|
348
|
+
checkout, same rationale as ``coord test``'s ``_test_worktree_path``
|
|
349
|
+
(#561): a Build/record must never move the base checkout's branch (it
|
|
350
|
+
doubles as the live editable coordinator source on some machines)."""
|
|
351
|
+
from coord.state import COORD_DIR
|
|
352
|
+
|
|
353
|
+
return COORD_DIR / "acceptance-worktrees" / f"{repo_name}-{issue_number}"
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _remove_acceptance_worktree(repo_dir: Path, wt_path: Path) -> None:
|
|
357
|
+
if not wt_path.exists():
|
|
358
|
+
return
|
|
359
|
+
for args in (
|
|
360
|
+
["git", "worktree", "remove", "--force", str(wt_path)],
|
|
361
|
+
["git", "worktree", "prune"],
|
|
362
|
+
):
|
|
363
|
+
try:
|
|
364
|
+
subprocess.run(
|
|
365
|
+
args, cwd=str(repo_dir), capture_output=True, text=True, timeout=30,
|
|
366
|
+
)
|
|
367
|
+
except (subprocess.SubprocessError, OSError):
|
|
368
|
+
pass
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _acceptance_record_via_daemon(svc, params: dict) -> None:
|
|
372
|
+
"""Run ``coord acceptance record`` on the daemon host (where the
|
|
373
|
+
canonical board + the repo checkouts live) and relay its output.
|
|
374
|
+
Mirrors ``_diagnose_via_daemon`` / ``_reconcile_via_daemon``."""
|
|
375
|
+
from coord.client import post_record # noqa: PLC0415
|
|
376
|
+
|
|
377
|
+
try:
|
|
378
|
+
resp = post_record(svc, "/acceptance-record", params, timeout=900.0)
|
|
379
|
+
except Exception as exc: # noqa: BLE001
|
|
380
|
+
click.echo(f"error: acceptance record via daemon failed: {exc}", err=True)
|
|
381
|
+
sys.exit(1)
|
|
382
|
+
output = resp.get("output") or ""
|
|
383
|
+
if output:
|
|
384
|
+
click.echo(output, nl=False)
|
|
385
|
+
if resp.get("error"):
|
|
386
|
+
click.echo(f"error: {resp['error']}", err=True)
|
|
387
|
+
code = resp.get("exit_code") or 0
|
|
388
|
+
if code:
|
|
389
|
+
sys.exit(int(code))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@acceptance_group.command(
|
|
393
|
+
"author",
|
|
394
|
+
help=(
|
|
395
|
+
"Dispatch an independent `type=\"test-author\"` session (#931, "
|
|
396
|
+
"docs/ORACLE_LOOP.md) that authors — or, with --issue, extends — the "
|
|
397
|
+
"sealed feature-level acceptance suite for a milestone from its "
|
|
398
|
+
"Gate-A contract. TRACKING_ISSUE is the milestone's tracking issue "
|
|
399
|
+
"number (same argument `coord milestone order`/`gate-c` take); the "
|
|
400
|
+
"milestone number is resolved from it. Requires "
|
|
401
|
+
"`tests/acceptance/ms-NN/contract.md` to already exist in the repo "
|
|
402
|
+
"(hand-authored, or produced by the mock-author, #930) — the "
|
|
403
|
+
"test-author reads it from its own checkout, it is not dispatched "
|
|
404
|
+
"with the contract text embedded."
|
|
405
|
+
),
|
|
406
|
+
)
|
|
407
|
+
@click.argument("repo")
|
|
408
|
+
@click.argument("tracking_issue", type=int)
|
|
409
|
+
@click.option(
|
|
410
|
+
"--issue", "issue_number", type=int, default=None,
|
|
411
|
+
help=(
|
|
412
|
+
"Scope to one issue's just-in-time slice instead of the whole "
|
|
413
|
+
"milestone (must be a member of TRACKING_ISSUE's work order)."
|
|
414
|
+
),
|
|
415
|
+
)
|
|
416
|
+
@click.option(
|
|
417
|
+
"--machine", "machine_override", default=None,
|
|
418
|
+
help="Force a specific machine instead of auto-picking one.",
|
|
419
|
+
)
|
|
420
|
+
@click.option(
|
|
421
|
+
"--for-path", "route_path", default=None,
|
|
422
|
+
help=(
|
|
423
|
+
"Repo-relative path (e.g. 'coord/foo.py') used to resolve a "
|
|
424
|
+
"routed acceptance driver (acceptance.drivers.<repo>.routes) — "
|
|
425
|
+
"required when the repo's driver is routed; unused/ignored for a "
|
|
426
|
+
"flat (unrouted) driver."
|
|
427
|
+
),
|
|
428
|
+
)
|
|
429
|
+
@click.option(
|
|
430
|
+
"--interactive", is_flag=True,
|
|
431
|
+
help=(
|
|
432
|
+
"#1173: run the test-authoring session as a HUMAN-ATTENDED "
|
|
433
|
+
"`claude` (provider `claude-pty`) instead of dispatching a "
|
|
434
|
+
"headless `claude -p` worker — same shape as `coord assign "
|
|
435
|
+
"--interactive`'s --smoke-of/--merge-of/etc flavours. The "
|
|
436
|
+
"independence contract (zero shared context with the "
|
|
437
|
+
"implementation, contract-only) is unchanged; only who "
|
|
438
|
+
"supervises the authoring changes."
|
|
439
|
+
),
|
|
440
|
+
)
|
|
441
|
+
@click.option(
|
|
442
|
+
"--dry-run", "dry_run", is_flag=True,
|
|
443
|
+
help="With --interactive: resolve everything and print what would run, but don't launch.",
|
|
444
|
+
)
|
|
445
|
+
@_CONFIG_OPTION
|
|
446
|
+
def acceptance_author(
|
|
447
|
+
repo: str,
|
|
448
|
+
tracking_issue: int,
|
|
449
|
+
issue_number: int | None,
|
|
450
|
+
machine_override: str | None,
|
|
451
|
+
route_path: str | None,
|
|
452
|
+
interactive: bool,
|
|
453
|
+
dry_run: bool,
|
|
454
|
+
config_path: Path,
|
|
455
|
+
) -> None:
|
|
456
|
+
"""Dispatch the independent test-author for REPO's milestone."""
|
|
457
|
+
cfg = _load_config(config_path)
|
|
458
|
+
|
|
459
|
+
if interactive:
|
|
460
|
+
from coord.test_author import dispatch_test_author_interactive
|
|
461
|
+
|
|
462
|
+
try:
|
|
463
|
+
exit_code = dispatch_test_author_interactive(
|
|
464
|
+
repo,
|
|
465
|
+
tracking_issue,
|
|
466
|
+
cfg,
|
|
467
|
+
issue_number=issue_number,
|
|
468
|
+
machine_override=machine_override,
|
|
469
|
+
path=route_path,
|
|
470
|
+
dry_run=dry_run,
|
|
471
|
+
)
|
|
472
|
+
except DispatchRefused as e:
|
|
473
|
+
# #2063: the Gate-A sign-off refusal is deterministic and
|
|
474
|
+
# operator-fixable (`coord gate-a --approved`), not a crash —
|
|
475
|
+
# exit EXIT_DISPATCH_REFUSED (not the generic 1 below) so
|
|
476
|
+
# `coord drive`'s subprocess boundary and `coord drive-queue`'s
|
|
477
|
+
# tick can tell it apart and PARK the entry (#1891/#1892)
|
|
478
|
+
# instead of burning attempts toward terminal `blocked` (#2040).
|
|
479
|
+
# Mirrors `coord fix`'s/`coord assign`'s existing
|
|
480
|
+
# `except DispatchRefused` handling.
|
|
481
|
+
from coord.drive import EXIT_DISPATCH_REFUSED # noqa: PLC0415
|
|
482
|
+
|
|
483
|
+
click.echo(f"error: {e}", err=True)
|
|
484
|
+
sys.exit(EXIT_DISPATCH_REFUSED)
|
|
485
|
+
except RuntimeError as e:
|
|
486
|
+
click.echo(f"error: {e}", err=True)
|
|
487
|
+
sys.exit(1)
|
|
488
|
+
sys.exit(exit_code)
|
|
489
|
+
|
|
490
|
+
if dry_run:
|
|
491
|
+
click.echo("error: --dry-run requires --interactive", err=True)
|
|
492
|
+
sys.exit(2)
|
|
493
|
+
|
|
494
|
+
from coord.test_author import dispatch_test_author
|
|
495
|
+
|
|
496
|
+
try:
|
|
497
|
+
assignment_id, machine_name = dispatch_test_author(
|
|
498
|
+
repo,
|
|
499
|
+
tracking_issue,
|
|
500
|
+
cfg,
|
|
501
|
+
issue_number=issue_number,
|
|
502
|
+
machine_override=machine_override,
|
|
503
|
+
path=route_path,
|
|
504
|
+
)
|
|
505
|
+
except DispatchRefused as e:
|
|
506
|
+
# #2063: see the matching --interactive branch above — same
|
|
507
|
+
# deterministic-refusal exit code so the headless dispatch path
|
|
508
|
+
# parks instead of blocking too.
|
|
509
|
+
from coord.drive import EXIT_DISPATCH_REFUSED # noqa: PLC0415
|
|
510
|
+
|
|
511
|
+
click.echo(f"error: {e}", err=True)
|
|
512
|
+
sys.exit(EXIT_DISPATCH_REFUSED)
|
|
513
|
+
except RuntimeError as e:
|
|
514
|
+
click.echo(f"error: {e}", err=True)
|
|
515
|
+
sys.exit(1)
|
|
516
|
+
|
|
517
|
+
scope = f"issue #{issue_number} slice" if issue_number is not None else "full milestone"
|
|
518
|
+
click.echo(
|
|
519
|
+
f"Dispatched test-author {assignment_id} to {machine_name} for "
|
|
520
|
+
f"{repo} (tracking issue #{tracking_issue}, {scope})."
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@acceptance_group.command("record")
|
|
525
|
+
@click.option("--repo", required=True, help="Local repo name (coordinator.yml repos[].name).")
|
|
526
|
+
@click.option("--issue", "issue_number", type=int, required=True, help="Issue number.")
|
|
527
|
+
@click.option(
|
|
528
|
+
"--sha", "sha", required=True,
|
|
529
|
+
help="Commit SHA to check out and re-run the sealed suite against — the trust gate.",
|
|
530
|
+
)
|
|
531
|
+
@click.option(
|
|
532
|
+
"--for-path", "route_path", default=None,
|
|
533
|
+
help=(
|
|
534
|
+
"Repo-relative path (e.g. 'coord/foo.py') used to resolve a "
|
|
535
|
+
"routed acceptance driver (acceptance.drivers.<repo>.routes) — "
|
|
536
|
+
"required when the repo's driver is routed; unused/ignored for a "
|
|
537
|
+
"flat (unrouted) driver."
|
|
538
|
+
),
|
|
539
|
+
)
|
|
540
|
+
@_CONFIG_OPTION
|
|
541
|
+
def acceptance_record(
|
|
542
|
+
repo: str,
|
|
543
|
+
issue_number: int,
|
|
544
|
+
sha: str,
|
|
545
|
+
route_path: str | None,
|
|
546
|
+
config_path: Path,
|
|
547
|
+
) -> None:
|
|
548
|
+
"""Re-run REPO's issue-N acceptance slice externally against SHA and
|
|
549
|
+
write the verdict to the board (the Acceptance box).
|
|
550
|
+
|
|
551
|
+
A headless worker can lie about "green" in its own session; it can't
|
|
552
|
+
fake the coordinator re-running the sealed suite itself, against the
|
|
553
|
+
exact SHA it pushed, in a throwaway worktree the worker never touches.
|
|
554
|
+
"""
|
|
555
|
+
from coord.board_service import daemon_reroute_target # noqa: PLC0415
|
|
556
|
+
|
|
557
|
+
# #944: the canonical board + the repo checkouts live on the daemon host,
|
|
558
|
+
# so a thin client routes the ENTIRE record run there (mirrors `coord
|
|
559
|
+
# merge` / `coord diagnose` — never a bare save_board from a thin
|
|
560
|
+
# client's empty local DB). COORD_ACCEPTANCE_ON_DAEMON guards the daemon
|
|
561
|
+
# against re-routing to itself (set by the /acceptance-record server
|
|
562
|
+
# route before it calls this callback directly).
|
|
563
|
+
svc = daemon_reroute_target("COORD_ACCEPTANCE_ON_DAEMON")
|
|
564
|
+
if svc is not None:
|
|
565
|
+
_acceptance_record_via_daemon(
|
|
566
|
+
svc,
|
|
567
|
+
{
|
|
568
|
+
"repo": repo, "issue": issue_number, "sha": sha,
|
|
569
|
+
"for_path": route_path,
|
|
570
|
+
},
|
|
571
|
+
)
|
|
572
|
+
return
|
|
573
|
+
|
|
574
|
+
_acceptance_record_local(repo, issue_number, sha, config_path, route_path)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _acceptance_record_local(
|
|
578
|
+
repo: str,
|
|
579
|
+
issue_number: int,
|
|
580
|
+
sha: str,
|
|
581
|
+
config_path: Path,
|
|
582
|
+
route_path: str | None = None,
|
|
583
|
+
) -> None:
|
|
584
|
+
from coord.test_orchestrator import find_local_repo_path # noqa: PLC0415
|
|
585
|
+
|
|
586
|
+
cfg = _load_config(config_path)
|
|
587
|
+
driver_cfg = _resolve_driver(cfg, repo, route_path)
|
|
588
|
+
_check_local_capability(driver_cfg, repo, cfg)
|
|
589
|
+
|
|
590
|
+
repo_dir = find_local_repo_path(repo, cfg)
|
|
591
|
+
if repo_dir is None or not repo_dir.exists():
|
|
592
|
+
click.echo(
|
|
593
|
+
f"error: no local repo checkout found for {repo!r} "
|
|
594
|
+
"(repo_paths in coordinator.yml)",
|
|
595
|
+
err=True,
|
|
596
|
+
)
|
|
597
|
+
sys.exit(1)
|
|
598
|
+
|
|
599
|
+
wt_path = _acceptance_worktree_path(repo, issue_number)
|
|
600
|
+
click.echo(
|
|
601
|
+
f"Fetching origin and preparing acceptance worktree at {sha!r} "
|
|
602
|
+
f"(base checkout {repo_dir} stays untouched)..."
|
|
603
|
+
)
|
|
604
|
+
try:
|
|
605
|
+
subprocess.run(
|
|
606
|
+
["git", "fetch", "origin", "--prune"], cwd=str(repo_dir),
|
|
607
|
+
check=True, capture_output=True, text=True,
|
|
608
|
+
)
|
|
609
|
+
except subprocess.CalledProcessError as e:
|
|
610
|
+
click.echo(f"error: git fetch failed: {e.stderr.strip()}", err=True)
|
|
611
|
+
sys.exit(1)
|
|
612
|
+
|
|
613
|
+
_remove_acceptance_worktree(repo_dir, wt_path)
|
|
614
|
+
wt_path.parent.mkdir(parents=True, exist_ok=True)
|
|
615
|
+
res = subprocess.run(
|
|
616
|
+
["git", "worktree", "add", "--force", "--detach", str(wt_path), sha],
|
|
617
|
+
cwd=str(repo_dir), capture_output=True, text=True,
|
|
618
|
+
)
|
|
619
|
+
if res.returncode != 0:
|
|
620
|
+
click.echo(
|
|
621
|
+
f"error: could not create acceptance worktree at {sha!r}: "
|
|
622
|
+
f"{res.stderr.strip()}",
|
|
623
|
+
err=True,
|
|
624
|
+
)
|
|
625
|
+
sys.exit(1)
|
|
626
|
+
|
|
627
|
+
# #1125 review finding 2: resolve `{ms}` from the issue's manifest-mapped
|
|
628
|
+
# ms-NN dir (the worktree just checked out at `sha`) before running —
|
|
629
|
+
# same fail-soft-to-None rationale as `acceptance_run` above.
|
|
630
|
+
ms: str | None = None
|
|
631
|
+
try:
|
|
632
|
+
ms = ms_dir_for_issue(wt_path / ACCEPTANCE_DIRNAME, issue_number)
|
|
633
|
+
except Exception: # noqa: BLE001
|
|
634
|
+
ms = None
|
|
635
|
+
|
|
636
|
+
try:
|
|
637
|
+
result = run_driver(
|
|
638
|
+
driver_cfg.kind, driver_cfg.run, cwd=str(wt_path), ms=ms,
|
|
639
|
+
setup_command=driver_cfg.setup,
|
|
640
|
+
)
|
|
641
|
+
except DriverError as e:
|
|
642
|
+
click.echo(f"error: {e}", err=True)
|
|
643
|
+
_remove_acceptance_worktree(repo_dir, wt_path)
|
|
644
|
+
sys.exit(1)
|
|
645
|
+
|
|
646
|
+
# #944 review: _scoped_verdict exits(1) internally for a manifest that
|
|
647
|
+
# hasn't been authored yet / has no slice for this issue — a
|
|
648
|
+
# configuration error, not a real (kept-for-inspection) test failure, so
|
|
649
|
+
# the throwaway worktree must still be cleaned up on the way out.
|
|
650
|
+
try:
|
|
651
|
+
verdict = _scoped_verdict(
|
|
652
|
+
result.tests, wt_path / ACCEPTANCE_DIRNAME, issue_number,
|
|
653
|
+
entrypoint=driver_cfg.entrypoint,
|
|
654
|
+
)
|
|
655
|
+
except SystemExit:
|
|
656
|
+
_remove_acceptance_worktree(repo_dir, wt_path)
|
|
657
|
+
raise
|
|
658
|
+
|
|
659
|
+
from coord.board_service import read_board # noqa: PLC0415
|
|
660
|
+
from coord.diagnose import stage_assignments # noqa: PLC0415
|
|
661
|
+
|
|
662
|
+
board = read_board()
|
|
663
|
+
work_rows = stage_assignments(board, repo, issue_number, "work")
|
|
664
|
+
if not work_rows:
|
|
665
|
+
click.echo(
|
|
666
|
+
f"error: no work assignment found for {repo} #{issue_number}; "
|
|
667
|
+
"cannot record verdict",
|
|
668
|
+
err=True,
|
|
669
|
+
)
|
|
670
|
+
# Same rationale: a lookup error, not a failing-verdict "kept for
|
|
671
|
+
# inspection" case — don't leak the worktree.
|
|
672
|
+
_remove_acceptance_worktree(repo_dir, wt_path)
|
|
673
|
+
sys.exit(1)
|
|
674
|
+
assignment_id = work_rows[0].assignment_id
|
|
675
|
+
|
|
676
|
+
from coord.state import record_acceptance_verdict # noqa: PLC0415
|
|
677
|
+
|
|
678
|
+
acceptance_state = "passed" if verdict["green"] else "failed"
|
|
679
|
+
reason = failure_summary(verdict) or None
|
|
680
|
+
record_acceptance_verdict(
|
|
681
|
+
assignment_id=assignment_id,
|
|
682
|
+
acceptance_state=acceptance_state,
|
|
683
|
+
acceptance_reason=reason,
|
|
684
|
+
acceptance_sha=sha,
|
|
685
|
+
# #932: per-test counts so the Acceptance box can show partial
|
|
686
|
+
# progress ("3/7 acceptance green") instead of a bare verdict.
|
|
687
|
+
acceptance_total=verdict["total"],
|
|
688
|
+
acceptance_passed=verdict["passed"],
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
click.echo(json.dumps(verdict, indent=2))
|
|
692
|
+
click.echo(f"\nAcceptance {acceptance_state.upper()} for {repo} #{issue_number} @ {sha}")
|
|
693
|
+
|
|
694
|
+
if acceptance_state == "passed":
|
|
695
|
+
_remove_acceptance_worktree(repo_dir, wt_path)
|
|
696
|
+
else:
|
|
697
|
+
click.echo(f" worktree kept for inspection: {wt_path}")
|
|
698
|
+
sys.exit(1)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def _stall_push_wip_snapshot(cwd: Path) -> str:
|
|
702
|
+
"""Best-effort WIP snapshot push (#846 worker self-report).
|
|
703
|
+
|
|
704
|
+
Not the coordinator's remote-exec finalize path
|
|
705
|
+
(``coord.interactive.finalize_remote_interactive_exit`` — that's for a
|
|
706
|
+
*remote* interactive fix session over ssh, the wrong shape here since
|
|
707
|
+
this runs inside the worker's own local checkout) — just a plain
|
|
708
|
+
``git push`` of whatever is on the current branch, so nothing is lost if
|
|
709
|
+
the coordinator takes over. Never raises: a worker calling ``stall`` is
|
|
710
|
+
already stuck, and a push failure shouldn't block the rest of the
|
|
711
|
+
report.
|
|
712
|
+
"""
|
|
713
|
+
try:
|
|
714
|
+
branch_res = subprocess.run(
|
|
715
|
+
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
|
716
|
+
cwd=str(cwd), capture_output=True, text=True, timeout=15,
|
|
717
|
+
)
|
|
718
|
+
except (subprocess.SubprocessError, OSError) as exc:
|
|
719
|
+
return f"WIP push skipped: could not resolve branch ({exc})."
|
|
720
|
+
branch = branch_res.stdout.strip()
|
|
721
|
+
if branch_res.returncode != 0 or not branch or branch == "HEAD":
|
|
722
|
+
return "WIP push skipped: not on a branch (detached HEAD)."
|
|
723
|
+
try:
|
|
724
|
+
push_res = subprocess.run(
|
|
725
|
+
["git", "push", "origin", f"HEAD:{branch}"],
|
|
726
|
+
cwd=str(cwd), capture_output=True, text=True, timeout=60,
|
|
727
|
+
)
|
|
728
|
+
except (subprocess.SubprocessError, OSError) as exc:
|
|
729
|
+
return f"WIP push failed (branch `{branch}`): {exc}"
|
|
730
|
+
if push_res.returncode == 0:
|
|
731
|
+
return f"WIP snapshot pushed to `{branch}`."
|
|
732
|
+
return f"WIP push failed (branch `{branch}`): {push_res.stderr.strip()[:200]}"
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
@acceptance_group.command(
|
|
736
|
+
"stall",
|
|
737
|
+
help=(
|
|
738
|
+
"Worker self-report (#846, preferred over a coordinator wall-clock "
|
|
739
|
+
"backstop): call this when your acceptance slice for REPO #ISSUE "
|
|
740
|
+
"isn't converging — the failing-set churns rather than shrinks "
|
|
741
|
+
"across >=2 rounds. Records a pinned #603 context note, "
|
|
742
|
+
"best-effort pushes a WIP snapshot of the current branch, and "
|
|
743
|
+
"posts the same one-shot 'needs attention' GitHub comment the "
|
|
744
|
+
"coordinator's backstop (coord.notify.detect_needs_attention) "
|
|
745
|
+
"would otherwise post later. This is the 'stop grinding and "
|
|
746
|
+
"report it' step the oracle-loop contract "
|
|
747
|
+
"(coord.acceptance.oracle_loop_contract_block) points workers at."
|
|
748
|
+
),
|
|
749
|
+
)
|
|
750
|
+
@click.option("--repo", required=True, help="Local repo name (coordinator.yml repos[].name).")
|
|
751
|
+
@click.option("--issue", "issue_number", type=int, required=True, help="Issue number.")
|
|
752
|
+
@click.option(
|
|
753
|
+
"--tried", required=True,
|
|
754
|
+
help="What you tried across the churning rounds (one or two sentences).",
|
|
755
|
+
)
|
|
756
|
+
@click.option(
|
|
757
|
+
"--stuck", required=True,
|
|
758
|
+
help="Which test id(s)/behavior are still failing and why, as best understood.",
|
|
759
|
+
)
|
|
760
|
+
@click.option(
|
|
761
|
+
"--path", "path_opt", type=click.Path(file_okay=False), default=None,
|
|
762
|
+
help="Repo checkout to push the WIP snapshot from (default: current directory).",
|
|
763
|
+
)
|
|
764
|
+
@_CONFIG_OPTION
|
|
765
|
+
def acceptance_stall(
|
|
766
|
+
repo: str,
|
|
767
|
+
issue_number: int,
|
|
768
|
+
tried: str,
|
|
769
|
+
stuck: str,
|
|
770
|
+
path_opt: str | None,
|
|
771
|
+
config_path: Path,
|
|
772
|
+
) -> None:
|
|
773
|
+
"""Report that REPO #ISSUE's acceptance slice isn't converging."""
|
|
774
|
+
from coord.board_service import read_board # noqa: PLC0415
|
|
775
|
+
from coord.diagnose import stage_assignments # noqa: PLC0415
|
|
776
|
+
from coord.state import add_issue_context_entry, mark_needs_attention_notified # noqa: PLC0415
|
|
777
|
+
|
|
778
|
+
cfg = _load_config(config_path)
|
|
779
|
+
repo_entry = cfg.repo(repo)
|
|
780
|
+
if repo_entry is None:
|
|
781
|
+
click.echo(f"error: unknown repo {repo!r}", err=True)
|
|
782
|
+
sys.exit(2)
|
|
783
|
+
|
|
784
|
+
cwd = Path(path_opt).expanduser() if path_opt else Path.cwd()
|
|
785
|
+
push_note = _stall_push_wip_snapshot(cwd)
|
|
786
|
+
|
|
787
|
+
tried = tried.strip()
|
|
788
|
+
stuck = stuck.strip()
|
|
789
|
+
note = f"Acceptance stall reported. Tried: {tried} Stuck: {stuck} {push_note}".strip()
|
|
790
|
+
add_issue_context_entry(repo, issue_number, note, pinned=True, source="acceptance-stall")
|
|
791
|
+
|
|
792
|
+
board = read_board()
|
|
793
|
+
work_rows = stage_assignments(board, repo, issue_number, "work")
|
|
794
|
+
work = work_rows[0] if work_rows else None
|
|
795
|
+
|
|
796
|
+
body = format_needs_attention(
|
|
797
|
+
assignment_id=(work.assignment_id if work else None) or "",
|
|
798
|
+
machine_name=(work.machine_name if work else None) or "(self-reported)",
|
|
799
|
+
repo_name=repo,
|
|
800
|
+
issue_number=issue_number,
|
|
801
|
+
reason="non_convergence",
|
|
802
|
+
detail=(
|
|
803
|
+
"Acceptance slice not converging (worker self-report).\n\n"
|
|
804
|
+
f"**Tried:** {tried}\n\n**Stuck:** {stuck}\n\n{push_note}"
|
|
805
|
+
),
|
|
806
|
+
)
|
|
807
|
+
try:
|
|
808
|
+
github_ops.post_issue_comment(repo_entry.github, issue_number, body)
|
|
809
|
+
except Exception as exc: # noqa: BLE001 — the context note above already
|
|
810
|
+
# landed; a comment-post failure shouldn't turn this into a hard error.
|
|
811
|
+
click.echo(f"warning: could not post needs-attention comment: {exc}", err=True)
|
|
812
|
+
else:
|
|
813
|
+
# #846 review: share the notified-ledger with the coordinator's
|
|
814
|
+
# wall-clock backstop (coord.notify.detect_needs_attention) so this
|
|
815
|
+
# self-report is a true one-shot — otherwise the same assignment
|
|
816
|
+
# stays eligible and can get a second "needs attention" comment
|
|
817
|
+
# later. Skip when no work assignment id was resolved (matches the
|
|
818
|
+
# existing blank-assignment-id test case).
|
|
819
|
+
if work is not None:
|
|
820
|
+
mark_needs_attention_notified(work.assignment_id)
|
|
821
|
+
|
|
822
|
+
click.echo(f"Recorded acceptance stall for {repo} #{issue_number}.")
|
|
823
|
+
click.echo(f" {push_note}")
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
@acceptance_group.command(
|
|
827
|
+
"mock",
|
|
828
|
+
help=(
|
|
829
|
+
"Gate A (#930, docs/ORACLE_LOOP.md): dispatch an independent "
|
|
830
|
+
"mock-author agent that renders a viewable mock of the milestone's "
|
|
831
|
+
"user-facing surface and writes tests/acceptance/ms-NN/contract.md "
|
|
832
|
+
"— the black-box contract the milestone's workers and the "
|
|
833
|
+
"independent test-author (#931) implement/test to. REPO is the "
|
|
834
|
+
"local repo name from coordinator.yml; TRACKING_ISSUE is the GH "
|
|
835
|
+
"issue number of the milestone's tracking issue (must carry a "
|
|
836
|
+
"milestone). `coord milestone dispatch` refuses this milestone's "
|
|
837
|
+
"issues until the contract this produces exists. Pass --amend (or "
|
|
838
|
+
"--amend-file) to instead dispatch a targeted correction to an "
|
|
839
|
+
"ALREADY-MERGED contract — the properly-typed tool for that #1315 "
|
|
840
|
+
"adds, replacing the type=\"work\" fallback that caused #1314."
|
|
841
|
+
),
|
|
842
|
+
)
|
|
843
|
+
@click.argument("repo")
|
|
844
|
+
@click.argument("tracking_issue", type=int)
|
|
845
|
+
@click.option(
|
|
846
|
+
"--machine",
|
|
847
|
+
default=None,
|
|
848
|
+
help="Override machine selection (default: first idle machine that lists the repo).",
|
|
849
|
+
)
|
|
850
|
+
@click.option(
|
|
851
|
+
"--for-path", "route_path", default=None,
|
|
852
|
+
help=(
|
|
853
|
+
"Repo-relative path (e.g. 'coord/foo.py') used to resolve a "
|
|
854
|
+
"routed acceptance driver (acceptance.drivers.<repo>.routes) — "
|
|
855
|
+
"required when the repo's driver is routed; unused/ignored for a "
|
|
856
|
+
"flat (unrouted) driver."
|
|
857
|
+
),
|
|
858
|
+
)
|
|
859
|
+
@click.option(
|
|
860
|
+
"--amend", "amend_text", default=None,
|
|
861
|
+
help=(
|
|
862
|
+
"#1315: targeted amendment mode — dispatch a narrow mock-author "
|
|
863
|
+
"session that corrects the ALREADY-MERGED contract.md/mocks under "
|
|
864
|
+
"tests/acceptance/ms-NN/, using this exact text as the correction "
|
|
865
|
+
"to make, instead of doing a full fresh render from the "
|
|
866
|
+
"milestone's open issues. This is the properly-typed replacement "
|
|
867
|
+
"for falling back to a plain `coord assign` (type=\"work\") to fix "
|
|
868
|
+
"a small contract mistake (#1314's root cause). Mutually "
|
|
869
|
+
"exclusive with --amend-file."
|
|
870
|
+
),
|
|
871
|
+
)
|
|
872
|
+
@click.option(
|
|
873
|
+
"--amend-file", "amend_file", type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
|
874
|
+
default=None,
|
|
875
|
+
help="Read the --amend correction text from a file instead of the command line.",
|
|
876
|
+
)
|
|
877
|
+
@_CONFIG_OPTION
|
|
878
|
+
def acceptance_mock_cmd(
|
|
879
|
+
repo: str,
|
|
880
|
+
tracking_issue: int,
|
|
881
|
+
machine: str | None,
|
|
882
|
+
route_path: str | None,
|
|
883
|
+
amend_text: str | None,
|
|
884
|
+
amend_file: Path | None,
|
|
885
|
+
config_path: Path,
|
|
886
|
+
) -> None:
|
|
887
|
+
if amend_text is not None and amend_file is not None:
|
|
888
|
+
click.echo("error: --amend and --amend-file are mutually exclusive", err=True)
|
|
889
|
+
sys.exit(2)
|
|
890
|
+
if amend_file is not None:
|
|
891
|
+
amend_text = amend_file.read_text()
|
|
892
|
+
|
|
893
|
+
cfg = _load_config(config_path)
|
|
894
|
+
repo_entry = cfg.repo(repo)
|
|
895
|
+
if repo_entry is None:
|
|
896
|
+
click.echo(f"error: unknown repo {repo!r}", err=True)
|
|
897
|
+
sys.exit(2)
|
|
898
|
+
|
|
899
|
+
from coord.mock_author import dispatch_acceptance_mock
|
|
900
|
+
|
|
901
|
+
try:
|
|
902
|
+
assignment_id, picked_machine = dispatch_acceptance_mock(
|
|
903
|
+
repo,
|
|
904
|
+
tracking_issue,
|
|
905
|
+
cfg,
|
|
906
|
+
machine_override=machine,
|
|
907
|
+
path=route_path,
|
|
908
|
+
amend_briefing=amend_text,
|
|
909
|
+
)
|
|
910
|
+
except RuntimeError as exc:
|
|
911
|
+
click.echo(f"error: {exc}", err=True)
|
|
912
|
+
sys.exit(1)
|
|
913
|
+
|
|
914
|
+
verb = "amend" if amend_text is not None else "mock-author"
|
|
915
|
+
click.echo(f"Dispatched {verb} for #{tracking_issue} -> {picked_machine}")
|
|
916
|
+
click.echo(assignment_id)
|