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/pipeline.py
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
"""Pipeline stage tracking for the assignment lifecycle.
|
|
2
|
+
|
|
3
|
+
Every "work" assignment passes through a series of approval gates before it
|
|
4
|
+
is considered fully shipped. This module computes a ``PipelineView`` that
|
|
5
|
+
describes exactly where an assignment sits in the pipeline so the dashboard
|
|
6
|
+
(and any other consumer) can show status and offer one-click gate actions.
|
|
7
|
+
|
|
8
|
+
The pipeline is intentionally pure-computation: ``compute_pipeline`` takes
|
|
9
|
+
already-loaded data structures and returns a value object — no I/O, no side
|
|
10
|
+
effects. The dashboard server wires the real persistence layer.
|
|
11
|
+
|
|
12
|
+
Pipeline stages (in order): ``coding`` prepended, then whichever gates
|
|
13
|
+
``config.pipeline.default_gates`` lists, in that order — e.g. the shipped
|
|
14
|
+
default ``["test", "review", "merge"]`` yields ``coding → smoke → review →
|
|
15
|
+
merge`` (see ``_STAGE_NAME_TO_GATE_NAME`` below for the ``smoke``/``test``
|
|
16
|
+
alias). #1724: this used to be hardcoded as ``coding → review → smoke →
|
|
17
|
+
merge``, which both mislabelled the gate and inverted Test/Review relative
|
|
18
|
+
to config.
|
|
19
|
+
|
|
20
|
+
Each stage may be "waiting", "active", "completed", or "skipped". The
|
|
21
|
+
``required_gates`` field on the assignment (defaulting to
|
|
22
|
+
``config.pipeline.default_gates``) controls which intermediate stages are
|
|
23
|
+
enforced — stages not in required_gates are marked "skipped" in the view.
|
|
24
|
+
|
|
25
|
+
``current_stage`` is a fine-grained state name (e.g. "review_running",
|
|
26
|
+
"smoke_passed") that the UI uses for colour-coding and gate routing beyond
|
|
27
|
+
what the coarse PipelineStage status captures.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from typing import TYPE_CHECKING
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from coord.config import Config
|
|
37
|
+
from coord.merge_queue import QueuedMerge
|
|
38
|
+
from coord.models import Assignment, Board
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ── Data structures ─────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class PipelineStage:
|
|
46
|
+
name: str
|
|
47
|
+
status: str # "active" | "completed" | "skipped" | "waiting"
|
|
48
|
+
is_current: bool = False
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class PipelineGate:
|
|
53
|
+
action: str # e.g. "dispatch_review", "enqueue", "merge", "retry"
|
|
54
|
+
label: str # button text shown in the dashboard
|
|
55
|
+
endpoint: str # API path to POST to
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class PipelineView:
|
|
60
|
+
assignment_id: str
|
|
61
|
+
issue_number: int
|
|
62
|
+
issue_title: str
|
|
63
|
+
repo_name: str
|
|
64
|
+
machine_name: str
|
|
65
|
+
stages: list[PipelineStage]
|
|
66
|
+
current_stage: str
|
|
67
|
+
available_gates: list[PipelineGate]
|
|
68
|
+
progress_pct: int # 0-100
|
|
69
|
+
# True when the review assignment completed but its findings have not yet
|
|
70
|
+
# been posted to GitHub (review_posted_at is None on the review assignment).
|
|
71
|
+
# The dashboard shows a ⚠ indicator and a "Post Findings" retry button.
|
|
72
|
+
review_findings_pending: bool = False
|
|
73
|
+
# Cached review verdict from the linked review assignment
|
|
74
|
+
# ("approve" | "request-changes" | None). Populated when the reviewer has
|
|
75
|
+
# completed and emitted a structured REVIEW_VERDICT block.
|
|
76
|
+
review_verdict: str | None = None
|
|
77
|
+
# #1456: when the coordinator overrode the reviewer's verdict (the #476
|
|
78
|
+
# approve-with-nits gate), the reviewer's OWN verdict and the evidence that
|
|
79
|
+
# justified the override. Both None when `review_verdict` is the
|
|
80
|
+
# reviewer's own call — which is the normal case. Exposed here so the
|
|
81
|
+
# dashboard/TUI can render "approve (coordinator override of
|
|
82
|
+
# request-changes)" instead of a bare approve the reviewer never gave.
|
|
83
|
+
review_verdict_original: str | None = None
|
|
84
|
+
review_verdict_override_reason: str | None = None
|
|
85
|
+
# Full text body of the review findings as cached by notify/auto_loop.
|
|
86
|
+
# Populated from the DB review_findings column so the phone detail screen
|
|
87
|
+
# can render them without a slow GitHub re-fetch. None when no findings
|
|
88
|
+
# have been cached yet.
|
|
89
|
+
review_findings_body: str | None = None
|
|
90
|
+
# Human-driven Test-gate verdict for the work assignment.
|
|
91
|
+
# Mirrors Assignment.test_state: None | "passed" | "failed" | "skipped".
|
|
92
|
+
# Populated by compute_pipeline so the phone detail screen can display the
|
|
93
|
+
# current verdict and the Record Test Verdict gate can offer Pass/Fail.
|
|
94
|
+
test_verdict: str | None = None
|
|
95
|
+
# #846: True when this assignment is running past its wall-clock
|
|
96
|
+
# threshold or thrashing through fix/review rounds without converging
|
|
97
|
+
# (see coord.notify.attention_signal — same detection core the
|
|
98
|
+
# coordinator's GitHub-comment backstop uses). Detection + surfacing
|
|
99
|
+
# only; nothing is killed or reassigned automatically.
|
|
100
|
+
needs_attention: bool = False
|
|
101
|
+
needs_attention_reason: str | None = None # "wall_clock" | "non_convergence" | None
|
|
102
|
+
needs_attention_detail: str | None = None
|
|
103
|
+
# #1218: most-recent completion timestamp available for this pipeline
|
|
104
|
+
# item, so the dashboard can sort the collapsed "Work done" section by
|
|
105
|
+
# recency. Mirrors the latest of Assignment.finished_at across the work
|
|
106
|
+
# assignment and any linked review/smoke assignment (whichever most
|
|
107
|
+
# recently made progress) — see compute_pipeline for the derivation.
|
|
108
|
+
# None only when nothing involved has finished yet (still coding).
|
|
109
|
+
finished_at: float | None = None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── Canonical gate naming (#1724) ────────────────────────────────────────────
|
|
113
|
+
#
|
|
114
|
+
# This module's internal stage/group name for the smoke-test gate is
|
|
115
|
+
# "smoke" — it matches ``assignment.type == "smoke"``, the ``_STAGE_GROUP``
|
|
116
|
+
# keys below, and the fine-grained "smoke_running"/"smoke_passed"/
|
|
117
|
+
# "smoke_failed" ``current_stage`` values, all of which describe the
|
|
118
|
+
# *smoke assignment*, not the gate. ``config.pipeline.default_gates`` (and
|
|
119
|
+
# ``Assignment.required_gates``) instead call this gate "test" — same
|
|
120
|
+
# convention ``coord/merge_queue.py``'s ``requires_smoke``/``_bypassed_gates``
|
|
121
|
+
# and ``coord/stage_projection.py`` (the TUI projection) already use. #1724
|
|
122
|
+
# found the code below comparing "smoke" against ``required_gates`` directly,
|
|
123
|
+
# so the membership check always failed. Translate at this one seam instead
|
|
124
|
+
# of scattering "smoke"/"test" string comparisons through the stage logic.
|
|
125
|
+
_STAGE_NAME_TO_GATE_NAME: dict[str, str] = {"smoke": "test"}
|
|
126
|
+
_GATE_NAME_TO_STAGE_NAME: dict[str, str] = {
|
|
127
|
+
v: k for k, v in _STAGE_NAME_TO_GATE_NAME.items()
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _gate_name_for(stage_name: str) -> str:
|
|
132
|
+
"""Translate an internal stage/group name to the ``default_gates`` /
|
|
133
|
+
``required_gates`` name it corresponds to (identity for stages that
|
|
134
|
+
aren't renamed, e.g. "review"/"merge")."""
|
|
135
|
+
return _STAGE_NAME_TO_GATE_NAME.get(stage_name, stage_name)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── Stage progression constants ──────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
# Maps a detailed current_stage name to the coarse display stage group.
|
|
141
|
+
# Used to determine which PipelineStage.is_current should be set.
|
|
142
|
+
_STAGE_GROUP: dict[str, str | None] = {
|
|
143
|
+
"coding": "coding",
|
|
144
|
+
"failed": "coding", # failure occurred in coding step
|
|
145
|
+
"done": None, # between steps, nothing highlighted
|
|
146
|
+
"review_running": "review",
|
|
147
|
+
"review_done": "review",
|
|
148
|
+
"review_failed": "review",
|
|
149
|
+
"smoke_running": "smoke",
|
|
150
|
+
"smoke_passed": "smoke",
|
|
151
|
+
"smoke_failed": "smoke",
|
|
152
|
+
"merge_ready": "merge",
|
|
153
|
+
"merging": "merge",
|
|
154
|
+
"merged": "merge",
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
# Approximate progress percentage for each detailed stage.
|
|
158
|
+
_PROGRESS: dict[str, int] = {
|
|
159
|
+
"coding": 10,
|
|
160
|
+
"failed": 5,
|
|
161
|
+
"done": 20,
|
|
162
|
+
"review_running": 35,
|
|
163
|
+
"review_done": 50,
|
|
164
|
+
"review_failed": 35,
|
|
165
|
+
"smoke_running": 60,
|
|
166
|
+
"smoke_passed": 70,
|
|
167
|
+
"smoke_failed": 60,
|
|
168
|
+
"merge_ready": 80,
|
|
169
|
+
"merging": 90,
|
|
170
|
+
"merged": 100,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ── Core computation ─────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def compute_pipeline(
|
|
178
|
+
assignment: "Assignment",
|
|
179
|
+
board: "Board",
|
|
180
|
+
merge_queue_items: list, # list[QueuedMerge]
|
|
181
|
+
config: "Config",
|
|
182
|
+
*,
|
|
183
|
+
review_findings_body: str | None = None,
|
|
184
|
+
now: float | None = None,
|
|
185
|
+
) -> PipelineView:
|
|
186
|
+
"""Return a PipelineView for a type='work' assignment.
|
|
187
|
+
|
|
188
|
+
Scans ``board.active``, ``board.completed``, and ``merge_queue_items`` to
|
|
189
|
+
determine downstream state. Pure computation — no I/O.
|
|
190
|
+
|
|
191
|
+
*now* pins the clock the #846 ``needs_attention`` check reads (it is the
|
|
192
|
+
one wall-clock-dependent field in the view — its ``detail`` string embeds
|
|
193
|
+
"Running Nm"). ``None`` means ``time.time()``, i.e. today's behaviour for
|
|
194
|
+
every production caller; the ``coord web --fixture`` seeded-board server
|
|
195
|
+
(#1538) passes the fixture's frozen clock so two runs against the same
|
|
196
|
+
fixture produce byte-identical ``/api/pipeline`` output.
|
|
197
|
+
"""
|
|
198
|
+
aid = assignment.assignment_id or ""
|
|
199
|
+
|
|
200
|
+
# Resolve effective required_gates: assignment field → config default.
|
|
201
|
+
required_gates: list[str] = (
|
|
202
|
+
assignment.required_gates
|
|
203
|
+
if assignment.required_gates
|
|
204
|
+
else list(config.pipeline.default_gates)
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# ── Find linked downstream assignments ──────────────────────────────────
|
|
208
|
+
all_assignments = list(board.active) + list(board.completed)
|
|
209
|
+
|
|
210
|
+
review_assignment: Assignment | None = next(
|
|
211
|
+
(
|
|
212
|
+
a for a in all_assignments
|
|
213
|
+
if a.review_of_assignment_id == aid and a.type == "review"
|
|
214
|
+
),
|
|
215
|
+
None,
|
|
216
|
+
)
|
|
217
|
+
smoke_assignment: Assignment | None = next(
|
|
218
|
+
(
|
|
219
|
+
a for a in all_assignments
|
|
220
|
+
if a.review_of_assignment_id == aid and a.type == "smoke"
|
|
221
|
+
),
|
|
222
|
+
None,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# Find merge queue entry for this assignment.
|
|
226
|
+
mq_entry = next(
|
|
227
|
+
(m for m in merge_queue_items if m.assignment_id == aid),
|
|
228
|
+
None,
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# ── Determine current_stage ──────────────────────────────────────────────
|
|
232
|
+
current_stage: str
|
|
233
|
+
if assignment.status == "running":
|
|
234
|
+
current_stage = "coding"
|
|
235
|
+
elif assignment.status == "failed":
|
|
236
|
+
current_stage = "failed"
|
|
237
|
+
elif assignment.status == "merged":
|
|
238
|
+
# #2084: `coord.reconcile`'s GitHub-truth sweep (`work_is_terminal`)
|
|
239
|
+
# flips a work assignment's OWN `status` to "merged" independently of
|
|
240
|
+
# `merge_queue` — it catches PRs merged outside `coord merge` (a
|
|
241
|
+
# manual `gh pr merge`, or any path that never touched the queue) as
|
|
242
|
+
# well as queue entries reconcile confirmed landed. Before this
|
|
243
|
+
# branch existed, "merged" fell through to the bare `else` below and
|
|
244
|
+
# was indistinguishable from a work item that had never been
|
|
245
|
+
# dispatched anywhere — offering "Dispatch Review"/"Queue for
|
|
246
|
+
# Merge"/"Record Test Verdict" on code that was already reviewed,
|
|
247
|
+
# tested, and merged (the dominant share of #2084's false-positive
|
|
248
|
+
# `available_gates`). `mq_entry`-based MERGED detection below still
|
|
249
|
+
# covers the narrower window between a queue drain and reconcile
|
|
250
|
+
# catching up.
|
|
251
|
+
current_stage = "merged"
|
|
252
|
+
elif assignment.status in ("done", "pending"):
|
|
253
|
+
# Evaluate from most advanced to least advanced.
|
|
254
|
+
if mq_entry is not None:
|
|
255
|
+
from coord.merge_queue import MERGED, MERGING
|
|
256
|
+
|
|
257
|
+
if mq_entry.state == MERGED:
|
|
258
|
+
current_stage = "merged"
|
|
259
|
+
elif mq_entry.state == MERGING:
|
|
260
|
+
current_stage = "merging"
|
|
261
|
+
else:
|
|
262
|
+
current_stage = "merge_ready"
|
|
263
|
+
elif assignment.smoke_test == "pass":
|
|
264
|
+
current_stage = "smoke_passed"
|
|
265
|
+
elif assignment.smoke_test == "fail":
|
|
266
|
+
current_stage = "smoke_failed"
|
|
267
|
+
elif smoke_assignment is not None and smoke_assignment.status in ("running", "pending"):
|
|
268
|
+
current_stage = "smoke_running"
|
|
269
|
+
elif smoke_assignment is not None and smoke_assignment.status == "failed":
|
|
270
|
+
# Smoke assignment itself failed (infra failure) — not the same as
|
|
271
|
+
# the smoke *test* failing, but still unblocks the work assignment.
|
|
272
|
+
current_stage = "smoke_failed"
|
|
273
|
+
elif smoke_assignment is not None and smoke_assignment.status in ("done",):
|
|
274
|
+
# Smoke assignment completed but smoke_test not yet set — treat as passed.
|
|
275
|
+
current_stage = "smoke_passed"
|
|
276
|
+
elif review_assignment is not None:
|
|
277
|
+
if review_assignment.status in ("running", "pending", "finalizing"):
|
|
278
|
+
# #1566: "finalizing" is a review row whose agent finished but
|
|
279
|
+
# whose verdict hasn't been parsed/posted by `coord notify`
|
|
280
|
+
# yet. Treating it as "review_done" here would surface the
|
|
281
|
+
# exact "no verdict, indistinguishable from dropped" gap
|
|
282
|
+
# #1566 was filed over — on the phone dashboard this also
|
|
283
|
+
# matters because review_done + no verdict adds the
|
|
284
|
+
# "record-review-verdict" gate below, inviting an operator to
|
|
285
|
+
# manually stamp a verdict for a review still being parsed.
|
|
286
|
+
current_stage = "review_running"
|
|
287
|
+
elif review_assignment.status == "failed":
|
|
288
|
+
current_stage = "review_failed"
|
|
289
|
+
else:
|
|
290
|
+
current_stage = "review_done"
|
|
291
|
+
else:
|
|
292
|
+
current_stage = "done"
|
|
293
|
+
else:
|
|
294
|
+
current_stage = "done"
|
|
295
|
+
|
|
296
|
+
# ── Build stages list ────────────────────────────────────────────────────
|
|
297
|
+
current_group = _STAGE_GROUP.get(current_stage)
|
|
298
|
+
stages: list[PipelineStage] = []
|
|
299
|
+
|
|
300
|
+
# Stages that appear after the current group are "waiting"; those before are
|
|
301
|
+
# "completed". "active" is the current group if still in progress.
|
|
302
|
+
|
|
303
|
+
# Define ordering for stage progression. #1724: derived from
|
|
304
|
+
# config.pipeline.default_gates (translating each configured gate name
|
|
305
|
+
# back to its internal stage name) rather than hardcoded, so the
|
|
306
|
+
# displayed order can't drift from the order the rest of the pipeline
|
|
307
|
+
# actually enforces. "coding" (the work stage) always prepends — it is
|
|
308
|
+
# never a configurable gate.
|
|
309
|
+
stage_order = ["coding"] + [
|
|
310
|
+
_GATE_NAME_TO_STAGE_NAME.get(gate, gate) for gate in config.pipeline.default_gates
|
|
311
|
+
]
|
|
312
|
+
current_group_idx = (
|
|
313
|
+
stage_order.index(current_group) if current_group in stage_order else -1
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
for i, stage_name in enumerate(stage_order):
|
|
317
|
+
if stage_name != "coding" and _gate_name_for(stage_name) not in required_gates:
|
|
318
|
+
stages.append(PipelineStage(name=stage_name, status="skipped", is_current=False))
|
|
319
|
+
continue
|
|
320
|
+
|
|
321
|
+
is_current = current_group == stage_name
|
|
322
|
+
|
|
323
|
+
if i < current_group_idx:
|
|
324
|
+
# This stage is before the current group → completed.
|
|
325
|
+
status = "completed"
|
|
326
|
+
elif i == current_group_idx:
|
|
327
|
+
# This is the current stage.
|
|
328
|
+
if current_stage == "merged":
|
|
329
|
+
status = "completed" # final state, show as completed
|
|
330
|
+
elif current_stage in ("smoke_passed", "review_done"):
|
|
331
|
+
status = "completed" # sub-stage "done", ready for next gate
|
|
332
|
+
elif current_stage == "failed":
|
|
333
|
+
status = "active" # coding failed, still "active" (needs attention)
|
|
334
|
+
else:
|
|
335
|
+
status = "active"
|
|
336
|
+
else:
|
|
337
|
+
# Future stage.
|
|
338
|
+
status = "waiting"
|
|
339
|
+
|
|
340
|
+
stages.append(PipelineStage(name=stage_name, status=status, is_current=is_current))
|
|
341
|
+
|
|
342
|
+
# ── Compute available gate actions ───────────────────────────────────────
|
|
343
|
+
_EP = "/api/pipeline/action"
|
|
344
|
+
available_gates: list[PipelineGate] = []
|
|
345
|
+
|
|
346
|
+
# #2084: current_stage collapses to "done" for two different populations
|
|
347
|
+
# — a work assignment that genuinely just finished coding with nothing
|
|
348
|
+
# dispatched downstream yet (status in "done"/"pending" — the fresh case
|
|
349
|
+
# the gates below are written for), and any OTHER terminal status
|
|
350
|
+
# (chiefly "advisory": a 0-commit clean exit with no code to test,
|
|
351
|
+
# review, or merge; also a defensive catch-all for any status this
|
|
352
|
+
# module doesn't otherwise recognize) that fell through to the bare
|
|
353
|
+
# `else` above. The latter has nothing left to gate — a human can't
|
|
354
|
+
# meaningfully "Record Test Verdict" or "Dispatch Review" on a worker
|
|
355
|
+
# that made no changes — so only offer these gates for the genuinely
|
|
356
|
+
# fresh population.
|
|
357
|
+
if current_stage == "done" and assignment.status in ("done", "pending"):
|
|
358
|
+
# Offer the human Test-gate so the phone can record a verdict before
|
|
359
|
+
# review auto-dispatch fires (Test precedes Review in the pipeline).
|
|
360
|
+
available_gates.append(PipelineGate("test-verdict", "Record Test Verdict", _EP))
|
|
361
|
+
# Only offer review/smoke gates if those stages are actually required.
|
|
362
|
+
if "review" in required_gates:
|
|
363
|
+
available_gates.append(PipelineGate("dispatch_review", "Dispatch Review", _EP))
|
|
364
|
+
if _gate_name_for("smoke") in required_gates:
|
|
365
|
+
available_gates.append(PipelineGate("dispatch_smoke", "Dispatch Smoke", _EP))
|
|
366
|
+
available_gates.append(PipelineGate("enqueue", "Queue for Merge", _EP))
|
|
367
|
+
elif current_stage == "review_failed":
|
|
368
|
+
available_gates.append(PipelineGate("dispatch_review", "Dispatch Review", _EP))
|
|
369
|
+
elif current_stage == "review_done":
|
|
370
|
+
available_gates.append(PipelineGate("enqueue", "Queue for Merge", _EP))
|
|
371
|
+
if review_assignment is not None and review_assignment.review_posted_at is None:
|
|
372
|
+
available_gates.append(PipelineGate("post_findings", "Post Findings", _EP))
|
|
373
|
+
# Offer the phone a way to record a review verdict manually when the
|
|
374
|
+
# automated reviewer produced no structured REVIEW_VERDICT block.
|
|
375
|
+
if review_assignment is not None and review_assignment.review_verdict is None:
|
|
376
|
+
available_gates.append(
|
|
377
|
+
PipelineGate("record-review-verdict", "Record Review Verdict", _EP)
|
|
378
|
+
)
|
|
379
|
+
# Offer a headless fix when the review returned request-changes (#699):
|
|
380
|
+
# the phone can dispatch a fix worker without attending a terminal.
|
|
381
|
+
if (
|
|
382
|
+
review_assignment is not None
|
|
383
|
+
and review_assignment.review_verdict == "request-changes"
|
|
384
|
+
):
|
|
385
|
+
available_gates.append(PipelineGate("dispatch_fix", "Dispatch Fix", _EP))
|
|
386
|
+
elif current_stage == "smoke_passed":
|
|
387
|
+
available_gates.append(PipelineGate("enqueue", "Queue for Merge", _EP))
|
|
388
|
+
elif current_stage == "merge_ready":
|
|
389
|
+
available_gates.append(PipelineGate("merge", "Merge", _EP))
|
|
390
|
+
elif current_stage == "smoke_failed":
|
|
391
|
+
available_gates.append(PipelineGate("dispatch_fix", "Dispatch Fix", _EP))
|
|
392
|
+
elif current_stage == "failed":
|
|
393
|
+
available_gates.append(PipelineGate("retry", "Retry", _EP))
|
|
394
|
+
|
|
395
|
+
progress_pct = _PROGRESS.get(current_stage, 0)
|
|
396
|
+
|
|
397
|
+
# Determine whether review findings need to be posted.
|
|
398
|
+
review_findings_pending = (
|
|
399
|
+
review_assignment is not None
|
|
400
|
+
and review_assignment.status == "done"
|
|
401
|
+
and review_assignment.review_posted_at is None
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
# Derive the cached review verdict from the in-memory review assignment
|
|
405
|
+
# (no I/O — review_assignment is already fetched from the board above).
|
|
406
|
+
review_verdict = review_assignment.review_verdict if review_assignment else None
|
|
407
|
+
# #1456: carry the override audit trail alongside the effective verdict.
|
|
408
|
+
review_verdict_original = (
|
|
409
|
+
review_assignment.review_verdict_original if review_assignment else None
|
|
410
|
+
)
|
|
411
|
+
review_verdict_override_reason = (
|
|
412
|
+
review_assignment.review_verdict_override_reason if review_assignment else None
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# Expose the human Test-gate verdict so the phone detail screen can display
|
|
416
|
+
# it and the record-test-verdict gate can be conditionally shown.
|
|
417
|
+
# Reads from Assignment.test_state — None | "passed" | "failed" | "skipped"
|
|
418
|
+
# | "running" (#1395: a transient, non-verdict marker an unattended driver
|
|
419
|
+
# sets while it runs the suite locally; deliberately NOT filtered out
|
|
420
|
+
# here — it's real signal for the phone screen too, and `test_verdict ==
|
|
421
|
+
# "failed"` / `!= None` (the only comparisons made against this field)
|
|
422
|
+
# both already do the right thing while it's "running").
|
|
423
|
+
test_verdict = assignment.test_state
|
|
424
|
+
|
|
425
|
+
# #846: long-running / non-converging signal, shared with the coordinator
|
|
426
|
+
# backstop (coord.notify.detect_needs_attention) via the same pure
|
|
427
|
+
# coord.notify.attention_signal core — local import to avoid a module-load
|
|
428
|
+
# cycle (mirrors the coord.merge_queue import above).
|
|
429
|
+
from coord.notify import attention_signal # noqa: PLC0415
|
|
430
|
+
|
|
431
|
+
needs_attention_reason, needs_attention_detail = attention_signal(
|
|
432
|
+
assignment_type=assignment.type,
|
|
433
|
+
status=assignment.status,
|
|
434
|
+
dispatched_at=assignment.dispatched_at,
|
|
435
|
+
review_iteration=assignment.review_iteration,
|
|
436
|
+
config=config,
|
|
437
|
+
now=now,
|
|
438
|
+
provider_name=assignment.provider_name,
|
|
439
|
+
review_of_assignment_id=assignment.review_of_assignment_id,
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
# #1218: most-recent finished_at across the work assignment and any
|
|
443
|
+
# linked review/smoke assignment — whichever last made progress. This is
|
|
444
|
+
# what the dashboard sorts the collapsed "Work done" section by, so it
|
|
445
|
+
# needs to advance as an item moves review_done → smoke_passed →
|
|
446
|
+
# merge_ready, not freeze at the moment the work assignment itself
|
|
447
|
+
# finished.
|
|
448
|
+
finished_at = max(
|
|
449
|
+
(
|
|
450
|
+
ts
|
|
451
|
+
for ts in (
|
|
452
|
+
assignment.finished_at,
|
|
453
|
+
review_assignment.finished_at if review_assignment else None,
|
|
454
|
+
smoke_assignment.finished_at if smoke_assignment else None,
|
|
455
|
+
)
|
|
456
|
+
if ts is not None
|
|
457
|
+
),
|
|
458
|
+
default=None,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
return PipelineView(
|
|
462
|
+
assignment_id=aid,
|
|
463
|
+
issue_number=assignment.issue_number,
|
|
464
|
+
issue_title=assignment.issue_title,
|
|
465
|
+
repo_name=assignment.repo_name,
|
|
466
|
+
machine_name=assignment.machine_name,
|
|
467
|
+
stages=stages,
|
|
468
|
+
current_stage=current_stage,
|
|
469
|
+
available_gates=available_gates,
|
|
470
|
+
progress_pct=progress_pct,
|
|
471
|
+
review_findings_pending=review_findings_pending,
|
|
472
|
+
review_verdict=review_verdict,
|
|
473
|
+
review_verdict_original=review_verdict_original,
|
|
474
|
+
review_verdict_override_reason=review_verdict_override_reason,
|
|
475
|
+
review_findings_body=review_findings_body,
|
|
476
|
+
test_verdict=test_verdict,
|
|
477
|
+
needs_attention=needs_attention_reason is not None,
|
|
478
|
+
needs_attention_reason=needs_attention_reason,
|
|
479
|
+
needs_attention_detail=needs_attention_detail,
|
|
480
|
+
finished_at=finished_at,
|
|
481
|
+
)
|