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.
Files changed (295) hide show
  1. code_coordinator-0.5.46.dist-info/METADATA +625 -0
  2. code_coordinator-0.5.46.dist-info/RECORD +295 -0
  3. code_coordinator-0.5.46.dist-info/WHEEL +5 -0
  4. code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
  5. code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
  6. code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
  7. coord/__init__.py +176 -0
  8. coord/_board_mapping.py +229 -0
  9. coord/acceptance.py +468 -0
  10. coord/acceptance_drivers.py +632 -0
  11. coord/agent.py +7517 -0
  12. coord/agent_app.py +1555 -0
  13. coord/agent_update.py +417 -0
  14. coord/agents/opencode/.gitignore +13 -0
  15. coord/agents/opencode/agents/work.md +129 -0
  16. coord/agents/opencode/routing.jsonc +49 -0
  17. coord/audit.py +301 -0
  18. coord/auto_loop.py +1440 -0
  19. coord/board_bool_guard.py +72 -0
  20. coord/board_service.py +141 -0
  21. coord/board_wire.py +309 -0
  22. coord/brain.py +581 -0
  23. coord/branch_model.py +214 -0
  24. coord/cargo_cache.py +258 -0
  25. coord/ci_github.py +386 -0
  26. coord/ci_store.py +560 -0
  27. coord/claim.py +353 -0
  28. coord/cli.py +454 -0
  29. coord/client.py +610 -0
  30. coord/commands/__init__.py +1 -0
  31. coord/commands/_common.py +329 -0
  32. coord/commands/acceptance.py +916 -0
  33. coord/commands/agent_ops.py +1339 -0
  34. coord/commands/audit.py +131 -0
  35. coord/commands/chat.py +320 -0
  36. coord/commands/dispatch.py +1780 -0
  37. coord/commands/dispatch_workers.py +4894 -0
  38. coord/commands/drive.py +616 -0
  39. coord/commands/drive_queue.py +1203 -0
  40. coord/commands/gate_a.py +217 -0
  41. coord/commands/gates.py +89 -0
  42. coord/commands/issues.py +681 -0
  43. coord/commands/lifecycle.py +513 -0
  44. coord/commands/merge.py +1900 -0
  45. coord/commands/milestone.py +2081 -0
  46. coord/commands/plan_followup.py +1243 -0
  47. coord/commands/plans.py +156 -0
  48. coord/commands/release.py +2232 -0
  49. coord/commands/report.py +341 -0
  50. coord/commands/review.py +1523 -0
  51. coord/commands/scorecard.py +252 -0
  52. coord/commands/sessions.py +1930 -0
  53. coord/commands/setup.py +576 -0
  54. coord/commands/status.py +2089 -0
  55. coord/commands/terminal.py +385 -0
  56. coord/commands/test_gate.py +775 -0
  57. coord/commands/tui.py +288 -0
  58. coord/comments.py +718 -0
  59. coord/config.py +3032 -0
  60. coord/conflict_fix.py +633 -0
  61. coord/dao.py +483 -0
  62. coord/dashboard/__init__.py +0 -0
  63. coord/dashboard/fixture.py +376 -0
  64. coord/dashboard/index.html +658 -0
  65. coord/dashboard/server.py +1894 -0
  66. coord/dashboard/terminal.py +382 -0
  67. coord/dashboard/webapp/.gitignore +9 -0
  68. coord/dashboard/webapp/components.json +17 -0
  69. coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
  70. coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
  71. coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
  72. coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
  73. coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
  74. coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
  75. coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
  76. coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
  77. coord/dashboard/webapp/dist/icons/icon.svg +5 -0
  78. coord/dashboard/webapp/dist/index.html +38 -0
  79. coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
  80. coord/dashboard/webapp/dist/sw.js +1 -0
  81. coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
  82. coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
  83. coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
  84. coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
  85. coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
  86. coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
  87. coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
  88. coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
  89. coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
  90. coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
  91. coord/dashboard/webapp/eslint.config.js +20 -0
  92. coord/dashboard/webapp/index.html +37 -0
  93. coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
  94. coord/dashboard/webapp/package-lock.json +10584 -0
  95. coord/dashboard/webapp/package.json +63 -0
  96. coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
  97. coord/dashboard/webapp/playwright.config.ts +93 -0
  98. coord/dashboard/webapp/postcss.config.js +6 -0
  99. coord/dashboard/webapp/public/icons/icon-192.png +0 -0
  100. coord/dashboard/webapp/public/icons/icon-512.png +0 -0
  101. coord/dashboard/webapp/public/icons/icon.svg +5 -0
  102. coord/dashboard/webapp/src/App.tsx +140 -0
  103. coord/dashboard/webapp/src/api/client.ts +199 -0
  104. coord/dashboard/webapp/src/api/generated.ts +176 -0
  105. coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
  106. coord/dashboard/webapp/src/components/Detail.tsx +800 -0
  107. coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
  108. coord/dashboard/webapp/src/components/Home.tsx +435 -0
  109. coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
  110. coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
  111. coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
  112. coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
  113. coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
  114. coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
  115. coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
  116. coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
  117. coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
  118. coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
  119. coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
  120. coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
  121. coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
  122. coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
  123. coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
  124. coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
  125. coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
  126. coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
  127. coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
  128. coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
  129. coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
  130. coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
  131. coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
  132. coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
  133. coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
  134. coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
  135. coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
  136. coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
  137. coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
  138. coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
  139. coord/dashboard/webapp/src/index.css +210 -0
  140. coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
  141. coord/dashboard/webapp/src/lib/utils.ts +6 -0
  142. coord/dashboard/webapp/src/main.tsx +46 -0
  143. coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
  144. coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
  145. coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
  146. coord/dashboard/webapp/src/realtime/connection.ts +227 -0
  147. coord/dashboard/webapp/src/realtime/events.ts +100 -0
  148. coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
  149. coord/dashboard/webapp/src/routes/paths.ts +92 -0
  150. coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
  151. coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
  152. coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
  153. coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
  154. coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
  155. coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
  156. coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
  157. coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
  158. coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
  159. coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
  160. coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
  161. coord/dashboard/webapp/src/shell/railItems.ts +105 -0
  162. coord/dashboard/webapp/src/shell/shellState.ts +174 -0
  163. coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
  164. coord/dashboard/webapp/src/test-setup.ts +41 -0
  165. coord/dashboard/webapp/src/vite-env.d.ts +2 -0
  166. coord/dashboard/webapp/tailwind.config.js +140 -0
  167. coord/dashboard/webapp/tsconfig.json +25 -0
  168. coord/dashboard/webapp/tsconfig.node.json +11 -0
  169. coord/dashboard/webapp/vite.config.ts +71 -0
  170. coord/db.py +1076 -0
  171. coord/dead_end.py +332 -0
  172. coord/deploy/README.md +33 -0
  173. coord/deploy/coord-agent.service +89 -0
  174. coord/deploy/coord-db-backup.service +60 -0
  175. coord/deploy/coord-db-backup.sh +74 -0
  176. coord/deploy/coord-db-backup.timer +18 -0
  177. coord/deploy/coord-drive-queue.service +117 -0
  178. coord/deploy/coord-drive-queue.timer +39 -0
  179. coord/deploy/coord-notify.service +48 -0
  180. coord/deploy/coord-notify.timer +24 -0
  181. coord/deploy/coord-release-propagate.service +83 -0
  182. coord/deploy/coord-release-propagate.timer +38 -0
  183. coord/deploy/coord-release-window.service +119 -0
  184. coord/deploy/coord-release-window.timer +36 -0
  185. coord/deploy/coord-serve.service +82 -0
  186. coord/deploy/coord-web-dist-build.service +43 -0
  187. coord/deploy/coord-web-dist-build.timer +36 -0
  188. coord/deploy/coord-web.service +125 -0
  189. coord/deploy_manifest.py +80 -0
  190. coord/deploy_units.py +384 -0
  191. coord/deps.py +115 -0
  192. coord/diagnose.py +1623 -0
  193. coord/dispatch.py +1009 -0
  194. coord/dist_name.py +123 -0
  195. coord/drive.py +3101 -0
  196. coord/drive_queue.py +2298 -0
  197. coord/drive_state.py +870 -0
  198. coord/events.py +381 -0
  199. coord/failure_class.py +914 -0
  200. coord/filelock.py +168 -0
  201. coord/fleet_config_health.py +300 -0
  202. coord/freshness.py +206 -0
  203. coord/gate_a.py +469 -0
  204. coord/gate_b.py +411 -0
  205. coord/gate_snapshot.py +385 -0
  206. coord/gates.py +582 -0
  207. coord/github_ops.py +1954 -0
  208. coord/goal.py +125 -0
  209. coord/graph_health.py +348 -0
  210. coord/health/__init__.py +69 -0
  211. coord/health/aggregate.py +129 -0
  212. coord/health/checks/__init__.py +13 -0
  213. coord/health/checks/agent_install.py +280 -0
  214. coord/health/checks/cargo_targets.py +171 -0
  215. coord/health/checks/claude_binary.py +65 -0
  216. coord/health/checks/deploy_lane_facts.py +458 -0
  217. coord/health/checks/disk.py +99 -0
  218. coord/health/checks/fleet_board.py +89 -0
  219. coord/health/checks/fleet_deploy_lanes.py +469 -0
  220. coord/health/checks/fleet_phantom.py +69 -0
  221. coord/health/checks/fleet_unit_drift.py +151 -0
  222. coord/health/checks/graph.py +192 -0
  223. coord/health/checks/plan_usage.py +88 -0
  224. coord/health/checks/repo_state.py +161 -0
  225. coord/health/checks/spawned_coord.py +465 -0
  226. coord/health/checks/timer_active.py +254 -0
  227. coord/health/checks/toolchain.py +547 -0
  228. coord/health/checks/unit_drift.py +648 -0
  229. coord/health/checks/unit_enablement.py +171 -0
  230. coord/health/checks/worktrees.py +96 -0
  231. coord/health/cli.py +121 -0
  232. coord/health/context.py +106 -0
  233. coord/health/fleet_snapshot.py +477 -0
  234. coord/health/models.py +250 -0
  235. coord/health/pypi.py +231 -0
  236. coord/health/registry.py +240 -0
  237. coord/health/render.py +82 -0
  238. coord/health/units.py +60 -0
  239. coord/hooks.py +106 -0
  240. coord/housekeeping.py +204 -0
  241. coord/interactive.py +4286 -0
  242. coord/issue_store.py +1496 -0
  243. coord/liveness_auditor.py +293 -0
  244. coord/machine_pause.py +755 -0
  245. coord/merge_queue.py +4681 -0
  246. coord/milestone_chat.py +600 -0
  247. coord/milestone_dispatch.py +943 -0
  248. coord/milestone_gate.py +709 -0
  249. coord/milestone_order.py +840 -0
  250. coord/mock_author.py +334 -0
  251. coord/models.py +891 -0
  252. coord/network.py +269 -0
  253. coord/new_issue_chat.py +229 -0
  254. coord/notify.py +3226 -0
  255. coord/openapi.py +404 -0
  256. coord/overlap_fence.py +133 -0
  257. coord/parentage.py +200 -0
  258. coord/parentage_github.py +58 -0
  259. coord/pipeline.py +481 -0
  260. coord/plan_parser.py +266 -0
  261. coord/plans.py +543 -0
  262. coord/platform_paths.py +43 -0
  263. coord/pr_body_lint.py +67 -0
  264. coord/prereqs.py +533 -0
  265. coord/progress.py +425 -0
  266. coord/providers/__init__.py +683 -0
  267. coord/providers/base.py +218 -0
  268. coord/providers/claude.py +284 -0
  269. coord/providers/claude_pty.py +610 -0
  270. coord/providers/opencode.py +896 -0
  271. coord/reconcile.py +2233 -0
  272. coord/refine_chat.py +485 -0
  273. coord/release_cordon.py +525 -0
  274. coord/release_propagate.py +1176 -0
  275. coord/release_verify.py +777 -0
  276. coord/release_window.py +322 -0
  277. coord/reports.py +1643 -0
  278. coord/revalidate.py +1101 -0
  279. coord/review.py +3317 -0
  280. coord/scorecard.py +484 -0
  281. coord/serve_app.py +7192 -0
  282. coord/skills/update-issue/SKILL.md +93 -0
  283. coord/smoke.py +1030 -0
  284. coord/split_work.py +210 -0
  285. coord/stage_projection.py +650 -0
  286. coord/state.py +5720 -0
  287. coord/test_author.py +1064 -0
  288. coord/test_chat.py +352 -0
  289. coord/test_orchestrator.py +494 -0
  290. coord/test_report.py +178 -0
  291. coord/tui_release.py +271 -0
  292. coord/usage.py +753 -0
  293. coord/usage_limits.py +358 -0
  294. coord/usage_rollup.py +709 -0
  295. coord/worker_events.py +954 -0
@@ -0,0 +1,1930 @@
1
+ """Session/log inspection: `log`, `reattach`, `sessions`, `session`,
2
+ `watch`, `wait`, `pull-artifact`. Extracted from coord/cli.py (#747)."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import fnmatch
7
+ import os
8
+ import shutil
9
+ import socket
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+
15
+ import click
16
+ import httpx
17
+
18
+
19
+ from coord.commands._common import AGENT_PORT, _CONFIG_OPTION, _load_config
20
+
21
+ from coord.commands.review import (
22
+ _prompt_and_relay_review_verdict,
23
+ _prompt_and_relay_test_verdict,
24
+ )
25
+
26
+
27
+ @click.command(help="View claude -p output for a specific assignment.")
28
+ @click.argument("assignment_id")
29
+ @_CONFIG_OPTION
30
+ @click.option("--follow", "-f", is_flag=True, help="Follow output (like tail -f).")
31
+ @click.option(
32
+ "--machine",
33
+ "machine_filter",
34
+ default=None,
35
+ help="Fetch from this machine over the network (otherwise auto-resolved).",
36
+ )
37
+
38
+
39
+ @click.option("--local", "force_local", is_flag=True, help="Read from local ~/.coord/logs only.")
40
+ @click.option(
41
+ "--raw",
42
+ is_flag=True,
43
+ help="Dump the raw log (NDJSON for stream-json workers) instead of the human-readable rendering.",
44
+ )
45
+
46
+
47
+ def log(
48
+ assignment_id: str,
49
+ config_path: Path,
50
+ follow: bool,
51
+ machine_filter: str | None,
52
+ force_local: bool,
53
+ raw: bool,
54
+ ) -> None:
55
+ from coord.state import load_dispatched
56
+
57
+ target_machine = None
58
+ if not force_local:
59
+ if machine_filter:
60
+ cfg_loaded = _load_config(config_path)
61
+ target_machine = next(
62
+ (m for m in cfg_loaded.machines if m.name == machine_filter), None
63
+ )
64
+ if target_machine is None:
65
+ click.echo(
66
+ f"error: machine {machine_filter!r} not in coordinator.yml",
67
+ err=True,
68
+ )
69
+ sys.exit(2)
70
+ else:
71
+ record = next(
72
+ (r for r in load_dispatched() if r.get("assignment_id") == assignment_id),
73
+ None,
74
+ )
75
+ if record is not None:
76
+ cfg_loaded = _load_config(config_path)
77
+ target_machine = next(
78
+ (m for m in cfg_loaded.machines if m.name == record["machine_name"]),
79
+ None,
80
+ )
81
+
82
+ # #851: the local dispatched ledger only knows about assignments
83
+ # DISPATCHED FROM this machine — a thin client (or any machine that
84
+ # wasn't the dispatcher) has no record for it, even when the id is
85
+ # perfectly valid on the daemon board. Before falling through to the
86
+ # local log path (which would report "no log found" and make a
87
+ # healthy id look broken), check whether a log already exists here,
88
+ # and otherwise ask the daemon board for the assignment's own
89
+ # machine_name instead of requiring the operator to guess --machine.
90
+ if target_machine is None:
91
+ from coord.agent import DEFAULT_STATE_DIR
92
+
93
+ local_log_path = DEFAULT_STATE_DIR / "logs" / f"{assignment_id}.log"
94
+ if not local_log_path.exists():
95
+ target_machine = _resolve_log_machine_via_daemon(
96
+ assignment_id, config_path
97
+ )
98
+
99
+ if target_machine is None:
100
+ _log_local(assignment_id, follow, raw=raw)
101
+ return
102
+
103
+ _log_remote(target_machine, assignment_id, follow, raw=raw)
104
+
105
+
106
+ def _resolve_log_machine_via_daemon(assignment_id: str, config_path: Path):
107
+ """#851: fall back to the assignment's own ``machine_name`` (read from the
108
+ daemon board) when the local dispatched ledger has no record and no local
109
+ log exists — the #584-class thin-client routing gap `coord test-plan` has
110
+ too. Returns ``None`` (silently — the caller's existing "no log found"
111
+ error is the right message in that case) when no board_service is
112
+ configured, the daemon is unreachable, or the id doesn't resolve there
113
+ either."""
114
+ from coord.client import fetch_board_payload, resolve_board_service
115
+
116
+ svc = resolve_board_service()
117
+ if svc is None:
118
+ return None
119
+ try:
120
+ payload = fetch_board_payload(svc)
121
+ except Exception: # noqa: BLE001 — best-effort; fall through to local error
122
+ return None
123
+ row = next(
124
+ (
125
+ a
126
+ for a in payload.get("assignments", [])
127
+ if a.get("assignment_id") == assignment_id
128
+ ),
129
+ None,
130
+ )
131
+ if row is None:
132
+ return None
133
+ machine_name = row.get("machine_name")
134
+ if not machine_name:
135
+ return None
136
+ cfg = _load_config(config_path)
137
+ return next((m for m in cfg.machines if m.name == machine_name), None)
138
+
139
+
140
+ def _emit_log_text(text: str, *, raw: bool) -> None:
141
+ """Print *text* either as-is (raw mode or plain-text log) or rendered.
142
+
143
+ #1710 inventory: kept as a direct ``coord.worker_events`` import rather
144
+ than routed through ``provider.parse_log()``. This — and the three other
145
+ ``parse_event``/``render_event``/``format_important_event`` sites in this
146
+ module (``_log_local``'s follow branch, ``_watch_remote``, ``watch``) —
147
+ is display-only live/local/remote log-tailing for ``coord logs``/
148
+ ``coord watch``: it decodes the generic Anthropic-Messages-API NDJSON
149
+ envelope one event at a time and renders each into a human-readable
150
+ line, streaming as the log grows. ``Provider.parse_log()`` returns a
151
+ single terminal ``WorkerSummary`` once the whole log is available — no
152
+ per-event streaming-render primitive exists to route through without a
153
+ new abstract ``Provider`` method touching every concrete provider,
154
+ including the out-of-scope ``opencode.py`` (same reasoning as
155
+ ``coord.review``/``coord.plan_parser``'s equivalent kept-direct sites).
156
+ Degradation is not silent either way: any line that fails to parse as an
157
+ event already falls through to a verbatim ``click.echo`` of the raw
158
+ line below, so a non-claude worker's log renders as unrendered raw text
159
+ rather than vanishing — distinct from the silent-empty-result failure
160
+ mode #1710 targets in the coordinator-side analytics functions
161
+ (progress/usage/failure-class) this migration is scoped to.
162
+ """
163
+ if not text:
164
+ return
165
+ if raw:
166
+ click.echo(text, nl=False)
167
+ return
168
+
169
+ from coord.worker_events import parse_event, render_event
170
+
171
+ # Detect format heuristically: if the first non-blank, non-comment line
172
+ # looks like JSON, treat the whole chunk as stream-json. Otherwise pass
173
+ # through unchanged (plain-text fallback for legacy workers).
174
+ is_json = False
175
+ for raw_line in text.splitlines():
176
+ stripped = raw_line.lstrip()
177
+ if not stripped or stripped.startswith("#"):
178
+ continue
179
+ is_json = stripped.startswith("{")
180
+ break
181
+
182
+ if not is_json:
183
+ click.echo(text, nl=False)
184
+ return
185
+
186
+ turn_counter = [0]
187
+ for raw_line in text.splitlines():
188
+ stripped = raw_line.lstrip()
189
+ if not stripped:
190
+ continue
191
+ if stripped.startswith("#"):
192
+ # Pass through the agent's header comment lines unchanged so the
193
+ # user can still see argv and any pull-dep notes.
194
+ click.echo(raw_line)
195
+ continue
196
+ event = parse_event(raw_line)
197
+ if event is None:
198
+ # Couldn't parse — show verbatim so nothing is silently dropped.
199
+ click.echo(raw_line)
200
+ continue
201
+ rendered = render_event(event, turn_counter=turn_counter)
202
+ if rendered is not None:
203
+ click.echo(rendered)
204
+
205
+
206
+ def _log_local(assignment_id: str, follow: bool, *, raw: bool = False) -> None:
207
+ from coord.agent import DEFAULT_STATE_DIR
208
+ import time as _time
209
+
210
+ log_path = DEFAULT_STATE_DIR / "logs" / f"{assignment_id}.log"
211
+ if not log_path.exists():
212
+ click.echo(f"error: no log found for assignment {assignment_id!r}", err=True)
213
+ click.echo(f" looked in: {log_path}", err=True)
214
+ click.echo(
215
+ " hint: pass --machine NAME to fetch a remote log, or check `coord status`",
216
+ err=True,
217
+ )
218
+ sys.exit(1)
219
+
220
+ if follow:
221
+ # #1710 inventory: kept direct — see the note on `_emit_log_text` above.
222
+ from coord.worker_events import parse_event, render_event
223
+
224
+ is_json: bool | None = None
225
+ turn_counter = [0]
226
+
227
+ with open(log_path) as f:
228
+ while True:
229
+ line = f.readline()
230
+ if not line:
231
+ _time.sleep(0.3)
232
+ continue
233
+ if raw:
234
+ click.echo(line, nl=False)
235
+ continue
236
+ stripped = line.lstrip()
237
+ if is_json is None:
238
+ if not stripped:
239
+ continue
240
+ if stripped.startswith("#"):
241
+ click.echo(line, nl=False)
242
+ continue
243
+ is_json = stripped.startswith("{")
244
+ if not is_json:
245
+ click.echo(line, nl=False)
246
+ continue
247
+ if stripped.startswith("#"):
248
+ click.echo(line, nl=False)
249
+ continue
250
+ event = parse_event(line)
251
+ if event is None:
252
+ click.echo(line, nl=False)
253
+ continue
254
+ rendered = render_event(event, turn_counter=turn_counter)
255
+ if rendered is not None:
256
+ click.echo(rendered)
257
+ else:
258
+ _emit_log_text(log_path.read_text(), raw=raw)
259
+
260
+
261
+ def _log_remote(machine, assignment_id: str, follow: bool, *, raw: bool = False) -> None:
262
+ from coord.network import fetch_log
263
+ import time as _time
264
+
265
+ since = 0
266
+ status_code, body = fetch_log(machine, assignment_id, since=since)
267
+ if status_code == 404:
268
+ click.echo(
269
+ f"error: no log for assignment {assignment_id!r} on machine {machine.name!r}",
270
+ err=True,
271
+ )
272
+ sys.exit(1)
273
+ if status_code != 200:
274
+ click.echo(
275
+ f"error: fetching log from {machine.name} returned HTTP {status_code}",
276
+ err=True,
277
+ )
278
+ sys.exit(1)
279
+ _emit_log_text(body.decode("utf-8", errors="replace"), raw=raw)
280
+ since = len(body)
281
+
282
+ if not follow:
283
+ return
284
+
285
+ while True:
286
+ _time.sleep(0.5)
287
+ try:
288
+ status_code, body = fetch_log(machine, assignment_id, since=since)
289
+ except Exception as e: # noqa: BLE001 — surface network errors
290
+ click.echo(f"\n(stream interrupted: {e})", err=True)
291
+ return
292
+ if status_code != 200:
293
+ click.echo(f"\n(stream interrupted: HTTP {status_code})", err=True)
294
+ return
295
+ if body:
296
+ _emit_log_text(body.decode("utf-8", errors="replace"), raw=raw)
297
+ since += len(body)
298
+
299
+
300
+ @click.command(
301
+ "pull-artifact",
302
+ help=(
303
+ "Pull built artifacts from an agent machine after a work assignment "
304
+ "completes. The agent stashes files matching `artifact_paths` globs "
305
+ "configured in coordinator.yml before the worktree is removed. This "
306
+ "command queries the manifest and rsyncs the files locally.\n\n"
307
+ "Requires passwordless SSH access to the agent host (see "
308
+ "docs/AGENT_OPERATIONS.md for setup)."
309
+ ),
310
+ )
311
+
312
+
313
+ @click.argument("assignment_id")
314
+ @click.option(
315
+ "--into",
316
+ "dest_path",
317
+ type=click.Path(path_type=Path),
318
+ default=None,
319
+ help=(
320
+ "Local directory to rsync artifacts into. "
321
+ "Defaults to ~/.coord/artifacts/<repo>/<branch>/ (stable per-branch "
322
+ "path; pulling the same branch twice overwrites the same location)."
323
+ ),
324
+ )
325
+ @click.option(
326
+ "--only",
327
+ "only_patterns",
328
+ multiple=True,
329
+ default=(),
330
+ help=(
331
+ "Fetch only files whose name matches this glob (fnmatch-style, e.g. "
332
+ "'tui_submenu' or 'gtk_*'). Repeatable — a file matching ANY given "
333
+ "pattern is pulled. Use this to scope a pull to the one or two "
334
+ "binaries actually under test instead of the whole stash (#940)."
335
+ ),
336
+ )
337
+
338
+
339
+ @_CONFIG_OPTION
340
+ def pull_artifact(
341
+ assignment_id: str,
342
+ dest_path: Path | None,
343
+ only_patterns: tuple[str, ...],
344
+ config_path: Path,
345
+ ) -> None:
346
+ """Rsync build artifacts from the agent machine that ran ASSIGNMENT_ID."""
347
+ from coord.agent import _sanitize_branch, _slugify
348
+ from coord.client import resolve_board_service
349
+
350
+ cfg = _load_config(config_path)
351
+
352
+ # ── Look up (machine, repo, branch) ──────────────────────────────────
353
+ # #601: a thin client's local DB is retired, so resolve from the daemon's
354
+ # board when board_service is set (the artifact itself is still pulled from
355
+ # the agent host below — that works from any machine over Tailscale).
356
+ svc = resolve_board_service()
357
+ if svc is not None:
358
+ from coord.client import fetch_board_payload # noqa: PLC0415
359
+
360
+ try:
361
+ payload = fetch_board_payload(svc)
362
+ except Exception as exc: # noqa: BLE001
363
+ click.echo(
364
+ f"error: could not reach board service {svc.url}: {exc}", err=True
365
+ )
366
+ sys.exit(1)
367
+ row = next(
368
+ (
369
+ a
370
+ for a in payload.get("assignments", [])
371
+ if a.get("assignment_id") == assignment_id
372
+ ),
373
+ None,
374
+ )
375
+ else:
376
+ from coord.db import get_connection # noqa: PLC0415
377
+
378
+ conn = get_connection()
379
+ row = conn.execute(
380
+ "SELECT machine_name, repo_name, branch, issue_number, issue_title "
381
+ "FROM assignments WHERE assignment_id = ?",
382
+ (assignment_id,),
383
+ ).fetchone()
384
+
385
+ if row is None:
386
+ click.echo(f"error: assignment {assignment_id!r} not found in database", err=True)
387
+ sys.exit(1)
388
+
389
+ machine_name: str = row["machine_name"]
390
+ repo_name: str = row["repo_name"]
391
+ branch: str | None = row["branch"]
392
+ issue_number: int = row["issue_number"]
393
+ issue_title: str = row["issue_title"]
394
+
395
+ machine = next((m for m in cfg.machines if m.name == machine_name), None)
396
+ if machine is None:
397
+ click.echo(
398
+ f"error: machine {machine_name!r} (from DB) not found in coordinator.yml",
399
+ err=True,
400
+ )
401
+ sys.exit(1)
402
+
403
+ # If branch is not yet recorded in the DB (notify hasn't run yet),
404
+ # fall back to the deterministic name derived from issue_number + title.
405
+ if not branch:
406
+ branch = f"issue-{issue_number}-{_slugify(issue_title)}"
407
+
408
+ sanitized = _sanitize_branch(branch)
409
+
410
+ # ── Query the manifest endpoint ───────────────────────────────────────
411
+ url = f"http://{machine.host}:{AGENT_PORT}/artifact/{repo_name}/{sanitized}"
412
+ try:
413
+ resp = httpx.get(url, timeout=10)
414
+ except (httpx.HTTPError, httpx.TimeoutException, OSError) as e:
415
+ click.echo(
416
+ f"error: could not reach agent on {machine.host}:{AGENT_PORT}: {e}",
417
+ err=True,
418
+ )
419
+ sys.exit(1)
420
+
421
+ if resp.status_code == 404:
422
+ # #914: the agent host knows the ground truth (it just tried a lazy
423
+ # stash-on-pull before giving up) — echo its actual reason instead of
424
+ # guessing among GC / glob mismatch / missing config, which are
425
+ # frequently all wrong (e.g. the real cause is a missed interactive
426
+ # finalize with the built worktree still sitting on disk).
427
+ reason: str | None = None
428
+ try:
429
+ reason = resp.json().get("error")
430
+ except Exception: # noqa: BLE001 — malformed/non-JSON body, fall back below
431
+ reason = None
432
+ detail = reason or (
433
+ "Possible causes: stash has been GC'd (default TTL 3 days), "
434
+ "the build did not match any artifact_paths globs, "
435
+ "or artifact_paths is not configured for this repo."
436
+ )
437
+ click.echo(
438
+ f"error: no artifacts found for assignment {assignment_id!r} "
439
+ f"(repo={repo_name!r}, branch={sanitized!r}) on {machine.name}.\n"
440
+ f"{detail}",
441
+ err=True,
442
+ )
443
+ sys.exit(1)
444
+
445
+ if resp.status_code != 200:
446
+ click.echo(
447
+ f"error: agent returned HTTP {resp.status_code}: {resp.text[:200]}",
448
+ err=True,
449
+ )
450
+ sys.exit(1)
451
+
452
+ manifest = resp.json()
453
+ files = manifest.get("files", [])
454
+ if not files:
455
+ click.echo(
456
+ f"No artifact files in stash for {assignment_id!r}. "
457
+ "The build may have produced no files matching artifact_paths.",
458
+ err=True,
459
+ )
460
+ sys.exit(1)
461
+
462
+ # #940: scope to just the requested file(s) when --only is given — a
463
+ # stash can hold dozens of ~100MB debug binaries (e.g. every `tui_*`/
464
+ # `gtk_*`/`msv_*` example in quadraui) when a tester only needs one or
465
+ # two of them.
466
+ if only_patterns:
467
+ matched = [
468
+ f
469
+ for f in files
470
+ if any(fnmatch.fnmatch(f["name"], pat) for pat in only_patterns)
471
+ ]
472
+ if not matched:
473
+ available = ", ".join(f["name"] for f in files)
474
+ click.echo(
475
+ f"error: no files in the stash for {assignment_id!r} match "
476
+ f"--only {list(only_patterns)!r}.\nAvailable: {available}",
477
+ err=True,
478
+ )
479
+ sys.exit(1)
480
+ files = matched
481
+
482
+ total_bytes = sum(f["size"] for f in files)
483
+ built_by = manifest.get("built_by_assignment_id") or assignment_id
484
+ click.echo(
485
+ f"Found {len(files)} artifact(s) ({total_bytes:,} bytes) "
486
+ f"on {machine.name} (built by {built_by}):"
487
+ )
488
+ for f in files:
489
+ click.echo(f" {f['name']} ({f['size']:,} bytes)")
490
+
491
+ # ── Determine destination ─────────────────────────────────────────────
492
+ if dest_path is None:
493
+ # Default to a stable per-branch location so pulling the same branch
494
+ # twice overwrites the same local path rather than creating new
495
+ # directories each time.
496
+ dest_path = Path.home() / ".coord" / "artifacts" / repo_name / sanitized
497
+ dest_path.mkdir(parents=True, exist_ok=True)
498
+
499
+ # ── Local-machine short-circuit ───────────────────────────────────────
500
+ # When the artifact was built on the machine running this command (e.g.
501
+ # the coordinator/TUI host), the agent already stashed the files locally
502
+ # at ~/.coord/artifacts/<repo>/<branch>/ — there is nothing to fetch, and
503
+ # rsync-over-ssh to our own hostname FAILS ("Permission denied" — no
504
+ # self-ssh key), which surfaced as a meaningless pull error in the TUI.
505
+ # Copy locally if the destination differs; otherwise it is a no-op.
506
+ local_hostname = socket.gethostname().split(".")[0].lower()
507
+ is_local = (
508
+ machine.name.lower() == local_hostname
509
+ or machine.host.split(".")[0].lower() == local_hostname
510
+ )
511
+ if is_local:
512
+ src_dir = Path.home() / ".coord" / "artifacts" / repo_name / sanitized
513
+ # Nothing to copy when the destination IS the stash directory
514
+ # (default `--into`) — the (possibly --only-filtered) files
515
+ # already live there regardless of filtering.
516
+ if src_dir.resolve() == dest_path.resolve():
517
+ click.echo(f"\nArtifacts already local at: {dest_path}")
518
+ return
519
+ click.echo(f"\nCopying local artifacts {src_dir}/ → {dest_path}/")
520
+ for item in src_dir.iterdir():
521
+ if item.name == ".assignment_id":
522
+ continue
523
+ if only_patterns and not any(
524
+ fnmatch.fnmatch(item.name, pat) for pat in only_patterns
525
+ ):
526
+ continue
527
+ target = dest_path / item.name
528
+ if item.is_dir():
529
+ shutil.copytree(item, target, dirs_exist_ok=True)
530
+ else:
531
+ shutil.copy2(item, target)
532
+ click.echo(f"\nArtifacts saved to: {dest_path}")
533
+ return
534
+
535
+ # ── rsync ─────────────────────────────────────────────────────────────
536
+ remote = f"{machine.host}:~/.coord/artifacts/{repo_name}/{sanitized}/"
537
+ cmd = [
538
+ "rsync", "-az", "--info=progress2",
539
+ # BatchMode=yes: ssh must NEVER prompt. When this runs under the TUI,
540
+ # an ssh passphrase/password/changed-host-key prompt opens /dev/tty
541
+ # directly — bypassing the nulled stdin — and hijacks the TUI's
542
+ # terminal (screen corruption, unresponsive to 'q'). BatchMode makes
543
+ # ssh fail fast instead; the TUI captures stderr and toasts it.
544
+ # accept-new: auto-accept a *new* host key on first contact so the
545
+ # pull stays non-interactive on a fresh agent machine (safe on
546
+ # Tailscale, where the network is already authenticated).
547
+ "-e", "ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new",
548
+ "--exclude=.assignment_id",
549
+ ]
550
+ # #940: when --only is given, turn the transfer into an allowlist —
551
+ # rsync filter rules are first-match-wins, so each --include must be
552
+ # listed before the trailing --exclude=* that drops everything else
553
+ # (including files that simply don't match any requested pattern).
554
+ if only_patterns:
555
+ cmd += [f"--include={pat}" for pat in only_patterns]
556
+ cmd += ["--exclude=*"]
557
+ cmd += [remote, str(dest_path) + "/"]
558
+ click.echo(f"\nRsyncing {remote} → {dest_path}/")
559
+ # start_new_session + stdin=DEVNULL: belt-and-braces so no descendant
560
+ # (ssh) can claim the controlling terminal even if BatchMode is somehow
561
+ # bypassed — see the TTY-hijack note on the rsync command above.
562
+ result = subprocess.run(cmd, stdin=subprocess.DEVNULL, start_new_session=True)
563
+
564
+ if result.returncode != 0:
565
+ click.echo(
566
+ f"error: rsync exited {result.returncode}. "
567
+ "Ensure passwordless SSH is set up between coordinator and agent "
568
+ "(see docs/AGENT_OPERATIONS.md).",
569
+ err=True,
570
+ )
571
+ sys.exit(1)
572
+
573
+ click.echo(f"\nArtifacts saved to: {dest_path}")
574
+
575
+
576
+ @click.command(help="Show current session state.")
577
+ def session() -> None:
578
+ from coord.state import load_session
579
+
580
+ data = load_session()
581
+ if data is None:
582
+ click.echo("No session state found. Start one with coord assign.")
583
+ return
584
+
585
+ clean = data.get("clean_shutdown", True)
586
+ started = data.get("started_at", "?")
587
+
588
+ if clean:
589
+ ended = data.get("ended_at", "?")
590
+ completed = len(data.get("completed_this_session", []))
591
+ issues = len(data.get("issues_closed", []))
592
+ cost = data.get("total_cost_usd", 0)
593
+ click.echo(f"Last session: {started} → {ended}")
594
+ click.echo(f" {completed} assignments, {issues} issues, ${cost:.2f}")
595
+ else:
596
+ click.echo(f"Session in progress (started {started})")
597
+ click.echo(f" clean_shutdown: false (crash recovery may be needed)")
598
+ click.echo(f" Run: coord resume")
599
+
600
+
601
+ def _prune_dead_sessions(enriched: "list[dict]", config_path: "Path") -> None:
602
+ """Kill dead-pane sessions and finalize their board assignments.
603
+
604
+ Called from ``coord sessions --prune`` (#491). Only processes LOCAL
605
+ dead-pane sessions (``pane_dead == "1"`` whose machine resolves to this
606
+ host, or sessions with no machine name). Remote dead sessions are handled
607
+ by ``reap_stale_remote_interactive_sessions`` (called from ``coord
608
+ reconcile``).
609
+
610
+ For each dead-pane local session the function:
611
+
612
+ 1. Resolves the worktree path (``~/.coord/worktrees/<assignment_id>``).
613
+ 2. Pushes any commits with ``git push -u origin HEAD`` (best-effort).
614
+ 3. Counts commits ahead of the base branch to decide the terminal status.
615
+ 4. Updates the DB: ``advisory`` (0 commits) or ``failed`` (≥1 commits).
616
+ 5. Removes the worktree (best-effort).
617
+ 6. Kills the tmux session with ``tmux kill-session -t coord-<id>``.
618
+ """
619
+ from coord.state import COORD_DIR, get_connection # noqa: PLC0415
620
+ from coord.agent import _commits_ahead # noqa: PLC0415
621
+ from coord.interactive import tmux_session_name # noqa: PLC0415
622
+
623
+ _local_hn = socket.gethostname().split(".")[0].lower()
624
+ worktrees_dir = COORD_DIR / "worktrees"
625
+ now = time.time()
626
+
627
+ # Load config so we can resolve repo_path for git worktree remove.
628
+ try:
629
+ cfg = _load_config(config_path)
630
+ machines_by_name = {m.name: m for m in cfg.machines}
631
+ repos_by_name = {r.name: r for r in cfg.repos}
632
+ except Exception: # noqa: BLE001
633
+ machines_by_name = {}
634
+ repos_by_name = {}
635
+
636
+ pruned = 0
637
+ for s in enriched:
638
+ if s.get("pane_dead") != "1":
639
+ continue # only dead-pane sessions
640
+
641
+ # Only prune local sessions — remote ones stay for the reconcile reaper.
642
+ raw_machine = s.get("machine")
643
+ if raw_machine is not None:
644
+ mobj = machines_by_name.get(raw_machine)
645
+ if mobj is not None:
646
+ _is_local = (
647
+ mobj.name.lower() == _local_hn
648
+ or mobj.host.split(".")[0].lower() == _local_hn
649
+ )
650
+ else:
651
+ # Machine name set but not in config — treat as remote to be safe.
652
+ _is_local = False
653
+ if not _is_local:
654
+ click.echo(
655
+ f" skip {s['session_name']} (remote machine: {raw_machine})"
656
+ )
657
+ continue
658
+
659
+ assignment_id = s["assignment_id"]
660
+ sname = tmux_session_name(assignment_id)
661
+ wt_path = worktrees_dir / assignment_id
662
+
663
+ # Resolve repo_path for worktree removal.
664
+ repo_path_val: str | None = None
665
+ machine_obj = machines_by_name.get(s.get("machine") or "")
666
+ if machine_obj is not None and s.get("repo_name"):
667
+ rp = machine_obj.repo_path(s["repo_name"])
668
+ if rp:
669
+ repo_path_val = str(Path(rp).expanduser())
670
+
671
+ # 2. Push commits (best-effort) so the work is not lost.
672
+ commits: int | None = None
673
+ if wt_path.exists():
674
+ repo = repos_by_name.get(s.get("repo_name") or "")
675
+ base_branch = repo.default_branch if repo is not None else "main"
676
+ commits = _commits_ahead(wt_path, base_branch)
677
+ if commits:
678
+ try:
679
+ subprocess.run(
680
+ ["git", "push", "-u", "origin", "HEAD"],
681
+ cwd=str(wt_path),
682
+ capture_output=True,
683
+ timeout=30.0,
684
+ )
685
+ click.echo(
686
+ f" pushed {s['session_name']} ({commits} commit(s))"
687
+ )
688
+ except Exception: # noqa: BLE001
689
+ click.echo(
690
+ f" push failed {s['session_name']} (worktree will be removed)"
691
+ )
692
+
693
+ terminal_status = "advisory" if commits == 0 else "failed"
694
+
695
+ # 3. Update DB.
696
+ try:
697
+ conn = get_connection()
698
+ conn.execute(
699
+ "UPDATE assignments SET status=?, finished_at=? "
700
+ "WHERE assignment_id=? AND status IN ('running', 'pending')",
701
+ (terminal_status, now, assignment_id),
702
+ )
703
+ conn.commit()
704
+ except Exception: # noqa: BLE001
705
+ pass
706
+
707
+ # 4. Remove worktree (best-effort). #1693: both branches go through
708
+ # `_safe_remove_worktree` — the old no-repo_path branch rmtree'd
709
+ # with no git attempt and no base-checkout guard whatsoever.
710
+ if wt_path.exists():
711
+ from coord.agent import _safe_remove_worktree # noqa: PLC0415
712
+
713
+ try:
714
+ _safe_remove_worktree(
715
+ Path(repo_path_val) if repo_path_val is not None else None,
716
+ wt_path,
717
+ sandbox_root=worktrees_dir,
718
+ )
719
+ except Exception: # noqa: BLE001 - never abort the reap sweep
720
+ pass
721
+
722
+ # 5. Kill the tmux session.
723
+ try:
724
+ subprocess.run(
725
+ ["tmux", "kill-session", "-t", sname],
726
+ capture_output=True,
727
+ timeout=5.0,
728
+ )
729
+ except Exception: # noqa: BLE001
730
+ pass
731
+
732
+ click.echo(
733
+ f" pruned {s['session_name']} "
734
+ f"({terminal_status}, {commits or 0} commit(s))"
735
+ )
736
+ pruned += 1
737
+
738
+ if pruned == 0:
739
+ click.echo("No dead-pane local sessions to prune.")
740
+ else:
741
+ click.echo(f"\nPruned {pruned} dead-pane session(s).")
742
+
743
+
744
+ @click.command(
745
+ "sessions",
746
+ help=(
747
+ "List running interactive sessions hosted in tmux (coord-* named sessions). "
748
+ "Use --json for machine-readable output (consumed by coord-tui on startup)."
749
+ ),
750
+ )
751
+
752
+
753
+ @click.option(
754
+ "--json",
755
+ "output_json",
756
+ is_flag=True,
757
+ default=False,
758
+ help="Output as JSON (consumed by coord-tui startup check).",
759
+ )
760
+
761
+
762
+ @click.option(
763
+ "--remote",
764
+ is_flag=True,
765
+ default=False,
766
+ help=(
767
+ "Also enumerate coord-* sessions on REMOTE fleet machines over ssh+tmux "
768
+ "(#486 Leg 4). Parallelised; bounded by a 5 s per-host probe."
769
+ ),
770
+ )
771
+
772
+
773
+ @click.option(
774
+ "--prune",
775
+ is_flag=True,
776
+ default=False,
777
+ help=(
778
+ "Kill dead-pane sessions (where claude has exited but tmux is still up) "
779
+ "and finalize their assignments. Only local sessions are pruned; remote "
780
+ "dead sessions are handled by ``coord reconcile`` (#491)."
781
+ ),
782
+ )
783
+
784
+
785
+ @click.option(
786
+ "--reap-merged",
787
+ "reap_merged",
788
+ is_flag=True,
789
+ default=False,
790
+ help=(
791
+ "Kill detached interactive MERGE sessions whose board row has reached "
792
+ "'merged' status (#1110). Triggers the same logic as the daemon's "
793
+ "auto-reap tick. Only DETACHED sessions are killed; attached sessions "
794
+ "are skipped so the operator is never surprised."
795
+ ),
796
+ )
797
+
798
+
799
+ @_CONFIG_OPTION
800
+ def sessions_cmd(
801
+ output_json: bool, remote: bool, prune: bool, reap_merged: bool, config_path: Path
802
+ ) -> None:
803
+ """List live coord-* tmux sessions with their assignment metadata."""
804
+ import json as _json # noqa: PLC0415
805
+
806
+ from coord.interactive import ( # noqa: PLC0415
807
+ TmuxHost,
808
+ list_coord_tmux_sessions,
809
+ TMUX_SESSION_PREFIX,
810
+ )
811
+ from coord.state import get_connection # noqa: PLC0415
812
+
813
+ # Track which machine each session lives on (None => local) so the TUI /
814
+ # operator knows where a reattach lands.
815
+ session_machine: dict[str, str | None] = {}
816
+ raw: list[dict[str, str]] = []
817
+ for _s in list_coord_tmux_sessions():
818
+ raw.append(_s)
819
+ session_machine.setdefault(_s["session_name"], None)
820
+
821
+ # #486 Leg 4: optionally probe REMOTE machines so the TUI can offer reattach
822
+ # to a session launched on another machine. A local session always wins on
823
+ # name collision. Down machines fail within the 5 s per-host cap; probes
824
+ # run in parallel so total wall-clock ≈ the slowest single host.
825
+ if remote:
826
+ import concurrent.futures as _cf # noqa: PLC0415
827
+
828
+ try:
829
+ _cfg = _load_config(config_path)
830
+ _local_hn = socket.gethostname().split(".")[0].lower()
831
+ _remotes = [
832
+ m for m in _cfg.machines
833
+ if not (
834
+ m.name.lower() == _local_hn
835
+ or m.host.split(".")[0].lower() == _local_hn
836
+ )
837
+ ]
838
+ except Exception: # noqa: BLE001
839
+ _remotes = []
840
+
841
+ def _probe(machine: object) -> tuple[str, list[dict[str, str]]]:
842
+ try:
843
+ # batch=True: this is a background sweep — NEVER prompt for an
844
+ # ssh passphrase (that would hijack the TUI's terminal at
845
+ # startup, #486 Leg 4 regression). No warm ControlMaster / agent
846
+ # key ⇒ the probe just fails and the machine reports no sessions.
847
+ found = list_coord_tmux_sessions(
848
+ host=TmuxHost(ssh_target=machine.host, batch=True) # type: ignore[attr-defined]
849
+ )
850
+ return machine.name, found # type: ignore[attr-defined]
851
+ except Exception: # noqa: BLE001
852
+ return machine.name, [] # type: ignore[attr-defined]
853
+
854
+ if _remotes:
855
+ with _cf.ThreadPoolExecutor(max_workers=min(8, len(_remotes))) as _ex:
856
+ for _mname, _found in _ex.map(_probe, _remotes):
857
+ for _s in _found:
858
+ if _s["session_name"] in session_machine:
859
+ continue # local (or earlier) session wins
860
+ raw.append(_s)
861
+ session_machine[_s["session_name"]] = _mname
862
+
863
+ enriched: list[dict] = []
864
+
865
+ # #601: resolve session→assignment metadata (issue_number/repo_name/...) so
866
+ # the TUI can match a live session to its issue row and offer reattach. On a
867
+ # thin client the local DB is retired, so read from the daemon's board when
868
+ # board_service is set; otherwise use the local DB singleton (acquired once —
869
+ # get_connection() is a module-level singleton).
870
+ from coord.client import resolve_board_service # noqa: PLC0415
871
+
872
+ _svc = resolve_board_service()
873
+ _remote_by_aid: dict[str, dict] = {}
874
+ _db_conn = None
875
+ if _svc is not None:
876
+ try:
877
+ from coord.client import fetch_board_payload # noqa: PLC0415
878
+
879
+ _remote_by_aid = {
880
+ a.get("assignment_id"): a
881
+ for a in fetch_board_payload(_svc).get("assignments", [])
882
+ }
883
+ except Exception: # noqa: BLE001
884
+ _remote_by_aid = {}
885
+ else:
886
+ try:
887
+ _db_conn = get_connection()
888
+ except Exception: # noqa: BLE001
889
+ _db_conn = None
890
+
891
+ for s in raw:
892
+ session_name = s["session_name"]
893
+ assignment_id = session_name[len(TMUX_SESSION_PREFIX):]
894
+ issue_number: int | None = None
895
+ repo_name: str | None = None
896
+ issue_title: str | None = None
897
+
898
+ machine_name: str | None = None
899
+ if _svc is not None:
900
+ a = _remote_by_aid.get(assignment_id)
901
+ if a is not None:
902
+ issue_number = a.get("issue_number")
903
+ repo_name = a.get("repo_name")
904
+ issue_title = a.get("issue_title")
905
+ machine_name = a.get("machine_name")
906
+ elif _db_conn is not None:
907
+ try:
908
+ row = _db_conn.execute(
909
+ "SELECT issue_number, repo_name, issue_title, machine_name "
910
+ "FROM assignments WHERE assignment_id=?",
911
+ (assignment_id,),
912
+ ).fetchone()
913
+ if row is not None:
914
+ issue_number = row["issue_number"] if hasattr(row, "keys") else row[0]
915
+ repo_name = row["repo_name"] if hasattr(row, "keys") else row[1]
916
+ issue_title = row["issue_title"] if hasattr(row, "keys") else row[2]
917
+ machine_name = row["machine_name"] if hasattr(row, "keys") else row[3]
918
+ except Exception: # noqa: BLE001
919
+ pass
920
+
921
+ # Prefer the DB's machine_name (authoritative); fall back to the host
922
+ # the session was discovered on (#486 Leg 4).
923
+ machine = machine_name or session_machine.get(session_name)
924
+ # pane_dead: "1" when the claude process inside the pane has exited but
925
+ # the tmux session is still up (detach-and-abandon case). "0" while
926
+ # running. Missing for remote sessions that pre-date the #491 field.
927
+ pane_dead = s.get("pane_dead", "0")
928
+ # attached: True when a client is currently attached to the tmux
929
+ # session (#1031) — mirrors ``coord terminal list --json``'s
930
+ # ``#{session_attached}`` handling. Missing (falls back to False)
931
+ # for sessions discovered by a probe that pre-dates this field.
932
+ attached = bool(s.get("attached", False))
933
+ enriched.append(
934
+ {
935
+ "session_name": session_name,
936
+ "assignment_id": assignment_id,
937
+ "issue_number": issue_number,
938
+ "repo_name": repo_name,
939
+ "issue_title": issue_title,
940
+ "machine": machine,
941
+ "pane_dead": pane_dead,
942
+ "attached": attached,
943
+ }
944
+ )
945
+
946
+ if output_json:
947
+ if prune:
948
+ click.echo(
949
+ "Warning: --prune is ignored when --json is used; "
950
+ "run 'coord sessions --prune' separately.",
951
+ err=True,
952
+ )
953
+ click.echo(_json.dumps({"sessions": enriched}))
954
+ return
955
+
956
+ # ── --prune: kill dead-pane sessions and finalize their assignments ──────
957
+ # Run before the human-readable list so output flows: list → prune results.
958
+
959
+ if prune:
960
+ _prune_dead_sessions(enriched, config_path)
961
+ return
962
+
963
+ # ── --reap-merged: kill detached MERGE sessions that have reached 'merged' ─
964
+ # Routes through the daemon endpoint when a board service is configured
965
+ # (the board lives on the daemon), or calls the tick function directly.
966
+
967
+ if reap_merged:
968
+ from coord.client import resolve_board_service # noqa: PLC0415
969
+
970
+ _svc = resolve_board_service()
971
+ if _svc is not None:
972
+ import httpx # noqa: PLC0415
973
+
974
+ _auth = {"Authorization": f"Bearer {_svc.token}"} if _svc.token else {}
975
+ try:
976
+ resp = httpx.post(
977
+ f"{_svc.url}/reap-merged-sessions",
978
+ json={},
979
+ headers=_auth,
980
+ timeout=30,
981
+ )
982
+ resp.raise_for_status()
983
+ reaped = resp.json().get("reaped", [])
984
+ except Exception as _e: # noqa: BLE001
985
+ click.echo(f"reap-merged: daemon request failed: {_e}", err=True)
986
+ return
987
+ else:
988
+ from coord.serve_app import _reap_merged_sessions_tick # noqa: PLC0415
989
+
990
+ try:
991
+ cfg = _load_config(config_path)
992
+ except Exception as _e: # noqa: BLE001
993
+ click.echo(f"reap-merged: could not load config: {_e}", err=True)
994
+ return
995
+ reaped = _reap_merged_sessions_tick(cfg)
996
+
997
+ if reaped:
998
+ for aid in reaped:
999
+ click.echo(f" reaped: {aid}")
1000
+ click.echo(f"\nReaped {len(reaped)} merged session(s).")
1001
+ else:
1002
+ click.echo("No detached merged MERGE sessions to reap.")
1003
+ return
1004
+
1005
+ # ── Human-readable output ─────────────────────────────────────────────────
1006
+
1007
+ if not enriched:
1008
+ click.echo("No running interactive sessions.")
1009
+ return
1010
+
1011
+ # Separate alive sessions from dead-pane ones so the operator can see at
1012
+ # a glance which need attention.
1013
+ _alive = [s for s in enriched if s.get("pane_dead") != "1"]
1014
+ _dead = [s for s in enriched if s.get("pane_dead") == "1"]
1015
+
1016
+ def _print_session(s: dict, *, dead: bool = False) -> None:
1017
+ issue_part = f"#{s['issue_number']}" if s["issue_number"] else "(unknown issue)"
1018
+ repo_part = s["repo_name"] or "(unknown repo)"
1019
+ title_part = f" — {s['issue_title']}" if s["issue_title"] else ""
1020
+ machine_part = f" @{s['machine']}" if s.get("machine") else ""
1021
+ dead_tag = " [DEAD PANE — claude exited]" if dead else ""
1022
+ click.echo(
1023
+ f" {s['session_name']} {repo_part} {issue_part}"
1024
+ f"{machine_part}{title_part}{dead_tag}"
1025
+ )
1026
+ # Two labeled options instead of a pipe that reads as a shell pipe.
1027
+ click.echo(
1028
+ f" Option 1 (recommended): coord reattach {s['assignment_id']}"
1029
+ )
1030
+ click.echo(
1031
+ f" (runs the finalize backstop on exit; preserves board state)"
1032
+ )
1033
+ click.echo(
1034
+ f" Option 2 (raw): tmux attach-session -t {s['session_name']}"
1035
+ )
1036
+
1037
+ for s in _alive:
1038
+ _print_session(s, dead=False)
1039
+
1040
+ if _dead:
1041
+ click.echo("")
1042
+ click.echo(
1043
+ f" {len(_dead)} dead-pane session(s) — claude has exited, "
1044
+ "tmux session still up:"
1045
+ )
1046
+ for s in _dead:
1047
+ _print_session(s, dead=True)
1048
+ click.echo("")
1049
+ click.echo(
1050
+ " Run: coord sessions --prune to finalize and kill dead sessions."
1051
+ )
1052
+
1053
+
1054
+ @click.command(
1055
+ "reattach",
1056
+ help=(
1057
+ "Reattach to a running interactive session (tmux) and finalize when done. "
1058
+ "The session must have been started with --interactive (tmux required)."
1059
+ ),
1060
+ )
1061
+
1062
+
1063
+ @click.argument("assignment_id")
1064
+ @_CONFIG_OPTION
1065
+ def reattach(assignment_id: str, config_path: Path) -> None:
1066
+ """Reattach to a live coord-* tmux session.
1067
+
1068
+ When the session ends (operator closes ``claude`` or types ``/exit``),
1069
+ the #466 git-floor backstop runs — same as after a normal interactive
1070
+ session exit — so the board always gets a terminal state recorded.
1071
+
1072
+ When the session is **already dead** before the user attempts to reattach
1073
+ (e.g. the tmux session was killed externally), the backstop also runs to
1074
+ release the claim and garbage-collect the orphaned worktree, unblocking a
1075
+ subsequent ``coord assign --interactive`` on the same issue.
1076
+ """
1077
+ import time as _time # noqa: PLC0415
1078
+
1079
+ from coord.interactive import ( # noqa: PLC0415
1080
+ TMUX_ATTACH_WARNING,
1081
+ TmuxHost,
1082
+ finalize_interactive_exit,
1083
+ finalize_remote_interactive_exit,
1084
+ tmux_available,
1085
+ tmux_session_alive,
1086
+ tmux_session_name,
1087
+ )
1088
+
1089
+ if not tmux_available():
1090
+ click.echo(" error: tmux is not available on this machine.", err=True)
1091
+ sys.exit(1)
1092
+
1093
+ sname = tmux_session_name(assignment_id)
1094
+
1095
+ # ── Look up assignment metadata (needed for both live and dead paths) ────
1096
+ # Done BEFORE the alive check so the dead-before-attach path can also
1097
+ # run finalize_interactive_exit and release the claim.
1098
+ repo_name_val: str | None = None
1099
+ repo_github_val: str | None = None
1100
+ issue_number_val: int | None = None
1101
+ machine_name_val: str | None = None
1102
+ base_branch_val: str = "main"
1103
+ artifact_paths_val: list[str] = []
1104
+ # #486 Leg 4: the assignment type + branch decide how a REMOTE session
1105
+ # finalizes — a read-only review records DB-only; a fix pushes its remote
1106
+ # worktree's commits back to origin.
1107
+ assignment_type_val: str | None = None
1108
+ branch_val: str | None = None
1109
+ # #923: for smoke sessions, the WORK assignment id (smoke_of) is stored
1110
+ # as review_of_assignment_id on the smoke row — needed for the test-
1111
+ # verdict backstop so we can check and record test_state on the WORK row.
1112
+ review_of_assignment_id_val: str | None = None
1113
+
1114
+ # #601: resolve the assignment metadata that finalize_interactive_exit needs
1115
+ # (repo/issue/machine/type/branch). On a thin client the local DB is retired,
1116
+ # so read from the daemon's board when board_service is set — otherwise the
1117
+ # metadata is all null and the session can never be finalized off its blue
1118
+ # "running" box. Local DB path is unchanged.
1119
+ from coord.client import resolve_board_service # noqa: PLC0415
1120
+
1121
+ _svc = resolve_board_service()
1122
+ if _svc is not None:
1123
+ try:
1124
+ from coord.client import fetch_board_payload # noqa: PLC0415
1125
+
1126
+ row = next(
1127
+ (
1128
+ a
1129
+ for a in fetch_board_payload(_svc).get("assignments", [])
1130
+ if a.get("assignment_id") == assignment_id
1131
+ ),
1132
+ None,
1133
+ )
1134
+ except Exception: # noqa: BLE001
1135
+ row = None
1136
+ if row is not None:
1137
+ issue_number_val = row.get("issue_number")
1138
+ repo_name_val = row.get("repo_name")
1139
+ repo_github_val = row.get("repo_github")
1140
+ machine_name_val = row.get("machine_name")
1141
+ assignment_type_val = row.get("type")
1142
+ _br = row.get("branch")
1143
+ branch_val = str(_br) if _br else None
1144
+ # #923: review_of_assignment_id = smoke_of (the work being tested)
1145
+ _roi = row.get("review_of_assignment_id")
1146
+ review_of_assignment_id_val = str(_roi) if _roi else None
1147
+ else:
1148
+ try:
1149
+ from coord.state import get_connection as _gc # noqa: PLC0415
1150
+ conn = _gc()
1151
+ row = conn.execute(
1152
+ "SELECT issue_number, repo_name, repo_github, machine_name, "
1153
+ "type, branch, review_of_assignment_id "
1154
+ "FROM assignments WHERE assignment_id=?",
1155
+ (assignment_id,),
1156
+ ).fetchone()
1157
+ if row is not None:
1158
+ def _col(r: object, key: str, idx: int) -> object: # noqa: ANN001
1159
+ return r[key] if hasattr(r, "keys") else r[idx] # type: ignore[index]
1160
+
1161
+ issue_number_val = _col(row, "issue_number", 0) # type: ignore[assignment]
1162
+ repo_name_val = str(_col(row, "repo_name", 1))
1163
+ repo_github_val = str(_col(row, "repo_github", 2))
1164
+ machine_name_val = str(_col(row, "machine_name", 3))
1165
+ _at = _col(row, "type", 4)
1166
+ assignment_type_val = str(_at) if _at is not None else None
1167
+ _br = _col(row, "branch", 5)
1168
+ branch_val = str(_br) if _br else None
1169
+ # #923: review_of_assignment_id = smoke_of (the work being tested)
1170
+ _roi = _col(row, "review_of_assignment_id", 6)
1171
+ review_of_assignment_id_val = str(_roi) if _roi else None
1172
+ except Exception: # noqa: BLE001
1173
+ pass
1174
+
1175
+ # Reconstruct the worktree path and repo_path from coordinator.yml.
1176
+ # worktree_path is always ~/.coord/worktrees/<assignment_id> per agent.py.
1177
+ from coord.state import COORD_DIR as _COORD_DIR # noqa: PLC0415
1178
+ worktree_path = str(_COORD_DIR / "worktrees" / assignment_id)
1179
+
1180
+ repo_path_val: str | None = None
1181
+ # #486 Leg 4: remote-vs-local routing for the attach + finalize. Defaults
1182
+ # to local so an unresolved machine preserves the original local behavior.
1183
+ is_local_session: bool = True
1184
+ ssh_target_val: str | None = None
1185
+ remote_repo_sh: str | None = None
1186
+ try:
1187
+ cfg = _load_config(config_path)
1188
+ # Get default_branch + artifact_paths from the repo config.
1189
+ if repo_name_val:
1190
+ repo_cfg_obj = next(
1191
+ (r for r in cfg.repos if r.name == repo_name_val), None
1192
+ )
1193
+ if repo_cfg_obj:
1194
+ base_branch_val = repo_cfg_obj.default_branch or "main"
1195
+ artifact_paths_val = list(repo_cfg_obj.artifact_paths or [])
1196
+ # Get repo_path + locality from machine config.
1197
+ if machine_name_val and repo_name_val:
1198
+ machine_obj = next(
1199
+ (m for m in cfg.machines if m.name == machine_name_val), None
1200
+ )
1201
+ if machine_obj:
1202
+ rp = machine_obj.repo_path(repo_name_val)
1203
+ if rp:
1204
+ repo_path_val = str(Path(rp).expanduser())
1205
+ # Raw `~/...` → `$HOME/...` so the REMOTE shell (not the
1206
+ # local one) expands it during the push-back finalize.
1207
+ remote_repo_sh = (
1208
+ "$HOME/" + rp[2:]
1209
+ if rp.startswith("~/")
1210
+ else ("$HOME" if rp == "~" else rp)
1211
+ )
1212
+ ssh_target_val = machine_obj.host
1213
+ _local_hn = socket.gethostname().split(".")[0].lower()
1214
+ is_local_session = (
1215
+ machine_obj.name.lower() == _local_hn
1216
+ or machine_obj.host.split(".")[0].lower() == _local_hn
1217
+ )
1218
+ except Exception: # noqa: BLE001
1219
+ pass
1220
+
1221
+ # The tmux seam: local calls are plain `tmux …`; remote calls become
1222
+ # `ssh -t <mux opts> <host> tmux …` (multiplexed via _SSH_MUX_OPTS).
1223
+ _tmux_host = (
1224
+ TmuxHost(ssh_target=ssh_target_val)
1225
+ if (not is_local_session and ssh_target_val)
1226
+ else TmuxHost(ssh_target=None)
1227
+ )
1228
+ _remote_worktree_sh = "$HOME/.coord/worktrees/" + assignment_id
1229
+
1230
+ # ── Shared helper: run finalize backstop and echo results ────────────────
1231
+ def _run_finalize(exit_code: int, started_at: float | None = None) -> None:
1232
+ if not (repo_name_val and repo_github_val and issue_number_val):
1233
+ click.echo(
1234
+ " (assignment metadata not found — skipping git-floor backstop)",
1235
+ err=True,
1236
+ )
1237
+ return
1238
+ try:
1239
+ # ── REMOTE session (#486 Leg 4) ──────────────────────────────
1240
+ # The local git-floor backstop can't see a remote worktree, so a
1241
+ # remote FIX pushes its commits back over ssh; everything else
1242
+ # records a DB-only terminal state (a review is read-only; remote
1243
+ # non-review push-back is deferred — #494/#486d).
1244
+ if not is_local_session:
1245
+ # A fix/work/plan session wrote commits in a remote worktree on
1246
+ # a known branch → push them back (#486d). (A review is
1247
+ # read-only and falls through to the DB-only branch below.)
1248
+ #
1249
+ # #557 defensive backstop: if branch_val is None (rework/fix
1250
+ # assignment was created before the record_dispatched_assignment
1251
+ # branch-persist fix landed), try to derive it from the remote
1252
+ # worktree's HEAD so we don't strand commits.
1253
+ _branch_val = branch_val
1254
+ if (
1255
+ assignment_type_val in ("fix", "work", "plan")
1256
+ and not _branch_val
1257
+ and ssh_target_val
1258
+ ):
1259
+ try:
1260
+ import subprocess as _sp # noqa: PLC0415
1261
+ from coord.interactive import ( # noqa: PLC0415
1262
+ _SSH_MUX_OPTS as _MUX,
1263
+ )
1264
+ _probe = _sp.run(
1265
+ [
1266
+ "ssh", *_MUX, ssh_target_val,
1267
+ f"git -C {_remote_worktree_sh}"
1268
+ " rev-parse --abbrev-ref HEAD 2>/dev/null",
1269
+ ],
1270
+ capture_output=True,
1271
+ text=True,
1272
+ timeout=15,
1273
+ )
1274
+ if _probe.returncode == 0:
1275
+ _derived = _probe.stdout.strip()
1276
+ if _derived and _derived != "HEAD":
1277
+ click.echo(
1278
+ f" note: branch not in DB — derived from "
1279
+ f"remote worktree HEAD: {_derived}",
1280
+ err=True,
1281
+ )
1282
+ _branch_val = _derived
1283
+ except Exception: # noqa: BLE001
1284
+ pass
1285
+ if (
1286
+ assignment_type_val in ("fix", "work", "plan")
1287
+ and _branch_val
1288
+ and remote_repo_sh
1289
+ and ssh_target_val
1290
+ ):
1291
+ fr = finalize_remote_interactive_exit(
1292
+ assignment_id=assignment_id,
1293
+ repo_name=repo_name_val,
1294
+ repo_github=repo_github_val,
1295
+ issue_number=int(issue_number_val), # type: ignore[arg-type]
1296
+ machine_name=machine_name_val or "unknown",
1297
+ ssh_target=ssh_target_val,
1298
+ remote_worktree_sh=_remote_worktree_sh,
1299
+ remote_repo_sh=remote_repo_sh,
1300
+ branch=_branch_val,
1301
+ base_branch=base_branch_val,
1302
+ exit_code=exit_code,
1303
+ started_at=started_at,
1304
+ artifact_paths=artifact_paths_val,
1305
+ )
1306
+ if fr.already_recorded:
1307
+ click.echo(
1308
+ " result recorded via `coord report-result`; remote "
1309
+ "backstop did not overwrite"
1310
+ )
1311
+ else:
1312
+ click.echo(
1313
+ f" remote backstop: status={fr.terminal_status} "
1314
+ f"commits_ahead={fr.commits_ahead} pushed={fr.push_ok}"
1315
+ )
1316
+ if not fr.push_ok:
1317
+ click.echo(
1318
+ f" warning: remote push failed: {fr.push_error}",
1319
+ err=True,
1320
+ )
1321
+ click.echo(
1322
+ f" fix commits preserved in {_remote_worktree_sh} "
1323
+ f"on {ssh_target_val} (worktree NOT removed)",
1324
+ err=True,
1325
+ )
1326
+ return
1327
+ # Read-only review (or a remote write we can't push back):
1328
+ # DB-only terminal state so the row doesn't linger as a phantom
1329
+ # 'running' worker holding the claim.
1330
+ fr2 = finalize_interactive_exit(
1331
+ assignment_id=assignment_id,
1332
+ repo_name=repo_name_val,
1333
+ repo_github=repo_github_val,
1334
+ issue_number=int(issue_number_val), # type: ignore[arg-type]
1335
+ machine_name=machine_name_val or "unknown",
1336
+ worktree_path=None,
1337
+ base_branch=base_branch_val,
1338
+ exit_code=exit_code,
1339
+ started_at=started_at,
1340
+ log_path=None,
1341
+ repo_path=None,
1342
+ # #617: the review ran on the REMOTE host, so its Claude
1343
+ # transcript lives there. Hand the transcript-floor the
1344
+ # ssh target so it recovers the verdict + findings from the
1345
+ # session's OWN host instead of scanning this (blind) one —
1346
+ # the #607 failure where a reattach-from-elsewhere dropped
1347
+ # the findings and left only a verdict-less operator prompt.
1348
+ ssh_target=ssh_target_val,
1349
+ # #1256: a no-op for every type except smoke (it only
1350
+ # acts when a matching snapshot marker exists) — safe to
1351
+ # pass unconditionally on the read-only/DB-only branch.
1352
+ smoke_repo_path=remote_repo_sh,
1353
+ # #1351: only meaningful for a smoke session (it names
1354
+ # the WORK row the Test transcript-floor records
1355
+ # against) — a no-op (None) for every other type.
1356
+ smoke_of=(
1357
+ review_of_assignment_id_val
1358
+ if assignment_type_val == "smoke" else None
1359
+ ),
1360
+ )
1361
+ if fr2.test_verdict_recovered:
1362
+ click.echo(
1363
+ f" test verdict {fr2.test_verdict_recovered!r} "
1364
+ "recovered from the remote session transcript and "
1365
+ f"recorded on the work assignment "
1366
+ f"{review_of_assignment_id_val} (#1351)"
1367
+ )
1368
+ if fr2.smoke_restored_paths:
1369
+ click.echo(
1370
+ " live checkout restored (#1256): reverted "
1371
+ + ", ".join(fr2.smoke_restored_paths)
1372
+ )
1373
+ if fr2.smoke_restore_error:
1374
+ click.echo(
1375
+ f" warning: live-checkout restore failed: "
1376
+ f"{fr2.smoke_restore_error}",
1377
+ err=True,
1378
+ )
1379
+ if fr2.already_recorded:
1380
+ if fr2.terminal_status == "transcript-floor":
1381
+ click.echo(
1382
+ " review verdict + findings recovered from the remote "
1383
+ "session transcript and recorded (#617)"
1384
+ )
1385
+ else:
1386
+ click.echo(
1387
+ " result recorded via `coord report-result`; backstop "
1388
+ "did not overwrite"
1389
+ )
1390
+ else:
1391
+ click.echo(
1392
+ f" backstop: status={fr2.terminal_status} (remote, DB-only)"
1393
+ )
1394
+ if assignment_type_val == "review":
1395
+ # #486d: relay the review verdict here — the remote
1396
+ # session can't write this DB — instead of leaving it
1397
+ # a manual `coord report-result` step.
1398
+ _prompt_and_relay_review_verdict(
1399
+ assignment_id=assignment_id,
1400
+ repo_name=repo_name_val,
1401
+ repo_github=repo_github_val,
1402
+ issue_number=int(issue_number_val), # type: ignore[arg-type]
1403
+ machine_name=machine_name_val or "unknown",
1404
+ verdict_cmd_hint=(
1405
+ f" coord report-result --assignment "
1406
+ f"{assignment_id} --status done "
1407
+ "--verdict approve|request-changes"
1408
+ ),
1409
+ )
1410
+ elif (
1411
+ assignment_type_val == "smoke"
1412
+ and review_of_assignment_id_val
1413
+ ):
1414
+ # #923 (review round 1): mirror the review-verdict relay
1415
+ # above for the REMOTE branch — a thin-client `coord
1416
+ # reattach` to a smoke session dispatched on a different
1417
+ # machine is a supported path (ssh-reattach is not
1418
+ # local-only even though --smoke-of dispatch is), so
1419
+ # this branch must also carry the test-verdict backstop
1420
+ # or the exact #923 bug (silent verdict loss, grey Test
1421
+ # box, blocked merge gate) reproduces here.
1422
+ _tv_cmd_remote = (
1423
+ f" coord test --passed "
1424
+ f"{review_of_assignment_id_val} # all good\n"
1425
+ f" coord test --fail "
1426
+ f"{review_of_assignment_id_val} --reason "
1427
+ '"<story>" # broken'
1428
+ )
1429
+ try:
1430
+ if not _prompt_and_relay_test_verdict(
1431
+ work_assignment_id=review_of_assignment_id_val,
1432
+ smoke_assignment_id=assignment_id,
1433
+ repo_name=repo_name_val,
1434
+ repo_github=repo_github_val,
1435
+ issue_number=int(issue_number_val), # type: ignore[arg-type]
1436
+ machine_name=machine_name_val or "unknown",
1437
+ verdict_cmd_hint=_tv_cmd_remote,
1438
+ ):
1439
+ click.echo(
1440
+ " smoke session ended with no test "
1441
+ "verdict recorded — the merge gate stays "
1442
+ "blocked until a verdict is reported."
1443
+ )
1444
+ except Exception as _tv_exc: # noqa: BLE001
1445
+ click.echo(
1446
+ " warning: test-verdict backstop failed: "
1447
+ f"{_tv_exc}",
1448
+ err=True,
1449
+ )
1450
+ elif assignment_type_val is not None:
1451
+ click.echo(
1452
+ " note: no branch recorded for this remote session "
1453
+ "— any commits remain on its remote worktree; push "
1454
+ "them manually.",
1455
+ err=True,
1456
+ )
1457
+ return
1458
+
1459
+ # ── LOCAL session ─────────────────────────────────────────────
1460
+ finalize_result = finalize_interactive_exit(
1461
+ assignment_id=assignment_id,
1462
+ repo_name=repo_name_val,
1463
+ repo_github=repo_github_val,
1464
+ issue_number=int(issue_number_val), # type: ignore[arg-type]
1465
+ machine_name=machine_name_val or "unknown",
1466
+ worktree_path=worktree_path,
1467
+ base_branch=base_branch_val,
1468
+ exit_code=exit_code,
1469
+ started_at=started_at,
1470
+ log_path=None,
1471
+ repo_path=repo_path_val,
1472
+ artifact_paths=artifact_paths_val,
1473
+ branch=branch_val,
1474
+ # #1256: repo_path_val IS the live checkout for a smoke
1475
+ # session (no worktree — the smoke agent worked directly in
1476
+ # it). A no-op for every other type: it only acts when a
1477
+ # matching snapshot marker exists, which only smoke dispatch
1478
+ # ever creates.
1479
+ smoke_repo_path=repo_path_val,
1480
+ # #1351: only meaningful for a smoke session (it names the
1481
+ # WORK row the Test transcript-floor records against) — a
1482
+ # no-op (None) for every other type.
1483
+ smoke_of=(
1484
+ review_of_assignment_id_val
1485
+ if assignment_type_val == "smoke" else None
1486
+ ),
1487
+ )
1488
+ if finalize_result.test_verdict_recovered:
1489
+ click.echo(
1490
+ f" test verdict {finalize_result.test_verdict_recovered!r} "
1491
+ "recovered from the session transcript and recorded on "
1492
+ f"the work assignment {review_of_assignment_id_val} "
1493
+ "(#1351)"
1494
+ )
1495
+ if finalize_result.smoke_restored_paths:
1496
+ click.echo(
1497
+ " live checkout restored (#1256): reverted "
1498
+ + ", ".join(finalize_result.smoke_restored_paths)
1499
+ )
1500
+ if finalize_result.smoke_restore_error:
1501
+ click.echo(
1502
+ f" warning: live-checkout restore failed: "
1503
+ f"{finalize_result.smoke_restore_error}",
1504
+ err=True,
1505
+ )
1506
+ if finalize_result.already_recorded:
1507
+ click.echo(
1508
+ " result already recorded via `coord report-result`; "
1509
+ "backstop did not overwrite",
1510
+ )
1511
+ else:
1512
+ click.echo(
1513
+ f" backstop: status={finalize_result.terminal_status} "
1514
+ f"commits_ahead={finalize_result.commits_ahead}"
1515
+ )
1516
+ if not finalize_result.push_ok:
1517
+ click.echo(
1518
+ f" warning: git push failed: {finalize_result.push_error}",
1519
+ err=True,
1520
+ )
1521
+ # #923: Test-verdict backstop for smoke sessions. The smoke
1522
+ # session's own report-result (above) records the session row,
1523
+ # but the WORK row's test_state is what unlocks the merge gate.
1524
+ # If the agent didn't run `coord test`, prompt the operator here.
1525
+ if (
1526
+ assignment_type_val == "smoke"
1527
+ and review_of_assignment_id_val
1528
+ ):
1529
+ _tv_cmd = (
1530
+ f" coord test --passed {review_of_assignment_id_val}"
1531
+ " # all good\n"
1532
+ f" coord test --fail {review_of_assignment_id_val}"
1533
+ " --reason \"<story>\" # broken"
1534
+ )
1535
+ try:
1536
+ if not _prompt_and_relay_test_verdict(
1537
+ work_assignment_id=review_of_assignment_id_val,
1538
+ smoke_assignment_id=assignment_id,
1539
+ repo_name=repo_name_val,
1540
+ repo_github=repo_github_val,
1541
+ issue_number=int(issue_number_val), # type: ignore[arg-type]
1542
+ machine_name=machine_name_val or "unknown",
1543
+ verdict_cmd_hint=_tv_cmd,
1544
+ ):
1545
+ click.echo(
1546
+ " smoke session ended with no test verdict "
1547
+ "recorded — the merge gate stays blocked until "
1548
+ "a verdict is reported."
1549
+ )
1550
+ except Exception as _tv_exc: # noqa: BLE001
1551
+ click.echo(
1552
+ f" warning: test-verdict backstop failed: {_tv_exc}",
1553
+ err=True,
1554
+ )
1555
+ except Exception as exc: # noqa: BLE001
1556
+ click.echo(
1557
+ f" warning: backstop failed to record completion: {exc}",
1558
+ err=True,
1559
+ )
1560
+
1561
+ # ── Dead-before-attach: session was killed externally ────────────────────
1562
+ # Run finalize here to release the claim and remove the orphaned worktree
1563
+ # so the operator can immediately re-dispatch with --interactive.
1564
+ if not tmux_session_alive(sname, host=_tmux_host):
1565
+ click.echo(f" session {sname!r} is not alive (it may have ended while you were away).")
1566
+ _run_finalize(exit_code=1)
1567
+ sys.exit(0)
1568
+
1569
+ # ── Attach ───────────────────────────────────────────────────────────────
1570
+ _where = "local" if is_local_session else f"{ssh_target_val} (ssh)"
1571
+ click.echo(f" Attaching to {sname} on {_where} …")
1572
+ # #1102: same kill-vs-detach warning as the initial attach in
1573
+ # coord/interactive.py::_launch_via_tmux — a wrong keystroke here is
1574
+ # just as destructive as at first launch.
1575
+ click.echo(TMUX_ATTACH_WARNING.rstrip("\n"))
1576
+ # #1102 fix-iteration-1 (live human-attended smoke FAILED): the attach
1577
+ # call a few lines below switches the terminal to tmux's alt-screen
1578
+ # buffer immediately, covering this banner before the operator can read
1579
+ # it — confirmed live at the twin call site in
1580
+ # coord/interactive.py::_launch_via_tmux. Require an explicit
1581
+ # acknowledgment here too. Best-effort: a piped/non-interactive stdin
1582
+ # (tests, scripted callers) raises immediately and is swallowed so this
1583
+ # never hangs a headless run.
1584
+ try:
1585
+ input("Press Enter to attach (read the warning above first)... ")
1586
+ except (EOFError, KeyboardInterrupt, OSError):
1587
+ pass
1588
+
1589
+ started_at = _time.time()
1590
+ try:
1591
+ import subprocess as _sp # noqa: PLC0415
1592
+ if not is_local_session:
1593
+ # Remote: ssh -t into the machine and attach its tmux session
1594
+ # (multiplexed via _SSH_MUX_OPTS). No nesting concern — the remote
1595
+ # tmux server is distinct from any local one we're sitting in.
1596
+ _reattach_cmd = list(
1597
+ _tmux_host.cmd(["attach-session", "-t", sname], tty=True)
1598
+ )
1599
+ elif os.environ.get("TMUX"):
1600
+ # Local + already inside tmux: `attach-session` refuses to nest
1601
+ # ("sessions should be nested with care") and exits 1; use
1602
+ # `switch-client` to move the current client to the session instead.
1603
+ _reattach_cmd = ["tmux", "switch-client", "-t", sname]
1604
+ else:
1605
+ _reattach_cmd = ["tmux", "attach-session", "-t", sname]
1606
+ result = _sp.run(_reattach_cmd)
1607
+ exit_code = result.returncode
1608
+ except (Exception, KeyboardInterrupt): # noqa: BLE001
1609
+ exit_code = 1
1610
+
1611
+ # After attach returns: check if session ended or user detached.
1612
+ if tmux_session_alive(sname, host=_tmux_host):
1613
+ click.echo(
1614
+ f"\n Session is still running. "
1615
+ f"Reattach later with: coord reattach {assignment_id}"
1616
+ )
1617
+ sys.exit(0)
1618
+
1619
+ # ── Session ended — run the finalize backstop ─────────────────────────
1620
+ _run_finalize(exit_code=exit_code, started_at=started_at)
1621
+ sys.exit(exit_code)
1622
+
1623
+
1624
+ @click.command(help="Block until an assignment completes (poll the agent server).")
1625
+ @click.argument("assignment_id")
1626
+ @_CONFIG_OPTION
1627
+ @click.option("--interval", default=30, show_default=True, type=int, help="Seconds between polls.")
1628
+ @click.option("--timeout", default=1800, show_default=True, type=int, help="Max seconds to wait.")
1629
+ def wait(assignment_id: str, config_path: Path, interval: int, timeout: int) -> None:
1630
+ from coord.state import load_dispatched
1631
+
1632
+ cfg = _load_config(config_path)
1633
+
1634
+ # Find which machine this assignment was dispatched to
1635
+ record = next(
1636
+ (r for r in load_dispatched() if r.get("assignment_id") == assignment_id),
1637
+ None,
1638
+ )
1639
+ if record is None:
1640
+ click.echo(f"error: assignment {assignment_id!r} not found in dispatched records", err=True)
1641
+ sys.exit(2)
1642
+
1643
+ machine_name = record["machine_name"]
1644
+ machine = next((m for m in cfg.machines if m.name == machine_name), None)
1645
+ if machine is None:
1646
+ click.echo(
1647
+ f"error: machine {machine_name!r} (from dispatched record) not in coordinator.yml",
1648
+ err=True,
1649
+ )
1650
+ sys.exit(2)
1651
+
1652
+ url = f"http://{machine.host}:{AGENT_PORT}/status"
1653
+ deadline = time.monotonic() + timeout
1654
+
1655
+ while time.monotonic() < deadline:
1656
+ try:
1657
+ resp = httpx.get(url, timeout=10)
1658
+ data = resp.json()
1659
+ except (httpx.HTTPError, httpx.TimeoutException, OSError) as e:
1660
+ click.echo(f"warning: could not reach agent on {machine.name}: {e}", err=True)
1661
+ time.sleep(interval)
1662
+ continue
1663
+
1664
+ # Check completed list
1665
+ for c in data.get("completed", []):
1666
+ if c.get("id") == assignment_id:
1667
+ exit_code = c.get("exit_code", -1)
1668
+ branch = c.get("branch", "unknown")
1669
+ started = c.get("started_at", 0)
1670
+ finished = c.get("finished_at", 0)
1671
+ duration = finished - started if finished and started else 0
1672
+ mins, secs = divmod(int(duration), 60)
1673
+
1674
+ if exit_code == 0:
1675
+ click.echo(f"Assignment {assignment_id} completed (exit 0, {mins}m {secs}s)")
1676
+ click.echo(f" branch: {branch}")
1677
+ sys.exit(0)
1678
+ else:
1679
+ click.echo(f"Assignment {assignment_id} failed (exit {exit_code}, {mins}m {secs}s)")
1680
+ error = c.get("error", "")
1681
+ if error:
1682
+ click.echo(f" error: {error}")
1683
+ click.echo(f" branch: {branch}")
1684
+ sys.exit(1)
1685
+
1686
+ # Check active list — if not there either, it vanished
1687
+ active_ids = [a.get("id") for a in data.get("active", [])]
1688
+ if assignment_id not in active_ids:
1689
+ click.echo(
1690
+ f"Assignment {assignment_id} not found on agent (not active or completed)",
1691
+ err=True,
1692
+ )
1693
+ sys.exit(2)
1694
+
1695
+ time.sleep(interval)
1696
+
1697
+ # Timeout
1698
+ click.echo(f"Timed out after {timeout}s waiting for {assignment_id}", err=True)
1699
+ sys.exit(3)
1700
+
1701
+
1702
+ def _tail_log(log_path: Path, interval: float = 1.0):
1703
+ """Yield new lines from *log_path* as they are written. Like tail -f.
1704
+
1705
+ Stops yielding when the generator is closed by the caller.
1706
+ """
1707
+ with open(log_path) as f:
1708
+ while True:
1709
+ line = f.readline()
1710
+ if line:
1711
+ yield line.rstrip("\n")
1712
+ else:
1713
+ time.sleep(interval)
1714
+
1715
+
1716
+ def _watch_remote(
1717
+ machine,
1718
+ assignment_id: str,
1719
+ *,
1720
+ show_all: bool,
1721
+ interval: float,
1722
+ timeout: int,
1723
+ ) -> None:
1724
+ """Watch a remote assignment by polling the agent's /logs/{id} endpoint.
1725
+
1726
+ Streams log bytes from the remote agent and routes them through the same
1727
+ worker_events rendering pipeline used by local watch. Never returns —
1728
+ exits via sys.exit().
1729
+
1730
+ #1710 inventory: kept direct — see the note on `_emit_log_text` above.
1731
+ """
1732
+ from coord.network import fetch_log
1733
+ from coord.worker_events import format_important_event, parse_event, render_event
1734
+
1735
+ deadline = time.monotonic() + timeout
1736
+ turn_counter: list[int] = [0]
1737
+ since = 0
1738
+ is_error = False
1739
+
1740
+ while True:
1741
+ if time.monotonic() > deadline:
1742
+ click.echo(
1743
+ f"error: timed out after {timeout}s waiting for result", err=True
1744
+ )
1745
+ sys.exit(3)
1746
+
1747
+ try:
1748
+ status_code, body = fetch_log(machine, assignment_id, since=since)
1749
+ except Exception as e: # noqa: BLE001
1750
+ click.echo(
1751
+ f"warning: could not reach agent on {machine.name}: {e}", err=True
1752
+ )
1753
+ time.sleep(interval)
1754
+ continue
1755
+
1756
+ if status_code == 404:
1757
+ # Assignment not started yet or log unavailable — keep waiting.
1758
+ time.sleep(interval)
1759
+ continue
1760
+
1761
+ if status_code != 200:
1762
+ click.echo(
1763
+ f"error: fetching log from {machine.name} returned HTTP {status_code}",
1764
+ err=True,
1765
+ )
1766
+ sys.exit(1)
1767
+
1768
+ done = False
1769
+ if body:
1770
+ for raw_line in body.decode("utf-8", errors="replace").splitlines():
1771
+ stripped = raw_line.lstrip()
1772
+ if not stripped:
1773
+ continue
1774
+ if stripped.startswith("#"):
1775
+ if show_all:
1776
+ click.echo(raw_line)
1777
+ continue
1778
+
1779
+ event = parse_event(raw_line)
1780
+ if event is None:
1781
+ if show_all:
1782
+ click.echo(raw_line)
1783
+ continue
1784
+
1785
+ if show_all:
1786
+ rendered = render_event(event, turn_counter=turn_counter)
1787
+ if rendered is not None:
1788
+ click.echo(rendered)
1789
+ else:
1790
+ important = format_important_event(event)
1791
+ if important is not None:
1792
+ click.echo(important)
1793
+
1794
+ if event.type == "result":
1795
+ is_error = bool(event.raw.get("is_error", False))
1796
+ done = True
1797
+ break
1798
+
1799
+ since += len(body)
1800
+
1801
+ if done:
1802
+ break
1803
+
1804
+ time.sleep(interval)
1805
+
1806
+ sys.exit(1 if is_error else 0)
1807
+
1808
+
1809
+ @click.command(help="Watch a running assignment — filtered live log output.")
1810
+ @click.argument("assignment_id")
1811
+ @_CONFIG_OPTION
1812
+ @click.option("--all", "show_all", is_flag=True, help="Show all events, not just important ones.")
1813
+ @click.option(
1814
+ "--interval",
1815
+ default=1.0,
1816
+ type=float,
1817
+ show_default=True,
1818
+ help="Poll interval in seconds.",
1819
+ )
1820
+
1821
+
1822
+ @click.option(
1823
+ "--timeout",
1824
+ default=1800,
1825
+ type=int,
1826
+ show_default=True,
1827
+ help="Max seconds to wait for the assignment to finish.",
1828
+ )
1829
+
1830
+
1831
+ def watch(
1832
+ assignment_id: str,
1833
+ config_path: Path,
1834
+ show_all: bool,
1835
+ interval: float,
1836
+ timeout: int,
1837
+ ) -> None:
1838
+ from coord.state import load_dispatched
1839
+
1840
+ # #1710 inventory: kept direct — see the note on `_emit_log_text` above.
1841
+ from coord.worker_events import format_important_event, parse_event, render_event
1842
+
1843
+ cfg = _load_config(config_path)
1844
+
1845
+ # ── Find the dispatched record ───────────────────────────────────────
1846
+ record = next(
1847
+ (r for r in load_dispatched() if r.get("assignment_id") == assignment_id),
1848
+ None,
1849
+ )
1850
+ if record is None:
1851
+ click.echo(f"error: assignment {assignment_id!r} not found", err=True)
1852
+ sys.exit(2)
1853
+
1854
+ # ── Detect whether the assignment lives on a remote agent ────────────
1855
+ machine_name = record.get("machine_name", "")
1856
+ machine = next((m for m in cfg.machines if m.name == machine_name), None)
1857
+ hostname = socket.gethostname().split(".")[0]
1858
+ is_remote = machine is not None and (
1859
+ machine.name != hostname
1860
+ and machine.host.split(".")[0] != hostname
1861
+ )
1862
+
1863
+ if is_remote:
1864
+ _watch_remote(
1865
+ machine,
1866
+ assignment_id,
1867
+ show_all=show_all,
1868
+ interval=interval,
1869
+ timeout=timeout,
1870
+ )
1871
+ return # _watch_remote exits via sys.exit
1872
+
1873
+ # ── Locate the log file ──────────────────────────────────────────────
1874
+ from coord.agent import DEFAULT_STATE_DIR
1875
+
1876
+ log_path = DEFAULT_STATE_DIR / "logs" / f"{assignment_id}.log"
1877
+
1878
+ if not log_path.exists():
1879
+ click.echo(f"Waiting for log file: {log_path}")
1880
+ deadline_appear = time.monotonic() + 60
1881
+ while not log_path.exists() and time.monotonic() < deadline_appear:
1882
+ time.sleep(1)
1883
+ if not log_path.exists():
1884
+ click.echo(
1885
+ f"error: log file never appeared: {log_path}", err=True
1886
+ )
1887
+ sys.exit(2)
1888
+
1889
+ # ── Tail and filter ──────────────────────────────────────────────────
1890
+ deadline = time.monotonic() + timeout
1891
+ turn_counter = [0]
1892
+ is_error = False
1893
+
1894
+ for raw_line in _tail_log(log_path, interval=interval):
1895
+ if time.monotonic() > deadline:
1896
+ click.echo(
1897
+ f"error: timed out after {timeout}s waiting for result", err=True
1898
+ )
1899
+ sys.exit(3)
1900
+
1901
+ stripped = raw_line.lstrip()
1902
+ if not stripped:
1903
+ continue
1904
+ # Pass through comment/header lines always
1905
+ if stripped.startswith("#"):
1906
+ if show_all:
1907
+ click.echo(raw_line)
1908
+ continue
1909
+
1910
+ event = parse_event(raw_line)
1911
+ if event is None:
1912
+ if show_all:
1913
+ click.echo(raw_line)
1914
+ continue
1915
+
1916
+ if show_all:
1917
+ rendered = render_event(event, turn_counter=turn_counter)
1918
+ if rendered is not None:
1919
+ click.echo(rendered)
1920
+ else:
1921
+ important = format_important_event(event)
1922
+ if important is not None:
1923
+ click.echo(important)
1924
+
1925
+ # Detect terminal result event and exit
1926
+ if event.type == "result":
1927
+ is_error = bool(event.raw.get("is_error", False))
1928
+ break
1929
+
1930
+ sys.exit(1 if is_error else 0)