code-coordinator 0.5.46__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- code_coordinator-0.5.46.dist-info/METADATA +625 -0
- code_coordinator-0.5.46.dist-info/RECORD +295 -0
- code_coordinator-0.5.46.dist-info/WHEEL +5 -0
- code_coordinator-0.5.46.dist-info/entry_points.txt +2 -0
- code_coordinator-0.5.46.dist-info/licenses/LICENSE +110 -0
- code_coordinator-0.5.46.dist-info/top_level.txt +1 -0
- coord/__init__.py +176 -0
- coord/_board_mapping.py +229 -0
- coord/acceptance.py +468 -0
- coord/acceptance_drivers.py +632 -0
- coord/agent.py +7517 -0
- coord/agent_app.py +1555 -0
- coord/agent_update.py +417 -0
- coord/agents/opencode/.gitignore +13 -0
- coord/agents/opencode/agents/work.md +129 -0
- coord/agents/opencode/routing.jsonc +49 -0
- coord/audit.py +301 -0
- coord/auto_loop.py +1440 -0
- coord/board_bool_guard.py +72 -0
- coord/board_service.py +141 -0
- coord/board_wire.py +309 -0
- coord/brain.py +581 -0
- coord/branch_model.py +214 -0
- coord/cargo_cache.py +258 -0
- coord/ci_github.py +386 -0
- coord/ci_store.py +560 -0
- coord/claim.py +353 -0
- coord/cli.py +454 -0
- coord/client.py +610 -0
- coord/commands/__init__.py +1 -0
- coord/commands/_common.py +329 -0
- coord/commands/acceptance.py +916 -0
- coord/commands/agent_ops.py +1339 -0
- coord/commands/audit.py +131 -0
- coord/commands/chat.py +320 -0
- coord/commands/dispatch.py +1780 -0
- coord/commands/dispatch_workers.py +4894 -0
- coord/commands/drive.py +616 -0
- coord/commands/drive_queue.py +1203 -0
- coord/commands/gate_a.py +217 -0
- coord/commands/gates.py +89 -0
- coord/commands/issues.py +681 -0
- coord/commands/lifecycle.py +513 -0
- coord/commands/merge.py +1900 -0
- coord/commands/milestone.py +2081 -0
- coord/commands/plan_followup.py +1243 -0
- coord/commands/plans.py +156 -0
- coord/commands/release.py +2232 -0
- coord/commands/report.py +341 -0
- coord/commands/review.py +1523 -0
- coord/commands/scorecard.py +252 -0
- coord/commands/sessions.py +1930 -0
- coord/commands/setup.py +576 -0
- coord/commands/status.py +2089 -0
- coord/commands/terminal.py +385 -0
- coord/commands/test_gate.py +775 -0
- coord/commands/tui.py +288 -0
- coord/comments.py +718 -0
- coord/config.py +3032 -0
- coord/conflict_fix.py +633 -0
- coord/dao.py +483 -0
- coord/dashboard/__init__.py +0 -0
- coord/dashboard/fixture.py +376 -0
- coord/dashboard/index.html +658 -0
- coord/dashboard/server.py +1894 -0
- coord/dashboard/terminal.py +382 -0
- coord/dashboard/webapp/.gitignore +9 -0
- coord/dashboard/webapp/components.json +17 -0
- coord/dashboard/webapp/dist/assets/Gallery-da3qNiIw.js +71 -0
- coord/dashboard/webapp/dist/assets/Terminal-9CEnUXvW.css +32 -0
- coord/dashboard/webapp/dist/assets/Terminal-skVFCxPU.js +63 -0
- coord/dashboard/webapp/dist/assets/index-DltfZR5f.js +184 -0
- coord/dashboard/webapp/dist/assets/index-Dq4kwTdw.css +1 -0
- coord/dashboard/webapp/dist/assets/workbox-window.prod.es5-BqEJf4Xk.js +2 -0
- coord/dashboard/webapp/dist/icons/icon-192.png +0 -0
- coord/dashboard/webapp/dist/icons/icon-512.png +0 -0
- coord/dashboard/webapp/dist/icons/icon.svg +5 -0
- coord/dashboard/webapp/dist/index.html +38 -0
- coord/dashboard/webapp/dist/manifest.webmanifest +1 -0
- coord/dashboard/webapp/dist/sw.js +1 -0
- coord/dashboard/webapp/dist/workbox-e4022e15.js +1 -0
- coord/dashboard/webapp/e2e/available-gates-terminal.spec.ts +75 -0
- coord/dashboard/webapp/e2e/deep-link.spec.ts +172 -0
- coord/dashboard/webapp/e2e/fixtureServer.ts +155 -0
- coord/dashboard/webapp/e2e/live-update-fixture.spec.ts +113 -0
- coord/dashboard/webapp/e2e/realtime.spec.ts +238 -0
- coord/dashboard/webapp/e2e/shell.spec.ts +309 -0
- coord/dashboard/webapp/e2e/smoke.spec.ts +191 -0
- coord/dashboard/webapp/e2e/terminal.spec.ts +420 -0
- coord/dashboard/webapp/e2e/theme.spec.ts +138 -0
- coord/dashboard/webapp/eslint.config.js +20 -0
- coord/dashboard/webapp/index.html +37 -0
- coord/dashboard/webapp/node_modules/flatted/python/flatted.py +144 -0
- coord/dashboard/webapp/package-lock.json +10584 -0
- coord/dashboard/webapp/package.json +63 -0
- coord/dashboard/webapp/playwright.acceptance.config.ts +166 -0
- coord/dashboard/webapp/playwright.config.ts +93 -0
- coord/dashboard/webapp/postcss.config.js +6 -0
- coord/dashboard/webapp/public/icons/icon-192.png +0 -0
- coord/dashboard/webapp/public/icons/icon-512.png +0 -0
- coord/dashboard/webapp/public/icons/icon.svg +5 -0
- coord/dashboard/webapp/src/App.tsx +140 -0
- coord/dashboard/webapp/src/api/client.ts +199 -0
- coord/dashboard/webapp/src/api/generated.ts +176 -0
- coord/dashboard/webapp/src/components/ConnectionBadge.tsx +52 -0
- coord/dashboard/webapp/src/components/Detail.tsx +800 -0
- coord/dashboard/webapp/src/components/Gallery.tsx +341 -0
- coord/dashboard/webapp/src/components/Home.tsx +435 -0
- coord/dashboard/webapp/src/components/MobileKeyBar.tsx +280 -0
- coord/dashboard/webapp/src/components/PanelHeader.tsx +59 -0
- coord/dashboard/webapp/src/components/PipelineCard.tsx +168 -0
- coord/dashboard/webapp/src/components/SessionCard.tsx +99 -0
- coord/dashboard/webapp/src/components/SessionDetail.tsx +140 -0
- coord/dashboard/webapp/src/components/SessionsList.tsx +81 -0
- coord/dashboard/webapp/src/components/Terminal.tsx +376 -0
- coord/dashboard/webapp/src/components/__tests__/ConnectionBadge.test.tsx +81 -0
- coord/dashboard/webapp/src/components/__tests__/Detail.test.tsx +680 -0
- coord/dashboard/webapp/src/components/__tests__/Gallery.test.tsx +83 -0
- coord/dashboard/webapp/src/components/__tests__/Home.test.tsx +271 -0
- coord/dashboard/webapp/src/components/__tests__/MobileKeyBar.test.tsx +197 -0
- coord/dashboard/webapp/src/components/__tests__/PipelineCard.test.tsx +143 -0
- coord/dashboard/webapp/src/components/__tests__/SessionCard.test.tsx +106 -0
- coord/dashboard/webapp/src/components/__tests__/Terminal.test.tsx +504 -0
- coord/dashboard/webapp/src/components/ui/badge.tsx +41 -0
- coord/dashboard/webapp/src/components/ui/button.tsx +54 -0
- coord/dashboard/webapp/src/components/ui/card.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/dialog.tsx +99 -0
- coord/dashboard/webapp/src/components/ui/dropdown-menu.tsx +189 -0
- coord/dashboard/webapp/src/components/ui/empty-state.tsx +35 -0
- coord/dashboard/webapp/src/components/ui/sheet.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/skeleton.tsx +9 -0
- coord/dashboard/webapp/src/components/ui/tabs.tsx +55 -0
- coord/dashboard/webapp/src/components/ui/theme-provider.tsx +78 -0
- coord/dashboard/webapp/src/components/ui/theme-toggle.tsx +20 -0
- coord/dashboard/webapp/src/components/ui/toast.tsx +123 -0
- coord/dashboard/webapp/src/components/ui/toaster.tsx +30 -0
- coord/dashboard/webapp/src/components/ui/tooltip.tsx +26 -0
- coord/dashboard/webapp/src/components/ui/use-toast.ts +134 -0
- coord/dashboard/webapp/src/index.css +210 -0
- coord/dashboard/webapp/src/lib/pipeline.ts +29 -0
- coord/dashboard/webapp/src/lib/utils.ts +6 -0
- coord/dashboard/webapp/src/main.tsx +46 -0
- coord/dashboard/webapp/src/realtime/RealtimeProvider.tsx +112 -0
- coord/dashboard/webapp/src/realtime/__tests__/RealtimeProvider.test.tsx +189 -0
- coord/dashboard/webapp/src/realtime/__tests__/connection.test.ts +255 -0
- coord/dashboard/webapp/src/realtime/connection.ts +227 -0
- coord/dashboard/webapp/src/realtime/events.ts +100 -0
- coord/dashboard/webapp/src/routes/__tests__/paths.test.ts +92 -0
- coord/dashboard/webapp/src/routes/paths.ts +92 -0
- coord/dashboard/webapp/src/shell/ActivityRail.tsx +335 -0
- coord/dashboard/webapp/src/shell/AppShell.tsx +276 -0
- coord/dashboard/webapp/src/shell/ComingSoon.tsx +33 -0
- coord/dashboard/webapp/src/shell/EmptyDetail.tsx +26 -0
- coord/dashboard/webapp/src/shell/RouteNotFound.tsx +33 -0
- coord/dashboard/webapp/src/shell/ShellLayout.tsx +147 -0
- coord/dashboard/webapp/src/shell/StatusBar.tsx +46 -0
- coord/dashboard/webapp/src/shell/__tests__/ShellLayout.test.tsx +520 -0
- coord/dashboard/webapp/src/shell/__tests__/shellState.test.ts +95 -0
- coord/dashboard/webapp/src/shell/__tests__/stubViewport.ts +40 -0
- coord/dashboard/webapp/src/shell/breakpoints.ts +87 -0
- coord/dashboard/webapp/src/shell/railItems.ts +105 -0
- coord/dashboard/webapp/src/shell/shellState.ts +174 -0
- coord/dashboard/webapp/src/shell/useRegionFocus.ts +95 -0
- coord/dashboard/webapp/src/test-setup.ts +41 -0
- coord/dashboard/webapp/src/vite-env.d.ts +2 -0
- coord/dashboard/webapp/tailwind.config.js +140 -0
- coord/dashboard/webapp/tsconfig.json +25 -0
- coord/dashboard/webapp/tsconfig.node.json +11 -0
- coord/dashboard/webapp/vite.config.ts +71 -0
- coord/db.py +1076 -0
- coord/dead_end.py +332 -0
- coord/deploy/README.md +33 -0
- coord/deploy/coord-agent.service +89 -0
- coord/deploy/coord-db-backup.service +60 -0
- coord/deploy/coord-db-backup.sh +74 -0
- coord/deploy/coord-db-backup.timer +18 -0
- coord/deploy/coord-drive-queue.service +117 -0
- coord/deploy/coord-drive-queue.timer +39 -0
- coord/deploy/coord-notify.service +48 -0
- coord/deploy/coord-notify.timer +24 -0
- coord/deploy/coord-release-propagate.service +83 -0
- coord/deploy/coord-release-propagate.timer +38 -0
- coord/deploy/coord-release-window.service +119 -0
- coord/deploy/coord-release-window.timer +36 -0
- coord/deploy/coord-serve.service +82 -0
- coord/deploy/coord-web-dist-build.service +43 -0
- coord/deploy/coord-web-dist-build.timer +36 -0
- coord/deploy/coord-web.service +125 -0
- coord/deploy_manifest.py +80 -0
- coord/deploy_units.py +384 -0
- coord/deps.py +115 -0
- coord/diagnose.py +1623 -0
- coord/dispatch.py +1009 -0
- coord/dist_name.py +123 -0
- coord/drive.py +3101 -0
- coord/drive_queue.py +2298 -0
- coord/drive_state.py +870 -0
- coord/events.py +381 -0
- coord/failure_class.py +914 -0
- coord/filelock.py +168 -0
- coord/fleet_config_health.py +300 -0
- coord/freshness.py +206 -0
- coord/gate_a.py +469 -0
- coord/gate_b.py +411 -0
- coord/gate_snapshot.py +385 -0
- coord/gates.py +582 -0
- coord/github_ops.py +1954 -0
- coord/goal.py +125 -0
- coord/graph_health.py +348 -0
- coord/health/__init__.py +69 -0
- coord/health/aggregate.py +129 -0
- coord/health/checks/__init__.py +13 -0
- coord/health/checks/agent_install.py +280 -0
- coord/health/checks/cargo_targets.py +171 -0
- coord/health/checks/claude_binary.py +65 -0
- coord/health/checks/deploy_lane_facts.py +458 -0
- coord/health/checks/disk.py +99 -0
- coord/health/checks/fleet_board.py +89 -0
- coord/health/checks/fleet_deploy_lanes.py +469 -0
- coord/health/checks/fleet_phantom.py +69 -0
- coord/health/checks/fleet_unit_drift.py +151 -0
- coord/health/checks/graph.py +192 -0
- coord/health/checks/plan_usage.py +88 -0
- coord/health/checks/repo_state.py +161 -0
- coord/health/checks/spawned_coord.py +465 -0
- coord/health/checks/timer_active.py +254 -0
- coord/health/checks/toolchain.py +547 -0
- coord/health/checks/unit_drift.py +648 -0
- coord/health/checks/unit_enablement.py +171 -0
- coord/health/checks/worktrees.py +96 -0
- coord/health/cli.py +121 -0
- coord/health/context.py +106 -0
- coord/health/fleet_snapshot.py +477 -0
- coord/health/models.py +250 -0
- coord/health/pypi.py +231 -0
- coord/health/registry.py +240 -0
- coord/health/render.py +82 -0
- coord/health/units.py +60 -0
- coord/hooks.py +106 -0
- coord/housekeeping.py +204 -0
- coord/interactive.py +4286 -0
- coord/issue_store.py +1496 -0
- coord/liveness_auditor.py +293 -0
- coord/machine_pause.py +755 -0
- coord/merge_queue.py +4681 -0
- coord/milestone_chat.py +600 -0
- coord/milestone_dispatch.py +943 -0
- coord/milestone_gate.py +709 -0
- coord/milestone_order.py +840 -0
- coord/mock_author.py +334 -0
- coord/models.py +891 -0
- coord/network.py +269 -0
- coord/new_issue_chat.py +229 -0
- coord/notify.py +3226 -0
- coord/openapi.py +404 -0
- coord/overlap_fence.py +133 -0
- coord/parentage.py +200 -0
- coord/parentage_github.py +58 -0
- coord/pipeline.py +481 -0
- coord/plan_parser.py +266 -0
- coord/plans.py +543 -0
- coord/platform_paths.py +43 -0
- coord/pr_body_lint.py +67 -0
- coord/prereqs.py +533 -0
- coord/progress.py +425 -0
- coord/providers/__init__.py +683 -0
- coord/providers/base.py +218 -0
- coord/providers/claude.py +284 -0
- coord/providers/claude_pty.py +610 -0
- coord/providers/opencode.py +896 -0
- coord/reconcile.py +2233 -0
- coord/refine_chat.py +485 -0
- coord/release_cordon.py +525 -0
- coord/release_propagate.py +1176 -0
- coord/release_verify.py +777 -0
- coord/release_window.py +322 -0
- coord/reports.py +1643 -0
- coord/revalidate.py +1101 -0
- coord/review.py +3317 -0
- coord/scorecard.py +484 -0
- coord/serve_app.py +7192 -0
- coord/skills/update-issue/SKILL.md +93 -0
- coord/smoke.py +1030 -0
- coord/split_work.py +210 -0
- coord/stage_projection.py +650 -0
- coord/state.py +5720 -0
- coord/test_author.py +1064 -0
- coord/test_chat.py +352 -0
- coord/test_orchestrator.py +494 -0
- coord/test_report.py +178 -0
- coord/tui_release.py +271 -0
- coord/usage.py +753 -0
- coord/usage_limits.py +358 -0
- coord/usage_rollup.py +709 -0
- coord/worker_events.py +954 -0
|
@@ -0,0 +1,1203 @@
|
|
|
1
|
+
"""``coord drive-queue`` — the queue CLI and the tick processor (#1754, DQ-2).
|
|
2
|
+
|
|
3
|
+
The thin I/O shell around :mod:`coord.drive_queue`. Everything that *decides*
|
|
4
|
+
lives there (``plan_tick``); everything that *touches the world* lives here:
|
|
5
|
+
the flock, the board fetch, the DQ-1 state accessors, the ``coord drive
|
|
6
|
+
--tmux`` subprocess, the escalation write. Same split, for the same reason, as
|
|
7
|
+
``coord/drive.py`` (pure ``decide``) and ``coord/commands/drive.py`` (thin
|
|
8
|
+
Click wrapper).
|
|
9
|
+
|
|
10
|
+
WHY A SEPARATE COMMAND GROUP. ``drive`` itself spends its argument positions
|
|
11
|
+
on ``REPO ISSUE``, which is why its ``--tmux`` companions are flat
|
|
12
|
+
(``drive-sessions``/``drive-attach``/``drive-stop``). The queue has a real
|
|
13
|
+
verb set of its own (add/list/remove/move/status/tick), so it gets a group —
|
|
14
|
+
``coord drive-queue <verb>`` — rather than six more hyphenated top-level
|
|
15
|
+
commands.
|
|
16
|
+
|
|
17
|
+
TWO POSTURES WORTH KEEPING WHEN EDITING THIS FILE:
|
|
18
|
+
|
|
19
|
+
* **Fail closed.** An unreadable board aborts the tick without launching
|
|
20
|
+
anything. A transient GitHub/daemon error must never read as "nothing is
|
|
21
|
+
running" — that reads as free capacity and stacks drives on live work.
|
|
22
|
+
* **Launch out of process.** ``coord drive --tmux`` is a subprocess, never an
|
|
23
|
+
inline ``Driver.run()``. A drive runs 60–90 minutes; an inline one under a
|
|
24
|
+
``Type=oneshot`` timer would hold the unit for hours, and the tick would
|
|
25
|
+
stop being a tick. ``--tmux`` already waits for a live session writing its
|
|
26
|
+
run log before exiting 0 (#1606), so a non-zero exit here is a genuinely
|
|
27
|
+
failed attempt, not an unknown.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json as _json
|
|
33
|
+
import socket
|
|
34
|
+
import subprocess
|
|
35
|
+
import time
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any, Mapping
|
|
38
|
+
|
|
39
|
+
import click
|
|
40
|
+
|
|
41
|
+
from coord.commands._common import _CONFIG_OPTION
|
|
42
|
+
from coord.drive_queue import (
|
|
43
|
+
DEFAULT_MAX_ATTEMPTS,
|
|
44
|
+
DEFAULT_MAX_PARALLEL_PER_REPO,
|
|
45
|
+
HOLD_RELEASED,
|
|
46
|
+
QUEUE_ALERT_ISSUE,
|
|
47
|
+
QUEUE_ALERT_REPO,
|
|
48
|
+
QUEUE_ALERT_STAGE,
|
|
49
|
+
RESUME_PROBE_TIMEOUT_SECONDS,
|
|
50
|
+
STATE_BLOCKED,
|
|
51
|
+
STATE_PARKED,
|
|
52
|
+
STATE_RUNNING,
|
|
53
|
+
STATE_WAITING,
|
|
54
|
+
BoardView,
|
|
55
|
+
ProbeResult,
|
|
56
|
+
QueueEntry,
|
|
57
|
+
QueueError,
|
|
58
|
+
TickPlan,
|
|
59
|
+
build_board_view,
|
|
60
|
+
entries_from_rows,
|
|
61
|
+
entry_key,
|
|
62
|
+
fired_holds,
|
|
63
|
+
parse_after_spec,
|
|
64
|
+
parse_key,
|
|
65
|
+
pending_probe_targets,
|
|
66
|
+
plan_tick,
|
|
67
|
+
render_plan,
|
|
68
|
+
validate_enqueue,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Wall-clock ceiling for the `coord drive --tmux` launch subprocess. The
|
|
72
|
+
# launch itself only blocks for #1606's liveness verification (16 × 0.5s) plus
|
|
73
|
+
# interpreter startup; this is a backstop against a wedged tmux server, not a
|
|
74
|
+
# budget.
|
|
75
|
+
_LAUNCH_TIMEOUT_SECONDS = 120.0
|
|
76
|
+
|
|
77
|
+
# Counts are rendered in pipeline order, not alphabetically, so
|
|
78
|
+
# `coord drive-queue status` reads as "1 running · 1 waiting". `parked`
|
|
79
|
+
# (#1891) sits between `waiting` and `blocked` — closer to "nothing wrong"
|
|
80
|
+
# than to "needs a human", but distinct from both, which is the entire point
|
|
81
|
+
# of the state: a held queue must not look like an idle one, see
|
|
82
|
+
# `coord.drive_queue.STATE_PARKED`'s docstring.
|
|
83
|
+
_STATE_ORDER = (
|
|
84
|
+
STATE_RUNNING, STATE_WAITING, STATE_PARKED, STATE_BLOCKED, "done", "failed",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
_GROUP_HELP = """The operator-declared `coord drive` work queue (#1750).
|
|
89
|
+
|
|
90
|
+
`coord drive` drives ONE issue; nothing decided what to drive next, so an
|
|
91
|
+
overnight batch was a bash loop that (twice) launched on top of live work.
|
|
92
|
+
This is the durable, board-backed replacement: declare the order once, then
|
|
93
|
+
let `tick` launch at most one drive per run, first-eligible-wins, never past
|
|
94
|
+
the concurrency ceiling.
|
|
95
|
+
|
|
96
|
+
Run `tick` from a systemd timer (DQ-4) or by hand, on any machine that can
|
|
97
|
+
reach the board daemon — the board itself is fleet-global. Liveness of a
|
|
98
|
+
RUNNING entry is not: it is always a local `tmux` read, so a tick only ever
|
|
99
|
+
confirms a session it launched itself. A tick run on a different machine than
|
|
100
|
+
the one that launched an entry reads that entry as UNKNOWN, not dead, and
|
|
101
|
+
leaves it alone rather than reaping a healthy drive out from under another
|
|
102
|
+
host (#1870).
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@click.group("drive-queue", help=_GROUP_HELP)
|
|
107
|
+
def drive_queue_group() -> None:
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
# ── add ──────────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@drive_queue_group.command("add")
|
|
115
|
+
@click.argument("repo")
|
|
116
|
+
@click.argument("issue", type=int)
|
|
117
|
+
@click.option("--machine", default="", help="Pin the drive to one machine (default: let `coord drive` route it).")
|
|
118
|
+
@click.option(
|
|
119
|
+
"--after",
|
|
120
|
+
"after_specs",
|
|
121
|
+
multiple=True,
|
|
122
|
+
default=(),
|
|
123
|
+
help=(
|
|
124
|
+
"Pre-req issues that must land first. `N` or `REPO#N`, comma-separated, "
|
|
125
|
+
"repeatable. Bare numbers resolve against REPO."
|
|
126
|
+
),
|
|
127
|
+
)
|
|
128
|
+
@click.option(
|
|
129
|
+
"--position",
|
|
130
|
+
type=int,
|
|
131
|
+
default=None,
|
|
132
|
+
help="Insert at this 0-based slot instead of appending at the tail.",
|
|
133
|
+
)
|
|
134
|
+
@click.option(
|
|
135
|
+
"--hold-after",
|
|
136
|
+
is_flag=True,
|
|
137
|
+
default=False,
|
|
138
|
+
help=(
|
|
139
|
+
"Deploy gate: when this entry completes, hold the queue — launch "
|
|
140
|
+
"NOTHING until a human deploys and runs `drive-queue resume` (or "
|
|
141
|
+
"`--resume-when` starts passing). `merged` is not `live`."
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
@click.option(
|
|
145
|
+
"--hold-reason",
|
|
146
|
+
default="",
|
|
147
|
+
help="What the operator must do while the gate is held. Shown in the alert.",
|
|
148
|
+
)
|
|
149
|
+
@click.option(
|
|
150
|
+
"--resume-when",
|
|
151
|
+
default="",
|
|
152
|
+
help=(
|
|
153
|
+
"Optional shell probe re-run each tick while the gate is held; exit 0 "
|
|
154
|
+
f"auto-releases it. Killed at {RESUME_PROBE_TIMEOUT_SECONDS:.0f}s and "
|
|
155
|
+
"treated as a failure. Requires --hold-after."
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
@_CONFIG_OPTION
|
|
159
|
+
def drive_queue_add(
|
|
160
|
+
repo: str,
|
|
161
|
+
issue: int,
|
|
162
|
+
machine: str,
|
|
163
|
+
after_specs: tuple[str, ...],
|
|
164
|
+
position: int | None,
|
|
165
|
+
hold_after: bool,
|
|
166
|
+
hold_reason: str,
|
|
167
|
+
resume_when: str,
|
|
168
|
+
config_path: Path,
|
|
169
|
+
) -> None:
|
|
170
|
+
"""Queue REPO ISSUE for `coord drive`, or update it if already queued.
|
|
171
|
+
|
|
172
|
+
Validation happens BEFORE the write, the same posture `coord milestone
|
|
173
|
+
write-order` takes for `## Work order`: a self-edge or a dependency cycle
|
|
174
|
+
exits non-zero and leaves the queue exactly as it was.
|
|
175
|
+
"""
|
|
176
|
+
from coord.state import enqueue_drive_queue, list_drive_queue # noqa: PLC0415
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
after = parse_after_spec(after_specs, repo)
|
|
180
|
+
validate_config_repo(config_path, repo)
|
|
181
|
+
validate_hold_flags(hold_after, hold_reason, resume_when)
|
|
182
|
+
validate_enqueue(entries_from_rows(list_drive_queue()), repo, issue, after)
|
|
183
|
+
except QueueError as exc:
|
|
184
|
+
raise click.ClickException(str(exc)) from None
|
|
185
|
+
|
|
186
|
+
enqueue_drive_queue(
|
|
187
|
+
repo,
|
|
188
|
+
issue,
|
|
189
|
+
machine=machine or None,
|
|
190
|
+
after=after,
|
|
191
|
+
position=position,
|
|
192
|
+
hold_after=hold_after,
|
|
193
|
+
hold_reason=hold_reason,
|
|
194
|
+
resume_when=resume_when,
|
|
195
|
+
)
|
|
196
|
+
suffix = f" after {', '.join(after)}" if after else ""
|
|
197
|
+
pinned = f" on {machine}" if machine else ""
|
|
198
|
+
gate = ""
|
|
199
|
+
if hold_after:
|
|
200
|
+
gate = " · holds the queue when done"
|
|
201
|
+
if resume_when:
|
|
202
|
+
gate += f" (auto-resume when `{resume_when}` passes)"
|
|
203
|
+
click.echo(f"queued {entry_key(repo, issue)}{pinned}{suffix}{gate}")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def validate_hold_flags(hold_after: bool, hold_reason: str, resume_when: str) -> None:
|
|
207
|
+
"""Refuse gate detail without a gate (#1757).
|
|
208
|
+
|
|
209
|
+
`--resume-when` / `--hold-reason` on an entry with no `--hold-after` would
|
|
210
|
+
be stored and then never read — a silent no-op on the ONE flag whose whole
|
|
211
|
+
job is to stop the queue. An operator who mistyped that has no signal at
|
|
212
|
+
all that overnight sequencing will now blow straight through the deploy
|
|
213
|
+
step, so this is a usage error, not a warning.
|
|
214
|
+
"""
|
|
215
|
+
if hold_after:
|
|
216
|
+
return
|
|
217
|
+
offenders = [
|
|
218
|
+
flag
|
|
219
|
+
for flag, value in (("--resume-when", resume_when), ("--hold-reason", hold_reason))
|
|
220
|
+
if value
|
|
221
|
+
]
|
|
222
|
+
if offenders:
|
|
223
|
+
raise QueueError(
|
|
224
|
+
f"{' and '.join(offenders)} require --hold-after "
|
|
225
|
+
"(without it there is no gate to resume or explain)"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def validate_config_repo(config_path: Path, repo: str) -> None:
|
|
230
|
+
"""Refuse a repo coordinator.yml has never heard of.
|
|
231
|
+
|
|
232
|
+
`coord drive <repo> <issue>` would fail at preflight anyway
|
|
233
|
+
(``DriveStateError: repo ... is not in coordinator.yml``) — catching it at
|
|
234
|
+
``add`` time turns a mysterious tick-time block into an immediate typo
|
|
235
|
+
report. Fail-OPEN on a config that won't load at all: a thin client whose
|
|
236
|
+
config cache is momentarily unreadable must still be able to queue work.
|
|
237
|
+
"""
|
|
238
|
+
try:
|
|
239
|
+
from coord.commands._common import _load_config # noqa: PLC0415
|
|
240
|
+
|
|
241
|
+
config = _load_config(config_path)
|
|
242
|
+
except Exception: # noqa: BLE001 — see the fail-open note above
|
|
243
|
+
return
|
|
244
|
+
if config.repo(repo) is None:
|
|
245
|
+
known = ", ".join(sorted(r.name for r in config.repos)) or "(none)"
|
|
246
|
+
raise QueueError(f"repo {repo!r} is not in coordinator.yml (known: {known})")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# ── list ─────────────────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@drive_queue_group.command("list")
|
|
253
|
+
@click.option("--repo", "repo", default=None, help="Restrict to one repo (default: every repo).")
|
|
254
|
+
@click.option("--json", "output_json", is_flag=True, default=False, help="Emit the raw rows as JSON.")
|
|
255
|
+
@_CONFIG_OPTION
|
|
256
|
+
def drive_queue_list(repo: str | None, output_json: bool, config_path: Path) -> None:
|
|
257
|
+
"""Show the queue in run order."""
|
|
258
|
+
from coord.state import list_drive_queue # noqa: PLC0415
|
|
259
|
+
|
|
260
|
+
rows = list_drive_queue(repo)
|
|
261
|
+
if output_json:
|
|
262
|
+
click.echo(_json.dumps(rows))
|
|
263
|
+
return
|
|
264
|
+
if not rows:
|
|
265
|
+
click.echo("(drive queue is empty)")
|
|
266
|
+
return
|
|
267
|
+
for entry in entries_from_rows(rows):
|
|
268
|
+
bits = [f"{entry.position:>2} {entry.key:<28} {entry.state}"]
|
|
269
|
+
if entry.machine:
|
|
270
|
+
bits.append(f"machine={entry.machine}")
|
|
271
|
+
if entry.after:
|
|
272
|
+
bits.append(f"after={','.join(entry.after)}")
|
|
273
|
+
if entry.attempts:
|
|
274
|
+
bits.append(f"attempts={entry.attempts}")
|
|
275
|
+
if entry.deferrals:
|
|
276
|
+
bits.append(f"deferrals={entry.deferrals}")
|
|
277
|
+
if entry.hold_after:
|
|
278
|
+
bits.append(f"hold={entry.hold_state or 'armed'}")
|
|
279
|
+
click.echo(" ".join(bits))
|
|
280
|
+
if entry.last_reason:
|
|
281
|
+
click.echo(f" last: {entry.last_reason}")
|
|
282
|
+
for line in _hold_lines(entry):
|
|
283
|
+
click.echo(line)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _hold_lines(entry: QueueEntry) -> list[str]:
|
|
287
|
+
"""The gate's rendering for `list` / `status`, or `[]` when there is none.
|
|
288
|
+
|
|
289
|
+
Both verbs render through this one function so `list` and `status` can
|
|
290
|
+
never disagree about whether the queue is held — the failure mode that
|
|
291
|
+
makes an operator stop trusting either.
|
|
292
|
+
"""
|
|
293
|
+
if not entry.hold_after:
|
|
294
|
+
return []
|
|
295
|
+
lines = [f" hold-after: {entry.gate_reason}"]
|
|
296
|
+
if entry.resume_when:
|
|
297
|
+
probe = f" resume-when: {entry.resume_when}"
|
|
298
|
+
if entry.hold_probes:
|
|
299
|
+
probe += f" (failed {entry.hold_probes}×)"
|
|
300
|
+
lines.append(probe)
|
|
301
|
+
return lines
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ── remove / move ────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@drive_queue_group.command("remove")
|
|
308
|
+
@click.argument("repo")
|
|
309
|
+
@click.argument("issue", type=int)
|
|
310
|
+
@_CONFIG_OPTION
|
|
311
|
+
def drive_queue_remove(repo: str, issue: int, config_path: Path) -> None:
|
|
312
|
+
"""Drop REPO ISSUE from the queue (positions are renumbered dense)."""
|
|
313
|
+
from coord.state import dequeue_drive_queue # noqa: PLC0415
|
|
314
|
+
|
|
315
|
+
removed = dequeue_drive_queue(repo, issue)
|
|
316
|
+
if not removed:
|
|
317
|
+
raise click.ClickException(f"{entry_key(repo, issue)} is not in the drive queue")
|
|
318
|
+
click.echo(f"removed {entry_key(repo, issue)} from the drive queue")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@drive_queue_group.command("move")
|
|
322
|
+
@click.argument("repo")
|
|
323
|
+
@click.argument("issue", type=int)
|
|
324
|
+
@click.option("--to", "to_position", type=int, required=True, help="New 0-based position (clamped into range).")
|
|
325
|
+
@_CONFIG_OPTION
|
|
326
|
+
def drive_queue_move(repo: str, issue: int, to_position: int, config_path: Path) -> None:
|
|
327
|
+
"""Move REPO ISSUE to a new position in the queue."""
|
|
328
|
+
from coord.state import move_drive_queue_entry # noqa: PLC0415
|
|
329
|
+
|
|
330
|
+
moved = move_drive_queue_entry(repo, issue, to_position)
|
|
331
|
+
if not moved:
|
|
332
|
+
raise click.ClickException(f"{entry_key(repo, issue)} is not in the drive queue")
|
|
333
|
+
click.echo(f"moved {entry_key(repo, issue)} to position {to_position}")
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
# ── status ───────────────────────────────────────────────────────────────────
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _counts(rows: list[Mapping[str, Any]]) -> dict[str, int]:
|
|
340
|
+
"""State histogram for a queue read."""
|
|
341
|
+
counts: dict[str, int] = {}
|
|
342
|
+
for entry in entries_from_rows(rows):
|
|
343
|
+
counts[entry.state] = counts.get(entry.state, 0) + 1
|
|
344
|
+
return counts
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _queue_alert() -> dict | None:
|
|
348
|
+
"""The current queue-level alert record, if a tick raised one.
|
|
349
|
+
|
|
350
|
+
Read back through the same synthetic escalation key the tick writes — see
|
|
351
|
+
``QUEUE_ALERT_REPO`` in coord/drive_queue.py for why that seam and not a
|
|
352
|
+
synthetic ``drive_queue`` row.
|
|
353
|
+
"""
|
|
354
|
+
from coord.state import get_drive_escalation # noqa: PLC0415
|
|
355
|
+
|
|
356
|
+
return get_drive_escalation(QUEUE_ALERT_REPO, QUEUE_ALERT_ISSUE)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
@drive_queue_group.command("status")
|
|
360
|
+
@click.option("--json", "output_json", is_flag=True, default=False, help="Emit counts + alert as JSON.")
|
|
361
|
+
@_CONFIG_OPTION
|
|
362
|
+
def drive_queue_status(output_json: bool, config_path: Path) -> None:
|
|
363
|
+
"""Queue counts by state, plus the current queue-level alert."""
|
|
364
|
+
from coord.state import list_drive_queue # noqa: PLC0415
|
|
365
|
+
|
|
366
|
+
rows = list_drive_queue()
|
|
367
|
+
counts = _counts(rows)
|
|
368
|
+
alert = _queue_alert()
|
|
369
|
+
held = fired_holds(entries_from_rows(rows))
|
|
370
|
+
|
|
371
|
+
if output_json:
|
|
372
|
+
click.echo(
|
|
373
|
+
_json.dumps(
|
|
374
|
+
{
|
|
375
|
+
"total": len(rows),
|
|
376
|
+
"counts": counts,
|
|
377
|
+
"alert": alert,
|
|
378
|
+
# #1757: typed, so a client (or a test) reads the gate
|
|
379
|
+
# without parsing the rendered sentence back out.
|
|
380
|
+
"held": [
|
|
381
|
+
{
|
|
382
|
+
"key": e.key,
|
|
383
|
+
"reason": e.gate_reason,
|
|
384
|
+
"resume_when": e.resume_when,
|
|
385
|
+
"probes": e.hold_probes,
|
|
386
|
+
}
|
|
387
|
+
for e in held
|
|
388
|
+
],
|
|
389
|
+
}
|
|
390
|
+
)
|
|
391
|
+
)
|
|
392
|
+
return
|
|
393
|
+
|
|
394
|
+
if not rows:
|
|
395
|
+
click.echo("drive queue: empty")
|
|
396
|
+
else:
|
|
397
|
+
ordered = [s for s in _STATE_ORDER if counts.get(s)]
|
|
398
|
+
ordered += sorted(s for s in counts if s not in _STATE_ORDER)
|
|
399
|
+
click.echo(
|
|
400
|
+
"drive queue: " + " · ".join(f"{counts[s]} {s}" for s in ordered)
|
|
401
|
+
)
|
|
402
|
+
# The gate goes ABOVE the alert: "HELD" is the state, the alert is the
|
|
403
|
+
# note about it, and an operator scanning the first line must not have to
|
|
404
|
+
# read three more to learn the queue has stopped.
|
|
405
|
+
for entry in held:
|
|
406
|
+
click.echo(f"HELD — {entry.gate_reason}")
|
|
407
|
+
for line in _hold_lines(entry):
|
|
408
|
+
click.echo(line)
|
|
409
|
+
click.echo(" release with: coord drive-queue resume")
|
|
410
|
+
if alert is not None:
|
|
411
|
+
click.echo(f"alert: {alert.get('reason') or ''}")
|
|
412
|
+
for detail in (alert.get("gate_readings") or "").split(" | "):
|
|
413
|
+
if detail:
|
|
414
|
+
click.echo(f" {detail}")
|
|
415
|
+
else:
|
|
416
|
+
click.echo("alert: (none)")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
# ── resume (#1757) ───────────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@drive_queue_group.command("resume")
|
|
423
|
+
@click.argument("repo", required=False)
|
|
424
|
+
@click.argument("issue", type=int, required=False)
|
|
425
|
+
@_CONFIG_OPTION
|
|
426
|
+
def drive_queue_resume(repo: str | None, issue: int | None, config_path: Path) -> None:
|
|
427
|
+
"""Release a fired deploy gate so the next tick can launch again.
|
|
428
|
+
|
|
429
|
+
With no arguments this releases every held gate — in practice there is at
|
|
430
|
+
most one, because a held queue launches nothing and therefore cannot reach
|
|
431
|
+
a second one. Pass REPO ISSUE to name a specific entry.
|
|
432
|
+
|
|
433
|
+
The entry itself is NOT removed or re-run: the release is what unblocks
|
|
434
|
+
the queue, not the held entry leaving it, so `list` keeps its run history.
|
|
435
|
+
"""
|
|
436
|
+
from coord.state import list_drive_queue, update_drive_queue_entry # noqa: PLC0415
|
|
437
|
+
|
|
438
|
+
held = fired_holds(entries_from_rows(list_drive_queue()))
|
|
439
|
+
if repo is not None:
|
|
440
|
+
if issue is None:
|
|
441
|
+
raise click.ClickException("give both REPO and ISSUE, or neither")
|
|
442
|
+
wanted = entry_key(repo, issue)
|
|
443
|
+
held = [e for e in held if e.key == wanted]
|
|
444
|
+
if not held:
|
|
445
|
+
raise click.ClickException(
|
|
446
|
+
f"{wanted} has no fired deploy gate to release "
|
|
447
|
+
"(see `coord drive-queue status`)"
|
|
448
|
+
)
|
|
449
|
+
if not held:
|
|
450
|
+
# Exit non-zero: "resume" on a queue that was never held is an
|
|
451
|
+
# operator misreading the board, and a silent success would confirm
|
|
452
|
+
# the misreading.
|
|
453
|
+
raise click.ClickException("no deploy gate is currently held")
|
|
454
|
+
|
|
455
|
+
for entry in held:
|
|
456
|
+
update_drive_queue_entry(
|
|
457
|
+
entry.repo, entry.issue, hold_state=HOLD_RELEASED, hold_probes=0
|
|
458
|
+
)
|
|
459
|
+
click.echo(f"released the deploy gate on {entry.key}")
|
|
460
|
+
_clear_queue_alert()
|
|
461
|
+
click.echo("the next tick will launch the next eligible entry")
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _clear_queue_alert() -> None:
|
|
465
|
+
"""Drop the queue-level HELD alert once its gate is released.
|
|
466
|
+
|
|
467
|
+
Best-effort: the next tick overwrites (or re-raises) this record anyway,
|
|
468
|
+
but leaving a stale "QUEUE HELD" sitting in `status` between the release
|
|
469
|
+
and the next timer fire is exactly the kind of contradiction that trains
|
|
470
|
+
an operator to stop reading alerts.
|
|
471
|
+
"""
|
|
472
|
+
try:
|
|
473
|
+
from coord.state import dismiss_drive_escalation # noqa: PLC0415
|
|
474
|
+
|
|
475
|
+
dismiss_drive_escalation(QUEUE_ALERT_REPO, QUEUE_ALERT_ISSUE)
|
|
476
|
+
except Exception: # noqa: BLE001 — cosmetic; never fail a release on it
|
|
477
|
+
pass
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# ── tick ─────────────────────────────────────────────────────────────────────
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _local_issue_rows() -> list[dict]:
|
|
484
|
+
"""``issues`` rows straight from the local DB (daemon-host path only).
|
|
485
|
+
|
|
486
|
+
``BoardFetcher`` builds the standalone payload with
|
|
487
|
+
``coord.client.serialize_board``, which ships assignment rows and
|
|
488
|
+
``round_number`` and nothing else — no ``issues`` key at all. The daemon's
|
|
489
|
+
own ``GET /board`` (``coord.dao.board_projection``) DOES carry one, so a
|
|
490
|
+
thin client already sees issue open/closed state and the daemon host would
|
|
491
|
+
not. Without this top-up a pre-req that is simply open-and-undispatched
|
|
492
|
+
would look "unknown to the board" on the exact machine a systemd timer runs
|
|
493
|
+
the tick on, and get blocked instead of deferred.
|
|
494
|
+
|
|
495
|
+
Fail-soft: an unreadable/absent table degrades to ``[]``, which puts the
|
|
496
|
+
daemon host back on the assignment-only signals rather than aborting.
|
|
497
|
+
"""
|
|
498
|
+
from coord.db import get_connection # noqa: PLC0415
|
|
499
|
+
|
|
500
|
+
try:
|
|
501
|
+
rows = get_connection().execute(
|
|
502
|
+
"SELECT repo_name, number, state FROM issues"
|
|
503
|
+
).fetchall()
|
|
504
|
+
except Exception: # noqa: BLE001 — see the fail-soft note above
|
|
505
|
+
return []
|
|
506
|
+
return [dict(r) for r in rows]
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _local_merge_queue_rows() -> list[dict]:
|
|
510
|
+
"""``merge_queue`` rows straight from the local DB (daemon-host path only).
|
|
511
|
+
|
|
512
|
+
Same gap as :func:`_local_issue_rows`, one table over: the standalone
|
|
513
|
+
``coord.client.serialize_board`` payload ships assignment rows and
|
|
514
|
+
``round_number`` only — no ``merge_queue`` (and no ``merge_plan``, which
|
|
515
|
+
is computed, not stored) — so on the daemon host itself (no
|
|
516
|
+
``board_service`` configured, the tick reads the local DB directly)
|
|
517
|
+
:func:`build_board_view`'s ``merge_ci_pending`` fact (#1891) would never
|
|
518
|
+
see a checks-still-pending entry at all. ``merge_plan`` is deliberately
|
|
519
|
+
NOT backfilled here — it needs a live ``config``/``ci_store`` to compute,
|
|
520
|
+
and ``build_board_view`` already falls back to this raw table's ``error``
|
|
521
|
+
column when the plan section is absent, exactly the fallback
|
|
522
|
+
``drive_state._merge_entry`` uses for the same gap.
|
|
523
|
+
|
|
524
|
+
Fail-soft: an unreadable table degrades to ``[]``, same posture as
|
|
525
|
+
:func:`_local_issue_rows`.
|
|
526
|
+
"""
|
|
527
|
+
from coord.db import get_connection # noqa: PLC0415
|
|
528
|
+
|
|
529
|
+
try:
|
|
530
|
+
rows = get_connection().execute(
|
|
531
|
+
"SELECT repo_name, issue_number, error FROM merge_queue"
|
|
532
|
+
).fetchall()
|
|
533
|
+
except Exception: # noqa: BLE001 — see the fail-soft note above
|
|
534
|
+
return []
|
|
535
|
+
return [dict(r) for r in rows]
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _local_host_id() -> str:
|
|
539
|
+
"""This machine's identity for #1870's launch-host / reconcile matching.
|
|
540
|
+
|
|
541
|
+
Same normalisation every other host-locality check in this codebase uses
|
|
542
|
+
(``coord/commands/sessions.py``, ``coord/commands/_common.py``,
|
|
543
|
+
``coord.interactive._get_local_short_hostname``): the short hostname,
|
|
544
|
+
lowercased, domain suffix dropped — so a machine addressed as
|
|
545
|
+
``dellserver`` in one config and ``dellserver.local`` by DNS still
|
|
546
|
+
compares equal to itself.
|
|
547
|
+
"""
|
|
548
|
+
return socket.gethostname().split(".")[0].lower()
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _fetch_cordons() -> dict[str, str]:
|
|
552
|
+
"""``{machine: "cordoned: draining for v0.5.31"}`` (#2101).
|
|
553
|
+
|
|
554
|
+
Daemon-aware (`coord.machine_pause.cordons()` routes to `GET /pause` on a
|
|
555
|
+
thin client) and fail-SOFT: an unreadable cordon store degrades to "no
|
|
556
|
+
cordons", the same posture `paused_set()` takes on every dispatch decision
|
|
557
|
+
in this codebase. That is the right trade here even though the board fetch
|
|
558
|
+
above fails CLOSED: a missed cordon costs one drive launched into a host
|
|
559
|
+
about to roll (which `coord agent update` then refuses, leaving the entry
|
|
560
|
+
to retry), whereas failing the whole tick closed on a cordon read would
|
|
561
|
+
stop the queue on a network blip — the outage this issue exists to end,
|
|
562
|
+
reintroduced by its own fix.
|
|
563
|
+
"""
|
|
564
|
+
from coord.machine_pause import cordons as fetch_cordons # noqa: PLC0415
|
|
565
|
+
|
|
566
|
+
try:
|
|
567
|
+
return {name: record.describe() for name, record in fetch_cordons().items()}
|
|
568
|
+
except Exception as exc: # noqa: BLE001 — see docstring
|
|
569
|
+
click.echo(f"warning: could not read release cordons ({exc}) — "
|
|
570
|
+
"treating the fleet as uncordoned", err=True)
|
|
571
|
+
return {}
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _fetch_board_view() -> BoardView:
|
|
575
|
+
"""Board + live drive sessions, typed.
|
|
576
|
+
|
|
577
|
+
Raises whatever the fetch raised — the caller turns that into a fail-closed
|
|
578
|
+
abort. ``list_drive_sessions()`` is deliberately NOT allowed to fail the
|
|
579
|
+
tick: it returns ``[]`` when tmux is unavailable, and the board's
|
|
580
|
+
``active_work`` signal still holds the capacity line in that case.
|
|
581
|
+
"""
|
|
582
|
+
from coord.board_service import resolve as resolve_board_service # noqa: PLC0415
|
|
583
|
+
from coord.drive import list_drive_sessions # noqa: PLC0415
|
|
584
|
+
from coord.drive_state import BoardFetcher # noqa: PLC0415
|
|
585
|
+
|
|
586
|
+
payload = BoardFetcher().fetch()
|
|
587
|
+
if not isinstance(payload, dict):
|
|
588
|
+
raise ValueError(f"board payload is not an object: {type(payload).__name__}")
|
|
589
|
+
# Standalone shape top-up, gated per-key rather than on "issues" alone
|
|
590
|
+
# (#2040: BoardFetcher's own standalone path now supplies "issues" —
|
|
591
|
+
# see coord.drive_state.BoardFetcher._fetch_local — so a single combined
|
|
592
|
+
# gate on that key would silently stop topping up "merge_queue" too,
|
|
593
|
+
# regressing #1891's merge_ci_pending signal on the daemon host). Each
|
|
594
|
+
# top-up is independently gated on board_service being unset so a thin
|
|
595
|
+
# client never reads its own local DB — both keys are always present in
|
|
596
|
+
# the daemon's HTTP projection, even when empty.
|
|
597
|
+
if resolve_board_service() is None:
|
|
598
|
+
top_up: dict = {}
|
|
599
|
+
if "issues" not in payload:
|
|
600
|
+
top_up["issues"] = _local_issue_rows()
|
|
601
|
+
if "merge_queue" not in payload:
|
|
602
|
+
# #1891: same top-up, one table over — see _local_merge_queue_rows.
|
|
603
|
+
top_up["merge_queue"] = _local_merge_queue_rows()
|
|
604
|
+
if top_up:
|
|
605
|
+
payload = {**payload, **top_up}
|
|
606
|
+
return build_board_view(payload, list_drive_sessions())
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _fetch_exit_reasons(
|
|
610
|
+
entries: list,
|
|
611
|
+
) -> tuple[dict[str, str], dict[str, bool], dict[str, bool]]:
|
|
612
|
+
"""The drive's own ``drive_exited`` summary — and whether it was
|
|
613
|
+
PERMANENT (a pre-dispatch refusal, or a dead end) — for every ``running``
|
|
614
|
+
entry THIS launch, keyed by entry key (#1845/#1844/#2019).
|
|
615
|
+
|
|
616
|
+
``coord.drive.Driver.run`` already writes the true reason a run stopped —
|
|
617
|
+
a deliberate refusal narrated in full, not just an exit code — to the
|
|
618
|
+
audit trail before it returns. Nothing downstream used to read it, so
|
|
619
|
+
`_reconcile_running`'s "no session, no active work, nothing landed" death
|
|
620
|
+
classifier (which also matches a clean, deliberate exit) always
|
|
621
|
+
overwrote it with a synthesised "drive session died" reason. This is the
|
|
622
|
+
one DB read the shell does to close that gap; `plan_tick`/
|
|
623
|
+
`_reconcile_running` stay pure and just consume the result as data (like
|
|
624
|
+
*probes*).
|
|
625
|
+
|
|
626
|
+
Returns ``(reasons, refused, dead_end)``. *reasons* is the summary text,
|
|
627
|
+
same as before #1844. *refused* is ``True`` for a key whose recorded
|
|
628
|
+
``details.exit_code`` equals ``coord.drive.EXIT_DISPATCH_REFUSED`` — a
|
|
629
|
+
DETERMINISTIC pre-dispatch guard refusal (#1138's oracle-readiness gate,
|
|
630
|
+
#1314's epic-target gate, or any other check `coord assign`/`coord
|
|
631
|
+
approve-plan`/`coord fix` raises a plain ``ValueError`` for) rather than
|
|
632
|
+
a transient crash. `_reconcile_running` uses this second mapping to skip
|
|
633
|
+
straight to `blocked` without spending an attempt — see its ``refused``
|
|
634
|
+
branch.
|
|
635
|
+
|
|
636
|
+
*dead_end* (#2019) is the same signal for ``coord.drive.EXIT_DEAD_END``:
|
|
637
|
+
the drive's dead-end predicate found the row terminal and unactionable
|
|
638
|
+
and exited on the first poll rather than counting ``no state change``
|
|
639
|
+
against an event that cannot happen. Same disposition as *refused*
|
|
640
|
+
(`blocked`, no attempt spent), separate mapping so the blocked entry's
|
|
641
|
+
``last_reason`` names the real cause instead of claiming a pre-dispatch
|
|
642
|
+
guard refused something.
|
|
643
|
+
|
|
644
|
+
Scoped with ``since=entry.launched_at`` so a stale reason (or refusal)
|
|
645
|
+
from a PRIOR attempt on the same (repo, issue) — the entry's key doesn't
|
|
646
|
+
change across a retry — is never replayed as if it explained the run
|
|
647
|
+
that just ended. An entry with no `launched_at` (a row from before this
|
|
648
|
+
launch was stamped) is skipped; the caller's fallback wording covers it.
|
|
649
|
+
|
|
650
|
+
Fail-soft per entry: an unreadable audit table degrades to "no reason
|
|
651
|
+
known for this entry", never aborts the tick — same posture as
|
|
652
|
+
:func:`_local_issue_rows`.
|
|
653
|
+
"""
|
|
654
|
+
from coord.audit import query_audit_log # noqa: PLC0415
|
|
655
|
+
from coord.drive import EXIT_DEAD_END, EXIT_DISPATCH_REFUSED # noqa: PLC0415
|
|
656
|
+
|
|
657
|
+
reasons: dict[str, str] = {}
|
|
658
|
+
refused: dict[str, bool] = {}
|
|
659
|
+
dead_end: dict[str, bool] = {}
|
|
660
|
+
for e in entries:
|
|
661
|
+
if e.state != STATE_RUNNING or e.launched_at is None:
|
|
662
|
+
continue
|
|
663
|
+
try:
|
|
664
|
+
result = query_audit_log(
|
|
665
|
+
event_type="drive_exited",
|
|
666
|
+
repo=e.repo,
|
|
667
|
+
issue=e.issue,
|
|
668
|
+
since=e.launched_at,
|
|
669
|
+
limit=1,
|
|
670
|
+
)
|
|
671
|
+
except Exception: # noqa: BLE001 — see the fail-soft note above
|
|
672
|
+
continue
|
|
673
|
+
rows = result.get("entries") or []
|
|
674
|
+
if not rows:
|
|
675
|
+
continue
|
|
676
|
+
row = rows[0]
|
|
677
|
+
summary = row.get("summary")
|
|
678
|
+
if summary:
|
|
679
|
+
reasons[e.key] = str(summary)
|
|
680
|
+
details = row.get("details") or {}
|
|
681
|
+
if details.get("exit_code") == EXIT_DISPATCH_REFUSED:
|
|
682
|
+
refused[e.key] = True
|
|
683
|
+
elif details.get("exit_code") == EXIT_DEAD_END:
|
|
684
|
+
dead_end[e.key] = True
|
|
685
|
+
return reasons, refused, dead_end
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _fetch_gate_a_pending(entries: list) -> dict[str, bool]:
|
|
689
|
+
"""``{entry_key: still_waiting_on_a_human}`` for every entry parked on a
|
|
690
|
+
missing Gate-A sign-off (#2063).
|
|
691
|
+
|
|
692
|
+
The ``(repo_name, milestone_number)`` pair comes out of the park
|
|
693
|
+
reason's own marker (:func:`coord.gate_a.parse_park_marker`), so this is
|
|
694
|
+
a pure board read — no ``gh`` call, and no work at all on a tick where
|
|
695
|
+
nothing is parked that way.
|
|
696
|
+
|
|
697
|
+
Fails **closed**: an entry whose verdict can't be read stays parked. The
|
|
698
|
+
cost of being wrong in that direction is a parked row an operator can
|
|
699
|
+
see and release; the cost of being wrong the other way is dispatching
|
|
700
|
+
work against a contract nobody approved, which is the whole issue.
|
|
701
|
+
"""
|
|
702
|
+
from coord.gate_a import approval_fingerprint, parse_park_marker # noqa: PLC0415
|
|
703
|
+
|
|
704
|
+
pending: dict[str, bool] = {}
|
|
705
|
+
#: ``None`` marks "the board could not be read for this milestone".
|
|
706
|
+
cache: dict[tuple[str, int], str | None] = {}
|
|
707
|
+
for e in entries:
|
|
708
|
+
if e.state != STATE_PARKED:
|
|
709
|
+
continue
|
|
710
|
+
parsed = parse_park_marker(getattr(e, "last_reason", "") or "")
|
|
711
|
+
if parsed is None:
|
|
712
|
+
continue
|
|
713
|
+
repo_name, milestone_number, parked_fingerprint = parsed
|
|
714
|
+
key = (repo_name, milestone_number)
|
|
715
|
+
if key not in cache:
|
|
716
|
+
try:
|
|
717
|
+
from coord.state import get_gate_a_approval # noqa: PLC0415
|
|
718
|
+
|
|
719
|
+
raw = get_gate_a_approval(
|
|
720
|
+
repo_name=repo_name, milestone_number=milestone_number
|
|
721
|
+
)
|
|
722
|
+
cache[key] = approval_fingerprint(raw)
|
|
723
|
+
except Exception: # noqa: BLE001 — fail closed, stay parked
|
|
724
|
+
cache[key] = None
|
|
725
|
+
if cache[key] is None:
|
|
726
|
+
pending[e.key] = True
|
|
727
|
+
continue
|
|
728
|
+
# Resume when the stored verdict has CHANGED since the park — not
|
|
729
|
+
# merely when one exists. A `--changes` verdict refuses too, so
|
|
730
|
+
# "exists" would resume, relaunch, refuse and re-park every tick
|
|
731
|
+
# forever; "changed" bounds it at one relaunch per operator action.
|
|
732
|
+
# The guard itself re-derives the real answer (including the
|
|
733
|
+
# contract-SHA freshness check) on that relaunch, so this predicate
|
|
734
|
+
# deliberately does not duplicate it.
|
|
735
|
+
pending[e.key] = cache[key] == parked_fingerprint
|
|
736
|
+
return pending
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _launch_argv(entry: QueueEntry, config_path: Path | None) -> list[str]:
|
|
740
|
+
"""The ``coord drive --tmux`` argv for *entry*.
|
|
741
|
+
|
|
742
|
+
#1809: this is the argv the tick actually spawns as a subprocess (below,
|
|
743
|
+
in the caller). When ``coord_argv()``'s PATH-less fallback was silently
|
|
744
|
+
broken (no ``__main__`` guard on ``coord/cli.py``), that subprocess
|
|
745
|
+
exited 0 having imported the module and run nothing — BEFORE ever
|
|
746
|
+
reaching ``launch_drive_in_tmux``'s #1606 alive/log-growth verification.
|
|
747
|
+
From the tick's side that is indistinguishable from a real launch that
|
|
748
|
+
passed verification: both are "subprocess exited 0". That fully explains
|
|
749
|
+
a launch reported as a success banner while its tmux session had already
|
|
750
|
+
died — no separate bug in this module's returncode handling or in
|
|
751
|
+
``launch_drive_in_tmux``'s growth check (both were re-verified against
|
|
752
|
+
the #1809 investigation and are correct: a non-zero exit is never
|
|
753
|
+
counted as running — see ``test_a_failed_launch_is_a_consumed_attempt_
|
|
754
|
+
not_a_running_entry`` — and the growth check does register an
|
|
755
|
+
absent-before-launch log file that then gets written to, per
|
|
756
|
+
``test_session_dies_immediately_raises_instead_of_reporting_success``).
|
|
757
|
+
Fixing the ``__main__`` guard closes this path too, since it is the same
|
|
758
|
+
fallback the driver's own ``coord assign`` calls go through.
|
|
759
|
+
"""
|
|
760
|
+
from coord.drive import coord_argv # noqa: PLC0415
|
|
761
|
+
|
|
762
|
+
argv = coord_argv() + ["drive", entry.repo, str(entry.issue), "--tmux"]
|
|
763
|
+
if entry.machine:
|
|
764
|
+
argv += ["--machine", entry.machine]
|
|
765
|
+
if config_path:
|
|
766
|
+
argv += ["--config", str(config_path)]
|
|
767
|
+
return argv
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _run_resume_probe(entry: QueueEntry) -> ProbeResult:
|
|
771
|
+
"""Run one entry's ``--resume-when`` probe with a hard timeout.
|
|
772
|
+
|
|
773
|
+
TRUST BOUNDARY — READ THIS BEFORE TOUCHING THE FUNCTION.
|
|
774
|
+
``resume_when`` is a SHELL command, executed by the tick, as the tick's
|
|
775
|
+
user, on the daemon host. That is deliberate, and it is acceptable for
|
|
776
|
+
exactly one reason: the string is **operator-authored and
|
|
777
|
+
operator-scoped**, the same trust level as the ``ExecStart=`` line of the
|
|
778
|
+
systemd timer unit that invokes this tick in the first place. It is:
|
|
779
|
+
|
|
780
|
+
* NOT sent to a worker and never executed on a worker machine;
|
|
781
|
+
* NOT derived from an issue body, a PR, a review comment, a plan, or any
|
|
782
|
+
other model output;
|
|
783
|
+
* NOT reachable by anything an agent writes — ``coord drive-queue add`` is
|
|
784
|
+
the only writer of this column, and DQ-1's update whitelist
|
|
785
|
+
(``coord.state._DRIVE_QUEUE_UPDATABLE``) deliberately excludes it, so
|
|
786
|
+
not even the tick can rewrite its own probe.
|
|
787
|
+
|
|
788
|
+
If any of those three ever stops being true, this is remote code execution
|
|
789
|
+
on the daemon host and the feature must be redesigned — not patched.
|
|
790
|
+
|
|
791
|
+
Fails CLOSED: a non-zero exit, a timeout, or a command that could not be
|
|
792
|
+
spawned at all all keep the gate held. A gate that releases because its
|
|
793
|
+
probe crashed is a gate that never existed.
|
|
794
|
+
"""
|
|
795
|
+
import os # noqa: PLC0415
|
|
796
|
+
import signal # noqa: PLC0415
|
|
797
|
+
|
|
798
|
+
try:
|
|
799
|
+
proc = subprocess.Popen( # noqa: S602 — operator-authored; see the trust note
|
|
800
|
+
entry.resume_when,
|
|
801
|
+
shell=True,
|
|
802
|
+
stdout=subprocess.PIPE,
|
|
803
|
+
stderr=subprocess.STDOUT,
|
|
804
|
+
text=True,
|
|
805
|
+
# Its own process GROUP, so a timeout can kill the whole tree.
|
|
806
|
+
# `sh -c 'a | b'` leaves children that outlive the shell; killing
|
|
807
|
+
# only the shell would leave a wedged probe holding the pipe and
|
|
808
|
+
# the tick blocked in communicate() — a tick that stops ticking is
|
|
809
|
+
# indistinguishable from a queue with nothing to do (#1616).
|
|
810
|
+
start_new_session=True,
|
|
811
|
+
)
|
|
812
|
+
except (OSError, ValueError) as exc:
|
|
813
|
+
return ProbeResult(entry.key, False, f"could not run the probe: {exc}")
|
|
814
|
+
|
|
815
|
+
try:
|
|
816
|
+
out, _ = proc.communicate(timeout=RESUME_PROBE_TIMEOUT_SECONDS)
|
|
817
|
+
except subprocess.TimeoutExpired:
|
|
818
|
+
try:
|
|
819
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
820
|
+
except OSError:
|
|
821
|
+
proc.kill()
|
|
822
|
+
try:
|
|
823
|
+
proc.communicate(timeout=2.0)
|
|
824
|
+
except (subprocess.SubprocessError, OSError):
|
|
825
|
+
pass
|
|
826
|
+
return ProbeResult(
|
|
827
|
+
entry.key,
|
|
828
|
+
False,
|
|
829
|
+
f"timed out after {RESUME_PROBE_TIMEOUT_SECONDS:.0f}s (killed)",
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
tail = (out or "").strip().splitlines()
|
|
833
|
+
detail = f"exit {proc.returncode}"
|
|
834
|
+
if proc.returncode != 0 and tail:
|
|
835
|
+
detail += f": {tail[-1][:160]}"
|
|
836
|
+
return ProbeResult(entry.key, proc.returncode == 0, detail)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _apply_writes(plan: TickPlan) -> None:
|
|
840
|
+
from coord.state import update_drive_queue_entry # noqa: PLC0415
|
|
841
|
+
|
|
842
|
+
for key, updates in plan.writes():
|
|
843
|
+
parsed = parse_key(key)
|
|
844
|
+
if parsed is None:
|
|
845
|
+
continue
|
|
846
|
+
update_drive_queue_entry(parsed[0], parsed[1], **updates)
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _escalate(repo: str, issue: int, *, reason: str, gates: str, command: str) -> None:
|
|
850
|
+
from coord.state import record_drive_escalation # noqa: PLC0415
|
|
851
|
+
|
|
852
|
+
record_drive_escalation(
|
|
853
|
+
repo,
|
|
854
|
+
issue,
|
|
855
|
+
stage=QUEUE_ALERT_STAGE,
|
|
856
|
+
reason=reason,
|
|
857
|
+
gate_readings=gates,
|
|
858
|
+
proposed_command=command,
|
|
859
|
+
)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _requeue_command(entry: QueueEntry | None, key: str) -> str:
|
|
863
|
+
"""The one-key fix for a blocked entry: drop it and re-add it clean.
|
|
864
|
+
|
|
865
|
+
There is deliberately no ``coord drive-queue reset`` — DQ-1's update
|
|
866
|
+
whitelist keeps run state out of the operator's write surface, so
|
|
867
|
+
remove+add IS the reset (a fresh row is ``waiting`` with ``attempts=0``
|
|
868
|
+
and no ``after``). Re-adding without the bad ``--after`` is also the fix
|
|
869
|
+
for an unsatisfiable pre-req.
|
|
870
|
+
"""
|
|
871
|
+
parsed = parse_key(key)
|
|
872
|
+
if parsed is None:
|
|
873
|
+
return "coord drive-queue list"
|
|
874
|
+
repo, issue = parsed
|
|
875
|
+
tail = f" --machine {entry.machine}" if entry is not None and entry.machine else ""
|
|
876
|
+
return (
|
|
877
|
+
f"coord drive-queue remove {repo} {issue} && "
|
|
878
|
+
f"coord drive-queue add {repo} {issue}{tail}"
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
@drive_queue_group.command("tick")
|
|
883
|
+
@click.option(
|
|
884
|
+
"--max-parallel",
|
|
885
|
+
type=int,
|
|
886
|
+
default=1,
|
|
887
|
+
show_default=True,
|
|
888
|
+
help=(
|
|
889
|
+
"Concurrency ceiling. Capacity is counted from BOARD state, not from a "
|
|
890
|
+
"session count, so a drive whose observer hit its deadline (#1660) "
|
|
891
|
+
"still occupies a slot."
|
|
892
|
+
),
|
|
893
|
+
)
|
|
894
|
+
@click.option(
|
|
895
|
+
"--max-parallel-per-repo",
|
|
896
|
+
type=int,
|
|
897
|
+
default=DEFAULT_MAX_PARALLEL_PER_REPO,
|
|
898
|
+
show_default=True,
|
|
899
|
+
help=(
|
|
900
|
+
"Per-repo concurrency ceiling, applied after --max-parallel (#1972). "
|
|
901
|
+
"An entry whose repo is already at it DEFERS — position unchanged, no "
|
|
902
|
+
"attempt spent — so the walk lands on the first entry from a repo with "
|
|
903
|
+
"headroom: per-repo serialisation, cross-repo parallelism. 0 disables "
|
|
904
|
+
"it, restoring one global counter."
|
|
905
|
+
),
|
|
906
|
+
)
|
|
907
|
+
@click.option(
|
|
908
|
+
"--dry-run",
|
|
909
|
+
is_flag=True,
|
|
910
|
+
default=False,
|
|
911
|
+
help="Print the resolved plan and mutate nothing.",
|
|
912
|
+
)
|
|
913
|
+
@click.option(
|
|
914
|
+
"--reconcile-only",
|
|
915
|
+
is_flag=True,
|
|
916
|
+
default=False,
|
|
917
|
+
help=(
|
|
918
|
+
"Update the queue's view of reality and launch nothing (#2110). "
|
|
919
|
+
"Every `running` entry is still checked against the board "
|
|
920
|
+
"(done/blocked/parked/retry, exactly as a normal tick would), but no "
|
|
921
|
+
"new `coord drive` is ever started this run — equivalent to "
|
|
922
|
+
"`--max-parallel 0`. This is the missing primitive for the "
|
|
923
|
+
"stop-the-timer-to-roll-the-fleet sequence: with the timer stopped, "
|
|
924
|
+
"nothing reconciles a finished drive's `running` row, and that stale "
|
|
925
|
+
"row alone can pin `coord release propagate` indefinitely. Run this "
|
|
926
|
+
"once (by hand, timer still stopped) to drain it before propagating, "
|
|
927
|
+
"then restart the timer."
|
|
928
|
+
),
|
|
929
|
+
)
|
|
930
|
+
@_CONFIG_OPTION
|
|
931
|
+
def drive_queue_tick(
|
|
932
|
+
max_parallel: int,
|
|
933
|
+
max_parallel_per_repo: int,
|
|
934
|
+
dry_run: bool,
|
|
935
|
+
reconcile_only: bool,
|
|
936
|
+
config_path: Path,
|
|
937
|
+
) -> None:
|
|
938
|
+
"""Drain one step of the queue: reconcile, then launch at most one drive.
|
|
939
|
+
|
|
940
|
+
Safe to run on any interval and from any machine that can reach the board
|
|
941
|
+
daemon. A tick already in progress makes this a quiet no-op (exit 0) — a
|
|
942
|
+
slow tick must never stack, and two ticks seconds apart are safe: a drive
|
|
943
|
+
launched inside the startup grace window reconciles as `starting`
|
|
944
|
+
(occupying a slot, attempts untouched) rather than as a death (#1794).
|
|
945
|
+
Two ticks on DIFFERENT machines are also safe: liveness is always a local
|
|
946
|
+
`tmux` read, so a tick reconciles only the entries it itself launched —
|
|
947
|
+
one launched elsewhere reads as `unknown`, occupying its slot but never
|
|
948
|
+
retried or relaunched, rather than being declared dead out from under the
|
|
949
|
+
host actually running it (#1870).
|
|
950
|
+
|
|
951
|
+
Capacity has two ceilings (#1972): the global `--max-parallel`, then
|
|
952
|
+
`--max-parallel-per-repo` (default 1). Entries whose repo is already at the
|
|
953
|
+
per-repo ceiling defer, so a tick with a claude-coordinator drive running
|
|
954
|
+
skips the 38 queued claude-coordinator entries behind it and launches the
|
|
955
|
+
quadraui one — per-repo serialisation, cross-repo parallelism. `--dry-run`
|
|
956
|
+
prints the per-repo breakdown so "why didn't item 2 go?" is answerable from
|
|
957
|
+
the output alone.
|
|
958
|
+
|
|
959
|
+
`--max-parallel 0` (or `--reconcile-only`, the readable spelling of the
|
|
960
|
+
same thing — #2110) reconciles every `running` entry against the board and
|
|
961
|
+
then stops: no capacity walk, no deferrals, no queue-level alert, no
|
|
962
|
+
launch. Reconciliation (`plan_tick` step 1/1b: a finished entry moves to
|
|
963
|
+
`done`, a permanently-refused one to `blocked`, a CI-pending one parks) is
|
|
964
|
+
unconditional and runs regardless of capacity, which is what makes this
|
|
965
|
+
safe to run with the periodic timer stopped — see
|
|
966
|
+
`docs/AGENT_OPERATIONS.md`'s propagation section for why that combination
|
|
967
|
+
used to deadlock.
|
|
968
|
+
"""
|
|
969
|
+
from coord.filelock import FileLock, LockBusy, drive_queue_lock_path # noqa: PLC0415
|
|
970
|
+
from coord.state import list_drive_queue, update_drive_queue_entry # noqa: PLC0415
|
|
971
|
+
|
|
972
|
+
if max_parallel < 0:
|
|
973
|
+
raise click.ClickException(
|
|
974
|
+
"--max-parallel must be at least 0 (0 = reconcile-only, launch "
|
|
975
|
+
"nothing this run — see --reconcile-only)"
|
|
976
|
+
)
|
|
977
|
+
if max_parallel_per_repo < 0:
|
|
978
|
+
raise click.ClickException(
|
|
979
|
+
"--max-parallel-per-repo must be 0 (no per-repo ceiling) or more"
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
# #2110: `--reconcile-only` and `--max-parallel 0` are the same request —
|
|
983
|
+
# one flag is a mnemonic for the other rather than a second code path, so
|
|
984
|
+
# there is exactly one way this behaves, not two that could drift apart.
|
|
985
|
+
reconcile_only = reconcile_only or max_parallel == 0
|
|
986
|
+
effective_capacity = 0 if reconcile_only else max_parallel
|
|
987
|
+
|
|
988
|
+
lock = FileLock(drive_queue_lock_path())
|
|
989
|
+
try:
|
|
990
|
+
lock.acquire(timeout=0.0)
|
|
991
|
+
except LockBusy:
|
|
992
|
+
# Quiet by design: this is the normal outcome when a timer fires while
|
|
993
|
+
# the previous tick is still verifying a launch. Noise here would
|
|
994
|
+
# train the operator to ignore the log.
|
|
995
|
+
click.echo("another drive-queue tick is running — skipping")
|
|
996
|
+
return
|
|
997
|
+
except OSError as exc:
|
|
998
|
+
raise click.ClickException(f"could not take the drive-queue lock: {exc}") from None
|
|
999
|
+
|
|
1000
|
+
try:
|
|
1001
|
+
# FAIL CLOSED. An unreadable board is not "nothing is running"; it is
|
|
1002
|
+
# "we do not know what is running", and launching on that assumption is
|
|
1003
|
+
# how a sequential batch becomes concurrent on the fleet.
|
|
1004
|
+
try:
|
|
1005
|
+
board = _fetch_board_view()
|
|
1006
|
+
except Exception as exc: # noqa: BLE001 — every fetch failure is fatal here
|
|
1007
|
+
raise click.ClickException(
|
|
1008
|
+
f"could not read the board — aborting without launching anything: {exc}"
|
|
1009
|
+
) from None
|
|
1010
|
+
|
|
1011
|
+
entries = entries_from_rows(list_drive_queue())
|
|
1012
|
+
|
|
1013
|
+
# #1757: run each held gate's `--resume-when` BEFORE deciding
|
|
1014
|
+
# anything, and hand the results to `plan_tick` as data so the
|
|
1015
|
+
# decision half stays pure. Deliberately skipped under `--dry-run`:
|
|
1016
|
+
# the probe is an arbitrary operator-authored shell command and
|
|
1017
|
+
# `--dry-run` promises to touch nothing. The consequence — a dry run
|
|
1018
|
+
# reports the gate as still held even if the deploy just landed — is
|
|
1019
|
+
# stated in the output rather than left for the operator to discover.
|
|
1020
|
+
probes: dict[str, ProbeResult] = {}
|
|
1021
|
+
pending = pending_probe_targets(entries)
|
|
1022
|
+
if pending and dry_run:
|
|
1023
|
+
click.echo(
|
|
1024
|
+
f"(--dry-run: not running {len(pending)} --resume-when probe(s); "
|
|
1025
|
+
"a held gate below may already be releasable)"
|
|
1026
|
+
)
|
|
1027
|
+
elif not dry_run:
|
|
1028
|
+
for target in pending:
|
|
1029
|
+
probes[target.key] = _run_resume_probe(target)
|
|
1030
|
+
|
|
1031
|
+
# #1845/#1844: the drive's own `drive_exited` summary — and, when it
|
|
1032
|
+
# was a PERMANENT pre-dispatch guard refusal, that fact too — for
|
|
1033
|
+
# each `running` entry, when one was recorded for THIS launch. Read
|
|
1034
|
+
# here (the shell) and handed to `plan_tick` as data, same as
|
|
1035
|
+
# `probes`, so a "no session, no active work, nothing landed"
|
|
1036
|
+
# reconcile can report the drive's real reason instead of a
|
|
1037
|
+
# synthesised "drive session died" for an exit that was actually
|
|
1038
|
+
# deliberate, and — when it was a deterministic refusal — block
|
|
1039
|
+
# immediately instead of spending an attempt on a guaranteed-to-fail
|
|
1040
|
+
# retry. #2019 adds a second permanent cause with the same handling:
|
|
1041
|
+
# a drive that exited on its own dead-end predicate.
|
|
1042
|
+
exit_reasons, exit_refused, exit_dead_end = _fetch_exit_reasons(entries)
|
|
1043
|
+
|
|
1044
|
+
# #2063: for every entry parked on a missing Gate-A human sign-off,
|
|
1045
|
+
# re-read the recorded verdict so `plan_tick` can un-park it the tick
|
|
1046
|
+
# after the operator approves. Board-only (the (repo, milestone) pair
|
|
1047
|
+
# is embedded in the park reason's marker), so this costs nothing per
|
|
1048
|
+
# tick when no entry is parked that way — which is the common case.
|
|
1049
|
+
gate_a_pending = _fetch_gate_a_pending(entries)
|
|
1050
|
+
|
|
1051
|
+
# #2101: release cordons. THIS is the hole the issue names — the
|
|
1052
|
+
# queue's launcher had zero pause awareness (`coord/drive.py` checks
|
|
1053
|
+
# pause only when routing a *worker*), so a cordoned host kept getting
|
|
1054
|
+
# drive sessions launched on it and the fleet could never drain into
|
|
1055
|
+
# a rollable state.
|
|
1056
|
+
cordons = _fetch_cordons()
|
|
1057
|
+
|
|
1058
|
+
# #1794: the clock is the shell's to read, never `coord.drive_queue`'s.
|
|
1059
|
+
# It powers the startup grace window on both sides of the tick — a
|
|
1060
|
+
# drive launched seconds ago is `starting`, not dead, and cannot be
|
|
1061
|
+
# relaunched — so a tick that fires immediately after another (which
|
|
1062
|
+
# `docs/DRIVE_QUEUE.md` §2's install sequence reliably produces) sees
|
|
1063
|
+
# a running entry rather than a phantom death.
|
|
1064
|
+
#
|
|
1065
|
+
# #1870: same posture for the machine's own identity. Liveness is a
|
|
1066
|
+
# LOCAL tmux read; without `local_host` a tick on host B would read a
|
|
1067
|
+
# healthy drive launched on host A as dead the instant it fell out of
|
|
1068
|
+
# #1794's grace window, and reap it.
|
|
1069
|
+
plan = plan_tick(
|
|
1070
|
+
entries,
|
|
1071
|
+
board,
|
|
1072
|
+
effective_capacity,
|
|
1073
|
+
max_parallel_per_repo=max_parallel_per_repo,
|
|
1074
|
+
probes=probes,
|
|
1075
|
+
now=time.time(),
|
|
1076
|
+
local_host=_local_host_id(),
|
|
1077
|
+
exit_reasons=exit_reasons,
|
|
1078
|
+
exit_refused=exit_refused,
|
|
1079
|
+
exit_dead_end=exit_dead_end,
|
|
1080
|
+
gate_a_pending=gate_a_pending,
|
|
1081
|
+
cordons=cordons,
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
if reconcile_only:
|
|
1085
|
+
# #2110: capacity 0 already makes `plan_tick` return before its
|
|
1086
|
+
# capacity walk (no deferrals, no queue-level alert, no launch —
|
|
1087
|
+
# see its docstring's step 3), so `plan.launch` is guaranteed
|
|
1088
|
+
# `None` below without any extra branching here. This line exists
|
|
1089
|
+
# purely so the log reads as an intentional reconcile-only run
|
|
1090
|
+
# rather than a queue that mysteriously stopped launching.
|
|
1091
|
+
click.echo("(--reconcile-only: updating queue state, launching nothing)")
|
|
1092
|
+
|
|
1093
|
+
for line in render_plan(plan, dry_run=dry_run):
|
|
1094
|
+
click.echo(line)
|
|
1095
|
+
if dry_run:
|
|
1096
|
+
return
|
|
1097
|
+
|
|
1098
|
+
_apply_writes(plan)
|
|
1099
|
+
|
|
1100
|
+
by_key = {e.key: e for e in entries}
|
|
1101
|
+
for item in plan.blocked:
|
|
1102
|
+
parsed = parse_key(item.key)
|
|
1103
|
+
if parsed is None:
|
|
1104
|
+
continue
|
|
1105
|
+
entry = by_key.get(item.key)
|
|
1106
|
+
_escalate(
|
|
1107
|
+
parsed[0],
|
|
1108
|
+
parsed[1],
|
|
1109
|
+
reason=item.reason,
|
|
1110
|
+
gates=(
|
|
1111
|
+
f"queue_state=blocked | position="
|
|
1112
|
+
f"{entry.position if entry else '?'} | after="
|
|
1113
|
+
f"{','.join(entry.after) if entry and entry.after else '(none)'}"
|
|
1114
|
+
),
|
|
1115
|
+
command=_requeue_command(entry, item.key),
|
|
1116
|
+
)
|
|
1117
|
+
|
|
1118
|
+
if plan.alert is not None:
|
|
1119
|
+
_escalate(
|
|
1120
|
+
QUEUE_ALERT_REPO,
|
|
1121
|
+
QUEUE_ALERT_ISSUE,
|
|
1122
|
+
reason=plan.alert.reason,
|
|
1123
|
+
gates=" | ".join(plan.alert.details),
|
|
1124
|
+
command=plan.alert.command,
|
|
1125
|
+
)
|
|
1126
|
+
elif any(h.outcome == "released" for h in plan.holds):
|
|
1127
|
+
# A probe just auto-released the gate. Drop the "QUEUE HELD"
|
|
1128
|
+
# record in the same tick, or `status` keeps shouting HELD while
|
|
1129
|
+
# the queue is demonstrably running again — the contradiction that
|
|
1130
|
+
# teaches an operator to stop reading alerts.
|
|
1131
|
+
_clear_queue_alert()
|
|
1132
|
+
|
|
1133
|
+
target = plan.launch
|
|
1134
|
+
if target is None:
|
|
1135
|
+
return
|
|
1136
|
+
|
|
1137
|
+
argv = _launch_argv(target, config_path)
|
|
1138
|
+
try:
|
|
1139
|
+
result = subprocess.run( # noqa: S603 — argv built from coord_argv + typed row
|
|
1140
|
+
argv,
|
|
1141
|
+
capture_output=True,
|
|
1142
|
+
text=True,
|
|
1143
|
+
timeout=_LAUNCH_TIMEOUT_SECONDS,
|
|
1144
|
+
)
|
|
1145
|
+
returncode = result.returncode
|
|
1146
|
+
detail = (result.stderr or result.stdout or "").strip().splitlines()
|
|
1147
|
+
message = detail[-1] if detail else ""
|
|
1148
|
+
except (subprocess.SubprocessError, OSError) as exc:
|
|
1149
|
+
returncode, message = 1, str(exc)
|
|
1150
|
+
|
|
1151
|
+
if returncode == 0:
|
|
1152
|
+
from coord.drive import drive_session_name # noqa: PLC0415
|
|
1153
|
+
|
|
1154
|
+
session = drive_session_name(target.repo, target.issue)
|
|
1155
|
+
update_drive_queue_entry(
|
|
1156
|
+
target.repo,
|
|
1157
|
+
target.issue,
|
|
1158
|
+
state=STATE_RUNNING,
|
|
1159
|
+
session_name=session,
|
|
1160
|
+
launched_at=time.time(),
|
|
1161
|
+
last_reason="",
|
|
1162
|
+
# #1870: stamp THIS host as the launcher so a later tick —
|
|
1163
|
+
# possibly on a different machine — knows whose tmux to trust.
|
|
1164
|
+
launch_host=_local_host_id(),
|
|
1165
|
+
)
|
|
1166
|
+
click.echo(f"launched {target.key} in tmux session {session!r}")
|
|
1167
|
+
return
|
|
1168
|
+
|
|
1169
|
+
# #1606: `--tmux` only exits 0 once the session is live and writing its
|
|
1170
|
+
# run log, so a non-zero exit means nothing is running — record a
|
|
1171
|
+
# consumed attempt, never a running entry.
|
|
1172
|
+
attempts = target.attempts + 1
|
|
1173
|
+
reason = (
|
|
1174
|
+
f"launch failed (exit {returncode}): {message}"
|
|
1175
|
+
if message
|
|
1176
|
+
else f"launch failed (exit {returncode})"
|
|
1177
|
+
)
|
|
1178
|
+
if attempts < DEFAULT_MAX_ATTEMPTS:
|
|
1179
|
+
update_drive_queue_entry(
|
|
1180
|
+
target.repo,
|
|
1181
|
+
target.issue,
|
|
1182
|
+
state=STATE_WAITING,
|
|
1183
|
+
attempts=attempts,
|
|
1184
|
+
last_reason=reason,
|
|
1185
|
+
)
|
|
1186
|
+
else:
|
|
1187
|
+
update_drive_queue_entry(
|
|
1188
|
+
target.repo,
|
|
1189
|
+
target.issue,
|
|
1190
|
+
state=STATE_BLOCKED,
|
|
1191
|
+
attempts=attempts,
|
|
1192
|
+
last_reason=reason,
|
|
1193
|
+
)
|
|
1194
|
+
_escalate(
|
|
1195
|
+
target.repo,
|
|
1196
|
+
target.issue,
|
|
1197
|
+
reason=reason,
|
|
1198
|
+
gates=f"queue_state=blocked | attempts={attempts}",
|
|
1199
|
+
command=_requeue_command(target, target.key),
|
|
1200
|
+
)
|
|
1201
|
+
raise click.ClickException(reason)
|
|
1202
|
+
finally:
|
|
1203
|
+
lock.release()
|