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/claim.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Issue claim detection — prevent two agents picking up the same issue.
|
|
2
|
+
|
|
3
|
+
Without this, two coordinator runs (e.g. two operators, or a manual dispatch
|
|
4
|
+
racing an auto-dispatch) can both kick off work on the same issue because
|
|
5
|
+
neither side notices the other. The fix is a simple pre-dispatch check:
|
|
6
|
+
|
|
7
|
+
1. Is there an active board assignment for `(issue_number, repo_name)`?
|
|
8
|
+
2. Does the remote already have a branch matching `issue-{N}-*` for this
|
|
9
|
+
repo? (Workers create branches in that shape — its existence is treated
|
|
10
|
+
as a claim signal even if our board doesn't know about it yet.)
|
|
11
|
+
|
|
12
|
+
If either is true, the dispatch site refuses with a clear message. Reviews
|
|
13
|
+
and smoke tests run *after* a worker has pushed a branch, so they don't
|
|
14
|
+
participate in this check — they have their own dedupe (no two reviews of
|
|
15
|
+
the same completed assignment).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from typing import Callable
|
|
23
|
+
|
|
24
|
+
from coord.models import Board
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# A branch_lookup takes (repo_github, issue_number) and returns the matching
|
|
28
|
+
# branch names. Injected so tests don't hit GitHub.
|
|
29
|
+
BranchLookup = Callable[[str, int], list[str]]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Claim:
|
|
34
|
+
"""Evidence that an issue is already being worked on."""
|
|
35
|
+
|
|
36
|
+
issue_number: int
|
|
37
|
+
repo_name: str
|
|
38
|
+
source: str # "board" or "remote_branch"
|
|
39
|
+
machine_name: str | None = None
|
|
40
|
+
branch: str | None = None
|
|
41
|
+
assignment_id: str | None = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def find_work_claim(
|
|
45
|
+
issue_number: int,
|
|
46
|
+
repo_name: str,
|
|
47
|
+
repo_github: str,
|
|
48
|
+
board: Board,
|
|
49
|
+
*,
|
|
50
|
+
branch_lookup: BranchLookup | None = None,
|
|
51
|
+
) -> Claim | None:
|
|
52
|
+
"""Return a `Claim` if dispatching new work for this issue would conflict.
|
|
53
|
+
|
|
54
|
+
Checks the board first (cheap, local) then the remote (one `gh` call).
|
|
55
|
+
Returns the first hit — callers only need to know *that* it's claimed,
|
|
56
|
+
not enumerate every claim source.
|
|
57
|
+
"""
|
|
58
|
+
for a in board.active:
|
|
59
|
+
if a.issue_number == issue_number and a.repo_name == repo_name:
|
|
60
|
+
if a.status == "failed":
|
|
61
|
+
continue
|
|
62
|
+
# #1059 fix: "chat"/"troubleshoot" are human-attended, read-only
|
|
63
|
+
# diagnostic sessions (#628/#676, coord/issue_store.py's
|
|
64
|
+
# `("chat", "troubleshoot")` advisory-only special case) — they
|
|
65
|
+
# never commit code and are always finalized as "advisory"
|
|
66
|
+
# regardless of how they end. A stale one (session died without
|
|
67
|
+
# the TUI ever finalizing it, e.g. a "Chat about issue" left
|
|
68
|
+
# open on an epic) must not permanently block a real dispatch
|
|
69
|
+
# for the same issue, the way a genuine work/review/smoke claim
|
|
70
|
+
# should. Without this, a leftover chat row on a tracking issue
|
|
71
|
+
# wedged every future "Dispatch Gate A mock" attempt with
|
|
72
|
+
# "already in flight" against a claim with no backing worker and
|
|
73
|
+
# no way to clear it short of `coord diagnose`.
|
|
74
|
+
if a.type in ("plan", "review", "smoke", "chat", "troubleshoot"):
|
|
75
|
+
continue
|
|
76
|
+
return Claim(
|
|
77
|
+
issue_number=issue_number,
|
|
78
|
+
repo_name=repo_name,
|
|
79
|
+
source="board",
|
|
80
|
+
machine_name=a.machine_name,
|
|
81
|
+
branch=a.branch,
|
|
82
|
+
assignment_id=a.assignment_id,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
lookup = branch_lookup or _default_branch_lookup
|
|
86
|
+
branches = lookup(repo_github, issue_number)
|
|
87
|
+
if branches:
|
|
88
|
+
return Claim(
|
|
89
|
+
issue_number=issue_number,
|
|
90
|
+
repo_name=repo_name,
|
|
91
|
+
source="remote_branch",
|
|
92
|
+
branch=branches[0],
|
|
93
|
+
)
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def claim_message(claim: Claim) -> str:
|
|
98
|
+
"""Human-readable error for a refused dispatch."""
|
|
99
|
+
if claim.source == "board":
|
|
100
|
+
parts = [
|
|
101
|
+
f"issue #{claim.issue_number} ({claim.repo_name}) already claimed",
|
|
102
|
+
]
|
|
103
|
+
if claim.machine_name:
|
|
104
|
+
parts.append(f"by {claim.machine_name}")
|
|
105
|
+
if claim.assignment_id:
|
|
106
|
+
parts.append(f"(assignment {claim.assignment_id})")
|
|
107
|
+
if claim.branch:
|
|
108
|
+
parts.append(f"on branch {claim.branch}")
|
|
109
|
+
return " ".join(parts)
|
|
110
|
+
return (
|
|
111
|
+
f"issue #{claim.issue_number} ({claim.repo_name}) already claimed: "
|
|
112
|
+
f"remote branch {claim.branch} exists"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ── Dedupe for downstream auto-dispatch (review / smoke) ────────────────────
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def has_active_followup(
|
|
120
|
+
board: Board,
|
|
121
|
+
*,
|
|
122
|
+
of_assignment_id: str | None,
|
|
123
|
+
assignment_type: str,
|
|
124
|
+
) -> bool:
|
|
125
|
+
"""True if `board.active` already has a review/smoke of the given work.
|
|
126
|
+
|
|
127
|
+
Used by `dispatch_review`/`dispatch_smoke` to skip when one is already in
|
|
128
|
+
flight. The check is by `review_of_assignment_id` rather than `(issue,
|
|
129
|
+
repo)` so that re-dispatching after a worker re-runs the same issue
|
|
130
|
+
isn't accidentally blocked.
|
|
131
|
+
"""
|
|
132
|
+
if of_assignment_id is None:
|
|
133
|
+
return False
|
|
134
|
+
for a in board.active:
|
|
135
|
+
if a.type != assignment_type:
|
|
136
|
+
continue
|
|
137
|
+
if a.review_of_assignment_id == of_assignment_id:
|
|
138
|
+
return True
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def has_active_branch_followup(
|
|
143
|
+
board: Board,
|
|
144
|
+
*,
|
|
145
|
+
repo_name: str,
|
|
146
|
+
branch: str | None,
|
|
147
|
+
assignment_type: str,
|
|
148
|
+
) -> bool:
|
|
149
|
+
"""True if `board.active` already has a follow-up for this ``(repo, branch)``.
|
|
150
|
+
|
|
151
|
+
#1819: the branch-scoped peer of :func:`has_active_followup`. The unit a
|
|
152
|
+
Test-stage run actually measures is the **branch**, not the work row that
|
|
153
|
+
happened to push it — and after a fix round (``coord fix`` / ``--fix-of``
|
|
154
|
+
reuses the branch **by design**) one branch carries *two* ``work`` rows.
|
|
155
|
+
The ``of_assignment_id``-keyed dedupe asks "does *this row* already have a
|
|
156
|
+
smoke in flight?" and answers "no" for the sibling row, so both rows
|
|
157
|
+
dispatched their own Test worker: two machines running the identical suite
|
|
158
|
+
on the identical branch, racing to write a verdict (observed live on
|
|
159
|
+
#1797, 2026-08-04).
|
|
160
|
+
|
|
161
|
+
Deliberately NOT applied to reviews. A review is a judgement *of a work
|
|
162
|
+
row's contribution* — the fix round genuinely needs its own review even
|
|
163
|
+
though it shares a branch with the round it fixes — whereas a smoke run
|
|
164
|
+
on branch B is the same measurement no matter which row asked for it.
|
|
165
|
+
"""
|
|
166
|
+
if not branch:
|
|
167
|
+
return False
|
|
168
|
+
for a in board.active:
|
|
169
|
+
if a.type != assignment_type:
|
|
170
|
+
continue
|
|
171
|
+
if a.repo_name == repo_name and a.branch == branch:
|
|
172
|
+
return True
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def superseding_work_row(board: Board, assignment) -> "Assignment | None":
|
|
177
|
+
"""The later work-like row on the same ``(repo, branch)``, if any (#1819).
|
|
178
|
+
|
|
179
|
+
A ``work`` row that another work-like row was dispatched *after*, on the
|
|
180
|
+
same branch, is **superseded**: the branch's current content is the later
|
|
181
|
+
row's output, so the earlier row is not a meaningful dispatch target for
|
|
182
|
+
the Test stage. Dispatching against it burns a machine re-testing a branch
|
|
183
|
+
it did not produce and lands the verdict on a row nothing gates on.
|
|
184
|
+
|
|
185
|
+
Ordering is by ``dispatched_at``, with the assignment id as a deterministic
|
|
186
|
+
tie-break so two rows stamped in the same second still order stably. A
|
|
187
|
+
``failed`` or ``advisory`` later row does not supersede — both are
|
|
188
|
+
terminal no-op outcomes (``_ZERO_COMMIT_TYPES`` in ``coord/agent.py``:
|
|
189
|
+
zero commits pushed, e.g. "already fixed" or a graceful usage-limit exit)
|
|
190
|
+
that leave the branch exactly as the earlier row left it, so the earlier
|
|
191
|
+
row is still the branch's author. Without excluding ``advisory`` here, a
|
|
192
|
+
`--fix-of` round that lands zero commits would falsely mark the row it
|
|
193
|
+
was fixing as superseded — and since an ``advisory`` row is itself never
|
|
194
|
+
a valid dispatch target (``dispatch_smoke`` requires ``status == "done"``),
|
|
195
|
+
the branch would then never get *any* Test dispatch.
|
|
196
|
+
|
|
197
|
+
Returns the (newest) superseding row so callers can name it in a log line,
|
|
198
|
+
or ``None`` when *assignment* is the branch's current work row. Related but
|
|
199
|
+
distinct from #1277, which is the *display* side of the same idea.
|
|
200
|
+
"""
|
|
201
|
+
from coord.models import WORK_LIKE_TYPES # noqa: PLC0415
|
|
202
|
+
|
|
203
|
+
branch = getattr(assignment, "branch", None)
|
|
204
|
+
if not branch:
|
|
205
|
+
return None
|
|
206
|
+
aid = getattr(assignment, "assignment_id", None)
|
|
207
|
+
key = (getattr(assignment, "dispatched_at", None) or 0.0, aid or "")
|
|
208
|
+
|
|
209
|
+
best = None
|
|
210
|
+
best_key: tuple[float, str] | None = None
|
|
211
|
+
for a in list(board.active) + list(board.completed):
|
|
212
|
+
if a is assignment:
|
|
213
|
+
continue
|
|
214
|
+
if a.type not in WORK_LIKE_TYPES:
|
|
215
|
+
continue
|
|
216
|
+
if a.status in ("failed", "advisory"):
|
|
217
|
+
continue
|
|
218
|
+
if a.repo_name != assignment.repo_name or a.branch != branch:
|
|
219
|
+
continue
|
|
220
|
+
other_aid = getattr(a, "assignment_id", None)
|
|
221
|
+
if other_aid is not None and other_aid == aid:
|
|
222
|
+
continue
|
|
223
|
+
other_key = (getattr(a, "dispatched_at", None) or 0.0, other_aid or "")
|
|
224
|
+
if other_key <= key:
|
|
225
|
+
continue
|
|
226
|
+
if best_key is None or other_key > best_key:
|
|
227
|
+
best, best_key = a, other_key
|
|
228
|
+
return best
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def has_active_work_followup(
|
|
232
|
+
board: Board,
|
|
233
|
+
*,
|
|
234
|
+
repo_name: str,
|
|
235
|
+
issue_number: int,
|
|
236
|
+
) -> bool:
|
|
237
|
+
"""True if a work or conflict-fix assignment is actively running for (repo, issue).
|
|
238
|
+
|
|
239
|
+
Used before dispatching a review to skip when a coord-bounce fix is
|
|
240
|
+
actively rewriting the branch — dispatching a review against stale code
|
|
241
|
+
produces a verdict on code that's about to change and causes unnecessary
|
|
242
|
+
churn. The existing ``has_active_followup`` covers duplicate-review
|
|
243
|
+
dedupe; this covers the orthogonal case where a *work* re-run (not a
|
|
244
|
+
review) is live for the same issue.
|
|
245
|
+
|
|
246
|
+
Called from both the reconcile review-dispatch loop and ``dispatch_review``
|
|
247
|
+
for defence in depth.
|
|
248
|
+
|
|
249
|
+
#1553: keyed on the *effective* issue
|
|
250
|
+
(:func:`coord.models.effective_issue_number`), not the raw
|
|
251
|
+
``issue_number``. Every oracle-loop acceptance slice under one milestone
|
|
252
|
+
shares the tracking issue's number, so keying on the raw field made an
|
|
253
|
+
in-flight fix for child A block review dispatch for child B — and for the
|
|
254
|
+
epic itself — even though they are unrelated pieces of work. With the
|
|
255
|
+
effective issue, a slice's follow-up only guards its own child.
|
|
256
|
+
"""
|
|
257
|
+
from coord.models import effective_issue_number # noqa: PLC0415
|
|
258
|
+
|
|
259
|
+
_WORK_TYPES = frozenset({"work", "conflict-fix"})
|
|
260
|
+
for a in board.active:
|
|
261
|
+
if a.type not in _WORK_TYPES:
|
|
262
|
+
continue
|
|
263
|
+
if a.status == "failed":
|
|
264
|
+
continue
|
|
265
|
+
if a.repo_name == repo_name and effective_issue_number(a) == issue_number:
|
|
266
|
+
return True
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# ── Default branch lookup (uses gh) ─────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _default_branch_lookup(repo_github: str, issue_number: int) -> list[str]:
|
|
274
|
+
"""Return remote branches whose name starts with `issue-{N}-`.
|
|
275
|
+
|
|
276
|
+
Uses `gh api repos/.../git/matching-refs/heads/issue-{N}-`. Empty result
|
|
277
|
+
on any lookup failure — we'd rather wave through a dispatch than block
|
|
278
|
+
on a transient GH error.
|
|
279
|
+
"""
|
|
280
|
+
from coord import github_ops
|
|
281
|
+
|
|
282
|
+
try:
|
|
283
|
+
raw = github_ops._gh(
|
|
284
|
+
"api",
|
|
285
|
+
f"repos/{repo_github}/git/matching-refs/heads/issue-{issue_number}-",
|
|
286
|
+
)
|
|
287
|
+
except RuntimeError:
|
|
288
|
+
return []
|
|
289
|
+
try:
|
|
290
|
+
refs = json.loads(raw)
|
|
291
|
+
except ValueError:
|
|
292
|
+
return []
|
|
293
|
+
if not isinstance(refs, list):
|
|
294
|
+
return []
|
|
295
|
+
branches: list[str] = []
|
|
296
|
+
for r in refs:
|
|
297
|
+
if not isinstance(r, dict):
|
|
298
|
+
continue
|
|
299
|
+
ref = r.get("ref", "")
|
|
300
|
+
if isinstance(ref, str) and ref.startswith("refs/heads/"):
|
|
301
|
+
branches.append(ref[len("refs/heads/"):])
|
|
302
|
+
# Drop branches already fully merged into the default branch — a merged
|
|
303
|
+
# branch is finished work, not an active claim. A stale merged branch (e.g. a
|
|
304
|
+
# PR head that wasn't auto-deleted on merge) must not block new work on the
|
|
305
|
+
# issue forever (the chat→work block on a long-merged issue-N-* branch).
|
|
306
|
+
return _drop_merged_branches(repo_github, branches)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _repo_default_branch(repo_github: str) -> str | None:
|
|
310
|
+
"""The repo's default branch via the GH API, or None on any error."""
|
|
311
|
+
from coord import github_ops
|
|
312
|
+
|
|
313
|
+
try:
|
|
314
|
+
data = json.loads(github_ops._gh("api", f"repos/{repo_github}"))
|
|
315
|
+
except (RuntimeError, ValueError):
|
|
316
|
+
return None
|
|
317
|
+
val = data.get("default_branch") if isinstance(data, dict) else None
|
|
318
|
+
return val if isinstance(val, str) and val else None
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _drop_merged_branches(repo_github: str, branches: list[str]) -> list[str]:
|
|
322
|
+
"""Filter out branches fully merged into the repo's default branch.
|
|
323
|
+
|
|
324
|
+
A branch with zero commits ahead of the default branch is finished work,
|
|
325
|
+
not an active claim, so it must not block a fresh dispatch. Conservative on
|
|
326
|
+
every uncertainty — unknown default branch, compare-API error, or a branch
|
|
327
|
+
that IS ahead — keeps the branch as a claim (fail toward blocking duplicate
|
|
328
|
+
work, never toward allowing it).
|
|
329
|
+
"""
|
|
330
|
+
if not branches:
|
|
331
|
+
return branches
|
|
332
|
+
from coord import github_ops
|
|
333
|
+
|
|
334
|
+
default_branch = _repo_default_branch(repo_github)
|
|
335
|
+
if not default_branch:
|
|
336
|
+
return branches # can't determine merged-ness → keep all (conservative)
|
|
337
|
+
kept: list[str] = []
|
|
338
|
+
for b in branches:
|
|
339
|
+
if b == default_branch:
|
|
340
|
+
continue
|
|
341
|
+
try:
|
|
342
|
+
cmp = json.loads(
|
|
343
|
+
github_ops._gh(
|
|
344
|
+
"api", f"repos/{repo_github}/compare/{default_branch}...{b}"
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
ahead = cmp.get("ahead_by") if isinstance(cmp, dict) else None
|
|
348
|
+
except (RuntimeError, ValueError):
|
|
349
|
+
ahead = None
|
|
350
|
+
if ahead == 0:
|
|
351
|
+
continue # fully merged → not an active claim
|
|
352
|
+
kept.append(b) # ahead > 0, or unknown → keep (conservative)
|
|
353
|
+
return kept
|