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,1243 @@
|
|
|
1
|
+
"""Plan-mode follow-up commands: `pr`, `fix`, `review`, `approve-plan`,
|
|
2
|
+
`reject-plan`, `resume-stuck`, `split`. Extracted from coord/cli.py (#747)."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from coord.config import Config
|
|
14
|
+
from coord.dispatch import DispatchRefused
|
|
15
|
+
|
|
16
|
+
from coord.commands._common import AGENT_PORT, _CONFIG_OPTION, _load_config
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.command(help="Create sub-issues from a split proposal (e.g. coord split S1).")
|
|
20
|
+
@click.argument("ids")
|
|
21
|
+
@_CONFIG_OPTION
|
|
22
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be created.")
|
|
23
|
+
def split(ids: str, config_path: Path, dry_run: bool) -> None:
|
|
24
|
+
from coord import github_ops
|
|
25
|
+
from coord.state import load_split_proposals, clear_split_proposals
|
|
26
|
+
|
|
27
|
+
cfg = _load_config(config_path)
|
|
28
|
+
splits = load_split_proposals()
|
|
29
|
+
if not splits:
|
|
30
|
+
click.echo("No pending split proposals. Run `coord plan` first.", err=True)
|
|
31
|
+
sys.exit(1)
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
selected_ids = [int(x.strip().lstrip("Ss")) for x in ids.split(",")]
|
|
35
|
+
except ValueError:
|
|
36
|
+
click.echo("error: IDs must be comma-separated (e.g. S1,S2 or 1,2)", err=True)
|
|
37
|
+
sys.exit(2)
|
|
38
|
+
|
|
39
|
+
selected = [s for s in splits if s.id in selected_ids]
|
|
40
|
+
missing = set(selected_ids) - {s.id for s in selected}
|
|
41
|
+
if missing:
|
|
42
|
+
click.echo(f"error: unknown split proposal IDs: {missing}", err=True)
|
|
43
|
+
sys.exit(2)
|
|
44
|
+
|
|
45
|
+
for s in selected:
|
|
46
|
+
repo = cfg.repo(s.repo_name)
|
|
47
|
+
if repo is None:
|
|
48
|
+
click.echo(f"error: unknown repo {s.repo_name!r}", err=True)
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
click.echo(f"\nSplitting #{s.issue_number}: {s.issue_title} into {len(s.chunks)} sub-issues:")
|
|
52
|
+
|
|
53
|
+
child_numbers: list[int] = []
|
|
54
|
+
for j, chunk in enumerate(s.chunks, 1):
|
|
55
|
+
title = f"{chunk.title} (sub-task {j}/{len(s.chunks)} of #{s.issue_number})"
|
|
56
|
+
body = (
|
|
57
|
+
f"## Sub-task of #{s.issue_number} — {s.issue_title}\n\n"
|
|
58
|
+
f"### Scope (chunk {j} of {len(s.chunks)}): {chunk.title}\n\n"
|
|
59
|
+
f"{chunk.scope}\n\n"
|
|
60
|
+
f"### Files likely touched\n\n"
|
|
61
|
+
+ "\n".join(f"- `{f}`" for f in chunk.files_likely)
|
|
62
|
+
+ f"\n\n### Context\n\n- Parent issue: #{s.issue_number}\n"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if dry_run:
|
|
66
|
+
click.echo(f" [{j}] would create: {title}")
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
result = github_ops.create_issue(
|
|
71
|
+
repo.github, title, body, labels=["sub-task"],
|
|
72
|
+
)
|
|
73
|
+
child_numbers.append(result["number"])
|
|
74
|
+
click.echo(f" [{j}] created #{result['number']}: {chunk.title}")
|
|
75
|
+
except RuntimeError as e:
|
|
76
|
+
click.echo(f" [{j}] failed to create: {e}", err=True)
|
|
77
|
+
|
|
78
|
+
if dry_run or not child_numbers:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
task_list = "\n".join(
|
|
82
|
+
f"- [ ] #{n}" for n in child_numbers
|
|
83
|
+
)
|
|
84
|
+
try:
|
|
85
|
+
github_ops.update_issue_body(
|
|
86
|
+
repo.github, s.issue_number,
|
|
87
|
+
f"Split into sub-tasks:\n\n{task_list}\n",
|
|
88
|
+
)
|
|
89
|
+
click.echo(f" Parent #{s.issue_number} updated with task list")
|
|
90
|
+
except RuntimeError as e:
|
|
91
|
+
click.echo(f" Failed to update parent: {e}", err=True)
|
|
92
|
+
|
|
93
|
+
if not dry_run:
|
|
94
|
+
clear_split_proposals()
|
|
95
|
+
click.echo("\nSplit proposals cleared. Run `coord plan` to assign the new sub-issues.")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _dispatch_followup(
|
|
99
|
+
cfg: Config,
|
|
100
|
+
original: Assignment,
|
|
101
|
+
briefing: str,
|
|
102
|
+
*,
|
|
103
|
+
issue_suffix: str = "",
|
|
104
|
+
model: str | None = None,
|
|
105
|
+
type: str = "work",
|
|
106
|
+
files_likely: list[str] | None = None,
|
|
107
|
+
inherit_branch: bool = True,
|
|
108
|
+
) -> str:
|
|
109
|
+
"""Dispatch a follow-up assignment for an existing assignment. Returns assignment ID.
|
|
110
|
+
|
|
111
|
+
*model* overrides the model tier for the follow-up. When None, the
|
|
112
|
+
dispatcher falls back to ``cfg.models.default``.
|
|
113
|
+
|
|
114
|
+
*type* sets the assignment type (``"work"`` or ``"plan"``). Defaults to
|
|
115
|
+
``"work"`` so existing callers are unaffected.
|
|
116
|
+
|
|
117
|
+
*files_likely* is the list of files the worker is expected to touch.
|
|
118
|
+
When None, an empty list is used (no file constraints).
|
|
119
|
+
|
|
120
|
+
*inherit_branch* controls whether the follow-up checks out the parent's
|
|
121
|
+
branch (``target_branch=original.branch``). True for follow-ups that
|
|
122
|
+
*continue* existing work on the same branch (``coord pr``, smoke-test
|
|
123
|
+
fix-up, continuation). Must be False when the parent is a read-only
|
|
124
|
+
PLAN assignment: a plan never pushes, its recorded branch is a
|
|
125
|
+
throwaway worktree name (sometimes a stale/wrong capture), and the
|
|
126
|
+
work it spawns must start a FRESH branch derived from the issue.
|
|
127
|
+
"""
|
|
128
|
+
from coord.board_service import read_board, write_board
|
|
129
|
+
from coord.dispatch import dispatch, post_briefing, compute_do_not_touch
|
|
130
|
+
from coord.state import record_dispatched
|
|
131
|
+
from coord.models import Proposal
|
|
132
|
+
|
|
133
|
+
repo = cfg.repo(original.repo_name)
|
|
134
|
+
if repo is None:
|
|
135
|
+
raise ValueError(f"Unknown repo: {original.repo_name!r}")
|
|
136
|
+
|
|
137
|
+
proposal = Proposal(
|
|
138
|
+
id=0,
|
|
139
|
+
machine_name=original.machine_name,
|
|
140
|
+
repo_name=original.repo_name,
|
|
141
|
+
issue_number=original.issue_number,
|
|
142
|
+
issue_title=original.issue_title,
|
|
143
|
+
rationale=f"follow-up for assignment {original.assignment_id}",
|
|
144
|
+
briefing=briefing,
|
|
145
|
+
model=model if model else cfg.models.default,
|
|
146
|
+
type=type,
|
|
147
|
+
files_likely=files_likely if files_likely is not None else [],
|
|
148
|
+
# Pin the follow-up to the parent's branch when one exists AND the
|
|
149
|
+
# caller wants continuation. Without this, prefixed issue titles
|
|
150
|
+
# like `[fix-1] …` / `[conflict-fix] …` carried into
|
|
151
|
+
# _dispatch_followup (e.g. `coord pr` on a fix-up assignment)
|
|
152
|
+
# cause the agent to slugify the prefixed title and push to an
|
|
153
|
+
# orphan branch instead of the original PR's branch. But for a
|
|
154
|
+
# plan→work hand-off the parent is read-only and its branch is a
|
|
155
|
+
# throwaway (sometimes wrong) capture, so the work must branch
|
|
156
|
+
# fresh — callers pass inherit_branch=False there.
|
|
157
|
+
target_branch=(original.branch or None) if inherit_branch else None,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
response = dispatch(proposal, cfg)
|
|
161
|
+
assignment_id = response.get("id", "pending")
|
|
162
|
+
record_dispatched(
|
|
163
|
+
assignment_id=assignment_id,
|
|
164
|
+
proposal=proposal,
|
|
165
|
+
repo_github=repo.github,
|
|
166
|
+
provider_name=response.get("_provider_name"),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# #906: use read_board() to build the peer-conflict in-flight list instead
|
|
170
|
+
# of load_dispatched() — the latter reads the local DB which is empty on a
|
|
171
|
+
# thin client. read_board() routes to the daemon's /board when configured,
|
|
172
|
+
# so we get the canonical active assignment list for do-not-touch detection.
|
|
173
|
+
# This also consolidates the board read we needed anyway for write_board().
|
|
174
|
+
board = read_board()
|
|
175
|
+
in_flight = [
|
|
176
|
+
{
|
|
177
|
+
"machine_name": a.machine_name,
|
|
178
|
+
"repo_name": a.repo_name,
|
|
179
|
+
"files_likely": a.files_allowed,
|
|
180
|
+
}
|
|
181
|
+
for a in board.active
|
|
182
|
+
if a.assignment_id != assignment_id # exclude just-dispatched
|
|
183
|
+
]
|
|
184
|
+
do_not_touch = compute_do_not_touch(proposal, peers=[], in_flight=in_flight)
|
|
185
|
+
post_briefing(proposal, cfg, assignment_id=assignment_id, do_not_touch=do_not_touch)
|
|
186
|
+
|
|
187
|
+
write_board(board)
|
|
188
|
+
|
|
189
|
+
return assignment_id
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _load_plan_for_assignment(assignment, assignment_id: str) -> dict | None:
|
|
193
|
+
"""Retrieve the plan dict for a plan-type assignment.
|
|
194
|
+
|
|
195
|
+
Tries (in order):
|
|
196
|
+
1. The plan field cached on the assignment object.
|
|
197
|
+
2. The plans table in the DB (populated by `coord notify`).
|
|
198
|
+
3. Parsing the local log file directly (works when agent is local).
|
|
199
|
+
|
|
200
|
+
Returns the plan dict or None if not found.
|
|
201
|
+
"""
|
|
202
|
+
from coord.state import COORD_DIR, load_plans
|
|
203
|
+
|
|
204
|
+
plan_dict = getattr(assignment, "plan", None)
|
|
205
|
+
if plan_dict is None:
|
|
206
|
+
plans = load_plans()
|
|
207
|
+
plan_dict = plans.get(assignment_id)
|
|
208
|
+
if plan_dict is None:
|
|
209
|
+
local_log = COORD_DIR / "logs" / f"{assignment_id}.log"
|
|
210
|
+
try:
|
|
211
|
+
from coord.plan_parser import parse_plan_from_log # noqa: PLC0415
|
|
212
|
+
worker_plan = parse_plan_from_log(local_log)
|
|
213
|
+
except Exception: # noqa: BLE001
|
|
214
|
+
worker_plan = None
|
|
215
|
+
if worker_plan is not None:
|
|
216
|
+
plan_dict = worker_plan.to_dict()
|
|
217
|
+
return plan_dict
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _plan_dict_to_text(plan_dict: dict) -> str:
|
|
221
|
+
"""Format a WorkerPlan dict into a human-readable text block for briefings."""
|
|
222
|
+
from coord.plan_parser import WorkerPlan # noqa: PLC0415
|
|
223
|
+
|
|
224
|
+
plan = WorkerPlan.from_dict(plan_dict)
|
|
225
|
+
parts: list[str] = []
|
|
226
|
+
if plan.plan:
|
|
227
|
+
parts.append(f"Summary:\n{plan.plan}")
|
|
228
|
+
if plan.files_modify:
|
|
229
|
+
parts.append("Files to modify:\n" + "\n".join(f" - {f}" for f in plan.files_modify))
|
|
230
|
+
if plan.approach:
|
|
231
|
+
parts.append(f"Approach:\n{plan.approach}")
|
|
232
|
+
if plan.risks:
|
|
233
|
+
parts.append(f"Risks:\n{plan.risks}")
|
|
234
|
+
if plan.estimate:
|
|
235
|
+
parts.append(f"Estimate:\n{plan.estimate}")
|
|
236
|
+
# Smoke tests authored at planning time — the work worker re-emits
|
|
237
|
+
# these (refining if needed) in its own SMOKE_TESTS block before
|
|
238
|
+
# exit. Surfacing them in the briefing lets the worker copy them
|
|
239
|
+
# verbatim when the change matches the plan.
|
|
240
|
+
if plan.smoke_tests:
|
|
241
|
+
bullets = "\n".join(f" - {b}" for b in plan.smoke_tests)
|
|
242
|
+
parts.append(f"Smoke tests (from plan — re-emit in your SMOKE_TESTS block):\n{bullets}")
|
|
243
|
+
elif plan.smoke_tests == []:
|
|
244
|
+
parts.append(
|
|
245
|
+
"Smoke tests (from plan): (none — change is internal). "
|
|
246
|
+
"Emit `SMOKE_TESTS: (none — change is internal)` in your block."
|
|
247
|
+
)
|
|
248
|
+
# Fall back to raw_text when no structured sections were found.
|
|
249
|
+
if not parts:
|
|
250
|
+
return plan.raw_text or "(no plan text)"
|
|
251
|
+
return "\n\n".join(parts)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
@click.command(help="Dispatch a worker to create a PR for a completed assignment.")
|
|
255
|
+
@click.argument("assignment_id")
|
|
256
|
+
@_CONFIG_OPTION
|
|
257
|
+
@click.option(
|
|
258
|
+
"--no-review",
|
|
259
|
+
is_flag=True,
|
|
260
|
+
default=False,
|
|
261
|
+
help="Skip auto-dispatching an adversarial review after the PR worker.",
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def pr(assignment_id: str, config_path: Path, no_review: bool) -> None:
|
|
266
|
+
from coord.board_service import read_board, write_board
|
|
267
|
+
|
|
268
|
+
cfg = _load_config(config_path)
|
|
269
|
+
board = read_board()
|
|
270
|
+
|
|
271
|
+
assignment = board.find_by_id(assignment_id)
|
|
272
|
+
if assignment is None:
|
|
273
|
+
click.echo(f"error: assignment {assignment_id!r} not found in board", err=True)
|
|
274
|
+
sys.exit(1)
|
|
275
|
+
|
|
276
|
+
if assignment.status != "done":
|
|
277
|
+
click.echo(
|
|
278
|
+
f"error: assignment {assignment_id} is {assignment.status!r}, "
|
|
279
|
+
"can only create a PR for done assignments",
|
|
280
|
+
err=True,
|
|
281
|
+
)
|
|
282
|
+
sys.exit(1)
|
|
283
|
+
|
|
284
|
+
if not assignment.branch:
|
|
285
|
+
click.echo(
|
|
286
|
+
f"error: assignment {assignment_id} has no branch recorded. "
|
|
287
|
+
"The worker may not have pushed yet.",
|
|
288
|
+
err=True,
|
|
289
|
+
)
|
|
290
|
+
sys.exit(1)
|
|
291
|
+
|
|
292
|
+
repo = cfg.repo(assignment.repo_name)
|
|
293
|
+
if repo is None:
|
|
294
|
+
click.echo(f"error: unknown repo {assignment.repo_name!r}", err=True)
|
|
295
|
+
sys.exit(1)
|
|
296
|
+
|
|
297
|
+
default_branch = repo.default_branch
|
|
298
|
+
# #1077: "mock-author" (Gate A) assignments' issue_number is the
|
|
299
|
+
# milestone's tracking issue, not something this PR resolves — closing
|
|
300
|
+
# it on merge would wrongly flip the epic to "done" while its real
|
|
301
|
+
# sub-issues are untouched. Only "work"-type PRs get the closing
|
|
302
|
+
# keyword; everything else gets a non-closing reference.
|
|
303
|
+
from coord.models import CLOSES_ISSUE_TYPES, PR_HELPER_TYPE # noqa: PLC0415
|
|
304
|
+
|
|
305
|
+
closes_issue = assignment.type in CLOSES_ISSUE_TYPES
|
|
306
|
+
|
|
307
|
+
# #1314: the #1077 guard above assumed only mock-author/test-author are
|
|
308
|
+
# ever dispatched directly against a tracking issue's own number — but a
|
|
309
|
+
# type="work" assignment can land there too (e.g. a Gate-A contract
|
|
310
|
+
# correction with no properly-typed tool yet, see #1314). Closing an
|
|
311
|
+
# epic on merge because a single corrective PR touched its number would
|
|
312
|
+
# wrongly flip it to "done" while its real children sit untouched, so
|
|
313
|
+
# check the issue's own labels regardless of assignment type. Fail-open
|
|
314
|
+
# (network hiccup, unknown repo, ...) — keep the type-only verdict
|
|
315
|
+
# rather than block PR creation on a GitHub read.
|
|
316
|
+
if closes_issue:
|
|
317
|
+
try:
|
|
318
|
+
from coord import github_ops # noqa: PLC0415
|
|
319
|
+
from coord.milestone_order import TRACKING_ISSUE_LABEL # noqa: PLC0415
|
|
320
|
+
|
|
321
|
+
issue_data = github_ops.get_issue(repo.github, assignment.issue_number)
|
|
322
|
+
issue_labels = {
|
|
323
|
+
lbl.get("name", "") for lbl in (issue_data.get("labels") or [])
|
|
324
|
+
}
|
|
325
|
+
if TRACKING_ISSUE_LABEL in issue_labels:
|
|
326
|
+
closes_issue = False
|
|
327
|
+
except Exception: # noqa: BLE001 — fail open, see docstring above
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
ref_keyword = (
|
|
331
|
+
f"Closes #{assignment.issue_number}"
|
|
332
|
+
if closes_issue
|
|
333
|
+
else f"Refs #{assignment.issue_number}"
|
|
334
|
+
)
|
|
335
|
+
briefing = (
|
|
336
|
+
f"You are on branch {assignment.branch}. The code is complete and tests pass.\n"
|
|
337
|
+
f"Create a PR from {assignment.branch} to {default_branch} for issue #{assignment.issue_number}.\n"
|
|
338
|
+
f"Title: {assignment.issue_title}\n\n"
|
|
339
|
+
f"Use gh pr create. Read the diff (git fetch origin && git diff origin/{default_branch}...HEAD) and write a clear\n"
|
|
340
|
+
f"summary of what changed. Reference the issue with \"{ref_keyword}\".\n"
|
|
341
|
+
f"Do NOT modify any code — only create the PR."
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# #1142: only give the PR-opening helper `type="work"` when the original
|
|
345
|
+
# assignment's own type actually resolves `issue_number` (mirrors the
|
|
346
|
+
# Closes/Refs split above). Otherwise (test-author/mock-author/etc, whose
|
|
347
|
+
# issue_number is a milestone tracking issue) the helper gets a distinct
|
|
348
|
+
# `PR_HELPER_TYPE` so it can never be mistaken for that tracking issue's
|
|
349
|
+
# own merged work by `coord.stage_projection.merge_stage_status_for` (or
|
|
350
|
+
# any other heuristic keyed on `type == "work"` / `CLOSES_ISSUE_TYPES`).
|
|
351
|
+
followup_type = "work" if closes_issue else PR_HELPER_TYPE
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
new_id = _dispatch_followup(cfg, assignment, briefing, type=followup_type)
|
|
355
|
+
except httpx.HTTPError as e:
|
|
356
|
+
click.echo(f"error: dispatch failed: {e}", err=True)
|
|
357
|
+
sys.exit(1)
|
|
358
|
+
except ValueError as e:
|
|
359
|
+
click.echo(f"error: {e}", err=True)
|
|
360
|
+
sys.exit(1)
|
|
361
|
+
|
|
362
|
+
click.echo(f"PR worker dispatched (assignment {new_id})")
|
|
363
|
+
click.echo(f" branch: {assignment.branch} → {default_branch}")
|
|
364
|
+
click.echo(f" issue: #{assignment.issue_number}: {assignment.issue_title}")
|
|
365
|
+
|
|
366
|
+
if not no_review and cfg.reviews.enabled:
|
|
367
|
+
from coord.review import dispatch_review
|
|
368
|
+
|
|
369
|
+
fresh_board = read_board()
|
|
370
|
+
review = dispatch_review(assignment, fresh_board, cfg)
|
|
371
|
+
if review is not None:
|
|
372
|
+
write_board(fresh_board)
|
|
373
|
+
click.echo(f"Review dispatched (assignment {review.assignment_id})")
|
|
374
|
+
click.echo(f" reviewer: {review.machine_name}")
|
|
375
|
+
else:
|
|
376
|
+
# #1627: report the specific guard dispatch_review hit rather
|
|
377
|
+
# than a generic guess.
|
|
378
|
+
reason = assignment.review_dispatch_reason or "reason not recorded"
|
|
379
|
+
click.echo(f" review not dispatched: {reason}")
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
@click.command(
|
|
383
|
+
help=(
|
|
384
|
+
"Deliberately dispatch a headless review for a done work assignment "
|
|
385
|
+
"(the #555 escape hatch — thin wrapper over dispatch_review)."
|
|
386
|
+
)
|
|
387
|
+
)
|
|
388
|
+
@click.argument("assignment_id")
|
|
389
|
+
@_CONFIG_OPTION
|
|
390
|
+
def review(assignment_id: str, config_path: Path) -> None:
|
|
391
|
+
"""``coord review <work_assignment_id>``
|
|
392
|
+
|
|
393
|
+
The #555 guard in ``dispatch_pending_reviews`` deliberately never
|
|
394
|
+
auto-dispatches a headless ``claude -p`` review for an *interactive*
|
|
395
|
+
(``provider_name="claude-pty"``) work completion — that path already had
|
|
396
|
+
a human attending it. Its comment promises an escape hatch for a human
|
|
397
|
+
to deliberately request a headless review anyway; this command is it.
|
|
398
|
+
|
|
399
|
+
Pure dispatch — no PR worker is spawned. :func:`coord.review.dispatch_review`
|
|
400
|
+
already opens (or reuses) the PR itself, so unlike ``coord pr`` there is no
|
|
401
|
+
``_dispatch_followup`` work session in between.
|
|
402
|
+
"""
|
|
403
|
+
from coord.board_service import read_board, write_board
|
|
404
|
+
from coord.claim import has_active_followup
|
|
405
|
+
from coord.review import dispatch_review
|
|
406
|
+
|
|
407
|
+
cfg = _load_config(config_path)
|
|
408
|
+
board = read_board()
|
|
409
|
+
|
|
410
|
+
assignment = board.find_by_id(assignment_id)
|
|
411
|
+
if assignment is None:
|
|
412
|
+
click.echo(f"error: assignment {assignment_id!r} not found in board", err=True)
|
|
413
|
+
sys.exit(1)
|
|
414
|
+
|
|
415
|
+
if assignment.status != "done":
|
|
416
|
+
click.echo(
|
|
417
|
+
f"error: assignment {assignment_id} is {assignment.status!r}, not "
|
|
418
|
+
"'done' — nothing to review yet",
|
|
419
|
+
err=True,
|
|
420
|
+
)
|
|
421
|
+
sys.exit(1)
|
|
422
|
+
|
|
423
|
+
if not assignment.branch:
|
|
424
|
+
click.echo(
|
|
425
|
+
f"error: assignment {assignment_id} has no branch recorded. "
|
|
426
|
+
"The worker may not have pushed yet.",
|
|
427
|
+
err=True,
|
|
428
|
+
)
|
|
429
|
+
sys.exit(1)
|
|
430
|
+
|
|
431
|
+
if has_active_followup(
|
|
432
|
+
board, of_assignment_id=assignment_id, assignment_type="review"
|
|
433
|
+
):
|
|
434
|
+
click.echo(
|
|
435
|
+
f"error: a review is already in flight for {assignment_id}",
|
|
436
|
+
err=True,
|
|
437
|
+
)
|
|
438
|
+
sys.exit(1)
|
|
439
|
+
|
|
440
|
+
if not cfg.reviews.enabled:
|
|
441
|
+
click.echo(
|
|
442
|
+
"error: reviews are disabled (reviews.enabled: false in config)",
|
|
443
|
+
err=True,
|
|
444
|
+
)
|
|
445
|
+
sys.exit(1)
|
|
446
|
+
|
|
447
|
+
review_assignment = dispatch_review(assignment, board, cfg)
|
|
448
|
+
if review_assignment is None:
|
|
449
|
+
# #1627: dispatch_review() records *why* it declined on the
|
|
450
|
+
# assignment itself (review_dispatch_reason) — print that verbatim
|
|
451
|
+
# instead of guessing at a cause. (It used to be a bare `return
|
|
452
|
+
# None` from any of 11 guards, most of which never logged anything,
|
|
453
|
+
# so this message used to send operators chasing a log entry that
|
|
454
|
+
# didn't exist — see #1627.)
|
|
455
|
+
reason = assignment.review_dispatch_reason or (
|
|
456
|
+
"no reason recorded — this is itself a bug in dispatch_review; "
|
|
457
|
+
"please report it"
|
|
458
|
+
)
|
|
459
|
+
click.echo(f"error: no review dispatched for {assignment_id} — {reason}", err=True)
|
|
460
|
+
sys.exit(1)
|
|
461
|
+
|
|
462
|
+
# Persist only on success: dispatch_review mutates the board in place
|
|
463
|
+
# (review_state, pr_url, the new review row).
|
|
464
|
+
write_board(board)
|
|
465
|
+
click.echo(
|
|
466
|
+
f"review dispatched: {review_assignment.assignment_id} on "
|
|
467
|
+
f"{review_assignment.machine_name}"
|
|
468
|
+
)
|
|
469
|
+
if assignment.pr_url:
|
|
470
|
+
click.echo(f" pr: {assignment.pr_url}")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _ci_failure_story(cfg: Config, assignment) -> str | None:
|
|
474
|
+
"""#1622 (part 3): a rendered summary of *assignment*'s FAILED CI checks.
|
|
475
|
+
|
|
476
|
+
The third legitimate fix trigger, alongside a request-changes review and a
|
|
477
|
+
failed local test gate. CI reports failures the local Test stage cannot
|
|
478
|
+
see — vimcode #613 was a sibling path-dep that only breaks in a clean
|
|
479
|
+
checkout — and the fix has to land on the *existing* branch, because the
|
|
480
|
+
thing being fixed is what gates the merge.
|
|
481
|
+
|
|
482
|
+
Read-only and fail-quiet: returns ``None`` when CI is not configured
|
|
483
|
+
(``ci_store.type: none`` ⇒ :class:`coord.ci_store.NoOpCi`, whose
|
|
484
|
+
``is_available`` is False), when the branch has no PR to read checks for,
|
|
485
|
+
when the read raises, or when every completed check is affirmatively
|
|
486
|
+
passing. ``None`` therefore means "no CI evidence of a failure", never
|
|
487
|
+
"CI is green" — the caller must not treat it as a verdict.
|
|
488
|
+
"""
|
|
489
|
+
from coord.ci_store import build_ci_store, failed_checks, summarize
|
|
490
|
+
from coord.drive import _extract_pr_number
|
|
491
|
+
|
|
492
|
+
repo = cfg.repo(assignment.repo_name)
|
|
493
|
+
if repo is None or not repo.github:
|
|
494
|
+
return None
|
|
495
|
+
|
|
496
|
+
pr_number = _extract_pr_number(assignment.pr_url or "")
|
|
497
|
+
if pr_number is None:
|
|
498
|
+
return None
|
|
499
|
+
|
|
500
|
+
store = build_ci_store(cfg.ci_store.type)
|
|
501
|
+
if not store.is_available:
|
|
502
|
+
return None
|
|
503
|
+
|
|
504
|
+
try:
|
|
505
|
+
checks = store.list_checks_for_pr(repo.github, pr_number)
|
|
506
|
+
except Exception as exc: # noqa: BLE001 — CI read is advisory, never fatal
|
|
507
|
+
click.echo(f"warning: could not read CI checks for PR #{pr_number}: {exc}", err=True)
|
|
508
|
+
return None
|
|
509
|
+
|
|
510
|
+
failed = failed_checks(checks)
|
|
511
|
+
if not failed:
|
|
512
|
+
return None
|
|
513
|
+
|
|
514
|
+
lines = [
|
|
515
|
+
f"CI on PR #{pr_number} is RED ({summarize(checks)}).",
|
|
516
|
+
"",
|
|
517
|
+
"Failed checks:",
|
|
518
|
+
]
|
|
519
|
+
for c in failed:
|
|
520
|
+
lines.append(f"- {c.name} — conclusion={c.conclusion!r}{(' — ' + c.url) if c.url else ''}")
|
|
521
|
+
lines += [
|
|
522
|
+
"",
|
|
523
|
+
"These failures were NOT visible to the local Test stage. Read the run "
|
|
524
|
+
"logs (`gh run view <id> --log-failed`, or open the URLs above), "
|
|
525
|
+
"reproduce locally where you can, and fix the root cause on THIS "
|
|
526
|
+
"branch — a new branch would not carry the change CI is gating on.",
|
|
527
|
+
]
|
|
528
|
+
return "\n".join(lines)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _fix_from_review(
|
|
532
|
+
cfg: Config,
|
|
533
|
+
board,
|
|
534
|
+
review,
|
|
535
|
+
*,
|
|
536
|
+
guidance: str,
|
|
537
|
+
force: bool,
|
|
538
|
+
) -> None:
|
|
539
|
+
"""#1622: dispatch a HEADLESS fix round for a request-changes review.
|
|
540
|
+
|
|
541
|
+
This is a CLI **door** onto the review→fix auto-loop, not a second
|
|
542
|
+
implementation of same-branch dispatch. It hands the review row straight
|
|
543
|
+
to :func:`coord.auto_loop.process_review_completion`, the same function the
|
|
544
|
+
``coord notify`` transition path calls, so the fix worker is produced by the
|
|
545
|
+
one and only :func:`coord.auto_loop._dispatch_fix` — which pins
|
|
546
|
+
``target_branch`` to the reviewed work's branch and bumps
|
|
547
|
+
``review_iteration``. Every guard that path applies applies here unchanged:
|
|
548
|
+
``pipeline.auto_loop``, the #476/#1456 approve-with-nits gate, the #522
|
|
549
|
+
terminal-work guard, and the ``max_review_iterations`` cap.
|
|
550
|
+
|
|
551
|
+
Two things are added on top, both *before* the shared call:
|
|
552
|
+
|
|
553
|
+
- the #555 interactive exclusion, checked against the reviewed WORK row so
|
|
554
|
+
an interactive completion is never followed by a headless fix; and
|
|
555
|
+
- resolution of the review's log path / machine host, which the transition
|
|
556
|
+
path gets handed by ``coord notify`` and a CLI invocation does not.
|
|
557
|
+
|
|
558
|
+
Never returns — always exits via :func:`sys.exit`.
|
|
559
|
+
"""
|
|
560
|
+
from coord import auto_loop
|
|
561
|
+
from coord.board_service import write_board
|
|
562
|
+
from coord.state import COORD_DIR
|
|
563
|
+
|
|
564
|
+
if guidance:
|
|
565
|
+
click.echo(
|
|
566
|
+
"warning: --guidance is ignored for a review id — the reviewer's "
|
|
567
|
+
"findings ARE the fix briefing. To brief a fix in your own words "
|
|
568
|
+
f"use `coord assign <machine> <repo> <issue> --fix-of "
|
|
569
|
+
f"{review.assignment_id} --interactive --briefing ...`.",
|
|
570
|
+
err=True,
|
|
571
|
+
)
|
|
572
|
+
|
|
573
|
+
work = None
|
|
574
|
+
if review.review_of_assignment_id:
|
|
575
|
+
work = board.find_by_id(review.review_of_assignment_id)
|
|
576
|
+
if work is None:
|
|
577
|
+
click.echo(
|
|
578
|
+
f"error: review {review.assignment_id} has no linked work assignment "
|
|
579
|
+
f"on the board (review_of_assignment_id="
|
|
580
|
+
f"{review.review_of_assignment_id!r}) — nothing to fix",
|
|
581
|
+
err=True,
|
|
582
|
+
)
|
|
583
|
+
sys.exit(1)
|
|
584
|
+
|
|
585
|
+
# #555: an INTERACTIVE work completion must never be silently followed by a
|
|
586
|
+
# headless fix. The human at the tmux pane owns that branch and may still
|
|
587
|
+
# be mid-edit; a `claude -p` worker pushing over them is the incident that
|
|
588
|
+
# exclusion exists to prevent. The auto-loop enforces this on the
|
|
589
|
+
# re-review side (`run_for_fix_transition`); a CLI door onto the *dispatch*
|
|
590
|
+
# side needs its own check, because `process_review_completion` never sees
|
|
591
|
+
# the provider. `--force` is the deliberate override for the case the
|
|
592
|
+
# exclusion cannot detect: the session is genuinely gone.
|
|
593
|
+
if (work.provider_name or "") == "claude-pty" and not force:
|
|
594
|
+
click.echo(
|
|
595
|
+
f"error: work {work.assignment_id} ran INTERACTIVELY "
|
|
596
|
+
"(provider=claude-pty); refusing to follow it with a headless fix "
|
|
597
|
+
"(#555). Continue in that session, or run `coord assign ... "
|
|
598
|
+
f"--fix-of {review.assignment_id} --interactive`, or pass --force "
|
|
599
|
+
"if the interactive session is really gone.",
|
|
600
|
+
err=True,
|
|
601
|
+
)
|
|
602
|
+
sys.exit(1)
|
|
603
|
+
|
|
604
|
+
# The transition path is handed these by `coord notify`; reconstruct them
|
|
605
|
+
# so the findings loader keeps all four of its sources (DB cache → local
|
|
606
|
+
# log → agent HTTP → GitHub message bus) rather than only the first and last.
|
|
607
|
+
_log = COORD_DIR / "logs" / f"{review.assignment_id}.log"
|
|
608
|
+
log_path = str(_log) if _log.exists() else None
|
|
609
|
+
machine_host = None
|
|
610
|
+
_machine = next(
|
|
611
|
+
(m for m in cfg.machines if m.name == review.machine_name), None
|
|
612
|
+
)
|
|
613
|
+
if _machine is not None and _machine.host:
|
|
614
|
+
machine_host = _machine.host
|
|
615
|
+
|
|
616
|
+
before = {a.assignment_id for a in board.active}
|
|
617
|
+
actions = auto_loop.process_review_completion(
|
|
618
|
+
review, board, cfg, log_path=log_path, machine_host=machine_host,
|
|
619
|
+
)
|
|
620
|
+
|
|
621
|
+
# `process_review_completion` mutates the board and leaves persistence to
|
|
622
|
+
# its caller — same contract, same kind list, as the transition path.
|
|
623
|
+
if any(a.kind in auto_loop.PERSIST_ACTION_KINDS for a in actions):
|
|
624
|
+
write_board(board)
|
|
625
|
+
|
|
626
|
+
kinds = {a.kind for a in actions}
|
|
627
|
+
detail = "; ".join(a.detail for a in actions if a.detail)
|
|
628
|
+
|
|
629
|
+
if "fix_dispatched" in kinds:
|
|
630
|
+
max_iter = cfg.pipeline.max_review_iterations
|
|
631
|
+
click.echo(f"Fix worker dispatched ({detail})")
|
|
632
|
+
for row in board.active:
|
|
633
|
+
if row.assignment_id in before:
|
|
634
|
+
continue
|
|
635
|
+
click.echo(f" assignment: {row.assignment_id}")
|
|
636
|
+
click.echo(f" branch: {row.branch}")
|
|
637
|
+
click.echo(f" review iteration: {row.review_iteration}/{max_iter}")
|
|
638
|
+
click.echo(f" issue: #{work.issue_number}: {work.issue_title}")
|
|
639
|
+
if work.pr_url:
|
|
640
|
+
click.echo(f" pr: {work.pr_url}")
|
|
641
|
+
return
|
|
642
|
+
|
|
643
|
+
# Everything else is a refusal. Each one is a guard doing its job, so say
|
|
644
|
+
# which guard and exit non-zero — a headless drive keys off the exit code.
|
|
645
|
+
_why = {
|
|
646
|
+
"disabled": (
|
|
647
|
+
"pipeline.auto_loop is disabled in coordinator.yml — the review→fix "
|
|
648
|
+
"path this command opens is switched off"
|
|
649
|
+
),
|
|
650
|
+
"no_findings": (
|
|
651
|
+
"no structured review findings could be resolved (DB cache, local "
|
|
652
|
+
"log, agent HTTP and the GitHub findings comment were all empty)"
|
|
653
|
+
),
|
|
654
|
+
"approved": "review verdict is approve — nothing to fix",
|
|
655
|
+
"approved_with_nits": (
|
|
656
|
+
"review raised no blocking findings, only advisory ones — the #476 "
|
|
657
|
+
"gate advanced the pipeline instead of dispatching a fix"
|
|
658
|
+
),
|
|
659
|
+
"max_iterations": (
|
|
660
|
+
"pipeline.max_review_iterations reached — raise it in "
|
|
661
|
+
"coordinator.yml or take the branch over by hand"
|
|
662
|
+
),
|
|
663
|
+
"terminal_skip": (
|
|
664
|
+
"the reviewed work is already merged/closed on GitHub (#522)"
|
|
665
|
+
),
|
|
666
|
+
"no_work_found": "fix dispatch failed",
|
|
667
|
+
}
|
|
668
|
+
reason = next(
|
|
669
|
+
(_why[a.kind] for a in actions if a.kind in _why),
|
|
670
|
+
f"auto-loop returned {sorted(kinds) or 'nothing'}",
|
|
671
|
+
)
|
|
672
|
+
click.echo(f"error: no fix dispatched for review {review.assignment_id}: {reason}", err=True)
|
|
673
|
+
if detail:
|
|
674
|
+
click.echo(f" {detail}", err=True)
|
|
675
|
+
sys.exit(1)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
@click.command(
|
|
679
|
+
help=(
|
|
680
|
+
"Dispatch a headless same-branch fix worker.\n\n"
|
|
681
|
+
"ASSIGNMENT_ID is either a WORK assignment whose test gate FAILED (or "
|
|
682
|
+
"whose PR has red CI), or a REVIEW assignment whose verdict was "
|
|
683
|
+
"request-changes. Either way the fix lands on the ORIGINAL branch and "
|
|
684
|
+
"updates the ORIGINAL PR — no new issue-N-* branch, no orphan PR."
|
|
685
|
+
)
|
|
686
|
+
)
|
|
687
|
+
@click.argument("assignment_id")
|
|
688
|
+
@_CONFIG_OPTION
|
|
689
|
+
@click.option("--guidance", default="", help="Additional guidance for the fix-up worker.")
|
|
690
|
+
@click.option(
|
|
691
|
+
"--force",
|
|
692
|
+
is_flag=True,
|
|
693
|
+
help=(
|
|
694
|
+
"Override the #555 interactive-work exclusion when fixing a review of "
|
|
695
|
+
"work that ran under claude-pty, or (#2051) dispatch a WORK-row fix "
|
|
696
|
+
"when neither the test verdict nor a live CI read shows a failure — "
|
|
697
|
+
"for when the caller knows the PR is red but the check missed it "
|
|
698
|
+
"(ci_store not configured, a transient read error). Does NOT override "
|
|
699
|
+
"max_review_iterations or the #522 terminal-work guard."
|
|
700
|
+
),
|
|
701
|
+
)
|
|
702
|
+
def fix(assignment_id: str, config_path: Path, guidance: str, force: bool) -> None:
|
|
703
|
+
from coord.board_service import read_board
|
|
704
|
+
from coord.state import COORD_DIR
|
|
705
|
+
|
|
706
|
+
cfg = _load_config(config_path)
|
|
707
|
+
board = read_board()
|
|
708
|
+
|
|
709
|
+
assignment = board.find_by_id(assignment_id)
|
|
710
|
+
if assignment is None:
|
|
711
|
+
click.echo(f"error: assignment {assignment_id!r} not found in board", err=True)
|
|
712
|
+
sys.exit(1)
|
|
713
|
+
|
|
714
|
+
# #1622: a REVIEW id is the request-changes door. It routes through
|
|
715
|
+
# `coord.auto_loop`, which owns same-branch fix dispatch — this command
|
|
716
|
+
# deliberately does not grow a second implementation of it.
|
|
717
|
+
if assignment.type == "review":
|
|
718
|
+
_fix_from_review(cfg, board, assignment, guidance=guidance, force=force)
|
|
719
|
+
return
|
|
720
|
+
|
|
721
|
+
# #1384: gate on the canonical `test_state` with the legacy `smoke_test`
|
|
722
|
+
# mirror as fallback, so the two fields can never drift apart again. The
|
|
723
|
+
# writer (`state._record_test_verdict_local`) now derives the mirror, but
|
|
724
|
+
# rows recorded before that fix — a headless smoke failure via #1021's
|
|
725
|
+
# `coord/notify.py` propagation — carry `test_state='failed'` with
|
|
726
|
+
# `smoke_test=NULL` and must still be fixable.
|
|
727
|
+
test_failed = assignment.test_state == "failed" or assignment.smoke_test == "fail"
|
|
728
|
+
|
|
729
|
+
# #1622 (part 3): red CI is the third trigger. Only consulted when the
|
|
730
|
+
# local test gate has NOT already failed, so the cheap in-DB path stays
|
|
731
|
+
# zero-I/O and the existing failure story keeps priority.
|
|
732
|
+
ci_story = None if test_failed else _ci_failure_story(cfg, assignment)
|
|
733
|
+
|
|
734
|
+
# #2051: none of the three doors (failed test verdict, red CI, or the
|
|
735
|
+
# review-id door handled above) is open. `_ci_failure_story` is
|
|
736
|
+
# read-only and fail-quiet (see its docstring) — it returns `None` for
|
|
737
|
+
# "no evidence of a failure", never "CI is green" — so a caller who
|
|
738
|
+
# KNOWS the PR is red (ci_store not configured for this repo, a
|
|
739
|
+
# transient GitHub API error, a check that hasn't reported yet) would
|
|
740
|
+
# otherwise be stranded with no headless door onto the ORIGINAL branch.
|
|
741
|
+
# `--force` is that caller's release valve; it is NOT a way to skip the
|
|
742
|
+
# #555 / #522 / max_review_iterations guards, which sit elsewhere.
|
|
743
|
+
forced_without_evidence = False
|
|
744
|
+
if not test_failed and ci_story is None:
|
|
745
|
+
if not force:
|
|
746
|
+
click.echo(
|
|
747
|
+
f"error: assignment {assignment_id} test_state is "
|
|
748
|
+
f"{assignment.test_state!r} / smoke_test is "
|
|
749
|
+
f"{assignment.smoke_test!r}, expected a failed test verdict "
|
|
750
|
+
"(or red CI on its PR, or a request-changes review id). If "
|
|
751
|
+
"the PR is actually red and this check missed it, re-run "
|
|
752
|
+
"with --force.",
|
|
753
|
+
err=True,
|
|
754
|
+
)
|
|
755
|
+
sys.exit(1)
|
|
756
|
+
if not guidance:
|
|
757
|
+
# There's no failed verdict, CI read, or test-output file to
|
|
758
|
+
# brief the fix worker with — `--force` alone would dispatch it
|
|
759
|
+
# blind. Require the caller to say what's actually broken.
|
|
760
|
+
click.echo(
|
|
761
|
+
f"error: --force on assignment {assignment_id} also needs "
|
|
762
|
+
"--guidance — there's no failed test verdict or CI read to "
|
|
763
|
+
"brief the fix worker with, so say what's actually broken.",
|
|
764
|
+
err=True,
|
|
765
|
+
)
|
|
766
|
+
sys.exit(1)
|
|
767
|
+
forced_without_evidence = True
|
|
768
|
+
|
|
769
|
+
repo = cfg.repo(assignment.repo_name)
|
|
770
|
+
if repo is None:
|
|
771
|
+
click.echo(f"error: unknown repo {assignment.repo_name!r}", err=True)
|
|
772
|
+
sys.exit(1)
|
|
773
|
+
|
|
774
|
+
default_branch = repo.default_branch
|
|
775
|
+
|
|
776
|
+
# Load stored test output if available
|
|
777
|
+
test_output = ""
|
|
778
|
+
test_output_file = COORD_DIR / "test_output" / f"{assignment_id}.txt"
|
|
779
|
+
if ci_story is not None:
|
|
780
|
+
test_output = ci_story
|
|
781
|
+
elif test_output_file.exists():
|
|
782
|
+
test_output = test_output_file.read_text()
|
|
783
|
+
elif assignment.smoke_test_reason or assignment.test_reason:
|
|
784
|
+
# #1337: the board wire carries a bounded PREVIEW of the reason text;
|
|
785
|
+
# the briefing quotes it verbatim — prefer the full text via the
|
|
786
|
+
# detail loader (falls back to the board-carried value).
|
|
787
|
+
# #1384: fall back to `test_reason` too — a headless-smoke failure
|
|
788
|
+
# recorded before the mirror-derivation fix has no smoke_test_reason.
|
|
789
|
+
from coord.state import load_assignment_test_reason as _load_tr # noqa: PLC0415
|
|
790
|
+
|
|
791
|
+
test_output = (
|
|
792
|
+
_load_tr(assignment_id)
|
|
793
|
+
or assignment.smoke_test_reason
|
|
794
|
+
or assignment.test_reason
|
|
795
|
+
or ""
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
guidance_text = guidance or "Fix the failing tests and push."
|
|
799
|
+
if forced_without_evidence:
|
|
800
|
+
_what = (
|
|
801
|
+
"a reported failure (--force: neither a failed test verdict nor "
|
|
802
|
+
"an automated CI read found one — the caller attests the PR is "
|
|
803
|
+
"red)"
|
|
804
|
+
)
|
|
805
|
+
_failure_heading = "Reported failure (--force, unverified)"
|
|
806
|
+
else:
|
|
807
|
+
_what = "red CI" if ci_story is not None else "a failed smoke test"
|
|
808
|
+
_failure_heading = "CI failure" if ci_story is not None else "Test failure"
|
|
809
|
+
|
|
810
|
+
briefing = (
|
|
811
|
+
f"You are fixing {_what} for issue #{assignment.issue_number}: {assignment.issue_title}\n\n"
|
|
812
|
+
f"The previous worker created branch {assignment.branch}. You are already on that branch.\n"
|
|
813
|
+
f"Do NOT start over — work from the existing code.\n\n"
|
|
814
|
+
f"## What was done\n"
|
|
815
|
+
f"The previous worker's changes are already committed on this branch.\n"
|
|
816
|
+
f"Run `git fetch origin && git log --oneline origin/{default_branch}..HEAD` to see what was done.\n"
|
|
817
|
+
f"Run `git diff origin/{default_branch}...HEAD` to see the full diff.\n\n"
|
|
818
|
+
f"## {_failure_heading}\n"
|
|
819
|
+
f"{test_output}\n\n"
|
|
820
|
+
f"## Guidance\n"
|
|
821
|
+
f"{guidance_text}\n\n"
|
|
822
|
+
f"## Rules\n"
|
|
823
|
+
f"- Do NOT start over or rewrite from scratch\n"
|
|
824
|
+
f"- Fix the specific test failures\n"
|
|
825
|
+
f"- Commit your fixes and push with git push origin HEAD"
|
|
826
|
+
)
|
|
827
|
+
|
|
828
|
+
# Determine escalated model for the fix-up.
|
|
829
|
+
original_model = assignment.model or cfg.models.default
|
|
830
|
+
escalated = cfg.models.next_model(original_model)
|
|
831
|
+
if escalated != original_model:
|
|
832
|
+
click.echo(f" escalating model: {original_model} → {escalated}")
|
|
833
|
+
|
|
834
|
+
try:
|
|
835
|
+
new_id = _dispatch_followup(cfg, assignment, briefing, model=escalated)
|
|
836
|
+
except httpx.HTTPError as e:
|
|
837
|
+
click.echo(f"error: dispatch failed: {e}", err=True)
|
|
838
|
+
sys.exit(1)
|
|
839
|
+
except DispatchRefused as e:
|
|
840
|
+
# #1844: this arm of `coord fix` (a failed test / red CI, reached
|
|
841
|
+
# from a WORK assignment id — see `coord/drive.py`'s
|
|
842
|
+
# `command=("fix", state.work_aid)`) is a RUN action `coord drive`
|
|
843
|
+
# dispatches directly, and `_dispatch_followup` → `dispatch()` raises
|
|
844
|
+
# THIS (a `ValueError` subclass) specifically for a deterministic
|
|
845
|
+
# pre-dispatch guard refusal — not worth a retry. (The OTHER `coord
|
|
846
|
+
# fix` arm — a request-changes review id — routes through
|
|
847
|
+
# `_fix_from_review` → `coord.auto_loop._dispatch_fix`, which POSTs
|
|
848
|
+
# directly and never runs these guards at all; this branch does not
|
|
849
|
+
# cover it.) Same distinguishable exit code as `_dispatch_headless`'s
|
|
850
|
+
# equivalent branch (coord/commands/dispatch_workers.py), so `coord
|
|
851
|
+
# drive`'s subprocess call can tell this apart from a crash.
|
|
852
|
+
from coord.drive import EXIT_DISPATCH_REFUSED # noqa: PLC0415
|
|
853
|
+
|
|
854
|
+
click.echo(f"error: {e}", err=True)
|
|
855
|
+
sys.exit(EXIT_DISPATCH_REFUSED)
|
|
856
|
+
except ValueError as e:
|
|
857
|
+
click.echo(f"error: {e}", err=True)
|
|
858
|
+
sys.exit(1)
|
|
859
|
+
|
|
860
|
+
click.echo(f"Fix-up worker dispatched (assignment {new_id})")
|
|
861
|
+
click.echo(f" branch: {assignment.branch}")
|
|
862
|
+
click.echo(f" issue: #{assignment.issue_number}: {assignment.issue_title}")
|
|
863
|
+
if test_output:
|
|
864
|
+
_label = "CI failure summary" if ci_story is not None else "test output"
|
|
865
|
+
click.echo(f" {_label} included in briefing ({len(test_output)} chars)")
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
@click.command(
|
|
869
|
+
"approve-plan",
|
|
870
|
+
help=(
|
|
871
|
+
"Approve a completed plan assignment and dispatch a work assignment "
|
|
872
|
+
"to implement it."
|
|
873
|
+
),
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
|
|
877
|
+
@click.argument("assignment_id")
|
|
878
|
+
@_CONFIG_OPTION
|
|
879
|
+
def approve_plan(assignment_id: str, config_path: Path) -> None:
|
|
880
|
+
from coord.board_service import read_board
|
|
881
|
+
|
|
882
|
+
cfg = _load_config(config_path)
|
|
883
|
+
board = read_board()
|
|
884
|
+
|
|
885
|
+
assignment = board.find_by_id(assignment_id)
|
|
886
|
+
if assignment is None:
|
|
887
|
+
click.echo(f"error: assignment {assignment_id!r} not found in board", err=True)
|
|
888
|
+
sys.exit(1)
|
|
889
|
+
|
|
890
|
+
if assignment.type != "plan":
|
|
891
|
+
click.echo(
|
|
892
|
+
f"error: assignment {assignment_id} is type {assignment.type!r}, not 'plan'. "
|
|
893
|
+
"Only plan assignments can be approved with approve-plan.",
|
|
894
|
+
err=True,
|
|
895
|
+
)
|
|
896
|
+
sys.exit(1)
|
|
897
|
+
|
|
898
|
+
if assignment.status != "done":
|
|
899
|
+
click.echo(
|
|
900
|
+
f"error: assignment {assignment_id} is {assignment.status!r}, not 'done'. "
|
|
901
|
+
"The plan worker must finish before you can approve it.",
|
|
902
|
+
err=True,
|
|
903
|
+
)
|
|
904
|
+
sys.exit(1)
|
|
905
|
+
|
|
906
|
+
plan_dict = _load_plan_for_assignment(assignment, assignment_id)
|
|
907
|
+
if plan_dict is None:
|
|
908
|
+
click.echo(
|
|
909
|
+
f"error: no plan data found for assignment {assignment_id}.\n"
|
|
910
|
+
"Possible reasons: the log is on a remote machine, or the worker "
|
|
911
|
+
"did not output plan sections.\n"
|
|
912
|
+
"Run 'coord notify' after the worker finishes to parse and cache the plan.",
|
|
913
|
+
err=True,
|
|
914
|
+
)
|
|
915
|
+
sys.exit(1)
|
|
916
|
+
|
|
917
|
+
plan_text = _plan_dict_to_text(plan_dict)
|
|
918
|
+
|
|
919
|
+
# Build the enhanced briefing for the work assignment.
|
|
920
|
+
original_briefing = (assignment.briefing or "").strip()
|
|
921
|
+
separator = "\n\n" if original_briefing else ""
|
|
922
|
+
enhanced_briefing = (
|
|
923
|
+
original_briefing
|
|
924
|
+
+ separator
|
|
925
|
+
+ "Your plan was reviewed and approved. Implement exactly as described:\n\n"
|
|
926
|
+
+ plan_text
|
|
927
|
+
).strip()
|
|
928
|
+
|
|
929
|
+
# Use files_modify from the plan as the allowed-files hint for the worker.
|
|
930
|
+
from coord.plan_parser import WorkerPlan # noqa: PLC0415
|
|
931
|
+
plan_obj = WorkerPlan.from_dict(plan_dict)
|
|
932
|
+
files_likely = plan_obj.files_modify or assignment.files_allowed or []
|
|
933
|
+
|
|
934
|
+
# #1430: the plan's ESTIMATE — informed by actually reading the code —
|
|
935
|
+
# is a better signal than the label chosen at issue-creation time, so it
|
|
936
|
+
# overrides the label-derived model for the work assignment. Falls back
|
|
937
|
+
# to the label when there's no usable ESTIMATE, and to models.default
|
|
938
|
+
# when neither resolves (mirrors dispatch()'s own precedence).
|
|
939
|
+
label_model: str | None = None
|
|
940
|
+
repo = cfg.repo(assignment.repo_name)
|
|
941
|
+
if repo is not None:
|
|
942
|
+
try:
|
|
943
|
+
from coord import github_ops # noqa: PLC0415
|
|
944
|
+
issue_data = github_ops.get_issue(repo.github, assignment.issue_number)
|
|
945
|
+
issue_labels = [
|
|
946
|
+
lbl.get("name", "") for lbl in (issue_data.get("labels") or [])
|
|
947
|
+
]
|
|
948
|
+
label_model = cfg.models.model_for_labels(issue_labels)
|
|
949
|
+
except RuntimeError:
|
|
950
|
+
pass # best-effort — fall through to estimate/default below
|
|
951
|
+
estimate_model = cfg.models.model_for_estimate(plan_obj.estimate)
|
|
952
|
+
if estimate_model:
|
|
953
|
+
work_model = estimate_model
|
|
954
|
+
click.echo(
|
|
955
|
+
f" model: {work_model} (from plan ESTIMATE={plan_obj.estimate!r}"
|
|
956
|
+
+ (f", overriding label-derived {label_model!r}" if label_model else "")
|
|
957
|
+
+ ")"
|
|
958
|
+
)
|
|
959
|
+
elif label_model:
|
|
960
|
+
work_model = label_model
|
|
961
|
+
click.echo(f" model: {work_model} (from issue label)")
|
|
962
|
+
else:
|
|
963
|
+
work_model = None
|
|
964
|
+
click.echo(f" model: {cfg.models.default} (default)")
|
|
965
|
+
|
|
966
|
+
click.echo(
|
|
967
|
+
f"Approving plan {assignment_id}: "
|
|
968
|
+
f"{assignment.repo_name} #{assignment.issue_number} — {assignment.issue_title}"
|
|
969
|
+
)
|
|
970
|
+
click.echo(f" Dispatching work assignment to {assignment.machine_name}...")
|
|
971
|
+
|
|
972
|
+
try:
|
|
973
|
+
new_id = _dispatch_followup(
|
|
974
|
+
cfg,
|
|
975
|
+
assignment,
|
|
976
|
+
enhanced_briefing,
|
|
977
|
+
model=work_model,
|
|
978
|
+
type="work",
|
|
979
|
+
files_likely=files_likely,
|
|
980
|
+
# The plan is read-only; its recorded branch is a throwaway
|
|
981
|
+
# worktree name (and can be a stale/wrong capture). Work must
|
|
982
|
+
# branch fresh from the issue, not inherit the plan's branch.
|
|
983
|
+
inherit_branch=False,
|
|
984
|
+
)
|
|
985
|
+
except httpx.HTTPError as e:
|
|
986
|
+
click.echo(f"error: dispatch failed: {e}", err=True)
|
|
987
|
+
sys.exit(1)
|
|
988
|
+
except DispatchRefused as e:
|
|
989
|
+
# #1844: same reasoning as `_dispatch_headless`'s equivalent branch
|
|
990
|
+
# (coord/commands/dispatch_workers.py) — `dispatch()` raises THIS (a
|
|
991
|
+
# `ValueError` subclass) specifically for a deterministic
|
|
992
|
+
# pre-dispatch guard refusal. `coord approve-plan` is the other RUN
|
|
993
|
+
# action `coord drive`'s work-stage dispatch can hit this on (the
|
|
994
|
+
# #1453 plan → work hand-off), so it needs the same distinguishable
|
|
995
|
+
# exit code.
|
|
996
|
+
from coord.drive import EXIT_DISPATCH_REFUSED # noqa: PLC0415
|
|
997
|
+
|
|
998
|
+
click.echo(f"error: {e}", err=True)
|
|
999
|
+
sys.exit(EXIT_DISPATCH_REFUSED)
|
|
1000
|
+
except ValueError as e:
|
|
1001
|
+
click.echo(f"error: {e}", err=True)
|
|
1002
|
+
sys.exit(1)
|
|
1003
|
+
|
|
1004
|
+
# Persist plan-stage SMOKE_TESTS onto the new work assignment so the
|
|
1005
|
+
# TUI surfaces them immediately — and so they survive even if the
|
|
1006
|
+
# work worker exits without re-emitting its own block. The work
|
|
1007
|
+
# worker's later SMOKE_TESTS (captured by notify._capture_smoke_tests)
|
|
1008
|
+
# overrides this when present.
|
|
1009
|
+
if plan_obj.smoke_tests is not None:
|
|
1010
|
+
from coord.state import update_assignment_smoke_tests # noqa: PLC0415
|
|
1011
|
+
update_assignment_smoke_tests(new_id, plan_obj.smoke_tests)
|
|
1012
|
+
|
|
1013
|
+
click.echo(f" Work assignment dispatched (assignment {new_id})")
|
|
1014
|
+
click.echo(f" repo: {assignment.repo_name} issue: #{assignment.issue_number}")
|
|
1015
|
+
click.echo(f" Run: coord log {new_id} to follow progress")
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
@click.command(
|
|
1019
|
+
"reject-plan",
|
|
1020
|
+
help=(
|
|
1021
|
+
"Reject a completed plan assignment and re-dispatch for revision "
|
|
1022
|
+
"with additional guidance."
|
|
1023
|
+
),
|
|
1024
|
+
)
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
@click.argument("assignment_id")
|
|
1028
|
+
@_CONFIG_OPTION
|
|
1029
|
+
@click.option(
|
|
1030
|
+
"--guidance",
|
|
1031
|
+
required=True,
|
|
1032
|
+
help="Guidance text explaining what to revise in the plan.",
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
|
|
1036
|
+
def reject_plan(assignment_id: str, config_path: Path, guidance: str) -> None:
|
|
1037
|
+
from coord.board_service import read_board
|
|
1038
|
+
|
|
1039
|
+
cfg = _load_config(config_path)
|
|
1040
|
+
board = read_board()
|
|
1041
|
+
|
|
1042
|
+
assignment = board.find_by_id(assignment_id)
|
|
1043
|
+
if assignment is None:
|
|
1044
|
+
click.echo(f"error: assignment {assignment_id!r} not found in board", err=True)
|
|
1045
|
+
sys.exit(1)
|
|
1046
|
+
|
|
1047
|
+
if assignment.type != "plan":
|
|
1048
|
+
click.echo(
|
|
1049
|
+
f"error: assignment {assignment_id} is type {assignment.type!r}, not 'plan'. "
|
|
1050
|
+
"Only plan assignments can be rejected with reject-plan.",
|
|
1051
|
+
err=True,
|
|
1052
|
+
)
|
|
1053
|
+
sys.exit(1)
|
|
1054
|
+
|
|
1055
|
+
if assignment.status != "done":
|
|
1056
|
+
click.echo(
|
|
1057
|
+
f"error: assignment {assignment_id} is {assignment.status!r}, not 'done'. "
|
|
1058
|
+
"The plan worker must finish before you can reject it.",
|
|
1059
|
+
err=True,
|
|
1060
|
+
)
|
|
1061
|
+
sys.exit(1)
|
|
1062
|
+
|
|
1063
|
+
plan_dict = _load_plan_for_assignment(assignment, assignment_id)
|
|
1064
|
+
if plan_dict is None:
|
|
1065
|
+
click.echo(
|
|
1066
|
+
f"error: no plan data found for assignment {assignment_id}.\n"
|
|
1067
|
+
"Possible reasons: the log is on a remote machine, or the worker "
|
|
1068
|
+
"did not output plan sections.\n"
|
|
1069
|
+
"Run 'coord notify' after the worker finishes to parse and cache the plan.",
|
|
1070
|
+
err=True,
|
|
1071
|
+
)
|
|
1072
|
+
sys.exit(1)
|
|
1073
|
+
|
|
1074
|
+
plan_text = _plan_dict_to_text(plan_dict)
|
|
1075
|
+
|
|
1076
|
+
# Build the enhanced briefing for the revised plan assignment.
|
|
1077
|
+
original_briefing = (assignment.briefing or "").strip()
|
|
1078
|
+
separator = "\n\n" if original_briefing else ""
|
|
1079
|
+
enhanced_briefing = (
|
|
1080
|
+
original_briefing
|
|
1081
|
+
+ separator
|
|
1082
|
+
+ "Previous plan (rejected):\n\n"
|
|
1083
|
+
+ plan_text
|
|
1084
|
+
+ "\n\nGuidance:\n\n"
|
|
1085
|
+
+ guidance.strip()
|
|
1086
|
+
).strip()
|
|
1087
|
+
|
|
1088
|
+
click.echo(
|
|
1089
|
+
f"Rejecting plan {assignment_id}: "
|
|
1090
|
+
f"{assignment.repo_name} #{assignment.issue_number} — {assignment.issue_title}"
|
|
1091
|
+
)
|
|
1092
|
+
click.echo(f" Re-dispatching revised plan to {assignment.machine_name}...")
|
|
1093
|
+
|
|
1094
|
+
try:
|
|
1095
|
+
new_id = _dispatch_followup(
|
|
1096
|
+
cfg,
|
|
1097
|
+
assignment,
|
|
1098
|
+
enhanced_briefing,
|
|
1099
|
+
type="plan",
|
|
1100
|
+
files_likely=list(assignment.files_allowed),
|
|
1101
|
+
# Revised plan is read-only too — don't inherit the prior
|
|
1102
|
+
# plan's throwaway branch.
|
|
1103
|
+
inherit_branch=False,
|
|
1104
|
+
)
|
|
1105
|
+
except httpx.HTTPError as e:
|
|
1106
|
+
click.echo(f"error: dispatch failed: {e}", err=True)
|
|
1107
|
+
sys.exit(1)
|
|
1108
|
+
except ValueError as e:
|
|
1109
|
+
click.echo(f"error: {e}", err=True)
|
|
1110
|
+
sys.exit(1)
|
|
1111
|
+
|
|
1112
|
+
click.echo(f" Revised plan assignment dispatched (assignment {new_id})")
|
|
1113
|
+
click.echo(f" repo: {assignment.repo_name} issue: #{assignment.issue_number}")
|
|
1114
|
+
click.echo(f" Run: coord log {new_id} to follow progress")
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
@click.command(
|
|
1118
|
+
"resume-stuck",
|
|
1119
|
+
help="Stop a stuck worker and dispatch a continuation with guidance.",
|
|
1120
|
+
)
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
@click.argument("assignment_id")
|
|
1124
|
+
@_CONFIG_OPTION
|
|
1125
|
+
@click.option("--guidance", required=True, help="Guidance for the continuation worker.")
|
|
1126
|
+
def resume_stuck(assignment_id: str, config_path: Path, guidance: str) -> None:
|
|
1127
|
+
from coord.board_service import read_board
|
|
1128
|
+
|
|
1129
|
+
cfg = _load_config(config_path)
|
|
1130
|
+
board = read_board()
|
|
1131
|
+
|
|
1132
|
+
assignment = board.find_by_id(assignment_id)
|
|
1133
|
+
if assignment is None:
|
|
1134
|
+
click.echo(f"error: assignment {assignment_id!r} not found in board", err=True)
|
|
1135
|
+
sys.exit(1)
|
|
1136
|
+
|
|
1137
|
+
if assignment.status != "running":
|
|
1138
|
+
click.echo(
|
|
1139
|
+
f"error: assignment {assignment_id} is {assignment.status!r}, "
|
|
1140
|
+
"can only resume-stuck a running assignment",
|
|
1141
|
+
err=True,
|
|
1142
|
+
)
|
|
1143
|
+
sys.exit(1)
|
|
1144
|
+
|
|
1145
|
+
# Find the machine this assignment is running on
|
|
1146
|
+
machine = next(
|
|
1147
|
+
(m for m in cfg.machines if m.name == assignment.machine_name), None
|
|
1148
|
+
)
|
|
1149
|
+
if machine is None:
|
|
1150
|
+
click.echo(
|
|
1151
|
+
f"error: machine {assignment.machine_name!r} not in config", err=True
|
|
1152
|
+
)
|
|
1153
|
+
sys.exit(1)
|
|
1154
|
+
|
|
1155
|
+
# Stop the current worker. #1567 changed `coord stop`'s default to NOT
|
|
1156
|
+
# push a worker's uncommitted WIP anywhere — but resume-stuck is not a
|
|
1157
|
+
# `coord stop`: it immediately dispatches a continuation onto this same
|
|
1158
|
+
# branch (below) and that continuation's fresh worktree is built from
|
|
1159
|
+
# `origin/<branch>` (see AgentServer._setup_worktree's continuation
|
|
1160
|
+
# path), so any uncommitted work MUST reach the branch on the remote or
|
|
1161
|
+
# the continuation silently starts over from before it. push_mode=branch
|
|
1162
|
+
# keeps the pre-#1567 behaviour here on purpose.
|
|
1163
|
+
try:
|
|
1164
|
+
resp = httpx.post(
|
|
1165
|
+
f"http://{machine.host}:{AGENT_PORT}/cancel/{assignment_id}",
|
|
1166
|
+
params={"push_mode": "branch"},
|
|
1167
|
+
timeout=10,
|
|
1168
|
+
)
|
|
1169
|
+
resp.raise_for_status()
|
|
1170
|
+
click.echo(f"Cancelled stuck worker on {machine.name}")
|
|
1171
|
+
except (httpx.HTTPError, httpx.TimeoutException) as e:
|
|
1172
|
+
click.echo(
|
|
1173
|
+
f"warning: could not cancel worker on {machine.name}: {e} "
|
|
1174
|
+
"(may have already stopped)",
|
|
1175
|
+
err=True,
|
|
1176
|
+
)
|
|
1177
|
+
|
|
1178
|
+
# Brief pause for cancellation to take effect
|
|
1179
|
+
time.sleep(2)
|
|
1180
|
+
|
|
1181
|
+
# Retrieve the stuck message from the agent's progress data
|
|
1182
|
+
stuck_message = ""
|
|
1183
|
+
try:
|
|
1184
|
+
status_resp = httpx.get(
|
|
1185
|
+
f"http://{machine.host}:{AGENT_PORT}/status", timeout=5
|
|
1186
|
+
)
|
|
1187
|
+
if status_resp.status_code == 200:
|
|
1188
|
+
status_data = status_resp.json()
|
|
1189
|
+
# Check active and completed for progress info
|
|
1190
|
+
for entry in status_data.get("active", []) + status_data.get("completed", []):
|
|
1191
|
+
if entry.get("id") == assignment_id:
|
|
1192
|
+
progress = entry.get("progress", {})
|
|
1193
|
+
if progress and progress.get("stuck"):
|
|
1194
|
+
stuck_message = progress["stuck"]
|
|
1195
|
+
break
|
|
1196
|
+
except Exception: # noqa: BLE001
|
|
1197
|
+
pass
|
|
1198
|
+
|
|
1199
|
+
repo = cfg.repo(assignment.repo_name)
|
|
1200
|
+
if repo is None:
|
|
1201
|
+
click.echo(f"error: unknown repo {assignment.repo_name!r}", err=True)
|
|
1202
|
+
sys.exit(1)
|
|
1203
|
+
|
|
1204
|
+
default_branch = repo.default_branch
|
|
1205
|
+
|
|
1206
|
+
stuck_section = stuck_message if stuck_message else "(no stuck message captured)"
|
|
1207
|
+
|
|
1208
|
+
briefing = (
|
|
1209
|
+
f"You are continuing work on issue #{assignment.issue_number}: {assignment.issue_title}\n\n"
|
|
1210
|
+
f"The previous worker got stuck on branch {assignment.branch or 'unknown'}. "
|
|
1211
|
+
f"You are already on that branch.\n"
|
|
1212
|
+
f"Do NOT start over — continue from where they left off.\n\n"
|
|
1213
|
+
f"## What was done\n"
|
|
1214
|
+
f"Run `git fetch origin && git log --oneline origin/{default_branch}..HEAD` to see previous work.\n"
|
|
1215
|
+
f"Run `git diff origin/{default_branch}...HEAD` to see the full diff.\n\n"
|
|
1216
|
+
f"## What the previous worker was stuck on\n"
|
|
1217
|
+
f"{stuck_section}\n\n"
|
|
1218
|
+
f"## Guidance\n"
|
|
1219
|
+
f"{guidance}\n\n"
|
|
1220
|
+
f"## Rules\n"
|
|
1221
|
+
f"- Continue from the existing branch, do not start over\n"
|
|
1222
|
+
f"- Commit your work and push with git push origin HEAD"
|
|
1223
|
+
)
|
|
1224
|
+
|
|
1225
|
+
# Determine escalated model for the continuation worker.
|
|
1226
|
+
original_model = assignment.model or cfg.models.default
|
|
1227
|
+
escalated = cfg.models.next_model(original_model)
|
|
1228
|
+
if escalated != original_model:
|
|
1229
|
+
click.echo(f" escalating model: {original_model} → {escalated}")
|
|
1230
|
+
|
|
1231
|
+
try:
|
|
1232
|
+
new_id = _dispatch_followup(cfg, assignment, briefing, model=escalated)
|
|
1233
|
+
except httpx.HTTPError as e:
|
|
1234
|
+
click.echo(f"error: dispatch failed: {e}", err=True)
|
|
1235
|
+
sys.exit(1)
|
|
1236
|
+
except ValueError as e:
|
|
1237
|
+
click.echo(f"error: {e}", err=True)
|
|
1238
|
+
sys.exit(1)
|
|
1239
|
+
|
|
1240
|
+
click.echo(f"Continuation dispatched (assignment {new_id})")
|
|
1241
|
+
click.echo(f" branch: {assignment.branch or 'unknown'}")
|
|
1242
|
+
click.echo(f" issue: #{assignment.issue_number}: {assignment.issue_title}")
|
|
1243
|
+
click.echo(f" guidance: {guidance}")
|