code-coordinator 0.5.46__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
coord/progress.py
ADDED
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""Parse worker progress signals from log output.
|
|
2
|
+
|
|
3
|
+
Two paths are supported:
|
|
4
|
+
|
|
5
|
+
* **stream-json**: when the worker was launched with
|
|
6
|
+
``--output-format stream-json --verbose`` (the new default), each log line
|
|
7
|
+
is a structured event. We delegate to the assignment's resolved
|
|
8
|
+
:class:`~coord.providers.base.Provider` (``provider.parse_log()`` — #1710),
|
|
9
|
+
defaulting to :class:`~coord.providers.claude.ClaudeProvider` (which itself
|
|
10
|
+
delegates to :mod:`coord.worker_events`) when no provider is known — so
|
|
11
|
+
``parse_progress`` produces byte-identical output to before #1710 for every
|
|
12
|
+
existing (claude) caller that doesn't pass ``provider``/``provider_name``.
|
|
13
|
+
* **plain text** (legacy): we fall back to the old ``STATUS:``/``STUCK:``
|
|
14
|
+
regex scan for backwards compatibility with logs from older agents and
|
|
15
|
+
for non-claude worker commands used in tests. This path is
|
|
16
|
+
provider-agnostic by construction — any worker that emits ``STATUS:``/
|
|
17
|
+
``STUCK:`` lines in plain text is handled the same way regardless of which
|
|
18
|
+
provider ran it.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import re
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import TYPE_CHECKING
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from coord.providers.base import Provider
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
STATUS_RE = re.compile(r"^STATUS:\s*(.+)$", re.MULTILINE)
|
|
33
|
+
STUCK_RE = re.compile(r"^STUCK:\s*(.+)$", re.MULTILINE)
|
|
34
|
+
CONFIDENCE_RE = re.compile(r"confidence:\s*(high|medium|low)", re.IGNORECASE)
|
|
35
|
+
|
|
36
|
+
# #252: workers emit a SMOKE_TESTS block before exiting. The whole block
|
|
37
|
+
# is captured (greedy across newlines) and parsed below. Optional `(none
|
|
38
|
+
# — change is internal)` form folds to an empty list.
|
|
39
|
+
_SMOKE_BLOCK_RE = re.compile(
|
|
40
|
+
r"SMOKE_TESTS:\s*(.*?)\s*END_SMOKE_TESTS",
|
|
41
|
+
re.DOTALL | re.IGNORECASE,
|
|
42
|
+
)
|
|
43
|
+
_SMOKE_NONE_RE = re.compile(
|
|
44
|
+
r"^\(?\s*none\b.*?(?:internal|change)?\s*\)?\s*$",
|
|
45
|
+
re.IGNORECASE,
|
|
46
|
+
)
|
|
47
|
+
_SMOKE_BULLET_RE = re.compile(r"^\s*[-*]\s+(.+?)\s*$")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class WorkerProgress:
|
|
52
|
+
updates: list[str] = field(default_factory=list)
|
|
53
|
+
stuck: str | None = None
|
|
54
|
+
warnings: list[str] = field(default_factory=list)
|
|
55
|
+
latest_confidence: str | None = None
|
|
56
|
+
|
|
57
|
+
def to_dict(self) -> dict:
|
|
58
|
+
return {
|
|
59
|
+
"updates": self.updates,
|
|
60
|
+
"stuck": self.stuck,
|
|
61
|
+
"warnings": self.warnings,
|
|
62
|
+
"latest_confidence": self.latest_confidence,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_progress(
|
|
67
|
+
log_path: str | Path,
|
|
68
|
+
tail_bytes: int = 32_768,
|
|
69
|
+
*,
|
|
70
|
+
provider_name: str | None = None,
|
|
71
|
+
provider: "Provider | None" = None,
|
|
72
|
+
) -> WorkerProgress:
|
|
73
|
+
"""Parse progress from a worker log.
|
|
74
|
+
|
|
75
|
+
Detects stream-json automatically and switches parsing strategies. Reads
|
|
76
|
+
only the tail of the log to stay cheap on large files.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
log_path: Path to the worker's log file.
|
|
80
|
+
tail_bytes: Only the last *tail_bytes* of the file is read.
|
|
81
|
+
provider_name: The assignment's resolved provider name (e.g.
|
|
82
|
+
``Assignment.provider_name``). ``None`` (the default) resolves to
|
|
83
|
+
:class:`~coord.providers.claude.ClaudeProvider` — the pre-#1710
|
|
84
|
+
behaviour — so existing callers that don't pass this are
|
|
85
|
+
unaffected.
|
|
86
|
+
provider: An already-constructed :class:`~coord.providers.base.Provider`
|
|
87
|
+
to use directly, bypassing name resolution entirely. Takes
|
|
88
|
+
precedence over *provider_name*. Mainly for tests that want to
|
|
89
|
+
exercise a specific (e.g. fake, non-claude) provider without
|
|
90
|
+
wiring up a full :class:`~coord.config.Config`.
|
|
91
|
+
"""
|
|
92
|
+
from coord.worker_events import detect_anomalies, is_stream_json
|
|
93
|
+
|
|
94
|
+
p = Path(log_path)
|
|
95
|
+
if not p.exists():
|
|
96
|
+
return WorkerProgress()
|
|
97
|
+
|
|
98
|
+
if is_stream_json(p):
|
|
99
|
+
if provider is None:
|
|
100
|
+
from coord.providers import get_provider # noqa: PLC0415
|
|
101
|
+
provider = get_provider(provider_name)
|
|
102
|
+
summary = provider.parse_log(p, tail_bytes=tail_bytes)
|
|
103
|
+
progress = WorkerProgress()
|
|
104
|
+
# Synthesise a single rolling "update" line so coord status keeps
|
|
105
|
+
# showing recent activity for stream-json workers.
|
|
106
|
+
if summary.num_turns or summary.last_tool:
|
|
107
|
+
tool_part = summary.last_tool or "thinking"
|
|
108
|
+
progress.updates.append(f"Turn {summary.num_turns}: {tool_part}")
|
|
109
|
+
# Surface anomaly patterns as warnings.
|
|
110
|
+
progress.warnings.extend(detect_anomalies(p, tail_bytes=tail_bytes))
|
|
111
|
+
if summary.stop_reason and summary.stop_reason not in (
|
|
112
|
+
"end_turn",
|
|
113
|
+
"stop_sequence",
|
|
114
|
+
None,
|
|
115
|
+
):
|
|
116
|
+
progress.warnings.append(f"unusual stop: {summary.stop_reason}")
|
|
117
|
+
return progress
|
|
118
|
+
|
|
119
|
+
# ── Plain-text fallback ───────────────────────────────────────────────
|
|
120
|
+
size = p.stat().st_size
|
|
121
|
+
with open(p) as f:
|
|
122
|
+
if size > tail_bytes:
|
|
123
|
+
f.seek(size - tail_bytes)
|
|
124
|
+
f.readline() # skip partial line
|
|
125
|
+
text = f.read()
|
|
126
|
+
|
|
127
|
+
updates = STATUS_RE.findall(text)
|
|
128
|
+
stuck_matches = STUCK_RE.findall(text)
|
|
129
|
+
|
|
130
|
+
progress = WorkerProgress(
|
|
131
|
+
updates=updates[-10:],
|
|
132
|
+
stuck=stuck_matches[-1] if stuck_matches else None,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Extract latest confidence
|
|
136
|
+
if updates:
|
|
137
|
+
conf = CONFIDENCE_RE.search(updates[-1])
|
|
138
|
+
if conf:
|
|
139
|
+
progress.latest_confidence = conf.group(1).lower()
|
|
140
|
+
|
|
141
|
+
# Detect warning patterns
|
|
142
|
+
_detect_warnings(progress, updates)
|
|
143
|
+
|
|
144
|
+
return progress
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _extract_smoke_tests_from_text(text: str) -> list[str] | None:
|
|
148
|
+
"""#252: pull the SMOKE_TESTS block out of *text*.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
* ``None`` — no block emitted (graceful degradation; the TUI shows
|
|
152
|
+
a "no smoke tests provided" placeholder).
|
|
153
|
+
* ``[]`` — explicit "(none — change is internal)" form.
|
|
154
|
+
* ``list[str]`` — one entry per bullet, stripped of leading "- "/"* ".
|
|
155
|
+
|
|
156
|
+
Tolerant of leading/trailing whitespace and stray empty lines. Picks
|
|
157
|
+
the LAST block in the text — workers occasionally redo their summary
|
|
158
|
+
if they reconsider the change.
|
|
159
|
+
"""
|
|
160
|
+
matches = list(_SMOKE_BLOCK_RE.finditer(text))
|
|
161
|
+
if not matches:
|
|
162
|
+
return None
|
|
163
|
+
block = matches[-1].group(1)
|
|
164
|
+
|
|
165
|
+
# Try the "(none — change is internal)" short form on the first
|
|
166
|
+
# non-empty line of the captured block.
|
|
167
|
+
for line in block.splitlines():
|
|
168
|
+
stripped = line.strip()
|
|
169
|
+
if not stripped:
|
|
170
|
+
continue
|
|
171
|
+
if _SMOKE_NONE_RE.match(stripped):
|
|
172
|
+
return []
|
|
173
|
+
break
|
|
174
|
+
|
|
175
|
+
bullets: list[str] = []
|
|
176
|
+
for line in block.splitlines():
|
|
177
|
+
m = _SMOKE_BULLET_RE.match(line)
|
|
178
|
+
if m:
|
|
179
|
+
item = m.group(1).strip()
|
|
180
|
+
if item:
|
|
181
|
+
bullets.append(item)
|
|
182
|
+
# If the block existed but had no bullets and wasn't the "none" form
|
|
183
|
+
# either, treat as "no smoke tests provided" (None) — the worker
|
|
184
|
+
# didn't actually fill in the template.
|
|
185
|
+
if not bullets:
|
|
186
|
+
return None
|
|
187
|
+
return bullets
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def parse_smoke_tests_from_log(
|
|
191
|
+
log_path: str | Path, tail_bytes: int = 65_536,
|
|
192
|
+
) -> list[str] | None:
|
|
193
|
+
"""#252: read the tail of *log_path* and extract any SMOKE_TESTS block.
|
|
194
|
+
|
|
195
|
+
Handles both stream-json logs (decodes assistant text events first)
|
|
196
|
+
and legacy plain-text logs. Returns the same three-state result as
|
|
197
|
+
:func:`_extract_smoke_tests_from_text`.
|
|
198
|
+
"""
|
|
199
|
+
p = Path(log_path)
|
|
200
|
+
if not p.exists():
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
from coord.worker_events import is_stream_json # noqa: PLC0415
|
|
204
|
+
|
|
205
|
+
if is_stream_json(p):
|
|
206
|
+
# Collect assistant text from the structured events. Workers may
|
|
207
|
+
# emit the block in a single assistant turn, so concatenating all
|
|
208
|
+
# of them is enough.
|
|
209
|
+
from coord.worker_events import _assistant_text, parse_event # noqa: PLC0415
|
|
210
|
+
texts: list[str] = []
|
|
211
|
+
try:
|
|
212
|
+
with open(p, encoding="utf-8", errors="replace") as f:
|
|
213
|
+
for line in f:
|
|
214
|
+
event = parse_event(line.rstrip("\n"))
|
|
215
|
+
if event is None or event.type != "assistant":
|
|
216
|
+
continue
|
|
217
|
+
t = _assistant_text(event)
|
|
218
|
+
if t:
|
|
219
|
+
texts.append(t)
|
|
220
|
+
except OSError:
|
|
221
|
+
return None
|
|
222
|
+
return _extract_smoke_tests_from_text("\n".join(texts))
|
|
223
|
+
|
|
224
|
+
# Plain-text path: read the tail (large enough to catch the block
|
|
225
|
+
# even when followed by many turns of build output).
|
|
226
|
+
try:
|
|
227
|
+
size = p.stat().st_size
|
|
228
|
+
with open(p, encoding="utf-8", errors="replace") as f:
|
|
229
|
+
if size > tail_bytes:
|
|
230
|
+
f.seek(size - tail_bytes)
|
|
231
|
+
f.readline() # skip partial line
|
|
232
|
+
text = f.read()
|
|
233
|
+
except OSError:
|
|
234
|
+
return None
|
|
235
|
+
return _extract_smoke_tests_from_text(text)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def parse_smoke_tests_from_agent(
|
|
239
|
+
host: str,
|
|
240
|
+
assignment_id: str,
|
|
241
|
+
port: int = 7433,
|
|
242
|
+
timeout: float = 15.0,
|
|
243
|
+
) -> list[str] | None:
|
|
244
|
+
"""#252: fetch a worker's log via the agent's ``/logs/<id>`` endpoint
|
|
245
|
+
and extract the SMOKE_TESTS block.
|
|
246
|
+
|
|
247
|
+
Use this instead of :func:`parse_smoke_tests_from_log` when the worker
|
|
248
|
+
ran on a remote agent and the log isn't on the coordinator's local
|
|
249
|
+
filesystem. Mirrors :func:`coord.review.parse_review_from_agent` and
|
|
250
|
+
:func:`coord.plan_parser.parse_plan_from_agent`. Returns the same
|
|
251
|
+
three-state result as :func:`_extract_smoke_tests_from_text`.
|
|
252
|
+
"""
|
|
253
|
+
import httpx # noqa: PLC0415
|
|
254
|
+
|
|
255
|
+
url = f"http://{host}:{port}/logs/{assignment_id}"
|
|
256
|
+
try:
|
|
257
|
+
resp = httpx.get(url, timeout=timeout)
|
|
258
|
+
resp.raise_for_status()
|
|
259
|
+
text = resp.text
|
|
260
|
+
except (httpx.HTTPError, httpx.TimeoutException):
|
|
261
|
+
return None
|
|
262
|
+
if not text:
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
# Detect stream-json the same way is_stream_json() does for files.
|
|
266
|
+
stream_json = False
|
|
267
|
+
for line in text.splitlines():
|
|
268
|
+
stripped = line.strip()
|
|
269
|
+
if not stripped or stripped.startswith("#"):
|
|
270
|
+
continue
|
|
271
|
+
stream_json = stripped.startswith("{")
|
|
272
|
+
break
|
|
273
|
+
|
|
274
|
+
if stream_json:
|
|
275
|
+
from coord.worker_events import _assistant_text, parse_event # noqa: PLC0415
|
|
276
|
+
texts: list[str] = []
|
|
277
|
+
for line in text.splitlines():
|
|
278
|
+
event = parse_event(line.rstrip("\n"))
|
|
279
|
+
if event is None or event.type != "assistant":
|
|
280
|
+
continue
|
|
281
|
+
t = _assistant_text(event)
|
|
282
|
+
if t:
|
|
283
|
+
texts.append(t)
|
|
284
|
+
return _extract_smoke_tests_from_text("\n".join(texts))
|
|
285
|
+
|
|
286
|
+
return _extract_smoke_tests_from_text(text)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# #874: workers emit a "### Summary" markdown heading before finishing.
|
|
290
|
+
# The block extends to the next "###" heading or to end-of-text. We
|
|
291
|
+
# pick the LAST occurrence (workers may recap after final fixes).
|
|
292
|
+
#
|
|
293
|
+
# NOTE: use [ \t]* (horizontal whitespace only) in the prefix — NOT \s* —
|
|
294
|
+
# so the blank line after the heading is NOT consumed by the prefix match.
|
|
295
|
+
# With re.DOTALL, \s* would greedily eat the blank \n, shifting the
|
|
296
|
+
# captured group to start at the *next* ### heading's content instead.
|
|
297
|
+
_SUMMARY_BLOCK_RE = re.compile(
|
|
298
|
+
r"###[ \t]+Summary[ \t]*\n(.*?)(?=\n###[ \t]|\Z)",
|
|
299
|
+
re.DOTALL | re.IGNORECASE,
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _extract_completion_summary_from_text(text: str) -> str | None:
|
|
304
|
+
"""#874: pull the ### Summary block out of *text*.
|
|
305
|
+
|
|
306
|
+
Returns:
|
|
307
|
+
* ``None`` — no "### Summary" heading found.
|
|
308
|
+
* ``str`` — the prose text immediately under the heading, stripped
|
|
309
|
+
of leading/trailing whitespace. Empty string is folded to None
|
|
310
|
+
(an empty heading is treated as absent).
|
|
311
|
+
|
|
312
|
+
Picks the LAST block in the text in case the worker re-emitted the
|
|
313
|
+
section after further edits.
|
|
314
|
+
"""
|
|
315
|
+
matches = list(_SUMMARY_BLOCK_RE.finditer(text))
|
|
316
|
+
if not matches:
|
|
317
|
+
return None
|
|
318
|
+
prose = matches[-1].group(1).strip()
|
|
319
|
+
return prose if prose else None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def parse_completion_summary_from_log(
|
|
323
|
+
log_path: str | Path, tail_bytes: int = 65_536,
|
|
324
|
+
) -> str | None:
|
|
325
|
+
"""#874: read the tail of *log_path* and extract any ### Summary block.
|
|
326
|
+
|
|
327
|
+
Handles both stream-json logs (decodes assistant text events first)
|
|
328
|
+
and legacy plain-text logs. Returns the same two-state result as
|
|
329
|
+
:func:`_extract_completion_summary_from_text`.
|
|
330
|
+
"""
|
|
331
|
+
p = Path(log_path)
|
|
332
|
+
if not p.exists():
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
from coord.worker_events import is_stream_json # noqa: PLC0415
|
|
336
|
+
|
|
337
|
+
if is_stream_json(p):
|
|
338
|
+
from coord.worker_events import _assistant_text, parse_event # noqa: PLC0415
|
|
339
|
+
texts: list[str] = []
|
|
340
|
+
try:
|
|
341
|
+
with open(p, encoding="utf-8", errors="replace") as f:
|
|
342
|
+
for line in f:
|
|
343
|
+
event = parse_event(line.rstrip("\n"))
|
|
344
|
+
if event is None or event.type != "assistant":
|
|
345
|
+
continue
|
|
346
|
+
t = _assistant_text(event)
|
|
347
|
+
if t:
|
|
348
|
+
texts.append(t)
|
|
349
|
+
except OSError:
|
|
350
|
+
return None
|
|
351
|
+
return _extract_completion_summary_from_text("\n".join(texts))
|
|
352
|
+
|
|
353
|
+
try:
|
|
354
|
+
size = p.stat().st_size
|
|
355
|
+
with open(p, encoding="utf-8", errors="replace") as f:
|
|
356
|
+
if size > tail_bytes:
|
|
357
|
+
f.seek(size - tail_bytes)
|
|
358
|
+
f.readline() # skip partial line
|
|
359
|
+
text = f.read()
|
|
360
|
+
except OSError:
|
|
361
|
+
return None
|
|
362
|
+
return _extract_completion_summary_from_text(text)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def parse_completion_summary_from_agent(
|
|
366
|
+
host: str,
|
|
367
|
+
assignment_id: str,
|
|
368
|
+
port: int = 7433,
|
|
369
|
+
timeout: float = 15.0,
|
|
370
|
+
) -> str | None:
|
|
371
|
+
"""#874: fetch a worker's log via the agent's ``/logs/<id>`` endpoint
|
|
372
|
+
and extract the ### Summary block.
|
|
373
|
+
|
|
374
|
+
Use this instead of :func:`parse_completion_summary_from_log` when the
|
|
375
|
+
worker ran on a remote agent and the log isn't on the coordinator's local
|
|
376
|
+
filesystem. Mirrors :func:`parse_smoke_tests_from_agent`.
|
|
377
|
+
"""
|
|
378
|
+
import httpx # noqa: PLC0415
|
|
379
|
+
|
|
380
|
+
url = f"http://{host}:{port}/logs/{assignment_id}"
|
|
381
|
+
try:
|
|
382
|
+
resp = httpx.get(url, timeout=timeout)
|
|
383
|
+
resp.raise_for_status()
|
|
384
|
+
text = resp.text
|
|
385
|
+
except (httpx.HTTPError, httpx.TimeoutException):
|
|
386
|
+
return None
|
|
387
|
+
if not text:
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
# Detect stream-json the same way is_stream_json() does for files.
|
|
391
|
+
stream_json = False
|
|
392
|
+
for line in text.splitlines():
|
|
393
|
+
stripped = line.strip()
|
|
394
|
+
if not stripped or stripped.startswith("#"):
|
|
395
|
+
continue
|
|
396
|
+
stream_json = stripped.startswith("{")
|
|
397
|
+
break
|
|
398
|
+
|
|
399
|
+
if stream_json:
|
|
400
|
+
from coord.worker_events import _assistant_text, parse_event # noqa: PLC0415
|
|
401
|
+
decoded: list[str] = []
|
|
402
|
+
for line in text.splitlines():
|
|
403
|
+
event = parse_event(line.rstrip("\n"))
|
|
404
|
+
if event is None or event.type != "assistant":
|
|
405
|
+
continue
|
|
406
|
+
t = _assistant_text(event)
|
|
407
|
+
if t:
|
|
408
|
+
decoded.append(t)
|
|
409
|
+
return _extract_completion_summary_from_text("\n".join(decoded))
|
|
410
|
+
|
|
411
|
+
return _extract_completion_summary_from_text(text)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _detect_warnings(progress: WorkerProgress, all_updates: list[str]) -> None:
|
|
415
|
+
if progress.stuck:
|
|
416
|
+
progress.warnings.append("worker is STUCK and waiting for guidance")
|
|
417
|
+
|
|
418
|
+
# Two consecutive low-confidence updates
|
|
419
|
+
confidences = []
|
|
420
|
+
for u in all_updates[-5:]:
|
|
421
|
+
m = CONFIDENCE_RE.search(u)
|
|
422
|
+
if m:
|
|
423
|
+
confidences.append(m.group(1).lower())
|
|
424
|
+
if len(confidences) >= 2 and confidences[-1] == "low" and confidences[-2] == "low":
|
|
425
|
+
progress.warnings.append("confidence dropped to low on consecutive updates")
|