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,4894 @@
|
|
|
1
|
+
"""`assign --interactive` mode implementations: review/smoke/troubleshoot/
|
|
2
|
+
chat/fix/rework/merge/plain-interactive/headless dispatch. Split out of
|
|
3
|
+
dispatch.py to keep either file from becoming a new god-file.
|
|
4
|
+
Extracted from coord/cli.py (#747)."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
from coord import github_ops
|
|
17
|
+
from coord.config import Config
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
from coord.commands.review import (
|
|
21
|
+
_prompt_and_relay_review_verdict,
|
|
22
|
+
_prompt_and_relay_test_verdict,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _echo_artifact_stash(fr: object) -> None:
|
|
27
|
+
"""Echo the artifact-stash outcome from a remote interactive finalize (#1295).
|
|
28
|
+
|
|
29
|
+
``fr.artifacts_stashed`` is ``None`` when no stash was even attempted
|
|
30
|
+
(no ``artifact_paths`` configured for the repo) — say nothing in that
|
|
31
|
+
case, it's not news. ``0`` means a stash DID run — on the remote host,
|
|
32
|
+
over ssh — but 0 files matched the configured globs; that's the exact
|
|
33
|
+
silent failure the issue reported (stderr redirected to ``/dev/null``
|
|
34
|
+
hid the agent-side warning), so surface it loudly here instead of
|
|
35
|
+
letting it disappear again on the operator's own terminal.
|
|
36
|
+
"""
|
|
37
|
+
n = getattr(fr, "artifacts_stashed", None)
|
|
38
|
+
if n is None:
|
|
39
|
+
return
|
|
40
|
+
if n == 0:
|
|
41
|
+
click.echo(
|
|
42
|
+
" warning: remote stash copied 0 artifacts — check "
|
|
43
|
+
"artifact_paths config and that the build actually produced "
|
|
44
|
+
"the expected outputs",
|
|
45
|
+
err=True,
|
|
46
|
+
)
|
|
47
|
+
else:
|
|
48
|
+
click.echo(f" stashed {n} artifact(s)")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _smoke_report_reminder(*, assignment_id: str, smoke_of: str) -> str:
|
|
52
|
+
"""The reporting contract prepended to an interactive smoke briefing
|
|
53
|
+
(#1349, updated #1351).
|
|
54
|
+
|
|
55
|
+
Recording the verdict is the ONE artifact this session exists to produce.
|
|
56
|
+
Until #1351, a test verdict had NO PATH-independent fallback channel — a
|
|
57
|
+
review has always had one (the transcript-recoverable ``REVIEW_VERDICT``
|
|
58
|
+
block, #606/#651): if ``coord test`` never ran, or ran and failed, the
|
|
59
|
+
verdict was simply lost and the operator was re-prompted on exit for
|
|
60
|
+
something the agent was dispatched to do. #1351 closes that gap by giving
|
|
61
|
+
the Test gate the same two-channel contract a review has always had: a
|
|
62
|
+
PRIMARY fast path (``coord test``, written straight to the board) plus a
|
|
63
|
+
BACKUP structured block (``TEST_VERDICT:``/``TEST_REASON:``/
|
|
64
|
+
``END_TEST``) recovered from the session transcript even when the
|
|
65
|
+
primary path never ran. Both are REQUIRED, every time — the backup does
|
|
66
|
+
not excuse skipping the primary, and vice versa.
|
|
67
|
+
|
|
68
|
+
Extracted to module scope so the contract is unit-testable — the briefing
|
|
69
|
+
text IS the product here, and a silent edit that drops the obligation is
|
|
70
|
+
exactly the drift this session is trying to stop.
|
|
71
|
+
"""
|
|
72
|
+
return (
|
|
73
|
+
f"[Coordinator smoke assignment {assignment_id}] HUMAN-ATTENDED "
|
|
74
|
+
"interactive smoke test.\n"
|
|
75
|
+
" Record your verdict TWICE before you finish — belt and braces, "
|
|
76
|
+
"neither step substitutes for the other, even if the session was "
|
|
77
|
+
"short or the operator seems done:\n"
|
|
78
|
+
" a. PRIMARY (do this first, once the operator has a clear "
|
|
79
|
+
"position):\n"
|
|
80
|
+
f" coord test --passed {smoke_of}\n"
|
|
81
|
+
f" coord test --fail {smoke_of} --reason \"<full failure brief>\"\n"
|
|
82
|
+
" Run exactly one of them. Then CHECK THE OUTPUT: the command "
|
|
83
|
+
"prints a confirmation. If it errored, or `coord` is not on your "
|
|
84
|
+
"PATH, say so plainly to the operator — and fall through to step b "
|
|
85
|
+
"anyway; it is REQUIRED regardless. Do NOT report the verdict as "
|
|
86
|
+
"recorded when you have not seen it confirmed.\n"
|
|
87
|
+
" b. BACKUP (always do this too, even after a successful step a): "
|
|
88
|
+
"at the END of your session, output your verdict in this exact "
|
|
89
|
+
"format:\n\n"
|
|
90
|
+
"TEST_VERDICT: passed\n"
|
|
91
|
+
"TEST_REASON:\n"
|
|
92
|
+
"<none, or brief notes>\n"
|
|
93
|
+
"END_TEST\n\n"
|
|
94
|
+
"Or for a failure:\n\n"
|
|
95
|
+
"TEST_VERDICT: failed\n"
|
|
96
|
+
"TEST_REASON:\n"
|
|
97
|
+
"<the COMPLETE failure brief: what was checked, expected vs actual, "
|
|
98
|
+
"the repro steps, and the suspected files/area — not just one "
|
|
99
|
+
"line; this reason IS what the fix worker is briefed with, so make "
|
|
100
|
+
"it self-contained>\n"
|
|
101
|
+
"END_TEST\n\n"
|
|
102
|
+
"This printed block is the PATH-independent fallback recovered from "
|
|
103
|
+
"your session transcript even when step a never ran or failed — it "
|
|
104
|
+
"is REQUIRED every time, not just when `coord test` is unavailable. "
|
|
105
|
+
"`END_TEST` is a HARD REQUIREMENT: the coordinator only records a "
|
|
106
|
+
"verdict recovered this way when it sees that exact line. The LAST "
|
|
107
|
+
"LINE of your LAST MESSAGE must be exactly `END_TEST` on its own "
|
|
108
|
+
"line, with nothing after it.\n"
|
|
109
|
+
f" Then run `coord report-result --assignment {assignment_id} "
|
|
110
|
+
"--status done --summary <one-line summary>` so this session's row "
|
|
111
|
+
"closes.\n\n"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _dispatch_review_of(
|
|
116
|
+
*,
|
|
117
|
+
machine: str,
|
|
118
|
+
repo: str,
|
|
119
|
+
issue: int,
|
|
120
|
+
briefing: str,
|
|
121
|
+
model: str | None,
|
|
122
|
+
dry_run: bool,
|
|
123
|
+
review_of: str,
|
|
124
|
+
cfg: Config,
|
|
125
|
+
machine_obj: object,
|
|
126
|
+
repo_cfg: object,
|
|
127
|
+
issue_data: dict,
|
|
128
|
+
issue_title: str,
|
|
129
|
+
provider: object,
|
|
130
|
+
_is_local: bool,
|
|
131
|
+
_svc: object,
|
|
132
|
+
_interactive_board: object,
|
|
133
|
+
_issue_ctx: str,
|
|
134
|
+
) -> None:
|
|
135
|
+
import time as _time # noqa: PLC0415
|
|
136
|
+
import uuid as _uuid # noqa: PLC0415
|
|
137
|
+
|
|
138
|
+
from coord.interactive import ( # noqa: PLC0415
|
|
139
|
+
TmuxHost,
|
|
140
|
+
_launch_via_tmux as _tmux_launch,
|
|
141
|
+
finalize_interactive_exit,
|
|
142
|
+
launch_human_attended_interactive,
|
|
143
|
+
tmux_available as _tmux_avail,
|
|
144
|
+
tmux_session_alive as _tmux_alive,
|
|
145
|
+
tmux_session_name as _tmux_name,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
from coord.review import ( # noqa: PLC0415
|
|
149
|
+
REVIEWER_SYSTEM_PROMPT,
|
|
150
|
+
_read_repo_claude_md,
|
|
151
|
+
build_review_briefing,
|
|
152
|
+
)
|
|
153
|
+
from coord.models import Assignment # noqa: PLC0415
|
|
154
|
+
from coord.state import ( # noqa: PLC0415
|
|
155
|
+
build_board as _build_board_rv,
|
|
156
|
+
record_dispatched_assignment,
|
|
157
|
+
save_board as _save_board_rv,
|
|
158
|
+
)
|
|
159
|
+
from coord.agent import AssignmentSpec as _AssignmentSpecRv # noqa: PLC0415
|
|
160
|
+
|
|
161
|
+
_rv_board = _interactive_board(_build_board_rv)
|
|
162
|
+
work = _rv_board.find_by_id(review_of)
|
|
163
|
+
if work is None:
|
|
164
|
+
click.echo(
|
|
165
|
+
f"error: --review-of {review_of}: no such assignment on the "
|
|
166
|
+
"board (use the work id from `coord status`).",
|
|
167
|
+
err=True,
|
|
168
|
+
)
|
|
169
|
+
sys.exit(2)
|
|
170
|
+
if not work.branch:
|
|
171
|
+
click.echo(
|
|
172
|
+
f"error: work assignment {review_of} has no branch to review.",
|
|
173
|
+
err=True,
|
|
174
|
+
)
|
|
175
|
+
sys.exit(2)
|
|
176
|
+
|
|
177
|
+
# Track B / #486: the review runs either on the LOCAL TTY (in the
|
|
178
|
+
# live checkout) or on a REMOTE machine over ssh+tmux. A review is
|
|
179
|
+
# read-only either way (no worktree, no branch mutation), so the
|
|
180
|
+
# remote path is the lowest-risk Track-B leg.
|
|
181
|
+
if _is_local:
|
|
182
|
+
# Expand `~` — the path is handed straight to a local child cwd.
|
|
183
|
+
review_repo_path = str(
|
|
184
|
+
Path(machine_obj.repo_path(repo) or str(Path.cwd())).expanduser()
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
# Keep the raw path so the remote shell expands `~`/$HOME itself.
|
|
188
|
+
review_repo_path = machine_obj.repo_path(repo) or f"~/src/{repo}"
|
|
189
|
+
review_default_branch = repo_cfg.default_branch or "main"
|
|
190
|
+
resolved_model = model if model else cfg.models.default
|
|
191
|
+
assignment_id = _uuid.uuid4().hex[:12]
|
|
192
|
+
|
|
193
|
+
# CLAUDE.md: local → read the live checkout; remote → leave empty and
|
|
194
|
+
# have the reviewer read ./CLAUDE.md in the remote checkout it sits
|
|
195
|
+
# in (its actual rules, not the coordinator's possibly-divergent copy).
|
|
196
|
+
claude_md = (
|
|
197
|
+
_read_repo_claude_md(Path(review_repo_path)) if _is_local else ""
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# #612: embed the merge-base (three-dot) diff so the reviewer has
|
|
201
|
+
# nothing to compute — a stale-base diff would show already-merged
|
|
202
|
+
# commits as spurious deletions (#546). Prefer the PR diff via gh
|
|
203
|
+
# when a PR exists; otherwise (the #546 no-PR case) compute the
|
|
204
|
+
# local three-dot diff in the live checkout. Remote / on-failure
|
|
205
|
+
# → diff_text=None and the briefing keeps its fallback instructions.
|
|
206
|
+
review_diff_text: str | None = None
|
|
207
|
+
try:
|
|
208
|
+
_existing_pr = github_ops.find_pr_for_branch(
|
|
209
|
+
repo_cfg.github, work.branch
|
|
210
|
+
)
|
|
211
|
+
except RuntimeError:
|
|
212
|
+
_existing_pr = None
|
|
213
|
+
if _existing_pr is not None:
|
|
214
|
+
review_diff_text = github_ops.pr_diff(
|
|
215
|
+
repo_cfg.github, _existing_pr["number"]
|
|
216
|
+
)
|
|
217
|
+
elif _is_local:
|
|
218
|
+
# No PR: compute the branch's own changes locally with a
|
|
219
|
+
# three-dot (merge-base) diff against origin/<default_branch>.
|
|
220
|
+
try:
|
|
221
|
+
subprocess.run(
|
|
222
|
+
["git", "-C", review_repo_path, "fetch", "origin"],
|
|
223
|
+
capture_output=True, text=True, timeout=60,
|
|
224
|
+
)
|
|
225
|
+
_diff_proc = subprocess.run(
|
|
226
|
+
[
|
|
227
|
+
"git", "-C", review_repo_path, "diff",
|
|
228
|
+
f"origin/{review_default_branch}...origin/{work.branch}",
|
|
229
|
+
],
|
|
230
|
+
capture_output=True, text=True, timeout=60,
|
|
231
|
+
)
|
|
232
|
+
if _diff_proc.returncode == 0 and _diff_proc.stdout.strip():
|
|
233
|
+
review_diff_text = _diff_proc.stdout
|
|
234
|
+
if len(review_diff_text) > 60000:
|
|
235
|
+
review_diff_text = (
|
|
236
|
+
review_diff_text[:60000]
|
|
237
|
+
+ "\n... [diff truncated at 60000 chars] ..."
|
|
238
|
+
)
|
|
239
|
+
except (subprocess.SubprocessError, OSError):
|
|
240
|
+
review_diff_text = None
|
|
241
|
+
|
|
242
|
+
review_briefing = build_review_briefing(
|
|
243
|
+
pr_number=None,
|
|
244
|
+
pr_url=None,
|
|
245
|
+
repo_github=repo_cfg.github,
|
|
246
|
+
repo_name=repo,
|
|
247
|
+
issue_number=issue,
|
|
248
|
+
issue_title=issue_title,
|
|
249
|
+
issue_body=issue_data.get("body", ""),
|
|
250
|
+
branch=work.branch,
|
|
251
|
+
worker_machine=work.machine_name or machine,
|
|
252
|
+
same_as_worker=False,
|
|
253
|
+
reviews_cfg=cfg.reviews,
|
|
254
|
+
repo_claude_md=claude_md,
|
|
255
|
+
default_branch=review_default_branch,
|
|
256
|
+
review_iteration=getattr(work, "review_iteration", 0) or 0,
|
|
257
|
+
diff_text=review_diff_text,
|
|
258
|
+
)
|
|
259
|
+
# #651 (reversed by #1457): #651 told the reviewer to report via
|
|
260
|
+
# report-result "instead of" the REVIEW_VERDICT block the briefing's
|
|
261
|
+
# tail (below) describes — two conflicting instructions for the same
|
|
262
|
+
# handoff — and framed the printed block as PRIMARY with
|
|
263
|
+
# `coord report-result` as an OPTIONAL fast path never actually asked
|
|
264
|
+
# for. That left the "preferred self-report path" (the #606 PATH-fix's
|
|
265
|
+
# own words, coord/interactive.py:_with_coord_on_path) unreachable
|
|
266
|
+
# without a human asking for it every session: the operator always had
|
|
267
|
+
# to prompt the agent to run report-result before exiting. #1457: BOTH
|
|
268
|
+
# are now REQUIRED, belt and braces — `coord report-result` FIRST as
|
|
269
|
+
# the authoritative write straight to the coordinator's board (its
|
|
270
|
+
# PATH is fixed for exactly this by #606, its assignment id is $COORD_
|
|
271
|
+
# ASSIGNMENT_ID exported below, and #590 makes it work from a remote
|
|
272
|
+
# session too), and the REVIEW_VERDICT block ALWAYS ALSO, unconditionally,
|
|
273
|
+
# as the PATH-independent backup the #606 transcript-floor recovery scans
|
|
274
|
+
# for when report-result never ran or failed. Neither step substitutes
|
|
275
|
+
# for the other, and the exit finalize's `already_recorded` check
|
|
276
|
+
# (coord/interactive.py:_assignment_already_recorded) means a successful
|
|
277
|
+
# report-result is never clobbered by the backstop.
|
|
278
|
+
_remote_note = (
|
|
279
|
+
""
|
|
280
|
+
if _is_local
|
|
281
|
+
else (
|
|
282
|
+
"running on a REMOTE machine. You are in the live checkout — "
|
|
283
|
+
"read ./CLAUDE.md (and any sub-repo CLAUDE.md) for the project "
|
|
284
|
+
"rules before reviewing. "
|
|
285
|
+
)
|
|
286
|
+
)
|
|
287
|
+
report_reminder = (
|
|
288
|
+
f"[Coordinator review assignment {assignment_id}] This is a "
|
|
289
|
+
f"HUMAN-ATTENDED interactive review {_remote_note}When you finish, "
|
|
290
|
+
"record your verdict TWICE — belt and braces, neither step "
|
|
291
|
+
"substitutes for the other:\n"
|
|
292
|
+
" 1. REQUIRED, PRIMARY — write your full findings (every blocking "
|
|
293
|
+
f"item, with file:line) to a file (e.g. /tmp/review-{assignment_id}.md) "
|
|
294
|
+
"and run, BEFORE you end your session:\n"
|
|
295
|
+
f" coord report-result --assignment {assignment_id} "
|
|
296
|
+
"--status done --verdict approve|request-changes "
|
|
297
|
+
f"--summary <one-line summary> --body-file /tmp/review-{assignment_id}.md\n"
|
|
298
|
+
" This writes your verdict straight to the coordinator's shared "
|
|
299
|
+
"board (#590) — the authoritative record, and it reaches the merge "
|
|
300
|
+
"gate the moment you run it. `coord` is on your PATH (the coordinator "
|
|
301
|
+
"prepends its venv bin for this) and your assignment id is also in "
|
|
302
|
+
"$COORD_ASSIGNMENT_ID. Check the command's output for a "
|
|
303
|
+
"confirmation; if it errors, say so plainly — then continue to "
|
|
304
|
+
"step 2 regardless.\n"
|
|
305
|
+
" 2. REQUIRED, BACKUP — ALWAYS ALSO, regardless of whether step 1 "
|
|
306
|
+
"ran or succeeded, end your session by outputting the "
|
|
307
|
+
"`REVIEW_VERDICT: / REVIEW_BODY: / END_REVIEW` block described at "
|
|
308
|
+
"the end of this briefing, with the SAME full findings in "
|
|
309
|
+
"REVIEW_BODY. This is the PATH-independent fallback — it is "
|
|
310
|
+
"recovered from your session transcript even when step 1 never ran. "
|
|
311
|
+
"It does NOT replace step 1; do both, every time.\n"
|
|
312
|
+
" The three marker lines are parsed by machine: emit each at "
|
|
313
|
+
"the start of its own line as literal plain text, with NO Markdown "
|
|
314
|
+
"decoration. `**REVIEW_VERDICT: request-changes**` is WRONG and is "
|
|
315
|
+
"silently dropped (#1346); `REVIEW_VERDICT: request-changes` is "
|
|
316
|
+
"right. The body between the markers may be Markdown.\n"
|
|
317
|
+
" Do NOT run any `gh` commands; the coordinator posts the verdict + "
|
|
318
|
+
"findings for you.\n\n"
|
|
319
|
+
)
|
|
320
|
+
effective_briefing = _issue_ctx + report_reminder + review_briefing
|
|
321
|
+
|
|
322
|
+
spec = _AssignmentSpecRv(
|
|
323
|
+
repo_name=repo,
|
|
324
|
+
repo_path=review_repo_path,
|
|
325
|
+
issue_number=issue,
|
|
326
|
+
issue_title=f"[review] {issue_title}",
|
|
327
|
+
briefing=effective_briefing,
|
|
328
|
+
model=resolved_model,
|
|
329
|
+
type="review",
|
|
330
|
+
provider="claude-pty",
|
|
331
|
+
)
|
|
332
|
+
# A review is READ-ONLY: use the reviewer system prompt (not the
|
|
333
|
+
# worker default build_command would otherwise apply for an
|
|
334
|
+
# unrecognised type) and drop Edit/Write from the tool set so the
|
|
335
|
+
# session can't mutate the live checkout. Bash stays for
|
|
336
|
+
# git fetch/diff/log; Read/Grep/Glob for inspecting the code.
|
|
337
|
+
argv = provider.build_command(
|
|
338
|
+
spec,
|
|
339
|
+
resolved_model=resolved_model,
|
|
340
|
+
system_prompt=REVIEWER_SYSTEM_PROMPT,
|
|
341
|
+
allowed_tools="Read,Bash,Grep,Glob",
|
|
342
|
+
)
|
|
343
|
+
# Remote: a bare "claude" is not on the SSH login PATH (#424/#425);
|
|
344
|
+
# swap argv[0] for the absolute path the remote shell can find.
|
|
345
|
+
if not _is_local:
|
|
346
|
+
argv = ["~/.local/bin/claude"] + list(argv)[1:]
|
|
347
|
+
|
|
348
|
+
_rv_location = (
|
|
349
|
+
"local TTY" if _is_local
|
|
350
|
+
else f"{machine_obj.host} (remote tmux)"
|
|
351
|
+
)
|
|
352
|
+
click.echo(
|
|
353
|
+
f"{machine} ({_rv_location}) → REVIEW of #{issue} "
|
|
354
|
+
f"on branch {work.branch}: {issue_title}"
|
|
355
|
+
)
|
|
356
|
+
click.echo(
|
|
357
|
+
" mode: HUMAN-ATTENDED interactive review "
|
|
358
|
+
"(migration A1 / Track B #486)"
|
|
359
|
+
)
|
|
360
|
+
click.echo(
|
|
361
|
+
f" assignment id: {assignment_id} (review_of={review_of})"
|
|
362
|
+
)
|
|
363
|
+
if _is_local:
|
|
364
|
+
click.echo(
|
|
365
|
+
f" cwd: {review_repo_path} (live checkout — read-only, "
|
|
366
|
+
"no worktree)"
|
|
367
|
+
)
|
|
368
|
+
else:
|
|
369
|
+
click.echo(
|
|
370
|
+
f" remote checkout: {review_repo_path} on "
|
|
371
|
+
f"{machine_obj.host} (read-only, no worktree)"
|
|
372
|
+
)
|
|
373
|
+
if dry_run:
|
|
374
|
+
click.echo(" (dry run — not launched)")
|
|
375
|
+
click.echo(f" would exec: {argv}")
|
|
376
|
+
return
|
|
377
|
+
|
|
378
|
+
review_assignment = Assignment(
|
|
379
|
+
machine_name=machine,
|
|
380
|
+
repo_name=repo,
|
|
381
|
+
issue_number=issue,
|
|
382
|
+
issue_title=f"[review] {issue_title}",
|
|
383
|
+
briefing=effective_briefing,
|
|
384
|
+
assignment_id=assignment_id,
|
|
385
|
+
status="running",
|
|
386
|
+
branch=work.branch,
|
|
387
|
+
dispatched_at=_time.time(),
|
|
388
|
+
type="review",
|
|
389
|
+
review_of_assignment_id=review_of,
|
|
390
|
+
review_target=work.branch,
|
|
391
|
+
model=resolved_model,
|
|
392
|
+
provider_name="claude-pty",
|
|
393
|
+
)
|
|
394
|
+
record_dispatched_assignment(
|
|
395
|
+
assignment=review_assignment, repo_github=repo_cfg.github
|
|
396
|
+
)
|
|
397
|
+
if _svc is None:
|
|
398
|
+
_save_board_rv(_build_board_rv())
|
|
399
|
+
os.environ["COORD_ASSIGNMENT_ID"] = assignment_id
|
|
400
|
+
|
|
401
|
+
started_at = _time.time()
|
|
402
|
+
if _is_local:
|
|
403
|
+
exit_code = launch_human_attended_interactive(
|
|
404
|
+
argv,
|
|
405
|
+
effective_briefing,
|
|
406
|
+
assignment_id=assignment_id,
|
|
407
|
+
cwd=review_repo_path,
|
|
408
|
+
)
|
|
409
|
+
if exit_code != 0:
|
|
410
|
+
click.echo(
|
|
411
|
+
f" claude exited with status {exit_code}", err=True
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
_sname = _tmux_name(assignment_id) if _tmux_avail() else None
|
|
415
|
+
if _sname and _tmux_alive(_sname):
|
|
416
|
+
click.echo(
|
|
417
|
+
f" session still running in tmux: {_sname}\n"
|
|
418
|
+
f" reattach with: coord reattach {assignment_id}"
|
|
419
|
+
)
|
|
420
|
+
sys.exit(0)
|
|
421
|
+
|
|
422
|
+
# finalize with worktree_path=None — the backstop must never push
|
|
423
|
+
# or remove the live checkout. If the reviewer ran
|
|
424
|
+
# `coord report-result --verdict`, finalize sees the terminal row
|
|
425
|
+
# and leaves the verdict untouched.
|
|
426
|
+
try:
|
|
427
|
+
finalize_result = finalize_interactive_exit(
|
|
428
|
+
assignment_id=assignment_id,
|
|
429
|
+
repo_name=repo,
|
|
430
|
+
repo_github=repo_cfg.github,
|
|
431
|
+
issue_number=issue,
|
|
432
|
+
machine_name=machine,
|
|
433
|
+
worktree_path=None,
|
|
434
|
+
base_branch=review_default_branch,
|
|
435
|
+
exit_code=exit_code,
|
|
436
|
+
started_at=started_at,
|
|
437
|
+
log_path=None,
|
|
438
|
+
repo_path=None,
|
|
439
|
+
)
|
|
440
|
+
if finalize_result.already_recorded:
|
|
441
|
+
click.echo(" verdict recorded via `coord report-result`")
|
|
442
|
+
else:
|
|
443
|
+
# The reviewer exited without running `coord
|
|
444
|
+
# report-result`. Mirror the remote path (#486d): prompt
|
|
445
|
+
# the operator here (this is a TTY) and relay the verdict
|
|
446
|
+
# through the same issue_store seam, so the merge gate /
|
|
447
|
+
# Fix routing sees it instead of silently stalling on a
|
|
448
|
+
# missing verdict.
|
|
449
|
+
_verdict_cmd = (
|
|
450
|
+
f" coord report-result --assignment {assignment_id} "
|
|
451
|
+
"--status done --verdict approve|request-changes "
|
|
452
|
+
"--summary <one-line summary>"
|
|
453
|
+
)
|
|
454
|
+
if not _prompt_and_relay_review_verdict(
|
|
455
|
+
assignment_id=assignment_id,
|
|
456
|
+
repo_name=repo,
|
|
457
|
+
repo_github=repo_cfg.github,
|
|
458
|
+
issue_number=issue,
|
|
459
|
+
machine_name=machine,
|
|
460
|
+
verdict_cmd_hint=_verdict_cmd,
|
|
461
|
+
started_at=started_at,
|
|
462
|
+
# Local review — session ran on THIS machine; no ssh needed.
|
|
463
|
+
# The transcript-floor already scanned the local projects dir
|
|
464
|
+
# in finalize_interactive_exit; ssh_target=None skips the
|
|
465
|
+
# remote SSH path while still checking the board (#877).
|
|
466
|
+
ssh_target=None,
|
|
467
|
+
):
|
|
468
|
+
click.echo(
|
|
469
|
+
" review session ended with no verdict reported "
|
|
470
|
+
f"(status={finalize_result.terminal_status}) — the "
|
|
471
|
+
"merge gate stays blocked until a verdict is "
|
|
472
|
+
"reported."
|
|
473
|
+
)
|
|
474
|
+
except Exception as exc: # noqa: BLE001 — best-effort backstop
|
|
475
|
+
click.echo(
|
|
476
|
+
f" warning: backstop failed to record review exit: {exc}",
|
|
477
|
+
err=True,
|
|
478
|
+
)
|
|
479
|
+
return
|
|
480
|
+
|
|
481
|
+
# ── REMOTE REVIEW (Track B / #486) ────────────────────────────
|
|
482
|
+
# Read-only: cd into the remote LIVE checkout, fetch+prune so the
|
|
483
|
+
# reviewer can diff origin/<branch>, then launch the reviewer. NO
|
|
484
|
+
# worktree and NO branch mutation — the live checkout is the worker
|
|
485
|
+
# worktree base and must not be disturbed. Verdict comes back via
|
|
486
|
+
# the operator running `coord report-result` on THIS coordinator
|
|
487
|
+
# (the assignment row lives here); a remote report-result would
|
|
488
|
+
# write the remote DB and never reach the merge gate (#486d).
|
|
489
|
+
import shlex as _shlex_rv # noqa: PLC0415
|
|
490
|
+
|
|
491
|
+
_rp_sh = (
|
|
492
|
+
"$HOME/" + review_repo_path[2:]
|
|
493
|
+
if review_repo_path.startswith("~/")
|
|
494
|
+
else ("$HOME" if review_repo_path == "~" else review_repo_path)
|
|
495
|
+
)
|
|
496
|
+
_claude_args = _shlex_rv.join(list(argv)[1:])
|
|
497
|
+
_remote_cmd = (
|
|
498
|
+
f"cd {_rp_sh}"
|
|
499
|
+
f" && git fetch origin --prune 2>/dev/null || true"
|
|
500
|
+
f" && COORD_ASSIGNMENT_ID={assignment_id} {argv[0]} {_claude_args}"
|
|
501
|
+
)
|
|
502
|
+
_tmux_host = TmuxHost(ssh_target=machine_obj.host)
|
|
503
|
+
_sname = _tmux_name(assignment_id)
|
|
504
|
+
|
|
505
|
+
# Echo the briefing to the LOCAL terminal before attaching, so the
|
|
506
|
+
# operator can read it before pressing Enter (mirrors the remote
|
|
507
|
+
# work path).
|
|
508
|
+
if effective_briefing.strip():
|
|
509
|
+
_hdr = (
|
|
510
|
+
"--- seeded briefing -- review below; "
|
|
511
|
+
"submit the pre-filled input in Claude to send ---"
|
|
512
|
+
)
|
|
513
|
+
_ftr = "-" * len(_hdr)
|
|
514
|
+
_preview = f"\n{_hdr}\n{effective_briefing.rstrip()}\n{_ftr}\n\n"
|
|
515
|
+
try:
|
|
516
|
+
os.write(sys.stdout.fileno(), _preview.encode("utf-8"))
|
|
517
|
+
except OSError:
|
|
518
|
+
pass
|
|
519
|
+
|
|
520
|
+
_rc = _tmux_launch(
|
|
521
|
+
argv,
|
|
522
|
+
effective_briefing,
|
|
523
|
+
_sname,
|
|
524
|
+
cwd=None,
|
|
525
|
+
host=_tmux_host,
|
|
526
|
+
raw_shell_cmd=_remote_cmd,
|
|
527
|
+
)
|
|
528
|
+
if _rc is None:
|
|
529
|
+
click.echo(
|
|
530
|
+
" error: could not create remote tmux session on "
|
|
531
|
+
f"{machine_obj.host}",
|
|
532
|
+
err=True,
|
|
533
|
+
)
|
|
534
|
+
sys.exit(1)
|
|
535
|
+
exit_code = _rc
|
|
536
|
+
if exit_code != 0:
|
|
537
|
+
click.echo(f" claude exited with status {exit_code}", err=True)
|
|
538
|
+
|
|
539
|
+
_still_alive = _tmux_alive(_sname, host=_tmux_host)
|
|
540
|
+
# Verdict-out (#486d): the verdict is recorded on THIS coordinator,
|
|
541
|
+
# where the assignment row lives and the merge gate reads
|
|
542
|
+
# `review_verdict` — never on the remote machine's DB.
|
|
543
|
+
_verdict_cmd = (
|
|
544
|
+
f" coord report-result --assignment {assignment_id} "
|
|
545
|
+
"--status done --verdict approve|request-changes "
|
|
546
|
+
"--summary <one-line summary>"
|
|
547
|
+
)
|
|
548
|
+
if _still_alive:
|
|
549
|
+
click.echo(
|
|
550
|
+
f" session still running in remote tmux: {_sname}\n"
|
|
551
|
+
f" reattach with: ssh -t {machine_obj.host}"
|
|
552
|
+
f" tmux attach-session -t {_sname}"
|
|
553
|
+
)
|
|
554
|
+
click.echo(
|
|
555
|
+
" to record the verdict (the merge gate keys on it), run ON "
|
|
556
|
+
f"THIS coordinator:\n{_verdict_cmd}"
|
|
557
|
+
)
|
|
558
|
+
sys.exit(0)
|
|
559
|
+
|
|
560
|
+
# Session ended. Record a terminal state so the review row does NOT
|
|
561
|
+
# linger as a phantom 'running' worker that holds the issue claim
|
|
562
|
+
# forever — the bug this path used to have (it printed the verdict
|
|
563
|
+
# reminder and exited, never going terminal). A review is
|
|
564
|
+
# read-only, so finalize with worktree_path=None / repo_path=None:
|
|
565
|
+
# the backstop only writes the coordinator DB (no push, no worktree
|
|
566
|
+
# touch), identical to the local review path above. An operator
|
|
567
|
+
# `coord report-result` is respected (already_recorded → no clobber).
|
|
568
|
+
try:
|
|
569
|
+
finalize_result = finalize_interactive_exit(
|
|
570
|
+
assignment_id=assignment_id,
|
|
571
|
+
repo_name=repo,
|
|
572
|
+
repo_github=repo_cfg.github,
|
|
573
|
+
issue_number=issue,
|
|
574
|
+
machine_name=machine,
|
|
575
|
+
worktree_path=None,
|
|
576
|
+
base_branch=review_default_branch,
|
|
577
|
+
exit_code=exit_code,
|
|
578
|
+
started_at=started_at,
|
|
579
|
+
log_path=None,
|
|
580
|
+
repo_path=None,
|
|
581
|
+
# #617: this review ran on the REMOTE host, so its Claude
|
|
582
|
+
# session transcript lives THERE — hand the transcript-floor
|
|
583
|
+
# the ssh target so it recovers the verdict + findings from
|
|
584
|
+
# the session's OWN host instead of scanning this (blind)
|
|
585
|
+
# coordinator's `~/.claude/projects`. Without it the #606
|
|
586
|
+
# recovery always misses for a remote review and the exit
|
|
587
|
+
# falls straight to the operator prompt (the #607 silent
|
|
588
|
+
# drop). Mirrors the `coord reattach` remote-review path.
|
|
589
|
+
ssh_target=machine_obj.host,
|
|
590
|
+
)
|
|
591
|
+
if finalize_result.already_recorded:
|
|
592
|
+
click.echo(" verdict recorded via `coord report-result`")
|
|
593
|
+
else:
|
|
594
|
+
# #486d: don't leave the verdict as a manual step — prompt the
|
|
595
|
+
# operator here (on the coordinator, where the row lives) and
|
|
596
|
+
# relay it, so the merge gate / leg-3 Fix routing sees it.
|
|
597
|
+
_prompt_and_relay_review_verdict(
|
|
598
|
+
assignment_id=assignment_id,
|
|
599
|
+
repo_name=repo,
|
|
600
|
+
repo_github=repo_cfg.github,
|
|
601
|
+
issue_number=issue,
|
|
602
|
+
machine_name=machine,
|
|
603
|
+
verdict_cmd_hint=_verdict_cmd,
|
|
604
|
+
started_at=started_at,
|
|
605
|
+
# #877: remote review — session ran on machine_obj.host, so the
|
|
606
|
+
# transcript lives THERE. Pass the ssh_target so the board-
|
|
607
|
+
# content gate can re-run the remote transcript-floor here
|
|
608
|
+
# (a 2nd attempt; the first ran in finalize_interactive_exit via
|
|
609
|
+
# its own ssh_target). Catches any timing window where the
|
|
610
|
+
# JSONL wasn't fully flushed when finalize ran.
|
|
611
|
+
ssh_target=machine_obj.host,
|
|
612
|
+
)
|
|
613
|
+
except Exception as exc: # noqa: BLE001 — best-effort backstop
|
|
614
|
+
click.echo(
|
|
615
|
+
f" warning: backstop failed to record review exit: {exc}",
|
|
616
|
+
err=True,
|
|
617
|
+
)
|
|
618
|
+
sys.exit(exit_code)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _dispatch_smoke_of(
|
|
622
|
+
*,
|
|
623
|
+
machine: str,
|
|
624
|
+
repo: str,
|
|
625
|
+
issue: int,
|
|
626
|
+
briefing: str,
|
|
627
|
+
model: str | None,
|
|
628
|
+
dry_run: bool,
|
|
629
|
+
smoke_of: str,
|
|
630
|
+
cfg: Config,
|
|
631
|
+
machine_obj: object,
|
|
632
|
+
repo_cfg: object,
|
|
633
|
+
issue_data: dict,
|
|
634
|
+
issue_title: str,
|
|
635
|
+
provider: object,
|
|
636
|
+
_is_local: bool,
|
|
637
|
+
_svc: object,
|
|
638
|
+
_interactive_board: object,
|
|
639
|
+
_issue_ctx: str,
|
|
640
|
+
) -> None:
|
|
641
|
+
import time as _time # noqa: PLC0415
|
|
642
|
+
import uuid as _uuid # noqa: PLC0415
|
|
643
|
+
|
|
644
|
+
from coord.interactive import ( # noqa: PLC0415
|
|
645
|
+
TmuxHost,
|
|
646
|
+
_launch_via_tmux as _tmux_launch,
|
|
647
|
+
finalize_interactive_exit,
|
|
648
|
+
launch_human_attended_interactive,
|
|
649
|
+
snapshot_live_checkout_for_smoke,
|
|
650
|
+
tmux_available as _tmux_avail,
|
|
651
|
+
tmux_session_alive as _tmux_alive,
|
|
652
|
+
tmux_session_name as _tmux_name,
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
from coord.models import Assignment as _AssignmentSm # noqa: PLC0415
|
|
656
|
+
from coord.state import ( # noqa: PLC0415
|
|
657
|
+
build_board as _build_board_sm,
|
|
658
|
+
get_test_plan as _get_test_plan_sm,
|
|
659
|
+
record_dispatched_assignment as _record_sm,
|
|
660
|
+
save_board as _save_board_sm,
|
|
661
|
+
)
|
|
662
|
+
from coord.agent import AssignmentSpec as _AssignmentSpecSm # noqa: PLC0415
|
|
663
|
+
|
|
664
|
+
_sm_board = _interactive_board(_build_board_sm)
|
|
665
|
+
work = _sm_board.find_by_id(smoke_of)
|
|
666
|
+
if work is None:
|
|
667
|
+
click.echo(
|
|
668
|
+
f"error: --smoke-of {smoke_of}: no such assignment on the "
|
|
669
|
+
"board (use the work id from `coord status`).",
|
|
670
|
+
err=True,
|
|
671
|
+
)
|
|
672
|
+
sys.exit(2)
|
|
673
|
+
if not work.branch:
|
|
674
|
+
click.echo(
|
|
675
|
+
f"error: work assignment {smoke_of} has no branch to test.",
|
|
676
|
+
err=True,
|
|
677
|
+
)
|
|
678
|
+
sys.exit(2)
|
|
679
|
+
|
|
680
|
+
# #1010 (mirrors --review-of / Track B #486): smoke is READ-ONLY (no
|
|
681
|
+
# worktree, no branch mutation) either way, so the remote path is the
|
|
682
|
+
# same low-risk shape as the review path — ssh+tmux into the live
|
|
683
|
+
# checkout, no worktree machinery needed.
|
|
684
|
+
if _is_local:
|
|
685
|
+
smoke_repo_path = str(
|
|
686
|
+
Path(machine_obj.repo_path(repo) or str(Path.cwd())).expanduser()
|
|
687
|
+
)
|
|
688
|
+
else:
|
|
689
|
+
# Keep the raw path so the remote shell expands `~`/$HOME itself.
|
|
690
|
+
smoke_repo_path = machine_obj.repo_path(repo) or f"~/src/{repo}"
|
|
691
|
+
smoke_default_branch = repo_cfg.default_branch or "main"
|
|
692
|
+
resolved_model = model if model else cfg.models.default
|
|
693
|
+
assignment_id = _uuid.uuid4().hex[:12]
|
|
694
|
+
|
|
695
|
+
# Surface the cached smoke-test plan (#342) when one exists so the
|
|
696
|
+
# agent can lead with the concrete steps instead of re-deriving them.
|
|
697
|
+
try:
|
|
698
|
+
_plan = _get_test_plan_sm(smoke_of)
|
|
699
|
+
except Exception: # noqa: BLE001
|
|
700
|
+
_plan = None
|
|
701
|
+
if _plan and isinstance(_plan, dict) and _plan.get("steps"):
|
|
702
|
+
import json as _json_sm # noqa: PLC0415
|
|
703
|
+
_plan_block = (
|
|
704
|
+
"A cached smoke-test plan exists for this branch:\n\n"
|
|
705
|
+
"```json\n" + _json_sm.dumps(_plan, indent=2) + "\n```\n"
|
|
706
|
+
)
|
|
707
|
+
else:
|
|
708
|
+
_plan_block = (
|
|
709
|
+
"No cached smoke-test plan was found. Run "
|
|
710
|
+
f"`coord test-plan {smoke_of}` to generate one (it reads the "
|
|
711
|
+
"PR diff, the repo's CLAUDE.md and the artifact manifest), "
|
|
712
|
+
"then read it back to the operator.\n"
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
INTERACTIVE_SMOKE_SYSTEM_PROMPT = (
|
|
716
|
+
"You are a human-attended smoke-test guide dispatched by the "
|
|
717
|
+
"coordinator. A human operator is at the keyboard with you. Your "
|
|
718
|
+
"job is to walk them through validating a completed branch and "
|
|
719
|
+
"then record their verdict.\n\n"
|
|
720
|
+
"Rules:\n"
|
|
721
|
+
"- Do NOT modify code, push commits, or open/merge PRs. You only "
|
|
722
|
+
"help validate. (Edit/Write are not available to you.)\n"
|
|
723
|
+
"- Do NOT run `gh` commands. The coordinator owns GitHub.\n"
|
|
724
|
+
"- You MAY run git (read-only), build/run commands, and the "
|
|
725
|
+
"`coord pull-artifact` / `coord test-plan` / `coord test` "
|
|
726
|
+
"commands.\n"
|
|
727
|
+
"- Keep it conversational: propose ONE concrete next command at a "
|
|
728
|
+
"time, wait for the operator to run it (or run it yourself when "
|
|
729
|
+
"it's safe and read-only) and tell you what they saw.\n\n"
|
|
730
|
+
"Flow:\n"
|
|
731
|
+
"1. Read the smoke-test plan (below, or generate one). List the "
|
|
732
|
+
"checks for the operator.\n"
|
|
733
|
+
"2. Offer to pull the prebuilt artifact for this branch with "
|
|
734
|
+
"`coord pull-artifact <work_aid>` so they don't have to rebuild.\n"
|
|
735
|
+
"3. Walk through each check. Ask what they observed. If something "
|
|
736
|
+
"is wrong, interview them for a clear repro (expected vs actual, "
|
|
737
|
+
"suspected area/files) — this becomes the fix brief.\n"
|
|
738
|
+
"4. When every check has a clear position, record the verdict:\n"
|
|
739
|
+
" - All good → run `coord test --passed <work_aid>`\n"
|
|
740
|
+
" - Broken → run `coord test --fail <work_aid> --reason "
|
|
741
|
+
"\"<story>\"` where <story> is the COMPLETE failure brief the fix "
|
|
742
|
+
"worker needs: what was checked, expected vs actual, the repro "
|
|
743
|
+
"steps, and the suspected files/area — not just one line. This "
|
|
744
|
+
"reason IS what the fix worker is briefed with, so make it "
|
|
745
|
+
"self-contained.\n"
|
|
746
|
+
" Then ALSO print the TEST_VERDICT:/TEST_REASON:/END_TEST backup "
|
|
747
|
+
"block described in your first message, even after `coord test` "
|
|
748
|
+
"succeeds — it is the PATH-independent fallback recovered from this "
|
|
749
|
+
"session's transcript if `coord test` didn't actually land.\n"
|
|
750
|
+
" Then tell the operator exactly what happens next (the TUI "
|
|
751
|
+
"will offer the fix or merge step).\n"
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
smoke_briefing = (
|
|
755
|
+
f"# Smoke-test assignment: {repo_cfg.github} #{issue}\n\n"
|
|
756
|
+
f"**Issue:** {issue_title}\n"
|
|
757
|
+
f"**Branch under test:** `{work.branch}` "
|
|
758
|
+
f"(worker: {work.machine_name or machine})\n"
|
|
759
|
+
f"**Work assignment id (use this for `coord test` / "
|
|
760
|
+
f"`coord pull-artifact`):** `{smoke_of}`\n"
|
|
761
|
+
f"**Repo checkout:** {smoke_repo_path}\n"
|
|
762
|
+
f"**Default branch:** {smoke_default_branch}\n\n"
|
|
763
|
+
"## ⚠ Do NOT move this checkout's branch (#601)\n\n"
|
|
764
|
+
f"`{smoke_repo_path}` is the **live checkout that runs the "
|
|
765
|
+
"coordinator itself** (and the worktree base for workers). Do "
|
|
766
|
+
"**NOT** `git checkout` / `git switch` / `git reset` / "
|
|
767
|
+
"`git stash` it — checking out the branch here silently "
|
|
768
|
+
"downgrades the running `coord` to this branch's code until it's "
|
|
769
|
+
"restored. To inspect the branch under test WITHOUT moving it:\n"
|
|
770
|
+
f" - `git -C {smoke_repo_path} fetch origin && "
|
|
771
|
+
f"git -C {smoke_repo_path} diff {smoke_default_branch}...origin/{work.branch}`\n"
|
|
772
|
+
f" - `git -C {smoke_repo_path} show origin/{work.branch}:<path>` for a single file\n"
|
|
773
|
+
f" - or make your OWN scratch worktree: "
|
|
774
|
+
f"`git -C {smoke_repo_path} worktree add /tmp/smoke-{smoke_of} origin/{work.branch}` "
|
|
775
|
+
f"(remove it with `git -C {smoke_repo_path} worktree remove /tmp/smoke-{smoke_of}` when done)\n"
|
|
776
|
+
" - prefer `coord pull-artifact` (above) for the prebuilt binary.\n\n"
|
|
777
|
+
f"## Issue body\n\n{issue_data.get('body', '') or '(none)'}\n\n"
|
|
778
|
+
f"## Smoke-test plan\n\n{_plan_block}\n"
|
|
779
|
+
"## Your job\n\n"
|
|
780
|
+
"Guide the operator through validating this branch (see the "
|
|
781
|
+
"system prompt for the flow), then record the verdict with "
|
|
782
|
+
f"`coord test --passed {smoke_of}` or `coord test --fail "
|
|
783
|
+
f"{smoke_of} --reason \"...\"`.\n"
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
report_reminder = _smoke_report_reminder(
|
|
787
|
+
assignment_id=assignment_id, smoke_of=smoke_of
|
|
788
|
+
)
|
|
789
|
+
effective_briefing = _issue_ctx + report_reminder + smoke_briefing
|
|
790
|
+
|
|
791
|
+
spec = _AssignmentSpecSm(
|
|
792
|
+
repo_name=repo,
|
|
793
|
+
repo_path=smoke_repo_path,
|
|
794
|
+
issue_number=issue,
|
|
795
|
+
issue_title=f"[smoke] {issue_title}",
|
|
796
|
+
briefing=effective_briefing,
|
|
797
|
+
model=resolved_model,
|
|
798
|
+
type="smoke",
|
|
799
|
+
provider="claude-pty",
|
|
800
|
+
)
|
|
801
|
+
# READ-ONLY like --review-of: no Edit/Write — the smoke agent
|
|
802
|
+
# validates, it does not fix. Bash stays for build/run + the
|
|
803
|
+
# coord helper commands; Read/Grep/Glob for inspecting the code.
|
|
804
|
+
argv = provider.build_command(
|
|
805
|
+
spec,
|
|
806
|
+
resolved_model=resolved_model,
|
|
807
|
+
system_prompt=INTERACTIVE_SMOKE_SYSTEM_PROMPT,
|
|
808
|
+
allowed_tools="Read,Bash,Grep,Glob",
|
|
809
|
+
)
|
|
810
|
+
# Remote: a bare "claude" is not on the SSH login PATH (#424/#425).
|
|
811
|
+
if not _is_local:
|
|
812
|
+
argv = ["~/.local/bin/claude"] + list(argv)[1:]
|
|
813
|
+
|
|
814
|
+
_sm_location = (
|
|
815
|
+
"local TTY" if _is_local
|
|
816
|
+
else f"{machine_obj.host} (remote tmux)"
|
|
817
|
+
)
|
|
818
|
+
click.echo(
|
|
819
|
+
f"{machine} ({_sm_location}) → SMOKE TEST of #{issue} "
|
|
820
|
+
f"on branch {work.branch}: {issue_title}"
|
|
821
|
+
)
|
|
822
|
+
click.echo(
|
|
823
|
+
" mode: HUMAN-ATTENDED interactive smoke test (leg 3c / A3, #1010)"
|
|
824
|
+
)
|
|
825
|
+
click.echo(
|
|
826
|
+
f" assignment id: {assignment_id} (smoke_of={smoke_of})"
|
|
827
|
+
)
|
|
828
|
+
if _is_local:
|
|
829
|
+
click.echo(
|
|
830
|
+
f" cwd: {smoke_repo_path} (live checkout — read-only, "
|
|
831
|
+
"no worktree)"
|
|
832
|
+
)
|
|
833
|
+
else:
|
|
834
|
+
click.echo(
|
|
835
|
+
f" remote checkout: {smoke_repo_path} on "
|
|
836
|
+
f"{machine_obj.host} (read-only, no worktree)"
|
|
837
|
+
)
|
|
838
|
+
if dry_run:
|
|
839
|
+
click.echo(" (dry run — not launched)")
|
|
840
|
+
click.echo(f" would exec: {argv}")
|
|
841
|
+
return
|
|
842
|
+
|
|
843
|
+
smoke_assignment = _AssignmentSm(
|
|
844
|
+
machine_name=machine,
|
|
845
|
+
repo_name=repo,
|
|
846
|
+
issue_number=issue,
|
|
847
|
+
issue_title=f"[smoke] {issue_title}",
|
|
848
|
+
briefing=effective_briefing,
|
|
849
|
+
assignment_id=assignment_id,
|
|
850
|
+
status="running",
|
|
851
|
+
branch=work.branch,
|
|
852
|
+
dispatched_at=_time.time(),
|
|
853
|
+
type="smoke",
|
|
854
|
+
review_of_assignment_id=smoke_of,
|
|
855
|
+
review_target=work.branch,
|
|
856
|
+
model=resolved_model,
|
|
857
|
+
provider_name="claude-pty",
|
|
858
|
+
)
|
|
859
|
+
_record_sm(assignment=smoke_assignment, repo_github=repo_cfg.github)
|
|
860
|
+
if _svc is None:
|
|
861
|
+
_save_board_sm(_build_board_sm())
|
|
862
|
+
os.environ["COORD_ASSIGNMENT_ID"] = assignment_id
|
|
863
|
+
|
|
864
|
+
# #923: Test-verdict backstop hint — shared by both the local and
|
|
865
|
+
# remote paths below. The verdict itself lands on the shared board the
|
|
866
|
+
# moment `coord test` runs INSIDE the session (routes through the
|
|
867
|
+
# daemon, #590, same as `coord report-result`) — this hint/prompt only
|
|
868
|
+
# covers the case where the agent exited without running it.
|
|
869
|
+
_test_verdict_cmd = (
|
|
870
|
+
f" coord test --passed {smoke_of} # all good\n"
|
|
871
|
+
f" coord test --fail {smoke_of} --reason \"<story>\" # broken"
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
if _is_local:
|
|
875
|
+
# #1256: snapshot the live checkout's branch + dirty-path baseline
|
|
876
|
+
# BEFORE the agent can mutate it (e.g. a path-scoped
|
|
877
|
+
# `git checkout <branch> -- <path>` used to exercise an agent-side
|
|
878
|
+
# file that must actually sit in this editable-install checkout to
|
|
879
|
+
# take effect). finalize_interactive_exit's restore-on-exit safety
|
|
880
|
+
# net reverts anything new dirtied since this point.
|
|
881
|
+
snapshot_live_checkout_for_smoke(smoke_repo_path, assignment_id)
|
|
882
|
+
|
|
883
|
+
started_at = _time.time()
|
|
884
|
+
exit_code = launch_human_attended_interactive(
|
|
885
|
+
argv,
|
|
886
|
+
effective_briefing,
|
|
887
|
+
assignment_id=assignment_id,
|
|
888
|
+
cwd=smoke_repo_path,
|
|
889
|
+
)
|
|
890
|
+
if exit_code != 0:
|
|
891
|
+
click.echo(f" claude exited with status {exit_code}", err=True)
|
|
892
|
+
|
|
893
|
+
_sname = _tmux_name(assignment_id) if _tmux_avail() else None
|
|
894
|
+
if _sname and _tmux_alive(_sname):
|
|
895
|
+
click.echo(
|
|
896
|
+
f" session still running in tmux: {_sname}\n"
|
|
897
|
+
f" reattach with: coord reattach {assignment_id}"
|
|
898
|
+
)
|
|
899
|
+
sys.exit(0)
|
|
900
|
+
|
|
901
|
+
# worktree_path=None: read-only smoke runs in the live checkout, the
|
|
902
|
+
# backstop must never push or remove it. The verdict that matters
|
|
903
|
+
# is the `coord test` write on the WORK row, not this session's row.
|
|
904
|
+
# smoke_repo_path=... triggers the #1256 restore-on-exit safety net.
|
|
905
|
+
try:
|
|
906
|
+
_fr = finalize_interactive_exit(
|
|
907
|
+
assignment_id=assignment_id,
|
|
908
|
+
repo_name=repo,
|
|
909
|
+
repo_github=repo_cfg.github,
|
|
910
|
+
issue_number=issue,
|
|
911
|
+
machine_name=machine,
|
|
912
|
+
worktree_path=None,
|
|
913
|
+
base_branch=smoke_default_branch,
|
|
914
|
+
exit_code=exit_code,
|
|
915
|
+
started_at=started_at,
|
|
916
|
+
log_path=None,
|
|
917
|
+
repo_path=None,
|
|
918
|
+
smoke_repo_path=smoke_repo_path,
|
|
919
|
+
smoke_of=smoke_of,
|
|
920
|
+
)
|
|
921
|
+
if _fr.test_verdict_recovered:
|
|
922
|
+
click.echo(
|
|
923
|
+
f" test verdict {_fr.test_verdict_recovered!r} recovered "
|
|
924
|
+
"from the session transcript and recorded on the work "
|
|
925
|
+
f"assignment {smoke_of} (#1351)"
|
|
926
|
+
)
|
|
927
|
+
if _fr.smoke_restored_paths:
|
|
928
|
+
click.echo(
|
|
929
|
+
" live checkout restored (#1256): reverted "
|
|
930
|
+
+ ", ".join(_fr.smoke_restored_paths)
|
|
931
|
+
)
|
|
932
|
+
if _fr.smoke_restore_error:
|
|
933
|
+
click.echo(
|
|
934
|
+
f" warning: live-checkout restore failed: "
|
|
935
|
+
f"{_fr.smoke_restore_error}",
|
|
936
|
+
err=True,
|
|
937
|
+
)
|
|
938
|
+
except Exception as exc: # noqa: BLE001 — best-effort backstop
|
|
939
|
+
click.echo(
|
|
940
|
+
f" warning: backstop failed to record smoke exit: {exc}",
|
|
941
|
+
err=True,
|
|
942
|
+
)
|
|
943
|
+
|
|
944
|
+
try:
|
|
945
|
+
if not _prompt_and_relay_test_verdict(
|
|
946
|
+
work_assignment_id=smoke_of,
|
|
947
|
+
smoke_assignment_id=assignment_id,
|
|
948
|
+
repo_name=repo,
|
|
949
|
+
repo_github=repo_cfg.github,
|
|
950
|
+
issue_number=issue,
|
|
951
|
+
machine_name=machine,
|
|
952
|
+
verdict_cmd_hint=_test_verdict_cmd,
|
|
953
|
+
):
|
|
954
|
+
click.echo(
|
|
955
|
+
" smoke session ended with no test verdict recorded "
|
|
956
|
+
"— the merge gate stays blocked until a verdict is "
|
|
957
|
+
"reported."
|
|
958
|
+
)
|
|
959
|
+
except Exception as exc: # noqa: BLE001 — best-effort backstop
|
|
960
|
+
click.echo(
|
|
961
|
+
f" warning: test-verdict backstop failed: {exc}",
|
|
962
|
+
err=True,
|
|
963
|
+
)
|
|
964
|
+
return
|
|
965
|
+
|
|
966
|
+
# ── REMOTE SMOKE TEST (#1010) ──────────────────────────────────
|
|
967
|
+
# Read-only: cd into the remote LIVE checkout, fetch+prune so the
|
|
968
|
+
# smoke agent can inspect origin/<branch>, then launch it. NO
|
|
969
|
+
# worktree and NO branch mutation — the live checkout is the worker
|
|
970
|
+
# worktree base and must not be disturbed (same shape as the remote
|
|
971
|
+
# review path). The Test verdict is recorded on the shared board the
|
|
972
|
+
# moment `coord test --passed|--fail` runs INSIDE the remote session
|
|
973
|
+
# (routes through the daemon, #590) — no remote-specific plumbing
|
|
974
|
+
# needed for that part; only the SESSION row's terminal state (this
|
|
975
|
+
# smoke assignment, not the WORK row) needs a coordinator-side backstop.
|
|
976
|
+
import shlex as _shlex_sm # noqa: PLC0415
|
|
977
|
+
|
|
978
|
+
_rp_sh = (
|
|
979
|
+
"$HOME/" + smoke_repo_path[2:]
|
|
980
|
+
if smoke_repo_path.startswith("~/")
|
|
981
|
+
else ("$HOME" if smoke_repo_path == "~" else smoke_repo_path)
|
|
982
|
+
)
|
|
983
|
+
# #1256: snapshot the remote live checkout's branch + dirty-path
|
|
984
|
+
# baseline before launch — same rationale as the local branch above.
|
|
985
|
+
snapshot_live_checkout_for_smoke(
|
|
986
|
+
_rp_sh, assignment_id, ssh_target=machine_obj.host
|
|
987
|
+
)
|
|
988
|
+
_claude_args = _shlex_sm.join(list(argv)[1:])
|
|
989
|
+
_remote_cmd = (
|
|
990
|
+
f"cd {_rp_sh}"
|
|
991
|
+
f" && git fetch origin --prune 2>/dev/null || true"
|
|
992
|
+
f" && COORD_ASSIGNMENT_ID={assignment_id} {argv[0]} {_claude_args}"
|
|
993
|
+
)
|
|
994
|
+
_tmux_host = TmuxHost(ssh_target=machine_obj.host)
|
|
995
|
+
_sname = _tmux_name(assignment_id)
|
|
996
|
+
|
|
997
|
+
# Echo the briefing to the LOCAL terminal before attaching, so the
|
|
998
|
+
# operator can read it before pressing Enter (mirrors the remote
|
|
999
|
+
# review/work paths).
|
|
1000
|
+
if effective_briefing.strip():
|
|
1001
|
+
_hdr = (
|
|
1002
|
+
"--- seeded briefing -- review below; "
|
|
1003
|
+
"submit the pre-filled input in Claude to send ---"
|
|
1004
|
+
)
|
|
1005
|
+
_ftr = "-" * len(_hdr)
|
|
1006
|
+
_preview = f"\n{_hdr}\n{effective_briefing.rstrip()}\n{_ftr}\n\n"
|
|
1007
|
+
try:
|
|
1008
|
+
os.write(sys.stdout.fileno(), _preview.encode("utf-8"))
|
|
1009
|
+
except OSError:
|
|
1010
|
+
pass
|
|
1011
|
+
|
|
1012
|
+
started_at = _time.time()
|
|
1013
|
+
_rc = _tmux_launch(
|
|
1014
|
+
argv,
|
|
1015
|
+
effective_briefing,
|
|
1016
|
+
_sname,
|
|
1017
|
+
cwd=None,
|
|
1018
|
+
host=_tmux_host,
|
|
1019
|
+
raw_shell_cmd=_remote_cmd,
|
|
1020
|
+
)
|
|
1021
|
+
if _rc is None:
|
|
1022
|
+
click.echo(
|
|
1023
|
+
" error: could not create remote tmux session on "
|
|
1024
|
+
f"{machine_obj.host}",
|
|
1025
|
+
err=True,
|
|
1026
|
+
)
|
|
1027
|
+
sys.exit(1)
|
|
1028
|
+
exit_code = _rc
|
|
1029
|
+
if exit_code != 0:
|
|
1030
|
+
click.echo(f" claude exited with status {exit_code}", err=True)
|
|
1031
|
+
|
|
1032
|
+
if _tmux_alive(_sname, host=_tmux_host):
|
|
1033
|
+
click.echo(
|
|
1034
|
+
f" session still running in remote tmux: {_sname}\n"
|
|
1035
|
+
f" reattach with: ssh -t {machine_obj.host}"
|
|
1036
|
+
f" tmux attach-session -t {_sname}"
|
|
1037
|
+
)
|
|
1038
|
+
sys.exit(0)
|
|
1039
|
+
|
|
1040
|
+
# Session ended. Record a terminal state for THIS SMOKE SESSION row
|
|
1041
|
+
# (not the WORK row) so it doesn't linger as a phantom 'running' worker
|
|
1042
|
+
# holding the issue claim forever — worktree_path=None/repo_path=None
|
|
1043
|
+
# since this is read-only (no push, no worktree touch), identical to
|
|
1044
|
+
# the local path above. ssh_target lets any future transcript-floor
|
|
1045
|
+
# recovery read the session's OWN host (mirrors the #617 review fix).
|
|
1046
|
+
try:
|
|
1047
|
+
_fr = finalize_interactive_exit(
|
|
1048
|
+
assignment_id=assignment_id,
|
|
1049
|
+
repo_name=repo,
|
|
1050
|
+
repo_github=repo_cfg.github,
|
|
1051
|
+
issue_number=issue,
|
|
1052
|
+
machine_name=machine,
|
|
1053
|
+
worktree_path=None,
|
|
1054
|
+
base_branch=smoke_default_branch,
|
|
1055
|
+
exit_code=exit_code,
|
|
1056
|
+
started_at=started_at,
|
|
1057
|
+
log_path=None,
|
|
1058
|
+
repo_path=None,
|
|
1059
|
+
ssh_target=machine_obj.host,
|
|
1060
|
+
smoke_repo_path=_rp_sh,
|
|
1061
|
+
smoke_of=smoke_of,
|
|
1062
|
+
)
|
|
1063
|
+
if _fr.test_verdict_recovered:
|
|
1064
|
+
click.echo(
|
|
1065
|
+
f" test verdict {_fr.test_verdict_recovered!r} recovered "
|
|
1066
|
+
"from the session transcript and recorded on the work "
|
|
1067
|
+
f"assignment {smoke_of} (#1351)"
|
|
1068
|
+
)
|
|
1069
|
+
if _fr.smoke_restored_paths:
|
|
1070
|
+
click.echo(
|
|
1071
|
+
" live checkout restored (#1256): reverted "
|
|
1072
|
+
+ ", ".join(_fr.smoke_restored_paths)
|
|
1073
|
+
)
|
|
1074
|
+
if _fr.smoke_restore_error:
|
|
1075
|
+
click.echo(
|
|
1076
|
+
f" warning: live-checkout restore failed: "
|
|
1077
|
+
f"{_fr.smoke_restore_error}",
|
|
1078
|
+
err=True,
|
|
1079
|
+
)
|
|
1080
|
+
except Exception as exc: # noqa: BLE001 — best-effort backstop
|
|
1081
|
+
click.echo(
|
|
1082
|
+
f" warning: backstop failed to record smoke exit: {exc}",
|
|
1083
|
+
err=True,
|
|
1084
|
+
)
|
|
1085
|
+
|
|
1086
|
+
# #923: Test-verdict backstop — see comment above the shared
|
|
1087
|
+
# _test_verdict_cmd definition.
|
|
1088
|
+
try:
|
|
1089
|
+
if not _prompt_and_relay_test_verdict(
|
|
1090
|
+
work_assignment_id=smoke_of,
|
|
1091
|
+
smoke_assignment_id=assignment_id,
|
|
1092
|
+
repo_name=repo,
|
|
1093
|
+
repo_github=repo_cfg.github,
|
|
1094
|
+
issue_number=issue,
|
|
1095
|
+
machine_name=machine,
|
|
1096
|
+
verdict_cmd_hint=_test_verdict_cmd,
|
|
1097
|
+
):
|
|
1098
|
+
click.echo(
|
|
1099
|
+
" smoke session ended with no test verdict recorded "
|
|
1100
|
+
"— the merge gate stays blocked until a verdict is reported."
|
|
1101
|
+
)
|
|
1102
|
+
except Exception as exc: # noqa: BLE001 — best-effort backstop
|
|
1103
|
+
click.echo(
|
|
1104
|
+
f" warning: test-verdict backstop failed: {exc}",
|
|
1105
|
+
err=True,
|
|
1106
|
+
)
|
|
1107
|
+
sys.exit(exit_code)
|
|
1108
|
+
|
|
1109
|
+
|
|
1110
|
+
def _dispatch_audit_of(
|
|
1111
|
+
*,
|
|
1112
|
+
machine: str,
|
|
1113
|
+
repo: str,
|
|
1114
|
+
model: str | None,
|
|
1115
|
+
dry_run: bool,
|
|
1116
|
+
audit_of: str,
|
|
1117
|
+
cfg: Config,
|
|
1118
|
+
machine_obj: object,
|
|
1119
|
+
repo_cfg: object,
|
|
1120
|
+
provider: object,
|
|
1121
|
+
_is_local: bool,
|
|
1122
|
+
_issue_ctx: str,
|
|
1123
|
+
) -> None:
|
|
1124
|
+
"""Milestone Outcome Audit Phase 1 (#885): human-attended, READ-ONLY
|
|
1125
|
+
milestone-outcome analyst for the milestone's tracking EPIC ISSUE.
|
|
1126
|
+
|
|
1127
|
+
Mirrors `_dispatch_smoke_of`'s read-only / live-checkout / no-worktree
|
|
1128
|
+
shape, but the target (``audit_of``) is a GitHub issue number — the
|
|
1129
|
+
milestone's tracking epic — not a board work-assignment id, so there is
|
|
1130
|
+
no board lookup here. The epic's own body (goals/acceptance/plan
|
|
1131
|
+
checklist) and its milestone's issue states are fetched fresh and handed
|
|
1132
|
+
to the agent, which measures each goal against the code with shell tools
|
|
1133
|
+
and posts a scorecard via `coord report-result` (landing as a comment on
|
|
1134
|
+
the epic, per issue_number below).
|
|
1135
|
+
"""
|
|
1136
|
+
import time as _time # noqa: PLC0415
|
|
1137
|
+
import uuid as _uuid # noqa: PLC0415
|
|
1138
|
+
|
|
1139
|
+
from coord.board_service import read_board as _read_board_au # noqa: PLC0415
|
|
1140
|
+
from coord.board_service import write_board as _write_board_au # noqa: PLC0415
|
|
1141
|
+
from coord.interactive import ( # noqa: PLC0415
|
|
1142
|
+
finalize_interactive_exit,
|
|
1143
|
+
launch_human_attended_interactive,
|
|
1144
|
+
tmux_available as _tmux_avail,
|
|
1145
|
+
tmux_session_alive as _tmux_alive,
|
|
1146
|
+
tmux_session_name as _tmux_name,
|
|
1147
|
+
)
|
|
1148
|
+
from coord.models import Assignment as _AssignmentAu # noqa: PLC0415
|
|
1149
|
+
from coord.state import record_dispatched_assignment as _record_au # noqa: PLC0415
|
|
1150
|
+
from coord.agent import AssignmentSpec as _AssignmentSpecAu # noqa: PLC0415
|
|
1151
|
+
|
|
1152
|
+
if not _is_local:
|
|
1153
|
+
click.echo(
|
|
1154
|
+
"error: --audit-of is local-only for now; run it on the "
|
|
1155
|
+
"machine that holds the checkout.",
|
|
1156
|
+
err=True,
|
|
1157
|
+
)
|
|
1158
|
+
sys.exit(2)
|
|
1159
|
+
|
|
1160
|
+
try:
|
|
1161
|
+
epic_num = int(audit_of)
|
|
1162
|
+
except ValueError:
|
|
1163
|
+
click.echo(
|
|
1164
|
+
f"error: --audit-of {audit_of!r} must be a GitHub issue number "
|
|
1165
|
+
"(the milestone's tracking epic).",
|
|
1166
|
+
err=True,
|
|
1167
|
+
)
|
|
1168
|
+
sys.exit(2)
|
|
1169
|
+
|
|
1170
|
+
try:
|
|
1171
|
+
epic_data = github_ops.get_issue(repo_cfg.github, epic_num)
|
|
1172
|
+
except RuntimeError as e:
|
|
1173
|
+
click.echo(f"error: could not fetch epic issue #{epic_num}: {e}", err=True)
|
|
1174
|
+
sys.exit(1)
|
|
1175
|
+
|
|
1176
|
+
epic_title = epic_data.get("title") or f"Issue #{epic_num}"
|
|
1177
|
+
epic_body = epic_data.get("body") or "(no body)"
|
|
1178
|
+
milestone = epic_data.get("milestone")
|
|
1179
|
+
if not milestone or not milestone.get("title"):
|
|
1180
|
+
click.echo(
|
|
1181
|
+
f"error: --audit-of {epic_num}: issue has no milestone — the "
|
|
1182
|
+
"audit needs a milestone to enumerate issue states for (assign "
|
|
1183
|
+
"one with `coord milestone assign` first).",
|
|
1184
|
+
err=True,
|
|
1185
|
+
)
|
|
1186
|
+
sys.exit(2)
|
|
1187
|
+
milestone_title = milestone["title"]
|
|
1188
|
+
|
|
1189
|
+
try:
|
|
1190
|
+
milestone_issues = github_ops.get_milestone_issues(repo_cfg.github, milestone_title)
|
|
1191
|
+
except RuntimeError as e:
|
|
1192
|
+
click.echo(
|
|
1193
|
+
f"error: could not list issues for milestone {milestone_title!r}: {e}",
|
|
1194
|
+
err=True,
|
|
1195
|
+
)
|
|
1196
|
+
sys.exit(1)
|
|
1197
|
+
|
|
1198
|
+
audit_repo_path = str(
|
|
1199
|
+
Path(machine_obj.repo_path(repo) or str(Path.cwd())).expanduser()
|
|
1200
|
+
)
|
|
1201
|
+
audit_default_branch = repo_cfg.default_branch or "main"
|
|
1202
|
+
resolved_model = model if model else cfg.models.default
|
|
1203
|
+
assignment_id = _uuid.uuid4().hex[:12]
|
|
1204
|
+
|
|
1205
|
+
# Table of milestone issue states (number/state/title/labels) so the
|
|
1206
|
+
# agent has them up front — it has no `gh` access to fetch this itself.
|
|
1207
|
+
_issue_lines = [
|
|
1208
|
+
"- #{num} [{state}] {title}{labels}".format(
|
|
1209
|
+
num=iss.get("number"),
|
|
1210
|
+
state=iss.get("state", "?"),
|
|
1211
|
+
title=iss.get("title", ""),
|
|
1212
|
+
labels=(
|
|
1213
|
+
" ({})".format(", ".join(lbl.get("name", "") for lbl in iss.get("labels") or []))
|
|
1214
|
+
if iss.get("labels")
|
|
1215
|
+
else ""
|
|
1216
|
+
),
|
|
1217
|
+
)
|
|
1218
|
+
for iss in sorted(milestone_issues, key=lambda i: i.get("number", 0))
|
|
1219
|
+
]
|
|
1220
|
+
milestone_issues_block = "\n".join(_issue_lines) or "(no issues found under this milestone)"
|
|
1221
|
+
|
|
1222
|
+
# #886 Phase 2: fetch prior `--audit-of` runs against this epic (if any) so
|
|
1223
|
+
# the agent measures AGAINST the last verdict — the concrete "re-ask the
|
|
1224
|
+
# question" loop the issue asks for — and so the next run_number is known
|
|
1225
|
+
# up front. Best-effort: a lookup failure just means this looks like run 1.
|
|
1226
|
+
import json as _json_au # noqa: PLC0415
|
|
1227
|
+
|
|
1228
|
+
from coord import issue_store as _issue_store_au # noqa: PLC0415
|
|
1229
|
+
|
|
1230
|
+
try:
|
|
1231
|
+
_prior_audit_runs = _issue_store_au.get_audit_runs_for_epic(repo, epic_num)
|
|
1232
|
+
except Exception: # noqa: BLE001 — best-effort; treat as no prior runs
|
|
1233
|
+
_prior_audit_runs = []
|
|
1234
|
+
_next_run_number = len(_prior_audit_runs) + 1
|
|
1235
|
+
if _prior_audit_runs:
|
|
1236
|
+
_last_run = _prior_audit_runs[-1]
|
|
1237
|
+
_last_goals: list = []
|
|
1238
|
+
try:
|
|
1239
|
+
_last_goals = _json_au.loads(_last_run.get("audit_goals_json") or "[]")
|
|
1240
|
+
except (TypeError, ValueError):
|
|
1241
|
+
_last_goals = []
|
|
1242
|
+
_prior_goal_lines = [
|
|
1243
|
+
"- {goal}: {verdict} ({evidence})".format(
|
|
1244
|
+
goal=g.get("goal", "?"),
|
|
1245
|
+
verdict=g.get("verdict", "?"),
|
|
1246
|
+
evidence=g.get("evidence", "") or "no evidence recorded",
|
|
1247
|
+
)
|
|
1248
|
+
for g in _last_goals
|
|
1249
|
+
]
|
|
1250
|
+
prior_run_block = (
|
|
1251
|
+
f"## Prior audit run (v{_last_run.get('audit_run_number')})\n\n"
|
|
1252
|
+
f"**Bottom line:** {_last_run.get('audit_bottom_line') or '(none recorded)'}\n\n"
|
|
1253
|
+
+ ("\n".join(_prior_goal_lines) or "(no goals recorded)")
|
|
1254
|
+
+ "\n\n**This is run v"
|
|
1255
|
+
+ str(_next_run_number)
|
|
1256
|
+
+ "** — measure each goal again from scratch (never trust the "
|
|
1257
|
+
"prior verdict without re-checking), then report whether each "
|
|
1258
|
+
"moved (gap→met, still open, regressed, or new).\n\n"
|
|
1259
|
+
)
|
|
1260
|
+
else:
|
|
1261
|
+
prior_run_block = (
|
|
1262
|
+
f"## Prior audit runs\n\n(none — this is run v{_next_run_number}, "
|
|
1263
|
+
"the first audit of this milestone.)\n\n"
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
INTERACTIVE_AUDIT_SYSTEM_PROMPT = (
|
|
1267
|
+
"You are a human-attended MILESTONE OUTCOME AUDITOR dispatched by "
|
|
1268
|
+
"the coordinator (#885). You are an independent analyst — measure "
|
|
1269
|
+
"reality, do not rubber-stamp ticket state.\n\n"
|
|
1270
|
+
"Rules:\n"
|
|
1271
|
+
"- READ-ONLY. Do NOT modify code, commit, push, or open/merge PRs "
|
|
1272
|
+
"(Edit/Write are not available to you).\n"
|
|
1273
|
+
"- Do NOT run `gh` commands. The coordinator owns GitHub — the epic "
|
|
1274
|
+
"body and milestone issue states are already in your briefing "
|
|
1275
|
+
"below.\n"
|
|
1276
|
+
"- You MAY run read-only git (log/diff/show/merge-base/branch -r) "
|
|
1277
|
+
"and any read-only shell tool (wc, grep -r, find, cat, test/coverage "
|
|
1278
|
+
"runs, etc.) against the LIVE checkout to measure reality.\n\n"
|
|
1279
|
+
"Method:\n"
|
|
1280
|
+
"1. Read the epic's goals, acceptance criteria, and any plan/work-"
|
|
1281
|
+
"order checklist from its body (below).\n"
|
|
1282
|
+
"2. Review the milestone's issue states (below) — which are open, "
|
|
1283
|
+
"closed, still in flight.\n"
|
|
1284
|
+
"3. For EACH goal, MEASURE it against the actual code — never trust "
|
|
1285
|
+
"ticket state or a self-reported summary. Concretely:\n"
|
|
1286
|
+
" - decomposition/size claims -> `wc -l <file>` on the files in "
|
|
1287
|
+
"question\n"
|
|
1288
|
+
" - \"feature X exists\" claims -> `grep -rn` for the actual "
|
|
1289
|
+
"symbols/call sites\n"
|
|
1290
|
+
" - \"branch Y merged\" claims -> `git merge-base --is-ancestor "
|
|
1291
|
+
"<branch> origin/main` (or confirm the file exists on the default "
|
|
1292
|
+
"branch)\n"
|
|
1293
|
+
" - test/coverage claims -> run the test suite or read coverage "
|
|
1294
|
+
"output where relevant\n"
|
|
1295
|
+
"4. Emit a scorecard: one row per goal — before/after, "
|
|
1296
|
+
"met|partial|gap, and the concrete evidence (command + result) that "
|
|
1297
|
+
"backs the verdict. Call out specific files/line-counts/gaps by "
|
|
1298
|
+
"name (e.g. a god-file that grew instead of shrank, an open seam "
|
|
1299
|
+
"issue that was supposed to close).\n"
|
|
1300
|
+
"5. End with a one-line bottom-line verdict (e.g. \"5/6 goals "
|
|
1301
|
+
"met\").\n"
|
|
1302
|
+
"6. If a PRIOR AUDIT RUN is included in your briefing below, measure "
|
|
1303
|
+
"each of ITS goals again too (same goal text) so the coordinator can "
|
|
1304
|
+
"compute the diff — which gaps closed, which are still open, whether "
|
|
1305
|
+
"anything regressed.\n\n"
|
|
1306
|
+
"When done, write BOTH of these and relay them together in ONE "
|
|
1307
|
+
"`coord report-result` call:\n"
|
|
1308
|
+
" (a) the FULL prose scorecard to a temp file (--body-file), AND\n"
|
|
1309
|
+
" (b) a STRUCTURED JSON file (--audit-json) shaped exactly like:\n"
|
|
1310
|
+
' {"bottom_line": "5/6 goals met", "goals": [{"goal": '
|
|
1311
|
+
'"short goal name — MUST match the prior run\'s goal text exactly '
|
|
1312
|
+
'when re-measuring it", "metric_before": "...", "metric_after": '
|
|
1313
|
+
'"...", "verdict": "met|partial|gap", "evidence": "command + '
|
|
1314
|
+
'result"}, ...]}\n'
|
|
1315
|
+
" coord report-result --assignment <assignment_id> --status done "
|
|
1316
|
+
"--summary \"<bottom-line, one paragraph>\" --body-file "
|
|
1317
|
+
"<path-to-scorecard.md> --audit-json <path-to-verdict.json>\n"
|
|
1318
|
+
"Both flags are REQUIRED — --body-file posts the readable scorecard "
|
|
1319
|
+
"as a comment; --audit-json is what makes the verdict structured, "
|
|
1320
|
+
"versioned, and diffable run-over-run (#886). Omitting --audit-json "
|
|
1321
|
+
"leaves this run un-diffable against the next one.\n"
|
|
1322
|
+
)
|
|
1323
|
+
|
|
1324
|
+
audit_briefing = (
|
|
1325
|
+
f"# Milestone outcome audit: {repo_cfg.github} epic #{epic_num}\n\n"
|
|
1326
|
+
f"**Epic:** {epic_title}\n"
|
|
1327
|
+
f"**Milestone:** {milestone_title}\n"
|
|
1328
|
+
f"**Repo checkout:** {audit_repo_path}\n\n"
|
|
1329
|
+
"## ⚠ Do NOT move this checkout's branch\n\n"
|
|
1330
|
+
f"`{audit_repo_path}` is the **live checkout that runs the "
|
|
1331
|
+
"coordinator itself** (and the worktree base for workers). Do "
|
|
1332
|
+
"**NOT** `git checkout` / `git switch` / `git reset` / "
|
|
1333
|
+
"`git stash` it. Read files, run read-only git/shell commands, and "
|
|
1334
|
+
"measure — never move the branch.\n\n"
|
|
1335
|
+
f"## Epic body (goals / acceptance / plan)\n\n{epic_body}\n\n"
|
|
1336
|
+
f"## Milestone issue states ({milestone_title})\n\n"
|
|
1337
|
+
f"{milestone_issues_block}\n\n"
|
|
1338
|
+
f"{prior_run_block}"
|
|
1339
|
+
"## Your job\n\n"
|
|
1340
|
+
"Measure each goal against the code (see the system prompt for the "
|
|
1341
|
+
"method), emit a scorecard, and relay it with `coord report-result "
|
|
1342
|
+
f"--assignment {assignment_id} --status done --summary \"...\" "
|
|
1343
|
+
"--body-file <scorecard.md> --audit-json <verdict.json>`.\n"
|
|
1344
|
+
)
|
|
1345
|
+
|
|
1346
|
+
report_reminder = (
|
|
1347
|
+
f"[Coordinator audit assignment {assignment_id}] HUMAN-ATTENDED "
|
|
1348
|
+
"read-only milestone-outcome audit (#885/#886), run v"
|
|
1349
|
+
f"{_next_run_number}. When done, run `coord "
|
|
1350
|
+
f"report-result --assignment {assignment_id} --status done "
|
|
1351
|
+
"--summary \"<bottom-line>\" --body-file <scorecard.md> --audit-json "
|
|
1352
|
+
"<verdict.json>` so the scorecard AND the structured verdict post "
|
|
1353
|
+
"together and this session's row closes.\n\n"
|
|
1354
|
+
)
|
|
1355
|
+
effective_briefing = _issue_ctx + report_reminder + audit_briefing
|
|
1356
|
+
|
|
1357
|
+
spec = _AssignmentSpecAu(
|
|
1358
|
+
repo_name=repo,
|
|
1359
|
+
repo_path=audit_repo_path,
|
|
1360
|
+
issue_number=epic_num,
|
|
1361
|
+
issue_title=f"[audit] {epic_title}",
|
|
1362
|
+
briefing=effective_briefing,
|
|
1363
|
+
model=resolved_model,
|
|
1364
|
+
type="audit",
|
|
1365
|
+
provider="claude-pty",
|
|
1366
|
+
)
|
|
1367
|
+
# READ-ONLY: no Edit/Write — the audit measures, it never fixes.
|
|
1368
|
+
argv = provider.build_command(
|
|
1369
|
+
spec,
|
|
1370
|
+
resolved_model=resolved_model,
|
|
1371
|
+
system_prompt=INTERACTIVE_AUDIT_SYSTEM_PROMPT,
|
|
1372
|
+
allowed_tools="Read,Bash,Grep,Glob",
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
click.echo(
|
|
1376
|
+
f"{machine} (local TTY) → AUDIT of epic #{epic_num}: {epic_title}"
|
|
1377
|
+
)
|
|
1378
|
+
click.echo(" mode: HUMAN-ATTENDED read-only milestone-outcome audit (#885)")
|
|
1379
|
+
click.echo(f" assignment id: {assignment_id} (audit_of={epic_num})")
|
|
1380
|
+
click.echo(f" milestone: {milestone_title}")
|
|
1381
|
+
click.echo(
|
|
1382
|
+
f" cwd: {audit_repo_path} (live checkout — read-only, no worktree)"
|
|
1383
|
+
)
|
|
1384
|
+
if dry_run:
|
|
1385
|
+
click.echo(" (dry run — not launched)")
|
|
1386
|
+
click.echo(f" would exec: {argv}")
|
|
1387
|
+
return
|
|
1388
|
+
|
|
1389
|
+
audit_assignment = _AssignmentAu(
|
|
1390
|
+
machine_name=machine,
|
|
1391
|
+
repo_name=repo,
|
|
1392
|
+
issue_number=epic_num,
|
|
1393
|
+
issue_title=f"[audit] {epic_title}",
|
|
1394
|
+
briefing=effective_briefing,
|
|
1395
|
+
assignment_id=assignment_id,
|
|
1396
|
+
status="running",
|
|
1397
|
+
dispatched_at=_time.time(),
|
|
1398
|
+
type="audit",
|
|
1399
|
+
model=resolved_model,
|
|
1400
|
+
provider_name="claude-pty",
|
|
1401
|
+
)
|
|
1402
|
+
_record_au(assignment=audit_assignment, repo_github=repo_cfg.github)
|
|
1403
|
+
_write_board_au(_read_board_au())
|
|
1404
|
+
os.environ["COORD_ASSIGNMENT_ID"] = assignment_id
|
|
1405
|
+
|
|
1406
|
+
started_at = _time.time()
|
|
1407
|
+
exit_code = launch_human_attended_interactive(
|
|
1408
|
+
argv,
|
|
1409
|
+
effective_briefing,
|
|
1410
|
+
assignment_id=assignment_id,
|
|
1411
|
+
cwd=audit_repo_path,
|
|
1412
|
+
)
|
|
1413
|
+
if exit_code != 0:
|
|
1414
|
+
click.echo(f" claude exited with status {exit_code}", err=True)
|
|
1415
|
+
|
|
1416
|
+
_sname = _tmux_name(assignment_id) if _tmux_avail() else None
|
|
1417
|
+
if _sname and _tmux_alive(_sname):
|
|
1418
|
+
click.echo(
|
|
1419
|
+
f" session still running in tmux: {_sname}\n"
|
|
1420
|
+
f" reattach with: coord reattach {assignment_id}"
|
|
1421
|
+
)
|
|
1422
|
+
sys.exit(0)
|
|
1423
|
+
|
|
1424
|
+
# worktree_path=None: read-only, live checkout — never push/remove it.
|
|
1425
|
+
try:
|
|
1426
|
+
finalize_interactive_exit(
|
|
1427
|
+
assignment_id=assignment_id,
|
|
1428
|
+
repo_name=repo,
|
|
1429
|
+
repo_github=repo_cfg.github,
|
|
1430
|
+
issue_number=epic_num,
|
|
1431
|
+
machine_name=machine,
|
|
1432
|
+
worktree_path=None,
|
|
1433
|
+
base_branch=audit_default_branch,
|
|
1434
|
+
exit_code=exit_code,
|
|
1435
|
+
started_at=started_at,
|
|
1436
|
+
log_path=None,
|
|
1437
|
+
repo_path=None,
|
|
1438
|
+
)
|
|
1439
|
+
except Exception as exc: # noqa: BLE001 — best-effort backstop
|
|
1440
|
+
click.echo(
|
|
1441
|
+
f" warning: backstop failed to record audit exit: {exc}",
|
|
1442
|
+
err=True,
|
|
1443
|
+
)
|
|
1444
|
+
return
|
|
1445
|
+
|
|
1446
|
+
|
|
1447
|
+
def _dispatch_milestone_chat_of(
|
|
1448
|
+
*,
|
|
1449
|
+
machine: str,
|
|
1450
|
+
repo: str,
|
|
1451
|
+
model: str | None,
|
|
1452
|
+
dry_run: bool,
|
|
1453
|
+
milestone_chat_of: str,
|
|
1454
|
+
add_child: str | None,
|
|
1455
|
+
cfg: Config,
|
|
1456
|
+
machine_obj: object,
|
|
1457
|
+
repo_cfg: object,
|
|
1458
|
+
provider: object,
|
|
1459
|
+
_is_local: bool,
|
|
1460
|
+
) -> None:
|
|
1461
|
+
"""#1029: human-attended, genuine tmux-attached interactive milestone-chat
|
|
1462
|
+
session — replaces the headless `claude -p` / SSE-overlay mechanism
|
|
1463
|
+
(`coord/milestone_chat.py::dispatch_milestone_chat`, still used by the
|
|
1464
|
+
remote/headless `coord milestone chat` CLI path and by
|
|
1465
|
+
`dispatch_new_milestone_chat`'s `--new` flow, which stays out of scope).
|
|
1466
|
+
|
|
1467
|
+
Mirrors `_dispatch_audit_of`'s shape: the target (`milestone_chat_of`) is
|
|
1468
|
+
a GitHub issue number — the milestone's tracking issue — not a board
|
|
1469
|
+
work-assignment id, so there is no board lookup. The milestone/issue
|
|
1470
|
+
resolution and seed-briefing content are shared with the headless path
|
|
1471
|
+
via `coord.milestone_chat.resolve_milestone_chat_briefing` so the two
|
|
1472
|
+
dispatch mechanisms can never drift apart on what the agent is told.
|
|
1473
|
+
|
|
1474
|
+
`--system-prompt`/`--allowedTools` are deliberately NOT passed to
|
|
1475
|
+
`provider.build_command` — `ClaudePtyProvider.build_command` already has
|
|
1476
|
+
a `spec.type == "milestone-chat"` branch (added for the headless path)
|
|
1477
|
+
that supplies `MILESTONE_CHAT_SYSTEM_PROMPT` +
|
|
1478
|
+
`build_deny_prompt(MILESTONE_CHAT_DENY_COMMANDS)` and `Read,Bash` tools
|
|
1479
|
+
automatically from `spec.type` alone.
|
|
1480
|
+
"""
|
|
1481
|
+
import tempfile as _tempfile # noqa: PLC0415
|
|
1482
|
+
import time as _time # noqa: PLC0415
|
|
1483
|
+
import uuid as _uuid # noqa: PLC0415
|
|
1484
|
+
|
|
1485
|
+
from coord.interactive import ( # noqa: PLC0415
|
|
1486
|
+
finalize_interactive_exit,
|
|
1487
|
+
launch_human_attended_interactive,
|
|
1488
|
+
tmux_available as _tmux_avail,
|
|
1489
|
+
tmux_session_alive as _tmux_alive,
|
|
1490
|
+
tmux_session_name as _tmux_name,
|
|
1491
|
+
)
|
|
1492
|
+
from coord.milestone_chat import resolve_milestone_chat_briefing # noqa: PLC0415
|
|
1493
|
+
from coord.board_service import read_board as _read_board_mc # noqa: PLC0415
|
|
1494
|
+
from coord.board_service import write_board as _write_board_mc # noqa: PLC0415
|
|
1495
|
+
from coord.models import Assignment as _AssignmentMc # noqa: PLC0415
|
|
1496
|
+
from coord.state import record_dispatched_assignment as _record_mc # noqa: PLC0415
|
|
1497
|
+
from coord.agent import AssignmentSpec as _AssignmentSpecMc # noqa: PLC0415
|
|
1498
|
+
|
|
1499
|
+
if not _is_local:
|
|
1500
|
+
click.echo(
|
|
1501
|
+
"error: --milestone-chat-of is local-only for now; run it on the "
|
|
1502
|
+
"machine that holds the checkout.",
|
|
1503
|
+
err=True,
|
|
1504
|
+
)
|
|
1505
|
+
sys.exit(2)
|
|
1506
|
+
|
|
1507
|
+
try:
|
|
1508
|
+
tracking_issue = int(milestone_chat_of)
|
|
1509
|
+
except ValueError:
|
|
1510
|
+
click.echo(
|
|
1511
|
+
f"error: --milestone-chat-of {milestone_chat_of!r} must be a GitHub "
|
|
1512
|
+
"issue number (the milestone's tracking issue).",
|
|
1513
|
+
err=True,
|
|
1514
|
+
)
|
|
1515
|
+
sys.exit(2)
|
|
1516
|
+
|
|
1517
|
+
add_child_issue: int | None = None
|
|
1518
|
+
if add_child is not None:
|
|
1519
|
+
try:
|
|
1520
|
+
add_child_issue = int(add_child)
|
|
1521
|
+
except ValueError:
|
|
1522
|
+
click.echo(
|
|
1523
|
+
f"error: --add-child {add_child!r} must be a GitHub issue number.",
|
|
1524
|
+
err=True,
|
|
1525
|
+
)
|
|
1526
|
+
sys.exit(2)
|
|
1527
|
+
|
|
1528
|
+
try:
|
|
1529
|
+
ctx = resolve_milestone_chat_briefing(
|
|
1530
|
+
repo, tracking_issue, cfg, add_child_issue=add_child_issue,
|
|
1531
|
+
)
|
|
1532
|
+
except RuntimeError as e:
|
|
1533
|
+
click.echo(f"error: {e}", err=True)
|
|
1534
|
+
sys.exit(1)
|
|
1535
|
+
|
|
1536
|
+
mc_repo_path = str(
|
|
1537
|
+
Path(machine_obj.repo_path(repo) or str(Path.cwd())).expanduser()
|
|
1538
|
+
)
|
|
1539
|
+
mc_default_branch = repo_cfg.default_branch or "main"
|
|
1540
|
+
resolved_model = model if model else cfg.models.default
|
|
1541
|
+
assignment_id = _uuid.uuid4().hex[:12]
|
|
1542
|
+
|
|
1543
|
+
# Write the full briefing to a temp file and pre-fill only a SHORT
|
|
1544
|
+
# single-line seed pointing at it — mirrors `_run_troubleshoot_or_chat`'s
|
|
1545
|
+
# rationale: a multi-KB multi-line paste over the embedded-terminal /
|
|
1546
|
+
# tmux path is less reliable than a short one, and this degrades
|
|
1547
|
+
# gracefully (the operator can open the file by hand if the paste misses).
|
|
1548
|
+
_mc_brief_path = str(
|
|
1549
|
+
Path(_tempfile.gettempdir()) / f"coord-milestone-chat-{tracking_issue}.md"
|
|
1550
|
+
)
|
|
1551
|
+
Path(_mc_brief_path).write_text(ctx.briefing, encoding="utf-8")
|
|
1552
|
+
if add_child_issue is not None:
|
|
1553
|
+
seed_prompt = (
|
|
1554
|
+
f"Milestone chat for {repo} #{tracking_issue} ({ctx.milestone_title}): "
|
|
1555
|
+
f"read the full context at {_mc_brief_path} — it includes candidate "
|
|
1556
|
+
f"sub-issue #{add_child_issue}. Discuss whether it belongs under this "
|
|
1557
|
+
"epic and, once I confirm, run the `coord milestone add-child` splice "
|
|
1558
|
+
"described there."
|
|
1559
|
+
)
|
|
1560
|
+
else:
|
|
1561
|
+
seed_prompt = (
|
|
1562
|
+
f"Milestone chat for {repo} #{tracking_issue} ({ctx.milestone_title}): "
|
|
1563
|
+
f"read the full context at {_mc_brief_path} (tracking issue body, open "
|
|
1564
|
+
"issues under the milestone, current work order) and let's talk it "
|
|
1565
|
+
"through. Once I confirm a work order / edit / assignment, write it "
|
|
1566
|
+
"with the exact `coord milestone ...` command described there."
|
|
1567
|
+
)
|
|
1568
|
+
|
|
1569
|
+
spec = _AssignmentSpecMc(
|
|
1570
|
+
repo_name=repo,
|
|
1571
|
+
repo_path=mc_repo_path,
|
|
1572
|
+
issue_number=tracking_issue,
|
|
1573
|
+
issue_title=f"[milestone-chat] {ctx.tracking_title}",
|
|
1574
|
+
briefing=ctx.briefing,
|
|
1575
|
+
model=resolved_model,
|
|
1576
|
+
type="milestone-chat",
|
|
1577
|
+
provider="claude-pty",
|
|
1578
|
+
)
|
|
1579
|
+
# No explicit system_prompt/allowed_tools: ClaudePtyProvider.build_command's
|
|
1580
|
+
# spec.type == "milestone-chat" branch supplies MILESTONE_CHAT_SYSTEM_PROMPT
|
|
1581
|
+
# + the deny-list + Read,Bash automatically.
|
|
1582
|
+
argv = provider.build_command(spec, resolved_model=resolved_model)
|
|
1583
|
+
|
|
1584
|
+
click.echo(
|
|
1585
|
+
f"{machine} (local TTY) → MILESTONE CHAT #{tracking_issue}: "
|
|
1586
|
+
f"{ctx.milestone_title}"
|
|
1587
|
+
)
|
|
1588
|
+
click.echo(
|
|
1589
|
+
" mode: HUMAN-ATTENDED interactive milestone chat "
|
|
1590
|
+
"(live checkout, no claim, no worktree) (#1029)"
|
|
1591
|
+
)
|
|
1592
|
+
click.echo(f" assignment id: {assignment_id} (milestone_chat_of={tracking_issue})")
|
|
1593
|
+
if add_child_issue is not None:
|
|
1594
|
+
click.echo(f" add-child candidate: #{add_child_issue}")
|
|
1595
|
+
click.echo(f" cwd: {mc_repo_path} (live checkout — read-only, no worktree)")
|
|
1596
|
+
if dry_run:
|
|
1597
|
+
click.echo(" (dry run — not launched)")
|
|
1598
|
+
click.echo(f" would exec: {argv}")
|
|
1599
|
+
return
|
|
1600
|
+
|
|
1601
|
+
mc_assignment = _AssignmentMc(
|
|
1602
|
+
machine_name=machine,
|
|
1603
|
+
repo_name=repo,
|
|
1604
|
+
issue_number=tracking_issue,
|
|
1605
|
+
issue_title=f"[milestone-chat] {ctx.tracking_title}",
|
|
1606
|
+
briefing=ctx.briefing,
|
|
1607
|
+
assignment_id=assignment_id,
|
|
1608
|
+
status="running",
|
|
1609
|
+
dispatched_at=_time.time(),
|
|
1610
|
+
type="milestone-chat",
|
|
1611
|
+
model=resolved_model,
|
|
1612
|
+
provider_name="claude-pty",
|
|
1613
|
+
)
|
|
1614
|
+
_record_mc(assignment=mc_assignment, repo_github=repo_cfg.github)
|
|
1615
|
+
_write_board_mc(_read_board_mc())
|
|
1616
|
+
os.environ["COORD_ASSIGNMENT_ID"] = assignment_id
|
|
1617
|
+
|
|
1618
|
+
started_at = _time.time()
|
|
1619
|
+
exit_code = launch_human_attended_interactive(
|
|
1620
|
+
argv,
|
|
1621
|
+
seed_prompt,
|
|
1622
|
+
assignment_id=assignment_id,
|
|
1623
|
+
cwd=mc_repo_path,
|
|
1624
|
+
)
|
|
1625
|
+
if exit_code != 0:
|
|
1626
|
+
click.echo(f" claude exited with status {exit_code}", err=True)
|
|
1627
|
+
|
|
1628
|
+
_sname = _tmux_name(assignment_id) if _tmux_avail() else None
|
|
1629
|
+
if _sname and _tmux_alive(_sname):
|
|
1630
|
+
click.echo(
|
|
1631
|
+
f" session still running in tmux: {_sname}\n"
|
|
1632
|
+
f" reattach with: coord reattach {assignment_id}"
|
|
1633
|
+
)
|
|
1634
|
+
sys.exit(0)
|
|
1635
|
+
|
|
1636
|
+
# worktree_path=None: read-only, live checkout — never push/remove it.
|
|
1637
|
+
try:
|
|
1638
|
+
finalize_interactive_exit(
|
|
1639
|
+
assignment_id=assignment_id,
|
|
1640
|
+
repo_name=repo,
|
|
1641
|
+
repo_github=repo_cfg.github,
|
|
1642
|
+
issue_number=tracking_issue,
|
|
1643
|
+
machine_name=machine,
|
|
1644
|
+
worktree_path=None,
|
|
1645
|
+
base_branch=mc_default_branch,
|
|
1646
|
+
exit_code=exit_code,
|
|
1647
|
+
started_at=started_at,
|
|
1648
|
+
log_path=None,
|
|
1649
|
+
repo_path=None,
|
|
1650
|
+
)
|
|
1651
|
+
except Exception as exc: # noqa: BLE001 — best-effort backstop
|
|
1652
|
+
click.echo(
|
|
1653
|
+
f" warning: backstop failed to record milestone-chat exit: {exc}",
|
|
1654
|
+
err=True,
|
|
1655
|
+
)
|
|
1656
|
+
return
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
def _run_troubleshoot_or_chat(
|
|
1660
|
+
is_chat: bool,
|
|
1661
|
+
*,
|
|
1662
|
+
machine: str,
|
|
1663
|
+
repo: str,
|
|
1664
|
+
issue: int,
|
|
1665
|
+
briefing: str,
|
|
1666
|
+
briefing_file: str | None,
|
|
1667
|
+
model: str | None,
|
|
1668
|
+
dry_run: bool,
|
|
1669
|
+
cfg: Config,
|
|
1670
|
+
machine_obj: object,
|
|
1671
|
+
repo_cfg: object,
|
|
1672
|
+
issue_title: str,
|
|
1673
|
+
provider: object,
|
|
1674
|
+
_is_local: bool,
|
|
1675
|
+
_issue_ctx: str,
|
|
1676
|
+
_ctx_write_hint: str,
|
|
1677
|
+
) -> None:
|
|
1678
|
+
import time as _time # noqa: PLC0415
|
|
1679
|
+
import uuid as _uuid # noqa: PLC0415
|
|
1680
|
+
|
|
1681
|
+
from coord.interactive import ( # noqa: PLC0415
|
|
1682
|
+
finalize_interactive_exit,
|
|
1683
|
+
launch_human_attended_interactive,
|
|
1684
|
+
tmux_available as _tmux_avail,
|
|
1685
|
+
tmux_session_alive as _tmux_alive,
|
|
1686
|
+
tmux_session_name as _tmux_name,
|
|
1687
|
+
)
|
|
1688
|
+
|
|
1689
|
+
from coord.board_service import read_board as _read_board_ts # noqa: PLC0415
|
|
1690
|
+
from coord.board_service import write_board as _write_board_ts # noqa: PLC0415
|
|
1691
|
+
from coord.models import Assignment as _AssignmentTs # noqa: PLC0415
|
|
1692
|
+
from coord.state import ( # noqa: PLC0415
|
|
1693
|
+
record_dispatched_assignment as _record_ts,
|
|
1694
|
+
)
|
|
1695
|
+
from coord.agent import AssignmentSpec as _AssignmentSpecTs # noqa: PLC0415
|
|
1696
|
+
|
|
1697
|
+
# #628: --chat shares troubleshoot's shape (human-attended, live
|
|
1698
|
+
# checkout, no claim/worktree, briefed-from-file, finalize
|
|
1699
|
+
# worktree_path=None). It differs only in the system prompt/seed
|
|
1700
|
+
# (general Q&A + may EDIT the issue via coord) and type=chat.
|
|
1701
|
+
_is_chat = is_chat
|
|
1702
|
+
_mode = "chat" if _is_chat else "troubleshoot"
|
|
1703
|
+
if not _is_chat and not (briefing or "").strip():
|
|
1704
|
+
click.echo(
|
|
1705
|
+
"error: --troubleshoot requires a briefing "
|
|
1706
|
+
"(--briefing or --briefing-file).",
|
|
1707
|
+
err=True,
|
|
1708
|
+
)
|
|
1709
|
+
sys.exit(2)
|
|
1710
|
+
if _is_chat and not (briefing or "").strip():
|
|
1711
|
+
# The TUI seeds a rich briefing; a bare CLI --chat gets a minimal
|
|
1712
|
+
# one so the session still knows what issue it's about.
|
|
1713
|
+
briefing = (
|
|
1714
|
+
f"Chat about {repo} #{issue}. Gather any context you need with "
|
|
1715
|
+
f"`gh issue view {issue} --comments` and `coord` reads."
|
|
1716
|
+
)
|
|
1717
|
+
if not _is_local:
|
|
1718
|
+
click.echo(f"error: --{_mode} is local-only for now.", err=True)
|
|
1719
|
+
sys.exit(2)
|
|
1720
|
+
|
|
1721
|
+
ts_repo_path = str(
|
|
1722
|
+
Path(machine_obj.repo_path(repo) or str(Path.cwd())).expanduser()
|
|
1723
|
+
)
|
|
1724
|
+
ts_default_branch = repo_cfg.default_branch or "main"
|
|
1725
|
+
resolved_model = model if model else cfg.models.default
|
|
1726
|
+
assignment_id = _uuid.uuid4().hex[:12]
|
|
1727
|
+
|
|
1728
|
+
if _is_chat:
|
|
1729
|
+
_ts_system_prompt = (
|
|
1730
|
+
"You are a coordinator assistant in a HUMAN-ATTENDED 'chat "
|
|
1731
|
+
"about this issue' session. Help the operator think it "
|
|
1732
|
+
"through: answer open questions (is it still needed? what "
|
|
1733
|
+
"milestone? scope?), sketch designs/UX, and diagnose a stalled "
|
|
1734
|
+
"pipeline item. You MAY update the issue itself with `coord "
|
|
1735
|
+
"issue edit <repo> <issue> --title/--body-file` and send it to "
|
|
1736
|
+
"the Pending stage with `coord ready <repo> <issue>` — confirm "
|
|
1737
|
+
"with the operator before each write. Do NOT edit files in this "
|
|
1738
|
+
"live checkout, do NOT commit, and do NOT run `gh` to mutate — "
|
|
1739
|
+
"go through `coord` so the tracker stays behind the seam. "
|
|
1740
|
+
"Type /update-issue at any point to synthesize what we agreed "
|
|
1741
|
+
"and write it back to the issue body. "
|
|
1742
|
+
"CRITICAL — this session is diagnostic-only: do NOT run "
|
|
1743
|
+
"`coord report-result --status done` or `--status blocked` "
|
|
1744
|
+
"(you have no committed work to back a success claim, and doing "
|
|
1745
|
+
"so leaves a false-done box on the pipeline). "
|
|
1746
|
+
"For a stalled item, start with `coord diagnose <repo> <issue>` "
|
|
1747
|
+
"— it auto-recovers phantoms, orphaned worktrees, and dropped "
|
|
1748
|
+
"findings; confirm any --reset with the operator first."
|
|
1749
|
+
)
|
|
1750
|
+
ts_reminder = (
|
|
1751
|
+
f"[Coordinator chat assignment {assignment_id}] HUMAN-ATTENDED "
|
|
1752
|
+
"chat about this issue. You may edit the issue (`coord issue "
|
|
1753
|
+
"edit`) and send it to Pending (`coord ready`) — confirm first. "
|
|
1754
|
+
"Type /update-issue to synthesize what we agreed and write it "
|
|
1755
|
+
"back to the issue body. "
|
|
1756
|
+
"You are in the LIVE checkout: do NOT modify files or commit "
|
|
1757
|
+
"here (it is the editable coordinator + worker-worktree base); "
|
|
1758
|
+
"for a code change, surface a plan so the operator can dispatch "
|
|
1759
|
+
"Work. "
|
|
1760
|
+
"IMPORTANT: do NOT run `coord report-result --status done` — "
|
|
1761
|
+
"this session has no committed work and claiming done would "
|
|
1762
|
+
"leave a false-good-to-go box on the pipeline (#676).\n\n"
|
|
1763
|
+
)
|
|
1764
|
+
else:
|
|
1765
|
+
_ts_system_prompt = (
|
|
1766
|
+
"You are a coordinator troubleshooter in a HUMAN-ATTENDED "
|
|
1767
|
+
"session. You are READ-ONLY in a live checkout: do NOT modify "
|
|
1768
|
+
"files, do NOT commit, do NOT run `gh`. Investigate the stalled "
|
|
1769
|
+
"pipeline item using coord, git, and sqlite3 reads; explain "
|
|
1770
|
+
"what is wrong and what will unstick it; and surface any plan "
|
|
1771
|
+
"for the operator to approve before mutating anything. "
|
|
1772
|
+
"Start with `coord diagnose <repo> <issue>` — it auto-recovers "
|
|
1773
|
+
"phantoms, orphaned worktrees, and dropped findings; confirm "
|
|
1774
|
+
"any --reset with the operator first. "
|
|
1775
|
+
"CRITICAL: do NOT run `coord report-result --status done` or "
|
|
1776
|
+
"`--status blocked` — this session is diagnostic-only with no "
|
|
1777
|
+
"committed work, and claiming done leaves a false status on the "
|
|
1778
|
+
"pipeline (#676)."
|
|
1779
|
+
)
|
|
1780
|
+
ts_reminder = (
|
|
1781
|
+
f"[Coordinator troubleshoot assignment {assignment_id}] "
|
|
1782
|
+
"HUMAN-ATTENDED, READ-ONLY diagnostic for a stalled pipeline "
|
|
1783
|
+
"item. You are in the LIVE checkout — do NOT modify files here "
|
|
1784
|
+
"(it is the editable coordinator and the worker-worktree base). "
|
|
1785
|
+
"If a code fix is needed, surface the plan so the operator can "
|
|
1786
|
+
"dispatch a proper Fix. "
|
|
1787
|
+
"IMPORTANT: do NOT run `coord report-result --status done` — "
|
|
1788
|
+
"this session has no committed work (#676).\n\n"
|
|
1789
|
+
)
|
|
1790
|
+
effective_briefing = _issue_ctx + ts_reminder + briefing + _ctx_write_hint
|
|
1791
|
+
|
|
1792
|
+
# Pre-fill a SHORT, single-line prompt that points the session at
|
|
1793
|
+
# the full diagnostic on disk, rather than pasting the whole
|
|
1794
|
+
# multi-line briefing into the input box. A short paste lands
|
|
1795
|
+
# reliably; a multi-KB multi-line paste over the embedded-terminal /
|
|
1796
|
+
# nested-tmux path often is dropped by the readiness poll
|
|
1797
|
+
# (interactive._inject_briefing_into_tmux_session is best-effort).
|
|
1798
|
+
# And it degrades gracefully — if the paste is missed, the operator
|
|
1799
|
+
# can type the one short line by hand instead of being stranded with
|
|
1800
|
+
# no context. The full briefing still lives in the file and on the
|
|
1801
|
+
# assignment row.
|
|
1802
|
+
if briefing_file:
|
|
1803
|
+
_ts_brief_path = str(Path(briefing_file).expanduser())
|
|
1804
|
+
else:
|
|
1805
|
+
import tempfile as _tempfile # noqa: PLC0415
|
|
1806
|
+
|
|
1807
|
+
_ts_brief_path = str(
|
|
1808
|
+
Path(_tempfile.gettempdir()) / f"coord-{_mode}-{issue}.md"
|
|
1809
|
+
)
|
|
1810
|
+
Path(_ts_brief_path).write_text(effective_briefing, encoding="utf-8")
|
|
1811
|
+
if _is_chat:
|
|
1812
|
+
seed_prompt = (
|
|
1813
|
+
f"Chat about {repo} #{issue}: read the full context at "
|
|
1814
|
+
f"{_ts_brief_path} (the issue, comments, and board state), then "
|
|
1815
|
+
"let's talk it through. Ask me what I want — questions about "
|
|
1816
|
+
"scope/milestone/whether it's still needed, a UX sketch, or "
|
|
1817
|
+
"diagnosing a stall. You can update the issue with `coord issue "
|
|
1818
|
+
"edit` and send it to Pending with `coord ready` once we've "
|
|
1819
|
+
"settled it — just confirm with me first."
|
|
1820
|
+
)
|
|
1821
|
+
else:
|
|
1822
|
+
seed_prompt = (
|
|
1823
|
+
f"Troubleshoot {repo} #{issue}: read the diagnostic briefing at "
|
|
1824
|
+
f"{_ts_brief_path} (board state, assignments, merge-queue, CI, "
|
|
1825
|
+
"and a playbook of likely causes), then tell me what's wrong and "
|
|
1826
|
+
"the options to unstick it. You are read-only — do not modify "
|
|
1827
|
+
"files, commit, or run gh; surface any fix plan for me to "
|
|
1828
|
+
"approve."
|
|
1829
|
+
)
|
|
1830
|
+
|
|
1831
|
+
spec = _AssignmentSpecTs(
|
|
1832
|
+
repo_name=repo,
|
|
1833
|
+
repo_path=ts_repo_path,
|
|
1834
|
+
issue_number=issue,
|
|
1835
|
+
issue_title=f"[{_mode}] {issue_title}",
|
|
1836
|
+
briefing=effective_briefing,
|
|
1837
|
+
model=resolved_model,
|
|
1838
|
+
type=_mode,
|
|
1839
|
+
provider="claude-pty",
|
|
1840
|
+
)
|
|
1841
|
+
# No Edit/Write — the live checkout must not be mutated. Bash carries
|
|
1842
|
+
# coord/git/sqlite3 reads (and, for chat, the `coord issue edit` /
|
|
1843
|
+
# `coord ready` ISSUE writes); Read/Grep/Glob for inspection.
|
|
1844
|
+
argv = provider.build_command(
|
|
1845
|
+
spec,
|
|
1846
|
+
resolved_model=resolved_model,
|
|
1847
|
+
system_prompt=_ts_system_prompt,
|
|
1848
|
+
allowed_tools="Read,Bash,Grep,Glob",
|
|
1849
|
+
)
|
|
1850
|
+
|
|
1851
|
+
click.echo(
|
|
1852
|
+
f"{machine} (local TTY) → {_mode.upper()} #{issue}: {issue_title}"
|
|
1853
|
+
)
|
|
1854
|
+
click.echo(
|
|
1855
|
+
" mode: HUMAN-ATTENDED interactive "
|
|
1856
|
+
+ ("chat about the issue " if _is_chat else "diagnostic ")
|
|
1857
|
+
+ "(live checkout, no claim, no worktree) "
|
|
1858
|
+
+ ("(#628)" if _is_chat else "(#569)")
|
|
1859
|
+
)
|
|
1860
|
+
click.echo(f" assignment id: {assignment_id}")
|
|
1861
|
+
click.echo(f" cwd: {ts_repo_path} (live checkout — read-only)")
|
|
1862
|
+
if dry_run:
|
|
1863
|
+
click.echo(" (dry run — not launched)")
|
|
1864
|
+
click.echo(f" would exec: {argv}")
|
|
1865
|
+
return
|
|
1866
|
+
|
|
1867
|
+
ts_assignment = _AssignmentTs(
|
|
1868
|
+
machine_name=machine,
|
|
1869
|
+
repo_name=repo,
|
|
1870
|
+
issue_number=issue,
|
|
1871
|
+
issue_title=f"[{_mode}] {issue_title}",
|
|
1872
|
+
briefing=effective_briefing,
|
|
1873
|
+
assignment_id=assignment_id,
|
|
1874
|
+
status="running",
|
|
1875
|
+
dispatched_at=_time.time(),
|
|
1876
|
+
type=_mode,
|
|
1877
|
+
model=resolved_model,
|
|
1878
|
+
provider_name="claude-pty",
|
|
1879
|
+
)
|
|
1880
|
+
_record_ts(assignment=ts_assignment, repo_github=repo_cfg.github)
|
|
1881
|
+
# #749: previously unconditional local build_board/save_board — this
|
|
1882
|
+
# function (unlike the --x-of variants) never had thin-client awareness,
|
|
1883
|
+
# so it silently wrote board_meta to a non-canonical local DB.
|
|
1884
|
+
_write_board_ts(_read_board_ts())
|
|
1885
|
+
os.environ["COORD_ASSIGNMENT_ID"] = assignment_id
|
|
1886
|
+
|
|
1887
|
+
started_at = _time.time()
|
|
1888
|
+
# Pre-fill the SHORT seed prompt (not the full briefing) — see the
|
|
1889
|
+
# seed_prompt rationale above.
|
|
1890
|
+
exit_code = launch_human_attended_interactive(
|
|
1891
|
+
argv,
|
|
1892
|
+
seed_prompt,
|
|
1893
|
+
assignment_id=assignment_id,
|
|
1894
|
+
cwd=ts_repo_path,
|
|
1895
|
+
)
|
|
1896
|
+
if exit_code != 0:
|
|
1897
|
+
click.echo(f" claude exited with status {exit_code}", err=True)
|
|
1898
|
+
|
|
1899
|
+
_sname = _tmux_name(assignment_id) if _tmux_avail() else None
|
|
1900
|
+
if _sname and _tmux_alive(_sname):
|
|
1901
|
+
click.echo(
|
|
1902
|
+
f" session still running in tmux: {_sname}\n"
|
|
1903
|
+
f" reattach with: coord reattach {assignment_id}"
|
|
1904
|
+
)
|
|
1905
|
+
sys.exit(0)
|
|
1906
|
+
|
|
1907
|
+
# finalize with worktree_path=None — read-only, never push/remove the
|
|
1908
|
+
# live checkout; the git-floor backstop just records a terminal row.
|
|
1909
|
+
try:
|
|
1910
|
+
finalize_interactive_exit(
|
|
1911
|
+
assignment_id=assignment_id,
|
|
1912
|
+
repo_name=repo,
|
|
1913
|
+
repo_github=repo_cfg.github,
|
|
1914
|
+
issue_number=issue,
|
|
1915
|
+
machine_name=machine,
|
|
1916
|
+
worktree_path=None,
|
|
1917
|
+
base_branch=ts_default_branch,
|
|
1918
|
+
exit_code=exit_code,
|
|
1919
|
+
started_at=started_at,
|
|
1920
|
+
log_path=None,
|
|
1921
|
+
repo_path=None,
|
|
1922
|
+
)
|
|
1923
|
+
except Exception as exc: # noqa: BLE001 — best-effort backstop
|
|
1924
|
+
click.echo(
|
|
1925
|
+
f" warning: backstop failed to record troubleshoot exit: {exc}",
|
|
1926
|
+
err=True,
|
|
1927
|
+
)
|
|
1928
|
+
return
|
|
1929
|
+
|
|
1930
|
+
|
|
1931
|
+
def _dispatch_troubleshoot(
|
|
1932
|
+
*,
|
|
1933
|
+
machine: str,
|
|
1934
|
+
repo: str,
|
|
1935
|
+
issue: int,
|
|
1936
|
+
briefing: str,
|
|
1937
|
+
briefing_file: str | None,
|
|
1938
|
+
model: str | None,
|
|
1939
|
+
dry_run: bool,
|
|
1940
|
+
cfg: Config,
|
|
1941
|
+
machine_obj: object,
|
|
1942
|
+
repo_cfg: object,
|
|
1943
|
+
issue_title: str,
|
|
1944
|
+
provider: object,
|
|
1945
|
+
_is_local: bool,
|
|
1946
|
+
_issue_ctx: str,
|
|
1947
|
+
_ctx_write_hint: str,
|
|
1948
|
+
) -> None:
|
|
1949
|
+
"""#569: human-attended, READ-ONLY diagnostic interactive session."""
|
|
1950
|
+
_run_troubleshoot_or_chat(
|
|
1951
|
+
is_chat=False,
|
|
1952
|
+
machine=machine,
|
|
1953
|
+
repo=repo,
|
|
1954
|
+
issue=issue,
|
|
1955
|
+
briefing=briefing,
|
|
1956
|
+
briefing_file=briefing_file,
|
|
1957
|
+
model=model,
|
|
1958
|
+
dry_run=dry_run,
|
|
1959
|
+
cfg=cfg,
|
|
1960
|
+
machine_obj=machine_obj,
|
|
1961
|
+
repo_cfg=repo_cfg,
|
|
1962
|
+
issue_title=issue_title,
|
|
1963
|
+
provider=provider,
|
|
1964
|
+
_is_local=_is_local,
|
|
1965
|
+
_issue_ctx=_issue_ctx,
|
|
1966
|
+
_ctx_write_hint=_ctx_write_hint,
|
|
1967
|
+
)
|
|
1968
|
+
|
|
1969
|
+
|
|
1970
|
+
def _dispatch_chat(
|
|
1971
|
+
*,
|
|
1972
|
+
machine: str,
|
|
1973
|
+
repo: str,
|
|
1974
|
+
issue: int,
|
|
1975
|
+
briefing: str,
|
|
1976
|
+
briefing_file: str | None,
|
|
1977
|
+
model: str | None,
|
|
1978
|
+
dry_run: bool,
|
|
1979
|
+
cfg: Config,
|
|
1980
|
+
machine_obj: object,
|
|
1981
|
+
repo_cfg: object,
|
|
1982
|
+
issue_title: str,
|
|
1983
|
+
provider: object,
|
|
1984
|
+
_is_local: bool,
|
|
1985
|
+
_issue_ctx: str,
|
|
1986
|
+
_ctx_write_hint: str,
|
|
1987
|
+
) -> None:
|
|
1988
|
+
"""#628: human-attended 'Chat about issue' interactive session."""
|
|
1989
|
+
_run_troubleshoot_or_chat(
|
|
1990
|
+
is_chat=True,
|
|
1991
|
+
machine=machine,
|
|
1992
|
+
repo=repo,
|
|
1993
|
+
issue=issue,
|
|
1994
|
+
briefing=briefing,
|
|
1995
|
+
briefing_file=briefing_file,
|
|
1996
|
+
model=model,
|
|
1997
|
+
dry_run=dry_run,
|
|
1998
|
+
cfg=cfg,
|
|
1999
|
+
machine_obj=machine_obj,
|
|
2000
|
+
repo_cfg=repo_cfg,
|
|
2001
|
+
issue_title=issue_title,
|
|
2002
|
+
provider=provider,
|
|
2003
|
+
_is_local=_is_local,
|
|
2004
|
+
_issue_ctx=_issue_ctx,
|
|
2005
|
+
_ctx_write_hint=_ctx_write_hint,
|
|
2006
|
+
)
|
|
2007
|
+
|
|
2008
|
+
|
|
2009
|
+
def _dispatch_fix_of(
|
|
2010
|
+
*,
|
|
2011
|
+
machine: str,
|
|
2012
|
+
repo: str,
|
|
2013
|
+
issue: int,
|
|
2014
|
+
briefing: str,
|
|
2015
|
+
model: str | None,
|
|
2016
|
+
dry_run: bool,
|
|
2017
|
+
force: bool,
|
|
2018
|
+
fix_of: str,
|
|
2019
|
+
cfg: Config,
|
|
2020
|
+
machine_obj: object,
|
|
2021
|
+
repo_cfg: object,
|
|
2022
|
+
issue_title: str,
|
|
2023
|
+
provider: object,
|
|
2024
|
+
_is_local: bool,
|
|
2025
|
+
_svc: object,
|
|
2026
|
+
_interactive_board: object,
|
|
2027
|
+
_issue_ctx: str,
|
|
2028
|
+
_ctx_write_hint: str,
|
|
2029
|
+
) -> None:
|
|
2030
|
+
"""Leg 3 (#517): human-attended FIX for a review whose verdict was request-changes (also accepts a failed-test work id, #581)."""
|
|
2031
|
+
import time as _time # noqa: PLC0415
|
|
2032
|
+
import uuid as _uuid # noqa: PLC0415
|
|
2033
|
+
|
|
2034
|
+
from coord.interactive import ( # noqa: PLC0415
|
|
2035
|
+
TmuxHost,
|
|
2036
|
+
_holder_is_base_checkout as _holder_is_base,
|
|
2037
|
+
_launch_via_tmux as _tmux_launch,
|
|
2038
|
+
_remote_base_checkout_free_branch as _remote_free_base,
|
|
2039
|
+
_remote_orphan_is_safe_to_prune as _remote_orphan_safe,
|
|
2040
|
+
_remote_worktree_remove as _remote_wt_remove,
|
|
2041
|
+
find_remote_branch_holder as _find_branch_holder,
|
|
2042
|
+
finalize_interactive_exit,
|
|
2043
|
+
finalize_remote_interactive_exit,
|
|
2044
|
+
launch_human_attended_interactive,
|
|
2045
|
+
remote_worktree_exists as _remote_wt_exists,
|
|
2046
|
+
tmux_available as _tmux_avail,
|
|
2047
|
+
tmux_session_alive as _tmux_alive,
|
|
2048
|
+
tmux_session_name as _tmux_name,
|
|
2049
|
+
)
|
|
2050
|
+
|
|
2051
|
+
from coord.auto_loop import ( # noqa: PLC0415
|
|
2052
|
+
_build_fix_briefing,
|
|
2053
|
+
_fix_model_for_iteration,
|
|
2054
|
+
_load_review_findings,
|
|
2055
|
+
)
|
|
2056
|
+
from coord.agent import ( # noqa: PLC0415
|
|
2057
|
+
AssignmentSpec as _AssignmentSpecFx,
|
|
2058
|
+
_GitError as _AgentGitErrorFx,
|
|
2059
|
+
narrow_artifact_paths as _narrow_ap_fx,
|
|
2060
|
+
setup_interactive_worktree as _setup_wt_fx,
|
|
2061
|
+
)
|
|
2062
|
+
from coord.models import Assignment as _AssignmentFx # noqa: PLC0415
|
|
2063
|
+
from coord.state import ( # noqa: PLC0415
|
|
2064
|
+
COORD_DIR as _COORD_DIR_FX,
|
|
2065
|
+
build_board as _build_board_fx,
|
|
2066
|
+
record_dispatched_assignment as _record_fx,
|
|
2067
|
+
save_board as _save_board_fx,
|
|
2068
|
+
set_assignment_failure_reason as _set_fail_reason_fx,
|
|
2069
|
+
)
|
|
2070
|
+
|
|
2071
|
+
# Track B / #486: a fix runs on the LOCAL TTY or on a REMOTE
|
|
2072
|
+
# machine over ssh+tmux. Unlike review, a fix WRITES — it needs a
|
|
2073
|
+
# worktree on the EXISTING branch and its commits must be pushed
|
|
2074
|
+
# back to origin (the #486d push-back, via
|
|
2075
|
+
# finalize_remote_interactive_exit; the local finalize only sees
|
|
2076
|
+
# the local filesystem).
|
|
2077
|
+
_fx_board = _interactive_board(_build_board_fx)
|
|
2078
|
+
review = _fx_board.find_by_id(fix_of)
|
|
2079
|
+
if review is None:
|
|
2080
|
+
click.echo(
|
|
2081
|
+
f"error: --fix-of {fix_of}: no such assignment on the board.",
|
|
2082
|
+
err=True,
|
|
2083
|
+
)
|
|
2084
|
+
sys.exit(2)
|
|
2085
|
+
# Two accepted shapes for --fix-of (#581):
|
|
2086
|
+
# (a) a REVIEW assignment whose verdict was request-changes — the
|
|
2087
|
+
# original leg-3a path; work = review.review_of_assignment_id,
|
|
2088
|
+
# findings = the reviewer's findings.
|
|
2089
|
+
# (b) a WORK assignment whose Test gate FAILED — the test-fail fix
|
|
2090
|
+
# front door; work = the target itself, findings = the recorded
|
|
2091
|
+
# test-failure story (test_reason).
|
|
2092
|
+
_fix_from_test_fail = (
|
|
2093
|
+
review.type != "review"
|
|
2094
|
+
and (getattr(review, "test_state", None) == "failed")
|
|
2095
|
+
)
|
|
2096
|
+
if review.type != "review" and not _fix_from_test_fail:
|
|
2097
|
+
click.echo(
|
|
2098
|
+
f"error: --fix-of {fix_of} is type={review.type!r} with "
|
|
2099
|
+
f"test_state={getattr(review, 'test_state', None)!r}. Pass "
|
|
2100
|
+
"either a REVIEW id whose verdict was request-changes, or a "
|
|
2101
|
+
"WORK id whose Test gate failed.",
|
|
2102
|
+
err=True,
|
|
2103
|
+
)
|
|
2104
|
+
sys.exit(2)
|
|
2105
|
+
if _fix_from_test_fail:
|
|
2106
|
+
work = review # the failed work row IS the thing to fix
|
|
2107
|
+
else:
|
|
2108
|
+
work = (
|
|
2109
|
+
_fx_board.find_by_id(review.review_of_assignment_id)
|
|
2110
|
+
if review.review_of_assignment_id
|
|
2111
|
+
else None
|
|
2112
|
+
)
|
|
2113
|
+
if work is None:
|
|
2114
|
+
click.echo(
|
|
2115
|
+
f"error: review {fix_of} has no linked work assignment "
|
|
2116
|
+
"(review_of_assignment_id is unset).",
|
|
2117
|
+
err=True,
|
|
2118
|
+
)
|
|
2119
|
+
sys.exit(2)
|
|
2120
|
+
if not work.branch:
|
|
2121
|
+
click.echo(
|
|
2122
|
+
f"error: work assignment {work.assignment_id} has no branch "
|
|
2123
|
+
"to fix.",
|
|
2124
|
+
err=True,
|
|
2125
|
+
)
|
|
2126
|
+
sys.exit(2)
|
|
2127
|
+
|
|
2128
|
+
# Iteration accounting mirrors the auto-loop fix path so the merge
|
|
2129
|
+
# gate and the next review see an identical work→fix→review chain.
|
|
2130
|
+
next_iteration = (work.review_iteration or 0) + 1
|
|
2131
|
+
max_iter = cfg.pipeline.max_review_iterations
|
|
2132
|
+
if next_iteration > max_iter:
|
|
2133
|
+
if force:
|
|
2134
|
+
# #855-follow-up: intractable stories (e.g. vimcode#515) legitimately
|
|
2135
|
+
# need more than the cap. --force is the operator's explicit "I know,
|
|
2136
|
+
# keep going" override so a hard stop isn't the only exit.
|
|
2137
|
+
click.echo(
|
|
2138
|
+
f"warning: max_review_iterations ({max_iter}) reached for "
|
|
2139
|
+
f"work {work.assignment_id}; dispatching iteration "
|
|
2140
|
+
f"{next_iteration} anyway (--force).",
|
|
2141
|
+
err=True,
|
|
2142
|
+
)
|
|
2143
|
+
else:
|
|
2144
|
+
click.echo(
|
|
2145
|
+
f"error: max_review_iterations ({max_iter}) reached for "
|
|
2146
|
+
f"work {work.assignment_id}; not dispatching another fix. "
|
|
2147
|
+
"Re-run with --force to override for this dispatch, or bump "
|
|
2148
|
+
"pipeline.max_review_iterations in coordinator.yml.",
|
|
2149
|
+
err=True,
|
|
2150
|
+
)
|
|
2151
|
+
sys.exit(2)
|
|
2152
|
+
|
|
2153
|
+
# Findings: reuse the SAME loader the claude -p fix path uses (DB
|
|
2154
|
+
# cache → log → agent). Local-only ⇒ no machine_host. Fall back to
|
|
2155
|
+
# a pointer-to-the-review brief when nothing structured was captured
|
|
2156
|
+
# (interactive reviews may report only a one-line verdict summary).
|
|
2157
|
+
if _fix_from_test_fail:
|
|
2158
|
+
# The findings ARE the recorded test-failure story (#581). No
|
|
2159
|
+
# reviewer log to consult — the operator's `coord test --fail
|
|
2160
|
+
# --reason` text is what the fix worker needs.
|
|
2161
|
+
# #1337: the board wire carries only a bounded PREVIEW of test_reason;
|
|
2162
|
+
# the briefing quotes it verbatim, so read the full text through the
|
|
2163
|
+
# detail endpoint (falls back to the board-carried value).
|
|
2164
|
+
from coord.state import load_assignment_test_reason as _load_tr # noqa: PLC0415
|
|
2165
|
+
|
|
2166
|
+
_test_story = (
|
|
2167
|
+
_load_tr(work.assignment_id or "")
|
|
2168
|
+
or getattr(work, "test_reason", None)
|
|
2169
|
+
or ""
|
|
2170
|
+
).strip()
|
|
2171
|
+
_findings_body = (
|
|
2172
|
+
"The manual smoke test FAILED. The operator reported:\n\n"
|
|
2173
|
+
f"> {_test_story}\n\n"
|
|
2174
|
+
"Reproduce the failure, fix the root cause, and re-validate "
|
|
2175
|
+
"before pushing."
|
|
2176
|
+
if _test_story
|
|
2177
|
+
else (
|
|
2178
|
+
"The manual smoke test FAILED (no reason text was "
|
|
2179
|
+
"recorded). Pull the branch, reproduce the failure the "
|
|
2180
|
+
"operator hit, and fix the root cause before pushing."
|
|
2181
|
+
)
|
|
2182
|
+
)
|
|
2183
|
+
else:
|
|
2184
|
+
_fx_log = _COORD_DIR_FX / "logs" / f"{fix_of}.log"
|
|
2185
|
+
_fx_log_path = str(_fx_log) if _fx_log.exists() else None
|
|
2186
|
+
try:
|
|
2187
|
+
findings = _load_review_findings(
|
|
2188
|
+
review, _fx_log_path, None, repo_github=repo_cfg.github,
|
|
2189
|
+
)
|
|
2190
|
+
except Exception: # noqa: BLE001 — best-effort; fall back below
|
|
2191
|
+
findings = None
|
|
2192
|
+
if findings is not None and (getattr(findings, "body", "") or "").strip():
|
|
2193
|
+
_findings_body = findings.body.strip()
|
|
2194
|
+
else:
|
|
2195
|
+
_findings_body = (
|
|
2196
|
+
f"(No structured findings were captured for review {fix_of}.) "
|
|
2197
|
+
f"The review verdict was {review.review_verdict or 'request-changes'!r}. "
|
|
2198
|
+
"Read the reviewer's feedback on the PR / issue and address "
|
|
2199
|
+
"every blocking item before pushing."
|
|
2200
|
+
)
|
|
2201
|
+
from types import SimpleNamespace as _SNS # noqa: PLC0415
|
|
2202
|
+
fix_briefing = _build_fix_briefing(
|
|
2203
|
+
work, _SNS(body=_findings_body), next_iteration, max_iter,
|
|
2204
|
+
)
|
|
2205
|
+
|
|
2206
|
+
# #803: escalate the model per fix iteration, mirroring the headless
|
|
2207
|
+
# auto-loop path. Explicit --model always wins; when omitted,
|
|
2208
|
+
# _fix_model_for_iteration returns the appropriate tier (or None when
|
|
2209
|
+
# pipeline.escalate_fix_model=False), falling back to cfg.models.default.
|
|
2210
|
+
resolved_model = (
|
|
2211
|
+
model
|
|
2212
|
+
or _fix_model_for_iteration(cfg, next_iteration)
|
|
2213
|
+
or cfg.models.default
|
|
2214
|
+
)
|
|
2215
|
+
assignment_id = _uuid.uuid4().hex[:12]
|
|
2216
|
+
if _is_local:
|
|
2217
|
+
fix_repo_path = str(
|
|
2218
|
+
Path(machine_obj.repo_path(repo) or str(Path.cwd())).expanduser()
|
|
2219
|
+
)
|
|
2220
|
+
else:
|
|
2221
|
+
fix_repo_path = machine_obj.repo_path(repo) or f"~/src/{repo}"
|
|
2222
|
+
fix_default_branch = repo_cfg.default_branch or "main"
|
|
2223
|
+
|
|
2224
|
+
os.environ["COORD_ASSIGNMENT_ID"] = assignment_id
|
|
2225
|
+
if _is_local:
|
|
2226
|
+
report_reminder = (
|
|
2227
|
+
f"[Coordinator fix assignment {assignment_id}] HUMAN-ATTENDED "
|
|
2228
|
+
f"fix iteration {next_iteration}/{max_iter} on branch "
|
|
2229
|
+
f"{work.branch}. Before you exit, run `coord report-result "
|
|
2230
|
+
f"--assignment {assignment_id} --status done --summary <text>` "
|
|
2231
|
+
"so the coordinator records the result and can re-review.\n\n"
|
|
2232
|
+
)
|
|
2233
|
+
else:
|
|
2234
|
+
report_reminder = (
|
|
2235
|
+
f"[Coordinator fix assignment {assignment_id}] HUMAN-ATTENDED "
|
|
2236
|
+
f"fix iteration {next_iteration}/{max_iter} on branch "
|
|
2237
|
+
f"{work.branch}, running on a REMOTE machine. Make your "
|
|
2238
|
+
"changes and COMMIT them. Before you exit, run `coord "
|
|
2239
|
+
f"report-result --assignment {assignment_id} --status done "
|
|
2240
|
+
"--summary <text>` — since #590 it routes to the "
|
|
2241
|
+
"coordinator's shared board, so the result is recorded from "
|
|
2242
|
+
"here. Do NOT run any `gh` commands. When you exit, the "
|
|
2243
|
+
f"coordinator also pushes your commits to origin/{work.branch} "
|
|
2244
|
+
"and re-reviews.\n\n"
|
|
2245
|
+
)
|
|
2246
|
+
effective_briefing = _issue_ctx + report_reminder + fix_briefing + _ctx_write_hint
|
|
2247
|
+
|
|
2248
|
+
spec = _AssignmentSpecFx(
|
|
2249
|
+
repo_name=repo,
|
|
2250
|
+
repo_path=fix_repo_path,
|
|
2251
|
+
issue_number=issue,
|
|
2252
|
+
issue_title=f"[fix-{next_iteration}] {issue_title}",
|
|
2253
|
+
briefing=effective_briefing,
|
|
2254
|
+
model=resolved_model,
|
|
2255
|
+
type="work",
|
|
2256
|
+
provider="claude-pty",
|
|
2257
|
+
)
|
|
2258
|
+
# type="work" ⇒ default worker tool set (Read/Edit/Write/Bash): the
|
|
2259
|
+
# fix session must be able to mutate the checkout, unlike --review-of.
|
|
2260
|
+
argv = provider.build_command(spec, resolved_model=resolved_model)
|
|
2261
|
+
# Remote: bare "claude" isn't on the SSH login PATH (#424/#425).
|
|
2262
|
+
if not _is_local:
|
|
2263
|
+
argv = ["~/.local/bin/claude"] + list(argv)[1:]
|
|
2264
|
+
|
|
2265
|
+
_fx_location = (
|
|
2266
|
+
"local TTY" if _is_local
|
|
2267
|
+
else f"{machine_obj.host} (remote tmux)"
|
|
2268
|
+
)
|
|
2269
|
+
click.echo(
|
|
2270
|
+
f"{machine} ({_fx_location}) → FIX of #{issue} "
|
|
2271
|
+
f"(iteration {next_iteration}/{max_iter}) on branch {work.branch}"
|
|
2272
|
+
)
|
|
2273
|
+
click.echo(
|
|
2274
|
+
" mode: HUMAN-ATTENDED interactive fix "
|
|
2275
|
+
"(migration leg 3 / Track B #486)"
|
|
2276
|
+
)
|
|
2277
|
+
# #803: show the escalated model so an opus escalation is visible.
|
|
2278
|
+
_explicit = "(explicit --model override)" if model else "(auto-selected)"
|
|
2279
|
+
click.echo(f" model: {resolved_model} {_explicit}")
|
|
2280
|
+
click.echo(
|
|
2281
|
+
f" assignment id: {assignment_id} (fix_of={fix_of}, "
|
|
2282
|
+
f"work={work.assignment_id})"
|
|
2283
|
+
)
|
|
2284
|
+
if dry_run:
|
|
2285
|
+
click.echo(" (dry run — not launched)")
|
|
2286
|
+
click.echo(f" would continue branch: {work.branch}")
|
|
2287
|
+
if not _is_local:
|
|
2288
|
+
click.echo(
|
|
2289
|
+
f" remote worktree: $HOME/.coord/worktrees/{assignment_id}"
|
|
2290
|
+
f" on {machine_obj.host} (branch: {work.branch})"
|
|
2291
|
+
)
|
|
2292
|
+
click.echo(f" would exec: {argv}")
|
|
2293
|
+
return
|
|
2294
|
+
|
|
2295
|
+
fix_assignment = _AssignmentFx(
|
|
2296
|
+
machine_name=machine,
|
|
2297
|
+
repo_name=repo,
|
|
2298
|
+
issue_number=issue,
|
|
2299
|
+
issue_title=f"[fix-{next_iteration}] {issue_title}",
|
|
2300
|
+
briefing=effective_briefing,
|
|
2301
|
+
assignment_id=assignment_id,
|
|
2302
|
+
status="running",
|
|
2303
|
+
branch=work.branch,
|
|
2304
|
+
pr_url=work.pr_url,
|
|
2305
|
+
dispatched_at=_time.time(),
|
|
2306
|
+
type="work",
|
|
2307
|
+
review_of_assignment_id=work.assignment_id,
|
|
2308
|
+
review_iteration=next_iteration,
|
|
2309
|
+
model=resolved_model,
|
|
2310
|
+
provider_name="claude-pty",
|
|
2311
|
+
)
|
|
2312
|
+
_record_fx(assignment=fix_assignment, repo_github=repo_cfg.github)
|
|
2313
|
+
if _svc is None:
|
|
2314
|
+
_save_board_fx(_build_board_fx())
|
|
2315
|
+
|
|
2316
|
+
if _is_local:
|
|
2317
|
+
try:
|
|
2318
|
+
_wt_path, _ = _setup_wt_fx(
|
|
2319
|
+
Path(fix_repo_path),
|
|
2320
|
+
issue_number=issue,
|
|
2321
|
+
issue_title=issue_title,
|
|
2322
|
+
assignment_id=assignment_id,
|
|
2323
|
+
default_branch=fix_default_branch,
|
|
2324
|
+
existing_branch=work.branch,
|
|
2325
|
+
)
|
|
2326
|
+
worktree_path = str(_wt_path)
|
|
2327
|
+
except (_AgentGitErrorFx, OSError) as _wt_err:
|
|
2328
|
+
_reason_fx = (
|
|
2329
|
+
f"worktree-add failed for branch {work.branch}: {_wt_err}"
|
|
2330
|
+
)
|
|
2331
|
+
click.echo(f" error: {_reason_fx}", err=True)
|
|
2332
|
+
# #618: persist reason + mark terminal immediately so the
|
|
2333
|
+
# TUI shows WHY the box is red (no log file on this path).
|
|
2334
|
+
_set_fail_reason_fx(assignment_id, _reason_fx)
|
|
2335
|
+
sys.exit(1)
|
|
2336
|
+
click.echo(f" worktree: {worktree_path} (branch: {work.branch})")
|
|
2337
|
+
|
|
2338
|
+
started_at = _time.time()
|
|
2339
|
+
exit_code = launch_human_attended_interactive(
|
|
2340
|
+
argv,
|
|
2341
|
+
effective_briefing,
|
|
2342
|
+
assignment_id=assignment_id,
|
|
2343
|
+
cwd=worktree_path,
|
|
2344
|
+
)
|
|
2345
|
+
if exit_code != 0:
|
|
2346
|
+
click.echo(f" claude exited with status {exit_code}", err=True)
|
|
2347
|
+
|
|
2348
|
+
_sname = _tmux_name(assignment_id) if _tmux_avail() else None
|
|
2349
|
+
if _sname and _tmux_alive(_sname):
|
|
2350
|
+
click.echo(
|
|
2351
|
+
f" session still running in tmux: {_sname}\n"
|
|
2352
|
+
f" reattach with: coord reattach {assignment_id}"
|
|
2353
|
+
)
|
|
2354
|
+
sys.exit(0)
|
|
2355
|
+
|
|
2356
|
+
# #982: narrow the stash to only the examples named in the work
|
|
2357
|
+
# assignment's smoke tests (if any). Falls back to the repo-wide
|
|
2358
|
+
# glob when no smoke tests were captured or nothing matched.
|
|
2359
|
+
_aps_fx = _narrow_ap_fx(
|
|
2360
|
+
list(repo_cfg.artifact_paths),
|
|
2361
|
+
getattr(work, "smoke_tests", None),
|
|
2362
|
+
)
|
|
2363
|
+
try:
|
|
2364
|
+
finalize_result = finalize_interactive_exit(
|
|
2365
|
+
assignment_id=assignment_id,
|
|
2366
|
+
repo_name=repo,
|
|
2367
|
+
repo_github=repo_cfg.github,
|
|
2368
|
+
issue_number=issue,
|
|
2369
|
+
machine_name=machine,
|
|
2370
|
+
worktree_path=worktree_path,
|
|
2371
|
+
base_branch=fix_default_branch,
|
|
2372
|
+
exit_code=exit_code,
|
|
2373
|
+
started_at=started_at,
|
|
2374
|
+
log_path=None,
|
|
2375
|
+
repo_path=fix_repo_path,
|
|
2376
|
+
artifact_paths=_aps_fx,
|
|
2377
|
+
branch=work.branch,
|
|
2378
|
+
)
|
|
2379
|
+
if finalize_result.already_recorded:
|
|
2380
|
+
click.echo(
|
|
2381
|
+
" result recorded via `coord report-result`; backstop "
|
|
2382
|
+
"did not overwrite"
|
|
2383
|
+
)
|
|
2384
|
+
else:
|
|
2385
|
+
click.echo(
|
|
2386
|
+
f" backstop: status={finalize_result.terminal_status} "
|
|
2387
|
+
f"commits_ahead={finalize_result.commits_ahead}"
|
|
2388
|
+
)
|
|
2389
|
+
if not finalize_result.push_ok:
|
|
2390
|
+
click.echo(
|
|
2391
|
+
f" warning: git push failed: {finalize_result.push_error}",
|
|
2392
|
+
err=True,
|
|
2393
|
+
)
|
|
2394
|
+
except Exception as exc: # noqa: BLE001 — best-effort backstop
|
|
2395
|
+
click.echo(
|
|
2396
|
+
f" warning: backstop failed to record fix exit: {exc}",
|
|
2397
|
+
err=True,
|
|
2398
|
+
)
|
|
2399
|
+
sys.exit(exit_code)
|
|
2400
|
+
|
|
2401
|
+
# ── REMOTE FIX (Track B / #486) ───────────────────────────────
|
|
2402
|
+
# A remote worktree on the EXISTING branch (`-B <branch>
|
|
2403
|
+
# origin/<branch>` resets a dedicated local branch to the reviewed
|
|
2404
|
+
# work's branch); the session commits there; on exit the
|
|
2405
|
+
# coordinator pushes the commits back to origin + records the
|
|
2406
|
+
# completion (#486d) so the re-review fires.
|
|
2407
|
+
import shlex as _shlex_fx # noqa: PLC0415
|
|
2408
|
+
|
|
2409
|
+
_remote_wt = "$HOME/.coord/worktrees/" + assignment_id
|
|
2410
|
+
_rp_sh = (
|
|
2411
|
+
"$HOME/" + fix_repo_path[2:]
|
|
2412
|
+
if fix_repo_path.startswith("~/")
|
|
2413
|
+
else ("$HOME" if fix_repo_path == "~" else fix_repo_path)
|
|
2414
|
+
)
|
|
2415
|
+
_claude_args = _shlex_fx.join(list(argv)[1:])
|
|
2416
|
+
_br_q = _shlex_fx.quote(work.branch)
|
|
2417
|
+
_orig_ref = _shlex_fx.quote(f"origin/{work.branch}")
|
|
2418
|
+
_remote_cmd = (
|
|
2419
|
+
f"mkdir -p $HOME/.coord/worktrees"
|
|
2420
|
+
f" && cd {_rp_sh}"
|
|
2421
|
+
f" && git fetch origin --prune 2>/dev/null || true"
|
|
2422
|
+
f" && git worktree prune 2>/dev/null || true"
|
|
2423
|
+
f" && git worktree add -B {_br_q} {_remote_wt} {_orig_ref}"
|
|
2424
|
+
f" && cd {_remote_wt}"
|
|
2425
|
+
f" && COORD_ASSIGNMENT_ID={assignment_id} {argv[0]} {_claude_args}"
|
|
2426
|
+
)
|
|
2427
|
+
_tmux_host = TmuxHost(ssh_target=machine_obj.host)
|
|
2428
|
+
_sname = _tmux_name(assignment_id)
|
|
2429
|
+
click.echo(
|
|
2430
|
+
f" remote worktree: $HOME/.coord/worktrees/{assignment_id}"
|
|
2431
|
+
f" on {machine_obj.host} (branch: {work.branch})"
|
|
2432
|
+
)
|
|
2433
|
+
|
|
2434
|
+
if effective_briefing.strip():
|
|
2435
|
+
_hdr = (
|
|
2436
|
+
"--- seeded briefing -- review below; "
|
|
2437
|
+
"submit the pre-filled input in Claude to send ---"
|
|
2438
|
+
)
|
|
2439
|
+
_ftr = "-" * len(_hdr)
|
|
2440
|
+
_preview = f"\n{_hdr}\n{effective_briefing.rstrip()}\n{_ftr}\n\n"
|
|
2441
|
+
try:
|
|
2442
|
+
os.write(sys.stdout.fileno(), _preview.encode("utf-8"))
|
|
2443
|
+
except OSError:
|
|
2444
|
+
pass
|
|
2445
|
+
|
|
2446
|
+
started_at = _time.time()
|
|
2447
|
+
_rc = _tmux_launch(
|
|
2448
|
+
argv,
|
|
2449
|
+
effective_briefing,
|
|
2450
|
+
_sname,
|
|
2451
|
+
cwd=None,
|
|
2452
|
+
host=_tmux_host,
|
|
2453
|
+
raw_shell_cmd=_remote_cmd,
|
|
2454
|
+
)
|
|
2455
|
+
if _rc is None:
|
|
2456
|
+
click.echo(
|
|
2457
|
+
" error: could not create remote tmux session on "
|
|
2458
|
+
f"{machine_obj.host}",
|
|
2459
|
+
err=True,
|
|
2460
|
+
)
|
|
2461
|
+
sys.exit(1)
|
|
2462
|
+
exit_code = _rc
|
|
2463
|
+
|
|
2464
|
+
if _tmux_alive(_sname, host=_tmux_host):
|
|
2465
|
+
click.echo(
|
|
2466
|
+
f" session still running in remote tmux: {_sname}\n"
|
|
2467
|
+
f" reattach with: ssh -t {machine_obj.host}"
|
|
2468
|
+
f" tmux attach-session -t {_sname}\n"
|
|
2469
|
+
" (the fix is not pushed until the session ends and the "
|
|
2470
|
+
"coordinator finalizes)"
|
|
2471
|
+
)
|
|
2472
|
+
sys.exit(0)
|
|
2473
|
+
|
|
2474
|
+
# ── #560: detect setup failures (worktree never created) ──────
|
|
2475
|
+
# If the session exited non-zero AND the remote worktree directory
|
|
2476
|
+
# was never created, the failure is a setup error — most commonly
|
|
2477
|
+
# git worktree add refused because the branch is already checked
|
|
2478
|
+
# out in another worktree (a leftover interactive session or
|
|
2479
|
+
# orphaned non-interactive worktree). Print an actionable message
|
|
2480
|
+
# and skip the misleading "commits preserved" noise. The finalize
|
|
2481
|
+
# call below still records the failure on the board so the pipeline
|
|
2482
|
+
# shows "failed" rather than leaving the row as "running".
|
|
2483
|
+
_wt_setup_ok: bool = True
|
|
2484
|
+
if exit_code != 0:
|
|
2485
|
+
if not _remote_wt_exists(machine_obj.host, _remote_wt):
|
|
2486
|
+
_wt_setup_ok = False
|
|
2487
|
+
_holder = _find_branch_holder(
|
|
2488
|
+
machine_obj.host, _rp_sh, work.branch,
|
|
2489
|
+
)
|
|
2490
|
+
if _holder:
|
|
2491
|
+
_holder_aid = Path(_holder).name
|
|
2492
|
+
_holder_sname = f"coord-{_holder_aid}"
|
|
2493
|
+
_holder_live = _tmux_alive(_holder_sname, host=_tmux_host)
|
|
2494
|
+
_err_header = (
|
|
2495
|
+
f" error: setup failed — branch {work.branch!r} is "
|
|
2496
|
+
f"already checked out at {_holder} on {machine_obj.host}."
|
|
2497
|
+
)
|
|
2498
|
+
if _holder_live:
|
|
2499
|
+
click.echo("\n".join([
|
|
2500
|
+
_err_header,
|
|
2501
|
+
f" active tmux session: {_holder_sname}",
|
|
2502
|
+
f" reattach: coord reattach {_holder_aid}",
|
|
2503
|
+
" exit the session first, then retry this fix.",
|
|
2504
|
+
]), err=True)
|
|
2505
|
+
else:
|
|
2506
|
+
# Dead holder — detect base checkout vs stale orphan (#814).
|
|
2507
|
+
if _holder_is_base(_holder):
|
|
2508
|
+
# #814: the base checkout ~/src/<repo> is on the
|
|
2509
|
+
# issue branch. NEVER prune the base (#561) —
|
|
2510
|
+
# checkout the default branch to free the lock.
|
|
2511
|
+
click.echo(
|
|
2512
|
+
f" base checkout {_holder!r} is on branch"
|
|
2513
|
+
f" {work.branch!r} on {machine_obj.host}"
|
|
2514
|
+
f" — checking out {fix_default_branch!r}"
|
|
2515
|
+
f" to free it …"
|
|
2516
|
+
)
|
|
2517
|
+
_freed = _remote_free_base(
|
|
2518
|
+
machine_obj.host, _rp_sh, fix_default_branch,
|
|
2519
|
+
)
|
|
2520
|
+
if _freed:
|
|
2521
|
+
click.echo(
|
|
2522
|
+
" base checkout freed — retrying launch …"
|
|
2523
|
+
)
|
|
2524
|
+
_rc2 = _tmux_launch(
|
|
2525
|
+
argv, effective_briefing, _sname,
|
|
2526
|
+
cwd=None, host=_tmux_host,
|
|
2527
|
+
raw_shell_cmd=_remote_cmd,
|
|
2528
|
+
)
|
|
2529
|
+
if _rc2 is None:
|
|
2530
|
+
click.echo(
|
|
2531
|
+
f" error: could not create remote tmux"
|
|
2532
|
+
f" session on {machine_obj.host}"
|
|
2533
|
+
f" (retry after base-checkout free)",
|
|
2534
|
+
err=True,
|
|
2535
|
+
)
|
|
2536
|
+
else:
|
|
2537
|
+
exit_code = _rc2
|
|
2538
|
+
if _tmux_alive(_sname, host=_tmux_host):
|
|
2539
|
+
click.echo(
|
|
2540
|
+
f" session still running in remote"
|
|
2541
|
+
f" tmux: {_sname}\n"
|
|
2542
|
+
f" reattach with: ssh -t"
|
|
2543
|
+
f" {machine_obj.host}"
|
|
2544
|
+
f" tmux attach-session -t {_sname}\n"
|
|
2545
|
+
" (the fix is not pushed until the"
|
|
2546
|
+
" session ends and the coordinator"
|
|
2547
|
+
" finalizes)"
|
|
2548
|
+
)
|
|
2549
|
+
sys.exit(0)
|
|
2550
|
+
if (
|
|
2551
|
+
exit_code == 0
|
|
2552
|
+
or _remote_wt_exists(
|
|
2553
|
+
machine_obj.host, _remote_wt
|
|
2554
|
+
)
|
|
2555
|
+
):
|
|
2556
|
+
_wt_setup_ok = True # retry succeeded
|
|
2557
|
+
else:
|
|
2558
|
+
click.echo("\n".join([
|
|
2559
|
+
_err_header,
|
|
2560
|
+
f" base checkout is stuck on"
|
|
2561
|
+
f" {work.branch!r} —",
|
|
2562
|
+
f" free it manually with:",
|
|
2563
|
+
f" ssh {machine_obj.host}"
|
|
2564
|
+
f" 'git -C {_rp_sh} checkout"
|
|
2565
|
+
f" {fix_default_branch}'",
|
|
2566
|
+
]), err=True)
|
|
2567
|
+
else:
|
|
2568
|
+
# Dead orphan — safety-gated auto-prune-and-retry (#759).
|
|
2569
|
+
_auto_pruned = False
|
|
2570
|
+
if _remote_orphan_safe(
|
|
2571
|
+
machine_obj.host, _rp_sh, _holder, work.branch,
|
|
2572
|
+
):
|
|
2573
|
+
click.echo(
|
|
2574
|
+
f" auto-pruning stale orphan {_holder}"
|
|
2575
|
+
f" on {machine_obj.host} …"
|
|
2576
|
+
)
|
|
2577
|
+
_auto_pruned = _remote_wt_remove(
|
|
2578
|
+
machine_obj.host, _rp_sh, _holder,
|
|
2579
|
+
)
|
|
2580
|
+
if _auto_pruned:
|
|
2581
|
+
click.echo(
|
|
2582
|
+
" stale orphan auto-pruned — retrying launch …"
|
|
2583
|
+
)
|
|
2584
|
+
_rc2 = _tmux_launch(
|
|
2585
|
+
argv, effective_briefing, _sname,
|
|
2586
|
+
cwd=None, host=_tmux_host,
|
|
2587
|
+
raw_shell_cmd=_remote_cmd,
|
|
2588
|
+
)
|
|
2589
|
+
if _rc2 is None:
|
|
2590
|
+
click.echo(
|
|
2591
|
+
f" error: could not create remote tmux"
|
|
2592
|
+
f" session on {machine_obj.host}"
|
|
2593
|
+
f" (retry after prune)",
|
|
2594
|
+
err=True,
|
|
2595
|
+
)
|
|
2596
|
+
else:
|
|
2597
|
+
exit_code = _rc2
|
|
2598
|
+
if _tmux_alive(_sname, host=_tmux_host):
|
|
2599
|
+
click.echo(
|
|
2600
|
+
f" session still running in remote"
|
|
2601
|
+
f" tmux: {_sname}\n"
|
|
2602
|
+
f" reattach with: ssh -t"
|
|
2603
|
+
f" {machine_obj.host}"
|
|
2604
|
+
f" tmux attach-session -t {_sname}\n"
|
|
2605
|
+
" (the fix is not pushed until the"
|
|
2606
|
+
" session ends and the coordinator"
|
|
2607
|
+
" finalizes)"
|
|
2608
|
+
)
|
|
2609
|
+
sys.exit(0)
|
|
2610
|
+
if (
|
|
2611
|
+
exit_code == 0
|
|
2612
|
+
or _remote_wt_exists(
|
|
2613
|
+
machine_obj.host, _remote_wt
|
|
2614
|
+
)
|
|
2615
|
+
):
|
|
2616
|
+
_wt_setup_ok = True # retry succeeded
|
|
2617
|
+
else:
|
|
2618
|
+
# Not safe or prune failed → manual command.
|
|
2619
|
+
click.echo("\n".join([
|
|
2620
|
+
_err_header,
|
|
2621
|
+
" stale worktree — prune it first:",
|
|
2622
|
+
f" ssh {machine_obj.host} 'cd {_rp_sh}"
|
|
2623
|
+
f" && git worktree remove --force"
|
|
2624
|
+
f" {_shlex_fx.quote(_holder)}'",
|
|
2625
|
+
]), err=True)
|
|
2626
|
+
else:
|
|
2627
|
+
click.echo(
|
|
2628
|
+
f" error: setup failed — the remote worktree was "
|
|
2629
|
+
f"never created (git worktree add refused on "
|
|
2630
|
+
f"{machine_obj.host}).",
|
|
2631
|
+
err=True,
|
|
2632
|
+
)
|
|
2633
|
+
else:
|
|
2634
|
+
click.echo(
|
|
2635
|
+
f" claude exited with status {exit_code}", err=True,
|
|
2636
|
+
)
|
|
2637
|
+
|
|
2638
|
+
# Remote finalize (#486d): push the fix commits to origin/<branch>,
|
|
2639
|
+
# record the completion, and clean up the remote worktree. Safe
|
|
2640
|
+
# even when the worktree was never created — _remote_push_and_count
|
|
2641
|
+
# detects the missing directory (exit 91) and marks push_ok=False.
|
|
2642
|
+
#
|
|
2643
|
+
# #982: narrow the stash to only the examples named in the work
|
|
2644
|
+
# assignment's smoke tests (if any).
|
|
2645
|
+
_aps_fx_remote = _narrow_ap_fx(
|
|
2646
|
+
list(repo_cfg.artifact_paths),
|
|
2647
|
+
getattr(work, "smoke_tests", None),
|
|
2648
|
+
)
|
|
2649
|
+
try:
|
|
2650
|
+
_fr = finalize_remote_interactive_exit(
|
|
2651
|
+
assignment_id=assignment_id,
|
|
2652
|
+
repo_name=repo,
|
|
2653
|
+
repo_github=repo_cfg.github,
|
|
2654
|
+
issue_number=issue,
|
|
2655
|
+
machine_name=machine,
|
|
2656
|
+
ssh_target=machine_obj.host,
|
|
2657
|
+
remote_worktree_sh=_remote_wt,
|
|
2658
|
+
remote_repo_sh=_rp_sh,
|
|
2659
|
+
branch=work.branch,
|
|
2660
|
+
base_branch=fix_default_branch,
|
|
2661
|
+
exit_code=exit_code,
|
|
2662
|
+
started_at=started_at,
|
|
2663
|
+
artifact_paths=_aps_fx_remote,
|
|
2664
|
+
)
|
|
2665
|
+
if _fr.already_recorded:
|
|
2666
|
+
click.echo(
|
|
2667
|
+
" result recorded via `coord report-result`; remote "
|
|
2668
|
+
"backstop did not overwrite"
|
|
2669
|
+
)
|
|
2670
|
+
else:
|
|
2671
|
+
click.echo(
|
|
2672
|
+
f" remote backstop: status={_fr.terminal_status} "
|
|
2673
|
+
f"commits_ahead={_fr.commits_ahead} pushed={_fr.push_ok}"
|
|
2674
|
+
)
|
|
2675
|
+
if not _fr.push_ok and _wt_setup_ok:
|
|
2676
|
+
# Worker ran but push failed — commits ARE preserved in
|
|
2677
|
+
# the worktree (which was successfully created).
|
|
2678
|
+
click.echo(
|
|
2679
|
+
f" warning: remote push failed: {_fr.push_error}",
|
|
2680
|
+
err=True,
|
|
2681
|
+
)
|
|
2682
|
+
click.echo(
|
|
2683
|
+
f" fix commits preserved in {_remote_wt} on "
|
|
2684
|
+
f"{machine_obj.host} (worktree NOT removed)",
|
|
2685
|
+
err=True,
|
|
2686
|
+
)
|
|
2687
|
+
# else _wt_setup_ok is False: setup never happened; error
|
|
2688
|
+
# already printed above — no "commits preserved" noise.
|
|
2689
|
+
_echo_artifact_stash(_fr)
|
|
2690
|
+
except Exception as exc: # noqa: BLE001 — best-effort backstop
|
|
2691
|
+
click.echo(
|
|
2692
|
+
f" warning: remote backstop failed to record fix exit: {exc}",
|
|
2693
|
+
err=True,
|
|
2694
|
+
)
|
|
2695
|
+
sys.exit(exit_code)
|
|
2696
|
+
|
|
2697
|
+
|
|
2698
|
+
def _dispatch_rework_of(
|
|
2699
|
+
*,
|
|
2700
|
+
machine: str,
|
|
2701
|
+
repo: str,
|
|
2702
|
+
issue: int,
|
|
2703
|
+
briefing: str,
|
|
2704
|
+
model: str | None,
|
|
2705
|
+
dry_run: bool,
|
|
2706
|
+
force: bool,
|
|
2707
|
+
rework_of: str,
|
|
2708
|
+
cfg: Config,
|
|
2709
|
+
machine_obj: object,
|
|
2710
|
+
repo_cfg: object,
|
|
2711
|
+
issue_title: str,
|
|
2712
|
+
provider: object,
|
|
2713
|
+
_is_local: bool,
|
|
2714
|
+
_svc: object,
|
|
2715
|
+
_interactive_board: object,
|
|
2716
|
+
_issue_ctx: str,
|
|
2717
|
+
) -> None:
|
|
2718
|
+
import time as _time # noqa: PLC0415
|
|
2719
|
+
import uuid as _uuid # noqa: PLC0415
|
|
2720
|
+
|
|
2721
|
+
from coord.interactive import ( # noqa: PLC0415
|
|
2722
|
+
TmuxHost,
|
|
2723
|
+
_holder_is_base_checkout as _holder_is_base,
|
|
2724
|
+
_launch_via_tmux as _tmux_launch,
|
|
2725
|
+
_remote_base_checkout_free_branch as _remote_free_base,
|
|
2726
|
+
_remote_orphan_is_safe_to_prune as _remote_orphan_safe,
|
|
2727
|
+
_remote_worktree_remove as _remote_wt_remove,
|
|
2728
|
+
find_remote_branch_holder as _find_branch_holder,
|
|
2729
|
+
finalize_interactive_exit,
|
|
2730
|
+
finalize_remote_interactive_exit,
|
|
2731
|
+
launch_human_attended_interactive,
|
|
2732
|
+
remote_worktree_exists as _remote_wt_exists,
|
|
2733
|
+
tmux_available as _tmux_avail,
|
|
2734
|
+
tmux_session_alive as _tmux_alive,
|
|
2735
|
+
tmux_session_name as _tmux_name,
|
|
2736
|
+
)
|
|
2737
|
+
|
|
2738
|
+
from coord.agent import ( # noqa: PLC0415
|
|
2739
|
+
AssignmentSpec as _AssignmentSpecRw,
|
|
2740
|
+
_GitError as _AgentGitErrorRw,
|
|
2741
|
+
narrow_artifact_paths as _narrow_ap_rw,
|
|
2742
|
+
setup_interactive_worktree as _setup_wt_rw,
|
|
2743
|
+
)
|
|
2744
|
+
from coord.models import Assignment as _AssignmentRw # noqa: PLC0415
|
|
2745
|
+
from coord.state import ( # noqa: PLC0415
|
|
2746
|
+
COORD_DIR as _COORD_DIR_RW,
|
|
2747
|
+
build_board as _build_board_rw,
|
|
2748
|
+
record_dispatched_assignment as _record_rw,
|
|
2749
|
+
save_board as _save_board_rw,
|
|
2750
|
+
set_assignment_failure_reason as _set_fail_reason_rw,
|
|
2751
|
+
)
|
|
2752
|
+
|
|
2753
|
+
# Resolve branch: try to find a work assignment by ID first, then
|
|
2754
|
+
# fall back to treating the argument as a literal branch name.
|
|
2755
|
+
_rw_board = _interactive_board(_build_board_rw)
|
|
2756
|
+
_rw_work = _rw_board.find_by_id(rework_of)
|
|
2757
|
+
if _rw_work is not None:
|
|
2758
|
+
if not _rw_work.branch:
|
|
2759
|
+
click.echo(
|
|
2760
|
+
f"error: work assignment {rework_of} has no branch.",
|
|
2761
|
+
err=True,
|
|
2762
|
+
)
|
|
2763
|
+
sys.exit(2)
|
|
2764
|
+
rw_branch = _rw_work.branch
|
|
2765
|
+
next_rw_iteration = (_rw_work.review_iteration or 0) + 1
|
|
2766
|
+
rw_work_id: str | None = _rw_work.assignment_id
|
|
2767
|
+
else:
|
|
2768
|
+
# Treat the argument as a branch name — useful when the
|
|
2769
|
+
# original assignment has aged off the board.
|
|
2770
|
+
rw_branch = rework_of
|
|
2771
|
+
# Look for any completed work on that branch to inherit
|
|
2772
|
+
# the iteration counter; default to 1 if none found.
|
|
2773
|
+
_branch_work = next(
|
|
2774
|
+
(
|
|
2775
|
+
a for a in _rw_board.completed
|
|
2776
|
+
if a.branch == rw_branch and a.type in ("work", "plan")
|
|
2777
|
+
),
|
|
2778
|
+
None,
|
|
2779
|
+
)
|
|
2780
|
+
next_rw_iteration = (
|
|
2781
|
+
(_branch_work.review_iteration or 0) + 1
|
|
2782
|
+
if _branch_work is not None
|
|
2783
|
+
else 1
|
|
2784
|
+
)
|
|
2785
|
+
rw_work_id = (
|
|
2786
|
+
_branch_work.assignment_id if _branch_work is not None else None
|
|
2787
|
+
)
|
|
2788
|
+
|
|
2789
|
+
resolved_model = model if model else cfg.models.default
|
|
2790
|
+
assignment_id = _uuid.uuid4().hex[:12]
|
|
2791
|
+
if _is_local:
|
|
2792
|
+
rw_repo_path = str(
|
|
2793
|
+
Path(machine_obj.repo_path(repo) or str(Path.cwd())).expanduser()
|
|
2794
|
+
)
|
|
2795
|
+
else:
|
|
2796
|
+
rw_repo_path = machine_obj.repo_path(repo) or f"~/src/{repo}"
|
|
2797
|
+
rw_default_branch = repo_cfg.default_branch or "main"
|
|
2798
|
+
|
|
2799
|
+
os.environ["COORD_ASSIGNMENT_ID"] = assignment_id
|
|
2800
|
+
if _is_local:
|
|
2801
|
+
rw_report_reminder = (
|
|
2802
|
+
f"[Coordinator rework assignment {assignment_id}] "
|
|
2803
|
+
f"HUMAN-ATTENDED rework (iteration {next_rw_iteration}) "
|
|
2804
|
+
f"on branch {rw_branch}. Before you exit, run "
|
|
2805
|
+
f"`coord report-result --assignment {assignment_id} "
|
|
2806
|
+
"--status done --summary <text>` so the coordinator "
|
|
2807
|
+
"records the result and can re-review.\n\n"
|
|
2808
|
+
)
|
|
2809
|
+
else:
|
|
2810
|
+
rw_report_reminder = (
|
|
2811
|
+
f"[Coordinator rework assignment {assignment_id}] "
|
|
2812
|
+
f"HUMAN-ATTENDED rework (iteration {next_rw_iteration}) "
|
|
2813
|
+
f"on branch {rw_branch}, running on a REMOTE machine. "
|
|
2814
|
+
"Make your changes and COMMIT them. Before you exit, run "
|
|
2815
|
+
f"`coord report-result --assignment {assignment_id} "
|
|
2816
|
+
"--status done --summary <text>` — since #590 it routes to "
|
|
2817
|
+
"the coordinator's shared board, so the result is recorded "
|
|
2818
|
+
"from here. Do NOT run any `gh` commands. When you exit, the "
|
|
2819
|
+
f"coordinator also pushes your commits to origin/{rw_branch} "
|
|
2820
|
+
"and re-reviews.\n\n"
|
|
2821
|
+
)
|
|
2822
|
+
effective_briefing = _issue_ctx + rw_report_reminder + briefing
|
|
2823
|
+
|
|
2824
|
+
spec = _AssignmentSpecRw(
|
|
2825
|
+
repo_name=repo,
|
|
2826
|
+
repo_path=rw_repo_path,
|
|
2827
|
+
issue_number=issue,
|
|
2828
|
+
issue_title=f"[rework-{next_rw_iteration}] {issue_title}",
|
|
2829
|
+
briefing=effective_briefing,
|
|
2830
|
+
model=resolved_model,
|
|
2831
|
+
type="work",
|
|
2832
|
+
provider="claude-pty",
|
|
2833
|
+
)
|
|
2834
|
+
argv = provider.build_command(spec, resolved_model=resolved_model)
|
|
2835
|
+
if not _is_local:
|
|
2836
|
+
argv = ["~/.local/bin/claude"] + list(argv)[1:]
|
|
2837
|
+
|
|
2838
|
+
_rw_location = (
|
|
2839
|
+
"local TTY" if _is_local
|
|
2840
|
+
else f"{machine_obj.host} (remote tmux)"
|
|
2841
|
+
)
|
|
2842
|
+
_rw_max_iter = cfg.pipeline.max_review_iterations
|
|
2843
|
+
click.echo(
|
|
2844
|
+
f"{machine} ({_rw_location}) → REWORK of #{issue} "
|
|
2845
|
+
f"(iteration {next_rw_iteration}/{_rw_max_iter}) on branch {rw_branch}"
|
|
2846
|
+
)
|
|
2847
|
+
click.echo(
|
|
2848
|
+
" mode: HUMAN-ATTENDED interactive rework (#563)"
|
|
2849
|
+
)
|
|
2850
|
+
click.echo(
|
|
2851
|
+
f" assignment id: {assignment_id} "
|
|
2852
|
+
f"(rework_of={rework_of!r}, branch={rw_branch})"
|
|
2853
|
+
)
|
|
2854
|
+
if dry_run:
|
|
2855
|
+
click.echo(" (dry run — not launched)")
|
|
2856
|
+
click.echo(f" would continue branch: {rw_branch}")
|
|
2857
|
+
if not _is_local:
|
|
2858
|
+
click.echo(
|
|
2859
|
+
f" remote worktree: $HOME/.coord/worktrees/{assignment_id}"
|
|
2860
|
+
f" on {machine_obj.host} (branch: {rw_branch})"
|
|
2861
|
+
)
|
|
2862
|
+
click.echo(f" would exec: {argv}")
|
|
2863
|
+
return
|
|
2864
|
+
|
|
2865
|
+
rw_assignment = _AssignmentRw(
|
|
2866
|
+
machine_name=machine,
|
|
2867
|
+
repo_name=repo,
|
|
2868
|
+
issue_number=issue,
|
|
2869
|
+
issue_title=f"[rework-{next_rw_iteration}] {issue_title}",
|
|
2870
|
+
briefing=effective_briefing,
|
|
2871
|
+
assignment_id=assignment_id,
|
|
2872
|
+
status="running",
|
|
2873
|
+
branch=rw_branch,
|
|
2874
|
+
pr_url=_rw_work.pr_url if _rw_work is not None else None,
|
|
2875
|
+
dispatched_at=_time.time(),
|
|
2876
|
+
type="work",
|
|
2877
|
+
review_of_assignment_id=rw_work_id,
|
|
2878
|
+
review_iteration=next_rw_iteration,
|
|
2879
|
+
model=resolved_model,
|
|
2880
|
+
provider_name="claude-pty",
|
|
2881
|
+
)
|
|
2882
|
+
_record_rw(assignment=rw_assignment, repo_github=repo_cfg.github)
|
|
2883
|
+
if _svc is None:
|
|
2884
|
+
_save_board_rw(_build_board_rw())
|
|
2885
|
+
|
|
2886
|
+
if _is_local:
|
|
2887
|
+
try:
|
|
2888
|
+
_wt_path, _ = _setup_wt_rw(
|
|
2889
|
+
Path(rw_repo_path),
|
|
2890
|
+
issue_number=issue,
|
|
2891
|
+
issue_title=issue_title,
|
|
2892
|
+
assignment_id=assignment_id,
|
|
2893
|
+
default_branch=rw_default_branch,
|
|
2894
|
+
existing_branch=rw_branch,
|
|
2895
|
+
)
|
|
2896
|
+
worktree_path = str(_wt_path)
|
|
2897
|
+
except (_AgentGitErrorRw, OSError) as _wt_err:
|
|
2898
|
+
_reason_rw = (
|
|
2899
|
+
f"worktree-add failed for branch {rw_branch}: {_wt_err}"
|
|
2900
|
+
)
|
|
2901
|
+
click.echo(f" error: {_reason_rw}", err=True)
|
|
2902
|
+
# #618: persist reason + mark terminal immediately so the
|
|
2903
|
+
# TUI shows WHY the box is red (no log file on this path).
|
|
2904
|
+
_set_fail_reason_rw(assignment_id, _reason_rw)
|
|
2905
|
+
sys.exit(1)
|
|
2906
|
+
click.echo(f" worktree: {worktree_path} (branch: {rw_branch})")
|
|
2907
|
+
|
|
2908
|
+
started_at = _time.time()
|
|
2909
|
+
exit_code = launch_human_attended_interactive(
|
|
2910
|
+
argv,
|
|
2911
|
+
effective_briefing,
|
|
2912
|
+
assignment_id=assignment_id,
|
|
2913
|
+
cwd=worktree_path,
|
|
2914
|
+
)
|
|
2915
|
+
if exit_code != 0:
|
|
2916
|
+
click.echo(f" claude exited with status {exit_code}", err=True)
|
|
2917
|
+
|
|
2918
|
+
_sname = _tmux_name(assignment_id) if _tmux_avail() else None
|
|
2919
|
+
if _sname and _tmux_alive(_sname):
|
|
2920
|
+
click.echo(
|
|
2921
|
+
f" session still running in tmux: {_sname}\n"
|
|
2922
|
+
f" reattach with: coord reattach {assignment_id}"
|
|
2923
|
+
)
|
|
2924
|
+
sys.exit(0)
|
|
2925
|
+
|
|
2926
|
+
# #982: narrow the stash to only the examples named in the
|
|
2927
|
+
# rework-target assignment's smoke tests (if any).
|
|
2928
|
+
_aps_rw = _narrow_ap_rw(
|
|
2929
|
+
list(repo_cfg.artifact_paths),
|
|
2930
|
+
getattr(_rw_work, "smoke_tests", None) if _rw_work is not None else None,
|
|
2931
|
+
)
|
|
2932
|
+
try:
|
|
2933
|
+
finalize_result = finalize_interactive_exit(
|
|
2934
|
+
assignment_id=assignment_id,
|
|
2935
|
+
repo_name=repo,
|
|
2936
|
+
repo_github=repo_cfg.github,
|
|
2937
|
+
issue_number=issue,
|
|
2938
|
+
machine_name=machine,
|
|
2939
|
+
worktree_path=worktree_path,
|
|
2940
|
+
base_branch=rw_default_branch,
|
|
2941
|
+
exit_code=exit_code,
|
|
2942
|
+
started_at=started_at,
|
|
2943
|
+
log_path=None,
|
|
2944
|
+
repo_path=rw_repo_path,
|
|
2945
|
+
artifact_paths=_aps_rw,
|
|
2946
|
+
branch=rw_branch,
|
|
2947
|
+
)
|
|
2948
|
+
if finalize_result.already_recorded:
|
|
2949
|
+
click.echo(
|
|
2950
|
+
" result recorded via `coord report-result`; backstop "
|
|
2951
|
+
"did not overwrite"
|
|
2952
|
+
)
|
|
2953
|
+
else:
|
|
2954
|
+
click.echo(
|
|
2955
|
+
f" backstop: status={finalize_result.terminal_status} "
|
|
2956
|
+
f"commits_ahead={finalize_result.commits_ahead}"
|
|
2957
|
+
)
|
|
2958
|
+
if not finalize_result.push_ok:
|
|
2959
|
+
click.echo(
|
|
2960
|
+
f" warning: git push failed: {finalize_result.push_error}",
|
|
2961
|
+
err=True,
|
|
2962
|
+
)
|
|
2963
|
+
except Exception as exc: # noqa: BLE001 — best-effort backstop
|
|
2964
|
+
click.echo(
|
|
2965
|
+
f" warning: backstop failed to record rework exit: {exc}",
|
|
2966
|
+
err=True,
|
|
2967
|
+
)
|
|
2968
|
+
sys.exit(exit_code)
|
|
2969
|
+
|
|
2970
|
+
# ── REMOTE REWORK ─────────────────────────────────────────────
|
|
2971
|
+
import shlex as _shlex_rw # noqa: PLC0415
|
|
2972
|
+
|
|
2973
|
+
_remote_wt = "$HOME/.coord/worktrees/" + assignment_id
|
|
2974
|
+
_rp_sh = (
|
|
2975
|
+
"$HOME/" + rw_repo_path[2:]
|
|
2976
|
+
if rw_repo_path.startswith("~/")
|
|
2977
|
+
else ("$HOME" if rw_repo_path == "~" else rw_repo_path)
|
|
2978
|
+
)
|
|
2979
|
+
_claude_args = _shlex_rw.join(list(argv)[1:])
|
|
2980
|
+
_br_q = _shlex_rw.quote(rw_branch)
|
|
2981
|
+
_orig_ref = _shlex_rw.quote(f"origin/{rw_branch}")
|
|
2982
|
+
_remote_cmd = (
|
|
2983
|
+
f"mkdir -p $HOME/.coord/worktrees"
|
|
2984
|
+
f" && cd {_rp_sh}"
|
|
2985
|
+
f" && git fetch origin --prune 2>/dev/null || true"
|
|
2986
|
+
f" && git worktree prune 2>/dev/null || true"
|
|
2987
|
+
f" && git worktree add -B {_br_q} {_remote_wt} {_orig_ref}"
|
|
2988
|
+
f" && cd {_remote_wt}"
|
|
2989
|
+
f" && COORD_ASSIGNMENT_ID={assignment_id} {argv[0]} {_claude_args}"
|
|
2990
|
+
)
|
|
2991
|
+
_tmux_host = TmuxHost(ssh_target=machine_obj.host)
|
|
2992
|
+
_sname = _tmux_name(assignment_id)
|
|
2993
|
+
click.echo(
|
|
2994
|
+
f" remote worktree: $HOME/.coord/worktrees/{assignment_id}"
|
|
2995
|
+
f" on {machine_obj.host} (branch: {rw_branch})"
|
|
2996
|
+
)
|
|
2997
|
+
|
|
2998
|
+
if effective_briefing.strip():
|
|
2999
|
+
_hdr = (
|
|
3000
|
+
"--- seeded briefing -- review below; "
|
|
3001
|
+
"submit the pre-filled input in Claude to send ---"
|
|
3002
|
+
)
|
|
3003
|
+
_ftr = "-" * len(_hdr)
|
|
3004
|
+
_preview = f"\n{_hdr}\n{effective_briefing.rstrip()}\n{_ftr}\n\n"
|
|
3005
|
+
try:
|
|
3006
|
+
os.write(sys.stdout.fileno(), _preview.encode("utf-8"))
|
|
3007
|
+
except OSError:
|
|
3008
|
+
pass
|
|
3009
|
+
|
|
3010
|
+
started_at = _time.time()
|
|
3011
|
+
_rc = _tmux_launch(
|
|
3012
|
+
argv,
|
|
3013
|
+
effective_briefing,
|
|
3014
|
+
_sname,
|
|
3015
|
+
cwd=None,
|
|
3016
|
+
host=_tmux_host,
|
|
3017
|
+
raw_shell_cmd=_remote_cmd,
|
|
3018
|
+
)
|
|
3019
|
+
if _rc is None:
|
|
3020
|
+
click.echo(
|
|
3021
|
+
" error: could not create remote tmux session on "
|
|
3022
|
+
f"{machine_obj.host}",
|
|
3023
|
+
err=True,
|
|
3024
|
+
)
|
|
3025
|
+
sys.exit(1)
|
|
3026
|
+
exit_code = _rc
|
|
3027
|
+
|
|
3028
|
+
if _tmux_alive(_sname, host=_tmux_host):
|
|
3029
|
+
click.echo(
|
|
3030
|
+
f" session still running in remote tmux: {_sname}\n"
|
|
3031
|
+
f" reattach with: ssh -t {machine_obj.host}"
|
|
3032
|
+
f" tmux attach-session -t {_sname}\n"
|
|
3033
|
+
" (the rework is not pushed until the session ends and "
|
|
3034
|
+
"the coordinator finalizes)"
|
|
3035
|
+
)
|
|
3036
|
+
sys.exit(0)
|
|
3037
|
+
|
|
3038
|
+
# ── #560: detect setup failures (worktree never created) ──────
|
|
3039
|
+
_wt_setup_ok_rw: bool = True
|
|
3040
|
+
if exit_code != 0:
|
|
3041
|
+
if not _remote_wt_exists(machine_obj.host, _remote_wt):
|
|
3042
|
+
_wt_setup_ok_rw = False
|
|
3043
|
+
_holder = _find_branch_holder(
|
|
3044
|
+
machine_obj.host, _rp_sh, rw_branch,
|
|
3045
|
+
)
|
|
3046
|
+
if _holder:
|
|
3047
|
+
_holder_aid = Path(_holder).name
|
|
3048
|
+
_holder_sname = f"coord-{_holder_aid}"
|
|
3049
|
+
_holder_live = _tmux_alive(_holder_sname, host=_tmux_host)
|
|
3050
|
+
_err_header = (
|
|
3051
|
+
f" error: setup failed — branch {rw_branch!r} is "
|
|
3052
|
+
f"already checked out at {_holder} on {machine_obj.host}."
|
|
3053
|
+
)
|
|
3054
|
+
if _holder_live:
|
|
3055
|
+
click.echo("\n".join([
|
|
3056
|
+
_err_header,
|
|
3057
|
+
f" active tmux session: {_holder_sname}",
|
|
3058
|
+
f" reattach: coord reattach {_holder_aid}",
|
|
3059
|
+
" exit the session first, then retry this rework.",
|
|
3060
|
+
]), err=True)
|
|
3061
|
+
else:
|
|
3062
|
+
# Dead holder — detect base checkout vs stale orphan (#814).
|
|
3063
|
+
if _holder_is_base(_holder):
|
|
3064
|
+
# #814: the base checkout ~/src/<repo> is on the
|
|
3065
|
+
# rework branch. NEVER prune the base (#561) —
|
|
3066
|
+
# checkout the default branch to free the lock.
|
|
3067
|
+
click.echo(
|
|
3068
|
+
f" base checkout {_holder!r} is on branch"
|
|
3069
|
+
f" {rw_branch!r} on {machine_obj.host}"
|
|
3070
|
+
f" — checking out {rw_default_branch!r}"
|
|
3071
|
+
f" to free it …"
|
|
3072
|
+
)
|
|
3073
|
+
_freed = _remote_free_base(
|
|
3074
|
+
machine_obj.host, _rp_sh, rw_default_branch,
|
|
3075
|
+
)
|
|
3076
|
+
if _freed:
|
|
3077
|
+
click.echo(
|
|
3078
|
+
" base checkout freed — retrying launch …"
|
|
3079
|
+
)
|
|
3080
|
+
_rc2 = _tmux_launch(
|
|
3081
|
+
argv, effective_briefing, _sname,
|
|
3082
|
+
cwd=None, host=_tmux_host,
|
|
3083
|
+
raw_shell_cmd=_remote_cmd,
|
|
3084
|
+
)
|
|
3085
|
+
if _rc2 is None:
|
|
3086
|
+
click.echo(
|
|
3087
|
+
f" error: could not create remote tmux"
|
|
3088
|
+
f" session on {machine_obj.host}"
|
|
3089
|
+
f" (retry after base-checkout free)",
|
|
3090
|
+
err=True,
|
|
3091
|
+
)
|
|
3092
|
+
else:
|
|
3093
|
+
exit_code = _rc2
|
|
3094
|
+
if _tmux_alive(_sname, host=_tmux_host):
|
|
3095
|
+
click.echo(
|
|
3096
|
+
f" session still running in remote"
|
|
3097
|
+
f" tmux: {_sname}\n"
|
|
3098
|
+
f" reattach with: ssh -t"
|
|
3099
|
+
f" {machine_obj.host}"
|
|
3100
|
+
f" tmux attach-session -t {_sname}\n"
|
|
3101
|
+
" (the rework is not pushed until"
|
|
3102
|
+
" the session ends and the coordinator"
|
|
3103
|
+
" finalizes)"
|
|
3104
|
+
)
|
|
3105
|
+
sys.exit(0)
|
|
3106
|
+
if (
|
|
3107
|
+
exit_code == 0
|
|
3108
|
+
or _remote_wt_exists(
|
|
3109
|
+
machine_obj.host, _remote_wt
|
|
3110
|
+
)
|
|
3111
|
+
):
|
|
3112
|
+
_wt_setup_ok_rw = True # retry succeeded
|
|
3113
|
+
else:
|
|
3114
|
+
click.echo("\n".join([
|
|
3115
|
+
_err_header,
|
|
3116
|
+
f" base checkout is stuck on"
|
|
3117
|
+
f" {rw_branch!r} —",
|
|
3118
|
+
f" free it manually with:",
|
|
3119
|
+
f" ssh {machine_obj.host}"
|
|
3120
|
+
f" 'git -C {_rp_sh} checkout"
|
|
3121
|
+
f" {rw_default_branch}'",
|
|
3122
|
+
]), err=True)
|
|
3123
|
+
else:
|
|
3124
|
+
# Dead orphan — safety-gated auto-prune-and-retry (#759).
|
|
3125
|
+
_auto_pruned = False
|
|
3126
|
+
if _remote_orphan_safe(
|
|
3127
|
+
machine_obj.host, _rp_sh, _holder, rw_branch,
|
|
3128
|
+
):
|
|
3129
|
+
click.echo(
|
|
3130
|
+
f" auto-pruning stale orphan {_holder}"
|
|
3131
|
+
f" on {machine_obj.host} …"
|
|
3132
|
+
)
|
|
3133
|
+
_auto_pruned = _remote_wt_remove(
|
|
3134
|
+
machine_obj.host, _rp_sh, _holder,
|
|
3135
|
+
)
|
|
3136
|
+
if _auto_pruned:
|
|
3137
|
+
click.echo(
|
|
3138
|
+
" stale orphan auto-pruned — retrying launch …"
|
|
3139
|
+
)
|
|
3140
|
+
_rc2 = _tmux_launch(
|
|
3141
|
+
argv, effective_briefing, _sname,
|
|
3142
|
+
cwd=None, host=_tmux_host,
|
|
3143
|
+
raw_shell_cmd=_remote_cmd,
|
|
3144
|
+
)
|
|
3145
|
+
if _rc2 is None:
|
|
3146
|
+
click.echo(
|
|
3147
|
+
f" error: could not create remote tmux"
|
|
3148
|
+
f" session on {machine_obj.host}"
|
|
3149
|
+
f" (retry after prune)",
|
|
3150
|
+
err=True,
|
|
3151
|
+
)
|
|
3152
|
+
else:
|
|
3153
|
+
exit_code = _rc2
|
|
3154
|
+
if _tmux_alive(_sname, host=_tmux_host):
|
|
3155
|
+
click.echo(
|
|
3156
|
+
f" session still running in remote"
|
|
3157
|
+
f" tmux: {_sname}\n"
|
|
3158
|
+
f" reattach with: ssh -t"
|
|
3159
|
+
f" {machine_obj.host}"
|
|
3160
|
+
f" tmux attach-session -t {_sname}\n"
|
|
3161
|
+
" (the rework is not pushed until"
|
|
3162
|
+
" the session ends and the coordinator"
|
|
3163
|
+
" finalizes)"
|
|
3164
|
+
)
|
|
3165
|
+
sys.exit(0)
|
|
3166
|
+
if (
|
|
3167
|
+
exit_code == 0
|
|
3168
|
+
or _remote_wt_exists(
|
|
3169
|
+
machine_obj.host, _remote_wt
|
|
3170
|
+
)
|
|
3171
|
+
):
|
|
3172
|
+
_wt_setup_ok_rw = True # retry succeeded
|
|
3173
|
+
else:
|
|
3174
|
+
# Not safe or prune failed → manual command.
|
|
3175
|
+
click.echo("\n".join([
|
|
3176
|
+
_err_header,
|
|
3177
|
+
" stale worktree — prune it first:",
|
|
3178
|
+
f" ssh {machine_obj.host} 'cd {_rp_sh}"
|
|
3179
|
+
f" && git worktree remove --force"
|
|
3180
|
+
f" {_shlex_rw.quote(_holder)}'",
|
|
3181
|
+
]), err=True)
|
|
3182
|
+
else:
|
|
3183
|
+
click.echo(
|
|
3184
|
+
f" error: setup failed — the remote worktree was "
|
|
3185
|
+
f"never created (git worktree add refused on "
|
|
3186
|
+
f"{machine_obj.host}).",
|
|
3187
|
+
err=True,
|
|
3188
|
+
)
|
|
3189
|
+
else:
|
|
3190
|
+
click.echo(
|
|
3191
|
+
f" claude exited with status {exit_code}", err=True,
|
|
3192
|
+
)
|
|
3193
|
+
|
|
3194
|
+
# Remote finalize: push the rework commits to origin/<branch>,
|
|
3195
|
+
# record the completion, and clean up the remote worktree.
|
|
3196
|
+
# #982: narrow the stash to the examples named in the rework-target's
|
|
3197
|
+
# smoke tests (if any).
|
|
3198
|
+
_aps_rw_remote = _narrow_ap_rw(
|
|
3199
|
+
list(repo_cfg.artifact_paths),
|
|
3200
|
+
getattr(_rw_work, "smoke_tests", None) if _rw_work is not None else None,
|
|
3201
|
+
)
|
|
3202
|
+
try:
|
|
3203
|
+
_fr = finalize_remote_interactive_exit(
|
|
3204
|
+
assignment_id=assignment_id,
|
|
3205
|
+
repo_name=repo,
|
|
3206
|
+
repo_github=repo_cfg.github,
|
|
3207
|
+
issue_number=issue,
|
|
3208
|
+
machine_name=machine,
|
|
3209
|
+
ssh_target=machine_obj.host,
|
|
3210
|
+
remote_worktree_sh=_remote_wt,
|
|
3211
|
+
remote_repo_sh=_rp_sh,
|
|
3212
|
+
branch=rw_branch,
|
|
3213
|
+
base_branch=rw_default_branch,
|
|
3214
|
+
exit_code=exit_code,
|
|
3215
|
+
started_at=started_at,
|
|
3216
|
+
artifact_paths=_aps_rw_remote,
|
|
3217
|
+
)
|
|
3218
|
+
if _fr.already_recorded:
|
|
3219
|
+
click.echo(
|
|
3220
|
+
" result recorded via `coord report-result`; remote "
|
|
3221
|
+
"backstop did not overwrite"
|
|
3222
|
+
)
|
|
3223
|
+
else:
|
|
3224
|
+
click.echo(
|
|
3225
|
+
f" remote backstop: status={_fr.terminal_status} "
|
|
3226
|
+
f"commits_ahead={_fr.commits_ahead} pushed={_fr.push_ok}"
|
|
3227
|
+
)
|
|
3228
|
+
if not _fr.push_ok and _wt_setup_ok_rw:
|
|
3229
|
+
click.echo(
|
|
3230
|
+
f" warning: remote push failed: {_fr.push_error}",
|
|
3231
|
+
err=True,
|
|
3232
|
+
)
|
|
3233
|
+
click.echo(
|
|
3234
|
+
f" rework commits preserved in {_remote_wt} on "
|
|
3235
|
+
f"{machine_obj.host} (worktree NOT removed)",
|
|
3236
|
+
err=True,
|
|
3237
|
+
)
|
|
3238
|
+
# else _wt_setup_ok_rw is False: setup never happened; error
|
|
3239
|
+
# already printed above — no "commits preserved" noise.
|
|
3240
|
+
_echo_artifact_stash(_fr)
|
|
3241
|
+
except Exception as exc: # noqa: BLE001 — best-effort backstop
|
|
3242
|
+
click.echo(
|
|
3243
|
+
f" warning: remote backstop failed to record rework exit: {exc}",
|
|
3244
|
+
err=True,
|
|
3245
|
+
)
|
|
3246
|
+
sys.exit(exit_code)
|
|
3247
|
+
|
|
3248
|
+
|
|
3249
|
+
def _dispatch_merge_of(
|
|
3250
|
+
*,
|
|
3251
|
+
machine: str,
|
|
3252
|
+
repo: str,
|
|
3253
|
+
issue: int,
|
|
3254
|
+
briefing: str,
|
|
3255
|
+
model: str | None,
|
|
3256
|
+
dry_run: bool,
|
|
3257
|
+
force: bool,
|
|
3258
|
+
merge_of: str,
|
|
3259
|
+
cfg: Config,
|
|
3260
|
+
machine_obj: object,
|
|
3261
|
+
repo_cfg: object,
|
|
3262
|
+
issue_title: str,
|
|
3263
|
+
provider: object,
|
|
3264
|
+
_is_local: bool,
|
|
3265
|
+
_svc: object,
|
|
3266
|
+
_interactive_board: object,
|
|
3267
|
+
_issue_ctx: str,
|
|
3268
|
+
) -> None:
|
|
3269
|
+
"""Leg 3c (#517, #306): human-attended MERGE-PREP for approved work.
|
|
3270
|
+
|
|
3271
|
+
#1007: runs on the LOCAL TTY or on a REMOTE machine over ssh+tmux,
|
|
3272
|
+
mirroring the local/remote split already in production for
|
|
3273
|
+
``--review-of`` / ``--fix-of`` / ``--rework-of``. Like a fix, a merge
|
|
3274
|
+
WRITES (rebase + force-push) — it needs a worktree on the EXISTING
|
|
3275
|
+
branch; unlike a fix, the merge-prep agent itself runs `git push
|
|
3276
|
+
--force-with-lease` and `coord merge` as part of its own instructed
|
|
3277
|
+
flow, so the coordinator's remote finalize does not need a push-back
|
|
3278
|
+
step of its own (the #604 verify-merge gate is the piece that must run
|
|
3279
|
+
on whichever host holds the worktree).
|
|
3280
|
+
"""
|
|
3281
|
+
import time as _time # noqa: PLC0415
|
|
3282
|
+
import uuid as _uuid # noqa: PLC0415
|
|
3283
|
+
|
|
3284
|
+
from coord.interactive import ( # noqa: PLC0415
|
|
3285
|
+
TmuxHost,
|
|
3286
|
+
_holder_is_base_checkout as _holder_is_base,
|
|
3287
|
+
_launch_via_tmux as _tmux_launch,
|
|
3288
|
+
_remote_base_checkout_free_branch as _remote_free_base,
|
|
3289
|
+
_remote_orphan_is_safe_to_prune as _remote_orphan_safe,
|
|
3290
|
+
_remote_worktree_remove as _remote_wt_remove,
|
|
3291
|
+
find_remote_branch_holder as _find_branch_holder,
|
|
3292
|
+
finalize_interactive_exit,
|
|
3293
|
+
finalize_remote_interactive_exit,
|
|
3294
|
+
launch_human_attended_interactive,
|
|
3295
|
+
remote_worktree_exists as _remote_wt_exists,
|
|
3296
|
+
tmux_available as _tmux_avail,
|
|
3297
|
+
tmux_session_alive as _tmux_alive,
|
|
3298
|
+
tmux_session_name as _tmux_name,
|
|
3299
|
+
)
|
|
3300
|
+
|
|
3301
|
+
from coord.agent import ( # noqa: PLC0415
|
|
3302
|
+
AssignmentSpec as _AssignmentSpecMg,
|
|
3303
|
+
_GitError as _AgentGitErrorMg,
|
|
3304
|
+
setup_interactive_worktree as _setup_wt_mg,
|
|
3305
|
+
)
|
|
3306
|
+
from coord.models import Assignment as _AssignmentMg # noqa: PLC0415
|
|
3307
|
+
from coord.state import ( # noqa: PLC0415
|
|
3308
|
+
build_board as _build_board_mg,
|
|
3309
|
+
record_dispatched_assignment as _record_mg,
|
|
3310
|
+
save_board as _save_board_mg,
|
|
3311
|
+
)
|
|
3312
|
+
|
|
3313
|
+
def _echo_merge_finalize(finalize_result: object, target_branch: str) -> None:
|
|
3314
|
+
# Forensics (#604): the worktree + reflog are gone by now, so this
|
|
3315
|
+
# echo is the only post-hoc record of what would merge.
|
|
3316
|
+
_mv = finalize_result.merge_verify
|
|
3317
|
+
if _mv is not None:
|
|
3318
|
+
click.echo(
|
|
3319
|
+
f" merge verify: {target_branch}-ahead="
|
|
3320
|
+
f"{_mv.default_ahead} added={len(_mv.added)} commit(s)"
|
|
3321
|
+
)
|
|
3322
|
+
for _sha, _subj in _mv.added:
|
|
3323
|
+
_flag = " [FOREIGN]" if (_sha, _subj) in _mv.foreign else ""
|
|
3324
|
+
click.echo(f" {_sha[:9]} {_subj}{_flag}")
|
|
3325
|
+
if not _mv.ok:
|
|
3326
|
+
click.echo(
|
|
3327
|
+
" ✗ MERGE BLOCKED (#604): "
|
|
3328
|
+
f"{_mv.block_summary(target_branch)}",
|
|
3329
|
+
err=True,
|
|
3330
|
+
)
|
|
3331
|
+
if finalize_result.already_recorded and (_mv is None or _mv.ok):
|
|
3332
|
+
click.echo(
|
|
3333
|
+
" result recorded via `coord report-result`; backstop "
|
|
3334
|
+
"did not overwrite"
|
|
3335
|
+
)
|
|
3336
|
+
else:
|
|
3337
|
+
click.echo(
|
|
3338
|
+
f" backstop: status={finalize_result.terminal_status} "
|
|
3339
|
+
f"commits_ahead={finalize_result.commits_ahead}"
|
|
3340
|
+
)
|
|
3341
|
+
|
|
3342
|
+
_mg_board = _interactive_board(_build_board_mg)
|
|
3343
|
+
work = _mg_board.find_by_id(merge_of)
|
|
3344
|
+
if work is None:
|
|
3345
|
+
click.echo(
|
|
3346
|
+
f"error: --merge-of {merge_of}: no such assignment on the "
|
|
3347
|
+
"board (use the work id from `coord status`).",
|
|
3348
|
+
err=True,
|
|
3349
|
+
)
|
|
3350
|
+
sys.exit(2)
|
|
3351
|
+
if not work.branch:
|
|
3352
|
+
click.echo(
|
|
3353
|
+
f"error: work assignment {merge_of} has no branch to merge.",
|
|
3354
|
+
err=True,
|
|
3355
|
+
)
|
|
3356
|
+
sys.exit(2)
|
|
3357
|
+
|
|
3358
|
+
resolved_model = model if model else cfg.models.default
|
|
3359
|
+
assignment_id = _uuid.uuid4().hex[:12]
|
|
3360
|
+
if _is_local:
|
|
3361
|
+
merge_repo_path = str(
|
|
3362
|
+
Path(machine_obj.repo_path(repo) or str(Path.cwd())).expanduser()
|
|
3363
|
+
)
|
|
3364
|
+
else:
|
|
3365
|
+
merge_repo_path = machine_obj.repo_path(repo) or f"~/src/{repo}"
|
|
3366
|
+
merge_target_branch = repo_cfg.default_branch or "main"
|
|
3367
|
+
_merge_test_cmd = None
|
|
3368
|
+
try:
|
|
3369
|
+
_merge_test_cmd = getattr(repo_cfg, "test_command", None)
|
|
3370
|
+
except Exception: # noqa: BLE001
|
|
3371
|
+
_merge_test_cmd = None
|
|
3372
|
+
|
|
3373
|
+
INTERACTIVE_MERGE_SYSTEM_PROMPT = (
|
|
3374
|
+
"You are a merge-prep agent dispatched by the coordinator. This "
|
|
3375
|
+
"session may be attended by a human operator or it may be "
|
|
3376
|
+
"unattended — do not assume anyone is watching. The branch has "
|
|
3377
|
+
"been reviewed/approved; your job is to get it cleanly rebased, "
|
|
3378
|
+
"verified, and MERGED.\n\n"
|
|
3379
|
+
"Rules:\n"
|
|
3380
|
+
"- Stay on the worker's branch. NEVER push to the default branch "
|
|
3381
|
+
"directly.\n"
|
|
3382
|
+
"- Use `git push --force-with-lease` (NOT plain --force) after a "
|
|
3383
|
+
"rebase.\n"
|
|
3384
|
+
"- Resolve MECHANICAL conflicts (non-overlapping struct fields, "
|
|
3385
|
+
"list/import entries, separate functions) additively — keep both "
|
|
3386
|
+
"sides. For SEMANTIC conflicts (same logic changed two ways), do "
|
|
3387
|
+
"NOT guess: explain the conflict to the operator and let them "
|
|
3388
|
+
"decide.\n"
|
|
3389
|
+
"- NEVER push to the default branch directly or use `gh`. "
|
|
3390
|
+
f"`coord merge --repo {repo}` is the ONLY merge path — it routes "
|
|
3391
|
+
"to the coordinator and re-checks the CI / review / smoke gates "
|
|
3392
|
+
"before merging via PR. You run it yourself, but ONLY after "
|
|
3393
|
+
"`coord verify-merge` passes clean.\n"
|
|
3394
|
+
"- If `coord merge` reports a gate failure (CI red, conflict, "
|
|
3395
|
+
"missing verdict), STOP and report it to the operator — never "
|
|
3396
|
+
"`--force-merge` / `--skip-*` on your own.\n\n"
|
|
3397
|
+
"Flow:\n"
|
|
3398
|
+
"1. `git fetch origin`.\n"
|
|
3399
|
+
"2. Rebase the branch onto `origin/<default_branch>`.\n"
|
|
3400
|
+
"3. Resolve conflicts (mechanical additively; semantic with the "
|
|
3401
|
+
"operator).\n"
|
|
3402
|
+
"4. Run the project's build/tests to confirm nothing broke.\n"
|
|
3403
|
+
"5. `git push --force-with-lease`.\n"
|
|
3404
|
+
f"6. Run `coord verify-merge {merge_of}` to self-check the "
|
|
3405
|
+
"result.\n"
|
|
3406
|
+
" `default-ahead != 0` means the rebase landed on a stale "
|
|
3407
|
+
"base — redo the rebase onto the current `origin/<default_branch>` "
|
|
3408
|
+
"and re-push.\n"
|
|
3409
|
+
" FOREIGN commits: `coord verify-merge` flags a commit as "
|
|
3410
|
+
"FOREIGN when its *subject line* does not contain the issue "
|
|
3411
|
+
"number. It NEVER reads the diff — only the subject string. "
|
|
3412
|
+
"This means a commit that is this branch's own work can be "
|
|
3413
|
+
"flagged if the subject has a typo'd or omitted issue ref. "
|
|
3414
|
+
"Before treating any FOREIGN flag as a real strand, apply this "
|
|
3415
|
+
"decision procedure for each flagged commit:\n"
|
|
3416
|
+
" (a) Run `git show <sha>` and read the diff. If the content "
|
|
3417
|
+
"is clearly this issue's own work → reword the commit subject "
|
|
3418
|
+
"to include the issue number (NEVER drop the commit). Verify "
|
|
3419
|
+
"the tree is unchanged: `git rev-parse HEAD^{tree}` before and "
|
|
3420
|
+
"after must be identical. Re-run `coord verify-merge` — it "
|
|
3421
|
+
"should now pass.\n"
|
|
3422
|
+
" (b) Genuinely unrelated content AND the commit is already an "
|
|
3423
|
+
"ancestor of the base branch → drop it from the branch.\n"
|
|
3424
|
+
" (c) Genuinely unrelated content AND NOT on the base branch → "
|
|
3425
|
+
"this is a real foreign strand. Escalate to the operator.\n"
|
|
3426
|
+
" Exhaust-before-blocking checklist (complete this BEFORE "
|
|
3427
|
+
"reporting `--status blocked`):\n"
|
|
3428
|
+
" [ ] List every FOREIGN-flagged commit\n"
|
|
3429
|
+
" [ ] Run `git show <sha>` on each to read the diff\n"
|
|
3430
|
+
" [ ] Categorise each: (a) own-mislabeled, (b) ancestor-on-"
|
|
3431
|
+
"base, (c) genuine strand\n"
|
|
3432
|
+
" [ ] Resolve (a) and (b) autonomously; only escalate (c)\n"
|
|
3433
|
+
" A `--status blocked` report MUST state: what you tried, "
|
|
3434
|
+
"which specific commit is the obstacle, and the single decision "
|
|
3435
|
+
"the operator must make. Blocking without that detail is itself "
|
|
3436
|
+
"a prompt violation.\n"
|
|
3437
|
+
" Blocking cost: the operator must context-switch to this "
|
|
3438
|
+
"session and the branch sits stranded until they do. It is not "
|
|
3439
|
+
"a free exit — exhaust autonomous options first.\n"
|
|
3440
|
+
" The coordinator also runs this check on exit and will record "
|
|
3441
|
+
"`blocked` (not `done`) if it still fails after you exit, so a "
|
|
3442
|
+
"bad rebase cannot slip through regardless.\n"
|
|
3443
|
+
f"7. Complete the merge: run `coord merge --repo {repo}` (it opens "
|
|
3444
|
+
"the PR, enforces the gates, and merges in dependency order). "
|
|
3445
|
+
"Report the outcome — merged, or which gate blocked.\n"
|
|
3446
|
+
)
|
|
3447
|
+
|
|
3448
|
+
merge_briefing = (
|
|
3449
|
+
f"# Merge-prep assignment: {repo_cfg.github} #{issue}\n\n"
|
|
3450
|
+
f"**Issue:** {issue_title}\n"
|
|
3451
|
+
f"**Branch to merge:** `{work.branch}` "
|
|
3452
|
+
f"(worker: {work.machine_name or machine})\n"
|
|
3453
|
+
f"**Rebase onto:** `origin/{merge_target_branch}`\n"
|
|
3454
|
+
f"**Work assignment id:** `{merge_of}`\n"
|
|
3455
|
+
+ (
|
|
3456
|
+
f"**Test command:** `{_merge_test_cmd}`\n"
|
|
3457
|
+
if _merge_test_cmd
|
|
3458
|
+
else ""
|
|
3459
|
+
)
|
|
3460
|
+
+ "\n## Your job\n\n"
|
|
3461
|
+
"This branch is approved. Fetch, rebase it onto "
|
|
3462
|
+
f"`origin/{merge_target_branch}` (#306 proactive rebase), resolve "
|
|
3463
|
+
"any conflicts (mechanical additively; semantic with the "
|
|
3464
|
+
"operator), run the tests, and `git push --force-with-lease`. "
|
|
3465
|
+
f"Before you report done, run `coord verify-merge {merge_of}`. "
|
|
3466
|
+
f"If `{merge_target_branch}-ahead != 0`, the rebase is on a "
|
|
3467
|
+
"stale base — redo it. If FOREIGN commits are listed, read the "
|
|
3468
|
+
"system-prompt decision procedure (a/b/c) before deciding whether "
|
|
3469
|
+
"to reword, drop, or escalate — FOREIGN is a subject-string check "
|
|
3470
|
+
"that never reads diffs, so it can fire on this branch's own "
|
|
3471
|
+
"mislabeled commit (#604). "
|
|
3472
|
+
f"Once verify-merge passes clean, COMPLETE the merge yourself: "
|
|
3473
|
+
f"run `coord merge --repo {repo}` (it re-checks CI/review/smoke "
|
|
3474
|
+
"on the coordinator and merges via PR). If it reports a gate "
|
|
3475
|
+
"failure, report that to the operator instead of forcing.\n"
|
|
3476
|
+
)
|
|
3477
|
+
|
|
3478
|
+
os.environ["COORD_ASSIGNMENT_ID"] = assignment_id
|
|
3479
|
+
report_reminder = (
|
|
3480
|
+
f"[Coordinator merge assignment {assignment_id}] HUMAN-ATTENDED "
|
|
3481
|
+
f"merge-prep on branch {work.branch} (rebasing onto "
|
|
3482
|
+
f"{merge_target_branch}). Before you exit, run `coord "
|
|
3483
|
+
f"report-result --assignment {assignment_id} --status done "
|
|
3484
|
+
"--summary <text>` (use --status blocked only for a semantic "
|
|
3485
|
+
"conflict or genuine foreign strand that needs the operator; "
|
|
3486
|
+
"blocked reports must name what was tried and the single decision "
|
|
3487
|
+
"needed).\n\n"
|
|
3488
|
+
)
|
|
3489
|
+
effective_briefing = _issue_ctx + report_reminder + merge_briefing
|
|
3490
|
+
|
|
3491
|
+
spec = _AssignmentSpecMg(
|
|
3492
|
+
repo_name=repo,
|
|
3493
|
+
repo_path=merge_repo_path,
|
|
3494
|
+
issue_number=issue,
|
|
3495
|
+
issue_title=f"[merge] {issue_title}",
|
|
3496
|
+
briefing=effective_briefing,
|
|
3497
|
+
model=resolved_model,
|
|
3498
|
+
type="conflict-fix",
|
|
3499
|
+
provider="claude-pty",
|
|
3500
|
+
)
|
|
3501
|
+
# Full worker tool set (Read/Edit/Write/Bash) — rebasing and resolving
|
|
3502
|
+
# conflicts mutates the checkout.
|
|
3503
|
+
argv = provider.build_command(
|
|
3504
|
+
spec,
|
|
3505
|
+
resolved_model=resolved_model,
|
|
3506
|
+
system_prompt=INTERACTIVE_MERGE_SYSTEM_PROMPT,
|
|
3507
|
+
)
|
|
3508
|
+
# Remote: bare "claude" isn't on the SSH login PATH (#424/#425).
|
|
3509
|
+
if not _is_local:
|
|
3510
|
+
argv = ["~/.local/bin/claude"] + list(argv)[1:]
|
|
3511
|
+
|
|
3512
|
+
_mg_location = (
|
|
3513
|
+
"local TTY" if _is_local
|
|
3514
|
+
else f"{machine_obj.host} (remote tmux)"
|
|
3515
|
+
)
|
|
3516
|
+
click.echo(
|
|
3517
|
+
f"{machine} ({_mg_location}) → MERGE-PREP of #{issue} "
|
|
3518
|
+
f"on branch {work.branch}: {issue_title}"
|
|
3519
|
+
)
|
|
3520
|
+
click.echo(" mode: HUMAN-ATTENDED interactive merge agent (leg 3c)")
|
|
3521
|
+
click.echo(
|
|
3522
|
+
f" assignment id: {assignment_id} (merge_of={merge_of}, "
|
|
3523
|
+
f"rebase onto origin/{merge_target_branch})"
|
|
3524
|
+
)
|
|
3525
|
+
if dry_run:
|
|
3526
|
+
click.echo(" (dry run — not launched)")
|
|
3527
|
+
click.echo(f" would continue branch: {work.branch}")
|
|
3528
|
+
if not _is_local:
|
|
3529
|
+
click.echo(
|
|
3530
|
+
f" remote worktree: $HOME/.coord/worktrees/{assignment_id}"
|
|
3531
|
+
f" on {machine_obj.host} (branch: {work.branch})"
|
|
3532
|
+
)
|
|
3533
|
+
click.echo(f" would exec: {argv}")
|
|
3534
|
+
return
|
|
3535
|
+
|
|
3536
|
+
merge_assignment = _AssignmentMg(
|
|
3537
|
+
machine_name=machine,
|
|
3538
|
+
repo_name=repo,
|
|
3539
|
+
issue_number=issue,
|
|
3540
|
+
issue_title=f"[merge] {issue_title}",
|
|
3541
|
+
briefing=effective_briefing,
|
|
3542
|
+
assignment_id=assignment_id,
|
|
3543
|
+
status="running",
|
|
3544
|
+
branch=work.branch,
|
|
3545
|
+
pr_url=work.pr_url,
|
|
3546
|
+
dispatched_at=_time.time(),
|
|
3547
|
+
type="conflict-fix",
|
|
3548
|
+
review_of_assignment_id=work.assignment_id,
|
|
3549
|
+
model=resolved_model,
|
|
3550
|
+
provider_name="claude-pty",
|
|
3551
|
+
)
|
|
3552
|
+
_record_mg(assignment=merge_assignment, repo_github=repo_cfg.github)
|
|
3553
|
+
if _svc is None:
|
|
3554
|
+
_save_board_mg(_build_board_mg())
|
|
3555
|
+
|
|
3556
|
+
if _is_local:
|
|
3557
|
+
try:
|
|
3558
|
+
_wt_path, _ = _setup_wt_mg(
|
|
3559
|
+
Path(merge_repo_path),
|
|
3560
|
+
issue_number=issue,
|
|
3561
|
+
issue_title=issue_title,
|
|
3562
|
+
assignment_id=assignment_id,
|
|
3563
|
+
default_branch=merge_target_branch,
|
|
3564
|
+
existing_branch=work.branch,
|
|
3565
|
+
)
|
|
3566
|
+
worktree_path = str(_wt_path)
|
|
3567
|
+
except (_AgentGitErrorMg, OSError) as _wt_err:
|
|
3568
|
+
click.echo(
|
|
3569
|
+
f" error: could not create merge worktree on branch "
|
|
3570
|
+
f"{work.branch}: {_wt_err}",
|
|
3571
|
+
err=True,
|
|
3572
|
+
)
|
|
3573
|
+
sys.exit(1)
|
|
3574
|
+
click.echo(f" worktree: {worktree_path} (branch: {work.branch})")
|
|
3575
|
+
|
|
3576
|
+
started_at = _time.time()
|
|
3577
|
+
exit_code = launch_human_attended_interactive(
|
|
3578
|
+
argv,
|
|
3579
|
+
effective_briefing,
|
|
3580
|
+
assignment_id=assignment_id,
|
|
3581
|
+
cwd=worktree_path,
|
|
3582
|
+
)
|
|
3583
|
+
if exit_code != 0:
|
|
3584
|
+
click.echo(f" claude exited with status {exit_code}", err=True)
|
|
3585
|
+
|
|
3586
|
+
_sname = _tmux_name(assignment_id) if _tmux_avail() else None
|
|
3587
|
+
if _sname and _tmux_alive(_sname):
|
|
3588
|
+
click.echo(
|
|
3589
|
+
f" session still running in tmux: {_sname}\n"
|
|
3590
|
+
f" reattach with: coord reattach {assignment_id}"
|
|
3591
|
+
)
|
|
3592
|
+
sys.exit(0)
|
|
3593
|
+
|
|
3594
|
+
try:
|
|
3595
|
+
finalize_result = finalize_interactive_exit(
|
|
3596
|
+
assignment_id=assignment_id,
|
|
3597
|
+
repo_name=repo,
|
|
3598
|
+
repo_github=repo_cfg.github,
|
|
3599
|
+
issue_number=issue,
|
|
3600
|
+
machine_name=machine,
|
|
3601
|
+
worktree_path=worktree_path,
|
|
3602
|
+
base_branch=merge_target_branch,
|
|
3603
|
+
exit_code=exit_code,
|
|
3604
|
+
started_at=started_at,
|
|
3605
|
+
log_path=None,
|
|
3606
|
+
repo_path=merge_repo_path,
|
|
3607
|
+
# #604: git truth overrides the agent's self-report on the
|
|
3608
|
+
# merge path — a botched rebase records `blocked`, not `done`.
|
|
3609
|
+
verify_merge=True,
|
|
3610
|
+
branch=work.branch,
|
|
3611
|
+
)
|
|
3612
|
+
_echo_merge_finalize(finalize_result, merge_target_branch)
|
|
3613
|
+
except Exception as exc: # noqa: BLE001 — best-effort backstop
|
|
3614
|
+
click.echo(
|
|
3615
|
+
f" warning: backstop failed to record merge exit: {exc}",
|
|
3616
|
+
err=True,
|
|
3617
|
+
)
|
|
3618
|
+
sys.exit(exit_code)
|
|
3619
|
+
|
|
3620
|
+
# ── REMOTE MERGE (#1007) ──────────────────────────────────────
|
|
3621
|
+
# A remote worktree on the EXISTING branch (`-B <branch>
|
|
3622
|
+
# origin/<branch>` resets a dedicated local branch to the reviewed
|
|
3623
|
+
# work's branch) — same shape as the remote fix path. The merge-prep
|
|
3624
|
+
# agent itself runs `git push --force-with-lease` and `coord merge` as
|
|
3625
|
+
# part of its instructed flow (INTERACTIVE_MERGE_SYSTEM_PROMPT above),
|
|
3626
|
+
# so on exit the coordinator's job is to VERIFY the rebase (#604) on
|
|
3627
|
+
# the remote worktree before trusting any self-report, then record
|
|
3628
|
+
# the completion.
|
|
3629
|
+
import shlex as _shlex_mg # noqa: PLC0415
|
|
3630
|
+
|
|
3631
|
+
_remote_wt = "$HOME/.coord/worktrees/" + assignment_id
|
|
3632
|
+
_rp_sh = (
|
|
3633
|
+
"$HOME/" + merge_repo_path[2:]
|
|
3634
|
+
if merge_repo_path.startswith("~/")
|
|
3635
|
+
else ("$HOME" if merge_repo_path == "~" else merge_repo_path)
|
|
3636
|
+
)
|
|
3637
|
+
_claude_args = _shlex_mg.join(list(argv)[1:])
|
|
3638
|
+
_br_q = _shlex_mg.quote(work.branch)
|
|
3639
|
+
_orig_ref = _shlex_mg.quote(f"origin/{work.branch}")
|
|
3640
|
+
_remote_cmd = (
|
|
3641
|
+
f"mkdir -p $HOME/.coord/worktrees"
|
|
3642
|
+
f" && cd {_rp_sh}"
|
|
3643
|
+
f" && git fetch origin --prune 2>/dev/null || true"
|
|
3644
|
+
f" && git worktree prune 2>/dev/null || true"
|
|
3645
|
+
f" && git worktree add -B {_br_q} {_remote_wt} {_orig_ref}"
|
|
3646
|
+
f" && cd {_remote_wt}"
|
|
3647
|
+
f" && COORD_ASSIGNMENT_ID={assignment_id} {argv[0]} {_claude_args}"
|
|
3648
|
+
)
|
|
3649
|
+
_tmux_host = TmuxHost(ssh_target=machine_obj.host)
|
|
3650
|
+
_sname = _tmux_name(assignment_id)
|
|
3651
|
+
click.echo(
|
|
3652
|
+
f" remote worktree: $HOME/.coord/worktrees/{assignment_id}"
|
|
3653
|
+
f" on {machine_obj.host} (branch: {work.branch})"
|
|
3654
|
+
)
|
|
3655
|
+
|
|
3656
|
+
if effective_briefing.strip():
|
|
3657
|
+
_hdr = (
|
|
3658
|
+
"--- seeded briefing -- review below; "
|
|
3659
|
+
"submit the pre-filled input in Claude to send ---"
|
|
3660
|
+
)
|
|
3661
|
+
_ftr = "-" * len(_hdr)
|
|
3662
|
+
_preview = f"\n{_hdr}\n{effective_briefing.rstrip()}\n{_ftr}\n\n"
|
|
3663
|
+
try:
|
|
3664
|
+
os.write(sys.stdout.fileno(), _preview.encode("utf-8"))
|
|
3665
|
+
except OSError:
|
|
3666
|
+
pass
|
|
3667
|
+
|
|
3668
|
+
started_at = _time.time()
|
|
3669
|
+
_rc = _tmux_launch(
|
|
3670
|
+
argv,
|
|
3671
|
+
effective_briefing,
|
|
3672
|
+
_sname,
|
|
3673
|
+
cwd=None,
|
|
3674
|
+
host=_tmux_host,
|
|
3675
|
+
raw_shell_cmd=_remote_cmd,
|
|
3676
|
+
)
|
|
3677
|
+
if _rc is None:
|
|
3678
|
+
click.echo(
|
|
3679
|
+
" error: could not create remote tmux session on "
|
|
3680
|
+
f"{machine_obj.host}",
|
|
3681
|
+
err=True,
|
|
3682
|
+
)
|
|
3683
|
+
sys.exit(1)
|
|
3684
|
+
exit_code = _rc
|
|
3685
|
+
|
|
3686
|
+
if _tmux_alive(_sname, host=_tmux_host):
|
|
3687
|
+
click.echo(
|
|
3688
|
+
f" session still running in remote tmux: {_sname}\n"
|
|
3689
|
+
f" reattach with: ssh -t {machine_obj.host}"
|
|
3690
|
+
f" tmux attach-session -t {_sname}\n"
|
|
3691
|
+
" (the merge is not finalized until the session ends and "
|
|
3692
|
+
"the coordinator finalizes)"
|
|
3693
|
+
)
|
|
3694
|
+
sys.exit(0)
|
|
3695
|
+
|
|
3696
|
+
# ── #560: detect setup failures (worktree never created) ──────
|
|
3697
|
+
_wt_setup_ok_mg: bool = True
|
|
3698
|
+
if exit_code != 0:
|
|
3699
|
+
if not _remote_wt_exists(machine_obj.host, _remote_wt):
|
|
3700
|
+
_wt_setup_ok_mg = False
|
|
3701
|
+
_holder = _find_branch_holder(
|
|
3702
|
+
machine_obj.host, _rp_sh, work.branch,
|
|
3703
|
+
)
|
|
3704
|
+
if _holder:
|
|
3705
|
+
_holder_aid = Path(_holder).name
|
|
3706
|
+
_holder_sname = f"coord-{_holder_aid}"
|
|
3707
|
+
_holder_live = _tmux_alive(_holder_sname, host=_tmux_host)
|
|
3708
|
+
_err_header = (
|
|
3709
|
+
f" error: setup failed — branch {work.branch!r} is "
|
|
3710
|
+
f"already checked out at {_holder} on {machine_obj.host}."
|
|
3711
|
+
)
|
|
3712
|
+
if _holder_live:
|
|
3713
|
+
click.echo("\n".join([
|
|
3714
|
+
_err_header,
|
|
3715
|
+
f" active tmux session: {_holder_sname}",
|
|
3716
|
+
f" reattach: coord reattach {_holder_aid}",
|
|
3717
|
+
" exit the session first, then retry this merge.",
|
|
3718
|
+
]), err=True)
|
|
3719
|
+
else:
|
|
3720
|
+
# Dead holder — detect base checkout vs stale orphan (#814).
|
|
3721
|
+
if _holder_is_base(_holder):
|
|
3722
|
+
# #814: the base checkout ~/src/<repo> is on the
|
|
3723
|
+
# merge branch. NEVER prune the base (#561) —
|
|
3724
|
+
# checkout the default branch to free the lock.
|
|
3725
|
+
click.echo(
|
|
3726
|
+
f" base checkout {_holder!r} is on branch"
|
|
3727
|
+
f" {work.branch!r} on {machine_obj.host}"
|
|
3728
|
+
f" — checking out {merge_target_branch!r}"
|
|
3729
|
+
f" to free it …"
|
|
3730
|
+
)
|
|
3731
|
+
_freed = _remote_free_base(
|
|
3732
|
+
machine_obj.host, _rp_sh, merge_target_branch,
|
|
3733
|
+
)
|
|
3734
|
+
if _freed:
|
|
3735
|
+
click.echo(
|
|
3736
|
+
" base checkout freed — retrying launch …"
|
|
3737
|
+
)
|
|
3738
|
+
_rc2 = _tmux_launch(
|
|
3739
|
+
argv, effective_briefing, _sname,
|
|
3740
|
+
cwd=None, host=_tmux_host,
|
|
3741
|
+
raw_shell_cmd=_remote_cmd,
|
|
3742
|
+
)
|
|
3743
|
+
if _rc2 is None:
|
|
3744
|
+
click.echo(
|
|
3745
|
+
f" error: could not create remote tmux"
|
|
3746
|
+
f" session on {machine_obj.host}"
|
|
3747
|
+
f" (retry after base-checkout free)",
|
|
3748
|
+
err=True,
|
|
3749
|
+
)
|
|
3750
|
+
else:
|
|
3751
|
+
exit_code = _rc2
|
|
3752
|
+
if _tmux_alive(_sname, host=_tmux_host):
|
|
3753
|
+
click.echo(
|
|
3754
|
+
f" session still running in remote"
|
|
3755
|
+
f" tmux: {_sname}\n"
|
|
3756
|
+
f" reattach with: ssh -t"
|
|
3757
|
+
f" {machine_obj.host}"
|
|
3758
|
+
f" tmux attach-session -t {_sname}\n"
|
|
3759
|
+
" (the merge is not finalized until"
|
|
3760
|
+
" the session ends and the"
|
|
3761
|
+
" coordinator finalizes)"
|
|
3762
|
+
)
|
|
3763
|
+
sys.exit(0)
|
|
3764
|
+
if (
|
|
3765
|
+
exit_code == 0
|
|
3766
|
+
or _remote_wt_exists(
|
|
3767
|
+
machine_obj.host, _remote_wt
|
|
3768
|
+
)
|
|
3769
|
+
):
|
|
3770
|
+
_wt_setup_ok_mg = True # retry succeeded
|
|
3771
|
+
else:
|
|
3772
|
+
click.echo("\n".join([
|
|
3773
|
+
_err_header,
|
|
3774
|
+
f" base checkout is stuck on"
|
|
3775
|
+
f" {work.branch!r} —",
|
|
3776
|
+
f" free it manually with:",
|
|
3777
|
+
f" ssh {machine_obj.host}"
|
|
3778
|
+
f" 'git -C {_rp_sh} checkout"
|
|
3779
|
+
f" {merge_target_branch}'",
|
|
3780
|
+
]), err=True)
|
|
3781
|
+
else:
|
|
3782
|
+
# Dead orphan — safety-gated auto-prune-and-retry (#759).
|
|
3783
|
+
_auto_pruned = False
|
|
3784
|
+
if _remote_orphan_safe(
|
|
3785
|
+
machine_obj.host, _rp_sh, _holder, work.branch,
|
|
3786
|
+
):
|
|
3787
|
+
click.echo(
|
|
3788
|
+
f" auto-pruning stale orphan {_holder}"
|
|
3789
|
+
f" on {machine_obj.host} …"
|
|
3790
|
+
)
|
|
3791
|
+
_auto_pruned = _remote_wt_remove(
|
|
3792
|
+
machine_obj.host, _rp_sh, _holder,
|
|
3793
|
+
)
|
|
3794
|
+
if _auto_pruned:
|
|
3795
|
+
click.echo(
|
|
3796
|
+
" stale orphan auto-pruned — retrying launch …"
|
|
3797
|
+
)
|
|
3798
|
+
_rc2 = _tmux_launch(
|
|
3799
|
+
argv, effective_briefing, _sname,
|
|
3800
|
+
cwd=None, host=_tmux_host,
|
|
3801
|
+
raw_shell_cmd=_remote_cmd,
|
|
3802
|
+
)
|
|
3803
|
+
if _rc2 is None:
|
|
3804
|
+
click.echo(
|
|
3805
|
+
f" error: could not create remote tmux"
|
|
3806
|
+
f" session on {machine_obj.host}"
|
|
3807
|
+
f" (retry after prune)",
|
|
3808
|
+
err=True,
|
|
3809
|
+
)
|
|
3810
|
+
else:
|
|
3811
|
+
exit_code = _rc2
|
|
3812
|
+
if _tmux_alive(_sname, host=_tmux_host):
|
|
3813
|
+
click.echo(
|
|
3814
|
+
f" session still running in remote"
|
|
3815
|
+
f" tmux: {_sname}\n"
|
|
3816
|
+
f" reattach with: ssh -t"
|
|
3817
|
+
f" {machine_obj.host}"
|
|
3818
|
+
f" tmux attach-session -t {_sname}\n"
|
|
3819
|
+
" (the merge is not finalized until"
|
|
3820
|
+
" the session ends and the"
|
|
3821
|
+
" coordinator finalizes)"
|
|
3822
|
+
)
|
|
3823
|
+
sys.exit(0)
|
|
3824
|
+
if (
|
|
3825
|
+
exit_code == 0
|
|
3826
|
+
or _remote_wt_exists(
|
|
3827
|
+
machine_obj.host, _remote_wt
|
|
3828
|
+
)
|
|
3829
|
+
):
|
|
3830
|
+
_wt_setup_ok_mg = True # retry succeeded
|
|
3831
|
+
else:
|
|
3832
|
+
# Not safe or prune failed → manual command.
|
|
3833
|
+
click.echo("\n".join([
|
|
3834
|
+
_err_header,
|
|
3835
|
+
" stale worktree — prune it first:",
|
|
3836
|
+
f" ssh {machine_obj.host} 'cd {_rp_sh}"
|
|
3837
|
+
f" && git worktree remove --force"
|
|
3838
|
+
f" {_shlex_mg.quote(_holder)}'",
|
|
3839
|
+
]), err=True)
|
|
3840
|
+
else:
|
|
3841
|
+
click.echo(
|
|
3842
|
+
f" error: setup failed — the remote worktree was "
|
|
3843
|
+
f"never created (git worktree add refused on "
|
|
3844
|
+
f"{machine_obj.host}).",
|
|
3845
|
+
err=True,
|
|
3846
|
+
)
|
|
3847
|
+
else:
|
|
3848
|
+
click.echo(
|
|
3849
|
+
f" claude exited with status {exit_code}", err=True,
|
|
3850
|
+
)
|
|
3851
|
+
|
|
3852
|
+
# Remote finalize (#1007): verify the rebase (#604) on the remote
|
|
3853
|
+
# worktree BEFORE trusting any self-report, then record the
|
|
3854
|
+
# completion and clean up. Safe even when the worktree was never
|
|
3855
|
+
# created — remote_worktree_exists() gates the verify step and
|
|
3856
|
+
# _remote_push_and_count detects the missing directory.
|
|
3857
|
+
try:
|
|
3858
|
+
finalize_result = finalize_remote_interactive_exit(
|
|
3859
|
+
assignment_id=assignment_id,
|
|
3860
|
+
repo_name=repo,
|
|
3861
|
+
repo_github=repo_cfg.github,
|
|
3862
|
+
issue_number=issue,
|
|
3863
|
+
machine_name=machine,
|
|
3864
|
+
ssh_target=machine_obj.host,
|
|
3865
|
+
remote_worktree_sh=_remote_wt,
|
|
3866
|
+
remote_repo_sh=_rp_sh,
|
|
3867
|
+
branch=work.branch,
|
|
3868
|
+
base_branch=merge_target_branch,
|
|
3869
|
+
exit_code=exit_code,
|
|
3870
|
+
started_at=started_at,
|
|
3871
|
+
verify_merge=True,
|
|
3872
|
+
)
|
|
3873
|
+
_echo_merge_finalize(finalize_result, merge_target_branch)
|
|
3874
|
+
_mv = finalize_result.merge_verify
|
|
3875
|
+
if not finalize_result.push_ok and _wt_setup_ok_mg and (
|
|
3876
|
+
_mv is None or _mv.ok
|
|
3877
|
+
):
|
|
3878
|
+
# Worker ran but the coordinator's own backstop push failed —
|
|
3879
|
+
# the agent's own `git push --force-with-lease` (briefing step
|
|
3880
|
+
# 5) may still have landed; this is a secondary signal only.
|
|
3881
|
+
click.echo(
|
|
3882
|
+
f" warning: remote push failed: {finalize_result.push_error}",
|
|
3883
|
+
err=True,
|
|
3884
|
+
)
|
|
3885
|
+
click.echo(
|
|
3886
|
+
f" merge commits preserved in {_remote_wt} on "
|
|
3887
|
+
f"{machine_obj.host} (worktree NOT removed)",
|
|
3888
|
+
err=True,
|
|
3889
|
+
)
|
|
3890
|
+
except Exception as exc: # noqa: BLE001 — best-effort backstop
|
|
3891
|
+
click.echo(
|
|
3892
|
+
f" warning: backstop failed to record remote merge exit: {exc}",
|
|
3893
|
+
err=True,
|
|
3894
|
+
)
|
|
3895
|
+
sys.exit(exit_code)
|
|
3896
|
+
|
|
3897
|
+
|
|
3898
|
+
def _dispatch_interactive_work(
|
|
3899
|
+
*,
|
|
3900
|
+
machine: str,
|
|
3901
|
+
repo: str,
|
|
3902
|
+
issue: int,
|
|
3903
|
+
briefing: str,
|
|
3904
|
+
model: str | None,
|
|
3905
|
+
dry_run: bool,
|
|
3906
|
+
plan_only: bool,
|
|
3907
|
+
no_plan: bool,
|
|
3908
|
+
force: bool,
|
|
3909
|
+
cfg: Config,
|
|
3910
|
+
machine_obj: object,
|
|
3911
|
+
repo_cfg: object,
|
|
3912
|
+
issue_title: str,
|
|
3913
|
+
provider: object,
|
|
3914
|
+
_is_local: bool,
|
|
3915
|
+
_issue_ctx: str,
|
|
3916
|
+
_ctx_write_hint: str,
|
|
3917
|
+
) -> None:
|
|
3918
|
+
"""#437: the flavour-less human-attended interactive work/plan launch (no --review-of/--fix-of/etc)."""
|
|
3919
|
+
import time as _time # noqa: PLC0415
|
|
3920
|
+
import uuid as _uuid # noqa: PLC0415
|
|
3921
|
+
|
|
3922
|
+
from coord.interactive import ( # noqa: PLC0415
|
|
3923
|
+
TmuxHost,
|
|
3924
|
+
_holder_is_base_checkout as _holder_is_base,
|
|
3925
|
+
_launch_via_tmux as _tmux_launch,
|
|
3926
|
+
_remote_base_checkout_free_branch as _remote_free_base,
|
|
3927
|
+
_remote_orphan_is_safe_to_prune as _remote_orphan_safe,
|
|
3928
|
+
_remote_worktree_remove as _remote_wt_remove,
|
|
3929
|
+
find_remote_branch_holder as _find_branch_holder,
|
|
3930
|
+
finalize_interactive_exit,
|
|
3931
|
+
finalize_remote_interactive_exit,
|
|
3932
|
+
launch_human_attended_interactive,
|
|
3933
|
+
remote_worktree_exists as _remote_wt_exists,
|
|
3934
|
+
tmux_available as _tmux_avail,
|
|
3935
|
+
tmux_session_alive as _tmux_alive,
|
|
3936
|
+
tmux_session_name as _tmux_name,
|
|
3937
|
+
)
|
|
3938
|
+
|
|
3939
|
+
if _is_local:
|
|
3940
|
+
# Expand `~` — repo_paths in coordinator.yml use `~/src/...`,
|
|
3941
|
+
# and unlike the agent (which expands everywhere) this local
|
|
3942
|
+
# interactive launch passes the path straight to the child's cwd,
|
|
3943
|
+
# so a literal `~` would fail with "No such file or directory".
|
|
3944
|
+
# Local launch ⇒ local home.
|
|
3945
|
+
repo_path = str(
|
|
3946
|
+
Path(machine_obj.repo_path(repo) or str(Path.cwd())).expanduser()
|
|
3947
|
+
)
|
|
3948
|
+
else:
|
|
3949
|
+
# Remote: keep the raw path from coordinator.yml so the remote
|
|
3950
|
+
# shell can expand `~` itself. Fall back to ~/src/<repo> when no
|
|
3951
|
+
# repo_path is configured (common for new machines).
|
|
3952
|
+
repo_path = machine_obj.repo_path(repo) or f"~/src/{repo}"
|
|
3953
|
+
|
|
3954
|
+
effective_plan_only = plan_only or (
|
|
3955
|
+
cfg.dispatch.require_plan and not no_plan
|
|
3956
|
+
)
|
|
3957
|
+
repo_default_branch = repo_cfg.default_branch or "main"
|
|
3958
|
+
|
|
3959
|
+
# ── Claim check. Without this an operator can spawn two
|
|
3960
|
+
# interactive sessions on the same issue and both push competing
|
|
3961
|
+
# branches. --force bypasses the check (mirrors the
|
|
3962
|
+
# claude -p path below).
|
|
3963
|
+
from coord.board_service import read_board, write_board # noqa: PLC0415
|
|
3964
|
+
from coord.claim import claim_message, find_work_claim # noqa: PLC0415
|
|
3965
|
+
from coord.state import record_dispatched # noqa: PLC0415
|
|
3966
|
+
|
|
3967
|
+
board_check = read_board()
|
|
3968
|
+
if not force:
|
|
3969
|
+
claim = find_work_claim(issue, repo, repo_cfg.github, board_check)
|
|
3970
|
+
if claim is not None:
|
|
3971
|
+
click.echo(
|
|
3972
|
+
f" skipping: {claim_message(claim)}",
|
|
3973
|
+
err=True,
|
|
3974
|
+
)
|
|
3975
|
+
sys.exit(1)
|
|
3976
|
+
|
|
3977
|
+
# #1138: the oracle-loop issue-level gate applies to --interactive work
|
|
3978
|
+
# dispatch too, not just headless — a human at the keyboard can miss the
|
|
3979
|
+
# same gap a `claude -p` worker would. Deliberately NOT bypassed by
|
|
3980
|
+
# --force (that flag is documented as an infra-retry/claim escape hatch,
|
|
3981
|
+
# not a way around the acceptance-authoring requirement).
|
|
3982
|
+
from coord.dispatch import ( # noqa: PLC0415
|
|
3983
|
+
enforce_epic_dispatch_guard,
|
|
3984
|
+
enforce_oracle_readiness,
|
|
3985
|
+
)
|
|
3986
|
+
|
|
3987
|
+
try:
|
|
3988
|
+
enforce_oracle_readiness(
|
|
3989
|
+
proposal_type="plan" if effective_plan_only else "work",
|
|
3990
|
+
repo=repo_cfg, config=cfg, issue_number=issue,
|
|
3991
|
+
)
|
|
3992
|
+
# #1314: same epic-target gate as the headless dispatch() path —
|
|
3993
|
+
# a human at the keyboard can dispatch --interactive directly
|
|
3994
|
+
# against a tracking issue's own number just as easily as a
|
|
3995
|
+
# headless worker.
|
|
3996
|
+
enforce_epic_dispatch_guard(
|
|
3997
|
+
proposal_type="plan" if effective_plan_only else "work",
|
|
3998
|
+
repo=repo_cfg, config=cfg, issue_number=issue,
|
|
3999
|
+
)
|
|
4000
|
+
except ValueError as e:
|
|
4001
|
+
click.echo(f" skipping: {e}", err=True)
|
|
4002
|
+
sys.exit(1)
|
|
4003
|
+
|
|
4004
|
+
from coord.agent import AssignmentSpec # noqa: PLC0415
|
|
4005
|
+
from coord.models import Proposal # noqa: PLC0415
|
|
4006
|
+
|
|
4007
|
+
# #1430: deliberately NOT consulting models.labels here. This is the
|
|
4008
|
+
# human-attended `--interactive` launcher (subscription-billed, not
|
|
4009
|
+
# metered per dispatch) — the operator is at the keyboard and can pass
|
|
4010
|
+
# --model directly when a tier matters; unlike the headless paths, a
|
|
4011
|
+
# wrong default here doesn't silently burn API budget unattended.
|
|
4012
|
+
resolved_model = model if model else cfg.models.default
|
|
4013
|
+
assignment_id = _uuid.uuid4().hex[:12]
|
|
4014
|
+
|
|
4015
|
+
spec = AssignmentSpec(
|
|
4016
|
+
repo_name=repo,
|
|
4017
|
+
repo_path=repo_path,
|
|
4018
|
+
issue_number=issue,
|
|
4019
|
+
issue_title=issue_title,
|
|
4020
|
+
briefing=briefing,
|
|
4021
|
+
model=resolved_model,
|
|
4022
|
+
type="plan" if effective_plan_only else "work",
|
|
4023
|
+
provider="claude-pty",
|
|
4024
|
+
)
|
|
4025
|
+
|
|
4026
|
+
# Build a minimal Proposal — only the fields record_dispatched
|
|
4027
|
+
# consumes need to be set. The actual record_dispatched call is
|
|
4028
|
+
# deferred until after the dry-run gate below so `--dry-run`
|
|
4029
|
+
# leaves no phantom "running" row in the DB.
|
|
4030
|
+
proposal = Proposal(
|
|
4031
|
+
id=0,
|
|
4032
|
+
machine_name=machine,
|
|
4033
|
+
repo_name=repo,
|
|
4034
|
+
issue_number=issue,
|
|
4035
|
+
issue_title=issue_title,
|
|
4036
|
+
rationale="manual --interactive dispatch (human-attended)",
|
|
4037
|
+
briefing=briefing,
|
|
4038
|
+
model=resolved_model,
|
|
4039
|
+
type="plan" if effective_plan_only else "work",
|
|
4040
|
+
required_gates=[],
|
|
4041
|
+
)
|
|
4042
|
+
|
|
4043
|
+
argv = provider.build_command(spec, resolved_model=resolved_model)
|
|
4044
|
+
|
|
4045
|
+
# For remote: replace the binary (argv[0]) with the absolute path
|
|
4046
|
+
# to claude on the remote machine. A bare "claude" is not on the
|
|
4047
|
+
# SSH login PATH (#424/#425); ~/.local/bin/claude is the canonical
|
|
4048
|
+
# location installed by `claude` setup on Linux.
|
|
4049
|
+
_REMOTE_CLAUDE_BIN = "~/.local/bin/claude"
|
|
4050
|
+
if not _is_local:
|
|
4051
|
+
argv = [_REMOTE_CLAUDE_BIN] + list(argv)[1:]
|
|
4052
|
+
|
|
4053
|
+
_location = "local TTY" if _is_local else f"{machine_obj.host} (remote tmux)"
|
|
4054
|
+
click.echo(f"{machine} ({_location}) → {repo} #{issue}: {issue_title}")
|
|
4055
|
+
click.echo(" mode: HUMAN-ATTENDED interactive launch (#437)")
|
|
4056
|
+
click.echo(f" assignment id: {assignment_id}")
|
|
4057
|
+
click.echo(
|
|
4058
|
+
" the briefing will be PRE-FILLED in the input box; "
|
|
4059
|
+
"press Enter to submit; Ctrl-C / `/exit` to end the session."
|
|
4060
|
+
)
|
|
4061
|
+
if dry_run:
|
|
4062
|
+
from coord.agent import _slugify as _slugify_dry # noqa: PLC0415
|
|
4063
|
+
_dry_branch = f"issue-{issue}-{_slugify_dry(issue_title)}"
|
|
4064
|
+
click.echo(" (dry run — not launched)")
|
|
4065
|
+
click.echo(f" would exec: {argv}")
|
|
4066
|
+
if _is_local:
|
|
4067
|
+
click.echo(
|
|
4068
|
+
f" cwd: worktree for {_dry_branch} "
|
|
4069
|
+
f"(under ~/.coord/worktrees/<assignment_id>)"
|
|
4070
|
+
)
|
|
4071
|
+
else:
|
|
4072
|
+
_dry_wt = f"~/.coord/worktrees/{assignment_id}"
|
|
4073
|
+
click.echo(
|
|
4074
|
+
f" remote worktree: {_dry_wt} on {machine_obj.host}"
|
|
4075
|
+
f" (branch: {_dry_branch})"
|
|
4076
|
+
)
|
|
4077
|
+
return
|
|
4078
|
+
|
|
4079
|
+
if _is_local:
|
|
4080
|
+
# ── LOCAL PATH ────────────────────────────────────────────────
|
|
4081
|
+
# Byte-identical to the pre-#494 behaviour: create an isolated
|
|
4082
|
+
# worktree + feature branch locally via setup_interactive_worktree,
|
|
4083
|
+
# then attach the current terminal directly via
|
|
4084
|
+
# launch_human_attended_interactive.
|
|
4085
|
+
from coord.agent import ( # noqa: PLC0415
|
|
4086
|
+
_GitError as _AgentGitError,
|
|
4087
|
+
setup_interactive_worktree,
|
|
4088
|
+
)
|
|
4089
|
+
try:
|
|
4090
|
+
_wt_path, _interactive_branch = setup_interactive_worktree(
|
|
4091
|
+
Path(repo_path),
|
|
4092
|
+
issue_number=issue,
|
|
4093
|
+
issue_title=issue_title,
|
|
4094
|
+
assignment_id=assignment_id,
|
|
4095
|
+
default_branch=repo_default_branch,
|
|
4096
|
+
)
|
|
4097
|
+
worktree_path = str(_wt_path)
|
|
4098
|
+
except (_AgentGitError, OSError) as _wt_err:
|
|
4099
|
+
click.echo(
|
|
4100
|
+
f" error: could not create worktree for interactive session: {_wt_err}",
|
|
4101
|
+
err=True,
|
|
4102
|
+
)
|
|
4103
|
+
sys.exit(1)
|
|
4104
|
+
|
|
4105
|
+
click.echo(f" worktree: {worktree_path} (branch: {_interactive_branch})")
|
|
4106
|
+
|
|
4107
|
+
# State mutations (DB row, env var, board write) ONLY on real
|
|
4108
|
+
# dispatch — never in dry-run. Record up front so:
|
|
4109
|
+
# * claim detection refuses a duplicate the second the human
|
|
4110
|
+
# hits Enter on a parallel `coord assign --interactive`,
|
|
4111
|
+
# * the board shows the in-flight interactive session,
|
|
4112
|
+
# * the issue_store seam has a row to UPDATE on exit.
|
|
4113
|
+
record_dispatched(
|
|
4114
|
+
assignment_id=assignment_id,
|
|
4115
|
+
proposal=proposal,
|
|
4116
|
+
repo_github=repo_cfg.github,
|
|
4117
|
+
provider_name="claude-pty",
|
|
4118
|
+
)
|
|
4119
|
+
|
|
4120
|
+
# #466: Inject the assignment id into the agent's process env so
|
|
4121
|
+
# the interactive Claude session can run
|
|
4122
|
+
# `coord report-result --assignment $COORD_ASSIGNMENT_ID …` to
|
|
4123
|
+
# report a structured result before exiting. Also prepend a
|
|
4124
|
+
# short reminder to the briefing so the operator notices.
|
|
4125
|
+
#
|
|
4126
|
+
# #646: do NOT offer --verdict here — this is a work/plan session.
|
|
4127
|
+
# A verdict belongs only on a review session. Offering it led the
|
|
4128
|
+
# work agent to run `report-result --verdict approve` against its
|
|
4129
|
+
# OWN work id, stamping a bogus verdict and finalizing a still-live
|
|
4130
|
+
# session (the write seam now rejects that too, but don't tempt it).
|
|
4131
|
+
os.environ["COORD_ASSIGNMENT_ID"] = assignment_id
|
|
4132
|
+
report_reminder = (
|
|
4133
|
+
f"[Coordinator assignment {assignment_id}] "
|
|
4134
|
+
"Before you exit, please run `coord report-result "
|
|
4135
|
+
f"--assignment {assignment_id} --status <done|blocked|"
|
|
4136
|
+
"already-implemented> --summary <text>` so the coordinator "
|
|
4137
|
+
"records the result.\n\n"
|
|
4138
|
+
)
|
|
4139
|
+
effective_briefing = _issue_ctx + report_reminder + briefing + _ctx_write_hint
|
|
4140
|
+
|
|
4141
|
+
# Update board metadata (round_number / board_initialized).
|
|
4142
|
+
# `record_dispatched` already wrote the assignment row, so the
|
|
4143
|
+
# read_board → write_board round-trip is a no-op for the
|
|
4144
|
+
# assignments table; the useful side-effect is board_meta — and
|
|
4145
|
+
# write_board() now actually reaches the daemon on a thin client
|
|
4146
|
+
# instead of silently touching a local DB that isn't canonical (#749).
|
|
4147
|
+
write_board(read_board())
|
|
4148
|
+
|
|
4149
|
+
started_at = _time.time()
|
|
4150
|
+
# #487: pass assignment_id so the tmux path names the session
|
|
4151
|
+
# coord-<assignment_id>, enabling reattach after a TUI crash.
|
|
4152
|
+
exit_code = launch_human_attended_interactive(
|
|
4153
|
+
argv, effective_briefing, assignment_id=assignment_id, cwd=worktree_path,
|
|
4154
|
+
)
|
|
4155
|
+
if exit_code != 0:
|
|
4156
|
+
click.echo(f" claude exited with status {exit_code}", err=True)
|
|
4157
|
+
|
|
4158
|
+
# #487: if the tmux session is still alive the user just detached
|
|
4159
|
+
# (Ctrl-b d) or the TUI crashed. Skip finalize — the session is
|
|
4160
|
+
# still running. Tell the operator how to reattach and let them
|
|
4161
|
+
# close the session themselves (at which point coord report-result
|
|
4162
|
+
# or coord reattach will record the terminal state).
|
|
4163
|
+
_sname = _tmux_name(assignment_id) if _tmux_avail() else None
|
|
4164
|
+
if _sname and _tmux_alive(_sname):
|
|
4165
|
+
click.echo(
|
|
4166
|
+
f" session still running in tmux: {_sname}\n"
|
|
4167
|
+
f" reattach with: coord reattach {assignment_id}\n"
|
|
4168
|
+
f" or from shell: tmux attach-session -t {_sname}"
|
|
4169
|
+
)
|
|
4170
|
+
sys.exit(0)
|
|
4171
|
+
|
|
4172
|
+
# #466 — git-floor backstop. ALWAYS write a terminal state for
|
|
4173
|
+
# this assignment through the issue_store seam, regardless of
|
|
4174
|
+
# whether the agent typed `coord report-result` first. The
|
|
4175
|
+
# finalizer respects an existing report (it checks the DB row's
|
|
4176
|
+
# status before clobbering).
|
|
4177
|
+
# #982: a fresh interactive work session has no pre-existing
|
|
4178
|
+
# smoke_tests to narrow against (none are available until the
|
|
4179
|
+
# worker's own session finishes, and that session runs in a live
|
|
4180
|
+
# tmux/TTY pane rather than a log file this code can parse), so
|
|
4181
|
+
# this always stashes the full repo-wide glob — narrowing happens
|
|
4182
|
+
# centrally in AgentServer._stash_artifacts for the headless Work
|
|
4183
|
+
# path instead.
|
|
4184
|
+
try:
|
|
4185
|
+
finalize_result = finalize_interactive_exit(
|
|
4186
|
+
assignment_id=assignment_id,
|
|
4187
|
+
repo_name=repo,
|
|
4188
|
+
repo_github=repo_cfg.github,
|
|
4189
|
+
issue_number=issue,
|
|
4190
|
+
machine_name=machine,
|
|
4191
|
+
worktree_path=worktree_path,
|
|
4192
|
+
base_branch=repo_default_branch,
|
|
4193
|
+
exit_code=exit_code,
|
|
4194
|
+
started_at=started_at,
|
|
4195
|
+
log_path=None,
|
|
4196
|
+
repo_path=repo_path,
|
|
4197
|
+
artifact_paths=repo_cfg.artifact_paths,
|
|
4198
|
+
branch=_interactive_branch,
|
|
4199
|
+
)
|
|
4200
|
+
if finalize_result.already_recorded:
|
|
4201
|
+
click.echo(
|
|
4202
|
+
" result already recorded via `coord report-result`; "
|
|
4203
|
+
"backstop did not overwrite",
|
|
4204
|
+
)
|
|
4205
|
+
else:
|
|
4206
|
+
click.echo(
|
|
4207
|
+
f" backstop: status={finalize_result.terminal_status} "
|
|
4208
|
+
f"commits_ahead={finalize_result.commits_ahead}"
|
|
4209
|
+
)
|
|
4210
|
+
if not finalize_result.push_ok:
|
|
4211
|
+
click.echo(
|
|
4212
|
+
f" warning: git push failed: {finalize_result.push_error}",
|
|
4213
|
+
err=True,
|
|
4214
|
+
)
|
|
4215
|
+
except Exception as exc: # noqa: BLE001 — best-effort backstop
|
|
4216
|
+
click.echo(
|
|
4217
|
+
f" warning: backstop failed to record completion: {exc}",
|
|
4218
|
+
err=True,
|
|
4219
|
+
)
|
|
4220
|
+
|
|
4221
|
+
sys.exit(exit_code)
|
|
4222
|
+
|
|
4223
|
+
else:
|
|
4224
|
+
# ── REMOTE PATH (#494 / #486b) ────────────────────────────────
|
|
4225
|
+
# The target machine is not the local host. We create a named
|
|
4226
|
+
# tmux session ON THE REMOTE machine that:
|
|
4227
|
+
# 1. cd's into the remote repo checkout,
|
|
4228
|
+
# 2. fetches origin + prunes worktrees,
|
|
4229
|
+
# 3. creates a feature-branch worktree at
|
|
4230
|
+
# ~/.coord/worktrees/<assignment_id>,
|
|
4231
|
+
# 4. cd's into the worktree,
|
|
4232
|
+
# 5. launches claude with COORD_ASSIGNMENT_ID set inline.
|
|
4233
|
+
#
|
|
4234
|
+
# The local terminal ATTACHES to the remote tmux session so the
|
|
4235
|
+
# operator can drive it as if it were a local session.
|
|
4236
|
+
#
|
|
4237
|
+
# We use $HOME in the shell command (not ~) so that the paths
|
|
4238
|
+
# survive single-quote wrapping during remote transmission.
|
|
4239
|
+
# (~ inside single quotes is NOT expanded; $HOME inside a
|
|
4240
|
+
# tmux-run shell command IS expanded by the final shell.)
|
|
4241
|
+
import shlex as _shlex # noqa: PLC0415
|
|
4242
|
+
|
|
4243
|
+
from coord.agent import _slugify as _remote_slugify # noqa: PLC0415
|
|
4244
|
+
|
|
4245
|
+
# Mirror setup_interactive_worktree branch/worktree naming.
|
|
4246
|
+
_remote_branch = f"issue-{issue}-{_remote_slugify(issue_title)}"
|
|
4247
|
+
_remote_wt = "$HOME/.coord/worktrees/" + assignment_id
|
|
4248
|
+
|
|
4249
|
+
# repo_path may be ~/src/repo or an absolute path; replace
|
|
4250
|
+
# leading ~/ with $HOME/ so the shell expands it correctly.
|
|
4251
|
+
_rp_sh = (
|
|
4252
|
+
"$HOME/" + repo_path[2:]
|
|
4253
|
+
if repo_path.startswith("~/")
|
|
4254
|
+
else ("$HOME" if repo_path == "~" else repo_path)
|
|
4255
|
+
)
|
|
4256
|
+
|
|
4257
|
+
# Build the shell command the remote tmux session will run.
|
|
4258
|
+
# Tries fresh -b first (new branch from origin/default), falls
|
|
4259
|
+
# back to -B (force-reset) from origin/<branch> (retry case),
|
|
4260
|
+
# then from origin/<default> as a last resort.
|
|
4261
|
+
_claude_args = _shlex.join(list(argv)[1:])
|
|
4262
|
+
_remote_cmd = (
|
|
4263
|
+
f"mkdir -p $HOME/.coord/worktrees"
|
|
4264
|
+
f" && cd {_rp_sh}"
|
|
4265
|
+
f" && git fetch origin --prune 2>/dev/null || true"
|
|
4266
|
+
f" && git worktree prune 2>/dev/null || true"
|
|
4267
|
+
f" && (git worktree add -b {_remote_branch} {_remote_wt}"
|
|
4268
|
+
f" origin/{repo_default_branch} 2>/dev/null"
|
|
4269
|
+
f" || git worktree add -B {_remote_branch} {_remote_wt}"
|
|
4270
|
+
f" origin/{_remote_branch} 2>/dev/null"
|
|
4271
|
+
f" || git worktree add -B {_remote_branch} {_remote_wt}"
|
|
4272
|
+
f" origin/{repo_default_branch})"
|
|
4273
|
+
f" && cd {_remote_wt}"
|
|
4274
|
+
f" && COORD_ASSIGNMENT_ID={assignment_id}"
|
|
4275
|
+
f" {argv[0]} {_claude_args}"
|
|
4276
|
+
)
|
|
4277
|
+
|
|
4278
|
+
_tmux_host = TmuxHost(ssh_target=machine_obj.host)
|
|
4279
|
+
_sname = _tmux_name(assignment_id)
|
|
4280
|
+
|
|
4281
|
+
click.echo(
|
|
4282
|
+
f" remote worktree: $HOME/.coord/worktrees/{assignment_id}"
|
|
4283
|
+
f" on {machine_obj.host} (branch: {_remote_branch})"
|
|
4284
|
+
)
|
|
4285
|
+
|
|
4286
|
+
# State mutations (DB row, env var, board write) — same as local.
|
|
4287
|
+
record_dispatched(
|
|
4288
|
+
assignment_id=assignment_id,
|
|
4289
|
+
proposal=proposal,
|
|
4290
|
+
repo_github=repo_cfg.github,
|
|
4291
|
+
provider_name="claude-pty",
|
|
4292
|
+
)
|
|
4293
|
+
# #486d: record the remote feature branch on the assignment so a
|
|
4294
|
+
# later `coord reattach` can push it back (record_dispatched writes
|
|
4295
|
+
# from the Proposal, which carries no branch).
|
|
4296
|
+
try:
|
|
4297
|
+
from coord.state import get_connection as _gc_wb # noqa: PLC0415
|
|
4298
|
+
_conn_wb = _gc_wb()
|
|
4299
|
+
_conn_wb.execute(
|
|
4300
|
+
"UPDATE assignments SET branch=? WHERE assignment_id=?",
|
|
4301
|
+
(_remote_branch, assignment_id),
|
|
4302
|
+
)
|
|
4303
|
+
_conn_wb.commit()
|
|
4304
|
+
except Exception: # noqa: BLE001
|
|
4305
|
+
pass
|
|
4306
|
+
|
|
4307
|
+
# Set COORD_ASSIGNMENT_ID in the local coordinator env as well
|
|
4308
|
+
# (for symmetry with local path; the remote process gets it
|
|
4309
|
+
# inline via the shell command).
|
|
4310
|
+
os.environ["COORD_ASSIGNMENT_ID"] = assignment_id
|
|
4311
|
+
# #646: no --verdict for a work/plan session (review-only field).
|
|
4312
|
+
report_reminder = (
|
|
4313
|
+
f"[Coordinator assignment {assignment_id}] "
|
|
4314
|
+
"Before you exit, please run `coord report-result "
|
|
4315
|
+
f"--assignment {assignment_id} --status <done|blocked|"
|
|
4316
|
+
"already-implemented> --summary <text>` so the coordinator "
|
|
4317
|
+
"records the result.\n\n"
|
|
4318
|
+
)
|
|
4319
|
+
effective_briefing = _issue_ctx + report_reminder + briefing + _ctx_write_hint
|
|
4320
|
+
|
|
4321
|
+
write_board(read_board())
|
|
4322
|
+
|
|
4323
|
+
# Echo briefing to the LOCAL terminal before connecting to the
|
|
4324
|
+
# remote session, so the operator can read it before pressing
|
|
4325
|
+
# Enter (mirrors the tmux path in launch_human_attended_interactive).
|
|
4326
|
+
if effective_briefing.strip():
|
|
4327
|
+
_hdr = (
|
|
4328
|
+
"--- seeded briefing -- review below; "
|
|
4329
|
+
"submit the pre-filled input in Claude to send ---"
|
|
4330
|
+
)
|
|
4331
|
+
_ftr = "-" * len(_hdr)
|
|
4332
|
+
_preview = f"\n{_hdr}\n{effective_briefing.rstrip()}\n{_ftr}\n\n"
|
|
4333
|
+
try:
|
|
4334
|
+
os.write(sys.stdout.fileno(), _preview.encode("utf-8"))
|
|
4335
|
+
except OSError:
|
|
4336
|
+
pass
|
|
4337
|
+
|
|
4338
|
+
# Launch the remote tmux session and attach to it. Pass
|
|
4339
|
+
# raw_shell_cmd so _launch_via_tmux uses the verbatim shell
|
|
4340
|
+
# command (with $HOME paths and && operators) rather than
|
|
4341
|
+
# re-quoting argv through shlex.join.
|
|
4342
|
+
started_at = _time.time()
|
|
4343
|
+
_rc = _tmux_launch(
|
|
4344
|
+
argv,
|
|
4345
|
+
effective_briefing,
|
|
4346
|
+
_sname,
|
|
4347
|
+
cwd=None,
|
|
4348
|
+
host=_tmux_host,
|
|
4349
|
+
raw_shell_cmd=_remote_cmd,
|
|
4350
|
+
)
|
|
4351
|
+
if _rc is None:
|
|
4352
|
+
click.echo(
|
|
4353
|
+
f" error: could not create remote tmux session on"
|
|
4354
|
+
f" {machine_obj.host}",
|
|
4355
|
+
err=True,
|
|
4356
|
+
)
|
|
4357
|
+
sys.exit(1)
|
|
4358
|
+
exit_code = _rc
|
|
4359
|
+
|
|
4360
|
+
# Check if the remote session is still alive (user detached).
|
|
4361
|
+
if _tmux_alive(_sname, host=_tmux_host):
|
|
4362
|
+
click.echo(
|
|
4363
|
+
f" session still running in remote tmux: {_sname}\n"
|
|
4364
|
+
f" reattach with: coord reattach {assignment_id}\n"
|
|
4365
|
+
" (work commits are pushed when the session ends and the "
|
|
4366
|
+
"coordinator finalizes)"
|
|
4367
|
+
)
|
|
4368
|
+
sys.exit(0)
|
|
4369
|
+
|
|
4370
|
+
# ── #560: detect setup failures (worktree never created) ──────
|
|
4371
|
+
_wt_setup_ok_work: bool = True
|
|
4372
|
+
if exit_code != 0:
|
|
4373
|
+
if not _remote_wt_exists(machine_obj.host, _remote_wt):
|
|
4374
|
+
_wt_setup_ok_work = False
|
|
4375
|
+
_holder = _find_branch_holder(
|
|
4376
|
+
machine_obj.host, _rp_sh, _remote_branch,
|
|
4377
|
+
)
|
|
4378
|
+
if _holder:
|
|
4379
|
+
_holder_aid = Path(_holder).name
|
|
4380
|
+
_holder_sname = f"coord-{_holder_aid}"
|
|
4381
|
+
_holder_live = _tmux_alive(_holder_sname, host=_tmux_host)
|
|
4382
|
+
_err_header = (
|
|
4383
|
+
f" error: setup failed — branch {_remote_branch!r} is "
|
|
4384
|
+
f"already checked out at {_holder} on {machine_obj.host}."
|
|
4385
|
+
)
|
|
4386
|
+
if _holder_live:
|
|
4387
|
+
click.echo("\n".join([
|
|
4388
|
+
_err_header,
|
|
4389
|
+
f" active tmux session: {_holder_sname}",
|
|
4390
|
+
f" reattach: coord reattach {_holder_aid}",
|
|
4391
|
+
" exit the session first, then retry this work.",
|
|
4392
|
+
]), err=True)
|
|
4393
|
+
else:
|
|
4394
|
+
# Dead holder — detect base checkout vs stale orphan (#814).
|
|
4395
|
+
if _holder_is_base(_holder):
|
|
4396
|
+
# #814: the base checkout ~/src/<repo> is on the
|
|
4397
|
+
# work branch. NEVER prune the base (#561) —
|
|
4398
|
+
# checkout the default branch to free the lock.
|
|
4399
|
+
click.echo(
|
|
4400
|
+
f" base checkout {_holder!r} is on branch"
|
|
4401
|
+
f" {_remote_branch!r} on {machine_obj.host}"
|
|
4402
|
+
f" — checking out {repo_default_branch!r}"
|
|
4403
|
+
f" to free it …"
|
|
4404
|
+
)
|
|
4405
|
+
_freed = _remote_free_base(
|
|
4406
|
+
machine_obj.host, _rp_sh, repo_default_branch,
|
|
4407
|
+
)
|
|
4408
|
+
if _freed:
|
|
4409
|
+
click.echo(
|
|
4410
|
+
" base checkout freed — retrying launch …"
|
|
4411
|
+
)
|
|
4412
|
+
_rc2 = _tmux_launch(
|
|
4413
|
+
argv, effective_briefing, _sname,
|
|
4414
|
+
cwd=None, host=_tmux_host,
|
|
4415
|
+
raw_shell_cmd=_remote_cmd,
|
|
4416
|
+
)
|
|
4417
|
+
if _rc2 is None:
|
|
4418
|
+
click.echo(
|
|
4419
|
+
f" error: could not create remote tmux"
|
|
4420
|
+
f" session on {machine_obj.host}"
|
|
4421
|
+
f" (retry after base-checkout free)",
|
|
4422
|
+
err=True,
|
|
4423
|
+
)
|
|
4424
|
+
else:
|
|
4425
|
+
exit_code = _rc2
|
|
4426
|
+
if _tmux_alive(_sname, host=_tmux_host):
|
|
4427
|
+
click.echo(
|
|
4428
|
+
f" session still running in remote"
|
|
4429
|
+
f" tmux: {_sname}\n"
|
|
4430
|
+
f" reattach with: coord reattach"
|
|
4431
|
+
f" {assignment_id}\n"
|
|
4432
|
+
" (work commits are pushed when"
|
|
4433
|
+
" the session ends and the"
|
|
4434
|
+
" coordinator finalizes)"
|
|
4435
|
+
)
|
|
4436
|
+
sys.exit(0)
|
|
4437
|
+
if (
|
|
4438
|
+
exit_code == 0
|
|
4439
|
+
or _remote_wt_exists(
|
|
4440
|
+
machine_obj.host, _remote_wt
|
|
4441
|
+
)
|
|
4442
|
+
):
|
|
4443
|
+
_wt_setup_ok_work = True # retry succeeded
|
|
4444
|
+
else:
|
|
4445
|
+
click.echo("\n".join([
|
|
4446
|
+
_err_header,
|
|
4447
|
+
f" base checkout is stuck on"
|
|
4448
|
+
f" {_remote_branch!r} —",
|
|
4449
|
+
f" free it manually with:",
|
|
4450
|
+
f" ssh {machine_obj.host}"
|
|
4451
|
+
f" 'git -C {_rp_sh} checkout"
|
|
4452
|
+
f" {repo_default_branch}'",
|
|
4453
|
+
]), err=True)
|
|
4454
|
+
else:
|
|
4455
|
+
# Dead orphan — safety-gated auto-prune-and-retry (#759).
|
|
4456
|
+
_auto_pruned = False
|
|
4457
|
+
if _remote_orphan_safe(
|
|
4458
|
+
machine_obj.host, _rp_sh, _holder, _remote_branch,
|
|
4459
|
+
):
|
|
4460
|
+
click.echo(
|
|
4461
|
+
f" auto-pruning stale orphan {_holder}"
|
|
4462
|
+
f" on {machine_obj.host} …"
|
|
4463
|
+
)
|
|
4464
|
+
_auto_pruned = _remote_wt_remove(
|
|
4465
|
+
machine_obj.host, _rp_sh, _holder,
|
|
4466
|
+
)
|
|
4467
|
+
if _auto_pruned:
|
|
4468
|
+
click.echo(
|
|
4469
|
+
" stale orphan auto-pruned — retrying launch …"
|
|
4470
|
+
)
|
|
4471
|
+
_rc2 = _tmux_launch(
|
|
4472
|
+
argv, effective_briefing, _sname,
|
|
4473
|
+
cwd=None, host=_tmux_host,
|
|
4474
|
+
raw_shell_cmd=_remote_cmd,
|
|
4475
|
+
)
|
|
4476
|
+
if _rc2 is None:
|
|
4477
|
+
click.echo(
|
|
4478
|
+
f" error: could not create remote tmux"
|
|
4479
|
+
f" session on {machine_obj.host}"
|
|
4480
|
+
f" (retry after prune)",
|
|
4481
|
+
err=True,
|
|
4482
|
+
)
|
|
4483
|
+
else:
|
|
4484
|
+
exit_code = _rc2
|
|
4485
|
+
if _tmux_alive(_sname, host=_tmux_host):
|
|
4486
|
+
click.echo(
|
|
4487
|
+
f" session still running in remote"
|
|
4488
|
+
f" tmux: {_sname}\n"
|
|
4489
|
+
f" reattach with: coord reattach"
|
|
4490
|
+
f" {assignment_id}\n"
|
|
4491
|
+
" (work commits are pushed when"
|
|
4492
|
+
" the session ends and the"
|
|
4493
|
+
" coordinator finalizes)"
|
|
4494
|
+
)
|
|
4495
|
+
sys.exit(0)
|
|
4496
|
+
if (
|
|
4497
|
+
exit_code == 0
|
|
4498
|
+
or _remote_wt_exists(
|
|
4499
|
+
machine_obj.host, _remote_wt
|
|
4500
|
+
)
|
|
4501
|
+
):
|
|
4502
|
+
_wt_setup_ok_work = True # retry succeeded
|
|
4503
|
+
else:
|
|
4504
|
+
# Not safe or prune failed → manual command.
|
|
4505
|
+
click.echo("\n".join([
|
|
4506
|
+
_err_header,
|
|
4507
|
+
" stale worktree — prune it first:",
|
|
4508
|
+
f" ssh {machine_obj.host} 'cd {_rp_sh}"
|
|
4509
|
+
f" && git worktree remove --force"
|
|
4510
|
+
f" {_shlex.quote(_holder)}'",
|
|
4511
|
+
]), err=True)
|
|
4512
|
+
else:
|
|
4513
|
+
click.echo(
|
|
4514
|
+
f" error: setup failed — the remote worktree was "
|
|
4515
|
+
f"never created (git worktree add refused on "
|
|
4516
|
+
f"{machine_obj.host}).",
|
|
4517
|
+
err=True,
|
|
4518
|
+
)
|
|
4519
|
+
else:
|
|
4520
|
+
click.echo(
|
|
4521
|
+
f" claude exited with status {exit_code}", err=True,
|
|
4522
|
+
)
|
|
4523
|
+
|
|
4524
|
+
# Remote finalize (#486d): push the work commits to origin/<branch>,
|
|
4525
|
+
# record the completion (so the pipeline advances + a re-review can
|
|
4526
|
+
# fire), and clean up the remote worktree. Mirrors the remote-FIX
|
|
4527
|
+
# path; the only difference is the branch is the fresh feature
|
|
4528
|
+
# branch this work session created, not an existing one.
|
|
4529
|
+
# #982: same as the local finalize above — no pre-existing
|
|
4530
|
+
# smoke_tests for a fresh interactive work session, so this always
|
|
4531
|
+
# stashes the full repo-wide glob.
|
|
4532
|
+
try:
|
|
4533
|
+
_fr = finalize_remote_interactive_exit(
|
|
4534
|
+
assignment_id=assignment_id,
|
|
4535
|
+
repo_name=repo,
|
|
4536
|
+
repo_github=repo_cfg.github,
|
|
4537
|
+
issue_number=issue,
|
|
4538
|
+
machine_name=machine,
|
|
4539
|
+
ssh_target=machine_obj.host,
|
|
4540
|
+
remote_worktree_sh=_remote_wt,
|
|
4541
|
+
remote_repo_sh=_rp_sh,
|
|
4542
|
+
branch=_remote_branch,
|
|
4543
|
+
base_branch=repo_default_branch,
|
|
4544
|
+
exit_code=exit_code,
|
|
4545
|
+
started_at=started_at,
|
|
4546
|
+
artifact_paths=repo_cfg.artifact_paths,
|
|
4547
|
+
)
|
|
4548
|
+
if _fr.already_recorded:
|
|
4549
|
+
click.echo(
|
|
4550
|
+
" result recorded via `coord report-result`; remote "
|
|
4551
|
+
"backstop did not overwrite"
|
|
4552
|
+
)
|
|
4553
|
+
else:
|
|
4554
|
+
click.echo(
|
|
4555
|
+
f" remote backstop: status={_fr.terminal_status} "
|
|
4556
|
+
f"commits_ahead={_fr.commits_ahead} pushed={_fr.push_ok}"
|
|
4557
|
+
)
|
|
4558
|
+
if not _fr.push_ok and _wt_setup_ok_work:
|
|
4559
|
+
click.echo(
|
|
4560
|
+
f" warning: remote push failed: {_fr.push_error}",
|
|
4561
|
+
err=True,
|
|
4562
|
+
)
|
|
4563
|
+
click.echo(
|
|
4564
|
+
f" work commits preserved in {_remote_wt} on "
|
|
4565
|
+
f"{machine_obj.host} (worktree NOT removed)",
|
|
4566
|
+
err=True,
|
|
4567
|
+
)
|
|
4568
|
+
# else _wt_setup_ok_work is False: setup never happened; error
|
|
4569
|
+
# already printed above — no "commits preserved" noise.
|
|
4570
|
+
_echo_artifact_stash(_fr)
|
|
4571
|
+
except Exception as exc: # noqa: BLE001 — best-effort backstop
|
|
4572
|
+
click.echo(
|
|
4573
|
+
f" warning: remote backstop failed to record work exit: {exc}",
|
|
4574
|
+
err=True,
|
|
4575
|
+
)
|
|
4576
|
+
|
|
4577
|
+
sys.exit(exit_code)
|
|
4578
|
+
|
|
4579
|
+
|
|
4580
|
+
def _dispatch_headless(
|
|
4581
|
+
*,
|
|
4582
|
+
machine: str,
|
|
4583
|
+
repo: str,
|
|
4584
|
+
issue: int,
|
|
4585
|
+
briefing: str,
|
|
4586
|
+
model: str | None,
|
|
4587
|
+
dry_run: bool,
|
|
4588
|
+
plan_only: bool,
|
|
4589
|
+
no_plan: bool,
|
|
4590
|
+
force: bool,
|
|
4591
|
+
no_pull: bool,
|
|
4592
|
+
skip_freshness: bool,
|
|
4593
|
+
cfg: Config,
|
|
4594
|
+
machine_obj: object,
|
|
4595
|
+
repo_cfg: object,
|
|
4596
|
+
issue_data: dict,
|
|
4597
|
+
issue_title: str,
|
|
4598
|
+
driven_by: str | None = None,
|
|
4599
|
+
provider: str | None = None,
|
|
4600
|
+
) -> None:
|
|
4601
|
+
"""The plain (non --interactive) HTTP-dispatch path: build a Proposal,
|
|
4602
|
+
run the claim + dependency-freshness checks, POST to the agent server,
|
|
4603
|
+
and record + post the briefing.
|
|
4604
|
+
|
|
4605
|
+
``driven_by`` (#1499): durable provenance threaded from ``coord assign
|
|
4606
|
+
--driven-by`` (set by ``coord drive``'s work-stage dispatch). ``None``
|
|
4607
|
+
for a hand ``coord assign`` — the overwhelming majority of callers.
|
|
4608
|
+
|
|
4609
|
+
``provider`` (#1707): the raw ``--provider`` flag value, already
|
|
4610
|
+
validated against ``cfg.providers.definitions`` by ``assign()`` before
|
|
4611
|
+
this function runs. ``None`` (the default — no flag given) reproduces
|
|
4612
|
+
pre-#1707 behaviour exactly: ``Proposal.provider`` stays ``None`` and
|
|
4613
|
+
``coord.dispatch.dispatch()``'s spec → repo → providers.default
|
|
4614
|
+
precedence chain falls through to the repo/global default unchanged.
|
|
4615
|
+
"""
|
|
4616
|
+
from coord.board_service import read_board, write_board # noqa: PLC0415
|
|
4617
|
+
from coord.dispatch import ( # noqa: PLC0415
|
|
4618
|
+
DispatchRefused,
|
|
4619
|
+
dispatch,
|
|
4620
|
+
post_briefing,
|
|
4621
|
+
resolve_dispatch_model_alias,
|
|
4622
|
+
)
|
|
4623
|
+
from coord.providers import resolve_provider_name # noqa: PLC0415
|
|
4624
|
+
from coord.state import record_dispatched # noqa: PLC0415
|
|
4625
|
+
|
|
4626
|
+
|
|
4627
|
+
# Build a Proposal inline
|
|
4628
|
+
from coord.models import Proposal
|
|
4629
|
+
|
|
4630
|
+
# Resolve required_gates: check issue labels against pipeline.labels config,
|
|
4631
|
+
# fall back to pipeline.default_gates.
|
|
4632
|
+
issue_labels: list[str] = [
|
|
4633
|
+
lbl.get("name", "") for lbl in (issue_data.get("labels") or [])
|
|
4634
|
+
]
|
|
4635
|
+
resolved_gates: list[str] = list(cfg.pipeline.default_gates)
|
|
4636
|
+
for lbl in issue_labels:
|
|
4637
|
+
if lbl in cfg.pipeline.labels:
|
|
4638
|
+
resolved_gates = list(cfg.pipeline.labels[lbl])
|
|
4639
|
+
break
|
|
4640
|
+
|
|
4641
|
+
# Determine effective plan-only mode.
|
|
4642
|
+
# --plan-only always wins; --no-plan overrides dispatch.require_plan;
|
|
4643
|
+
# otherwise dispatch.require_plan sets the default.
|
|
4644
|
+
effective_plan_only = plan_only or (cfg.dispatch.require_plan and not no_plan)
|
|
4645
|
+
|
|
4646
|
+
# #1707: resolve the effective provider BEFORE the model, so model
|
|
4647
|
+
# resolution can be provider-aware (#1706 review fix, below).
|
|
4648
|
+
# #1889: providers.labels (work dispatch only, mirrors models.labels'
|
|
4649
|
+
# #1430 gating) — must be resolved with the SAME issue_labels the model
|
|
4650
|
+
# resolution below uses, or the two could silently disagree about which
|
|
4651
|
+
# provider is effective.
|
|
4652
|
+
effective_provider_name = resolve_provider_name(
|
|
4653
|
+
provider, repo_cfg.provider, cfg.providers,
|
|
4654
|
+
issue_labels=issue_labels if not effective_plan_only else None,
|
|
4655
|
+
)
|
|
4656
|
+
|
|
4657
|
+
# Resolve model: --model flag → models.labels (work dispatch only) →
|
|
4658
|
+
# the effective provider's own pinned model (non-claude/claude-pty
|
|
4659
|
+
# only) → config default. #1430: plan workers are read-only/cheap and
|
|
4660
|
+
# must not inherit a tier:large -> opus routing meant for the eventual
|
|
4661
|
+
# work dispatch, so label resolution only applies when this is a work
|
|
4662
|
+
# dispatch, not a plan-only one. #1706: `resolve_dispatch_model_alias`
|
|
4663
|
+
# is the single place the provider-aware exception to `models.default`
|
|
4664
|
+
# lives — see its docstring. Without this, `resolved_model` was always
|
|
4665
|
+
# truthy by the time it reached `Proposal.model`, which meant
|
|
4666
|
+
# `coord.dispatch.dispatch()`'s own (also provider-aware)
|
|
4667
|
+
# `definition.model` fallback could never fire for a plain `coord
|
|
4668
|
+
# assign` — the primary scenario #1706 was meant to unblock.
|
|
4669
|
+
label_model, matched_label, shadowed_labels = (
|
|
4670
|
+
cfg.models.model_for_labels_with_reason(issue_labels)
|
|
4671
|
+
if not effective_plan_only
|
|
4672
|
+
else (None, None, [])
|
|
4673
|
+
)
|
|
4674
|
+
resolved_model = resolve_dispatch_model_alias(
|
|
4675
|
+
explicit_model=model,
|
|
4676
|
+
label_model=label_model,
|
|
4677
|
+
config=cfg,
|
|
4678
|
+
effective_provider_name=effective_provider_name,
|
|
4679
|
+
)
|
|
4680
|
+
|
|
4681
|
+
proposal = Proposal(
|
|
4682
|
+
id=0,
|
|
4683
|
+
machine_name=machine,
|
|
4684
|
+
repo_name=repo,
|
|
4685
|
+
issue_number=issue,
|
|
4686
|
+
issue_title=issue_title,
|
|
4687
|
+
rationale="manual assignment via coord assign",
|
|
4688
|
+
briefing=briefing,
|
|
4689
|
+
model=resolved_model,
|
|
4690
|
+
type="plan" if effective_plan_only else "work",
|
|
4691
|
+
required_gates=resolved_gates,
|
|
4692
|
+
issue_labels=issue_labels,
|
|
4693
|
+
driven_by=driven_by,
|
|
4694
|
+
provider=provider,
|
|
4695
|
+
)
|
|
4696
|
+
|
|
4697
|
+
click.echo(f"{machine} → {repo} #{issue}: {issue_title}")
|
|
4698
|
+
if driven_by:
|
|
4699
|
+
click.echo(f" driven by: {driven_by}")
|
|
4700
|
+
if effective_plan_only:
|
|
4701
|
+
if cfg.dispatch.require_plan and not plan_only:
|
|
4702
|
+
click.echo(" mode: plan-only (dispatch.require_plan=true; use --no-plan to override)")
|
|
4703
|
+
else:
|
|
4704
|
+
click.echo(" mode: plan-only (read-only, no worktree)")
|
|
4705
|
+
if resolved_model:
|
|
4706
|
+
# #1454: state *why* this model was picked — a silent fall-through
|
|
4707
|
+
# to models.default (stale/missing label) used to be indistinguishable
|
|
4708
|
+
# from an intentional default in this same line of output.
|
|
4709
|
+
from coord.config import describe_model_choice # noqa: PLC0415
|
|
4710
|
+
|
|
4711
|
+
click.echo(
|
|
4712
|
+
" model: "
|
|
4713
|
+
+ describe_model_choice(
|
|
4714
|
+
resolved_model=resolved_model,
|
|
4715
|
+
explicit_reason="explicit --model" if model else None,
|
|
4716
|
+
matched_label=matched_label,
|
|
4717
|
+
shadowed_labels=shadowed_labels,
|
|
4718
|
+
)
|
|
4719
|
+
)
|
|
4720
|
+
else:
|
|
4721
|
+
# #1706/#1798: resolved_model is None when the effective provider's
|
|
4722
|
+
# own `providers.definitions.<name>.model` is pinned and --model
|
|
4723
|
+
# wasn't given — state that explicitly instead of silently printing
|
|
4724
|
+
# nothing, so the operator can see --model was deliberately left
|
|
4725
|
+
# off the wire payload in favor of the pin. #1798: this now also
|
|
4726
|
+
# covers the case where a label DID match (e.g. `tier:small`) but
|
|
4727
|
+
# lost to the pin — surface that explicitly too, since a
|
|
4728
|
+
# namespace-mismatched label (a Claude alias) silently losing to a
|
|
4729
|
+
# non-claude provider's pin is exactly the fix this issue made.
|
|
4730
|
+
_pinned = cfg.providers.definitions.get(effective_provider_name)
|
|
4731
|
+
if _pinned is not None and _pinned.model:
|
|
4732
|
+
_via = f"providers.definitions[{effective_provider_name!r}].model"
|
|
4733
|
+
if matched_label:
|
|
4734
|
+
click.echo(
|
|
4735
|
+
f" model: {_pinned.model} (via {_via}, "
|
|
4736
|
+
f"overriding label {matched_label!r})"
|
|
4737
|
+
)
|
|
4738
|
+
else:
|
|
4739
|
+
click.echo(f" model: {_pinned.model} (via {_via})")
|
|
4740
|
+
|
|
4741
|
+
# #1707: mirror the --model reasoning line above — state which link of
|
|
4742
|
+
# the spec (--provider) → providers.labels → repo (Repo.provider) →
|
|
4743
|
+
# providers.default chain (coord.providers.resolve_provider_name) won,
|
|
4744
|
+
# so a mixed claude/opencode fleet is legible at dispatch time
|
|
4745
|
+
# (including under --dry-run, before any of it is committed) instead of
|
|
4746
|
+
# only discoverable via coordinator.yml. #1889: issue_labels threaded
|
|
4747
|
+
# through so a providers.labels match (e.g. `harness:opencode`) is named
|
|
4748
|
+
# explicitly, not just inferred from source.
|
|
4749
|
+
from coord.providers import describe_provider_choice # noqa: PLC0415
|
|
4750
|
+
|
|
4751
|
+
click.echo(
|
|
4752
|
+
" provider: "
|
|
4753
|
+
+ describe_provider_choice(
|
|
4754
|
+
spec_provider=provider,
|
|
4755
|
+
repo_provider=repo_cfg.provider,
|
|
4756
|
+
providers_cfg=cfg.providers,
|
|
4757
|
+
issue_labels=issue_labels if not effective_plan_only else None,
|
|
4758
|
+
)
|
|
4759
|
+
)
|
|
4760
|
+
|
|
4761
|
+
if dry_run:
|
|
4762
|
+
click.echo(" (dry run — not dispatched)")
|
|
4763
|
+
return
|
|
4764
|
+
|
|
4765
|
+
# Claim check
|
|
4766
|
+
from coord.claim import claim_message, find_work_claim
|
|
4767
|
+
|
|
4768
|
+
board = read_board()
|
|
4769
|
+
if not force:
|
|
4770
|
+
claim = find_work_claim(issue, repo, repo_cfg.github, board)
|
|
4771
|
+
if claim is not None:
|
|
4772
|
+
click.echo(
|
|
4773
|
+
f" skipping: {claim_message(claim)}",
|
|
4774
|
+
err=True,
|
|
4775
|
+
)
|
|
4776
|
+
sys.exit(1)
|
|
4777
|
+
|
|
4778
|
+
# #267: dependency freshness check — same machinery `coord approve`
|
|
4779
|
+
# uses. Default for `coord assign` is `--auto-pull` (the manual /
|
|
4780
|
+
# right-click dispatch path is a deliberate user action; we want it
|
|
4781
|
+
# to be safe by default). `--no-pull` falls back to the briefing
|
|
4782
|
+
# addendum; `--skip-freshness` bypasses entirely.
|
|
4783
|
+
# #268: `relevant_repos` covers both transitive `depends_on` (build
|
|
4784
|
+
# deps) and direct `reference_repos` (context).
|
|
4785
|
+
pull_repos: list[str] = []
|
|
4786
|
+
if not skip_freshness:
|
|
4787
|
+
from coord import freshness as _fresh # noqa: PLC0415
|
|
4788
|
+
from coord.network import fetch_repos # noqa: PLC0415
|
|
4789
|
+
|
|
4790
|
+
agent_repos = fetch_repos(machine_obj) or {}
|
|
4791
|
+
|
|
4792
|
+
repos_needed = _fresh.relevant_repos(proposal, cfg)
|
|
4793
|
+
github_heads: dict[str, str | None] = {}
|
|
4794
|
+
for dep_name, _kind in repos_needed:
|
|
4795
|
+
dep_cfg = cfg.repo(dep_name)
|
|
4796
|
+
if dep_cfg is None:
|
|
4797
|
+
github_heads[dep_name] = None
|
|
4798
|
+
continue
|
|
4799
|
+
try:
|
|
4800
|
+
github_heads[dep_name] = github_ops.get_default_branch_head(
|
|
4801
|
+
dep_cfg.github, dep_cfg.default_branch
|
|
4802
|
+
)
|
|
4803
|
+
except RuntimeError as e:
|
|
4804
|
+
click.echo(
|
|
4805
|
+
f" warning: could not get HEAD of {dep_cfg.github}: {e}",
|
|
4806
|
+
err=True,
|
|
4807
|
+
)
|
|
4808
|
+
github_heads[dep_name] = None
|
|
4809
|
+
|
|
4810
|
+
freshness = _fresh.dependency_freshness(
|
|
4811
|
+
proposal, cfg, agent_repos, github_heads
|
|
4812
|
+
)
|
|
4813
|
+
needs = _fresh.stale_or_dirty(freshness)
|
|
4814
|
+
if needs:
|
|
4815
|
+
for f in needs:
|
|
4816
|
+
click.echo(
|
|
4817
|
+
f" dependency {f.repo_name}: {f.state}"
|
|
4818
|
+
+ (f" ({f.error})" if f.error else ""),
|
|
4819
|
+
)
|
|
4820
|
+
if not no_pull:
|
|
4821
|
+
pull_repos = [f.repo_name for f in needs if f.state == _fresh.STALE]
|
|
4822
|
+
if pull_repos:
|
|
4823
|
+
click.echo(f" will pull on agent before worker: {pull_repos}")
|
|
4824
|
+
else:
|
|
4825
|
+
addendum = _fresh.format_briefing_addendum(freshness)
|
|
4826
|
+
if addendum:
|
|
4827
|
+
proposal.briefing = (proposal.briefing or "") + addendum
|
|
4828
|
+
|
|
4829
|
+
# Dispatch to agent server
|
|
4830
|
+
try:
|
|
4831
|
+
response = dispatch(
|
|
4832
|
+
proposal, cfg, pull_repos=pull_repos, fresh_branch=force,
|
|
4833
|
+
)
|
|
4834
|
+
except httpx.HTTPError as e:
|
|
4835
|
+
click.echo(f" dispatch failed: {e}", err=True)
|
|
4836
|
+
sys.exit(1)
|
|
4837
|
+
except DispatchRefused as e:
|
|
4838
|
+
# #1844: `enforce_oracle_readiness`/`enforce_epic_dispatch_guard`
|
|
4839
|
+
# raise THIS (a `ValueError` subclass, caught before the generic
|
|
4840
|
+
# branch below) specifically because their refusal is DETERMINISTIC —
|
|
4841
|
+
# nothing about retrying this exact command changes the condition
|
|
4842
|
+
# that refused it (no acceptance slice appears, no label gets added).
|
|
4843
|
+
# A distinct exit code is what lets `coord drive`'s own subprocess
|
|
4844
|
+
# call — and, through it, `coord drive-queue`'s tick — tell this
|
|
4845
|
+
# apart from a crash instead of retrying a guaranteed-to-fail
|
|
4846
|
+
# dispatch until `max_attempts` is exhausted (the #1817 overnight
|
|
4847
|
+
# incident this issue is named for).
|
|
4848
|
+
from coord.drive import EXIT_DISPATCH_REFUSED # noqa: PLC0415
|
|
4849
|
+
|
|
4850
|
+
click.echo(f" dispatch failed: {e}", err=True)
|
|
4851
|
+
sys.exit(EXIT_DISPATCH_REFUSED)
|
|
4852
|
+
except ValueError as e:
|
|
4853
|
+
click.echo(f" dispatch failed: {e}", err=True)
|
|
4854
|
+
sys.exit(1)
|
|
4855
|
+
|
|
4856
|
+
assignment_id = response.get("id", "pending")
|
|
4857
|
+
click.echo(f" dispatched (assignment {assignment_id})")
|
|
4858
|
+
|
|
4859
|
+
# Record the dispatch
|
|
4860
|
+
record_dispatched(
|
|
4861
|
+
assignment_id=assignment_id,
|
|
4862
|
+
proposal=proposal,
|
|
4863
|
+
repo_github=repo_cfg.github,
|
|
4864
|
+
provider_name=response.get("_provider_name"),
|
|
4865
|
+
)
|
|
4866
|
+
|
|
4867
|
+
# Update board (read now so the briefing can see the full in-flight picture).
|
|
4868
|
+
board = read_board()
|
|
4869
|
+
|
|
4870
|
+
# Post briefing to GitHub
|
|
4871
|
+
try:
|
|
4872
|
+
from coord.dispatch import compute_do_not_touch
|
|
4873
|
+
|
|
4874
|
+
# #906: use board.active instead of load_dispatched() so a thin client
|
|
4875
|
+
# (empty local DB) still sees peer assignments running on other machines.
|
|
4876
|
+
# Exclude the just-dispatched assignment to avoid it listing itself.
|
|
4877
|
+
in_flight = [
|
|
4878
|
+
{"machine_name": a.machine_name, "repo_name": a.repo_name, "files_likely": a.files_allowed}
|
|
4879
|
+
for a in board.active
|
|
4880
|
+
if a.assignment_id != assignment_id
|
|
4881
|
+
]
|
|
4882
|
+
do_not_touch = compute_do_not_touch(proposal, peers=[], in_flight=in_flight)
|
|
4883
|
+
post_briefing(proposal, cfg, assignment_id=assignment_id, do_not_touch=do_not_touch)
|
|
4884
|
+
click.echo(" briefing posted to GitHub")
|
|
4885
|
+
except Exception as e:
|
|
4886
|
+
click.echo(f" briefing post failed: {e}", err=True)
|
|
4887
|
+
|
|
4888
|
+
write_board(board)
|
|
4889
|
+
|
|
4890
|
+
# Mark session start on first dispatch of the session
|
|
4891
|
+
from coord.state import load_session, write_session_start
|
|
4892
|
+
session = load_session()
|
|
4893
|
+
if session is None or session.get("clean_shutdown", True):
|
|
4894
|
+
write_session_start()
|