code-coordinator 0.5.46__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
|
@@ -0,0 +1,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)
|