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,1780 @@
|
|
|
1
|
+
"""Core dispatch commands: `assign`, `approve`, `plan`, `retry`, `stop`,
|
|
2
|
+
`inject`, `chat-continue`. The mode-specific `_dispatch_*` worker
|
|
3
|
+
implementations `assign` delegates to live in dispatch_workers.py.
|
|
4
|
+
Extracted from coord/cli.py (#747)."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import dataclasses
|
|
9
|
+
import socket
|
|
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 describe_model_choice
|
|
18
|
+
|
|
19
|
+
from coord.commands._common import AGENT_PORT, _CONFIG_OPTION, _load_config
|
|
20
|
+
from coord.commands.dispatch_workers import (
|
|
21
|
+
_dispatch_audit_of,
|
|
22
|
+
_dispatch_chat,
|
|
23
|
+
_dispatch_fix_of,
|
|
24
|
+
_dispatch_headless,
|
|
25
|
+
_dispatch_interactive_work,
|
|
26
|
+
_dispatch_merge_of,
|
|
27
|
+
_dispatch_milestone_chat_of,
|
|
28
|
+
_dispatch_review_of,
|
|
29
|
+
_dispatch_rework_of,
|
|
30
|
+
_dispatch_smoke_of,
|
|
31
|
+
_dispatch_troubleshoot,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _stdin_is_tty() -> bool:
|
|
36
|
+
"""Seam over ``sys.stdin.isatty()`` — see ``_require_interactive_tty``
|
|
37
|
+
for what this gates and why (#2086).
|
|
38
|
+
|
|
39
|
+
``click.testing.CliRunner.invoke()`` swaps in its own non-TTY stdin
|
|
40
|
+
object for the duration of the call, so a test that monkeypatches
|
|
41
|
+
``sys.stdin.isatty`` *before* calling ``invoke()`` is patching an
|
|
42
|
+
attribute on the object ``sys.stdin`` pointed at pre-call — CliRunner's
|
|
43
|
+
replacement object is unaffected. Routing the check through this
|
|
44
|
+
module-level function gives tests (and any other caller) a stable seam
|
|
45
|
+
to patch directly instead.
|
|
46
|
+
"""
|
|
47
|
+
return sys.stdin.isatty()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _require_interactive_tty(dry_run: bool) -> None:
|
|
51
|
+
"""Refuse ``--interactive`` when stdin is not a TTY and this isn't a
|
|
52
|
+
dry run (#2086).
|
|
53
|
+
|
|
54
|
+
Every ``--interactive`` flavour hands the child session (and, for a
|
|
55
|
+
remote/tmux target, the final ``tmux attach-session``) the operator's
|
|
56
|
+
own terminal — there's no human at the keyboard to drive the
|
|
57
|
+
paste/attach handshake without one. Left unchecked, this used to fall
|
|
58
|
+
through to ``input()`` in interactive.py swallowing the resulting
|
|
59
|
+
``EOFError``, the attach failing ("Pseudo-terminal will not be
|
|
60
|
+
allocated…" / "no sessions"), and the session never actually starting
|
|
61
|
+
— yet by then the assignment was already claimed + recorded as
|
|
62
|
+
dispatched, so the #466 git-floor backstop could go on to record a
|
|
63
|
+
false ``done`` and the auto-loop would dispatch real, metered
|
|
64
|
+
downstream stages (Test, Review, Merge) against work that never
|
|
65
|
+
happened. Same precedent as ``--skip-review`` refusing explicitly
|
|
66
|
+
rather than silently degrading when routed to the daemon (#821/#1489):
|
|
67
|
+
fail loudly, before any claim/board write, so a confusing multi-minute
|
|
68
|
+
cascade becomes one clear error instead.
|
|
69
|
+
|
|
70
|
+
Called from :func:`_build_interactive_launch_setup` — the single choke
|
|
71
|
+
point every ``--interactive`` flavour passes through in BOTH callers
|
|
72
|
+
(``assign()`` and ``coord.test_author.dispatch_test_author_interactive``)
|
|
73
|
+
— so neither can bypass it by construction. ``assign()`` also calls
|
|
74
|
+
this directly, before its own harmless read-only issue-title fetch,
|
|
75
|
+
purely so a no-TTY dispatch fails before that network round-trip too;
|
|
76
|
+
it is the exact same check reused, not a re-implementation of it.
|
|
77
|
+
|
|
78
|
+
A dry run never claims or writes anything (every ``_dispatch_*_of``
|
|
79
|
+
flavour short-circuits on ``dry_run`` before any claim/attach code
|
|
80
|
+
runs), so it's exempt from this gate too.
|
|
81
|
+
"""
|
|
82
|
+
if dry_run or _stdin_is_tty():
|
|
83
|
+
return
|
|
84
|
+
raise RuntimeError(
|
|
85
|
+
"--interactive requires a TTY on stdin — it drives a "
|
|
86
|
+
"human-attended claude session (pre-filling the briefing and "
|
|
87
|
+
"attaching your terminal to it). Run this from an actual "
|
|
88
|
+
"terminal, or omit --interactive for headless dispatch."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@click.command(help="Brain proposes assignments for idle machines.")
|
|
93
|
+
@_CONFIG_OPTION
|
|
94
|
+
@click.option("--dry-run", is_flag=True, help="Plan without saving proposals.")
|
|
95
|
+
def plan(config_path: Path, dry_run: bool) -> None:
|
|
96
|
+
from coord.brain import propose
|
|
97
|
+
from coord.state import save_proposals, save_split_proposals
|
|
98
|
+
|
|
99
|
+
cfg = _load_config(config_path)
|
|
100
|
+
click.echo("Gathering context...", nl=False)
|
|
101
|
+
sys.stdout.flush()
|
|
102
|
+
|
|
103
|
+
from coord.brain import gather_context, build_prompt, call_claude, parse_proposals, parse_split_proposals, resolve_models, resolve_required_gates, SYSTEM_PROMPT
|
|
104
|
+
context = gather_context(cfg)
|
|
105
|
+
issue_count = sum(len(v) for v in context["issues_by_repo"].values())
|
|
106
|
+
online = sum(1 for v in context["machine_status"].values() if v.get("status") != "offline" and "error" not in str(v))
|
|
107
|
+
click.echo(f" {issue_count} issues across {len(cfg.repos)} repos, {online} machines online.")
|
|
108
|
+
click.echo("Calling Claude (this may take 1-2 minutes)...", nl=False)
|
|
109
|
+
sys.stdout.flush()
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
prompt = build_prompt(cfg, context)
|
|
113
|
+
response = call_claude(SYSTEM_PROMPT, prompt)
|
|
114
|
+
proposals = parse_proposals(response)
|
|
115
|
+
resolve_required_gates(proposals, cfg, context["issues_by_repo"])
|
|
116
|
+
# #1454: this CLI wrapper never called resolve_models() (only
|
|
117
|
+
# coord.brain.propose()'s full cycle did) — so `models.labels`
|
|
118
|
+
# routing was silently dead for the entire `coord plan` ->
|
|
119
|
+
# `coord approve` two-step; every work proposal saved by `coord
|
|
120
|
+
# plan` reached `approve()` with `p.model` unset regardless of the
|
|
121
|
+
# issue's tier/category label, and fell back to `models.default`.
|
|
122
|
+
resolve_models(proposals, cfg, context["issues_by_repo"])
|
|
123
|
+
splits = parse_split_proposals(response)
|
|
124
|
+
except RuntimeError as e:
|
|
125
|
+
click.echo(f"error: {e}", err=True)
|
|
126
|
+
sys.exit(1)
|
|
127
|
+
|
|
128
|
+
# #1711: never show a proposal `coord approve` would immediately refuse
|
|
129
|
+
# for lacking the resolved provider's machine capability (e.g. an
|
|
130
|
+
# opencode-routed repo proposed against a machine with no
|
|
131
|
+
# `provider:opencode`) — see coord.brain.filter_unroutable_provider_proposals.
|
|
132
|
+
from coord.brain import filter_unroutable_provider_proposals
|
|
133
|
+
|
|
134
|
+
proposals, dropped = filter_unroutable_provider_proposals(proposals, cfg)
|
|
135
|
+
for p, reason in dropped:
|
|
136
|
+
click.echo(
|
|
137
|
+
f" ⚠ dropped proposal: {p.machine_name} → {p.repo_name} "
|
|
138
|
+
f"#{p.issue_number}: {reason}"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
if splits:
|
|
142
|
+
click.echo(f"{len(splits)} split proposal(s):\n")
|
|
143
|
+
for s in splits:
|
|
144
|
+
click.echo(f" [S{s.id}] {s.repo_name} #{s.issue_number}: {s.issue_title}")
|
|
145
|
+
click.echo(f" {s.rationale}")
|
|
146
|
+
click.echo(f" chunks ({len(s.chunks)}):")
|
|
147
|
+
for j, chunk in enumerate(s.chunks, 1):
|
|
148
|
+
click.echo(f" {j}. {chunk.title}")
|
|
149
|
+
click.echo(f" {chunk.scope}")
|
|
150
|
+
click.echo()
|
|
151
|
+
|
|
152
|
+
if proposals:
|
|
153
|
+
click.echo(f"{len(proposals)} assignment proposal(s):\n")
|
|
154
|
+
for p in proposals:
|
|
155
|
+
click.echo(f" [{p.id}] {p.machine_name} → {p.repo_name} #{p.issue_number}: {p.issue_title}")
|
|
156
|
+
click.echo(f" {p.rationale}")
|
|
157
|
+
if p.files_likely:
|
|
158
|
+
click.echo(f" files: {', '.join(p.files_likely)}")
|
|
159
|
+
click.echo()
|
|
160
|
+
|
|
161
|
+
if not proposals and not splits:
|
|
162
|
+
click.echo("No assignments to propose.")
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
if dry_run:
|
|
166
|
+
click.echo("(dry run — proposals not saved)")
|
|
167
|
+
else:
|
|
168
|
+
if proposals:
|
|
169
|
+
save_proposals(proposals)
|
|
170
|
+
if splits:
|
|
171
|
+
save_split_proposals(splits)
|
|
172
|
+
click.echo("Proposals saved.")
|
|
173
|
+
if proposals:
|
|
174
|
+
click.echo("Run `coord approve <ids>` to dispatch (e.g. coord approve 1,2)")
|
|
175
|
+
if splits:
|
|
176
|
+
click.echo("Run `coord split <ids>` to create sub-issues (e.g. coord split S1)")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@click.command(help="Dispatch approved assignments (comma-separated IDs).")
|
|
180
|
+
@click.argument("ids")
|
|
181
|
+
@_CONFIG_OPTION
|
|
182
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be dispatched.")
|
|
183
|
+
@click.option(
|
|
184
|
+
"--auto-pull",
|
|
185
|
+
is_flag=True,
|
|
186
|
+
help="Tell the agent to `git pull --ff-only` stale dependency repos before starting.",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@click.option(
|
|
191
|
+
"--skip-freshness",
|
|
192
|
+
is_flag=True,
|
|
193
|
+
help="Skip the dependency freshness check (faster, no network for GH HEADs).",
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def approve(
|
|
198
|
+
ids: str, config_path: Path, dry_run: bool, auto_pull: bool, skip_freshness: bool
|
|
199
|
+
) -> None:
|
|
200
|
+
from coord import freshness as fresh
|
|
201
|
+
from coord.board_service import read_board, write_board
|
|
202
|
+
from coord.deps import blocked_repos as compute_blocked, build_dep_graph, transitive_deps
|
|
203
|
+
from coord.dispatch import compute_do_not_touch, dispatch, dispatch_with_retry, post_briefing
|
|
204
|
+
from coord.network import classify_error, fetch_repos
|
|
205
|
+
from coord.state import (
|
|
206
|
+
clear_proposals,
|
|
207
|
+
load_proposals,
|
|
208
|
+
record_dispatched,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
cfg = _load_config(config_path)
|
|
212
|
+
proposals = load_proposals()
|
|
213
|
+
if not proposals:
|
|
214
|
+
click.echo("No pending proposals. Run `coord plan` first.", err=True)
|
|
215
|
+
sys.exit(1)
|
|
216
|
+
|
|
217
|
+
# ── Max-plan usage-window pre-check (#1466) ─────────────────────────
|
|
218
|
+
# A batch approve can start several headless workers at once — exactly
|
|
219
|
+
# what runs a Max-plan 5h/weekly window dry mid-batch, stranding the
|
|
220
|
+
# later workers' branches. The probe is server-side/account-wide and
|
|
221
|
+
# ~60s cached (coord.usage_limits) so this costs nothing extra even
|
|
222
|
+
# across several `coord approve` calls in a row. `usage_gate.mode`
|
|
223
|
+
# defaults to "warn" — never refuses until an operator opts into
|
|
224
|
+
# "block" in coordinator.yml. See UsageGateConfig's docstring for the
|
|
225
|
+
# CAVEAT: this is predictive only while headless usage still draws the
|
|
226
|
+
# subscription windows `/usage` reports (paused rollout as of
|
|
227
|
+
# 2026-06-15) rather than a separate monthly credit pool.
|
|
228
|
+
if cfg.usage_gate.mode != "disabled":
|
|
229
|
+
from coord.usage_limits import evaluate_usage_gate, get_plan_limits
|
|
230
|
+
|
|
231
|
+
gate_result = evaluate_usage_gate(get_plan_limits(), cfg.usage_gate)
|
|
232
|
+
if gate_result.action == "block":
|
|
233
|
+
click.echo(
|
|
234
|
+
f"error: {gate_result.message} (usage_gate.mode: block) — "
|
|
235
|
+
"refusing to dispatch. Wait for the window to reset, or set "
|
|
236
|
+
"usage_gate.mode: warn in coordinator.yml.",
|
|
237
|
+
err=True,
|
|
238
|
+
)
|
|
239
|
+
sys.exit(1)
|
|
240
|
+
elif gate_result.action == "warn":
|
|
241
|
+
click.echo(f"warning: {gate_result.message}", err=True)
|
|
242
|
+
|
|
243
|
+
try:
|
|
244
|
+
selected_ids = [int(x.strip()) for x in ids.split(",")]
|
|
245
|
+
except ValueError:
|
|
246
|
+
click.echo("error: IDs must be comma-separated integers (e.g. 1,3)", err=True)
|
|
247
|
+
sys.exit(2)
|
|
248
|
+
|
|
249
|
+
selected = [p for p in proposals if p.id in selected_ids]
|
|
250
|
+
missing = set(selected_ids) - {p.id for p in selected}
|
|
251
|
+
if missing:
|
|
252
|
+
click.echo(f"error: unknown proposal IDs: {missing}", err=True)
|
|
253
|
+
sys.exit(2)
|
|
254
|
+
|
|
255
|
+
# Warn about dependency-blocked repos
|
|
256
|
+
board = read_board()
|
|
257
|
+
blocked = compute_blocked(cfg.repos, board.active)
|
|
258
|
+
for p in selected:
|
|
259
|
+
if p.repo_name in blocked:
|
|
260
|
+
click.echo(f" warning: {p.repo_name} is blocked by upstream work:", err=True)
|
|
261
|
+
for reason in blocked[p.repo_name]:
|
|
262
|
+
click.echo(f" - {reason}", err=True)
|
|
263
|
+
|
|
264
|
+
# #906: derive in_flight from board.active instead of load_dispatched() so
|
|
265
|
+
# a thin client (empty local DB) still sees peer assignments that are
|
|
266
|
+
# running on other machines (the daemon board tracks them all).
|
|
267
|
+
in_flight = [
|
|
268
|
+
{"machine_name": a.machine_name, "repo_name": a.repo_name, "files_likely": a.files_allowed}
|
|
269
|
+
for a in board.active
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
# ── Claim pre-check ──────────────────────────────────────────────
|
|
273
|
+
# Refuse any proposal whose issue is already being worked on (board
|
|
274
|
+
# has an active assignment, or remote has an `issue-{N}-*` branch).
|
|
275
|
+
from coord.claim import claim_message, find_work_claim
|
|
276
|
+
|
|
277
|
+
unclaimed: list = []
|
|
278
|
+
for p in selected:
|
|
279
|
+
repo_cfg = cfg.repo(p.repo_name)
|
|
280
|
+
if repo_cfg is None:
|
|
281
|
+
unclaimed.append(p)
|
|
282
|
+
continue
|
|
283
|
+
claim = find_work_claim(
|
|
284
|
+
p.issue_number, p.repo_name, repo_cfg.github, board
|
|
285
|
+
)
|
|
286
|
+
if claim is not None:
|
|
287
|
+
click.echo(
|
|
288
|
+
f"[{p.id}] skipping {p.repo_name} #{p.issue_number}: "
|
|
289
|
+
f"{claim_message(claim)}",
|
|
290
|
+
err=True,
|
|
291
|
+
)
|
|
292
|
+
continue
|
|
293
|
+
unclaimed.append(p)
|
|
294
|
+
|
|
295
|
+
if not unclaimed:
|
|
296
|
+
click.echo("No proposals remain after claim check.", err=True)
|
|
297
|
+
sys.exit(1)
|
|
298
|
+
selected = unclaimed
|
|
299
|
+
|
|
300
|
+
# ── Freshness pre-check ──────────────────────────────────────────
|
|
301
|
+
machine_repos: dict[str, dict | None] = {}
|
|
302
|
+
github_heads: dict[str, str | None] = {}
|
|
303
|
+
if not skip_freshness and not dry_run:
|
|
304
|
+
graph = build_dep_graph(cfg.repos)
|
|
305
|
+
machines_needed = {p.machine_name for p in selected}
|
|
306
|
+
for mname in machines_needed:
|
|
307
|
+
machine = next((m for m in cfg.machines if m.name == mname), None)
|
|
308
|
+
machine_repos[mname] = fetch_repos(machine) if machine else None
|
|
309
|
+
|
|
310
|
+
repos_needed: set[str] = set()
|
|
311
|
+
for p in selected:
|
|
312
|
+
repos_needed.update(transitive_deps(p.repo_name, graph))
|
|
313
|
+
for repo_name in repos_needed:
|
|
314
|
+
repo_cfg = cfg.repo(repo_name)
|
|
315
|
+
if repo_cfg is None:
|
|
316
|
+
github_heads[repo_name] = None
|
|
317
|
+
continue
|
|
318
|
+
try:
|
|
319
|
+
github_heads[repo_name] = github_ops.get_default_branch_head(
|
|
320
|
+
repo_cfg.github, repo_cfg.default_branch
|
|
321
|
+
)
|
|
322
|
+
except RuntimeError as e:
|
|
323
|
+
click.echo(f" warning: could not get HEAD of {repo_cfg.github}: {e}", err=True)
|
|
324
|
+
github_heads[repo_name] = None
|
|
325
|
+
|
|
326
|
+
# ── Auto-split advisory ───────────────────────────────────────────────
|
|
327
|
+
if cfg.dispatch.auto_split:
|
|
328
|
+
from coord.split_work import analyze_plan, format_chunks_summary
|
|
329
|
+
|
|
330
|
+
for p in selected:
|
|
331
|
+
chunks = analyze_plan(p.files_likely, cfg.dispatch)
|
|
332
|
+
if len(chunks) > 1:
|
|
333
|
+
click.echo(
|
|
334
|
+
f" ⚠ [{p.id}] {p.repo_name} #{p.issue_number} touches "
|
|
335
|
+
f"{len(p.files_likely)} files (threshold: "
|
|
336
|
+
f"{cfg.dispatch.max_files_per_worker}) — consider splitting:"
|
|
337
|
+
)
|
|
338
|
+
click.echo(format_chunks_summary(chunks))
|
|
339
|
+
|
|
340
|
+
for p in selected:
|
|
341
|
+
click.echo(f"[{p.id}] {p.machine_name} → {p.repo_name} #{p.issue_number}: {p.issue_title}")
|
|
342
|
+
# Resolve model so the dispatched record and board reflect what ran.
|
|
343
|
+
# #1430: coord.brain.resolve_models() already set p.model from
|
|
344
|
+
# models.labels (via config.models.model_for_labels) for work
|
|
345
|
+
# proposals with a matching label, during `coord plan`. That call
|
|
346
|
+
# sets `proposal.model` *directly* from the label match — with zero
|
|
347
|
+
# awareness of the effective provider or its pin.
|
|
348
|
+
#
|
|
349
|
+
# #1454: that resolution is only as fresh as the issue's labels AT
|
|
350
|
+
# PLAN TIME. "Label it, then dispatch it" is the documented tier
|
|
351
|
+
# workflow (#1430) — and `coord plan` / `coord approve` are two
|
|
352
|
+
# separate invocations that can be minutes or hours apart, so a
|
|
353
|
+
# label added after planning must still win here, not silently fall
|
|
354
|
+
# back to `models.default` because the plan-time snapshot missed
|
|
355
|
+
# it. When there's no already-resolved model, re-check the issue's
|
|
356
|
+
# CURRENT labels with a live fetch (same as `coord assign` /
|
|
357
|
+
# `coord milestone dispatch` already do) before falling back.
|
|
358
|
+
#
|
|
359
|
+
# #1798 review fix: a plan-time `p.model` used to flow straight into
|
|
360
|
+
# `dispatch()` untouched. `dispatch()` (via `resolve_dispatch_model()`)
|
|
361
|
+
# treats a non-None `proposal.model` as an *explicit* override — the
|
|
362
|
+
# branch meant for "a human was specific" — so a plan-time label
|
|
363
|
+
# match (e.g. "haiku") won over a pinned non-claude provider's own
|
|
364
|
+
# model exactly like before the #1798 precedence fix, and now trips
|
|
365
|
+
# the new `enforce_model_provider_compatibility` gate instead of
|
|
366
|
+
# just silently misdispatching. `coord approve` has no `--model`
|
|
367
|
+
# flag of its own, so there is no genuine explicit override at this
|
|
368
|
+
# call site: every non-None `p.model` reaching this loop is
|
|
369
|
+
# label-derived (from `coord.brain.resolve_models()` at plan time,
|
|
370
|
+
# or a human editing the saved proposal — either way label-shaped,
|
|
371
|
+
# not "a human was specific about --model just now"). Feed it to
|
|
372
|
+
# `resolve_dispatch_model_alias` as *label_model*, never as
|
|
373
|
+
# *explicit_model*, the same way `_dispatch_headless` and
|
|
374
|
+
# `milestone_dispatch.dispatch_entry` already do — so a provider's
|
|
375
|
+
# pin still wins over it.
|
|
376
|
+
matched_label: str | None = None
|
|
377
|
+
shadowed_labels: list[str] = []
|
|
378
|
+
label_model: str | None = None
|
|
379
|
+
used_plan_time_snapshot = False
|
|
380
|
+
# #1889: this issue's labels, used for BOTH models.labels (below)
|
|
381
|
+
# and providers.labels (the resolve_provider_name call right after
|
|
382
|
+
# this block).
|
|
383
|
+
work_issue_labels: list[str] = []
|
|
384
|
+
if p.type == "work":
|
|
385
|
+
if p.model:
|
|
386
|
+
label_model = p.model
|
|
387
|
+
used_plan_time_snapshot = True
|
|
388
|
+
# #1889: no live re-fetch here — mirrors the model-resolution
|
|
389
|
+
# optimization above (a plan-time-resolved model is trusted
|
|
390
|
+
# as-is, no freshness re-check). Reuse the plan-time label
|
|
391
|
+
# snapshot `coord.brain.resolve_models()` already stamped
|
|
392
|
+
# onto the proposal (empty if that never ran, e.g. a hand-
|
|
393
|
+
# authored/edited proposal) so providers.labels has SOME
|
|
394
|
+
# signal here without adding a GitHub call this branch has
|
|
395
|
+
# deliberately never made.
|
|
396
|
+
work_issue_labels = list(p.issue_labels)
|
|
397
|
+
else:
|
|
398
|
+
repo_for_model = cfg.repo(p.repo_name)
|
|
399
|
+
if repo_for_model is not None:
|
|
400
|
+
try:
|
|
401
|
+
fresh_issue = github_ops.get_issue(repo_for_model.github, p.issue_number)
|
|
402
|
+
work_issue_labels = [
|
|
403
|
+
lbl.get("name", "") for lbl in (fresh_issue.get("labels") or [])
|
|
404
|
+
]
|
|
405
|
+
except RuntimeError:
|
|
406
|
+
work_issue_labels = [] # fail open — fall back to default below
|
|
407
|
+
label_model, matched_label, shadowed_labels = (
|
|
408
|
+
cfg.models.model_for_labels_with_reason(work_issue_labels)
|
|
409
|
+
)
|
|
410
|
+
# #1889: write the (possibly freshly-fetched) labels back onto
|
|
411
|
+
# the proposal — `dispatch()` (called below) does its OWN
|
|
412
|
+
# providers.labels resolution from `proposal.issue_labels`, and
|
|
413
|
+
# it must agree with what THIS loop just echoed, or the dry-run/
|
|
414
|
+
# echoed reason and the actual wire payload could silently
|
|
415
|
+
# disagree (the exact divergence class #1798 was about, one
|
|
416
|
+
# level up).
|
|
417
|
+
p.issue_labels = work_issue_labels
|
|
418
|
+
# #1707: needed regardless of whether p.model still needs resolving
|
|
419
|
+
# below — also used by the "model:" echo's provider-pin branch.
|
|
420
|
+
# #1889: `issue_labels=work_issue_labels` threads providers.labels
|
|
421
|
+
# through the same precedence chain — see resolve_provider_name's
|
|
422
|
+
# docstring for the full spec > label > repo > default order.
|
|
423
|
+
from coord.providers import resolve_provider_name # noqa: PLC0415
|
|
424
|
+
|
|
425
|
+
repo_for_provider = cfg.repo(p.repo_name)
|
|
426
|
+
effective_provider_name = resolve_provider_name(
|
|
427
|
+
p.provider,
|
|
428
|
+
repo_for_provider.provider if repo_for_provider is not None else None,
|
|
429
|
+
cfg.providers,
|
|
430
|
+
issue_labels=work_issue_labels,
|
|
431
|
+
)
|
|
432
|
+
# #1706 review fix: don't force `models.default` (a Claude model
|
|
433
|
+
# alias) onto a non-claude/claude-pty provider that pins its own
|
|
434
|
+
# `model` in `providers.definitions.<name>.model` — see
|
|
435
|
+
# `resolve_dispatch_model_alias`'s docstring. #1798 review fix:
|
|
436
|
+
# unconditional now (was `if not p.model:`), so a plan-time/already-
|
|
437
|
+
# resolved `label_model` (above) is still checked against the pin
|
|
438
|
+
# instead of bypassing it — see the comment above for why trusting
|
|
439
|
+
# a pre-set `p.model` as an implicit *explicit_model* was the bug.
|
|
440
|
+
from coord.dispatch import resolve_dispatch_model_alias # noqa: PLC0415
|
|
441
|
+
|
|
442
|
+
p.model = resolve_dispatch_model_alias(
|
|
443
|
+
explicit_model=None,
|
|
444
|
+
label_model=label_model,
|
|
445
|
+
config=cfg,
|
|
446
|
+
effective_provider_name=effective_provider_name,
|
|
447
|
+
)
|
|
448
|
+
if p.model:
|
|
449
|
+
click.echo(
|
|
450
|
+
" model: "
|
|
451
|
+
+ describe_model_choice(
|
|
452
|
+
resolved_model=p.model,
|
|
453
|
+
explicit_reason="resolved at plan time" if used_plan_time_snapshot else None,
|
|
454
|
+
matched_label=matched_label,
|
|
455
|
+
shadowed_labels=shadowed_labels,
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
else:
|
|
459
|
+
# #1706/#1798: p.model is None when the effective provider's own
|
|
460
|
+
# `providers.definitions.<name>.model` is pinned and neither an
|
|
461
|
+
# explicit/plan-time model nor a label matched — or when one DID
|
|
462
|
+
# match/was already resolved but lost to the pin. Surface which
|
|
463
|
+
# one lost explicitly, mirroring `_dispatch_headless`'s
|
|
464
|
+
# equivalent branch, instead of silently printing nothing — a
|
|
465
|
+
# namespace-mismatched label/plan-time model (a Claude alias)
|
|
466
|
+
# silently losing to a non-claude provider's pin is exactly the
|
|
467
|
+
# transparency #1798 asked for.
|
|
468
|
+
_pinned = cfg.providers.definitions.get(effective_provider_name)
|
|
469
|
+
if _pinned is not None and _pinned.model:
|
|
470
|
+
_via = f"providers.definitions[{effective_provider_name!r}].model"
|
|
471
|
+
if matched_label:
|
|
472
|
+
click.echo(
|
|
473
|
+
f" model: {_pinned.model} (via {_via}, "
|
|
474
|
+
f"overriding label {matched_label!r})"
|
|
475
|
+
)
|
|
476
|
+
elif used_plan_time_snapshot and label_model:
|
|
477
|
+
click.echo(
|
|
478
|
+
f" model: {_pinned.model} (via {_via}, "
|
|
479
|
+
f"overriding plan-time model {label_model!r})"
|
|
480
|
+
)
|
|
481
|
+
else:
|
|
482
|
+
click.echo(f" model: {_pinned.model} (via {_via})")
|
|
483
|
+
# #1889: mirror the --model reasoning line above — state which link
|
|
484
|
+
# of the spec (Proposal.provider) → providers.labels → repo
|
|
485
|
+
# (Repo.provider) → providers.default chain
|
|
486
|
+
# (coord.providers.resolve_provider_name) won, so a
|
|
487
|
+
# providers.labels match (e.g. `harness:opencode`) is legible at
|
|
488
|
+
# `coord approve`/`--dry-run` time, not just discoverable via
|
|
489
|
+
# coordinator.yml.
|
|
490
|
+
from coord.providers import describe_provider_choice # noqa: PLC0415
|
|
491
|
+
|
|
492
|
+
click.echo(
|
|
493
|
+
" provider: "
|
|
494
|
+
+ describe_provider_choice(
|
|
495
|
+
spec_provider=p.provider,
|
|
496
|
+
repo_provider=repo_for_provider.provider if repo_for_provider is not None else None,
|
|
497
|
+
providers_cfg=cfg.providers,
|
|
498
|
+
issue_labels=work_issue_labels,
|
|
499
|
+
)
|
|
500
|
+
)
|
|
501
|
+
# Resolve required_gates: fall back to config default for proposals
|
|
502
|
+
# that were saved before label-based gate resolution was wired in.
|
|
503
|
+
if not p.required_gates:
|
|
504
|
+
p.required_gates = list(cfg.pipeline.default_gates)
|
|
505
|
+
if dry_run:
|
|
506
|
+
click.echo(" (dry run — not dispatched)")
|
|
507
|
+
continue
|
|
508
|
+
|
|
509
|
+
pull_repos: list[str] = []
|
|
510
|
+
if not skip_freshness:
|
|
511
|
+
agent_repos = machine_repos.get(p.machine_name) or {}
|
|
512
|
+
freshness = fresh.dependency_freshness(p, cfg, agent_repos, github_heads)
|
|
513
|
+
needs = fresh.stale_or_dirty(freshness)
|
|
514
|
+
if needs:
|
|
515
|
+
for f in needs:
|
|
516
|
+
click.echo(
|
|
517
|
+
f" dependency {f.repo_name}: {f.state}"
|
|
518
|
+
+ (f" ({f.error})" if f.error else ""),
|
|
519
|
+
err=True,
|
|
520
|
+
)
|
|
521
|
+
if auto_pull:
|
|
522
|
+
pull_repos = [f.repo_name for f in needs if f.state == fresh.STALE]
|
|
523
|
+
if pull_repos:
|
|
524
|
+
click.echo(f" will pull on agent before worker: {pull_repos}")
|
|
525
|
+
else:
|
|
526
|
+
addendum = fresh.format_briefing_addendum(freshness)
|
|
527
|
+
if addendum:
|
|
528
|
+
p.briefing = (p.briefing or "") + addendum
|
|
529
|
+
|
|
530
|
+
def _on_retry(attempt, max_r, state, reason, wait):
|
|
531
|
+
click.echo(
|
|
532
|
+
f" retry {attempt}/{max_r} after {state} ({reason}), "
|
|
533
|
+
f"waiting {wait:.0f}s...",
|
|
534
|
+
err=True,
|
|
535
|
+
)
|
|
536
|
+
|
|
537
|
+
try:
|
|
538
|
+
response = dispatch_with_retry(
|
|
539
|
+
p, cfg,
|
|
540
|
+
max_retries=cfg.concurrency.max_retries,
|
|
541
|
+
backoff_base=cfg.concurrency.backoff_base,
|
|
542
|
+
pull_repos=pull_repos,
|
|
543
|
+
on_retry=_on_retry,
|
|
544
|
+
)
|
|
545
|
+
except httpx.HTTPError as e:
|
|
546
|
+
state, reason = classify_error(e)
|
|
547
|
+
click.echo(
|
|
548
|
+
f" dispatch failed after {cfg.concurrency.max_retries} retries: "
|
|
549
|
+
f"{p.machine_name} {state} — {reason}",
|
|
550
|
+
err=True,
|
|
551
|
+
)
|
|
552
|
+
continue
|
|
553
|
+
except ValueError as e:
|
|
554
|
+
click.echo(f" dispatch failed: {e}", err=True)
|
|
555
|
+
continue
|
|
556
|
+
assignment_id = response.get("id", "pending")
|
|
557
|
+
click.echo(f" dispatched to agent server (assignment {assignment_id})")
|
|
558
|
+
|
|
559
|
+
repo = cfg.repo(p.repo_name)
|
|
560
|
+
if repo is not None:
|
|
561
|
+
record_dispatched(
|
|
562
|
+
assignment_id=assignment_id,
|
|
563
|
+
proposal=p,
|
|
564
|
+
repo_github=repo.github,
|
|
565
|
+
provider_name=response.get("_provider_name"),
|
|
566
|
+
)
|
|
567
|
+
|
|
568
|
+
try:
|
|
569
|
+
do_not_touch = compute_do_not_touch(p, peers=selected, in_flight=in_flight)
|
|
570
|
+
post_briefing(p, cfg, assignment_id=assignment_id, do_not_touch=do_not_touch)
|
|
571
|
+
click.echo(" briefing posted to GitHub")
|
|
572
|
+
except Exception as e:
|
|
573
|
+
click.echo(f" briefing post failed: {e}", err=True)
|
|
574
|
+
|
|
575
|
+
if not dry_run and p is not selected[-1] and cfg.concurrency.stagger_seconds > 0:
|
|
576
|
+
import time as _time
|
|
577
|
+
click.echo(f" staggering {cfg.concurrency.stagger_seconds:.0f}s before next dispatch...")
|
|
578
|
+
_time.sleep(cfg.concurrency.stagger_seconds)
|
|
579
|
+
|
|
580
|
+
if not dry_run:
|
|
581
|
+
clear_proposals()
|
|
582
|
+
board = read_board()
|
|
583
|
+
board.round_number += 1
|
|
584
|
+
write_board(board)
|
|
585
|
+
click.echo("\nPending proposals cleared. Board saved.")
|
|
586
|
+
|
|
587
|
+
# Mark session start on first dispatch of the session
|
|
588
|
+
from coord.state import load_session, write_session_start
|
|
589
|
+
session = load_session()
|
|
590
|
+
if session is None or session.get("clean_shutdown", True):
|
|
591
|
+
write_session_start()
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
@click.command(help="Directly assign an issue to a machine, bypassing coord plan.")
|
|
595
|
+
@click.argument("machine")
|
|
596
|
+
@click.argument("repo")
|
|
597
|
+
@click.argument("issue", type=int)
|
|
598
|
+
@_CONFIG_OPTION
|
|
599
|
+
@click.option("--briefing", default="", help="Optional briefing text for the worker.")
|
|
600
|
+
@click.option(
|
|
601
|
+
"--briefing-file",
|
|
602
|
+
"briefing_file",
|
|
603
|
+
default=None,
|
|
604
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
605
|
+
help=(
|
|
606
|
+
"#569: read the briefing from a file instead of --briefing. Avoids "
|
|
607
|
+
"shell-quoting a multi-line briefing on the command line (a multi-line "
|
|
608
|
+
"--briefing typed into a PTY shell strands it at `quote>`). Overrides "
|
|
609
|
+
"--briefing when both are given."
|
|
610
|
+
),
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
@click.option(
|
|
615
|
+
"--model",
|
|
616
|
+
default=None,
|
|
617
|
+
help="Claude model tier (haiku, sonnet, opus). Defaults to models.default.",
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
@click.option(
|
|
622
|
+
"--provider",
|
|
623
|
+
"cli_provider",
|
|
624
|
+
default=None,
|
|
625
|
+
help=(
|
|
626
|
+
"#1707: per-assignment worker-backend override — the human escape "
|
|
627
|
+
"hatch for the precedence chain this flag > Repo.provider > "
|
|
628
|
+
"providers.default (coord/providers/__init__.py's "
|
|
629
|
+
"resolve_provider_name). Must name a key in providers.definitions "
|
|
630
|
+
"(coordinator.yml); validated here, before dispatch, and the error "
|
|
631
|
+
"lists the valid names. Lets two concurrent `coord assign`s in the "
|
|
632
|
+
"SAME repo run different backends (e.g. claude on one issue, "
|
|
633
|
+
"opencode on another) since worktrees are already per-assignment. "
|
|
634
|
+
"Not supported with --interactive, which always spawns the "
|
|
635
|
+
"human-attended claude-pty provider directly. A human-attended-only "
|
|
636
|
+
"backend (capabilities().human_attended_only) is still refused for "
|
|
637
|
+
"this unattended path — see guard_unattended_dispatch."
|
|
638
|
+
),
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
@click.option("--dry-run", is_flag=True, help="Show what would be dispatched.")
|
|
643
|
+
@click.option(
|
|
644
|
+
"--driven-by",
|
|
645
|
+
"driven_by",
|
|
646
|
+
default=None,
|
|
647
|
+
hidden=True,
|
|
648
|
+
help=(
|
|
649
|
+
"#1499: internal — stamps durable provenance (`Assignment.driven_by` / "
|
|
650
|
+
"`Proposal.driven_by`) on the dispatched assignment so it stays "
|
|
651
|
+
"distinguishable from a hand `coord assign` in the board/audit log "
|
|
652
|
+
"after the driver process exits. Set by `coord drive`'s work-stage "
|
|
653
|
+
"dispatch (`drive:<repo>#<issue>`); not meant to be typed by hand."
|
|
654
|
+
),
|
|
655
|
+
)
|
|
656
|
+
@click.option(
|
|
657
|
+
"--plan-only",
|
|
658
|
+
is_flag=True,
|
|
659
|
+
help=(
|
|
660
|
+
"Dispatch a read-only planning worker. The worker reads the codebase "
|
|
661
|
+
"and outputs a structured plan (FILES_READ, FILES_MODIFY, APPROACH, "
|
|
662
|
+
"RISKS, ESTIMATE) without writing code or modifying files. "
|
|
663
|
+
"No worktree or feature branch is created."
|
|
664
|
+
),
|
|
665
|
+
)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
@click.option(
|
|
669
|
+
"--no-plan",
|
|
670
|
+
is_flag=True,
|
|
671
|
+
help=(
|
|
672
|
+
"Force a direct work dispatch even when dispatch.require_plan is true "
|
|
673
|
+
"in coordinator.yml. Has no effect when require_plan is false."
|
|
674
|
+
),
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
@click.option(
|
|
679
|
+
"--force",
|
|
680
|
+
is_flag=True,
|
|
681
|
+
help=(
|
|
682
|
+
"Bypass claim detection (retry after infra failures); also overrides "
|
|
683
|
+
"the pipeline.max_review_iterations cap on --fix-of so an intractable "
|
|
684
|
+
"story can keep iterating."
|
|
685
|
+
),
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
@click.option(
|
|
690
|
+
"--no-pull",
|
|
691
|
+
is_flag=True,
|
|
692
|
+
help=(
|
|
693
|
+
"Skip the auto-pull of stale dependency repos on the agent. "
|
|
694
|
+
"The briefing still carries a 'pull these before building' "
|
|
695
|
+
"addendum so the worker is aware (#267)."
|
|
696
|
+
),
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
@click.option(
|
|
701
|
+
"--skip-freshness",
|
|
702
|
+
is_flag=True,
|
|
703
|
+
help=(
|
|
704
|
+
"Skip the dependency freshness check entirely — faster, no "
|
|
705
|
+
"network for GH HEADs. Matches `coord approve --skip-freshness` (#267)."
|
|
706
|
+
),
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
@click.option(
|
|
711
|
+
"--interactive",
|
|
712
|
+
is_flag=True,
|
|
713
|
+
help=(
|
|
714
|
+
"HUMAN-ATTENDED launcher (#437): start interactive `claude` "
|
|
715
|
+
"locally on THIS terminal with the briefing PRE-FILLED in the "
|
|
716
|
+
"input box. You press Enter to submit and Ctrl-C / `/exit` to "
|
|
717
|
+
"end the session. Used for the subscription-billed path; the "
|
|
718
|
+
"coordinator does NOT watch the TTY, does NOT auto-submit, does "
|
|
719
|
+
"NOT advance the pipeline from session output. This bypasses "
|
|
720
|
+
"the agent HTTP server and runs `claude` as a child of your "
|
|
721
|
+
"shell."
|
|
722
|
+
),
|
|
723
|
+
)
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
@click.option(
|
|
727
|
+
"--review-of",
|
|
728
|
+
"review_of",
|
|
729
|
+
default=None,
|
|
730
|
+
help=(
|
|
731
|
+
"Launch a human-attended interactive REVIEW of completed work "
|
|
732
|
+
"assignment <ID> (the work id from `coord status`). Implies a "
|
|
733
|
+
"review-shaped dispatch: type=review linked to the work (so the merge "
|
|
734
|
+
"gate's has_approved_review can find the verdict), the diff-only "
|
|
735
|
+
"review briefing, and NO isolated worktree (read-only in the live "
|
|
736
|
+
"checkout). Report your verdict with `coord report-result --verdict "
|
|
737
|
+
"approve|request-changes`. Requires --interactive; local-only for now "
|
|
738
|
+
"(remote review is Track B / #486)."
|
|
739
|
+
),
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
@click.option(
|
|
744
|
+
"--fix-of",
|
|
745
|
+
"fix_of",
|
|
746
|
+
default=None,
|
|
747
|
+
help=(
|
|
748
|
+
"Leg 3 (#517): launch a human-attended interactive FIX for a review "
|
|
749
|
+
"assignment <ID> whose verdict was request-changes. Continues on the "
|
|
750
|
+
"reviewed work's EXISTING branch (so the same PR is updated, not a new "
|
|
751
|
+
"orphan branch), is briefed with the reviewer's findings, and bumps "
|
|
752
|
+
"review_iteration so the next review can scope to just the fix delta. "
|
|
753
|
+
"ALSO accepts a WORK assignment id whose test gate FAILED (#581): the "
|
|
754
|
+
"fix is then briefed with the recorded test-failure story. "
|
|
755
|
+
"Requires --interactive; local-only for now (remote is Track B / #486)."
|
|
756
|
+
),
|
|
757
|
+
)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
@click.option(
|
|
761
|
+
"--troubleshoot",
|
|
762
|
+
"troubleshoot",
|
|
763
|
+
is_flag=True,
|
|
764
|
+
default=False,
|
|
765
|
+
help=(
|
|
766
|
+
"#569: launch a human-attended READ-ONLY diagnostic session for a "
|
|
767
|
+
"stalled item. Runs in the LIVE checkout with NO claim and NO worktree "
|
|
768
|
+
"(so it never conflicts with the item's own in-progress claim), "
|
|
769
|
+
"type=troubleshoot, briefed from --briefing/--briefing-file. Requires "
|
|
770
|
+
"--interactive; local-only."
|
|
771
|
+
),
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
@click.option(
|
|
776
|
+
"--chat",
|
|
777
|
+
"chat",
|
|
778
|
+
is_flag=True,
|
|
779
|
+
default=False,
|
|
780
|
+
help=(
|
|
781
|
+
"#628: launch a human-attended 'Chat about issue' session — a live "
|
|
782
|
+
"interactive `claude` seeded with everything we know about the issue "
|
|
783
|
+
"(body, comments, board state). Ask open questions ('is this still "
|
|
784
|
+
"needed?', 'what milestone?', 'sketch the UX'), diagnose a stall, edit "
|
|
785
|
+
"the issue (via `coord issue edit`), and send it to Pending (`coord "
|
|
786
|
+
"ready`). Runs in the LIVE checkout with NO claim and NO worktree; "
|
|
787
|
+
"type=chat. Mutates the ISSUE through coord (never raw gh) and never the "
|
|
788
|
+
"code/checkout. Requires --interactive; local-only."
|
|
789
|
+
),
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
@click.option(
|
|
794
|
+
"--rework-of",
|
|
795
|
+
"rework_of",
|
|
796
|
+
default=None,
|
|
797
|
+
help=(
|
|
798
|
+
"#563: launch a human-attended interactive REWORK of an existing branch. "
|
|
799
|
+
"Accepts a work assignment ID (resolves its branch) or a branch name "
|
|
800
|
+
"directly. Continues on the EXISTING branch (no orphan branch), seeds "
|
|
801
|
+
"the session with the operator-supplied --briefing verbatim, and bumps "
|
|
802
|
+
"review_iteration so the reworked branch is re-reviewed before merge. "
|
|
803
|
+
"Requires --interactive and --briefing; works local and remote (same "
|
|
804
|
+
"worktree + push-back as --fix-of)."
|
|
805
|
+
),
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
@click.option(
|
|
810
|
+
"--smoke-of",
|
|
811
|
+
"smoke_of",
|
|
812
|
+
default=None,
|
|
813
|
+
help=(
|
|
814
|
+
"Leg 3c / A3 (#517, #350, #581): launch a human-attended interactive "
|
|
815
|
+
"TESTING agent for completed work assignment <ID>. The agent lists the "
|
|
816
|
+
"smoke tests, pulls the build artifact, guides you through running it, "
|
|
817
|
+
"interviews you about what you saw, and records the verdict with "
|
|
818
|
+
"`coord test --passed|--fail`. Read-only tools, NO worktree (runs in the "
|
|
819
|
+
"live checkout). Requires --interactive; works local and remote (#1010, "
|
|
820
|
+
"same ssh+tmux live-checkout shape as --review-of)."
|
|
821
|
+
),
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
@click.option(
|
|
826
|
+
"--merge-of",
|
|
827
|
+
"merge_of",
|
|
828
|
+
default=None,
|
|
829
|
+
help=(
|
|
830
|
+
"Leg 3c (#517, #306): launch a human-attended interactive MERGE agent "
|
|
831
|
+
"for completed+approved work assignment <ID>. Continues the work branch "
|
|
832
|
+
"in a worktree, fetches + rebases it onto the repo's default branch "
|
|
833
|
+
"(proactive rebase, #306), resolves mechanical conflicts, runs the "
|
|
834
|
+
"tests, pushes --force-with-lease, then guides you to merge. Requires "
|
|
835
|
+
"--interactive; works local and remote (#1007, same ssh+tmux worktree "
|
|
836
|
+
"as --fix-of/--rework-of)."
|
|
837
|
+
),
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
@click.option(
|
|
842
|
+
"--audit-of",
|
|
843
|
+
"audit_of",
|
|
844
|
+
default=None,
|
|
845
|
+
help=(
|
|
846
|
+
"Milestone Outcome Audit Phase 1 (#885): launch a human-attended "
|
|
847
|
+
"READ-ONLY analyst for the milestone tracking epic <EPIC_ISSUE>. Reads "
|
|
848
|
+
"the epic body's goals/acceptance/plan checklist, enumerates the "
|
|
849
|
+
"milestone's issue states, measures each goal against the code with "
|
|
850
|
+
"shell tools (never trusting ticket state or self-report), and relays "
|
|
851
|
+
"a scorecard verdict via `coord report-result` — posted as a comment "
|
|
852
|
+
"on the epic. Runs in the LIVE checkout with NO claim and NO worktree "
|
|
853
|
+
"(read-only tools only). Requires --interactive; local-only for now."
|
|
854
|
+
),
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
@click.option(
|
|
859
|
+
"--milestone-chat-of",
|
|
860
|
+
"milestone_chat_of",
|
|
861
|
+
default=None,
|
|
862
|
+
help=(
|
|
863
|
+
"#1029: launch a human-attended interactive MILESTONE CHAT for the "
|
|
864
|
+
"milestone's tracking issue <TRACKING_ISSUE> — a genuine tmux-attached "
|
|
865
|
+
"`claude` session, replacing the old headless `claude -p` / SSE-overlay "
|
|
866
|
+
"mechanism. Resolves the milestone/tracking-issue context the same way "
|
|
867
|
+
"the headless `coord milestone chat` CLI path does (shared via "
|
|
868
|
+
"`coord.milestone_chat.resolve_milestone_chat_briefing`), and may write "
|
|
869
|
+
"a `## Work order` block / edit the milestone / add a sub-issue via "
|
|
870
|
+
"`coord milestone ...` once you confirm in conversation — never raw "
|
|
871
|
+
"`gh`. Runs in the LIVE checkout with NO claim and NO worktree; "
|
|
872
|
+
"type=milestone-chat. Requires --interactive; local-only for now (the "
|
|
873
|
+
"headless path remains available for remote machines via `coord "
|
|
874
|
+
"milestone chat`)."
|
|
875
|
+
),
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
|
|
879
|
+
@click.option(
|
|
880
|
+
"--add-child",
|
|
881
|
+
"chat_add_child",
|
|
882
|
+
default=None,
|
|
883
|
+
help=(
|
|
884
|
+
"#1029: paired with --milestone-chat-of — seeds an 'Add sub-issue' "
|
|
885
|
+
"milestone chat for candidate child issue <ISSUE> (mirrors `coord "
|
|
886
|
+
"milestone chat --add-child`). Ignored without --milestone-chat-of."
|
|
887
|
+
),
|
|
888
|
+
)
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def assign(
|
|
892
|
+
machine: str,
|
|
893
|
+
repo: str,
|
|
894
|
+
issue: int,
|
|
895
|
+
config_path: Path,
|
|
896
|
+
briefing: str,
|
|
897
|
+
model: str | None,
|
|
898
|
+
# #1707: named `cli_provider`, not `provider` — the --interactive branch
|
|
899
|
+
# below reuses the bare name `provider` for the ClaudePtyProvider
|
|
900
|
+
# *instance* it always spawns (see `_build_interactive_launch_setup`);
|
|
901
|
+
# colliding names here would shadow one with the other mid-function.
|
|
902
|
+
cli_provider: str | None,
|
|
903
|
+
dry_run: bool,
|
|
904
|
+
driven_by: str | None,
|
|
905
|
+
plan_only: bool,
|
|
906
|
+
no_plan: bool,
|
|
907
|
+
force: bool,
|
|
908
|
+
no_pull: bool,
|
|
909
|
+
skip_freshness: bool,
|
|
910
|
+
interactive: bool,
|
|
911
|
+
review_of: str | None,
|
|
912
|
+
fix_of: str | None,
|
|
913
|
+
briefing_file: str | None,
|
|
914
|
+
troubleshoot: bool,
|
|
915
|
+
chat: bool,
|
|
916
|
+
rework_of: str | None,
|
|
917
|
+
smoke_of: str | None,
|
|
918
|
+
merge_of: str | None,
|
|
919
|
+
audit_of: str | None,
|
|
920
|
+
milestone_chat_of: str | None,
|
|
921
|
+
chat_add_child: str | None,
|
|
922
|
+
) -> None:
|
|
923
|
+
cfg = _load_config(config_path)
|
|
924
|
+
|
|
925
|
+
# Validate machine exists in config
|
|
926
|
+
machine_obj = next((m for m in cfg.machines if m.name == machine), None)
|
|
927
|
+
if machine_obj is None:
|
|
928
|
+
click.echo(
|
|
929
|
+
f"error: machine {machine!r} not in coordinator.yml "
|
|
930
|
+
f"(have: {[m.name for m in cfg.machines]})",
|
|
931
|
+
err=True,
|
|
932
|
+
)
|
|
933
|
+
sys.exit(2)
|
|
934
|
+
|
|
935
|
+
# Validate repo exists in config
|
|
936
|
+
repo_cfg = cfg.repo(repo)
|
|
937
|
+
if repo_cfg is None:
|
|
938
|
+
click.echo(
|
|
939
|
+
f"error: repo {repo!r} not in coordinator.yml "
|
|
940
|
+
f"(have: {[r.name for r in cfg.repos]})",
|
|
941
|
+
err=True,
|
|
942
|
+
)
|
|
943
|
+
sys.exit(2)
|
|
944
|
+
|
|
945
|
+
# Validate machine can work on this repo
|
|
946
|
+
if not machine_obj.can_work_on(repo):
|
|
947
|
+
click.echo(
|
|
948
|
+
f"error: machine {machine!r} does not list repo {repo!r} "
|
|
949
|
+
f"(has: {machine_obj.repos})",
|
|
950
|
+
err=True,
|
|
951
|
+
)
|
|
952
|
+
sys.exit(2)
|
|
953
|
+
|
|
954
|
+
# Refuse direct assignment to a paused machine — `coord pause` exists
|
|
955
|
+
# so the user can explicitly steer work away. If they meant to dispatch
|
|
956
|
+
# anyway they should `coord unpause` first. #1862: also refuses inside
|
|
957
|
+
# a machine's `quiet_hours` window — `coord unpause` is the fix there
|
|
958
|
+
# too (it grants a quiet-hours override) — but the message names the
|
|
959
|
+
# window and its end time rather than reusing the generic hand-pause
|
|
960
|
+
# wording, so the operator isn't left guessing whether this is a
|
|
961
|
+
# deliberate `coord pause` or just an overnight window about to lift
|
|
962
|
+
# on its own (same distinguishability #1862 requires of `coord status`
|
|
963
|
+
# and the TUI sidebar, applied to this refusal too).
|
|
964
|
+
from coord.machine_pause import describe_pause_state as _describe_pause_state
|
|
965
|
+
from coord.machine_pause import paused_set as _paused_set
|
|
966
|
+
paused = _paused_set(cfg.machines)
|
|
967
|
+
if machine in paused:
|
|
968
|
+
state = _describe_pause_state(machine_obj, paused)
|
|
969
|
+
if state is not None and state.kind == "quiet":
|
|
970
|
+
click.echo(
|
|
971
|
+
f"error: machine {machine!r} is in quiet hours {state.detail}; "
|
|
972
|
+
f"run `coord unpause {machine}` to override for the rest of the window",
|
|
973
|
+
err=True,
|
|
974
|
+
)
|
|
975
|
+
else:
|
|
976
|
+
click.echo(
|
|
977
|
+
f"error: machine {machine!r} is paused; run `coord unpause {machine}` first",
|
|
978
|
+
err=True,
|
|
979
|
+
)
|
|
980
|
+
sys.exit(2)
|
|
981
|
+
|
|
982
|
+
# #1707: validate --provider at the CLI, before any network call or
|
|
983
|
+
# dispatch — an unknown name must never reach dispatch() (where it would
|
|
984
|
+
# currently just fall through to the agent's own unknown-provider
|
|
985
|
+
# handling per guard_unattended_dispatch's docstring). "claude" is always
|
|
986
|
+
# valid even on a bare config because ProvidersConfig.__post_init__
|
|
987
|
+
# always materialises the implicit entry, so cfg.providers.definitions
|
|
988
|
+
# is the complete, authoritative set of valid names.
|
|
989
|
+
if cli_provider is not None and cli_provider not in cfg.providers.definitions:
|
|
990
|
+
click.echo(
|
|
991
|
+
f"error: provider {cli_provider!r} not in providers.definitions "
|
|
992
|
+
f"(have: {sorted(cfg.providers.definitions)})",
|
|
993
|
+
err=True,
|
|
994
|
+
)
|
|
995
|
+
sys.exit(2)
|
|
996
|
+
|
|
997
|
+
# --provider only has meaning on the headless (config-driven provider
|
|
998
|
+
# registry) dispatch path below. --interactive always spawns
|
|
999
|
+
# ClaudePtyProvider() directly (_build_interactive_launch_setup) — it
|
|
1000
|
+
# never looks a name up in providers.definitions — so accepting
|
|
1001
|
+
# --provider there would either silently do nothing or read as though it
|
|
1002
|
+
# could steer an interactive session onto a different backend. Refuse
|
|
1003
|
+
# instead of guessing.
|
|
1004
|
+
if cli_provider is not None and interactive:
|
|
1005
|
+
click.echo(
|
|
1006
|
+
"error: --provider is not supported with --interactive "
|
|
1007
|
+
"(interactive sessions always use the human-attended claude-pty "
|
|
1008
|
+
"provider directly; there is no name to select)",
|
|
1009
|
+
err=True,
|
|
1010
|
+
)
|
|
1011
|
+
sys.exit(2)
|
|
1012
|
+
|
|
1013
|
+
# #2086: fails fast, before the (harmless, read-only) issue-title fetch
|
|
1014
|
+
# below — see _require_interactive_tty's docstring for the full
|
|
1015
|
+
# rationale. This is the SAME check _build_interactive_launch_setup
|
|
1016
|
+
# enforces further down for every --interactive flavour (here and in
|
|
1017
|
+
# coord.test_author.dispatch_test_author_interactive), called again
|
|
1018
|
+
# here only so a no-TTY dispatch fails before that network round-trip.
|
|
1019
|
+
if interactive:
|
|
1020
|
+
try:
|
|
1021
|
+
_require_interactive_tty(dry_run)
|
|
1022
|
+
except RuntimeError as e:
|
|
1023
|
+
click.echo(f"error: {e}", err=True)
|
|
1024
|
+
sys.exit(2)
|
|
1025
|
+
|
|
1026
|
+
# Fetch the issue title from GitHub
|
|
1027
|
+
try:
|
|
1028
|
+
issue_data = github_ops.get_issue(repo_cfg.github, issue)
|
|
1029
|
+
except RuntimeError as e:
|
|
1030
|
+
click.echo(f"error: could not fetch issue #{issue}: {e}", err=True)
|
|
1031
|
+
sys.exit(1)
|
|
1032
|
+
issue_title = issue_data.get("title", f"Issue #{issue}")
|
|
1033
|
+
|
|
1034
|
+
# --briefing-file (#569): read the briefing from a file; this avoids having
|
|
1035
|
+
# to shell-quote a multi-line briefing on the command line (a multi-line
|
|
1036
|
+
# --briefing typed into a PTY shell strands it at `quote>`). Overrides
|
|
1037
|
+
# --briefing when both are given.
|
|
1038
|
+
if briefing_file:
|
|
1039
|
+
briefing = Path(briefing_file).read_text(encoding="utf-8")
|
|
1040
|
+
|
|
1041
|
+
# Auto-generate briefing from issue body when none provided.
|
|
1042
|
+
if not briefing:
|
|
1043
|
+
issue_body = issue_data.get("body", "")
|
|
1044
|
+
if issue_body:
|
|
1045
|
+
briefing = f"Issue #{issue}: {issue_title}\n\n{issue_body}"
|
|
1046
|
+
|
|
1047
|
+
# A1 (interactive-mode migration): --review-of is a flavour of the
|
|
1048
|
+
# human-attended interactive launcher, so it requires --interactive.
|
|
1049
|
+
if review_of is not None and not interactive:
|
|
1050
|
+
click.echo("error: --review-of requires --interactive", err=True)
|
|
1051
|
+
sys.exit(2)
|
|
1052
|
+
|
|
1053
|
+
# Leg 3 (#517): --fix-of is a sibling flavour — a human-attended fix of a
|
|
1054
|
+
# request-changes review. Same interactive requirement; mutually exclusive
|
|
1055
|
+
# with --review-of (a dispatch is one shape or the other).
|
|
1056
|
+
if fix_of is not None and not interactive:
|
|
1057
|
+
click.echo("error: --fix-of requires --interactive", err=True)
|
|
1058
|
+
sys.exit(2)
|
|
1059
|
+
if fix_of is not None and review_of is not None:
|
|
1060
|
+
click.echo("error: --fix-of and --review-of are mutually exclusive", err=True)
|
|
1061
|
+
sys.exit(2)
|
|
1062
|
+
|
|
1063
|
+
# #569: --troubleshoot is a read-only diagnostic flavour — requires
|
|
1064
|
+
# --interactive.
|
|
1065
|
+
if troubleshoot and not interactive:
|
|
1066
|
+
click.echo("error: --troubleshoot requires --interactive", err=True)
|
|
1067
|
+
sys.exit(2)
|
|
1068
|
+
|
|
1069
|
+
# #628: --chat (Chat about issue) — human-attended, requires --interactive.
|
|
1070
|
+
if chat and not interactive:
|
|
1071
|
+
click.echo("error: --chat requires --interactive", err=True)
|
|
1072
|
+
sys.exit(2)
|
|
1073
|
+
|
|
1074
|
+
# #563: --rework-of — requires --interactive, and --briefing so the operator
|
|
1075
|
+
# always supplies explicit rework instructions.
|
|
1076
|
+
if rework_of is not None and not interactive:
|
|
1077
|
+
click.echo("error: --rework-of requires --interactive", err=True)
|
|
1078
|
+
sys.exit(2)
|
|
1079
|
+
if rework_of is not None and not (briefing or "").strip():
|
|
1080
|
+
click.echo(
|
|
1081
|
+
"error: --rework-of requires --briefing (supply the rework instructions).",
|
|
1082
|
+
err=True,
|
|
1083
|
+
)
|
|
1084
|
+
sys.exit(2)
|
|
1085
|
+
|
|
1086
|
+
# Leg 3c (#517): --smoke-of (interactive testing agent) and --merge-of
|
|
1087
|
+
# (interactive merge agent) — each requires --interactive.
|
|
1088
|
+
if smoke_of is not None and not interactive:
|
|
1089
|
+
click.echo("error: --smoke-of requires --interactive", err=True)
|
|
1090
|
+
sys.exit(2)
|
|
1091
|
+
if merge_of is not None and not interactive:
|
|
1092
|
+
click.echo("error: --merge-of requires --interactive", err=True)
|
|
1093
|
+
sys.exit(2)
|
|
1094
|
+
|
|
1095
|
+
# #885: --audit-of (interactive milestone-outcome analyst) also requires
|
|
1096
|
+
# --interactive — same shape as smoke/merge above.
|
|
1097
|
+
if audit_of is not None and not interactive:
|
|
1098
|
+
click.echo("error: --audit-of requires --interactive", err=True)
|
|
1099
|
+
sys.exit(2)
|
|
1100
|
+
|
|
1101
|
+
# #1029: --milestone-chat-of (interactive milestone chat) — same shape.
|
|
1102
|
+
if milestone_chat_of is not None and not interactive:
|
|
1103
|
+
click.echo("error: --milestone-chat-of requires --interactive", err=True)
|
|
1104
|
+
sys.exit(2)
|
|
1105
|
+
if chat_add_child is not None and milestone_chat_of is None:
|
|
1106
|
+
click.echo("error: --add-child requires --milestone-chat-of", err=True)
|
|
1107
|
+
sys.exit(2)
|
|
1108
|
+
|
|
1109
|
+
# All interactive flavours are mutually exclusive — a dispatch is exactly
|
|
1110
|
+
# one shape (review / fix / troubleshoot / rework / smoke / merge / audit /
|
|
1111
|
+
# milestone-chat).
|
|
1112
|
+
_interactive_flavours = [
|
|
1113
|
+
("--review-of", review_of is not None),
|
|
1114
|
+
("--fix-of", fix_of is not None),
|
|
1115
|
+
("--troubleshoot", troubleshoot),
|
|
1116
|
+
("--chat", chat),
|
|
1117
|
+
("--rework-of", rework_of is not None),
|
|
1118
|
+
("--smoke-of", smoke_of is not None),
|
|
1119
|
+
("--merge-of", merge_of is not None),
|
|
1120
|
+
("--audit-of", audit_of is not None),
|
|
1121
|
+
("--milestone-chat-of", milestone_chat_of is not None),
|
|
1122
|
+
]
|
|
1123
|
+
_set_flavours = [name for name, on in _interactive_flavours if on]
|
|
1124
|
+
if len(_set_flavours) > 1:
|
|
1125
|
+
click.echo(
|
|
1126
|
+
f"error: {', '.join(_set_flavours)} are mutually exclusive "
|
|
1127
|
+
"(a dispatch is exactly one shape).",
|
|
1128
|
+
err=True,
|
|
1129
|
+
)
|
|
1130
|
+
sys.exit(2)
|
|
1131
|
+
|
|
1132
|
+
# #437: HUMAN-ATTENDED branch. When --interactive is set, we run
|
|
1133
|
+
# interactive `claude` as a child of THIS shell with the briefing
|
|
1134
|
+
# PRE-FILLED in the input box. No HTTP agent, no Proposal, no
|
|
1135
|
+
# GitHub posting, no board update — the operator drives the session
|
|
1136
|
+
# and closes it manually. This is the subscription-billed escape
|
|
1137
|
+
# hatch from Anthropic ToS §3.7 metering. Resolving
|
|
1138
|
+
# ClaudePtyProvider here AND asserting its capabilities are flagged
|
|
1139
|
+
# human_attended_only is the structural guarantee that this path is
|
|
1140
|
+
# the only one that can launch it; the unattended dispatch sites
|
|
1141
|
+
# (dispatch/review/reconcile) refuse the same capability.
|
|
1142
|
+
#
|
|
1143
|
+
# assign() is a thin dispatcher (#746): the validation above is the
|
|
1144
|
+
# only logic that lives here. Every dispatch SHAPE (review / fix /
|
|
1145
|
+
# troubleshoot / chat / rework / smoke / merge / plain-interactive /
|
|
1146
|
+
# headless) is a self-contained _dispatch_* function below — each one
|
|
1147
|
+
# mirrors exactly what used to be a top-level if/elif branch in this
|
|
1148
|
+
# function, taking the already-validated values as parameters.
|
|
1149
|
+
if interactive:
|
|
1150
|
+
setup = _build_interactive_launch_setup(
|
|
1151
|
+
machine=machine, repo=repo, issue=issue, machine_obj=machine_obj,
|
|
1152
|
+
dry_run=dry_run,
|
|
1153
|
+
)
|
|
1154
|
+
provider = setup.provider
|
|
1155
|
+
_is_local = setup.is_local
|
|
1156
|
+
_svc = setup.svc
|
|
1157
|
+
_interactive_board = setup.interactive_board
|
|
1158
|
+
_issue_ctx = setup.issue_ctx
|
|
1159
|
+
_ctx_write_hint = setup.ctx_write_hint
|
|
1160
|
+
|
|
1161
|
+
if review_of is not None:
|
|
1162
|
+
_dispatch_review_of(
|
|
1163
|
+
machine=machine, repo=repo, issue=issue, briefing=briefing,
|
|
1164
|
+
model=model, dry_run=dry_run, review_of=review_of,
|
|
1165
|
+
cfg=cfg, machine_obj=machine_obj, repo_cfg=repo_cfg,
|
|
1166
|
+
issue_data=issue_data, issue_title=issue_title,
|
|
1167
|
+
provider=provider, _is_local=_is_local, _svc=_svc,
|
|
1168
|
+
_interactive_board=_interactive_board, _issue_ctx=_issue_ctx,
|
|
1169
|
+
)
|
|
1170
|
+
return
|
|
1171
|
+
if smoke_of is not None:
|
|
1172
|
+
_dispatch_smoke_of(
|
|
1173
|
+
machine=machine, repo=repo, issue=issue, briefing=briefing,
|
|
1174
|
+
model=model, dry_run=dry_run, smoke_of=smoke_of,
|
|
1175
|
+
cfg=cfg, machine_obj=machine_obj, repo_cfg=repo_cfg,
|
|
1176
|
+
issue_data=issue_data, issue_title=issue_title,
|
|
1177
|
+
provider=provider, _is_local=_is_local, _svc=_svc,
|
|
1178
|
+
_interactive_board=_interactive_board, _issue_ctx=_issue_ctx,
|
|
1179
|
+
)
|
|
1180
|
+
return
|
|
1181
|
+
if troubleshoot:
|
|
1182
|
+
_dispatch_troubleshoot(
|
|
1183
|
+
machine=machine, repo=repo, issue=issue, briefing=briefing,
|
|
1184
|
+
briefing_file=briefing_file, model=model, dry_run=dry_run,
|
|
1185
|
+
cfg=cfg, machine_obj=machine_obj, repo_cfg=repo_cfg,
|
|
1186
|
+
issue_title=issue_title, provider=provider, _is_local=_is_local,
|
|
1187
|
+
_issue_ctx=_issue_ctx, _ctx_write_hint=_ctx_write_hint,
|
|
1188
|
+
)
|
|
1189
|
+
return
|
|
1190
|
+
if chat:
|
|
1191
|
+
_dispatch_chat(
|
|
1192
|
+
machine=machine, repo=repo, issue=issue, briefing=briefing,
|
|
1193
|
+
briefing_file=briefing_file, model=model, dry_run=dry_run,
|
|
1194
|
+
cfg=cfg, machine_obj=machine_obj, repo_cfg=repo_cfg,
|
|
1195
|
+
issue_title=issue_title, provider=provider, _is_local=_is_local,
|
|
1196
|
+
_issue_ctx=_issue_ctx, _ctx_write_hint=_ctx_write_hint,
|
|
1197
|
+
)
|
|
1198
|
+
return
|
|
1199
|
+
if fix_of is not None:
|
|
1200
|
+
_dispatch_fix_of(
|
|
1201
|
+
machine=machine, repo=repo, issue=issue, briefing=briefing,
|
|
1202
|
+
model=model, dry_run=dry_run, force=force, fix_of=fix_of,
|
|
1203
|
+
cfg=cfg, machine_obj=machine_obj, repo_cfg=repo_cfg,
|
|
1204
|
+
issue_title=issue_title, provider=provider, _is_local=_is_local,
|
|
1205
|
+
_svc=_svc, _interactive_board=_interactive_board,
|
|
1206
|
+
_issue_ctx=_issue_ctx, _ctx_write_hint=_ctx_write_hint,
|
|
1207
|
+
)
|
|
1208
|
+
return
|
|
1209
|
+
if rework_of is not None:
|
|
1210
|
+
_dispatch_rework_of(
|
|
1211
|
+
machine=machine, repo=repo, issue=issue, briefing=briefing,
|
|
1212
|
+
model=model, dry_run=dry_run, force=force, rework_of=rework_of,
|
|
1213
|
+
cfg=cfg, machine_obj=machine_obj, repo_cfg=repo_cfg,
|
|
1214
|
+
issue_title=issue_title, provider=provider, _is_local=_is_local,
|
|
1215
|
+
_svc=_svc, _interactive_board=_interactive_board, _issue_ctx=_issue_ctx,
|
|
1216
|
+
)
|
|
1217
|
+
return
|
|
1218
|
+
if merge_of is not None:
|
|
1219
|
+
_dispatch_merge_of(
|
|
1220
|
+
machine=machine, repo=repo, issue=issue, briefing=briefing,
|
|
1221
|
+
model=model, dry_run=dry_run, force=force, merge_of=merge_of,
|
|
1222
|
+
cfg=cfg, machine_obj=machine_obj, repo_cfg=repo_cfg,
|
|
1223
|
+
issue_title=issue_title, provider=provider, _is_local=_is_local,
|
|
1224
|
+
_svc=_svc, _interactive_board=_interactive_board, _issue_ctx=_issue_ctx,
|
|
1225
|
+
)
|
|
1226
|
+
return
|
|
1227
|
+
if audit_of is not None:
|
|
1228
|
+
_dispatch_audit_of(
|
|
1229
|
+
machine=machine, repo=repo, model=model,
|
|
1230
|
+
dry_run=dry_run, audit_of=audit_of,
|
|
1231
|
+
cfg=cfg, machine_obj=machine_obj, repo_cfg=repo_cfg,
|
|
1232
|
+
provider=provider, _is_local=_is_local,
|
|
1233
|
+
_issue_ctx=_issue_ctx,
|
|
1234
|
+
)
|
|
1235
|
+
return
|
|
1236
|
+
if milestone_chat_of is not None:
|
|
1237
|
+
_dispatch_milestone_chat_of(
|
|
1238
|
+
machine=machine, repo=repo, model=model,
|
|
1239
|
+
dry_run=dry_run, milestone_chat_of=milestone_chat_of,
|
|
1240
|
+
add_child=chat_add_child,
|
|
1241
|
+
cfg=cfg, machine_obj=machine_obj, repo_cfg=repo_cfg,
|
|
1242
|
+
provider=provider, _is_local=_is_local,
|
|
1243
|
+
)
|
|
1244
|
+
return
|
|
1245
|
+
|
|
1246
|
+
_dispatch_interactive_work(
|
|
1247
|
+
machine=machine, repo=repo, issue=issue, briefing=briefing,
|
|
1248
|
+
model=model, dry_run=dry_run, plan_only=plan_only, no_plan=no_plan,
|
|
1249
|
+
force=force, cfg=cfg, machine_obj=machine_obj, repo_cfg=repo_cfg,
|
|
1250
|
+
issue_title=issue_title, provider=provider, _is_local=_is_local,
|
|
1251
|
+
_issue_ctx=_issue_ctx, _ctx_write_hint=_ctx_write_hint,
|
|
1252
|
+
)
|
|
1253
|
+
return
|
|
1254
|
+
|
|
1255
|
+
_dispatch_headless(
|
|
1256
|
+
machine=machine, repo=repo, issue=issue, briefing=briefing,
|
|
1257
|
+
model=model, provider=cli_provider, dry_run=dry_run,
|
|
1258
|
+
plan_only=plan_only, no_plan=no_plan,
|
|
1259
|
+
force=force, no_pull=no_pull, skip_freshness=skip_freshness,
|
|
1260
|
+
cfg=cfg, machine_obj=machine_obj, repo_cfg=repo_cfg,
|
|
1261
|
+
issue_data=issue_data, issue_title=issue_title, driven_by=driven_by,
|
|
1262
|
+
)
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
@dataclasses.dataclass
|
|
1266
|
+
class _InteractiveLaunchSetup:
|
|
1267
|
+
"""Shared one-time setup for every `coord assign --interactive` flavour
|
|
1268
|
+
(review/fix/troubleshoot/chat/rework/smoke/merge/plain). Built once per
|
|
1269
|
+
dispatch by :func:`_build_interactive_launch_setup` and threaded into
|
|
1270
|
+
whichever ``_dispatch_*`` function the flavour flags select.
|
|
1271
|
+
"""
|
|
1272
|
+
|
|
1273
|
+
provider: object
|
|
1274
|
+
is_local: bool
|
|
1275
|
+
svc: object
|
|
1276
|
+
interactive_board: object
|
|
1277
|
+
issue_ctx: str
|
|
1278
|
+
ctx_write_hint: str
|
|
1279
|
+
|
|
1280
|
+
|
|
1281
|
+
def _build_interactive_launch_setup(
|
|
1282
|
+
*,
|
|
1283
|
+
machine: str,
|
|
1284
|
+
repo: str,
|
|
1285
|
+
issue: int,
|
|
1286
|
+
machine_obj: object,
|
|
1287
|
+
dry_run: bool,
|
|
1288
|
+
) -> _InteractiveLaunchSetup:
|
|
1289
|
+
# #2086: the authoritative gate — see _require_interactive_tty's
|
|
1290
|
+
# docstring. Every --interactive flavour in BOTH callers (assign()'s
|
|
1291
|
+
# _dispatch_*_of flavours and dispatch_test_author_interactive) passes
|
|
1292
|
+
# through here, so this is the one place neither caller can bypass by
|
|
1293
|
+
# forgetting its own earlier check.
|
|
1294
|
+
_require_interactive_tty(dry_run)
|
|
1295
|
+
|
|
1296
|
+
# #466: The interactive launcher path now CLAIMS the issue and
|
|
1297
|
+
# RECORDS the dispatched assignment up front (it used to write
|
|
1298
|
+
# nothing then sys.exit), and on session exit invokes the
|
|
1299
|
+
# git-floor backstop in :func:`finalize_interactive_exit` so the
|
|
1300
|
+
# board ALWAYS gets a terminal completion — even if the human
|
|
1301
|
+
# closed the TTY without typing `coord report-result`. Both the
|
|
1302
|
+
# backstop and the report-result subcommand write through the
|
|
1303
|
+
# single :mod:`coord.issue_store` seam so the future #183
|
|
1304
|
+
# IssueStore + coordination MCP can slot in without changing any
|
|
1305
|
+
# of these call sites.
|
|
1306
|
+
|
|
1307
|
+
from coord.providers import ClaudePtyProvider # noqa: PLC0415
|
|
1308
|
+
|
|
1309
|
+
provider = ClaudePtyProvider()
|
|
1310
|
+
caps = provider.capabilities()
|
|
1311
|
+
# Structural guard: confirm we wired the right backend.
|
|
1312
|
+
# Use RuntimeError (not assert) so this is never silently removed
|
|
1313
|
+
# when Python runs with -O.
|
|
1314
|
+
if not caps.human_attended_only:
|
|
1315
|
+
raise RuntimeError(
|
|
1316
|
+
"BUG: --interactive resolved a provider whose capabilities do "
|
|
1317
|
+
"NOT report human_attended_only=True; refusing to launch."
|
|
1318
|
+
)
|
|
1319
|
+
|
|
1320
|
+
# Detect whether the target machine is the local machine so we can
|
|
1321
|
+
# choose the local TTY path vs the remote SSH+tmux path (#494).
|
|
1322
|
+
# Mirrors the hostname-matching logic in _save_config_snapshot.
|
|
1323
|
+
_local_hn = socket.gethostname().split(".")[0].lower()
|
|
1324
|
+
_is_local = (
|
|
1325
|
+
machine_obj.name.lower() == _local_hn
|
|
1326
|
+
or machine_obj.host.split(".")[0].lower() == _local_hn
|
|
1327
|
+
)
|
|
1328
|
+
|
|
1329
|
+
# #590/#749: on a thin client the local board/DB is empty, so resolve the
|
|
1330
|
+
# interactive-launch target (--review-of/--fix-of/--rework-of/--smoke-of/
|
|
1331
|
+
# --merge-of) from the daemon's board, and skip the local post-dispatch
|
|
1332
|
+
# save_board (record_dispatched_assignment already routed the row to the
|
|
1333
|
+
# daemon; a local save would write/resurrect an empty local coord.db).
|
|
1334
|
+
from coord.board_service import read_board as _read_interactive_board
|
|
1335
|
+
from coord.board_service import resolve as _resolve_svc # noqa: PLC0415
|
|
1336
|
+
|
|
1337
|
+
_svc = _resolve_svc()
|
|
1338
|
+
|
|
1339
|
+
def _interactive_board(_local_build):
|
|
1340
|
+
"""The board used to resolve a launch target: routes through
|
|
1341
|
+
board_service.read_board() (daemon when configured, else local) —
|
|
1342
|
+
*_local_build* (each call site's own ``build_board``) is accepted for
|
|
1343
|
+
backward-compat call-site signatures but no longer called directly."""
|
|
1344
|
+
del _local_build
|
|
1345
|
+
return _read_interactive_board()
|
|
1346
|
+
|
|
1347
|
+
# #603: the per-issue context digest, prepended to the TOP of EVERY
|
|
1348
|
+
# interactive briefing below so each agent reads prior-attempt findings
|
|
1349
|
+
# (cross-repo deps, approaches already tried, hard constraints) first.
|
|
1350
|
+
# Computed once per dispatch; "" when there's no context (no-op prefix).
|
|
1351
|
+
from coord.state import issue_context_block as _issue_context_block # noqa: PLC0415
|
|
1352
|
+
|
|
1353
|
+
_issue_ctx = _issue_context_block(repo, issue)
|
|
1354
|
+
|
|
1355
|
+
# #603 write-path hint: interactive agents run in the operator's
|
|
1356
|
+
# environment (so `coord` is on PATH, unlike #402-PATH-stripped -p
|
|
1357
|
+
# workers). Tell the implementer flavours to record durable findings so
|
|
1358
|
+
# the next agent doesn't rediscover them.
|
|
1359
|
+
_ctx_write_hint = (
|
|
1360
|
+
"\n\n## Record durable findings for future agents (#603)\n"
|
|
1361
|
+
"If you discover something a LATER agent on this issue must know — a "
|
|
1362
|
+
"cross-repo dependency (another repo's branch/commit you had to "
|
|
1363
|
+
"pull), an approach that FAILED and why, or a non-obvious constraint "
|
|
1364
|
+
"— record it so it survives to the next attempt:\n"
|
|
1365
|
+
f' `coord context add {repo} {issue} "<one-line finding>" --pin` '
|
|
1366
|
+
"(--pin for a hard dependency/constraint; omit for a normal note).\n"
|
|
1367
|
+
"It is injected at the TOP of every later briefing for this issue — "
|
|
1368
|
+
"don't rely on memory or the PR alone.\n"
|
|
1369
|
+
)
|
|
1370
|
+
return _InteractiveLaunchSetup(
|
|
1371
|
+
provider=provider,
|
|
1372
|
+
is_local=_is_local,
|
|
1373
|
+
svc=_svc,
|
|
1374
|
+
interactive_board=_interactive_board,
|
|
1375
|
+
issue_ctx=_issue_ctx,
|
|
1376
|
+
ctx_write_hint=_ctx_write_hint,
|
|
1377
|
+
)
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
@click.command(help="Send a user message to a running worker mid-session.")
|
|
1381
|
+
@click.argument("assignment_id")
|
|
1382
|
+
@click.argument("text", nargs=-1, required=True)
|
|
1383
|
+
@_CONFIG_OPTION
|
|
1384
|
+
def inject(assignment_id: str, text: tuple[str, ...], config_path: Path) -> None:
|
|
1385
|
+
"""Inject TEXT as a new user message into the running worker's session.
|
|
1386
|
+
|
|
1387
|
+
The worker picks the message up at its next turn boundary — between
|
|
1388
|
+
tool calls, not mid-tool. Useful for adding guidance to a worker
|
|
1389
|
+
that's going off the rails without having to stop + re-dispatch.
|
|
1390
|
+
"""
|
|
1391
|
+
from coord.board_service import read_board
|
|
1392
|
+
from coord.network import inject_message
|
|
1393
|
+
|
|
1394
|
+
cfg = _load_config(config_path)
|
|
1395
|
+
board = read_board()
|
|
1396
|
+
|
|
1397
|
+
assignment = board.find_by_id(assignment_id)
|
|
1398
|
+
if assignment is None:
|
|
1399
|
+
click.echo(f"error: assignment {assignment_id!r} not found in board", err=True)
|
|
1400
|
+
sys.exit(1)
|
|
1401
|
+
|
|
1402
|
+
machine = next(
|
|
1403
|
+
(m for m in cfg.machines if m.name == assignment.machine_name), None
|
|
1404
|
+
)
|
|
1405
|
+
if machine is None:
|
|
1406
|
+
click.echo(f"error: machine {assignment.machine_name!r} not in config", err=True)
|
|
1407
|
+
sys.exit(1)
|
|
1408
|
+
|
|
1409
|
+
message = " ".join(text).strip()
|
|
1410
|
+
if not message:
|
|
1411
|
+
click.echo("error: message text is empty", err=True)
|
|
1412
|
+
sys.exit(2)
|
|
1413
|
+
|
|
1414
|
+
try:
|
|
1415
|
+
status, body = inject_message(machine, assignment_id, message)
|
|
1416
|
+
except (httpx.HTTPError, httpx.TimeoutException) as e:
|
|
1417
|
+
click.echo(f"error: could not reach agent on {machine.name}: {e}", err=True)
|
|
1418
|
+
sys.exit(1)
|
|
1419
|
+
|
|
1420
|
+
if status == 202:
|
|
1421
|
+
click.echo(
|
|
1422
|
+
f"Message delivered to {assignment.repo_name} #{assignment.issue_number} "
|
|
1423
|
+
f"on {machine.name}"
|
|
1424
|
+
)
|
|
1425
|
+
else:
|
|
1426
|
+
click.echo(
|
|
1427
|
+
f"error: agent rejected message (HTTP {status}): {body.get('error', body)}",
|
|
1428
|
+
err=True,
|
|
1429
|
+
)
|
|
1430
|
+
sys.exit(1)
|
|
1431
|
+
|
|
1432
|
+
|
|
1433
|
+
@click.command(name="chat-continue", help="Continue a finished chat session with a new message.")
|
|
1434
|
+
@click.argument("prior_assignment_id")
|
|
1435
|
+
@click.argument("text", nargs=-1, required=True)
|
|
1436
|
+
@_CONFIG_OPTION
|
|
1437
|
+
def chat_continue(
|
|
1438
|
+
prior_assignment_id: str,
|
|
1439
|
+
text: tuple[str, ...],
|
|
1440
|
+
config_path: Path,
|
|
1441
|
+
) -> None:
|
|
1442
|
+
"""Re-dispatch a finished refinement assignment with TEXT as the next user turn.
|
|
1443
|
+
|
|
1444
|
+
Looks up the claude session ID from the prior assignment and passes
|
|
1445
|
+
``--resume <session_id>`` to the next worker so it loads the full
|
|
1446
|
+
conversation history before seeing TEXT as the next user message.
|
|
1447
|
+
|
|
1448
|
+
Prints the new assignment ID on stdout so the TUI can bind to it.
|
|
1449
|
+
Does NOT post a GitHub briefing comment (chat turns are developer-side
|
|
1450
|
+
conversation, not issue activity).
|
|
1451
|
+
"""
|
|
1452
|
+
from coord.board_service import read_board # noqa: PLC0415
|
|
1453
|
+
from coord.dispatch import dispatch
|
|
1454
|
+
from coord.models import Proposal
|
|
1455
|
+
from coord.state import record_dispatched
|
|
1456
|
+
|
|
1457
|
+
cfg = _load_config(config_path)
|
|
1458
|
+
|
|
1459
|
+
# #906: use read_board() so a thin client with an empty local DB still
|
|
1460
|
+
# finds the prior assignment on the daemon's canonical board. The
|
|
1461
|
+
# Assignment model carries all fields we need; claude_session_id is handled
|
|
1462
|
+
# separately below (it is not projected into the board payload).
|
|
1463
|
+
board = read_board()
|
|
1464
|
+
prior = board.find_by_id(prior_assignment_id)
|
|
1465
|
+
if prior is None:
|
|
1466
|
+
click.echo(
|
|
1467
|
+
f"error: assignment {prior_assignment_id!r} not found in DB", err=True
|
|
1468
|
+
)
|
|
1469
|
+
sys.exit(1)
|
|
1470
|
+
|
|
1471
|
+
# claude_session_id is not projected into the board payload. On a daemon
|
|
1472
|
+
# host (no board_service configured), read it directly from the local DB.
|
|
1473
|
+
# On a thin client, svc is not None so we skip the DB and rely solely on
|
|
1474
|
+
# the #315 agent-/status fallback below.
|
|
1475
|
+
from coord.client import resolve_board_service as _resolve_svc # noqa: PLC0415
|
|
1476
|
+
|
|
1477
|
+
claude_session_id = None
|
|
1478
|
+
if _resolve_svc() is None:
|
|
1479
|
+
try:
|
|
1480
|
+
from coord.db import get_connection as _get_conn # noqa: PLC0415
|
|
1481
|
+
|
|
1482
|
+
_row = _get_conn().execute(
|
|
1483
|
+
"SELECT claude_session_id FROM assignments WHERE assignment_id = ?",
|
|
1484
|
+
(prior_assignment_id,),
|
|
1485
|
+
).fetchone()
|
|
1486
|
+
if _row:
|
|
1487
|
+
claude_session_id = _row[0]
|
|
1488
|
+
except Exception: # noqa: BLE001
|
|
1489
|
+
pass
|
|
1490
|
+
|
|
1491
|
+
machine_name = prior.machine_name
|
|
1492
|
+
repo_name = prior.repo_name
|
|
1493
|
+
issue_number = prior.issue_number
|
|
1494
|
+
issue_title = prior.issue_title
|
|
1495
|
+
message_text = " ".join(text).strip()
|
|
1496
|
+
|
|
1497
|
+
# #316: preserve the chat type so the agent server uses the right system
|
|
1498
|
+
# prompt and tool restrictions on continuation. The known chat types are
|
|
1499
|
+
# "refinement", "test-chat", "new-issue-chat", and "milestone-chat"
|
|
1500
|
+
# (#770); anything else falls back to "refinement" (the original
|
|
1501
|
+
# behaviour before type-preservation).
|
|
1502
|
+
_CHAT_TYPES = {"refinement", "test-chat", "new-issue-chat", "milestone-chat"}
|
|
1503
|
+
prior_type: str = prior.type if prior.type in _CHAT_TYPES else "refinement"
|
|
1504
|
+
|
|
1505
|
+
# #315: if the DB doesn't have the session_id yet, fetch it directly
|
|
1506
|
+
# from the agent's /status endpoint. The notify cycle (typically every
|
|
1507
|
+
# 30s) is what syncs session_id from agent → DB; if the user types a
|
|
1508
|
+
# second chat message before notify catches up, the DB row is still
|
|
1509
|
+
# NULL even though the agent captured the session_id in memory.
|
|
1510
|
+
# Without this fallback every fast follow-up submit fails with
|
|
1511
|
+
# "no session ID captured" and the TUI's bind waits 30s and times out.
|
|
1512
|
+
if not claude_session_id:
|
|
1513
|
+
from coord.network import fetch_status # noqa: PLC0415
|
|
1514
|
+
machine_for_status = next(
|
|
1515
|
+
(m for m in cfg.machines if m.name == machine_name), None,
|
|
1516
|
+
)
|
|
1517
|
+
if machine_for_status is not None:
|
|
1518
|
+
status_result = fetch_status(machine_for_status)
|
|
1519
|
+
if status_result.ok and status_result.data:
|
|
1520
|
+
# /status returns {"active": [...], "completed": [...]}
|
|
1521
|
+
# each entry is AgentAssignment.to_dict() with an `id` field
|
|
1522
|
+
for bucket in ("active", "completed"):
|
|
1523
|
+
for entry in status_result.data.get(bucket, []):
|
|
1524
|
+
if entry.get("id") == prior_assignment_id:
|
|
1525
|
+
sid = entry.get("claude_session_id")
|
|
1526
|
+
if isinstance(sid, str) and sid:
|
|
1527
|
+
claude_session_id = sid
|
|
1528
|
+
# Persist to DB so subsequent calls (and the
|
|
1529
|
+
# coordinator's notify loop) don't re-fetch.
|
|
1530
|
+
try:
|
|
1531
|
+
from coord.state import update_assignment_claude_session_id # noqa: PLC0415
|
|
1532
|
+
update_assignment_claude_session_id(
|
|
1533
|
+
prior_assignment_id, sid,
|
|
1534
|
+
)
|
|
1535
|
+
except Exception: # noqa: BLE001
|
|
1536
|
+
pass
|
|
1537
|
+
break
|
|
1538
|
+
if claude_session_id:
|
|
1539
|
+
break
|
|
1540
|
+
|
|
1541
|
+
if not claude_session_id:
|
|
1542
|
+
click.echo(
|
|
1543
|
+
f"error: assignment {prior_assignment_id!r} has no session ID captured — "
|
|
1544
|
+
"agent has no session_id for this assignment (worker may not have "
|
|
1545
|
+
"emitted system.init, or the agent has restarted and forgotten it)",
|
|
1546
|
+
err=True,
|
|
1547
|
+
)
|
|
1548
|
+
sys.exit(1)
|
|
1549
|
+
|
|
1550
|
+
repo_cfg = cfg.repo(repo_name)
|
|
1551
|
+
if repo_cfg is None:
|
|
1552
|
+
click.echo(f"error: repo {repo_name!r} not found in config", err=True)
|
|
1553
|
+
sys.exit(1)
|
|
1554
|
+
|
|
1555
|
+
# Verify the target machine exists; warn but don't abort if missing
|
|
1556
|
+
# (the agent might still be reachable by name even if not in this config).
|
|
1557
|
+
machine = next((m for m in cfg.machines if m.name == machine_name), None)
|
|
1558
|
+
if machine is None:
|
|
1559
|
+
click.echo(
|
|
1560
|
+
f"warning: machine {machine_name!r} not in config — dispatch may fail",
|
|
1561
|
+
err=True,
|
|
1562
|
+
)
|
|
1563
|
+
|
|
1564
|
+
# #315/#314/#316: use the type from the prior assignment so the agent
|
|
1565
|
+
# server uses the right system prompt and tool restrictions on continuation.
|
|
1566
|
+
# resume_session_id passes --resume so the full prior conversation is
|
|
1567
|
+
# loaded before the new user message is appended.
|
|
1568
|
+
proposal = Proposal(
|
|
1569
|
+
id=0, # not inserted into proposals table; dummy value
|
|
1570
|
+
machine_name=machine_name,
|
|
1571
|
+
repo_name=repo_name,
|
|
1572
|
+
issue_number=issue_number,
|
|
1573
|
+
issue_title=issue_title,
|
|
1574
|
+
rationale="chat continuation",
|
|
1575
|
+
briefing=message_text,
|
|
1576
|
+
type=prior_type,
|
|
1577
|
+
resume_session_id=claude_session_id,
|
|
1578
|
+
)
|
|
1579
|
+
|
|
1580
|
+
try:
|
|
1581
|
+
response = dispatch(proposal, cfg)
|
|
1582
|
+
except Exception as e: # noqa: BLE001
|
|
1583
|
+
click.echo(f"error: dispatch failed: {e}", err=True)
|
|
1584
|
+
sys.exit(1)
|
|
1585
|
+
|
|
1586
|
+
assignment_id = response.get("id", "pending")
|
|
1587
|
+
|
|
1588
|
+
# Record in coordinator DB so the board / TUI / notify see it.
|
|
1589
|
+
record_dispatched(
|
|
1590
|
+
assignment_id=assignment_id,
|
|
1591
|
+
proposal=proposal,
|
|
1592
|
+
repo_github=repo_cfg.github,
|
|
1593
|
+
provider_name=response.get("_provider_name"),
|
|
1594
|
+
)
|
|
1595
|
+
|
|
1596
|
+
# Print the new assignment ID on stdout so callers (e.g. TUI) can bind.
|
|
1597
|
+
click.echo(assignment_id)
|
|
1598
|
+
|
|
1599
|
+
|
|
1600
|
+
@click.command(help="Cancel a running assignment.")
|
|
1601
|
+
@click.argument("assignment_id")
|
|
1602
|
+
@click.option(
|
|
1603
|
+
"--rescue",
|
|
1604
|
+
is_flag=True,
|
|
1605
|
+
default=False,
|
|
1606
|
+
help=(
|
|
1607
|
+
"Push any uncommitted WIP to a disposable rescue/<id> ref instead "
|
|
1608
|
+
"of leaving it local-only on the worker machine. Never touches the "
|
|
1609
|
+
"worker's own branch (#1567)."
|
|
1610
|
+
),
|
|
1611
|
+
)
|
|
1612
|
+
@_CONFIG_OPTION
|
|
1613
|
+
def stop(assignment_id: str, rescue: bool, config_path: Path) -> None:
|
|
1614
|
+
from coord.board_service import read_board, write_board
|
|
1615
|
+
|
|
1616
|
+
cfg = _load_config(config_path)
|
|
1617
|
+
board = read_board()
|
|
1618
|
+
|
|
1619
|
+
assignment = board.find_by_id(assignment_id)
|
|
1620
|
+
if assignment is None:
|
|
1621
|
+
click.echo(f"error: assignment {assignment_id!r} not found in board", err=True)
|
|
1622
|
+
sys.exit(1)
|
|
1623
|
+
|
|
1624
|
+
machine = next(
|
|
1625
|
+
(m for m in cfg.machines if m.name == assignment.machine_name), None
|
|
1626
|
+
)
|
|
1627
|
+
if machine is None:
|
|
1628
|
+
click.echo(f"error: machine {assignment.machine_name!r} not in config", err=True)
|
|
1629
|
+
sys.exit(1)
|
|
1630
|
+
|
|
1631
|
+
try:
|
|
1632
|
+
resp = httpx.post(
|
|
1633
|
+
f"http://{machine.host}:{AGENT_PORT}/cancel/{assignment_id}",
|
|
1634
|
+
params={"rescue": "1"} if rescue else None,
|
|
1635
|
+
timeout=10,
|
|
1636
|
+
)
|
|
1637
|
+
resp.raise_for_status()
|
|
1638
|
+
click.echo(f"Assignment {assignment_id} cancelled on {machine.name}")
|
|
1639
|
+
# #1567: print exactly what happened to the worktree/branch — a
|
|
1640
|
+
# `coord stop` that silently pushed (or silently didn't) is the
|
|
1641
|
+
# whole reason this issue exists.
|
|
1642
|
+
try:
|
|
1643
|
+
payload = resp.json()
|
|
1644
|
+
except ValueError:
|
|
1645
|
+
payload = {}
|
|
1646
|
+
dirty_reason = payload.get("dirty_worktree_reason")
|
|
1647
|
+
if dirty_reason:
|
|
1648
|
+
click.echo(f"Worktree: {dirty_reason}")
|
|
1649
|
+
elif not rescue:
|
|
1650
|
+
click.echo(
|
|
1651
|
+
"Worktree: clean or already removed — nothing to rescue; "
|
|
1652
|
+
"the worker's remote branch is unchanged."
|
|
1653
|
+
)
|
|
1654
|
+
except (httpx.HTTPError, httpx.TimeoutException) as e:
|
|
1655
|
+
click.echo(f"warning: could not reach agent on {machine.name}: {e}", err=True)
|
|
1656
|
+
|
|
1657
|
+
board.mark_failed_by_id(assignment_id)
|
|
1658
|
+
write_board(board)
|
|
1659
|
+
click.echo(f"Board updated: {assignment.repo_name} #{assignment.issue_number} marked failed")
|
|
1660
|
+
|
|
1661
|
+
|
|
1662
|
+
@click.command(
|
|
1663
|
+
help=(
|
|
1664
|
+
"Re-dispatch a failed WORK-LIKE assignment (work/mock-author/"
|
|
1665
|
+
"test-author) to a different machine. Also accepts a genuine "
|
|
1666
|
+
"zero-commit ADVISORY (#1606) — an advisory whose branch carries "
|
|
1667
|
+
"real commits is refused; use `coord drive --accept-advisory` for "
|
|
1668
|
+
"that shape instead. A failed 'smoke' or 'review' assignment is "
|
|
1669
|
+
"refused with the command that re-runs its stage (#1636) — this "
|
|
1670
|
+
"never silently re-dispatches one as a fresh work worker."
|
|
1671
|
+
)
|
|
1672
|
+
)
|
|
1673
|
+
@click.argument("assignment_id")
|
|
1674
|
+
@_CONFIG_OPTION
|
|
1675
|
+
def retry(assignment_id: str, config_path: Path) -> None:
|
|
1676
|
+
from coord.board_service import read_board, write_board
|
|
1677
|
+
from coord.models import WORK_LIKE_TYPES
|
|
1678
|
+
from coord.reconcile import (
|
|
1679
|
+
UnsupportedRetryType,
|
|
1680
|
+
_reassign,
|
|
1681
|
+
describe_no_candidate_machines,
|
|
1682
|
+
describe_unsupported_retry_type,
|
|
1683
|
+
)
|
|
1684
|
+
|
|
1685
|
+
cfg = _load_config(config_path)
|
|
1686
|
+
board = read_board()
|
|
1687
|
+
|
|
1688
|
+
assignment = board.find_by_id(assignment_id)
|
|
1689
|
+
if assignment is None:
|
|
1690
|
+
click.echo(f"error: assignment {assignment_id!r} not found in board", err=True)
|
|
1691
|
+
sys.exit(1)
|
|
1692
|
+
if assignment.status not in ("failed", "advisory"):
|
|
1693
|
+
click.echo(
|
|
1694
|
+
f"error: assignment {assignment_id} is {assignment.status!r}, not "
|
|
1695
|
+
f"'failed' or 'advisory'. Only a failed assignment, or a genuine "
|
|
1696
|
+
f"zero-commit advisory (#1606), can be retried.",
|
|
1697
|
+
err=True,
|
|
1698
|
+
)
|
|
1699
|
+
sys.exit(1)
|
|
1700
|
+
|
|
1701
|
+
if assignment.status == "advisory":
|
|
1702
|
+
# #1606: an ADVISORY row is TERMINAL — nothing else on the board ever
|
|
1703
|
+
# re-dispatches it. Only the genuine zero-commit exit (nothing
|
|
1704
|
+
# pushed) is safe to blindly retry here; an advisory whose branch
|
|
1705
|
+
# carries real commits is the #1357 false-positive signature and
|
|
1706
|
+
# must go through `coord drive --accept-advisory` instead, so real
|
|
1707
|
+
# work is never silently discarded by a retry that assumes it's
|
|
1708
|
+
# empty. Shared with `coord diagnose --stage work`'s identical
|
|
1709
|
+
# question (coord/diagnose.py's `_work_advisory_commits_ahead`) via
|
|
1710
|
+
# `github_ops.branch_commits_ahead_for_assignment` — this used to be
|
|
1711
|
+
# an independent inline copy of the same "branch empty → 0, repo
|
|
1712
|
+
# missing → None, else ask GitHub" logic.
|
|
1713
|
+
ahead = github_ops.branch_commits_ahead_for_assignment(assignment, cfg)
|
|
1714
|
+
if ahead != 0:
|
|
1715
|
+
detail = (
|
|
1716
|
+
"its commit count could not be confirmed (gh lookup failed)"
|
|
1717
|
+
if ahead is None
|
|
1718
|
+
else f"{ahead} commit(s) present on its branch"
|
|
1719
|
+
)
|
|
1720
|
+
click.echo(
|
|
1721
|
+
f"error: assignment {assignment_id} is 'advisory', and {detail} "
|
|
1722
|
+
"— coord retry only re-dispatches a GENUINE zero-commit "
|
|
1723
|
+
"advisory. This looks like the #1357 false-positive "
|
|
1724
|
+
"signature instead; use `coord drive --accept-advisory` to "
|
|
1725
|
+
"proceed with the existing commits, or inspect by hand.",
|
|
1726
|
+
err=True,
|
|
1727
|
+
)
|
|
1728
|
+
sys.exit(1)
|
|
1729
|
+
|
|
1730
|
+
# #1636: `coord retry` only knows how to re-dispatch WORK_LIKE_TYPES
|
|
1731
|
+
# ("work", "mock-author", "test-author") through this path — it drives a
|
|
1732
|
+
# fresh worker down the Work→Test→Review→Merge pipeline. A "smoke" or
|
|
1733
|
+
# "review" (or other) row has its own re-dispatch command that re-runs
|
|
1734
|
+
# the right stage instead of silently spinning up a work worker on the
|
|
1735
|
+
# already-complete branch (#1636's reported bug). Checked up front, before
|
|
1736
|
+
# the model-escalation message, so a refusal doesn't first print a
|
|
1737
|
+
# reassuring "escalating model" line for a retry that's about to be
|
|
1738
|
+
# refused anyway.
|
|
1739
|
+
if assignment.type not in WORK_LIKE_TYPES:
|
|
1740
|
+
exc = UnsupportedRetryType(assignment.type, assignment.review_of_assignment_id)
|
|
1741
|
+
click.echo(f"error: {describe_unsupported_retry_type(exc)}", err=True)
|
|
1742
|
+
sys.exit(1)
|
|
1743
|
+
|
|
1744
|
+
# Determine escalated model for the retry.
|
|
1745
|
+
original_model = assignment.model or cfg.models.default
|
|
1746
|
+
escalated = cfg.models.next_model(original_model)
|
|
1747
|
+
if escalated != original_model:
|
|
1748
|
+
click.echo(f" escalating model: {original_model} → {escalated}")
|
|
1749
|
+
|
|
1750
|
+
try:
|
|
1751
|
+
result = _reassign(assignment, board, cfg, model=escalated)
|
|
1752
|
+
except UnsupportedRetryType as exc:
|
|
1753
|
+
# Defense in depth — the precheck above already covers this, but
|
|
1754
|
+
# `_reassign` is shared with `auto_reassign` and must never silently
|
|
1755
|
+
# downgrade a non-work retry even if a future caller skips the
|
|
1756
|
+
# precheck.
|
|
1757
|
+
click.echo(f"error: {describe_unsupported_retry_type(exc)}", err=True)
|
|
1758
|
+
sys.exit(1)
|
|
1759
|
+
if result is None:
|
|
1760
|
+
# #1396: name the blocking machines and their apparent load instead
|
|
1761
|
+
# of a bare "no available machine" — the usual cause is a phantom
|
|
1762
|
+
# `running` row from a dead interactive session nothing reaped.
|
|
1763
|
+
click.echo(
|
|
1764
|
+
f"error: {describe_no_candidate_machines(assignment, board, cfg)}",
|
|
1765
|
+
err=True,
|
|
1766
|
+
)
|
|
1767
|
+
sys.exit(1)
|
|
1768
|
+
|
|
1769
|
+
write_board(board)
|
|
1770
|
+
click.echo(
|
|
1771
|
+
f"Retried: {result.machine_name} → {result.repo_name} "
|
|
1772
|
+
f"#{result.issue_number} (assignment {result.assignment_id}, "
|
|
1773
|
+
f"type={result.type})"
|
|
1774
|
+
)
|
|
1775
|
+
# #1101: surface the continued branch so it's obvious the retry picked
|
|
1776
|
+
# up existing work instead of forking a fresh branch off the default.
|
|
1777
|
+
if result.branch:
|
|
1778
|
+
click.echo(f" continuing branch: {result.branch}")
|
|
1779
|
+
else:
|
|
1780
|
+
click.echo(" no prior branch recorded — starting a fresh branch")
|