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
coord/network.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Network health checks for agent servers over Tailscale (plain HTTP).
|
|
2
|
+
|
|
3
|
+
Tailscale's MagicDNS resolves hostnames and the tailnet encrypts the
|
|
4
|
+
connection, so we use plain HTTP on the agent port. This module classifies
|
|
5
|
+
connection failures so the CLI can give actionable diagnostics rather than
|
|
6
|
+
a generic "offline".
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import socket
|
|
12
|
+
import time
|
|
13
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Iterable
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from coord.models import Machine
|
|
20
|
+
|
|
21
|
+
AGENT_PORT = 7433
|
|
22
|
+
DEFAULT_TIMEOUT = 3.0
|
|
23
|
+
|
|
24
|
+
# Status categories — keep these stable; downstream code may key off them.
|
|
25
|
+
ONLINE = "online"
|
|
26
|
+
OFFLINE = "offline"
|
|
27
|
+
TIMEOUT = "timeout"
|
|
28
|
+
DNS_ERROR = "dns_error"
|
|
29
|
+
HTTP_ERROR = "http_error"
|
|
30
|
+
RATE_LIMITED = "rate_limited"
|
|
31
|
+
UNKNOWN = "unknown"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class MachineStatus:
|
|
36
|
+
machine: Machine
|
|
37
|
+
state: str
|
|
38
|
+
reason: str = ""
|
|
39
|
+
latency_ms: float | None = None
|
|
40
|
+
health: dict | None = None
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def is_online(self) -> bool:
|
|
44
|
+
return self.state == ONLINE
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def classify_error(exc: Exception) -> tuple[str, str]:
|
|
48
|
+
"""Map an httpx/network exception to a (state, reason) pair."""
|
|
49
|
+
if isinstance(exc, httpx.ConnectTimeout) or isinstance(exc, httpx.ReadTimeout):
|
|
50
|
+
return TIMEOUT, "timed out"
|
|
51
|
+
if isinstance(exc, httpx.ConnectError):
|
|
52
|
+
msg = str(exc).lower()
|
|
53
|
+
if "name or service not known" in msg or "nodename nor servname" in msg or "getaddrinfo" in msg:
|
|
54
|
+
return DNS_ERROR, "hostname not resolvable (Tailscale up?)"
|
|
55
|
+
if "connection refused" in msg:
|
|
56
|
+
return OFFLINE, "connection refused (agent not running?)"
|
|
57
|
+
return OFFLINE, f"connection failed ({exc})"
|
|
58
|
+
if isinstance(exc, socket.gaierror):
|
|
59
|
+
return DNS_ERROR, "hostname not resolvable (Tailscale up?)"
|
|
60
|
+
if isinstance(exc, httpx.HTTPStatusError) and exc.response.status_code == 429:
|
|
61
|
+
return RATE_LIMITED, "rate limited (429 Too Many Requests)"
|
|
62
|
+
if isinstance(exc, httpx.HTTPError):
|
|
63
|
+
return HTTP_ERROR, f"http error: {exc}"
|
|
64
|
+
return UNKNOWN, f"{type(exc).__name__}: {exc}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_retryable(state: str) -> bool:
|
|
68
|
+
"""Whether a classified error state should trigger a retry."""
|
|
69
|
+
return state in (TIMEOUT, RATE_LIMITED, HTTP_ERROR)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def check_machine(machine: Machine, timeout: float = DEFAULT_TIMEOUT) -> MachineStatus:
|
|
73
|
+
"""Ping `machine`'s /health endpoint and classify the result."""
|
|
74
|
+
url = f"http://{machine.host}:{AGENT_PORT}/health"
|
|
75
|
+
start = time.perf_counter()
|
|
76
|
+
try:
|
|
77
|
+
resp = httpx.get(url, timeout=timeout)
|
|
78
|
+
except Exception as e: # noqa: BLE001 — we classify all network errors
|
|
79
|
+
state, reason = classify_error(e)
|
|
80
|
+
return MachineStatus(machine=machine, state=state, reason=reason)
|
|
81
|
+
|
|
82
|
+
latency_ms = (time.perf_counter() - start) * 1000.0
|
|
83
|
+
if resp.status_code != 200:
|
|
84
|
+
return MachineStatus(
|
|
85
|
+
machine=machine,
|
|
86
|
+
state=HTTP_ERROR,
|
|
87
|
+
reason=f"HTTP {resp.status_code}",
|
|
88
|
+
latency_ms=latency_ms,
|
|
89
|
+
)
|
|
90
|
+
try:
|
|
91
|
+
health = resp.json()
|
|
92
|
+
except ValueError:
|
|
93
|
+
return MachineStatus(
|
|
94
|
+
machine=machine,
|
|
95
|
+
state=HTTP_ERROR,
|
|
96
|
+
reason="invalid JSON from /health",
|
|
97
|
+
latency_ms=latency_ms,
|
|
98
|
+
)
|
|
99
|
+
return MachineStatus(
|
|
100
|
+
machine=machine,
|
|
101
|
+
state=ONLINE,
|
|
102
|
+
reason="",
|
|
103
|
+
latency_ms=latency_ms,
|
|
104
|
+
health=health,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def check_all(
|
|
109
|
+
machines: Iterable[Machine],
|
|
110
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
111
|
+
max_workers: int | None = None,
|
|
112
|
+
) -> list[MachineStatus]:
|
|
113
|
+
"""Health-check every machine concurrently. Preserves input order."""
|
|
114
|
+
machines = list(machines)
|
|
115
|
+
if not machines:
|
|
116
|
+
return []
|
|
117
|
+
workers = max_workers or min(8, len(machines))
|
|
118
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
119
|
+
return list(pool.map(lambda m: check_machine(m, timeout=timeout), machines))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class StatusResult:
|
|
124
|
+
"""Result of fetching /status from an agent.
|
|
125
|
+
|
|
126
|
+
On success, ``data`` holds the parsed response and ``error`` is None.
|
|
127
|
+
On failure, ``data`` is None and ``error`` describes what went wrong.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
data: dict | None = None
|
|
131
|
+
error: str | None = None
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def ok(self) -> bool:
|
|
135
|
+
return self.data is not None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def fetch_status(machine: Machine, timeout: float = DEFAULT_TIMEOUT) -> StatusResult:
|
|
139
|
+
"""GET /status from a machine. Returns a StatusResult (never None)."""
|
|
140
|
+
try:
|
|
141
|
+
resp = httpx.get(
|
|
142
|
+
f"http://{machine.host}:{AGENT_PORT}/status", timeout=timeout
|
|
143
|
+
)
|
|
144
|
+
resp.raise_for_status()
|
|
145
|
+
return StatusResult(data=resp.json())
|
|
146
|
+
except httpx.HTTPStatusError as exc:
|
|
147
|
+
return StatusResult(error=f"HTTP {exc.response.status_code}")
|
|
148
|
+
except (httpx.ConnectTimeout, httpx.ReadTimeout):
|
|
149
|
+
return StatusResult(error="timeout")
|
|
150
|
+
except httpx.ConnectError as exc:
|
|
151
|
+
return StatusResult(error=f"connection error: {exc}")
|
|
152
|
+
except (httpx.HTTPError, ValueError) as exc:
|
|
153
|
+
return StatusResult(error=str(exc))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def fetch_repos(machine: Machine, timeout: float = DEFAULT_TIMEOUT) -> dict | None:
|
|
157
|
+
"""GET /repos. Returns None on network error (per-repo errors come back inside the dict)."""
|
|
158
|
+
try:
|
|
159
|
+
resp = httpx.get(
|
|
160
|
+
f"http://{machine.host}:{AGENT_PORT}/repos", timeout=timeout
|
|
161
|
+
)
|
|
162
|
+
resp.raise_for_status()
|
|
163
|
+
return resp.json()
|
|
164
|
+
except (httpx.HTTPError, ValueError):
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def fetch_log(
|
|
169
|
+
machine: Machine,
|
|
170
|
+
assignment_id: str,
|
|
171
|
+
*,
|
|
172
|
+
since: int = 0,
|
|
173
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
174
|
+
) -> tuple[int, bytes]:
|
|
175
|
+
"""GET /logs/{id} from a machine. Returns (status_code, body)."""
|
|
176
|
+
url = f"http://{machine.host}:{AGENT_PORT}/logs/{assignment_id}"
|
|
177
|
+
params = {"since": since} if since else None
|
|
178
|
+
resp = httpx.get(url, params=params, timeout=timeout)
|
|
179
|
+
return resp.status_code, resp.content
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def inject_message(
|
|
183
|
+
machine: Machine,
|
|
184
|
+
assignment_id: str,
|
|
185
|
+
text: str,
|
|
186
|
+
*,
|
|
187
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
188
|
+
) -> tuple[int, dict]:
|
|
189
|
+
"""POST /inject/{id} to send `text` as a new user message to a running
|
|
190
|
+
worker. Returns (status_code, response_body_dict). Returned status:
|
|
191
|
+
202 on delivery, 404 unknown assignment, 409 not running, 410 stdin
|
|
192
|
+
closed, 400 bad body.
|
|
193
|
+
"""
|
|
194
|
+
url = f"http://{machine.host}:{AGENT_PORT}/inject/{assignment_id}"
|
|
195
|
+
resp = httpx.post(url, json={"text": text}, timeout=timeout)
|
|
196
|
+
try:
|
|
197
|
+
body = resp.json()
|
|
198
|
+
except Exception:
|
|
199
|
+
body = {"error": "non-json response", "raw": resp.text[:200]}
|
|
200
|
+
return resp.status_code, body
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def clean_worktrees(
|
|
204
|
+
machine: Machine,
|
|
205
|
+
*,
|
|
206
|
+
recent_secs: float = 300.0,
|
|
207
|
+
protect: list[str] | None = None,
|
|
208
|
+
timeout: float = 30.0,
|
|
209
|
+
) -> dict:
|
|
210
|
+
"""POST /worktree-clean to `machine` (#1220).
|
|
211
|
+
|
|
212
|
+
Same endpoint the manual ``coord agent clean-worktrees`` CLI hits — this
|
|
213
|
+
is the helper a fleet-wide *automatic* sweep (``coord.serve_app``'s tick
|
|
214
|
+
loop) calls per machine. Never raises: any network/HTTP/decode failure
|
|
215
|
+
is folded into the returned dict's ``error`` field (``ok=False``) so one
|
|
216
|
+
unreachable machine can't abort a sweep across the rest of the fleet.
|
|
217
|
+
|
|
218
|
+
#1295: an optional *protect* list of assignment IDs the coordinator
|
|
219
|
+
knows are still live (interactive sessions, dispatched-but-not-yet-
|
|
220
|
+
finished work) is forwarded to the agent so those worktrees are kept
|
|
221
|
+
even when the agent's own record has been lost (agent restart, DB
|
|
222
|
+
reload race). A None / empty *protect* is omitted from the request
|
|
223
|
+
body so an older agent that doesn't know about the field sees the
|
|
224
|
+
same wire shape as before.
|
|
225
|
+
|
|
226
|
+
Returns ``{"ok": bool, "cleaned": int, "kept": int, "bytes_freed": int,
|
|
227
|
+
"cargo_cache_bytes": int, "cargo_caches_evicted": int,
|
|
228
|
+
"error": str | None}``. The ``cargo_*`` fields (#1402) report the shared
|
|
229
|
+
cargo target cache's size after the agent's GC and how many per-repo
|
|
230
|
+
caches it evicted; they are 0 against an agent too old to report them.
|
|
231
|
+
"""
|
|
232
|
+
url = f"http://{machine.host}:{AGENT_PORT}/worktree-clean"
|
|
233
|
+
payload: dict[str, object] = {"recent_secs": recent_secs}
|
|
234
|
+
if protect:
|
|
235
|
+
payload["protect"] = list(protect)
|
|
236
|
+
try:
|
|
237
|
+
resp = httpx.post(url, json=payload, timeout=timeout)
|
|
238
|
+
except Exception as e: # noqa: BLE001 — classify uniformly, never propagate
|
|
239
|
+
_, reason = classify_error(e)
|
|
240
|
+
return {"ok": False, "cleaned": 0, "kept": 0, "bytes_freed": 0, "error": reason}
|
|
241
|
+
if resp.status_code != 200:
|
|
242
|
+
return {
|
|
243
|
+
"ok": False,
|
|
244
|
+
"cleaned": 0,
|
|
245
|
+
"kept": 0,
|
|
246
|
+
"bytes_freed": 0,
|
|
247
|
+
"error": f"HTTP {resp.status_code}",
|
|
248
|
+
}
|
|
249
|
+
try:
|
|
250
|
+
data = resp.json()
|
|
251
|
+
except ValueError:
|
|
252
|
+
return {
|
|
253
|
+
"ok": False,
|
|
254
|
+
"cleaned": 0,
|
|
255
|
+
"kept": 0,
|
|
256
|
+
"bytes_freed": 0,
|
|
257
|
+
"error": "invalid JSON from /worktree-clean",
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
"ok": True,
|
|
261
|
+
"cleaned": data.get("cleaned", 0),
|
|
262
|
+
"kept": data.get("kept", 0),
|
|
263
|
+
"bytes_freed": data.get("bytes_freed", 0),
|
|
264
|
+
# #1402: shared cargo-cache GC counters. ``.get`` defaults keep an
|
|
265
|
+
# older agent (which doesn't report them) on the same wire shape.
|
|
266
|
+
"cargo_cache_bytes": data.get("cargo_cache_bytes", 0),
|
|
267
|
+
"cargo_caches_evicted": data.get("cargo_caches_evicted", 0),
|
|
268
|
+
"error": None,
|
|
269
|
+
}
|
coord/new_issue_chat.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""#316: dispatch a `type="new-issue-chat"` session for drafting a new GitHub issue.
|
|
2
|
+
|
|
3
|
+
Used by `coord new-issue-chat <repo>` (the CLI command shelled out by the
|
|
4
|
+
TUI's "New issue" action). Seeds the worker with the repo's CLAUDE.md, the
|
|
5
|
+
per-repo new-issue guidance from ``coordinator.yml``, and a bounded list of
|
|
6
|
+
recently open issues so the chat can flag near-duplicates. The developer
|
|
7
|
+
drives the conversation; the worker helps articulate intent and produces a
|
|
8
|
+
finished issue body in the ``TITLE: / --- / body`` format.
|
|
9
|
+
|
|
10
|
+
The session runs as a ``type="new-issue-chat"`` assignment on an agent server.
|
|
11
|
+
Tools are restricted to ``Read,Bash`` with a deny list that blocks all
|
|
12
|
+
mutations (``gh issue create``, ``git push``, etc.) — the user-side TUI
|
|
13
|
+
handles the actual ``gh issue create`` submission.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from coord import github_ops
|
|
23
|
+
from coord.config import Config
|
|
24
|
+
from coord.models import Machine, Proposal, Repo
|
|
25
|
+
from coord.refine_chat import _read_claude_md
|
|
26
|
+
|
|
27
|
+
# How many open issues to fetch for near-duplicate detection.
|
|
28
|
+
MAX_OPEN_ISSUES = 20
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def pick_new_issue_chat_machine(cfg: Config, repo: str) -> Machine | None:
|
|
32
|
+
"""Pick a machine to run the new-issue-chat session on.
|
|
33
|
+
|
|
34
|
+
Returns the first unpaused machine that lists *repo* and has a resolved
|
|
35
|
+
``repo_path`` for it. New-issue-chat is read-only and short-lived, so
|
|
36
|
+
any qualified machine works — no freshness / capacity weighting needed.
|
|
37
|
+
Returns ``None`` when no machine claims the repo.
|
|
38
|
+
"""
|
|
39
|
+
from coord.machine_pause import paused_set
|
|
40
|
+
|
|
41
|
+
paused = paused_set(cfg.machines)
|
|
42
|
+
for m in cfg.machines:
|
|
43
|
+
if (
|
|
44
|
+
m.can_work_on(repo)
|
|
45
|
+
and m.repo_path(repo) is not None
|
|
46
|
+
and m.name not in paused
|
|
47
|
+
):
|
|
48
|
+
return m
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _fetch_open_issues(slug: str, limit: int = MAX_OPEN_ISSUES) -> list[dict]:
|
|
53
|
+
"""Best-effort fetch of open issue titles from ``gh issue list``.
|
|
54
|
+
|
|
55
|
+
Returns a list of ``{"number": int, "title": str}`` dicts, truncated to
|
|
56
|
+
*limit*. Returns an empty list on any failure so the seed still works
|
|
57
|
+
without issue-list context.
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
raw = github_ops._gh(
|
|
61
|
+
"issue", "list",
|
|
62
|
+
"--repo", slug,
|
|
63
|
+
"--state", "open",
|
|
64
|
+
"--limit", str(limit),
|
|
65
|
+
"--json", "number,title",
|
|
66
|
+
)
|
|
67
|
+
data = json.loads(raw)
|
|
68
|
+
if not isinstance(data, list):
|
|
69
|
+
return []
|
|
70
|
+
return data[:limit]
|
|
71
|
+
except (RuntimeError, ValueError, json.JSONDecodeError):
|
|
72
|
+
return []
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def build_new_issue_briefing(
|
|
76
|
+
*,
|
|
77
|
+
repo_slug: str,
|
|
78
|
+
claude_md: str,
|
|
79
|
+
new_issue_guidance: str,
|
|
80
|
+
open_issues: list[dict],
|
|
81
|
+
) -> str:
|
|
82
|
+
"""Compose the seed briefing the worker sees as its first user message.
|
|
83
|
+
|
|
84
|
+
The agent's ``NEW_ISSUE_CHAT_SYSTEM_PROMPT`` already tells it how to
|
|
85
|
+
behave; this packs the repo context into the first user turn.
|
|
86
|
+
"""
|
|
87
|
+
parts: list[str] = []
|
|
88
|
+
parts.append(f"=== New-issue chat context for {repo_slug} ===\n")
|
|
89
|
+
|
|
90
|
+
parts.append("ISSUE GUIDANCE (required sections and conventions):")
|
|
91
|
+
parts.append(new_issue_guidance.strip() if new_issue_guidance.strip() else "(none)")
|
|
92
|
+
parts.append("")
|
|
93
|
+
|
|
94
|
+
parts.append("PROJECT CLAUDE.md:")
|
|
95
|
+
parts.append(claude_md.strip() if claude_md.strip() else "(not found)")
|
|
96
|
+
parts.append("")
|
|
97
|
+
|
|
98
|
+
parts.append(
|
|
99
|
+
f"RECENT OPEN ISSUES (last {MAX_OPEN_ISSUES}, check for near-duplicates):"
|
|
100
|
+
)
|
|
101
|
+
if open_issues:
|
|
102
|
+
for issue in open_issues:
|
|
103
|
+
num = issue.get("number", "?")
|
|
104
|
+
title = (issue.get("title") or "").strip()
|
|
105
|
+
parts.append(f" #{num}: {title}")
|
|
106
|
+
else:
|
|
107
|
+
parts.append(" (none fetched)")
|
|
108
|
+
parts.append("")
|
|
109
|
+
|
|
110
|
+
parts.append("---")
|
|
111
|
+
parts.append(
|
|
112
|
+
"The developer will now describe the feature or bug they want to file "
|
|
113
|
+
"as an issue. Ask focused clarifying questions to help shape a "
|
|
114
|
+
"well-structured draft. When ready, present the draft in the "
|
|
115
|
+
"TITLE: / --- / body format."
|
|
116
|
+
)
|
|
117
|
+
return "\n".join(parts)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def dispatch_new_issue_chat(
|
|
121
|
+
repo_name: str,
|
|
122
|
+
config: Config,
|
|
123
|
+
*,
|
|
124
|
+
machine_override: str | None = None,
|
|
125
|
+
) -> tuple[str, str]:
|
|
126
|
+
"""End-to-end: pick a machine, seed the briefing, dispatch a
|
|
127
|
+
``type="new-issue-chat"`` assignment. Returns ``(assignment_id, machine_name)``.
|
|
128
|
+
|
|
129
|
+
Raises ``RuntimeError`` when the repo is unknown, no machine claims it,
|
|
130
|
+
or the agent rejects the dispatch.
|
|
131
|
+
"""
|
|
132
|
+
repo_cfg = config.repo(repo_name)
|
|
133
|
+
if repo_cfg is None:
|
|
134
|
+
raise RuntimeError(
|
|
135
|
+
f"repo {repo_name!r} not in coordinator.yml"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Pick the machine.
|
|
139
|
+
if machine_override:
|
|
140
|
+
machine = next(
|
|
141
|
+
(m for m in config.machines if m.name == machine_override),
|
|
142
|
+
None,
|
|
143
|
+
)
|
|
144
|
+
if machine is None:
|
|
145
|
+
raise RuntimeError(
|
|
146
|
+
f"machine {machine_override!r} not in coordinator.yml"
|
|
147
|
+
)
|
|
148
|
+
if not machine.can_work_on(repo_name):
|
|
149
|
+
raise RuntimeError(
|
|
150
|
+
f"machine {machine_override!r} does not list repo {repo_name!r}"
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
picked = pick_new_issue_chat_machine(config, repo_name)
|
|
154
|
+
if picked is None:
|
|
155
|
+
raise RuntimeError(
|
|
156
|
+
f"no machine claims repo {repo_name!r} — new-issue-chat needs a "
|
|
157
|
+
"machine that has the repo cloned to read CLAUDE.md"
|
|
158
|
+
)
|
|
159
|
+
machine = picked
|
|
160
|
+
|
|
161
|
+
repo_path = machine.repo_path(repo_name)
|
|
162
|
+
if repo_path is None:
|
|
163
|
+
raise RuntimeError(
|
|
164
|
+
f"machine {machine.name!r} has no resolved path for repo {repo_name!r}"
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# Read local context (coordinator's view of the repo).
|
|
168
|
+
repo_root = Path(repo_path).expanduser()
|
|
169
|
+
claude_md = _read_claude_md(repo_root)
|
|
170
|
+
new_issue_guidance = repo_cfg.resolve_new_issue_guidance(repo_root)
|
|
171
|
+
open_issues = _fetch_open_issues(repo_cfg.github)
|
|
172
|
+
|
|
173
|
+
briefing = build_new_issue_briefing(
|
|
174
|
+
repo_slug=repo_cfg.github,
|
|
175
|
+
claude_md=claude_md,
|
|
176
|
+
new_issue_guidance=new_issue_guidance,
|
|
177
|
+
open_issues=open_issues,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
resolved_model = config.models.default
|
|
181
|
+
proposal = Proposal(
|
|
182
|
+
id=0,
|
|
183
|
+
machine_name=machine.name,
|
|
184
|
+
repo_name=repo_name,
|
|
185
|
+
# No existing issue — use 0 as a sentinel. The DB column is NOT NULL
|
|
186
|
+
# but there is no issue to reference yet.
|
|
187
|
+
issue_number=0,
|
|
188
|
+
issue_title="(new issue draft)",
|
|
189
|
+
rationale="new-issue-chat",
|
|
190
|
+
briefing=briefing,
|
|
191
|
+
model=resolved_model,
|
|
192
|
+
type="new-issue-chat",
|
|
193
|
+
required_gates=[],
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
from coord.dispatch import dispatch_with_retry
|
|
197
|
+
from coord.models import Assignment
|
|
198
|
+
from coord.state import record_dispatched_assignment
|
|
199
|
+
|
|
200
|
+
response = dispatch_with_retry(
|
|
201
|
+
proposal,
|
|
202
|
+
config,
|
|
203
|
+
max_retries=config.concurrency.max_retries,
|
|
204
|
+
backoff_base=config.concurrency.backoff_base,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
assignment_id = response.get("id") or uuid.uuid4().hex[:12]
|
|
208
|
+
|
|
209
|
+
# Persist the assignment row so the TUI / notify / board can find it.
|
|
210
|
+
asg = Assignment(
|
|
211
|
+
machine_name=machine.name,
|
|
212
|
+
repo_name=repo_name,
|
|
213
|
+
issue_number=0,
|
|
214
|
+
issue_title="(new issue draft)",
|
|
215
|
+
files_allowed=[],
|
|
216
|
+
files_forbidden=[],
|
|
217
|
+
briefing=briefing,
|
|
218
|
+
assignment_id=assignment_id,
|
|
219
|
+
status="running",
|
|
220
|
+
dispatched_at=time.time(),
|
|
221
|
+
type="new-issue-chat",
|
|
222
|
+
model=resolved_model,
|
|
223
|
+
)
|
|
224
|
+
record_dispatched_assignment(
|
|
225
|
+
assignment=asg,
|
|
226
|
+
repo_github=repo_cfg.github,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
return assignment_id, machine.name
|